From 3f378abcbc2b6070f970124391a491f12a458422 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 28 Aug 2026 00:26:16 -0700 Subject: [PATCH] fix(automations): match recovery turn evidence by run-id marker, not prompt body A reused occurrence run keeps the dispatchPromptPreview built at creation, while a later dispatch submits the current (possibly edited) automation prompt. Exact-equality matching then pins recovery to a prompt that never ran and disables the promptless fallback, so the run dies as 'agent never started' despite the agent working. Turn identity is the embedded ORCA_AUTOMATION_RUN_ID marker: match evidence by marker run-id equality (a superset of exact equality for marker prompts), and stop backfilling legacy previews from the current automation prompt -- absence now routes those runs through the time-based fallback instead of a prompt nobody proved was submitted. --- src/main/persistence-automations.test.ts | 5 ++ .../automation-context-migration.ts | 10 ++-- src/main/runtime/orca-runtime.test.ts | 49 +++++++++++++++++++ src/main/runtime/orca-runtime.ts | 20 +++++--- 4 files changed, 71 insertions(+), 13 deletions(-) diff --git a/src/main/persistence-automations.test.ts b/src/main/persistence-automations.test.ts index 9db16b7e681..6604b7af29a 100644 --- a/src/main/persistence-automations.test.ts +++ b/src/main/persistence-automations.test.ts @@ -420,6 +420,7 @@ describe('Store', () => { delete persisted.automations[0].sourceContext delete persisted.automationRuns[0].runContext delete persisted.automationRuns[0].sourceContext + delete persisted.automationRuns[0].dispatchPromptPreview writeDataFile(persisted) const reloaded = await createStore() @@ -449,6 +450,10 @@ describe('Store', () => { }) expect(migratedRun?.runContext).toEqual(migratedAutomation?.runContext) expect(migratedRun?.sourceContext).toEqual(migratedAutomation?.sourceContext) + // The current automation prompt is not evidence of what a legacy run + // submitted — a derived preview would pin recovery to a prompt that never + // ran. Absence routes recovery through the time-based fallback instead. + expect(migratedRun?.dispatchPromptPreview).toBeUndefined() }) it('shrinks an oversized automationRuns file on load without any later mutation', async () => { diff --git a/src/main/persistence/scheduling-automations/automation-context-migration.ts b/src/main/persistence/scheduling-automations/automation-context-migration.ts index 1555222e517..e23849b3daa 100644 --- a/src/main/persistence/scheduling-automations/automation-context-migration.ts +++ b/src/main/persistence/scheduling-automations/automation-context-migration.ts @@ -17,7 +17,6 @@ import { } from '../../../shared/task-source-context' import { getRepoExecutionHostId, parseExecutionHostId } from '../../../shared/execution-host' import { parsePaneKey } from '../../../shared/stable-pane-id' -import { normalizePromptField } from '../../../shared/agent-status-field-normalization' export function normalizeAutomationRunWorkspaceDisplayName(value: string | null): string | null { const trimmed = value?.trim() @@ -183,7 +182,6 @@ export function backfillLegacyAutomationContexts( } { let changed = false const contextsByAutomationId = new Map>() - const promptByAutomationId = new Map() const reposById = new Map((state.repos ?? []).map((repo) => [repo.id, repo])) const automations = (state.automations ?? []).map((automation) => { const contexts = getAutomationContextsForRepo( @@ -204,7 +202,6 @@ export function backfillLegacyAutomationContexts( runContext: next.runContext ?? null, sourceContext: next.sourceContext ?? null }) - promptByAutomationId.set(next.id, normalizePromptField(next.prompt)) return next }) const automationRuns = (state.automationRuns ?? []).map((run) => { @@ -226,10 +223,9 @@ export function backfillLegacyAutomationContexts( next.terminalPtyId = null changed = true } - if (!Object.hasOwn(next, 'dispatchPromptPreview')) { - next.dispatchPromptPreview = promptByAutomationId.get(run.automationId) - changed = true - } + // No dispatchPromptPreview backfill: the automation's current prompt is not + // evidence of what a legacy run submitted, and a wrong preview pins recovery + // to a prompt that never ran. Absent preview → time-based recovery fallback. return next }) if (!changed) { diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index f4d9cb60951..5e94b53af4f 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -38107,6 +38107,55 @@ describe('OrcaRuntimeService', () => { ).toBe(false) }) + it('accepts same-run marker evidence after the automation prompt body was edited', async () => { + // A reused run keeps the preview built at creation while a later dispatch + // submits the edited automation prompt. The run-id marker, not the body, + // is the turn identity — recovery must still find the turn. + const observedAfter = Date.now() - 1_000 + const leafId = '11111111-2222-4333-8444-555555555555' + const expectedPrompt = buildAutomationTurnPrompt('automation prompt', 'run-1') + const runtime = new OrcaRuntimeService(store, undefined, { + getAgentStatusSnapshot: () => [ + { + state: 'done', + prompt: buildAutomationTurnPrompt('edited automation prompt', 'run-1'), + agentType: 'codex', + paneKey: `tab-1:${leafId}`, + connectionId: null, + receivedAt: observedAfter + 100, + stateStartedAt: observedAfter + 100, + turnCompletedAt: observedAfter + 100 + } + ] + }) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { + tabs: [ + { + tabId: 'tab-1', + worktreeId: 'repo-1::/tmp/worktree-a', + title: 'Codex', + activeLeafId: leafId, + layout: null + } + ], + leaves: [ + { + tabId: 'tab-1', + worktreeId: 'repo-1::/tmp/worktree-a', + leafId, + paneRuntimeId: 1, + ptyId: 'pty-1' + } + ] + }) + const [terminal] = (await runtime.listTerminals()).terminals + + expect( + runtime.hasTerminalAgentWorkedSince(terminal.handle, observedAfter, expectedPrompt, 'run-1') + ).toBe(true) + }) + it('rejects a stale clock-ahead remote completion before a reused dispatch', async () => { const observedAfter = Date.now() const leafId = '11111111-2222-4333-8444-555555555555' diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index ac850311c32..52534af48f5 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -19665,14 +19665,23 @@ export class OrcaRuntimeService { expectedPrompt && expectedTurnId ? isAutomationTurnPrompt(expectedPrompt, expectedTurnId) : false + // Turn identity is the embedded run-id marker, not body equality: a reused + // run's later dispatch can submit an edited prompt body under the same run id. + const matchesExpectedTurn = (prompt: string | null | undefined): boolean => + Boolean( + hasTurnIdentity && + expectedTurnId && + typeof prompt === 'string' && + isAutomationTurnPrompt(prompt, expectedTurnId) + ) const liveHookEvidence = paneKey ? this.automationHookTurnEvidenceByPaneKey.get(paneKey) : undefined - const liveEvidenceMatchesTurn = hasTurnIdentity && liveHookEvidence?.prompt === expectedPrompt + const liveEvidenceMatchesTurn = matchesExpectedTurn(liveHookEvidence?.prompt) if ( liveHookEvidence?.startedAt !== null && liveHookEvidence?.startedAt !== undefined && - (!expectedPrompt || liveHookEvidence.prompt === expectedPrompt) + (!expectedPrompt || liveHookEvidence.prompt === expectedPrompt || liveEvidenceMatchesTurn) ) { if (liveEvidenceMatchesTurn || liveHookEvidence.startedAt >= observedAfter) { startedAt = Math.max(startedAt ?? observedAfter, liveHookEvidence.startedAt) @@ -19698,8 +19707,7 @@ export class OrcaRuntimeService { entry.observation.origin === 'hook' && entry.observation.replayedAt !== undefined && entry.observation.replayedAt > observedAfter - const matchesTurnIdentity = - hasTurnIdentity && expectedPrompt !== null && entry.prompt === expectedPrompt + const matchesTurnIdentity = matchesExpectedTurn(entry.prompt) if ( entry.observation?.kind === 'snapshot' && entry.state !== 'working' && @@ -19711,7 +19719,7 @@ export class OrcaRuntimeService { if ( entry.state === 'working' && (matchesTurnIdentity || entry.stateStartedAt >= observedAfter) && - (!expectedPrompt || entry.prompt === expectedPrompt) + (!expectedPrompt || entry.prompt === expectedPrompt || matchesTurnIdentity) ) { startedAt = Math.max(startedAt ?? observedAfter, entry.stateStartedAt) } @@ -19720,7 +19728,7 @@ export class OrcaRuntimeService { entry.sessionBoundary !== true && (entry.turnCompletedAt !== undefined || replayedCompletion || matchesTurnIdentity) && (matchesTurnIdentity || entry.receivedAt > observedAfter) && - (!expectedPrompt || entry.prompt === expectedPrompt) + (!expectedPrompt || entry.prompt === expectedPrompt || matchesTurnIdentity) ) { completedFromHook = true }