Skip to content
This repository was archived by the owner on Aug 6, 2026. It is now read-only.

Commit 8222db1

Browse files
authored
fix(agent): stop handoff checkpoint uploads failing on large repos
Cloud sessions that end without local handoff state packed every object reachable from HEAD (the entire repo snapshot for a fresh sandbox clone), then shipped it base64-encoded inside a JSON API body. On large repos the request exceeded API body limits and the final checkpoint was lost with a 413, so cold resumes could not restore the session's git state. - default the pack baseline to remote-tracking refs when no local git state is provided, so packs only carry session-created objects (the apply side already restores the baseline by fetching the upstream ref) - upload checkpoint artifacts via the presigned prepare/finalize direct upload flow, storing raw bytes instead of base64 text; keep the inline JSON upload as a fallback and sniff pack/index magic bytes on download so legacy base64-at-rest artifacts still apply - cap artifact and inline upload sizes, skipping with a clear warning instead of failing the whole checkpoint capture Generated-By: PostHog Code Task-Id: 2a8b146c-f202-420f-b0b8-3327230eba28
1 parent e72c5b8 commit 8222db1

5 files changed

Lines changed: 496 additions & 27 deletions

File tree

‎packages/agent/src/handoff-checkpoint.test.ts‎

Lines changed: 164 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
1-
import { afterEach, describe, expect, it } from "vitest";
2-
import { HandoffCheckpointTracker } from "./handoff-checkpoint";
1+
import { afterEach, describe, expect, it, vi } from "vitest";
2+
import {
3+
decodeHandoffArtifact,
4+
HandoffCheckpointTracker,
5+
} from "./handoff-checkpoint";
36
import {
47
cloneTestRepo,
58
createTestRepo,
@@ -8,7 +11,7 @@ import {
811
import type { HandoffLocalGitState } from "./types";
912

1013
interface BundleStore {
11-
artifacts: Record<string, string>;
14+
artifacts: Record<string, Buffer>;
1215
storagePath: string;
1316
manifest: Array<{ storage_path: string }>;
1417
}
@@ -20,19 +23,31 @@ interface HandoffRepos {
2023
localGitState: HandoffLocalGitState;
2124
}
2225

23-
function createMockApi(store: BundleStore) {
24-
return {
26+
interface MockApiOptions {
27+
/** Store inline uploads as base64 text, like the backend did before content_encoding was sent. */
28+
legacyBase64AtRest?: boolean;
29+
/** Expose the prepare/finalize direct-upload endpoints. */
30+
directUploads?: boolean;
31+
}
32+
33+
function createMockApi(store: BundleStore, options?: MockApiOptions) {
34+
let nextId = 0;
35+
const api: Record<string, unknown> = {
2536
uploadTaskArtifacts: async (
2637
_taskId: string,
2738
_runId: string,
2839
artifacts: Array<{
2940
name: string;
3041
content: string;
42+
content_encoding?: string;
3143
}>,
3244
) => {
33-
const uploaded = artifacts.map((artifact, index) => {
34-
const storagePath = `${store.storagePath}-${store.manifest.length + index}-${artifact.name}`;
35-
store.artifacts[storagePath] = artifact.content;
45+
const uploaded = artifacts.map((artifact) => {
46+
const storagePath = `${store.storagePath}-${nextId++}-${artifact.name}`;
47+
store.artifacts[storagePath] =
48+
!options?.legacyBase64AtRest && artifact.content_encoding === "base64"
49+
? Buffer.from(artifact.content, "base64")
50+
: Buffer.from(artifact.content, "utf-8");
3651
return { storage_path: storagePath };
3752
});
3853
for (const entry of uploaded) {
@@ -45,15 +60,64 @@ function createMockApi(store: BundleStore) {
4560
_runId: string,
4661
artifactPath: string,
4762
) => {
48-
const contentBase64 = store.artifacts[artifactPath];
49-
if (!contentBase64) return null;
50-
const buffer = Buffer.from(contentBase64, "utf-8");
51-
return buffer.buffer.slice(
52-
buffer.byteOffset,
53-
buffer.byteOffset + buffer.byteLength,
63+
const content = store.artifacts[artifactPath];
64+
if (!content) return null;
65+
return content.buffer.slice(
66+
content.byteOffset,
67+
content.byteOffset + content.byteLength,
5468
);
5569
},
5670
};
71+
72+
if (options?.directUploads) {
73+
api.prepareTaskArtifactUploads = async (
74+
_taskId: string,
75+
_runId: string,
76+
artifacts: Array<{ name: string; type: string; size: number }>,
77+
) =>
78+
artifacts.map((artifact) => {
79+
const storagePath = `${store.storagePath}-${nextId++}-${artifact.name}`;
80+
return {
81+
id: `prepared-${storagePath}`,
82+
name: artifact.name,
83+
type: artifact.type,
84+
size: artifact.size,
85+
storage_path: storagePath,
86+
expires_in: 300,
87+
presigned_post: {
88+
url: "https://object-storage.test/upload",
89+
fields: { key: storagePath },
90+
},
91+
};
92+
});
93+
api.finalizeTaskArtifactUploads = async (
94+
_taskId: string,
95+
_runId: string,
96+
artifacts: Array<{ name: string; type: string; storage_path: string }>,
97+
) => {
98+
const finalized = artifacts.map((artifact) => ({
99+
name: artifact.name,
100+
type: artifact.type,
101+
storage_path: artifact.storage_path,
102+
}));
103+
for (const entry of finalized) {
104+
store.manifest.push(entry);
105+
}
106+
return finalized;
107+
};
108+
}
109+
110+
return api;
111+
}
112+
113+
function stubPresignedUploadFetch(store: BundleStore): void {
114+
vi.stubGlobal("fetch", async (_url: string, init?: { body?: unknown }) => {
115+
const form = init?.body as FormData;
116+
const key = form.get("key") as string;
117+
const file = form.get("file") as Blob;
118+
store.artifacts[key] = Buffer.from(await file.arrayBuffer());
119+
return new Response(null, { status: 204 });
120+
});
57121
}
58122

59123
function createBundleStore(): BundleStore {
@@ -130,6 +194,7 @@ describe("HandoffCheckpointTracker", () => {
130194
const cleanups: Array<() => Promise<void>> = [];
131195

132196
afterEach(async () => {
197+
vi.unstubAllGlobals();
133198
await Promise.all(cleanups.splice(0).map((cleanup) => cleanup()));
134199
});
135200

@@ -166,4 +231,89 @@ describe("HandoffCheckpointTracker", () => {
166231
expect(status).toContain("?? untracked.txt");
167232
expect(localRepo.exists(".posthog/tmp")).toBe(false);
168233
});
234+
235+
it("round-trips a cloud capture without local git state via direct-to-storage uploads", async () => {
236+
const originRepo = await createTestRepo("handoff-origin");
237+
cleanups.push(originRepo.cleanup);
238+
await seedCloudRepo(originRepo);
239+
240+
const sandboxRepo = await cloneTestRepo(originRepo.path, "handoff-sandbox");
241+
cleanups.push(sandboxRepo.cleanup);
242+
const resumeRepo = await cloneTestRepo(originRepo.path, "handoff-resume");
243+
cleanups.push(resumeRepo.cleanup);
244+
245+
await makeCloudChanges(sandboxRepo);
246+
247+
const store = createBundleStore();
248+
const apiClient = createMockApi(store, { directUploads: true });
249+
stubPresignedUploadFetch(store);
250+
251+
const captureTracker = createTracker(sandboxRepo.path, apiClient);
252+
const checkpoint = await captureTracker.captureForHandoff();
253+
254+
expect(checkpoint).not.toBeNull();
255+
if (!checkpoint) return;
256+
expect(checkpoint.artifactPath).toBeDefined();
257+
expect(checkpoint.indexArtifactPath).toBeDefined();
258+
259+
// Direct uploads store raw bytes, not base64 text.
260+
const pack = store.artifacts[checkpoint.artifactPath as string];
261+
expect(pack.subarray(0, 4).toString("utf-8")).toBe("PACK");
262+
263+
const applyTracker = createTracker(resumeRepo.path, apiClient);
264+
await applyTracker.applyFromHandoff(checkpoint);
265+
266+
expect(await resumeRepo.git(["rev-parse", "HEAD"])).toBe(checkpoint.head);
267+
expect(await resumeRepo.readFile("committed.txt")).toBe("cloud commit\n");
268+
expect(await resumeRepo.readFile("tracked.txt")).toBe("staged change\n");
269+
expect(await resumeRepo.readFile("unstaged.txt")).toBe("unstaged change\n");
270+
expect(await resumeRepo.readFile("untracked.txt")).toBe("untracked\n");
271+
272+
const status = await resumeRepo.git(["status", "--porcelain"]);
273+
expect(status).toContain("M tracked.txt");
274+
expect(status).toContain(" M unstaged.txt");
275+
expect(status).toContain("?? untracked.txt");
276+
});
277+
278+
it("applies checkpoints whose artifacts are stored as legacy base64 text", async () => {
279+
const { cloudRepo, localRepo, localGitState } =
280+
await prepareHandoffRepos(cleanups);
281+
await makeCloudChanges(cloudRepo);
282+
283+
const store = createBundleStore();
284+
const apiClient = createMockApi(store, { legacyBase64AtRest: true });
285+
const captureTracker = createTracker(cloudRepo.path, apiClient);
286+
287+
const checkpoint = await captureTracker.captureForHandoff(localGitState);
288+
expect(checkpoint).not.toBeNull();
289+
if (!checkpoint) return;
290+
291+
// Sanity-check the fixture: artifacts at rest are base64 text, not raw bytes.
292+
const pack = store.artifacts[checkpoint.artifactPath as string];
293+
expect(pack.subarray(0, 4).toString("utf-8")).not.toBe("PACK");
294+
295+
const applyTracker = createTracker(localRepo.path, apiClient);
296+
await applyTracker.applyFromHandoff(checkpoint);
297+
298+
expect(await localRepo.git(["rev-parse", "HEAD"])).toBe(checkpoint.head);
299+
expect(await localRepo.readFile("committed.txt")).toBe("cloud commit\n");
300+
expect(await localRepo.readFile("tracked.txt")).toBe("staged change\n");
301+
});
302+
303+
it("decodes raw and legacy base64 artifact buffers", () => {
304+
const rawPack = Buffer.concat([
305+
Buffer.from("PACK"),
306+
Buffer.from([0, 0, 0, 2, 255, 1, 2, 3]),
307+
]);
308+
expect(decodeHandoffArtifact(rawPack)).toEqual(rawPack);
309+
310+
const rawIndex = Buffer.concat([
311+
Buffer.from("DIRC"),
312+
Buffer.from([0, 0, 0, 2, 255, 4, 5, 6]),
313+
]);
314+
expect(decodeHandoffArtifact(rawIndex)).toEqual(rawIndex);
315+
316+
const legacyBase64 = Buffer.from(rawPack.toString("base64"), "utf-8");
317+
expect(decodeHandoffArtifact(legacyBase64)).toEqual(rawPack);
318+
});
169319
});

0 commit comments

Comments
 (0)