trantor 0.18.82 → 0.18.86

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,11 +2,12 @@
2
2
  // trantor crew runner — keeps a crew agent alive without burning tokens: it long-polls the bus
3
3
  // (zero tokens) and resumes the CLI with each message. Usage: node crew-runner.mjs <agent> [dir]
4
4
  import { execSync, spawnSync, spawn } from "node:child_process";
5
- import { readFileSync, writeFileSync, unlinkSync, existsSync, appendFileSync, mkdirSync, realpathSync } from "node:fs";
5
+ import { readFileSync, writeFileSync, unlinkSync, existsSync, appendFileSync, mkdirSync, realpathSync, statSync } from "node:fs";
6
6
  import { join, basename } from "node:path";
7
7
  import { homedir } from "node:os";
8
8
  import { resolveProject, resolveHub, withEnvFiles, hostId } from "../lib/project.mjs";
9
9
  import { resolveSecrets, shadowEnv, withSecretExports } from "../lib/secrets.mjs";
10
+ import { runnerPersona } from "../lib/persona.mjs";
10
11
  import { loadOrCreate } from "../lib/identity.mjs";
11
12
  import { signedHeaders } from "../lib/signed-fetch.mjs";
12
13
  import { ensureEnrolled } from "../lib/enroll.mjs";
@@ -26,12 +27,14 @@ import {
26
27
  senderProjectOf, isLinkedProject, stateSkipReason, isMessageCardTitle, OPEN_CARD_STATUSES,
27
28
  CUT_CHAIN_PARK_MIN, cutChainEvidence, isBoundedPark, askDuringTurn, askedVerdict,
28
29
  } from "../lib/turn-policy.mjs";
30
+ import { snapshotBeforeCut } from "./cut-snapshot.mjs";
29
31
  import { ocTurnUsage, dshTurnUsage, usageTotal } from "../lib/turn-usage.mjs";
30
32
  import { listContainers, newContainers, recordContainers, statePathFor as dockerStatePathFor, sweep as dockerSweep } from "../lib/docker-janitor.mjs";
31
33
  import {
32
34
  auditDutyNudges, claimDutyNudges, claudeTranscriptDir, dutyEscalations, dutyNudgeDirective,
33
- observedDutyNudgeIds, requeueMissingWakeMessages, shedExpiredHubAlerts,
35
+ observedDutyNudgeIds, requeueMissingWakeMessages, releaseDutyNudgeClaims, shedExpiredHubAlerts,
34
36
  } from "../lib/duty-nudges.mjs";
37
+ import { reserveNudgeIds, releaseNudgeIds } from "../lib/nudge-ledger.mjs";
35
38
  import { dutyRecipientResolver } from "../lib/duty-recipient.mjs";
36
39
  import { cliEffortFlag } from "../lib/model-catalog.mjs";
37
40
  import {
@@ -43,8 +46,37 @@ import { resolveStateFlags } from "../lib/state/flags.mjs";
43
46
 
44
47
  import { awaitSwapRelease } from "./crew/stage.mjs";
45
48
 
46
- await awaitSwapRelease();
47
- const AGENT = process.env.CREW_SWAP_AGENT || process.argv[2];
49
+ const releaseState = process.env.CREW_RELEASE_STATE ? JSON.parse(process.env.CREW_RELEASE_STATE) : null;
50
+ delete process.env.CREW_RELEASE_STATE;
51
+ const packagePath = new URL("../package.json", import.meta.url);
52
+ function installedVersion() {
53
+ try {
54
+ statSync(packagePath);
55
+ return JSON.parse(readFileSync(packagePath, "utf8")).version;
56
+ } catch { return ""; }
57
+ }
58
+ const bootVersion = installedVersion();
59
+ const attemptedVersions = new Set(releaseState?.attempted || []);
60
+ function newerVersion(next, previous) {
61
+ const parse = value => /^(\d+)\.(\d+)\.(\d+)(?:-([\w.-]+))?(?:\+[\w.-]+)?$/.exec(value || "");
62
+ const a = parse(next), b = parse(previous);
63
+ if (!a || !b) return false;
64
+ for (let i = 1; i <= 3; i++) if (+a[i] !== +b[i]) return +a[i] > +b[i];
65
+ if (!a[4] || !b[4]) return !!b[4] && !a[4];
66
+ const ap = a[4].split("."), bp = b[4].split(".");
67
+ for (let i = 0; i < Math.max(ap.length, bp.length); i++) {
68
+ if (ap[i] === bp[i]) continue;
69
+ if (ap[i] === undefined || bp[i] === undefined) return bp[i] === undefined;
70
+ const an = /^\d+$/.test(ap[i]), bn = /^\d+$/.test(bp[i]);
71
+ if (an && bn) return +ap[i] > +bp[i];
72
+ if (an !== bn) return bn;
73
+ return ap[i] > bp[i];
74
+ }
75
+ return false;
76
+ }
77
+ if (!releaseState) await awaitSwapRelease();
78
+ const AGENT = process.env.CREW_SWAP_AGENT || process.env.CREW_AGENT || process.argv[2];
79
+ const PERSONA = runnerPersona(process.env, AGENT);
48
80
  const DIR = process.argv[3] || process.cwd();
49
81
  // Crew agents MUST share the orchestrator's project key (one repo = one lane).
50
82
  // RELAY_PROJECT is inherited from crew.mjs (the host's resolved key); else fall
@@ -58,7 +90,7 @@ function gitOut(args, cwd = DIR) {
58
90
  }
59
91
 
60
92
  function seatWorktree(sourceDir) {
61
- const wt = ensureSeatWorktree({ sourceDir, project: PROJ, agent: AGENT });
93
+ const wt = ensureSeatWorktree({ sourceDir, project: PROJ, agent: PERSONA });
62
94
  // #7760: a worktree is a git checkout — it lacks the gitignored files the build needs and cannot
63
95
  // resolve relative sibling packages. The project's declaration provides both before the first turn.
64
96
  if (wt.root) {
@@ -99,7 +131,7 @@ function ocSid(dir) {
99
131
  // RUNNER_SESSION override: an orchestrator seat (bin/orchestrate.mjs) runs the same CLI as a crew
100
132
  // seat but must live on the bus under its own name (claude-orch:proj), or it would collide with a
101
133
  // plain claude crew seat on the same project.
102
- const SESSION = process.env.RUNNER_SESSION || `${AGENT}:${PROJ}`;
134
+ const SESSION = process.env.RUNNER_SESSION || `${PERSONA}:${PROJ}`;
103
135
  // One keypair per seat, so `deepseek:crebral` and `deepseek:trantor` are genuinely different
104
136
  // identities on the bus rather than one shared string label.
105
137
  const identity = loadOrCreate(SESSION, "agent");
@@ -126,10 +158,10 @@ process.on("unhandledRejection", (e) => { console.log(`\x1b[31m[runner] UNHANDLE
126
158
  const log = (s) => console.log(`\x1b[38;5;43m[runner]\x1b[0m ${redactKeys(String(s))}`);
127
159
  const LOGDIR = join(homedir(), ".agent-bus", "logs");
128
160
  try { mkdirSync(LOGDIR, { recursive: true }); } catch {}
129
- let TURN = 0;
130
- const telemetry = (rec) => { try { appendFileSync(join(LOGDIR, `${AGENT}-${PROJ}.jsonl`), JSON.stringify(rec) + "\n"); } catch {} };
161
+ let TURN = releaseState?.turn || 0;
162
+ const telemetry = (rec) => { try { appendFileSync(join(LOGDIR, `${PERSONA}-${PROJ}.jsonl`), JSON.stringify(rec) + "\n"); } catch {} };
131
163
  // The boot line records the HUB this runner bound to, so a split-brain is diagnosable from disk.
132
- telemetry({ ts: Date.now(), agent: AGENT, project: PROJ, boot: true, hub: HUB });
164
+ telemetry({ ts: Date.now(), persona: PERSONA, agent: AGENT, project: PROJ, boot: true, hub: HUB, pid: process.pid, version: bootVersion });
133
165
  // A seat can open a terminal window on a machine whose owner never asked for one and does not know
134
166
  // what they are looking at. "◤ CLAUDE ◢ trantor crew · fleet" tells that person nothing: not what
135
167
  // started, not what it will do, not how to stop it. RUNNER_TITLE names it in full and RUNNER_ABOUT
@@ -176,7 +208,7 @@ function cmuxStatus(value, color, icon = "robot", opts = {}) {
176
208
  // Label with the REAL seat identity (this is the DISPLAY path, distinct from the bus identity).
177
209
  // Pill = "<agent> · <state>" in the agent's brand color; alerts keep their alarm color.
178
210
  const col = opts.alert ? color : (BRAND_HEX[AGENT.toLowerCase()] || color);
179
- try { spawnSync(CMUX_BIN, ["set-status", SESSION, `${AGENT} · ${value}`, "--color", col, "--icon", icon, "--priority", String(opts.priority ?? 0)], { stdio: "ignore", timeout: 1500, env: { ...process.env, CMUX_QUIET: "1" } }); } catch {}
211
+ try { spawnSync(CMUX_BIN, ["set-status", SESSION, `${PERSONA} · ${AGENT} · ${value}`, "--color", col, "--icon", icon, "--priority", String(opts.priority ?? 0)], { stdio: "ignore", timeout: 1500, env: { ...process.env, CMUX_QUIET: "1" } }); } catch {}
180
212
  }
181
213
  // herdr drops a pane's agent registration when the process inside exits, and a seat's CLI exits
182
214
  // every turn, so re-report at each turn boundary. Argument order: pane id FIRST, then the flags.
@@ -186,7 +218,7 @@ function herdrAgent(state) {
186
218
  if (!existsSync(f)) return;
187
219
  const row = readFileSync(f, "utf8").split("\n")
188
220
  .map(l => l.split("\t"))
189
- .filter(c => c.length >= 4 && c[0] === PROJ && c[1] === "herdr" && c[2] === AGENT)
221
+ .filter(c => c.length >= 4 && c[0] === PROJ && c[1] === "herdr" && c[2] === PERSONA)
190
222
  .pop();
191
223
  if (!row || !row[3]) return;
192
224
  spawnSync("herdr", ["pane", "report-agent", row[3], "--source", "crew", "--agent", AGENT, "--state", state],
@@ -258,7 +290,7 @@ const EFFORT_FLAG = EFFORT ? cliEffortFlag(DRIVER, EFFORT) : { flag: "", text: "
258
290
 
259
291
  // RUNNER_RULES / RUNNER_KICKOFF env overrides: the runner is also the substrate for non-crew
260
292
  // always-on seats (the fleet DUTY agent, bin/duty.mjs) whose doctrine is not "work your card".
261
- const RULES = process.env.RUNNER_RULES || `Rules: you are ${SESSION} on the trantor crew. Before starting a card, read YOUR card: relay_board with card:<id> (the card, its deps, its notes, and the last five done cards whose title shares a word); never the whole board. If your wake names no card id (or cites only a message-card), make relay_board with mine:true your FIRST call — it lists the cards assigned to you (doing/testing/todo, newest first, full card shape); work your newest one (#7763). Work your assigned file(s), report on the bus (relay_send, <280 chars), move your Kanban card as you go with a NOTE saying what you did (doing -> testing, then STOP: you never close your own card to done — the orchestrator runs the card's drill on the built artifact and closes it, and the hub refuses a move to done on a card with no drill line anyway; in 'testing' run YOUR OWN test file — never the full npm test, suites collide across seats — plus \`node bin/slop-gate.mjs\` when the repo has one: it lints ONLY your changed files against the anti-slop rules, and a card must not reach testing with slop-gate failing; use 'failed' + a report if anything breaks). If your work starts containers or a dev server (\`supabase start\`, \`docker run\`, a watch process), stop them before moving your card to testing and name them in the note — the runner also stops containers it saw your turn create when the card closes, but the first responsibility is yours (#9778). If a contract omits a fact you cannot proceed without, ASK — never invent the value: relay_ask(<card>, <question>) blocks the card with your question, keeps the turn owed (no park, no failure), and resumes you when the assigner's answer lands; an invented value that reads as reasoned is the worst outcome this crew ships (#7756). If you need something from another session, message THAT SESSION (relay_peers to find its id, relay_send to reach it) — never ask the human to pass it along; carrying messages between agents is the job this bus exists to remove. When your work for THIS message is finished, END YOUR TURN — do NOT park, do NOT loop relay_wait; the runner waits for you and will wake you with the next message. Path discipline: build/test from your worktree root ${TURN_DIR} with absolute paths or --manifest-path/--prefix instead of cd-ing into subdirs, and put anything that must land outside the repo under ${TURN_DIR}/.agent-bus-out/ (gitignored) — never ~/.agent-bus. Realigning your seat branch after the orchestrator harvested your commits is \`trantor sync\` run from your worktree: it reads the harvest receipts and refuses when an unharvested commit would be lost, so never reset or rebase onto main by hand. A contract's \`base: <sha>\` line is the integration head: start your seat branch at that sha (\`git reset --hard <sha>\` on the fresh branch), never at origin/main, which trails the orchestrator's unpushed integration commits; the object is already here as the ref \`main\`. If \`git cat-file -e <sha>\` fails, say \`cannot resolve base <sha>\` on the bus and move the card to blocked instead of reasoning from origin/main. Every testing/done note names the sha you verified against as \`verified at <sha>\`; a note without it is flagged HOLLOW. Cross-project action is a breach: never \`trantor up\` a crew, register a seat, or send a card/contract into a project other than ${PROJ} unless the operator ran \`trantor policy link ${PROJ} <other> --reason "<why>"\` first — the hub, the CLI and this runner all refuse it mechanically, so ask the operator to link the projects instead of routing around the refusal.`;
293
+ const RULES = process.env.RUNNER_RULES || `Rules: you are ${SESSION} on the trantor crew. Before starting a card, read YOUR card: relay_board with card:<id> (the card, its deps, its notes, and the last five done cards whose title shares a word); never the whole board. If your wake names no card id (or cites only a message-card), make relay_board with mine:true your FIRST call — it lists the cards assigned to you (doing/testing/todo, newest first, full card shape); work your newest one (#7763). After long read-only recon, commit a WIP note with findings so the turn leaves evidence. Work your assigned file(s), report on the bus (relay_send, <280 chars), move your Kanban card as you go with a NOTE saying what you did (doing -> testing, then STOP: you never close your own card to done — the orchestrator runs the card's drill on the built artifact and closes it, and the hub refuses a move to done on a card with no drill line anyway; in 'testing' run YOUR OWN test file — never the full npm test, suites collide across seats — plus \`node bin/slop-gate.mjs\` when the repo has one: it lints ONLY your changed files against the anti-slop rules, and a card must not reach testing with slop-gate failing; use 'failed' + a report if anything breaks). If your work starts containers or a dev server (\`supabase start\`, \`docker run\`, a watch process), stop them before moving your card to testing and name them in the note — the runner also stops containers it saw your turn create when the card closes, but the first responsibility is yours (#9778). If a contract omits a fact you cannot proceed without, ASK — never invent the value: relay_ask(<card>, <question>) blocks the card with your question, keeps the turn owed (no park, no failure), and resumes you when the assigner's answer lands; an invented value that reads as reasoned is the worst outcome this crew ships (#7756). If you need something from another session, message THAT SESSION (relay_peers to find its id, relay_send to reach it) — never ask the human to pass it along; carrying messages between agents is the job this bus exists to remove. When your work for THIS message is finished, END YOUR TURN — do NOT park, do NOT loop relay_wait; the runner waits for you and will wake you with the next message. Path discipline: build/test from your worktree root ${TURN_DIR} with absolute paths or --manifest-path/--prefix instead of cd-ing into subdirs, and put anything that must land outside the repo under ${TURN_DIR}/.agent-bus-out/ (gitignored) — never ~/.agent-bus. Realigning your seat branch after the orchestrator harvested your commits is \`trantor sync\` run from your worktree: it reads the harvest receipts and refuses when an unharvested commit would be lost, so never reset or rebase onto main by hand. A contract's \`base: <sha>\` line is the integration head: start your seat branch at that sha (\`git reset --hard <sha>\` on the fresh branch), never at origin/main, which trails the orchestrator's unpushed integration commits; the object is already here as the ref \`main\`. If \`git cat-file -e <sha>\` fails, say \`cannot resolve base <sha>\` on the bus and move the card to blocked instead of reasoning from origin/main. Every testing/done note names the sha you verified against as \`verified at <sha>\`; a note without it is flagged HOLLOW. Cross-project action is a breach: never \`trantor up\` a crew, register a seat, or send a card/contract into a project other than ${PROJ} unless the operator ran \`trantor policy link ${PROJ} <other> --reason "<why>"\` first — the hub, the CLI and this runner all refuse it mechanically, so ask the operator to link the projects instead of routing around the refusal.`;
262
294
 
263
295
  // ---- the pulse --------------------------------------------------------------
264
296
  // RUNNER_PULSE_MS re-runs an orchestrator seat's mission note on a cadence when the bus is silent;
@@ -288,7 +320,7 @@ let lastEmptyTurn = false;
288
320
  // at turn end rides along so the assigner's notice can name it.
289
321
  let lastNoDelivery = false;
290
322
  let lastNoDeliveryStatus = "";
291
- const ERRF = join(homedir(), ".agent-bus", `err-${AGENT}-${PROJ}.txt`);
323
+ const ERRF = join(homedir(), ".agent-bus", `err-${PERSONA}-${PROJ}.txt`);
292
324
  const DUTY_NUDGES = process.env.RUNNER_DUTY_NUDGES === "1";
293
325
  const DUTY_NUDGE_STATE = process.env.RUNNER_DUTY_NUDGE_STATE
294
326
  || join(homedir(), ".agent-bus", "duty-nudged.json");
@@ -309,15 +341,15 @@ function startDutyNudgeWatcher(plan, sinceMs) {
309
341
  // ---- undelivered wake messages (the runner owns delivery, not the hub) ----
310
342
  // The hub hands a message out exactly once, so a turn that died took its wake with it. Here a
311
343
  // message is consumed only when a turn exits 0; the queue lives on disk and retries on backoff.
312
- const PENDF = join(homedir(), ".agent-bus", `pending-${AGENT}-${PROJ}.json`);
344
+ const PENDF = join(homedir(), ".agent-bus", `pending-${PERSONA}-${PROJ}.json`);
313
345
  // #7914: the park, on disk, for everyone who is not on the bus. A park announces itself once and
314
346
  // then the seat looks exactly like an idle one — which is how three seats sat 46 minutes while
315
347
  // `trantor seat-why` read "live, last turn failed". This file is the state behind that event, so a
316
348
  // reader who missed the message still finds the reason, the evidence and when it lifts.
317
- const PARKF = join(homedir(), ".agent-bus", `park-${AGENT}-${PROJ}.json`);
349
+ const PARKF = join(homedir(), ".agent-bus", `park-${PERSONA}-${PROJ}.json`);
318
350
  // #9778: the seat's recorded containers, persisted per seat so a card close, a park or
319
351
  // `trantor down <seat>` can stop what the seat started even after this runner is gone.
320
- const DOCKERF = dockerStatePathFor(AGENT, PROJ);
352
+ const DOCKERF = dockerStatePathFor(PERSONA, PROJ);
321
353
  // One bus line per sweep, naming what was stopped; docker absent sweeps stay silent by contract.
322
354
  async function janitorStop(why, card) {
323
355
  const r = dockerSweep({ path: DOCKERF, card });
@@ -373,7 +405,7 @@ function savePending(wake, bcast) {
373
405
  // the dedupe ledger outlives the deliveries it guards, and a restarted runner must wait for
374
406
  // the answer instead of redelivering the very contract the seat already asked about.
375
407
  if (!wake.length && !bcast.length && !seenLedger.length && !awaitingAsk) { try { unlinkSync(PENDF); } catch {} return; }
376
- writeFileSync(PENDF, JSON.stringify({ agent: AGENT, project: PROJ, ts: Date.now(), wake, bcast, seen: seenLedger, ask: awaitingAsk?.id || 0, askTo: awaitingAsk?.to || "" }));
408
+ writeFileSync(PENDF, JSON.stringify({ persona: PERSONA, agent: AGENT, project: PROJ, ts: Date.now(), wake, bcast, seen: seenLedger, ask: awaitingAsk?.id || 0, askTo: awaitingAsk?.to || "" }));
377
409
  } catch {}
378
410
  }
379
411
  function loadPending() {
@@ -405,7 +437,7 @@ async function reportFailure(exit, trigger, undelivered = 0, reasonOverride = ""
405
437
  const reason = reasonOverride || classify(exit);
406
438
  const down = consecFails >= 2;
407
439
  const status = down ? `down: ${reason} · ${consecFails} fails` : `errored: ${reason}`;
408
- await api("/register", { session: SESSION, project: PROJ, status, llm: AGENT, model: MODEL, kind: "agent" }).catch(() => {});
440
+ await api("/register", { session: SESSION, project: PROJ, status, persona: PERSONA, agent: AGENT, llm: AGENT, model: MODEL, kind: "agent" }).catch(() => {});
409
441
  const hint = reason === "exhausted" ? " — needs `trantor swap`"
410
442
  : reason === "stalled" ? " — CLI silent for the whole watchdog window: check the provider, or `trantor swap`"
411
443
  : reason === "auth" ? " — check credentials"
@@ -467,7 +499,7 @@ async function parkSeat(reason, undelivered, resetHint = 0, { evidence = "" } =
467
499
  parkState = { reason, until, evidence, held: undelivered, ts: Date.now(), bounded };
468
500
  // The park is a STATE, and every reader of it is off the bus: `trantor seat-why`, the app, the
469
501
  // operator at a terminal an hour later. The message below is the event; this file is the state.
470
- try { writeFileSync(PARKF, JSON.stringify({ session: SESSION, agent: AGENT, project: PROJ, ...parkState })); } catch {}
502
+ try { writeFileSync(PARKF, JSON.stringify({ session: SESSION, persona: PERSONA, agent: AGENT, project: PROJ, ...parkState })); } catch {}
471
503
  if (!parkAnnounced) {
472
504
  parkAnnounced = true;
473
505
  // #7752: a stalled park names the CLI and its model — the swap decision needs both, and this
@@ -488,7 +520,7 @@ async function parkSeat(reason, undelivered, resetHint = 0, { evidence = "" } =
488
520
  `${undelivered} message(s) held${when ? ` — retrying after ${when}` : ` — needs \`trantor up ${AGENT}\``}`);
489
521
  // #7749: parked is a first-class phase — the file and the peer row both say so, so an
490
522
  // orchestrator stops confusing a quota-held seat with a working or a dead one.
491
- writeTurnState(AGENT, PROJ, { phase: "parked", since: Date.now() });
523
+ writeTurnState(PERSONA, PROJ, { phase: "parked", since: Date.now() });
492
524
  await registerStatus(`parked (${reason})`, { phase: "parked", phaseSince: Date.now() });
493
525
  // #9778: a parked seat is done working for now — its recorded containers go down with it.
494
526
  await janitorStop(`seat parked (${reason})`);
@@ -551,7 +583,7 @@ async function balanceRows() {
551
583
  // #7749: the live phase + its start ride the same call, so /peers answers what the seat does NOW.
552
584
  async function registerStatus(status, extra = {}) {
553
585
  const url = HUB + "/register";
554
- const body = JSON.stringify({ session: SESSION, project: PROJ, status, llm: AGENT, model: MODEL, ...extra });
586
+ const body = JSON.stringify({ session: SESSION, project: PROJ, status, persona: PERSONA, agent: AGENT, llm: AGENT, model: MODEL, ...extra });
555
587
  try {
556
588
  const opts = { method: "POST", headers: { "content-type": "application/json", connection: "close" }, body };
557
589
  await fetch(url, { ...opts, headers: { ...opts.headers, ...signedHeaders(identity, url, opts) }, signal: AbortSignal.timeout(5000) });
@@ -603,7 +635,7 @@ async function reportHealthy() {
603
635
  // Recovery is a change too, so the next failure is news again — a park included.
604
636
  announced = "";
605
637
  parkAnnounced = false;
606
- await api("/register", { session: SESSION, project: PROJ, status: `active in ${PROJ}`, llm: AGENT, model: MODEL, kind: "agent" }).catch(() => {});
638
+ await api("/register", { session: SESSION, project: PROJ, status: `active in ${PROJ}`, persona: PERSONA, agent: AGENT, llm: AGENT, model: MODEL, kind: "agent" }).catch(() => {});
607
639
  await api("/send", { from: SESSION, to: "all", text: `✅ ${SESSION} recovered`, project: PROJ, kind: "status" }).catch(() => {});
608
640
  cmuxStatus("ok", "#14b8a6", "check"); herdrAgent("idle");
609
641
  }
@@ -617,7 +649,7 @@ for (const [name, flag] of Object.entries(stateFlags)) {
617
649
  if (flag.value && process.env[name] === undefined) process.env[name] = flag.value;
618
650
  }
619
651
  const STATE_FLAG_ON = process.env[STATE_ENV] === "1";
620
- const STATE_SCHEMA_FILE = join(homedir(), ".agent-bus", `state-schema-${AGENT}-${PROJ}.json`);
652
+ const STATE_SCHEMA_FILE = join(homedir(), ".agent-bus", `state-schema-${PERSONA}-${PROJ}.json`);
621
653
  const STATE_MODE = (() => {
622
654
  if (!STATE_FLAG_ON) return false;
623
655
  if (AGENT !== "claude") { log(`${STATE_ENV}=1 but this seat is '${AGENT}' — state mode is claude-only (TDD §7.3); staying on the transcript path`); return false; }
@@ -713,9 +745,9 @@ let cutChain = [];
713
745
  const PARK_WINDOW_MS = Math.max(0, Number(process.env.TRANTOR_PARK_WINDOW_MS || 15 * 60 * 1000));
714
746
  // The card the CURRENT CLI session belongs to (#6134). 0 = the kickoff session, which belongs to
715
747
  // no card, so the first contract that names one starts a session of its own.
716
- let sessionCard = 0;
748
+ let sessionCard = releaseState?.sessionCard || 0;
717
749
 
718
- let sid = "";
750
+ let sid = releaseState?.sid || "";
719
751
  // #6206: the watchdog is DETACHED, so a runner that dies without ending it leaves an orphan
720
752
  // sleeping toward a false alarm against whatever runner comes next.
721
753
  // Every exit path therefore kills it, and the stamp carries this runner's instance id so any
@@ -741,8 +773,8 @@ async function runTurn(prompt, isFirst, trigger = "kickoff", opts = {}) {
741
773
  await registerStatus(`working · ${trigger}`, { phase: "working", phaseSince: t0 });
742
774
  // #7749: the same boundary on disk — the watchdog refreshes lastBytesAt/lastTranscriptAt while
743
775
  // the turn runs, so a live reader sees the turn advance without waiting for its end.
744
- writeTurnState(AGENT, PROJ, { turn: TURN, phase: "working", since: t0, card: sessionCard || 0, lastBytesAt: t0, lastTranscriptAt: 0 });
745
- const pf = join(homedir(), ".agent-bus", `turn-${AGENT}-${PROJ}.txt`);
776
+ writeTurnState(PERSONA, PROJ, { turn: TURN, phase: "working", since: t0, card: sessionCard || 0, lastBytesAt: t0, lastTranscriptAt: 0 });
777
+ const pf = join(homedir(), ".agent-bus", `turn-${PERSONA}-${PROJ}.txt`);
746
778
  appendFileSync(pf, "", { flag: "w" }); // truncate
747
779
  appendFileSync(pf, prompt);
748
780
  // #5868: where HEAD stood when the turn began. A turn that moved it shipped real work, and an
@@ -755,11 +787,12 @@ async function runTurn(prompt, isFirst, trigger = "kickoff", opts = {}) {
755
787
  // #10197: the card status snapshot the turn is judged against at its end — a move to
756
788
  // testing/done under the turn is delivery even without a commit. null = the hub read failed:
757
789
  // the delivery check stands down rather than claim no-delivery on a transport error.
758
- let cardStatusBefore = null;
790
+ let cardStatusBefore = null, cardLogBefore = [];
759
791
  if (!opts.state && sessionCard > 0) {
760
792
  try {
761
793
  const { task } = await api(`/card?project=${encodeURIComponent(PROJ)}&id=${sessionCard}`);
762
794
  cardStatusBefore = task ? String(task.status || "") : null;
795
+ cardLogBefore = task?.log || [];
763
796
  } catch { cardStatusBefore = null; }
764
797
  }
765
798
  // #9778: the docker snapshot the turn is judged against — containers the CLI spawns are recorded
@@ -799,7 +832,7 @@ async function runTurn(prompt, isFirst, trigger = "kickoff", opts = {}) {
799
832
  // instead of prose, and the operator watches that window. So on a state step stdout goes to a
800
833
  // file and the runner prints the result line and the cost line itself. stderr still streams to
801
834
  // the window through the process substitution below, unchanged.
802
- const ENVF = join(homedir(), ".agent-bus", `envelope-${AGENT}-${PROJ}.json`);
835
+ const ENVF = join(homedir(), ".agent-bus", `envelope-${PERSONA}-${PROJ}.json`);
803
836
  if (opts.state) { try { unlinkSync(ENVF); } catch {} }
804
837
  const inner = opts.state
805
838
  ? `${cmd} > ${ENVF}`
@@ -807,20 +840,20 @@ async function runTurn(prompt, isFirst, trigger = "kickoff", opts = {}) {
807
840
  // #5684: runTurn is spawnSync, so a DETACHED watchdog (stamp-armed) watches the turn and sends
808
841
  // ONE stall report, never a kill. #6206: floor 10 min, never derived from TRANTOR_TURN_MAX_MS.
809
842
  const WD_MS = Number(process.env.TRANTOR_TURN_WATCHDOG_MS) || 10 * 60 * 1000;
810
- const STAMPF = join(homedir(), ".agent-bus", `turnstamp-${AGENT}-${PROJ}.json`);
843
+ const STAMPF = join(homedir(), ".agent-bus", `turnstamp-${PERSONA}-${PROJ}.json`);
811
844
  // #6206: the CLI's transcript dir (a missing dir is a quiet channel), exported so a drill's fake
812
845
  // CLI can write lines the watchdog sees. CUTF is written by the shell's time box: cut, not crashed.
813
- const CUTF = join(homedir(), ".agent-bus", `turncut-${AGENT}-${PROJ}`);
846
+ const CUTF = join(homedir(), ".agent-bus", `turncut-${PERSONA}-${PROJ}`);
814
847
  try { unlinkSync(CUTF); } catch {}
815
848
  // #7752: written by the watchdog's kill mode when EVERY liveness channel has been quiet for
816
849
  // the whole window; the box sweeps on the marker, so a silent turn ends at the stall window
817
850
  // instead of burning the box. Unlinked at turn start and again with the cut cleanup.
818
- const STALLF = join(homedir(), ".agent-bus", `turnstall-${AGENT}-${PROJ}`);
851
+ const STALLF = join(homedir(), ".agent-bus", `turnstall-${PERSONA}-${PROJ}`);
819
852
  try { unlinkSync(STALLF); } catch {}
820
853
  // #7761: the watchdog writes the extended deadline (epoch seconds) here for the shell box to
821
854
  // re-read, and one row per extension to EXTF for the ledger. Both unlinked at turn start.
822
- const DEADLINEF = join(homedir(), ".agent-bus", `turndeadline-${AGENT}-${PROJ}`);
823
- const EXTF = join(homedir(), ".agent-bus", `turnext-${AGENT}-${PROJ}`);
855
+ const DEADLINEF = join(homedir(), ".agent-bus", `turndeadline-${PERSONA}-${PROJ}`);
856
+ const EXTF = join(homedir(), ".agent-bus", `turnext-${PERSONA}-${PROJ}`);
824
857
  try { unlinkSync(DEADLINEF); } catch {}
825
858
  try { unlinkSync(EXTF); } catch {}
826
859
  const boxPlan = TURN_EXTENSIONS_MAX ? {
@@ -830,16 +863,20 @@ async function runTurn(prompt, isFirst, trigger = "kickoff", opts = {}) {
830
863
  } : undefined;
831
864
  // Touched by the stderr scrubber as its LAST act (the shell below); node waits for it after
832
865
  // spawnSync before reading ERRF — see the drain note at the spawnSync call.
833
- const DRAINF = join(homedir(), ".agent-bus", `turndrain-${AGENT}-${PROJ}`);
866
+ const DRAINF = join(homedir(), ".agent-bus", `turndrain-${PERSONA}-${PROJ}`);
834
867
  try { unlinkSync(DRAINF); } catch {}
835
868
  try {
836
- writeFileSync(STAMPF, JSON.stringify({ turn: TURN, startedAt: Date.now(), runner: RUNNER_ID, box: boxPlan }));
869
+ const activity = cli.pinned || ["kimi", "claude"].includes(AGENT) ? {
870
+ kind: cli.pinned ? "opencode" : AGENT, sid, db: OC_DB, workDir: TURN_DIR,
871
+ home: homedir(), startedAt: t0, transcriptDir: TRANSCRIPT_DIR,
872
+ } : undefined;
873
+ writeFileSync(STAMPF, JSON.stringify({ turn: TURN, startedAt: Date.now(), runner: RUNNER_ID, box: boxPlan, activity }));
837
874
  const wd = spawn(process.execPath, [join(import.meta.dirname, "turn-watchdog.mjs"), STAMPF, ERRF, String(WD_MS), SESSION, PROJ, HUB, TRANSCRIPT_DIR, TURN_DIR,
838
875
  // #7752: the stall marker only exists when a box does — with no box there is no sweep to
839
876
  // end the turn, so the watchdog stays report-only (reporting is the whole job, boxless).
840
877
  TURN_MAX_MS ? STALLF : "",
841
878
  // #7749: the turn-state file the watchdog refreshes mid-turn (lastBytesAt/lastTranscriptAt).
842
- join(homedir(), ".agent-bus", `turnstate-${AGENT}-${PROJ}.json`)],
879
+ join(homedir(), ".agent-bus", `turnstate-${PERSONA}-${PROJ}.json`)],
843
880
  { detached: true, stdio: "ignore" });
844
881
  WD_CHILD = wd;
845
882
  wd.unref();
@@ -851,6 +888,8 @@ async function runTurn(prompt, isFirst, trigger = "kickoff", opts = {}) {
851
888
  // #7752: the box POLLS instead of one long sleep — the watchdog's stall marker (a silent
852
889
  // turn) breaks the loop at the stall window, the deadline breaks it at the full box, and only
853
890
  // the deadline path writes CUTF so the runner can tell the two cuts apart.
891
+ const shellArg = value => "'" + String(value).replaceAll("'", "'\"'\"'") + "'";
892
+ const snapshotCmd = [process.execPath, join(import.meta.dirname, "cut-snapshot.mjs"), TURN_DIR].map(shellArg).join(" ");
854
893
  const box = TURN_MAX_MS ? `
855
894
  ${sweep}
856
895
  ( trap 'kill "$sleeppid" 2>/dev/null; wait "$sleeppid" 2>/dev/null; exit 0' TERM
@@ -863,12 +902,25 @@ ${sweep}
863
902
  wait "$sleeppid"
864
903
  done
865
904
  kill -0 $job 2>/dev/null || exit 0
905
+ reason=stall
906
+ [ -f "${STALLF}" ] || reason=box
907
+ ${snapshotCmd} "$reason" 2>> ${ERRF}
866
908
  [ -f "${STALLF}" ] || : > ${CUTF}
867
909
  sweep $job
868
910
  ) >/dev/null 2>&1 & boxpid=$!` : "\nboxpid=";
911
+ const backstop = TURN_MAX_MS ? `
912
+ ${sweep}
913
+ backstop() {
914
+ [ -n "$boxpid" ] && kill $boxpid 2>/dev/null
915
+ ${snapshotCmd} box 2>> ${ERRF}
916
+ : > ${CUTF}
917
+ sweep $job
918
+ exit 137
919
+ }
920
+ trap backstop TERM` : "";
869
921
  const shell = `set -o pipefail
870
922
  { ${inner} ; } 2> >(${SCRUB} --tee2 ${ERRF}; : >> "${DRAINF}") &
871
- job=$!${box}
923
+ job=$!${box}${backstop}
872
924
  wait $job; turn_exit=$?
873
925
  [ -n "$boxpid" ] && kill $boxpid 2>/dev/null
874
926
  wait
@@ -890,10 +942,9 @@ exit $turn_exit`;
890
942
  TRANTOR_TRANSCRIPT_DIR: TRANSCRIPT_DIR },
891
943
  maxBuffer: 16 * 1024 * 1024,
892
944
  };
893
- // A BACKSTOP only, deliberately later than the shell's own box: if bash itself wedges, node
894
- // still ends the turn. When the in-shell box works — the normal path — this never fires, which
895
- // is the point: the shell kills while the tree is still walkable, node cannot.
896
- if (TURN_MAX_MS) { spawnOpts.timeout = (TURN_EXTENSIONS_MAX ? TURN_CEILING_MS : TURN_MAX_MS) + 30000; spawnOpts.killSignal = "SIGKILL"; }
945
+ // The timeout signals the shell to snapshot before sweeping, just like the normal box.
946
+ // Its 30s margin keeps it out of the normal cut path.
947
+ if (TURN_MAX_MS) { spawnOpts.timeout = (TURN_EXTENSIONS_MAX ? TURN_CEILING_MS : TURN_MAX_MS) + 30000; spawnOpts.killSignal = "SIGTERM"; }
897
948
  const r = spawnSync("/bin/bash", ["-c", shell], spawnOpts);
898
949
  // The shell's box leaves the marker; the backstop leaves an ETIMEDOUT. Either way the turn was
899
950
  // cut, not merely failed. A stall marker (#7752) outranks the box marker: a turn silent for
@@ -918,6 +969,8 @@ exit $turn_exit`;
918
969
  killWatchdog(); // #6206: turn over — the watchdog dies NOW, it does not sleep on
919
970
  try { unlinkSync(STAMPF); } catch {} // disarm any survivor: the stamp is gone
920
971
  if (cut) {
972
+ const saved = snapshotBeforeCut(TURN_DIR, stallCut ? "stall" : "box");
973
+ if (!saved.ok) log(`cut WIP snapshot failed: ${saved.error}`);
921
974
  // Belt and braces after the shell's descendant sweep: anything still sharing the turn's group.
922
975
  if (r.pid) { try { process.kill(-r.pid, "SIGKILL"); } catch {} }
923
976
  try { unlinkSync(CUTF); } catch {}
@@ -931,7 +984,7 @@ exit $turn_exit`;
931
984
  // #7749: the cut boundary lands on disk and on the peer row BEFORE the follow-up turn overwrites
932
985
  // it — a reader sampling mid-chain sees what actually just happened, not a stale "working".
933
986
  if (cut) {
934
- writeTurnState(AGENT, PROJ, { phase: stallCut ? "stalled" : "cut", since: Date.now() });
987
+ writeTurnState(PERSONA, PROJ, { phase: stallCut ? "stalled" : "cut", since: Date.now() });
935
988
  await registerStatus(stallCut ? "stalled · no activity" : "cut at the time box", { phase: stallCut ? "stalled" : "cut", phaseSince: Date.now() });
936
989
  }
937
990
  // #5869: scrub AT REST, synchronously, before anything reads the file back. The explicit shell
@@ -1041,17 +1094,24 @@ exit $turn_exit`;
1041
1094
  // turn started, or the card moved to testing/done under it. The ibkr shape (exit 0, output, no
1042
1095
  // commit, card still todo) is NO-DELIVERY: the ledger names it and the contract stays owed.
1043
1096
  // Judged on hub reads of the card status; a failed read keeps today's label, never no-delivery.
1044
- let cardStatusAtEnd = "", noDelivery = false;
1097
+ let cardStatusAtEnd = "", noDelivery = false, blockedVerdict = "";
1045
1098
  if (!cut && !opts.state && realExit === 0 && effExit === 0 && sessionCard > 0 && cardStatusBefore !== null) {
1046
1099
  let endStatus = null;
1047
1100
  try {
1048
1101
  const { task } = await api(`/card?project=${encodeURIComponent(PROJ)}&id=${sessionCard}`);
1049
1102
  endStatus = task ? String(task.status || "") : null;
1103
+ const freshNote = (task?.log || []).filter(n => n.by === SESSION && n.ts >= t0
1104
+ && !cardLogBefore.some(old => old.ts === n.ts && old.by === n.by && old.text === n.text)).at(-1);
1105
+ if (endStatus === "blocked" && (cardStatusBefore !== "blocked" || freshNote)) {
1106
+ const note = freshNote || (task?.log || []).at(-1);
1107
+ blockedVerdict = `blocked: ${String(note?.text || "").split(/\r?\n/)[0]}`;
1108
+ lastEmptyTurn = false;
1109
+ }
1050
1110
  } catch { endStatus = null; }
1051
1111
  if (endStatus !== null) {
1052
1112
  cardStatusAtEnd = endStatus;
1053
1113
  const moved = ["testing", "done"].includes(endStatus) && !["testing", "done"].includes(cardStatusBefore);
1054
- noDelivery = !newCommit && !moved && !askVerdict;
1114
+ noDelivery = !newCommit && !moved && !askVerdict && !blockedVerdict;
1055
1115
  }
1056
1116
  }
1057
1117
  lastNoDelivery = noDelivery;
@@ -1063,6 +1123,7 @@ exit $turn_exit`;
1063
1123
  // reads as the sweep's SIGKILL (#7099); a zero-usage turn names its own verdict too (#9724).
1064
1124
  const verdict = stallCut ? stallVerdict() : zeroUsage ? zeroUsageVerdict()
1065
1125
  : askVerdict ? askVerdict
1126
+ : blockedVerdict ? blockedVerdict
1066
1127
  : noDelivery ? noDeliveryVerdict(sessionCard, cardStatusAtEnd || "unknown")
1067
1128
  : verdictFor(realExit, effExit, lastEmptyOutput, ownOut, lastEmptyTurn, cut);
1068
1129
  // #6289: every ledger row names in ONE field what happened to the turn — cut, stalled (#7752),
@@ -1070,8 +1131,8 @@ exit $turn_exit`;
1070
1131
  // binds the row to the card the turn worked (0 = kickoff/pulse) so the seat record attributes
1071
1132
  // empty/stalled turns to the card that produced nothing. `cut` stays too: the drills read it.
1072
1133
  const outcome = cut ? (stallCut ? "stalled" : "cut") : (effExit !== 0 ? "api-error" : noDelivery ? "no-delivery" : lastEmptyTurn ? "empty" : "completed");
1073
- const finalOutcome = askVerdict ? "asked" : outcome;
1074
- const telemetryRow = { ts: Date.now(), agent: AGENT, project: PROJ, turn: TURN, trigger, card: sessionCard || 0, model: MODEL || "cli-default", duration_ms: Date.now() - t0, exit: realExit, effExit, authFailed: effExit !== realExit, emptyOutput: lastEmptyOutput, emptyTurn: lastEmptyTurn, verdict, outcome: finalOutcome, tokens };
1134
+ const finalOutcome = askVerdict ? "asked" : blockedVerdict ? "blocked" : outcome;
1135
+ const telemetryRow = { ts: Date.now(), persona: PERSONA, agent: AGENT, project: PROJ, turn: TURN, trigger, card: sessionCard || 0, model: MODEL || "cli-default", duration_ms: Date.now() - t0, exit: realExit, effExit, authFailed: effExit !== realExit, emptyOutput: lastEmptyOutput, emptyTurn: lastEmptyTurn, verdict, outcome: finalOutcome, tokens };
1075
1136
  if (usage) telemetryRow.usage = usage;
1076
1137
  if (dockerNew.length) telemetryRow.containers = dockerNew.map(c => c.name || c.id);
1077
1138
  if (cut) telemetryRow.cut = true;
@@ -1090,7 +1151,7 @@ exit $turn_exit`;
1090
1151
  // #5965 — TURN END. A clean exit means the seat is idle again; say so right away so the app stops
1091
1152
  // pulsing it even before the next /poll heartbeat. Failure keeps reportFailure's down/errored.
1092
1153
  if (realExit === 0 && effExit === 0) {
1093
- writeTurnState(AGENT, PROJ, { phase: "idle", since: Date.now() });
1154
+ writeTurnState(PERSONA, PROJ, { phase: "idle", since: Date.now() });
1094
1155
  await registerStatus("idle", { phase: "idle", phaseSince: Date.now() });
1095
1156
  }
1096
1157
  // The follow-up rides the SAME session, exactly once. A state step gets none: TIME_BOX_PROMPT
@@ -1255,7 +1316,7 @@ function shouldWake(message) {
1255
1316
  }
1256
1317
  return message?.to === "all"
1257
1318
  && isContract(message)
1258
- && (message.text.includes(`@${AGENT}`) || message.text.toLowerCase().includes(`${AGENT}:`));
1319
+ && (message.text.includes(`@${PERSONA}`) || message.text.toLowerCase().includes(`${PERSONA.toLowerCase()}:`));
1259
1320
  }
1260
1321
 
1261
1322
  // #6228: the operator's declared project links, cached briefly so a burst of wakes doesn't hit
@@ -1311,13 +1372,14 @@ async function resolveWakeCard(messages, { session }) {
1311
1372
  await loadLessons();
1312
1373
  // start cursor at the CURRENT tip so we don't replay history
1313
1374
  let cursor = 0;
1314
- if (process.env.CREW_SWAP_STAGE) cursor = JSON.parse(readFileSync(`${process.env.CREW_SWAP_STAGE}.release`, "utf8")).cursor;
1375
+ if (releaseState) cursor = releaseState.cursor;
1376
+ else if (process.env.CREW_SWAP_STAGE) cursor = JSON.parse(readFileSync(`${process.env.CREW_SWAP_STAGE}.release`, "utf8")).cursor;
1315
1377
  else try { const r = await api(`/inbox?session=${encodeURIComponent(SESSION)}&since=0`); cursor = r.cursor || 0; } catch {}
1316
1378
  // kind "agent" on every beat (#6075): the peer row's kind is the hub's OWN record of what a
1317
1379
  // session is — the overseer's declared-crew exemption reads it, and on the remote hub there is
1318
1380
  // no crew-windows.txt to fall back to. /register preserves absent fields, so a seat running an
1319
1381
  // older runner never loses a kind an updated one stamped.
1320
- await api("/register", { session: SESSION, project: PROJ, status: "crew member booting", llm: AGENT, model: MODEL, kind: "agent" }).catch(() => {});
1382
+ await api("/register", { session: SESSION, project: PROJ, status: "crew member booting", persona: PERSONA, agent: AGENT, llm: AGENT, model: MODEL, kind: "agent" }).catch(() => {});
1321
1383
  // Announce runner-side, signed as THIS seat: the runner process is per-seat by construction, so
1322
1384
  // its signature cannot be borrowed the way a shared opencode MCP daemon once borrowed identities.
1323
1385
  try {
@@ -1337,7 +1399,7 @@ async function resolveWakeCard(messages, { session }) {
1337
1399
  // #7914: a park belongs to the runner that made it. This process is starting fresh and will
1338
1400
  // re-park if the CLI is still cutting, so a record left by the runner before it is stale state —
1339
1401
  // and stale state in a health read is the same failure as no state at all.
1340
- try { unlinkSync(PARKF); } catch {}
1402
+ if (!releaseState) { try { unlinkSync(PARKF); } catch {} }
1341
1403
  seenLedger = restored.seen;
1342
1404
  if (seenLedger.length) log(`\x1b[33m${seenLedger.length} consumed message id(s) restored from the pending file — the poll keeps standing down on them\x1b[0m`);
1343
1405
  if (restored.ask) {
@@ -1357,25 +1419,54 @@ async function resolveWakeCard(messages, { session }) {
1357
1419
  // disagreeing makes a health check lie.
1358
1420
  savePending(pendingWake, pendingBcast);
1359
1421
  }
1360
- let retryAt = 0; // 0 = deliver at the next opportunity
1361
- let deliveryFails = 0; // consecutive failed attempts at the SAME pending batch
1362
- let noDeliveryRetry = false;
1422
+ let retryAt = releaseState?.retryAt || 0; // 0 = deliver at the next opportunity
1423
+ let deliveryFails = releaseState?.deliveryFails || 0; // consecutive failed attempts at the SAME pending batch
1424
+ let noDeliveryRetry = releaseState?.noDeliveryRetry || false;
1425
+ askReleased = releaseState?.askReleased || false;
1363
1426
  if (pendingWake.length) log(`\x1b[33m${pendingWake.length} message(s) survived from a previous run — redelivering\x1b[0m`);
1364
1427
 
1365
1428
  // #7060: the turn the boot line was read as a promise about. It is a transcript turn by
1366
1429
  // construction and now says so, in the same breath as the line that armed the mode.
1367
- stateSkip("kickoff");
1368
- const ec0 = await runTurn(composedTurn({ base: KICKOFF, lessons: pickLessons(LESSONS_RAW, "") }), true, "kickoff");
1369
- if (ec0) await reportFailure(ec0, "kickoff", pendingWake.length); // a failed kickoff = the "fired up, died, nobody knew" case
1370
- // #7778: the kickoff is a turn like any other — a message that landed mid-kickoff and was read
1371
- // through the session's own inbox is consumed by a successful kickoff, and the first poll must
1372
- // not wake on it. Same reconcile, same clean-boundary rule as deliverWake below.
1373
- else await reconcileSessionReads(cursor);
1374
- let lastTurnAt = Date.now();
1430
+ if (!releaseState) {
1431
+ stateSkip("kickoff");
1432
+ const ec0 = await runTurn(composedTurn({ base: KICKOFF, lessons: pickLessons(LESSONS_RAW, "") }), true, "kickoff");
1433
+ if (ec0) await reportFailure(ec0, "kickoff", pendingWake.length); // a failed kickoff = the "fired up, died, nobody knew" case
1434
+ // #7778: the kickoff is a turn like any other — a message that landed mid-kickoff and was read
1435
+ // through the session's own inbox is consumed by a successful kickoff, and the first poll must
1436
+ // not wake on it. Same reconcile, same clean-boundary rule as deliverWake below.
1437
+ else await reconcileSessionReads(cursor);
1438
+ } else {
1439
+ const text = `runner restarted onto ${bootVersion}`;
1440
+ log(text);
1441
+ telemetry({ ts: Date.now(), text, version: bootVersion });
1442
+ await api("/send", { from: SESSION, to: "all", project: PROJ, kind: "status", text }).catch(() => {});
1443
+ }
1444
+ let lastTurnAt = releaseState?.lastTurnAt || Date.now();
1445
+ function restartForRelease() {
1446
+ const version = installedVersion();
1447
+ if (!newerVersion(version, bootVersion) || attemptedVersions.has(version)) return;
1448
+ attemptedVersions.add(version);
1449
+ // SAFETY: older supported Node releases lack in-place exec; never replace it with a duplicate runner.
1450
+ // oxlint-disable-next-line anti-slop/no-runtime-typeof
1451
+ if (typeof process.execve !== "function") {
1452
+ log(`runner upgrade to ${version} requires Node with process.execve; restart manually`);
1453
+ return;
1454
+ }
1455
+ try {
1456
+ // Persist before exec: a held contract and a released ask both survive the boundary.
1457
+ writeFileSync(PENDF, JSON.stringify({ persona: PERSONA, agent: AGENT, project: PROJ, ts: Date.now(),
1458
+ wake: pendingWake, bcast: pendingBcast, seen: seenLedger, ask: awaitingAsk?.id || 0, askTo: awaitingAsk?.to || "" }));
1459
+ const state = { cursor, turn: TURN, sid, sessionCard, retryAt, deliveryFails,
1460
+ noDeliveryRetry, askReleased, lastTurnAt, attempted: [...attemptedVersions] };
1461
+ process.execve(process.execPath, [process.execPath, ...process.execArgv, ...process.argv.slice(1)],
1462
+ { ...process.env, CREW_RELEASE_STATE: JSON.stringify(state) });
1463
+ } catch (error) { log(`runner restart failed: ${error.message}`); }
1464
+ }
1375
1465
  if (PULSE_MS) log(`pulse armed — mission re-read every ${Math.round(PULSE_MS / 1000)}s (${MISSION_FILE})`);
1376
1466
  log(`parked — long-polling the bus as ${SESSION} (free; this poll is also the heartbeat)`);
1377
1467
 
1378
1468
  while (true) {
1469
+ restartForRelease();
1379
1470
  // pulse first: a due mission beat runs even on a silent bus. Measured from the END of the
1380
1471
  // last turn, so a long turn doesn't stack an immediate pulse on top of itself.
1381
1472
  if (PULSE_MS && Date.now() - lastTurnAt >= PULSE_MS) {
@@ -1545,6 +1636,7 @@ async function resolveWakeCard(messages, { session }) {
1545
1636
  } catch { return 0; }
1546
1637
  }
1547
1638
  async function deliverWake() {
1639
+ restartForRelease();
1548
1640
  noDeliveryRetry = false;
1549
1641
  // #7756: captured and cleared HERE so an early return below never leaks the flag into a
1550
1642
  // later turn — only the turn the answer released reads it.
@@ -1570,6 +1662,22 @@ async function resolveWakeCard(messages, { session }) {
1570
1662
  },
1571
1663
  })
1572
1664
  : { items: [], targets: [], owner: "" };
1665
+ // #11355: the shared pre-send ledger — the wake daemon stamps the same files, so of the two
1666
+ // orchestrator-wake nudgers exactly one send per unread id lands; skipped ids release this
1667
+ // path's claim and never reach the seat, so the existing consumed-without-a-wake branch fires.
1668
+ const dutyOwner = dutyPlan.owner;
1669
+ const skippedNudgeIds = [];
1670
+ for (const target of dutyPlan.targets) {
1671
+ const reserve = await reserveNudgeIds({ recipient: target.recipient, ids: target.ids, owner: dutyOwner });
1672
+ target.ids = reserve.allowed;
1673
+ skippedNudgeIds.push(...reserve.skipped.map(s => s.id));
1674
+ }
1675
+ if (skippedNudgeIds.length) {
1676
+ const skippedSet = new Set(skippedNudgeIds);
1677
+ dutyPlan.items = dutyPlan.items.filter(item => !skippedSet.has(item.id));
1678
+ dutyPlan.targets = dutyPlan.targets.filter(target => target.ids.length);
1679
+ await releaseDutyNudgeClaims({ ids: skippedNudgeIds, statePath: DUTY_NUDGE_STATE, owner: dutyOwner });
1680
+ }
1573
1681
  const claimedIds = new Set(dutyPlan.items.map(item => item.id));
1574
1682
  const wakeForTurn = DUTY_NUDGES
1575
1683
  ? wake.filter(message => {
@@ -1692,6 +1800,11 @@ async function resolveWakeCard(messages, { session }) {
1692
1800
  },
1693
1801
  });
1694
1802
  skippedNudges = audit.missing;
1803
+ // #11355: the ledger mirrors the claim release — a duty turn that ended without the send
1804
+ // must not silence the wake daemon for the TTL.
1805
+ for (const target of audit.missing) {
1806
+ await releaseNudgeIds({ recipient: target.recipient, ids: target.ids, owner: dutyOwner });
1807
+ }
1695
1808
  }
1696
1809
  if (!ec && skippedNudges.length) {
1697
1810
  deliveryFails++;