diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-command-concurrency.test.ts b/src/main/native-chat/agent-session-wire/structured-conversation-command-concurrency.test.ts index 9c09a369722..5f6c598658f 100644 --- a/src/main/native-chat/agent-session-wire/structured-conversation-command-concurrency.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-conversation-command-concurrency.test.ts @@ -126,4 +126,67 @@ describe('host conversation command concurrency', () => { { ok: true, value: { state: 'completed' } } ) }) + + it('retires a provider completion that arrives after recovery advances the generation', async () => { + await host.hold(HOST_TEST_SESSION, 'conversation-surface') + const completion = Promise.withResolvers>() + compact.mockReturnValueOnce(completion.promise) + const running = host.conversationCommand(CALLER, commandParams('compact')) + await vi.waitFor(() => expect(compact).toHaveBeenCalledOnce()) + const fence = store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence + + await host.handleAdapterEvent({ + type: 'ended', + sessionId: HOST_TEST_SESSION, + reason: 'provider exited', + cause: 'unexpected-exit', + fence, + acquisitionGeneration: 'generation-1' + }) + expect(store.getRecord(HOST_TEST_SESSION)?.lease.runtimeFence).toBeGreaterThan(fence) + completion.resolve({}) + + await expect(running).resolves.toMatchObject({ ok: true, value: { state: 'unknown' } }) + await expect(host.conversationCommand(CALLER, commandParams('compact'))).resolves.toMatchObject( + { ok: true, value: { state: 'completed' } } + ) + }) + + it('retires a clear replacement that finishes after recovery advances the generation', async () => { + await host.hold(HOST_TEST_SESSION, 'conversation-surface') + const originalAttach = host.attach.bind(host) + const replacementStarted = Promise.withResolvers() + const releaseReplacement = Promise.withResolvers() + vi.spyOn(host, 'attach').mockImplementation(async (caller, params) => { + if (params.envelope.sessionId.startsWith('clear-')) { + replacementStarted.resolve() + await releaseReplacement.promise + } + return originalAttach(caller, params) + }) + const params = commandParams('clear') + const running = host.conversationCommand(CALLER, params) + await replacementStarted.promise + const fence = store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence + + await host.handleAdapterEvent({ + type: 'ended', + sessionId: HOST_TEST_SESSION, + reason: 'provider exited', + cause: 'unexpected-exit', + fence, + acquisitionGeneration: 'generation-1' + }) + expect(store.getRecord(HOST_TEST_SESSION)?.lease.runtimeFence).toBeGreaterThan(fence) + releaseReplacement.resolve() + + await expect(running).resolves.toMatchObject({ ok: true, value: { state: 'unknown' } }) + const recoveredFence = store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence + await expect( + host.conversationCommand(CALLER, { + ...params, + envelope: { ...params.envelope, expectedRuntimeFence: recoveredFence } + }) + ).resolves.toMatchObject({ ok: true, value: { state: 'completed' } }) + }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-command-execution.ts b/src/main/native-chat/agent-session-wire/structured-conversation-command-execution.ts index 480300ad868..efa65c92eee 100644 --- a/src/main/native-chat/agent-session-wire/structured-conversation-command-execution.ts +++ b/src/main/native-chat/agent-session-wire/structured-conversation-command-execution.ts @@ -25,6 +25,8 @@ type ExecutionOwner = { report: (entry: PendingConversationCommand, error: unknown) => void } +const STALE_COMMAND_COMPLETION = new Error('Conversation operation became stale after recovery.') + export class StructuredConversationCommandExecution { constructor( private readonly context: () => StructuredAgentSessionMutationContext, @@ -172,6 +174,7 @@ export class StructuredConversationCommandExecution { .close(replacementSessionId) .catch((error) => this.owner.report(entry, error)) } + await this.markUnknown(entry, STALE_COMMAND_COMPLETION, true) return } await this.complete(entry, attachError ?? undefined, Boolean(attachError)) @@ -188,6 +191,7 @@ export class StructuredConversationCommandExecution { } await this.context().serialize(execution.turn.sessionId, async () => { if (!this.canSettle(entry, execution)) { + await this.markUnknownInLane(entry, STALE_COMMAND_COMPLETION, true) return } const value: AgentSessionConversationCommandRecord = {