From d007afbf86687f3003c3d943cb3f866ef28de81b Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Sun, 8 Nov 2026 13:57:32 -0600 Subject: [PATCH] fix(native): let a follow-up wait out the previous turn's warm release instead of failing busy (#20068) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path - With a warm lifecycle, one session serves consecutive turns on a task. A follow-up comment wakes the next run, which claims the warm session's instruction copy and reuses the session. - #20048 makes warm the default on remote sandboxes. Its merge condition includes "no restart or follow-up ever leaves a task needing Retry". - In the #20048 Daytona chaos wave, task t12's follow-up run was created 43 ms after the previous run settled. The previous run was still releasing its warm session, so `claimWarmNativeInstructionCopy` saw it busy and failed the run with `native_session_supervisor_busy`. The task then needed a Retry. - The release takes seconds and always finishes, so a follow-up should wait for it rather than fail. A session busy because another run is preparing or executing a turn is a genuine conflict and must still fail at once, as existing tests require. ## Linked Issues or Issue Description Refs #20048 **What happened?** Wave w-20261108T142906-s9501, t12 (claude, warm): run 5b839da4 succeeded at 14:46:36.224. The follow-up run f2bf2f05 was created at 36.267 and failed immediately with `adapter_failed` / `native_session_supervisor_busy`, from `claimWarmNativeInstructionCopy` called by `instruction-working-copy.prepare`. Its automatic retry also failed, and only a user Retry completed the task. **Steps to reproduce** 1. Run a warm native session turn to completion. 2. While that run is still releasing its warm session (post-turn instruction check, release, hand-back), admit the next run on the same session. 3. The next run's instruction-copy claim throws `native_session_supervisor_busy`. **Expected behavior** The follow-up waits for the previous run's release (bounded) and reuses the session. A session held by a run that is still preparing or executing fails admission at once, as before. ## What Changed - `server/src/services/native-runtime/native-session-executor.ts`: - When the provider turn ends, the executor records the session scope as settling (`settlingNativeSessionScopes`). The outer `finally` clears the mark once the run has handed every scope back. - `claimWarmNativeInstructionCopy` waits, polling every 25 ms for at most 30 s (`warmSessionReleaseWait`), while the warm entry is busy and its scope is settling. It then re-reads the entry and runs the existing checks. If the entry is still busy at the deadline, or busy for any other reason, it throws `native_session_supervisor_busy` as before. - No wait happens on the normal path (entry idle), so turn admission adds no latency. - `server/src/services/native-runtime/native-session-executor.test.ts`: two cases. - A follow-up claimed while the previous turn's post-turn check is held: it waits, then succeeds once the release finishes. It fails on master with the wave's error. - The release outlasts the bound: it fails busy. **Previous-release state:** the change touches only in-memory admission state, so no saved run or session state changes. ## Verification - `cd server && npx vitest run src/services/native-runtime/native-session-executor.test.ts src/services/heartbeat/instruction-working-copy.test.ts`: 1301/1301 pass. - With the wait removed (behaviour on master), the "release finishes" case fails with `native_session_supervisor_busy`. - The existing concurrency tests ("fences owned instruction preparation: concurrent", and the settled-release ownership tests) still fail admission at once. - Not yet covered: the Daytona warm wave rerun for #20048. ## Risks - **A wait that never ends.** The wait is bounded at 30 s and happens only while the owner is past its turn, which always reaches the outer `finally`. Every caller of the inner executor runs inside that `try`, and attachment staging keeps the same session scope. - **Hiding a real conflict.** Only a settling owner is waited for. A preparing or executing owner fails at once, as the existing tests assert. - **Scope.** I tried a similar wait at execution admission, but it conflicted with deliberate fences that reject a run during a settled release (the existing "releases settled codex only under its current owner" tests). It is not part of this PR. ## Model Used Claude Opus 5.5 (`claude-opus-5-5`, Anthropic), 1M context window, tool use and code execution, via Claude Code. ## 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 | 132 +++++++++++++ .../native-runtime/native-session-executor.ts | 185 +++++++++++------- 2 files changed, 244 insertions(+), 73 deletions(-) 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 ce4ecb810bf..a7444964fc5 100644 --- a/server/src/services/native-runtime/native-session-executor.test.ts +++ b/server/src/services/native-runtime/native-session-executor.test.ts @@ -393,6 +393,7 @@ import { cancelNativeSession, closeWarmNativeSessionsForEnvironment, claimWarmNativeInstructionCopy, + warmSessionReleaseWait, nativeSessionWorkspaceScope, reserveWarmNativeInstructionDirectory, closeIdleWarmNativeSessionsForRestart, @@ -10460,6 +10461,137 @@ describe("native warm session supervision", () => { } }, 90_000); + it.each([["finishes", true], ["outlasts the wait", false]] as const)( + "a follow-up admitted while the previous turn's warm release is still running waits for it when the release %s", + async (_label, releaseFinishes) => { + const identity = `warm-release-wait-${releaseFinishes}`; + const warmExecution = { ...execution, binding: { ...execution.binding, runId: identity, executionWorkspaceId: `${identity}-workspace` }, + session: { ...execution.session, normalizedSessionId: identity, lifecyclePolicy: { mode: "warm" as const, idleTimeoutMs: 60_000 } }, + } as NativeExecutionInputV1; + const session = { close: vi.fn(async () => undefined) }; + let armPostTurnHold = false, holdPostTurnCheck = false; + state.execute.mockReset().mockImplementation(async (options) => { + await options.onSession?.(session); + // Only checks after the provider turn are held: that is the release window. + if (armPostTurnHold) holdPostTurnCheck = true; + return { result: { summary: "completed" }, terminal: { runTerminalState: "succeeded" }, + turnId: identity, normalizedSessionId: identity, providerSessionId: identity, + driverKind: "test", driverVersion: "1", nativeEventCount: 1, highestContiguousSourceSeq: 1, usage: null }; + }); + let releaseGate!: () => void, settling!: () => void; + const gate = new Promise(resolve => { releaseGate = resolve; }); + const reachedSettle = new Promise(resolve => { settling = resolve; }); + const copy = (runId: string, slow = false) => ({ runId, root: `/tmp/${identity}`, preparationKey: "same", + // The previous turn's post-turn check runs while it still holds the session. + hasChanges: async () => { if (slow && holdPostTurnCheck) { settling(); await gate; } return false; }, + collectStopped: async () => undefined }); + const original = { ...warmSessionReleaseWait }; + warmSessionReleaseWait.timeoutMs = releaseFinishes ? 5_000 : 200; + try { + await executePaperclipNativeSession({ db: leaseDb(warmExecution), execution: warmExecution, runnerInstanceId: identity, + runnerExecutionTarget: { kind: "remote", transport: "sandbox", environmentId: identity, remoteCwd: `/tmp/${identity}` }, + instructionWorkingCopy: copy(identity) }); + const second = { ...warmExecution, binding: { ...warmExecution.binding, runId: `${identity}-second` } }; + const secondCopy = copy(second.binding.runId, true); + expect(await claimWarmNativeInstructionCopy({ priorExecution: warmExecution, + companyId: second.binding.companyId, agentId: second.binding.agentId, + executionWorkspaceId: second.binding.executionWorkspaceId, workspace: second.workspace, + environmentId: identity, runId: second.binding.runId, preparationKey: "same", adopt: async () => secondCopy })).toBeTypeOf("function"); + armPostTurnHold = true; + const previous = executePaperclipNativeSession({ db: leaseDb(second), execution: second, runnerInstanceId: identity, + runnerExecutionTarget: { kind: "remote", transport: "sandbox", environmentId: identity, remoteCwd: `/tmp/${identity}` }, + instructionWorkingCopy: secondCopy }); + await reachedSettle; + // A follow-up wakes 43 ms after the turn settled, while its release runs. + const followUpRunId = `${identity}-follow-up`; + const followUp = claimWarmNativeInstructionCopy({ priorExecution: second, + companyId: second.binding.companyId, agentId: second.binding.agentId, + executionWorkspaceId: second.binding.executionWorkspaceId, workspace: second.workspace, + environmentId: identity, runId: followUpRunId, preparationKey: "same", adopt: async () => copy(followUpRunId) }); + if (releaseFinishes) { + setTimeout(releaseGate, 100); + const release = await followUp; + expect(release).toBeTypeOf("function"); + // The follow-up's own execution is admitted at once: the previous owner + // handed every scope back before the claim returned. + const followUpExecution = { ...second, binding: { ...second.binding, runId: followUpRunId } }; + await expect(executePaperclipNativeSession({ db: leaseDb(followUpExecution), execution: followUpExecution, + runnerInstanceId: identity, runnerExecutionTarget: { kind: "remote", transport: "sandbox", environmentId: identity, + remoteCwd: `/tmp/${identity}` }, instructionWorkingCopy: copy(followUpRunId) })).resolves.toBeDefined(); + await previous; + await release!(); + } else { + await expect(followUp).rejects.toThrow("native_session_supervisor_busy"); + releaseGate(); + } + await previous; + } finally { + Object.assign(warmSessionReleaseWait, original); + releaseGate(); + await closeWarmNativeSessionsForEnvironment({ environmentId: identity, reason: "test cleanup" }); + } + }); + + it("admits the follow-up's execution only after the previous owner's final cleanup hands its scopes back", async () => { + const identity = "warm-release-scope-handback"; + const warmExecution = { ...execution, binding: { ...execution.binding, runId: identity, executionWorkspaceId: `${identity}-workspace` }, + session: { ...execution.session, normalizedSessionId: identity, lifecyclePolicy: { mode: "warm" as const, idleTimeoutMs: 60_000 } }, + } as NativeExecutionInputV1; + const session = { close: vi.fn(async () => undefined) }; + let turnDone = false; + state.execute.mockReset().mockImplementation(async (options) => { + await options.onSession?.(session); + turnDone = true; + return { result: { summary: "completed" }, terminal: { runTerminalState: "succeeded" }, + turnId: identity, normalizedSessionId: identity, providerSessionId: identity, + driverKind: "test", driverVersion: "1", nativeEventCount: 1, highestContiguousSourceSeq: 1, usage: null }; + }); + const copy = (runId: string) => ({ runId, root: `/tmp/${identity}`, preparationKey: "same", hasChanges: async () => false, + collectStopped: async () => undefined }); + const target = { kind: "remote" as const, transport: "sandbox" as const, environmentId: identity, remoteCwd: `/tmp/${identity}` }; + try { + await executePaperclipNativeSession({ db: leaseDb(warmExecution), execution: warmExecution, runnerInstanceId: identity, + runnerExecutionTarget: target, instructionWorkingCopy: copy(identity) }); + const second = { ...warmExecution, binding: { ...warmExecution.binding, runId: `${identity}-second` } }; + const secondCopy = copy(second.binding.runId); + expect(await claimWarmNativeInstructionCopy({ priorExecution: warmExecution, + companyId: second.binding.companyId, agentId: second.binding.agentId, + executionWorkspaceId: second.binding.executionWorkspaceId, workspace: second.workspace, + environmentId: identity, runId: second.binding.runId, preparationKey: "same", adopt: async () => secondCopy })).toBeTypeOf("function"); + // Hold the previous run's post-turn coordinator release: the warm session + // is already handed back, but the run still holds its execution scopes. + let openGate!: () => void, held!: () => void; + const gate = new Promise(resolve => { openGate = resolve; }); + const reachedHold = new Promise(resolve => { held = resolve; }); + turnDone = false; + const secondDb = leaseDb(second); + const transaction = secondDb.transaction.bind(secondDb); + (secondDb as unknown as { transaction: typeof transaction }).transaction = (async (...args: Parameters) => { + if (turnDone) { held(); await gate; } + return transaction(...args); + }) as typeof transaction; + const previous = executePaperclipNativeSession({ db: secondDb, execution: second, runnerInstanceId: identity, + runnerExecutionTarget: target, instructionWorkingCopy: secondCopy }); + await reachedHold; + const followUpRunId = `${identity}-follow-up`; + const followUpCopy = copy(followUpRunId); + const claim = claimWarmNativeInstructionCopy({ priorExecution: second, + companyId: second.binding.companyId, agentId: second.binding.agentId, + executionWorkspaceId: second.binding.executionWorkspaceId, workspace: second.workspace, + environmentId: identity, runId: followUpRunId, preparationKey: "same", adopt: async () => followUpCopy }); + setTimeout(openGate, 100); + const release = await claim; + const followUp = { ...second, binding: { ...second.binding, runId: followUpRunId } }; + // Admitted at once: no native_session_supervisor_busy from the old owner's scopes. + await expect(executePaperclipNativeSession({ db: leaseDb(followUp), execution: followUp, runnerInstanceId: identity, + runnerExecutionTarget: target, ...(release ? { instructionWorkingCopy: followUpCopy } : {}) })).resolves.toBeDefined(); + await previous; + await release?.(); + } finally { + await closeWarmNativeSessionsForEnvironment({ environmentId: identity, reason: "test cleanup" }); + } + }); + it.each(["idle", "edit", "add", "remove", "projectless"])("retains the actual agent_files copy across warm turns, then retires for %s", async ending => { const root = await realpath(await mkdtemp(join(tmpdir(), "warm-agent-files-"))); const localRoot = join(root, "run", "live"); diff --git a/server/src/services/native-runtime/native-session-executor.ts b/server/src/services/native-runtime/native-session-executor.ts index bddd42e90e3..df2df509da7 100644 --- a/server/src/services/native-runtime/native-session-executor.ts +++ b/server/src/services/native-runtime/native-session-executor.ts @@ -738,6 +738,26 @@ export function readNativeSessionIdleWork(): IdleLocalWork { } registerIdleLocalWorkProbe(readNativeSessionIdleWork); +/** + * How long a new run's admission waits for the warm session to stop being busy. + * A follow-up comment can wake the next run within milliseconds of the previous + * run settling, while that run's warm release is still finishing. Waiting out + * the release lets the follow-up reuse the session instead of failing. A + * session still busy at the deadline is a real concurrent owner and fails as + * before. + */ +export const warmSessionReleaseWait = { timeoutMs: 30_000, pollMs: 25 }; + +async function waitForWarmNativeSessionSettled(scope: string): Promise { + const deadline = Date.now() + warmSessionReleaseWait.timeoutMs; + // Wait while the owner's turn is over and it is releasing the session, until + // it has handed every scope back (its execution's final cleanup), so the + // follow-up's own admission does not meet the old owner's scopes either. + while (settlingNativeSessionScopes.has(scope) && Date.now() < deadline) { + await new Promise(resolve => setTimeout(resolve, warmSessionReleaseWait.pollMs)); + } +} + /** Reserve the existing physical copy before heartbeat composes AGENT_HOME and * task guidance. The final execution/configuration fence still owns admission. */ export async function claimWarmNativeInstructionCopy(input: { @@ -759,6 +779,9 @@ export async function claimWarmNativeInstructionCopy(input: { binding: { ...prior.binding, runId: input.runId, executionWorkspaceId: input.executionWorkspaceId }, workspace: input.workspace, })) || prior.workspace.cwd !== input.workspace.cwd) return null; const scope = nativeSessionScopeKey(prior); + const busyEntry = warmNativeSessions.get(scope); + if (busyEntry?.busy || settlingNativeSessionScopes.has(scope)) await waitForWarmNativeSessionSettled(scope); + // Re-read after the wait: the release may have retired or replaced the session. const entry = warmNativeSessions.get(scope); if (!entry || !entry.instructionWorkingCopy?.runId || entry.environmentId !== input.environmentId) return null; if (entry.busy) throw new Error("native_session_supervisor_busy"); @@ -1982,6 +2005,9 @@ const initializingSessionToolAuthorities = new Set(); const executingRunnerdSessionScopes = new Map(); const executingNativeOwnerScopes = new Map(); const executingNativeIngressAllocations = new Map(); +/** Session scope -> the run releasing it after its turn (warm release and + * hand-back), which a follow-up's instruction claim may wait for. */ +const settlingNativeSessionScopes = new Map(); export function nativeSessionScopeDigestIsBusy(digest: string): boolean { const scopes = [ @@ -9366,84 +9392,90 @@ export async function executePaperclipNativeSession(input: { } throw error; } finally { - startup.providerPackSourceAbort.abort(new Error("native_execution_startup_completed")); - for (const lease of diagnosticLeases) lease.retire(); - let startupUnsettled = false; - if (startup.executionContext) { - if (startup.executionContext.active) startup.executionContext.abort(new Error("native_execution_startup_completed")); - startupUnsettled = !(await startup.executionContext.drain()); - startup.executionContext.finish(); - } - // Join preparation cleanup before release; keep cancellation reachable - // until the exact retained owner has finished the handover. - await startup.executionLeaseRenewal?.stop().catch(() => undefined); - if (startup.unstartedClaim && !nativeRunsDetachingForRestart.has(runId)) { - await releaseUnstartedNativeExecutionClaim({ db: input.db, runId, companyId: input.execution.binding.companyId, - issueId: input.execution.binding.issueId, ...startup.unstartedClaim }).catch(error => { - logger.warn({ err: error, runId }, "Native preparation claim release failed; the control deadline will settle it"); - }); - } - let stagedCleanupFailed = false; try { - await cleanupStagedAttachments(); - } catch (cleanupError) { - stagedCleanupFailed = true; - // Cleanup is a confidentiality incident, but it occurs after the native - // provider may already have completed the turn. Reclassifying that turn - // as failed could replay provider side effects. Emit a private runtime - // health event while preserving the provider result/error disposition. - await input - .onEvent?.({ - eventType: "native.attachment_staging_cleanup_failed", - level: "error", - message: "Native inbound attachment staging cleanup failed.", - payload: { - runId: input.execution.binding.runId, - issueId: input.execution.binding.issueId, - executionAlreadyFailed: executionFailure !== undefined, - }, - }) - .catch(() => undefined); - } - releaseRetainedNativeSessionCleanup(input.db, { - companyId: input.execution.binding.companyId, agentId: input.execution.binding.agentId, - }, { companyId: input.execution.binding.companyId, runId: input.execution.binding.runId, - issueId: input.execution.binding.issueId }); - const pendingRelease = ownsSessionScope && sessionScopeId - && executingRunnerdSessionScopes.get(sessionScopeId) === runId - ? warmNativeSessions.get(sessionScopeId) : undefined; - try { - if (pendingRelease?.runId === runId && pendingRelease.finishRelease) { - if (executionFailure !== undefined || startupUnsettled || stagedCleanupFailed) { - pendingRelease.closeOnReleaseReason = "warm native session final cleanup failed"; + startup.providerPackSourceAbort.abort(new Error("native_execution_startup_completed")); + for (const lease of diagnosticLeases) lease.retire(); + let startupUnsettled = false; + if (startup.executionContext) { + if (startup.executionContext.active) startup.executionContext.abort(new Error("native_execution_startup_completed")); + startupUnsettled = !(await startup.executionContext.drain()); + startup.executionContext.finish(); + } + // Join preparation cleanup before release; keep cancellation reachable + // until the exact retained owner has finished the handover. + await startup.executionLeaseRenewal?.stop().catch(() => undefined); + if (startup.unstartedClaim && !nativeRunsDetachingForRestart.has(runId)) { + await releaseUnstartedNativeExecutionClaim({ db: input.db, runId, companyId: input.execution.binding.companyId, + issueId: input.execution.binding.issueId, ...startup.unstartedClaim }).catch(error => { + logger.warn({ err: error, runId }, "Native preparation claim release failed; the control deadline will settle it"); + }); + } + let stagedCleanupFailed = false; + try { + await cleanupStagedAttachments(); + } catch (cleanupError) { + stagedCleanupFailed = true; + // Cleanup is a confidentiality incident, but it occurs after the native + // provider may already have completed the turn. Reclassifying that turn + // as failed could replay provider side effects. Emit a private runtime + // health event while preserving the provider result/error disposition. + await input + .onEvent?.({ + eventType: "native.attachment_staging_cleanup_failed", + level: "error", + message: "Native inbound attachment staging cleanup failed.", + payload: { + runId: input.execution.binding.runId, + issueId: input.execution.binding.issueId, + executionAlreadyFailed: executionFailure !== undefined, + }, + }) + .catch(() => undefined); + } + releaseRetainedNativeSessionCleanup(input.db, { + companyId: input.execution.binding.companyId, agentId: input.execution.binding.agentId, + }, { companyId: input.execution.binding.companyId, runId: input.execution.binding.runId, + issueId: input.execution.binding.issueId }); + const pendingRelease = ownsSessionScope && sessionScopeId + && executingRunnerdSessionScopes.get(sessionScopeId) === runId + ? warmNativeSessions.get(sessionScopeId) : undefined; + try { + if (pendingRelease?.runId === runId && pendingRelease.finishRelease) { + if (executionFailure !== undefined || startupUnsettled || stagedCleanupFailed) { + pendingRelease.closeOnReleaseReason = "warm native session final cleanup failed"; + } + await pendingRelease.finishRelease(); + } + } finally { + if (ownsPhysicalIngress && physicalIngressKey && executingNativeIngressAllocations.get(physicalIngressKey) === ownerToken) { + executingNativeIngressAllocations.delete(physicalIngressKey); + } + if (executingNativeOwnerScopes.get(ownerScope) === ownerToken) { + executingNativeOwnerScopes.delete(ownerScope); + } + if ( + ownsSessionScope && + sessionScopeId !== null && + executingRunnerdSessionScopes.get(sessionScopeId) === + input.execution.binding.runId + ) { + executingRunnerdSessionScopes.delete(sessionScopeId); + } + startup.resolve(null); + startup.settle(); + if (nativeSessionStartups.get(runId) === startup) { + nativeSessionStartups.delete(runId); + setNativeExecutionActive(runId, false); } - await pendingRelease.finishRelease(); } + if (startupUnsettled) throw new NativeStartupCleanupPendingError(startup.executionContext!.id, + startup.executionContext!.deadlineAtMs, startup.leaseOwner!, startup.executionContext!.pluginSupported, + input.runnerExecutionTarget?.kind === "remote" ? input.runnerExecutionTarget.leaseId ?? undefined : undefined); } finally { - if (ownsPhysicalIngress && physicalIngressKey && executingNativeIngressAllocations.get(physicalIngressKey) === ownerToken) { - executingNativeIngressAllocations.delete(physicalIngressKey); - } - if (executingNativeOwnerScopes.get(ownerScope) === ownerToken) { - executingNativeOwnerScopes.delete(ownerScope); - } - if ( - ownsSessionScope && - sessionScopeId !== null && - executingRunnerdSessionScopes.get(sessionScopeId) === - input.execution.binding.runId - ) { - executingRunnerdSessionScopes.delete(sessionScopeId); - } - startup.resolve(null); - startup.settle(); - if (nativeSessionStartups.get(runId) === startup) { - nativeSessionStartups.delete(runId); - setNativeExecutionActive(runId, false); - } + // Always hand the settling mark back, even if a cleanup step throws. + const settledScope = nativeSessionScopeKey(input.execution); + if (settlingNativeSessionScopes.get(settledScope) === runId) settlingNativeSessionScopes.delete(settledScope); } - if (startupUnsettled) throw new NativeStartupCleanupPendingError(startup.executionContext!.id, - startup.executionContext!.deadlineAtMs, startup.leaseOwner!, startup.executionContext!.pluginSupported, - input.runnerExecutionTarget?.kind === "remote" ? input.runnerExecutionTarget.leaseId ?? undefined : undefined); } } @@ -11362,6 +11394,9 @@ async function executePaperclipNativeSessionWithinScope( clearSteeringDeliveries(input.execution.binding.runId); clearNativeRuntimeRequestResolutions(input.execution.binding.runId); } catch (error) { + // A failed turn releases its warm session here, before the finally below: + // mark the scope settling first so a follow-up waits for that release too. + settlingNativeSessionScopes.set(nativeSessionScopeKey(input.execution), input.execution.binding.runId); executionContext?.abort(error instanceof Error ? error : new Error("native_execution_startup_failed")); const startupUnsettled = executionContext ? !(await executionContext.drain()) : false; if (startupUnsettled) throw new NativeStartupCleanupPendingError(executionContext!.id, executionContext!.deadlineAtMs, @@ -12024,6 +12059,10 @@ async function executePaperclipNativeSessionWithinScope( if (protocolIntegrityFailure !== null) throw protocolIntegrityFailure; } } finally { + // The provider turn is over, whether it succeeded or threw: what follows is + // release and hand-back, which a follow-up's admission may wait for + // (`warmSessionReleaseWait`). The outer execution's finally clears it. + settlingNativeSessionScopes.set(nativeSessionScopeKey(input.execution), input.execution.binding.runId); releaseGitHubRun?.(); if (githubAccess && (!warmSessionId || warmNativeSessions.get(warmSessionId)?.githubAccess !== githubAccess)) { await timeController("github_stop", () => githubAccess!.stop());