ruvnet-brain 4.5.6 → 4.5.8
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 +50 -1
- package/kb/forge-update.mjs +50 -9
- package/package.json +5 -3
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/commands/configure.md +13 -1
- package/plugin/hooks/codex-hooks.json +31 -1
- package/plugin/hooks/hook-contracts.json +82 -2
- package/plugin/hooks/hooks.json +31 -1
- package/plugin/mcp/managed-cli-generation.mjs +91 -0
- package/plugin/mcp/managed-cli-interface.mjs +36 -12
- package/plugin/mcp/server.mjs +3 -2
- package/plugin/scripts/agentdb-recall.mjs +38 -10
- package/plugin/scripts/continuation-gate.mjs +3 -1
- package/plugin/scripts/continuity-hook-policy.mjs +3 -0
- package/plugin/scripts/continuity-journal.mjs +11 -11
- package/plugin/scripts/hook-shim.mjs +3 -2
- package/plugin/scripts/kb-copy-proof.mjs +84 -22
- package/plugin/scripts/learn-capture.mjs +44 -0
- package/plugin/scripts/learn-capture.sh +2 -241
- package/plugin/scripts/learn-flush.mjs +86 -191
- package/plugin/scripts/learning-observation.mjs +51 -0
- package/plugin/scripts/learning-queue.mjs +135 -0
- package/plugin/scripts/learning-store.mjs +105 -0
- package/plugin/scripts/learning-worker-supervisor.mjs +79 -0
- package/plugin/scripts/nightly-scheduler.mjs +6 -2
- package/plugin/scripts/project-capture-queue.mjs +16 -7
- package/plugin/scripts/project-progression-contract.mjs +58 -4
- package/plugin/scripts/project-progression-outbox.mjs +22 -6
- package/plugin/scripts/project-progression-session-start.mjs +10 -0
- package/plugin/scripts/project-progression-suspension.mjs +60 -0
- package/plugin/scripts/project-transition-hook.mjs +25 -8
- package/plugin/scripts/runtime-preferences.mjs +45 -4
- package/plugin/scripts/session-snapshot-hook.mjs +14 -7
- package/plugin/scripts/session-start-core.mjs +8 -0
- package/plugin/scripts/session-start-proof.mjs +49 -0
- package/plugin/scripts/turn-outcome-capture.mjs +7 -3
- package/scripts/codex-fresh-host-proof.mjs +179 -0
- package/scripts/codex-host-execution-proof.mjs +246 -0
- package/scripts/codex-host-proof-runtime.mjs +104 -0
- package/scripts/console-engine.mjs +29 -16
- package/scripts/health-repair.mjs +47 -68
- package/scripts/onboarding-console.mjs +25 -54
- package/scripts/qa/progression-validation-benchmark.mjs +66 -0
- package/scripts/release-qualification-contract.mjs +76 -7
- package/scripts/remedy-registry.mjs +9 -2
|
@@ -0,0 +1,135 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// Canonical workflow observations; existing Ruflo is the sole writer, never the legacy JSON hook store.
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { createHash } from 'node:crypto';
|
|
5
|
+
import { spawnSync } from 'node:child_process';
|
|
6
|
+
import { resolveProjectStore } from './project-store-resolver.mjs';
|
|
7
|
+
import { withProgressionReader } from './project-progression-reader.mjs';
|
|
8
|
+
import { rufloRunDir } from './project-progression-store.mjs';
|
|
9
|
+
import { rufloInvocation } from './ruflo-bin.mjs';
|
|
10
|
+
import { loadRuntimePreferences } from './runtime-preferences.mjs';
|
|
11
|
+
import { loadNodeSqlite } from './node-sqlite.mjs';
|
|
12
|
+
import { safeQueue, writeAtomic } from './learning-queue.mjs';
|
|
13
|
+
|
|
14
|
+
export const LEARNING_NAMESPACE = 'learning-observations';
|
|
15
|
+
export function learningTarget(context, { env = process.env, explicitLegacyApply = false } = {}) {
|
|
16
|
+
if (!context.enabled) throw new Error('learning consent disabled');
|
|
17
|
+
if (context.scope === 'project') return resolveProjectStore({ projectDir: context.projectDir, gitTimeoutMs: 1000 }).canonicalAgentDbPath;
|
|
18
|
+
const root = path.join(context.home, '.claude', 'global-memory');
|
|
19
|
+
const chosen = loadRuntimePreferences({ env, cwd: context.projectDir }).values.learningScope === 'user';
|
|
20
|
+
if (!chosen && !explicitLegacyApply) throw new Error('user learning requires persisted user consent or explicit legacy Apply');
|
|
21
|
+
safeQueue({ scope: 'user', home: context.home, queueDir: root });
|
|
22
|
+
const db = path.join(root, '.swarm', 'memory.db');
|
|
23
|
+
if (!fs.lstatSync(db).isFile()) throw new Error('user store not adopted');
|
|
24
|
+
const resolved = resolveProjectStore({ projectDir: root, requestedStorePath: db, gitTimeoutMs: 1000 });
|
|
25
|
+
if (resolved.projectRoot !== root) throw new Error('foreign global learner root');
|
|
26
|
+
return resolved.canonicalAgentDbPath;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function runLearningRuflo(binary, args, db, { env = process.env, deadline = Date.now() + 18_000 } = {}) {
|
|
30
|
+
const remaining = deadline - Date.now();
|
|
31
|
+
if (remaining <= 0) throw new Error('learning deadline exhausted');
|
|
32
|
+
const cwd = rufloRunDir(db, { root: env.RUVNET_RUFLO_CWD_ROOT || path.join(env.HOME, '.cache', 'ruvnet-brain', 'learning-ruflo') });
|
|
33
|
+
try {
|
|
34
|
+
const invocation = rufloInvocation(binary, args);
|
|
35
|
+
return spawnSync(invocation.executable, invocation.args, { cwd, env: { ...env, RUFLO_DAEMON_AUTOSTART: '0' },
|
|
36
|
+
encoding: 'utf8', shell: false, timeout: Math.min(6000, remaining), killSignal: 'SIGKILL', maxBuffer: 65536, windowsHide: true });
|
|
37
|
+
} finally { fs.rmSync(cwd, { force: true, recursive: true }); }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function recordLearningObservation(binary, context, file, record, row, options = {}) {
|
|
41
|
+
const db = learningTarget(context, options);
|
|
42
|
+
const key = 'workflow-' + createHash('sha256').update(path.basename(file) + ':' + record.key).digest('hex');
|
|
43
|
+
const value = JSON.stringify({ schemaVersion: 1, tool: row.tool, action: row.action, scope: context.scope,
|
|
44
|
+
authoritative: false, provenance: 'system-observation', outcome: 'host-reported-success' });
|
|
45
|
+
const run = args => { if (options.allowed && !options.allowed()) throw new Error('learning consent changed'); return runLearningRuflo(binary, args, db, options); };
|
|
46
|
+
const existing = withProgressionReader(db, reader => reader.readContent(LEARNING_NAMESPACE, key));
|
|
47
|
+
if (!(existing.ok && existing.value === value)) {
|
|
48
|
+
const stored = run(['memory', 'store', '--key', key, '--value', value, '--namespace', LEARNING_NAMESPACE,
|
|
49
|
+
'--no-upsert', '--provenance', 'system_observation', '--path', db]);
|
|
50
|
+
if (stored.error || stored.status !== 0) throw new Error('canonical observation write failed');
|
|
51
|
+
}
|
|
52
|
+
const retrieved = run(['memory', 'retrieve', '--key', key, '--namespace', LEARNING_NAMESPACE, '--value-only', '--path', db]);
|
|
53
|
+
if (retrieved.error || retrieved.status !== 0 || retrieved.stdout.trim() !== value) throw new Error('canonical exact CLI readback failed');
|
|
54
|
+
const independent = withProgressionReader(db, reader => reader.readContent(LEARNING_NAMESPACE, key));
|
|
55
|
+
if (!independent.ok || independent.value !== value) throw new Error('canonical independent row readback failed');
|
|
56
|
+
return { db, key, digest: createHash('sha256').update(value).digest('hex'), exactCli: true, independentRow: true };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function learningStoreStatus(db) {
|
|
60
|
+
const rows = withProgressionReader(db, reader => reader.listKeys(LEARNING_NAMESPACE, { maxEntries: 100_000 }).length);
|
|
61
|
+
if (!rows.ok) return { known: false, observations: 0, patterns: null, lastDistillAt: null };
|
|
62
|
+
const sqlite = loadNodeSqlite(); let connection;
|
|
63
|
+
try {
|
|
64
|
+
connection = new sqlite.DatabaseSync(db, { readOnly: true });
|
|
65
|
+
const present = name => !!connection.prepare('SELECT 1 FROM sqlite_master WHERE type=? AND name=?').get('table', name);
|
|
66
|
+
const patterns = present('reasoning_patterns') ? connection.prepare('SELECT count(*) AS n FROM reasoning_patterns WHERE json_valid(metadata) AND json_extract(metadata,?)=?').get('$.namespace', LEARNING_NAMESPACE).n : 0;
|
|
67
|
+
const at = present('distill_state') ? connection.prepare('SELECT last_run_at AS at FROM distill_state WHERE namespace=?').get(LEARNING_NAMESPACE)?.at : null;
|
|
68
|
+
return { known: true, observations: rows.value, patterns, lastDistillAt: at ?? null };
|
|
69
|
+
} catch { return { known: false, observations: rows.value, patterns: null, lastDistillAt: null }; }
|
|
70
|
+
finally { connection?.close(); }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Existing $0 structural distiller, with a verified native WAL-safe snapshot before mutation. */
|
|
74
|
+
export function distillLearning(binary, context, options = {}) {
|
|
75
|
+
const db = learningTarget(context, options); const before = learningStoreStatus(db);
|
|
76
|
+
if (!before.known) throw new Error('distillation baseline unavailable');
|
|
77
|
+
if (options.automatic && before.lastDistillAt !== null && Date.now() - before.lastDistillAt < 3600_000) {
|
|
78
|
+
return { db, completed: false, deferred: 'automatic cadence: at most hourly', patternDelta: 0, ratifiedLessons: 0 };
|
|
79
|
+
}
|
|
80
|
+
const backupDir = path.join(path.dirname(db), 'learning-backups');
|
|
81
|
+
const run = args => { if (options.allowed && !options.allowed()) throw new Error('learning consent changed'); return runLearningRuflo(binary, args, db, options); };
|
|
82
|
+
if (options.allowed && !options.allowed()) throw new Error('learning consent changed');
|
|
83
|
+
safeQueue({ scope: 'project', projectDir: path.dirname(path.dirname(db)), queueDir: backupDir }, true);
|
|
84
|
+
const prior = new Set(fs.readdirSync(backupDir));
|
|
85
|
+
const backup = run(['memory', 'backup', '--db', db, '--dir', backupDir, '--keep', '3', '--verbose']);
|
|
86
|
+
if (backup.error || backup.status !== 0) throw new Error('distillation backup failed');
|
|
87
|
+
if (!backup.stdout.includes('memory DB backed up → ') || backup.stdout.includes('byte-copy')) throw new Error('native online backup mode unverified');
|
|
88
|
+
const images = fs.readdirSync(backupDir).filter(name => name.endsWith('.db') && !prior.has(name));
|
|
89
|
+
if (images.length !== 1) throw new Error('fresh distillation snapshot absent');
|
|
90
|
+
const image = path.join(backupDir, images[0]);
|
|
91
|
+
fs.chmodSync(image, 0o600);
|
|
92
|
+
// The source-bound verbose marker above distinguishes native online backup from
|
|
93
|
+
// Ruflo's byte-copy fallback. Row verification is additional observation evidence,
|
|
94
|
+
// not a claim that this subset alone proves whole-database rollback.
|
|
95
|
+
const source = withProgressionReader(db, reader => reader.listKeys(LEARNING_NAMESPACE, { maxEntries: 100_000 }).map(key => [key, reader.readContent(LEARNING_NAMESPACE, key)]));
|
|
96
|
+
const copy = withProgressionReader(image, reader => reader.listKeys(LEARNING_NAMESPACE, { maxEntries: 100_000 }).map(key => [key, reader.readContent(LEARNING_NAMESPACE, key)]));
|
|
97
|
+
if (!source.ok || !copy.ok || JSON.stringify(source.value) !== JSON.stringify(copy.value)) throw new Error('distillation snapshot row verification failed');
|
|
98
|
+
writeAtomic(path.join(backupDir, 'latest-receipt.json'), JSON.stringify({ db, before, snapshot: image, providerMode: 'native-online-backup', observationsVerified: true, retainedSnapshots: 3, restore: 'automatic exact restore unavailable' }));
|
|
99
|
+
const distilled = run(['memory', 'distill', 'run', '--db', db, '--namespace', LEARNING_NAMESPACE,
|
|
100
|
+
'--max-entries', '8', '--judge', 'structural', '--budget-usd', '0']);
|
|
101
|
+
const after = learningStoreStatus(db);
|
|
102
|
+
return { db, before, after, completed: !distilled.error && distilled.status === 0,
|
|
103
|
+
patternDelta: before.known && after.known ? after.patterns - before.patterns : null,
|
|
104
|
+
snapshot: image, ratifiedLessons: 0 };
|
|
105
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
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
|
+
}
|
|
@@ -101,8 +101,12 @@ export function describeFailedRefreshRun(receipt, { reasonLimit = 200 } = {}) {
|
|
|
101
101
|
const ledger = receipt.phases.map((entry) => entry?.phase);
|
|
102
102
|
if (ledger.length > declared.length || ledger.some((phase, index) => phase !== declared[index])) return null;
|
|
103
103
|
if (ledger.length === 0) return `failed before its first phase (${receipt.terminalVerdict || 'unknown'})`;
|
|
104
|
-
const failing =
|
|
105
|
-
|
|
104
|
+
const failing = receipt.phases.find((entry) => entry?.required !== false && entry?.status === 'FAIL')
|
|
105
|
+
|| receipt.phases.find((entry) => entry?.status === 'FAIL')
|
|
106
|
+
|| [...receipt.phases].reverse().find((entry) => entry?.status !== 'PASS')
|
|
107
|
+
|| receipt.phases[receipt.phases.length - 1];
|
|
108
|
+
const reason = String(failing.evidence?.updateResult?.reason ?? failing.evidence?.error
|
|
109
|
+
?? failing.evidence?.reason ?? '').split('\n')[0].trim();
|
|
106
110
|
return reason ? `failed at ${failing.phase}: ${reason.slice(0, reasonLimit)}` : `failed at ${failing.phase}`;
|
|
107
111
|
}
|
|
108
112
|
|
|
@@ -13,6 +13,7 @@ import { redactProgression } from './project-progression-contract.mjs';
|
|
|
13
13
|
import { ProgressionOutbox } from './project-progression-outbox.mjs';
|
|
14
14
|
import { captureNormalizedTransition } from './project-transition-hook.mjs';
|
|
15
15
|
import { boundedStoreFactory, runSessionSnapshotHook } from './session-snapshot-hook.mjs';
|
|
16
|
+
import { operatorProgressionSuspension } from './project-progression-suspension.mjs';
|
|
16
17
|
|
|
17
18
|
/**
|
|
18
19
|
* How long the detached worker may spend per step. The lock is refreshed between steps and goes stale
|
|
@@ -49,6 +50,7 @@ const mtimeOf = (projectDir, name) => { try { return fs.statSync(path.join(proje
|
|
|
49
50
|
*/
|
|
50
51
|
export function queueCapture({ projectDir, originProjectDir = projectDir, event, host, payload, env = process.env }) {
|
|
51
52
|
try {
|
|
53
|
+
if (operatorProgressionSuspension(env)) return null;
|
|
52
54
|
const consent = resolveTurnDb({ projectDir: originProjectDir, requestedStorePath: path.join(projectDir, '.swarm', 'memory.db'),
|
|
53
55
|
brainHome: env.RUVNET_BRAIN_HOME || path.join(env.HOME || os.homedir(), '.cache', 'ruvnet-brain') });
|
|
54
56
|
if (consent.skipped) return null;
|
|
@@ -232,13 +234,16 @@ export function releaseReplayLock(projectDir, token) {
|
|
|
232
234
|
}
|
|
233
235
|
|
|
234
236
|
/** Hand the lock (or take it, if free) to a detached worker. Returns whether one was started. Never throws. */
|
|
235
|
-
export function replayOutboxDetached({ projectDir, token = null, spawnFn = spawn } = {}) {
|
|
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; }
|
|
236
241
|
const held = token || takeReplayLock(projectDir);
|
|
237
242
|
if (!held) return false;
|
|
238
243
|
try {
|
|
239
244
|
const child = spawnFn(process.execPath, [path.join(path.dirname(fileURLToPath(import.meta.url)), 'session-snapshot-hook.mjs'), '--replay-outbox'], {
|
|
240
245
|
cwd: projectDir, detached: true, stdio: 'ignore', windowsHide: true,
|
|
241
|
-
env: { ...
|
|
246
|
+
env: { ...env, RUVNET_REPLAY_LOCK_TOKEN: held },
|
|
242
247
|
});
|
|
243
248
|
child.unref?.();
|
|
244
249
|
return true;
|
|
@@ -259,10 +264,11 @@ export function replayOutboxDetached({ projectDir, token = null, spawnFn = spawn
|
|
|
259
264
|
*/
|
|
260
265
|
export function runOutboxReplay({ projectDir, token = process.env.RUVNET_REPLAY_LOCK_TOKEN || null, budgetMs = DETACHED_REPLAY_BUDGET_MS,
|
|
261
266
|
makeStoreFactory = boundedStoreFactory, now = Date.now, runCapture = runSessionSnapshotHook, onClaim = null,
|
|
262
|
-
captureNormalized = captureNormalizedTransition, onCaptured = null } = {}) {
|
|
267
|
+
captureNormalized = captureNormalizedTransition, onCaptured = null, env = process.env } = {}) {
|
|
263
268
|
const deadlineAt = now() + budgetMs;
|
|
264
269
|
if (developmentHooksSuspended(projectDir)) return 0;
|
|
265
|
-
|
|
270
|
+
if (operatorProgressionSuspension(env)) { if (token) releaseReplayLock(projectDir, token); return 0; }
|
|
271
|
+
const brainHome = env.RUVNET_BRAIN_HOME || path.join(env.HOME || os.homedir(), '.cache', 'ruvnet-brain');
|
|
266
272
|
try {
|
|
267
273
|
const consent = resolveTurnDb({ projectDir, brainHome });
|
|
268
274
|
if (consent.skipped && !consent.skipped.startsWith('no project memory db')) return 0;
|
|
@@ -274,8 +280,9 @@ export function runOutboxReplay({ projectDir, token = process.env.RUVNET_REPLAY_
|
|
|
274
280
|
if (!adoptReplayLock(projectDir, held)) return replayed;
|
|
275
281
|
reclaimOrphans(projectDir);
|
|
276
282
|
const resolution = resolveProjectStore({ projectDir });
|
|
277
|
-
const store = makeStoreFactory(deadlineAt)({ projectDir, requestedStorePath: resolution.canonicalAgentDbPath });
|
|
283
|
+
const store = makeStoreFactory(deadlineAt)({ projectDir, env, requestedStorePath: resolution.canonicalAgentDbPath });
|
|
278
284
|
for (const snapshot of store.outbox.pendingSnapshots()) {
|
|
285
|
+
if (operatorProgressionSuspension(env)) return replayed;
|
|
279
286
|
if (now() >= deadlineAt) return replayed;
|
|
280
287
|
if (!refreshReplayLock(projectDir, held)) return replayed;
|
|
281
288
|
store.captureFrozen(snapshot, { canCommit: () => refreshReplayLock(projectDir, held) });
|
|
@@ -283,6 +290,7 @@ export function runOutboxReplay({ projectDir, token = process.env.RUVNET_REPLAY_
|
|
|
283
290
|
replayed += 1;
|
|
284
291
|
}
|
|
285
292
|
for (const file of queuedCaptures(projectDir)) {
|
|
293
|
+
if (operatorProgressionSuspension(env)) return replayed;
|
|
286
294
|
if (now() >= deadlineAt) return replayed;
|
|
287
295
|
if (!refreshReplayLock(projectDir, held)) return replayed;
|
|
288
296
|
const claimed = claimQueued(file);
|
|
@@ -292,6 +300,7 @@ export function runOutboxReplay({ projectDir, token = process.env.RUVNET_REPLAY_
|
|
|
292
300
|
returnClaim(claimed);
|
|
293
301
|
return replayed;
|
|
294
302
|
}
|
|
303
|
+
if (operatorProgressionSuspension(env)) { returnClaim(claimed); return replayed; }
|
|
295
304
|
let job = null;
|
|
296
305
|
try { job = JSON.parse(fs.readFileSync(claimed, 'utf8')); } catch { /* torn: dropped below */ }
|
|
297
306
|
let committed = false;
|
|
@@ -302,7 +311,7 @@ export function runOutboxReplay({ projectDir, token = process.env.RUVNET_REPLAY_
|
|
|
302
311
|
const consent = resolveTurnDb({ projectDir: job.originProjectDir || projectDir, brainHome });
|
|
303
312
|
if (developmentHooksSuspended(job.originProjectDir || projectDir)
|
|
304
313
|
|| (consent.skipped && !consent.skipped.startsWith('no project memory db'))) { returnClaim(claimed); return replayed; }
|
|
305
|
-
const options = { rawInput: JSON.stringify(job.payload), host: job.host,
|
|
314
|
+
const options = { rawInput: JSON.stringify(job.payload), host: job.host, env,
|
|
306
315
|
budgetMs: Math.max(0, deadlineAt - now()), makeStoreFactory: () => makeStoreFactory(deadlineAt), now, ordered: held, writeMetadata: false,
|
|
307
316
|
captureTurn: () => ({ recorded: false, skipped: 'detached replay' }),
|
|
308
317
|
captureEvents: () => ({ recorded: 0, skipped: 'detached replay' }) };
|
|
@@ -327,6 +336,7 @@ export function runOutboxReplay({ projectDir, token = process.env.RUVNET_REPLAY_
|
|
|
327
336
|
|
|
328
337
|
/** Synchronous bounded startup drain. Pending debt must downgrade restore, never disappear. */
|
|
329
338
|
export function drainCaptureQueue({ projectDir, budgetMs = 1000, ...options } = {}) {
|
|
339
|
+
if (operatorProgressionSuspension(options.env || process.env)) return { state: 'suspended', replayed: 0, pending: null };
|
|
330
340
|
const startedAt = Date.now();
|
|
331
341
|
const resolution = resolveProjectStore({ projectDir, gitTimeoutMs: Math.max(1, Math.min(300, budgetMs)) });
|
|
332
342
|
const root = resolution.projectRoot;
|
|
@@ -336,4 +346,3 @@ export function drainCaptureQueue({ projectDir, budgetMs = 1000, ...options } =
|
|
|
336
346
|
const pending = queuedWork(root) + outboxPending;
|
|
337
347
|
return { state: pending ? 'pending' : 'settled', replayed, pending };
|
|
338
348
|
}
|
|
339
|
-
|
|
@@ -14,7 +14,32 @@ function canonicalize(value) {
|
|
|
14
14
|
}
|
|
15
15
|
|
|
16
16
|
export function digestCanonical(value) {
|
|
17
|
-
return crypto.createHash('sha256').update(JSON.stringify(canonicalize(value))).digest('hex');
|
|
17
|
+
return crypto.createHash('sha256').update(JSON.stringify(isCanonicalJson(value) ? value : canonicalize(value))).digest('hex');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Stored snapshots are already canonical. Verify that fact without constructing a second
|
|
21
|
+
// complete object graph; arbitrary callers still receive the original sorting/validation path.
|
|
22
|
+
function isCanonicalJson(value) {
|
|
23
|
+
if (value === null || typeof value === 'string' || typeof value === 'boolean') return true;
|
|
24
|
+
if (typeof value === 'number') return Number.isFinite(value);
|
|
25
|
+
if (Array.isArray(value)) {
|
|
26
|
+
if (Object.getPrototypeOf(value) !== Array.prototype || ['toJSON', 'map', 'constructor'].some(key => Object.hasOwn(value, key))) return false;
|
|
27
|
+
for (let index = 0; index < value.length; index++) {
|
|
28
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, index);
|
|
29
|
+
if (!descriptor || !Object.hasOwn(descriptor, 'value') || !isCanonicalJson(descriptor.value)) return false;
|
|
30
|
+
}
|
|
31
|
+
return true;
|
|
32
|
+
}
|
|
33
|
+
if (!value || typeof value !== 'object' || Object.getPrototypeOf(value) !== Object.prototype) return false;
|
|
34
|
+
if (Object.hasOwn(value, 'toJSON')) return false;
|
|
35
|
+
let previous;
|
|
36
|
+
for (const key of Object.keys(value)) {
|
|
37
|
+
if (previous !== undefined && previous > key) return false;
|
|
38
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
|
39
|
+
if (!Object.hasOwn(descriptor, 'value') || !isCanonicalJson(descriptor.value)) return false;
|
|
40
|
+
previous = key;
|
|
41
|
+
}
|
|
42
|
+
return true;
|
|
18
43
|
}
|
|
19
44
|
|
|
20
45
|
function requireRecord(value, label) {
|
|
@@ -115,6 +140,35 @@ const INLINE_SECRETS = Object.freeze([
|
|
|
115
140
|
},
|
|
116
141
|
]);
|
|
117
142
|
|
|
143
|
+
// Validation needs a verdict, not a rebuilt redacted payload. Keep exactly the writer's
|
|
144
|
+
// key and replacement semantics, including already-redacted markers and nested values.
|
|
145
|
+
function containsUnredactedSecret(value) {
|
|
146
|
+
const kinds = new Map();
|
|
147
|
+
const visit = (current) => {
|
|
148
|
+
if (typeof current === 'string') {
|
|
149
|
+
if (!/PRIVATE KEY|Bearer|(?:sk|api-key|ghp|github_pat)[-_]|password|passwd|token|secret|credential/i.test(current)) return false;
|
|
150
|
+
for (const rule of INLINE_SECRETS) {
|
|
151
|
+
rule.pattern.lastIndex = 0;
|
|
152
|
+
if (current.replace(rule.pattern, rule.replace) !== current) return true;
|
|
153
|
+
}
|
|
154
|
+
return false;
|
|
155
|
+
}
|
|
156
|
+
if (Array.isArray(current)) {
|
|
157
|
+
for (const item of current) if (visit(item)) return true;
|
|
158
|
+
} else if (current && typeof current === 'object' && Object.getPrototypeOf(current) === Object.prototype) {
|
|
159
|
+
for (const key of Object.keys(current)) {
|
|
160
|
+
if (!kinds.has(key)) kinds.set(key, secretKind(key));
|
|
161
|
+
const kind = kinds.get(key);
|
|
162
|
+
if (kind && current[key] !== null && current[key] !== undefined) {
|
|
163
|
+
if (current[key] !== `[REDACTED:${kind}]`) return true;
|
|
164
|
+
} else if (visit(current[key])) return true;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return false;
|
|
168
|
+
};
|
|
169
|
+
return visit(value);
|
|
170
|
+
}
|
|
171
|
+
|
|
118
172
|
export function redactProgression(value) {
|
|
119
173
|
const redactions = [];
|
|
120
174
|
const mark = (path, kind) => {
|
|
@@ -239,15 +293,15 @@ export function validateProgressionSnapshot(snapshot, { expectedProjectIdentity
|
|
|
239
293
|
} catch { errors.push('event key is unverifiable'); }
|
|
240
294
|
}
|
|
241
295
|
try {
|
|
242
|
-
|
|
243
|
-
|
|
296
|
+
if (Object.getPrototypeOf(snapshot) !== Object.prototype) throw new TypeError('snapshot must be a plain JSON object');
|
|
297
|
+
const { payloadDigest: _digest, ...body } = snapshot;
|
|
244
298
|
if (snapshot.payloadDigest !== digestCanonical(body)) errors.push('payload digest mismatch');
|
|
245
299
|
} catch { errors.push('payload is not canonical JSON'); }
|
|
246
300
|
if (Array.isArray(snapshot.redactions)
|
|
247
301
|
&& snapshot.redactions.some((row) => !row || typeof row.path !== 'string' || typeof row.kind !== 'string'
|
|
248
302
|
|| Object.keys(row).sort().join(',') !== 'kind,path')) errors.push('invalid redaction marker');
|
|
249
303
|
try {
|
|
250
|
-
if (
|
|
304
|
+
if (containsUnredactedSecret(snapshot)) errors.push('unredacted secret material');
|
|
251
305
|
} catch { /* canonical validation already reports the malformed payload */ }
|
|
252
306
|
|
|
253
307
|
return { ok: errors.length === 0, errors: [...new Set(errors)] };
|
|
@@ -157,13 +157,21 @@ export class ProgressionOutbox {
|
|
|
157
157
|
const committed = new Map();
|
|
158
158
|
const quarantined = new Map();
|
|
159
159
|
const recoveries = [];
|
|
160
|
+
let ordinal = 0;
|
|
160
161
|
for (const record of readJsonl(this.path)) {
|
|
162
|
+
ordinal += 1;
|
|
161
163
|
requireIdentity(record?.eventKey, 'outbox eventKey');
|
|
162
164
|
requireIdentity(record?.payloadDigest, 'outbox payloadDigest');
|
|
163
165
|
if (record.type === 'snapshot') {
|
|
164
166
|
const prior = snapshots.get(record.eventKey);
|
|
165
167
|
if (prior && prior.payloadDigest !== record.payloadDigest) quarantined.set(record.eventKey, 'outbox event key collision');
|
|
166
|
-
|
|
168
|
+
// Retain identity/recovery metadata, never every historical snapshot's full payload.
|
|
169
|
+
// A second incremental scan materializes only the unresolved payloads selected here.
|
|
170
|
+
else snapshots.set(record.eventKey, { eventKey: record.eventKey, payloadDigest: record.payloadDigest,
|
|
171
|
+
ordinal, recoveryDiagnostics: record.snapshot?.recoveryDiagnostics && {
|
|
172
|
+
originalEventKey: record.snapshot.recoveryDiagnostics.originalEventKey,
|
|
173
|
+
frozenPayloadDigest: record.snapshot.recoveryDiagnostics.frozenPayloadDigest,
|
|
174
|
+
}, snapshotDigest: record.snapshot?.payloadDigest });
|
|
167
175
|
} else if (record.type === 'commit') {
|
|
168
176
|
const prior = committed.get(record.eventKey);
|
|
169
177
|
if (prior && prior !== record.payloadDigest) quarantined.set(record.eventKey, 'outbox commit collision');
|
|
@@ -186,19 +194,27 @@ export class ProgressionOutbox {
|
|
|
186
194
|
}
|
|
187
195
|
}
|
|
188
196
|
const recovered = new Set(recoveries.filter((record) => {
|
|
189
|
-
const target = snapshots.get(record.recoveryEventKey)
|
|
197
|
+
const target = snapshots.get(record.recoveryEventKey);
|
|
190
198
|
return !quarantined.has(record.recoveryEventKey)
|
|
191
199
|
&& committed.get(record.recoveryEventKey) === record.recoveryPayloadDigest
|
|
192
|
-
&& target?.
|
|
200
|
+
&& target?.snapshotDigest === record.recoveryPayloadDigest
|
|
193
201
|
&& target.recoveryDiagnostics?.originalEventKey === record.eventKey
|
|
194
202
|
&& target.recoveryDiagnostics?.frozenPayloadDigest === record.payloadDigest;
|
|
195
203
|
}).map((record) => `${record.eventKey}:${record.payloadDigest}`));
|
|
196
204
|
this.quarantine = [...quarantined].map(([eventKey, reason]) => ({ eventKey, reason }));
|
|
197
|
-
|
|
205
|
+
const pending = new Map([...snapshots.values()]
|
|
198
206
|
.filter((record) => !quarantined.has(record.eventKey) && !committed.has(record.eventKey)
|
|
199
207
|
&& !recovered.has(`${record.eventKey}:${record.payloadDigest}`))
|
|
200
|
-
.
|
|
201
|
-
|
|
208
|
+
.map((record) => [record.ordinal, record.eventKey]));
|
|
209
|
+
const payloads = [];
|
|
210
|
+
ordinal = 0;
|
|
211
|
+
if (pending.size) {
|
|
212
|
+
for (const record of readJsonl(this.path)) {
|
|
213
|
+
ordinal += 1;
|
|
214
|
+
if (pending.has(ordinal)) payloads.push({ eventKey: pending.get(ordinal), snapshot: record.snapshot });
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
return payloads.sort((left, right) => left.eventKey.localeCompare(right.eventKey)).map((record) => record.snapshot);
|
|
202
218
|
}
|
|
203
219
|
|
|
204
220
|
/** Keys pendingSnapshots() refused to replay because their records disagree, with the reason. */
|
|
@@ -8,6 +8,7 @@ import { withProgressionReader } from './project-progression-reader.mjs';
|
|
|
8
8
|
import { drainCaptureQueue, queuedWork } from './session-snapshot-hook.mjs';
|
|
9
9
|
import { replayTurnQueue } from './turn-outcome-capture.mjs';
|
|
10
10
|
import { STAGE_BUDGETS_MS } from './session-start-budget.mjs';
|
|
11
|
+
import { operatorProgressionSuspension } from './project-progression-suspension.mjs';
|
|
11
12
|
|
|
12
13
|
const PROGRESSION_NAMESPACE = 'project-progression';
|
|
13
14
|
|
|
@@ -191,6 +192,15 @@ export function restoreProgressionForSession({
|
|
|
191
192
|
},
|
|
192
193
|
} = {}) {
|
|
193
194
|
const projectDir = env.CLAUDE_PROJECT_DIR || cwd;
|
|
195
|
+
try {
|
|
196
|
+
if (operatorProgressionSuspension(env)) {
|
|
197
|
+
// Turn recording is independent: suspension must not strand ordinary AgentDB capture.
|
|
198
|
+
const turns = replayTurnQueue({ projectDir, env, home: env.HOME || os.homedir(), synchronous: true,
|
|
199
|
+
deadlineMs: Date.now() + deadlineMs });
|
|
200
|
+
return { status: 'unavailable', reason: 'operator-suspended', severity: 'info', pendingTurns: turns.pending,
|
|
201
|
+
context: '[RuvNet Brain — PROJECT CONTINUITY UNAVAILABLE]\nAutomatic progression capture, replay and restore are operator-suspended. Evidence and queues are preserved. Ordinary AgentDB memory and explicit checkpoints remain available; no project progression was restored.' };
|
|
202
|
+
}
|
|
203
|
+
} catch { return unknown('restore-failed'); }
|
|
194
204
|
let resolution;
|
|
195
205
|
try {
|
|
196
206
|
resolution = resolveProjectStore({ projectDir });
|