Files
stack/agents/researcher/work/queue-109/row109.patch
T
jason.woltjeandClaude Sonnet 5.5 8ea93f556e docs: Researcher row 109 candidate packet (#1603, PM reads current launch state and task status before it plans)
Candidate patch (state verb, runner state block, header time, pm.md rule, fake-adapter fixture), six new harness tests, six mutants, full suite outputs, candidate manifest.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-13 18:10:24 -05:00

406 lines
22 KiB
Diff

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..7565e82da 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 and reader 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..3a14c7ae9
--- /dev/null
+++ b/packages/bus/tests/state.test.mjs
@@ -0,0 +1,89 @@
+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: `<p>${i}</p>`, 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/);
+});
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: <reason>`; 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 <time>`: the launch state with the time and id of the event that
+ set it, and the open tasks (refs and titles, at most the stated cap, with
+ the total). The block is the truth over any older message. The message
+ header after it names when the message was sent. A failed read fails the
+ turn without running the adapter: the sender gets `The turn failed: could
+ not read the current state: <code>`, and the message is read.
5. SIGTERM or SIGINT kills the turn's process group, releases the role and
exits 0. The handlers go in before anything else, because pid 1 of a
PID namespace never sees a signal it has no handler for; a stop before
diff --git a/packages/harness/src/runner.mjs b/packages/harness/src/runner.mjs
index 00595542b..f806cabed 100644
--- a/packages/harness/src/runner.mjs
+++ b/packages/harness/src/runner.mjs
@@ -62,7 +62,10 @@ export function founderCheck(policy, env) {
// The request text one message becomes.
export function turnRequest(m) {
- const head = [`Message ${m.id} from ${m.from_role}`, `class ${m.class}`];
+ const head = [`Message ${m.id} from ${m.from_role}`];
+ // Row 109 (#1603): when the sender sent it, so a message that waited reads as old.
+ if (m.at) head.push(`sent ${m.at}`);
+ head.push(`class ${m.class}`);
if (m.in_reply_to) head.push(`in reply to ${m.in_reply_to}`);
if (m.decision) head.push(`citing decision ${m.decision}`);
// Row 90 (#1584): the human input id task_create cites as its request.
@@ -70,6 +73,22 @@ export function turnRequest(m) {
return `${head.join(", ")}:\n\n${m.body}`;
}
+// Row 109 (#1603): the block at the top of a turn's request. It is what the broker said at `readAt`,
+// the truth for launch state and task status over any older message.
+export function stateBlock(state, readAt) {
+ const { launch, tasks } = state;
+ const since = launch.at ? ` since ${launch.at} (event ${launch.event})` : " (never switched)";
+ const shown = tasks.open.length < tasks.total ? `, the first ${tasks.open.length} of cap ${tasks.cap}` : "";
+ const open = tasks.open.map((t) => `- ${t.task_ref} ${t.title ?? ""}`.trimEnd());
+ return [
+ `Current state, read at ${readAt}:`,
+ `Launches: ${launch.state}${since}.`,
+ `Open tasks: ${tasks.total}${shown}.`,
+ ...open,
+ "",
+ ].join("\n");
+}
+
function clip(text) {
return text.length > BODY_MAX ? text.slice(0, BODY_MAX) + "\n[answer cut at 32000 characters]" : text;
}
@@ -339,7 +358,23 @@ export async function main(runDir, { stdin = process.stdin, env = process.env, l
}
for (const m of messages) {
if (stopping) break loop;
- const turn = await runTurn({ session, request: turnRequest(m), n: ++n, env: turnEnv, onChild: (c) => (child = c) });
+ // Row 109 (#1603): read the state now, not when the message was sent. A failed read fails the turn.
+ let block = null;
+ let unread = null;
+ try {
+ block = stateBlock(await client.call("state"), new Date().toISOString());
+ } catch (e) {
+ if (SESSION_GONE.has(e.code)) {
+ log(`runner: session stopped working: ${e.code}`);
+ code = EXIT.session;
+ break loop;
+ }
+ unread = `could not read the current state: ${e.code ?? e.message}`;
+ }
+ ++n;
+ const turn = unread
+ ? { ok: false, reason: unread }
+ : await runTurn({ session, request: `${block}\n${turnRequest(m)}`, n, env: turnEnv, onChild: (c) => (child = c) });
if (stopping) break loop;
if (!turn.ok) log(`runner: turn ${n} for message ${m.id} failed: ${turn.reason}`);
try {
diff --git a/packages/harness/tests/fixtures/fake-adapter.mjs b/packages/harness/tests/fixtures/fake-adapter.mjs
index 4024172d1..4e1ec9bd3 100644
--- a/packages/harness/tests/fixtures/fake-adapter.mjs
+++ b/packages/harness/tests/fixtures/fake-adapter.mjs
@@ -11,7 +11,8 @@ import { appendFileSync, writeFileSync } from "node:fs";
import { callTool } from "../../src/tools.mjs";
const request = process.env.MOSAIC_REQUEST ?? "";
-const body = request.split("\n\n").slice(1).join("\n\n");
+// The message body follows the "Message ...:" header line; a state block (row 109) may sit above it.
+const body = request.match(/^Message [^\n]*:\n\n([\s\S]*)$/m)?.[1] ?? request.split("\n\n").slice(1).join("\n\n");
if (process.env.FAKE_PIDS) appendFileSync(process.env.FAKE_PIDS, `${process.pid}\n`);
process.stderr.write(`fake adapter: ${process.env.MOSAIC_MODEL}\n`);
const done = () => writeFileSync(process.env.MOSAIC_TURN_MARKER, "done\n");
diff --git a/packages/harness/tests/runner.test.mjs b/packages/harness/tests/runner.test.mjs
index 777d16148..12b683cb7 100644
--- a/packages/harness/tests/runner.test.mjs
+++ b/packages/harness/tests/runner.test.mjs
@@ -11,9 +11,9 @@ import { open } from "node:fs/promises";
import { createServer } from "node:net";
import { join } from "node:path";
import { Store } from "../../bus/src/store.mjs";
-import { Broker } from "../../bus/src/broker.mjs";
+import { Broker, BusError } from "../../bus/src/broker.mjs";
import { serve } from "../../bus/src/server.mjs";
-import { EXIT, founderCheck, turnRequest } from "../src/runner.mjs";
+import { EXIT, founderCheck, stateBlock, turnRequest } from "../src/runner.mjs";
import { scratch } from "./helpers.mjs";
const RUNNER = new URL("../src/runner.mjs", import.meta.url).pathname;
@@ -56,6 +56,25 @@ test("turnRequest names the sender, class, reply and decision", () => {
assert.equal(turnRequest({ id: "m4", from_role: "cto", class: "RESULT", request: null, body: "b" }), "Message m4 from cto, class RESULT:\n\nb");
});
+// Row 109 (#1603): the header carries when the message was sent, not when it is read.
+test("turnRequest shows the message's own sent time, a day old as well as new", () => {
+ const sent = "2026-10-12T09:30:00.000Z";
+ assert.equal(
+ turnRequest({ id: "m5", from_role: "jason", class: "REQUEST", at: sent, body: "resume" }),
+ `Message m5 from jason, sent ${sent}, class REQUEST:\n\nresume`,
+ );
+ assert.equal(turnRequest({ id: "m6", from_role: "cto", class: "RESULT", at: sent, in_reply_to: "m5", body: "b" }), `Message m6 from cto, sent ${sent}, class RESULT, in reply to m5:\n\nb`);
+});
+
+test("stateBlock names the launch state with its time and event, and the open tasks with the cap", () => {
+ const read = "2026-10-13T10:00:00.000Z";
+ const restored = stateBlock({ launch: { state: "allowed", at: "2026-10-12T20:00:00.000Z", event: "ev-9" }, tasks: { open: [{ task_ref: "vikunja:3/7", title: "Seven" }], total: 1, cap: 50 } }, read);
+ assert.equal(restored, "Current state, read at 2026-10-13T10:00:00.000Z:\nLaunches: allowed since 2026-10-12T20:00:00.000Z (event ev-9).\nOpen tasks: 1.\n- vikunja:3/7 Seven\n");
+ const capped = stateBlock({ launch: { state: "allowed", at: null, event: null }, tasks: { open: [{ task_ref: "vikunja:3/1", title: "One" }], total: 9, cap: 1 } }, read);
+ assert.match(capped, /Launches: allowed \(never switched\)\./);
+ assert.match(capped, /Open tasks: 9, the first 1 of cap 1\./);
+});
+
async function setup(t, { session = {}, policy = {}, tools } = {}) {
const dir = scratch(t);
const store = new Store(dir);
@@ -113,7 +132,8 @@ async function setup(t, { session = {}, policy = {}, tools } = {}) {
const pm = broker.bindLaunch({ business: "demo", role: "pm", run: "pm-run", harness: "pi" });
const call = (c, verb, args = {}) => broker.request(c, { verb, args });
call(pm, "role.claim");
- return { dir, runDir, store, broker, server, cap, pm, call, launches, pids: join(dir, "pids") };
+ const human = broker.bindHuman({ business: "demo", human: "jason", via: "cli", outsideAgent: true });
+ return { dir, runDir, store, broker, server, cap, pm, human, call, launches, pids: join(dir, "pids") };
}
// A runner that hasn't exited after a minute is killed, so a test that
@@ -162,7 +182,8 @@ test("a message becomes a turn, the answer goes back as a RESULT, SIGTERM releas
assert.equal(reply.from_role, "coder");
assert.equal(reply.from_run, "coder-run");
assert.equal(reply.in_reply_to, sent.id);
- assert.equal(reply.body, `echo: Message ${sent.id} from pm, class REQUEST:\n\nhello`);
+ const sentAt = ctx.store.get("SELECT at FROM messages WHERE id=?", sent.id).at;
+ assert.match(reply.body, new RegExp(`^echo: Current state, read at \\S+:\nLaunches: allowed \\(never switched\\)\\.\nOpen tasks: 0\\.\n\nMessage ${sent.id} from pm, sent ${sentAt}, class REQUEST:\n\nhello$`));
await until(() => ctx.store.get("SELECT 1 FROM deliveries WHERE message=? AND op='read'", sent.id));
const err = join(ctx.runDir, "turns", "0001.stderr");
assert.equal(statSync(err).mode & 0o777, 0o600);
@@ -176,6 +197,85 @@ test("a message becomes a turn, the answer goes back as a RESULT, SIGTERM releas
assert.ok(!existsSync(join(ctx.runDir, "tools.sock")));
});
+// Row 109 (#1603): each turn reads the state when it starts. The fake adapter echoes the request,
+// so a reply shows the block the turn was given.
+const ask = async (ctx, body = "what now") => {
+ const before = ctx.inbox.length;
+ ctx.call(ctx.pm, "message.send", { to: "coder", body, class: "REQUEST" });
+ await until(() => replies(ctx)() && ctx.inbox.length > before);
+ return ctx.inbox.at(-1);
+};
+const snapshot = (i, over = {}, read = "2026-10-12T12:00:00.000Z") => ({
+ task_ref: `vikunja:3/${i}`,
+ updated: read,
+ digest: "d".repeat(64),
+ fields: { project_id: 3, title: `Task ${i}`, description: "", done: false, bucket: 11, ...over },
+ via: "board",
+ read_at: read,
+});
+
+test("launches revoked and then restored: the turn's block reads allowed and gives the restore time", async (t) => {
+ const ctx = await setup(t);
+ ctx.inbox = [];
+ const r = startRunner(ctx.runDir, ctx.cap);
+ t.after(() => r.child.kill("SIGKILL"));
+ ctx.call(ctx.human, "launch.revoke");
+ assert.match((await ask(ctx)).body, /\nLaunches: revoked since \S+ \(event \S+\)\.\n/);
+ ctx.call(ctx.human, "launch.restore");
+ const restored = ctx.store.get("SELECT id,at FROM events WHERE kind='launch.restored'");
+ assert.ok((await ask(ctx)).body.includes(`\nLaunches: allowed since ${restored.at} (event ${restored.id}).\n`));
+});
+
+test("a task closed on the tracker is missing from the next turn's open list", async (t) => {
+ const ctx = await setup(t);
+ ctx.inbox = [];
+ ctx.broker.recordTask({ business: "demo", snapshots: [snapshot(1), snapshot(2)] });
+ const r = startRunner(ctx.runDir, ctx.cap);
+ t.after(() => r.child.kill("SIGKILL"));
+ const first = (await ask(ctx)).body;
+ assert.match(first, /\nOpen tasks: 2\.\n- vikunja:3\/1 Task 1\n- vikunja:3\/2 Task 2\n/);
+ ctx.broker.recordTask({ business: "demo", snapshots: [snapshot(2, { done: true }, "2026-10-12T13:00:00.000Z")] });
+ const second = (await ask(ctx)).body;
+ assert.match(second, /\nOpen tasks: 1\.\n- vikunja:3\/1 Task 1\n\nMessage /);
+ assert.ok(!second.includes("vikunja:3/2"));
+});
+
+test("a message read a day after it was sent shows the time it was sent", async (t) => {
+ const ctx = await setup(t);
+ ctx.inbox = [];
+ const sent = ctx.call(ctx.pm, "message.send", { to: "coder", body: "resume the launch", class: "REQUEST" });
+ const sentAt = ctx.store.get("SELECT at FROM messages WHERE id=?", sent.id).at;
+ // The broker's clock jumps a day before the runner reads: the header keeps the original time.
+ const now = Date.now;
+ Date.now = () => now() + 86_400_000;
+ t.after(() => (Date.now = now));
+ const r = startRunner(ctx.runDir, ctx.cap);
+ t.after(() => r.child.kill("SIGKILL"));
+ const [reply] = await until(replies(ctx));
+ assert.ok(reply.body.includes(`Message ${sent.id} from pm, sent ${sentAt}, class REQUEST:`));
+ assert.ok(Date.parse(reply.at) - Date.parse(sentAt) >= 86_000_000);
+});
+
+test("a failed state read fails the turn: no adapter runs, the sender hears why, the message is read", async (t) => {
+ const ctx = await setup(t);
+ ctx.inbox = [];
+ const request = ctx.broker.request.bind(ctx.broker);
+ ctx.broker.request = (cap, req) => {
+ if (req.verb === "state") throw new BusError("state-unavailable");
+ return request(cap, req);
+ };
+ const r = startRunner(ctx.runDir, ctx.cap);
+ t.after(() => r.child.kill("SIGKILL"));
+ const sent = ctx.call(ctx.pm, "message.send", { to: "coder", body: "plan the week", class: "REQUEST" });
+ const [reply] = await until(replies(ctx));
+ assert.equal(reply.class, "RESULT");
+ assert.equal(reply.in_reply_to, sent.id);
+ assert.equal(reply.body, "The turn failed: could not read the current state: state-unavailable");
+ await until(() => ctx.store.get("SELECT 1 FROM deliveries WHERE message=? AND op='read'", sent.id));
+ assert.ok(!existsSync(join(ctx.runDir, "turns", "0001.stderr")));
+ assert.match(r.stderr(), /turn 1 for message \S+ failed: could not read the current state: state-unavailable/);
+});
+
test("a SIGTERM before the claim stops the runner with exit 0 and no claim", async (t) => {
const ctx = await setup(t);
// session.json becomes a FIFO: main opens it right after its handlers go
diff --git a/roles/pm.md b/roles/pm.md
index 522ac9c0e..8ef433f58 100644
--- a/roles/pm.md
+++ b/roles/pm.md
@@ -53,5 +53,10 @@ cross-role. Raise it as a decision for the technical arbiter.
- You act on the tracker and on Gitea only through the typed tools the
broker gives you. You never hold a token.
+- Each turn opens with a "Current state" block, read when the turn
+ started. It is the truth for launch state and task status. A message
+ or a note of yours that disagrees with it is out of date. Before you
+ plan to resume or launch work on a task, check the task with
+ `get_task`.
- Send messages through the bus, addressed to a role, not a person.
- Report a blocker as soon as you find it, with what you tried.