trantor 0.18.62 → 0.18.64

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trantor",
3
- "version": "0.18.62",
3
+ "version": "0.18.64",
4
4
  "description": "Trantor — the hub-world for AI agent crews: live message bus, presence, project Kanban/flow board + crew orchestration for independent AI coding agents (Claude, Codex, Gemini, Kimi, DeepSeek)",
5
5
  "mcpServers": {
6
6
  "relay": {
package/bin/bridge.mjs CHANGED
@@ -62,6 +62,7 @@ const saveMap = () => { try { mkdirSync(dirname(MAPFILE), { recursive: true });
62
62
 
63
63
  const cardBody = (t) => ({ project: PROJECT, title: t.title, status: t.status, assignee: t.assignee || "",
64
64
  difficulty: t.difficulty || undefined, model: t.model || undefined, phase: t.phase || undefined,
65
+ drill: t.drill || undefined, // #6452: a mirror keeps its source's drill line
65
66
  by: t.by || "", source: "bridge" });
66
67
 
67
68
  async function tick() {
package/bin/cli.mjs CHANGED
@@ -132,6 +132,7 @@ switch (cmd) {
132
132
  case "orchestrate": run("bin/orchestrate.mjs"); break;
133
133
  case "app": run("bin/app.mjs"); break;
134
134
  case "patrol": run("bin/patrol.mjs"); break;
135
+ case "retire": run("bin/retire.mjs"); break;
135
136
  case "identity": {
136
137
  const { load, publicView, generate, keyPath } = await import(join(ROOT, "lib/identity.mjs"));
137
138
  const sub = args[0], name = args[1] || "human";
@@ -271,6 +272,7 @@ switch (cmd) {
271
272
  trantor duty the always-on fleet duty agent: up | down | status — hub-escalated triage so you are not the switchboard
272
273
  runs on sonnet by default (it never writes code); duty up --model <m> to pick, or --model inherit for the CLI default
273
274
  trantor orchestrate a per-project ORCHESTRATOR with a MISSION.md and a pulse: up [--every 10m] | down | status — the loop-orchestrator pattern
275
+ trantor retire retire orchestrator panes nothing is using — preview by default [--hours N] [--yes] [--json]
274
276
  trantor patrol machine-wide resource sweep: crews/runners/workspaces/orphans — [--json] [--reap] (reap = dead rows + stale artifacts ONLY)
275
277
 
276
278
  Claude Code plugin (the orchestrator side):
package/bin/crew/core.mjs CHANGED
@@ -2,6 +2,7 @@ import { accessSync, constants, existsSync, mkdirSync, readFileSync } from "node
2
2
  import { basename, dirname, join } from "node:path";
3
3
  import { spawnSync } from "node:child_process";
4
4
  import { fileURLToPath } from "node:url";
5
+ import { STATE_FLAGS } from "../../lib/state/flags.mjs";
5
6
 
6
7
  export const ROOT = dirname(dirname(dirname(fileURLToPath(import.meta.url))));
7
8
 
@@ -132,14 +133,11 @@ export function gridColumns(size) {
132
133
  return columns;
133
134
  }
134
135
 
135
- // Operator flags the RUNNER itself reads, forwarded from whoever launched the seat.
136
- //
137
- // The Trantor State flags are read as `process.env` INSIDE crew-runner.mjs, not by the CLI it
138
- // spawns so ~/.agent-bus/.env (the crew key layer) cannot set them: that file is applied to the
139
- // spawned command, one level too deep. Without this list the flags documented in TDD §11 have no
140
- // supported way to reach a seat at all, which is how Phase 2a came to be "enabled" with a schema
141
- // file that was never written and a runner still on the transcript path.
142
- const FORWARDED_ENV = ["TRANTOR_STATE", "TRANTOR_STATE_ASSEMBLE", "TRANTOR_STATE_HANDOFF", "TRANTOR_STATE_GATE"];
136
+ // Operator flags the RUNNER itself reads, forwarded from whoever launched the seat. The list lives
137
+ // in lib/state/flags.mjs beside the resolver: the launcher env stays the forwarder (the runner boots
138
+ // before any file is sourced), and #7159 lets the runner fall back to ~/.agent-bus/.env itself, so a
139
+ // flag documented there finally arms the seat that reads it.
140
+ const FORWARDED_ENV = STATE_FLAGS;
143
141
 
144
142
  export function runnerCommand(ctx, agent, model = "", effort = null) {
145
143
  const forwarded = FORWARDED_ENV
@@ -6,6 +6,8 @@
6
6
  export const PAYLOAD_CAPS = Object.freeze({
7
7
  wakeCount: 10, // direct/@mention messages: keep the last ~10
8
8
  wakeMsgChars: 2000, // per-message body cap (the hub caps card notes at 2000 too)
9
+ wakeHeadChars: 1200, // #7063: over the per-message cap, keep the head…
10
+ wakeTailChars: 700, // #7063: …AND the tail — a work order is asks-last, the instructions live there
9
11
  bcastCount: 10, // FYI broadcast context: keep the last ~10
10
12
  bcastMsgChars: 1000,
11
13
  lessonsCount: 15, // top ~15 lessons, ranked by relevance to this turn's trigger
@@ -22,8 +24,17 @@ export function capWake(wake, caps = PAYLOAD_CAPS) {
22
24
  const kept = list.slice(-caps.wakeCount);
23
25
  const lines = kept.map(m => {
24
26
  let body = String(m?.text ?? "");
25
- if (body.length > caps.wakeMsgChars)
26
- body = body.slice(0, caps.wakeMsgChars) + `[+${n(body.length - caps.wakeMsgChars)} chars of this message dropped]`;
27
+ // #7063: a work order is written context-first, asks-last, so a HEAD cut deleted exactly the
28
+ // instructions ("so, do this: 1)2) …") and kept the rationale — two orders lost their item 2
29
+ // in one turn. Over the per-message cap, keep the head AND the tail with ONE marker line
30
+ // between, so a card id, a base: line and the closing asks all survive. The cap is per
31
+ // message, never a batch budget (body > wakeMsgChars ⇒ elided > 0, the marker is always true).
32
+ if (body.length > caps.wakeMsgChars) {
33
+ const elided = body.length - caps.wakeHeadChars - caps.wakeTailChars;
34
+ body = body.slice(0, caps.wakeHeadChars)
35
+ + `\n…[${n(elided)} chars of this message elided from the middle]\n`
36
+ + body.slice(-caps.wakeTailChars);
37
+ }
27
38
  return `[${m?.from}${m?.to === "all" ? " -> all (mentions you)" : ""}]: ${body}`;
28
39
  });
29
40
  return { text: lines.join("\n"), kept: kept.length, total: list.length };
@@ -33,6 +33,7 @@ import {
33
33
  breakerVerdict, describeTurn, hasJsonSchemaFlag, parseEnvelope, renderCardTail, runStep,
34
34
  } from "../lib/state/driver.mjs";
35
35
  import { costLine } from "../lib/state/cost.mjs";
36
+ import { resolveStateFlags } from "../lib/state/flags.mjs";
36
37
 
37
38
  const AGENT = process.argv[2];
38
39
  const DIR = process.argv[3] || process.cwd();
@@ -247,7 +248,7 @@ const EFFORT_FLAG = EFFORT ? cliEffortFlag(AGENT, EFFORT) : { flag: "", text: ""
247
248
 
248
249
  // RUNNER_RULES / RUNNER_KICKOFF env overrides: the runner is also the substrate for non-crew
249
250
  // always-on seats (the fleet DUTY agent, bin/duty.mjs) whose doctrine is not "work your card".
250
- 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 -> done; 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 done with slop-gate failing; use 'failed' + a report if anything breaks). 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.`;
251
+ 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 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.`;
251
252
 
252
253
  // ---- the pulse --------------------------------------------------------------
253
254
  // RUNNER_PULSE_MS re-runs an orchestrator seat's mission note on a cadence when the bus is silent;
@@ -543,8 +544,13 @@ async function reportHealthy() {
543
544
  }
544
545
 
545
546
  // ---- Trantor State Phase 2a — the flagged path (TDD §4.1, §4.6, §7.3) -----------------------
546
- // Off by default, and off means the transcript path runs unchanged. Reachable only when the operator
547
- // set TRANTOR_STATE_ASSEMBLE=1, the seat is `claude` (§7.3) and the CLI carries --json-schema (§6).
547
+ // Off by default off means the transcript path runs unchanged. #7159: the flags resolve through
548
+ // the one place (lib/state/flags.mjs), launcher env first and ~/.agent-bus/.env filling the rest;
549
+ // resolved values are applied fill-only to process.env so downstream reads (runGate's GATE) agree.
550
+ const stateFlags = resolveStateFlags();
551
+ for (const [name, flag] of Object.entries(stateFlags)) {
552
+ if (flag.value && process.env[name] === undefined) process.env[name] = flag.value;
553
+ }
548
554
  const STATE_FLAG_ON = process.env[STATE_ENV] === "1";
549
555
  const STATE_SCHEMA_FILE = join(homedir(), ".agent-bus", `state-schema-${AGENT}-${PROJ}.json`);
550
556
  const STATE_MODE = (() => {
@@ -558,7 +564,7 @@ const STATE_MODE = (() => {
558
564
  } catch (e) { log(`\x1b[33mstate mode OFF — could not write ${STATE_SCHEMA_FILE}: ${e.message}\x1b[0m`); return false; }
559
565
  // #7060: the checks above prove CONFIGURATION, not the prompt, so say "armed" and name the one
560
566
  // thing that engages it; each turn then reports which path it took.
561
- log(`\x1b[36mTrantor State: ASSEMBLE armed for this seat (schema ${STATE_SCHEMA_FILE})\x1b[0m`);
567
+ log(`\x1b[36mTrantor State: ASSEMBLE armed for this seat (${STATE_ENV}=${stateFlags[STATE_ENV].value} via ${stateFlags[STATE_ENV].layer}; schema ${STATE_SCHEMA_FILE})\x1b[0m`);
562
568
  log(`\x1b[36m a turn is assembled only when a wake ASSIGNS it a card — the kickoff and every pulse run the transcript path, and each turn says which one it took\x1b[0m`);
563
569
  return true;
564
570
  })();
@@ -615,6 +621,14 @@ let statePromotedHash;
615
621
  // TRANTOR_TURN_MAX_MS ends the CLI's process group at the box and runs ONE follow-up turn in the
616
622
  // same session ("commit what is done, move the card, report") so a cut turn lands its work.
617
623
  const TURN_MAX_MS = Math.max(0, Number(process.env.TRANTOR_TURN_MAX_MS || 20 * 60 * 1000));
624
+ // #7761: the box counts LIVENESS. At the deadline a turn that moved within the stall window is
625
+ // extended one step (half the box: +10m at the default) up to TRANTOR_TURN_CEILING_MS, the one
626
+ // knob (default 60m). A ceiling at or under the box disables extension; a silent turn still ends
627
+ // at the #7752 stall window, extended or not. The watchdog owns the clock — see turn-watchdog.mjs.
628
+ const TURN_CEILING_MS = TURN_MAX_MS ? Math.max(0, Number(process.env.TRANTOR_TURN_CEILING_MS || 60 * 60 * 1000)) : 0;
629
+ const TURN_EXTEND_MS = Math.ceil(TURN_MAX_MS / 2);
630
+ const TURN_EXTENSIONS_MAX = TURN_EXTEND_MS > 0 && TURN_CEILING_MS > TURN_MAX_MS
631
+ ? Math.floor((TURN_CEILING_MS - TURN_MAX_MS) / TURN_EXTEND_MS) : 0;
618
632
  const TIME_BOX_PROMPT = "your previous turn was cut at the time box; commit what is done, move the card with a note, report in one line";
619
633
  let inFollowUp = false;
620
634
  // #6289: whether the turn that just ended was CUT at the time box. The follow-up recursion
@@ -715,12 +729,23 @@ async function runTurn(prompt, isFirst, trigger = "kickoff", opts = {}) {
715
729
  // instead of burning the box. Unlinked at turn start and again with the cut cleanup.
716
730
  const STALLF = join(homedir(), ".agent-bus", `turnstall-${AGENT}-${PROJ}`);
717
731
  try { unlinkSync(STALLF); } catch {}
732
+ // #7761: the watchdog writes the extended deadline (epoch seconds) here for the shell box to
733
+ // re-read, and one row per extension to EXTF for the ledger. Both unlinked at turn start.
734
+ const DEADLINEF = join(homedir(), ".agent-bus", `turndeadline-${AGENT}-${PROJ}`);
735
+ const EXTF = join(homedir(), ".agent-bus", `turnext-${AGENT}-${PROJ}`);
736
+ try { unlinkSync(DEADLINEF); } catch {}
737
+ try { unlinkSync(EXTF); } catch {}
738
+ const boxPlan = TURN_EXTENSIONS_MAX ? {
739
+ maxMs: TURN_MAX_MS, extendMs: TURN_EXTEND_MS, ceilingMs: TURN_CEILING_MS, extensionsMax: TURN_EXTENSIONS_MAX,
740
+ deadlineFile: DEADLINEF, extFile: EXTF, card: sessionCard || 0,
741
+ assigners: (opts.assigners || []).map(a => ({ from: a.from, id: a.id })),
742
+ } : undefined;
718
743
  // Touched by the stderr scrubber as its LAST act (the shell below); node waits for it after
719
744
  // spawnSync before reading ERRF — see the drain note at the spawnSync call.
720
745
  const DRAINF = join(homedir(), ".agent-bus", `turndrain-${AGENT}-${PROJ}`);
721
746
  try { unlinkSync(DRAINF); } catch {}
722
747
  try {
723
- writeFileSync(STAMPF, JSON.stringify({ turn: TURN, startedAt: Date.now(), runner: RUNNER_ID }));
748
+ writeFileSync(STAMPF, JSON.stringify({ turn: TURN, startedAt: Date.now(), runner: RUNNER_ID, box: boxPlan }));
724
749
  const wd = spawn(process.execPath, [join(import.meta.dirname, "turn-watchdog.mjs"), STAMPF, ERRF, String(WD_MS), SESSION, PROJ, HUB, TRANSCRIPT_DIR, TURN_DIR,
725
750
  // #7752: the stall marker only exists when a box does — with no box there is no sweep to
726
751
  // end the turn, so the watchdog stays report-only (reporting is the whole job, boxless).
@@ -742,6 +767,7 @@ ${sweep}
742
767
  deadline=$(( $(date +%s) + ${Math.ceil(TURN_MAX_MS / 1000)} ))
743
768
  while :; do
744
769
  [ -f "${STALLF}" ] && break
770
+ d=$(cat "${DEADLINEF}" 2>/dev/null); case "$d" in ""|*[!0-9]*) ;; *) deadline=$d;; esac
745
771
  [ "$(date +%s)" -ge "$deadline" ] && break
746
772
  sleep 1 & sleeppid=$!
747
773
  wait "$sleeppid"
@@ -777,7 +803,7 @@ exit $turn_exit`;
777
803
  // A BACKSTOP only, deliberately later than the shell's own box: if bash itself wedges, node
778
804
  // still ends the turn. When the in-shell box works — the normal path — this never fires, which
779
805
  // is the point: the shell kills while the tree is still walkable, node cannot.
780
- if (TURN_MAX_MS) { spawnOpts.timeout = TURN_MAX_MS + 30000; spawnOpts.killSignal = "SIGKILL"; }
806
+ if (TURN_MAX_MS) { spawnOpts.timeout = (TURN_EXTENSIONS_MAX ? TURN_CEILING_MS : TURN_MAX_MS) + 30000; spawnOpts.killSignal = "SIGKILL"; }
781
807
  const r = spawnSync("/bin/bash", ["-c", shell], spawnOpts);
782
808
  // The shell's box leaves the marker; the backstop leaves an ETIMEDOUT. Either way the turn was
783
809
  // cut, not merely failed. A stall marker (#7752) outranks the box marker: a turn silent for
@@ -785,6 +811,13 @@ exit $turn_exit`;
785
811
  const boxed = existsSync(CUTF);
786
812
  const stallCut = existsSync(STALLF);
787
813
  const cut = !!TURN_MAX_MS && (boxed || stallCut || r.error?.code === "ETIMEDOUT");
814
+ // #7761: how far the watchdog moved the box, one EXTF row per extension. boxMs is the box the
815
+ // turn actually had, so a ceiling cut reads as the ceiling, never as the 20-minute default.
816
+ let extensions = 0;
817
+ try { extensions = readFileSync(EXTF, "utf8").split("\n").filter(Boolean).length; } catch {}
818
+ const boxMs = TURN_MAX_MS + extensions * TURN_EXTEND_MS;
819
+ try { unlinkSync(DEADLINEF); } catch {}
820
+ try { unlinkSync(EXTF); } catch {}
788
821
  // DRAIN before classifying, never on a CUT turn (the sweep killed the scrubber, its marker never
789
822
  // comes). bash 3.2 `wait` skips process substitutions, so wait for DRAINF, bounded.
790
823
  if (!cut) {
@@ -800,6 +833,7 @@ exit $turn_exit`;
800
833
  try { unlinkSync(CUTF); } catch {}
801
834
  try { unlinkSync(STALLF); } catch {}
802
835
  if (stallCut) log(`\x1b[33mturn STALLED — no bytes on either stream and no transcript advance for ${Math.round(WD_MS / 60000)}m; ended at the stall window, not the ${Math.round(TURN_MAX_MS / 1000)}s box\x1b[0m`);
836
+ else if (extensions) log(`\x1b[33mturn cut at the ${Math.round(boxMs / 1000)}s box after ${extensions} liveness extension(s) (ceiling ${Math.round(TURN_CEILING_MS / 1000)}s) — CLI and every descendant ended${boxed ? "" : " (node backstop: bash itself was wedged)"}\x1b[0m`);
803
837
  else log(`\x1b[33mturn cut at the ${Math.round(TURN_MAX_MS / 1000)}s time box — CLI and every descendant ended${boxed ? "" : " (node backstop: bash itself was wedged)"}\x1b[0m`);
804
838
  }
805
839
  lastTurnCut = cut;
@@ -883,6 +917,7 @@ exit $turn_exit`;
883
917
  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 };
884
918
  if (cut) telemetryRow.cut = true;
885
919
  if (stallCut) telemetryRow.stalled = true;
920
+ if (extensions) { telemetryRow.extensions = extensions; telemetryRow.boxMs = boxMs; }
886
921
  // #7752/#7099: a cut turn's 141/137 is the sweep's own signal (SIGPIPE/SIGKILL), recorded as
887
922
  // the cut signal — never read as a quota or crash pattern downstream.
888
923
  if (cut) { const sig = cutSignalFor(realExit); if (sig) telemetryRow.cutSignal = sig; }
@@ -920,7 +955,7 @@ async function stateTurn({ card, observation, trigger, assigners = [] }) {
920
955
  callCli: async (prompt) => {
921
956
  // isFirst=true every step ON PURPOSE: a state step carries no `-c`, so there is no session
922
957
  // to resume and a stale sid must never be handed to one.
923
- const exit = await runTurn(prompt, true, trigger, { state: true });
958
+ const exit = await runTurn(prompt, true, trigger, { state: true, assigners });
924
959
  return { exit, stdout: lastEnvelope, cut: lastTurnCut };
925
960
  },
926
961
  executeAction: async (action) => {
@@ -1367,9 +1402,17 @@ async function resolveWakeCard(messages, { session }) {
1367
1402
  await loadLessons();
1368
1403
  const lessons = pickLessons(LESSONS_RAW, wakeCapped.text + " " + bcastCapped.text);
1369
1404
  const trigger = wakeForTurn.some(m => m.to === SESSION) ? "direct message" : "@mention";
1370
- // Who is owed an answer, captured BEFORE the turn: pendingWake is cleared on success.
1405
+ // Who is owed an answer, captured BEFORE the turn: pendingWake is cleared on success. The id is
1406
+ // the NEWEST wake this runner holds for that assigner (#6987): append order means first-wins
1407
+ // threaded the receipt onto a PREVIOUS turn's id — the original contract, not the answer that
1408
+ // released this turn — which kept the contract reading WAITING while the stop hook chased.
1371
1409
  const assigners = [];
1372
- for (const m of wakeForTurn) if (m.from && !assigners.some(a => a.from === m.from)) assigners.push({ from: m.from, id: m.id });
1410
+ for (const m of wakeForTurn) {
1411
+ if (!m.from) continue;
1412
+ const held = assigners.find(a => a.from === m.from);
1413
+ if (held) held.id = m.id;
1414
+ else assigners.push({ from: m.from, id: m.id });
1415
+ }
1373
1416
  const asked = askedExcerpt(wakeForTurn[0]);
1374
1417
  const tStart = Date.now();
1375
1418
  // #6134: ONE SESSION PER CARD; a different card starts a fresh CLI session and the seat is told.
@@ -1428,7 +1471,7 @@ async function resolveWakeCard(messages, { session }) {
1428
1471
  // dies quietly (§4.6).
1429
1472
  observation: [stateObservation, wakeText, ctxText, againText + freshText + baseText].filter(Boolean).join("\n"),
1430
1473
  })
1431
- : await runTurn(prompt, fresh, deliveryFails ? `${trigger} (redelivery)` : trigger, { judgeOutcome: askJudge });
1474
+ : await runTurn(prompt, fresh, deliveryFails ? `${trigger} (redelivery)` : trigger, { judgeOutcome: askJudge, assigners });
1432
1475
  }
1433
1476
  finally { stopDutyNudgeWatcher(); }
1434
1477
  const secs = Math.round((Date.now() - tStart) / 1000);
package/bin/doctor.mjs CHANGED
@@ -13,6 +13,7 @@ import { loadOrCreate } from "../lib/identity.mjs";
13
13
  import { sfetchJson } from "../lib/signed-fetch.mjs";
14
14
  import { scan } from "../lib/splitbrain.mjs";
15
15
  import { resolveSecrets, envFileSecrets, backendFor } from "../lib/secrets.mjs";
16
+ import { resolveStateFlags } from "../lib/state/flags.mjs";
16
17
 
17
18
  const H = homedir();
18
19
  const ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
@@ -324,6 +325,17 @@ if (straggling.live.length && backendFor(process.env, { create: true }) !== "non
324
325
  ok(`secret store: ${storeNames.length} key(s) in the ${backendFor()} (${storeNames.join(", ")}), none left in .env`);
325
326
  }
326
327
 
328
+ // ── Trantor State flags (#7159): the runners resolve these through ONE place — process env first,
329
+ // ~/.agent-bus/.env second — and each line names the layer that answered, so a flag that LOOKED
330
+ // applied can no longer pass for armed.
331
+ section("trantor state flags");
332
+ {
333
+ const resolved = resolveStateFlags();
334
+ const setFlags = Object.entries(resolved).filter(([, r]) => r.layer !== "unset");
335
+ for (const [name, r] of setFlags) ok(`${name}=${r.value} via ${r.layer}`);
336
+ if (!setFlags.length) note("no state flags set — Trantor State runs dark by default (TDD §11)");
337
+ }
338
+
327
339
  // brain
328
340
  section("the brain");
329
341
  has("scrooge") || existsSync(join(H, ".local", "bin", "scrooge"))
@@ -68,7 +68,7 @@ export class DrillReport {
68
68
  if (!this.map[id].autoClose || result.status !== "pass" || this.closures[id]) continue;
69
69
  const response = await signedPost("/task/update", {
70
70
  id: Number(id), project: this.project, by: this.session, status: "done",
71
- note: `trantor drill PASS\n${result.evidence.join("\n")}`.slice(0, 2000),
71
+ note: `Drill: trantor drill PASS\n${result.evidence.join("\n")}`.slice(0, 2000), // "Drill:" prefix: the hub's done gate (#6452) reads it as the drill line
72
72
  }, { project: this.project, session: this.session, timeoutMs: 15000 });
73
73
  this.closures[id] = response.ok && response.json?.task?.status === "done"
74
74
  && response.json.task.id === Number(id) && response.json.task.project === this.project
@@ -51,6 +51,7 @@ test("signed closer on an enforce hub: complete evidence closes, partial/failure
51
51
  const card = (await signedGet("/tasks?project=trantor")).json.tasks[0];
52
52
  assert.equal(card.status, "done");
53
53
  assert.equal(card.workedBy, session);
54
+ assert.match(card.log.at(-1).text, /^Drill: trantor drill PASS/); // #6452: the closer's note IS the drill line the hub gate requires
54
55
  assert.match(card.log.at(-1).text, /real evidence A/);
55
56
  assert.match(card.log.at(-1).text, /real evidence B/);
56
57
  assert.equal(JSON.parse(readFileSync(path, "utf8"))[id].closure, "done");
package/bin/reconcile.mjs CHANGED
@@ -45,8 +45,25 @@ async function tasks() {
45
45
  const j = r.json;
46
46
  return Array.isArray(j) ? j : (j?.tasks || j?.cards || []);
47
47
  }
48
- async function move(id, status) {
49
- await signedPost("/task/update", { id, status, by: "reconcile" }, { timeoutMs: 4000 }).catch(() => {});
48
+ async function move(id, status, note) {
49
+ return signedPost("/task/update", { id, status, by: "reconcile", note }, { timeoutMs: 4000 })
50
+ .catch(e => ({ ok: false, status: 0, json: { error: e.message } }));
51
+ }
52
+ // #6452: the hub refuses done on a card with no drill line, and reconcile cannot claim a drill it
53
+ // never ran. A card judged shipped closes when it already carries a drill line; otherwise it parks
54
+ // at testing with the verdict on its log, and the orchestrator runs the drill and closes it.
55
+ async function close(x) {
56
+ const note = `reconcile: judged shipped${x.commit ? ` at ${x.commit}` : ""}${x.reason ? ` — ${x.reason}` : ""}`;
57
+ const r = await move(x.t.id, "done", note);
58
+ if (r.ok) return "closed";
59
+ if (r.status === 409 && /drill/i.test(r.json?.error || "")) {
60
+ const parked = await move(x.t.id, "testing", `${note}; no drill line on the card, so it waits at testing for the orchestrator's drill`);
61
+ if (parked.ok) return "parked";
62
+ console.error(` #${x.t.id}: hub ${parked.status} ${parked.json?.error || ""}`);
63
+ return "failed";
64
+ }
65
+ console.error(` #${x.t.id}: hub ${r.status} ${r.json?.error || ""}`);
66
+ return "failed";
50
67
  }
51
68
  // the memory record for THIS project (Claude Code stores it per encoded-cwd); optional context.
52
69
  function memoryExcerpt() {
@@ -118,6 +135,7 @@ if (!doIt) {
118
135
  console.log(`\n ${done.length} card(s) → done, ${stale.length} → stale. Re-run to apply:\n trantor reconcile${val("--older", "-o") ? ` --older ${val("--older", "-o")}` : ""} --yes\n`);
119
136
  process.exit(0);
120
137
  }
121
- for (const x of done) await move(x.t.id, "done");
138
+ const outcome = { closed: 0, parked: 0, failed: 0 };
139
+ for (const x of done) outcome[await close(x)]++;
122
140
  for (const x of stale) await move(x.t.id, "stale");
123
- console.log(`\n ✓ reconciled: ${done.length} closed as done, ${stale.length} moved to stale. ${active.length} left active.\n`);
141
+ console.log(`\n ✓ reconciled: ${outcome.closed} closed as done${outcome.parked ? `, ${outcome.parked} parked at testing (no drill line)` : ""}${outcome.failed ? `, ${outcome.failed} refused` : ""}, ${stale.length} moved to stale. ${active.length} left active.\n`);
package/bin/retire.mjs ADDED
@@ -0,0 +1,67 @@
1
+ #!/usr/bin/env node
2
+ // `trantor retire` — retire orchestrator panes that nothing is using (#8017). Previews by default;
3
+ // --yes performs it. Liveness, never age alone, decides: a pane mid-turn or holding an in-flight
4
+ // contract is held open whatever its idle age.
5
+ import { execFileSync } from "node:child_process";
6
+ import { turnInFlight, buildSummary, writeHandoff, sessionProcessState } from "../hooks/lib/handoff.mjs";
7
+ import { hostId, resolveHub } from "../lib/project.mjs";
8
+ import { sfetchJson } from "../lib/signed-fetch.mjs";
9
+ import {
10
+ retireHours, retireEnabled, collectPanes, retireDecision, retirePane,
11
+ isRetired, humanHours, retiredLedgerPath,
12
+ } from "../lib/retire-panes.mjs";
13
+
14
+ const D = "\x1b[2m", B = "\x1b[1m", Y = "\x1b[33m", G = "\x1b[32m", R = "\x1b[0m";
15
+ const args = process.argv.slice(2);
16
+ const flag = n => { const i = args.indexOf(n); return i >= 0 ? args[i + 1] : null; };
17
+ const APPLY = args.includes("--yes");
18
+ const JSON_OUT = args.includes("--json");
19
+
20
+ const hours = flag("--hours") !== null ? Number(flag("--hours")) : retireHours();
21
+ if (!retireEnabled(hours)) {
22
+ console.log(`retirement disabled (threshold ${hours}h) — set TRANTOR_PANE_RETIRE_HOURS or config.paneRetireHours`);
23
+ process.exit(0);
24
+ }
25
+
26
+ const host = hostId();
27
+ // Fail CLOSED on the hub: a contract count we could not read must never be taken as zero, or an
28
+ // unreachable hub would retire a pane that is holding work.
29
+ async function contractsFor(session) {
30
+ try {
31
+ const res = await sfetchJson(`${resolveHub("")}/contracts?session=${encodeURIComponent(session)}`,
32
+ { method: "GET", name: session });
33
+ if (!res.ok) return 1;
34
+ return Number((await res.json())?.open) || 0;
35
+ } catch { return 1; }
36
+ }
37
+
38
+ const panes = collectPanes({ turnInFlight, sessionProcessState, hostId: host }).filter(p => !isRetired(p.sid));
39
+ for (const p of panes) p.openContracts = await contractsFor(`${host}:${p.project}`);
40
+ const decided = panes.map(p => retireDecision(p, { hours }));
41
+ const due = decided.filter(d => d.retire);
42
+
43
+ if (JSON_OUT) {
44
+ console.log(JSON.stringify({ hours, applied: APPLY, panes: decided }, null, 2));
45
+ }
46
+
47
+ if (!JSON_OUT) {
48
+ console.log(`${B}retire${R} ${D}· threshold ${hours}h · ledger ${retiredLedgerPath()}${R}`);
49
+ for (const d of decided) {
50
+ const mark = d.retire ? `${Y}retire${R}` : `${G}hold ${R}`;
51
+ const age = d.idleMs === null ? "?" : humanHours(d.idleMs);
52
+ console.log(` ${mark} ${d.project.padEnd(18)} ${D}idle ${age.padEnd(7)} ${d.reason}${R}`);
53
+ }
54
+ if (!decided.length) console.log(` ${D}no orchestrator panes in the session map${R}`);
55
+ }
56
+
57
+ if (!due.length) process.exit(0);
58
+ if (!APPLY) {
59
+ if (!JSON_OUT) console.log(`\n${D}preview only — rerun with --yes to retire ${due.length} pane(s)${R}`);
60
+ process.exit(0);
61
+ }
62
+
63
+ for (const d of due) {
64
+ const out = await retirePane(d, { by: `retire@${host}`, exec: execFileSync, writeHandoff, buildSummary });
65
+ console.log(` ${G}retired${R} ${d.project} ${D}${out.steps.join(" · ")}${R}`);
66
+ console.log(` ${D}resume the thread with: claude --resume ${d.sid}${R}`);
67
+ }
@@ -616,6 +616,13 @@ export function turnsBeforeCut(rows) {
616
616
  return cut >= 0 ? cut : (rows || []).length;
617
617
  }
618
618
 
619
+ /** Was this run STOPPED, or did it reach its own end? §8.7 is a runway metric and runway only means
620
+ * something for a run something cut short (#8066). Kept separate from turnsBeforeCut so that
621
+ * function's return shape — and every caller reading it as a number — stays as it was. */
622
+ export function wasCut(rows) {
623
+ return (rows || []).some(r => r?.cut === true);
624
+ }
625
+
619
626
  /**
620
627
  * §8.7 — median turns-per-card on the state path vs the baseline path, over n ≥ MIN_CARDS.
621
628
  *
@@ -625,16 +632,35 @@ export function turnsBeforeCut(rows) {
625
632
  */
626
633
  export function turnsGate(pairs, { minCards = MIN_CARDS } = {}) {
627
634
  const usable = (pairs || []).filter(p => p.state?.length && p.baseline?.length)
628
- .map(p => ({ card: p.card, state: turnsBeforeCut(p.state), baseline: turnsBeforeCut(p.baseline) }));
635
+ .map(p => ({
636
+ card: p.card,
637
+ state: turnsBeforeCut(p.state), baseline: turnsBeforeCut(p.baseline),
638
+ state_cut: wasCut(p.state), baseline_cut: wasCut(p.baseline),
639
+ }));
629
640
  const out = { cards: usable, n: usable.length, state_median: median(usable.map(u => u.state)), baseline_median: median(usable.map(u => u.baseline)), ok: false, code: null, message: "" };
630
641
  if (usable.length < minCards) {
631
642
  out.code = "CARRY_FORWARD";
632
643
  out.message = `n=${usable.length} card(s) with both paths recorded; §8.7 wants ≥${minCards}. The medians are recorded on the card and this gate CARRIES FORWARD to the next phase — it is not waived.`;
633
644
  return out;
634
645
  }
646
+ // Runway is only a question for a run something STOPPED. A state run that reached its own end has
647
+ // no runway problem, and scoring it against a baseline that WAS stopped inverts the metric — the
648
+ // better the state path does, the worse this reads. That is not hypothetical: the first real
649
+ // Phase-2a run finished card #6448 in ONE turn against a 149-turn baseline that never finished,
650
+ // and this gate called it FEWER_TURNS (#8066). The regression §8.7 exists to catch is the state
651
+ // path being cut EARLIER than the prose path, and that still fails below.
652
+ const stopped = usable.filter(u => u.state_cut);
653
+ out.cut_n = stopped.length;
654
+ if (!stopped.length) {
655
+ out.ok = true;
656
+ out.message = `no state run was forced to cut across n=${usable.length} card(s) — every card reached its own end, so there is no runway to compare (state median ${out.state_median} turns vs baseline ${out.baseline_median})`;
657
+ return out;
658
+ }
659
+ out.state_median = median(stopped.map(u => u.state));
660
+ out.baseline_median = median(stopped.map(u => u.baseline));
635
661
  out.ok = out.state_median >= out.baseline_median;
636
662
  out.code = out.ok ? null : "FEWER_TURNS";
637
- out.message = `median turns before a forced cut — state ${out.state_median} vs baseline ${out.baseline_median} over n=${usable.length}`;
663
+ out.message = `median turns before a forced cut, over the ${stopped.length} card(s) whose state run WAS cut — state ${out.state_median} vs baseline ${out.baseline_median} (n=${usable.length} recorded)`;
638
664
  return out;
639
665
  }
640
666
 
@@ -645,7 +671,14 @@ export function evaluateRun({ project, card, repo = REPO, ...opts }) {
645
671
  const gates = [];
646
672
  const base = requireBaseline({ project, card, repo });
647
673
  gates.push({ n: 1, name: "baseline committed (§7.5)", ok: base.ok, code: base.code ?? null, message: base.ok ? `${base.doc} in HEAD · ${base.rows.length} baseline turns` : base.message });
648
- if (!base.ok) return { ok: false, gates, halted: "gate 1", project, card };
674
+ // §7.5 exists so the >=5x CLAIM cannot be made against a number recorded after the fact, and that
675
+ // still holds: with no baseline the phase does NOT open and this run cannot pass. But halting here
676
+ // also blocked gates 3, 5 and 6 — which read the state run alone and never touch the baseline — so
677
+ // "does the evidence pipeline work at all" was unanswerable for any card without prose-path
678
+ // history. Since every card that HAS a baseline is now done, that was every card worth asking
679
+ // about. The unfalsifiability guard is kept where it belongs (gates 4 and 7, the comparisons) and
680
+ // the mechanism gates are allowed to report.
681
+ const noBaseline = !base.ok;
649
682
 
650
683
  const steps = readJsonl(runPath(project, card));
651
684
  if (!steps || !steps.length) {
@@ -658,8 +691,15 @@ export function evaluateRun({ project, card, repo = REPO, ...opts }) {
658
691
  const cache = checkCache(steps);
659
692
  gates.push({ n: 3, name: "cache read > 0 after the first step (§8.3)", ok: cache.ok, code: cache.code, message: cache.message });
660
693
 
661
- const cost = costGate(steps, base.rows, { weights: opts.weights || null });
662
- gates.push({ n: 4, name: `cost: flat curve, ≥${COST_FACTOR}× below baseline (§8.4)`, ok: cost.ok, code: cost.code, message: cost.message, numbers: cost });
694
+ // The ONE gate §7.5's guard actually protects: no recorded-first baseline, no ratio, no claim.
695
+ const cost = noBaseline ? null : costGate(steps, base.rows, { weights: opts.weights || null });
696
+ gates.push({ n: 4, name: `cost: flat curve, ≥${COST_FACTOR}× below baseline (§8.4)`,
697
+ ok: noBaseline ? false : cost.ok,
698
+ code: noBaseline ? "NO_BASELINE" : cost.code,
699
+ message: noBaseline
700
+ ? "no committed baseline for this card — the ≥5× claim is measured against a number recorded BEFORE the thing that judges it (§7.5), and there is none, so no ratio is computed and none is guessed"
701
+ : cost.message,
702
+ numbers: cost });
663
703
 
664
704
  const dist = disturbanceCheck(steps);
665
705
  gates.push({ n: 5, name: "zero recovery after a mid-run disturbance (§8.5)", ok: dist.ok, code: dist.code, message: dist.message, cases: dist.cases });
@@ -673,7 +713,7 @@ export function evaluateRun({ project, card, repo = REPO, ...opts }) {
673
713
 
674
714
  // §8.4's "at equal-or-better task success": the cache claim can hold while the card gets less far.
675
715
  const turns = turnsGate(collectTurnPairs(project));
676
- gates.push({ n: 7, name: `turns per card, median over n≥${MIN_CARDS} (§8.7)`, ok: turns.ok, code: turns.code, message: turns.message, numbers: turns });
716
+ gates.push({ n: 7, name: `turns per card, median over n≥${MIN_CARDS} (§8.7)`, ok: noBaseline ? false : turns.ok, code: noBaseline ? "NO_BASELINE" : turns.code, message: noBaseline ? "no committed baseline for this card — §8.7 pairs the state path against the prose path and has nothing to pair with" : turns.message, numbers: turns });
677
717
 
678
718
  return { ok: gates.every(g => g.ok), gates, project, card, baseline: base, steps: steps.length };
679
719
  }
@@ -1,22 +1,9 @@
1
1
  #!/usr/bin/env node
2
- // Turn watchdog (#5684, reworked #6206). runTurn is spawnSync the runner cannot watch its own
3
- // turn — so this DETACHED helper does: armed at turn start, disarmed by turn end. A turn past
4
- // the window with NO new activity earns ONE direct stall report to the foreman (episode, never a
5
- // timer storm). With no stall file (report-only mode) the turn is never killed — reporting is
6
- // the whole job. With one (#7752, kill mode) a turn silent on EVERY channel for the whole
7
- // window is also ENDED at the window: the marker tells the runner's shell box to sweep early,
8
- // so the kill itself still lives in exactly one place — the shell that owns $job.
9
- //
10
- // #6206: stdout silence is NOT a stall — `claude -p` prints nothing until the turn ends by
11
- // design, so a seat editing five files was reported STALLED while its transcript advanced.
12
- // Liveness is new activity in the seat's transcript (the CLI's session file), its worktree, or
13
- // stderr growth; silence on ALL of them for a whole window is the only thing reported. And a
14
- // watchdog never speaks for a runner it does not belong to: the stamp carries the runner's
15
- // instance id, so a survivor of a replaced runner exits on mismatch or runner death instead of
16
- // re-matching the NEW runner's turn number (the 09:47 false alarm was exactly that orphan).
17
- //
2
+ // Turn watchdog (#5684, #6206, #7752, #7761): runTurn is spawnSync, so this DETACHED helper watches
3
+ // the turn — liveness = transcript, worktree or stderr moving; a whole silent window earns ONE stall
4
+ // report, and in kill mode (stall file given) also ends the turn via the shell box. Stamp-bound to one runner.
18
5
  // node bin/turn-watchdog.mjs <stampFile> <errFile> <windowMs> <session> <project> <hubUrl> <transcriptDir> <workDir> [stallFile]
19
- import { readFileSync, writeFileSync, statSync, readdirSync } from "node:fs";
6
+ import { readFileSync, writeFileSync, appendFileSync, statSync, readdirSync } from "node:fs";
20
7
  import { join } from "node:path";
21
8
  import { hostId } from "../lib/project.mjs";
22
9
  import { signedPost } from "../hooks/lib/api.mjs";
@@ -74,14 +61,29 @@ let baseErr = errSize();
74
61
  const armedAt = armed.startedAt || Date.now();
75
62
  const SLACK = 2000; // timestamp granularity + scheduler drift under load
76
63
 
77
- // #7752 kill mode: poll liveness continuously and, on a whole window of silence on EVERY
78
- // channel, write the stall marker, report once, exit. The runner's shell box sweeps the CLI
79
- // when it sees the marker, so a silent turn ends at the window instead of burning the box.
80
- // stderr liveness is measured ROLLING here (growth since the last poll, not since arm): the
81
- // question is "any bytes in this window", not "any bytes ever".
64
+ // #7752 kill mode: poll liveness; a whole window silent on EVERY channel writes the stall marker
65
+ // (the shell box sweeps on it), reports once, exits; stderr is measured ROLLING (bytes this window).
66
+ // #7761: `armed.box` (step, ceiling, assigners) exists only when a ceiling above the box does; a
67
+ // deadline one poll away on a turn that moved within the window is pushed out one step.
68
+ const box = stallFile && armed.box && Number(armed.box.extensionsMax) > 0 ? armed.box : null;
69
+ const mins = (ms) => `${Math.max(1, Math.round(ms / 60000))}m`;
70
+ const secsOrMins = (ms) => (ms >= 60000 ? mins(ms) : `${Math.round(ms / 1000)}s`);
71
+ async function tellExtension(text) {
72
+ const orch = `${hostId()}:${project}`;
73
+ const seen = new Set();
74
+ for (const a of [...(box.assigners || []), { from: orch }]) {
75
+ const to = String(a?.from || "");
76
+ if (!to || to === "all" || to === session || to.startsWith("hub:") || seen.has(to)) continue;
77
+ seen.add(to);
78
+ try { await signedPost(`${hub}/send`, { from: session, to, text, project, kind: "status", wake: false }, { session }); } catch {}
79
+ }
80
+ }
81
+
82
82
  if (stallFile) {
83
83
  const poll = Math.max(250, Math.min(windowMs / 4, 10000));
84
84
  let lastErrAt = armedAt;
85
+ let deadline = armedAt + (box ? Number(box.maxMs) : 0);
86
+ let extensions = 0;
85
87
  for (;;) {
86
88
  await sleep(poll);
87
89
  const s = readStamp();
@@ -93,7 +95,17 @@ if (stallFile) {
93
95
  const freshCut = Math.max(armedAt, now - windowMs - SLACK);
94
96
  const tr = transcriptDir ? newestMtime(transcriptDir) : 0;
95
97
  const wk = workDir ? newestMtime(workDir) : 0;
96
- if (tr > freshCut || wk > freshCut || now - lastErrAt < windowMs) continue; // producing work: alive
98
+ if (tr > freshCut || wk > freshCut || now - lastErrAt < windowMs) { // producing work: alive
99
+ if (box && extensions < Number(box.extensionsMax) && now + poll + SLACK >= deadline) {
100
+ extensions++;
101
+ deadline += Number(box.extendMs);
102
+ try { writeFileSync(box.deadlineFile, String(Math.floor(deadline / 1000))); } catch {}
103
+ try { appendFileSync(box.extFile, JSON.stringify({ n: extensions, at: now, until: deadline }) + "\n"); } catch {}
104
+ const card = Number(box.card) > 0 ? ` on #${box.card}` : "";
105
+ await tellExtension(`⏳ ${session} turn extended +${secsOrMins(Number(box.extendMs))} (${extensions}/${box.extensionsMax})${card} — alive: ${describeLast({ tr, wk })}${now - lastErrAt < windowMs ? ", stderr moving" : ""}; box now ${secsOrMins(deadline - armedAt)} of a ${secsOrMins(Number(box.ceilingMs))} ceiling`);
106
+ }
107
+ continue;
108
+ }
97
109
  try { writeFileSync(stallFile, ""); } catch {}
98
110
  const mins = Math.round((now - armedAt) / 60000);
99
111
  const orch = `${hostId()}:${project}`;