Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 0 additions & 19 deletions mobile/src/session/MobileNativeChatTurnStatus.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,25 +75,6 @@ describe('MobileNativeChatTurnStatus', () => {
expect(onToggleExpanded).toHaveBeenCalledOnce()
})

// The crash's notice row explains the cut; the turn bar reads like any finished turn.
it('heads a turn a crash cut off as worked, and a turn the user stopped as interrupted', () => {
const crashed = render({
startedAt: Date.now(),
workedSeconds: 12,
verdict: 'interruption',
onToggleExpanded: vi.fn()
})
expect(labels(crashed.root)).toEqual(['Worked for 12s'])
act(() => crashed.unmount())
const stopped = render({
startedAt: Date.now(),
workedSeconds: 12,
verdict: 'cancellation',
onToggleExpanded: vi.fn()
})
expect(labels(stopped.root)).toEqual(['Interrupted after 12s'])
})

it('stays a plain row when the settled turn has nothing to disclose', () => {
const tree = render({ startedAt: Date.now(), workedSeconds: 5 })
expect(tree.root.findAllByType('Pressable' as never)).toHaveLength(0)
Expand Down
11 changes: 4 additions & 7 deletions mobile/src/worktree/agent-row-display.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,12 +63,12 @@ describe('agentDotState', () => {
)
})

it('reads a crash-cut turn as failed and an unproven end as unconfirmed', () => {
it('reads an Orca-cut turn as interrupted and an unproven end as unconfirmed', () => {
expect(agentDotState(row({ state: 'done', ...mainAgentDone('interruption') }), 0)).toBe(
'failed'
'interrupted'
)
expect(agentDisplayLabel(row({ state: 'done', ...mainAgentDone('interruption') }), 0)).toBe(
'Failed'
'Interrupted'
)
expect(agentDotState(row({ state: 'done', ...mainAgentDone('unconfirmed') }), 0)).toBe(
'unconfirmed'
Expand All @@ -88,9 +88,6 @@ describe('agentDotState', () => {
it('shows a main agent that failed while its subagents still run as failed', () => {
expect(agentDotState(row({ state: 'working', ...mainAgentDone('failure') }), 0)).toBe('failed')
expect(agentDotState(row({ state: 'waiting', ...mainAgentDone('failure') }), 0)).toBe('failed')
expect(agentDotState(row({ state: 'working', ...mainAgentDone('interruption') }), 0)).toBe(
'failed'
)
// Only a fault outranks live work; a success or a stop with live subagents reads working.
expect(agentDotState(row({ state: 'working', ...mainAgentDone('success') }), 0)).toBe('working')
expect(
Expand Down Expand Up @@ -143,7 +140,7 @@ describe('agentDotState', () => {
expect(agentDotState(row({ state: 'done', updatedAt: 0 }), stale)).toBe('done')
expect(
agentDotState(row({ state: 'done', updatedAt: 0, ...mainAgentDone('interruption') }), stale)
).toBe('failed')
).toBe('interrupted')
})
})

Expand Down
7 changes: 3 additions & 4 deletions mobile/src/worktree/agent-row-display.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,18 +49,17 @@ export function agentRowVerdict(row: AgentRowVerdictSource): AgentTurnOutcome |
return row.state === 'done' && row.interrupted ? 'cancellation' : null
}

// A fault (a failure, or a turn cut short by something other than the user) reads failed and
// outranks every state; a user's Stop, a turn a newer request replaced, and an unproven end mark
// only a row that is itself done.
// A failure reads failed and outranks every state; a turn cut short (a user's Stop, a newer
// request, Orca quitting or crashing) and an unproven end mark only a row that is itself done.
export function agentRowVerdictMark(
row: AgentRowVerdictSource
): 'failed' | 'interrupted' | 'unconfirmed' | null {
switch (agentRowVerdict(row)) {
case 'failure':
case 'interruption':
return 'failed'
case 'cancellation':
case 'superseded':
case 'interruption':
return row.state === 'done' ? 'interrupted' : null
case 'unconfirmed':
return row.state === 'done' ? 'unconfirmed' : null
Expand Down
6 changes: 3 additions & 3 deletions src/main/acp/acp-structured-host-crash.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,16 +71,16 @@ function expectOneTurnScopedCutShortRow(settled: Awaited<ReturnType<typeof settl
}

describe('a Grok crash with its exit seen first', () => {
it('ends the turn at the exit itself; the provider-exit batch only reports on it', async () => {
it('ends the turn at the exit itself; the provider-exit batch only adds its failure', async () => {
const rig = await midReply()
rig.child.exit()
const settled = await settledCrash(rig)
expectOneTurnScopedCutShortRow(settled)
// The child's own end landed first, interrupted at the exit and never `unverifiable`: the host
// has no turn or call of it left to revise.
// only records that end as Grok's failure.
expect(
settled.batch.mutations.map((mutation) => mutation.kind === 'item' && mutation.body.kind)
).toEqual(['status'])
).toEqual(['status', 'turn'])
await rig.host.close(SESSION, 'user-close')
})
})
Expand Down
7 changes: 3 additions & 4 deletions src/main/ipc/notification-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,14 +90,13 @@ function formatAgentNotificationStatusText(
return translate('notifications.agentStatus.finished', 'finished')
}
switch (args.agentTurnOutcome) {
// A turn cut short by anything but the user is a fault, as a failure is.
case 'failure':
case 'interruption':
return translate('notifications.agentStatus.failed', 'failed')
// Why: a Stop the user asked for, a turn a newer request replaced, or an end Orca cannot
// prove, still never reads finished.
// Why: a Stop the user asked for, a turn a newer request replaced or Orca cut off, or an end
// Orca cannot prove, still never reads finished.
case 'cancellation':
case 'superseded':
case 'interruption':
case 'unconfirmed':
return translate('notifications.agentStatus.stopped', 'stopped')
case 'success':
Expand Down
3 changes: 1 addition & 2 deletions src/main/ipc/notifications-message-formatting.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -319,8 +319,7 @@ describe('registerNotificationHandlers', () => {
it.each([
{ agentTurnOutcome: 'cancellation', word: 'stopped' },
{ agentTurnOutcome: 'failure', word: 'failed' },
// A turn cut short by anything but the user is a fault, worded as one.
{ agentTurnOutcome: 'interruption', word: 'failed' },
{ agentTurnOutcome: 'interruption', word: 'stopped' },
{ agentTurnOutcome: 'unconfirmed', word: 'stopped' },
{ agentTurnOutcome: 'superseded', word: 'stopped' },
{ agentTurnOutcome: 'success', word: 'finished' },
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
// The one handler that ends a provider child's record, whether its exit was expected (a close this
// host asked for) or not (the child died on its own). The exit was seen or proven first-hand, so
// every step after it is bookkeeping: each is attempted and reported, none keeps the child on
// record, and the record ends in `finally`. `expected` changes only what the chat is told.
// record, and the record ends in `finally`. `expected` changes only what the chat is told; the
// exit's failure fact says whether the turn it cut is the provider's failure.

import {
agentSessionFailureFact,
Expand Down Expand Up @@ -221,7 +222,9 @@ export async function endExitedStructuredAgentSessionChildUnderSerialize<
? { exitedDuringStartup: { generation: child.generation } }
: {}),
exit: watched,
...(unrunRejection ? { unrunRejection } : {})
...(unrunRejection ? { unrunRejection } : {}),
// An exit with no fact is the provider's own, as `providerEndFailure` reads it; Orca's cut is not.
providerFailed: !expected && (exit.failure?.kind ?? 'providerExited') === 'providerExited'
})
if (!settled.ok) {
logExitFailure(context, sessionId, 'exit-settlement', settled.error)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,6 @@ async function settledTurn() {
return { turn, settled, label, notices }
}

/** A turn nobody stopped reads like a finished one, with exactly one row saying it stopped. */
const ONE_NOTICE = [
'Codex stopped while this response was in progress. You can continue in this conversation.'
]
Expand Down Expand Up @@ -166,8 +165,7 @@ describe('a turn cut short by closing its provider', () => {
const { turn, label, notices } = await settledTurn()
expect(turn).toMatchObject({ state: 'interrupted' })
expect(turn).not.toHaveProperty('outcome')
// News for the sidebar; the turn reads like a finished one beside its one notice.
expect(label).toBe('workedFor')
expect(label).toBe('interruptedAfter')
expect(notices).toEqual(ONE_NOTICE)
})

Expand Down Expand Up @@ -261,7 +259,7 @@ describe('a turn cut short by closing its provider', () => {
session: { status: 'idle', turnOutcome: 'interruption' }
})
const { label, notices } = await settledTurn()
expect(label).toBe('workedFor')
expect(label).toBe('interruptedAfter')
expect(notices).toEqual(ONE_NOTICE)
})

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -228,7 +228,6 @@ describe('a turn a crash cut short mid-tool', () => {
expect(completedStructuredAgentTurnSeconds(timing)).toBe(27)
})

// The turn bar reads like a finished turn, so this row is the one place the chat says why.
it('explains the cut once, with one notice row and a turn bar that does not repeat it', async () => {
openHost({ probeOwner: async () => ({ outcome: 'pid-absent' }) })

Expand All @@ -253,7 +252,7 @@ describe('a turn a crash cut short mid-tool', () => {
workedSeconds: completedStructuredAgentTurnSeconds(timing),
verdict: timing?.verdict
})
).toEqual({ key: 'workedFor', duration: '27s' })
).toEqual({ key: 'interruptedAfter', duration: '27s' })
})

it('ends at the pre-crash renewal when the child outlived Orca and recovery stopped it', async () => {
Expand Down Expand Up @@ -333,7 +332,7 @@ describe('a turn a read reached before the reconcile proved its owner dead', ()
unsubscribe()
})

it('reports the open to the status feed as an interruption, which the chat folds as worked', async () => {
it('reports the open to the status feed as an interruption, which the chat reads as interrupted', async () => {
const published: AgentSessionStatusSummary[] = []
openHost({
probeOwner: async () => ({ outcome: 'pid-absent' }),
Expand All @@ -344,7 +343,6 @@ describe('a turn a read reached before the reconcile proved its owner dead', ()
published
.filter((summary) => summary.sessionId === SESSION && summary.turnOutcome)
.map((summary) => summary.turnOutcome)
// The sidebar's red Failed until seen; the turn folds as "Worked for 27s" beside its notice row.
expect(outcomes()).toEqual(['interruption'])

await host.reconcileRestartLeases()
Expand All @@ -360,7 +358,7 @@ describe('a turn a read reached before the reconcile proved its owner dead', ()
workedSeconds: completedStructuredAgentTurnSeconds(timing),
verdict: timing?.verdict
})
).toEqual({ key: 'workedFor', duration: '27s' })
).toEqual({ key: 'interruptedAfter', duration: '27s' })
})

it('explains the turn it cut, so the cut reads once and offers Continue', async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import type { AgentSessionJournal } from '../agent-session-journal/journal-store
import { settleStructuredAgentSessionDeadGeneration } from './structured-agent-session-dead-generation-settlement'
import {
captureUnfinishedStructuredAgentSessionWork,
type DeadGenerationJournal,
unfinishedStructuredAgentSessionWorkWasInterrupted
} from './structured-agent-session-unfinished-work'
import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support'
Expand Down Expand Up @@ -277,19 +278,14 @@ describe('dead structured-session generation settlement', () => {
}
}
const settledSnapshot = journal.snapshot()
const closedJournal: Pick<
AgentSessionJournal,
'snapshot' | 'submissions' | 'rejectQueuedSubmissions' | 'appendLifecycleBatch' | 'itemFence'
> = {
const closedJournal: DeadGenerationJournal = {
itemFence: () => undefined,
stopMarks: journal.stopMarks,
snapshot: () => ({
...settledSnapshot,
items: [settledItem]
}),
submissions: () => [],
rejectQueuedSubmissions: async () => {
throw new Error('journal_closed')
},
appendLifecycleBatch: async () => {
throw new Error('journal_closed')
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import {
provenUnverifiedToolCallRevisions,
runningTurnLifecycleRevisions,
lastProvenTurnLiveAt,
providerExitFailureRevision,
turnVerdictFromDeathEvidence,
turnVerdictFromReplacedRuntime,
watchedExitRevisions,
Expand Down Expand Up @@ -89,6 +90,8 @@ export async function settleStructuredAgentSessionDeadGeneration(input: {
exit?: StructuredAgentSessionWatchedExit
/** A person's Stop ended a starting child before what it was handed could run: each is rejected so. */
unrunRejection?: SubmissionRejectionFact
/** The provider exited on its own: the turn its exit row reports on is the provider's failure. */
providerFailed?: boolean
}): Promise<StructuredAgentSessionDeadGenerationSettlement> {
try {
const hasUnfinishedWork = hasUnfinishedStructuredAgentSessionWork(input.journal, input.exit)
Expand Down Expand Up @@ -121,6 +124,7 @@ export async function settleStructuredAgentSessionDeadGeneration(input: {
const items = input.journal.snapshot().items
const proven = watchedExitRevisions(items, input.exit, input.journal)
const mutations: JournalLifecycleMutationInput[] = []
let exitedTurnItemId: string | undefined
if (showUnexpectedExitOutcome && input.exitedDuringStartup && startupFailure) {
const startKey = input.exitedDuringStartup.generation ?? input.settlementId
// A start a message waited on is the delivery loop's to record, before or after this exit,
Expand All @@ -133,6 +137,8 @@ export async function settleStructuredAgentSessionDeadGeneration(input: {
mutations.push(structuredAgentSessionStartFailureRow(startKey, startupFailure))
}
} else if (showUnexpectedExitOutcome) {
const turnScope = exitedRootTurnScope(withRevisions(items, proven), input.verdict)
exitedTurnItemId = turnScope.kind === 'turn' ? turnScope.turnItemId : undefined
// The turn the exit ended, and an error so no fold ever hides why it stopped.
mutations.push({
kind: 'item',
Expand All @@ -148,7 +154,7 @@ export async function settleStructuredAgentSessionDeadGeneration(input: {
),
tone: 'error'
},
turnScope: exitedRootTurnScope(withRevisions(items, proven), input.verdict)
turnScope
})
}
const bodies = new Map(items.map((item) => [item.itemId, item.body]))
Expand All @@ -166,7 +172,19 @@ export async function settleStructuredAgentSessionDeadGeneration(input: {
})
}
}
mutations.push(...runningTurnLifecycleRevisions(items, input.verdict), ...proven)
const turnEnds = [...runningTurnLifecycleRevisions(items, input.verdict), ...proven]
const failed =
input.providerFailed && exitedTurnItemId !== undefined
? providerExitFailureRevision(
withRevisions(items, turnEnds).find((item) => item.itemId === exitedTurnItemId),
input.journal
)
: null
const failedId = failed && journalLifecycleMutationItemId(failed)
mutations.push(
...turnEnds.filter((end) => journalLifecycleMutationItemId(end) !== failedId),
...(failed ? [failed] : [])
)
await input.journal.appendLifecycleBatch({
settlementId: `dead-generation:${input.settlementId}`,
fence: input.fence,
Expand Down
Loading
Loading