ruvnet-brain 4.4.0 β 4.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/bin/install.mjs +68 -9
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/scripts/grounding-turn-evidence.mjs +52 -5
- package/plugin/scripts/project-progression-reader.mjs +10 -0
- package/plugin/scripts/project-progression-store.mjs +153 -6
- package/plugin/scripts/session-snapshot-hook.mjs +198 -38
- package/scripts/console-instances.mjs +70 -12
- package/scripts/full-suite-gate.mjs +9 -2
package/README.md
CHANGED
|
@@ -7,7 +7,7 @@ Created: 2026-06-29 22:36:38 EDT
|
|
|
7
7
|
|
|
8
8
|
# π§ RuvNet Brain
|
|
9
9
|
|
|
10
|
-
### π§ RuvNet Brain β [](https://github.com/stuinfla/ruvnet-brain/blob/main/plugin/.claude-plugin/plugin.json)
|
|
11
11
|
|
|
12
12
|
**A portable, source-grounded brain over Reuven Cohen's (rUv's) RuvNet stack β delivered as a Claude Code plugin that makes Claude _use_ the stack instead of fighting it.**
|
|
13
13
|
|
package/bin/install.mjs
CHANGED
|
@@ -72,6 +72,8 @@ import {
|
|
|
72
72
|
} from '../scripts/console-runtime-identity.mjs';
|
|
73
73
|
import { shellDiff as pluginShellDiff } from '../plugin/scripts/host-shell-boundary.mjs';
|
|
74
74
|
import { readConsoleReceipts, replaceStaleConsoles } from '../scripts/console-instances.mjs';
|
|
75
|
+
import { cleanLegacyRufloDebris } from '../plugin/scripts/project-progression-store.mjs';
|
|
76
|
+
import { resolveProjectStore } from '../plugin/scripts/project-store-resolver.mjs';
|
|
75
77
|
import { runHostCli, waitForHostCli } from '../scripts/host-cli.mjs';
|
|
76
78
|
import {
|
|
77
79
|
writeInstalledRuntimeIdentity, recordCorpusTransportIdentity, isCorpusReleaseTag, rejectedReleasePath,
|
|
@@ -1297,6 +1299,30 @@ export function withLiveConsoleState(recorded, { receiptDir, alive, probe } = {}
|
|
|
1297
1299
|
return { ...recorded, consoleRuntime: live };
|
|
1298
1300
|
}
|
|
1299
1301
|
|
|
1302
|
+
/**
|
|
1303
|
+
* 4.3.40's ruflo leftovers inside this project's `.swarm` (cleanLegacyRufloDebris). --update removes
|
|
1304
|
+
* them; --doctor only reports (dryRun). Either way a REFUSED artifact (unexpected contents, a symlink) is
|
|
1305
|
+
* printed, never dropped silently. Not a project (or no .swarm): nothing to say.
|
|
1306
|
+
*/
|
|
1307
|
+
export function reportLegacyRufloDebris({ projectDir = process.cwd(), dryRun = false } = {}) {
|
|
1308
|
+
let storeDir;
|
|
1309
|
+
try { storeDir = path.dirname(resolveProjectStore({ projectDir }).canonicalAgentDbPath); } catch { return null; }
|
|
1310
|
+
if (!fs.existsSync(storeDir)) return null;
|
|
1311
|
+
const result = cleanLegacyRufloDebris(storeDir, { dryRun });
|
|
1312
|
+
for (const entry of result.removed) {
|
|
1313
|
+
// Dry run applies the same checks (allowlist, mirror proof, in-use window); only the final
|
|
1314
|
+
// unchanged-since-proof comparison can still keep it at --update time.
|
|
1315
|
+
if (dryRun) info(`legacy ruflo debris from 4.3.40 in ${entry}: passes every check; --update removes it unless it is written to before then`);
|
|
1316
|
+
else ok(`removed legacy ruflo debris from 4.3.40: ${entry}`);
|
|
1317
|
+
}
|
|
1318
|
+
for (const { path: entry, reason, kept } of result.refused) {
|
|
1319
|
+
// A KEPT nested AgentDB holds rows (or may still be written): never suggest deleting it by hand.
|
|
1320
|
+
if (kept) warn(`left 4.3.40's nested ruflo store in place β ${entry}: ${reason}. It is checked again on every update.`);
|
|
1321
|
+
else warn(`left legacy ruflo debris in place β ${entry}: ${reason}. Inspect it; remove it yourself if it is ruflo's.`);
|
|
1322
|
+
}
|
|
1323
|
+
return result;
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1300
1326
|
export function consoleRestartState(identity, {
|
|
1301
1327
|
receiptDir = path.join(process.env.RUVNET_BRAIN_HOME || path.join(os.homedir(), '.cache', 'ruvnet-brain'), 'console-instances'),
|
|
1302
1328
|
alive, probe,
|
|
@@ -1463,17 +1489,22 @@ function wirePlugin({ expectedVersion = PACKAGE_VERSION, requireManaged = false
|
|
|
1463
1489
|
const installedHookRetirement = claudeInstalledHookRetirementStatus({ plugin: installed });
|
|
1464
1490
|
if (installed.installed && versionSatisfies(installed.version, expectedVersion) && installedHookRetirement.ok) {
|
|
1465
1491
|
ok(`plugin installed at user scope (global, alongside Ruflo / RuVector) β exact version ${installed.version}`);
|
|
1466
|
-
if (shellBoundary.restartRequired) {
|
|
1467
|
-
|
|
1492
|
+
if (shellBoundary.restartRequired && shellBoundary.known) {
|
|
1493
|
+
info(` boot-level plugin declarations changed (${shellBoundary.paths.join(', ')}): new Claude Code sessions load them; already-open windows keep the old hook definitions until they are reopened.`);
|
|
1494
|
+
} else if (shellBoundary.restartRequired) {
|
|
1495
|
+
warn(`could not verify the plugin's boot-level declarations (${shellBoundary.reason}); restart Claude Code once to be sure they are loaded.`);
|
|
1468
1496
|
} else if (before.installed && before.version !== installed.version) {
|
|
1469
1497
|
info(' body-only update: the Stable Spine is live on the next hook/MCP call; no restart is required.');
|
|
1470
1498
|
}
|
|
1471
|
-
info(` commands available${shellBoundary.restartRequired ? '
|
|
1499
|
+
info(` commands available${shellBoundary.restartRequired ? ' in new sessions' : ' immediately'}: ${c.bold('/rvbc')}, ${c.bold('/ruvnet-brain:configure')}`);
|
|
1472
1500
|
return {
|
|
1473
1501
|
host: true, wired: true, version: installed.version, manualMarketplace, manualInstall,
|
|
1474
1502
|
shellChanged: shellBoundary.changed, shellChangedPaths: shellBoundary.paths,
|
|
1475
1503
|
restartRequired: shellBoundary.restartRequired,
|
|
1476
|
-
|
|
1504
|
+
// 'open-sessions': the new declarations are installed and PROVEN changed β only sessions already
|
|
1505
|
+
// open booted the old ones. 'unproven': the boot surface could not be compared at all.
|
|
1506
|
+
...(shellBoundary.restartRequired ? { sessionSafety: 'restart-required', sessionSafetyReason: shellBoundary.reason,
|
|
1507
|
+
restartScope: shellBoundary.known ? 'open-sessions' : 'unproven' } : {}),
|
|
1477
1508
|
};
|
|
1478
1509
|
}
|
|
1479
1510
|
|
|
@@ -2002,7 +2033,7 @@ export function wireCodexPlugin({
|
|
|
2002
2033
|
if (announce) {
|
|
2003
2034
|
ok(`Codex Brain plugin installed and enabled (${after.version || 'version unknown'}).`);
|
|
2004
2035
|
if (shellBoundary.restartRequired) {
|
|
2005
|
-
warn(`boot-level plugin declarations changed; restart Codex
|
|
2036
|
+
warn(`boot-level plugin declarations changed; restart Codex, then review them in /hooks (${shellBoundary.paths.join(', ') || shellBoundary.reason}).`);
|
|
2006
2037
|
} else if (before.installed && before.version !== after.version) {
|
|
2007
2038
|
info(' body-only update: the Stable Spine is live on the next hook/MCP call; no restart is required.');
|
|
2008
2039
|
}
|
|
@@ -2017,6 +2048,10 @@ export function wireCodexPlugin({
|
|
|
2017
2048
|
...(shellBoundary.restartRequired ? {
|
|
2018
2049
|
sessionSafety: 'restart-required',
|
|
2019
2050
|
sessionSafetyReason: shellBoundary.reason,
|
|
2051
|
+
// Never 'open-sessions' for Codex: changed hook definitions show as PENDING until reviewed in
|
|
2052
|
+
// /hooks (doctor fails closed on pending trust; the SessionStart notice says to trust them), and no
|
|
2053
|
+
// measurement shows a fresh Codex session running them without that step. Unproven = restart.
|
|
2054
|
+
restartScope: 'unproven',
|
|
2020
2055
|
} : {}),
|
|
2021
2056
|
};
|
|
2022
2057
|
}
|
|
@@ -2781,7 +2816,10 @@ async function doctor() {
|
|
|
2781
2816
|
const recorded = withLiveConsoleState(JSON.parse(fs.readFileSync(convergencePath, 'utf8')),
|
|
2782
2817
|
{ receiptDir: path.join(path.dirname(convergencePath), 'console-instances') });
|
|
2783
2818
|
hostConvergence = classifyHostConvergence(recorded);
|
|
2784
|
-
if (hostConvergence.healthy)
|
|
2819
|
+
if (hostConvergence.healthy) {
|
|
2820
|
+
ok(`host convergence receipt: ${hostConvergence.state}`);
|
|
2821
|
+
if (hostConvergence.notice) info(hostConvergence.notice);
|
|
2822
|
+
}
|
|
2785
2823
|
else {
|
|
2786
2824
|
warn(`host convergence incomplete: ${hostConvergence.state}`);
|
|
2787
2825
|
info(`Retry the same generation: ${c.bold('npx ruvnet-brain --update')}${hostConvergence.action ? `; ${hostConvergence.action}` : ''}`);
|
|
@@ -2791,6 +2829,7 @@ async function doctor() {
|
|
|
2791
2829
|
warn(`host convergence receipt is invalid: ${error.message}`);
|
|
2792
2830
|
}
|
|
2793
2831
|
}
|
|
2832
|
+
try { reportLegacyRufloDebris({ dryRun: true }); } catch (error) { warn(`legacy ruflo debris check failed: ${error.message}`); }
|
|
2794
2833
|
const brainHome = process.env.RUVNET_BRAIN_HOME || path.dirname(cacheDir);
|
|
2795
2834
|
const nightlyHealth = schedulerStatus({ platform: process.platform, env: process.env,
|
|
2796
2835
|
brainHome, kbDir: cacheDir });
|
|
@@ -3464,6 +3503,7 @@ export function syncHostsAfterUpdate(cacheDir = resolvedKbDir(), {
|
|
|
3464
3503
|
codexReceipt.restartRequired = true;
|
|
3465
3504
|
codexReceipt.sessionSafety = results.codex.sessionSafety || null;
|
|
3466
3505
|
codexReceipt.sessionSafetyReason = results.codex.sessionSafetyReason || null;
|
|
3506
|
+
codexReceipt.restartScope = results.codex.restartScope || 'unproven';
|
|
3467
3507
|
}
|
|
3468
3508
|
const claudeReceipt = {
|
|
3469
3509
|
state: results.claude.host ? 'ready' : 'absent',
|
|
@@ -3473,6 +3513,7 @@ export function syncHostsAfterUpdate(cacheDir = resolvedKbDir(), {
|
|
|
3473
3513
|
claudeReceipt.restartRequired = true;
|
|
3474
3514
|
claudeReceipt.sessionSafety = results.claude.sessionSafety || null;
|
|
3475
3515
|
claudeReceipt.sessionSafetyReason = results.claude.sessionSafetyReason || null;
|
|
3516
|
+
claudeReceipt.restartScope = results.claude.restartScope || 'unproven';
|
|
3476
3517
|
}
|
|
3477
3518
|
if (okApplied) {
|
|
3478
3519
|
// ISSUE #153 β a running host may freeze an old plugin root. Reclaim only generations whose
|
|
@@ -3563,9 +3604,16 @@ export function classifyHostConvergence(receipt, expectedVersion = PACKAGE_VERSI
|
|
|
3563
3604
|
return { healthy: false, state: 'version-mismatch', action: `required version ${expectedVersion}` };
|
|
3564
3605
|
}
|
|
3565
3606
|
const hostStates = Object.values(receipt.hosts || {});
|
|
3566
|
-
|
|
3607
|
+
// A host that is ready at the expected version and whose ONLY gap is PROVEN changed boot-level
|
|
3608
|
+
// declarations is converged: new sessions load the new hooks; only windows already open booted the
|
|
3609
|
+
// old ones (owner's Mac, 4.3.40 -> 4.4.0: a fresh `claude -p` loaded 4.4.0 and ran its hooks). That is
|
|
3610
|
+
// reported, not failed. An UNPROVEN boot surface (it could not be compared) still requires a restart.
|
|
3611
|
+
const openSessionsOnly = (host) => host?.state === 'ready' && versionSatisfies(host.version, expectedVersion)
|
|
3612
|
+
&& host.restartRequired === true && host.restartScope === 'open-sessions';
|
|
3613
|
+
const openSessions = Object.entries(receipt.hosts || {}).filter(([, host]) => openSessionsOnly(host)).map(([name]) => name);
|
|
3614
|
+
const badHost = hostStates.find((host) => !openSessionsOnly(host) && (!['ready', 'disabled', 'absent'].includes(host?.state)
|
|
3567
3615
|
|| (host.state === 'ready' && !versionSatisfies(host.version, expectedVersion))
|
|
3568
|
-
|| (host.state === 'ready' && host.restartRequired === true));
|
|
3616
|
+
|| (host.state === 'ready' && host.restartRequired === true)));
|
|
3569
3617
|
if (badHost?.restartRequired === true) {
|
|
3570
3618
|
return { healthy: false, state: 'host-restart-required', action: badHost.sessionSafetyReason || 'restart the host, then re-run --doctor' };
|
|
3571
3619
|
}
|
|
@@ -3575,9 +3623,17 @@ export function classifyHostConvergence(receipt, expectedVersion = PACKAGE_VERSI
|
|
|
3575
3623
|
? `the installer could not replace the running Console (${receipt.consoleRuntime.replacementFailures.join('; ')}); ` : '';
|
|
3576
3624
|
return { healthy: false, state: receipt.consoleRuntime?.state || 'console-unproven', action: `${why}restart Console, then re-run --doctor` };
|
|
3577
3625
|
}
|
|
3626
|
+
if (openSessions.length) return { healthy: true, state: 'channels-converged', openSessions, notice: openSessionsNotice(openSessions, receipt.desiredVersion) };
|
|
3578
3627
|
return { healthy: true, state: 'channels-converged' };
|
|
3579
3628
|
}
|
|
3580
3629
|
|
|
3630
|
+
const HOST_LABELS = { claude: 'Claude Code', codex: 'Codex' };
|
|
3631
|
+
/** The one accurate line for converged hosts whose already-open windows booted the old declarations. */
|
|
3632
|
+
export function openSessionsNotice(hosts, version) {
|
|
3633
|
+
const names = hosts.map((host) => HOST_LABELS[host] || host).join('/');
|
|
3634
|
+
return `new ${names} sessions use ${version}; already-open windows keep the old hook definitions until they are reopened`;
|
|
3635
|
+
}
|
|
3636
|
+
|
|
3581
3637
|
async function runUpdate() {
|
|
3582
3638
|
printBanner('update');
|
|
3583
3639
|
const kbDir = resolvedKbDir();
|
|
@@ -3590,6 +3646,7 @@ async function runUpdate() {
|
|
|
3590
3646
|
process.exitCode = 1;
|
|
3591
3647
|
return;
|
|
3592
3648
|
}
|
|
3649
|
+
if (convergence.convergence?.notice) info(convergence.convergence.notice);
|
|
3593
3650
|
try {
|
|
3594
3651
|
const managed = applyManagedCatalogUpdate({
|
|
3595
3652
|
routerDir: path.join(os.homedir(), '.claude', 'model-router'),
|
|
@@ -3826,9 +3883,11 @@ async function runUpdate() {
|
|
|
3826
3883
|
if (!convergence.ok) {
|
|
3827
3884
|
warn(`host synchronization is incomplete β runtime stays on the prior verified generation${convergence.error ? ` (${convergence.error})` : ''}`);
|
|
3828
3885
|
updateStatus = 1;
|
|
3829
|
-
}
|
|
3886
|
+
} else if (convergence.convergence?.notice) info(convergence.convergence.notice);
|
|
3887
|
+
try { reportLegacyRufloDebris(); } catch (error) { warn(`legacy ruflo debris cleanup failed: ${error.message}`); }
|
|
3830
3888
|
recordRefreshPhase(refreshReceipt, 'host-convergence', convergence.ok && convergence.convergence?.healthy === true ? 'PASS' : 'FAIL', {
|
|
3831
3889
|
state: convergence.convergence?.state || null, error: convergence.error || null,
|
|
3890
|
+
...(convergence.convergence?.openSessions ? { openSessions: convergence.convergence.openSessions } : {}),
|
|
3832
3891
|
execution: { kind: 'executed', runId: refreshReceipt.runId },
|
|
3833
3892
|
});
|
|
3834
3893
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ruvnet-brain",
|
|
3
|
-
"version": "4.4.
|
|
3
|
+
"version": "4.4.1",
|
|
4
4
|
"description": "One-command installer for RuvNet Brain \u2014 a portable, source-grounded brain over rUv's RuvNet building blocks, delivered as a Claude Code plugin so Claude uses the stack instead of fighting it.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ruvnet-brain",
|
|
3
3
|
"description": "RuvNet brain transplant for Claude Code β grounds every RuvNet decision in real source across 77 rUv repositories, prefers Ruflo / RuVector-RVF / AgentDB over training-prior defaults (pgvector, Pinecone, hand-rolled cosine), and can pull in any RuvNet repo on demand. Ships a UserPromptSubmit retrieve-and-inject grounding hook and a PreToolUse write gate that refuses ungrounded rUv-product code until search_ruvnet has been consulted (ADR-0012 / ADR-067).",
|
|
4
|
-
"version": "4.4.
|
|
4
|
+
"version": "4.4.1",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Stuart Kerr"
|
|
7
7
|
},
|
|
@@ -221,7 +221,7 @@ const RUV_SUBJECT = String.raw`(?<![\w/.@-])${RUV_PRODUCT}(?![\w-]|[./][\w])(?:(
|
|
|
221
221
|
// ("rUv's tools turn β¦") β a lookbehind, so a rejected noun never consumes the real verb after it.
|
|
222
222
|
// Not \b at the end: `support-ticket` is no verb.
|
|
223
223
|
const PLURAL_NOUN = 'tools|packages|crates|plugins|libraries|hooks|agents|skills|servers|workers|daemons|commands|apis|clis|sdks|bindings|routers|gates|controllers';
|
|
224
|
-
const VERBS = 'support|provide|expose|ship|offer|export|implement|include|allow|enable|accept|return|store|require|need|handle|route|record|persist|index|cache|spawn|create|generate|compute|sort|classif|scan|detect|block|prevent|replace|wrap|call|launch|keep|clamp|turn|give|make|run|use|take|let|write';
|
|
224
|
+
const VERBS = 'support|provide|expose|ship|offer|export|implement|include|allow|enable|accept|return|store|require|need|handle|route|record|persist|index|cache|spawn|create|generate|compute|sort|classif|scan|detect|block|prevent|replace|wrap|call|launch|keep|clamp|turn|give|make|run|use|take|let|write|default';
|
|
225
225
|
// "will not" is a capability claim only with a capability verb: "AgentDB will not open X" is, while
|
|
226
226
|
// "Ruflo will not be touched by this patch" / "will not need a rebuild" report OUR change (4.4.0 nit).
|
|
227
227
|
const WILL_NOT_VERBS = 'run|work|support|open|load|accept|start|install|handle|read|write|store|return|expose|allow|connect|recogni[sz]e|parse|build|compile|sync|scale|persist|import|export';
|
|
@@ -237,7 +237,20 @@ const RUV_DOC_CLAIM = new RegExp(`(?<![\\w/.@-])${RUV_PRODUCT}(?:(?:'|β)s)?(?:
|
|
|
237
237
|
// "Ruflo is the orchestration layer and it has no hooks API": the pronoun refers back, in the same
|
|
238
238
|
// sentence β only to a product that OPENS the sentence as its subject (a product inside a list or a
|
|
239
239
|
// parenthetical is not what "they" means: "N-API builds (ruvector, rvf, β¦), so they don't dependβ¦").
|
|
240
|
-
|
|
240
|
+
// The opening subject phrase of a sentence: "The RuVector router package (`ruvector-router`)",
|
|
241
|
+
// "RuVector's router", "Ruflo". Words stop at a copula; a parenthetical right after it is allowed.
|
|
242
|
+
// OUR change to a product ("the ruflo upgrade", "the AgentDB write", "the ruflo fix") is not the product.
|
|
243
|
+
const CHANGE_NOUN = 'upgrade|update|fix|change|patch|install|installation|write|read|call|run|release|build|migration|restart|bump|version|commit|test|tests|check|ci|job|workflow|step|lock|config|setting|settings|issue|bug|error|failure|outage|incident|pr|branch|diff|rollout|deploy|deployment';
|
|
244
|
+
const OPENING_SUBJECT = String.raw`^(?:the\s+)?(${RUV_PRODUCT})(?![\w-]|[./][\w])(?:(?:'|β)s)?(?:\s+(?!(?:is|are|was|were|has|and|or|but|it|they|${CHANGE_NOUN})\b)[\w.@/-]+){0,4}?\s+(?:\([^)]{0,80}\)\s+)?`;
|
|
245
|
+
const RUV_COREF_CLAIM = new RegExp(`${OPENING_SUBJECT}(?:(?:is|are)\\s+(?:a|an|the)\\b|has\\b|provides?\\b|ships?\\b|${CAPABILITY_VERB})[^.;!?]{0,200}?\\b(?:and|but|so|which|because)\\s+(?:it|they)\\s+(?:also\\s+|now\\s+|still\\s+|only\\s+)?${CAPABILITY_VERB}`, 'gi');
|
|
246
|
+
// A DEFINITION is a capability claim too: "The RuVector router package (β¦) is a vector database β¦"
|
|
247
|
+
// asserts what the product is and does (4.4.1, a live miss). Only as the sentence's opening subject,
|
|
248
|
+
// and only "is a/an": "your ruflo is a version behind" (a status) does not open the sentence.
|
|
249
|
+
const RUV_DEFINE_CLAIM = new RegExp(`${OPENING_SUBJECT}(?:is|are)\\s+(?:a|an)\\s+(?!(?:version|bit|few|lot|little|couple|day|week|month|commit|no-op|success|failure|regression|bug|fix|one-line|change|problem|mistake|non-issue|win|loss)s?\\b)\\w`, 'gi');
|
|
250
|
+
// "this is agentic-qe's own static/heuristic estimate": a copula naming what the PRODUCT's output is.
|
|
251
|
+
const RUV_COPULA_OWN_CLAIM = new RegExp(`\\b(?:this|that|it)\\s+is\\s+(${RUV_PRODUCT})(?![\\w-]|[./][\\w])(?:'|β)s\\s+own\\s+(?!(?:ci|tests?|build|pipeline|run|job|workflow|checks?|bug|failure|issue|repo|release|fault|problem|mistake|error)\\b)[\\w/-]+`, 'gi');
|
|
252
|
+
// "is confirmed honored by the installed ruflo": the product as the passive AGENT of a behaviour.
|
|
253
|
+
const RUV_PASSIVE_CLAIM = new RegExp(`\\b(?:is|are)\\s+(?:\\w+\\s+){0,2}?(?:honou?red|supported|handled|enforced|rejected|ignored|accepted|respected|read|parsed)\\s+by\\s+(?:the\\s+)?(?:installed\\s+|global\\s+|current\\s+)?(${RUV_PRODUCT})(?![\\w-]|[./][\\w])`, 'gi');
|
|
241
254
|
// Not an assertion: a question, a hedge, a plan or hypothetical. Narrower than HEDGE above on purpose,
|
|
242
255
|
// and narrower still since the 4.4.0 review (S2): "now", "if" and "will not" no longer silence a whole
|
|
243
256
|
// sentence β "Ruflo now supports Windows natively", "RuVector cannot run on Windows, so if you need it
|
|
@@ -262,9 +275,40 @@ function isAssertion(s, m) {
|
|
|
262
275
|
return !/^\s*(?:about\s+|around\s+|only\s+|~|β)?\d/.test(s.slice(m.index + m[0].length));
|
|
263
276
|
}
|
|
264
277
|
|
|
278
|
+
// "## What AgentDB actually is" followed by "It's a SQLite database file β¦": the heading names the subject
|
|
279
|
+
// and the section's sentence-initial "It" refers to it (4.4.1, a measured known miss).
|
|
280
|
+
const DEFINING_HEADING = new RegExp(`^\\s*#{1,6}\\s+(?:what|how)\\s+(?:the\\s+)?(${RUV_PRODUCT})(?![\\w-])\\s+(?:(?:actually|really|even)\\s+)?(?:is|are|does|works?)\\b`, 'i'); // the product ITSELF, not "the ruflo fix"
|
|
281
|
+
function bindHeadingPronouns(message) {
|
|
282
|
+
let subject = null;
|
|
283
|
+
return String(message || '').split('\n').map((line) => {
|
|
284
|
+
if (/^\s*#{1,6}\s/.test(line)) { subject = DEFINING_HEADING.exec(line)?.[1] ?? null; return line; }
|
|
285
|
+
if (!subject) return line;
|
|
286
|
+
return line.replace(/(^|[.!?]\s+)(?:\*\*)?It(?:'|β)s\b/g, `$1${subject} is`)
|
|
287
|
+
.replace(/(^|[.!?]\s+)(?:\*\*)?It\s+(?=(?:is|has|does|can|stores|runs|uses|keeps)\b)/g, `$1${subject} `);
|
|
288
|
+
}).join('\n');
|
|
289
|
+
}
|
|
290
|
+
// A table row whose FIRST cell is a product and whose other cells DESCRIBE it ("| AgentDB | Append-only
|
|
291
|
+
// audit trail | Concurrent-write safe KB |") asserts what the product is. Only descriptive cells count:
|
|
292
|
+
// letters only (no digits, hashes, dates or versions) and no status vocabulary β a status table about a
|
|
293
|
+
// product ("| ruflo | 3.41.2 | PASS |", "| ruflo | No version at all β¦ |") never qualifies.
|
|
294
|
+
const STATUS_WORD = /\b(?:no|not|none|n\/a|missing|stale|behind|current|unverified|verified|pass(?:ed)?|fail(?:ed)?|yes|done|version|commit|ok|error|broken|pending|todo|skipped|live|shipped|absent|present|installed|upgraded|restarted|healthy|unhealthy|running|reachable|unreachable|updated|configured|enabled|disabled|failing|passing|green|red|removed|added|fixed|deployed|published|merged|stopped|started|restored|reset|rebuilt)\b/i;
|
|
295
|
+
function productRowClaims(message) {
|
|
296
|
+
const claims = [];
|
|
297
|
+
for (const line of String(message || '').split('\n')) {
|
|
298
|
+
if (!/^\s*\|/.test(line) || /^\s*\|[\s:|-]+\|\s*$/.test(line)) continue;
|
|
299
|
+
const cells = line.split('|').slice(1, -1).map((c) => c.replace(/\*\*|__|`/g, '').trim());
|
|
300
|
+
const first = new RegExp(`^(${RUV_PRODUCT})$`, 'i').exec(cells[0] || '');
|
|
301
|
+
if (!first || cells.length < 2) continue;
|
|
302
|
+
const described = cells.slice(1).filter((c) => /^[A-Za-z][A-Za-z +/-]{3,80}$/.test(c) && c.split(/\s+/).length >= 2 && !STATUS_WORD.test(c));
|
|
303
|
+
if (described.length === cells.length - 1) claims.push({ text: line.trim(), match: `${first[1]} | ${described[0]}`, subject: first[1].toLowerCase() });
|
|
304
|
+
}
|
|
305
|
+
return claims;
|
|
306
|
+
}
|
|
307
|
+
|
|
265
308
|
/** The final answer's sentences (and table cells) that assert what a rUv product does. Never throws. */
|
|
266
309
|
export function ruvCapabilityClaims(rawMessage) {
|
|
267
|
-
const
|
|
310
|
+
const rows = productRowClaims(String(rawMessage || '').replace(/```[\s\S]*?```/g, '\n'));
|
|
311
|
+
const text = bindHeadingPronouns(rawMessage)
|
|
268
312
|
.replace(/```[\s\S]*?```/g, '\n') // command output and code are not prose claims
|
|
269
313
|
.replace(/^\s*>.*$/gm, ' ') // quoted material
|
|
270
314
|
.replace(/"[^"\n]{0,300}"|β[^β\n]{0,300}β/g, '\n') // quoted speech: someone else's words (a break: it may carry the full stop)
|
|
@@ -272,11 +316,14 @@ export function ruvCapabilityClaims(rawMessage) {
|
|
|
272
316
|
.replace(/^\s*#{1,6}\s.*$/gm, ' ') // headings name a topic
|
|
273
317
|
.replace(/\*\*|__/g, '')
|
|
274
318
|
.replace(/\|/g, '\n'); // table cells judged one by one
|
|
275
|
-
const out =
|
|
319
|
+
const out = rows.slice(0, 8);
|
|
276
320
|
for (const raw of text.split(/(?<=[.!?;])\s+|\n+|\s+[ββ]\s+/)) {
|
|
321
|
+
if (out.length >= 8) break;
|
|
277
322
|
const s = raw.replace(/^[\s\-*β’#>\d.)]+/, '').trim();
|
|
278
323
|
if (!s || s.length > 400 || NOT_RUV_ASSERTION.test(s)) continue;
|
|
279
|
-
const m = [...s.matchAll(RUV_CLAIM), ...s.matchAll(RUV_DOC_CLAIM), ...s.matchAll(RUV_COREF_CLAIM)
|
|
324
|
+
const m = [...s.matchAll(RUV_CLAIM), ...s.matchAll(RUV_DOC_CLAIM), ...s.matchAll(RUV_COREF_CLAIM), ...s.matchAll(RUV_DEFINE_CLAIM),
|
|
325
|
+
...s.matchAll(RUV_COPULA_OWN_CLAIM), ...s.matchAll(RUV_PASSIVE_CLAIM)]
|
|
326
|
+
.find((x) => isAssertion(s, x));
|
|
280
327
|
if (m) out.push({ text: s, match: m[0], subject: (m[1] || m[0]).trim().split(/\s+/)[0].replace(/(?:'|β)s$/, '').toLowerCase() });
|
|
281
328
|
if (out.length >= 8) break;
|
|
282
329
|
}
|
|
@@ -207,11 +207,13 @@ export function openProgressionReader(dbPath) {
|
|
|
207
207
|
};
|
|
208
208
|
let listStatement;
|
|
209
209
|
let readStatement;
|
|
210
|
+
let allStatement;
|
|
210
211
|
try {
|
|
211
212
|
// Prepared eagerly so an unexpected schema (or a WAL image this process cannot read) is
|
|
212
213
|
// reported as UNAVAILABLE now, before the caller has committed to the fast path.
|
|
213
214
|
listStatement = prepare(`SELECT key FROM memory_entries WHERE ${ACTIVE_ROW_SQL} AND namespace = ? ORDER BY key`);
|
|
214
215
|
readStatement = prepare(`SELECT content FROM memory_entries WHERE ${ACTIVE_ROW_SQL} AND namespace = ? AND key = ?`);
|
|
216
|
+
allStatement = prepare(`SELECT namespace, key, content FROM memory_entries WHERE ${ACTIVE_ROW_SQL} ORDER BY namespace, key`);
|
|
215
217
|
} catch (error) {
|
|
216
218
|
try { database.close(); } catch { /* the open failure is the news */ }
|
|
217
219
|
throw error;
|
|
@@ -252,6 +254,14 @@ export function openProgressionReader(dbPath) {
|
|
|
252
254
|
return content;
|
|
253
255
|
},
|
|
254
256
|
|
|
257
|
+
/** Every active (namespace, key, content) row β bounded, never truncated (past the bound is an error). */
|
|
258
|
+
allRows({ maxRows = 100_000 } = {}) {
|
|
259
|
+
const rows = query(allStatement, []);
|
|
260
|
+
if (rows.length > maxRows) throw new ProgressionReaderUnavailable(`store holds more than ${maxRows} active rows`);
|
|
261
|
+
return rows.map((row) => ({ namespace: String(row.namespace ?? ''), key: String(row.key ?? ''),
|
|
262
|
+
content: typeof row.content === 'string' ? row.content : null }));
|
|
263
|
+
},
|
|
264
|
+
|
|
255
265
|
close() {
|
|
256
266
|
try { database.close(); } catch { /* closing a spent read handle is never news */ }
|
|
257
267
|
},
|
|
@@ -180,6 +180,145 @@ export function rufloCwdFor(storePath, { root = rufloScratchRoot() } = {}) {
|
|
|
180
180
|
return ensurePrivateDir(path.join(root, projectKey));
|
|
181
181
|
}
|
|
182
182
|
|
|
183
|
+
/**
|
|
184
|
+
* 4.3.40 ran ruflo with cwd `<project>/.swarm`, so upgraded projects still hold ruflo's cwd artifacts
|
|
185
|
+
* INSIDE the store directory β including `.swarm/.swarm/hnsw.metadata.json`, a copy of snapshot content.
|
|
186
|
+
* Removes exactly those, and only when every path in them is one ruflo is known to write there; anything
|
|
187
|
+
* unexpected (or any symlink) leaves that artifact untouched and is REPORTED. The store itself
|
|
188
|
+
* (memory.db, -wal/-shm, schema.sql), the outbox and queue files are never candidates.
|
|
189
|
+
*
|
|
190
|
+
* The allowlist is measured, not guessed: real ruflo 3.49.0 run with cwd=<dir> and --path elsewhere
|
|
191
|
+
* writes .claude/{memory.db,.proven-config-version,proven-config.json} (init/store),
|
|
192
|
+
* .claude-flow/harness-active-policy.json and .swarm/{hnsw.index,hnsw.metadata.json} and ruvector.db
|
|
193
|
+
* (store), .claude-flow/policy/state.json (retrieve). The owner's real 4.3.40 projects also hold
|
|
194
|
+
* .swarm/.swarm/agentdb-memory.db(-wal,-shm): ruflo's AgentDB bridge opens <cwd>/.swarm/agentdb-memory.db
|
|
195
|
+
* (ruflo/v3/@claude-flow/cli/src/memory/memory-bridge.ts getAgentDbPath), and the native bindings drop
|
|
196
|
+
* ruvector.db into whatever cwd they run in (ruflo/scripts/smoke-memory-no-stray-db.mjs, ADR-125 Phase 7).
|
|
197
|
+
*/
|
|
198
|
+
const SQLITE = (name) => [name, `${name}-wal`, `${name}-shm`, `${name}-journal`];
|
|
199
|
+
const LEGACY_CWD_ARTIFACTS = Object.freeze({
|
|
200
|
+
'.swarm': new Set(['hnsw.index', 'hnsw.metadata.json', ...SQLITE('agentdb-memory.db')]),
|
|
201
|
+
'.claude': new Set(['.proven-config-version', 'proven-config.json', ...SQLITE('memory.db')]),
|
|
202
|
+
'.claude-flow': new Set(['harness-active-policy.json', 'policy/', 'policy/state.json']),
|
|
203
|
+
'ruvector.db': null, // a regular file
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
/** Every path under `dir`, relative, directories with a trailing slash; symlinks reported, never followed. */
|
|
207
|
+
function relativeEntries(dir) {
|
|
208
|
+
const out = [];
|
|
209
|
+
const walk = (current, prefix) => {
|
|
210
|
+
for (const name of fs.readdirSync(current)) {
|
|
211
|
+
const full = path.join(current, name);
|
|
212
|
+
const stat = fs.lstatSync(full);
|
|
213
|
+
const rel = prefix + name;
|
|
214
|
+
if (stat.isSymbolicLink()) out.push({ rel, link: true });
|
|
215
|
+
else if (stat.isDirectory()) { out.push({ rel: `${rel}/` }); walk(full, `${rel}/`); }
|
|
216
|
+
else out.push({ rel, file: stat.isFile() });
|
|
217
|
+
}
|
|
218
|
+
};
|
|
219
|
+
walk(dir, '');
|
|
220
|
+
return out;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* With `dryRun`, reports what WOULD be removed and touches nothing (--doctor). Returns
|
|
225
|
+
* { removed: [paths], refused: [{ path, reason }] }; a refusal must be shown to the user, never dropped.
|
|
226
|
+
*/
|
|
227
|
+
const IN_USE_MS = 10 * 60_000;
|
|
228
|
+
// The files that carry DATA: the database, its WAL and its rollback journal. Never -shm: it is the WAL
|
|
229
|
+
// index, and every reader β including this proof's own read-only open β rewrites it. Counting it made
|
|
230
|
+
// every real 4.3.40 store look "changed while it was being checked" (and "in use" on the next run).
|
|
231
|
+
const dataFiles = (dbPath) => {
|
|
232
|
+
const base = path.basename(dbPath);
|
|
233
|
+
return [base, `${base}-wal`, `${base}-journal`];
|
|
234
|
+
};
|
|
235
|
+
const sqliteFingerprint = (dbPath) => dataFiles(dbPath).map((name) => {
|
|
236
|
+
try { const st = fs.statSync(path.join(path.dirname(dbPath), name)); return `${name}:${st.size}:${st.mtimeMs}`; }
|
|
237
|
+
catch { return `${name}:absent`; }
|
|
238
|
+
});
|
|
239
|
+
const sameFiles = (a, b) => a.join('|') === b.join('|');
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Is every active (namespace, key, content) row of a stray nested AgentDB present, byte-identical, in
|
|
243
|
+
* the canonical store? Read-only through the progression reader (node:sqlite, readOnly β no write, no
|
|
244
|
+
* checkpoint). Anything it cannot prove β recently written (in use), unreadable, a row missing or
|
|
245
|
+
* different β keeps the file and says why.
|
|
246
|
+
*/
|
|
247
|
+
export function proveMirrored(nestedDb, canonicalDb, { now = Date.now(), maxRows = 100_000 } = {}) {
|
|
248
|
+
const fingerprint = sqliteFingerprint(nestedDb);
|
|
249
|
+
const newest = Math.max(...dataFiles(nestedDb).map((name) => {
|
|
250
|
+
try { return fs.statSync(path.join(path.dirname(nestedDb), name)).mtimeMs; } catch { return 0; }
|
|
251
|
+
}));
|
|
252
|
+
if (now - newest < IN_USE_MS) return { ok: false, reason: `kept: agentdb-memory.db was written ${Math.round((now - newest) / 1000)}s ago (in use)` };
|
|
253
|
+
const nested = withProgressionReader(nestedDb, (reader) => reader.allRows({ maxRows }));
|
|
254
|
+
if (!nested.ok) return { ok: false, reason: `kept: agentdb-memory.db could not be read to prove it is mirrored (${nested.reason})` };
|
|
255
|
+
const canonical = withProgressionReader(canonicalDb, (reader) => reader.allRows({ maxRows }));
|
|
256
|
+
if (!canonical.ok) return { ok: false, reason: `kept: memory.db could not be read to prove agentdb-memory.db is mirrored (${canonical.reason})` };
|
|
257
|
+
const have = new Map(canonical.value.map((row) => [`${row.namespace}\u0000${row.key}`, row.content]));
|
|
258
|
+
const missing = nested.value.filter((row) => row.content === null || have.get(`${row.namespace}\u0000${row.key}`) !== row.content);
|
|
259
|
+
if (missing.length) return { ok: false, reason: `kept: ${missing.length} of ${nested.value.length} rows not in memory.db`, missing: missing.length };
|
|
260
|
+
return { ok: true, rows: nested.value.length, fingerprint };
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export function cleanLegacyRufloDebris(storeDir, { dryRun = false, now = Date.now() } = {}) {
|
|
264
|
+
const removed = [];
|
|
265
|
+
const refused = [];
|
|
266
|
+
for (const [name, allowed] of Object.entries(LEGACY_CWD_ARTIFACTS)) {
|
|
267
|
+
const entry = path.join(storeDir, name);
|
|
268
|
+
let stat;
|
|
269
|
+
try { stat = fs.lstatSync(entry); } catch { continue; } // absent: nothing to do
|
|
270
|
+
if (stat.isSymbolicLink()) { refused.push({ path: entry, reason: 'symbolic link' }); continue; }
|
|
271
|
+
if (allowed === null) {
|
|
272
|
+
if (!stat.isFile()) { refused.push({ path: entry, reason: 'not a regular file' }); continue; }
|
|
273
|
+
} else {
|
|
274
|
+
if (!stat.isDirectory()) { refused.push({ path: entry, reason: 'not a directory' }); continue; }
|
|
275
|
+
const unknown = relativeEntries(entry).filter((item) => item.link || item.file === false || !allowed.has(item.rel))
|
|
276
|
+
.map((item) => (item.link ? `${item.rel} (symbolic link)` : item.rel));
|
|
277
|
+
if (unknown.length) { refused.push({ path: entry, reason: `unexpected entries: ${unknown.join(', ')}` }); continue; }
|
|
278
|
+
}
|
|
279
|
+
// A nested AgentDB is a real store (owner's projects: 1-41 rows, still written by open 4.3.40
|
|
280
|
+
// sessions). It goes only when every row is PROVEN present, identically, in the canonical memory.db.
|
|
281
|
+
const nestedDb = path.join(entry, 'agentdb-memory.db');
|
|
282
|
+
const mirror = name === '.swarm' && fs.existsSync(nestedDb) ? proveMirrored(nestedDb, path.join(storeDir, 'memory.db'), { now }) : null;
|
|
283
|
+
if (mirror && !mirror.ok) { refused.push({ path: entry, reason: mirror.reason, kept: true }); continue; }
|
|
284
|
+
if (!dryRun) {
|
|
285
|
+
// The proof is only valid for the bytes it read: anything written since means the file is in use.
|
|
286
|
+
if (mirror && !sameFiles(mirror.fingerprint, sqliteFingerprint(nestedDb))) {
|
|
287
|
+
refused.push({ path: entry, reason: 'kept: agentdb-memory.db changed while it was being checked (in use)', kept: true });
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
fs.rmSync(entry, { recursive: true, force: true });
|
|
291
|
+
}
|
|
292
|
+
removed.push(entry);
|
|
293
|
+
}
|
|
294
|
+
return { removed, refused };
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// What ruflo leaves in a cwd (measured, ruflo 3.49.0): `.swarm/` (hnsw.index, hnsw.metadata.json β a
|
|
298
|
+
// copy of every stored value), `.claude/`, `.claude-flow/`, `ruvector.db`. None of it is read back by the
|
|
299
|
+
// product: every call carries --path, and the store of record is that file.
|
|
300
|
+
const RUFLO_CWD_ARTIFACTS = Object.freeze(['.swarm', '.claude', '.claude-flow', 'ruvector.db']);
|
|
301
|
+
const STALE_RUN_MS = 3_600_000;
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* One ruflo invocation's working directory: a fresh private `run-*` directory inside the project's
|
|
305
|
+
* scratch dir, removed again by the caller after the call. ruflo copies every snapshot it touches into
|
|
306
|
+
* `<cwd>/.swarm/hnsw.metadata.json`; with one cwd per call that copy never accumulates (4.4.0 kept one
|
|
307
|
+
* per project that grew without bound and outlived the project). Also clears what 4.4.0 left directly
|
|
308
|
+
* in the project scratch dir and any `run-*` older than an hour (a call killed before its cleanup).
|
|
309
|
+
*/
|
|
310
|
+
export function rufloRunDir(storePath, { root = rufloScratchRoot(), now = Date.now() } = {}) {
|
|
311
|
+
const projectScratch = rufloCwdFor(storePath, { root });
|
|
312
|
+
for (const name of fs.readdirSync(projectScratch)) {
|
|
313
|
+
const entry = path.join(projectScratch, name);
|
|
314
|
+
let stat;
|
|
315
|
+
try { stat = fs.lstatSync(entry); } catch { continue; }
|
|
316
|
+
const stale = name.startsWith('run-') && stat.isDirectory() && now - stat.mtimeMs > STALE_RUN_MS;
|
|
317
|
+
if (RUFLO_CWD_ARTIFACTS.includes(name) || stale) fs.rmSync(entry, { recursive: true, force: true });
|
|
318
|
+
}
|
|
319
|
+
return fs.mkdtempSync(path.join(projectScratch, 'run-'));
|
|
320
|
+
}
|
|
321
|
+
|
|
183
322
|
export class ProjectProgressionStore {
|
|
184
323
|
constructor({
|
|
185
324
|
projectDir,
|
|
@@ -194,6 +333,9 @@ export class ProjectProgressionStore {
|
|
|
194
333
|
} = {}) {
|
|
195
334
|
if (!rufloBinary) throw new Error(RUFLO_MISSING);
|
|
196
335
|
this.resolution = resolveProjectStore({ projectDir, requestedStorePath });
|
|
336
|
+
// Best effort: a cleanup that cannot run must never stop a capture or a restore.
|
|
337
|
+
try { this.legacyDebris = cleanLegacyRufloDebris(path.dirname(this.resolution.canonicalAgentDbPath)); }
|
|
338
|
+
catch (error) { this.legacyDebris = { removed: [], refused: [{ path: null, reason: error.message }] }; }
|
|
197
339
|
this.rufloBinary = rufloBinary;
|
|
198
340
|
this.runner = runner;
|
|
199
341
|
this.clock = clock;
|
|
@@ -212,12 +354,17 @@ export class ProjectProgressionStore {
|
|
|
212
354
|
}
|
|
213
355
|
|
|
214
356
|
run(args) {
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
357
|
+
const cwd = rufloRunDir(this.resolution.canonicalAgentDbPath);
|
|
358
|
+
try {
|
|
359
|
+
return this.runner(this.rufloBinary, args, {
|
|
360
|
+
cwd,
|
|
361
|
+
encoding: 'utf8',
|
|
362
|
+
timeout: 120_000,
|
|
363
|
+
env: { ...process.env, RUFLO_DAEMON_AUTOSTART: '0' },
|
|
364
|
+
});
|
|
365
|
+
} finally {
|
|
366
|
+
fs.rmSync(cwd, { recursive: true, force: true });
|
|
367
|
+
}
|
|
221
368
|
}
|
|
222
369
|
|
|
223
370
|
validateSnapshot(snapshot) {
|
|
@@ -183,12 +183,12 @@ export function runSessionSnapshotHook(projectDir, event, {
|
|
|
183
183
|
if (!handed && token && token !== ordered) releaseReplayLock(root, token);
|
|
184
184
|
return { ...idle, replayed: 0, progressionCaptured: false, deferredToReplayer: Boolean(queued),
|
|
185
185
|
replaySkipped: `${why}; this capture ${queued ? 'queued behind it' : 'NOT queued (queue unwritable)'}`
|
|
186
|
-
+ `${queued ? (handed ? ', handed to a detached worker' : ' (
|
|
186
|
+
+ `${queued ? (handed ? ', handed to a detached worker' : ' (the current lock holder hands the queue to a worker when it releases)') : ''}` };
|
|
187
187
|
};
|
|
188
188
|
if (!ordered) {
|
|
189
189
|
token = takeReplayLock(root);
|
|
190
190
|
if (!token) return handOff('a worker is committing older work');
|
|
191
|
-
const queuedAhead =
|
|
191
|
+
const queuedAhead = queuedWork(root);
|
|
192
192
|
if (queuedAhead) return handOff(`${queuedAhead} older capture(s) queued`);
|
|
193
193
|
if (budgetMs < REPLAY_MIN_BUDGET_MS) {
|
|
194
194
|
const pending = pendingCount();
|
|
@@ -207,6 +207,14 @@ export function runSessionSnapshotHook(projectDir, event, {
|
|
|
207
207
|
} catch { /* the debt stays durable in the outbox; this capture is still worth attempting */ }
|
|
208
208
|
}
|
|
209
209
|
|
|
210
|
+
// Before COMMITTING anything, re-check the lock is still ours: with three racers, a stale-lock
|
|
211
|
+
// put-back can leave a holder that no longer owns it. One that lost it queues itself instead.
|
|
212
|
+
if (!ordered && !refreshReplayLock(root, token)) {
|
|
213
|
+
token = null;
|
|
214
|
+
handedLock = true; // nothing of ours to release
|
|
215
|
+
return handOff('the lock was taken over before this capture committed');
|
|
216
|
+
}
|
|
217
|
+
|
|
210
218
|
let produced;
|
|
211
219
|
try {
|
|
212
220
|
produced = produce({ resolution, payload, host, trigger: event });
|
|
@@ -240,7 +248,16 @@ export function runSessionSnapshotHook(projectDir, event, {
|
|
|
240
248
|
provenance: produced.provenance,
|
|
241
249
|
};
|
|
242
250
|
} finally {
|
|
243
|
-
|
|
251
|
+
// A boundary that fired while this one held the lock queued itself and could not start a worker
|
|
252
|
+
// (this lock was in the way). Releasing without looking stranded it until the next boundary β two
|
|
253
|
+
// simultaneous SessionEnds lost the second one's final state (4.4.1). So: queued work β hand THIS
|
|
254
|
+
// lock to a worker; and re-check after releasing, for a boundary that queued in between.
|
|
255
|
+
if (!ordered && !handedLock) {
|
|
256
|
+
if (!(queuedWork(root) && spawnReplay({ projectDir: root, token }))) {
|
|
257
|
+
releaseReplayLock(root, token);
|
|
258
|
+
if (queuedWork(root)) spawnReplay({ projectDir: root });
|
|
259
|
+
}
|
|
260
|
+
}
|
|
244
261
|
}
|
|
245
262
|
}
|
|
246
263
|
|
|
@@ -253,44 +270,171 @@ export const REPLAY_LOCK_STALE_MS = 120_000;
|
|
|
253
270
|
const REPLAY_LOCK = '.progression-replay.lock';
|
|
254
271
|
const QUEUE_PREFIX = '.progression-capture-queue-';
|
|
255
272
|
const lockPath = (projectDir) => path.join(projectDir, '.swarm', REPLAY_LOCK);
|
|
256
|
-
|
|
257
|
-
|
|
273
|
+
// The lock's FIRST line is the owner token; a second `pid <n>` line names the process holding it.
|
|
274
|
+
const readLock = (projectDir) => { try { return fs.readFileSync(lockPath(projectDir), 'utf8').split('\n')[0].trim(); } catch { return null; } };
|
|
275
|
+
const CLAIM_PREFIX = '.progression-capture-claimed-';
|
|
276
|
+
/** After this long a stale lock is taken over even if its holder pid looks alive (pid reuse, a wedged process). */
|
|
277
|
+
export const REPLAY_LOCK_ABANDON_MS = 30 * 60_000;
|
|
258
278
|
|
|
259
|
-
/**
|
|
260
|
-
export function
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
279
|
+
/** Is a process with this pid alive? EPERM means alive but not ours. Never throws. */
|
|
280
|
+
export function pidAlive(pid) {
|
|
281
|
+
if (!Number.isSafeInteger(pid) || pid <= 0) return false;
|
|
282
|
+
try { process.kill(pid, 0); return true; } catch (error) { return error?.code === 'EPERM'; }
|
|
283
|
+
}
|
|
284
|
+
const seqOf = (name) => Number((/(\d{12})\.json$/.exec(name) || [])[1] ?? 0);
|
|
285
|
+
const swarmEntries = (projectDir) => { try { return fs.readdirSync(path.join(projectDir, '.swarm')); } catch { return []; } };
|
|
286
|
+
// 4.4.0 named queue files by wall clock: `<prefix><15-digit ms>-<hrtime>-<pid>-<n>.json`. Open 4.4.0
|
|
287
|
+
// sessions keep queuing in that format after the update, so the two formats coexist for a while.
|
|
288
|
+
const LEGACY_QUEUE = /^\d{15}-/;
|
|
289
|
+
const queueTail = (name) => (name.startsWith(QUEUE_PREFIX) ? name.slice(QUEUE_PREFIX.length) : name.slice(CLAIM_PREFIX.length).replace(/^\d+-[A-Za-z0-9]*-\d+-/, '')); // <pid>-<start>-<queuedAt>-
|
|
290
|
+
const mtimeOf = (projectDir, name) => { try { return fs.statSync(path.join(projectDir, '.swarm', name)).mtimeMs; } catch { return Infinity; } };
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Queue one boundary's capture for the worker (0600, inside the project's own .swarm). ORDER IS THE
|
|
294
|
+
* ORDER OF EXCLUSIVE CREATION: the name is the next sequence number after every queued or claimed one,
|
|
295
|
+
* created with O_EXCL and retried on collision β never a clock, which can step backwards or wrap.
|
|
296
|
+
*/
|
|
297
|
+
export function queueCapture({ projectDir, event, host, payload }) {
|
|
298
|
+
const body = JSON.stringify({ event, host, payload });
|
|
299
|
+
for (let attempt = 0; attempt < 64; attempt += 1) {
|
|
300
|
+
const seq = Math.max(0, ...swarmEntries(projectDir).filter((n) => n.startsWith(QUEUE_PREFIX) || n.startsWith(CLAIM_PREFIX)).map(seqOf)) + 1;
|
|
301
|
+
const file = path.join(projectDir, '.swarm', `${QUEUE_PREFIX}${String(seq).padStart(12, '0')}.json`);
|
|
302
|
+
try { fs.writeFileSync(file, body, { flag: 'wx', mode: 0o600 }); return file; } catch (error) {
|
|
303
|
+
if (error?.code !== 'EEXIST') return null;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
return null;
|
|
268
307
|
}
|
|
269
308
|
|
|
309
|
+
/**
|
|
310
|
+
* Unclaimed queued captures, in queue order. While any 4.4.0 (timestamp-named) entry is present the
|
|
311
|
+
* order is creation time (mtime, then name) β by NAME every 4.4.1 sequence file would sort before every
|
|
312
|
+
* 4.4.0 one, replaying newer captures before older ones across the upgrade window. Once the legacy
|
|
313
|
+
* entries are gone the order is the sequence alone, independent of any clock.
|
|
314
|
+
*/
|
|
270
315
|
export function queuedCaptures(projectDir) {
|
|
316
|
+
const all = swarmEntries(projectDir).filter((n) => (n.startsWith(QUEUE_PREFIX) || n.startsWith(CLAIM_PREFIX)) && n.endsWith('.json'));
|
|
317
|
+
const queued = all.filter((n) => n.startsWith(QUEUE_PREFIX));
|
|
318
|
+
const mixed = all.some((n) => LEGACY_QUEUE.test(queueTail(n)));
|
|
319
|
+
const ordered = mixed
|
|
320
|
+
? queued.map((n) => [mtimeOf(projectDir, n), n]).sort((a, b) => a[0] - b[0] || (a[1] < b[1] ? -1 : a[1] > b[1] ? 1 : 0)).map(([, n]) => n)
|
|
321
|
+
: queued.sort();
|
|
322
|
+
return ordered.map((n) => path.join(projectDir, '.swarm', n));
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** All older work a boundary must wait behind: unclaimed captures plus captures a worker has claimed. */
|
|
326
|
+
export function queuedWork(projectDir) {
|
|
327
|
+
return swarmEntries(projectDir).filter((n) => (n.startsWith(QUEUE_PREFIX) || n.startsWith(CLAIM_PREFIX)) && n.endsWith('.json')).length;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* A process's START TIME, as a filename-safe token, or null where it cannot be read (no `ps`, e.g.
|
|
332
|
+
* Windows). With the pid it identifies the process: a reused pid has a different start time.
|
|
333
|
+
*/
|
|
334
|
+
export function processStart(pid) {
|
|
271
335
|
try {
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
336
|
+
// TZ and locale PINNED: `lstart` prints local time in the locale's format, so two workers with
|
|
337
|
+
// different settings would record the same live process differently and read it as pid reuse.
|
|
338
|
+
const r = spawnSync('ps', ['-o', 'lstart=', '-p', String(pid)], { encoding: 'utf8', timeout: 2000, windowsHide: true,
|
|
339
|
+
env: { ...process.env, TZ: 'UTC', LC_ALL: 'C', LANG: 'C' } });
|
|
340
|
+
const s = String(r.stdout || '').replace(/[^A-Za-z0-9]/g, '');
|
|
341
|
+
return r.status === 0 && s ? s : null;
|
|
342
|
+
} catch { return null; }
|
|
343
|
+
}
|
|
344
|
+
let selfStart;
|
|
345
|
+
const ownStart = () => (selfStart === undefined ? (selfStart = processStart(process.pid)) : selfStart);
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Claim a queued capture by atomic rename; null if taken. The claim's name records pid, start time and
|
|
349
|
+
* the queue file's ORIGINAL mtime (its creation order, which the mixed upgrade window sorts by); the
|
|
350
|
+
* claim file's own mtime is then set to the claim time, from which the orphan ceiling counts.
|
|
351
|
+
*/
|
|
352
|
+
function claimQueued(file) {
|
|
353
|
+
let queuedAt = 0;
|
|
354
|
+
try { queuedAt = Math.floor(fs.statSync(file).mtimeMs); } catch { return null; }
|
|
355
|
+
const claimed = path.join(path.dirname(file), `${CLAIM_PREFIX}${process.pid}-${ownStart() || 'na'}-${queuedAt}-${path.basename(file).slice(QUEUE_PREFIX.length)}`);
|
|
356
|
+
try { fs.renameSync(file, claimed); } catch { return null; }
|
|
357
|
+
try { const t = new Date(); fs.utimesSync(claimed, t, t); } catch { /* the ceiling then counts from queue time: earlier, never later */ }
|
|
358
|
+
return claimed;
|
|
275
359
|
}
|
|
360
|
+
const unclaimedName = (claimed) => path.join(path.dirname(claimed), `${QUEUE_PREFIX}${queueTail(path.basename(claimed))}`);
|
|
361
|
+
/** Put a claim back in the queue: rename (atomic, needs no hard links), restoring its creation order. */
|
|
362
|
+
const returnClaim = (claimed) => {
|
|
363
|
+
const queuedAt = Number(path.basename(claimed).slice(CLAIM_PREFIX.length).split('-')[2]);
|
|
364
|
+
const back = unclaimedName(claimed);
|
|
365
|
+
try { fs.renameSync(claimed, back); } catch { return false; }
|
|
366
|
+
if (Number.isFinite(queuedAt) && queuedAt > 0) { try { const t = new Date(queuedAt); fs.utimesSync(back, t, t); } catch { /* best effort */ } }
|
|
367
|
+
return true;
|
|
368
|
+
};
|
|
276
369
|
|
|
277
370
|
/**
|
|
278
|
-
*
|
|
279
|
-
*
|
|
280
|
-
*
|
|
371
|
+
* Return to the queue every claim whose worker is gone: its pid is dead, OR the pid now belongs to a
|
|
372
|
+
* different process (start time differs β pid reuse), OR the claim is older than REPLAY_LOCK_ABANDON_MS
|
|
373
|
+
* whatever the pid says (a reused pid where no start time can be read, a wedged worker). Without the
|
|
374
|
+
* last two a reused pid stranded a claim forever and every Stop spawned a worker that could not run it.
|
|
281
375
|
*/
|
|
282
|
-
export function
|
|
376
|
+
export function reclaimOrphans(projectDir, { isAlive = pidAlive, startOf = processStart, now = Date.now() } = {}) {
|
|
377
|
+
let n = 0;
|
|
378
|
+
for (const name of swarmEntries(projectDir).filter((x) => x.startsWith(CLAIM_PREFIX))) {
|
|
379
|
+
const [pidText, start] = name.slice(CLAIM_PREFIX.length).split('-');
|
|
380
|
+
const pid = Number(pidText);
|
|
381
|
+
const claimed = path.join(projectDir, '.swarm', name);
|
|
382
|
+
const abandoned = now - mtimeOf(projectDir, name) > REPLAY_LOCK_ABANDON_MS;
|
|
383
|
+
let gone = abandoned || !isAlive(pid);
|
|
384
|
+
if (!gone && start && start !== 'na') {
|
|
385
|
+
const current = startOf(pid);
|
|
386
|
+
gone = Boolean(current) && current !== start;
|
|
387
|
+
}
|
|
388
|
+
if (gone && returnClaim(claimed)) n += 1;
|
|
389
|
+
}
|
|
390
|
+
return n;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
const lockFacts = (file) => { const st = fs.statSync(file); return { content: fs.readFileSync(file, 'utf8'), mtimeMs: st.mtimeMs, ino: st.ino }; };
|
|
394
|
+
const sameFacts = (a, b) => a.content === b.content && a.mtimeMs === b.mtimeMs && a.ino === b.ino;
|
|
395
|
+
/** The pid ACTUALLY holding a lock: the `pid <n>` line a worker writes for itself, else the token's pid. */
|
|
396
|
+
const holderPid = (content) => {
|
|
397
|
+
const line = /^pid (\d+)$/m.exec(String(content));
|
|
398
|
+
return Number(line ? line[1] : String(content).trim().split('-')[0]);
|
|
399
|
+
};
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Take the lock. Returns this holder's TOKEN (`<pid>-<time>-<random>`), or null.
|
|
403
|
+
* β’ Free β exclusive create.
|
|
404
|
+
* β’ Fresh (refreshed within REPLAY_LOCK_STALE_MS) β null.
|
|
405
|
+
* β’ Stale but its holder pid is ALIVE β null until REPLAY_LOCK_ABANDON_MS: a laptop asleep mid-step,
|
|
406
|
+
* or a long step, is not a dead worker, and taking over would put two workers on one job. The holder
|
|
407
|
+
* pid is the WORKER's own (it rewrites the lock on start), not the hook that spawned it and exited.
|
|
408
|
+
* β’ Otherwise taken over: the stale file is renamed aside and VERIFIED to be the very file judged
|
|
409
|
+
* stale (content, mtime, inode). If a successor's fresh lock was moved instead (it took over between
|
|
410
|
+
* our check and our rename), it is put back β never over a third lock β and we back off. One winner.
|
|
411
|
+
*/
|
|
412
|
+
export function takeReplayLock(projectDir, now = Date.now(), { isAlive = pidAlive, beforeRename = null } = {}) {
|
|
283
413
|
const lock = lockPath(projectDir);
|
|
284
414
|
const token = `${process.pid}-${now}-${Math.random().toString(36).slice(2, 10)}`;
|
|
285
|
-
const create = () => { fs.writeFileSync(lock, `${token}\n`, { flag: 'wx', mode: 0o600 }); return token; };
|
|
415
|
+
const create = () => { fs.writeFileSync(lock, `${token}\npid ${process.pid}\n`, { flag: 'wx', mode: 0o600 }); return token; };
|
|
286
416
|
try { return create(); } catch { /* held, or stale */ }
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
417
|
+
let seen;
|
|
418
|
+
try { seen = lockFacts(lock); } catch { try { return create(); } catch { return null; } }
|
|
419
|
+
const age = now - seen.mtimeMs;
|
|
420
|
+
if (age <= REPLAY_LOCK_STALE_MS) return null;
|
|
421
|
+
if (age <= REPLAY_LOCK_ABANDON_MS && isAlive(holderPid(seen.content))) return null;
|
|
422
|
+
beforeRename?.();
|
|
423
|
+
const aside = `${lock}.stale-${token}`;
|
|
424
|
+
try { fs.renameSync(lock, aside); } catch { return null; }
|
|
425
|
+
let moved = null;
|
|
426
|
+
try { moved = lockFacts(aside); } catch { /* vanished */ }
|
|
427
|
+
if (!moved || !sameFacts(moved, seen)) {
|
|
428
|
+
// Put the successor's lock back without ever overwriting a third holder's: a hard link where the
|
|
429
|
+
// filesystem has them, else an exclusive copy.
|
|
430
|
+
try { fs.linkSync(aside, lock); } catch {
|
|
431
|
+
try { fs.copyFileSync(aside, lock, fs.constants.COPYFILE_EXCL); } catch { /* a third holder exists; the successor sees it lost the lock and stops */ }
|
|
432
|
+
}
|
|
433
|
+
try { fs.rmSync(aside, { force: true }); } catch { /* best effort */ }
|
|
434
|
+
return null;
|
|
435
|
+
}
|
|
436
|
+
try { fs.rmSync(aside, { force: true }); } catch { /* best effort */ }
|
|
437
|
+
try { return create(); } catch { return null; }
|
|
294
438
|
}
|
|
295
439
|
|
|
296
440
|
/** Heartbeat: refresh the lock's mtime if (and only if) this holder still owns it. */
|
|
@@ -299,6 +443,12 @@ export function refreshReplayLock(projectDir, token) {
|
|
|
299
443
|
try { const t = new Date(); fs.utimesSync(lockPath(projectDir), t, t); return true; } catch { return false; }
|
|
300
444
|
}
|
|
301
445
|
|
|
446
|
+
/** A worker that inherited the lock records ITS OWN pid on it, keeping the owner token. */
|
|
447
|
+
export function adoptReplayLock(projectDir, token) {
|
|
448
|
+
if (!token || readLock(projectDir) !== token) return false;
|
|
449
|
+
try { fs.writeFileSync(lockPath(projectDir), `${token}\npid ${process.pid}\n`, { mode: 0o600 }); return true; } catch { return false; }
|
|
450
|
+
}
|
|
451
|
+
|
|
302
452
|
/** Release ONLY a lock this holder owns; a successor's lock is never deleted. */
|
|
303
453
|
export function releaseReplayLock(projectDir, token) {
|
|
304
454
|
if (!token || readLock(projectDir) !== token) return false;
|
|
@@ -323,19 +473,22 @@ export function replayOutboxDetached({ projectDir, token = null, spawnFn = spawn
|
|
|
323
473
|
}
|
|
324
474
|
|
|
325
475
|
/**
|
|
326
|
-
* The detached worker's body, holding the lock `token`:
|
|
327
|
-
*
|
|
328
|
-
*
|
|
329
|
-
*
|
|
476
|
+
* The detached worker's body, holding the lock `token`: record its own pid on the lock, return orphaned
|
|
477
|
+
* claims to the queue, replay the outbox, then run every queued capture IN ORDER β each CLAIMED by
|
|
478
|
+
* atomic rename first, so no other worker can run it too, and each re-entering the boundary as
|
|
479
|
+
* `ordered`, so it replays before it produces. Ownership is re-checked before every step and right
|
|
480
|
+
* after each claim; a worker that lost the lock puts an unstarted claim back and stops. A finished
|
|
481
|
+
* claim is the claimer's own and is deleted. Releases only its own lock, then re-checks for captures
|
|
330
482
|
* queued while it held it.
|
|
331
483
|
*/
|
|
332
484
|
export function runOutboxReplay({ projectDir, token = process.env.RUVNET_REPLAY_LOCK_TOKEN || null, budgetMs = DETACHED_REPLAY_BUDGET_MS,
|
|
333
|
-
makeStoreFactory = boundedStoreFactory, now = Date.now, runCapture = runSessionSnapshotHook } = {}) {
|
|
485
|
+
makeStoreFactory = boundedStoreFactory, now = Date.now, runCapture = runSessionSnapshotHook, onClaim = null } = {}) {
|
|
334
486
|
let held = token || takeReplayLock(projectDir);
|
|
335
487
|
let replayed = 0;
|
|
336
488
|
for (let round = 0; held && round < 8; round += 1) {
|
|
337
489
|
try {
|
|
338
|
-
if (!
|
|
490
|
+
if (!adoptReplayLock(projectDir, held)) return replayed;
|
|
491
|
+
reclaimOrphans(projectDir);
|
|
339
492
|
const resolution = resolveProjectStore({ projectDir });
|
|
340
493
|
const store = makeStoreFactory(now() + budgetMs)({ projectDir, requestedStorePath: resolution.canonicalAgentDbPath });
|
|
341
494
|
for (const snapshot of store.outbox.pendingSnapshots()) {
|
|
@@ -345,19 +498,26 @@ export function runOutboxReplay({ projectDir, token = process.env.RUVNET_REPLAY_
|
|
|
345
498
|
}
|
|
346
499
|
for (const file of queuedCaptures(projectDir)) {
|
|
347
500
|
if (!refreshReplayLock(projectDir, held)) return replayed;
|
|
501
|
+
const claimed = claimQueued(file);
|
|
502
|
+
if (!claimed) continue;
|
|
503
|
+
onClaim?.(claimed);
|
|
504
|
+
if (!refreshReplayLock(projectDir, held)) {
|
|
505
|
+
returnClaim(claimed);
|
|
506
|
+
return replayed;
|
|
507
|
+
}
|
|
348
508
|
let job = null;
|
|
349
|
-
try { job = JSON.parse(fs.readFileSync(
|
|
509
|
+
try { job = JSON.parse(fs.readFileSync(claimed, 'utf8')); } catch { /* torn: dropped below */ }
|
|
350
510
|
try {
|
|
351
511
|
if (job) runCapture(projectDir, job.event, { rawInput: JSON.stringify(job.payload), host: job.host,
|
|
352
512
|
budgetMs, makeStoreFactory, now, ordered: held, writeMetadata: false,
|
|
353
513
|
captureTurn: () => ({ recorded: false, skipped: 'detached replay' }) });
|
|
354
514
|
} catch { /* a failed capture leaves its own snapshot durable in the outbox */ }
|
|
355
|
-
try { fs.rmSync(
|
|
515
|
+
try { fs.rmSync(claimed, { force: true }); } catch { /* best effort */ }
|
|
356
516
|
}
|
|
357
517
|
} catch { /* the debt stays durable; the next boundary hands it on again */ } finally {
|
|
358
518
|
releaseReplayLock(projectDir, held);
|
|
359
519
|
}
|
|
360
|
-
held =
|
|
520
|
+
held = queuedWork(projectDir) ? takeReplayLock(projectDir) : null;
|
|
361
521
|
}
|
|
362
522
|
return replayed;
|
|
363
523
|
}
|
|
@@ -37,12 +37,60 @@ export function probeRuntimeSync(port, timeoutMs = 1500) {
|
|
|
37
37
|
|
|
38
38
|
export const sameIdentity = (left, right) => IDENTITY_KEYS.every((key) => left?.[key] === right?.[key]);
|
|
39
39
|
|
|
40
|
+
const MONTHS = 'JanFebMarAprMayJunJulAugSepOctNovDec';
|
|
41
|
+
/** When process `pid` started (epoch ms, UTC, 1 s resolution), or null when it cannot be read. */
|
|
42
|
+
let clockTicks = null;
|
|
40
43
|
/**
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
* A receipt with no integer pid cannot be proven dead and is kept (counted as before).
|
|
44
|
+
* Linux: boot-relative start (/proc/<pid>/stat field 22, clock ticks since boot) anchored at the kernel's
|
|
45
|
+
* boot time (/proc/stat btime) β not `ps lstart`, which a forward wall-clock jump would push later.
|
|
44
46
|
*/
|
|
45
|
-
export function
|
|
47
|
+
export function linuxProcessStartMs(pid, { readFile = fs.readFileSync, spawn = spawnSync } = {}) {
|
|
48
|
+
try {
|
|
49
|
+
const stat = String(readFile(`/proc/${pid}/stat`, 'utf8'));
|
|
50
|
+
const fields = stat.slice(stat.lastIndexOf(')') + 2).split(' '); // field 3 onward (comm may hold spaces)
|
|
51
|
+
const startTicks = Number(fields[22 - 3]);
|
|
52
|
+
const btime = Number(String(readFile('/proc/stat', 'utf8')).match(/^btime\s+(\d+)/m)?.[1]);
|
|
53
|
+
if (clockTicks === null) clockTicks = Number(String(spawn('getconf', ['CLK_TCK'], { encoding: 'utf8' })?.stdout || '').trim()) || 100;
|
|
54
|
+
if (!Number.isFinite(startTicks) || !Number.isFinite(btime)) return null;
|
|
55
|
+
return btime * 1000 + Math.round((startTicks * 1000) / clockTicks);
|
|
56
|
+
} catch { return null; }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function processStartMs(pid, { spawn = spawnSync, platform = process.platform, readFile } = {}) {
|
|
60
|
+
if (!Number.isInteger(pid) || pid <= 0) return null;
|
|
61
|
+
if (platform === 'linux') {
|
|
62
|
+
const linux = linuxProcessStartMs(pid, { spawn, ...(readFile ? { readFile } : {}) });
|
|
63
|
+
if (linux !== null) return linux;
|
|
64
|
+
}
|
|
65
|
+
const run = process.platform === 'win32'
|
|
66
|
+
? spawn('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command',
|
|
67
|
+
`$p=Get-Process -Id ${pid} -ErrorAction Stop; $p.StartTime.ToUniversalTime().ToString('ddd MMM d HH:mm:ss yyyy',[Globalization.CultureInfo]::InvariantCulture)`],
|
|
68
|
+
{ encoding: 'utf8', windowsHide: true })
|
|
69
|
+
: spawn('ps', ['-p', String(pid), '-o', 'lstart='], { encoding: 'utf8', env: { ...process.env, TZ: 'UTC', LC_ALL: 'C', LANG: 'C' } });
|
|
70
|
+
if (run?.status !== 0) return null;
|
|
71
|
+
const m = String(run.stdout || '').trim().match(/^\w{3}\s+(\w{3})\s+(\d{1,2})\s+(\d{2}):(\d{2}):(\d{2})\s+(\d{4})$/);
|
|
72
|
+
if (!m || MONTHS.indexOf(m[1]) % 3 !== 0) return null;
|
|
73
|
+
return Date.UTC(+m[6], MONTHS.indexOf(m[1]) / 3, +m[2], +m[3], +m[4], +m[5]);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* A live pid that is NOT the receipt's Console: the process now holding that pid started after the
|
|
78
|
+
* Console wrote its receipt (a Console's process always starts before it records startedAt). Two seconds
|
|
79
|
+
* of slack cover the 1 s resolution of the start time. Unreadable start time = not provably reused.
|
|
80
|
+
*/
|
|
81
|
+
export function pidReused(receipt, { startMs = processStartMs } = {}) {
|
|
82
|
+
const recorded = Date.parse(receipt?.startedAt || '');
|
|
83
|
+
const started = startMs(receipt?.pid);
|
|
84
|
+
return Number.isFinite(recorded) && Number.isFinite(started) && started > recorded + 2_000;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Console receipts in `receiptDir`, with dead ones pruned. A receipt is dead when its port does not
|
|
89
|
+
* answer /api/runtime with the receipt's identity AND either its pid is not alive or that pid now
|
|
90
|
+
* belongs to a process that started after the receipt was written (pid reuse). A busy live Console
|
|
91
|
+
* (pid alive, same process) is never pruned. A receipt with no integer pid cannot be proven dead.
|
|
92
|
+
*/
|
|
93
|
+
export function readConsoleReceipts(receiptDir, { alive = pidAlive, probe = probeRuntimeSync, startMs = processStartMs } = {}) {
|
|
46
94
|
const live = [];
|
|
47
95
|
const pruned = [];
|
|
48
96
|
let names = [];
|
|
@@ -53,12 +101,17 @@ export function readConsoleReceipts(receiptDir, { alive = pidAlive, probe = prob
|
|
|
53
101
|
let receipt;
|
|
54
102
|
try { receipt = JSON.parse(fs.readFileSync(file, 'utf8')); } catch { continue; }
|
|
55
103
|
if (receipt?.product !== PRODUCT || receipt.schema !== 1) continue;
|
|
56
|
-
if (Number.isInteger(receipt.pid) && receipt.pid > 0
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
104
|
+
if (Number.isInteger(receipt.pid) && receipt.pid > 0) {
|
|
105
|
+
const dead = !alive(receipt.pid);
|
|
106
|
+
const reused = !dead && pidReused(receipt, { startMs });
|
|
107
|
+
if (dead || reused) {
|
|
108
|
+
const answer = probe(receipt.port);
|
|
109
|
+
if (!answer || !sameIdentity(answer, receipt)) {
|
|
110
|
+
try { fs.unlinkSync(file); } catch { /* raced or read-only: still not a running Console */ }
|
|
111
|
+
pruned.push({ file, pid: receipt.pid, port: receipt.port ?? null, startedAt: receipt.startedAt ?? null,
|
|
112
|
+
reason: dead ? 'pid not alive' : 'pid reused by a process started after the receipt' });
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
62
115
|
}
|
|
63
116
|
}
|
|
64
117
|
live.push({ file, receipt });
|
|
@@ -73,8 +126,13 @@ export function readConsoleReceipts(receiptDir, { alive = pidAlive, probe = prob
|
|
|
73
126
|
// process plumbing. A denylist, so nothing the Console legitimately reads is silently lost.
|
|
74
127
|
const CONSOLE_ENV_DENY = new Set(['RUVNET_NIGHTLY', 'RUVNET_BRAIN_TEST', 'RUVNET_BRAIN_TEST_LATEST_TAG', 'RUVNET_BRAIN_SCHEDULER_TEST',
|
|
75
128
|
'RUVNET_BRAIN_IMPORT_ONLY', 'RUVNET_BRAIN_NO_UPDATE_FALLBACK', 'RUVNET_STRICT_INSTALL', 'RUVNET_REFRESH_RUN_TOKEN',
|
|
76
|
-
'RUVNET_REFRESH_RECEIPT', 'RUVNET_UPGRADE_NOTICE_FILE', 'NODE_OPTIONS', 'NODE_TEST_CONTEXT', 'INIT_CWD', 'CONSOLE_PORT'
|
|
77
|
-
|
|
129
|
+
'RUVNET_REFRESH_RECEIPT', 'RUVNET_UPGRADE_NOTICE_FILE', 'NODE_OPTIONS', 'NODE_TEST_CONTEXT', 'INIT_CWD', 'CONSOLE_PORT',
|
|
130
|
+
// The launching host session's own identity (the spine version it booted, the hook host, its Node).
|
|
131
|
+
'RUVNET_BRAIN_ACTIVE_VERSION', 'RUVNET_HOOK_HOST', 'RUVNET_NODE_BIN']);
|
|
132
|
+
// CLAUDE* describes the Claude Code SESSION that ran the installer (CLAUDECODE, CLAUDE_PROJECT_DIR,
|
|
133
|
+
// CLAUDE_PLUGIN_ROOT, CLAUDE_SESSION_ID, CLAUDE_CODE_*): stale for a long-lived Console. CLAUDE_CONFIG_DIR
|
|
134
|
+
// is the user's configuration location, not session state, and is kept (host-update.mjs forwards it too).
|
|
135
|
+
const CONSOLE_ENV_DENY_PREFIX = /^(?:RUVNET_NIGHTLY_|npm_|VITEST|CLAUDE(?!_CONFIG_DIR$))/;
|
|
78
136
|
export function consoleEnv(env = process.env, port) {
|
|
79
137
|
const clean = Object.fromEntries(Object.entries(env).filter(([key, value]) => value != null
|
|
80
138
|
&& !CONSOLE_ENV_DENY.has(key) && !CONSOLE_ENV_DENY_PREFIX.test(key)));
|
|
@@ -50,10 +50,17 @@ export function redFiles(report, quarantine, root = ROOT) {
|
|
|
50
50
|
* assertion and stays RED. Matched on the failure text only, never the test title. */
|
|
51
51
|
const VITEST_TIMEOUT = /\b(?:Test|Hook) timed out in \d+ ?ms\b/;
|
|
52
52
|
const NUMERIC_BOUND = /\bexpected -?\d+(?:\.\d+)? to be (?:less|greater) than (?:or equal to )?-?\d+(?:\.\d+)?/;
|
|
53
|
-
|
|
53
|
+
// A DURATION, stated: a number with a time unit, or a percentile. This alone binds a bound to time.
|
|
54
|
+
const UNIT_CUE = /\d ?(?:ms|s|sec|seconds?)\b|\bp9[59]\b/i;
|
|
55
|
+
// A duration WORD is weaker: "retries before timeout: expected 5 to be less than 3" is a count. A word
|
|
56
|
+
// cue binds only a MEASURED value β a fractional left-hand number, which is what performance.now()-based
|
|
57
|
+
// timings produce ("β¦the ceiling: expected 337.294918 to be less than 300"); integer counts stay RED.
|
|
58
|
+
const WORD_CUE = /\b(?:took|elapsed|duration|latency|budget|ceiling|timeout)\b/i;
|
|
59
|
+
const MEASURED = /\bexpected -?\d+\.\d+ to be (?:less|greater) than/;
|
|
54
60
|
export const isTimingFailure = (message) => {
|
|
55
61
|
const text = String(message || '').slice(0, 2000);
|
|
56
|
-
return VITEST_TIMEOUT.test(text)
|
|
62
|
+
return VITEST_TIMEOUT.test(text)
|
|
63
|
+
|| (NUMERIC_BOUND.test(text) && (UNIT_CUE.test(text) || (WORD_CUE.test(text) && MEASURED.test(text))));
|
|
57
64
|
};
|
|
58
65
|
/** More flaky tests than this in one run fails the gate: load-sensitivity that wide is itself a defect. */
|
|
59
66
|
export const MAX_FLAKY = 3;
|