claude-flow 3.27.4 → 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.
@@ -336,6 +336,42 @@ export function isLocalWorker(type) {
336
336
  export function getModelId(model) {
337
337
  return MODEL_IDS[model];
338
338
  }
339
+ /**
340
+ * #2661 root-fix — best-effort parse of `claude --print --output-format
341
+ * json`'s response envelope. Deliberately lenient: probes a couple of
342
+ * plausible field-name shapes (the CLI's JSON schema is not a versioned
343
+ * public contract) and returns null on anything unexpected rather than
344
+ * throwing, so a schema mismatch degrades to "no usage captured" — the
345
+ * caller then falls back to the raw stdout text, exactly today's behavior.
346
+ * Exported for direct unit testing without spawning a real process.
347
+ */
348
+ export function parseClaudePrintJsonEnvelope(raw) {
349
+ const trimmed = raw.trim();
350
+ if (!trimmed || trimmed[0] !== '{')
351
+ return null;
352
+ let parsed;
353
+ try {
354
+ parsed = JSON.parse(trimmed);
355
+ }
356
+ catch {
357
+ return null;
358
+ }
359
+ if (!parsed || typeof parsed !== 'object')
360
+ return null;
361
+ const obj = parsed;
362
+ const result = typeof obj.result === 'string' ? obj.result : undefined;
363
+ if (result === undefined)
364
+ return null; // not the envelope shape we expect
365
+ const usage = (obj.usage && typeof obj.usage === 'object') ? obj.usage : undefined;
366
+ const numOrUndef = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : undefined);
367
+ return {
368
+ result,
369
+ inputTokens: numOrUndef(usage?.input_tokens ?? usage?.inputTokens),
370
+ outputTokens: numOrUndef(usage?.output_tokens ?? usage?.outputTokens),
371
+ costUsd: numOrUndef(obj.total_cost_usd ?? obj.cost_usd ?? obj.totalCostUsd),
372
+ durationMs: numOrUndef(obj.duration_ms ?? obj.durationMs),
373
+ };
374
+ }
339
375
  /**
340
376
  * Get worker configuration by type
341
377
  */
@@ -721,6 +757,9 @@ export class HeadlessWorkerExecutor extends EventEmitter {
721
757
  parsedOutput,
722
758
  durationMs: Date.now() - startTime,
723
759
  tokensUsed: result.tokensUsed,
760
+ inputTokens: result.inputTokens,
761
+ outputTokens: result.outputTokens,
762
+ costUsd: result.costUsd,
724
763
  model: headless.model || 'sonnet',
725
764
  sandboxMode: headless.sandbox,
726
765
  workerType,
@@ -730,6 +769,17 @@ export class HeadlessWorkerExecutor extends EventEmitter {
730
769
  };
731
770
  // Log result
732
771
  this.logExecution(executionId, 'result', JSON.stringify(executionResult, null, 2));
772
+ // #2661 root-fix — structured per-launch telemetry, receipted
773
+ // regardless of success/failure (a failed launch still spent
774
+ // whatever tokens it spent before erroring).
775
+ budget.recordUsage(permit.permitId, {
776
+ workerType,
777
+ model,
778
+ inputTokens: result.inputTokens,
779
+ outputTokens: result.outputTokens,
780
+ durationMs: result.apiDurationMs ?? executionResult.durationMs,
781
+ costUsd: result.costUsd,
782
+ });
733
783
  // #2661 invariant 5 — record the success so sibling worktrees at the
734
784
  // same HEAD skip this job for the rest of the freshness window.
735
785
  if (result.success) {
@@ -988,7 +1038,15 @@ Analyze the above codebase context and provide your response following the forma
988
1038
  // diagnosed as a 5-second redispatch + subprocess-table growth.
989
1039
  // `detached: true` puts the child in its own process group so we
990
1040
  // can signal the whole tree with `process.kill(-pid, sig)`.
991
- const child = spawn('claude', ['--print'], {
1041
+ // #2661 root-fix — structured usage telemetry. `--output-format json`
1042
+ // wraps the response in an envelope carrying `result` (the actual
1043
+ // text — what `output` must still contain, unchanged for every
1044
+ // existing downstream parser) alongside `usage`/cost/duration
1045
+ // metadata. Parsing is lenient (parseClaudePrintJsonEnvelope below)
1046
+ // and ALWAYS falls back to the raw text on any mismatch — a schema
1047
+ // surprise from an older/newer `claude` CLI must degrade to exactly
1048
+ // today's behavior (no usage captured), never break analysis output.
1049
+ const child = spawn('claude', ['--print', '--output-format', 'json'], {
992
1050
  cwd: this.projectRoot,
993
1051
  env,
994
1052
  stdio: ['pipe', 'pipe', 'pipe'],
@@ -1068,9 +1126,17 @@ Analyze the above codebase context and provide your response following the forma
1068
1126
  return;
1069
1127
  resolved = true;
1070
1128
  cleanup();
1129
+ const envelope = parseClaudePrintJsonEnvelope(stdout);
1071
1130
  resolve({
1072
1131
  success: code === 0,
1073
- output: stdout || stderr,
1132
+ output: envelope?.result ?? stdout ?? stderr,
1133
+ inputTokens: envelope?.inputTokens,
1134
+ outputTokens: envelope?.outputTokens,
1135
+ tokensUsed: envelope
1136
+ ? (envelope.inputTokens ?? 0) + (envelope.outputTokens ?? 0)
1137
+ : undefined,
1138
+ costUsd: envelope?.costUsd,
1139
+ apiDurationMs: envelope?.durationMs,
1074
1140
  error: code !== 0 ? stderr || `Process exited with code ${code}` : undefined,
1075
1141
  });
1076
1142
  });
@@ -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