ruvnet-brain 4.3.40 → 4.4.0
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/README.md +2 -2
- package/bin/install.mjs +111 -27
- package/kb/brain-profile.mjs +17 -2
- package/kb/forge-update.mjs +6 -2
- package/kb/lifecycle-evidence-retention.mjs +12 -9
- package/kb/refresh-run.mjs +17 -1
- package/kb/update-storage-transaction.mjs +33 -5
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/hooks/codex-hooks.json +2 -2
- package/plugin/hooks/hooks.json +1 -1
- package/plugin/scripts/capability-registry.mjs +3 -3
- package/plugin/scripts/codex-hook-wrapper.mjs +7 -2
- package/plugin/scripts/design-wall.sh +1 -0
- package/plugin/scripts/ground-before-write.sh +1 -0
- package/plugin/scripts/ground-ruvnet.sh +3 -3
- package/plugin/scripts/grounding-answer.mjs +129 -0
- package/plugin/scripts/grounding-stamp.sh +32 -31
- package/plugin/scripts/grounding-turn-evidence.mjs +99 -5
- package/plugin/scripts/grounding-turn-gate.mjs +25 -6
- package/plugin/scripts/hook-shim.mjs +3 -0
- package/plugin/scripts/kling-preflight.sh +1 -0
- package/plugin/scripts/learn-capture.sh +1 -0
- package/plugin/scripts/project-progression-sources.mjs +16 -4
- package/plugin/scripts/project-progression-store.mjs +49 -1
- package/plugin/scripts/protect-brain-state.sh +1 -0
- package/plugin/scripts/route-dispatch.sh +1 -0
- package/plugin/scripts/session-snapshot-hook.mjs +224 -38
- package/plugin/scripts/session-start-health.mjs +24 -3
- package/plugin/scripts/session-start-update-plane.mjs +1 -1
- package/plugin/scripts/update-apply.mjs +22 -2
- package/scripts/console-instances.mjs +145 -0
- package/scripts/console-runtime-identity.mjs +2 -0
- package/scripts/corpus-canary.mjs +130 -18
- package/scripts/customer-seams.mjs +84 -0
- package/scripts/customer-state-matrix.mjs +363 -0
- package/scripts/full-suite-gate.mjs +155 -0
- package/scripts/grounding-turn-replay.mjs +11 -3
- package/scripts/hook-qualify-core.mjs +346 -0
- package/scripts/hook-qualify-hosts.mjs +115 -0
- package/scripts/hook-qualify.mjs +101 -0
- package/scripts/host-cli.mjs +115 -0
- package/scripts/qe/agentic-qe-4.3.mjs +0 -1
- package/scripts/route-gold-rank.mjs +156 -0
- package/scripts/route-index-memory.mjs +51 -0
- package/scripts/route-latency-warm.mjs +123 -0
- package/scripts/wired-check.mjs +17 -3
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
-
import { spawnSync } from 'node:child_process';
|
|
3
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { ProgressionOutbox } from './project-progression-outbox.mjs';
|
|
4
6
|
import {
|
|
5
7
|
captureProjectTransition,
|
|
6
8
|
hasProjectProgression,
|
|
@@ -19,6 +21,23 @@ import { captureTurnOutcome } from './turn-outcome-capture.mjs';
|
|
|
19
21
|
*/
|
|
20
22
|
export const CAPTURE_BUDGET_MS = 8_000;
|
|
21
23
|
|
|
24
|
+
/**
|
|
25
|
+
* Replaying an interrupted session's outbox costs one `ruflo` write per pending snapshot, each ~3s
|
|
26
|
+
* cold (project-progression-store.mjs). Under this budget there is room for the NEW snapshot or the
|
|
27
|
+
* old ones, not both — and the new one is the one nothing else will ever write.
|
|
28
|
+
*/
|
|
29
|
+
export const REPLAY_MIN_BUDGET_MS = 4_000;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The budget this invocation really has. The Codex wrapper hands its own kill deadline down as
|
|
33
|
+
* RUVNET_CODEX_BUDGET_MS (2200ms at SessionEnd, which Codex caps at 3s); planning for 8s there meant
|
|
34
|
+
* being SIGKILLed mid-write with nothing reported. 300ms is left for the adapter → shim → body spawns.
|
|
35
|
+
*/
|
|
36
|
+
export function effectiveBudgetMs(env = process.env) {
|
|
37
|
+
const handed = Number(env.RUVNET_CODEX_BUDGET_MS);
|
|
38
|
+
return Number.isFinite(handed) && handed > 0 ? Math.max(0, Math.min(CAPTURE_BUDGET_MS, handed - 300)) : CAPTURE_BUDGET_MS;
|
|
39
|
+
}
|
|
40
|
+
|
|
22
41
|
function regularOrAbsent(file) {
|
|
23
42
|
try {
|
|
24
43
|
const stat = fs.lstatSync(file);
|
|
@@ -96,11 +115,16 @@ export function runSessionSnapshotHook(projectDir, event, {
|
|
|
96
115
|
host = process.env.RUVNET_HOOK_HOST || 'claude',
|
|
97
116
|
captureProgression = captureProjectTransition,
|
|
98
117
|
produce = buildProjectProgression,
|
|
99
|
-
budgetMs =
|
|
118
|
+
budgetMs = effectiveBudgetMs(),
|
|
100
119
|
now = Date.now,
|
|
101
120
|
captureTurn = captureTurnOutcome,
|
|
121
|
+
writeMetadata = true,
|
|
122
|
+
makeStoreFactory = boundedStoreFactory,
|
|
123
|
+
spawnReplay = replayOutboxDetached,
|
|
124
|
+
ordered = null,
|
|
102
125
|
} = {}) {
|
|
103
|
-
|
|
126
|
+
// The detached worker re-runs a QUEUED boundary; its session receipt was already written then.
|
|
127
|
+
const metadataWritten = writeMetadata ? writeSessionSnapshot(projectDir, event) : false;
|
|
104
128
|
let payload;
|
|
105
129
|
try { payload = rawInput ? JSON.parse(rawInput) : {}; } catch { payload = {}; }
|
|
106
130
|
// TURN OUTCOMES FIRST, and independent of `.swarm`: every turn in every repository is recorded
|
|
@@ -134,51 +158,213 @@ export function runSessionSnapshotHook(projectDir, event, {
|
|
|
134
158
|
return { ...idle, skipped: 'project has not adopted the canonical store' };
|
|
135
159
|
}
|
|
136
160
|
|
|
137
|
-
const
|
|
138
|
-
const
|
|
161
|
+
const root = resolution.projectRoot;
|
|
162
|
+
const pendingCount = () => {
|
|
163
|
+
try { return new ProgressionOutbox({ projectRoot: root }).pendingSnapshots().length; } catch { return 0; }
|
|
164
|
+
};
|
|
139
165
|
|
|
140
|
-
//
|
|
141
|
-
//
|
|
142
|
-
//
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
166
|
+
// CAUSAL ORDER. The producer links a new snapshot to the COMMITTED heads
|
|
167
|
+
// (project-progression-producer.mjs), so a snapshot produced while older work is uncommitted would
|
|
168
|
+
// not descend from it and the project would end with two unrelated heads
|
|
169
|
+
// (tests/acceptance/cross-host-project-resume.test.mjs). "Older work" is BOTH the outbox (captures
|
|
170
|
+
// interrupted after their fsync) AND the capture queue (whole boundaries waiting for a worker).
|
|
171
|
+
//
|
|
172
|
+
// THE REPLAY LOCK IS THE RIGHT TO COMMIT IN ORDER (4.4.0 re-review S-A). Every boundary takes it
|
|
173
|
+
// before doing anything that commits. If it cannot — a live worker holds it — or older work is
|
|
174
|
+
// queued, or (on a short budget) outbox debt cannot be replayed here, this boundary QUEUES itself
|
|
175
|
+
// behind that work and the lock passes to a DETACHED, bounded worker that drains everything in
|
|
176
|
+
// order. On Codex no boundary has the replay budget (Stop 3700ms effective, SessionEnd 1900ms, no
|
|
177
|
+
// PreCompact). Any boundary, of any budget, therefore also drains a queue a dead worker stranded.
|
|
178
|
+
// `ordered` is the worker's own re-entry: it already holds the lock and is draining in order.
|
|
179
|
+
let token = ordered;
|
|
180
|
+
const handOff = (why) => {
|
|
181
|
+
const queued = queueCapture({ projectDir: root, event, host, payload });
|
|
182
|
+
const handed = queued ? spawnReplay({ projectDir: root, token }) : false;
|
|
183
|
+
if (!handed && token && token !== ordered) releaseReplayLock(root, token);
|
|
184
|
+
return { ...idle, replayed: 0, progressionCaptured: false, deferredToReplayer: Boolean(queued),
|
|
185
|
+
replaySkipped: `${why}; this capture ${queued ? 'queued behind it' : 'NOT queued (queue unwritable)'}`
|
|
186
|
+
+ `${queued ? (handed ? ', handed to a detached worker' : ' (a live worker holds the lock and drains the queue)') : ''}` };
|
|
187
|
+
};
|
|
188
|
+
if (!ordered) {
|
|
189
|
+
token = takeReplayLock(root);
|
|
190
|
+
if (!token) return handOff('a worker is committing older work');
|
|
191
|
+
const queuedAhead = queuedCaptures(root).length;
|
|
192
|
+
if (queuedAhead) return handOff(`${queuedAhead} older capture(s) queued`);
|
|
193
|
+
if (budgetMs < REPLAY_MIN_BUDGET_MS) {
|
|
194
|
+
const pending = pendingCount();
|
|
195
|
+
if (pending) return handOff(`outbox replay deferred: budget ${budgetMs}ms < ${REPLAY_MIN_BUDGET_MS}ms; ${pending} pending`);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
147
198
|
|
|
148
|
-
let
|
|
199
|
+
let handedLock = false;
|
|
149
200
|
try {
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
201
|
+
const deadlineAt = now() + budgetMs;
|
|
202
|
+
const storeFactory = makeStoreFactory(deadlineAt);
|
|
203
|
+
let replayed = 0;
|
|
204
|
+
if (budgetMs >= REPLAY_MIN_BUDGET_MS) {
|
|
205
|
+
try {
|
|
206
|
+
replayed = storeFactory({ projectDir, requestedStorePath: resolution.canonicalAgentDbPath }).replay().length;
|
|
207
|
+
} catch { /* the debt stays durable in the outbox; this capture is still worth attempting */ }
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
let produced;
|
|
211
|
+
try {
|
|
212
|
+
produced = produce({ resolution, payload, host, trigger: event });
|
|
213
|
+
} catch (error) {
|
|
214
|
+
return { ...idle, replayed, skipped: `producer failed: ${error.message}` };
|
|
215
|
+
}
|
|
216
|
+
if (produced.skipped) return { ...idle, replayed, skipped: produced.skipped.reason };
|
|
217
|
+
|
|
218
|
+
let result;
|
|
219
|
+
try {
|
|
220
|
+
result = captureProgression({
|
|
221
|
+
host,
|
|
222
|
+
payload: { ...payload, hook_event_name: event, projectProgression: produced.projectProgression },
|
|
223
|
+
projectDir,
|
|
224
|
+
storeFactory,
|
|
225
|
+
});
|
|
226
|
+
} catch (error) {
|
|
227
|
+
// NOT LOST — DEFERRED. capture() fsyncs the snapshot to the durable outbox BEFORE it writes to
|
|
228
|
+
// the store, so a budget overrun leaves the evidence on disk. On a short budget nothing later in
|
|
229
|
+
// this process can settle it, so the lock goes straight to a detached worker.
|
|
230
|
+
handedLock = !ordered && budgetMs < REPLAY_MIN_BUDGET_MS && pendingCount() > 0 && spawnReplay({ projectDir: root, token });
|
|
231
|
+
return { ...idle, replayed, skipped: `capture deferred: ${error.message}`,
|
|
232
|
+
...(handedLock ? { replaySkipped: 'deferred capture handed to a detached worker' } : {}) };
|
|
233
|
+
}
|
|
234
|
+
return {
|
|
235
|
+
metadataWritten,
|
|
236
|
+
progressionCaptured: true,
|
|
237
|
+
turn,
|
|
238
|
+
replayed,
|
|
239
|
+
receipt: result.receipt,
|
|
240
|
+
provenance: produced.provenance,
|
|
241
|
+
};
|
|
242
|
+
} finally {
|
|
243
|
+
if (!ordered && !handedLock) releaseReplayLock(root, token);
|
|
153
244
|
}
|
|
154
|
-
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* How long the detached worker may spend per step. The lock is refreshed between steps and goes stale
|
|
249
|
+
* after REPLAY_LOCK_STALE_MS, which is more than twice a step, so a live worker never looks dead.
|
|
250
|
+
*/
|
|
251
|
+
export const DETACHED_REPLAY_BUDGET_MS = 45_000;
|
|
252
|
+
export const REPLAY_LOCK_STALE_MS = 120_000;
|
|
253
|
+
const REPLAY_LOCK = '.progression-replay.lock';
|
|
254
|
+
const QUEUE_PREFIX = '.progression-capture-queue-';
|
|
255
|
+
const lockPath = (projectDir) => path.join(projectDir, '.swarm', REPLAY_LOCK);
|
|
256
|
+
const readLock = (projectDir) => { try { return fs.readFileSync(lockPath(projectDir), 'utf8').trim(); } catch { return null; } };
|
|
257
|
+
let queueSeq = 0;
|
|
155
258
|
|
|
156
|
-
|
|
259
|
+
/** Queue one boundary's capture for the worker (0600, inside the project's own .swarm), in arrival order. */
|
|
260
|
+
export function queueCapture({ projectDir, event, host, payload, now = Date.now() }) {
|
|
157
261
|
try {
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
262
|
+
queueSeq += 1;
|
|
263
|
+
const name = `${QUEUE_PREFIX}${String(now).padStart(15, '0')}-${String(process.hrtime.bigint() % 1_000_000_000n).padStart(9, '0')}-${process.pid}-${queueSeq}.json`;
|
|
264
|
+
const file = path.join(projectDir, '.swarm', name);
|
|
265
|
+
fs.writeFileSync(file, JSON.stringify({ event, host, payload }), { flag: 'wx', mode: 0o600 });
|
|
266
|
+
return file;
|
|
267
|
+
} catch { return null; }
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export function queuedCaptures(projectDir) {
|
|
271
|
+
try {
|
|
272
|
+
return fs.readdirSync(path.join(projectDir, '.swarm')).filter((n) => n.startsWith(QUEUE_PREFIX) && n.endsWith('.json'))
|
|
273
|
+
.sort().map((n) => path.join(projectDir, '.swarm', n));
|
|
274
|
+
} catch { return []; }
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Take the lock. Returns this holder's TOKEN, or null when a live holder has it. A lock not refreshed
|
|
279
|
+
* for REPLAY_LOCK_STALE_MS belongs to a dead worker and is taken over: renamed aside first, so two
|
|
280
|
+
* would-be successors cannot both win the exclusive create.
|
|
281
|
+
*/
|
|
282
|
+
export function takeReplayLock(projectDir, now = Date.now()) {
|
|
283
|
+
const lock = lockPath(projectDir);
|
|
284
|
+
const token = `${process.pid}-${now}-${Math.random().toString(36).slice(2, 10)}`;
|
|
285
|
+
const create = () => { fs.writeFileSync(lock, `${token}\n`, { flag: 'wx', mode: 0o600 }); return token; };
|
|
286
|
+
try { return create(); } catch { /* held, or stale */ }
|
|
287
|
+
try {
|
|
288
|
+
if (now - fs.statSync(lock).mtimeMs <= REPLAY_LOCK_STALE_MS) return null;
|
|
289
|
+
const aside = `${lock}.stale-${process.pid}-${now}`;
|
|
290
|
+
fs.renameSync(lock, aside);
|
|
291
|
+
fs.rmSync(aside, { force: true });
|
|
292
|
+
return create();
|
|
293
|
+
} catch { return null; }
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** Heartbeat: refresh the lock's mtime if (and only if) this holder still owns it. */
|
|
297
|
+
export function refreshReplayLock(projectDir, token) {
|
|
298
|
+
if (!token || readLock(projectDir) !== token) return false;
|
|
299
|
+
try { const t = new Date(); fs.utimesSync(lockPath(projectDir), t, t); return true; } catch { return false; }
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** Release ONLY a lock this holder owns; a successor's lock is never deleted. */
|
|
303
|
+
export function releaseReplayLock(projectDir, token) {
|
|
304
|
+
if (!token || readLock(projectDir) !== token) return false;
|
|
305
|
+
try { fs.rmSync(lockPath(projectDir), { force: true }); return true; } catch { return false; }
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** Hand the lock (or take it, if free) to a detached worker. Returns whether one was started. Never throws. */
|
|
309
|
+
export function replayOutboxDetached({ projectDir, token = null, spawnFn = spawn } = {}) {
|
|
310
|
+
const held = token || takeReplayLock(projectDir);
|
|
311
|
+
if (!held) return false;
|
|
312
|
+
try {
|
|
313
|
+
const child = spawnFn(process.execPath, [fileURLToPath(import.meta.url), '--replay-outbox'], {
|
|
314
|
+
cwd: projectDir, detached: true, stdio: 'ignore', windowsHide: true,
|
|
315
|
+
env: { ...process.env, RUVNET_REPLAY_LOCK_TOKEN: held },
|
|
163
316
|
});
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
return { ...idle, replayed, skipped: `capture deferred: ${error.message}` };
|
|
317
|
+
child.unref?.();
|
|
318
|
+
return true;
|
|
319
|
+
} catch {
|
|
320
|
+
releaseReplayLock(projectDir, held);
|
|
321
|
+
return false;
|
|
170
322
|
}
|
|
171
|
-
return {
|
|
172
|
-
metadataWritten,
|
|
173
|
-
progressionCaptured: true,
|
|
174
|
-
turn,
|
|
175
|
-
replayed,
|
|
176
|
-
receipt: result.receipt,
|
|
177
|
-
provenance: produced.provenance,
|
|
178
|
-
};
|
|
179
323
|
}
|
|
180
324
|
|
|
181
|
-
|
|
325
|
+
/**
|
|
326
|
+
* The detached worker's body, holding the lock `token`: replay the outbox, then run every queued capture
|
|
327
|
+
* IN ORDER — each re-entering the boundary as `ordered`, so it replays before it produces — refreshing
|
|
328
|
+
* the lock between steps and STOPPING the moment the lock is no longer its own (a successor took it
|
|
329
|
+
* over: running on would duplicate its work). Releases only its own lock, then re-checks for captures
|
|
330
|
+
* queued while it held it.
|
|
331
|
+
*/
|
|
332
|
+
export function runOutboxReplay({ projectDir, token = process.env.RUVNET_REPLAY_LOCK_TOKEN || null, budgetMs = DETACHED_REPLAY_BUDGET_MS,
|
|
333
|
+
makeStoreFactory = boundedStoreFactory, now = Date.now, runCapture = runSessionSnapshotHook } = {}) {
|
|
334
|
+
let held = token || takeReplayLock(projectDir);
|
|
335
|
+
let replayed = 0;
|
|
336
|
+
for (let round = 0; held && round < 8; round += 1) {
|
|
337
|
+
try {
|
|
338
|
+
if (!refreshReplayLock(projectDir, held)) return replayed;
|
|
339
|
+
const resolution = resolveProjectStore({ projectDir });
|
|
340
|
+
const store = makeStoreFactory(now() + budgetMs)({ projectDir, requestedStorePath: resolution.canonicalAgentDbPath });
|
|
341
|
+
for (const snapshot of store.outbox.pendingSnapshots()) {
|
|
342
|
+
if (!refreshReplayLock(projectDir, held)) return replayed;
|
|
343
|
+
store.outbox.markCommitted(store.appendExact(snapshot));
|
|
344
|
+
replayed += 1;
|
|
345
|
+
}
|
|
346
|
+
for (const file of queuedCaptures(projectDir)) {
|
|
347
|
+
if (!refreshReplayLock(projectDir, held)) return replayed;
|
|
348
|
+
let job = null;
|
|
349
|
+
try { job = JSON.parse(fs.readFileSync(file, 'utf8')); } catch { /* torn: dropped below */ }
|
|
350
|
+
try {
|
|
351
|
+
if (job) runCapture(projectDir, job.event, { rawInput: JSON.stringify(job.payload), host: job.host,
|
|
352
|
+
budgetMs, makeStoreFactory, now, ordered: held, writeMetadata: false,
|
|
353
|
+
captureTurn: () => ({ recorded: false, skipped: 'detached replay' }) });
|
|
354
|
+
} catch { /* a failed capture leaves its own snapshot durable in the outbox */ }
|
|
355
|
+
try { fs.rmSync(file, { force: true }); } catch { /* best effort */ }
|
|
356
|
+
}
|
|
357
|
+
} catch { /* the debt stays durable; the next boundary hands it on again */ } finally {
|
|
358
|
+
releaseReplayLock(projectDir, held);
|
|
359
|
+
}
|
|
360
|
+
held = queuedCaptures(projectDir).length ? takeReplayLock(projectDir) : null;
|
|
361
|
+
}
|
|
362
|
+
return replayed;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
if (process.argv[1] && path.resolve(process.argv[1]).endsWith('session-snapshot-hook.mjs') && process.argv[2] === '--replay-outbox') {
|
|
366
|
+
try { runOutboxReplay({ projectDir: process.cwd() }); } catch { /* the debt stays durable in the outbox */ }
|
|
367
|
+
} else if (process.argv[1] && path.resolve(process.argv[1]).endsWith('session-snapshot-hook.mjs')) {
|
|
182
368
|
// projectDirectory() is the SAME derivation the Console's detector uses. Deriving it here
|
|
183
369
|
// independently is what let this hook write a receipt the Console then reported as missing (#85).
|
|
184
370
|
const rawInput = fs.readFileSync(0, 'utf8');
|
|
@@ -93,13 +93,29 @@ export const knowledgeFacts = ({ env = process.env, home, now = Date.now() } = {
|
|
|
93
93
|
lockMs: Date.parse(json(auto.lockFile)?.at || '') || mtimeMs(auto.lockFile) };
|
|
94
94
|
};
|
|
95
95
|
|
|
96
|
+
/**
|
|
97
|
+
* agentic-kit ownership is a CLAIM in kit.json, not a delivery: on the owner's Mac (2026-09-30)
|
|
98
|
+
* kit.json said ruvnetBrain:true while agentic-kit scheduled nothing, so the self-heal stood down
|
|
99
|
+
* forever and the knowledge base aged by hand only. Ownership is honoured only while an update is
|
|
100
|
+
* PROVEN inside this window (a successful refresh receipt or a CURRENT --check verdict); 36h leaves
|
|
101
|
+
* the self-heal 12h to land one before the 48h invariant breaks.
|
|
102
|
+
*/
|
|
103
|
+
export const AGENTIC_KIT_PROOF_HOURS = 36;
|
|
104
|
+
/** 'none' | 'delivering' (kit.json claims it AND an update is proven) | 'not-delivering'. */
|
|
105
|
+
export const agenticKitUpdates = ({ home, facts }) => {
|
|
106
|
+
if (!updateOwnedByAgenticKit(home)) return 'none';
|
|
107
|
+
return facts.provenWithin(AGENTIC_KIT_PROOF_HOURS) ? 'delivering' : 'not-delivering';
|
|
108
|
+
};
|
|
109
|
+
|
|
96
110
|
/** Why the SessionStart knowledge auto-update may NEVER run on this machine ('' = it may). */
|
|
97
111
|
export const autoUpdateOptOut = ({ env = process.env, home, facts }) => {
|
|
98
112
|
const flag = String(env.RUVNET_AUTO_UPDATE || '').toLowerCase();
|
|
99
113
|
if (flag === 'off') return 'RUVNET_AUTO_UPDATE=off';
|
|
100
114
|
if (env.RUVNET_BRAIN_TEST === '1' && flag !== 'on') return 'test mode';
|
|
101
115
|
if (read(path.join(facts.brainHome, '.auto-update-pref')).trim() === 'no') return 'you answered no to background auto-update';
|
|
102
|
-
if (
|
|
116
|
+
if (agenticKitUpdates({ home, facts }) === 'delivering') {
|
|
117
|
+
return `agentic-kit owns updates and one is proven within ${AGENTIC_KIT_PROOF_HOURS}h: ak sync`;
|
|
118
|
+
}
|
|
103
119
|
if (!exists(path.join(facts.kbDir, 'forge-update.mjs'))) return 'this install predates the self-updater';
|
|
104
120
|
return '';
|
|
105
121
|
};
|
|
@@ -122,8 +138,10 @@ export const knowledgeCurrency = ({ env = process.env, home, now = Date.now(), w
|
|
|
122
138
|
const ageKnown = Number.isFinite(builtMs);
|
|
123
139
|
if (!failing && proven) return '';
|
|
124
140
|
if (!failing && ageKnown && hours(builtMs) <= windowHours) return '';
|
|
125
|
-
const
|
|
126
|
-
const
|
|
141
|
+
const kit = agenticKitUpdates({ home, facts });
|
|
142
|
+
const agentKit = kit === 'delivering';
|
|
143
|
+
// An agentic-kit machine must never be told to also --enable-nightly (one owner per machine).
|
|
144
|
+
const scheduled = kit !== 'none' || readNightlyRegistration({ brainHome }).ok;
|
|
127
145
|
const parts = [ageKnown ? `knowledge base built ${day(builtMs)} (${age(builtMs)})`
|
|
128
146
|
: 'knowledge base age UNKNOWN (SOURCE.json missing or unreadable)'];
|
|
129
147
|
if (autoFailed) {
|
|
@@ -138,6 +156,9 @@ export const knowledgeCurrency = ({ env = process.env, home, now = Date.now(), w
|
|
|
138
156
|
parts.push(history.receipts
|
|
139
157
|
? `${history.failuresSinceSuccess} failed run(s) since the last success (${history.lastSuccess ? day(history.lastSuccess.at) : 'none recorded'})`
|
|
140
158
|
: 'no refresh has ever run on this machine');
|
|
159
|
+
if (kit === 'not-delivering') {
|
|
160
|
+
parts.push(`agentic-kit claims updates (kit.json ruvnetBrain:true) but no update is proven in ${AGENTIC_KIT_PROOF_HOURS}h, so the Brain's own self-heal runs instead`);
|
|
161
|
+
}
|
|
141
162
|
if (!scheduled) parts.push('no nightly refresh is scheduled');
|
|
142
163
|
if (history.unreadable) parts.push(`${history.unreadable} unreadable receipt(s)`);
|
|
143
164
|
const fix = agentKit ? 'ak sync' : scheduled ? 'npx ruvnet-brain@latest --update'
|
|
@@ -145,7 +145,7 @@ export const heartbeat = ({ env, hookDir, stateDir, home, running, seedDispatche
|
|
|
145
145
|
if (pref === 'yes' && exists(path.join(kbDir, 'forge-update.mjs'))) {
|
|
146
146
|
const kbLog = path.join(stateDir, '.last-kb-check.log');
|
|
147
147
|
if (/\bBEHIND\b/.test(read(kbLog))) {
|
|
148
|
-
emit('[RuvNet Brain — a newer knowledge bundle is available. It is signed (Ed25519) and the updater verifies that signature before extracting anything. We do NOT auto-apply it: applying replaces executable tool files, which is your call. To update:
|
|
148
|
+
emit('[RuvNet Brain — a newer knowledge bundle is available. It is signed (Ed25519) and the updater verifies that signature before extracting anything. We do NOT auto-apply it: applying replaces executable tool files, which is your call. To update: npx ruvnet-brain@latest --update]');
|
|
149
149
|
}
|
|
150
150
|
// S2 (ONE CURRENCY VERDICT): --result-file records the SAME structured verdict --check/--apply
|
|
151
151
|
// and bin/install.mjs already read (forge-update.mjs's currencyVerdict()), at the well-known path
|
|
@@ -53,10 +53,19 @@ const RECEIPTS = path.join(BRAIN_HOME, 'update-receipts.jsonl');
|
|
|
53
53
|
const LEASES = path.join(BRAIN_HOME, 'leases');
|
|
54
54
|
const DEV = path.join(BRAIN_HOME, 'dev.json');
|
|
55
55
|
const SEEDED = path.join(BRAIN_HOME, '.spine-seeded');
|
|
56
|
+
// Claude Code honours CLAUDE_CONFIG_DIR for its whole config tree, plugins included; Codex honours CODEX_HOME.
|
|
57
|
+
const CLAUDE_CONFIG = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
|
|
58
|
+
const CODEX_CONFIG = process.env.CODEX_HOME || path.join(os.homedir(), '.codex');
|
|
56
59
|
const PLUGIN_CACHES = [
|
|
57
|
-
path.join(
|
|
58
|
-
path.join(
|
|
60
|
+
path.join(CLAUDE_CONFIG, 'plugins', 'cache', 'ruvnet-brain', 'ruvnet-brain'),
|
|
61
|
+
path.join(CODEX_CONFIG, 'plugins', 'cache', 'ruvnet-brain', 'ruvnet-brain'),
|
|
59
62
|
];
|
|
63
|
+
// Evidence that a host has been pointed at the Brain's plugin at all (marketplace registered or plugin
|
|
64
|
+
// cache created) — even when `plugin install` then failed and staged nothing.
|
|
65
|
+
const HOST_PLUGIN_EVIDENCE = [CLAUDE_CONFIG, CODEX_CONFIG].flatMap((root) => [
|
|
66
|
+
path.join(root, 'plugins', 'marketplaces', 'ruvnet-brain'),
|
|
67
|
+
path.join(root, 'plugins', 'cache', 'ruvnet-brain'),
|
|
68
|
+
]);
|
|
60
69
|
const LEASE_FRESH_MS = 6 * 3600_000; // a lease older than 6h is stale (its process is long gone)
|
|
61
70
|
|
|
62
71
|
const argv = process.argv.slice(2);
|
|
@@ -425,6 +434,17 @@ function main() {
|
|
|
425
434
|
console.log(`already on ${activeNow.version}, at or above requested ${expectedVersion} — nothing to apply.`);
|
|
426
435
|
return 0;
|
|
427
436
|
}
|
|
437
|
+
// NO HOST AT ALL is not a stale spine. With no active spine and no payload of ANY version in
|
|
438
|
+
// any host cache, nothing was ever seeded, so nothing can be behind (a desktop-app/IDE-extension
|
|
439
|
+
// customer whose shell has no host CLI). A host cache holding some OTHER version still fails
|
|
440
|
+
// closed below — issue #64's exact-selection guard is untouched.
|
|
441
|
+
// Existence, not a parse: a corrupt active.json is a damaged spine and still fails closed below.
|
|
442
|
+
// A host that registered the Brain's plugin but staged nothing (its `plugin install` failed) is
|
|
443
|
+
// NOT "no host": that is a failed install and must keep failing.
|
|
444
|
+
if (!fs.existsSync(ACTIVE) && !newestStagedCC() && !HOST_PLUGIN_EVIDENCE.some((dir) => fs.existsSync(dir))) {
|
|
445
|
+
console.log(`no host has staged a payload and no spine is active — nothing to converge for ${expectedVersion}.`);
|
|
446
|
+
return 0;
|
|
447
|
+
}
|
|
428
448
|
console.error(`✗ no staged host payload exactly matches expected version ${expectedVersion} — spine unchanged`);
|
|
429
449
|
return 1;
|
|
430
450
|
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// console-instances.mjs — the installer's view of running Consoles: which receipts are real, and how
|
|
2
|
+
// to replace an owned Console that is still serving a pre-update runtime without asking anyone.
|
|
3
|
+
//
|
|
4
|
+
// Two owner-Mac failures (2026-09-30) this exists for:
|
|
5
|
+
// 1. Receipts left by Consoles that had long since died (2026-09-16/17) were counted as "stale
|
|
6
|
+
// running" forever, so --doctor said pending-console-restart / FAILING with nothing to restart.
|
|
7
|
+
// A receipt whose pid is gone AND whose port does not answer /api/runtime with the receipt's
|
|
8
|
+
// own identity is debris: it is pruned.
|
|
9
|
+
// 2. After a successful --update the installer told the owner to "restart Console". The Console
|
|
10
|
+
// already knows how to replace an owned stale instance of itself (scripts/onboarding-console.mjs
|
|
11
|
+
// launchConsole, state 'stale-running': token-authenticated shutdown, then serve on the same
|
|
12
|
+
// port). The installer now runs exactly that, from the freshly activated runtime, without --open.
|
|
13
|
+
//
|
|
14
|
+
// Everything here is synchronous because syncHostsAfterUpdate is; the one network probe runs in a
|
|
15
|
+
// bounded child node process.
|
|
16
|
+
import fs from 'node:fs';
|
|
17
|
+
import path from 'node:path';
|
|
18
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
19
|
+
|
|
20
|
+
const PRODUCT = 'ruvnet-brain-console';
|
|
21
|
+
// The same identity fields onboarding-console.mjs sameRuntimeIdentity compares.
|
|
22
|
+
const IDENTITY_KEYS = ['product', 'schema', 'apiContract', 'pid', 'port', 'startedAt', 'scope',
|
|
23
|
+
'scriptRealpath', 'runtimeVersion', 'sourceSha256'];
|
|
24
|
+
|
|
25
|
+
export function pidAlive(pid) {
|
|
26
|
+
try { process.kill(pid, 0); return true; }
|
|
27
|
+
catch (error) { return error?.code !== 'ESRCH'; } // EPERM: alive, owned by someone else
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** GET 127.0.0.1:<port>/api/runtime → parsed JSON, or null. Bounded; never throws. */
|
|
31
|
+
export function probeRuntimeSync(port, timeoutMs = 1500) {
|
|
32
|
+
if (!Number.isInteger(port) || port <= 0 || port > 65535) return null;
|
|
33
|
+
const code = `const h=require('node:http');const r=h.get({host:'127.0.0.1',port:${port},path:'/api/runtime',timeout:${timeoutMs}},(s)=>{let b='';s.on('data',(c)=>{b+=c;});s.on('end',()=>{if(s.statusCode===200)process.stdout.write(b);process.exit(0);});});r.on('error',()=>process.exit(0));r.on('timeout',()=>{r.destroy();process.exit(0);});`;
|
|
34
|
+
const run = spawnSync(process.execPath, ['-e', code], { encoding: 'utf8', timeout: timeoutMs + 1000, windowsHide: true });
|
|
35
|
+
try { return run.stdout ? JSON.parse(run.stdout) : null; } catch { return null; }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export const sameIdentity = (left, right) => IDENTITY_KEYS.every((key) => left?.[key] === right?.[key]);
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Console receipts in `receiptDir`, with dead ones pruned. A receipt is dead only when BOTH hold:
|
|
42
|
+
* its pid is not alive, and its port does not answer /api/runtime with the receipt's identity.
|
|
43
|
+
* A receipt with no integer pid cannot be proven dead and is kept (counted as before).
|
|
44
|
+
*/
|
|
45
|
+
export function readConsoleReceipts(receiptDir, { alive = pidAlive, probe = probeRuntimeSync } = {}) {
|
|
46
|
+
const live = [];
|
|
47
|
+
const pruned = [];
|
|
48
|
+
let names = [];
|
|
49
|
+
try { names = fs.readdirSync(receiptDir).filter((name) => name.endsWith('.json')); }
|
|
50
|
+
catch { return { live, pruned }; } // no receipt directory is the ordinary "no Console" state
|
|
51
|
+
for (const name of names) {
|
|
52
|
+
const file = path.join(receiptDir, name);
|
|
53
|
+
let receipt;
|
|
54
|
+
try { receipt = JSON.parse(fs.readFileSync(file, 'utf8')); } catch { continue; }
|
|
55
|
+
if (receipt?.product !== PRODUCT || receipt.schema !== 1) continue;
|
|
56
|
+
if (Number.isInteger(receipt.pid) && receipt.pid > 0 && !alive(receipt.pid)) {
|
|
57
|
+
const answer = probe(receipt.port);
|
|
58
|
+
if (!answer || !sameIdentity(answer, receipt)) {
|
|
59
|
+
try { fs.unlinkSync(file); } catch { /* raced or read-only: still not a running Console */ }
|
|
60
|
+
pruned.push({ file, pid: receipt.pid, port: receipt.port ?? null, startedAt: receipt.startedAt ?? null });
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
live.push({ file, receipt });
|
|
65
|
+
}
|
|
66
|
+
return { live, pruned };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// The replacement Console is a user-facing process, not part of the installer run. It inherits the
|
|
70
|
+
// user's environment — provider API keys (ANTHROPIC/OPENAI/OPENROUTER/GEMINI/GOOGLE/XAI) the Console reads,
|
|
71
|
+
// HTTP(S)_PROXY / NO_PROXY / NODE_EXTRA_CA_CERTS its release fetch needs — minus the flags that describe
|
|
72
|
+
// THIS run: the scheduler's nightly identity, test-harness switches, the refresh-run lock token, Node/npm
|
|
73
|
+
// process plumbing. A denylist, so nothing the Console legitimately reads is silently lost.
|
|
74
|
+
const CONSOLE_ENV_DENY = new Set(['RUVNET_NIGHTLY', 'RUVNET_BRAIN_TEST', 'RUVNET_BRAIN_TEST_LATEST_TAG', 'RUVNET_BRAIN_SCHEDULER_TEST',
|
|
75
|
+
'RUVNET_BRAIN_IMPORT_ONLY', 'RUVNET_BRAIN_NO_UPDATE_FALLBACK', 'RUVNET_STRICT_INSTALL', 'RUVNET_REFRESH_RUN_TOKEN',
|
|
76
|
+
'RUVNET_REFRESH_RECEIPT', 'RUVNET_UPGRADE_NOTICE_FILE', 'NODE_OPTIONS', 'NODE_TEST_CONTEXT', 'INIT_CWD', 'CONSOLE_PORT']);
|
|
77
|
+
const CONSOLE_ENV_DENY_PREFIX = /^(?:RUVNET_NIGHTLY_|npm_|VITEST)/;
|
|
78
|
+
export function consoleEnv(env = process.env, port) {
|
|
79
|
+
const clean = Object.fromEntries(Object.entries(env).filter(([key, value]) => value != null
|
|
80
|
+
&& !CONSOLE_ENV_DENY.has(key) && !CONSOLE_ENV_DENY_PREFIX.test(key)));
|
|
81
|
+
return { ...clean, CONSOLE_PORT: String(port) };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const sleepSync = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
85
|
+
const readJson = (file) => { try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return null; } };
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Replace every owned Console still serving an older runtime by running the activated runtime's own
|
|
89
|
+
* launcher (`<entry> --serve`, never --open) in that Console's scope on that Console's port. The
|
|
90
|
+
* launcher authenticates the shutdown with the private control token from the receipt; we only start
|
|
91
|
+
* it and wait for the receipt to name the new runtime. Returns one result per stale receipt, each
|
|
92
|
+
* with `replaced` and, when false, the exact reason.
|
|
93
|
+
*/
|
|
94
|
+
export function replaceStaleConsoles({ entry, identity, receiptDir, env = process.env, timeoutMs = 20_000,
|
|
95
|
+
spawnFn = spawn, alive = pidAlive, probe = probeRuntimeSync } = {}) {
|
|
96
|
+
const results = [];
|
|
97
|
+
const { live } = readConsoleReceipts(receiptDir, { alive, probe });
|
|
98
|
+
for (const { file, receipt } of live) {
|
|
99
|
+
if (receipt.sourceSha256 === identity.sourceSha256) continue;
|
|
100
|
+
const where = { scope: receipt.scope ?? null, port: receipt.port ?? null, pid: receipt.pid ?? null };
|
|
101
|
+
const refuse = (reason) => results.push({ ...where, replaced: false, reason });
|
|
102
|
+
if (!Number.isInteger(receipt.pid) || !Number.isInteger(receipt.port) || typeof receipt.scope !== 'string') {
|
|
103
|
+
refuse('its receipt has no pid/port/scope, so it cannot be addressed safely'); continue;
|
|
104
|
+
}
|
|
105
|
+
if (!/^[a-f0-9]{48}$/.test(receipt.controlToken || '')) {
|
|
106
|
+
refuse('its receipt carries no control token, so the installer cannot prove it owns that Console'); continue;
|
|
107
|
+
}
|
|
108
|
+
if (!fs.existsSync(entry)) { refuse(`the activated Console runtime is missing (${entry})`); continue; }
|
|
109
|
+
if (!fs.existsSync(receipt.scope)) { refuse(`its project directory no longer exists (${receipt.scope})`); continue; }
|
|
110
|
+
// The pid is alive (dead ones were pruned by readConsoleReceipts). If its port does not answer with
|
|
111
|
+
// this receipt's identity, it is either a busy Console or a reused pid — we cannot tell which, so we
|
|
112
|
+
// never delete the receipt (that could orphan a live stale Console). Report it at once instead of
|
|
113
|
+
// launching and waiting 20s for a process that may never release anything.
|
|
114
|
+
const answer = probe(receipt.port) || probe(receipt.port);
|
|
115
|
+
if (!answer || !sameIdentity(answer, receipt)) {
|
|
116
|
+
refuse(`pid ${receipt.pid} is alive but port ${receipt.port} does not answer with that Console's identity (busy, or the pid was reused); its receipt was kept — restart Console`);
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
try {
|
|
120
|
+
const child = spawnFn(process.execPath, [entry, '--serve'], { cwd: receipt.scope, detached: true, stdio: 'ignore',
|
|
121
|
+
windowsHide: true, env: consoleEnv(env, receipt.port) });
|
|
122
|
+
child.on?.('error', () => {});
|
|
123
|
+
child.unref?.();
|
|
124
|
+
} catch (error) { refuse(`could not start the current Console: ${error.message}`); continue; }
|
|
125
|
+
// Done = the scope's receipt names the new runtime from a new live process AND the old process
|
|
126
|
+
// is gone (the launcher falls back to a free port if the old one never lets go — that is not a
|
|
127
|
+
// replacement, it is two Consoles).
|
|
128
|
+
const until = Date.now() + timeoutMs;
|
|
129
|
+
let current = null;
|
|
130
|
+
let oldAlive = true;
|
|
131
|
+
while (Date.now() < until) {
|
|
132
|
+
const seen = readJson(file);
|
|
133
|
+
current = seen?.sourceSha256 === identity.sourceSha256 && seen.pid !== receipt.pid && alive(seen.pid) ? seen : null;
|
|
134
|
+
oldAlive = alive(receipt.pid);
|
|
135
|
+
if (current && !oldAlive) break;
|
|
136
|
+
sleepSync(200);
|
|
137
|
+
}
|
|
138
|
+
const secs = Math.round(timeoutMs / 1000);
|
|
139
|
+
if (current && !oldAlive) { results.push({ ...where, replaced: true, newPid: current.pid, newPort: current.port }); continue; }
|
|
140
|
+
if (current) refuse(`the current Console started (pid ${current.pid}, port ${current.port}) but the old one (pid ${receipt.pid}) did not exit within ${secs}s`);
|
|
141
|
+
else if (oldAlive) refuse(`the old Console (pid ${receipt.pid}) did not release port ${receipt.port} within ${secs}s`);
|
|
142
|
+
else refuse(`the old Console stopped but the current one did not report the new runtime within ${secs}s`);
|
|
143
|
+
}
|
|
144
|
+
return results;
|
|
145
|
+
}
|
|
@@ -42,6 +42,8 @@ export const CONSOLE_RUNTIME_SURFACE = Object.freeze([
|
|
|
42
42
|
// Keep their bytes in the same copy/digest authority as the installer itself.
|
|
43
43
|
'kb/refresh-run.mjs',
|
|
44
44
|
'kb/lifecycle-evidence-retention.mjs',
|
|
45
|
+
// install.mjs --update recovers an interrupted storage transaction before it looks for the updater.
|
|
46
|
+
'kb/update-storage-transaction.mjs',
|
|
45
47
|
'kb/model-requirements.mjs',
|
|
46
48
|
'kb/zip-extract.mjs',
|
|
47
49
|
// install.mjs imports this STATICALLY (corpus transport identity + approved-runtime stamping,
|