claude-flow 3.7.0-alpha.15 → 3.7.0-alpha.17
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/package.json +1 -1
- package/v3/@claude-flow/cli/dist/src/commands/daemon.js +284 -2
- package/v3/@claude-flow/cli/dist/src/services/headless-worker-executor.d.ts +6 -0
- package/v3/@claude-flow/cli/dist/src/services/headless-worker-executor.js +14 -0
- package/v3/@claude-flow/cli/dist/src/services/worker-daemon.d.ts +46 -0
- package/v3/@claude-flow/cli/dist/src/services/worker-daemon.js +173 -1
- package/v3/@claude-flow/cli/package.json +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-flow",
|
|
3
|
-
"version": "3.7.0-alpha.
|
|
3
|
+
"version": "3.7.0-alpha.17",
|
|
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",
|
|
@@ -391,10 +391,17 @@ async function killBackgroundDaemon(projectRoot) {
|
|
|
391
391
|
}
|
|
392
392
|
}
|
|
393
393
|
/**
|
|
394
|
-
* Kill stale daemon processes not tracked by the PID file (#1551).
|
|
395
|
-
* Uses `ps`
|
|
394
|
+
* Kill stale daemon processes not tracked by the PID file (#1551, #1857).
|
|
395
|
+
* Uses `ps` on POSIX and `tasklist` on Windows to find all daemon
|
|
396
|
+
* processes for this project and kill them.
|
|
396
397
|
*/
|
|
397
398
|
async function killStaleDaemons(projectRoot, quiet) {
|
|
399
|
+
if (process.platform === 'win32') {
|
|
400
|
+
return killStaleDaemonsWindows(projectRoot, quiet);
|
|
401
|
+
}
|
|
402
|
+
return killStaleDaemonsPosix(projectRoot, quiet);
|
|
403
|
+
}
|
|
404
|
+
async function killStaleDaemonsPosix(projectRoot, quiet) {
|
|
398
405
|
try {
|
|
399
406
|
const { execFileSync } = await import('child_process');
|
|
400
407
|
const psOutput = execFileSync('ps', ['-eo', 'pid,command'], { encoding: 'utf-8', timeout: 5000 });
|
|
@@ -430,6 +437,63 @@ async function killStaleDaemons(projectRoot, quiet) {
|
|
|
430
437
|
// ps not available or failed — skip stale cleanup
|
|
431
438
|
}
|
|
432
439
|
}
|
|
440
|
+
/**
|
|
441
|
+
* #1857: Windows replacement for the POSIX `ps -eo pid,command` path.
|
|
442
|
+
* Uses `tasklist /v /fo csv` which returns CSV with the full Window
|
|
443
|
+
* Title column (last field) — Node-spawned daemon processes carry
|
|
444
|
+
* their command line there. Best-effort like the POSIX path: any
|
|
445
|
+
* tooling failure (tasklist missing, parse error, etc.) is swallowed
|
|
446
|
+
* silently so cleanup doesn't break daemon start.
|
|
447
|
+
*/
|
|
448
|
+
async function killStaleDaemonsWindows(projectRoot, quiet) {
|
|
449
|
+
try {
|
|
450
|
+
const { execFileSync } = await import('child_process');
|
|
451
|
+
// /v includes the Window Title; /fo csv uses comma-separated quoted fields
|
|
452
|
+
const out = execFileSync('tasklist', ['/v', '/fo', 'csv', '/nh'], { encoding: 'utf-8', timeout: 5000 });
|
|
453
|
+
const lines = out.split(/\r?\n/);
|
|
454
|
+
const currentPid = process.pid;
|
|
455
|
+
const trackedPid = getBackgroundDaemonPid(projectRoot);
|
|
456
|
+
let killed = 0;
|
|
457
|
+
for (const line of lines) {
|
|
458
|
+
if (!line.trim())
|
|
459
|
+
continue;
|
|
460
|
+
// Match daemon command line markers — the Window Title field
|
|
461
|
+
// typically holds the full invocation. Skip rows that aren't ours.
|
|
462
|
+
if (!line.includes('daemon start --foreground'))
|
|
463
|
+
continue;
|
|
464
|
+
if (!line.includes('claude-flow') && !line.includes('@claude-flow/cli'))
|
|
465
|
+
continue;
|
|
466
|
+
// Parse CSV: tasklist quotes each field, so split on `","`
|
|
467
|
+
const fields = line.split(/","/).map(f => f.replace(/^"|"$/g, ''));
|
|
468
|
+
// fields[0] = Image Name, fields[1] = PID, …
|
|
469
|
+
const pidStr = fields[1];
|
|
470
|
+
const pid = parseInt(pidStr ?? '', 10);
|
|
471
|
+
if (isNaN(pid) || pid === currentPid || pid === trackedPid)
|
|
472
|
+
continue;
|
|
473
|
+
if (!isProcessRunning(pid))
|
|
474
|
+
continue;
|
|
475
|
+
try {
|
|
476
|
+
// taskkill is the Windows equivalent of kill — /pid <n> /f forces.
|
|
477
|
+
// Use SIGTERM-equivalent (no /f) first; the daemon's signal handler
|
|
478
|
+
// catches and cleans up; force-kill is the next start's job.
|
|
479
|
+
execFileSync('taskkill', ['/pid', String(pid), '/t'], { encoding: 'utf-8', timeout: 5000 });
|
|
480
|
+
killed++;
|
|
481
|
+
if (!quiet) {
|
|
482
|
+
output.printWarning(`Killed stale daemon process (PID: ${pid})`);
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
catch { /* taskkill failed — process may have exited; ignore */ }
|
|
486
|
+
}
|
|
487
|
+
if (killed > 0 && !quiet) {
|
|
488
|
+
output.printInfo(`Cleaned up ${killed} stale daemon process(es)`);
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
catch {
|
|
492
|
+
// tasklist not available or failed — skip stale cleanup. Defensive
|
|
493
|
+
// shape matches the POSIX path. Not tested on Windows by the
|
|
494
|
+
// maintainer; please report regressions on the issue tracker.
|
|
495
|
+
}
|
|
496
|
+
}
|
|
433
497
|
/**
|
|
434
498
|
* Get PID of background daemon from PID file
|
|
435
499
|
*/
|
|
@@ -671,6 +735,222 @@ function formatTimeUntil(date) {
|
|
|
671
735
|
return `in ${Math.floor(seconds / 3600)}h`;
|
|
672
736
|
return `in ${Math.floor(seconds / 86400)}d`;
|
|
673
737
|
}
|
|
738
|
+
// #1565: Supervisor installer subcommand. Writes a native auto-restart
|
|
739
|
+
// unit (launchd plist on macOS, systemd-user .service on Linux) so the
|
|
740
|
+
// daemon survives crashes and reboots without requiring the operator
|
|
741
|
+
// to manually run `daemon start` after every failure.
|
|
742
|
+
const installSupervisorCommand = {
|
|
743
|
+
name: 'install-supervisor',
|
|
744
|
+
description: 'Install OS-level auto-restart supervisor (launchd on macOS, systemd-user on Linux)',
|
|
745
|
+
options: [
|
|
746
|
+
{ name: 'force', short: 'f', type: 'boolean', description: 'Overwrite existing unit file', default: 'false' },
|
|
747
|
+
{ name: 'load', type: 'boolean', description: 'Load/enable the unit immediately', default: 'true' },
|
|
748
|
+
{ name: 'dry-run', type: 'boolean', description: 'Print the unit file content without writing', default: 'false' },
|
|
749
|
+
],
|
|
750
|
+
examples: [
|
|
751
|
+
{ command: 'claude-flow daemon install-supervisor', description: 'Install + load (auto-restart enabled)' },
|
|
752
|
+
{ command: 'claude-flow daemon install-supervisor --no-load', description: 'Write unit file but do not enable yet' },
|
|
753
|
+
{ command: 'claude-flow daemon install-supervisor --dry-run', description: 'Preview the unit file' },
|
|
754
|
+
],
|
|
755
|
+
action: async (ctx) => {
|
|
756
|
+
const force = ctx.flags.force === true;
|
|
757
|
+
const load = ctx.flags.load !== false;
|
|
758
|
+
const dryRun = ctx.flags['dry-run'] === true || ctx.flags.dryRun === true;
|
|
759
|
+
const projectRoot = process.cwd();
|
|
760
|
+
const platform = process.platform;
|
|
761
|
+
if (platform === 'win32') {
|
|
762
|
+
output.printError('Windows scheduled-task installer is not yet implemented.');
|
|
763
|
+
output.printInfo('Use Task Scheduler manually, or follow this issue: https://github.com/ruvnet/ruflo/issues/1565');
|
|
764
|
+
return { success: false, exitCode: 1 };
|
|
765
|
+
}
|
|
766
|
+
if (platform !== 'darwin' && platform !== 'linux') {
|
|
767
|
+
output.printError(`Unsupported platform: ${platform}. Supported: darwin (launchd), linux (systemd-user).`);
|
|
768
|
+
return { success: false, exitCode: 1 };
|
|
769
|
+
}
|
|
770
|
+
// Resolve absolute paths the unit file will reference.
|
|
771
|
+
const home = process.env.HOME ?? process.env.USERPROFILE ?? '';
|
|
772
|
+
if (!home) {
|
|
773
|
+
output.printError('HOME/USERPROFILE not set; cannot resolve user unit path.');
|
|
774
|
+
return { success: false, exitCode: 1 };
|
|
775
|
+
}
|
|
776
|
+
const nodeBin = process.execPath;
|
|
777
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
778
|
+
const __dirname = dirname(__filename);
|
|
779
|
+
const cliJs = resolve(join(__dirname, '..', '..', '..', 'bin', 'cli.js'));
|
|
780
|
+
if (!fs.existsSync(cliJs)) {
|
|
781
|
+
output.printError(`CLI not found at: ${cliJs}`);
|
|
782
|
+
return { success: false, exitCode: 1 };
|
|
783
|
+
}
|
|
784
|
+
if (platform === 'darwin') {
|
|
785
|
+
const plistDir = join(home, 'Library', 'LaunchAgents');
|
|
786
|
+
const plistPath = join(plistDir, 'io.ruv.ruflo.daemon.plist');
|
|
787
|
+
const logDir = join(projectRoot, '.claude-flow', 'logs');
|
|
788
|
+
const plist = `<?xml version="1.0" encoding="UTF-8"?>
|
|
789
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
790
|
+
<plist version="1.0">
|
|
791
|
+
<dict>
|
|
792
|
+
<key>Label</key><string>io.ruv.ruflo.daemon</string>
|
|
793
|
+
<key>ProgramArguments</key>
|
|
794
|
+
<array>
|
|
795
|
+
<string>${nodeBin}</string>
|
|
796
|
+
<string>${cliJs}</string>
|
|
797
|
+
<string>daemon</string><string>start</string><string>--foreground</string><string>--quiet</string>
|
|
798
|
+
</array>
|
|
799
|
+
<key>WorkingDirectory</key><string>${projectRoot}</string>
|
|
800
|
+
<key>RunAtLoad</key><true/>
|
|
801
|
+
<key>KeepAlive</key>
|
|
802
|
+
<dict>
|
|
803
|
+
<key>SuccessfulExit</key><false/>
|
|
804
|
+
<key>Crashed</key><true/>
|
|
805
|
+
</dict>
|
|
806
|
+
<key>ThrottleInterval</key><integer>10</integer>
|
|
807
|
+
<key>StandardOutPath</key><string>${logDir}/supervisor.out.log</string>
|
|
808
|
+
<key>StandardErrorPath</key><string>${logDir}/supervisor.err.log</string>
|
|
809
|
+
<key>EnvironmentVariables</key>
|
|
810
|
+
<dict>
|
|
811
|
+
<key>CLAUDE_FLOW_DAEMON</key><string>1</string>
|
|
812
|
+
</dict>
|
|
813
|
+
</dict>
|
|
814
|
+
</plist>
|
|
815
|
+
`;
|
|
816
|
+
if (dryRun) {
|
|
817
|
+
output.writeln(plist);
|
|
818
|
+
return { success: true };
|
|
819
|
+
}
|
|
820
|
+
if (fs.existsSync(plistPath) && !force) {
|
|
821
|
+
output.printWarning(`Already installed: ${plistPath}`);
|
|
822
|
+
output.printInfo('Use --force to overwrite.');
|
|
823
|
+
return { success: false, exitCode: 1 };
|
|
824
|
+
}
|
|
825
|
+
if (!fs.existsSync(plistDir))
|
|
826
|
+
fs.mkdirSync(plistDir, { recursive: true });
|
|
827
|
+
if (!fs.existsSync(logDir))
|
|
828
|
+
fs.mkdirSync(logDir, { recursive: true });
|
|
829
|
+
fs.writeFileSync(plistPath, plist, 'utf-8');
|
|
830
|
+
output.printSuccess(`Wrote ${plistPath}`);
|
|
831
|
+
if (load) {
|
|
832
|
+
try {
|
|
833
|
+
const { execFileSync } = await import('child_process');
|
|
834
|
+
// unload first in case a previous version is loaded
|
|
835
|
+
try {
|
|
836
|
+
execFileSync('launchctl', ['unload', plistPath], { encoding: 'utf-8', timeout: 5000 });
|
|
837
|
+
}
|
|
838
|
+
catch { /* ok */ }
|
|
839
|
+
execFileSync('launchctl', ['load', '-w', plistPath], { encoding: 'utf-8', timeout: 5000 });
|
|
840
|
+
output.printSuccess('Supervisor loaded — daemon will auto-restart on crash and survive reboot.');
|
|
841
|
+
}
|
|
842
|
+
catch (err) {
|
|
843
|
+
output.printWarning(`launchctl load failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
844
|
+
output.printInfo(`Run manually: launchctl load -w ${plistPath}`);
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
else {
|
|
848
|
+
output.printInfo(`Run when ready: launchctl load -w ${plistPath}`);
|
|
849
|
+
}
|
|
850
|
+
return { success: true };
|
|
851
|
+
}
|
|
852
|
+
// Linux: systemd-user
|
|
853
|
+
const unitDir = join(home, '.config', 'systemd', 'user');
|
|
854
|
+
const unitPath = join(unitDir, 'ruflo-daemon.service');
|
|
855
|
+
const unit = `[Unit]
|
|
856
|
+
Description=RuFlo background worker daemon
|
|
857
|
+
After=default.target
|
|
858
|
+
|
|
859
|
+
[Service]
|
|
860
|
+
Type=simple
|
|
861
|
+
WorkingDirectory=${projectRoot}
|
|
862
|
+
Environment=CLAUDE_FLOW_DAEMON=1
|
|
863
|
+
ExecStart=${nodeBin} ${cliJs} daemon start --foreground --quiet
|
|
864
|
+
Restart=on-failure
|
|
865
|
+
RestartSec=10
|
|
866
|
+
# Restart on Crashed (signal) too
|
|
867
|
+
StartLimitIntervalSec=300
|
|
868
|
+
StartLimitBurst=5
|
|
869
|
+
|
|
870
|
+
[Install]
|
|
871
|
+
WantedBy=default.target
|
|
872
|
+
`;
|
|
873
|
+
if (dryRun) {
|
|
874
|
+
output.writeln(unit);
|
|
875
|
+
return { success: true };
|
|
876
|
+
}
|
|
877
|
+
if (fs.existsSync(unitPath) && !force) {
|
|
878
|
+
output.printWarning(`Already installed: ${unitPath}`);
|
|
879
|
+
output.printInfo('Use --force to overwrite.');
|
|
880
|
+
return { success: false, exitCode: 1 };
|
|
881
|
+
}
|
|
882
|
+
if (!fs.existsSync(unitDir))
|
|
883
|
+
fs.mkdirSync(unitDir, { recursive: true });
|
|
884
|
+
fs.writeFileSync(unitPath, unit, 'utf-8');
|
|
885
|
+
output.printSuccess(`Wrote ${unitPath}`);
|
|
886
|
+
if (load) {
|
|
887
|
+
try {
|
|
888
|
+
const { execFileSync } = await import('child_process');
|
|
889
|
+
execFileSync('systemctl', ['--user', 'daemon-reload'], { encoding: 'utf-8', timeout: 5000 });
|
|
890
|
+
execFileSync('systemctl', ['--user', 'enable', '--now', 'ruflo-daemon.service'], { encoding: 'utf-8', timeout: 10000 });
|
|
891
|
+
output.printSuccess('Supervisor enabled — daemon will auto-restart on crash and survive reboot.');
|
|
892
|
+
output.printInfo('Note: requires `loginctl enable-linger $USER` for restart-after-logout on some distros.');
|
|
893
|
+
}
|
|
894
|
+
catch (err) {
|
|
895
|
+
output.printWarning(`systemctl --user enable failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
896
|
+
output.printInfo(`Run manually: systemctl --user daemon-reload && systemctl --user enable --now ruflo-daemon.service`);
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
else {
|
|
900
|
+
output.printInfo(`Run when ready: systemctl --user daemon-reload && systemctl --user enable --now ruflo-daemon.service`);
|
|
901
|
+
}
|
|
902
|
+
return { success: true };
|
|
903
|
+
},
|
|
904
|
+
};
|
|
905
|
+
const uninstallSupervisorCommand = {
|
|
906
|
+
name: 'uninstall-supervisor',
|
|
907
|
+
description: 'Remove the auto-restart supervisor unit (launchd on macOS, systemd-user on Linux)',
|
|
908
|
+
options: [],
|
|
909
|
+
action: async () => {
|
|
910
|
+
const platform = process.platform;
|
|
911
|
+
const home = process.env.HOME ?? process.env.USERPROFILE ?? '';
|
|
912
|
+
if (platform === 'darwin') {
|
|
913
|
+
const plistPath = join(home, 'Library', 'LaunchAgents', 'io.ruv.ruflo.daemon.plist');
|
|
914
|
+
try {
|
|
915
|
+
const { execFileSync } = await import('child_process');
|
|
916
|
+
try {
|
|
917
|
+
execFileSync('launchctl', ['unload', plistPath], { encoding: 'utf-8', timeout: 5000 });
|
|
918
|
+
}
|
|
919
|
+
catch { /* ok */ }
|
|
920
|
+
}
|
|
921
|
+
catch { /* ignore */ }
|
|
922
|
+
if (fs.existsSync(plistPath)) {
|
|
923
|
+
fs.unlinkSync(plistPath);
|
|
924
|
+
output.printSuccess(`Removed ${plistPath}`);
|
|
925
|
+
}
|
|
926
|
+
else {
|
|
927
|
+
output.printInfo(`Not installed: ${plistPath}`);
|
|
928
|
+
}
|
|
929
|
+
return { success: true };
|
|
930
|
+
}
|
|
931
|
+
if (platform === 'linux') {
|
|
932
|
+
const unitPath = join(home, '.config', 'systemd', 'user', 'ruflo-daemon.service');
|
|
933
|
+
try {
|
|
934
|
+
const { execFileSync } = await import('child_process');
|
|
935
|
+
try {
|
|
936
|
+
execFileSync('systemctl', ['--user', 'disable', '--now', 'ruflo-daemon.service'], { encoding: 'utf-8', timeout: 5000 });
|
|
937
|
+
}
|
|
938
|
+
catch { /* ok */ }
|
|
939
|
+
}
|
|
940
|
+
catch { /* ignore */ }
|
|
941
|
+
if (fs.existsSync(unitPath)) {
|
|
942
|
+
fs.unlinkSync(unitPath);
|
|
943
|
+
output.printSuccess(`Removed ${unitPath}`);
|
|
944
|
+
}
|
|
945
|
+
else {
|
|
946
|
+
output.printInfo(`Not installed: ${unitPath}`);
|
|
947
|
+
}
|
|
948
|
+
return { success: true };
|
|
949
|
+
}
|
|
950
|
+
output.printError(`Unsupported platform: ${platform}`);
|
|
951
|
+
return { success: false, exitCode: 1 };
|
|
952
|
+
},
|
|
953
|
+
};
|
|
674
954
|
// Main daemon command
|
|
675
955
|
export const daemonCommand = {
|
|
676
956
|
name: 'daemon',
|
|
@@ -681,6 +961,8 @@ export const daemonCommand = {
|
|
|
681
961
|
statusCommand,
|
|
682
962
|
triggerCommand,
|
|
683
963
|
enableCommand,
|
|
964
|
+
installSupervisorCommand,
|
|
965
|
+
uninstallSupervisorCommand,
|
|
684
966
|
],
|
|
685
967
|
options: [],
|
|
686
968
|
examples: [
|
|
@@ -222,6 +222,12 @@ export declare class HeadlessWorkerExecutor extends EventEmitter {
|
|
|
222
222
|
/**
|
|
223
223
|
* Get pool status
|
|
224
224
|
*/
|
|
225
|
+
/**
|
|
226
|
+
* #1855: return the PIDs of all currently-running headless worker
|
|
227
|
+
* children. Used by `WorkerDaemon` to snapshot active child PIDs to
|
|
228
|
+
* disk so the next lifetime can reap orphans after a hard crash.
|
|
229
|
+
*/
|
|
230
|
+
getActiveChildPids(): number[];
|
|
225
231
|
getPoolStatus(): PoolStatus;
|
|
226
232
|
/**
|
|
227
233
|
* Get number of active executions
|
|
@@ -457,6 +457,20 @@ export class HeadlessWorkerExecutor extends EventEmitter {
|
|
|
457
457
|
/**
|
|
458
458
|
* Get pool status
|
|
459
459
|
*/
|
|
460
|
+
/**
|
|
461
|
+
* #1855: return the PIDs of all currently-running headless worker
|
|
462
|
+
* children. Used by `WorkerDaemon` to snapshot active child PIDs to
|
|
463
|
+
* disk so the next lifetime can reap orphans after a hard crash.
|
|
464
|
+
*/
|
|
465
|
+
getActiveChildPids() {
|
|
466
|
+
const out = [];
|
|
467
|
+
for (const entry of this.processPool.values()) {
|
|
468
|
+
const pid = entry.process?.pid;
|
|
469
|
+
if (typeof pid === 'number' && pid > 0)
|
|
470
|
+
out.push(pid);
|
|
471
|
+
}
|
|
472
|
+
return out;
|
|
473
|
+
}
|
|
460
474
|
getPoolStatus() {
|
|
461
475
|
const now = Date.now();
|
|
462
476
|
return {
|
|
@@ -27,6 +27,7 @@ interface WorkerState {
|
|
|
27
27
|
failureCount: number;
|
|
28
28
|
averageDurationMs: number;
|
|
29
29
|
isRunning: boolean;
|
|
30
|
+
lastStartedAt?: Date;
|
|
30
31
|
}
|
|
31
32
|
interface WorkerResult {
|
|
32
33
|
workerId: string;
|
|
@@ -106,6 +107,51 @@ export declare class WorkerDaemon extends EventEmitter {
|
|
|
106
107
|
* Setup graceful shutdown handlers
|
|
107
108
|
*/
|
|
108
109
|
private setupShutdownHandlers;
|
|
110
|
+
/**
|
|
111
|
+
* #1855: install crash handlers for uncaught exceptions and unhandled
|
|
112
|
+
* rejections. Without these, a thrown error from any timer callback,
|
|
113
|
+
* worker logic path, or transitive import crashes the daemon process
|
|
114
|
+
* silently — the PID file leaks and any in-flight child processes
|
|
115
|
+
* orphan. With these, we log a structured crash record, run stop()
|
|
116
|
+
* to clean up, then exit 1 so the process actually dies (otherwise
|
|
117
|
+
* Node would crash anyway after the handler returns).
|
|
118
|
+
*/
|
|
119
|
+
private installCrashHandlers;
|
|
120
|
+
/**
|
|
121
|
+
* Append a structured crash record to .claude-flow/logs/crash.log.
|
|
122
|
+
* Inspectable by hand or via `ruflo daemon status` follow-ups.
|
|
123
|
+
*/
|
|
124
|
+
private writeCrashRecord;
|
|
125
|
+
/**
|
|
126
|
+
* Path to the on-disk children registry — list of headless worker
|
|
127
|
+
* child PIDs the daemon currently owns. #1855: written on every
|
|
128
|
+
* execution:start / :complete / :error transition; read by the next
|
|
129
|
+
* lifetime to reap orphans after a hard crash.
|
|
130
|
+
*/
|
|
131
|
+
private get childrenFile();
|
|
132
|
+
/**
|
|
133
|
+
* #1856: detect workers that were mid-flight when the previous daemon
|
|
134
|
+
* lifetime ended. A mid-flight worker has `lastStartedAt > lastRun`
|
|
135
|
+
* (started after the last successful completion). On crash recovery
|
|
136
|
+
* we count these as failures so the run-counter math stays consistent
|
|
137
|
+
* (`runCount === successCount + failureCount`). Workers naturally
|
|
138
|
+
* retry at their next scheduled interval; we deliberately don't
|
|
139
|
+
* immediately re-run because the failure may have been deterministic.
|
|
140
|
+
*/
|
|
141
|
+
private detectMidFlightFailures;
|
|
142
|
+
/**
|
|
143
|
+
* Snapshot the currently-active headless worker child PIDs to disk.
|
|
144
|
+
* Best-effort; failures don't propagate.
|
|
145
|
+
*/
|
|
146
|
+
private writeChildrenSnapshot;
|
|
147
|
+
/**
|
|
148
|
+
* #1855: reap orphan headless worker children left behind by a
|
|
149
|
+
* previous crashed lifetime. Reads `.claude-flow/daemon-children.json`,
|
|
150
|
+
* SIGTERMs any PID still alive that doesn't belong to the current
|
|
151
|
+
* daemon, then truncates the file. Called at the top of `start()`
|
|
152
|
+
* so the next lifetime starts with a clean process tree.
|
|
153
|
+
*/
|
|
154
|
+
private reapOrphanedChildren;
|
|
109
155
|
/**
|
|
110
156
|
* Check if system resources allow worker execution
|
|
111
157
|
*/
|
|
@@ -79,6 +79,9 @@ export class WorkerDaemon extends EventEmitter {
|
|
|
79
79
|
};
|
|
80
80
|
// Setup graceful shutdown handlers
|
|
81
81
|
this.setupShutdownHandlers();
|
|
82
|
+
// #1855: install crash handlers so uncaught exceptions and unhandled
|
|
83
|
+
// rejections don't leak the PID file or orphan child processes.
|
|
84
|
+
this.installCrashHandlers();
|
|
82
85
|
// Ensure directories exist
|
|
83
86
|
if (!existsSync(claudeFlowDir)) {
|
|
84
87
|
mkdirSync(claudeFlowDir, { recursive: true });
|
|
@@ -104,14 +107,19 @@ export class WorkerDaemon extends EventEmitter {
|
|
|
104
107
|
this.headlessAvailable = await this.headlessExecutor.isAvailable();
|
|
105
108
|
if (this.headlessAvailable) {
|
|
106
109
|
this.log('info', 'Claude Code headless mode available - AI workers enabled');
|
|
107
|
-
// Forward headless executor events
|
|
110
|
+
// Forward headless executor events. #1855: also snapshot the
|
|
111
|
+
// active child PIDs to disk on every transition so the next
|
|
112
|
+
// lifetime can reap orphans after a hard crash.
|
|
108
113
|
this.headlessExecutor.on('execution:start', (data) => {
|
|
114
|
+
this.writeChildrenSnapshot();
|
|
109
115
|
this.emit('headless:start', data);
|
|
110
116
|
});
|
|
111
117
|
this.headlessExecutor.on('execution:complete', (data) => {
|
|
118
|
+
this.writeChildrenSnapshot();
|
|
112
119
|
this.emit('headless:complete', data);
|
|
113
120
|
});
|
|
114
121
|
this.headlessExecutor.on('execution:error', (data) => {
|
|
122
|
+
this.writeChildrenSnapshot();
|
|
115
123
|
this.emit('headless:error', data);
|
|
116
124
|
});
|
|
117
125
|
this.headlessExecutor.on('output', (data) => {
|
|
@@ -249,6 +257,153 @@ export class WorkerDaemon extends EventEmitter {
|
|
|
249
257
|
process.on('SIGINT', shutdown);
|
|
250
258
|
process.on('SIGHUP', shutdown);
|
|
251
259
|
}
|
|
260
|
+
/**
|
|
261
|
+
* #1855: install crash handlers for uncaught exceptions and unhandled
|
|
262
|
+
* rejections. Without these, a thrown error from any timer callback,
|
|
263
|
+
* worker logic path, or transitive import crashes the daemon process
|
|
264
|
+
* silently — the PID file leaks and any in-flight child processes
|
|
265
|
+
* orphan. With these, we log a structured crash record, run stop()
|
|
266
|
+
* to clean up, then exit 1 so the process actually dies (otherwise
|
|
267
|
+
* Node would crash anyway after the handler returns).
|
|
268
|
+
*/
|
|
269
|
+
installCrashHandlers() {
|
|
270
|
+
const onCrash = (kind, err) => {
|
|
271
|
+
// Best-effort logging; never throw from inside the crash handler.
|
|
272
|
+
try {
|
|
273
|
+
this.writeCrashRecord(kind, err);
|
|
274
|
+
}
|
|
275
|
+
catch { /* nothing more we can do */ }
|
|
276
|
+
try {
|
|
277
|
+
// Synchronous stop — don't await; the process is dying. Just
|
|
278
|
+
// remove the PID file and snapshot state so the next start
|
|
279
|
+
// sees a clean slate.
|
|
280
|
+
this.removePidFile();
|
|
281
|
+
this.saveState();
|
|
282
|
+
// Snapshot any in-flight child PIDs one last time so the next
|
|
283
|
+
// lifetime can reap them.
|
|
284
|
+
this.writeChildrenSnapshot();
|
|
285
|
+
}
|
|
286
|
+
catch { /* ignore */ }
|
|
287
|
+
// Exit non-zero so supervisors / shells see the failure.
|
|
288
|
+
process.exit(1);
|
|
289
|
+
};
|
|
290
|
+
process.on('uncaughtException', (err) => onCrash('uncaughtException', err));
|
|
291
|
+
process.on('unhandledRejection', (err) => onCrash('unhandledRejection', err));
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* Append a structured crash record to .claude-flow/logs/crash.log.
|
|
295
|
+
* Inspectable by hand or via `ruflo daemon status` follow-ups.
|
|
296
|
+
*/
|
|
297
|
+
writeCrashRecord(kind, err) {
|
|
298
|
+
const logDir = this.config.logDir;
|
|
299
|
+
if (!existsSync(logDir))
|
|
300
|
+
mkdirSync(logDir, { recursive: true });
|
|
301
|
+
const crashLog = join(logDir, 'crash.log');
|
|
302
|
+
const ts = new Date().toISOString();
|
|
303
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
304
|
+
const stack = err instanceof Error && err.stack ? err.stack : '<no stack>';
|
|
305
|
+
const record = `[${ts}] [${kind}] pid=${process.pid} ${message}\n${stack}\n---\n`;
|
|
306
|
+
appendFileSync(crashLog, record, 'utf-8');
|
|
307
|
+
this.log('warn', `Daemon crashed (${kind}): ${message} — see ${crashLog}`);
|
|
308
|
+
}
|
|
309
|
+
/**
|
|
310
|
+
* Path to the on-disk children registry — list of headless worker
|
|
311
|
+
* child PIDs the daemon currently owns. #1855: written on every
|
|
312
|
+
* execution:start / :complete / :error transition; read by the next
|
|
313
|
+
* lifetime to reap orphans after a hard crash.
|
|
314
|
+
*/
|
|
315
|
+
get childrenFile() {
|
|
316
|
+
return join(this.projectRoot, '.claude-flow', 'daemon-children.json');
|
|
317
|
+
}
|
|
318
|
+
/**
|
|
319
|
+
* #1856: detect workers that were mid-flight when the previous daemon
|
|
320
|
+
* lifetime ended. A mid-flight worker has `lastStartedAt > lastRun`
|
|
321
|
+
* (started after the last successful completion). On crash recovery
|
|
322
|
+
* we count these as failures so the run-counter math stays consistent
|
|
323
|
+
* (`runCount === successCount + failureCount`). Workers naturally
|
|
324
|
+
* retry at their next scheduled interval; we deliberately don't
|
|
325
|
+
* immediately re-run because the failure may have been deterministic.
|
|
326
|
+
*/
|
|
327
|
+
detectMidFlightFailures() {
|
|
328
|
+
let detected = 0;
|
|
329
|
+
for (const [type, state] of this.workers.entries()) {
|
|
330
|
+
const startedAt = state.lastStartedAt?.getTime() ?? 0;
|
|
331
|
+
const lastRunAt = state.lastRun?.getTime() ?? 0;
|
|
332
|
+
// started after the last successful completion → was mid-flight
|
|
333
|
+
if (startedAt > 0 && startedAt > lastRunAt) {
|
|
334
|
+
state.failureCount++;
|
|
335
|
+
state.isRunning = false;
|
|
336
|
+
// Don't bump runCount — it was already incremented at start
|
|
337
|
+
this.log('info', `Worker ${type} was mid-flight at last crash (started ${state.lastStartedAt?.toISOString()}); counted as failure, will retry at next scheduled interval`);
|
|
338
|
+
detected++;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
if (detected > 0) {
|
|
342
|
+
this.saveState();
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
/**
|
|
346
|
+
* Snapshot the currently-active headless worker child PIDs to disk.
|
|
347
|
+
* Best-effort; failures don't propagate.
|
|
348
|
+
*/
|
|
349
|
+
writeChildrenSnapshot() {
|
|
350
|
+
if (!this.headlessExecutor)
|
|
351
|
+
return;
|
|
352
|
+
try {
|
|
353
|
+
const pids = this.headlessExecutor.getActiveChildPids();
|
|
354
|
+
const dir = join(this.projectRoot, '.claude-flow');
|
|
355
|
+
if (!existsSync(dir))
|
|
356
|
+
mkdirSync(dir, { recursive: true });
|
|
357
|
+
writeFileSync(this.childrenFile, JSON.stringify({ pids, daemonPid: process.pid, timestamp: new Date().toISOString() }, null, 2), 'utf-8');
|
|
358
|
+
}
|
|
359
|
+
catch { /* best-effort */ }
|
|
360
|
+
}
|
|
361
|
+
/**
|
|
362
|
+
* #1855: reap orphan headless worker children left behind by a
|
|
363
|
+
* previous crashed lifetime. Reads `.claude-flow/daemon-children.json`,
|
|
364
|
+
* SIGTERMs any PID still alive that doesn't belong to the current
|
|
365
|
+
* daemon, then truncates the file. Called at the top of `start()`
|
|
366
|
+
* so the next lifetime starts with a clean process tree.
|
|
367
|
+
*/
|
|
368
|
+
reapOrphanedChildren() {
|
|
369
|
+
const file = this.childrenFile;
|
|
370
|
+
if (!existsSync(file))
|
|
371
|
+
return;
|
|
372
|
+
let snapshot;
|
|
373
|
+
try {
|
|
374
|
+
snapshot = JSON.parse(readFileSync(file, 'utf-8'));
|
|
375
|
+
}
|
|
376
|
+
catch {
|
|
377
|
+
try {
|
|
378
|
+
unlinkSync(file);
|
|
379
|
+
}
|
|
380
|
+
catch { /* ignore */ }
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
const pids = Array.isArray(snapshot.pids) ? snapshot.pids : [];
|
|
384
|
+
let reaped = 0;
|
|
385
|
+
for (const pid of pids) {
|
|
386
|
+
if (typeof pid !== 'number' || pid <= 0)
|
|
387
|
+
continue;
|
|
388
|
+
if (pid === process.pid)
|
|
389
|
+
continue; // never our own PID
|
|
390
|
+
try {
|
|
391
|
+
process.kill(pid, 0); // is alive?
|
|
392
|
+
process.kill(pid, 'SIGTERM');
|
|
393
|
+
reaped++;
|
|
394
|
+
}
|
|
395
|
+
catch {
|
|
396
|
+
// already dead — fine
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
if (reaped > 0) {
|
|
400
|
+
this.log('info', `Reaped ${reaped} orphan headless worker child(ren) from previous lifetime`);
|
|
401
|
+
}
|
|
402
|
+
try {
|
|
403
|
+
unlinkSync(file);
|
|
404
|
+
}
|
|
405
|
+
catch { /* ignore */ }
|
|
406
|
+
}
|
|
252
407
|
/**
|
|
253
408
|
* Check if system resources allow worker execution
|
|
254
409
|
*/
|
|
@@ -332,12 +487,14 @@ export class WorkerDaemon extends EventEmitter {
|
|
|
332
487
|
for (const [type, state] of Object.entries(saved.workers)) {
|
|
333
488
|
const savedState = state;
|
|
334
489
|
const lastRunValue = savedState.lastRun;
|
|
490
|
+
const lastStartedAtValue = savedState.lastStartedAt;
|
|
335
491
|
this.workers.set(type, {
|
|
336
492
|
runCount: savedState.runCount || 0,
|
|
337
493
|
successCount: savedState.successCount || 0,
|
|
338
494
|
failureCount: savedState.failureCount || 0,
|
|
339
495
|
averageDurationMs: savedState.averageDurationMs || 0,
|
|
340
496
|
lastRun: lastRunValue ? new Date(lastRunValue) : undefined,
|
|
497
|
+
lastStartedAt: lastStartedAtValue ? new Date(lastStartedAtValue) : undefined,
|
|
341
498
|
nextRun: undefined,
|
|
342
499
|
isRunning: false,
|
|
343
500
|
});
|
|
@@ -437,6 +594,18 @@ export class WorkerDaemon extends EventEmitter {
|
|
|
437
594
|
this.emit('warning', `Daemon already running (PID: ${existingPid})`);
|
|
438
595
|
return;
|
|
439
596
|
}
|
|
597
|
+
// #1855: reap orphan headless worker children left by a previous
|
|
598
|
+
// crashed lifetime, BEFORE we mark ourselves running and start
|
|
599
|
+
// accepting new work. The children file from the prior daemon's
|
|
600
|
+
// last-snapshot is the authoritative list.
|
|
601
|
+
this.reapOrphanedChildren();
|
|
602
|
+
// #1856: detect workers that were mid-flight at the previous crash
|
|
603
|
+
// and count them as failures so runCount/successCount/failureCount
|
|
604
|
+
// stay consistent. Workers retry naturally at their next scheduled
|
|
605
|
+
// interval — we don't immediately re-run them, which avoids a
|
|
606
|
+
// freshly-recovered daemon hammering the same code path that just
|
|
607
|
+
// killed it.
|
|
608
|
+
this.detectMidFlightFailures();
|
|
440
609
|
this.running = true;
|
|
441
610
|
this.startedAt = new Date();
|
|
442
611
|
this.writePidFile();
|
|
@@ -631,6 +800,8 @@ export class WorkerDaemon extends EventEmitter {
|
|
|
631
800
|
// Track running worker
|
|
632
801
|
this.runningWorkers.add(workerConfig.type);
|
|
633
802
|
state.isRunning = true;
|
|
803
|
+
state.lastStartedAt = new Date(); // #1856: timestamp the start
|
|
804
|
+
this.saveState(); // persist before we run anything
|
|
634
805
|
this.emit('worker:start', { workerId, type: workerConfig.type });
|
|
635
806
|
this.log('info', `Starting worker: ${workerConfig.type} (${this.runningWorkers.size}/${this.config.maxConcurrent} concurrent)`);
|
|
636
807
|
try {
|
|
@@ -1082,6 +1253,7 @@ export class WorkerDaemon extends EventEmitter {
|
|
|
1082
1253
|
{
|
|
1083
1254
|
...state,
|
|
1084
1255
|
lastRun: state.lastRun?.toISOString(),
|
|
1256
|
+
lastStartedAt: state.lastStartedAt?.toISOString(),
|
|
1085
1257
|
nextRun: state.nextRun?.toISOString(),
|
|
1086
1258
|
}
|
|
1087
1259
|
])),
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@claude-flow/cli",
|
|
3
|
-
"version": "3.7.0-alpha.
|
|
3
|
+
"version": "3.7.0-alpha.17",
|
|
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",
|