Skip to content

Commit 2ce256f

Browse files
committed
Track settled worker models and failure telemetry
1 parent be370e4 commit 2ce256f

4 files changed

Lines changed: 194 additions & 3 deletions

File tree

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import { expect, test } from "bun:test";
2+
import { mkdtemp } from "node:fs/promises";
3+
import { tmpdir } from "node:os";
4+
import { join } from "node:path";
5+
6+
import type { ReactorEmittedEvent } from "@intx/inference";
7+
8+
import { withMockedModuleDuring } from "../../tests/helpers/mock-module.js";
9+
import { createPermissionGate } from "../permission/gate.js";
10+
import type { SubAgentRunSettlement } from "./types.js";
11+
12+
const permissionGate = createPermissionGate({
13+
approvals: [],
14+
interactive: false,
15+
skipPermissions: true,
16+
});
17+
18+
test("rejected workers settle prior rollups with the latest observed model", async () => {
19+
const cwd = await mkdtemp(join(tmpdir(), "corbits-run-settlement-"));
20+
const originalError = new Error("worker failed after prior activity");
21+
let settlement: Readonly<SubAgentRunSettlement> | undefined;
22+
23+
const caught = await withMockedModuleDuring(
24+
import.meta.resolve("../agent/live-tool-dispatch.js"),
25+
(real: typeof import("../agent/live-tool-dispatch.js")) => ({
26+
...real,
27+
createAgentWithLiveToolDispatch: async () => ({
28+
send: async () => {
29+
await new Promise((resolve) => setTimeout(resolve, 20));
30+
throw originalError;
31+
},
32+
stream: () =>
33+
(async function* (): AsyncGenerator<ReactorEmittedEvent> {
34+
yield {
35+
type: "tool.start",
36+
seq: 1,
37+
data: { call: { id: "call-1", name: "read_file", arguments: {} } },
38+
} as ReactorEmittedEvent;
39+
yield {
40+
type: "tool.done",
41+
seq: 2,
42+
data: {
43+
call: { id: "call-1", name: "read_file", arguments: {} },
44+
result: { callId: "call-1", content: "failed", isError: true },
45+
},
46+
} as ReactorEmittedEvent;
47+
yield {
48+
type: "inference.done",
49+
seq: 3,
50+
data: {
51+
turn: { role: "assistant", content: [], model: "backup-model", timestamp: 0 },
52+
usage: {
53+
input: 11,
54+
output: 7,
55+
cacheRead: 3,
56+
cacheWrite: 2,
57+
thinking: 5,
58+
},
59+
source: {
60+
sourceId: "backup-source",
61+
provider: "backup",
62+
model: "backup-model",
63+
},
64+
},
65+
} as ReactorEmittedEvent;
66+
})(),
67+
deliver: () => {},
68+
close: async () => {},
69+
setSource: () => {},
70+
setSources: () => {},
71+
history: async () => [],
72+
checkpoints: async () => [],
73+
readAt: async () => [],
74+
blobReader: {},
75+
}),
76+
}),
77+
async () => {
78+
const { runSubAgent } = await import("./run.js");
79+
try {
80+
await runSubAgent({
81+
cwd,
82+
workdirBase: join(cwd, ".ctx"),
83+
permissionGate,
84+
provider: {
85+
providerName: "initial",
86+
baseURL: "http://localhost",
87+
model: "initial-model",
88+
},
89+
description: "settlement probe",
90+
prompt: "do work then fail",
91+
onRunSettled: (summary) => {
92+
settlement = summary;
93+
},
94+
});
95+
} catch (error) {
96+
return error;
97+
}
98+
throw new Error("expected runSubAgent to reject");
99+
},
100+
);
101+
102+
expect(caught).toBe(originalError);
103+
expect(settlement).toMatchObject({
104+
turn_count: 1,
105+
input_tokens: 11,
106+
output_tokens: 7,
107+
cache_read_tokens: 3,
108+
cache_write_tokens: 2,
109+
reasoning_tokens: 5,
110+
tool_call_count: 1,
111+
tool_error_count: 1,
112+
error_count: 1,
113+
model: "backup-model",
114+
terminal_reason: "error",
115+
});
116+
expect(Object.isFrozen(settlement)).toBe(true);
117+
});

src/subagent/run.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -375,9 +375,10 @@ export async function runSubAgent(params: RunSubAgentParams): Promise<RunSubAgen
375375
};
376376
let terminalReason: SubAgentTerminalReason = "error";
377377
let errorCount = 0;
378+
const settlementState = { latestModel: params.provider.model };
378379

379380
try {
380-
const result = await runSubAgentInner(params, telemetryRollup);
381+
const result = await runSubAgentInner(params, telemetryRollup, settlementState);
381382
terminalReason = result.stopReason ?? "complete";
382383
return result;
383384
} catch (error) {
@@ -390,7 +391,7 @@ export async function runSubAgent(params: RunSubAgentParams): Promise<RunSubAgen
390391
...telemetryRollup,
391392
error_count: errorCount,
392393
duration_ms: Date.now() - startedAt,
393-
model: params.provider.model,
394+
model: settlementState.latestModel,
394395
terminal_reason: terminalReason,
395396
}),
396397
);
@@ -403,6 +404,7 @@ export async function runSubAgent(params: RunSubAgentParams): Promise<RunSubAgen
403404
async function runSubAgentInner(
404405
params: RunSubAgentParams,
405406
telemetryRollup: SubAgentTelemetryRollup,
407+
settlementState: { latestModel: string },
406408
): Promise<RunSubAgentResult> {
407409
await seedPricingMetadataFromCache({
408410
cachePath: defaultPricingCachePath(),
@@ -916,6 +918,9 @@ async function runSubAgentInner(
916918
const result = (event as { data?: { result?: { isError?: unknown } } }).data?.result;
917919
if (result?.isError === true) telemetryRollup.tool_error_count += 1;
918920
}
921+
if (event.type === "inference.done") {
922+
settlementState.latestModel = event.data.source.model;
923+
}
919924
if (onTurnBoundary(event)) {
920925
telemetryRollup.turn_count += 1;
921926
const usage = (

src/subagent/spawn-agent-worktree.test.ts

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { createFleetRecords, createSpawnAgentTool } from "./agent-fleet.js";
99
import { createSubAgentSessionStore } from "./session-store.js";
1010
import { createPermissionGate } from "../permission/gate.js";
1111
import type { RunSubAgentParams, RunSubAgentResult } from "./types.js";
12+
import type { Telemetry } from "../telemetry/index.js";
1213

1314
const run = promisify(execFile);
1415

@@ -24,6 +25,19 @@ const provider = {
2425
model: "test-model",
2526
};
2627

28+
function telemetryCapture() {
29+
const events: { event: string; properties: Record<string, unknown> }[] = [];
30+
const telemetry: Telemetry = {
31+
enabled: true,
32+
installationId: "test",
33+
capture: (event, properties = {}) => events.push({ event, properties }),
34+
captureIntentional: () => false,
35+
flush: async () => {},
36+
discard: () => {},
37+
};
38+
return { telemetry, events };
39+
}
40+
2741
const tempDirs: string[] = [];
2842

2943
afterEach(async () => {
@@ -108,17 +122,20 @@ describe("spawn_agent worktree isolation", () => {
108122
tempDirs.push(workdirBase);
109123

110124
let ran = false;
125+
const { telemetry, events } = telemetryCapture();
126+
const sessions = createSubAgentSessionStore();
111127
const tool = createSpawnAgentTool({
112128
permissionGate: testPermissionGate,
113129
cwd: notARepo,
114130
getWorkdirBase: () => workdirBase,
115131
provider,
116132
useWorktree: true,
133+
telemetry,
117134
run: async () => {
118135
ran = true;
119136
return { report: "no" };
120137
},
121-
sessions: createSubAgentSessionStore(),
138+
sessions,
122139
fleetRecords: createFleetRecords(),
123140
});
124141
if (tool.kind !== "full") throw new Error("expected full tool");
@@ -132,6 +149,25 @@ describe("spawn_agent worktree isolation", () => {
132149
);
133150
expect(result.isError).toBe(true);
134151
expect(ran).toBe(false);
152+
expect(sessions.list()).toHaveLength(1);
153+
expect(sessions.list()[0]?.status).toBe("failed");
154+
expect(events.filter((event) => event.event === "subagent_start")).toHaveLength(1);
155+
const ends = events.filter((event) => event.event === "subagent_end");
156+
expect(ends).toHaveLength(1);
157+
expect(ends[0]?.properties).toMatchObject({
158+
status: "failed",
159+
stop_reason: "setup_error",
160+
model: "test-model",
161+
turn_count: 0,
162+
input_tokens: 0,
163+
output_tokens: 0,
164+
cache_read_tokens: 0,
165+
cache_write_tokens: 0,
166+
reasoning_tokens: 0,
167+
tool_call_count: 0,
168+
tool_error_count: 0,
169+
});
170+
expect(typeof ends[0]?.properties.duration_ms).toBe("number");
135171
});
136172

137173
test("defers worktree cleanup while the session is retained for followup", async () => {

src/subagent/task-tool-worktree.test.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { promisify } from "node:util";
88
import { createTaskTool } from "./task-tool.js";
99
import type { RunSubAgentParams } from "./types.js";
1010
import { createPermissionGate } from "../permission/gate.js";
11+
import type { Telemetry } from "../telemetry/index.js";
1112

1213
const run = promisify(execFile);
1314

@@ -23,6 +24,19 @@ const provider = {
2324
model: "test-model",
2425
};
2526

27+
function telemetryCapture() {
28+
const events: { event: string; properties: Record<string, unknown> }[] = [];
29+
const telemetry: Telemetry = {
30+
enabled: true,
31+
installationId: "test",
32+
capture: (event, properties = {}) => events.push({ event, properties }),
33+
captureIntentional: () => false,
34+
flush: async () => {},
35+
discard: () => {},
36+
};
37+
return { telemetry, events };
38+
}
39+
2640
async function callTask(
2741
tool: ReturnType<typeof createTaskTool>,
2842
args: Record<string, unknown>,
@@ -122,12 +136,14 @@ describe("createTaskTool worktree isolation", () => {
122136
tempDirs.push(workdirBase);
123137

124138
let ran = false;
139+
const { telemetry, events } = telemetryCapture();
125140
const tool = createTaskTool({
126141
permissionGate: testPermissionGate,
127142
cwd: notARepo,
128143
getWorkdirBase: () => workdirBase,
129144
provider,
130145
useWorktree: true,
146+
telemetry,
131147
run: async () => {
132148
ran = true;
133149
return { report: "done" };
@@ -143,6 +159,23 @@ describe("createTaskTool worktree isolation", () => {
143159
expect(result).toContain("Error:");
144160
expect(result).toContain("not inside a git repository");
145161
expect(ran).toBe(false);
162+
expect(events.filter((event) => event.event === "subagent_start")).toHaveLength(1);
163+
const ends = events.filter((event) => event.event === "subagent_end");
164+
expect(ends).toHaveLength(1);
165+
expect(ends[0]?.properties).toMatchObject({
166+
status: "failed",
167+
stop_reason: "setup_error",
168+
model: "test-model",
169+
turn_count: 0,
170+
input_tokens: 0,
171+
output_tokens: 0,
172+
cache_read_tokens: 0,
173+
cache_write_tokens: 0,
174+
reasoning_tokens: 0,
175+
tool_call_count: 0,
176+
tool_error_count: 0,
177+
});
178+
expect(typeof ends[0]?.properties.duration_ms).toBe("number");
146179
});
147180

148181
test("preserves a worktree the sub-agent left dirty, with a notice in the report", async () => {

0 commit comments

Comments
 (0)