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]>
406 lines
22 KiB
Diff
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.
|