@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.
- 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/commands/init.js +3 -1
- package/dist/src/init/executor.js +26 -2
- 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,191 @@
|
|
|
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
|
+
import * as fs from 'fs';
|
|
23
|
+
import { join } from 'path';
|
|
24
|
+
import { homedir } from 'os';
|
|
25
|
+
import { createHash } from 'crypto';
|
|
26
|
+
export const LEASE_TTL_MS = 15 * 60 * 1000; // 15 minutes — issue #2661 spec
|
|
27
|
+
const LOCK_STALE_MS = 10_000;
|
|
28
|
+
function delay(ms) {
|
|
29
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
30
|
+
}
|
|
31
|
+
function isProcessAlive(pid) {
|
|
32
|
+
try {
|
|
33
|
+
process.kill(pid, 0);
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/** Invariant 9 (#2661): registry files must never be symlinks. */
|
|
41
|
+
function assertNotSymlink(path) {
|
|
42
|
+
try {
|
|
43
|
+
const st = fs.lstatSync(path);
|
|
44
|
+
if (st.isSymbolicLink()) {
|
|
45
|
+
throw new Error(`Workspace lease file is a symlink (refusing): ${path}`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
catch (e) {
|
|
49
|
+
if (e.code === 'ENOENT')
|
|
50
|
+
return;
|
|
51
|
+
throw e;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
function leaseKey(worktreeRoot) {
|
|
55
|
+
return createHash('sha256').update(worktreeRoot).digest('hex').slice(0, 16);
|
|
56
|
+
}
|
|
57
|
+
export class WorkspaceLeaseRegistry {
|
|
58
|
+
dir;
|
|
59
|
+
constructor(options) {
|
|
60
|
+
this.dir = options?.baseDir
|
|
61
|
+
?? process.env.RUFLO_AI_BUDGET_DIR // shares the same override as global-ai-budget for test isolation
|
|
62
|
+
?? join(homedir(), '.claude-flow');
|
|
63
|
+
}
|
|
64
|
+
fileFor(repositoryId) {
|
|
65
|
+
return join(this.dir, 'leases', `${repositoryId}.json`);
|
|
66
|
+
}
|
|
67
|
+
ensureDir(repositoryId) {
|
|
68
|
+
const dir = join(this.dir, 'leases');
|
|
69
|
+
if (!fs.existsSync(dir))
|
|
70
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
71
|
+
void repositoryId;
|
|
72
|
+
}
|
|
73
|
+
async withLock(repositoryId, fn) {
|
|
74
|
+
this.ensureDir(repositoryId);
|
|
75
|
+
const lockFile = `${this.fileFor(repositoryId)}.lock`;
|
|
76
|
+
const deadline = Date.now() + 2000;
|
|
77
|
+
for (;;) {
|
|
78
|
+
try {
|
|
79
|
+
const fd = fs.openSync(lockFile, fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY, 0o600);
|
|
80
|
+
fs.writeSync(fd, String(process.pid));
|
|
81
|
+
fs.closeSync(fd);
|
|
82
|
+
try {
|
|
83
|
+
return fn();
|
|
84
|
+
}
|
|
85
|
+
finally {
|
|
86
|
+
try {
|
|
87
|
+
fs.unlinkSync(lockFile);
|
|
88
|
+
}
|
|
89
|
+
catch { /* already gone */ }
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
catch (e) {
|
|
93
|
+
if (e.code !== 'EEXIST')
|
|
94
|
+
throw e;
|
|
95
|
+
try {
|
|
96
|
+
const st = fs.lstatSync(lockFile);
|
|
97
|
+
if (Date.now() - st.mtimeMs > LOCK_STALE_MS) {
|
|
98
|
+
fs.unlinkSync(lockFile);
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
catch { /* raced — retry */ }
|
|
103
|
+
if (Date.now() > deadline)
|
|
104
|
+
throw new Error('timed out acquiring workspace-lease lock');
|
|
105
|
+
await delay(25);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
readFile(repositoryId) {
|
|
110
|
+
const file = this.fileFor(repositoryId);
|
|
111
|
+
assertNotSymlink(file);
|
|
112
|
+
let parsed = { version: 1, leases: {} };
|
|
113
|
+
if (fs.existsSync(file)) {
|
|
114
|
+
try {
|
|
115
|
+
const raw = JSON.parse(fs.readFileSync(file, 'utf-8'));
|
|
116
|
+
if (raw && typeof raw === 'object' && raw.leases && typeof raw.leases === 'object') {
|
|
117
|
+
parsed = { version: 1, leases: raw.leases };
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
catch { /* corrupt — start fresh */ }
|
|
121
|
+
}
|
|
122
|
+
return parsed;
|
|
123
|
+
}
|
|
124
|
+
writeFile(repositoryId, data) {
|
|
125
|
+
const file = this.fileFor(repositoryId);
|
|
126
|
+
assertNotSymlink(file);
|
|
127
|
+
const tmp = `${file}.tmp.${process.pid}`;
|
|
128
|
+
fs.writeFileSync(tmp, JSON.stringify(data), { mode: 0o600 });
|
|
129
|
+
fs.renameSync(tmp, file);
|
|
130
|
+
}
|
|
131
|
+
/** Register or renew this process's lease on a worktree. Best-effort. */
|
|
132
|
+
async heartbeat(repositoryId, worktreeRoot) {
|
|
133
|
+
try {
|
|
134
|
+
await this.withLock(repositoryId, () => {
|
|
135
|
+
const data = this.readFile(repositoryId);
|
|
136
|
+
const now = Date.now();
|
|
137
|
+
const key = leaseKey(worktreeRoot);
|
|
138
|
+
const existing = data.leases[key];
|
|
139
|
+
data.leases[key] = {
|
|
140
|
+
worktreeRoot,
|
|
141
|
+
pid: process.pid,
|
|
142
|
+
registeredAt: existing?.registeredAt ?? now,
|
|
143
|
+
lastHeartbeat: now,
|
|
144
|
+
};
|
|
145
|
+
this.writeFile(repositoryId, data);
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
catch { /* best-effort — a missed heartbeat just expires the lease early */ }
|
|
149
|
+
}
|
|
150
|
+
/** Release this worktree's lease on graceful shutdown. Best-effort. */
|
|
151
|
+
async release(repositoryId, worktreeRoot) {
|
|
152
|
+
try {
|
|
153
|
+
await this.withLock(repositoryId, () => {
|
|
154
|
+
const data = this.readFile(repositoryId);
|
|
155
|
+
delete data.leases[leaseKey(worktreeRoot)];
|
|
156
|
+
this.writeFile(repositoryId, data);
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
catch { /* best-effort */ }
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Active leases for a repository — expired (>15 min stale) or dead-PID
|
|
163
|
+
* entries are excluded, not just filtered at read time, so callers never
|
|
164
|
+
* see a worktree that's actually gone.
|
|
165
|
+
*/
|
|
166
|
+
listActive(repositoryId) {
|
|
167
|
+
try {
|
|
168
|
+
const data = this.readFile(repositoryId);
|
|
169
|
+
const now = Date.now();
|
|
170
|
+
return Object.values(data.leases).filter((l) => now - l.lastHeartbeat < LEASE_TTL_MS && isProcessAlive(l.pid));
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
return [];
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
/** True when the given worktree currently holds a live (non-expired) lease. */
|
|
177
|
+
isLeaseActive(repositoryId, worktreeRoot) {
|
|
178
|
+
return this.listActive(repositoryId).some((l) => l.worktreeRoot === worktreeRoot);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
let registryInstance = null;
|
|
182
|
+
export function getWorkspaceLeaseRegistry() {
|
|
183
|
+
if (!registryInstance)
|
|
184
|
+
registryInstance = new WorkspaceLeaseRegistry();
|
|
185
|
+
return registryInstance;
|
|
186
|
+
}
|
|
187
|
+
/** Test hook: reset the singleton (e.g. after changing RUFLO_AI_BUDGET_DIR). */
|
|
188
|
+
export function resetWorkspaceLeaseRegistryForTests() {
|
|
189
|
+
registryInstance = null;
|
|
190
|
+
}
|
|
191
|
+
//# sourceMappingURL=workspace-lease.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@claude-flow/cli",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.28.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",
|