Skip to content

Commit f837e95

Browse files
committed
feat: remove tree snapshots, use checkpoints, fix handoff bugs
1 parent 64de17a commit f837e95

33 files changed

+343
-3178
lines changed

apps/code/src/main/services/cloud-task/service.test.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -446,8 +446,9 @@ describe("CloudTaskService", () => {
446446
);
447447

448448
expect(mockStreamFetch.mock.calls.length).toBe(6);
449-
// 2 bootstrap calls + 6 handleStreamCompletion calls (one per stream error)
450-
expect(mockNetFetch).toHaveBeenCalledTimes(8);
449+
// 2 bootstrap calls + 1 post-bootstrap status verification + 6
450+
// handleStreamCompletion calls (one per stream error)
451+
expect(mockNetFetch).toHaveBeenCalledTimes(9);
451452
expect(updates).toContainEqual({
452453
taskId: "task-1",
453454
runId: "run-1",

apps/code/src/main/services/cloud-task/service.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -548,6 +548,33 @@ export class CloudTaskService extends TypedEventEmitter<CloudTaskEvents> {
548548
watcher.needsPostBootstrapReconnect = false;
549549
this.scheduleReconnect(key);
550550
}
551+
552+
void this.verifyPostBootstrapStatus(key);
553+
}
554+
555+
private async verifyPostBootstrapStatus(key: string): Promise<void> {
556+
const watcher = this.watchers.get(key);
557+
if (!watcher) return;
558+
if (isTerminalStatus(watcher.lastStatus)) return;
559+
560+
const run = await this.fetchTaskRun(watcher);
561+
const currentWatcher = this.watchers.get(key);
562+
if (!currentWatcher || currentWatcher !== watcher) return;
563+
if (!run) return;
564+
565+
if (!this.applyTaskRunState(watcher, run)) return;
566+
if (isTerminalStatus(watcher.lastStatus)) return;
567+
568+
this.emit(CloudTaskEvent.Update, {
569+
taskId: watcher.taskId,
570+
runId: watcher.runId,
571+
kind: "status",
572+
status: watcher.lastStatus ?? undefined,
573+
stage: watcher.lastStage,
574+
output: watcher.lastOutput,
575+
errorMessage: watcher.lastErrorMessage,
576+
branch: watcher.lastBranch,
577+
});
551578
}
552579

553580
private async connectSse(

apps/code/src/main/services/handoff/handoff-saga.test.ts

Lines changed: 12 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -33,19 +33,6 @@ function createInput(
3333
};
3434
}
3535

36-
function createSnapshot(
37-
overrides: Partial<AgentTypes.TreeSnapshotEvent> = {},
38-
): AgentTypes.TreeSnapshotEvent {
39-
return {
40-
treeHash: "abc123",
41-
baseCommit: "def456",
42-
archiveUrl: "https://s3.example.com/archive.tar.gz",
43-
changes: [{ path: "test.txt", status: "A" }],
44-
timestamp: "2026-04-07T00:00:00Z",
45-
...overrides,
46-
};
47-
}
48-
4936
function createCheckpoint(
5037
overrides: Partial<AgentTypes.GitCheckpointEvent> = {},
5138
): AgentTypes.GitCheckpointEvent {
@@ -75,7 +62,6 @@ function createDeps(overrides: Partial<HandoffSagaDeps> = {}): HandoffSagaDeps {
7562
}),
7663
updateTaskRun: vi.fn().mockResolvedValue({}),
7764
}),
78-
applyTreeSnapshot: vi.fn().mockResolvedValue(undefined),
7965
applyGitCheckpoint: vi.fn().mockResolvedValue(undefined),
8066
updateWorkspaceMode: vi.fn(),
8167
reconnectSession: vi.fn().mockResolvedValue({
@@ -96,7 +82,6 @@ function createResumeState(
9682
): AgentResume.ResumeState {
9783
return {
9884
conversation: [],
99-
latestSnapshot: null,
10085
latestGitCheckpoint: null,
10186
interrupted: false,
10287
logEntryCount: 0,
@@ -130,22 +115,22 @@ describe("HandoffSaga", () => {
130115
mockFormatConversation.mockReturnValue("conversation summary");
131116
});
132117

133-
it("completes happy path with snapshot", async () => {
134-
const snapshot = createSnapshot();
118+
it("completes happy path with checkpoint", async () => {
119+
const checkpoint = createCheckpoint();
135120
const { result } = await runSaga({
136121
resumeState: {
137122
conversation: [
138123
{ role: "user", content: [{ type: "text", text: "hello" }] },
139124
],
140-
latestSnapshot: snapshot,
125+
latestGitCheckpoint: checkpoint,
141126
logEntryCount: 10,
142127
},
143128
});
144129

145130
expect(result.success).toBe(true);
146131
if (!result.success) return;
147132
expect(result.data.sessionId).toBe("session-1");
148-
expect(result.data.snapshotApplied).toBe(true);
133+
expect(result.data.checkpointApplied).toBe(true);
149134
expect(result.data.conversationTurns).toBe(1);
150135
});
151136

@@ -165,27 +150,15 @@ describe("HandoffSaga", () => {
165150
expect(closeOrder).toBeLessThan(fetchOrder);
166151
});
167152

168-
it("skips snapshot apply when no archiveUrl", async () => {
153+
it("skips checkpoint apply when no checkpoint is present", async () => {
169154
const { deps, result } = await runSaga({
170-
resumeState: {
171-
latestSnapshot: createSnapshot({ archiveUrl: undefined }),
172-
logEntryCount: 5,
173-
},
155+
resumeState: { logEntryCount: 5 },
174156
});
175157

176158
expect(result.success).toBe(true);
177159
if (!result.success) return;
178-
expect(result.data.snapshotApplied).toBe(false);
179-
expect(deps.applyTreeSnapshot).not.toHaveBeenCalled();
180-
});
181-
182-
it("skips snapshot apply when no snapshot at all", async () => {
183-
const { deps, result } = await runSaga();
184-
185-
expect(result.success).toBe(true);
186-
if (!result.success) return;
187-
expect(result.data.snapshotApplied).toBe(false);
188-
expect(deps.applyTreeSnapshot).not.toHaveBeenCalled();
160+
expect(result.data.checkpointApplied).toBe(false);
161+
expect(deps.applyGitCheckpoint).not.toHaveBeenCalled();
189162
});
190163

191164
it("seeds local logs when cloudLogUrl is present", async () => {
@@ -232,16 +205,16 @@ describe("HandoffSaga", () => {
232205
);
233206
});
234207

235-
it("context mentions files restored when snapshot applied", async () => {
208+
it("context mentions files restored when checkpoint applied", async () => {
236209
const { deps } = await runSaga({
237210
resumeState: {
238-
latestSnapshot: createSnapshot(),
211+
latestGitCheckpoint: createCheckpoint(),
239212
},
240213
});
241214

242215
expect(deps.setPendingContext).toHaveBeenCalledWith(
243216
"run-1",
244-
expect.stringContaining("fully restored"),
217+
expect.stringContaining("restored from the cloud session checkpoint"),
245218
);
246219
});
247220

@@ -262,14 +235,12 @@ describe("HandoffSaga", () => {
262235
const { deps } = await runSaga({
263236
resumeState: {
264237
latestGitCheckpoint: createCheckpoint(),
265-
latestSnapshot: createSnapshot(),
266238
},
267239
});
268240

269241
expect(getProgressSteps(deps)).toEqual([
270242
"fetching_logs",
271243
"applying_git_checkpoint",
272-
"applying_snapshot",
273244
"spawning_agent",
274245
"complete",
275246
]);
@@ -318,11 +289,10 @@ describe("HandoffSaga", () => {
318289
});
319290
});
320291

321-
it("applies git checkpoint before restoring the file snapshot", async () => {
292+
it("applies git checkpoint with local git state during handoff", async () => {
322293
const { deps, result } = await runSaga({
323294
input: { localGitState: DEFAULT_LOCAL_GIT_STATE },
324295
resumeState: {
325-
latestSnapshot: createSnapshot(),
326296
latestGitCheckpoint: createCheckpoint(),
327297
},
328298
});
@@ -338,6 +308,5 @@ describe("HandoffSaga", () => {
338308
expect.any(Object),
339309
DEFAULT_LOCAL_GIT_STATE,
340310
);
341-
expect(deps.applyTreeSnapshot).toHaveBeenCalledTimes(1);
342311
});
343312
});

apps/code/src/main/services/handoff/handoff-saga.ts

Lines changed: 9 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -13,18 +13,11 @@ export type HandoffSagaInput = HandoffExecuteInput;
1313

1414
export interface HandoffSagaOutput {
1515
sessionId: string;
16-
snapshotApplied: boolean;
16+
checkpointApplied: boolean;
1717
conversationTurns: number;
1818
}
1919

2020
export interface HandoffSagaDeps extends HandoffBaseDeps {
21-
applyTreeSnapshot(
22-
snapshot: AgentTypes.TreeSnapshotEvent,
23-
repoPath: string,
24-
taskId: string,
25-
runId: string,
26-
apiClient: PostHogAPIClient,
27-
): Promise<void>;
2821
applyGitCheckpoint(
2922
checkpoint: AgentTypes.GitCheckpointEvent,
3023
repoPath: string,
@@ -102,7 +95,7 @@ export class HandoffSaga extends Saga<HandoffSagaInput, HandoffSagaOutput> {
10295
},
10396
);
10497

105-
let filesRestored = false;
98+
let checkpointApplied = false;
10699
const checkpoint = resumeState.latestGitCheckpoint;
107100
if (checkpoint) {
108101
this.deps.onProgress(
@@ -121,29 +114,7 @@ export class HandoffSaga extends Saga<HandoffSagaInput, HandoffSagaOutput> {
121114
apiClient,
122115
input.localGitState,
123116
);
124-
},
125-
rollback: async () => {},
126-
});
127-
}
128-
129-
const snapshot = resumeState.latestSnapshot;
130-
if (snapshot?.archiveUrl) {
131-
this.deps.onProgress(
132-
"applying_snapshot",
133-
"Applying cloud file state locally...",
134-
);
135-
136-
await this.step({
137-
name: "apply_snapshot",
138-
execute: async () => {
139-
await this.deps.applyTreeSnapshot(
140-
snapshot,
141-
repoPath,
142-
taskId,
143-
runId,
144-
apiClient,
145-
);
146-
filesRestored = true;
117+
checkpointApplied = true;
147118
},
148119
rollback: async () => {},
149120
});
@@ -198,7 +169,7 @@ export class HandoffSaga extends Saga<HandoffSagaInput, HandoffSagaOutput> {
198169
await this.readOnlyStep("set_context", async () => {
199170
const context = this.buildHandoffContext(
200171
resumeState.conversation,
201-
filesRestored,
172+
checkpointApplied,
202173
);
203174
this.deps.setPendingContext(runId, context);
204175
});
@@ -207,20 +178,20 @@ export class HandoffSaga extends Saga<HandoffSagaInput, HandoffSagaOutput> {
207178

208179
return {
209180
sessionId: agentSessionId,
210-
snapshotApplied: filesRestored,
181+
checkpointApplied,
211182
conversationTurns: resumeState.conversation.length,
212183
};
213184
}
214185

215186
private buildHandoffContext(
216187
conversation: AgentResume.ConversationTurn[],
217-
snapshotApplied: boolean,
188+
checkpointApplied: boolean,
218189
): string {
219190
const conversationSummary = formatConversationForResume(conversation);
220191

221-
const fileStatus = snapshotApplied
222-
? "The workspace files have been fully restored from the cloud session."
223-
: "The workspace files from the cloud session could not be restored. You are working with the local file state.";
192+
const fileStatus = checkpointApplied
193+
? "The workspace git state and files have been restored from the cloud session checkpoint."
194+
: "The workspace from the cloud session could not be restored from a checkpoint. You are working with the local file state.";
224195

225196
return (
226197
`You are resuming a previous conversation that was running in a cloud sandbox. ` +
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import { beforeEach, describe, expect, it, vi } from "vitest";
2+
import {
3+
HandoffToCloudSaga,
4+
type HandoffToCloudSagaDeps,
5+
} from "./handoff-to-cloud-saga";
6+
7+
function createDeps(
8+
overrides: Partial<HandoffToCloudSagaDeps> = {},
9+
): HandoffToCloudSagaDeps {
10+
return {
11+
captureGitCheckpoint: vi.fn().mockResolvedValue({
12+
checkpointId: "checkpoint-1",
13+
checkpointRef: "refs/posthog-code-checkpoint/checkpoint-1",
14+
}),
15+
persistCheckpointToLog: vi.fn().mockResolvedValue(undefined),
16+
countLocalLogEntries: vi.fn().mockReturnValue(7),
17+
resumeRunInCloud: vi.fn().mockResolvedValue(undefined),
18+
killSession: vi.fn().mockResolvedValue(undefined),
19+
updateWorkspaceMode: vi.fn(),
20+
onProgress: vi.fn(),
21+
...overrides,
22+
} as unknown as HandoffToCloudSagaDeps;
23+
}
24+
25+
describe("HandoffToCloudSaga", () => {
26+
beforeEach(() => {
27+
vi.clearAllMocks();
28+
});
29+
30+
it("persists the fresh checkpoint, starts cloud, then kills the local session", async () => {
31+
const deps = createDeps();
32+
const order: string[] = [];
33+
34+
vi.mocked(deps.persistCheckpointToLog).mockImplementation(async () => {
35+
order.push("checkpoint");
36+
});
37+
vi.mocked(deps.resumeRunInCloud).mockImplementation(async () => {
38+
order.push("resume");
39+
});
40+
vi.mocked(deps.killSession).mockImplementation(async () => {
41+
order.push("kill");
42+
});
43+
44+
const saga = new HandoffToCloudSaga(deps);
45+
const result = await saga.run({
46+
taskId: "task-1",
47+
runId: "run-1",
48+
repoPath: "/repo/path",
49+
apiHost: "https://us.posthog.com",
50+
teamId: 1,
51+
localGitState: {
52+
head: "head-1",
53+
branch: "main",
54+
upstreamHead: "upstream-head-1",
55+
upstreamRemote: "origin",
56+
upstreamMergeRef: "refs/heads/main",
57+
},
58+
});
59+
60+
expect(result.success).toBe(true);
61+
expect(order).toEqual(["checkpoint", "resume", "kill"]);
62+
expect(deps.countLocalLogEntries).toHaveBeenCalledWith("run-1");
63+
if (result.success) {
64+
expect(result.data.logEntryCount).toBe(7);
65+
expect(result.data.checkpointCaptured).toBe(true);
66+
}
67+
});
68+
69+
it("reports logEntryCount of 0 when no local cache exists", async () => {
70+
const deps = createDeps({
71+
countLocalLogEntries: vi.fn().mockReturnValue(0),
72+
});
73+
74+
const saga = new HandoffToCloudSaga(deps);
75+
const result = await saga.run({
76+
taskId: "task-1",
77+
runId: "run-1",
78+
repoPath: "/repo/path",
79+
apiHost: "https://us.posthog.com",
80+
teamId: 1,
81+
});
82+
83+
expect(result.success).toBe(true);
84+
if (result.success) {
85+
expect(result.data.logEntryCount).toBe(0);
86+
}
87+
});
88+
});

0 commit comments

Comments
 (0)