trantor 0.18.53 → 0.18.55
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/README.md +2 -0
- package/bin/crew/herdr.mjs +60 -4
- package/bin/crew/open.mjs +16 -6
- package/bin/crew/state.mjs +10 -1
- package/bin/crew-runner.mjs +111 -320
- package/bin/slop-gate.mjs +69 -41
- package/hooks/agent-notify.mjs +4 -13
- package/hooks/file-claim.mjs +6 -22
- package/hooks/handoff-now.mjs +5 -9
- package/hooks/heartbeat.mjs +16 -51
- package/hooks/inbox-deliver.mjs +8 -36
- package/hooks/lib/api.mjs +17 -66
- package/hooks/lib/handoff.mjs +69 -217
- package/hooks/lib/inbox-ledger.mjs +3 -11
- package/hooks/lib/resources.mjs +6 -16
- package/hooks/lib/update-check.mjs +4 -12
- package/hooks/overseer-warn.mjs +4 -16
- package/hooks/precompact.mjs +5 -10
- package/hooks/prompt-focus.mjs +6 -12
- package/hooks/sessionstart.mjs +38 -125
- package/hooks/statusline.mjs +4 -9
- package/hooks/stop-inbox.mjs +9 -49
- package/hooks/subagent-cost.mjs +2 -6
- package/hooks/subagent-start.mjs +4 -18
- package/hooks/todo-sync.mjs +2 -5
- package/hub/reaper.mjs +7 -2
- package/lib/autonomy.mjs +6 -30
- package/lib/balances.mjs +12 -48
- package/lib/classify-failure.mjs +6 -36
- package/lib/duty-nudges.mjs +2 -13
- package/lib/enroll.mjs +2 -12
- package/lib/identity.mjs +4 -22
- package/lib/integrate.mjs +6 -20
- package/lib/overseer.mjs +2 -15
- package/lib/project.mjs +22 -72
- package/lib/provider-keys.mjs +2 -5
- package/lib/providers.mjs +6 -27
- package/lib/redact.mjs +4 -24
- package/lib/same-project.mjs +2 -15
- package/lib/scrub.mjs +4 -13
- package/lib/seat-why.mjs +2 -9
- package/lib/seats.mjs +3 -18
- package/lib/signed-fetch.mjs +2 -8
- package/lib/splitbrain.mjs +4 -24
- package/lib/state/apply.mjs +7 -26
- package/lib/state/assemble.mjs +10 -33
- package/lib/state/cost.mjs +10 -32
- package/lib/state/derive.mjs +14 -52
- package/lib/state/driver.mjs +26 -108
- package/lib/state/gate.mjs +13 -71
- package/lib/state/migrate.mjs +3 -6
- package/lib/state/promote.mjs +7 -27
- package/lib/state/schema.mjs +12 -33
- package/lib/state/store.mjs +35 -121
- package/lib/state/validate.mjs +7 -31
- package/lib/store-contract.mjs +17 -40
- package/lib/store-pg.mjs +27 -32
- package/lib/subagent-manifest.mjs +2 -17
- package/lib/subagent-scan.mjs +0 -0
- package/lib/turn-policy.mjs +10 -40
- package/package.json +1 -1
package/bin/crew-runner.mjs
CHANGED
|
@@ -1,13 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// trantor crew runner — keeps a crew agent alive
|
|
3
|
-
//
|
|
4
|
-
// node crew-runner.mjs <agent> [project-dir]
|
|
5
|
-
//
|
|
6
|
-
// The park problem: CLIs end their turn no matter what you prompt (harnesses actively kill
|
|
7
|
-
// "call relay_wait repeatedly" loops). So the runner owns the waiting: it long-polls the bus
|
|
8
|
-
// over plain HTTP (zero tokens, doubles as a heartbeat), and when a message addressed to this
|
|
9
|
-
// agent arrives it RESUMES the CLI session (native resume = full context kept) with that
|
|
10
|
-
// message as the prompt. The model just works and ends its turn; the runner does the rest.
|
|
2
|
+
// trantor crew runner — keeps a crew agent alive without burning tokens: it long-polls the bus
|
|
3
|
+
// (zero tokens) and resumes the CLI with each message. Usage: node crew-runner.mjs <agent> [dir]
|
|
11
4
|
import { execSync, spawnSync, spawn } from "node:child_process";
|
|
12
5
|
import { readFileSync, writeFileSync, unlinkSync, existsSync, appendFileSync, mkdirSync, realpathSync } from "node:fs";
|
|
13
6
|
import { join, basename } from "node:path";
|
|
@@ -120,11 +113,8 @@ function ensureSeatWorktree(sourceDir) {
|
|
|
120
113
|
|
|
121
114
|
const TURN_DIR = ensureSeatWorktree(DIR);
|
|
122
115
|
|
|
123
|
-
// #6154: opencode
|
|
124
|
-
//
|
|
125
|
-
// session a resume may pin — anything else in that DB belongs to another project on this machine,
|
|
126
|
-
// which is exactly what `run -c` used to hand us. Read-only, fail-open: no DB or no row means the
|
|
127
|
-
// next turn starts fresh, which is always safe, instead of resuming a stranger, which never is.
|
|
116
|
+
// #6154: opencode records each session with its directory; the newest row for OUR worktree is the
|
|
117
|
+
// only session a resume may pin. Fail-open: no row means a fresh turn, never a stranger's session.
|
|
128
118
|
const OC_DB = join(process.env.XDG_DATA_HOME || join(homedir(), ".local", "share"), "opencode", "opencode.db");
|
|
129
119
|
function ocSid(dir) {
|
|
130
120
|
try {
|
|
@@ -171,8 +161,7 @@ const LOGDIR = join(homedir(), ".agent-bus", "logs");
|
|
|
171
161
|
try { mkdirSync(LOGDIR, { recursive: true }); } catch {}
|
|
172
162
|
let TURN = 0;
|
|
173
163
|
const telemetry = (rec) => { try { appendFileSync(join(LOGDIR, `${AGENT}-${PROJ}.jsonl`), JSON.stringify(rec) + "\n"); } catch {} };
|
|
174
|
-
//
|
|
175
|
-
// diagnose because nothing on disk said which hub a seat was talking to.
|
|
164
|
+
// The boot line records the HUB this runner bound to, so a split-brain is diagnosable from disk.
|
|
176
165
|
telemetry({ ts: Date.now(), agent: AGENT, project: PROJ, boot: true, hub: HUB });
|
|
177
166
|
// A seat can open a terminal window on a machine whose owner never asked for one and does not know
|
|
178
167
|
// what they are looking at. "◤ CLAUDE ◢ trantor crew · fleet" tells that person nothing: not what
|
|
@@ -194,11 +183,8 @@ async function api(path, body) {
|
|
|
194
183
|
// that shows up as a seat that quietly records nothing rather than one that errors.
|
|
195
184
|
const url = HUB + path;
|
|
196
185
|
const sig = signedHeaders(identity, url, opts);
|
|
197
|
-
//
|
|
198
|
-
//
|
|
199
|
-
// the runner sat "parked" with zero connections and zero retries while its crew was rebuilt
|
|
200
|
-
// around it. Deadline = the poll's own wait window + slack, so a healthy long-poll never trips
|
|
201
|
-
// it and a dead one surfaces as a catchable error that the main loop retries in 5s.
|
|
186
|
+
// A long-poll whose socket dies silently would hang fetch forever, so every call carries a
|
|
187
|
+
// deadline of the poll's own wait window plus slack; a dead poll surfaces as a retryable error.
|
|
202
188
|
const waitS = Number((path.match(/[?&]wait=(\d+)/) || [])[1] || 0);
|
|
203
189
|
const r = await fetch(url, { ...opts, headers: { ...opts.headers, ...sig }, signal: AbortSignal.timeout((waitS + 30) * 1000) });
|
|
204
190
|
return r.json();
|
|
@@ -220,24 +206,13 @@ const BRAND_HEX = { claude: "#D97757", codex: "#e8e8ee", openai: "#e8e8ee", deep
|
|
|
220
206
|
kimi: "#8b8bf5", moonshot: "#8b8bf5", glm: "#5ea0f5", zai: "#5ea0f5", gemini: "#8E75B2", openrouter: "#94A3B8" };
|
|
221
207
|
function cmuxStatus(value, color, icon = "robot", opts = {}) {
|
|
222
208
|
if (!inCmux()) return;
|
|
223
|
-
// Label with the REAL seat identity
|
|
224
|
-
//
|
|
225
|
-
// identically in the sidebar, which is why a runner leak looked like mystery sessions instead of
|
|
226
|
-
// obvious duplicates. Note this is the DISPLAY path; two previous fixes to the crossed-label
|
|
227
|
-
// symptom both landed on the *bus* identity and never touched this line.
|
|
228
|
-
// Pill = "<agent> · <state>" in the agent's BRAND color (alerts keep their alarm color — a red
|
|
229
|
-
// error must read as red at a glance); errors sort first via --priority.
|
|
209
|
+
// Label with the REAL seat identity (this is the DISPLAY path, distinct from the bus identity).
|
|
210
|
+
// Pill = "<agent> · <state>" in the agent's brand color; alerts keep their alarm color.
|
|
230
211
|
const col = opts.alert ? color : (BRAND_HEX[AGENT.toLowerCase()] || color);
|
|
231
212
|
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 {}
|
|
232
213
|
}
|
|
233
|
-
// herdr drops a pane's agent registration when the process inside
|
|
234
|
-
//
|
|
235
|
-
// the seat vanishes from `herdr agent list` after its first turn, `herdr agent attach` starts
|
|
236
|
-
// answering agent_not_found, and the app renders that raw error where the terminal should be.
|
|
237
|
-
// Observed 2026-08-27 on codex, which was crash-looping on an exhausted quota.
|
|
238
|
-
//
|
|
239
|
-
// So re-report at every turn boundary, which also gives herdr a truthful working/idle state.
|
|
240
|
-
// NOTE the argument order: the pane id comes FIRST, before the flags.
|
|
214
|
+
// herdr drops a pane's agent registration when the process inside exits, and a seat's CLI exits
|
|
215
|
+
// every turn, so re-report at each turn boundary. Argument order: pane id FIRST, then the flags.
|
|
241
216
|
function herdrAgent(state) {
|
|
242
217
|
try {
|
|
243
218
|
const f = join(homedir(), ".agent-bus", "crew-windows.txt");
|
|
@@ -274,15 +249,8 @@ const CLI = {
|
|
|
274
249
|
// --yolo in prompt mode (prompt mode auto-approves tools), and emits session_-prefixed ids.
|
|
275
250
|
kimi: { first: `kimi{M} -p "$(cat {P})" < /dev/null`,
|
|
276
251
|
next: `kimi{M} -r {SID} -p "$(cat {P})" < /dev/null`, mflag: " --model ", sid: /To resume this session: kimi -r (\S+)/ },
|
|
277
|
-
// #6154:
|
|
278
|
-
//
|
|
279
|
-
// trantor ones) — and the resumed session's stored directory becomes the Location every relative
|
|
280
|
-
// path resolves against. A seat then `cd desktop/src-tauri` inside its own worktree while
|
|
281
|
-
// opencode resolves it against a stranger's root, the bash tool reads it as external_directory
|
|
282
|
-
// and auto-rejects, and the turn dies mid-work with everything uncommitted. So: every spawn
|
|
283
|
-
// pins --dir to the seat worktree, and a resume pins -s to the session id looked up from
|
|
284
|
-
// opencode's own DB by directory — the session CREATED here (ocSid below). A missed lookup
|
|
285
|
-
// degrades to a fresh session, never to a foreign one.
|
|
252
|
+
// #6154: `run -c` resumes the globally last session on this machine, any project's, and its stored
|
|
253
|
+
// directory becomes the path root. So every spawn pins --dir and a resume pins -s (ocSid below).
|
|
286
254
|
deepseek: { first: `opencode run --dir {DIR}{M} "$(cat {P})"`,
|
|
287
255
|
next: `opencode run --dir {DIR} -s {SID}{M} "$(cat {P})"`, mflag: " -m ", pinned: true, env: join(homedir(), ".token-scrooge", ".env") },
|
|
288
256
|
opencode: { first: `opencode run --dir {DIR}{M} "$(cat {P})"`,
|
|
@@ -295,23 +263,12 @@ const CLI = {
|
|
|
295
263
|
next: `opencode run --dir {DIR} -s {SID}{M} "$(cat {P})"`, mflag: " -m ", pinned: true, env: join(homedir(), ".token-scrooge", ".env") },
|
|
296
264
|
claude: { first: `claude{M} -p "$(cat {P})" --dangerously-skip-permissions`,
|
|
297
265
|
next: `claude -c{M} -p "$(cat {P})" --dangerously-skip-permissions`,
|
|
298
|
-
//
|
|
299
|
-
//
|
|
300
|
-
// state step is a fresh `claude -p` carrying the assembled prefix instead, and
|
|
301
|
-
// `--json-schema` holds the seat to the TurnResult grammar. Used for the first step
|
|
302
|
-
// of a card too: with no `-c` it IS the `first` shape, and a first step that returned
|
|
303
|
-
// no TurnResult would leave the run recorder a hole on turn 1.
|
|
304
|
-
//
|
|
305
|
-
// The flag is off by default and nothing above changes, so the transcript path stays
|
|
306
|
-
// byte-identical — test/state/test-runner-state.mjs asserts that against these very
|
|
307
|
-
// strings rather than against a reading of this comment.
|
|
266
|
+
// TDD §4.6: a state step is a fresh `claude -p` (no `-c`) carrying the assembled prefix, held
|
|
267
|
+
// to the TurnResult grammar by --json-schema; test/state/test-runner-state.mjs pins the strings.
|
|
308
268
|
stateNext: `claude{M} -p "$(cat {P})" --dangerously-skip-permissions --output-format json --json-schema "$(cat {S})"`,
|
|
309
269
|
mflag: " --model " },
|
|
310
|
-
// DeepSeek Harness
|
|
311
|
-
//
|
|
312
|
-
// conversation memory. `trantor connect` builds the ~/.dsh/profiles/trantor composition: their
|
|
313
|
-
// CC-hooks bridge running OUR hooks + their MCP client running our relay server. No model flag:
|
|
314
|
-
// headless takes only the task; the model is profile config.
|
|
270
|
+
// DeepSeek Harness: every turn is a fresh session (headless has no resume), so the seat leans on
|
|
271
|
+
// the wake prompt and the board. `trantor connect` builds ~/.dsh/profiles/trantor; no model flag.
|
|
315
272
|
dsh: { first: `dsh --profile trantor "$(cat {P})" < /dev/null`,
|
|
316
273
|
next: `dsh --profile trantor "$(cat {P})" < /dev/null`, mflag: "", env: join(homedir(), ".token-scrooge", ".env") },
|
|
317
274
|
};
|
|
@@ -328,27 +285,19 @@ if (!CLI[AGENT]) log(`'${AGENT}' is not a built-in seat — running it as an ope
|
|
|
328
285
|
// always-on seats (the fleet DUTY agent, bin/duty.mjs) whose doctrine is not "work your card".
|
|
329
286
|
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. Work your assigned file(s), report on the bus (relay_send, <280 chars), move your Kanban card as you go with a NOTE saying what you did (doing -> testing -> done; in 'testing' run YOUR OWN test file — never the full npm test, suites collide across seats — plus \`node bin/slop-gate.mjs\` when the repo has one: it lints ONLY your changed files against the anti-slop rules, and a card must not reach done with slop-gate failing; use 'failed' + a report if anything breaks). If 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. 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.`;
|
|
330
287
|
|
|
331
|
-
// ---- the pulse
|
|
332
|
-
//
|
|
333
|
-
//
|
|
334
|
-
// The pulse prompt is deliberately almost verbatim the one that works in the wild: re-read the
|
|
335
|
-
// note, continue, check your children, record. Boot discipline rides with it — an empty mission
|
|
336
|
-
// means STAND BY, never invented work.
|
|
288
|
+
// ---- the pulse --------------------------------------------------------------
|
|
289
|
+
// RUNNER_PULSE_MS re-runs an orchestrator seat's mission note on a cadence when the bus is silent;
|
|
290
|
+
// an empty mission means STAND BY, never invented work.
|
|
337
291
|
const PULSE_MS = Math.max(0, Number(process.env.RUNNER_PULSE_MS || 0));
|
|
338
292
|
const MISSION_FILE = process.env.RUNNER_MISSION_FILE || "MISSION.md";
|
|
339
293
|
const PULSE_PROMPT = `[pulse] Re-read your mission note (${MISSION_FILE} in your working directory) and continue your mission. Check on your children and your board, unblock what is stuck, and record what you did. If the mission note is missing, empty, or has no actionable mission, reply ONLY that you are standing by and end your turn — do NOT invent work, create files, or spawn anything.`;
|
|
340
294
|
|
|
341
295
|
// ---- failure visibility ----------------------------------------------------
|
|
342
|
-
// A turn
|
|
343
|
-
//
|
|
344
|
-
// every non-zero turn to the bus in real time so the orchestrator (and `trantor swap`)
|
|
345
|
-
// can react, and flip presence to errored/down.
|
|
296
|
+
// A failed turn would otherwise re-park green on the bus; every non-zero turn is surfaced in real
|
|
297
|
+
// time so the orchestrator and `trantor swap` can react, and presence flips to errored/down.
|
|
346
298
|
let consecFails = 0;
|
|
347
|
-
// The failure
|
|
348
|
-
//
|
|
349
|
-
// everyone else to says report duration, not repetition. Observed cost: a permanently exhausted
|
|
350
|
-
// codex seat broadcast "DOWN" to `all` 31 times over six hours, and every broadcast is a turn for
|
|
351
|
-
// every live seat, so two working agents spent the evening reading the same sentence.
|
|
299
|
+
// The failure state the room has already been told: a seat that is down stays down, and repeating
|
|
300
|
+
// it every retry costs a turn for every live seat (monitoring doctrine: duration, not repetition).
|
|
352
301
|
let announced = "";
|
|
353
302
|
let lastErrText = "";
|
|
354
303
|
// #5481: the turn exited 0 with a NULL/empty transcript — the Inception/Mercury trap. The provider
|
|
@@ -374,21 +323,14 @@ function startDutyNudgeWatcher(plan, sinceMs) {
|
|
|
374
323
|
}
|
|
375
324
|
|
|
376
325
|
// ---- undelivered wake messages (the runner owns delivery, not the hub) ----
|
|
377
|
-
// The hub hands a message out exactly
|
|
378
|
-
//
|
|
379
|
-
// its wake message down with it, and an escalation addressed to this seat was gone forever with
|
|
380
|
-
// no trace anywhere. The queue below makes delivery the runner's job: a message is not consumed
|
|
381
|
-
// until a turn actually exits 0. It survives a runner restart on disk, retries on its own backoff
|
|
382
|
-
// so a silent bus still gets it through, and says how many are outstanding every time it reports.
|
|
326
|
+
// The hub hands a message out exactly once, so a turn that died took its wake with it. Here a
|
|
327
|
+
// message is consumed only when a turn exits 0; the queue lives on disk and retries on backoff.
|
|
383
328
|
const PENDF = join(homedir(), ".agent-bus", `pending-${AGENT}-${PROJ}.json`);
|
|
384
329
|
// A cap, so a long outage cannot grow the queue without bound. Overflow drops the OLDEST and says
|
|
385
330
|
// so on the bus — a silent drop is the exact failure this whole mechanism exists to end.
|
|
386
331
|
const PENDING_MAX = 50;
|
|
387
|
-
//
|
|
388
|
-
//
|
|
389
|
-
// that is already refusing us.
|
|
390
|
-
// TRANTOR_RETRY_MS (comma-separated ms) shortens the ladder so the redelivery drill can exercise
|
|
391
|
-
// a real backoff in seconds instead of waiting out the production one.
|
|
332
|
+
// Redelivery backoff: fast first, landing at 15 minutes ("properly down", not a retry storm).
|
|
333
|
+
// TRANTOR_RETRY_MS (comma-separated ms) shortens the ladder for the redelivery drill.
|
|
392
334
|
const RETRY_MS = (() => {
|
|
393
335
|
// Guard the UNSET case explicitly: "".split(",") is [""], Number("") is 0, and a >=0 filter
|
|
394
336
|
// accepted it — so every production runner got a ZERO backoff and a failing seat became a
|
|
@@ -411,12 +353,8 @@ function loadPending() {
|
|
|
411
353
|
} catch { return { wake: [], bcast: [] }; }
|
|
412
354
|
}
|
|
413
355
|
|
|
414
|
-
// Auth
|
|
415
|
-
//
|
|
416
|
-
// (card #5405). The rules live in lib/classify-failure.mjs (#5868) so they are testable against
|
|
417
|
-
// the real specimens; classify() wraps them with the one-line verdict the seat log carries, and
|
|
418
|
-
// runTurn judges only the CLI's OWN output (the prompt echo is replay, not speech — the rules
|
|
419
|
-
// line "…deleting failing tests is forbidden." once classified healthy codex turns as auth).
|
|
356
|
+
// Auth failures in TURN OUTPUT: opencode prints its auth error and still exits 0 (#5405). The rules
|
|
357
|
+
// live in lib/classify-failure.mjs (#5868); runTurn judges only the CLI's own output, not the echo.
|
|
420
358
|
function classify(exit) {
|
|
421
359
|
const { reason, matched } = classifyFailure(exit, lastErrText, lastEmptyOutput);
|
|
422
360
|
log(`classified ${reason} because ${matched}`);
|
|
@@ -468,10 +406,8 @@ async function reportFailure(exit, trigger, undelivered = 0, reasonOverride = ""
|
|
|
468
406
|
}
|
|
469
407
|
|
|
470
408
|
// ---- a dead seat is not retried (#6134) -------------------------------------------------------
|
|
471
|
-
//
|
|
472
|
-
//
|
|
473
|
-
// redelivered to. So those two reasons PARK — the queue is kept, the ladder stops, and the room is
|
|
474
|
-
// told once, with the reset time when the CLI printed one. `trantor up` (a restart) resumes.
|
|
409
|
+
// Against a spent plan or a rejected key the ladder never succeeds, so those two reasons PARK:
|
|
410
|
+
// queue kept, ladder stopped, room told once with the reset time. `trantor up` resumes.
|
|
475
411
|
let parkAnnounced = false;
|
|
476
412
|
async function parkSeat(reason, undelivered, resetHint = 0) {
|
|
477
413
|
// A seat that went QUIET printed no wall message to parse (#6131), so its own balance row is the
|
|
@@ -486,11 +422,8 @@ async function parkSeat(reason, undelivered, resetHint = 0) {
|
|
|
486
422
|
if (orch !== SESSION) await api("/send", { from: SESSION, to: orch, text, project: PROJ, kind: "alert" }).catch(() => {});
|
|
487
423
|
}
|
|
488
424
|
log(`\x1b[31mparked (${reason})${when ? ` — retrying after ${when}` : " — no reset time in the output; waiting for a restart"}\x1b[0m`);
|
|
489
|
-
// The
|
|
490
|
-
// the
|
|
491
|
-
// the very bus that had stopped moving, to an orchestrator that was idle and therefore could not
|
|
492
|
-
// receive it. The alarm for "the bus is stuck" cannot itself be a bus message. So park also
|
|
493
|
-
// rings a bell the operator can actually hear, out of band, once per park.
|
|
425
|
+
// The alarm for "the bus is stuck" cannot itself be a bus message, so a park also rings a bell
|
|
426
|
+
// the operator can hear out of band, once per park.
|
|
494
427
|
notifyOperator(`Trantor: ${SESSION} PARKED (${reason})`,
|
|
495
428
|
`${undelivered} message(s) held${when ? ` — retrying after ${when}` : ` — needs \`trantor up ${AGENT}\``}`);
|
|
496
429
|
// No reset time means no timer can clear it: hold until the operator restarts the seat.
|
|
@@ -498,9 +431,8 @@ async function parkSeat(reason, undelivered, resetHint = 0) {
|
|
|
498
431
|
}
|
|
499
432
|
|
|
500
433
|
/**
|
|
501
|
-
* Reach the operator on a channel
|
|
502
|
-
*
|
|
503
|
-
* Silence-able with TRANTOR_NO_DESKTOP_NOTIFY=1 for headless boxes and test runs.
|
|
434
|
+
* Reach the operator on a channel independent of the bus, the hub and any session. Best-effort,
|
|
435
|
+
* never fatal; TRANTOR_NO_DESKTOP_NOTIFY=1 silences it for headless boxes and test runs.
|
|
504
436
|
*/
|
|
505
437
|
function notifyOperator(title, body) {
|
|
506
438
|
if (process.env.TRANTOR_NO_DESKTOP_NOTIFY === "1") return;
|
|
@@ -538,13 +470,8 @@ async function balanceRows() {
|
|
|
538
470
|
}
|
|
539
471
|
|
|
540
472
|
// ---- activity truth (#5965): the RUNNER is the source for this seat ----------------
|
|
541
|
-
//
|
|
542
|
-
//
|
|
543
|
-
// begins and `idle` the instant it lands clean. herdr's screen detection cannot see a
|
|
544
|
-
// runner-driven CLI mid-turn (it sets screen_detection_skipped for those panes), which is why
|
|
545
|
-
// seats used to read as idle while genuinely working — the desktop's herdr row is unreliable
|
|
546
|
-
// for runner seats, so it falls back to this hub status. Bounded 5s so a slow hub never delays
|
|
547
|
-
// the very turn it is reporting; one HTTP call per transition, never a poll.
|
|
473
|
+
// herdr cannot see a runner-driven CLI mid-turn, so the runner reports turn boundaries to the hub:
|
|
474
|
+
// `working · <trigger>` at start, `idle` on a clean landing. Bounded 5s, one call per transition.
|
|
548
475
|
async function registerStatus(status) {
|
|
549
476
|
const url = HUB + "/register";
|
|
550
477
|
const body = JSON.stringify({ session: SESSION, project: PROJ, status, llm: AGENT, model: MODEL });
|
|
@@ -555,14 +482,8 @@ async function registerStatus(status) {
|
|
|
555
482
|
}
|
|
556
483
|
|
|
557
484
|
// ---- telling the ASSIGNER, mechanically ------------------------------------
|
|
558
|
-
//
|
|
559
|
-
//
|
|
560
|
-
// orchestrator blind, and nothing watched for the omission. Failures were mechanical but went to
|
|
561
|
-
// "all", and a plain broadcast does not wake anyone (see the wake policy in the main loop). From
|
|
562
|
-
// the orchestrator's seat a finished crew and a crew that never started looked identical.
|
|
563
|
-
//
|
|
564
|
-
// So: whoever sent the message that woke this seat gets told DIRECTLY what became of it. Direct
|
|
565
|
-
// messages wake; that is the whole difference. Kept short, like every other bus line.
|
|
485
|
+
// Whoever sent the wake is told directly what became of it: a direct message wakes, a broadcast
|
|
486
|
+
// does not, and a seat that finished silently left the orchestrator blind.
|
|
566
487
|
async function notifyAssigners(pairs, text) {
|
|
567
488
|
text = redactKeys(text); // #5869: the "asked" excerpt quotes the wake message — keys stay off the bus
|
|
568
489
|
const seen = new Set();
|
|
@@ -577,7 +498,10 @@ async function notifyAssigners(pairs, text) {
|
|
|
577
498
|
// right one instead of guessing from timing.
|
|
578
499
|
const payload = { from: SESSION, to: f, text: text.slice(0, 280), project: PROJ, kind: "receipt" };
|
|
579
500
|
if (id) payload.re = id;
|
|
580
|
-
await api("/send", payload).catch(() => {});
|
|
501
|
+
const ack = await api("/send", payload).catch(() => ({}));
|
|
502
|
+
// #7288: remember the id, so a reply threaded onto THIS outcome can later be told apart from a
|
|
503
|
+
// work order that merely rides `re`.
|
|
504
|
+
if (Number(ack?.id) > 0) sentOutcomes.add(Number(ack.id));
|
|
581
505
|
}
|
|
582
506
|
if (seen.size) log(`reported outcome to ${[...seen].join(", ")}`);
|
|
583
507
|
}
|
|
@@ -594,12 +518,8 @@ async function reportHealthy() {
|
|
|
594
518
|
}
|
|
595
519
|
|
|
596
520
|
// ---- Trantor State Phase 2a — the flagged path (TDD §4.1, §4.6, §7.3) -----------------------
|
|
597
|
-
//
|
|
598
|
-
//
|
|
599
|
-
// before a single byte of this is reachable: the operator set TRANTOR_STATE_ASSEMBLE=1, the seat is
|
|
600
|
-
// `claude` (§7.3 — it is the only row whose CLI can enforce the grammar), and the installed CLI
|
|
601
|
-
// actually carries `--json-schema` (§6 — the minimum version is unconfirmed, so this PROBES rather
|
|
602
|
-
// than assuming; no flag, no state mode, and the runner says so once).
|
|
521
|
+
// Off by default, and off means the transcript path runs unchanged. Reachable only when the operator
|
|
522
|
+
// set TRANTOR_STATE_ASSEMBLE=1, the seat is `claude` (§7.3) and the CLI carries --json-schema (§6).
|
|
603
523
|
const STATE_FLAG_ON = process.env[STATE_ENV] === "1";
|
|
604
524
|
const STATE_SCHEMA_FILE = join(homedir(), ".agent-bus", `state-schema-${AGENT}-${PROJ}.json`);
|
|
605
525
|
const STATE_MODE = (() => {
|
|
@@ -611,25 +531,15 @@ const STATE_MODE = (() => {
|
|
|
611
531
|
mkdirSync(join(homedir(), ".agent-bus"), { recursive: true, mode: 0o700 });
|
|
612
532
|
writeFileSync(STATE_SCHEMA_FILE, JSON.stringify(TURN_RESULT_SCHEMA), { mode: 0o600 });
|
|
613
533
|
} catch (e) { log(`\x1b[33mstate mode OFF — could not write ${STATE_SCHEMA_FILE}: ${e.message}\x1b[0m`); return false; }
|
|
614
|
-
// #7060:
|
|
615
|
-
//
|
|
616
|
-
// the two come apart on the literal next turn: the kickoff runs before any message exists, so it
|
|
617
|
-
// belongs to no card and cannot be a state step. So say what was proved — armed — and name the
|
|
618
|
-
// one thing that engages it. Each turn then reports which path it actually took.
|
|
534
|
+
// #7060: the checks above prove CONFIGURATION, not the prompt, so say "armed" and name the one
|
|
535
|
+
// thing that engages it; each turn then reports which path it took.
|
|
619
536
|
log(`\x1b[36mTrantor State: ASSEMBLE armed for this seat (schema ${STATE_SCHEMA_FILE})\x1b[0m`);
|
|
620
537
|
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`);
|
|
621
538
|
return true;
|
|
622
539
|
})();
|
|
623
540
|
|
|
624
|
-
// #7060: the one place a turn decides whether it is a state step
|
|
625
|
-
// spoken.
|
|
626
|
-
// the runner reads `if (!stateSkip(...))` and cannot drift from what the operator was just told.
|
|
627
|
-
//
|
|
628
|
-
// It speaks on CHANGE, not on repetition. A pulse fires on a timer and skips for the same reason
|
|
629
|
-
// every time; printing that line forever is the repetition the monitoring doctrine rules out, and
|
|
630
|
-
// it would bury the turn where the path actually flipped. Assembling a turn clears the memory, so
|
|
631
|
-
// the next skip after real work always speaks. Silent when state mode is off: the IIFE above
|
|
632
|
-
// already said why, once, and a transcript seat has no claim here to mistake for proof.
|
|
541
|
+
// #7060: the one place a turn decides whether it is a state step and the one place a skip is
|
|
542
|
+
// spoken. Speaks on CHANGE, not repetition; assembling a turn clears the memory so the next skip speaks.
|
|
633
543
|
let spokenStateSkip = null;
|
|
634
544
|
const stateSkip = (kind, card = 0) => {
|
|
635
545
|
const why = stateSkipReason({ mode: STATE_MODE, kind, breakerTripped, card });
|
|
@@ -677,10 +587,8 @@ let breakerTripped = false;
|
|
|
677
587
|
let statePromotedHash;
|
|
678
588
|
|
|
679
589
|
// ---- the time box (#6134) --------------------------------------------------------------------
|
|
680
|
-
//
|
|
681
|
-
//
|
|
682
|
-
// at the box and runs ONE follow-up turn in the SAME session — "commit what is done, move the
|
|
683
|
-
// card, report in one line" — so a cut turn lands its work instead of losing it.
|
|
590
|
+
// TRANTOR_TURN_MAX_MS ends the CLI's process group at the box and runs ONE follow-up turn in the
|
|
591
|
+
// same session ("commit what is done, move the card, report") so a cut turn lands its work.
|
|
684
592
|
const TURN_MAX_MS = Math.max(0, Number(process.env.TRANTOR_TURN_MAX_MS || 20 * 60 * 1000));
|
|
685
593
|
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";
|
|
686
594
|
let inFollowUp = false;
|
|
@@ -694,7 +602,7 @@ let sessionCard = 0;
|
|
|
694
602
|
|
|
695
603
|
let sid = "";
|
|
696
604
|
// #6206: the watchdog is DETACHED, so a runner that dies without ending it leaves an orphan
|
|
697
|
-
// sleeping toward a false alarm against whatever runner comes next
|
|
605
|
+
// sleeping toward a false alarm against whatever runner comes next.
|
|
698
606
|
// Every exit path therefore kills it, and the stamp carries this runner's instance id so any
|
|
699
607
|
// survivor that outlives the kill still refuses to speak for a runner it never belonged to.
|
|
700
608
|
const RUNNER_ID = `${process.pid}.${Date.now()}`;
|
|
@@ -732,14 +640,8 @@ async function runTurn(prompt, isFirst, trigger = "kickoff", opts = {}) {
|
|
|
732
640
|
const mfrag = MODEL && cli.mflag ? `${cli.mflag}${MODEL}` : "";
|
|
733
641
|
cmd = cmd.replaceAll("{M}", mfrag).replaceAll("{P}", pf).replaceAll("{SID}", sid).replaceAll("{DIR}", TURN_DIR)
|
|
734
642
|
.replaceAll("{S}", STATE_SCHEMA_FILE);
|
|
735
|
-
// PRECEDENCE
|
|
736
|
-
//
|
|
737
|
-
// LAST wins. To make ~/.agent-bus/.env (the CREW layer) win it must be prepended FIRST, i.e.
|
|
738
|
-
// iterate the list in its written order — highest priority first. A `.reverse()` here inverted it
|
|
739
|
-
// and handed every seat Scrooge's key instead of the crew's, which is why one key was paying for
|
|
740
|
-
// both and no provider bill could tell them apart. `.reverse()` also mutated the array in place.
|
|
741
|
-
// Verified by test-crew-env.mjs, which runs the real shell rather than reading this comment.
|
|
742
|
-
// Priority order: the CREW layer first, the agent's own fallback (Scrooge's .env) after it.
|
|
643
|
+
// PRECEDENCE: each file is PREPENDED, so the list is iterated in written order, highest priority
|
|
644
|
+
// first, and the CREW layer (~/.agent-bus/.env) wins. test-crew-env.mjs runs the real shell.
|
|
743
645
|
const envs = [join(homedir(), ".agent-bus", ".env"), cli.env].filter(f => f && existsSync(f));
|
|
744
646
|
cmd = withEnvFiles(cmd, envs);
|
|
745
647
|
log(`turn starting (${isFirst ? "fresh session" : "resume"})${MODEL ? ` · model=${MODEL}` : ""}`);
|
|
@@ -748,18 +650,8 @@ async function runTurn(prompt, isFirst, trigger = "kickoff", opts = {}) {
|
|
|
748
650
|
// Tee stderr to ERRF (still shown live in the window) so a failed turn can be classified.
|
|
749
651
|
try { appendFileSync(ERRF, "", { flag: "w" }); } catch {} // truncate
|
|
750
652
|
lastEmptyOutput = false;
|
|
751
|
-
// pipefail
|
|
752
|
-
//
|
|
753
|
-
// A CLI's own explanation for quitting often goes to STDOUT, not stderr — Claude's usage-limit
|
|
754
|
-
// notice is the case that bit us: ERRF stayed empty, so a plainly exhausted seat was reported as
|
|
755
|
-
// `crashed` and nobody knew to swap it. sid seats already fold stdout into the ERRF stream via
|
|
756
|
-
// `tee /dev/stderr`; the rest now tee straight into ERRF. A real pipeline (not a process
|
|
757
|
-
// substitution) so bash waits for tee to flush before we read the file back.
|
|
758
|
-
// #5869: redaction rides IN the pipeline — lib/redact.mjs is a tee replacement that echoes
|
|
759
|
-
// stdin verbatim to the live window and appends only REDACTED bytes to ERRF, so a CLI that
|
|
760
|
-
// echoes its environment never parks a provider key in a file every seat can read. The tee
|
|
761
|
-
// topology is load-bearing (#5481): stdout+stderr must still BOTH land in ERRF, and the sid
|
|
762
|
-
// path still folds stdout in via /dev/stderr → the --tee2 hop below.
|
|
653
|
+
// pipefail so a failed CLI behind `| tee` still exits non-zero. Every stream lands in ERRF via
|
|
654
|
+
// lib/redact.mjs (#5869, redacted bytes only); the tee topology is load-bearing (#5481).
|
|
763
655
|
const SCRUB = `node ${join(import.meta.dirname, "..", "lib", "redact.mjs")}`;
|
|
764
656
|
// §4.6 names a real cost of `--output-format json`: the seat's window would print a JSON blob
|
|
765
657
|
// instead of prose, and the operator watches that window. So on a state step stdout goes to a
|
|
@@ -770,19 +662,12 @@ async function runTurn(prompt, isFirst, trigger = "kickoff", opts = {}) {
|
|
|
770
662
|
const inner = opts.state
|
|
771
663
|
? `${cmd} > ${ENVF}`
|
|
772
664
|
: (cli.sid ? `${cmd} | tee /dev/stderr` : `${cmd} | ${SCRUB} --tee ${ERRF}`);
|
|
773
|
-
// #5684: runTurn is spawnSync, so
|
|
774
|
-
//
|
|
775
|
-
// the window with no activity (transcript, worktree, or stderr — #6206: stdout silence alone
|
|
776
|
-
// is never a stall) earns ONE direct stall report to the foreman, never a kill.
|
|
777
|
-
// #6206: the window's floor is 10 minutes and is never derived from TRANTOR_TURN_MAX_MS —
|
|
778
|
-
// a 1-minute alarm is a false alarm by construction. The env override exists for drills.
|
|
665
|
+
// #5684: runTurn is spawnSync, so a DETACHED watchdog (stamp-armed) watches the turn and sends
|
|
666
|
+
// ONE stall report, never a kill. #6206: floor 10 min, never derived from TRANTOR_TURN_MAX_MS.
|
|
779
667
|
const WD_MS = Number(process.env.TRANTOR_TURN_WATCHDOG_MS) || 10 * 60 * 1000;
|
|
780
668
|
const STAMPF = join(homedir(), ".agent-bus", `turnstamp-${AGENT}-${PROJ}.json`);
|
|
781
|
-
// #6206:
|
|
782
|
-
//
|
|
783
|
-
// CLI's env so a drill's fake CLI can write transcript lines the watchdog will see.
|
|
784
|
-
// Written by the shell's own time box (below) and read back here — the only honest signal that
|
|
785
|
-
// the turn was CUT rather than that the CLI failed on its own. Cleared before every turn.
|
|
669
|
+
// #6206: the CLI's transcript dir (a missing dir is a quiet channel), exported so a drill's fake
|
|
670
|
+
// CLI can write lines the watchdog sees. CUTF is written by the shell's time box: cut, not crashed.
|
|
786
671
|
const CUTF = join(homedir(), ".agent-bus", `turncut-${AGENT}-${PROJ}`);
|
|
787
672
|
try { unlinkSync(CUTF); } catch {}
|
|
788
673
|
// Touched by the stderr scrubber as its LAST act (the shell below); node waits for it after
|
|
@@ -796,22 +681,8 @@ async function runTurn(prompt, isFirst, trigger = "kickoff", opts = {}) {
|
|
|
796
681
|
WD_CHILD = wd;
|
|
797
682
|
wd.unref();
|
|
798
683
|
} catch {}
|
|
799
|
-
//
|
|
800
|
-
//
|
|
801
|
-
// under load the classifier then reads an empty ERRF and reports the wrong failure reason.
|
|
802
|
-
// #6134-followup: the time box has to fire from INSIDE the shell, while the process tree is
|
|
803
|
-
// still standing. Killing the turn's process group from node missed a grandchild — codex runs
|
|
804
|
-
// its own commands via setsid, so `sleep 400` sat in a different group and survived
|
|
805
|
-
// process.kill(-pid). Worse, by the time node's timeout has killed bash the survivors have been
|
|
806
|
-
// reparented to init, so there is no tree left to walk and nothing to sweep.
|
|
807
|
-
//
|
|
808
|
-
// So bash boxes itself: at the deadline it walks its own descendants and kills them bottom-up.
|
|
809
|
-
// setsid changes a process's group and session but NEVER its parent, so `pgrep -P` recursion
|
|
810
|
-
// reaches exactly the children that a group signal cannot. Children first, then the parent, so
|
|
811
|
-
// nothing gets reparented mid-sweep and escapes the walk.
|
|
812
|
-
//
|
|
813
|
-
// The marker file is how node learns the turn was cut rather than merely failing: an exit status
|
|
814
|
-
// alone cannot tell "killed at the box" from "the CLI died on its own".
|
|
684
|
+
// #6134: the box fires from INSIDE the shell, walking its own descendants bottom-up with `pgrep -P`
|
|
685
|
+
// (setsid escapes a group signal, never its parent). The marker file tells node "cut", not "crashed".
|
|
815
686
|
const sweep = `sweep() { local p; for p in $(pgrep -P $1 2>/dev/null); do sweep $p; done; kill -KILL $1 2>/dev/null; }`;
|
|
816
687
|
const box = TURN_MAX_MS ? `
|
|
817
688
|
${sweep}
|
|
@@ -828,31 +699,16 @@ wait $job; turn_exit=$?
|
|
|
828
699
|
wait
|
|
829
700
|
exit $turn_exit`;
|
|
830
701
|
const spawnOpts = {
|
|
831
|
-
// detached: bash leads its
|
|
832
|
-
//
|
|
833
|
-
// stdin is /dev/null for every seat (it already was for codex/kimi/dsh via `< /dev/null`):
|
|
834
|
-
// a detached group is a BACKGROUND group, and a background process that reads the terminal
|
|
835
|
-
// takes SIGTTIN and stops forever. Nothing here runs interactively — every CLI is in -p /
|
|
836
|
-
// exec / run mode — so closing stdin is what makes the group safe.
|
|
702
|
+
// detached: bash leads its own process group so the box can kill the CLI and everything it
|
|
703
|
+
// spawned. stdin is /dev/null: a background group that reads the terminal stops on SIGTTIN.
|
|
837
704
|
detached: true,
|
|
838
705
|
cwd: TURN_DIR, encoding: "utf8", stdio: cli.sid ? ["ignore", "pipe", "inherit"] : ["ignore", "inherit", "inherit"],
|
|
839
706
|
env: { ...process.env, RELAY_URL: HUB, RELAY_AGENT: AGENT, RELAY_SESSION: SESSION, RELAY_PROJECT: PROJ,
|
|
840
|
-
// #6228:
|
|
841
|
-
//
|
|
842
|
-
// plain shell, set RELAY_PROJECT alone and must keep working — only THIS marker means "the
|
|
843
|
-
// env I'm running in already has a project home"). crew.mjs's `up` guard refuses to bring up a
|
|
844
|
-
// DIFFERENT project's crew from a shell carrying this badge, same as it refuses TRANTOR_ORCH.
|
|
707
|
+
// #6228: marks this env as belonging to PROJ, unlike a one-off RELAY_PROJECT override; crew.mjs's
|
|
708
|
+
// `up` guard refuses to bring up another project's crew from a shell carrying this badge.
|
|
845
709
|
TRANTOR_SEAT: PROJ,
|
|
846
|
-
// A
|
|
847
|
-
//
|
|
848
|
-
// The handoff machinery exists for an INTERACTIVE session: near its context limit it writes a
|
|
849
|
-
// handoff and opens a fresh window to carry on. A seat has no use for that — the runner is its
|
|
850
|
-
// lifecycle manager and wakes it per event — so the spawn just leaks an unmanaged interactive
|
|
851
|
-
// session into a window nobody asked for.
|
|
852
|
-
//
|
|
853
|
-
// Observed on the duty seat: handoff records at 17:24 and 18:59 on 2026-08-24, and two stray
|
|
854
|
-
// `claude` processes in ~/.agent-bus/trantor-duty started at 17:24:57 and 18:59:50, still
|
|
855
|
-
// sitting there days later. To the operator that reads as "why are there two duty agents".
|
|
710
|
+
// A runner-managed seat must never hand itself a baton: the runner is its lifecycle manager,
|
|
711
|
+
// so a handoff spawn would only leak an unmanaged interactive window.
|
|
856
712
|
TRANTOR_NO_HANDOFF_SPAWN: "1", TRANTOR_NO_BATON_SPAWN: "1",
|
|
857
713
|
// #6206: the seat's transcript dir — a real CLI ignores it, a drill's fake CLI writes
|
|
858
714
|
// its transcript lines there so the watchdog sees the liveness a real claude shows.
|
|
@@ -868,14 +724,8 @@ exit $turn_exit`;
|
|
|
868
724
|
// cut, not merely failed.
|
|
869
725
|
const boxed = existsSync(CUTF);
|
|
870
726
|
const cut = !!TURN_MAX_MS && (boxed || r.error?.code === "ETIMEDOUT");
|
|
871
|
-
// DRAIN before classifying
|
|
872
|
-
//
|
|
873
|
-
// /bin/bash) `wait` does NOT wait for process substitutions — verified 2026-09-05 — so when
|
|
874
|
-
// spawnSync returns on a LIVE turn, the stderr scrubber can still be draining, and an auth
|
|
875
|
-
// line still in the pipe reads as an EMPTY ERRF: the turn is then mislabelled "empty-output",
|
|
876
|
-
// which breaks the seat-down contract (wrong DOWN label, retry ladder instead of a park) and
|
|
877
|
-
// cost run 33940247163 three CI-only drill-6 failures. The scrubber touches DRAINF as its
|
|
878
|
-
// last act; wait for it, bounded.
|
|
727
|
+
// DRAIN before classifying, never on a CUT turn (the sweep killed the scrubber, its marker never
|
|
728
|
+
// comes). bash 3.2 `wait` skips process substitutions, so wait for DRAINF, bounded.
|
|
879
729
|
if (!cut) {
|
|
880
730
|
const drainStart = Date.now();
|
|
881
731
|
while (!existsSync(DRAINF) && Date.now() - drainStart < 3000) await new Promise(s => setTimeout(s, 50));
|
|
@@ -912,13 +762,8 @@ exit $turn_exit`;
|
|
|
912
762
|
// and the next turn starts fresh rather than resuming whatever other project ran last.
|
|
913
763
|
if (cli.pinned) { const found = ocSid(TURN_DIR); if (found) sid = found; }
|
|
914
764
|
const realExit = r.status;
|
|
915
|
-
// A zero exit is
|
|
916
|
-
//
|
|
917
|
-
// green through an auth outage (card #5405). Cross-check the turn output and treat an
|
|
918
|
-
// exit-0-with-auth turn as FAILED — but ONLY when the CLI's own output is short enough to be
|
|
919
|
-
// just the error (#5868): a long output is a real answer, and a warning inside it must not
|
|
920
|
-
// fail the turn. Telemetry keeps the REAL exit; the returned code is the effective one every
|
|
921
|
-
// call site branches on (kickoff, pulse, deliverWake).
|
|
765
|
+
// A zero exit is not proof the turn ran (#5405): an exit-0 auth turn is FAILED when the CLI's own
|
|
766
|
+
// output is short enough to be just the error (#5868). Telemetry keeps the real exit.
|
|
922
767
|
let effExit = realExit;
|
|
923
768
|
let authHit = "";
|
|
924
769
|
// #5868: a NEW commit since turn start is real work, and an exit-0 turn with real output is
|
|
@@ -930,18 +775,8 @@ exit $turn_exit`;
|
|
|
930
775
|
authHit = AUTH_MARKER_RE.exec(ownOut)[0];
|
|
931
776
|
log(`\x1b[31mexit 0 but the turn output IS an auth failure — treating as FAILED (auth, "${authHit}")\x1b[0m`);
|
|
932
777
|
}
|
|
933
|
-
// #5481:
|
|
934
|
-
//
|
|
935
|
-
// opencode family, `| tee /dev/stderr` + the stderr tee for sid seats — line ~448). So an
|
|
936
|
-
// empty ERRF on a clean exit means the turn produced nothing on EITHER stream — and every
|
|
937
|
-
// real CLI prints something on success (drill C pins that), so silence is the trap, not a
|
|
938
|
-
// quiet victory. (Integration note: this was nearly "fixed" into stdout-only detection that
|
|
939
|
-
// never fired — the tee topology is the load-bearing fact; keep this comment with it.)
|
|
940
|
-
// The judgment now runs on the ECHO-STRIPPED text (#5868): a CLI that replays the prompt but
|
|
941
|
-
// does no work has still produced nothing of its own.
|
|
942
|
-
// #6969: on a state step the CLI's whole answer is the envelope, so ERRF holds only stderr and
|
|
943
|
-
// silence there is the NORMAL shape of a healthy turn. Judging it "empty-output" would park a
|
|
944
|
-
// working seat on its first clean state step.
|
|
778
|
+
// #5481: exit 0 with an empty ERRF (the TOTAL capture, both streams) is the null-completion trap,
|
|
779
|
+
// judged on echo-stripped text (#5868). #6969: on a state step ERRF is stderr only, so silence is normal.
|
|
945
780
|
if (realExit === 0 && effExit === 0 && !lastErrText.trim() && !lastEnvelope.trim()) {
|
|
946
781
|
effExit = 1;
|
|
947
782
|
lastEmptyOutput = true;
|
|
@@ -971,14 +806,8 @@ exit $turn_exit`;
|
|
|
971
806
|
// #5965 — TURN END. A clean exit means the seat is idle again; say so right away so the app stops
|
|
972
807
|
// pulsing it even before the next /poll heartbeat. Failure keeps reportFailure's down/errored.
|
|
973
808
|
if (realExit === 0 && effExit === 0) await registerStatus("idle");
|
|
974
|
-
// The follow-up rides the SAME session,
|
|
975
|
-
//
|
|
976
|
-
// boxed turn forever is the loop this card exists to end.
|
|
977
|
-
// A state step gets no prose follow-up: TIME_BOX_PROMPT is not a TurnResult prompt, and feeding
|
|
978
|
-
// it would break the byte-identical prefix the cost claim rests on. It is also unnecessary —
|
|
979
|
-
// §4.4 is explicit that a cut turn has never partially applied a patch, so what was lost is the
|
|
980
|
-
// dead turn's observations, and store.recover() rebuilds those from git on the next readState.
|
|
981
|
-
// The step is recorded with `cut: true` so §8.7 can still count it.
|
|
809
|
+
// The follow-up rides the SAME session, exactly once. A state step gets none: TIME_BOX_PROMPT
|
|
810
|
+
// would break the byte-identical prefix, and §4.4 says a cut turn never partially applied a patch.
|
|
982
811
|
if (cut && !inFollowUp && !opts.state) {
|
|
983
812
|
inFollowUp = true;
|
|
984
813
|
try { return await runTurn(TIME_BOX_PROMPT, false, "time-box follow-up"); }
|
|
@@ -992,10 +821,8 @@ exit $turn_exit`;
|
|
|
992
821
|
let stateObservation = "";
|
|
993
822
|
|
|
994
823
|
// ---- Phase 2a: one state step, driven by lib/state/driver.mjs -------------------------------
|
|
995
|
-
//
|
|
996
|
-
//
|
|
997
|
-
// CLI and a way to act on the returned action, and the driver holds the §4.1 order. Returns an
|
|
998
|
-
// exit code so deliverWake's success/failure ladder is untouched.
|
|
824
|
+
// The runner is transport and side effects; the driver holds the §4.1 order. Returns an exit code
|
|
825
|
+
// so deliverWake's success/failure ladder is untouched.
|
|
999
826
|
async function stateTurn({ card, observation, trigger, assigners = [] }) {
|
|
1000
827
|
const tail = await cardTail(card);
|
|
1001
828
|
const r = await runStep({
|
|
@@ -1063,11 +890,8 @@ async function loadLessons() {
|
|
|
1063
890
|
} catch {}
|
|
1064
891
|
}
|
|
1065
892
|
|
|
1066
|
-
//
|
|
1067
|
-
//
|
|
1068
|
-
// because a resumed session re-fed the full lessons block (22,298 of the 24,698 chars in its last
|
|
1069
|
-
// turn file — 90%) plus an unbounded broadcast backlog on EVERY turn, redelivery after redelivery.
|
|
1070
|
-
// Below the caps the composition is byte-identical to the old concatenation.
|
|
893
|
+
// #5683: every prompt section is capped (bin/crew-payload.mjs) and the payload has ONE hard total
|
|
894
|
+
// cap; below the caps the composition is byte-identical to the old concatenation.
|
|
1071
895
|
function composedTurn({ base = "", wakeText = "", ctxText = "", againText = "", tailText = "", rulesText = "", lessons = null }) {
|
|
1072
896
|
const built = composePrompt([
|
|
1073
897
|
{ name: "base", text: base },
|
|
@@ -1087,11 +911,20 @@ function composedTurn({ base = "", wakeText = "", ctxText = "", againText = "",
|
|
|
1087
911
|
const RECEIPT_MARKER = "✅ done on";
|
|
1088
912
|
const CARD_REF_RE = /#\d{1,7}(?!\d)/;
|
|
1089
913
|
|
|
914
|
+
// Outcome ids this seat SENT (notifyAssigners records them): a reply threaded onto one of these is
|
|
915
|
+
// about our own outcome, not a new contract.
|
|
916
|
+
const sentOutcomes = new Set();
|
|
917
|
+
|
|
1090
918
|
// Runner-authored metadata is bus state, not work. Typed messages are authoritative; `re` and the
|
|
1091
919
|
// stable text marker keep a mixed-version crew safe while older runners are still on the bus.
|
|
1092
920
|
function isReceipt(message) {
|
|
1093
921
|
const text = String(message?.text || "").trimStart();
|
|
1094
|
-
|
|
922
|
+
if (message?.kind === "receipt" || text.startsWith(RECEIPT_MARKER)) return true;
|
|
923
|
+
// #7288: `re` alone is NOT a receipt — senders are told to thread EVERY reply, so a corrected
|
|
924
|
+
// contract riding `re` must wake. A reply is a receipt only when it threads an outcome THIS seat
|
|
925
|
+
// sent and its own text carries no work (an ack/closure, #7079).
|
|
926
|
+
if (!(Number(message?.re) > 0)) return false;
|
|
927
|
+
return sentOutcomes.has(Number(message.re)) && !carriesWork(text) && !isContract(message);
|
|
1095
928
|
}
|
|
1096
929
|
|
|
1097
930
|
function isStatusBroadcast(message) {
|
|
@@ -1117,16 +950,8 @@ function isRunnerSession(session) {
|
|
|
1117
950
|
return /^[a-z0-9_.-]+$/.test(label) && !label.startsWith("hub:");
|
|
1118
951
|
}
|
|
1119
952
|
|
|
1120
|
-
// A hub staleness alert describes a
|
|
1121
|
-
//
|
|
1122
|
-
// had no expiry, so on 2026-09-09 the duty seat's backlog became SELF-POISONING: the hub kept
|
|
1123
|
-
// noticing undelivered mail and sending more alerts, duty could not work them off, and a restart
|
|
1124
|
-
// faithfully redelivered 49 dead nudges and re-wedged the seat. 46 of those 49 were hub alerts, the
|
|
1125
|
-
// oldest 22.1 hours old, every one describing a two-minute condition.
|
|
1126
|
-
//
|
|
1127
|
-
// So these EXPIRE. Deliberately narrow: only messages the HUB generated about staleness, never a
|
|
1128
|
-
// message from a peer. A real contract is never dropped for being old — a seat that misses a
|
|
1129
|
-
// teammate's request is the failure this bus exists to prevent, and no backlog is worth causing it.
|
|
953
|
+
// A hub staleness alert describes a moment, so it EXPIRES; a peer's message never does, because a
|
|
954
|
+
// seat missing a teammate's request is the failure this bus exists to prevent.
|
|
1130
955
|
const HUB_ALERT_TTL_MS = Number(process.env.TRANTOR_HUB_ALERT_TTL_MS || 30 * 60_000);
|
|
1131
956
|
const isExpiredHubAlert = (m) =>
|
|
1132
957
|
m?.from === "hub:duty" &&
|
|
@@ -1141,12 +966,8 @@ function shouldWake(message) {
|
|
|
1141
966
|
if (message?.wake === false) return false;
|
|
1142
967
|
if (message?.to === SESSION) {
|
|
1143
968
|
if (message?.kind === "status") return false;
|
|
1144
|
-
//
|
|
1145
|
-
//
|
|
1146
|
-
// Two exemptions, both because the shape net reads WORDS and these carry their meaning in
|
|
1147
|
-
// their type: a typed alert (a failure escalation, a bounce), and an OVERSEER warning that got
|
|
1148
|
-
// this far — the one chatty overseer kind is already batched by name upstream, so anything
|
|
1149
|
-
// still here is file-conflict or linked-activity, which #5760 deliberately kept waking.
|
|
969
|
+
// Safety net for senders that never set the flag: a direct message carrying no card and no
|
|
970
|
+
// instruction is context. Typed alerts and overseer warnings still wake (#5760).
|
|
1150
971
|
const typed = message?.kind === "alert" || /^🤝 OVERSEER /.test(String(message?.text || ""));
|
|
1151
972
|
if (!typed && !isContract(message) && !carriesWork(message?.text)) return false;
|
|
1152
973
|
return !isRunnerSession(message?.from) || isContract(message);
|
|
@@ -1187,9 +1008,8 @@ function askedExcerpt(message) {
|
|
|
1187
1008
|
// no crew-windows.txt to fall back to. /register preserves absent fields, so a seat running an
|
|
1188
1009
|
// older runner never loses a kind an updated one stamped.
|
|
1189
1010
|
await api("/register", { session: SESSION, project: PROJ, status: "crew member booting", llm: AGENT, model: MODEL, kind: "agent" }).catch(() => {});
|
|
1190
|
-
// Announce runner-side, signed as THIS seat
|
|
1191
|
-
//
|
|
1192
|
-
// 2026-07-29): the runner process is per-seat by construction, so its signature cannot be borrowed.
|
|
1011
|
+
// Announce runner-side, signed as THIS seat: the runner process is per-seat by construction, so
|
|
1012
|
+
// its signature cannot be borrowed the way a shared opencode MCP daemon once borrowed identities.
|
|
1193
1013
|
try {
|
|
1194
1014
|
const { sfetchJson } = await import("../lib/signed-fetch.mjs");
|
|
1195
1015
|
const { loadOrCreate } = await import("../lib/identity.mjs");
|
|
@@ -1213,11 +1033,8 @@ function askedExcerpt(message) {
|
|
|
1213
1033
|
let pendingBcast = restored.bcast.filter(m => !isExpiredHubAlert(m) && !isReceipt(m) && !isStatusBroadcast(m));
|
|
1214
1034
|
if (shed) {
|
|
1215
1035
|
log(`\x1b[33mdropped ${shed} expired hub staleness alert(s) older than ${Math.round(HUB_ALERT_TTL_MS / 60000)}m — they describe conditions that have long since changed\x1b[0m`);
|
|
1216
|
-
//
|
|
1217
|
-
//
|
|
1218
|
-
// 4 held, because status reads the FILE and the file was still the pre-shed one. Disk and memory
|
|
1219
|
-
// disagreeing is the whole class of bug this day was about — a health check cannot be honest if
|
|
1220
|
-
// the state it reads is stale.
|
|
1036
|
+
// Persist the shed queue NOW: `trantor duty status` reads the FILE, and disk and memory
|
|
1037
|
+
// disagreeing makes a health check lie.
|
|
1221
1038
|
savePending(pendingWake, pendingBcast);
|
|
1222
1039
|
}
|
|
1223
1040
|
let retryAt = 0; // 0 = deliver at the next opportunity
|
|
@@ -1279,11 +1096,8 @@ function askedExcerpt(message) {
|
|
|
1279
1096
|
// reply-linked outcomes, and the old stable marker before direct-address logic sees them. Status
|
|
1280
1097
|
// broadcasts are presence chatter and are dropped rather than saved as future prompt context.
|
|
1281
1098
|
msgs = msgs.filter(m => !isReceipt(m) && !isStatusBroadcast(m));
|
|
1282
|
-
// #5760
|
|
1283
|
-
//
|
|
1284
|
-
// kind is pure coordination CONTEXT ("no human needs to relay this" — and no turn needs to
|
|
1285
|
-
// burn on it either): batch it like a broadcast. file-conflict and linked-activity overseer
|
|
1286
|
-
// warnings still wake — those are actionable by the seat right now.
|
|
1099
|
+
// #5760: the hub's hourly same-project-sessions FYI is coordination context, batched like a
|
|
1100
|
+
// broadcast; file-conflict and linked-activity overseer warnings still wake.
|
|
1287
1101
|
const fyi = msgs.filter(m => m.from === "hub:duty" && String(m.text || "").startsWith("🤝 OVERSEER same-project-sessions"));
|
|
1288
1102
|
const rest = msgs.filter(m => !fyi.includes(m));
|
|
1289
1103
|
const direct = rest.filter(m => m.to === SESSION && shouldWake(m));
|
|
@@ -1294,14 +1108,8 @@ function askedExcerpt(message) {
|
|
|
1294
1108
|
const bcast = [...rest.filter(m => !direct.includes(m) && !mentions.includes(m)), ...fyi];
|
|
1295
1109
|
pendingBcast.push(...bcast); // wake-policy: plain broadcasts batch, they don't wake
|
|
1296
1110
|
const wakeCandidates = [...direct, ...mentions];
|
|
1297
|
-
// #6228: a wake naming
|
|
1298
|
-
//
|
|
1299
|
-
// into context. One report goes back to the sender so it does not just look like silence.
|
|
1300
|
-
// The hub's OWN agents (`hub:duty` et al.) are exempt: they speak for this hub's projects,
|
|
1301
|
-
// not a foreign one, and fencing them made every seat deaf to #5760's actionable
|
|
1302
|
-
// file-conflict warnings — the same class of pseudo-id notifyAssigners already treats
|
|
1303
|
-
// specially. Found by test-failure.mjs (#6301): the fence refused the drill's duty-agent
|
|
1304
|
-
// wake exactly as it refused real cross-project traffic.
|
|
1111
|
+
// #6228: a wake naming an unlinked foreign project is dropped, with one report to the sender.
|
|
1112
|
+
// The hub's own agents (`hub:duty` et al.) are exempt: they speak for this hub's projects (#6301).
|
|
1305
1113
|
const links = wakeCandidates.length ? await currentLinks() : [];
|
|
1306
1114
|
const crossProject = wakeCandidates.filter(m => !String(m.from || "").startsWith("hub:") && !isLinkedProject(senderProjectOf(m.from), PROJ, links));
|
|
1307
1115
|
for (const m of crossProject) {
|
|
@@ -1338,11 +1146,8 @@ function askedExcerpt(message) {
|
|
|
1338
1146
|
messages: wake,
|
|
1339
1147
|
statePath: DUTY_NUDGE_STATE,
|
|
1340
1148
|
owner: `${RUNNER_ID}:${TURN + 1}`,
|
|
1341
|
-
//
|
|
1342
|
-
//
|
|
1343
|
-
// cursor is monotonic, so `>= id` means the message was handed over and there is nothing to
|
|
1344
|
-
// nudge about. Best-effort by design: any failure here leaves the nudge standing, because a
|
|
1345
|
-
// missed nudge is worse than a redundant one.
|
|
1149
|
+
// /peer (singular) is the only endpoint that serialises deliveredUpTo; the cursor is monotonic,
|
|
1150
|
+
// so `>= id` means handed over. Best-effort: a missed nudge is worse than a redundant one.
|
|
1346
1151
|
isDelivered: async ({ id, recipient }) => {
|
|
1347
1152
|
if (!recipient || !/^\d+$/.test(String(id))) return false;
|
|
1348
1153
|
const r = await api(`/peer?session=${encodeURIComponent(recipient)}`).catch(() => null);
|
|
@@ -1385,13 +1190,8 @@ function askedExcerpt(message) {
|
|
|
1385
1190
|
for (const m of wakeForTurn) if (m.from && !assigners.some(a => a.from === m.from)) assigners.push({ from: m.from, id: m.id });
|
|
1386
1191
|
const asked = askedExcerpt(wakeForTurn[0]);
|
|
1387
1192
|
const tStart = Date.now();
|
|
1388
|
-
// #6134: ONE SESSION PER CARD
|
|
1389
|
-
//
|
|
1390
|
-
// card that moved this wake decides: a different one starts a fresh CLI session, and the seat
|
|
1391
|
-
// is told so, because a fresh session remembers nothing and must be sent to its card.
|
|
1392
|
-
// #7061: bound by SHAPE, not by position. `cardRef` alone took the earliest id in the wake
|
|
1393
|
-
// TEXT, and an order that opens with what shipped ("#7037 is merged as a01f629 … YOUR CARD:
|
|
1394
|
-
// #6983") binds the turn — its state sidecar, its card log, its run record — to a done card.
|
|
1193
|
+
// #6134: ONE SESSION PER CARD; a different card starts a fresh CLI session and the seat is told.
|
|
1194
|
+
// #7061: bound by SHAPE, not position, so an order opening with what shipped binds the right card.
|
|
1395
1195
|
const card = wakeCard(wakeForTurn, { session: SESSION });
|
|
1396
1196
|
const fresh = card > 0 && card !== sessionCard;
|
|
1397
1197
|
if (card) sessionCard = card;
|
|
@@ -1469,22 +1269,13 @@ function askedExcerpt(message) {
|
|
|
1469
1269
|
}
|
|
1470
1270
|
savePending(pendingWake, pendingBcast);
|
|
1471
1271
|
await reportFailure(ec, "message", pendingWake.length, reason);
|
|
1472
|
-
// #6289: TWO consecutive exit-1 turns on one contract PARK the seat
|
|
1473
|
-
//
|
|
1474
|
-
// and every rung re-sent the SAME contract as a fresh full turn — the seat re-read and
|
|
1475
|
-
// re-did finished work, then died to the same API error or the box again; five cycles,
|
|
1476
|
-
// 4.7h, for a 34-line change. The first failure still retries; the second parks with a
|
|
1477
|
-
// reason (time-box when the chain died to box cuts, api-error otherwise), holds the queue,
|
|
1478
|
-
// and is woken again only by `trantor up` (a restart).
|
|
1272
|
+
// #6289: TWO consecutive exit-1 turns on one contract PARK the seat (time-box when the chain
|
|
1273
|
+
// died to cuts, api-error otherwise), holding the queue until `trantor up`.
|
|
1479
1274
|
const parkReason = PARKING_REASONS.has(reason) ? reason : (lastTurnCut ? "time-box" : "api-error");
|
|
1480
1275
|
if (PARKING_REASONS.has(reason) || deliveryFails >= 2) {
|
|
1481
1276
|
retryAt = await parkSeat(parkReason, pendingWake.length, quotaReset);
|
|
1482
|
-
//
|
|
1483
|
-
//
|
|
1484
|
-
// ceiling, exit and let the supervisor restart it clean — a fresh process re-reads auth and
|
|
1485
|
-
// redelivers the queue from disk, which is exactly what un-wedged the 2026-09-09 incident
|
|
1486
|
-
// when the operator finally ran `trantor duty up` by hand 21.9 hours late.
|
|
1487
|
-
// Unsupervised seats keep the old behaviour: exiting would just kill them for good.
|
|
1277
|
+
// RUNNER_PARK_MAX_MS is set only by `trantor duty up` (launchd keepalive): past the ceiling,
|
|
1278
|
+
// exit so the supervisor restarts clean. Unsupervised seats stay parked; exiting would kill them.
|
|
1488
1279
|
const parkMax = Number(process.env.RUNNER_PARK_MAX_MS || 0);
|
|
1489
1280
|
if (parkMax > 0) {
|
|
1490
1281
|
const wakeIn = Math.max(0, Math.min(retryAt - Date.now(), parkMax));
|