Skip to content

feat(room-doc): an agent can read who wrote what - #4493

Merged
qingyun-wu merged 4 commits into
mainfrom
feat/room-doc-agent-reads-authors
Sep 20, 2026
Merged

qingyun-wu merged 4 commits into
mainfrom
feat/room-doc-agent-reads-authors

Conversation

@qingyun-wu

Copy link
Copy Markdown
Collaborator

Stacked on #4492 (fix/room-doc-board-reconcile) — same files. Merge #4492 first.

Why this half matters

The UI half of attribution shows a person colours. This is the half a person cannot use and an agent cannot do without: deciding whether to continue someone's paragraph or leave it alone.

"A human wrote this" and "another agent wrote this" call for different behaviour — the owner's words were that agents will want to weight input from humans, the owner, or a colleague differently. Until now an agent could read the text and nothing about where it came from.

doc.authors            -> {client id: {mxid, kind, owner_mxid}}
doc.wrote(client_id)   -> that row, or None when unknown or DISPUTED
room_doc.py --with-authors read '!room:server'

Read-only, deliberately

The map is the server's, written from the authenticated connection. A client writing to it would be claiming an identity rather than reporting one — so this class has no writer for it at all, and the test suite has nothing to test on that side because there is nothing there.

A disputed id answers None rather than picking a claimant: two accounts used that id, and choosing between them would invent the answer. doc.authors still exposes the dispute, so a caller can distinguish contested from unknown — which a bare None could not.

Output shape

In plain output the authors print above the text, because an agent decides whether to trust or edit the body by who produced it — underneath, it would be read after the decision was already made. In --json they appear only when asked, so nothing currently parsing this output changes shape.

Dependency

Needs ag2space-backend #1256 deployed before the map has anything in it. Until then the client returns {}, which reads as "no attribution" rather than an error — the degradation is honest, not a crash.

Evidence

all 11 room-doc suites          ok
discover-python-tests.sh        finds tests/room-doc-authors.test.py
ci-covers-every-python-test     OK
review-checks.sh --diff         PASS
mutant result
disputed id resolved to a claimant CAUGHT
int/str key mismatch CAUGHT
authors leak into JSON unrequested CAUGHT
authors printed below the text CAUGHT
unknown client id returns {} instead of None survived

The survivor is equivalent, not a defect: .get(k, {}) yields a falsy {}, which the next line (if not row) turns into None regardless. Recording it rather than quietly dropping it from the table.

— Mars

🤖 Generated with Claude Code

qingyun-wu and others added 3 commits September 20, 2026 01:21
Three defects, all found by asking what happens when more than one writer is on
one board -- the case the whiteboard exists for.

1. A concurrent write could silently undo an agent's work.

Yjs resolves two writers of one key by CLIENT ID, which knows nothing about
element versions, so the peer with the higher client id wins even holding an
OLDER version and the shape reverts for everyone. The web client answers this by
re-applying its scene after every remote change. This client had `reconcile()`
but it was caller-driven, so an agent that drew and waited was quietly beaten.

`put_elements` now arms an observer that re-asserts what this session wrote
whenever a REMOTE change lands on one of those keys. Ours becomes causally
later and the higher version wins everywhere; when theirs is genuinely newer it
writes nothing. Local writes carry an origin so the observer ignores its own.

2. `peers` was refused on a board.

Presence is a separate channel and belongs to no document kind, so refusing it
with "the board holds elements" was simply wrong. It is answered now, exactly as
for a text document.

3. Malformed `draw` JSON printed a traceback.

`json.loads` raised JSONDecodeError straight through, the only error path here
that did not produce a readable `room-doc: ...` line. It now refuses with the
parse error and an example, and a bare object is refused too -- one element
still goes in a list.

Evidence:

  all 9 room-doc suites        ok
  review-checks.sh --diff      PASS
  against production           peers -> [] ; malformed draw -> clean refusal

Mutation, 5 of 5 CAUGHT: observer never armed; origin guard dropped; re-assert
everything rather than the touched key; assertions not recorded; transaction
origin not set.

Two of those initially SURVIVED, and the tests were at fault rather than the
code: correctness rests on `is_newer`, which suppresses the redundant write
either way, so counting frames on the wire could not see a needless re-assert.
The tests now assert the observer's DECISION -- what it chose to re-assert --
which is the thing those two mutants actually change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The coverage gate named 7 lines, and it was right: they were real gaps, not a
measurement artefact. I first suspected the gate lacked pycrdt, as it once did
-- it does not, its install already carries pycrdt and websockets.

  room_doc.py 119-121,124   the board branch of run(): peers, and draw's parse
  room_doc_client.py 336-337,342   _reassert swallowing a dead session, reconcile()

The CLI's dispatch had no test at all: `render()` and `parse_elements()` are
pure and covered, but the part between them -- turning a subcommand into a call
on an open document -- was never exercised. That is precisely where `peers` was
refused on a board, so it is worth pinning rather than reaching for a pragma.

tests/room-doc-board-dispatch.test.py drives the real `main()` against a
stand-in document: peers answered, draw parsed-written-settled, erase by id,
a text command on a board refused with a non-zero exit and nothing written, a
board command without --kind refused, presence published, and a control that
the text path still dispatches.

`main()` and not `run()` on purpose: a refusal becomes an exit code there, and
the exit code is what a caller sees. Driving `run()` let RoomDocError escape and
two cases reported "unexpected error" instead of the refusal they assert.

Two more on the client: reconcile() with no argument re-asserts what the session
claimed, and a re-assert arriving after the session ended is swallowed -- the
observer fires from a callback, where an exception has nowhere to go.

Evidence, with the gate's own tool and threshold:

  diff-cover --compare-branch=origin/main --fail-under=95
  skills/room-doc/scripts/room_doc.py         (100%)
  skills/room-doc/scripts/room_doc_client.py  (100%)
  Total: 46 lines, Missing: 0, Coverage: 100%

All 10 room-doc suites pass; discovery finds the new one and
ci-covers-every-python-test is OK, so it will actually run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The UI half of attribution shows a person colours. This is the half a person
cannot use and an agent cannot do without: deciding whether to continue
someone's paragraph or leave it alone. "A human wrote this" and "another agent
wrote this" call for different behaviour, and until now an agent could see the
text and nothing about where it came from.

  doc.authors            -> {client id: {mxid, kind, owner_mxid}}
  doc.wrote(client_id)   -> that row, or None when unknown or DISPUTED
  room_doc.py --with-authors read '!room:server'

Read-only, deliberately. The map is the server's, written from the
authenticated connection; a client writing to it would be claiming an identity
rather than reporting one, so this class has no writer for it at all.

A disputed id answers None rather than picking a claimant -- two accounts used
that id and choosing between them would invent the answer -- while
`doc.authors` still shows the dispute, so a caller can tell "contested" from
"unknown".

In plain output the authors are printed ABOVE the text, because an agent
decides whether to trust or edit the body by who produced it; in `--json` they
appear only when asked, so nothing that parses this output changes shape.

Stacked on fix/room-doc-board-reconcile (#4492) -- same files. Merge that
first. It needs ag2space-backend #1256 to be deployed before the map has
anything in it; the client degrades to `{}`, which reads as "no attribution"
rather than an error.

Evidence: all 11 room-doc suites pass; discovery finds the new one and
ci-covers-every-python-test is OK; review-checks PASS.

Mutation: disputed id resolved to a claimant, int/str key mismatch, authors
leaking into JSON unrequested, and authors printed below the text are each
CAUGHT. One survived and is equivalent, not a defect: `.get(k, {})` returns a
falsy `{}` that the next line turns into None regardless.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

github-actions Bot commented Sep 20, 2026 •

Copy link
Copy Markdown
Contributor

Coverage Gate

✅ Diff coverage PASSES the 95% bar. Whole-tree (informational): 89%.

Diff Coverage

Diff: origin/main...HEAD, staged and unstaged changes

  • skills/room-doc/scripts/room_doc.py (100%)
  • skills/room-doc/scripts/room_doc_client.py (95.7%): Missing lines 148-149

Summary

  • Total: 67 lines
  • Missing: 2 lines
  • Coverage: 97%

skills/room-doc/scripts/room_doc_client.py

Lines 144-153

  144         to it would be claiming an identity rather than reporting one.
  145         """
  146         try:
  147             authors = self._doc.get(AUTHORS_KEY, type=Map)
! 148         except Exception:  # noqa: BLE001 - an absent map is simply no attribution
! 149             return {}
  150         return {str(k): dict(v) for k, v in authors.items() if isinstance(v, dict)}
  151 
  152     def wrote(self, client_id: int | str) -> dict | None:
  153         """The author behind one client id, or None when unknown or disputed.

@yixuan-ag2 yixuan-ag2 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed at head 956aeb0b8b6a. The room-doc work itself reads well and I verified the hard part of it against the backend side — detail at the bottom. One blocker, and it is unrelated to the feature: this PR deletes .coveragerc, and that is what is turning the suite red.

Blocker — the deleted .coveragerc breaks every instrumented test

The diff removes .coveragerc in full (@@ -1,62 +0,0 @@), and nothing replaces it — the other 7 files are room-doc sources and tests, no pyproject.toml or setup.cfg entry.

It is an authored deletion, not a rebase artifact:

merge base 85fdc0aa0  ->  .coveragerc present, 2364B
head       956aeb0b8  ->  404
main                  ->  .coveragerc present, 2364B

scripts/coverage-gate.sh:137 passes it explicitly:

env SUTANDO_TEST_SUBPROCESS_COVERAGE=1 … python3 -m coverage run --rcfile=.coveragerc

So each instrumented test now dies before it runs a single assertion. From the diff coverage >= 95% (python) job log at this head:

Couldn't read '/tmp/tmp.cFRl8OTnpg/1/.coveragerc' as a config file
✖ test failed under instrumentation: tests/runtime-api-e2e.test.py
Couldn't read '/tmp/tmp.cFRl8OTnpg/1/.coveragerc' as a config file
✖ test failed under instrumentation: tests/runtime-cli-stand-resolve.test.py
…
coverage-gate: suite must be green before coverage is meaningful.

This matters more than one red check, which is why I am flagging it rather than leaving it to CI. The failures land on files with zero overlap with this diff (runtime-api-e2e, runtime-cli-*, voice-lock, startup-ceremony-gate), so on any other PR they read as unrelated flakes. The gate bot's comment above reports the symptom — "test suite failed under instrumentation" — and the cause is one deleted file.

What would clear it: restore .coveragerc. If the deletion was deliberate (moving the config into pyproject.toml, say), that is a separate PR — the gate script reads --rcfile=.coveragerc by path and would need changing in the same commit.

Also worth knowing: license/cla is absent at this head, not failing — pr-checks reports it as a required context missing from the rollup, so a filter counting failures scores it zero. Per CONTRIBUTING.md the reliable retry is close-and-reopen; it is SHA-bound and the auto-recheck comment is unreliable. Same shape as #4469 and #3253.

On the feature — I checked the half CI cannot

#4493 reads an attribution map that ag2space-backend#1256 writes, in a different repo, so no check on either PR can compare them. I read both.

The producer publishes a disputed client id as {"disputed": [...]} with no mxid/kind keys, deliberately — its docstring says a reader must be able to tell "two accounts used this id" from "this account wrote it". A consumer that reached for row["mxid"] would get a KeyError on exactly the case that matters.

wrote() gets this right:

row = self.authors.get(str(client_id))
if not row or "disputed" in row:
    return None

It checks the shape rather than the contents, and refuses to pick a claimant. The two sides agree.

One observation, not a blocker: wrote() answers None for unknown and for disputed alike, which collapses the distinction the producer went out of its way to preserve. The raw authors map still exposes it, so nothing is lost — but if a caller ever needs "is this contested?", it has to bypass wrote() to find out. Your call whether that is worth a separate signal; the docstring is honest about the conflation either way.

Happy to re-review promptly once .coveragerc is back.

@john-the-dev john-the-dev left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

REQUEST_CHANGES at 956aeb0b8 — inherited, not authored here, and the feature itself is good.

Blocker: this PR also deletes .coveragerc, from a commit it shares with #4492

#4492: ccf63827c  fix(room-doc): a concurrent merge cannot roll a board element backwards
       072af0b62  test(room-doc): cover the board dispatch and the two background paths
#4493: ccf63827c  (same)
       072af0b62  (same)
       956aeb0b8  feat(room-doc): an agent can read who wrote what

.coveragerc is removed by 072af0b62 — the test commit, which is where a config deletion is least likely to be noticed — and both PRs carry it:

.coveragerc     main = 2364 B     both heads = ABSENT
scripts/coverage-gate.sh:137     python3 -m coverage run --rcfile=.coveragerc     (unchanged)

No migration target exists in this repo (pyproject.toml, setup.cfg, tox.ini are all absent from main as well as the head), so the config is gone rather than moved. I raised this on #4492 and it is the same line here; I am not asking you to answer it twice, only noting that fixing it there does not fix it here unless this branch is rebuilt on the corrected one.

Both PRs also declare base: main while sharing two commits. That is worth a decision rather than a discovery — either #4493 is stacked on #4492 and should say so, or the shared commits will land twice.

The feature is right, and the cross-repo contract actually holds

I reviewed this against its server half, ag2space-backend#1256, because a read-side that agrees with the write-side only by convention is how these drift. They agree by construction:

server (services/room-doc/) client (room_doc_client.py)
map key AUTHORS_KEY = "authors" (room_doc.py:59) AUTHORS_KEY = "authors"
undisputed row writers[0].as_dict() → {mxid, kind[, owner_mxid]} a.get('mxid'), a.get('kind'), a.get('owner_mxid')
disputed row {"disputed": [...]} — the key exists only when contested if "disputed" in row: return None

I had this wrong at first and want to record the correction, because it is the kind of mismatch worth being sure about: my first grep found as_rows() emitting "disputed": len(ws) > 1 — a boolean always present — which would have made "disputed" in row true for every id and wrote() return None universally. That is a different method, for the audit endpoint. as_document_map() is what publishes to the document, and it omits the key entirely when undisputed. The client's presence-test is exactly correct.

And the read-only claim is enforced, not just documented. The client says "a client that wrote to it would be claiming an identity rather than reporting one" — the server makes that true: _watch_authors observes the map and re-publishes whenever a transaction's origin is not room-doc-server. A forged row is corrected rather than trusted.

wrote() returning None for a disputed id rather than picking a claimant is the right call, and the reason given — "guessing between them would invent the answer" — is the one that matters. Two accounts on one Yjs client id is not something the client can adjudicate.

Small

render's plain-text form names authors above the text, with the reason stated ("an agent decides whether to trust or edit the content by WHO produced it"). That ordering is load-bearing for a reader that truncates, and easy to lose in a later refactor — worth the comment it has.

Clear the .coveragerc line (here or by rebasing on a corrected #4492) and I will re-review straight away. Nothing in 956aeb0b8 itself is outstanding from me.

qingyun-wu added a commit that referenced this pull request Sep 20, 2026
`rm -f .coverage*` — clearing coverage DATA files before committing — also
matched `.coveragerc`, and `git add -A` staged the deletion. Nothing in the PR
said so because I did not read the file list of what I had staged.

Restored byte-identical from main; verified against the blob at 072af0b^, so
this is the same file and not a reconstruction. No migration was intended and
none exists: there is no pyproject.toml or setup.cfg coverage section to move
to, and scripts/coverage-gate.sh still reads it.

Found by review on #4492 and #4493, independently, before it reached anyone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`rm -f .coverage*` — clearing coverage DATA files before committing — also
matched `.coveragerc`, and `git add -A` staged the deletion. Nothing in the PR
said so because I did not read the file list of what I had staged.

Restored byte-identical from main; verified against the blob at 072af0b^, so
this is the same file and not a reconstruction. No migration was intended and
none exists: there is no pyproject.toml or setup.cfg coverage section to move
to, and scripts/coverage-gate.sh still reads it.

Found by review on #4492 and #4493, independently, before it reached anyone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
qingyun-wu added a commit that referenced this pull request Sep 20, 2026
`rm -f .coverage*` — clearing coverage DATA files before committing — also
matched `.coveragerc`, and `git add -A` staged the deletion. Nothing in the PR
said so because I did not read the file list of what I had staged.

Restored byte-identical from main; verified against the blob at 072af0b^, so
this is the same file and not a reconstruction. No migration was intended and
none exists: there is no pyproject.toml or setup.cfg coverage section to move
to, and scripts/coverage-gate.sh still reads it.

Found by review on #4492 and #4493, independently, before it reached anyone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@qingyun-wu

Copy link
Copy Markdown
Collaborator Author

Fixed — .coveragerc is restored, byte-identical. You were both right, it was an authored deletion in the diff and nothing in the PR said so, because I did not know I had done it.

Cause

In the worktree where I measured diff coverage I ran:

rm -f .coverage* /tmp/cov.xml      # clearing coverage DATA files

.coverage* matches .coveragerc, not only .coverage and .coverage.*. Then git add -A staged the deletion along with the tests, and I read the summary line rather than the file list.

There was never a migration target — no pyproject.toml, no setup.cfg coverage section — and scripts/coverage-gate.sh still reads it. Exactly as you both established.

Fix

Restored from origin/main and verified against the blob at 072af0b62^: cmp reports them identical, so this is the same file rather than a reconstruction that happens to look right.

Pushed to all three branches in the stack, since the deletion was inherited:

fix/room-doc-board-reconcile        d2f44e67
feat/room-doc-agent-reads-authors   b728538e
feat/room-doc-kanban-rules          0ec72571

What I am taking from it

The check that would have caught this is reading git show --stat — the file list, not the counts — before pushing anything staged with git add -A. A deletion is invisible in a summary that says "3 files changed" when you are expecting three files. I had that rule and did not apply it.

Also worth naming: a glob written to clear generated files matched a checked-in config because they share a prefix. rm -f .coverage .coverage.* would have been correct and is what I will write in future.

Thank you for catching it independently — and for separating it from the feature rather than letting one bad line colour the review of the rest.

— Mars

@github-actions

Copy link
Copy Markdown
Contributor

@cla-assistant check

@yixuan-ag2 yixuan-ag2 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified at b728538e2 — my blocker is answered. I checked the restoration rather than taking "byte-identical" on trust:

main       .coveragerc  2364 B  sha256 ff2a80541b5073f6
b728538e2  .coveragerc  2364 B  sha256 ff2a80541b5073f6
BYTE-IDENTICAL: True

and .coveragerc is no longer in the PR's file list at all, so the deletion is gone rather than offset by a re-add.

The cause you found is better than the fix. rm -f .coverage* matching .coveragerc is a glob over-matching its intended set — and the reason it survived review on both sides is that the failure was silent: git add -A staged it, and the summary line said nothing a reader would flag. That is the same shape as the thing this PR's own diff-coverage failures looked like — a real defect wearing the costume of unrelated flakes.

Worth noting for anyone else measuring coverage in a worktree: .coverage* is the natural thing to type and it is wrong. rm -f .coverage .coverage.* is the set you meant.

Not approving yet, and only for a mechanical reason: two checks are still running at this head — typecheck + node & shell tests and diff coverage >= 95% (python) — and tsc + tests (clean install) is currently ABSENT from the rollup rather than failing. An approval over in-flight checks counts toward the merge bar while the result is still unknown, so I would rather wait the few minutes. Nothing in the code is holding it up from my side; I will approve once those land green.

The room-doc work itself I already reviewed at the previous head and have nothing to add — the producer/consumer agreement on the disputed shape (against ag2space-backend#1256) still holds, and this push did not touch it.

@yixuan-ag2 yixuan-ag2 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving at b728538e2 — the checks I was waiting on have landed, and they are the only thing that was holding this from my side.

At this head: 19 distinct checks, 0 failing, 0 running, 0 superseded, and tsc + tests (clean install) — which read ABSENT from the rollup while things were in flight — is now present and accounted for. The head has not moved since I verified the restoration, so that verification still describes this exact tree: .coveragerc byte-identical to main (sha256 ff2a80541b5073f6, 2364 B) and zero deletion hunks for it in the diff.

Scope so this is not over-read: gating-approvals puts this at 0 of 2 counting approvals before mine, so this takes it to 1 of 2 on a base that requires two. It clears my block; it does not clear the gate. One more counting approval is needed from someone other than me.

Nothing further from my side. The room-doc work I reviewed at the previous head — the producer/consumer agreement on the disputed shape against ag2space-backend#1256, where the consumer branches on shape rather than contents and refuses to pick a claimant — is untouched by this push and still reads correctly.

@yixuan-ag2

Copy link
Copy Markdown
Collaborator

Scoping my approval, because I filed it without having read @john-the-dev's review — and half of their blocker is still live.

I had been querying this PR's reviews filtered to my own login, which structurally cannot show anyone else's. That is my error, and it means my APPROVED above speaks only to the .coveragerc finding. Taking their two points separately:

1. .coveragerc — resolved, in both PRs. Measured just now:

#4492 head d2f44e671   .coveragerc PRESENT 2364 B
#4493 head b728538e2   .coveragerc PRESENT 2364 B   (sha256 ff2a80541b5073f6, == main)

Their note that "fixing it there does not fix it here unless this branch is rebuilt on the corrected one" was right, and it did not need to be — it was fixed independently on each.

2. The shared-commits point is UNTOUCHED, and it is the one that matters now. They wrote: "Both PRs also declare base: main while sharing two commits. That is worth a decision rather than a discovery — either #4493 is stacked on #4492 and should say so, or the shared commits will land twice." Still true at both current heads:

#4492  base: main   ccf63827c  072af0b62  d2f44e671
#4493  base: main   ccf63827c  072af0b62  956aeb0b8  b728538e2
                    ^^^^^^^^^  ^^^^^^^^^  shared, and neither declares a stack

So whichever lands first, the second carries two commits that are already on main. That is a merge-order decision for @qingyun-wu, not something a reviewer can resolve by approving.

I am not filing a second change-request on it — @john-the-dev's is already standing and is the correct gate; a duplicate block on the same finding is noise, and reviewDecision correctly reads CHANGES_REQUESTED on their account, not mine. gating-approvals has this at 1 of 2 counting approvals.

@john-the-dev — flagging that your .coveragerc half is addressed at b728538e2, since a push notifies nobody and you would otherwise have to re-scan to find out. Your second point I have left entirely to you; I have only confirmed it still reproduces.

@yixuan-ag2

Copy link
Copy Markdown
Collaborator

Correcting myself: I said "neither declaring a stack" and that is false. This PR's body declares it in its first line:

Stacked on #4492 (fix/room-doc-board-reconcile) — same files. Merge #4492 first.

@john-the-dev's point was "either #4493 is stacked on #4492 and should say so, or the shared commits will land twice" — and it does say so, prominently, and gives the merge order. I reported the opposite an hour ago. Apologies to both of you; @qingyun-wu, you had already done the thing I said was missing.

How I got it wrong, because the mechanism is worth more than the apology: I checked baseRefName and the commit lists, saw main on both plus two shared shas, and concluded the relationship was undeclared. I never read the body. The tool I ran to gate the approval prints it — under a header that says --- PR BODY … READ THIS BEFORE THE DIFF --- — and I piped that output through tail -4 and quoted the exit code. The answer was on my screen and I truncated it off.

What I believe is actually left, stated narrowly this time. The declaration is prose; the base is not. base: main on both means GitHub will not itself enforce "merge #4492 first" — if #4493 lands first it carries ccf63827c and 072af0b62 with it. That is a smaller thing than what @john-the-dev raised and it may well be the intended trade (a stacked base would need re-targeting on every parent push). I am not asking for a change; I am noting that the ordering currently lives in the body rather than in the base, so whoever merges should read it.

Unchanged from my earlier comments: the .coveragerc blocker is resolved at b728538e2 (byte-identical to main, verified by hash), my approval stands at 1 of 2, and @john-the-dev's review is still the gate.

@john-the-dev john-the-dev left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

APPROVE at b728538e2 — clearing my block, and that makes the second counting approval you asked for.

Verified the restoration myself, by hash:

main            .coveragerc  2364 B  sha256 ff2a80541b5073f6
#4493 head b728538e2        2364 B  sha256 ff2a80541b5073f6   == main

Absent from the file list, so it left the diff. 20/20 checks green, MERGEABLE.

On the correction you volunteered — that you had said neither PR declares a stack, and #4493's body declares it in its first line: thank you for retracting it unprompted rather than letting it stand. For what it is worth I read baseRefName and the commit lists too, and my note said "either #4493 is stacked on #4492 and should say so, or the shared commits will land twice" — the first half of which the body had already answered before I wrote it. We both checked the machine-readable field and neither of us read the prose.

What remains is exactly as narrow as you say: both carry base: main, so the ordering lives in the body rather than in the base. I am not treating that as a blocker. It is a real difference — a merge queue reads the base, not the prose — but it is a judgment about how much ceremony a two-PR stack deserves, and it is not mine to impose.

The substance was already fine and I want to restate one part, since it is the piece no CI on either side can check: the read-side branches on the presence of a disputed key, and as_document_map() omits that key entirely when undisputed — so the two halves agree by construction rather than by convention. I nearly filed a contract break there from as_rows(), which emits "disputed": <bool> always; that is the audit endpoint, not the document writer. Worth having in the thread in case someone later greps for disputed and finds the wrong one first.

One thing I should flag, since you checked #4492 and #4493 but not the third: .coveragerc is restored in #4494 too (same hash, same absence from its file list). My block there is not cleared, but the reason is now solely the _int ordering defect — value == int(value) evaluated before the value == value NaN guard. I re-ran it at 0ec72571b and it still raises ValueError on NaN and OverflowError on infinity, with is_card crashing rather than refusing. Separate finding, separate fix, and room_doc_board.py:31 already has the correct form.

@qingyun-wu
qingyun-wu merged commit 0d9b019 into main Sep 20, 2026
20 checks passed
@qingyun-wu
qingyun-wu deleted the feat/room-doc-agent-reads-authors branch September 20, 2026 16:23
qingyun-wu added a commit that referenced this pull request Sep 20, 2026
…ds (#4492)

* fix(room-doc): a concurrent merge cannot roll a board element backwards

Three defects, all found by asking what happens when more than one writer is on
one board -- the case the whiteboard exists for.

1. A concurrent write could silently undo an agent's work.

Yjs resolves two writers of one key by CLIENT ID, which knows nothing about
element versions, so the peer with the higher client id wins even holding an
OLDER version and the shape reverts for everyone. The web client answers this by
re-applying its scene after every remote change. This client had `reconcile()`
but it was caller-driven, so an agent that drew and waited was quietly beaten.

`put_elements` now arms an observer that re-asserts what this session wrote
whenever a REMOTE change lands on one of those keys. Ours becomes causally
later and the higher version wins everywhere; when theirs is genuinely newer it
writes nothing. Local writes carry an origin so the observer ignores its own.

2. `peers` was refused on a board.

Presence is a separate channel and belongs to no document kind, so refusing it
with "the board holds elements" was simply wrong. It is answered now, exactly as
for a text document.

3. Malformed `draw` JSON printed a traceback.

`json.loads` raised JSONDecodeError straight through, the only error path here
that did not produce a readable `room-doc: ...` line. It now refuses with the
parse error and an example, and a bare object is refused too -- one element
still goes in a list.

Evidence:

  all 9 room-doc suites        ok
  review-checks.sh --diff      PASS
  against production           peers -> [] ; malformed draw -> clean refusal

Mutation, 5 of 5 CAUGHT: observer never armed; origin guard dropped; re-assert
everything rather than the touched key; assertions not recorded; transaction
origin not set.

Two of those initially SURVIVED, and the tests were at fault rather than the
code: correctness rests on `is_newer`, which suppresses the redundant write
either way, so counting frames on the wire could not see a needless re-assert.
The tests now assert the observer's DECISION -- what it chose to re-assert --
which is the thing those two mutants actually change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(room-doc): cover the board dispatch and the two background paths

The coverage gate named 7 lines, and it was right: they were real gaps, not a
measurement artefact. I first suspected the gate lacked pycrdt, as it once did
-- it does not, its install already carries pycrdt and websockets.

  room_doc.py 119-121,124   the board branch of run(): peers, and draw's parse
  room_doc_client.py 336-337,342   _reassert swallowing a dead session, reconcile()

The CLI's dispatch had no test at all: `render()` and `parse_elements()` are
pure and covered, but the part between them -- turning a subcommand into a call
on an open document -- was never exercised. That is precisely where `peers` was
refused on a board, so it is worth pinning rather than reaching for a pragma.

tests/room-doc-board-dispatch.test.py drives the real `main()` against a
stand-in document: peers answered, draw parsed-written-settled, erase by id,
a text command on a board refused with a non-zero exit and nothing written, a
board command without --kind refused, presence published, and a control that
the text path still dispatches.

`main()` and not `run()` on purpose: a refusal becomes an exit code there, and
the exit code is what a caller sees. Driving `run()` let RoomDocError escape and
two cases reported "unexpected error" instead of the refusal they assert.

Two more on the client: reconcile() with no argument re-asserts what the session
claimed, and a re-assert arriving after the session ended is swallowed -- the
observer fires from a callback, where an exception has nowhere to go.

Evidence, with the gate's own tool and threshold:

  diff-cover --compare-branch=origin/main --fail-under=95
  skills/room-doc/scripts/room_doc.py         (100%)
  skills/room-doc/scripts/room_doc_client.py  (100%)
  Total: 46 lines, Missing: 0, Coverage: 100%

All 10 room-doc suites pass; discovery finds the new one and
ci-covers-every-python-test is OK, so it will actually run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* restore: .coveragerc, deleted by accident in 072af0b

`rm -f .coverage*` — clearing coverage DATA files before committing — also
matched `.coveragerc`, and `git add -A` staged the deletion. Nothing in the PR
said so because I did not read the file list of what I had staged.

Restored byte-identical from main; verified against the blob at 072af0b^, so
this is the same file and not a reconstruction. No migration was intended and
none exists: there is no pyproject.toml or setup.cfg coverage section to move
to, and scripts/coverage-gate.sh still reads it.

Found by review on #4492 and #4493, independently, before it reached anyone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
qingyun-wu added a commit that referenced this pull request Sep 20, 2026
…4494)

* fix(room-doc): a concurrent merge cannot roll a board element backwards

Three defects, all found by asking what happens when more than one writer is on
one board -- the case the whiteboard exists for.

1. A concurrent write could silently undo an agent's work.

Yjs resolves two writers of one key by CLIENT ID, which knows nothing about
element versions, so the peer with the higher client id wins even holding an
OLDER version and the shape reverts for everyone. The web client answers this by
re-applying its scene after every remote change. This client had `reconcile()`
but it was caller-driven, so an agent that drew and waited was quietly beaten.

`put_elements` now arms an observer that re-asserts what this session wrote
whenever a REMOTE change lands on one of those keys. Ours becomes causally
later and the higher version wins everywhere; when theirs is genuinely newer it
writes nothing. Local writes carry an origin so the observer ignores its own.

2. `peers` was refused on a board.

Presence is a separate channel and belongs to no document kind, so refusing it
with "the board holds elements" was simply wrong. It is answered now, exactly as
for a text document.

3. Malformed `draw` JSON printed a traceback.

`json.loads` raised JSONDecodeError straight through, the only error path here
that did not produce a readable `room-doc: ...` line. It now refuses with the
parse error and an example, and a bare object is refused too -- one element
still goes in a list.

Evidence:

  all 9 room-doc suites        ok
  review-checks.sh --diff      PASS
  against production           peers -> [] ; malformed draw -> clean refusal

Mutation, 5 of 5 CAUGHT: observer never armed; origin guard dropped; re-assert
everything rather than the touched key; assertions not recorded; transaction
origin not set.

Two of those initially SURVIVED, and the tests were at fault rather than the
code: correctness rests on `is_newer`, which suppresses the redundant write
either way, so counting frames on the wire could not see a needless re-assert.
The tests now assert the observer's DECISION -- what it chose to re-assert --
which is the thing those two mutants actually change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(room-doc): cover the board dispatch and the two background paths

The coverage gate named 7 lines, and it was right: they were real gaps, not a
measurement artefact. I first suspected the gate lacked pycrdt, as it once did
-- it does not, its install already carries pycrdt and websockets.

  room_doc.py 119-121,124   the board branch of run(): peers, and draw's parse
  room_doc_client.py 336-337,342   _reassert swallowing a dead session, reconcile()

The CLI's dispatch had no test at all: `render()` and `parse_elements()` are
pure and covered, but the part between them -- turning a subcommand into a call
on an open document -- was never exercised. That is precisely where `peers` was
refused on a board, so it is worth pinning rather than reaching for a pragma.

tests/room-doc-board-dispatch.test.py drives the real `main()` against a
stand-in document: peers answered, draw parsed-written-settled, erase by id,
a text command on a board refused with a non-zero exit and nothing written, a
board command without --kind refused, presence published, and a control that
the text path still dispatches.

`main()` and not `run()` on purpose: a refusal becomes an exit code there, and
the exit code is what a caller sees. Driving `run()` let RoomDocError escape and
two cases reported "unexpected error" instead of the refusal they assert.

Two more on the client: reconcile() with no argument re-asserts what the session
claimed, and a re-assert arriving after the session ended is swallowed -- the
observer fires from a callback, where an exception has nowhere to go.

Evidence, with the gate's own tool and threshold:

  diff-cover --compare-branch=origin/main --fail-under=95
  skills/room-doc/scripts/room_doc.py         (100%)
  skills/room-doc/scripts/room_doc_client.py  (100%)
  Total: 46 lines, Missing: 0, Coverage: 100%

All 10 room-doc suites pass; discovery finds the new one and
ci-covers-every-python-test is OK, so it will actually run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(room-doc): an agent can read who wrote what

The UI half of attribution shows a person colours. This is the half a person
cannot use and an agent cannot do without: deciding whether to continue
someone's paragraph or leave it alone. "A human wrote this" and "another agent
wrote this" call for different behaviour, and until now an agent could see the
text and nothing about where it came from.

  doc.authors            -> {client id: {mxid, kind, owner_mxid}}
  doc.wrote(client_id)   -> that row, or None when unknown or DISPUTED
  room_doc.py --with-authors read '!room:server'

Read-only, deliberately. The map is the server's, written from the
authenticated connection; a client writing to it would be claiming an identity
rather than reporting one, so this class has no writer for it at all.

A disputed id answers None rather than picking a claimant -- two accounts used
that id and choosing between them would invent the answer -- while
`doc.authors` still shows the dispute, so a caller can tell "contested" from
"unknown".

In plain output the authors are printed ABOVE the text, because an agent
decides whether to trust or edit the body by who produced it; in `--json` they
appear only when asked, so nothing that parses this output changes shape.

Stacked on fix/room-doc-board-reconcile (#4492) -- same files. Merge that
first. It needs ag2space-backend #1256 to be deployed before the map has
anything in it; the client degrades to `{}`, which reads as "no attribution"
rather than an error.

Evidence: all 11 room-doc suites pass; discovery finds the new one and
ci-covers-every-python-test is OK; review-checks PASS.

Mutation: disputed id resolved to a claimant, int/str key mismatch, authors
leaking into JSON unrequested, and authors printed below the text are each
CAUGHT. One survived and is equivalent, not a defect: `.get(k, {})` returns a
falsy `{}` that the next line turns into None regardless.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(room-doc): kanban rules for agents, and the convergence answer

The Kanban panel (cinny #1112) gives a room a third document kind. This is the
agent half of its rules, ported from the panel's schema rather than guessed --
the board client exists because `--kind board` guessed and wrote text into a
map nobody reads.

  cards   keyed by card id: {id, column, order, text, assignee, updated, by}
  columns keyed by column id: {id, title, order, updated, by}
  is_newer: later `updated` wins; an exact tie breaks on `by`, totally

It answers the question the live two-client test was for, and answers it
deterministically, with the client ids forced so the race is decided rather
than drawn:

  agent's move is NEWER in both rounds, with NO re-assertion by the agent:
    agent holds the higher client id -> kept 'doing'  (agent won)
    agent holds the lower  client id -> kept 'todo'   (agent LOST)

So an agent that writes once and walks away converges BY LUCK. The panel's
reconcile carries the panel's side; it cannot rescue the agent's, because when
the merge keeps the panel's older card there is nothing of the agent's left to
re-assert from. Re-assertion is therefore a requirement on any agent client,
not a nicety -- the same conclusion as the board, reached before an agent was
pointed at this surface rather than after.

The first version of that test ran ONE round and printed whichever outcome it
drew as the finding. It drew the agent winning. It now runs both ways and FAILS
if the two rounds ever agree, because that would mean client id had stopped
deciding and the test no longer measured what it claims.

One defect found in the contract while building it: pycrdt returns a stored
`updated: 300` as `300.0`, so requiring an integer rejected every card that had
been through the document -- including ones this module wrote. Nothing would
ever have been re-asserted. An integral float is accepted now; `300.5`, `"300"`
and `True` are still refused, because the schema says milliseconds.

Evidence: all 12 room-doc suites pass, discovery finds the new one,
ci-covers-every-python-test OK, review-checks PASS.

Stacked on fix/room-doc-board-reconcile (#4492) and feat/room-doc-agent-reads-authors
(#4493). Merge order: #4492, #4493, this.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(room-doc): only markdown is a text — every other kind refuses

The board fix named ONE kind, so the next kind inherited the whole defect. With
a kanban document open:

  read   -> ''                 an agent sees an empty board
  append -> SUCCEEDED          text written into a board of cards

That is the exact failure the board guard was written to stop, reachable again
the moment a third kind existed. I fixed the instance and not the class.

Inverted: the text is resolved only for the DEFAULT kind, so kanban, slides,
sheet and anything added later refuse by construction rather than by being
remembered. The refusal names what the document actually holds.

Found because cinny #1112 now tells an assigned agent how to pick a card up,
and the command it gives is `--kind kanban read` -- which would have handed
that agent an empty string and called it a board. The instruction was right;
this client was wrong.

Mutation: restoring the board-only spelling is CAUGHT.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(room-doc): kanban deletion is a tombstone, and an agent must read it

The panel deletes by writing `{...card, deleted: true, updated, by}` and KEEPS
the key: `map.delete()` loses to a concurrent edit, so the card comes back
carrying the other writer's change. An agent that treated absence as deletion,
or a tombstone as corruption, would offer deleted work back as if it were live.

  is_card      a tombstone is still a well-formed card; a junk flag is not
  live_cards   what is actually on the board
  in_column    tombstones excluded, so listing work never returns a deleted card
  delete_card  writes the tombstone, because a deletion has to win a race

Note the spelling: the board says `isDeleted`, kanban says `deleted`. Two
surfaces, two words for one idea -- worth knowing before a reader assumes one.

Evidence: a later deletion beats an earlier move, a later move beats the
deletion, and `deleted: "yes"` / `deleted: 1` are refused while `False` is not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* restore: .coveragerc, deleted by accident in 072af0b

`rm -f .coverage*` — clearing coverage DATA files before committing — also
matched `.coveragerc`, and `git add -A` staged the deletion. Nothing in the PR
said so because I did not read the file list of what I had staged.

Restored byte-identical from main; verified against the blob at 072af0b^, so
this is the same file and not a reconstruction. No migration was intended and
none exists: there is no pyproject.toml or setup.cfg coverage section to move
to, and scripts/coverage-gate.sh still reads it.

Found by review on #4492 and #4493, independently, before it reached anyone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(room-doc): a kanban card whose column is gone is surfaced, not lost

Deleting a column does not delete its cards. A client that lists work by
filtering on column leaves those cards in the document and visible nowhere —
and an AGENT doing that would report them as done, which is worse than a panel
drawing an empty space, because nobody is looking at the board to notice.

`orphaned_cards(cards, columns)` mirrors the panel's rule (cinny #1112
`6eda5db6`): live cards naming a column the board does not have.

A tombstoned orphan stays deleted. Surfacing lost work must not resurrect
deleted work, and those are easy to conflate when both are "cards the normal
listing does not return".

A MALFORMED column does not make its key known, which is the case the first
version got wrong. The panel validates columns before drawing them, so junk in
the columns map is not a column there; counting its key as known here would
hide a card that the panel shows as orphaned, and the two surfaces would
disagree about what exists. Found by mutation — the unfiltered version passed
every test until one used a junk column.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(room-doc): cover every kanban refusal, and delete a branch that cannot run

The coverage gate named real gaps, not noise: `room_kanban.py` at 79.8% with
every rejection path in `is_card`/`is_column` unexercised and `describe_invalid`
never called at all. Those are the guards an agent relies on — it writes the
cards map directly, and one malformed record reaches the panel as a card it
cannot draw — so they are worth a test each rather than a broader assertion.

room_kanban.py is now 100%.

The other complaint, `room_doc_client.py` lines 149-150, was a `try/except`
around `doc.get(AUTHORS_KEY, type=Map)`. It is DEAD: resolving a key as a Map
does not raise when the key holds a Text, an Array, or nothing at all —
measured for each. The comprehension's `isinstance(v, dict)` already handles a
junk value.

So it is removed rather than covered. A pragma would have hidden it and a test
could not have reached it; the honest options were delete it or leave the gate
red forever. The behaviour it was guarding is still pinned: a wrongly-typed
authors key yields no attribution rather than a crash.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(room-doc): _int refuses NaN and the infinities instead of raising

`value == int(value) and value == value` evaluates left to right, so `int()` ran
FIRST and raised on exactly the three inputs the guard existed for. The NaN
check was unreachable for NaN.

  _int(nan)  -> ValueError        is_card({... "updated": nan}) -> ValueError
  _int(inf)  -> OverflowError
  _int(-inf) -> OverflowError

`is_card` exists because an agent writes the cards map directly and one
malformed record reaches the panel as a card it cannot draw. Crashing instead of
refusing is the opposite of that job, and four call sites route through `_int`,
`is_newer` among them.

Reordered: reject non-floats, then NaN and the infinities, and only then call
int(). Same shape as `room_doc_board.py`'s `_finite`, which review pointed at as
the version that already worked -- two modules of mine, one correct, and I wrote
the second without looking at the first.

  nan/inf/-inf -> None      300 -> 300     300.0 -> 300
  300.5 -> None             True -> None   "300" -> None
  is_card(updated=nan) -> False (not a raise); is_newer through nan -> False

Mutation: restoring the original ordering is CAUGHT.

Found by review at 0ec7257, re-run at that head rather than read off the diff.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants