Skip to content

Commit 476377e

Browse files
author
testikun
committed
fix(runtime-host): detach sandbox graph wake reconciliation
1 parent 9d4002b commit 476377e

2 files changed

Lines changed: 81 additions & 1 deletion

File tree

packages/runtime-host/src/__tests__/interaction-coordinator.test.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -295,6 +295,77 @@ describe('HostInteractionCoordinator', () => {
295295
});
296296
});
297297

298+
test('does not hold Session admission while graph wake reconciliation waits', async () => {
299+
await withStore(async ({ owner, store, stores }) => {
300+
const workspace = join(owner.capability.canonicalPath, 'wake-workspace');
301+
await mkdir(workspace);
302+
const session = await stores.sessionStore.create({
303+
cwd: workspace,
304+
llmConnectionId: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc',
305+
llmConnectionSlug: 'fake',
306+
model: 'fake-model',
307+
permissionMode: 'ask',
308+
});
309+
const identity = { ...RUN, sessionId: session.id };
310+
const wakeStarted = deferred();
311+
const releaseWake = deferred();
312+
const wakeFinished = deferred();
313+
const coordinator = new HostInteractionCoordinator({
314+
store,
315+
sandboxBoundaries: stores.sessionStore,
316+
sessionAdmission: new SessionAdmissionGate(),
317+
sessions: stores.sessionStore,
318+
preflightSessionSnapshot: () => true,
319+
refreshCanonicalContinuity: async () => {},
320+
onSandboxBoundarySettled: async () => {
321+
wakeStarted.resolve();
322+
await releaseWake.promise;
323+
wakeFinished.resolve();
324+
},
325+
onPoison: () => {},
326+
});
327+
const binding = coordinator.bindRun(identity);
328+
const request = sandboxBoundaryEvent({
329+
sessionId: session.id,
330+
requestId: 'boundary_wake_wait',
331+
status: 'pending',
332+
baseRevision: 0,
333+
turnId: identity.turnId,
334+
runId: identity.runId,
335+
expansion: { network: { enabled: true } },
336+
justification: 'Connect to the requested service.',
337+
createdAt: 1,
338+
});
339+
await binding.acceptSandboxBoundaryRequest({
340+
request,
341+
continuation: sandboxBoundaryContinuation(identity, request.requestId),
342+
});
343+
344+
const answer = coordinator.handlers['interaction.answer'](
345+
{
346+
sessionId: session.id,
347+
interactionId: request.requestId,
348+
answer: { kind: 'sandbox_boundary', decision: 'allow' },
349+
},
350+
connection(),
351+
);
352+
await wakeStarted.promise;
353+
const result = await Promise.race([
354+
answer,
355+
new Promise<undefined>((resolve) => setTimeout(() => resolve(undefined), 500)),
356+
]);
357+
assert.notEqual(result, undefined, 'interaction answer waited for graph wake reconciliation');
358+
assert.equal(result?.ok, true);
359+
if (result?.ok) assert.equal(result.result.status, 'answered');
360+
361+
releaseWake.resolve();
362+
await wakeFinished.promise;
363+
await binding.close('turn_terminal');
364+
binding.release();
365+
await coordinator.close();
366+
});
367+
});
368+
298369
test('a queued stop waits for sandbox boundary publication before closing its Run', async () => {
299370
await withStore(async ({ owner, store, stores }) => {
300371
const workspace = join(owner.capability.canonicalPath, 'publication-workspace');

packages/runtime-host/src/server/interaction-coordinator.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -815,7 +815,16 @@ export class HostInteractionCoordinator implements RuntimeInteractionAuthority {
815815
await this.#refreshCanonicalContinuity(request.sessionId, admission);
816816
this.#throwIfPoisoned();
817817
await this.#applySandboxBoundaryDecisionAndDelete(entry, settlement);
818-
await this.#onSandboxBoundarySettled(request.sessionId);
818+
// The answer owns Session admission. Graph-wake reconciliation may need to
819+
// acquire the activity lease held by the wake turn that is parked on this
820+
// very answer, so awaiting it here deadlocks the Session (#3328, #3866).
821+
// Start it after the durable answer is applied, but keep failures visible
822+
// to the Host's fail-stop path instead of creating an unhandled rejection.
823+
void Promise.resolve()
824+
.then(() => this.#onSandboxBoundarySettled(request.sessionId))
825+
.catch((error: unknown) => {
826+
this.#poison(error);
827+
});
819828
const result = projectSandboxBoundaryInteraction(settlement.request);
820829
if (result.status !== 'answered') {
821830
throw this.#poison(

0 commit comments

Comments
 (0)