@claude-flow/cli 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.
- package/.claude/helpers/helpers.manifest.json +3 -3
- package/.claude/helpers/statusline.cjs +1 -1
- package/catalog-manifest.json +2 -2
- package/dist/src/commands/daemon.d.ts +23 -0
- package/dist/src/commands/daemon.js +172 -0
- package/dist/src/init/statusline-generator.js +45 -2
- package/dist/src/services/global-ai-budget.d.ts +25 -0
- package/dist/src/services/global-ai-budget.js +56 -0
- package/dist/src/services/headless-worker-executor.d.ts +21 -0
- package/dist/src/services/headless-worker-executor.js +68 -2
- package/dist/src/services/repo-supervisor.d.ts +70 -0
- package/dist/src/services/repo-supervisor.js +228 -0
- package/dist/src/services/worker-daemon.d.ts +26 -0
- package/dist/src/services/worker-daemon.js +95 -5
- package/dist/src/services/workspace-lease.d.ts +55 -0
- package/dist/src/services/workspace-lease.js +191 -0
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|