@claude-flow/cli 3.26.1 → 3.27.0

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.
@@ -155,6 +155,12 @@ export class WorkerDaemon extends EventEmitter {
155
155
  // env-or-default and honors an explicit 0 (disable).
156
156
  ttlMs: config?.ttlMs ?? fileConfig.ttlMs ?? readEnvSecsAsMs('RUFLO_DAEMON_TTL_SECS', DEFAULT_DAEMON_TTL_MS),
157
157
  idleShutdownMs: config?.idleShutdownMs ?? fileConfig.idleShutdownMs ?? readEnvSecsAsMs('RUFLO_DAEMON_IDLE_SECS', DEFAULT_DAEMON_IDLE_SHUTDOWN_MS),
158
+ // #2661 — AI workers are opt-in: flag > config.json > env > OFF.
159
+ // Deliberately NOT restored from daemon-state.json (initializeWorkerStates
160
+ // whitelist) so a stale state file can never resurrect consent.
161
+ aiWorkersEnabled: config?.aiWorkersEnabled
162
+ ?? fileConfig.aiWorkersEnabled
163
+ ?? (process.env.RUFLO_DAEMON_AI_WORKERS === '1'),
158
164
  workers: config?.workers ?? DEFAULT_WORKERS,
159
165
  };
160
166
  // Setup graceful shutdown handlers
@@ -183,6 +189,14 @@ export class WorkerDaemon extends EventEmitter {
183
189
  * Initialize headless executor if Claude Code is available
184
190
  */
185
191
  async initHeadlessExecutor() {
192
+ // #2661 — scheduled AI workers require explicit consent. Without it,
193
+ // don't even probe `claude --version`: headlessAvailable stays false,
194
+ // every worker runs its $0 local path, and a default install produces
195
+ // zero autonomous Claude launches regardless of worktree count.
196
+ if (!this.config.aiWorkersEnabled) {
197
+ this.log('info', 'AI workers disabled (default) - all workers run local-only. Enable with `daemon start --headless`, daemon.aiWorkers.enabled=true, or RUFLO_DAEMON_AI_WORKERS=1 (#2661)');
198
+ return;
199
+ }
186
200
  try {
187
201
  this.headlessExecutor = new HeadlessWorkerExecutor(this.projectRoot, {
188
202
  maxConcurrent: this.config.maxConcurrent,
@@ -342,6 +356,8 @@ export class WorkerDaemon extends EventEmitter {
342
356
  // and env var; stored internally as ms. An explicit 0 disables.
343
357
  const rawTtl = cfg['daemon.ttlSecs'] ?? raw['daemon.ttlSecs'];
344
358
  const rawIdle = cfg['daemon.idleSecs'] ?? raw['daemon.idleSecs'];
359
+ // #2661 — explicit opt-in for scheduled AI workers.
360
+ const rawAiEnabled = cfg['daemon.aiWorkers.enabled'] ?? raw['daemon.aiWorkers.enabled'];
345
361
  return {
346
362
  autoStart: typeof raw['daemon.autoStart'] === 'boolean' ? raw['daemon.autoStart'] : undefined,
347
363
  maxConcurrent: (typeof rawMaxConcurrent === 'number' && rawMaxConcurrent > 0) ? rawMaxConcurrent : undefined,
@@ -350,6 +366,7 @@ export class WorkerDaemon extends EventEmitter {
350
366
  minFreeMemoryPercent: (typeof rawMinMem === 'number' && rawMinMem >= 0 && rawMinMem <= 100) ? rawMinMem : undefined,
351
367
  ttlMs: (typeof rawTtl === 'number' && rawTtl >= 0) ? rawTtl * 1000 : undefined,
352
368
  idleShutdownMs: (typeof rawIdle === 'number' && rawIdle >= 0) ? rawIdle * 1000 : undefined,
369
+ aiWorkersEnabled: typeof rawAiEnabled === 'boolean' ? rawAiEnabled : undefined,
353
370
  };
354
371
  }
355
372
  catch {
@@ -840,6 +857,16 @@ export class WorkerDaemon extends EventEmitter {
840
857
  clearInterval(this.lifecycleTimer);
841
858
  this.lifecycleTimer = undefined;
842
859
  }
860
+ // #2661 — reap in-flight headless `claude --print` children. They run
861
+ // detached (own process group on POSIX) and would otherwise outlive the
862
+ // daemon; `daemon stop --all` relies on SIGTERM → this path to cancel
863
+ // active Claude process groups.
864
+ if (this.headlessExecutor) {
865
+ try {
866
+ this.headlessExecutor.cancelAll();
867
+ }
868
+ catch { /* best-effort */ }
869
+ }
843
870
  this.running = false;
844
871
  this.removePidFile();
845
872
  this.saveState();
@@ -859,36 +886,58 @@ export class WorkerDaemon extends EventEmitter {
859
886
  startLifecycleMonitor() {
860
887
  const ttlMs = this.config.ttlMs;
861
888
  const idleMs = this.config.idleShutdownMs;
862
- if ((!ttlMs || ttlMs <= 0) && (!idleMs || idleMs <= 0)) {
863
- return; // both limits disabled preserve legacy run-until-stopped behavior
864
- }
889
+ // #2661 unlike ttl/idle (both optional), the workspace-removal check
890
+ // always runs, so the monitor is no longer skipped when both limits are
891
+ // disabled. A daemon whose worktree was deleted must not keep running.
865
892
  const CHECK_INTERVAL_MS = 60_000;
866
893
  this.lifecycleTimer = setInterval(() => {
867
894
  if (!this.running)
868
895
  return;
869
- const now = Date.now();
870
- const startedMs = this.startedAt?.getTime() ?? now;
871
- if (ttlMs > 0 && now - startedMs >= ttlMs) {
872
- void this.selfShutdown(`max age ${Math.round(ttlMs / 1000)}s reached`);
873
- return;
874
- }
875
- if (idleMs > 0) {
876
- const lastActivity = this.lastWorkerActivityMs() ?? startedMs;
877
- if (now - lastActivity >= idleMs) {
878
- void this.selfShutdown(`idle for ${Math.round(idleMs / 1000)}s (no worker activity)`);
879
- }
896
+ const reason = this.lifecycleShutdownReason(Date.now());
897
+ if (reason) {
898
+ void this.selfShutdown(reason);
880
899
  }
881
900
  }, CHECK_INTERVAL_MS);
882
901
  if (typeof this.lifecycleTimer.unref === 'function') {
883
902
  this.lifecycleTimer.unref();
884
903
  }
885
- const parts = [];
904
+ const parts = ['workspace-removal'];
886
905
  if (ttlMs > 0)
887
906
  parts.push(`ttl=${Math.round(ttlMs / 1000)}s`);
888
907
  if (idleMs > 0)
889
908
  parts.push(`idle=${Math.round(idleMs / 1000)}s`);
890
909
  this.log('info', `Lifecycle monitor active (${parts.join(', ')})`);
891
910
  }
911
+ /**
912
+ * Decide whether the daemon should self-shutdown, and why. Extracted from
913
+ * the lifecycle timer so it is testable without racing a 60s interval or
914
+ * calling process.exit().
915
+ *
916
+ * #2661 (invariant 6, containment form): a removed worktree makes its
917
+ * daemon ineligible within one check interval — the daemon detects that
918
+ * its workspace directory is gone and shuts down instead of continuing to
919
+ * schedule jobs against a deleted tree. The full lease architecture
920
+ * (supervisor-dispatched jobs, heartbeats) is follow-up work; this stops
921
+ * the leak where recreated/removed worktrees leave schedulers behind.
922
+ */
923
+ lifecycleShutdownReason(now) {
924
+ if (!existsSync(this.projectRoot)) {
925
+ return 'workspace directory removed (#2661)';
926
+ }
927
+ const ttlMs = this.config.ttlMs;
928
+ const idleMs = this.config.idleShutdownMs;
929
+ const startedMs = this.startedAt?.getTime() ?? now;
930
+ if (ttlMs > 0 && now - startedMs >= ttlMs) {
931
+ return `max age ${Math.round(ttlMs / 1000)}s reached`;
932
+ }
933
+ if (idleMs > 0) {
934
+ const lastActivity = this.lastWorkerActivityMs() ?? startedMs;
935
+ if (now - lastActivity >= idleMs) {
936
+ return `idle for ${Math.round(idleMs / 1000)}s (no worker activity)`;
937
+ }
938
+ }
939
+ return null;
940
+ }
892
941
  /**
893
942
  * Most recent worker start/finish time across all workers (epoch ms), or
894
943
  * null if no worker has ever started. Used for idle-shutdown detection.
@@ -1100,8 +1149,11 @@ export class WorkerDaemon extends EventEmitter {
1100
1149
  * Run the actual worker logic
1101
1150
  */
1102
1151
  async runWorkerLogic(workerConfig) {
1103
- // Check if this is a headless worker type and headless execution is available
1104
- if (isHeadlessWorker(workerConfig.type) && this.headlessAvailable && this.headlessExecutor) {
1152
+ // Check if this is a headless worker type and headless execution is available.
1153
+ // #2661 — aiWorkersEnabled is re-checked here (not just at init) as
1154
+ // defence in depth: no code path may promote a worker to `claude --print`
1155
+ // without explicit consent.
1156
+ if (this.config.aiWorkersEnabled && isHeadlessWorker(workerConfig.type) && this.headlessAvailable && this.headlessExecutor) {
1105
1157
  try {
1106
1158
  this.log('info', `Running ${workerConfig.type} in headless mode (Claude Code AI)`);
1107
1159
  const result = await this.headlessExecutor.execute(workerConfig.type);
@@ -1124,6 +1176,18 @@ export class WorkerDaemon extends EventEmitter {
1124
1176
  });
1125
1177
  // Fall through to local switch.
1126
1178
  }
1179
+ else if (result.dedupSkipped) {
1180
+ // #2661 invariant 5 — the same job (repositoryId + HEAD + worker +
1181
+ // config) already succeeded within the freshness window, e.g. in a
1182
+ // sibling worktree. No model call happened; do NOT overwrite the
1183
+ // persisted metrics (which hold the real prior result) and do NOT
1184
+ // fall back to local — the work is already done.
1185
+ this.log('info', `Worker ${workerConfig.type} dedup-skipped (same repo+HEAD job ran recently in another worktree)`);
1186
+ return {
1187
+ mode: 'headless-dedup-skip',
1188
+ ...result,
1189
+ };
1190
+ }
1127
1191
  else {
1128
1192
  // #1793: persist the headless result to the same metrics files the
1129
1193
  // local workers write to. Without this, AI-mode runs produced rich
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@claude-flow/cli",
3
- "version": "3.26.1",
3
+ "version": "3.27.0",
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",