trantor 0.18.61 → 0.18.63
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +12 -4
- package/bin/adopt.mjs +28 -13
- package/bin/advise.mjs +12 -28
- package/bin/app.mjs +43 -16
- package/bin/bridge.mjs +1 -0
- package/bin/cli.mjs +31 -0
- package/bin/connect.mjs +14 -2
- package/bin/crew/core.mjs +6 -8
- package/bin/crew/worktrees.mjs +7 -5
- package/bin/crew-payload.mjs +13 -2
- package/bin/crew-runner.mjs +124 -34
- package/bin/doctor.mjs +85 -47
- package/bin/drill-report.mjs +1 -1
- package/bin/drill-report.test.mjs +1 -0
- package/bin/new.mjs +3 -1
- package/bin/provider.mjs +18 -18
- package/bin/reconcile.mjs +22 -4
- package/bin/secrets.mjs +83 -0
- package/bin/takeover.mjs +4 -4
- package/bin/turn-watchdog.mjs +35 -23
- package/docs/BUILD-DOCTRINE.md +117 -0
- package/hooks/lib/hollow-move.mjs +52 -1
- package/hooks/sessionstart.mjs +36 -3
- package/hub/events.mjs +2 -1
- package/hub/routes/admin.mjs +47 -32
- package/hub/routes/cards.mjs +47 -44
- package/hub/store.mjs +22 -6
- package/lib/autonomy.mjs +2 -1
- package/lib/balances.mjs +3 -1
- package/lib/classify-failure.mjs +17 -8
- package/lib/decode.mjs +13 -0
- package/lib/identity.mjs +4 -2
- package/lib/launch-env.mjs +23 -0
- package/lib/model-catalog.mjs +8 -19
- package/lib/project.mjs +110 -21
- package/lib/provider-keys.mjs +5 -3
- package/lib/providers.mjs +3 -2
- package/lib/seat-record.mjs +3 -14
- package/lib/seat-why.mjs +4 -3
- package/lib/seats.mjs +15 -11
- package/lib/secrets.mjs +232 -0
- package/lib/state/driver.mjs +20 -6
- package/lib/state/flags.mjs +26 -0
- package/lib/subagent-manifest.mjs +2 -1
- package/lib/subagent-scan.mjs +0 -0
- package/lib/turn-policy.mjs +14 -0
- package/mcp.mjs +49 -16
- package/package.json +3 -2
- package/skills/crew/SKILL.md +18 -7
- package/skills/prd-review/SKILL.md +12 -3
- package/ui.html +6 -3
package/bin/crew-runner.mjs
CHANGED
|
@@ -6,6 +6,7 @@ import { readFileSync, writeFileSync, unlinkSync, existsSync, appendFileSync, mk
|
|
|
6
6
|
import { join, basename } from "node:path";
|
|
7
7
|
import { homedir } from "node:os";
|
|
8
8
|
import { resolveProject, resolveHub, withEnvFiles, hostId } from "../lib/project.mjs";
|
|
9
|
+
import { resolveSecrets, shadowEnv, withSecretExports } from "../lib/secrets.mjs";
|
|
9
10
|
import { loadOrCreate } from "../lib/identity.mjs";
|
|
10
11
|
import { signedHeaders } from "../lib/signed-fetch.mjs";
|
|
11
12
|
import { ensureEnrolled } from "../lib/enroll.mjs";
|
|
@@ -19,7 +20,7 @@ import {
|
|
|
19
20
|
import { capWake, capBcast, pickLessons, composePrompt, contractBase, baseLine } from "./crew-payload.mjs";
|
|
20
21
|
import {
|
|
21
22
|
cardRefs, wakeCard, carriesWork, parseTurnTokens, parseResetAt, reasonWithBalances, quotaResetAt, PARKING_REASONS,
|
|
22
|
-
senderProjectOf, isLinkedProject, stateSkipReason,
|
|
23
|
+
senderProjectOf, isLinkedProject, stateSkipReason, isMessageCardTitle, OPEN_CARD_STATUSES,
|
|
23
24
|
} from "../lib/turn-policy.mjs";
|
|
24
25
|
import {
|
|
25
26
|
auditDutyNudges, claimDutyNudges, claudeTranscriptDir, dutyEscalations, dutyNudgeDirective,
|
|
@@ -32,6 +33,7 @@ import {
|
|
|
32
33
|
breakerVerdict, describeTurn, hasJsonSchemaFlag, parseEnvelope, renderCardTail, runStep,
|
|
33
34
|
} from "../lib/state/driver.mjs";
|
|
34
35
|
import { costLine } from "../lib/state/cost.mjs";
|
|
36
|
+
import { resolveStateFlags } from "../lib/state/flags.mjs";
|
|
35
37
|
|
|
36
38
|
const AGENT = process.argv[2];
|
|
37
39
|
const DIR = process.argv[3] || process.cwd();
|
|
@@ -246,7 +248,7 @@ const EFFORT_FLAG = EFFORT ? cliEffortFlag(AGENT, EFFORT) : { flag: "", text: ""
|
|
|
246
248
|
|
|
247
249
|
// RUNNER_RULES / RUNNER_KICKOFF env overrides: the runner is also the substrate for non-crew
|
|
248
250
|
// always-on seats (the fleet DUTY agent, bin/duty.mjs) whose doctrine is not "work your card".
|
|
249
|
-
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
|
|
251
|
+
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. If your wake names no card id (or cites only a message-card), make relay_board with mine:true your FIRST call — it lists the cards assigned to you (doing/testing/todo, newest first, full card shape); work your newest one (#7763). 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, then STOP: you never close your own card to done — the orchestrator runs the card's drill on the built artifact and closes it, and the hub refuses a move to done on a card with no drill line anyway; 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 testing 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. A contract's \`base: <sha>\` line is the integration head: start your seat branch at that sha (\`git reset --hard <sha>\` on the fresh branch), never at origin/main, which trails the orchestrator's unpushed integration commits; the object is already here as the ref \`main\`. If \`git cat-file -e <sha>\` fails, say \`cannot resolve base <sha>\` on the bus and move the card to blocked instead of reasoning from origin/main. Every testing/done note names the sha you verified against as \`verified at <sha>\`; a note without it is flagged HOLLOW. 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.`;
|
|
250
252
|
|
|
251
253
|
// ---- the pulse --------------------------------------------------------------
|
|
252
254
|
// RUNNER_PULSE_MS re-runs an orchestrator seat's mission note on a cadence when the bus is silent;
|
|
@@ -315,6 +317,10 @@ const RETRY_MS = (() => {
|
|
|
315
317
|
// #7756: while an ask awaits its answer the contract's wake stays owed but is NOT redelivered —
|
|
316
318
|
// the answer (re = the ask's id, or any direct word from the assigner) is what releases it.
|
|
317
319
|
let awaitingAsk = null;
|
|
320
|
+
// #7756: set the moment an answer releases a held ask, consumed by the next deliverWake — the
|
|
321
|
+
// released turn RESUMES the held contract's session, so it is never "fresh" however the batch
|
|
322
|
+
// cites cards (the session already belongs to the contract the ask was holding).
|
|
323
|
+
let askReleased = false;
|
|
318
324
|
// The bus is the record of a turn that ended by ASKING: an open kind:ask contract from this
|
|
319
325
|
// session. deliverWake judges each clean wake turn against /contracts — an open ask renames the
|
|
320
326
|
// ledger row "asked" and HOLDS the wake (no failure count, no backoff, no park) until the answer.
|
|
@@ -356,7 +362,9 @@ function loadPending() {
|
|
|
356
362
|
// Auth failures in TURN OUTPUT: opencode prints its auth error and still exits 0 (#5405). The rules
|
|
357
363
|
// live in lib/classify-failure.mjs (#5868); runTurn judges only the CLI's own output, not the echo.
|
|
358
364
|
function classify(exit) {
|
|
359
|
-
|
|
365
|
+
// lastTurnCut lets a boxed turn's 137 read as the sweep's SIGKILL (#7099); without the cut
|
|
366
|
+
// marker the same exit stays a crash, exactly as classifyFailure's cut rule draws it.
|
|
367
|
+
const { reason, matched } = classifyFailure(exit, lastErrText, lastEmptyOutput, lastEmptyTurn, lastTurnCut);
|
|
360
368
|
log(`classified ${reason} because ${matched}`);
|
|
361
369
|
return reason;
|
|
362
370
|
}
|
|
@@ -465,9 +473,9 @@ async function balanceRows() {
|
|
|
465
473
|
// Same key sources the crew itself uses: QWEN_API_KEY (and most others) live in
|
|
466
474
|
// ~/.agent-bus/.env, not in the runner's inherited environment — reading bare process.env
|
|
467
475
|
// here would report "no key" and quietly leave every silent turn classified as a crash.
|
|
468
|
-
const { resolveKeys } = await import("../lib/provider-keys.mjs");
|
|
476
|
+
const { resolveKeys, keyFiles } = await import("../lib/provider-keys.mjs");
|
|
469
477
|
return await Promise.race([
|
|
470
|
-
fetchBalances(resolveKeys(process.env), { only: [AGENT] }),
|
|
478
|
+
fetchBalances(resolveKeys(process.env, keyFiles(), resolveSecrets(process.env)), { only: [AGENT] }),
|
|
471
479
|
new Promise((r) => setTimeout(() => r([]), 4000)),
|
|
472
480
|
]);
|
|
473
481
|
} catch { return []; }
|
|
@@ -536,8 +544,13 @@ async function reportHealthy() {
|
|
|
536
544
|
}
|
|
537
545
|
|
|
538
546
|
// ---- Trantor State Phase 2a — the flagged path (TDD §4.1, §4.6, §7.3) -----------------------
|
|
539
|
-
// Off by default
|
|
540
|
-
//
|
|
547
|
+
// Off by default — off means the transcript path runs unchanged. #7159: the flags resolve through
|
|
548
|
+
// the one place (lib/state/flags.mjs), launcher env first and ~/.agent-bus/.env filling the rest;
|
|
549
|
+
// resolved values are applied fill-only to process.env so downstream reads (runGate's GATE) agree.
|
|
550
|
+
const stateFlags = resolveStateFlags();
|
|
551
|
+
for (const [name, flag] of Object.entries(stateFlags)) {
|
|
552
|
+
if (flag.value && process.env[name] === undefined) process.env[name] = flag.value;
|
|
553
|
+
}
|
|
541
554
|
const STATE_FLAG_ON = process.env[STATE_ENV] === "1";
|
|
542
555
|
const STATE_SCHEMA_FILE = join(homedir(), ".agent-bus", `state-schema-${AGENT}-${PROJ}.json`);
|
|
543
556
|
const STATE_MODE = (() => {
|
|
@@ -551,7 +564,7 @@ const STATE_MODE = (() => {
|
|
|
551
564
|
} catch (e) { log(`\x1b[33mstate mode OFF — could not write ${STATE_SCHEMA_FILE}: ${e.message}\x1b[0m`); return false; }
|
|
552
565
|
// #7060: the checks above prove CONFIGURATION, not the prompt, so say "armed" and name the one
|
|
553
566
|
// thing that engages it; each turn then reports which path it took.
|
|
554
|
-
log(`\x1b[36mTrantor State: ASSEMBLE armed for this seat (schema ${STATE_SCHEMA_FILE})\x1b[0m`);
|
|
567
|
+
log(`\x1b[36mTrantor State: ASSEMBLE armed for this seat (${STATE_ENV}=${stateFlags[STATE_ENV].value} via ${stateFlags[STATE_ENV].layer}; schema ${STATE_SCHEMA_FILE})\x1b[0m`);
|
|
555
568
|
log(`\x1b[36m a turn is assembled only when a wake ASSIGNS it a card — the kickoff and every pulse run the transcript path, and each turn says which one it took\x1b[0m`);
|
|
556
569
|
return true;
|
|
557
570
|
})();
|
|
@@ -608,6 +621,14 @@ let statePromotedHash;
|
|
|
608
621
|
// TRANTOR_TURN_MAX_MS ends the CLI's process group at the box and runs ONE follow-up turn in the
|
|
609
622
|
// same session ("commit what is done, move the card, report") so a cut turn lands its work.
|
|
610
623
|
const TURN_MAX_MS = Math.max(0, Number(process.env.TRANTOR_TURN_MAX_MS || 20 * 60 * 1000));
|
|
624
|
+
// #7761: the box counts LIVENESS. At the deadline a turn that moved within the stall window is
|
|
625
|
+
// extended one step (half the box: +10m at the default) up to TRANTOR_TURN_CEILING_MS, the one
|
|
626
|
+
// knob (default 60m). A ceiling at or under the box disables extension; a silent turn still ends
|
|
627
|
+
// at the #7752 stall window, extended or not. The watchdog owns the clock — see turn-watchdog.mjs.
|
|
628
|
+
const TURN_CEILING_MS = TURN_MAX_MS ? Math.max(0, Number(process.env.TRANTOR_TURN_CEILING_MS || 60 * 60 * 1000)) : 0;
|
|
629
|
+
const TURN_EXTEND_MS = Math.ceil(TURN_MAX_MS / 2);
|
|
630
|
+
const TURN_EXTENSIONS_MAX = TURN_EXTEND_MS > 0 && TURN_CEILING_MS > TURN_MAX_MS
|
|
631
|
+
? Math.floor((TURN_CEILING_MS - TURN_MAX_MS) / TURN_EXTEND_MS) : 0;
|
|
611
632
|
const TIME_BOX_PROMPT = "your previous turn was cut at the time box; commit what is done, move the card with a note, report in one line";
|
|
612
633
|
let inFollowUp = false;
|
|
613
634
|
// #6289: whether the turn that just ended was CUT at the time box. The follow-up recursion
|
|
@@ -671,7 +692,10 @@ async function runTurn(prompt, isFirst, trigger = "kickoff", opts = {}) {
|
|
|
671
692
|
// PRECEDENCE: each file is PREPENDED, so the list is iterated in written order, highest priority
|
|
672
693
|
// first, and the CREW layer (~/.agent-bus/.env) wins. test-crew-env.mjs runs the real shell.
|
|
673
694
|
const envs = [join(homedir(), ".agent-bus", ".env"), cli.env].filter(f => f && existsSync(f));
|
|
674
|
-
|
|
695
|
+
// #6393: the store's keys ride the spawn env under shadow names and the shell re-exports them
|
|
696
|
+
// AFTER the sources ran, so the keychain wins over a stale .env line and no file is ever copied.
|
|
697
|
+
const secrets = resolveSecrets(process.env);
|
|
698
|
+
cmd = withEnvFiles(withSecretExports(cmd, Object.keys(secrets)), envs);
|
|
675
699
|
log(`turn starting (${isFirst ? "fresh session" : "resume"})${MODEL ? ` · model=${MODEL}` : ""}`);
|
|
676
700
|
// #7777: the one effort line — which effort parameters were set, or that the model is uncatalogued.
|
|
677
701
|
if (EFFORT && EFFORT_FLAG.text) log(EFFORT_FLAG.text);
|
|
@@ -705,12 +729,23 @@ async function runTurn(prompt, isFirst, trigger = "kickoff", opts = {}) {
|
|
|
705
729
|
// instead of burning the box. Unlinked at turn start and again with the cut cleanup.
|
|
706
730
|
const STALLF = join(homedir(), ".agent-bus", `turnstall-${AGENT}-${PROJ}`);
|
|
707
731
|
try { unlinkSync(STALLF); } catch {}
|
|
732
|
+
// #7761: the watchdog writes the extended deadline (epoch seconds) here for the shell box to
|
|
733
|
+
// re-read, and one row per extension to EXTF for the ledger. Both unlinked at turn start.
|
|
734
|
+
const DEADLINEF = join(homedir(), ".agent-bus", `turndeadline-${AGENT}-${PROJ}`);
|
|
735
|
+
const EXTF = join(homedir(), ".agent-bus", `turnext-${AGENT}-${PROJ}`);
|
|
736
|
+
try { unlinkSync(DEADLINEF); } catch {}
|
|
737
|
+
try { unlinkSync(EXTF); } catch {}
|
|
738
|
+
const boxPlan = TURN_EXTENSIONS_MAX ? {
|
|
739
|
+
maxMs: TURN_MAX_MS, extendMs: TURN_EXTEND_MS, ceilingMs: TURN_CEILING_MS, extensionsMax: TURN_EXTENSIONS_MAX,
|
|
740
|
+
deadlineFile: DEADLINEF, extFile: EXTF, card: sessionCard || 0,
|
|
741
|
+
assigners: (opts.assigners || []).map(a => ({ from: a.from, id: a.id })),
|
|
742
|
+
} : undefined;
|
|
708
743
|
// Touched by the stderr scrubber as its LAST act (the shell below); node waits for it after
|
|
709
744
|
// spawnSync before reading ERRF — see the drain note at the spawnSync call.
|
|
710
745
|
const DRAINF = join(homedir(), ".agent-bus", `turndrain-${AGENT}-${PROJ}`);
|
|
711
746
|
try { unlinkSync(DRAINF); } catch {}
|
|
712
747
|
try {
|
|
713
|
-
writeFileSync(STAMPF, JSON.stringify({ turn: TURN, startedAt: Date.now(), runner: RUNNER_ID }));
|
|
748
|
+
writeFileSync(STAMPF, JSON.stringify({ turn: TURN, startedAt: Date.now(), runner: RUNNER_ID, box: boxPlan }));
|
|
714
749
|
const wd = spawn(process.execPath, [join(import.meta.dirname, "turn-watchdog.mjs"), STAMPF, ERRF, String(WD_MS), SESSION, PROJ, HUB, TRANSCRIPT_DIR, TURN_DIR,
|
|
715
750
|
// #7752: the stall marker only exists when a box does — with no box there is no sweep to
|
|
716
751
|
// end the turn, so the watchdog stays report-only (reporting is the whole job, boxless).
|
|
@@ -732,6 +767,7 @@ ${sweep}
|
|
|
732
767
|
deadline=$(( $(date +%s) + ${Math.ceil(TURN_MAX_MS / 1000)} ))
|
|
733
768
|
while :; do
|
|
734
769
|
[ -f "${STALLF}" ] && break
|
|
770
|
+
d=$(cat "${DEADLINEF}" 2>/dev/null); case "$d" in ""|*[!0-9]*) ;; *) deadline=$d;; esac
|
|
735
771
|
[ "$(date +%s)" -ge "$deadline" ] && break
|
|
736
772
|
sleep 1 & sleeppid=$!
|
|
737
773
|
wait "$sleeppid"
|
|
@@ -752,7 +788,7 @@ exit $turn_exit`;
|
|
|
752
788
|
// spawned. stdin is /dev/null: a background group that reads the terminal stops on SIGTTIN.
|
|
753
789
|
detached: true,
|
|
754
790
|
cwd: TURN_DIR, encoding: "utf8", stdio: cli.sid ? ["ignore", "pipe", "inherit"] : ["ignore", "inherit", "inherit"],
|
|
755
|
-
env: { ...process.env, RELAY_URL: HUB, RELAY_AGENT: AGENT, RELAY_SESSION: SESSION, RELAY_PROJECT: PROJ,
|
|
791
|
+
env: { ...process.env, ...shadowEnv(secrets), RELAY_URL: HUB, RELAY_AGENT: AGENT, RELAY_SESSION: SESSION, RELAY_PROJECT: PROJ,
|
|
756
792
|
// #6228: marks this env as belonging to PROJ, unlike a one-off RELAY_PROJECT override; crew.mjs's
|
|
757
793
|
// `up` guard refuses to bring up another project's crew from a shell carrying this badge.
|
|
758
794
|
TRANTOR_SEAT: PROJ,
|
|
@@ -767,7 +803,7 @@ exit $turn_exit`;
|
|
|
767
803
|
// A BACKSTOP only, deliberately later than the shell's own box: if bash itself wedges, node
|
|
768
804
|
// still ends the turn. When the in-shell box works — the normal path — this never fires, which
|
|
769
805
|
// is the point: the shell kills while the tree is still walkable, node cannot.
|
|
770
|
-
if (TURN_MAX_MS) { spawnOpts.timeout = TURN_MAX_MS + 30000; spawnOpts.killSignal = "SIGKILL"; }
|
|
806
|
+
if (TURN_MAX_MS) { spawnOpts.timeout = (TURN_EXTENSIONS_MAX ? TURN_CEILING_MS : TURN_MAX_MS) + 30000; spawnOpts.killSignal = "SIGKILL"; }
|
|
771
807
|
const r = spawnSync("/bin/bash", ["-c", shell], spawnOpts);
|
|
772
808
|
// The shell's box leaves the marker; the backstop leaves an ETIMEDOUT. Either way the turn was
|
|
773
809
|
// cut, not merely failed. A stall marker (#7752) outranks the box marker: a turn silent for
|
|
@@ -775,6 +811,13 @@ exit $turn_exit`;
|
|
|
775
811
|
const boxed = existsSync(CUTF);
|
|
776
812
|
const stallCut = existsSync(STALLF);
|
|
777
813
|
const cut = !!TURN_MAX_MS && (boxed || stallCut || r.error?.code === "ETIMEDOUT");
|
|
814
|
+
// #7761: how far the watchdog moved the box, one EXTF row per extension. boxMs is the box the
|
|
815
|
+
// turn actually had, so a ceiling cut reads as the ceiling, never as the 20-minute default.
|
|
816
|
+
let extensions = 0;
|
|
817
|
+
try { extensions = readFileSync(EXTF, "utf8").split("\n").filter(Boolean).length; } catch {}
|
|
818
|
+
const boxMs = TURN_MAX_MS + extensions * TURN_EXTEND_MS;
|
|
819
|
+
try { unlinkSync(DEADLINEF); } catch {}
|
|
820
|
+
try { unlinkSync(EXTF); } catch {}
|
|
778
821
|
// DRAIN before classifying, never on a CUT turn (the sweep killed the scrubber, its marker never
|
|
779
822
|
// comes). bash 3.2 `wait` skips process substitutions, so wait for DRAINF, bounded.
|
|
780
823
|
if (!cut) {
|
|
@@ -790,6 +833,7 @@ exit $turn_exit`;
|
|
|
790
833
|
try { unlinkSync(CUTF); } catch {}
|
|
791
834
|
try { unlinkSync(STALLF); } catch {}
|
|
792
835
|
if (stallCut) log(`\x1b[33mturn STALLED — no bytes on either stream and no transcript advance for ${Math.round(WD_MS / 60000)}m; ended at the stall window, not the ${Math.round(TURN_MAX_MS / 1000)}s box\x1b[0m`);
|
|
836
|
+
else if (extensions) log(`\x1b[33mturn cut at the ${Math.round(boxMs / 1000)}s box after ${extensions} liveness extension(s) (ceiling ${Math.round(TURN_CEILING_MS / 1000)}s) — CLI and every descendant ended${boxed ? "" : " (node backstop: bash itself was wedged)"}\x1b[0m`);
|
|
793
837
|
else log(`\x1b[33mturn cut at the ${Math.round(TURN_MAX_MS / 1000)}s time box — CLI and every descendant ended${boxed ? "" : " (node backstop: bash itself was wedged)"}\x1b[0m`);
|
|
794
838
|
}
|
|
795
839
|
lastTurnCut = cut;
|
|
@@ -848,8 +892,8 @@ exit $turn_exit`;
|
|
|
848
892
|
// #5868: the verdict rides the telemetry row so a classification survives the pane scrolling
|
|
849
893
|
// away — the same "classified X because Y" shape the runner logs, in the seat's jsonl forever.
|
|
850
894
|
// A silent cut names its own verdict (#7752): the exit under it is the sweep's SIGPIPE, never
|
|
851
|
-
// a provider failure.
|
|
852
|
-
const verdict = stallCut ? stallVerdict() : verdictFor(realExit, effExit, lastEmptyOutput, ownOut, lastEmptyTurn);
|
|
895
|
+
// a provider failure. A box cut passes `cut` so its 137 reads as the sweep's SIGKILL (#7099).
|
|
896
|
+
const verdict = stallCut ? stallVerdict() : verdictFor(realExit, effExit, lastEmptyOutput, ownOut, lastEmptyTurn, cut);
|
|
853
897
|
// #6134: what the turn COST, from the CLI's own usage line. Zero means this CLI printed none —
|
|
854
898
|
// never that the turn was free. `trantor seat-why` totals these into today's spend per seat.
|
|
855
899
|
let tokens = parseTurnTokens(ownOut);
|
|
@@ -859,12 +903,10 @@ exit $turn_exit`;
|
|
|
859
903
|
// actually carried counts.
|
|
860
904
|
if (c) tokens = c.input + c.output + c.cache_read + c.cache_creation;
|
|
861
905
|
}
|
|
862
|
-
// #6289: every ledger row names in ONE field what happened to the turn — cut (
|
|
863
|
-
//
|
|
864
|
-
//
|
|
865
|
-
//
|
|
866
|
-
// to the card the turn was working (0 = kickoff/pulse, no card) so the seat record can
|
|
867
|
-
// attribute empty/stalled turns to the card that produced nothing.
|
|
906
|
+
// #6289: every ledger row names in ONE field what happened to the turn — cut, stalled (#7752),
|
|
907
|
+
// api-error, completed — and what it cost (0 means "not reported", never "free"). #7762: `card`
|
|
908
|
+
// binds the row to the card the turn worked (0 = kickoff/pulse) so the seat record attributes
|
|
909
|
+
// empty/stalled turns to the card that produced nothing. `cut` stays too: the drills read it.
|
|
868
910
|
const outcome = cut ? (stallCut ? "stalled" : "cut") : (effExit !== 0 ? "api-error" : lastEmptyTurn ? "empty" : "completed");
|
|
869
911
|
// #7756: a clean turn that ASKED its assigner is demoted-but-owed, not "completed". The judge
|
|
870
912
|
// (deliverWake's /contracts read) renames the ledger row, so "asked" is what the log keeps.
|
|
@@ -875,8 +917,9 @@ exit $turn_exit`;
|
|
|
875
917
|
const telemetryRow = { ts: Date.now(), agent: AGENT, project: PROJ, turn: TURN, trigger, card: sessionCard || 0, model: MODEL || "cli-default", duration_ms: Date.now() - t0, exit: realExit, effExit, authFailed: effExit !== realExit, emptyOutput: lastEmptyOutput, emptyTurn: lastEmptyTurn, verdict, outcome: finalOutcome, tokens };
|
|
876
918
|
if (cut) telemetryRow.cut = true;
|
|
877
919
|
if (stallCut) telemetryRow.stalled = true;
|
|
878
|
-
|
|
879
|
-
// a
|
|
920
|
+
if (extensions) { telemetryRow.extensions = extensions; telemetryRow.boxMs = boxMs; }
|
|
921
|
+
// #7752/#7099: a cut turn's 141/137 is the sweep's own signal (SIGPIPE/SIGKILL), recorded as
|
|
922
|
+
// the cut signal — never read as a quota or crash pattern downstream.
|
|
880
923
|
if (cut) { const sig = cutSignalFor(realExit); if (sig) telemetryRow.cutSignal = sig; }
|
|
881
924
|
telemetry(telemetryRow);
|
|
882
925
|
log(`turn ended (exit ${realExit}${effExit !== realExit ? ` → effective ${effExit} (${lastEmptyOutput ? "empty-output" : "auth"})` : ""}, ${((Date.now() - t0) / 1000).toFixed(0)}s)`);
|
|
@@ -912,7 +955,7 @@ async function stateTurn({ card, observation, trigger, assigners = [] }) {
|
|
|
912
955
|
callCli: async (prompt) => {
|
|
913
956
|
// isFirst=true every step ON PURPOSE: a state step carries no `-c`, so there is no session
|
|
914
957
|
// to resume and a stale sid must never be handed to one.
|
|
915
|
-
const exit = await runTurn(prompt, true, trigger, { state: true });
|
|
958
|
+
const exit = await runTurn(prompt, true, trigger, { state: true, assigners });
|
|
916
959
|
return { exit, stdout: lastEnvelope, cut: lastTurnCut };
|
|
917
960
|
},
|
|
918
961
|
executeAction: async (action) => {
|
|
@@ -1066,6 +1109,32 @@ function askedExcerpt(message) {
|
|
|
1066
1109
|
return text.slice(0, 120);
|
|
1067
1110
|
}
|
|
1068
1111
|
|
|
1112
|
+
// #7763/#7765: the card a wake binds to, with message-cards out of the race — a transcript of a
|
|
1113
|
+
// bus message parked on the board is never work, and chasing one (or a phantom id) is how a turn
|
|
1114
|
+
// burns on bookkeeping. Only a CONFIRMED message-card title reroutes, to the seat's OWN newest
|
|
1115
|
+
// open card (same statuses/ordering as relay_board's mine view); a phantom binds nothing.
|
|
1116
|
+
async function newestOwnCard(session, why) {
|
|
1117
|
+
try {
|
|
1118
|
+
const { tasks } = await api(`/tasks?project=${encodeURIComponent(PROJ)}`);
|
|
1119
|
+
const own = (Array.isArray(tasks) ? tasks : [])
|
|
1120
|
+
.filter((t) => t.assignee === session && OPEN_CARD_STATUSES.includes(t.status) && !isMessageCardTitle(t.title))
|
|
1121
|
+
.sort((a, b) => (b.updated || b.ts || 0) - (a.updated || a.ts || 0));
|
|
1122
|
+
if (own.length) log(`${why} — resolved to the seat's own #${own[0].id} (${own[0].status})`);
|
|
1123
|
+
else log(`${why} — and the seat has no open card, so this turn carries no card binding`);
|
|
1124
|
+
return own.length ? own[0].id : 0;
|
|
1125
|
+
} catch { return 0; } // board unreadable: no binding beats a guessed one
|
|
1126
|
+
}
|
|
1127
|
+
async function resolveWakeCard(messages, { session }) {
|
|
1128
|
+
const cited = wakeCard(messages, { session });
|
|
1129
|
+
if (!(cited > 0)) return 0;
|
|
1130
|
+
try {
|
|
1131
|
+
const { task } = await api(`/card?project=${encodeURIComponent(PROJ)}&id=${cited}`);
|
|
1132
|
+
if (!task) { log(`#${cited}: the wake cites a card this board does not have — no card binding; the mine view is the first call`); return 0; }
|
|
1133
|
+
if (isMessageCardTitle(task.title)) return newestOwnCard(session, `#${cited} is a message-card ("${String(task.title).slice(0, 60)}")`);
|
|
1134
|
+
return cited;
|
|
1135
|
+
} catch { return cited; } // hub unreachable: keep the pre-#7763 reading rather than drop a contract
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1069
1138
|
(async () => {
|
|
1070
1139
|
await loadLessons();
|
|
1071
1140
|
// start cursor at the CURRENT tip so we don't replay history
|
|
@@ -1248,6 +1317,7 @@ function askedExcerpt(message) {
|
|
|
1248
1317
|
if (awaitingAsk && wake.some(m => m.to === SESSION && (Number(m.re) === awaitingAsk.id || m.from === awaitingAsk.to))) {
|
|
1249
1318
|
log(`answer to ask #${awaitingAsk.id} landed — resuming with the contract re-attached`);
|
|
1250
1319
|
awaitingAsk = null;
|
|
1320
|
+
askReleased = true;
|
|
1251
1321
|
savePending(pendingWake, pendingBcast);
|
|
1252
1322
|
}
|
|
1253
1323
|
if (awaitingAsk) { log(`holding for the answer to ask #${awaitingAsk.id} — ${wake.length} new message(s) queued behind it`); continue; }
|
|
@@ -1279,6 +1349,10 @@ function askedExcerpt(message) {
|
|
|
1279
1349
|
} catch { return 0; }
|
|
1280
1350
|
}
|
|
1281
1351
|
async function deliverWake() {
|
|
1352
|
+
// #7756: captured and cleared HERE so an early return below never leaks the flag into a
|
|
1353
|
+
// later turn — only the turn the answer released reads it.
|
|
1354
|
+
const resumedAfterAsk = askReleased;
|
|
1355
|
+
askReleased = false;
|
|
1282
1356
|
const wake = pendingWake;
|
|
1283
1357
|
const dutyPlan = DUTY_NUDGES
|
|
1284
1358
|
? await claimDutyNudges({
|
|
@@ -1328,22 +1402,33 @@ function askedExcerpt(message) {
|
|
|
1328
1402
|
await loadLessons();
|
|
1329
1403
|
const lessons = pickLessons(LESSONS_RAW, wakeCapped.text + " " + bcastCapped.text);
|
|
1330
1404
|
const trigger = wakeForTurn.some(m => m.to === SESSION) ? "direct message" : "@mention";
|
|
1331
|
-
// Who is owed an answer, captured BEFORE the turn: pendingWake is cleared on success.
|
|
1405
|
+
// Who is owed an answer, captured BEFORE the turn: pendingWake is cleared on success. The id is
|
|
1406
|
+
// the NEWEST wake this runner holds for that assigner (#6987): append order means first-wins
|
|
1407
|
+
// threaded the receipt onto a PREVIOUS turn's id — the original contract, not the answer that
|
|
1408
|
+
// released this turn — which kept the contract reading WAITING while the stop hook chased.
|
|
1332
1409
|
const assigners = [];
|
|
1333
|
-
for (const m of wakeForTurn)
|
|
1410
|
+
for (const m of wakeForTurn) {
|
|
1411
|
+
if (!m.from) continue;
|
|
1412
|
+
const held = assigners.find(a => a.from === m.from);
|
|
1413
|
+
if (held) held.id = m.id;
|
|
1414
|
+
else assigners.push({ from: m.from, id: m.id });
|
|
1415
|
+
}
|
|
1334
1416
|
const asked = askedExcerpt(wakeForTurn[0]);
|
|
1335
1417
|
const tStart = Date.now();
|
|
1336
1418
|
// #6134: ONE SESSION PER CARD; a different card starts a fresh CLI session and the seat is told.
|
|
1337
1419
|
// #7061: bound by SHAPE, not position, so an order opening with what shipped binds the right card.
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
//
|
|
1341
|
-
//
|
|
1420
|
+
// #7763: a message-card (or phantom) citation never binds — the seat's own newest card does.
|
|
1421
|
+
const card = await resolveWakeCard(wakeForTurn, { session: SESSION });
|
|
1422
|
+
// #7754: a `base:` sha this worktree cannot resolve is never worked from origin/main — the card
|
|
1423
|
+
// goes to blocked, the assigner is told, and no model turn is spent. The check keeps the id the
|
|
1424
|
+
// wake cites in its text: #7763's rebinding covers confirmed message-card ids only, never a
|
|
1425
|
+
// card cited in the text, so the unresolvable-base branch blocks the cited card.
|
|
1426
|
+
const baseCard = card || wakeCard(wakeForTurn, { session: SESSION });
|
|
1342
1427
|
const base = contractBase(wakeForTurn) || localMainHead();
|
|
1343
|
-
if (base &&
|
|
1344
|
-
const text = `cannot resolve base ${base} in ${TURN_DIR} (git cat-file -e failed) — #${
|
|
1428
|
+
if (base && baseCard && !resolvesHere(base)) {
|
|
1429
|
+
const text = `cannot resolve base ${base} in ${TURN_DIR} (git cat-file -e failed) — #${baseCard} moved to blocked; push or fetch that sha, or name one this worktree can reach`;
|
|
1345
1430
|
log(text);
|
|
1346
|
-
await api("/task/update", { id:
|
|
1431
|
+
await api("/task/update", { id: baseCard, status: "blocked", note: text, by: SESSION }).catch(() => {});
|
|
1347
1432
|
const owed = assigners.map(a => a.from).filter(f => f && f !== SESSION);
|
|
1348
1433
|
for (const to of (owed.length ? owed : ["all"]))
|
|
1349
1434
|
await api("/send", { from: SESSION, to, text, project: PROJ, kind: "alert" }).catch(() => {});
|
|
@@ -1352,7 +1437,12 @@ function askedExcerpt(message) {
|
|
|
1352
1437
|
return;
|
|
1353
1438
|
}
|
|
1354
1439
|
const baseText = base ? `\n${baseLine(base)}\n` : "";
|
|
1355
|
-
|
|
1440
|
+
// #6289: the card the SESSION belongs to reads the citation when the rebinding binds nothing —
|
|
1441
|
+
// #7763 decides WHICH card work binds to, never the turn shape. #7756: the turn an ask's
|
|
1442
|
+
// ANSWER releases is the exception — it resumes the session the held contract already owns,
|
|
1443
|
+
// whatever card the batch cites; only a new contract for a different card opens fresh.
|
|
1444
|
+
const sessionCardForTurn = card || baseCard;
|
|
1445
|
+
const fresh = !resumedAfterAsk && sessionCardForTurn > 0 && sessionCardForTurn !== sessionCard;
|
|
1356
1446
|
if (card) sessionCard = card;
|
|
1357
1447
|
const cited = [...new Set(wakeForTurn.flatMap(m => cardRefs(m.text)))];
|
|
1358
1448
|
const freshText = fresh
|
|
@@ -1381,7 +1471,7 @@ function askedExcerpt(message) {
|
|
|
1381
1471
|
// dies quietly (§4.6).
|
|
1382
1472
|
observation: [stateObservation, wakeText, ctxText, againText + freshText + baseText].filter(Boolean).join("\n"),
|
|
1383
1473
|
})
|
|
1384
|
-
: await runTurn(prompt, fresh, deliveryFails ? `${trigger} (redelivery)` : trigger, { judgeOutcome: askJudge });
|
|
1474
|
+
: await runTurn(prompt, fresh, deliveryFails ? `${trigger} (redelivery)` : trigger, { judgeOutcome: askJudge, assigners });
|
|
1385
1475
|
}
|
|
1386
1476
|
finally { stopDutyNudgeWatcher(); }
|
|
1387
1477
|
const secs = Math.round((Date.now() - tStart) / 1000);
|
package/bin/doctor.mjs
CHANGED
|
@@ -4,14 +4,16 @@
|
|
|
4
4
|
// quota profile, optional Scrooge brain. Prints a checklist with copy-paste fixes.
|
|
5
5
|
// node bin/doctor.mjs
|
|
6
6
|
import { readFileSync, readdirSync, existsSync } from "node:fs";
|
|
7
|
-
import { join, dirname } from "node:path";
|
|
7
|
+
import { join, dirname, basename } from "node:path";
|
|
8
8
|
import { homedir } from "node:os";
|
|
9
9
|
import { execSync } from "node:child_process";
|
|
10
10
|
import { fileURLToPath } from "node:url";
|
|
11
|
-
import { resolveProject, resolveHub, DEFAULT_HUB_URL } from "../lib/project.mjs";
|
|
11
|
+
import { resolveProject, resolveProjectInfo, resolveHub, DEFAULT_HUB_URL, gitRoot, readProjectId, checkoutFor, orchSessionsPath, PROJECT_MARKER } from "../lib/project.mjs";
|
|
12
12
|
import { loadOrCreate } from "../lib/identity.mjs";
|
|
13
13
|
import { sfetchJson } from "../lib/signed-fetch.mjs";
|
|
14
14
|
import { scan } from "../lib/splitbrain.mjs";
|
|
15
|
+
import { resolveSecrets, envFileSecrets, backendFor } from "../lib/secrets.mjs";
|
|
16
|
+
import { resolveStateFlags } from "../lib/state/flags.mjs";
|
|
15
17
|
|
|
16
18
|
const H = homedir();
|
|
17
19
|
const ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
@@ -64,6 +66,36 @@ if (pkg?.version) {
|
|
|
64
66
|
tooOld ? warn(`trantor v${pkg.version} too old — heartbeat/presence requires v0.17.0+`, "npm update -g trantor") : ok(`trantor v${pkg.version}`);
|
|
65
67
|
} else warn("could not read trantor version", "reinstall: npm install -g trantor");
|
|
66
68
|
|
|
69
|
+
// ── project identity (#6724): a directory rename left every record under the old name and minted
|
|
70
|
+
// an empty twin. The id in .trantor/project.json survives a rename; this section says whether THIS
|
|
71
|
+
// checkout has one and names every orchestrated project whose checkout is gone, with the fix.
|
|
72
|
+
section("project identity");
|
|
73
|
+
{
|
|
74
|
+
const root = gitRoot(process.cwd()) || process.cwd();
|
|
75
|
+
const label = basename(root);
|
|
76
|
+
const { via } = resolveProjectInfo(process.cwd());
|
|
77
|
+
const marked = readProjectId(root);
|
|
78
|
+
if (marked) ok(`${marked} recorded in ${PROJECT_MARKER}${marked !== label ? ` — directory "${label}" is a label; the identity survived a rename` : ""}`);
|
|
79
|
+
else if (via === "env" || via === "worktree") note(`${PROJECT} named by ${via === "env" ? "RELAY_PROJECT" : "the seat worktree path"}; the checkout itself is unmarked`);
|
|
80
|
+
else note(`${PROJECT} is named by its directory; a rename would orphan its pin, board and orchestrator row → trantor project ${PROJECT} (records ${PROJECT_MARKER}; commit it)`);
|
|
81
|
+
// An orphan is a name with an orchestrator row (a session ran HERE) and no checkout. A pin alone
|
|
82
|
+
// is not evidence: projects that live on another machine are pinned here too, so pins get a note.
|
|
83
|
+
const orchRows = (() => { try { return [...new Set(readFileSync(orchSessionsPath(), "utf8").split("\n").map(l => l.split("\t")[0]).filter(Boolean))]; } catch { return []; } })();
|
|
84
|
+
const pinned = Object.keys(cfg.hubs || {});
|
|
85
|
+
const claim = (name) => marked
|
|
86
|
+
? `if that project was renamed, claim it inside its checkout: cd <its dir> && trantor project ${name}`
|
|
87
|
+
: `if "${label}" is that project renamed: trantor project ${name} (the pin, board and orchestrator row then follow the id)`;
|
|
88
|
+
let orphans = 0;
|
|
89
|
+
for (const name of orchRows) {
|
|
90
|
+
if (checkoutFor(name)) continue;
|
|
91
|
+
orphans++;
|
|
92
|
+
warn(`orphaned identity: ${name} has an orchestrator session row${pinned.includes(name) ? " and a hub pin" : ""} but no checkout on this machine carries it`, claim(name));
|
|
93
|
+
}
|
|
94
|
+
const pinnedOnly = pinned.filter(n => !orchRows.includes(n) && !checkoutFor(n));
|
|
95
|
+
if (pinnedOnly.length) note(`${pinnedOnly.length} pinned project(s) with no checkout here (${pinnedOnly.slice(0, 8).join(", ")}${pinnedOnly.length > 8 ? ", …" : ""}) — fine when they live on another machine`);
|
|
96
|
+
if (!orphans) ok("every orchestrated project still has its checkout");
|
|
97
|
+
}
|
|
98
|
+
|
|
67
99
|
// ── hub routing: is any project split across two hubs? ───────────────────────────────────────
|
|
68
100
|
// Cards, messages and collision detection only work over ONE hub. A project split across two
|
|
69
101
|
// breaks silently — every seat reports healthy and half the work records where nobody looks.
|
|
@@ -89,10 +121,9 @@ section("hub routing");
|
|
|
89
121
|
}
|
|
90
122
|
|
|
91
123
|
// ── duty seat: is the fleet's watcher actually alive? ────────────────────────────────────────
|
|
92
|
-
//
|
|
93
|
-
//
|
|
94
|
-
//
|
|
95
|
-
// beat, each with its fix.
|
|
124
|
+
// A dead watcher raises no error of its own, it just stops producing nudges (it once sat dead for
|
|
125
|
+
// four days while everything else reported green), so this row makes that state loud: process,
|
|
126
|
+
// keepalive, hub registration and the freshness of the seat's last hub beat, each with its fix.
|
|
96
127
|
section("duty seat (the fleet watcher)");
|
|
97
128
|
{
|
|
98
129
|
const BUSD = join(H, ".agent-bus");
|
|
@@ -163,7 +194,7 @@ const CLIS = [
|
|
|
163
194
|
auth: () => !!process.env.ANTHROPIC_API_KEY || existsSync(join(H, ".claude", ".credentials.json")) || keychainHas("Claude Code-credentials"),
|
|
164
195
|
login: "claude (sign in with your Anthropic account on first run)" },
|
|
165
196
|
{ name: "codex", bin: "codex", wired: () => (readFileSync(join(H, ".codex", "config.toml"), "utf8")).includes("[mcp_servers.relay]"), auth: () => existsSync(join(H, ".codex", "auth.json")), login: "codex (sign in with your ChatGPT account on first run)" },
|
|
166
|
-
// Gemini CLI was retired
|
|
197
|
+
// Gemini CLI was retired for free/Pro/Ultra (Google → Antigravity `agy`). Kept as an
|
|
167
198
|
// optional seat for enterprise/paid-key holders; for everyone else the seat moved to GLM/opencode,
|
|
168
199
|
// and Gemini lives on only as a Scrooge cheap-model via GEMINI_API_KEY (the API/models aren't retired).
|
|
169
200
|
{ name: "gemini (CLI retired 2026-06-18)", bin: "gemini", wired: () => !!read(join(H, ".gemini", "settings.json"))?.mcpServers?.relay, auth: () => existsSync(join(H, ".gemini", "oauth_creds.json")) || !!process.env.GEMINI_API_KEY || !!process.env.GOOGLE_API_KEY, login: "Gemini CLI retired 2026-06-18 (free/Pro/Ultra). Crew seat → GLM (opencode) or Antigravity `agy`. Gemini still serves as a Scrooge cheap-model via GEMINI_API_KEY." },
|
|
@@ -212,28 +243,15 @@ section("providers");
|
|
|
212
243
|
}
|
|
213
244
|
|
|
214
245
|
// ---- key attribution: WHICH key does each surface actually spend on? ------------------------
|
|
215
|
-
//
|
|
216
|
-
//
|
|
217
|
-
//
|
|
218
|
-
// runner sources that file). Scrooge turned out to be 0.15% of the tokens on that key and the
|
|
219
|
-
// crew was the other 99.85%, but the bill could not say so — one key, two jobs, one line item.
|
|
220
|
-
//
|
|
221
|
-
// The layers, highest priority first — this MIRRORS bin/crew-runner.mjs, which sources
|
|
222
|
-
// ~/.agent-bus/.env last so it wins:
|
|
223
|
-
// 1. the process environment
|
|
224
|
-
// 2. ~/.agent-bus/.env — the CREW layer (seats: opencode/deepseek/openrouter/dsh)
|
|
225
|
-
// 3. ~/.token-scrooge/.env — the SCROOGE layer (cheap-model grunt routing)
|
|
246
|
+
// A layered lookup with nothing showing which layer won once let Scrooge's key authenticate every
|
|
247
|
+
// crew seat (a $14 day the bill could not attribute). Precedence, highest first, mirroring the
|
|
248
|
+
// runner: the secret store (keychain, #6393), ~/.agent-bus/.env, ~/.token-scrooge/.env, process env.
|
|
226
249
|
section("provider keys (who spends on what)");
|
|
227
250
|
const KEY_VARS = ["DEEPSEEK_API_KEY", "OPENROUTER_API_KEY", "MOONSHOT_API_KEY", "ZAI_API_KEY", "OPENAI_API_KEY", "GEMINI_API_KEY", "XAI_API_KEY", "INCEPTION_API_KEY"];
|
|
228
|
-
// Only some of these are a CREW credential
|
|
229
|
-
//
|
|
230
|
-
//
|
|
231
|
-
//
|
|
232
|
-
// kimi → ~/.kimi/credentials codex → its own login gemini → retired
|
|
233
|
-
// The opencode-driven seats that DO take their key from the env layer are deepseek and openrouter,
|
|
234
|
-
// and even then only when opencode.json has not already given that provider its own key.
|
|
235
|
-
// Getting this wrong cries wolf: the first version flagged all seven and five were false positives,
|
|
236
|
-
// which is how a doctor becomes something you skip.
|
|
251
|
+
// Only some of these are a CREW credential: glm reads opencode.json, kimi its own credentials,
|
|
252
|
+
// codex its own login, so a shared value in their env var costs nothing and flagging it is noise.
|
|
253
|
+
// deepseek and openrouter DO take the env layer, and only when opencode.json gave them no literal
|
|
254
|
+
// key. The first version flagged all seven, five were false positives; a doctor gets skipped that way.
|
|
237
255
|
const OPENCODE_CFG = read(join(H, ".config", "opencode", "opencode.json")) || {};
|
|
238
256
|
const SEAT_ENV_VARS = { DEEPSEEK_API_KEY: "deepseek", OPENROUTER_API_KEY: "openrouter" };
|
|
239
257
|
const ENV_TEMPLATE = /\{env:([A-Z0-9_]+)\}/;
|
|
@@ -269,14 +287,15 @@ const readEnvFile = (f) => {
|
|
|
269
287
|
// Never print a key. The suffix is enough to match a line item in a provider console.
|
|
270
288
|
const mask = (v) => (!v ? "" : v.length <= 12 ? "****" : `${v.slice(0, 5)}…${v.slice(-4)}`);
|
|
271
289
|
const crewEnv = readEnvFile(CREW_ENV), scroogeEnv = readEnvFile(SCROOGE_ENV);
|
|
290
|
+
const storeEnv = resolveSecrets();
|
|
272
291
|
let anyKey = false, shared = 0; const sharedVars = [];
|
|
273
|
-
for (const v of KEY_VARS) {
|
|
274
|
-
|
|
275
|
-
const
|
|
276
|
-
|
|
292
|
+
for (const v of [...new Set([...KEY_VARS, ...Object.keys(storeEnv)])]) {
|
|
293
|
+
// The layer that answers is named on the line, in the runner's precedence.
|
|
294
|
+
const answered = storeEnv[v] ? ["keychain", storeEnv[v]] : crewEnv[v] ? ["~/.agent-bus/.env (crew)", crewEnv[v]]
|
|
295
|
+
: scroogeEnv[v] ? ["~/.token-scrooge/.env (FALLBACK)", scroogeEnv[v]] : process.env[v] ? ["process env", process.env[v]] : null;
|
|
296
|
+
if (!answered) continue;
|
|
277
297
|
anyKey = true;
|
|
278
|
-
const crewSrc
|
|
279
|
-
const crewKey = crew || scrooge;
|
|
298
|
+
const [crewSrc, crewKey] = answered;
|
|
280
299
|
const scroogeFileKey = scroogeEnv[v] || "";
|
|
281
300
|
if (!crewUsesVar(v)) {
|
|
282
301
|
ok(`${v}: Scrooge only ${mask(scroogeFileKey || crewKey)} — no crew seat reads this var`);
|
|
@@ -287,11 +306,34 @@ for (const v of KEY_VARS) {
|
|
|
287
306
|
ok(`${v}: crew ${mask(crewKey)} via ${crewSrc}${scroogeFileKey && scroogeFileKey !== crewKey ? ` · scrooge ${mask(scroogeFileKey)} via ~/.token-scrooge/.env` : ""}`);
|
|
288
307
|
}
|
|
289
308
|
}
|
|
290
|
-
if (!anyKey) note("no provider API keys found in env, ~/.agent-bus/.env or ~/.token-scrooge/.env");
|
|
309
|
+
if (!anyKey) note("no provider API keys found in the keychain, env, ~/.agent-bus/.env or ~/.token-scrooge/.env");
|
|
291
310
|
else if (!shared) ok("crew and Scrooge spend on separate keys — each shows up as its own line item");
|
|
292
311
|
else {
|
|
293
312
|
warn(`${shared} provider key(s) do double duty (${sharedVars.join(", ")}) — a spike on the bill cannot be attributed to the crew or to Scrooge`,
|
|
294
|
-
`mint a second key per provider and give the CREW its own, e.g.:
|
|
313
|
+
`mint a second key per provider and give the CREW its own, e.g.: printf '%s' '<new-crew-key>' | trantor secrets set DEEPSEEK_API_KEY (Scrooge keeps ~/.token-scrooge/.env; the store wins over every file)`);
|
|
314
|
+
}
|
|
315
|
+
// Keys still living in the file (#6393): a mode-600 file is still a file any process of yours can
|
|
316
|
+
// read; the store is the keychain. Named by key so the fix is one command.
|
|
317
|
+
const straggling = envFileSecrets();
|
|
318
|
+
const storeNames = Object.keys(storeEnv);
|
|
319
|
+
if (straggling.live.length && backendFor(process.env, { create: true }) !== "none") {
|
|
320
|
+
warn(`${straggling.live.length} key(s) still live in ~/.agent-bus/.env (${straggling.live.join(", ")})`,
|
|
321
|
+
"trantor secrets migrate (each moves to the keychain; the line becomes a `# NAME -> keychain` stub)");
|
|
322
|
+
} else if (straggling.live.length) {
|
|
323
|
+
note(`${straggling.live.length} key(s) live in ~/.agent-bus/.env (${straggling.live.join(", ")}) — no keychain on this platform; TRANTOR_SECRETS_BACKEND=file opts into a file store`);
|
|
324
|
+
} else if (storeNames.length) {
|
|
325
|
+
ok(`secret store: ${storeNames.length} key(s) in the ${backendFor()} (${storeNames.join(", ")}), none left in .env`);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// ── Trantor State flags (#7159): the runners resolve these through ONE place — process env first,
|
|
329
|
+
// ~/.agent-bus/.env second — and each line names the layer that answered, so a flag that LOOKED
|
|
330
|
+
// applied can no longer pass for armed.
|
|
331
|
+
section("trantor state flags");
|
|
332
|
+
{
|
|
333
|
+
const resolved = resolveStateFlags();
|
|
334
|
+
const setFlags = Object.entries(resolved).filter(([, r]) => r.layer !== "unset");
|
|
335
|
+
for (const [name, r] of setFlags) ok(`${name}=${r.value} via ${r.layer}`);
|
|
336
|
+
if (!setFlags.length) note("no state flags set — Trantor State runs dark by default (TDD §11)");
|
|
295
337
|
}
|
|
296
338
|
|
|
297
339
|
// brain
|
|
@@ -304,13 +346,10 @@ prof?.providers && Object.keys(prof.providers).length
|
|
|
304
346
|
? ok(`quota profile set (${Object.entries(prof.providers).map(([k, v]) => `${k}=${v.plan}`).join(", ")})`)
|
|
305
347
|
: 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
348
|
|
|
307
|
-
// fleet — is the crew actually OPERATING, not just installed?
|
|
308
|
-
//
|
|
309
|
-
//
|
|
310
|
-
//
|
|
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.
|
|
349
|
+
// fleet — is the crew actually OPERATING, not just installed? Doctor once reported nine key and
|
|
350
|
+
// billing issues while the duty seat had held 48 undelivered messages for 22 hours and finished
|
|
351
|
+
// crew work went unread overnight: credentials EXIST is not the fleet is MOVING. Both signals are
|
|
352
|
+
// already on disk, written by the runner itself.
|
|
314
353
|
section("the fleet (is it actually running?)");
|
|
315
354
|
{
|
|
316
355
|
const busDir = join(H, ".agent-bus");
|
|
@@ -328,11 +367,10 @@ section("the fleet (is it actually running?)");
|
|
|
328
367
|
const oldest = stamps.length ? Math.min(...stamps) : j?.ts;
|
|
329
368
|
const hours = (Date.now() - oldest) / 3.6e6;
|
|
330
369
|
const seat = f.replace(/^pending-|\.json$/g, "");
|
|
331
|
-
// Three bands, because one flat warning per stuck queue is its own failure:
|
|
332
|
-
//
|
|
333
|
-
//
|
|
334
|
-
//
|
|
335
|
-
// The band between is the live stall — the shape of the 2026-09-09 incident.
|
|
370
|
+
// Three bands, because one flat warning per stuck queue is its own failure: leftovers from
|
|
371
|
+
// projects that ended weeks ago would drown the one that matters. Under an hour is the retry
|
|
372
|
+
// ladder doing its job; over a week is an abandoned seat, worth tidying, not alarming about;
|
|
373
|
+
// the band between is the live stall.
|
|
336
374
|
const abandoned = hours >= 24 * 7;
|
|
337
375
|
if (hours >= 1 && !abandoned) {
|
|
338
376
|
stuck++;
|
package/bin/drill-report.mjs
CHANGED
|
@@ -68,7 +68,7 @@ export class DrillReport {
|
|
|
68
68
|
if (!this.map[id].autoClose || result.status !== "pass" || this.closures[id]) continue;
|
|
69
69
|
const response = await signedPost("/task/update", {
|
|
70
70
|
id: Number(id), project: this.project, by: this.session, status: "done",
|
|
71
|
-
note: `trantor drill PASS\n${result.evidence.join("\n")}`.slice(0, 2000),
|
|
71
|
+
note: `Drill: trantor drill PASS\n${result.evidence.join("\n")}`.slice(0, 2000), // "Drill:" prefix: the hub's done gate (#6452) reads it as the drill line
|
|
72
72
|
}, { project: this.project, session: this.session, timeoutMs: 15000 });
|
|
73
73
|
this.closures[id] = response.ok && response.json?.task?.status === "done"
|
|
74
74
|
&& response.json.task.id === Number(id) && response.json.task.project === this.project
|
|
@@ -51,6 +51,7 @@ test("signed closer on an enforce hub: complete evidence closes, partial/failure
|
|
|
51
51
|
const card = (await signedGet("/tasks?project=trantor")).json.tasks[0];
|
|
52
52
|
assert.equal(card.status, "done");
|
|
53
53
|
assert.equal(card.workedBy, session);
|
|
54
|
+
assert.match(card.log.at(-1).text, /^Drill: trantor drill PASS/); // #6452: the closer's note IS the drill line the hub gate requires
|
|
54
55
|
assert.match(card.log.at(-1).text, /real evidence A/);
|
|
55
56
|
assert.match(card.log.at(-1).text, /real evidence B/);
|
|
56
57
|
assert.equal(JSON.parse(readFileSync(path, "utf8"))[id].closure, "done");
|