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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-flow",
3
- "version": "3.7.0-alpha.15",
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` to find all daemon processes for this project and kills them.
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.15",
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",