From 6781eb62710bf2d8cf2dadd4e83ae3b71ee5a34f Mon Sep 17 00:00:00 2001 From: Merge Sim Date: Sun, 6 Sep 2026 18:27:00 -0700 Subject: [PATCH] fix: settle dead TUI handoffs before reacquire --- ...-agent-session-handoff-flow-runner.test.ts | 1 + ...tructured-agent-session-handoff-reverse.ts | 7 +++++ ...-agent-session-handoff-test-coordinator.ts | 1 + .../structured-agent-session-handoff-types.ts | 1 + .../structured-agent-session-handoff.test.ts | 2 ++ .../structured-agent-session-host-handoff.ts | 8 +++++ ...-session-live-tui-restart-survival.test.ts | 2 ++ ...ed-agent-session-proven-dead-retry.test.ts | 24 ++++++++++++++ ...ructured-agent-session-settlement-retry.ts | 31 +++++++++++++------ ...tructured-agent-session-unexpected-exit.ts | 6 ++-- 10 files changed, 70 insertions(+), 13 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-flow-runner.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-flow-runner.test.ts index 20402c8c05e..286a0dca059 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-flow-runner.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-flow-runner.test.ts @@ -70,6 +70,7 @@ async function failingFlowRunner( throw new Error('unused') }, importTuiHistory: async () => {}, + retryPendingSettlement: async () => true, publish: () => {}, schedule: async () => { throw new Error('scheduling failed') diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-reverse.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-reverse.ts index ebfca81525c..1dcd4904895 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-reverse.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-reverse.ts @@ -95,6 +95,13 @@ export async function handoffStructuredSessionToNative( ...(transcriptPath ? { transcriptPath } : {}) }) } + if (record.lease.settlementRetryRequired) { + const settled = await deps.retryPendingSettlement(sessionId) + if (!settled) { + throw new Error('The provider-exit terminal journal settlement is still pending.') + } + record = context.requireRecord(sessionId) + } const spawnToken = randomUUID() record = await reserveStoredAgentSessionHandoffOwner(deps.store, { sessionId, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-test-coordinator.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-test-coordinator.ts index e5dd478f719..81ef23aeb3b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-test-coordinator.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-test-coordinator.ts @@ -73,6 +73,7 @@ export function createStructuredAgentSessionHandoffTestCoordinator( { fence, recovered: true } ) }, + retryPendingSettlement: async () => true, publish: (_sessionId, status) => input.statuses.push(status), schedule: async (_sessionId, task) => task(), now: () => input.now 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 7155826702d..218db8c539c 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 @@ -81,6 +81,7 @@ export type StructuredAgentSessionHandoffDeps = { fence: number transcriptPath?: string }) => Promise + retryPendingSettlement: (sessionId: string) => Promise prepareTuiHistoryCatchup?: (sessionId: string, fence: number) => Promise recoverTuiHistoryCatchup?: (sessionId: string, fence: number) => Promise activateTuiHistoryCatchup?: (sessionId: string) => Promise diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff.test.ts index f0f410b66aa..beca21cb63a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff.test.ts @@ -179,6 +179,7 @@ function createCoordinator(): StructuredAgentSessionHandoffCoordinator { { fence, recovered: true } ) }, + retryPendingSettlement: async () => true, prepareTuiHistoryCatchup, recoverTuiHistoryCatchup, activateTuiHistoryCatchup, @@ -274,6 +275,7 @@ describe('structured session handoff failure handling', () => { }), acquireNativeStop: (_sessionId, turnId) => acquireNativeStop(turnId), importTuiHistory: vi.fn(async () => undefined), + retryPendingSettlement: vi.fn(async () => true), prepareTuiHistoryCatchup, recoverTuiHistoryCatchup, activateTuiHistoryCatchup, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-handoff.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-handoff.ts index abd2268c809..cb316850e5b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-handoff.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-handoff.ts @@ -14,6 +14,7 @@ import { recoverDeadTuiHandoffStatus } from './structured-agent-session-dead-tui import { readNativeSessionOptions } from './structured-agent-session-option-restoration' import type { AgentSessionSubscribers } from './structured-agent-session-subscribers' import { StructuredTuiTranscriptCatchup } from './structured-tui-transcript-catchup' +import { retryLoadedStructuredAgentSessionSettlement } from './structured-agent-session-settlement-retry' type HostHandoffAccess = { session: (sessionId: string) => StructuredAgentSessionHostSession @@ -92,6 +93,13 @@ export function createStructuredAgentSessionHostHandoff( acquireNativeStop: async (sessionId, turnId, fence) => (await deps.adapter.cancelTurn({ sessionId, turnId, fence })).cancelled, importTuiHistory: (input) => importTuiHistory(deps, host, input), + retryPendingSettlement: (sessionId) => + retryLoadedStructuredAgentSessionSettlement({ + deps, + sessionId, + session: host.session(sessionId), + now: host.now + }), prepareTuiHistoryCatchup: (sessionId, fence) => tuiHistoryCatchup.prepare(sessionId, fence), recoverTuiHistoryCatchup: (sessionId, fence) => tuiHistoryCatchup.recover(sessionId, fence), activateTuiHistoryCatchup: (sessionId) => tuiHistoryCatchup.activate(sessionId), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-live-tui-restart-survival.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-live-tui-restart-survival.test.ts index 90b68194c4f..3a8de86c3de 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-live-tui-restart-survival.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-live-tui-restart-survival.test.ts @@ -104,6 +104,7 @@ describe('structured session live TUI restart survival', () => { suspendNative: vi.fn(), acquireNative: vi.fn(), importTuiHistory: vi.fn(), + retryPendingSettlement: vi.fn(async () => true), publish: vi.fn(), schedule: async (_sessionId, task) => task(), now: () => NOW @@ -224,6 +225,7 @@ describe('structured session live TUI restart survival', () => { suspendNative: vi.fn(), acquireNative: vi.fn(), importTuiHistory: vi.fn(), + retryPendingSettlement: vi.fn(async () => true), publish: vi.fn(), schedule: async (_sessionId, task) => task(), now: () => NOW diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-proven-dead-retry.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-proven-dead-retry.test.ts index 3caba894cb9..aed78f3e5b9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-proven-dead-retry.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-proven-dead-retry.test.ts @@ -3,12 +3,14 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' +import { activeStructuredAgentSessionTurnId } from '../../../shared/structured-agent-session-projection' import type { AgentSessionHandoffRequest } from '../../../shared/agent-session-wire' import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import { recoverStoredDeadTuiOwnerForHandoff } from '../../runtime/agent-session-handoff-record-transitions' import { openAgentSessionJournal } from '../agent-session-journal/journal-store-factory' import { StructuredAgentSessionHandoffCoordinator } from './structured-agent-session-handoff' import type { StructuredAgentSessionHandoffTransport } from './structured-agent-session-handoff-types' +import { retryLoadedStructuredAgentSessionSettlement } from './structured-agent-session-settlement-retry' const NOW = 1_800_000_000_000 const SESSION = 'session-proven-dead-retry' @@ -89,6 +91,15 @@ describe('structured session proven-dead TUI retry', () => { }, journalDir: join(root, 'journal') }) + await journal.appendItem( + { provider: 'orca', clientMessageId: 'running-turn' }, + { + kind: 'status', + text: 'Working', + turnLifecycle: { turnId: 'turn-1', state: 'running' } + }, + { fence: store.getRecord(SESSION)?.lease.runtimeFence ?? tuiFence } + ) const closeTuiOwner = vi.fn>() const coordinator = new StructuredAgentSessionHandoffCoordinator({ @@ -134,6 +145,17 @@ describe('structured session proven-dead TUI retry', () => { }, acquireNativeStop: vi.fn(async () => true), importTuiHistory: vi.fn(), + retryPendingSettlement: (sessionId) => + retryLoadedStructuredAgentSessionSettlement({ + deps: { store }, + sessionId, + session: { + journal, + fence: store.getRecord(sessionId)?.lease.runtimeFence ?? 1, + acquisitionGeneration: null + }, + now: () => NOW + }), publish: vi.fn(), schedule: async (_sessionId, task) => task(), now: () => NOW @@ -174,5 +196,7 @@ describe('structured session proven-dead TUI retry', () => { claimStatus: 'live', handoffStage: null }) + expect(store.getRecord(SESSION)?.lease.settlementRetryRequired).toBeUndefined() + expect(activeStructuredAgentSessionTurnId(journal.snapshot().items)).toBe(null) }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-settlement-retry.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-settlement-retry.ts index fc60d6c4696..301a712e182 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-settlement-retry.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-settlement-retry.ts @@ -46,15 +46,27 @@ export async function retryPendingStructuredAgentSessionSettlement(input: { hasProviderChild: false, acquisitionGeneration: null } as StructuredAgentSessionHostSession) + return retryLoadedStructuredAgentSessionSettlement({ + deps: input.deps, + sessionId: input.sessionId, + session: retrySession, + now: input.now + }) +} + +export async function retryLoadedStructuredAgentSessionSettlement(input: { + deps: Pick + sessionId: string + session: Pick + now: () => number +}): Promise { + const record = input.deps.store.getRecord(input.sessionId) + if (!record?.lease.settlementRetryRequired || !record.lease.settlementRetryId) { + return true + } + const retrySession = input.session retrySession.fence = record.lease.runtimeFence - const context: StructuredAgentSessionUnexpectedExitContext = { - store: input.deps.store, - sessions: input.sessions, - flushLifecycle: async () => ({ ok: true as const }), - publishFence: () => undefined, - hasResumeCapableHolder: () => false, - serialize: async (_id: string, task: () => Promise) => task(), - now: input.now, + const context: Pick = { onBarrierError: (id, error) => input.deps.onEventSinkError?.({ sessionId: id, error }) } const ok = await retryUnexpectedExitSettlement({ @@ -65,7 +77,7 @@ export async function retryPendingStructuredAgentSessionSettlement(input: { reason: record.lease.deathEvidence?.detail ?? 'provider exited', cause: 'unexpected-exit', fence: record.lease.runtimeFence, - acquisitionGeneration: current?.acquisitionGeneration ?? 'recovery' + acquisitionGeneration: retrySession.acquisitionGeneration ?? 'recovery' }, session: retrySession, stableSettlementId: record.lease.settlementRetryId @@ -85,7 +97,6 @@ export async function retryPendingStructuredAgentSessionSettlement(input: { ...latest, lease: { ...latest.lease, - handoffStage: null, settlementRetryRequired: undefined, settlementRetryId: undefined, lastRenewedAt: input.now() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.ts index af7f2ccfde3..87fe0cbbeec 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.ts @@ -161,9 +161,9 @@ export function isStructuredAgentSessionRecoveryTicketCurrent( } export async function retryUnexpectedExitSettlement(input: { - context: StructuredAgentSessionUnexpectedExitContext + context: Pick event: UnexpectedExitLifecycleEvent - session: StructuredAgentSessionHostSession + session: Pick stableSettlementId: string }): Promise { try { @@ -189,7 +189,7 @@ export async function retryUnexpectedExitSettlement(input: { function unexpectedExitFallbackMutations( event: UnexpectedExitLifecycleEvent, - session: StructuredAgentSessionHostSession, + session: Pick, stableSettlementId: string ): JournalLifecycleMutationInput[] { const mutations: JournalLifecycleMutationInput[] = []