trantor 0.18.72 → 0.18.73
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 +21 -5
- package/hooks/heartbeat.mjs +10 -4
- package/hooks/subagent-cost.mjs +2 -2
- package/hub/overseer.mjs +29 -38
- package/hub/reaper.mjs +18 -4
- package/hub/routes/admin.mjs +8 -5
- package/hub/routes/cards.mjs +3 -1
- package/hub/routes/messages.mjs +3 -2
- package/hub.mjs +8 -3
- package/lib/classify-failure.mjs +42 -2
- package/mcp.mjs +6 -5
- package/package.json +1 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "trantor",
|
|
3
|
-
"version": "0.18.
|
|
3
|
+
"version": "0.18.73",
|
|
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
|
@@ -16,6 +16,7 @@ import { applyWorktreeDeclaration, ensureSeatWorktree, provisioningLines, readWo
|
|
|
16
16
|
import {
|
|
17
17
|
AUTH_MARKER_RE, classifyFailure, looksLikeAuthDeath,
|
|
18
18
|
verdictFor, substantiveOutput, stallVerdict, cutSignalFor,
|
|
19
|
+
BILLING_RE, looksLikeBillingDeath, usageSaysNoWork, zeroUsageVerdict,
|
|
19
20
|
readPromptText, stripPromptEcho,
|
|
20
21
|
} from "../lib/classify-failure.mjs";
|
|
21
22
|
import { capWake, capBcast, pickLessons, composePrompt, contractBase, baseLine } from "./crew-payload.mjs";
|
|
@@ -928,6 +929,14 @@ exit $turn_exit`;
|
|
|
928
929
|
authHit = AUTH_MARKER_RE.exec(ownOut)[0];
|
|
929
930
|
log(`\x1b[31mexit 0 but the turn output IS an auth failure — treating as FAILED (auth, "${authHit}")\x1b[0m`);
|
|
930
931
|
}
|
|
932
|
+
// #9724: the same trap in the quota family — opencode printed the OpenRouter credit error and
|
|
933
|
+
// exited 0, and exit 0 + output read as success over a turn where nothing ran. A billing
|
|
934
|
+
// rejection on a clean exit lifts effExit like the auth death does; classifyFailure names it
|
|
935
|
+
// exhausted and the existing ladder parks the seat (exhausted is in PARKING_REASONS).
|
|
936
|
+
if (realExit === 0 && effExit === 0 && looksLikeBillingDeath(ownOut, newCommit)) {
|
|
937
|
+
effExit = 1;
|
|
938
|
+
log(`\x1b[31mexit 0 but the turn output IS a provider billing rejection — treating as FAILED (exhausted, "${BILLING_RE.exec(ownOut)[0]}")\x1b[0m`);
|
|
939
|
+
}
|
|
931
940
|
// #5481: exit 0 with an empty ERRF (the TOTAL capture, both streams) is the null-completion trap,
|
|
932
941
|
// judged on echo-stripped text (#5868). #6969: on a state step ERRF is stderr only, so silence is normal.
|
|
933
942
|
if (realExit === 0 && effExit === 0 && !lastErrText.trim() && !lastEnvelope.trim()) {
|
|
@@ -944,11 +953,6 @@ exit $turn_exit`;
|
|
|
944
953
|
lastEmptyTurn = !cut && realExit === 0 && effExit === 0 && !authHit && !opts.state
|
|
945
954
|
&& !worktreeChanged && !busActive && !substantiveOutput(ownOut);
|
|
946
955
|
if (lastEmptyTurn) log("\x1b[33mexit 0 but the turn was EMPTY — no worktree change, no substantive output, no bus activity\x1b[0m");
|
|
947
|
-
// #5868: the verdict rides the telemetry row so a classification survives the pane scrolling
|
|
948
|
-
// away — the same "classified X because Y" shape the runner logs, in the seat's jsonl forever.
|
|
949
|
-
// A silent cut names its own verdict (#7752): the exit under it is the sweep's SIGPIPE, never
|
|
950
|
-
// a provider failure. A box cut passes `cut` so its 137 reads as the sweep's SIGKILL (#7099).
|
|
951
|
-
const verdict = stallCut ? stallVerdict() : verdictFor(realExit, effExit, lastEmptyOutput, ownOut, lastEmptyTurn, cut);
|
|
952
956
|
// #6134: what the turn COST, from the CLI's own usage line. Zero means this CLI printed none —
|
|
953
957
|
// never that the turn was free. `trantor seat-why` totals these into today's spend per seat.
|
|
954
958
|
let tokens = parseTurnTokens(ownOut);
|
|
@@ -965,6 +969,18 @@ exit $turn_exit`;
|
|
|
965
969
|
: AGENT === "dsh" ? dshTurnUsage(join(homedir(), ".dsh", "sessions"), TURN_DIR, t0, Date.now())
|
|
966
970
|
: null;
|
|
967
971
|
if (usage) tokens = usageTotal(usage);
|
|
972
|
+
// #9724: usage RESOLVED to all zeros means no model work happened — never success; null stays
|
|
973
|
+
// unknown, never failure, and a shipped commit is real work (stale record, not the turn).
|
|
974
|
+
const zeroUsage = realExit === 0 && effExit === 0 && !newCommit && usageSaysNoWork(usage);
|
|
975
|
+
if (zeroUsage) {
|
|
976
|
+
effExit = 1;
|
|
977
|
+
log("\x1b[31mexit 0 but the session usage record resolved to all zeros — no model work happened; treating as FAILED\x1b[0m");
|
|
978
|
+
}
|
|
979
|
+
// #5868: the verdict rides the telemetry row so a classification survives the pane scrolling
|
|
980
|
+
// away — the same "classified X because Y" shape the runner logs. A silent cut names its own
|
|
981
|
+
// verdict (#7752): the exit under it is the sweep's SIGPIPE. A box cut passes `cut` so its 137
|
|
982
|
+
// reads as the sweep's SIGKILL (#7099); a zero-usage turn names its own verdict too (#9724).
|
|
983
|
+
const verdict = stallCut ? stallVerdict() : zeroUsage ? zeroUsageVerdict() : verdictFor(realExit, effExit, lastEmptyOutput, ownOut, lastEmptyTurn, cut);
|
|
968
984
|
// #6289: every ledger row names in ONE field what happened to the turn — cut, stalled (#7752),
|
|
969
985
|
// api-error, completed — and what it cost (0 means "not reported", never "free"). #7762: `card`
|
|
970
986
|
// binds the row to the card the turn worked (0 = kickoff/pulse) so the seat record attributes
|
package/hooks/heartbeat.mjs
CHANGED
|
@@ -153,12 +153,18 @@ async function main(stdinRaw) {
|
|
|
153
153
|
// Write the stamp BEFORE the network call so rapid concurrent tool calls don't all fire.
|
|
154
154
|
try { writeFileSync(stamp, String(Date.now())); } catch {}
|
|
155
155
|
|
|
156
|
-
// POST /register with no status -> hub refreshes lastSeen + project, preserves status
|
|
157
|
-
//
|
|
158
|
-
|
|
156
|
+
// POST /register with no status -> hub refreshes lastSeen + project, preserves status; llm from
|
|
157
|
+
// the seat brand when a dialect bridge runs us (RELAY_AGENT=kimi-orch etc.), else claude.
|
|
158
|
+
// #8723 bounce-3: the beat carries the kind too — "orch" for the orchestrator pane (the same
|
|
159
|
+
// strict test sessionstart.mjs uses), "agent" when the runner's TRANTOR_SEAT says crew seat.
|
|
160
|
+
const beatKind = process.env.TRANTOR_ORCH && process.env.TRANTOR_ORCH === project ? "orch"
|
|
161
|
+
: process.env.TRANTOR_SEAT ? "agent" : "";
|
|
162
|
+
const beatBody = { session, project,
|
|
159
163
|
llm: process.env.RELAY_LLM || (process.env.RELAY_AGENT ? process.env.RELAY_AGENT.replace(/-orch$/, "") : "claude"),
|
|
160
164
|
model: modelFromTranscript(stdinRaw),
|
|
161
|
-
hookVersion: (() => { try { return installedVersion(); } catch { return ""; } })() }
|
|
165
|
+
hookVersion: (() => { try { return installedVersion(); } catch { return ""; } })() };
|
|
166
|
+
if (beatKind) beatBody.kind = beatKind;
|
|
167
|
+
await signedPost("/register", beatBody, { session, timeoutMs: Number(process.env.RELAY_HEARTBEAT_TIMEOUT_MS || 1500) });
|
|
162
168
|
|
|
163
169
|
// Ambient narratives: once an hour (machine-wide stamp), spawn the summarizer detached — the
|
|
164
170
|
// board's machine-titled cards gain a plain-language "assigned — did" line without anyone asking.
|
package/hooks/subagent-cost.mjs
CHANGED
|
@@ -109,8 +109,8 @@ try {
|
|
|
109
109
|
phase: "sub-agents",
|
|
110
110
|
}, { timeoutMs: 2500 });
|
|
111
111
|
process.stderr.write(`[trantor] subagent-cost: ${agentType} ${model} ~$${usd == null ? "?" : usd.toFixed(4)} (${tokens.input + tokens.output + tokens.cacheWrite + tokens.cacheRead} tok) → ${project}\n`);
|
|
112
|
-
//
|
|
113
|
-
process.stdout.write(
|
|
112
|
+
// #9707: log only. CC 2.1.280 feeds SubagentStop additionalContext back to the sub-agent as input, which re-woke it in a loop.
|
|
113
|
+
process.stdout.write("{}");
|
|
114
114
|
} catch (e) {
|
|
115
115
|
process.stderr.write(`[trantor] subagent-cost error: ${e?.message || e}\n`);
|
|
116
116
|
process.stdout.write("{}");
|
package/hub/overseer.mjs
CHANGED
|
@@ -12,11 +12,9 @@ const OVERSEER_TICK_MS = Number(process.env.RELAY_OVERSEER_TICK_MS || 30 * 1000)
|
|
|
12
12
|
// timer: see overseerTick.
|
|
13
13
|
const OVERSEER_CLEAR_MS = Number(process.env.RELAY_OVERSEER_CLEAR_MS || process.env.RELAY_OVERSEER_DEDUP_MS || 10 * 60 * 1000);
|
|
14
14
|
// Standing conditions, keyed by collision identity -> { since, lastTick }. A collision is a STATE,
|
|
15
|
-
// not an event: it persists
|
|
16
|
-
// 500 events for 4
|
|
17
|
-
//
|
|
18
|
-
// forgotten only after the condition has been gone for OVERSEER_CLEAR_MS, so a genuine recurrence
|
|
19
|
-
// warns again.
|
|
15
|
+
// not an event: it persists (#5350 doctrine). It fires ONCE when it starts, stays quiet while it
|
|
16
|
+
// holds (a metronome woke duty on 500 events for 4 conditions once), and the entry is forgotten
|
|
17
|
+
// only after OVERSEER_CLEAR_MS of absence, so a genuine recurrence warns again.
|
|
20
18
|
const overseerActive = new Map();
|
|
21
19
|
// Heartbeat for the WATCHER itself: /overseer/status must distinguish "fleet is clear" from "the
|
|
22
20
|
// overseer stopped ticking" — a monitor that cannot prove it is alive reads as clear when dead.
|
|
@@ -48,31 +46,29 @@ function overseerInputs() {
|
|
|
48
46
|
}
|
|
49
47
|
|
|
50
48
|
// --- #5760: the same-project warning is an EPISODE keyed by the MEMBER SET -------------------
|
|
51
|
-
//
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
// the NORMAL state of a project and is not a collision at all; a set that never changed re-warns
|
|
55
|
-
// never. The warn itself rides the SAME episode machinery as every other kind (#5350: one warn at
|
|
56
|
-
// open, newcomer-only intros while standing, a genuine clear ends it) — the record below only
|
|
57
|
-
// feeds the pure rule the set it judged last, so "unchanged" is one hash comparison and the
|
|
58
|
-
// record line reports DURATION ("same-project for 6h"), never a count of warnings.
|
|
49
|
+
// lib/same-project.mjs (pure) decides from (previous set, current set, declared crew,
|
|
50
|
+
// last-fired-at): a declared crew is the NORMAL state of a project and not a collision at all,
|
|
51
|
+
// an unchanged set never re-warns (#5350 machinery), and the record reports DURATION.
|
|
59
52
|
const sameProjectFired = new Map(); // project -> { hash, sessions, ts } — the set as of the last verdict
|
|
60
53
|
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
//
|
|
64
|
-
//
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
//
|
|
70
|
-
//
|
|
54
|
+
// #8723 bounce-4: peerKindOf is the ONE answer to "what is this peer" — the row's own kind,
|
|
55
|
+
// nothing else — shared with /peers, so roster and overseer can never disagree. The enrolled
|
|
56
|
+
// identity is NOT a fallback (every identity defaults kind "agent", which made every peer crew
|
|
57
|
+
// and hid real intruders): the beats stamp orch/agent and pg COALESCE keeps the kind instead.
|
|
58
|
+
function peerKindOf(p) {
|
|
59
|
+
return p && typeof p === "object" ? String(p.kind || "") : "";
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// The declared crew is HUB state: the peer row's kind (#6148, #6075) — "agent" is a crew seat
|
|
63
|
+
// (crew-runner stamps every /register its seats make), "orch" the project's orchestrator pane
|
|
64
|
+
// (sessionstart stamps it when TRANTOR_ORCH names this project). A local crew-windows.txt reader
|
|
65
|
+
// would describe the OPERATOR's machine, never this hub's. Genesis is deliberately NOT crew (#6068).
|
|
71
66
|
function declaredCrewFor(project) {
|
|
72
67
|
const crew = new Set();
|
|
73
68
|
for (const [sid, p] of Object.entries(state.peers)) {
|
|
74
69
|
if ((p.project || "") !== project) continue;
|
|
75
|
-
|
|
70
|
+
const k = peerKindOf(p);
|
|
71
|
+
if (k === "agent" || k === "orch") crew.add(sid);
|
|
76
72
|
}
|
|
77
73
|
return [...crew];
|
|
78
74
|
}
|
|
@@ -85,12 +81,10 @@ function overseerTick() {
|
|
|
85
81
|
overseerLastTick = t;
|
|
86
82
|
const pol = overseerPolicy();
|
|
87
83
|
const seen = new Set();
|
|
88
|
-
//
|
|
89
|
-
//
|
|
90
|
-
//
|
|
91
|
-
//
|
|
92
|
-
// (newcomers only, same-project included): existing members never
|
|
93
|
-
// re-hear it, so a standing condition must not re-wake every party every tick.
|
|
84
|
+
// The intro hands each party the others' session ids at the moment coordination is warranted —
|
|
85
|
+
// the warning alone went only to duty, and ids never cross the bus by themselves. Shared by the
|
|
86
|
+
// episode-start branch (all parties) and the standing branch (newcomers only, same-project
|
|
87
|
+
// included): existing members never re-hear it, so a standing condition cannot re-wake every tick.
|
|
94
88
|
const intro = (c, me, others) => {
|
|
95
89
|
const rest = others.filter(p => p !== me);
|
|
96
90
|
if (rest.length === 0) return;
|
|
@@ -102,13 +96,10 @@ function overseerTick() {
|
|
|
102
96
|
// project, not a collision — so not even the context feed narrates them.
|
|
103
97
|
const kept = [];
|
|
104
98
|
for (const c of collisions) {
|
|
105
|
-
// #5760:
|
|
106
|
-
//
|
|
107
|
-
//
|
|
108
|
-
//
|
|
109
|
-
// the record line reports DURATION. Without the rule module this branch is invisible and
|
|
110
|
-
// same-project rides the generic loop exactly as before — a missing rule must never
|
|
111
|
-
// re-instate the hourly metronome, so the fallback is the pre-#5760 behavior, never stricter.
|
|
99
|
+
// #5760: the pure episode rule rides ON TOP of the shared machinery — a crew-only set is not
|
|
100
|
+
// a collision at all (dropped, no context), a liveness flap replays the SAME set and stays
|
|
101
|
+
// silent, the record reports DURATION. Without the rule module the fallback is the pre-#5760
|
|
102
|
+
// generic loop: a missing rule must never re-instate the metronome, only loosen it.
|
|
112
103
|
if (c.kind === "same-project-sessions" && _sameProject?.sameProjectDecision) {
|
|
113
104
|
const prior = sameProjectFired.get(c.project) || null;
|
|
114
105
|
const d = _sameProject.sameProjectDecision({
|
|
@@ -205,7 +196,7 @@ setInterval(overseerTick, OVERSEER_TICK_MS).unref?.();
|
|
|
205
196
|
setTimeout(overseerTick, 2000).unref?.();
|
|
206
197
|
|
|
207
198
|
return {
|
|
208
|
-
overseerTick, overseerPolicy, overseerInputs, declaredCrewFor,
|
|
199
|
+
overseerTick, overseerPolicy, overseerInputs, declaredCrewFor, peerKindOf,
|
|
209
200
|
active: overseerActive,
|
|
210
201
|
get engine() { return _overseer; },
|
|
211
202
|
get sameProject() { return _sameProject; },
|
package/hub/reaper.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export function createReaper({
|
|
2
2
|
state, now, canon, appendCardEvent, appendEvent, appendTaskLog, markDirty, sweepPresence,
|
|
3
|
-
ONLINE_MS, PEER_TTL_MS, FOCUS_OFFLINE_MS, FOCUS_IDLE_MS,
|
|
3
|
+
ONLINE_MS, PEER_TTL_MS, PEER_FORGET_MS, FOCUS_OFFLINE_MS, FOCUS_IDLE_MS,
|
|
4
4
|
REAP_GRACE_MS, TODO_STALE_MS, REAP_INTERVAL_MS,
|
|
5
5
|
CONTRACT_ABANDON_MS, CONTRACT_WINDOW_MS,
|
|
6
6
|
}) {
|
|
@@ -49,11 +49,25 @@ function linkCommitToFocus(commitCard, by) {
|
|
|
49
49
|
}
|
|
50
50
|
function prunePeers() {
|
|
51
51
|
const cutoff = now() - PEER_TTL_MS;
|
|
52
|
-
|
|
52
|
+
const forgetCut = now() - PEER_FORGET_MS;
|
|
53
|
+
let changed = false;
|
|
53
54
|
for (const [session, peer] of Object.entries(state.peers)) {
|
|
54
|
-
|
|
55
|
+
const seen = peer.lastSeen || 0;
|
|
56
|
+
if (seen < forgetCut) { // #8723: the memory row itself is forgotten after RELAY_PEER_FORGET_MS
|
|
57
|
+
if (closeFocus(session)) changed = true;
|
|
58
|
+
delete state.peers[session];
|
|
59
|
+
changed = true;
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
if (seen >= cutoff) continue;
|
|
63
|
+
// #8723: a TTL-expired peer is a memory row, not a deleted one — deleting dropped `kind`
|
|
64
|
+
// (the overseer then warned about the crew's own returning seats) and `deliveredUpTo`
|
|
65
|
+
// (duty then escalated consumed mail as UNDELIVERED, "last seen never"). Presence readers
|
|
66
|
+
// already filter by lastSeen freshness, so only the offline edge is pinned here.
|
|
67
|
+
if (closeFocus(session)) changed = true;
|
|
68
|
+
if (peer._on === true) { peer._on = false; changed = true; }
|
|
55
69
|
}
|
|
56
|
-
if (
|
|
70
|
+
if (changed) markDirty();
|
|
57
71
|
}
|
|
58
72
|
setInterval(prunePeers, 60000).unref?.();
|
|
59
73
|
setInterval(sweepPresence, 60000).unref?.();
|
package/hub/routes/admin.mjs
CHANGED
|
@@ -10,11 +10,11 @@ const fileReads = new Map();
|
|
|
10
10
|
export async function routeAdmin({ req, res, q, P, auth, ctx }) {
|
|
11
11
|
const {
|
|
12
12
|
state, body, json, crossProjectGuard, touch, canon, filterReadable,
|
|
13
|
-
appendEvent, EVENT_CAP, overseer, ONLINE_MS, pruneClaims, fileClaims,
|
|
13
|
+
appendEvent, EVENT_CAP, overseer, ONLINE_MS, PEER_TTL_MS, pruneClaims, fileClaims,
|
|
14
14
|
scopeAllows, duty, markDirty, HUB_VERSION, cmpSemver, prunePeers,
|
|
15
15
|
filterDiscoverable, healthOf, now, canRead, AUTH_MODE, CLAIM_TTL_MS, subFp,
|
|
16
16
|
} = ctx;
|
|
17
|
-
const { overseerPolicy, overseerInputs, declaredCrewFor } = overseer;
|
|
17
|
+
const { overseerPolicy, overseerInputs, declaredCrewFor, peerKindOf } = overseer;
|
|
18
18
|
if (req.method === "POST" && P === "/register") {
|
|
19
19
|
const b = await body(req);
|
|
20
20
|
const cpg = crossProjectGuard(auth, P, b);
|
|
@@ -29,7 +29,8 @@ export async function routeAdmin({ req, res, q, P, auth, ctx }) {
|
|
|
29
29
|
// #7749: the runner's live turn phase (working/idle/cut/stalled/parked) + its start ride the
|
|
30
30
|
// presence row, so /peers answers "what is the seat doing now" without inferring it from durations.
|
|
31
31
|
if (pr && b.phase) { pr.phase = String(b.phase).slice(0, 20); pr.phaseSince = Number(b.phaseSince) || now(); }
|
|
32
|
-
|
|
32
|
+
// #8723: memory rows (quiet past PEER_TTL_MS) are internal state — a peer listing never shows them.
|
|
33
|
+
return json(res, 200, { ok: true, session: b.session, peers: Object.keys(state.peers).filter(s => (state.peers[s]?.lastSeen || 0) >= now() - PEER_TTL_MS) });
|
|
33
34
|
}
|
|
34
35
|
if (req.method === "POST" && P === "/status") { const b = await body(req); touch(b.session, b.status ?? "", b.project, b.hookVersion, auth); return json(res, 200, { ok: true }); }
|
|
35
36
|
// Single-peer lookup, including the read receipt (how far this session's inbox has actually been
|
|
@@ -318,10 +319,12 @@ export async function routeAdmin({ req, res, q, P, auth, ctx }) {
|
|
|
318
319
|
if (req.method === "GET" && P === "/peers") {
|
|
319
320
|
prunePeers();
|
|
320
321
|
const cutoff = now() - ONLINE_MS;
|
|
321
|
-
const
|
|
322
|
+
const ttlCut = now() - PEER_TTL_MS; // #8723: memory rows are for declaredCrewFor + duty only, never a listing
|
|
323
|
+
const peerRows = filterDiscoverable(auth, Object.entries(state.peers), ([, v]) => v.project || "").filter(([, v]) => (v.lastSeen || 0) >= ttlCut);
|
|
322
324
|
return json(res, 200, { hubVersion: HUB_VERSION, authMode: AUTH_MODE, peers: peerRows.map(([s, v]) => ({ session: s, lastSeen: v.lastSeen, online: v.lastSeen > cutoff, status: v.status || "", health: healthOf(v.status), project: v.project || "", phase: v.phase || "", phaseSince: v.phaseSince || 0,
|
|
323
325
|
pubkey: v.pubkey || "", identity: v.identity || null, authWarning: v.authWarning || "",
|
|
324
|
-
kind: v
|
|
326
|
+
kind: peerKindOf(v), // #8723 bounce-3: same resolver declaredCrewFor reads — never a roster-only answer
|
|
327
|
+
llm: v.llm || "", model: v.model || "", hookVersion: v.hookVersion || "", staleHooks: !!(v.lastSeen > cutoff && v.hookVersion && HUB_VERSION && cmpSemver(v.hookVersion, HUB_VERSION) < 0) })) });
|
|
325
328
|
}
|
|
326
329
|
// --- Provider balances (prepaid credit) ---
|
|
327
330
|
// The hub runs under launchd with no provider keys, so it can't fetch balances itself. Env-having
|
package/hub/routes/cards.mjs
CHANGED
|
@@ -29,7 +29,7 @@ export async function routeCards({ req, res, q, P, auth, ctx }) {
|
|
|
29
29
|
appendTaskLog, appendTaskNote, cleanChecklist, cleanDrill, hasDrillLine, linkCommitToFocus,
|
|
30
30
|
derivePhases, PROPOSAL_CAP, propFp, healthOf, REAP_GRACE_MS, subFp,
|
|
31
31
|
prunePeers, canRead, HUB_VERSION, cmpSemver, isCardEvent, fmtAge, hubSend,
|
|
32
|
-
ONLINE_MS,
|
|
32
|
+
ONLINE_MS, PEER_TTL_MS,
|
|
33
33
|
} = ctx;
|
|
34
34
|
if (req.method === "POST" && P === "/task") { // create a card
|
|
35
35
|
const b = await body(req);
|
|
@@ -544,7 +544,9 @@ export async function routeCards({ req, res, q, P, auth, ctx }) {
|
|
|
544
544
|
const cutoff = now() - ONLINE_MS; const byProj = {};
|
|
545
545
|
const proj = p => canon(p) || "(unassigned)";
|
|
546
546
|
const mk = k => (byProj[k] ||= { project: k, brief: (state.projectMeta[k]?.brief) || "", agents: [], tasks: { todo:0,doing:0,testing:0,failed:0,done:0,blocked:0 }, doingTitles: [], lastActivity: 0 });
|
|
547
|
+
const ttlCut = now() - PEER_TTL_MS; // #8723: a memory row (quiet past PEER_TTL_MS) never lists as a board agent…
|
|
547
548
|
for (const [s, v] of filterReadable(auth, Object.entries(state.peers), ([, v]) => v.project || "")) {
|
|
549
|
+
if ((v.lastSeen || 0) < ttlCut) continue; // …declaredCrewFor and the duty watermark still read it from state.
|
|
548
550
|
const k = proj(v.project); const e = mk(k); e.agents.push({ session: s, online: v.lastSeen > cutoff, status: v.status || "", health: healthOf(v.status),
|
|
549
551
|
llm: v.llm || "", model: v.model || "", hookVersion: v.hookVersion || "", staleHooks: !!(v.lastSeen > cutoff && v.hookVersion && HUB_VERSION && cmpSemver(v.hookVersion, HUB_VERSION) < 0) });
|
|
550
552
|
if ((v.lastSeen || 0) > e.lastActivity) e.lastActivity = v.lastSeen;
|
package/hub/routes/messages.mjs
CHANGED
|
@@ -6,7 +6,7 @@ export async function routeMessages({ req, res, q, P, auth, ctx }) {
|
|
|
6
6
|
state, body, json, stripNulText, crossProjectGuard, touch, pushToStreams,
|
|
7
7
|
appendEvent, appendCardEvent, appendTaskLog, markDelivered, contractsFor, canUseInboxSession, inboxWindow,
|
|
8
8
|
deliverable, inboxReadable, inboxResponse, filterReadable, streams, UI,
|
|
9
|
-
AUTH_MODE, persistHealth, duty, now, markDirty, assertNoSecrets,
|
|
9
|
+
AUTH_MODE, persistHealth, duty, now, markDirty, assertNoSecrets, PEER_TTL_MS,
|
|
10
10
|
CONTRACT_WINDOW_MS,
|
|
11
11
|
} = ctx;
|
|
12
12
|
if (req.method === "POST" && P === "/send") {
|
|
@@ -157,7 +157,8 @@ export async function routeMessages({ req, res, q, P, auth, ctx }) {
|
|
|
157
157
|
if (req.method === "GET" && (P === "/" || P === "/ui")) {
|
|
158
158
|
res.writeHead(200, { "content-type": "text/html; charset=utf-8" }); return res.end(UI || "<h1>trantor</h1><p>dashboard unavailable</p>");
|
|
159
159
|
}
|
|
160
|
-
|
|
160
|
+
// #8723: the count is a listing in aggregate — memory rows (past PEER_TTL_MS) never count.
|
|
161
|
+
if (P === "/health") return json(res, 200, { ok: true, authMode: AUTH_MODE, peers: Object.values(state.peers).filter(v => (v.lastSeen || 0) >= now() - PEER_TTL_MS).length, messages: state.messages.length, streams: streams.length,
|
|
161
162
|
persist: persistHealth.view(),
|
|
162
163
|
// #5686: duty liveness rides /health so the app's Home strip and doctor read one truth.
|
|
163
164
|
duty: { ...duty.dutyLiveness(), darkSinceMs: duty.darkSince ? now() - duty.darkSince : 0, queuedEscalations: duty.dutyQueuedEscalations() } });
|
package/hub.mjs
CHANGED
|
@@ -33,6 +33,11 @@ const ONLINE_MS = Number(process.env.RELAY_ONLINE_MS || 5 * 60 * 1000);
|
|
|
33
33
|
const PEER_TTL_DEFAULT_MS = 21600000;
|
|
34
34
|
const peerTtlRaw = Number(process.env.RELAY_PEER_TTL_MS || PEER_TTL_DEFAULT_MS);
|
|
35
35
|
const PEER_TTL_MS = Math.max(Number.isFinite(peerTtlRaw) ? peerTtlRaw : PEER_TTL_DEFAULT_MS, ONLINE_MS);
|
|
36
|
+
// #8723: the memory row a TTL-expired peer becomes is not immortal either — after this long quiet
|
|
37
|
+
// it is forgotten outright. Floored at PEER_TTL_MS so every row gets its memory phase before deletion.
|
|
38
|
+
const PEER_FORGET_DEFAULT_MS = 30 * 24 * 60 * 60 * 1000;
|
|
39
|
+
const forgetRaw = Number(process.env.RELAY_PEER_FORGET_MS || PEER_FORGET_DEFAULT_MS);
|
|
40
|
+
const PEER_FORGET_MS = Math.max(Number.isFinite(forgetRaw) ? forgetRaw : PEER_FORGET_DEFAULT_MS, PEER_TTL_MS);
|
|
36
41
|
const REAP_GRACE_MS = Number(process.env.RELAY_REAP_GRACE_MS || 15 * 60 * 1000);
|
|
37
42
|
const SUPERSEDE_GRACE_MS = Number(process.env.RELAY_SUPERSEDE_GRACE_MS || REAP_GRACE_MS);
|
|
38
43
|
const TODO_STALE_DEFAULT_MS = 14 * 24 * 60 * 60 * 1000;
|
|
@@ -73,8 +78,8 @@ if (process.argv.includes("--smoke")) {
|
|
|
73
78
|
const reaper = createReaper({
|
|
74
79
|
state: store.state, markDirty: store.markDirty, canon: authRuntime.canon,
|
|
75
80
|
now: events.now, appendCardEvent: events.appendCardEvent, appendEvent: events.appendEvent,
|
|
76
|
-
appendTaskLog: store.appendTaskLog, sweepPresence: events.sweepPresence, ONLINE_MS, PEER_TTL_MS,
|
|
77
|
-
FOCUS_IDLE_MS, REAP_GRACE_MS, TODO_STALE_MS, REAP_INTERVAL_MS,
|
|
81
|
+
appendTaskLog: store.appendTaskLog, sweepPresence: events.sweepPresence, ONLINE_MS, PEER_TTL_MS, PEER_FORGET_MS,
|
|
82
|
+
FOCUS_OFFLINE_MS, FOCUS_IDLE_MS, REAP_GRACE_MS, TODO_STALE_MS, REAP_INTERVAL_MS,
|
|
78
83
|
CONTRACT_ABANDON_MS, CONTRACT_WINDOW_MS,
|
|
79
84
|
});
|
|
80
85
|
const duty = createDuty({
|
|
@@ -114,7 +119,7 @@ let UI = "";
|
|
|
114
119
|
try { UI = readFileSync(new URL("./ui.html", import.meta.url), "utf8"); } catch {}
|
|
115
120
|
const context = {
|
|
116
121
|
...store, ...authRuntime, ...events, ...reaper, duty, overseer, derivePhases,
|
|
117
|
-
hubSend: duty.hubSend, assertNoSecrets, UI, AUTH_MODE, ONLINE_MS,
|
|
122
|
+
hubSend: duty.hubSend, assertNoSecrets, UI, AUTH_MODE, ONLINE_MS, PEER_TTL_MS,
|
|
118
123
|
REAP_GRACE_MS, CONTRACT_WINDOW_MS,
|
|
119
124
|
};
|
|
120
125
|
const routes = [routeAdmin, routeCards, routeInsights, routeMessages];
|
package/lib/classify-failure.mjs
CHANGED
|
@@ -84,15 +84,27 @@ export function looksLikeAuthDeath(ownText, realWork = false) {
|
|
|
84
84
|
* it did, phrased exactly like the runner's own "classified X because Y" log — so a pane that
|
|
85
85
|
* scrolls away loses nothing that the telemetry row needs to say. */
|
|
86
86
|
export function verdictFor(realExit, effExit, emptyOutput, ownText, emptyTurn = false, cut = false) {
|
|
87
|
-
if (realExit === 0 && effExit === 0)
|
|
87
|
+
if (realExit === 0 && effExit === 0) {
|
|
88
|
+
// #9724: the quota family's exit-0 trap — opencode printed the OpenRouter credit error and
|
|
89
|
+
// quit clean, so exit 0 + bytes read as success over a turn where nothing ran. A billing
|
|
90
|
+
// rejection on a clean exit is the provider refusing the turn; a shipped commit is still
|
|
91
|
+
// real work (checked by the runner via git), and it stands above the captured stream.
|
|
92
|
+
const bl = looksLikeBillingDeath(ownText);
|
|
93
|
+
if (bl) return `classified exhausted because ${BILLING_RE.exec(String(ownText || ""))[0]} in the CLI's own output on a clean exit`;
|
|
94
|
+
return emptyTurn
|
|
88
95
|
? "classified empty-turn because exit 0 with no worktree change, no substantive output, no bus activity"
|
|
89
96
|
: "classified success because exit 0 with CLI output";
|
|
97
|
+
}
|
|
90
98
|
if (realExit === 0 && effExit === 1) {
|
|
91
99
|
if (emptyOutput) return "classified empty-output because exit 0 with no output on either stream";
|
|
92
100
|
// #8778: the exit-0 escalation must not relabel a provider-side rejection as "auth" — codex
|
|
93
101
|
// prints the chatgpt-backend 401 and exits 0, so the real specimen rode this exact path.
|
|
94
102
|
const pr = providerRejected(String(ownText || ""));
|
|
95
103
|
if (pr) return `classified ${pr.reason} because ${pr.matched}`;
|
|
104
|
+
// #9724: the runner's billing escalation rides this branch too — the OpenRouter specimen's
|
|
105
|
+
// credit error must read exhausted here, never fall through to the auth label.
|
|
106
|
+
const bl = String(ownText || "").match(BILLING_RE);
|
|
107
|
+
if (bl) return `classified exhausted because ${bl[0]} in the CLI's own output on a clean exit`;
|
|
96
108
|
const m = AUTH_MARKER_RE.exec(String(ownText || ""));
|
|
97
109
|
return `classified auth because ${m ? m[0] : "auth marker"} in the CLI's own short output`;
|
|
98
110
|
}
|
|
@@ -120,6 +132,32 @@ export function providerRejected(errText) {
|
|
|
120
132
|
};
|
|
121
133
|
}
|
|
122
134
|
|
|
135
|
+
// ---- the provider billing rejection on a clean exit (#9724) ------------------------------------
|
|
136
|
+
// The OpenRouter specimen: opencode printed "This request requires more credits, or fewer
|
|
137
|
+
// max_tokens. You requested up to 32000 tokens, but can only afford 16." and exited 0 — the
|
|
138
|
+
// runner read exit 0 + bytes as success and posted "✅ done" over a turn where nothing ran.
|
|
139
|
+
export const BILLING_RE = /requires more credits|can only afford|http 402|insufficient balance|payment required/i;
|
|
140
|
+
|
|
141
|
+
/** #9724: a billing rejection on a clean exit is the provider refusing the turn. Same doctrine as
|
|
142
|
+
* looksLikeAuthDeath: a shipped commit (realWork, checked by the runner via git) is a live turn. */
|
|
143
|
+
export function looksLikeBillingDeath(ownText, realWork = false) {
|
|
144
|
+
if (realWork) return false;
|
|
145
|
+
return BILLING_RE.test(String(ownText || ""));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** #9724/#8234: a usage record that RESOLVED (the #8234 lookup found the session's record) to all
|
|
149
|
+
* zeros means no model work happened. Null usage — the CLI prints none and keeps none — stays
|
|
150
|
+
* unknown and is never failure. */
|
|
151
|
+
export function usageSaysNoWork(usage) {
|
|
152
|
+
const vals = Object.values(usage || {});
|
|
153
|
+
return vals.length > 0 && vals.every(v => Number(v) === 0);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** The verdict line for a resolved-zero-usage turn — named here so the drill pins the string. */
|
|
157
|
+
export function zeroUsageVerdict() {
|
|
158
|
+
return "classified zero-usage because the session usage record resolved to all zeros — no model work happened";
|
|
159
|
+
}
|
|
160
|
+
|
|
123
161
|
/** Classify one failed turn. Returns { reason, matched } — matched is the evidence excerpt the
|
|
124
162
|
* runner logs, so the next misclassification is diagnosable from the seat log alone. `cut` is the
|
|
125
163
|
* runner's box/stall marker: 137 reads as the sweep's SIGKILL only under it — without the marker
|
|
@@ -151,7 +189,9 @@ export function classifyFailure(exit, errText, emptyOutput = false, emptyTurn =
|
|
|
151
189
|
if (pr) return pr;
|
|
152
190
|
// "reached your … limit" / "usage limit" catch the subscription CLIs (Claude's "You've reached
|
|
153
191
|
// your Fable 5 limit"), which say nothing about quota or credits and would otherwise read as a crash.
|
|
154
|
-
|
|
192
|
+
// #9724: the OpenRouter billing phrasing — "requires more credits … can only afford 16" — and
|
|
193
|
+
// "insufficient balance" name the same wall without any of the older tokens.
|
|
194
|
+
m = t.match(/quota|payment required|requires more credits|can only afford|insufficient balance|402|429|too many requests|rate.?limit|usage limit|exceeded your|reached your [^.\n]*limit|insufficient (?:credits?|funds)|out of (?:credits?|quota)/);
|
|
155
195
|
if (m) return { reason: "exhausted", matched: m[0] };
|
|
156
196
|
m = t.match(/unauthor|401|invalid[ _-]?api[ _-]?key|forbidden|403|token expired|expired/);
|
|
157
197
|
if (m) return { reason: "auth", matched: m[0] };
|
package/mcp.mjs
CHANGED
|
@@ -594,11 +594,12 @@ const projectDir = process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
|
594
594
|
const nonProjectReason = nonSeatReason(projectDir);
|
|
595
595
|
const isHomeDirSession = !!nonProjectReason;
|
|
596
596
|
|
|
597
|
-
// #6170: WHAT this session is, when it can know
|
|
598
|
-
//
|
|
599
|
-
//
|
|
600
|
-
//
|
|
601
|
-
const KIND = process.env.TRANTOR_ORCH && process.env.TRANTOR_ORCH === PROJECT ? { kind: "orch" }
|
|
597
|
+
// #6170: WHAT this session is, when it can know — sessionstart stamps kind "orch" once, but every
|
|
598
|
+
// MCP beat afterwards was kindless, so the hub's crew exemption read the demoted row and the
|
|
599
|
+
// orchestrator was warned as an intruder on its own project. Same test sessionstart uses.
|
|
600
|
+
// #8723 bounce-3: a crew seat stamps "agent" the same way (TRANTOR_SEAT, set by bin/crew-runner.mjs).
|
|
601
|
+
const KIND = process.env.TRANTOR_ORCH && process.env.TRANTOR_ORCH === PROJECT ? { kind: "orch" }
|
|
602
|
+
: process.env.TRANTOR_SEAT ? { kind: "agent" } : {};
|
|
602
603
|
|
|
603
604
|
if (!isHomeDirSession) {
|
|
604
605
|
await api("POST", "/register", { session: SESSION, project: PROJECT, status: `active in ${PROJECT}`, hookVersion: MCP_VERSION, ...KIND })
|