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/.claude-plugin/plugin.json +1 -1
- package/bin/cli.mjs +8 -0
- package/bin/crew/cmux.mjs +11 -11
- package/bin/crew/core.mjs +3 -2
- package/bin/crew/herdr.mjs +8 -8
- package/bin/crew/state.mjs +30 -15
- package/bin/crew/swap.mjs +12 -12
- package/bin/crew/tmux.mjs +11 -11
- package/bin/crew-runner.mjs +176 -63
- package/bin/crew.mjs +22 -10
- package/bin/cut-snapshot.mjs +37 -0
- package/bin/gate.mjs +30 -0
- package/bin/keys.mjs +53 -0
- package/bin/turn-watchdog.mjs +14 -9
- package/bin/wake-nudge.mjs +21 -2
- package/hooks/file-claim.mjs +19 -7
- package/hooks/file-hold.mjs +49 -0
- package/hooks/heartbeat.mjs +2 -1
- package/hooks/hooks.json +10 -1
- package/hooks/lib/git-checkout.mjs +10 -0
- package/hooks/lib/overseer-level-cache.mjs +25 -0
- package/hooks/overseer-warn.mjs +1 -1
- package/hub/auth.mjs +7 -52
- package/hub/overseer.mjs +100 -34
- package/hub/routes/admin.mjs +58 -12
- package/hub/store.mjs +3 -2
- package/hub.mjs +1 -1
- package/lib/duty-nudges.mjs +12 -0
- package/lib/identity.mjs +181 -8
- package/lib/nudge-ledger.mjs +95 -0
- package/lib/overseer.mjs +82 -24
- package/lib/persona.mjs +43 -0
- package/lib/remote-overlap.mjs +193 -0
- package/lib/seat-why.mjs +6 -1
- package/lib/session-liveness.mjs +83 -0
- package/lib/state/gate.mjs +1 -1
- package/lib/store-pg.mjs +2 -0
- package/package.json +1 -1
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
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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: { "*":
|
|
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
|
-
|
|
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
|
|
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 = `${
|
|
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(
|
|
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(
|
|
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 =
|
|
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
|
-
|
|
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; },
|
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/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 : "
|
|
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;
|
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
|