Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fa9e7fae64 | ||
|
|
e77c9d30cb | ||
|
|
0b7076cafd | ||
|
|
11333e6d21 | ||
|
|
3e838c0937 | ||
|
|
39e3dc5159 | ||
|
|
88247505e0 |
No files matched your search
@@ -0,0 +1,267 @@
|
||||
# #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-<row> 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.claim` writes `thread_claims` only and returns (broker.mjs:2682 at ce6a96ad6).
|
||||
- `agents` (broker.mjs:3409) reads `role_claims` only:
|
||||
- human and reader sessions get `#agentState(c)` per held role;
|
||||
- role and tester sessions get the raw rows.
|
||||
- A held thread claim is the thread's latest `thread_claims` row with op `claim`. `#threadHolders`
|
||||
(:483) and `#threadRows` (:624) already compute it that way, with `c.seq = max(seq)` per thread.
|
||||
- `threads.name` is the name a user wrote. `threads.instance` is 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:
|
||||
|
||||
```js
|
||||
[{ thread, instance, holder_run, harness, at }]
|
||||
```
|
||||
|
||||
- One row per thread whose latest `thread_claims` op is `claim`.
|
||||
- `at` is that claim row's time.
|
||||
- It doesn't return `name`, message content or `address`. `address` matches the brief's four fields;
|
||||
it can be added if Duckworth wants it next to the role rows.
|
||||
- `instance` is 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:
|
||||
|
||||
```sql
|
||||
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.claims` lists 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 `claim` row, so a first-op reading would still list it.
|
||||
- **T3.** A role session's `thread.claims` refuses `human-required`.
|
||||
- **T4.** `agents` is deepEqual to a copy taken before the thread run claimed, for the role, human
|
||||
and reader sessions on the same store.
|
||||
- **T5.** `JSON.stringify` of the `thread.claims` result 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 to `agents`)
|
||||
- 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_VERBS` exactly. D6 adds nothing to it, so the pin holds.
|
||||
- No test pins the reader list at :2370.
|
||||
- `thread.messages` appears 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 --tester` makes one call, `agents`; human `agents` makes
|
||||
two, `agents` then `thread.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.claims` refuses 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.claims` needs no entry there, and no test pins the list (grep at
|
||||
c27207f6e). 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 `assistant` instance, so a thread run would
|
||||
need a new fixture there. The new file copies message-filter.test.mjs's setup (chief plus an
|
||||
`assistant` thread instance, human, reader and tester sessions). Its threads are owned by the
|
||||
business human (`jason`), so the human's `assistant.stop` reaches 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 `--json` pin 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 `--json` adds only `agents`. Its JSON deepEquals the broker's
|
||||
own `agents` result.
|
||||
- **T7.** The message-filter.test.mjs pin now reads `holder_run` from the owner's `thread.claims`.
|
||||
The old pin read `holder` from `threadsState`.
|
||||
|
||||
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 agents` no longer fails as a whole when `thread.claims` is refused.
|
||||
cli.mjs prints the role lines, then `thread claims: refused <code>`, and exits 0. The choice of
|
||||
exit 0 follows D1: `agents` stays 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}$` shape `markRead` checks); any other error still
|
||||
throws. Pinned by a new test in cli tester.test.mjs: a stand-in transport refuses thread.claims
|
||||
with `processing-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 the
|
||||
`thread` lines, the refusal line, `--json` and `--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 with `ECONNRESET`. 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.
|
||||
@@ -345,6 +345,17 @@ A new holder doesn't inherit the previous holder's unread deliveries;
|
||||
decisions stay with the role. Unheld and released roles aren't listed, so a
|
||||
message stranded for a role nobody holds shows once a run claims the role.
|
||||
|
||||
Thread claims (row 277, #1782): a thread run's `role.claim` claims its thread
|
||||
in `thread_claims`, which `agents` doesn't read, so `agents` lists roles only.
|
||||
`thread.claims` takes no arguments and lists the held thread claims in the
|
||||
business, oldest thread first, as `{thread, instance, holder_run, harness,
|
||||
at}`. A claim is held while its thread's latest `thread_claims` row is a
|
||||
claim, so a released or revoked thread drops out. Human and reader sessions
|
||||
only: a role session refuses `human-required` and the tester
|
||||
`tester-refused`. The result carries the thread id but never its name, which
|
||||
a user wrote, nor any message. `mosaic agents` prints these as `thread` lines
|
||||
after the roles; `mosaic agents --json` and `--tester` print `agents` alone.
|
||||
|
||||
Unknown keys, roles, references, actions and verbs refuse. If writing refusal
|
||||
evidence fails, the caller receives the static `storage-unavailable` code. `human` is the reserved
|
||||
recipient. Message classes are REQUEST, ASSIGNMENT, REVIEW-REQUEST, REVIEW-RESULT,
|
||||
|
||||
@@ -3705,7 +3705,7 @@ export class Broker {
|
||||
return result;
|
||||
}
|
||||
return this.#store.transaction(() => {
|
||||
if (s.reader && !['inbox', 'agents', 'tasks', 'trail', 'decisions', 'credentials', 'replies', 'messages', 'state', 'usage'].includes(request.verb))
|
||||
if (s.reader && !['inbox', 'agents', 'thread.claims', 'tasks', 'trail', 'decisions', 'credentials', 'replies', 'messages', 'state', 'usage'].includes(request.verb))
|
||||
fail('read-only');
|
||||
if (s.tester && !TESTER_VERBS.includes(request.verb)) fail('tester-refused');
|
||||
// A tester reads the human's views, as a reader does, and its message verbs are its own.
|
||||
@@ -4944,6 +4944,18 @@ export class Broker {
|
||||
if (verb === 'tasks') return this.#tasks(s.business, a);
|
||||
return this.#trail(s, identifier(a.subject));
|
||||
}
|
||||
// Row 277 (#1782, design D1): the thread claims held in the business, for human and reader
|
||||
// sessions only. A thread run's role.claim writes thread_claims, which `agents` doesn't read,
|
||||
// so this is where the owner sees it (PRD :1083-1084). A claim is held when its thread's latest
|
||||
// thread_claims row is a claim, as #threadHolders reads it. No thread name: a user wrote that.
|
||||
case 'thread.claims': {
|
||||
if (!s.human && !s.reader) fail('human-required');
|
||||
keys(a, [], []);
|
||||
return this.#store.all(
|
||||
"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",
|
||||
s.business,
|
||||
);
|
||||
}
|
||||
// Row 57 (#1546): the Inbox's "Routed to roles" and "Recent" lists, for human and
|
||||
// reader sessions only. Each row is inbox's view of the decision: the `decisions` row trail
|
||||
// gives the same session for that id, with blocking as a boolean, plus `authorization` from
|
||||
|
||||
@@ -172,11 +172,9 @@ test('governance pin: the owner sees decisions, inbox entries, claims, tasks and
|
||||
|
||||
const inbox = await f.call(f.human, 'inbox');
|
||||
assert.ok(inbox.some((x) => x.id === d), 'inbox entries: the run raise is in the owner inbox');
|
||||
// A thread run's claim is in thread_claims, and `agents` reads role_claims only, so no owner verb
|
||||
// shows it yet (sage-2430). This is the launcher view: the claim is recorded and the filter leaves it
|
||||
// alone. The follow-up row Sage adds for an owner-visible thread-claim read moves this to an owner verb.
|
||||
const claim = f.broker.threadsState('demo').find((x) => x.thread === U);
|
||||
assert.equal(claim?.holder, 'assistant-run', 'claims: the run claim is recorded');
|
||||
// Row 277 (#1782): the owner's thread.claims lists the run's held thread claim.
|
||||
const claim = (await f.call(f.human, 'thread.claims')).find((x) => x.thread === U);
|
||||
assert.equal(claim?.holder_run, 'assistant-run', 'claims: the run claim shows to the owner');
|
||||
const tasks = await f.call(f.human, 'tasks');
|
||||
assert.ok(tasks.some((x) => x.task_ref === 'vikunja:3/47' && x.run === 'assistant-run'), 'tasks: the run task write shows');
|
||||
const file = await f.call(f.human, 'project.file', { project: 'stack', path: 'docs/alice-plan.md' });
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
// Row 277 (#1782, design D1, duckworth-0118): `thread.claims` gives human and reader sessions the
|
||||
// thread claims held in the business. A thread run's role.claim writes thread_claims, which `agents`
|
||||
// doesn't read, so this is where the owner sees it. `agents` is unchanged for every session.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { Store } from '../src/store.mjs';
|
||||
import { Broker } from '../src/broker.mjs';
|
||||
|
||||
const AT = '2026-10-22T00:00:00.000Z';
|
||||
const within = (grants) => ({ authority: { withinRole: grants, crossRole: [] } });
|
||||
const ROW = ['thread', 'instance', 'holder_run', 'harness', 'at'];
|
||||
|
||||
function setup(t) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'bus-thread-claims-'));
|
||||
const store = new Store(root);
|
||||
t.after(() => {
|
||||
store.close();
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
const b = new Broker({
|
||||
store,
|
||||
businesses: {
|
||||
demo: {
|
||||
id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, vars: { 'bus.tester': true },
|
||||
roles: { chief: { definition: 'chief-of-staff', ...within(['message.send']) }, assistant: { definition: 'assistant', ...within(['message.send']) } },
|
||||
},
|
||||
},
|
||||
});
|
||||
const chief = b.bindLaunch({ business: 'demo', role: 'chief', run: 'chief-run', harness: 'pi', address: 'chief' });
|
||||
b.request(chief, { verb: 'role.claim' });
|
||||
const caps = {
|
||||
human: b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }),
|
||||
reader: b.bindReader({ business: 'demo' }),
|
||||
tester: b.bindTester({ business: 'demo' }),
|
||||
chief,
|
||||
};
|
||||
// `who` is a session name from caps, or a run's capability.
|
||||
const call = (who, verb, args = {}) => b.request(Object.hasOwn(caps, who) ? caps[who] : who, { verb, args });
|
||||
const project = call('human', 'project.create', { name: 'shared work' }).id;
|
||||
// The human's own thread, so the human's assistant.stop reaches its run.
|
||||
const thread = (name) => {
|
||||
const id = randomUUID();
|
||||
store.run('INSERT INTO threads(id,at,business,project,instance,owner,name) VALUES(?,?,?,?,?,?,?)', id, AT, 'demo', project, 'assistant', 'jason', name);
|
||||
return id;
|
||||
};
|
||||
const claim = (H, run) => {
|
||||
const cap = b.bindLaunch({ business: 'demo', role: 'assistant', run, harness: 'pi', thread: H });
|
||||
call(cap, 'role.claim');
|
||||
return cap;
|
||||
};
|
||||
return { b, store, caps, call, thread, claim };
|
||||
}
|
||||
|
||||
test('T1: the human and the reader see a held thread claim; agents still lists the role only', (t) => {
|
||||
const { store, call, thread, claim } = setup(t);
|
||||
const H = thread('plan');
|
||||
claim(H, 'assistant-run');
|
||||
const at = store.get("SELECT at FROM thread_claims WHERE thread=? AND op='claim'", H).at;
|
||||
const rows = call('human', 'thread.claims');
|
||||
// The store's rows have no prototype, so each is copied before the strict compare.
|
||||
assert.deepEqual(rows.map((r) => ({ ...r })), [{ thread: H, instance: 'assistant', holder_run: 'assistant-run', harness: 'pi', at }]);
|
||||
for (const r of rows) assert.deepEqual(Object.keys(r), ROW);
|
||||
assert.deepEqual(call('reader', 'thread.claims'), rows);
|
||||
assert.deepEqual(call('human', 'agents').map((r) => r.role), ['chief']);
|
||||
});
|
||||
|
||||
test('T2: a released or revoked thread claim is not listed, though its thread has an earlier claim row', (t) => {
|
||||
const { store, call, thread, claim } = setup(t);
|
||||
const H = thread('plan');
|
||||
const first = claim(H, 'run-1');
|
||||
call(first, 'role.release');
|
||||
assert.deepEqual(call('human', 'thread.claims'), [], 'released');
|
||||
claim(H, 'run-2');
|
||||
assert.deepEqual(call('human', 'thread.claims').map((r) => r.holder_run), ['run-2'], 'claimed again');
|
||||
call('human', 'assistant.stop', { instance: 'assistant' });
|
||||
assert.deepEqual(call('human', 'thread.claims'), [], 'revoked');
|
||||
// The thread's first row is still a claim, so only the latest row decides.
|
||||
assert.deepEqual(store.all('SELECT op FROM thread_claims WHERE thread=? ORDER BY seq', H).map((r) => r.op), ['claim', 'release', 'claim', 'revoke']);
|
||||
});
|
||||
|
||||
test('T3: a role session refuses human-required and the tester refuses tester-refused', (t) => {
|
||||
const { call, thread, claim } = setup(t);
|
||||
const run = claim(thread('plan'), 'assistant-run');
|
||||
for (const who of ['chief', run]) assert.throws(() => call(who, 'thread.claims'), { code: 'human-required' });
|
||||
assert.throws(() => call('tester', 'thread.claims'), { code: 'tester-refused' });
|
||||
});
|
||||
|
||||
test('T4: agents answers each session the same before and after a thread run claims', (t) => {
|
||||
const { call, thread, claim } = setup(t);
|
||||
const sessions = ['human', 'reader', 'tester', 'chief'];
|
||||
const before = Object.fromEntries(sessions.map((who) => [who, call(who, 'agents')]));
|
||||
claim(thread('plan'), 'assistant-run');
|
||||
assert.equal(call('human', 'thread.claims').length, 1);
|
||||
for (const who of sessions) assert.deepEqual(call(who, 'agents'), before[who], who);
|
||||
});
|
||||
|
||||
test('T5: the result carries neither the thread name a user wrote nor a message body', (t) => {
|
||||
const { store, call, thread, claim } = setup(t);
|
||||
const H = thread('alice secret thread name');
|
||||
claim(H, 'assistant-run');
|
||||
// The thread's message, written to the store as message-filter.test.mjs's put() writes one.
|
||||
const m = randomUUID();
|
||||
store.run(
|
||||
'INSERT INTO messages(id,at,business,from_role,from_run,to_role,class,in_reply_to,decision,corrects,body) VALUES(?,?,?,?,?,?,?,?,?,?,?)',
|
||||
m, AT, 'demo', 'assistant', 'assistant-run', 'human', 'INFO', null, null, null, 'alice secret message body',
|
||||
);
|
||||
store.run('INSERT INTO message_threads(message,thread) VALUES(?,?)', m, H);
|
||||
const text = JSON.stringify(call('human', 'thread.claims'));
|
||||
assert.ok(text.includes(H), 'the thread row is there');
|
||||
assert.doesNotMatch(text, /secret/);
|
||||
});
|
||||
@@ -28,7 +28,7 @@ mosaic inbox [--business <id>] [--json]
|
||||
mosaic inbox --read <id>... | --read-all [--business <id>]
|
||||
mosaic decide <decision> <option> [--note <text>] [--yes] [--business <id>]
|
||||
mosaic tasks [--business <id>] [--json]
|
||||
mosaic agents [--business <id>] [--json]
|
||||
mosaic agents [--business <id>] [--json] [--tester]
|
||||
mosaic trail <task|decision> [--business <id>] [--json]
|
||||
mosaic talk <role> <text> [--wait <seconds> | --follow] [--business <id>]
|
||||
mosaic follow [--business <id>]
|
||||
@@ -78,6 +78,12 @@ mosaic launches list [--json]
|
||||
message that call receives drops out of a later `follow`, like the ones
|
||||
plain `talk` skips, and stays unread in `inbox` until marked. Neither flag
|
||||
takes `--json` or `--tester` (exit 4).
|
||||
- `agents` (row 277, #1782) prints the held roles, then a `thread` line for
|
||||
each held thread claim from the bus verb `thread.claims`: thread id,
|
||||
instance, run, harness and since when. It never prints a thread's name. If
|
||||
the bus refuses `thread.claims`, the role lines still print, then `thread
|
||||
claims: refused <code>`, and the command exits 0. `--json` prints the
|
||||
`agents` array alone, and `--tester` makes only the `agents` call.
|
||||
- `decide` takes the full id, or a unique prefix of at least 8 characters,
|
||||
from the inbox. It prints the decision and whether the choice authorizes
|
||||
or declines, then asks `[y/N]` on a terminal. Without a terminal it needs
|
||||
|
||||
@@ -313,7 +313,20 @@ async function human(verb, rest, io, deps) {
|
||||
`\nmessages: ${value.messages.length} unread\n` + value.messages.map(formatMessage).join(""));
|
||||
}
|
||||
if (verb === "tasks") return print(await call("tasks"), formatTasks);
|
||||
return print(await call("agents"), formatAgents);
|
||||
const agents = await call("agents");
|
||||
// Row 277 (#1782, C1/C2): the human's text output adds the held thread claims. A tester can't read
|
||||
// thread.claims, so --tester makes the one call, and --json stays the agents array.
|
||||
if (json || parsed.flags.has("--tester")) return print(agents, formatAgents);
|
||||
// Row 277 N1 (sage-2505): `agents` stays as it was, so a refused thread.claims still prints the
|
||||
// role rows, then the refusal with its code. An error without a bus code still throws.
|
||||
let threads;
|
||||
try {
|
||||
threads = await call("thread.claims");
|
||||
} catch (e) {
|
||||
if (typeof e?.code !== "string" || !/^[a-z-]{1,64}$/.test(e.code)) throw e;
|
||||
return print(agents, (list) => `${formatAgents(list)}thread claims: refused ${e.code}\n`);
|
||||
}
|
||||
return print(agents, (list) => formatAgents(list, threads));
|
||||
}
|
||||
|
||||
// Row 141 (#1635, REQ-MSG-2): `mosaic name` sets the name `talk` and message.send accept for an
|
||||
|
||||
@@ -49,9 +49,10 @@ export function formatDecision(d) {
|
||||
return `${out.join("\n")}\n`;
|
||||
}
|
||||
|
||||
export function formatAgents(list) {
|
||||
if (list.length === 0) return "agents: no role is claimed\n";
|
||||
return `${list.map((c) => `${c.role} ${c.holder_run} ${c.harness ?? "-"} since ${c.at}`).join("\n")}\n`;
|
||||
// Row 277 (#1782): the held thread claims follow the role lines, each marked `thread`.
|
||||
export function formatAgents(list, threads = []) {
|
||||
const roles = list.length === 0 ? "agents: no role is claimed\n" : `${list.map((c) => `${c.role} ${c.holder_run} ${c.harness ?? "-"} since ${c.at}`).join("\n")}\n`;
|
||||
return roles + threads.map((c) => `thread ${c.thread} ${c.instance} ${c.holder_run} ${c.harness} since ${c.at}\n`).join("");
|
||||
}
|
||||
|
||||
export function formatTasks(list) {
|
||||
|
||||
@@ -8,6 +8,18 @@ test("empty views say so", () => {
|
||||
assert.equal(formatTasks([]), "tasks: none\n");
|
||||
});
|
||||
|
||||
// Row 277 (#1782, C2): the held thread claims follow the role lines, each marked `thread`.
|
||||
test("agents adds a thread line for each held thread claim, after the roles", () => {
|
||||
const roles = [{ role: "coder", holder_run: "coder-run", harness: "pi", at: "2026-10-22T05:00:00Z" }];
|
||||
const threads = [{ thread: "t-1", instance: "assistant", holder_run: "assistant-run", harness: "pi", at: "2026-10-22T05:01:00Z" }];
|
||||
assert.equal(
|
||||
formatAgents(roles, threads),
|
||||
"coder coder-run pi since 2026-10-22T05:00:00Z\nthread t-1 assistant assistant-run pi since 2026-10-22T05:01:00Z\n",
|
||||
);
|
||||
assert.equal(formatAgents([], threads), "agents: no role is claimed\nthread t-1 assistant assistant-run pi since 2026-10-22T05:01:00Z\n");
|
||||
assert.equal(formatAgents(roles), formatAgents(roles, []));
|
||||
});
|
||||
|
||||
test("the trail keeps the broker's order and names a decision's task without its rows", () => {
|
||||
const rows = [
|
||||
{ at: "2026-10-08T10:00:00Z", table: "decisions", seq: 4, id: "d-1", class: "gated", action: "deploy", route_to: "human", blocking: 1, question: "Ship?", task_ref: "vikunja:1/7" },
|
||||
|
||||
@@ -128,6 +128,55 @@ test("with no stand-in transport, --tester runs tester-cli.mjs on the data root'
|
||||
assert.deepEqual(seen, [{ argv: [TESTER_CLI, join(dataRoot, "bus", "broker.sock")], input: { business: "demo", verb: "agents", args: {} } }]);
|
||||
});
|
||||
|
||||
// Row 277 (#1782, C1/C2): the human's text adds thread.claims, which a tester can't read, so
|
||||
// `agents --tester` stays one call. --json prints the agents array alone, as `inbox --json` does.
|
||||
test("agents --tester makes one call; the human's agents also reads thread.claims, and --json is the agents array", async (t) => {
|
||||
const s = setup(t);
|
||||
await main(["agents", "--tester", "--business", "demo"], inAgent(), s.deps);
|
||||
assert.deepEqual(s.calls.tester.map((c) => c.verb), ["agents"]);
|
||||
const x = io();
|
||||
await main(["agents", "--business", "demo"], x, s.deps);
|
||||
assert.deepEqual(s.calls.human.map((c) => c.verb), ["agents", "thread.claims"]);
|
||||
assert.match(x.out.text, /^coder coder-run pi since /);
|
||||
const j = io();
|
||||
await main(["agents", "--json", "--business", "demo"], j, s.deps);
|
||||
assert.deepEqual(s.calls.human.map((c) => c.verb), ["agents", "thread.claims", "agents"]);
|
||||
assert.deepEqual(JSON.parse(j.out.text), s.b.request(s.human, { verb: "agents", args: {} }));
|
||||
});
|
||||
|
||||
// Row 277 N1 (sage-2505): a refused thread.claims doesn't fail the human's agents. The role rows
|
||||
// print, then the refusal with its code.
|
||||
test("a refused thread.claims still prints the role rows and the refusal's code", async (t) => {
|
||||
const s = setup(t);
|
||||
const refusing = (business) => async (verb, args) => {
|
||||
if (verb !== "thread.claims") return s.deps.transport(business)(verb, args);
|
||||
const error = new Error("processing-halted");
|
||||
error.code = "processing-halted";
|
||||
throw error;
|
||||
};
|
||||
const x = io();
|
||||
await main(["agents", "--business", "demo"], x, { ...s.deps, transport: refusing });
|
||||
assert.match(x.out.text, /^coder coder-run pi since .+\nthread claims: refused processing-halted\n$/);
|
||||
assert.deepEqual(s.calls.human.map((c) => c.verb), ["agents"]);
|
||||
});
|
||||
|
||||
// Row 277 r4 (duckworth-0123 N1): an error with no bus code is not a refusal, so it still throws and
|
||||
// nothing prints: no code at all, and a code that isn't the bus's shape.
|
||||
test("a thread.claims error without a bus code still throws and prints nothing", async (t) => {
|
||||
const s = setup(t);
|
||||
for (const code of [undefined, "ECONNRESET"]) {
|
||||
const error = new Error("socket closed");
|
||||
if (code !== undefined) error.code = code;
|
||||
const failing = (business) => async (verb, args) => {
|
||||
if (verb !== "thread.claims") return s.deps.transport(business)(verb, args);
|
||||
throw error;
|
||||
};
|
||||
const x = io();
|
||||
await assert.rejects(main(["agents", "--business", "demo"], x, { ...s.deps, transport: failing }), (e) => e === error, String(code));
|
||||
assert.equal(x.out.text, "", String(code));
|
||||
}
|
||||
});
|
||||
|
||||
// Row 170 (#1667): the bus refuses a tester's message to a stopped role and stores nothing, and
|
||||
// talk says so and exits 3.
|
||||
test("talk --tester to a stopped role exits 3, prints no sent line and stores nothing", async (t) => {
|
||||
|
||||
Reference in new issue
Block a user