trantor 0.18.82 → 0.18.85
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/cli.mjs +2 -0
- package/bin/crew/state.mjs +17 -2
- package/bin/crew-runner.mjs +142 -31
- package/bin/cut-snapshot.mjs +37 -0
- package/bin/gate.mjs +30 -0
- package/bin/turn-watchdog.mjs +14 -9
- package/bin/wake-nudge.mjs +21 -2
- package/hooks/file-claim.mjs +19 -7
- package/hooks/file-hold.mjs +49 -0
- package/hooks/heartbeat.mjs +2 -1
- package/hooks/hooks.json +10 -1
- package/hooks/lib/git-checkout.mjs +10 -0
- package/hooks/lib/overseer-level-cache.mjs +25 -0
- package/hooks/overseer-warn.mjs +1 -1
- package/hub/auth.mjs +6 -51
- package/hub/overseer.mjs +100 -34
- package/hub/routes/admin.mjs +58 -12
- package/hub/store.mjs +3 -2
- package/lib/duty-nudges.mjs +12 -0
- package/lib/nudge-ledger.mjs +95 -0
- package/lib/overseer.mjs +82 -24
- package/lib/remote-overlap.mjs +193 -0
- package/lib/seat-why.mjs +6 -1
- package/lib/session-liveness.mjs +81 -0
- package/lib/store-pg.mjs +2 -0
- package/package.json +1 -1
package/hub/routes/admin.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { checkoutKey, collisionIdentity, levelFor } from "../../lib/overseer.mjs";
|
|
1
2
|
/* oxlint-disable anti-slop/no-runtime-typeof -- SAFETY: These checks preserve the established request and provider-response compatibility boundary during a route-only extraction. */
|
|
2
3
|
import { readFileSync } from "node:fs";
|
|
3
4
|
import { homedir } from "node:os";
|
|
@@ -7,6 +8,19 @@ import { join } from "node:path";
|
|
|
7
8
|
// consume a claim's first-touch (or vice versa), and /claims must never list reads. #7972.
|
|
8
9
|
const fileReads = new Map();
|
|
9
10
|
|
|
11
|
+
function remoteEvidence(value, file) {
|
|
12
|
+
if (!Array.isArray(value)) return [];
|
|
13
|
+
return value.slice(0, 25).filter(row =>
|
|
14
|
+
row && typeof row.repo === "string" && /^[\w.-]+\/[\w.-]+$/.test(row.repo) && row.repo.length <= 200 &&
|
|
15
|
+
Number.isSafeInteger(row.number) && row.number > 0 &&
|
|
16
|
+
typeof row.author === "string" && row.author.length > 0 && row.author.length <= 120 &&
|
|
17
|
+
typeof row.url === "string" && row.url.length <= 2000 &&
|
|
18
|
+
(row.url === "" || /^https:\/\/[^\s]+$/.test(row.url)) &&
|
|
19
|
+
Array.isArray(row.files) && row.files.includes(file)
|
|
20
|
+
).map(row => ({ repo: row.repo.toLowerCase(), number: row.number, author: row.author,
|
|
21
|
+
url: row.url, files: [file] }));
|
|
22
|
+
}
|
|
23
|
+
|
|
10
24
|
export async function routeAdmin({ req, res, q, P, auth, ctx }) {
|
|
11
25
|
const {
|
|
12
26
|
state, body, json, crossProjectGuard, touch, canon, filterReadable,
|
|
@@ -23,6 +37,13 @@ export async function routeAdmin({ req, res, q, P, auth, ctx }) {
|
|
|
23
37
|
// WHO is this, really: the LLM brand + the exact model currently loaded. In-memory like the
|
|
24
38
|
// rest of presence — the next heartbeat re-supplies it after a restart.
|
|
25
39
|
const pr = state.peers[b.session];
|
|
40
|
+
if (pr && b.gitRoot) {
|
|
41
|
+
pr.gitRoot = String(b.gitRoot).slice(0, 2000);
|
|
42
|
+
state.overseerState ??= {};
|
|
43
|
+
state.overseerState.roots ??= {};
|
|
44
|
+
state.overseerState.roots[b.session] = pr.gitRoot;
|
|
45
|
+
markDirty();
|
|
46
|
+
}
|
|
26
47
|
// #6148: WHAT a session is rides its peer row (kind "genesis" = the CLI's brief-poster,
|
|
27
48
|
// "agent" = a crew seat) — /peers hands it to the app so the seat strip can tell them apart.
|
|
28
49
|
if (pr) { if (b.model) pr.model = String(b.model).slice(0, 80); if (b.llm) pr.llm = String(b.llm).slice(0, 40); if (b.kind) pr.kind = String(b.kind).slice(0, 40); }
|
|
@@ -194,7 +215,7 @@ export async function routeAdmin({ req, res, q, P, auth, ctx }) {
|
|
|
194
215
|
const proj = canon(String(q.project || "").slice(0, 80));
|
|
195
216
|
if (!proj) return json(res, 400, { error: "project required" });
|
|
196
217
|
const pol = overseerPolicy();
|
|
197
|
-
const level = overseer.engine?.levelFor ? overseer.engine.levelFor(proj, pol.autonomy) : (pol.autonomy[proj] ?? pol.autonomy["*"] ??
|
|
218
|
+
const level = overseer.engine?.levelFor ? overseer.engine.levelFor(proj, pol.autonomy) : (pol.autonomy[proj] ?? pol.autonomy["*"] ?? 2);
|
|
198
219
|
const links = pol.links.filter(l => (l.projects || []).includes(proj));
|
|
199
220
|
const linked = new Set(links.flatMap(l => l.projects).filter(x => x !== proj));
|
|
200
221
|
const cutoff = now() - ONLINE_MS;
|
|
@@ -207,7 +228,7 @@ export async function routeAdmin({ req, res, q, P, auth, ctx }) {
|
|
|
207
228
|
let warnings = [];
|
|
208
229
|
try {
|
|
209
230
|
warnings = (overseer.engine?.detectCollisions ? overseer.engine.detectCollisions(overseerInputs()) : [])
|
|
210
|
-
.filter(c => c.project === proj || linked.has(c.project));
|
|
231
|
+
.filter(c => c.project === proj || c.projects?.includes(proj) || linked.has(c.project));
|
|
211
232
|
// #5760: a declared crew is the NORMAL state of a project, not a collision — the tick
|
|
212
233
|
// loop drops crew-only sets before they ever become episodes, and this live view must
|
|
213
234
|
// agree with it: the SessionStart hook narrates exactly these lines, so a crew-only leak
|
|
@@ -215,14 +236,14 @@ export async function routeAdmin({ req, res, q, P, auth, ctx }) {
|
|
|
215
236
|
warnings = warnings.filter(c => !(c.kind === "same-project-sessions" && overseer.sameProject?.sameProjectDecision &&
|
|
216
237
|
overseer.sameProject.sameProjectDecision({
|
|
217
238
|
current: c.sessions,
|
|
218
|
-
declaredCrew: declaredCrewFor(c.project),
|
|
239
|
+
declaredCrew: declaredCrewFor(c.project, c.gitRoot),
|
|
219
240
|
now: now(),
|
|
220
241
|
}).reason === "crew-only"));
|
|
221
242
|
// The record line reports DURATION ("standing for 6h"), never a count of warnings. Episode
|
|
222
243
|
// identity is project+kind+files, exactly as the tick loop keys it, so this view says the
|
|
223
244
|
// same thing the warning did for every kind, not just same-project (#7029).
|
|
224
245
|
for (const c of warnings) {
|
|
225
|
-
const ep = overseer.active.get(
|
|
246
|
+
const ep = overseer.active.get(c.kind === "same-project-sessions" ? `${checkoutKey(c)} ${c.kind}` : collisionIdentity(c));
|
|
226
247
|
if (!ep) continue;
|
|
227
248
|
c.since = ep.since;
|
|
228
249
|
const label = c.kind === "same-project-sessions" ? "same-project" : "standing";
|
|
@@ -269,25 +290,50 @@ export async function routeAdmin({ req, res, q, P, auth, ctx }) {
|
|
|
269
290
|
markDirty();
|
|
270
291
|
return json(res, 200, { ok: true });
|
|
271
292
|
}
|
|
272
|
-
if (req.method === "
|
|
293
|
+
if (req.method === "GET" && P === "/holds") {
|
|
294
|
+
const rows = overseer.listHolds().filter(h => (!q.project || h.project === canon(q.project)) && (!q.status || h.status === q.status));
|
|
295
|
+
return json(res, 200, { holds: filterReadable(auth, rows, h => h.project) });
|
|
296
|
+
}
|
|
297
|
+
if (req.method === "POST" && P === "/hold/decide") {
|
|
298
|
+
const b = await body(req);
|
|
299
|
+
const hold = overseer.listHolds().find(h => h.id === Number(b.id));
|
|
300
|
+
if (!hold) return json(res, 404, { error: "hold expired or not found" });
|
|
301
|
+
if (AUTH_MODE !== "off" && !scopeAllows(auth?.identity, hold.project, "owner")) return json(res, 403, { error: "operator permission required" });
|
|
302
|
+
if (b.status !== "go" && b.status !== "nogo") return json(res, 400, { error: "status must be go or nogo" });
|
|
303
|
+
const decided = overseer.decideHold(hold.id, b.status, auth?.identity?.name || "operator");
|
|
304
|
+
return decided ? json(res, 200, { ok: true, hold: decided }) : json(res, 409, { error: "hold already decided" });
|
|
305
|
+
}
|
|
306
|
+
if (req.method === "POST" && (P === "/claim" || P === "/hold/check")) {
|
|
273
307
|
const b = await body(req);
|
|
274
308
|
const proj = canon(String(b.project || "").slice(0, 80));
|
|
275
309
|
const file = String(b.file || "").slice(0, 400);
|
|
276
310
|
const session = String(b.session || "").slice(0, 120);
|
|
277
311
|
if (!proj || !file || !session) return json(res, 400, { error: "project, file and session required" });
|
|
312
|
+
if (auth?.identity && session !== auth.identity.name) return json(res, 403, { error: "session must match signer" });
|
|
313
|
+
const gitRoot = String(b.gitRoot || state.peers[session]?.gitRoot || state.overseerState?.roots?.[session] || "").slice(0, 2000);
|
|
278
314
|
pruneClaims();
|
|
279
|
-
const key = `${proj}
|
|
315
|
+
const key = `${checkoutKey({ project: proj, gitRoot })}\u0000${file}\u0000${session}`;
|
|
280
316
|
const mine = fileClaims.get(key);
|
|
281
317
|
const conflicts = [...fileClaims.values()]
|
|
282
|
-
.filter(c => c
|
|
318
|
+
.filter(c => checkoutKey(c) === checkoutKey({ project: proj, gitRoot }) && c.file === file && c.session !== session)
|
|
283
319
|
.map(c => ({ session: c.session, ts: c.ts, agoSec: Math.round((now() - c.ts) / 1000) }));
|
|
284
|
-
|
|
320
|
+
const remote = P === "/hold/check" && b.remote === undefined
|
|
321
|
+
? (mine?.remote || []) : remoteEvidence(b.remote, file);
|
|
322
|
+
fileClaims.set(key, { project: proj, file, session, gitRoot, ts: now(), remote });
|
|
285
323
|
touch(session, undefined, undefined, undefined, auth);
|
|
286
|
-
|
|
287
|
-
|
|
324
|
+
if (gitRoot) {
|
|
325
|
+
state.peers[session].gitRoot = gitRoot;
|
|
326
|
+
state.overseerState ??= {};
|
|
327
|
+
state.overseerState.roots ??= {};
|
|
328
|
+
state.overseerState.roots[session] = gitRoot;
|
|
329
|
+
}
|
|
330
|
+
// Repeated checks refresh the claim without repeating its conflict event.
|
|
288
331
|
if (!mine) appendEvent("file.claim", proj, session, { file });
|
|
289
|
-
if (conflicts.length) appendEvent("file.conflict", proj, session, { file, with: conflicts.map(c => c.session) });
|
|
290
|
-
|
|
332
|
+
if (!mine && conflicts.length) appendEvent("file.conflict", proj, session, { file, with: conflicts.map(c => c.session) });
|
|
333
|
+
overseer.overseerTick();
|
|
334
|
+
const hold = overseer.holdFor(proj, file, session, gitRoot);
|
|
335
|
+
const level = levelFor(proj, overseer.overseerPolicy().autonomy);
|
|
336
|
+
return json(res, 200, { ok: true, conflicts: level >= 2 ? conflicts : [], level, hold, ttlMs: CLAIM_TTL_MS });
|
|
291
337
|
}
|
|
292
338
|
if (req.method === "GET" && P === "/claims") {
|
|
293
339
|
pruneClaims();
|
package/hub/store.mjs
CHANGED
|
@@ -6,7 +6,7 @@ import { DEFAULT_ORG } from "../lib/store-contract.mjs";
|
|
|
6
6
|
|
|
7
7
|
export async function createStoreRuntime({ STORE_KIND, PG_URL, ORG_ID, DATA }) {
|
|
8
8
|
function emptyState() {
|
|
9
|
-
return { messages: [], peers: {}, seq: 0, tasks: [], taskSeq: 0, projectMeta: {}, lessons: [], events: [], cardEventsBackfilled: false, aliases: {}, phaseMeta: {}, verifyGates: [], verifyGateSeq: 0, proposals: [], proposalSeq: 0, balances: { ts: 0, by: "", entries: [] }, subagentCostReset: false, handoffLog: [], identities: {}, inviteTokens: {}, focus: {}, orgPolicy: {}, instances: {}, dutySession: "", contractReap: {}, eventSeq: 0 };
|
|
9
|
+
return { messages: [], peers: {}, seq: 0, tasks: [], taskSeq: 0, projectMeta: {}, lessons: [], events: [], cardEventsBackfilled: false, aliases: {}, phaseMeta: {}, verifyGates: [], verifyGateSeq: 0, proposals: [], proposalSeq: 0, balances: { ts: 0, by: "", entries: [] }, subagentCostReset: false, handoffLog: [], identities: {}, inviteTokens: {}, focus: {}, orgPolicy: {}, overseerState: {}, instances: {}, dutySession: "", contractReap: {}, eventSeq: 0 };
|
|
10
10
|
}
|
|
11
11
|
|
|
12
12
|
const CARD_LOG_MAX = 40;
|
|
@@ -110,6 +110,7 @@ function normalizeState(loaded = {}) {
|
|
|
110
110
|
s.instances = loaded.instances && typeof loaded.instances === "object" ? loaded.instances : {};
|
|
111
111
|
s.focus = loaded.focus && typeof loaded.focus === "object" ? loaded.focus : {};
|
|
112
112
|
s.orgPolicy = loaded.orgPolicy && typeof loaded.orgPolicy === "object" ? loaded.orgPolicy : {};
|
|
113
|
+
s.overseerState = loaded.overseerState || {};
|
|
113
114
|
s.dutySession = String(loaded.dutySession || "");
|
|
114
115
|
s.contractReap = loaded.contractReap && typeof loaded.contractReap === "object" ? loaded.contractReap : {};
|
|
115
116
|
// The event id high-water mark. Seeded from the store's own MAX(id) where it supplied one, and
|
|
@@ -127,7 +128,7 @@ function normalizeState(loaded = {}) {
|
|
|
127
128
|
// #6170: `kind` rides the load. This normalizer rebuilds every peer from an explicit field
|
|
128
129
|
// list, so a field missing here is dropped however faithfully the store returned it (the kinds
|
|
129
130
|
// came back empty on the first live restart). llm/model stay out: they are in-memory presence.
|
|
130
|
-
: { lastSeen: v.lastSeen || 0, status: v.status || "", project: v.project || "", pubkey: v.pubkey || "", identity: v.identity || null, authWarning: v.authWarning || "", hookVersion: v.hookVersion || "", kind: v.kind || "", deliveredUpTo: v.deliveredUpTo || v.delivered_up_to || 0, _on: v._on === true || v.online === true };
|
|
131
|
+
: { lastSeen: v.lastSeen || 0, status: v.status || "", project: v.project || "", pubkey: v.pubkey || "", identity: v.identity || null, authWarning: v.authWarning || "", hookVersion: v.hookVersion || "", kind: v.kind || "", gitRoot: v.gitRoot || loaded.overseerState?.roots?.[session] || "", deliveredUpTo: v.deliveredUpTo || v.delivered_up_to || 0, _on: v._on === true || v.online === true };
|
|
131
132
|
}
|
|
132
133
|
return s;
|
|
133
134
|
}
|
package/lib/duty-nudges.mjs
CHANGED
|
@@ -283,6 +283,18 @@ export async function recordDutyNudges({ plan, observedIds, statePath, now = Dat
|
|
|
283
283
|
return nudged;
|
|
284
284
|
}
|
|
285
285
|
|
|
286
|
+
// #11355: an id the shared pre-send ledger awarded to the OTHER nudger must not keep this path's
|
|
287
|
+
// planned claim — it would block the winner's own re-claim machinery for up to 30 minutes.
|
|
288
|
+
export async function releaseDutyNudgeClaims({ ids, statePath, owner }) {
|
|
289
|
+
const wanted = [...new Set((ids || []).map(String).filter(Boolean))];
|
|
290
|
+
if (!wanted.length) return;
|
|
291
|
+
await withStateLock(statePath, state => {
|
|
292
|
+
for (const id of wanted) {
|
|
293
|
+
if (state.planned[id]?.owner === owner) delete state.planned[id];
|
|
294
|
+
}
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
|
|
286
298
|
export async function auditDutyNudges({ plan, observedIds, statePath, reportFailure, now = Date.now() }) {
|
|
287
299
|
const nudged = await recordDutyNudges({ plan, observedIds, statePath, now });
|
|
288
300
|
// #7429: the audit reads the ledger — an id the mechanical wake already verified (or a terminal
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// #11355: ONE shared nudged-id ledger for BOTH orchestrator-wake nudgers — the mechanical
|
|
2
|
+
// com.trantor.wake-nudge daemon and the duty seat's runner-side nudge plan. Each path checks and
|
|
3
|
+
// stamps BEFORE sending; of two nudgers offered the same unread id exactly one send lands, the
|
|
4
|
+
// other logs "already nudged #N". Per-recipient file, atomic check-and-stamp under a lock.
|
|
5
|
+
import { closeSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { busDir } from "./project.mjs";
|
|
8
|
+
|
|
9
|
+
// While an id is nudged under this window it is suppressed; past it the stamp is pruned and a
|
|
10
|
+
// still-unread id is nudgeable again.
|
|
11
|
+
export const NUDGE_LEDGER_TTL_MS = 10 * 60 * 1000;
|
|
12
|
+
|
|
13
|
+
export function nudgeLedgerPath(recipient, { bus = busDir() } = {}) {
|
|
14
|
+
const safe = String(recipient || "").replace(/[^A-Za-z0-9_.-]/g, "_");
|
|
15
|
+
return join(bus, `nudged-${safe}.json`);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function readLedger(path) {
|
|
19
|
+
try {
|
|
20
|
+
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
21
|
+
if (!(parsed?.nudged instanceof Object) || Array.isArray(parsed.nudged)) throw new Error("bad shape");
|
|
22
|
+
return parsed;
|
|
23
|
+
} catch {
|
|
24
|
+
return { version: 1, nudged: {} };
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function writeLedger(path, state) {
|
|
29
|
+
const temporary = `${path}.${process.pid}.tmp`;
|
|
30
|
+
writeFileSync(temporary, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600 });
|
|
31
|
+
renameSync(temporary, path);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function withLedgerLock(path, update) {
|
|
35
|
+
const lockPath = `${path}.lock`;
|
|
36
|
+
let lock = null;
|
|
37
|
+
for (let attempt = 0; attempt < 100 && lock === null; attempt++) {
|
|
38
|
+
try { lock = openSync(lockPath, "wx", 0o600); }
|
|
39
|
+
catch { await new Promise(resolve => setTimeout(resolve, 10)); }
|
|
40
|
+
}
|
|
41
|
+
if (lock === null) throw new Error(`could not lock ${path}`);
|
|
42
|
+
try {
|
|
43
|
+
const state = readLedger(path);
|
|
44
|
+
await update(state);
|
|
45
|
+
writeLedger(path, state);
|
|
46
|
+
} finally {
|
|
47
|
+
closeSync(lock);
|
|
48
|
+
try { unlinkSync(lockPath); } catch {}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Check-and-stamp BEFORE sending: ids stamped under the TTL come back skipped (the caller logs
|
|
53
|
+
// "already nudged #N"), the rest are stamped for `owner`. One locked step, so of two concurrent
|
|
54
|
+
// nudgers exactly one wins.
|
|
55
|
+
export async function reserveNudgeIds({
|
|
56
|
+
recipient, ids, owner, now = Date.now(), ttlMs = NUDGE_LEDGER_TTL_MS, bus, ledgerPath, log = (m) => console.error(m),
|
|
57
|
+
} = {}) {
|
|
58
|
+
const wanted = [...new Set((ids || []).map(id => String(id ?? "").trim()).filter(Boolean))];
|
|
59
|
+
if (!recipient || !wanted.length) return { allowed: [], skipped: [] };
|
|
60
|
+
// ledgerPath is resolved LAZILY from `bus`: a default parameter would bind busDir() at call
|
|
61
|
+
// time and silently ignore the caller's bus override.
|
|
62
|
+
const path = ledgerPath || nudgeLedgerPath(recipient, { bus });
|
|
63
|
+
const allowed = [], skipped = [];
|
|
64
|
+
await withLedgerLock(path, state => {
|
|
65
|
+
for (const id of wanted) {
|
|
66
|
+
const at = Number(state.nudged[id]?.at || 0);
|
|
67
|
+
if (at > 0 && now - at < ttlMs) skipped.push({ id, reason: `already nudged #${id}` });
|
|
68
|
+
else {
|
|
69
|
+
state.nudged[id] = { at: now, by: String(owner || "") };
|
|
70
|
+
allowed.push(id);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
for (const [id, v] of Object.entries(state.nudged)) {
|
|
74
|
+
if (now - Number(v?.at || 0) >= ttlMs) delete state.nudged[id];
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
for (const s of skipped) log(s.reason);
|
|
78
|
+
return { allowed, skipped };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Drop stamps for ids whose send ended UNVERIFIED — the ledger mirrors the duty-claim lifecycle
|
|
82
|
+
// (auditDutyNudges releases unverified claims), so a failed or unconfirmed send never suppresses
|
|
83
|
+
// the other nudger. With `owner`, only that path's own stamps are dropped.
|
|
84
|
+
export async function releaseNudgeIds({ recipient, ids, owner, bus, ledgerPath, now = Date.now(), ttlMs = NUDGE_LEDGER_TTL_MS } = {}) {
|
|
85
|
+
const wanted = [...new Set((ids || []).map(id => String(id ?? "").trim()).filter(Boolean))];
|
|
86
|
+
if (!recipient || !wanted.length) return;
|
|
87
|
+
const path = ledgerPath || nudgeLedgerPath(recipient, { bus });
|
|
88
|
+
await withLedgerLock(path, state => {
|
|
89
|
+
for (const id of wanted) {
|
|
90
|
+
const at = Number(state.nudged[id]?.at || 0);
|
|
91
|
+
const own = !owner || state.nudged[id]?.by === String(owner);
|
|
92
|
+
if (at > 0 && now - at < ttlMs && own) delete state.nudged[id];
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
}
|
package/lib/overseer.mjs
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// so the episode-recurrence path was untestable and therefore unproven.
|
|
4
4
|
const PEER_LIVE_MS = Number(process.env.RELAY_OVERSEER_PEER_LIVE_MS || 5 * 60 * 1000);
|
|
5
5
|
const CLAIM_LIVE_MS = Number(process.env.RELAY_OVERSEER_CLAIM_LIVE_MS || 10 * 60 * 1000);
|
|
6
|
-
const KINDS = new Set(["same-project-sessions", "file-conflict", "linked-activity"]);
|
|
6
|
+
const KINDS = new Set(["same-project-sessions", "file-conflict", "linked-activity", "remote-overlap"]);
|
|
7
7
|
// A card in `todo` is queued, not held; `done`/`failed`/`blocked` are nobody's hands. Only these
|
|
8
8
|
// two mean a session has it open right now.
|
|
9
9
|
const HELD_STATUSES = new Set(["doing", "testing"]);
|
|
@@ -21,7 +21,19 @@ export function levelFor(project, autonomy = {}) {
|
|
|
21
21
|
const direct = autonomy?.[key];
|
|
22
22
|
if (isLevel(direct)) return direct;
|
|
23
23
|
const fallback = autonomy?.["*"];
|
|
24
|
-
return isLevel(fallback) ? fallback :
|
|
24
|
+
return isLevel(fallback) ? fallback : 2;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function checkoutKey({ project, gitRoot }) {
|
|
28
|
+
return JSON.stringify(gitRoot ? ["checkout", gitRoot] : ["project", project]);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function collisionIdentity(collision) {
|
|
32
|
+
// #11311: remote episodes are PROJECT/repo/PR/file per the orchestrator's spec — project, not
|
|
33
|
+
// checkoutKey, so one gitRoot shared across projects never merges two projects' remote episodes.
|
|
34
|
+
const base = collision.kind === "remote-overlap" ? clean(collision.project) : checkoutKey(collision);
|
|
35
|
+
const remote = collision.kind === "remote-overlap" ? ` ${clean(collision.repo)}#${finiteNumber(collision.pr) ?? ""}` : "";
|
|
36
|
+
return `${base} ${collision.kind} ${(collision.files || []).join(",")}${remote}`;
|
|
25
37
|
}
|
|
26
38
|
|
|
27
39
|
function isFresh(ts, now, ttl) {
|
|
@@ -35,13 +47,17 @@ function sortedStrings(items) {
|
|
|
35
47
|
|
|
36
48
|
function pushCollision(out, collision) {
|
|
37
49
|
if (!collision.project || !KINDS.has(collision.kind) || collision.sessions.length === 0) return;
|
|
38
|
-
|
|
50
|
+
const row = {
|
|
39
51
|
project: collision.project,
|
|
40
52
|
kind: collision.kind,
|
|
41
53
|
sessions: sortedStrings(collision.sessions),
|
|
42
54
|
files: sortedStrings(collision.files ?? []),
|
|
43
55
|
detail: collision.detail,
|
|
44
|
-
}
|
|
56
|
+
};
|
|
57
|
+
if (collision.gitRoot) { row.gitRoot = collision.gitRoot; row.projects = collision.projects; }
|
|
58
|
+
// (repo, PR number, file) is the remote-overlap episode identity — collisionIdentity appends it.
|
|
59
|
+
if (collision.repo) { row.repo = clean(collision.repo); row.pr = finiteNumber(collision.pr); }
|
|
60
|
+
out.push(row);
|
|
45
61
|
}
|
|
46
62
|
|
|
47
63
|
function collisionKey(c) {
|
|
@@ -80,22 +96,22 @@ export function detectCollisions({ peers = [], claims = [], links = [], cards =
|
|
|
80
96
|
livePeers.push({ ...peer, session, project });
|
|
81
97
|
}
|
|
82
98
|
|
|
83
|
-
const
|
|
99
|
+
const sessionsByCheckout = new Map();
|
|
84
100
|
for (const peer of livePeers) {
|
|
85
|
-
const
|
|
86
|
-
|
|
87
|
-
|
|
101
|
+
const key = checkoutKey(peer);
|
|
102
|
+
const group = sessionsByCheckout.get(key) ?? [];
|
|
103
|
+
group.push(peer);
|
|
104
|
+
sessionsByCheckout.set(key, group);
|
|
88
105
|
}
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
const sessions = sortedStrings(sessionsByProject.get(project));
|
|
106
|
+
for (const group of sessionsByCheckout.values()) {
|
|
107
|
+
const sessions = sortedStrings(group.map(p => p.session));
|
|
92
108
|
if (sessions.length < 2) continue;
|
|
109
|
+
const projects = sortedStrings(group.map(p => p.project));
|
|
110
|
+
const gitRoot = clean(group[0].gitRoot);
|
|
93
111
|
pushCollision(out, {
|
|
94
|
-
project,
|
|
95
|
-
kind: "same-project-sessions",
|
|
96
|
-
sessions,
|
|
97
|
-
files: [],
|
|
98
|
-
detail: `${sessions.join(", ")} are live on project ${project}.`,
|
|
112
|
+
project: projects[0], projects, gitRoot,
|
|
113
|
+
kind: "same-project-sessions", sessions, files: [],
|
|
114
|
+
detail: `${sessions.join(", ")} are live on ${gitRoot ? `checkout ${gitRoot}` : `project ${projects[0]}`}.`,
|
|
99
115
|
});
|
|
100
116
|
}
|
|
101
117
|
|
|
@@ -105,30 +121,36 @@ export function detectCollisions({ peers = [], claims = [], links = [], cards =
|
|
|
105
121
|
// file — the cross-project overlap that claimSessionsByFile, keyed on project+file, structurally
|
|
106
122
|
// cannot see. linked-activity reads this one.
|
|
107
123
|
const claimProjectsByPath = new Map();
|
|
124
|
+
const liveClaims = [];
|
|
108
125
|
for (const claim of asArray(claims)) {
|
|
109
126
|
const project = clean(claim?.project);
|
|
110
127
|
const file = clean(claim?.file);
|
|
111
128
|
const session = clean(claim?.session);
|
|
112
129
|
if (!project || !file || !session || !isFresh(claim?.ts, at, CLAIM_LIVE_MS)) continue;
|
|
113
|
-
const
|
|
114
|
-
const
|
|
115
|
-
|
|
116
|
-
claimSessionsByFile.
|
|
130
|
+
const gitRoot = clean(claim.gitRoot || livePeers.find(p => p.session === session)?.gitRoot);
|
|
131
|
+
const key = JSON.stringify([checkoutKey({ project, gitRoot }), file]);
|
|
132
|
+
liveClaims.push({ project, file, session, gitRoot, key, remote: claim.remote });
|
|
133
|
+
const group = claimSessionsByFile.get(key) ?? { gitRoot, file, sessions: new Set(), projects: new Set() };
|
|
134
|
+
group.sessions.add(session);
|
|
135
|
+
group.projects.add(project);
|
|
136
|
+
claimSessionsByFile.set(key, group);
|
|
117
137
|
const byProject = claimProjectsByPath.get(file) ?? new Map();
|
|
118
138
|
byProject.set(project, (byProject.get(project) ?? new Set()).add(session));
|
|
119
139
|
claimProjectsByPath.set(file, byProject);
|
|
120
140
|
}
|
|
121
141
|
|
|
122
142
|
for (const key of [...claimSessionsByFile.keys()].sort()) {
|
|
123
|
-
const
|
|
124
|
-
const
|
|
143
|
+
const group = claimSessionsByFile.get(key);
|
|
144
|
+
const projects = sortedStrings(group.projects);
|
|
145
|
+
const project = projects[0], file = group.file;
|
|
146
|
+
const sessions = sortedStrings(group.sessions);
|
|
125
147
|
if (sessions.length < 2) continue;
|
|
126
148
|
pushCollision(out, {
|
|
127
149
|
project,
|
|
128
|
-
kind: "file-conflict",
|
|
150
|
+
kind: "file-conflict", gitRoot: group.gitRoot, projects,
|
|
129
151
|
sessions,
|
|
130
152
|
files: [file],
|
|
131
|
-
detail: `${sessions.join(", ")} have live claims on ${project}/${file}.`,
|
|
153
|
+
detail: `${sessions.join(", ")} have live claims on ${group.gitRoot || project}/${file}.`,
|
|
132
154
|
});
|
|
133
155
|
}
|
|
134
156
|
|
|
@@ -191,6 +213,42 @@ export function detectCollisions({ peers = [], claims = [], links = [], cards =
|
|
|
191
213
|
});
|
|
192
214
|
}
|
|
193
215
|
|
|
216
|
+
// #11311: open-PR evidence is computed CLIENT-SIDE (hooks/file-claim.mjs polls the claimant's
|
|
217
|
+
// own checkout via lib/remote-overlap.mjs) and rides the claim as its `remote` field — repo- and
|
|
218
|
+
// checkout-scoped by construction, one collision per (repo, PR, file) triple, warn-level, gone
|
|
219
|
+
// when the PR closes. That triple IS the episode identity (collisionIdentity appends repo#pr).
|
|
220
|
+
for (const claim of liveClaims) {
|
|
221
|
+
for (const r of asArray(claim.remote)) {
|
|
222
|
+
const pr = finiteNumber(r?.number);
|
|
223
|
+
const repo = clean(r?.repo);
|
|
224
|
+
if (pr == null || !repo || !repo.includes("/")) continue;
|
|
225
|
+
const author = clean(r?.author) || "unknown";
|
|
226
|
+
const url = clean(r?.url);
|
|
227
|
+
for (const file of sortedStrings(r.files)) {
|
|
228
|
+
if (file !== claim.file) continue;
|
|
229
|
+
// Recipients are EVIDENCE-MATCHED (codex hub case): within the same project+file, only
|
|
230
|
+
// claimants whose OWN remote carries this same repo+PR get warned — a checkout without
|
|
231
|
+
// that evidence is not a participant in a PR its repo does not have.
|
|
232
|
+
const sessions = sortedStrings(
|
|
233
|
+
liveClaims
|
|
234
|
+
.filter((c) => c.project === claim.project && c.file === claim.file
|
|
235
|
+
&& asArray(c.remote).some((x) => clean(x?.repo) === repo && finiteNumber(x?.number) === pr))
|
|
236
|
+
.map((c) => c.session)
|
|
237
|
+
);
|
|
238
|
+
pushCollision(out, {
|
|
239
|
+
project: claim.project,
|
|
240
|
+
kind: "remote-overlap",
|
|
241
|
+
gitRoot: claim.gitRoot,
|
|
242
|
+
sessions,
|
|
243
|
+
files: [file],
|
|
244
|
+
repo,
|
|
245
|
+
pr,
|
|
246
|
+
detail: `open PR #${pr} by ${author} in ${repo} also changes ${file}${url ? ` (${url})` : ""}.`,
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
194
252
|
const deduped = new Map();
|
|
195
253
|
for (const collision of sortCollisions(out)) deduped.set(collisionKey(collision), collision);
|
|
196
254
|
return [...deduped.values()];
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
// #11311: client-side open-PR overlap evidence. The session's OWN hook polls the repo it is
|
|
2
|
+
// actually editing (`git -C <gitRoot> remote get-url origin`) — never the hub, whose checkout and
|
|
3
|
+
// unauthenticated gh would match trantor's PRs to every project. Cached 5 min per repo in
|
|
4
|
+
// ~/.agent-bus/remote-overlap-<owner>-<name>.json; gh missing/unauthenticated = off, one log line.
|
|
5
|
+
import { execFile, spawnSync } from "node:child_process";
|
|
6
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import { homedir } from "node:os";
|
|
9
|
+
|
|
10
|
+
const POLL_TTL_MS = 5 * 60 * 1000;
|
|
11
|
+
const RATE_LIMIT_BACKOFF_MS = 10 * 60 * 1000;
|
|
12
|
+
const BLIP_BACKOFF_MS = 30 * 1000;
|
|
13
|
+
const GH_TIMEOUT_MS = Number(process.env.RELAY_REMOTE_OVERLAP_TIMEOUT_MS || 5000);
|
|
14
|
+
// Cap PRs per poll: each costs a /files call, and a busy repo must not eat the rate limit.
|
|
15
|
+
const PR_CAP = 25;
|
|
16
|
+
|
|
17
|
+
const GH_OFF = /gh auth login|not logged into|gh: auth|authentication/i;
|
|
18
|
+
const RATE_LIMIT = /rate limit/i;
|
|
19
|
+
|
|
20
|
+
// "owner/name" from every remote form in the wild — https, ssh, git://, with credentials or a
|
|
21
|
+
// .git suffix — or a bare owner/name that is already parsed. Anything else is not pollable.
|
|
22
|
+
export function parseRepoUrl(url) {
|
|
23
|
+
const raw = String(url ?? "").trim();
|
|
24
|
+
if (!raw) return null;
|
|
25
|
+
if (/^[\w.-]+\/[\w.-]+$/.test(raw)) return raw;
|
|
26
|
+
const m = raw.match(/^(?:(?:https?:\/\/|ssh:\/\/|git:\/\/)(?:[^@/]+@)?|git@)?[^/:@]+[:/](.+?)(?:\.git)?\/?$/i);
|
|
27
|
+
const parts = m ? m[1].replace(/\.git$/i, "").split("/").filter(Boolean) : [];
|
|
28
|
+
const owner = parts[parts.length - 2];
|
|
29
|
+
const name = parts[parts.length - 1];
|
|
30
|
+
return owner && name && /^[\w.-]+$/.test(owner) && /^[\w.-]+$/.test(name) ? `${owner}/${name}` : null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function defaultGitRun(args, { cwd } = {}) {
|
|
34
|
+
const r = spawnSync("git", args, { cwd, encoding: "utf8", timeout: 5000 });
|
|
35
|
+
return { status: r.status, stdout: r.stdout || "", stderr: r.stderr || "" };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function defaultGhRun(args) {
|
|
39
|
+
const r = spawnSync("gh", args, { encoding: "utf8", timeout: GH_TIMEOUT_MS, maxBuffer: 16 * 1024 * 1024 });
|
|
40
|
+
if (r.error && !r.error.message?.includes("exited with")) {
|
|
41
|
+
return { code: r.error.code ?? 1, stdout: "", stderr: String(r.stderr || r.error.message || "") };
|
|
42
|
+
}
|
|
43
|
+
return { code: r.status ?? 1, stdout: r.stdout || "", stderr: r.stderr || "" };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Repo detection: `git remote get-url origin` from the session's git root. Null when there is
|
|
47
|
+
// no origin or it is not a GitHub-ish remote — the poller stays quietly off.
|
|
48
|
+
export function detectRepo({ dir = process.cwd(), run = defaultGitRun } = {}) {
|
|
49
|
+
try {
|
|
50
|
+
const r = run(["remote", "get-url", "origin"], { cwd: dir });
|
|
51
|
+
return r.status === 0 ? parseRepoUrl(r.stdout) : null;
|
|
52
|
+
} catch {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const cleanPaths = (files) =>
|
|
58
|
+
[...new Set(Array.from(files ?? []).map((f) => String(f ?? "").trim()).filter(Boolean))].sort((a, b) => a.localeCompare(b));
|
|
59
|
+
|
|
60
|
+
// The poller. overlapsFor({repo, gitRoot, files}) is SYNC: cache file read first (the norm), a
|
|
61
|
+
// stale or cold repo triggers one gh poll (list + per-PR files) whose result rewrites the cache,
|
|
62
|
+
// so sibling processes (other claims, other seats) hit the warm file. rate-limit and gh-off state
|
|
63
|
+
// persist in the cache file too, so backoff survives the hook's short-lived processes.
|
|
64
|
+
export function createRemoteOverlap({
|
|
65
|
+
run = defaultGhRun,
|
|
66
|
+
gitRun = defaultGitRun,
|
|
67
|
+
log = (m) => console.error(`[remote-overlap] ${m}`),
|
|
68
|
+
now = Date.now,
|
|
69
|
+
ttlMs = POLL_TTL_MS,
|
|
70
|
+
backoffMs = RATE_LIMIT_BACKOFF_MS,
|
|
71
|
+
cacheDir,
|
|
72
|
+
} = {}) {
|
|
73
|
+
const dir = cacheDir || process.env.AGENT_BUS_DIR || join(homedir(), ".agent-bus");
|
|
74
|
+
let enabled = true;
|
|
75
|
+
let logged = "";
|
|
76
|
+
|
|
77
|
+
const say = (m) => {
|
|
78
|
+
if (!logged) {
|
|
79
|
+
logged = m;
|
|
80
|
+
log(m);
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const cachePath = (repo) => join(dir, `remote-overlap-${String(repo).replace(/\//g, "-")}.json`);
|
|
85
|
+
const readCache = (repo) => {
|
|
86
|
+
try {
|
|
87
|
+
return JSON.parse(readFileSync(cachePath(repo), "utf8"));
|
|
88
|
+
} catch {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
const writeCache = (repo, data) => {
|
|
93
|
+
try {
|
|
94
|
+
mkdirSync(dir, { recursive: true });
|
|
95
|
+
writeFileSync(cachePath(repo), JSON.stringify({ repo, at: now(), prs: [], off: "", blockedUntil: 0, ...data }));
|
|
96
|
+
} catch {}
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
function fail(repo, res) {
|
|
100
|
+
const stderr = String(res?.stderr || "");
|
|
101
|
+
if (res?.code === "ENOENT" || GH_OFF.test(stderr)) {
|
|
102
|
+
enabled = false;
|
|
103
|
+
say(`gh unusable (${res?.code === "ENOENT" ? "gh not found" : "not authenticated"}) — remote-overlap off.`);
|
|
104
|
+
writeCache(repo, { off: "gh missing or unauthenticated", prs: [] });
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
const prior = readCache(repo);
|
|
108
|
+
const blockedUntil = RATE_LIMIT.test(stderr) ? (say(`GitHub rate limit hit — polling paused ${Math.round(backoffMs / 60000)}m.`), now() + backoffMs) : now() + BLIP_BACKOFF_MS;
|
|
109
|
+
writeCache(repo, { at: prior?.at ?? 0, prs: prior?.prs ?? [], blockedUntil, off: "" });
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function poll(repo) {
|
|
114
|
+
const list = run(["api", `repos/${repo}/pulls?state=open`]);
|
|
115
|
+
if (list.code !== 0) return fail(repo, list);
|
|
116
|
+
let pulls;
|
|
117
|
+
try {
|
|
118
|
+
pulls = JSON.parse(list.stdout || "[]");
|
|
119
|
+
} catch {
|
|
120
|
+
return fail(repo, { code: list.code, stderr: "unparsable PR list" });
|
|
121
|
+
}
|
|
122
|
+
if (!Array.isArray(pulls)) return fail(repo, { code: list.code, stderr: "unexpected PR list shape" });
|
|
123
|
+
const prs = [];
|
|
124
|
+
for (const p of pulls.slice(0, PR_CAP)) {
|
|
125
|
+
const number = Number(p?.number);
|
|
126
|
+
if (!Number.isFinite(number)) continue;
|
|
127
|
+
const res = run(["api", `repos/${repo}/pulls/${number}/files`]);
|
|
128
|
+
let files = [];
|
|
129
|
+
if (res.code === 0) {
|
|
130
|
+
try {
|
|
131
|
+
files = cleanPaths(JSON.parse(res.stdout || "[]").map((f) => f?.filename));
|
|
132
|
+
} catch {
|
|
133
|
+
files = [];
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
prs.push({ number, author: String(p?.user?.login || ""), url: String(p?.html_url || ""), files });
|
|
137
|
+
}
|
|
138
|
+
const data = { repo, at: now(), prs, off: "", blockedUntil: 0 };
|
|
139
|
+
writeCache(repo, data);
|
|
140
|
+
return data;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function overlapsFor(input = {}) {
|
|
144
|
+
if (!enabled) return [];
|
|
145
|
+
const want = cleanPaths(input.files);
|
|
146
|
+
if (!want.length) return [];
|
|
147
|
+
const repo = String(input.repo || "").trim() || (input.gitRoot ? detectRepo({ dir: input.gitRoot, run: gitRun }) : null);
|
|
148
|
+
if (!repo) return [];
|
|
149
|
+
const t = now();
|
|
150
|
+
let data = readCache(repo);
|
|
151
|
+
if (!data || data.off) {
|
|
152
|
+
// a fresh off marker stays off without spawning gh; past the TTL the poll retries once
|
|
153
|
+
if (data?.off && t - (data.at ?? 0) <= ttlMs) return [];
|
|
154
|
+
poll(repo);
|
|
155
|
+
data = readCache(repo);
|
|
156
|
+
if (!data || data.off) return [];
|
|
157
|
+
} else if (t - (data.at ?? 0) > ttlMs && t >= (data.blockedUntil ?? 0)) {
|
|
158
|
+
poll(repo);
|
|
159
|
+
data = readCache(repo) ?? data;
|
|
160
|
+
}
|
|
161
|
+
const wantSet = new Set(want);
|
|
162
|
+
const out = [];
|
|
163
|
+
for (const p of data.prs ?? []) {
|
|
164
|
+
const hit = (p.files || []).filter((f) => wantSet.has(f));
|
|
165
|
+
if (hit.length) out.push({ repo, number: p.number, author: p.author, url: p.url, files: cleanPaths(hit) });
|
|
166
|
+
}
|
|
167
|
+
return out.sort((a, b) => a.number - b.number);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Force a poll, cache result, return it (or null on failure) — the test seam and warm-up path.
|
|
171
|
+
function refresh(repo) {
|
|
172
|
+
return poll(repo);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return {
|
|
176
|
+
overlapsFor,
|
|
177
|
+
refresh,
|
|
178
|
+
get enabled() {
|
|
179
|
+
return enabled;
|
|
180
|
+
},
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const defaultApi = createRemoteOverlap({});
|
|
185
|
+
|
|
186
|
+
// The hook-facing seam: never raises, whatever the input.
|
|
187
|
+
export function overlapsFor(input) {
|
|
188
|
+
try {
|
|
189
|
+
return defaultApi.overlapsFor(input);
|
|
190
|
+
} catch {
|
|
191
|
+
return [];
|
|
192
|
+
}
|
|
193
|
+
}
|
package/lib/seat-why.mjs
CHANGED
|
@@ -134,6 +134,7 @@ export function seatWhy(project, agent, opts = {}) {
|
|
|
134
134
|
const pids = opts.pidCheck ? opts.pidCheck(project, agent) : scanPids(project, agent);
|
|
135
135
|
const last = [...telemetry].reverse().find(r => asNumber(r.turn) !== null) || null;
|
|
136
136
|
const boots = telemetry.filter(r => r.boot).length;
|
|
137
|
+
const lifecycle = [...telemetry].reverse().find(r => r.boot || r.stopped);
|
|
137
138
|
const errTail = errText.trim().split("\n").filter(Boolean).slice(-2).join(" | ").slice(0, 240);
|
|
138
139
|
|
|
139
140
|
let state, why, advice;
|
|
@@ -184,7 +185,11 @@ export function seatWhy(project, agent, opts = {}) {
|
|
|
184
185
|
else if (boots) bits.push(`${boots} boot record(s), no completed turn`);
|
|
185
186
|
if (pane) bits.push(`pane ${pane.source}/${pane.pane} registered but no runner process`);
|
|
186
187
|
|
|
187
|
-
if (
|
|
188
|
+
if (lifecycle?.stopped) {
|
|
189
|
+
state = "stopped";
|
|
190
|
+
why = `no runner process; operator stopped ${agent}:${project} ${rel(lifecycle.ts)}.`;
|
|
191
|
+
advice = "seat was deliberately stopped — `trantor up <agent>` to start it again.";
|
|
192
|
+
} else if (last?.authFailed || AUTH_RE.test(errText)) {
|
|
188
193
|
state = "dead-auth";
|
|
189
194
|
why = `no runner process; ${bits.join("; ") || "auth markers in evidence"}.`;
|
|
190
195
|
advice = "provider credentials are rejected — fix the key (~/.agent-bus/.env or the seat's token-scrooge .env), then `trantor up <agent>`.";
|