From 74c0a84ebe90795f4f258b0fd0af778f77b2d8dd Mon Sep 17 00:00:00 2001 From: Alessandro Pogliaghi Date: Mon, 13 Jul 2026 15:28:55 +0100 Subject: [PATCH] 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 the branch's upstream tracking ref (which the apply side fetches before unpacking), falling back to the remote default branch that any fresh clone already contains, so packs only carry session-created objects - 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 (also used when finalize does not confirm the artifact) and sniff pack/index magic bytes on download so legacy base64-at-rest artifacts still apply - cap artifact and inline upload sizes with clear warnings, and discard the checkpoint instead of publishing one whose required artifacts never reached storage --- packages/agent/src/handoff-checkpoint.test.ts | 228 ++++++++++++++++-- packages/agent/src/handoff-checkpoint.ts | 210 +++++++++++++++- packages/agent/src/posthog-api.ts | 85 +++++++ packages/git/src/handoff.test.ts | 63 +++++ packages/git/src/handoff.ts | 54 ++++- 5 files changed, 611 insertions(+), 29 deletions(-) diff --git a/packages/agent/src/handoff-checkpoint.test.ts b/packages/agent/src/handoff-checkpoint.test.ts index 7c56a5e82a..9ad3f8cedf 100644 --- a/packages/agent/src/handoff-checkpoint.test.ts +++ b/packages/agent/src/handoff-checkpoint.test.ts @@ -1,5 +1,8 @@ -import { afterEach, describe, expect, it } from "vitest"; -import { HandoffCheckpointTracker } from "./handoff-checkpoint"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + decodeHandoffArtifact, + HandoffCheckpointTracker, +} from "./handoff-checkpoint"; import { cloneTestRepo, createTestRepo, @@ -8,7 +11,7 @@ import { import type { HandoffLocalGitState } from "./types"; interface BundleStore { - artifacts: Record; + artifacts: Record; storagePath: string; manifest: Array<{ storage_path: string }>; } @@ -20,19 +23,38 @@ interface HandoffRepos { localGitState: HandoffLocalGitState; } -function createMockApi(store: BundleStore) { - return { +interface MockApiOptions { + /** Store inline uploads as base64 text, like the backend did before content_encoding was sent. */ + legacyBase64AtRest?: boolean; + /** Expose the prepare/finalize direct-upload endpoints. */ + directUploads?: boolean; + /** Make inline uploads fail, like an API rejecting the request. */ + failInlineUploads?: boolean; + /** Make finalize omit the uploaded artifact from its response. */ + unconfirmedFinalize?: boolean; +} + +function createMockApi(store: BundleStore, options?: MockApiOptions) { + let nextId = 0; + const api: Record = { uploadTaskArtifacts: async ( _taskId: string, _runId: string, artifacts: Array<{ name: string; content: string; + content_encoding?: string; }>, ) => { - const uploaded = artifacts.map((artifact, index) => { - const storagePath = `${store.storagePath}-${store.manifest.length + index}-${artifact.name}`; - store.artifacts[storagePath] = artifact.content; + if (options?.failInlineUploads) { + throw new Error("Failed request: [413] Payload Too Large"); + } + const uploaded = artifacts.map((artifact) => { + const storagePath = `${store.storagePath}-${nextId++}-${artifact.name}`; + store.artifacts[storagePath] = + !options?.legacyBase64AtRest && artifact.content_encoding === "base64" + ? Buffer.from(artifact.content, "base64") + : Buffer.from(artifact.content, "utf-8"); return { storage_path: storagePath }; }); for (const entry of uploaded) { @@ -45,15 +67,67 @@ function createMockApi(store: BundleStore) { _runId: string, artifactPath: string, ) => { - const contentBase64 = store.artifacts[artifactPath]; - if (!contentBase64) return null; - const buffer = Buffer.from(contentBase64, "utf-8"); - return buffer.buffer.slice( - buffer.byteOffset, - buffer.byteOffset + buffer.byteLength, + const content = store.artifacts[artifactPath]; + if (!content) return null; + return content.buffer.slice( + content.byteOffset, + content.byteOffset + content.byteLength, ); }, }; + + if (options?.directUploads) { + api.prepareTaskArtifactUploads = async ( + _taskId: string, + _runId: string, + artifacts: Array<{ name: string; type: string; size: number }>, + ) => + artifacts.map((artifact) => { + const storagePath = `${store.storagePath}-${nextId++}-${artifact.name}`; + return { + id: `prepared-${storagePath}`, + name: artifact.name, + type: artifact.type, + size: artifact.size, + storage_path: storagePath, + expires_in: 300, + presigned_post: { + url: "https://object-storage.test/upload", + fields: { key: storagePath }, + }, + }; + }); + api.finalizeTaskArtifactUploads = async ( + _taskId: string, + _runId: string, + artifacts: Array<{ name: string; type: string; storage_path: string }>, + ) => { + if (options?.unconfirmedFinalize) { + return []; + } + const finalized = artifacts.map((artifact) => ({ + name: artifact.name, + type: artifact.type, + storage_path: artifact.storage_path, + })); + for (const entry of finalized) { + store.manifest.push(entry); + } + return finalized; + }; + } + + return api; +} + +function stubPresignedUploadFetch(store: BundleStore): void { + vi.stubGlobal("fetch", async (_url: string, init?: { body?: unknown }) => { + const form = init?.body as FormData; + const key = form.get("key") as string; + const file = form.get("file") as Blob; + store.artifacts[key] = Buffer.from(await file.arrayBuffer()); + return new Response(null, { status: 204 }); + }); } function createBundleStore(): BundleStore { @@ -130,6 +204,7 @@ describe("HandoffCheckpointTracker", () => { const cleanups: Array<() => Promise> = []; afterEach(async () => { + vi.unstubAllGlobals(); await Promise.all(cleanups.splice(0).map((cleanup) => cleanup())); }); @@ -166,4 +241,129 @@ describe("HandoffCheckpointTracker", () => { expect(status).toContain("?? untracked.txt"); expect(localRepo.exists(".posthog/tmp")).toBe(false); }); + + it("round-trips a cloud capture without local git state via direct-to-storage uploads", async () => { + const originRepo = await createTestRepo("handoff-origin"); + cleanups.push(originRepo.cleanup); + await seedCloudRepo(originRepo); + + const sandboxRepo = await cloneTestRepo(originRepo.path, "handoff-sandbox"); + cleanups.push(sandboxRepo.cleanup); + const resumeRepo = await cloneTestRepo(originRepo.path, "handoff-resume"); + cleanups.push(resumeRepo.cleanup); + + await makeCloudChanges(sandboxRepo); + + const store = createBundleStore(); + const apiClient = createMockApi(store, { directUploads: true }); + stubPresignedUploadFetch(store); + + const captureTracker = createTracker(sandboxRepo.path, apiClient); + const checkpoint = await captureTracker.captureForHandoff(); + + expect(checkpoint).not.toBeNull(); + if (!checkpoint) return; + expect(checkpoint.artifactPath).toBeDefined(); + expect(checkpoint.indexArtifactPath).toBeDefined(); + + // Direct uploads store raw bytes, not base64 text. + const pack = store.artifacts[checkpoint.artifactPath as string]; + expect(pack.subarray(0, 4).toString("utf-8")).toBe("PACK"); + + const applyTracker = createTracker(resumeRepo.path, apiClient); + await applyTracker.applyFromHandoff(checkpoint); + + expect(await resumeRepo.git(["rev-parse", "HEAD"])).toBe(checkpoint.head); + expect(await resumeRepo.readFile("committed.txt")).toBe("cloud commit\n"); + expect(await resumeRepo.readFile("tracked.txt")).toBe("staged change\n"); + expect(await resumeRepo.readFile("unstaged.txt")).toBe("unstaged change\n"); + expect(await resumeRepo.readFile("untracked.txt")).toBe("untracked\n"); + + const status = await resumeRepo.git(["status", "--porcelain"]); + expect(status).toContain("M tracked.txt"); + expect(status).toContain(" M unstaged.txt"); + expect(status).toContain("?? untracked.txt"); + }); + + it("applies checkpoints whose artifacts are stored as legacy base64 text", async () => { + const { cloudRepo, localRepo, localGitState } = + await prepareHandoffRepos(cleanups); + await makeCloudChanges(cloudRepo); + + const store = createBundleStore(); + const apiClient = createMockApi(store, { legacyBase64AtRest: true }); + const captureTracker = createTracker(cloudRepo.path, apiClient); + + const checkpoint = await captureTracker.captureForHandoff(localGitState); + expect(checkpoint).not.toBeNull(); + if (!checkpoint) return; + + // Sanity-check the fixture: artifacts at rest are base64 text, not raw bytes. + const pack = store.artifacts[checkpoint.artifactPath as string]; + expect(pack.subarray(0, 4).toString("utf-8")).not.toBe("PACK"); + + const applyTracker = createTracker(localRepo.path, apiClient); + await applyTracker.applyFromHandoff(checkpoint); + + expect(await localRepo.git(["rev-parse", "HEAD"])).toBe(checkpoint.head); + expect(await localRepo.readFile("committed.txt")).toBe("cloud commit\n"); + expect(await localRepo.readFile("tracked.txt")).toBe("staged change\n"); + }); + + it("returns null instead of a checkpoint when artifact uploads fail", async () => { + const { cloudRepo, localGitState } = await prepareHandoffRepos(cleanups); + await makeCloudChanges(cloudRepo); + + const store = createBundleStore(); + const apiClient = createMockApi(store, { failInlineUploads: true }); + const captureTracker = createTracker(cloudRepo.path, apiClient); + + const checkpoint = await captureTracker.captureForHandoff(localGitState); + + expect(checkpoint).toBeNull(); + }); + + it("falls back to inline upload when finalize does not confirm the artifact", async () => { + const { cloudRepo, localRepo, localGitState } = + await prepareHandoffRepos(cleanups); + await makeCloudChanges(cloudRepo); + + const store = createBundleStore(); + const apiClient = createMockApi(store, { + directUploads: true, + unconfirmedFinalize: true, + }); + stubPresignedUploadFetch(store); + const captureTracker = createTracker(cloudRepo.path, apiClient); + + const checkpoint = await captureTracker.captureForHandoff(localGitState); + + expect(checkpoint).not.toBeNull(); + if (!checkpoint) return; + // The unconfirmed direct upload must not be referenced; the checkpoint + // points at the inline upload, which stores decoded bytes. + const pack = store.artifacts[checkpoint.artifactPath as string]; + expect(pack.subarray(0, 4).toString("utf-8")).toBe("PACK"); + + const applyTracker = createTracker(localRepo.path, apiClient); + await applyTracker.applyFromHandoff(checkpoint); + expect(await localRepo.readFile("committed.txt")).toBe("cloud commit\n"); + }); + + it("decodes raw and legacy base64 artifact buffers", () => { + const rawPack = Buffer.concat([ + Buffer.from("PACK"), + Buffer.from([0, 0, 0, 2, 255, 1, 2, 3]), + ]); + expect(decodeHandoffArtifact(rawPack)).toEqual(rawPack); + + const rawIndex = Buffer.concat([ + Buffer.from("DIRC"), + Buffer.from([0, 0, 0, 2, 255, 4, 5, 6]), + ]); + expect(decodeHandoffArtifact(rawIndex)).toEqual(rawIndex); + + const legacyBase64 = Buffer.from(rawPack.toString("base64"), "utf-8"); + expect(decodeHandoffArtifact(legacyBase64)).toEqual(rawPack); + }); }); diff --git a/packages/agent/src/handoff-checkpoint.ts b/packages/agent/src/handoff-checkpoint.ts index e9ac6d9e10..3813841f24 100644 --- a/packages/agent/src/handoff-checkpoint.ts +++ b/packages/agent/src/handoff-checkpoint.ts @@ -6,10 +6,39 @@ import { type GitHandoffCheckpoint, GitHandoffTracker, } from "@posthog/git/handoff"; -import type { PostHogAPIClient } from "./posthog-api"; +import type { + PostHogAPIClient, + PreparedTaskArtifactUpload, +} from "./posthog-api"; import type { GitCheckpoint, HandoffLocalGitState } from "./types"; import { Logger } from "./utils/logger"; +/** Server-side cap on a single task-run artifact; larger files are skipped, not failed. */ +const MAX_ARTIFACT_UPLOAD_BYTES = 30 * 1024 * 1024; +/** Inline uploads travel base64-encoded inside a JSON API body, so they must stay well under API request size limits. */ +const MAX_INLINE_UPLOAD_BYTES = 10 * 1024 * 1024; + +const PACK_MAGIC = Buffer.from("PACK"); +const INDEX_MAGIC = Buffer.from("DIRC"); + +/** + * Handoff artifacts used to be stored as base64 text (inline uploads without + * content_encoding); direct-to-storage uploads store raw bytes. Detect raw + * git payloads by their magic bytes and fall back to the legacy base64 + * decode otherwise. + */ +export function decodeHandoffArtifact(buffer: Buffer): Buffer { + const head = buffer.subarray(0, 4); + if (head.equals(PACK_MAGIC) || head.equals(INDEX_MAGIC)) { + return buffer; + } + const text = buffer.toString("utf-8"); + if (/^[A-Za-z0-9+/]+={0,2}$/.test(text)) { + return Buffer.from(text, "base64"); + } + return buffer; +} + export interface HandoffCheckpointTrackerConfig { repositoryPath: string; taskId: string; @@ -93,6 +122,25 @@ export class HandoffCheckpointTracker { }, ]); + // A checkpoint that references artifacts which never made it to storage + // would make resume apply an incomplete git state; drop it instead. + const packUploadMissing = + !!capture.headPack && !uploads.pack?.storagePath; + const indexUploadMissing = !uploads.index?.storagePath; + if (packUploadMissing || indexUploadMissing) { + this.logger.warn( + "Discarding handoff checkpoint: required artifact uploads did not complete", + { + checkpointId: capture.checkpoint.checkpointId, + packUploadMissing, + indexUploadMissing, + packBytes: capture.headPack?.rawBytes ?? 0, + indexBytes: capture.indexFile.rawBytes, + }, + ); + return null; + } + this.logCaptureMetrics(capture.checkpoint, uploads); return { @@ -198,25 +246,166 @@ export class HandoffCheckpointTracker { } const content = await readFile(filePath); - const base64Content = content.toString("base64"); - const artifacts = await this.apiClient.uploadTaskArtifacts( + if (content.byteLength > MAX_ARTIFACT_UPLOAD_BYTES) { + this.logger.warn( + "Skipping handoff artifact upload: file exceeds the artifact size limit", + { + name, + rawBytes: content.byteLength, + maxBytes: MAX_ARTIFACT_UPLOAD_BYTES, + }, + ); + return { rawBytes: content.byteLength, wireBytes: 0 }; + } + + try { + const storagePath = await this.uploadArtifactDirect( + content, + name, + contentType, + ); + if (storagePath) { + return { + storagePath, + rawBytes: content.byteLength, + wireBytes: content.byteLength, + }; + } + } catch (error) { + this.logger.warn( + "Direct artifact upload failed; falling back to inline upload", + { name, error: error instanceof Error ? error.message : String(error) }, + ); + } + + return this.uploadArtifactInline(content, name, contentType); + } + + private async uploadArtifactDirect( + content: Buffer, + name: string, + contentType: string, + ): Promise { + if (!this.apiClient) { + return undefined; + } + + const [prepared] = await this.apiClient.prepareTaskArtifactUploads( this.taskId, this.runId, [ { name, type: "artifact", - content: base64Content, + size: content.byteLength, content_type: contentType, }, ], ); + if (!prepared) { + return undefined; + } - return { - storagePath: artifacts.at(-1)?.storage_path, - rawBytes: content.byteLength, - wireBytes: Buffer.byteLength(base64Content, "utf-8"), - }; + await this.postToPresignedUrl(prepared, content, contentType); + + const [finalized] = await this.apiClient.finalizeTaskArtifactUploads( + this.taskId, + this.runId, + [ + { + id: prepared.id, + name: prepared.name, + type: "artifact", + storage_path: prepared.storage_path, + content_type: contentType, + }, + ], + ); + // An unconfirmed finalize means the artifact was never attached to the + // run manifest; referencing it would break the download on resume. + if (!finalized?.storage_path) { + throw new Error( + `Artifact finalize did not confirm ${name} at ${prepared.storage_path}`, + ); + } + return finalized.storage_path; + } + + private async postToPresignedUrl( + prepared: PreparedTaskArtifactUpload, + content: Buffer, + contentType: string, + ): Promise { + const form = new FormData(); + for (const [key, value] of Object.entries(prepared.presigned_post.fields)) { + form.append(key, value); + } + form.append( + "file", + new Blob([new Uint8Array(content)], { type: contentType }), + prepared.name, + ); + + const response = await fetch(prepared.presigned_post.url, { + method: "POST", + body: form, + }); + if (!response.ok) { + throw new Error( + `Presigned artifact upload failed: [${response.status}] ${response.statusText}`, + ); + } + } + + private async uploadArtifactInline( + content: Buffer, + name: string, + contentType: string, + ): Promise { + if (!this.apiClient) { + return { rawBytes: content.byteLength, wireBytes: 0 }; + } + + if (content.byteLength > MAX_INLINE_UPLOAD_BYTES) { + this.logger.warn( + "Skipping inline handoff artifact upload: file exceeds the inline upload limit", + { + name, + rawBytes: content.byteLength, + maxBytes: MAX_INLINE_UPLOAD_BYTES, + }, + ); + return { rawBytes: content.byteLength, wireBytes: 0 }; + } + + const base64Content = content.toString("base64"); + try { + const artifacts = await this.apiClient.uploadTaskArtifacts( + this.taskId, + this.runId, + [ + { + name, + type: "artifact", + content: base64Content, + content_encoding: "base64", + content_type: contentType, + }, + ], + ); + return { + storagePath: artifacts.at(-1)?.storage_path, + rawBytes: content.byteLength, + wireBytes: Buffer.byteLength(base64Content, "utf-8"), + }; + } catch (error) { + this.logger.warn("Inline handoff artifact upload failed", { + name, + rawBytes: content.byteLength, + error: error instanceof Error ? error.message : String(error), + }); + return { rawBytes: content.byteLength, wireBytes: 0 }; + } } private async uploadArtifacts(specs: UploadArtifactSpec[]): Promise { @@ -257,8 +446,7 @@ export class HandoffCheckpointTracker { if (!arrayBuffer) { throw new Error(`Failed to download ${label} from ${artifactPath}`); } - const base64Content = Buffer.from(arrayBuffer).toString("utf-8"); - const binaryContent = Buffer.from(base64Content, "base64"); + const binaryContent = decodeHandoffArtifact(Buffer.from(arrayBuffer)); await writeFile(filePath, binaryContent); return { filePath, diff --git a/packages/agent/src/posthog-api.ts b/packages/agent/src/posthog-api.ts index fdeb360188..5ad0d1c1e5 100644 --- a/packages/agent/src/posthog-api.ts +++ b/packages/agent/src/posthog-api.ts @@ -25,6 +25,34 @@ export interface TaskArtifactUploadPayload { name: string; type: ArtifactType; content: string; + /** Encoding of `content`. With "base64" the backend stores the decoded bytes. */ + content_encoding?: "utf-8" | "base64"; + content_type?: string; +} + +export interface TaskArtifactPrepareUploadPayload { + name: string; + type: ArtifactType; + size: number; + content_type?: string; +} + +export interface PreparedTaskArtifactUpload { + id: string; + name: string; + type: ArtifactType; + size: number; + content_type?: string; + storage_path: string; + expires_in: number; + presigned_post: { url: string; fields: Record }; +} + +export interface TaskArtifactFinalizeUploadPayload { + id: string; + name: string; + type: ArtifactType; + storage_path: string; content_type?: string; } @@ -253,6 +281,63 @@ export class PostHogAPIClient { return manifest.slice(-artifacts.length); } + /** + * Reserve S3 keys and presigned POST forms so artifact bytes can be + * uploaded directly to object storage instead of traveling base64-encoded + * through the API (which enforces much smaller request body limits). + */ + async prepareTaskArtifactUploads( + taskId: string, + runId: string, + artifacts: TaskArtifactPrepareUploadPayload[], + ): Promise { + if (!artifacts.length) { + return []; + } + + const teamId = this.getTeamId(); + const response = await this.apiRequest<{ + artifacts: PreparedTaskArtifactUpload[]; + }>( + `/api/projects/${teamId}/tasks/${taskId}/runs/${runId}/artifacts/prepare_upload/`, + { + method: "POST", + body: JSON.stringify({ artifacts }), + }, + ); + return response.artifacts ?? []; + } + + /** Attach directly-uploaded artifacts (see prepareTaskArtifactUploads) to the run manifest. */ + async finalizeTaskArtifactUploads( + taskId: string, + runId: string, + artifacts: TaskArtifactFinalizeUploadPayload[], + ): Promise { + if (!artifacts.length) { + return []; + } + + const teamId = this.getTeamId(); + const response = await this.apiRequest<{ artifacts: TaskRunArtifact[] }>( + `/api/projects/${teamId}/tasks/${taskId}/runs/${runId}/artifacts/finalize_upload/`, + { + method: "POST", + body: JSON.stringify({ artifacts }), + }, + ); + + // The backend returns the full run artifact manifest; pick out the + // entries for this request (retried finalizes can land mid-manifest). + const manifest = response.artifacts ?? []; + const byStoragePath = new Map( + manifest.map((artifact) => [artifact.storage_path, artifact]), + ); + return artifacts + .map((artifact) => byStoragePath.get(artifact.storage_path)) + .filter((artifact): artifact is TaskRunArtifact => !!artifact); + } + /** Signal reports the given task is associated with (via report task associations). */ async getSignalReportIdsForTask(taskId: string): Promise { const teamId = this.getTeamId(); diff --git a/packages/git/src/handoff.test.ts b/packages/git/src/handoff.test.ts index 8db2a144cf..20f377b7e7 100644 --- a/packages/git/src/handoff.test.ts +++ b/packages/git/src/handoff.test.ts @@ -422,4 +422,67 @@ describe("GitHandoffTracker", () => { } }); }, 15000); + + it.each([ + ["the branch's upstream tracking ref", false], + ["the remote default branch when the branch has no upstream", true], + ])( + "packs against %s when no local git state is provided", + async (_label, useBranchWithoutUpstream) => { + const originRepo = await setupRepo(); + const sandboxRepo = await cloneRepo(originRepo); + try { + const sandboxGit = createGitClient(sandboxRepo); + const branch = ( + await sandboxGit.revparse(["--abbrev-ref", "HEAD"]) + ).trim(); + const baseCommit = ( + await sandboxGit.revparse([`origin/${branch}`]) + ).trim(); + const baseBlob = ( + await sandboxGit.revparse([`origin/${branch}:tracked.txt`]) + ).trim(); + + if (useBranchWithoutUpstream) { + await sandboxGit.checkout(["-b", "session-branch"]); + } + + await writeFile( + path.join(sandboxRepo, "committed.txt"), + "session commit\n", + ); + await sandboxGit.add(["committed.txt"]); + await sandboxGit.commit("Session commit"); + const sessionCommit = (await sandboxGit.revparse(["HEAD"])).trim(); + + const tracker = new GitHandoffTracker({ repositoryPath: sandboxRepo }); + const capture = await tracker.captureForHandoff(); + + try { + expect(capture.headPack).toBeDefined(); + const packPath = capture.headPack?.path as string; + await execFileAsync("git", ["index-pack", packPath], { + cwd: sandboxRepo, + }); + const idxPath = packPath.replace(/\.pack$/, ".idx"); + const { stdout } = await execFileAsync( + "git", + ["verify-pack", "-v", idxPath], + { cwd: sandboxRepo }, + ); + await rm(idxPath, { force: true }); + + expect(stdout).toContain(sessionCommit); + expect(stdout).not.toContain(baseCommit); + expect(stdout).not.toContain(baseBlob); + } finally { + await cleanupCapture(capture); + } + } finally { + await rm(sandboxRepo, { recursive: true, force: true }); + await rm(originRepo, { recursive: true, force: true }); + } + }, + 15000, + ); }); diff --git a/packages/git/src/handoff.ts b/packages/git/src/handoff.ts index 86d8a74ce2..81f2a2c538 100644 --- a/packages/git/src/handoff.ts +++ b/packages/git/src/handoff.ts @@ -104,22 +104,24 @@ export class GitHandoffTracker { checkpoint.checkpointId, ); - const packBaseline = localGitState?.upstreamHead ?? null; + const tracking = await getTrackingMetadata(git, checkpoint.branch); + const baselineRefs = localGitState?.upstreamHead + ? [localGitState.upstreamHead] + : await this.resolveDefaultPackBaseline(git, tracking); const packRefs = [ checkpoint.head, reconciledIndex.indexTree, checkpoint.worktreeTree, - packBaseline ? `^${packBaseline}` : null, + ...baselineRefs.map((ref) => `^${ref}`), ].filter((ref): ref is string => !!ref); const headRef = checkpoint.head ? `${HANDOFF_HEAD_REF_PREFIX}${checkpoint.checkpointId}` : undefined; const packPrefix = path.join(tempDir, checkpoint.checkpointId); - const [headPack, indexFile, tracking] = await Promise.all([ + const [headPack, indexFile] = await Promise.all([ this.captureObjectPack(packPrefix, packRefs), this.statFileArtifact(reconciledIndex.indexFilePath), - getTrackingMetadata(git, checkpoint.branch), ]); return { @@ -208,6 +210,50 @@ export class GitHandoffTracker { }; } + /** + * Without local handoff state the pack baseline must stay limited to + * objects the apply side is able to restore: the branch's upstream + * tracking ref (which ensureBaselineForApply fetches before unpacking), + * or failing that the remote's default branch, which any fresh clone of + * the repository already contains. Excluding broader refs (e.g. every + * remote-tracking ref) risks producing a pack that omits objects the + * resume repository cannot fetch. Without any baseline, a capture with no + * local handoff state packs the entire repo snapshot, which for large + * repos exceeds artifact upload limits. + */ + private async resolveDefaultPackBaseline( + git: GitClient, + tracking: GitTrackingMetadata, + ): Promise { + if (tracking.upstreamRemote && tracking.upstreamMergeRef) { + const branchName = tracking.upstreamMergeRef.replace( + /^refs\/heads\//, + "", + ); + const upstreamHead = await this.revparseOrNull( + git, + `refs/remotes/${tracking.upstreamRemote}/${branchName}`, + ); + if (upstreamHead) { + return [upstreamHead]; + } + } + + const defaultHead = await this.revparseOrNull( + git, + "refs/remotes/origin/HEAD", + ); + return defaultHead ? [defaultHead] : []; + } + + private async revparseOrNull( + git: GitClient, + ref: string, + ): Promise { + const value = await git.revparse([ref]).catch(() => null); + return value ? value.trim() : null; + } + private async captureObjectPack( packPrefix: string, refs: string[],