ruvnet-brain 4.6.3 → 4.7.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 +1 -1
- package/bin/install.mjs +6 -50
- package/config/practical-rule-catalog.json +22 -22
- package/console/app.js +1 -1
- package/console/architecture.html +2 -2
- package/package.json +2 -2
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/commands/checkpoint.md +22 -40
- package/plugin/commands/rnb-brief.md +18 -15
- package/plugin/hooks/codex-hooks.json +1 -58
- package/plugin/hooks/hook-contracts.json +19 -220
- package/plugin/hooks/hooks.json +1 -70
- package/plugin/mcp/managed-cli-interface.mjs +0 -56
- package/plugin/scripts/agentdb-recall-reader.mjs +29 -9
- package/plugin/scripts/agentdb-recall.mjs +27 -21
- package/plugin/scripts/brain-footprint.mjs +1 -1
- package/plugin/scripts/codex-hook-wrapper.mjs +13 -3
- package/plugin/scripts/completion-claim-evidence.mjs +1 -1
- package/plugin/scripts/continuation-gate.mjs +78 -1
- package/plugin/scripts/continuity-events.mjs +90 -82
- package/plugin/scripts/continuity-hook-policy.mjs +5 -14
- package/plugin/scripts/decision-gate.mjs +3 -3
- package/plugin/scripts/decision-recall-gate.mjs +141 -0
- package/plugin/scripts/decision-record.mjs +324 -0
- package/plugin/scripts/deferral-evidence.mjs +134 -0
- package/plugin/scripts/hook-failures.mjs +61 -0
- package/plugin/scripts/hook-shim.mjs +71 -6
- package/plugin/scripts/learn-capture.mjs +88 -37
- package/plugin/scripts/learning-store.mjs +70 -60
- package/plugin/scripts/node-sqlite.mjs +1 -1
- package/plugin/scripts/ruflo-bin.mjs +71 -1
- package/plugin/scripts/ruflo-memory-hooks.mjs +132 -0
- package/plugin/scripts/runtime-preferences.mjs +1 -1
- package/plugin/scripts/session-snapshot-hook.mjs +85 -332
- package/plugin/scripts/session-start-core.mjs +7 -9
- package/plugin/scripts/signal-watch.mjs +1 -1
- package/plugin/scripts/substitution-wall.mjs +222 -0
- package/plugin/scripts/turn-outcome-capture.mjs +74 -88
- package/scripts/codex-host-execution-proof.mjs +1 -1
- package/scripts/console-engine.mjs +0 -32
- package/scripts/deferral-replay.mjs +47 -0
- package/scripts/health-repair.mjs +1 -81
- package/scripts/hook-qualify-core.mjs +2 -2
- package/scripts/model-managed-workflow-service.mjs +9 -10
- package/scripts/model-routing-controller.mjs +9 -8
- package/scripts/no-silent-substitution.mjs +65 -1
- package/scripts/onboarding-console.mjs +8 -23
- package/scripts/product-integrity-contract.mjs +1 -1
- package/scripts/release-qualification-contract.mjs +9 -27
- package/scripts/remedy-registry.mjs +0 -18
- package/scripts/wired-check.mjs +1 -5
- package/plugin/scripts/continuity-brief.mjs +0 -357
- package/plugin/scripts/continuity-journal.mjs +0 -553
- package/plugin/scripts/learn-flush.mjs +0 -96
- package/plugin/scripts/learning-observation.mjs +0 -51
- package/plugin/scripts/learning-queue.mjs +0 -135
- package/plugin/scripts/learning-worker-supervisor.mjs +0 -79
- package/plugin/scripts/project-capture-queue.mjs +0 -357
- package/plugin/scripts/project-memory-enrollment.mjs +0 -203
- package/plugin/scripts/project-progression-checkpoint.mjs +0 -145
- package/plugin/scripts/project-progression-contract.mjs +0 -496
- package/plugin/scripts/project-progression-hook.mjs +0 -233
- package/plugin/scripts/project-progression-outbox.mjs +0 -225
- package/plugin/scripts/project-progression-producer.mjs +0 -309
- package/plugin/scripts/project-progression-reader.mjs +0 -304
- package/plugin/scripts/project-progression-session-start.mjs +0 -320
- package/plugin/scripts/project-progression-sources.mjs +0 -246
- package/plugin/scripts/project-progression-store.mjs +0 -723
- package/plugin/scripts/project-progression-suspension.mjs +0 -60
- package/plugin/scripts/project-transition-hook.mjs +0 -246
- package/plugin/scripts/session-snapshot-contract.mjs +0 -105
- package/plugin/scripts/turn-transport-journal.mjs +0 -108
- package/scripts/qa/progression-validation-benchmark.mjs +0 -66
- package/scripts/session-snapshot-contract.mjs +0 -1
|
@@ -1,51 +0,0 @@
|
|
|
1
|
-
// Read-only evidence shared by Console and the training remedy. No queue or learner is initialized.
|
|
2
|
-
import path from 'node:path';
|
|
3
|
-
import fs from 'node:fs';
|
|
4
|
-
import { learningTarget, learningStoreStatus } from './learning-store.mjs';
|
|
5
|
-
import { queueFiles, pendingRecords, safeQueue, readSafe } from './learning-queue.mjs';
|
|
6
|
-
import { learningContext } from './runtime-preferences.mjs';
|
|
7
|
-
|
|
8
|
-
const queueContext = (queueDir) => path.basename(queueDir) === 'ruvnet-brain-learn'
|
|
9
|
-
? { scope: 'project', queueDir, projectDir: path.dirname(path.dirname(queueDir)) }
|
|
10
|
-
: { scope: 'user', queueDir, home: path.dirname(path.dirname(path.dirname(queueDir))) };
|
|
11
|
-
|
|
12
|
-
export function learningQueueFiles(queueDir) { return queueFiles(queueContext(queueDir)); }
|
|
13
|
-
|
|
14
|
-
/** Pending evidence includes malformed/torn lines; acknowledged retained originals are history. */
|
|
15
|
-
export function learningQueueDepth(queueDir) {
|
|
16
|
-
return learningQueueFiles(queueDir).reduce((depth, file) => depth + pendingRecords(file).records.length, 0);
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
export function observeLearning(options = {}) {
|
|
20
|
-
const env = options.env ?? process.env;
|
|
21
|
-
const context = learningContext(options);
|
|
22
|
-
const result = { ...context, queueDepth: 0, queueKnown: true, legacyUserDepth: 0, legacyUserKnown: true, lastTrainSeconds: null, trajectories: 0, statusKnown: false };
|
|
23
|
-
if (!context.enabled) return result;
|
|
24
|
-
try { learningTarget(context, { env }); } catch { result.statusKnown = false; result.queueKnown = false; return result; }
|
|
25
|
-
try { result.queueDepth = learningQueueDepth(context.queueDir); }
|
|
26
|
-
catch { result.queueKnown = false; }
|
|
27
|
-
try { const lock = JSON.parse(readSafe(path.join(context.queueDir, '.worker-lock'), 4096)); result.workerRetirementUnconfirmed = lock.retirementUnconfirmed === true || (lock.retirementRequired === true && lock.expires < Date.now()); }
|
|
28
|
-
catch { /* Absent diagnostic lock grants no claim about a running process. */ }
|
|
29
|
-
try {
|
|
30
|
-
const latest = fs.readdirSync(safeQueue(context)).filter(n => /^\.run-\d+-\d+\.json$/.test(n))
|
|
31
|
-
.sort((a, b) => Number(b.split('-')[1]) - Number(a.split('-')[1]))[0];
|
|
32
|
-
const receipt = latest && JSON.parse(readSafe(path.join(context.queueDir, latest), 65536));
|
|
33
|
-
result.capturePendingFailure = result.queueDepth > 0 && ((Number.isSafeInteger(receipt?.failed) && receipt.failed > 0)
|
|
34
|
-
|| (Number.isSafeInteger(receipt?.malformed) && receipt.malformed > 0));
|
|
35
|
-
result.distillationUnavailable = receipt?.distillation?.completed === false;
|
|
36
|
-
} catch { /* Unknown diagnostic is never fabricated success. */ }
|
|
37
|
-
if (context.scope === 'project') {
|
|
38
|
-
try { result.legacyUserDepth = learningQueueDepth(path.join(context.home, '.cache', 'ruvnet-brain', 'learn')); }
|
|
39
|
-
catch { result.legacyUserKnown = false; }
|
|
40
|
-
}
|
|
41
|
-
try {
|
|
42
|
-
result.learningDb = learningTarget(context, { env });
|
|
43
|
-
const state = learningStoreStatus(result.learningDb);
|
|
44
|
-
result.statusKnown = state.known;
|
|
45
|
-
result.trajectories = state.observations; // compatibility field; explicitly observations, never SONA trajectories.
|
|
46
|
-
result.observations = state.observations; result.patterns = state.patterns;
|
|
47
|
-
result.lastDistillAt = state.lastDistillAt;
|
|
48
|
-
if (state.lastDistillAt !== null) result.lastTrainSeconds = Math.max(0, (Date.now() - state.lastDistillAt) / 1000);
|
|
49
|
-
} catch { /* Missing consent/store/readback remains unknown. */ }
|
|
50
|
-
return result;
|
|
51
|
-
}
|
|
@@ -1,135 +0,0 @@
|
|
|
1
|
-
// Workflow queue transport only. Retained source bytes plus acknowledgments permit crash replay.
|
|
2
|
-
import fs from 'node:fs';
|
|
3
|
-
import path from 'node:path';
|
|
4
|
-
import { createHash, randomUUID } from 'node:crypto';
|
|
5
|
-
|
|
6
|
-
export const MAX_QUEUE_BYTES = 1024 * 1024;
|
|
7
|
-
export const WORKER_BUDGET_MS = 18_000;
|
|
8
|
-
const hash = (value) => createHash('sha256').update(value).digest('hex');
|
|
9
|
-
const nofollow = fs.constants.O_NOFOLLOW || 0;
|
|
10
|
-
export const queueName = /^session-[A-Za-z0-9_-]+\.jsonl$/;
|
|
11
|
-
|
|
12
|
-
/** Reject symlinks/junctions at every ancestor beneath the canonical selected scope. */
|
|
13
|
-
export function safeQueue(context, create = false) {
|
|
14
|
-
const root = fs.realpathSync.native(context.scope === 'user' ? context.home : context.projectDir);
|
|
15
|
-
const relative = path.relative(root, context.queueDir);
|
|
16
|
-
if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) throw new Error('queue outside scope');
|
|
17
|
-
let current = root;
|
|
18
|
-
for (const part of relative.split(path.sep)) {
|
|
19
|
-
current = path.join(current, part);
|
|
20
|
-
if (create && !fs.existsSync(current)) fs.mkdirSync(current, { mode: 0o700 });
|
|
21
|
-
const stat = fs.lstatSync(current);
|
|
22
|
-
if (!stat.isDirectory() || stat.isSymbolicLink() || fs.realpathSync.native(current) !== current) throw new Error('unsafe queue ancestor');
|
|
23
|
-
}
|
|
24
|
-
return current;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
export function readSafe(file, maxBytes = MAX_QUEUE_BYTES) {
|
|
28
|
-
if (!fs.lstatSync(file).isFile()) throw new Error('unsafe queue file');
|
|
29
|
-
const fd = fs.openSync(file, fs.constants.O_RDONLY | nofollow);
|
|
30
|
-
try {
|
|
31
|
-
const stat = fs.fstatSync(fd);
|
|
32
|
-
if (!stat.isFile() || stat.size > maxBytes) throw new Error('queue file exceeds bounded read');
|
|
33
|
-
return fs.readFileSync(fd);
|
|
34
|
-
} finally { fs.closeSync(fd); }
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
export function queueFiles(context, options = {}) {
|
|
38
|
-
try {
|
|
39
|
-
const dir = safeQueue(context);
|
|
40
|
-
if (Number.isInteger(options.limit)) {
|
|
41
|
-
const before = []; const after = []; let passed = !options.cursor;
|
|
42
|
-
const iterator = fs.opendirSync(dir);
|
|
43
|
-
try {
|
|
44
|
-
let entry;
|
|
45
|
-
while (Date.now() < options.deadline && (entry = iterator.readSync())) {
|
|
46
|
-
if (!queueName.test(entry.name)) continue;
|
|
47
|
-
const file = path.join(dir, entry.name);
|
|
48
|
-
if (!passed && entry.name === options.cursor) { passed = true; if (before.length < options.limit) before.push(file); continue; }
|
|
49
|
-
if (passed) { after.push(file); if (after.length >= options.limit) break; }
|
|
50
|
-
else if (before.length < options.limit) before.push(file);
|
|
51
|
-
}
|
|
52
|
-
} finally { iterator.closeSync(); }
|
|
53
|
-
return [...after, ...before].slice(0, options.limit);
|
|
54
|
-
}
|
|
55
|
-
return fs.readdirSync(dir).filter(name => queueName.test(name)).map(name => path.join(dir, name))
|
|
56
|
-
.sort((a, b) => fs.lstatSync(a).mtimeMs - fs.lstatSync(b).mtimeMs || a.localeCompare(b));
|
|
57
|
-
} catch (error) { if (error.code === 'ENOENT') return []; throw error; }
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
/** Hash-bound offsets cannot acknowledge replaced source bytes or an appended sibling line. */
|
|
61
|
-
export function pendingRecords(file) {
|
|
62
|
-
const bytes = readSafe(file); let ack = {};
|
|
63
|
-
try { ack = JSON.parse(readSafe(`${file}.ack.json`).toString()); }
|
|
64
|
-
catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
65
|
-
const records = []; let start = 0;
|
|
66
|
-
for (let end = 0; end < bytes.length; end++) {
|
|
67
|
-
if (bytes[end] !== 10) continue;
|
|
68
|
-
const line = bytes.subarray(start, end + 1); const key = `${start}:${hash(line)}`;
|
|
69
|
-
if (line.toString().trim() && ack[key] !== true) records.push({ key, raw: line.toString() });
|
|
70
|
-
start = end + 1;
|
|
71
|
-
}
|
|
72
|
-
// A torn final line is pending evidence; it can become complete on a legacy writer's next append.
|
|
73
|
-
if (start < bytes.length && bytes.subarray(start).toString().trim()) records.push({ key: null, raw: bytes.subarray(start).toString() });
|
|
74
|
-
return { records, ack };
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
export function writeExclusive(file, bytes) {
|
|
78
|
-
const fd = fs.openSync(file, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | nofollow, 0o600);
|
|
79
|
-
try { fs.writeFileSync(fd, bytes); fs.fsyncSync(fd); } finally { fs.closeSync(fd); }
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
export function writeAtomic(target, bytes) {
|
|
83
|
-
try { if (!fs.lstatSync(target).isFile()) throw new Error('unsafe acknowledgment'); }
|
|
84
|
-
catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
85
|
-
const temporary = `${target}.${randomUUID()}`;
|
|
86
|
-
writeExclusive(temporary, bytes);
|
|
87
|
-
fs.renameSync(temporary, target);
|
|
88
|
-
}
|
|
89
|
-
export function acknowledge(file, ack) { writeAtomic(`${file}.ack.json`, JSON.stringify(ack)); }
|
|
90
|
-
|
|
91
|
-
/** Lease lasts longer than the finite worker. PID is diagnostic, never writer authority. */
|
|
92
|
-
export function takeQueueLock(context, now = Date.now()) {
|
|
93
|
-
const dir = safeQueue(context); const file = path.join(dir, '.worker-lock');
|
|
94
|
-
const token = randomUUID(); const body = JSON.stringify({ token, expires: now + 60_000 });
|
|
95
|
-
try { writeExclusive(file, body); return token; } catch (error) { if (error.code !== 'EEXIST') throw error; }
|
|
96
|
-
const seen = readSafe(file).toString();
|
|
97
|
-
let expires;
|
|
98
|
-
try { const owner = JSON.parse(seen); if (owner.retirementUnconfirmed || owner.retirementRequired) return null; expires = owner.expires; } catch { expires = fs.lstatSync(file).mtimeMs + 60_000; }
|
|
99
|
-
if (!Number.isFinite(expires)) expires = fs.lstatSync(file).mtimeMs + 60_000;
|
|
100
|
-
if (!(expires < now)) return null;
|
|
101
|
-
const aside = `${file}.${token}`;
|
|
102
|
-
fs.renameSync(file, aside);
|
|
103
|
-
if (readSafe(aside).toString() !== seen) {
|
|
104
|
-
try { fs.linkSync(aside, file); } catch { /* another fenced owner already exists */ }
|
|
105
|
-
fs.rmSync(aside); return null;
|
|
106
|
-
}
|
|
107
|
-
fs.rmSync(aside);
|
|
108
|
-
try { writeExclusive(file, body); return token; } catch (error) { if (error.code === 'EEXIST') return null; throw error; }
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
export function ownsQueueLock(context, token) {
|
|
112
|
-
try {
|
|
113
|
-
safeQueue(context);
|
|
114
|
-
const lock = JSON.parse(readSafe(path.join(context.queueDir, '.worker-lock')).toString());
|
|
115
|
-
return !lock.retirementUnconfirmed && lock.token === token && Date.now() < lock.expires;
|
|
116
|
-
} catch { return false; }
|
|
117
|
-
}
|
|
118
|
-
export function releaseQueueLock(context, token) {
|
|
119
|
-
if (ownsQueueLock(context, token)) fs.rmSync(path.join(context.queueDir, '.worker-lock'));
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
// Fixed vocabulary: arbitrary executable names, arguments, basenames and paths never persist.
|
|
123
|
-
const verbs = new Set('git npm node python python3 cargo rustc go gh cd ls rg cat export mkdir rm cp mv bash sh curl wget npx ruflo'.split(' '));
|
|
124
|
-
const subs = new Set('status diff log show add commit push pull fetch checkout switch branch test run install ci build check fmt clippy auth workflow release memory hooks'.split(' '));
|
|
125
|
-
export function safeAction(tool, value) {
|
|
126
|
-
if (typeof value !== 'string') return null;
|
|
127
|
-
if (tool === 'Bash') {
|
|
128
|
-
const [verb, sub] = value.trim().split(/\s+/, 3);
|
|
129
|
-
if (!verbs.has(verb)) return 'command';
|
|
130
|
-
return subs.has(sub) ? `${verb} ${sub}` : verb;
|
|
131
|
-
}
|
|
132
|
-
if (!['Write', 'Edit', 'MultiEdit'].includes(tool)) return null;
|
|
133
|
-
// Legacy action is minimized again before invocation; no private basename reaches Ruflo argv.
|
|
134
|
-
return 'edit file';
|
|
135
|
-
}
|
|
@@ -1,79 +0,0 @@
|
|
|
1
|
-
// One finite owner retires its separate worker tree before surrendering the queue fence.
|
|
2
|
-
import { spawn, spawnSync } from 'node:child_process';
|
|
3
|
-
import path from 'node:path';
|
|
4
|
-
import { fileURLToPath } from 'node:url';
|
|
5
|
-
import { ownsQueueLock, releaseQueueLock, writeExclusive, writeAtomic, readSafe } from './learning-queue.mjs';
|
|
6
|
-
import { learningContext } from './runtime-preferences.mjs';
|
|
7
|
-
|
|
8
|
-
const aliveGroup = pid => {
|
|
9
|
-
try { process.kill(-pid, 0); return true; } catch (error) { return error.code !== 'ESRCH'; }
|
|
10
|
-
};
|
|
11
|
-
const killTree = pid => {
|
|
12
|
-
if (process.platform === 'win32') {
|
|
13
|
-
const r = spawnSync('taskkill', ['/PID', String(pid), '/T', '/F'], { timeout: 1000, windowsHide: true, stdio: 'ignore' });
|
|
14
|
-
return !r.error && r.status === 0;
|
|
15
|
-
}
|
|
16
|
-
try { process.kill(-pid, 'SIGKILL'); return true; } catch (error) { return error.code === 'ESRCH'; }
|
|
17
|
-
};
|
|
18
|
-
|
|
19
|
-
export async function superviseLearning(context, token, deadline, { report = false, spawnEngine = spawn,
|
|
20
|
-
killOwned = killTree, groupAlive = aliveGroup, platform = process.platform, env = process.env,
|
|
21
|
-
persistFence = writeAtomic, writeDiagnostic = writeExclusive } = {}) {
|
|
22
|
-
// A supervisor crash or later read-only filesystem must never permit expired-lease takeover.
|
|
23
|
-
// Persist and verify the retirement requirement before any worker can exist.
|
|
24
|
-
const lockFile = path.join(context.queueDir, '.worker-lock');
|
|
25
|
-
try {
|
|
26
|
-
if (!ownsQueueLock(context, token)) throw new Error('queue ownership lost');
|
|
27
|
-
const lock = JSON.parse(readSafe(lockFile));
|
|
28
|
-
persistFence(lockFile, JSON.stringify({ ...lock, retirementRequired: true }));
|
|
29
|
-
const verified = JSON.parse(readSafe(lockFile));
|
|
30
|
-
if (verified.token !== token || verified.retirementRequired !== true) throw new Error('retirement requirement unverified');
|
|
31
|
-
} catch {
|
|
32
|
-
return { retirementConfirmed: true, fencePersisted: false, launched: false };
|
|
33
|
-
}
|
|
34
|
-
const child = spawnEngine(process.execPath, [fileURLToPath(new URL('./learn-flush.mjs', import.meta.url)), '--worker', ...(report ? ['--report'] : [])], {
|
|
35
|
-
cwd: context.projectDir, detached: true, stdio: ['ignore', report ? 'inherit' : 'ignore', report ? 'inherit' : 'ignore', 'ipc'], windowsHide: true,
|
|
36
|
-
env: { ...env, RUVNET_LEARN_WORKER_TOKEN: token, RUVNET_LEARN_WORKER_EXPIRES: String(deadline), RUFLO_DAEMON_AUTOSTART: '0' },
|
|
37
|
-
});
|
|
38
|
-
return await new Promise(resolve => {
|
|
39
|
-
let retiring = false; let exited = false;
|
|
40
|
-
const timer = setTimeout(() => void retire(true), Math.max(1, deadline - Date.now()));
|
|
41
|
-
const retire = async failed => {
|
|
42
|
-
if (retiring) return; retiring = true; clearTimeout(timer);
|
|
43
|
-
let killed = false;
|
|
44
|
-
try { killed = !child.pid || killOwned(child.pid); } catch { /* bounded refusal below */ }
|
|
45
|
-
const until = Date.now() + 600; let confirmed = !child.pid;
|
|
46
|
-
while (!confirmed && Date.now() < until) {
|
|
47
|
-
try { confirmed = platform === 'win32' ? killed && exited : !groupAlive(child.pid); } catch { confirmed = false; }
|
|
48
|
-
if (!confirmed) await new Promise(r => setTimeout(r, 25));
|
|
49
|
-
}
|
|
50
|
-
// Mandatory fencing is independent of optional diagnostics (including ENOSPC).
|
|
51
|
-
let fencePersisted = confirmed;
|
|
52
|
-
if (!confirmed) {
|
|
53
|
-
try {
|
|
54
|
-
if (ownsQueueLock(context, token)) {
|
|
55
|
-
const lock = JSON.parse(readSafe(lockFile));
|
|
56
|
-
persistFence(lockFile, JSON.stringify({ ...lock, retirementUnconfirmed: true }));
|
|
57
|
-
fencePersisted = JSON.parse(readSafe(lockFile)).retirementUnconfirmed === true;
|
|
58
|
-
}
|
|
59
|
-
} catch { /* Report failed durability without claiming the queue is fenced. */ }
|
|
60
|
-
}
|
|
61
|
-
try {
|
|
62
|
-
const current = learningContext({ env, cwd: context.projectDir });
|
|
63
|
-
if (current.enabled && current.queueDir === context.queueDir) {
|
|
64
|
-
if (failed || !confirmed) writeDiagnostic(path.join(context.queueDir, `.run-${Date.now()}-${process.pid}.json`), JSON.stringify({
|
|
65
|
-
schemaVersion: 1, failed: 1, fed: 0, acknowledged: 0, workerPid: child.pid, retirementConfirmed: confirmed, fencePersisted,
|
|
66
|
-
reason: confirmed ? 'owned worker exceeded deadline or crashed; retired; originals retained' : fencePersisted
|
|
67
|
-
? 'owned tree retirement unconfirmed; queue fenced; originals retained'
|
|
68
|
-
: 'owned tree retirement unconfirmed; fence persistence failed; manual recovery required; originals retained',
|
|
69
|
-
}));
|
|
70
|
-
}
|
|
71
|
-
} catch { /* Diagnostic failure never consumes original evidence. */ }
|
|
72
|
-
if (confirmed) releaseQueueLock(context, token);
|
|
73
|
-
if (child.connected) child.disconnect(); child.unref?.(); resolve({ retirementConfirmed: confirmed, fencePersisted });
|
|
74
|
-
};
|
|
75
|
-
child.once('message', message => { if (message?.type === 'learning-worker-complete') void retire(false); });
|
|
76
|
-
child.once('error', () => void retire(true));
|
|
77
|
-
child.once('exit', () => { exited = true; if (!retiring) void retire(true); });
|
|
78
|
-
});
|
|
79
|
-
}
|
|
@@ -1,357 +0,0 @@
|
|
|
1
|
-
/** Canonical capture queue, fencing and bounded replay; no independent writer authority. */
|
|
2
|
-
import fs from 'node:fs';
|
|
3
|
-
import path from 'node:path';
|
|
4
|
-
import os from 'node:os';
|
|
5
|
-
import { spawn, spawnSync } from 'node:child_process';
|
|
6
|
-
import { fileURLToPath } from 'node:url';
|
|
7
|
-
import { developmentHooksSuspended } from './development-maintenance.mjs';
|
|
8
|
-
import { resolveTurnDb } from './turn-outcome-capture.mjs';
|
|
9
|
-
import { enrichStateWithObservation } from './project-progression-hook.mjs';
|
|
10
|
-
import { buildProjectProgression } from './project-progression-producer.mjs';
|
|
11
|
-
import { resolveProjectStore } from './project-store-resolver.mjs';
|
|
12
|
-
import { redactProgression } from './project-progression-contract.mjs';
|
|
13
|
-
import { ProgressionOutbox } from './project-progression-outbox.mjs';
|
|
14
|
-
import { captureNormalizedTransition } from './project-transition-hook.mjs';
|
|
15
|
-
import { boundedStoreFactory, runSessionSnapshotHook } from './session-snapshot-hook.mjs';
|
|
16
|
-
import { operatorProgressionSuspension } from './project-progression-suspension.mjs';
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
* How long the detached worker may spend per step. The lock is refreshed between steps and goes stale
|
|
20
|
-
* after REPLAY_LOCK_STALE_MS, which is more than twice a step, so a live worker never looks dead.
|
|
21
|
-
*/
|
|
22
|
-
export const DETACHED_REPLAY_BUDGET_MS = 45_000;
|
|
23
|
-
export const REPLAY_LOCK_STALE_MS = 120_000;
|
|
24
|
-
const REPLAY_LOCK = '.progression-replay.lock';
|
|
25
|
-
const QUEUE_PREFIX = '.progression-capture-queue-';
|
|
26
|
-
const lockPath = (projectDir) => path.join(projectDir, '.swarm', REPLAY_LOCK);
|
|
27
|
-
// The lock's FIRST line is the owner token; a second `pid <n>` line names the process holding it.
|
|
28
|
-
const readLock = (projectDir) => { try { return fs.readFileSync(lockPath(projectDir), 'utf8').split('\n')[0].trim(); } catch { return null; } };
|
|
29
|
-
const CLAIM_PREFIX = '.progression-capture-claimed-';
|
|
30
|
-
/** After this long a stale lock is taken over even if its holder pid looks alive (pid reuse, a wedged process). */
|
|
31
|
-
export const REPLAY_LOCK_ABANDON_MS = 30 * 60_000;
|
|
32
|
-
|
|
33
|
-
/** Is a process with this pid alive? EPERM means alive but not ours. Never throws. */
|
|
34
|
-
export function pidAlive(pid) {
|
|
35
|
-
if (!Number.isSafeInteger(pid) || pid <= 0) return false;
|
|
36
|
-
try { process.kill(pid, 0); return true; } catch (error) { return error?.code === 'EPERM'; }
|
|
37
|
-
}
|
|
38
|
-
const seqOf = (name) => Number((/(\d{12})\.json$/.exec(name) || [])[1] ?? 0);
|
|
39
|
-
const swarmEntries = (projectDir) => { try { return fs.readdirSync(path.join(projectDir, '.swarm')); } catch { return []; } };
|
|
40
|
-
// 4.4.0 named queue files by wall clock: `<prefix><15-digit ms>-<hrtime>-<pid>-<n>.json`. Open 4.4.0
|
|
41
|
-
// sessions keep queuing in that format after the update, so the two formats coexist for a while.
|
|
42
|
-
const LEGACY_QUEUE = /^\d{15}-/;
|
|
43
|
-
const queueTail = (name) => (name.startsWith(QUEUE_PREFIX) ? name.slice(QUEUE_PREFIX.length) : name.slice(CLAIM_PREFIX.length).replace(/^\d+-[A-Za-z0-9]*-\d+-/, '')); // <pid>-<start>-<queuedAt>-
|
|
44
|
-
const mtimeOf = (projectDir, name) => { try { return fs.statSync(path.join(projectDir, '.swarm', name)).mtimeMs; } catch { return Infinity; } };
|
|
45
|
-
|
|
46
|
-
/**
|
|
47
|
-
* Queue one boundary's capture for the worker (0600, inside the project's own .swarm). ORDER IS THE
|
|
48
|
-
* ORDER OF EXCLUSIVE CREATION: the name is the next sequence number after every queued or claimed one,
|
|
49
|
-
* created with O_EXCL and retried on collision — never a clock, which can step backwards or wrap.
|
|
50
|
-
*/
|
|
51
|
-
export function queueCapture({ projectDir, originProjectDir = projectDir, event, host, payload, env = process.env }) {
|
|
52
|
-
try {
|
|
53
|
-
if (operatorProgressionSuspension(env)) return null;
|
|
54
|
-
const consent = resolveTurnDb({ projectDir: originProjectDir, requestedStorePath: path.join(projectDir, '.swarm', 'memory.db'),
|
|
55
|
-
brainHome: env.RUVNET_BRAIN_HOME || path.join(env.HOME || os.homedir(), '.cache', 'ruvnet-brain') });
|
|
56
|
-
if (consent.skipped) return null;
|
|
57
|
-
} catch { return null; }
|
|
58
|
-
// Freeze legacy callers at the original boundary too, before dropping host payload.
|
|
59
|
-
let progression = payload?.projectProgression;
|
|
60
|
-
if (!progression && !payload?.normalizedTransition) {
|
|
61
|
-
try { progression = buildProjectProgression({ resolution: resolveProjectStore({ projectDir: originProjectDir }), projectDir: originProjectDir, payload, host, trigger: event }).projectProgression; } catch { return null; }
|
|
62
|
-
}
|
|
63
|
-
// Freeze the bounded native observation before discarding raw host input. Replay receives no
|
|
64
|
-
// tool fields, so the native writer cannot append it twice.
|
|
65
|
-
if (progression && (payload?.tool_name || payload?.tool_input)) progression = { ...progression,
|
|
66
|
-
completeProjectState: enrichStateWithObservation(progression.completeProjectState, { ...payload, hook_event_name: event }) };
|
|
67
|
-
// This queue is durable: never serialize arbitrary host prompts, tool input or output.
|
|
68
|
-
const minimized = { session_id: payload?.session_id, hook_event_name: event,
|
|
69
|
-
...(progression ? { projectProgression: progression } : {}),
|
|
70
|
-
...(payload?.normalizedTransition ? { normalizedTransition: payload.normalizedTransition } : {}) };
|
|
71
|
-
const body = JSON.stringify(redactProgression({ event, host, originProjectDir,
|
|
72
|
-
queuedAt: new Date().toISOString(), payload: minimized }).value);
|
|
73
|
-
for (let attempt = 0; attempt < 64; attempt += 1) {
|
|
74
|
-
const seq = Math.max(0, ...swarmEntries(projectDir).filter((n) => n.startsWith(QUEUE_PREFIX) || n.startsWith(CLAIM_PREFIX)).map(seqOf)) + 1;
|
|
75
|
-
const file = path.join(projectDir, '.swarm', `${QUEUE_PREFIX}${String(seq).padStart(12, '0')}.json`);
|
|
76
|
-
try {
|
|
77
|
-
const fd = fs.openSync(file, 'wx', 0o600);
|
|
78
|
-
try { fs.writeFileSync(fd, body); fs.fsyncSync(fd); } finally { fs.closeSync(fd); }
|
|
79
|
-
return file;
|
|
80
|
-
} catch (error) {
|
|
81
|
-
if (error?.code !== 'EEXIST') return null;
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
return null;
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
/**
|
|
88
|
-
* Unclaimed queued captures, in queue order. While any 4.4.0 (timestamp-named) entry is present the
|
|
89
|
-
* order is creation time (mtime, then name) — by NAME every 4.4.1 sequence file would sort before every
|
|
90
|
-
* 4.4.0 one, replaying newer captures before older ones across the upgrade window. Once the legacy
|
|
91
|
-
* entries are gone the order is the sequence alone, independent of any clock.
|
|
92
|
-
*/
|
|
93
|
-
export function queuedCaptures(projectDir) {
|
|
94
|
-
const all = swarmEntries(projectDir).filter((n) => (n.startsWith(QUEUE_PREFIX) || n.startsWith(CLAIM_PREFIX)) && n.endsWith('.json'));
|
|
95
|
-
const queued = all.filter((n) => n.startsWith(QUEUE_PREFIX));
|
|
96
|
-
const mixed = all.some((n) => LEGACY_QUEUE.test(queueTail(n)));
|
|
97
|
-
const ordered = mixed
|
|
98
|
-
? queued.map((n) => [mtimeOf(projectDir, n), n]).sort((a, b) => a[0] - b[0] || (a[1] < b[1] ? -1 : a[1] > b[1] ? 1 : 0)).map(([, n]) => n)
|
|
99
|
-
: queued.sort();
|
|
100
|
-
return ordered.map((n) => path.join(projectDir, '.swarm', n));
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
/** All older work a boundary must wait behind: unclaimed captures plus captures a worker has claimed. */
|
|
104
|
-
export function queuedWork(projectDir) {
|
|
105
|
-
return swarmEntries(projectDir).filter((n) => (n.startsWith(QUEUE_PREFIX) || n.startsWith(CLAIM_PREFIX)) && n.endsWith('.json')).length;
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
/**
|
|
109
|
-
* A process's START TIME, as a filename-safe token, or null where it cannot be read (no `ps`, e.g.
|
|
110
|
-
* Windows). With the pid it identifies the process: a reused pid has a different start time.
|
|
111
|
-
*/
|
|
112
|
-
export function processStart(pid) {
|
|
113
|
-
try {
|
|
114
|
-
// TZ and locale PINNED: `lstart` prints local time in the locale's format, so two workers with
|
|
115
|
-
// different settings would record the same live process differently and read it as pid reuse.
|
|
116
|
-
const r = spawnSync('ps', ['-o', 'lstart=', '-p', String(pid)], { encoding: 'utf8', timeout: 2000, windowsHide: true,
|
|
117
|
-
env: { ...process.env, TZ: 'UTC', LC_ALL: 'C', LANG: 'C' } });
|
|
118
|
-
const s = String(r.stdout || '').replace(/[^A-Za-z0-9]/g, '');
|
|
119
|
-
return r.status === 0 && s ? s : null;
|
|
120
|
-
} catch { return null; }
|
|
121
|
-
}
|
|
122
|
-
let selfStart;
|
|
123
|
-
const ownStart = () => (selfStart === undefined ? (selfStart = processStart(process.pid)) : selfStart);
|
|
124
|
-
|
|
125
|
-
/**
|
|
126
|
-
* Claim a queued capture by atomic rename; null if taken. The claim's name records pid, start time and
|
|
127
|
-
* the queue file's ORIGINAL mtime (its creation order, which the mixed upgrade window sorts by); the
|
|
128
|
-
* claim file's own mtime is then set to the claim time, from which the orphan ceiling counts.
|
|
129
|
-
*/
|
|
130
|
-
function claimQueued(file) {
|
|
131
|
-
let queuedAt = 0;
|
|
132
|
-
try { queuedAt = Math.floor(fs.statSync(file).mtimeMs); } catch { return null; }
|
|
133
|
-
const claimed = path.join(path.dirname(file), `${CLAIM_PREFIX}${process.pid}-${ownStart() || 'na'}-${queuedAt}-${path.basename(file).slice(QUEUE_PREFIX.length)}`);
|
|
134
|
-
try { fs.renameSync(file, claimed); } catch { return null; }
|
|
135
|
-
try { const t = new Date(); fs.utimesSync(claimed, t, t); } catch { /* the ceiling then counts from queue time: earlier, never later */ }
|
|
136
|
-
return claimed;
|
|
137
|
-
}
|
|
138
|
-
const unclaimedName = (claimed) => path.join(path.dirname(claimed), `${QUEUE_PREFIX}${queueTail(path.basename(claimed))}`);
|
|
139
|
-
/** Put a claim back in the queue: rename (atomic, needs no hard links), restoring its creation order. */
|
|
140
|
-
const returnClaim = (claimed) => {
|
|
141
|
-
const queuedAt = Number(path.basename(claimed).slice(CLAIM_PREFIX.length).split('-')[2]);
|
|
142
|
-
const back = unclaimedName(claimed);
|
|
143
|
-
try { fs.renameSync(claimed, back); } catch { return false; }
|
|
144
|
-
if (Number.isFinite(queuedAt) && queuedAt > 0) { try { const t = new Date(queuedAt); fs.utimesSync(back, t, t); } catch { /* best effort */ } }
|
|
145
|
-
return true;
|
|
146
|
-
};
|
|
147
|
-
|
|
148
|
-
/**
|
|
149
|
-
* Return to the queue every claim whose worker is gone: its pid is dead, OR the pid now belongs to a
|
|
150
|
-
* different process (start time differs — pid reuse), OR the claim is older than REPLAY_LOCK_ABANDON_MS
|
|
151
|
-
* whatever the pid says (a reused pid where no start time can be read, a wedged worker). Without the
|
|
152
|
-
* last two a reused pid stranded a claim forever and every Stop spawned a worker that could not run it.
|
|
153
|
-
*/
|
|
154
|
-
export function reclaimOrphans(projectDir, { isAlive = pidAlive, startOf = processStart, now = Date.now() } = {}) {
|
|
155
|
-
let n = 0;
|
|
156
|
-
for (const name of swarmEntries(projectDir).filter((x) => x.startsWith(CLAIM_PREFIX))) {
|
|
157
|
-
const [pidText, start] = name.slice(CLAIM_PREFIX.length).split('-');
|
|
158
|
-
const pid = Number(pidText);
|
|
159
|
-
const claimed = path.join(projectDir, '.swarm', name);
|
|
160
|
-
const abandoned = now - mtimeOf(projectDir, name) > REPLAY_LOCK_ABANDON_MS;
|
|
161
|
-
let gone = abandoned || !isAlive(pid);
|
|
162
|
-
if (!gone && start && start !== 'na') {
|
|
163
|
-
const current = startOf(pid);
|
|
164
|
-
gone = Boolean(current) && current !== start;
|
|
165
|
-
}
|
|
166
|
-
if (gone && returnClaim(claimed)) n += 1;
|
|
167
|
-
}
|
|
168
|
-
return n;
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
const lockFacts = (file) => { const st = fs.statSync(file); return { content: fs.readFileSync(file, 'utf8'), mtimeMs: st.mtimeMs, ino: st.ino }; };
|
|
172
|
-
const sameFacts = (a, b) => a.content === b.content && a.mtimeMs === b.mtimeMs && a.ino === b.ino;
|
|
173
|
-
/** The pid ACTUALLY holding a lock: the `pid <n>` line a worker writes for itself, else the token's pid. */
|
|
174
|
-
const holderPid = (content) => {
|
|
175
|
-
const line = /^pid (\d+)$/m.exec(String(content));
|
|
176
|
-
return Number(line ? line[1] : String(content).trim().split('-')[0]);
|
|
177
|
-
};
|
|
178
|
-
|
|
179
|
-
/**
|
|
180
|
-
* Take the lock. Returns this holder's TOKEN (`<pid>-<time>-<random>`), or null.
|
|
181
|
-
* • Free → exclusive create.
|
|
182
|
-
* • Fresh (refreshed within REPLAY_LOCK_STALE_MS) → null.
|
|
183
|
-
* • Stale but its holder pid is ALIVE → null until REPLAY_LOCK_ABANDON_MS: a laptop asleep mid-step,
|
|
184
|
-
* or a long step, is not a dead worker, and taking over would put two workers on one job. The holder
|
|
185
|
-
* pid is the WORKER's own (it rewrites the lock on start), not the hook that spawned it and exited.
|
|
186
|
-
* • Otherwise taken over: the stale file is renamed aside and VERIFIED to be the very file judged
|
|
187
|
-
* stale (content, mtime, inode). If a successor's fresh lock was moved instead (it took over between
|
|
188
|
-
* our check and our rename), it is put back — never over a third lock — and we back off. One winner.
|
|
189
|
-
*/
|
|
190
|
-
export function takeReplayLock(projectDir, now = Date.now(), { isAlive = pidAlive, beforeRename = null } = {}) {
|
|
191
|
-
const lock = lockPath(projectDir);
|
|
192
|
-
const token = `${process.pid}-${now}-${Math.random().toString(36).slice(2, 10)}`;
|
|
193
|
-
const create = () => { fs.writeFileSync(lock, `${token}\npid ${process.pid}\n`, { flag: 'wx', mode: 0o600 }); return token; };
|
|
194
|
-
try { return create(); } catch { /* held, or stale */ }
|
|
195
|
-
let seen;
|
|
196
|
-
try { seen = lockFacts(lock); } catch { try { return create(); } catch { return null; } }
|
|
197
|
-
const age = now - seen.mtimeMs;
|
|
198
|
-
if (age <= REPLAY_LOCK_STALE_MS) return null;
|
|
199
|
-
if (age <= REPLAY_LOCK_ABANDON_MS && isAlive(holderPid(seen.content))) return null;
|
|
200
|
-
beforeRename?.();
|
|
201
|
-
const aside = `${lock}.stale-${token}`;
|
|
202
|
-
try { fs.renameSync(lock, aside); } catch { return null; }
|
|
203
|
-
let moved = null;
|
|
204
|
-
try { moved = lockFacts(aside); } catch { /* vanished */ }
|
|
205
|
-
if (!moved || !sameFacts(moved, seen)) {
|
|
206
|
-
// Put the successor's lock back without ever overwriting a third holder's: a hard link where the
|
|
207
|
-
// filesystem has them, else an exclusive copy.
|
|
208
|
-
try { fs.linkSync(aside, lock); } catch {
|
|
209
|
-
try { fs.copyFileSync(aside, lock, fs.constants.COPYFILE_EXCL); } catch { /* a third holder exists; the successor sees it lost the lock and stops */ }
|
|
210
|
-
}
|
|
211
|
-
try { fs.rmSync(aside, { force: true }); } catch { /* best effort */ }
|
|
212
|
-
return null;
|
|
213
|
-
}
|
|
214
|
-
try { fs.rmSync(aside, { force: true }); } catch { /* best effort */ }
|
|
215
|
-
try { return create(); } catch { return null; }
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
/** Heartbeat: refresh the lock's mtime if (and only if) this holder still owns it. */
|
|
219
|
-
export function refreshReplayLock(projectDir, token) {
|
|
220
|
-
if (!token || readLock(projectDir) !== token) return false;
|
|
221
|
-
try { const t = new Date(); fs.utimesSync(lockPath(projectDir), t, t); return true; } catch { return false; }
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
/** A worker that inherited the lock records ITS OWN pid on it, keeping the owner token. */
|
|
225
|
-
export function adoptReplayLock(projectDir, token) {
|
|
226
|
-
if (!token || readLock(projectDir) !== token) return false;
|
|
227
|
-
try { fs.writeFileSync(lockPath(projectDir), `${token}\npid ${process.pid}\n`, { mode: 0o600 }); return true; } catch { return false; }
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
/** Release ONLY a lock this holder owns; a successor's lock is never deleted. */
|
|
231
|
-
export function releaseReplayLock(projectDir, token) {
|
|
232
|
-
if (!token || readLock(projectDir) !== token) return false;
|
|
233
|
-
try { fs.rmSync(lockPath(projectDir), { force: true }); return true; } catch { return false; }
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
/** Hand the lock (or take it, if free) to a detached worker. Returns whether one was started. Never throws. */
|
|
237
|
-
export function replayOutboxDetached({ projectDir, token = null, spawnFn = spawn, env = process.env } = {}) {
|
|
238
|
-
try {
|
|
239
|
-
if (operatorProgressionSuspension(env)) { if (token) releaseReplayLock(projectDir, token); return false; }
|
|
240
|
-
} catch { if (token) releaseReplayLock(projectDir, token); return false; }
|
|
241
|
-
const held = token || takeReplayLock(projectDir);
|
|
242
|
-
if (!held) return false;
|
|
243
|
-
try {
|
|
244
|
-
const child = spawnFn(process.execPath, [path.join(path.dirname(fileURLToPath(import.meta.url)), 'session-snapshot-hook.mjs'), '--replay-outbox'], {
|
|
245
|
-
cwd: projectDir, detached: true, stdio: 'ignore', windowsHide: true,
|
|
246
|
-
env: { ...env, RUVNET_REPLAY_LOCK_TOKEN: held },
|
|
247
|
-
});
|
|
248
|
-
child.unref?.();
|
|
249
|
-
return true;
|
|
250
|
-
} catch {
|
|
251
|
-
releaseReplayLock(projectDir, held);
|
|
252
|
-
return false;
|
|
253
|
-
}
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
/**
|
|
257
|
-
* The detached worker's body, holding the lock `token`: record its own pid on the lock, return orphaned
|
|
258
|
-
* claims to the queue, replay the outbox, then run every queued capture IN ORDER — each CLAIMED by
|
|
259
|
-
* atomic rename first, so no other worker can run it too, and each re-entering the boundary as
|
|
260
|
-
* `ordered`, so it replays before it produces. Ownership is re-checked before every step and right
|
|
261
|
-
* after each claim; a worker that lost the lock puts an unstarted claim back and stops. A finished
|
|
262
|
-
* claim is the claimer's own and is deleted. Releases only its own lock, then re-checks for captures
|
|
263
|
-
* queued while it held it.
|
|
264
|
-
*/
|
|
265
|
-
export function runOutboxReplay({ projectDir, token = process.env.RUVNET_REPLAY_LOCK_TOKEN || null, budgetMs = DETACHED_REPLAY_BUDGET_MS,
|
|
266
|
-
makeStoreFactory = boundedStoreFactory, now = Date.now, runCapture = runSessionSnapshotHook, onClaim = null,
|
|
267
|
-
captureNormalized = captureNormalizedTransition, onCaptured = null, env = process.env,
|
|
268
|
-
deadlineAt: inheritedDeadlineAt = Infinity, signal } = {}) {
|
|
269
|
-
const deadlineAt = Math.min(inheritedDeadlineAt, now() + budgetMs);
|
|
270
|
-
if (signal?.aborted || now() >= deadlineAt) return 0;
|
|
271
|
-
if (developmentHooksSuspended(projectDir)) return 0;
|
|
272
|
-
if (operatorProgressionSuspension(env)) { if (token) releaseReplayLock(projectDir, token); return 0; }
|
|
273
|
-
const brainHome = env.RUVNET_BRAIN_HOME || path.join(env.HOME || os.homedir(), '.cache', 'ruvnet-brain');
|
|
274
|
-
try {
|
|
275
|
-
const consent = resolveTurnDb({ projectDir, brainHome, deadlineAt, signal });
|
|
276
|
-
if (consent.skipped && !consent.skipped.startsWith('no project memory db')) return 0;
|
|
277
|
-
} catch { return 0; }
|
|
278
|
-
if (signal?.aborted || now() >= deadlineAt) return 0;
|
|
279
|
-
let held = token || takeReplayLock(projectDir);
|
|
280
|
-
let replayed = 0;
|
|
281
|
-
for (let round = 0; held && round < 8 && now() < deadlineAt && !signal?.aborted; round += 1) {
|
|
282
|
-
try {
|
|
283
|
-
if (!adoptReplayLock(projectDir, held)) return replayed;
|
|
284
|
-
reclaimOrphans(projectDir);
|
|
285
|
-
const resolution = resolveProjectStore({ projectDir, deadlineAt });
|
|
286
|
-
const store = makeStoreFactory(deadlineAt)({ projectDir, env, requestedStorePath: resolution.canonicalAgentDbPath, deadlineAt, signal });
|
|
287
|
-
for (const snapshot of store.outbox.pendingSnapshots()) {
|
|
288
|
-
if (operatorProgressionSuspension(env)) return replayed;
|
|
289
|
-
if (signal?.aborted || now() >= deadlineAt) return replayed;
|
|
290
|
-
if (!refreshReplayLock(projectDir, held)) return replayed;
|
|
291
|
-
store.captureFrozen(snapshot, { canCommit: () => !signal?.aborted && now() < deadlineAt && refreshReplayLock(projectDir, held) });
|
|
292
|
-
// captureFrozen commits only the verified key, retaining conflicting original history.
|
|
293
|
-
replayed += 1;
|
|
294
|
-
}
|
|
295
|
-
for (const file of queuedCaptures(projectDir)) {
|
|
296
|
-
if (operatorProgressionSuspension(env)) return replayed;
|
|
297
|
-
if (signal?.aborted || now() >= deadlineAt) return replayed;
|
|
298
|
-
if (!refreshReplayLock(projectDir, held)) return replayed;
|
|
299
|
-
const claimed = claimQueued(file);
|
|
300
|
-
if (!claimed) continue;
|
|
301
|
-
onClaim?.(claimed);
|
|
302
|
-
if (signal?.aborted || now() >= deadlineAt) { returnClaim(claimed); return replayed; }
|
|
303
|
-
if (!refreshReplayLock(projectDir, held)) {
|
|
304
|
-
returnClaim(claimed);
|
|
305
|
-
return replayed;
|
|
306
|
-
}
|
|
307
|
-
if (operatorProgressionSuspension(env)) { returnClaim(claimed); return replayed; }
|
|
308
|
-
let job = null;
|
|
309
|
-
try { job = JSON.parse(fs.readFileSync(claimed, 'utf8')); } catch { /* torn: dropped below */ }
|
|
310
|
-
let committed = false;
|
|
311
|
-
try {
|
|
312
|
-
if (job) {
|
|
313
|
-
// Pre-upgrade raw queues lack origin identity and cannot be truthfully reconstructed.
|
|
314
|
-
if (runCapture === runSessionSnapshotHook && (!job.originProjectDir || (!job.payload?.projectProgression && !job.payload?.normalizedTransition))) { returnClaim(claimed); return replayed; }
|
|
315
|
-
const consent = resolveTurnDb({ projectDir: job.originProjectDir || projectDir, brainHome, deadlineAt, signal });
|
|
316
|
-
if (developmentHooksSuspended(job.originProjectDir || projectDir)
|
|
317
|
-
|| (consent.skipped && !consent.skipped.startsWith('no project memory db'))) { returnClaim(claimed); return replayed; }
|
|
318
|
-
const options = { rawInput: JSON.stringify(job.payload), host: job.host, env,
|
|
319
|
-
budgetMs: Math.max(0, deadlineAt - now()), deadlineAt, signal,
|
|
320
|
-
makeStoreFactory: () => makeStoreFactory(deadlineAt), now, ordered: held, writeMetadata: false,
|
|
321
|
-
// A queued job was admitted at its live boundary; replay must not re-run (or be suspended by) enrollment.
|
|
322
|
-
enrollMemory: () => ({ state: 'existing' }),
|
|
323
|
-
captureTurn: () => ({ recorded: false, skipped: 'detached replay' }),
|
|
324
|
-
captureEvents: () => ({ recorded: 0, skipped: 'detached replay' }) };
|
|
325
|
-
const result = job.payload?.normalizedTransition ? captureNormalized(job, options)
|
|
326
|
-
: runCapture(job.originProjectDir || projectDir, job.event, options);
|
|
327
|
-
committed = now() < deadlineAt && !signal?.aborted && result?.progressionCaptured === true && Boolean(result.receipt);
|
|
328
|
-
if (committed) onCaptured?.(result);
|
|
329
|
-
}
|
|
330
|
-
} catch { /* retain the queue until an exact-readback receipt exists */ }
|
|
331
|
-
if (!committed) { returnClaim(claimed); return replayed; }
|
|
332
|
-
try { fs.rmSync(claimed, { force: true }); } catch { /* best effort */ }
|
|
333
|
-
}
|
|
334
|
-
} catch { /* the debt stays durable; the next boundary hands it on again */ } finally {
|
|
335
|
-
releaseReplayLock(projectDir, held);
|
|
336
|
-
}
|
|
337
|
-
held = !signal?.aborted && now() < deadlineAt && queuedWork(projectDir) ? takeReplayLock(projectDir) : null;
|
|
338
|
-
}
|
|
339
|
-
if (held) releaseReplayLock(projectDir, held);
|
|
340
|
-
return replayed;
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
/** Synchronous bounded startup drain. Pending debt must downgrade restore, never disappear. */
|
|
345
|
-
export function drainCaptureQueue({ projectDir, budgetMs = 1000, ...options } = {}) {
|
|
346
|
-
if (operatorProgressionSuspension(options.env || process.env)) return { state: 'suspended', replayed: 0, pending: null };
|
|
347
|
-
const startedAt = Date.now();
|
|
348
|
-
const deadlineAt = Math.min(options.deadlineAt ?? Infinity, startedAt + budgetMs);
|
|
349
|
-
if (options.signal?.aborted || startedAt >= deadlineAt) return { state: 'pending', replayed: 0, pending: null, reason: 'restore deadline exceeded' };
|
|
350
|
-
const resolution = resolveProjectStore({ projectDir, gitTimeoutMs: Math.max(1, Math.min(300, budgetMs)), deadlineAt });
|
|
351
|
-
const root = resolution.projectRoot;
|
|
352
|
-
const replayed = runOutboxReplay({ ...options, deadlineAt, projectDir: root, budgetMs: Math.max(0, deadlineAt - Date.now()) });
|
|
353
|
-
let outboxPending = 0;
|
|
354
|
-
try { outboxPending = new ProgressionOutbox({ projectRoot: root }).pendingSnapshots().length; } catch { return { state: 'degraded', replayed, pending: null }; }
|
|
355
|
-
const pending = queuedWork(root) + outboxPending;
|
|
356
|
-
return { state: pending ? 'pending' : 'settled', replayed, pending };
|
|
357
|
-
}
|