trantor 0.18.82 → 0.18.86

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/hub/overseer.mjs CHANGED
@@ -1,12 +1,11 @@
1
+ import * as collisionRules from "../lib/overseer.mjs";
2
+ import * as sameProjectRules from "../lib/same-project.mjs";
3
+ const { levelFor, checkoutKey, collisionIdentity } = collisionRules;
1
4
  /* oxlint-disable anti-slop/no-runtime-typeof -- SAFETY: orgPolicy is loaded from legacy durable state and this structural split preserves its existing compatibility guard. */
2
5
  export function createOverseer({ state, fileClaims, now, appendEvent, markDirty, duty }) {
3
- let _overseer = null;
4
- import("../lib/overseer.mjs").then(m => { _overseer = m; }).catch(() => {});
5
- // #5760: the same-project episode rule (lib/same-project.mjs, pure) — lazy like the detector, so
6
- // a hub booted before the module lands still runs (the same-project branch fails QUIET until it
7
- // arrives: a missing rule must never re-instate the hourly metronome).
8
- let _sameProject = null;
9
- import("../lib/same-project.mjs").then(m => { _sameProject = m; }).catch(() => {});
6
+ const _overseer = collisionRules;
7
+ const _sameProject = sameProjectRules;
8
+ const saved = state.overseerState || {};
10
9
  const OVERSEER_TICK_MS = Number(process.env.RELAY_OVERSEER_TICK_MS || 30 * 1000);
11
10
  // How long a condition must be ABSENT before we consider the episode over. This is NOT a re-warn
12
11
  // timer: see overseerTick.
@@ -15,7 +14,7 @@ const OVERSEER_CLEAR_MS = Number(process.env.RELAY_OVERSEER_CLEAR_MS || process.
15
14
  // not an event: it persists (#5350 doctrine). It fires ONCE when it starts, stays quiet while it
16
15
  // holds (a metronome woke duty on 500 events for 4 conditions once), and the entry is forgotten
17
16
  // only after OVERSEER_CLEAR_MS of absence, so a genuine recurrence warns again.
18
- const overseerActive = new Map();
17
+ const overseerActive = new Map((saved.active || []).map(([key, entry]) => [key, { ...entry, sessions: new Set(entry.sessions) }]));
19
18
  // Heartbeat for the WATCHER itself: /overseer/status must distinguish "fleet is clear" from "the
20
19
  // overseer stopped ticking" — a monitor that cannot prove it is alive reads as clear when dead.
21
20
  let overseerLastTick = 0;
@@ -23,14 +22,14 @@ let overseerLastCollisions = [];
23
22
  function overseerPolicy() {
24
23
  const p = state.orgPolicy && typeof state.orgPolicy === "object" ? state.orgPolicy : {};
25
24
  return {
26
- autonomy: { "*": 1, ...(p.autonomy || {}) },
25
+ autonomy: { "*": 2, ...(p.autonomy || {}) },
27
26
  links: Array.isArray(p.links) ? p.links : [],
28
27
  };
29
28
  }
30
29
  function overseerInputs() {
31
30
  return {
32
31
  peers: Object.entries(state.peers).map(([session, v]) => ({
33
- session, project: v.project || "", lastSeen: v.lastSeen || 0,
32
+ session, project: v.project || "", gitRoot: v.gitRoot || state.overseerState?.roots?.[session] || "", lastSeen: v.lastSeen || 0,
34
33
  llm: v.llm || "", model: v.model || "", status: v.status || "",
35
34
  })),
36
35
  claims: [...fileClaims.values()],
@@ -45,11 +44,75 @@ function overseerInputs() {
45
44
  };
46
45
  }
47
46
 
47
+ // Claims and their decisions recover together so a restart cannot reopen a standing hold.
48
+ const holds = new Map(saved.holds || []);
49
+ const claimTtl = Number(process.env.RELAY_CLAIM_TTL_MS || 10 * 60 * 1000);
50
+ for (const claim of saved.claims || []) {
51
+ if (now() - claim.ts <= claimTtl) fileClaims.set(`${checkoutKey(claim)}\u0000${claim.file}\u0000${claim.session}`, claim);
52
+ }
53
+ function persistEpisodes() {
54
+ state.overseerState = {
55
+ roots: state.overseerState?.roots || {},
56
+ active: [...overseerActive].map(([key, entry]) => [key, { ...entry, sessions: [...entry.sessions] }]),
57
+ sameProjectFired: [...sameProjectFired],
58
+ holds: [...holds],
59
+ claims: [...fileClaims.values()].filter(claim => now() - claim.ts <= claimTtl),
60
+ };
61
+ markDirty();
62
+ }
63
+ function syncHolds() {
64
+ const live = [...fileClaims.values()].filter(c => now() - c.ts <= claimTtl);
65
+ for (const [key, hold] of holds) {
66
+ if (levelFor(hold.project, overseerPolicy().autonomy) < 3 || !live.some(c => checkoutKey(c) === checkoutKey(hold) && c.file === hold.file && c.session === hold.other)) {
67
+ holds.delete(key);
68
+ appendEvent("hold.expired", hold.project, "overseer", { holdId: hold.id, file: hold.file });
69
+ }
70
+ }
71
+ const first = new Map();
72
+ for (const claim of live) {
73
+ const fileKey = JSON.stringify([checkoutKey(claim), claim.file]);
74
+ const other = first.get(fileKey);
75
+ if (!other) { first.set(fileKey, claim.session); continue; }
76
+ if (other === claim.session || levelFor(claim.project, overseerPolicy().autonomy) < 3) continue;
77
+ const key = JSON.stringify([checkoutKey(claim), claim.file, claim.session]);
78
+ if (holds.has(key)) continue;
79
+ const hold = { id: ++state.verifyGateSeq, project: claim.project, file: claim.file,
80
+ session: claim.session, gitRoot: claim.gitRoot || "", other, status: "pending", ts: now() };
81
+ holds.set(key, hold);
82
+ markDirty();
83
+ appendEvent("hold.opened", hold.project, hold.session,
84
+ { holdId: hold.id, file: hold.file, sessions: [other, hold.session], reason: holdReason(hold) });
85
+ }
86
+ persistEpisodes();
87
+ }
88
+ function holdReason(hold) {
89
+ return `held: file conflict with ${hold.other}, ${hold.status === "nogo" ? "operator decided no-go" : "waiting on the operator"}`;
90
+ }
91
+ function holdFor(project, file, session, gitRoot = "") {
92
+ syncHolds();
93
+ if (levelFor(project, overseerPolicy().autonomy) < 3) return null;
94
+ const hold = holds.get(JSON.stringify([checkoutKey({ project, gitRoot }), file, session]));
95
+ return hold && hold.status !== "go" ? { ...hold, reason: holdReason(hold) } : null;
96
+ }
97
+ function listHolds() { syncHolds(); return [...holds.values()]; }
98
+ function decideHold(id, status, by) {
99
+ syncHolds();
100
+ const hold = [...holds.values()].find(h => h.id === id);
101
+ if (!hold || hold.status !== "pending") return null;
102
+ hold.status = status;
103
+ hold.decidedBy = by;
104
+ hold.decidedTs = now();
105
+ persistEpisodes();
106
+ appendEvent("hold.decided", hold.project, by, { holdId: hold.id, file: hold.file, status });
107
+ duty.hubSend(hold.session, `File hold #${hold.id}: ${status === "go" ? "Go — retry your edit" : "No-go — your edit remains held"} (${hold.file}).`, hold.project);
108
+ return hold;
109
+ }
110
+
48
111
  // --- #5760: the same-project warning is an EPISODE keyed by the MEMBER SET -------------------
49
112
  // lib/same-project.mjs (pure) decides from (previous set, current set, declared crew,
50
113
  // last-fired-at): a declared crew is the NORMAL state of a project and not a collision at all,
51
114
  // an unchanged set never re-warns (#5350 machinery), and the record reports DURATION.
52
- const sameProjectFired = new Map(); // project -> { hash, sessions, ts } — the set as of the last verdict
115
+ const sameProjectFired = new Map(saved.sameProjectFired || []); // project -> { hash, sessions, ts } — the set as of the last verdict
53
116
 
54
117
  // #8723 bounce-4: peerKindOf is the ONE answer to "what is this peer" — the row's own kind,
55
118
  // nothing else — shared with /peers, so roster and overseer can never disagree. The enrolled
@@ -63,10 +126,10 @@ function peerKindOf(p) {
63
126
  // (crew-runner stamps every /register its seats make), "orch" the project's orchestrator pane
64
127
  // (sessionstart stamps it when TRANTOR_ORCH names this project). A local crew-windows.txt reader
65
128
  // would describe the OPERATOR's machine, never this hub's. Genesis is deliberately NOT crew (#6068).
66
- function declaredCrewFor(project) {
129
+ function declaredCrewFor(project, gitRoot = "") {
67
130
  const crew = new Set();
68
131
  for (const [sid, p] of Object.entries(state.peers)) {
69
- if ((p.project || "") !== project) continue;
132
+ if (gitRoot ? (p.gitRoot || state.overseerState?.roots?.[sid]) !== gitRoot : (p.project || "") !== project) continue;
70
133
  const k = peerKindOf(p);
71
134
  if (k === "agent" || k === "orch") crew.add(sid);
72
135
  }
@@ -74,6 +137,7 @@ function declaredCrewFor(project) {
74
137
  }
75
138
 
76
139
  function overseerTick() {
140
+ syncHolds();
77
141
  if (!_overseer?.detectCollisions) return;
78
142
  let collisions = [];
79
143
  try { collisions = _overseer.detectCollisions(overseerInputs()) || []; } catch { return; }
@@ -86,11 +150,17 @@ function overseerTick() {
86
150
  // episode-start branch (all parties) and the standing branch (newcomers only, same-project
87
151
  // included): existing members never re-hear it, so a standing condition cannot re-wake every tick.
88
152
  const intro = (c, me, others) => {
153
+ const project = state.peers[me]?.project || c.project;
154
+ if (levelFor(project, pol.autonomy) < 2) return;
155
+ if (c.kind === "remote-overlap") {
156
+ duty.hubSend(me, `⚠️ OVERSEER remote-overlap: ${c.detail || ""}`, project);
157
+ return;
158
+ }
89
159
  const rest = others.filter(p => p !== me);
90
160
  if (rest.length === 0) return;
91
161
  duty.hubSend(me,
92
162
  `🤝 OVERSEER ${c.kind}: you and ${rest.join(", ")} are working on overlapping ground${c.files?.length ? ` (${c.files.slice(0, 3).join(", ")})` : ""}. ${c.detail || ""} Coordinate directly — relay_send to ${rest[0]} — and split the work between you. No human needs to relay this.`,
93
- c.project);
163
+ project);
94
164
  };
95
165
  // #5760: same-project sets judged crew-only are dropped entirely — the normal state of a
96
166
  // project, not a collision — so not even the context feed narrates them.
@@ -101,16 +171,17 @@ function overseerTick() {
101
171
  // silent, the record reports DURATION. Without the rule module the fallback is the pre-#5760
102
172
  // generic loop: a missing rule must never re-instate the metronome, only loosen it.
103
173
  if (c.kind === "same-project-sessions" && _sameProject?.sameProjectDecision) {
104
- const prior = sameProjectFired.get(c.project) || null;
174
+ const scope = checkoutKey(c);
175
+ const prior = sameProjectFired.get(scope) || null;
105
176
  const d = _sameProject.sameProjectDecision({
106
177
  previous: prior?.sessions ?? null,
107
178
  current: c.sessions,
108
- declaredCrew: declaredCrewFor(c.project),
179
+ declaredCrew: declaredCrewFor(c.project, c.gitRoot),
109
180
  lastFiredAt: prior?.ts ?? null,
110
181
  now: t,
111
182
  });
112
183
  if (d.reason === "crew-only") continue;
113
- const key = `${c.project} ${c.kind}`;
184
+ const key = `${scope} ${c.kind}`;
114
185
  c.key = key;
115
186
  seen.add(key);
116
187
  kept.push(c);
@@ -125,18 +196,18 @@ function overseerTick() {
125
196
  for (const me of parties) standing.sessions.add(me);
126
197
  // The record tracks the live membership (ts stays at the last warn) so a later open
127
198
  // judges the true previous set and can say how long the old one held.
128
- if (d.fire && prior) sameProjectFired.set(c.project, { hash: _sameProject.memberSetHash(c.sessions), sessions: c.sessions, ts: prior.ts });
199
+ if (d.fire && prior) sameProjectFired.set(scope, { hash: _sameProject.memberSetHash(c.sessions), sessions: c.sessions, ts: prior.ts });
129
200
  continue;
130
201
  }
131
202
  // The episode OPENS and the pure rule said fire — first sighting, or a membership change
132
203
  // on a remembered set; the record line states how long the previous state held.
133
- overseerActive.set(key, { since: t, lastTick: t, sessions: new Set(parties) });
204
+ overseerActive.set(key, { kind: c.kind, since: t, lastTick: t, sessions: new Set(parties) });
134
205
  c.since = t;
135
206
  if (d.reason === "membership-changed") c.detail = `${c.detail || ""} (same-project for ${_sameProject.durationLabel(d.durationMs)})`.trim();
136
- sameProjectFired.set(c.project, { hash: _sameProject.memberSetHash(c.sessions), sessions: c.sessions, ts: t });
207
+ sameProjectFired.set(scope, { hash: _sameProject.memberSetHash(c.sessions), sessions: c.sessions, ts: t });
137
208
  appendEvent("overseer.warn", c.project, "overseer",
138
209
  { kind: c.kind, sessions: c.sessions || [], files: c.files || [], detail: c.detail || "", narrated: false });
139
- if (duty.session) duty.hubSend(duty.session, `⚠️ OVERSEER ${c.kind} [${c.project}]: ${c.detail || ""} — if the parties are not already coordinating, message them.`, c.project);
210
+ if (levelFor(c.project, pol.autonomy) >= 2 && duty.session) duty.hubSend(duty.session, `⚠️ OVERSEER ${c.kind} [${c.project}]: ${c.detail || ""} — if the parties are not already coordinating, message them.`, c.project);
140
211
  if (parties.length > 1) for (const me of parties) intro(c, me, parties);
141
212
  continue;
142
213
  }
@@ -145,7 +216,7 @@ function overseerTick() {
145
216
  // membership is volatile — a third seat bouncing in and out of a standing collision minted a
146
217
  // fresh key, so a fresh episode, so a fresh warn (+ duty wake + party intros) per permutation.
147
218
  // Sessions are participants, not identity; current membership still rides every warn payload.
148
- const key = `${c.project} ${c.kind} ${(c.files || []).join(",")}`;
219
+ const key = collisionIdentity(c);
149
220
  c.key = key;
150
221
  seen.add(key);
151
222
  const parties = [...new Set(c.sessions || [])].filter(s => s && s !== duty.session);
@@ -165,25 +236,18 @@ function overseerTick() {
165
236
  for (const me of parties) standing.sessions.add(me);
166
237
  continue;
167
238
  }
168
- overseerActive.set(key, { since: t, lastTick: t, sessions: new Set(parties) });
239
+ overseerActive.set(key, { kind: c.kind, since: t, lastTick: t, sessions: new Set(parties) });
169
240
  c.since = t;
170
241
  appendEvent("overseer.warn", c.project, "overseer",
171
242
  { kind: c.kind, sessions: c.sessions || [], files: c.files || [], detail: c.detail || "", narrated: false });
172
- if (duty.session) duty.hubSend(duty.session, `⚠️ OVERSEER ${c.kind} [${c.project}]: ${c.detail || ""} — if the parties are not already coordinating, message them.`, c.project);
173
- if (parties.length > 1) for (const me of parties) intro(c, me, parties);
174
- const level = _overseer.levelFor ? _overseer.levelFor(c.project, pol.autonomy) : 1;
175
- if (level >= 3 && c.kind === "file-conflict") {
176
- const g = { id: ++state.verifyGateSeq, project: c.project, status: "open", ts: now(),
177
- by: "overseer", claim: `file conflict: ${(c.files || []).join(", ")} — ${(c.sessions || []).join(" vs ")}`,
178
- why: c.detail || "two live sessions on the same file", howToVerify: "decide who proceeds; coordinate over the bus" };
179
- state.verifyGates.push(g); markDirty();
180
- appendEvent("verify.gate.opened", c.project, "overseer", { gateId: g.id, claim: g.claim, why: g.why });
181
- }
243
+ if (levelFor(c.project, pol.autonomy) >= 2 && duty.session) duty.hubSend(duty.session, `⚠️ OVERSEER ${c.kind} [${c.project}]: ${c.detail || ""} — if the parties are not already coordinating, message them.`, c.project);
244
+ if (parties.length > 1 || c.kind === "remote-overlap") for (const me of parties) intro(c, me, parties);
245
+
182
246
  }
183
247
  // Episode end: a condition gone for the whole clear window is over, so a LATER recurrence is a
184
248
  // new episode and warns again. Without this the map would grow forever and nothing could re-fire.
185
249
  for (const [k, v] of overseerActive) {
186
- if (!seen.has(k) && t - v.lastTick > OVERSEER_CLEAR_MS) {
250
+ if (!seen.has(k) && (v.kind === "remote-overlap" || t - v.lastTick > OVERSEER_CLEAR_MS)) {
187
251
  overseerActive.delete(k);
188
252
  // #5760: the same-project verdict record dies WITH its episode — a set that returns after a
189
253
  // genuine clear is a new episode (it warns again, first sighting), not the old one continuing.
@@ -191,11 +255,13 @@ function overseerTick() {
191
255
  }
192
256
  }
193
257
  overseerLastCollisions = kept;
258
+ persistEpisodes();
194
259
  }
195
260
  setInterval(overseerTick, OVERSEER_TICK_MS).unref?.();
196
261
  setTimeout(overseerTick, 2000).unref?.();
197
262
 
198
263
  return {
264
+ syncHolds, holdFor, listHolds, decideHold,
199
265
  overseerTick, overseerPolicy, overseerInputs, declaredCrewFor, peerKindOf,
200
266
  active: overseerActive,
201
267
  get engine() { return _overseer; },
@@ -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
  }
package/hub.mjs CHANGED
@@ -27,7 +27,7 @@ const PG_URL = process.env.RELAY_DATABASE_URL || process.env.POSTGRES_URL ||
27
27
  ((RAW_STORE_KIND === "pg" || RAW_STORE_KIND === "postgres") ? process.env.DATABASE_URL : "");
28
28
  const STORE_KIND = RAW_STORE_KIND || (PG_URL ? "pg" : "json");
29
29
  const ORG_ID = process.env.RELAY_ORG_ID || DEFAULT_ORG;
30
- const AUTH_MODE = ["off", "warn", "enforce"].includes(process.env.RELAY_AUTH) ? process.env.RELAY_AUTH : "warn";
30
+ const AUTH_MODE = ["off", "warn", "enforce"].includes(process.env.RELAY_AUTH) ? process.env.RELAY_AUTH : "enforce";
31
31
  const ENROLL_MODE = process.env.RELAY_ENROLL || "tofu";
32
32
  const ONLINE_MS = Number(process.env.RELAY_ONLINE_MS || 5 * 60 * 1000);
33
33
  const PEER_TTL_DEFAULT_MS = 21600000;
@@ -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