diff --git a/src/main/claude/claude-command-lifecycle.test.ts b/src/main/claude/claude-command-lifecycle.test.ts index c5570e476fd1..f4182a601747 100644 --- a/src/main/claude/claude-command-lifecycle.test.ts +++ b/src/main/claude/claude-command-lifecycle.test.ts @@ -10,7 +10,13 @@ import { describe, expect, it } from 'vitest' import type { AgentJournalItemBody } from '../../shared/agent-session-journal-types' import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' import { DISPATCH_REJECTED_CANCELLED } from '../../shared/structured-agent-session-dispatch-rejection' +import { + DISPATCH_DOUBT_PROVIDER_ENDED_UNANSWERED, + DISPATCH_DOUBT_PROVIDER_IDLE +} from '../native-chat/agent-session-journal/journal-dispatch-doubt-reasons' +import { agentSessionFailureFact } from '../../shared/agent-session-failure' import { ClaudeControlRequestError } from './claude-agent-sdk-control-requests' +import { claudeDispatchRejection } from './claude-structured-dispatch-content' import { ClaudeStructuredSessionAdapter } from './claude-structured-session-adapter' import type { ClaudeLateDispatchOutcome } from './claude-structured-session-state' import { @@ -96,6 +102,8 @@ async function replayCapture( omitFrame?: (frame: Record) => boolean /** Frames to deliver right after a captured one, in the capture's own uuids. */ afterFrame?: (frame: Record) => Record[] + /** Deliver this in place of a captured frame, as a CLI reporting another state would. */ + rewriteFrame?: (frame: Record) => Record /** Stop after the control request settles; its tail answers the CLI's own control path. */ stopAfterControl?: boolean } = {} @@ -161,7 +169,7 @@ async function replayCapture( if (options.omitFrame?.(frame)) { return } - const mapped = mapUuids(frame) + const mapped = mapUuids(options.rewriteFrame?.(frame) ?? frame) if (Array.isArray(mapped.capabilities) && options.withoutCapabilities) { mapped.capabilities = mapped.capabilities.filter( (capability) => !options.withoutCapabilities!.includes(String(capability)) @@ -366,6 +374,30 @@ describe('a queued send the CLI withdraws without Orca hearing why', () => { }) }) +/** The auth-failed capture's turn output: without it, its send started and got no echo. */ +function isTurnOutput(frame: Record): boolean { + return frame.type === 'user' || frame.type === 'assistant' || frame.type === 'result' +} + +function isLifecycleFrame(frame: Record, state?: string): boolean { + return frame.type === 'command_lifecycle' && (state === undefined || frame.state === state) +} + +function isIdleFrame(frame: Record): boolean { + return frame.subtype === 'session_state_changed' && frame.state === 'idle' +} + +/** The uuid a capture recorded for one of its sends, as its own frames name it. */ +function capturedUuid(name: string, clientMessageId: string): string { + const dispatch = loadCapture(name).find( + (event) => event.kind === 'dispatch' && event.clientMessageId === clientMessageId + ) + return dispatch?.kind === 'dispatch' ? dispatch.sentUuid : '' +} + +const ENDED_IN_DOUBT = { state: 'unknown', reason: DISPATCH_DOUBT_PROVIDER_ENDED_UNANSWERED } +const IDLE_IN_DOUBT = { state: 'unknown', reason: DISPATCH_DOUBT_PROVIDER_IDLE } + describe('a send the CLI started, then cancelled', () => { it('stays accepted when its turn failed after the echo', async () => { const replay = await replayCapture('auth-failed') @@ -373,28 +405,220 @@ describe('a send the CLI started, then cancelled', () => { expect(replay.settlementsFor('client-A')).toEqual([acceptedAs(replay.liveUuid('client-A'))]) }) - it('is not read as withdrawn when the cancelled frame came before any echo', async () => { - // The auth-failed capture with the turn's output removed: started, then cancelled. + it('is released as doubt, never withdrawn, when the cancelled frame came before any echo', async () => { + const replay = await replayCapture('auth-failed', { omitFrame: isTurnOutput }) + + expect(replay.settlementsFor('client-A')).toEqual([ENDED_IN_DOUBT]) + expect(replay.adapter.holdsDispatch('session-1')).toBe(false) + }) + + it('stays started when a redelivered command re-emits queued before its cancelled frame', async () => { const replay = await replayCapture('auth-failed', { + omitFrame: isTurnOutput, + afterFrame: (frame) => + isLifecycleFrame(frame, 'started') ? [{ ...frame, state: 'queued' }] : [] + }) + + expect(replay.settlementsFor('client-A')).toEqual([ENDED_IN_DOUBT]) + }) + + it('is still accepted when its echo lands after the doubt', async () => { + const replay = await replayCapture('auth-failed', { omitFrame: isTurnOutput }) + const uuid = replay.liveUuid('client-A') + + replay.connection.handlers.onMessage?.({ + type: 'user', + message: { role: 'user', content: [{ type: 'text', text: 'late echo' }] }, + parent_tool_use_id: null, + session_id: PROVIDER_SESSION_ID, + uuid + }) + + expect(replay.settlementsFor('client-A')).toEqual([ENDED_IN_DOUBT, acceptedAs(uuid)]) + }) + + it.each(['discarded', 'refused'])( + 'is released as doubt when its turn ends %s after it started', + async (state) => { + const replay = await replayCapture('auth-failed', { + omitFrame: isTurnOutput, + rewriteFrame: (frame) => + isLifecycleFrame(frame, 'cancelled') ? { ...frame, state } : frame + }) + + expect(replay.settlementsFor('client-A')).toEqual([ENDED_IN_DOUBT]) + } + ) +}) + +// batch-lead's client-C is a steer: written while A's turn runs, queued, then started as its own +// turn once A completed. Its queued-while-Stopped case is interrupt-lost's client-B, above. +describe('a steer a Stop interrupts after it started', () => { + it('is released as doubt, never withdrawn, when the Stop lands before its echo', async () => { + const steer = capturedUuid('batch-lead', 'client-C') + let started = false + let routes: ControlPoint['routes'] | undefined + const replay = await replayCapture('batch-lead', { + atControl: async (point) => { + routes = point.routes + point.deliverInFlight() + }, + // The Stop lands between the steer's start and its echo: nothing after its start arrives. + omitFrame: () => started, + afterFrame: (frame) => { + started ||= isLifecycleFrame(frame, 'started') && frame.command_uuid === steer + return [] + } + }) + const uuid = replay.liveUuid('client-C') + expect(replay.adapter.holdsDispatch('session-1')).toBe(true) + + // Answered as interrupt-lost's was, with nothing left queued; then that capture's tail for a + // started command a Stop ended (its result dropped: one naming the steer would accept it). + routes!.interrupt = () => ({ still_queued: [], cancelled: [] }) + await replay.adapter.cancelTurn({ sessionId: 'session-1', fence: 7 }) + for (const frame of [ + { + type: 'command_lifecycle', + command_uuid: uuid, + state: 'cancelled', + uuid: 'steer-cancelled' + }, + { type: 'system', subtype: 'session_state_changed', state: 'idle', uuid: 'steer-idle' } + ]) { + replay.connection.handlers.onMessage?.({ ...frame, session_id: PROVIDER_SESSION_ID }) + } + + expect(replay.connection.calls.filter((call) => call.subtype === 'interrupt')).toEqual([ + { subtype: 'interrupt', params: { cancelQueued: true } } + ]) + expect(replay.settlementsFor('client-C')).toEqual([ENDED_IN_DOUBT]) + expect(replay.adapter.holdsDispatch('session-1')).toBe(false) + }) +}) + +describe('a send the CLI ended before starting it', () => { + it.each([ + // The CLI ended its session with the send still queued. + ['discarded', 'notDelivered', false], + // Declined before it queued, so no `queued` frame precedes it. + ['refused', 'providerRejected', true] + ] as const)('settles a %s send as not sent, saying why', async (state, kind, neverQueued) => { + const followUp = capturedUuid('cancel-async', 'client-B') + const replay = await replayCapture('cancel-async', { omitFrame: (frame) => - frame.type === 'user' || frame.type === 'assistant' || frame.type === 'result' + neverQueued && isLifecycleFrame(frame, 'queued') && frame.command_uuid === followUp, + rewriteFrame: (frame) => + isLifecycleFrame(frame, 'cancelled') && frame.command_uuid === followUp + ? { ...frame, state } + : frame }) - expect(replay.settlementsFor('client-A')).toEqual([]) + expect(replay.settlementsFor('client-B')).toEqual([ + { state: 'rejected', ...claudeDispatchRejection(agentSessionFailureFact(kind)) } + ]) + expect(replay.settlementsFor('client-A')).toEqual([acceptedAs(replay.liveUuid('client-A'))]) }) +}) - it('stays started when a redelivered command re-emits queued before its cancelled frame', async () => { +describe('a send the CLI holds', () => { + it('holds its child from the idle sweep until the CLI idles, then is released as doubt', async () => { + // Started, then its turn threw: no echo and no terminal state, as the schema allows. const replay = await replayCapture('auth-failed', { omitFrame: (frame) => - frame.type === 'user' || frame.type === 'assistant' || frame.type === 'result', + isTurnOutput(frame) || isLifecycleFrame(frame, 'cancelled') || isIdleFrame(frame) + }) + expect(replay.adapter.holdsDispatch('session-1')).toBe(true) + expect(replay.settlementsFor('client-A')).toEqual([]) + + replay.connection.handlers.onMessage?.({ + type: 'system', + subtype: 'session_state_changed', + state: 'idle', + uuid: 'idle-after-throw', + session_id: PROVIDER_SESSION_ID + }) + + expect(replay.settlementsFor('client-A')).toEqual([IDLE_IN_DOUBT]) + expect(replay.adapter.holdsDispatch('session-1')).toBe(false) + }) + + // 2.1.280's end-of-turn cleanup can report idle before it re-reads its queue. + it('leaves a queued send the CLI idles before starting to open its turn and be accepted', async () => { + const replay = await replayCapture('auth-failed', { afterFrame: (frame) => - frame.type === 'command_lifecycle' && frame.state === 'started' - ? [{ ...frame, state: 'queued' }] + isLifecycleFrame(frame, 'queued') + ? [ + { + type: 'system', + subtype: 'session_state_changed', + state: 'idle', + uuid: 'idle-before-start', + session_id: frame.session_id + } + ] : [] }) + const uuid = replay.liveUuid('client-A') + expect(replay.settlementsFor('client-A')).toEqual([acceptedAs(uuid)]) + expect(replay.turnStates.get(uuid)).toBe('completed') + }) + + it('holds the child for a send the CLI has only queued, and does not release it at idle', async () => { + const replay = await replayCapture('auth-failed', { + omitFrame: (frame) => + isTurnOutput(frame) || + isLifecycleFrame(frame, 'started') || + isLifecycleFrame(frame, 'cancelled') + }) + + expect(replay.idles).toEqual(['session-1']) + expect(replay.adapter.holdsDispatch('session-1')).toBe(true) expect(replay.settlementsFor('client-A')).toEqual([]) }) + + it('holds a queued send until the CLI withdraws it', async () => { + const held: boolean[] = [] + const replay = await replayCapture('cancel-async', { + atControl: async (point) => { + // The lead was echoed already; only the queued follow-up is left. + held.push(point.adapter.holdsDispatch('session-1')) + point.deliverInFlight() + held.push(point.adapter.holdsDispatch('session-1')) + } + }) + + expect(held).toEqual([true, false]) + expect(replay.settlementsFor('client-B')).toEqual([WITHDRAWN]) + }) + + it('holds a queued send until it starts and is echoed', async () => { + const held: boolean[] = [] + const replay = await replayCapture('batch-lead', { + // Without its turn's end or the idle, only the start and echo can let it go. + omitFrame: (frame) => + frame.type === 'result' || isLifecycleFrame(frame, 'completed') || isIdleFrame(frame), + atControl: async (point) => { + point.deliverInFlight() + // The batch lead is withdrawn; the send behind it is only queued. + held.push(point.adapter.holdsDispatch('session-1')) + } + }) + + expect(held).toEqual([true]) + expect(replay.adapter.holdsDispatch('session-1')).toBe(false) + expect(replay.settlementsFor('client-C')).toEqual([acceptedAs(replay.liveUuid('client-C'))]) + }) + + it.each(['interrupt-lost', 'cancel-async', 'batch-lead', 'auth-failed'])( + 'holds nothing once the %s capture has run to its idle', + async (name) => { + const replay = await replayCapture(name) + + expect(replay.adapter.holdsDispatch('session-1')).toBe(false) + } + ) }) describe('the CLI reporting its session idle', () => { diff --git a/src/main/claude/claude-command-lifecycle.ts b/src/main/claude/claude-command-lifecycle.ts index 652a431d5383..a5d4408799bd 100644 --- a/src/main/claude/claude-command-lifecycle.ts +++ b/src/main/claude/claude-command-lifecycle.ts @@ -1,15 +1,37 @@ // What Claude's per-command `command_lifecycle` frames (msg_lifecycle_v1) settle. // -// `cancelled` is not by itself a withdrawal: a command the CLI already started also ends -// `cancelled` when its turn is interrupted or fails (measured on 2.1.280). Only a command -// cancelled before it started was withdrawn. That frame lands ahead of the control answer, so -// it settles the send even when the interrupt or cancel_async_message answer is lost. +// A terminal state for a command that never started is a verdict: `cancelled` was withdrawn, +// `discarded` was dropped when the CLI ended its session, `refused` was declined before it +// queued. None ran. After `started`, 2.1.280 sends only `cancelled`, when an interrupt or a hard +// failure ends the turn (measured), so the send may be in the conversation: any end is doubt. +// An unstarted send's `cancelled` lands before the Stop's answer, so it settles if that is lost. -import { settleCancelledClaudeDispatchWaiters } from './claude-structured-dispatch' +import { + agentSessionFailureFact, + type SubmissionRejectionKind +} from '../../shared/agent-session-failure' +import { + DISPATCH_DOUBT_PROVIDER_ENDED_UNANSWERED, + DISPATCH_DOUBT_PROVIDER_IDLE +} from '../native-chat/agent-session-journal/journal-dispatch-doubt-reasons' +import { + rejectClaudeDispatchWaiters, + releaseClaudeDispatchWaitersInDoubt +} from './claude-structured-dispatch' import { readClaudeFrameString } from './claude-structured-init-proof' import type { ClaudeLateDispatchSettlement } from './claude-replay-turn-resolution' import type { ClaudeSession } from './claude-structured-session-state' +const UNSTARTED_VERDICT = { + cancelled: 'cancelled', + discarded: 'notDelivered', + refused: 'providerRejected' +} satisfies Record + +function isTerminalState(state: unknown): state is keyof typeof UNSTARTED_VERDICT { + return state === 'cancelled' || state === 'discarded' || state === 'refused' +} + export function observeClaudeCommandLifecycle( session: ClaudeSession, message: Record, @@ -27,7 +49,36 @@ export function observeClaudeCommandLifecycle( if (state === 'started' || (state === 'queued' && waiter.commandLifecycle !== 'started')) { // Forward only: a redelivered command re-emits `queued`, but it has still started. waiter.commandLifecycle = state - } else if (state === 'cancelled' && waiter.commandLifecycle !== 'started') { - settleCancelledClaudeDispatchWaiters(session, [waiter.sentUuid], onSettledLate) + } else if (isTerminalState(state) && waiter.commandLifecycle === 'started') { + releaseClaudeDispatchWaitersInDoubt( + session, + [waiter], + DISPATCH_DOUBT_PROVIDER_ENDED_UNANSWERED, + onSettledLate + ) + } else if (isTerminalState(state)) { + rejectClaudeDispatchWaiters( + session, + [waiter.sentUuid], + agentSessionFailureFact(UNSTARTED_VERDICT[state]), + onSettledLate + ) } } + +/** A send the CLI took, until its echo, a terminal state or exit: the sweep must not rest its child. */ +export function claudeHoldsDispatch(session: ClaudeSession): boolean { + return session.dispatchWaiters.some((waiter) => waiter.commandLifecycle !== undefined) +} + +/** + * At idle, only a `started` send still unanswered is doubt: a turn that threw leaves it with no + * terminal state. A `queued` one may still start: 2.1.280's code idles before re-reading its queue. + */ +export function releaseClaudeDispatchesUnansweredAtIdle( + session: ClaudeSession, + onSettledLate?: ClaudeLateDispatchSettlement +): void { + const started = session.dispatchWaiters.filter((waiter) => waiter.commandLifecycle === 'started') + releaseClaudeDispatchWaitersInDoubt(session, started, DISPATCH_DOUBT_PROVIDER_IDLE, onSettledLate) +} diff --git a/src/main/claude/claude-replay-turn-resolution.ts b/src/main/claude/claude-replay-turn-resolution.ts index 9db40feac49e..fe2bb1523f95 100644 --- a/src/main/claude/claude-replay-turn-resolution.ts +++ b/src/main/claude/claude-replay-turn-resolution.ts @@ -3,10 +3,14 @@ // A replay or result is joined to its waiter by the client uuid Claude echoes. // A send Claude FOLDS into the running request cycle is replayed mid-cycle with // the client uuid adopted: once that cycle has done work, that replay is a -// delivery receipt and opens no boundary. A `command_lifecycle` frame opens no turn; its -// `cancelled` can settle a send the CLI withdrew (`claude-command-lifecycle.ts`). +// delivery receipt and opens no boundary. A `command_lifecycle` frame opens no turn; it and the +// CLI going idle settle sends the CLI took but never echoed (`claude-command-lifecycle.ts`). -import { observeClaudeCommandLifecycle } from './claude-command-lifecycle' +import { + observeClaudeCommandLifecycle, + releaseClaudeDispatchesUnansweredAtIdle +} from './claude-command-lifecycle' +import { claudeSessionStateEndsTurn } from './claude-session-state-turn-over' import { forgetRetiredWaiter } from './claude-structured-dispatch-waiters' import { claudeHasReplayContent, @@ -34,6 +38,10 @@ export function resolveClaudeReplayTurn( observeClaudeCommandLifecycle(session, message, onSettledLate) return null } + if (claudeSessionStateEndsTurn(message)) { + releaseClaudeDispatchesUnansweredAtIdle(session, onSettledLate) + return null + } const envelope = readClaudeMessageEnvelope(message) const isUserReplay = envelope?.role === 'user' && diff --git a/src/main/claude/claude-structured-dispatch-waiters.ts b/src/main/claude/claude-structured-dispatch-waiters.ts index 92b6a5048b2f..2c5df3c671eb 100644 --- a/src/main/claude/claude-structured-dispatch-waiters.ts +++ b/src/main/claude/claude-structured-dispatch-waiters.ts @@ -13,8 +13,10 @@ export function forgetRetiredWaiter(session: ClaudeSession, waiter: ClaudeDispat * A waiter with no deadline. A mid-turn send Claude folds into the running turn * is replayed mid-turn; one it runs later is replayed only when its own turn * starts — an interval bounded only by the previous turn. Elapsed time is - * therefore not evidence about delivery, and nothing here expires. Waiters are - * retired by process facts instead: a failed write, or child exit. + * therefore not evidence about delivery, and nothing here expires. Waiters end + * by provider and process facts instead: an echo, a `command_lifecycle` frame, + * the CLI's idle after `started` (`claude-command-lifecycle.ts`), a failed + * write, or child exit. */ export function waitForReplay( session: ClaudeSession, diff --git a/src/main/claude/claude-structured-dispatch.ts b/src/main/claude/claude-structured-dispatch.ts index c208cceccdbf..87e706ddbbc3 100644 --- a/src/main/claude/claude-structured-dispatch.ts +++ b/src/main/claude/claude-structured-dispatch.ts @@ -7,7 +7,7 @@ import { } from './claude-structured-dispatch-waiters' import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types' import type { AgentSessionDispatchOutcome } from '../native-chat/agent-session-wire/structured-agent-session-adapter' -import type { ClaudeSession } from './claude-structured-session-state' +import type { ClaudeDispatchWaiter, ClaudeSession } from './claude-structured-session-state' import type { ClaudeLateDispatchSettlement } from './claude-replay-turn-resolution' import { claudeDispatchContentKey, @@ -18,7 +18,10 @@ import { } from './claude-structured-dispatch-content' import { dispatchWriteOutcomeUnknownReason } from '../native-chat/agent-session-journal/journal-dispatch-doubt-reasons' import { DISPATCH_REJECTED_QUEUE_FULL } from '../../shared/structured-agent-session-dispatch-rejection' -import { agentSessionFailureFact } from '../../shared/agent-session-failure' +import { + agentSessionFailureFact, + type SubmissionRejectionFact +} from '../../shared/agent-session-failure' import type { AgentJournalDispatchRejection } from '../../shared/agent-session-failure-words' import { claudeUnwrittenUserMessageError, @@ -37,10 +40,25 @@ export function settleCancelledClaudeDispatchWaiters( cancelledUuids: readonly string[], onSettledLate?: ClaudeLateDispatchSettlement ): void { - const cancelled = new Set(cancelledUuids) - const activeWaiters = session.dispatchWaiters.filter((waiter) => cancelled.has(waiter.sentUuid)) + rejectClaudeDispatchWaiters( + session, + cancelledUuids, + agentSessionFailureFact('cancelled'), + onSettledLate + ) +} + +/** Settles sends the CLI proved it will never run, each with the fact that says why. */ +export function rejectClaudeDispatchWaiters( + session: ClaudeSession, + uuids: readonly string[], + fact: SubmissionRejectionFact, + onSettledLate?: ClaudeLateDispatchSettlement +): void { + const named = new Set(uuids) + const activeWaiters = session.dispatchWaiters.filter((waiter) => named.has(waiter.sentUuid)) const retiredWaiters = session.retiredDispatchWaiters.filter((waiter) => - cancelled.has(waiter.sentUuid) + named.has(waiter.sentUuid) ) for (const waiter of activeWaiters) { forgetWaiter(session, waiter) @@ -54,14 +72,31 @@ export function settleCancelledClaudeDispatchWaiters( onSettledLate?.({ clientMessageId: waiter.clientMessageId, state: 'rejected', - ...claudeDispatchRejection(agentSessionFailureFact('cancelled')) + ...claudeDispatchRejection(fact) }) } } } -/** Nothing expires a waiter, so the child's death is what ends every live one. - * Retired rather than dropped: their identities stay joinable, bounded by +/** Releases sends the CLI took and let go without an echo or a verdict: each may have run, so each + * settles as doubt, never re-sent. Retired, not dropped, so a late replay still accepts it. */ +export function releaseClaudeDispatchWaitersInDoubt( + session: ClaudeSession, + waiters: readonly ClaudeDispatchWaiter[], + reason: string, + onSettledLate?: ClaudeLateDispatchSettlement +): void { + for (const waiter of waiters) { + retireWaiter(session, waiter) + waiter.resolve(null) + if (waiter.clientMessageId) { + onSettledLate?.({ clientMessageId: waiter.clientMessageId, state: 'unknown', reason }) + } + } +} + +/** Nothing expires a waiter; the child's death ends every one its echo, a lifecycle frame or the + * CLI's idle has not. Retired rather than dropped: their identities stay joinable, bounded by * `MAX_RETIRED_DISPATCH_WAITERS`. */ export function retireClaudeDispatchWaiters(session: ClaudeSession): void { failClaudeStartup(session, new Error('claude stream-json ended before startup completed')) diff --git a/src/main/claude/claude-structured-session-acquisition.ts b/src/main/claude/claude-structured-session-acquisition.ts index b4adbfccfa5c..0a160d99b934 100644 --- a/src/main/claude/claude-structured-session-acquisition.ts +++ b/src/main/claude/claude-structured-session-acquisition.ts @@ -118,7 +118,7 @@ export async function acquireClaudeSession({ if (message.type === 'result' && sessions.get(sessionId) === liveSession) { persistClaudeTurnResumePoint(sessionId, liveSession, deps) } - // The CLI idles only once its queue drains, so it holds none of this session's sends. + // The CLI's idle releases its doubted sends; a late echo still accepts one it goes on to run. if (claudeSessionStateEndsTurn(message) && sessions.get(sessionId) === liveSession) { deps.onSessionIdle?.({ sessionId }) } diff --git a/src/main/claude/claude-structured-session-adapter.ts b/src/main/claude/claude-structured-session-adapter.ts index 64d3c67c2277..d2788bf40276 100644 --- a/src/main/claude/claude-structured-session-adapter.ts +++ b/src/main/claude/claude-structured-session-adapter.ts @@ -7,6 +7,7 @@ import type { } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import { stopClaudeBackgroundTasks } from './claude-structured-control-actions' import { dispatchClaudeTurn } from './claude-structured-dispatch' +import { claudeHoldsDispatch } from './claude-command-lifecycle' import { releaseClaudeAcquisition } from './claude-structured-acquisition-release' import { acquireClaudeSession } from './claude-structured-session-acquisition' import { supportsClaudeStructuredLocation } from './claude-structured-location-support' @@ -227,6 +228,10 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda } readCommands: NonNullable = (sessionId) => this.sessions.get(sessionId)?.commands.commands + holdsDispatch = (sessionId: string): boolean => { + const session = this.sessions.get(sessionId) + return session ? claudeHoldsDispatch(session) : false + } answerPrompt: StructuredAgentSessionAdapter['answerPrompt'] = (request) => answerClaudeStructuredPrompt({ request, sessions: this.sessions }) setOption: StructuredAgentSessionAdapter['setOption'] = (input) => diff --git a/src/main/claude/claude-structured-session-state.ts b/src/main/claude/claude-structured-session-state.ts index b40aaba81c44..0d43dbb99ec8 100644 --- a/src/main/claude/claude-structured-session-state.ts +++ b/src/main/claude/claude-structured-session-state.ts @@ -87,6 +87,8 @@ export type ClaudeLateDispatchOutcome = providerIdentity: AgentJournalItemIdentity } | ({ clientMessageId: string; state: 'rejected' } & AgentJournalDispatchRejection) + /** The CLI took the send and let it go unanswered: it may have run, so it is never re-sent. */ + | { clientMessageId: string; state: 'unknown'; reason: string } export type ClaudeStructuredSessionAdapterDeps = { resolveLaunch: (input: { @@ -95,7 +97,7 @@ export type ClaudeStructuredSessionAdapterDeps = { onEvent?: (event: ClaudeStructuredSessionEvent) => void /** Direct settlement path for provider-proven late dispatch outcomes. */ onDispatchSettledLate?: (input: { sessionId: string } & ClaudeLateDispatchOutcome) => void - /** The CLI reported `session_state_changed idle`, which it sends only once its queue drains. */ + /** The CLI reported `session_state_changed idle`: its turn is over. */ onSessionIdle?: (input: { sessionId: string }) => void onBackgroundTasksChanged?: ( sessionId: string, @@ -139,7 +141,7 @@ export type ClaudeDispatchWaiter = { requestedAt: number | null /** Set when the provider replay settled this waiter before send returned. */ settledUuid?: string - /** The write failed or the child died, but a replay may still name it. */ + /** Settled with no echo (failed write, child exit, doubt after start); a replay may name it. */ retired?: boolean /** The CLI's last non-terminal `command_lifecycle` state for this send; in memory only. */ commandLifecycle?: 'queued' | 'started' @@ -158,7 +160,7 @@ export type ClaudeSession = { acquisitionGeneration: string prompts: ClaudePromptRegistry dispatchWaiters: ClaudeDispatchWaiter[] - /** Bounded identities for dispatches whose child died or whose write failed. */ + /** Bounded identities for dispatches settled without an echo; a late replay still accepts one. */ retiredDispatchWaiters: ClaudeDispatchWaiter[] /** Once a retired waiter is evicted, legacy content-only replay matching is unsafe. */ replayContentFallbackBlocked: boolean diff --git a/src/main/native-chat/agent-session-journal/journal-dispatch-doubt-reasons.ts b/src/main/native-chat/agent-session-journal/journal-dispatch-doubt-reasons.ts index 6c01e6224dc0..1aaa0b76df95 100644 --- a/src/main/native-chat/agent-session-journal/journal-dispatch-doubt-reasons.ts +++ b/src/main/native-chat/agent-session-journal/journal-dispatch-doubt-reasons.ts @@ -27,6 +27,9 @@ export const DISPATCH_DOUBT_SUBMISSION_MISSING = 'durable_send_submission_missin * left that could still acknowledge the message. */ export const DISPATCH_DOUBT_PROVIDER_IDLE = 'provider_idle_before_acknowledgement' +/** The provider started the message, then ended its turn before acknowledging it. */ +export const DISPATCH_DOUBT_PROVIDER_ENDED_UNANSWERED = 'provider_ended_before_acknowledgement' + /** The SDK took the frame, but its input pump did not prove whether the write completed. */ export const DISPATCH_DOUBT_WRITE_OUTCOME_UNKNOWN = 'provider_write_outcome_unknown' diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts index bb003f93427d..9a759ba0411b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts @@ -112,6 +112,9 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi sessionId ) => this.liveOwnerOrNull(sessionId)?.backgroundTaskState?.(sessionId) + holdsDispatch = (sessionId: string): boolean => + this.liveOwnerOrNull(sessionId)?.holdsDispatch?.(sessionId) ?? false + readCommands: NonNullable = (sessionId) => this.liveOwnerOrNull(sessionId)?.readCommands?.(sessionId) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts index a689334c8b43..5b8a9b190ffc 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts @@ -331,6 +331,9 @@ export type StructuredAgentSessionAdapter = { taskId?: string }): Promise<{ cancelled: boolean }> backgroundTaskState?(sessionId: string): AgentSessionBackgroundTaskState | null | undefined + /** The provider reported taking a send it has neither answered nor ended, as a queued follow-up + * or a silent retry does. Derived from the live child; false with none. */ + holdsDispatch?(sessionId: string): boolean /** The `/` surface the running provider reports for itself. Undefined when the * provider never reports one, which is what keeps the client on its catalog. */ readCommands?(sessionId: string): AgentSessionSlashCommand[] | undefined diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-close.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-close.test.ts index bf3cd1f96a60..977b0ff55cfd 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-close.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-close.test.ts @@ -313,6 +313,7 @@ describe('the wind-down retry with a message queued (P2-31)', () => { deliveryActive: () => true, backgroundTaskState: () => undefined, hasOpenDispatch: () => false, + providerHoldsDispatch: () => false, stopAgent, stopStartingAgent: stopAgent, closeConversation: vi.fn(async () => false), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts index f69129183b4a..4ecfea0381b4 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts @@ -74,6 +74,7 @@ export function createStructuredAgentSessionConversationLifetime(host: { const record = deps().store.getRecord(sessionId) return record !== null && deps().hasOpenDispatch?.(record) === true }, + providerHoldsDispatch: (sessionId) => deps().adapter.holdsDispatch?.(sessionId) === true, stopAgent, // A host stop: the delivery loop waiting on this child writes the one error row and rejects // what is queued with it, both worded from the hostStopped fact. diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts index 01143eb4bb47..3055bb2d8789 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts @@ -7,10 +7,7 @@ // admitted without the writer lease; the delivery loop starts the provider child a send needs, and // an operation only the provider can perform starts it before admission. -import type { - AgentJournalItemIdentity, - AgentJournalMessageItem -} from '../../../shared/agent-session-journal-types' +import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' import type { AgentSessionCancelResult, AgentSessionMutationEnvelope, @@ -21,10 +18,7 @@ import type { AgentSessionThreadGoalChange, AgentSessionThreadGoalResult } from '../../../shared/agent-session-wire' -import { - agentSessionFailureWords, - type AgentJournalDispatchRejection -} from '../../../shared/agent-session-failure-words' +import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import type { AgentSessionPromptRequest } from './structured-agent-session-turns-prompt' import { threadGoalPlan } from './structured-agent-session-thread-goal' @@ -32,7 +26,6 @@ import { isMainAgentWorkingOnceFlushed, performCancel } from './structured-agent-session-turns-cancel' -import { structuredAgentSessionConversationFence } from './structured-agent-session-provider-child' import { admitAndRunAgentSessionMutation, type AgentSessionMutationRequest, @@ -281,41 +274,6 @@ export function changeStructuredAgentSessionThreadGoal( ) } -/** Settle provider-proven delivery independently of an in-flight client mutation. */ -export async function settleStructuredAgentSessionLateDispatch( - context: StructuredAgentSessionMutationContext, - input: { - sessionId: string - clientMessageId: string - } & ( - | { providerIdentity: AgentJournalItemIdentity } - | ({ state: 'rejected' } & AgentJournalDispatchRejection) - ) -): Promise { - const session = context.sessions.get(input.sessionId) - if (!session) { - return - } - const fence = structuredAgentSessionConversationFence(context.deps.store, input.sessionId) - // The journal queue drains before close; the host queue would defer this past teardown. - await session.journal.resolveDispatch( - 'providerIdentity' in input - ? { - clientMessageId: input.clientMessageId, - state: 'accepted', - providerIdentity: input.providerIdentity, - fence - } - : { - clientMessageId: input.clientMessageId, - state: 'rejected', - reason: input.reason, - rejection: input.rejection, - fence - } - ) -} - /** The host's thin mutation surface. Each call re-reads the context, so a session * map or fence that moves between calls is never captured by a stale closure. */ export function structuredAgentSessionMutationDelegates( diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts index 79a129118ee0..ae2eb3650f63 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts @@ -34,9 +34,9 @@ import type { StructuredAgentSessionAttachContext } from './structured-agent-ses import * as sessionTabs from './structured-agent-session-host-tabs' import { structuredAgentSessionMutationDelegates, - settleStructuredAgentSessionLateDispatch, type StructuredAgentSessionMutationContext } from './structured-agent-session-host-mutations' +import { settleStructuredAgentSessionLateDispatch } from './structured-agent-session-late-dispatch' import { releaseStructuredAgentSessionUnansweredDispatches } from './structured-agent-session-unanswered-dispatch-release' import { flushStructuredAgentSessionHost } from './structured-agent-session-host-teardown' import type { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.ts index 25288dc64905..1345adc574f7 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.ts @@ -240,6 +240,23 @@ describe('the idle sweep', () => { await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION)) }) + // A Claude retrying a rate-limited request has taken the send but echoes nothing, so no turn row + // exists yet; only the provider can say it still holds the send. + it('never stops an agent while its provider holds a send, and gives a full window once it lets go', async () => { + await foundRestTestChat(rig) + rig.adapter.holdsDispatch.mockReturnValue(true) + rig.clock.now += 2 * IDLE_MS + + await sweepTicks(12) + expect(rig.adapter.closeSession).not.toHaveBeenCalled() + expect(rig.adapter.holdsDispatch).toHaveBeenCalledWith(SESSION) + rig.adapter.holdsDispatch.mockReturnValue(false) + await sweepTicks() + expect(rig.adapter.closeSession).not.toHaveBeenCalled() + rig.clock.now += IDLE_MS + 1 + await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION)) + }) + it('keeps a child an unanswered prompt waits on (P2-22 i)', async () => { await foundRestTestChat(rig) providerEvents().appendItem( @@ -304,6 +321,7 @@ describe('the idle sweep with no child running (P2-22 ii)', () => { deliveryActive: () => false, backgroundTaskState: () => undefined, hasOpenDispatch: () => false, + providerHoldsDispatch: () => false, stopAgent, stopStartingAgent: stopAgent, closeConversation, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.ts index 1fc223808f6b..cdf412a7bcd5 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.ts @@ -30,6 +30,8 @@ export type StructuredAgentSessionIdleSweepDeps = { backgroundTaskState: (sessionId: string) => AgentSessionBackgroundTaskState | null | undefined /** An orchestration dispatch that still owns this session's worker; derived from its database. */ hasOpenDispatch: (sessionId: string) => boolean + /** The provider holds a send it took and has not answered; see `holdsDispatch`. */ + providerHoldsDispatch: (sessionId: string) => boolean /** Each of these runs inside the session's serialize and never takes it again. */ stopAgent: (sessionId: string) => Promise stopStartingAgent: (sessionId: string) => Promise @@ -145,6 +147,7 @@ export class StructuredAgentSessionIdleSweep { this.queuedOrDelivering(sessionId, session) || agentChildWorkLiveness(this.deps.backgroundTaskState(sessionId)?.tasks) !== null || this.deps.hasOpenDispatch(sessionId) || + this.deps.providerHoldsDispatch(sessionId) || hasPendingStructuredAgentSessionPrompt(items) ) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-late-dispatch.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-late-dispatch.ts new file mode 100644 index 000000000000..ba0ab9231261 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-late-dispatch.ts @@ -0,0 +1,49 @@ +import type { AgentJournalItemIdentity } from '../../../shared/agent-session-journal-types' +import type { AgentJournalDispatchRejection } from '../../../shared/agent-session-failure-words' +import type { StructuredAgentSessionMutationContext } from './structured-agent-session-host-mutations' +import { structuredAgentSessionConversationFence } from './structured-agent-session-provider-child' + +/** Settle a provider-proven outcome independently of an in-flight client mutation. `unknown` is + * released doubt: the provider let the send go unanswered, so nothing re-sends it. */ +export async function settleStructuredAgentSessionLateDispatch( + context: StructuredAgentSessionMutationContext, + input: { + sessionId: string + clientMessageId: string + } & ( + | { providerIdentity: AgentJournalItemIdentity } + | ({ state: 'rejected' } & AgentJournalDispatchRejection) + | { state: 'unknown'; reason: string } + ) +): Promise { + const session = context.sessions.get(input.sessionId) + if (!session) { + return + } + const fence = structuredAgentSessionConversationFence(context.deps.store, input.sessionId) + // The journal queue drains before close; the host queue would defer this past teardown. + await session.journal.resolveDispatch( + 'providerIdentity' in input + ? { + clientMessageId: input.clientMessageId, + state: 'accepted', + providerIdentity: input.providerIdentity, + fence + } + : input.state === 'unknown' + ? { + clientMessageId: input.clientMessageId, + state: 'unknown', + reason: input.reason, + fence, + recovered: true + } + : { + clientMessageId: input.clientMessageId, + state: 'rejected', + reason: input.reason, + rejection: input.rejection, + fence + } + ) +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-rest-test-rig.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-rest-test-rig.ts index 0c890767ac22..9e5a136d4a45 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-rest-test-rig.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-rest-test-rig.ts @@ -45,6 +45,7 @@ export type RestTestAdapter = { NonNullable > backgroundTaskState: Mock> + holdsDispatch: Mock> readOptions: Mock> } @@ -146,6 +147,7 @@ export async function createRestTestRig( dispatch: vi.fn(async () => acceptedDispatch()), acknowledgeSessionRelease: vi.fn(), backgroundTaskState: vi.fn(() => undefined), + holdsDispatch: vi.fn(() => false), readOptions: vi.fn(async () => ({ models: [], current: { model: 'gpt-live' } })) } const hostFor = (overrides: Partial) => diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-unanswered-dispatch-release.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-unanswered-dispatch-release.ts index 713838c177ec..62078ef853c6 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-unanswered-dispatch-release.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-unanswered-dispatch-release.ts @@ -3,7 +3,7 @@ import type { StructuredAgentSessionHostDeps } from './structured-agent-session- import type { StructuredAgentSessionMutationContext } from './structured-agent-session-host-mutations' /** - * Releases sends the provider can no longer be holding. + * Releases doubted sends once the provider reports it has stopped running. * * A dispatch whose RPC timed out is recorded `unknown` — doubt, never proof of * non-delivery — and a live `unknown` reads as work still owed, so the session diff --git a/src/main/runtime/claude-structured-idle-releases-unknown.test.ts b/src/main/runtime/claude-structured-idle-releases-unknown.test.ts index becdc9f2b389..9fa9cbfcfa6a 100644 --- a/src/main/runtime/claude-structured-idle-releases-unknown.test.ts +++ b/src/main/runtime/claude-structured-idle-releases-unknown.test.ts @@ -1,12 +1,17 @@ // A Claude send whose write ended in doubt is recorded `unknown`, and a live `unknown` reads as -// work still owed. The CLI reports `session_state_changed idle` only once its queue has drained, -// so that report retires the doubt; a send whose dispatch is still `pending` is left alone. +// work still owed. The CLI reporting `session_state_changed idle` retires the doubt; a send whose +// dispatch is still `pending` is left alone unless the CLI had started it. // Against the production runtime, adapter, record store and host, with only the CLI scripted. import { afterEach, describe, expect, it, vi } from 'vitest' import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-session-mutation-envelope' import { claudeSessionIdForOrcaSession } from '../claude/claude-structured-launch-resolution' -import { DISPATCH_DOUBT_WRITE_OUTCOME_UNKNOWN } from '../native-chat/agent-session-journal/journal-dispatch-doubt-reasons' +import { + DISPATCH_DOUBT_PROVIDER_ENDED_UNANSWERED, + DISPATCH_DOUBT_PROVIDER_IDLE, + DISPATCH_DOUBT_WRITE_OUTCOME_UNKNOWN +} from '../native-chat/agent-session-journal/journal-dispatch-doubt-reasons' +import { hasUnansweredStructuredAgentSessionDispatch } from '../../shared/structured-agent-session-unanswered-dispatch' import { hostTestMessage } from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' import type { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' import { createScriptedClaudeRuntime } from './structured-claude-scripted-runtime-test-support' @@ -106,3 +111,81 @@ describe('a live Claude chat whose CLI reports idle', () => { expect((await submission(host, pending))?.recovered).toBeUndefined() }) }) + +function lifecycle(commandUuid: string, state: string): Record { + return { + type: 'command_lifecycle', + command_uuid: commandUuid, + state, + uuid: `lifecycle-${state}`, + session_id: PROVIDER_SESSION + } +} + +describe('a live Claude chat whose CLI took a send and let it go without an echo', () => { + it.each([ + ['its turn ended cancelled', 'cancelled', DISPATCH_DOUBT_PROVIDER_ENDED_UNANSWERED], + ['it went idle with no terminal state', 'idle', DISPATCH_DOUBT_PROVIDER_IDLE] + ])('records released doubt when %s', async (_label, ending, reason) => { + const host = await claude.install() + await expect(host.attach(CALLER, claude.attachParams(SESSION, null))).resolves.toMatchObject({ + ok: true + }) + const child = claude.child(SESSION) + let sentUuid = '' + child.connection.send = async (message, beforeDispatch) => { + await beforeDispatch?.() + sentUuid = String(message.uuid) + } + const taken = await send(host, 'retrying') + // The pending row lands before the adapter's write; the write is what names the uuid. + await vi.waitFor(() => expect(sentUuid).not.toBe('')) + expect(await submission(host, taken)).toMatchObject({ dispatchState: 'pending' }) + child.handlers.onMessage?.(lifecycle(sentUuid, 'queued')) + child.handlers.onMessage?.(lifecycle(sentUuid, 'started')) + expect(host.deps.adapter.holdsDispatch?.(SESSION)).toBe(true) + + child.handlers.onMessage?.( + ending === 'idle' ? sessionState('idle') : lifecycle(sentUuid, ending) + ) + + await vi.waitFor(async () => + expect(await submission(host, taken)).toMatchObject({ + dispatchState: 'unknown', + recovered: true, + reason + }) + ) + expect(host.deps.adapter.holdsDispatch?.(SESSION)).toBe(false) + // Released doubt: the chat stops reading working, and nothing re-sends it. + expect( + hasUnansweredStructuredAgentSessionDispatch((await host.journalSnapshot(SESSION)).submissions) + ).toBe(false) + }) + + it('holds a send it has only queued, through an idle that may precede its start', async () => { + const host = await claude.install() + await expect(host.attach(CALLER, claude.attachParams(SESSION, null))).resolves.toMatchObject({ + ok: true + }) + const child = claude.child(SESSION) + let sentUuid = '' + child.connection.send = async (message, beforeDispatch) => { + await beforeDispatch?.() + sentUuid = String(message.uuid) + } + const queued = await send(host, 'behind a turn') + await vi.waitFor(() => expect(sentUuid).not.toBe('')) + // POSITIVE CONTROL: written but not yet taken, nothing holds the child. + expect(host.deps.adapter.holdsDispatch?.(SESSION)).toBe(false) + + child.handlers.onMessage?.(lifecycle(sentUuid, 'queued')) + expect(host.deps.adapter.holdsDispatch?.(SESSION)).toBe(true) + child.handlers.onMessage?.(sessionState('idle')) + await new Promise((resolve) => setTimeout(resolve, 50)) + + expect(host.deps.adapter.holdsDispatch?.(SESSION)).toBe(true) + expect(await submission(host, queued)).toMatchObject({ dispatchState: 'pending' }) + expect((await submission(host, queued))?.recovered).toBeUndefined() + }) +}) diff --git a/src/main/runtime/structured-agent-session-runtime.ts b/src/main/runtime/structured-agent-session-runtime.ts index 82c9803e2a76..3c306b629df2 100644 --- a/src/main/runtime/structured-agent-session-runtime.ts +++ b/src/main/runtime/structured-agent-session-runtime.ts @@ -232,7 +232,7 @@ async function installOnJournal( }) ) } - // The provider going idle is what re-derives a doubted send: it can no longer be holding it. + // The provider going idle is what re-derives a doubted send; a late echo still accepts it. const releaseUnansweredDispatches = ({ sessionId }: { sessionId: string }): void => { void host ?.releaseUnansweredDispatches({