trantor 0.18.81 → 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 +23 -3
- 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/duty-recipient.mjs +11 -9
- 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
|
package/lib/duty-recipient.mjs
CHANGED
|
@@ -1,7 +1,4 @@
|
|
|
1
|
-
// #7430:
|
|
2
|
-
// nudge. A recipient observed busy is a no-op (prompt rule 4a agrees), a recipient that is not a
|
|
3
|
-
// socket-nudgeable local interactive session is terminal, and a resolver that cannot see at all
|
|
4
|
-
// throws so lib/duty-nudges.mjs fails OPEN to a standing nudge.
|
|
1
|
+
// #7430: preflight skips busy or nonlocal recipients; uncertain local discovery leaves duty a nudge.
|
|
5
2
|
import { execFile } from "node:child_process";
|
|
6
3
|
import { readFileSync } from "node:fs";
|
|
7
4
|
import { join } from "node:path";
|
|
@@ -9,16 +6,21 @@ import { busDir, hostId } from "./project.mjs";
|
|
|
9
6
|
|
|
10
7
|
const BUSY_STATUSES = new Set(["working", "busy"]);
|
|
11
8
|
|
|
9
|
+
// #11109: the recorded session outranks cwd; stale panes often share the orchestrator's directory.
|
|
10
|
+
export function recipientPanes(project, mapped, agents) {
|
|
11
|
+
const claude = (Array.isArray(agents) ? agents : []).filter(agent => agent?.agent === "claude");
|
|
12
|
+
const exact = mapped ? claude.filter(agent => agent.agent_session?.value === mapped) : [];
|
|
13
|
+
return exact.length ? exact : claude.filter(agent => String(agent.cwd || "").split("/").pop() === project);
|
|
14
|
+
}
|
|
15
|
+
|
|
12
16
|
export function recipientVerdict(recipient, { localHost, mapped, agents }) {
|
|
13
17
|
// Remote hosts and crew slugs (`glm:trantor`) have no socket here: the hub delivers their mail
|
|
14
18
|
// through their own runners, so a nudge from this machine is neither possible nor needed.
|
|
15
19
|
if (!String(recipient || "").startsWith(`${localHost}:`)) return "unknown";
|
|
16
20
|
const project = recipient.slice(localHost.length + 1);
|
|
17
|
-
const panes = (
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
// Zero or ambiguous panes both mean "no single local session to nudge" (#7429 agrees).
|
|
21
|
-
if (panes.length !== 1) return "unknown";
|
|
21
|
+
const panes = recipientPanes(project, mapped, agents);
|
|
22
|
+
// #11109: uncertain local discovery must reach duty's ListAgents, never a permanent terminal mark.
|
|
23
|
+
if (panes.length !== 1) return mapped || panes.length ? "unresolved" : "unknown";
|
|
22
24
|
return BUSY_STATUSES.has(panes[0].agent_status) ? "busy" : "idle";
|
|
23
25
|
}
|
|
24
26
|
|
|
@@ -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()];
|