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.
- package/.claude-plugin/plugin.json +1 -1
- package/bin/bridge.mjs +1 -0
- package/bin/cli.mjs +2 -0
- package/bin/crew/core.mjs +6 -8
- package/bin/crew-payload.mjs +13 -2
- package/bin/crew-runner.mjs +53 -10
- package/bin/doctor.mjs +12 -0
- package/bin/drill-report.mjs +1 -1
- package/bin/drill-report.test.mjs +1 -0
- package/bin/reconcile.mjs +22 -4
- package/bin/retire.mjs +67 -0
- package/bin/state-bench.mjs +46 -6
- package/bin/turn-watchdog.mjs +35 -23
- package/docs/BUILD-DOCTRINE.md +117 -0
- package/engine/bin/scrooge +10 -2
- package/hooks/handoff-now.mjs +9 -2
- package/hooks/lib/handoff.mjs +46 -14
- package/hooks/sessionstart.mjs +25 -0
- package/hub/routes/cards.mjs +9 -1
- package/hub/store.mjs +22 -6
- package/lib/autonomy.mjs +2 -1
- package/lib/decode.mjs +13 -0
- package/lib/identity.mjs +4 -2
- package/lib/model-catalog.mjs +8 -19
- package/lib/project.mjs +35 -10
- package/lib/retire-panes.mjs +205 -0
- package/lib/seat-record.mjs +3 -14
- package/lib/seat-why.mjs +4 -3
- package/lib/seats.mjs +15 -11
- package/lib/state/derive.mjs +6 -0
- package/lib/state/driver.mjs +35 -11
- package/lib/state/flags.mjs +26 -0
- package/lib/state/store.mjs +20 -0
- package/lib/state/validate.mjs +40 -19
- package/lib/subagent-manifest.mjs +2 -1
- package/lib/subagent-scan.mjs +0 -0
- package/mcp.mjs +20 -10
- package/package.json +3 -2
- package/skills/crew/SKILL.md +18 -7
- package/skills/prd-review/SKILL.md +12 -3
- package/ui.html +6 -3
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "trantor",
|
|
3
|
-
"version": "0.18.
|
|
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
|
-
//
|
|
138
|
-
//
|
|
139
|
-
|
|
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
|
package/bin/crew-payload.mjs
CHANGED
|
@@ -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
|
-
|
|
26
|
-
|
|
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 };
|
package/bin/crew-runner.mjs
CHANGED
|
@@ -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
|
|
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
|
|
547
|
-
//
|
|
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)
|
|
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"))
|
package/bin/drill-report.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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: ${
|
|
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
|
+
}
|
package/bin/state-bench.mjs
CHANGED
|
@@ -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 => ({
|
|
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}
|
|
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
|
-
|
|
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
|
-
|
|
662
|
-
|
|
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
|
}
|
package/bin/turn-watchdog.mjs
CHANGED
|
@@ -1,22 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// Turn watchdog (#5684,
|
|
3
|
-
// turn —
|
|
4
|
-
//
|
|
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
|
|
78
|
-
//
|
|
79
|
-
//
|
|
80
|
-
//
|
|
81
|
-
|
|
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)
|
|
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}`;
|