@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
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"manifest": {
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.28.0",
|
|
4
4
|
"files": {
|
|
5
5
|
"auto-memory-hook.mjs": "68be7e9a9eba7bf9c4e8a230db7bf61a243b965639f8504842799d6c6ca28762",
|
|
6
6
|
"hook-handler.cjs": "2e4927ff158c0079a67a6f0ef0b99ac748d600d33798b0a5fbd5a6a82225ec03",
|
|
7
7
|
"intelligence.cjs": "bd1f8e4b034944aee1df0391dc47ac2e8cc4b3aa542c65407a59d49620bbf76b",
|
|
8
|
-
"statusline.cjs": "
|
|
8
|
+
"statusline.cjs": "8ce3ea9b732baaad8dd0df4123760496ad2f857b6c3ab2b55b4de879b5cf7276"
|
|
9
9
|
}
|
|
10
10
|
},
|
|
11
|
-
"signature": "
|
|
11
|
+
"signature": "K7iXHhAaLffQjHpnBB3K9PtOwYreUOw9aROTN5jkb+bn7BH4dzdaDT/L0nBJstvt8uzUZHhAZSXRjnXR5malDw==",
|
|
12
12
|
"algorithm": "ed25519"
|
|
13
13
|
}
|
|
@@ -618,7 +618,7 @@ function getPkgVersion() {
|
|
|
618
618
|
// version (see generateStatuslineScript()'s doc comment) — correct even
|
|
619
619
|
// when this renders via a pure npx invocation with no local install for
|
|
620
620
|
// the candidate scan below to find.
|
|
621
|
-
let ver = "3.
|
|
621
|
+
let ver = "3.28.0";
|
|
622
622
|
try {
|
|
623
623
|
const home = os.homedir();
|
|
624
624
|
const pkgPaths = [
|
package/catalog-manifest.json
CHANGED
|
@@ -31,6 +31,29 @@ export declare function daemonCommandLineBelongsToWorkspace(commandLine: string,
|
|
|
31
31
|
* Returns null for pre-#1914 daemons that never stamped a workspace.
|
|
32
32
|
*/
|
|
33
33
|
export declare function extractWorkspaceFromDaemonLine(commandLine: string): string | null;
|
|
34
|
+
/**
|
|
35
|
+
* #2661 root-fix — one-time upgrade migration warning. A user who had
|
|
36
|
+
* `aiWorkersEnabled: true` configured BEFORE this fix landed (old config
|
|
37
|
+
* file or RUFLO_DAEMON_AI_WORKERS=1) and already has multiple worktree
|
|
38
|
+
* daemons running is exactly the P0 scenario the issue describes — surface
|
|
39
|
+
* it plainly, ONCE ever (not on every `daemon start`, which would just be
|
|
40
|
+
* noise once the user has seen and acted on it). The supervisor/lease
|
|
41
|
+
* mechanism (task #9) already makes only one of those daemons actually
|
|
42
|
+
* schedule AI workers going forward; this warning's job is purely to make
|
|
43
|
+
* a pre-existing fleet VISIBLE the first time this code runs, not to take
|
|
44
|
+
* any destructive action — nothing here stops or kills another daemon.
|
|
45
|
+
*
|
|
46
|
+
* `opts` exists for tests ONLY, mirroring the injectable-dependency pattern
|
|
47
|
+
* used elsewhere in this codebase (e.g. helper-refresh.ts's
|
|
48
|
+
* sourceDirOverride) — real callers always use the defaults.
|
|
49
|
+
*/
|
|
50
|
+
export declare function maybeShowMultiDaemonMigrationWarning(opts?: {
|
|
51
|
+
markerFile?: string;
|
|
52
|
+
fleetScanner?: () => Promise<Array<{
|
|
53
|
+
pid: number;
|
|
54
|
+
workspace: string | null;
|
|
55
|
+
}>>;
|
|
56
|
+
}): Promise<void>;
|
|
34
57
|
export declare const daemonCommand: Command;
|
|
35
58
|
export default daemonCommand;
|
|
36
59
|
//# sourceMappingURL=daemon.d.ts.map
|
|
@@ -7,6 +7,7 @@ import { getDaemon, startDaemon, stopDaemon } from '../services/worker-daemon.js
|
|
|
7
7
|
import { fork } from 'child_process';
|
|
8
8
|
import { fileURLToPath } from 'url';
|
|
9
9
|
import { dirname, join, resolve } from 'path';
|
|
10
|
+
import { homedir } from 'os';
|
|
10
11
|
import * as fs from 'fs';
|
|
11
12
|
// Start daemon subcommand
|
|
12
13
|
const startCommand = {
|
|
@@ -580,6 +581,11 @@ async function startBackgroundDaemon(projectRoot, quiet, forwarded = {}) {
|
|
|
580
581
|
}
|
|
581
582
|
}
|
|
582
583
|
catch { /* best-effort visibility — never fail the start */ }
|
|
584
|
+
// #2661 root-fix — one-time migration warning for a pre-existing
|
|
585
|
+
// multi-daemon fleet that already had AI workers enabled before this
|
|
586
|
+
// fix landed. Separate from the always-shown notice above: this one
|
|
587
|
+
// fires at most once ever, and only for the genuinely risky shape.
|
|
588
|
+
await maybeShowMultiDaemonMigrationWarning();
|
|
583
589
|
}
|
|
584
590
|
return { success: true };
|
|
585
591
|
}
|
|
@@ -942,6 +948,68 @@ async function scanRunningDaemons() {
|
|
|
942
948
|
return [];
|
|
943
949
|
}
|
|
944
950
|
}
|
|
951
|
+
function defaultMultiDaemonWarningMarker() {
|
|
952
|
+
return join(homedir(), '.claude-flow', 'multi-daemon-warning-shown.json');
|
|
953
|
+
}
|
|
954
|
+
/**
|
|
955
|
+
* #2661 root-fix — one-time upgrade migration warning. A user who had
|
|
956
|
+
* `aiWorkersEnabled: true` configured BEFORE this fix landed (old config
|
|
957
|
+
* file or RUFLO_DAEMON_AI_WORKERS=1) and already has multiple worktree
|
|
958
|
+
* daemons running is exactly the P0 scenario the issue describes — surface
|
|
959
|
+
* it plainly, ONCE ever (not on every `daemon start`, which would just be
|
|
960
|
+
* noise once the user has seen and acted on it). The supervisor/lease
|
|
961
|
+
* mechanism (task #9) already makes only one of those daemons actually
|
|
962
|
+
* schedule AI workers going forward; this warning's job is purely to make
|
|
963
|
+
* a pre-existing fleet VISIBLE the first time this code runs, not to take
|
|
964
|
+
* any destructive action — nothing here stops or kills another daemon.
|
|
965
|
+
*
|
|
966
|
+
* `opts` exists for tests ONLY, mirroring the injectable-dependency pattern
|
|
967
|
+
* used elsewhere in this codebase (e.g. helper-refresh.ts's
|
|
968
|
+
* sourceDirOverride) — real callers always use the defaults.
|
|
969
|
+
*/
|
|
970
|
+
export async function maybeShowMultiDaemonMigrationWarning(opts) {
|
|
971
|
+
const markerFile = opts?.markerFile ?? defaultMultiDaemonWarningMarker();
|
|
972
|
+
try {
|
|
973
|
+
if (fs.existsSync(markerFile))
|
|
974
|
+
return;
|
|
975
|
+
const fleet = await (opts?.fleetScanner ?? scanRunningDaemons)();
|
|
976
|
+
if (fleet.length <= 1)
|
|
977
|
+
return;
|
|
978
|
+
let anyAiEnabled = false;
|
|
979
|
+
for (const d of fleet) {
|
|
980
|
+
if (!d.workspace)
|
|
981
|
+
continue;
|
|
982
|
+
try {
|
|
983
|
+
const statePath = join(d.workspace, '.claude-flow', 'daemon-state.json');
|
|
984
|
+
if (!fs.existsSync(statePath))
|
|
985
|
+
continue;
|
|
986
|
+
const st = JSON.parse(fs.readFileSync(statePath, 'utf-8'));
|
|
987
|
+
if (st?.config?.aiWorkersEnabled === true) {
|
|
988
|
+
anyAiEnabled = true;
|
|
989
|
+
break;
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
catch { /* unreadable state — skip this daemon */ }
|
|
993
|
+
}
|
|
994
|
+
// Only the genuinely risky shape (pre-existing fleet + AI workers
|
|
995
|
+
// enabled somewhere in it) warrants the migration warning. A harmless
|
|
996
|
+
// multi-daemon fleet with AI workers off everywhere already gets the
|
|
997
|
+
// lighter, always-shown fleet-size notice at daemon start.
|
|
998
|
+
if (anyAiEnabled) {
|
|
999
|
+
output.writeln();
|
|
1000
|
+
output.printWarning(`Ruflo found ${fleet.length} worktree daemons. Scheduled AI workers are now supervisor-gated.`);
|
|
1001
|
+
output.printInfo('Inspect: ruflo daemon status --all');
|
|
1002
|
+
output.printInfo('Stop all: ruflo daemon stop --all');
|
|
1003
|
+
output.printInfo('Pause autonomous launches: ruflo daemon budget pause');
|
|
1004
|
+
output.writeln();
|
|
1005
|
+
}
|
|
1006
|
+
const dir = dirname(markerFile);
|
|
1007
|
+
if (!fs.existsSync(dir))
|
|
1008
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
1009
|
+
fs.writeFileSync(markerFile, JSON.stringify({ shownAt: new Date().toISOString(), fleetSize: fleet.length, anyAiEnabled }), { mode: 0o600 });
|
|
1010
|
+
}
|
|
1011
|
+
catch { /* best-effort visibility — never fail the command */ }
|
|
1012
|
+
}
|
|
945
1013
|
/**
|
|
946
1014
|
* #2356: render the global `daemon status --all` view. For each running daemon
|
|
947
1015
|
* it reads that workspace's daemon-state.json to show age + configured TTL,
|
|
@@ -1010,6 +1078,39 @@ async function renderAllDaemonsStatus() {
|
|
|
1010
1078
|
if (daemons.length > 1) {
|
|
1011
1079
|
output.printInfo('Stop all daemons across workspaces with: ruflo daemon stop --all');
|
|
1012
1080
|
}
|
|
1081
|
+
// #2661 root-fix — repository supervisor state, one row per distinct
|
|
1082
|
+
// repository among the scanned daemons' workspaces. Resolving identity
|
|
1083
|
+
// per workspace is cheap (a couple of `git rev-parse` calls, cached).
|
|
1084
|
+
try {
|
|
1085
|
+
const { resolveGitWorkspaceIdentity } = await import('../services/git-workspace-identity.js');
|
|
1086
|
+
const { getRepoSupervisorRegistry } = await import('../services/repo-supervisor.js');
|
|
1087
|
+
const { getWorkspaceLeaseRegistry } = await import('../services/workspace-lease.js');
|
|
1088
|
+
const seenRepos = new Map(); // repositoryId -> a representative workspace path
|
|
1089
|
+
for (const d of daemons) {
|
|
1090
|
+
if (!d.workspace)
|
|
1091
|
+
continue;
|
|
1092
|
+
const identity = resolveGitWorkspaceIdentity(d.workspace);
|
|
1093
|
+
if (identity.isGit && !seenRepos.has(identity.repositoryId)) {
|
|
1094
|
+
seenRepos.set(identity.repositoryId, d.workspace);
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
if (seenRepos.size > 0) {
|
|
1098
|
+
const supervisorReg = getRepoSupervisorRegistry();
|
|
1099
|
+
const leaseReg = getWorkspaceLeaseRegistry();
|
|
1100
|
+
const lines = [];
|
|
1101
|
+
for (const [repositoryId, sampleWorkspace] of seenRepos) {
|
|
1102
|
+
const record = supervisorReg.getRecord(repositoryId);
|
|
1103
|
+
const activeLeases = leaseReg.listActive(repositoryId).length;
|
|
1104
|
+
const label = repositoryId.slice(0, 12);
|
|
1105
|
+
lines.push(record
|
|
1106
|
+
? ` ${label}… supervisor: ${record.worktreeRoot} (pid ${record.pid}) | active leases: ${activeLeases}`
|
|
1107
|
+
: ` ${label}… supervisor: ${output.dim('none elected')} | active leases: ${activeLeases}`);
|
|
1108
|
+
}
|
|
1109
|
+
output.writeln();
|
|
1110
|
+
output.printBox(lines.join('\n'), 'Repository Supervisors (#2661 root-fix)');
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
catch { /* supervisor registry unavailable — skip the panel */ }
|
|
1013
1114
|
// #2661: user-global AI launch usage — the shared budget every daemon
|
|
1014
1115
|
// draws from, independent of worktree count.
|
|
1015
1116
|
try {
|
|
@@ -1496,6 +1597,76 @@ const uninstallSupervisorCommand = {
|
|
|
1496
1597
|
return { success: false, exitCode: 1 };
|
|
1497
1598
|
},
|
|
1498
1599
|
};
|
|
1600
|
+
// #2661 root-fix — `daemon budget show|pause|resume`. The budget state was
|
|
1601
|
+
// previously visible only inline in `daemon status --all`; these give it an
|
|
1602
|
+
// independently scriptable surface (e.g. `ruflo daemon budget pause` before
|
|
1603
|
+
// a long interactive session, `... resume` after).
|
|
1604
|
+
const budgetShowCommand = {
|
|
1605
|
+
name: 'show',
|
|
1606
|
+
description: 'Show the user-global AI launch budget (launches, active children, circuit-breaker state)',
|
|
1607
|
+
options: [],
|
|
1608
|
+
examples: [{ command: 'claude-flow daemon budget show', description: 'Show current budget usage and limits' }],
|
|
1609
|
+
action: async () => {
|
|
1610
|
+
const { getGlobalAiBudget } = await import('../services/global-ai-budget.js');
|
|
1611
|
+
const budget = getGlobalAiBudget();
|
|
1612
|
+
const usage = budget.getUsage();
|
|
1613
|
+
const limits = budget.getLimits();
|
|
1614
|
+
output.writeln();
|
|
1615
|
+
const byWs = usage.byWorkspace.slice(0, 10).map((w) => ` ${w.launches}× ${w.workspace}`);
|
|
1616
|
+
output.printBox([
|
|
1617
|
+
`Launches (last hour): ${usage.lastHour}/${limits.maxLaunchesPerHour}`,
|
|
1618
|
+
`Launches (last 24h): ${usage.lastDay}/${limits.maxLaunchesPerDay}`,
|
|
1619
|
+
`Active Claude children: ${usage.active}/${limits.maxConcurrentGlobal}`,
|
|
1620
|
+
usage.pausedUntil
|
|
1621
|
+
? output.warning(`PAUSED until ${new Date(usage.pausedUntil).toISOString()} (${usage.pauseReason ?? 'quota error'})`)
|
|
1622
|
+
: `Circuit breaker: ${output.dim('closed (normal)')}`,
|
|
1623
|
+
...(byWs.length > 0 ? ['Launches by workspace (24h):', ...byWs] : []),
|
|
1624
|
+
].join('\n'), 'Global AI Budget');
|
|
1625
|
+
return { success: true, data: { usage, limits } };
|
|
1626
|
+
},
|
|
1627
|
+
};
|
|
1628
|
+
const budgetPauseCommand = {
|
|
1629
|
+
name: 'pause',
|
|
1630
|
+
description: 'Pause ALL autonomous Claude launches across every daemon until resumed',
|
|
1631
|
+
options: [
|
|
1632
|
+
{ name: 'reason', short: 'r', type: 'string', description: 'Optional reason recorded in the pause receipt' },
|
|
1633
|
+
],
|
|
1634
|
+
examples: [{ command: 'claude-flow daemon budget pause --reason "conserving quota for a demo"', description: 'Pause autonomous launches' }],
|
|
1635
|
+
action: async (ctx) => {
|
|
1636
|
+
const { getGlobalAiBudget } = await import('../services/global-ai-budget.js');
|
|
1637
|
+
await getGlobalAiBudget().pause(ctx.flags.reason);
|
|
1638
|
+
output.printSuccess('Autonomous AI worker launches paused across all daemons. Resume with: ruflo daemon budget resume');
|
|
1639
|
+
return { success: true };
|
|
1640
|
+
},
|
|
1641
|
+
};
|
|
1642
|
+
const budgetResumeCommand = {
|
|
1643
|
+
name: 'resume',
|
|
1644
|
+
description: 'Resume autonomous Claude launches (clears a manual pause or a quota-triggered circuit-breaker pause)',
|
|
1645
|
+
options: [],
|
|
1646
|
+
examples: [{ command: 'claude-flow daemon budget resume', description: 'Resume autonomous launches' }],
|
|
1647
|
+
action: async () => {
|
|
1648
|
+
const { getGlobalAiBudget } = await import('../services/global-ai-budget.js');
|
|
1649
|
+
await getGlobalAiBudget().resume();
|
|
1650
|
+
output.printSuccess('Autonomous AI worker launches resumed.');
|
|
1651
|
+
return { success: true };
|
|
1652
|
+
},
|
|
1653
|
+
};
|
|
1654
|
+
const budgetCommand = {
|
|
1655
|
+
name: 'budget',
|
|
1656
|
+
description: 'Inspect and control the user-global AI launch budget (#2661)',
|
|
1657
|
+
subcommands: [budgetShowCommand, budgetPauseCommand, budgetResumeCommand],
|
|
1658
|
+
options: [],
|
|
1659
|
+
examples: [
|
|
1660
|
+
{ command: 'claude-flow daemon budget show', description: 'Show current usage/limits' },
|
|
1661
|
+
{ command: 'claude-flow daemon budget pause', description: 'Pause all autonomous launches' },
|
|
1662
|
+
{ command: 'claude-flow daemon budget resume', description: 'Resume autonomous launches' },
|
|
1663
|
+
],
|
|
1664
|
+
// Bare `daemon budget` (no subcommand) shows usage — same as `show`.
|
|
1665
|
+
action: async (ctx) => {
|
|
1666
|
+
const result = await budgetShowCommand.action(ctx);
|
|
1667
|
+
return result ?? { success: true };
|
|
1668
|
+
},
|
|
1669
|
+
};
|
|
1499
1670
|
// Main daemon command
|
|
1500
1671
|
export const daemonCommand = {
|
|
1501
1672
|
name: 'daemon',
|
|
@@ -1506,6 +1677,7 @@ export const daemonCommand = {
|
|
|
1506
1677
|
statusCommand,
|
|
1507
1678
|
triggerCommand,
|
|
1508
1679
|
enableCommand,
|
|
1680
|
+
budgetCommand,
|
|
1509
1681
|
installSupervisorCommand,
|
|
1510
1682
|
uninstallSupervisorCommand,
|
|
1511
1683
|
],
|
|
@@ -9,7 +9,50 @@
|
|
|
9
9
|
* - Shared settings cache
|
|
10
10
|
* - Strict 2s timeouts on all shell calls
|
|
11
11
|
*/
|
|
12
|
-
import
|
|
12
|
+
import * as fs from 'fs';
|
|
13
|
+
import * as path from 'path';
|
|
14
|
+
import { fileURLToPath } from 'url';
|
|
15
|
+
import { createRequire } from 'module';
|
|
16
|
+
const __dirname_sg = path.dirname(fileURLToPath(import.meta.url));
|
|
17
|
+
/**
|
|
18
|
+
* Resolves the running CLI's own version — same createRequire/walk-up
|
|
19
|
+
* approach as helper-refresh.ts's getInstalledCliVersion(), duplicated
|
|
20
|
+
* here rather than imported. helper-refresh.ts pulls in the `semver`
|
|
21
|
+
* package at module scope (for autoRefreshHelpersIfStale()'s version
|
|
22
|
+
* comparison, unrelated to this) — ES module imports load a module's
|
|
23
|
+
* ENTIRE top-level regardless of which export is used, so importing just
|
|
24
|
+
* getInstalledCliVersion from there still requires `semver` to be resolvable.
|
|
25
|
+
* Confirmed live: the CI smoke job that loads this generator via a minimal
|
|
26
|
+
* "smoke deps" install (no full `npm install`) failed with
|
|
27
|
+
* ERR_MODULE_NOT_FOUND('semver') the moment this file gained that import,
|
|
28
|
+
* even though this function itself never touches semver. Keeping this
|
|
29
|
+
* generator's own dependency footprint to bare Node builtins avoids
|
|
30
|
+
* dragging every future helper-refresh.ts dependency into every context
|
|
31
|
+
* that merely wants to render a statusline script.
|
|
32
|
+
*/
|
|
33
|
+
function getInstalledCliVersionLocal() {
|
|
34
|
+
try {
|
|
35
|
+
const esmRequire = createRequire(import.meta.url);
|
|
36
|
+
const pkg = JSON.parse(fs.readFileSync(esmRequire.resolve('@claude-flow/cli/package.json'), 'utf-8'));
|
|
37
|
+
return String(pkg.version || '0.0.0');
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
let dir = __dirname_sg;
|
|
41
|
+
for (let i = 0; i < 6; i++) {
|
|
42
|
+
try {
|
|
43
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf-8'));
|
|
44
|
+
if (pkg && pkg.name === '@claude-flow/cli')
|
|
45
|
+
return String(pkg.version || '0.0.0');
|
|
46
|
+
}
|
|
47
|
+
catch { /* no package.json here, or unreadable — keep climbing */ }
|
|
48
|
+
const parent = path.dirname(dir);
|
|
49
|
+
if (parent === dir)
|
|
50
|
+
break;
|
|
51
|
+
dir = parent;
|
|
52
|
+
}
|
|
53
|
+
return '0.0.0';
|
|
54
|
+
}
|
|
55
|
+
}
|
|
13
56
|
/**
|
|
14
57
|
* Generate optimized statusline script
|
|
15
58
|
* Output format:
|
|
@@ -33,7 +76,7 @@ export function generateStatuslineScript(options) {
|
|
|
33
76
|
// previous hardcoded "3.6" placeholder. getPkgVersion()'s own runtime
|
|
34
77
|
// candidate scan still wins over this baked-in value when it finds
|
|
35
78
|
// something newer (e.g. a later `npm update` in the same project).
|
|
36
|
-
const bakedVersion =
|
|
79
|
+
const bakedVersion = getInstalledCliVersionLocal();
|
|
37
80
|
return `#!/usr/bin/env node
|
|
38
81
|
/**
|
|
39
82
|
* RuFlo V3 Statusline — delegation build (#2195)
|
|
@@ -80,6 +80,31 @@ export declare class GlobalAiBudget {
|
|
|
80
80
|
* daemon pauses ALL autonomous Claude launches for the cooldown window.
|
|
81
81
|
*/
|
|
82
82
|
recordQuotaError(detail: string): Promise<void>;
|
|
83
|
+
/**
|
|
84
|
+
* #2661 root-fix — manual pause, via `ruflo daemon budget pause`. Distinct
|
|
85
|
+
* from the automatic quota-error circuit breaker only in duration (open-
|
|
86
|
+
* ended, until explicitly resumed, instead of a fixed cooldown) and
|
|
87
|
+
* reason text — the enforcement path in reserve() is identical, so a
|
|
88
|
+
* manual pause is just as hard a stop as a quota-triggered one.
|
|
89
|
+
*/
|
|
90
|
+
pause(reason?: string): Promise<void>;
|
|
91
|
+
/** #2661 root-fix — `ruflo daemon budget resume`. Clears ANY pause (manual or quota-triggered). */
|
|
92
|
+
resume(): Promise<void>;
|
|
93
|
+
/**
|
|
94
|
+
* #2661 root-fix — structured per-launch token telemetry. Best-effort,
|
|
95
|
+
* receipt-only: usage is recorded as a distinct receipt keyed by permitId
|
|
96
|
+
* rather than mutated into the launch ledger, so a usage-recording failure
|
|
97
|
+
* can never corrupt the budget-enforcement ledger. Only operational
|
|
98
|
+
* metadata — never prompts or source content.
|
|
99
|
+
*/
|
|
100
|
+
recordUsage(permitId: string | undefined, usage: {
|
|
101
|
+
workerType: string;
|
|
102
|
+
model: string;
|
|
103
|
+
inputTokens?: number;
|
|
104
|
+
outputTokens?: number;
|
|
105
|
+
durationMs?: number;
|
|
106
|
+
costUsd?: number;
|
|
107
|
+
}): void;
|
|
83
108
|
/** Snapshot for `daemon status` / diagnostics. */
|
|
84
109
|
getUsage(): {
|
|
85
110
|
lastHour: number;
|
|
@@ -221,6 +221,62 @@ export class GlobalAiBudget {
|
|
|
221
221
|
unlock?.();
|
|
222
222
|
}
|
|
223
223
|
}
|
|
224
|
+
/**
|
|
225
|
+
* #2661 root-fix — manual pause, via `ruflo daemon budget pause`. Distinct
|
|
226
|
+
* from the automatic quota-error circuit breaker only in duration (open-
|
|
227
|
+
* ended, until explicitly resumed, instead of a fixed cooldown) and
|
|
228
|
+
* reason text — the enforcement path in reserve() is identical, so a
|
|
229
|
+
* manual pause is just as hard a stop as a quota-triggered one.
|
|
230
|
+
*/
|
|
231
|
+
async pause(reason) {
|
|
232
|
+
let unlock = null;
|
|
233
|
+
try {
|
|
234
|
+
unlock = await this.acquireLock();
|
|
235
|
+
const now = Date.now();
|
|
236
|
+
const ledger = this.readLedger(now);
|
|
237
|
+
// Sentinel far-future timestamp rather than a real duration — resume()
|
|
238
|
+
// is the only thing that clears it. year ~2255, safely beyond any
|
|
239
|
+
// realistic process lifetime, and still a valid finite JS timestamp.
|
|
240
|
+
ledger.pausedUntil = 9_000_000_000_000;
|
|
241
|
+
ledger.pauseReason = (reason ?? 'manual pause (ruflo daemon budget pause)').slice(0, 200);
|
|
242
|
+
this.writeLedger(ledger);
|
|
243
|
+
this.appendReceipt({ event: 'manual-pause', at: now, reason: ledger.pauseReason });
|
|
244
|
+
}
|
|
245
|
+
finally {
|
|
246
|
+
unlock?.();
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
/** #2661 root-fix — `ruflo daemon budget resume`. Clears ANY pause (manual or quota-triggered). */
|
|
250
|
+
async resume() {
|
|
251
|
+
let unlock = null;
|
|
252
|
+
try {
|
|
253
|
+
unlock = await this.acquireLock();
|
|
254
|
+
const now = Date.now();
|
|
255
|
+
const ledger = this.readLedger(now);
|
|
256
|
+
const wasPaused = ledger.pausedUntil !== undefined && ledger.pausedUntil > now;
|
|
257
|
+
ledger.pausedUntil = undefined;
|
|
258
|
+
ledger.pauseReason = undefined;
|
|
259
|
+
this.writeLedger(ledger);
|
|
260
|
+
if (wasPaused) {
|
|
261
|
+
this.appendReceipt({ event: 'manual-resume', at: now });
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
finally {
|
|
265
|
+
unlock?.();
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* #2661 root-fix — structured per-launch token telemetry. Best-effort,
|
|
270
|
+
* receipt-only: usage is recorded as a distinct receipt keyed by permitId
|
|
271
|
+
* rather than mutated into the launch ledger, so a usage-recording failure
|
|
272
|
+
* can never corrupt the budget-enforcement ledger. Only operational
|
|
273
|
+
* metadata — never prompts or source content.
|
|
274
|
+
*/
|
|
275
|
+
recordUsage(permitId, usage) {
|
|
276
|
+
if (!permitId || permitId.startsWith('bypass_'))
|
|
277
|
+
return;
|
|
278
|
+
this.appendReceipt({ event: 'usage', at: Date.now(), permitId, ...usage });
|
|
279
|
+
}
|
|
224
280
|
/** Snapshot for `daemon status` / diagnostics. */
|
|
225
281
|
getUsage() {
|
|
226
282
|
try {
|
|
@@ -119,6 +119,10 @@ export interface HeadlessExecutionResult {
|
|
|
119
119
|
durationMs: number;
|
|
120
120
|
/** Estimated tokens used (if available) */
|
|
121
121
|
tokensUsed?: number;
|
|
122
|
+
/** #2661 root-fix — structured usage, when `claude --print --output-format json` exposed it. */
|
|
123
|
+
inputTokens?: number;
|
|
124
|
+
outputTokens?: number;
|
|
125
|
+
costUsd?: number;
|
|
122
126
|
/** Model used for execution */
|
|
123
127
|
model: string;
|
|
124
128
|
/** Sandbox mode used */
|
|
@@ -190,6 +194,23 @@ export declare function isLocalWorker(type: WorkerType): type is LocalWorkerType
|
|
|
190
194
|
* Get model ID from model type
|
|
191
195
|
*/
|
|
192
196
|
export declare function getModelId(model: ModelType): string;
|
|
197
|
+
export interface ClaudePrintEnvelope {
|
|
198
|
+
result: string;
|
|
199
|
+
inputTokens?: number;
|
|
200
|
+
outputTokens?: number;
|
|
201
|
+
costUsd?: number;
|
|
202
|
+
durationMs?: number;
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* #2661 root-fix — best-effort parse of `claude --print --output-format
|
|
206
|
+
* json`'s response envelope. Deliberately lenient: probes a couple of
|
|
207
|
+
* plausible field-name shapes (the CLI's JSON schema is not a versioned
|
|
208
|
+
* public contract) and returns null on anything unexpected rather than
|
|
209
|
+
* throwing, so a schema mismatch degrades to "no usage captured" — the
|
|
210
|
+
* caller then falls back to the raw stdout text, exactly today's behavior.
|
|
211
|
+
* Exported for direct unit testing without spawning a real process.
|
|
212
|
+
*/
|
|
213
|
+
export declare function parseClaudePrintJsonEnvelope(raw: string): ClaudePrintEnvelope | null;
|
|
193
214
|
/**
|
|
194
215
|
* Get worker configuration by type
|
|
195
216
|
*/
|
|
@@ -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
|
-
|
|
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
|
|
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
|