@claude-flow/cli 3.26.0 → 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.
@@ -22,6 +22,9 @@ import { spawn, execSync } from 'child_process';
22
22
  import { EventEmitter } from 'events';
23
23
  import { existsSync, readFileSync, readdirSync, mkdirSync, writeFileSync } from 'fs';
24
24
  import { join } from 'path';
25
+ import { getGlobalAiBudget, isQuotaErrorText } from './global-ai-budget.js';
26
+ import { resolveGitWorkspaceIdentity } from './git-workspace-identity.js';
27
+ import { getAiJobDedupRegistry, computeAiJobKey, hashWorkerConfig } from './ai-job-dedup.js';
25
28
  // ============================================
26
29
  // Constants
27
30
  // ============================================
@@ -517,6 +520,25 @@ export class HeadlessWorkerExecutor extends EventEmitter {
517
520
  getActiveCount() {
518
521
  return this.processPool.size;
519
522
  }
523
+ /**
524
+ * #2661 — signal a pool entry's whole process group, not just the head.
525
+ * Children are spawned `detached: true` on POSIX precisely so their MCP
526
+ * bridge grandchildren can be reaped with `kill(-pid)`; a head-only kill
527
+ * orphans them (#2098B).
528
+ */
529
+ killEntryTree(proc, signal) {
530
+ if (process.platform !== 'win32' && typeof proc.pid === 'number') {
531
+ try {
532
+ process.kill(-proc.pid, signal);
533
+ return;
534
+ }
535
+ catch { /* fall through */ }
536
+ }
537
+ try {
538
+ proc.kill(signal);
539
+ }
540
+ catch { /* already dead */ }
541
+ }
520
542
  /**
521
543
  * Cancel a running execution
522
544
  */
@@ -526,7 +548,7 @@ export class HeadlessWorkerExecutor extends EventEmitter {
526
548
  return false;
527
549
  }
528
550
  clearTimeout(entry.timeout);
529
- entry.process.kill('SIGTERM');
551
+ this.killEntryTree(entry.process, 'SIGTERM');
530
552
  this.processPool.delete(executionId);
531
553
  this.emit('cancelled', { executionId });
532
554
  // Process next in queue
@@ -542,12 +564,12 @@ export class HeadlessWorkerExecutor extends EventEmitter {
542
564
  const entries = Array.from(this.processPool.entries());
543
565
  for (const [executionId, entry] of entries) {
544
566
  clearTimeout(entry.timeout);
545
- entry.process.kill('SIGTERM');
567
+ this.killEntryTree(entry.process, 'SIGTERM');
546
568
  // SIGKILL fallback after 5s to prevent orphan processes (#1395 Bug 6)
547
569
  setTimeout(() => {
548
570
  try {
549
571
  if (!entry.process.killed)
550
- entry.process.kill('SIGKILL');
572
+ this.killEntryTree(entry.process, 'SIGKILL');
551
573
  }
552
574
  catch { /* already dead */ }
553
575
  }, 5000).unref();
@@ -612,6 +634,63 @@ export class HeadlessWorkerExecutor extends EventEmitter {
612
634
  const headless = { ...baseConfig.headless, ...configOverrides };
613
635
  const startTime = Date.now();
614
636
  const executionId = `${workerType}_${startTime}_${Math.random().toString(36).slice(2, 8)}`;
637
+ // #2661 invariant 5 — cross-worktree job dedup. Worktrees of one
638
+ // repository at the same HEAD would otherwise run identical analyses
639
+ // once per worktree. jobKey = sha256(repositoryId, HEAD, worker,
640
+ // configHash); a success within the freshness window (the worker's own
641
+ // interval, floor 10 min) skips the launch entirely — no budget spend,
642
+ // no process. HEAD moves → new key → the job runs again.
643
+ const identity = resolveGitWorkspaceIdentity(this.projectRoot);
644
+ const jobKey = computeAiJobKey({
645
+ repositoryId: identity.repositoryId,
646
+ head: identity.head,
647
+ workerType,
648
+ configHash: hashWorkerConfig(headless),
649
+ });
650
+ const dedup = getAiJobDedupRegistry();
651
+ const envWindowSecs = Number.parseInt(process.env.RUFLO_AI_DEDUP_WINDOW_SECS || '', 10);
652
+ const freshnessMs = Number.isFinite(envWindowSecs) && envWindowSecs >= 0
653
+ ? envWindowSecs * 1000
654
+ : Math.max(baseConfig.intervalMs || 0, 10 * 60 * 1000);
655
+ const freshness = dedup.isFresh(jobKey, freshnessMs);
656
+ if (freshness.fresh) {
657
+ const skipped = {
658
+ success: true,
659
+ dedupSkipped: true,
660
+ output: '',
661
+ parsedOutput: undefined,
662
+ durationMs: 0,
663
+ model: 'none',
664
+ sandboxMode: headless.sandbox,
665
+ workerType,
666
+ timestamp: new Date(),
667
+ executionId,
668
+ };
669
+ this.logExecution(executionId, 'result', `dedup-skip: job ${jobKey.slice(0, 12)} succeeded ${Math.round((Date.now() - (freshness.lastRunAt ?? Date.now())) / 1000)}s ago (repo ${identity.repositoryId.slice(0, 12)}, head ${identity.head.slice(0, 12) || 'n/a'})`);
670
+ this.emit('dedup:skipped', { executionId, workerType, jobKey, lastRunAt: freshness.lastRunAt });
671
+ this.processQueue();
672
+ return skipped;
673
+ }
674
+ // #2661 — every autonomous launch must reserve a slot in the USER-GLOBAL
675
+ // AI budget before any process is created. This is the hard invariant
676
+ // that bounds aggregate launches across all worktree daemons: per-daemon
677
+ // maxConcurrent limits multiply with worktree count, the global budget
678
+ // does not. Denials return an error result (with a receipted reason)
679
+ // instead of queueing, so denied work never piles up into a retry storm.
680
+ const budget = getGlobalAiBudget();
681
+ const model = headless.model || 'sonnet';
682
+ const permit = await budget.reserve({ workerType, model, workspace: this.projectRoot });
683
+ if (!permit.allowed) {
684
+ const denied = this.createErrorResult(workerType, `Denied by global AI budget: ${permit.reason}`);
685
+ denied.executionId = executionId;
686
+ this.logExecution(executionId, 'error', `budget-denied: ${permit.reason}`);
687
+ // NOTE: deliberately no `emit('error', ...)` here — Node treats
688
+ // unlistened 'error' events as throws, and callers consume the
689
+ // returned error result; `budget:denied` is the observable signal.
690
+ this.emit('budget:denied', { executionId, workerType, reason: permit.reason });
691
+ this.processQueue();
692
+ return denied;
693
+ }
615
694
  this.emit('start', { executionId, workerType, config: headless });
616
695
  try {
617
696
  // Build context from file patterns
@@ -651,6 +730,23 @@ export class HeadlessWorkerExecutor extends EventEmitter {
651
730
  };
652
731
  // Log result
653
732
  this.logExecution(executionId, 'result', JSON.stringify(executionResult, null, 2));
733
+ // #2661 invariant 5 — record the success so sibling worktrees at the
734
+ // same HEAD skip this job for the rest of the freshness window.
735
+ if (result.success) {
736
+ dedup.recordSuccess(jobKey, {
737
+ workerType,
738
+ repositoryId: identity.repositoryId,
739
+ workspace: this.projectRoot,
740
+ });
741
+ }
742
+ // #2661 — a quota/429/usage-limit failure opens the user-global
743
+ // circuit breaker so EVERY daemon stops launching for the cooldown
744
+ // window instead of retrying into an exhausted quota. Only inspected
745
+ // on failure: successful analysis output may legitimately discuss
746
+ // rate limiting in the user's own code.
747
+ if (!result.success && isQuotaErrorText(result.error)) {
748
+ await budget.recordQuotaError(`${workerType}: ${(result.error ?? '').slice(0, 200)}`);
749
+ }
654
750
  this.emit('complete', executionResult);
655
751
  return executionResult;
656
752
  }
@@ -660,10 +756,15 @@ export class HeadlessWorkerExecutor extends EventEmitter {
660
756
  executionResult.executionId = executionId;
661
757
  executionResult.durationMs = Date.now() - startTime;
662
758
  this.logExecution(executionId, 'error', errorMessage);
759
+ if (isQuotaErrorText(errorMessage)) {
760
+ await budget.recordQuotaError(`${workerType}: ${errorMessage.slice(0, 200)}`);
761
+ }
663
762
  this.emit('error', executionResult);
664
763
  return executionResult;
665
764
  }
666
765
  finally {
766
+ // #2661 — free the global concurrency slot (launch counts persist).
767
+ await budget.release(permit.permitId);
667
768
  // Process next in queue
668
769
  this.processQueue();
669
770
  }
@@ -58,6 +58,7 @@ export interface DaemonConfig {
58
58
  };
59
59
  ttlMs: number;
60
60
  idleShutdownMs: number;
61
+ aiWorkersEnabled: boolean;
61
62
  workers: WorkerConfig[];
62
63
  }
63
64
  /**
@@ -237,6 +238,19 @@ export declare class WorkerDaemon extends EventEmitter {
237
238
  * on its own. A no-op when both limits are disabled (0).
238
239
  */
239
240
  private startLifecycleMonitor;
241
+ /**
242
+ * Decide whether the daemon should self-shutdown, and why. Extracted from
243
+ * the lifecycle timer so it is testable without racing a 60s interval or
244
+ * calling process.exit().
245
+ *
246
+ * #2661 (invariant 6, containment form): a removed worktree makes its
247
+ * daemon ineligible within one check interval — the daemon detects that
248
+ * its workspace directory is gone and shuts down instead of continuing to
249
+ * schedule jobs against a deleted tree. The full lease architecture
250
+ * (supervisor-dispatched jobs, heartbeats) is follow-up work; this stops
251
+ * the leak where recreated/removed worktrees leave schedulers behind.
252
+ */
253
+ private lifecycleShutdownReason;
240
254
  /**
241
255
  * Most recent worker start/finish time across all workers (epoch ms), or
242
256
  * null if no worker has ever started. Used for idle-shutdown detection.
@@ -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.0",
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",