@agentguard-run/burn 0.1.0 → 0.2.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/CHANGELOG.md +64 -0
- package/README.md +110 -1
- package/dist/src/adapters/codex.d.ts +48 -0
- package/dist/src/adapters/codex.js +194 -0
- package/dist/src/adapters/cursor.d.ts +35 -0
- package/dist/src/adapters/cursor.js +132 -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 +99 -10
- 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 +134 -0
- package/dist/src/gateway.js +522 -0
- package/dist/src/hook/pre-tool-use.js +5 -4
- package/dist/src/index.d.ts +15 -4
- package/dist/src/index.js +41 -1
- 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 +10 -3
- package/dist/src/replay/render.js +175 -44
- package/dist/src/replay/simulate.d.ts +4 -0
- package/dist/src/replay/simulate.js +24 -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 +11 -0
- package/dist/src/status.js +48 -0
- package/dist/src/types.d.ts +14 -1
- package/package.json +34 -7
|
@@ -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,11 @@
|
|
|
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 { ComputeSnapshot } from './state/reservations';
|
|
11
|
+
export declare function renderMachineStatus(sessions: GatewaySessionView[], compute: ComputeSnapshot, now?: number): string;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* One status for the whole machine.
|
|
4
|
+
*
|
|
5
|
+
* Every host's sessions in one table, with coverage stated per plane, so an
|
|
6
|
+
* OK never masquerades as full visibility. A Cursor session shows
|
|
7
|
+
* `usage:n/a`; a proxy session shows `spawns:n/a`; a session fed by both
|
|
8
|
+
* middleware and the proxy shows full coverage, because it has it.
|
|
9
|
+
*/
|
|
10
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
11
|
+
exports.renderMachineStatus = renderMachineStatus;
|
|
12
|
+
const evaluate_1 = require("./detectors/evaluate");
|
|
13
|
+
const LIVE_WINDOW_MS = 30 * 60 * 1000;
|
|
14
|
+
function renderMachineStatus(sessions, compute, now = Date.now()) {
|
|
15
|
+
const live = sessions.filter((s) => s.closedAt === null && now - s.state.lastEventAt <= LIVE_WINDOW_MS);
|
|
16
|
+
const totalTokens = live.reduce((n, s) => n + s.state.totalTokens, 0);
|
|
17
|
+
const lines = [];
|
|
18
|
+
lines.push(`cross-tool sessions: ${live.length} live · ${(0, evaluate_1.fmt)(totalTokens)} tokens · ${compute.inFlight} model call(s) in flight · ${Math.round(compute.occupiedMs / 1000)}s occupied in last ${Math.round(compute.windowMs / 60000)} min`);
|
|
19
|
+
if (live.length === 0) {
|
|
20
|
+
lines.push(' (none in the last 30 minutes; Claude Code sessions are tracked by the hook and listed by `replay`)');
|
|
21
|
+
return lines.join('\n');
|
|
22
|
+
}
|
|
23
|
+
lines.push(' ' + pad('HOST(S)', 22) + pad('SESSION', 20) + pad('TOKENS', 9) + pad('SPAWNS', 8) + pad('DEPTH', 7) + pad('LIVE', 6) + 'COVERAGE');
|
|
24
|
+
for (const s of live) {
|
|
25
|
+
lines.push(' ' +
|
|
26
|
+
pad(s.hosts.join('+'), 22) +
|
|
27
|
+
pad(shortId(s.sessionId), 20) +
|
|
28
|
+
pad((0, evaluate_1.fmt)(s.state.totalTokens), 9) +
|
|
29
|
+
pad(String(s.state.spawnCount), 8) +
|
|
30
|
+
pad(String(s.state.maxDepth), 7) +
|
|
31
|
+
pad(String(s.liveSpawns), 6) +
|
|
32
|
+
coverageLine(s));
|
|
33
|
+
}
|
|
34
|
+
return lines.join('\n');
|
|
35
|
+
}
|
|
36
|
+
function coverageLine(s) {
|
|
37
|
+
const usage = s.usage.missing > 0 ? `usage:partial(${s.usage.missing} missing)` : s.usage.authoritative > 0 ? 'usage:auth' : s.usage.estimated > 0 ? 'usage:est' : `usage:${short(s.capabilities.usage)}`;
|
|
38
|
+
return `spawns:${short(s.capabilities.spawns)} depth:${short(s.capabilities.depth)} ${usage}`;
|
|
39
|
+
}
|
|
40
|
+
function short(c) {
|
|
41
|
+
return c === 'authoritative' ? 'auth' : c === 'estimated' ? 'est' : 'n/a';
|
|
42
|
+
}
|
|
43
|
+
function pad(v, w) {
|
|
44
|
+
return v.length >= w ? `${v.slice(0, w - 1)} ` : v.padEnd(w, ' ');
|
|
45
|
+
}
|
|
46
|
+
function shortId(v) {
|
|
47
|
+
return v.length <= 18 ? v : `${v.slice(0, 8)}…${v.slice(-8)}`;
|
|
48
|
+
}
|
package/dist/src/types.d.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* competitors, so the policy layer has to sit above all of them.
|
|
9
9
|
*/
|
|
10
10
|
export type Verdict = 'OK' | 'WARN' | 'STOP';
|
|
11
|
-
export type Detector = 'fanout' | 'sustained_burn' | 'burn_debt' | 'spawn_rate' | 'account' | 'duplicate_work';
|
|
11
|
+
export type Detector = 'fanout' | 'sustained_burn' | 'burn_debt' | 'spawn_rate' | 'account' | 'duplicate_work' | 'local_compute';
|
|
12
12
|
/** One normalised observation from a host transcript. */
|
|
13
13
|
export interface BurnEvent {
|
|
14
14
|
/** Unix milliseconds. */
|
|
@@ -114,6 +114,19 @@ export interface Thresholds {
|
|
|
114
114
|
windowActiveMinutes: number;
|
|
115
115
|
warnConcurrentSessions: number;
|
|
116
116
|
};
|
|
117
|
+
/**
|
|
118
|
+
* Local inference plane (Ollama, vLLM, LM Studio). Token dollars are close
|
|
119
|
+
* to meaningless when the GPU is yours; what runs away is concurrency and
|
|
120
|
+
* occupied time. WARN-only by default: no universal STOP is honest for
|
|
121
|
+
* hardware we cannot see. Optional so pre-0.2 policy files stay valid.
|
|
122
|
+
*/
|
|
123
|
+
localCompute?: {
|
|
124
|
+
windowMs: number;
|
|
125
|
+
warnConcurrent: number;
|
|
126
|
+
stopConcurrent: number | null;
|
|
127
|
+
warnOccupiedMs: number | null;
|
|
128
|
+
stopOccupiedMs: number | null;
|
|
129
|
+
};
|
|
117
130
|
}
|
|
118
131
|
export type Mode = 'shadow' | 'enforce';
|
|
119
132
|
export interface Policy {
|
package/package.json
CHANGED
|
@@ -1,17 +1,41 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agentguard-run/burn",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Local runaway-agent circuit breaker for AI coding agents.
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Local runaway-agent circuit breaker for AI coding agents and local model runtimes. One policy across Claude Code, Cursor, Codex, Ollama, vLLM, LM Studio and raw orchestrators: detects fan-out storms and sustained token burn, blocks the next spawn, and proves what happened with content-free signed receipts. Nothing leaves the machine.",
|
|
5
5
|
"license": "SEE LICENSE IN LICENSE",
|
|
6
6
|
"type": "commonjs",
|
|
7
7
|
"main": "dist/src/index.js",
|
|
8
8
|
"types": "dist/src/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./dist/src/index.d.ts",
|
|
12
|
+
"default": "./dist/src/index.js"
|
|
13
|
+
},
|
|
14
|
+
"./middleware": {
|
|
15
|
+
"types": "./dist/src/adapters/raw-api.d.ts",
|
|
16
|
+
"default": "./dist/src/adapters/raw-api.js"
|
|
17
|
+
},
|
|
18
|
+
"./proxy": {
|
|
19
|
+
"types": "./dist/src/proxy/server.d.ts",
|
|
20
|
+
"default": "./dist/src/proxy/server.js"
|
|
21
|
+
},
|
|
22
|
+
"./cursor": {
|
|
23
|
+
"types": "./dist/src/adapters/cursor.d.ts",
|
|
24
|
+
"default": "./dist/src/adapters/cursor.js"
|
|
25
|
+
},
|
|
26
|
+
"./codex": {
|
|
27
|
+
"types": "./dist/src/adapters/codex.d.ts",
|
|
28
|
+
"default": "./dist/src/adapters/codex.js"
|
|
29
|
+
},
|
|
30
|
+
"./package.json": "./package.json"
|
|
31
|
+
},
|
|
9
32
|
"bin": {
|
|
10
33
|
"agentguard-burn": "dist/src/cli.js"
|
|
11
34
|
},
|
|
12
35
|
"files": [
|
|
13
36
|
"dist/src",
|
|
14
37
|
"README.md",
|
|
38
|
+
"CHANGELOG.md",
|
|
15
39
|
"LICENSE"
|
|
16
40
|
],
|
|
17
41
|
"engines": {
|
|
@@ -21,11 +45,10 @@
|
|
|
21
45
|
"build": "tsc -p tsconfig.json",
|
|
22
46
|
"test": "tsc -p tsconfig.json && node --test \"dist/tests/**/*.test.js\"",
|
|
23
47
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
24
|
-
"
|
|
25
|
-
|
|
26
|
-
"dependencies": {
|
|
27
|
-
"@noble/ed25519": "^3.0.0"
|
|
48
|
+
"conformance": "tsc -p tsconfig.json && node dist/src/cli.js conformance",
|
|
49
|
+
"replay": "node dist/src/cli.js replay"
|
|
28
50
|
},
|
|
51
|
+
"dependencies": {},
|
|
29
52
|
"devDependencies": {
|
|
30
53
|
"@types/node": "^22",
|
|
31
54
|
"typescript": "^5.0.0"
|
|
@@ -33,10 +56,14 @@
|
|
|
33
56
|
"keywords": [
|
|
34
57
|
"agentguard",
|
|
35
58
|
"claude-code",
|
|
59
|
+
"cursor",
|
|
60
|
+
"codex",
|
|
61
|
+
"ollama",
|
|
62
|
+
"vllm",
|
|
36
63
|
"agents",
|
|
37
64
|
"token-budget",
|
|
38
65
|
"circuit-breaker",
|
|
39
66
|
"runaway",
|
|
40
67
|
"fan-out"
|
|
41
68
|
]
|
|
42
|
-
}
|
|
69
|
+
}
|