diff --git a/doc/PRODUCT.md b/doc/PRODUCT.md
index c4c28fedf37..8bd41009632 100644
--- a/doc/PRODUCT.md
+++ b/doc/PRODUCT.md
@@ -93,6 +93,12 @@ Tasks have parentage. Every task exists in service of a parent task, all the way
The current issue model includes stable issue identifiers, parent/sub-issues, blockers, a single assignee, comments, issue documents, attachments and work products, and review/approval handoffs. That structure keeps work inspectable by both the board and agents while still allowing agents to decompose work into smaller tasks.
+Automatic monitor checks pause when a task is blocked. A person can use Check now
+to request one check of a retained monitor through normal execution admission.
+Dependencies, recovery, pause, budgets and ownership still apply. Provider-usage
+recovery stays paused until the task resumes. A check that
+cannot start reports that result instead of claiming it was queued.
+
The experimental Cases feature also supports persistent work records and
prompt-driven agent handoffs. Native case creation requires kickoff and creates
one primary task; later handoffs move that same task
diff --git a/doc/SPEC-implementation.md b/doc/SPEC-implementation.md
index bac9acad7b9..3b9d918e848 100644
--- a/doc/SPEC-implementation.md
+++ b/doc/SPEC-implementation.md
@@ -767,6 +767,7 @@ V1 non-terminal liveness rule:
- A published advisory GitHub review can also remain `in_review` through an authorized external response wait or a saved monitor. Rating, CI state, and merge state alone do not create human attention. The current committed wait is an action path while its assigned endpoint and conversation remain active; it creates no Mine participation.
- a blocked chain is covered only when each unresolved leaf issue is live or explicitly waiting
- external waits are durable only when persisted as a bounded monitor/scheduled wake, a first-class blocker with a named owner and action, or healthy delegated child work connected by a blocker edge when the source must wait; parent/child structure alone is not a wait path
+- automatic monitor checks require an agent-owned task in `in_progress` or `in_review`; an authorized human may request one check of a retained monitor on a `blocked` task through normal admission without changing its status or bypassing dependency, recovery, ownership, pause or budget gates; provider-quota recovery monitors remain paused until the task resumes; a skipped manual dispatch returns a conflict instead of claiming success
- unmanaged shell jobs, detached sessions, adapter child processes, local polling loops, PIDs, logs, and comments are evidence rather than liveness; a managed runtime service counts only when paired with a persisted monitor, wake, blocker, or delegated issue that owns the next check
- heartbeat finalization evaluates liveness from persisted Paperclip state; an issue cannot remain healthy `in_progress` solely because the exiting heartbeat started a local/background watcher
- a continuation cancelled as `issue_continuation_waiting_on_review` first converts a current typed wait target into a first-class wait; without a current target it is classified as `deliberate_wait_without_target` and gives the invokable original owner five normal-model disposition-repair attempts (immediate, then after 60, 120, 240, and 480 seconds, with up to 10 percent jitter)
diff --git a/maps/agent-access-map/tasks.md b/maps/agent-access-map/tasks.md
index 859c5304653..ce801ef5f2e 100644
--- a/maps/agent-access-map/tasks.md
+++ b/maps/agent-access-map/tasks.md
@@ -222,7 +222,7 @@ API: `present` — PATCH /api/issues/{id} with executionPolicy.monitor; POST /ap
Agent access: `partial` — native set_task_monitor standard-mode binding; permitted agent REST/call_api policy PATCH/check-now; no CLI monitor flags. No REST operation named set_task_monitor. Queue and HTTP/2 sandbox callbacks admit the wake-diagnostics read and monitor check-now request. Controller task-read, monitor-management and assignment checks still apply. Recovery resolution and scheduled-retry controls are outside this callback allowance. See the [callback bridge](../../packages/adapter-utils/src/sandbox-callback-bridge.ts) and [callback boundary tests](../../packages/adapter-utils/src/sandbox-callback-context-routes.test.ts).
-Policy: Native nested monitor|null, optional OWNED target taskId, key<=240, notes1..500, future check/timeouts/attempts<=100; reserved quota service denied, externalRef sanitized. Target assigned same agent/no human, in_progress|in_review; issue:read,runtime:manage,responsible-user authority. REST monitor changes require runtime:manage+current assignee for agent; board role gate differs. Receipt replay returns current schedule without rearming consumed monitor. Check-now cannot bypass recovery/finalization/eligibility.
+Policy: Native nested monitor|null, optional OWNED target taskId, key<=240, notes1..500, future check/timeouts/attempts<=100; reserved quota service denied, externalRef sanitized. Target assigned same agent/no human, in_progress|in_review; issue:read,runtime:manage,responsible-user authority. REST monitor changes require runtime:manage+current assignee for agent; board role gate differs. Receipt replay returns current schedule without rearming consumed monitor. Human check-now may request one check of a blocked task with a retained monitor; agents and automatic ticks still require in_progress|in_review. This does not change status or bypass dependency, recovery, finalization, pause, budget or ownership gates. Provider-quota recovery uses a direct retry path and stays paused on blocked tasks; check-now rejects it before consuming the schedule. A skipped check-now returns 409 instead of success.
Evidence: `source-only` — reviewed at `3bbc2104b65caf9f7763938a01260a5673e12f7a`; [issues.ts](../../server/src/routes/issues.ts), [paperclip-runner-tool-authority.ts](../../server/src/services/native-runtime/paperclip-runner-tool-authority.ts), [issue-monitors.ts](../../server/src/services/issue-monitors.ts), [issue-monitor-scheduler.test.ts](../../server/src/__tests__/issue-monitor-scheduler.test.ts), [task-monitor.integration.test.ts](../../server/src/services/native-runtime/task-monitor.integration.test.ts), [scheduling.test.ts](../../server/src/services/heartbeat/scheduling.test.ts). No live execution claimed.
diff --git a/maps/feature-map/tasks.md b/maps/feature-map/tasks.md
index 8716319460c..37e39a58ce3 100644
--- a/maps/feature-map/tasks.md
+++ b/maps/feature-map/tasks.md
@@ -138,6 +138,13 @@ schema feedback supports a corrected call before a wait is claimed.
For a due monitor held by execution recovery, confirm that the schedule remains
and task activity explains the deferral. Repeated checks must not replay the held
work. Resolve the hold through its existing recovery action, then verify one wake.
+On a blocked task with a retained monitor, verify that automatic checks show as
+paused. Check now requests one check through normal admission without changing
+the task status. Confirm that an unresolved dependency or recovery hold prevents
+dispatch and does not produce a success message. Backlog and todo tasks must not
+offer Check now. Blocked provider-quota recovery must offer no manual check and
+retain its schedule until the task resumes. Agents and automatic ticks cannot
+check a blocked task.
Hold native finalization after the provider has stopped, and verify that both
scheduled and manual checks retain the due monitor. After settlement, verify one
wake. If its timeout passes during workspace recovery, verify settlement followed
diff --git a/server/src/__tests__/issue-execution-policy-routes.test.ts b/server/src/__tests__/issue-execution-policy-routes.test.ts
index f759f98659b..26dacf2f1c1 100644
--- a/server/src/__tests__/issue-execution-policy-routes.test.ts
+++ b/server/src/__tests__/issue-execution-policy-routes.test.ts
@@ -19,7 +19,7 @@ const mockIssueService = vi.hoisted(() => ({
const mockHeartbeatService = vi.hoisted(() => ({
wakeup: vi.fn(async () => undefined),
- triggerIssueMonitor: vi.fn(async () => ({ outcome: "triggered" as const })),
+ triggerIssueMonitor: vi.fn(async (): Promise<{ outcome: "triggered" } | { outcome: "skipped"; reason: string }> => ({ outcome: "triggered" })),
reportRunActivity: vi.fn(async () => undefined),
getRun: vi.fn(async () => null),
getActiveRunForAgent: vi.fn(async () => null),
@@ -1157,11 +1157,11 @@ describe("issue execution policy routes", () => {
expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled();
});
- it("triggers a scheduled monitor immediately from the dedicated route", async () => {
+ it.each(["in_progress", "blocked"])("triggers a scheduled monitor on an %s task from the dedicated route", async (status) => {
const issue = {
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
companyId: "company-1",
- status: "in_progress",
+ status,
assigneeAgentId: "33333333-3333-4333-8333-333333333333",
assigneeUserId: null,
createdByUserId: "local-board",
@@ -1194,6 +1194,20 @@ describe("issue execution policy routes", () => {
);
});
+ it.each(["execution_reconciliation_required", "monitor_dispatch_deferred", "monitor_replaced"])("does not claim success for a skipped monitor check: %s", async (reason) => {
+ mockIssueService.getById.mockResolvedValue({
+ id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", companyId: "company-1", status: "blocked",
+ assigneeAgentId: "33333333-3333-4333-8333-333333333333", assigneeUserId: null,
+ });
+ mockHeartbeatService.triggerIssueMonitor.mockResolvedValueOnce({ outcome: "skipped", reason });
+ const res = await request(await createApp())
+ .post("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/monitor/check-now").send({});
+ expect(res.status).toBe(409);
+ expect(res.body).toMatchObject({ details: { code: "issue_monitor_check_deferred", reason } });
+ if (reason === "monitor_replaced") expect(res.body.error).toContain("monitor changed");
+ expect(res.body.ok).toBeUndefined();
+ });
+
it("lets a board user create a child issue with a scheduled monitor", async () => {
mockIssueService.getById.mockResolvedValue({
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
diff --git a/server/src/__tests__/issue-monitor-scheduler.test.ts b/server/src/__tests__/issue-monitor-scheduler.test.ts
index 4cfe7d95801..8c051e38f38 100644
--- a/server/src/__tests__/issue-monitor-scheduler.test.ts
+++ b/server/src/__tests__/issue-monitor-scheduler.test.ts
@@ -17,6 +17,7 @@ import {
heartbeatRunEvents,
heartbeatRuns,
issueComments,
+ issueRelations,
issueRecoveryActions,
issueDocuments,
issues,
@@ -424,7 +425,7 @@ describeEmbeddedPostgres("issue monitor scheduler", () => {
if (change === "cleared") expect(issue.monitorNextCheckAt).toBeNull();
});
- it("does not erase a replacement scheduled after wake admission", async () => {
+ it.each(["manual", "scheduled"] as const)("reports an accepted %s check and preserves a replacement schedule", async (source) => {
const { companyId, issueId } = await seedFixture();
const nextCheckAt = new Date("2026-04-11T13:30:00.000Z");
let replaced = false;
@@ -443,7 +444,14 @@ describeEmbeddedPostgres("issue monitor scheduler", () => {
};
},
});
- await heartbeatService(racingDb).tickTimers(new Date("2026-04-11T12:31:00.000Z"));
+ const heartbeat = heartbeatService(racingDb);
+ const now = new Date("2026-04-11T12:31:00.000Z");
+ if (source === "manual") {
+ expect(await heartbeat.triggerIssueMonitor(issueId, { now, actorType: "user", actorId: "local-board" }))
+ .toMatchObject({ outcome: "triggered" });
+ } else {
+ expect(await heartbeat.tickTimers(now)).toMatchObject({ enqueued: 1, skipped: 0 });
+ }
expect(replaced).toBe(true);
const [issue] = await db.select().from(issues).where(eq(issues.id, issueId));
expect(issue.monitorNextCheckAt).toEqual(nextCheckAt);
@@ -547,8 +555,9 @@ describeEmbeddedPostgres("issue monitor scheduler", () => {
expect(participantRuns.find((run) => run.id === scheduled.id)?.errorCode).not.toBe("issue_assignee_changed");
});
- it("lets the board trigger a scheduled issue monitor immediately", async () => {
+ it.each(["in_progress", "blocked"] as const)("lets the board check a scheduled monitor on an %s task", async (status) => {
const { issueId, agentId, nextCheckAt } = await seedFixture();
+ await db.update(issues).set({ status }).where(eq(issues.id, issueId));
const heartbeat = heartbeatService(db);
const triggeredAt = new Date("2026-04-11T12:00:00.000Z");
@@ -577,6 +586,11 @@ describeEmbeddedPostgres("issue monitor scheduler", () => {
nextCheckAt: nextCheckAt.toISOString(),
source: "manual",
});
+ expect(wakeup?.runId).toBeTruthy();
+ await waitForHeartbeatIdle();
+ const [run] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, wakeup!.runId!));
+ expect(run?.status).toBe("succeeded");
+ expect(run?.startedAt).not.toBeNull();
const activity = await db
.select()
@@ -593,6 +607,32 @@ describeEmbeddedPostgres("issue monitor scheduler", () => {
});
});
+ it("retains a blocked task's monitor when a human check is held by a dependency", async () => {
+ const { issueId, agentId, companyId, nextCheckAt } = await seedFixture();
+ await db.update(issues).set({ status: "blocked" }).where(eq(issues.id, issueId));
+ const [blocker] = await db.insert(issues).values({ companyId, title: "Unfinished dependency", status: "todo" }).returning();
+ await db.insert(issueRelations).values({ companyId, issueId: blocker.id, relatedIssueId: issueId, type: "blocks" });
+ const result = await heartbeatService(db).triggerIssueMonitor(issueId, { actorType: "user", actorId: "local-board" });
+ expect(result).toMatchObject({ outcome: "skipped" });
+ const [issue] = await db.select().from(issues).where(eq(issues.id, issueId));
+ expect(issue).toMatchObject({ status: "blocked", monitorNextCheckAt: nextCheckAt, monitorWakeRequestedAt: null, monitorAttemptCount: 0 });
+ expect(await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.agentId, agentId))).toHaveLength(0);
+ });
+
+ it("does not consume blocked quota recovery when a dependency is unresolved", async () => {
+ const { issueId, agentId, companyId, nextCheckAt } = await seedFixture({
+ monitor: { serviceName: PROVIDER_QUOTA_MONITOR_SERVICE_NAME },
+ });
+ await db.update(issues).set({ status: "blocked" }).where(eq(issues.id, issueId));
+ const [blocker] = await db.insert(issues).values({ companyId, title: "Unfinished dependency", status: "todo" }).returning();
+ await db.insert(issueRelations).values({ companyId, issueId: blocker.id, relatedIssueId: issueId, type: "blocks" });
+ await expect(heartbeatService(db).triggerIssueMonitor(issueId, { actorType: "user", actorId: "local-board" }))
+ .rejects.toThrow("Resume this task before checking provider usage.");
+ const [issue] = await db.select().from(issues).where(eq(issues.id, issueId));
+ expect(issue).toMatchObject({ status: "blocked", monitorNextCheckAt: nextCheckAt, monitorWakeRequestedAt: null, monitorAttemptCount: 0 });
+ expect(await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.agentId, agentId))).toHaveLength(0);
+ });
+
it("clears due monitors that cannot be dispatched and records a skip", async () => {
const { issueId } = await seedFixture({ agentStatus: "paused" });
const heartbeat = heartbeatService(db);
diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts
index 55649ef9f81..9ab7951bd46 100644
--- a/server/src/routes/issues.ts
+++ b/server/src/routes/issues.ts
@@ -13379,12 +13379,22 @@ export function issueRoutes(
);
const actor = getActorInfo(req);
- await heartbeat.triggerIssueMonitor(issue.id, {
+ const result = await heartbeat.triggerIssueMonitor(issue.id, {
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId ?? null,
runId: actor.runId ?? null,
});
+ if (result.outcome !== "triggered") {
+ throw conflict(
+ result.reason === "execution_reconciliation_required"
+ ? "The previous run is still being recovered. Try again when recovery finishes."
+ : result.reason === "monitor_replaced"
+ ? "The monitor changed during this check. Review the task's current state before trying again."
+ : "The monitor check did not start. Refresh the task to see its current state.",
+ { code: "issue_monitor_check_deferred", reason: result.reason },
+ );
+ }
res.json({ ok: true });
});
diff --git a/server/src/routes/openapi.ts b/server/src/routes/openapi.ts
index 66f617e2592..eea69ddc69e 100644
--- a/server/src/routes/openapi.ts
+++ b/server/src/routes/openapi.ts
@@ -10791,7 +10791,8 @@ registerCurrentRoute({
method: "post",
path: "/api/issues/{id}/monitor/check-now",
tags: ["issues"],
- summary: "Run an issue monitor check now",
+ summary: "Run an issue monitor check now; humans may check blocked tasks",
+ responses: { 200: r.ok(), 401: r.unauthorized, 403: r.forbidden, 404: r.notFound, 409: r.conflict },
});
registerCurrentRoute({
diff --git a/server/src/services/heartbeat/scheduling.test.ts b/server/src/services/heartbeat/scheduling.test.ts
index b07e30ab3c6..26b84d48b82 100644
--- a/server/src/services/heartbeat/scheduling.test.ts
+++ b/server/src/services/heartbeat/scheduling.test.ts
@@ -168,7 +168,7 @@ describe.skipIf(!postgres.supported)("heartbeat scheduling database boundary", (
await db.update(issues).set({ monitorNextCheckAt: replacement, monitorWakeRequestedAt: null }).where(eq(issues.id, f.issue.id));
return admitted;
});
- expect(await f.scheduler.tickTimers(now)).toEqual({ checked: 1, enqueued: 0, skipped: 1 });
+ expect(await f.scheduler.tickTimers(now)).toEqual({ checked: 1, enqueued: 1, skipped: 0 });
expect(await readIssue(f.issue.id)).toMatchObject({ monitorNextCheckAt: replacement, monitorAttemptCount: 0 });
});
@@ -197,6 +197,28 @@ describe.skipIf(!postgres.supported)("heartbeat scheduling database boundary", (
expect(await readIssue(f.issue.id)).toMatchObject({ monitorNextCheckAt: null });
});
+ it("lets a human check a blocked task without resuming automatic checks", async () => {
+ const f = await fixture();
+ await db.update(issues).set({ status: "blocked" }).where(eq(issues.id, f.issue.id));
+ expect(await f.scheduler.tickTimers(now)).toEqual({ checked: 0, enqueued: 0, skipped: 0 });
+ expect(f.deps.enqueueWakeup).not.toHaveBeenCalled();
+ await expect(f.scheduler.triggerIssueMonitor(f.issue.id, { now, actorType: "user", actorId: "board-user" }))
+ .resolves.toMatchObject({ outcome: "triggered" });
+ expect(f.deps.enqueueWakeup).toHaveBeenCalledExactlyOnceWith(f.agent.id, expect.objectContaining({
+ source: "on_demand", requestedByActorType: "user", requestedByActorId: "board-user",
+ issueStateGuard: expect.objectContaining({ statuses: ["blocked"], assigneeAgentId: f.agent.id }),
+ }));
+ expect(await readIssue(f.issue.id)).toMatchObject({ status: "blocked", monitorNextCheckAt: null, monitorAttemptCount: 1 });
+ });
+
+ it.each(["agent", "system"] as const)("does not let a %s check a blocked task", async (actorType) => {
+ const f = await fixture();
+ await db.update(issues).set({ status: "blocked" }).where(eq(issues.id, f.issue.id));
+ await expect(f.scheduler.triggerIssueMonitor(f.issue.id, { now, actorType, actorId: "caller" })).rejects.toThrow();
+ expect(f.deps.enqueueWakeup).not.toHaveBeenCalled();
+ expect(await readIssue(f.issue.id)).toMatchObject({ status: "blocked", monitorNextCheckAt: dueAt, monitorWakeRequestedAt: null });
+ });
+
it("retains monitors after transient admission failures", async () => {
const f = await fixture();
f.deps.enqueueWakeup.mockRejectedValueOnce(new Error("Temporary admission failure"));
diff --git a/server/src/services/heartbeat/scheduling.ts b/server/src/services/heartbeat/scheduling.ts
index c1714de7b55..34a3821b0bf 100644
--- a/server/src/services/heartbeat/scheduling.ts
+++ b/server/src/services/heartbeat/scheduling.ts
@@ -792,7 +792,9 @@ export function createHeartbeatScheduling(
})
.where(issueMonitorClaimCondition(claimed))
.returning({ id: issues.id });
- if (consumed.length === 0) return { outcome: "skipped" as const, reason: "monitor_replaced" };
+ // Admission already accepted the check. A replacement schedule must stay
+ // intact, but losing its consumption fence does not undo that accepted wake.
+ if (consumed.length === 0) return { outcome: "triggered" as const };
await logActivity(db, {
companyId: claimed.companyId,
@@ -907,7 +909,18 @@ export function createHeartbeatScheduling(
if (!issue.assigneeAgentId || issue.assigneeUserId) {
throw conflict("Issue monitor requires an agent assignee");
}
- if (!["in_progress", "in_review"].includes(issue.status)) {
+ if (issue.status === "blocked" && actorType === "user" &&
+ normalizeIssueExecutionPolicy(issue.executionPolicy ?? null)?.monitor?.serviceName === PROVIDER_QUOTA_MONITOR_SERVICE_NAME) {
+ // Quota recovery schedules a bounded retry directly, rather than using
+ // ordinary wake admission. It must not consume a blocked task's monitor.
+ throw conflict("Resume this task before checking provider usage.", { code: "blocked_quota_monitor" });
+ }
+ // A human can request one check of a blocked task. Admission still owns
+ // dependencies, recovery, pause and budget gates; automatic ticks stay idle.
+ const allowedStatuses = actorType === "user"
+ ? ["in_progress", "in_review", "blocked"]
+ : ["in_progress", "in_review"];
+ if (!allowedStatuses.includes(issue.status)) {
throw conflict(
"Issue monitor can only run while the issue is in progress or in review",
);
@@ -928,7 +941,7 @@ export function createHeartbeatScheduling(
sql`${issues.monitorNextCheckAt} is not null`,
isNull(issues.assigneeUserId),
sql`${issues.assigneeAgentId} is not null`,
- inArray(issues.status, ["in_progress", "in_review"]),
+ inArray(issues.status, allowedStatuses),
or(
isNull(issues.monitorWakeRequestedAt),
lt(issues.monitorWakeRequestedAt, staleClaimThreshold),
diff --git a/ui/src/components/IssueMonitorBanner.test.tsx b/ui/src/components/IssueMonitorBanner.test.tsx
index 5680dc963af..9156408fc95 100644
--- a/ui/src/components/IssueMonitorBanner.test.tsx
+++ b/ui/src/components/IssueMonitorBanner.test.tsx
@@ -3,7 +3,7 @@
import { act } from "react";
import { flushSync } from "react-dom";
import { createRoot } from "react-dom/client";
-import type { Issue, IssueWorkProduct } from "@paperclipai/shared";
+import { PROVIDER_QUOTA_MONITOR_SERVICE_NAME, type Issue, type IssueWorkProduct } from "@paperclipai/shared";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
@@ -260,6 +260,49 @@ describe("IssueMonitorBanner / IssueMonitorComposerStrip rendering", () => {
flushSync(() => root.unmount());
});
+ it("offers a manual check on a blocked task without promising an automatic wake", () => {
+ const onCheckNow = vi.fn();
+ const issue = { ...issueWithMonitor(new Date(NOW.getTime() - 12 * 60_000).toISOString()), status: "blocked" as const };
+ const root = createRoot(container);
+ flushSync(() => root.render(<>
+
- {copy.pullRequestReview - ? "Review on GitHub, then check status to have the agent verify the result." - : copy.workspaceWait - ? "You can keep sending instructions while the agent waits." - : "Sending a reply wakes the agent now — before the scheduled check."} -
+ {!copy.automaticChecksPaused ? ( ++ {copy.pullRequestReview + ? "Review on GitHub, then check status to have the agent verify the result." + : copy.workspaceWait + ? "You can keep sending instructions while the agent waits." + : "Sending a reply wakes the agent now — before the scheduled check."} +
+ ) : null} {checkError ?{checkError}
: null} ); diff --git a/ui/src/components/IssueProperties.test.tsx b/ui/src/components/IssueProperties.test.tsx index cb981180726..5ed4d721649 100644 --- a/ui/src/components/IssueProperties.test.tsx +++ b/ui/src/components/IssueProperties.test.tsx @@ -3032,6 +3032,7 @@ describe("IssueProperties", () => { }; renderMonitor(createIssue({ + status: "in_progress", executionPolicy: createExecutionPolicy({ monitor: { ...baseMonitorState, serviceName: "vercel-deploy" } }), executionState: createExecutionState({ monitor: baseMonitorState }), monitorAttemptCount: 1, @@ -3045,6 +3046,7 @@ describe("IssueProperties", () => { expect(monitorRowText()).toContain("Today, 4:08 PM · Attempt 1"); renderMonitor(createIssue({ + status: "in_progress", executionPolicy: createExecutionPolicy({ monitor: { ...baseMonitorState, nextCheckAt: at(252) } }), executionState: createExecutionState({ monitor: { ...baseMonitorState, nextCheckAt: at(132) } }), monitorNextCheckAt: new Date(at(192)), @@ -3054,6 +3056,7 @@ describe("IssueProperties", () => { expect(monitorRowText()).toContain("Today, 4:08 PM"); renderMonitor(createIssue({ + status: "in_progress", executionPolicy: createExecutionPolicy({ monitor: { ...baseMonitorState, serviceName: "vercel-deploy" } }), executionState: createExecutionState({ monitor: { ...baseMonitorState, attemptCount: 3 } }), monitorAttemptCount: 3, @@ -3062,6 +3065,7 @@ describe("IssueProperties", () => { expect(monitorRowText()).toContain("Attempt 3"); renderMonitor(createIssue({ + status: "in_progress", executionPolicy: createExecutionPolicy({ monitor: { ...baseMonitorState, nextCheckAt: at(0) } }), executionState: createExecutionState({ monitor: { ...baseMonitorState, nextCheckAt: at(0) } }), })); @@ -3070,6 +3074,7 @@ describe("IssueProperties", () => { expect(monitorRowText()).toContain("checking momentarily…"); renderMonitor(createIssue({ + status: "in_progress", executionPolicy: createExecutionPolicy({ monitor: { ...baseMonitorState, nextCheckAt: at(-18) } }), executionState: createExecutionState({ monitor: { ...baseMonitorState, nextCheckAt: at(-18) } }), })); @@ -3078,6 +3083,17 @@ describe("IssueProperties", () => { expect(monitorRowText()).toContain("Today, 1:38 PM · fires on next tick"); renderMonitor(createIssue({ + status: "blocked", + executionPolicy: createExecutionPolicy({ monitor: { ...baseMonitorState, nextCheckAt: at(-18) } }), + executionState: createExecutionState({ monitor: { ...baseMonitorState, nextCheckAt: at(-18) } }), + })); + await flush(); + expect(monitorRowText()).toContain("Automatic checks paused"); + expect(monitorRowText()).toContain("Check now to request a manual check"); + expect(monitorRowText()).not.toMatch(/overdue|next tick/i); + + renderMonitor(createIssue({ + status: "in_progress", executionPolicy: createExecutionPolicy(), executionState: createExecutionState({ monitor: { ...baseMonitorState, diff --git a/ui/src/components/issue-properties/IssueProperties.tsx b/ui/src/components/issue-properties/IssueProperties.tsx index 924c7c8eb05..91ccdb43932 100644 --- a/ui/src/components/issue-properties/IssueProperties.tsx +++ b/ui/src/components/issue-properties/IssueProperties.tsx @@ -50,6 +50,8 @@ import { formatAssigneeUserLabel, formatUserLabel } from "../../lib/assignees"; import { buildExecutionPolicy, stageParticipantValues } from "../../lib/issue-execution-policy"; import { useExecutionPolicy } from "../../hooks/useExecutionPolicy"; import { + areAutomaticMonitorChecksPaused, + canCheckIssueMonitorNow, formatMonitorAbsolute, formatMonitorAbsoluteFull, formatMonitorEta, @@ -1323,17 +1325,21 @@ export function IssueProperties({ const monitorLastTriggeredAt = issue.monitorLastTriggeredAt ?? monitorState?.lastTriggeredAt ?? null; const monitorServiceName = executionPolicy?.monitor?.serviceName ?? monitorState?.serviceName ?? null; const monitorNotes = executionPolicy?.monitor?.notes ?? monitorState?.notes ?? null; - const monitorNow = useMonitorCountdown(monitorNextCheckAt); + const monitorPaused = areAutomaticMonitorChecksPaused(issue); + const canCheckMonitorNow = canCheckIssueMonitorNow(issue, monitorServiceName); + const monitorNow = useMonitorCountdown(monitorPaused ? null : monitorNextCheckAt); const monitorRelative = monitorNextCheckAt ? formatMonitorEta(monitorNextCheckAt, monitorNow) : null; const monitorIsDueNow = monitorRelative === "due now"; const monitorIsOverdue = Boolean(monitorRelative?.startsWith("overdue by ")); const monitorPrimary = monitorNextCheckAt - ? formatMonitorEtaLabel(monitorNextCheckAt, monitorNow) + ? monitorPaused ? "Automatic checks paused" : formatMonitorEtaLabel(monitorNextCheckAt, monitorNow) : monitorState?.status === "cleared" ? "Cleared" : "None"; const monitorSecondary = monitorNextCheckAt - ? monitorIsDueNow + ? monitorPaused + ? canCheckMonitorNow ? "Check now to request a manual check" : "Resume this task to continue checking" + : monitorIsDueNow ? "checking momentarily…" : `${formatMonitorAbsolute(monitorNextCheckAt, {}, monitorNow)}${monitorIsOverdue ? " · fires on next tick" : monitorAttemptCount > 0 ? ` · Attempt ${monitorAttemptCount}` : ""}` : monitorState?.status === "cleared" @@ -1376,7 +1382,7 @@ export function IssueProperties({