trantor 0.18.71 → 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 +71 -3
- package/lib/state/derive.mjs +4 -5
- package/lib/state/driver.mjs +4 -6
- package/lib/state/store.mjs +3 -6
- package/lib/state/validate.mjs +8 -13
- package/lib/turn-policy.mjs +3 -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,11 +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";
|
|
100
|
+
// #8778: the exit-0 escalation must not relabel a provider-side rejection as "auth" — codex
|
|
101
|
+
// prints the chatgpt-backend 401 and exits 0, so the real specimen rode this exact path.
|
|
102
|
+
const pr = providerRejected(String(ownText || ""));
|
|
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`;
|
|
92
108
|
const m = AUTH_MARKER_RE.exec(String(ownText || ""));
|
|
93
109
|
return `classified auth because ${m ? m[0] : "auth marker"} in the CLI's own short output`;
|
|
94
110
|
}
|
|
@@ -96,6 +112,52 @@ export function verdictFor(realExit, effExit, emptyOutput, ownText, emptyTurn =
|
|
|
96
112
|
return `classified ${reason} because ${matched}`;
|
|
97
113
|
}
|
|
98
114
|
|
|
115
|
+
// ---- the provider-rejected 401 (#8778) ---------------------------------------------------------
|
|
116
|
+
// chatgpt.com/backend-api is the ChatGPT-SUBSCRIPTION backend codex hits while logged in with no
|
|
117
|
+
// API key stored, so a 401 naming it is OpenAI rejecting its own server-side key — the specimen's
|
|
118
|
+
// sk-svcacct key existed in no env file, keychain or process env on the machine.
|
|
119
|
+
const REQUEST_ID_RE = /request id:\s*([a-z0-9][a-z0-9-]*)/i;
|
|
120
|
+
|
|
121
|
+
/** The #8778 rule: a 401 whose url is the chatgpt.com/backend-api endpoint classifies
|
|
122
|
+
* provider-rejected — the verdict names the request id and steers to waiting or the OpenAI
|
|
123
|
+
* account, never a re-login. Returns null for any other shape, so a 401 from an API-key
|
|
124
|
+
* endpoint (or one naming no endpoint) stays "auth". */
|
|
125
|
+
export function providerRejected(errText) {
|
|
126
|
+
const t = String(errText || "");
|
|
127
|
+
if (!/\b401\b/.test(t) || !/chatgpt\.com\/backend-api/i.test(t)) return null;
|
|
128
|
+
const rid = (t.match(REQUEST_ID_RE) || [])[1] || "";
|
|
129
|
+
return {
|
|
130
|
+
reason: "provider-rejected",
|
|
131
|
+
matched: `401 from https://chatgpt.com/backend-api${rid ? ` (request id ${rid})` : ""} — OpenAI rejected its own subscription key server-side; wait or check the OpenAI account, logging in again will not help`,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
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
|
+
|
|
99
161
|
/** Classify one failed turn. Returns { reason, matched } — matched is the evidence excerpt the
|
|
100
162
|
* runner logs, so the next misclassification is diagnosable from the seat log alone. `cut` is the
|
|
101
163
|
* runner's box/stall marker: 137 reads as the sweep's SIGKILL only under it — without the marker
|
|
@@ -118,12 +180,18 @@ export function classifyFailure(exit, errText, emptyOutput = false, emptyTurn =
|
|
|
118
180
|
// #5684: a provider BACKEND failure is not quota — it wants retry/swap, not a window wait.
|
|
119
181
|
// The specimen (#5683): codex's "unexpected status 404 Not Found … /responses/compact" was
|
|
120
182
|
// labelled "exhausted" and the operator was advised to wait out a window that did not exist.
|
|
121
|
-
// 401/403/429 deliberately fall through to the auth/exhausted branches below.
|
|
183
|
+
// 401/403/429 deliberately fall through to the provider-rejected/auth/exhausted branches below.
|
|
122
184
|
let m = t.match(/unexpected status (?:404|408|410|5\d\d)|internal server error|bad gateway|service unavailable|gateway time.?out|econnrefused|connection refused|socket hang ?up|network is unreachable/);
|
|
123
185
|
if (m) return { reason: "backend-error", matched: m[0] };
|
|
186
|
+
// #8778: ranked ABOVE the quota/auth patterns — the specimen text itself carries an "API key"
|
|
187
|
+
// phrase, and either pattern reading first would relabel the same provider-side rejection.
|
|
188
|
+
const pr = providerRejected(String(errText || ""));
|
|
189
|
+
if (pr) return pr;
|
|
124
190
|
// "reached your … limit" / "usage limit" catch the subscription CLIs (Claude's "You've reached
|
|
125
191
|
// your Fable 5 limit"), which say nothing about quota or credits and would otherwise read as a crash.
|
|
126
|
-
|
|
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)/);
|
|
127
195
|
if (m) return { reason: "exhausted", matched: m[0] };
|
|
128
196
|
m = t.match(/unauthor|401|invalid[ _-]?api[ _-]?key|forbidden|403|token expired|expired/);
|
|
129
197
|
if (m) return { reason: "auth", matched: m[0] };
|
package/lib/state/derive.mjs
CHANGED
|
@@ -170,11 +170,10 @@ export function deriveState({ project, seat, card, worktree, handoffText, cardTi
|
|
|
170
170
|
now: Number.isInteger(now) ? now : Math.floor(Date.now() / 1000),
|
|
171
171
|
by: author,
|
|
172
172
|
files: deriveFiles(worktree),
|
|
173
|
-
//
|
|
174
|
-
//
|
|
175
|
-
//
|
|
176
|
-
//
|
|
177
|
-
// empty — so the successor must still re-earn evidence before anything NEW moves to done.
|
|
173
|
+
// RECONSTRUCTION, not a live claim (#8068): a ✅ bullet records what the predecessor reported
|
|
174
|
+
// done, so the arrival-in-done gate must not fire on this path — CONTRACT-state.md, "Derive".
|
|
175
|
+
// Credit is still withheld (verified:false everywhere), so the successor re-earns evidence
|
|
176
|
+
// before anything NEW moves to done.
|
|
178
177
|
reconstructing: true,
|
|
179
178
|
};
|
|
180
179
|
|
package/lib/state/driver.mjs
CHANGED
|
@@ -277,12 +277,10 @@ export async function runStep({
|
|
|
277
277
|
// Per attempt, not per step (#7226): a cut on the first attempt belongs to that attempt's row.
|
|
278
278
|
let stepCut = cut === true;
|
|
279
279
|
|
|
280
|
-
// §7.3's
|
|
281
|
-
// as the observation
|
|
282
|
-
//
|
|
283
|
-
//
|
|
284
|
-
// a malformed-output retry re-runs the CLI, and commits the first attempt already made are still
|
|
285
|
-
// this step's work — re-reading HEAD per attempt would silently drop them.
|
|
280
|
+
// Two seams, both in CONTRACT-state.md: (1) §7.3's ladder — a MALFORMED patch earns exactly one
|
|
281
|
+
// retry with the rejection as the observation; an EVIDENCE rejection is not retried in-turn,
|
|
282
|
+
// the code was wrong, not the grammar (#8068). (2) HEAD captured ONCE, outside the retry loop:
|
|
283
|
+
// the first attempt's commits are still this step's work (#8069).
|
|
286
284
|
const headAtStart = (D.gitHead || gitHead)(cwd);
|
|
287
285
|
|
|
288
286
|
for (;;) {
|
package/lib/state/store.mjs
CHANGED
|
@@ -91,12 +91,9 @@ export function gitHead(cwd) {
|
|
|
91
91
|
return /^[0-9a-f]{40}$/.test(sha) ? sha : null;
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
-
/** Paths changed by the COMMITS made since `sha`, NUL-separated (#8069). The
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
* corner case: every seat is told to commit early and often so a cut cannot erase it, which made tier 1
|
|
98
|
-
* blind on the ordinary flow. A card that rewrote 8,000 lines across 4 commits recorded files:{}.
|
|
99
|
-
* Degrades to [] on any git error, exactly like the other readers — recovery never throws. */
|
|
94
|
+
/** Paths changed by the COMMITS made since `sha`, NUL-separated (#8069). The §4.8 status/diff pair
|
|
95
|
+
* measures the tree against HEAD, so a seat that commits (the ordinary flow) reads empty from
|
|
96
|
+
* both — CONTRACT-state.md, "Tier 1". Degrades to [] on any git error, like the other readers. */
|
|
100
97
|
export function gitCommittedSince(sha, cwd) {
|
|
101
98
|
if (!sha || !cwd) return [];
|
|
102
99
|
const out = git(["diff", "--name-only", "-z", `${sha}..HEAD`], cwd);
|
package/lib/state/validate.mjs
CHANGED
|
@@ -49,21 +49,16 @@ export function hasEvidence(state, item, ctx = {}) {
|
|
|
49
49
|
return paths.every(p => ({ ...(state.files[p] || {}), ...(ctxFiles[p] || {}) }).verified === true);
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
-
/** The evidence gate for an item ARRIVING in `done`, by whichever op put it there (#8068). Returns
|
|
53
|
-
* rejection, or null when the item may land. `move → done`
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
* only thing that differs, so the seat's rejection still names the op it actually wrote. */
|
|
52
|
+
/** The evidence gate for an item ARRIVING in `done`, by whichever op put it there (#8068). Returns
|
|
53
|
+
* the rejection, or null when the item may land. Both doors — `move → done` and
|
|
54
|
+
* `add … list:"done"` — lead through this one check (CONTRACT-state.md, "Schema and validation");
|
|
55
|
+
* only `at` differs, so the rejection names the op the seat actually wrote. */
|
|
57
56
|
function arrivalInDone(state, item, ctx, at) {
|
|
58
57
|
if (!item || hasEvidence(state, item, ctx)) return null;
|
|
59
|
-
// The one exception
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
// CONTRACT-state.md, "a derived state carries no credit … the successor must re-earn its evidence
|
|
64
|
-
// before anything moves to done". The items arrive uncredited, so the successor still cannot ride
|
|
65
|
-
// route (b) on them. `ctx` is harness-supplied and a patch cannot write it (§3.0 write matrix),
|
|
66
|
-
// so a seat cannot set this for itself — which is the only reason a flag is safe here at all.
|
|
58
|
+
// The one exception is RECONSTRUCTION, not assertion (#8068): derive.mjs's ✅ bullets are records
|
|
59
|
+
// of what the predecessor reported done, and gating them would delete the handoff's content
|
|
60
|
+
// (#6528's failure). The skipped rule is kept another way (CONTRACT-state.md, "Derive"); ctx is
|
|
61
|
+
// harness-supplied, so a seat cannot set this flag for itself.
|
|
67
62
|
if (ctx.reconstructing === true) return null;
|
|
68
63
|
// The split that makes §4.8's one-retry bound a fact: a gate that ran and failed looks
|
|
69
64
|
// exactly like a gate that never ran, unless the driver says which happened.
|
package/lib/turn-policy.mjs
CHANGED
|
@@ -207,8 +207,9 @@ export function quotaResetAt(rows, now = Date.now()) {
|
|
|
207
207
|
}
|
|
208
208
|
|
|
209
209
|
/// Seats that park rather than retry. A backend error is the provider having a bad minute and the
|
|
210
|
-
/// ladder is exactly right for it; a spent plan
|
|
211
|
-
|
|
210
|
+
/// ladder is exactly right for it; a spent plan, a rejected key, or a provider-side key rejection
|
|
211
|
+
/// (#8778: the chatgpt-backend 401 on a seat whose login is valid) will not fix itself on a timer.
|
|
212
|
+
export const PARKING_REASONS = new Set(["exhausted", "auth", "provider-rejected"]);
|
|
212
213
|
|
|
213
214
|
/// #6228: the sender's home project, by the same "name suffix after the last colon" convention
|
|
214
215
|
/// every crew/orch identity is minted with (isRunnerSession in crew-runner.mjs uses the mirror
|
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 })
|