@claude-flow/cli 3.27.3 → 3.28.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.
@@ -0,0 +1,70 @@
1
+ /**
2
+ * #2661 root-fix — one repository-level supervisor.
3
+ *
4
+ * Ten worktree daemons of the same repository independently deciding "is it
5
+ * time to run audit/optimize/testgaps" every tick is the redundant-scheduler
6
+ * half of the original cardinality bug — the budget ledger and job dedup
7
+ * (global-ai-budget.ts, ai-job-dedup.ts) already bound and dedupe the actual
8
+ * `claude --print` spend, but every daemon still runs its own timer and its
9
+ * own "should I launch" decision. This module elects exactly ONE daemon per
10
+ * repositoryId to own the recurring AI-worker schedule; every other worktree
11
+ * of that repository stays a lease-only participant (workspace-lease.ts) —
12
+ * it keeps running its cheap, $0 local-only workers (map/consolidate/backup)
13
+ * on its own schedule, but does not attempt headless (`claude --print`)
14
+ * execution for its recurring ticks. An explicit `daemon trigger --headless`
15
+ * in a non-supervisor worktree is still honored (still budget/dedup-gated) —
16
+ * this module only governs the unattended recurring schedule.
17
+ *
18
+ * Election is a simple lock-protected takeover, same pattern as
19
+ * global-ai-budget.ts:
20
+ * - No record, a dead PID, or a stale heartbeat (>SUPERVISOR_STALE_MS) →
21
+ * the calling daemon takes over.
22
+ * - A live supervisor with a fresh heartbeat → the calling daemon is not
23
+ * elected; it does nothing (never overwrites a healthy supervisor).
24
+ *
25
+ * The registry lives under the user's home directory so every worktree's
26
+ * daemon can see it:
27
+ *
28
+ * ~/.claude-flow/supervisors/<repositoryId>.json
29
+ */
30
+ export declare const SUPERVISOR_STALE_MS: number;
31
+ export interface SupervisorRecord {
32
+ worktreeRoot: string;
33
+ pid: number;
34
+ electedAt: number;
35
+ lastHeartbeat: number;
36
+ }
37
+ export interface SupervisorElectionResult {
38
+ isSupervisor: boolean;
39
+ record: SupervisorRecord | null;
40
+ }
41
+ export declare class RepoSupervisorRegistry {
42
+ private readonly dir;
43
+ constructor(options?: {
44
+ baseDir?: string;
45
+ });
46
+ private fileFor;
47
+ private ensureDir;
48
+ private withLock;
49
+ private readRecord;
50
+ private writeRecord;
51
+ private isStale;
52
+ /**
53
+ * Attempt election (or renewal, if this process already holds it). Safe
54
+ * and cheap to call on every daemon tick — a no-op write when nothing
55
+ * needs to change would still cost a lock+read+write, so callers should
56
+ * still throttle to their own heartbeat cadence rather than calling this
57
+ * per-worker-tick.
58
+ */
59
+ electOrRenew(repositoryId: string, worktreeRoot: string): Promise<SupervisorElectionResult>;
60
+ /** Cheap read-only check — does NOT renew or elect. */
61
+ isSupervisor(repositoryId: string, worktreeRoot: string): boolean;
62
+ /** Release supervisor status on graceful shutdown, so the next tick elsewhere can take over promptly. Best-effort. */
63
+ release(repositoryId: string, worktreeRoot: string): Promise<void>;
64
+ /** Snapshot for `daemon status --all`. Read-only, never mutates. */
65
+ getRecord(repositoryId: string): SupervisorRecord | null;
66
+ }
67
+ export declare function getRepoSupervisorRegistry(): RepoSupervisorRegistry;
68
+ /** Test hook: reset the singleton (e.g. after changing RUFLO_AI_BUDGET_DIR). */
69
+ export declare function resetRepoSupervisorRegistryForTests(): void;
70
+ //# sourceMappingURL=repo-supervisor.d.ts.map
@@ -0,0 +1,228 @@
1
+ /**
2
+ * #2661 root-fix — one repository-level supervisor.
3
+ *
4
+ * Ten worktree daemons of the same repository independently deciding "is it
5
+ * time to run audit/optimize/testgaps" every tick is the redundant-scheduler
6
+ * half of the original cardinality bug — the budget ledger and job dedup
7
+ * (global-ai-budget.ts, ai-job-dedup.ts) already bound and dedupe the actual
8
+ * `claude --print` spend, but every daemon still runs its own timer and its
9
+ * own "should I launch" decision. This module elects exactly ONE daemon per
10
+ * repositoryId to own the recurring AI-worker schedule; every other worktree
11
+ * of that repository stays a lease-only participant (workspace-lease.ts) —
12
+ * it keeps running its cheap, $0 local-only workers (map/consolidate/backup)
13
+ * on its own schedule, but does not attempt headless (`claude --print`)
14
+ * execution for its recurring ticks. An explicit `daemon trigger --headless`
15
+ * in a non-supervisor worktree is still honored (still budget/dedup-gated) —
16
+ * this module only governs the unattended recurring schedule.
17
+ *
18
+ * Election is a simple lock-protected takeover, same pattern as
19
+ * global-ai-budget.ts:
20
+ * - No record, a dead PID, or a stale heartbeat (>SUPERVISOR_STALE_MS) →
21
+ * the calling daemon takes over.
22
+ * - A live supervisor with a fresh heartbeat → the calling daemon is not
23
+ * elected; it does nothing (never overwrites a healthy supervisor).
24
+ *
25
+ * The registry lives under the user's home directory so every worktree's
26
+ * daemon can see it:
27
+ *
28
+ * ~/.claude-flow/supervisors/<repositoryId>.json
29
+ */
30
+ import * as fs from 'fs';
31
+ import { join } from 'path';
32
+ import { homedir } from 'os';
33
+ // Longer than any plausible daemon heartbeat interval (daemons renew via the
34
+ // existing 60s lifecycle-monitor tick) so a merely-slow tick never triggers
35
+ // a false takeover, but short enough that a crashed supervisor's worktree
36
+ // yields the schedule within a few minutes rather than indefinitely.
37
+ export const SUPERVISOR_STALE_MS = 3 * 60 * 1000;
38
+ const LOCK_STALE_MS = 10_000;
39
+ function delay(ms) {
40
+ return new Promise((r) => setTimeout(r, ms));
41
+ }
42
+ function isProcessAlive(pid) {
43
+ try {
44
+ process.kill(pid, 0);
45
+ return true;
46
+ }
47
+ catch {
48
+ return false;
49
+ }
50
+ }
51
+ /** Invariant 9 (#2661): registry files must never be symlinks. */
52
+ function assertNotSymlink(path) {
53
+ try {
54
+ const st = fs.lstatSync(path);
55
+ if (st.isSymbolicLink()) {
56
+ throw new Error(`Repo-supervisor file is a symlink (refusing): ${path}`);
57
+ }
58
+ }
59
+ catch (e) {
60
+ if (e.code === 'ENOENT')
61
+ return;
62
+ throw e;
63
+ }
64
+ }
65
+ export class RepoSupervisorRegistry {
66
+ dir;
67
+ constructor(options) {
68
+ this.dir = options?.baseDir
69
+ ?? process.env.RUFLO_AI_BUDGET_DIR
70
+ ?? join(homedir(), '.claude-flow');
71
+ }
72
+ fileFor(repositoryId) {
73
+ return join(this.dir, 'supervisors', `${repositoryId}.json`);
74
+ }
75
+ ensureDir() {
76
+ const dir = join(this.dir, 'supervisors');
77
+ if (!fs.existsSync(dir))
78
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
79
+ }
80
+ async withLock(repositoryId, fn) {
81
+ this.ensureDir();
82
+ const lockFile = `${this.fileFor(repositoryId)}.lock`;
83
+ const deadline = Date.now() + 2000;
84
+ for (;;) {
85
+ try {
86
+ const fd = fs.openSync(lockFile, fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY, 0o600);
87
+ fs.writeSync(fd, String(process.pid));
88
+ fs.closeSync(fd);
89
+ try {
90
+ return fn();
91
+ }
92
+ finally {
93
+ try {
94
+ fs.unlinkSync(lockFile);
95
+ }
96
+ catch { /* already gone */ }
97
+ }
98
+ }
99
+ catch (e) {
100
+ if (e.code !== 'EEXIST')
101
+ throw e;
102
+ try {
103
+ const st = fs.lstatSync(lockFile);
104
+ if (Date.now() - st.mtimeMs > LOCK_STALE_MS) {
105
+ fs.unlinkSync(lockFile);
106
+ continue;
107
+ }
108
+ }
109
+ catch { /* raced — retry */ }
110
+ if (Date.now() > deadline)
111
+ throw new Error('timed out acquiring repo-supervisor lock');
112
+ await delay(25);
113
+ }
114
+ }
115
+ }
116
+ readRecord(repositoryId) {
117
+ const file = this.fileFor(repositoryId);
118
+ assertNotSymlink(file);
119
+ if (!fs.existsSync(file))
120
+ return null;
121
+ try {
122
+ const raw = JSON.parse(fs.readFileSync(file, 'utf-8'));
123
+ if (raw && typeof raw.pid === 'number' && typeof raw.lastHeartbeat === 'number') {
124
+ return raw;
125
+ }
126
+ }
127
+ catch { /* corrupt — treat as absent */ }
128
+ return null;
129
+ }
130
+ writeRecord(repositoryId, record) {
131
+ const file = this.fileFor(repositoryId);
132
+ assertNotSymlink(file);
133
+ const tmp = `${file}.tmp.${process.pid}`;
134
+ fs.writeFileSync(tmp, JSON.stringify(record), { mode: 0o600 });
135
+ fs.renameSync(tmp, file);
136
+ }
137
+ isStale(record, now) {
138
+ return now - record.lastHeartbeat > SUPERVISOR_STALE_MS || !isProcessAlive(record.pid);
139
+ }
140
+ /**
141
+ * Attempt election (or renewal, if this process already holds it). Safe
142
+ * and cheap to call on every daemon tick — a no-op write when nothing
143
+ * needs to change would still cost a lock+read+write, so callers should
144
+ * still throttle to their own heartbeat cadence rather than calling this
145
+ * per-worker-tick.
146
+ */
147
+ async electOrRenew(repositoryId, worktreeRoot) {
148
+ try {
149
+ return await this.withLock(repositoryId, () => {
150
+ const now = Date.now();
151
+ const existing = this.readRecord(repositoryId);
152
+ if (existing && existing.pid === process.pid && existing.worktreeRoot === worktreeRoot) {
153
+ const renewed = { ...existing, lastHeartbeat: now };
154
+ this.writeRecord(repositoryId, renewed);
155
+ return { isSupervisor: true, record: renewed };
156
+ }
157
+ if (!existing || this.isStale(existing, now)) {
158
+ const record = { worktreeRoot, pid: process.pid, electedAt: now, lastHeartbeat: now };
159
+ this.writeRecord(repositoryId, record);
160
+ return { isSupervisor: true, record };
161
+ }
162
+ // A live supervisor already owns this repository — never overwrite it.
163
+ return { isSupervisor: false, record: existing };
164
+ });
165
+ }
166
+ catch {
167
+ // Fail closed on the SCHEDULE side too: if we can't safely coordinate,
168
+ // assume we are NOT the supervisor rather than risk two daemons both
169
+ // believing they own the schedule. The budget ledger is the actual
170
+ // hard invariant either way.
171
+ return { isSupervisor: false, record: null };
172
+ }
173
+ }
174
+ /** Cheap read-only check — does NOT renew or elect. */
175
+ isSupervisor(repositoryId, worktreeRoot) {
176
+ try {
177
+ const existing = this.readRecord(repositoryId);
178
+ if (!existing)
179
+ return false;
180
+ const now = Date.now();
181
+ if (this.isStale(existing, now))
182
+ return false;
183
+ return existing.pid === process.pid && existing.worktreeRoot === worktreeRoot;
184
+ }
185
+ catch {
186
+ return false;
187
+ }
188
+ }
189
+ /** Release supervisor status on graceful shutdown, so the next tick elsewhere can take over promptly. Best-effort. */
190
+ async release(repositoryId, worktreeRoot) {
191
+ try {
192
+ await this.withLock(repositoryId, () => {
193
+ const existing = this.readRecord(repositoryId);
194
+ if (existing && existing.pid === process.pid && existing.worktreeRoot === worktreeRoot) {
195
+ const file = this.fileFor(repositoryId);
196
+ try {
197
+ fs.unlinkSync(file);
198
+ }
199
+ catch { /* already gone */ }
200
+ }
201
+ });
202
+ }
203
+ catch { /* best-effort */ }
204
+ }
205
+ /** Snapshot for `daemon status --all`. Read-only, never mutates. */
206
+ getRecord(repositoryId) {
207
+ try {
208
+ const existing = this.readRecord(repositoryId);
209
+ if (!existing || this.isStale(existing, Date.now()))
210
+ return null;
211
+ return existing;
212
+ }
213
+ catch {
214
+ return null;
215
+ }
216
+ }
217
+ }
218
+ let registryInstance = null;
219
+ export function getRepoSupervisorRegistry() {
220
+ if (!registryInstance)
221
+ registryInstance = new RepoSupervisorRegistry();
222
+ return registryInstance;
223
+ }
224
+ /** Test hook: reset the singleton (e.g. after changing RUFLO_AI_BUDGET_DIR). */
225
+ export function resetRepoSupervisorRegistryForTests() {
226
+ registryInstance = null;
227
+ }
228
+ //# sourceMappingURL=repo-supervisor.js.map
@@ -12,6 +12,7 @@
12
12
  */
13
13
  import { EventEmitter } from 'events';
14
14
  import { HeadlessWorkerExecutor } from './headless-worker-executor.js';
15
+ import { type SupervisorRecord } from './repo-supervisor.js';
15
16
  export type WorkerType = 'ultralearn' | 'optimize' | 'consolidate' | 'predict' | 'audit' | 'map' | 'preload' | 'deepdive' | 'document' | 'refactor' | 'benchmark' | 'testgaps' | 'backup' | 'harness';
16
17
  interface WorkerConfig {
17
18
  type: WorkerType;
@@ -45,6 +46,12 @@ interface DaemonStatus {
45
46
  startedAt?: Date;
46
47
  workers: Map<WorkerType, WorkerState>;
47
48
  config: DaemonConfig;
49
+ supervisor?: {
50
+ repositoryId: string;
51
+ isSupervisor: boolean;
52
+ record: SupervisorRecord | null;
53
+ activeLeases: number;
54
+ } | null;
48
55
  }
49
56
  export interface DaemonConfig {
50
57
  autoStart: boolean;
@@ -79,6 +86,8 @@ export declare class WorkerDaemon extends EventEmitter {
79
86
  private headlessAvailable;
80
87
  private headlessInitPromise;
81
88
  private originalConfig?;
89
+ private gitIdentity;
90
+ private lastSupervisorRenewalMs;
82
91
  constructor(projectRoot: string, config?: Partial<DaemonConfig>);
83
92
  /**
84
93
  * Initialize headless executor if Claude Code is available
@@ -264,6 +273,23 @@ export declare class WorkerDaemon extends EventEmitter {
264
273
  * service timers — leaving a zombie that reports stopped but never exits.
265
274
  */
266
275
  private selfShutdown;
276
+ /**
277
+ * #2661 root-fix — heartbeat this worktree's lease and attempt/renew
278
+ * repository-supervisor election. Best-effort: any failure here must
279
+ * never affect worker scheduling correctness (the budget ledger remains
280
+ * the hard invariant regardless of supervisor state) — a daemon that
281
+ * can't safely coordinate simply falls back to "not supervisor" and runs
282
+ * its cheap local-only workers, same as any other lease-only worktree.
283
+ */
284
+ private renewLeaseAndSupervisor;
285
+ /**
286
+ * Cheap read-only check: does THIS daemon currently hold repository
287
+ * supervisor status? Used to gate recurring headless (`claude --print`)
288
+ * execution — see repo-supervisor.ts's module doc comment for the full
289
+ * rationale. Never true when AI workers are disabled or the git identity
290
+ * couldn't be resolved (e.g. a non-git directory).
291
+ */
292
+ private isRepositorySupervisor;
267
293
  /**
268
294
  * Get daemon status
269
295
  */
@@ -22,6 +22,9 @@ import { HeadlessWorkerExecutor, isHeadlessWorker, } from './headless-worker-exe
22
22
  // quick_check-gated, so it's safe to call unconditionally on every tick.
23
23
  import { runDistillation, defaultMemoryDbPath } from './memory-distillation.js';
24
24
  import { backupMemoryDb } from './memory-backup.js';
25
+ import { resolveGitWorkspaceIdentity } from './git-workspace-identity.js';
26
+ import { getWorkspaceLeaseRegistry } from './workspace-lease.js';
27
+ import { getRepoSupervisorRegistry } from './repo-supervisor.js';
25
28
  // Default worker configurations with improved intervals (P0 fix: map 5min -> 15min)
26
29
  const DEFAULT_WORKERS = [
27
30
  { type: 'map', intervalMs: 15 * 60 * 1000, offsetMs: 0, priority: 'normal', description: 'Codebase mapping', enabled: true },
@@ -112,6 +115,11 @@ export class WorkerDaemon extends EventEmitter {
112
115
  // Preserve the original constructor config so we can detect explicit overrides
113
116
  // during state restoration (R1: constructor config takes priority over stale state)
114
117
  originalConfig;
118
+ // #2661 root-fix — resolved once (git identity doesn't change at runtime)
119
+ // and reused for lease heartbeats + supervisor election, both gated on
120
+ // aiWorkersEnabled since they only matter for the recurring AI schedule.
121
+ gitIdentity = null;
122
+ lastSupervisorRenewalMs = 0;
115
123
  constructor(projectRoot, config) {
116
124
  super();
117
125
  this.projectRoot = projectRoot;
@@ -739,6 +747,14 @@ export class WorkerDaemon extends EventEmitter {
739
747
  this.startedAt = new Date();
740
748
  this.writePidFile();
741
749
  this.emit('started', { pid: process.pid, startedAt: this.startedAt });
750
+ // #2661 root-fix — resolve repository identity and register/attempt
751
+ // election immediately at start, not just on the first 60s lifecycle
752
+ // tick, so `daemon status` reflects supervisor state right away. Only
753
+ // meaningful when AI workers are enabled (see field doc comment).
754
+ if (this.config.aiWorkersEnabled) {
755
+ this.gitIdentity = resolveGitWorkspaceIdentity(this.projectRoot);
756
+ void this.renewLeaseAndSupervisor();
757
+ }
742
758
  // Schedule all enabled workers
743
759
  for (const workerConfig of this.config.workers) {
744
760
  if (workerConfig.enabled) {
@@ -867,6 +883,19 @@ export class WorkerDaemon extends EventEmitter {
867
883
  }
868
884
  catch { /* best-effort */ }
869
885
  }
886
+ // #2661 root-fix — release this worktree's lease and, if held,
887
+ // supervisor status, so a sibling worktree's daemon can take over the
888
+ // schedule within its next tick instead of waiting out the 3-minute
889
+ // supervisor staleness window. Best-effort — a graceful release is an
890
+ // optimization; the staleness timeout is what actually bounds a crash.
891
+ if (this.config.aiWorkersEnabled && this.gitIdentity) {
892
+ const { repositoryId, worktreeRoot } = this.gitIdentity;
893
+ try {
894
+ await getRepoSupervisorRegistry().release(repositoryId, worktreeRoot);
895
+ await getWorkspaceLeaseRegistry().release(repositoryId, worktreeRoot);
896
+ }
897
+ catch { /* best-effort */ }
898
+ }
870
899
  this.running = false;
871
900
  this.removePidFile();
872
901
  this.saveState();
@@ -896,6 +925,13 @@ export class WorkerDaemon extends EventEmitter {
896
925
  const reason = this.lifecycleShutdownReason(Date.now());
897
926
  if (reason) {
898
927
  void this.selfShutdown(reason);
928
+ return;
929
+ }
930
+ // #2661 root-fix — renew this worktree's lease + supervisor status on
931
+ // the same cadence. Both windows (15min lease TTL, 3min supervisor
932
+ // staleness) comfortably outlive a single missed 60s tick.
933
+ if (this.config.aiWorkersEnabled) {
934
+ void this.renewLeaseAndSupervisor();
899
935
  }
900
936
  }, CHECK_INTERVAL_MS);
901
937
  if (typeof this.lifecycleTimer.unref === 'function') {
@@ -971,16 +1007,58 @@ export class WorkerDaemon extends EventEmitter {
971
1007
  catch { /* best-effort — we are exiting regardless */ }
972
1008
  process.exit(0);
973
1009
  }
1010
+ /**
1011
+ * #2661 root-fix — heartbeat this worktree's lease and attempt/renew
1012
+ * repository-supervisor election. Best-effort: any failure here must
1013
+ * never affect worker scheduling correctness (the budget ledger remains
1014
+ * the hard invariant regardless of supervisor state) — a daemon that
1015
+ * can't safely coordinate simply falls back to "not supervisor" and runs
1016
+ * its cheap local-only workers, same as any other lease-only worktree.
1017
+ */
1018
+ async renewLeaseAndSupervisor() {
1019
+ if (!this.gitIdentity)
1020
+ return;
1021
+ const { repositoryId, worktreeRoot } = this.gitIdentity;
1022
+ try {
1023
+ await getWorkspaceLeaseRegistry().heartbeat(repositoryId, worktreeRoot);
1024
+ await getRepoSupervisorRegistry().electOrRenew(repositoryId, worktreeRoot);
1025
+ this.lastSupervisorRenewalMs = Date.now();
1026
+ }
1027
+ catch { /* best-effort — see docstring */ }
1028
+ }
1029
+ /**
1030
+ * Cheap read-only check: does THIS daemon currently hold repository
1031
+ * supervisor status? Used to gate recurring headless (`claude --print`)
1032
+ * execution — see repo-supervisor.ts's module doc comment for the full
1033
+ * rationale. Never true when AI workers are disabled or the git identity
1034
+ * couldn't be resolved (e.g. a non-git directory).
1035
+ */
1036
+ isRepositorySupervisor() {
1037
+ if (!this.config.aiWorkersEnabled || !this.gitIdentity)
1038
+ return false;
1039
+ return getRepoSupervisorRegistry().isSupervisor(this.gitIdentity.repositoryId, this.gitIdentity.worktreeRoot);
1040
+ }
974
1041
  /**
975
1042
  * Get daemon status
976
1043
  */
977
1044
  getStatus() {
1045
+ let supervisor = null;
1046
+ if (this.config.aiWorkersEnabled && this.gitIdentity) {
1047
+ const { repositoryId, worktreeRoot } = this.gitIdentity;
1048
+ supervisor = {
1049
+ repositoryId,
1050
+ isSupervisor: getRepoSupervisorRegistry().isSupervisor(repositoryId, worktreeRoot),
1051
+ record: getRepoSupervisorRegistry().getRecord(repositoryId),
1052
+ activeLeases: getWorkspaceLeaseRegistry().listActive(repositoryId).length,
1053
+ };
1054
+ }
978
1055
  return {
979
1056
  running: this.running,
980
1057
  pid: process.pid,
981
1058
  startedAt: this.startedAt,
982
1059
  workers: new Map(this.workers),
983
1060
  config: this.config,
1061
+ supervisor,
984
1062
  };
985
1063
  }
986
1064
  /**
@@ -1038,7 +1116,7 @@ export class WorkerDaemon extends EventEmitter {
1038
1116
  /**
1039
1117
  * Execute a worker with timeout protection
1040
1118
  */
1041
- async executeWorker(workerConfig) {
1119
+ async executeWorker(workerConfig, opts) {
1042
1120
  const state = this.workers.get(workerConfig.type);
1043
1121
  const workerId = `${workerConfig.type}_${Date.now()}`;
1044
1122
  const startTime = Date.now();
@@ -1052,7 +1130,7 @@ export class WorkerDaemon extends EventEmitter {
1052
1130
  try {
1053
1131
  // Execute worker logic with timeout (P1 fix)
1054
1132
  // Pass cleanup callback to kill orphan child processes on timeout (#1117)
1055
- const output = await this.runWithTimeout(() => this.runWorkerLogic(workerConfig), this.config.workerTimeoutMs, `Worker ${workerConfig.type} timed out after ${this.config.workerTimeoutMs / 1000}s`, () => {
1133
+ const output = await this.runWithTimeout(() => this.runWorkerLogic(workerConfig, opts), this.config.workerTimeoutMs, `Worker ${workerConfig.type} timed out after ${this.config.workerTimeoutMs / 1000}s`, () => {
1056
1134
  // On timeout, cancel any headless execution to prevent orphan processes
1057
1135
  if (this.headlessExecutor) {
1058
1136
  this.headlessExecutor.cancelAll();
@@ -1148,12 +1226,21 @@ export class WorkerDaemon extends EventEmitter {
1148
1226
  /**
1149
1227
  * Run the actual worker logic
1150
1228
  */
1151
- async runWorkerLogic(workerConfig) {
1229
+ async runWorkerLogic(workerConfig, opts) {
1152
1230
  // Check if this is a headless worker type and headless execution is available.
1153
1231
  // #2661 — aiWorkersEnabled is re-checked here (not just at init) as
1154
1232
  // defence in depth: no code path may promote a worker to `claude --print`
1155
1233
  // without explicit consent.
1156
- if (this.config.aiWorkersEnabled && isHeadlessWorker(workerConfig.type) && this.headlessAvailable && this.headlessExecutor) {
1234
+ //
1235
+ // #2661 root-fix — the RECURRING schedule additionally requires this
1236
+ // daemon to be the elected repository supervisor (see repo-supervisor.ts):
1237
+ // ten worktree daemons of one repository must not each independently
1238
+ // decide "is it time to run audit" every tick. An explicit
1239
+ // `daemon trigger --headless` (opts.manualTrigger) is still honored
1240
+ // regardless of supervisor status — it's a one-off, user-initiated
1241
+ // action, still budget/dedup-gated the same as any other launch.
1242
+ const supervisorGateOk = opts?.manualTrigger === true || this.isRepositorySupervisor();
1243
+ if (this.config.aiWorkersEnabled && supervisorGateOk && isHeadlessWorker(workerConfig.type) && this.headlessAvailable && this.headlessExecutor) {
1157
1244
  try {
1158
1245
  this.log('info', `Running ${workerConfig.type} in headless mode (Claude Code AI)`);
1159
1246
  const result = await this.headlessExecutor.execute(workerConfig.type);
@@ -1671,7 +1758,10 @@ export class WorkerDaemon extends EventEmitter {
1671
1758
  // use headless correctly. Scheduled fires already wait long enough
1672
1759
  // (timer + offset) that this is a no-op for them.
1673
1760
  await this.headlessInitPromise;
1674
- return this.executeWorker(workerConfig);
1761
+ // #2661 root-fix — an explicit manual trigger bypasses the repository-
1762
+ // supervisor gate (still budget/dedup-gated) — see runWorkerLogic()'s
1763
+ // doc comment.
1764
+ return this.executeWorker(workerConfig, { manualTrigger: true });
1675
1765
  }
1676
1766
  /**
1677
1767
  * Enable/disable a worker
@@ -0,0 +1,55 @@
1
+ /**
2
+ * #2661 root-fix — worktree leases.
3
+ *
4
+ * Registers which worktrees of a repository are currently "alive" (have a
5
+ * running daemon actively heartbeating), independent of whether that
6
+ * worktree's daemon is the elected repository supervisor (see
7
+ * repo-supervisor.ts). A lease expires after 15 minutes without a heartbeat
8
+ * — a removed worktree, or a daemon that crashed without a graceful
9
+ * shutdown, becomes ineligible within one expiry window instead of lingering
10
+ * forever in the registry.
11
+ *
12
+ * The registry lives under the user's home directory (not the workspace) so
13
+ * it is visible to every worktree's daemon, keyed by repositoryId:
14
+ *
15
+ * ~/.claude-flow/leases/<repositoryId>.json
16
+ *
17
+ * This is deliberately a thin, single-purpose registry — repo-supervisor.ts
18
+ * is the piece that actually elects one process to own the recurring AI
19
+ * worker schedule; leases only answer "which worktrees are currently live"
20
+ * for status reporting and future supervisor-dispatched work.
21
+ */
22
+ export declare const LEASE_TTL_MS: number;
23
+ export interface LeaseRecord {
24
+ worktreeRoot: string;
25
+ pid: number;
26
+ registeredAt: number;
27
+ lastHeartbeat: number;
28
+ }
29
+ export declare class WorkspaceLeaseRegistry {
30
+ private readonly dir;
31
+ constructor(options?: {
32
+ baseDir?: string;
33
+ });
34
+ private fileFor;
35
+ private ensureDir;
36
+ private withLock;
37
+ private readFile;
38
+ private writeFile;
39
+ /** Register or renew this process's lease on a worktree. Best-effort. */
40
+ heartbeat(repositoryId: string, worktreeRoot: string): Promise<void>;
41
+ /** Release this worktree's lease on graceful shutdown. Best-effort. */
42
+ release(repositoryId: string, worktreeRoot: string): Promise<void>;
43
+ /**
44
+ * Active leases for a repository — expired (>15 min stale) or dead-PID
45
+ * entries are excluded, not just filtered at read time, so callers never
46
+ * see a worktree that's actually gone.
47
+ */
48
+ listActive(repositoryId: string): LeaseRecord[];
49
+ /** True when the given worktree currently holds a live (non-expired) lease. */
50
+ isLeaseActive(repositoryId: string, worktreeRoot: string): boolean;
51
+ }
52
+ export declare function getWorkspaceLeaseRegistry(): WorkspaceLeaseRegistry;
53
+ /** Test hook: reset the singleton (e.g. after changing RUFLO_AI_BUDGET_DIR). */
54
+ export declare function resetWorkspaceLeaseRegistryForTests(): void;
55
+ //# sourceMappingURL=workspace-lease.d.ts.map