diff --git a/src/shared/structured-agent-session-send-disposition.test.ts b/src/shared/structured-agent-session-send-disposition.test.ts index 98ea2ff8b67..5514d422015 100644 --- a/src/shared/structured-agent-session-send-disposition.test.ts +++ b/src/shared/structured-agent-session-send-disposition.test.ts @@ -411,6 +411,33 @@ describe('ambiguous operation refusals', () => { expect(disposition.entries).toMatchObject([{ clientMessageId: 'fresh-id', state: 'rejected' }]) }) + it("parks an older host's restart answer that carries the reason but not the recovered marker", () => { + const result = rejectedWith(null) + if (!result.ok || !('submission' in result.value)) { + throw new Error('expected a send result') + } + result.value.submission = { + ...result.value.submission, + dispatchState: 'unknown', + reason: 'host_restarted_before_acknowledgement' + } + + const disposition = disposeStructuredAgentSessionSendResult({ + entries: [entry], + entry, + result, + createOperationId: () => 'unused' + }) + + expect(disposition.entries).toMatchObject([ + { + clientMessageId: entry.clientMessageId, + state: 'unconfirmed', + retryAfterUnknownSubmittedAt: -1 + } + ]) + }) + it('parks a recovered missing submission without polling forever', () => { const result = rejectedWith(null) if (!result.ok || !('submission' in result.value)) { diff --git a/src/shared/structured-agent-session-send-disposition.ts b/src/shared/structured-agent-session-send-disposition.ts index f717e54f2d4..a8f68057fd5 100644 --- a/src/shared/structured-agent-session-send-disposition.ts +++ b/src/shared/structured-agent-session-send-disposition.ts @@ -22,6 +22,7 @@ import { import type { AgentSessionFailureFact } from './agent-session-failure' import type { AgentSessionFailureWordsContext } from './agent-session-failure-words' import { classifyDispatchRejection } from './structured-agent-session-dispatch-rejection' +import { isRecoveredStructuredAgentSessionSubmission } from './structured-agent-session-unanswered-dispatch' import { classifyStructuredAgentSessionSendFailure, requeueStructuredAgentSessionSendRefusal, @@ -277,7 +278,7 @@ export function disposeStructuredAgentSessionSendResult( error: null } } - if (submission.dispatchState === 'unknown' && submission.recovered) { + if (isRecoveredStructuredAgentSessionSubmission(submission)) { return { entries: input.entries.map((candidate) => candidate.clientMessageId === input.entry.clientMessageId diff --git a/src/shared/structured-agent-session-unanswered-dispatch.test.ts b/src/shared/structured-agent-session-unanswered-dispatch.test.ts new file mode 100644 index 00000000000..39a67f880e7 --- /dev/null +++ b/src/shared/structured-agent-session-unanswered-dispatch.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest' +import type { AgentJournalSubmission } from './agent-session-journal-types' +import { + isRecoveredStructuredAgentSessionSubmission, + isUnansweredStructuredAgentSessionDispatch +} from './structured-agent-session-unanswered-dispatch' + +function submission(patch: Partial): AgentJournalSubmission { + return { + clientMessageId: 'client-1', + fence: 1, + payloadFingerprint: 'fingerprint', + dispatchState: 'unknown', + providerItemId: null, + reason: null, + submittedAt: 1, + resolvedAt: 1, + ...patch + } +} + +describe('a send whose outcome the host lost for good', () => { + it('is one the host marked recovered', () => { + const row = submission({ recovered: true, reason: 'provider_exited_before_acknowledgement' }) + expect(isRecoveredStructuredAgentSessionSubmission(row)).toBe(true) + expect(isUnansweredStructuredAgentSessionDispatch(row)).toBe(false) + }) + + it("is an older host's restart row, which carries the reason without the marker", () => { + const row = submission({ reason: 'host_restarted_before_acknowledgement' }) + expect(isRecoveredStructuredAgentSessionSubmission(row)).toBe(true) + expect(isUnansweredStructuredAgentSessionDispatch(row)).toBe(false) + }) + + it('is never a live unknown, which something still running may answer', () => { + const row = submission({ reason: 'provider_ack_ambiguous' }) + expect(isRecoveredStructuredAgentSessionSubmission(row)).toBe(false) + expect(isUnansweredStructuredAgentSessionDispatch(row)).toBe(true) + }) + + it('is never a settled send, whatever its reason says', () => { + expect( + isRecoveredStructuredAgentSessionSubmission( + submission({ dispatchState: 'rejected', reason: 'host_restarted_before_acknowledgement' }) + ) + ).toBe(false) + }) +}) diff --git a/src/shared/structured-agent-session-unanswered-dispatch.ts b/src/shared/structured-agent-session-unanswered-dispatch.ts index 412b3e98196..2113525564d 100644 --- a/src/shared/structured-agent-session-unanswered-dispatch.ts +++ b/src/shared/structured-agent-session-unanswered-dispatch.ts @@ -1,6 +1,19 @@ import type { AgentJournalSubmission } from './agent-session-journal-types' import { isQueuedAgentJournalSubmission } from './agent-session-queued-submission' +/** A send whose outcome the host lost for good when the process that sent it went away (a restart, + * a provider exit, an idle release): nothing still running can answer it. */ +export function isRecoveredStructuredAgentSessionSubmission( + submission: Pick +): boolean { + return ( + submission.dispatchState === 'unknown' && + (submission.recovered === true || + // Older hosts publish the recovery reason but omit the optional marker. + submission.reason === 'host_restarted_before_acknowledgement') + ) +} + /** One send the provider has neither opened a turn for nor refused; the rule is explained on * `hasUnansweredStructuredAgentSessionDispatch`, which asks it of every send. */ export function isUnansweredStructuredAgentSessionDispatch( @@ -15,9 +28,7 @@ export function isUnansweredStructuredAgentSessionDispatch( (currentFence == null || submission.fence >= currentFence) && (submission.dispatchState === 'pending' || (submission.dispatchState === 'unknown' && - submission.recovered !== true && - // Older hosts publish the recovery reason but omit the optional marker. - submission.reason !== 'host_restarted_before_acknowledgement')) + !isRecoveredStructuredAgentSessionSubmission(submission))) ) }