fix(ui): distinguish finalization recovery from live observation

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-09-27 15:59:17 -05:00
1 parent 0f14d26123
commit e4317c8683
4 files changed
+81 -8

No files matched your search

+8
View File
@@ -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.”
+53
View File
@@ -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({
+10 -5
View File
@@ -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";
+10 -3
View File
@@ -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);