fix(chat): release provider children after lost resume holds

This commit is contained in:
m4air
2026-09-15 23:48:10 -07:00
parent b27aa26bb7
commit 765e345ca5
9 changed files with 621 additions and 26 deletions
@@ -0,0 +1,50 @@
# Structured session hold lost during resume
Run from the repository root:
```sh
ORCA_BACKGROUND_LAUNCH=1 node docs/audits/structured-hold-retention/reproduce.mjs
```
The script bundles the actual `StructuredAgentSessionHolds` implementation in memory. It runs it
once without the post-resume holder check and once with the current source. It uses a deferred
provider acquisition, an isolated fake child, and a 5 ms release grace. It launches no application,
provider, or terminal and reads no user profile.
The last surface releases its hold while acquisition is pending. At that point the session has no
provider child, so `release()` cannot arm the release clock. Before the fix, acquisition completes
with a child, zero holders, and no scheduled eviction. With the fix, successful acquisition checks
for surviving holders and schedules the existing release clock. The recorded child is released once.
The RPC path registers connection cleanup before awaiting `host.hold()` in
`src/main/runtime/rpc/methods/structured-agent-session-hold.ts`. Runtime socket close calls
`cleanupSubscriptionsForConnection()` in `runtime-rpc/runtime-rpc-lifecycle.ts`. That supplies the
production release-during-acquisition ordering reproduced here.
## Ownership limits
- This proves a lifecycle race, not that it caused any particular OOM report. No process RSS was
measured. It applies to structured sessions acquiring a provider child, not ordinary PTY tabs.
- The release clock preserves its 15-second production grace, waits while a turn is active, and
cancels when a new holder arrives. Acquisition failures retain their existing handling.
- Disposal prevents late acquisition or release callbacks from restarting the clock. Host teardown
owns cleanup after disposal. The host's broader pre-attach shutdown admission is outside this fix.
- A restored childless journal is not necessarily abandoned. Startup selects persisted visible
tabs; `host.sessions` supplies `listSessionTabs()`, and childless sessions can retain live TUI
owners. `host.close()` closes that TUI owner before removing the journal. This fix neither evicts
childless history nor infers process exit from transport loss.
- Execution remains on the owning runtime, with no wire or SSH routing changes.
Targeted regressions live in
`src/main/native-chat/agent-session-wire/structured-agent-session-hold-resume-race.test.ts`.
They cover last-holder loss, active turns, new holders, reconnection, failed acquisition, explicit
close, and disposal.
Same-ID replacement is fenced at both ownership layers. Holder entries receive a new incarnation
after release and re-add, so an old failed acquisition cannot remove a replacement. The RPC uses
the subscription registry's `releaseIfCurrent()` cleanup, so its failure cannot unregister the
replacement's connection cleanup. Duplicate adds remain one holder. Class tests and real host/RPC
tests cover the old failure arriving before and after replacement success; disconnect still releases
the replacement normally. They also cover the reverse outcome: an old acquisition succeeds and the
replacement refuses a stale fence. Its last-holder rollback starts the same turn-aware release clock
for the acquired child. No RPC fields or published frame shapes change.
@@ -0,0 +1,120 @@
import { createHash } from 'node:crypto'
import { readFile } from 'node:fs/promises'
import { createRequire } from 'node:module'
import { fileURLToPath } from 'node:url'
import { build } from 'esbuild'
if (process.env.ORCA_BACKGROUND_LAUNCH !== '1') {
throw new Error('Run with ORCA_BACKGROUND_LAUNCH=1.')
}
const root = fileURLToPath(new URL('../../../', import.meta.url))
const sourcePath = fileURLToPath(
new URL(
'../../../src/main/native-chat/agent-session-wire/structured-agent-session-holds.ts',
import.meta.url
)
)
const source = await readFile(sourcePath, 'utf8')
const postResumeCheck =
' // The last surface can disconnect before acquisition makes a child available to release.\n' +
' if (!this.disposed && !this.holders.isHeld(sessionId)) {\n' +
' this.clock.arm(sessionId)\n' +
' }\n'
if (!source.includes(postResumeCheck)) {
throw new Error('Source changed: review the before-fix transform before running this proof.')
}
async function loadHolds(withPostResumeCheck) {
const result = await build({
absWorkingDir: root,
entryPoints: [sourcePath],
bundle: true,
platform: 'node',
format: 'cjs',
write: false,
logLevel: 'silent',
plugins: [
{
name: 'compare-post-resume-holder-check',
setup(plugin) {
plugin.onLoad({ filter: /structured-agent-session-holds\.ts$/ }, () => ({
contents: withPostResumeCheck ? source : source.replace(postResumeCheck, ''),
loader: 'ts'
}))
}
}
]
})
const loaded = { exports: {} }
new Function('module', 'exports', 'require', result.outputFiles[0].text)(
loaded,
loaded.exports,
createRequire(import.meta.url)
)
return loaded.exports.StructuredAgentSessionHolds
}
async function reproduce(Holds) {
const gate = Promise.withResolvers()
let child = false
let evictions = 0
const holds = new Holds({
resume: async () => {
await gate.promise
child = true
},
hasProviderChild: () => child,
isTurnActive: () => false,
evict: async () => {
evictions += 1
child = false
},
graceMs: 5
})
try {
const acquiring = holds.hold('restored-session', 'connection:surface')
holds.release('restored-session', 'connection:surface')
gate.resolve()
await acquiring
const releasePendingAfterAcquisition = holds.isReleasePending('restored-session')
await new Promise((resolve) => setTimeout(resolve, 30))
return {
child,
held: holds.isHeld('restored-session'),
releasePendingAfterAcquisition,
evictions
}
} finally {
holds.dispose()
}
}
const before = await reproduce(await loadHolds(false))
const after = await reproduce(await loadHolds(true))
const passed =
before.child &&
!before.held &&
!before.releasePendingAfterAcquisition &&
before.evictions === 0 &&
!after.child &&
!after.held &&
after.releasePendingAfterAcquisition &&
after.evictions === 1
console.log(
JSON.stringify(
{
source: 'src/main/native-chat/agent-session-wire/structured-agent-session-holds.ts',
sourceSha256: createHash('sha256').update(source).digest('hex'),
comparison: 'same source, before omits only the post-resume holder check',
before,
after,
passed
},
null,
2
)
)
if (!passed) {
process.exitCode = 1
}
@@ -0,0 +1,18 @@
{
"source": "src/main/native-chat/agent-session-wire/structured-agent-session-holds.ts",
"sourceSha256": "c95e27518d6cb09f1c97e5ff18bbb9fc5b15c7680d4a99e90cc14353d602230f",
"comparison": "same source, before omits only the post-resume holder check",
"before": {
"child": true,
"held": false,
"releasePendingAfterAcquisition": false,
"evictions": 0
},
"after": {
"child": false,
"held": false,
"releasePendingAfterAcquisition": true,
"evictions": 1
},
"passed": true
}
@@ -0,0 +1,289 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { StructuredAgentSessionHolds } from './structured-agent-session-holds'
const GRACE_MS = 15_000
const pendingHolds: StructuredAgentSessionHolds[] = []
function resumeHarness() {
const resumeGate = Promise.withResolvers<void>()
let child = false
let turnActive = false
const evict = vi.fn(async () => {
child = false
})
const holds = new StructuredAgentSessionHolds({
resume: async () => {
await resumeGate.promise
child = true
},
hasProviderChild: () => child,
isTurnActive: () => turnActive,
evict,
graceMs: GRACE_MS
})
pendingHolds.push(holds)
return {
holds,
resumeGate,
evict,
hasChild: () => child,
setTurnActive: (value: boolean) => {
turnActive = value
}
}
}
beforeEach(() => vi.useFakeTimers())
afterEach(() => {
for (const holds of pendingHolds.splice(0)) {
holds.dispose()
}
vi.useRealTimers()
})
describe('a surface leaving while its structured session resumes', () => {
it('releases the acquired child after the last surface disconnects during resume', async () => {
const { holds, resumeGate, evict, hasChild } = resumeHarness()
const hold = holds.hold('session-1', 'connection-1:chat')
holds.release('session-1', 'connection-1:chat')
expect(holds.isReleasePending('session-1')).toBe(false)
resumeGate.resolve()
await hold
expect(hasChild()).toBe(true)
expect(holds.isHeld('session-1')).toBe(false)
expect(holds.isReleasePending('session-1')).toBe(true)
await vi.advanceTimersByTimeAsync(GRACE_MS - 1)
expect(evict).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(1)
expect(evict).toHaveBeenCalledExactlyOnceWith('session-1')
expect(hasChild()).toBe(false)
})
it('waits for an active turn before releasing the late child', async () => {
const { holds, resumeGate, evict, setTurnActive } = resumeHarness()
const hold = holds.hold('session-1', 'connection-1:chat')
holds.release('session-1', 'connection-1:chat')
setTurnActive(true)
resumeGate.resolve()
await hold
await vi.advanceTimersByTimeAsync(GRACE_MS * 2)
expect(evict).not.toHaveBeenCalled()
expect(holds.isReleasePending('session-1')).toBe(true)
setTurnActive(false)
await vi.advanceTimersByTimeAsync(GRACE_MS)
expect(evict).toHaveBeenCalledExactlyOnceWith('session-1')
})
it.each([false, true])('preserves an arriving holder with resume=%s', async (resume) => {
const { holds, resumeGate, evict } = resumeHarness()
const first = holds.hold('session-1', 'connection-1:chat')
holds.release('session-1', 'connection-1:chat')
const replacement = holds.hold('session-1', 'connection-2:chat', { resume })
resumeGate.resolve()
await Promise.all([first, replacement])
await vi.advanceTimersByTimeAsync(GRACE_MS * 2)
expect(holds.isHeld('session-1')).toBe(true)
expect(holds.isReleasePending('session-1')).toBe(false)
expect(evict).not.toHaveBeenCalled()
holds.release('session-1', 'connection-2:chat')
await vi.advanceTimersByTimeAsync(GRACE_MS)
expect(evict).toHaveBeenCalledExactlyOnceWith('session-1')
})
it('cancels the late-child release when a surface reconnects during grace', async () => {
const { holds, resumeGate, evict } = resumeHarness()
const hold = holds.hold('session-1', 'connection-1:chat')
holds.release('session-1', 'connection-1:chat')
resumeGate.resolve()
await hold
expect(holds.isReleasePending('session-1')).toBe(true)
await holds.hold('session-1', 'connection-2:chat')
await vi.advanceTimersByTimeAsync(GRACE_MS * 2)
expect(holds.isReleasePending('session-1')).toBe(false)
expect(evict).not.toHaveBeenCalled()
})
it('preserves a failed resume without scheduling eviction', async () => {
const { holds, resumeGate, evict, hasChild } = resumeHarness()
const failure = new Error('provider acquisition failed')
const hold = holds.hold('session-1', 'connection-1:chat')
const rejected = expect(hold).rejects.toBe(failure)
holds.release('session-1', 'connection-1:chat')
resumeGate.reject(failure)
await rejected
await vi.advanceTimersByTimeAsync(GRACE_MS * 2)
expect(hasChild()).toBe(false)
expect(holds.isHeld('session-1')).toBe(false)
expect(holds.isReleasePending('session-1')).toBe(false)
expect(evict).not.toHaveBeenCalled()
})
it('leaves late acquisition cleanup to host teardown after disposal', async () => {
const { holds, resumeGate, evict } = resumeHarness()
const hold = holds.hold('session-1', 'connection-1:chat')
holds.release('session-1', 'connection-1:chat')
holds.dispose()
resumeGate.resolve()
await hold
await vi.advanceTimersByTimeAsync(GRACE_MS * 2)
expect(holds.isReleasePending('session-1')).toBe(false)
expect(evict).not.toHaveBeenCalled()
})
it('does not restart release timers when a surface leaves after disposal', async () => {
const { holds, resumeGate, evict } = resumeHarness()
const hold = holds.hold('session-1', 'connection-1:chat')
resumeGate.resolve()
await hold
holds.dispose()
holds.release('session-1', 'connection-1:chat')
await vi.advanceTimersByTimeAsync(GRACE_MS * 2)
expect(holds.isHeld('session-1')).toBe(false)
expect(holds.isReleasePending('session-1')).toBe(false)
expect(evict).not.toHaveBeenCalled()
})
it('releases a late child acquired after explicit close forgot its holders', async () => {
const { holds, resumeGate, evict } = resumeHarness()
const hold = holds.hold('session-1', 'connection-1:chat')
holds.forget('session-1')
resumeGate.resolve()
await hold
await vi.advanceTimersByTimeAsync(GRACE_MS)
expect(holds.isHeld('session-1')).toBe(false)
expect(evict).toHaveBeenCalledExactlyOnceWith('session-1')
})
it.each([false, true])(
'keeps a reused holder when old resume fails (replacement finished=%s)',
async (replacementFinished) => {
const firstGate = Promise.withResolvers<void>()
const replacementGate = Promise.withResolvers<void>()
let child = false
const resume = vi
.fn()
.mockImplementationOnce(() => firstGate.promise)
.mockImplementationOnce(async () => {
await replacementGate.promise
child = true
})
const evict = vi.fn(async () => {})
const holds = new StructuredAgentSessionHolds({
resume,
hasProviderChild: () => child,
isTurnActive: () => false,
evict,
graceMs: GRACE_MS
})
pendingHolds.push(holds)
const first = holds.hold('session-1', 'same-holder')
const rejected = expect(first).rejects.toThrow('old acquisition failed')
holds.release('session-1', 'same-holder')
const replacement = holds.hold('session-1', 'same-holder')
if (replacementFinished) {
replacementGate.resolve()
await replacement
}
firstGate.reject(new Error('old acquisition failed'))
await rejected
expect(holds.isHeld('session-1')).toBe(true)
replacementGate.resolve()
await replacement
await vi.advanceTimersByTimeAsync(GRACE_MS * 2)
expect(evict).not.toHaveBeenCalled()
holds.release('session-1', 'same-holder')
await vi.advanceTimersByTimeAsync(GRACE_MS)
expect(evict).toHaveBeenCalledExactlyOnceWith('session-1')
}
)
it('removes a failed replacement while the released old hold is still pending', async () => {
const firstGate = Promise.withResolvers<void>()
const resume = vi
.fn()
.mockImplementationOnce(() => firstGate.promise)
.mockRejectedValueOnce(new Error('replacement acquisition failed'))
const holds = new StructuredAgentSessionHolds({
resume,
hasProviderChild: () => false,
isTurnActive: () => false,
evict: async () => {},
graceMs: GRACE_MS
})
pendingHolds.push(holds)
const first = holds.hold('session-1', 'same-holder')
const rejected = expect(first).rejects.toThrow('old acquisition failed')
holds.release('session-1', 'same-holder')
await expect(holds.hold('session-1', 'same-holder')).rejects.toThrow(
'replacement acquisition failed'
)
expect(holds.isHeld('session-1')).toBe(false)
expect(holds.isReleasePending('session-1')).toBe(false)
firstGate.reject(new Error('old acquisition failed'))
await rejected
expect(holds.isHeld('session-1')).toBe(false)
})
it.each(['old-holder', 'different-holder'])(
'releases the old acquisition after replacement %s fails, once its turn finishes',
async (replacementHolder) => {
const firstGate = Promise.withResolvers<void>()
const replacementGate = Promise.withResolvers<void>()
let child = false
let turnActive = true
const resume = vi
.fn()
.mockImplementationOnce(async () => {
await firstGate.promise
child = true
})
.mockImplementationOnce(() => replacementGate.promise)
const evict = vi.fn(async () => {
child = false
})
const holds = new StructuredAgentSessionHolds({
resume,
hasProviderChild: () => child,
isTurnActive: () => turnActive,
evict,
graceMs: GRACE_MS
})
pendingHolds.push(holds)
const first = holds.hold('session-1', 'old-holder')
holds.release('session-1', 'old-holder')
const replacement = holds.hold('session-1', replacementHolder)
const rejected = expect(replacement).rejects.toThrow('replacement acquisition failed')
firstGate.resolve()
await first
expect(holds.isReleasePending('session-1')).toBe(false)
replacementGate.reject(new Error('replacement acquisition failed'))
await rejected
expect(holds.isHeld('session-1')).toBe(false)
expect(holds.isReleasePending('session-1')).toBe(true)
await vi.advanceTimersByTimeAsync(GRACE_MS)
expect(evict).not.toHaveBeenCalled()
expect(child).toBe(true)
turnActive = false
await vi.advanceTimersByTimeAsync(GRACE_MS)
expect(evict).toHaveBeenCalledExactlyOnceWith('session-1')
expect(child).toBe(false)
}
)
})
@@ -6,23 +6,35 @@
// still looking at, and a lost one leaks the child forever. A set answers both idempotently,
// because it records WHICH surface holds the session, not how many do.
type Holder = { resumeCapable: boolean; incarnation: symbol }
export class StructuredAgentSessionHolders {
private readonly bySession = new Map<string, Map<string, boolean>>()
private readonly bySession = new Map<string, Map<string, Holder>>()
/** True when the session gained its FIRST holder — the edge that ends a pending release. */
add(sessionId: string, holderId: string, resumeCapable = true): boolean {
const holders = this.bySession.get(sessionId)
if (!holders) {
this.bySession.set(sessionId, new Map([[holderId, resumeCapable]]))
this.bySession.set(sessionId, new Map([[holderId, { resumeCapable, incarnation: Symbol() }]]))
return true
}
holders.set(holderId, (holders.get(holderId) ?? false) || resumeCapable)
const previous = holders.get(holderId)
holders.set(holderId, {
resumeCapable: (previous?.resumeCapable ?? false) || resumeCapable,
incarnation: previous?.incarnation ?? Symbol()
})
return false
}
/** True when the session lost its LAST holder — the edge that starts one. */
remove(sessionId: string, holderId: string): boolean {
remove(sessionId: string, holderId: string, expectedIncarnation?: symbol): boolean {
const holders = this.bySession.get(sessionId)
if (
expectedIncarnation !== undefined &&
holders?.get(holderId)?.incarnation !== expectedIncarnation
) {
return false
}
if (!holders?.delete(holderId) || holders.size > 0) {
return false
}
@@ -38,12 +50,18 @@ export class StructuredAgentSessionHolders {
return this.bySession.get(sessionId)?.has(holderId) ?? false
}
incarnation(sessionId: string, holderId: string): symbol | undefined {
return this.bySession.get(sessionId)?.get(holderId)?.incarnation
}
holderIds(sessionId: string): string[] {
return [...(this.bySession.get(sessionId)?.keys() ?? [])]
}
hasResumeCapableHolder(sessionId: string): boolean {
return [...(this.bySession.get(sessionId)?.values() ?? [])].some(Boolean)
return [...(this.bySession.get(sessionId)?.values() ?? [])].some(
(holder) => holder.resumeCapable
)
}
/** Drops every holder of one session without evaluating the edge, for a session that is gone. */
@@ -36,6 +36,7 @@ export type StructuredAgentSessionHoldOptions = {
export class StructuredAgentSessionHolds {
private readonly holders = new StructuredAgentSessionHolders()
private readonly clock: StructuredAgentSessionReleaseClock
private disposed = false
constructor(private readonly deps: StructuredAgentSessionHoldsDeps) {
const clockDeps: StructuredAgentSessionReleaseClockDeps = {
@@ -55,6 +56,7 @@ export class StructuredAgentSessionHolds {
): Promise<void> {
const alreadyHeld = this.holders.has(sessionId, holderId)
this.holders.add(sessionId, holderId, options.resume !== false)
const incarnation = this.holders.incarnation(sessionId, holderId)
// Unconditional, not only on the first-holder edge: a second surface arriving during the grace
// window must cancel the pending release too.
this.clock.cancel(sessionId)
@@ -66,19 +68,23 @@ export class StructuredAgentSessionHolds {
if (!this.deps.hasProviderChild(sessionId)) {
throw new Error('agent_session_ownership_unknown')
}
// The last surface can disconnect before acquisition makes a child available to release.
if (!this.disposed && !this.holders.isHeld(sessionId)) {
this.clock.arm(sessionId)
}
} catch (error) {
if (!alreadyHeld) {
this.holders.remove(sessionId, holderId)
if (!alreadyHeld && incarnation !== undefined) {
this.release(sessionId, holderId, incarnation)
}
throw error
}
}
release(sessionId: string, holderId: string): void {
if (!this.holders.remove(sessionId, holderId)) {
release(sessionId: string, holderId: string, expectedIncarnation?: symbol): void {
if (!this.holders.remove(sessionId, holderId, expectedIncarnation)) {
return
}
if (this.deps.hasProviderChild(sessionId)) {
if (!this.disposed && this.deps.hasProviderChild(sessionId)) {
this.clock.arm(sessionId)
}
}
@@ -102,6 +108,7 @@ export class StructuredAgentSessionHolds {
}
dispose(): void {
this.disposed = true
this.clock.dispose()
}
}
@@ -162,6 +162,107 @@ describe('a client that holds a session', () => {
})
describe('a client that disappears without cleanup', () => {
it('releases a late child after its same-ID replacement refuses the stale fence', async () => {
await host.close(SESSION)
await host.restoreReadableSessions()
closeSession.mockClear()
const firstEntered = Promise.withResolvers<void>()
const firstGate = Promise.withResolvers<void>()
const replacementEntered = Promise.withResolvers<void>()
const replacementGate = Promise.withResolvers<void>()
const attach = host.attach.bind(host)
const attachSpy = vi
.spyOn(host, 'attach')
.mockImplementationOnce(async (...args) => {
firstEntered.resolve()
await firstGate.promise
return attach(...args)
})
.mockImplementationOnce(async (...args) => {
replacementEntered.resolve()
await replacementGate.promise
return attach(...args)
})
try {
const params = { sessionId: SESSION, holderId: 'same-chat' }
const first = call('agentSession.hold', params)
await firstEntered.promise
const replacement = call('agentSession.hold', params)
await replacementEntered.promise
firstGate.resolve()
expect(await first).toMatchObject({ ok: true })
expect(host.isHeld(SESSION)).toBe(true)
expect(closeSession).not.toHaveBeenCalled()
replacementGate.resolve()
expect(await replacement).toMatchObject({
ok: false,
error: { code: 'agent_session_checkpoint_stale' }
})
expect(host.isHeld(SESSION)).toBe(false)
await vi.waitFor(() => expect(host.hasSession(SESSION)).toBe(false))
expect(closeSession).toHaveBeenCalledExactlyOnceWith(SESSION)
} finally {
firstGate.resolve()
replacementGate.resolve()
attachSpy.mockRestore()
}
})
it.each([false, true])(
'keeps replacement hold and cleanup after an old request fails (replacement finished=%s)',
async (replacementFinished) => {
await host.close(SESSION)
await host.restoreReadableSessions()
closeSession.mockClear()
const firstEntered = Promise.withResolvers<void>()
const firstGate = Promise.withResolvers<void>()
const replacementEntered = Promise.withResolvers<void>()
const replacementGate = Promise.withResolvers<void>()
const attach = host.attach.bind(host)
const attachSpy = vi
.spyOn(host, 'attach')
.mockImplementationOnce(async () => {
firstEntered.resolve()
await firstGate.promise
throw new Error('old acquisition failed')
})
.mockImplementationOnce(async (...args) => {
replacementEntered.resolve()
await replacementGate.promise
return attach(...args)
})
try {
const params = { sessionId: SESSION, holderId: 'same-chat' }
const first = call('agentSession.hold', params)
await firstEntered.promise
const replacement = call('agentSession.hold', params)
await replacementEntered.promise
if (replacementFinished) {
replacementGate.resolve()
expect(await replacement).toMatchObject({ ok: true })
}
firstGate.resolve()
expect(await first).toMatchObject({ ok: false })
expect(host.isHeld(SESSION)).toBe(true)
replacementGate.resolve()
expect(await replacement).toMatchObject({ ok: true })
await new Promise((resolve) => setTimeout(resolve, GRACE_MS * 4))
expect(host.hasSession(SESSION)).toBe(true)
expect(closeSession).not.toHaveBeenCalled()
runtime.cleanupSubscriptionsForConnection(CONNECTION)
await vi.waitFor(() => expect(host.hasSession(SESSION)).toBe(false))
expect(closeSession).toHaveBeenCalledExactlyOnceWith(SESSION)
} finally {
firstGate.resolve()
replacementGate.resolve()
attachSpy.mockRestore()
}
}
)
it('still releases the session when its transport closes', async () => {
await call('agentSession.hold', { sessionId: SESSION, holderId: 'chat-1' })
@@ -36,7 +36,7 @@ export const STRUCTURED_AGENT_SESSION_HOLD_METHODS = [
await ensureStructuredHostInstalled(ctx)
const host = requireStructuredHost(ctx)
const holderKey = holderKeyFor(ctx, params.holderId)
ctx.runtime.registerSubscriptionCleanup(
const registration = ctx.runtime.registerOwnedSubscriptionCleanup(
holdCleanupIdFor(params.sessionId, holderKey),
() => host.release(params.sessionId, holderKey),
ctx.connectionId
@@ -44,7 +44,7 @@ export const STRUCTURED_AGENT_SESSION_HOLD_METHODS = [
try {
await host.hold(params.sessionId, holderKey)
} catch (error) {
ctx.runtime.cleanupSubscription(holdCleanupIdFor(params.sessionId, holderKey))
registration.releaseIfCurrent()
throw error
}
return { held: true as const }
@@ -21,6 +21,7 @@ import type { AgentSessionAttachParams } from '../../../src/main/native-chat/age
import { StructuredAgentSessionHost } from '../../../src/main/native-chat/agent-session-wire/structured-agent-session-host'
import { setStructuredAgentSessionHost } from '../../../src/main/native-chat/agent-session-wire/structured-agent-session-registry'
import { AgentSessionRecordStore } from '../../../src/main/runtime/agent-session-record-store'
import { RuntimeSubscriptionRegistry } from '../../../src/main/runtime/runtime-subscription-registry'
import { computeAgentSessionPayloadFingerprint } from '../../../src/shared/agent-session-mutation-envelope'
import type { AgentSessionSubscribeEvent } from '../../../src/shared/agent-session-wire'
import {
@@ -272,7 +273,7 @@ function paramsFor(method: string): unknown {
}
function runtimeStub(): unknown {
const cleanups = new Map<string, () => void>()
const subscriptions = new RuntimeSubscriptionRegistry()
return {
getRuntimeId: () => 'runtime-1',
getClientSettings: () => ({ experimentalStructuredNativeChat: true }),
@@ -287,19 +288,10 @@ function runtimeStub(): unknown {
return resolved
},
publishStructuredAgentSessionTab: () => {},
registerSubscriptionCleanup: (id: string, cleanup: () => void) => cleanups.set(id, cleanup),
cleanupSubscription: (id: string) => {
cleanups.get(id)?.()
cleanups.delete(id)
},
cleanupSubscriptionsByPrefix: (prefix: string) => {
for (const [id, cleanup] of cleanups) {
if (id.startsWith(prefix)) {
cleanup()
cleanups.delete(id)
}
}
}
registerSubscriptionCleanup: subscriptions.register.bind(subscriptions),
registerOwnedSubscriptionCleanup: subscriptions.registerOwned.bind(subscriptions),
cleanupSubscription: subscriptions.cleanup.bind(subscriptions),
cleanupSubscriptionsByPrefix: subscriptions.cleanupByPrefix.bind(subscriptions)
}
}