fix: preserve native target bindings and distinguish checkpoint status

Keep the execution target identity through workspace realization, restore its saved home for finalization, and distinguish warm setup reuse from replacement dependency recovery. Label direct file updates separately from agent checkpoints.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-09-07 14:38:28 -05:00
parent 6651964a4c
commit fc05cd88a8
14 changed files with 111 additions and 32 deletions

View File

@ -7,7 +7,10 @@ Set `PAPERCLIP_DEPLOYED_STACK_MANIFEST` to a JSON manifest matching
and `PAPERCLIP_DEPLOYED_STACK_EVIDENCE` to an absolute output directory.
The harness never launches a local server. It checks the deployed commit and
adapter inventory, exercises scoped file APIs, and starts real sandbox tasks
for every configured profile. Missing profiles fail the inventory gate.
for every configured profile, including cold/warm Git state and real timed saves.
The maintainer-approved matrix is Codex, Claude, OpenCode, and Pi in both runner
generations (11 CLI/ACP profiles). Only Cursor, Gemini, Grok, and Kimi are deferred
for this campaign. Missing required profiles fail the inventory gate.
Credentials are not recorded in Playwright reports. These API checks supplement
the required browser walkthrough, two real 180-second intervals, and recovery
scenarios; passing them alone is not staging acceptance.
@ -94,7 +97,9 @@ URL. Names derive from repository names, with stable workspace-ID suffixes on
collisions. Initial clones use existing Git credentials and starting-ref policy;
the primary clone also honors the task's configured branch. Warm starts never
reset branches, clean edits, or rerun completed setup. Added repositories are
prepared at the next startup; removed bindings retain saved work.
prepared at the next startup; removed bindings retain saved work. Replacement
sandboxes rerun project setup to restore excluded dependencies; an existing
warm checkout keeps both its completed setup and reusable caches.
A complete repository checkpoint includes Git objects, refs, HEAD and index,
tracked working files, and nonignored untracked files. It excludes dependencies
@ -110,7 +115,9 @@ directories are not supported by this checkpoint format.
The task, agent, project, and current-user pages expose a Files dialog using the
shared file tree and viewer. It supports uploads, folder creation, previews,
downloads, deletion, trash restore/purge, and sync state with the last save time.
downloads, deletion, trash restore/purge, and sync state with the last agent save
time. Direct file-operation timestamps are labeled separately as “Files updated”;
a delete, restore, or idempotent receipt is not presented as an agent checkpoint.
All routes start at
`/api/companies/:companyId/work-folders/:scope/:ownerId`:

View File

@ -27,7 +27,8 @@ export interface WorkFolderListing {
owner: WorkFolderOwner;
files: WorkFile[];
nextCursor: string | null;
lastSavedAt: string | null;
/** Last accepted file operation, including deletion, restore, and retries. */
lastOperationAt: string | null;
}
export interface WorkFolderSyncStatus {

View File

@ -47,6 +47,7 @@ async function buildSandboxTarget(input: {
snapshot: EffectiveExecutionCapabilities | null;
supportsSync: boolean;
config?: Record<string, unknown>;
leaseMetadata?: Record<string, unknown>;
// Reject the capability resolution to exercise the fail-closed error path.
rejectResolution?: boolean;
}) {
@ -82,7 +83,7 @@ async function buildSandboxTarget(input: {
adapterType: "codex_local",
environment: { id: "env-1", driver: "sandbox", config: { provider: "daytona" } },
leaseId: "lease-1",
leaseMetadata: { remoteCwd: "/work" },
leaseMetadata: { remoteCwd: "/work", ...input.leaseMetadata },
lease: { id: "lease-1", leasePolicy: "reuse_by_environment", metadata: { remoteCwd: "/work", marker: "preserved" } } as never,
environmentRuntime,
});
@ -285,6 +286,16 @@ describe("effective snapshot gates the sync decision", () => {
expect(target.remoteCwd).toBe("/work");
});
it("restores the host-bound sync home when reconstructing a target from its saved lease", async () => {
const { target, environmentRuntime } = await buildSandboxTarget({ snapshot: FULL_GRANT, supportsSync: true,
leaseMetadata: { workFolderHome: "/home/daytona" } });
expect(target.workFolderHome).toBe("/home/daytona");
await target.runner!.syncOut!([]);
expect(environmentRuntime.syncOut).toHaveBeenLastCalledWith(expect.objectContaining({
lease: expect.objectContaining({ metadata: expect.objectContaining({ remoteCwd: "/home/daytona" }) }),
}));
});
it("omits the native sync hooks when the snapshot removes a sync verb", async () => {
// The snapshot verified inbound sync but not outbound sync. The runner
// exposes the sync hooks both-or-neither, so it keeps the base64 fallback.

View File

@ -287,11 +287,14 @@ describe("environmentRunOrchestrator — realizeForRun", () => {
});
it("uses an in-place authoritative root on the adapter execution target", async () => {
mockResolveEnvironmentExecutionTarget.mockResolvedValue({
const executionTarget = {
kind: "remote",
transport: "sandbox",
remoteCwd: "/copied/workspace",
});
workFolderHome: undefined as string | undefined,
runner: { syncOut: async () => executionTarget.workFolderHome },
};
mockResolveEnvironmentExecutionTarget.mockResolvedValue(executionTarget);
const runtime = makeMockRuntime({
realizeWorkspace: vi.fn().mockResolvedValue({
cwd: "/app",
@ -323,6 +326,10 @@ describe("environmentRunOrchestrator — realizeForRun", () => {
outboundRestorePaths: [],
},
}));
expect(result.executionTarget).toBe(executionTarget);
if (result.executionTarget?.kind !== "remote" || result.executionTarget.transport !== "sandbox") throw new Error("Expected sandbox target");
result.executionTarget.workFolderHome = "/home/daytona";
await expect(result.executionTarget.runner!.syncOut!([])).resolves.toBe("/home/daytona");
});
it("realization failure: runtime.realizeWorkspace throws → EnvironmentRunError with code workspace_realization_failed", async () => {

View File

@ -37,11 +37,12 @@ describe("shared sandbox work-folder lifecycle", () => {
const source = path.join(root, name);
await exec("git", ["init", source]);
await fs.writeFile(path.join(source, "tracked"), "initial\n");
await fs.writeFile(path.join(source, ".gitignore"), "node_modules/\n");
await fs.symlink("tracked", path.join(source, "link"));
await exec("git", ["-C", source, "add", "."]);
await exec("git", ["-C", source, "-c", "user.name=Test", "-c", "user.email=test@example.com", "commit", "-m", "initial"]);
await db.insert(projectWorkspaces).values({ companyId, projectId, name, repoUrl: source, sourceType: "git_repo", isPrimary: name === "repo-one",
setupCommand: "printf 'initialized\\n' >> .setup-count" });
setupCommand: "mkdir -p node_modules/acceptance && printf ready > node_modules/acceptance/installed && printf 'initialized\\n' >> .setup-count" });
}
}, 60_000);
afterAll(async () => {
@ -87,15 +88,19 @@ describe("shared sandbox work-folder lifecycle", () => {
}, 120_000);
it("reuses clones and restores saved unpushed work, staged changes, and task files after losing the sandbox", async () => {
const repositoryTaskId = randomUUID();
await db.insert(issues).values({ id: repositoryTaskId, companyId, projectId, title: "Repository recovery", assigneeAgentId: agentId });
const task = { taskId: repositoryTaskId };
const home = path.join(root, "sandbox");
const leaseId = randomUUID();
const first = await prepare(home, leaseId);
const first = await prepare(home, leaseId, leaseId, null, task);
expect(first.home).toBe(home);
expect(first.manifest.repositories).toHaveLength(2);
expect(first.primaryRepo).toBe(path.join(home, "repos/repo-one"));
await fs.writeFile(path.join(home, "task/report.md"), "durable task file");
const repo = first.primaryRepo;
expect(await fs.readFile(path.join(repo, ".setup-count"), "utf8")).toBe("initialized\n");
await fs.writeFile(path.join(repo, "node_modules/acceptance/warm-cache"), "reusable");
await fs.writeFile(path.join(repo, "tracked"), "committed\n");
await exec("git", ["-C", repo, "add", "."]);
await exec("git", ["-C", repo, "-c", "user.name=Test", "-c", "user.email=test@example.com", "commit", "-m", "unpushed"]);
@ -105,13 +110,17 @@ describe("shared sandbox work-folder lifecycle", () => {
await fs.writeFile(path.join(repo, "tracked"), "unstaged\n");
await fs.writeFile(path.join(repo, "untracked"), "untracked\n");
await first.stop(); active.splice(active.indexOf(first), 1);
const warm = await prepare(home, randomUUID(), leaseId);
const warm = await prepare(home, randomUUID(), leaseId, null, task);
expect(await fs.readFile(path.join(repo, ".setup-count"), "utf8")).toBe("initialized\n");
expect(await fs.readFile(path.join(repo, "node_modules/acceptance/warm-cache"), "utf8")).toBe("reusable");
expect(await fs.readFile(path.join(repo, "tracked"), "utf8")).toBe("unstaged\n");
await warm.stop(); active.splice(active.indexOf(warm), 1);
await fs.rm(home, { recursive: true });
const restored = await prepare(path.join(root, "replacement"), randomUUID());
expect(await fs.readFile(path.join(restored.primaryRepo, ".setup-count"), "utf8")).toBe("initialized\n");
const replacementId = randomUUID();
const restored = await prepare(path.join(root, "replacement"), replacementId, replacementId, null, task);
expect(await fs.readFile(path.join(restored.primaryRepo, ".setup-count"), "utf8")).toBe("initialized\ninitialized\n");
expect(await fs.readFile(path.join(restored.primaryRepo, "node_modules/acceptance/installed"), "utf8")).toBe("ready");
await expect(fs.stat(path.join(restored.primaryRepo, "node_modules/acceptance/warm-cache"))).rejects.toMatchObject({ code: "ENOENT" });
expect(await fs.readFile(path.join(restored.home, "task/report.md"), "utf8")).toBe("durable task file");
expect((await exec("git", ["-C", restored.primaryRepo, "rev-parse", "HEAD"])).stdout.trim()).toBe(expectedHead);
expect((await exec("git", ["-C", restored.primaryRepo, "show", ":tracked"])).stdout).toBe("staged\n");

View File

@ -57,17 +57,17 @@ describe("durable work folders", () => {
expect(await textContent(f, "memory.md")).toBe("second");
await expect(svc.write(f, { ...first, body: Buffer.from("different") })).rejects.toMatchObject({ status: 409 });
});
it("retains the last accepted save time after all files are removed", async () => {
it("retains the last accepted operation time after all files are removed", async () => {
const f = await folder();
expect((await svc.list(f)).lastSavedAt).toBeNull();
expect((await svc.list(f)).lastOperationAt).toBeNull();
await svc.write(f, { path: "note", body: Buffer.from("saved"), operationId: "write" });
const saved = (await svc.list(f)).lastSavedAt;
const saved = (await svc.list(f)).lastOperationAt;
expect(saved).toEqual(expect.any(String));
await svc.remove(f, "note", "delete");
const listing = await svc.list(f);
expect(listing.files).toEqual([]);
expect(Date.parse(listing.lastSavedAt!)).toBeGreaterThanOrEqual(Date.parse(saved!));
expect((await svc.list(f, { trash: true })).lastSavedAt).toBe(listing.lastSavedAt);
expect(Date.parse(listing.lastOperationAt!)).toBeGreaterThanOrEqual(Date.parse(saved!));
expect((await svc.list(f, { trash: true })).lastOperationAt).toBe(listing.lastOperationAt);
});
it("retains a deleted copy after the same path is recreated", async () => {
const f = await folder();

View File

@ -5276,7 +5276,7 @@ const workFolderErrors = { 400: r.badRequest, 401: r.unauthorized, 404: r.notFou
registry.registerPath({ method: "get", path: workFolderPath, tags: ["work-folders"], summary: "List scoped sandbox files or recoverable trash",
description: "User files require the owning user or an authorized run acting for that user. Company access alone does not grant access.",
request: { params: workFolderParams, query: z.object({ trash: z.enum(["true", "false"]).optional(), cursor: z.uuid().optional(), limit: z.coerce.number().int().min(1).max(1000).optional() }) },
responses: { ...workFolderErrors, 200: r.ok(z.object({ id: z.uuid(), owner: workFolderParams, files: z.array(workFileResponse), nextCursor: z.string().nullable(), lastSavedAt: z.string().nullable() })) },
responses: { ...workFolderErrors, 200: r.ok(z.object({ id: z.uuid(), owner: workFolderParams, files: z.array(workFileResponse), nextCursor: z.string().nullable(), lastOperationAt: z.string().nullable() })) },
});
registry.registerPath({ method: "get", path: `${workFolderPath}/content`, tags: ["work-folders"], summary: "Download a scoped file",
request: { params: workFolderParams, query: z.object({ path: z.string() }) },

View File

@ -372,6 +372,8 @@ export async function resolveEnvironmentExecutionTarget(input: {
providerKey: parsed.config.provider,
shellCommand,
remoteCwd,
...(typeof input.leaseMetadata?.workFolderHome === "string"
? { workFolderHome: input.leaseMetadata.workFolderHome } : {}),
enableSandboxDuplexBridge,
runnerLifecyclePolicy:
parsed.config.runnerLifecycleMode === "warm"

View File

@ -543,13 +543,14 @@ export function environmentRunOrchestrator(
: [],
};
if (executionTarget) {
executionTarget = {
...executionTarget,
// Runner callbacks retain this host-owned target. Keep its identity so
// the later work-folder binding reaches native file-sync callbacks.
Object.assign(executionTarget, {
...(executionTarget.kind === "remote" && realizationMode === "in_place"
? { remoteCwd: authoritativeRoot }
: {}),
workspaceRealization: workspaceTargetMetadata,
} as AdapterExecutionTarget;
});
}
} catch (err) {
throw new EnvironmentRunError(

View File

@ -227,14 +227,12 @@ export async function prepareSandboxWorkFolders(input: {
const root = path.posix.join(paths.repos!, binding.name);
const probe = await target.runner!.execute({ command: "git", args: ["-C", root, "rev-parse", "--git-dir"], bypassSession: true, timeoutMs: 10_000 });
const freshCheckout = probe.exitCode !== 0;
let restoredCheckout = false;
if (freshCheckout) {
// Publish the checkout directory only after every restore object or
// clone step completes. An interrupted attempt cannot masquerade as a
// reusable checkout merely because it contains a .git directory.
const temporary = path.posix.join(staging, `repo-${binding.id}-${randomUUID()}`);
const restored = await repositories.restore(binding, temporary, staging);
restoredCheckout = restored;
if (!restored) {
const auth = await resolveGitAuth(workspace.repoUrl!);
const result = await target.runner!.execute({ command: "git", args: [...(auth?.configArgs ?? []), "clone", "--no-hardlinks",
@ -265,9 +263,10 @@ export async function prepareSandboxWorkFolders(input: {
}
await transport.moveRoot(temporary, root);
}
// A complete checkpoint already contains the setup's durable outputs.
// Replacing the sandbox must not repeat completed project setup.
if ((!binding.setupComplete || (freshCheckout && !restoredCheckout)) && workspace.setupCommand) {
// Warm checkouts retain completed setup. A replacement only restores
// durable repository files, so setup must recreate ignored dependencies
// and caches that are deliberately outside the checkpoint guarantee.
if ((!binding.setupComplete || freshCheckout) && workspace.setupCommand) {
const setup = await target.runner!.execute({ command: "sh", args: ["-c", workspace.setupCommand], cwd: root, bypassSession: true, timeoutMs: 300_000 });
if (setup.exitCode !== 0 || setup.timedOut) throw new Error(`Repository ${binding.name} setup failed`);
}

View File

@ -44,7 +44,7 @@ export function workFolderService(db: Db, storage: StorageProvider) {
const [saved] = await db.select({ at: max(workFileOperations.createdAt) }).from(workFileOperations)
.where(and(eq(workFileOperations.companyId, folder.companyId), eq(workFileOperations.folderId, folder.id)));
return { id: folder.id, owner: { companyId: folder.companyId, scope: folder.scope, ownerId: folder.ownerId },
lastSavedAt: saved?.at?.toISOString() ?? null,
lastOperationAt: saved?.at?.toISOString() ?? null,
files: rows.slice(0, limit).map(workFileDto), nextCursor: rows.length > limit ? rows[limit - 1]!.id : null };
}

View File

@ -8,16 +8,16 @@ export const workFoldersApi = {
async list(owner: WorkFolderOwner, trash = false) {
const files: WorkFile[] = [];
let cursor: string | null = null;
let lastSavedAt: string | null = null;
let lastOperationAt: string | null = null;
do {
const query = new URLSearchParams({ trash: String(trash), limit: "1000", ...(cursor ? { cursor } : {}) });
const page: WorkFolderListing = await api.get(`${base(owner)}?${query}`);
files.push(...page.files);
if (page.lastSavedAt && (!lastSavedAt || page.lastSavedAt > lastSavedAt)) lastSavedAt = page.lastSavedAt;
if (page.lastOperationAt && (!lastOperationAt || page.lastOperationAt > lastOperationAt)) lastOperationAt = page.lastOperationAt;
cursor = page.nextCursor;
} while (cursor && files.length < 100_000);
if (cursor) throw new Error("This folder is too large to display in one view");
return { files, lastSavedAt };
return { files, lastOperationAt };
},
upload: (owner: WorkFolderOwner, file: File, filePath: string, operationId: string) =>
api.putRaw(`${base(owner)}/content?${new URLSearchParams({ path: filePath })}`, file,

View File

@ -0,0 +1,38 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import type { WorkFolderSyncStatus } from "@paperclipai/shared";
import { TooltipProvider } from "@/components/ui/tooltip";
import { WorkFolderBrowser } from "./WorkFolderBrowser";
const owner = { companyId: "company", scope: "task" as const, ownerId: "task" };
const key = ["work-folders", owner.companyId, owner.scope, owner.ownerId];
function render(statuses: WorkFolderSyncStatus[], lastOperationAt: string | null) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity } } });
client.setQueryData([...key, "files", false], { files: [], lastOperationAt });
client.setQueryData([...key, "sync"], statuses);
return renderToStaticMarkup(<QueryClientProvider client={client}><TooltipProvider><WorkFolderBrowser owner={owner} /></TooltipProvider></QueryClientProvider>);
}
const checkpoint: WorkFolderSyncStatus = { runId: "run", state: "saved", active: false,
lastSavedAt: "2026-09-07T12:00:00.000Z", error: null, refreshRequested: false };
describe("work folder save feedback", () => {
it("labels direct file operations separately from agent checkpoints", () => {
const html = render([checkpoint], "2026-09-07T12:01:00.000Z");
expect(html).toContain('role="status">Saved');
expect(html).toContain("Last agent save");
expect(html).toContain("Files updated");
const manualOnly = render([], "2026-09-07T12:01:00.000Z");
expect(manualOnly).toContain("Files updated");
expect(manualOnly).not.toContain("Last agent save");
});
it("keeps the last successful checkpoint visible during a failed or pending save", () => {
const failed = render([{ ...checkpoint, state: "failed", error: "Working copy retained" }], null);
expect(failed).toContain('role="status">Save failed');
expect(failed).toContain("Last agent save");
expect(failed).toContain("Working copy retained");
const saving = render([{ ...checkpoint, state: "saving", active: true }], null);
expect(saving).toContain('role="status">Saving…');
expect(saving).toContain("Last agent save");
});
});

View File

@ -71,8 +71,10 @@ export function WorkFolderBrowser({ owner, exampleFiles }: { owner: WorkFolderOw
const statuses = syncQuery.data ?? [];
const failed = statuses.find((status) => status.state === "failed");
const saving = mutation.isPending || statuses.some((status) => status.state === "saving");
const lastSaved = [...statuses.map((status) => status.lastSavedAt), filesQuery.data?.lastSavedAt]
const lastSaved = statuses.map((status) => status.lastSavedAt)
.filter((value): value is string => Boolean(value)).sort().at(-1);
const lastOperation = filesQuery.data?.lastOperationAt;
const saveFailed = Boolean(failed) || mutation.isError;
const disabled = mutation.isPending || Boolean(exampleFiles);
return <div className="flex min-h-0 flex-col gap-3">
<div className="flex flex-wrap items-center gap-2">
@ -83,7 +85,9 @@ export function WorkFolderBrowser({ owner, exampleFiles }: { owner: WorkFolderOw
}} />
<Button variant={trash ? "secondary" : "outline"} size="sm" onClick={() => { setTrash(!trash); setSelectedPath(null); }}><Trash2 aria-hidden />{trash ? "Back to files" : "Trash"}</Button>
<Button variant="outline" size="sm" disabled={disabled || !statuses.some((status) => status.active)} onClick={() => mutation.mutate({ type: "refresh" })}><RefreshCw aria-hidden />Refresh sandbox</Button>
<span className="text-xs text-muted-foreground" role="status">{saving ? "Saving…" : failed ? "Save failed" : lastSaved ? `Saved ${new Date(lastSaved).toLocaleTimeString()}` : "Saved files"}</span>
<span className="text-xs text-muted-foreground" role="status">{saving ? "Saving…" : saveFailed ? "Save failed" : "Saved"}</span>
{lastSaved && <span className="text-xs text-muted-foreground">Last agent save {new Date(lastSaved).toLocaleTimeString()}</span>}
{lastOperation && (!lastSaved || lastOperation > lastSaved) && <span className="text-xs text-muted-foreground">Files updated {new Date(lastOperation).toLocaleTimeString()}</span>}
</div>
{!trash && <div className="flex flex-wrap items-end gap-2">
<div className="flex-1 space-y-1"><Label htmlFor={directoryId}>Folder path</Label><Input id={directoryId} value={directory} onChange={(event) => setDirectory(event.target.value)} placeholder="Root folder" /></div>