From 5333f0f4e1d8be5bceefc467a9e28feb5a422554 Mon Sep 17 00:00:00 2001 From: m4air Date: Wed, 16 Sep 2026 01:48:04 -0700 Subject: [PATCH] fix(sessions): cancel TUI transcript acquisition during teardown --- .../tui-transcript-acquisition/README.md | 32 +++ .../tui-transcript-acquisition/fix.patch | 254 ++++++++++++++++++ .../tui-transcript-acquisition/reproduce.mjs | 150 +++++++++++ .../tui-transcript-acquisition/results.json | 46 ++++ ...tructured-agent-session-handoff-forward.ts | 18 +- ...tructured-agent-session-handoff-restart.ts | 11 +- .../structured-agent-session-handoff-types.ts | 11 +- .../structured-tui-transcript-catchup.ts | 91 +++++-- ...tructured-tui-transcript-ownership.test.ts | 130 +++++++++ ...ed-tui-transcript-teardown-test-fixture.ts | 104 +++++++ ...structured-tui-transcript-teardown.test.ts | 190 +++++++++++++ 11 files changed, 1003 insertions(+), 34 deletions(-) create mode 100644 docs/audits/tui-transcript-acquisition/README.md create mode 100644 docs/audits/tui-transcript-acquisition/fix.patch create mode 100644 docs/audits/tui-transcript-acquisition/reproduce.mjs create mode 100644 docs/audits/tui-transcript-acquisition/results.json create mode 100644 src/main/native-chat/agent-session-wire/structured-tui-transcript-ownership.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-tui-transcript-teardown-test-fixture.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-tui-transcript-teardown.test.ts diff --git a/docs/audits/tui-transcript-acquisition/README.md b/docs/audits/tui-transcript-acquisition/README.md new file mode 100644 index 00000000000..9d481b5abdf --- /dev/null +++ b/docs/audits/tui-transcript-acquisition/README.md @@ -0,0 +1,32 @@ +# Transcript catchup can outlive host teardown + +Host teardown stops TUI transcript catchup before draining in-flight handoffs. Previously, catchup setup registered its state only after asynchronous path resolution and stored its unsubscribe function only after asynchronous subscription acquisition. Teardown could miss either resource. A handoff that had not entered preparation yet could also start a watcher after `stopAll`. Actual-host tests reproduced a surviving watcher after the host session was removed. Stopping an already acquired watcher before its first snapshot instead left preparation waiting indefinitely for that snapshot. + +## Ownership fix + +Catchup now registers its state before its first await and owns an abort controller throughout setup. It passes the existing resolver/subscriber cancellation signal, releases late subscriptions, settles the initial-ready wait on stop, and preserves a newer same-session acquisition. `stopAll` permanently closes this host's admission; ordinary per-session `stop` still permits a replacement. + +Preparation returns its signal internally so the handoff checks cancellation immediately before launching a TUI. A dedicated internal cancellation error, while no TUI owner or process identity has been committed, releases the unused reservation through the existing fenced `abandonStoredAgentSessionHandoffAttempt` transition. It leaves a recoverable native lease without acquiring a replacement. This distinction matters: ordinary preparation failure invokes native recovery, and a delayed replacement acquisition can finish after the five-second teardown drain. Ordinary read failures retain that recovery behavior. Canceled recovery of a live TUI stops without retrying or relabeling its live lease. + +These are internal lifecycle changes. They add no wire type and infer no remote process death. An already launched TUI and boundary/import I/O already admitted before cancellation remain outside this change's cancellation guarantee. + +## Reproduce + +```sh +ORCA_BACKGROUND_LAUNCH=1 node docs/audits/tui-transcript-acquisition/reproduce.mjs +``` + +The script runs seven tests through the actual host, handoff coordinator, durable record store, journal, and transcript watcher. Real file resolution, watcher installation, and initial read are paused at explicit asynchronous boundaries; provider processes use the existing fake adapter/transport. No real shell or app window launches. + +`fix.patch` is reversed inside a temporary Vite transform for the baseline. The new internal error declaration remains available to the same assertions; it does not change baseline control flow. Source hashes and exact failing cases are recorded in `results.json`. Each runner uses a 512 MiB old-space limit, a 90-second deadline, and the repository's cross-platform `runProcess`. Temporary runner/configuration files and acquired watchers are cleaned up. + +| Version | Passed | Failed | +| ---------- | -----: | -----: | +| Before fix | 1 | 6 | +| With fix | 7 | 0 | + +The five preparation cases cover resolution, subscription return, initial snapshot, admission after teardown, and a completed preparation whose caller has not resumed. The recovery case preserves the live TUI lease. The control delays native acquisition after an ordinary resolver error and verifies the original error and recovery behavior. Six additional ownership tests cover overlapping prepare/recover replacements, per-session restart, repeated shutdown, and the signal returned when no supported record is available. Existing catchup tests preserve live appends and restart gap replay. + +## Version and attribution + +Named-path reads confirm the same setup gaps, unguarded forward launch, and stop-before-drain ordering in `v1.4.198`. In that tag the teardown phases are inline in `structured-agent-session-host.ts:254`; current source extracts them into `structured-agent-session-host-teardown.ts`. The executable proof compares current source before/after this fix. It establishes an execution-host watcher retaining path present in the reported version, without proving that #19831 or #19768 exercised this teardown race or explaining either report's memory magnitude. diff --git a/docs/audits/tui-transcript-acquisition/fix.patch b/docs/audits/tui-transcript-acquisition/fix.patch new file mode 100644 index 00000000000..89db6d11749 --- /dev/null +++ b/docs/audits/tui-transcript-acquisition/fix.patch @@ -0,0 +1,254 @@ +diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-forward.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-forward.ts +index a73e8b2111..63d178a67d 100644 +--- a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-forward.ts ++++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-forward.ts +@@ -12,7 +12,10 @@ import type { + StructuredAgentSessionHandoffFlowContext, + StructuredTuiOwner + } from './structured-agent-session-handoff-types' +-import { StructuredTuiLaunchCleanupError } from './structured-agent-session-handoff-types' ++import { ++ StructuredTuiCatchupStoppedError, ++ StructuredTuiLaunchCleanupError ++} from './structured-agent-session-handoff-types' + + export async function handoffStructuredSessionToTui( + context: StructuredAgentSessionHandoffFlowContext, +@@ -75,7 +78,8 @@ export async function handoffStructuredSessionToTui( + let owner: StructuredTuiOwner | null = null + let processIdentityCommitted = false + try { +- await deps.prepareTuiHistoryCatchup?.(sessionId, record.lease.runtimeFence) ++ const prepared = await deps.prepareTuiHistoryCatchup?.(sessionId, record.lease.runtimeFence) ++ prepared?.throwIfAborted() + owner = await deps.transport!.launchTui({ + record, + fence: record.lease.runtimeFence, +@@ -107,6 +111,16 @@ export async function handoffStructuredSessionToTui( + }) + } catch (error) { + deps.stopTuiHistoryCatchup?.(sessionId) ++ if (!owner && !processIdentityCommitted && error instanceof StructuredTuiCatchupStoppedError) { ++ await abandonStoredAgentSessionHandoffAttempt(deps.store, { ++ sessionId, ++ expectedFence: record.lease.runtimeFence, ++ operationId, ++ recoverableRuntimeKind: 'native', ++ now: deps.now() ++ }) ++ throw error ++ } + if (!owner && error instanceof StructuredTuiLaunchCleanupError) { + await markStructuredHandoffManualRecovery(context, sessionId, operationId) + throw error +diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-restart.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-restart.ts +index 13a26f2a7a..8947e4931a 100644 +--- a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-restart.ts ++++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-restart.ts +@@ -12,6 +12,7 @@ import { + structuredTuiRecoveryProofIsAdmissible + } from './structured-agent-session-handoff-status' + import type { StructuredTuiOwner } from './structured-agent-session-handoff-types' ++import { StructuredTuiCatchupStoppedError } from './structured-agent-session-handoff-types' + import { + persistReprovedTuiOwner, + recoverTuiOwnerOrContinue, +@@ -57,6 +58,9 @@ export async function restoreStructuredAgentSessionHandoff( + } + return + } catch (error) { ++ if (error instanceof StructuredTuiCatchupStoppedError) { ++ throw error ++ } + lastError = error + if (attempt < 2) { + await new Promise((resolve) => setTimeout(resolve, 100 * 2 ** attempt)) +@@ -282,8 +286,13 @@ async function startRecoveredTuiCatchup( + input: RestartAccess, + record: AgentSessionRecord + ): Promise { +- await input.deps.recoverTuiHistoryCatchup?.(record.sessionId, record.lease.runtimeFence) ++ const prepared = await input.deps.recoverTuiHistoryCatchup?.( ++ record.sessionId, ++ record.lease.runtimeFence ++ ) ++ prepared?.throwIfAborted() + await input.deps.activateTuiHistoryCatchup?.(record.sessionId) ++ prepared?.throwIfAborted() + } + + async function continueHandoff(input: RestartAccess, record: AgentSessionRecord): Promise { +diff --git a/src/main/native-chat/agent-session-wire/structured-tui-transcript-catchup.ts b/src/main/native-chat/agent-session-wire/structured-tui-transcript-catchup.ts +index cc343c9231..10ce2416a3 100644 +--- a/src/main/native-chat/agent-session-wire/structured-tui-transcript-catchup.ts ++++ b/src/main/native-chat/agent-session-wire/structured-tui-transcript-catchup.ts +@@ -18,12 +18,15 @@ import { + type NativeChatTranscriptSubscription + } from '../transcript-watch' + import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' ++import { StructuredTuiCatchupStoppedError } from './structured-agent-session-handoff-types' + import { + readStructuredTuiTranscriptBoundary, + writeStructuredTuiTranscriptBoundary + } from './structured-tui-transcript-boundary' + + type CatchupState = { ++ controller: AbortController ++ initialReady: (() => void) | null + active: boolean + fence: number + agent: AgentSessionHandleProvider +@@ -35,6 +38,7 @@ type CatchupState = { + + export class StructuredTuiTranscriptCatchup { + private readonly states = new Map() ++ private readonly teardown = new AbortController() + + constructor( + private readonly input: { +@@ -47,15 +51,16 @@ export class StructuredTuiTranscriptCatchup { + } + ) {} + +- async prepare(sessionId: string, fence: number): Promise { +- await this.start(sessionId, fence, false) ++ async prepare(sessionId: string, fence: number): Promise { ++ return this.start(sessionId, fence, false) + } + +- async recover(sessionId: string, fence: number): Promise { +- await this.start(sessionId, fence, true) ++ async recover(sessionId: string, fence: number): Promise { ++ return this.start(sessionId, fence, true) + } + +- private async start(sessionId: string, fence: number, recovering: boolean): Promise { ++ private async start(sessionId: string, fence: number, recovering: boolean): Promise { ++ this.teardown.signal.throwIfAborted() + this.stop(sessionId) + const record = this.input.store.getRecord(sessionId) + const head = record?.providerHandleChain.at(-1) +@@ -64,7 +69,7 @@ export class StructuredTuiTranscriptCatchup { + !head || + (head.handle.provider !== 'codex' && head.handle.provider !== 'claude') + ) { +- return ++ return this.teardown.signal + } + const agent = head.handle.provider + const providerSessionId = agent === 'claude' ? head.handle.sessionId : head.handle.threadId +@@ -73,17 +78,9 @@ export class StructuredTuiTranscriptCatchup { + agent === 'claude' + ? { claudeProjectsDir: join(record.accountHome.path, 'projects') } + : { codexSessionsDirs: [join(record.accountHome.path, 'sessions')] } +- const boundary = recovering +- ? await readStructuredTuiTranscriptBoundary(journal.directory) +- : null +- const filePath = await resolveSessionFilePath(agent, providerSessionId, { +- ...transcriptOptions, +- ...(boundary?.filePath ? { transcriptPath: boundary.filePath } : {}) +- }) +- let initialReady: (() => void) | null = null +- let baselineOffset = 0 +- const ready = filePath ? new Promise((resolve) => (initialReady = resolve)) : null + const state: CatchupState = { ++ controller: new AbortController(), ++ initialReady: null, + active: false, + fence, + agent, +@@ -95,20 +92,42 @@ export class StructuredTuiTranscriptCatchup { + const receive = (messages: NativeChatMessage[]) => this.receive(sessionId, state, messages) + this.states.set(sessionId, state) + try { +- state.subscription = await subscribeNativeChatTranscript({ ++ const signal = state.controller.signal ++ const boundary = recovering ++ ? await readStructuredTuiTranscriptBoundary(journal.directory) ++ : null ++ signal.throwIfAborted() ++ const filePath = await resolveSessionFilePath( + agent, +- sessionId: providerSessionId, +- ...transcriptOptions, +- ...(filePath ? { filePath, initialLimit: 0 } : {}), +- onInitialSnapshot: (messages, _hasMore, beforeOffset) => { +- baselineOffset = beforeOffset +- receive(messages) +- initialReady?.() +- initialReady = null ++ providerSessionId, ++ { ++ ...transcriptOptions, ++ ...(boundary?.filePath ? { transcriptPath: boundary.filePath } : {}) + }, +- onAppend: receive +- }) ++ signal ++ ) ++ signal.throwIfAborted() ++ let baselineOffset = 0 ++ const ready = filePath ? new Promise((resolve) => (state.initialReady = resolve)) : null ++ state.subscription = await subscribeNativeChatTranscript( ++ { ++ agent, ++ sessionId: providerSessionId, ++ ...transcriptOptions, ++ ...(filePath ? { filePath, initialLimit: 0 } : {}), ++ onInitialSnapshot: (messages, _hasMore, beforeOffset) => { ++ baselineOffset = beforeOffset ++ receive(messages) ++ state.initialReady?.() ++ state.initialReady = null ++ }, ++ onAppend: receive ++ }, ++ signal ++ ) ++ signal.throwIfAborted() + await ready ++ signal.throwIfAborted() + if (!recovering) { + await writeStructuredTuiTranscriptBoundary(journal.directory, { + providerSessionId, +@@ -134,13 +153,21 @@ export class StructuredTuiTranscriptCatchup { + if (!imported.ok) { + throw new Error(imported.error) + } ++ signal.throwIfAborted() + this.input.reset(sessionId, fence) + } ++ signal.throwIfAborted() ++ return signal + } catch (error) { ++ const stopped = state.controller.signal.aborted + if (this.states.get(sessionId) === state) { +- this.states.delete(sessionId) ++ this.stop(sessionId) ++ } else { ++ state.subscription?.unsubscribe() ++ } ++ if (stopped) { ++ state.controller.signal.throwIfAborted() + } +- state.subscription?.unsubscribe() + throw error + } + } +@@ -197,10 +224,16 @@ export class StructuredTuiTranscriptCatchup { + stop(sessionId: string): void { + const state = this.states.get(sessionId) + this.states.delete(sessionId) ++ state?.controller.abort(new StructuredTuiCatchupStoppedError()) ++ state?.initialReady?.() ++ if (state) { ++ state.initialReady = null ++ } + state?.subscription?.unsubscribe() + } + + stopAll(): void { ++ this.teardown.abort(new StructuredTuiCatchupStoppedError()) + for (const sessionId of this.states.keys()) { + this.stop(sessionId) + } diff --git a/docs/audits/tui-transcript-acquisition/reproduce.mjs b/docs/audits/tui-transcript-acquisition/reproduce.mjs new file mode 100644 index 00000000000..efed2e51df2 --- /dev/null +++ b/docs/audits/tui-transcript-acquisition/reproduce.mjs @@ -0,0 +1,150 @@ +import { createHash } from 'node:crypto' +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' +import { applyPatch, parsePatch, reversePatch } from 'diff' +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 patch = await readFile(new URL('./fix.patch', import.meta.url), 'utf8') +const beforeSources = {} +const sourceHashes = {} +for (const parsed of parsePatch(patch)) { + const path = parsed.newFileName.replace(/^b\//, '') + const absolute = resolve(root, path) + const current = await readFile(absolute, 'utf8') + const before = applyPatch(current, reversePatch(parsed)) + if (before === false) { + throw new Error(`Source changed; review the proof patch: ${path}`) + } + beforeSources[absolute.replaceAll('\\', '/')] = before + sourceHashes[path] = { + before: createHash('sha256').update(before).digest('hex'), + after: createHash('sha256').update(current).digest('hex') + } +} + +for (const path of [ + 'src/main/native-chat/agent-session-wire/structured-agent-session-handoff-types.ts', + 'src/main/native-chat/agent-session-wire/structured-tui-transcript-teardown.test.ts', + 'src/main/native-chat/agent-session-wire/structured-tui-transcript-teardown-test-fixture.ts' +]) { + sourceHashes[path] = { + current: createHash('sha256') + .update(await readFile(resolve(root, path))) + .digest('hex') + } +} + +const scratch = await mkdtemp(join(tmpdir(), 'orca-tui-transcript-acquisition-')) +const require = createRequire(import.meta.url) +let runnerModuleId +try { + const runnerPath = join(scratch, 'run-process.cjs') + await build({ + absWorkingDir: root, + entryPoints: [resolve(root, 'src/shared/child-process/run-process.ts')], + outfile: runnerPath, + bundle: true, + platform: 'node', + format: 'cjs', + logLevel: 'silent' + }) + runnerModuleId = require.resolve(runnerPath) + const { runProcess } = require(runnerModuleId) + const baselineConfig = join(scratch, 'before.config.mjs') + const fixedConfig = join(scratch, 'after.config.mjs') + const includes = [ + 'src/main/native-chat/agent-session-wire/structured-tui-transcript-teardown.test.ts' + ] + const configImport = JSON.stringify(pathToFileURL(resolve(root, 'config/vitest.config.ts')).href) + await writeFile( + baselineConfig, + `import base from ${configImport}; +const beforeSources = ${JSON.stringify(beforeSources)}; +export default {...base, test: {...base.test, include: ${JSON.stringify(includes)}}, plugins: [{ + name: 'tui-transcript-acquisition-before-fix', enforce: 'pre', + transform(_code, id) { + const before = beforeSources[id.replaceAll('\\\\', '/').split('?')[0]]; + return before === undefined ? null : {code: before, map: null}; + } +}]};\n` + ) + + await writeFile( + fixedConfig, + `import base from ${configImport};\nexport default {...base, test: {...base.test, include: ${JSON.stringify(includes)}}};\n` + ) + + async function run(label, config) { + const report = join(scratch, `${label}.json`) + const result = await runProcess({ + program: process.execPath, + args: [ + resolve(root, 'node_modules/vitest/vitest.mjs'), + 'run', + '--config', + config, + '--reporter=json', + `--outputFile=${report}` + ], + cwd: root, + env: { ...process.env, NODE_OPTIONS: '--max-old-space-size=512' }, + timeoutMs: 90_000, + maxOutputBytes: 4 * 1024 * 1024 + }) + let parsed + try { + parsed = JSON.parse(await readFile(report, 'utf8')) + } catch (error) { + throw new Error(`${label} runner failed: ${result.stderr || result.stdout}`, { cause: error }) + } + return { + exitCode: result.code, + passed: parsed.numPassedTests, + failed: parsed.numFailedTests, + failedCases: parsed.testResults.flatMap((suite) => + suite.assertionResults + .filter((test) => test.status === 'failed') + .map((test) => test.fullName) + ) + } + } + + const before = await run('before', baselineConfig) + const after = await run('after', fixedConfig) + const passed = + before.failed === 6 && + before.passed === 1 && + before.passed + before.failed === 7 && + after.passed === 7 && + after.failed === 0 + console.log( + JSON.stringify( + { + comparison: + 'Actual structured host teardown, record store, journal, and transcript watcher; baseline reverses catchup/forward/restart behavior through a temporary Vite transform', + sourceHashes, + before, + after, + passed + }, + null, + 2 + ) + ) + if (!passed) { + process.exitCode = 1 + } +} finally { + if (runnerModuleId) { + delete require.cache[runnerModuleId] + } + await rm(scratch, { recursive: true, force: true }) +} diff --git a/docs/audits/tui-transcript-acquisition/results.json b/docs/audits/tui-transcript-acquisition/results.json new file mode 100644 index 00000000000..c32be44c141 --- /dev/null +++ b/docs/audits/tui-transcript-acquisition/results.json @@ -0,0 +1,46 @@ +{ + "comparison": "Actual structured host teardown, record store, journal, and transcript watcher; baseline reverses catchup/forward/restart behavior through a temporary Vite transform", + "sourceHashes": { + "src/main/native-chat/agent-session-wire/structured-agent-session-handoff-forward.ts": { + "before": "c9bb6fbf8ca3fc3fad815f35a21c73e392dd6be267335984deb0b5c9319210f1", + "after": "89e1b6769dafde03be5de24f8a2e08b48a490527cab7568b9a5ec4a97a0593ea" + }, + "src/main/native-chat/agent-session-wire/structured-agent-session-handoff-restart.ts": { + "before": "8f2dc4f31fd2f96f3e9393afcc0826b712591c5d3bfa80965113e63be65f69ab", + "after": "03b76c1e3ba2389154f3fba4971fc17fbf3ba4ccde471f309e80f67134091d90" + }, + "src/main/native-chat/agent-session-wire/structured-tui-transcript-catchup.ts": { + "before": "36085d52e44152c7d8906ac2691242e8e31e54511fc908510c0d3aee10615973", + "after": "2d0dc6dcfbfba666a0bdebb229b78706c8a137b8427aa2a8b8bc679f0d43749b" + }, + "src/main/native-chat/agent-session-wire/structured-agent-session-handoff-types.ts": { + "current": "225283eaf80f976fcad35554b330ad24e81cd4c1dd275996dc471c53de46ba67" + }, + "src/main/native-chat/agent-session-wire/structured-tui-transcript-teardown.test.ts": { + "current": "cc8be5277db229dcf52d1c72bfddfc86b2f07fd2f77eba3f11af01d1877f2604" + }, + "src/main/native-chat/agent-session-wire/structured-tui-transcript-teardown-test-fixture.ts": { + "current": "d186aeab78e31f0aa493f92d5f472708e81791670e82637e37481ebca9918756" + } + }, + "before": { + "exitCode": 1, + "passed": 1, + "failed": 6, + "failedCases": [ + "cancels transcript acquisition during host teardown at resolve", + "cancels transcript acquisition during host teardown at subscribe", + "cancels transcript acquisition during host teardown at initial-ready", + "cancels transcript acquisition during host teardown at before-prepare", + "cancels transcript acquisition during host teardown at after-prepare", + "cancels recovered TUI catchup without relabeling the live owner or retrying" + ] + }, + "after": { + "exitCode": 0, + "passed": 7, + "failed": 0, + "failedCases": [] + }, + "passed": true +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-forward.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-forward.ts index a73e8b21116..63d178a67d2 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-forward.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-forward.ts @@ -12,7 +12,10 @@ import type { StructuredAgentSessionHandoffFlowContext, StructuredTuiOwner } from './structured-agent-session-handoff-types' -import { StructuredTuiLaunchCleanupError } from './structured-agent-session-handoff-types' +import { + StructuredTuiCatchupStoppedError, + StructuredTuiLaunchCleanupError +} from './structured-agent-session-handoff-types' export async function handoffStructuredSessionToTui( context: StructuredAgentSessionHandoffFlowContext, @@ -75,7 +78,8 @@ export async function handoffStructuredSessionToTui( let owner: StructuredTuiOwner | null = null let processIdentityCommitted = false try { - await deps.prepareTuiHistoryCatchup?.(sessionId, record.lease.runtimeFence) + const prepared = await deps.prepareTuiHistoryCatchup?.(sessionId, record.lease.runtimeFence) + prepared?.throwIfAborted() owner = await deps.transport!.launchTui({ record, fence: record.lease.runtimeFence, @@ -107,6 +111,16 @@ export async function handoffStructuredSessionToTui( }) } catch (error) { deps.stopTuiHistoryCatchup?.(sessionId) + if (!owner && !processIdentityCommitted && error instanceof StructuredTuiCatchupStoppedError) { + await abandonStoredAgentSessionHandoffAttempt(deps.store, { + sessionId, + expectedFence: record.lease.runtimeFence, + operationId, + recoverableRuntimeKind: 'native', + now: deps.now() + }) + throw error + } if (!owner && error instanceof StructuredTuiLaunchCleanupError) { await markStructuredHandoffManualRecovery(context, sessionId, operationId) throw error diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-restart.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-restart.ts index 13a26f2a7a9..8947e4931ae 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-restart.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-restart.ts @@ -12,6 +12,7 @@ import { structuredTuiRecoveryProofIsAdmissible } from './structured-agent-session-handoff-status' import type { StructuredTuiOwner } from './structured-agent-session-handoff-types' +import { StructuredTuiCatchupStoppedError } from './structured-agent-session-handoff-types' import { persistReprovedTuiOwner, recoverTuiOwnerOrContinue, @@ -57,6 +58,9 @@ export async function restoreStructuredAgentSessionHandoff( } return } catch (error) { + if (error instanceof StructuredTuiCatchupStoppedError) { + throw error + } lastError = error if (attempt < 2) { await new Promise((resolve) => setTimeout(resolve, 100 * 2 ** attempt)) @@ -282,8 +286,13 @@ async function startRecoveredTuiCatchup( input: RestartAccess, record: AgentSessionRecord ): Promise { - await input.deps.recoverTuiHistoryCatchup?.(record.sessionId, record.lease.runtimeFence) + const prepared = await input.deps.recoverTuiHistoryCatchup?.( + record.sessionId, + record.lease.runtimeFence + ) + prepared?.throwIfAborted() await input.deps.activateTuiHistoryCatchup?.(record.sessionId) + prepared?.throwIfAborted() } async function continueHandoff(input: RestartAccess, record: AgentSessionRecord): Promise { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-types.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-types.ts index 5a36c691098..c1115f33a2b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-types.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-types.ts @@ -28,6 +28,13 @@ export class StructuredTuiLaunchCleanupError extends Error { } } +export class StructuredTuiCatchupStoppedError extends Error { + constructor() { + super('TUI transcript catchup was stopped.') + this.name = 'StructuredTuiCatchupStoppedError' + } +} + export type StructuredAgentSessionHandoffTransport = { hostLabel: string launchTui(input: { @@ -84,8 +91,8 @@ export type StructuredAgentSessionHandoffDeps = { transcriptPath?: string }) => Promise retryPendingSettlement: (sessionId: string) => Promise - prepareTuiHistoryCatchup?: (sessionId: string, fence: number) => Promise - recoverTuiHistoryCatchup?: (sessionId: string, fence: number) => Promise + prepareTuiHistoryCatchup?: (sessionId: string, fence: number) => Promise + recoverTuiHistoryCatchup?: (sessionId: string, fence: number) => Promise activateTuiHistoryCatchup?: (sessionId: string) => Promise stopTuiHistoryCatchup?: (sessionId: string) => void publish: (sessionId: string, status: AgentSessionHandoffStatus) => void diff --git a/src/main/native-chat/agent-session-wire/structured-tui-transcript-catchup.ts b/src/main/native-chat/agent-session-wire/structured-tui-transcript-catchup.ts index cc343c9231c..10ce2416a32 100644 --- a/src/main/native-chat/agent-session-wire/structured-tui-transcript-catchup.ts +++ b/src/main/native-chat/agent-session-wire/structured-tui-transcript-catchup.ts @@ -18,12 +18,15 @@ import { type NativeChatTranscriptSubscription } from '../transcript-watch' import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' +import { StructuredTuiCatchupStoppedError } from './structured-agent-session-handoff-types' import { readStructuredTuiTranscriptBoundary, writeStructuredTuiTranscriptBoundary } from './structured-tui-transcript-boundary' type CatchupState = { + controller: AbortController + initialReady: (() => void) | null active: boolean fence: number agent: AgentSessionHandleProvider @@ -35,6 +38,7 @@ type CatchupState = { export class StructuredTuiTranscriptCatchup { private readonly states = new Map() + private readonly teardown = new AbortController() constructor( private readonly input: { @@ -47,15 +51,16 @@ export class StructuredTuiTranscriptCatchup { } ) {} - async prepare(sessionId: string, fence: number): Promise { - await this.start(sessionId, fence, false) + async prepare(sessionId: string, fence: number): Promise { + return this.start(sessionId, fence, false) } - async recover(sessionId: string, fence: number): Promise { - await this.start(sessionId, fence, true) + async recover(sessionId: string, fence: number): Promise { + return this.start(sessionId, fence, true) } - private async start(sessionId: string, fence: number, recovering: boolean): Promise { + private async start(sessionId: string, fence: number, recovering: boolean): Promise { + this.teardown.signal.throwIfAborted() this.stop(sessionId) const record = this.input.store.getRecord(sessionId) const head = record?.providerHandleChain.at(-1) @@ -64,7 +69,7 @@ export class StructuredTuiTranscriptCatchup { !head || (head.handle.provider !== 'codex' && head.handle.provider !== 'claude') ) { - return + return this.teardown.signal } const agent = head.handle.provider const providerSessionId = agent === 'claude' ? head.handle.sessionId : head.handle.threadId @@ -73,17 +78,9 @@ export class StructuredTuiTranscriptCatchup { agent === 'claude' ? { claudeProjectsDir: join(record.accountHome.path, 'projects') } : { codexSessionsDirs: [join(record.accountHome.path, 'sessions')] } - const boundary = recovering - ? await readStructuredTuiTranscriptBoundary(journal.directory) - : null - const filePath = await resolveSessionFilePath(agent, providerSessionId, { - ...transcriptOptions, - ...(boundary?.filePath ? { transcriptPath: boundary.filePath } : {}) - }) - let initialReady: (() => void) | null = null - let baselineOffset = 0 - const ready = filePath ? new Promise((resolve) => (initialReady = resolve)) : null const state: CatchupState = { + controller: new AbortController(), + initialReady: null, active: false, fence, agent, @@ -95,20 +92,42 @@ export class StructuredTuiTranscriptCatchup { const receive = (messages: NativeChatMessage[]) => this.receive(sessionId, state, messages) this.states.set(sessionId, state) try { - state.subscription = await subscribeNativeChatTranscript({ + const signal = state.controller.signal + const boundary = recovering + ? await readStructuredTuiTranscriptBoundary(journal.directory) + : null + signal.throwIfAborted() + const filePath = await resolveSessionFilePath( agent, - sessionId: providerSessionId, - ...transcriptOptions, - ...(filePath ? { filePath, initialLimit: 0 } : {}), - onInitialSnapshot: (messages, _hasMore, beforeOffset) => { - baselineOffset = beforeOffset - receive(messages) - initialReady?.() - initialReady = null + providerSessionId, + { + ...transcriptOptions, + ...(boundary?.filePath ? { transcriptPath: boundary.filePath } : {}) }, - onAppend: receive - }) + signal + ) + signal.throwIfAborted() + let baselineOffset = 0 + const ready = filePath ? new Promise((resolve) => (state.initialReady = resolve)) : null + state.subscription = await subscribeNativeChatTranscript( + { + agent, + sessionId: providerSessionId, + ...transcriptOptions, + ...(filePath ? { filePath, initialLimit: 0 } : {}), + onInitialSnapshot: (messages, _hasMore, beforeOffset) => { + baselineOffset = beforeOffset + receive(messages) + state.initialReady?.() + state.initialReady = null + }, + onAppend: receive + }, + signal + ) + signal.throwIfAborted() await ready + signal.throwIfAborted() if (!recovering) { await writeStructuredTuiTranscriptBoundary(journal.directory, { providerSessionId, @@ -134,13 +153,21 @@ export class StructuredTuiTranscriptCatchup { if (!imported.ok) { throw new Error(imported.error) } + signal.throwIfAborted() this.input.reset(sessionId, fence) } + signal.throwIfAborted() + return signal } catch (error) { + const stopped = state.controller.signal.aborted if (this.states.get(sessionId) === state) { - this.states.delete(sessionId) + this.stop(sessionId) + } else { + state.subscription?.unsubscribe() + } + if (stopped) { + state.controller.signal.throwIfAborted() } - state.subscription?.unsubscribe() throw error } } @@ -197,10 +224,16 @@ export class StructuredTuiTranscriptCatchup { stop(sessionId: string): void { const state = this.states.get(sessionId) this.states.delete(sessionId) + state?.controller.abort(new StructuredTuiCatchupStoppedError()) + state?.initialReady?.() + if (state) { + state.initialReady = null + } state?.subscription?.unsubscribe() } stopAll(): void { + this.teardown.abort(new StructuredTuiCatchupStoppedError()) for (const sessionId of this.states.keys()) { this.stop(sessionId) } diff --git a/src/main/native-chat/agent-session-wire/structured-tui-transcript-ownership.test.ts b/src/main/native-chat/agent-session-wire/structured-tui-transcript-ownership.test.ts new file mode 100644 index 00000000000..6a9080d4a9e --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-tui-transcript-ownership.test.ts @@ -0,0 +1,130 @@ +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import type * as Resolver from '../session-file-resolver' +import type * as TranscriptWatch from '../transcript-watch' +import { getActiveNativeChatWatcherCount } from '../transcript-watcher-count' +import { HOST_TEST_SESSION as SESSION } from './structured-agent-session-host-test-data' +import { StructuredTuiTranscriptCatchup } from './structured-tui-transcript-catchup' +import { StructuredTuiCatchupStoppedError } from './structured-agent-session-handoff-types' +import { createTuiTranscriptTeardownFixture } from './structured-tui-transcript-teardown-test-fixture' + +const gate = vi.hoisted(() => ({ + mode: '', + entered: Promise.withResolvers(), + release: Promise.withResolvers(), + cleanups: new Set<() => void>() +})) + +vi.mock('../session-file-resolver', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + resolveSessionFilePath: async (...args: Parameters) => { + if (gate.mode === 'resolve') { + gate.mode = '' + gate.entered.resolve() + await gate.release.promise + } + return actual.resolveSessionFilePath(...args) + } + } +}) + +vi.mock('../transcript-watch', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + subscribeNativeChatTranscript: async ( + ...args: Parameters + ) => { + const subscription = await actual.subscribeNativeChatTranscript(...args) + gate.cleanups.add(subscription.unsubscribe) + if (gate.mode === 'subscribe') { + gate.mode = '' + gate.entered.resolve() + await gate.release.promise + } + return subscription + } + } +}) + +let fixture: Awaited> +let catchup: StructuredTuiTranscriptCatchup + +beforeEach(async () => { + gate.mode = '' + gate.entered = Promise.withResolvers() + gate.release = Promise.withResolvers() + fixture = await createTuiTranscriptTeardownFixture() + catchup = new StructuredTuiTranscriptCatchup({ + store: fixture.store, + session: (sessionId) => { + const session = fixture.host['sessions'].get(sessionId) + if (!session) { + throw new Error('Session fixture missing') + } + return session + }, + schedule: (_sessionId, task) => task(), + publish: vi.fn(), + reset: vi.fn() + }) +}) + +afterEach(() => { + gate.release.resolve() + catchup.stopAll() + for (const cleanup of gate.cleanups) { + cleanup() + } + gate.cleanups.clear() + vi.restoreAllMocks() +}) + +it.each([ + { method: 'prepare', mode: 'resolve' }, + { method: 'prepare', mode: 'subscribe' }, + { method: 'recover', mode: 'resolve' }, + { method: 'recover', mode: 'subscribe' } +] as const)( + 'preserves replacement ownership after canceled $method at $mode completes', + async ({ method, mode }) => { + gate.mode = mode + const old = catchup[method](SESSION, 1) + const rejected = expect(old).rejects.toBeInstanceOf(StructuredTuiCatchupStoppedError) + await gate.entered.promise + const replacement = await catchup[method === 'prepare' ? 'recover' : 'prepare'](SESSION, 2) + gate.release.resolve() + await rejected + expect(replacement.aborted).toBe(false) + expect(catchup['states'].get(SESSION)?.fence).toBe(2) + expect(getActiveNativeChatWatcherCount()).toBe(fixture.watcherBaseline + 1) + catchup.stop(SESSION) + expect(replacement.aborted).toBe(true) + expect(getActiveNativeChatWatcherCount()).toBe(fixture.watcherBaseline) + } +) + +it('allows a new per-session catchup after stop but rejects every start after stopAll', async () => { + const first = await catchup.prepare(SESSION, 1) + catchup.stop(SESSION) + expect(first.aborted).toBe(true) + const replacement = await catchup.prepare(SESSION, 2) + expect(replacement.aborted).toBe(false) + catchup.stopAll() + catchup.stopAll() + await expect(catchup.prepare(SESSION, 3)).rejects.toBeInstanceOf(StructuredTuiCatchupStoppedError) + await expect(catchup.recover(SESSION, 3)).rejects.toBeInstanceOf(StructuredTuiCatchupStoppedError) + expect(catchup['states'].size).toBe(0) + expect(getActiveNativeChatWatcherCount()).toBe(fixture.watcherBaseline) +}) + +it('fences an unsupported preparation result when teardown runs before its consumer', async () => { + vi.spyOn(fixture.store, 'getRecord').mockReturnValueOnce(null) + const prepared = await catchup.prepare(SESSION, 1) + expect(prepared.aborted).toBe(false) + expect(catchup['states'].size).toBe(0) + catchup.stopAll() + expect(() => prepared.throwIfAborted()).toThrow(StructuredTuiCatchupStoppedError) + expect(getActiveNativeChatWatcherCount()).toBe(fixture.watcherBaseline) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-tui-transcript-teardown-test-fixture.ts b/src/main/native-chat/agent-session-wire/structured-tui-transcript-teardown-test-fixture.ts new file mode 100644 index 00000000000..71f9576f368 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-tui-transcript-teardown-test-fixture.ts @@ -0,0 +1,104 @@ +import { mkdir, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { expect, vi } from 'vitest' +import { getActiveNativeChatWatcherCount } from '../transcript-watcher-count' +import { + CALLER, + adapter, + hostTestState, + replaceHostTestState +} from './structured-agent-session-host-test-harness' +import { + HOST_TEST_NOW as NOW, + HOST_TEST_SESSION as SESSION, + HOST_TEST_THREAD as THREAD, + hostTestAttachParams, + hostTestOperationId +} from './structured-agent-session-host-test-data' +import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { StructuredHandoffTestRequests } from './structured-agent-session-handoff-test-requests' +import type { + StructuredAgentSessionHandoffTransport, + StructuredTuiOwner +} from './structured-agent-session-handoff-types' + +function rolloutLine(message: string): string { + return `${JSON.stringify({ + type: 'event_msg', + timestamp: '2026-08-11T10:00:00.000Z', + payload: { type: 'agent_message', message } + })}\n` +} + +function tuiOwner(fence: number, spawnToken: string): StructuredTuiOwner { + return { + terminal: { handle: 'term-tui', tabId: 'tab-tui', paneKey: 'pane-tui', ptyId: 'pty-tui' }, + process: { hostId: 'local', pid: 5200, processStartTimeMs: NOW, spawnToken }, + link: { + linkId: `tui-link-${fence}`, + handle: { provider: 'codex', threadId: THREAD }, + origin: 'resumed', + mintedAtFence: fence, + observedAt: NOW + } + } +} + +export async function createTuiTranscriptTeardownFixture() { + const initial = hostTestState() + await initial.host.flushAllStreamedEvents() + const watcherBaseline = getActiveNativeChatWatcherCount() + const closeTuiOwner = vi.fn(async () => ({})) + const launchTui = vi.fn( + async ({ fence, spawnToken }) => tuiOwner(fence, spawnToken) + ) + const host = new StructuredAgentSessionHost({ + ...initial.host.deps, + adapter: { ...adapter(), closeSession: vi.fn(async () => true) }, + handoffTransport: { + hostLabel: 'Test host', + launchTui, + reproveTuiOwner: async ({ owner }) => owner, + recoverTuiOwner: async (record) => + tuiOwner(record.lease.runtimeFence, record.lease.reservedSpawnToken ?? 'recovered'), + stopRecoveredOwner: async () => undefined, + closeTuiOwner, + waitForTuiExit: async () => ({}), + waitForTuiIdleOrExit: async () => 'idle', + tuiStatus: () => 'idle' + } + }) + replaceHostTestState({ host, store: initial.store }) + const accountHome = join(initial.root, 'codex-home') + const sessionsDir = join(accountHome, 'sessions', '2026', '08', '11') + await mkdir(sessionsDir, { recursive: true }) + const rollout = join(sessionsDir, `rollout-2026-08-11T10-00-00-${THREAD}.jsonl`) + await writeFile(rollout, rolloutLine('before handoff')) + expect( + await host.attach( + CALLER, + hostTestAttachParams(null, { accountHome: { variable: 'CODEX_HOME', path: accountHome } }) + ) + ).toMatchObject({ ok: true }) + const requests = new StructuredHandoffTestRequests( + NOW, + SESSION, + () => initial.store.getRecord(SESSION)?.lease.runtimeFence ?? 0 + ) + return { + host, + store: initial.store, + acquire: initial.acquire, + launchTui, + rollout, + watcherBaseline, + async requestHandoff() { + expect( + await host.requestHandoff( + CALLER, + requests.request('to-tui', 'now', { operationId: hostTestOperationId() }) + ) + ).toMatchObject({ ok: true }) + } + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-tui-transcript-teardown.test.ts b/src/main/native-chat/agent-session-wire/structured-tui-transcript-teardown.test.ts new file mode 100644 index 00000000000..fdf18e4f9df --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-tui-transcript-teardown.test.ts @@ -0,0 +1,190 @@ +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import type * as Resolver from '../session-file-resolver' +import type * as TranscriptWatch from '../transcript-watch' +import type * as TranscriptTail from '../transcript-tail-reader' +import { getActiveNativeChatWatcherCount } from '../transcript-watcher-count' +import { HOST_TEST_SESSION as SESSION } from './structured-agent-session-host-test-data' +import { StructuredTuiTranscriptCatchup } from './structured-tui-transcript-catchup' +import { StructuredTuiCatchupStoppedError } from './structured-agent-session-handoff-types' +import { createTuiTranscriptTeardownFixture } from './structured-tui-transcript-teardown-test-fixture' + +const gate = vi.hoisted(() => ({ + mode: '', + entered: Promise.withResolvers(), + release: Promise.withResolvers(), + cleanups: new Set<() => void>() +})) + +vi.mock('../session-file-resolver', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + resolveSessionFilePath: async (...args: Parameters) => { + if (gate.mode === 'resolve-error') { + gate.mode = '' + throw new Error('transcript read failed') + } + if (gate.mode === 'resolve') { + gate.mode = '' + gate.entered.resolve() + await gate.release.promise + } + return actual.resolveSessionFilePath(...args) + } + } +}) + +vi.mock('../transcript-watch', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + subscribeNativeChatTranscript: async ( + ...args: Parameters + ) => { + const subscription = await actual.subscribeNativeChatTranscript(...args) + gate.cleanups.add(subscription.unsubscribe) + if (gate.mode === 'subscribe') { + gate.mode = '' + gate.entered.resolve() + await gate.release.promise + } + return subscription + } + } +}) + +vi.mock('../transcript-tail-reader', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + readNativeChatTranscriptTailFile: async ( + ...args: Parameters + ) => { + if (gate.mode === 'initial-ready') { + gate.mode = '' + gate.entered.resolve() + await gate.release.promise + } + return actual.readNativeChatTranscriptTailFile(...args) + } + } +}) + +let fixture: Awaited> + +beforeEach(async () => { + gate.mode = '' + gate.entered = Promise.withResolvers() + gate.release = Promise.withResolvers() + fixture = await createTuiTranscriptTeardownFixture() +}) + +afterEach(async () => { + gate.release.resolve() + for (const cleanup of gate.cleanups) { + cleanup() + } + gate.cleanups.clear() + vi.restoreAllMocks() +}) + +async function beginTeardown() { + const stopped = Promise.withResolvers() + const handoffs = fixture.host['handoffs'] + const stop = handoffs.stopTuiHistoryCatchup.bind(handoffs) + vi.spyOn(handoffs, 'stopTuiHistoryCatchup').mockImplementation(() => { + stop() + stopped.resolve() + }) + const completed = fixture.host.flushAllStreamedEvents() + await stopped.promise + return { completed } +} + +it.each(['resolve', 'subscribe', 'initial-ready', 'before-prepare', 'after-prepare'])( + 'cancels transcript acquisition during host teardown at %s', + async (mode) => { + gate.mode = mode + if (mode === 'before-prepare') { + vi.spyOn(fixture.host.deps.adapter, 'closeSession').mockImplementationOnce(async () => { + gate.entered.resolve() + await gate.release.promise + return true + }) + } else if (mode === 'after-prepare') { + const prepare = StructuredTuiTranscriptCatchup.prototype.prepare + vi.spyOn(StructuredTuiTranscriptCatchup.prototype, 'prepare').mockImplementation( + async function (this: StructuredTuiTranscriptCatchup, sessionId, fence) { + const signal = await prepare.call(this, sessionId, fence) + gate.entered.resolve() + await gate.release.promise + return signal + } + ) + } + await fixture.requestHandoff() + await gate.entered.promise + const teardown = await beginTeardown() + gate.release.resolve() + await teardown.completed + expect(fixture.host.hasSession(SESSION)).toBe(false) + expect(getActiveNativeChatWatcherCount()).toBe(fixture.watcherBaseline) + expect(fixture.launchTui).not.toHaveBeenCalled() + expect(fixture.acquire).toHaveBeenCalledOnce() + expect(fixture.host['handoffs']['flowRunner']['active'].size).toBe(0) + expect(fixture.store.getRecord(SESSION)?.lease).toMatchObject({ + runtimeKind: 'native', + claimStatus: 'released', + handoffStage: 'old-owner-stopped', + ownerProcess: null, + reservedSpawnToken: null + }) + } +) + +it('keeps native recovery for an ordinary preparation failure', async () => { + gate.mode = 'resolve-error' + const acquire = fixture.acquire.getMockImplementation() + if (!acquire) { + throw new Error('Native acquisition fixture missing') + } + fixture.acquire.mockImplementationOnce(async (...args) => { + gate.entered.resolve() + await gate.release.promise + return acquire(...args) + }) + await fixture.requestHandoff() + await gate.entered.promise + expect(fixture.acquire).toHaveBeenCalledTimes(2) + gate.release.resolve() + await fixture.host['handoffs'].drain() + expect(fixture.launchTui).not.toHaveBeenCalled() + expect(fixture.store.getRecord(SESSION)?.lease).toMatchObject({ + runtimeKind: 'native', + claimStatus: 'live', + handoffStage: null + }) + expect((await fixture.host.handoffStatus(SESSION)).error?.details).toBe('transcript read failed') +}) + +it('cancels recovered TUI catchup without relabeling the live owner or retrying', async () => { + await fixture.requestHandoff() + await fixture.host['handoffs'].drain() + const recover = vi.spyOn(StructuredTuiTranscriptCatchup.prototype, 'recover') + gate.mode = 'resolve' + const restoring = fixture.host['handoffs'].restore(SESSION) + const rejected = expect(restoring).rejects.toBeInstanceOf(StructuredTuiCatchupStoppedError) + await gate.entered.promise + const teardown = await beginTeardown() + gate.release.resolve() + await rejected + await teardown.completed + expect(recover).toHaveBeenCalledOnce() + expect(getActiveNativeChatWatcherCount()).toBe(fixture.watcherBaseline) + expect(fixture.acquire).toHaveBeenCalledOnce() + expect(fixture.store.getRecord(SESSION)?.lease).toMatchObject({ + runtimeKind: 'tui', + claimStatus: 'live', + handoffStage: null + }) +})