fix(native-chat): a chat the user closed while its agent started is not a failed start

A still-starting child the user's close cut counted as a failed start, since only 'user-stop' was excluded: a start-failure row, and queued messages rejected as a provider failure. Whether an ending fails its start is now one exhaustive switch, shared by the failed-start read and the delivery loop's handover: a user's stop or close never does; an exit, a failed attach and the host's own stops still do.
This commit is contained in:
Brennan Benson
2026-09-27 20:35:26 -07:00
parent ae4ad9bab9
commit 876b6989f1
3 changed files with 40 additions and 5 deletions
@@ -26,7 +26,10 @@ import {
oldestQueuedSubmission,
recordStructuredAgentSessionStartFailure
} from './structured-agent-session-start-failure-row'
import { failedProviderChildStart } from './structured-agent-session-provider-child'
import {
childEndFailsItsStart,
failedProviderChildStart
} from './structured-agent-session-provider-child'
import { handOverSubmission } from './structured-agent-session-turns'
export type StructuredAgentSessionDeliveryLoopDeps = {
@@ -171,9 +174,9 @@ export class StructuredAgentSessionDeliveryLoop {
if (!awaitedChild || (awaitedChild.phase === 'starting' && startFailure !== null)) {
// The child waited on is gone, replaced by another, or settled its start without proving it.
const ended = awaitedChild ? undefined : session.lastEndedChild
// A user's Stop is not a failure: the next step starts, or waits on, a child for what is
// queued. A host stop is: its cause is why the start did not land.
if (ended?.cause === 'user-stop') {
// A user's Stop or close is not a failure: the next step starts, or waits on, a child for
// what is queued. A host stop is: its cause is why the start did not land.
if (ended && !childEndFailsItsStart(ended.cause)) {
return 'continue'
}
return this.fail(sessionId, {
@@ -672,6 +672,22 @@ describe('how a stopped child ends the start its loop was waiting on', () => {
expect(statusRows()).toEqual([])
})
it('records no failure when the user closed the chat while its child started', async () => {
// The close's stop alone: a close that aborts after it leaves the conversation indexed.
const second = await stoppedWhileStarting(() =>
host['serialize'](SESSION, () =>
stopStructuredAgentSessionAgentUnderSerialize(host['lifetimeContext'](), SESSION, {
cause: 'user-close'
})
)
)
await settleLoop()
expect(conversation()?.lastEndedChild).toMatchObject({ cause: 'user-close' })
expect(submission(second)?.dispatchState).not.toBe('rejected')
expect(statusRows()).toEqual([])
})
it('fails the start after a host stop, with the stop as the reason (R2)', async () => {
const reason = 'Claude never finished starting, so Orca stopped it.'
const second = await stoppedWhileStarting(() =>
@@ -8,6 +8,7 @@
import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store'
import type { AgentSessionJournal } from '../agent-session-journal/journal-store'
import type {
StructuredAgentSessionChildEndCause,
StructuredAgentSessionEndedChild,
StructuredAgentSessionHostSession,
StructuredAgentSessionProviderChild,
@@ -64,7 +65,22 @@ export function failedProviderChildStart(
session: Pick<ChildBearer, 'child' | 'lastEndedChild'>
): StructuredAgentSessionEndedChild | null {
const ended = session.lastEndedChild
return !session.child && ended?.duringStartup && ended.cause !== 'user-stop' ? ended : null
return !session.child && ended?.duringStartup && childEndFailsItsStart(ended.cause) ? ended : null
}
/** Whether a child that ended this way, still starting, failed its start. A stop the user aimed at
* this chat is their cancellation, never a failure; a host's own stop is why the start did not land. */
export function childEndFailsItsStart(cause: StructuredAgentSessionChildEndCause): boolean {
switch (cause) {
case 'user-stop':
case 'user-close':
return false
case 'exit':
case 'attach-failed':
case 'host-stop':
case 'evict':
return true
}
}
function matchingChild(