trantor 0.18.50 → 0.18.51
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 +69 -1
- package/bin/doctor.mjs +61 -1
- package/bin/duty.mjs +68 -1
- package/bin/write-handoff.mjs +7 -1
- package/hooks/lib/handoff.mjs +115 -1
- package/hooks/sessionstart.mjs +10 -0
- package/lib/state/derive.mjs +229 -0
- package/lib/state/gate.mjs +355 -0
- package/lib/state/promote.mjs +122 -0
- package/lib/state/store.mjs +442 -0
- package/package.json +1 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "trantor",
|
|
3
|
-
"version": "0.18.
|
|
3
|
+
"version": "0.18.51",
|
|
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
|
@@ -469,10 +469,40 @@ async function parkSeat(reason, undelivered, resetHint = 0) {
|
|
|
469
469
|
if (orch !== SESSION) await api("/send", { from: SESSION, to: orch, text, project: PROJ, kind: "alert" }).catch(() => {});
|
|
470
470
|
}
|
|
471
471
|
log(`\x1b[31mparked (${reason})${when ? ` — retrying after ${when}` : " — no reset time in the output; waiting for a restart"}\x1b[0m`);
|
|
472
|
+
// The two /send calls above are the whole escalation, and on 2026-09-09 that was not enough:
|
|
473
|
+
// the DUTY seat parked on a quota read, held 48 messages for 21.9 hours, and announced it over
|
|
474
|
+
// the very bus that had stopped moving, to an orchestrator that was idle and therefore could not
|
|
475
|
+
// receive it. The alarm for "the bus is stuck" cannot itself be a bus message. So park also
|
|
476
|
+
// rings a bell the operator can actually hear, out of band, once per park.
|
|
477
|
+
notifyOperator(`Trantor: ${SESSION} PARKED (${reason})`,
|
|
478
|
+
`${undelivered} message(s) held${when ? ` — retrying after ${when}` : ` — needs \`trantor up ${AGENT}\``}`);
|
|
472
479
|
// No reset time means no timer can clear it: hold until the operator restarts the seat.
|
|
473
480
|
return resetAt || Number.MAX_SAFE_INTEGER;
|
|
474
481
|
}
|
|
475
482
|
|
|
483
|
+
/**
|
|
484
|
+
* Reach the operator on a channel that does not depend on the bus, the hub, or a live session.
|
|
485
|
+
* Best-effort and strictly non-fatal: a seat must never die because a notifier is missing.
|
|
486
|
+
* Silence-able with TRANTOR_NO_DESKTOP_NOTIFY=1 for headless boxes and test runs.
|
|
487
|
+
*/
|
|
488
|
+
function notifyOperator(title, body) {
|
|
489
|
+
if (process.env.TRANTOR_NO_DESKTOP_NOTIFY === "1") return;
|
|
490
|
+
try {
|
|
491
|
+
// Always leave a durable trace first: a notification can be missed or suppressed, a file cannot.
|
|
492
|
+
// This is what `trantor doctor` reads, so the escalation survives a machine nobody was sitting at.
|
|
493
|
+
const alertsPath = join(homedir(), ".agent-bus", "alerts.jsonl");
|
|
494
|
+
appendFileSync(alertsPath, `${JSON.stringify({ ts: Date.now(), session: SESSION, title, body })}\n`);
|
|
495
|
+
} catch {}
|
|
496
|
+
try {
|
|
497
|
+
if (process.platform === "darwin") {
|
|
498
|
+
// osascript is present on every mac; no dependency to install and nothing to keep running.
|
|
499
|
+
const esc = (s) => String(s).replace(/["\\]/g, "\\$&");
|
|
500
|
+
spawnSync("osascript", ["-e", `display notification "${esc(body)}" with title "${esc(title)}"`],
|
|
501
|
+
{ timeout: 5000, stdio: "ignore" });
|
|
502
|
+
}
|
|
503
|
+
} catch {}
|
|
504
|
+
}
|
|
505
|
+
|
|
476
506
|
// The seat's own balance rows, for the #6131 read: a stalled turn that printed nothing on a seat
|
|
477
507
|
// whose plan is spent is exhaustion, not a crash. Bounded and best-effort — a slow provider API
|
|
478
508
|
// must never hold up the failure path, and an unreachable one just leaves the reason as it was.
|
|
@@ -887,7 +917,24 @@ function isRunnerSession(session) {
|
|
|
887
917
|
return /^[a-z0-9_.-]+$/.test(label) && !label.startsWith("hub:");
|
|
888
918
|
}
|
|
889
919
|
|
|
920
|
+
// A hub staleness alert describes a condition that was true for a moment: "#16909 has been
|
|
921
|
+
// UNDELIVERED for 2m — go nudge someone". Acting on it 22 hours later is meaningless, and the queue
|
|
922
|
+
// had no expiry, so on 2026-09-09 the duty seat's backlog became SELF-POISONING: the hub kept
|
|
923
|
+
// noticing undelivered mail and sending more alerts, duty could not work them off, and a restart
|
|
924
|
+
// faithfully redelivered 49 dead nudges and re-wedged the seat. 46 of those 49 were hub alerts, the
|
|
925
|
+
// oldest 22.1 hours old, every one describing a two-minute condition.
|
|
926
|
+
//
|
|
927
|
+
// So these EXPIRE. Deliberately narrow: only messages the HUB generated about staleness, never a
|
|
928
|
+
// message from a peer. A real contract is never dropped for being old — a seat that misses a
|
|
929
|
+
// teammate's request is the failure this bus exists to prevent, and no backlog is worth causing it.
|
|
930
|
+
const HUB_ALERT_TTL_MS = Number(process.env.TRANTOR_HUB_ALERT_TTL_MS || 30 * 60_000);
|
|
931
|
+
const isExpiredHubAlert = (m) =>
|
|
932
|
+
m?.from === "hub:duty" &&
|
|
933
|
+
Number.isFinite(m?.ts) &&
|
|
934
|
+
Date.now() - m.ts > HUB_ALERT_TTL_MS;
|
|
935
|
+
|
|
890
936
|
function shouldWake(message) {
|
|
937
|
+
if (isExpiredHubAlert(message)) return false;
|
|
891
938
|
if (isReceipt(message) || isStatusBroadcast(message)) return false;
|
|
892
939
|
// #6134: the SENDER decides. `wake:false` says "this is context, not a contract" — it batches
|
|
893
940
|
// into the next turn's prompt like a broadcast and never buys a CLI session of its own.
|
|
@@ -957,8 +1004,14 @@ function askedExcerpt(message) {
|
|
|
957
1004
|
// broadcasts batched behind them. Restored from disk first: a runner that was killed mid-turn
|
|
958
1005
|
// (or a machine that rebooted) still owes those messages, and the hub will never send them again.
|
|
959
1006
|
const restored = loadPending();
|
|
1007
|
+
// Say what the restore SHED, not just what it kept. A queue that quietly halves itself on restart
|
|
1008
|
+
// is indistinguishable from one that lost real work, and this is the moment the expiry above
|
|
1009
|
+
// actually bites — a wedged seat comes back carrying only what still means something.
|
|
1010
|
+
const shed = restored.wake.filter(isExpiredHubAlert).length +
|
|
1011
|
+
restored.bcast.filter(isExpiredHubAlert).length;
|
|
960
1012
|
let pendingWake = restored.wake.filter(shouldWake);
|
|
961
|
-
let pendingBcast = restored.bcast.filter(m => !isReceipt(m) && !isStatusBroadcast(m));
|
|
1013
|
+
let pendingBcast = restored.bcast.filter(m => !isExpiredHubAlert(m) && !isReceipt(m) && !isStatusBroadcast(m));
|
|
1014
|
+
if (shed) log(`\x1b[33mdropped ${shed} expired hub staleness alert(s) older than ${Math.round(HUB_ALERT_TTL_MS / 60000)}m — they describe conditions that have long since changed\x1b[0m`);
|
|
962
1015
|
let retryAt = 0; // 0 = deliver at the next opportunity
|
|
963
1016
|
let deliveryFails = 0; // consecutive failed attempts at the SAME pending batch
|
|
964
1017
|
if (pendingWake.length) log(`\x1b[33m${pendingWake.length} message(s) survived from a previous run — redelivering\x1b[0m`);
|
|
@@ -1183,6 +1236,21 @@ function askedExcerpt(message) {
|
|
|
1183
1236
|
const parkReason = PARKING_REASONS.has(reason) ? reason : (lastTurnCut ? "time-box" : "api-error");
|
|
1184
1237
|
if (PARKING_REASONS.has(reason) || deliveryFails >= 2) {
|
|
1185
1238
|
retryAt = await parkSeat(parkReason, pendingWake.length, quotaReset);
|
|
1239
|
+
// A supervised seat does not have to sit parked until someone notices. RUNNER_PARK_MAX_MS
|
|
1240
|
+
// is set only by `trantor duty up`, which runs the seat under a launchd keepalive: past the
|
|
1241
|
+
// ceiling, exit and let the supervisor restart it clean — a fresh process re-reads auth and
|
|
1242
|
+
// redelivers the queue from disk, which is exactly what un-wedged the 2026-09-09 incident
|
|
1243
|
+
// when the operator finally ran `trantor duty up` by hand 21.9 hours late.
|
|
1244
|
+
// Unsupervised seats keep the old behaviour: exiting would just kill them for good.
|
|
1245
|
+
const parkMax = Number(process.env.RUNNER_PARK_MAX_MS || 0);
|
|
1246
|
+
if (parkMax > 0) {
|
|
1247
|
+
const wakeIn = Math.max(0, Math.min(retryAt - Date.now(), parkMax));
|
|
1248
|
+
log(`\x1b[33msupervised seat: exiting in ${Math.round(wakeIn / 1000)}s so the keepalive restarts it clean\x1b[0m`);
|
|
1249
|
+
setTimeout(() => {
|
|
1250
|
+
log("parked past the ceiling — exiting for the keepalive to relaunch");
|
|
1251
|
+
process.exit(0); // 0, not 1: this is a deliberate hand-off, not a crash
|
|
1252
|
+
}, wakeIn).unref?.();
|
|
1253
|
+
}
|
|
1186
1254
|
await notifyAssigners(assigners,
|
|
1187
1255
|
`⛔ your contract is PARKED on ${SESSION} (${parkReason}) — not retrying · asked: "${asked}"`);
|
|
1188
1256
|
lastTurnAt = Date.now();
|
package/bin/doctor.mjs
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// Checks: runtime, hub, plugin, each CLI (installed? wired? AUTHENTICATED?), API keys,
|
|
4
4
|
// quota profile, optional Scrooge brain. Prints a checklist with copy-paste fixes.
|
|
5
5
|
// node bin/doctor.mjs
|
|
6
|
-
import { readFileSync, existsSync } from "node:fs";
|
|
6
|
+
import { readFileSync, readdirSync, existsSync } from "node:fs";
|
|
7
7
|
import { join, dirname } from "node:path";
|
|
8
8
|
import { homedir } from "node:os";
|
|
9
9
|
import { execSync } from "node:child_process";
|
|
@@ -304,6 +304,66 @@ prof?.providers && Object.keys(prof.providers).length
|
|
|
304
304
|
? ok(`quota profile set (${Object.entries(prof.providers).map(([k, v]) => `${k}=${v.plan}`).join(", ")})`)
|
|
305
305
|
: warn("quota profile not set — the Advisor will assume API billing everywhere", `node ${join(ROOT, "bin", "profile.mjs")} set claude=max codex=plus deepseek=api … (use YOUR real plans)`);
|
|
306
306
|
|
|
307
|
+
// fleet — is the crew actually OPERATING, not just installed?
|
|
308
|
+
//
|
|
309
|
+
// Added 2026-09-09, because on that morning this command reported nine issues, every one about
|
|
310
|
+
// provider keys and billing attribution, while the duty seat had been holding 48 undelivered
|
|
311
|
+
// messages for 21.9 hours and the orchestrator had slept through a night of finished crew work.
|
|
312
|
+
// Doctor checked whether credentials EXIST. Nothing checked whether the fleet was MOVING. These
|
|
313
|
+
// two signals are both already on disk, written by the runner itself — nobody was reading them.
|
|
314
|
+
section("the fleet (is it actually running?)");
|
|
315
|
+
{
|
|
316
|
+
const busDir = join(H, ".agent-bus");
|
|
317
|
+
// 1. Undelivered queues. crew-runner persists pending-<agent>-<project>.json on every failed
|
|
318
|
+
// delivery and unlinks it when the queue drains, so a file with an old head means mail is
|
|
319
|
+
// stuck for that seat — whatever its process table says.
|
|
320
|
+
let stuck = 0;
|
|
321
|
+
const abandonedSeats = [];
|
|
322
|
+
try {
|
|
323
|
+
for (const f of readdirSync(busDir).filter(n => n.startsWith("pending-") && n.endsWith(".json"))) {
|
|
324
|
+
const j = read(join(busDir, f));
|
|
325
|
+
const held = [...(j?.wake || []), ...(j?.bcast || [])];
|
|
326
|
+
if (!held.length) continue;
|
|
327
|
+
const stamps = held.map(m => m?.ts).filter(Number.isFinite);
|
|
328
|
+
const oldest = stamps.length ? Math.min(...stamps) : j?.ts;
|
|
329
|
+
const hours = (Date.now() - oldest) / 3.6e6;
|
|
330
|
+
const seat = f.replace(/^pending-|\.json$/g, "");
|
|
331
|
+
// Three bands, because one flat warning per stuck queue is its own failure: this machine has
|
|
332
|
+
// leftovers from projects that ended weeks ago, and a doctor that cries about ten of them
|
|
333
|
+
// every run teaches you to skim past the one that matters. Under an hour is the retry ladder
|
|
334
|
+
// doing its job. Over a week is an abandoned seat, worth tidying, not worth alarming about.
|
|
335
|
+
// The band between is the live stall — the shape of the 2026-09-09 incident.
|
|
336
|
+
const abandoned = hours >= 24 * 7;
|
|
337
|
+
if (hours >= 1 && !abandoned) {
|
|
338
|
+
stuck++;
|
|
339
|
+
warn(`${seat}: ${held.length} message(s) undelivered, oldest ${hours.toFixed(1)}h old — mail is not moving`,
|
|
340
|
+
`the runner parks on quota/api failure and only a restart un-parks it: trantor up ${seat.split("-")[0]} (duty: trantor duty up)`);
|
|
341
|
+
} else if (abandoned) {
|
|
342
|
+
abandonedSeats.push(`${seat} (${(hours / 24).toFixed(0)}d)`);
|
|
343
|
+
} else {
|
|
344
|
+
note(`${seat}: ${held.length} queued, oldest ${hours.toFixed(1)}h — within the retry ladder`);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
if (abandonedSeats.length) {
|
|
348
|
+
note(`${abandonedSeats.length} abandoned queue(s) older than a week: ${abandonedSeats.join(", ")} — leftovers from finished work, safe to delete`);
|
|
349
|
+
}
|
|
350
|
+
if (!stuck) ok("no live seat is sitting on undelivered mail");
|
|
351
|
+
} catch { note(`no bus directory at ${busDir} yet — nothing has run`); }
|
|
352
|
+
|
|
353
|
+
// 2. Park alerts. notifyOperator appends one line per park, so a park that happened while nobody
|
|
354
|
+
// was at the machine is still visible here afterwards — the point of writing it to disk.
|
|
355
|
+
try {
|
|
356
|
+
const alerts = readFileSync(join(busDir, "alerts.jsonl"), "utf8").trim().split("\n").filter(Boolean);
|
|
357
|
+
const recent = alerts.map(l => { try { return JSON.parse(l); } catch { return null; } })
|
|
358
|
+
.filter(a => a && Date.now() - a.ts < 24 * 3.6e6);
|
|
359
|
+
if (recent.length) {
|
|
360
|
+
const last = recent[recent.length - 1];
|
|
361
|
+
warn(`${recent.length} seat park alert(s) in the last 24h — most recent: ${last.title}`,
|
|
362
|
+
`read them: tail ~/.agent-bus/alerts.jsonl — then restart the seat named above`);
|
|
363
|
+
} else ok("no seat has parked in the last 24h");
|
|
364
|
+
} catch { ok("no seat has parked in the last 24h"); }
|
|
365
|
+
}
|
|
366
|
+
|
|
307
367
|
say(issues ? `\n${issues} issue(s) — fix the → lines above, then re-run the doctor.` : "\nAll clear — open a claude session in any project and say: \"fire up the crew\".");
|
|
308
368
|
// Must come BEFORE the exit — process.exit() here truncated the report entirely.
|
|
309
369
|
if (JSON_MODE) console.log(JSON.stringify({ ...REPORT, issueCount: issues }));
|
package/bin/duty.mjs
CHANGED
|
@@ -245,6 +245,14 @@ if (cmd === "up") {
|
|
|
245
245
|
const env = (() => {
|
|
246
246
|
const e = { RELAY_URL: hub, RUNNER_RULES: RULES, CREW_KICKOFF: KICKOFF,
|
|
247
247
|
RUNNER_DUTY_NUDGES: "1",
|
|
248
|
+
// A parked seat waits for `trantor up`. That is right for a CODE seat — burning a
|
|
249
|
+
// plan re-sending the same contract helps nobody (#6270). It is wrong for DUTY,
|
|
250
|
+
// which is the seat every other seat's liveness runs through: on 2026-09-09 it
|
|
251
|
+
// parked on a quota read and sat holding 48 messages for 21.9 hours while the
|
|
252
|
+
// crew finished a night's work nobody gated. Duty is the one seat under a launchd
|
|
253
|
+
// keepalive, so it does not need to sit there — it can exit and let the supervisor
|
|
254
|
+
// bring it back clean, which also re-reads auth. 15 minutes, then hand over.
|
|
255
|
+
RUNNER_PARK_MAX_MS: "900000",
|
|
248
256
|
RUNNER_TITLE: "Trantor Duty Agent", RUNNER_ABOUT: ABOUT,
|
|
249
257
|
// launchd starts jobs with a MINIMAL Path — the resurrected seat could not find
|
|
250
258
|
// `claude` and every turn died exit 127 "missing-cli" (found live 2026-08-31,
|
|
@@ -360,6 +368,31 @@ if (cmd === "down") {
|
|
|
360
368
|
process.exit(0);
|
|
361
369
|
}
|
|
362
370
|
|
|
371
|
+
/**
|
|
372
|
+
* Is duty actually moving mail? Read from the runner's own on-disk queue rather than trusting the
|
|
373
|
+
* process table. `pending-<agent>-<project>.json` is written on every failed delivery and deleted
|
|
374
|
+
* when the queue drains, so its existence means messages are held and its oldest entry says for how
|
|
375
|
+
* long. STALL_HOURS is deliberately well past the runner's own 15-minute retry ceiling: anything
|
|
376
|
+
* older than that is not a retry in progress, it is a parked seat nobody restarted.
|
|
377
|
+
*/
|
|
378
|
+
const STALL_HOURS = 1;
|
|
379
|
+
function dutyHealth() {
|
|
380
|
+
const f = join(BUS, `pending-${AGENT}-trantor-duty.json`);
|
|
381
|
+
try {
|
|
382
|
+
const j = JSON.parse(readFileSync(f, "utf8"));
|
|
383
|
+
const wake = Array.isArray(j.wake) ? j.wake : [];
|
|
384
|
+
const bcast = Array.isArray(j.bcast) ? j.bcast : [];
|
|
385
|
+
const held = wake.length + bcast.length;
|
|
386
|
+
if (!held) return { held: 0, stalled: false, oldestHours: "0.0" };
|
|
387
|
+
const stamps = [...wake, ...bcast].map(m => m?.ts).filter(Number.isFinite);
|
|
388
|
+
const oldest = stamps.length ? Math.min(...stamps) : j.ts;
|
|
389
|
+
const hours = (Date.now() - oldest) / 3.6e6;
|
|
390
|
+
return { held, stalled: hours >= STALL_HOURS, oldestHours: hours.toFixed(1) };
|
|
391
|
+
} catch {
|
|
392
|
+
return { held: 0, stalled: false, oldestHours: "0.0" }; // no queue file = nothing held
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
363
396
|
// status
|
|
364
397
|
{
|
|
365
398
|
const pid = alivePid();
|
|
@@ -370,7 +403,41 @@ if (cmd === "down") {
|
|
|
370
403
|
console.log(" whether a seat is still alive, and clears away dead runners. It never writes code");
|
|
371
404
|
console.log(" and never edits your project files. Stop it with: trantor duty down");
|
|
372
405
|
console.log("");
|
|
373
|
-
|
|
406
|
+
// A pid is not health. This line used to read `pid ? "RUNNING" : "NOT running"`, and on 2026-09-09
|
|
407
|
+
// it said RUNNING for a seat whose last successful turn was 21.8 hours earlier and which was
|
|
408
|
+
// holding 48 undelivered messages, the oldest 21.9 hours old — the runner had parked itself
|
|
409
|
+
// waiting on a quota reset and only `trantor up` un-parks it. Nothing noticed, because the one
|
|
410
|
+
// command whose job is to answer "is duty working" was answering "does a process exist".
|
|
411
|
+
//
|
|
412
|
+
// The irony worth keeping: ten lines below, this same function already refuses to assume the hub
|
|
413
|
+
// feed is wired, on the grounds that "a running seat the hub isn't feeding looks identical to a
|
|
414
|
+
// working one from the outside". That rigour was applied to the hub and not to the seat itself.
|
|
415
|
+
//
|
|
416
|
+
// The queue is the honest signal because it survives the process: the runner persists it to
|
|
417
|
+
// pending-<agent>-<project>.json on every failed delivery, so a backlog with an old head means
|
|
418
|
+
// messages are not moving no matter what the process table says.
|
|
419
|
+
const health = dutyHealth();
|
|
420
|
+
if (!pid) {
|
|
421
|
+
console.log("NOT running");
|
|
422
|
+
// A backlog matters MORE when the seat is down, not less: those messages have nobody to deliver
|
|
423
|
+
// them and no retry ladder running. Reporting it only in the pid branch was the first version
|
|
424
|
+
// of this fix, and it hid exactly the case that needs saying out loud.
|
|
425
|
+
if (health.held) {
|
|
426
|
+
console.log(` \x1b[31mand ${health.held} undelivered message(s) are held on disk, oldest ${health.oldestHours}h old\x1b[0m`);
|
|
427
|
+
console.log(` → nothing will deliver them until the seat is back: trantor duty up`);
|
|
428
|
+
} else {
|
|
429
|
+
console.log(" queue empty — nothing is waiting");
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
else if (health.stalled) {
|
|
433
|
+
console.log(`\x1b[31mSTALLED\x1b[0m (pid ${pid}) as ${SESSION} — the process is up but mail is not moving`);
|
|
434
|
+
console.log(` holding ${health.held} undelivered message(s), oldest ${health.oldestHours}h old`);
|
|
435
|
+
console.log(` → the runner parks on quota/api failure and only a restart un-parks it: trantor duty up`);
|
|
436
|
+
} else if (health.held) {
|
|
437
|
+
console.log(`RUNNING (pid ${pid}) as ${SESSION} — draining ${health.held} queued message(s), oldest ${health.oldestHours}h old`);
|
|
438
|
+
} else {
|
|
439
|
+
console.log(`RUNNING (pid ${pid}) as ${SESSION} — queue empty`);
|
|
440
|
+
}
|
|
374
441
|
console.log(existsSync(DUTY_PLIST)
|
|
375
442
|
? `keepalive: installed (${DUTY_LABEL}${process.platform === "darwin" ? (dutyLoaded() ? ", loaded" : ", not loaded in this session") : ""}) — launchd relaunches the seat after a crash or reboot`
|
|
376
443
|
: "keepalive: NOT installed — a crash or reboot leaves the seat down (trantor duty up installs it)");
|
package/bin/write-handoff.mjs
CHANGED
|
@@ -6,7 +6,7 @@ import { writeFileSync, readFileSync, existsSync, mkdirSync, readdirSync } from
|
|
|
6
6
|
import { join, basename } from "node:path";
|
|
7
7
|
import { homedir, hostname } from "node:os";
|
|
8
8
|
import { execSync } from "node:child_process";
|
|
9
|
-
import { spawnBaton, handoffMode, resolveHandoffSurface } from "../hooks/lib/handoff.mjs";
|
|
9
|
+
import { spawnBaton, handoffMode, resolveHandoffSurface, attachState, resolveSeat, resolveHandoffCard } from "../hooks/lib/handoff.mjs";
|
|
10
10
|
import { handoffDir } from "../lib/project.mjs";
|
|
11
11
|
|
|
12
12
|
const baton = process.argv.includes("--baton");
|
|
@@ -63,6 +63,12 @@ if (latest) {
|
|
|
63
63
|
// carries the same interface the hooks-side records have: transcript_path ("" — the summary IS
|
|
64
64
|
// the model's own words), mode (attended|unattended, #5648).
|
|
65
65
|
const rec = { id: `${name}-${stamp}`, project, projectName: name, machine: hostname(), trigger: baton ? "manual-baton" : "manual-skill", stamp: Number(stamp) || 0, summary: summary.trim() || "(empty)", transcript_path: "", mode: handoffMode(name), gitStatus: git, consumed: false, states: [{ state: "written", ts: Number(stamp) || 0, by: baton ? "manual-baton" : "manual-skill" }] };
|
|
66
|
+
// The structured working state rides beside the prose (TDD §4.5), dark behind
|
|
67
|
+
// TRANTOR_STATE_HANDOFF. The manual path gets it for the same reason the hook path does: this
|
|
68
|
+
// record is what the successor loads, and `summary` here IS the model's own handoff — the richest
|
|
69
|
+
// STATE block there is.
|
|
70
|
+
const seat = resolveSeat(name);
|
|
71
|
+
attachState(rec, { project: name, seat, card: resolveHandoffCard({ projectName: name, seat }), worktree: project });
|
|
66
72
|
const file = join(dir, `${rec.id}.json`);
|
|
67
73
|
writeFileSync(file, JSON.stringify(rec, null, 2));
|
|
68
74
|
console.log(`handoff saved: ${file}`);
|
package/hooks/lib/handoff.mjs
CHANGED
|
@@ -18,7 +18,13 @@ import { fileURLToPath } from "node:url";
|
|
|
18
18
|
import { deriveSubagentManifest } from "../../lib/subagent-manifest.mjs";
|
|
19
19
|
import { signedPost } from "./api.mjs";
|
|
20
20
|
import { loadAutonomy, resolveAutonomy } from "../../lib/autonomy.mjs";
|
|
21
|
-
import { resolveProject, orchSessionsPath } from "../../lib/project.mjs";
|
|
21
|
+
import { resolveProject, orchSessionsPath, hostId } from "../../lib/project.mjs";
|
|
22
|
+
// Trantor State (TDD §4.5). Dark behind TRANTOR_STATE_HANDOFF: these are imported unconditionally
|
|
23
|
+
// because they are pure modules with no side effects at load, and a lazy import would make
|
|
24
|
+
// attachState async on a path that is deliberately synchronous.
|
|
25
|
+
import { statePath, readState } from "../../lib/state/store.mjs";
|
|
26
|
+
import { stateError } from "../../lib/state/schema.mjs";
|
|
27
|
+
import { deriveState } from "../../lib/state/derive.mjs";
|
|
22
28
|
|
|
23
29
|
// Writer and reader MUST resolve the same directory — see lib/project.mjs busDir(). This used to
|
|
24
30
|
// honour only RELAY_DATA_DIR while the reader honoured neither override.
|
|
@@ -448,6 +454,109 @@ export function capSummary(text, cap = 4096) {
|
|
|
448
454
|
return head + elide + tail;
|
|
449
455
|
}
|
|
450
456
|
|
|
457
|
+
// ---------------------------------------------------------------------------------------------
|
|
458
|
+
// Trantor State — the structured field on the record (TDD §4.5). `summary` keeps being written
|
|
459
|
+
// exactly as it is today; `state` rides beside it, validated on write and NEVER capped. That is
|
|
460
|
+
// the whole fix for #6528: capSummary's mid-string elision ate the STATE section of the prose, and
|
|
461
|
+
// the structured field cannot lose a member because the lossy operation is not applied to it.
|
|
462
|
+
// ---------------------------------------------------------------------------------------------
|
|
463
|
+
|
|
464
|
+
/** Dark by default. The prose path is untouched either way; this flag only decides whether the
|
|
465
|
+
* structured field is built and rendered (TDD §4.5, "Fallback"). */
|
|
466
|
+
export function stateHandoffEnabled(env = process.env) {
|
|
467
|
+
return ["1", "true", "on", "yes"].includes(String(env.TRANTOR_STATE_HANDOFF || "").toLowerCase());
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/** The bus id of the seat writing this handoff — the same resolution sessionstart.mjs uses, so a
|
|
471
|
+
* sidecar written under the runner's seat name is the one this path reads back. */
|
|
472
|
+
export function resolveSeat(projectName, env = process.env) {
|
|
473
|
+
return env.RELAY_SESSION || (env.RELAY_AGENT ? `${env.RELAY_AGENT}:${projectName}` : `${hostId()}:${projectName}`);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* Which card this handoff belongs to. `TRANTOR_CARD` wins — the crew runner knows the answer for
|
|
478
|
+
* certain and a lookup cannot beat being told. Otherwise ask the hub for this seat's newest open
|
|
479
|
+
* card, on the same 2s best-effort budget as the verify-gates fetch: a hub that is down costs the
|
|
480
|
+
* handoff a card number, never the handoff.
|
|
481
|
+
*/
|
|
482
|
+
export function resolveHandoffCard({ projectName, seat, env = process.env } = {}) {
|
|
483
|
+
const told = Number(env.TRANTOR_CARD);
|
|
484
|
+
if (Number.isInteger(told) && told > 0) return told;
|
|
485
|
+
try {
|
|
486
|
+
const out = execSync(`curl -s --max-time 2 ${JSON.stringify(relayUrl() + "/tasks?project=" + encodeURIComponent(projectName))}`, { encoding: "utf8", timeout: 2500 });
|
|
487
|
+
const tasks = JSON.parse(out).tasks || [];
|
|
488
|
+
const mine = tasks
|
|
489
|
+
.filter(t => t && Number.isInteger(t.id) && t.assignee === seat && ["doing", "testing"].includes(t.status))
|
|
490
|
+
.sort((a, b) => (a.status === b.status ? (b.updated || b.ts || 0) - (a.updated || a.ts || 0) : a.status === "doing" ? -1 : 1));
|
|
491
|
+
return mine.length ? mine[0].id : 0;
|
|
492
|
+
} catch { return 0; }
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/**
|
|
496
|
+
* Attach the structured working state to a handoff record, from two sources in order:
|
|
497
|
+
* 1. the sidecar, when one exists (Phase 2a and after) — read, migrated, validated;
|
|
498
|
+
* 2. derived from git + the model's own STATE block, when none does (every Phase-1 handoff).
|
|
499
|
+
*
|
|
500
|
+
* Invalid or underivable state attaches `null` and logs. It NEVER blocks a handoff: a session at
|
|
501
|
+
* the context wall losing its baton because a state object would not validate is a far worse
|
|
502
|
+
* failure than a successor reading prose, which is exactly what it read before this field existed.
|
|
503
|
+
* @returns {object|null} the attached state
|
|
504
|
+
*/
|
|
505
|
+
export function attachState(rec, { project, seat, card, worktree, env = process.env } = {}) {
|
|
506
|
+
if (!stateHandoffEnabled(env)) return null;
|
|
507
|
+
try {
|
|
508
|
+
const name = project || rec?.projectName || "";
|
|
509
|
+
const who = seat || resolveSeat(name, env);
|
|
510
|
+
const no = Number.isInteger(card) ? card : 0;
|
|
511
|
+
const cwd = worktree || rec?.project || "";
|
|
512
|
+
|
|
513
|
+
let state = null;
|
|
514
|
+
const sidecar = statePath(who, no, name);
|
|
515
|
+
if (sidecar && existsSync(sidecar)) {
|
|
516
|
+
const r = readState(who, no, { project: name, cwd, recover: false });
|
|
517
|
+
if (!r.ok) throw new Error(`sidecar rejected: ${r.code} at ${r.at} — ${r.message}`);
|
|
518
|
+
state = r.state;
|
|
519
|
+
} else {
|
|
520
|
+
state = deriveState({ project: name, seat: who, card: no, worktree: cwd, handoffText: rec?.summary || "" });
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
const why = state ? stateError(state) : "no state could be derived";
|
|
524
|
+
if (why) throw new Error(why);
|
|
525
|
+
rec.state = state;
|
|
526
|
+
return state;
|
|
527
|
+
} catch (e) {
|
|
528
|
+
process.stderr.write(`[trantor] handoff state skipped: ${e?.message || e}\n`);
|
|
529
|
+
rec.state = null;
|
|
530
|
+
return null;
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/** The state as the successor reads it: one compact block, bounded by the schema's own caps, with
|
|
535
|
+
* the absence of credit stated rather than implied. */
|
|
536
|
+
export function renderStateBlock(state) {
|
|
537
|
+
const line = (items) => items.map(i => `${i.id} ${i.text}${i.paths?.length ? ` [${i.paths.join(", ")}]` : ""}`).join("; ");
|
|
538
|
+
const rows = [];
|
|
539
|
+
if (state?.task) rows.push(`task: ${state.task}`);
|
|
540
|
+
for (const [list, label] of [["done", "done"], ["in_flight", "in flight"], ["next", "next"], ["blockers", "blockers"]]) {
|
|
541
|
+
const items = state?.[list] || [];
|
|
542
|
+
if (!items.length) continue;
|
|
543
|
+
const more = list === "done" && state.done_count ? ` (+${state.done_count} compacted)` : "";
|
|
544
|
+
rows.push(`${label} (${items.length}${more}): ${line(items)}`);
|
|
545
|
+
}
|
|
546
|
+
const files = state?.files || {};
|
|
547
|
+
const paths = Object.keys(files);
|
|
548
|
+
const verified = paths.filter(p => files[p].verified === true);
|
|
549
|
+
if (paths.length) rows.push(`files: ${paths.length} touched, ${verified.length} verified${verified.length ? ` — ${verified.join(", ")}` : ""}`);
|
|
550
|
+
const verify = Object.entries(state?.verify || {});
|
|
551
|
+
if (verify.length) rows.push(`verify: ${verify.map(([k, v]) => `${k}=${v}`).join(" ")}`);
|
|
552
|
+
if (!rows.length) return ""; // nothing to render is not a block with a warning in it
|
|
553
|
+
if (!verified.length) {
|
|
554
|
+
rows.push("NO PATH IS VERIFIED HERE — no gate ran at the handoff. Nothing in this block is evidence: re-earn it before you move anything to done.");
|
|
555
|
+
}
|
|
556
|
+
if (state.notes) rows.push(`notes: ${state.notes}`);
|
|
557
|
+
return rows.join("\n");
|
|
558
|
+
}
|
|
559
|
+
|
|
451
560
|
// How fresh a model-authored handoff must be before an automatic digest DEFERS to it: 15 minutes.
|
|
452
561
|
// Older than that, the state it describes has likely moved on — compose fresh.
|
|
453
562
|
const FRESH_HANDOFF_SEC = 15 * 60;
|
|
@@ -564,6 +673,11 @@ export function writeHandoff({ projectDir, sessionId, transcript, trigger, summa
|
|
|
564
673
|
// The §5 machine's ledger: every transition appends here via appendHandoffState.
|
|
565
674
|
states: [{ state: "written", ts: Number(stamp) || 0, by: sessionId || "" }],
|
|
566
675
|
};
|
|
676
|
+
// The structured field (TDD §4.5), dark behind TRANTOR_STATE_HANDOFF. It is attached AFTER the
|
|
677
|
+
// record is built because it reads `summary` — the model's own STATE block is one of its two
|
|
678
|
+
// sources — and BEFORE the write, so the field lands in the same file the successor loads.
|
|
679
|
+
const seat = resolveSeat(projectName);
|
|
680
|
+
attachState(record, { project: projectName, seat, card: resolveHandoffCard({ projectName, seat }), worktree: projectDir });
|
|
567
681
|
const file = join(HANDOFF_DIR, `${record.id}.json`);
|
|
568
682
|
writeFileSync(file, JSON.stringify(record, null, 2));
|
|
569
683
|
supersedeOlderHandoffs(projectName, record.id);
|
package/hooks/sessionstart.mjs
CHANGED
|
@@ -14,6 +14,7 @@ import { fileURLToPath } from "node:url";
|
|
|
14
14
|
import { resolveProject, hostId, resolveHubInfo, knownProjects, nonSeatReason, handoffDir, readOrchSession, writeOrchSession } from "../lib/project.mjs";
|
|
15
15
|
import { formatSubagentManifest } from "../lib/subagent-manifest.mjs";
|
|
16
16
|
import { updateAvailable, maybeNotifyDesktop, readConfig } from "./lib/update-check.mjs";
|
|
17
|
+
import { renderStateBlock } from "./lib/handoff.mjs";
|
|
17
18
|
import { maybeCheckBalances } from "./lib/balance-check.mjs";
|
|
18
19
|
import { getJSON, signedGet, signedPost, loadIdentity } from "./lib/api.mjs";
|
|
19
20
|
import { ledgerPaths, ensureStart, anchorCursor, writeCursor } from "./lib/inbox-ledger.mjs";
|
|
@@ -569,6 +570,15 @@ try {
|
|
|
569
570
|
}
|
|
570
571
|
additionalContext += `\n`;
|
|
571
572
|
}
|
|
573
|
+
// The structured working state (TDD §4.5), AFTER the recap instruction and above the prose: it
|
|
574
|
+
// is bounded by the schema's own caps, so unlike the summary it cannot have lost a member to an
|
|
575
|
+
// elision (#6528). A record without one — every handoff until the flag is on — renders nothing
|
|
576
|
+
// and the successor sees exactly today's prose handoff.
|
|
577
|
+
const stateBlock = renderStateBlock(handoff.state);
|
|
578
|
+
if (stateBlock) {
|
|
579
|
+
additionalContext += `## Working state (structured, card #${sanitize(String(handoff.state.card || 0))}, turn ${sanitize(String(handoff.state.cursor?.turn ?? 0))})\n`;
|
|
580
|
+
additionalContext += `${sanitize(stateBlock)}\n\n`;
|
|
581
|
+
}
|
|
572
582
|
additionalContext += `## Handoff summary\n${sanitize(capHandoffSummary(handoff))}\n`;
|
|
573
583
|
if (handoff.gitStatus) additionalContext += `\n## Git working-tree at handoff\n\`\`\`\n${sanitize(handoff.gitStatus)}\n\`\`\`\n`;
|
|
574
584
|
// Sub-agent manifest: LIVE-primary, snapshot-as-fallback. The prior session may have had
|