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/lib/identity.mjs
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// trantor — cryptographic identity, THE INTERFACE CONTRACT for Phase 0 (docs/TDD-trantor-platform.md §7).
|
|
2
2
|
// An identity is an Ed25519 keypair and the label is cosmetic; every primitive lives behind this module.
|
|
3
3
|
import { generateKeyPairSync, createPublicKey, createPrivateKey, sign as cryptoSign, verify as cryptoVerify, createHash, randomBytes } from "node:crypto";
|
|
4
|
-
import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync, chmodSync } from "node:fs";
|
|
4
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync, chmodSync, readdirSync, unlinkSync } from "node:fs";
|
|
5
|
+
import { spawnSync } from "node:child_process";
|
|
5
6
|
import { join } from "node:path";
|
|
6
7
|
import { homedir } from "node:os";
|
|
7
8
|
import { asFunction, asString } from "./decode.mjs";
|
|
@@ -64,23 +65,120 @@ function privKeyObject(identity) {
|
|
|
64
65
|
});
|
|
65
66
|
}
|
|
66
67
|
|
|
68
|
+
// --- the keychain layer (#11370) -----------------------------------------------------------------
|
|
69
|
+
// The private half moves OFF disk into the macOS Keychain (service below, account = the raw
|
|
70
|
+
// identity name); `trantor keys migrate` rewrites the file as a pubkey-only stub. Reads go
|
|
71
|
+
// Keychain-first through a per-process cache, with a one-line file fallback when locked/absent.
|
|
72
|
+
export const IDENTITY_SERVICE = "trantor-identity";
|
|
73
|
+
const SECURITY = "/usr/bin/security";
|
|
74
|
+
// A drill points this at a throwaway keychain so the operator's real one is never touched.
|
|
75
|
+
const kcArgs = () => process.env.TRANTOR_IDENTITY_KEYCHAIN ? [process.env.TRANTOR_IDENTITY_KEYCHAIN] : [];
|
|
76
|
+
|
|
77
|
+
// Test seam: drills inject a fake ({ get, set, remove } — get returns the stored string | null and
|
|
78
|
+
// may THROW to simulate a locked keychain) so no test needs the real thing.
|
|
79
|
+
let injectedKeychain = null;
|
|
80
|
+
export function injectIdentityKeychain(fake) {
|
|
81
|
+
injectedKeychain = fake || null;
|
|
82
|
+
processCache.clear();
|
|
83
|
+
warned = false;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const keychainUsable = () =>
|
|
87
|
+
!!injectedKeychain ||
|
|
88
|
+
(process.platform === "darwin" && process.env.TRANTOR_NO_KEYCHAIN !== "1" && existsSync(SECURITY));
|
|
89
|
+
|
|
90
|
+
let warned = false;
|
|
91
|
+
function warnOnce(msg) {
|
|
92
|
+
if (warned) return;
|
|
93
|
+
warned = true;
|
|
94
|
+
console.error(`trantor identity: ${msg}`);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Three outcomes matter: a hit, a MISS (never stored — normal before migrate), and UNAVAILABLE
|
|
98
|
+
// (locked, headless, pre-wake). Only the last one ever warns.
|
|
99
|
+
function keychainGet(account) {
|
|
100
|
+
if (injectedKeychain) {
|
|
101
|
+
try { return { value: injectedKeychain.get(account) || null }; }
|
|
102
|
+
catch { return { error: true }; }
|
|
103
|
+
}
|
|
104
|
+
const r = spawnSync(SECURITY, ["find-generic-password", "-a", account, "-s", IDENTITY_SERVICE, "-w", ...kcArgs()], { encoding: "utf8", timeout: 8000 });
|
|
105
|
+
if (r.status === 0) return { value: String(r.stdout).replace(/\n$/, "") };
|
|
106
|
+
return r.status === 44 ? {} : { error: true }; // 44 = errSecItemNotFound
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function keychainSet(account, value) {
|
|
110
|
+
if (injectedKeychain) { injectedKeychain.set(account, value); return; }
|
|
111
|
+
// The value travels on stdin in `security -i` syntax, so it never shows in the process table;
|
|
112
|
+
// only the exit code comes back (security echoes the failing line, so stderr is not relayed).
|
|
113
|
+
const quote = (s) => `"${String(s).replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
|
114
|
+
const kc = kcArgs();
|
|
115
|
+
const line = `add-generic-password -a ${quote(account)} -s ${quote(IDENTITY_SERVICE)} -U -w ${quote(value)}${kc.length ? ` ${quote(kc[0])}` : ""}\n`;
|
|
116
|
+
const r = spawnSync(SECURITY, ["-i"], { encoding: "utf8", input: line, timeout: 8000 });
|
|
117
|
+
if (r.status !== 0) throw new Error(`keychain write failed for ${account} (security exited ${r.status ?? "signal"})`);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// A keychain entry is the whole identity as JSON, so it stays self-sufficient even if the stub
|
|
121
|
+
// file is lost. Anything that does not parse to a well-formed Ed25519 pair is not a key.
|
|
122
|
+
const HEX64 = /^[0-9a-f]{64}$/i;
|
|
123
|
+
function parseStored(raw) {
|
|
124
|
+
try {
|
|
125
|
+
const id = JSON.parse(raw);
|
|
126
|
+
return HEX64.test(id?.pubkey || "") && HEX64.test(id?.privkey || "") ? id : null;
|
|
127
|
+
} catch { return null; }
|
|
128
|
+
}
|
|
129
|
+
|
|
67
130
|
// --- key file ----------------------------------------------------------------------------------
|
|
131
|
+
// One read per process: hooks sign often, and the Keychain read is the expensive half.
|
|
132
|
+
const processCache = new Map();
|
|
133
|
+
const cacheKey = (name) => `${keysDir()}\x00${name}`;
|
|
68
134
|
export function load(name) {
|
|
135
|
+
const ck = cacheKey(String(name));
|
|
136
|
+
if (processCache.has(ck)) return processCache.get(ck);
|
|
137
|
+
const id = resolveIdentity(String(name));
|
|
138
|
+
processCache.set(ck, id);
|
|
139
|
+
return id;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function resolveIdentity(name) {
|
|
69
143
|
const f = keyPath(name);
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
144
|
+
let fileId = null;
|
|
145
|
+
if (existsSync(f)) {
|
|
146
|
+
try {
|
|
147
|
+
const parsed = JSON.parse(readFileSync(f, "utf8"));
|
|
148
|
+
if (parsed?.pubkey) fileId = parsed;
|
|
149
|
+
} catch { fileId = null; }
|
|
150
|
+
}
|
|
151
|
+
// A file still carrying its privkey is the live record: a rotate or a fresh mint lands in the
|
|
152
|
+
// file first, so it must win over any older keychain copy under the same name.
|
|
153
|
+
if (fileId?.privkey) return fileId;
|
|
154
|
+
if (keychainUsable()) {
|
|
155
|
+
const kc = keychainGet(String(name));
|
|
156
|
+
const stored = kc.value ? parseStored(kc.value) : null;
|
|
157
|
+
// The keychain holds a self-consistent pair; the stub contributes display fields only. Taking
|
|
158
|
+
// the stub's pubkey with the keychain's privkey would mint an identity whose signatures never
|
|
159
|
+
// verify — so the crypto pair always comes from the same store.
|
|
160
|
+
if (stored) return fileId ? { ...stored, ...fileId, privkey: stored.privkey, pubkey: stored.pubkey } : stored;
|
|
161
|
+
if (fileId) {
|
|
162
|
+
warnOnce(kc.error
|
|
163
|
+
? `the Keychain is unavailable (locked?) and "${name}" has no key file to fall back on — signing fails until it unlocks`
|
|
164
|
+
: `"${name}": the key file is a pubkey-only stub but the Keychain has no entry — re-run \`trantor keys migrate\` or restore the key`);
|
|
165
|
+
}
|
|
166
|
+
} else if (fileId) {
|
|
167
|
+
warnOnce(`the Keychain is disabled on this platform and "${name}" has no key file to fall back on — signing fails`);
|
|
168
|
+
}
|
|
169
|
+
return null;
|
|
76
170
|
}
|
|
77
171
|
|
|
78
172
|
// Create-if-missing, safe against two hooks racing at session start: write to a unique temp file,
|
|
79
173
|
// rename into place (atomic on the same filesystem), and if we lost the race, use the winner's key.
|
|
80
174
|
// Two identities for one name would silently split a session's history in half.
|
|
175
|
+
// New keys are born as FILES even when the Keychain is up — `trantor keys migrate` moves them.
|
|
81
176
|
export function loadOrCreate(name, kind = "agent") {
|
|
82
177
|
const existing = load(name);
|
|
83
178
|
if (existing) return existing;
|
|
179
|
+
// A record exists (a pubkey-only stub, or a keychain entry the read above could not use) but its
|
|
180
|
+
// private half is unreachable right now: minting a new key would split the history, so refuse.
|
|
181
|
+
if (existsSync(keyPath(name))) return null;
|
|
84
182
|
mkdirSync(keysDir(), { recursive: true, mode: 0o700 });
|
|
85
183
|
try { chmodSync(keysDir(), 0o700); } catch {}
|
|
86
184
|
const { pubkey, privkey } = generate();
|
|
@@ -89,11 +187,16 @@ export function loadOrCreate(name, kind = "agent") {
|
|
|
89
187
|
const tmp = `${f}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
|
|
90
188
|
try {
|
|
91
189
|
writeFileSync(tmp, JSON.stringify(id), { mode: 0o600 });
|
|
92
|
-
if (existsSync(f)) {
|
|
190
|
+
if (existsSync(f)) { // someone beat us between check and write
|
|
191
|
+
try { unlinkSync(tmp); } catch {} // the loser's tmp still holds a private key
|
|
192
|
+
return load(name) || id;
|
|
193
|
+
}
|
|
93
194
|
renameSync(tmp, f);
|
|
94
195
|
chmodSync(f, 0o600);
|
|
196
|
+
processCache.set(cacheKey(String(name)), id);
|
|
95
197
|
return id;
|
|
96
198
|
} catch {
|
|
199
|
+
try { unlinkSync(tmp); } catch {}
|
|
97
200
|
return load(name) || id; // lost the race, or unwritable — never throw
|
|
98
201
|
}
|
|
99
202
|
}
|
|
@@ -103,6 +206,76 @@ export function publicView(identity) {
|
|
|
103
206
|
return { name: identity.name, kind: identity.kind, pubkey: identity.pubkey, createdAt: identity.createdAt };
|
|
104
207
|
}
|
|
105
208
|
|
|
209
|
+
// --- migrate + status (`trantor keys …`) ---------------------------------------------------------
|
|
210
|
+
// One <safe-name>.json per durable identity; the instances/ subdir holds per-session subkeys that
|
|
211
|
+
// die with the session and stay files by design.
|
|
212
|
+
export function listKeyNames() {
|
|
213
|
+
let files = [];
|
|
214
|
+
try { files = readdirSync(keysDir()); } catch { return []; }
|
|
215
|
+
return files.filter((f) => f.endsWith(".json")).map((f) => f.slice(0, -".json".length)).sort();
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// The stub keeps everything a peer may see plus a pointer at where the private half now lives.
|
|
219
|
+
function writeKeyStub(f, id) {
|
|
220
|
+
const stub = { name: id.name, kind: id.kind, pubkey: id.pubkey, createdAt: id.createdAt, keychain: IDENTITY_SERVICE };
|
|
221
|
+
const tmp = `${f}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
|
|
222
|
+
writeFileSync(tmp, JSON.stringify(stub), { mode: 0o600 });
|
|
223
|
+
renameSync(tmp, f);
|
|
224
|
+
try { chmodSync(f, 0o600); } catch {}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Copy each file-held private key into the Keychain, prove the stored copy SIGNS (a round-trip
|
|
228
|
+
// against the file's pubkey), and only then rewrite the file as a pubkey-only stub. A key that
|
|
229
|
+
// fails to store or to verify keeps its file untouched.
|
|
230
|
+
export function migrateKeysToKeychain({ dryRun = false } = {}) {
|
|
231
|
+
const result = { service: IDENTITY_SERVICE, usable: keychainUsable(), moved: [], already: [], failed: [], dryRun };
|
|
232
|
+
if (!result.usable) return result;
|
|
233
|
+
for (const fileBase of listKeyNames()) {
|
|
234
|
+
let id = null;
|
|
235
|
+
try { id = JSON.parse(readFileSync(keyPath(fileBase), "utf8")); } catch {}
|
|
236
|
+
// The keychain account is the IDENTITY's name (card #11370), i.e. the raw name the JSON
|
|
237
|
+
// carries — the file's name is only the sanitised lookup key and load() reads back by the
|
|
238
|
+
// raw name, so storing under "name_proj" while hooks look for "name:proj" would orphan
|
|
239
|
+
// every migrated key on its first read.
|
|
240
|
+
const name = id?.name ?? fileBase;
|
|
241
|
+
if (!id?.pubkey) { result.failed.push({ name, reason: "unreadable key file" }); continue; }
|
|
242
|
+
if (!id.privkey) { result.already.push(name); continue; }
|
|
243
|
+
if (dryRun) { result.moved.push(name); continue; }
|
|
244
|
+
try {
|
|
245
|
+
keychainSet(name, JSON.stringify(id));
|
|
246
|
+
const stored = parseStored(keychainGet(name).value || "");
|
|
247
|
+
if (!stored || stored.privkey !== id.privkey || stored.pubkey !== id.pubkey) throw new Error("read-back mismatch");
|
|
248
|
+
const probe = Buffer.from(`trantor-keychain-migrate\n${id.pubkey}`, "utf8");
|
|
249
|
+
const sig = cryptoSign(null, probe, privKeyObject(stored));
|
|
250
|
+
if (!cryptoVerify(null, probe, pubFromHex(id.pubkey), sig)) throw new Error("signature round-trip failed");
|
|
251
|
+
writeKeyStub(keyPath(name), id);
|
|
252
|
+
processCache.clear(); // this process now reads stub + keychain
|
|
253
|
+
result.moved.push(name);
|
|
254
|
+
} catch (e) {
|
|
255
|
+
result.failed.push({ name, reason: String(e?.message || e) });
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
return result;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Where does each identity's PRIVATE half live? Presence booleans only — key material is never
|
|
262
|
+
// read into the report.
|
|
263
|
+
export function keyLocationReport() {
|
|
264
|
+
const usable = keychainUsable();
|
|
265
|
+
return listKeyNames().map((fileBase) => {
|
|
266
|
+
let fileKey = false, pubkey = null, name = fileBase;
|
|
267
|
+
try {
|
|
268
|
+
const id = JSON.parse(readFileSync(keyPath(fileBase), "utf8"));
|
|
269
|
+
fileKey = !!id?.privkey;
|
|
270
|
+
pubkey = id?.pubkey || null;
|
|
271
|
+
name = id?.name ?? fileBase; // the identity's real name is what the keychain is keyed on
|
|
272
|
+
} catch {}
|
|
273
|
+
const inKeychain = usable ? !!parseStored(keychainGet(name).value || "") : false;
|
|
274
|
+
const where = fileKey && inKeychain ? "file + keychain" : fileKey ? "file (plaintext)" : inKeychain ? "keychain" : "NO USABLE PRIVATE KEY";
|
|
275
|
+
return { name, pubkey, where, keychain: inKeychain, fileKey };
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
|
|
106
279
|
// --- canonical request -------------------------------------------------------------------------
|
|
107
280
|
export function bodyHash(body) {
|
|
108
281
|
if (body === undefined || body === null || body === "") return "";
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// #11355: ONE shared nudged-id ledger for BOTH orchestrator-wake nudgers — the mechanical
|
|
2
|
+
// com.trantor.wake-nudge daemon and the duty seat's runner-side nudge plan. Each path checks and
|
|
3
|
+
// stamps BEFORE sending; of two nudgers offered the same unread id exactly one send lands, the
|
|
4
|
+
// other logs "already nudged #N". Per-recipient file, atomic check-and-stamp under a lock.
|
|
5
|
+
import { closeSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { busDir } from "./project.mjs";
|
|
8
|
+
|
|
9
|
+
// While an id is nudged under this window it is suppressed; past it the stamp is pruned and a
|
|
10
|
+
// still-unread id is nudgeable again.
|
|
11
|
+
export const NUDGE_LEDGER_TTL_MS = 10 * 60 * 1000;
|
|
12
|
+
|
|
13
|
+
export function nudgeLedgerPath(recipient, { bus = busDir() } = {}) {
|
|
14
|
+
const safe = String(recipient || "").replace(/[^A-Za-z0-9_.-]/g, "_");
|
|
15
|
+
return join(bus, `nudged-${safe}.json`);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function readLedger(path) {
|
|
19
|
+
try {
|
|
20
|
+
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
21
|
+
if (!(parsed?.nudged instanceof Object) || Array.isArray(parsed.nudged)) throw new Error("bad shape");
|
|
22
|
+
return parsed;
|
|
23
|
+
} catch {
|
|
24
|
+
return { version: 1, nudged: {} };
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function writeLedger(path, state) {
|
|
29
|
+
const temporary = `${path}.${process.pid}.tmp`;
|
|
30
|
+
writeFileSync(temporary, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600 });
|
|
31
|
+
renameSync(temporary, path);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function withLedgerLock(path, update) {
|
|
35
|
+
const lockPath = `${path}.lock`;
|
|
36
|
+
let lock = null;
|
|
37
|
+
for (let attempt = 0; attempt < 100 && lock === null; attempt++) {
|
|
38
|
+
try { lock = openSync(lockPath, "wx", 0o600); }
|
|
39
|
+
catch { await new Promise(resolve => setTimeout(resolve, 10)); }
|
|
40
|
+
}
|
|
41
|
+
if (lock === null) throw new Error(`could not lock ${path}`);
|
|
42
|
+
try {
|
|
43
|
+
const state = readLedger(path);
|
|
44
|
+
await update(state);
|
|
45
|
+
writeLedger(path, state);
|
|
46
|
+
} finally {
|
|
47
|
+
closeSync(lock);
|
|
48
|
+
try { unlinkSync(lockPath); } catch {}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Check-and-stamp BEFORE sending: ids stamped under the TTL come back skipped (the caller logs
|
|
53
|
+
// "already nudged #N"), the rest are stamped for `owner`. One locked step, so of two concurrent
|
|
54
|
+
// nudgers exactly one wins.
|
|
55
|
+
export async function reserveNudgeIds({
|
|
56
|
+
recipient, ids, owner, now = Date.now(), ttlMs = NUDGE_LEDGER_TTL_MS, bus, ledgerPath, log = (m) => console.error(m),
|
|
57
|
+
} = {}) {
|
|
58
|
+
const wanted = [...new Set((ids || []).map(id => String(id ?? "").trim()).filter(Boolean))];
|
|
59
|
+
if (!recipient || !wanted.length) return { allowed: [], skipped: [] };
|
|
60
|
+
// ledgerPath is resolved LAZILY from `bus`: a default parameter would bind busDir() at call
|
|
61
|
+
// time and silently ignore the caller's bus override.
|
|
62
|
+
const path = ledgerPath || nudgeLedgerPath(recipient, { bus });
|
|
63
|
+
const allowed = [], skipped = [];
|
|
64
|
+
await withLedgerLock(path, state => {
|
|
65
|
+
for (const id of wanted) {
|
|
66
|
+
const at = Number(state.nudged[id]?.at || 0);
|
|
67
|
+
if (at > 0 && now - at < ttlMs) skipped.push({ id, reason: `already nudged #${id}` });
|
|
68
|
+
else {
|
|
69
|
+
state.nudged[id] = { at: now, by: String(owner || "") };
|
|
70
|
+
allowed.push(id);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
for (const [id, v] of Object.entries(state.nudged)) {
|
|
74
|
+
if (now - Number(v?.at || 0) >= ttlMs) delete state.nudged[id];
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
for (const s of skipped) log(s.reason);
|
|
78
|
+
return { allowed, skipped };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Drop stamps for ids whose send ended UNVERIFIED — the ledger mirrors the duty-claim lifecycle
|
|
82
|
+
// (auditDutyNudges releases unverified claims), so a failed or unconfirmed send never suppresses
|
|
83
|
+
// the other nudger. With `owner`, only that path's own stamps are dropped.
|
|
84
|
+
export async function releaseNudgeIds({ recipient, ids, owner, bus, ledgerPath, now = Date.now(), ttlMs = NUDGE_LEDGER_TTL_MS } = {}) {
|
|
85
|
+
const wanted = [...new Set((ids || []).map(id => String(id ?? "").trim()).filter(Boolean))];
|
|
86
|
+
if (!recipient || !wanted.length) return;
|
|
87
|
+
const path = ledgerPath || nudgeLedgerPath(recipient, { bus });
|
|
88
|
+
await withLedgerLock(path, state => {
|
|
89
|
+
for (const id of wanted) {
|
|
90
|
+
const at = Number(state.nudged[id]?.at || 0);
|
|
91
|
+
const own = !owner || state.nudged[id]?.by === String(owner);
|
|
92
|
+
if (at > 0 && now - at < ttlMs && own) delete state.nudged[id];
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
}
|
package/lib/overseer.mjs
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// so the episode-recurrence path was untestable and therefore unproven.
|
|
4
4
|
const PEER_LIVE_MS = Number(process.env.RELAY_OVERSEER_PEER_LIVE_MS || 5 * 60 * 1000);
|
|
5
5
|
const CLAIM_LIVE_MS = Number(process.env.RELAY_OVERSEER_CLAIM_LIVE_MS || 10 * 60 * 1000);
|
|
6
|
-
const KINDS = new Set(["same-project-sessions", "file-conflict", "linked-activity"]);
|
|
6
|
+
const KINDS = new Set(["same-project-sessions", "file-conflict", "linked-activity", "remote-overlap"]);
|
|
7
7
|
// A card in `todo` is queued, not held; `done`/`failed`/`blocked` are nobody's hands. Only these
|
|
8
8
|
// two mean a session has it open right now.
|
|
9
9
|
const HELD_STATUSES = new Set(["doing", "testing"]);
|
|
@@ -21,7 +21,19 @@ export function levelFor(project, autonomy = {}) {
|
|
|
21
21
|
const direct = autonomy?.[key];
|
|
22
22
|
if (isLevel(direct)) return direct;
|
|
23
23
|
const fallback = autonomy?.["*"];
|
|
24
|
-
return isLevel(fallback) ? fallback :
|
|
24
|
+
return isLevel(fallback) ? fallback : 2;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function checkoutKey({ project, gitRoot }) {
|
|
28
|
+
return JSON.stringify(gitRoot ? ["checkout", gitRoot] : ["project", project]);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function collisionIdentity(collision) {
|
|
32
|
+
// #11311: remote episodes are PROJECT/repo/PR/file per the orchestrator's spec — project, not
|
|
33
|
+
// checkoutKey, so one gitRoot shared across projects never merges two projects' remote episodes.
|
|
34
|
+
const base = collision.kind === "remote-overlap" ? clean(collision.project) : checkoutKey(collision);
|
|
35
|
+
const remote = collision.kind === "remote-overlap" ? ` ${clean(collision.repo)}#${finiteNumber(collision.pr) ?? ""}` : "";
|
|
36
|
+
return `${base} ${collision.kind} ${(collision.files || []).join(",")}${remote}`;
|
|
25
37
|
}
|
|
26
38
|
|
|
27
39
|
function isFresh(ts, now, ttl) {
|
|
@@ -35,13 +47,17 @@ function sortedStrings(items) {
|
|
|
35
47
|
|
|
36
48
|
function pushCollision(out, collision) {
|
|
37
49
|
if (!collision.project || !KINDS.has(collision.kind) || collision.sessions.length === 0) return;
|
|
38
|
-
|
|
50
|
+
const row = {
|
|
39
51
|
project: collision.project,
|
|
40
52
|
kind: collision.kind,
|
|
41
53
|
sessions: sortedStrings(collision.sessions),
|
|
42
54
|
files: sortedStrings(collision.files ?? []),
|
|
43
55
|
detail: collision.detail,
|
|
44
|
-
}
|
|
56
|
+
};
|
|
57
|
+
if (collision.gitRoot) { row.gitRoot = collision.gitRoot; row.projects = collision.projects; }
|
|
58
|
+
// (repo, PR number, file) is the remote-overlap episode identity — collisionIdentity appends it.
|
|
59
|
+
if (collision.repo) { row.repo = clean(collision.repo); row.pr = finiteNumber(collision.pr); }
|
|
60
|
+
out.push(row);
|
|
45
61
|
}
|
|
46
62
|
|
|
47
63
|
function collisionKey(c) {
|
|
@@ -80,22 +96,22 @@ export function detectCollisions({ peers = [], claims = [], links = [], cards =
|
|
|
80
96
|
livePeers.push({ ...peer, session, project });
|
|
81
97
|
}
|
|
82
98
|
|
|
83
|
-
const
|
|
99
|
+
const sessionsByCheckout = new Map();
|
|
84
100
|
for (const peer of livePeers) {
|
|
85
|
-
const
|
|
86
|
-
|
|
87
|
-
|
|
101
|
+
const key = checkoutKey(peer);
|
|
102
|
+
const group = sessionsByCheckout.get(key) ?? [];
|
|
103
|
+
group.push(peer);
|
|
104
|
+
sessionsByCheckout.set(key, group);
|
|
88
105
|
}
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
const sessions = sortedStrings(sessionsByProject.get(project));
|
|
106
|
+
for (const group of sessionsByCheckout.values()) {
|
|
107
|
+
const sessions = sortedStrings(group.map(p => p.session));
|
|
92
108
|
if (sessions.length < 2) continue;
|
|
109
|
+
const projects = sortedStrings(group.map(p => p.project));
|
|
110
|
+
const gitRoot = clean(group[0].gitRoot);
|
|
93
111
|
pushCollision(out, {
|
|
94
|
-
project,
|
|
95
|
-
kind: "same-project-sessions",
|
|
96
|
-
sessions,
|
|
97
|
-
files: [],
|
|
98
|
-
detail: `${sessions.join(", ")} are live on project ${project}.`,
|
|
112
|
+
project: projects[0], projects, gitRoot,
|
|
113
|
+
kind: "same-project-sessions", sessions, files: [],
|
|
114
|
+
detail: `${sessions.join(", ")} are live on ${gitRoot ? `checkout ${gitRoot}` : `project ${projects[0]}`}.`,
|
|
99
115
|
});
|
|
100
116
|
}
|
|
101
117
|
|
|
@@ -105,30 +121,36 @@ export function detectCollisions({ peers = [], claims = [], links = [], cards =
|
|
|
105
121
|
// file — the cross-project overlap that claimSessionsByFile, keyed on project+file, structurally
|
|
106
122
|
// cannot see. linked-activity reads this one.
|
|
107
123
|
const claimProjectsByPath = new Map();
|
|
124
|
+
const liveClaims = [];
|
|
108
125
|
for (const claim of asArray(claims)) {
|
|
109
126
|
const project = clean(claim?.project);
|
|
110
127
|
const file = clean(claim?.file);
|
|
111
128
|
const session = clean(claim?.session);
|
|
112
129
|
if (!project || !file || !session || !isFresh(claim?.ts, at, CLAIM_LIVE_MS)) continue;
|
|
113
|
-
const
|
|
114
|
-
const
|
|
115
|
-
|
|
116
|
-
claimSessionsByFile.
|
|
130
|
+
const gitRoot = clean(claim.gitRoot || livePeers.find(p => p.session === session)?.gitRoot);
|
|
131
|
+
const key = JSON.stringify([checkoutKey({ project, gitRoot }), file]);
|
|
132
|
+
liveClaims.push({ project, file, session, gitRoot, key, remote: claim.remote });
|
|
133
|
+
const group = claimSessionsByFile.get(key) ?? { gitRoot, file, sessions: new Set(), projects: new Set() };
|
|
134
|
+
group.sessions.add(session);
|
|
135
|
+
group.projects.add(project);
|
|
136
|
+
claimSessionsByFile.set(key, group);
|
|
117
137
|
const byProject = claimProjectsByPath.get(file) ?? new Map();
|
|
118
138
|
byProject.set(project, (byProject.get(project) ?? new Set()).add(session));
|
|
119
139
|
claimProjectsByPath.set(file, byProject);
|
|
120
140
|
}
|
|
121
141
|
|
|
122
142
|
for (const key of [...claimSessionsByFile.keys()].sort()) {
|
|
123
|
-
const
|
|
124
|
-
const
|
|
143
|
+
const group = claimSessionsByFile.get(key);
|
|
144
|
+
const projects = sortedStrings(group.projects);
|
|
145
|
+
const project = projects[0], file = group.file;
|
|
146
|
+
const sessions = sortedStrings(group.sessions);
|
|
125
147
|
if (sessions.length < 2) continue;
|
|
126
148
|
pushCollision(out, {
|
|
127
149
|
project,
|
|
128
|
-
kind: "file-conflict",
|
|
150
|
+
kind: "file-conflict", gitRoot: group.gitRoot, projects,
|
|
129
151
|
sessions,
|
|
130
152
|
files: [file],
|
|
131
|
-
detail: `${sessions.join(", ")} have live claims on ${project}/${file}.`,
|
|
153
|
+
detail: `${sessions.join(", ")} have live claims on ${group.gitRoot || project}/${file}.`,
|
|
132
154
|
});
|
|
133
155
|
}
|
|
134
156
|
|
|
@@ -191,6 +213,42 @@ export function detectCollisions({ peers = [], claims = [], links = [], cards =
|
|
|
191
213
|
});
|
|
192
214
|
}
|
|
193
215
|
|
|
216
|
+
// #11311: open-PR evidence is computed CLIENT-SIDE (hooks/file-claim.mjs polls the claimant's
|
|
217
|
+
// own checkout via lib/remote-overlap.mjs) and rides the claim as its `remote` field — repo- and
|
|
218
|
+
// checkout-scoped by construction, one collision per (repo, PR, file) triple, warn-level, gone
|
|
219
|
+
// when the PR closes. That triple IS the episode identity (collisionIdentity appends repo#pr).
|
|
220
|
+
for (const claim of liveClaims) {
|
|
221
|
+
for (const r of asArray(claim.remote)) {
|
|
222
|
+
const pr = finiteNumber(r?.number);
|
|
223
|
+
const repo = clean(r?.repo);
|
|
224
|
+
if (pr == null || !repo || !repo.includes("/")) continue;
|
|
225
|
+
const author = clean(r?.author) || "unknown";
|
|
226
|
+
const url = clean(r?.url);
|
|
227
|
+
for (const file of sortedStrings(r.files)) {
|
|
228
|
+
if (file !== claim.file) continue;
|
|
229
|
+
// Recipients are EVIDENCE-MATCHED (codex hub case): within the same project+file, only
|
|
230
|
+
// claimants whose OWN remote carries this same repo+PR get warned — a checkout without
|
|
231
|
+
// that evidence is not a participant in a PR its repo does not have.
|
|
232
|
+
const sessions = sortedStrings(
|
|
233
|
+
liveClaims
|
|
234
|
+
.filter((c) => c.project === claim.project && c.file === claim.file
|
|
235
|
+
&& asArray(c.remote).some((x) => clean(x?.repo) === repo && finiteNumber(x?.number) === pr))
|
|
236
|
+
.map((c) => c.session)
|
|
237
|
+
);
|
|
238
|
+
pushCollision(out, {
|
|
239
|
+
project: claim.project,
|
|
240
|
+
kind: "remote-overlap",
|
|
241
|
+
gitRoot: claim.gitRoot,
|
|
242
|
+
sessions,
|
|
243
|
+
files: [file],
|
|
244
|
+
repo,
|
|
245
|
+
pr,
|
|
246
|
+
detail: `open PR #${pr} by ${author} in ${repo} also changes ${file}${url ? ` (${url})` : ""}.`,
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
194
252
|
const deduped = new Map();
|
|
195
253
|
for (const collision of sortCollisions(out)) deduped.set(collisionKey(collision), collision);
|
|
196
254
|
return [...deduped.values()];
|
package/lib/persona.mjs
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
export function personaName(value) {
|
|
5
|
+
if (!/^[A-Za-z0-9_-]+$/.test(value || "")) throw new Error("persona must contain only letters, digits, underscores or hyphens");
|
|
6
|
+
return value;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function runnerPersona(env, agent) {
|
|
10
|
+
return personaName(env.CREW_PERSONA || agent);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function allocatePersona({ dir, project, agent, model = "", name, occupied = [], dry = false }) {
|
|
14
|
+
personaName(agent);
|
|
15
|
+
const folder = join(dir, "personas", encodeURIComponent(project));
|
|
16
|
+
if (!dry) mkdirSync(folder, { recursive: true });
|
|
17
|
+
for (let n = 1; ; n++) {
|
|
18
|
+
const persona = personaName(name || (n === 1 ? agent : `${agent}-${n}`));
|
|
19
|
+
if (occupied.includes(persona)) {
|
|
20
|
+
if (name) throw new Error(`persona ${persona} is already active`);
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
const file = join(folder, `${persona}.json`);
|
|
24
|
+
const seat = { persona, agent, model };
|
|
25
|
+
if (dry) return seat;
|
|
26
|
+
try {
|
|
27
|
+
writeFileSync(file, JSON.stringify(seat), { flag: "wx", mode: 0o600 });
|
|
28
|
+
return seat;
|
|
29
|
+
} catch (error) {
|
|
30
|
+
if (error.code !== "EEXIST") throw error;
|
|
31
|
+
if (!name) continue;
|
|
32
|
+
const prior = JSON.parse(readFileSync(file, "utf8"));
|
|
33
|
+
if (prior.agent !== agent) throw new Error(`persona ${persona} runs ${prior.agent}; use swap to change its agent`);
|
|
34
|
+
return { ...prior, model };
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function updatePersona({ dir, project, persona, agent, model = "" }) {
|
|
40
|
+
const folder = join(dir, "personas", encodeURIComponent(project));
|
|
41
|
+
mkdirSync(folder, { recursive: true });
|
|
42
|
+
writeFileSync(join(folder, `${personaName(persona)}.json`), JSON.stringify({ persona, agent, model }), { mode: 0o600 });
|
|
43
|
+
}
|