mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-24 18:19:05 +02:00
fix(ui): distinguish finalization recovery from live observation
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
0f14d26123
commit
e4317c8683
4 files changed
+81
-8
No files matched your search
@@ -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.”
|
||||
@@ -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({
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user