trantor 0.18.58 → 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.58",
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": {
@@ -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. 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.`;
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;
@@ -331,6 +331,11 @@ function startDutyNudgeWatcher(plan, sinceMs) {
331
331
  // The hub hands a message out exactly once, so a turn that died took its wake with it. Here a
332
332
  // message is consumed only when a turn exits 0; the queue lives on disk and retries on backoff.
333
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 = [];
334
339
  // A cap, so a long outage cannot grow the queue without bound. Overflow drops the OLDEST and says
335
340
  // so on the bus — a silent drop is the exact failure this whole mechanism exists to end.
336
341
  const PENDING_MAX = 50;
@@ -345,17 +350,45 @@ const RETRY_MS = (() => {
345
350
  const custom = raw ? raw.split(",").map(Number).filter(n => Number.isFinite(n) && n > 0) : [];
346
351
  return custom.length ? custom : [30e3, 60e3, 120e3, 300e3, 900e3];
347
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
+ }
348
371
  function savePending(wake, bcast) {
349
372
  try {
350
- if (!wake.length && !bcast.length) { try { unlinkSync(PENDF); } catch {} return; }
351
- 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 || "" }));
352
378
  } catch {}
353
379
  }
354
380
  function loadPending() {
355
381
  try {
356
382
  const j = JSON.parse(readFileSync(PENDF, "utf8"));
357
- return { wake: Array.isArray(j.wake) ? j.wake : [], bcast: Array.isArray(j.bcast) ? j.bcast : [] };
358
- } 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: "" }; }
359
392
  }
360
393
 
361
394
  // Auth failures in TURN OUTPUT: opencode prints its auth error and still exits 0 (#5405). The rules
@@ -835,7 +868,13 @@ exit $turn_exit`;
835
868
  // api-error (the CLI failed), completed — and what it cost in tokens, even when this CLI printed
836
869
  // no usage line (0 means "not reported", never "free"). `cut` stays too: the drills read it.
837
870
  const outcome = cut ? "cut" : (effExit !== 0 ? "api-error" : lastEmptyTurn ? "empty" : "completed");
838
- 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, tokens };
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 };
839
878
  if (cut) telemetryRow.cut = true;
840
879
  telemetry(telemetryRow);
841
880
  log(`turn ended (exit ${realExit}${effExit !== realExit ? ` → effective ${effExit} (${lastEmptyOutput ? "empty-output" : "auth"})` : ""}, ${((Date.now() - t0) / 1000).toFixed(0)}s)`);
@@ -1051,6 +1090,12 @@ function askedExcerpt(message) {
1051
1090
  // broadcasts batched behind them. Restored from disk first: a runner that was killed mid-turn
1052
1091
  // (or a machine that rebooted) still owes those messages, and the hub will never send them again.
1053
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
+ }
1054
1099
  // Say what the restore SHED, not just what it kept. A queue that quietly halves itself on restart
1055
1100
  // is indistinguishable from one that lost real work, and this is the moment the expiry above
1056
1101
  // actually bites — a wedged seat comes back carrying only what still means something.
@@ -1073,6 +1118,10 @@ function askedExcerpt(message) {
1073
1118
  stateSkip("kickoff");
1074
1119
  const ec0 = await runTurn(composedTurn({ base: KICKOFF, lessons: pickLessons(LESSONS_RAW, "") }), true, "kickoff");
1075
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);
1076
1125
  let lastTurnAt = Date.now();
1077
1126
  if (PULSE_MS) log(`pulse armed — mission re-read every ${Math.round(PULSE_MS / 1000)}s (${MISSION_FILE})`);
1078
1127
  log(`parked — long-polling the bus as ${SESSION} (free; this poll is also the heartbeat)`);
@@ -1083,18 +1132,21 @@ function askedExcerpt(message) {
1083
1132
  if (PULSE_MS && Date.now() - lastTurnAt >= PULSE_MS) {
1084
1133
  stateSkip("pulse");
1085
1134
  const ecp = await runTurn(composedTurn({ base: PULSE_PROMPT + "\n\n", rulesText: RULES, lessons: pickLessons(LESSONS_RAW, PULSE_PROMPT) }), false, "pulse");
1086
- 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(); }
1087
1138
  lastTurnAt = Date.now();
1088
1139
  log("parked — waiting for the next message or pulse");
1089
1140
  continue;
1090
1141
  }
1091
1142
  // A due REDELIVERY runs before we go back to waiting — during an outage the bus is silent by
1092
1143
  // definition, so the retry timer is the only thing that will ever move these messages.
1093
- 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; }
1094
1146
  // cap the long-poll hold so neither a due pulse nor a due redelivery waits out a silent 280s window
1095
1147
  const due = [];
1096
1148
  if (PULSE_MS) due.push(PULSE_MS - (Date.now() - lastTurnAt));
1097
- if (pendingWake.length) due.push(retryAt - Date.now());
1149
+ if (pendingWake.length && !awaitingAsk) due.push(retryAt - Date.now());
1098
1150
  const holdS = due.length
1099
1151
  ? Math.max(5, Math.min(280, Math.ceil(Math.min(...due) / 1000)))
1100
1152
  : 280;
@@ -1123,6 +1175,23 @@ function askedExcerpt(message) {
1123
1175
  // reply-linked outcomes, and the old stable marker before direct-address logic sees them. Status
1124
1176
  // broadcasts are presence chatter and are dropped rather than saved as future prompt context.
1125
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
+ }
1126
1195
  // #5760: the hub's hourly same-project-sessions FYI is coordination context, batched like a
1127
1196
  // broadcast; file-conflict and linked-activity overseer warnings still wake.
1128
1197
  const fyi = msgs.filter(m => m.from === "hub:duty" && String(m.text || "").startsWith("🤝 OVERSEER same-project-sessions"));
@@ -1173,6 +1242,15 @@ function askedExcerpt(message) {
1173
1242
  kind: "status", text: `⚠️ ${SESSION} dropped ${dropped.length} undelivered message(s) — queue hit its ${PENDING_MAX} cap during a failure streak` }).catch(() => {});
1174
1243
  }
1175
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; }
1176
1254
  // Respect an active backoff: a new message during an outage joins the batch, it does not
1177
1255
  // reset the clock and hammer a CLI that is already failing.
1178
1256
  if (Date.now() < retryAt) { log(`queued — ${pendingWake.length} undelivered, next attempt in ${Math.max(0, Math.round((retryAt - Date.now()) / 1000))}s`); continue; }
@@ -1182,6 +1260,31 @@ function askedExcerpt(message) {
1182
1260
 
1183
1261
  // Run the pending batch. The messages are cleared ONLY on exit 0; any other outcome leaves them
1184
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
+ }
1185
1288
  async function deliverWake() {
1186
1289
  const wake = pendingWake;
1187
1290
  const dutyPlan = DUTY_NUDGES
@@ -1257,6 +1360,7 @@ function askedExcerpt(message) {
1257
1360
  });
1258
1361
  const stopDutyNudgeWatcher = startDutyNudgeWatcher(dutyPlan, tStart);
1259
1362
  let ec;
1363
+ turnAsk = null; // #7756: the judge reads THIS turn's ending, never a previous turn's ask
1260
1364
  const stateStep = !stateSkip("wake", card);
1261
1365
  try {
1262
1366
  ec = stateStep
@@ -1268,7 +1372,7 @@ function askedExcerpt(message) {
1268
1372
  // dies quietly (§4.6).
1269
1373
  observation: [stateObservation, wakeText, ctxText, againText + freshText].filter(Boolean).join("\n"),
1270
1374
  })
1271
- : await runTurn(prompt, fresh, deliveryFails ? `${trigger} (redelivery)` : trigger);
1375
+ : await runTurn(prompt, fresh, deliveryFails ? `${trigger} (redelivery)` : trigger, { judgeOutcome: askJudge });
1272
1376
  }
1273
1377
  finally { stopDutyNudgeWatcher(); }
1274
1378
  const secs = Math.round((Date.now() - tStart) / 1000);
@@ -1350,6 +1454,14 @@ function askedExcerpt(message) {
1350
1454
  await notifyAssigners(assigners,
1351
1455
  `⚠️ your contract FAILED on ${SESSION} (exit ${ec}, ${reason}) · retrying in ${Math.round(wait / 1000)}s · asked: "${asked}"`);
1352
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`);
1353
1465
  } else if (lastEmptyTurn) {
1354
1466
  // #7759: the turn exited 0 but was HOLLOW — a banner is not work. The wake is NOT
1355
1467
  // consumed: the queue is kept and the ladder retries, and the assigner hears EMPTY,
@@ -1370,6 +1482,8 @@ function askedExcerpt(message) {
1370
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}"`);
1371
1483
  } else {
1372
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);
1373
1487
  savePending([], []);
1374
1488
  await reportHealthy();
1375
1489
  await notifyAssigners(assigners,
package/hub/reaper.mjs CHANGED
@@ -82,12 +82,10 @@ function appendReaperStaleLog(t, reason, ts) {
82
82
  const lastSeen = seen ? `${humanMs(ts - seen)} ago` : "never";
83
83
  appendTaskLog(t, "reaper", `${reason}; owner last seen ${lastSeen}`, ts);
84
84
  }
85
- // The general stale-card reaper prunePeers never was. Every 60s:
86
- // (a) close a focus card once its session has been OFFLINE past FOCUS_OFFLINE_MS (not the old 6h peer TTL).
87
- // (b) move an OFFLINE-owner doing card to "stale" once it's untouched past REAP_GRACE_MS.
88
- // Testing is waiting for the operator's verdict and is therefore outside the reaper's authority.
89
- // It NEVER touches a card whose owner is still online, so a live long task is safe; the owner-alive-but-idle
90
- // "forgot its card" case is left to the explicit /sweep path (preview + confirm).
85
+ // The general stale-card reaper prunePeers never was. Every 60s: (a) close a focus card whose
86
+ // session is OFFLINE past FOCUS_OFFLINE_MS; (b) stale an offline-owner doing card past REAP_GRACE_MS.
87
+ // Testing (the operator's verdict) and online owners are NEVER touched the owner-alive-but-idle
88
+ // case belongs to the explicit /sweep path (preview + confirm).
91
89
  function reapStaleCards() {
92
90
  const onCut = now() - ONLINE_MS;
93
91
  const focusCut = now() - FOCUS_OFFLINE_MS;
@@ -133,20 +131,10 @@ function reapStaleCards() {
133
131
  }
134
132
  setInterval(reapStaleCards, REAP_INTERVAL_MS).unref?.();
135
133
 
136
- // ---- the contract ledger -------------------------------------------------------------------
137
- // ONE derivation, shared by GET /contracts and the reaper below. Two copies of this is how you get
138
- // an endpoint and a sweeper that disagree about what is open, which is the same "two names for one
139
- // intent" mistake the spawn guards made.
140
- //
141
- // A contract is a DIRECT message from `session` to one peer. It closes when that peer answers:
142
- // strictly by `re`, or, for seats that predate that column, oldest-open-first. Excluded outright,
143
- // because none of these can ever be answered and so would hang open forever:
144
- // - broadcasts (`to === "all"`)
145
- // - self-dispatch (`from === to`) — a reply from yourself is never counted as an answer
146
- // - the hub's own pseudo-identities (`hub:*`) and hub-authored mail — nothing polls them (0.17.87)
147
- // Durations an agent READS and acts on, so they must not round to nonsense. Minutes are the useful
148
- // unit in production (the abandon window is an hour), but the drills run in seconds and "past the 0m
149
- // abandon window" is a sentence that tells the reader nothing.
134
+ // ---- the contract ledger: ONE derivation shared by GET /contracts and the reaper below ---------
135
+ // A contract is a DIRECT message from `session` to one peer, closed when that peer answers:
136
+ // strictly by `re`, or, for seats that predate that column, oldest-open-first. Broadcasts,
137
+ // self-dispatch and hub:* identities are excluded — none can ever be answered (0.17.87).
150
138
  function humanMs(ms) {
151
139
  const n = Math.max(0, Number(ms) || 0);
152
140
  if (n < 60000) return `${Math.max(1, Math.round(n / 1000))}s`;
@@ -158,11 +146,10 @@ function contractRecipientIsAnswerable(m) {
158
146
  return !!m.to && m.to !== "all" && m.from !== m.to && !m.to.startsWith("hub:") && !m.from.startsWith("hub:");
159
147
  }
160
148
 
161
- // A direct message the SENDER declared owes nothing back (#7079). `wake:false` is the sender saying
162
- // "context, not a contract" the send result even prints "(batched no turn)" and a `receipt` or
163
- // `status` is a report, not a request. None of these ever buys the recipient a turn, so none can be
164
- // answered; counted as contracts they age into `stalled` and block the dispatcher's stop hook over
165
- // work that was never owed (four times in one day, every row an ack to an idle seat).
149
+ // A direct message the SENDER declared owes nothing back (#7079): `wake:false` ("context, not a
150
+ // contract"), a `receipt`, or a `status` never buys the recipient a turn, so none can be answered —
151
+ // counted as contracts they age into `stalled` and block the dispatcher's stop hook over work that
152
+ // was never owed.
166
153
  function contractIsAck(m) {
167
154
  return m.wake === false || m.kind === "receipt" || m.kind === "status";
168
155
  }
@@ -175,10 +162,12 @@ function contractsFor(session, { project = "", windowMs = CONTRACT_WINDOW_MS, ov
175
162
  const mine = state.messages.filter(m =>
176
163
  m.from === session && m.ts >= cutoff && contractRecipientIsAnswerable(m) && (!project || m.project === project));
177
164
  const replies = state.messages.filter(m => m.to === session && m.from !== session && m.ts >= cutoff);
165
+ // #7756: an ask (kind:"ask") rides `re` to name the contract it QUESTIONS — it is not the answer.
166
+ // Counted as one, the contract would read answered while the seat sits blocked waiting.
178
167
  const byRe = new Map();
179
- for (const r of replies) if (r.re) byRe.set(Number(r.re), r);
168
+ for (const r of replies) if (r.re && r.kind !== "ask") byRe.set(Number(r.re), r);
180
169
  const looseByPeer = new Map();
181
- for (const r of replies) if (!r.re) { if (!looseByPeer.has(r.from)) looseByPeer.set(r.from, []); looseByPeer.get(r.from).push(r); }
170
+ for (const r of replies) if (!r.re && r.kind !== "ask") { if (!looseByPeer.has(r.from)) looseByPeer.set(r.from, []); looseByPeer.get(r.from).push(r); }
182
171
  for (const arr of looseByPeer.values()) arr.sort((a, b) => a.ts - b.ts);
183
172
 
184
173
  const out = [];
@@ -227,17 +216,8 @@ function contractsFor(session, { project = "", windowMs = CONTRACT_WINDOW_MS, ov
227
216
 
228
217
  // ---- superseded: the terminal state for a row nobody will ever answer ------------------------
229
218
  // `abandoned` keys on the ASSIGNEE being gone, so a permanently HEALTHY seat could strand a
230
- // contract forever: it can never be answered (that seat's replies all carry `re` for other
231
- // contracts, so the loose-reply fallback above never claims this one) and it can never be
232
- // abandoned (the seat is alive). The row then blocks its dispatcher's stop hook every single
233
- // turn, for days — observed on #10573 across two consecutive sessions.
234
- //
235
- // TWO signals must agree, because either alone is wrong. "The peer answered something newer" on
236
- // its own would punish honest out-of-order completion — a seat handed three jobs may finish the
237
- // third first and still be working the second, which is a real pattern this suite already drills.
238
- // Age on its own would punish a seat legitimately grinding one long job. Together they are only
239
- // true when the assignee is alive, has moved on to later work, AND the row has sat unanswered
240
- // past the window in which any genuine in-flight job would have reported.
219
+ // contract forever unanswerable, yet never abandoned blocking the stop hook for days (#10573).
220
+ // Two signals must agree (a newer answer AND age past the window) so out-of-order completion is safe.
241
221
  const newestAnswered = new Map();
242
222
  for (const c of out) {
243
223
  if (c.answered && c.ts > (newestAnswered.get(c.to) || 0)) newestAnswered.set(c.to, c.ts);
@@ -248,20 +228,9 @@ function contractsFor(session, { project = "", windowMs = CONTRACT_WINDOW_MS, ov
248
228
  if (c.ts < (newestAnswered.get(c.to) || 0)) c.disposition = "superseded";
249
229
  }
250
230
  // ---- superseded by a later DIRECT reply: the morning case (#11047/#11048) --------------------
251
- // A row can be unanswerable by a seat that is perfectly healthy: its later replies all carry `re`
252
- // for NEWER contracts (a re-dispatch, or an "ack by reference" threaded to the newer id), so
253
- // neither byRe nor the loose fallback ever claims the old row the two matchers above only ever
254
- // see replies aimed at the NEWER work. The row then sits WAITING forever: it can never be
255
- // answered, it can never be abandoned (the seat is alive), and the newestAnswered rule above
256
- // misses it whenever the newer work was dispatched under a peer identity the old row never shares.
257
- //
258
- // The signal both matchers ignored is the DIRECT reply itself: if the assignee has sent this
259
- // session ANY message after the row was dispatched, the assignee is alive, reachable, and has
260
- // demonstrably moved on to later work — an older row that has then sat unanswered past the
261
- // abandon window is dead weight, not in flight. Age still gates it, so honest out-of-order
262
- // completion (a seat that answered a newer job while still working an older one) is never
263
- // punished — the older row stays open until the window that any genuine in-flight job would have
264
- // reported within has passed.
231
+ // Both matchers above only see replies aimed at NEWER work, so a healthy seat whose replies are
232
+ // all `re`-threaded to newer contracts strands the old row even when newestAnswered misses it.
233
+ // ANY later direct reply + past the abandon window = moved on; age gates out-of-order completion.
265
234
  const latestDirectReply = new Map();
266
235
  for (const r of replies) {
267
236
  if (r.ts > (latestDirectReply.get(r.from) || 0)) latestDirectReply.set(r.from, r.ts);
@@ -285,10 +254,8 @@ function contractDispatchers(windowMs = CONTRACT_WINDOW_MS) {
285
254
  }
286
255
 
287
256
  // The contract reaper. Records — never invents an answer for — a contract whose assignee has been
288
- // quiet past CONTRACT_ABANDON_MS, so the abandonment survives a hub restart (in-memory-only state is
289
- // exactly why the escalation backlog re-fires on every restart) and shows up once in the FEED.
290
- // After this the contract stops counting as open, so it stops nagging every future session; it stays
291
- // listed with its evidence, so `relay_contracts` can still show what died.
257
+ // quiet past CONTRACT_ABANDON_MS, persisted so the abandonment survives a hub restart and shows once
258
+ // in the FEED. It stops counting as open but stays listed with its evidence for `relay_contracts`.
292
259
  function reapAbandonedContracts() {
293
260
  let changed = false;
294
261
  for (const session of contractDispatchers()) {
@@ -4,7 +4,7 @@ import { setTimeout, setInterval, clearInterval } from "node:timers";
4
4
  export async function routeMessages({ req, res, q, P, auth, ctx }) {
5
5
  const {
6
6
  state, body, json, stripNulText, crossProjectGuard, touch, pushToStreams,
7
- appendEvent, markDelivered, contractsFor, canUseInboxSession, inboxWindow,
7
+ appendEvent, appendCardEvent, appendTaskLog, markDelivered, contractsFor, canUseInboxSession, inboxWindow,
8
8
  deliverable, inboxReadable, inboxResponse, filterReadable, streams, UI,
9
9
  AUTH_MODE, persistHealth, duty, now, markDirty, assertNoSecrets,
10
10
  CONTRACT_WINDOW_MS,
@@ -39,20 +39,28 @@ export async function routeMessages({ req, res, q, P, auth, ctx }) {
39
39
  // that field is the card-event key, and /card must keep counting card events only.
40
40
  const refs = [...new Set((msg.text.match(/#(\d{1,7})(?![0-9])/g) || []).map(s => Number(s.slice(1))))].slice(0, 8);
41
41
  appendEvent("message", msg.project, msg.from, { msgId: msg.id, toSession: msg.to, text: msg.text.slice(0, 2000), refs });
42
+ // #7756: the ask primitive's board half — a kind:ask blocks the card it cites (the question is
43
+ // the note, so the board reads why nothing moves); the answer (re = the ask's id) unblocks it.
44
+ const askCardMove = (cardId, to, noteText) => {
45
+ const t = cardId && state.tasks.find(x => x.id === cardId && x.project === msg.project);
46
+ if (!t || t.status === to || (to === "doing" && t.status !== "blocked")) return;
47
+ const from = t.status;
48
+ (t.history ||= []).push({ from, to, by: msg.from, ts: now() });
49
+ if (t.history.length > 40) t.history.splice(0, 10);
50
+ t.status = to; t.updated = now();
51
+ appendTaskLog(t, msg.from, noteText); appendCardEvent("moved", t, msg.from, from, to); markDirty();
52
+ };
53
+ if (kind === "ask") askCardMove(refs[0] || 0, "blocked", msg.text);
54
+ else if (re) {
55
+ const opened = state.messages.find(m => m.id === re && m.kind === "ask");
56
+ if (opened) askCardMove(Number((String(opened.text).match(/#(\d{1,7})(?![0-9])/) || [])[1] || 0), "doing", `answered: ${text}`);
57
+ }
42
58
  return json(res, 200, { ok: true, id: msg.id });
43
59
  }
44
60
  // ---- /contracts: what this session dispatched and has not been answered on ----------------
45
- // A contract is a DIRECT message from you to one peer. It closes when that peer sends you an
46
- // outcome: strictly by `re`, or, for seats that predate it, oldest-open-first. Broadcasts are
47
- // never contracts. Each open one carries the assignee's presence, because the actionable half
48
- // of "still waiting" is whether anyone is still on the other end.
49
- // ---- /delivered: an endpoint that has actually READ its mail says so ----------------------
50
- // The desktop app lists with peek=1 on purpose, so it never steals a message from a session's
51
- // delivery hooks. For a HUMAN endpoint there are no hooks — the app is the only reader — so
52
- // sasha@mac's deliveredUpTo sat at 0 forever while mail piled up. dutyTick then escalated every
53
- // message the human had already read, told the duty seat about it, the seat messaged the human,
54
- // and that was undelivered too: about six escalations a minute, all about mail already read.
55
- // Peeking stays the default; this lets a reader record delivery explicitly instead.
61
+ // Open contracts carry the assignee's presence the actionable half of "still waiting" is
62
+ // whether anyone is on the other end. /delivered lets a HUMAN endpoint (the app peeks, never
63
+ // steals) record delivery explicitly, so duty stops escalating mail the human already read.
56
64
  if (req.method === "POST" && P === "/delivered") {
57
65
  const b = await body(req);
58
66
  const session = String(b.session || "");
@@ -73,19 +81,10 @@ export async function routeMessages({ req, res, q, P, auth, ctx }) {
73
81
  const overdueMs = Number.isFinite(rawOverdue) ? Math.max(0, rawOverdue) : null;
74
82
  const all = contractsFor(session, { project: String(q.project || ""), windowMs, overdueMs });
75
83
  const by = (d) => all.filter(c => c.disposition === d).length;
76
- // Abandoned contracts leave `contracts` entirely and ride in their own key.
77
- //
78
- // Not cosmetic. A session's hooks are PINNED at session start, so an older stop hook iterates
79
- // `contracts` with its own predicate and knows nothing about `disposition` it kept blocking on
80
- // ghosts no matter what the hub called them. Keeping them in the array meant the fix only
81
- // reached sessions that restarted, and a live one nagged its operator every single turn.
82
- // Splitting them out fixes every running session the moment the hub redeploys, and the ledger
83
- // still shows what died via `abandonedContracts`.
84
- // `superseded` leaves `contracts` for exactly the reason `abandoned` does: a session's hooks
85
- // are PINNED at session start, so an older stop hook filters this array with its own
86
- // predicate and would keep blocking on a row the hub has already settled.
87
- // `ack` leaves `contracts` too (#7079): a `wake:false` send, a `receipt` or a `status` is the
88
- // sender declaring nothing is owed, so an old pinned hook must never see it as a row to block on.
84
+ // Abandoned/superseded/ack contracts leave `contracts` and ride in their own keys (#7079).
85
+ // Not cosmetic: a session's hooks are PINNED at session start, so an older stop hook filters
86
+ // this array with its own predicate and would keep blocking on rows the hub already settled —
87
+ // splitting them out fixes every running session the moment the hub redeploys.
89
88
  const out = all.filter(c => c.disposition !== "abandoned" && c.disposition !== "superseded" && c.disposition !== "ack");
90
89
  return json(res, 200, {
91
90
  session, contracts: out, abandonedContracts: all.filter(c => c.disposition === "abandoned"),
package/mcp.mjs CHANGED
@@ -442,6 +442,33 @@ server.tool("relay_send", "Send a live message to another agent session (or 'all
442
442
  return { content: [{ type: "text", text: `sent #${id} to ${to}${wake === false ? " (batched — no turn)" : ""}` }] };
443
443
  });
444
444
 
445
+ server.tool("relay_ask", "Ask a BLOCKING question on your card without forfeiting the contract (#7756). This is the correct move when the contract omits a fact you cannot proceed without — never invent the value. Sends a kind:ask message to the card's assigner: the hub blocks the card with your question as its note so the board reads why nothing is moving, your runner keeps the wake owed (no failure, no park), and the assigner's answer resumes THIS session with the original contract re-attached. End your turn right after calling it.",
446
+ { card: z.number().describe("the card your current contract is working"),
447
+ question: z.string().describe("the ONE missing fact you cannot proceed without, stated precisely") },
448
+ async ({ card, question }) => {
449
+ // Append-only log rule, same as relay_send: a secret is refused BEFORE anything reaches the hub.
450
+ const scrub = assertNoSecrets(question);
451
+ if (!scrub.ok) return { content: [{ type: "text", text: `REFUSED — not sent. Credential-shaped string(s) detected: ${scrub.kinds.join(", ")}. Remove them and resend.` }], isError: true };
452
+ const { task } = await api("GET", `/card?project=${encodeURIComponent(PROJECT)}&id=${card}`);
453
+ if (!task) return { content: [{ type: "text", text: `${PROJECT}: no card #${card}` }], isError: true };
454
+ // The assigner is who DISPATCHED the card (by); the assignee is usually this seat itself.
455
+ const assigner = task.by && task.by !== SESSION ? task.by
456
+ : task.assignee && task.assignee !== SESSION ? task.assignee : "";
457
+ if (!assigner) return { content: [{ type: "text", text: `card #${card} has nobody to ask (by=${task.by || "?"}, assignee=${task.assignee || "?"})` }], isError: true };
458
+ // `re` threads the ask to the contract it questions: the assigner's latest direct message to
459
+ // this session, preferring one that cites this card. Peek only — the hooks own delivery.
460
+ let re = 0;
461
+ try {
462
+ const { messages } = await api("GET", `/inbox?session=${encodeURIComponent(SESSION)}&since=0&peek=1`);
463
+ const direct = (messages || []).filter(m => m.from === assigner && m.to === SESSION);
464
+ re = (direct.filter(m => String(m.text || "").includes(`#${card}`)).pop() || direct.pop())?.id || 0;
465
+ } catch {}
466
+ const payload = { from: SESSION, to: assigner, kind: "ask", text: `❓ ask on #${card}: ${question}` };
467
+ if (re) payload.re = re;
468
+ const sentAsk = await api("POST", "/send", payload);
469
+ return { content: [{ type: "text", text: `ask #${sentAsk.id} sent to ${assigner} — card #${card} now reads blocked with your question. Your turn stays owed (no park, no failure); the answer resumes this session with the contract re-attached. End your turn now.` }] };
470
+ });
471
+
445
472
  server.tool("relay_duty_failure", "Duty-seat only: record that a required cross-session socket nudge was skipped or that a relay send returned 403. The hub appends the failure to the target project's active focus card and exposes it in trantor doctor.",
446
473
  { recipient: z.string().describe("recipient session whose project is affected"), project: z.string().optional().describe("target project; normally inferred from the recipient"), kind: z.enum(["relay-403", "skipped-nudge"]), detail: z.string().max(500).optional() },
447
474
  async ({ recipient, project, kind, detail }) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trantor",
3
- "version": "0.18.58",
3
+ "version": "0.18.59",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "trantor": "bin/cli.mjs"