claude-flow 3.10.41 β 3.10.43
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/.claude/scheduled_tasks.lock +1 -0
- package/README.md +0 -4
- package/package.json +1 -1
- package/v3/@claude-flow/cli/README.md +0 -4
- package/v3/@claude-flow/cli/dist/src/commands/daemon.d.ts +8 -0
- package/v3/@claude-flow/cli/dist/src/commands/daemon.js +161 -1
- package/v3/@claude-flow/cli/dist/src/commands/hooks.js +12 -1
- package/v3/@claude-flow/cli/dist/src/commands/init.js +24 -1
- package/v3/@claude-flow/cli/dist/src/commands/neural.js +13 -3
- package/v3/@claude-flow/cli/dist/src/init/claudemd-generator.js +7 -3
- package/v3/@claude-flow/cli/dist/src/mcp-tools/agent-execute-core.d.ts +1 -0
- package/v3/@claude-flow/cli/dist/src/mcp-tools/agent-execute-core.js +27 -6
- package/v3/@claude-flow/cli/dist/src/mcp-tools/hooks-tools.js +79 -1
- package/v3/@claude-flow/cli/dist/src/memory/memory-initializer.js +29 -1
- package/v3/@claude-flow/cli/dist/src/ruvector/neural-router.d.ts +49 -0
- package/v3/@claude-flow/cli/dist/src/ruvector/neural-router.js +132 -0
- package/v3/@claude-flow/cli/dist/src/ruvector/router-trajectory.d.ts +69 -0
- package/v3/@claude-flow/cli/dist/src/ruvector/router-trajectory.js +87 -0
- package/v3/@claude-flow/cli/dist/src/services/worker-daemon.d.ts +27 -0
- package/v3/@claude-flow/cli/dist/src/services/worker-daemon.js +123 -0
- package/v3/@claude-flow/cli/package.json +1 -1
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"sessionId":"d0b433ab-a217-4dde-9ea4-b2c996ac456a","pid":35460,"acquiredAt":1781120150954}
|
package/README.md
CHANGED
|
@@ -16,10 +16,6 @@
|
|
|
16
16
|
[](https://www.npmjs.com/package/@claude-flow/codex)
|
|
17
17
|
[](https://github.com/ruvnet/ruvector)
|
|
18
18
|
|
|
19
|
-
[](https://cognitum.one/appliance)
|
|
20
|
-
|
|
21
|
-
[](https://github.com/ruvnet/ruflo/issues/1967)
|
|
22
|
-
|
|
23
19
|
# Ruflo
|
|
24
20
|
|
|
25
21
|
**Multi-agent AI harness for Claude Code and Codex**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-flow",
|
|
3
|
-
"version": "3.10.
|
|
3
|
+
"version": "3.10.43",
|
|
4
4
|
"description": "Ruflo - Enterprise AI agent orchestration for Claude Code. Deploy 60+ specialized agents in coordinated swarms with self-learning, fault-tolerant consensus, vector memory, and MCP integration",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"type": "module",
|
|
@@ -16,10 +16,6 @@
|
|
|
16
16
|
[](https://www.npmjs.com/package/@claude-flow/codex)
|
|
17
17
|
[](https://github.com/ruvnet/ruvector)
|
|
18
18
|
|
|
19
|
-
[](https://cognitum.one/appliance)
|
|
20
|
-
|
|
21
|
-
[](https://github.com/ruvnet/ruflo/issues/1967)
|
|
22
|
-
|
|
23
19
|
# Ruflo
|
|
24
20
|
|
|
25
21
|
**Multi-agent AI harness for Claude Code and Codex**
|
|
@@ -23,6 +23,14 @@ export declare function resolveWorkspaceFlag(raw: unknown): string | null;
|
|
|
23
23
|
* via the PID file.
|
|
24
24
|
*/
|
|
25
25
|
export declare function daemonCommandLineBelongsToWorkspace(commandLine: string, workspaceRoot: string): boolean;
|
|
26
|
+
/**
|
|
27
|
+
* #2356: extract the workspace root from a daemon process command line for the
|
|
28
|
+
* global `daemon status --all` view. The launcher always appends
|
|
29
|
+
* `--workspace <root>` as the FINAL argv entry (see startBackgroundDaemon), so
|
|
30
|
+
* we capture everything after it to end-of-line and strip trailing quotes.
|
|
31
|
+
* Returns null for pre-#1914 daemons that never stamped a workspace.
|
|
32
|
+
*/
|
|
33
|
+
export declare function extractWorkspaceFromDaemonLine(commandLine: string): string | null;
|
|
26
34
|
export declare const daemonCommand: Command;
|
|
27
35
|
export default daemonCommand;
|
|
28
36
|
//# sourceMappingURL=daemon.d.ts.map
|
|
@@ -21,6 +21,9 @@ const startCommand = {
|
|
|
21
21
|
{ name: 'sandbox', type: 'string', description: 'Default sandbox mode for headless workers', choices: ['strict', 'permissive', 'disabled'] },
|
|
22
22
|
{ name: 'max-cpu-load', type: 'string', description: 'Override maxCpuLoad resource threshold (e.g. 4.0)' },
|
|
23
23
|
{ name: 'min-free-memory', type: 'string', description: 'Override minFreeMemoryPercent resource threshold (e.g. 15)' },
|
|
24
|
+
// #2356: self-terminating lifecycle. Caps how long a forgotten daemon can
|
|
25
|
+
// keep dispatching headless worker sweeps. Default 12h (or RUFLO_DAEMON_TTL_SECS); 0 = run until stopped.
|
|
26
|
+
{ name: 'ttl', type: 'string', description: 'Max daemon age in seconds before graceful self-shutdown (0 = run until stopped; default 43200 = 12h)' },
|
|
24
27
|
// #1914: workspace root for this daemon. Set automatically when the
|
|
25
28
|
// background launcher forks the foreground child so the daemon process
|
|
26
29
|
// carries its workspace path in argv β `killStaleDaemons` then only
|
|
@@ -71,6 +74,18 @@ const startCommand = {
|
|
|
71
74
|
config.resourceThresholds = thresholds;
|
|
72
75
|
}
|
|
73
76
|
}
|
|
77
|
+
// #2356: parse --ttl (seconds β ms). Integer-only so 0 (disable) is valid;
|
|
78
|
+
// INT_RE forbids the decimals NUMERIC_RE allows, since a TTL is whole seconds.
|
|
79
|
+
const rawTtl = ctx.flags.ttl;
|
|
80
|
+
const INT_RE = /^\d+$/;
|
|
81
|
+
if (rawTtl !== undefined) {
|
|
82
|
+
if (INT_RE.test(rawTtl)) {
|
|
83
|
+
config.ttlMs = parseInt(rawTtl, 10) * 1000;
|
|
84
|
+
}
|
|
85
|
+
else if (!quiet) {
|
|
86
|
+
output.printWarning(`Ignoring invalid --ttl value: ${sanitize(rawTtl)}`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
74
89
|
// Check if background daemon already running (skip if we ARE the daemon process)
|
|
75
90
|
if (!isDaemonProcess) {
|
|
76
91
|
const bgPid = getBackgroundDaemonPid(projectRoot);
|
|
@@ -95,6 +110,7 @@ const startCommand = {
|
|
|
95
110
|
workers: ctx.flags.workers,
|
|
96
111
|
headless: ctx.flags.headless,
|
|
97
112
|
sandbox: ctx.flags.sandbox,
|
|
113
|
+
ttl: rawTtl,
|
|
98
114
|
});
|
|
99
115
|
}
|
|
100
116
|
// Foreground mode: run in current process (blocks terminal)
|
|
@@ -134,6 +150,9 @@ const startCommand = {
|
|
|
134
150
|
output.printBox([
|
|
135
151
|
`PID: ${status.pid}`,
|
|
136
152
|
`Started: ${status.startedAt?.toISOString()}`,
|
|
153
|
+
status.config.ttlMs > 0
|
|
154
|
+
? `TTL: ${Math.round(status.config.ttlMs / 3600000)}h (self-shutdown)`
|
|
155
|
+
: `TTL: off (runs until stopped)`,
|
|
137
156
|
`Workers: ${status.config.workers.filter(w => w.enabled).length} enabled`,
|
|
138
157
|
`Max Concurrent: ${status.config.maxConcurrent}`,
|
|
139
158
|
`Max CPU Load: ${status.config.resourceThresholds.maxCpuLoad}`,
|
|
@@ -243,8 +262,22 @@ export function resolveWorkspaceFlag(raw) {
|
|
|
243
262
|
export function daemonCommandLineBelongsToWorkspace(commandLine, workspaceRoot) {
|
|
244
263
|
return commandLine.replace(/[\s"']+$/u, '').endsWith(`--workspace ${workspaceRoot}`);
|
|
245
264
|
}
|
|
265
|
+
/**
|
|
266
|
+
* #2356: extract the workspace root from a daemon process command line for the
|
|
267
|
+
* global `daemon status --all` view. The launcher always appends
|
|
268
|
+
* `--workspace <root>` as the FINAL argv entry (see startBackgroundDaemon), so
|
|
269
|
+
* we capture everything after it to end-of-line and strip trailing quotes.
|
|
270
|
+
* Returns null for pre-#1914 daemons that never stamped a workspace.
|
|
271
|
+
*/
|
|
272
|
+
export function extractWorkspaceFromDaemonLine(commandLine) {
|
|
273
|
+
const m = commandLine.match(/--workspace\s+(.+?)\s*$/u);
|
|
274
|
+
if (!m)
|
|
275
|
+
return null;
|
|
276
|
+
const ws = m[1].replace(/["']+$/u, '').trim();
|
|
277
|
+
return ws.length > 0 ? ws : null;
|
|
278
|
+
}
|
|
246
279
|
async function startBackgroundDaemon(projectRoot, quiet, forwarded = {}) {
|
|
247
|
-
const { maxCpuLoad, minFreeMemory, workers, headless, sandbox } = forwarded;
|
|
280
|
+
const { maxCpuLoad, minFreeMemory, workers, headless, sandbox, ttl } = forwarded;
|
|
248
281
|
// Validate and resolve project root
|
|
249
282
|
const resolvedRoot = resolve(projectRoot);
|
|
250
283
|
validatePath(resolvedRoot, 'Project root');
|
|
@@ -312,6 +345,11 @@ async function startBackgroundDaemon(projectRoot, quiet, forwarded = {}) {
|
|
|
312
345
|
if (minFreeMemory && SPAWN_NUMERIC_RE.test(minFreeMemory)) {
|
|
313
346
|
forkArgs.push('--min-free-memory', minFreeMemory);
|
|
314
347
|
}
|
|
348
|
+
// #2356: forward the TTL so the background daemon enforces it too. Integer
|
|
349
|
+
// seconds only (incl. 0 to disable) β reject anything else before it hits argv.
|
|
350
|
+
if (typeof ttl === 'string' && /^\d+$/.test(ttl)) {
|
|
351
|
+
forkArgs.push('--ttl', ttl);
|
|
352
|
+
}
|
|
315
353
|
// #1968: forward worker-selection / sandbox flags. The previous launcher
|
|
316
354
|
// dropped these, so `daemon start --workers map` ran with the default
|
|
317
355
|
// five-worker set instead of just `map`. Validate each before passing
|
|
@@ -599,6 +637,116 @@ function isProcessRunning(pid) {
|
|
|
599
637
|
return false;
|
|
600
638
|
}
|
|
601
639
|
}
|
|
640
|
+
/**
|
|
641
|
+
* #2356: enumerate every running ruflo daemon across ALL workspaces. Reuses
|
|
642
|
+
* the same `ps`/`tasklist` scan as killStaleDaemons but, instead of killing,
|
|
643
|
+
* returns each live daemon's PID + workspace so `daemon status --all` can
|
|
644
|
+
* surface daemons leaked in other projects. Best-effort: any tooling failure
|
|
645
|
+
* yields an empty list (matching the kill-stale paths).
|
|
646
|
+
*/
|
|
647
|
+
async function scanRunningDaemons() {
|
|
648
|
+
const isWin = process.platform === 'win32';
|
|
649
|
+
try {
|
|
650
|
+
const { execFileSync } = await import('child_process');
|
|
651
|
+
const out = isWin
|
|
652
|
+
? execFileSync('tasklist', ['/v', '/fo', 'csv', '/nh'], { encoding: 'utf-8', timeout: 5000 })
|
|
653
|
+
: execFileSync('ps', ['-eo', 'pid,command'], { encoding: 'utf-8', timeout: 5000 });
|
|
654
|
+
const lines = out.split(/\r?\n/);
|
|
655
|
+
const found = [];
|
|
656
|
+
for (const line of lines) {
|
|
657
|
+
if (!line.includes('daemon start --foreground'))
|
|
658
|
+
continue;
|
|
659
|
+
if (!line.includes('claude-flow') && !line.includes('@claude-flow/cli'))
|
|
660
|
+
continue;
|
|
661
|
+
let pid;
|
|
662
|
+
let cmd;
|
|
663
|
+
if (isWin) {
|
|
664
|
+
// tasklist /fo csv: quoted fields; PID is field[1], Window Title is last.
|
|
665
|
+
const fields = line.split(/","/).map(f => f.replace(/^"|"$/g, ''));
|
|
666
|
+
pid = parseInt(fields[1] ?? '', 10);
|
|
667
|
+
cmd = fields[fields.length - 1] ?? line;
|
|
668
|
+
}
|
|
669
|
+
else {
|
|
670
|
+
pid = parseInt(line.trim().split(/\s+/)[0], 10);
|
|
671
|
+
cmd = line;
|
|
672
|
+
}
|
|
673
|
+
if (Number.isNaN(pid) || !isProcessRunning(pid))
|
|
674
|
+
continue;
|
|
675
|
+
found.push({ pid, workspace: extractWorkspaceFromDaemonLine(cmd) });
|
|
676
|
+
}
|
|
677
|
+
return found;
|
|
678
|
+
}
|
|
679
|
+
catch {
|
|
680
|
+
return [];
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
/**
|
|
684
|
+
* #2356: render the global `daemon status --all` view. For each running daemon
|
|
685
|
+
* it reads that workspace's daemon-state.json to show age + configured TTL,
|
|
686
|
+
* and flags any daemon that has outlived its TTL (or 12h when TTL is unknown)
|
|
687
|
+
* as stale β the visibility that was missing when leaked daemons ran for days.
|
|
688
|
+
*/
|
|
689
|
+
async function renderAllDaemonsStatus() {
|
|
690
|
+
const daemons = await scanRunningDaemons();
|
|
691
|
+
output.writeln();
|
|
692
|
+
if (daemons.length === 0) {
|
|
693
|
+
output.printBox('No ruflo daemons are running in any workspace.', 'RuFlo Daemons (all workspaces)');
|
|
694
|
+
return { success: true, data: { daemons: [] } };
|
|
695
|
+
}
|
|
696
|
+
const now = Date.now();
|
|
697
|
+
const TWELVE_HOURS_MS = 12 * 60 * 60 * 1000;
|
|
698
|
+
let staleCount = 0;
|
|
699
|
+
const rows = daemons.map(d => {
|
|
700
|
+
let startedAt;
|
|
701
|
+
let ttlMs;
|
|
702
|
+
if (d.workspace) {
|
|
703
|
+
try {
|
|
704
|
+
const statePath = join(d.workspace, '.claude-flow', 'daemon-state.json');
|
|
705
|
+
if (fs.existsSync(statePath)) {
|
|
706
|
+
const st = JSON.parse(fs.readFileSync(statePath, 'utf-8'));
|
|
707
|
+
if (st?.startedAt)
|
|
708
|
+
startedAt = new Date(st.startedAt);
|
|
709
|
+
if (typeof st?.config?.ttlMs === 'number')
|
|
710
|
+
ttlMs = st.config.ttlMs;
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
catch { /* unreadable/partial state β show what we have */ }
|
|
714
|
+
}
|
|
715
|
+
const ageMs = startedAt ? now - startedAt.getTime() : undefined;
|
|
716
|
+
const overTtl = ttlMs !== undefined && ttlMs > 0 && ageMs !== undefined && ageMs > ttlMs;
|
|
717
|
+
const overTwelveH = ageMs !== undefined && ageMs > TWELVE_HOURS_MS;
|
|
718
|
+
const isStale = overTtl || overTwelveH;
|
|
719
|
+
if (isStale)
|
|
720
|
+
staleCount++;
|
|
721
|
+
const ageText = ageMs !== undefined ? formatTimeAgo(startedAt).replace(' ago', '') : '?';
|
|
722
|
+
const ttlText = ttlMs !== undefined
|
|
723
|
+
? (ttlMs > 0 ? `${Math.round(ttlMs / 3600000)}h` : 'off')
|
|
724
|
+
: '?';
|
|
725
|
+
return {
|
|
726
|
+
pid: isStale ? output.warning(String(d.pid)) : String(d.pid),
|
|
727
|
+
workspace: d.workspace ?? output.dim('(unknown)'),
|
|
728
|
+
age: isStale ? output.warning(ageText) : ageText,
|
|
729
|
+
ttl: ttlText === 'off' ? output.dim('off') : ttlText,
|
|
730
|
+
};
|
|
731
|
+
});
|
|
732
|
+
output.printTable({
|
|
733
|
+
columns: [
|
|
734
|
+
{ key: 'pid', header: 'PID', width: 8 },
|
|
735
|
+
{ key: 'age', header: 'Age', width: 8 },
|
|
736
|
+
{ key: 'ttl', header: 'TTL', width: 6 },
|
|
737
|
+
{ key: 'workspace', header: 'Workspace', width: 50 },
|
|
738
|
+
],
|
|
739
|
+
data: rows,
|
|
740
|
+
});
|
|
741
|
+
output.writeln();
|
|
742
|
+
if (staleCount > 0) {
|
|
743
|
+
output.printWarning(`${staleCount} daemon(s) have outlived their TTL (or have run >12h). Stop one with: cd <workspace> && ruflo daemon stop`);
|
|
744
|
+
}
|
|
745
|
+
else {
|
|
746
|
+
output.printInfo(`${daemons.length} daemon(s) running, all within their TTL.`);
|
|
747
|
+
}
|
|
748
|
+
return { success: true, data: { daemons: rows.length } };
|
|
749
|
+
}
|
|
602
750
|
// Status subcommand
|
|
603
751
|
const statusCommand = {
|
|
604
752
|
name: 'status',
|
|
@@ -606,15 +754,24 @@ const statusCommand = {
|
|
|
606
754
|
options: [
|
|
607
755
|
{ name: 'verbose', short: 'v', type: 'boolean', description: 'Show detailed worker statistics' },
|
|
608
756
|
{ name: 'show-modes', type: 'boolean', description: 'Show worker execution modes (local/headless) and sandbox settings' },
|
|
757
|
+
// #2356: the default status reads only the CURRENT workspace, so a daemon
|
|
758
|
+
// leaked in another project is invisible. --all scans every running ruflo
|
|
759
|
+
// daemon across all workspaces (the global view that surfaces leaks).
|
|
760
|
+
{ name: 'all', short: 'a', type: 'boolean', description: 'List ruflo daemons across ALL workspaces (global view β surfaces leaked daemons)' },
|
|
609
761
|
],
|
|
610
762
|
examples: [
|
|
611
763
|
{ command: 'claude-flow daemon status', description: 'Show daemon status' },
|
|
612
764
|
{ command: 'claude-flow daemon status -v', description: 'Show detailed status' },
|
|
613
765
|
{ command: 'claude-flow daemon status --show-modes', description: 'Show worker execution modes' },
|
|
766
|
+
{ command: 'claude-flow daemon status --all', description: 'List daemons across all workspaces' },
|
|
614
767
|
],
|
|
615
768
|
action: async (ctx) => {
|
|
616
769
|
const verbose = ctx.flags.verbose;
|
|
617
770
|
const showModes = ctx.flags['show-modes'];
|
|
771
|
+
// #2356: global view across every workspace, not just cwd.
|
|
772
|
+
if (ctx.flags.all) {
|
|
773
|
+
return renderAllDaemonsStatus();
|
|
774
|
+
}
|
|
618
775
|
const projectRoot = process.cwd();
|
|
619
776
|
try {
|
|
620
777
|
const daemon = getDaemon(projectRoot);
|
|
@@ -633,6 +790,9 @@ const statusCommand = {
|
|
|
633
790
|
`Status: ${statusIcon} ${statusText}${mode}`,
|
|
634
791
|
`PID: ${displayPid}`,
|
|
635
792
|
status.startedAt ? `Started: ${status.startedAt.toISOString()}` : '',
|
|
793
|
+
status.config.ttlMs > 0
|
|
794
|
+
? `TTL: ${Math.round(status.config.ttlMs / 3600000)}h (self-shutdown)`
|
|
795
|
+
: `TTL: ${output.dim('off (runs until stopped)')}`,
|
|
636
796
|
`Workers Enabled: ${status.config.workers.filter(w => w.enabled).length}`,
|
|
637
797
|
`Max Concurrent: ${status.config.maxConcurrent}`,
|
|
638
798
|
`Max CPU Load: ${status.config.resourceThresholds.maxCpuLoad}`,
|
|
@@ -399,9 +399,20 @@ const postEditCommand = {
|
|
|
399
399
|
metrics,
|
|
400
400
|
timestamp: Date.now(),
|
|
401
401
|
});
|
|
402
|
+
// #2352: the MCP handler returns `{success: false, error: "..."}` on
|
|
403
|
+
// validation failure (e.g. unsupported path shape) without throwing.
|
|
404
|
+
// Surface that explicitly instead of always printing the success line β
|
|
405
|
+
// Windows users were seeing `[OK]` while nothing reached the learning
|
|
406
|
+
// pipeline because absolute paths were rejected upstream.
|
|
407
|
+
const mcpFailed = result && result.success === false;
|
|
408
|
+
const mcpError = result?.error;
|
|
402
409
|
if (ctx.flags.format === 'json') {
|
|
403
410
|
output.printJson(result);
|
|
404
|
-
return { success:
|
|
411
|
+
return { success: !mcpFailed, exitCode: mcpFailed ? 1 : 0, data: result };
|
|
412
|
+
}
|
|
413
|
+
if (mcpFailed) {
|
|
414
|
+
output.printError(`Post-edit hook failed: ${mcpError || 'unknown error'}`);
|
|
415
|
+
return { success: false, exitCode: 1 };
|
|
405
416
|
}
|
|
406
417
|
output.writeln();
|
|
407
418
|
output.printSuccess(`Outcome recorded for ${filePath}`);
|
|
@@ -190,6 +190,7 @@ const initAction = async (ctx) => {
|
|
|
190
190
|
// set still got `~/.claude/CLAUDE.md` modified. Read the real key.
|
|
191
191
|
const noGlobal = ctx.flags['no-global'] === true || ctx.flags['global'] === false;
|
|
192
192
|
const allAgents = ctx.flags['all-agents'];
|
|
193
|
+
const cloudMcp = ctx.flags['cloud-mcp'];
|
|
193
194
|
const codexMode = ctx.flags.codex;
|
|
194
195
|
const dualMode = ctx.flags.dual;
|
|
195
196
|
const cwd = ctx.cwd;
|
|
@@ -230,6 +231,12 @@ const initAction = async (ctx) => {
|
|
|
230
231
|
}
|
|
231
232
|
else if (full) {
|
|
232
233
|
options = { ...FULL_INIT_OPTIONS, targetDir: cwd, force };
|
|
234
|
+
// #2356: keep auth-gated cloud MCP servers opt-in even under --full. They
|
|
235
|
+
// require a login, get committed into .mcp.json, and add per-session MCP
|
|
236
|
+
// tool-definition token cost. --cloud-mcp restores the all-three behavior.
|
|
237
|
+
if (!cloudMcp) {
|
|
238
|
+
options.mcp = { ...options.mcp, ruvSwarm: false, flowNexus: false };
|
|
239
|
+
}
|
|
233
240
|
}
|
|
234
241
|
else {
|
|
235
242
|
options = { ...DEFAULT_INIT_OPTIONS, targetDir: cwd, force };
|
|
@@ -773,7 +780,13 @@ const hooksCommand = {
|
|
|
773
780
|
skills: false,
|
|
774
781
|
commands: false,
|
|
775
782
|
agents: false,
|
|
776
|
-
|
|
783
|
+
// #2350: helpers MUST ship with the hooks subcommand. The hook entries
|
|
784
|
+
// in settings.json point at `.claude/helpers/hook-handler.cjs`; if
|
|
785
|
+
// that file doesn't exist, settings-generator (#1744 fix) drops the
|
|
786
|
+
// hooks block entirely β so the one subcommand whose stated purpose
|
|
787
|
+
// is "Initialize only hooks configuration" produced settings.json
|
|
788
|
+
// with no `hooks` key while reporting "N hooks enabled".
|
|
789
|
+
helpers: true,
|
|
777
790
|
statusline: false,
|
|
778
791
|
mcp: false,
|
|
779
792
|
runtime: false,
|
|
@@ -964,6 +977,16 @@ export const initCommand = {
|
|
|
964
977
|
type: 'boolean',
|
|
965
978
|
default: false,
|
|
966
979
|
},
|
|
980
|
+
{
|
|
981
|
+
// #2356: under --full, the auth-gated cloud MCP servers (ruv-swarm,
|
|
982
|
+
// flow-nexus) get written into a committed .mcp.json and add MCP
|
|
983
|
+
// tool-definition token cost every session. Keep them opt-in even with
|
|
984
|
+
// --full; pass --cloud-mcp to register them.
|
|
985
|
+
name: 'cloud-mcp',
|
|
986
|
+
description: 'Register auth-gated cloud MCP servers (ruv-swarm, flow-nexus) in .mcp.json (only relevant with --full)',
|
|
987
|
+
type: 'boolean',
|
|
988
|
+
default: false,
|
|
989
|
+
},
|
|
967
990
|
{
|
|
968
991
|
name: 'skip-claude',
|
|
969
992
|
description: 'Skip .claude/ directory creation (runtime only)',
|
|
@@ -411,11 +411,21 @@ const statusCommand = {
|
|
|
411
411
|
details: `${stats.patternsLearned} patterns stored`,
|
|
412
412
|
},
|
|
413
413
|
{
|
|
414
|
+
// #2356: distinguish "loaded in this process" from "installed but
|
|
415
|
+
// not yet loaded" from "not installed". Previously `neural status`
|
|
416
|
+
// always printed "Not loaded" because it never warms the lazy
|
|
417
|
+
// singleton β a false negative even when @ruvector/core is present.
|
|
414
418
|
component: 'HNSW Index',
|
|
415
|
-
status: hnswStatus.
|
|
416
|
-
|
|
419
|
+
status: hnswStatus.initialized
|
|
420
|
+
? output.success('Ready')
|
|
421
|
+
: hnswStatus.available
|
|
422
|
+
? output.info('Available')
|
|
423
|
+
: output.dim('Not installed'),
|
|
424
|
+
details: hnswStatus.initialized
|
|
417
425
|
? `${hnswStatus.entryCount} vectors, ${hnswStatus.dimensions}-dim`
|
|
418
|
-
:
|
|
426
|
+
: hnswStatus.available
|
|
427
|
+
? '@ruvector/core installed (loads on first vector search)'
|
|
428
|
+
: '@ruvector/core not available',
|
|
419
429
|
},
|
|
420
430
|
{
|
|
421
431
|
component: 'Embedding Model',
|
|
@@ -172,11 +172,15 @@ function setupAndBoundary() {
|
|
|
172
172
|
return `## Setup
|
|
173
173
|
|
|
174
174
|
\`\`\`bash
|
|
175
|
-
claude mcp add claude-flow -- npx -y @
|
|
176
|
-
npx @
|
|
177
|
-
npx @claude-flow/cli@latest doctor --fix
|
|
175
|
+
claude mcp add claude-flow -- npx -y ruflo@latest mcp start
|
|
176
|
+
npx ruflo@latest doctor --fix
|
|
178
177
|
\`\`\`
|
|
179
178
|
|
|
179
|
+
> The background \`daemon\` is optional. It runs interval workers that each spawn
|
|
180
|
+
> a headless \`claude\` session, so it consumes tokens continuously. Start it only
|
|
181
|
+
> if you want those sweeps: \`npx ruflo@latest daemon start\` (self-stops after 12h
|
|
182
|
+
> by default; \`--ttl 0\` to disable, \`daemon status --all\` to audit running daemons).
|
|
183
|
+
|
|
180
184
|
**Agent tool** handles execution (agents, files, code, git). **MCP tools** handle coordination (swarm, memory, hooks). **CLI** is the same via Bash.`;
|
|
181
185
|
}
|
|
182
186
|
function buildAndTest() {
|
|
@@ -22,6 +22,7 @@ export interface AgentRecord {
|
|
|
22
22
|
lastResult?: Record<string, unknown>;
|
|
23
23
|
}
|
|
24
24
|
export declare const DEFAULT_ANTHROPIC_MODEL = "claude-sonnet-4-6";
|
|
25
|
+
export declare function modelRejectsSamplingParams(model: string): boolean;
|
|
25
26
|
export interface AnthropicCallInput {
|
|
26
27
|
prompt: string;
|
|
27
28
|
systemPrompt?: string;
|
|
@@ -46,6 +46,15 @@ const MODEL_MAP = {
|
|
|
46
46
|
'opus-4.7': 'claude-opus-4-7',
|
|
47
47
|
inherit: DEFAULT_ANTHROPIC_MODEL,
|
|
48
48
|
};
|
|
49
|
+
// #2357 β the adaptive-thinking family (Fable 5, Opus 4.8, Opus 4.7) removed
|
|
50
|
+
// the sampling parameters (temperature/top_p/top_k); the Anthropic API
|
|
51
|
+
// returns 400 "Extra inputs are not permitted" when any is present.
|
|
52
|
+
// Prefix-match so dated snapshots (e.g. claude-opus-4-8-YYYYMMDD) are
|
|
53
|
+
// covered. Applies only to the direct Anthropic path β the Ollama/OpenRouter
|
|
54
|
+
// OpenAI-compat paths accept temperature and are unchanged.
|
|
55
|
+
export function modelRejectsSamplingParams(model) {
|
|
56
|
+
return /^claude-(fable-5|opus-4-8|opus-4-7)/.test(model);
|
|
57
|
+
}
|
|
49
58
|
/**
|
|
50
59
|
* Generic Anthropic Messages API call. No agent registry coupling β used
|
|
51
60
|
* by agent_execute (with the agent's configured model) and by the WASM
|
|
@@ -76,7 +85,11 @@ export async function callAnthropicMessages(input) {
|
|
|
76
85
|
apiKey: openrouterKey,
|
|
77
86
|
baseUrl: process.env.OPENROUTER_BASE_URL || 'https://openrouter.ai/api',
|
|
78
87
|
providerLabel: 'openrouter',
|
|
79
|
-
|
|
88
|
+
// #2357 Finding C: anthropic/claude-3.5-sonnet was retired Oct 2025.
|
|
89
|
+
// Default to the same canonical family the rest of the resolver uses
|
|
90
|
+
// (MODEL_MAP). `OPENROUTER_DEFAULT_MODEL` still wins for callers who
|
|
91
|
+
// want to pin a specific OpenRouter slug.
|
|
92
|
+
defaultModel: process.env.OPENROUTER_DEFAULT_MODEL || 'anthropic/claude-sonnet-4-6',
|
|
80
93
|
});
|
|
81
94
|
}
|
|
82
95
|
if (useOllama && ollamaKey) {
|
|
@@ -103,7 +116,13 @@ export async function callAnthropicMessages(input) {
|
|
|
103
116
|
body: JSON.stringify({
|
|
104
117
|
model,
|
|
105
118
|
max_tokens: input.maxTokens || 1024,
|
|
106
|
-
|
|
119
|
+
// #2357 β omit temperature for models that reject sampling params
|
|
120
|
+
// (Fable 5 / Opus 4.8 / Opus 4.7 β 400 "Extra inputs are not
|
|
121
|
+
// permitted"); keep the 0.7 default unchanged for models that still
|
|
122
|
+
// accept it (sonnet / haiku / opus β€4.6).
|
|
123
|
+
...(modelRejectsSamplingParams(model)
|
|
124
|
+
? {}
|
|
125
|
+
: { temperature: typeof input.temperature === 'number' ? input.temperature : 0.7 }),
|
|
107
126
|
// #8 prompt caching (hermes-agent pattern): mark the (often large,
|
|
108
127
|
// stable) system prompt as an ephemeral cache breakpoint so repeated
|
|
109
128
|
// agent_execute calls with the same system prompt hit Anthropic's
|
|
@@ -299,13 +318,15 @@ async function callOpenAICompat(input) {
|
|
|
299
318
|
function resolveOpenAICompatModel(input, fallback) {
|
|
300
319
|
if (!input)
|
|
301
320
|
return fallback;
|
|
302
|
-
// Logical Claude names β OpenRouter Anthropic-vendored names
|
|
321
|
+
// Logical Claude names β OpenRouter Anthropic-vendored names.
|
|
322
|
+
// #2357 Finding C: the 3.5 / 3-opus slugs were retired Oct 2025; align with
|
|
323
|
+
// MODEL_MAP (claude-haiku-4-5 / claude-sonnet-4-6 / claude-opus-4-8).
|
|
303
324
|
if (input === 'haiku')
|
|
304
|
-
return 'anthropic/claude-
|
|
325
|
+
return 'anthropic/claude-haiku-4-5';
|
|
305
326
|
if (input === 'sonnet' || input === 'inherit')
|
|
306
|
-
return 'anthropic/claude-
|
|
327
|
+
return 'anthropic/claude-sonnet-4-6';
|
|
307
328
|
if (input === 'opus')
|
|
308
|
-
return 'anthropic/claude-
|
|
329
|
+
return 'anthropic/claude-opus-4-8';
|
|
309
330
|
return input;
|
|
310
331
|
}
|
|
311
332
|
function resolveOllamaModel(input) {
|
|
@@ -2665,7 +2665,81 @@ export const hooksTrajectoryEnd = {
|
|
|
2665
2665
|
}
|
|
2666
2666
|
catch { /* intelligence module not loadable β keep sona-only behaviour */ }
|
|
2667
2667
|
}
|
|
2668
|
+
// #2351: when an agent calls trajectory-end with no recorded steps but a
|
|
2669
|
+
// non-empty `feedback` string, the feedback was previously dropped on the
|
|
2670
|
+
// floor β `patternsExtracted` reported 0 and `pattern-search` never
|
|
2671
|
+
// surfaced it. Step-less trajectories are the common case for LLM agents
|
|
2672
|
+
// (nothing forces step logging mid-task), and feedback is often the most
|
|
2673
|
+
// distilled lesson available. Route it through the same store + embed
|
|
2674
|
+
// path that pattern-store uses so it becomes searchable. Best-effort:
|
|
2675
|
+
// failures here must not turn the trajectory-end call itself into a
|
|
2676
|
+
// failure β the trajectory record was already persisted above.
|
|
2677
|
+
let feedbackDistilled = { stored: false };
|
|
2678
|
+
const hasSteps = !!trajectory && trajectory.steps.length > 0;
|
|
2679
|
+
const trimmedFeedback = typeof feedback === 'string' ? feedback.trim() : '';
|
|
2680
|
+
if (trajectory && !hasSteps && trimmedFeedback.length > 0) {
|
|
2681
|
+
const distilledPatternId = `pattern-feedback-${trajectoryId}-${Date.now()}`;
|
|
2682
|
+
const patternMetadata = {
|
|
2683
|
+
sourceTrajectoryId: trajectoryId,
|
|
2684
|
+
task: trajectory.task,
|
|
2685
|
+
agent: trajectory.agent,
|
|
2686
|
+
outcome: success ? 'success' : 'failure',
|
|
2687
|
+
distilledFrom: 'trajectory-end-feedback',
|
|
2688
|
+
};
|
|
2689
|
+
// Modest default confidence β step-less feedback hasn't been validated
|
|
2690
|
+
// by execution evidence the way a multi-step trajectory has.
|
|
2691
|
+
const feedbackConfidence = success ? 0.6 : 0.4;
|
|
2692
|
+
try {
|
|
2693
|
+
const bridge = await import('../memory/memory-bridge.js');
|
|
2694
|
+
const rb = await bridge.bridgeStorePattern({
|
|
2695
|
+
pattern: trimmedFeedback,
|
|
2696
|
+
type: 'trajectory-feedback',
|
|
2697
|
+
confidence: feedbackConfidence,
|
|
2698
|
+
metadata: patternMetadata,
|
|
2699
|
+
});
|
|
2700
|
+
if (rb?.success) {
|
|
2701
|
+
feedbackDistilled = { stored: true, patternId: rb.patternId, controller: rb.controller };
|
|
2702
|
+
}
|
|
2703
|
+
}
|
|
2704
|
+
catch {
|
|
2705
|
+
// Bridge unavailable β fall through to direct store
|
|
2706
|
+
}
|
|
2707
|
+
if (!feedbackDistilled.stored) {
|
|
2708
|
+
try {
|
|
2709
|
+
const storeFn = await getRealStoreFunction();
|
|
2710
|
+
if (storeFn) {
|
|
2711
|
+
const r = await storeFn({
|
|
2712
|
+
key: distilledPatternId,
|
|
2713
|
+
value: JSON.stringify({
|
|
2714
|
+
pattern: trimmedFeedback,
|
|
2715
|
+
type: 'trajectory-feedback',
|
|
2716
|
+
confidence: feedbackConfidence,
|
|
2717
|
+
metadata: patternMetadata,
|
|
2718
|
+
timestamp: endedAt,
|
|
2719
|
+
}),
|
|
2720
|
+
namespace: 'pattern',
|
|
2721
|
+
generateEmbeddingFlag: true,
|
|
2722
|
+
tags: [
|
|
2723
|
+
'trajectory-feedback',
|
|
2724
|
+
success ? 'success' : 'failure',
|
|
2725
|
+
`confidence-${Math.round(feedbackConfidence * 100)}`,
|
|
2726
|
+
],
|
|
2727
|
+
});
|
|
2728
|
+
if (r?.success) {
|
|
2729
|
+
feedbackDistilled = { stored: true, patternId: r.id || distilledPatternId, controller: 'store-fallback' };
|
|
2730
|
+
}
|
|
2731
|
+
}
|
|
2732
|
+
}
|
|
2733
|
+
catch {
|
|
2734
|
+
// Both paths failed β leave feedbackDistilled.stored = false.
|
|
2735
|
+
}
|
|
2736
|
+
}
|
|
2737
|
+
}
|
|
2668
2738
|
const learningTimeMs = Date.now() - startTime;
|
|
2739
|
+
// patternsExtracted now reflects either recorded steps (the original
|
|
2740
|
+
// semantics) OR a distilled feedback pattern (#2351), so step-less
|
|
2741
|
+
// trajectories with useful feedback no longer report 0.
|
|
2742
|
+
const patternsExtracted = (trajectory?.steps.length || 0) + (feedbackDistilled.stored ? 1 : 0);
|
|
2669
2743
|
return {
|
|
2670
2744
|
trajectoryId,
|
|
2671
2745
|
success,
|
|
@@ -2678,7 +2752,11 @@ export const hooksTrajectoryEnd = {
|
|
|
2678
2752
|
sonaConfidence: sonaResult.confidence || undefined,
|
|
2679
2753
|
ewcConsolidation: ewcResult.consolidated,
|
|
2680
2754
|
ewcPenalty: ewcResult.penalty || undefined,
|
|
2681
|
-
patternsExtracted
|
|
2755
|
+
patternsExtracted,
|
|
2756
|
+
feedbackDistilled: feedbackDistilled.stored ? {
|
|
2757
|
+
patternId: feedbackDistilled.patternId,
|
|
2758
|
+
controller: feedbackDistilled.controller,
|
|
2759
|
+
} : undefined,
|
|
2682
2760
|
learningTimeMs,
|
|
2683
2761
|
globalStatsTrajectoriesDelta: globalStatsDelta, // Round B: was 0, now reflects
|
|
2684
2762
|
},
|
|
@@ -10,7 +10,30 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import * as fs from 'fs';
|
|
12
12
|
import * as path from 'path';
|
|
13
|
+
import { createRequire } from 'node:module';
|
|
13
14
|
import { readFileMaybeEncrypted, writeFileRestricted } from '../fs-secure.js';
|
|
15
|
+
/**
|
|
16
|
+
* #2356 β cached, synchronous capability probe for @ruvector/core. `getHNSWStatus`
|
|
17
|
+
* is sync and is called by `neural status` in a fresh process that never warms
|
|
18
|
+
* the lazy HNSW singleton, so reporting availability off the warm singleton
|
|
19
|
+
* alone produced a false "Not loaded β @ruvector/core not available" even when
|
|
20
|
+
* the package is installed and exposes VectorDb. Resolving the module (without
|
|
21
|
+
* importing/initializing it) is a faithful, cheap availability signal.
|
|
22
|
+
*/
|
|
23
|
+
let _ruvectorCoreResolvable;
|
|
24
|
+
function isRuvectorCoreResolvable() {
|
|
25
|
+
if (_ruvectorCoreResolvable !== undefined)
|
|
26
|
+
return _ruvectorCoreResolvable;
|
|
27
|
+
try {
|
|
28
|
+
const req = createRequire(import.meta.url);
|
|
29
|
+
req.resolve('@ruvector/core');
|
|
30
|
+
_ruvectorCoreResolvable = true;
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
_ruvectorCoreResolvable = false;
|
|
34
|
+
}
|
|
35
|
+
return _ruvectorCoreResolvable;
|
|
36
|
+
}
|
|
14
37
|
/**
|
|
15
38
|
* #1854: previously every site that needed the memory directory hardcoded
|
|
16
39
|
* `getMemoryRoot()`, so the documented config entry
|
|
@@ -671,8 +694,13 @@ export function getHNSWStatus() {
|
|
|
671
694
|
dimensions: hnswIndex?.dimensions ?? 384
|
|
672
695
|
};
|
|
673
696
|
}
|
|
697
|
+
// #2356: `available` now reflects real capability (index already loaded OR
|
|
698
|
+
// @ruvector/core installed and resolvable), not merely whether the lazy
|
|
699
|
+
// singleton happens to be warm in this process. `initialized` still reports
|
|
700
|
+
// whether the in-process index is actually loaded, so callers can tell
|
|
701
|
+
// "installed but not yet loaded" apart from "loaded".
|
|
674
702
|
return {
|
|
675
|
-
available: hnswIndex !== null,
|
|
703
|
+
available: hnswIndex !== null || isRuvectorCoreResolvable(),
|
|
676
704
|
initialized: hnswIndex?.initialized ?? false,
|
|
677
705
|
entryCount: hnswIndex?.entries.size ?? 0,
|
|
678
706
|
dimensions: hnswIndex?.dimensions ?? 384
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Neural routing scaffold β `@ruvector/tiny-dancer` FastGRNN seam (#2334 Phase 1)
|
|
3
|
+
*
|
|
4
|
+
* Wires the optional neural path that ADR-026 originally described, behind a
|
|
5
|
+
* double gate that is OFF by default:
|
|
6
|
+
*
|
|
7
|
+
* CLAUDE_FLOW_ROUTER_NEURAL=1 β opt in to the neural path
|
|
8
|
+
* CLAUDE_FLOW_ROUTER_MODEL_PATH=<x.safetensors> β trained FastGRNN artifact
|
|
9
|
+
*
|
|
10
|
+
* Both must be set; otherwise `tryNeuralRoute` returns `null` immediately and
|
|
11
|
+
* the caller stays on the shipped heuristic + Thompson-bandit path. When the
|
|
12
|
+
* gate is open but anything fails (package not installed β it is an
|
|
13
|
+
* optionalDependency per ADR-124 β artifact missing/incompatible, runtime
|
|
14
|
+
* error), this module degrades gracefully: it returns `null`, never throws,
|
|
15
|
+
* and the caller reports `routedBy: 'bandit-fallback'` so the active path is
|
|
16
|
+
* observable rather than inferred from import success (ADR-086/074).
|
|
17
|
+
*
|
|
18
|
+
* Candidate modeling (#2334 Q3, provisional): the 3 model tiers are encoded as
|
|
19
|
+
* fixed candidates with deterministic placeholder embeddings (orthogonal-ish
|
|
20
|
+
* one-hot-block vectors). This is explicitly provisional β the trained Phase 2
|
|
21
|
+
* artifact defines what candidate embeddings mean, and this encoding is the
|
|
22
|
+
* scaffolding default until the maintainers answer #2334's candidate-modeling
|
|
23
|
+
* question. Until a real artifact exists the gate stays closed in practice, so
|
|
24
|
+
* the placeholder never influences routing.
|
|
25
|
+
*
|
|
26
|
+
* @module neural-router
|
|
27
|
+
*/
|
|
28
|
+
/** The three routable tiers β 'inherit' is never a neural candidate. */
|
|
29
|
+
export type NeuralRoutableModel = 'haiku' | 'sonnet' | 'opus';
|
|
30
|
+
export interface NeuralRouteDecision {
|
|
31
|
+
model: NeuralRoutableModel;
|
|
32
|
+
confidence: number;
|
|
33
|
+
uncertainty: number;
|
|
34
|
+
inferenceTimeUs: number;
|
|
35
|
+
}
|
|
36
|
+
/** True when the user has opted in AND pointed at a model artifact. */
|
|
37
|
+
export declare function neuralRoutingEnabled(): boolean;
|
|
38
|
+
/** Reset cached state β for tests. */
|
|
39
|
+
export declare function resetNeuralRouter(): void;
|
|
40
|
+
/**
|
|
41
|
+
* Attempt a neural routing decision for the given task embedding.
|
|
42
|
+
*
|
|
43
|
+
* Returns `null` (never throws) when the gate is closed, the package or
|
|
44
|
+
* artifact is unavailable, or inference fails β callers fall back to the
|
|
45
|
+
* bandit and report `routedBy: 'bandit-fallback'` (when the gate was open)
|
|
46
|
+
* or `'heuristic'` (when it never was).
|
|
47
|
+
*/
|
|
48
|
+
export declare function tryNeuralRoute(embedding: number[]): Promise<NeuralRouteDecision | null>;
|
|
49
|
+
//# sourceMappingURL=neural-router.d.ts.map
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Neural routing scaffold β `@ruvector/tiny-dancer` FastGRNN seam (#2334 Phase 1)
|
|
3
|
+
*
|
|
4
|
+
* Wires the optional neural path that ADR-026 originally described, behind a
|
|
5
|
+
* double gate that is OFF by default:
|
|
6
|
+
*
|
|
7
|
+
* CLAUDE_FLOW_ROUTER_NEURAL=1 β opt in to the neural path
|
|
8
|
+
* CLAUDE_FLOW_ROUTER_MODEL_PATH=<x.safetensors> β trained FastGRNN artifact
|
|
9
|
+
*
|
|
10
|
+
* Both must be set; otherwise `tryNeuralRoute` returns `null` immediately and
|
|
11
|
+
* the caller stays on the shipped heuristic + Thompson-bandit path. When the
|
|
12
|
+
* gate is open but anything fails (package not installed β it is an
|
|
13
|
+
* optionalDependency per ADR-124 β artifact missing/incompatible, runtime
|
|
14
|
+
* error), this module degrades gracefully: it returns `null`, never throws,
|
|
15
|
+
* and the caller reports `routedBy: 'bandit-fallback'` so the active path is
|
|
16
|
+
* observable rather than inferred from import success (ADR-086/074).
|
|
17
|
+
*
|
|
18
|
+
* Candidate modeling (#2334 Q3, provisional): the 3 model tiers are encoded as
|
|
19
|
+
* fixed candidates with deterministic placeholder embeddings (orthogonal-ish
|
|
20
|
+
* one-hot-block vectors). This is explicitly provisional β the trained Phase 2
|
|
21
|
+
* artifact defines what candidate embeddings mean, and this encoding is the
|
|
22
|
+
* scaffolding default until the maintainers answer #2334's candidate-modeling
|
|
23
|
+
* question. Until a real artifact exists the gate stays closed in practice, so
|
|
24
|
+
* the placeholder never influences routing.
|
|
25
|
+
*
|
|
26
|
+
* @module neural-router
|
|
27
|
+
*/
|
|
28
|
+
import { existsSync } from 'fs';
|
|
29
|
+
// ============================================================================
|
|
30
|
+
// Gate & lifecycle
|
|
31
|
+
// ============================================================================
|
|
32
|
+
/** True when the user has opted in AND pointed at a model artifact. */
|
|
33
|
+
export function neuralRoutingEnabled() {
|
|
34
|
+
return process.env.CLAUDE_FLOW_ROUTER_NEURAL === '1'
|
|
35
|
+
&& !!process.env.CLAUDE_FLOW_ROUTER_MODEL_PATH;
|
|
36
|
+
}
|
|
37
|
+
// Cached router instance + a sticky failure latch so a broken install/artifact
|
|
38
|
+
// costs one failed load, not one per routing call.
|
|
39
|
+
let routerInstance = null;
|
|
40
|
+
let loadFailed = false;
|
|
41
|
+
/** Reset cached state β for tests. */
|
|
42
|
+
export function resetNeuralRouter() {
|
|
43
|
+
routerInstance = null;
|
|
44
|
+
loadFailed = false;
|
|
45
|
+
}
|
|
46
|
+
async function loadRouter() {
|
|
47
|
+
if (routerInstance)
|
|
48
|
+
return routerInstance;
|
|
49
|
+
if (loadFailed)
|
|
50
|
+
return null;
|
|
51
|
+
const modelPath = process.env.CLAUDE_FLOW_ROUTER_MODEL_PATH;
|
|
52
|
+
if (!modelPath || !existsSync(modelPath)) {
|
|
53
|
+
loadFailed = true;
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
try {
|
|
57
|
+
// Dynamic import of an optionalDependency (ADR-124): absent on installs
|
|
58
|
+
// where the native binding failed or was skipped β degrade, don't throw.
|
|
59
|
+
const mod = await import('@ruvector/tiny-dancer');
|
|
60
|
+
const RouterCtor = mod.Router;
|
|
61
|
+
if (!RouterCtor) {
|
|
62
|
+
loadFailed = true;
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
routerInstance = new RouterCtor({ modelPath });
|
|
66
|
+
return routerInstance;
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
loadFailed = true;
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
// ============================================================================
|
|
74
|
+
// Candidate encoding (provisional β see header + #2334 Q3)
|
|
75
|
+
// ============================================================================
|
|
76
|
+
const TIER_ORDER = ['haiku', 'sonnet', 'opus'];
|
|
77
|
+
/**
|
|
78
|
+
* Deterministic placeholder embedding for a tier candidate: a block one-hot
|
|
79
|
+
* over the embedding dimensionality. Replaced by whatever the Phase 2 trained
|
|
80
|
+
* artifact defines as candidate space.
|
|
81
|
+
*/
|
|
82
|
+
function tierCandidateEmbedding(tierIndex, dim) {
|
|
83
|
+
const v = new Array(dim).fill(0);
|
|
84
|
+
const block = Math.max(1, Math.floor(dim / TIER_ORDER.length));
|
|
85
|
+
const start = tierIndex * block;
|
|
86
|
+
for (let i = start; i < Math.min(start + block, dim); i++)
|
|
87
|
+
v[i] = 1 / Math.sqrt(block);
|
|
88
|
+
return v;
|
|
89
|
+
}
|
|
90
|
+
// ============================================================================
|
|
91
|
+
// Routing
|
|
92
|
+
// ============================================================================
|
|
93
|
+
/**
|
|
94
|
+
* Attempt a neural routing decision for the given task embedding.
|
|
95
|
+
*
|
|
96
|
+
* Returns `null` (never throws) when the gate is closed, the package or
|
|
97
|
+
* artifact is unavailable, or inference fails β callers fall back to the
|
|
98
|
+
* bandit and report `routedBy: 'bandit-fallback'` (when the gate was open)
|
|
99
|
+
* or `'heuristic'` (when it never was).
|
|
100
|
+
*/
|
|
101
|
+
export async function tryNeuralRoute(embedding) {
|
|
102
|
+
if (!neuralRoutingEnabled())
|
|
103
|
+
return null;
|
|
104
|
+
if (!embedding || embedding.length === 0)
|
|
105
|
+
return null;
|
|
106
|
+
const router = await loadRouter();
|
|
107
|
+
if (!router)
|
|
108
|
+
return null;
|
|
109
|
+
try {
|
|
110
|
+
const response = await router.route({
|
|
111
|
+
queryEmbedding: embedding,
|
|
112
|
+
candidates: TIER_ORDER.map((tier, i) => ({
|
|
113
|
+
id: tier,
|
|
114
|
+
embedding: tierCandidateEmbedding(i, embedding.length),
|
|
115
|
+
metadata: JSON.stringify({ tier }),
|
|
116
|
+
})),
|
|
117
|
+
});
|
|
118
|
+
const best = response.decisions?.[0];
|
|
119
|
+
if (!best || !TIER_ORDER.includes(best.candidateId))
|
|
120
|
+
return null;
|
|
121
|
+
return {
|
|
122
|
+
model: best.candidateId,
|
|
123
|
+
confidence: best.confidence,
|
|
124
|
+
uncertainty: best.uncertainty,
|
|
125
|
+
inferenceTimeUs: response.inferenceTimeUs,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
//# sourceMappingURL=neural-router.js.map
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Router trajectory collection (#2334 Phase 1)
|
|
3
|
+
*
|
|
4
|
+
* Opt-in per-decision dataset collection for the model router. The persisted
|
|
5
|
+
* bandit state (`.swarm/model-router-state.json`) keeps only aggregates β
|
|
6
|
+
* 9 Beta(Ξ±,Ξ²) cells and a capped/truncated history β which is not trainable
|
|
7
|
+
* material for the Phase 2 FastGRNN tier-classifier. This sidecar captures
|
|
8
|
+
* the per-example rows that training needs:
|
|
9
|
+
*
|
|
10
|
+
* decision rows: { taskHash, task, embedding?, complexity, features,
|
|
11
|
+
* model, confidence, uncertainty, routedBy, ts }
|
|
12
|
+
* outcome rows: { taskHash, model, outcome, ts }
|
|
13
|
+
*
|
|
14
|
+
* joined offline on `taskHash` (sha256-16 of the task text).
|
|
15
|
+
*
|
|
16
|
+
* OFF by default. Enable with CLAUDE_FLOW_ROUTER_TRAJECTORY=1. Rows append to
|
|
17
|
+
* `.swarm/model-router-trajectories.jsonl` β local-only, same trust domain as
|
|
18
|
+
* the existing state file, but unlike it the rows contain full task text (up
|
|
19
|
+
* to 500 chars) and raw embeddings, which is why this is opt-in rather than
|
|
20
|
+
* always-on.
|
|
21
|
+
*
|
|
22
|
+
* Writes are best-effort: any fs error is swallowed (collection must never
|
|
23
|
+
* break routing), matching the state-file behavior in model-router.ts.
|
|
24
|
+
*
|
|
25
|
+
* @module router-trajectory
|
|
26
|
+
*/
|
|
27
|
+
export declare const TRAJECTORY_FILE = ".swarm/model-router-trajectories.jsonl";
|
|
28
|
+
export declare function trajectoryCollectionEnabled(): boolean;
|
|
29
|
+
/** Join key: first 16 hex chars of sha256(task). */
|
|
30
|
+
export declare function taskHash(task: string): string;
|
|
31
|
+
export interface TrajectoryDecisionRow {
|
|
32
|
+
v: number;
|
|
33
|
+
type: 'decision';
|
|
34
|
+
ts: string;
|
|
35
|
+
taskHash: string;
|
|
36
|
+
/** Task text, capped at 500 chars (cf. learningHistory's 100). */
|
|
37
|
+
task: string;
|
|
38
|
+
/** Raw embedding when one was threaded through route(); else omitted. */
|
|
39
|
+
embedding?: number[];
|
|
40
|
+
complexity: number;
|
|
41
|
+
features: {
|
|
42
|
+
lexicalComplexity: number;
|
|
43
|
+
semanticDepth: number;
|
|
44
|
+
taskScope: number;
|
|
45
|
+
uncertaintyLevel: number;
|
|
46
|
+
};
|
|
47
|
+
model: string;
|
|
48
|
+
confidence: number;
|
|
49
|
+
uncertainty: number;
|
|
50
|
+
routedBy: string;
|
|
51
|
+
}
|
|
52
|
+
export interface TrajectoryOutcomeRow {
|
|
53
|
+
v: number;
|
|
54
|
+
type: 'outcome';
|
|
55
|
+
ts: string;
|
|
56
|
+
taskHash: string;
|
|
57
|
+
model: string;
|
|
58
|
+
outcome: 'success' | 'failure' | 'escalated';
|
|
59
|
+
}
|
|
60
|
+
export declare function recordTrajectoryDecision(task: string, embedding: number[] | undefined, complexity: TrajectoryDecisionRow['features'] & {
|
|
61
|
+
score: number;
|
|
62
|
+
}, decision: {
|
|
63
|
+
model: string;
|
|
64
|
+
confidence: number;
|
|
65
|
+
uncertainty: number;
|
|
66
|
+
routedBy: string;
|
|
67
|
+
}): void;
|
|
68
|
+
export declare function recordTrajectoryOutcome(task: string, model: string, outcome: 'success' | 'failure' | 'escalated'): void;
|
|
69
|
+
//# sourceMappingURL=router-trajectory.d.ts.map
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Router trajectory collection (#2334 Phase 1)
|
|
3
|
+
*
|
|
4
|
+
* Opt-in per-decision dataset collection for the model router. The persisted
|
|
5
|
+
* bandit state (`.swarm/model-router-state.json`) keeps only aggregates β
|
|
6
|
+
* 9 Beta(Ξ±,Ξ²) cells and a capped/truncated history β which is not trainable
|
|
7
|
+
* material for the Phase 2 FastGRNN tier-classifier. This sidecar captures
|
|
8
|
+
* the per-example rows that training needs:
|
|
9
|
+
*
|
|
10
|
+
* decision rows: { taskHash, task, embedding?, complexity, features,
|
|
11
|
+
* model, confidence, uncertainty, routedBy, ts }
|
|
12
|
+
* outcome rows: { taskHash, model, outcome, ts }
|
|
13
|
+
*
|
|
14
|
+
* joined offline on `taskHash` (sha256-16 of the task text).
|
|
15
|
+
*
|
|
16
|
+
* OFF by default. Enable with CLAUDE_FLOW_ROUTER_TRAJECTORY=1. Rows append to
|
|
17
|
+
* `.swarm/model-router-trajectories.jsonl` β local-only, same trust domain as
|
|
18
|
+
* the existing state file, but unlike it the rows contain full task text (up
|
|
19
|
+
* to 500 chars) and raw embeddings, which is why this is opt-in rather than
|
|
20
|
+
* always-on.
|
|
21
|
+
*
|
|
22
|
+
* Writes are best-effort: any fs error is swallowed (collection must never
|
|
23
|
+
* break routing), matching the state-file behavior in model-router.ts.
|
|
24
|
+
*
|
|
25
|
+
* @module router-trajectory
|
|
26
|
+
*/
|
|
27
|
+
import { createHash } from 'crypto';
|
|
28
|
+
import { appendFileSync, existsSync, mkdirSync } from 'fs';
|
|
29
|
+
import { dirname, join } from 'path';
|
|
30
|
+
export const TRAJECTORY_FILE = '.swarm/model-router-trajectories.jsonl';
|
|
31
|
+
/** Schema version stamped on every row so offline training can dispatch. */
|
|
32
|
+
const ROW_VERSION = 1;
|
|
33
|
+
export function trajectoryCollectionEnabled() {
|
|
34
|
+
return process.env.CLAUDE_FLOW_ROUTER_TRAJECTORY === '1';
|
|
35
|
+
}
|
|
36
|
+
/** Join key: first 16 hex chars of sha256(task). */
|
|
37
|
+
export function taskHash(task) {
|
|
38
|
+
return createHash('sha256').update(task).digest('hex').slice(0, 16);
|
|
39
|
+
}
|
|
40
|
+
function appendRow(row) {
|
|
41
|
+
try {
|
|
42
|
+
const fullPath = join(process.cwd(), TRAJECTORY_FILE);
|
|
43
|
+
const dir = dirname(fullPath);
|
|
44
|
+
if (!existsSync(dir))
|
|
45
|
+
mkdirSync(dir, { recursive: true });
|
|
46
|
+
appendFileSync(fullPath, JSON.stringify(row) + '\n');
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
// Best-effort: collection must never break routing.
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
export function recordTrajectoryDecision(task, embedding, complexity, decision) {
|
|
53
|
+
if (!trajectoryCollectionEnabled())
|
|
54
|
+
return;
|
|
55
|
+
appendRow({
|
|
56
|
+
v: ROW_VERSION,
|
|
57
|
+
type: 'decision',
|
|
58
|
+
ts: new Date().toISOString(),
|
|
59
|
+
taskHash: taskHash(task),
|
|
60
|
+
task: task.slice(0, 500),
|
|
61
|
+
...(embedding && embedding.length > 0 ? { embedding } : {}),
|
|
62
|
+
complexity: complexity.score,
|
|
63
|
+
features: {
|
|
64
|
+
lexicalComplexity: complexity.lexicalComplexity,
|
|
65
|
+
semanticDepth: complexity.semanticDepth,
|
|
66
|
+
taskScope: complexity.taskScope,
|
|
67
|
+
uncertaintyLevel: complexity.uncertaintyLevel,
|
|
68
|
+
},
|
|
69
|
+
model: decision.model,
|
|
70
|
+
confidence: decision.confidence,
|
|
71
|
+
uncertainty: decision.uncertainty,
|
|
72
|
+
routedBy: decision.routedBy,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
export function recordTrajectoryOutcome(task, model, outcome) {
|
|
76
|
+
if (!trajectoryCollectionEnabled())
|
|
77
|
+
return;
|
|
78
|
+
appendRow({
|
|
79
|
+
v: ROW_VERSION,
|
|
80
|
+
type: 'outcome',
|
|
81
|
+
ts: new Date().toISOString(),
|
|
82
|
+
taskHash: taskHash(task),
|
|
83
|
+
model,
|
|
84
|
+
outcome,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
//# sourceMappingURL=router-trajectory.js.map
|
|
@@ -55,6 +55,8 @@ export interface DaemonConfig {
|
|
|
55
55
|
maxCpuLoad: number;
|
|
56
56
|
minFreeMemoryPercent: number;
|
|
57
57
|
};
|
|
58
|
+
ttlMs: number;
|
|
59
|
+
idleShutdownMs: number;
|
|
58
60
|
workers: WorkerConfig[];
|
|
59
61
|
}
|
|
60
62
|
/**
|
|
@@ -65,6 +67,7 @@ export declare class WorkerDaemon extends EventEmitter {
|
|
|
65
67
|
private workers;
|
|
66
68
|
private timers;
|
|
67
69
|
private queuePollTimer?;
|
|
70
|
+
private lifecycleTimer?;
|
|
68
71
|
private running;
|
|
69
72
|
private startedAt?;
|
|
70
73
|
private projectRoot;
|
|
@@ -222,6 +225,30 @@ export declare class WorkerDaemon extends EventEmitter {
|
|
|
222
225
|
* Stop the daemon and all workers
|
|
223
226
|
*/
|
|
224
227
|
stop(): Promise<void>;
|
|
228
|
+
/**
|
|
229
|
+
* #2356 β Self-terminating lifecycle monitor. A daemon with no upper bound
|
|
230
|
+
* on its lifetime is the documented root cause of multi-day token leaks:
|
|
231
|
+
* each interval worker spawns a headless `claude --print` sweep, so a daemon
|
|
232
|
+
* left running for days dispatches tens of thousands of sessions invisibly.
|
|
233
|
+
* This timer enforces a max age (`ttlMs`) and an optional idle window
|
|
234
|
+
* (`idleShutdownMs`), shutting the daemon down gracefully when either trips.
|
|
235
|
+
* Checked once a minute and `unref()`'d so it never keeps the process alive
|
|
236
|
+
* on its own. A no-op when both limits are disabled (0).
|
|
237
|
+
*/
|
|
238
|
+
private startLifecycleMonitor;
|
|
239
|
+
/**
|
|
240
|
+
* Most recent worker start/finish time across all workers (epoch ms), or
|
|
241
|
+
* null if no worker has ever started. Used for idle-shutdown detection.
|
|
242
|
+
*/
|
|
243
|
+
private lastWorkerActivityMs;
|
|
244
|
+
/**
|
|
245
|
+
* Graceful self-shutdown triggered by the lifecycle monitor. Mirrors the
|
|
246
|
+
* signal-handler path (`stop()` then `process.exit(0)`) because the
|
|
247
|
+
* foreground keep-alive in the daemon command is a *ref'd* `setInterval`
|
|
248
|
+
* that would otherwise hold the process open after `stop()` clears the
|
|
249
|
+
* service timers β leaving a zombie that reports stopped but never exits.
|
|
250
|
+
*/
|
|
251
|
+
private selfShutdown;
|
|
225
252
|
/**
|
|
226
253
|
* Get daemon status
|
|
227
254
|
*/
|
|
@@ -27,6 +27,30 @@ const DEFAULT_WORKERS = [
|
|
|
27
27
|
// Worker timeout β must exceed the longest per-worker headless timeout (15 min for audit/refactor).
|
|
28
28
|
// Previously 5 min, which caused orphan processes when daemon timeout fired before executor timeout (#1117).
|
|
29
29
|
const DEFAULT_WORKER_TIMEOUT_MS = 16 * 60 * 1000;
|
|
30
|
+
// #2356 β Self-terminating lifecycle defaults. A background daemon with no
|
|
31
|
+
// upper bound on its lifetime runs until the box reboots; in the field this
|
|
32
|
+
// leaked tens of thousands of headless `claude --print` sweeps over many days
|
|
33
|
+
// (one observed daemon ran 19 days). A 12h default age cap (matching the
|
|
34
|
+
// pacphi/ruflo-machine-ref kit's proven value) heals a forgotten daemon within
|
|
35
|
+
// half a day; set RUFLO_DAEMON_TTL_SECS=0 (or `--ttl 0`) to opt out. Idle
|
|
36
|
+
// shutdown is opt-in (0 = disabled) since a legitimately quiet daemon is not a leak.
|
|
37
|
+
const DEFAULT_DAEMON_TTL_MS = 12 * 60 * 60 * 1000;
|
|
38
|
+
const DEFAULT_DAEMON_IDLE_SHUTDOWN_MS = 0;
|
|
39
|
+
/**
|
|
40
|
+
* Read a non-negative seconds value from an env var and return it as ms.
|
|
41
|
+
* Unlike the `parseInt(x) || default` idiom used elsewhere, an explicit `0`
|
|
42
|
+
* is honored (it disables the corresponding limit) rather than falling back
|
|
43
|
+
* to the default. Invalid / negative / absent values fall back.
|
|
44
|
+
*/
|
|
45
|
+
function readEnvSecsAsMs(name, defaultMs) {
|
|
46
|
+
const raw = process.env[name];
|
|
47
|
+
if (raw === undefined || raw.trim() === '')
|
|
48
|
+
return defaultMs;
|
|
49
|
+
const secs = Number.parseInt(raw, 10);
|
|
50
|
+
if (!Number.isFinite(secs) || secs < 0)
|
|
51
|
+
return defaultMs;
|
|
52
|
+
return secs * 1000;
|
|
53
|
+
}
|
|
30
54
|
/**
|
|
31
55
|
* Worker Daemon - Manages background workers with Node.js
|
|
32
56
|
*/
|
|
@@ -37,6 +61,9 @@ export class WorkerDaemon extends EventEmitter {
|
|
|
37
61
|
// #1845: separate timer for the MCP-dispatch queue poller. Kept off
|
|
38
62
|
// the per-worker map so stop() clears both kinds without confusion.
|
|
39
63
|
queuePollTimer;
|
|
64
|
+
// #2356: separate timer that enforces the daemon's max-age TTL + idle
|
|
65
|
+
// shutdown. Cleared in stop() alongside the worker/queue timers.
|
|
66
|
+
lifecycleTimer;
|
|
40
67
|
running = false;
|
|
41
68
|
startedAt;
|
|
42
69
|
projectRoot;
|
|
@@ -92,6 +119,11 @@ export class WorkerDaemon extends EventEmitter {
|
|
|
92
119
|
maxCpuLoad: config?.resourceThresholds?.maxCpuLoad ?? fileConfig.maxCpuLoad ?? smartMaxCpuLoad,
|
|
93
120
|
minFreeMemoryPercent: config?.resourceThresholds?.minFreeMemoryPercent ?? fileConfig.minFreeMemoryPercent ?? defaultMinFreeMemory,
|
|
94
121
|
},
|
|
122
|
+
// #2356 β precedence: constructor arg > config.json (daemon.ttlSecs) >
|
|
123
|
+
// env (RUFLO_DAEMON_TTL_SECS) > built-in default. readEnvSecsAsMs folds
|
|
124
|
+
// env-or-default and honors an explicit 0 (disable).
|
|
125
|
+
ttlMs: config?.ttlMs ?? fileConfig.ttlMs ?? readEnvSecsAsMs('RUFLO_DAEMON_TTL_SECS', DEFAULT_DAEMON_TTL_MS),
|
|
126
|
+
idleShutdownMs: config?.idleShutdownMs ?? fileConfig.idleShutdownMs ?? readEnvSecsAsMs('RUFLO_DAEMON_IDLE_SECS', DEFAULT_DAEMON_IDLE_SHUTDOWN_MS),
|
|
95
127
|
workers: config?.workers ?? DEFAULT_WORKERS,
|
|
96
128
|
};
|
|
97
129
|
// Setup graceful shutdown handlers
|
|
@@ -274,12 +306,19 @@ export class WorkerDaemon extends EventEmitter {
|
|
|
274
306
|
const rawMinMem = cfg['daemon.resourceThresholds.minFreeMemoryPercent'] ?? raw['daemon.resourceThresholds.minFreeMemoryPercent'];
|
|
275
307
|
const rawMaxConcurrent = cfg['daemon.maxConcurrent'] ?? raw['daemon.maxConcurrent'];
|
|
276
308
|
const rawTimeout = cfg['daemon.workerTimeoutMs'] ?? raw['daemon.workerTimeoutMs'];
|
|
309
|
+
// #2356 β lifecycle limits are configured in SECONDS in config.json
|
|
310
|
+
// (`daemon.ttlSecs` / `daemon.idleSecs`) for parity with the CLI flag
|
|
311
|
+
// and env var; stored internally as ms. An explicit 0 disables.
|
|
312
|
+
const rawTtl = cfg['daemon.ttlSecs'] ?? raw['daemon.ttlSecs'];
|
|
313
|
+
const rawIdle = cfg['daemon.idleSecs'] ?? raw['daemon.idleSecs'];
|
|
277
314
|
return {
|
|
278
315
|
autoStart: typeof raw['daemon.autoStart'] === 'boolean' ? raw['daemon.autoStart'] : undefined,
|
|
279
316
|
maxConcurrent: (typeof rawMaxConcurrent === 'number' && rawMaxConcurrent > 0) ? rawMaxConcurrent : undefined,
|
|
280
317
|
workerTimeoutMs: (typeof rawTimeout === 'number' && rawTimeout > 0) ? rawTimeout : undefined,
|
|
281
318
|
maxCpuLoad: (typeof rawCpuLoad === 'number' && rawCpuLoad > 0 && rawCpuLoad < 1000) ? rawCpuLoad : undefined,
|
|
282
319
|
minFreeMemoryPercent: (typeof rawMinMem === 'number' && rawMinMem >= 0 && rawMinMem <= 100) ? rawMinMem : undefined,
|
|
320
|
+
ttlMs: (typeof rawTtl === 'number' && rawTtl >= 0) ? rawTtl * 1000 : undefined,
|
|
321
|
+
idleShutdownMs: (typeof rawIdle === 'number' && rawIdle >= 0) ? rawIdle * 1000 : undefined,
|
|
283
322
|
};
|
|
284
323
|
}
|
|
285
324
|
catch {
|
|
@@ -668,6 +707,9 @@ export class WorkerDaemon extends EventEmitter {
|
|
|
668
707
|
if (typeof this.queuePollTimer.unref === 'function') {
|
|
669
708
|
this.queuePollTimer.unref();
|
|
670
709
|
}
|
|
710
|
+
// #2356: self-terminating lifecycle. Without an upper bound on lifetime a
|
|
711
|
+
// forgotten daemon keeps dispatching headless worker sweeps for days.
|
|
712
|
+
this.startLifecycleMonitor();
|
|
671
713
|
// Save state
|
|
672
714
|
this.saveState();
|
|
673
715
|
this.log('info', `Daemon started (PID: ${process.pid}, CPUs: ${cpus().length}, workers: ${this.config.workers.filter(w => w.enabled).length}, maxCpuLoad: ${this.config.resourceThresholds.maxCpuLoad}, minFreeMemoryPercent: ${this.config.resourceThresholds.minFreeMemoryPercent}%)`);
|
|
@@ -762,12 +804,93 @@ export class WorkerDaemon extends EventEmitter {
|
|
|
762
804
|
clearInterval(this.queuePollTimer);
|
|
763
805
|
this.queuePollTimer = undefined;
|
|
764
806
|
}
|
|
807
|
+
// #2356: stop the TTL/idle lifecycle monitor.
|
|
808
|
+
if (this.lifecycleTimer) {
|
|
809
|
+
clearInterval(this.lifecycleTimer);
|
|
810
|
+
this.lifecycleTimer = undefined;
|
|
811
|
+
}
|
|
765
812
|
this.running = false;
|
|
766
813
|
this.removePidFile();
|
|
767
814
|
this.saveState();
|
|
768
815
|
this.emit('stopped', { stoppedAt: new Date() });
|
|
769
816
|
this.log('info', 'Daemon stopped');
|
|
770
817
|
}
|
|
818
|
+
/**
|
|
819
|
+
* #2356 β Self-terminating lifecycle monitor. A daemon with no upper bound
|
|
820
|
+
* on its lifetime is the documented root cause of multi-day token leaks:
|
|
821
|
+
* each interval worker spawns a headless `claude --print` sweep, so a daemon
|
|
822
|
+
* left running for days dispatches tens of thousands of sessions invisibly.
|
|
823
|
+
* This timer enforces a max age (`ttlMs`) and an optional idle window
|
|
824
|
+
* (`idleShutdownMs`), shutting the daemon down gracefully when either trips.
|
|
825
|
+
* Checked once a minute and `unref()`'d so it never keeps the process alive
|
|
826
|
+
* on its own. A no-op when both limits are disabled (0).
|
|
827
|
+
*/
|
|
828
|
+
startLifecycleMonitor() {
|
|
829
|
+
const ttlMs = this.config.ttlMs;
|
|
830
|
+
const idleMs = this.config.idleShutdownMs;
|
|
831
|
+
if ((!ttlMs || ttlMs <= 0) && (!idleMs || idleMs <= 0)) {
|
|
832
|
+
return; // both limits disabled β preserve legacy run-until-stopped behavior
|
|
833
|
+
}
|
|
834
|
+
const CHECK_INTERVAL_MS = 60_000;
|
|
835
|
+
this.lifecycleTimer = setInterval(() => {
|
|
836
|
+
if (!this.running)
|
|
837
|
+
return;
|
|
838
|
+
const now = Date.now();
|
|
839
|
+
const startedMs = this.startedAt?.getTime() ?? now;
|
|
840
|
+
if (ttlMs > 0 && now - startedMs >= ttlMs) {
|
|
841
|
+
void this.selfShutdown(`max age ${Math.round(ttlMs / 1000)}s reached`);
|
|
842
|
+
return;
|
|
843
|
+
}
|
|
844
|
+
if (idleMs > 0) {
|
|
845
|
+
const lastActivity = this.lastWorkerActivityMs() ?? startedMs;
|
|
846
|
+
if (now - lastActivity >= idleMs) {
|
|
847
|
+
void this.selfShutdown(`idle for ${Math.round(idleMs / 1000)}s (no worker activity)`);
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
}, CHECK_INTERVAL_MS);
|
|
851
|
+
if (typeof this.lifecycleTimer.unref === 'function') {
|
|
852
|
+
this.lifecycleTimer.unref();
|
|
853
|
+
}
|
|
854
|
+
const parts = [];
|
|
855
|
+
if (ttlMs > 0)
|
|
856
|
+
parts.push(`ttl=${Math.round(ttlMs / 1000)}s`);
|
|
857
|
+
if (idleMs > 0)
|
|
858
|
+
parts.push(`idle=${Math.round(idleMs / 1000)}s`);
|
|
859
|
+
this.log('info', `Lifecycle monitor active (${parts.join(', ')})`);
|
|
860
|
+
}
|
|
861
|
+
/**
|
|
862
|
+
* Most recent worker start/finish time across all workers (epoch ms), or
|
|
863
|
+
* null if no worker has ever started. Used for idle-shutdown detection.
|
|
864
|
+
*/
|
|
865
|
+
lastWorkerActivityMs() {
|
|
866
|
+
let latest = null;
|
|
867
|
+
for (const state of this.workers.values()) {
|
|
868
|
+
for (const t of [state.lastRun, state.lastStartedAt]) {
|
|
869
|
+
if (t) {
|
|
870
|
+
const ms = t.getTime();
|
|
871
|
+
if (latest === null || ms > latest)
|
|
872
|
+
latest = ms;
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
return latest;
|
|
877
|
+
}
|
|
878
|
+
/**
|
|
879
|
+
* Graceful self-shutdown triggered by the lifecycle monitor. Mirrors the
|
|
880
|
+
* signal-handler path (`stop()` then `process.exit(0)`) because the
|
|
881
|
+
* foreground keep-alive in the daemon command is a *ref'd* `setInterval`
|
|
882
|
+
* that would otherwise hold the process open after `stop()` clears the
|
|
883
|
+
* service timers β leaving a zombie that reports stopped but never exits.
|
|
884
|
+
*/
|
|
885
|
+
async selfShutdown(reason) {
|
|
886
|
+
this.log('info', `Daemon self-shutdown: ${reason}`);
|
|
887
|
+
this.emit('self-shutdown', { reason });
|
|
888
|
+
try {
|
|
889
|
+
await this.stop();
|
|
890
|
+
}
|
|
891
|
+
catch { /* best-effort β we are exiting regardless */ }
|
|
892
|
+
process.exit(0);
|
|
893
|
+
}
|
|
771
894
|
/**
|
|
772
895
|
* Get daemon status
|
|
773
896
|
*/
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@claude-flow/cli",
|
|
3
|
-
"version": "3.10.
|
|
3
|
+
"version": "3.10.43",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Ruflo CLI - Enterprise AI agent orchestration with 60+ specialized agents, swarm coordination, MCP server, self-learning hooks, and vector memory for Claude Code",
|
|
6
6
|
"main": "dist/src/index.js",
|