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.
Files changed (75) hide show
  1. package/README.md +1 -1
  2. package/bin/install.mjs +6 -50
  3. package/config/practical-rule-catalog.json +22 -22
  4. package/console/app.js +1 -1
  5. package/console/architecture.html +2 -2
  6. package/package.json +2 -2
  7. package/plugin/.claude-plugin/plugin.json +1 -1
  8. package/plugin/.codex-plugin/plugin.json +1 -1
  9. package/plugin/commands/checkpoint.md +22 -40
  10. package/plugin/commands/rnb-brief.md +18 -15
  11. package/plugin/hooks/codex-hooks.json +1 -58
  12. package/plugin/hooks/hook-contracts.json +19 -220
  13. package/plugin/hooks/hooks.json +1 -70
  14. package/plugin/mcp/managed-cli-interface.mjs +0 -56
  15. package/plugin/scripts/agentdb-recall-reader.mjs +29 -9
  16. package/plugin/scripts/agentdb-recall.mjs +27 -21
  17. package/plugin/scripts/brain-footprint.mjs +1 -1
  18. package/plugin/scripts/codex-hook-wrapper.mjs +13 -3
  19. package/plugin/scripts/completion-claim-evidence.mjs +1 -1
  20. package/plugin/scripts/continuation-gate.mjs +78 -1
  21. package/plugin/scripts/continuity-events.mjs +90 -82
  22. package/plugin/scripts/continuity-hook-policy.mjs +5 -14
  23. package/plugin/scripts/decision-gate.mjs +3 -3
  24. package/plugin/scripts/decision-recall-gate.mjs +141 -0
  25. package/plugin/scripts/decision-record.mjs +324 -0
  26. package/plugin/scripts/deferral-evidence.mjs +134 -0
  27. package/plugin/scripts/hook-failures.mjs +61 -0
  28. package/plugin/scripts/hook-shim.mjs +71 -6
  29. package/plugin/scripts/learn-capture.mjs +88 -37
  30. package/plugin/scripts/learning-store.mjs +70 -60
  31. package/plugin/scripts/node-sqlite.mjs +1 -1
  32. package/plugin/scripts/ruflo-bin.mjs +71 -1
  33. package/plugin/scripts/ruflo-memory-hooks.mjs +132 -0
  34. package/plugin/scripts/runtime-preferences.mjs +1 -1
  35. package/plugin/scripts/session-snapshot-hook.mjs +85 -332
  36. package/plugin/scripts/session-start-core.mjs +7 -9
  37. package/plugin/scripts/signal-watch.mjs +1 -1
  38. package/plugin/scripts/substitution-wall.mjs +222 -0
  39. package/plugin/scripts/turn-outcome-capture.mjs +74 -88
  40. package/scripts/codex-host-execution-proof.mjs +1 -1
  41. package/scripts/console-engine.mjs +0 -32
  42. package/scripts/deferral-replay.mjs +47 -0
  43. package/scripts/health-repair.mjs +1 -81
  44. package/scripts/hook-qualify-core.mjs +2 -2
  45. package/scripts/model-managed-workflow-service.mjs +9 -10
  46. package/scripts/model-routing-controller.mjs +9 -8
  47. package/scripts/no-silent-substitution.mjs +65 -1
  48. package/scripts/onboarding-console.mjs +8 -23
  49. package/scripts/product-integrity-contract.mjs +1 -1
  50. package/scripts/release-qualification-contract.mjs +9 -27
  51. package/scripts/remedy-registry.mjs +0 -18
  52. package/scripts/wired-check.mjs +1 -5
  53. package/plugin/scripts/continuity-brief.mjs +0 -357
  54. package/plugin/scripts/continuity-journal.mjs +0 -553
  55. package/plugin/scripts/learn-flush.mjs +0 -96
  56. package/plugin/scripts/learning-observation.mjs +0 -51
  57. package/plugin/scripts/learning-queue.mjs +0 -135
  58. package/plugin/scripts/learning-worker-supervisor.mjs +0 -79
  59. package/plugin/scripts/project-capture-queue.mjs +0 -357
  60. package/plugin/scripts/project-memory-enrollment.mjs +0 -203
  61. package/plugin/scripts/project-progression-checkpoint.mjs +0 -145
  62. package/plugin/scripts/project-progression-contract.mjs +0 -496
  63. package/plugin/scripts/project-progression-hook.mjs +0 -233
  64. package/plugin/scripts/project-progression-outbox.mjs +0 -225
  65. package/plugin/scripts/project-progression-producer.mjs +0 -309
  66. package/plugin/scripts/project-progression-reader.mjs +0 -304
  67. package/plugin/scripts/project-progression-session-start.mjs +0 -320
  68. package/plugin/scripts/project-progression-sources.mjs +0 -246
  69. package/plugin/scripts/project-progression-store.mjs +0 -723
  70. package/plugin/scripts/project-progression-suspension.mjs +0 -60
  71. package/plugin/scripts/project-transition-hook.mjs +0 -246
  72. package/plugin/scripts/session-snapshot-contract.mjs +0 -105
  73. package/plugin/scripts/turn-transport-journal.mjs +0 -108
  74. package/scripts/qa/progression-validation-benchmark.mjs +0 -66
  75. package/scripts/session-snapshot-contract.mjs +0 -1
@@ -1,553 +0,0 @@
1
- /**
2
- * Durable material events: fsync the outbox, store through canonical Ruflo, then exact-read back.
3
- * Ruflo refuses sql.js writes during native WAL contention (#2735/#2878); a refused event stays
4
- * pending and the bounded worker retries with backoff. Only verified bytes produce a commit receipt.
5
- * Current original-path consent governs capture and every replay attempt; opt-out retains debt.
6
- * One kind:id denotes one event, even when concurrent sessions observe it. A conflicting stored row
7
- * is quarantined. Missing store/Ruflo is not applicable, while pending debt or corruption is reported
8
- * by the SessionStart brief, doctor, and a once-per-session Claude Stop notice.
9
- *
10
- * Failures retain one attempts/last-error record per event. Lock-protected compaction checks the file
11
- * size before atomic replacement so concurrent appends survive. It removes committed history after
12
- * RETAIN_COMMITTED_MS, ages quarantine/corrupt notices after QUARANTINE_REPORT_MS, and bounds committed
13
- * history at MAX_EVENT_RECORDS. Pending events are never dropped; prolonged outages report pressure.
14
- */
15
- import { nativeTurnLines } from './native-turn-transcript.mjs';
16
- import fs from 'node:fs';
17
- import os from 'node:os';
18
- import path from 'node:path';
19
- import { randomUUID } from 'node:crypto';
20
- import { spawn, spawnSync } from 'node:child_process';
21
- import { fileURLToPath } from 'node:url';
22
- import { digestCanonical } from './project-progression-contract.mjs';
23
- import { withProgressionReader } from './project-progression-reader.mjs';
24
- import { rufloRunDir } from './project-progression-store.mjs';
25
- import { resolveRuflo, rufloInvocation } from './ruflo-bin.mjs';
26
- import { resolveProjectStore } from './project-store-resolver.mjs';
27
- import { resolveTurnDb, readSettledTranscript, claudeTurn } from './turn-outcome-capture.mjs';
28
- import { privateContinuityEvent, maskExcludedPaths, captureFailureReason, turnReferencesExcludedResource } from './turn-capture-privacy.mjs';
29
- import {
30
- CONTINUITY_NAMESPACE, INITIAL_LOOKBACK_MS, collectCommits, collectReleases, collectTurnEvents, eventIdOf, eventKey,
31
- } from './continuity-events.mjs';
32
-
33
- export const OUTBOX_NAME = 'continuity-events-outbox.jsonl';
34
- const LOCK_NAME = '.continuity-events.lock';
35
- const APPEND_LOCK_NAME = '.continuity-events-outbox.lock';
36
- const NOTICE_STATE = '.continuity-stop-notices.json';
37
- export const DRAIN_BUDGET_MS = 90_000;
38
- export const RETRY_BACKOFF_MS = Object.freeze([1_000, 3_000, 8_000, 20_000]);
39
- /** Pending longer than this is no longer "in flight": it is stuck, and the user is told. */
40
- export const STUCK_AFTER_MS = 10 * 60_000;
41
- export const QUARANTINE_REPORT_MS = 7 * 86_400_000;
42
- export const RETAIN_COMMITTED_MS = 7 * 86_400_000;
43
- export const MAX_EVENT_RECORDS = 2_000;
44
- const [LOCK_STALE_MS, APPEND_LOCK_WAIT_MS, APPEND_LOCK_STALE_MS] = [3 * 60_000, 1_000, 30_000];
45
- const WAL_REFUSAL = /refusing an unsafe sql\.js|active native WAL|database is locked|SQLITE_BUSY/i;
46
- export const CLEAR_COMMAND = `node "${path.join(path.dirname(fileURLToPath(import.meta.url)), 'continuity-brief.mjs')}" --clear`;
47
-
48
- const SQLITE_HEADER = Buffer.concat([Buffer.from('SQLite format 3', 'latin1'), Buffer.of(0)]);
49
- /** Is `db` an initialized SQLite store (not absent, not an empty placeholder)? */
50
- export function storeReady(db) {
51
- try {
52
- const fd = fs.openSync(db, 'r');
53
- try { const head = Buffer.alloc(16); return fs.readSync(fd, head, 0, 16, 0) === 16 && head.equals(SQLITE_HEADER); } finally { fs.closeSync(fd); }
54
- } catch { return false; }
55
- }
56
- const pause = (t) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, t);
57
- const ms = (iso) => Date.parse(iso || '') || 0;
58
-
59
- export class ContinuityJournal {
60
- /** `ruflo`: the binary (string), null = not installed, undefined = resolve it. */
61
- constructor({ projectRoot, projectDir = projectRoot, env = process.env, fsync = fs.fsyncSync, now = Date.now, ruflo,
62
- deadlineAt = Infinity, signal } = {}) {
63
- if (typeof projectRoot !== 'string' || !projectRoot) throw new TypeError('projectRoot is required');
64
- this.projectRoot = projectRoot;
65
- this.projectDir = projectDir;
66
- this.brainHome = env.RUVNET_BRAIN_HOME || path.join(env.HOME || os.homedir(), '.cache', 'ruvnet-brain');
67
- this.swarm = path.join(projectRoot, '.swarm');
68
- this.path = path.join(this.swarm, OUTBOX_NAME);
69
- this.db = path.join(this.swarm, 'memory.db');
70
- this.fsync = fsync;
71
- this.now = now;
72
- this.ruflo = ruflo === undefined ? resolveRuflo() : ruflo;
73
- this.deadlineAt = deadlineAt;
74
- this.signal = signal;
75
- }
76
-
77
- requireBudget() {
78
- if (!this.signal && this.deadlineAt === Infinity) return;
79
- if (this.signal?.aborted || Date.now() >= this.deadlineAt) throw new Error('continuity capture deadline exceeded or aborted; unavailable');
80
- }
81
-
82
- /** Short exclusive section shared by every appender and the compactor (never held across a store call). */
83
- withAppendLock(fn) {
84
- const lock = path.join(this.swarm, APPEND_LOCK_NAME);
85
- this.requireBudget();
86
- const deadline = Math.min(Date.now() + APPEND_LOCK_WAIT_MS, this.deadlineAt);
87
- const token = `${process.pid} ${Date.now()} ${randomUUID()}\n`;
88
- const owns = () => {
89
- try { return !fs.lstatSync(lock).isSymbolicLink() && fs.readFileSync(lock, 'utf8') === token; } catch { return false; }
90
- };
91
- let held = false;
92
- // Only EEXIST is retried, and every iteration is deadline-bounded: EACCES/EROFS/ENOSPC used to spin at 100% CPU
93
- // forever (re-review B2). Any other error is the outbox's own failure: thrown, reported as "outbox write failed".
94
- for (;;) {
95
- this.requireBudget();
96
- try { fs.writeFileSync(lock, token, { flag: 'wx', mode: 0o600 }); held = true; break; } catch (error) {
97
- if (error?.code !== 'EEXIST') throw error;
98
- }
99
- let stale = false;
100
- try {
101
- const st = fs.lstatSync(lock), body = fs.readFileSync(lock, 'utf8');
102
- const pid = Number(body.trim().split(' ')[0]);
103
- let dead = false;
104
- if (Number.isSafeInteger(pid) && pid > 0) try { process.kill(pid, 0); } catch (error) { dead = error.code === 'ESRCH'; }
105
- stale = !st.isSymbolicLink() && dead && Date.now() - st.mtimeMs > APPEND_LOCK_STALE_MS
106
- && fs.readFileSync(lock, 'utf8') === body;
107
- } catch { /* unavailable ownership is not permission to remove a lock */ }
108
- let removed = false; // a stale lock we cannot remove is waited out ASLEEP, never spun on (re-review a6 NIT)
109
- if (stale) { try { fs.rmSync(lock, { force: true }); removed = true; } catch { /* not ours to remove: wait it out */ } }
110
- if (Date.now() >= deadline) throw Object.assign(new Error('outbox append lock unavailable; mutation deferred'), { code: 'EAGAIN' });
111
- if (!removed) pause(5);
112
- }
113
- try {
114
- if (!owns()) throw Object.assign(new Error('outbox append lock ownership changed; mutation deferred'), { code: 'EAGAIN' });
115
- return fn(owns);
116
- } finally { if (held && owns()) fs.rmSync(lock, { force: true }); }
117
- }
118
-
119
- /** Append records with ONE fsync. Refuses to create `.swarm` (absence = project did not adopt). */
120
- appendRecords(records) {
121
- this.requireBudget();
122
- if (!records.length) return 0;
123
- const stat = fs.lstatSync(this.swarm);
124
- if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error('.swarm is not a real directory');
125
- return this.withAppendLock((owns) => {
126
- const fd = fs.openSync(this.path, fs.constants.O_APPEND | fs.constants.O_CREAT | fs.constants.O_WRONLY | (fs.constants.O_NOFOLLOW ?? 0), 0o600);
127
- try {
128
- this.requireBudget();
129
- if (!owns()) throw Object.assign(new Error('outbox append lock ownership changed; mutation deferred'), { code: 'EAGAIN' });
130
- fs.writeSync(fd, records.map((r) => `${JSON.stringify(r)}\n`).join(''));
131
- this.fsync(fd);
132
- } finally { fs.closeSync(fd); }
133
- return records.length;
134
- });
135
- }
136
-
137
- /** Every line, tolerant: unparseable lines are counted, never thrown. */
138
- scan() {
139
- this.requireBudget();
140
- let text = '';
141
- try { text = fs.readFileSync(this.path, 'utf8'); } catch (error) {
142
- if (error.code !== 'ENOENT') throw new Error(`continuity outbox unreadable: ${error.code || 'I/O error'}`);
143
- }
144
- const events = new Map();
145
- const committed = new Map();
146
- const failures = new Map();
147
- const quarantined = new Map();
148
- const notices = [];
149
- let clearedAt = 0;
150
- let corrupt = 0;
151
- let lines = 0;
152
- for (const line of text.split('\n')) {
153
- this.requireBudget();
154
- if (!line.trim()) continue;
155
- lines += 1;
156
- let rec;
157
- try { rec = JSON.parse(line); } catch { corrupt += 1; continue; }
158
- if (rec?.type === 'event' && typeof rec.key === 'string' && typeof rec.digest === 'string' && rec.event) {
159
- if (!events.has(rec.key)) events.set(rec.key, rec); // a second observation of the same event: first wins
160
- } else if (rec?.type === 'commit' && typeof rec.key === 'string') committed.set(rec.key, rec);
161
- else if (rec?.type === 'failure' && typeof rec.key === 'string') {
162
- const prior = failures.get(rec.key);
163
- failures.set(rec.key, { ...rec, attempts: (prior?.attempts || 0) + (Number(rec.attempts) || 1) });
164
- } else if (rec?.type === 'quarantine' && typeof rec.key === 'string') quarantined.set(rec.key, rec);
165
- else if (rec?.type === 'notice') notices.push(rec);
166
- else if (rec?.type === 'cleared') clearedAt = Math.max(clearedAt, ms(rec.at));
167
- else corrupt += 1;
168
- }
169
- return { events, committed, failures, quarantined, notices, clearedAt, corrupt, lines, bytes: Buffer.byteLength(text) };
170
- }
171
-
172
- /** Events fsynced but not yet read back from AgentDB, oldest key first. A quarantined key never retries. */
173
- pending(scan = this.scan()) {
174
- return [...scan.events.values()]
175
- .filter((rec) => !scan.committed.has(rec.key) && !scan.quarantined.has(rec.key))
176
- .sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0));
177
- }
178
-
179
- /** `kind:id` of every event already journalled here or committed to the store (dedupe set). */
180
- knownIds(scan = this.scan()) {
181
- this.requireBudget();
182
- const ids = new Set();
183
- for (const key of scan.events.keys()) { const id = eventIdOf(key); if (id) ids.add(id); }
184
- if (storeReady(this.db)) {
185
- const listed = withProgressionReader(this.db, (reader) => reader.listKeys(CONTINUITY_NAMESPACE, { maxEntries: 200_000 }),
186
- { deadlineAt: this.deadlineAt, signal: this.signal });
187
- this.requireBudget();
188
- if (!listed.ok) throw new Error(`continuity deduplication unavailable: ${listed.reason}`);
189
- for (const key of listed.value) { this.requireBudget(); const id = eventIdOf(key); if (id) ids.add(id); }
190
- }
191
- return ids;
192
- }
193
-
194
- captureConsent(projectDir = this.projectDir, unknownOriginalPath = false) {
195
- this.requireBudget();
196
- const result = resolveTurnDb({ projectDir, brainHome: this.brainHome, requestedStorePath: this.db, unknownOriginalPath,
197
- deadlineAt: this.deadlineAt, signal: this.signal, gitTimeoutMs: Math.max(1, Math.min(1000, this.deadlineAt - Date.now())) });
198
- this.requireBudget();
199
- return result;
200
- }
201
-
202
- /** Journal the events this boundary observed that are not already known. Returns what was added. */
203
- record(events) {
204
- this.requireBudget();
205
- const consent = this.captureConsent();
206
- if (consent.skipped) throw new Error(`event capture suspended: ${consent.skipped}`);
207
- const known = this.knownIds(); const fresh = [];
208
- for (const original of events) { const event = privateContinuityEvent(original, consent.contentPathExcludes, consent.capturePath);
209
- this.requireBudget();
210
- const id = `${event.kind}:${event.id}`;
211
- if (known.has(id)) continue;
212
- known.add(id);
213
- fresh.push({ type: 'event', capturePath: consent.capturePath, key: eventKey(event), digest: digestCanonical(event), journaledAt: new Date(this.now()).toISOString(), event });
214
- }
215
- this.appendRecords(fresh);
216
- return fresh;
217
- }
218
-
219
- /** The one documented clear: acknowledges every reported quarantine/corrupt/drop up to now. */
220
- clearProblems() {
221
- // Compact first, so raw corrupt lines become a notice dated before the acknowledgement.
222
- try { this.compact(); } catch { /* the acknowledgement still stands for everything recorded so far */ }
223
- return this.appendRecords([{ type: 'cleared', at: new Date(this.now()).toISOString() }]);
224
- }
225
-
226
- /** Does the file carry anything compact() would remove? (cheap: from one scan) */
227
- needsCompaction(scan = this.scan()) {
228
- const now = this.now();
229
- if (scan.corrupt) return true;
230
- const failureLines = scan.lines - scan.events.size - scan.committed.size - scan.quarantined.size - scan.notices.length - (scan.clearedAt ? 1 : 0) - scan.corrupt;
231
- if (failureLines > scan.failures.size) return true;
232
- for (const c of scan.committed.values()) if (now - ms(c.committedAt) > RETAIN_COMMITTED_MS) return true;
233
- if (scan.notices.some((n) => now - ms(n.at) > QUARANTINE_REPORT_MS || ms(n.at) <= scan.clearedAt)) return true;
234
- // Quarantine warnings expire in status(); disputed original bytes do not expire here.
235
- // An unshrinkable pending backlog must not be rewritten on every capture boundary.
236
- return scan.events.size > MAX_EVENT_RECORDS && [...scan.events.keys()].some((key) => scan.committed.has(key));
237
- }
238
-
239
- /**
240
- * Rewrite the outbox to what still matters, atomically. Never loses an event that is still pending.
241
- * `beforeRename` is a test seam (an append landing mid-compaction must abort the rewrite, not vanish).
242
- */
243
- compact({ beforeRename = null } = {}) {
244
- const now = this.now();
245
- return this.withAppendLock((owns) => {
246
- let before;
247
- try { before = fs.statSync(this.path); } catch { return { compacted: false, reason: 'no outbox' }; }
248
- const scan = this.scan();
249
- const out = [];
250
- const keep = [...scan.events.values()].filter((rec) => {
251
- const c = scan.committed.get(rec.key);
252
- if (c) return now - ms(c.committedAt) <= RETAIN_COMMITTED_MS;
253
- return true; // pending and disputed originals are both durable recovery evidence
254
- }).sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0));
255
- // Only read-back committed events may leave at the cap. Pending bytes are the recovery copy.
256
- const gone = new Set(keep.filter((r) => scan.committed.has(r.key)).slice(0, Math.max(0, keep.length - MAX_EVENT_RECORDS)));
257
- keep.splice(0, keep.length, ...keep.filter((r) => !gone.has(r)));
258
- const kept = new Set(keep.map((r) => r.key));
259
- for (const rec of keep) {
260
- out.push(rec);
261
- if (scan.committed.has(rec.key)) out.push(scan.committed.get(rec.key));
262
- else if (scan.quarantined.has(rec.key)) out.push(scan.quarantined.get(rec.key));
263
- else if (scan.failures.has(rec.key)) out.push({ ...scan.failures.get(rec.key), type: 'failure' });
264
- }
265
- const notice = { corrupt: 0, dropped: 0, at: null };
266
- for (const n of scan.notices) {
267
- if (now - ms(n.at) > QUARANTINE_REPORT_MS || ms(n.at) <= scan.clearedAt) continue;
268
- notice.corrupt += Number(n.corrupt) || 0; notice.dropped += Number(n.dropped) || 0;
269
- notice.at = notice.at && notice.at < n.at ? notice.at : n.at;
270
- }
271
- if (scan.corrupt) {
272
- notice.corrupt += scan.corrupt;
273
- notice.at = notice.at || new Date(now).toISOString();
274
- }
275
- if (notice.corrupt || notice.dropped) out.push({ type: 'notice', ...notice });
276
- if (scan.clearedAt) out.push({ type: 'cleared', at: new Date(scan.clearedAt).toISOString() });
277
- const tmp = `${this.path}.tmp-${process.pid}`;
278
- const fd = fs.openSync(tmp, 'w', 0o600);
279
- try { fs.writeSync(fd, out.map((r) => `${JSON.stringify(r)}\n`).join('')); this.fsync(fd); } finally { fs.closeSync(fd); }
280
- beforeRename?.();
281
- this.requireBudget();
282
- if (!owns()) {
283
- fs.rmSync(tmp, { force: true });
284
- return { compacted: false, reason: 'outbox append lock ownership changed; compaction deferred' };
285
- }
286
- let now_;
287
- try { now_ = fs.statSync(this.path); } catch { now_ = null; }
288
- if (!now_ || now_.size !== before.size || now_.mtimeMs !== before.mtimeMs) {
289
- fs.rmSync(tmp, { force: true });
290
- return { compacted: false, reason: 'the outbox changed during compaction; left as is' };
291
- }
292
- fs.renameSync(tmp, this.path);
293
- return { compacted: true, lines: out.length, kept: kept.size, dropped: 0 };
294
- });
295
- }
296
-
297
- status(scan = this.scan()) {
298
- const now = this.now();
299
- const pending = this.pending(scan);
300
- const oldest = pending.length ? Math.min(...pending.map((r) => ms(r.journaledAt || r.event.at) || now)) : null;
301
- const commits = [...scan.committed.values()];
302
- const lastCommitAt = commits.reduce((max, c) => Math.max(max, ms(c.committedAt)), 0) || null;
303
- const lastFailure = [...scan.failures.values()].reduce((latest, f) => (!latest || f.at > latest.at ? f : latest), null);
304
- const dayStart = new Date(now); dayStart.setHours(0, 0, 0, 0);
305
- const eventsToday = commits.filter((c) => ms(c.committedAt) >= dayStart.getTime()).length;
306
- const reportable = (at) => at > scan.clearedAt && now - at <= QUARANTINE_REPORT_MS;
307
- const quarantined = [...scan.quarantined.values()].filter((q) => reportable(ms(q.at))).map((q) => q.key);
308
- const live = scan.notices.filter((n) => reportable(ms(n.at)));
309
- const corrupt = live.reduce((n, x) => n + (Number(x.corrupt) || 0), 0) + (scan.corrupt && reportable(now) ? scan.corrupt : 0);
310
- const dropped = live.reduce((n, x) => n + (Number(x.dropped) || 0), 0);
311
- const ready = storeReady(this.db);
312
- const notApplicable = !ready ? 'no AgentDB store in this project (.swarm/memory.db is not initialized)'
313
- : !this.ruflo ? 'ruflo is not installed' : null;
314
- const retainedQuarantined = [...scan.quarantined.keys()].filter(key => scan.events.has(key) && !scan.committed.has(key)).length;
315
- const capacityPressure = pending.length + retainedQuarantined > MAX_EVENT_RECORDS;
316
- const problem = quarantined.length ? 'quarantined' : corrupt ? 'corrupt' : dropped ? 'dropped'
317
- : !notApplicable && capacityPressure ? 'capacity-pressure' : !notApplicable && oldest !== null && now - oldest > STUCK_AFTER_MS ? 'stuck-pending' : null;
318
- return {
319
- outbox: this.path, db: this.db, storeReady: ready, rufloPresent: Boolean(this.ruflo),
320
- applicable: !notApplicable, notApplicable,
321
- pending: pending.length, oldestPendingAt: oldest, lastCommitAt, eventsToday,
322
- lastFailure: lastFailure && (!lastCommitAt || ms(lastFailure.at) > lastCommitAt) ? lastFailure : null,
323
- quarantined, retainedQuarantined, corrupt, dropped, capacityPressure, problem, stuck: Boolean(problem),
324
- };
325
- }
326
- }
327
-
328
- const ago = (t) => (t < 90_000 ? `${Math.max(0, Math.round(t / 1000))}s` : t < 5_400_000 ? `${Math.round(t / 60_000)}m` : `${Math.round(t / 3_600_000)}h`);
329
-
330
- /** The one-line positive (or loud) confirmation, shared by the brief, --doctor and the Stop line. */
331
- export function recordingLine(status, now = Date.now()) {
332
- if (!status) return 'AgentDB: recording unknown — status unavailable';
333
- if (status.stuck) {
334
- const clears = `clears itself in ${Math.round(QUARANTINE_REPORT_MS / 86_400_000)}d, or now: ${CLEAR_COMMAND}`;
335
- const why = status.problem === 'quarantined' ? `${status.quarantined.length} quarantined (a different row holds its key; never retried — ${clears})`
336
- : status.problem === 'corrupt' ? `${status.corrupt} corrupt outbox line(s) removed (${clears})`
337
- : status.problem === 'dropped' ? `${status.dropped} uncommitted event(s) dropped at the outbox cap (${clears})`
338
- : status.problem === 'capacity-pressure' ? `retained backlog exceeds soft limit ${MAX_EVENT_RECORDS}; no pending or disputed events discarded`
339
- : status.lastFailure ? `last error: ${status.lastFailure.reason || status.lastFailure.error}` : 'not committing';
340
- return `AgentDB: recording stuck — ${status.pending} event(s) pending${status.oldestPendingAt && status.problem === 'stuck-pending' ? ` for ${ago(now - status.oldestPendingAt)}` : ''}, ${why}.`
341
- + ` Pending events are durable in ${status.outbox} and retry at every capture boundary.`;
342
- }
343
- if (status.retainedQuarantined) return `AgentDB: recording unresolved — ${status.retainedQuarantined} disputed original event(s) retained, not committed; ${status.pending} event(s) pending. No disputed original is deleted automatically.`;
344
- if (status.notApplicable) {
345
- return `AgentDB: recording n/a — ${status.notApplicable}; ${status.pending} event(s) wait in the outbox (not a failure).`
346
- + (status.capacityPressure ? ` Capacity pressure: pending backlog exceeds soft limit ${MAX_EVENT_RECORDS}; no pending events discarded.` : '');
347
- }
348
- // ✓ only on evidence: a committed, read-back event. "Nothing failed yet" is not proof of recording.
349
- if (!status.lastCommitAt) return `AgentDB: recording not yet proven — no event committed and read back yet (outbox ${status.pending} pending)`;
350
- return `AgentDB: recording ✓ (last write ${ago(now - status.lastCommitAt)} ago, ${status.eventsToday} event(s) today, outbox ${status.pending} pending)`;
351
- }
352
-
353
- /** Shared notices: once per session/condition, bounded to the last twenty sessions in .swarm. */
354
- export function conditionNotice({ swarm, session, condition, message, now = Date.now }) {
355
- const file = path.join(swarm, NOTICE_STATE);
356
- let state = {};
357
- try { state = JSON.parse(fs.readFileSync(file, 'utf8')) || {}; } catch { /* first notice */ }
358
- const id = String(session || 'unknown');
359
- const shown = Array.isArray(state[id]?.conditions) ? state[id].conditions : [];
360
- if (shown.includes(condition)) return '';
361
- state[id] = { at: new Date(now()).toISOString(), conditions: [...shown, condition] };
362
- const recent = Object.entries(state).sort((a, b) => String(b[1]?.at).localeCompare(String(a[1]?.at))).slice(0, 20);
363
- try { fs.writeFileSync(file, JSON.stringify(Object.fromEntries(recent)), { mode: 0o600 }); } catch { /* still show it once */ }
364
- return message;
365
- }
366
- export function stopNotice({ journal, status, session }) {
367
- if (!status?.stuck || !status.problem) return '';
368
- return conditionNotice({ swarm: journal.swarm, session, condition: status.problem, now: () => journal.now(),
369
- message: `[RuvNet Brain] ${recordingLine(status, journal.now())}` });
370
- }
371
- function defaultStore({ ruflo, db, key, value }) {
372
- const cwd = rufloRunDir(db);
373
- const source = JSON.parse(value).source;
374
- const provenance = ['assistant-detected', 'owner-correction-detected', 'agent-result'].includes(source) ? 'agent_output' : 'system_observation';
375
- try {
376
- const { executable, args } = rufloInvocation(ruflo, ['memory', 'store', '--key', key, '--value', value,
377
- '--namespace', CONTINUITY_NAMESPACE, '--no-upsert', '--require-native', '--append-only', '--provenance', provenance, '--path', db]);
378
- const r = spawnSync(executable, args, { cwd, encoding: 'utf8', timeout: 60_000, windowsHide: true,
379
- env: { ...process.env, RUFLO_DAEMON_AUTOSTART: '0', CLAUDE_FLOW_MEMORY_PATH: undefined } });
380
- return { status: Number.isInteger(r.status) ? r.status : 1, output: `${r.stderr || ''}\n${r.stdout || ''}${r.error ? `\n${r.error.message}` : ''}` };
381
- } finally { fs.rmSync(cwd, { recursive: true, force: true }); }
382
- }
383
-
384
- function defaultReadBack({ ruflo, db, key }) {
385
- const cwd = rufloRunDir(db);
386
- try {
387
- const { executable, args } = rufloInvocation(ruflo, ['memory', 'retrieve', '--key', key, '--namespace', CONTINUITY_NAMESPACE, '--value-only', '--path', db]);
388
- const r = spawnSync(executable, args, { cwd, encoding: 'utf8', timeout: 60_000, windowsHide: true, env: { ...process.env, RUFLO_DAEMON_AUTOSTART: '0', CLAUDE_FLOW_MEMORY_PATH: undefined } });
389
- return { key, content: r.status === 0 ? String(r.stdout || '') : null, readPath: 'ruflo-cli' };
390
- } finally { fs.rmSync(cwd, { recursive: true, force: true }); }
391
- }
392
-
393
- /**
394
- * Commit every pending event: store → exact read-back → commit line. A refusal is retried with
395
- * backoff while the budget lasts; nothing is ever dropped. Failures are ONE record per event per run
396
- * (attempts counted), merged across runs by compact(). Returns { committed, failed, remaining }.
397
- */
398
- export function drain(journal, {
399
- ruflo = journal.ruflo, store = defaultStore, readBack = defaultReadBack,
400
- budgetMs = DRAIN_BUDGET_MS, backoff = RETRY_BACKOFF_MS, now = Date.now, sleep = pause,
401
- } = {}) {
402
- const deadline = now() + budgetMs;
403
- let committed = 0;
404
- let failed = 0;
405
- // Not applicable is not a failure: nothing is written, the events simply wait (review S4).
406
- if (!ruflo) return { committed, failed, remaining: journal.pending().length, skipped: 'ruflo not found' };
407
- if (!storeReady(journal.db)) return { committed, failed, remaining: journal.pending().length, skipped: 'store not initialized' };
408
- for (const rec of journal.pending()) {
409
- let done = false;
410
- let attempts = 0;
411
- let last = null;
412
- for (let attempt = 0; !done && now() < deadline; attempt += 1) {
413
- let consent;
414
- try { consent = journal.captureConsent(rec.capturePath || journal.projectRoot, !rec.capturePath); }
415
- catch (error) { consent = { skipped: `capture consent unavailable: ${error.message}` }; }
416
- if (consent.skipped) return { committed, failed, remaining: journal.pending().length, skipped: consent.skipped };
417
- if (now() >= deadline) break;
418
- const value = JSON.stringify(privateContinuityEvent(rec.event, consent.contentPathExcludes, consent.capturePath));
419
- if (value !== JSON.stringify(rec.event)) return { committed, failed, remaining: journal.pending().length, skipped: 'content exclusions changed; immutable continuity retained' };
420
- attempts += 1;
421
- const prior = readBack({ ruflo, db: journal.db, key: rec.key });
422
- const alreadyStored = typeof prior.content === 'string' && prior.content.trim() === value && (prior.key === undefined || prior.key === rec.key);
423
- const result = alreadyStored ? { status: 0 } : store({ ruflo, db: journal.db, key: rec.key, value });
424
- const back = alreadyStored ? prior : readBack({ ruflo, db: journal.db, key: rec.key });
425
- const content = typeof back.content === 'string' ? back.content.trim() : '';
426
- if (result.status === 0 && content === value && digestCanonical(rec.event) === rec.digest && (back.key === undefined || back.key === rec.key)) {
427
- journal.appendRecords([{ type: 'commit', key: rec.key, digest: rec.digest, committedAt: new Date(now()).toISOString(), readPath: back.readPath,
428
- alreadyStored, ...(attempts > 1 ? { attempts, lastError: last?.reason } : {}) }]);
429
- committed += 1;
430
- done = true;
431
- } else if (content && (content !== value || digestCanonical(rec.event) !== rec.digest || (back.key !== undefined && back.key !== rec.key))) {
432
- // A DIFFERENT row holds our key: never overwritten (--no-upsert), never retried, reported.
433
- journal.appendRecords([{ type: 'quarantine', key: rec.key, at: new Date(now()).toISOString(), reason: 'a different stored row holds this key' }]);
434
- failed += 1;
435
- done = true;
436
- } else {
437
- last = { reason: WAL_REFUSAL.test(result.output || '') ? 'wal-contention' : `store exited ${result.status}`, error: maskExcludedPaths(captureFailureReason({ stderr: result.output }, result.status), consent.contentPathExcludes) };
438
- const wait = backoff[Math.min(attempt, backoff.length - 1)];
439
- if (attempt >= backoff.length || now() + wait >= deadline) break;
440
- sleep(wait);
441
- }
442
- }
443
- if (!done && last) {
444
- failed += 1;
445
- try { journal.appendRecords([{ type: 'failure', key: rec.key, at: new Date(now()).toISOString(), attempts, ...last }]); } catch { /* the event itself is still durable */ }
446
- }
447
- if (!done && now() >= deadline) break;
448
- }
449
- try { if (journal.needsCompaction()) journal.compact(); } catch { /* a larger file is not a lost event */ }
450
- return { committed, failed, remaining: journal.pending().length };
451
- }
452
-
453
- /** One drainer per project at a time. Returns a release function, or null if another is live. */
454
- export function takeLock(journal, { now = Date.now } = {}) {
455
- const lock = path.join(journal.swarm, LOCK_NAME);
456
- const body = `${process.pid} ${now()}\n`;
457
- const create = () => { fs.writeFileSync(lock, body, { flag: 'wx', mode: 0o600 }); return () => { try { if (fs.readFileSync(lock, 'utf8') === body) fs.rmSync(lock, { force: true }); } catch { /* gone */ } }; };
458
- try { return create(); } catch { /* held */ }
459
- try {
460
- const [pid, at] = fs.readFileSync(lock, 'utf8').trim().split(' ').map(Number);
461
- let alive = false;
462
- try { process.kill(pid, 0); alive = true; } catch (e) { alive = e?.code === 'EPERM'; }
463
- if (now() - at > LOCK_STALE_MS && (!alive || now() - at > 10 * LOCK_STALE_MS)) {
464
- fs.rmSync(lock, { force: true });
465
- return create();
466
- }
467
- } catch { /* unreadable lock: leave it, the next boundary tries again */ }
468
- return null;
469
- }
470
-
471
- /** Start a detached drainer for this project. Never throws; returns whether one was started. */
472
- export function launchDrain({ projectRoot, spawnFn = spawn, env = process.env } = {}) {
473
- try {
474
- const child = spawnFn(process.execPath, [fileURLToPath(import.meta.url), '--drain', projectRoot], {
475
- cwd: os.tmpdir(), detached: true, stdio: 'ignore', windowsHide: true, env: { ...env, RUFLO_DAEMON_AUTOSTART: '0', CLAUDE_FLOW_MEMORY_PATH: undefined },
476
- });
477
- child.unref?.();
478
- return true;
479
- } catch { return false; }
480
- }
481
-
482
- /**
483
- * THE CAPTURE BOUNDARY'S CALL (session-snapshot-hook.mjs, Stop / PreCompact / SessionEnd on Claude,
484
- * Stop / SessionEnd on Codex). Reads git and the turn, journals new events with one fsync, and hands
485
- * the AgentDB writes to a detached drainer — so it fits Codex's 3s SessionEnd with room to spare.
486
- * Never throws; every skip carries its reason.
487
- */
488
- export function captureContinuityEvents({
489
- projectDir, event, payload = {}, host = 'claude', env = process.env, ruflo = resolveRuflo({ env }),
490
- readTranscript = (file) => readSettledTranscript(file, { maxMs: 0 }), launch = launchDrain, now = Date.now,
491
- deadlineAt = Infinity, signal,
492
- } = {}) {
493
- const report = { event, recorded: 0, launched: false };
494
- if (String(env.RUVNET_CONTINUITY_CAPTURE || '').toLowerCase() === 'off') return { ...report, skipped: 'RUVNET_CONTINUITY_CAPTURE=off' };
495
- try {
496
- if (signal?.aborted || Date.now() >= deadlineAt) throw new Error('continuity capture unavailable');
497
- let resolution;
498
- try { resolution = resolveProjectStore({ projectDir, deadlineAt, signal,
499
- gitTimeoutMs: Math.max(1, Math.min(1000, deadlineAt - Date.now())) }); } catch { return { ...report, skipped: 'project store could not be resolved' }; }
500
- const journal = new ContinuityJournal({ projectRoot: resolution.projectRoot, projectDir, env, now, ruflo, deadlineAt, signal });
501
- try {
502
- journal.requireBudget();
503
- const consent = journal.captureConsent();
504
- if (consent.skipped) return { ...report, skipped: consent.skipped.startsWith('no project memory db') ? `not applicable: ${consent.skipped}` : consent.skipped };
505
- } catch (error) { return { ...report, skipped: `capture consent unavailable: ${error.message}` }; }
506
- try {
507
- const st = fs.lstatSync(journal.swarm);
508
- if (!st.isDirectory() || st.isSymbolicLink()) return { ...report, skipped: '.swarm is not a real directory' };
509
- } catch { return { ...report, skipped: 'project has not adopted the canonical store' }; }
510
- // No initialized store: recording is not applicable here, so nothing is journalled (review S3c/S4).
511
- if (!storeReady(journal.db)) return { ...report, skipped: 'not applicable: no AgentDB store in this project (.swarm/memory.db is not initialized)' };
512
- const session = typeof payload.session_id === 'string' ? payload.session_id : null;
513
- const project = path.basename(resolution.projectRoot);
514
- const scan = journal.scan();
515
- const lastBoundary = [...scan.events.values()].reduce((max, r) => Math.max(max, ms(r.journaledAt)), 0);
516
- const sinceMs = lastBoundary ? Math.min(lastBoundary - 86_400_000, now() - 3_600_000) : now() - INITIAL_LOOKBACK_MS;
517
- const events = [];
518
- if (resolution.kind === 'git') {
519
- events.push(...collectCommits({ checkoutRoot: resolution.checkoutRoot, sinceMs, host, session, project }));
520
- events.push(...collectReleases({ checkoutRoot: resolution.checkoutRoot, sinceMs, host, session, project }));
521
- }
522
- if (['Stop', 'SessionEnd', 'PreCompact', 'SubagentStop'].includes(event)) {
523
- let lines = null;
524
- if (typeof payload.transcript_path === 'string' && payload.transcript_path) {
525
- try { lines = readTranscript(payload.transcript_path); } catch { /* unreadable transcript: git events still count */ }
526
- }
527
- const last = typeof payload.last_assistant_message === 'string' ? payload.last_assistant_message : ''; const policy = journal.captureConsent();
528
- if (!policy.skipped && !(lines && turnReferencesExcludedResource(claudeTurn(nativeTurnLines(lines, host)), policy.contentPathExcludes, projectDir))) events.push(...collectTurnEvents({ lines, lastAssistantMessage: last, host, session, project, env, at: now() }));
529
- }
530
- try { report.recorded = journal.record(events).length; } catch (error) { return { ...report, skipped: `outbox write failed: ${error.message}` }; }
531
- try { if (journal.needsCompaction()) journal.compact(); } catch { /* bounded next time */ }
532
- const status = journal.status();
533
- // A drainer only where it can succeed: an initialized store AND a ruflo to write it (review S4).
534
- if (status.pending && status.applicable) report.launched = launch({ projectRoot: resolution.projectRoot, env });
535
- return { ...report, status, journal };
536
- } catch {
537
- return { ...report, queued: report.recorded > 0, skipped: signal?.aborted ? 'continuity capture aborted; unavailable'
538
- : Date.now() >= deadlineAt ? 'continuity capture deadline exceeded; unavailable' : 'canonical continuity unavailable' };
539
- }
540
- }
541
-
542
- /** The detached worker body. */
543
- export function runDrain(projectRoot, options = {}) {
544
- const journal = new ContinuityJournal({ projectRoot, ...(options.env ? { env: options.env } : {}), ...(options.ruflo !== undefined ? { ruflo: options.ruflo } : {}) });
545
- const release = takeLock(journal);
546
- if (!release) return { skipped: 'another drainer holds the lock' };
547
- try { return drain(journal, options); } finally { release(); }
548
- }
549
-
550
- if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url) && process.argv[2] === '--drain') {
551
- try { runDrain(process.argv[3]); } catch { /* the events stay durable in the outbox */ }
552
- process.exit(0);
553
- }
@@ -1,96 +0,0 @@
1
- #!/usr/bin/env node
2
- // SessionEnd schedules a finite detached learner; --sync is the explicit Console drain.
3
- import fs from 'node:fs';
4
- import path from 'node:path';
5
- import { spawn } from 'node:child_process';
6
- import { fileURLToPath } from 'node:url';
7
- import { learningContext } from './runtime-preferences.mjs';
8
- import { recordLearningObservation, distillLearning } from './learning-store.mjs';
9
- import { superviseLearning } from './learning-worker-supervisor.mjs';
10
- import { resolveRuflo } from './ruflo-bin.mjs';
11
- import { safeQueue, queueFiles, pendingRecords, acknowledge, safeAction, takeQueueLock,
12
- ownsQueueLock, releaseQueueLock, WORKER_BUDGET_MS, writeExclusive, writeAtomic, readSafe } from './learning-queue.mjs';
13
-
14
- const started = Date.now();
15
- const budget = Math.min(WORKER_BUDGET_MS, Math.max(1, Number(process.env.LEARN_FLUSH_DEADLINE_MS) || WORKER_BUDGET_MS));
16
- const deadline = Math.min(started + budget, Number(process.env.RUVNET_LEARN_WORKER_EXPIRES) || Infinity);
17
- const context = () => learningContext();
18
- const initial = context();
19
- if (!initial.enabled) process.exit(0);
20
- const allowed = () => { const current = context(); return current.enabled && current.scope === initial.scope && current.queueDir === initial.queueDir; };
21
- let token = process.env.RUVNET_LEARN_WORKER_TOKEN;
22
- try { token ||= takeQueueLock(initial); } catch { process.exit(0); }
23
- if (!token || !ownsQueueLock(initial, token)) process.exit(0);
24
-
25
- // No enumeration/sorting on the native hook's synchronous path.
26
- if (!process.argv.includes('--worker')) {
27
- if (process.argv.includes('--sync') || process.argv.includes('--supervisor')) {
28
- await superviseLearning(initial, token, deadline, { report: process.argv.includes('--sync') });
29
- } else {
30
- try {
31
- const child = spawn(process.execPath, [fileURLToPath(import.meta.url), '--supervisor'], {
32
- cwd: initial.projectDir, detached: true, stdio: 'ignore', windowsHide: true,
33
- env: { ...process.env, RUVNET_LEARN_WORKER_TOKEN: token, RUVNET_LEARN_WORKER_EXPIRES: String(deadline), RUFLO_DAEMON_AUTOSTART: '0' },
34
- });
35
- child.once('error', () => releaseQueueLock(initial, token)); child.unref();
36
- } catch { releaseQueueLock(initial, token); }
37
- }
38
- process.exit(0);
39
- }
40
- const cursorFile = path.join(initial.queueDir, '.scan-cursor');
41
- let cursor = ''; try { cursor = readSafe(cursorFile, 256).toString(); } catch { /* first pass */ }
42
- let files; try { files = queueFiles(initial, { cursor, limit: 128, deadline }); } catch { if (!process.send) releaseQueueLock(initial, token); process.exit(0); }
43
- const result = { schemaVersion: 1, scope: initial.scope, startedAt: new Date().toISOString(),
44
- fed: 0, acknowledged: 0, failed: 0, malformed: 0, scannedFiles: 0, exhausted: false, recorded: [], distillation: null,
45
- contract: 'Exact CLI plus independent canonical AgentDB row commits observations; distillation and ratified lessons are separate; originals retained' };
46
- const binary = resolveRuflo({ home: initial.home });
47
- try {
48
- let bytes = 0; let scanned = null;
49
- for (const file of files.slice(0, 128)) {
50
- if (Date.now() >= deadline || result.fed + result.failed >= 8 || bytes >= 16 * 1024 * 1024) break;
51
- safeQueue(initial); result.scannedFiles++; scanned = path.basename(file);
52
- let state;
53
- try { state = pendingRecords(file); bytes += fs.statSync(file).size; }
54
- catch { result.failed++; continue; } // Unsafe/torn sidecars remain intact; siblings still get a fair turn.
55
- for (const record of state.records) {
56
- if (Date.now() >= deadline || result.fed + result.failed >= 8) break;
57
- if (!allowed() || !ownsQueueLock(initial, token)) break;
58
- let row;
59
- try { row = JSON.parse(record.raw); } catch { result.malformed++; continue; }
60
- const action = safeAction(row.tool, row.action);
61
- if (!record.key || !action) { result.malformed++; continue; }
62
- if (!binary) { result.failed++; break; }
63
- let delivery;
64
- try {
65
- delivery = recordLearningObservation(binary, initial, file, record, { tool: row.tool, action }, {
66
- deadline, explicitLegacyApply: process.env.RUVNET_LEGACY_USER_APPLY === '1',
67
- allowed: () => allowed() && ownsQueueLock(initial, token),
68
- });
69
- } catch { result.failed++; continue; }
70
- result.recorded.push(delivery);
71
- result.fed++;
72
- if (!allowed() || !ownsQueueLock(initial, token)) break;
73
- state.ack[record.key] = true;
74
- acknowledge(file, state.ack); result.acknowledged++;
75
- }
76
- }
77
- if (binary && result.fed && allowed() && ownsQueueLock(initial, token) && deadline - Date.now() > 1000) {
78
- try { result.distillation = distillLearning(binary, initial, { deadline, automatic: true, allowed: () => allowed() && ownsQueueLock(initial, token), explicitLegacyApply: process.env.RUVNET_LEGACY_USER_APPLY === '1' }); }
79
- catch { result.distillation = { completed: false, reason: 'bounded canonical distillation unavailable', ratifiedLessons: 0 }; }
80
- }
81
- result.exhausted = Date.now() >= deadline || result.fed + result.failed >= 8;
82
- if (allowed() && ownsQueueLock(initial, token)) {
83
- if (scanned) writeAtomic(cursorFile, scanned);
84
- const receipt = path.join(initial.queueDir, `.run-${Date.now()}-${process.pid}.json`);
85
- writeExclusive(receipt, JSON.stringify({ ...result, endedAt: new Date().toISOString() }));
86
- }
87
- } catch { result.failed++; }
88
- finally { if (!process.send) releaseQueueLock(initial, token); }
89
- if (process.argv.includes('--report')) console.log(`learn-flush: fed ${result.fed}; acknowledged ${result.acknowledged}; failed ${result.failed}; malformed ${result.malformed}; original queue is KEPT for retry/history`);
90
-
91
- // Keep the owned root alive until its supervisor terminates and confirms the whole tree.
92
- // A lost supervisor cannot keep this worker alive beyond its inherited deadline.
93
- if (process.send) {
94
- process.send({ type: 'learning-worker-complete' });
95
- await new Promise(resolve => setTimeout(resolve, Math.max(1, deadline - Date.now() + 600)));
96
- }