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.
@@ -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["*"] ?? 1);
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(`${c.project} ${c.kind}${c.kind === "same-project-sessions" ? "" : ` ${(c.files || []).join(",")}`}`);
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 === "POST" && P === "/claim") {
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}${file}${session}`;
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.project === proj && c.file === file && c.session !== session)
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
- fileClaims.set(key, { project: proj, file, session, ts: now() });
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
- // Feed events, throttled by design: the FIRST touch inside a TTL window says "claimed";
287
- // a collision says so every time — that is the one worth seeing on the FEED.
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
- return json(res, 200, { ok: true, conflicts, ttlMs: CLAIM_TTL_MS });
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
  }
@@ -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 : 1;
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
- out.push({
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 sessionsByProject = new Map();
99
+ const sessionsByCheckout = new Map();
84
100
  for (const peer of livePeers) {
85
- const sessions = sessionsByProject.get(peer.project) ?? [];
86
- sessions.push(peer.session);
87
- sessionsByProject.set(peer.project, sessions);
101
+ const key = checkoutKey(peer);
102
+ const group = sessionsByCheckout.get(key) ?? [];
103
+ group.push(peer);
104
+ sessionsByCheckout.set(key, group);
88
105
  }
89
-
90
- for (const project of sortedStrings(sessionsByProject.keys())) {
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 key = `${project}\u0000${file}`;
114
- const sessions = claimSessionsByFile.get(key) ?? new Set();
115
- sessions.add(session);
116
- claimSessionsByFile.set(key, sessions);
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 [project, file] = key.split("\u0000");
124
- const sessions = sortedStrings(claimSessionsByFile.get(key));
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 (last?.authFailed || AUTH_RE.test(errText)) {
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>`.";