mirror of
https://github.com/stablyai/orca.git
synced 2026-10-24 00:04:01 +00:00
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:
@@ -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) {
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user