ruvnet-brain 4.5.16 → 4.6.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 +15 -19
- package/bin/developer-update.mjs +3 -0
- package/bin/install.mjs +15 -3
- package/bin/nightly-refresh.mjs +17 -1
- package/config/practical-rule-catalog.json +111 -0
- package/console/CONTRACT.md +47 -3
- package/console/app.js +112 -20
- package/package.json +5 -2
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/commands/brain-console.md +13 -1
- package/plugin/commands/configure.md +2 -2
- package/plugin/commands/rnb.md +13 -1
- package/plugin/commands/rnbc.md +13 -1
- package/plugin/commands/rvbc.md +13 -1
- package/plugin/commands/rvcb.md +13 -1
- package/plugin/hooks/codex-hooks.json +13 -1
- package/plugin/hooks/hook-contracts.json +20 -4
- package/plugin/scripts/adr-currency-gate.mjs +75 -31
- package/plugin/scripts/advocacy-outcomes.mjs +18 -4
- package/plugin/scripts/agentdb-recall.mjs +161 -26
- package/plugin/scripts/automatic-update.mjs +11 -2
- package/plugin/scripts/capability-inventory-receipt.mjs +16 -7
- package/plugin/scripts/capability-registry.mjs +55 -114
- package/plugin/scripts/capacity-aware-parallel-work.mjs +1 -1
- package/plugin/scripts/codex-hook-adapter.mjs +33 -6
- package/plugin/scripts/codex-hook-wrapper.mjs +23 -3
- package/plugin/scripts/continuation-gate.mjs +2 -1
- package/plugin/scripts/continuity-brief.mjs +55 -22
- package/plugin/scripts/continuity-events.mjs +93 -17
- package/plugin/scripts/continuity-hook-policy.mjs +1 -1
- package/plugin/scripts/continuity-journal.mjs +77 -29
- package/plugin/scripts/decision-gate.mjs +62 -37
- package/plugin/scripts/decision-outcomes.mjs +192 -199
- package/plugin/scripts/detach.mjs +53 -13
- package/plugin/scripts/developer-update-cleanup.mjs +38 -0
- package/plugin/scripts/developer-update-lock.mjs +30 -0
- package/plugin/scripts/developer-update-maintenance.mjs +178 -0
- package/plugin/scripts/developer-update-owner.mjs +24 -0
- package/plugin/scripts/developer-update-policy.mjs +48 -0
- package/plugin/scripts/developer-update.mjs +435 -0
- package/plugin/scripts/doc-currency.mjs +993 -0
- package/plugin/scripts/ground-ruvnet.sh +16 -3
- package/plugin/scripts/grounding-answer.mjs +18 -16
- package/plugin/scripts/grounding-stamp.sh +3 -0
- package/plugin/scripts/grounding-turn-evidence.mjs +60 -65
- package/plugin/scripts/grounding-turn-gate.mjs +34 -29
- package/plugin/scripts/grounding-turn-mark.mjs +101 -17
- package/plugin/scripts/hook-context-budget.mjs +90 -0
- package/plugin/scripts/hook-registry.mjs +129 -1
- package/plugin/scripts/hook-shim.mjs +20 -2
- package/plugin/scripts/host-update.mjs +6 -3
- package/plugin/scripts/lesson-gate.mjs +1 -1
- package/plugin/scripts/nightly-scheduler.mjs +55 -16
- package/plugin/scripts/plugin-artifact-proof.mjs +135 -0
- package/plugin/scripts/project-capture-queue.mjs +22 -15
- package/plugin/scripts/project-progression-producer.mjs +15 -8
- package/plugin/scripts/project-progression-reader.mjs +14 -4
- package/plugin/scripts/project-progression-session-start.mjs +29 -17
- package/plugin/scripts/project-progression-sources.mjs +24 -10
- package/plugin/scripts/project-progression-store.mjs +7 -2
- package/plugin/scripts/project-store-resolver.mjs +10 -7
- package/plugin/scripts/project-transition-hook.mjs +20 -12
- package/plugin/scripts/session-snapshot-hook.mjs +20 -9
- package/plugin/scripts/session-start-budget.mjs +18 -0
- package/plugin/scripts/session-start-core.mjs +32 -6
- package/plugin/scripts/session-start-trace.mjs +2 -1
- package/plugin/scripts/turn-outcome-capture.mjs +7 -5
- package/plugin/scripts/unprompted-runtime.mjs +36 -10
- package/plugin/scripts/user-settings.mjs +7 -1
- package/plugin/skills/ruvnet-brain/SKILL.md +2 -2
- package/scripts/codex-managed-terminal.mjs +1 -1
- package/scripts/console-runtime-identity.mjs +2 -0
- package/scripts/console-suite-update.mjs +117 -0
- package/scripts/doc-currency.mjs +6 -990
- package/scripts/hook-qualify-core.mjs +30 -1
- package/scripts/model-managed-prompt.mjs +1 -1
- package/scripts/model-managed-workflow-service.mjs +113 -14
- package/scripts/model-routing-controller.mjs +112 -21
- package/scripts/onboarding-console.mjs +41 -12
- package/scripts/practical-rule-selector.mjs +51 -0
- package/scripts/product-integrity-contract.mjs +11 -4
- package/scripts/release-qualification-contract.mjs +14 -0
- package/scripts/remedy-registry.mjs +1 -0
- package/scripts/source-scope-receipt.mjs +9 -4
- package/scripts/stack-sync.mjs +19 -132
|
@@ -1,71 +1,27 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Decision refusal observations, not repair-success proof. An allowed PreToolUse retry is an
|
|
3
|
+
* admission; no PostToolUse completion is observed here. Historical corrected records remain
|
|
4
|
+
* untouched and are labeled as legacy admissions in the report. Fresh foreign-session debts are
|
|
5
|
+
* retained. Only explicit session closure is abandonment; old observations may expire by age.
|
|
3
6
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* the bridged lessons — names exactly that failure: a stamp proves the ritual ran, not that its
|
|
9
|
-
* answer was obeyed.
|
|
10
|
-
*
|
|
11
|
-
* A system that cannot see its own outcomes cannot improve, and one that reports a number it cannot
|
|
12
|
-
* source is doing the fabrication this repo has a CI gate against.
|
|
13
|
-
*
|
|
14
|
-
* ─────────────────────────────────────────────────────────────────────────────────────────────────
|
|
15
|
-
* WHAT IS ACTUALLY OBSERVABLE, and this is the honest part.
|
|
16
|
-
*
|
|
17
|
-
* "Did the model obey an advisory?" is NOT observable from a hook. The advisory reaches the model's
|
|
18
|
-
* context and what it does next is unconstrained prose. Claiming to measure that would be the
|
|
19
|
-
* inflated-score failure mode.
|
|
20
|
-
*
|
|
21
|
-
* What IS observable, exactly, from the one gate that sees every Write/Edit/Bash: **what happened
|
|
22
|
-
* after a REFUSAL**. Three outcomes, mutually exclusive and jointly exhaustive:
|
|
23
|
-
*
|
|
24
|
-
* corrected the same target was attempted again and was ALLOWED — the reason landed, the user
|
|
25
|
-
* or model fixed the thing and proceeded. This is the outcome the gate exists to cause.
|
|
26
|
-
* repeated the same target was attempted again and refused again — the reason did NOT land.
|
|
27
|
-
* A guard that produces this is teaching nothing and is on its way to being switched off.
|
|
28
|
-
* abandoned never attempted again in this session. Ambiguous ON PURPOSE and never counted as a
|
|
29
|
-
* win: it is equally "they understood and stopped" and "they gave up and worked around
|
|
30
|
-
* us", and this ledger must not pick the flattering reading.
|
|
31
|
-
*
|
|
32
|
-
* THE ONE WAY TO FABRICATE THIS, named so a reviewer can check for it: record only `corrected`. The
|
|
33
|
-
* rate is corrected ÷ (corrected + repeated + abandoned), so a caller that forgets the other two
|
|
34
|
-
* reports a perfect score. The invariant is therefore NOT "record outcomes" but **every refusal
|
|
35
|
-
* produces exactly one record**, and `abandoned` is what an unresolved refusal becomes at session
|
|
36
|
-
* end. Same invariant, same reasoning, as advocacy-outcomes.mjs — one pattern, not two.
|
|
37
|
-
*
|
|
38
|
-
* SCOPE, stated rather than implied: this measures the BLOCKING path only. Advisory lessons are
|
|
39
|
-
* counted as `surfaced` for coverage and are NEVER scored, because their effect is not observable
|
|
40
|
-
* here. A coverage number that hides what it cannot see is the same lie as a truncated list.
|
|
41
|
-
*
|
|
42
|
-
* STORAGE: ~/.config/ruvnet-brain/, user-level, deliberately OUTSIDE ~/.cache/ruvnet-brain/ which
|
|
43
|
-
* `--update` replaces wholesale — an outcome destroyed by the next release never compounds.
|
|
44
|
-
* Append-only JSONL, bounded, node builtins only, and every write is best-effort: a ledger that
|
|
45
|
-
* could break a tool call would be worse than no ledger.
|
|
7
|
+
* The existing JSON/JSONL paths remain intact. Owned exclusive locks serialize pending snapshots
|
|
8
|
+
* and ledger appends; atomic/fsynced publication follows the repository's existing write protocol.
|
|
9
|
+
* Malformed, linked, busy or full state is unavailable, never replaced, truncated or written through.
|
|
10
|
+
* Logging remains best-effort and cannot alter the gate's verdict.
|
|
46
11
|
*/
|
|
47
12
|
import fs from 'node:fs';
|
|
48
13
|
import os from 'node:os';
|
|
49
14
|
import path from 'node:path';
|
|
15
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
50
16
|
|
|
51
|
-
export const CONFIG_ROOT = process.env.RUVNET_CONFIG_ROOT
|
|
52
|
-
|
|
53
|
-
export const
|
|
54
|
-
|
|
55
|
-
/** Pending refusals awaiting an outcome. Separate from the ledger so a resolve is one small write. */
|
|
56
|
-
export const PENDING = process.env.RUVNET_DECISION_PENDING
|
|
57
|
-
|| path.join(CONFIG_ROOT, 'decision-pending.json');
|
|
58
|
-
|
|
59
|
-
const MAX_LEDGER_BYTES = 1 << 20; // ~1MB, then the oldest half is dropped
|
|
17
|
+
export const CONFIG_ROOT = process.env.RUVNET_CONFIG_ROOT || path.join(os.homedir(), '.config', 'ruvnet-brain');
|
|
18
|
+
export const LEDGER = process.env.RUVNET_DECISION_LEDGER || path.join(CONFIG_ROOT, 'decision-outcomes.jsonl');
|
|
19
|
+
export const PENDING = process.env.RUVNET_DECISION_PENDING || path.join(CONFIG_ROOT, 'decision-pending.json');
|
|
20
|
+
const MAX_LEDGER_BYTES = 1 << 20;
|
|
60
21
|
const MAX_PENDING = 200;
|
|
22
|
+
const LOCK_WAIT_MS = 1000;
|
|
23
|
+
const TERMINAL = new Set(['admitted-retry', 'corrected', 'repeated', 'abandoned', 'expired']);
|
|
61
24
|
|
|
62
|
-
/**
|
|
63
|
-
* The identity of "the same action being retried".
|
|
64
|
-
*
|
|
65
|
-
* Deliberately COARSE: tool + target, never the full input. A model that fixes a refusal usually
|
|
66
|
-
* changes the content while keeping the target, and keying on content would score every correction
|
|
67
|
-
* as a brand-new action and make `repeated` unreachable — a metric that can only produce good news.
|
|
68
|
-
*/
|
|
69
25
|
export function actionKey(toolName, toolInput) {
|
|
70
26
|
const t = String(toolName || '').toLowerCase();
|
|
71
27
|
const i = toolInput || {};
|
|
@@ -76,156 +32,193 @@ export function actionKey(toolName, toolInput) {
|
|
|
76
32
|
return `${t}:${String(i.file_path || i.path || '').trim()}`;
|
|
77
33
|
}
|
|
78
34
|
|
|
79
|
-
const readJson = (file, fallback) => {
|
|
80
|
-
try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return fallback; }
|
|
81
|
-
};
|
|
82
|
-
const writeJson = (file, value) => {
|
|
83
|
-
try {
|
|
84
|
-
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
85
|
-
fs.writeFileSync(file, JSON.stringify(value));
|
|
86
|
-
return true;
|
|
87
|
-
} catch { return false; }
|
|
88
|
-
};
|
|
89
35
|
|
|
90
|
-
|
|
91
|
-
export function append(record, file = LEDGER) {
|
|
36
|
+
function regular(file) {
|
|
92
37
|
try {
|
|
93
|
-
fs.
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
38
|
+
const st = fs.lstatSync(file);
|
|
39
|
+
if (!st.isFile() || st.isSymbolicLink() || st.nlink > 1
|
|
40
|
+
|| (typeof process.getuid === 'function' && st.uid !== process.getuid())) throw Error('unmanaged decision state');
|
|
41
|
+
if (st.size > MAX_LEDGER_BYTES) throw Error('decision state capacity exhausted');
|
|
42
|
+
return st;
|
|
43
|
+
} catch (error) { if (error.code === 'ENOENT') return null; throw error; }
|
|
44
|
+
}
|
|
45
|
+
function readPending(file) {
|
|
46
|
+
if (!regular(file)) return {};
|
|
47
|
+
const value = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
48
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) throw Error('pending state unavailable');
|
|
49
|
+
return value;
|
|
50
|
+
}
|
|
51
|
+
function readLedger(file) {
|
|
52
|
+
if (!regular(file)) return [];
|
|
53
|
+
return fs.readFileSync(file, 'utf8').split('\n').filter(Boolean).map(line => {
|
|
54
|
+
const row = JSON.parse(line);
|
|
55
|
+
if (row?.kind === 'refused' || TERMINAL.has(row?.kind)) {
|
|
56
|
+
if (!validDebt(row) || (row.host != null && typeof row.host !== 'string')
|
|
57
|
+
|| (row.projectId != null && !/^[a-f0-9]{64}$/.test(row.projectId))) throw Error('malformed decision observation');
|
|
58
|
+
if (row.kind === 'admitted-retry' && (typeof row.refusalId !== 'string' || !row.refusalId
|
|
59
|
+
|| row.evidence !== 'pretool-decision-observation' || row.repairVerified !== false)) throw Error('malformed admitted observation');
|
|
60
|
+
}
|
|
61
|
+
return row;
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
/** Same owned wx/token protocol as the existing grounding and continuity handlers. */
|
|
65
|
+
function locked(files, work, fallback, { deadlineAt = Infinity, nonBlocking = true } = {}) {
|
|
66
|
+
const paths = [...new Set(files)].sort(), held = [], token = randomUUID();
|
|
67
|
+
const owns = () => held.every(({ file }) => {
|
|
68
|
+
try { return !fs.lstatSync(file).isSymbolicLink() && fs.readFileSync(file, 'utf8') === token; } catch { return false; }
|
|
69
|
+
});
|
|
70
|
+
try {
|
|
71
|
+
if (!(Number.isFinite(deadlineAt) || deadlineAt === Infinity) || Date.now() >= deadlineAt) return fallback;
|
|
72
|
+
const deadline = Math.min(Date.now() + LOCK_WAIT_MS, deadlineAt);
|
|
73
|
+
for (const target of paths) {
|
|
74
|
+
if (Date.now() >= deadline) return fallback;
|
|
75
|
+
fs.mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 });
|
|
76
|
+
regular(target);
|
|
77
|
+
const file = target + '.lock';
|
|
78
|
+
for (;;) {
|
|
79
|
+
try {
|
|
80
|
+
const fd = fs.openSync(file, 'wx', 0o600); held.push({ file, fd }); fs.writeSync(fd, token); break;
|
|
81
|
+
} catch (error) {
|
|
82
|
+
if (error.code !== 'EEXIST' || nonBlocking || Date.now() >= deadline) return fallback;
|
|
83
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 5);
|
|
84
|
+
}
|
|
99
85
|
}
|
|
100
|
-
}
|
|
101
|
-
|
|
86
|
+
}
|
|
87
|
+
const permitted = () => Date.now() < deadline && owns();
|
|
88
|
+
if (!permitted()) return fallback;
|
|
89
|
+
return work(permitted);
|
|
90
|
+
} catch { return fallback; }
|
|
91
|
+
finally {
|
|
92
|
+
for (const { file, fd } of held.reverse()) {
|
|
93
|
+
try { fs.closeSync(fd); } catch {}
|
|
94
|
+
try { if (!fs.lstatSync(file).isSymbolicLink() && fs.readFileSync(file, 'utf8') === token) fs.unlinkSync(file); } catch {}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
function writePending(file, value, owns) {
|
|
99
|
+
const previous = regular(file), temp = `${file}.tmp-${randomUUID()}`;
|
|
100
|
+
const fd = fs.openSync(temp, 'wx', previous ? previous.mode & 0o777 : 0o600);
|
|
101
|
+
try {
|
|
102
|
+
const text = JSON.stringify(value);
|
|
103
|
+
if (fs.writeSync(fd, text) !== Buffer.byteLength(text)) throw Error('incomplete pending state write');
|
|
104
|
+
fs.fsyncSync(fd);
|
|
105
|
+
if (!owns()) throw Error('decision state lock changed');
|
|
106
|
+
fs.renameSync(temp, file);
|
|
107
|
+
} finally { fs.closeSync(fd); try { fs.unlinkSync(temp); } catch {} }
|
|
108
|
+
}
|
|
109
|
+
function appendLocked(record, file, owns) {
|
|
110
|
+
const st = regular(file), text = JSON.stringify(record) + '\n';
|
|
111
|
+
if ((st?.size || 0) + Buffer.byteLength(text) > MAX_LEDGER_BYTES || !owns()) throw Error('decision ledger unavailable');
|
|
112
|
+
const fd = fs.openSync(file, fs.constants.O_APPEND | fs.constants.O_CREAT | fs.constants.O_WRONLY | (fs.constants.O_NOFOLLOW || 0), 0o600);
|
|
113
|
+
try {
|
|
114
|
+
if (fs.writeSync(fd, text) !== Buffer.byteLength(text)) throw Error('incomplete decision ledger write');
|
|
115
|
+
fs.fsyncSync(fd);
|
|
116
|
+
} finally { fs.closeSync(fd); }
|
|
117
|
+
}
|
|
118
|
+
export function append(record, file = LEDGER, budget = {}) {
|
|
119
|
+
return locked([file], owns => { appendLocked(record, file, owns); return true; }, false, budget);
|
|
120
|
+
}
|
|
121
|
+
const stateFiles = files => ({ pending: files.pending || PENDING, ledger: files.ledger || LEDGER });
|
|
122
|
+
const debtId = debt => debt.refusalId || createHash('sha256').update(JSON.stringify([debt.session, debt.key, debt.ts, debt.host || null, debt.projectId || null])).digest('hex');
|
|
123
|
+
const validDebt = value => value && typeof value.session === 'string' && typeof value.key === 'string'
|
|
124
|
+
&& value.session.length > 0 && value.key.length > 0 && Number.isFinite(value.ts) && Array.isArray(value.policies)
|
|
125
|
+
&& value.policies.every(policy => typeof policy === 'string');
|
|
126
|
+
function scopeOf(host, project) {
|
|
127
|
+
if (host !== undefined && host !== null && typeof host !== 'string') throw Error('host scope unavailable');
|
|
128
|
+
const projectId = project ? createHash('sha256').update(fs.realpathSync(project)).digest('hex') : null;
|
|
129
|
+
return { host: host || null, projectId };
|
|
130
|
+
}
|
|
131
|
+
const sameScope = (left, right) => (left.host || null) === (right.host || null) && (left.projectId || null) === (right.projectId || null);
|
|
132
|
+
const pendingId = debt => `${debt.session}\u0000${debt.key}${debt.host || debt.projectId ? `\u0000${debt.host || ''}\u0000${debt.projectId || ''}` : ''}`;
|
|
133
|
+
const terminals = rows => new Set(rows.filter(row => TERMINAL.has(row?.kind) && validDebt(row)).map(debtId));
|
|
134
|
+
function debts(pending, rows) {
|
|
135
|
+
const out = new Map(), closed = terminals(rows);
|
|
136
|
+
for (const value of Object.values(pending)) if (validDebt(value) && !closed.has(debtId(value))) out.set(debtId(value), value);
|
|
137
|
+
// Recover the crash window after a refused append was fsynced but before its pending rename.
|
|
138
|
+
for (const row of rows) if (row?.kind === 'refused' && validDebt(row) && !closed.has(debtId(row))) out.set(debtId(row), row);
|
|
139
|
+
return [...out.values()];
|
|
140
|
+
}
|
|
141
|
+
function transaction(files, callback, fallback, budget = {}) {
|
|
142
|
+
const f = stateFiles(files);
|
|
143
|
+
return locked([f.pending, f.ledger], owns => callback(f, readPending(f.pending), readLedger(f.ledger), owns), fallback, budget);
|
|
144
|
+
}
|
|
145
|
+
export function recordRefusal({ session, key, policies, ts, host, project, deadlineAt, nonBlocking }, files = {}) {
|
|
146
|
+
if (!validDebt({ session, key, policies, ts })) return false;
|
|
147
|
+
return transaction(files, (f, pending, rows, owns) => {
|
|
148
|
+
const scope = scopeOf(host, project), id = pendingId({ session, key, ...scope });
|
|
149
|
+
if (pending[id] && !validDebt(pending[id])) return false;
|
|
150
|
+
const open = debts(pending, rows);
|
|
151
|
+
const prior = open.find(value => value.session === session && value.key === key && sameScope(value, scope));
|
|
152
|
+
if (open.length >= MAX_PENDING && !prior) return false; // defer; never abandon another owner to make room
|
|
153
|
+
if (prior) appendLocked({ ...prior, kind: 'repeated', ts, refusalId: debtId(prior) }, f.ledger, owns);
|
|
154
|
+
const debt = { session, key, policies, ts, ...scope, refusalId: randomUUID() };
|
|
155
|
+
appendLocked({ ...debt, kind: 'refused' }, f.ledger, owns);
|
|
156
|
+
pending[id] = debt; writePending(f.pending, pending, owns);
|
|
102
157
|
return true;
|
|
103
|
-
}
|
|
158
|
+
}, false, { deadlineAt, nonBlocking });
|
|
104
159
|
}
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
append({ kind: 'refused', session, key, policies, ts }, ledger);
|
|
114
|
-
const pending = readJson(pendingFile, {});
|
|
115
|
-
pending[`${session}\u0000${key}`] = { session, key, policies, ts };
|
|
116
|
-
// Bound it: on overflow the OLDEST debts are resolved as `abandoned` rather than silently dropped,
|
|
117
|
-
// because a dropped debt is a miss that never appears in the denominator.
|
|
118
|
-
const entries = Object.entries(pending);
|
|
119
|
-
if (entries.length > MAX_PENDING) {
|
|
120
|
-
const sorted = entries.sort((a, b) => (a[1].ts || 0) - (b[1].ts || 0));
|
|
121
|
-
for (const [k, v] of sorted.slice(0, entries.length - MAX_PENDING)) {
|
|
122
|
-
append({ kind: 'abandoned', session: v.session, key: v.key, policies: v.policies, ts }, ledger);
|
|
123
|
-
delete pending[k];
|
|
160
|
+
export function resolve({ session, key, allowed, ts, host, project, deadlineAt, nonBlocking }, files = {}) {
|
|
161
|
+
if (typeof allowed !== 'boolean' || typeof session !== 'string' || typeof key !== 'string' || !Number.isFinite(ts)) return null;
|
|
162
|
+
return transaction(files, (f, pending, rows, owns) => {
|
|
163
|
+
const scope = scopeOf(host, project), id = pendingId({ session, key, ...scope });
|
|
164
|
+
const debt = debts(pending, rows).find(value => value.session === session && value.key === key && sameScope(value, scope));
|
|
165
|
+
if (!debt) {
|
|
166
|
+
if (validDebt(pending[id]) && terminals(rows).has(debtId(pending[id]))) { delete pending[id]; writePending(f.pending, pending, owns); }
|
|
167
|
+
return null;
|
|
124
168
|
}
|
|
125
|
-
|
|
126
|
-
|
|
169
|
+
const kind = allowed === true ? 'admitted-retry' : 'repeated';
|
|
170
|
+
appendLocked({ kind, session, key, ...scope, policies: debt.policies, ts, refusalId: debtId(debt), afterMs: ts - debt.ts,
|
|
171
|
+
evidence: 'pretool-decision-observation', repairVerified: false }, f.ledger, owns);
|
|
172
|
+
if (validDebt(pending[id])) delete pending[id];
|
|
173
|
+
writePending(f.pending, pending, owns);
|
|
174
|
+
return kind;
|
|
175
|
+
}, null, { deadlineAt, nonBlocking });
|
|
127
176
|
}
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
if (!debt) return null;
|
|
140
|
-
const kind = allowed ? 'corrected' : 'repeated';
|
|
141
|
-
append({ kind, session, key, policies: debt.policies, ts, afterMs: ts - (debt.ts || ts) }, ledger);
|
|
142
|
-
delete pending[id];
|
|
143
|
-
writeJson(pendingFile, pending);
|
|
144
|
-
return kind;
|
|
177
|
+
function closeDebts(files, ts, predicate, kind, budget = {}) {
|
|
178
|
+
return transaction(files, (f, pending, rows, owns) => {
|
|
179
|
+
const selected = debts(pending, rows).filter(predicate);
|
|
180
|
+
for (const debt of selected) {
|
|
181
|
+
appendLocked({ kind, session: debt.session, key: debt.key, host: debt.host || null, projectId: debt.projectId || null,
|
|
182
|
+
policies: debt.policies, ts, refusalId: debtId(debt) }, f.ledger, owns);
|
|
183
|
+
const id = pendingId(debt); if (validDebt(pending[id])) delete pending[id];
|
|
184
|
+
}
|
|
185
|
+
if (selected.length) writePending(f.pending, pending, owns);
|
|
186
|
+
return selected.length;
|
|
187
|
+
}, 0, budget);
|
|
145
188
|
}
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
* WITHOUT THIS THE METRIC IS A LIE BY OMISSION. A refusal the model simply walked away from is the
|
|
151
|
-
* single most likely outcome — and an unresolved debt sits in `open`, outside the denominator, so
|
|
152
|
-
* `correctedRate` would be computed only over the actions someone bothered to retry. That is the
|
|
153
|
-
* "record only the wins" fabrication this file's header names, arriving through the back door.
|
|
154
|
-
*
|
|
155
|
-
* Swept from the gate itself rather than a SessionEnd hook: SessionEnd is not guaranteed to fire (a
|
|
156
|
-
* crash, a kill, a compact all skip it), and this repo has already paid for a queue that only drained
|
|
157
|
-
* on a graceful exit — ADR-027's heartbeat flush, where 1,884 events accumulated over days. Activity
|
|
158
|
-
* is the trigger, not politeness.
|
|
159
|
-
*/
|
|
160
|
-
export function sweepStale({ session, ts, maxAgeMs = 6 * 60 * 60_000 }, files = {}) {
|
|
161
|
-
const pendingFile = files.pending || PENDING;
|
|
162
|
-
const ledger = files.ledger || LEDGER;
|
|
163
|
-
const pending = readJson(pendingFile, {});
|
|
164
|
-
let closed = 0;
|
|
165
|
-
for (const [k, v] of Object.entries(pending)) {
|
|
166
|
-
// A different session is over as far as this process can tell; the current session's debts stay
|
|
167
|
-
// open until they resolve or age out, because a retry three calls later is still a real outcome.
|
|
168
|
-
const stale = v.session !== session || (ts - (v.ts || ts)) > maxAgeMs;
|
|
169
|
-
if (!stale) continue;
|
|
170
|
-
append({ kind: 'abandoned', session: v.session, key: v.key, policies: v.policies, ts }, ledger);
|
|
171
|
-
delete pending[k];
|
|
172
|
-
closed += 1;
|
|
173
|
-
}
|
|
174
|
-
if (closed) writeJson(pendingFile, pending);
|
|
175
|
-
return closed;
|
|
189
|
+
/** A differing session is not evidence of abandonment; only proven observation age expires debt. */
|
|
190
|
+
export function sweepStale({ session, ts, maxAgeMs = 6 * 60 * 60_000, deadlineAt, nonBlocking }, files = {}) {
|
|
191
|
+
if (!Number.isFinite(maxAgeMs) || maxAgeMs <= 0 || !Number.isFinite(ts)) return 0;
|
|
192
|
+
return closeDebts(files, ts, debt => ts - debt.ts > maxAgeMs, 'expired', { deadlineAt, nonBlocking });
|
|
176
193
|
}
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
const pending = readJson(pendingFile, {});
|
|
183
|
-
let closed = 0;
|
|
184
|
-
for (const [k, v] of Object.entries(pending)) {
|
|
185
|
-
if (v.session !== session) continue;
|
|
186
|
-
append({ kind: 'abandoned', session, key: v.key, policies: v.policies, ts }, ledger);
|
|
187
|
-
delete pending[k];
|
|
188
|
-
closed += 1;
|
|
189
|
-
}
|
|
190
|
-
if (closed) writeJson(pendingFile, pending);
|
|
191
|
-
return closed;
|
|
194
|
+
/** Explicit session closure only affects that named session; not an automatic lifecycle delivery claim. */
|
|
195
|
+
export function abandonSession(session, ts, files = {}, scopeInput = {}) {
|
|
196
|
+
if (!Number.isFinite(ts)) return 0;
|
|
197
|
+
let scope; try { scope = scopeOf(scopeInput.host, scopeInput.project); } catch { return 0; }
|
|
198
|
+
return closeDebts(files, ts, debt => debt.session === session && sameScope(debt, scope), 'abandoned', scopeInput);
|
|
192
199
|
}
|
|
193
|
-
|
|
194
|
-
/**
|
|
195
|
-
* The report. Every refusal must appear in exactly one terminal bucket, and `open` is stated
|
|
196
|
-
* separately so a half-finished session cannot inflate the rate by shrinking the denominator.
|
|
197
|
-
*/
|
|
198
200
|
export function report(files = {}) {
|
|
199
|
-
const
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
for (const p of r.policies || []) {
|
|
215
|
-
byPolicy[p] = byPolicy[p] || { corrected: 0, repeated: 0, abandoned: 0 };
|
|
216
|
-
byPolicy[p][r.kind] += 1;
|
|
201
|
+
const unavailable = { available: false, metricBasis: 'pretool-admission-only', admittedRetryRate: null,
|
|
202
|
+
correctedRate: null, repairSuccessRate: null, reason: 'decision measurement busy, full or unreadable' };
|
|
203
|
+
return transaction(files, (f, pending, rows) => {
|
|
204
|
+
const count = kind => rows.filter(row => row?.kind === kind).length;
|
|
205
|
+
const legacyCorrectedRecords = count('corrected');
|
|
206
|
+
const admittedRetries = count('admitted-retry') + legacyCorrectedRecords;
|
|
207
|
+
const repeated = count('repeated'), abandoned = count('abandoned'), expired = count('expired');
|
|
208
|
+
const resolved = admittedRetries + repeated + abandoned + expired, open = debts(pending, rows).length;
|
|
209
|
+
const byPolicy = Object.create(null);
|
|
210
|
+
for (const row of rows) {
|
|
211
|
+
if (!TERMINAL.has(row?.kind)) continue;
|
|
212
|
+
const field = ['corrected', 'admitted-retry'].includes(row.kind) ? 'admittedRetries' : row.kind;
|
|
213
|
+
for (const policy of Array.isArray(row.policies) ? row.policies : []) {
|
|
214
|
+
byPolicy[policy] ||= { admittedRetries: 0, repeated: 0, abandoned: 0, expired: 0 }; byPolicy[policy][field]++;
|
|
215
|
+
}
|
|
217
216
|
}
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
abandoned,
|
|
226
|
-
// NULL, not 0, when nothing has resolved. A rate printed as 0% on an empty ledger is a claim the
|
|
227
|
-
// guards are failing; the truth is that nothing has been measured yet.
|
|
228
|
-
correctedRate: resolved ? +(corrected / resolved).toFixed(3) : null,
|
|
229
|
-
byPolicy,
|
|
230
|
-
};
|
|
217
|
+
const atCapacity = open >= MAX_PENDING || (regular(f.ledger)?.size || 0) >= MAX_LEDGER_BYTES;
|
|
218
|
+
return { available: !atCapacity, completeCoverage: false, metricBasis: 'pretool-admission-only', refused: count('refused'), resolved, open,
|
|
219
|
+
admittedRetries, legacyCorrectedRecords, repeated, abandoned, expired, byPolicy, atCapacity,
|
|
220
|
+
foreignPendingRecords: Object.values(pending).filter(value => !validDebt(value)).length,
|
|
221
|
+
admittedRetryRate: !atCapacity && resolved ? +(admittedRetries / resolved).toFixed(3) : null,
|
|
222
|
+
correctedRate: null, repairSuccessRate: null };
|
|
223
|
+
}, unavailable);
|
|
231
224
|
}
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
import fs from 'node:fs';
|
|
42
42
|
import os from 'node:os';
|
|
43
43
|
import path from 'node:path';
|
|
44
|
-
import { spawn } from 'node:child_process';
|
|
44
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
45
45
|
import { fileURLToPath } from 'node:url';
|
|
46
46
|
|
|
47
47
|
const SELF = fileURLToPath(import.meta.url);
|
|
@@ -73,7 +73,8 @@ function receipt(row) {
|
|
|
73
73
|
const p = receiptPath();
|
|
74
74
|
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
75
75
|
fs.appendFileSync(p, `${JSON.stringify({ ts: new Date().toISOString(), ...row })}\n`);
|
|
76
|
-
|
|
76
|
+
return true;
|
|
77
|
+
} catch { return false; }
|
|
77
78
|
}
|
|
78
79
|
|
|
79
80
|
/** Open the job's log, or /dev/null. Never throws — an unwritable log is not a reason to skip work. */
|
|
@@ -149,6 +150,8 @@ if (!SUPERVISOR) {
|
|
|
149
150
|
// ── SUPERVISOR HALF (already outside the session's process group). Runs the real job, holds the
|
|
150
151
|
// clock, and is the only thing that can end it early.
|
|
151
152
|
const out = openLog();
|
|
153
|
+
// Keep an unresolved retirement requirement if any later receipt cannot be written.
|
|
154
|
+
if (!receipt({ state: 'launching', supervisorPid: process.pid, ttlSec, cmd, retirementRequired: true })) process.exit(0);
|
|
152
155
|
const job = spawn(cmd[0], cmd.slice(1), {
|
|
153
156
|
detached: true, // its own group again, so the TTL kill reaches ITS children too (npm, git, node)
|
|
154
157
|
stdio: ['ignore', out, out],
|
|
@@ -157,26 +160,63 @@ const job = spawn(cmd[0], cmd.slice(1), {
|
|
|
157
160
|
});
|
|
158
161
|
|
|
159
162
|
let killedAtTtl = false;
|
|
160
|
-
|
|
163
|
+
let finished = false;
|
|
164
|
+
let grace;
|
|
165
|
+
const probe = (group = process.platform !== 'win32') => {
|
|
166
|
+
if (!job.pid) return { state: 'unknown', error: 'missing-job-pid' };
|
|
167
|
+
try { process.kill(group ? -job.pid : job.pid, 0); return { state: 'alive' }; }
|
|
168
|
+
catch (error) { return error.code === 'ESRCH' ? { state: 'gone' } : { state: 'unknown', error: error.code || 'probe-failed' }; }
|
|
169
|
+
};
|
|
170
|
+
const finish = (state, detail = {}) => {
|
|
171
|
+
if (finished) return;
|
|
172
|
+
finished = true; clearTimeout(deadline); clearTimeout(grace);
|
|
173
|
+
// Group absence cannot certify descendants that escaped into another group. Keep that fence.
|
|
174
|
+
receipt({ state, pid: job.pid ?? null, supervisorPid: process.pid, ttlSec, cmd,
|
|
175
|
+
retirementRequired: true, retirementConfirmed: false, retirementState: 'UNKNOWN',
|
|
176
|
+
scope: process.platform === 'win32' ? 'windows-taskkill-tree-attempt' : 'posix-process-group', ...detail });
|
|
177
|
+
process.exit(0);
|
|
178
|
+
};
|
|
179
|
+
receipt({ state: 'started', pid: job.pid ?? null, supervisorPid: process.pid, ttlSec, cmd, retirementRequired: true });
|
|
161
180
|
|
|
162
181
|
const deadline = setTimeout(() => {
|
|
163
182
|
killedAtTtl = true;
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
183
|
+
if (process.platform === 'win32') {
|
|
184
|
+
if (job.exitCode !== null || job.signalCode !== null) return finish('retirement-unknown', { reason: 'parent-exited; descendant identity unavailable', rootObservation: probe(false) });
|
|
185
|
+
const killed = spawnSync('taskkill', ['/PID', String(job.pid), '/T', '/F'], {
|
|
186
|
+
timeout: 1000, windowsHide: true, encoding: 'utf8', maxBuffer: 16 * 1024 });
|
|
187
|
+
return finish('retirement-unknown', { reason: 'complete Windows descendant retirement not observed',
|
|
188
|
+
taskkill: { status: killed.status, error: killed.error?.code || null }, rootObservation: probe(false) });
|
|
189
|
+
}
|
|
190
|
+
const errors = [];
|
|
191
|
+
const signal = name => {
|
|
192
|
+
try { process.kill(-job.pid, name); }
|
|
193
|
+
catch (error) { if (error.code !== 'ESRCH') errors.push({ signal: name, error: error.code || 'kill-failed' }); }
|
|
194
|
+
};
|
|
195
|
+
signal('SIGTERM');
|
|
196
|
+
grace = setTimeout(() => {
|
|
197
|
+
if (probe().state !== 'gone') signal('SIGKILL');
|
|
198
|
+
// Ref'd timers retain supervision after the direct parent exits; observe native reaping too.
|
|
199
|
+
grace = setTimeout(() => {
|
|
200
|
+
const groupObservation = probe();
|
|
201
|
+
finish(groupObservation.state === 'gone' && !errors.length ? 'group-retired-at-ttl' : 'retirement-unknown',
|
|
202
|
+
{ groupObservation, groupRetirementConfirmed: groupObservation.state === 'gone', killErrors: errors,
|
|
203
|
+
reason: 'escaped or unobserved descendants are not certified' });
|
|
204
|
+
}, 100);
|
|
205
|
+
}, GRACE_MS);
|
|
170
206
|
}, ttlSec * 1000);
|
|
171
207
|
|
|
172
208
|
job.on('error', (e) => {
|
|
173
209
|
clearTimeout(deadline);
|
|
174
|
-
receipt({ state: 'spawn-failed', pid: null, ttlSec, cmd, detail: e.message });
|
|
210
|
+
receipt({ state: 'spawn-failed', pid: null, ttlSec, cmd, detail: e.message, retirementRequired: false, launched: false });
|
|
175
211
|
process.exit(0);
|
|
176
212
|
});
|
|
177
213
|
job.on('exit', (code, signal) => {
|
|
178
|
-
clearTimeout(deadline);
|
|
179
214
|
if (killedAtTtl) return; // the TTL path writes its own, more specific receipt
|
|
180
|
-
receipt({ state: 'exited', pid: job.pid ?? null, code, signal, cmd });
|
|
181
|
-
process.exit(
|
|
215
|
+
receipt({ state: 'parent-exited', pid: job.pid ?? null, code, signal, cmd, retirementRequired: true, retirementConfirmed: false });
|
|
216
|
+
if (process.platform === 'win32') return finish('retirement-unknown', { reason: 'parent exit does not establish descendant retirement', rootObservation: probe(false) });
|
|
217
|
+
const groupObservation = probe();
|
|
218
|
+
if (groupObservation.state === 'gone') finish('group-retired', { code, signal, groupObservation, groupRetirementConfirmed: true,
|
|
219
|
+
reason: 'escaped or unobserved descendants are not certified' });
|
|
220
|
+
else if (groupObservation.state === 'unknown') finish('retirement-unknown', { code, signal, groupObservation });
|
|
221
|
+
// A surviving owned group stays supervised until the original TTL.
|
|
182
222
|
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// Only closed, attributed npx installer entries qualify. Unknown/project/app/runtime trees never do.
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
const INSTALLERS = new Set(['ruvnet-brain', 'ruflo']);
|
|
5
|
+
export function cleanupNpxDuplicates({ home, globalRoot, enabled = false, run, active = null }) {
|
|
6
|
+
const root = path.join(home, '.npm/_npx'), result = { enabled, removed: [], retained: [] };
|
|
7
|
+
if (!fs.existsSync(root)) return result;
|
|
8
|
+
if (fs.realpathSync(root) !== path.resolve(root)) { result.retained.push({ directory: root, reason: 'cache root alias is unclassified' }); return result; }
|
|
9
|
+
for (const name of fs.readdirSync(root)) {
|
|
10
|
+
const directory = path.join(root, name);
|
|
11
|
+
if (!/^[a-f0-9]{16,64}$/.test(name) || !fs.lstatSync(directory).isDirectory() || fs.lstatSync(directory).isSymbolicLink()) continue;
|
|
12
|
+
if (fs.readdirSync(directory).some(entry => !['package.json', 'package-lock.json', 'node_modules'].includes(entry))) { result.retained.push({ directory, reason: 'unknown cache content' }); continue; }
|
|
13
|
+
let manifest;
|
|
14
|
+
try { manifest = JSON.parse(fs.readFileSync(path.join(directory, 'package.json'), 'utf8')); } catch { result.retained.push({ directory, reason: 'unknown installer identity' }); continue; }
|
|
15
|
+
const dependencies = Object.keys(manifest.dependencies || {});
|
|
16
|
+
if (!dependencies.length || dependencies.some(pkg => !INSTALLERS.has(pkg) || !fs.existsSync(path.join(globalRoot, pkg, 'package.json')))) continue;
|
|
17
|
+
if (!enabled) { result.retained.push({ directory, reason: 'cleanup disabled' }); continue; }
|
|
18
|
+
let busy = true;
|
|
19
|
+
try {
|
|
20
|
+
if (active) busy = active(directory);
|
|
21
|
+
else {
|
|
22
|
+
const processes = run('ps', ['-axo', 'command='], { timeout: 15_000 });
|
|
23
|
+
if (processes.includes(directory)) busy = true;
|
|
24
|
+
else {
|
|
25
|
+
// lsof exit 1 means no files; any other failure is unknown and retains the tree.
|
|
26
|
+
const opened = run('lsof', ['-nP', '+D', directory], { timeout: 15_000, allowed: [0, 1] });
|
|
27
|
+
busy = opened.trim().length > 0;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
} catch { busy = true; }
|
|
31
|
+
if (busy) { result.retained.push({ directory, reason: 'live or unknown process/open-file owner' }); continue; }
|
|
32
|
+
// Recheck exact directory type and manifest bytes at the removal boundary.
|
|
33
|
+
const second = JSON.parse(fs.readFileSync(path.join(directory, 'package.json'), 'utf8'));
|
|
34
|
+
if (JSON.stringify(second) !== JSON.stringify(manifest) || fs.lstatSync(directory).isSymbolicLink()) throw Error('npx cleanup identity changed');
|
|
35
|
+
fs.rmSync(directory, { recursive: true }); result.removed.push({ directory, installers: dependencies });
|
|
36
|
+
}
|
|
37
|
+
return result;
|
|
38
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// Directory ownership protocol shared with agentic-kit. Inherited children do not release the parent.
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import crypto from 'node:crypto';
|
|
6
|
+
const brainRoot = brainHome => brainHome || process.env.RUVNET_BRAIN_HOME || path.join(os.homedir(), '.cache/ruvnet-brain');
|
|
7
|
+
export function sharedLockStatus({ brainHome, alive = pid => { try { process.kill(pid, 0); return true; } catch (e) { return e.code === 'EPERM'; } } } = {}) {
|
|
8
|
+
const directory = path.join(brainRoot(brainHome), 'developer-update.lock');
|
|
9
|
+
try {
|
|
10
|
+
if (!fs.lstatSync(directory).isDirectory() || fs.lstatSync(directory).isSymbolicLink()) throw Error('not an owned directory');
|
|
11
|
+
const owner = JSON.parse(fs.readFileSync(path.join(directory, 'owner.json'), 'utf8'));
|
|
12
|
+
if (!Number.isInteger(owner.pid) || owner.pid < 1 || typeof owner.token !== 'string' || owner.token.length < 16) throw Error('invalid owner');
|
|
13
|
+
return { state: alive(owner.pid) ? 'running' : 'stale', directory, owner };
|
|
14
|
+
} catch (error) { return { state: error.code === 'ENOENT' && !fs.existsSync(directory) ? 'idle' : 'unknown', directory, error: error.message }; }
|
|
15
|
+
}
|
|
16
|
+
export function acquireDeveloperLock({ brainHome, token = process.env.RUVNET_DEVELOPER_UPDATE_TOKEN, pid = process.pid, alive } = {}) {
|
|
17
|
+
const status = sharedLockStatus({ brainHome, alive });
|
|
18
|
+
if (token && status.state === 'running' && status.owner.token === token) return { token, ownerPid: status.owner.pid, inherited: true, release() {} };
|
|
19
|
+
if (status.state !== 'idle') throw Error(`developer update lock ${status.state}; ${status.directory}`);
|
|
20
|
+
fs.mkdirSync(path.dirname(status.directory), { recursive: true });
|
|
21
|
+
fs.mkdirSync(status.directory, { mode: 0o700 }); // atomic claim; racing owners get EEXIST
|
|
22
|
+
const owner = { pid, token: crypto.randomUUID(), startedAt: new Date().toISOString() };
|
|
23
|
+
try { fs.writeFileSync(path.join(status.directory, 'owner.json'), JSON.stringify(owner), { flag: 'wx', mode: 0o600 }); }
|
|
24
|
+
catch (error) { fs.rmdirSync(status.directory); throw error; }
|
|
25
|
+
return { token: owner.token, ownerPid: pid, inherited: false, release() {
|
|
26
|
+
const current = sharedLockStatus({ brainHome, alive });
|
|
27
|
+
if (current.owner?.token !== owner.token || current.owner?.pid !== pid) throw Error('developer update lock ownership changed');
|
|
28
|
+
fs.unlinkSync(path.join(status.directory, 'owner.json')); fs.rmdirSync(status.directory);
|
|
29
|
+
} };
|
|
30
|
+
}
|