@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
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
/**
|
|
3
|
-
* Terminal rendering
|
|
4
|
-
*
|
|
5
|
-
*
|
|
3
|
+
* Terminal rendering. This output is the distribution artifact, so it is
|
|
4
|
+
* designed to be screenshotted: one giant number, a curve you can see the
|
|
5
|
+
* runaway in, one line worth quoting. Pure ASCII/ANSI, degrades cleanly.
|
|
6
6
|
*/
|
|
7
7
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
8
|
+
exports.sparkline = sparkline;
|
|
9
|
+
exports.comparison = comparison;
|
|
8
10
|
exports.renderReplay = renderReplay;
|
|
11
|
+
exports.renderStop = renderStop;
|
|
9
12
|
exports.renderSessionRow = renderSessionRow;
|
|
10
13
|
const evaluate_1 = require("../detectors/evaluate");
|
|
11
14
|
const C = {
|
|
@@ -16,74 +19,202 @@ const C = {
|
|
|
16
19
|
yellow: '\x1b[33m',
|
|
17
20
|
green: '\x1b[32m',
|
|
18
21
|
cyan: '\x1b[36m',
|
|
22
|
+
magenta: '\x1b[35m',
|
|
23
|
+
bgRed: '\x1b[41m',
|
|
24
|
+
white: '\x1b[97m',
|
|
19
25
|
};
|
|
20
|
-
|
|
21
|
-
|
|
26
|
+
const W = 72;
|
|
27
|
+
function paint(on, code, text) {
|
|
28
|
+
return on ? `${code}${text}${C.reset}` : text;
|
|
22
29
|
}
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
30
|
+
function strip(text) {
|
|
31
|
+
return text.replace(/\x1b\[[0-9;]*m/g, '');
|
|
32
|
+
}
|
|
33
|
+
function row(text = '') {
|
|
34
|
+
const pad = Math.max(0, W - 2 - strip(text).length);
|
|
27
35
|
return `│ ${text}${' '.repeat(pad)} │`;
|
|
28
36
|
}
|
|
29
37
|
const TOP = `╭${'─'.repeat(W)}╮`;
|
|
30
38
|
const MID = `├${'─'.repeat(W)}┤`;
|
|
31
39
|
const BOT = `╰${'─'.repeat(W)}╯`;
|
|
32
|
-
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
// Block digits. Five rows, three columns each, for 0-9 . % B K M and space.
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
const GLYPHS = {
|
|
44
|
+
'0': ['█▀▀█', '█ █', '█ █', '█ █', '▀▀▀▀'],
|
|
45
|
+
'1': [' ▀█', ' █', ' █', ' █', ' ▀'],
|
|
46
|
+
'2': ['▀▀▀█', ' █', '█▀▀▀', '█ ', '▀▀▀▀'],
|
|
47
|
+
'3': ['▀▀▀█', ' █', ' ▀▀█', ' █', '▀▀▀▀'],
|
|
48
|
+
'4': ['█ █', '█ █', '▀▀▀█', ' █', ' ▀'],
|
|
49
|
+
'5': ['█▀▀▀', '█ ', '▀▀▀█', ' █', '▀▀▀▀'],
|
|
50
|
+
'6': ['█▀▀▀', '█ ', '█▀▀█', '█ █', '▀▀▀▀'],
|
|
51
|
+
'7': ['▀▀▀█', ' █', ' █', ' █', ' ▀'],
|
|
52
|
+
'8': ['█▀▀█', '█ █', '█▀▀█', '█ █', '▀▀▀▀'],
|
|
53
|
+
'9': ['█▀▀█', '█ █', '▀▀▀█', ' █', '▀▀▀▀'],
|
|
54
|
+
'.': [' ', ' ', ' ', ' ', ' ▀ '],
|
|
55
|
+
'%': ['▀▀ █', ' █ ', ' █ ', ' █ ', '█ ▀▀'],
|
|
56
|
+
B: ['█▀▀▄', '█ █', '█▀▀▄', '█ █', '▀▀▀ '],
|
|
57
|
+
M: ['█▄ ▄█', '█ ▀ █', '█ █', '█ █', '▀ ▀'],
|
|
58
|
+
K: ['█ █', '█ █ ', '██ ', '█ █ ', '▀ ▀'],
|
|
59
|
+
' ': [' ', ' ', ' ', ' ', ' '],
|
|
60
|
+
};
|
|
61
|
+
function blockDigits(text) {
|
|
62
|
+
const rows = ['', '', '', '', ''];
|
|
63
|
+
for (const ch of text) {
|
|
64
|
+
const g = GLYPHS[ch] ?? GLYPHS[' '];
|
|
65
|
+
for (let r = 0; r < 5; r++)
|
|
66
|
+
rows[r] += g[r] + ' ';
|
|
67
|
+
}
|
|
68
|
+
return rows.map((r) => r.trimEnd());
|
|
69
|
+
}
|
|
70
|
+
// ---------------------------------------------------------------------------
|
|
71
|
+
// Sparkline with the STOP point marked.
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
const BARS = '▁▂▃▄▅▆▇█';
|
|
74
|
+
function sparkline(curve, stopAt, on) {
|
|
75
|
+
if (curve.length === 0)
|
|
76
|
+
return '';
|
|
77
|
+
let out = '';
|
|
78
|
+
for (let i = 0; i < curve.length; i++) {
|
|
79
|
+
const level = Math.min(BARS.length - 1, Math.max(0, Math.round(curve[i] * (BARS.length - 1))));
|
|
80
|
+
const bar = BARS[level];
|
|
81
|
+
if (stopAt !== null && i >= stopAt) {
|
|
82
|
+
// Everything after the stop is the tail enforcement would have cut.
|
|
83
|
+
out += paint(on, C.red, i === stopAt ? '┃' : bar);
|
|
84
|
+
}
|
|
85
|
+
else {
|
|
86
|
+
out += paint(on, C.green, bar);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return out;
|
|
90
|
+
}
|
|
91
|
+
// ---------------------------------------------------------------------------
|
|
92
|
+
// The quotable comparison. Tokens to something a human can picture.
|
|
93
|
+
// ---------------------------------------------------------------------------
|
|
94
|
+
const COMPARISONS = [
|
|
95
|
+
{ tokens: 7_000_000_000, label: 'all of English Wikipedia' },
|
|
96
|
+
{ tokens: 1_200_000_000, label: 'the entire Harry Potter series, 1,000 times' },
|
|
97
|
+
{ tokens: 200_000_000, label: 'the complete works of Shakespeare, 200 times' },
|
|
98
|
+
{ tokens: 40_000_000, label: 'the King James Bible, 50 times' },
|
|
99
|
+
{ tokens: 130_000, label: 'Slaughterhouse-Five' },
|
|
100
|
+
];
|
|
101
|
+
function comparison(tokens) {
|
|
102
|
+
for (const c of COMPARISONS) {
|
|
103
|
+
const times = tokens / c.tokens;
|
|
104
|
+
if (times >= 1) {
|
|
105
|
+
const n = times >= 10 ? Math.round(times) : Math.round(times * 10) / 10;
|
|
106
|
+
return `≈ ${c.label}${n > 1 ? `, ${n}×` : ''}`;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return '';
|
|
110
|
+
}
|
|
111
|
+
// ---------------------------------------------------------------------------
|
|
112
|
+
// Replay
|
|
113
|
+
// ---------------------------------------------------------------------------
|
|
33
114
|
function scenarioUsd(tokens, cacheShare) {
|
|
34
115
|
const cached = tokens * cacheShare;
|
|
35
116
|
return (cached * 0.3 + (tokens - cached) * 5) / 1e6;
|
|
36
117
|
}
|
|
37
118
|
function renderReplay(summary, opts = {}) {
|
|
38
119
|
const on = opts.colour ?? Boolean(process.stdout.isTTY);
|
|
39
|
-
const top = opts.top ??
|
|
120
|
+
const top = opts.top ?? 6;
|
|
40
121
|
const out = [];
|
|
41
|
-
const cacheShare = summary.
|
|
42
|
-
? summary.sessions.reduce((s, r) => s + r.cacheReadRatio * r.totalTokens, 0) /
|
|
122
|
+
const cacheShare = summary.totalTokens
|
|
123
|
+
? summary.sessions.reduce((s, r) => s + r.cacheReadRatio * r.totalTokens, 0) / summary.totalTokens
|
|
43
124
|
: 0;
|
|
125
|
+
const share = Math.round(summary.catchableShare * 100);
|
|
44
126
|
out.push(TOP);
|
|
45
|
-
out.push(
|
|
46
|
-
out.push(line(colour(on, C.dim, 'Your agent history, before enforcement was installed')));
|
|
127
|
+
out.push(row(paint(on, C.bold + C.magenta, 'AGENTGUARD') + ' ' + paint(on, C.dim, 'replay · your agent history, before enforcement')));
|
|
47
128
|
out.push(MID);
|
|
48
|
-
|
|
49
|
-
out.push(
|
|
129
|
+
// Hero: the one number. Rendered huge.
|
|
130
|
+
out.push(row(''));
|
|
131
|
+
for (const line of blockDigits(`${share}%`))
|
|
132
|
+
out.push(row(' ' + paint(on, C.bold + C.green, line)));
|
|
133
|
+
out.push(row(''));
|
|
134
|
+
out.push(row(' ' + paint(on, C.bold, 'of everything you burned came after a point it would have blocked')));
|
|
135
|
+
out.push(row(''));
|
|
136
|
+
// Three supporting numbers, card-style.
|
|
137
|
+
const cards = [
|
|
138
|
+
{ n: (0, evaluate_1.fmt)(summary.totalTokens), l: 'observed', c: C.white },
|
|
139
|
+
{ n: (0, evaluate_1.fmt)(summary.catchableTail), l: 'after STOP', c: C.red },
|
|
140
|
+
{ n: `$${Math.round(scenarioUsd(summary.catchableTail, cacheShare)).toLocaleString()}`, l: 'API-list, scenario', c: C.yellow },
|
|
141
|
+
];
|
|
142
|
+
const COL = 20;
|
|
143
|
+
out.push(row(' ' + cards.map((k) => paint(on, C.bold + k.c, k.n.padEnd(COL))).join('')));
|
|
144
|
+
out.push(row(' ' + cards.map((k) => paint(on, C.dim, k.l.padEnd(COL))).join('')));
|
|
145
|
+
const comp = comparison(summary.catchableTail);
|
|
146
|
+
if (comp)
|
|
147
|
+
out.push(row(' ' + paint(on, C.dim, comp)));
|
|
50
148
|
out.push(MID);
|
|
51
|
-
out.push(
|
|
52
|
-
|
|
53
|
-
const share = Math.round(summary.catchableShare * 100);
|
|
54
|
-
out.push(line(`${colour(on, C.bold, (0, evaluate_1.fmt)(summary.catchableTail))} tokens observed after the first STOP boundary`));
|
|
55
|
-
out.push(line(`${share}% of everything you burned ≈ $${Math.round(scenarioUsd(summary.catchableTail, cacheShare)).toLocaleString()} at API list, as a scenario`));
|
|
56
|
-
out.push(line(''));
|
|
57
|
-
out.push(line(colour(on, C.dim, 'Upper bound. It assumes you would not have overridden or restarted.')));
|
|
58
|
-
out.push(MID);
|
|
59
|
-
out.push(line(`${colour(on, C.red, `${summary.stops} STOP`)} ${colour(on, C.yellow, `${summary.warns} WARN`)} ${colour(on, C.green, `${summary.clean} clean`)}`));
|
|
149
|
+
out.push(row(`${paint(on, C.bold + C.red, `${summary.stops} STOP`)} ${paint(on, C.bold + C.yellow, `${summary.warns} WARN`)} ${paint(on, C.bold + C.green, `${summary.clean} clean`)}` +
|
|
150
|
+
paint(on, C.dim, ` across ${summary.sessions.length} sessions, ${summary.totalSpawns} spawns`)));
|
|
60
151
|
out.push(MID);
|
|
61
152
|
for (const s of summary.sessions.slice(0, top)) {
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
if (s.firstWarn)
|
|
71
|
-
out.push(line(` ${colour(on, C.yellow, 'WARN')} (${s.firstWarn.detector}) at ${(0, evaluate_1.fmt)(s.firstWarn.tokensAt)} · no stop`));
|
|
72
|
-
else
|
|
73
|
-
out.push(line(` ${colour(on, C.green, 'clean')}`));
|
|
74
|
-
}
|
|
75
|
-
if (s.catchableTail > 0) {
|
|
76
|
-
out.push(line(` observed tail after stop: ${colour(on, C.bold, (0, evaluate_1.fmt)(s.catchableTail))}`));
|
|
77
|
-
}
|
|
153
|
+
const tag = s.fanoutStop ? paint(on, C.red, 'FAN-OUT STOP') : s.sustainedStop ? paint(on, C.red, 'SUSTAINED STOP') : s.firstWarn ? paint(on, C.yellow, 'WARN') : paint(on, C.green, 'clean');
|
|
154
|
+
out.push(row(`${sparkline(s.curve, s.stopAtIndex, on)} ${tag}`));
|
|
155
|
+
const detail = s.fanoutStop
|
|
156
|
+
? `before spawn ${s.fanoutStop.atSpawn}, tail ${(0, evaluate_1.fmt)(s.catchableTail)}`
|
|
157
|
+
: s.sustainedStop
|
|
158
|
+
? `near ${(0, evaluate_1.fmt)(s.sustainedStop.tokensAtStop)}, tail ${(0, evaluate_1.fmt)(s.catchableTail)}`
|
|
159
|
+
: `${(0, evaluate_1.fmt)(s.totalTokens)} · ${s.spawns} spawns`;
|
|
160
|
+
out.push(row(paint(on, C.dim, `${s.sessionId.slice(0, 8)} ${(0, evaluate_1.fmt)(s.totalTokens).padStart(6)} · ${String(s.spawns).padStart(3)} spawns ${detail}`)));
|
|
78
161
|
}
|
|
79
162
|
if (summary.sessions.length > top) {
|
|
80
|
-
out.push(
|
|
163
|
+
out.push(row(paint(on, C.dim, `… ${summary.sessions.length - top} more, all clean`)));
|
|
81
164
|
}
|
|
82
165
|
out.push(MID);
|
|
83
|
-
out.push(
|
|
166
|
+
out.push(row(paint(on, C.dim, 'Upper bound; assumes no override or restart. Nothing left this machine.')));
|
|
167
|
+
out.push(row(paint(on, C.cyan, 'next agentguard-burn init') + paint(on, C.dim, ' shadow mode; blocks nothing until you say so')));
|
|
84
168
|
out.push(BOT);
|
|
85
169
|
return out.join('\n');
|
|
86
170
|
}
|
|
171
|
+
// ---------------------------------------------------------------------------
|
|
172
|
+
// The mid-session STOP box. What Claude shows the user when a spawn is denied.
|
|
173
|
+
// ---------------------------------------------------------------------------
|
|
174
|
+
function renderStop(report, opts = {}) {
|
|
175
|
+
const on = opts.colour ?? false;
|
|
176
|
+
const lead = report.findings.find((f) => f.verdict === 'STOP')?.summary ?? 'Fan-out ceiling reached.';
|
|
177
|
+
const w = 64;
|
|
178
|
+
const bar = '━'.repeat(w);
|
|
179
|
+
const title = opts.subject === 'call' ? ' model call blocked' : ' agent spawn blocked';
|
|
180
|
+
const lines = [];
|
|
181
|
+
lines.push(paint(on, C.red, `┏${bar}┓`));
|
|
182
|
+
lines.push(paint(on, C.red, '┃ ') + paint(on, C.bold + C.bgRed + C.white, ' AGENTGUARD STOP ') + paint(on, C.bold, title) + paint(on, C.red, ' '.repeat(w - 19 - title.length) + '┃'));
|
|
183
|
+
lines.push(paint(on, C.red, `┣${bar}┫`));
|
|
184
|
+
lines.push(paint(on, C.red, '┃ ') + lead.padEnd(w - 1).slice(0, w - 1) + paint(on, C.red, '┃'));
|
|
185
|
+
lines.push(paint(on, C.red, '┃ ') + paint(on, C.dim, `${(0, evaluate_1.fmt)(report.totals.tokens)} tokens · ${report.totals.spawns} spawns · depth ${report.totals.maxDepth}`.padEnd(w - 1)) + paint(on, C.red, '┃'));
|
|
186
|
+
lines.push(paint(on, C.red, `┣${bar}┫`));
|
|
187
|
+
lines.push(paint(on, C.red, '┃ ') + paint(on, C.bold, 'DO NOW'.padEnd(w - 1)) + paint(on, C.red, '┃'));
|
|
188
|
+
report.prescriptions.slice(0, 3).forEach((p, i) => {
|
|
189
|
+
const wrapped = wrap(`${i + 1}. ${p}`, w - 2, ' ');
|
|
190
|
+
for (const line of wrapped) {
|
|
191
|
+
lines.push(paint(on, C.red, '┃ ') + line.padEnd(w - 1) + paint(on, C.red, '┃'));
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
lines.push(paint(on, C.red, `┣${bar}┫`));
|
|
195
|
+
lines.push(paint(on, C.red, '┃ ') + paint(on, C.dim, 'override once: agentguard-burn resume --once --reason "..."'.padEnd(w - 1)) + paint(on, C.red, '┃'));
|
|
196
|
+
lines.push(paint(on, C.red, `┗${bar}┛`));
|
|
197
|
+
return lines.join('\n');
|
|
198
|
+
}
|
|
199
|
+
/** Word-aware wrap. Continuation lines are indented. */
|
|
200
|
+
function wrap(text, width, indent) {
|
|
201
|
+
const words = text.split(' ');
|
|
202
|
+
const lines = [];
|
|
203
|
+
let current = '';
|
|
204
|
+
for (const word of words) {
|
|
205
|
+
const prefix = lines.length ? indent : '';
|
|
206
|
+
if ((current + ' ' + word).trim().length + prefix.length > width && current) {
|
|
207
|
+
lines.push((lines.length ? indent : '') + current);
|
|
208
|
+
current = word;
|
|
209
|
+
}
|
|
210
|
+
else {
|
|
211
|
+
current = current ? `${current} ${word}` : word;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
if (current)
|
|
215
|
+
lines.push((lines.length ? indent : '') + current);
|
|
216
|
+
return lines;
|
|
217
|
+
}
|
|
87
218
|
function renderSessionRow(s) {
|
|
88
219
|
return `${s.sessionId.slice(0, 8)} ${(0, evaluate_1.fmt)(s.totalTokens).padStart(8)} ${String(s.spawns).padStart(4)} spawns ${s.finalVerdict}`;
|
|
89
220
|
}
|
|
@@ -37,6 +37,10 @@ export interface SessionReplay {
|
|
|
37
37
|
tokensAt: number;
|
|
38
38
|
spawnsAt: number;
|
|
39
39
|
} | null;
|
|
40
|
+
/** Cumulative tokens sampled over active time, for a sparkline. 0..1 normalised. */
|
|
41
|
+
curve: number[];
|
|
42
|
+
/** Index into curve where the earliest STOP fired, or null. */
|
|
43
|
+
stopAtIndex: number | null;
|
|
40
44
|
}
|
|
41
45
|
export declare function discoverTranscripts(root?: string): string[];
|
|
42
46
|
export declare function replaySession(path: string, thresholds: Thresholds): SessionReplay | null;
|
|
@@ -60,6 +60,9 @@ function replayEvents(sessionId, path, events, thresholds) {
|
|
|
60
60
|
let sustainedStop = null;
|
|
61
61
|
let firstWarn = null;
|
|
62
62
|
let tokensAtEarliestStop = null;
|
|
63
|
+
// Cumulative tokens after every event, for the sparkline.
|
|
64
|
+
const timeline = [];
|
|
65
|
+
let stopEventIndex = null;
|
|
63
66
|
for (const event of events) {
|
|
64
67
|
// A hook boundary exists only where a spawn was attempted. Evaluate the
|
|
65
68
|
// *proposal* before applying the event, then apply it.
|
|
@@ -80,6 +83,7 @@ function replayEvents(sessionId, path, events, thresholds) {
|
|
|
80
83
|
}
|
|
81
84
|
if ((fanoutStop || sustainedStop) && tokensAtEarliestStop === null) {
|
|
82
85
|
tokensAtEarliestStop = state.totalTokens;
|
|
86
|
+
stopEventIndex = timeline.length;
|
|
83
87
|
}
|
|
84
88
|
}
|
|
85
89
|
// The sustained plane also has a boundary at every tool call, not just
|
|
@@ -92,12 +96,16 @@ function replayEvents(sessionId, path, events, thresholds) {
|
|
|
92
96
|
const hit = report.findings.find((f) => f.verdict === 'STOP' && (f.detector === 'sustained_burn' || f.detector === 'burn_debt'));
|
|
93
97
|
if (hit) {
|
|
94
98
|
sustainedStop = { atSpawn: state.spawnCount, tokensAtStop: state.totalTokens };
|
|
95
|
-
if (tokensAtEarliestStop === null)
|
|
99
|
+
if (tokensAtEarliestStop === null) {
|
|
96
100
|
tokensAtEarliestStop = state.totalTokens;
|
|
101
|
+
stopEventIndex = timeline.length;
|
|
102
|
+
}
|
|
97
103
|
}
|
|
98
104
|
}
|
|
99
105
|
(0, session_1.applyEvent)(state, event);
|
|
106
|
+
timeline.push(state.totalTokens);
|
|
100
107
|
}
|
|
108
|
+
const { curve, stopAtIndex } = downsample(timeline, stopEventIndex, 32);
|
|
101
109
|
const final = (0, evaluate_1.evaluate)(state, thresholds, null);
|
|
102
110
|
return {
|
|
103
111
|
sessionId,
|
|
@@ -112,8 +120,23 @@ function replayEvents(sessionId, path, events, thresholds) {
|
|
|
112
120
|
sustainedStop,
|
|
113
121
|
catchableTail: tokensAtEarliestStop === null ? 0 : Math.max(0, state.totalTokens - tokensAtEarliestStop),
|
|
114
122
|
firstWarn,
|
|
123
|
+
curve,
|
|
124
|
+
stopAtIndex,
|
|
115
125
|
};
|
|
116
126
|
}
|
|
127
|
+
/** Compress a cumulative timeline into N normalised buckets, carrying the stop index across. */
|
|
128
|
+
function downsample(timeline, stopIndex, buckets) {
|
|
129
|
+
if (timeline.length === 0)
|
|
130
|
+
return { curve: [], stopAtIndex: null };
|
|
131
|
+
const max = timeline[timeline.length - 1] || 1;
|
|
132
|
+
const curve = [];
|
|
133
|
+
for (let b = 0; b < buckets; b++) {
|
|
134
|
+
const i = Math.min(timeline.length - 1, Math.floor(((b + 1) / buckets) * timeline.length) - 1);
|
|
135
|
+
curve.push(Math.max(0, timeline[Math.max(0, i)]) / max);
|
|
136
|
+
}
|
|
137
|
+
const stopAtIndex = stopIndex === null ? null : Math.min(buckets - 1, Math.floor((stopIndex / timeline.length) * buckets));
|
|
138
|
+
return { curve, stopAtIndex };
|
|
139
|
+
}
|
|
117
140
|
function replayAll(paths, thresholds, minTokens = 0) {
|
|
118
141
|
const sessions = [];
|
|
119
142
|
for (const path of paths) {
|
|
@@ -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 {};
|