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