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.
- package/.claude-plugin/plugin.json +1 -1
- package/bin/crew-runner.mjs +124 -10
- package/hub/reaper.mjs +23 -56
- package/hub/routes/messages.mjs +24 -25
- package/mcp.mjs +27 -0
- package/package.json +1 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "trantor",
|
|
3
|
-
"version": "0.18.
|
|
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/crew-runner.mjs
CHANGED
|
@@ -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
|
-
|
|
351
|
-
|
|
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 {
|
|
358
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
87
|
-
//
|
|
88
|
-
//
|
|
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
|
-
//
|
|
138
|
-
//
|
|
139
|
-
//
|
|
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)
|
|
162
|
-
// "
|
|
163
|
-
//
|
|
164
|
-
//
|
|
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
|
|
231
|
-
//
|
|
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
|
-
//
|
|
252
|
-
//
|
|
253
|
-
//
|
|
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
|
|
289
|
-
//
|
|
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()) {
|
package/hub/routes/messages.mjs
CHANGED
|
@@ -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
|
-
//
|
|
46
|
-
//
|
|
47
|
-
//
|
|
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`
|
|
77
|
-
//
|
|
78
|
-
//
|
|
79
|
-
//
|
|
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 }) => {
|