@delegance/claude-autopilot 7.10.1 → 7.11.0-pre.2
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/dist/src/cli/index.js +28 -2
- package/dist/src/cli/runs.d.ts +29 -0
- package/dist/src/cli/runs.js +226 -0
- package/dist/src/core/concurrent-dispatch/budget-reservation.d.ts +163 -0
- package/dist/src/core/concurrent-dispatch/budget-reservation.js +642 -0
- package/dist/src/core/concurrent-dispatch/dep-graph.d.ts +68 -0
- package/dist/src/core/concurrent-dispatch/dep-graph.js +626 -0
- package/dist/src/core/concurrent-dispatch/git-op-queue.d.ts +47 -0
- package/dist/src/core/concurrent-dispatch/git-op-queue.js +114 -0
- package/dist/src/core/concurrent-dispatch/index.d.ts +13 -0
- package/dist/src/core/concurrent-dispatch/index.js +21 -0
- package/dist/src/core/concurrent-dispatch/merge-orchestrator.d.ts +99 -0
- package/dist/src/core/concurrent-dispatch/merge-orchestrator.js +831 -0
- package/dist/src/core/concurrent-dispatch/scheduler.d.ts +160 -0
- package/dist/src/core/concurrent-dispatch/scheduler.js +933 -0
- package/dist/src/core/concurrent-dispatch/types.d.ts +156 -0
- package/dist/src/core/concurrent-dispatch/types.js +38 -0
- package/dist/src/core/concurrent-dispatch/worktree-lifecycle.d.ts +138 -0
- package/dist/src/core/concurrent-dispatch/worktree-lifecycle.js +418 -0
- package/dist/src/core/phases/deploy.d.ts +19 -0
- package/dist/src/core/phases/deploy.js +87 -0
- package/dist/src/core/run-state/repo-lock.d.ts +110 -0
- package/dist/src/core/run-state/repo-lock.js +373 -0
- package/dist/src/core/run-state/serialized-writer.d.ts +90 -0
- package/dist/src/core/run-state/serialized-writer.js +341 -0
- package/package.json +1 -1
- package/skills/ui-ux-pro-max/SKILL.md +4 -4
package/dist/src/cli/index.js
CHANGED
|
@@ -1264,7 +1264,7 @@ switch (subcommand) {
|
|
|
1264
1264
|
process.stdout.write(focused ?? buildHelpText());
|
|
1265
1265
|
process.exit(0);
|
|
1266
1266
|
}
|
|
1267
|
-
const { runRunsList, runRunsShow, runRunsGc, runRunsDelete, runRunsDoctor, } = await import("./runs.js");
|
|
1267
|
+
const { runRunsList, runRunsShow, runRunsGc, runRunsDelete, runRunsDoctor, runRunsCleanup, } = await import("./runs.js");
|
|
1268
1268
|
let result;
|
|
1269
1269
|
switch (sub) {
|
|
1270
1270
|
case 'list': {
|
|
@@ -1349,8 +1349,34 @@ switch (subcommand) {
|
|
|
1349
1349
|
});
|
|
1350
1350
|
break;
|
|
1351
1351
|
}
|
|
1352
|
+
case 'cleanup': {
|
|
1353
|
+
// v7.11.0 PR 2/6 — stale repo-lock recovery. The only operation
|
|
1354
|
+
// currently supported is `--force-unlock`. The verb is scoped
|
|
1355
|
+
// under `runs` (not its own top-level command) because the
|
|
1356
|
+
// intended audience is anyone who already runs `runs list` /
|
|
1357
|
+
// `runs show` etc. — they shouldn't need to learn a new noun.
|
|
1358
|
+
//
|
|
1359
|
+
// SECURITY: `--lock-path` is INTENTIONALLY NOT exposed on the
|
|
1360
|
+
// CLI. The handler accepts an override for tests, but routing a
|
|
1361
|
+
// user-supplied path here would let a typo or malicious script
|
|
1362
|
+
// `forceUnlockRepoLock` an arbitrary `<path>` + `<path>.lock`
|
|
1363
|
+
// pair on disk. The lock path is always derived from the cwd as
|
|
1364
|
+
// `<cwd>/.claude/run-state/repo.lock` in production.
|
|
1365
|
+
// (Codex pass 1 WARNING.)
|
|
1366
|
+
const forceUnlock = boolFlag('force-unlock');
|
|
1367
|
+
const yes = boolFlag('yes');
|
|
1368
|
+
const allowActive = boolFlag('allow-active');
|
|
1369
|
+
result = await runRunsCleanup({
|
|
1370
|
+
cwd,
|
|
1371
|
+
forceUnlock,
|
|
1372
|
+
yes,
|
|
1373
|
+
allowActive,
|
|
1374
|
+
json,
|
|
1375
|
+
});
|
|
1376
|
+
break;
|
|
1377
|
+
}
|
|
1352
1378
|
default: {
|
|
1353
|
-
process.stderr.write(`\x1b[31m[claude-autopilot] runs: unknown sub-verb "${sub}" — valid: list, show, gc, delete, doctor, watch\x1b[0m\n`);
|
|
1379
|
+
process.stderr.write(`\x1b[31m[claude-autopilot] runs: unknown sub-verb "${sub}" — valid: list, show, gc, delete, doctor, watch, cleanup\x1b[0m\n`);
|
|
1354
1380
|
process.exit(1);
|
|
1355
1381
|
}
|
|
1356
1382
|
}
|
package/dist/src/cli/runs.d.ts
CHANGED
|
@@ -118,5 +118,34 @@ export interface RunsDoctorRunReport {
|
|
|
118
118
|
* events-corrupt : events.ndjson can't be folded (bigger problem)
|
|
119
119
|
*/
|
|
120
120
|
export declare function runRunsDoctor(opts: RunRunsDoctorOptions): Promise<RunsCliResult>;
|
|
121
|
+
export interface RunRunsCleanupOptions {
|
|
122
|
+
cwd?: string;
|
|
123
|
+
/** Required — currently the only operation. Future cleanup verbs (e.g.
|
|
124
|
+
* `--gc-worktrees`) would be additional bool flags on the same subcommand. */
|
|
125
|
+
forceUnlock: boolean;
|
|
126
|
+
/** Bypass the interactive `yes` prompt. Used by scripts (not exposed in
|
|
127
|
+
* the README as an example — we want manual operators to type yes). */
|
|
128
|
+
yes?: boolean;
|
|
129
|
+
/** Permit clearing a lock whose holder is still alive (or whose status
|
|
130
|
+
* cannot be determined, e.g. cross-host). By default we refuse to clear
|
|
131
|
+
* non-stale locks because doing so can corrupt git state under a live
|
|
132
|
+
* writer. Codex pass 1 WARNING — see issue #189 PR review. */
|
|
133
|
+
allowActive?: boolean;
|
|
134
|
+
/** Override the repo-lock path. Tests use this. INTENTIONALLY NOT exposed
|
|
135
|
+
* on the CLI (see `src/cli/index.ts`) — a user-supplied path would let a
|
|
136
|
+
* typo destroy arbitrary files on disk. */
|
|
137
|
+
lockPath?: string;
|
|
138
|
+
/** Custom stdin reader for tests. Defaults to a node:readline-bound
|
|
139
|
+
* interface against process.stdin. */
|
|
140
|
+
promptFn?: (question: string) => Promise<string>;
|
|
141
|
+
json?: boolean;
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* `runs cleanup --force-unlock` — surface the holder, require explicit
|
|
145
|
+
* `yes` confirmation, then unlink the lock metadata + the proper-lockfile
|
|
146
|
+
* `.lock` directory. Idempotent: if nothing is held, returns success with
|
|
147
|
+
* a "nothing to do" message.
|
|
148
|
+
*/
|
|
149
|
+
export declare function runRunsCleanup(opts: RunRunsCleanupOptions): Promise<RunsCliResult>;
|
|
121
150
|
export { statePath };
|
|
122
151
|
//# sourceMappingURL=runs.d.ts.map
|
package/dist/src/cli/runs.js
CHANGED
|
@@ -18,11 +18,13 @@
|
|
|
18
18
|
// mode", "Migration path". `--json` envelope shape in Phase 3 is the v1
|
|
19
19
|
// surface; strict stdout/stderr channel discipline lands in Phase 5.
|
|
20
20
|
import * as fs from 'node:fs';
|
|
21
|
+
import * as os from 'node:os';
|
|
21
22
|
import * as path from 'node:path';
|
|
22
23
|
import * as readline from 'node:readline';
|
|
23
24
|
import { GuardrailError } from "../core/errors.js";
|
|
24
25
|
import { foldEvents, readEvents } from "../core/run-state/events.js";
|
|
25
26
|
import { acquireRunLock } from "../core/run-state/lock.js";
|
|
27
|
+
import { forceUnlockRepoLock, formatLockDiagnostic, isLockStale, peekRepoLock, } from "../core/run-state/repo-lock.js";
|
|
26
28
|
import { decideReplay } from "../core/run-state/replay-decision.js";
|
|
27
29
|
import { readStateSnapshot, statePath, writeStateSnapshot } from "../core/run-state/state.js";
|
|
28
30
|
import { isValidULID } from "../core/run-state/ulid.js";
|
|
@@ -897,6 +899,230 @@ function diffStates(a, b) {
|
|
|
897
899
|
}
|
|
898
900
|
return null;
|
|
899
901
|
}
|
|
902
|
+
/** Default user prompt — single line, expects exactly the string `yes`. */
|
|
903
|
+
function defaultPromptFn(question) {
|
|
904
|
+
return new Promise(resolve => {
|
|
905
|
+
const rl = readline.createInterface({
|
|
906
|
+
input: process.stdin,
|
|
907
|
+
output: process.stdout,
|
|
908
|
+
});
|
|
909
|
+
rl.question(question, answer => {
|
|
910
|
+
rl.close();
|
|
911
|
+
resolve(answer);
|
|
912
|
+
});
|
|
913
|
+
});
|
|
914
|
+
}
|
|
915
|
+
/** Resolve the default repo-lock path relative to a given cwd. */
|
|
916
|
+
function defaultRepoLockPath(cwd) {
|
|
917
|
+
return path.join(cwd, '.claude', 'run-state', 'repo.lock');
|
|
918
|
+
}
|
|
919
|
+
/**
|
|
920
|
+
* Validate that a caller-supplied `lockPath` is safe to use. We accept:
|
|
921
|
+
* - the conventional default `<cwd>/.claude/run-state/repo.lock`
|
|
922
|
+
* - paths under the OS temp dir (for tests using `mkdtemp` scratch dirs)
|
|
923
|
+
*
|
|
924
|
+
* Anything else throws. This protects against an internal caller routing
|
|
925
|
+
* user-controlled input into `forceUnlockRepoLock`, which would otherwise
|
|
926
|
+
* remove arbitrary `<path>` + `<path>.lock` pairs on disk.
|
|
927
|
+
*
|
|
928
|
+
* Codex pass 2 WARNING — the `lockPath` option remained on the exported
|
|
929
|
+
* API surface even after the CLI flag was removed.
|
|
930
|
+
*/
|
|
931
|
+
function assertSafeLockPath(lockPath, cwd) {
|
|
932
|
+
const conventional = defaultRepoLockPath(cwd);
|
|
933
|
+
const tmpRoot = os.tmpdir();
|
|
934
|
+
const resolved = path.resolve(lockPath);
|
|
935
|
+
if (resolved === path.resolve(conventional))
|
|
936
|
+
return;
|
|
937
|
+
// Allow temp-dir paths for tests. Use realpath-resolved tmpdir to handle
|
|
938
|
+
// /var → /private/var on macOS.
|
|
939
|
+
let tmpResolved;
|
|
940
|
+
try {
|
|
941
|
+
tmpResolved = fs.realpathSync(tmpRoot);
|
|
942
|
+
}
|
|
943
|
+
catch {
|
|
944
|
+
tmpResolved = path.resolve(tmpRoot);
|
|
945
|
+
}
|
|
946
|
+
const resolvedReal = (() => {
|
|
947
|
+
try {
|
|
948
|
+
return fs.realpathSync(path.dirname(resolved)) + path.sep + path.basename(resolved);
|
|
949
|
+
}
|
|
950
|
+
catch {
|
|
951
|
+
return resolved;
|
|
952
|
+
}
|
|
953
|
+
})();
|
|
954
|
+
if (resolvedReal.startsWith(tmpResolved + path.sep))
|
|
955
|
+
return;
|
|
956
|
+
throw new GuardrailError(`runs cleanup: lockPath override outside the expected location is refused (got "${lockPath}", expected "${conventional}" or a temp-dir path)`, {
|
|
957
|
+
code: 'invalid_config',
|
|
958
|
+
provider: 'runs-cli',
|
|
959
|
+
details: { lockPath, conventional },
|
|
960
|
+
});
|
|
961
|
+
}
|
|
962
|
+
/**
|
|
963
|
+
* `runs cleanup --force-unlock` — surface the holder, require explicit
|
|
964
|
+
* `yes` confirmation, then unlink the lock metadata + the proper-lockfile
|
|
965
|
+
* `.lock` directory. Idempotent: if nothing is held, returns success with
|
|
966
|
+
* a "nothing to do" message.
|
|
967
|
+
*/
|
|
968
|
+
export async function runRunsCleanup(opts) {
|
|
969
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
970
|
+
const json = !!opts.json;
|
|
971
|
+
const lockPath = opts.lockPath ?? defaultRepoLockPath(cwd);
|
|
972
|
+
// Validate lockPath if it was supplied (Codex pass 2 WARNING).
|
|
973
|
+
if (opts.lockPath) {
|
|
974
|
+
try {
|
|
975
|
+
assertSafeLockPath(opts.lockPath, cwd);
|
|
976
|
+
}
|
|
977
|
+
catch (err) {
|
|
978
|
+
return maybeEnvelope('runs cleanup', json, {
|
|
979
|
+
exit: 1,
|
|
980
|
+
stdout: [],
|
|
981
|
+
stderr: [`[claude-autopilot] runs cleanup: ${formatErr(err)}`],
|
|
982
|
+
}, { error: formatErr(err) });
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
if (!opts.forceUnlock) {
|
|
986
|
+
const err = new GuardrailError('runs cleanup requires an operation flag — currently only --force-unlock is supported', {
|
|
987
|
+
code: 'invalid_config',
|
|
988
|
+
provider: 'runs-cli',
|
|
989
|
+
details: { lockPath },
|
|
990
|
+
});
|
|
991
|
+
return maybeEnvelope('runs cleanup', json, {
|
|
992
|
+
exit: 1,
|
|
993
|
+
stdout: [],
|
|
994
|
+
stderr: [`[claude-autopilot] runs cleanup: ${formatErr(err)}`],
|
|
995
|
+
}, { error: formatErr(err) });
|
|
996
|
+
}
|
|
997
|
+
// Read existing metadata. Missing meta + missing lock dir = nothing to
|
|
998
|
+
// clean; we say so and exit 0 (idempotent).
|
|
999
|
+
const meta = peekRepoLock(lockPath);
|
|
1000
|
+
const lockDirExists = fs.existsSync(lockPath + '.lock');
|
|
1001
|
+
if (!meta && !lockDirExists) {
|
|
1002
|
+
return maybeEnvelope('runs cleanup', json, {
|
|
1003
|
+
exit: 0,
|
|
1004
|
+
stdout: [`runs cleanup: no repo lock at ${lockPath} — nothing to do`],
|
|
1005
|
+
stderr: [],
|
|
1006
|
+
}, { lockPath, cleared: false, reason: 'no-lock-present' });
|
|
1007
|
+
}
|
|
1008
|
+
// Print the diagnostic so the user knows exactly what they're clearing.
|
|
1009
|
+
const diagnosticLines = [];
|
|
1010
|
+
if (meta) {
|
|
1011
|
+
diagnosticLines.push(formatLockDiagnostic(meta, lockPath));
|
|
1012
|
+
}
|
|
1013
|
+
else {
|
|
1014
|
+
diagnosticLines.push(`Repo lock at ${lockPath} exists but has no metadata sidecar.`, '(The holder likely crashed between acquiring the lock and writing metadata.)');
|
|
1015
|
+
}
|
|
1016
|
+
// Safety gate: refuse to clear non-stale locks without --allow-active.
|
|
1017
|
+
// A stale lock (dead PID AND >1h old) is safe to clear; anything else
|
|
1018
|
+
// — live holder, unknown holder (no metadata), or cross-host — is
|
|
1019
|
+
// suspicious. Codex pass 1 WARNING flagged that scripted `--yes` could
|
|
1020
|
+
// silently steal an active lock. (#189 PR review)
|
|
1021
|
+
//
|
|
1022
|
+
// Fail-closed semantics (Codex pass 2 WARNING): only proceed when
|
|
1023
|
+
// `isLockStale(meta) === true`. Any other value (false, or — if the
|
|
1024
|
+
// signature evolves — null/undefined for "cannot determine") falls
|
|
1025
|
+
// back to refusing without --allow-active.
|
|
1026
|
+
const isConfirmedStale = meta ? isLockStale(meta) === true : false;
|
|
1027
|
+
if (!isConfirmedStale && !opts.allowActive) {
|
|
1028
|
+
const err = new GuardrailError(meta
|
|
1029
|
+
? `repo lock at ${lockPath} is not stale (holder appears active) — re-run with --allow-active to override`
|
|
1030
|
+
: `repo lock at ${lockPath} has no metadata sidecar — holder identity is unknown. Re-run with --allow-active to override`, {
|
|
1031
|
+
code: 'invalid_config',
|
|
1032
|
+
provider: 'runs-cli',
|
|
1033
|
+
details: { lockPath, ...(meta ? { metadata: meta } : {}) },
|
|
1034
|
+
});
|
|
1035
|
+
return maybeEnvelope('runs cleanup', json, {
|
|
1036
|
+
exit: 1,
|
|
1037
|
+
stdout: [],
|
|
1038
|
+
stderr: [
|
|
1039
|
+
...diagnosticLines,
|
|
1040
|
+
'',
|
|
1041
|
+
`[claude-autopilot] runs cleanup: ${formatErr(err)}`,
|
|
1042
|
+
],
|
|
1043
|
+
}, { error: formatErr(err), lockPath, ...(meta ? { previousHolder: meta } : {}) });
|
|
1044
|
+
}
|
|
1045
|
+
// Confirmation — REQUIRED in text mode, may be bypassed by --yes in
|
|
1046
|
+
// scripted mode. JSON mode without --yes is rejected: we will not
|
|
1047
|
+
// dump a confirmation prompt to JSON consumers.
|
|
1048
|
+
if (!opts.yes) {
|
|
1049
|
+
if (json) {
|
|
1050
|
+
const err = new GuardrailError('runs cleanup --force-unlock requires --yes when used with --json', {
|
|
1051
|
+
code: 'invalid_config',
|
|
1052
|
+
provider: 'runs-cli',
|
|
1053
|
+
details: { lockPath },
|
|
1054
|
+
});
|
|
1055
|
+
return maybeEnvelope('runs cleanup', json, {
|
|
1056
|
+
exit: 1,
|
|
1057
|
+
stdout: [],
|
|
1058
|
+
stderr: [`[claude-autopilot] runs cleanup: ${formatErr(err)}`],
|
|
1059
|
+
}, { error: formatErr(err), lockPath });
|
|
1060
|
+
}
|
|
1061
|
+
// Print diagnostic to stderr so it appears even under output piping.
|
|
1062
|
+
for (const line of diagnosticLines) {
|
|
1063
|
+
process.stderr.write(`${line}\n`);
|
|
1064
|
+
}
|
|
1065
|
+
process.stderr.write('\n');
|
|
1066
|
+
const prompt = opts.promptFn ?? defaultPromptFn;
|
|
1067
|
+
const answer = (await prompt('Type "yes" to force-unlock: ')).trim();
|
|
1068
|
+
if (answer !== 'yes') {
|
|
1069
|
+
return {
|
|
1070
|
+
exit: 1,
|
|
1071
|
+
stdout: [],
|
|
1072
|
+
stderr: [`runs cleanup: aborted (confirmation not "yes")`],
|
|
1073
|
+
};
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
// TOCTOU re-check (Codex pass 2 WARNING): between the safety gate above
|
|
1077
|
+
// and the actual unlock, the holder could have released and a new
|
|
1078
|
+
// process could have acquired the lock. Re-read the metadata and refuse
|
|
1079
|
+
// the unlock if the holder identity changed since the diagnostic was
|
|
1080
|
+
// printed. Skipped when --allow-active was explicitly requested (the
|
|
1081
|
+
// user has accepted the risk; this branch is reserved for force-clear
|
|
1082
|
+
// operations that should not be blocked by a fast-acquiring sibling).
|
|
1083
|
+
if (!opts.allowActive) {
|
|
1084
|
+
const refreshed = peekRepoLock(lockPath);
|
|
1085
|
+
const sameHolder = (refreshed === null && meta === null) ||
|
|
1086
|
+
(refreshed !== null &&
|
|
1087
|
+
meta !== null &&
|
|
1088
|
+
refreshed.pid === meta.pid &&
|
|
1089
|
+
refreshed.hostname === meta.hostname &&
|
|
1090
|
+
refreshed.run_id === meta.run_id &&
|
|
1091
|
+
refreshed.acquired_at_iso === meta.acquired_at_iso);
|
|
1092
|
+
if (!sameHolder) {
|
|
1093
|
+
const err = new GuardrailError('repo lock changed hands during cleanup — refusing to clear the new holder', {
|
|
1094
|
+
code: 'invalid_config',
|
|
1095
|
+
provider: 'runs-cli',
|
|
1096
|
+
details: {
|
|
1097
|
+
lockPath,
|
|
1098
|
+
...(meta ? { observedHolder: meta } : {}),
|
|
1099
|
+
...(refreshed ? { currentHolder: refreshed } : {}),
|
|
1100
|
+
},
|
|
1101
|
+
});
|
|
1102
|
+
return maybeEnvelope('runs cleanup', json, {
|
|
1103
|
+
exit: 1,
|
|
1104
|
+
stdout: [],
|
|
1105
|
+
stderr: [`[claude-autopilot] runs cleanup: ${formatErr(err)}`],
|
|
1106
|
+
}, { error: formatErr(err), lockPath });
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
const removed = forceUnlockRepoLock(lockPath);
|
|
1110
|
+
const stale = meta ? isLockStale(meta) : null;
|
|
1111
|
+
return maybeEnvelope('runs cleanup', json, {
|
|
1112
|
+
exit: 0,
|
|
1113
|
+
stdout: [
|
|
1114
|
+
...diagnosticLines,
|
|
1115
|
+
removed
|
|
1116
|
+
? `runs cleanup: removed repo lock at ${lockPath}`
|
|
1117
|
+
: `runs cleanup: nothing to remove at ${lockPath}`,
|
|
1118
|
+
],
|
|
1119
|
+
stderr: [],
|
|
1120
|
+
}, {
|
|
1121
|
+
lockPath,
|
|
1122
|
+
cleared: removed,
|
|
1123
|
+
...(meta ? { previousHolder: meta, wasStale: stale } : {}),
|
|
1124
|
+
});
|
|
1125
|
+
}
|
|
900
1126
|
// `statePath` is re-exported for convenience to keep CLI imports tidy.
|
|
901
1127
|
export { statePath };
|
|
902
1128
|
//# sourceMappingURL=runs.js.map
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { GuardrailError } from '../errors.ts';
|
|
2
|
+
import { SerializedWriter } from '../run-state/serialized-writer.ts';
|
|
3
|
+
/** Budget caps for a single run. `perRunUSD` is the hard ceiling on total
|
|
4
|
+
* committed spend (spent + active reservations) across all concurrent
|
|
5
|
+
* tasks. `perSubagentUSD` is the per-task hard cap that dispatch refuses
|
|
6
|
+
* to exceed even if perRunUSD has headroom. */
|
|
7
|
+
export interface BudgetCaps {
|
|
8
|
+
/** Total USD this run is allowed to commit across all subagents +
|
|
9
|
+
* in-flight reservations. */
|
|
10
|
+
perRunUSD: number;
|
|
11
|
+
/** Hard per-subagent cap. A pre-flight estimate exceeding this rejects
|
|
12
|
+
* dispatch; an actual cost exceeding it mid-execution triggers the
|
|
13
|
+
* scheduler's SIGTERM path (PR 4) — this layer just emits the
|
|
14
|
+
* `task.failed` event with `error_type: 'budget_exceeded'`. */
|
|
15
|
+
perSubagentUSD: number;
|
|
16
|
+
}
|
|
17
|
+
/** Per-task ledger entry. Reconstructed via `replayFromEvents` at startup
|
|
18
|
+
* / resume. */
|
|
19
|
+
export interface ReservationEntry {
|
|
20
|
+
task_id: string;
|
|
21
|
+
/** Latest reservation value (after any
|
|
22
|
+
* `task.budget_increased_reservation` events; ABSOLUTE, not delta). */
|
|
23
|
+
reserved_usd: number;
|
|
24
|
+
/** True once `task.budget_released` has landed for this task. */
|
|
25
|
+
released: boolean;
|
|
26
|
+
/** Actual cost if `released` is true. */
|
|
27
|
+
actual_cost_usd?: number;
|
|
28
|
+
}
|
|
29
|
+
/** Result of replaying events.ndjson into a fresh ledger state. */
|
|
30
|
+
export interface BudgetReplaySummary {
|
|
31
|
+
/** Sum of `actual_cost_usd` across released tasks. Committed against
|
|
32
|
+
* `perRunUSD` permanently. */
|
|
33
|
+
spentTotal: number;
|
|
34
|
+
/** Sum of `reserved_usd` for tasks NOT yet released. Each task
|
|
35
|
+
* contributes its LATEST reservation (absolute value), not the sum of
|
|
36
|
+
* reserved + increased values. */
|
|
37
|
+
activeReservedTotal: number;
|
|
38
|
+
/** `spentTotal + activeReservedTotal`. The number the headroom check
|
|
39
|
+
* compares against `perRunUSD`. */
|
|
40
|
+
committedTotal: number;
|
|
41
|
+
/** Per-task latest state. */
|
|
42
|
+
perTask: Map<string, ReservationEntry>;
|
|
43
|
+
}
|
|
44
|
+
/** Thrown by `reserve()` / `increaseReservation()` when the requested
|
|
45
|
+
* amount would push the run over `perRunUSD`, or when a pre-flight
|
|
46
|
+
* exceeds `perSubagentUSD`. Carries the GuardrailError code
|
|
47
|
+
* `budget_exceeded` for upstream resume classification. */
|
|
48
|
+
export declare class BudgetExceededError extends GuardrailError {
|
|
49
|
+
constructor(message: string, details: Record<string, unknown>);
|
|
50
|
+
}
|
|
51
|
+
export interface ReserveOptions {
|
|
52
|
+
/** Pre-flight cost estimate for the task in USD. */
|
|
53
|
+
preFlightEstimateUsd: number;
|
|
54
|
+
/** Budget caps for this run. */
|
|
55
|
+
caps: BudgetCaps;
|
|
56
|
+
}
|
|
57
|
+
export interface IncreaseReservationOptions {
|
|
58
|
+
/** New reservation total (NOT a delta — the absolute new value). */
|
|
59
|
+
newReservedUsd: number;
|
|
60
|
+
/** Free-form reason captured on the event. */
|
|
61
|
+
reason: string;
|
|
62
|
+
/** Budget caps for this run. */
|
|
63
|
+
caps: BudgetCaps;
|
|
64
|
+
}
|
|
65
|
+
export interface ReleaseOptions {
|
|
66
|
+
/** Actual cost spent by the subagent (from telemetry). */
|
|
67
|
+
actualCostUsd: number;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Budget reservation ledger. One instance per run, owned by the scheduler.
|
|
71
|
+
* All mutations route through the supplied `SerializedWriter` and re-replay
|
|
72
|
+
* events.ndjson under the exclusive lock so they are atomic against
|
|
73
|
+
* concurrent callers AND any other `BudgetReservation` instances pointed at
|
|
74
|
+
* the same run.
|
|
75
|
+
*/
|
|
76
|
+
export declare class BudgetReservation {
|
|
77
|
+
private readonly writer;
|
|
78
|
+
/** In-memory mirror of disk state, kept for fast `snapshot()` reads.
|
|
79
|
+
* NOT authoritative — every mutating operation re-replays the on-disk
|
|
80
|
+
* log inside the writer's exclusive lock before checking caps. Per-task
|
|
81
|
+
* reservations stored in USD (public API); the running totals below are
|
|
82
|
+
* in MICROS (integer-safe arithmetic). */
|
|
83
|
+
private reservations;
|
|
84
|
+
private spentMicros;
|
|
85
|
+
private activeReservedMicros;
|
|
86
|
+
constructor(writer: SerializedWriter);
|
|
87
|
+
/** Re-seed in-memory state from events.ndjson. Call on construction OR
|
|
88
|
+
* on resume. Safe to call multiple times — overwrites the cache. */
|
|
89
|
+
hydrateFromEvents(eventsNdjsonPath: string): Promise<void>;
|
|
90
|
+
/** Read the current in-memory state. Best-effort: a concurrent writer
|
|
91
|
+
* in another instance could make this stale until the next mutating
|
|
92
|
+
* call re-hydrates from disk. */
|
|
93
|
+
snapshot(): BudgetReplaySummary;
|
|
94
|
+
/**
|
|
95
|
+
* Reserve budget for a task. Atomic under the writer lock:
|
|
96
|
+
*
|
|
97
|
+
* 1. HARD cap check (pre-lock): `preFlightEstimate <= caps.perSubagentUSD`
|
|
98
|
+
* 2. Acquire SerializedWriter lock
|
|
99
|
+
* 3. Re-replay events.ndjson from disk (authoritative)
|
|
100
|
+
* 4. Verify task doesn't already have an in-flight reservation
|
|
101
|
+
* 5. Verify `caps.perRunUSD - committedTotal >= preFlightEstimate`
|
|
102
|
+
* (committedTotal = spent + active reservations)
|
|
103
|
+
* 6. If pass: append `task.budget_reserved` event + fsync
|
|
104
|
+
* If fail: append `task.budget_halt` event + fsync, then throw
|
|
105
|
+
* 7. Release lock
|
|
106
|
+
*
|
|
107
|
+
* Throws `BudgetExceededError` on cap violations. The `task.budget_halt`
|
|
108
|
+
* variant is durable: the event lands inside the same critical section
|
|
109
|
+
* before the throw propagates, so a crash post-throw still surfaces the
|
|
110
|
+
* halt on resume.
|
|
111
|
+
*/
|
|
112
|
+
reserve(taskId: string, opts: ReserveOptions): Promise<void>;
|
|
113
|
+
/**
|
|
114
|
+
* Bump an in-flight reservation to a new ABSOLUTE value. Atomic under
|
|
115
|
+
* the writer lock; re-replays disk state before checking caps.
|
|
116
|
+
* Re-checks `perRunUSD` against the NEW reservation total; halts the
|
|
117
|
+
* run with `task.budget_halt` if the bump would exceed the cap.
|
|
118
|
+
*
|
|
119
|
+
* Throws `BudgetExceededError` (with durable `task.budget_halt`) if no
|
|
120
|
+
* prior reservation exists, the bump is downward (use `release`
|
|
121
|
+
* instead), or the bump exceeds caps.
|
|
122
|
+
*/
|
|
123
|
+
increaseReservation(taskId: string, opts: IncreaseReservationOptions): Promise<void>;
|
|
124
|
+
/**
|
|
125
|
+
* Release a reservation with the actual cost. Atomic under the writer
|
|
126
|
+
* lock; re-replays disk state. Moves the task's commitment from
|
|
127
|
+
* `activeReserved` to `spent`. Emits `task.budget_released` with
|
|
128
|
+
* `delta_vs_reservation_usd` (positive = under, negative = over).
|
|
129
|
+
*
|
|
130
|
+
* Note: a release with `actualCostUsd > reserved_usd` is NOT a halt —
|
|
131
|
+
* the budget was already committed at reservation time. The scheduler
|
|
132
|
+
* is expected to issue `increaseReservation` mid-flight when telemetry
|
|
133
|
+
* suggests overrun, and to emit `task.failed{error_type:'budget_exceeded'}`
|
|
134
|
+
* if the increase itself fails. This `release` just records the truth.
|
|
135
|
+
*/
|
|
136
|
+
release(taskId: string, opts: ReleaseOptions): Promise<void>;
|
|
137
|
+
/** Internal: replace in-memory ledger from a replay summary (in micros).
|
|
138
|
+
* Used by every mutating method to re-sync cache with disk under the
|
|
139
|
+
* lock. */
|
|
140
|
+
private applyReplayMicros;
|
|
141
|
+
/**
|
|
142
|
+
* Replay events.ndjson into a fresh `BudgetReplaySummary` (decimal USD).
|
|
143
|
+
* Used by:
|
|
144
|
+
* 1. `hydrateFromEvents` — runtime cache seed on resume / startup
|
|
145
|
+
* 2. tests — to assert reconstruction is exact
|
|
146
|
+
*
|
|
147
|
+
* Internally calls `replayFromEventsMicros` and converts to USD at the
|
|
148
|
+
* boundary so external consumers see decimal numbers, but the fold itself
|
|
149
|
+
* is integer-micro arithmetic.
|
|
150
|
+
*
|
|
151
|
+
* Line parsing is lenient: blank lines are skipped, parse failures stop
|
|
152
|
+
* the walk (treated as truncated tail; the next `appendEvent` would
|
|
153
|
+
* emit `run.recovery`, and budget state stays consistent up to the
|
|
154
|
+
* last-known-good line).
|
|
155
|
+
*/
|
|
156
|
+
static replayFromEvents(eventsNdjsonPath: string): BudgetReplaySummary;
|
|
157
|
+
/**
|
|
158
|
+
* Internal: micro-precision replay. Used by every mutating method INSIDE
|
|
159
|
+
* `withExclusive` so cap checks operate on integer arithmetic.
|
|
160
|
+
*/
|
|
161
|
+
private static replayFromEventsMicros;
|
|
162
|
+
}
|
|
163
|
+
//# sourceMappingURL=budget-reservation.d.ts.map
|