@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.
- package/.claude/helpers/helpers.manifest.json +2 -2
- package/catalog-manifest.json +2 -2
- package/dist/src/commands/daemon.js +163 -3
- package/dist/src/init/helpers-generator.js +31 -0
- package/dist/src/services/ai-job-dedup.d.ts +61 -0
- package/dist/src/services/ai-job-dedup.js +136 -0
- package/dist/src/services/git-workspace-identity.d.ts +42 -0
- package/dist/src/services/git-workspace-identity.js +99 -0
- package/dist/src/services/global-ai-budget.d.ts +110 -0
- package/dist/src/services/global-ai-budget.js +359 -0
- package/dist/src/services/headless-worker-executor.d.ts +14 -0
- package/dist/src/services/headless-worker-executor.js +104 -3
- package/dist/src/services/worker-daemon.d.ts +14 -0
- package/dist/src/services/worker-daemon.js +81 -17
- package/package.json +1 -1
|
@@ -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
|
-
|
|
863
|
-
|
|
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
|
|
870
|
-
|
|
871
|
-
|
|
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
|
-
|
|
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.
|
|
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",
|