@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.
- package/.claude/helpers/helpers.manifest.json +3 -3
- package/.claude/helpers/statusline.cjs +12 -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/init/statusline-generator.js +12 -2
- 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
|
@@ -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
|
|
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
|
|
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
|
|
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
|
-
|
|
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",
|