From ccd381cdac564b5e0082f5832d4d767b87730b8f Mon Sep 17 00:00:00 2001 From: chinawch007 Date: Sat, 22 Aug 2026 00:40:33 +0800 Subject: [PATCH 01/19] feat(runtime): queued quiescent mutation with quiescence waiting (#3349) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit runSessionQuiescentMutation bails with session_busy whenever any execution claim exists, so a permission switch can never land while a Goal keeps admitting successor turns back to back. Add runSessionQueuedQuiescentMutation: it reserves a slot on each session's mutation tail synchronously, then runs the operation only once the session is at rest — every claim that predated the reservation has settled, and no run is active. Claims only cover admission (a turn's claim settles once its run is bound), so live turns are observed through hasActiveRuns instead; a run registers on its backend generation before its claim settles, so an in-flight admission is never invisible to both checks. Claims created after the reservation carry it in their admission barrier, so neither they nor runs started through them can appear first; waiting chains run strictly backwards in claim-creation order, which keeps the queue deadlock-free. Wakeups fire from claim settlement and run unregistration. Worst-case delay is one turn. The eager variant keeps its semantics unchanged. Generated-by: ZCode (Z.ai GLM) --- ...e-kernel-queued-quiescent-mutation.test.ts | 346 ++++++++++++++++++ packages/runtime/src/runtime-kernel.ts | 106 ++++++ 2 files changed, 452 insertions(+) create mode 100644 packages/runtime/src/__tests__/runtime-kernel-queued-quiescent-mutation.test.ts diff --git a/packages/runtime/src/__tests__/runtime-kernel-queued-quiescent-mutation.test.ts b/packages/runtime/src/__tests__/runtime-kernel-queued-quiescent-mutation.test.ts new file mode 100644 index 0000000000..76ad66871d --- /dev/null +++ b/packages/runtime/src/__tests__/runtime-kernel-queued-quiescent-mutation.test.ts @@ -0,0 +1,346 @@ +import assert from 'node:assert/strict'; +import { describe, test } from 'node:test'; + +import type { SessionEvent } from '@maka/core/events'; +import type { SessionHeader, StoredMessage } from '@maka/core/session'; +import type { AgentBackend, BackendSendInput } from '@maka/core/backend-types'; + +import { + RuntimeKernel, + SessionQuiescentMutationBusyError, +} from '../runtime-kernel.js'; +import { + BackendRegistry, + type BackendFactoryContext, + type SessionStore, +} from '../session-manager.js'; + +const SESSION_ID = 'session-queued-quiescent'; +const OTHER_SESSION_ID = 'session-queued-quiescent-other'; + +describe('RuntimeKernel queued quiescent mutation', () => { + test('runs immediately when no execution claim exists', async () => { + const kernel = newKernel(); + assert.equal( + await within(kernel.runSessionQueuedQuiescentMutation([SESSION_ID], () => 'committed')), + 'committed', + ); + }); + + test('waits for a claim that already existed when the mutation was requested', async () => { + const kernel = newKernel(); + const claim = kernel.claimExecution(SESSION_ID); + const result = track( + kernel.runSessionQueuedQuiescentMutation([SESSION_ID], () => 'committed'), + ); + await settleTicks(); + assert.equal(result.settled, false, 'mutation must wait while the claim is held'); + + claim.release(); + assert.equal(await within(result.promise), 'committed'); + }); + + test('re-arms while older claims remain after one releases', async () => { + const kernel = newKernel(); + const first = kernel.claimExecution(SESSION_ID); + const second = kernel.claimExecution(SESSION_ID); + const result = track( + kernel.runSessionQueuedQuiescentMutation([SESSION_ID], () => 'committed'), + ); + + first.release(); + await settleTicks(); + assert.equal(result.settled, false, 'mutation must keep waiting for the older claim'); + + second.release(); + assert.equal(await within(result.promise), 'committed'); + }); + + test('does not wait for a claim created after the mutation was requested', async () => { + const kernel = newKernel(); + // A preceding in-flight mutation keeps the queued slot reserved-but-not-run, + // so the later claim below is created after the frontier is captured. + const gate = deferred(); + const started = deferred(); + const preceding = kernel.runSessionAdmissionMutation([SESSION_ID], async () => { + started.resolve(); + await gate.promise; + return 'first'; + }); + await started.promise; + + const result = track( + kernel.runSessionQueuedQuiescentMutation([SESSION_ID], () => 'committed'), + ); + const lateClaim = kernel.claimExecution(SESSION_ID); + gate.resolve(); + + assert.equal(await within(preceding), 'first'); + assert.equal( + await within(result.promise), + 'committed', + 'mutation must not wait for a claim requested after it', + ); + lateClaim.release(); + }); + + test('commit lands between goal-style turns without waiting for the successor claim', async () => { + const kernel = newKernel(); + const predecessor = kernel.claimExecution(SESSION_ID); + const result = track( + kernel.runSessionQueuedQuiescentMutation([SESSION_ID], () => 'committed'), + ); + // The successor turn's claim arrives while the mutation is queued: it is + // newer than the frontier, so the committed slot must not wait for it. + const successor = kernel.claimExecution(SESSION_ID); + + predecessor.release(); + assert.equal(await within(result.promise), 'committed'); + successor.release(); + }); + + test('waits across every session named by the mutation', async () => { + const kernel = newKernel(); + const first = kernel.claimExecution(SESSION_ID); + const second = kernel.claimExecution(OTHER_SESSION_ID); + const result = track( + kernel.runSessionQueuedQuiescentMutation([SESSION_ID, OTHER_SESSION_ID], () => 'committed'), + ); + + first.release(); + await settleTicks(); + assert.equal(result.settled, false, 'mutation must wait for the other session claim'); + + second.release(); + assert.equal(await within(result.promise), 'committed'); + }); + + test('serializes with other session mutations in request order', async () => { + const kernel = newKernel(); + const order: string[] = []; + await Promise.all([ + kernel.runSessionAdmissionMutation([SESSION_ID], async () => { + order.push('admission'); + }), + kernel.runSessionQueuedQuiescentMutation([SESSION_ID], () => { + order.push('queued'); + }), + ]); + assert.deepEqual(order, ['admission', 'queued']); + }); + + test('propagates an operation failure and releases the mutation tail', async () => { + const kernel = newKernel(); + await assert.rejects( + kernel.runSessionQueuedQuiescentMutation([SESSION_ID], () => { + throw new Error('commit rejected'); + }), + /commit rejected/, + ); + assert.equal( + await within(kernel.runSessionAdmissionMutation([SESSION_ID], () => 'next')), + 'next', + ); + }); + + test('propagates a failure that happens after the drain wait', async () => { + const kernel = newKernel(); + const claim = kernel.claimExecution(SESSION_ID); + const result = kernel.runSessionQueuedQuiescentMutation([SESSION_ID], () => { + throw new Error('commit rejected after drain'); + }); + claim.release(); + await assert.rejects(within(result), /commit rejected after drain/); + assert.equal( + await within(kernel.runSessionAdmissionMutation([SESSION_ID], () => 'next')), + 'next', + ); + }); + + test('keeps the eager quiescent mutation semantics unchanged', async () => { + const kernel = newKernel(); + const claim = kernel.claimExecution(SESSION_ID); + await assert.rejects( + kernel.runSessionQuiescentMutation([SESSION_ID], () => 'committed'), + SessionQuiescentMutationBusyError, + ); + claim.release(); + assert.equal( + await within(kernel.runSessionQuiescentMutation([SESSION_ID], () => 'committed')), + 'committed', + ); + }); + + test('waits for a running turn even after its admission claim has settled', async () => { + const gate = deferred(); + const backends = new BackendRegistry(); + backends.register('ai-sdk', (ctx) => new GatedBackend(ctx, gate.promise)); + let id = 0; + const kernel = new RuntimeKernel({ + store: memoryStore(), + backends, + newId: () => `queued-quiescent-id-${++id}`, + now: () => id, + }); + + // The turn is dispatched: its admission claim settles once the run is bound, + // but the run itself stays active on the backend generation while the gate + // holds the stream open. The mutation must wait for the run. + const iterator = kernel + .startTurn(SESSION_ID, { turnId: 'turn-gated', text: 'start' }) + [Symbol.asyncIterator](); + assert.equal((await iterator.next()).value?.type, 'text_delta'); + + const result = track(kernel.runSessionQueuedQuiescentMutation([SESSION_ID], () => 'committed')); + await settleTicks(); + assert.equal(result.settled, false, 'mutation must wait while the run is active'); + + gate.resolve(); + while (!(await iterator.next()).done) {} + assert.equal(await within(result.promise), 'committed'); + }); +}); + +function newKernel(): RuntimeKernel { + const store = memoryStore(); + let id = 0; + return new RuntimeKernel({ + store, + backends: new BackendRegistry(), + newId: () => `queued-quiescent-id-${++id}`, + now: () => id, + }); +} + +class GatedBackend implements AgentBackend { + readonly kind = 'ai-sdk' as const; + readonly sessionId: string; + + constructor(ctx: BackendFactoryContext, private readonly gate: Promise) { + this.sessionId = ctx.sessionId; + } + + async *send(input: BackendSendInput): AsyncIterable { + yield { + type: 'text_delta', + id: `${input.turnId}-delta`, + turnId: input.turnId, + ts: 1, + messageId: `${input.turnId}-message`, + text: 'ok', + }; + await this.gate; + yield { + type: 'complete', + id: `${input.turnId}-complete`, + turnId: input.turnId, + ts: 2, + stopReason: 'end_turn', + }; + } + + async stop(): Promise {} + + async respondToSandboxBoundary(): Promise {} + + async dispose(): Promise {} +} + +function memoryStore(): SessionStore { + let header: SessionHeader = { + id: SESSION_ID, + workspaceRoot: '/tmp/maka-runtime-kernel-queued-quiescent', + cwd: '/tmp/maka-runtime-kernel-queued-quiescent', + createdAt: 1, + lastUsedAt: 1, + name: 'Queued quiescent mutation', + titleIsManual: true, + isFlagged: false, + labels: [], + isArchived: false, + status: 'active', + statusUpdatedAt: 1, + hasUnread: false, + backend: 'ai-sdk', + llmConnectionSlug: 'test', + connectionLocked: true, + model: 'test', + permissionMode: 'ask', + schemaVersion: 1, + }; + let messages: StoredMessage[] = []; + return { + create: async () => header, + createSubagent: async () => ({ header, created: false }), + setExecutionBoundaryKind: async () => { + throw new Error('not implemented'); + }, + readExecutionBoundary: async () => { + throw new Error('not implemented'); + }, + list: async () => [], + readHeader: async () => header, + readMessages: async () => [...messages], + listTurns: async () => [], + appendMessage: async (_sessionId, message) => { + messages.push(message); + }, + appendMessages: async (_sessionId, next) => { + messages.push(...next); + }, + updateHeader: async (_sessionId, patch) => { + header = { ...header, ...patch }; + return header; + }, + setFlagged: async () => {}, + rename: async () => {}, + remove: async () => {}, + }; +} + +function track(promise: Promise): { promise: Promise; settled: boolean } { + const state = { promise, settled: false }; + void promise.then( + () => { + state.settled = true; + }, + () => { + state.settled = true; + }, + ); + return state; +} + +function deferred(): { + promise: Promise; + resolve(value: T | PromiseLike): void; +} { + let resolve!: (value: T | PromiseLike) => void; + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise; + }); + return { promise, resolve }; +} + +async function settleTicks(): Promise { + for (let tick = 0; tick < 2; tick += 1) { + await new Promise((resolve) => setImmediate(resolve)); + } +} + +async function within(promise: Promise, timeoutMs = 1_000): Promise { + let timeout: NodeJS.Timeout | undefined; + try { + return await Promise.race([ + promise, + new Promise((_resolve, reject) => { + timeout = setTimeout( + () => reject(new Error('queued quiescent mutation timed out')), + timeoutMs, + ); + }), + ]); + } finally { + if (timeout) clearTimeout(timeout); + } +} diff --git a/packages/runtime/src/runtime-kernel.ts b/packages/runtime/src/runtime-kernel.ts index b91e7627c8..d893650deb 100644 --- a/packages/runtime/src/runtime-kernel.ts +++ b/packages/runtime/src/runtime-kernel.ts @@ -158,6 +158,10 @@ export interface RuntimeKernelLike { sessionIds: readonly string[], operation: () => Promise | T, ): Promise; + runSessionQueuedQuiescentMutation?( + sessionIds: readonly string[], + operation: () => Promise | T, + ): Promise; startTurn( sessionId: string, input: UserMessageInput, @@ -366,6 +370,12 @@ type ExecutionClaimOutcome = { ok: true } | { ok: false; error: unknown }; interface PendingExecutionClaim { readonly handle: RuntimeExecutionClaim; readonly sessionId: string; + /** + * Monotonic creation order. A queued quiescent mutation only waits out claims + * that predate its tail reservation (`claimSeq <= frontier`); anything newer + * carries the reservation in its own admission barrier and cannot attach first. + */ + readonly claimSeq: number; readonly abortController: AbortController; readonly cancellation: RuntimeExecutionCancellation; readonly admissionBarrier: Promise; @@ -403,6 +413,8 @@ export class RuntimeKernel implements RuntimeKernelLike { private readonly stopAttempts = new Map>(); private readonly executionClaims = new Map>(); private readonly sessionMutationTails = new Map>(); + private claimSequence = 0; + private readonly sessionQuiescenceWaiters = new Map void>>(); private readonly executionClaimStates = new WeakMap< RuntimeExecutionClaim, PendingExecutionClaim @@ -450,6 +462,7 @@ export class RuntimeKernel implements RuntimeKernelLike { const state: PendingExecutionClaim = { handle, sessionId, + claimSeq: ++this.claimSequence, abortController, cancellation, admissionBarrier: this.sessionMutationTails.get(sessionId) ?? Promise.resolve(), @@ -487,6 +500,44 @@ export class RuntimeKernel implements RuntimeKernelLike { return this.enqueueSessionMutation(ids, operation); } + /** + * A quiescent mutation that queues instead of bailing: the operation claims a + * slot on each session's mutation tail right away, then runs only once the + * session is at rest — every execution claim that already existed when the + * mutation was requested has settled, and no run is active. Unlike + * `runSessionQuiescentMutation`, a busy session delays the operation — by at + * most one turn's lifetime — instead of rejecting it. + * + * Claims only cover admission: a turn's claim settles once its run is bound + * (see `bindInteraction`), so live turns are visible through `hasActiveRuns` + * instead. Admission stays continuously observable because a run registers on + * its backend generation (reserve step) before its claim settles, so there is + * no instant where an in-flight admission is invisible to both checks. + * + * Deadlock freedom rests on two facts. First, claims created after the tail + * reservation capture that reservation in their admission barrier, so neither + * they nor runs started through them can appear before this mutation has run; + * the mutation only waits on strictly older executions. Second, waiting chains + * therefore always run backwards in claim-creation order, so no cycle can + * close — a running turn never enqueues a mutation on its own session's tail. + * + * The claim frontier must be captured in the same synchronous block as the + * tail reservation (`enqueueSessionMutation` registers tails before its first + * await): that keeps claim-creation order and barrier order identical, which + * is what makes "strictly older" meaningful. + */ + async runSessionQueuedQuiescentMutation( + sessionIds: readonly string[], + operation: () => Promise | T, + ): Promise { + const ids = this.normalizeSessionMutationIds(sessionIds); + const claimFrontier = this.claimSequence; + return this.enqueueSessionMutation(ids, async () => { + await this.waitForSessionQuiescence(ids, claimFrontier); + return await operation(); + }); + } + private normalizeSessionMutationIds(sessionIds: readonly string[]): string[] { const ids = [...new Set(sessionIds)].sort(); if (ids.length === 0 || ids.some((sessionId) => sessionId.length === 0)) { @@ -525,6 +576,59 @@ export class RuntimeKernel implements RuntimeKernelLike { } } + private hasUnsettledExecutionClaims(sessionId: string, claimFrontier: number): boolean { + for (const claim of this.executionClaims.get(sessionId) ?? []) { + if (claim.claimSeq <= claimFrontier) return true; + } + return false; + } + + private isSessionExecuting(sessionId: string, claimFrontier: number): boolean { + return this.hasUnsettledExecutionClaims(sessionId, claimFrontier) || this.hasActiveRuns(sessionId); + } + + private async waitForSessionQuiescence( + sessionIds: readonly string[], + claimFrontier: number, + ): Promise { + for (;;) { + const blocking = sessionIds.filter((sessionId) => + this.isSessionExecuting(sessionId, claimFrontier), + ); + if (blocking.length === 0) return; + await new Promise((resolve) => { + const wake = (): void => { + for (const sessionId of blocking) { + this.removeSessionQuiescenceWaiter(sessionId, wake); + } + resolve(); + }; + for (const sessionId of blocking) { + let waiters = this.sessionQuiescenceWaiters.get(sessionId); + if (!waiters) { + waiters = new Set(); + this.sessionQuiescenceWaiters.set(sessionId, waiters); + } + waiters.add(wake); + } + }); + } + } + + private removeSessionQuiescenceWaiter(sessionId: string, wake: () => void): void { + const waiters = this.sessionQuiescenceWaiters.get(sessionId); + if (!waiters) return; + waiters.delete(wake); + if (waiters.size === 0) this.sessionQuiescenceWaiters.delete(sessionId); + } + + private wakeSessionQuiescenceWaiters(sessionId: string): void { + const waiters = this.sessionQuiescenceWaiters.get(sessionId); + if (!waiters) return; + this.sessionQuiescenceWaiters.delete(sessionId); + for (const wake of [...waiters]) wake(); + } + private takeExecutionClaim( sessionId: string, supplied?: RuntimeExecutionClaim, @@ -614,6 +718,7 @@ export class RuntimeKernel implements RuntimeKernelLike { const claims = this.executionClaims.get(execution.sessionId); claims?.delete(execution); if (claims?.size === 0) this.executionClaims.delete(execution.sessionId); + this.wakeSessionQuiescenceWaiters(execution.sessionId); if (outcome.ok) execution.resolveSettled(); else execution.rejectSettled(outcome.error); } @@ -2957,6 +3062,7 @@ export class RuntimeKernel implements RuntimeKernelLike { if (active.turnToRunId.get(run.turnId) === run.runId) { active.turnToRunId.delete(run.turnId); } + this.wakeSessionQuiescenceWaiters(active.sessionId); } private async unregisterParentRun(active: AgentRunActiveSession, run: AgentRun): Promise { From 0a4c362b5464ba436e5996a204a40bc8381b3192 Mon Sep 17 00:00:00 2001 From: chinawch007 Date: Sat, 22 Aug 2026 02:51:28 +0800 Subject: [PATCH 02/19] fix(runtime): queue permission transitions behind live execution (#3349) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit setPermissionMode, setExecutionBoundaryKind and transitionSessionConfiguration rejected with session_busy whenever a turn was running or an admission claim existed, so a switch could never land while a Goal keeps admitting successor turns back to back. Route commitExecutionResourceTransition through the kernel's queued quiescent mutation instead: the switch waits out the claims and runs that predate it, commits in the inter-turn gap, and the successor turn — admission-barrier-gated on the reserved slot — observes the new configuration before its first tool call. The eager hasActiveRuns guards come out (quiescence is now the kernel's single authority); the waiting_for_user rejections and relocateSessionWorkspace's fail-fast keep their semantics. Existing assertions expecting the reject behavior are updated to the queued semantics, and a regression test drives a gated turn through an Auto→Bypass switch, asserting the switch commits in the gap and the next turn is rebuilt from the committed mode. Generated-by: ZCode (Z.ai GLM) --- .../src/__tests__/session-manager.test.ts | 152 ++++++++++++++---- packages/runtime/src/session-manager.ts | 34 ++-- 2 files changed, 141 insertions(+), 45 deletions(-) diff --git a/packages/runtime/src/__tests__/session-manager.test.ts b/packages/runtime/src/__tests__/session-manager.test.ts index 794572efb0..ed9ed3aa0b 100644 --- a/packages/runtime/src/__tests__/session-manager.test.ts +++ b/packages/runtime/src/__tests__/session-manager.test.ts @@ -4283,7 +4283,7 @@ describe('SessionManager manual compaction and quiescent session changes', () => await sendPromise; }); - test('configuration transitions fence active runs and unavailable resource side effects', async () => { + test('configuration transitions queue behind held claims and fence resource side effects', async () => { const store = new VersionedConfigurationMemorySessionStore(); const kernel = new DelegatingRuntimeKernel(); const manager = new SessionManager({ @@ -4308,27 +4308,24 @@ describe('SessionManager manual compaction and quiescent session changes', () => orchestrationMode: 'graph' as const, }; - kernel.activeRuns = true; - await assert.rejects( - manager.transitionSessionConfiguration(session.id, { + const heldClaim = kernel.claimExecution(session.id); + let transitionSettled = false; + const queuedTransition = manager + .transitionSessionConfiguration(session.id, { expectedRevision: 1, clearConnectionBlock: false, configuration: baseConfiguration, - }), - (error: unknown) => { - assert.ok(error instanceof SessionConfigurationTransitionError); - assert.equal(error.code, 'session_busy'); - return true; - }, - ); + }) + .then((value) => { + transitionSettled = true; + return value; + }); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(transitionSettled, false); assert.deepEqual(kernel.disposed, []); - kernel.activeRuns = false; - const committed = await manager.transitionSessionConfiguration(session.id, { - expectedRevision: 1, - clearConnectionBlock: false, - configuration: baseConfiguration, - }); + heldClaim.release(); + const committed = await queuedTransition; assert.equal(committed.revision, 2); assert.equal(committed.header.orchestrationMode, 'graph'); assert.deepEqual(kernel.disposed, [session.id]); @@ -4460,7 +4457,7 @@ describe('SessionManager manual compaction and quiescent session changes', () => assert.equal((await store.readHeader(session.id)).cwd, '/workspace/new'); }); - test('configuration transitions reject a claimed turn without waiting for it to settle', async () => { + test('configuration transitions wait for a claimed turn to settle before committing', async () => { const store = new VersionedConfigurationMemorySessionStore(); const readStarted = makeGate(); const releaseRead = makeGate(); @@ -4477,7 +4474,8 @@ describe('SessionManager manual compaction and quiescent session changes', () => const turn = drain(manager.sendMessage(session.id, { turnId: 'turn-1', text: 'start' })); await readStarted.promise; - const transitionResult = await manager + let transitionSettled = false; + const transition = manager .transitionSessionConfiguration(session.id, { expectedRevision: 1, clearConnectionBlock: false, @@ -4493,18 +4491,20 @@ describe('SessionManager manual compaction and quiescent session changes', () => orchestrationMode: 'graph', }, }) - .then( - (value) => ({ ok: true as const, value }), - (error: unknown) => ({ ok: false as const, error }), - ); - assert.equal(transitionResult.ok, false); - if (transitionResult.ok) assert.fail('Configuration transition unexpectedly committed'); - assert.ok(transitionResult.error instanceof SessionConfigurationTransitionError); - assert.equal(transitionResult.error.code, 'session_busy'); + .then((value) => { + transitionSettled = true; + return value; + }); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(transitionSettled, false); assert.equal((await store.readHeader(session.id)).orchestrationMode, 'default'); releaseRead.release(); await turn; + const committed = await transition; + assert.equal(committed.revision, 2); + assert.equal(committed.header.orchestrationMode, 'graph'); + assert.equal((await store.readHeader(session.id)).orchestrationMode, 'graph'); }); test('a claimed turn waits for an in-flight session mutation before reading its header', async () => { @@ -5054,7 +5054,7 @@ describe('SessionManager permission mode updates', () => { expect(store.disposeCount).toBe(3); }); - test('keeps mode changes blocked until all overlapping turns finish', async () => { + test('queues mode changes until all overlapping turns finish', async () => { const store = new MemorySessionStore(); const runStore = new MemoryAgentRunStore(); const backends = new BackendRegistry(); @@ -5089,11 +5089,18 @@ describe('SessionManager permission mode updates', () => { expect(afterFirstRuns.find((run) => run.turnId === 'turn-1')?.status).toBe('completed'); expect(afterFirstRuns.find((run) => run.turnId === 'turn-2')?.status).toBe('running'); - await expectRejects(manager.setPermissionMode(session.id, 'bypass'), /当前任务正在运行/); + let modeChangeSettled = false; + const modeChange = manager.setPermissionMode(session.id, 'bypass').then((result) => { + modeChangeSettled = true; + return result; + }); + await new Promise((resolve) => setImmediate(resolve)); + expect(modeChangeSettled).toBe(false); secondGate.release(); await second.next(); await second.next(); + while (!(await second.next()).done) {} expect((await store.readHeader(session.id)).status).toBe('active'); const finalRuns = await runStore.listSessionRuns(session.id); expect(finalRuns.map((run) => [run.turnId, run.status])).toEqual([ @@ -5105,10 +5112,71 @@ describe('SessionManager permission mode updates', () => { expect(firstEvents.map((event) => event.type)).toContain('run_started'); expect(firstEvents.map((event) => event.type)).toContain('run_completed'); - const summary = await manager.setPermissionMode(session.id, 'bypass'); + const summary = await modeChange; expect(summary.permissionMode).toBe('bypass'); }); + test('a permission switch requested mid-turn lands before the next turn starts', async () => { + const store = new MemorySessionStore(); + const runStore = new MemoryAgentRunStore(); + const backends = new BackendRegistry(); + const gates: Gate[] = []; + const builtPermissionModes: SessionHeader['permissionMode'][] = []; + backends.register('ai-sdk', (ctx) => { + const gate = makeGate(); + gates.push(gate); + builtPermissionModes.push(ctx.header.permissionMode); + return new TestBackend(ctx, gate); + }); + const manager = new SessionManager({ + store, + runStore, + runtimeEventStore: runStore, + backends, + newId: nextId(), + now: nextNow(8_000), + }); + const session = await manager.createSession(makeInput({ permissionMode: 'ask' })); + + // Turn 1 runs under ask; the gate holds the backend so the claim stays attached. + const first = manager + .sendMessage(session.id, { turnId: 'turn-1', text: 'first' }) + [Symbol.asyncIterator](); + expect((await first.next()).value?.type).toBe('text_delta'); + + // Auto → Bypass requested mid-turn: the switch queues instead of rejecting. + let switchSettled = false; + const switchPromise = manager.setPermissionMode(session.id, 'bypass').then( + (result) => { + switchSettled = true; + return result; + }, + (error) => { + switchSettled = true; + throw error; + }, + ); + await new Promise((resolve) => setImmediate(resolve)); + expect(switchSettled).toBe(false); + expect((await store.readExecutionBoundary(session.id)).kind).toBe('managed'); + + // The switch commits in the gap as soon as turn 1 settles. + gates[0]!.release(); + while (!(await first.next()).done) {} + const summary = await switchPromise; + expect(summary.permissionMode).toBe('bypass'); + expect((await store.readExecutionBoundary(session.id)).kind).toBe('bypass'); + + // The next turn is rebuilt from the committed configuration. + const second = manager + .sendMessage(session.id, { turnId: 'turn-2', text: 'second' }) + [Symbol.asyncIterator](); + expect((await second.next()).value?.type).toBe('text_delta'); + gates[1]!.release(); + while (!(await second.next()).done) {} + expect(builtPermissionModes).toEqual(['ask', 'bypass']); + }); + test('leaving explore clears the deep research label so visible read-only copy stays truthful', async () => { const store = new MemorySessionStore(); const backends = new BackendRegistry(); @@ -12273,7 +12341,10 @@ describe('SessionManager permission mode updates', () => { expect((await store.readHeader(session.id)).status).toBe('waiting_for_user'); const [run] = await runStore.listSessionRuns(session.id); expect(run?.status).toBe('waiting_for_user'); - await expectRejects(manager.setPermissionMode(session.id, 'bypass'), /当前任务正在运行/); + await expectRejects( + manager.setPermissionMode(session.id, 'bypass'), + /当前有工具调用正在等待确认/, + ); expect((await store.readHeader(session.id)).permissionMode).toBe('ask'); await manager.respondToSandboxBoundary(session.id, { @@ -14994,13 +15065,20 @@ class DelegatingRuntimeKernel implements RuntimeKernelLike { constructor(private readonly events: readonly SessionEvent[] = []) {} + private readonly heldClaims = new Map(); + claimExecution(sessionId: string): ReturnType { + this.heldClaims.set(sessionId, (this.heldClaims.get(sessionId) ?? 0) + 1); const stopController = new AbortController(); return { sessionId, stopSignal: stopController.signal, isStopRequested: () => false, - release: () => {}, + release: () => { + const remaining = (this.heldClaims.get(sessionId) ?? 0) - 1; + if (remaining > 0) this.heldClaims.set(sessionId, remaining); + else this.heldClaims.delete(sessionId); + }, }; } @@ -15018,6 +15096,16 @@ class DelegatingRuntimeKernel implements RuntimeKernelLike { return operation(); } + async runSessionQueuedQuiescentMutation( + sessionIds: readonly string[], + operation: () => Promise | T, + ): Promise { + while (sessionIds.some((sessionId) => (this.heldClaims.get(sessionId) ?? 0) > 0)) { + await new Promise((resolve) => setImmediate(resolve)); + } + return operation(); + } + async *startTurn( sessionId: string, input: Parameters[1], diff --git a/packages/runtime/src/session-manager.ts b/packages/runtime/src/session-manager.ts index d9d31e06bf..af8313a4f7 100644 --- a/packages/runtime/src/session-manager.ts +++ b/packages/runtime/src/session-manager.ts @@ -1626,9 +1626,6 @@ export class SessionManager { return headerToSummary(previous); } - if (this.runtimeKernel.hasActiveRuns(sessionId)) { - throw new Error('当前任务正在运行,等结束后再切换权限模式。'); - } if (previous.status === 'waiting_for_user') { throw new Error('当前有工具调用正在等待确认,处理后再切换权限模式。'); } @@ -1659,9 +1656,6 @@ export class SessionManager { sessionId: string, kind: 'managed' | 'bypass', ): Promise { - if (this.runtimeKernel.hasActiveRuns(sessionId)) { - throw new Error('当前任务正在运行,等结束后再切换沙箱边界。'); - } const header = await this.deps.store.readHeader(sessionId); if (header.status === 'waiting_for_user') { throw new Error('当前有沙箱边界请求正在等待确认,处理后再切换。'); @@ -1705,7 +1699,7 @@ export class SessionManager { : []; const fencedSessionIds = [sessionId, ...initialDescendants]; - return this.runSessionQuiescentMutation(fencedSessionIds, async () => { + return this.runSessionQueuedQuiescentMutation(fencedSessionIds, async () => { const currentBoundary = await this.deps.store.readExecutionBoundary(sessionId); const narrowsShellAuthority = narrowsExecutionAuthority(currentBoundary, nextPermissionMode); const descendantSessionIds = narrowsShellAuthority @@ -1722,12 +1716,6 @@ export class SessionManager { ); } const lineageSessionIds = [sessionId, ...descendantSessionIds]; - if (lineageSessionIds.some((id) => this.runtimeKernel.hasActiveRuns(id))) { - throw new SessionConfigurationTransitionError( - 'session_busy', - 'Session configuration cannot change while a linked Turn is active', - ); - } if (narrowsShellAuthority && !this.deps.shellRuns) { throw new SessionConfigurationTransitionError( 'operation_unavailable', @@ -1810,6 +1798,26 @@ export class SessionManager { } } + /** + * Quiescent mutation that queues behind live execution instead of rejecting: + * the kernel defers the operation until every claim that predates the request + * settles, so a permission switch lands in the next inter-turn gap. Turns + * admitted after the request are admission-barrier-gated on the reserved + * slot, which is what lets them observe the committed configuration. + */ + private async runSessionQueuedQuiescentMutation( + sessionIds: readonly string[], + operation: () => Promise, + ): Promise { + if (!this.runtimeKernel.runSessionQueuedQuiescentMutation) { + throw new SessionConfigurationTransitionError( + 'operation_unavailable', + 'Session execution mutation authority is unavailable', + ); + } + return await this.runtimeKernel.runSessionQueuedQuiescentMutation(sessionIds, operation); + } + private async listLinkedDescendantSessionIds(sessionId: string): Promise { const sessions = await this.deps.store.list(); const childrenByParent = new Map(); From 21fd63b5f6f6c34e6cfc944425888218b333dfdf Mon Sep 17 00:00:00 2001 From: chinawch007 Date: Sat, 22 Aug 2026 09:34:22 +0800 Subject: [PATCH 03/19] fix(runtime): derive tool permission mode from the live boundary (#3349) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ctx.permissionMode was frozen at backend build time from the session header, so even a committed switch left a running turn's approvals on the old mode — the half-applied state behind the issue's mixed symptoms. Derive the mode per tool dispatch from the authoritative boundary (executionBoundaryDisplayMode), with the plan-mode downgrade applied at the same point; an external boundary falls back to the last known header mode. resolveCollaborationPermissionMode moves to core so the composer (build time) and the tool runtime (dispatch time) share one rule. Also close the catalog short-circuit amplifier: session.configuration.update now requires the durable boundary to match the requested mode before treating the update as a committed no-op, so a header/boundary divergence is repaired instead of blessed. executionBoundaryMatchesPermissionMode moves to core next to the display-mode derivation. Legacy 'execute' is audit-safe: compilePermissionProfile and the filesystem worker treat it identically to 'ask', and the subagent snapshot's permissionMode is unused by tool building. Generated-by: ZCode (Z.ai GLM) --- packages/core/src/permission.ts | 16 ++ packages/core/src/sandbox-boundary.ts | 17 ++ .../session-catalog-coordinator.test.ts | 59 ++++++ .../src/server/execution-model-composition.ts | 11 +- .../src/server/session-catalog-coordinator.ts | 14 +- .../tool-runtime-permission-mode.test.ts | 194 ++++++++++++++++++ packages/runtime/src/session-manager.ts | 12 +- packages/runtime/src/tool-runtime.ts | 18 +- 8 files changed, 319 insertions(+), 22 deletions(-) create mode 100644 packages/runtime/src/__tests__/tool-runtime-permission-mode.test.ts diff --git a/packages/core/src/permission.ts b/packages/core/src/permission.ts index 6c716cc2cf..a7e55ac4d6 100644 --- a/packages/core/src/permission.ts +++ b/packages/core/src/permission.ts @@ -62,6 +62,22 @@ export function isPermissionMode(value: unknown): value is PermissionMode { return typeof value === 'string' && (PERMISSION_MODES as readonly string[]).includes(value); } +/** + * The permission mode a tool-facing consumer should act on: a plan-mode + * session presents read-only authority to tools unless it is bypassed. Shared + * by the runtime-host composer (build time) and the tool runtime (dispatch + * time), so both derive the same mode from the same inputs. + */ +export function resolveCollaborationPermissionMode(input: { + readonly collaborationMode: 'agent' | 'plan'; + readonly permissionMode: PermissionMode; +}): PermissionMode { + return input.collaborationMode === 'plan' && input.permissionMode !== 'bypass' + ? 'explore' + : input.permissionMode; +} + + /** Canonical category names use Claude SDK terminology. Pi adapter MUST * translate Pi-native tool names into these before they reach the runtime. */ export type ToolCategory = diff --git a/packages/core/src/sandbox-boundary.ts b/packages/core/src/sandbox-boundary.ts index 208fd1f2b3..d9aaeb3080 100644 --- a/packages/core/src/sandbox-boundary.ts +++ b/packages/core/src/sandbox-boundary.ts @@ -226,6 +226,23 @@ export function executionBoundaryDisplayMode( return readOnly ? 'explore' : 'ask'; } +/** + * Whether the durable boundary already expresses the requested permission + * mode. Callers that short-circuit a no-op configuration update on this + * answer must consult it: comparing the header's stored `permissionMode` + * alone would bless a header/boundary divergence as already-committed. + */ +export function executionBoundaryMatchesPermissionMode( + boundary: ExecutionBoundary, + mode: PermissionMode, +): boolean { + if (mode === 'bypass') return boundary.kind === 'bypass'; + if (boundary.kind !== 'managed') return false; + return mode === 'explore' + ? boundary.profile.name === 'read-only' + : boundary.profile.name !== 'read-only'; +} + export function createGenesisExecutionBoundary(mode: PermissionMode): ExecutionBoundary { if (mode === 'bypass') return { kind: 'bypass', revision: 0 }; return { diff --git a/packages/runtime-host/src/__tests__/session-catalog-coordinator.test.ts b/packages/runtime-host/src/__tests__/session-catalog-coordinator.test.ts index e5f788bac5..415a8bac53 100644 --- a/packages/runtime-host/src/__tests__/session-catalog-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/session-catalog-coordinator.test.ts @@ -416,6 +416,57 @@ test('typed configuration rejection does not request Host drain', async () => { assert.equal(fixture.drainRequests(), 0); }); +test('a no-op configuration update repairs a header/boundary divergence instead of blessing it', async () => { + // The header matches the requested configuration on every field, so only the + // boundary consistency check can tell a genuine no-op from a divergence that + // must be repaired through Runtime authority. + const matchingHeader = (labels: readonly string[]): SessionHeader => ({ + ...sessionHeader('session-1', labels), + permissionMode: 'bypass', + orchestrationMode: 'graph', + }); + + let transitions = 0; + const consistent = createFixture({ + stores: { + readHeaderRecordSnapshot: async () => headerSnapshot(matchingHeader(['user-label']), 3), + readCatalogRecord: async () => catalogRecord(matchingHeader(['user-label']), 3), + readExecutionBoundary: async () => ({ kind: 'bypass', revision: 1 }), + }, + manager: { + transitionSessionConfiguration: async () => { + transitions += 1; + return headerSnapshot(matchingHeader(['user-label']), 3); + }, + }, + }); + const consistentOutcome = await consistent.coordinator.handlers[ + 'session.configuration.update' + ](bypassConfigurationInput(consistent.sessionId, consistent.revision()), context); + assert.equal(consistentOutcome.ok, true); + assert.equal(transitions, 0); + + const divergent = createFixture({ + stores: { + readHeaderRecordSnapshot: async () => headerSnapshot(matchingHeader(['user-label']), 3), + readCatalogRecord: async () => catalogRecord(matchingHeader(['user-label']), 3), + // The header says bypass while the durable boundary stays managed. + readExecutionBoundary: async () => createGenesisExecutionBoundary('ask'), + }, + manager: { + transitionSessionConfiguration: async () => { + transitions += 1; + return headerSnapshot(matchingHeader(['user-label']), 3); + }, + }, + }); + const divergentOutcome = await divergent.coordinator.handlers[ + 'session.configuration.update' + ](bypassConfigurationInput(divergent.sessionId, divergent.revision()), context); + assert.equal(divergentOutcome.ok, true); + assert.equal(transitions, 1); +}); + test('creation rejects reserved execution labels before claiming a Session identity', async () => { let createAttempts = 0; const fixture = createFixture({ @@ -1585,6 +1636,14 @@ function configurationInput( }; } +function bypassConfigurationInput( + sessionId: string, + expectedRevision: number, +): SessionConfigurationUpdateInput { + const base = configurationInput(sessionId, expectedRevision); + return { ...base, configuration: { ...base.configuration, permissionMode: 'bypass' } }; +} + function sessionHeader(sessionId: string, labels: readonly string[]): SessionHeader { return { id: sessionId, diff --git a/packages/runtime-host/src/server/execution-model-composition.ts b/packages/runtime-host/src/server/execution-model-composition.ts index 0c450b39cd..92d60abe06 100644 --- a/packages/runtime-host/src/server/execution-model-composition.ts +++ b/packages/runtime-host/src/server/execution-model-composition.ts @@ -23,7 +23,7 @@ import { resolveModelVisionSupport } from '@maka/core/model-metadata'; import { relayModelProfile } from '@maka/core/model-thinking'; import type { ModelCallAttempt } from '@maka/core/model-call-attempt'; import type { ModelCallCommit } from '@maka/core/agent-run'; -import type { PermissionMode } from '@maka/core/permission'; +import { resolveCollaborationPermissionMode } from '@maka/core/permission'; import { AiSdkBackend } from '@maka/runtime/ai-sdk-backend'; import { buildDefaultContextBudgetPolicy, @@ -475,11 +475,4 @@ class HostAiSdkBackend extends AiSdkBackend { } } -export function resolveCollaborationPermissionMode(input: { - readonly collaborationMode: 'agent' | 'plan'; - readonly permissionMode: PermissionMode; -}): PermissionMode { - return input.collaborationMode === 'plan' && input.permissionMode !== 'bypass' - ? 'explore' - : input.permissionMode; -} +export { resolveCollaborationPermissionMode } from '@maka/core/permission'; diff --git a/packages/runtime-host/src/server/session-catalog-coordinator.ts b/packages/runtime-host/src/server/session-catalog-coordinator.ts index e20e9f6be4..bbe51b57ef 100644 --- a/packages/runtime-host/src/server/session-catalog-coordinator.ts +++ b/packages/runtime-host/src/server/session-catalog-coordinator.ts @@ -24,6 +24,7 @@ import { isModelExplicitlyUnsupportedForChat } from '@maka/core/model-catalog'; import { thinkingVariantsForConnection } from '@maka/core/model-thinking'; import { executionBoundaryDisplayMode, + executionBoundaryMatchesPermissionMode, type ExecutionBoundary, type ExecutionBoundarySummary, } from '@maka/core/sandbox-boundary'; @@ -541,7 +542,18 @@ export class HostSessionCatalogCoordinator { const clearsConnectionBlock = input.patch.modelTarget !== undefined && current.header.blockedReason === 'NO_REAL_CONNECTION'; - if (!clearsConnectionBlock && sessionConfigurationMatches(current.header, configuration)) { + // The boundary must match too: the header's stored permissionMode alone + // cannot bless a no-op, or a header/boundary divergence would be + // short-circuited as already-committed instead of repaired. + const boundaryMatchesConfiguration = executionBoundaryMatchesPermissionMode( + await this.#stores.readExecutionBoundary(input.sessionId), + configuration.permissionMode, + ); + if ( + !clearsConnectionBlock && + boundaryMatchesConfiguration && + sessionConfigurationMatches(current.header, configuration) + ) { return configurationSuccess({ kind: 'committed', session: projectSessionCatalogRecord( diff --git a/packages/runtime/src/__tests__/tool-runtime-permission-mode.test.ts b/packages/runtime/src/__tests__/tool-runtime-permission-mode.test.ts new file mode 100644 index 0000000000..f6ebafb012 --- /dev/null +++ b/packages/runtime/src/__tests__/tool-runtime-permission-mode.test.ts @@ -0,0 +1,194 @@ +import assert from 'node:assert/strict'; +import { describe, test } from 'node:test'; + +import type { ExecutionBoundary } from '@maka/core/sandbox-boundary'; +import { + createReadOnlyPermissionProfile, + createWorkspaceWritePermissionProfile, +} from '@maka/core/permission-profile'; +import type { SessionEvent } from '@maka/core/events'; +import type { SessionHeader } from '@maka/core/session'; + +import { ToolRuntime, type MakaTool, type ToolRuntimeInput } from '../tool-runtime.js'; + +describe('ToolRuntime permission mode derivation', () => { + test('derives the tool permission mode from the live boundary on every dispatch', async () => { + const writable: ExecutionBoundary = { + kind: 'managed', + profile: createWorkspaceWritePermissionProfile(), + revision: 0, + }; + const bypass: ExecutionBoundary = { kind: 'bypass', revision: 1 }; + const readOnly: ExecutionBoundary = { + kind: 'managed', + profile: createReadOnlyPermissionProfile(), + revision: 2, + }; + // A committed switch flips the durable boundary between two dispatches of + // the same turn; the very next tool call must observe it in both facts. + let boundary: ExecutionBoundary = writable; + const observed: Array> = []; + const runtime = new ToolRuntime({ + turnId: 'turn-1', + sessionId: 'session-1', + header: header(), + connection: { providerType: 'openai', slug: 'test' } as never, + modelId: 'test', + appendMessage: async () => {}, + readExecutionBoundary: async () => boundary, + newId: nextId(), + now: () => 1, + getPermissionPauseTarget: () => null, + } as unknown as ToolRuntimeInput); + const tool: MakaTool = { + name: 'Read', + description: 'test', + parameters: {}, + impl: (_args, context) => { + observed.push({ + executionBoundary: context.executionBoundary, + permissionMode: context.permissionMode, + }); + return { ok: true }; + }, + }; + + await settle(runtime, tool, 'tool-1'); + boundary = bypass; + await settle(runtime, tool, 'tool-2'); + boundary = readOnly; + await settle(runtime, tool, 'tool-3'); + + assert.deepEqual( + observed.map((sample) => [sample.executionBoundary?.kind, sample.permissionMode]), + [ + ['managed', 'ask'], + ['bypass', 'bypass'], + ['managed', 'explore'], + ], + ); + }); + + test('a plan-mode session presents read-only authority at dispatch time unless bypassed', async () => { + let boundary: ExecutionBoundary = { + kind: 'managed', + profile: createWorkspaceWritePermissionProfile(), + revision: 0, + }; + const observed: (string | undefined)[] = []; + const runtime = new ToolRuntime({ + turnId: 'turn-1', + sessionId: 'session-1', + header: header({ collaborationMode: 'plan' }), + connection: { providerType: 'openai', slug: 'test' } as never, + modelId: 'test', + appendMessage: async () => {}, + readExecutionBoundary: async () => boundary, + newId: nextId(), + now: () => 1, + getPermissionPauseTarget: () => null, + } as unknown as ToolRuntimeInput); + const tool: MakaTool = { + name: 'Read', + description: 'test', + parameters: {}, + impl: (_args, context) => { + observed.push(context.permissionMode); + return { ok: true }; + }, + }; + + await settle(runtime, tool, 'tool-1'); + boundary = { kind: 'bypass', revision: 1 }; + await settle(runtime, tool, 'tool-2'); + + assert.deepEqual(observed, ['explore', 'bypass']); + }); + + test('an external boundary falls back to the last known header mode', async () => { + const observed: (string | undefined)[] = []; + const runtime = new ToolRuntime({ + turnId: 'turn-1', + sessionId: 'session-1', + header: header({ permissionMode: 'ask' }), + connection: { providerType: 'openai', slug: 'test' } as never, + modelId: 'test', + appendMessage: async () => {}, + readExecutionBoundary: async () => ({ kind: 'external', revision: 0 }), + newId: nextId(), + now: () => 1, + getPermissionPauseTarget: () => null, + } as unknown as ToolRuntimeInput); + const tool: MakaTool = { + name: 'Read', + description: 'test', + parameters: {}, + impl: (_args, context) => { + observed.push(context.permissionMode); + return { ok: true }; + }, + }; + + await settle(runtime, tool, 'tool-1'); + + assert.deepEqual(observed, ['ask']); + }); +}); + +interface MakaToolContextShape { + executionBoundary: ExecutionBoundary | undefined; + permissionMode: string | undefined; +} + +function header( + overrides: { + permissionMode?: SessionHeader['permissionMode']; + collaborationMode?: SessionHeader['collaborationMode']; + } = {}, +): SessionHeader { + const cwd = process.cwd(); + return { + id: 'session-1', + workspaceRoot: cwd, + cwd, + createdAt: 1, + lastUsedAt: 1, + name: 'test', + titleIsManual: false, + isFlagged: false, + labels: [], + isArchived: false, + status: 'active', + statusUpdatedAt: 1, + hasUnread: false, + backend: 'ai-sdk', + llmConnectionSlug: 'test', + connectionLocked: true, + model: 'test', + permissionMode: overrides.permissionMode ?? 'ask', + ...(overrides.collaborationMode ? { collaborationMode: overrides.collaborationMode } : {}), + schemaVersion: 1, + }; +} + +function nextId(): () => string { + let value = 0; + return () => `id-${++value}`; +} + +async function settle(runtime: ToolRuntime, tool: MakaTool, toolCallId: string): Promise { + const events: SessionEvent[] = []; + await runtime.settleToolCall({ + tool, + turnId: 'turn-1', + toolCallId, + input: {}, + abortSignal: new AbortController().signal, + eventSink: { + push: (event) => events.push(event), + pushAndWaitUntilConsumed: async (event) => { + events.push(event); + }, + }, + }); +} diff --git a/packages/runtime/src/session-manager.ts b/packages/runtime/src/session-manager.ts index af8313a4f7..40125dcfdb 100644 --- a/packages/runtime/src/session-manager.ts +++ b/packages/runtime/src/session-manager.ts @@ -80,6 +80,7 @@ import type { SandboxBoundarySettlement, SettleSandboxBoundaryRequest, } from '@maka/core/sandbox-boundary'; +import { executionBoundaryMatchesPermissionMode } from '@maka/core/sandbox-boundary'; import type { CollaborationMode } from '@maka/core/collaboration'; import type { OrchestrationMode } from '@maka/core/orchestration'; import { @@ -6290,17 +6291,6 @@ function claimedAgentGraphIntentResult( }; } -function executionBoundaryMatchesPermissionMode( - boundary: ExecutionBoundary, - mode: PermissionMode, -): boolean { - if (mode === 'bypass') return boundary.kind === 'bypass'; - if (boundary.kind !== 'managed') return false; - return mode === 'explore' - ? boundary.profile.name === 'read-only' - : boundary.profile.name !== 'read-only'; -} - function narrowsExecutionAuthority( boundary: ExecutionBoundary, nextPermissionMode: PermissionMode, diff --git a/packages/runtime/src/tool-runtime.ts b/packages/runtime/src/tool-runtime.ts index b46e2917a2..9daf9a13c5 100644 --- a/packages/runtime/src/tool-runtime.ts +++ b/packages/runtime/src/tool-runtime.ts @@ -23,12 +23,14 @@ import { projectToolActivityArgs } from '@maka/core/tool-activity-args'; import { type CreateSandboxBoundaryRequest, type ExecutionBoundary, + executionBoundaryDisplayMode, type SandboxBoundaryDecision, type SandboxBoundaryExpansion, type SandboxBoundaryRequest, type SandboxBoundarySettlement, type SettleSandboxBoundaryRequest, } from '@maka/core/sandbox-boundary'; +import { resolveCollaborationPermissionMode } from '@maka/core/permission'; import { serializedByteLength } from '@maka/core/serialized-byte-length'; import { encodeToolStepProgress, ToolOutcomeUnknownError } from '@maka/core/events'; import type { @@ -1444,6 +1446,20 @@ export class ToolRuntime { try { const runId = this.input.runId; const executionBoundary = clientCapabilityBoundary ?? (await this.readExecutionBoundary()); + // The boundary is the authority on what this session may do (#1611); + // the mode a tool acts on is derived from it per call, so a committed + // permission switch reaches the very next tool dispatch without + // waiting for a backend rebuild. An external boundary is not locally + // controllable and derives no mode — the last known header mode is the + // best available answer there. + const boundaryMode = executionBoundaryDisplayMode(executionBoundary); + const permissionMode = + boundaryMode === undefined + ? this.input.header.permissionMode + : resolveCollaborationPermissionMode({ + collaborationMode: this.input.header.collaborationMode ?? 'agent', + permissionMode: boundaryMode, + }); const result = await tool.impl(structuredClone(executionArgs) as never, { sessionId: this.input.sessionId, turnId, @@ -1453,7 +1469,7 @@ export class ToolRuntime { : {}), cwd: this.input.header.cwd, executionBoundary, - permissionMode: this.input.header.permissionMode, + permissionMode, toolCallId: toolUseId, // The id the call event actually carries, not the candidate: by here // `prepareDurableToolAttempt` has pushed it on the dispatch lane. From 5e0db8ddd59f62145a7963b4380c6e64d7989507 Mon Sep 17 00:00:00 2001 From: chinawch007 Date: Sat, 22 Aug 2026 18:11:25 +0800 Subject: [PATCH 04/19] feat(runtime): boundary-revision guard for backend generation reuse (#3349) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Commits 1-3 rely on every config write disposing the backend before it commits. Keep that convention from staying implicit: each generation now records the boundary revision it was composed against, and ensureActive compares it against the store before reuse. On drift with no active runs the generation is disposed and rebuilt in the same activation; with runs still live it is only marked for invalidation — the existing flush path retires it when they exit and the next activation composes fresh. Tools stay correct during the grace turn: they read the boundary live on every call. An unreadable boundary leaves the guard dormant; a self-heal test drives a stray revision bump through both the idle and the live-run branches. Generated-by: ZCode (Z.ai GLM) --- .../src/__tests__/session-manager.test.ts | 93 +++++++++++++++++++ packages/runtime/src/runtime-kernel.ts | 63 ++++++++++++- 2 files changed, 152 insertions(+), 4 deletions(-) diff --git a/packages/runtime/src/__tests__/session-manager.test.ts b/packages/runtime/src/__tests__/session-manager.test.ts index ed9ed3aa0b..a4a7bdec41 100644 --- a/packages/runtime/src/__tests__/session-manager.test.ts +++ b/packages/runtime/src/__tests__/session-manager.test.ts @@ -5177,6 +5177,94 @@ describe('SessionManager permission mode updates', () => { expect(builtPermissionModes).toEqual(['ask', 'bypass']); }); + test('a boundary revision bump without backend disposal rebuilds on the next activation', async () => { + const store = new MemorySessionStore(); + const runStore = new MemoryAgentRunStore(); + const backends = new BackendRegistry(); + let builds = 0; + backends.register('ai-sdk', (ctx) => { + builds += 1; + return new TestBackend(ctx); + }); + const manager = new SessionManager({ + store, + runStore, + runtimeEventStore: runStore, + backends, + newId: nextId(), + now: nextNow(8_000), + }); + const session = await manager.createSession(makeInput({ permissionMode: 'ask' })); + + await drain(manager.sendMessage(session.id, { turnId: 'turn-1', text: 'one' })); + expect(builds).toBe(1); + + // A write path that skips backend disposal bumps the durable boundary + // while the generation stays alive. + store.forceExecutionBoundary(session.id, { kind: 'bypass', revision: 5 }); + + await drain(manager.sendMessage(session.id, { turnId: 'turn-2', text: 'two' })); + expect(builds).toBe(2); + expect(store.disposeCount).toBe(1); + + // Once rebuilt against the current revision, the generation is reused again. + await drain(manager.sendMessage(session.id, { turnId: 'turn-3', text: 'three' })); + expect(builds).toBe(2); + }); + + test('a stale generation with live runs flushes after they exit instead of disposing underneath them', async () => { + const store = new MemorySessionStore(); + const runStore = new MemoryAgentRunStore(); + const backends = new BackendRegistry(); + const gates: Gate[] = []; + let builds = 0; + backends.register('ai-sdk', (ctx) => { + builds += 1; + const gate = makeGate(); + gates.push(gate); + return new TestBackend(ctx, gate); + }); + const manager = new SessionManager({ + store, + runStore, + runtimeEventStore: runStore, + backends, + newId: nextId(), + now: nextNow(8_000), + }); + const session = await manager.createSession(makeInput({ permissionMode: 'ask' })); + + const first = manager + .sendMessage(session.id, { turnId: 'turn-1', text: 'one' }) + [Symbol.asyncIterator](); + expect((await first.next()).value?.type).toBe('text_delta'); + expect(builds).toBe(1); + + store.forceExecutionBoundary(session.id, { kind: 'bypass', revision: 5 }); + + // An overlapping activation while turn 1 is live must not dispose the + // generation underneath it: the generation is marked, reused for this + // turn, and flushed once both runs exit. Both turns share the reused + // backend, so a single gate holds them both. + const second = manager + .sendMessage(session.id, { turnId: 'turn-2', text: 'two' }) + [Symbol.asyncIterator](); + expect((await second.next()).value?.type).toBe('text_delta'); + expect(builds).toBe(1); + + gates[0]!.release(); + while (!(await first.next()).done) {} + while (!(await second.next()).done) {} + + const third = manager + .sendMessage(session.id, { turnId: 'turn-3', text: 'three' }) + [Symbol.asyncIterator](); + expect((await third.next()).value?.type).toBe('text_delta'); + gates[1]!.release(); + while (!(await third.next()).done) {} + expect(builds).toBe(2); + }); + test('leaving explore clears the deep research label so visible read-only copy stays truthful', async () => { const store = new MemorySessionStore(); const backends = new BackendRegistry(); @@ -16728,6 +16816,11 @@ class MemorySessionStore implements SessionStore { return boundary; } + /** Simulates a config write path that bumps the boundary without disposing backends. */ + forceExecutionBoundary(sessionId: string, boundary: ExecutionBoundary): void { + this.executionBoundaries.set(sessionId, boundary); + } + async createSandboxBoundaryRequest( input: CreateSandboxBoundaryRequest, ): Promise { diff --git a/packages/runtime/src/runtime-kernel.ts b/packages/runtime/src/runtime-kernel.ts index d893650deb..3b37546617 100644 --- a/packages/runtime/src/runtime-kernel.ts +++ b/packages/runtime/src/runtime-kernel.ts @@ -319,6 +319,13 @@ interface BackendGeneration extends AgentRunActiveSession { | { kind: 'failed'; error: unknown }; disposal?: Promise; disposalFailure?: Error; + /** + * The durable boundary revision this generation was composed against. + * `ensureActive` compares it against the store to rebuild when a config + * write skipped backend disposal; `undefined` (unreadable at build) keeps + * the guard dormant for this generation. + */ + boundaryRevision?: number; cachedHeader: SessionHeader; activeRuns: Map; turnToRunId: Map; @@ -2785,16 +2792,27 @@ export class RuntimeKernel implements RuntimeKernelLike { execution: PendingExecutionClaim, ): Promise { await this.clearBackendQuarantineForActivation(sessionId, execution); + // The boundary revision this activation is composed against. Recorded on + // the generation so a later activation can detect a config write that + // skipped backend disposal (#3349). An unreadable boundary leaves the + // guard dormant rather than blocking activation. + const boundaryRevision = await this.readBoundaryRevision(sessionId); let existing = this.active.get(sessionId); if (existing) { - existing.cachedHeader = header; - return existing; + const reusable = await this.resolveReusableGeneration(sessionId, existing, boundaryRevision); + if (reusable) { + reusable.cachedHeader = header; + return reusable; + } } await this.waitForBackendDisposal(sessionId); existing = this.active.get(sessionId); if (existing) { - existing.cachedHeader = header; - return existing; + const reusable = await this.resolveReusableGeneration(sessionId, existing, boundaryRevision); + if (reusable) { + reusable.cachedHeader = header; + return reusable; + } } const entry = await this.shareBackendActivation(`parent:${sessionId}`, async () => { const current = this.active.get(sessionId); @@ -2828,10 +2846,47 @@ export class RuntimeKernel implements RuntimeKernelLike { this.active.set(sessionId, generation); return generation; }); + entry.boundaryRevision ??= boundaryRevision; entry.cachedHeader = header; return entry; } + /** + * Defense in depth against a config write that bumped the durable boundary + * without disposing the backend generation it was composed against: dispose + * and rebuild now when nothing executes on the generation, and when runs are + * still live, mark the generation for invalidation instead — it flushes when + * they exit, and the next activation composes fresh. Tools are unaffected + * meanwhile: they read the boundary live on every call. + */ + private async resolveReusableGeneration( + sessionId: string, + existing: BackendGeneration, + boundaryRevision: number | undefined, + ): Promise { + if ( + boundaryRevision === undefined || + existing.boundaryRevision === undefined || + existing.boundaryRevision === boundaryRevision + ) { + return existing; + } + if (this.hasActiveRuns(sessionId)) { + this.ensureBackendInvalidation(sessionId); + return existing; + } + await this.disposeBackend(sessionId); + return undefined; + } + + private async readBoundaryRevision(sessionId: string): Promise { + try { + return (await this.deps.store.readExecutionBoundary(sessionId)).revision; + } catch { + return undefined; + } + } + private async shareBackendActivation( activationKey: string, activate: () => Promise, From af0f9bdd5f37c207fadf04bc8a06454348764014 Mon Sep 17 00:00:00 2001 From: chinawch007 Date: Sat, 22 Aug 2026 19:56:01 +0800 Subject: [PATCH 05/19] test(runtime): permission switch race matrix and seeded interleaving sweep (#3349) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Complete the verification plan: the plain-session case the reporter asked for drives an ordinary turn, an idle Auto→Bypass switch, and the next turn, asserting the successor is composed from the committed mode and that a probe tool call — resolving the boundary through the session's own store, the same read a real dispatch performs — sees executionBoundary.kind === 'bypass' and permissionMode === 'bypass' at once. A seeded sweep then alternates ask/bypass (both widening and narrowing, the latter through the shell-run fence) across interleaving classes — idle, mid-turn, racing the turn's release — asserting the invariant that every turn started after a switch resolved observes the committed configuration. Every checkpoint awaits a deterministic event; the seed is fixed, so failures reproduce. Together with the earlier suites this closes the matrix: mid-turn, gap/idle, successor-claim-pending, waiting_for_user, mid-dispatch boundary flips, and stray revision bumps. Generated-by: ZCode (Z.ai GLM) --- .../src/__tests__/session-manager.test.ts | 175 ++++++++++++++++++ 1 file changed, 175 insertions(+) diff --git a/packages/runtime/src/__tests__/session-manager.test.ts b/packages/runtime/src/__tests__/session-manager.test.ts index a4a7bdec41..ae6014279c 100644 --- a/packages/runtime/src/__tests__/session-manager.test.ts +++ b/packages/runtime/src/__tests__/session-manager.test.ts @@ -24,6 +24,7 @@ import { createHash } from 'node:crypto'; import { applySandboxBoundaryExpansion, createGenesisExecutionBoundary, + executionBoundaryDisplayMode, isSandboxBoundaryRestartClosure, } from '@maka/core/sandbox-boundary'; import { createWorkspaceWritePermissionProfile } from '@maka/core/permission-profile'; @@ -91,6 +92,7 @@ import { type SessionStore, type VersionedSessionHeader, } from '../session-manager.js'; +import { ToolRuntime, type ToolRuntimeInput } from '../tool-runtime.js'; import { RuntimeContextCompactError, RuntimeKernel, @@ -5265,6 +5267,126 @@ describe('SessionManager permission mode updates', () => { expect(builds).toBe(2); }); + test('an idle Auto→Bypass switch is observed by the next turn and its first tool dispatch', async () => { + const store = new MemorySessionStore(); + const runStore = new MemoryAgentRunStore(); + const backends = new BackendRegistry(); + const composedModes: SessionHeader['permissionMode'][] = []; + backends.register('ai-sdk', (ctx) => { + composedModes.push(ctx.header.permissionMode); + return new TestBackend(ctx); + }); + const manager = new SessionManager({ + store, + runStore, + runtimeEventStore: runStore, + backends, + newId: nextId(), + now: nextNow(8_000), + }); + const session = await manager.createSession(makeInput({ permissionMode: 'ask' })); + + await drain(manager.sendMessage(session.id, { turnId: 'turn-1', text: 'first' })); + + // The plain-session case from the issue: switch between two ordinary + // turns, no Goal, session idle. + const summary = await manager.setPermissionMode(session.id, 'bypass'); + expect(summary.permissionMode).toBe('bypass'); + + await drain(manager.sendMessage(session.id, { turnId: 'turn-2', text: 'second' })); + expect(composedModes).toEqual(['ask', 'bypass']); + + // The reporter's dual assertion, at the layer a real dispatch reads: a + // tool call resolving this session's boundary — through the same store + // the switch committed to — must see both facts at once. + const dispatch = await dispatchProbeTool(store, session.id); + expect(dispatch.boundaryKind).toBe('bypass'); + expect(dispatch.permissionMode).toBe('bypass'); + }); + + test('seeded switch/turn interleavings always observe the committed mode', async () => { + // A fixed-seed PRNG picks the interleaving class per iteration; every + // checkpoint awaits a deterministic event, so the sweep is reproducible. + let seed = 0x3349; + const random = (): number => { + seed = (seed * 1_103_515_245 + 12_345) % 2_147_483_648; + return seed / 2_147_483_648; + }; + + const store = new MemorySessionStore(); + const runStore = new MemoryAgentRunStore(); + const backends = new BackendRegistry(); + const gates: Gate[] = []; + const composedModes: SessionHeader['permissionMode'][] = []; + backends.register('ai-sdk', (ctx) => { + const gate = makeGate(); + gates.push(gate); + composedModes.push(ctx.header.permissionMode); + return new TestBackend(ctx, gate); + }); + const manager = new SessionManager({ + store, + runStore, + runtimeEventStore: runStore, + backends, + // Narrowing (bypass → ask) fences shell runs through this authority. + shellRuns: { + async terminateSession() { + return undefined; + }, + async commitSessionClose() {}, + rollbackSessionClose() {}, + resumeSession() {}, + } as never, + newId: nextId(), + now: nextNow(9_000), + }); + const session = await manager.createSession(makeInput({ permissionMode: 'ask' })); + + let expected: 'ask' | 'bypass' = 'ask'; + let turnCount = 0; + for (let iteration = 0; iteration < 12; iteration += 1) { + const nextMode: 'ask' | 'bypass' = random() < 0.5 ? 'bypass' : 'ask'; + const interleaving = Math.floor(random() * 3); + + if (interleaving === 0) { + // Switch while the session is idle. + await manager.setPermissionMode(session.id, nextMode); + expected = nextMode; + } else { + // Switch requested while a turn is running; the queued commit lands + // in the gap as the turn settles (class 1 requests it mid-flight, + // class 2 races it with the gate release). + turnCount += 1; + const turn = manager + .sendMessage(session.id, { turnId: `turn-${turnCount}`, text: `t${turnCount}` }) + [Symbol.asyncIterator](); + expect((await turn.next()).value?.type).toBe('text_delta'); + const switching = manager.setPermissionMode(session.id, nextMode); + if (interleaving === 2) gates[gates.length - 1]!.release(); + if (interleaving === 1) gates[gates.length - 1]!.release(); + while (!(await turn.next()).done) {} + await switching; + expected = nextMode; + } + + // Invariant: every turn started after the switch resolved is composed + // from the committed mode, and a tool call against the committed store + // derives the same mode. + turnCount += 1; + const verify = manager + .sendMessage(session.id, { turnId: `turn-${turnCount}`, text: `t${turnCount}` }) + [Symbol.asyncIterator](); + expect((await verify.next()).value?.type).toBe('text_delta'); + expect(composedModes[composedModes.length - 1]).toBe(expected); + const dispatch = await dispatchProbeTool(store, session.id); + expect(dispatch.boundaryKind).toBe(expected === 'bypass' ? 'bypass' : 'managed'); + expect(dispatch.permissionMode).toBe(expected); + gates[gates.length - 1]!.release(); + while (!(await verify.next()).done) {} + } + }); + test('leaving explore clears the deep research label so visible read-only copy stays truthful', async () => { const store = new MemorySessionStore(); const backends = new BackendRegistry(); @@ -18430,6 +18552,59 @@ async function drain(iterable: AsyncIterable): Promise { } } +/** + * Dispatches one probe tool call whose boundary resolves through the given + * session's own store — the same read a real tool dispatch performs — and + * reports both facts a tool acts on: the boundary kind and the derived + * permission mode. + */ +async function dispatchProbeTool( + store: SessionStore, + sessionId: string, +): Promise<{ boundaryKind: ExecutionBoundary['kind']; permissionMode: string | undefined }> { + const observed: Array<{ + kind: ExecutionBoundary['kind'] | undefined; + mode: string | undefined; + }> = []; + const runtime = new ToolRuntime({ + turnId: 'probe-turn', + sessionId, + header: await store.readHeader(sessionId), + connection: { providerType: 'openai', slug: 'probe' } as never, + modelId: 'probe', + appendMessage: async () => {}, + readExecutionBoundary: () => store.readExecutionBoundary(sessionId), + newId: nextId(), + now: () => 1, + getPermissionPauseTarget: () => null, + } as unknown as ToolRuntimeInput); + const tool: MakaTool = { + name: 'Read', + description: 'probe', + parameters: {}, + impl: (_args, context) => { + observed.push({ kind: context.executionBoundary?.kind, mode: context.permissionMode }); + return { ok: true }; + }, + }; + const events: SessionEvent[] = []; + await runtime.settleToolCall({ + tool, + turnId: 'probe-turn', + toolCallId: 'probe-tool', + input: {}, + abortSignal: new AbortController().signal, + eventSink: { + push: (event) => events.push(event), + pushAndWaitUntilConsumed: async (event) => { + events.push(event); + }, + }, + }); + const sample = observed[0]!; + return { boundaryKind: sample.kind as ExecutionBoundary['kind'], permissionMode: sample.mode }; +} + async function collectSessionEvents( iterable: AsyncIterable, ): Promise { From 1559d7eb14edc3db2266f92f4724ab3e2c587624 Mon Sep 17 00:00:00 2001 From: chinawch007 Date: Sat, 22 Aug 2026 21:31:24 +0800 Subject: [PATCH 06/19] fix(runtime): close an admission gate instead of holding the tail while awaiting quiescence (#3349) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review finding (P0): the queued quiescent mutation held the session's mutation tail for its entire wait, and a running turn can depend on an admission mutation enqueued on its own session's tail — graph operator provisioning runs on the supervisor's session exactly when the supervisor's yield tool is waiting on a reconciliation milestone that needs it. Tail held + run drained + run waiting on the tail = deadlock. Decouple the two: the mutation now closes a per-session admission gate (new claims capture it in their admission barrier) and waits for quiescence WITHOUT the tail, so admission mutations a running turn depends on still pass. Only after quiescence does the operation join the tail, still serialized with other mutations; claims created after the request stay gated until it completes. Session-manager side, the wait is now scoped to the primary session only — waiting on descendants could deadlock the same way through gated child claims — and descendant activity is rejected at commit time instead, restoring the pre-queue session_busy guard as a truthful failure rather than a hang. Also corrects the falsified invariant in the doc comment: a running turn CAN enqueue a mutation on its own session's tail; the kernel regression test drives exactly that interleaving. Generated-by: ZCode (Z.ai GLM) --- ...e-kernel-queued-quiescent-mutation.test.ts | 37 ++++++++++ .../src/__tests__/session-manager.test.ts | 66 +++++++++++++++++ packages/runtime/src/runtime-kernel.ts | 70 ++++++++++++++----- packages/runtime/src/session-manager.ts | 21 ++++-- 4 files changed, 173 insertions(+), 21 deletions(-) diff --git a/packages/runtime/src/__tests__/runtime-kernel-queued-quiescent-mutation.test.ts b/packages/runtime/src/__tests__/runtime-kernel-queued-quiescent-mutation.test.ts index 76ad66871d..f321d0be80 100644 --- a/packages/runtime/src/__tests__/runtime-kernel-queued-quiescent-mutation.test.ts +++ b/packages/runtime/src/__tests__/runtime-kernel-queued-quiescent-mutation.test.ts @@ -199,6 +199,43 @@ describe('RuntimeKernel queued quiescent mutation', () => { while (!(await iterator.next()).done) {} assert.equal(await within(result.promise), 'committed'); }); + + test('an admission mutation a running turn depends on passes while the queued mutation waits', async () => { + // Graph operator provisioning (#3349 review): a running supervisor turn's + // completion can depend on an admission mutation enqueued on its own + // session's tail. The queued mutation must therefore never hold the tail + // while it waits for quiescence — only the admission gate closes. + const gate = deferred(); + const backends = new BackendRegistry(); + backends.register('ai-sdk', (ctx) => new GatedBackend(ctx, gate.promise)); + let id = 0; + const kernel = new RuntimeKernel({ + store: memoryStore(), + backends, + newId: () => `queued-quiescent-id-${++id}`, + now: () => id, + }); + const iterator = kernel + .startTurn(SESSION_ID, { turnId: 'turn-provision', text: 'start' }) + [Symbol.asyncIterator](); + assert.equal((await iterator.next()).value?.type, 'text_delta'); + + const queued = track( + kernel.runSessionQueuedQuiescentMutation([SESSION_ID], () => 'committed'), + ); + await settleTicks(); + assert.equal(queued.settled, false); + + const admission = track( + kernel.runSessionAdmissionMutation([SESSION_ID], () => 'provisioned'), + ); + assert.equal(await within(admission.promise), 'provisioned'); + assert.equal(queued.settled, false, 'the queued mutation still waits for the run'); + + gate.resolve(); + while (!(await iterator.next()).done) {} + assert.equal(await within(queued.promise), 'committed'); + }); }); function newKernel(): RuntimeKernel { diff --git a/packages/runtime/src/__tests__/session-manager.test.ts b/packages/runtime/src/__tests__/session-manager.test.ts index ae6014279c..e0ba862d35 100644 --- a/packages/runtime/src/__tests__/session-manager.test.ts +++ b/packages/runtime/src/__tests__/session-manager.test.ts @@ -5267,6 +5267,72 @@ describe('SessionManager permission mode updates', () => { expect(builds).toBe(2); }); + test('narrowing with an active descendant rejects at commit time instead of hanging', async () => { + const store = new MemorySessionStore(); + const runStore = new MemoryAgentRunStore(); + const backends = new BackendRegistry(); + const childGate = makeGate(); + backends.register('ai-sdk', (ctx) => new TestBackend(ctx, childGate)); + const manager = new SessionManager({ + store, + runStore, + runtimeEventStore: runStore, + backends, + // Narrowing fences shell runs through this authority. + shellRuns: { + async terminateSession() { + return undefined; + }, + async commitSessionClose() {}, + rollbackSessionClose() {}, + resumeSession() {}, + } as never, + newId: nextId(), + now: nextNow(8_000), + }); + const parent = await manager.createSession(makeInput({ permissionMode: 'bypass' })); + const child = await manager.createSession( + makeInput({ + permissionMode: 'ask', + subagentParent: { + kind: 'subagent', + parentSessionId: parent.id, + spawnedBy: { + parentRunId: 'parent-run', + parentTurnId: 'parent-turn', + toolCallId: 'parent-tool', + }, + lifecycle: 'foreground', + }, + subagentRuntime: { + schemaVersion: 1, + definitionVersion: 1, + agentId: 'descendant-agent', + agentName: 'descendant-agent', + profile: 'default', + systemPrompt: '', + toolNames: [], + categoryPolicy: {}, + }, + }), + ); + + // The child session runs a gated turn; narrowing the parent must not wait + // on it — the parent's own supervisor chain could depend on the child, so + // waiting could deadlock. It rejects at commit time instead. + const childTurn = manager + .sendMessage(child.id, { turnId: 'child-turn', text: 'work' }) + [Symbol.asyncIterator](); + expect((await childTurn.next()).value?.type).toBe('text_delta'); + + await expectRejects(manager.setPermissionMode(parent.id, 'ask'), /linked Turn is active/); + + childGate.release(); + while (!(await childTurn.next()).done) {} + const summary = await manager.setPermissionMode(parent.id, 'ask'); + expect(summary.permissionMode).toBe('ask'); + }); + test('an idle Auto→Bypass switch is observed by the next turn and its first tool dispatch', async () => { const store = new MemorySessionStore(); const runStore = new MemoryAgentRunStore(); diff --git a/packages/runtime/src/runtime-kernel.ts b/packages/runtime/src/runtime-kernel.ts index 3b37546617..10f765ab1e 100644 --- a/packages/runtime/src/runtime-kernel.ts +++ b/packages/runtime/src/runtime-kernel.ts @@ -422,6 +422,10 @@ export class RuntimeKernel implements RuntimeKernelLike { private readonly sessionMutationTails = new Map>(); private claimSequence = 0; private readonly sessionQuiescenceWaiters = new Map void>>(); + private readonly sessionAdmissionGates = new Map< + string, + Set<{ promise: Promise; open: () => void }> + >(); private readonly executionClaimStates = new WeakMap< RuntimeExecutionClaim, PendingExecutionClaim @@ -472,7 +476,7 @@ export class RuntimeKernel implements RuntimeKernelLike { claimSeq: ++this.claimSequence, abortController, cancellation, - admissionBarrier: this.sessionMutationTails.get(sessionId) ?? Promise.resolve(), + admissionBarrier: this.admissionBarrierFor(sessionId), settled, resolveSettled, rejectSettled, @@ -508,10 +512,11 @@ export class RuntimeKernel implements RuntimeKernelLike { } /** - * A quiescent mutation that queues instead of bailing: the operation claims a - * slot on each session's mutation tail right away, then runs only once the - * session is at rest — every execution claim that already existed when the - * mutation was requested has settled, and no run is active. Unlike + * A quiescent mutation that queues instead of bailing: the session's + * admission gate closes right away — claims created from then on capture it + * in their admission barrier — and the operation runs only once the session + * is at rest: every execution claim that already existed when the mutation + * was requested has settled, and no run is active. Unlike * `runSessionQuiescentMutation`, a busy session delays the operation — by at * most one turn's lifetime — instead of rejecting it. * @@ -521,17 +526,19 @@ export class RuntimeKernel implements RuntimeKernelLike { * its backend generation (reserve step) before its claim settles, so there is * no instant where an in-flight admission is invisible to both checks. * - * Deadlock freedom rests on two facts. First, claims created after the tail - * reservation capture that reservation in their admission barrier, so neither - * they nor runs started through them can appear before this mutation has run; - * the mutation only waits on strictly older executions. Second, waiting chains - * therefore always run backwards in claim-creation order, so no cycle can - * close — a running turn never enqueues a mutation on its own session's tail. + * Deadlock freedom rests on the mutation tail NOT being held while + * quiescence is awaited: a running turn may legitimately depend on an + * admission mutation enqueued on its own session's tail (graph operator + * provisioning, #3349 review), so gating happens through the admission gate + * — which claims observe — while mutations pass freely. The operation joins + * the tail only after quiescence, still serialized with other mutations. + * Claims created after the request wait at the gate, so they cannot attach + * before the operation has run, and waiting chains run strictly backwards in + * claim-creation order, so no cycle can close. * * The claim frontier must be captured in the same synchronous block as the - * tail reservation (`enqueueSessionMutation` registers tails before its first - * await): that keeps claim-creation order and barrier order identical, which - * is what makes "strictly older" meaningful. + * gate closing: that keeps claim-creation order and gate order identical, + * which is what makes "strictly older" meaningful. */ async runSessionQueuedQuiescentMutation( sessionIds: readonly string[], @@ -539,10 +546,41 @@ export class RuntimeKernel implements RuntimeKernelLike { ): Promise { const ids = this.normalizeSessionMutationIds(sessionIds); const claimFrontier = this.claimSequence; - return this.enqueueSessionMutation(ids, async () => { + const openGates = ids.map((sessionId) => this.closeAdmissionGate(sessionId)); + try { await this.waitForSessionQuiescence(ids, claimFrontier); - return await operation(); + return await this.enqueueSessionMutation(ids, operation); + } finally { + for (const openGate of openGates) openGate(); + } + } + + private closeAdmissionGate(sessionId: string): () => void { + let open!: () => void; + const promise = new Promise((resolve) => { + open = resolve; }); + const gate = { promise, open }; + let gates = this.sessionAdmissionGates.get(sessionId); + if (!gates) { + gates = new Set(); + this.sessionAdmissionGates.set(sessionId, gates); + } + gates.add(gate); + return () => { + gates.delete(gate); + if (gates.size === 0) this.sessionAdmissionGates.delete(sessionId); + open(); + }; + } + + private admissionBarrierFor(sessionId: string): Promise { + const tail = this.sessionMutationTails.get(sessionId) ?? Promise.resolve(); + const gates = this.sessionAdmissionGates.get(sessionId); + if (!gates || gates.size === 0) return tail; + return Promise.all([tail, ...[...gates].map((gate) => gate.promise)]).then( + () => undefined, + ); } private normalizeSessionMutationIds(sessionIds: readonly string[]): string[] { diff --git a/packages/runtime/src/session-manager.ts b/packages/runtime/src/session-manager.ts index 40125dcfdb..07314751db 100644 --- a/packages/runtime/src/session-manager.ts +++ b/packages/runtime/src/session-manager.ts @@ -1700,7 +1700,11 @@ export class SessionManager { : []; const fencedSessionIds = [sessionId, ...initialDescendants]; - return this.runSessionQueuedQuiescentMutation(fencedSessionIds, async () => { + // Only the primary session is waited on: a descendant's execution may + // depend on claims this mutation would gate (graph supervisor chains), so + // waiting on descendants can deadlock. Descendant activity is instead + // rejected at commit time below — a truthful failure, never a hang. + return this.runSessionQueuedQuiescentMutation([sessionId], async () => { const currentBoundary = await this.deps.store.readExecutionBoundary(sessionId); const narrowsShellAuthority = narrowsExecutionAuthority(currentBoundary, nextPermissionMode); const descendantSessionIds = narrowsShellAuthority @@ -1717,6 +1721,12 @@ export class SessionManager { ); } const lineageSessionIds = [sessionId, ...descendantSessionIds]; + if (lineageSessionIds.some((id) => this.runtimeKernel.hasActiveRuns(id))) { + throw new SessionConfigurationTransitionError( + 'session_busy', + 'Session configuration cannot change while a linked Turn is active', + ); + } if (narrowsShellAuthority && !this.deps.shellRuns) { throw new SessionConfigurationTransitionError( 'operation_unavailable', @@ -1801,10 +1811,11 @@ export class SessionManager { /** * Quiescent mutation that queues behind live execution instead of rejecting: - * the kernel defers the operation until every claim that predates the request - * settles, so a permission switch lands in the next inter-turn gap. Turns - * admitted after the request are admission-barrier-gated on the reserved - * slot, which is what lets them observe the committed configuration. + * the kernel closes the session's admission gate and defers the operation + * until the claims and runs that predate the request settle, so a permission + * switch lands in the next inter-turn gap. Turns admitted after the request + * wait at the gate, which is what lets them observe the committed + * configuration. */ private async runSessionQueuedQuiescentMutation( sessionIds: readonly string[], From 3ffaf6e89b543d802bc1e4cbbc048a5618750f80 Mon Sep 17 00:00:00 2001 From: chinawch007 Date: Sat, 22 Aug 2026 21:33:22 +0800 Subject: [PATCH 07/19] fix(core): match permission modes through the structural derivation (#3349) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review finding (P1a): executionBoundaryMatchesPermissionMode judged read-only-ness by profile NAME while the authoritative display mode judges it structurally (#1611). A read-only-named profile widened by an approved expansion therefore read as explore to the matcher while presenting as ask — the catalog short-circuit could bless exactly the profile-level divergence this series set out to repair — and a custom structurally-read-only profile forced a transition that silently reset it to the canonical explore profile. Derive the match from executionBoundaryDisplayMode so both answers come from one implementation: a widened read-only no longer matches explore (repaired through the transition instead), custom read-only profiles match and are preserved, legacy 'execute' never matches (forcing the transition is the safe direction), and an external boundary stays unverifiable. Generated-by: ZCode (Z.ai GLM) --- .../src/__tests__/sandbox-boundary.test.ts | 53 +++++++++++++++++++ packages/core/src/sandbox-boundary.ts | 13 ++--- 2 files changed, 60 insertions(+), 6 deletions(-) diff --git a/packages/core/src/__tests__/sandbox-boundary.test.ts b/packages/core/src/__tests__/sandbox-boundary.test.ts index 733caeb954..541f696d3c 100644 --- a/packages/core/src/__tests__/sandbox-boundary.test.ts +++ b/packages/core/src/__tests__/sandbox-boundary.test.ts @@ -27,6 +27,7 @@ import { decodeExecutionBoundary, executionBoundaryContains, executionBoundaryDisplayMode, + executionBoundaryMatchesPermissionMode, validateSandboxBoundaryExpansion, } from '../sandbox-boundary.js'; import { @@ -73,6 +74,58 @@ describe('executionBoundaryDisplayMode', () => { ); }); + test('a widened read-only profile no longer matches explore (#3349)', () => { + const widened = applySandboxBoundaryExpansion(createReadOnlyPermissionProfile(), { + filesystem: { entries: [{ path: '/outside/dist', access: 'write', scope: 'subtree' }] }, + }); + const boundary = { kind: 'managed', profile: widened, revision: 1 } as const; + + // The name stayed 'read-only' while the structure became writable: a + // no-op short-circuit keyed on this answer must not bless that divergence. + expect(executionBoundaryMatchesPermissionMode(boundary, 'explore')).toBe(false); + expect(executionBoundaryMatchesPermissionMode(boundary, 'ask')).toBe(true); + }); + + test('matching follows the structural derivation, not the profile name', () => { + const customReadOnly: PermissionProfileManaged = { + ...createReadOnlyPermissionProfile(), + name: 'custom', + }; + expect( + executionBoundaryMatchesPermissionMode( + { kind: 'managed', profile: customReadOnly, revision: 0 }, + 'explore', + ), + ).toBe(true); + expect( + executionBoundaryMatchesPermissionMode( + { kind: 'managed', profile: createWorkspaceWritePermissionProfile(), revision: 0 }, + 'ask', + ), + ).toBe(true); + expect(executionBoundaryMatchesPermissionMode({ kind: 'bypass', revision: 0 }, 'bypass')).toBe( + true, + ); + expect( + executionBoundaryMatchesPermissionMode( + { kind: 'managed', profile: createWorkspaceWritePermissionProfile(), revision: 0 }, + 'bypass', + ), + ).toBe(false); + }); + + test('legacy execute never matches and an external boundary is not verifiable', () => { + expect( + executionBoundaryMatchesPermissionMode( + { kind: 'managed', profile: createWorkspaceWritePermissionProfile(), revision: 0 }, + 'execute', + ), + ).toBe(false); + expect(executionBoundaryMatchesPermissionMode({ kind: 'external', revision: 0 }, 'ask')).toBe( + false, + ); + }); + test('under-states danger-full-access as Auto rather than naming a mode for it', () => { // A deliberate collapse, NOT a description of this profile: the picker // offers two modes and no third one is being invented for a profile the diff --git a/packages/core/src/sandbox-boundary.ts b/packages/core/src/sandbox-boundary.ts index d9aaeb3080..cb068bd9f4 100644 --- a/packages/core/src/sandbox-boundary.ts +++ b/packages/core/src/sandbox-boundary.ts @@ -228,19 +228,20 @@ export function executionBoundaryDisplayMode( /** * Whether the durable boundary already expresses the requested permission - * mode. Callers that short-circuit a no-op configuration update on this + * mode, derived through the same structural read (#1611) as the display mode: + * a read-only-named profile widened by an approved expansion no longer reads + * as explore. Callers that short-circuit a no-op configuration update on this * answer must consult it: comparing the header's stored `permissionMode` * alone would bless a header/boundary divergence as already-committed. + * Legacy 'execute' never matches — forcing the transition is the safe + * direction — and an external boundary is not locally verifiable. */ export function executionBoundaryMatchesPermissionMode( boundary: ExecutionBoundary, mode: PermissionMode, ): boolean { - if (mode === 'bypass') return boundary.kind === 'bypass'; - if (boundary.kind !== 'managed') return false; - return mode === 'explore' - ? boundary.profile.name === 'read-only' - : boundary.profile.name !== 'read-only'; + const displayMode = executionBoundaryDisplayMode(boundary); + return displayMode !== undefined && displayMode === mode; } export function createGenesisExecutionBoundary(mode: PermissionMode): ExecutionBoundary { From 63a1db361a33486c7ebaa2e00277a3ad9006ad62 Mon Sep 17 00:00:00 2001 From: chinawch007 Date: Sat, 22 Aug 2026 21:34:35 +0800 Subject: [PATCH 08/19] fix(runtime-host): keep benign no-op updates working for externally isolated sessions (#3349) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review finding (P1b): the new boundary-consistency condition in the session.configuration.update short-circuit made an external boundary (always unverifiable) fail the check on every update, so even a no-op re-apply went through transitionSessionConfiguration and hit the store's refusal to move an externally isolated boundary — a regression for sessions whose configuration had matched. Skip the boundary comparison when the boundary is external: the header comparison alone decides the no-op there, as it did before the divergence repair. Generated-by: ZCode (Z.ai GLM) --- .../session-catalog-coordinator.test.ts | 40 ++++++++++++++++--- .../src/server/session-catalog-coordinator.ts | 13 +++--- 2 files changed, 42 insertions(+), 11 deletions(-) diff --git a/packages/runtime-host/src/__tests__/session-catalog-coordinator.test.ts b/packages/runtime-host/src/__tests__/session-catalog-coordinator.test.ts index 415a8bac53..976eda3786 100644 --- a/packages/runtime-host/src/__tests__/session-catalog-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/session-catalog-coordinator.test.ts @@ -420,12 +420,6 @@ test('a no-op configuration update repairs a header/boundary divergence instead // The header matches the requested configuration on every field, so only the // boundary consistency check can tell a genuine no-op from a divergence that // must be repaired through Runtime authority. - const matchingHeader = (labels: readonly string[]): SessionHeader => ({ - ...sessionHeader('session-1', labels), - permissionMode: 'bypass', - orchestrationMode: 'graph', - }); - let transitions = 0; const consistent = createFixture({ stores: { @@ -467,6 +461,32 @@ test('a no-op configuration update repairs a header/boundary divergence instead assert.equal(transitions, 1); }); +test('an externally isolated session keeps benign no-op updates on the header short-circuit', async () => { + let transitions = 0; + const fixture = createFixture({ + stores: { + readHeaderRecordSnapshot: async () => headerSnapshot(matchingHeader(['user-label']), 3), + readCatalogRecord: async () => catalogRecord(matchingHeader(['user-label']), 3), + readExecutionBoundary: async () => ({ kind: 'external', revision: 0 }), + }, + manager: { + transitionSessionConfiguration: async () => { + transitions += 1; + return headerSnapshot(matchingHeader(['user-label']), 3); + }, + }, + }); + const outcome = await fixture.coordinator.handlers['session.configuration.update']( + bypassConfigurationInput(fixture.sessionId, fixture.revision()), + context, + ); + // The external boundary is not locally verifiable, so the header comparison + // alone decides the no-op: the store would refuse to move an externally + // isolated boundary, and a benign re-apply must not become a failure. + assert.equal(outcome.ok, true); + assert.equal(transitions, 0); +}); + test('creation rejects reserved execution labels before claiming a Session identity', async () => { let createAttempts = 0; const fixture = createFixture({ @@ -1644,6 +1664,14 @@ function bypassConfigurationInput( return { ...base, configuration: { ...base.configuration, permissionMode: 'bypass' } }; } +function matchingHeader(labels: readonly string[]): SessionHeader { + return { + ...sessionHeader('session-1', labels), + permissionMode: 'bypass', + orchestrationMode: 'graph', + }; +} + function sessionHeader(sessionId: string, labels: readonly string[]): SessionHeader { return { id: sessionId, diff --git a/packages/runtime-host/src/server/session-catalog-coordinator.ts b/packages/runtime-host/src/server/session-catalog-coordinator.ts index bbe51b57ef..f0418765fb 100644 --- a/packages/runtime-host/src/server/session-catalog-coordinator.ts +++ b/packages/runtime-host/src/server/session-catalog-coordinator.ts @@ -544,11 +544,14 @@ export class HostSessionCatalogCoordinator { current.header.blockedReason === 'NO_REAL_CONNECTION'; // The boundary must match too: the header's stored permissionMode alone // cannot bless a no-op, or a header/boundary divergence would be - // short-circuited as already-committed instead of repaired. - const boundaryMatchesConfiguration = executionBoundaryMatchesPermissionMode( - await this.#stores.readExecutionBoundary(input.sessionId), - configuration.permissionMode, - ); + // short-circuited as already-committed instead of repaired. An external + // boundary is not locally verifiable — the store refuses to move it into + // Auto or Bypass — so for those sessions the header comparison alone + // decides the no-op, keeping benign updates working. + const boundary = await this.#stores.readExecutionBoundary(input.sessionId); + const boundaryMatchesConfiguration = + boundary.kind === 'external' || + executionBoundaryMatchesPermissionMode(boundary, configuration.permissionMode); if ( !clearsConnectionBlock && boundaryMatchesConfiguration && From 250385ef7fe56058382c51468991461c6f1d222c Mon Sep 17 00:00:00 2001 From: chinawch007 Date: Sat, 22 Aug 2026 21:39:24 +0800 Subject: [PATCH 09/19] fix(runtime): reject a queued switch when the turn pauses on an interaction (#3349) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review finding (P2): setPermissionMode and setExecutionBoundaryKind only inspected waiting_for_user at request time. A turn that pauses on an approval after the switch was queued never settles until the user answers, so the queued commit waited indefinitely — the D1 'reject while the user holds a pending decision' semantics degraded into an unbounded hang in that window. The quiescence wait now treats an active interaction as busy: it throws SessionQuiescentMutationBusyError, interaction registration wakes the waiters so the rejection is timely, and the session-manager wrapper maps it to the same session_busy outcome transitionSessionConfiguration already produces for the same condition. Request-time checks stay as fast-fail; the regression test drives a switch queued behind a gated turn that then opens a sandbox boundary approval. Generated-by: ZCode (Z.ai GLM) --- .../src/__tests__/session-manager.test.ts | 129 ++++++++++++++++++ packages/runtime/src/runtime-kernel.ts | 16 ++- packages/runtime/src/session-manager.ts | 12 +- 3 files changed, 155 insertions(+), 2 deletions(-) diff --git a/packages/runtime/src/__tests__/session-manager.test.ts b/packages/runtime/src/__tests__/session-manager.test.ts index e0ba862d35..9bcffd32ce 100644 --- a/packages/runtime/src/__tests__/session-manager.test.ts +++ b/packages/runtime/src/__tests__/session-manager.test.ts @@ -5333,6 +5333,66 @@ describe('SessionManager permission mode updates', () => { expect(summary.permissionMode).toBe('ask'); }); + test('a switch queued behind a turn that pauses on an interaction rejects busy', async () => { + const store = new MemorySessionStore(); + const runStore = new MemoryAgentRunStore(); + const backends = new BackendRegistry(); + const requestGate = makeGate(); + const responseGate = makeGate(); + backends.register( + 'ai-sdk', + (ctx) => new InteractionPauseBackend(ctx, requestGate, responseGate), + ); + const manager = new SessionManager({ + store, + runStore, + runtimeEventStore: runStore, + backends, + newId: nextId(), + now: nextNow(8_000), + }); + const session = await manager.createSession(makeInput({ permissionMode: 'ask' })); + + const turn = manager + .sendMessage(session.id, { turnId: 'turn-1', text: 'work' }) + [Symbol.asyncIterator](); + expect((await turn.next()).value?.type).toBe('text_delta'); + + let switchSettled = false; + const switching = manager.setPermissionMode(session.id, 'bypass').then( + (result) => { + switchSettled = true; + return result; + }, + (error) => { + switchSettled = true; + throw error; + }, + ); + await new Promise((resolve) => setImmediate(resolve)); + expect(switchSettled).toBe(false); + + // The turn pauses on a sandbox boundary approval: quiescence now depends + // on the user answering, so the queued switch must reject, not hang. + // Events are pull-driven, so the request only registers once observed. + requestGate.release(); + let sawRequest = false; + while (!sawRequest) { + const next = await turn.next(); + if (next.done) break; + sawRequest = next.value?.type === 'sandbox_boundary_request'; + } + expect(sawRequest).toBe(true); + expect((await manager.listActiveInteractions(session.id)).length).toBe(1); + await expectRejects(switching, /pending Interaction/); + + await manager.respondToSandboxBoundary(session.id, { + requestId: 'boundary-1', + decision: 'deny', + }); + while (!(await turn.next()).done) {} + }); + test('an idle Auto→Bypass switch is observed by the next turn and its first tool dispatch', async () => { const store = new MemorySessionStore(); const runStore = new MemoryAgentRunStore(); @@ -15987,6 +16047,75 @@ class EventBackend implements AgentBackend { async dispose(): Promise {} } +class InteractionPauseBackend implements AgentBackend { + readonly kind = 'ai-sdk' as const; + readonly sessionId: string; + readonly responses: SandboxBoundaryResponse[] = []; + + constructor( + ctx: BackendFactoryContext, + private readonly requestGate: Gate, + private readonly responseGate: Gate, + ) { + this.sessionId = ctx.sessionId; + } + + async *send(input: BackendSendInput): AsyncIterable { + yield { + type: 'text_delta', + id: `${input.turnId}-delta`, + turnId: input.turnId, + ts: 1, + messageId: `${input.turnId}-message`, + text: 'ok', + }; + await this.requestGate.promise; + yield { + type: 'sandbox_boundary_request', + id: `${input.turnId}-request`, + turnId: input.turnId, + ts: 2, + requestId: 'boundary-1', + toolUseId: 'tool-1', + justification: 'Write the requested export.', + expansion: { + filesystem: { + entries: [{ path: '/tmp/export.txt', access: 'write', scope: 'exact' }], + }, + }, + }; + await this.responseGate.promise; + const response = this.responses[0]!; + yield { + type: 'sandbox_boundary_decision_ack', + id: `${input.turnId}-decision`, + turnId: input.turnId, + ts: 3, + requestId: response.requestId, + toolUseId: 'tool-1', + decision: response.decision, + status: response.decision === 'allow' ? 'approved' : 'denied', + revision: response.decision === 'allow' ? 1 : 0, + }; + yield { + type: 'complete', + id: `${input.turnId}-complete`, + turnId: input.turnId, + ts: 4, + stopReason: 'end_turn', + }; + } + + async stop(): Promise {} + + async respondToSandboxBoundary(response: SandboxBoundaryResponse): Promise { + this.responses.push(response); + this.responseGate.release(); + } + + async dispose(): Promise {} +} + class SandboxBoundaryWaitBackend implements AgentBackend { readonly kind = 'ai-sdk' as const; readonly sessionId: string; diff --git a/packages/runtime/src/runtime-kernel.ts b/packages/runtime/src/runtime-kernel.ts index 10f765ab1e..a168a12d53 100644 --- a/packages/runtime/src/runtime-kernel.ts +++ b/packages/runtime/src/runtime-kernel.ts @@ -534,7 +534,9 @@ export class RuntimeKernel implements RuntimeKernelLike { * the tail only after quiescence, still serialized with other mutations. * Claims created after the request wait at the gate, so they cannot attach * before the operation has run, and waiting chains run strictly backwards in - * claim-creation order, so no cycle can close. + * claim-creation order, so no cycle can close. A session that pauses on an + * interaction while the mutation waits is rejected busy instead: quiescence + * would otherwise depend on the user answering. * * The claim frontier must be captured in the same synchronous block as the * gate closing: that keeps claim-creation order and gate order identical, @@ -641,6 +643,15 @@ export class RuntimeKernel implements RuntimeKernelLike { this.isSessionExecuting(sessionId, claimFrontier), ); if (blocking.length === 0) return; + // A session paused on an interaction never reaches quiescence on its + // own — the user must answer first. Reject instead of parking the + // request indefinitely behind that decision (#3349 review). + const interactive = blocking.filter( + (sessionId) => this.listActiveInteractions(sessionId).length > 0, + ); + if (interactive.length > 0) { + throw new SessionQuiescentMutationBusyError(interactive); + } await new Promise((resolve) => { const wake = (): void => { for (const sessionId of blocking) { @@ -2640,6 +2651,9 @@ export class RuntimeKernel implements RuntimeKernelLike { generation: generation.generation, request: event, }); + // A queued quiescence mutation must re-evaluate: the session now pauses on + // an interaction and would otherwise look eternally busy to it. + this.wakeSessionQuiescenceWaiters(sessionId); } private clearInteractionRequestOwners(sessionId: string, turnId: string): void { diff --git a/packages/runtime/src/session-manager.ts b/packages/runtime/src/session-manager.ts index 07314751db..9e4ca99028 100644 --- a/packages/runtime/src/session-manager.ts +++ b/packages/runtime/src/session-manager.ts @@ -1827,7 +1827,17 @@ export class SessionManager { 'Session execution mutation authority is unavailable', ); } - return await this.runtimeKernel.runSessionQueuedQuiescentMutation(sessionIds, operation); + try { + return await this.runtimeKernel.runSessionQueuedQuiescentMutation(sessionIds, operation); + } catch (error) { + if (error instanceof SessionQuiescentMutationBusyError) { + throw new SessionConfigurationTransitionError( + 'session_busy', + 'Session has a pending Interaction', + ); + } + throw error; + } } private async listLinkedDescendantSessionIds(sessionId: string): Promise { From 6d12197b4fa3e45ab4974bfcdc9806417920824c Mon Sep 17 00:00:00 2001 From: chinawch007 Date: Sat, 22 Aug 2026 21:41:32 +0800 Subject: [PATCH 10/19] chore(runtime): address review P3 notes on the #3349 series MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Document the boundary-revision stamping race (the stamp may trail the revision actually composed against — safe direction, one extra rebuild), the one-read-per-activation cost choice in the revision guard, and the operation_unavailable cliff a kernel without the queued primitive would create. Grow the seeded interleaving sweep from 12 to 100 iterations, closer to the stress volume the plan promised. Generated-by: ZCode (Z.ai GLM) --- .../src/__tests__/session-manager.test.ts | 2 +- packages/runtime/src/runtime-kernel.ts | 23 ++++++++++++------- packages/runtime/src/session-manager.ts | 3 +++ 3 files changed, 19 insertions(+), 9 deletions(-) diff --git a/packages/runtime/src/__tests__/session-manager.test.ts b/packages/runtime/src/__tests__/session-manager.test.ts index 9bcffd32ce..9b2627dc41 100644 --- a/packages/runtime/src/__tests__/session-manager.test.ts +++ b/packages/runtime/src/__tests__/session-manager.test.ts @@ -5471,7 +5471,7 @@ describe('SessionManager permission mode updates', () => { let expected: 'ask' | 'bypass' = 'ask'; let turnCount = 0; - for (let iteration = 0; iteration < 12; iteration += 1) { + for (let iteration = 0; iteration < 100; iteration += 1) { const nextMode: 'ask' | 'bypass' = random() < 0.5 ? 'bypass' : 'ask'; const interleaving = Math.floor(random() * 3); diff --git a/packages/runtime/src/runtime-kernel.ts b/packages/runtime/src/runtime-kernel.ts index a168a12d53..34b863039e 100644 --- a/packages/runtime/src/runtime-kernel.ts +++ b/packages/runtime/src/runtime-kernel.ts @@ -2898,11 +2898,26 @@ export class RuntimeKernel implements RuntimeKernelLike { this.active.set(sessionId, generation); return generation; }); + // Concurrent activations share one build; the first to arrive stamps the + // revision it read. That stamp may trail the revision actually composed + // against (the reader ran before the builder) — safe direction: a later + // activation rebuilds once more, never reuses a newer composition blindly. entry.boundaryRevision ??= boundaryRevision; entry.cachedHeader = header; return entry; } + private async readBoundaryRevision(sessionId: string): Promise { + // One dedicated store read per activation: cheaper than widening the + // session header read the turn already performs, and the guard is optional + // defense in depth — an unreadable boundary simply leaves it dormant. + try { + return (await this.deps.store.readExecutionBoundary(sessionId)).revision; + } catch { + return undefined; + } + } + /** * Defense in depth against a config write that bumped the durable boundary * without disposing the backend generation it was composed against: dispose @@ -2931,14 +2946,6 @@ export class RuntimeKernel implements RuntimeKernelLike { return undefined; } - private async readBoundaryRevision(sessionId: string): Promise { - try { - return (await this.deps.store.readExecutionBoundary(sessionId)).revision; - } catch { - return undefined; - } - } - private async shareBackendActivation( activationKey: string, activate: () => Promise, diff --git a/packages/runtime/src/session-manager.ts b/packages/runtime/src/session-manager.ts index 9e4ca99028..7d598a012f 100644 --- a/packages/runtime/src/session-manager.ts +++ b/packages/runtime/src/session-manager.ts @@ -1821,6 +1821,9 @@ export class SessionManager { sessionIds: readonly string[], operation: () => Promise, ): Promise { + // A kernel without this method turns permission switching into + // operation_unavailable rather than session_busy; kernel and host ship as + // one versioned unit, so the cliff only matters for injected test doubles. if (!this.runtimeKernel.runSessionQueuedQuiescentMutation) { throw new SessionConfigurationTransitionError( 'operation_unavailable', From 6ef9dedb08d92f24701726865eb9c66cc3c7c52f Mon Sep 17 00:00:00 2001 From: chinawch007 Date: Sun, 23 Aug 2026 20:37:49 +0800 Subject: [PATCH 11/19] test(core): adapt the legacy-execute matcher assertion to the rebase target (#3349) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Main removed 'execute' from the permission-mode vocabulary, so the P1a regression assertion now passes the legacy value through a type cast: the runtime property it protects — a stale persisted mode never matches, so it always routes through a transition — is unchanged. Generated-by: ZCode (Z.ai GLM) --- packages/core/src/__tests__/sandbox-boundary.test.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/core/src/__tests__/sandbox-boundary.test.ts b/packages/core/src/__tests__/sandbox-boundary.test.ts index 541f696d3c..2ef563db1e 100644 --- a/packages/core/src/__tests__/sandbox-boundary.test.ts +++ b/packages/core/src/__tests__/sandbox-boundary.test.ts @@ -30,6 +30,7 @@ import { executionBoundaryMatchesPermissionMode, validateSandboxBoundaryExpansion, } from '../sandbox-boundary.js'; +import type { PermissionMode } from '../permission.js'; import { canReadPath, canWritePath, @@ -114,11 +115,14 @@ describe('executionBoundaryDisplayMode', () => { ).toBe(false); }); - test('legacy execute never matches and an external boundary is not verifiable', () => { + test('a legacy persisted execute value never matches and an external boundary is not verifiable', () => { + // 'execute' left the mode vocabulary on main; a stale persisted value must + // still never match, so it always routes through a transition instead of + // being blessed as already-committed. expect( executionBoundaryMatchesPermissionMode( { kind: 'managed', profile: createWorkspaceWritePermissionProfile(), revision: 0 }, - 'execute', + 'execute' as PermissionMode, ), ).toBe(false); expect(executionBoundaryMatchesPermissionMode({ kind: 'external', revision: 0 }, 'ask')).toBe( From b1772e2b7bfd95d55acbe52fc6400ff11b086e73 Mon Sep 17 00:00:00 2001 From: chinawch007 Date: Sun, 23 Aug 2026 20:45:45 +0800 Subject: [PATCH 12/19] style: apply biome formatting to the #3349 series files Generated-by: ZCode (Z.ai GLM) --- packages/core/src/permission.ts | 1 - .../session-catalog-coordinator.test.ts | 14 ++++---- ...e-kernel-queued-quiescent-mutation.test.ts | 34 ++++++------------- packages/runtime/src/runtime-kernel.ts | 8 ++--- 4 files changed, 23 insertions(+), 34 deletions(-) diff --git a/packages/core/src/permission.ts b/packages/core/src/permission.ts index a7e55ac4d6..7e5039fc83 100644 --- a/packages/core/src/permission.ts +++ b/packages/core/src/permission.ts @@ -77,7 +77,6 @@ export function resolveCollaborationPermissionMode(input: { : input.permissionMode; } - /** Canonical category names use Claude SDK terminology. Pi adapter MUST * translate Pi-native tool names into these before they reach the runtime. */ export type ToolCategory = diff --git a/packages/runtime-host/src/__tests__/session-catalog-coordinator.test.ts b/packages/runtime-host/src/__tests__/session-catalog-coordinator.test.ts index 976eda3786..6bcded4eb5 100644 --- a/packages/runtime-host/src/__tests__/session-catalog-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/session-catalog-coordinator.test.ts @@ -434,9 +434,10 @@ test('a no-op configuration update repairs a header/boundary divergence instead }, }, }); - const consistentOutcome = await consistent.coordinator.handlers[ - 'session.configuration.update' - ](bypassConfigurationInput(consistent.sessionId, consistent.revision()), context); + const consistentOutcome = await consistent.coordinator.handlers['session.configuration.update']( + bypassConfigurationInput(consistent.sessionId, consistent.revision()), + context, + ); assert.equal(consistentOutcome.ok, true); assert.equal(transitions, 0); @@ -454,9 +455,10 @@ test('a no-op configuration update repairs a header/boundary divergence instead }, }, }); - const divergentOutcome = await divergent.coordinator.handlers[ - 'session.configuration.update' - ](bypassConfigurationInput(divergent.sessionId, divergent.revision()), context); + const divergentOutcome = await divergent.coordinator.handlers['session.configuration.update']( + bypassConfigurationInput(divergent.sessionId, divergent.revision()), + context, + ); assert.equal(divergentOutcome.ok, true); assert.equal(transitions, 1); }); diff --git a/packages/runtime/src/__tests__/runtime-kernel-queued-quiescent-mutation.test.ts b/packages/runtime/src/__tests__/runtime-kernel-queued-quiescent-mutation.test.ts index f321d0be80..979be4b97c 100644 --- a/packages/runtime/src/__tests__/runtime-kernel-queued-quiescent-mutation.test.ts +++ b/packages/runtime/src/__tests__/runtime-kernel-queued-quiescent-mutation.test.ts @@ -5,10 +5,7 @@ import type { SessionEvent } from '@maka/core/events'; import type { SessionHeader, StoredMessage } from '@maka/core/session'; import type { AgentBackend, BackendSendInput } from '@maka/core/backend-types'; -import { - RuntimeKernel, - SessionQuiescentMutationBusyError, -} from '../runtime-kernel.js'; +import { RuntimeKernel, SessionQuiescentMutationBusyError } from '../runtime-kernel.js'; import { BackendRegistry, type BackendFactoryContext, @@ -30,9 +27,7 @@ describe('RuntimeKernel queued quiescent mutation', () => { test('waits for a claim that already existed when the mutation was requested', async () => { const kernel = newKernel(); const claim = kernel.claimExecution(SESSION_ID); - const result = track( - kernel.runSessionQueuedQuiescentMutation([SESSION_ID], () => 'committed'), - ); + const result = track(kernel.runSessionQueuedQuiescentMutation([SESSION_ID], () => 'committed')); await settleTicks(); assert.equal(result.settled, false, 'mutation must wait while the claim is held'); @@ -44,9 +39,7 @@ describe('RuntimeKernel queued quiescent mutation', () => { const kernel = newKernel(); const first = kernel.claimExecution(SESSION_ID); const second = kernel.claimExecution(SESSION_ID); - const result = track( - kernel.runSessionQueuedQuiescentMutation([SESSION_ID], () => 'committed'), - ); + const result = track(kernel.runSessionQueuedQuiescentMutation([SESSION_ID], () => 'committed')); first.release(); await settleTicks(); @@ -69,9 +62,7 @@ describe('RuntimeKernel queued quiescent mutation', () => { }); await started.promise; - const result = track( - kernel.runSessionQueuedQuiescentMutation([SESSION_ID], () => 'committed'), - ); + const result = track(kernel.runSessionQueuedQuiescentMutation([SESSION_ID], () => 'committed')); const lateClaim = kernel.claimExecution(SESSION_ID); gate.resolve(); @@ -87,9 +78,7 @@ describe('RuntimeKernel queued quiescent mutation', () => { test('commit lands between goal-style turns without waiting for the successor claim', async () => { const kernel = newKernel(); const predecessor = kernel.claimExecution(SESSION_ID); - const result = track( - kernel.runSessionQueuedQuiescentMutation([SESSION_ID], () => 'committed'), - ); + const result = track(kernel.runSessionQueuedQuiescentMutation([SESSION_ID], () => 'committed')); // The successor turn's claim arrives while the mutation is queued: it is // newer than the frontier, so the committed slot must not wait for it. const successor = kernel.claimExecution(SESSION_ID); @@ -220,15 +209,11 @@ describe('RuntimeKernel queued quiescent mutation', () => { [Symbol.asyncIterator](); assert.equal((await iterator.next()).value?.type, 'text_delta'); - const queued = track( - kernel.runSessionQueuedQuiescentMutation([SESSION_ID], () => 'committed'), - ); + const queued = track(kernel.runSessionQueuedQuiescentMutation([SESSION_ID], () => 'committed')); await settleTicks(); assert.equal(queued.settled, false); - const admission = track( - kernel.runSessionAdmissionMutation([SESSION_ID], () => 'provisioned'), - ); + const admission = track(kernel.runSessionAdmissionMutation([SESSION_ID], () => 'provisioned')); assert.equal(await within(admission.promise), 'provisioned'); assert.equal(queued.settled, false, 'the queued mutation still waits for the run'); @@ -253,7 +238,10 @@ class GatedBackend implements AgentBackend { readonly kind = 'ai-sdk' as const; readonly sessionId: string; - constructor(ctx: BackendFactoryContext, private readonly gate: Promise) { + constructor( + ctx: BackendFactoryContext, + private readonly gate: Promise, + ) { this.sessionId = ctx.sessionId; } diff --git a/packages/runtime/src/runtime-kernel.ts b/packages/runtime/src/runtime-kernel.ts index 34b863039e..12b0625897 100644 --- a/packages/runtime/src/runtime-kernel.ts +++ b/packages/runtime/src/runtime-kernel.ts @@ -580,9 +580,7 @@ export class RuntimeKernel implements RuntimeKernelLike { const tail = this.sessionMutationTails.get(sessionId) ?? Promise.resolve(); const gates = this.sessionAdmissionGates.get(sessionId); if (!gates || gates.size === 0) return tail; - return Promise.all([tail, ...[...gates].map((gate) => gate.promise)]).then( - () => undefined, - ); + return Promise.all([tail, ...[...gates].map((gate) => gate.promise)]).then(() => undefined); } private normalizeSessionMutationIds(sessionIds: readonly string[]): string[] { @@ -631,7 +629,9 @@ export class RuntimeKernel implements RuntimeKernelLike { } private isSessionExecuting(sessionId: string, claimFrontier: number): boolean { - return this.hasUnsettledExecutionClaims(sessionId, claimFrontier) || this.hasActiveRuns(sessionId); + return ( + this.hasUnsettledExecutionClaims(sessionId, claimFrontier) || this.hasActiveRuns(sessionId) + ); } private async waitForSessionQuiescence( From 6498ea9d5010bb7da808c3a42080250d7f7b2494 Mon Sep 17 00:00:00 2001 From: chinawch007 Date: Mon, 24 Aug 2026 21:06:12 +0800 Subject: [PATCH 13/19] fix(runtime): apply permission narrowing on the next dispatch, not the next turn (#3349) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review finding: the queued quiescent mutation waited out the live turn before any tightening began, so a mid-turn Bypass→Auto request left the durable boundary unrestricted — and every later tool call in that same turn read the old authority — for a wall-clock-unbounded window. Split widening from tightening. Widening keeps the inter-turn-gap semantics: a delayed grant only affects turns that start later. A tightening transition now commits on the mutation tail alone — serialized with other transitions, never waiting for claims or runs — so the narrower durable boundary lands immediately and the running turn's next tool dispatch reads it; lineage shells are fenced at once; backend disposal defers to idle-time invalidation with the boundary-revision guard rebuilding stale generations, so nothing in the flow needs an idle session anymore. The lineage race guard moves inside the tail with a fresh listing: a descendant provisioned while the request queued still rejects operation_conflict, and the retry fences the full lineage. The reviewer-specified regression drives a gated turn under bypass, requests Auto mid-turn, and asserts the switch resolves before the turn ends, a write-capable dispatch before completion reads managed+ask, shell fencing fired, the turn was not stopped, and the successor turn composes from the committed mode as a separate invariant. On the pre-fix code that test times out waiting for the turn to end. Generated-by: ZCode (Z.ai GLM) --- .../src/__tests__/session-manager.test.ts | 78 +++++++++++-- packages/runtime/src/session-manager.ts | 106 ++++++++++++------ 2 files changed, 140 insertions(+), 44 deletions(-) diff --git a/packages/runtime/src/__tests__/session-manager.test.ts b/packages/runtime/src/__tests__/session-manager.test.ts index 9b2627dc41..176db01473 100644 --- a/packages/runtime/src/__tests__/session-manager.test.ts +++ b/packages/runtime/src/__tests__/session-manager.test.ts @@ -5267,12 +5267,13 @@ describe('SessionManager permission mode updates', () => { expect(builds).toBe(2); }); - test('narrowing with an active descendant rejects at commit time instead of hanging', async () => { + test('narrowing with an active descendant commits promptly and fences the lineage shells', async () => { const store = new MemorySessionStore(); const runStore = new MemoryAgentRunStore(); const backends = new BackendRegistry(); const childGate = makeGate(); backends.register('ai-sdk', (ctx) => new TestBackend(ctx, childGate)); + const terminated: string[] = []; const manager = new SessionManager({ store, runStore, @@ -5280,7 +5281,8 @@ describe('SessionManager permission mode updates', () => { backends, // Narrowing fences shell runs through this authority. shellRuns: { - async terminateSession() { + async terminateSession(sessionId: string) { + terminated.push(sessionId); return undefined; }, async commitSessionClose() {}, @@ -5317,20 +5319,82 @@ describe('SessionManager permission mode updates', () => { }), ); - // The child session runs a gated turn; narrowing the parent must not wait - // on it — the parent's own supervisor chain could depend on the child, so - // waiting could deadlock. It rejects at commit time instead. + // The child session runs a gated turn; narrowing the parent revokes now — + // it does not wait out the child's turn (whose supervisor chain could + // depend on it) and does not need to: the boundary write is immediate. const childTurn = manager .sendMessage(child.id, { turnId: 'child-turn', text: 'work' }) [Symbol.asyncIterator](); expect((await childTurn.next()).value?.type).toBe('text_delta'); - await expectRejects(manager.setPermissionMode(parent.id, 'ask'), /linked Turn is active/); + const summary = await manager.setPermissionMode(parent.id, 'ask'); + expect(summary.permissionMode).toBe('ask'); + expect(terminated).toEqual([parent.id, child.id]); + // The live child turn is not stopped; it completes normally. childGate.release(); while (!(await childTurn.next()).done) {} - const summary = await manager.setPermissionMode(parent.id, 'ask'); + }); + + test('a mid-turn Bypass→Ask narrowing reaches the next dispatch, not the next turn', async () => { + const store = new MemorySessionStore(); + const runStore = new MemoryAgentRunStore(); + const backends = new BackendRegistry(); + const gate = makeGate(); + const composedModes: SessionHeader['permissionMode'][] = []; + const terminated: string[] = []; + backends.register('ai-sdk', (ctx) => { + composedModes.push(ctx.header.permissionMode); + return new TestBackend(ctx, gate); + }); + const manager = new SessionManager({ + store, + runStore, + runtimeEventStore: runStore, + backends, + shellRuns: { + async terminateSession(sessionId: string) { + terminated.push(sessionId); + return undefined; + }, + async commitSessionClose() {}, + rollbackSessionClose() {}, + resumeSession() {}, + } as never, + newId: nextId(), + now: nextNow(8_000), + }); + const session = await manager.createSession(makeInput({ permissionMode: 'bypass' })); + + // The turn is live under bypass when the user tightens to Auto. + const turn = manager + .sendMessage(session.id, { turnId: 'turn-1', text: 'work' }) + [Symbol.asyncIterator](); + expect((await turn.next()).value?.type).toBe('text_delta'); + + // Revocation is prompt: it resolves without waiting for the turn to end. + const summary = await manager.setPermissionMode(session.id, 'ask'); expect(summary.permissionMode).toBe('ask'); + expect(terminated).toEqual([session.id]); + + // Before the turn completes, its next write-capable dispatch reads the + // narrower authority through the same store closure a real dispatch uses. + const dispatch = await dispatchProbeTool(store, session.id); + expect(dispatch.boundaryKind).toBe('managed'); + expect(dispatch.permissionMode).toBe('ask'); + + // The live turn is not stopped; it finishes, and its generation rebuilds + // from the committed configuration on the next activation (a separate + // invariant from the dispatch-level revocation above). + gate.release(); + while (!(await turn.next()).done) {} + const successor = manager + .sendMessage(session.id, { turnId: 'turn-2', text: 'next' }) + [Symbol.asyncIterator](); + expect((await successor.next()).value?.type).toBe('text_delta'); + gate.release(); + while (!(await successor.next()).done) {} + expect(composedModes).toEqual(['bypass', 'ask']); }); test('a switch queued behind a turn that pauses on an interaction rejects busy', async () => { diff --git a/packages/runtime/src/session-manager.ts b/packages/runtime/src/session-manager.ts index 7d598a012f..5dbe8a8a4b 100644 --- a/packages/runtime/src/session-manager.ts +++ b/packages/runtime/src/session-manager.ts @@ -1694,68 +1694,92 @@ export class SessionManager { prepareCommit: () => Promise<() => Promise>, ): Promise { const initialBoundary = await this.deps.store.readExecutionBoundary(sessionId); - const initiallyNarrows = narrowsExecutionAuthority(initialBoundary, nextPermissionMode); - const initialDescendants = initiallyNarrows - ? await this.listLinkedDescendantSessionIds(sessionId) - : []; - const fencedSessionIds = [sessionId, ...initialDescendants]; + if (!narrowsExecutionAuthority(initialBoundary, nextPermissionMode)) { + return this.commitWideningTransition(sessionId, prepareCommit); + } + const initialDescendants = await this.listLinkedDescendantSessionIds(sessionId); + return this.commitTighteningTransition( + sessionId, + [sessionId, ...initialDescendants], + prepareCommit, + ); + } - // Only the primary session is waited on: a descendant's execution may - // depend on claims this mutation would gate (graph supervisor chains), so - // waiting on descendants can deadlock. Descendant activity is instead - // rejected at commit time below — a truthful failure, never a hang. + /** + * Widening grants more authority, so it only needs to reach turns that + * start after the request: it commits in the inter-turn gap, and the + * successor turn — admission-gated until it commits — observes the new + * configuration before its first tool call. A delayed grant is a UX + * tradeoff, not a hazard. + */ + private async commitWideningTransition( + sessionId: string, + prepareCommit: () => Promise<() => Promise>, + ): Promise { return this.runSessionQueuedQuiescentMutation([sessionId], async () => { - const currentBoundary = await this.deps.store.readExecutionBoundary(sessionId); - const narrowsShellAuthority = narrowsExecutionAuthority(currentBoundary, nextPermissionMode); - const descendantSessionIds = narrowsShellAuthority - ? await this.listLinkedDescendantSessionIds(sessionId) - : []; + const commit = await prepareCommit(); + await this.runtimeKernel.disposeBackend(sessionId); + return await commit(); + }); + } + + /** + * Tightening revokes authority, and revocation must not wait out the turn + * that is still executing under the wider grant. The durable boundary is + * committed on the mutation tail — serialized with other transitions, never + * waiting for claims or runs — so the live turn's next tool dispatch + * already reads the narrower authority, and background shell authority + * across the lineage is fenced at once. Backend disposal is deferred: a + * live run keeps executing on its generation (tools read the boundary live + * on every call), idle generations dispose through invalidation now, and + * the boundary-revision guard rebuilds stale ones on their next activation. + */ + private async commitTighteningTransition( + sessionId: string, + fencedSessionIds: readonly string[], + prepareCommit: () => Promise<() => Promise>, + ): Promise { + if (!this.runtimeKernel.runSessionAdmissionMutation) { + throw new SessionConfigurationTransitionError( + 'operation_unavailable', + 'Session execution mutation authority is unavailable', + ); + } + return this.runtimeKernel.runSessionAdmissionMutation([sessionId], async () => { + // Re-list the lineage after tail serialization: a descendant provisioned + // while this request queued is a fencing gap, not something to fence + // blindly — reject and let the retry fence the full lineage. + const descendants = await this.listLinkedDescendantSessionIds(sessionId); if ( - descendantSessionIds.some( - (descendantSessionId) => !fencedSessionIds.includes(descendantSessionId), - ) + descendants.some((descendantSessionId) => !fencedSessionIds.includes(descendantSessionId)) ) { throw new SessionConfigurationTransitionError( 'operation_conflict', 'Session lineage changed before the configuration transition', ); } - const lineageSessionIds = [sessionId, ...descendantSessionIds]; - if (lineageSessionIds.some((id) => this.runtimeKernel.hasActiveRuns(id))) { - throw new SessionConfigurationTransitionError( - 'session_busy', - 'Session configuration cannot change while a linked Turn is active', - ); - } - if (narrowsShellAuthority && !this.deps.shellRuns) { + if (!this.deps.shellRuns) { throw new SessionConfigurationTransitionError( 'operation_unavailable', 'Session permission narrowing requires Runtime Resource authority', ); } - const commit = await prepareCommit(); const descendantBoundaries = new Map(); - for (const descendantSessionId of descendantSessionIds) { + for (const descendantSessionId of descendants) { descendantBoundaries.set( descendantSessionId, await this.deps.store.readExecutionBoundary(descendantSessionId), ); } + const lineageSessionIds = [sessionId, ...descendants]; const shellRunCloses: Array>> = []; try { - if (narrowsShellAuthority) { - for (const lineageSessionId of lineageSessionIds) { - const close = await this.deps.shellRuns?.terminateSession(lineageSessionId); - if (close) shellRunCloses.push(close); - } + for (const lineageSessionId of lineageSessionIds) { + const close = await this.deps.shellRuns?.terminateSession(lineageSessionId); + if (close) shellRunCloses.push(close); } - await Promise.all( - lineageSessionIds.map((lineageSessionId) => - this.runtimeKernel.disposeBackend(lineageSessionId), - ), - ); } catch { for (const close of shellRunCloses) this.deps.shellRuns?.rollbackSessionClose(close); throw new SessionConfigurationTransitionError( @@ -1782,6 +1806,14 @@ export class SessionManager { } } } + // Deferred disposal, best-effort by design: an invalidation marked on a + // session with a live run flushes when the run exits; the narrower + // boundary is already durable, so disposal is hygiene, not safety. + await Promise.all( + lineageSessionIds.map((lineageSessionId) => + this.runtimeKernel.invalidateBackend(lineageSessionId).catch(() => undefined), + ), + ); return result; }); } From 61ab4a4d78784c98dbca6b0eecd26d4d4df77ea3 Mon Sep 17 00:00:00 2001 From: chinawch007 Date: Mon, 24 Aug 2026 21:44:42 +0800 Subject: [PATCH 14/19] chore(runtime): add ASF license headers to the new #3349 test files The two test files added by this PR predate main's repo-wide header pass and were never covered by it; write:asf-headers fills them in. Generated-by: ZCode (Z.ai GLM) --- ...e-kernel-queued-quiescent-mutation.test.ts | 19 +++++++++++++++++++ .../tool-runtime-permission-mode.test.ts | 19 +++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/packages/runtime/src/__tests__/runtime-kernel-queued-quiescent-mutation.test.ts b/packages/runtime/src/__tests__/runtime-kernel-queued-quiescent-mutation.test.ts index 979be4b97c..7f8fec3da2 100644 --- a/packages/runtime/src/__tests__/runtime-kernel-queued-quiescent-mutation.test.ts +++ b/packages/runtime/src/__tests__/runtime-kernel-queued-quiescent-mutation.test.ts @@ -1,3 +1,22 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + import assert from 'node:assert/strict'; import { describe, test } from 'node:test'; diff --git a/packages/runtime/src/__tests__/tool-runtime-permission-mode.test.ts b/packages/runtime/src/__tests__/tool-runtime-permission-mode.test.ts index f6ebafb012..a95b378682 100644 --- a/packages/runtime/src/__tests__/tool-runtime-permission-mode.test.ts +++ b/packages/runtime/src/__tests__/tool-runtime-permission-mode.test.ts @@ -1,3 +1,22 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + import assert from 'node:assert/strict'; import { describe, test } from 'node:test'; From 1428dbec996a83f1e623db3c2caf7375be2a0417 Mon Sep 17 00:00:00 2001 From: chinawch007 Date: Fri, 28 Aug 2026 21:00:50 +0800 Subject: [PATCH 15/19] fix(runtime): drop the removed lastUsedAt field from the new test fixtures (#3349) Main removed SessionHeader.lastUsedAt; the two fixtures this PR adds still set it, so TypeScript compilation (and therefore every claimed suite) failed on the rebased head. Generated-by: ZCode (Z.ai GLM) --- .../__tests__/runtime-kernel-queued-quiescent-mutation.test.ts | 1 - .../runtime/src/__tests__/tool-runtime-permission-mode.test.ts | 1 - 2 files changed, 2 deletions(-) diff --git a/packages/runtime/src/__tests__/runtime-kernel-queued-quiescent-mutation.test.ts b/packages/runtime/src/__tests__/runtime-kernel-queued-quiescent-mutation.test.ts index 7f8fec3da2..128060859e 100644 --- a/packages/runtime/src/__tests__/runtime-kernel-queued-quiescent-mutation.test.ts +++ b/packages/runtime/src/__tests__/runtime-kernel-queued-quiescent-mutation.test.ts @@ -296,7 +296,6 @@ function memoryStore(): SessionStore { workspaceRoot: '/tmp/maka-runtime-kernel-queued-quiescent', cwd: '/tmp/maka-runtime-kernel-queued-quiescent', createdAt: 1, - lastUsedAt: 1, name: 'Queued quiescent mutation', titleIsManual: true, isFlagged: false, diff --git a/packages/runtime/src/__tests__/tool-runtime-permission-mode.test.ts b/packages/runtime/src/__tests__/tool-runtime-permission-mode.test.ts index a95b378682..81c57d3c76 100644 --- a/packages/runtime/src/__tests__/tool-runtime-permission-mode.test.ts +++ b/packages/runtime/src/__tests__/tool-runtime-permission-mode.test.ts @@ -171,7 +171,6 @@ function header( workspaceRoot: cwd, cwd, createdAt: 1, - lastUsedAt: 1, name: 'test', titleIsManual: false, isFlagged: false, From 9b87fe203740ff5cf77c443beebe46b8f6631202 Mon Sep 17 00:00:00 2001 From: chinawch007 Date: Fri, 28 Aug 2026 21:02:33 +0800 Subject: [PATCH 16/19] fix(runtime): reject a mixed tightening while a Turn is active (#3349) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review finding: session.configuration.update is a full configuration operation, but the fast tightening path commits the entire record while the live backend keeps its frozen composition. For bypass/agent → ask/plan the permission half applies per dispatch while the stale agent collaboration keeps combining with the fresh ask boundary — so a write-capable dispatch can still be admitted after the stored configuration already says the session is read-only Plan. A tightening that also changes any backend-composed field (backend, connection, model, thinking level, collaboration mode, orchestration mode) now rejects session_busy while a run is live — never partially committed; the retry lands whole once the run ends and the queued path recomposes the backend with every field. Permission-only projections keep the immediate-revocation path. The regression drives the reviewer's scenario end to end: live rejection with zero partial commit, then a read-only dispatch (plan downgrades ask to explore) after the retry. Generated-by: ZCode (Z.ai GLM) --- .../src/__tests__/session-manager.test.ts | 81 +++++++++++++++++++ packages/runtime/src/session-manager.ts | 56 ++++++++++--- 2 files changed, 127 insertions(+), 10 deletions(-) diff --git a/packages/runtime/src/__tests__/session-manager.test.ts b/packages/runtime/src/__tests__/session-manager.test.ts index 176db01473..99539a593f 100644 --- a/packages/runtime/src/__tests__/session-manager.test.ts +++ b/packages/runtime/src/__tests__/session-manager.test.ts @@ -5397,6 +5397,87 @@ describe('SessionManager permission mode updates', () => { expect(composedModes).toEqual(['bypass', 'ask']); }); + test('a mixed bypass/agent → ask/plan tightening rejects while a run is live and lands read-only on retry', async () => { + const store = new VersionedConfigurationMemorySessionStore(); + const runStore = new MemoryAgentRunStore(); + const backends = new BackendRegistry(); + const gate = makeGate(); + backends.register('ai-sdk', (ctx) => new TestBackend(ctx, gate)); + const manager = new SessionManager({ + store, + runStore, + runtimeEventStore: runStore, + backends, + shellRuns: { + async terminateSession() { + return undefined; + }, + async commitSessionClose() {}, + rollbackSessionClose() {}, + resumeSession() {}, + } as never, + planStore: { + readState: async () => ({ activeExecutionId: null, latestProposalId: null, proposals: [] }), + } as never, + newId: nextId(), + now: nextNow(8_000), + }); + const session = await manager.createSession( + makeInput({ permissionMode: 'bypass', collaborationMode: 'agent' }), + ); + const mixedConfiguration = { + backend: session.backend, + llmConnectionSlug: session.llmConnectionSlug, + connectionLocked: true, + model: session.model, + thinkingLevel: session.thinkingLevel, + permissionMode: 'ask', + collaborationMode: 'plan', + orchestrationMode: session.orchestrationMode ?? 'default', + } as const; + + // The turn is live under bypass/agent when the one-shot tightening to + // ask/plan arrives. Only the permission half could apply per dispatch; the + // plan half is backend-composed, so committing now would publish a + // read-only configuration the live composition cannot enforce. + const turn = manager + .sendMessage(session.id, { turnId: 'turn-1', text: 'work' }) + [Symbol.asyncIterator](); + expect((await turn.next()).value?.type).toBe('text_delta'); + + await expectRejects( + manager.transitionSessionConfiguration(session.id, { + expectedRevision: 1, + configuration: mixedConfiguration, + }), + /while a Turn is active/, + ); + + // Nothing partially committed: the record and a dispatch in this turn + // still agree on the old authority. + const header = await store.readHeader(session.id); + expect(header.permissionMode).toBe('bypass'); + expect(header.collaborationMode).toBe('agent'); + const dispatch = await dispatchProbeTool(store, session.id); + expect(dispatch.boundaryKind).toBe('bypass'); + expect(dispatch.permissionMode).toBe('bypass'); + + gate.release(); + while (!(await turn.next()).done) {} + + // Once the run ended the retry lands whole, and a write-capable dispatch + // cannot receive writable authority: plan downgrades ask to explore. + const committed = await manager.transitionSessionConfiguration(session.id, { + expectedRevision: 1, + configuration: mixedConfiguration, + }); + expect(committed.header.permissionMode).toBe('ask'); + expect(committed.header.collaborationMode).toBe('plan'); + const narrowed = await dispatchProbeTool(store, session.id); + expect(narrowed.boundaryKind).toBe('managed'); + expect(narrowed.permissionMode).toBe('explore'); + }); + test('a switch queued behind a turn that pauses on an interaction rejects busy', async () => { const store = new MemorySessionStore(); const runStore = new MemoryAgentRunStore(); diff --git a/packages/runtime/src/session-manager.ts b/packages/runtime/src/session-manager.ts index 5dbe8a8a4b..88fc2d6f4a 100644 --- a/packages/runtime/src/session-manager.ts +++ b/packages/runtime/src/session-manager.ts @@ -1138,9 +1138,24 @@ export class SessionManager { input: SessionConfigurationTransitionRequest, ): Promise { const store = this.requireSessionConfigurationStore(); + // A tightening that also changes any backend-composed field cannot use the + // immediate-commit path while a run is live: the frozen composition (for + // example a stale collaboration mode) would keep combining with the fresh + // boundary, so a plan/ask session could still admit writable dispatches. + const header = await this.deps.store.readHeader(sessionId); + const changesBackendComposition = + input.configuration.backend !== header.backend || + input.configuration.llmConnectionSlug !== header.llmConnectionSlug || + input.configuration.model !== header.model || + (input.configuration.thinkingLevel ?? undefined) !== header.thinkingLevel || + (input.configuration.collaborationMode ?? 'agent') !== + (header.collaborationMode ?? 'agent') || + (input.configuration.orchestrationMode ?? 'default') !== + (header.orchestrationMode ?? 'default'); const next = await this.commitExecutionResourceTransition( sessionId, input.configuration.permissionMode, + changesBackendComposition, async () => { const current = await store.readHeaderRecordSnapshot(sessionId); if (current.revision !== input.expectedRevision) { @@ -1676,21 +1691,28 @@ export class SessionManager { }, ): Promise { const nextPermissionMode = projection?.permissionMode ?? (kind === 'bypass' ? 'bypass' : 'ask'); - return this.commitExecutionResourceTransition(sessionId, nextPermissionMode, async () => { - const latest = await this.deps.store.readExecutionBoundary(sessionId); - if (latest.revision !== current.revision) { - throw new SessionConfigurationTransitionError( - 'operation_conflict', - 'Session execution boundary changed before the transition', - ); - } - return () => this.deps.store.setExecutionBoundaryKind(sessionId, kind, projection); - }); + // Permission-only projection: no backend-composed field rides along. + return this.commitExecutionResourceTransition( + sessionId, + nextPermissionMode, + false, + async () => { + const latest = await this.deps.store.readExecutionBoundary(sessionId); + if (latest.revision !== current.revision) { + throw new SessionConfigurationTransitionError( + 'operation_conflict', + 'Session execution boundary changed before the transition', + ); + } + return () => this.deps.store.setExecutionBoundaryKind(sessionId, kind, projection); + }, + ); } private async commitExecutionResourceTransition( sessionId: string, nextPermissionMode: PermissionMode, + changesBackendComposition: boolean, prepareCommit: () => Promise<() => Promise>, ): Promise { const initialBoundary = await this.deps.store.readExecutionBoundary(sessionId); @@ -1701,6 +1723,7 @@ export class SessionManager { return this.commitTighteningTransition( sessionId, [sessionId, ...initialDescendants], + changesBackendComposition, prepareCommit, ); } @@ -1737,6 +1760,7 @@ export class SessionManager { private async commitTighteningTransition( sessionId: string, fencedSessionIds: readonly string[], + changesBackendComposition: boolean, prepareCommit: () => Promise<() => Promise>, ): Promise { if (!this.runtimeKernel.runSessionAdmissionMutation) { @@ -1758,6 +1782,18 @@ export class SessionManager { 'Session lineage changed before the configuration transition', ); } + if (changesBackendComposition && this.runtimeKernel.hasActiveRuns(sessionId)) { + // The permission half of this update could apply per dispatch, but the + // composed half only refreshes on rebuild — committing both now would + // publish a read-only configuration the live backend cannot enforce + // (a stale agent composition keeps admitting writable dispatches). + // Reject the atomic update whole; the retry lands once the run ends + // and the queued path recomposes the backend with every field. + throw new SessionConfigurationTransitionError( + 'session_busy', + 'A mixed permission and configuration tightening cannot commit while a Turn is active', + ); + } if (!this.deps.shellRuns) { throw new SessionConfigurationTransitionError( 'operation_unavailable', From 11e6a48c1ac7c2a93b2b63a569c8ec47c88aada2 Mon Sep 17 00:00:00 2001 From: chinawch007 Date: Fri, 28 Aug 2026 21:03:58 +0800 Subject: [PATCH 17/19] refactor(runtime): drop the boundary-revision backend watcher (#3349) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review finding: the boundary revision is not a backend-composition fingerprint — an approved sandbox expansion increments it too, and expansions are consumed live per dispatch by design, changing neither model nor backend-composed configuration. The watcher therefore paid a durable read on every activation and rebuilt backend, transport and composer state after every valid expansion, while defending against a writer that does not exist: configuration transitions already own their backend disposal and invalidation. Remove boundaryRevision, readBoundaryRevision and resolveReusableGeneration (ensureActive returns to plain reuse), the two forced-revision self-heal tests, and the fixed-seed sweep — its interleaving classes stay covered by the direct deterministic tests: claim/run waiting, successor admission, the admission-gate deadlock, immediate tightening at the current dispatch, shell lineage fencing, and the mixed-configuration regression. Generated-by: ZCode (Z.ai GLM) --- .../src/__tests__/session-manager.test.ts | 173 ------------------ packages/runtime/src/runtime-kernel.ts | 70 +------ packages/runtime/src/session-manager.ts | 4 +- 3 files changed, 6 insertions(+), 241 deletions(-) diff --git a/packages/runtime/src/__tests__/session-manager.test.ts b/packages/runtime/src/__tests__/session-manager.test.ts index 99539a593f..adac9ccf95 100644 --- a/packages/runtime/src/__tests__/session-manager.test.ts +++ b/packages/runtime/src/__tests__/session-manager.test.ts @@ -5179,93 +5179,7 @@ describe('SessionManager permission mode updates', () => { expect(builtPermissionModes).toEqual(['ask', 'bypass']); }); - test('a boundary revision bump without backend disposal rebuilds on the next activation', async () => { - const store = new MemorySessionStore(); - const runStore = new MemoryAgentRunStore(); - const backends = new BackendRegistry(); - let builds = 0; - backends.register('ai-sdk', (ctx) => { - builds += 1; - return new TestBackend(ctx); - }); - const manager = new SessionManager({ - store, - runStore, - runtimeEventStore: runStore, - backends, - newId: nextId(), - now: nextNow(8_000), - }); - const session = await manager.createSession(makeInput({ permissionMode: 'ask' })); - - await drain(manager.sendMessage(session.id, { turnId: 'turn-1', text: 'one' })); - expect(builds).toBe(1); - - // A write path that skips backend disposal bumps the durable boundary - // while the generation stays alive. - store.forceExecutionBoundary(session.id, { kind: 'bypass', revision: 5 }); - - await drain(manager.sendMessage(session.id, { turnId: 'turn-2', text: 'two' })); - expect(builds).toBe(2); - expect(store.disposeCount).toBe(1); - - // Once rebuilt against the current revision, the generation is reused again. - await drain(manager.sendMessage(session.id, { turnId: 'turn-3', text: 'three' })); - expect(builds).toBe(2); - }); - - test('a stale generation with live runs flushes after they exit instead of disposing underneath them', async () => { - const store = new MemorySessionStore(); - const runStore = new MemoryAgentRunStore(); - const backends = new BackendRegistry(); - const gates: Gate[] = []; - let builds = 0; - backends.register('ai-sdk', (ctx) => { - builds += 1; - const gate = makeGate(); - gates.push(gate); - return new TestBackend(ctx, gate); - }); - const manager = new SessionManager({ - store, - runStore, - runtimeEventStore: runStore, - backends, - newId: nextId(), - now: nextNow(8_000), - }); - const session = await manager.createSession(makeInput({ permissionMode: 'ask' })); - - const first = manager - .sendMessage(session.id, { turnId: 'turn-1', text: 'one' }) - [Symbol.asyncIterator](); - expect((await first.next()).value?.type).toBe('text_delta'); - expect(builds).toBe(1); - store.forceExecutionBoundary(session.id, { kind: 'bypass', revision: 5 }); - - // An overlapping activation while turn 1 is live must not dispose the - // generation underneath it: the generation is marked, reused for this - // turn, and flushed once both runs exit. Both turns share the reused - // backend, so a single gate holds them both. - const second = manager - .sendMessage(session.id, { turnId: 'turn-2', text: 'two' }) - [Symbol.asyncIterator](); - expect((await second.next()).value?.type).toBe('text_delta'); - expect(builds).toBe(1); - - gates[0]!.release(); - while (!(await first.next()).done) {} - while (!(await second.next()).done) {} - - const third = manager - .sendMessage(session.id, { turnId: 'turn-3', text: 'three' }) - [Symbol.asyncIterator](); - expect((await third.next()).value?.type).toBe('text_delta'); - gates[1]!.release(); - while (!(await third.next()).done) {} - expect(builds).toBe(2); - }); test('narrowing with an active descendant commits promptly and fences the lineage shells', async () => { const store = new MemorySessionStore(); @@ -5575,88 +5489,6 @@ describe('SessionManager permission mode updates', () => { expect(dispatch.permissionMode).toBe('bypass'); }); - test('seeded switch/turn interleavings always observe the committed mode', async () => { - // A fixed-seed PRNG picks the interleaving class per iteration; every - // checkpoint awaits a deterministic event, so the sweep is reproducible. - let seed = 0x3349; - const random = (): number => { - seed = (seed * 1_103_515_245 + 12_345) % 2_147_483_648; - return seed / 2_147_483_648; - }; - - const store = new MemorySessionStore(); - const runStore = new MemoryAgentRunStore(); - const backends = new BackendRegistry(); - const gates: Gate[] = []; - const composedModes: SessionHeader['permissionMode'][] = []; - backends.register('ai-sdk', (ctx) => { - const gate = makeGate(); - gates.push(gate); - composedModes.push(ctx.header.permissionMode); - return new TestBackend(ctx, gate); - }); - const manager = new SessionManager({ - store, - runStore, - runtimeEventStore: runStore, - backends, - // Narrowing (bypass → ask) fences shell runs through this authority. - shellRuns: { - async terminateSession() { - return undefined; - }, - async commitSessionClose() {}, - rollbackSessionClose() {}, - resumeSession() {}, - } as never, - newId: nextId(), - now: nextNow(9_000), - }); - const session = await manager.createSession(makeInput({ permissionMode: 'ask' })); - - let expected: 'ask' | 'bypass' = 'ask'; - let turnCount = 0; - for (let iteration = 0; iteration < 100; iteration += 1) { - const nextMode: 'ask' | 'bypass' = random() < 0.5 ? 'bypass' : 'ask'; - const interleaving = Math.floor(random() * 3); - - if (interleaving === 0) { - // Switch while the session is idle. - await manager.setPermissionMode(session.id, nextMode); - expected = nextMode; - } else { - // Switch requested while a turn is running; the queued commit lands - // in the gap as the turn settles (class 1 requests it mid-flight, - // class 2 races it with the gate release). - turnCount += 1; - const turn = manager - .sendMessage(session.id, { turnId: `turn-${turnCount}`, text: `t${turnCount}` }) - [Symbol.asyncIterator](); - expect((await turn.next()).value?.type).toBe('text_delta'); - const switching = manager.setPermissionMode(session.id, nextMode); - if (interleaving === 2) gates[gates.length - 1]!.release(); - if (interleaving === 1) gates[gates.length - 1]!.release(); - while (!(await turn.next()).done) {} - await switching; - expected = nextMode; - } - - // Invariant: every turn started after the switch resolved is composed - // from the committed mode, and a tool call against the committed store - // derives the same mode. - turnCount += 1; - const verify = manager - .sendMessage(session.id, { turnId: `turn-${turnCount}`, text: `t${turnCount}` }) - [Symbol.asyncIterator](); - expect((await verify.next()).value?.type).toBe('text_delta'); - expect(composedModes[composedModes.length - 1]).toBe(expected); - const dispatch = await dispatchProbeTool(store, session.id); - expect(dispatch.boundaryKind).toBe(expected === 'bypass' ? 'bypass' : 'managed'); - expect(dispatch.permissionMode).toBe(expected); - gates[gates.length - 1]!.release(); - while (!(await verify.next()).done) {} - } - }); test('leaving explore clears the deep research label so visible read-only copy stays truthful', async () => { const store = new MemorySessionStore(); @@ -17278,11 +17110,6 @@ class MemorySessionStore implements SessionStore { return boundary; } - /** Simulates a config write path that bumps the boundary without disposing backends. */ - forceExecutionBoundary(sessionId: string, boundary: ExecutionBoundary): void { - this.executionBoundaries.set(sessionId, boundary); - } - async createSandboxBoundaryRequest( input: CreateSandboxBoundaryRequest, ): Promise { diff --git a/packages/runtime/src/runtime-kernel.ts b/packages/runtime/src/runtime-kernel.ts index 12b0625897..6eb0fcd6d8 100644 --- a/packages/runtime/src/runtime-kernel.ts +++ b/packages/runtime/src/runtime-kernel.ts @@ -319,13 +319,6 @@ interface BackendGeneration extends AgentRunActiveSession { | { kind: 'failed'; error: unknown }; disposal?: Promise; disposalFailure?: Error; - /** - * The durable boundary revision this generation was composed against. - * `ensureActive` compares it against the store to rebuild when a config - * write skipped backend disposal; `undefined` (unreadable at build) keeps - * the guard dormant for this generation. - */ - boundaryRevision?: number; cachedHeader: SessionHeader; activeRuns: Map; turnToRunId: Map; @@ -2844,27 +2837,16 @@ export class RuntimeKernel implements RuntimeKernelLike { execution: PendingExecutionClaim, ): Promise { await this.clearBackendQuarantineForActivation(sessionId, execution); - // The boundary revision this activation is composed against. Recorded on - // the generation so a later activation can detect a config write that - // skipped backend disposal (#3349). An unreadable boundary leaves the - // guard dormant rather than blocking activation. - const boundaryRevision = await this.readBoundaryRevision(sessionId); let existing = this.active.get(sessionId); if (existing) { - const reusable = await this.resolveReusableGeneration(sessionId, existing, boundaryRevision); - if (reusable) { - reusable.cachedHeader = header; - return reusable; - } + existing.cachedHeader = header; + return existing; } await this.waitForBackendDisposal(sessionId); existing = this.active.get(sessionId); if (existing) { - const reusable = await this.resolveReusableGeneration(sessionId, existing, boundaryRevision); - if (reusable) { - reusable.cachedHeader = header; - return reusable; - } + existing.cachedHeader = header; + return existing; } const entry = await this.shareBackendActivation(`parent:${sessionId}`, async () => { const current = this.active.get(sessionId); @@ -2898,54 +2880,10 @@ export class RuntimeKernel implements RuntimeKernelLike { this.active.set(sessionId, generation); return generation; }); - // Concurrent activations share one build; the first to arrive stamps the - // revision it read. That stamp may trail the revision actually composed - // against (the reader ran before the builder) — safe direction: a later - // activation rebuilds once more, never reuses a newer composition blindly. - entry.boundaryRevision ??= boundaryRevision; entry.cachedHeader = header; return entry; } - private async readBoundaryRevision(sessionId: string): Promise { - // One dedicated store read per activation: cheaper than widening the - // session header read the turn already performs, and the guard is optional - // defense in depth — an unreadable boundary simply leaves it dormant. - try { - return (await this.deps.store.readExecutionBoundary(sessionId)).revision; - } catch { - return undefined; - } - } - - /** - * Defense in depth against a config write that bumped the durable boundary - * without disposing the backend generation it was composed against: dispose - * and rebuild now when nothing executes on the generation, and when runs are - * still live, mark the generation for invalidation instead — it flushes when - * they exit, and the next activation composes fresh. Tools are unaffected - * meanwhile: they read the boundary live on every call. - */ - private async resolveReusableGeneration( - sessionId: string, - existing: BackendGeneration, - boundaryRevision: number | undefined, - ): Promise { - if ( - boundaryRevision === undefined || - existing.boundaryRevision === undefined || - existing.boundaryRevision === boundaryRevision - ) { - return existing; - } - if (this.hasActiveRuns(sessionId)) { - this.ensureBackendInvalidation(sessionId); - return existing; - } - await this.disposeBackend(sessionId); - return undefined; - } - private async shareBackendActivation( activationKey: string, activate: () => Promise, diff --git a/packages/runtime/src/session-manager.ts b/packages/runtime/src/session-manager.ts index 88fc2d6f4a..7bde264adf 100644 --- a/packages/runtime/src/session-manager.ts +++ b/packages/runtime/src/session-manager.ts @@ -1754,8 +1754,8 @@ export class SessionManager { * already reads the narrower authority, and background shell authority * across the lineage is fenced at once. Backend disposal is deferred: a * live run keeps executing on its generation (tools read the boundary live - * on every call), idle generations dispose through invalidation now, and - * the boundary-revision guard rebuilds stale ones on their next activation. + * on every call), and idle-time invalidation disposes and rebuilds stale + * generations — configuration transitions own their backend lifecycle. */ private async commitTighteningTransition( sessionId: string, From 4484f8ae4b1374bba6e97e09ca2ecc8a34e59e57 Mon Sep 17 00:00:00 2001 From: chinawch007 Date: Fri, 28 Aug 2026 23:06:30 +0800 Subject: [PATCH 18/19] style(runtime): drop stray blank lines left by the review-fix splits (#3349) Generated-by: ZCode (Z.ai GLM) --- packages/runtime/src/__tests__/session-manager.test.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/runtime/src/__tests__/session-manager.test.ts b/packages/runtime/src/__tests__/session-manager.test.ts index adac9ccf95..a2dbd739aa 100644 --- a/packages/runtime/src/__tests__/session-manager.test.ts +++ b/packages/runtime/src/__tests__/session-manager.test.ts @@ -5179,8 +5179,6 @@ describe('SessionManager permission mode updates', () => { expect(builtPermissionModes).toEqual(['ask', 'bypass']); }); - - test('narrowing with an active descendant commits promptly and fences the lineage shells', async () => { const store = new MemorySessionStore(); const runStore = new MemoryAgentRunStore(); @@ -5489,7 +5487,6 @@ describe('SessionManager permission mode updates', () => { expect(dispatch.permissionMode).toBe('bypass'); }); - test('leaving explore clears the deep research label so visible read-only copy stays truthful', async () => { const store = new MemorySessionStore(); const backends = new BackendRegistry(); From ba731ea420a5e16c70cb278cae6e9730eb03711a Mon Sep 17 00:00:00 2001 From: chinawch007 Date: Fri, 28 Aug 2026 23:50:29 +0800 Subject: [PATCH 19/19] test: adapt the #3349 series to main's configuration-update API (#3349) Main moved session.configuration.update to a patch input and made transitionSessionConfiguration's clearConnectionBlock explicit. Adapt the mixed-tightening and catalog short-circuit tests accordingly; the assertions are unchanged. Generated-by: ZCode (Z.ai GLM) --- .../src/__tests__/session-catalog-coordinator.test.ts | 2 +- packages/runtime/src/__tests__/session-manager.test.ts | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/runtime-host/src/__tests__/session-catalog-coordinator.test.ts b/packages/runtime-host/src/__tests__/session-catalog-coordinator.test.ts index 6bcded4eb5..3d540572a6 100644 --- a/packages/runtime-host/src/__tests__/session-catalog-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/session-catalog-coordinator.test.ts @@ -1663,7 +1663,7 @@ function bypassConfigurationInput( expectedRevision: number, ): SessionConfigurationUpdateInput { const base = configurationInput(sessionId, expectedRevision); - return { ...base, configuration: { ...base.configuration, permissionMode: 'bypass' } }; + return { ...base, patch: { ...base.patch, permissionMode: 'bypass' } }; } function matchingHeader(labels: readonly string[]): SessionHeader { diff --git a/packages/runtime/src/__tests__/session-manager.test.ts b/packages/runtime/src/__tests__/session-manager.test.ts index a2dbd739aa..94fb545038 100644 --- a/packages/runtime/src/__tests__/session-manager.test.ts +++ b/packages/runtime/src/__tests__/session-manager.test.ts @@ -5360,6 +5360,7 @@ describe('SessionManager permission mode updates', () => { await expectRejects( manager.transitionSessionConfiguration(session.id, { expectedRevision: 1, + clearConnectionBlock: false, configuration: mixedConfiguration, }), /while a Turn is active/, @@ -5381,6 +5382,7 @@ describe('SessionManager permission mode updates', () => { // cannot receive writable authority: plan downgrades ask to explore. const committed = await manager.transitionSessionConfiguration(session.id, { expectedRevision: 1, + clearConnectionBlock: false, configuration: mixedConfiguration, }); expect(committed.header.permissionMode).toBe('ask');