From badfa52b9d5b956392bd9cb141ff1f4ce9f402f0 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Tue, 13 Oct 2026 19:12:54 -0500 Subject: [PATCH] harness: a new PM run reads current launch state and open tasks before each turn, and old messages show their age, row 109 (#1603) The broker gains a read-only state verb (launch state with the event that set it, open tasks capped). The runner fetches it before each turn and puts a "Current state" block at the top of the request; a failed read fails the turn. turnRequest() adds the message time. roles/pm.md gets one rule: the block is the truth for launch state and task status. Seat session, seat sandbox and cli launcher tests re-anchored to the new request shape. Built by Researcher, packet 875c515e9. Filbert approved round 3, comment 28333, candidate manifest 0a62225e (11 paths). Sage gate on 9774d70db plus row109.patch: every package node suite and every scripts/test-*.sh green on the first run, skipped 0 in bus, harness and seat. Vikunja #259. Co-Authored-By: Claude Opus 5.5 --- packages/bus/README.md | 1 + packages/bus/src/broker.mjs | 19 ++- packages/bus/tests/state.test.mjs | 113 ++++++++++++++++++ packages/cli/tests/launcher.test.mjs | 6 +- packages/harness/README.md | 9 ++ packages/harness/src/runner.mjs | 39 +++++- .../harness/tests/fixtures/fake-adapter.mjs | 3 +- packages/harness/tests/runner.test.mjs | 108 ++++++++++++++++- packages/seat/tests/sandbox.test.mjs | 3 +- packages/seat/tests/session.test.mjs | 4 +- roles/pm.md | 5 + 11 files changed, 299 insertions(+), 11 deletions(-) create mode 100644 packages/bus/tests/state.test.mjs diff --git a/packages/bus/README.md b/packages/bus/README.md index 0cbbc8192..b0c469263 100644 --- a/packages/bus/README.md +++ b/packages/bus/README.md @@ -157,6 +157,7 @@ an operator inspect content and delivery evidence after an uncertain result. | `decisions` | `{list}`: `roles` or `recent`; human and reader sessions only. Open decisions routed to a role, or the last 50 closed. | | `credentials` | No args; human and reader sessions only. `[{service,account,role,dateKind,date,state}]` for the capability's business, never a token, file path or variable name. | | `requirements` | No args; role sessions only, as `task.create` is (row 90, #1584). The approved PRD's requirement ids, `{prd,version,requirements:[{id,title}]}`, from `packages/tasks/requirements.json`, loaded when the broker starts; a bad file refuses the start. A human session refuses `agent-required`, a reader `read-only`. | +| `state` | No args; role, human and reader sessions (row 109, #1603). `{launch:{state,at,event},tasks:{open:[{task_ref,title}],total,cap}}` for the capability's business. `launch` is the latest `launch.revoked` or `launch.restored` event: `allowed` with `at` and `event` null when none exists. `open` is the tracker snapshot's open tasks in `task_ref` order, at most `cap` (the `tasks` page size, 50); `total` counts every open task. The harness runner reads it before each turn. Writes nothing. | | `replies` | `{id}`: a message UUID; human and reader sessions only. The messages whose `in_reply_to` is that id, oldest first, at most 50. Writes nothing. | ### Agent state (row 59, #1548) diff --git a/packages/bus/src/broker.mjs b/packages/bus/src/broker.mjs index 732c7310e..ddc18f21c 100644 --- a/packages/bus/src/broker.mjs +++ b/packages/bus/src/broker.mjs @@ -654,7 +654,7 @@ export class Broker { if (!object(args)) fail('invalid-request'); this.#secretCheck(args); return this.#store.transaction(() => { - if (s.reader && !['inbox', 'agents', 'tasks', 'trail', 'decisions', 'credentials', 'replies', 'messages'].includes(request.verb)) + if (s.reader && !['inbox', 'agents', 'tasks', 'trail', 'decisions', 'credentials', 'replies', 'messages', 'state'].includes(request.verb)) fail('read-only'); const result = this.#dispatch(s, request.verb, args); this.#secretCheck(result); @@ -1086,6 +1086,23 @@ export class Broker { this.#agent(s); return this.#requirements; } + // Row 109 (#1603): what a run needs to know before it plans. The business's launch state with + // the time and id of the event that set it (`allowed`, no time and no id when none has), and + // the open tasks as the tracker snapshot shows them: refs and titles, at most `cap` of them, + // `total` counting every open task. A read for role, reader and human sessions; nothing is stored. + case 'state': { + keys(a, []); + if (!s.human && !s.reader) this.#agent(s); + const set = this.#store.get( + "SELECT id,kind,at FROM events WHERE business=? AND kind IN ('launch.revoked','launch.restored') ORDER BY seq DESC LIMIT 1", + s.business, + ); + const open = this.#tasks(s.business, { done: false }); + return { + launch: { state: set?.kind === 'launch.revoked' ? 'revoked' : 'allowed', at: set?.at ?? null, event: set?.id ?? null }, + tasks: { open: open.tasks.map(({ task_ref, title }) => ({ task_ref, title })), total: open.total, cap: TASK_PAGE }, + }; + } case 'inbox': case 'agents': case 'tasks': diff --git a/packages/bus/tests/state.test.mjs b/packages/bus/tests/state.test.mjs new file mode 100644 index 000000000..7ae0085a7 --- /dev/null +++ b/packages/bus/tests/state.test.mjs @@ -0,0 +1,113 @@ +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 { Store } from '../src/store.mjs'; +import { Broker, TASK_PAGE } from '../src/broker.mjs'; +// Row 109 (#1603): `state` is what a run reads before it plans: the launch state with the time and +// id of the event that set it, and the open tasks from the tracker snapshot, capped. +const authority = { withinRole: ['message.send', 'role.launch'], crossRole: [] }; +const roles = { pm: { authority }, coder: { authority } }; +const businesses = { + demo: { id: 'demo', human: 'jason', arbiters: { technical: 'pm', delivery: 'pm' }, launch: { by: 'pm', instances: ['coder'], max: { opus: 2, sonnet: 2 } }, roles }, +}; +const at = '2026-10-12T12:00:00.000Z'; +const digest = 'd'.repeat(64); +const ref = (i) => `vikunja:3/${i}`; +const fields = (i, done = false) => ({ project_id: 3, title: `Task ${i}`, description: `

${i}

`, done, bucket: 11 }); + +function setup(t) { + const root = mkdtempSync(join(tmpdir(), 'bus-state-')); + const store = new Store(root); + const b = new Broker({ store, businesses }); + t.after(() => { + store.close(); + rmSync(root, { recursive: true, force: true }); + }); + const human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }); + const pm = b.bindLaunch({ business: 'demo', role: 'pm', run: 'demo-pm', harness: 'claude-code' }); + b.request(pm, { verb: 'role.claim' }); + const snap = (i, over = {}, read_at = at) => ({ task_ref: ref(i), updated: read_at, digest, fields: { ...fields(i), ...over }, via: 'board', read_at }); + const record = (...snapshots) => b.recordTask({ business: 'demo', snapshots }); + const state = (cap = pm, args = {}) => b.request(cap, { verb: 'state', args }); + return { b, store, human, pm, snap, record, state, reader: b.bindReader({ business: 'demo' }) }; +} + +test('a business that never switched launches reads allowed with no time and no event', (t) => { + const { state } = setup(t); + assert.deepEqual(state().launch, { state: 'allowed', at: null, event: null }); +}); + +test('launches revoked and then restored read allowed, with the restore time and event id', (t) => { + const { b, store, human, pm, state } = setup(t); + const revoked = b.request(human, { verb: 'launch.revoke' }).id; + assert.deepEqual( + { state: state().launch.state, event: state().launch.event }, + { state: 'revoked', event: revoked }, + ); + const restored = b.request(human, { verb: 'launch.restore' }).id; + const row = store.get('SELECT at FROM events WHERE id=?', restored); + assert.deepEqual(state(pm).launch, { state: 'allowed', at: row.at, event: restored }); + assert.notEqual(restored, revoked); +}); + +test('a task closed on the tracker leaves the open list, and total counts the open ones', (t) => { + const { snap, record, state } = setup(t); + record(snap(1), snap(2), snap(3)); + assert.deepEqual(state().tasks, { + open: [1, 2, 3].map((i) => ({ task_ref: ref(i), title: `Task ${i}` })), + total: 3, + cap: TASK_PAGE, + }); + record(snap(2, { done: true }, '2026-10-12T13:00:00.000Z')); + const { open, total } = state().tasks; + assert.deepEqual(open.map((x) => x.task_ref), [ref(1), ref(3)]); + assert.equal(total, 2); +}); + +test('a tombstone is not open', (t) => { + const { snap, record, state } = setup(t); + record(snap(1), { ...snap(2), fields: { gone: 'no-access' }, via: 'task' }); + assert.deepEqual(state().tasks.open.map((x) => x.task_ref), [ref(1)]); +}); + +test('the open list stops at the cap and says so; total still counts every open task', (t) => { + const { snap, record, state } = setup(t); + record(...Array.from({ length: TASK_PAGE + 5 }, (_, k) => snap(k + 1))); + const { open, total, cap } = state().tasks; + assert.equal(open.length, TASK_PAGE); + assert.equal(total, TASK_PAGE + 5); + assert.equal(cap, TASK_PAGE); +}); + +test('a role and a reader session read state; arguments, and an unbound cap, refuse', (t) => { + const { reader, pm, state } = setup(t); + assert.deepEqual(state(reader), state(pm)); + assert.throws(() => state(pm, { business: 'other' }), { code: 'invalid-request' }); + assert.throws(() => state('f'.repeat(64)), /unauthenticated/); +}); + +test('a human session reads the same state, with the same cap', (t) => { + const { snap, record, human, pm, state } = setup(t); + record(...Array.from({ length: TASK_PAGE + 2 }, (_, k) => snap(k + 1))); + const read = state(human); + assert.deepEqual(read, state(pm)); + assert.equal(read.tasks.cap, TASK_PAGE); + assert.equal(read.tasks.open.length, TASK_PAGE); + assert.equal(read.tasks.total, TASK_PAGE + 2); +}); + +test('reading state writes nothing: every table keeps its row count', (t) => { + const { b, store, human, pm, reader, snap, record, state } = setup(t); + b.request(human, { verb: 'launch.revoke' }); + record(snap(1), snap(2)); + const counts = () => Object.fromEntries( + store.all("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'") + .map(({ name }) => [name, store.get(`SELECT COUNT(*) AS n FROM "${name}"`).n]), + ); + const before = counts(); + assert.ok(before.events > 0 && before.task_snapshots > 0); + for (const cap of [pm, reader, human]) state(cap); + assert.deepEqual(counts(), before); +}); diff --git a/packages/cli/tests/launcher.test.mjs b/packages/cli/tests/launcher.test.mjs index 7e2623728..80d945dc2 100644 --- a/packages/cli/tests/launcher.test.mjs +++ b/packages/cli/tests/launcher.test.mjs @@ -234,10 +234,14 @@ test("the PM launches a coder through its launch tool; the coder answers; refusa } // The coder claimed its role and answers its own messages. + const before = Date.now(); const hello = await client.call("message.send", { to: "coder", class: "REQUEST", body: "hello" }); const echo = await until(async () => (await client.call("message.receive")).find((m) => m.in_reply_to === hello.id)); assert.equal(echo.from_role, "coder"); - assert.match(echo.body, /^echo: Message .* from cto, class REQUEST:\n\nhello/); + // Row 109 (#1603): the turn opens with the state block, then the header with the time it was sent. + const turn = echo.body.match(new RegExp(`^echo: Current state, read at \\S+:\nLaunches: allowed \\(never switched\\)\\.\nOpen tasks: 0\\.\n\nMessage ${hello.id} from cto, sent (\\S+), class REQUEST:\n\nhello$`)); + assert.ok(turn, echo.body); + assert.ok(Date.parse(turn[1]) >= before - 1000 && Date.parse(turn[1]) <= Date.now(), "the sent time is when it was sent"); // Host refusals, asked with the cto capability (steps 2 to 8 come before // the broker's role.launch check, which cto would fail). diff --git a/packages/harness/README.md b/packages/harness/README.md index 6f87ac731..4d2d6fb96 100644 --- a/packages/harness/README.md +++ b/packages/harness/README.md @@ -182,6 +182,15 @@ it never touches the disk and isn't in the environment or argv. Its stdout goes back to the sender as a RESULT; a failed turn replies `The turn failed: `; an incoming RESULT never gets an automatic reply. Then `message.read`. + + Row 109 (#1603): before each turn the runner calls the broker's `state` + verb and puts a block at the top of the request, headed `Current state, + read at