From e4317c86831016567cf1fb0fe1a5e1a89f95ddd0 Mon Sep 17 00:00:00 2001 From: Dotta Date: Sun, 27 Sep 2026 15:08:09 -0500 Subject: [PATCH] fix(ui): distinguish finalization recovery from live observation Co-Authored-By: Paperclip --- doc/execution-semantics.md | 8 +++++ ui/src/lib/recovery-display.test.ts | 53 +++++++++++++++++++++++++++++ ui/src/lib/recovery-display.ts | 15 +++++--- ui/src/lib/recovery-lineage.ts | 13 +++++-- 4 files changed, 81 insertions(+), 8 deletions(-) diff --git a/doc/execution-semantics.md b/doc/execution-semantics.md index 4674753f237..48cf7fc3461 100644 --- a/doc/execution-semantics.md +++ b/doc/execution-semantics.md @@ -1456,3 +1456,11 @@ Contracts reference the existing brief and answers instead of copying them again Resumed sessions keep the existing message-delta path; fresh sessions receive the full covered history. Stable wording and bounded references avoid adding another full brief on each comment, but provider cache hits must be measured separately. + +### Native finalization recovery display + +A native finalization retry uses the existing recovery record but does not imply +that an agent turn is running. Task and inbox surfaces show recovery in progress +only while its recorded retry is still due or its matching retry run is verified +live. An expired or missing retry, exhausted budget, or board-owned failure shows +recovery needed rather than “Observing active run.” diff --git a/ui/src/lib/recovery-display.test.ts b/ui/src/lib/recovery-display.test.ts index 8e3e03ef121..99434133ec8 100644 --- a/ui/src/lib/recovery-display.test.ts +++ b/ui/src/lib/recovery-display.test.ts @@ -117,6 +117,59 @@ describe("deriveRecoveryDisplayState", () => { }, ); + describe("native finalization recovery", () => { + const now = Date.parse("2026-09-27T02:00:00.000Z"); + const action = { + ...base, + kind: "active_run_watchdog" as const, + ownerType: "agent" as const, + cause: "native_finalization_invalid", + attemptCount: 1, + maxAttempts: 3, + evidence: { runId: "finalizing-run", coordinatorAttempt: 1 }, + wakePolicy: { + kind: "resume_native_run", + runId: "finalizing-run", + notBefore: "2026-09-27T01:48:53.988Z", + }, + }; + + it("shows the missed PAP-64 finalization retry as recovery needed", () => { + const state = deriveRecoveryDisplayState(action, { now }); + expect(state).toBe("needed"); + expect(recoveryChipLabel(state as "needed", action.kind)).toBe("Recovery needed"); + }); + + it("describes a future finalization retry as recovery, not an active agent turn", () => { + expect(deriveRecoveryDisplayState({ + ...action, + wakePolicy: { ...action.wakePolicy, notBefore: "2026-09-27T02:01:00.000Z" }, + }, { now })).toBe("in_progress"); + }); + + it("recognizes only the matching verified run for an overdue retry", () => { + expect(deriveRecoveryDisplayState(action, { + now, scheduledRetry: { runId: "finalizing-run", status: "running" }, + })).toBe("in_progress"); + expect(deriveRecoveryDisplayState(action, { + now, scheduledRetry: { runId: "different-run", status: "running" }, + })).toBe("needed"); + }); + + it.each([null, { kind: "resume_native_run" }, { kind: "resume_native_run", notBefore: "invalid" }])( + "does not claim observation without a usable finalization retry (%j)", + (wakePolicy) => { + expect(deriveRecoveryDisplayState({ ...action, wakePolicy }, { now })).toBe("needed"); + }, + ); + + it("keeps exhausted retries and board-owned failures actionable", () => { + const wakePolicy = { ...action.wakePolicy, notBefore: "2026-09-27T02:01:00.000Z" }; + expect(deriveRecoveryDisplayState({ ...action, wakePolicy, attemptCount: 3 }, { now })).toBe("needed"); + expect(deriveRecoveryDisplayState({ ...action, wakePolicy, ownerType: "board" }, { now })).toBe("needed"); + }); + }); + it("preserves observation for an agent-owned watchdog", () => { expect( deriveRecoveryDisplayState({ diff --git a/ui/src/lib/recovery-display.ts b/ui/src/lib/recovery-display.ts index c43e16382dd..d66951029fc 100644 --- a/ui/src/lib/recovery-display.ts +++ b/ui/src/lib/recovery-display.ts @@ -55,6 +55,7 @@ export type RecoveryDisplayInput = Pick< Partial< Pick< IssueRecoveryAction, + | "cause" | "ownerType" | "wakePolicy" | "evidence" @@ -71,11 +72,6 @@ export function deriveRecoveryDisplayState( if (action.status === "resolved") return "resolved"; if (action.status === "escalated") return "escalated"; if (action.status === "cancelled") return "resolved"; - if (action.kind === "active_run_watchdog") { - // Native terminal failures also use this kind. Board ownership means a - // human must choose recovery; it is not evidence of a still-running turn. - return action.ownerType === "board" ? "needed" : "observe_only"; - } // A bounded retry lineage still holding a durable path is work the server will do on its // own. Shouting "recovery needed" over it would ask a human to fix something nobody has to // fix yet, so the calm tone is reserved for a lane with an attempt genuinely still coming. @@ -88,6 +84,15 @@ export function deriveRecoveryDisplayState( maxAttempts: action.maxAttempts, timeoutAt: action.timeoutAt, }, context); + if (action.kind === "active_run_watchdog") { + // Native finalization shares the watchdog kind, but resumes a failed + // coordinator rather than observing a live agent turn. Preserve board + // ownership and only describe recovery as active while its retry is live. + if (action.ownerType === "board") return "needed"; + if (lineage) return lineage.hasDurablePath ? "in_progress" : "needed"; + if (action.cause?.startsWith("native_")) return "needed"; + return "observe_only"; + } if (lineage && lineage.lane !== "board" && lineage.hasDurablePath) return "in_progress"; if (action.outcome === "delegated") return "in_progress"; return "needed"; diff --git a/ui/src/lib/recovery-lineage.ts b/ui/src/lib/recovery-lineage.ts index efb2809a1b2..8cffd1fa895 100644 --- a/ui/src/lib/recovery-lineage.ts +++ b/ui/src/lib/recovery-lineage.ts @@ -55,6 +55,7 @@ export interface RecoveryRetryLineage { const SOURCE_LANE_POLICY = "bounded_owner_disposition_repair"; const RECOVERY_LANE_POLICY = "bounded_recovery_owner"; const BOARD_LANE_POLICY = "board_escalation"; +const NATIVE_FINALIZATION_POLICY = "resume_native_run"; /** Scheduled-run states that mean the parked attempt is actually in flight right now. */ const LIVE_SCHEDULED_RUN_STATUSES = new Set(["queued", "running"]); @@ -143,13 +144,14 @@ export function readRecoveryRetryLineage( const policy = asRecord(action.wakePolicy); if (!policy) return null; const type = asNonEmptyString(policy.type); + const nativeFinalization = policy.kind === NATIVE_FINALIZATION_POLICY; const preservesSourceAssignee = policy.preservesSourceAssignee === true; const evidence = asRecord(action.evidence) ?? {}; const evidenceSourceAttempt = asCount(evidence.sourceAttemptCount); const evidenceSourceMaxAttempts = asCount(evidence.sourceMaxAttempts); let lane: RecoveryRetryLane; - if (type === SOURCE_LANE_POLICY) lane = "source_owner"; + if (type === SOURCE_LANE_POLICY || nativeFinalization) lane = "source_owner"; else if (type === RECOVERY_LANE_POLICY) lane = "recovery_owner"; else if ( type === BOARD_LANE_POLICY && @@ -160,8 +162,13 @@ export function readRecoveryRetryLineage( const attempt = asCount(policy.attempt) ?? asCount(action.attemptCount) ?? 0; const maxAttempts = asCount(policy.maxAttempts) ?? asCount(action.maxAttempts); - const scheduledRunId = asNonEmptyString(policy.scheduledRunId); - const storedRetryAt = asIsoDate(policy.retryAt) ?? asIsoDate(action.timeoutAt); + const scheduledRunId = asNonEmptyString(nativeFinalization ? policy.runId : policy.scheduledRunId); + // Native finalization resumes an existing coordinator; its scheduling fields + // differ from an agent wake. A retry without that run identity is not a path. + if (nativeFinalization && scheduledRunId === null) return null; + const storedRetryAt = nativeFinalization + ? asIsoDate(policy.notBefore) + : asIsoDate(policy.retryAt) ?? asIsoDate(action.timeoutAt); const exhausted = lane === "board" || (maxAttempts !== null && attempt >= maxAttempts); const nextRetryAt = exhausted ? null : storedRetryAt; const liveRunId = resolveLiveRunId(scheduledRunId, context?.scheduledRetry);