diff --git a/doc/execution-semantics.md b/doc/execution-semantics.md index a9cf075ffc..accf02b8e6 100644 --- a/doc/execution-semantics.md +++ b/doc/execution-semantics.md @@ -499,6 +499,12 @@ Monitor policy lives under `executionPolicy.monitor` and includes: Monitors are not recurring intervals. When a monitor fires, Paperclip clears the scheduled monitor and queues an `issue_monitor_due` wake for the assignee. If the external service is still pending, the assignee must explicitly re-arm the monitor with a new `nextCheckAt`. If the issue moves to `done`, `cancelled`, an invalid status, or a human/unassigned owner, the monitor is cleared. +The task's waiting banner and composer countdown also display automatic retries +while their run is `scheduled_retry`. Once a retry is `queued` or `running`, its +retained `scheduledRetryAt` is historical and must not produce a waiting or overdue +warning. A separately scheduled monitor remains visible. Completed and cancelled +tasks hide both waiting surfaces even if a stale schedule remains in the response. + Because `serviceName` and `notes` remain visible in issue activity and wake context, operators should keep them short and non-secret. Put enough context for the assignee to know what to inspect, but do not include signed URLs, bearer tokens, customer secrets, tenant-private identifiers, or provider links with embedded credentials. Monitor bounds are enforced. Paperclip rejects attempts to re-arm a monitor whose `timeoutAt` or `maxAttempts` is already exhausted. When a scheduled monitor reaches an exhausted bound at trigger time, Paperclip clears it and follows `recoveryPolicy`: `wake_owner` queues a bounded recovery wake for the assignee, `create_recovery_issue` opens visible issue-backed recovery work, and `escalate_to_board` records a board-visible escalation comment/activity. diff --git a/ui/src/components/IssueMonitorBanner.test.tsx b/ui/src/components/IssueMonitorBanner.test.tsx index 53c9f0fc7b..0a8bd470c1 100644 --- a/ui/src/components/IssueMonitorBanner.test.tsx +++ b/ui/src/components/IssueMonitorBanner.test.tsx @@ -193,4 +193,42 @@ describe("IssueMonitorBanner / IssueMonitorComposerStrip rendering", () => { flushSync(() => root.unmount()); }); + + it("removes both countdowns and Check now when the retry starts, then shows a newly scheduled retry", () => { + const root = createRoot(container); + const issue = { + status: "in_progress", + scheduledRetry: { + status: "scheduled_retry", + scheduledRetryAt: new Date(NOW.getTime() - 2 * 60_000).toISOString(), + scheduledRetryAttempt: 1, + }, + } as Issue; + const render = (next: Issue) => flushSync(() => root.render( + <> + + + , + )); + + render(issue); + expect(container.textContent).toContain("Overdue by 2m"); + + for (const status of ["queued", "running"] as const) { + const promoted = { ...issue, scheduledRetry: { ...issue.scheduledRetry!, status } }; + render(promoted); + expect(hasVisibleMonitorSurface(promoted)).toBe(false); + expect(container.textContent).toBe(""); + expect(container.querySelector("button")).toBeNull(); + expect(vi.getTimerCount()).toBe(0); + } + + render({ ...issue, scheduledRetry: { ...issue.scheduledRetry!, scheduledRetryAt: new Date(NOW.getTime() + 5 * 60_000).toISOString() } }); + expect(container.textContent).toContain("Resumes in 5m"); + + render({ ...issue, status: "done" }); + expect(container.textContent).toBe(""); + expect(vi.getTimerCount()).toBe(0); + flushSync(() => root.unmount()); + }); }); diff --git a/ui/src/lib/issue-monitor.test.tsx b/ui/src/lib/issue-monitor.test.tsx index 84b7ced040..82526fb4af 100644 --- a/ui/src/lib/issue-monitor.test.tsx +++ b/ui/src/lib/issue-monitor.test.tsx @@ -140,6 +140,35 @@ describe("deriveMonitorState", () => { expect(deriveMonitorState(issue("2026-07-17T19:59:00.000Z"), now).state).toBe("overdue"); }); + it.each(["queued", "running", "cancelled"] as const)("ignores a %s retry's historical start time", (status) => { + const issue = { + status: "in_progress", + scheduledRetry: { + status, + scheduledRetryAt: "2026-07-17T19:58:00.000Z", + scheduledRetryAttempt: 1, + }, + }; + + expect(deriveMonitorState(issue, now)).toMatchObject({ state: "none", nextCheckAt: null }); + // A separate, explicitly scheduled monitor must still be visible. + expect(deriveMonitorState({ ...issue, monitorNextCheckAt: "2026-07-17T20:05:00.000Z" }, now)) + .toMatchObject({ state: "scheduled", source: "monitor" }); + }); + + it("keeps overdue warnings for retries that have not been promoted", () => { + expect(deriveMonitorState({ + scheduledRetry: { status: "scheduled_retry", scheduledRetryAt: "2026-07-17T19:58:00.000Z" }, + }, now)).toMatchObject({ state: "overdue", source: "scheduled-retry" }); + }); + + it.each(["done", "cancelled"])("ignores stale monitor and retry schedules on %s tasks", (status) => { + const scheduledRetry = { status: "scheduled_retry" as const, scheduledRetryAt: "2026-07-17T19:58:00.000Z" }; + expect(deriveMonitorState({ status, scheduledRetry }, now)).toMatchObject({ state: "none", nextCheckAt: null }); + expect(deriveMonitorState({ status, monitorNextCheckAt: scheduledRetry.scheduledRetryAt }, now)) + .toMatchObject({ state: "none", nextCheckAt: null }); + }); + it("derives cleared, none, and scheduled retry states", () => { expect( deriveMonitorState({ executionState: { monitor: { status: "cleared", attemptCount: 2 } } }, now), diff --git a/ui/src/lib/issue-monitor.ts b/ui/src/lib/issue-monitor.ts index adc6f2a476..3a313b1ba1 100644 --- a/ui/src/lib/issue-monitor.ts +++ b/ui/src/lib/issue-monitor.ts @@ -27,6 +27,7 @@ type ScheduledRetry = { }; export interface MonitorIssueLike { + status?: string; executionState?: { monitor?: MonitorDetails | null } | null; executionPolicy?: { monitor?: MonitorPolicy | null } | null; monitorNextCheckAt?: MonitorDate | null; @@ -173,25 +174,28 @@ export function formatMonitorAbsoluteFull( } export function deriveMonitorState(issue: MonitorIssueLike, now: MonitorDate = new Date()): DerivedMonitorState { + if (issue.status === "done" || issue.status === "cancelled") { + return { state: "none", source: "none", nextCheckAt: null, attemptCount: 0, serviceName: null }; + } + const runtimeMonitor = issue.executionState?.monitor ?? null; const policyMonitor = issue.executionPolicy?.monitor ?? null; const scheduledRetry = issue.scheduledRetry ?? null; - const retryIsActive = - scheduledRetry?.status === "scheduled_retry" || - scheduledRetry?.status === "queued" || - scheduledRetry?.status === "running"; + // Promotion preserves scheduledRetryAt as history. Once queued or running, + // the retry is no longer waiting for that timestamp and cannot be overdue. + const retryIsScheduled = scheduledRetry?.status === "scheduled_retry"; const nextCheckAt = runtimeMonitor?.nextCheckAt ?? issue.monitorNextCheckAt ?? policyMonitor?.nextCheckAt ?? - (retryIsActive ? scheduledRetry?.scheduledRetryAt : null) ?? + (retryIsScheduled ? scheduledRetry?.scheduledRetryAt : null) ?? null; const hasMonitor = runtimeMonitor !== null || policyMonitor !== null || issue.monitorNextCheckAt != null; - const source = hasMonitor ? "monitor" : retryIsActive ? "scheduled-retry" : "none"; + const source = hasMonitor ? "monitor" : retryIsScheduled ? "scheduled-retry" : "none"; const attemptCount = runtimeMonitor?.attemptCount ?? (hasMonitor ? issue.monitorAttemptCount : null) ?? - (retryIsActive ? scheduledRetry?.scheduledRetryAttempt : null) ?? + (retryIsScheduled ? scheduledRetry?.scheduledRetryAttempt : null) ?? 0; const serviceName = runtimeMonitor?.serviceName ?? policyMonitor?.serviceName ?? null; @@ -199,11 +203,11 @@ export function deriveMonitorState(issue: MonitorIssueLike, now: MonitorDate = n return { state: "cleared", source, nextCheckAt, attemptCount, serviceName }; } - if (!hasMonitor && !retryIsActive) { + if (!hasMonitor && !retryIsScheduled) { return { state: "none", source, nextCheckAt: null, attemptCount: 0, serviceName: null }; } if (!nextCheckAt) { - return { state: retryIsActive || attemptCount > 1 ? "retrying" : "scheduled", source, nextCheckAt, attemptCount, serviceName }; + return { state: retryIsScheduled || attemptCount > 1 ? "retrying" : "scheduled", source, nextCheckAt, attemptCount, serviceName }; } const deltaMs = toTimestamp(nextCheckAt) - toTimestamp(now); @@ -214,7 +218,7 @@ export function deriveMonitorState(issue: MonitorIssueLike, now: MonitorDate = n return { state: "due-now", source, nextCheckAt, attemptCount, serviceName }; } return { - state: retryIsActive || attemptCount > 1 ? "retrying" : "scheduled", + state: retryIsScheduled || attemptCount > 1 ? "retrying" : "scheduled", source, nextCheckAt, attemptCount,