Skip to content

Commit f683283

Browse files
committed
feat(agents): persist internal turn history
1 parent ccbbee0 commit f683283

6 files changed

Lines changed: 343 additions & 37 deletions

File tree

src/db/migrations.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,11 @@ const migrations: Migration[] = [
3737
name: "local-agent-effort-rename",
3838
up: migrateLocalAgentEffortRename,
3939
},
40+
{
41+
version: 7,
42+
name: "local-agent-turns",
43+
up: migrateLocalAgentTurns,
44+
},
4045
];
4146

4247
export function migrateDatabase(sqlite: Database.Database): void {
@@ -235,6 +240,30 @@ function migrateLocalAgentEffortRename(sqlite: Database.Database): void {
235240
sqlite.exec("alter table local_agent_sessions rename column thinking to effort");
236241
}
237242

243+
function migrateLocalAgentTurns(sqlite: Database.Database): void {
244+
sqlite.exec(`
245+
create table if not exists local_agent_turns (
246+
id integer primary key autoincrement,
247+
agent_id text not null,
248+
prompt text not null,
249+
status text not null,
250+
response text,
251+
error text,
252+
error_code text,
253+
error_retryable text,
254+
created_at text not null,
255+
completed_at text,
256+
foreign key (agent_id) references local_agent_sessions(id) on delete cascade
257+
);
258+
259+
create index if not exists local_agent_turns_agent_id_idx
260+
on local_agent_turns(agent_id, id desc);
261+
262+
create index if not exists local_agent_turns_status_idx
263+
on local_agent_turns(status);
264+
`);
265+
}
266+
238267
function addColumnIfMissing(
239268
sqlite: Database.Database,
240269
table: "workspace_sessions" | "local_agent_sessions",

src/local-agent-manager.test.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,8 @@ const stale = store.create({
121121
profileName: "reviewer",
122122
provider: "codex",
123123
});
124-
store.update(stale.id, { status: "running", latestResponse: "previous response" });
124+
const staleTurn = store.beginTurn(stale.id, { prompt: "interrupted turn" });
125+
store.update(stale.id, { latestResponse: "previous response" });
125126

126127
const manager = new LocalAgentManager({
127128
store,
@@ -222,6 +223,8 @@ assert.equal(getRecord(stale.id).latestResponse, "previous response");
222223
assert.equal(getRecord(stale.id).error, "DevSpace restarted while this agent turn was running.");
223224
assert.equal(getRecord(stale.id).errorCode, "DAEMON_UNAVAILABLE");
224225
assert.equal(getRecord(stale.id).errorRetryable, true);
226+
assert.equal(store.getTurnById(staleTurn.turn.id)?.status, "failed");
227+
assert.equal(store.getTurnById(staleTurn.turn.id)?.errorCode, "DAEMON_UNAVAILABLE");
225228

226229
const first = unwrap(await manager.start({
227230
target: "reviewer",
@@ -244,6 +247,10 @@ runtimes.get(first.id)!.release();
244247
await waitFor(() => getRecord(first.id).status === "idle");
245248
assert.equal(getRecord(first.id).providerSessionId, "thread_test");
246249
assert.match(getRecord(first.id).latestResponse ?? "", /Task:\nhold/);
250+
assert.deepEqual(
251+
store.listTurns(first.id).map((turn) => ({ prompt: turn.prompt, status: turn.status })),
252+
[{ prompt: "hold", status: "completed" }],
253+
);
247254

248255
const continued = unwrap(await manager.continue(first.id, "continue", {
249256
model: "gpt-run",
@@ -253,6 +260,13 @@ assert.equal(continued.status, "running");
253260
await waitFor(() => getRecord(first.id).status === "idle");
254261
assert.equal(getRecord(first.id).model, "gpt-run");
255262
assert.equal(getRecord(first.id).effort, "high");
263+
assert.deepEqual(
264+
store.listTurns(first.id).map((turn) => ({ prompt: turn.prompt, status: turn.status })),
265+
[
266+
{ prompt: "hold", status: "completed" },
267+
{ prompt: "continue", status: "completed" },
268+
],
269+
);
256270

257271
const second = unwrap(await manager.start({
258272
target: "reviewer",
@@ -274,6 +288,8 @@ await waitFor(() => getRecord(failed.id).status === "error");
274288
assert.equal(getRecord(failed.id).error, "provider failed");
275289
assert.equal(getRecord(failed.id).errorCode, "PROVIDER_EXECUTION_ERROR");
276290
assert.equal(getRecord(failed.id).errorRetryable, false);
291+
assert.equal(store.getLatestTurn(failed.id)?.status, "failed");
292+
assert.equal(store.getLatestTurn(failed.id)?.error, "provider failed");
277293
const recovered = unwrap(await manager.continue(failed.id, "recovered", {}, scope));
278294
assert.equal(recovered.status, "running", "provider Err releases active-turn ownership");
279295
await waitFor(() => getRecord(failed.id).status === "idle");

src/local-agent-manager.ts

Lines changed: 21 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -246,28 +246,25 @@ export class LocalAgentManager {
246246
}));
247247
}
248248

249-
const updated = this.store.updateResult(record.id, {
250-
status: "running",
249+
const begun = this.store.beginTurnResult(record.id, {
250+
prompt,
251251
model: overrides.model ?? record.model,
252252
effort: overrides.effort ?? record.effort,
253-
latestResponse: undefined,
254-
error: undefined,
255-
errorCode: undefined,
256-
errorRetryable: undefined,
257253
});
258-
if (updated.isErr()) return updated;
254+
if (begun.isErr()) return begun;
259255
// Defer invocation until after the tracking entry is visible. This keeps
260256
// cleanup correct even if runTurn later gains a synchronous completion path.
261257
const turn = Promise.resolve().then(() => (
262-
this.runTurn(updated.value, prompt, overrides, workspaceId)
258+
this.runTurn(begun.value.agent, begun.value.turn.id, prompt, overrides, workspaceId)
263259
));
264260
this.activeTurns.set(record.id, turn);
265261
void turn.catch(() => undefined);
266-
return updated;
262+
return Result.ok(begun.value.agent);
267263
}
268264

269265
private async runTurn(
270266
record: LocalAgentRecord,
267+
turnId: number,
271268
prompt: string,
272269
overrides: RunOverrides,
273270
workspaceId?: string,
@@ -281,7 +278,7 @@ export class LocalAgentManager {
281278
try {
282279
const authorized = this.authorizeWorkspace(record.workspaceRoot, workspaceId, "run");
283280
if (authorized.isErr()) {
284-
this.persistRunError(record, authorized.error, startedAt);
281+
this.persistRunError(record, turnId, authorized.error, startedAt);
285282
return;
286283
}
287284
const workspaceRoot = authorized.value;
@@ -290,22 +287,22 @@ export class LocalAgentManager {
290287
: { ...record, workspaceRoot };
291288
const profiles = await this.loadProfilesResult(workspaceRoot, record.profileName);
292289
if (profiles.isErr()) {
293-
this.persistRunError(record, profiles.error, startedAt);
290+
this.persistRunError(record, turnId, profiles.error, startedAt);
294291
return;
295292
}
296293
const profile = this.profileForRecordResult(record, profiles.value);
297294
if (profile.isErr()) {
298-
this.persistRunError(record, profile.error, startedAt);
295+
this.persistRunError(record, turnId, profile.error, startedAt);
299296
return;
300297
}
301298
const input = this.buildRunInputResult(authorizedRecord, profile.value, prompt, overrides);
302299
if (input.isErr()) {
303-
this.persistRunError(record, input.error, startedAt);
300+
this.persistRunError(record, turnId, input.error, startedAt);
304301
return;
305302
}
306303
const driver = this.driverResult(record.provider, "run", record.id);
307304
if (driver.isErr()) {
308-
this.persistRunError(record, driver.error, startedAt);
305+
this.persistRunError(record, turnId, driver.error, startedAt);
309306
return;
310307
}
311308
const context: LocalAgentRuntimeContext = {
@@ -329,20 +326,17 @@ export class LocalAgentManager {
329326
};
330327
const result = await this.pool.run(driver.value, context, input.value, callbacks);
331328
if (result.isErr()) {
332-
this.persistRunError(record, result.error, startedAt);
329+
this.persistRunError(record, turnId, result.error, startedAt);
333330
return;
334331
}
335332
const runResult = result.value;
336333
const current = this.store.getByIdResult(record.id);
337334
if (current.isErr()) throw current.error;
338335
if (!current.value) return;
339-
const updated = this.store.updateResult(record.id, {
336+
const updated = this.store.finishTurnResult(record.id, turnId, {
340337
providerSessionId: runResult.providerSessionId ?? current.value.providerSessionId,
341-
status: "idle",
342-
latestResponse: runResult.finalResponse,
343-
error: undefined,
344-
errorCode: undefined,
345-
errorRetryable: undefined,
338+
status: "completed",
339+
response: runResult.finalResponse,
346340
});
347341
if (updated.isErr()) throw updated.error;
348342
this.log("info", "agent_run_completed", {
@@ -353,11 +347,11 @@ export class LocalAgentManager {
353347
});
354348
} catch (error) {
355349
if (isLocalAgentError(error)) {
356-
this.persistRunError(record, error, startedAt);
350+
this.persistRunError(record, turnId, error, startedAt);
357351
return;
358352
}
359-
const persisted = this.store.updateResult(record.id, {
360-
status: "error",
353+
const persisted = this.store.finishTurnResult(record.id, turnId, {
354+
status: "failed",
361355
error: "Unexpected internal subagent failure.",
362356
errorCode: "AGENT_INTERNAL_ERROR",
363357
errorRetryable: false,
@@ -379,11 +373,12 @@ export class LocalAgentManager {
379373

380374
private persistRunError(
381375
record: LocalAgentRecord,
376+
turnId: number,
382377
error: LocalAgentError,
383378
startedAt: number,
384379
): void {
385-
const persisted = this.store.updateResult(record.id, {
386-
status: "error",
380+
const persisted = this.store.finishTurnResult(record.id, turnId, {
381+
status: "failed",
387382
error: error.message,
388383
errorCode: error.code,
389384
errorRetryable: error.retryable,

src/local-agent-store.test.ts

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,66 @@ try {
5454
assert.deepEqual(store.list({ workspaceId: "ws_1" }).map((agent) => agent.id), [created.id]);
5555
assert.deepEqual(store.list({ workspaceId: "ws_other" }), []);
5656
assert.deepEqual(store.list({ workspaceId: "ws_1", workspaceRoot: join(root, "other") }), []);
57-
assert.deepEqual(store.list({ workspaceRoot: join(root, "other") }), []);
57+
assert.deepEqual(store.list({ workspaceRoot: join(root, "other") }), []);
58+
59+
const begun = store.beginTurn(created.id, {
60+
prompt: "Review the current changes.",
61+
model: updated.model,
62+
effort: updated.effort,
63+
});
64+
assert.equal(begun.agent.status, "running");
65+
assert.equal(begun.turn.agentId, created.id);
66+
assert.equal(begun.turn.prompt, "Review the current changes.");
67+
assert.equal(begun.turn.status, "running");
68+
assert.equal(begun.turn.completedAt, undefined);
69+
70+
const completed = store.finishTurn(created.id, begun.turn.id, {
71+
status: "completed",
72+
response: "No issues found.",
73+
providerSessionId: "thread_456",
74+
});
75+
assert.equal(completed.status, "idle");
76+
assert.equal(completed.latestResponse, "No issues found.");
77+
assert.equal(completed.providerSessionId, "thread_456");
78+
const completedTurn = store.getLatestTurn(created.id);
79+
assert.equal(completedTurn?.id, begun.turn.id);
80+
assert.equal(completedTurn?.status, "completed");
81+
assert.equal(completedTurn?.response, "No issues found.");
82+
assert.ok(completedTurn?.completedAt);
83+
84+
const failing = store.beginTurn(created.id, {
85+
prompt: "Retry the review.",
86+
model: completed.model,
87+
effort: completed.effort,
88+
});
89+
store.finishTurn(created.id, failing.turn.id, {
90+
status: "failed",
91+
error: "Provider disconnected.",
92+
errorCode: "PROVIDER_EXECUTION_ERROR",
93+
errorRetryable: true,
94+
});
95+
assert.deepEqual(
96+
store.listTurns(created.id).map((turn) => ({
97+
prompt: turn.prompt,
98+
status: turn.status,
99+
response: turn.response,
100+
errorCode: turn.errorCode,
101+
})),
102+
[
103+
{
104+
prompt: "Review the current changes.",
105+
status: "completed",
106+
response: "No issues found.",
107+
errorCode: undefined,
108+
},
109+
{
110+
prompt: "Retry the review.",
111+
status: "failed",
112+
response: undefined,
113+
errorCode: "PROVIDER_EXECUTION_ERROR",
114+
},
115+
],
116+
);
58117

59118
const otherStore = new LocalAgentStore(root);
60119
stores.push(otherStore);
@@ -69,6 +128,7 @@ assert.deepEqual(store.list({ workspaceRoot: join(root, "other") }), []);
69128
store.list({ workspaceId: "ws_1" }).map((agent) => agent.id).sort(),
70129
[created.id, createdFromOtherStore.id].sort(),
71130
);
131+
assert.equal(otherStore.listTurns(created.id).length, 2);
72132

73133
const legacyStateDir = join(root, "legacy-state");
74134
mkdirSync(legacyStateDir, { recursive: true });
@@ -137,6 +197,12 @@ assert.deepEqual(store.list({ workspaceRoot: join(root, "other") }), []);
137197
assert.equal(reloadedRecord?.error, "old error");
138198
assert.equal(reloadedRecord?.errorCode, "DAEMON_TIMEOUT");
139199
assert.equal(reloadedRecord?.errorRetryable, true);
200+
const legacyTurn = upgradedStore.beginTurn("agt_legacy", {
201+
prompt: "Continue after upgrade.",
202+
model: reloadedRecord?.model,
203+
effort: reloadedRecord?.effort,
204+
});
205+
assert.equal(legacyTurn.turn.status, "running");
140206
} finally {
141207
for (const store of stores) {
142208
store.close();

0 commit comments

Comments
 (0)