15 KiB
#1782: the owner sees a thread agent's claims (design r1)
Morgana, 2026-10-22, for Duckworth. Brief: docs/plans/2026-10-22_owner-thread-claims.md (Sage, for
mos-1262). The brief wasn't committed and had no queue row when this was written, so the folder is named
for the brief. It moves to queue- once the row exists. Base: refactor c27207f6e (205 landed at
ce6a96ad6, then 268). No code yet.
What the code does now
- A thread run's
role.claimwritesthread_claimsonly and returns (broker.mjs:2682 atce6a96ad6). agents(broker.mjs:3409) readsrole_claimsonly:- human and reader sessions get
#agentState(c)per held role; - role and tester sessions get the raw rows.
- human and reader sessions get
- A held thread claim is the thread's latest
thread_claimsrow with opclaim.#threadHolders(:483) and#threadRows(:624) already compute it that way, withc.seq = max(seq)per thread. threads.nameis the name a user wrote.threads.instanceis the role instance from the business file, which the operator writes, not the user.
The problem with the brief's shape
The brief adds thread rows to the human and reader agents result, after the role rows. Every consumer
of that result assumes one row per role:
| Consumer | What it reads | What a thread row would do |
|---|---|---|
| Console Agents view, webui bus.js:474 | c.role in a "Held roles" table, claims.length as its count |
blank role cell, wrong count |
Console board held table, bus.js:447 and :462 held-count |
same | same |
| Console composer, bus.js:298 | c.role, filtered with typeof r === 'string' |
dropped safely |
| CLI TUI agents view, screens.mjs:280 and :284, app.mjs:288 and :433 | c.role as title and column |
blank title |
CLI mosaic agents, format.mjs:54 |
c.role |
undefined in the line (brief item 4 covers this one) |
So the shape as briefed also needs the Console (webui) and the TUI changed in this row, or the live Agents view breaks on the next re-pin. The brief's manifest names only format.mjs.
Proposed shape: a separate owner verb
The brief allows this ("for example a separate owner verb, it goes in BUILD.md to Duckworth before code").
D1. New verb thread.claims, human and reader sessions only. Every other session refuses with
human-required, the code decisions uses at :3426. No arguments (keys(a, [], [])). The result, in
thread creation order:
[{ thread, instance, holder_run, harness, at }]
- One row per thread whose latest
thread_claimsop isclaim. atis that claim row's time.- It doesn't return
name, message content oraddress.addressmatches the brief's four fields; it can be added if Duckworth wants it next to the role rows. instanceis beyond the brief's four. It names which assistant holds the thread, and the operator writes it, not the user.
SQL, the #threadHolders join without the instance and owner filter:
SELECT t.id AS thread, t.instance, c.holder_run, c.harness, c.at FROM threads t
JOIN thread_claims c ON c.seq=(SELECT max(seq) FROM thread_claims WHERE thread=t.id)
WHERE t.business=? AND c.op='claim' ORDER BY t.seq
D2. agents doesn't change for any session. That is stronger than brief item 2 (role sessions
only), and it means the Console, TUI and composer need no change in this row.
D3. Reader allow-list. thread.claims goes in the reader list at :2370. The processing gate (:402)
already exempts human and reader sessions, so a halt doesn't hide claims. No TESTER_VERBS or USER_VERBS
entry is added (see D6).
D4. CLI mosaic agents (cli.mjs:314) calls agents and then thread.claims. formatAgents(list, threads = []) prints the role lines unchanged, then for each held thread claim:
thread <thread> <instance> <holder_run> <harness> since <at>
Each line starts with thread , so a reader can tell the two kinds apart (brief item 1). Role output
with no thread claims is byte-equal to today, including "agents: no role is claimed". When there are no
roles but there are thread claims, the empty-roles line is still printed, then the thread lines.
D5. The governance pin. message-filter.test.mjs, the "claims" item: threadsState becomes
call(owner, 'thread.claims'), which must include { thread: U, holder_run: 'assistant-run' }. The
comment that names this row goes away (brief item 5).
D6. What this row leaves out, so as not to widen beyond owner and reader sessions (mos-1202):
- tester sessions;
- FED R3 user principals, who own threads under R3, so their own claims are an R6 question;
- a Console section;
- the TUI.
Questions for Duckworth and Sage
- Q1. Do you accept D1 over the briefed mixed list? If you'd rather have the mixed list, the manifest grows to webui bus.js, screens.mjs and app.mjs, plus their tests (bus-browser, webui bus.test and the TUI tests).
- Q2. Should the Console show thread claims before a user thread goes live? PRD :1083-1084 promises
visibility to the owner, and a Console user won't run
mosaic agents. My recommendation: a follow-up row bound like this one (it lands before any user thread goes live), so this row stays small for 10-27. The alternative is a "Thread claims" section in the Console Agents view in this row.
Tests (under D1)
All new tests go in packages/bus/tests/agents.test.mjs unless named otherwise. Each one runs on a store with one held role claim and one thread run.
- T1. Human
thread.claimslists the held thread claim (thread, instance, holder_run, harness, at). Reader gets the same result. - T2. After release, the thread is gone. After a re-claim followed by a revoke, it's gone again.
The thread has an earlier
claimrow, so a first-op reading would still list it. - T3. A role session's
thread.claimsrefuseshuman-required. - T4.
agentsis deepEqual to a copy taken before the thread run claimed, for the role, human and reader sessions on the same store. - T5.
JSON.stringifyof thethread.claimsresult has neither the thread's name nor the body of a message sent in that thread. - T6. packages/cli/tests/format.test.mjs: a mixed list renders the role lines exactly as today,
then
thread …lines. The empty-roles case with threads is covered too. - T7. packages/bus/tests/message-filter.test.mjs: the moved pin (D5).
Mutants (each killed by a named test)
| Mutant | Change | Killed by |
|---|---|---|
| M1 | thread.claims returns [] |
T1, T7 |
| M2 | the held check uses min(seq) (first op) |
T2 |
| M3 | the human/reader check is dropped, so a role session gets thread rows | T3 |
| M4 | agents for human appends the thread rows |
T4 |
| M5 | the SELECT adds t.name |
T5 |
| M6 | formatAgents drops the thread lines | T6 |
Manifest (expected)
- packages/bus/src/broker.mjs
- packages/bus/README.md (a paragraph for
thread.claims, next toagents) - packages/bus/tests/agents.test.mjs
- packages/bus/tests/message-filter.test.mjs
- packages/cli/src/cli.mjs
- packages/cli/src/format.mjs
- packages/cli/tests/format.test.mjs
- this BUILD.md
Verb-list pins checked at c27207f6e:
- tester-writes.test.mjs:19 pins
TESTER_VERBSexactly. D6 adds nothing to it, so the pin holds. - No test pins the reader list at :2370.
thread.messagesappears in tester-threads, threads and webui chat as calls, not as a verb set.
Before the in-review move, I'll rerun the same grep for thread.claims and for any new verb-set pin.
Gate (brief): Duckworth approves the manifest. On Sage's gate, the touched test files run whole, the bus and cli suites are green, every scripts/test-*.sh is green, and M1-M6 are killed.
r2 (duckworth-0118: D1 approved with C1-C3)
Q1: Duckworth accepts D1. Q2: Duckworth is fine with a follow-up row under the same bind; Sage rules on
it. Duckworth's note is agents/duckworth/work/row1782/DESIGN-r1.md on duckworth/reviews 13fb9abc5.
C1. --tester makes one call. callFor binds the tester when --tester is given (cli.mjs:192).
D6 keeps thread.claims off TESTER_VERBS, so a second call there would refuse tester-refused and exit
3. tester.test.mjs:128 pins exactly one spawn, agents. So cli.mjs calls thread.claims only when
--tester is absent. A tester's mosaic agents is unchanged.
- T8 (packages/cli/tests/format.test.mjs or the CLI test that drives main() with a stand-in transport;
the exact file is picked at build):
agents --testermakes one call,agents; humanagentsmakes two,agentsthenthread.claims. - Gate adds tester.test.mjs and other-tree.test.mjs, each run whole.
- M7: thread.claims is called on the tester path too. T8 and tester.test.mjs:128 kill it.
C2. --json shape. mosaic agents --json prints the agents array unchanged, as inbox --json
prints decisions only (cli.mjs:308). Only the text output adds the thread … lines. The machine read of
thread claims is the bus verb, and the bus README paragraph documents thread.claims as such. A grep at
c27207f6e found no consumer of mosaic agents --json in packages/*/tests or scripts. T6 pins both: the
text output is mixed, and the --json output deepEquals the agents result alone.
C3. Where the pin moves. Under D1 the governance pin moves to the owner's thread.claims call, not
to agents. That is brief item 5 read under D1: the brief named agents because it put thread rows
there.
Notes taken:
- N1: T4's before/after equality covers the tester session too.
- N2: T3 adds a tester:
thread.claimsrefuses tester-refused. - N3: FED R3 user principals must not get other users' thread claims. This row gives them none (no USER_VERBS entry). The rule carries with 205 N1/N3 to 205b/R3.
- N4: the :403 processing-gate list is in the verb-set grep. Human and reader sessions return before
that list (:402), so
thread.claimsneeds no entry there, and no test pins the list (grep atc27207f6e). The :2370 reader list is the one list that gains the verb.
Mutants are now M1-M7. Building starts once Sage creates the row.
r3: build (sage-2486, on 23c3d6218)
Changes from the design, each a placement choice:
- Bus tests file. T1-T5 go in a new file, packages/bus/tests/thread-claims.test.mjs, not
agents.test.mjs. agents.test.mjs's businesses have no
assistantinstance, so a thread run would need a new fixture there. The new file copies message-filter.test.mjs's setup (chief plus anassistantthread instance, human, reader and tester sessions). Its threads are owned by the business human (jason), so the human'sassistant.stopreaches the run for T2's revoke. - T3 covers the chief's role session and the thread run itself (both
human-required) and the tester (tester-refused, N2). - T4 covers human, reader, tester and chief sessions (N1).
- T5 writes the thread's message to the store, as message-filter.test.mjs's put() does. The run's own message.send would also pass through the messaging graph, which T5 doesn't test.
- T6/T8 split. format.test.mjs holds T6's text cases: mixed, empty roles with threads, and no
threads argument. The
--jsonpin needs main(), so it sits with T8 in tester.test.mjs, whose setup has both stand-in transports. That test also pins the call order: tester["agents"]; human["agents", "thread.claims"]; human--jsonadds onlyagents. Its JSON deepEquals the broker's ownagentsresult. - T7. The message-filter.test.mjs pin now reads
holder_runfrom the owner'sthread.claims. The old pin readholderfromthreadsState.
Manifest (actual):
- packages/bus/src/broker.mjs
- packages/bus/README.md (a "Thread claims" paragraph at the end of "Agent state")
- packages/bus/tests/thread-claims.test.mjs (new)
- packages/bus/tests/message-filter.test.mjs
- packages/cli/src/cli.mjs
- packages/cli/src/format.mjs
- packages/cli/tests/format.test.mjs
- packages/cli/tests/tester.test.mjs
- agents/morgana/work/queue-277/BUILD.md
Mutants: M1 (T1, T7), M2 (T2), M3 (T3), M4 (T1, T4), M5 (T5), M6 (T6), M7 (T8). Gate: the touched test files run whole, plus other-tree.test.mjs. Then the bus and cli suites and every scripts/test-*.sh. Nothing runs before Sage's CLEAR.
Early look (sage-2504, 05:27Z), on c050bb90b: thread-claims.test.mjs failed 5/5. The test's call
helper took any string as a session name, but a run's capability is a string too, so the run's calls
went out unauthenticated. T1 also compared null-prototype store rows strictly. Both are test bugs, and
the fix is r2 of the build. On r2, thread-claims 5/5, cli tester 9/9, other-tree 4/4 pass, 0 skipped.
r3: Duckworth notes N1/N2 (duckworth-0119, ruled sage-2505), for the post-194 rebase round
- N1. A human
mosaic agentsno longer fails as a whole whenthread.claimsis refused. cli.mjs prints the role lines, thenthread claims: refused <code>, and exits 0. The choice of exit 0 follows D1:agentsstays as it was, and it exited 0 before this row. Only an error with a bus code is caught (the same^[a-z-]{1,64}$shapemarkReadchecks); any other error still throws. Pinned by a new test in cli tester.test.mjs: a stand-in transport refuses thread.claims withprocessing-halted, the role row prints, then the refusal line. This matches row 278 item 4 for the Console. M8 (the catch drops the refusal line, or rethrows) is killed by that test. - N2. packages/cli/README.md: the usage line gains
[--tester], and a bullet describes thethreadlines, the refusal line,--jsonand--tester. host.test.mjs:855 reads this README for a unit file name, which is still there. host.test.mjs joins the gate's whole-file runs.
Manifest adds packages/cli/README.md (10 paths). Nothing here has run yet. It runs whole after the rebase onto 194, on Sage's CLEAR.
r4: rebase onto 0165be266 and Duckworth's delta notes (sage-2587, duckworth-0123)
- Rebase. Commits 1-6 are cherry-picked onto refactor
0165be266, which has 194, 236 and 274 landed, on morgana/row277-0165. The range-diff is=for all six and the patch-ids are unchanged. Four files auto-merged around upstream lines: bus README, broker.mjs, cli README and cli.mjs. - N1 (duckworth-0123). The N1 catch's rethrow branch had no test, so a mutant that takes every
error as a refusal (it would print
refused undefined) survived. A new cli tester.test.mjs test throws from thread.claims with no code, then withECONNRESET. Each rejects main with the same error and prints nothing. M9 (the code check dropped) is killed by that test. - N2 (duckworth-0123), accepted. Since 274, the owner's and a reader's thread.claims can include user threads (id, instance, holder run, harness, time; no name, no messages). That is the design join approved in duckworth-0118, with no owner filter, and owner-visible run governance under sage-1368 B1.
- N3 (duckworth-0123), for later. When 264a and 277 meet, thread.claims's latest row per thread goes per session, and thread-claims.test.mjs needs the default session. It is checked in whichever round lands second.
Gate (sage-2590): the touched test files plus user-principal, other-tree and host whole; the bus, cli, harness, business and seat suites; every scripts/test-*.sh; M1-M9. The kit prints HEAD and the manifest check first. Nothing runs before Sage's CLEAR.