ruvnet-brain 4.7.20 → 4.7.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/bin/install.mjs +494 -97
- package/kb/download-retry.mjs +93 -21
- package/kb/forge-guard-injection.mjs +277 -0
- package/kb/forge-update.mjs +166 -38
- package/kb/lifecycle-evidence-retention.mjs +151 -14
- package/kb/model-requirements.mjs +4 -1
- package/kb/refresh-run.mjs +175 -27
- package/kb/verify-citation.mjs +15 -2
- package/package.json +3 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/mcp/managed-cli-interface.mjs +16 -8
- package/plugin/scripts/adr-currency-gate.mjs +2 -1
- package/plugin/scripts/advisory-contract.mjs +21 -0
- package/plugin/scripts/advocacy-route.mjs +5 -4
- package/plugin/scripts/agentdb-recall.mjs +15 -5
- package/plugin/scripts/automatic-update.mjs +9 -1
- package/plugin/scripts/brain-confirmation.mjs +2 -1
- package/plugin/scripts/brain-footprint.mjs +72 -11
- package/plugin/scripts/capability-registry.mjs +1 -1
- package/plugin/scripts/capacity-aware-parallel-work.mjs +2 -1
- package/plugin/scripts/codex-overnight-notice.mjs +33 -0
- package/plugin/scripts/completion-claim-evidence.mjs +56 -12
- package/plugin/scripts/continuation-gate.mjs +28 -9
- package/plugin/scripts/decision-gate.mjs +13 -63
- package/plugin/scripts/decision-outcomes.mjs +36 -0
- package/plugin/scripts/decision-recall-gate.mjs +40 -5
- package/plugin/scripts/decision-record.mjs +8 -3
- package/plugin/scripts/degradation-watch.mjs +2 -1
- package/plugin/scripts/developer-update-lock.mjs +172 -33
- package/plugin/scripts/developer-update-owner.mjs +8 -5
- package/plugin/scripts/developer-update-policy.mjs +4 -1
- package/plugin/scripts/developer-update.mjs +155 -53
- package/plugin/scripts/doc-currency.mjs +2 -16
- package/plugin/scripts/duplicate-gate.mjs +2 -1
- package/plugin/scripts/entrypoint.mjs +34 -0
- package/plugin/scripts/footprint-io.mjs +135 -8
- package/plugin/scripts/grok-hook-adapter.mjs +2 -1
- package/plugin/scripts/ground-ruvnet.sh +6 -3
- package/plugin/scripts/grounding-answer.mjs +2 -1
- package/plugin/scripts/grounding-code-projection.mjs +2 -1
- package/plugin/scripts/grounding-substance.mjs +2 -1
- package/plugin/scripts/grounding-turn-gate.mjs +2 -2
- package/plugin/scripts/grounding-turn-mark.mjs +2 -2
- package/plugin/scripts/hook-context-budget.mjs +2 -1
- package/plugin/scripts/hook-input.mjs +2 -2
- package/plugin/scripts/hook-shim.mjs +9 -2
- package/plugin/scripts/host-update.mjs +29 -5
- package/plugin/scripts/identifier-preflight.mjs +2 -1
- package/plugin/scripts/kb-copy-proof.mjs +57 -9
- package/plugin/scripts/learn-capture.mjs +2 -1
- package/plugin/scripts/learning-store.mjs +62 -16
- package/plugin/scripts/lesson-bridge.mjs +2 -2
- package/plugin/scripts/lesson-promote.mjs +83 -22
- package/plugin/scripts/md-stamp.mjs +2 -1
- package/plugin/scripts/native-turn-transcript.mjs +98 -6
- package/plugin/scripts/nightly-scheduler.mjs +10 -5
- package/plugin/scripts/package-recommender.mjs +2 -2
- package/plugin/scripts/project-store-resolver.mjs +19 -0
- package/plugin/scripts/routing-outcome-capture.mjs +2 -1
- package/plugin/scripts/session-snapshot-hook.mjs +24 -4
- package/plugin/scripts/session-start-core.mjs +5 -15
- package/plugin/scripts/session-start-health.mjs +97 -10
- package/plugin/scripts/session-start-issue-alert.mjs +63 -0
- package/plugin/scripts/session-start-update-plane.mjs +15 -1
- package/plugin/scripts/signal-watch.mjs +2 -1
- package/plugin/scripts/spend-guard.mjs +2 -1
- package/plugin/scripts/substitution-wall.mjs +2 -1
- package/plugin/scripts/turn-outcome-capture.mjs +7 -3
- package/plugin/scripts/unprompted-runtime.mjs +20 -3
- package/plugin/scripts/update-apply.mjs +186 -15
- package/plugin/skills/release-proof/scripts/release-proof.mjs +2 -1
- package/plugin/skills/ruvnet-brain/PLAYBOOK.md +2 -2
- package/plugin/skills/ruvnet-brain/SKILL.md +3 -2
- package/scripts/adr-072-completion.mjs +2 -1
- package/scripts/agentdb-context.mjs +2 -1
- package/scripts/agentdb-fleet-doctor.mjs +30 -8
- package/scripts/approved-runtime.mjs +2 -1
- package/scripts/architecture-review-lock.mjs +2 -1
- package/scripts/ascii-drift.mjs +2 -1
- package/scripts/brain-latency-50.mjs +2 -1
- package/scripts/brain-novice-50.mjs +2 -16
- package/scripts/brain-score.mjs +13 -2
- package/scripts/build-bundle.mjs +2 -1
- package/scripts/build-concepts.mjs +2 -1
- package/scripts/candidate-ci-receipt.mjs +2 -1
- package/scripts/candidate-host-evidence.mjs +2 -1
- package/scripts/card-from-source.mjs +2 -1
- package/scripts/ci/warm-brain-models.mjs +2 -1
- package/scripts/claims-verify.mjs +7 -3
- package/scripts/claude-controlled-terminal.mjs +2 -1
- package/scripts/claude-terminal-mod.mjs +2 -1
- package/scripts/code-release-corpus.mjs +2 -1
- package/scripts/codex-hook-trust-reconcile.mjs +67 -1
- package/scripts/codex-hook-trust.mjs +40 -1
- package/scripts/codex-hook-wrapper-history.mjs +2 -1
- package/scripts/codex-host-execution-proof.mjs +2 -1
- package/scripts/completion-claim-replay.mjs +2 -1
- package/scripts/console-engine.mjs +8 -3
- package/scripts/corpus-canary.mjs +2 -1
- package/scripts/corpus-candidate.mjs +2 -2
- package/scripts/corpus-coverage-sidecar.mjs +2 -1
- package/scripts/corpus-dispatch-decision.mjs +2 -1
- package/scripts/corpus-dispatch-receipt.mjs +2 -1
- package/scripts/corpus-next-seed.mjs +2 -1
- package/scripts/corpus-qa.mjs +2 -1
- package/scripts/corpus-reconcile.mjs +2 -2
- package/scripts/corpus-watchdog.mjs +2 -1
- package/scripts/count-chunks.mjs +2 -1
- package/scripts/customer-state-matrix.mjs +2 -1
- package/scripts/derive-passage-content-map.mjs +2 -1
- package/scripts/development-push-check.mjs +2 -1
- package/scripts/dispatch-receipt.mjs +2 -1
- package/scripts/doc-currency.mjs +2 -1
- package/scripts/dream-issue-gate.mjs +2 -1
- package/scripts/dual-host-deliberation.mjs +2 -16
- package/scripts/dual-host-suggest.mjs +2 -16
- package/scripts/eval-brain.mjs +7 -3
- package/scripts/execution-policy.mjs +2 -1
- package/scripts/execution-preflight.mjs +2 -1
- package/scripts/falsify.mjs +2 -1
- package/scripts/fix-metaharness-memretrieve.mjs +2 -1
- package/scripts/fix-workstream.mjs +2 -1
- package/scripts/full-hints.mjs +2 -1
- package/scripts/full-suite-gate.mjs +2 -1
- package/scripts/grok-host.mjs +68 -0
- package/scripts/hook-qualify-core.mjs +3 -1
- package/scripts/hook-qualify.mjs +2 -1
- package/scripts/hook-retirement-check.mjs +2 -1
- package/scripts/host-registry.mjs +2 -1
- package/scripts/independent-review-receipt.mjs +2 -1
- package/scripts/ingest-new-repos.mjs +2 -1
- package/scripts/ingest-repo.mjs +12 -2
- package/scripts/integration-evidence.mjs +2 -1
- package/scripts/issue-fix.mjs +7 -24
- package/scripts/issue-watch.mjs +116 -40
- package/scripts/job-heartbeat.sh +54 -19
- package/scripts/key-canary.mjs +9 -3
- package/scripts/latency-to-surface.mjs +2 -1
- package/scripts/learning-replay-cli.mjs +97 -19
- package/scripts/learning-replay-execution.mjs +2 -0
- package/scripts/learning-replay-fixture.mjs +3 -0
- package/scripts/learning-replay-proof.mjs +14 -13
- package/scripts/learning-replay.mjs +3 -3
- package/scripts/learnings.mjs +2 -1
- package/scripts/lesson-ratify.mjs +88 -40
- package/scripts/loop-checkpoint.mjs +2 -1
- package/scripts/metaharness-receipts.mjs +2 -1
- package/scripts/model-catalog.mjs +2 -1
- package/scripts/model-currency-evidence.mjs +6 -4
- package/scripts/model-currency.mjs +33 -3
- package/scripts/model-native-catalog.mjs +2 -1
- package/scripts/model-router-agent-hook.mjs +2 -1
- package/scripts/model-router-dispatch.mjs +2 -1
- package/scripts/model-router-engine.mjs +2 -1
- package/scripts/model-router-outcome.mjs +2 -1
- package/scripts/model-router-setup.mjs +2 -1
- package/scripts/model-routing-eval.mjs +2 -1
- package/scripts/model-routing-gateway.mjs +2 -1
- package/scripts/model-routing-launchers.mjs +4 -1
- package/scripts/model-terminal-gateway.mjs +2 -1
- package/scripts/model-terminal-launchers.mjs +2 -1
- package/scripts/model-weekly-analyst.mjs +2 -1
- package/scripts/model-weekly-cycle.mjs +2 -1
- package/scripts/model-weekly-qualification.mjs +2 -1
- package/scripts/nightly-gists.sh +26 -20
- package/scripts/nightly-two-run-proof.mjs +2 -1
- package/scripts/nightly-watchdog.mjs +158 -47
- package/scripts/no-silent-substitution.mjs +2 -1
- package/scripts/notify.sh +11 -6
- package/scripts/ntfy-inbox.mjs +41 -18
- package/scripts/onboarding-console.mjs +47 -8
- package/scripts/oracle/abstain-threshold-sweep.mjs +2 -1
- package/scripts/oracle/abstain-trace.mjs +2 -1
- package/scripts/oracle/doc2query-generate.mjs +2 -1
- package/scripts/oracle/doc2query-reach.mjs +2 -1
- package/scripts/oracle/judge-train.mjs +2 -1
- package/scripts/oracle/need-set-split.mjs +2 -1
- package/scripts/oracle/produce-questions.mjs +2 -16
- package/scripts/oracle/repo-recall.mjs +2 -2
- package/scripts/oracle/retrieval-accuracy.mjs +2 -2
- package/scripts/oracle/sona-query-adapter-eval.mjs +2 -1
- package/scripts/oracle/source-tree.mjs +2 -15
- package/scripts/oracle/source-units.mjs +2 -16
- package/scripts/oracle/spike-run.mjs +2 -16
- package/scripts/oracle/validate-labels.mjs +2 -16
- package/scripts/org-repo-count.mjs +2 -1
- package/scripts/package-cards.mjs +2 -1
- package/scripts/prepublication-evidence.mjs +2 -1
- package/scripts/private-overlay.mjs +2 -1
- package/scripts/product-integrity-contract.mjs +2 -1
- package/scripts/protected-release-invocation.mjs +2 -1
- package/scripts/public-verification-abandon.mjs +2 -1
- package/scripts/public-verification-aggregate.mjs +2 -1
- package/scripts/public-verification-finalizer.mjs +2 -1
- package/scripts/public-verification-inputs.mjs +2 -1
- package/scripts/public-verification-lane.mjs +2 -1
- package/scripts/publication-receipt.mjs +2 -1
- package/scripts/published-surface-probe.mjs +2 -1
- package/scripts/qe/card-lane-gate.mjs +2 -16
- package/scripts/qe/session-start-gate.mjs +2 -16
- package/scripts/qe/ux-suite.mjs +2 -1
- package/scripts/qualified-candidate-check.mjs +2 -1
- package/scripts/rebuild-gists-from-receipts.mjs +2 -1
- package/scripts/recommendation-eval.mjs +2 -1
- package/scripts/recommendation-floor.mjs +2 -1
- package/scripts/recommendation-judge-score.mjs +2 -1
- package/scripts/recommendation-real-host-score.mjs +2 -1
- package/scripts/recommendation-real-host.mjs +2 -1
- package/scripts/refresh-capability-only-store.mjs +2 -1
- package/scripts/refresh-model-catalog.mjs +2 -1
- package/scripts/rehearse-corpus-pipeline.mjs +2 -1
- package/scripts/release-authority.mjs +2 -1
- package/scripts/release-evidence-aggregate.mjs +2 -1
- package/scripts/release-payload.mjs +2 -1
- package/scripts/release-projection.mjs +2 -1
- package/scripts/release-proof.mjs +2 -1
- package/scripts/release-qualification-contract.mjs +64 -28
- package/scripts/release-qualification.mjs +8 -1
- package/scripts/release-vector.mjs +2 -1
- package/scripts/restore-local-ingests.mjs +2 -1
- package/scripts/retrieval-canary.mjs +2 -1
- package/scripts/route-cheap.mjs +2 -1
- package/scripts/route-gold-rank.mjs +2 -1
- package/scripts/route-index-memory.mjs +2 -1
- package/scripts/route-latency-warm.mjs +2 -1
- package/scripts/router-utilization.mjs +2 -1
- package/scripts/routing-flywheel.mjs +2 -1
- package/scripts/run-operational-benchmark.mjs +2 -1
- package/scripts/run-operational-benchmark.v3.mjs +2 -1
- package/scripts/rvf-index-audit.mjs +2 -16
- package/scripts/seal-gist-receipt.mjs +2 -1
- package/scripts/security-guidance-codex-compat.mjs +2 -1
- package/scripts/selfcheck.mjs +2 -1
- package/scripts/signal-watch.mjs +2 -1
- package/scripts/source-coverage.mjs +2 -1
- package/scripts/stabilization-receipt.mjs +2 -1
- package/scripts/stamp-sweep.mjs +2 -1
- package/scripts/status-honesty.mjs +2 -1
- package/scripts/sync-commands.mjs +2 -2
- package/scripts/top100-benchmark.mjs +2 -1
- package/scripts/top100-corpus.mjs +2 -1
- package/scripts/user-model-hook-install.mjs +2 -1
- package/scripts/user-model-prompt-hook.mjs +2 -1
- package/scripts/verify-bundle.mjs +3 -1
- package/scripts/verify-model-catalog.mjs +2 -1
- package/scripts/version.mjs +2 -1
- package/scripts/wired-check.mjs +11 -5
- package/scripts/worktree-integrity.mjs +2 -2
|
@@ -118,6 +118,42 @@ function appendLocked(record, file, owns) {
|
|
|
118
118
|
export function append(record, file = LEDGER, budget = {}) {
|
|
119
119
|
return locked([file], owns => { appendLocked(record, file, owns); return true; }, false, budget);
|
|
120
120
|
}
|
|
121
|
+
/**
|
|
122
|
+
* ── A BUDGET THAT CAN BE EXCEEDED SILENTLY FAILS OPEN WITHOUT SAYING SO (decision-gate.mjs) ──────
|
|
123
|
+
*
|
|
124
|
+
* The gate's old loop did `break` when the budget ran out: every remaining policy AND the speech
|
|
125
|
+
* chokepoint were skipped with no record anywhere, and nothing distinguished "five policies agreed"
|
|
126
|
+
* from "we ran out of time and stopped asking" — silence standing in for a measurement.
|
|
127
|
+
*
|
|
128
|
+
* TWO CHANNELS, because each fails differently:
|
|
129
|
+
* · this ledger (survives `--update`), ALWAYS, so a trip COMPOUNDS into evidence. `budget-exceeded`
|
|
130
|
+
* and `policy-skipped` are inert in report()'s buckets, so they cannot move the obedience rate.
|
|
131
|
+
* · one stderr line, ONLY when the action was refused or under RUVNET_STOP_NUDGES=full (quiet by
|
|
132
|
+
* default since 2026-10-15): on an ALLOW a stderr line only makes the host show a hook "error" for
|
|
133
|
+
* a decision that was made. tests/unit/write-gate-policy.test.mjs pins both modes.
|
|
134
|
+
*/
|
|
135
|
+
const STOP_NUDGES_FULL = () => String(process.env.RUVNET_STOP_NUDGES || '').toLowerCase() === 'full';
|
|
136
|
+
export function reportBudget({ event, session, unconsulted, trace, started, budgetMs, refused = false }) {
|
|
137
|
+
const elapsed = Date.now() - started;
|
|
138
|
+
if (process.env.RUVNET_DECISION_TRACE === '1') {
|
|
139
|
+
process.stderr.write(`[decision-gate] ${event} ${elapsed}ms budget=${budgetMs}ms ${JSON.stringify(trace)}\n`);
|
|
140
|
+
}
|
|
141
|
+
if (!unconsulted.length) return;
|
|
142
|
+
try {
|
|
143
|
+
append({ kind: 'budget-exceeded', event, session, unconsulted, elapsedMs: elapsed, budgetMs, ts: Date.now() });
|
|
144
|
+
} catch { /* a ledger must never break a tool call */ }
|
|
145
|
+
if (refused || STOP_NUDGES_FULL()) process.stderr.write(
|
|
146
|
+
`[decision-gate] ${budgetMs}ms budget exhausted after ${elapsed}ms — ${refused ? 'REFUSED; UNCONSULTED' : 'ALLOWED WITHOUT CONSULTING'}: `
|
|
147
|
+
+ `${unconsulted.join(', ')}. These policies did not vote; ${refused ? 'another policy refused the action.' : 'this allow is a timeout, not a verdict.'}\n`,
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
/** Record each policy that skipped itself (exit 3) with its own one-line reason; print it only under full nudges. */
|
|
151
|
+
export function reportSelfSkipped({ event, session, selfSkipped }) {
|
|
152
|
+
for (const r of selfSkipped) {
|
|
153
|
+
try { append({ kind: 'policy-skipped', event, session, policy: r.id, reason: r.reason, ts: Date.now() }); } catch { /* a ledger must never break a tool call */ }
|
|
154
|
+
if (STOP_NUDGES_FULL()) process.stderr.write(`[decision-gate] ${r.reason || `${r.id} skipped`} — ${r.id} did not vote; it supplied no authorization verdict.\n`);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
121
157
|
const stateFiles = files => ({ pending: files.pending || PENDING, ledger: files.ledger || LEDGER });
|
|
122
158
|
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
159
|
const validDebt = value => value && typeof value.session === 'string' && typeof value.key === 'string'
|
|
@@ -16,7 +16,8 @@
|
|
|
16
16
|
* AT MOST ONE REFUSAL PER PATH PER SESSION (the duplicate-gate shape): the retry is allowed and
|
|
17
17
|
* recorded. Only in an adopted project (canonical `.swarm/memory.db` exists). Opt-outs win
|
|
18
18
|
* (ADR-105 §7): RUVNET_DECISION_RECALL_GATE=off, RUVNET_AGENTDB_FIRST=off, the brain-off sentinel.
|
|
19
|
-
* Fails open on its own errors
|
|
19
|
+
* Fails open on its own errors, except a Git identity timeout, which exits 3 (skipped, with the reason) so the
|
|
20
|
+
* gate's ledger records that this ADR decision was not checked (D8, 4.7.21).
|
|
20
21
|
*/
|
|
21
22
|
import fs from 'node:fs';
|
|
22
23
|
import os from 'node:os';
|
|
@@ -25,9 +26,13 @@ import crypto from 'node:crypto';
|
|
|
25
26
|
import { fileURLToPath } from 'node:url';
|
|
26
27
|
import { resolveProjectStore } from './project-store-resolver.mjs';
|
|
27
28
|
import { isAdrPath, freshConsultReceipt, brainHomeOf } from './decision-record.mjs';
|
|
29
|
+
import { isDirectInvocation } from './entrypoint.mjs';
|
|
28
30
|
|
|
29
31
|
const ALLOW = 0;
|
|
30
32
|
const REFUSE = 2;
|
|
33
|
+
/** decision-gate.mjs's code for "this policy could not decide, and says why on stderr". */
|
|
34
|
+
const SKIPPED_SELF = 3;
|
|
35
|
+
const GIT_TIMEOUT_MS = 400;
|
|
31
36
|
const STATUS_LINE = /^\s*(?:status:\s*\S.*|\*\*Status\*\*\s*:.*)$/im;
|
|
32
37
|
/** The document's status line, ignoring fenced code (a YAML example is not the ADR's status). */
|
|
33
38
|
const STATUS_LINES = new RegExp(STATUS_LINE.source, 'gim');
|
|
@@ -87,6 +92,29 @@ function brainOff(env) {
|
|
|
87
92
|
const ackFile = (brainHome) => path.join(brainHome, 'decision-recall-acks.json');
|
|
88
93
|
function readAcks(file) { try { const o = JSON.parse(fs.readFileSync(file, 'utf8')); return o && typeof o === 'object' ? o : {}; } catch { return {}; } }
|
|
89
94
|
|
|
95
|
+
/**
|
|
96
|
+
* Publish the ledger the way decision-record.mjs's own sibling (writeConsultReceipt) already does:
|
|
97
|
+
* temp file in the same directory, fsync, then rename. fs.writeFileSync on the real path truncates
|
|
98
|
+
* before it writes — a process killed between those two steps leaves an empty ledger, discarding
|
|
99
|
+
* every acknowledgment ever recorded, not just the one being added. rename() is atomic on POSIX, so
|
|
100
|
+
* a reader (or a crash) only ever sees the old complete ledger or the new complete one.
|
|
101
|
+
*/
|
|
102
|
+
function writeAcksAtomic(file, acks) {
|
|
103
|
+
const tmp = `${file}.tmp-${process.pid}-${Date.now()}`;
|
|
104
|
+
let fd;
|
|
105
|
+
try {
|
|
106
|
+
fd = fs.openSync(tmp, 'wx', 0o600);
|
|
107
|
+
fs.writeSync(fd, JSON.stringify(acks));
|
|
108
|
+
try { fs.fsyncSync(fd); } catch { /* filesystem without fsync (some network mounts) — proceed */ }
|
|
109
|
+
fs.closeSync(fd);
|
|
110
|
+
fd = null;
|
|
111
|
+
fs.renameSync(tmp, file);
|
|
112
|
+
} finally {
|
|
113
|
+
if (fd !== null) { try { fs.closeSync(fd); } catch { /* nothing to close */ } }
|
|
114
|
+
try { if (fs.existsSync(tmp)) fs.unlinkSync(tmp); } catch { /* leave the temp rather than throw over it */ }
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
90
118
|
/** The verdict. Returns { code, stderr?, stdout? }. */
|
|
91
119
|
export function evaluate(payload, { env = process.env, now = Date.now() } = {}) {
|
|
92
120
|
if (['off'].includes(String(env.RUVNET_DECISION_RECALL_GATE || '').toLowerCase())
|
|
@@ -94,7 +122,14 @@ export function evaluate(payload, { env = process.env, now = Date.now() } = {})
|
|
|
94
122
|
const decision = adrDecisionIn(payload);
|
|
95
123
|
if (!decision) return { code: ALLOW };
|
|
96
124
|
let resolution;
|
|
97
|
-
try { resolution = resolveProjectStore({ projectDir: payload.cwd || process.cwd(), gitTimeoutMs:
|
|
125
|
+
try { resolution = resolveProjectStore({ projectDir: payload.cwd || process.cwd(), gitTimeoutMs: GIT_TIMEOUT_MS }); } catch (error) {
|
|
126
|
+
// D8 (4.7.21): a Git timeout is not a verdict. Exit SKIPPED_SELF with the reason, so decision-gate records a
|
|
127
|
+
// `policy-skipped` ledger row for this ADR decision instead of the silent allow it used to be.
|
|
128
|
+
if (/timed out/i.test(String(error?.message))) {
|
|
129
|
+
return { code: SKIPPED_SELF, stderr: `decision-recall: Git project identity timed out after ${GIT_TIMEOUT_MS}ms; the ${decision.kind} ${decision.path} was not checked for an AgentDB consult.` };
|
|
130
|
+
}
|
|
131
|
+
return { code: ALLOW };
|
|
132
|
+
}
|
|
98
133
|
try { if (!fs.statSync(resolution.canonicalAgentDbPath).isFile()) return { code: ALLOW }; } catch { return { code: ALLOW }; }
|
|
99
134
|
const brainHome = brainHomeOf(env);
|
|
100
135
|
const receipt = freshConsultReceipt({ projectRoot: resolution.projectRoot, brainHome, now });
|
|
@@ -108,13 +143,13 @@ export function evaluate(payload, { env = process.env, now = Date.now() } = {})
|
|
|
108
143
|
const acks = readAcks(file);
|
|
109
144
|
if (acks[key]) {
|
|
110
145
|
acks[key].retriedAt = new Date(now).toISOString();
|
|
111
|
-
try {
|
|
146
|
+
try { writeAcksAtomic(file, acks); } catch { /* the ledger never blocks a write */ }
|
|
112
147
|
return { code: ALLOW, stdout: `This ${decision.kind} was written without an AgentDB consult (retry after one refusal). Record it afterwards with decision-record.mjs --record.` };
|
|
113
148
|
}
|
|
114
149
|
// Keep the ledger bounded: drop acknowledgements older than a week.
|
|
115
150
|
for (const [k, v] of Object.entries(acks)) if (now - Date.parse(v?.at) > 7 * 86_400_000) delete acks[k];
|
|
116
151
|
acks[key] = { at: new Date(now).toISOString(), kind: decision.kind };
|
|
117
|
-
try { fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
152
|
+
try { fs.mkdirSync(path.dirname(file), { recursive: true }); writeAcksAtomic(file, acks); } catch { return { code: ALLOW }; }
|
|
118
153
|
const script = path.join(path.dirname(fileURLToPath(import.meta.url)), 'decision-record.mjs');
|
|
119
154
|
return { code: REFUSE, stderr: [
|
|
120
155
|
`⛔ BLOCKED (once) — ${decision.kind} ${decision.path}: run decision-record.mjs --consult "<topic>" first (AgentDB decisions unread).`,
|
|
@@ -127,7 +162,7 @@ export function evaluate(payload, { env = process.env, now = Date.now() } = {})
|
|
|
127
162
|
}
|
|
128
163
|
|
|
129
164
|
function isMain() {
|
|
130
|
-
try { return
|
|
165
|
+
try { return isDirectInvocation(import.meta.url); } catch { return false; }
|
|
131
166
|
}
|
|
132
167
|
if (isMain()) {
|
|
133
168
|
let verdict = { code: ALLOW };
|
|
@@ -26,11 +26,12 @@ import { fileURLToPath } from 'node:url';
|
|
|
26
26
|
import { redactText } from './continuity-events.mjs';
|
|
27
27
|
import { maskExcludedPaths } from './turn-capture-privacy.mjs';
|
|
28
28
|
import { resolveTurnDb, readSettledTranscript } from './turn-outcome-capture.mjs';
|
|
29
|
-
import { resolveProjectStore } from './project-store-resolver.mjs';
|
|
29
|
+
import { resolveProjectStore, storeEncryptedAtRest, ENCRYPTED_STORE_REASON } from './project-store-resolver.mjs';
|
|
30
30
|
import { resolveRuflo, rufloInvocation } from './ruflo-bin.mjs';
|
|
31
31
|
import { rufloRunDir } from './ruflo-bin.mjs';
|
|
32
32
|
import { currentTurnRecords } from './completion-claim-evidence.mjs';
|
|
33
33
|
import { recordHookFailure } from './hook-failures.mjs';
|
|
34
|
+
import { isDirectInvocation } from './entrypoint.mjs';
|
|
34
35
|
|
|
35
36
|
export const DECISION_SCHEMA = 'ruvnet-brain.decision-record';
|
|
36
37
|
export const STATUSES = Object.freeze(['ratified', 'detected-unconfirmed']);
|
|
@@ -160,6 +161,10 @@ export function storeDecision({ ruflo, db, namespace, key, value, store = defaul
|
|
|
160
161
|
const prior = readBack({ ruflo, db, namespace, key });
|
|
161
162
|
if (typeof prior.content === 'string' && prior.content === value) return { state: 'already-stored', key };
|
|
162
163
|
if (typeof prior.content === 'string' && prior.content && prior.content !== value) return { state: 'quarantined', key, reason: 'a different stored row holds this key' };
|
|
164
|
+
if (storeEncryptedAtRest(db)) { // #439: the native-only store below is refused on every attempt; say why instead
|
|
165
|
+
recordHookFailure({ hook: 'decision-record', event: 'record', error: `not recorded: ${ENCRYPTED_STORE_REASON}`.slice(0, 160) });
|
|
166
|
+
return { state: 'refused', key, reason: ENCRYPTED_STORE_REASON };
|
|
167
|
+
}
|
|
163
168
|
const result = store({ ruflo, db, namespace, key, value });
|
|
164
169
|
const back = readBack({ ruflo, db, namespace, key });
|
|
165
170
|
if (result.status === 0 && back.content === value) return { state: 'committed', key };
|
|
@@ -300,7 +305,7 @@ export async function main(argv = process.argv.slice(2), { env = process.env, ou
|
|
|
300
305
|
out.write(`decision-record: supersedes ${key} — ${String(row.what).slice(0, 120)}\n`);
|
|
301
306
|
}
|
|
302
307
|
const r = storeDecision({ ruflo, db: target.db, namespace, key: built.key, value: built.value, ...(store ? { store } : {}), ...(readBack ? { readBack } : {}) });
|
|
303
|
-
out.write(`decision-record: ${r.state} ${r.key} (namespace ${namespace})${r.reason ? ` — ${r.reason}` : ''}${r.state === 'refused' ? ` — NOT recorded (logged to hook-failures). A store that committed but did not read back may still hold it: run \`ruflo memory retrieve -k ${r.key} -n ${namespace} --path ${target.db}\` before re-running --record` : ''}\n`);
|
|
308
|
+
out.write(`decision-record: ${r.state} ${r.key} (namespace ${namespace})${r.reason ? ` — ${r.reason}` : ''}${r.state === 'refused' && r.reason === ENCRYPTED_STORE_REASON ? ' — NOT recorded (logged to hook-failures). Keep this project\'s store unencrypted to record decisions here' : ''}${r.state === 'refused' && r.reason !== ENCRYPTED_STORE_REASON ? ` — NOT recorded (logged to hook-failures). A store that committed but did not read back may still hold it: run \`ruflo memory retrieve -k ${r.key} -n ${namespace} --path ${target.db}\` before re-running --record` : ''}\n`);
|
|
304
309
|
if (!['committed', 'already-stored'].includes(r.state)) return 3;
|
|
305
310
|
let ok = true;
|
|
306
311
|
for (const oldKey of built.record.supersedes) {
|
|
@@ -319,6 +324,6 @@ export async function main(argv = process.argv.slice(2), { env = process.env, ou
|
|
|
319
324
|
}
|
|
320
325
|
|
|
321
326
|
function isMain() {
|
|
322
|
-
try { return
|
|
327
|
+
try { return isDirectInvocation(import.meta.url); } catch { return false; }
|
|
323
328
|
}
|
|
324
329
|
if (isMain()) main().then((code) => { process.exitCode = code; }, (e) => { process.stderr.write(`decision-record: ${e.message}\n`); process.exitCode = 1; });
|
|
@@ -53,6 +53,7 @@ import path from 'node:path';
|
|
|
53
53
|
// reading the code and believing it.
|
|
54
54
|
import { fileURLToPath } from 'node:url';
|
|
55
55
|
import { rufloRunDir } from './ruflo-bin.mjs';
|
|
56
|
+
import { isDirectInvocation } from './entrypoint.mjs';
|
|
56
57
|
|
|
57
58
|
/**
|
|
58
59
|
* THE PROBE TARGET IS A SCRATCH DB, NOT THE PROJECT'S.
|
|
@@ -264,7 +265,7 @@ export function cachedDegradations(opts = {}) {
|
|
|
264
265
|
* mechanism; the invariant this repo already paid for is that ONE process refuses a given tool call.
|
|
265
266
|
*/
|
|
266
267
|
const isMain = (() => {
|
|
267
|
-
try { return
|
|
268
|
+
try { return isDirectInvocation(import.meta.url); }
|
|
268
269
|
catch { return false; }
|
|
269
270
|
})();
|
|
270
271
|
|
|
@@ -3,41 +3,180 @@ import fs from 'node:fs';
|
|
|
3
3
|
import os from 'node:os';
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import crypto from 'node:crypto';
|
|
6
|
+
import { spawnSync } from 'node:child_process';
|
|
6
7
|
const brainRoot = brainHome => brainHome || process.env.RUVNET_BRAIN_HOME || path.join(os.homedir(), '.cache/ruvnet-brain');
|
|
7
|
-
|
|
8
|
+
const pidAlive = pid => { try { process.kill(pid, 0); return true; } catch (e) { return e.code === 'EPERM'; } };
|
|
9
|
+
// A dead or reclaimable owner must have been gone this long; an unreadable owner is judged by the directory's age.
|
|
10
|
+
export const STALE_RECLAIM_MS = 10 * 60 * 1000;
|
|
11
|
+
// An owner-less lock younger than this is another process between creating the lock and writing its owner.
|
|
12
|
+
export const OWNERLESS_GRACE_MS = 5 * 1000;
|
|
13
|
+
/** The OS start time of `pid` (`ps -o lstart=`, as kb/refresh-run.mjs records it), or null when it cannot be read (Windows). */
|
|
14
|
+
export function processStartOf(pid, { platform = process.platform } = {}) {
|
|
15
|
+
if (platform === 'win32' || !Number.isSafeInteger(pid) || pid < 1) return null;
|
|
16
|
+
try {
|
|
17
|
+
const r = spawnSync('ps', ['-p', String(pid), '-o', 'lstart='], { encoding: 'utf8', timeout: 5000, env: { ...process.env, TZ: 'UTC', LC_ALL: 'C', LANG: 'C' } });
|
|
18
|
+
const start = String(r.stdout || '').trim();
|
|
19
|
+
return r.status === 0 && start ? start : null;
|
|
20
|
+
} catch { return null; }
|
|
21
|
+
}
|
|
22
|
+
const removal = directory => process.platform === 'win32' ? `Remove-Item -Recurse -Force "${directory}"` : `rm -rf "${directory}"`;
|
|
23
|
+
// ── takeover is serialized (4.7.21 review, Codex #1) ─────────────────────────────────────────────
|
|
24
|
+
// Two contenders could judge the same abandoned lock: A moved it aside and acquired, then B moved A's FRESH lock
|
|
25
|
+
// aside on its stale judgement and both ran. A takeover now (1) creates an exclusive claim file inside the judged
|
|
26
|
+
// directory ('wx': one winner per directory instance), (2) re-checks, while holding the claim, that it is the same
|
|
27
|
+
// directory (dev/ino) with byte-identical owner.json, and only then (3) moves it aside. A claim left by a crashed
|
|
28
|
+
// reclaimer is superseded by the next numbered claim once CLAIM_STALE_MS old.
|
|
29
|
+
// Round 4 (Codex #1): a lock that existed before its owner record (mkdir, then write) could be judged owner-less and old,
|
|
30
|
+
// claimed, re-checked (still owner-less, so "the same") and moved aside together with the owner published in between:
|
|
31
|
+
// two owners. The lock is now built complete in a staging sibling and published by ONE rename, so it never exists
|
|
32
|
+
// without its owner; a rename onto an existing lock fails (ENOTEMPTY/EEXIST, EPERM/EACCES on Windows) and is re-judged.
|
|
33
|
+
export const CLAIM_STALE_MS = 5 * 60 * 1000;
|
|
34
|
+
const CLAIM_FILE = /^takeover(?:-(\d+))?\.json$/;
|
|
35
|
+
function observeDirectory(directory) {
|
|
36
|
+
const stat = fs.lstatSync(directory, { bigint: true });
|
|
37
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) throw Error('not an owned directory');
|
|
38
|
+
let raw = null;
|
|
39
|
+
try { raw = fs.readFileSync(path.join(directory, 'owner.json'), 'utf8'); } catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
40
|
+
return { identity: `${stat.dev}:${stat.ino}`, raw };
|
|
41
|
+
}
|
|
42
|
+
const readRaw = file => { try { return fs.readFileSync(file, 'utf8'); } catch { return null; } };
|
|
43
|
+
// A claim file must not restart the age an owner-less lock is judged by (Opus r2 #6): writing or removing one bumps the
|
|
44
|
+
// directory's mtime, so the directory's own times are put back.
|
|
45
|
+
function keepDirectoryTimes(directory, change) {
|
|
46
|
+
let before = null;
|
|
47
|
+
try { before = fs.lstatSync(directory); } catch { /* judged below */ }
|
|
48
|
+
try { return change(); } finally { if (before) try { fs.lutimesSync(directory, before.atime, before.mtime); } catch { /* gone */ } }
|
|
49
|
+
}
|
|
50
|
+
const GONE = Symbol('judged lock vanished');
|
|
51
|
+
function claimTakeover(directory) {
|
|
52
|
+
let highest = -1, highestName = null, names;
|
|
53
|
+
// The judged lock vanished (another reclaimer moved it): re-judge rather than throw (Fable r2 P3-1).
|
|
54
|
+
try { names = fs.readdirSync(directory); } catch (error) { if (error.code === 'ENOENT' || error.code === 'ENOTDIR') return GONE; throw error; }
|
|
55
|
+
for (const name of names) {
|
|
56
|
+
const match = CLAIM_FILE.exec(name);
|
|
57
|
+
if (match && (match[1] ? Number(match[1]) : 0) > highest) { highest = match[1] ? Number(match[1]) : 0; highestName = name; }
|
|
58
|
+
}
|
|
59
|
+
if (highestName) {
|
|
60
|
+
let ageMs = 0;
|
|
61
|
+
try { ageMs = Date.now() - fs.lstatSync(path.join(directory, highestName)).mtimeMs; } catch { return null; }
|
|
62
|
+
if (!(ageMs > CLAIM_STALE_MS)) return null; // another reclaimer is mid-takeover
|
|
63
|
+
}
|
|
64
|
+
const level = highest + 1;
|
|
65
|
+
const file = path.join(directory, level ? `takeover-${level}.json` : 'takeover.json');
|
|
66
|
+
const token = crypto.randomUUID();
|
|
67
|
+
try { keepDirectoryTimes(directory, () => fs.writeFileSync(file, JSON.stringify({ token, pid: process.pid, claimedAt: new Date().toISOString() }), { flag: 'wx', mode: 0o600 })); }
|
|
68
|
+
catch (error) { if (error.code === 'EEXIST') return null; if (error.code === 'ENOENT') return GONE; throw error; }
|
|
69
|
+
return { file, token };
|
|
70
|
+
}
|
|
71
|
+
const dropClaim = claim => { try { if (readRaw(claim.file)?.includes(claim.token)) keepDirectoryTimes(path.dirname(claim.file), () => fs.unlinkSync(claim.file)); } catch { /* gone with its directory */ } };
|
|
72
|
+
/** Move `directory` aside only if it is still exactly the one observed: 'removed' | 'changed' | 'busy'. */
|
|
73
|
+
function removeObservedLock(directory, observed, { afterClaim = null } = {}) {
|
|
74
|
+
const claim = claimTakeover(directory);
|
|
75
|
+
if (claim === GONE) return 'changed';
|
|
76
|
+
if (!claim) return 'busy';
|
|
77
|
+
afterClaim?.({ directory, claim });
|
|
78
|
+
let same = false;
|
|
79
|
+
try {
|
|
80
|
+
const now = observeDirectory(directory);
|
|
81
|
+
same = now.identity === observed.identity && now.raw === observed.raw && Boolean(readRaw(claim.file)?.includes(claim.token));
|
|
82
|
+
} catch { same = false; }
|
|
83
|
+
if (!same) { dropClaim(claim); return 'changed'; }
|
|
84
|
+
const tomb = `${directory}.stale-${process.pid}-${crypto.randomBytes(6).toString('hex')}`;
|
|
85
|
+
try { fs.renameSync(directory, tomb); } catch (error) { if (error.code === 'ENOENT') return 'changed'; dropClaim(claim); throw error; }
|
|
86
|
+
if (!readRaw(path.join(tomb, path.basename(claim.file)))?.includes(claim.token)) {
|
|
87
|
+
try { if (!fs.existsSync(directory)) fs.renameSync(tomb, directory); } catch { /* keep it as evidence */ }
|
|
88
|
+
throw Error(`developer update lock takeover moved a directory it did not claim; ${directory}`);
|
|
89
|
+
}
|
|
90
|
+
fs.rmSync(tomb, { recursive: true, force: true });
|
|
91
|
+
return 'removed';
|
|
92
|
+
}
|
|
93
|
+
/** Publish the lock with its owner already inside: one rename, which fails when any populated lock is there. */
|
|
94
|
+
const CONTENDED = new Set(['EEXIST', 'ENOTEMPTY', 'EPERM', 'EACCES']);
|
|
95
|
+
function publishLock(directory, content) {
|
|
96
|
+
const staged = `${directory}.acquire-${process.pid}-${crypto.randomBytes(6).toString('hex')}`;
|
|
97
|
+
const dropStaged = () => { try { fs.rmSync(staged, { recursive: true, force: true }); } catch { /* best effort */ } };
|
|
98
|
+
try {
|
|
99
|
+
fs.mkdirSync(staged, { mode: 0o700 });
|
|
100
|
+
fs.writeFileSync(path.join(staged, 'owner.json'), content, { flag: 'wx', mode: 0o600 });
|
|
101
|
+
} catch (error) { dropStaged(); throw error; } // staging failures are real errors, never contention
|
|
102
|
+
try { fs.renameSync(staged, directory); return true; } catch (error) {
|
|
103
|
+
dropStaged();
|
|
104
|
+
// A refused publication is contention even if the lock is gone a moment later: its owner can release in between,
|
|
105
|
+
// and re-checking existsSync here threw a raw ENOTEMPTY at the caller (Opus r3 P2-1). Re-judge (bounded by the caller).
|
|
106
|
+
if (CONTENDED.has(error.code)) return false;
|
|
107
|
+
throw error;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
export function sharedLockStatus({ brainHome, alive = pidAlive, startOf = processStartOf } = {}) {
|
|
8
111
|
const directory = path.join(brainRoot(brainHome), 'developer-update.lock');
|
|
112
|
+
let status = judgeLock(directory, alive, startOf);
|
|
113
|
+
// An owner-less reading can be the instant between one owner's release and the next owner's publication: the lock was
|
|
114
|
+
// gone (or moved aside before owner.json was read) and a new owner's lock was there when existence was checked. That
|
|
115
|
+
// live lock was judged "unknown" and the refusal offered rm -rf (Opus r3 P2-2). Look again before calling it unknown.
|
|
116
|
+
for (let look = 0; look < 2 && status.state === 'unknown' && (status.observed === null || status.observed.raw === null); look++) {
|
|
117
|
+
let again = null;
|
|
118
|
+
try { again = observeDirectory(directory); } catch { /* vanished or unjudgeable: judged afresh */ }
|
|
119
|
+
if (again && status.observed && again.identity === status.observed.identity && again.raw === null) break; // the same owner-less directory
|
|
120
|
+
status = judgeLock(directory, alive, startOf);
|
|
121
|
+
}
|
|
122
|
+
return status;
|
|
123
|
+
}
|
|
124
|
+
function judgeLock(directory, alive, startOf) {
|
|
125
|
+
let observed = null;
|
|
9
126
|
try {
|
|
10
|
-
|
|
11
|
-
|
|
127
|
+
observed = observeDirectory(directory);
|
|
128
|
+
if (observed.raw === null) throw Object.assign(Error('owner.json absent'), { code: 'ENOENT' });
|
|
129
|
+
const owner = JSON.parse(observed.raw);
|
|
12
130
|
if (!Number.isInteger(owner.pid) || owner.pid < 1 || typeof owner.token !== 'string' || owner.token.length < 16) throw Error('invalid owner');
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
if (
|
|
41
|
-
|
|
42
|
-
|
|
131
|
+
if (!alive(owner.pid)) return { state: 'stale', directory, owner, observed };
|
|
132
|
+
// A live pid is the owner only when the recorded process start matches: a reused pid is another process.
|
|
133
|
+
const seen = typeof owner.processStart === 'string' && owner.processStart ? startOf(owner.pid) : null;
|
|
134
|
+
if (seen && seen !== owner.processStart) return { state: 'stale', directory, owner, reusedPid: true, observed };
|
|
135
|
+
return { state: 'running', directory, owner, observed };
|
|
136
|
+
} catch (error) { return { state: error.code === 'ENOENT' && !fs.existsSync(directory) ? 'idle' : 'unknown', directory, error: error.message, observed }; }
|
|
137
|
+
}
|
|
138
|
+
export function acquireDeveloperLock({ brainHome, token = process.env.RUVNET_DEVELOPER_UPDATE_TOKEN, pid = process.pid, alive, startOf = processStartOf, now = Date.now(), afterTakeoverClaim = null } = {}) {
|
|
139
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
140
|
+
const status = sharedLockStatus({ brainHome, alive, startOf });
|
|
141
|
+
if (token && status.state === 'running' && status.owner.token === token) return { token, ownerPid: status.owner.pid, inherited: true, release() {} };
|
|
142
|
+
// A dead owner's lock must not wedge every future update (measured 2026-10-15: a coordinator killed mid-run left `stale`
|
|
143
|
+
// behind and the automatic updater refused until a person removed the directory). Reclaimable: a provably dead or reused-pid
|
|
144
|
+
// owner, or a directory with no readable owner (a kill between mkdir and the owner write), once STALE_RECLAIM_MS old by its
|
|
145
|
+
// recorded start or, failing that, the directory's own mtime. The takeover is claimed and re-checked (above), then the
|
|
146
|
+
// normal atomic mkdir claim follows. A live owner, or anything younger, still refuses.
|
|
147
|
+
let ageMs = NaN;
|
|
148
|
+
if (status.state === 'stale' || status.state === 'unknown') {
|
|
149
|
+
ageMs = now - Date.parse(status.owner?.startedAt || '');
|
|
150
|
+
if (!Number.isFinite(ageMs)) try { ageMs = now - fs.lstatSync(status.directory).mtimeMs; } catch { ageMs = NaN; }
|
|
151
|
+
}
|
|
152
|
+
const refuse = state => Error(`developer update lock ${state}; ${status.directory}. If no update is running on this machine, remove it: ${removal(status.directory)}`);
|
|
153
|
+
// A lock with no owner yet that is seconds old is another process mid-creation (agentic-kit writes its owner after its
|
|
154
|
+
// mkdir), not debris: never offer to remove it (Opus r3 P2-2).
|
|
155
|
+
if (status.state === 'unknown' && status.observed?.raw === null && Number.isFinite(ageMs) && ageMs < OWNERLESS_GRACE_MS) {
|
|
156
|
+
throw Error(`developer update lock is being created by another process; ${status.directory}. Retry shortly.`);
|
|
157
|
+
}
|
|
158
|
+
if ((status.state === 'stale' || status.state === 'unknown') && Number.isFinite(ageMs) && ageMs >= STALE_RECLAIM_MS) {
|
|
159
|
+
if (!status.observed) throw refuse(status.state); // not a lock directory this protocol can judge (a symlink or a file)
|
|
160
|
+
const outcome = removeObservedLock(status.directory, status.observed, { afterClaim: afterTakeoverClaim });
|
|
161
|
+
if (outcome === 'busy') throw refuse('is being reclaimed by another process');
|
|
162
|
+
continue; // 'removed' or 'changed': judge the directory afresh
|
|
163
|
+
} else if (status.state !== 'idle') throw refuse(status.state);
|
|
164
|
+
fs.mkdirSync(path.dirname(status.directory), { recursive: true });
|
|
165
|
+
const owner = { pid, token: crypto.randomUUID(), startedAt: new Date().toISOString(), processStart: startOf(pid) };
|
|
166
|
+
if (!publishLock(status.directory, JSON.stringify(owner))) continue; // judge the lock that won
|
|
167
|
+
return { token: owner.token, ownerPid: pid, inherited: false, release() {
|
|
168
|
+
const current = sharedLockStatus({ brainHome, alive, startOf });
|
|
169
|
+
if (current.owner?.token !== owner.token || current.owner?.pid !== pid) throw Error('developer update lock ownership changed');
|
|
170
|
+
// Move the whole lock aside in one step, then confirm it was this owner's: removing owner.json first left an empty
|
|
171
|
+
// directory that a contender's publication rename could replace, and the follow-up removal deleted that live lock.
|
|
172
|
+
const tomb = `${status.directory}.released-${process.pid}-${crypto.randomBytes(6).toString('hex')}`;
|
|
173
|
+
fs.renameSync(status.directory, tomb);
|
|
174
|
+
if (!readRaw(path.join(tomb, 'owner.json'))?.includes(owner.token)) {
|
|
175
|
+
try { if (!fs.existsSync(status.directory)) fs.renameSync(tomb, status.directory); } catch { /* keep it as evidence */ }
|
|
176
|
+
throw Error('developer update lock ownership changed');
|
|
177
|
+
}
|
|
178
|
+
fs.rmSync(tomb, { recursive: true, force: true });
|
|
179
|
+
} };
|
|
180
|
+
}
|
|
181
|
+
throw Error(`developer update lock is contended; ${path.join(brainRoot(brainHome), 'developer-update.lock')}. Retry shortly.`);
|
|
43
182
|
}
|
|
@@ -3,20 +3,23 @@ import fs from 'node:fs';
|
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import os from 'node:os';
|
|
5
5
|
import crypto from 'node:crypto';
|
|
6
|
-
import { EXECUTION_MODULES } from './developer-update-policy.mjs';
|
|
6
|
+
import { EXECUTION_MODULES, OPTIONAL_EXECUTION_MODULES } from './developer-update-policy.mjs';
|
|
7
7
|
const hash = file => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
|
|
8
8
|
export function developerCoordinatorOwner({ home = os.homedir(), brainHome = process.env.RUVNET_BRAIN_HOME || path.join(home, '.cache/ruvnet-brain') } = {}) {
|
|
9
9
|
const config = path.join(brainHome, 'developer-update-config.json'), registration = path.join(brainHome, 'scheduler/registration.json');
|
|
10
|
-
let record;
|
|
11
|
-
try { record = JSON.parse(fs.readFileSync(registration, 'utf8')); } catch {
|
|
12
|
-
|
|
10
|
+
let record, registered = true;
|
|
11
|
+
try { record = JSON.parse(fs.readFileSync(registration, 'utf8')); } catch (error) { registered = error.code !== 'ENOENT'; }
|
|
12
|
+
// A config left behind by --disable-nightly (no registration at all) owns nothing: refusing every automatic update
|
|
13
|
+
// as "not ready" forever would wedge the machine (AuditC #11). An unreadable registration still refuses.
|
|
14
|
+
const configured = (fs.existsSync(config) && registered) || record?.mode === 'developer-suite';
|
|
13
15
|
if (!configured) return { active: false, ready: false };
|
|
14
16
|
const entry = record?.updateModules?.['developer-update.mjs'];
|
|
15
17
|
try {
|
|
16
18
|
if (record?.mode !== 'developer-suite' || !path.isAbsolute(entry?.path || '') || !/^[a-f0-9]{64}$/.test(entry?.sha256 || '')
|
|
17
19
|
|| hash(entry.path) !== entry.sha256 || hash(record.runnerPath) !== record.runnerSha256) throw Error('registered source is absent or changed');
|
|
18
20
|
if (!record.updateModules?.['plugin-artifact-proof.mjs'] && fs.readFileSync(entry.path, 'utf8').includes('./plugin-artifact-proof.mjs')) throw Error('new coordinator artifact closure is incomplete');
|
|
19
|
-
|
|
21
|
+
if (!record.updateModules?.['entrypoint.mjs'] && fs.readFileSync(entry.path, 'utf8').includes('./entrypoint.mjs')) throw Error('coordinator closure is missing entrypoint.mjs');
|
|
22
|
+
const generation = EXECUTION_MODULES.filter(name => !OPTIONAL_EXECUTION_MODULES.includes(name) || record.updateModules?.[name]);
|
|
20
23
|
if (generation.some(name => !record.updateModules?.[name])) throw Error('coordinator execution closure is incomplete');
|
|
21
24
|
for (const module of Object.values(record.updateModules)) if (hash(module.path) !== module.sha256) throw Error('registered module closure changed');
|
|
22
25
|
return { active: true, ready: true, entry: entry.path, node: record.nodePath, registration, sourceSha256: entry.sha256 };
|
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
export const EXECUTION_MODULES = Object.freeze(['developer-update.mjs', 'developer-update-policy.mjs', 'developer-update-lock.mjs', 'developer-update-maintenance.mjs', 'developer-update-cleanup.mjs', 'plugin-artifact-proof.mjs']);
|
|
1
|
+
export const EXECUTION_MODULES = Object.freeze(['developer-update.mjs', 'developer-update-policy.mjs', 'developer-update-lock.mjs', 'developer-update-maintenance.mjs', 'developer-update-cleanup.mjs', 'plugin-artifact-proof.mjs', 'entrypoint.mjs']);
|
|
2
|
+
// Members a coordinator generation may lack: each is required exactly when the staged developer-update.mjs imports it
|
|
3
|
+
// (plugin-artifact-proof since 4.6, entrypoint since the 4.7.21 entry-point sweep), so older registrations stay valid.
|
|
4
|
+
export const OPTIONAL_EXECUTION_MODULES = Object.freeze(['plugin-artifact-proof.mjs', 'entrypoint.mjs']);
|
|
2
5
|
// Shared stack classification and ordering: installations are selected, never manufactured.
|
|
3
6
|
export const FAMILY = /^(ruflo|ruvnet-brain|ruvector|ruvector-extensions|ruvi|ruvbot|qudag|flow-nexus|agent-browser|agent-browser-mcp|agentic-flow|agentic-qe|agentic-robotics|agentic-payments|agentdb|ruv-swarm|@pacphi\/agentic-kit$|@ruvector\/|@claude-flow\/|@metaharness\/|@agentic-robotics\/)/;
|
|
4
7
|
export const PLUGIN_MARKETPLACES = new Set(['ruflo', 'ruview', 'ruvnet-brain', 'cognitum']);
|