From b659ff4e0ae2d21397c17d630db6950154b62c2e Mon Sep 17 00:00:00 2001 From: testikun Date: Thu, 3 Sep 2026 14:46:01 +0800 Subject: [PATCH] fix(runtime-host): detach sandbox graph wake reconciliation Generated-by: OpenAI Codex --- .../__tests__/interaction-coordinator.test.ts | 88 +++++++++++++++++++ .../src/server/interaction-coordinator.ts | 11 ++- 2 files changed, 98 insertions(+), 1 deletion(-) diff --git a/packages/runtime-host/src/__tests__/interaction-coordinator.test.ts b/packages/runtime-host/src/__tests__/interaction-coordinator.test.ts index bae9e4b7fc..54db27bda8 100644 --- a/packages/runtime-host/src/__tests__/interaction-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/interaction-coordinator.test.ts @@ -295,6 +295,94 @@ describe('HostInteractionCoordinator', () => { }); }); + test('does not hold Session admission while graph wake reconciliation waits', async () => { + await withStore(async ({ owner, store, stores }) => { + const workspace = join(owner.capability.canonicalPath, 'wake-workspace'); + await mkdir(workspace); + const session = await stores.sessionStore.create({ + cwd: workspace, + llmConnectionId: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', + llmConnectionSlug: 'fake', + model: 'fake-model', + permissionMode: 'ask', + }); + const identity = { ...RUN, sessionId: session.id }; + const wakeStarted = deferred(); + const releaseWake = deferred(); + const wakeFinished = deferred(); + const coordinator = new HostInteractionCoordinator({ + store, + sandboxBoundaries: stores.sessionStore, + sessionAdmission: new SessionAdmissionGate(), + sessions: stores.sessionStore, + preflightSessionSnapshot: () => true, + refreshCanonicalContinuity: async () => {}, + onSandboxBoundarySettled: async () => { + wakeStarted.resolve(); + await releaseWake.promise; + wakeFinished.resolve(); + }, + onPoison: () => {}, + }); + const binding = coordinator.bindRun(identity); + const request = sandboxBoundaryEvent({ + sessionId: session.id, + requestId: 'boundary_wake_wait', + status: 'pending', + baseRevision: 0, + turnId: identity.turnId, + runId: identity.runId, + expansion: { network: { enabled: true } }, + justification: 'Connect to the requested service.', + createdAt: 1, + }); + await binding.acceptSandboxBoundaryRequest({ + request, + continuation: sandboxBoundaryContinuation(identity, request.requestId), + }); + + let answerSettled = false; + let answerResult: + | Awaited> + | undefined; + const answer = coordinator.handlers['interaction.answer']( + { + sessionId: session.id, + interactionId: request.requestId, + answer: { kind: 'sandbox_boundary', decision: 'allow' }, + }, + connection(), + ); + void answer.then( + (result) => { + answerResult = result; + answerSettled = true; + }, + () => { + answerSettled = true; + }, + ); + await wakeStarted.promise; + try { + await new Promise((resolve) => setImmediate(resolve)); + assert.equal( + answerSettled, + true, + 'interaction answer waited for graph wake reconciliation', + ); + assert.equal(answerResult?.ok, true); + if (answerResult?.ok) assert.equal(answerResult.result.status, 'answered'); + } finally { + releaseWake.resolve(); + await wakeFinished.promise; + await answer; + } + await binding.close('turn_terminal'); + binding.release(); + await coordinator.close(); + }); + }); + test('a queued stop waits for sandbox boundary publication before closing its Run', async () => { await withStore(async ({ owner, store, stores }) => { const workspace = join(owner.capability.canonicalPath, 'publication-workspace'); diff --git a/packages/runtime-host/src/server/interaction-coordinator.ts b/packages/runtime-host/src/server/interaction-coordinator.ts index 68b36e0dc5..70de3f9e48 100644 --- a/packages/runtime-host/src/server/interaction-coordinator.ts +++ b/packages/runtime-host/src/server/interaction-coordinator.ts @@ -815,7 +815,16 @@ export class HostInteractionCoordinator implements RuntimeInteractionAuthority { await this.#refreshCanonicalContinuity(request.sessionId, admission); this.#throwIfPoisoned(); await this.#applySandboxBoundaryDecisionAndDelete(entry, settlement); - await this.#onSandboxBoundarySettled(request.sessionId); + // The answer owns Session admission. Graph-wake reconciliation may need to + // acquire the activity lease held by the wake turn that is parked on this + // very answer, so awaiting it here deadlocks the Session (#3328, #3866). + // Start it after the durable answer is applied, but keep failures visible + // to the Host's fail-stop path instead of creating an unhandled rejection. + void Promise.resolve() + .then(() => this.#onSandboxBoundarySettled(request.sessionId)) + .catch((error: unknown) => { + this.#poison(error); + }); const result = projectSandboxBoundaryInteraction(settlement.request); if (result.status !== 'answered') { throw this.#poison(