fix(native-chat): retire stale command completions

This commit is contained in:
Brennan Benson
2026-09-16 17:12:38 -07:00
parent 258917a0c6
commit e9690a707f
2 changed files with 67 additions and 0 deletions
@@ -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<Record<string, never>>()
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<void>()
const releaseReplacement = Promise.withResolvers<void>()
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' } })
})
})
@@ -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 = {