diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index a918c61cdf..e5f6031d30 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -606,6 +606,8 @@ Paperclip applies one process-wide scheduler to expensive host-side workspace Gi The cache intentionally trades up to a few seconds of changed-file freshness for stable server latency. The file browser retains an explicit refresh action, does not start its query while the panel or browser tab is hidden, and presents overloads as retryable failures rather than an empty workspace. A full queue returns `503` with code `workspace_git_scan_saturated`; a scan exceeding its wall-clock limit returns `504` with code `workspace_git_scan_timeout`. Both responses include `Retry-After: 1`. +Sandbox Git sync treats only the selected repository root as a clone source. A selected subfolder uses directory sync within that folder, applies the enclosing repository's ignore rules, and does not transfer parent files or Git history. + Environment overrides: - `PAPERCLIP_WORKSPACE_GIT_SCAN_CONCURRENCY` (default `2`, range `1`–`16`) @@ -990,6 +992,8 @@ that classification finishes. Codex thread and goal state. Empty retry directories do not prevent recovery; conflicting histories, changed profiles, and live or unverifiable owners do. +A resumed sandbox lease can contain a workspace whose provider never started. A new attempt may create its exact session directory only when durable control-plane evidence proves zero connections, zero events, and untouched bootstrap commands, and no backup or remote session directory exists. Directory creation is atomic; partial state or uncertain ownership remains blocked. + Run the credential-free real-process restart suite with: ```sh diff --git a/packages/adapter-utils/src/git-workspace-sync.test.ts b/packages/adapter-utils/src/git-workspace-sync.test.ts index 31ac4590a8..4d94ecf45b 100644 --- a/packages/adapter-utils/src/git-workspace-sync.test.ts +++ b/packages/adapter-utils/src/git-workspace-sync.test.ts @@ -118,6 +118,19 @@ describe("git workspace sync", () => { return repo; } + it("does not classify a selected repository subfolder as a cloneable repository root", async () => { + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-git-selected-folder-")); + cleanupDirs.push(rootDir); + const repo = await createRepo(rootDir); + const selectedDir = path.join(repo, "project"); + await mkdir(selectedDir); + await writeFile(path.join(selectedDir, "draft.md"), "selected work\n"); + + expect(await git(selectedDir, ["rev-parse", "--is-inside-work-tree"])).toBe("true"); + expect(await readGitWorkspaceSnapshot(selectedDir)).toBeNull(); + expect((await readGitWorkspaceSnapshot(repo))?.headCommit).toBe(await git(repo, ["rev-parse", "HEAD"])); + }); + it("creates a shallow standalone clone from the local HEAD snapshot", async () => { const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-git-sync-")); cleanupDirs.push(rootDir); @@ -588,6 +601,27 @@ describe("git workspace sync", () => { await expect(readReferencedSourceGitIgnoredPaths(plainDir)).resolves.toBeNull(); }); + it.each([ + { code: "workspace_git_scan_failed", exitCode: 128, signal: null, nonGit: true }, + { code: "workspace_git_scan_timeout", exitCode: 128, signal: null, nonGit: false }, + { code: "workspace_git_scan_cancelled", exitCode: 128, signal: null, nonGit: false }, + { code: "workspace_git_scan_output_limit", exitCode: 128, signal: null, nonGit: false }, + { code: "workspace_git_scan_failed", exitCode: null, signal: "SIGTERM", nonGit: false }, + ])("classifies scheduled non-repository failures without swallowing $code/$signal", async ({ code, exitCode, signal, nonGit }) => { + const error = Object.assign(new Error("Workspace Git scan failed"), { + code, + details: { exitCode, signal, stderr: "fatal: not a git repository (or any of the parent directories): .git" }, + }); + setExpensiveWorkspaceGitExecutor(async () => { throw error; }); + try { + const result = readReferencedSourceGitIgnoredPaths("/plain-workspace"); + if (nonGit) await expect(result).resolves.toBeNull(); + else await expect(result).rejects.toBe(error); + } finally { + setExpensiveWorkspaceGitExecutor(null); + } + }); + it("reads the repository top level and the ignored paths of a Git work tree", async () => { const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-referenced-git-")); cleanupDirs.push(rootDir); diff --git a/packages/adapter-utils/src/git-workspace-sync.ts b/packages/adapter-utils/src/git-workspace-sync.ts index 0d80e76cd4..1b9297e7df 100644 --- a/packages/adapter-utils/src/git-workspace-sync.ts +++ b/packages/adapter-utils/src/git-workspace-sync.ts @@ -146,6 +146,19 @@ export async function readGitWorkspaceSnapshot(localDir: string): Promise : {}; + return error.code === "workspace_git_scan_failed" && details.exitCode === 128 && details.signal === null && + typeof details.stderr === "string" && /not a git repository/i.test(details.stderr); + } const stderr = error && typeof error === "object" && "stderr" in error ? String((error as { stderr: unknown }).stderr) : ""; const message = error instanceof Error ? error.message : String(error); return /not a git repository/i.test(stderr) || /not a git repository/i.test(message); diff --git a/packages/adapter-utils/src/sandbox-file-sync.test.ts b/packages/adapter-utils/src/sandbox-file-sync.test.ts index 7deadcda4c..99401f6905 100644 --- a/packages/adapter-utils/src/sandbox-file-sync.test.ts +++ b/packages/adapter-utils/src/sandbox-file-sync.test.ts @@ -92,6 +92,40 @@ describe("sandbox native file sync", () => { } }); + it("syncs a selected repository subfolder without parent files, history, or ignored files", async () => { + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-native-nested-workspace-")); + cleanupDirs.push(rootDir); + const repo = path.join(rootDir, "repo"); + const selectedDir = path.join(repo, "project"); + const remoteDir = path.join(rootDir, "remote"); + await mkdir(selectedDir, { recursive: true }); + await execFile("git", ["-C", repo, "init"]); + await writeFile(path.join(repo, "outside.txt"), "outside boundary\n"); + await writeFile(path.join(repo, ".gitignore"), "project/private.txt\n"); + await writeFile(path.join(selectedDir, "draft.md"), "preserved draft\n"); + await execFile("git", ["-C", repo, "add", "."]); + await execFile("git", ["-C", repo, "-c", "user.name=Test", "-c", "user.email=test@example.com", "commit", "-m", "base"]); + await writeFile(path.join(selectedDir, "private.txt"), "stay local\n"); + + const { client } = makeNativeClient(); + const prepared = await prepareSandboxManagedRuntime({ + spec: { transport: "sandbox", provider: "test", sandboxId: "s1", remoteCwd: remoteDir, timeoutMs: 30_000, apiKey: null }, + adapterKey: "test-adapter", + client, + workspaceLocalDir: selectedDir, + }); + + expect(await readFile(path.join(remoteDir, "draft.md"), "utf8")).toBe("preserved draft\n"); + for (const absent of [".git", "outside.txt", "project", "private.txt"]) { + await expect(lstat(path.join(remoteDir, absent))).rejects.toMatchObject({ code: "ENOENT" }); + } + await writeFile(path.join(remoteDir, "draft.md"), "continued draft\n"); + await prepared.restoreWorkspace(); + expect(await readFile(path.join(selectedDir, "draft.md"), "utf8")).toBe("continued draft\n"); + expect(await readFile(path.join(selectedDir, "private.txt"), "utf8")).toBe("stay local\n"); + expect(await readFile(path.join(repo, "outside.txt"), "utf8")).toBe("outside boundary\n"); + }); + it("prefers the native path for default-provision asset inbound and workspace outbound", async () => { const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-native-sync-")); cleanupDirs.push(rootDir); diff --git a/packages/adapter-utils/src/sandbox-managed-runtime.ts b/packages/adapter-utils/src/sandbox-managed-runtime.ts index 937c812ebf..4ca1ea9ed2 100644 --- a/packages/adapter-utils/src/sandbox-managed-runtime.ts +++ b/packages/adapter-utils/src/sandbox-managed-runtime.ts @@ -1127,7 +1127,17 @@ export async function prepareSandboxManagedRuntime(input: { readGitWorkspaceSnapshot(input.workspaceLocalDir), ) : null; - const gitIgnoredExcludes = gitSnapshot?.ignoredPaths; + // A selected subfolder has no cloneable Git snapshot, but its parent + // repository's ignore rules still govern which files may leave the host. + // Use the same bounded, path-relative resolver as referenced project trees. + const directoryIgnore = syncWorkspace && !gitSnapshot + ? await resolveReferencedSourceIgnore(input.workspaceLocalDir) + : null; + if (directoryIgnore?.kind === "failed") { + throw new Error(`Workspace ignore scan failed: ${directoryIgnore.reason}`); + } + const gitIgnoredExcludes = gitSnapshot?.ignoredPaths + ?? (directoryIgnore?.kind === "git" ? directoryIgnore.ignoredPaths : undefined); const workspaceArchiveExclude = mergeExcludes( SANDBOX_WORKSPACE_HEAVY_DIR_EXCLUDES, [...GIT_ARCHIVE_EXCLUDES], diff --git a/packages/plugins/sandbox-providers/daytona/README.md b/packages/plugins/sandbox-providers/daytona/README.md index facdc7526c..618f0df429 100644 --- a/packages/plugins/sandbox-providers/daytona/README.md +++ b/packages/plugins/sandbox-providers/daytona/README.md @@ -27,7 +27,7 @@ Notes: - The current published Daytona SDK package is `@daytonaio/sdk`. - The driver supports both `snapshot`-based and `image`-based sandbox creation. If both are set, validation rejects the config as ambiguous. -- Reusable leases map to Daytona stop/start semantics. Non-reusable leases are deleted on release. +- Reusable leases map to Daytona stop/start semantics. Non-reusable leases are deleted on release. A provider-resolved `target` does not change the identity of an existing sandbox. Release closes the same scoped lease that a later sentinel-verified resume reopens. ## Local development diff --git a/packages/plugins/sandbox-providers/daytona/src/plugin.test.ts b/packages/plugins/sandbox-providers/daytona/src/plugin.test.ts index 4a08bef53b..a97ed710b6 100644 --- a/packages/plugins/sandbox-providers/daytona/src/plugin.test.ts +++ b/packages/plugins/sandbox-providers/daytona/src/plugin.test.ts @@ -2551,6 +2551,21 @@ describe("Daytona sandbox provider plugin", () => { expect(mockGet).toHaveBeenCalledTimes(3); }); + it("keeps account credentials and API endpoints isolated for the same sandbox ID", async () => { + mockGet.mockImplementation(async () => createMockSandbox({ id: "sandbox-account" })); + const params = execParams("sandbox-account"); + for (const config of [ + { apiKey: "account-a", apiUrl: "https://one.daytona.test/api" }, + { apiKey: "account-b", apiUrl: "https://one.daytona.test/api" }, + { apiKey: "account-a", apiUrl: "https://two.daytona.test/api" }, + ]) { + await plugin.definition.onEnvironmentExecute!({ + ...params, config: { ...params.config, ...config }, + }); + } + expect(mockGet).toHaveBeenCalledTimes(3); + }); + it("rejects a queued execute after release teardown closes the lease", async () => { process.env.DAYTONA_API_KEY = "host-key"; mockGet.mockImplementation(async () => createMockSandbox({ id: "lease-a" })); @@ -3273,6 +3288,55 @@ describe("Daytona sandbox provider plugin", () => { expect(mockGet).toHaveBeenCalledTimes(2); }); + it("realizes a resumed lease after the provider fills in an unspecified target", async () => { + process.env.DAYTONA_API_KEY = "host-key"; + const sandbox = createMockSandbox({ id: "sandbox-default-target" }); + mockCreate.mockResolvedValue(sandbox); + mockGet.mockResolvedValue(sandbox); + const base = { driverKey: "daytona", companyId: "company-1", environmentId: "env-1" }; + const config = { image: "node:20", timeoutMs: 300000, reuseLease: true }; + const lease = await plugin.definition.onEnvironmentAcquireLease!({ + ...base, runId: "run-1", agentId: "agent-1", executionWorkspaceId: "workspace-1", config, + }); + // The host materializes provider metadata into later operation config, + // but resumes with the environment's original, target-less config. + const realizedConfig = { ...config, ...lease.metadata }; + expect(realizedConfig).toMatchObject({ target: "us" }); + await plugin.definition.onEnvironmentRealizeWorkspace!({ + ...base, config: realizedConfig, lease, + workspace: { remotePath: "/home/daytona/paperclip-workspace" }, + }); + expect(mockGet).not.toHaveBeenCalled(); + await plugin.definition.onEnvironmentReleaseLease!({ + ...base, config: realizedConfig, providerLeaseId: lease.providerLeaseId!, + }); + await expect(plugin.definition.onEnvironmentRealizeWorkspace!({ + ...base, config, lease, + workspace: { remotePath: "/home/daytona/paperclip-workspace" }, + })).rejects.toThrow(/no longer active/); + await expect(plugin.definition.onEnvironmentRealizeWorkspace!({ + ...base, config: realizedConfig, lease, + workspace: { remotePath: "/home/daytona/paperclip-workspace" }, + })).rejects.toThrow(/no longer active/); + + sandbox.state = "stopped"; + const sentinel = lease.metadata!.workspaceSentinel as { token: string }; + sandbox.process.executeCommand.mockResolvedValueOnce({ + exitCode: 0, result: JSON.stringify({ token: sentinel.token }), + artifacts: { stdout: JSON.stringify({ token: sentinel.token }) }, + }); + const resumed = await plugin.definition.onEnvironmentResumeLease!({ + ...base, config, providerLeaseId: lease.providerLeaseId!, leaseMetadata: lease.metadata, + }); + expect(resumed.metadata).toMatchObject({ + resumedLease: true, workspaceSentinel: { result: "matched" }, + }); + await expect(plugin.definition.onEnvironmentRealizeWorkspace!({ + ...base, config: { ...config, ...resumed.metadata }, lease: resumed, + workspace: { remotePath: "/home/daytona/paperclip-workspace" }, + })).resolves.toMatchObject({ cwd: "/home/daytona/paperclip-workspace" }); + }); + it("realizes the workspace from the acquire-seeded handle without a client.get", async () => { process.env.DAYTONA_API_KEY = "host-key"; const sandbox = createMockSandbox({ id: "sandbox-seed" }); diff --git a/packages/plugins/sandbox-providers/daytona/src/plugin.ts b/packages/plugins/sandbox-providers/daytona/src/plugin.ts index cc202abee1..2df234bf51 100644 --- a/packages/plugins/sandbox-providers/daytona/src/plugin.ts +++ b/packages/plugins/sandbox-providers/daytona/src/plugin.ts @@ -965,7 +965,9 @@ function sandboxAccountDiscriminator(config: DaytonaDriverConfig): string { return createHash("sha256") .update(stableStringify({ apiUrl: config.apiUrl, - target: config.target, + // Target is a creation placement hint, not account identity: the SDK + // resolves existing sandboxes by ID. Lease metadata fills an omitted + // target with the actual region, which must not split admission state. apiKey: resolvedApiKey, })) .digest("hex"); diff --git a/server/src/services/execution-projection.test.ts b/server/src/services/execution-projection.test.ts index 899dcff15f..2db13acc7a 100644 --- a/server/src/services/execution-projection.test.ts +++ b/server/src/services/execution-projection.test.ts @@ -42,6 +42,29 @@ describe("execution truth projection", () => { scheduledRetryAttempt: 12, contextSnapshot: { failureRetriesBeforeWorkspaceWait: 1 } }), undefined, [], undefined, now)) .toMatchObject({ label: "Waiting for workspace", phase: "retry_scheduled", attempt: 2, recoveryOwner: null }); }); + it.each([ + { status: "resolved", previousRunId: "old", nextRunId: "next", continued: true }, + { status: "active", previousRunId: "old", nextRunId: "next", continued: false }, + { status: "resolved", previousRunId: "other", nextRunId: "next", continued: false }, + { status: "resolved", previousRunId: "old", nextRunId: "old", continued: false }, + { status: "resolved", previousRunId: "old", nextRunId: null, continued: false }, + ])("projects the recorded explicit successor without hiding unresolved recovery: $status/$previousRunId/$nextRunId", ({ status, previousRunId, nextRunId, continued }) => { + const source = run({ id: "old", status: "failed", errorCode: "adapter_failed" }); + expect(projectExecution(source, coordinator({ phase: "terminal_failure" }), [], { + status, + cause: "native_continuation_requires_reconciliation", + nextAction: "Inspect the stopped execution.", + evidence: { + automaticRecovery: { policy: "preserve_without_replay_v1" }, + explicitUserContinuation: { previousRunId, runId: nextRunId }, + }, + }, now)).toMatchObject(continued ? { + phase: "completed", label: "Continued in another run", successorRunId: "next", + cause: "native_continuation_requires_reconciliation", nextAction: null, + } : { phase: "recovery_needed", successorRunId: null }); + expect(source.status).toBe("failed"); + }); + it("shows a reconciled continuation as queued until its durable delivery is recorded", () => { const action = { cause: "native_session_retry_exhausted", diff --git a/server/src/services/execution-projection.ts b/server/src/services/execution-projection.ts index ab870b35f8..24e60004a1 100644 --- a/server/src/services/execution-projection.ts +++ b/server/src/services/execution-projection.ts @@ -184,6 +184,16 @@ export function projectExecution( if (recoveryAction?.status === "resolved" && recoveryAction.evidence?.automaticRecovery) { projection.cause = recoveryAction.cause; projection.nextAction = recoveryAction.nextAction; + const continuation = recoveryAction.evidence.explicitUserContinuation as + { previousRunId?: unknown; runId?: unknown } | undefined; + const explicitSuccessor = text(continuation?.runId); + if (continuation?.previousRunId === run.id && explicitSuccessor && explicitSuccessor !== run.id) { + // The admission transaction already recorded the user's successor. Keep + // the old failure diagnostic without making it hold the newer attempt. + projection.successorRunId = explicitSuccessor; + projection.nextAction = null; + return set("completed", "Continued in another run"); + } // Diagnostic projection only: no user decision or replay affordance. return set("recovery_needed", "Stopped"); } diff --git a/server/src/services/native-runtime/native-session-executor.test.ts b/server/src/services/native-runtime/native-session-executor.test.ts index c916562c6f..2e48766402 100644 --- a/server/src/services/native-runtime/native-session-executor.test.ts +++ b/server/src/services/native-runtime/native-session-executor.test.ts @@ -9500,6 +9500,77 @@ describe("runnerd provider runtime wiring", () => { ); }); + it.each(["fresh", "existing_state", "symlink_parent", "wrong_identity", "connected", "pending_turn", "remote_probe_failed", "backup_present"])( + "bootstraps only an untouched provider session in a resumed workspace lease: %s", async (scenario) => { + const remoteCwd = join(isolatedStateDirectory, "remote"); + const runtimeRoot = join(remoteCwd, ".paperclip-runtime", "paperclip-runner"); + await mkdir(runtimeRoot, { recursive: true }); + const sessionRoot = join(runtimeRoot, "sessions", createHash("sha256").update(execution.session.normalizedSessionId!).digest("hex")); + if (scenario === "existing_state") await mkdir(sessionRoot, { recursive: true }); + if (scenario === "symlink_parent") await symlink(isolatedStateDirectory, join(runtimeRoot, "sessions")); + const remoteExecute = vi.fn(async (command: { command: string; args?: string[] }) => { + if (command.args?.[2] === "paperclip-runner-claim-unstarted-session") { + let exitCode = 1; + if (scenario !== "remote_probe_failed") { + try { execFileSync("sh", command.args, { stdio: "pipe" }); exitCode = 0; } catch {} + } + return { exitCode, timedOut: false, stdout: "", stderr: "" }; + } + if (command.args?.[0] === "--build-metadata") return { + exitCode: 0, timedOut: false, stdout: JSON.stringify({ + schema: "paperclip-runner/runnerd-build-metadata/v1", binaryName: "paperclip-runnerd", + packageName: "@paperclipai/paperclip-runner", binaryContractVersion: 2, + prpTransportModes: ["listen_ws"], + }), stderr: "", + }; + if (command.args?.[0] === "--version") return { + exitCode: 0, timedOut: false, stdout: "codex-cli 0.153.4", stderr: "", + }; + if (command.args?.[1]?.includes("base64")) return { + exitCode: 1, timedOut: false, stdout: "", stderr: "", + }; + return { exitCode: 0, timedOut: false, stdout: "", stderr: "" }; + }); + const backend = await createRunnerdBackend({ + db: leaseDb(execution), execution, runnerInstanceId: "runner-new-in-retained-workspace", + runnerIngressAuthorized: true, + runnerExecutionTarget: { + kind: "remote", transport: "sandbox", remoteCwd, environmentId: "environment", + leaseId: "lease-resumed", providerKey: "daytona", + effectiveCapabilities: { runnerWebSocketIngress: true }, + sandboxLeaseAcquisition: { outcome: "resumed", providerLeaseId: "sandbox-retained" }, + runner: { execute: remoteExecute }, + } as never, + }); + expect(backend).toBeDefined(); + state.createBackend.mock.calls.at(-1)![1].codexTransportFactory!(); + const options = state.createTransport.mock.calls.at(-1)![0] as RunnerTransportOptions & { + prepareExternalRunnerState: () => Promise; + }; + await mkdir(join(options.stateDirectory!, "control-plane"), { recursive: true }); + await writeFile(join(options.stateDirectory!, "control-plane", "control-plane-state.json"), JSON.stringify({ + schema: "paperclip.runner.durable.control-plane-state.v1", + identity: { ...options.prpIdentity, ...(scenario === "wrong_identity" ? { runId: "other-run" } : {}) }, + connectionCount: scenario === "connected" ? 1 : 0, committedEvents: [], + commands: [{ type: "run.prepare", status: "pending" }, { type: scenario === "pending_turn" ? "turn.start" : "session.open", status: "pending" }], + })); + if (scenario === "backup_present") { + await mkdir(join(options.stateDirectory!, "failover-backups", "current"), { recursive: true }); + await writeFile(join(options.stateDirectory!, "failover-backups", "current", "manifest.json"), "{}"); + } + if (scenario === "fresh") { + await expect(options.prepareExternalRunnerState()).resolves.toBeUndefined(); + expect(remoteExecute.mock.calls.some(([command]) => command.args?.[1]?.includes("install -d"))).toBe(true); + const claimCommand = remoteExecute.mock.calls.find(([command]) => command.args?.[2] === "paperclip-runner-claim-unstarted-session")![0]; + expect((await lstat(sessionRoot)).mode & 0o777).toBe(0o700); + // The exact same claim cannot silently reopen an existing partial root. + expect(() => execFileSync("sh", claimCommand.args!, { stdio: "pipe" })).toThrow(); + } else { + await expect(options.prepareExternalRunnerState()).rejects.toThrow("runner_harness_state_mismatch"); + expect(remoteExecute.mock.calls.some(([command]) => command.args?.[1]?.includes("install -d"))).toBe(false); + } + }); + it("uses the image's shared Codex without uploading or installing artifacts", async () => { const syncIn = vi.fn(async () => undefined); const remoteExecute = vi.fn( diff --git a/server/src/services/native-runtime/native-session-executor.ts b/server/src/services/native-runtime/native-session-executor.ts index 4706037655..da85250004 100644 --- a/server/src/services/native-runtime/native-session-executor.ts +++ b/server/src/services/native-runtime/native-session-executor.ts @@ -10702,6 +10702,28 @@ async function createRunnerdBackendWithinSessionClaim( }; }; + const claimUntouchedSessionInResumedLease = async (): Promise => { + if (!remoteCommandRunner || !remoteRuntimeRoot || !remoteSessionRoot) return false; + const identity = readRunnerdDurableIdentity(root); + if (!durableIdentityMatchesExecution(identity, input.execution) || + identity?.runnerInstanceId !== effectiveRunnerInstanceId || + identity?.environmentLeaseId !== effectiveEnvironmentLeaseId || + !runnerdStateProvesIncompleteBootstrap(root)) return false; + // A reusable workspace may have failed before any harness was created. + // Claim this exact new session atomically under readable real directories. + // Missing files inside an existing session never authorize a fresh start. + const probe = await remoteCommandRunner.execute({ + command: "sh", + args: ["-c", + 'set -eu; umask 077; test -d "$1" && test ! -L "$1" && test -r "$1" && test -x "$1" || exit 1; if test ! -e "$2" && test ! -L "$2"; then mkdir -- "$2"; fi; test -d "$2" && test ! -L "$2" && test -r "$2" && test -x "$2" || exit 1; mkdir -- "$3"', + "paperclip-runner-claim-unstarted-session", remoteRuntimeRoot, + posix.dirname(remoteSessionRoot), remoteSessionRoot], + bypassSession: true, + timeoutMs: 10_000, + }); + return probe.exitCode === 0 && !probe.timedOut; + }; + const recordInPlaceHarnessReuse = async ( providerSessionIdentity: Record, startedAtMs = Date.now(), @@ -10958,12 +10980,16 @@ async function createRunnerdBackendWithinSessionClaim( !state.runnerState || !state.providerSessionIdentity ) { - throw new Error("runner_harness_state_mismatch"); + if (state.incompleteReason !== "unavailable" || backupAvailable || + !(await claimUntouchedSessionInResumedLease())) { + throw new Error("runner_harness_state_mismatch"); + } + } else { + await recordInPlaceHarnessReuse( + state.providerSessionIdentity, + reuseStartedAtMs, + ); } - await recordInPlaceHarnessReuse( - state.providerSessionIdentity, - reuseStartedAtMs, - ); } else if ( sandboxLeaseAcquisition?.outcome === "replacement" ) { diff --git a/ui/src/components/TaskChatThread.test.tsx b/ui/src/components/TaskChatThread.test.tsx index 2d76a00885..02b43edd05 100644 --- a/ui/src/components/TaskChatThread.test.tsx +++ b/ui/src/components/TaskChatThread.test.tsx @@ -1056,6 +1056,40 @@ describe("TaskChatThread runtime transcript selection", () => { expect(onRetryFailedRun).toHaveBeenCalledWith("native-failed"); }); + it.each([false, true])("keeps a later bootstrap failure actionable only after the old recovery has a successor: %s", async (continued) => { + const onRetryFailedRun = vi.fn(); + render( {}} issueStatus="blocked" + onRetryFailedRun={onRetryFailedRun} linkedRuns={[ + { + runId: "old-native", runtimeMode: "native", status: "failed", errorCode: "adapter_failed", + agentId: "agent-1", agentName: "Runner", adapterType: "paperclip_runner", + createdAt: "2026-08-25T18:00:00.000Z", startedAt: "2026-08-25T18:00:00.000Z", + finishedAt: "2026-08-25T18:00:02.000Z", + execution: { + phase: continued ? "completed" : "recovery_needed", label: continued ? "Continued in another run" : "Stopped", + cause: "native_continuation_requires_reconciliation", lastConfirmedActivityAt: null, + retryAt: null, attempt: 1, maxAttempts: 3, recoveryOwner: null, nextAction: null, + permittedActions: ["inspect_run"], predecessorRunId: null, successorRunId: continued ? "failed-bootstrap" : null, + }, + }, + { + runId: "failed-bootstrap", runtimeMode: "legacy", status: "failed", errorCode: "setup_failed", + agentId: "agent-1", agentName: "Runner", adapterType: "paperclip_runner", + createdAt: "2026-08-25T18:01:00.000Z", startedAt: "2026-08-25T18:01:00.000Z", + finishedAt: "2026-08-25T18:01:02.000Z", + }, + ]} />); + const retryButtons = Array.from(container.querySelectorAll('[data-testid="task-chat-run-failed-try-again"]')); + if (!continued) { + expect(retryButtons).toHaveLength(0); + return; + } + expect(retryButtons.length).toBeGreaterThan(0); + flushSync(() => retryButtons.at(-1)!.click()); + await Promise.resolve(); + expect(onRetryFailedRun).toHaveBeenCalledExactlyOnceWith("failed-bootstrap"); + }); + it("explains a native provider usage limit without exposing its error code", async () => { const onRetryFailedRun = vi.fn(); render(