@agentguard-run/burn 0.2.3 → 0.2.6
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 +30 -2
- package/README.md +105 -20
- package/dist/src/adapters/codex.js +11 -1
- package/dist/src/adapters/cursor.js +2 -2
- package/dist/src/calibrate.js +2 -3
- package/dist/src/cli.js +53 -15
- package/dist/src/conformance.d.ts +5 -2
- package/dist/src/conformance.js +30 -17
- package/dist/src/defaults.d.ts +7 -4
- package/dist/src/defaults.js +9 -6
- package/dist/src/detectors/evaluate.d.ts +4 -5
- package/dist/src/detectors/evaluate.js +13 -11
- package/dist/src/eligibility.d.ts +17 -0
- package/dist/src/eligibility.js +29 -0
- package/dist/src/gateway.d.ts +2 -0
- package/dist/src/gateway.js +4 -8
- package/dist/src/history/claude-transcript.d.ts +20 -2
- package/dist/src/history/claude-transcript.js +56 -15
- package/dist/src/hook/pre-tool-use.d.ts +13 -9
- package/dist/src/hook/pre-tool-use.js +63 -31
- package/dist/src/insights/attribution.d.ts +4 -0
- package/dist/src/insights/attribution.js +151 -0
- package/dist/src/insights/blocks.d.ts +61 -0
- package/dist/src/insights/blocks.js +243 -0
- package/dist/src/insights/live.d.ts +53 -0
- package/dist/src/insights/live.js +211 -0
- package/dist/src/insights/pace.d.ts +34 -0
- package/dist/src/insights/pace.js +54 -0
- package/dist/src/insights/pricing.d.ts +48 -0
- package/dist/src/insights/pricing.js +139 -0
- package/dist/src/insights/render.d.ts +8 -0
- package/dist/src/insights/render.js +126 -0
- package/dist/src/insights/sessions.d.ts +12 -0
- package/dist/src/insights/sessions.js +51 -0
- package/dist/src/insights/transcript.d.ts +14 -0
- package/dist/src/insights/transcript.js +505 -0
- package/dist/src/insights/types.d.ts +164 -0
- package/dist/src/insights/types.js +4 -0
- package/dist/src/install.js +14 -5
- package/dist/src/policy.d.ts +4 -0
- package/dist/src/policy.js +57 -0
- package/dist/src/replay/render.js +4 -2
- package/dist/src/replay/simulate.d.ts +5 -0
- package/dist/src/replay/simulate.js +23 -8
- package/dist/src/state/reservations.d.ts +7 -5
- package/dist/src/state/reservations.js +60 -45
- package/dist/src/state/spawn-window.d.ts +10 -0
- package/dist/src/state/spawn-window.js +25 -0
- package/dist/src/types.d.ts +8 -1
- package/docs/USAGE_AND_PRICING.md +132 -0
- package/fixtures/usage-dedup-session/subagents/agent-synthetic-first.jsonl +5 -0
- package/fixtures/usage-dedup-session/subagents/agent-synthetic-second.jsonl +4 -0
- package/fixtures/usage-dedup-session.jsonl +4 -0
- package/package.json +4 -3
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/** Content-free transcript usage. No prompt, output text, path, or tool input. */
|
|
2
|
+
export type InsightHost = 'claude' | 'codex' | 'unknown';
|
|
3
|
+
export type TokenCategory = 'input' | 'cacheCreation' | 'cacheRead' | 'output';
|
|
4
|
+
export type InsightBucket = 'instruction_stack' | 'history_resent' | 'reread_files' | 'subagent_fanout' | 'tool_output' | 'conversation' | 'full_prefix_rewrites' | 'output' | 'unattributed';
|
|
5
|
+
export declare const INSIGHT_BUCKETS: InsightBucket[];
|
|
6
|
+
export interface ExplicitTokenAttribution {
|
|
7
|
+
bucket: InsightBucket;
|
|
8
|
+
category: TokenCategory;
|
|
9
|
+
tokens: number;
|
|
10
|
+
}
|
|
11
|
+
/** Bytes divide a measured usage increment; they are never converted to tokens. */
|
|
12
|
+
export interface InsightInterval {
|
|
13
|
+
toolOutputBytes: number;
|
|
14
|
+
reReadBytes: number;
|
|
15
|
+
conversationBytes: number;
|
|
16
|
+
toolResults: number;
|
|
17
|
+
reReadResults?: number;
|
|
18
|
+
userMessages: number;
|
|
19
|
+
shared: boolean;
|
|
20
|
+
}
|
|
21
|
+
export interface InsightTurn {
|
|
22
|
+
id: string;
|
|
23
|
+
sessionId: string;
|
|
24
|
+
host: InsightHost;
|
|
25
|
+
subagent?: boolean;
|
|
26
|
+
at?: number;
|
|
27
|
+
model?: string;
|
|
28
|
+
/** Fresh input only. Codex cached input is subtracted from its inclusive input. */
|
|
29
|
+
inputTokens: number;
|
|
30
|
+
cacheWriteTokens: number;
|
|
31
|
+
cacheReadTokens: number;
|
|
32
|
+
outputTokens: number;
|
|
33
|
+
cacheWrite5mTokens?: number;
|
|
34
|
+
cacheWrite1hTokens?: number;
|
|
35
|
+
cacheTtlSeconds?: 300 | 3600;
|
|
36
|
+
cacheTtlSource?: 'usage_split' | 'explicit_metadata';
|
|
37
|
+
/** Actual input context, never the model's advertised maximum window. */
|
|
38
|
+
contextTokens: number;
|
|
39
|
+
signals: {
|
|
40
|
+
idleMs?: number;
|
|
41
|
+
afterCompaction: boolean;
|
|
42
|
+
firstSpawnedTurn: boolean;
|
|
43
|
+
prefixChanged?: boolean;
|
|
44
|
+
};
|
|
45
|
+
explicitAttribution: ExplicitTokenAttribution[];
|
|
46
|
+
uncertainties: string[];
|
|
47
|
+
/** Input events received since the previous distinct assistant response. */
|
|
48
|
+
interval?: InsightInterval;
|
|
49
|
+
}
|
|
50
|
+
export interface InsightDiagnostics {
|
|
51
|
+
lines: number;
|
|
52
|
+
malformedLines: number;
|
|
53
|
+
duplicateUsageRecords: number;
|
|
54
|
+
unsupportedUsageRecords: number;
|
|
55
|
+
inheritedUsageRecords?: number;
|
|
56
|
+
}
|
|
57
|
+
export interface InsightParserState {
|
|
58
|
+
host: InsightHost;
|
|
59
|
+
sessionId: string;
|
|
60
|
+
model?: string;
|
|
61
|
+
lastAt?: number;
|
|
62
|
+
compactionPending: boolean;
|
|
63
|
+
spawnedSession: boolean;
|
|
64
|
+
seen: Record<string, InsightTurn>;
|
|
65
|
+
/** Codex token_count reports both cumulative totals and last response usage. */
|
|
66
|
+
codexTotals?: {
|
|
67
|
+
input: number;
|
|
68
|
+
cached: number;
|
|
69
|
+
output: number;
|
|
70
|
+
};
|
|
71
|
+
prefixDigest?: string;
|
|
72
|
+
prefixChangedPending?: boolean;
|
|
73
|
+
recordsSeen?: number;
|
|
74
|
+
codexEpoch?: number;
|
|
75
|
+
observedCalls?: Record<string, {
|
|
76
|
+
kind: 'read' | 'spawn' | 'tool_result';
|
|
77
|
+
fileKey?: string;
|
|
78
|
+
}>;
|
|
79
|
+
sessionMetadataSeen?: boolean;
|
|
80
|
+
/** Host-recorded lineage identifiers only, never inherited prompt content. */
|
|
81
|
+
lineage?: {
|
|
82
|
+
sessionId?: string;
|
|
83
|
+
parentSessionId?: string;
|
|
84
|
+
forkContextRef?: boolean;
|
|
85
|
+
forked?: boolean;
|
|
86
|
+
};
|
|
87
|
+
inheritedBeforeOrdinal?: number;
|
|
88
|
+
forkHistoryBoundaryUnknown?: boolean;
|
|
89
|
+
inheritedTotalDigest?: string;
|
|
90
|
+
codexTurnIdDigest?: string;
|
|
91
|
+
forkThreadIdDigest?: string;
|
|
92
|
+
awaitingForkBoundary?: boolean;
|
|
93
|
+
intervalStateVersion?: 1;
|
|
94
|
+
pendingInterval?: InsightInterval;
|
|
95
|
+
inputEventsSeen?: Record<string, true>;
|
|
96
|
+
readPathsSeen?: Record<string, true>;
|
|
97
|
+
readToolCalls?: Record<string, {
|
|
98
|
+
fileKey: string;
|
|
99
|
+
repeated: boolean;
|
|
100
|
+
}>;
|
|
101
|
+
conversationEcho?: {
|
|
102
|
+
digest: string;
|
|
103
|
+
source: 'response' | 'event';
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
export interface InsightObservations {
|
|
107
|
+
readCalls: number;
|
|
108
|
+
repeatedReadCalls: number;
|
|
109
|
+
uniqueReadFiles: number;
|
|
110
|
+
spawnCalls: number;
|
|
111
|
+
toolResultRecords: number;
|
|
112
|
+
}
|
|
113
|
+
export interface InsightTranscript {
|
|
114
|
+
host: InsightHost;
|
|
115
|
+
sessionId: string;
|
|
116
|
+
turns: InsightTurn[];
|
|
117
|
+
/** New or amended turns, suitable for an incremental observer keyed by id. */
|
|
118
|
+
updatedTurns: InsightTurn[];
|
|
119
|
+
diagnostics: InsightDiagnostics;
|
|
120
|
+
state: InsightParserState;
|
|
121
|
+
observations: InsightObservations;
|
|
122
|
+
/** Zero-based line indexes in this parsed chunk, after fork ownership checks. */
|
|
123
|
+
acceptedQuotaLineNumbers?: number[];
|
|
124
|
+
}
|
|
125
|
+
export interface InsightParseOptions {
|
|
126
|
+
host?: InsightHost;
|
|
127
|
+
sessionId?: string;
|
|
128
|
+
state?: InsightParserState;
|
|
129
|
+
spawnedSession?: boolean;
|
|
130
|
+
}
|
|
131
|
+
export type RewriteCause = 'idle_ttl_expired' | 'compaction' | 'first_spawned_turn' | 'prefix_change' | 'unknown_ttl';
|
|
132
|
+
export interface RewriteEvidence {
|
|
133
|
+
turnId: string;
|
|
134
|
+
cause: RewriteCause;
|
|
135
|
+
cacheWriteTokens: number;
|
|
136
|
+
contextTokens: number;
|
|
137
|
+
idleMs?: number;
|
|
138
|
+
idleOver60Minutes: boolean;
|
|
139
|
+
ttlSeconds?: 300 | 3600;
|
|
140
|
+
ttlUnknown: boolean;
|
|
141
|
+
explanation: string;
|
|
142
|
+
}
|
|
143
|
+
export interface BucketAttribution {
|
|
144
|
+
bucket: InsightBucket;
|
|
145
|
+
tokens: number;
|
|
146
|
+
categories: Record<TokenCategory, number>;
|
|
147
|
+
}
|
|
148
|
+
export interface AttributionSummary {
|
|
149
|
+
buckets: BucketAttribution[];
|
|
150
|
+
totalTokens: number;
|
|
151
|
+
rewrites: RewriteEvidence[];
|
|
152
|
+
rewriteCounts: Record<RewriteCause, number>;
|
|
153
|
+
idleOver60MinuteRewrites: number;
|
|
154
|
+
uncertainties: string[];
|
|
155
|
+
turnAttributions: Array<{
|
|
156
|
+
turnId: string;
|
|
157
|
+
sessionId: string;
|
|
158
|
+
buckets: BucketAttribution[];
|
|
159
|
+
totalTokens: number;
|
|
160
|
+
}>;
|
|
161
|
+
sharedTurns: number;
|
|
162
|
+
prefixRebaselines: number;
|
|
163
|
+
previousOutputTokensReserved: number;
|
|
164
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.INSIGHT_BUCKETS = void 0;
|
|
4
|
+
exports.INSIGHT_BUCKETS = ['instruction_stack', 'history_resent', 'reread_files', 'subagent_fanout', 'tool_output', 'conversation', 'full_prefix_rewrites', 'output', 'unattributed'];
|
package/dist/src/install.js
CHANGED
|
@@ -48,7 +48,16 @@ function mergeMatcherStyle(cfg, event, matcher, command, timeout) {
|
|
|
48
48
|
const list = (hooks[event] ??= []);
|
|
49
49
|
let changed = false;
|
|
50
50
|
let found = false;
|
|
51
|
-
for (const m of list) {
|
|
51
|
+
for (const m of [...list]) {
|
|
52
|
+
if (m.matcher !== matcher && m.hooks?.some(h => typeof h.command === 'string' && MARK.test(h.command)) &&
|
|
53
|
+
m.hooks.some(h => typeof h.command !== 'string' || !MARK.test(h.command))) {
|
|
54
|
+
const mine = m.hooks.filter(h => typeof h.command === 'string' && MARK.test(h.command));
|
|
55
|
+
m.hooks = m.hooks.filter(h => !mine.includes(h));
|
|
56
|
+
list.push({ matcher, hooks: mine.map(h => ({ ...h, command, timeout })) });
|
|
57
|
+
found = true;
|
|
58
|
+
changed = true;
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
52
61
|
for (const h of m.hooks ?? []) {
|
|
53
62
|
if (typeof h.command === 'string' && MARK.test(h.command)) {
|
|
54
63
|
found = true;
|
|
@@ -76,8 +85,8 @@ function mergeCursor(cfg, command) {
|
|
|
76
85
|
}
|
|
77
86
|
const hooks = (cfg.hooks ??= {});
|
|
78
87
|
const want = {
|
|
79
|
-
subagentStart: { command, timeout:
|
|
80
|
-
subagentStop: { command, timeout:
|
|
88
|
+
subagentStart: { command, timeout: 15, failClosed: true },
|
|
89
|
+
subagentStop: { command, timeout: 15 },
|
|
81
90
|
sessionEnd: { command, timeout: 3 },
|
|
82
91
|
};
|
|
83
92
|
for (const [event, entry] of Object.entries(want)) {
|
|
@@ -101,11 +110,11 @@ function install(host, cliPath, home = (0, node_os_1.homedir)()) {
|
|
|
101
110
|
let changed;
|
|
102
111
|
if (host === 'claude') {
|
|
103
112
|
command = `node ${cliPath} hook`;
|
|
104
|
-
changed = mergeMatcherStyle(cfg, 'PreToolUse', '
|
|
113
|
+
changed = mergeMatcherStyle(cfg, 'PreToolUse', '.*', command, 15);
|
|
105
114
|
}
|
|
106
115
|
else if (host === 'codex') {
|
|
107
116
|
command = `node ${cliPath} codex-hook`;
|
|
108
|
-
changed = mergeMatcherStyle(cfg, 'PreToolUse', '
|
|
117
|
+
changed = mergeMatcherStyle(cfg, 'PreToolUse', '.*', command, 15);
|
|
109
118
|
}
|
|
110
119
|
else {
|
|
111
120
|
command = `node ${cliPath} cursor-hook`;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.normalizePolicy = normalizePolicy;
|
|
4
|
+
exports.loadPolicy = loadPolicy;
|
|
5
|
+
/** Load additive policy defaults without rewriting the operator's policy. */
|
|
6
|
+
const node_fs_1 = require("node:fs");
|
|
7
|
+
const node_path_1 = require("node:path");
|
|
8
|
+
const defaults_1 = require("./defaults");
|
|
9
|
+
const noticed = new Set();
|
|
10
|
+
/** Normalize before evaluating or hashing so receipts bind the actual policy. */
|
|
11
|
+
function normalizePolicy(policy) {
|
|
12
|
+
const thresholds = policy.thresholds;
|
|
13
|
+
const normalized = {
|
|
14
|
+
fanout: { ...defaults_1.DEFAULT_THRESHOLDS.fanout, ...thresholds.fanout },
|
|
15
|
+
sustained: { ...defaults_1.DEFAULT_THRESHOLDS.sustained, ...thresholds.sustained },
|
|
16
|
+
burnDebt: { ...defaults_1.DEFAULT_THRESHOLDS.burnDebt, ...thresholds.burnDebt },
|
|
17
|
+
spawnRate: { ...defaults_1.DEFAULT_THRESHOLDS.spawnRate, ...thresholds.spawnRate },
|
|
18
|
+
duplicate: { ...defaults_1.DEFAULT_THRESHOLDS.duplicate, ...thresholds.duplicate },
|
|
19
|
+
account: { ...defaults_1.DEFAULT_THRESHOLDS.account, ...thresholds.account },
|
|
20
|
+
localCompute: { ...defaults_1.DEFAULT_THRESHOLDS.localCompute, ...thresholds.localCompute },
|
|
21
|
+
};
|
|
22
|
+
return { ...policy, thresholds: normalized };
|
|
23
|
+
}
|
|
24
|
+
function noticeOnce(home, fields) {
|
|
25
|
+
if (noticed.has(home))
|
|
26
|
+
return;
|
|
27
|
+
noticed.add(home);
|
|
28
|
+
try {
|
|
29
|
+
(0, node_fs_1.mkdirSync)(home, { recursive: true, mode: 0o700 });
|
|
30
|
+
(0, node_fs_1.writeFileSync)((0, node_path_1.join)(home, '.burn-policy-0.2.6-notice'), fields.join(', ') + '\n', { flag: 'wx', mode: 0o600 });
|
|
31
|
+
}
|
|
32
|
+
catch (error) {
|
|
33
|
+
if (error.code === 'EEXIST')
|
|
34
|
+
return;
|
|
35
|
+
// A read-only home still gets the notice once in this process.
|
|
36
|
+
}
|
|
37
|
+
process.stderr.write(`AgentGuard loaded missing policy fields from defaults: ${fields.join(', ')}; existing overrides are unchanged.\n`);
|
|
38
|
+
}
|
|
39
|
+
function loadPolicy(home) {
|
|
40
|
+
try {
|
|
41
|
+
const parsed = JSON.parse((0, node_fs_1.readFileSync)((0, node_path_1.join)(home, 'burn-policy.json'), 'utf8'));
|
|
42
|
+
if (parsed && (parsed.mode === 'shadow' || parsed.mode === 'enforce') && parsed.thresholds) {
|
|
43
|
+
const missing = [];
|
|
44
|
+
if (parsed.thresholds.fanout?.windowActiveMinutes === undefined)
|
|
45
|
+
missing.push('fanout.windowActiveMinutes=120');
|
|
46
|
+
if (parsed.thresholds.spawnRate?.enforce === undefined)
|
|
47
|
+
missing.push('spawnRate.enforce=true');
|
|
48
|
+
if (missing.length)
|
|
49
|
+
noticeOnce(home, missing);
|
|
50
|
+
return normalizePolicy(parsed);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
/* no policy yet: shadow defaults */
|
|
55
|
+
}
|
|
56
|
+
return normalizePolicy(defaults_1.DEFAULT_POLICY);
|
|
57
|
+
}
|
|
@@ -150,13 +150,15 @@ function renderReplay(summary, opts = {}) {
|
|
|
150
150
|
paint(on, C.dim, ` across ${summary.sessions.length} sessions, ${summary.totalSpawns} spawns`)));
|
|
151
151
|
out.push(MID);
|
|
152
152
|
for (const s of summary.sessions.slice(0, top)) {
|
|
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');
|
|
153
|
+
const tag = s.fanoutStop ? paint(on, C.red, 'FAN-OUT STOP') : s.sustainedStop ? paint(on, C.red, 'SUSTAINED STOP') : s.spawnRateStop ? paint(on, C.red, 'SPAWN-RATE STOP') : s.firstWarn ? paint(on, C.yellow, 'WARN') : paint(on, C.green, 'clean');
|
|
154
154
|
out.push(row(`${sparkline(s.curve, s.stopAtIndex, on)} ${tag}`));
|
|
155
155
|
const detail = s.fanoutStop
|
|
156
156
|
? `before spawn ${s.fanoutStop.atSpawn}, tail ${(0, evaluate_1.fmt)(s.catchableTail)}`
|
|
157
157
|
: s.sustainedStop
|
|
158
158
|
? `near ${(0, evaluate_1.fmt)(s.sustainedStop.tokensAtStop)}, tail ${(0, evaluate_1.fmt)(s.catchableTail)}`
|
|
159
|
-
:
|
|
159
|
+
: s.spawnRateStop
|
|
160
|
+
? `before spawn ${s.spawnRateStop.atSpawn}, tail ${(0, evaluate_1.fmt)(s.catchableTail)}`
|
|
161
|
+
: `${(0, evaluate_1.fmt)(s.totalTokens)} · ${s.spawns} spawns`;
|
|
160
162
|
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}`)));
|
|
161
163
|
}
|
|
162
164
|
if (summary.sessions.length > top) {
|
|
@@ -30,6 +30,11 @@ export interface SessionReplay {
|
|
|
30
30
|
atSpawn: number;
|
|
31
31
|
tokensAtStop: number;
|
|
32
32
|
} | null;
|
|
33
|
+
/** Additive boundary for policies that enforce short-window spawn rate. */
|
|
34
|
+
spawnRateStop?: {
|
|
35
|
+
atSpawn: number;
|
|
36
|
+
tokensAtStop: number;
|
|
37
|
+
} | null;
|
|
33
38
|
/** Tokens observed after the earliest STOP boundary. */
|
|
34
39
|
catchableTail: number;
|
|
35
40
|
firstWarn: {
|
|
@@ -58,6 +58,7 @@ function replayEvents(sessionId, path, events, thresholds) {
|
|
|
58
58
|
const state = (0, session_1.newSessionState)(sessionId, first.at);
|
|
59
59
|
let fanoutStop = null;
|
|
60
60
|
let sustainedStop = null;
|
|
61
|
+
let spawnRateStop = null;
|
|
61
62
|
let firstWarn = null;
|
|
62
63
|
let tokensAtEarliestStop = null;
|
|
63
64
|
// Cumulative tokens after every event, for the sparkline.
|
|
@@ -66,25 +67,38 @@ function replayEvents(sessionId, path, events, thresholds) {
|
|
|
66
67
|
for (const event of events) {
|
|
67
68
|
// A hook boundary exists only where a spawn was attempted. Evaluate the
|
|
68
69
|
// *proposal* before applying the event, then apply it.
|
|
70
|
+
const candidateState = event.spawns.length > 0
|
|
71
|
+
? { ...state, spawnsByActiveMinute: new Map(state.spawnsByActiveMinute) }
|
|
72
|
+
: state;
|
|
73
|
+
if (event.spawns.length > 0) {
|
|
74
|
+
// One assistant row can propose several agents. Advance its active-time
|
|
75
|
+
// clock once, then account for each earlier proposal in this row. Usage
|
|
76
|
+
// and the durable state are still folded only once below.
|
|
77
|
+
(0, session_1.applyEvent)(candidateState, { at: event.at, tokens: 0, cacheRead: 0, spawns: [], surfaces: [], sidechain: event.sidechain });
|
|
78
|
+
}
|
|
69
79
|
for (const spawn of event.spawns) {
|
|
70
|
-
const report = (0, evaluate_1.evaluate)(
|
|
80
|
+
const report = (0, evaluate_1.evaluate)(candidateState, thresholds, spawn.issuerDepth + 1);
|
|
71
81
|
if (!firstWarn && report.verdict !== 'OK') {
|
|
72
|
-
firstWarn = { detector: report.findings[0]?.detector ?? 'unknown', tokensAt: state.totalTokens, spawnsAt:
|
|
82
|
+
firstWarn = { detector: report.findings[0]?.detector ?? 'unknown', tokensAt: state.totalTokens, spawnsAt: candidateState.spawnCount + 1 };
|
|
73
83
|
}
|
|
74
84
|
for (const finding of report.findings) {
|
|
75
85
|
if (finding.verdict !== 'STOP')
|
|
76
86
|
continue;
|
|
77
87
|
if (finding.detector === 'fanout' && !fanoutStop) {
|
|
78
|
-
fanoutStop = { atSpawn:
|
|
88
|
+
fanoutStop = { atSpawn: candidateState.spawnCount + 1, tokensAtStop: state.totalTokens };
|
|
89
|
+
}
|
|
90
|
+
if (finding.detector === 'spawn_rate' && !spawnRateStop) {
|
|
91
|
+
spawnRateStop = { atSpawn: candidateState.spawnCount + 1, tokensAtStop: state.totalTokens };
|
|
79
92
|
}
|
|
80
93
|
if ((finding.detector === 'sustained_burn' || finding.detector === 'burn_debt') && !sustainedStop) {
|
|
81
|
-
sustainedStop = { atSpawn:
|
|
94
|
+
sustainedStop = { atSpawn: candidateState.spawnCount + 1, tokensAtStop: state.totalTokens };
|
|
82
95
|
}
|
|
83
96
|
}
|
|
84
|
-
if ((fanoutStop || sustainedStop) && tokensAtEarliestStop === null) {
|
|
97
|
+
if ((fanoutStop || sustainedStop || spawnRateStop) && tokensAtEarliestStop === null) {
|
|
85
98
|
tokensAtEarliestStop = state.totalTokens;
|
|
86
99
|
stopEventIndex = timeline.length;
|
|
87
100
|
}
|
|
101
|
+
(0, session_1.applyEvent)(candidateState, { at: event.at, tokens: 0, cacheRead: 0, spawns: [spawn], surfaces: [], sidechain: event.sidechain });
|
|
88
102
|
}
|
|
89
103
|
// The sustained plane also has a boundary at every tool call, not just
|
|
90
104
|
// spawns. Approximate: any event with surfaces is a tool boundary.
|
|
@@ -118,6 +132,7 @@ function replayEvents(sessionId, path, events, thresholds) {
|
|
|
118
132
|
finalVerdict: final.verdict,
|
|
119
133
|
fanoutStop,
|
|
120
134
|
sustainedStop,
|
|
135
|
+
spawnRateStop,
|
|
121
136
|
catchableTail: tokensAtEarliestStop === null ? 0 : Math.max(0, state.totalTokens - tokensAtEarliestStop),
|
|
122
137
|
firstWarn,
|
|
123
138
|
curve,
|
|
@@ -160,8 +175,8 @@ function replayAll(paths, thresholds, minTokens = 0) {
|
|
|
160
175
|
totalSpawns: sessions.reduce((s, r) => s + r.spawns, 0),
|
|
161
176
|
catchableTail,
|
|
162
177
|
catchableShare: totalTokens > 0 ? catchableTail / totalTokens : 0,
|
|
163
|
-
stops: sessions.filter((r) => r.fanoutStop || r.sustainedStop).length,
|
|
164
|
-
warns: sessions.filter((r) => !r.fanoutStop && !r.sustainedStop && r.firstWarn).length,
|
|
165
|
-
clean: sessions.filter((r) => !r.fanoutStop && !r.sustainedStop && !r.firstWarn).length,
|
|
178
|
+
stops: sessions.filter((r) => r.fanoutStop || r.sustainedStop || r.spawnRateStop).length,
|
|
179
|
+
warns: sessions.filter((r) => !r.fanoutStop && !r.sustainedStop && !r.spawnRateStop && r.firstWarn).length,
|
|
180
|
+
clean: sessions.filter((r) => !r.fanoutStop && !r.sustainedStop && !r.spawnRateStop && !r.firstWarn).length,
|
|
166
181
|
};
|
|
167
182
|
}
|
|
@@ -83,11 +83,11 @@ export declare class ReservationStore {
|
|
|
83
83
|
/** The holder's own instance is still the one on the path. */
|
|
84
84
|
private fence;
|
|
85
85
|
/**
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
86
|
+
* Serialize retirement inside the existing lock before checking its owner.
|
|
87
|
+
* A delayed observer must never temporarily rename a newer live lock: even
|
|
88
|
+
* restoring it can trip that holder's final fence after audit rows were
|
|
89
|
+
* appended. Every release/recovery uses this claim, so the nonce is stable
|
|
90
|
+
* from this check through the atomic rename. Delete only the retired path.
|
|
91
91
|
*/
|
|
92
92
|
private discard;
|
|
93
93
|
private trace;
|
|
@@ -152,6 +152,8 @@ export declare class Transaction {
|
|
|
152
152
|
private readonly data;
|
|
153
153
|
dirty: boolean;
|
|
154
154
|
constructor(data: ReservationFile);
|
|
155
|
+
/** Pending proposals participate in every spawn window under the same lock. */
|
|
156
|
+
pendingSpawns(sessionId: string, toolUseId: string, now: number): number;
|
|
155
157
|
reserve(args: ReserveArgs): ReserveResult;
|
|
156
158
|
reconcile(sessionId: string, observedSpawns: number, previouslyObserved: number): void;
|
|
157
159
|
/**
|
|
@@ -25,9 +25,9 @@
|
|
|
25
25
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
26
26
|
exports.Transaction = exports.ReservationStore = exports.RESERVATION_TTL_MS = void 0;
|
|
27
27
|
const node_fs_1 = require("node:fs");
|
|
28
|
+
const node_crypto_1 = require("node:crypto");
|
|
28
29
|
const node_path_1 = require("node:path");
|
|
29
|
-
const
|
|
30
|
-
const LOCK_WAIT_MS = 3_000;
|
|
30
|
+
const LOCK_WAIT_MS = 8_000;
|
|
31
31
|
const LOCK_SPIN_MS = 15;
|
|
32
32
|
exports.RESERVATION_TTL_MS = 90_000;
|
|
33
33
|
function pidAlive(pid) {
|
|
@@ -118,42 +118,63 @@ class ReservationStore {
|
|
|
118
118
|
}
|
|
119
119
|
}
|
|
120
120
|
/**
|
|
121
|
-
*
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
*
|
|
125
|
-
*
|
|
121
|
+
* Serialize retirement inside the existing lock before checking its owner.
|
|
122
|
+
* A delayed observer must never temporarily rename a newer live lock: even
|
|
123
|
+
* restoring it can trip that holder's final fence after audit rows were
|
|
124
|
+
* appended. Every release/recovery uses this claim, so the nonce is stable
|
|
125
|
+
* from this check through the atomic rename. Delete only the retired path.
|
|
126
126
|
*/
|
|
127
127
|
discard(reason, expect) {
|
|
128
|
-
const
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
}
|
|
132
|
-
catch {
|
|
133
|
-
return false; // somebody else already took it off the path
|
|
134
|
-
}
|
|
135
|
-
const got = this.readOwnerAt(quarantine);
|
|
136
|
-
if ((got?.nonce ?? null) !== expect) {
|
|
137
|
-
// Not the instance we judged. Give it back; if a waiter slipped into
|
|
138
|
-
// the freed path in between, the displaced holder's fence throws and
|
|
139
|
-
// its transaction is discarded, so nothing double-commits.
|
|
128
|
+
const claim = (0, node_path_1.join)(this.lockDir, 'retiring');
|
|
129
|
+
const deadline = Date.now() + LOCK_WAIT_MS;
|
|
130
|
+
for (;;) {
|
|
140
131
|
try {
|
|
141
|
-
(0, node_fs_1.
|
|
142
|
-
|
|
143
|
-
return false;
|
|
132
|
+
(0, node_fs_1.mkdirSync)(claim, { mode: 0o700 });
|
|
133
|
+
break;
|
|
144
134
|
}
|
|
145
|
-
catch {
|
|
146
|
-
|
|
135
|
+
catch (error) {
|
|
136
|
+
if (error.code !== 'EEXIST' || reason !== 'released' || Date.now() >= deadline)
|
|
137
|
+
return false;
|
|
138
|
+
// A stale observer may briefly claim a newer live instance, notice the
|
|
139
|
+
// nonce mismatch, and leave it untouched. Let its owner release next.
|
|
140
|
+
sleepSync(LOCK_SPIN_MS);
|
|
147
141
|
}
|
|
148
142
|
}
|
|
143
|
+
let retired = false;
|
|
149
144
|
try {
|
|
150
|
-
(0, node_fs_1.
|
|
145
|
+
(0, node_fs_1.writeFileSync)((0, node_path_1.join)(claim, 'owner'), JSON.stringify({ pid: process.pid, since: Date.now(), nonce: (0, node_crypto_1.randomUUID)(), lockNonce: expect }), { mode: 0o600 });
|
|
146
|
+
const got = this.readOwnerAt(this.lockDir);
|
|
147
|
+
if ((got?.nonce ?? null) !== expect) {
|
|
148
|
+
this.trace(`discard ${reason}: wrong instance, untouched`);
|
|
149
|
+
return false;
|
|
150
|
+
}
|
|
151
|
+
if (reason === 'stale' && got && pidAlive(got.pid))
|
|
152
|
+
return false;
|
|
153
|
+
const quarantine = `${this.lockDir}.${reason}.${process.pid}.${(0, node_crypto_1.randomUUID)()}`;
|
|
154
|
+
(0, node_fs_1.renameSync)(this.lockDir, quarantine);
|
|
155
|
+
retired = true;
|
|
156
|
+
try {
|
|
157
|
+
(0, node_fs_1.rmSync)(quarantine, { recursive: true, force: true });
|
|
158
|
+
}
|
|
159
|
+
catch { /* Never remove a replacement live lock. */ }
|
|
160
|
+
this.trace(`discard ${reason} ok`);
|
|
161
|
+
return true;
|
|
151
162
|
}
|
|
152
|
-
|
|
153
|
-
|
|
163
|
+
finally {
|
|
164
|
+
// If the process dies while holding this tiny retirement claim, later
|
|
165
|
+
// contenders fail closed. Never guess that a claim is abandoned and
|
|
166
|
+
// risk removing another process's active coordination primitive.
|
|
167
|
+
if (!retired) {
|
|
168
|
+
try {
|
|
169
|
+
(0, node_fs_1.unlinkSync)((0, node_path_1.join)(claim, 'owner'));
|
|
170
|
+
}
|
|
171
|
+
catch { /* A missing owner record is harmless while this process holds the claim. */ }
|
|
172
|
+
try {
|
|
173
|
+
(0, node_fs_1.rmdirSync)(claim);
|
|
174
|
+
}
|
|
175
|
+
catch { /* Conservative failure; future callers fail closed. */ }
|
|
176
|
+
}
|
|
154
177
|
}
|
|
155
|
-
this.trace(`discard ${reason} ok`);
|
|
156
|
-
return true;
|
|
157
178
|
}
|
|
158
179
|
trace(line) {
|
|
159
180
|
if (!process.env.AGENTGUARD_DEBUG_LOCK)
|
|
@@ -168,27 +189,17 @@ class ReservationStore {
|
|
|
168
189
|
recoverIfStale() {
|
|
169
190
|
const owner = this.readOwnerAt(this.lockDir);
|
|
170
191
|
if (owner) {
|
|
171
|
-
const stale = !pidAlive(owner.pid)
|
|
192
|
+
const stale = !pidAlive(owner.pid);
|
|
172
193
|
if (stale) {
|
|
173
194
|
this.trace(`reclaim: owner ${owner.pid} alive=${pidAlive(owner.pid)} age=${Date.now() - owner.since}ms`);
|
|
174
195
|
this.discard('stale', owner.nonce);
|
|
175
196
|
}
|
|
176
197
|
return;
|
|
177
198
|
}
|
|
178
|
-
//
|
|
179
|
-
//
|
|
180
|
-
//
|
|
181
|
-
//
|
|
182
|
-
try {
|
|
183
|
-
const age = Date.now() - (0, node_fs_1.statSync)(this.lockDir).mtimeMs;
|
|
184
|
-
if (age > LOCK_STALE_MS) {
|
|
185
|
-
this.trace(`reclaim ownerless dir age=${age}ms`);
|
|
186
|
-
this.discard('orphan', null);
|
|
187
|
-
}
|
|
188
|
-
}
|
|
189
|
-
catch {
|
|
190
|
-
/* directory vanished between checks: the next mkdir attempt decides */
|
|
191
|
-
}
|
|
199
|
+
// An ownerless directory may belong to a creator paused between mkdir and
|
|
200
|
+
// publishing its owner record. Age cannot prove that creator is dead, and
|
|
201
|
+
// publication does not take the retirement claim. Never reclaim this gap
|
|
202
|
+
// automatically: an abandoned ownerless lock requires manual recovery.
|
|
192
203
|
}
|
|
193
204
|
release() {
|
|
194
205
|
// Only our own instance is released. If it was reclaimed and a sibling
|
|
@@ -299,6 +310,10 @@ class Transaction {
|
|
|
299
310
|
if (!this.data.calls)
|
|
300
311
|
this.data.calls = [];
|
|
301
312
|
}
|
|
313
|
+
/** Pending proposals participate in every spawn window under the same lock. */
|
|
314
|
+
pendingSpawns(sessionId, toolUseId, now) {
|
|
315
|
+
return this.data.reservations.filter(r => r.sessionId === sessionId && r.toolUseId !== toolUseId && r.expiresAt > now).length;
|
|
316
|
+
}
|
|
302
317
|
reserve(args) {
|
|
303
318
|
const now = args.now ?? Date.now();
|
|
304
319
|
const data = this.data;
|
|
@@ -307,7 +322,7 @@ class Transaction {
|
|
|
307
322
|
if (data.reservations.length !== before)
|
|
308
323
|
this.dirty = true;
|
|
309
324
|
// Idempotent: the same tool_use_id evaluated twice must not double-count.
|
|
310
|
-
const existing = data.reservations.find((r) => r.toolUseId === args.toolUseId);
|
|
325
|
+
const existing = data.reservations.find((r) => r.sessionId === args.sessionId && r.toolUseId === args.toolUseId);
|
|
311
326
|
const pendingForSession = data.reservations.filter((r) => r.sessionId === args.sessionId && r.toolUseId !== args.toolUseId).length;
|
|
312
327
|
const effective = args.observedSpawns + pendingForSession + 1;
|
|
313
328
|
if (existing) {
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { BurnReport, SessionState, Thresholds } from '../types';
|
|
2
|
+
import type { ReserveResult, Transaction } from './reservations';
|
|
3
|
+
/** Evaluate and reserve together. Pending forks must not bypass either window. */
|
|
4
|
+
export declare function evaluateSpawnReservation(tx: Transaction, state: SessionState, thresholds: Thresholds, proposedDepth: number, toolUseId: string, now: number, account?: {
|
|
5
|
+
sessions: SessionState[];
|
|
6
|
+
now: number;
|
|
7
|
+
}): {
|
|
8
|
+
report: BurnReport;
|
|
9
|
+
reservation: ReserveResult;
|
|
10
|
+
};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.evaluateSpawnReservation = evaluateSpawnReservation;
|
|
4
|
+
const evaluate_1 = require("../detectors/evaluate");
|
|
5
|
+
const defaults_1 = require("../defaults");
|
|
6
|
+
const session_1 = require("./session");
|
|
7
|
+
/** Evaluate and reserve together. Pending forks must not bypass either window. */
|
|
8
|
+
function evaluateSpawnReservation(tx, state, thresholds, proposedDepth, toolUseId, now, account) {
|
|
9
|
+
const pending = tx.pendingSpawns(state.sessionId, toolUseId, now);
|
|
10
|
+
const minute = Math.floor(state.activeMinutes);
|
|
11
|
+
const spawns = new Map(state.spawnsByActiveMinute);
|
|
12
|
+
if (pending)
|
|
13
|
+
spawns.set(minute, (spawns.get(minute) ?? 0) + pending);
|
|
14
|
+
const candidateState = { ...state, spawnCount: state.spawnCount + pending, spawnsByActiveMinute: spawns };
|
|
15
|
+
const report = (0, evaluate_1.evaluate)(candidateState, thresholds, proposedDepth, account);
|
|
16
|
+
// Receipts retain the lifetime ordinal, while admission uses the active window.
|
|
17
|
+
const effectiveSpawns = state.spawnCount + pending + 1;
|
|
18
|
+
if (report.verdict === 'STOP') {
|
|
19
|
+
// A refused proposal never consumes a reservation. Its detector explains why.
|
|
20
|
+
return { report, reservation: { allowed: true, effectiveSpawns, pending } };
|
|
21
|
+
}
|
|
22
|
+
const recent = (0, session_1.windowSum)(state.spawnsByActiveMinute, state.activeMinutes, thresholds.fanout.windowActiveMinutes ?? defaults_1.DEFAULT_THRESHOLDS.fanout.windowActiveMinutes);
|
|
23
|
+
const reservation = tx.reserve({ sessionId: state.sessionId, toolUseId, observedSpawns: recent, ceiling: thresholds.fanout.stop, now });
|
|
24
|
+
return { report, reservation: { ...reservation, effectiveSpawns } };
|
|
25
|
+
}
|
package/dist/src/types.d.ts
CHANGED
|
@@ -83,7 +83,8 @@ export interface Thresholds {
|
|
|
83
83
|
fanout: {
|
|
84
84
|
warn: number;
|
|
85
85
|
stop: number;
|
|
86
|
-
maxDepth: number;
|
|
86
|
+
maxDepth: number; /** Older policies use 120 active minutes. */
|
|
87
|
+
windowActiveMinutes?: number;
|
|
87
88
|
};
|
|
88
89
|
sustained: {
|
|
89
90
|
warnTokens: number;
|
|
@@ -132,6 +133,12 @@ export type Mode = 'shadow' | 'enforce';
|
|
|
132
133
|
export interface Policy {
|
|
133
134
|
mode: Mode;
|
|
134
135
|
thresholds: Thresholds;
|
|
136
|
+
/** Additive local usage advisories. Older policy files use these defaults. */
|
|
137
|
+
insights?: {
|
|
138
|
+
rewriteWarnDollarsPerHour?: number;
|
|
139
|
+
heavyTurnTokens?: number;
|
|
140
|
+
pricingFile?: string;
|
|
141
|
+
};
|
|
135
142
|
/** Calibration provenance, so a report can say where its numbers came from. */
|
|
136
143
|
calibration?: {
|
|
137
144
|
sessionsSampled: number;
|