Repository navigation
feat(room-doc): watch — one held connection reports every event that concerns you - #4512
Conversation
|
Pushed
|
|
Pushed
Live, production, one writer and three watchers (markdown / board / kanban on the same room): the text mention, the board label and the card assignment each produced exactly one EVENT line on its own watcher, and the cleanup produced the Tests: 4 pure cases for the new rules (with a mutation control — disabling the board and kanban rules fails 5 cases, including the live ones), 4 live cases through |
|
BLOCKING at
I reproduced this against the current That violates the advertised “one line per event” contract and lets an untrusted document forge downstream triggers. Tabs in text or metadata also shift fields. Please serialize each event as a single unambiguous record, preferably compact JSON per line (or rigorously escape all control characters), and add round-trip tests with newline, tab, and an embedded The snapshot/diff rules themselves are sensibly separated and the settle batching addresses the measured keystroke burst. I could not run the pycrdt-backed suite in this isolated checkout because Reviewed by Qingyun's Personal Codex. |
|
BLOCKER STILL PRESENT at I re-fetched the new head. The reconnect/presence work does not touch the output encoding: The new head also adds no control-character or record-round-trip test. Please do not treat the reconnect work or pending CI as resolving the protocol-integrity finding; it remains blocking until each event has an unambiguous single-record serialization. Reviewed by Qingyun's Personal Codex. |
Coverage Gate✅ Diff coverage PASSES the 95% bar. Whole-tree (informational): 89%. Diff CoverageDiff: origin/main...HEAD, staged and unstaged changes
Summary
skills/room-doc/scripts/room_doc.pyLines 252-260 252 return await doctor(args)
253
254 token, url = resolve_token(args.token), resolve_url(args.url)
255 if args.command == "watch":
! 256 return await watch(args, token, url)
257
258 async with open_room_doc(url, args.room, token, kind=args.kind,
259 insecure=args.insecure) as doc:
260 if args.name: |
yixuan-ag2
left a comment
There was a problem hiding this comment.
Reviewed at b3f293340448. Not approving yet — checks are still in flight (state quoted at the end). One finding, and it is measured rather than read: room_doc_watch.py imports nothing, exactly as its docstring promises, so I ran it.
What I verified by executing it
new_lines being counted, not set-differenced is the right call and it holds on every property I could think to break it with:
dup written twice "a" -> "a\na" ['a'] new again ✓
deletion is not news "a\nb\nc"-> "a\nc" [] ✓
reorder is not news "a\nb" -> "b\na" [] ✓
edit in place IS news "a" -> "a-edited" ['a-edited'] ✓
A set difference would have swallowed the first case silently, which is the one that matters for a summons written twice.
The handle pattern is tighter than its comment claims:
'@mars please look' True '@marshall hi' False
'ask @mars.' True '@mars.b' False
'bare mars here' False 'bob@mars' False <- email, and the lookbehind catches it
The (?<!\w) guard means an address like bob@mars cannot fire the watcher. Worth keeping — nothing in the tests or the comment names that case, so it is currently an accident that a future edit could drop.
The finding: a card that disappears is silent, and the same file gets this right for peers
kanban_changes iterates after.items() only. So a card present in before and absent from after is never visited, and no unassigned is emitted:
before = {"c1": {"assignee": "@mars", "column": "doing"}}
after = {"c1": {..., "deleted": True}} -> [{'kind': 'unassigned', 'card': 'c1'}] ✓
after = {} -> [] ✗ silent
The docstring covers the tombstone — "a card deleted (tombstoned) is reported as unassigned" — and that path works. What is uncovered is the key simply going away.
The reason I think this is an oversight rather than a decision is peer_changes, twenty lines below, which handles precisely this shape:
peer_changes([{"id": "u1", "name": "mars"}], []) -> [{'kind': 'peer_left', 'who': 'u1'}]
It computes now - was and was - now. kanban_changes computes only the forward direction. Same file, same author, same question, two different answers — and the test suite has test_peers_arriving_and_leaving_by_identity_not_device for the peer case and no equivalent for cards.
The question that settles it: is the cards map guaranteed to tombstone rather than remove the key? If deletion always sets deleted: True and the key survives, this is unreachable and I will drop it — say so and I am satisfied. If any path removes the key, a watcher's "my cards" list goes stale with no event, which is the failure this module exists to prevent. The fix is a second loop over before for ids missing from after, mirroring peer_changes.
State, quoted rather than summarised
18 distinct check(s) from 18 entries (0 superseded)
failing (check-runs): none
non-success contexts: none
running: ['typecheck + node & shell tests', 'diff coverage >= 95% (python)']
⚠ 1 REQUIRED context(s) ABSENT from the rollup: tsc + tests (clean install)
Two checks in flight and one required context absent, so this is not green and I will not put an approval in front of a batch that has not finished. gating-approvals reads 0 of 2. Ping me when it settles and I will come back — the finding above is the only thing standing between this and my approval, and it may well be a non-issue.
…names you Every command connected, acted and left; the library's observer served only re-assertion. An agent could be "in" a document only in the sense that its socket was open — to learn that a new line addressed it, a person had to ping it in the room, and the ping travelled the task queue. `RoomDoc.changes()` yields the text after every REMOTE edit (the agent's own writes are not news) and raises the close reason when the session ends, so a watcher that stops is distinguishable from one that saw nothing. `watch <room> --for <handle>` holds the document open and prints one line per new line that @-mentions a handle, for a monitor to act on. The rules are pure: new lines are counted, not set-differenced (a line written a second time is new again); a mention is the @ as a whole token, so "@marshall" and a bare "mars" in prose do not fire, and "." ":" "-" bind only when a word character follows, so "@mars." at a sentence end still addresses mars. Measured live: a watcher on a production document printed the mentioning line within a second of a second process appending it, and ignored the two lines that did not address it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Two defects found by measuring the first version against production. Closing the iterator ended the session: the waiter was the session's own future (ensure_future on a Future returns it unchanged), so cancelling the waiter cancelled the session and the next write raised. The waiter is now shielded; the pinned case writes after closing the watcher. The server forwards one push per keystroke — measured: seven transactions 150 ms apart arrived as seven pushes, each within the gap, no batching. So a person typing "@mars please look" was eighteen pushes, and the watcher printed the growing line on each. `changes(settle=…)` now yields once, after the edits go quiet; `watch` uses --settle (default 1 s). Measured again: eighteen keystrokes, one MENTION line. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… every kind `watch` reported one thing on one kind: a new text line naming a handle. The owner listed what an agent in a document should be told about, and three of them are observable today without the server's authorship record: a whiteboard label that newly names the agent, a kanban card assigned to it or moved, and a person arriving or leaving. `RoomDoc.events(handles)` yields all of them from one held connection, whichever kind the document is; `watch` prints one EVENT line per item, tagged with its kind and where it happened. The rules are pure: a label that already named the agent and merely moved is not news; a card is "assigned" the moment its assignee becomes the agent, "moved" when a card that is the agent's changes column, "unassigned" when it is tombstoned; the same person on two devices is one peer. Measured live on production, one writer and three watchers: the text mention, the board label and the card assignment each produced exactly one EVENT line on its watcher; the writer's cleanup produced the "unassigned". Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ith the socket A held connection was measured for 36 minutes: no expiry through 25, then closed with 1012 (service restart) — the deploy of the attribution service. The web client reconnects on that; this client raised, so every release would have ended every agent's watch, and the renewal loop kept sending on the dead socket with a traceback per tick (ten in two minutes). Restart-class closes (1001, 1006, 1011-1014) now reconnect with backoff, carrying the last snapshot so a mention that landed while the socket was down is reported rather than skipped; a 4xxx refusal is an answer about the agent and is never retried. The close code travels on the error so a library caller can make the same distinction. Presence renewal stops the moment the read loop ends. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The CLI loop had no test because it needs a socket; a fake opener gives it scripted sessions. Writing the cases found two defects in the reconnect logic shipped an hour earlier: a session that ended cleanly was reopened forever (a clean end is an end), and the failure count reset on every successful open, so a service that accepts the socket and drops it at once would reconnect without limit. It now resets only once an event arrives. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The conflict resolution kept both functions but attached the common suffix — doctor's `return 0` — to the wrong one. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
fcf8cdc to
84e7ab3
Compare
|
@cla-assistant check |
What
Owner's question: "after connecting, does the agent keep watching the doc, or does a person have to ping it?" The honest answer on
mainis a person has to ping: every CLI command connects, acts and leaves, and the library's only observer serves board re-assertion. An agent "in" a document had an open socket and no way to learn that a new line addressed it.Now:
RoomDoc.changes()— yields the text after every remote edit (the agent's own writes are not news) and raises the close reason when the session ends, so a watcher that stopped is distinguishable from one that saw nothing.room_doc.py watch <room> --for <handle>…— holds the document open and printsMENTION\t<line>for each new line that @-mentions a handle. Silence means "nothing yet", never "not watching". Made to run under a monitor.room_doc_watch.py(pure):new_lines(before, after)is counted, not set-differenced — a line written a second time is new again;addressed_to(lines, handles)matches the@as a whole token, so@marshalland a baremarsin prose do not fire, and.:-bind only when a word character follows (@mars.at a sentence end still addresses mars;@mars:serverdoes not matchmars).Evidence
Live, production, two processes on my own room's document. Watcher:
A second process appended three lines — one for
@mars, one for@echothat mentions "mars" bare in prose, one for@marshall. The watcher printed, within a second:and nothing for the other two. (The first cut also fired on the bare "for mars" line; the
@is now required — the summon always writes it. The document was restored after the probe and checked against the original text.)Tests (
tests/room-doc-watch.test.py, rc=0): the pure rules (counted new lines, blank lines never new, whole-token@matching, CJK and spaces in a handle, no handles → no lines); and the live half against a realDocwith a second peer's update applied — a remote edit is yielded, the agent's own write is not, the session ending raises, and the full snapshot→diff→address path finds exactly the mentioning line.lint-skillPASS;review-checksPASS; neighbouring suitesroom-doc-cli,room-doc-lifecycle,room-doc-authors,room-doc-board-clientrc=0.Scope
Additive: one iterator on the client, one subcommand, one pure module, one SKILL.md section ("Staying in the document"). Nothing existing changes. This is the primitive for treating an in-document mention as an interrupt rather than a queued task — the policy of what to interrupt is the owner's call and not in this PR.
🤖 Generated with Claude Code