Skip to content

Commit 4792af5

Browse files
committed
refactor(runtime): drop the boundary-revision backend watcher (#3349)
Review finding: the boundary revision is not a backend-composition fingerprint — an approved sandbox expansion increments it too, and expansions are consumed live per dispatch by design, changing neither model nor backend-composed configuration. The watcher therefore paid a durable read on every activation and rebuilt backend, transport and composer state after every valid expansion, while defending against a writer that does not exist: configuration transitions already own their backend disposal and invalidation. Remove boundaryRevision, readBoundaryRevision and resolveReusableGeneration (ensureActive returns to plain reuse), the two forced-revision self-heal tests, and the fixed-seed sweep — its interleaving classes stay covered by the direct deterministic tests: claim/run waiting, successor admission, the admission-gate deadlock, immediate tightening at the current dispatch, shell lineage fencing, and the mixed-configuration regression. Generated-by: ZCode (Z.ai GLM)
1 parent 81ebbf2 commit 4792af5

3 files changed

Lines changed: 6 additions & 241 deletions

File tree

packages/runtime/src/__tests__/session-manager.test.ts

Lines changed: 0 additions & 173 deletions
Original file line numberDiff line numberDiff line change
@@ -5123,93 +5123,7 @@ describe('SessionManager permission mode updates', () => {
51235123
expect(builtPermissionModes).toEqual(['ask', 'bypass']);
51245124
});
51255125

5126-
test('a boundary revision bump without backend disposal rebuilds on the next activation', async () => {
5127-
const store = new MemorySessionStore();
5128-
const runStore = new MemoryAgentRunStore();
5129-
const backends = new BackendRegistry();
5130-
let builds = 0;
5131-
backends.register('ai-sdk', (ctx) => {
5132-
builds += 1;
5133-
return new TestBackend(ctx);
5134-
});
5135-
const manager = new SessionManager({
5136-
store,
5137-
runStore,
5138-
runtimeEventStore: runStore,
5139-
backends,
5140-
newId: nextId(),
5141-
now: nextNow(8_000),
5142-
});
5143-
const session = await manager.createSession(makeInput({ permissionMode: 'ask' }));
5144-
5145-
await drain(manager.sendMessage(session.id, { turnId: 'turn-1', text: 'one' }));
5146-
expect(builds).toBe(1);
5147-
5148-
// A write path that skips backend disposal bumps the durable boundary
5149-
// while the generation stays alive.
5150-
store.forceExecutionBoundary(session.id, { kind: 'bypass', revision: 5 });
5151-
5152-
await drain(manager.sendMessage(session.id, { turnId: 'turn-2', text: 'two' }));
5153-
expect(builds).toBe(2);
5154-
expect(store.disposeCount).toBe(1);
5155-
5156-
// Once rebuilt against the current revision, the generation is reused again.
5157-
await drain(manager.sendMessage(session.id, { turnId: 'turn-3', text: 'three' }));
5158-
expect(builds).toBe(2);
5159-
});
5160-
5161-
test('a stale generation with live runs flushes after they exit instead of disposing underneath them', async () => {
5162-
const store = new MemorySessionStore();
5163-
const runStore = new MemoryAgentRunStore();
5164-
const backends = new BackendRegistry();
5165-
const gates: Gate[] = [];
5166-
let builds = 0;
5167-
backends.register('ai-sdk', (ctx) => {
5168-
builds += 1;
5169-
const gate = makeGate();
5170-
gates.push(gate);
5171-
return new TestBackend(ctx, gate);
5172-
});
5173-
const manager = new SessionManager({
5174-
store,
5175-
runStore,
5176-
runtimeEventStore: runStore,
5177-
backends,
5178-
newId: nextId(),
5179-
now: nextNow(8_000),
5180-
});
5181-
const session = await manager.createSession(makeInput({ permissionMode: 'ask' }));
5182-
5183-
const first = manager
5184-
.sendMessage(session.id, { turnId: 'turn-1', text: 'one' })
5185-
[Symbol.asyncIterator]();
5186-
expect((await first.next()).value?.type).toBe('text_delta');
5187-
expect(builds).toBe(1);
51885126

5189-
store.forceExecutionBoundary(session.id, { kind: 'bypass', revision: 5 });
5190-
5191-
// An overlapping activation while turn 1 is live must not dispose the
5192-
// generation underneath it: the generation is marked, reused for this
5193-
// turn, and flushed once both runs exit. Both turns share the reused
5194-
// backend, so a single gate holds them both.
5195-
const second = manager
5196-
.sendMessage(session.id, { turnId: 'turn-2', text: 'two' })
5197-
[Symbol.asyncIterator]();
5198-
expect((await second.next()).value?.type).toBe('text_delta');
5199-
expect(builds).toBe(1);
5200-
5201-
gates[0]!.release();
5202-
while (!(await first.next()).done) {}
5203-
while (!(await second.next()).done) {}
5204-
5205-
const third = manager
5206-
.sendMessage(session.id, { turnId: 'turn-3', text: 'three' })
5207-
[Symbol.asyncIterator]();
5208-
expect((await third.next()).value?.type).toBe('text_delta');
5209-
gates[1]!.release();
5210-
while (!(await third.next()).done) {}
5211-
expect(builds).toBe(2);
5212-
});
52135127

52145128
test('narrowing with an active descendant commits promptly and fences the lineage shells', async () => {
52155129
const store = new MemorySessionStore();
@@ -5519,88 +5433,6 @@ describe('SessionManager permission mode updates', () => {
55195433
expect(dispatch.permissionMode).toBe('bypass');
55205434
});
55215435

5522-
test('seeded switch/turn interleavings always observe the committed mode', async () => {
5523-
// A fixed-seed PRNG picks the interleaving class per iteration; every
5524-
// checkpoint awaits a deterministic event, so the sweep is reproducible.
5525-
let seed = 0x3349;
5526-
const random = (): number => {
5527-
seed = (seed * 1_103_515_245 + 12_345) % 2_147_483_648;
5528-
return seed / 2_147_483_648;
5529-
};
5530-
5531-
const store = new MemorySessionStore();
5532-
const runStore = new MemoryAgentRunStore();
5533-
const backends = new BackendRegistry();
5534-
const gates: Gate[] = [];
5535-
const composedModes: SessionHeader['permissionMode'][] = [];
5536-
backends.register('ai-sdk', (ctx) => {
5537-
const gate = makeGate();
5538-
gates.push(gate);
5539-
composedModes.push(ctx.header.permissionMode);
5540-
return new TestBackend(ctx, gate);
5541-
});
5542-
const manager = new SessionManager({
5543-
store,
5544-
runStore,
5545-
runtimeEventStore: runStore,
5546-
backends,
5547-
// Narrowing (bypass → ask) fences shell runs through this authority.
5548-
shellRuns: {
5549-
async terminateSession() {
5550-
return undefined;
5551-
},
5552-
async commitSessionClose() {},
5553-
rollbackSessionClose() {},
5554-
resumeSession() {},
5555-
} as never,
5556-
newId: nextId(),
5557-
now: nextNow(9_000),
5558-
});
5559-
const session = await manager.createSession(makeInput({ permissionMode: 'ask' }));
5560-
5561-
let expected: 'ask' | 'bypass' = 'ask';
5562-
let turnCount = 0;
5563-
for (let iteration = 0; iteration < 100; iteration += 1) {
5564-
const nextMode: 'ask' | 'bypass' = random() < 0.5 ? 'bypass' : 'ask';
5565-
const interleaving = Math.floor(random() * 3);
5566-
5567-
if (interleaving === 0) {
5568-
// Switch while the session is idle.
5569-
await manager.setPermissionMode(session.id, nextMode);
5570-
expected = nextMode;
5571-
} else {
5572-
// Switch requested while a turn is running; the queued commit lands
5573-
// in the gap as the turn settles (class 1 requests it mid-flight,
5574-
// class 2 races it with the gate release).
5575-
turnCount += 1;
5576-
const turn = manager
5577-
.sendMessage(session.id, { turnId: `turn-${turnCount}`, text: `t${turnCount}` })
5578-
[Symbol.asyncIterator]();
5579-
expect((await turn.next()).value?.type).toBe('text_delta');
5580-
const switching = manager.setPermissionMode(session.id, nextMode);
5581-
if (interleaving === 2) gates[gates.length - 1]!.release();
5582-
if (interleaving === 1) gates[gates.length - 1]!.release();
5583-
while (!(await turn.next()).done) {}
5584-
await switching;
5585-
expected = nextMode;
5586-
}
5587-
5588-
// Invariant: every turn started after the switch resolved is composed
5589-
// from the committed mode, and a tool call against the committed store
5590-
// derives the same mode.
5591-
turnCount += 1;
5592-
const verify = manager
5593-
.sendMessage(session.id, { turnId: `turn-${turnCount}`, text: `t${turnCount}` })
5594-
[Symbol.asyncIterator]();
5595-
expect((await verify.next()).value?.type).toBe('text_delta');
5596-
expect(composedModes[composedModes.length - 1]).toBe(expected);
5597-
const dispatch = await dispatchProbeTool(store, session.id);
5598-
expect(dispatch.boundaryKind).toBe(expected === 'bypass' ? 'bypass' : 'managed');
5599-
expect(dispatch.permissionMode).toBe(expected);
5600-
gates[gates.length - 1]!.release();
5601-
while (!(await verify.next()).done) {}
5602-
}
5603-
});
56045436

56055437
test('leaving explore clears the deep research label so visible read-only copy stays truthful', async () => {
56065438
const store = new MemorySessionStore();
@@ -17218,11 +17050,6 @@ class MemorySessionStore implements SessionStore {
1721817050
return boundary;
1721917051
}
1722017052

17221-
/** Simulates a config write path that bumps the boundary without disposing backends. */
17222-
forceExecutionBoundary(sessionId: string, boundary: ExecutionBoundary): void {
17223-
this.executionBoundaries.set(sessionId, boundary);
17224-
}
17225-
1722617053
async createSandboxBoundaryRequest(
1722717054
input: CreateSandboxBoundaryRequest,
1722817055
): Promise<SandboxBoundaryRequest> {

packages/runtime/src/runtime-kernel.ts

Lines changed: 4 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -319,13 +319,6 @@ interface BackendGeneration extends AgentRunActiveSession {
319319
| { kind: 'failed'; error: unknown };
320320
disposal?: Promise<BackendDisposalOutcome>;
321321
disposalFailure?: Error;
322-
/**
323-
* The durable boundary revision this generation was composed against.
324-
* `ensureActive` compares it against the store to rebuild when a config
325-
* write skipped backend disposal; `undefined` (unreadable at build) keeps
326-
* the guard dormant for this generation.
327-
*/
328-
boundaryRevision?: number;
329322
cachedHeader: SessionHeader;
330323
activeRuns: Map<string, AgentRun>;
331324
turnToRunId: Map<string, string>;
@@ -2844,27 +2837,16 @@ export class RuntimeKernel implements RuntimeKernelLike {
28442837
execution: PendingExecutionClaim,
28452838
): Promise<BackendGeneration> {
28462839
await this.clearBackendQuarantineForActivation(sessionId, execution);
2847-
// The boundary revision this activation is composed against. Recorded on
2848-
// the generation so a later activation can detect a config write that
2849-
// skipped backend disposal (#3349). An unreadable boundary leaves the
2850-
// guard dormant rather than blocking activation.
2851-
const boundaryRevision = await this.readBoundaryRevision(sessionId);
28522840
let existing = this.active.get(sessionId);
28532841
if (existing) {
2854-
const reusable = await this.resolveReusableGeneration(sessionId, existing, boundaryRevision);
2855-
if (reusable) {
2856-
reusable.cachedHeader = header;
2857-
return reusable;
2858-
}
2842+
existing.cachedHeader = header;
2843+
return existing;
28592844
}
28602845
await this.waitForBackendDisposal(sessionId);
28612846
existing = this.active.get(sessionId);
28622847
if (existing) {
2863-
const reusable = await this.resolveReusableGeneration(sessionId, existing, boundaryRevision);
2864-
if (reusable) {
2865-
reusable.cachedHeader = header;
2866-
return reusable;
2867-
}
2848+
existing.cachedHeader = header;
2849+
return existing;
28682850
}
28692851
const entry = await this.shareBackendActivation(`parent:${sessionId}`, async () => {
28702852
const current = this.active.get(sessionId);
@@ -2898,54 +2880,10 @@ export class RuntimeKernel implements RuntimeKernelLike {
28982880
this.active.set(sessionId, generation);
28992881
return generation;
29002882
});
2901-
// Concurrent activations share one build; the first to arrive stamps the
2902-
// revision it read. That stamp may trail the revision actually composed
2903-
// against (the reader ran before the builder) — safe direction: a later
2904-
// activation rebuilds once more, never reuses a newer composition blindly.
2905-
entry.boundaryRevision ??= boundaryRevision;
29062883
entry.cachedHeader = header;
29072884
return entry;
29082885
}
29092886

2910-
private async readBoundaryRevision(sessionId: string): Promise<number | undefined> {
2911-
// One dedicated store read per activation: cheaper than widening the
2912-
// session header read the turn already performs, and the guard is optional
2913-
// defense in depth — an unreadable boundary simply leaves it dormant.
2914-
try {
2915-
return (await this.deps.store.readExecutionBoundary(sessionId)).revision;
2916-
} catch {
2917-
return undefined;
2918-
}
2919-
}
2920-
2921-
/**
2922-
* Defense in depth against a config write that bumped the durable boundary
2923-
* without disposing the backend generation it was composed against: dispose
2924-
* and rebuild now when nothing executes on the generation, and when runs are
2925-
* still live, mark the generation for invalidation instead — it flushes when
2926-
* they exit, and the next activation composes fresh. Tools are unaffected
2927-
* meanwhile: they read the boundary live on every call.
2928-
*/
2929-
private async resolveReusableGeneration(
2930-
sessionId: string,
2931-
existing: BackendGeneration,
2932-
boundaryRevision: number | undefined,
2933-
): Promise<BackendGeneration | undefined> {
2934-
if (
2935-
boundaryRevision === undefined ||
2936-
existing.boundaryRevision === undefined ||
2937-
existing.boundaryRevision === boundaryRevision
2938-
) {
2939-
return existing;
2940-
}
2941-
if (this.hasActiveRuns(sessionId)) {
2942-
this.ensureBackendInvalidation(sessionId);
2943-
return existing;
2944-
}
2945-
await this.disposeBackend(sessionId);
2946-
return undefined;
2947-
}
2948-
29492887
private async shareBackendActivation(
29502888
activationKey: string,
29512889
activate: () => Promise<BackendGeneration>,

packages/runtime/src/session-manager.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1751,8 +1751,8 @@ export class SessionManager {
17511751
* already reads the narrower authority, and background shell authority
17521752
* across the lineage is fenced at once. Backend disposal is deferred: a
17531753
* live run keeps executing on its generation (tools read the boundary live
1754-
* on every call), idle generations dispose through invalidation now, and
1755-
* the boundary-revision guard rebuilds stale ones on their next activation.
1754+
* on every call), and idle-time invalidation disposes and rebuilds stale
1755+
* generations — configuration transitions own their backend lifecycle.
17561756
*/
17571757
private async commitTighteningTransition<T>(
17581758
sessionId: string,

0 commit comments

Comments
 (0)