@agentguard-run/burn 0.1.1 → 0.2.1
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/CHANGELOG.md +93 -0
- package/README.md +145 -6
- package/dist/src/adapters/codex.d.ts +48 -0
- package/dist/src/adapters/codex.js +197 -0
- package/dist/src/adapters/cursor.d.ts +35 -0
- package/dist/src/adapters/cursor.js +135 -0
- package/dist/src/adapters/raw-api.d.ts +76 -0
- package/dist/src/adapters/raw-api.js +130 -0
- package/dist/src/cli.d.ts +7 -3
- package/dist/src/cli.js +141 -17
- package/dist/src/conformance.d.ts +26 -0
- package/dist/src/conformance.js +261 -0
- package/dist/src/defaults.d.ts +11 -0
- package/dist/src/defaults.js +16 -1
- package/dist/src/detectors/local-compute.d.ts +19 -0
- package/dist/src/detectors/local-compute.js +66 -0
- package/dist/src/events.d.ts +94 -0
- package/dist/src/events.js +47 -0
- package/dist/src/gateway.d.ts +141 -0
- package/dist/src/gateway.js +536 -0
- package/dist/src/hook/pre-tool-use.d.ts +25 -1
- package/dist/src/hook/pre-tool-use.js +64 -16
- package/dist/src/index.d.ts +19 -4
- package/dist/src/index.js +57 -1
- package/dist/src/install.d.ts +29 -0
- package/dist/src/install.js +145 -0
- package/dist/src/override.d.ts +32 -0
- package/dist/src/override.js +72 -0
- package/dist/src/proxy/server.d.ts +45 -0
- package/dist/src/proxy/server.js +169 -0
- package/dist/src/proxy/usage-observer.d.ts +40 -0
- package/dist/src/proxy/usage-observer.js +128 -0
- package/dist/src/receipt.d.ts +61 -0
- package/dist/src/receipt.js +98 -0
- package/dist/src/replay/render.d.ts +1 -0
- package/dist/src/replay/render.js +2 -1
- package/dist/src/state/reservations.d.ts +115 -11
- package/dist/src/state/reservations.js +293 -59
- package/dist/src/state/session.d.ts +6 -0
- package/dist/src/state/session.js +17 -0
- package/dist/src/status.d.ts +19 -0
- package/dist/src/status.js +112 -0
- package/dist/src/types.d.ts +14 -1
- package/fixtures/codex-0.151.0-pretooluse.json +49 -0
- package/package.json +34 -6
|
@@ -27,18 +27,88 @@ export interface Reservation {
|
|
|
27
27
|
at: number;
|
|
28
28
|
expiresAt: number;
|
|
29
29
|
}
|
|
30
|
+
/**
|
|
31
|
+
* A model call in flight or recently finished. Lives in the same file, under
|
|
32
|
+
* the same lock, as spawn reservations: the cross-tool claim collapses the
|
|
33
|
+
* moment there are two lock domains. Finished calls are kept only as long as
|
|
34
|
+
* the local-compute window needs them for occupied-time accounting.
|
|
35
|
+
*/
|
|
36
|
+
export interface CallReservation {
|
|
37
|
+
sessionId: string;
|
|
38
|
+
callId: string;
|
|
39
|
+
host: string;
|
|
40
|
+
estimatedTokens: number;
|
|
41
|
+
startedAt: number;
|
|
42
|
+
finishedAt: number | null;
|
|
43
|
+
expiresAt: number;
|
|
44
|
+
}
|
|
45
|
+
interface ReservationFile {
|
|
46
|
+
version: 1 | 2;
|
|
47
|
+
reservations: Reservation[];
|
|
48
|
+
calls?: CallReservation[];
|
|
49
|
+
}
|
|
50
|
+
export interface ComputeSnapshot {
|
|
51
|
+
/** Calls started and not yet finished, across every host on this machine. */
|
|
52
|
+
inFlight: number;
|
|
53
|
+
inFlightForSession: number;
|
|
54
|
+
/** Sum of request durations overlapping the window. Not GPU utilisation. */
|
|
55
|
+
occupiedMs: number;
|
|
56
|
+
/** Estimates reserved by calls still in flight. */
|
|
57
|
+
pendingEstimatedTokens: number;
|
|
58
|
+
windowMs: number;
|
|
59
|
+
}
|
|
30
60
|
export declare const RESERVATION_TTL_MS = 90000;
|
|
61
|
+
/**
|
|
62
|
+
* Lock instances are identified by nonce, not by path. This is what makes
|
|
63
|
+
* the lock survive contention from hundreds of processes:
|
|
64
|
+
*
|
|
65
|
+
* A waiter that reads the owner record, then gets descheduled, then judges
|
|
66
|
+
* "owner is dead" is telling the truth about an instance that has since
|
|
67
|
+
* been released and replaced. Under 240 concurrent hook processes that
|
|
68
|
+
* exact stall happened, the waiter tore down a live sibling's lock, two
|
|
69
|
+
* processes ran the transaction at once, and 43 spawns were admitted
|
|
70
|
+
* against a cap of 40. Every teardown below is therefore checked against
|
|
71
|
+
* the nonce it was judged on, and every write is fenced on the holder's own
|
|
72
|
+
* nonce still being on the path.
|
|
73
|
+
*/
|
|
31
74
|
export declare class ReservationStore {
|
|
32
75
|
private readonly home;
|
|
33
76
|
private readonly lockDir;
|
|
34
77
|
private readonly file;
|
|
78
|
+
private held;
|
|
35
79
|
constructor(home: string);
|
|
36
80
|
/** Acquire the lock or throw. Callers must fail closed on throw. */
|
|
37
81
|
private acquire;
|
|
82
|
+
private readOwnerAt;
|
|
83
|
+
/** The holder's own instance is still the one on the path. */
|
|
84
|
+
private fence;
|
|
85
|
+
/**
|
|
86
|
+
* Take the lock directory off its path atomically, then verify it is the
|
|
87
|
+
* instance we meant. rmSync on the live path is readdir + unlink + rmdir,
|
|
88
|
+
* and a sibling can mkdir the same path between those steps, so removal
|
|
89
|
+
* is always rename-then-delete. If the instance we grabbed is not the one
|
|
90
|
+
* we judged (`expect`), it is a live sibling's: put it back.
|
|
91
|
+
*/
|
|
92
|
+
private discard;
|
|
93
|
+
private trace;
|
|
38
94
|
private recoverIfStale;
|
|
39
95
|
private release;
|
|
40
96
|
private load;
|
|
41
97
|
private save;
|
|
98
|
+
/**
|
|
99
|
+
* Run `fn` with the machine-wide lock held. Everything inside sees one
|
|
100
|
+
* consistent reservation file and writes it back once. The gateway uses
|
|
101
|
+
* this to make "fold state, evaluate, reserve, sign" a single transaction,
|
|
102
|
+
* so two hosts racing the same session cannot interleave halfway.
|
|
103
|
+
*
|
|
104
|
+
* Throws if the lock cannot be taken. Callers must fail closed on throw.
|
|
105
|
+
*/
|
|
106
|
+
withLock<T>(fn: (tx: Transaction) => T): T;
|
|
107
|
+
/**
|
|
108
|
+
* Callers that write their own files inside a transaction (the gateway's
|
|
109
|
+
* session file) call this right before writing, for the same reason.
|
|
110
|
+
*/
|
|
111
|
+
assertHeld(): void;
|
|
42
112
|
/**
|
|
43
113
|
* Try to reserve one spawn slot. `observedSpawns` is what the transcript
|
|
44
114
|
* shows; the decision is made against observed + pending, under the lock.
|
|
@@ -46,19 +116,53 @@ export declare class ReservationStore {
|
|
|
46
116
|
* Returns the effective count that was evaluated, so the caller can report
|
|
47
117
|
* exactly why a spawn was denied.
|
|
48
118
|
*/
|
|
49
|
-
reserve(args:
|
|
50
|
-
sessionId: string;
|
|
51
|
-
toolUseId: string;
|
|
52
|
-
observedSpawns: number;
|
|
53
|
-
ceiling: number;
|
|
54
|
-
now?: number;
|
|
55
|
-
}): {
|
|
56
|
-
allowed: boolean;
|
|
57
|
-
effectiveSpawns: number;
|
|
58
|
-
pending: number;
|
|
59
|
-
};
|
|
119
|
+
reserve(args: ReserveArgs): ReserveResult;
|
|
60
120
|
/** Drop reservations the transcript has now accounted for. */
|
|
61
121
|
reconcile(sessionId: string, observedSpawns: number, previouslyObserved: number): void;
|
|
62
122
|
pendingFor(sessionId: string, now?: number): number;
|
|
123
|
+
reserveCall(args: ReserveCallArgs): ComputeSnapshot;
|
|
124
|
+
finishCall(callId: string, windowMs: number, now?: number): number | null;
|
|
125
|
+
/** Lock-free read for status. May be a few milliseconds stale; never used to decide. */
|
|
126
|
+
computeSnapshot(sessionId: string, windowMs: number, now?: number): ComputeSnapshot;
|
|
63
127
|
clear(): void;
|
|
64
128
|
}
|
|
129
|
+
export interface ReserveArgs {
|
|
130
|
+
sessionId: string;
|
|
131
|
+
toolUseId: string;
|
|
132
|
+
observedSpawns: number;
|
|
133
|
+
ceiling: number;
|
|
134
|
+
now?: number;
|
|
135
|
+
}
|
|
136
|
+
export interface ReserveResult {
|
|
137
|
+
allowed: boolean;
|
|
138
|
+
effectiveSpawns: number;
|
|
139
|
+
pending: number;
|
|
140
|
+
}
|
|
141
|
+
export interface ReserveCallArgs {
|
|
142
|
+
sessionId: string;
|
|
143
|
+
callId: string;
|
|
144
|
+
host: string;
|
|
145
|
+
estimatedTokens: number;
|
|
146
|
+
ttlMs: number;
|
|
147
|
+
windowMs: number;
|
|
148
|
+
now?: number;
|
|
149
|
+
}
|
|
150
|
+
/** Operations on the loaded reservation file while the lock is held. */
|
|
151
|
+
export declare class Transaction {
|
|
152
|
+
private readonly data;
|
|
153
|
+
dirty: boolean;
|
|
154
|
+
constructor(data: ReservationFile);
|
|
155
|
+
reserve(args: ReserveArgs): ReserveResult;
|
|
156
|
+
reconcile(sessionId: string, observedSpawns: number, previouslyObserved: number): void;
|
|
157
|
+
/**
|
|
158
|
+
* Open a model-call reservation and return the compute snapshot it was
|
|
159
|
+
* admitted against. Idempotent on callId: middleware and a proxy that both
|
|
160
|
+
* see the same call converge on one record. The caller decides the verdict;
|
|
161
|
+
* the store only guarantees the count is atomic across processes.
|
|
162
|
+
*/
|
|
163
|
+
reserveCall(args: ReserveCallArgs): ComputeSnapshot;
|
|
164
|
+
/** Mark a call finished. Returns its duration, or null if unknown to us. */
|
|
165
|
+
finishCall(callId: string, windowMs: number, now?: number): number | null;
|
|
166
|
+
computeSnapshot(sessionId: string, windowMs: number, now?: number): ComputeSnapshot;
|
|
167
|
+
}
|
|
168
|
+
export {};
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
* account do not share it, and that is stated rather than papered over.
|
|
24
24
|
*/
|
|
25
25
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
26
|
-
exports.ReservationStore = exports.RESERVATION_TTL_MS = void 0;
|
|
26
|
+
exports.Transaction = exports.ReservationStore = exports.RESERVATION_TTL_MS = void 0;
|
|
27
27
|
const node_fs_1 = require("node:fs");
|
|
28
28
|
const node_path_1 = require("node:path");
|
|
29
29
|
const LOCK_STALE_MS = 10_000;
|
|
@@ -42,10 +42,24 @@ function pidAlive(pid) {
|
|
|
42
42
|
function sleepSync(ms) {
|
|
43
43
|
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
44
44
|
}
|
|
45
|
+
/**
|
|
46
|
+
* Lock instances are identified by nonce, not by path. This is what makes
|
|
47
|
+
* the lock survive contention from hundreds of processes:
|
|
48
|
+
*
|
|
49
|
+
* A waiter that reads the owner record, then gets descheduled, then judges
|
|
50
|
+
* "owner is dead" is telling the truth about an instance that has since
|
|
51
|
+
* been released and replaced. Under 240 concurrent hook processes that
|
|
52
|
+
* exact stall happened, the waiter tore down a live sibling's lock, two
|
|
53
|
+
* processes ran the transaction at once, and 43 spawns were admitted
|
|
54
|
+
* against a cap of 40. Every teardown below is therefore checked against
|
|
55
|
+
* the nonce it was judged on, and every write is fenced on the holder's own
|
|
56
|
+
* nonce still being on the path.
|
|
57
|
+
*/
|
|
45
58
|
class ReservationStore {
|
|
46
59
|
home;
|
|
47
60
|
lockDir;
|
|
48
61
|
file;
|
|
62
|
+
held = null;
|
|
49
63
|
constructor(home) {
|
|
50
64
|
this.home = home;
|
|
51
65
|
(0, node_fs_1.mkdirSync)(home, { recursive: true, mode: 0o700 });
|
|
@@ -58,7 +72,15 @@ class ReservationStore {
|
|
|
58
72
|
for (;;) {
|
|
59
73
|
try {
|
|
60
74
|
(0, node_fs_1.mkdirSync)(this.lockDir, { mode: 0o700 });
|
|
61
|
-
|
|
75
|
+
// The owner record must appear whole or not at all. A sibling that
|
|
76
|
+
// read a half-written timestamp once parsed it as "held since 1970",
|
|
77
|
+
// reclaimed a live lock, and admitted a 41st spawn.
|
|
78
|
+
const owner = { pid: process.pid, since: Date.now(), nonce: `${process.pid}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}` };
|
|
79
|
+
const tmp = (0, node_path_1.join)(this.lockDir, `owner.${process.pid}`);
|
|
80
|
+
(0, node_fs_1.writeFileSync)(tmp, JSON.stringify(owner), { mode: 0o600 });
|
|
81
|
+
(0, node_fs_1.renameSync)(tmp, (0, node_path_1.join)(this.lockDir, 'owner'));
|
|
82
|
+
this.held = owner;
|
|
83
|
+
this.trace('acquired');
|
|
62
84
|
return;
|
|
63
85
|
}
|
|
64
86
|
catch (error) {
|
|
@@ -72,34 +94,124 @@ class ReservationStore {
|
|
|
72
94
|
}
|
|
73
95
|
}
|
|
74
96
|
}
|
|
97
|
+
readOwnerAt(dir) {
|
|
98
|
+
try {
|
|
99
|
+
const parsed = JSON.parse((0, node_fs_1.readFileSync)((0, node_path_1.join)(dir, 'owner'), 'utf8'));
|
|
100
|
+
if (Number.isInteger(parsed.pid) && typeof parsed.since === 'number' && parsed.since > 1_000_000_000_000 && typeof parsed.nonce === 'string') {
|
|
101
|
+
return { pid: parsed.pid, since: parsed.since, nonce: parsed.nonce };
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
/* missing or unreadable */
|
|
106
|
+
}
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
/** The holder's own instance is still the one on the path. */
|
|
110
|
+
fence() {
|
|
111
|
+
const current = this.readOwnerAt(this.lockDir);
|
|
112
|
+
if (!this.held || !current || current.nonce !== this.held.nonce) {
|
|
113
|
+
throw new Error('AgentGuard lost the reservation lock mid-transaction; failing closed.');
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Take the lock directory off its path atomically, then verify it is the
|
|
118
|
+
* instance we meant. rmSync on the live path is readdir + unlink + rmdir,
|
|
119
|
+
* and a sibling can mkdir the same path between those steps, so removal
|
|
120
|
+
* is always rename-then-delete. If the instance we grabbed is not the one
|
|
121
|
+
* we judged (`expect`), it is a live sibling's: put it back.
|
|
122
|
+
*/
|
|
123
|
+
discard(reason, expect) {
|
|
124
|
+
const quarantine = `${this.lockDir}.${reason}.${process.pid}.${Date.now().toString(36)}`;
|
|
125
|
+
try {
|
|
126
|
+
(0, node_fs_1.renameSync)(this.lockDir, quarantine);
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
return false; // somebody else already took it off the path
|
|
130
|
+
}
|
|
131
|
+
const got = this.readOwnerAt(quarantine);
|
|
132
|
+
if ((got?.nonce ?? null) !== expect) {
|
|
133
|
+
// Not the instance we judged. Give it back; if a waiter slipped into
|
|
134
|
+
// the freed path in between, the displaced holder's fence throws and
|
|
135
|
+
// its transaction is discarded, so nothing double-commits.
|
|
136
|
+
try {
|
|
137
|
+
(0, node_fs_1.renameSync)(quarantine, this.lockDir);
|
|
138
|
+
this.trace(`discard ${reason}: wrong instance, returned`);
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
this.trace(`discard ${reason}: wrong instance, could not return`);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
try {
|
|
146
|
+
(0, node_fs_1.rmSync)(quarantine, { recursive: true, force: true });
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
/* late write by the old holder; unique name, harmless */
|
|
150
|
+
}
|
|
151
|
+
this.trace(`discard ${reason} ok`);
|
|
152
|
+
return true;
|
|
153
|
+
}
|
|
154
|
+
trace(line) {
|
|
155
|
+
if (!process.env.AGENTGUARD_DEBUG_LOCK)
|
|
156
|
+
return;
|
|
157
|
+
try {
|
|
158
|
+
(0, node_fs_1.appendFileSync)((0, node_path_1.join)(this.home, 'lock.log'), `${Date.now()} ${process.pid} ${line}\n`);
|
|
159
|
+
}
|
|
160
|
+
catch {
|
|
161
|
+
/* debug only */
|
|
162
|
+
}
|
|
163
|
+
}
|
|
75
164
|
recoverIfStale() {
|
|
165
|
+
const owner = this.readOwnerAt(this.lockDir);
|
|
166
|
+
if (owner) {
|
|
167
|
+
const stale = !pidAlive(owner.pid) || Date.now() - owner.since > LOCK_STALE_MS;
|
|
168
|
+
if (stale) {
|
|
169
|
+
this.trace(`reclaim: owner ${owner.pid} alive=${pidAlive(owner.pid)} age=${Date.now() - owner.since}ms`);
|
|
170
|
+
this.discard('stale', owner.nonce);
|
|
171
|
+
}
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
// No trustworthy owner record: the holder is between mkdir and rename, or
|
|
175
|
+
// it died in that gap. Only the directory's age can tell those apart.
|
|
176
|
+
// Reclaiming immediately here was the race that let a 41st spawn through
|
|
177
|
+
// under 60 concurrent processes.
|
|
76
178
|
try {
|
|
77
|
-
const
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
(0, node_fs_1.rmSync)(this.lockDir, { recursive: true, force: true });
|
|
179
|
+
const age = Date.now() - (0, node_fs_1.statSync)(this.lockDir).mtimeMs;
|
|
180
|
+
if (age > LOCK_STALE_MS) {
|
|
181
|
+
this.trace(`reclaim ownerless dir age=${age}ms`);
|
|
182
|
+
this.discard('orphan', null);
|
|
183
|
+
}
|
|
83
184
|
}
|
|
84
185
|
catch {
|
|
85
|
-
|
|
86
|
-
// directory is older than the stale window, reclaim it.
|
|
87
|
-
(0, node_fs_1.rmSync)(this.lockDir, { recursive: true, force: true });
|
|
186
|
+
/* directory vanished between checks: the next mkdir attempt decides */
|
|
88
187
|
}
|
|
89
188
|
}
|
|
90
189
|
release() {
|
|
91
|
-
|
|
190
|
+
// Only our own instance is released. If it was reclaimed and a sibling
|
|
191
|
+
// now holds the path, discard() sees the nonce mismatch and returns it.
|
|
192
|
+
// Release never throws: a crash here is how a lock goes ownerless.
|
|
193
|
+
const mine = this.held;
|
|
194
|
+
this.held = null;
|
|
195
|
+
if (!mine)
|
|
196
|
+
return;
|
|
197
|
+
try {
|
|
198
|
+
this.discard('released', mine.nonce);
|
|
199
|
+
}
|
|
200
|
+
catch (error) {
|
|
201
|
+
this.trace(`release threw ${error.message}`);
|
|
202
|
+
}
|
|
92
203
|
}
|
|
93
204
|
load() {
|
|
94
205
|
try {
|
|
95
206
|
const parsed = JSON.parse((0, node_fs_1.readFileSync)(this.file, 'utf8'));
|
|
96
|
-
if (parsed && parsed.version === 1 && Array.isArray(parsed.reservations))
|
|
97
|
-
return parsed;
|
|
207
|
+
if (parsed && (parsed.version === 1 || parsed.version === 2) && Array.isArray(parsed.reservations)) {
|
|
208
|
+
return { version: 2, reservations: parsed.reservations, calls: Array.isArray(parsed.calls) ? parsed.calls : [] };
|
|
209
|
+
}
|
|
98
210
|
}
|
|
99
211
|
catch {
|
|
100
212
|
/* corrupt or missing: start clean, never trust partial state */
|
|
101
213
|
}
|
|
102
|
-
return { version:
|
|
214
|
+
return { version: 2, reservations: [], calls: [] };
|
|
103
215
|
}
|
|
104
216
|
save(data) {
|
|
105
217
|
const tmp = `${this.file}.${process.pid}.tmp`;
|
|
@@ -107,70 +219,192 @@ class ReservationStore {
|
|
|
107
219
|
(0, node_fs_1.renameSync)(tmp, this.file);
|
|
108
220
|
}
|
|
109
221
|
/**
|
|
110
|
-
*
|
|
111
|
-
*
|
|
222
|
+
* Run `fn` with the machine-wide lock held. Everything inside sees one
|
|
223
|
+
* consistent reservation file and writes it back once. The gateway uses
|
|
224
|
+
* this to make "fold state, evaluate, reserve, sign" a single transaction,
|
|
225
|
+
* so two hosts racing the same session cannot interleave halfway.
|
|
112
226
|
*
|
|
113
|
-
*
|
|
114
|
-
* exactly why a spawn was denied.
|
|
227
|
+
* Throws if the lock cannot be taken. Callers must fail closed on throw.
|
|
115
228
|
*/
|
|
116
|
-
|
|
117
|
-
const now = args.now ?? Date.now();
|
|
229
|
+
withLock(fn) {
|
|
118
230
|
this.acquire();
|
|
119
231
|
try {
|
|
120
232
|
const data = this.load();
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
if (
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
if (effective > args.ceiling) {
|
|
130
|
-
return { allowed: false, effectiveSpawns: effective, pending: pendingForSession };
|
|
131
|
-
}
|
|
132
|
-
data.reservations.push({
|
|
133
|
-
sessionId: args.sessionId,
|
|
134
|
-
toolUseId: args.toolUseId,
|
|
135
|
-
at: now,
|
|
136
|
-
expiresAt: now + exports.RESERVATION_TTL_MS,
|
|
137
|
-
});
|
|
138
|
-
this.save(data);
|
|
139
|
-
return { allowed: true, effectiveSpawns: effective, pending: pendingForSession };
|
|
233
|
+
const tx = new Transaction(data);
|
|
234
|
+
const result = fn(tx);
|
|
235
|
+
// Fence before anything is written: if our instance was reclaimed
|
|
236
|
+
// while fn ran, the whole transaction is discarded, not half-applied.
|
|
237
|
+
this.fence();
|
|
238
|
+
if (tx.dirty)
|
|
239
|
+
this.save(data);
|
|
240
|
+
return result;
|
|
140
241
|
}
|
|
141
242
|
finally {
|
|
142
243
|
this.release();
|
|
143
244
|
}
|
|
144
245
|
}
|
|
246
|
+
/**
|
|
247
|
+
* Callers that write their own files inside a transaction (the gateway's
|
|
248
|
+
* session file) call this right before writing, for the same reason.
|
|
249
|
+
*/
|
|
250
|
+
assertHeld() {
|
|
251
|
+
this.fence();
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* Try to reserve one spawn slot. `observedSpawns` is what the transcript
|
|
255
|
+
* shows; the decision is made against observed + pending, under the lock.
|
|
256
|
+
*
|
|
257
|
+
* Returns the effective count that was evaluated, so the caller can report
|
|
258
|
+
* exactly why a spawn was denied.
|
|
259
|
+
*/
|
|
260
|
+
reserve(args) {
|
|
261
|
+
return this.withLock((tx) => tx.reserve(args));
|
|
262
|
+
}
|
|
145
263
|
/** Drop reservations the transcript has now accounted for. */
|
|
146
264
|
reconcile(sessionId, observedSpawns, previouslyObserved) {
|
|
147
|
-
|
|
148
|
-
if (settled === 0)
|
|
265
|
+
if (Math.max(0, observedSpawns - previouslyObserved) === 0)
|
|
149
266
|
return;
|
|
150
|
-
this.
|
|
151
|
-
try {
|
|
152
|
-
const data = this.load();
|
|
153
|
-
let remaining = settled;
|
|
154
|
-
data.reservations = data.reservations.filter((r) => {
|
|
155
|
-
if (r.sessionId === sessionId && remaining > 0) {
|
|
156
|
-
remaining -= 1;
|
|
157
|
-
return false;
|
|
158
|
-
}
|
|
159
|
-
return true;
|
|
160
|
-
});
|
|
161
|
-
this.save(data);
|
|
162
|
-
}
|
|
163
|
-
finally {
|
|
164
|
-
this.release();
|
|
165
|
-
}
|
|
267
|
+
this.withLock((tx) => tx.reconcile(sessionId, observedSpawns, previouslyObserved));
|
|
166
268
|
}
|
|
167
269
|
pendingFor(sessionId, now = Date.now()) {
|
|
168
270
|
return this.load().reservations.filter((r) => r.sessionId === sessionId && r.expiresAt > now).length;
|
|
169
271
|
}
|
|
272
|
+
reserveCall(args) {
|
|
273
|
+
return this.withLock((tx) => tx.reserveCall(args));
|
|
274
|
+
}
|
|
275
|
+
finishCall(callId, windowMs, now = Date.now()) {
|
|
276
|
+
return this.withLock((tx) => tx.finishCall(callId, windowMs, now));
|
|
277
|
+
}
|
|
278
|
+
/** Lock-free read for status. May be a few milliseconds stale; never used to decide. */
|
|
279
|
+
computeSnapshot(sessionId, windowMs, now = Date.now()) {
|
|
280
|
+
return computeSnapshot(pruneCalls(this.load().calls ?? [], now, windowMs), sessionId, now, windowMs);
|
|
281
|
+
}
|
|
170
282
|
clear() {
|
|
171
283
|
if ((0, node_fs_1.existsSync)(this.file))
|
|
172
284
|
(0, node_fs_1.rmSync)(this.file, { force: true });
|
|
173
|
-
(
|
|
285
|
+
this.discard('cleared', this.readOwnerAt(this.lockDir)?.nonce ?? null);
|
|
174
286
|
}
|
|
175
287
|
}
|
|
176
288
|
exports.ReservationStore = ReservationStore;
|
|
289
|
+
/** Operations on the loaded reservation file while the lock is held. */
|
|
290
|
+
class Transaction {
|
|
291
|
+
data;
|
|
292
|
+
dirty = false;
|
|
293
|
+
constructor(data) {
|
|
294
|
+
this.data = data;
|
|
295
|
+
if (!this.data.calls)
|
|
296
|
+
this.data.calls = [];
|
|
297
|
+
}
|
|
298
|
+
reserve(args) {
|
|
299
|
+
const now = args.now ?? Date.now();
|
|
300
|
+
const data = this.data;
|
|
301
|
+
const before = data.reservations.length;
|
|
302
|
+
data.reservations = data.reservations.filter((r) => r.expiresAt > now);
|
|
303
|
+
if (data.reservations.length !== before)
|
|
304
|
+
this.dirty = true;
|
|
305
|
+
// Idempotent: the same tool_use_id evaluated twice must not double-count.
|
|
306
|
+
const existing = data.reservations.find((r) => r.toolUseId === args.toolUseId);
|
|
307
|
+
const pendingForSession = data.reservations.filter((r) => r.sessionId === args.sessionId && r.toolUseId !== args.toolUseId).length;
|
|
308
|
+
const effective = args.observedSpawns + pendingForSession + 1;
|
|
309
|
+
if (existing) {
|
|
310
|
+
return { allowed: true, effectiveSpawns: effective, pending: pendingForSession };
|
|
311
|
+
}
|
|
312
|
+
if (effective > args.ceiling) {
|
|
313
|
+
return { allowed: false, effectiveSpawns: effective, pending: pendingForSession };
|
|
314
|
+
}
|
|
315
|
+
data.reservations.push({
|
|
316
|
+
sessionId: args.sessionId,
|
|
317
|
+
toolUseId: args.toolUseId,
|
|
318
|
+
at: now,
|
|
319
|
+
expiresAt: now + exports.RESERVATION_TTL_MS,
|
|
320
|
+
});
|
|
321
|
+
this.dirty = true;
|
|
322
|
+
return { allowed: true, effectiveSpawns: effective, pending: pendingForSession };
|
|
323
|
+
}
|
|
324
|
+
reconcile(sessionId, observedSpawns, previouslyObserved) {
|
|
325
|
+
let remaining = Math.max(0, observedSpawns - previouslyObserved);
|
|
326
|
+
if (remaining === 0)
|
|
327
|
+
return;
|
|
328
|
+
const before = this.data.reservations.length;
|
|
329
|
+
this.data.reservations = this.data.reservations.filter((r) => {
|
|
330
|
+
if (r.sessionId === sessionId && remaining > 0) {
|
|
331
|
+
remaining -= 1;
|
|
332
|
+
return false;
|
|
333
|
+
}
|
|
334
|
+
return true;
|
|
335
|
+
});
|
|
336
|
+
if (this.data.reservations.length !== before)
|
|
337
|
+
this.dirty = true;
|
|
338
|
+
}
|
|
339
|
+
/**
|
|
340
|
+
* Open a model-call reservation and return the compute snapshot it was
|
|
341
|
+
* admitted against. Idempotent on callId: middleware and a proxy that both
|
|
342
|
+
* see the same call converge on one record. The caller decides the verdict;
|
|
343
|
+
* the store only guarantees the count is atomic across processes.
|
|
344
|
+
*/
|
|
345
|
+
reserveCall(args) {
|
|
346
|
+
const now = args.now ?? Date.now();
|
|
347
|
+
const calls = pruneCalls(this.data.calls, now, args.windowMs);
|
|
348
|
+
if (calls.length !== this.data.calls.length)
|
|
349
|
+
this.dirty = true;
|
|
350
|
+
this.data.calls = calls;
|
|
351
|
+
if (!calls.some((c) => c.callId === args.callId)) {
|
|
352
|
+
calls.push({
|
|
353
|
+
sessionId: args.sessionId,
|
|
354
|
+
callId: args.callId,
|
|
355
|
+
host: args.host,
|
|
356
|
+
estimatedTokens: Math.max(0, args.estimatedTokens),
|
|
357
|
+
startedAt: now,
|
|
358
|
+
finishedAt: null,
|
|
359
|
+
expiresAt: now + args.ttlMs,
|
|
360
|
+
});
|
|
361
|
+
this.dirty = true;
|
|
362
|
+
}
|
|
363
|
+
return computeSnapshot(calls, args.sessionId, now, args.windowMs);
|
|
364
|
+
}
|
|
365
|
+
/** Mark a call finished. Returns its duration, or null if unknown to us. */
|
|
366
|
+
finishCall(callId, windowMs, now = Date.now()) {
|
|
367
|
+
const call = this.data.calls.find((c) => c.callId === callId);
|
|
368
|
+
if (!call)
|
|
369
|
+
return null;
|
|
370
|
+
if (call.finishedAt === null) {
|
|
371
|
+
call.finishedAt = now;
|
|
372
|
+
call.estimatedTokens = 0;
|
|
373
|
+
// Keep it exactly as long as the occupied-time window can see it.
|
|
374
|
+
call.expiresAt = now + windowMs;
|
|
375
|
+
this.dirty = true;
|
|
376
|
+
}
|
|
377
|
+
return call.finishedAt - call.startedAt;
|
|
378
|
+
}
|
|
379
|
+
computeSnapshot(sessionId, windowMs, now = Date.now()) {
|
|
380
|
+
return computeSnapshot(pruneCalls(this.data.calls, now, windowMs), sessionId, now, windowMs);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
exports.Transaction = Transaction;
|
|
384
|
+
function pruneCalls(calls, now, windowMs) {
|
|
385
|
+
const horizon = now - windowMs;
|
|
386
|
+
return calls.filter((c) => {
|
|
387
|
+
if (c.finishedAt === null)
|
|
388
|
+
return c.expiresAt > now;
|
|
389
|
+
return c.finishedAt > horizon;
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
function computeSnapshot(calls, sessionId, now, windowMs) {
|
|
393
|
+
const horizon = now - windowMs;
|
|
394
|
+
let inFlight = 0;
|
|
395
|
+
let inFlightForSession = 0;
|
|
396
|
+
let occupiedMs = 0;
|
|
397
|
+
let pendingEstimatedTokens = 0;
|
|
398
|
+
for (const c of calls) {
|
|
399
|
+
const end = c.finishedAt ?? now;
|
|
400
|
+
if (c.finishedAt === null) {
|
|
401
|
+
inFlight += 1;
|
|
402
|
+
if (c.sessionId === sessionId)
|
|
403
|
+
inFlightForSession += 1;
|
|
404
|
+
pendingEstimatedTokens += c.estimatedTokens;
|
|
405
|
+
}
|
|
406
|
+
// Only the part of the request inside the window counts.
|
|
407
|
+
occupiedMs += Math.max(0, end - Math.max(c.startedAt, horizon));
|
|
408
|
+
}
|
|
409
|
+
return { inFlight, inFlightForSession, occupiedMs, pendingEstimatedTokens, windowMs };
|
|
410
|
+
}
|
|
@@ -12,6 +12,12 @@ import type { BurnEvent, SessionState } from '../types';
|
|
|
12
12
|
export declare function newSessionState(sessionId: string, firstEventAt: number): SessionState;
|
|
13
13
|
/** Apply one event. Returns the active minutes that elapsed for debt accounting. */
|
|
14
14
|
export declare function applyEvent(state: SessionState, event: BurnEvent): number;
|
|
15
|
+
/**
|
|
16
|
+
* Correct a count already applied, in either direction, without touching
|
|
17
|
+
* active time. Used when a real usage figure replaces an estimate that was
|
|
18
|
+
* reserved under the same call ID. Buckets never go below zero.
|
|
19
|
+
*/
|
|
20
|
+
export declare function applyCorrection(state: SessionState, deltaTokens: number, deltaCacheRead: number): void;
|
|
15
21
|
/** Sum of a rolling window over the last N active minutes. */
|
|
16
22
|
export declare function windowSum(byMinute: Map<number, number>, activeMinutes: number, windowMinutes: number): number;
|
|
17
23
|
/** Median of completed, nonzero active minutes. The current partial minute is excluded. */
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
13
13
|
exports.newSessionState = newSessionState;
|
|
14
14
|
exports.applyEvent = applyEvent;
|
|
15
|
+
exports.applyCorrection = applyCorrection;
|
|
15
16
|
exports.windowSum = windowSum;
|
|
16
17
|
exports.medianCompletedMinute = medianCompletedMinute;
|
|
17
18
|
exports.cacheReadRatio = cacheReadRatio;
|
|
@@ -62,6 +63,22 @@ function applyEvent(state, event) {
|
|
|
62
63
|
}
|
|
63
64
|
return activeGapMinutes;
|
|
64
65
|
}
|
|
66
|
+
/**
|
|
67
|
+
* Correct a count already applied, in either direction, without touching
|
|
68
|
+
* active time. Used when a real usage figure replaces an estimate that was
|
|
69
|
+
* reserved under the same call ID. Buckets never go below zero.
|
|
70
|
+
*/
|
|
71
|
+
function applyCorrection(state, deltaTokens, deltaCacheRead) {
|
|
72
|
+
state.totalTokens = Math.max(0, state.totalTokens + deltaTokens);
|
|
73
|
+
state.totalCacheRead = Math.max(0, state.totalCacheRead + deltaCacheRead);
|
|
74
|
+
const bucket = Math.floor(state.activeMinutes);
|
|
75
|
+
const current = state.tokensByActiveMinute.get(bucket) ?? 0;
|
|
76
|
+
const next = Math.max(0, current + deltaTokens);
|
|
77
|
+
if (next > 0)
|
|
78
|
+
state.tokensByActiveMinute.set(bucket, next);
|
|
79
|
+
else
|
|
80
|
+
state.tokensByActiveMinute.delete(bucket);
|
|
81
|
+
}
|
|
65
82
|
/** Sum of a rolling window over the last N active minutes. */
|
|
66
83
|
function windowSum(byMinute, activeMinutes, windowMinutes) {
|
|
67
84
|
const from = Math.max(0, Math.floor(activeMinutes) - windowMinutes);
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One status for the whole machine.
|
|
3
|
+
*
|
|
4
|
+
* Every host's sessions in one table, with coverage stated per plane, so an
|
|
5
|
+
* OK never masquerades as full visibility. A Cursor session shows
|
|
6
|
+
* `usage:n/a`; a proxy session shows `spawns:n/a`; a session fed by both
|
|
7
|
+
* middleware and the proxy shows full coverage, because it has it.
|
|
8
|
+
*/
|
|
9
|
+
import type { GatewaySessionView } from './gateway';
|
|
10
|
+
import type { HostHealth } from './install';
|
|
11
|
+
import type { ComputeSnapshot } from './state/reservations';
|
|
12
|
+
/**
|
|
13
|
+
* Claude Code sessions live in the hook's own files (they carry a transcript
|
|
14
|
+
* cursor the gateway sessions do not). Read them into the same view so the
|
|
15
|
+
* table shows every host, not just the new ones.
|
|
16
|
+
*/
|
|
17
|
+
export declare function readHookSessions(home: string): GatewaySessionView[];
|
|
18
|
+
export declare function renderHostHealth(hosts: HostHealth[]): string;
|
|
19
|
+
export declare function renderMachineStatus(sessions: GatewaySessionView[], compute: ComputeSnapshot, now?: number): string;
|