From eace90ae87f79317ad94304d736847049803ff0a Mon Sep 17 00:00:00 2001 From: chinawch007 Date: Sat, 22 Aug 2026 00:40:33 +0800 Subject: [PATCH 01/14] 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 14738c8719..5d765ded05 100644 --- a/packages/runtime/src/runtime-kernel.ts +++ b/packages/runtime/src/runtime-kernel.ts @@ -143,6 +143,10 @@ export interface RuntimeKernelLike { sessionIds: readonly string[], operation: () => Promise | T, ): Promise; + runSessionQueuedQuiescentMutation?( + sessionIds: readonly string[], + operation: () => Promise | T, + ): Promise; startTurn( sessionId: string, input: UserMessageInput, @@ -398,6 +402,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; @@ -435,6 +445,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 @@ -483,6 +495,7 @@ export class RuntimeKernel implements RuntimeKernelLike { const state: PendingExecutionClaim = { handle, sessionId, + claimSeq: ++this.claimSequence, abortController, cancellation, admissionBarrier: this.sessionMutationTails.get(sessionId) ?? Promise.resolve(), @@ -520,6 +533,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)) { @@ -558,6 +609,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, @@ -647,6 +751,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); } @@ -3148,6 +3253,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 ffc14ac3c232b68d93bcbd8379cabf05c3c1bc93 Mon Sep 17 00:00:00 2001 From: chinawch007 Date: Sat, 22 Aug 2026 02:51:28 +0800 Subject: [PATCH 02/14] 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 | 151 ++++++++++++++---- packages/runtime/src/session-manager.ts | 34 ++-- 2 files changed, 141 insertions(+), 44 deletions(-) diff --git a/packages/runtime/src/__tests__/session-manager.test.ts b/packages/runtime/src/__tests__/session-manager.test.ts index f9fa2e36a6..4c7ed71230 100644 --- a/packages/runtime/src/__tests__/session-manager.test.ts +++ b/packages/runtime/src/__tests__/session-manager.test.ts @@ -4347,7 +4347,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({ @@ -4371,25 +4371,23 @@ 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, 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, - 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]); @@ -4478,7 +4476,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(); @@ -4495,7 +4493,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, configuration: { @@ -4509,18 +4508,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 () => { @@ -5068,7 +5069,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(); @@ -5103,11 +5104,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([ @@ -5119,8 +5127,69 @@ 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 () => { @@ -12224,7 +12293,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, { @@ -15864,13 +15936,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); + }, }; } @@ -15888,6 +15967,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 9edfe06a3f..8ada4d77d5 100644 --- a/packages/runtime/src/session-manager.ts +++ b/packages/runtime/src/session-manager.ts @@ -1646,9 +1646,6 @@ export class SessionManager { return headerToSummary(previous); } - if (this.runtimeKernel.hasActiveRuns(sessionId)) { - throw new Error('当前任务正在运行,等结束后再切换权限模式。'); - } if (previous.status === 'waiting_for_user') { throw new Error('当前有工具调用正在等待确认,处理后再切换权限模式。'); } @@ -1679,9 +1676,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('当前有沙箱边界请求正在等待确认,处理后再切换。'); @@ -1725,7 +1719,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 @@ -1742,12 +1736,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', @@ -1830,6 +1818,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 daff28b1f42d736bcd4249cec3ed36b123fb56ff Mon Sep 17 00:00:00 2001 From: chinawch007 Date: Sat, 22 Aug 2026 09:34:22 +0800 Subject: [PATCH 03/14] 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 | 9 + .../tool-runtime-permission-mode.test.ts | 194 ++++++++++++++++++ packages/runtime/src/session-manager.ts | 12 +- packages/runtime/src/tool-runtime.ts | 18 +- 8 files changed, 315 insertions(+), 21 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 8c2c6a669c..ad1cbb7217 100644 --- a/packages/runtime-host/src/__tests__/session-catalog-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/session-catalog-coordinator.test.ts @@ -391,6 +391,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({ @@ -1311,6 +1362,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 5c0f3acdaa..fdfb9d96d2 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, @@ -468,11 +468,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 760453eb89..84ab30bdd7 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'; @@ -470,8 +471,16 @@ export class HostSessionCatalogCoordinator { input.configuration.thinkingLevel ?? undefined, ); const clearsConnectionBlock = 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), + input.configuration.permissionMode, + ); if ( !clearsConnectionBlock && + boundaryMatchesConfiguration && sessionConfigurationMatches(current.header, model, input.configuration) ) { return configurationSuccess({ 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 8ada4d77d5..397c67f669 100644 --- a/packages/runtime/src/session-manager.ts +++ b/packages/runtime/src/session-manager.ts @@ -81,6 +81,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 { @@ -6336,17 +6337,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 84fab7c475..4dbe1f9337 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 { @@ -1323,6 +1325,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, @@ -1332,7 +1348,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 c0875a51060d4341e6b8eecc5831ae60d540f9e5 Mon Sep 17 00:00:00 2001 From: chinawch007 Date: Sat, 22 Aug 2026 18:11:25 +0800 Subject: [PATCH 04/14] 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 4c7ed71230..f0db35153d 100644 --- a/packages/runtime/src/__tests__/session-manager.test.ts +++ b/packages/runtime/src/__tests__/session-manager.test.ts @@ -5192,6 +5192,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(); @@ -17615,6 +17703,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 5d765ded05..bf57f27ec4 100644 --- a/packages/runtime/src/runtime-kernel.ts +++ b/packages/runtime/src/runtime-kernel.ts @@ -351,6 +351,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; @@ -2976,16 +2983,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); @@ -3019,10 +3037,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 506f76e68af88246ff79206369972e9a3562feec Mon Sep 17 00:00:00 2001 From: chinawch007 Date: Sat, 22 Aug 2026 19:56:01 +0800 Subject: [PATCH 05/14] 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 f0db35153d..61a9da839e 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, @@ -5280,6 +5282,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(); @@ -19324,6 +19446,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 f27421f7a1fcddaf0acb6f8ef086e499ede2d52f Mon Sep 17 00:00:00 2001 From: chinawch007 Date: Sat, 22 Aug 2026 21:31:24 +0800 Subject: [PATCH 06/14] 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 61a9da839e..e53c086a6e 100644 --- a/packages/runtime/src/__tests__/session-manager.test.ts +++ b/packages/runtime/src/__tests__/session-manager.test.ts @@ -5282,6 +5282,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 bf57f27ec4..3c557e7a82 100644 --- a/packages/runtime/src/runtime-kernel.ts +++ b/packages/runtime/src/runtime-kernel.ts @@ -454,6 +454,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 @@ -505,7 +509,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, @@ -541,10 +545,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. * @@ -554,17 +559,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[], @@ -572,10 +579,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 397c67f669..5a8c9eabb7 100644 --- a/packages/runtime/src/session-manager.ts +++ b/packages/runtime/src/session-manager.ts @@ -1720,7 +1720,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 @@ -1737,6 +1741,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', @@ -1821,10 +1831,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 0427ee2bc25d56e0d018d98e2ac9f1ed1c072729 Mon Sep 17 00:00:00 2001 From: chinawch007 Date: Sat, 22 Aug 2026 21:33:22 +0800 Subject: [PATCH 07/14] 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 eccd34dcc9af97cf2be0fd0e308646311b4621ef Mon Sep 17 00:00:00 2001 From: chinawch007 Date: Sat, 22 Aug 2026 21:34:35 +0800 Subject: [PATCH 08/14] 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 ad1cbb7217..cfedc82e49 100644 --- a/packages/runtime-host/src/__tests__/session-catalog-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/session-catalog-coordinator.test.ts @@ -395,12 +395,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: { @@ -442,6 +436,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({ @@ -1370,6 +1390,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 84ab30bdd7..70b47572ad 100644 --- a/packages/runtime-host/src/server/session-catalog-coordinator.ts +++ b/packages/runtime-host/src/server/session-catalog-coordinator.ts @@ -473,11 +473,14 @@ export class HostSessionCatalogCoordinator { const clearsConnectionBlock = 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), - input.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, input.configuration.permissionMode); if ( !clearsConnectionBlock && boundaryMatchesConfiguration && From 34f6b8355bed8f2c5bd4808a8bc296321f1b0c41 Mon Sep 17 00:00:00 2001 From: chinawch007 Date: Sat, 22 Aug 2026 21:39:24 +0800 Subject: [PATCH 09/14] 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 e53c086a6e..2c183effee 100644 --- a/packages/runtime/src/__tests__/session-manager.test.ts +++ b/packages/runtime/src/__tests__/session-manager.test.ts @@ -5348,6 +5348,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(); @@ -16873,6 +16933,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 3c557e7a82..be2d9a0ff4 100644 --- a/packages/runtime/src/runtime-kernel.ts +++ b/packages/runtime/src/runtime-kernel.ts @@ -567,7 +567,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, @@ -674,6 +676,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) { @@ -2827,6 +2838,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 5a8c9eabb7..969fee580b 100644 --- a/packages/runtime/src/session-manager.ts +++ b/packages/runtime/src/session-manager.ts @@ -1847,7 +1847,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 14d4a6b25b1135850dbed554ba47acfb4040cdf9 Mon Sep 17 00:00:00 2001 From: chinawch007 Date: Sat, 22 Aug 2026 21:41:32 +0800 Subject: [PATCH 10/14] 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 2c183effee..b904c0b756 100644 --- a/packages/runtime/src/__tests__/session-manager.test.ts +++ b/packages/runtime/src/__tests__/session-manager.test.ts @@ -5486,7 +5486,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 be2d9a0ff4..e1c117bc5c 100644 --- a/packages/runtime/src/runtime-kernel.ts +++ b/packages/runtime/src/runtime-kernel.ts @@ -3089,11 +3089,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 @@ -3122,14 +3137,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 969fee580b..74d317366d 100644 --- a/packages/runtime/src/session-manager.ts +++ b/packages/runtime/src/session-manager.ts @@ -1841,6 +1841,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 8efbb480e66135bd156564947739a43b53c65d8b Mon Sep 17 00:00:00 2001 From: chinawch007 Date: Sun, 23 Aug 2026 20:37:49 +0800 Subject: [PATCH 11/14] 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 8ba7c4c907ec01e56035b0ee2e2742f1ec5d7417 Mon Sep 17 00:00:00 2001 From: chinawch007 Date: Sun, 23 Aug 2026 20:45:45 +0800 Subject: [PATCH 12/14] 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 cfedc82e49..ad569765b7 100644 --- a/packages/runtime-host/src/__tests__/session-catalog-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/session-catalog-coordinator.test.ts @@ -409,9 +409,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); @@ -429,9 +430,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 e1c117bc5c..c28560e58c 100644 --- a/packages/runtime/src/runtime-kernel.ts +++ b/packages/runtime/src/runtime-kernel.ts @@ -613,9 +613,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[] { @@ -664,7 +662,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 2127aaa2c72881c9f9b4f7719f2031a1cf0d6cb6 Mon Sep 17 00:00:00 2001 From: chinawch007 Date: Mon, 24 Aug 2026 21:06:12 +0800 Subject: [PATCH 13/14] 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 b904c0b756..d017f51fc2 100644 --- a/packages/runtime/src/__tests__/session-manager.test.ts +++ b/packages/runtime/src/__tests__/session-manager.test.ts @@ -5282,12 +5282,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, @@ -5295,7 +5296,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() {}, @@ -5332,20 +5334,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 74d317366d..b3df1df913 100644 --- a/packages/runtime/src/session-manager.ts +++ b/packages/runtime/src/session-manager.ts @@ -1714,68 +1714,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( @@ -1802,6 +1826,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 183fb72e48c3ca36cdf0c67f0b8d53b7d223a5fe Mon Sep 17 00:00:00 2001 From: chinawch007 Date: Mon, 24 Aug 2026 21:44:42 +0800 Subject: [PATCH 14/14] 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';