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/hooks/file-claim.mjs
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import { gitCheckoutRoot } from "./lib/git-checkout.mjs";
|
|
2
3
|
// trantor PreToolUse file-claim — before every file edit the session posts a claim and the hub answers
|
|
3
4
|
// with any LIVE claim on the same file by another session, handed to the model as context.
|
|
4
5
|
// Informational, never blocking: a lock server that fails open is worse than no lock. Fail-open and
|
|
5
6
|
// cheap: tight timeout, per-(session,file) stamp throttles re-claims; the FIRST touch always goes out.
|
|
6
7
|
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
7
|
-
import { join, relative,
|
|
8
|
+
import { join, relative, resolve } from "node:path";
|
|
8
9
|
import { homedir } from "node:os";
|
|
9
10
|
import { relayUrl, sessionContext, signedPost } from "./lib/api.mjs";
|
|
11
|
+
import { writeOverseerLevel } from "./lib/overseer-level-cache.mjs";
|
|
12
|
+
import { overlapsFor } from "../lib/remote-overlap.mjs";
|
|
10
13
|
|
|
11
14
|
const FETCH_TIMEOUT_MS = Number(process.env.RELAY_CLAIM_TIMEOUT_MS || 900);
|
|
12
15
|
const RECLAIM_MS = Number(process.env.RELAY_RECLAIM_MS || 60 * 1000);
|
|
@@ -41,26 +44,35 @@ try {
|
|
|
41
44
|
const ctx = sessionContext(input.cwd);
|
|
42
45
|
if (!ctx.project) allow();
|
|
43
46
|
// claims compare by path, and absolute paths differ per machine — store repo-relative
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
|
|
47
|
+
const gitRoot = gitCheckoutRoot(ctx.projectDir);
|
|
48
|
+
const absolute = resolve(ctx.projectDir, abs);
|
|
49
|
+
const rel = relative(gitRoot || ctx.projectDir, absolute);
|
|
50
|
+
const file = !rel.startsWith("..") ? rel : absolute;
|
|
47
51
|
|
|
48
52
|
// throttle re-claims of the same file; never throttle its first touch
|
|
49
53
|
const stampDir = join(process.env.AGENT_BUS_DIR || join(homedir(), ".agent-bus"), "claims");
|
|
50
|
-
const stamp = join(stampDir, `${ctx.session} ${file}`.replace(/[^A-Za-z0-9_.-]/g, "_"));
|
|
54
|
+
const stamp = join(stampDir, `${ctx.session} ${gitRoot} ${file}`.replace(/[^A-Za-z0-9_.-]/g, "_"));
|
|
51
55
|
try {
|
|
52
56
|
const last = Number(readFileSync(stamp, "utf8"));
|
|
53
57
|
if (Date.now() - last < RECLAIM_MS) allow();
|
|
54
58
|
} catch {}
|
|
55
59
|
|
|
60
|
+
// #11311: open-PR evidence, computed HERE from this session's own checkout (the hub's gh is not
|
|
61
|
+
// logged in and its cwd is the wrong repo). Cache file makes the normal call a stat+read; off
|
|
62
|
+
// when gh is missing. Fail-open like everything else in this hook.
|
|
63
|
+
let remote = [];
|
|
64
|
+
try { remote = overlapsFor({ gitRoot, files: [file] }); } catch {}
|
|
65
|
+
|
|
56
66
|
const r = await signedPost(`${relayUrl(ctx.project)}/claim`,
|
|
57
|
-
{ project: ctx.project, file, session: ctx.session },
|
|
67
|
+
{ project: ctx.project, file, session: ctx.session, gitRoot, remote },
|
|
58
68
|
{ timeoutMs: FETCH_TIMEOUT_MS, session: ctx.session });
|
|
59
69
|
|
|
60
70
|
try { mkdirSync(stampDir, { recursive: true }); writeFileSync(stamp, String(Date.now())); } catch {}
|
|
61
71
|
|
|
72
|
+
if (r.ok) writeOverseerLevel({ project: ctx.project, hub: relayUrl(ctx.project) }, r.json?.level);
|
|
73
|
+
|
|
62
74
|
const conflicts = r.ok ? r.json?.conflicts ?? [] : [];
|
|
63
|
-
if (!conflicts.length) allow();
|
|
75
|
+
if (Number(r.json?.level ?? 2) < 2 || !conflicts.length) allow();
|
|
64
76
|
|
|
65
77
|
const who = conflicts.map(c => `${c.session} (${ago(c.agoSec)} ago)`).join(", ");
|
|
66
78
|
// NO permissionDecision on purpose: additionalContext reaches the model on its own, and an "allow"
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { gitCheckoutRoot } from "./lib/git-checkout.mjs";
|
|
3
|
+
import { relative, resolve } from "node:path";
|
|
4
|
+
import { relayUrl, sessionContext, signedPost } from "./lib/api.mjs";
|
|
5
|
+
import { readOverseerLevel, writeOverseerLevel } from "./lib/overseer-level-cache.mjs";
|
|
6
|
+
|
|
7
|
+
function deny(reason) {
|
|
8
|
+
process.stdout.write(JSON.stringify({ hookSpecificOutput: {
|
|
9
|
+
hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: reason,
|
|
10
|
+
} }));
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function unavailable() {
|
|
14
|
+
process.stderr.write("trantor: file hold could not be checked; allowing this edit.\n");
|
|
15
|
+
process.stdout.write("{}");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
try {
|
|
19
|
+
let raw = "";
|
|
20
|
+
for await (const chunk of process.stdin) raw += chunk;
|
|
21
|
+
const input = JSON.parse(raw || "{}");
|
|
22
|
+
const path = input.tool_name === "NotebookEdit" ? input.tool_input?.notebook_path : input.tool_input?.file_path;
|
|
23
|
+
const ctx = sessionContext(input.cwd);
|
|
24
|
+
if (!path || !ctx.project) process.stdout.write("{}");
|
|
25
|
+
else {
|
|
26
|
+
const cache = { project: ctx.project, hub: relayUrl(ctx.project) };
|
|
27
|
+
const level = readOverseerLevel(cache);
|
|
28
|
+
if (level !== null && level < 3) {
|
|
29
|
+
process.stdout.write("{}");
|
|
30
|
+
process.exit(0);
|
|
31
|
+
}
|
|
32
|
+
const gitRoot = gitCheckoutRoot(ctx.projectDir);
|
|
33
|
+
const absolute = resolve(ctx.projectDir, path);
|
|
34
|
+
const rel = relative(gitRoot || ctx.projectDir, absolute);
|
|
35
|
+
const file = !rel.startsWith("..") ? rel : absolute;
|
|
36
|
+
// Register and check atomically: parallel PreToolUse hooks cannot race the first claim.
|
|
37
|
+
const r = await signedPost(`${cache.hub}/hold/check`,
|
|
38
|
+
{ project: ctx.project, file, session: ctx.session, gitRoot },
|
|
39
|
+
{ timeoutMs: 2500, session: ctx.session });
|
|
40
|
+
if (!r.ok || !r.json?.ok) unavailable();
|
|
41
|
+
else {
|
|
42
|
+
writeOverseerLevel(cache, r.json.level);
|
|
43
|
+
if (r.json.hold) deny(r.json.hold.reason);
|
|
44
|
+
else process.stdout.write("{}");
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
} catch {
|
|
48
|
+
unavailable();
|
|
49
|
+
}
|
package/hooks/heartbeat.mjs
CHANGED
|
@@ -12,6 +12,7 @@ import { armBaton, readArm, clearArm, readConfig, contextUsage, warnFrac, alread
|
|
|
12
12
|
import { resolveProject, hostId } from "../lib/project.mjs";
|
|
13
13
|
import { installedVersion } from "./lib/update-check.mjs"; // report our hook version so the hub can flag stale sessions
|
|
14
14
|
import { signedPost } from "./lib/api.mjs";
|
|
15
|
+
import { gitCheckoutRoot } from "./lib/git-checkout.mjs";
|
|
15
16
|
|
|
16
17
|
const HEARTBEAT_MS = Number(process.env.RELAY_HEARTBEAT_MS || 60 * 1000);
|
|
17
18
|
const FETCH_TIMEOUT_MS = Number(process.env.RELAY_HEARTBEAT_TIMEOUT_MS || 1500);
|
|
@@ -161,7 +162,7 @@ async function main(stdinRaw) {
|
|
|
161
162
|
// strict test sessionstart.mjs uses), "agent" when the runner's TRANTOR_SEAT says crew seat.
|
|
162
163
|
const beatKind = process.env.TRANTOR_ORCH && process.env.TRANTOR_ORCH === project ? "orch"
|
|
163
164
|
: process.env.TRANTOR_SEAT ? "agent" : "";
|
|
164
|
-
const beatBody = { session, project,
|
|
165
|
+
const beatBody = { session, project, gitRoot: gitCheckoutRoot(projectDir),
|
|
165
166
|
llm: process.env.RELAY_LLM || (process.env.RELAY_AGENT ? process.env.RELAY_AGENT.replace(/-orch$/, "") : "claude"),
|
|
166
167
|
model: modelFromTranscript(stdinRaw),
|
|
167
168
|
hookVersion: (() => { try { return installedVersion(); } catch { return ""; } })() };
|
package/hooks/hooks.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"description": "trantor
|
|
2
|
+
"description": "trantor \u2014 auto-register each session + inject live roster + overseer landscape warning: linked projects, live peers, files in flight, collisions (SessionStart); turn each substantive user prompt into the session's live 'focus' card so a regular session's own work shows IN PROGRESS (UserPromptSubmit); post an in-flight 'doing' card when a sub-agent is dispatched + claim each file edit on the bus and hand the model a warning when ANOTHER live session is touching the same file (PreToolUse) and enrich it with the native agent_id + parent session when the sub-agent spawns (SubagentStart); heartbeat presence on every tool call + deliver unread bus messages to a busy session mid-turn + mirror the session's TodoWrite list onto the board as cards (PostToolUse); write a handoff before compaction (PreCompact); surface CC's own background/child agents (fork/--agent/subtask) on the board when they need input or complete (Notification); card each sub-agent's notional API cost when it finishes (SubagentStop); refuse to go idle while a peer's DIRECT message sits unread, handing it to the model instead (Stop)",
|
|
3
3
|
"hooks": {
|
|
4
4
|
"SessionStart": [
|
|
5
5
|
{
|
|
@@ -81,6 +81,15 @@
|
|
|
81
81
|
"command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/ask-sidecar.mjs"
|
|
82
82
|
}
|
|
83
83
|
]
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
"matcher": "Edit|Write|MultiEdit|NotebookEdit",
|
|
87
|
+
"hooks": [
|
|
88
|
+
{
|
|
89
|
+
"type": "command",
|
|
90
|
+
"command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/file-hold.mjs"
|
|
91
|
+
}
|
|
92
|
+
]
|
|
84
93
|
}
|
|
85
94
|
],
|
|
86
95
|
"PermissionRequest": [
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { realpathSync } from "node:fs";
|
|
3
|
+
|
|
4
|
+
export function gitCheckoutRoot(directory) {
|
|
5
|
+
try {
|
|
6
|
+
const root = execFileSync("git", ["-C", directory, "rev-parse", "--show-toplevel"],
|
|
7
|
+
{ encoding: "utf8", timeout: 1000, stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
8
|
+
return realpathSync(root);
|
|
9
|
+
} catch { return ""; }
|
|
10
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
|
|
6
|
+
const cacheDir = busDir => join(busDir || process.env.AGENT_BUS_DIR || join(homedir(), ".agent-bus"), "overseer-levels");
|
|
7
|
+
const cacheFile = ({ project, hub, busDir }) => join(cacheDir(busDir), createHash("sha256").update(JSON.stringify([hub, project])).digest("hex") + ".json");
|
|
8
|
+
|
|
9
|
+
export function readOverseerLevel(context) {
|
|
10
|
+
try {
|
|
11
|
+
const { level: cachedLevel, ts } = JSON.parse(readFileSync(cacheFile(context), "utf8"));
|
|
12
|
+
const level = cachedLevel ?? 2;
|
|
13
|
+
const age = Date.now() - ts;
|
|
14
|
+
return [1, 2, 3, 4].includes(level) && age >= 0 && age < 60_000 ? level : null;
|
|
15
|
+
} catch { return null; }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function writeOverseerLevel(context, level = 2) {
|
|
19
|
+
level ??= 2;
|
|
20
|
+
if (![1, 2, 3, 4].includes(level)) return;
|
|
21
|
+
try {
|
|
22
|
+
mkdirSync(cacheDir(context.busDir), { recursive: true });
|
|
23
|
+
writeFileSync(cacheFile(context), JSON.stringify({ level, ts: Date.now() }));
|
|
24
|
+
} catch { /* A cache write failure must not affect the edit decision. */ }
|
|
25
|
+
}
|
package/hooks/overseer-warn.mjs
CHANGED
|
@@ -31,7 +31,7 @@ try {
|
|
|
31
31
|
if (!r.ok || !r.json) silent();
|
|
32
32
|
const c = r.json;
|
|
33
33
|
|
|
34
|
-
const level = Number(c.level
|
|
34
|
+
const level = Number(c.level ?? 2);
|
|
35
35
|
const warnings = Array.isArray(c.warnings) ? c.warnings : [];
|
|
36
36
|
const inflight = Array.isArray(c.inflight) ? c.inflight : [];
|
|
37
37
|
const links = Array.isArray(c.links) ? c.links : [];
|
package/hub/auth.mjs
CHANGED
|
@@ -117,18 +117,7 @@ function projectFromRequest(P, q, b) {
|
|
|
117
117
|
}
|
|
118
118
|
return canon(String(b?.project || q?.project || "").slice(0, 80));
|
|
119
119
|
}
|
|
120
|
-
//
|
|
121
|
-
// scopeAllows/canRead answer "can this identity touch project P at all" — and most identities
|
|
122
|
-
// are minted project:"*" role:"owner" by defaultScopesFor (any non-agent kind: an orchestrator,
|
|
123
|
-
// genesis, a human). That is not a project fence; it is exactly the loophole the pr-os
|
|
124
|
-
// orchestrator walked through to register seats and post contracts into crebral-com from its
|
|
125
|
-
// own session, nothing it was ever working. This is the fence: does the CALLER's own project
|
|
126
|
-
// (its identity name's "kind:project" suffix, the same convention defaultScopesFor reads) match
|
|
127
|
-
// the project the request ACTS on. Only a declared `trantor policy link` (state.orgPolicy.links,
|
|
128
|
-
// the same store /policy reads and writes — POST /policy is itself OWNER_ENDPOINTS-gated) or the
|
|
129
|
-
// operator's own identity (kind "human", the key /instance/supersede already treats as owner)
|
|
130
|
-
// opens the door. Applies to the five endpoints that reach across sessions or mint access:
|
|
131
|
-
// /send, /task, /task/update, /register, /invite.
|
|
120
|
+
// Cross-project actions require a declared link or the operator identity, even with wildcard scopes (#6228).
|
|
132
121
|
const CROSS_PROJECT_ENDPOINTS = new Set(["/send", "/task", "/task/update", "/register", "/invite"]);
|
|
133
122
|
function projectsLinked(a, b) {
|
|
134
123
|
if (!a || !b || a === b) return true;
|
|
@@ -139,7 +128,7 @@ function projectsLinked(a, b) {
|
|
|
139
128
|
}
|
|
140
129
|
function overseerPolicy() {
|
|
141
130
|
const policy = state.orgPolicy && typeof state.orgPolicy === "object" ? state.orgPolicy : {};
|
|
142
|
-
return { autonomy: { "*":
|
|
131
|
+
return { autonomy: { "*": 2, ...(policy.autonomy || {}) }, links: Array.isArray(policy.links) ? policy.links : [] };
|
|
143
132
|
}
|
|
144
133
|
// "projPair-a" is an INSTANCE of "projPair", not a different project: the shorter name is a prefix
|
|
145
134
|
// of the longer and the remainder starts with a separator, not another project's first letter.
|
|
@@ -147,18 +136,7 @@ function instanceOfProject(a, b) {
|
|
|
147
136
|
const [lo, hi] = a.length <= b.length ? [a, b] : [b, a];
|
|
148
137
|
return hi.length > lo.length && hi.startsWith(lo) && !/[a-z0-9]/i.test(hi[lo.length]);
|
|
149
138
|
}
|
|
150
|
-
//
|
|
151
|
-
// uses to mint a fresh identity's default scope. An identity with no colon in its name (a bare
|
|
152
|
-
// human alias, or a tool identity never given a project) has no home to fence — nothing to check.
|
|
153
|
-
//
|
|
154
|
-
// The suffix is a CONVENTION, not a fact: session ids routinely carry an instance marker after the
|
|
155
|
-
// project — "agent:projPair-a" is session -a OF projPair, the same shape as the fleet's per-seat
|
|
156
|
-
// session ids — and reading that whole suffix as a home project fenced a session against its OWN
|
|
157
|
-
// project's register/send (#6446, red since 3e18faf). So when the identity's own scopes name
|
|
158
|
-
// exactly one concrete project (what /enroll bound at enrollment, and what /invite granted), that
|
|
159
|
-
// enrolled project is the home — unless the suffix names a DIFFERENT project, in which case the
|
|
160
|
-
// stricter suffix wins. Wildcard-scope identities (the fence's original target: a "*" owner like
|
|
161
|
-
// an orchestrator or genesis) keep the pure suffix rule unchanged.
|
|
139
|
+
// Enrollment identifies instance sessions; a conflicting suffix stays stricter. Wildcard identities keep the suffix fence (#6446).
|
|
162
140
|
function callerProject(auth) {
|
|
163
141
|
const name = String(auth?.identity?.name || "");
|
|
164
142
|
const suffix = name.includes(":") ? canon(name.slice(name.lastIndexOf(":") + 1)) : "";
|
|
@@ -197,15 +175,7 @@ function crossProjectGuard(auth, P, b) {
|
|
|
197
175
|
return { ok: false, code: 403,
|
|
198
176
|
error: `cross-project: ${home} may not act on ${target} — cross-project action is a breach unless the operator linked the projects. Run: trantor policy link ${home} ${target} --reason "<why>"` };
|
|
199
177
|
}
|
|
200
|
-
//
|
|
201
|
-
// (`exceptInstanceId`), so we defer to THAT instance only while it is still being seen. A claimant
|
|
202
|
-
// that died stops muzzling its twins; one that comes back starts again. Records claimed before
|
|
203
|
-
// `supersededBy` existed carry no claimant, so they fall back to "is any OTHER live instance of this
|
|
204
|
-
// name still carrying it?" — an orphaned flag must not outlive every possible carrier.
|
|
205
|
-
// NOTE lastSeen only advances on a request, and the heartbeat is PostToolUse, so an alive-but-idle
|
|
206
|
-
// claimant reads as gone after the grace window. That is the intended trade: the only session that
|
|
207
|
-
// ever asks this question is one a human is actively driving right now, and deferring to a claimant
|
|
208
|
-
// that has been silent for longer than the window is worse than letting the driven session work.
|
|
178
|
+
// Supersession lasts while its claimant is live, with startup grace; legacy claims defer to another live instance.
|
|
209
179
|
function supersessionActive(rec) {
|
|
210
180
|
if (!rec?.superseded) return false;
|
|
211
181
|
const cut = now() - SUPERSEDE_GRACE_MS;
|
|
@@ -231,12 +201,7 @@ async function authenticate(req, path) {
|
|
|
231
201
|
return { ok: false, code: 401, error: "signature required" };
|
|
232
202
|
}
|
|
233
203
|
const raw = req.method === "GET" || req.method === "HEAD" ? undefined : await rawBody(req);
|
|
234
|
-
//
|
|
235
|
-
// where the hub records what WOULD fail under enforce. The restarted local hub proved the
|
|
236
|
-
// failure mode: signed requests from a not-yet-enrolled identity got 401 "unknown identity"
|
|
237
|
-
// while UNSIGNED requests passed — punishing exactly the clients that already do the right
|
|
238
|
-
// thing. Under warn: bad signature, replay and unknown identity all pass with a warning;
|
|
239
|
-
// under enforce they are the hard failures they should be.
|
|
204
|
+
// Auth warn mode annotates signature, replay and enrollment failures; enforce mode rejects them.
|
|
240
205
|
const soft = (warning) => AUTH_MODE === "warn"
|
|
241
206
|
? { ok: true, mode: AUTH_MODE, trusted: false, warning }
|
|
242
207
|
: { ok: false, code: 401, error: warning };
|
|
@@ -287,17 +252,7 @@ function filterReadable(auth, rows, projectOf) {
|
|
|
287
252
|
if (AUTH_MODE !== "enforce" && !auth?.identity) return rows;
|
|
288
253
|
return rows.filter(row => canRead(auth, projectOf(row)));
|
|
289
254
|
}
|
|
290
|
-
//
|
|
291
|
-
//
|
|
292
|
-
// Sending across projects was never blocked: /send authorizes against the SENDER's project, so any
|
|
293
|
-
// session can DM any session id it happens to know. Only the ROSTER was scoped — which meant two
|
|
294
|
-
// sessions the operator had explicitly declared codependent could not learn each other's ids. The
|
|
295
|
-
// overseer would tell both of them to "coordinate over the bus" and neither could find the other,
|
|
296
|
-
// so the only remaining channel was the human. That is the exact traffic-cop role this project
|
|
297
|
-
// exists to delete.
|
|
298
|
-
//
|
|
299
|
-
// A link is an operator declaration that two projects share resources. Treating it as mutual
|
|
300
|
-
// discovery grants nothing a linked pair wasn't already told to do.
|
|
255
|
+
// Declared links permit mutual roster discovery without granting broader read or write access.
|
|
301
256
|
function canDiscover(auth, project) {
|
|
302
257
|
if (canRead(auth, project)) return true;
|
|
303
258
|
const proj = canon(project || "");
|
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; },
|