trantor 0.18.57 → 0.18.59

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trantor",
3
- "version": "0.18.57",
3
+ "version": "0.18.59",
4
4
  "description": "Trantor — the hub-world for AI agent crews: live message bus, presence, project Kanban/flow board + crew orchestration for independent AI coding agents (Claude, Codex, Gemini, Kimi, DeepSeek)",
5
5
  "mcpServers": {
6
6
  "relay": {
package/bin/cli.mjs CHANGED
@@ -33,6 +33,8 @@ switch (cmd) {
33
33
  case "agent-settings": run("bin/agent-settings.mjs"); break;
34
34
  case "adopt": spawn(process.execPath, [join(ROOT, "bin/adopt.mjs"), ...args], { stdio: "inherit", cwd: process.cwd() }).on("exit", c => process.exit(c ?? 0)); break;
35
35
  case "integrate": spawn(process.execPath, [join(ROOT, "bin/integrate.mjs"), ...args], { stdio: "inherit", cwd: process.cwd() }).on("exit", c => process.exit(c ?? 0)); break;
36
+ case "harvest": run("bin/harvest.mjs"); break;
37
+ case "sync": run("bin/sync.mjs"); break;
36
38
  case "down": runCrew(); break;
37
39
  case "swap": runCrew(); break;
38
40
  case "prune": runCrew(); break;
@@ -207,6 +209,8 @@ switch (cmd) {
207
209
  trantor adopt take over a session already running in a Terminal, then open it here
208
210
  trantor takeover the whole move in one command: idle-gate the Terminal session, end it gracefully, adopt, open in the pane — [--force] [--session <id>] [--dry-run]
209
211
  trantor integrate collect the crew's work, merge it, verify it, push it (--dry-run to rehearse)
212
+ trantor harvest receipt for a seat commit you landed on main by hand: harvest <seat-sha> <main-sha> [--card N]
213
+ trantor sync realign a seat branch with main from the receipts: sync [<seat>] [--dry-run] — refuses and names unharvested commits
210
214
  trantor down tear the crew down (kills processes, closes windows, no dialogs)
211
215
  trantor prune drop dead crew-window tracking rows (ghost workspaces/panes) without spawning anything
212
216
  trantor ui open the live dashboard (board + flow views)
@@ -12,7 +12,7 @@ import { ensureEnrolled } from "../lib/enroll.mjs";
12
12
  import { redactKeys } from "../lib/redact.mjs";
13
13
  import {
14
14
  AUTH_MARKER_RE, classifyFailure, looksLikeAuthDeath,
15
- verdictFor,
15
+ verdictFor, substantiveOutput,
16
16
  readPromptText, stripPromptEcho,
17
17
  } from "../lib/classify-failure.mjs";
18
18
  import { capWake, capBcast, pickLessons, composePrompt } from "./crew-payload.mjs";
@@ -284,7 +284,7 @@ if (!CLI[AGENT]) log(`'${AGENT}' is not a built-in seat — running it as an ope
284
284
 
285
285
  // RUNNER_RULES / RUNNER_KICKOFF env overrides: the runner is also the substrate for non-crew
286
286
  // always-on seats (the fleet DUTY agent, bin/duty.mjs) whose doctrine is not "work your card".
287
- 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.`;
287
+ 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 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. 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.`;
288
288
 
289
289
  // ---- the pulse --------------------------------------------------------------
290
290
  // RUNNER_PULSE_MS re-runs an orchestrator seat's mission note on a cadence when the bus is silent;
@@ -305,6 +305,10 @@ let lastErrText = "";
305
305
  // burned its whole max_tokens budget on internal reasoning and returned a null completion; the
306
306
  // runner used to read that silence as a clean turn while nothing was produced.
307
307
  let lastEmptyOutput = false;
308
+ // #7759: exit 0 with bytes on the stream but neither a changed worktree nor substantive output —
309
+ // the CLI printed its banner and quit. The both-streams-silent rule above stays; this is the
310
+ // second, harder-to-see empty shape.
311
+ let lastEmptyTurn = false;
308
312
  const ERRF = join(homedir(), ".agent-bus", `err-${AGENT}-${PROJ}.txt`);
309
313
  const DUTY_NUDGES = process.env.RUNNER_DUTY_NUDGES === "1";
310
314
  const DUTY_NUDGE_STATE = process.env.RUNNER_DUTY_NUDGE_STATE
@@ -327,6 +331,11 @@ function startDutyNudgeWatcher(plan, sinceMs) {
327
331
  // The hub hands a message out exactly once, so a turn that died took its wake with it. Here a
328
332
  // message is consumed only when a turn exits 0; the queue lives on disk and retries on backoff.
329
333
  const PENDF = join(homedir(), ".agent-bus", `pending-${AGENT}-${PROJ}.json`);
334
+ // #7778: message ids this seat already consumed through its OWN inbox path (relay_inbox, the
335
+ // PostToolUse hook) — reconciled from the hub's deliveredUpTo ledger at each clean turn boundary
336
+ // and persisted in the pending file, so a message the session read mid-turn is never re-woken by
337
+ // the poll, across turn AND process boundaries.
338
+ let seenLedger = [];
330
339
  // A cap, so a long outage cannot grow the queue without bound. Overflow drops the OLDEST and says
331
340
  // so on the bus — a silent drop is the exact failure this whole mechanism exists to end.
332
341
  const PENDING_MAX = 50;
@@ -341,23 +350,51 @@ const RETRY_MS = (() => {
341
350
  const custom = raw ? raw.split(",").map(Number).filter(n => Number.isFinite(n) && n > 0) : [];
342
351
  return custom.length ? custom : [30e3, 60e3, 120e3, 300e3, 900e3];
343
352
  })();
353
+ // #7756: while an ask awaits its answer the contract's wake stays owed but is NOT redelivered —
354
+ // the answer (re = the ask's id, or any direct word from the assigner) is what releases it.
355
+ let awaitingAsk = null;
356
+ // The bus is the record of a turn that ended by ASKING: an open kind:ask contract from this
357
+ // session. deliverWake judges each clean wake turn against /contracts — an open ask renames the
358
+ // ledger row "asked" and HOLDS the wake (no failure count, no backoff, no park) until the answer.
359
+ let turnAsk = null;
360
+ async function askJudge() {
361
+ try {
362
+ const r = await api(`/contracts?session=${encodeURIComponent(SESSION)}&project=${encodeURIComponent(PROJ)}`);
363
+ // abandonedContracts included on purpose: an ask to a gone-quiet assigner is exactly the ask
364
+ // that must keep holding, and the hub files those rows under their own key (#7079).
365
+ const rows = [...(r?.contracts || []), ...(r?.abandonedContracts || [])];
366
+ const open = rows.filter(c => c.kind === "ask" && !c.answered);
367
+ turnAsk = open.length ? { id: open[open.length - 1].id, to: String(open[open.length - 1].to || "") } : null;
368
+ return turnAsk ? "asked" : null;
369
+ } catch { return null; }
370
+ }
344
371
  function savePending(wake, bcast) {
345
372
  try {
346
- if (!wake.length && !bcast.length) { try { unlinkSync(PENDF); } catch {} return; }
347
- writeFileSync(PENDF, JSON.stringify({ agent: AGENT, project: PROJ, ts: Date.now(), wake, bcast }));
373
+ // the seen-set (#7778) and a held ask (#7756) keep the file alive even with both queues empty:
374
+ // the dedupe ledger outlives the deliveries it guards, and a restarted runner must wait for
375
+ // the answer instead of redelivering the very contract the seat already asked about.
376
+ if (!wake.length && !bcast.length && !seenLedger.length && !awaitingAsk) { try { unlinkSync(PENDF); } catch {} return; }
377
+ writeFileSync(PENDF, JSON.stringify({ agent: AGENT, project: PROJ, ts: Date.now(), wake, bcast, seen: seenLedger, ask: awaitingAsk?.id || 0, askTo: awaitingAsk?.to || "" }));
348
378
  } catch {}
349
379
  }
350
380
  function loadPending() {
351
381
  try {
352
382
  const j = JSON.parse(readFileSync(PENDF, "utf8"));
353
- return { wake: Array.isArray(j.wake) ? j.wake : [], bcast: Array.isArray(j.bcast) ? j.bcast : [] };
354
- } catch { return { wake: [], bcast: [] }; }
383
+ return {
384
+ wake: Array.isArray(j.wake) ? j.wake : [],
385
+ bcast: Array.isArray(j.bcast) ? j.bcast : [],
386
+ seen: Array.isArray(j.seen)
387
+ ? j.seen.filter(e => e && Number.isFinite(Number(e.id))).map(e => ({ id: Number(e.id), ts: Number(e.ts) || 0 }))
388
+ : [],
389
+ ask: Number(j.ask) || 0, askTo: String(j.askTo || ""),
390
+ };
391
+ } catch { return { wake: [], bcast: [], seen: [], ask: 0, askTo: "" }; }
355
392
  }
356
393
 
357
394
  // Auth failures in TURN OUTPUT: opencode prints its auth error and still exits 0 (#5405). The rules
358
395
  // live in lib/classify-failure.mjs (#5868); runTurn judges only the CLI's own output, not the echo.
359
396
  function classify(exit) {
360
- const { reason, matched } = classifyFailure(exit, lastErrText, lastEmptyOutput);
397
+ const { reason, matched } = classifyFailure(exit, lastErrText, lastEmptyOutput, lastEmptyTurn);
361
398
  log(`classified ${reason} because ${matched}`);
362
399
  return reason;
363
400
  }
@@ -507,6 +544,20 @@ async function notifyAssigners(pairs, text) {
507
544
  if (seen.size) log(`reported outcome to ${[...seen].join(", ")}`);
508
545
  }
509
546
 
547
+ // #7759: bus activity is work. The hub's unified log already records sends and card moves/notes
548
+ // with the actor (`by`), and GET /events filters by it — so the runner can see, with no new
549
+ // endpoint, that the seat answered on the bus even when it printed nothing and touched no file.
550
+ // Fail-open: an unreachable or older hub yields "no activity", the pre-fix behaviour.
551
+ async function latestBusEventId() {
552
+ try { const r = await api("/events?limit=1"); return Number(r?.latest) || 0; } catch { return 0; }
553
+ }
554
+ async function busActivitySince(seq) {
555
+ try {
556
+ const r = await api(`/events?by=${encodeURIComponent(SESSION)}&since=${seq}&limit=500`);
557
+ return Array.isArray(r?.events) && r.events.some(e => Number(e?.id) > seq);
558
+ } catch { return false; }
559
+ }
560
+
510
561
  async function reportHealthy() {
511
562
  if (consecFails === 0) return; // already healthy — don't spam
512
563
  consecFails = 0;
@@ -632,6 +683,12 @@ async function runTurn(prompt, isFirst, trigger = "kickoff", opts = {}) {
632
683
  // exit-0 turn with real output must never be re-labelled "auth" by the #5405 escalation — the
633
684
  // qwen specimen committed aa3c340 while its captured stream still tripped the auth regex.
634
685
  const headBefore = gitOut(["rev-parse", "HEAD"], TURN_DIR);
686
+ // #7759: the worktree snapshot the turn is judged against at its end — porcelain covers edits
687
+ // AND new untracked files, which a HEAD-only comparison misses.
688
+ const statusBefore = gitOut(["status", "--porcelain"], TURN_DIR);
689
+ // #7759: the bus event cursor the turn is judged against — anything the seat posts after this
690
+ // (relay_send, a card move or note) is bus activity, which counts as work.
691
+ const busSeqBefore = await latestBusEventId();
635
692
  // #6154: a pinned seat with no sid yet resumes as FRESH — the guard below fails open, because
636
693
  // a resume without an id must fall back to a new session, never to `next`'s bare resume shape.
637
694
  let cmd = (isFirst || ((cli.sid || cli.pinned) && !sid)) ? cli.first : cli.next;
@@ -786,9 +843,18 @@ exit $turn_exit`;
786
843
  lastEmptyOutput = true;
787
844
  log("\x1b[31mexit 0 but the turn produced NO output — treating as FAILED (empty-output)\x1b[0m");
788
845
  }
846
+ // #7759: the turn is HOLLOW when it exited 0 having neither changed the worktree (HEAD or
847
+ // porcelain vs the turn-start snapshot, untracked included), said anything beyond CLI chrome,
848
+ // nor posted to the bus. Computed LAST and only when the escalations above did not claim the
849
+ // turn: an auth death or a null completion is never re-labelled empty (#7759, #5405, #5481).
850
+ const worktreeChanged = newCommit || gitOut(["status", "--porcelain"], TURN_DIR) !== statusBefore;
851
+ const busActive = await busActivitySince(busSeqBefore);
852
+ lastEmptyTurn = !cut && realExit === 0 && effExit === 0 && !authHit && !opts.state
853
+ && !worktreeChanged && !busActive && !substantiveOutput(ownOut);
854
+ if (lastEmptyTurn) log("\x1b[33mexit 0 but the turn was EMPTY — no worktree change, no substantive output, no bus activity\x1b[0m");
789
855
  // #5868: the verdict rides the telemetry row so a classification survives the pane scrolling
790
856
  // away — the same "classified X because Y" shape the runner logs, in the seat's jsonl forever.
791
- const verdict = verdictFor(realExit, effExit, lastEmptyOutput, ownOut);
857
+ const verdict = verdictFor(realExit, effExit, lastEmptyOutput, ownOut, lastEmptyTurn);
792
858
  // #6134: what the turn COST, from the CLI's own usage line. Zero means this CLI printed none —
793
859
  // never that the turn was free. `trantor seat-why` totals these into today's spend per seat.
794
860
  let tokens = parseTurnTokens(ownOut);
@@ -801,8 +867,14 @@ exit $turn_exit`;
801
867
  // #6289: every ledger row names in ONE field what happened to the turn — cut (the box ended it),
802
868
  // api-error (the CLI failed), completed — and what it cost in tokens, even when this CLI printed
803
869
  // no usage line (0 means "not reported", never "free"). `cut` stays too: the drills read it.
804
- const outcome = cut ? "cut" : (effExit !== 0 ? "api-error" : "completed");
805
- const telemetryRow = { ts: Date.now(), agent: AGENT, project: PROJ, turn: TURN, trigger, model: MODEL || "cli-default", duration_ms: Date.now() - t0, exit: realExit, effExit, authFailed: effExit !== realExit, emptyOutput: lastEmptyOutput, verdict, outcome, tokens };
870
+ const outcome = cut ? "cut" : (effExit !== 0 ? "api-error" : lastEmptyTurn ? "empty" : "completed");
871
+ // #7756: a clean turn that ASKED its assigner is demoted-but-owed, not "completed". The judge
872
+ // (deliverWake's /contracts read) renames the ledger row, so "asked" is what the log keeps.
873
+ let finalOutcome = outcome;
874
+ if (outcome === "completed" && opts.judgeOutcome) {
875
+ try { finalOutcome = (await opts.judgeOutcome()) || outcome; } catch {}
876
+ }
877
+ const telemetryRow = { ts: Date.now(), agent: AGENT, project: PROJ, turn: TURN, trigger, model: MODEL || "cli-default", duration_ms: Date.now() - t0, exit: realExit, effExit, authFailed: effExit !== realExit, emptyOutput: lastEmptyOutput, emptyTurn: lastEmptyTurn, verdict, outcome: finalOutcome, tokens };
806
878
  if (cut) telemetryRow.cut = true;
807
879
  telemetry(telemetryRow);
808
880
  log(`turn ended (exit ${realExit}${effExit !== realExit ? ` → effective ${effExit} (${lastEmptyOutput ? "empty-output" : "auth"})` : ""}, ${((Date.now() - t0) / 1000).toFixed(0)}s)`);
@@ -944,16 +1016,6 @@ function isContract(message) {
944
1016
  return message?.kind === "contract" || /^\s*contract\s*:/i.test(text) || CARD_REF_RE.test(text);
945
1017
  }
946
1018
 
947
- function isRunnerSession(session) {
948
- const suffix = `:${PROJ}`;
949
- const name = String(session || "");
950
- if (!name.endsWith(suffix)) return false;
951
- const label = name.slice(0, -suffix.length);
952
- // Crew labels are CLI/provider slugs. Host sessions keep their machine-style identity and remain
953
- // valid direct assigners; runner-to-runner prose needs `contract:` or a card reference.
954
- return /^[a-z0-9_.-]+$/.test(label) && !label.startsWith("hub:");
955
- }
956
-
957
1019
  // A hub staleness alert describes a moment, so it EXPIRES; a peer's message never does, because a
958
1020
  // seat missing a teammate's request is the failure this bus exists to prevent.
959
1021
  const HUB_ALERT_TTL_MS = Number(process.env.TRANTOR_HUB_ALERT_TTL_MS || 30 * 60_000);
@@ -969,12 +1031,12 @@ function shouldWake(message) {
969
1031
  // into the next turn's prompt like a broadcast and never buys a CLI session of its own.
970
1032
  if (message?.wake === false) return false;
971
1033
  if (message?.to === SESSION) {
1034
+ // #7766: with wake unset or true the sender's flag IS the decision. No keyword, card or
1035
+ // sender-shape heuristic on the body second-guesses a direct message any more, so a plain
1036
+ // question buys its turn. kind:status stays chatter; what is still demoted tells the sender
1037
+ // (see the demotion notice at the wake split).
972
1038
  if (message?.kind === "status") return false;
973
- // Safety net for senders that never set the flag: a direct message carrying no card and no
974
- // instruction is context. Typed alerts and overseer warnings still wake (#5760).
975
- const typed = message?.kind === "alert" || /^🤝 OVERSEER /.test(String(message?.text || ""));
976
- if (!typed && !isContract(message) && !carriesWork(message?.text)) return false;
977
- return !isRunnerSession(message?.from) || isContract(message);
1039
+ return true;
978
1040
  }
979
1041
  return message?.to === "all"
980
1042
  && isContract(message)
@@ -1028,6 +1090,12 @@ function askedExcerpt(message) {
1028
1090
  // broadcasts batched behind them. Restored from disk first: a runner that was killed mid-turn
1029
1091
  // (or a machine that rebooted) still owes those messages, and the hub will never send them again.
1030
1092
  const restored = loadPending();
1093
+ seenLedger = restored.seen;
1094
+ 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`);
1095
+ if (restored.ask) {
1096
+ awaitingAsk = { id: restored.ask, to: restored.askTo };
1097
+ log(`resuming a held ask (#${awaitingAsk.id}) — the queue stays owed until ${awaitingAsk.to || "the assigner"} answers`);
1098
+ }
1031
1099
  // Say what the restore SHED, not just what it kept. A queue that quietly halves itself on restart
1032
1100
  // is indistinguishable from one that lost real work, and this is the moment the expiry above
1033
1101
  // actually bites — a wedged seat comes back carrying only what still means something.
@@ -1050,6 +1118,10 @@ function askedExcerpt(message) {
1050
1118
  stateSkip("kickoff");
1051
1119
  const ec0 = await runTurn(composedTurn({ base: KICKOFF, lessons: pickLessons(LESSONS_RAW, "") }), true, "kickoff");
1052
1120
  if (ec0) await reportFailure(ec0, "kickoff", pendingWake.length); // a failed kickoff = the "fired up, died, nobody knew" case
1121
+ // #7778: the kickoff is a turn like any other — a message that landed mid-kickoff and was read
1122
+ // through the session's own inbox is consumed by a successful kickoff, and the first poll must
1123
+ // not wake on it. Same reconcile, same clean-boundary rule as deliverWake below.
1124
+ else await reconcileSessionReads(cursor);
1053
1125
  let lastTurnAt = Date.now();
1054
1126
  if (PULSE_MS) log(`pulse armed — mission re-read every ${Math.round(PULSE_MS / 1000)}s (${MISSION_FILE})`);
1055
1127
  log(`parked — long-polling the bus as ${SESSION} (free; this poll is also the heartbeat)`);
@@ -1060,18 +1132,21 @@ function askedExcerpt(message) {
1060
1132
  if (PULSE_MS && Date.now() - lastTurnAt >= PULSE_MS) {
1061
1133
  stateSkip("pulse");
1062
1134
  const ecp = await runTurn(composedTurn({ base: PULSE_PROMPT + "\n\n", rulesText: RULES, lessons: pickLessons(LESSONS_RAW, PULSE_PROMPT) }), false, "pulse");
1063
- if (ecp) await reportFailure(ecp, "pulse"); else await reportHealthy();
1135
+ // #7778: a pulse turn is also a clean boundary when it succeeds — same reconcile rule.
1136
+ if (ecp) await reportFailure(ecp, "pulse");
1137
+ else { await reconcileSessionReads(cursor); await reportHealthy(); }
1064
1138
  lastTurnAt = Date.now();
1065
1139
  log("parked — waiting for the next message or pulse");
1066
1140
  continue;
1067
1141
  }
1068
1142
  // A due REDELIVERY runs before we go back to waiting — during an outage the bus is silent by
1069
1143
  // definition, so the retry timer is the only thing that will ever move these messages.
1070
- if (pendingWake.length && Date.now() >= retryAt) { await deliverWake(); continue; }
1144
+ // #7756: a held ask is owed but NEVER redelivered — only the assigner's answer releases it.
1145
+ if (pendingWake.length && !awaitingAsk && Date.now() >= retryAt) { await deliverWake(); continue; }
1071
1146
  // cap the long-poll hold so neither a due pulse nor a due redelivery waits out a silent 280s window
1072
1147
  const due = [];
1073
1148
  if (PULSE_MS) due.push(PULSE_MS - (Date.now() - lastTurnAt));
1074
- if (pendingWake.length) due.push(retryAt - Date.now());
1149
+ if (pendingWake.length && !awaitingAsk) due.push(retryAt - Date.now());
1075
1150
  const holdS = due.length
1076
1151
  ? Math.max(5, Math.min(280, Math.ceil(Math.min(...due) / 1000)))
1077
1152
  : 280;
@@ -1100,12 +1175,43 @@ function askedExcerpt(message) {
1100
1175
  // reply-linked outcomes, and the old stable marker before direct-address logic sees them. Status
1101
1176
  // broadcasts are presence chatter and are dropped rather than saved as future prompt context.
1102
1177
  msgs = msgs.filter(m => !isReceipt(m) && !isStatusBroadcast(m));
1178
+ // #7778: ids in the seen-set were already handed to the model through the session's own inbox
1179
+ // read mid-turn (the reconcile at the last clean turn boundary recorded them). The poll still
1180
+ // serves them — it filters by THIS runner's cursor, not the shared ledger — so the second
1181
+ // reader stands down here, and the cursor adopts the consumed ids so the hub stops re-serving.
1182
+ // Filtering BEFORE the wake/bcast split keeps a dupe out of the demoted-sender notice too:
1183
+ // the seat DID turn on that message, and #7766's notice must not say otherwise.
1184
+ if (seenLedger.length) {
1185
+ const seenIds = new Set(seenLedger.map(e => e.id));
1186
+ const dupes = msgs.filter(m => seenIds.has(m.id));
1187
+ if (dupes.length) {
1188
+ log(`duplicate delivery suppressed — ${dupes.map(m => `#${m.id}`).join(", ")} already consumed by the session's own inbox read`);
1189
+ msgs = msgs.filter(m => !seenIds.has(m.id));
1190
+ const top = Math.max(...dupes.map(m => m.id));
1191
+ if (top > cursor) cursor = top;
1192
+ seenLedger = seenLedger.filter(e => e.id > cursor);
1193
+ }
1194
+ }
1103
1195
  // #5760: the hub's hourly same-project-sessions FYI is coordination context, batched like a
1104
1196
  // broadcast; file-conflict and linked-activity overseer warnings still wake.
1105
1197
  const fyi = msgs.filter(m => m.from === "hub:duty" && String(m.text || "").startsWith("🤝 OVERSEER same-project-sessions"));
1106
1198
  const rest = msgs.filter(m => !fyi.includes(m));
1107
1199
  const direct = rest.filter(m => m.to === SESSION && shouldWake(m));
1108
1200
  const mentions = rest.filter(m => m.to === "all" && shouldWake(m));
1201
+ // #7766: a direct message that is STILL demoted to context tells its SENDER in one line, so a
1202
+ // dropped ask is visible instead of looking like a dead seat. Two demotions stay silent: the
1203
+ // sender's own wake:false, and kind:status chatter — this notice is itself kind status, so
1204
+ // answering one would loop. What remains today is the expired hub alert.
1205
+ const demotedSenders = new Set();
1206
+ for (const m of rest) {
1207
+ if (m.to !== SESSION || direct.includes(m) || m.wake === false || m.kind === "status") continue;
1208
+ const who = String(m.from || "");
1209
+ if (!who || who === SESSION || demotedSenders.has(who)) continue;
1210
+ demotedSenders.add(who);
1211
+ log(`\x1b[33mdirect message demoted to context\x1b[0m — telling ${who}`);
1212
+ api("/send", { from: SESSION, to: who, project: senderProjectOf(who) || PROJ, kind: "status",
1213
+ text: `ℹ️ ${SESSION} did not turn on your direct message — demoted to context: "${askedExcerpt(m)}". Re-send citing a card if it still needs a turn` }).catch(() => {});
1214
+ }
1109
1215
  // #6134: messages that do not earn a turn still become context, including direct acks.
1110
1216
  // #7430: stale hub alerts are shed HERE too, not only at boot — one that arrives already past
1111
1217
  // its TTL must not sit in every prompt until the next successful turn clears the batch.
@@ -1136,6 +1242,15 @@ function askedExcerpt(message) {
1136
1242
  kind: "status", text: `⚠️ ${SESSION} dropped ${dropped.length} undelivered message(s) — queue hit its ${PENDING_MAX} cap during a failure streak` }).catch(() => {});
1137
1243
  }
1138
1244
  savePending(pendingWake, pendingBcast);
1245
+ // #7756: the assigner's answer releases a held ask — by `re` on the ask's id, or any direct
1246
+ // word from the assigner (an older peer answers untagged). The owed contract is still queued
1247
+ // ahead of it, so the resumed turn reads the contract re-attached above the answer.
1248
+ if (awaitingAsk && wake.some(m => m.to === SESSION && (Number(m.re) === awaitingAsk.id || m.from === awaitingAsk.to))) {
1249
+ log(`answer to ask #${awaitingAsk.id} landed — resuming with the contract re-attached`);
1250
+ awaitingAsk = null;
1251
+ savePending(pendingWake, pendingBcast);
1252
+ }
1253
+ if (awaitingAsk) { log(`holding for the answer to ask #${awaitingAsk.id} — ${wake.length} new message(s) queued behind it`); continue; }
1139
1254
  // Respect an active backoff: a new message during an outage joins the batch, it does not
1140
1255
  // reset the clock and hammer a CLI that is already failing.
1141
1256
  if (Date.now() < retryAt) { log(`queued — ${pendingWake.length} undelivered, next attempt in ${Math.max(0, Math.round((retryAt - Date.now()) / 1000))}s`); continue; }
@@ -1145,6 +1260,31 @@ function askedExcerpt(message) {
1145
1260
 
1146
1261
  // Run the pending batch. The messages are cleared ONLY on exit 0; any other outcome leaves them
1147
1262
  // queued, on disk, with a backoff — which is the whole point of the change.
1263
+ // #7778 — one message, one delivery, whichever path sees it first. The session's own inbox read
1264
+ // and this runner's /poll are two readers of one bus with no shared cursor: a message the seat
1265
+ // read and answered MID-TURN was re-polled after the turn and woken AGAIN (seen live: a seat
1266
+ // answered inside its turn, then a second turn 16s later re-quoted the same message verbatim).
1267
+ // The hub's deliveredUpTo is the one ledger both paths already write, so at a CLEAN turn
1268
+ // boundary every id above our poll cursor but at-or-below it was necessarily consumed by the
1269
+ // session-side path. Those ids go into the persisted seen-set; the poll filter stands the
1270
+ // second reader down. A failed, parked or hollow turn reconciles NOTHING — dedupe must never
1271
+ // swallow a message the seat still owes, so only full success is trusted to mark history read.
1272
+ async function reconcileSessionReads(upToCursor) {
1273
+ try {
1274
+ const peer = await api(`/peer?session=${encodeURIComponent(SESSION)}`);
1275
+ const readUpTo = Number(peer?.deliveredUpTo || 0);
1276
+ // A delta wider than one turn could plausibly carry is a corrupt or rewound ledger: mark
1277
+ // nothing. Non-marking only risks the duplicate this fixes, never a lost message.
1278
+ if (!(readUpTo > upToCursor) || readUpTo - upToCursor > 10000) return 0;
1279
+ for (let id = upToCursor + 1; id <= readUpTo; id++) seenLedger.push({ id, ts: Date.now() });
1280
+ seenLedger = seenLedger.filter(e => e.id > upToCursor).slice(-500);
1281
+ // persist HERE, at every caller: a runner killed seconds after the boundary is exactly the
1282
+ // restart the restored seen-set exists for.
1283
+ savePending(pendingWake, pendingBcast);
1284
+ log(`dedupe: message id(s) ${upToCursor + 1}..${readUpTo} were consumed by the session's own inbox read — the poll will not wake on them again`);
1285
+ return readUpTo - upToCursor;
1286
+ } catch { return 0; }
1287
+ }
1148
1288
  async function deliverWake() {
1149
1289
  const wake = pendingWake;
1150
1290
  const dutyPlan = DUTY_NUDGES
@@ -1220,6 +1360,7 @@ function askedExcerpt(message) {
1220
1360
  });
1221
1361
  const stopDutyNudgeWatcher = startDutyNudgeWatcher(dutyPlan, tStart);
1222
1362
  let ec;
1363
+ turnAsk = null; // #7756: the judge reads THIS turn's ending, never a previous turn's ask
1223
1364
  const stateStep = !stateSkip("wake", card);
1224
1365
  try {
1225
1366
  ec = stateStep
@@ -1231,7 +1372,7 @@ function askedExcerpt(message) {
1231
1372
  // dies quietly (§4.6).
1232
1373
  observation: [stateObservation, wakeText, ctxText, againText + freshText].filter(Boolean).join("\n"),
1233
1374
  })
1234
- : await runTurn(prompt, fresh, deliveryFails ? `${trigger} (redelivery)` : trigger);
1375
+ : await runTurn(prompt, fresh, deliveryFails ? `${trigger} (redelivery)` : trigger, { judgeOutcome: askJudge });
1235
1376
  }
1236
1377
  finally { stopDutyNudgeWatcher(); }
1237
1378
  const secs = Math.round((Date.now() - tStart) / 1000);
@@ -1313,8 +1454,36 @@ function askedExcerpt(message) {
1313
1454
  await notifyAssigners(assigners,
1314
1455
  `⚠️ your contract FAILED on ${SESSION} (exit ${ec}, ${reason}) · retrying in ${Math.round(wait / 1000)}s · asked: "${asked}"`);
1315
1456
  log(`\x1b[31m${pendingWake.length} message(s) still UNDELIVERED — next attempt in ${Math.round(wait / 1000)}s\x1b[0m`);
1457
+ } else if (turnAsk) {
1458
+ // #7756: a clean exit that ASKED is not "done". The wake stays owed — queued on disk with
1459
+ // the ask's id, so a restart keeps holding — and the failure ladder never sees it. The
1460
+ // assigner is NOT notified here: the kind:ask message itself is already that notification.
1461
+ awaitingAsk = turnAsk; turnAsk = null;
1462
+ savePending(pendingWake, pendingBcast);
1463
+ await reportHealthy();
1464
+ log(`turn asked its assigner — holding the contract until ask #${awaitingAsk.id} is answered`);
1465
+ } else if (lastEmptyTurn) {
1466
+ // #7759: the turn exited 0 but was HOLLOW — a banner is not work. The wake is NOT
1467
+ // consumed: the queue is kept and the ladder retries, and the assigner hears EMPTY,
1468
+ // never "done". Second hollow attempt in a row parks the seat, like a failed turn.
1469
+ deliveryFails++;
1470
+ savePending(pendingWake, pendingBcast);
1471
+ if (deliveryFails >= 2) {
1472
+ retryAt = await parkSeat("empty-turn", pendingWake.length);
1473
+ await notifyAssigners(assigners,
1474
+ `⛔ your contract is PARKED on ${SESSION} (empty-turn: two exit-0 turns with no worktree change, substantive output or bus activity) · asked: "${asked}"`);
1475
+ lastTurnAt = Date.now();
1476
+ return;
1477
+ }
1478
+ const wait = RETRY_MS[Math.min(deliveryFails - 1, RETRY_MS.length - 1)];
1479
+ retryAt = Date.now() + wait;
1480
+ log(`\x1b[33mturn was EMPTY — wake not consumed, ${pendingWake.length} message(s) stay owed; retrying in ${Math.round(wait / 1000)}s\x1b[0m`);
1481
+ await notifyAssigners(assigners,
1482
+ `🫥 EMPTY turn on ${SESSION} (exit 0, ${secs}s — no worktree change, no substantive output, no bus activity) · wake stays owed · retrying in ${Math.round(wait / 1000)}s · asked: "${asked}"`);
1316
1483
  } else {
1317
1484
  pendingWake = []; pendingBcast = []; deliveryFails = 0; retryAt = 0;
1485
+ // #7778: reconcile BEFORE the write below so the persisted snapshot carries the seen-set.
1486
+ await reconcileSessionReads(cursor);
1318
1487
  savePending([], []);
1319
1488
  await reportHealthy();
1320
1489
  await notifyAssigners(assigners,
@@ -0,0 +1,42 @@
1
+ #!/usr/bin/env node
2
+ // `trantor harvest <seat-sha> <main-sha> [--card N]` (#7748): the receipt for a hand-made cherry-pick
3
+ // or squash. The receipt is what lets `trantor sync` realign the seat branch later.
4
+ import { resolveProject, hostId } from "../lib/project.mjs";
5
+ import { recordHarvest, receiptsPath, run } from "../lib/harvest.mjs";
6
+ import { signedPost } from "../hooks/lib/api.mjs";
7
+
8
+ const args = process.argv.slice(2);
9
+ const flag = (name) => { const i = args.indexOf(`--${name}`); return i >= 0 ? args[i + 1] : ""; };
10
+ const positional = args.filter((a, i) => !a.startsWith("--") && !(i > 0 && args[i - 1].startsWith("--") && !args[i - 1].startsWith("--no-")));
11
+ const [seatArg, mainArg] = positional;
12
+ if (!seatArg || !mainArg || args.includes("--help")) {
13
+ console.error("usage: trantor harvest <seat-sha> <main-sha> [--card N] [--seat <agent>] [--project <p>] [--no-note]");
14
+ process.exit(seatArg && mainArg ? 0 : 1);
15
+ }
16
+
17
+ const repo = process.cwd();
18
+ const project = flag("project") || resolveProject(repo);
19
+ const resolve = (s) => run(repo, ["rev-parse", "--verify", "-q", `${s}^{commit}`]).out || String(s).trim();
20
+ const seat = resolve(seatArg), main = resolve(mainArg);
21
+ if (!run(repo, ["rev-parse", "--verify", "-q", `${mainArg}^{commit}`]).ok) {
22
+ console.error(`harvest: ${mainArg} is not a commit in ${repo}`);
23
+ process.exit(1);
24
+ }
25
+ let branch = flag("seat") ? `seat/${flag("seat")}` : "";
26
+ if (!branch) {
27
+ const holders = run(repo, ["branch", "--list", "seat/*", "--contains", seat, "--format=%(refname:short)"]).out;
28
+ branch = holders.split("\n").filter(Boolean)[0] || "";
29
+ }
30
+
31
+ let receipt;
32
+ try { receipt = recordHarvest(project, { seat, main, card: flag("card"), branch, by: "harvest" }); }
33
+ catch (e) { console.error(`harvest: ${e.message}`); process.exit(1); }
34
+ const line = `harvested ${receipt.seat.slice(0, 7)} as ${receipt.main.slice(0, 7)}`;
35
+ console.log(`${line}${branch ? ` (${branch})` : ""}${receipt.card ? ` · card #${receipt.card}` : ""} → ${receiptsPath(project)}`);
36
+
37
+ if (receipt.card && !args.includes("--no-note")) {
38
+ const me = `${hostId()}:${project}`;
39
+ const r = await signedPost("/task/update", { id: receipt.card, note: line, by: me, project }, { session: me, project });
40
+ if (r.ok) console.log(`card #${receipt.card} noted: ${line}`);
41
+ else console.error(`warning: card #${receipt.card} not noted (${r.status || r.reason || "hub unreachable"}); the receipt is recorded`);
42
+ }
package/bin/integrate.mjs CHANGED
@@ -1,12 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
  // `trantor integrate` — collect the crew's work, prove it, ship it.
3
- //
4
3
  // The steps are the ones the orchestrator already performs by hand. The dials decide how far it is
5
4
  // allowed to go on its own, and every stop says which dial stopped it, so "why didn't it push"
6
5
  // always has an answer.
7
6
  import { resolveProject } from "../lib/project.mjs";
8
7
  import { resolveAutonomy } from "../lib/autonomy.mjs";
9
8
  import { seatWorktrees, commitSeatWork, seatAhead, mergeSeat, verify, git } from "../lib/integrate.mjs";
9
+ import { recordHarvest } from "../lib/harvest.mjs";
10
10
 
11
11
  const D = "\x1b[2m", B = "\x1b[1m", G = "\x1b[32m", Y = "\x1b[33m", RED = "\x1b[31m", R = "\x1b[0m";
12
12
  const args = process.argv.slice(2);
@@ -53,7 +53,14 @@ for (const w of seats) {
53
53
  blocked = true;
54
54
  continue;
55
55
  }
56
- if (m.merged) { console.log(` ${G}merged ${branch} (${ahead} commit(s))${R}`); merged.push(branch); }
56
+ if (m.merged) {
57
+ console.log(` ${G}merged ${branch} (${ahead} commit(s))${R}`);
58
+ merged.push(branch);
59
+ // #7748: the receipt `trantor sync` reads later; a merge keeps the seat shas, so this one is
60
+ // the seat tip -> the merge commit.
61
+ try { recordHarvest(project, { seat: git(w.dir, ["rev-parse", branch]), main: git(repo, ["rev-parse", "HEAD"]), branch, by: "integrate" }); }
62
+ catch (e) { console.log(` ${Y}${branch}: receipt not written (${e.message})${R}`); }
63
+ }
57
64
  }
58
65
 
59
66
  if (blocked) {
package/bin/sync.mjs ADDED
@@ -0,0 +1,41 @@
1
+ #!/usr/bin/env node
2
+ // `trantor sync [<seat>]` (#7748): realign a seat branch with main from the harvest receipts.
3
+ // Every commit main lacks must carry a receipt; otherwise the sync refuses and names them.
4
+ import { existsSync } from "node:fs";
5
+ import { join } from "node:path";
6
+ import { resolveProject, busDir } from "../lib/project.mjs";
7
+ import { syncSeat, receiptsPath } from "../lib/harvest.mjs";
8
+
9
+ const D = "\x1b[2m", G = "\x1b[32m", Y = "\x1b[33m", RED = "\x1b[31m", R = "\x1b[0m";
10
+ const args = process.argv.slice(2);
11
+ const flag = (name) => { const i = args.indexOf(`--${name}`); return i >= 0 ? args[i + 1] : ""; };
12
+ const seat = args.find((a, i) => !a.startsWith("--") && !(i > 0 && args[i - 1] === "--project"));
13
+ if (args.includes("--help")) {
14
+ console.log("usage: trantor sync [<seat>] [--project <p>] [--dry-run] [--no-fetch] (no seat: the worktree you are in)");
15
+ process.exit(0);
16
+ }
17
+
18
+ const project = flag("project") || resolveProject(process.cwd());
19
+ const dir = seat ? join(busDir(), "worktrees", project, seat) : process.cwd();
20
+ if (seat && !existsSync(join(dir, ".git"))) {
21
+ console.error(`sync: no worktree for seat '${seat}' at ${dir}`);
22
+ process.exit(1);
23
+ }
24
+
25
+ const r = syncSeat(dir, { dryRun: args.includes("--dry-run"), fetch: !args.includes("--no-fetch"), project });
26
+ const short = (s) => String(s || "").slice(0, 7);
27
+ if (r.refused) {
28
+ console.log(`${RED}sync refused${R} · ${r.branch} carries ${r.unharvested.length} commit(s) ${r.target.ref} does not, with no harvest receipt:`);
29
+ for (const c of r.unharvested) console.log(` ${short(c.sha)} ${c.subject}`);
30
+ if (r.harvested.length) console.log(`${D}${r.harvested.length} other commit(s) are receipted and would be dropped once these are.${R}`);
31
+ console.log(`${D}Harvest them (trantor harvest <seat-sha> <main-sha> --card N) or park them on another branch first. Receipts: ${receiptsPath(project)}${R}`);
32
+ process.exit(1);
33
+ }
34
+ if (!r.ok) { console.error(`${RED}sync failed${R}: ${r.reason}`); process.exit(1); }
35
+ if (r.noop) { console.log(`${D}${r.branch} already at ${r.target.ref} (${short(r.head)})${R}`); process.exit(0); }
36
+ const dropped = r.harvested.map(c => `${short(c.sha)}→${short(c.receipt.main)}`).join(", ");
37
+ if (r.dry) {
38
+ console.log(`${Y}[dry]${R} would move ${r.branch} ${short(r.head)} → ${r.target.ref} ${short(r.target.sha)}${dropped ? ` ${D}(receipted: ${dropped})${R}` : ""}`);
39
+ process.exit(0);
40
+ }
41
+ console.log(`${G}synced${R} ${r.branch} ${short(r.from)} → ${r.target.ref} ${short(r.to)}${dropped ? ` ${D}(receipted: ${dropped})${R}` : ""}`);
package/hooks/lib/api.mjs CHANGED
@@ -10,6 +10,7 @@ import { loadOrCreate, loadOrCreateInstance } from "../../lib/identity.mjs";
10
10
  import { sfetchJson } from "../../lib/signed-fetch.mjs";
11
11
 
12
12
  export const DEFAULT_TIMEOUT_MS = 1500;
13
+ export const WRITE_TIMEOUT_MS = 8000; // #7755: writes get their own budget above the read default
13
14
 
14
15
  // Hub URL, PER-PROJECT (TDD §12.1): env RELAY_URL, then config `hubs[project]`, then the legacy global
15
16
  // `url`, then the local default. Never throws, keeping hooks fail-open.
@@ -175,12 +176,25 @@ export async function signedGet(pathOrUrl, { timeoutMs = DEFAULT_TIMEOUT_MS, ses
175
176
  }
176
177
 
177
178
  // Signed POST → { ok, status, json|null }. Never throws.
178
- export async function signedPost(pathOrUrl, payload, { timeoutMs = DEFAULT_TIMEOUT_MS, session, instance, project } = {}) {
179
+ // #7755: writes keep their own timeout, and a write carrying `_op` resolves a timeout through
180
+ // one GET /_op — committed returns the stored body as success, rejected returns the stored refusal,
181
+ // unknown reads "ambiguous: timed out, outcome unknown". Never a bare failure for a write that landed.
182
+ export async function signedPost(pathOrUrl, payload, { timeoutMs = WRITE_TIMEOUT_MS, session, instance, project } = {}) {
179
183
  const proj = projectOf(project, payload, pathOrUrl);
180
184
  const sess = session || sessionFor(proj);
181
185
  const durable = loadIdentity(sess);
182
186
  const id = instance ? loadInstance(sess, instance) : durable;
183
187
  await ensureEnrolled(sess, durable, proj); // instances never enroll — the DURABLE key does
188
+ const resolveOp = async (f) => {
189
+ const op = String(payload?._op || "");
190
+ if (!f.timedOut || !op) return f;
191
+ const base = new URL(toUrl(pathOrUrl, proj)).origin;
192
+ const q = await signedGet(`${base}/_op?id=${encodeURIComponent(op)}`, { session: sess, instance, project: proj });
193
+ const st = q.ok ? q.json?.status : "unknown";
194
+ if (st === "committed") return { ok: true, status: q.json.code || 200, json: q.json.body, resolved: "committed" };
195
+ if (st === "rejected") return { ok: false, status: q.json.code || 400, json: q.json.body, resolved: "rejected" };
196
+ return { ...f, ambiguous: true, reason: "ambiguous: timed out, outcome unknown" };
197
+ };
184
198
  try {
185
199
  // sfetchJson (FROZEN) stringifies the payload + signs with `id` in one call — the single shape
186
200
  // every client uses (lib/signed-fetch.mjs). We pass our memoised identity so it doesn't re-load.
@@ -200,5 +214,5 @@ export async function signedPost(pathOrUrl, payload, { timeoutMs = DEFAULT_TIMEO
200
214
  const text = await r.text();
201
215
  let json = null; try { json = text ? JSON.parse(text) : null; } catch {}
202
216
  return { ok: true, status: r.status, json };
203
- } catch (e) { return failure(e, timeoutMs); }
217
+ } catch (e) { return resolveOp(failure(e, timeoutMs)); }
204
218
  }