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.
This commit is contained in:
Brennan Benson
2026-08-28 00:26:43 -07:00
parent 93b24a236f
commit 3f378abcbc
4 changed files with 71 additions and 13 deletions
+5
View File
@@ -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 () => {
@@ -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<string, Pick<Automation, 'runContext' | 'sourceContext'>>()
const promptByAutomationId = new Map<string, string>()
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) {
+49
View File
@@ -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'
+14 -6
View File
@@ -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
}