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 b3ef5fa16b4..ce552bb3077 100644 --- a/server/src/services/native-runtime/native-session-executor.test.ts +++ b/server/src/services/native-runtime/native-session-executor.test.ts @@ -10497,6 +10497,66 @@ describe("native warm session supervision", () => { } }); + 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 86b5960a3b7..df847803851 100644 --- a/server/src/services/native-runtime/native-session-executor.ts +++ b/server/src/services/native-runtime/native-session-executor.ts @@ -12036,14 +12036,15 @@ 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()); } } - // The provider turn is over: what follows is release and hand-back, which a - // follow-up's admission may wait for (`warmSessionReleaseWait`). - settlingNativeSessionScopes.set(nativeSessionScopeKey(input.execution), input.execution.binding.runId); let postExecutionFailed = false; let postExecutionCleanupFailed = false; try {