Skip to content

Commit 2b96d5d

Browse files
author
Aaron Coville
committed
fix: reset usage when a PTY exits
Tearing down a PTY forgets the agent's circuit-breaker state but leaves its telemetry sessions in place. Because live usage is summed across every retained session for an agent id, a replacement agent reusing that id starts with its dead predecessor's tokens already counted against it, and a per-agent token cap can trip on spend that belongs to a session that no longer exists. Forget the agent's live telemetry sessions alongside the breaker state, so a replacement starts from its own usage. Only the in-memory live accumulation is cleared; the on-disk cost ledger and the transcript fallback are untouched, so lifetime spending is still reported.
1 parent eb3df9f commit 2b96d5d

3 files changed

Lines changed: 129 additions & 0 deletions

File tree

‎src/main/index.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -448,6 +448,8 @@ function teardownPty(id: string): void {
448448
try { workerWake.forget(agentId, id); } catch { /* best-effort */ }
449449
// Drop breaker state so a dead agent can't leak/zombie a tripped level.
450450
try { breaker.forget(agentId); } catch { /* best-effort */ }
451+
// A replacement using this id needs a new usage counter, not the dead PTY's.
452+
try { telemetry.forgetAgent(agentId); } catch { /* best-effort */ }
451453
// W1 — kill this agent's proxy-bridge sidecar (qwen), if any, so a dead
452454
// PTY never leaves an orphan loopback listener. No-op for non-proxy agents.
453455
try { hive.stopProxyBridge(agentId); } catch (e) { console.error('[hive] stopProxyBridge failed:', e); }

‎src/main/telemetry.ts‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,25 @@ export class TelemetryCollector {
206206
return () => this.apiErrorSubs.delete(cb);
207207
}
208208

209+
/** Drop an agent's live usage so a respawn starts with a fresh counter.
210+
* Clears the in-memory accumulation only — the on-disk cost ledger and the
211+
* transcript fallback are untouched, so lifetime spending still reports. The
212+
* span ring goes too: it is keyed by agent id, so a replacement would
213+
* otherwise inherit the dead agent's tool waterfall.
214+
*
215+
* Known edge: metrics are delivered asynchronously, so a batch already in
216+
* flight when the PTY dies can land after this call and recreate the dead
217+
* session's entries. The window is milliseconds and the next teardown
218+
* clears it, so it is left unguarded rather than carrying a tombstone. */
219+
forgetAgent(agentId: string): void {
220+
const sessionIds = this.agentSessions.get(agentId);
221+
if (sessionIds) {
222+
for (const sessionId of sessionIds) this.sessions.delete(sessionId);
223+
}
224+
this.agentSessions.delete(agentId);
225+
this.spans.delete(agentId);
226+
}
227+
209228
/** Recent tool spans for the per-agent waterfall (#7B.2), oldest→newest. */
210229
getSpans(agentId: string): ToolSpan[] {
211230
return this.spans.get(agentId)?.slice() ?? [];
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
'use strict';
2+
3+
const test = require('node:test');
4+
const assert = require('node:assert/strict');
5+
const loadTs = require('./load-ts.cjs');
6+
7+
const { TelemetryCollector } = loadTs('src/main/telemetry.ts');
8+
9+
function tokenBatch(agentId, sessionId, output) {
10+
return {
11+
resourceMetrics: [{
12+
resource: { attributes: [{ key: 'agent.id', value: { stringValue: agentId } }] },
13+
scopeMetrics: [{
14+
metrics: [{
15+
name: 'claude_code.token.usage',
16+
sum: {
17+
dataPoints: [{
18+
asInt: String(output),
19+
attributes: [
20+
{ key: 'session.id', value: { stringValue: sessionId } },
21+
{ key: 'type', value: { stringValue: 'output' } },
22+
{ key: 'model', value: { stringValue: 'claude-sonnet-5' } }
23+
]
24+
}]
25+
}
26+
}]
27+
}]
28+
}]
29+
};
30+
}
31+
32+
async function postMetrics(endpoint, body) {
33+
const response = await fetch(`${endpoint}/v1/metrics`, {
34+
method: 'POST',
35+
headers: { 'content-type': 'application/json' },
36+
body: JSON.stringify(body)
37+
});
38+
assert.equal(response.status, 200);
39+
await response.text();
40+
}
41+
42+
test('forgetAgent resets usage before the same agent id is respawned', async (t) => {
43+
const telemetry = new TelemetryCollector({ host: '127.0.0.1', port: 0 });
44+
await telemetry.start();
45+
t.after(() => telemetry.stop());
46+
const endpoint = telemetry.endpoint();
47+
48+
await postMetrics(endpoint, tokenBatch('agent-1', 'dead-session', 900));
49+
assert.equal(telemetry.getAgentUsage('agent-1').output, 900);
50+
51+
telemetry.forgetAgent('agent-1');
52+
53+
await postMetrics(endpoint, tokenBatch('agent-1', 'respawn-session', 10));
54+
assert.equal(telemetry.getAgentUsage('agent-1').output, 10);
55+
});
56+
57+
test('forgetAgent drops the dead session so its total cannot resurrect', async (t) => {
58+
const telemetry = new TelemetryCollector({ host: '127.0.0.1', port: 0 });
59+
await telemetry.start();
60+
t.after(() => telemetry.stop());
61+
const endpoint = telemetry.endpoint();
62+
63+
await postMetrics(endpoint, tokenBatch('agent-1', 'dead-session', 900));
64+
assert.equal(telemetry.getAgentUsage('agent-1').output, 900);
65+
66+
telemetry.forgetAgent('agent-1');
67+
68+
// A late or duplicate sample for the SAME session id must accumulate from
69+
// zero, not re-attach the forgotten 900. Deleting only agentSessions and
70+
// leaving the sessions entry behind would let the dead total resurrect the
71+
// moment any further metric arrives for that session — reinstating the very
72+
// inherited usage this reset exists to clear.
73+
await postMetrics(endpoint, tokenBatch('agent-1', 'dead-session', 10));
74+
assert.equal(telemetry.getAgentUsage('agent-1').output, 10);
75+
});
76+
77+
test('forgetAgent keeps usage for other agents', async (t) => {
78+
const telemetry = new TelemetryCollector({ host: '127.0.0.1', port: 0 });
79+
await telemetry.start();
80+
t.after(() => telemetry.stop());
81+
const endpoint = telemetry.endpoint();
82+
83+
await postMetrics(endpoint, tokenBatch('agent-1', 'session-1', 500));
84+
await postMetrics(endpoint, tokenBatch('agent-2', 'session-2', 700));
85+
86+
telemetry.forgetAgent('agent-1');
87+
88+
assert.equal(telemetry.getAgentUsage('agent-1'), null);
89+
assert.equal(telemetry.getAgentUsage('agent-2').output, 700);
90+
});
91+
92+
test('teardown resets telemetry when it forgets breaker state', () => {
93+
const indexPath = require('node:path').join(__dirname, '..', 'src', 'main', 'index.ts');
94+
const rawSource = require('node:fs').readFileSync(indexPath, 'utf8');
95+
const activeSource = rawSource
96+
.replace(/\/\*[\s\S]*?\*\//g, '')
97+
.split('\n')
98+
.map((line) => line.replace(/\/\/.*$/, ''))
99+
.join('\n');
100+
// Bounded to the teardown block: an unbounded slice would also match the call
101+
// if it were moved into any later function in the file.
102+
const start = activeSource.indexOf('breaker.forget(agentId)');
103+
const end = activeSource.indexOf('hive.stopProxyBridge(agentId)', start);
104+
assert.ok(start !== -1 && end > start, 'teardown block not found in index.ts');
105+
const afterBreakerReset = activeSource.slice(start, end);
106+
107+
assert.match(afterBreakerReset, /telemetry\.forgetAgent\(agentId\)/);
108+
});

0 commit comments

Comments
 (0)