Researcher's candidate 8977d907 (patch 843fdee9), approved by Filbert in #1669 comment 29193. Built ond58c9c5cdand applied on04155deffwithout fuzz; the patch's +/- lines hash the same on both bases. Row 171's one broker.mjs line (project-name-is-repo, at :2256) is the only change on a manifest path between them.
839 lines
46 KiB
JavaScript
839 lines
46 KiB
JavaScript
// The runner against a real broker on a real socket, with a fake adapter.
|
|
// The broker runs in this process; the runner is a child, as the host
|
|
// starts it (minus the PID namespace, which packages/seat tests).
|
|
|
|
import { test } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import { spawn, spawnSync } from "node:child_process";
|
|
import { once } from "node:events";
|
|
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
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, BusError } from "../../bus/src/broker.mjs";
|
|
import { serve } from "../../bus/src/server.mjs";
|
|
import { EXIT, founderCheck, landingRequest, stateBlock, turnRequest } from "../src/runner.mjs";
|
|
import { typedTools } from "../src/tools.mjs";
|
|
import { resolvedFor, scratch } from "./helpers.mjs";
|
|
|
|
const RUNNER = new URL("../src/runner.mjs", import.meta.url).pathname;
|
|
const FAKE = new URL("./fixtures/fake-adapter.mjs", import.meta.url).pathname;
|
|
const businesses = {
|
|
demo: {
|
|
id: "demo",
|
|
human: "jason",
|
|
arbiters: { technical: "pm", delivery: "pm" },
|
|
launch: { by: "pm", instances: ["coder"], models:{}, limits:{} },
|
|
roles: {
|
|
pm: { authority: { withinRole: ["message.send", "role.launch"], crossRole: [] } },
|
|
coder: { authority: { withinRole: ["message.send"], crossRole: [] } },
|
|
// Row 132 (#1628): the chief of staff's grants and the assistant. Since row 139 (Q169) the
|
|
// chief may end the assistant; with no thread run holding a claim the end refuses not-held.
|
|
// Row 155 (#1649) adds row 141's instance.name for set_name.
|
|
chief: { definition: "chief-of-staff", authority: { withinRole: ["message.send", "role.end", "organization.change", "instance.name"], crossRole: [] } },
|
|
assistant: { definition: "assistant", authority: { withinRole: ["message.send"], crossRole: [] } },
|
|
},
|
|
},
|
|
};
|
|
|
|
test("founderCheck: founder variables, then a needed service without a usable token", () => {
|
|
const policy = { needs: ["gitea"], credentials: [{ service: "gitea", state: "valid" }] };
|
|
assert.equal(founderCheck(policy, {}), null);
|
|
assert.equal(founderCheck({ needs: [] }, { PATH: "/bin" }), null);
|
|
assert.match(founderCheck(policy, { GITEA_TOKEN: "x", SSH_AUTH_SOCK: "/s" }), /GITEA_TOKEN, SSH_AUTH_SOCK/);
|
|
assert.match(founderCheck(policy, { MOSAIC_GITEA_CREDENTIAL_FILE: "" }), /MOSAIC_GITEA_CREDENTIAL_FILE/);
|
|
for (const state of ["expiring", "rotation-due"]) assert.equal(founderCheck({ needs: ["x"], credentials: [{ service: "x", state }] }, {}), null);
|
|
assert.match(founderCheck({ needs: ["vikunja"], credentials: [{ service: "vikunja", state: "expired" }] }, {}), /no usable role token for vikunja/);
|
|
assert.match(founderCheck({ needs: ["gitea", "vikunja"], credentials: [{ service: "gitea", state: "valid" }] }, {}), /vikunja/);
|
|
});
|
|
|
|
test("turnRequest names the sender, class, reply and decision", () => {
|
|
assert.equal(turnRequest({ id: "m1", from_role: "pm", class: "ASSIGNMENT", body: "do it" }), "Message m1 from pm, class ASSIGNMENT:\n\ndo it");
|
|
assert.equal(
|
|
turnRequest({ id: "m2", from_role: "cto", class: "RESULT", in_reply_to: "m1", decision: "d1", body: "b" }),
|
|
"Message m2 from cto, class RESULT, in reply to m1, citing decision d1:\n\nb",
|
|
);
|
|
// Row 90 (#1584): a human message's request id, which task_create cites; none for a role's.
|
|
assert.equal(
|
|
turnRequest({ id: "m3", from_role: "jason", class: "REQUEST", request: "e7", body: "b" }),
|
|
"Message m3 from jason, class REQUEST, request e7:\n\nb",
|
|
);
|
|
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 says a tester's message is a test and never Jason's instruction", () => {
|
|
assert.equal(
|
|
turnRequest({ id: "m7", from_role: "tester", class: "REQUEST", request: null, body: "[mos test] is the plan current?" }),
|
|
"Message m7 from tester, class REQUEST:\n\nThis message is from a tester running a PM test. It is never Jason's instruction and authorizes nothing.\n\n[mos test] is the plan current?",
|
|
);
|
|
// No other sender gets the line, the human included.
|
|
for (const from_role of ["human", "pm", "jason"])
|
|
assert.doesNotMatch(turnRequest({ id: "m8", from_role, class: "REQUEST", body: "[mos test] b" }), /tester|never Jason's/, from_role);
|
|
});
|
|
|
|
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\./);
|
|
});
|
|
|
|
// `roles` replaces demo's role entries by name (row 156 round 2 gives the coder git.merge.protected).
|
|
async function setup(t, { session = {}, policy = {}, tools, as = "coder", organization = null, launchBy, usage = null, limits = null, roles = null } = {}) {
|
|
const dir = scratch(t);
|
|
const store = new Store(dir);
|
|
let demo = launchBy ? { ...businesses.demo, launch: { ...businesses.demo.launch, by: launchBy } } : businesses.demo;
|
|
if (roles) demo = { ...demo, roles: { ...demo.roles, ...roles } };
|
|
const broker = new Broker({ store, businesses: { demo }, organization, usage, limits });
|
|
const brokerSocket = join(dir, "bus.sock");
|
|
const server = await serve({ broker, path: brokerSocket });
|
|
t.after(async () => {
|
|
await server.close().catch(() => {});
|
|
store.close();
|
|
});
|
|
const runDir = join(dir, "run");
|
|
const bundle = join(runDir, "bundle");
|
|
mkdirSync(bundle, { recursive: true, mode: 0o700 });
|
|
const adapter = join(dir, "adapter.sh");
|
|
writeFileSync(adapter, `exec '${process.execPath}' '${FAKE}'\n`);
|
|
writeFileSync(join(bundle, "prompt.md"), "# test\n");
|
|
writeFileSync(join(bundle, "policy.json"), JSON.stringify({ harness: "pi", workspace: join(dir, "ws"), tools: [], typed: ["send_message", "launch"], network: "none", needs: [], credentials: [], ...policy }));
|
|
writeFileSync(join(bundle, "tools.json"), JSON.stringify(tools ?? [
|
|
{ name: "send_message", verb: "message.send", action: "message.send" },
|
|
{ name: "launch", verb: "launch", action: "role.launch" },
|
|
]));
|
|
mkdirSync(join(dir, "ws"));
|
|
const launches = [];
|
|
const launchSocket = join(dir, "launch.sock");
|
|
const launchServer = createServer((s) => {
|
|
s.once("data", (b) => {
|
|
const r = JSON.parse(b);
|
|
launches.push(r);
|
|
s.end(JSON.stringify(r.instance === "coder" ? { ok: false, error: "capacity-full" } : { ok: true, result: { run: "new-run" } }) + "\n");
|
|
});
|
|
});
|
|
await new Promise((r) => launchServer.listen(launchSocket, r));
|
|
t.after(() => launchServer.close());
|
|
writeFileSync(join(runDir, "session.json"), JSON.stringify({
|
|
business: "demo",
|
|
instance: as,
|
|
run: `${as}-run`,
|
|
launchedBy: "pm",
|
|
harness: "pi",
|
|
provider: "fake",
|
|
model: "fake-model",
|
|
brokerSocket,
|
|
launchSocket,
|
|
workspace: join(dir, "ws"),
|
|
sessionDir: join(runDir, "session"),
|
|
adapter,
|
|
bundle: { prompt: join(bundle, "prompt.md"), policy: join(bundle, "policy.json"), tools: join(bundle, "tools.json") },
|
|
toolSocket: join(runDir, "tools.sock"),
|
|
turnMarker: join(runDir, "turn.done"),
|
|
turnEnd: "marker",
|
|
harnessEnv: {},
|
|
pollInterval: 50,
|
|
turnTimeout: 20,
|
|
...session,
|
|
}));
|
|
const cap = broker.bindLaunch({ business: "demo", role: as, run: `${as}-run`, harness: "pi" });
|
|
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");
|
|
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
|
|
// expected an exit fails on the code instead of hanging.
|
|
function startRunner(runDir, cap, env = {}, input = cap === null ? "" : JSON.stringify({ cap }) + "\n") {
|
|
const child = spawn(process.execPath, [RUNNER, runDir], {
|
|
stdio: ["pipe", "ignore", "pipe"],
|
|
env: { PATH: process.env.PATH, ...env },
|
|
});
|
|
let stderr = "";
|
|
child.stderr.on("data", (b) => (stderr += b));
|
|
child.stdin.on("error", () => {});
|
|
child.stdin.end(input);
|
|
const clock = setTimeout(() => child.kill("SIGKILL"), 60_000);
|
|
const exited = once(child, "exit").then(([code, signal]) => {
|
|
clearTimeout(clock);
|
|
return { code, signal, stderr };
|
|
});
|
|
return { child, exited, stderr: () => stderr };
|
|
}
|
|
|
|
async function until(fn, ms = 15_000) {
|
|
const end = Date.now() + ms;
|
|
for (;;) {
|
|
const v = fn();
|
|
if (v) return v;
|
|
if (Date.now() > end) throw new Error("timed out waiting");
|
|
await new Promise((r) => setTimeout(r, 25));
|
|
}
|
|
}
|
|
|
|
const replies = (ctx) => () => {
|
|
const got = ctx.call(ctx.pm, "message.receive");
|
|
ctx.inbox.push(...got);
|
|
return ctx.inbox.length ? ctx.inbox : null;
|
|
};
|
|
|
|
test("a message becomes a turn, the answer goes back as a RESULT, SIGTERM releases and exits 0", async (t) => {
|
|
const ctx = await setup(t);
|
|
ctx.inbox = [];
|
|
const r = startRunner(ctx.runDir, ctx.cap);
|
|
await until(() => ctx.store.get("SELECT 1 FROM role_claims WHERE role='coder' AND op='claim'"));
|
|
const sent = ctx.call(ctx.pm, "message.send", { to: "coder", body: "hello", class: "REQUEST" });
|
|
const [reply] = await until(replies(ctx));
|
|
assert.equal(reply.class, "RESULT");
|
|
assert.equal(reply.from_role, "coder");
|
|
assert.equal(reply.from_run, "coder-run");
|
|
assert.equal(reply.in_reply_to, sent.id);
|
|
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);
|
|
assert.equal(readFileSync(err, "utf8"), "fake adapter: fake-model\n");
|
|
assert.equal(statSync(join(ctx.runDir, "tools.sock")).mode & 0o777, 0o600);
|
|
r.child.kill("SIGTERM");
|
|
const out = await r.exited;
|
|
assert.equal(out.code, EXIT.stopped, out.stderr);
|
|
assert.match(out.stderr, /runner: demo\/coder claimed by run coder-run/);
|
|
assert.equal(ctx.store.get("SELECT op FROM role_claims WHERE role='coder' ORDER BY seq DESC LIMIT 1").op, "release");
|
|
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);
|
|
});
|
|
|
|
// Row 115 (#1609): the human closing the role's decision gives the role a turn. The runner is unchanged;
|
|
// the header cites the decision and the close's human.input answer as the request.
|
|
test("a decision the human resolves becomes a turn citing the decision and the answer", async (t) => {
|
|
const ctx = await setup(t);
|
|
const r = startRunner(ctx.runDir, ctx.cap);
|
|
t.after(() => r.child.kill("SIGKILL"));
|
|
await until(() => ctx.store.get("SELECT 1 FROM role_claims WHERE role='coder' AND op='claim'"));
|
|
const d = ctx.call(ctx.cap, "decision.raise", {
|
|
action: "deploy",
|
|
target: "release-1",
|
|
question: "Ship release-1?",
|
|
options: [{ key: "yes", text: "Allow" }, { key: "no", text: "Decline" }],
|
|
recommendation: "yes",
|
|
blocking: false,
|
|
});
|
|
assert.equal(d.route_to, "human");
|
|
ctx.call(ctx.human, "decision.resolve", { id: d.id, choice: "yes" });
|
|
const m = ctx.store.get("SELECT id,at FROM messages WHERE class='DECISION' AND decision=?", d.id);
|
|
const answer = ctx.store.get("SELECT id FROM events WHERE kind='human.input' AND subject=?", d.id).id;
|
|
const [reply] = await until(() => {
|
|
const got = ctx.call(ctx.human, "message.receive");
|
|
return got.length ? got : null;
|
|
});
|
|
assert.equal(reply.from_role, "coder");
|
|
assert.equal(reply.class, "RESULT");
|
|
assert.equal(reply.in_reply_to, m.id);
|
|
assert.ok(
|
|
reply.body.endsWith(
|
|
`\nMessage ${m.id} from human, sent ${m.at}, class DECISION, citing decision ${d.id}, request ${answer}:\n\n` +
|
|
`Decision ${d.id} on deploy was resolved by jason: yes (Allow).`,
|
|
),
|
|
reply.body,
|
|
);
|
|
await until(() => ctx.store.get("SELECT 1 FROM deliveries WHERE message=? AND op='read'", m.id));
|
|
});
|
|
|
|
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
|
|
// in, so the open on this side returns once a SIGTERM would be caught.
|
|
const file = join(ctx.runDir, "session.json");
|
|
const body = readFileSync(file);
|
|
rmSync(file);
|
|
assert.equal(spawnSync("mkfifo", ["-m", "600", file]).status, 0);
|
|
const child = spawn(process.execPath, [RUNNER, ctx.runDir], { stdio: ["pipe", "ignore", "pipe"], env: { PATH: process.env.PATH } });
|
|
t.after(() => child.kill("SIGKILL"));
|
|
let stderr = "";
|
|
child.stderr.on("data", (b) => (stderr += b));
|
|
const exited = once(child, "exit");
|
|
const fifo = await open(file, "w");
|
|
child.kill("SIGTERM");
|
|
await fifo.write(body);
|
|
await fifo.close();
|
|
child.stdin.end(JSON.stringify({ cap: ctx.cap }) + "\n");
|
|
const [code, signal] = await exited;
|
|
assert.deepEqual([code, signal], [EXIT.stopped, null], stderr);
|
|
assert.match(stderr, /runner: stopped before claim/);
|
|
assert.equal(ctx.store.get("SELECT 1 FROM role_claims WHERE role='coder'"), undefined);
|
|
});
|
|
|
|
test("typed tools carry the runner's capability; launch goes to the host's launch socket", 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.pm, "message.send", { to: "coder", body: 'TOOL send_message {"to":"pm","body":"side note","class":"INFO"}' });
|
|
await until(() => replies(ctx)() && ctx.inbox.length >= 2);
|
|
const [info, result] = ctx.inbox;
|
|
assert.equal(info.class, "INFO");
|
|
assert.equal(info.body, "side note");
|
|
assert.equal(info.from_run, "coder-run");
|
|
assert.equal(JSON.parse(result.body).id, info.id);
|
|
ctx.inbox.length = 0;
|
|
ctx.call(ctx.pm, "message.send", { to: "coder", body: 'TOOL launch {"instance":"coder"}' });
|
|
ctx.call(ctx.pm, "message.send", { to: "coder", body: 'TOOL launch {"instance":"reviewer"}' });
|
|
ctx.call(ctx.pm, "message.send", { to: "coder", body: 'TOOL resolve_decision {"id":"x"}' });
|
|
ctx.call(ctx.pm, "message.send", { to: "coder", body: 'TOOL send_message {"to":"nobody","body":"x"}' });
|
|
await until(() => replies(ctx)() && ctx.inbox.length >= 4);
|
|
assert.deepEqual(ctx.inbox.map((m) => m.body), ["refused: capacity-full", '{"run":"new-run"}', "refused: unknown-tool", "refused: unknown-role"]);
|
|
assert.deepEqual(ctx.launches, [{ cap: ctx.cap, instance: "coder" }, { cap: ctx.cap, instance: "reviewer" }]);
|
|
});
|
|
|
|
// Row 132 (#1628, REQ-ROLE-2, REQ-COS-1, REQ-LIMIT-1): the chief's two tools reach the broker by their verbs
|
|
// with the arguments the session gave, and a refusal comes back with the broker's code.
|
|
// Row 134 (#1669): organization_revision reaches the broker's organization.revision and answers the revision alone.
|
|
test("end_run, organization_revision and change_organization reach the broker with the tool's arguments, and refusals keep their codes", async (t) => {
|
|
const written = [];
|
|
const organization = (...args) => {
|
|
written.push(args);
|
|
if (args[2].revision === "e".repeat(64)) throw Object.assign(new Error("stale"), { code: "revision-conflict" });
|
|
return args[1] === "limits" ? { revision: "c".repeat(64), limits: { total: 7 }, tokens: {} } : { revision: "b".repeat(64) };
|
|
};
|
|
const tools = typedTools({ resolved: resolvedFor(t, "chief") }).filter((x) => ["end_run", "organization_revision", "change_organization"].includes(x.name));
|
|
assert.deepEqual(tools.map((x) => x.name), ["end_run", "organization_revision", "change_organization"]);
|
|
const ctx = await setup(t, { as: "chief", organization, tools, policy: { typed: tools.map((x) => x.name) } });
|
|
ctx.inbox = [];
|
|
ctx.call(ctx.broker.bindLaunch({ business: "demo", role: "coder", run: "coder-run", harness: "pi" }), "role.claim");
|
|
const r = startRunner(ctx.runDir, ctx.cap);
|
|
t.after(() => r.child.kill("SIGKILL"));
|
|
const send = (name, args) => ctx.call(ctx.pm, "message.send", { to: "chief", body: `TOOL ${name} ${JSON.stringify(args)}` });
|
|
const change = { revision: "a".repeat(64), limits: { total: 3 } };
|
|
send("end_run", { role: "coder", reason: "stuck on the same test for an hour" });
|
|
send("end_run", { role: "chief", reason: "my own run" });
|
|
send("end_run", { role: "assistant", reason: "the user's seat" });
|
|
send("organization_revision", {});
|
|
send("organization_revision", { surprise: 1 });
|
|
send("change_organization", change);
|
|
send("change_organization", { ...change, surprise: 1 });
|
|
send("change_organization", { ...change, revision: "e".repeat(64) });
|
|
await until(() => replies(ctx)() && ctx.inbox.length >= 8);
|
|
assert.deepEqual(ctx.inbox.map((m) => m.body), [
|
|
'{"revoked":true}',
|
|
"refused: role-end-refused",
|
|
"refused: not-held",
|
|
'{"revision":"' + "c".repeat(64) + '"}',
|
|
"refused: invalid-request",
|
|
'{"revision":"' + "b".repeat(64) + '"}',
|
|
"refused: invalid-request",
|
|
"refused: revision-conflict",
|
|
]);
|
|
assert.deepEqual({ ...ctx.store.get("SELECT op, reason, by FROM role_claims WHERE role='coder' ORDER BY rowid DESC LIMIT 1") }, { op: "revoke", reason: "stuck on the same test for an hour", by: "chief-run" });
|
|
// The writer saw the tool's arguments and the chief's name; the unknown key never reached it.
|
|
assert.deepEqual(written.map((w) => [w[0], w[1], w[2], w[3]]), [
|
|
["demo", "limits", {}, "chief-of-staff.chief"],
|
|
["demo", "limits.set", change, "chief-of-staff.chief"],
|
|
["demo", "limits.set", { ...change, revision: "e".repeat(64) }, "chief-of-staff.chief"],
|
|
]);
|
|
});
|
|
|
|
// Row 155 (#1649, REQ-MSG-2): set_name reaches name.set with the session's arguments, and the broker's
|
|
// refusals come back with their codes.
|
|
test("set_name reaches the broker's name.set with the tool's arguments, and refusals keep their codes", async (t) => {
|
|
const tools = typedTools({ resolved: resolvedFor(t, "chief") }).filter((x) => x.name === "set_name");
|
|
assert.deepEqual(tools.map((x) => x.name), ["set_name"]);
|
|
const ctx = await setup(t, { as: "chief", tools, policy: { typed: tools.map((x) => x.name) } });
|
|
ctx.inbox = [];
|
|
const r = startRunner(ctx.runDir, ctx.cap);
|
|
t.after(() => r.child.kill("SIGKILL"));
|
|
const send = (args) => ctx.call(ctx.pm, "message.send", { to: "chief", body: `TOOL set_name ${JSON.stringify(args)}` });
|
|
send({ instance: "coder", name: "rex" });
|
|
send({ instance: "coder", name: "pm" });
|
|
await until(() => replies(ctx)() && ctx.inbox.length >= 2);
|
|
assert.deepEqual(ctx.inbox.map((m) => m.body), [
|
|
'{"instance":"coder","name":"rex"}',
|
|
"refused: name-is-instance",
|
|
]);
|
|
assert.deepEqual({ ...ctx.store.get("SELECT instance, name, by FROM instance_names ORDER BY seq DESC LIMIT 1") }, { instance: "coder", name: "rex", by: "chief-run" });
|
|
});
|
|
|
|
test("a RESULT gets no automatic reply; failed turns reply with the reason", async (t) => {
|
|
const ctx = await setup(t, { session: { turnTimeout: 1 } });
|
|
ctx.inbox = [];
|
|
const r = startRunner(ctx.runDir, ctx.cap);
|
|
t.after(() => r.child.kill("SIGKILL"));
|
|
const first = ctx.call(ctx.pm, "message.send", { to: "coder", body: "hi" });
|
|
const res = ctx.call(ctx.pm, "message.send", { to: "coder", body: "thanks", class: "RESULT", in_reply_to: first.id });
|
|
ctx.call(ctx.pm, "message.send", { to: "coder", body: "FAIL" });
|
|
ctx.call(ctx.pm, "message.send", { to: "coder", body: "NOMARKER" });
|
|
ctx.call(ctx.pm, "message.send", { to: "coder", body: "SLEEP" });
|
|
await until(() => replies(ctx)() && ctx.inbox.length >= 4);
|
|
assert.deepEqual(ctx.inbox.map((m) => m.body.replace(/^echo: .*/s, "echo")), [
|
|
"echo",
|
|
"The turn failed: adapter exited 3",
|
|
"The turn failed: pi exited before the turn ended (no turn marker)",
|
|
"The turn failed: turn passed its 1 s wall clock",
|
|
]);
|
|
await until(() => ctx.store.get("SELECT 1 FROM deliveries WHERE message=? AND op='read'", res.id));
|
|
});
|
|
|
|
// Row 146 (#1640): the bus's notice to launch.by is a RESULT from _bus. The runner takes one turn
|
|
// on it, sends no automatic reply and marks it read. Any other class would send a reply to _bus,
|
|
// which the bus refuses; the runner would log the failure and skip the read, leaving the notice
|
|
// unread for good.
|
|
test("the bus's launch notice is one turn with no reply, and is not taken again", async (t) => {
|
|
const ctx = await setup(t, { as: "chief", launchBy: "chief" });
|
|
ctx.inbox = [];
|
|
const r = startRunner(ctx.runDir, ctx.cap);
|
|
t.after(() => r.child.kill("SIGKILL"));
|
|
await until(() => ctx.store.get("SELECT 1 FROM role_claims WHERE role='chief' AND op='claim'"));
|
|
const sent = ctx.call(ctx.human, "message.send", { to: "coder", body: "is the build green?" });
|
|
assert.equal(sent.waiting.told, true);
|
|
const notice = ctx.store.get("SELECT * FROM messages WHERE from_role='_bus' AND in_reply_to=?", sent.id);
|
|
assert.equal(notice.to_role, "chief");
|
|
await until(() => ctx.store.get("SELECT 1 FROM deliveries WHERE message=? AND op='read'", notice.id));
|
|
// A later message is one more turn, so the notice wasn't taken again in between.
|
|
const later = ctx.call(ctx.pm, "message.send", { to: "chief", body: "status?" });
|
|
const [reply] = await until(replies(ctx));
|
|
assert.equal(reply.in_reply_to, later.id);
|
|
assert.deepEqual(readdirSync(join(ctx.runDir, "turns")).sort(), ["0001.stderr", "0002.stderr"]);
|
|
assert.equal(ctx.store.get("SELECT count(*) n FROM deliveries WHERE message=? AND op='delivered'", notice.id).n, 1);
|
|
assert.equal(ctx.store.get("SELECT count(*) n FROM deliveries WHERE message=? AND op='read'", notice.id).n, 1);
|
|
assert.equal(ctx.store.get("SELECT count(*) n FROM messages WHERE in_reply_to=? AND from_run='chief-run'", notice.id).n, 0);
|
|
assert.doesNotMatch(r.stderr(), /reply to .* failed/);
|
|
});
|
|
|
|
test("SIGTERM during a turn kills the turn's process group and still exits 0", async (t) => {
|
|
// A long wall clock, so only the stop path can end the turn in time.
|
|
const ctx = await setup(t, { session: { turnTimeout: 600 } });
|
|
const r = startRunner(ctx.runDir, ctx.cap, { FAKE_PIDS: ctx.pids });
|
|
t.after(() => r.child.kill("SIGKILL"));
|
|
ctx.call(ctx.pm, "message.send", { to: "coder", body: "SLEEP" });
|
|
const pid = Number(await until(() => existsSync(ctx.pids) && readFileSync(ctx.pids, "utf8").trim()));
|
|
const started = Date.now();
|
|
r.child.kill("SIGTERM");
|
|
const out = await Promise.race([r.exited, new Promise((res) => setTimeout(() => res({ code: "still running" }), 8000))]);
|
|
assert.equal(out.code, EXIT.stopped, out.stderr);
|
|
assert.ok(Date.now() - started < 8000);
|
|
await until(() => {
|
|
try {
|
|
process.kill(pid, 0);
|
|
return false;
|
|
} catch {
|
|
return true;
|
|
}
|
|
}, 8000);
|
|
});
|
|
|
|
test("founder credentials stop before the claim (20)", async (t) => {
|
|
const ctx = await setup(t);
|
|
const out = await startRunner(ctx.runDir, ctx.cap, { GH_TOKEN: "x" }).exited;
|
|
assert.equal(out.code, EXIT.founder);
|
|
assert.match(out.stderr, /REQ-CRED-2.*GH_TOKEN/);
|
|
assert.equal(ctx.store.get("SELECT 1 FROM role_claims WHERE role='coder'"), undefined);
|
|
const ctx2 = await setup(t, { policy: { needs: ["gitea"], credentials: [{ service: "gitea", state: "expired" }] } });
|
|
const out2 = await startRunner(ctx2.runDir, ctx2.cap).exited;
|
|
assert.equal(out2.code, EXIT.founder);
|
|
assert.match(out2.stderr, /no usable role token for gitea/);
|
|
});
|
|
|
|
test("a refused claim exits 21; an ended run's capability exits 22", async (t) => {
|
|
const ctx = await setup(t);
|
|
const other = ctx.broker.bindLaunch({ business: "demo", role: "coder", run: "other-run", harness: "pi" });
|
|
ctx.call(other, "role.claim");
|
|
const out = await startRunner(ctx.runDir, ctx.cap).exited;
|
|
assert.equal(out.code, EXIT.claim, out.stderr);
|
|
assert.match(out.stderr, /role.claim refused: role-already-held/);
|
|
const ctx2 = await setup(t);
|
|
ctx2.broker.endLaunch({ business: "demo", run: "coder-run", reason: "stopped", exitCode: 0 });
|
|
assert.equal((await startRunner(ctx2.runDir, ctx2.cap).exited).code, EXIT.session);
|
|
});
|
|
|
|
test("the launch ending under a running session exits 22", async (t) => {
|
|
const ctx = await setup(t);
|
|
const r = startRunner(ctx.runDir, ctx.cap);
|
|
await until(() => ctx.store.get("SELECT 1 FROM role_claims WHERE role='coder' AND op='claim'"));
|
|
ctx.broker.endLaunch({ business: "demo", run: "coder-run", reason: "killed", exitCode: null });
|
|
const out = await r.exited;
|
|
assert.equal(out.code, EXIT.session, out.stderr);
|
|
assert.match(out.stderr, /session stopped working: unauthenticated/);
|
|
});
|
|
|
|
test("a broker that stays unreachable exits 23 after brokerRetries polls", async (t) => {
|
|
const ctx = await setup(t, { session: { brokerRetries: 3 } });
|
|
const r = startRunner(ctx.runDir, ctx.cap);
|
|
await until(() => ctx.store.get("SELECT 1 FROM role_claims WHERE role='coder' AND op='claim'"));
|
|
await ctx.server.close();
|
|
const out = await r.exited;
|
|
assert.equal(out.code, EXIT.broker, out.stderr);
|
|
assert.match(out.stderr, /broker unreachable after 3 tries/);
|
|
});
|
|
|
|
test("a broker that is down at the claim exits 23, not 21", async (t) => {
|
|
const ctx = await setup(t);
|
|
await ctx.server.close();
|
|
const out = await startRunner(ctx.runDir, ctx.cap).exited;
|
|
assert.equal(out.code, EXIT.broker, out.stderr);
|
|
assert.match(out.stderr, /role.claim got no answer from the broker/);
|
|
});
|
|
|
|
test("no capability, or a malformed one, on stdin exits 2", async (t) => {
|
|
const ctx = await setup(t);
|
|
assert.equal((await startRunner(ctx.runDir, null).exited).code, EXIT.usage);
|
|
assert.equal((await startRunner(ctx.runDir, "not-hex").exited).code, EXIT.usage);
|
|
assert.equal((await startRunner(join(ctx.dir, "nope"), ctx.cap).exited).code, EXIT.usage);
|
|
// A valid capability inside an over-long line: the 4096-byte cap refuses it.
|
|
const long = await startRunner(ctx.runDir, ctx.cap, {}, JSON.stringify({ cap: ctx.cap, pad: "x".repeat(5000) }) + "\n").exited;
|
|
assert.equal(long.code, EXIT.usage, long.stderr);
|
|
assert.match(long.stderr, /stdin too large/);
|
|
assert.equal(ctx.store.get("SELECT 1 FROM role_claims WHERE role='coder'"), undefined);
|
|
});
|
|
|
|
test("a missing or malformed policy exits 2 before the claim", async (t) => {
|
|
const ctx = await setup(t);
|
|
const policy = join(ctx.runDir, "bundle", "policy.json");
|
|
writeFileSync(policy, "{");
|
|
const bad = await startRunner(ctx.runDir, ctx.cap).exited;
|
|
assert.equal(bad.code, EXIT.usage, bad.stderr);
|
|
assert.match(bad.stderr, /^runner: /m);
|
|
rmSync(policy);
|
|
const missing = await startRunner(ctx.runDir, ctx.cap).exited;
|
|
assert.equal(missing.code, EXIT.usage, missing.stderr);
|
|
assert.match(missing.stderr, /runner: ENOENT/);
|
|
assert.equal(ctx.store.get("SELECT 1 FROM role_claims WHERE role='coder'"), undefined);
|
|
});
|
|
|
|
|
|
test("REQ-LIMIT-4 runner brackets its actual harness turn before the reply and read receipt", async t => {
|
|
const calls = [];
|
|
let ctx;
|
|
ctx = await setup(t, { usage: { collect: async (session, phase, turn) => {
|
|
calls.push({ phase, turn, run: session.run, started: existsSync(join(ctx.runDir, "turns/0001.stderr")) });
|
|
return { recorded: true };
|
|
} } });
|
|
ctx.inbox = [];
|
|
const runner = startRunner(ctx.runDir, ctx.cap);
|
|
t.after(() => runner.child.kill("SIGKILL"));
|
|
await until(() => ctx.store.get("SELECT 1 FROM role_claims WHERE role='coder' AND op='claim'"));
|
|
const sent = ctx.call(ctx.pm, "message.send", { to: "coder", body: "account this turn", class: "REQUEST" });
|
|
await until(replies(ctx));
|
|
runner.child.kill("SIGTERM");
|
|
assert.equal((await runner.exited).code, 0);
|
|
assert.deepEqual(calls, [
|
|
{ phase: 'begin', turn: sent.id, run: 'coder-run', started: false },
|
|
{ phase: 'end', turn: sent.id, run: 'coder-run', started: true },
|
|
]);
|
|
});
|
|
|
|
// Row 125 (#1619, REQ-LIMIT-4, decision 150 Q4): a run at its limit stops at the end of its turn.
|
|
// The turn's RESULT and read receipt land first; the next receive is refused not-holder, the
|
|
// runner exits as the session gone, and the claim row names the limit.
|
|
test("REQ-LIMIT-4 a runner at its limit stops at the end of its turn: RESULT and read first, then exit 22 and a limit-check revoke", async t => {
|
|
const asked = [];
|
|
const hit = { path: ["tokens", "instances", "coder"], limit: 100, count: 120, unknownTurns: 0 };
|
|
const ctx = await setup(t, {
|
|
usage: { collect: async () => ({ recorded: true }) },
|
|
limits: { mustStop: async (business, subject) => {
|
|
asked.push({ business, subject });
|
|
return { stop: true, check: "token-limit", hit };
|
|
} },
|
|
});
|
|
ctx.inbox = [];
|
|
const runner = startRunner(ctx.runDir, ctx.cap);
|
|
t.after(() => runner.child.kill("SIGKILL"));
|
|
await until(() => ctx.store.get("SELECT 1 FROM role_claims WHERE role='coder' AND op='claim'"));
|
|
const sent = ctx.call(ctx.pm, "message.send", { to: "coder", body: "one turn", class: "REQUEST" });
|
|
const out = await runner.exited;
|
|
assert.equal(out.code, EXIT.session, out.stderr);
|
|
assert.match(out.stderr, /runner: session stopped working: not-holder/);
|
|
const [reply] = await until(replies(ctx));
|
|
assert.equal(reply.class, "RESULT");
|
|
assert.equal(reply.in_reply_to, sent.id);
|
|
assert.ok(ctx.store.get("SELECT 1 FROM deliveries WHERE message=? AND op='read'", sent.id));
|
|
assert.deepEqual(asked, [{ business: "demo", subject: { instance: "coder", provider: null, model: null, account: null } }]);
|
|
const row = ctx.store.get("SELECT op, by, reason, holder_run FROM role_claims WHERE role='coder' ORDER BY seq DESC LIMIT 1");
|
|
assert.deepEqual({ ...row }, { op: "revoke", by: "limit-check", holder_run: "coder-run",
|
|
reason: "limit: token-limit tokens.instances.coder limit 100 count 120" });
|
|
// The RESULT and the read receipt are both older than the revoke.
|
|
const revokedAt = ctx.store.get("SELECT at FROM role_claims WHERE role='coder' ORDER BY seq DESC LIMIT 1").at;
|
|
assert.ok(ctx.store.get("SELECT at FROM messages WHERE id=?", reply.id).at <= revokedAt);
|
|
});
|
|
|
|
// Row 156 (#1651): the session file's landing. The adapter records each turn's request, so a test
|
|
// sees what the turn was given and in which order.
|
|
test("landingRequest is the fixed template, with the decision and task ref and nothing else", () => {
|
|
assert.equal(
|
|
landingRequest({ decision: "d1", task_ref: "vikunja:3/7" }),
|
|
"Landing from the host, decision d1, task vikunja:3/7:\n\nYour merge decision d1 for vikunja:3/7 is approved and unused. Land it now with land_work, task_ref vikunja:3/7 and decision d1. The reply only says the landing started; the gate's outcome comes later as a RESULT from the bus. If land_work refuses decision-consumed, the landing already happened.",
|
|
);
|
|
});
|
|
|
|
function recording(ctx) {
|
|
const file = join(ctx.dir, "requests");
|
|
writeFileSync(join(ctx.dir, "adapter.sh"), `printf '%s\\0' "$MOSAIC_REQUEST" >> '${file}'\nexec '${process.execPath}' '${FAKE}'\n`);
|
|
return () => (existsSync(file) ? readFileSync(file, "utf8").split("\0").slice(0, -1) : []);
|
|
}
|
|
|
|
test("each landing entry is one turn before any message, with the state block and the fixed request; it sends nothing and reads nothing", async (t) => {
|
|
const landing = [{ decision: "d1", task_ref: "vikunja:3/7" }, { decision: "d2", task_ref: "vikunja:3/8" }];
|
|
const ctx = await setup(t, { session: { landing } });
|
|
const requests = recording(ctx);
|
|
ctx.inbox = [];
|
|
// The message waits from before the start; both landing turns still come first.
|
|
const sent = ctx.call(ctx.pm, "message.send", { to: "coder", body: "hello", class: "REQUEST" });
|
|
const r = startRunner(ctx.runDir, ctx.cap);
|
|
t.after(() => r.child.kill("SIGKILL"));
|
|
const [reply] = await until(replies(ctx));
|
|
assert.equal(reply.in_reply_to, sent.id);
|
|
const got = requests();
|
|
assert.equal(got.length, 3);
|
|
for (const [i, e] of landing.entries())
|
|
assert.match(got[i], new RegExp(`^Current state, read at \\S+:\nLaunches: allowed \\(never switched\\)\\.\nOpen tasks: 0\\.\n\n${landingRequest(e).replace(/[.*+?^${}()|[\]\\/]/g, "\\$&")}$`));
|
|
assert.match(got[2], new RegExp(`Message ${sent.id} from pm, sent \\S+, class REQUEST:\n\nhello$`));
|
|
r.child.kill("SIGTERM");
|
|
const out = await r.exited;
|
|
assert.equal(out.code, EXIT.stopped, out.stderr);
|
|
assert.match(out.stderr, /runner: landing turn 1 for decision d1 \(vikunja:3\/7\): ok\n[\s\S]*runner: landing turn 2 for decision d2 \(vikunja:3\/8\): ok\n/);
|
|
// Each landing turn asks usage.begin and usage.end with its decision id. These decisions aren't
|
|
// held in this broker, so both refuse not-recipient (land.test.mjs has a held one); a message
|
|
// turn with no usage configured refuses usage-not-configured instead.
|
|
for (const [i, e] of landing.entries())
|
|
assert.match(out.stderr, new RegExp(`runner: usage baseline unavailable: not-recipient\\n(?:(?!runner: landing turn)[\\s\\S])*?runner: usage outcome unavailable: not-recipient\\n(?:(?!runner: landing turn)[\\s\\S])*?runner: landing turn ${i + 1} for decision ${e.decision} `));
|
|
// The landing turns sent nothing and marked nothing read: one RESULT, one read, both for the message.
|
|
assert.deepEqual(ctx.store.all("SELECT in_reply_to FROM messages WHERE from_run='coder-run'").map((m) => m.in_reply_to), [sent.id]);
|
|
assert.deepEqual(ctx.store.all("SELECT message FROM deliveries WHERE op='read'").map((d) => d.message), [sent.id]);
|
|
assert.deepEqual(readdirSync(join(ctx.runDir, "turns")).sort(), ["0001.stderr", "0002.stderr", "0003.stderr"]);
|
|
});
|
|
|
|
test("an empty landing takes no landing turn: the message is turn 1", async (t) => {
|
|
const ctx = await setup(t, { session: { landing: [] } });
|
|
const requests = recording(ctx);
|
|
ctx.inbox = [];
|
|
const r = startRunner(ctx.runDir, ctx.cap);
|
|
t.after(() => r.child.kill("SIGKILL"));
|
|
await until(() => ctx.store.get("SELECT 1 FROM role_claims WHERE role='coder' AND op='claim'"));
|
|
const sent = ctx.call(ctx.pm, "message.send", { to: "coder", body: "hello", class: "REQUEST" });
|
|
await until(replies(ctx));
|
|
r.child.kill("SIGTERM");
|
|
const out = await r.exited;
|
|
assert.equal(out.code, EXIT.stopped, out.stderr);
|
|
assert.doesNotMatch(out.stderr, /landing/);
|
|
const got = requests();
|
|
assert.equal(got.length, 1);
|
|
assert.match(got[0], new RegExp(`Message ${sent.id} from pm`));
|
|
});
|
|
|
|
// Row 156 round 2 (Filbert's F1 in 29033): while the runner is in a landing turn, the launcher's
|
|
// landing pass must not stop it for a second decision resolved meanwhile. The pass stops a run only
|
|
// when landRelease answers released (packages/cli/tests/launcher.test.mjs has the launcher's side),
|
|
// so this holds a real landing turn open on a real broker and asks landRelease as the pass does.
|
|
test("a run in a landing turn isn't released for a second held decision until its landing turns end and it has read its messages", async (t) => {
|
|
const MERGE = "git.merge.protected";
|
|
const ctx = await setup(t, { roles: { coder: { authority: { withinRole: ["message.send"], crossRole: [MERGE] } } } });
|
|
const merge = (target) => ({
|
|
action: MERGE, target, question: `Merge ${target}?`,
|
|
options: [{ key: "yes", text: "Land it" }, { key: "no", text: "Not yet" }], recommendation: "yes", blocking: false,
|
|
});
|
|
// d1 is raised and resolved before the run starts, by an earlier run of the instance.
|
|
const earlier = ctx.broker.bindLaunch({ business: "demo", role: "coder", run: "coder-0", harness: "pi" });
|
|
ctx.call(earlier, "role.claim");
|
|
const d1 = ctx.call(earlier, "decision.raise", merge("vikunja:1/2"));
|
|
assert.equal(d1.route_to, "pm");
|
|
ctx.call(ctx.pm, "decision.resolve", { id: d1.id, choice: "yes" });
|
|
ctx.broker.endLaunch({ business: "demo", run: "coder-0", reason: "exited" });
|
|
const file = join(ctx.runDir, "session.json");
|
|
writeFileSync(file, JSON.stringify({ ...JSON.parse(readFileSync(file, "utf8")), landing: [{ decision: d1.id, task_ref: "vikunja:1/2" }] }));
|
|
// The adapter's first turn, the landing turn, waits for the go file.
|
|
const started = join(ctx.dir, "started");
|
|
const go = join(ctx.dir, "go");
|
|
writeFileSync(join(ctx.dir, "adapter.sh"), `touch '${started}'\nwhile [ ! -e '${go}' ]; do sleep 0.05; done\nexec '${process.execPath}' '${FAKE}'\n`);
|
|
const r = startRunner(ctx.runDir, ctx.cap);
|
|
t.after(() => r.child.kill("SIGKILL"));
|
|
await until(() => existsSync(started));
|
|
// d2 is resolved during the landing turn, so the landing pass lists it for the running holder.
|
|
const d2 = ctx.call(ctx.cap, "decision.raise", merge("vikunja:1/3"));
|
|
ctx.call(ctx.pm, "decision.resolve", { id: d2.id, choice: "yes" });
|
|
assert.deepEqual(ctx.broker.landHolders("demo").map((h) => [h.instance, h.decision]), [["coder", d1.id], ["coder", d2.id]]);
|
|
const release = () => ctx.broker.landRelease({ business: "demo", run: "coder-run", decision: d2.id });
|
|
assert.deepEqual(release(), { released: false, landing: true });
|
|
await new Promise((resolve) => setTimeout(resolve, 300));
|
|
assert.deepEqual(release(), { released: false, landing: true }, "still in the landing turn");
|
|
assert.equal(ctx.store.get("SELECT op FROM role_claims WHERE role='coder' ORDER BY seq DESC LIMIT 1").op, "claim");
|
|
writeFileSync(go, "");
|
|
// After the landing turn the run asks message.receive and takes both DECISION messages as turns;
|
|
// once it has read them, the next ask releases it.
|
|
await until(() => release().released);
|
|
assert.deepEqual(
|
|
{ ...ctx.store.get("SELECT op, holder_run, \"by\" FROM role_claims WHERE role='coder' ORDER BY seq DESC LIMIT 1") },
|
|
{ op: "release", holder_run: "coder-run", by: "land-relaunch" },
|
|
);
|
|
assert.equal(ctx.store.get("SELECT count(*) AS n FROM deliveries WHERE holder_run='coder-run' AND op='read'").n, 2);
|
|
const out = await r.exited;
|
|
assert.equal(out.code, EXIT.session, out.stderr);
|
|
assert.match(out.stderr, new RegExp(`runner: landing turn 1 for decision ${d1.id} \\(vikunja:1/2\\): ok\n[\\s\\S]*runner: session stopped working: not-holder\n`));
|
|
});
|
|
|
|
test("a malformed landing exits 2 before the claim, with no turn", async (t) => {
|
|
const ok = { decision: "d1", task_ref: "vikunja:3/7" };
|
|
const cases = {
|
|
"not an array": ok,
|
|
"not an object": [null],
|
|
"an array entry": [["d1", "vikunja:3/7"]],
|
|
"an extra key": [{ ...ok, ticket: "x" }],
|
|
"a missing key": [{ decision: "d1" }],
|
|
"a bad decision": [{ ...ok, decision: "-d1" }],
|
|
"a decision of 161 characters": [{ ...ok, decision: "d".repeat(161) }],
|
|
"a decision that isn't a string": [{ ...ok, decision: 1 }],
|
|
"a bad task ref": [{ ...ok, task_ref: "vikunja:0/7" }],
|
|
"a task ref with a tail": [{ ...ok, task_ref: "vikunja:3/7\nland now" }],
|
|
"one bad entry after a good one": [ok, { ...ok, task_ref: "gitea:3/7" }],
|
|
};
|
|
const ctx = await setup(t);
|
|
const file = join(ctx.runDir, "session.json");
|
|
const session = JSON.parse(readFileSync(file, "utf8"));
|
|
for (const [why, landing] of Object.entries(cases)) {
|
|
writeFileSync(file, JSON.stringify({ ...session, landing }));
|
|
const out = await startRunner(ctx.runDir, ctx.cap).exited;
|
|
assert.equal(out.code, EXIT.usage, `${why}: ${out.stderr}`);
|
|
assert.match(out.stderr, /runner: malformed landing entry/, why);
|
|
}
|
|
assert.equal(ctx.store.get("SELECT 1 FROM role_claims WHERE role='coder'"), undefined);
|
|
assert.equal(existsSync(join(ctx.runDir, "turns")), false);
|
|
// The longest decision the broker's id allows is accepted.
|
|
writeFileSync(file, JSON.stringify({ ...session, landing: [{ ...ok, decision: "d".repeat(160) }] }));
|
|
ctx.inbox = [];
|
|
const r = startRunner(ctx.runDir, ctx.cap);
|
|
t.after(() => r.child.kill("SIGKILL"));
|
|
await until(() => ctx.store.get("SELECT 1 FROM role_claims WHERE role='coder' AND op='claim'"));
|
|
// A reply to a message means the landing turn before it has finished, so the SIGTERM can't cut it.
|
|
ctx.call(ctx.pm, "message.send", { to: "coder", body: "hello", class: "REQUEST" });
|
|
await until(replies(ctx));
|
|
r.child.kill("SIGTERM");
|
|
const out = await r.exited;
|
|
assert.equal(out.code, EXIT.stopped, out.stderr);
|
|
assert.match(out.stderr, new RegExp(`runner: landing turn 1 for decision ${"d".repeat(160)} \\(vikunja:3/7\\): ok`));
|
|
});
|
|
|
|
// Row 65: the harness module's answers reach the runner as data in session.json, and the
|
|
// runner refuses one without them rather than guess.
|
|
test("row 65 a session.json without a turnEnd of marker or exit, or a harnessEnv of strings, exits 2 before the claim", async (t) => {
|
|
const cases = {
|
|
"no turnEnd": { turnEnd: undefined },
|
|
"another turnEnd": { turnEnd: "agent_end" },
|
|
"no harnessEnv": { harnessEnv: undefined },
|
|
"a null harnessEnv": { harnessEnv: null },
|
|
"an array harnessEnv": { harnessEnv: ["MOSAIC_THINKING=high"] },
|
|
"a number in harnessEnv": { harnessEnv: { MOSAIC_THINKING: 3 } },
|
|
};
|
|
const ctx = await setup(t);
|
|
const file = join(ctx.runDir, "session.json");
|
|
const session = JSON.parse(readFileSync(file, "utf8"));
|
|
for (const [why, change] of Object.entries(cases)) {
|
|
writeFileSync(file, JSON.stringify({ ...session, ...change }));
|
|
const out = await startRunner(ctx.runDir, ctx.cap).exited;
|
|
assert.equal(out.code, EXIT.usage, `${why}: ${out.stderr}`);
|
|
assert.match(out.stderr, /runner: session\.json has no turnEnd of marker or exit, or no harnessEnv of strings/, why);
|
|
}
|
|
assert.equal(ctx.store.get("SELECT 1 FROM role_claims WHERE role='coder'"), undefined);
|
|
});
|
|
|
|
test("row 65 harnessEnv reaches the adapter, and an exit harness needs no turn marker", async (t) => {
|
|
const ctx = await setup(t, { session: { turnEnd: "exit", harnessEnv: { MOSAIC_THINKING: "xhigh" } } });
|
|
ctx.inbox = [];
|
|
const r = startRunner(ctx.runDir, ctx.cap);
|
|
t.after(() => r.child.kill("SIGKILL"));
|
|
ctx.call(ctx.pm, "message.send", { to: "coder", body: "ENV MOSAIC_THINKING" });
|
|
ctx.call(ctx.pm, "message.send", { to: "coder", body: "NOMARKER" });
|
|
await until(() => replies(ctx)() && ctx.inbox.length >= 2);
|
|
assert.deepEqual(ctx.inbox.map((m) => m.body.trim()), ["env: xhigh", "half an answer"]);
|
|
});
|