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,