From e99854249ce712da998d427cf1f4b98c5164a7cf Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Mon, 5 Oct 2026 16:43:15 -0700 Subject: [PATCH] fix(workspaces): restore rebased sandbox history against its starting snapshot (#15268) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip manages agents and preserves their work across runs. > - Sandbox execution restores Git history and files to the host workspace. > - An agent can rebase or amend its branch before it finishes. > - Restore currently treats the original and rewritten tips as concurrent work. > - That merge can conflict even when the host has not changed. > - This change uses the starting Git snapshot to accept rewritten history safely. ## Linked Issues or Issue Description **What happened?** A successful sandbox turn can end with `workspace_restore_failed` after the agent rebases and pushes its branch. The restore step merges the original host tip with the rewritten sandbox tip. This can recreate conflicts that the agent already resolved. **Expected behavior** Accept the rewritten history when the host still has the recorded starting branch and commit. Preserve concurrent host work through the existing merge and recovery paths. **Steps to reproduce** 1. Start a sandbox from a feature branch. 2. Rebase that branch onto an upstream commit that changes the same file. Resolve the conflict in the sandbox. 3. Restore the sandbox while the host remains on the original commit. 4. The old implementation attempts a conflicting Git merge and fails the run after the agent finishes. **Paperclip version or commit** Reproduced on `cab4263dc9` with a real Git rebase fixture. **Deployment mode** Self-hosted server with sandbox execution. Related work: #15005 records restore failure stages. #11638 preserves unrelated imported history with a graft. #10601 handles bundle prerequisites. Those changes do not distinguish a rebase from a concurrent host edit. ## What Changed - Pass the run's starting Git branch and commit into sandbox history integration. - Adopt a related rewritten tip when the host still matches that snapshot. Keep the expected-old-value ref update and bounded retry. - Verify the branch attachment inside a prepared Git transaction while Git holds its ref locks. Abort if a checkout changed the branch. - Check host and sandbox Git identity before warm reuse, including nested repositories. Restage when their tips or branches differ. - Reject host branch changes and unrelated imports after a concurrent host commit. - Retain the existing unrelated-history graft for an unchanged host and the conservative behavior for callers without a snapshot. - Export a full bundle for an intentional reset to an ancestor so restore receives the actual sandbox tip. - Add real Git and sandbox restore regressions. Document the restore contract. ## Verification - Eight Git sync, sandbox restore, and native workspace suites pass: 255 tests. - The new checkout-race and warm-reuse regressions failed before the fixes. Real Git hooks verify that prepared transactions prevent a concurrent HEAD change. - `pnpm -r typecheck` passed after rebasing onto `984f092ddf` and applying the Apex findings. - `pnpm build` passed on `f5132603d6`. - The earlier `pnpm test:run` attempt reported three `company-skills-service.test.ts` failures on macOS (`EACCES` renaming a read-only staging directory). The same failures reproduced on unchanged master. The broader run was stopped after confirming that baseline failure; it was not a full-suite pass. Those source and test files are unchanged in the current base. - [Apex review](https://github.com/paperclipai/paperclip/pull/15268#issuecomment-6002549219): 5/5 on `f5132603d6`, requested with `@greptileai apex review`. All three historical findings are addressed and all review threads are resolved. - All CI gates passed on `f5132603d6` (53 successful checks, two skipped). The signoff-policy browser fixture initially timed out waiting for a local process-agent run; its one retry passed without code changes. [CI run](https://github.com/paperclipai/paperclip/actions/runs/37387511152) ## Risks - A recorded starting snapshot now authorizes replacement of related rewritten history. A stale or changed host tip retains the concurrent-history path. Ref writes still compare the expected old commit. - Branch changes and unrelated rewrites after host advancement require recovery instead of replacing host work. - An intentional reset to an ancestor uses a full bundle. Large histories can increase transfer time in that case. - The existing directory merge rules remain in force. The fix does not resolve an earlier failed restore or replay its external actions. ## Model Used OpenAI GPT-6 via Codex, with reasoning, repository analysis, code editing, and local test execution. The exact deployment model ID and context window were not exposed in this session. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass — 255 affected tests; the broader-suite baseline failure is documented above - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- doc/execution-semantics.md | 17 ++ .../src/git-workspace-sync.test.ts | 172 +++++++++++++++++- .../adapter-utils/src/git-workspace-sync.ts | 102 +++++++++-- .../src/sandbox-managed-runtime.test.ts | 87 +++++++++ .../src/sandbox-managed-runtime.ts | 1 + .../native-workspace-sync-history.test.ts | 122 +++++++++++++ .../native-runtime/native-workspace-sync.ts | 20 +- 7 files changed, 499 insertions(+), 22 deletions(-) create mode 100644 server/src/__tests__/native-workspace-sync-history.test.ts diff --git a/doc/execution-semantics.md b/doc/execution-semantics.md index 3e3cd076ba..f94909d6c8 100644 --- a/doc/execution-semantics.md +++ b/doc/execution-semantics.md @@ -452,6 +452,23 @@ Workspace incoherence feeds into the same non-terminal liveness and stranded ass For runtime-created `git_worktree` execution workspaces, branch coherence is part of workspace coherence. The persisted execution workspace branch is the recorded branch for future dispatch. Reusing that workspace must verify that the worktree is still registered and that `HEAD` is on the recorded branch. Successful run finalization must perform the same check before recording `workspace_finalize=succeeded`. If the run switched to a publishing/PR branch without updating the execution workspace record, finalization may auto-restore the recorded branch only when the worktree is clean, still registered, and the recorded branch points at the current `HEAD`; the repair is recorded as a workspace operation before the successful finalize row. If that safe repair cannot be proven, finalization records a failed workspace finalize and the run fails with bounded evidence for the expected and actual branch. A branch change is sanctioned when a control-plane path updates the execution workspace record before finalization, when publishing work happens in a separate worktree and the managed issue worktree remains on its recorded branch, or when the finalizer performs this clean same-commit restoration. +Sandbox Git restore uses the host branch and commit captured before staging. +If that identity is unchanged, a rebased or amended sandbox history with shared +ancestry replaces the starting tip instead of being merged with it. The ref +update checks the expected old commit; a concurrent change retries through the +normal history integration path. Git holds the HEAD and applicable branch locks +while restore verifies the attached/detached branch identity and commits the ref +transaction. A checkout during integration cannot redirect that write. +The directory merge still preserves host-only +file changes under its existing rules. A changed host branch requires recovery. +An intentional reset to an ancestor exports a full Git bundle so restore keeps +the actual sandbox tip; an empty delta is reserved for an unchanged tip. +Unrelated sandbox history keeps the existing history-preserving graft only when +the recorded host has not advanced; it must not replace concurrent host work. +Warm sandbox reuse must match the current host Git tip and branch as well as the +file snapshot and saved stamp, including managed nested repositories. A history +or branch mismatch restages the host before the next run begins. + ### Workspace scan failures before provider startup Repository discovery distinguishes an ordinary folder from a failed Git read. diff --git a/packages/adapter-utils/src/git-workspace-sync.test.ts b/packages/adapter-utils/src/git-workspace-sync.test.ts index b2231066fa..5af489d9a0 100644 --- a/packages/adapter-utils/src/git-workspace-sync.test.ts +++ b/packages/adapter-utils/src/git-workspace-sync.test.ts @@ -653,7 +653,9 @@ describe("git workspace sync", () => { const savedEnv = new Map(identityEnvKeys.map((key) => [key, process.env[key]])); for (const key of identityEnvKeys) delete process.env[key]; try { - await integrateImportedGitHead({ localDir: repo, importedHead }); + await integrateImportedGitHead({ + localDir: repo, importedHead, baseline: { headCommit: baseHead, branchName: "main" }, + }); } finally { for (const [key, value] of savedEnv) { if (value === undefined) delete process.env[key]; @@ -672,7 +674,161 @@ describe("git workspace sync", () => { expect(mergedTree).toContain("imported.txt"); }); - it("grafts an imported head onto the current head when histories share no ancestor", async () => { + describe("sandbox history rewrites", () => { + async function rewrittenHistory() { + const repo = await mkdtemp(path.join(os.tmpdir(), "paperclip-git-rewrite-")); + cleanupDirs.push(repo); + await git(repo, ["init"]); + await git(repo, ["checkout", "-b", "host"]); + await git(repo, ["config", "user.name", "Paperclip Test"]); + await git(repo, ["config", "user.email", "test@paperclip.dev"]); + await writeFile(path.join(repo, "tracked.txt"), "base\n"); + await git(repo, ["add", "."]); + await git(repo, ["commit", "-m", "base"]); + const ancestor = await git(repo, ["rev-parse", "HEAD"]); + await writeFile(path.join(repo, "tracked.txt"), "original change\n"); + await git(repo, ["commit", "-am", "original change"]); + const baseline = { headCommit: await git(repo, ["rev-parse", "HEAD"]), branchName: "host" }; + await git(repo, ["checkout", "-b", "sandbox"]); + await writeFile(path.join(repo, "tracked.txt"), "rewritten change\n"); + await git(repo, ["commit", "-am", "rewritten change", "--amend"]); + const importedHead = await git(repo, ["rev-parse", "HEAD"]); + await git(repo, ["checkout", "host"]); + return { repo, ancestor, baseline, importedHead }; + } + + it("accepts rewritten history when the host still matches the starting snapshot", async () => { + const { repo, baseline, importedHead } = await rewrittenHistory(); + // The old integration path attempts this conflicting merge even though + // the host has not changed since the run started. + await expect(git(repo, ["merge-tree", "--write-tree", baseline.headCommit, importedHead])) + .rejects.toMatchObject({ code: 1 }); + await writeFile(path.join(repo, "local.txt"), "local work\n"); + await git(repo, ["add", "local.txt"]); + const indexBefore = await git(repo, ["write-tree"]); + + await integrateImportedGitHead({ localDir: repo, importedHead, baseline }); + + expect(await git(repo, ["rev-parse", "HEAD"])).toBe(importedHead); + expect(await git(repo, ["symbolic-ref", "--short", "HEAD"])).toBe("host"); + expect(await git(repo, ["write-tree"])).toBe(indexBefore); + expect(await readFile(path.join(repo, "local.txt"), "utf8")).toBe("local work\n"); + }); + + it("restores an intentional reset to an ancestor when the host has not changed", async () => { + const { repo, ancestor, baseline } = await rewrittenHistory(); + await integrateImportedGitHead({ localDir: repo, importedHead: ancestor, baseline }); + expect(await git(repo, ["rev-parse", "HEAD"])).toBe(ancestor); + }); + + it("does not replace host commits made after the starting snapshot", async () => { + const { repo, baseline, importedHead } = await rewrittenHistory(); + await writeFile(path.join(repo, "local.txt"), "concurrent work\n"); + await git(repo, ["add", "local.txt"]); + await git(repo, ["commit", "-m", "host advanced"]); + const hostHead = await git(repo, ["rev-parse", "HEAD"]); + + await expect(integrateImportedGitHead({ localDir: repo, importedHead, baseline })) + .rejects.toThrow("Failed to merge concurrent remote git histories"); + + expect(await git(repo, ["rev-parse", "HEAD"])).toBe(hostHead); + expect(await readFile(path.join(repo, "local.txt"), "utf8")).toBe("concurrent work\n"); + }); + + it("does not replace a different host branch at the same starting commit", async () => { + const { repo, baseline, importedHead } = await rewrittenHistory(); + const tree = await git(repo, ["rev-parse", `${importedHead}^{tree}`]); + const fastForwardHead = await git(repo, ["commit-tree", tree, "-p", baseline.headCommit, "-m", "sandbox advance"]); + await git(repo, ["checkout", "-b", "other"]); + await expect(integrateImportedGitHead({ localDir: repo, importedHead: fastForwardHead, baseline })) + .rejects.toThrow("branch changed"); + expect(await git(repo, ["rev-parse", "HEAD"])).toBe(baseline.headCommit); + expect(await git(repo, ["rev-parse", "host"])).toBe(baseline.headCommit); + }); + + it("keeps the conservative merge behavior without a starting snapshot", async () => { + const { repo, baseline, importedHead } = await rewrittenHistory(); + await expect(integrateImportedGitHead({ localDir: repo, importedHead })) + .rejects.toThrow("Failed to merge concurrent remote git histories"); + expect(await git(repo, ["rev-parse", "HEAD"])).toBe(baseline.headCommit); + }); + + it("refuses unrelated rewritten history after a concurrent host commit", async () => { + const { repo, baseline, importedHead } = await rewrittenHistory(); + const tree = await git(repo, ["rev-parse", `${importedHead}^{tree}`]); + const unrelated = await git(repo, ["commit-tree", tree, "-m", "shallow rewrite"]); + await writeFile(path.join(repo, "local.txt"), "concurrent work\n"); + await git(repo, ["add", "local.txt"]); + await git(repo, ["commit", "-m", "host advanced"]); + const hostHead = await git(repo, ["rev-parse", "HEAD"]); + await expect(integrateImportedGitHead({ localDir: repo, importedHead: unrelated, baseline })) + .rejects.toThrow("Cannot restore unrelated remote history after the host advanced"); + expect(await git(repo, ["rev-parse", "HEAD"])).toBe(hostHead); + }); + + it("accepts a rewrite of an unchanged detached host without attaching a branch", async () => { + const { repo, baseline, importedHead } = await rewrittenHistory(); + await git(repo, ["checkout", "--detach"]); + await integrateImportedGitHead({ localDir: repo, importedHead, baseline: { ...baseline, branchName: null } }); + expect(await git(repo, ["rev-parse", "HEAD"])).toBe(importedHead); + await expect(git(repo, ["symbolic-ref", "--quiet", "HEAD"])).rejects.toMatchObject({ code: 1 }); + }); + + it.each([false, true])("rejects a checkout during integration (initially detached: %s)", async (detached) => { + const { repo, baseline, importedHead } = await rewrittenHistory(); + if (detached) await git(repo, ["checkout", "--detach"]); + const realGit = (await execFile("sh", ["-c", "command -v git"])).stdout.trim(); + const bin = path.join(repo, "test-bin"); + await mkdir(bin); + const quote = (value: string) => `'${value.replaceAll("'", "'\\''")}'`; + // Switch branches after the initial identity read, at the merge-base + // subprocess boundary. Both refs still point to the expected old OID. + await writeFile(path.join(bin, "git"), `#!/bin/sh +if [ "$3" = merge-base ]; then + ${quote(realGit)} -C "$2" checkout -B other ${baseline.headCommit} >/dev/null 2>&1 || exit 1 +fi +exec ${quote(realGit)} "$@" +`, { mode: 0o755 }); + const priorPath = process.env.PATH; + process.env.PATH = `${bin}${path.delimiter}${priorPath ?? ""}`; + try { + await expect(integrateImportedGitHead({ + localDir: repo, importedHead, baseline: { ...baseline, branchName: detached ? null : "host" }, + })).rejects.toThrow("branch changed"); + } finally { + if (priorPath === undefined) delete process.env.PATH; + else process.env.PATH = priorPath; + } + expect(await git(repo, ["symbolic-ref", "--short", "HEAD"])).toBe("other"); + expect(await git(repo, ["rev-parse", "host"])).toBe(baseline.headCommit); + expect(await git(repo, ["rev-parse", "other"])).toBe(baseline.headCommit); + expect(await stat(path.join(repo, ".git", "HEAD.lock")).catch(() => null)).toBeNull(); + }); + + it.each([false, true])("holds the HEAD lock through commit (initially detached: %s)", async (detached) => { + const { repo, baseline, importedHead } = await rewrittenHistory(); + await git(repo, ["branch", "other"]); + if (detached) await git(repo, ["checkout", "--detach"]); + await writeFile(path.join(repo, ".git", "hooks", "reference-transaction"), `#!/bin/sh +if [ "$1" = prepared ]; then + if git symbolic-ref HEAD refs/heads/other 2>/dev/null; then exit 1; fi + printf blocked > checkout-attempt.txt +fi +exit 0 +`, { mode: 0o755 }); + await integrateImportedGitHead({ + localDir: repo, importedHead, baseline: { ...baseline, branchName: detached ? null : "host" }, + }); + expect(await readFile(path.join(repo, "checkout-attempt.txt"), "utf8")).toBe("blocked"); + expect(await git(repo, ["rev-parse", "HEAD"])).toBe(importedHead); + expect(await git(repo, ["rev-parse", "other"])).toBe(baseline.headCommit); + if (detached) await expect(git(repo, ["symbolic-ref", "--quiet", "HEAD"])).rejects.toMatchObject({ code: 1 }); + else expect(await git(repo, ["symbolic-ref", "--short", "HEAD"])).toBe("host"); + expect(await stat(path.join(repo, ".git", "HEAD.lock")).catch(() => null)).toBeNull(); + }); + }); + + it.each([false, true])("grafts an unrelated imported head with an unchanged host (baseline supplied: %s)", async (withBaseline) => { const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-git-graft-")); cleanupDirs.push(rootDir); const setupIdentity = ["-c", "user.name=Setup", "-c", "user.email=setup@paperclip.dev"]; @@ -695,7 +851,10 @@ describe("git workspace sync", () => { const importedTree = await git(repo, ["rev-parse", `${baseHead}^{tree}`]); const importedHead = await git(repo, [...setupIdentity, "commit-tree", importedTree, "-m", "sandbox rewrite"]); - await integrateImportedGitHead({ localDir: repo, importedHead }); + await integrateImportedGitHead({ + localDir: repo, importedHead, + baseline: withBaseline ? { headCommit: currentHead, branchName: "main" } : undefined, + }); const parents = (await git(repo, ["rev-list", "--parents", "-1", "HEAD"])).split(" "); expect(parents.slice(1)).toEqual([currentHead]); @@ -707,7 +866,7 @@ describe("git workspace sync", () => { expect(body).toContain("shares no ancestor"); }); - it("does not graft when merge-base fails for a reason other than missing ancestry", async () => { + it.each([false, true])("does not graft on a merge-base error other than missing ancestry (baseline supplied: %s)", async (withBaseline) => { const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-git-no-graft-")); cleanupDirs.push(rootDir); const setupIdentity = ["-c", "user.name=Setup", "-c", "user.email=setup@paperclip.dev"]; @@ -727,7 +886,10 @@ describe("git workspace sync", () => { const expectedExit = await runLocalGit(repo, ["merge-tree", "--write-tree", currentHead, missingHead]) .catch((error: { code: number }) => error.code); const error = await withWorkspaceRestoreDiagnostics("workspace", () => withWorkspaceRestoreStep("git_integration", () => - integrateImportedGitHead({ localDir: repo, importedHead: missingHead }))).catch((error: unknown) => error); + integrateImportedGitHead({ + localDir: repo, importedHead: missingHead, + baseline: withBaseline ? { headCommit: currentHead, branchName: "main" } : undefined, + }))).catch((error: unknown) => error); expect(error).toBeInstanceOf(Error); expect((error as Error).message).toMatch(/Failed to merge concurrent remote git histories/); expect(error).not.toHaveProperty("cause"); diff --git a/packages/adapter-utils/src/git-workspace-sync.ts b/packages/adapter-utils/src/git-workspace-sync.ts index ce983289be..57bbbd06c4 100644 --- a/packages/adapter-utils/src/git-workspace-sync.ts +++ b/packages/adapter-utils/src/git-workspace-sync.ts @@ -774,6 +774,12 @@ export function buildRemoteGitDeltaBundleScript(input: { ` bundle_base=""`, "fi", ]), + // An empty bundle means "still at baseSha" to the importer. A reset to an + // older ancestor has no delta commits either, but must carry its new tip. + // Use the full-bundle path so Git advertises that tip instead of losing it. + `if [ -n "$bundle_base" ] && [ "$bundle_base" != ${baseSha} ] && [ "$bundle_base" = "$(git -C ${remoteDir} rev-parse HEAD)" ]; then`, + ` bundle_base=""`, + "fi", `if [ -n "$bundle_base" ]; then`, ` commit_count=$(git -C ${remoteDir} rev-list --count HEAD --not "$bundle_base")`, "else", @@ -849,9 +855,68 @@ export async function createUnrelatedHistoryGraftCommit(input: { return graftCommit.stdout.trim(); } +async function updateLocalGitHead(input: { + localDir: string; + newHead: string; + oldHead: string; + branchName: string | null; +}): Promise { + // Prepare the ref transaction before checking the symbolic HEAD identity. + // Git holds HEAD.lock (and the branch lock when attached) until commit/abort, + // so a checkout cannot redirect the write after this check. --no-deref also + // prevents a detached write from following a newly attached branch. + await new Promise((resolve, reject) => { + let identityError: unknown; + let prepared = false; + let output = ""; + const child = execFile("git", ["-C", input.localDir, "update-ref", "--stdin"], { + timeout: 15_000, + maxBuffer: 64 * 1024, + }, (error, stdout, stderr) => { + if (identityError) reject(identityError); + else if (error) reject(Object.assign(error, { stdout, stderr })); + else if (!stdout.includes("commit: ok\n")) reject(new Error("Git HEAD transaction did not commit.")); + else resolve(); + }); + // Early Git errors close stdin; the process callback reports the error. + child.stdin!.on("error", () => {}); + child.stdout!.on("data", (chunk: string | Buffer) => { + output += chunk.toString(); + if (prepared || !output.includes("prepare: ok\n")) return; + prepared = true; + void (async () => { + try { + const branchName = (await runLocalGit(input.localDir, ["symbolic-ref", "--quiet", "--short", "HEAD"], { + timeout: 10_000, + }).catch((error) => { + if (error.code === 1) return { stdout: "" }; + throw error; + })).stdout.trim() || null; + if (branchName !== input.branchName) { + throw new Error("Workspace branch changed while remote work was running."); + } + child.stdin!.end("commit\n"); + } catch (error) { + identityError = error; + child.stdin!.end("abort\n"); + } + })(); + }); + child.stdin!.write([ + "start", + ...(input.branchName === null ? ["option no-deref"] : []), + `update HEAD ${input.newHead} ${input.oldHead}`, + "prepare", + "", + ].join("\n")); + }); +} + export async function integrateImportedGitHead(input: { localDir: string; importedHead: string; + /** The host Git identity captured before staging this run. */ + baseline?: Pick; }): Promise { const isConcurrentRefUpdateError = (error: unknown) => { const message = error instanceof Error ? error.message : String(error); @@ -867,10 +932,12 @@ export async function integrateImportedGitHead(input: { })).stdout.trim() || null, }; + if (input.baseline && snapshot.branchName !== input.baseline.branchName) { + throw new Error("Workspace branch changed while remote work was running."); + } const currentHead = snapshot.headCommit; if (!currentHead || currentHead === input.importedHead) return; - const headRef = snapshot.branchName ? `refs/heads/${snapshot.branchName}` : "HEAD"; // `git merge-base` exits 1 when the commits share no ancestor — the only // outcome that authorizes the graft fallback below. Every other failure // (timeout, missing object, repository error) must keep failing the @@ -885,15 +952,15 @@ export async function integrateImportedGitHead(input: { }); const mergeBaseHead = mergeBase?.stdout.trim() ?? ""; - if (mergeBaseHead === input.importedHead) { - return; - } - - if (mergeBaseHead === currentHead) { + // A rebase/amend rewrites the sandbox tip without a concurrent host edit. + // Adopt that history when the host still matches the run's starting tip; + // merging the old and rewritten commits can reintroduce resolved conflicts. + // Keep the expected-old-value check: if the host advances during this write, + // retry against its new tip and use the normal concurrent-history path. + if (mergeBaseHead === currentHead || (mergeBaseHead && currentHead === input.baseline?.headCommit)) { try { - await runLocalGit(input.localDir, ["update-ref", headRef, input.importedHead, currentHead], { - timeout: 10_000, - maxBuffer: 16 * 1024, + await updateLocalGitHead({ + localDir: input.localDir, newHead: input.importedHead, oldHead: currentHead, branchName: snapshot.branchName, }); return; } catch (error) { @@ -902,7 +969,14 @@ export async function integrateImportedGitHead(input: { } } + if (mergeBaseHead === input.importedHead) { + return; + } + if (noCommonAncestor) { + if (input.baseline && currentHead !== input.baseline.headCommit) { + throw new Error("Cannot restore unrelated remote history after the host advanced."); + } // No common ancestor — merging is impossible and failing here would // discard the imported work. Graft it onto the current head instead; // see createUnrelatedHistoryGraftCommit. @@ -913,9 +987,8 @@ export async function integrateImportedGitHead(input: { syncLabel: "Paperclip remote git sync", }); try { - await runLocalGit(input.localDir, ["update-ref", headRef, graftCommit, currentHead], { - timeout: 10_000, - maxBuffer: 16 * 1024, + await updateLocalGitHead({ + localDir: input.localDir, newHead: graftCommit, oldHead: currentHead, branchName: snapshot.branchName, }); return; } catch (error) { @@ -960,9 +1033,8 @@ export async function integrateImportedGitHead(input: { }, ); try { - await runLocalGit(input.localDir, ["update-ref", headRef, mergeCommit.stdout.trim(), currentHead], { - timeout: 10_000, - maxBuffer: 16 * 1024, + await updateLocalGitHead({ + localDir: input.localDir, newHead: mergeCommit.stdout.trim(), oldHead: currentHead, branchName: snapshot.branchName, }); return; } catch (error) { diff --git a/packages/adapter-utils/src/sandbox-managed-runtime.test.ts b/packages/adapter-utils/src/sandbox-managed-runtime.test.ts index e84f86b514..bf5ab10e4d 100644 --- a/packages/adapter-utils/src/sandbox-managed-runtime.test.ts +++ b/packages/adapter-utils/src/sandbox-managed-runtime.test.ts @@ -838,6 +838,93 @@ describe("sandbox managed runtime", () => { }, ); + it("restores a sandbox rebase without merging it with unchanged host history", async () => { + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-sandbox-rebase-")); + cleanupDirs.push(rootDir); + const host = path.join(rootDir, "host"); + const remote = path.join(rootDir, "remote"); + await mkdir(host); + await git(host, ["init"]); + await git(host, ["checkout", "-b", "work"]); + await git(host, ["config", "user.name", "Paperclip Test"]); + await git(host, ["config", "user.email", "test@paperclip.dev"]); + await writeFile(path.join(host, "pins.txt"), "base\n"); + await writeFile(path.join(host, "notes.txt"), "original notes\n"); + await git(host, ["add", "."]); + await git(host, ["commit", "-m", "base"]); + const base = await git(host, ["rev-parse", "HEAD"]); + await writeFile(path.join(host, "pins.txt"), "feature\n"); + await git(host, ["commit", "-am", "feature"]); + const startingHead = await git(host, ["rev-parse", "HEAD"]); + await git(host, ["checkout", "-b", "upstream", base]); + await writeFile(path.join(host, "pins.txt"), "upstream\n"); + await git(host, ["commit", "-am", "upstream"]); + await git(host, ["checkout", "work"]); + + const prepared = await prepareSandboxManagedRuntime({ + spec: { transport: "sandbox", provider: "test", sandboxId: "rebase", remoteCwd: remote, timeoutMs: 30_000, apiKey: null }, + adapterKey: "test-adapter", + client: makeFilesystemClient(), + workspaceLocalDir: host, + }); + await git(remote, ["config", "user.name", "Paperclip Test"]); + await git(remote, ["config", "user.email", "test@paperclip.dev"]); + await git(remote, ["fetch", "--unshallow", host, "+refs/heads/*:refs/remotes/source/*"]); + await expect(git(remote, ["rebase", "refs/remotes/source/upstream"])).rejects.toMatchObject({ code: 1 }); + await writeFile(path.join(remote, "pins.txt"), "resolved pins\n"); + await git(remote, ["add", "pins.txt"]); + await git(remote, ["-c", "core.editor=true", "rebase", "--continue"]); + const rebasedHead = await git(remote, ["rev-parse", "HEAD"]); + expect(rebasedHead).not.toBe(startingHead); + await writeFile(path.join(host, "notes.txt"), "host edits during the run\n"); + await writeFile(path.join(host, "local-only.txt"), "local file\n"); + await writeFile(path.join(remote, "output.txt"), "sandbox output\n"); + + await prepared.restoreWorkspace(); + + expect(await git(host, ["rev-parse", "HEAD"])).toBe(rebasedHead); + expect(await git(host, ["symbolic-ref", "--short", "HEAD"])).toBe("work"); + expect(await readFile(path.join(host, "pins.txt"), "utf8")).toBe("resolved pins\n"); + expect(await readFile(path.join(host, "notes.txt"), "utf8")).toBe("host edits during the run\n"); + expect(await readFile(path.join(host, "local-only.txt"), "utf8")).toBe("local file\n"); + expect(await readFile(path.join(host, "output.txt"), "utf8")).toBe("sandbox output\n"); + expect(await git(host, ["diff", "--cached", "--name-only"])).toBe(""); + }); + + it("restores a sandbox reset to an ancestor with the matching clean working tree", async () => { + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-sandbox-reset-")); + cleanupDirs.push(rootDir); + const host = path.join(rootDir, "host"); + const remote = path.join(rootDir, "remote"); + await mkdir(host); + await git(host, ["init"]); + await git(host, ["checkout", "-b", "work"]); + await git(host, ["config", "user.name", "Paperclip Test"]); + await git(host, ["config", "user.email", "test@paperclip.dev"]); + await writeFile(path.join(host, "tracked.txt"), "original\n"); + await git(host, ["add", "."]); + await git(host, ["commit", "-m", "original"]); + const ancestor = await git(host, ["rev-parse", "HEAD"]); + await writeFile(path.join(host, "tracked.txt"), "change to discard\n"); + await git(host, ["commit", "-am", "later change"]); + + const prepared = await prepareSandboxManagedRuntime({ + spec: { transport: "sandbox", provider: "test", sandboxId: "reset", remoteCwd: remote, timeoutMs: 30_000, apiKey: null }, + adapterKey: "test-adapter", + client: makeFilesystemClient(), + workspaceLocalDir: host, + }); + await git(remote, ["fetch", "--unshallow", host, "work"]); + await git(remote, ["reset", "--hard", ancestor]); + + await prepared.restoreWorkspace(); + + expect(await git(host, ["rev-parse", "HEAD"])).toBe(ancestor); + expect(await git(host, ["symbolic-ref", "--short", "HEAD"])).toBe("work"); + expect(await readFile(path.join(host, "tracked.txt"), "utf8")).toBe("original\n"); + expect(await git(host, ["status", "--porcelain"])).toBe(""); + }); + it("syncs git-backed workspaces through a shallow standalone clone and keeps .git out of archives", async () => { const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-sandbox-git-")); cleanupDirs.push(rootDir); diff --git a/packages/adapter-utils/src/sandbox-managed-runtime.ts b/packages/adapter-utils/src/sandbox-managed-runtime.ts index abb5bdf4c3..9ea60e5147 100644 --- a/packages/adapter-utils/src/sandbox-managed-runtime.ts +++ b/packages/adapter-utils/src/sandbox-managed-runtime.ts @@ -1937,6 +1937,7 @@ export async function prepareSandboxManagedRuntime(input: { await withWorkspaceRestoreStep("git_integration", () => integrateImportedGitHead({ localDir: input.workspaceLocalDir, importedHead: gitHeadToIntegrate, + baseline: gitSnapshot ?? undefined, })); } : undefined, diff --git a/server/src/__tests__/native-workspace-sync-history.test.ts b/server/src/__tests__/native-workspace-sync-history.test.ts new file mode 100644 index 0000000000..7ce201103a --- /dev/null +++ b/server/src/__tests__/native-workspace-sync-history.test.ts @@ -0,0 +1,122 @@ +import { execFile } from "node:child_process"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { heartbeatRuns, type Db } from "@paperclipai/db"; +import type { EnvironmentLease } from "@paperclipai/shared"; +import type { AdapterSandboxExecutionTarget } from "@paperclipai/adapter-utils/execution-target"; +import type { CommandManagedRuntimeRunner } from "@paperclipai/adapter-utils/command-managed-runtime"; +import { runLocalGit } from "@paperclipai/adapter-utils/git-workspace-sync"; +import { prepareNativeWorkspaceSync } from "../services/native-runtime/native-workspace-sync.js"; + +const runner: CommandManagedRuntimeRunner = { + execute: (input) => new Promise((resolve) => { + const child = execFile(input.command, input.args ?? [], { + cwd: input.cwd, env: { ...process.env, ...input.env }, + timeout: input.timeoutMs, maxBuffer: 16 * 1024 * 1024, + }, (error, stdout, stderr) => resolve({ + exitCode: error ? (typeof error.code === "number" ? error.code : 1) : 0, + signal: error?.signal ?? null, timedOut: error?.killed ?? false, stdout, stderr, + })); + child.stdin?.end(input.stdin ?? ""); + }), +}; + +async function git(cwd: string, ...args: string[]) { + return (await runLocalGit(cwd, args)).stdout.trim(); +} + +describe("native warm workspace Git history", () => { + const oldHome = process.env.PAPERCLIP_HOME; + const oldInstance = process.env.PAPERCLIP_INSTANCE_ID; + const roots: string[] = []; + afterEach(async () => { + if (oldHome === undefined) delete process.env.PAPERCLIP_HOME; + else process.env.PAPERCLIP_HOME = oldHome; + if (oldInstance === undefined) delete process.env.PAPERCLIP_INSTANCE_ID; + else process.env.PAPERCLIP_INSTANCE_ID = oldInstance; + await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true }))); + }); + + it.each(["unchanged", "host_commit", "host_branch", "remote_commit", "nested_commit"] as const)( + "checks Git identity before reusing a warm sandbox: %s", async (change) => { + const root = await mkdtemp(path.join(os.tmpdir(), "paperclip-warm-history-")); + roots.push(root); + process.env.PAPERCLIP_HOME = path.join(root, "home"); + process.env.PAPERCLIP_INSTANCE_ID = "test"; + const host = path.join(root, "host"); + const remote = path.join(root, "remote"); + async function initRepo(dir: string) { + await mkdir(dir, { recursive: true }); + await git(dir, "init", "-b", "work"); + await git(dir, "config", "user.name", "Test"); + await git(dir, "config", "user.email", "test@paperclip.dev"); + await writeFile(path.join(dir, "file.txt"), "unchanged contents\n"); + await git(dir, "add", "."); + await git(dir, "commit", "-m", "base"); + } + await initRepo(host); + const nestedPath = path.join(".paperclip-repositories", "nested"); + if (change === "nested_commit") await initRepo(path.join(host, nestedPath)); + const lease = { id: "lease", providerLeaseId: "sandbox", metadata: {} } as EnvironmentLease; + let runnerProfileJson: Record = {}; + // Persist the real service's descriptors and stamps; only the DB query + // plumbing is in memory. Git, staging, warm adoption, and restore are real. + const db = { + select: () => { + const query = { + from: () => query, where: () => query, for: () => query, + limit: async () => [{ runnerProfileJson, metadata: lease.metadata }], + }; + return query; + }, + update: (table: unknown) => ({ + set: (value: { runnerProfileJson?: Record; metadata?: Record }) => ({ + where: async () => { + if (table === heartbeatRuns) runnerProfileJson = value.runnerProfileJson!; + else lease.metadata = value.metadata!; + }, + }), + }), + transaction: async (fn: (tx: unknown) => Promise) => fn(db), + }; + const target: AdapterSandboxExecutionTarget = { + kind: "remote", transport: "sandbox", providerKey: "test", leaseId: lease.id, + remoteCwd: remote, runner, timeoutMs: 30_000, + sandboxLeaseAcquisition: { outcome: "created", providerLeaseId: "sandbox" }, + }; + const input = { db: db as unknown as Db, companyId: "company", workspaceId: "workspace", workspaceLocalDir: host, lease, target }; + const first = (await prepareNativeWorkspaceSync({ ...input, runId: "first" }))!; + expect(first.mode).toBe("host_current"); + await first.restoreWorkspace(); + await first.cleanup(); + + if (change === "host_commit" || change === "nested_commit") { + await git(change === "nested_commit" ? path.join(host, nestedPath) : host, "commit", "--allow-empty", "-m", "host only"); + } else if (change === "host_branch") { + await git(host, "checkout", "-b", "other"); + } else if (change === "remote_commit") { + await git(remote, "-c", "user.name=Test", "-c", "user.email=test@paperclip.dev", "commit", "--allow-empty", "-m", "remote only"); + } + const expectedHead = await git(host, "rev-parse", "HEAD"); + const expectedBranch = await git(host, "symbolic-ref", "--short", "HEAD"); + const nestedHead = change === "nested_commit" ? await git(path.join(host, nestedPath), "rev-parse", "HEAD") : null; + runnerProfileJson = {}; + const second = (await prepareNativeWorkspaceSync({ ...input, runId: "second", target: { + ...target, sandboxLeaseAcquisition: { outcome: "resumed", providerLeaseId: "sandbox" }, + } }))!; + try { + expect(second.mode).toBe(change === "unchanged" ? "adopt_remote" : "host_current"); + expect(await git(remote, "rev-parse", "HEAD")).toBe(expectedHead); + expect(await git(remote, "symbolic-ref", "--short", "HEAD")).toBe(expectedBranch); + await second.restoreWorkspace(); + expect(await git(host, "rev-parse", "HEAD")).toBe(expectedHead); + if (nestedHead) { + expect(await git(path.join(remote, nestedPath), "rev-parse", "HEAD")).toBe(nestedHead); + expect(await git(path.join(host, nestedPath), "rev-parse", "HEAD")).toBe(nestedHead); + } + } finally { await second.cleanup(); } + }, 30_000, + ); +}); diff --git a/server/src/services/native-runtime/native-workspace-sync.ts b/server/src/services/native-runtime/native-workspace-sync.ts index 9953d1473f..b6e1aeaf9c 100644 --- a/server/src/services/native-runtime/native-workspace-sync.ts +++ b/server/src/services/native-runtime/native-workspace-sync.ts @@ -680,6 +680,7 @@ async function writeRemoteStamp(input: { async function remoteStampMatches(input: { target: Extract; expected: Record; + gitSnapshot?: GitWorkspaceSnapshot | null; }): Promise { if (!input.target.runner) return false; const stampPath = path.posix.join( @@ -688,11 +689,26 @@ async function remoteStampMatches(input: { "paperclip-runner", REMOTE_STAMP_NAME, ); + // File hashes do not detect empty commits or branch changes. A fresh host + // snapshot may authorize replacement only if the retained sandbox actually + // starts from that same Git identity, including each managed repository. + const gitChecks: string[] = []; + const checkGit = (remoteDir: string, snapshot: GitWorkspaceSnapshot) => { + const git = `git -C ${shellQuote(remoteDir)}`; + gitChecks.push(`test "$(${git} rev-parse HEAD)" = ${shellQuote(snapshot.headCommit)}`); + gitChecks.push(snapshot.branchName === null + ? `(${git} symbolic-ref --quiet HEAD >/dev/null 2>&1; test $? -eq 1)` + : `test "$(${git} symbolic-ref --quiet --short HEAD)" = ${shellQuote(snapshot.branchName)}`); + for (const repository of snapshot.repositories ?? []) { + checkGit(path.posix.join(remoteDir, repository.path), repository.snapshot); + } + }; + if (input.gitSnapshot) checkGit(input.target.remoteCwd, input.gitSnapshot); const result = await input.target.runner.execute({ command: input.target.shellCommand ?? "sh", args: [ "-c", - `test -f ${shellQuote(stampPath)} && cat ${shellQuote(stampPath)}`, + [...gitChecks, `test -f ${shellQuote(stampPath)}`, `cat ${shellQuote(stampPath)}`].join(" && "), ], cwd: "/", timeoutMs: 15_000, @@ -935,7 +951,7 @@ export async function prepareNativeWorkspaceSync(input: { ); const verifiedWarmAdoption = priorStamp.hostSha256 === currentHostSha256 && - (await remoteStampMatches({ target, expected: priorStamp })); + (await remoteStampMatches({ target, expected: priorStamp, gitSnapshot: currentSnapshot.gitSnapshot })); if (verifiedWarmAdoption) { mode = "adopt_remote"; } else {