From 5820d431970b1c7a70c4445a0e94e8d83355cc37 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Thu, 12 Nov 2026 05:24:17 -0600 Subject: [PATCH] fix(runner): relaunch a settled OpenCode Runner with its admitted artifact paths (#21076) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - OpenCode agent turns run in a remote Runner in a Daytona sandbox. The Runner saves its state, so a retry of the same run can start a new Runner from that saved state. > - The first launch of a run starts OpenCode from the admitted provider files under one folder in the sandbox. The new Runner replays that first launch and checks that its own launch profile names the same files. > - When a retry reconnects to a Runner that already stopped, Paperclip starts a new Runner. On that path, Paperclip did not restore the admitted provider files and gave the new Runner the provider pack folder instead. > - The new Runner then refused the replayed launch ("does not match the runner-owned qualified profile"). That used the last retry, and the task stayed blocked. > - This pull request restores the admitted provider files and gives the new Runner the same folder as the first launch. > - The benefit is that a retry after a server upgrade continues the same run instead of blocking the task. ## Linked Issues or Issue Description **What happened?** On staging, after an automatic upgrade, a run used all 3 attempts. The last attempt started a new Runner from its saved state. It failed in the drain step with "OpenCode run.prepare launch does not match the runner-owned qualified profile". The task was blocked and needed a person. **Expected behavior** The new Runner gets the same OpenCode launch files as the first launch of the run, and the run continues. **Steps to reproduce** 1. Start an OpenCode run on a remote sandbox with durable provider artifacts. 2. Let the server restart, so the retry reconnects with `reattach_remote_runner`. 3. The Runner is found settled, so a new Runner starts from the saved state. Its launch profile points at the provider pack folder, not at the admitted artifact folder. ## What Changed - `native-session-executor.ts`: when a reconnect relaunches a settled Runner and the run has admitted provider artifacts, restore those artifacts and prepare the same artifact folder as the first launch. Other reconnects (live adoption) do not change. Normal turn starts do not change. - `native-session-executor.test.ts`: the existing settled-relaunch case now also covers a run with admitted provider artifacts. It expects the restore and the admitted folder in the launch profile. ## Verification - Red: without the fix, the new test case fails (`restoreNativeProviderArtifacts` is not called, and the launch uses the provider pack folder). - Green: `npx vitest run src/services/native-runtime/native-session-executor.test.ts src/services/native-runtime/remote-opencode-artifacts.test.ts` passes 1417/1417. - `npx tsc --noEmit -p server/tsconfig.json` passes. ## Risks - Low risk. The change applies only when a reconnect must relaunch a settled OpenCode Runner and the run has admitted provider artifacts. - That relaunch now downloads the admitted artifacts from storage, as a cold continuation already does. This adds time only on that recovery path, not on a normal turn start. - The artifact folder check (same account and same digest) still applies, so a different sandbox account still fails closed. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - Claude Opus 5.5 (`claude-opus-5-5`), Claude Code agent with tool use (shell, code search, test execution). ## 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 - [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 - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 --- .../native-session-executor.test.ts | 29 +++++++++++++++++++ .../native-runtime/native-session-executor.ts | 10 ++++++- 2 files changed, 38 insertions(+), 1 deletion(-) 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 96d47b42c97..32025817354 100644 --- a/server/src/services/native-runtime/native-session-executor.test.ts +++ b/server/src/services/native-runtime/native-session-executor.test.ts @@ -3064,6 +3064,35 @@ describe("remote provider pack manifest", () => { ? { opencodeCommand: `/opt/paperclip-runner/provider-pack/${admitted.payload.artifacts.opencodeExecutable.path}`, opencodeCommandSha256: admitted.payload.artifacts.opencodeExecutable.sha256 } : { acpxSidecarSha256: (admitted.payload.artifacts as Record).acpxSidecar.sha256 }) }); + if (opencode) { + // Staging failure after an upgrade: the run first launched OpenCode + // from its admitted artifact root. The relaunched Runner replays that + // launch, so it must get the same root, not the provider pack root. + const launchRoot = `/home/runtime-account/.paperclip-runtime/paperclip-runner/provider-artifacts/${admitted.digest.slice(7)}`; + const artifactReceipt = { schema: "paperclip.native-provider-artifacts.v1" as const, artifactId: randomUUID(), + closureDigest: "sha256:" + "9".repeat(64), providerPackDigest: admitted.digest, manifest: admitted, launchRoot, accountUid: 1000 }; + const storageMode = vi.spyOn(harnessStorage, "durableNativeHarnessStorage").mockReturnValue({} as never); + const backupOwner = vi.spyOn(harnessStorage, "nativeHarnessBackupOwner").mockResolvedValue({} as never); + const restore = vi.spyOn(providerArtifactStorage, "restoreNativeProviderArtifacts").mockResolvedValue(undefined); + onTestFinished(() => { storageMode.mockRestore(); backupOwner.mockRestore(); restore.mockRestore(); }); + const artifactExecute = vi.fn(async (request: { args: string[] }) => request.args?.[1]?.startsWith("const os=require") + ? { exitCode: 0, timedOut: false, stderr: "", stdout: JSON.stringify({ home: "/home/runtime-account", uid: 1000 }) } + : settledExecute(request)); + await createRunnerdBackend({ ...recoveryInput, + runnerExecutionTarget: { ...settledTarget, runner: { execute: artifactExecute, + syncIn: vi.fn(async () => undefined), syncOut: vi.fn(async () => undefined) } } as never, + controllerPersistence: { bootId: "replacement-controller", runnerArtifact: artifact }, + providerPackAdmissionSource: { runId: remoteExecution.binding.runId, receipt: null, state: null, artifacts: artifactReceipt } }); + state.createBackend.mock.calls.at(-1)![1].codexTransportFactory!(); + expect(restore).toHaveBeenCalledWith(expect.objectContaining({ receipt: artifactReceipt })); + expect(state.createTransport.mock.calls.at(-1)![0]).toMatchObject({ providerPackAuthorityDigest: admitted.digest, + providerNodeCommand: `${launchRoot}/${admitted.payload.artifacts.nodeCommand.path}`, + opencodeProxyPath: `${launchRoot}/${admitted.payload.artifacts.opencodeProxy.path}`, + opencodeCommand: `${launchRoot}/${admitted.payload.artifacts.opencodeExecutable.path}` }); + storageMode.mockRestore(); + backupOwner.mockRestore(); + restore.mockRestore(); + } const recoveredBackend = await createRunnerdBackend(recoveryInput); state.createBackend.mock.calls.at(-1)![1].codexTransportFactory!(); const recovered = state.createTransport.mock.calls.at(-1)![0]; diff --git a/server/src/services/native-runtime/native-session-executor.ts b/server/src/services/native-runtime/native-session-executor.ts index 3368538bca0..28028a99062 100644 --- a/server/src/services/native-runtime/native-session-executor.ts +++ b/server/src/services/native-runtime/native-session-executor.ts @@ -16622,9 +16622,17 @@ async function createRunnerdBackendWithinSessionClaim( const prepareRemoteRunner = async (mode: "dial_wss" | "listen_ws") => { await prepareRemoteRunnerAssets(mode); + // A settled Runner relaunched from its durable state replays the run's + // first launch, whose OpenCode command lives under the admitted artifact + // root. Rebuild that same root; the pack root would fail the Runner's + // qualified-profile check. + const relaunchAdmittedArtifacts = relaunchesSettledRemoteRunner && providerArtifacts !== undefined; if (!artifactStorage || openCodeLaunchArtifacts || !remoteCommandRunner || !remoteTarget - || !expectedProviderPackManifest || liveProviderRecovery || legacyProviderContinuity) return; + || !expectedProviderPackManifest || (liveProviderRecovery && !relaunchAdmittedArtifacts) || legacyProviderContinuity) return; if (!remoteCommandRunner.syncIn || !remoteCommandRunner.syncOut) throw new Error("native_provider_artifacts_bulk_transport_required"); + if (relaunchAdmittedArtifacts && liveProviderRecovery) await restoreNativeProviderArtifacts({ db: input.db, storage: artifactStorage, + companyId: input.execution.binding.companyId, receipt: providerArtifacts!, targetRoot: restoredArtifactDirectory, + signal: providerArtifactsSignal() }); const bootstrap = activeRemoteProviderPackRoot ? { root: activeRemoteProviderPackRoot, manifest: expectedProviderPackManifest } : remoteNativeArtifacts?.providerPackInventory().find(pack => pack.immutable);