From 1b46ba667468bf7cb472c02e5ff586c134a1dd84 Mon Sep 17 00:00:00 2001 From: Dotta Date: Thu, 30 Apr 2026 13:17:07 -0500 Subject: [PATCH] Add issue monitor follow-up controls Co-Authored-By: Paperclip --- packages/shared/src/constants.ts | 11 + packages/shared/src/index.ts | 4 + packages/shared/src/types/issue.ts | 14 + packages/shared/src/validators/issue.ts | 14 + .../issue-execution-policy-routes.test.ts | 38 ++ .../__tests__/issue-monitor-scheduler.test.ts | 48 ++- .../__tests__/recovery-classifiers.test.ts | 33 ++ server/src/routes/issues.ts | 37 ++ server/src/services/heartbeat.ts | 389 +++++++++++++----- server/src/services/issue-execution-policy.ts | 42 ++ server/src/services/issues.ts | 6 + .../services/recovery/issue-graph-liveness.ts | 6 + server/src/services/recovery/service.ts | 1 + ui/src/api/issues.ts | 1 + .../IssueMonitorActivityCard.test.tsx | 165 ++++++++ .../components/IssueMonitorActivityCard.tsx | 84 ++++ ui/src/components/IssueProperties.tsx | 32 +- ui/src/lib/activity-format.ts | 8 + ui/src/pages/IssueDetail.tsx | 35 ++ 19 files changed, 852 insertions(+), 116 deletions(-) create mode 100644 ui/src/components/IssueMonitorActivityCard.test.tsx create mode 100644 ui/src/components/IssueMonitorActivityCard.tsx diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index 7e8119d1d52..7f5ede9a502 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -224,6 +224,17 @@ export type IssueExecutionStageType = (typeof ISSUE_EXECUTION_STAGE_TYPES)[numbe export const ISSUE_MONITOR_SCHEDULED_BY = ["assignee", "board"] as const; export type IssueMonitorScheduledBy = (typeof ISSUE_MONITOR_SCHEDULED_BY)[number]; +export const ISSUE_EXECUTION_MONITOR_KINDS = ["external_service"] as const; +export type IssueExecutionMonitorKind = (typeof ISSUE_EXECUTION_MONITOR_KINDS)[number]; + +export const ISSUE_EXECUTION_MONITOR_RECOVERY_POLICIES = [ + "wake_owner", + "create_recovery_issue", + "escalate_to_board", +] as const; +export type IssueExecutionMonitorRecoveryPolicy = + (typeof ISSUE_EXECUTION_MONITOR_RECOVERY_POLICIES)[number]; + export const ISSUE_EXECUTION_STATE_STATUSES = ["idle", "pending", "changes_requested", "completed"] as const; export type IssueExecutionStateStatus = (typeof ISSUE_EXECUTION_STATE_STATUSES)[number]; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 120c9ec99c9..87a298c77aa 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -36,6 +36,8 @@ export { ISSUE_EXECUTION_POLICY_MODES, ISSUE_EXECUTION_STAGE_TYPES, ISSUE_MONITOR_SCHEDULED_BY, + ISSUE_EXECUTION_MONITOR_KINDS, + ISSUE_EXECUTION_MONITOR_RECOVERY_POLICIES, ISSUE_EXECUTION_STATE_STATUSES, ISSUE_EXECUTION_MONITOR_STATE_STATUSES, ISSUE_EXECUTION_MONITOR_CLEAR_REASONS, @@ -140,6 +142,8 @@ export { type IssueExecutionPolicyMode, type IssueExecutionStageType, type IssueMonitorScheduledBy, + type IssueExecutionMonitorKind, + type IssueExecutionMonitorRecoveryPolicy, type IssueExecutionStateStatus, type IssueExecutionMonitorStateStatus, type IssueExecutionMonitorClearReason, diff --git a/packages/shared/src/types/issue.ts b/packages/shared/src/types/issue.ts index 54db9298ccc..56e92d5a3d6 100644 --- a/packages/shared/src/types/issue.ts +++ b/packages/shared/src/types/issue.ts @@ -1,5 +1,7 @@ import type { IssueExecutionMonitorClearReason, + IssueExecutionMonitorKind, + IssueExecutionMonitorRecoveryPolicy, IssueExecutionMonitorStateStatus, IssueExecutionDecisionOutcome, IssueMonitorScheduledBy, @@ -208,6 +210,12 @@ export interface IssueExecutionMonitorPolicy { nextCheckAt: string; notes: string | null; scheduledBy: IssueMonitorScheduledBy; + kind?: IssueExecutionMonitorKind | null; + serviceName?: string | null; + externalRef?: string | null; + timeoutAt?: string | null; + maxAttempts?: number | null; + recoveryPolicy?: IssueExecutionMonitorRecoveryPolicy | null; } export interface IssueExecutionPolicy { @@ -224,6 +232,12 @@ export interface IssueExecutionMonitorState { attemptCount: number; notes: string | null; scheduledBy: IssueMonitorScheduledBy | null; + kind?: IssueExecutionMonitorKind | null; + serviceName?: string | null; + externalRef?: string | null; + timeoutAt?: string | null; + maxAttempts?: number | null; + recoveryPolicy?: IssueExecutionMonitorRecoveryPolicy | null; clearedAt: string | null; clearReason: IssueExecutionMonitorClearReason | null; } diff --git a/packages/shared/src/validators/issue.ts b/packages/shared/src/validators/issue.ts index f91107229df..57ff440d2e5 100644 --- a/packages/shared/src/validators/issue.ts +++ b/packages/shared/src/validators/issue.ts @@ -2,6 +2,8 @@ import { z } from "zod"; import { ISSUE_EXECUTION_DECISION_OUTCOMES, ISSUE_EXECUTION_MONITOR_CLEAR_REASONS, + ISSUE_EXECUTION_MONITOR_KINDS, + ISSUE_EXECUTION_MONITOR_RECOVERY_POLICIES, ISSUE_EXECUTION_MONITOR_STATE_STATUSES, ISSUE_EXECUTION_POLICY_MODES, ISSUE_EXECUTION_STAGE_TYPES, @@ -110,6 +112,12 @@ export const issueExecutionMonitorPolicySchema = z.object({ nextCheckAt: z.string().datetime(), notes: z.string().max(500).optional().nullable().default(null), scheduledBy: z.enum(ISSUE_MONITOR_SCHEDULED_BY).optional().default("assignee"), + kind: z.enum(ISSUE_EXECUTION_MONITOR_KINDS).optional().nullable().default(null), + serviceName: z.string().trim().min(1).max(120).optional().nullable().default(null), + externalRef: z.string().trim().min(1).max(500).optional().nullable().default(null), + timeoutAt: z.string().datetime().optional().nullable().default(null), + maxAttempts: z.number().int().positive().max(100).optional().nullable().default(null), + recoveryPolicy: z.enum(ISSUE_EXECUTION_MONITOR_RECOVERY_POLICIES).optional().nullable().default(null), }); export const issueExecutionPolicySchema = z.object({ @@ -126,6 +134,12 @@ export const issueExecutionMonitorStateSchema = z.object({ attemptCount: z.number().int().nonnegative().default(0), notes: z.string().max(500).nullable(), scheduledBy: z.enum(ISSUE_MONITOR_SCHEDULED_BY).nullable(), + kind: z.enum(ISSUE_EXECUTION_MONITOR_KINDS).nullable().optional().default(null), + serviceName: z.string().trim().min(1).max(120).nullable().optional().default(null), + externalRef: z.string().trim().min(1).max(500).nullable().optional().default(null), + timeoutAt: z.string().datetime().nullable().optional().default(null), + maxAttempts: z.number().int().positive().max(100).nullable().optional().default(null), + recoveryPolicy: z.enum(ISSUE_EXECUTION_MONITOR_RECOVERY_POLICIES).nullable().optional().default(null), clearedAt: z.string().datetime().nullable(), clearReason: z.enum(ISSUE_EXECUTION_MONITOR_CLEAR_REASONS).nullable(), }); diff --git a/server/src/__tests__/issue-execution-policy-routes.test.ts b/server/src/__tests__/issue-execution-policy-routes.test.ts index f21e7b95c4d..15ee31d5181 100644 --- a/server/src/__tests__/issue-execution-policy-routes.test.ts +++ b/server/src/__tests__/issue-execution-policy-routes.test.ts @@ -16,6 +16,7 @@ const mockIssueService = vi.hoisted(() => ({ const mockHeartbeatService = vi.hoisted(() => ({ wakeup: vi.fn(async () => undefined), + triggerIssueMonitor: vi.fn(async () => ({ outcome: "triggered" as const })), reportRunActivity: vi.fn(async () => undefined), getRun: vi.fn(async () => null), getActiveRunForAgent: vi.fn(async () => null), @@ -162,4 +163,41 @@ describe("issue execution policy routes", () => { expect(updatePatch.executionState).toBeUndefined(); expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled(); }); + + it("triggers a scheduled monitor immediately from the dedicated route", async () => { + const issue = { + id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + companyId: "company-1", + status: "in_progress", + assigneeAgentId: "33333333-3333-4333-8333-333333333333", + assigneeUserId: null, + createdByUserId: "local-board", + identifier: "PAP-1001", + title: "Manual monitor trigger", + executionPolicy: normalizeIssueExecutionPolicy({ + monitor: { + nextCheckAt: "2026-04-11T12:30:00.000Z", + notes: "Check deployment", + scheduledBy: "board", + }, + }), + executionState: null, + }; + mockIssueService.getById.mockResolvedValue(issue); + + const res = await request(await createApp()) + .post("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/monitor/check-now") + .send({}); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ ok: true }); + expect(mockHeartbeatService.triggerIssueMonitor).toHaveBeenCalledWith( + "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + expect.objectContaining({ + actorType: "user", + actorId: "local-board", + agentId: null, + }), + ); + }); }); diff --git a/server/src/__tests__/issue-monitor-scheduler.test.ts b/server/src/__tests__/issue-monitor-scheduler.test.ts index 912734df667..a39f16d52f6 100644 --- a/server/src/__tests__/issue-monitor-scheduler.test.ts +++ b/server/src/__tests__/issue-monitor-scheduler.test.ts @@ -46,9 +46,9 @@ describeEmbeddedPostgres("issue monitor scheduler", () => { } seededAgentIds.clear(); await new Promise((resolve) => setTimeout(resolve, 50)); - await db.delete(activityLog); await db.delete(issues); await db.delete(heartbeatRunEvents); + await db.delete(activityLog); await db.delete(heartbeatRuns); await db.delete(agentWakeupRequests); await db.delete(agentRuntimeState); @@ -184,6 +184,52 @@ describeEmbeddedPostgres("issue monitor scheduler", () => { expect(activity).toContain("issue.monitor_triggered"); }); + it("lets the board trigger a scheduled issue monitor immediately", async () => { + const { issueId, agentId, nextCheckAt } = await seedFixture(); + const heartbeat = heartbeatService(db); + const triggeredAt = new Date("2026-04-11T12:00:00.000Z"); + + const result = await heartbeat.triggerIssueMonitor(issueId, { + now: triggeredAt, + actorType: "user", + actorId: "local-board", + }); + + expect(result.outcome).toBe("triggered"); + + const issue = await db.select().from(issues).where(eq(issues.id, issueId)).then((rows) => rows[0]!); + expect(issue.monitorNextCheckAt).toBeNull(); + expect(issue.monitorLastTriggeredAt?.toISOString()).toBe(triggeredAt.toISOString()); + expect(issue.monitorAttemptCount).toBe(1); + expect(normalizeIssueExecutionPolicy(issue.executionPolicy ?? null)?.monitor ?? null).toBeNull(); + + const wakeup = await db + .select() + .from(agentWakeupRequests) + .where(eq(agentWakeupRequests.agentId, agentId)) + .then((rows) => rows[0] ?? null); + expect(wakeup?.reason).toBe("issue_monitor_due"); + expect(wakeup?.payload).toMatchObject({ + issueId, + nextCheckAt: nextCheckAt.toISOString(), + source: "manual", + }); + + const activity = await db + .select() + .from(activityLog) + .where(eq(activityLog.entityId, issueId)) + .orderBy(activityLog.createdAt); + expect(activity.map((row) => row.action)).toContain("issue.monitor_triggered"); + const triggerEvent = activity.find((row) => row.action === "issue.monitor_triggered"); + expect(triggerEvent?.actorType).toBe("user"); + expect(triggerEvent?.actorId).toBe("local-board"); + expect(triggerEvent?.details).toMatchObject({ + nextCheckAt: nextCheckAt.toISOString(), + source: "manual", + }); + }); + 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/__tests__/recovery-classifiers.test.ts b/server/src/__tests__/recovery-classifiers.test.ts index f16b29f9bb1..1f9b832007b 100644 --- a/server/src/__tests__/recovery-classifiers.test.ts +++ b/server/src/__tests__/recovery-classifiers.test.ts @@ -74,6 +74,39 @@ describe("recovery classifier boundary", () => { expect(classifyIssueGraphLiveness(input)).toEqual(classifyIssueGraphLivenessCompat(input)); }); + it("treats a scheduled monitor as an explicit review action path", () => { + const findings = classifyIssueGraphLiveness({ + issues: [ + { + id: issueId, + companyId, + identifier: "PAP-2945", + title: "Wait for external review", + status: "in_review", + assigneeAgentId: agentId, + assigneeUserId: null, + createdByAgentId: null, + createdByUserId: null, + executionState: null, + monitorNextCheckAt: "2026-04-30T19:00:00.000Z", + }, + ], + relations: [], + agents: [ + { + id: agentId, + companyId, + name: "Coder", + role: "engineer", + status: "idle", + reportsTo: managerId, + }, + ], + }); + + expect(findings).toEqual([]); + }); + it("keeps run liveness continuation decision parity with the compatibility export", () => { const input = { run: { diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index ecc15b3ac74..5be078bee4c 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -202,6 +202,12 @@ function summarizeIssueMonitor( attemptCount: issue.monitorAttemptCount ?? state?.monitor?.attemptCount ?? 0, notes: policy?.monitor?.notes ?? issue.monitorNotes ?? state?.monitor?.notes ?? null, scheduledBy: issue.monitorScheduledBy ?? policy?.monitor?.scheduledBy ?? state?.monitor?.scheduledBy ?? null, + kind: policy?.monitor?.kind ?? state?.monitor?.kind ?? null, + serviceName: policy?.monitor?.serviceName ?? state?.monitor?.serviceName ?? null, + externalRef: policy?.monitor?.externalRef ?? state?.monitor?.externalRef ?? null, + timeoutAt: policy?.monitor?.timeoutAt ?? state?.monitor?.timeoutAt ?? null, + maxAttempts: policy?.monitor?.maxAttempts ?? state?.monitor?.maxAttempts ?? null, + recoveryPolicy: policy?.monitor?.recoveryPolicy ?? state?.monitor?.recoveryPolicy ?? null, status: state?.monitor?.status ?? (policy?.monitor ? "scheduled" : null), clearReason: state?.monitor?.clearReason ?? null, }; @@ -1908,6 +1914,11 @@ export function issueRoutes( nextCheckAt: executionPolicy.monitor.nextCheckAt, notes: executionPolicy.monitor.notes, scheduledBy: executionPolicy.monitor.scheduledBy, + serviceName: executionPolicy.monitor.serviceName ?? null, + externalRef: executionPolicy.monitor.externalRef ?? null, + timeoutAt: executionPolicy.monitor.timeoutAt ?? null, + maxAttempts: executionPolicy.monitor.maxAttempts ?? null, + recoveryPolicy: executionPolicy.monitor.recoveryPolicy ?? null, }, }); } @@ -1986,6 +1997,27 @@ export function issueRoutes( res.status(201).json(issue); }); + router.post("/issues/:id/monitor/check-now", async (req, res) => { + const id = req.params.id as string; + const issue = await svc.getById(id); + if (!issue) { + res.status(404).json({ error: "Issue not found" }); + return; + } + assertCompanyAccess(req, issue.companyId); + assertCanManageIssueMonitor(req, issue.assigneeAgentId, true); + + const actor = getActorInfo(req); + await heartbeat.triggerIssueMonitor(issue.id, { + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId ?? null, + runId: actor.runId ?? null, + }); + + res.json({ ok: true }); + }); + router.patch("/issues/:id", validate(updateIssueRouteSchema), async (req, res) => { const id = req.params.id as string; const existing = await svc.getById(id); @@ -2464,6 +2496,11 @@ export function issueRoutes( previousNextCheckAt: previousMonitor.nextCheckAt, notes: nextMonitor.notes, scheduledBy: nextMonitor.scheduledBy, + serviceName: nextMonitor.serviceName, + externalRef: nextMonitor.externalRef, + timeoutAt: nextMonitor.timeoutAt, + maxAttempts: nextMonitor.maxAttempts, + recoveryPolicy: nextMonitor.recoveryPolicy, }, }); } else if (!nextMonitor.nextCheckAt && previousMonitor.nextCheckAt) { diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 8846786af4c..3fdccf9be61 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -2333,26 +2333,274 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) .then((rows) => rows[0] ?? null); } + const issueMonitorDispatchColumns = { + id: issues.id, + companyId: issues.companyId, + identifier: issues.identifier, + title: issues.title, + status: issues.status, + assigneeAgentId: issues.assigneeAgentId, + assigneeUserId: issues.assigneeUserId, + executionPolicy: issues.executionPolicy, + executionState: issues.executionState, + monitorNextCheckAt: issues.monitorNextCheckAt, + monitorWakeRequestedAt: issues.monitorWakeRequestedAt, + monitorLastTriggeredAt: issues.monitorLastTriggeredAt, + monitorAttemptCount: issues.monitorAttemptCount, + monitorNotes: issues.monitorNotes, + monitorScheduledBy: issues.monitorScheduledBy, + }; + + interface IssueMonitorDispatchRow { + id: string; + companyId: string; + identifier: string | null; + title: string; + status: string; + assigneeAgentId: string | null; + assigneeUserId: string | null; + executionPolicy: Record | null; + executionState: Record | null; + monitorNextCheckAt: Date | null; + monitorWakeRequestedAt: Date | null; + monitorLastTriggeredAt: Date | null; + monitorAttemptCount: number | null; + monitorNotes: string | null; + monitorScheduledBy: string | null; + } + + async function dispatchClaimedIssueMonitor( + claimed: IssueMonitorDispatchRow, + input: { + now: Date; + source: "automation" | "on_demand"; + triggerDetail: "manual" | "system"; + wakeReason: string; + actorType: "user" | "agent" | "system"; + actorId: string; + agentId: string | null; + runId: string | null; + clearOnClientError: boolean; + activitySource: "manual" | "scheduled"; + }, + ) { + if (!claimed.assigneeAgentId || !claimed.monitorNextCheckAt) { + throw conflict("Issue monitor is not ready to dispatch"); + } + + const scheduledAtIso = claimed.monitorNextCheckAt.toISOString(); + const nextAttemptCount = (claimed.monitorAttemptCount ?? 0) + 1; + const policy = normalizeIssueExecutionPolicy(claimed.executionPolicy ?? null); + const monitor = policy?.monitor ?? null; + const monitorMetadata = { + serviceName: monitor?.serviceName ?? null, + externalRef: monitor?.externalRef ?? null, + timeoutAt: monitor?.timeoutAt ?? null, + maxAttempts: monitor?.maxAttempts ?? null, + recoveryPolicy: monitor?.recoveryPolicy ?? null, + }; + + try { + await enqueueWakeup(claimed.assigneeAgentId, { + source: input.source, + triggerDetail: input.triggerDetail, + reason: input.wakeReason, + idempotencyKey: `issue-monitor:${claimed.id}:${scheduledAtIso}`, + payload: { + issueId: claimed.id, + nextCheckAt: scheduledAtIso, + monitorAttemptCount: nextAttemptCount, + monitorNotes: claimed.monitorNotes ?? null, + ...monitorMetadata, + source: input.activitySource, + }, + requestedByActorType: input.actorType, + requestedByActorId: input.actorId, + contextSnapshot: { + issueId: claimed.id, + source: "issue.monitor", + wakeReason: input.wakeReason, + nextCheckAt: scheduledAtIso, + monitorAttemptCount: nextAttemptCount, + monitorNotes: claimed.monitorNotes ?? null, + ...monitorMetadata, + manualTrigger: input.activitySource === "manual", + }, + }); + + await db + .update(issues) + .set({ + ...buildIssueMonitorTriggeredPatch({ + issue: claimed, + policy, + triggeredAt: input.now, + }), + updatedAt: new Date(), + }) + .where(eq(issues.id, claimed.id)); + + await logActivity(db, { + companyId: claimed.companyId, + actorType: input.actorType, + actorId: input.actorId, + agentId: input.agentId, + runId: input.runId, + action: "issue.monitor_triggered", + entityType: "issue", + entityId: claimed.id, + details: { + identifier: claimed.identifier, + nextCheckAt: scheduledAtIso, + lastTriggeredAt: input.now.toISOString(), + attemptCount: nextAttemptCount, + notes: claimed.monitorNotes ?? null, + ...monitorMetadata, + source: input.activitySource, + }, + }); + + return { outcome: "triggered" as const }; + } catch (err) { + if (err instanceof HttpError && err.status >= 400 && err.status < 500) { + if (input.clearOnClientError) { + await db + .update(issues) + .set({ + ...buildIssueMonitorClearedPatch({ + issue: claimed, + policy, + clearReason: "dispatch_skipped", + clearedAt: input.now, + }), + updatedAt: new Date(), + }) + .where(eq(issues.id, claimed.id)); + + await logActivity(db, { + companyId: claimed.companyId, + actorType: input.actorType, + actorId: input.actorId, + agentId: input.agentId, + runId: input.runId, + action: "issue.monitor_skipped", + entityType: "issue", + entityId: claimed.id, + details: { + identifier: claimed.identifier, + nextCheckAt: scheduledAtIso, + attemptCount: nextAttemptCount, + notes: claimed.monitorNotes ?? null, + reason: err.message, + source: input.activitySource, + }, + }); + + return { outcome: "skipped" as const, reason: err.message }; + } + + await db + .update(issues) + .set({ + monitorWakeRequestedAt: null, + updatedAt: new Date(), + }) + .where(eq(issues.id, claimed.id)); + } else { + await db + .update(issues) + .set({ + monitorWakeRequestedAt: null, + updatedAt: new Date(), + }) + .where(eq(issues.id, claimed.id)); + } + + throw err; + } + } + + async function triggerIssueMonitor(issueId: string, input?: { + now?: Date; + actorType?: "user" | "agent" | "system"; + actorId?: string | null; + agentId?: string | null; + runId?: string | null; + wakeReason?: string; + }) { + const now = input?.now ?? new Date(); + const actorType = input?.actorType ?? "system"; + const actorId = input?.actorId ?? (actorType === "system" ? "heartbeat_scheduler" : null); + if (!actorId) { + throw conflict("Issue monitor trigger requires an actor"); + } + + const issue = await db + .select(issueMonitorDispatchColumns) + .from(issues) + .where(eq(issues.id, issueId)) + .limit(1) + .then((rows) => rows[0] ?? null); + if (!issue) { + throw notFound("Issue not found"); + } + if (!issue.monitorNextCheckAt) { + throw conflict("Issue has no scheduled monitor"); + } + if (!issue.assigneeAgentId || issue.assigneeUserId) { + throw conflict("Issue monitor requires an agent assignee"); + } + if (!["in_progress", "in_review"].includes(issue.status)) { + throw conflict("Issue monitor can only run while the issue is in progress or in review"); + } + + const staleClaimThreshold = new Date(now.getTime() - 5 * 60 * 1000); + const claimed = await db.transaction(async (tx) => { + const [updated] = await tx + .update(issues) + .set({ + monitorWakeRequestedAt: now, + updatedAt: now, + }) + .where( + and( + eq(issues.id, issueId), + sql`${issues.monitorNextCheckAt} is not null`, + isNull(issues.assigneeUserId), + sql`${issues.assigneeAgentId} is not null`, + inArray(issues.status, ["in_progress", "in_review"]), + or( + isNull(issues.monitorWakeRequestedAt), + lt(issues.monitorWakeRequestedAt, staleClaimThreshold), + ), + ), + ) + .returning(); + return (updated ?? null) as IssueMonitorDispatchRow | null; + }); + + if (!claimed) { + throw conflict("Issue monitor check is already in progress"); + } + + return dispatchClaimedIssueMonitor(claimed, { + now, + source: "on_demand", + triggerDetail: "manual", + wakeReason: input?.wakeReason ?? "issue_monitor_due", + actorType, + actorId, + agentId: input?.agentId ?? null, + runId: input?.runId ?? null, + clearOnClientError: false, + activitySource: "manual", + }); + } + async function tickDueIssueMonitors(now = new Date()) { const staleClaimThreshold = new Date(now.getTime() - 5 * 60 * 1000); const dueMonitors = await db - .select({ - id: issues.id, - companyId: issues.companyId, - identifier: issues.identifier, - title: issues.title, - status: issues.status, - assigneeAgentId: issues.assigneeAgentId, - assigneeUserId: issues.assigneeUserId, - executionPolicy: issues.executionPolicy, - executionState: issues.executionState, - monitorNextCheckAt: issues.monitorNextCheckAt, - monitorWakeRequestedAt: issues.monitorWakeRequestedAt, - monitorLastTriggeredAt: issues.monitorLastTriggeredAt, - monitorAttemptCount: issues.monitorAttemptCount, - monitorNotes: issues.monitorNotes, - monitorScheduledBy: issues.monitorScheduledBy, - }) + .select(issueMonitorDispatchColumns) .from(issues) .where( and( @@ -2396,115 +2644,27 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) ), ) .returning(); - return updated ?? null; + return (updated ?? null) as IssueMonitorDispatchRow | null; }); - if (!claimed?.assigneeAgentId || !claimed.monitorNextCheckAt) continue; - - const scheduledAtIso = claimed.monitorNextCheckAt.toISOString(); - const nextAttemptCount = (claimed.monitorAttemptCount ?? 0) + 1; - const policy = normalizeIssueExecutionPolicy(claimed.executionPolicy ?? null); + if (!claimed) continue; try { - await enqueueWakeup(claimed.assigneeAgentId, { + const result = await dispatchClaimedIssueMonitor(claimed, { + now, source: "automation", triggerDetail: "system", - reason: "issue_monitor_due", - idempotencyKey: `issue-monitor:${claimed.id}:${scheduledAtIso}`, - payload: { - issueId: claimed.id, - nextCheckAt: scheduledAtIso, - monitorAttemptCount: nextAttemptCount, - monitorNotes: claimed.monitorNotes ?? null, - }, - requestedByActorType: "system", - requestedByActorId: "heartbeat_scheduler", - contextSnapshot: { - issueId: claimed.id, - source: "issue.monitor", - wakeReason: "issue_monitor_due", - nextCheckAt: scheduledAtIso, - monitorAttemptCount: nextAttemptCount, - monitorNotes: claimed.monitorNotes ?? null, - }, - }); - - await db - .update(issues) - .set({ - ...buildIssueMonitorTriggeredPatch({ - issue: claimed, - policy, - triggeredAt: now, - }), - updatedAt: new Date(), - }) - .where(eq(issues.id, claimed.id)); - - await logActivity(db, { - companyId: claimed.companyId, + wakeReason: "issue_monitor_due", actorType: "system", actorId: "heartbeat_scheduler", agentId: null, runId: null, - action: "issue.monitor_triggered", - entityType: "issue", - entityId: claimed.id, - details: { - identifier: claimed.identifier, - nextCheckAt: scheduledAtIso, - lastTriggeredAt: now.toISOString(), - attemptCount: nextAttemptCount, - notes: claimed.monitorNotes ?? null, - }, + clearOnClientError: true, + activitySource: "scheduled", }); - - triggered += 1; + if (result.outcome === "triggered") triggered += 1; + if (result.outcome === "skipped") skipped += 1; } catch (err) { - if (err instanceof HttpError && err.status >= 400 && err.status < 500) { - await db - .update(issues) - .set({ - ...buildIssueMonitorClearedPatch({ - issue: claimed, - policy, - clearReason: "dispatch_skipped", - clearedAt: now, - }), - updatedAt: new Date(), - }) - .where(eq(issues.id, claimed.id)); - - await logActivity(db, { - companyId: claimed.companyId, - actorType: "system", - actorId: "heartbeat_scheduler", - agentId: null, - runId: null, - action: "issue.monitor_skipped", - entityType: "issue", - entityId: claimed.id, - details: { - identifier: claimed.identifier, - nextCheckAt: scheduledAtIso, - attemptCount: nextAttemptCount, - notes: claimed.monitorNotes ?? null, - reason: err.message, - }, - }); - - skipped += 1; - continue; - } - - await db - .update(issues) - .set({ - monitorWakeRequestedAt: null, - updatedAt: new Date(), - }) - .where(eq(issues.id, claimed.id)); - logger.error({ err, issueId: claimed.id }, "issue monitor tick failed"); } } @@ -7923,6 +8083,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) }), wakeup: enqueueWakeup, + triggerIssueMonitor, reportRunActivity: clearDetachedRunWarning, diff --git a/server/src/services/issue-execution-policy.ts b/server/src/services/issue-execution-policy.ts index 4de6e303ec6..cd0a1e86897 100644 --- a/server/src/services/issue-execution-policy.ts +++ b/server/src/services/issue-execution-policy.ts @@ -69,6 +69,34 @@ function normalizeMonitorNotes(notes: string | null | undefined) { return trimmed.length > 0 ? trimmed : null; } +function normalizeMonitorText(value: string | null | undefined) { + if (typeof value !== "string") return null; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +function monitorMetadataFromPolicy(monitor: IssueExecutionMonitorPolicy) { + return { + kind: monitor.kind ?? null, + serviceName: normalizeMonitorText(monitor.serviceName), + externalRef: normalizeMonitorText(monitor.externalRef), + timeoutAt: monitor.timeoutAt ?? null, + maxAttempts: monitor.maxAttempts ?? null, + recoveryPolicy: monitor.recoveryPolicy ?? null, + }; +} + +function monitorMetadataFromState(state: IssueExecutionMonitorState | null | undefined) { + return { + kind: state?.kind ?? null, + serviceName: normalizeMonitorText(state?.serviceName), + externalRef: normalizeMonitorText(state?.externalRef), + timeoutAt: state?.timeoutAt ?? null, + maxAttempts: state?.maxAttempts ?? null, + recoveryPolicy: state?.recoveryPolicy ?? null, + }; +} + function blankExecutionState(): IssueExecutionState { return { status: "idle", @@ -77,6 +105,7 @@ function blankExecutionState(): IssueExecutionState { currentStageType: null, currentParticipant: null, returnAssignee: null, + reviewRequest: null, completedStageIds: [], lastDecisionId: null, lastDecisionOutcome: null, @@ -120,6 +149,7 @@ function derivePersistedMonitorState(input: { const scheduledByRaw = input.issue.monitorScheduledBy ?? scheduledMonitor?.scheduledBy ?? fromState?.scheduledBy ?? null; const scheduledBy = scheduledByRaw === "assignee" || scheduledByRaw === "board" ? scheduledByRaw : null; + const metadata = scheduledMonitor ? monitorMetadataFromPolicy(scheduledMonitor) : monitorMetadataFromState(fromState); if (nextCheckAt) { return { @@ -129,6 +159,7 @@ function derivePersistedMonitorState(input: { attemptCount, notes, scheduledBy, + ...metadata, clearedAt: null, clearReason: null, }; @@ -141,6 +172,7 @@ function derivePersistedMonitorState(input: { scheduledBy, attemptCount, lastTriggeredAt, + ...metadata, }; } @@ -152,6 +184,7 @@ function derivePersistedMonitorState(input: { attemptCount, notes, scheduledBy, + ...metadata, clearedAt: null, clearReason: null, }; @@ -171,6 +204,7 @@ function buildScheduledMonitorState( attemptCount: previous?.attemptCount ?? 0, notes: monitor.notes ?? null, scheduledBy: monitor.scheduledBy, + ...monitorMetadataFromPolicy(monitor), clearedAt: null, clearReason: null, }; @@ -187,6 +221,7 @@ function buildTriggeredMonitorState(input: { attemptCount: (input.previous?.attemptCount ?? 0) + 1, notes: input.previous?.notes ?? null, scheduledBy: input.previous?.scheduledBy ?? null, + ...monitorMetadataFromState(input.previous), clearedAt: null, clearReason: null, }; @@ -204,6 +239,7 @@ function buildClearedMonitorState(input: { attemptCount: input.previous?.attemptCount ?? 0, notes: input.previous?.notes ?? null, scheduledBy: input.previous?.scheduledBy ?? null, + ...monitorMetadataFromState(input.previous), clearedAt: input.clearedAt.toISOString(), clearReason: input.clearReason, }; @@ -314,6 +350,12 @@ export function normalizeIssueExecutionPolicy(input: unknown): IssueExecutionPol nextCheckAt: parsed.data.monitor.nextCheckAt, notes: normalizeMonitorNotes(parsed.data.monitor.notes), scheduledBy: parsed.data.monitor.scheduledBy, + kind: parsed.data.monitor.kind ?? null, + serviceName: normalizeMonitorText(parsed.data.monitor.serviceName), + externalRef: normalizeMonitorText(parsed.data.monitor.externalRef), + timeoutAt: parsed.data.monitor.timeoutAt ?? null, + maxAttempts: parsed.data.monitor.maxAttempts ?? null, + recoveryPolicy: parsed.data.monitor.recoveryPolicy ?? null, } : null; diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index 1d3056c4bde..8f49238d876 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -1422,6 +1422,12 @@ const issueListSelect = { assigneeAdapterOverrides: issues.assigneeAdapterOverrides, executionPolicy: sql`null`, executionState: sql`null`, + monitorNextCheckAt: issues.monitorNextCheckAt, + monitorWakeRequestedAt: issues.monitorWakeRequestedAt, + monitorLastTriggeredAt: issues.monitorLastTriggeredAt, + monitorAttemptCount: issues.monitorAttemptCount, + monitorNotes: issues.monitorNotes, + monitorScheduledBy: issues.monitorScheduledBy, executionWorkspaceId: issues.executionWorkspaceId, executionWorkspacePreference: issues.executionWorkspacePreference, executionWorkspaceSettings: sql`null`, diff --git a/server/src/services/recovery/issue-graph-liveness.ts b/server/src/services/recovery/issue-graph-liveness.ts index 68c64183e76..7f2f40081ae 100644 --- a/server/src/services/recovery/issue-graph-liveness.ts +++ b/server/src/services/recovery/issue-graph-liveness.ts @@ -23,6 +23,7 @@ export interface IssueLivenessIssueInput { createdByAgentId?: string | null; createdByUserId?: string | null; executionState?: Record | null; + monitorNextCheckAt?: Date | string | null; } export interface IssueLivenessRelationInput { @@ -140,6 +141,10 @@ function hasWaitingPath( return waitingPaths.some((entry) => entry.companyId === companyId && entry.issueId === issueId); } +function hasScheduledMonitor(issue: IssueLivenessIssueInput) { + return Boolean(issue.monitorNextCheckAt); +} + function readPrincipalAgentId(principal: unknown): string | null { if (!principal || typeof principal !== "object") return null; const value = principal as Record; @@ -351,6 +356,7 @@ export function classifyIssueGraphLiveness(input: IssueGraphLivenessInput): Issu function hasExplicitWaitingPath(issue: IssueLivenessIssueInput) { return Boolean(issue.assigneeUserId) || + hasScheduledMonitor(issue) || hasActiveExecutionPath(issue.companyId, issue.id, activeRuns, queuedWakeRequests) || hasWaitingPath(issue.companyId, issue.id, pendingInteractions) || hasWaitingPath(issue.companyId, issue.id, pendingApprovals) || diff --git a/server/src/services/recovery/service.ts b/server/src/services/recovery/service.ts index 87192d7ad06..5ab183ac83e 100644 --- a/server/src/services/recovery/service.ts +++ b/server/src/services/recovery/service.ts @@ -1837,6 +1837,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) createdByAgentId: issues.createdByAgentId, createdByUserId: issues.createdByUserId, executionState: issues.executionState, + monitorNextCheckAt: issues.monitorNextCheckAt, }) .from(issues) .where( diff --git a/ui/src/api/issues.ts b/ui/src/api/issues.ts index 027222a0911..acae3a71ce2 100644 --- a/ui/src/api/issues.ts +++ b/ui/src/api/issues.ts @@ -126,6 +126,7 @@ export const issuesApi = { }>(`/issues/${id}/tree-control/state`), releaseTreeHold: (id: string, holdId: string, data: ReleaseIssueTreeHold) => api.post(`/issues/${id}/tree-holds/${holdId}/release`, data), + checkMonitorNow: (id: string) => api.post<{ ok: true }>(`/issues/${id}/monitor/check-now`, {}), remove: (id: string) => api.delete(`/issues/${id}`), checkout: (id: string, agentId: string) => api.post(`/issues/${id}/checkout`, { diff --git a/ui/src/components/IssueMonitorActivityCard.test.tsx b/ui/src/components/IssueMonitorActivityCard.test.tsx new file mode 100644 index 00000000000..b6b67ccb347 --- /dev/null +++ b/ui/src/components/IssueMonitorActivityCard.test.tsx @@ -0,0 +1,165 @@ +// @vitest-environment jsdom + +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import type { Issue } from "@paperclipai/shared"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { IssueMonitorActivityCard } from "./IssueMonitorActivityCard"; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +function createIssue(overrides: Partial = {}): Issue { + return { + id: "issue-1", + companyId: "company-1", + projectId: null, + projectWorkspaceId: null, + goalId: null, + parentId: null, + title: "Watch deploy", + description: null, + status: "in_progress", + priority: "medium", + assigneeAgentId: "agent-1", + assigneeUserId: null, + checkoutRunId: null, + executionRunId: null, + executionAgentNameKey: null, + executionLockedAt: null, + createdByAgentId: null, + createdByUserId: "local-board", + issueNumber: 1, + identifier: "PAP-1", + requestDepth: 0, + billingCode: null, + assigneeAdapterOverrides: null, + executionPolicy: { + mode: "normal", + commentRequired: true, + stages: [], + monitor: { + nextCheckAt: "2026-04-11T12:30:00.000Z", + notes: "Check deployment health", + scheduledBy: "board", + }, + }, + executionState: { + status: "idle", + currentStageId: null, + currentStageIndex: null, + currentStageType: null, + currentParticipant: null, + returnAssignee: null, + reviewRequest: null, + completedStageIds: [], + lastDecisionId: null, + lastDecisionOutcome: null, + monitor: { + status: "scheduled", + nextCheckAt: "2026-04-11T12:30:00.000Z", + lastTriggeredAt: null, + attemptCount: 0, + notes: "Check deployment health", + scheduledBy: "board", + clearedAt: null, + clearReason: null, + }, + }, + monitorNextCheckAt: new Date("2026-04-11T12:30:00.000Z"), + monitorLastTriggeredAt: null, + monitorAttemptCount: 0, + monitorNotes: "Check deployment health", + monitorScheduledBy: "board", + executionWorkspaceId: null, + executionWorkspacePreference: null, + executionWorkspaceSettings: null, + startedAt: null, + completedAt: null, + cancelledAt: null, + hiddenAt: null, + createdAt: new Date("2026-04-11T10:00:00.000Z"), + updatedAt: new Date("2026-04-11T10:00:00.000Z"), + ...overrides, + }; +} + +describe("IssueMonitorActivityCard", () => { + let container: HTMLDivElement; + + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-04-11T12:00:00.000Z")); + container = document.createElement("div"); + document.body.appendChild(container); + }); + + afterEach(() => { + vi.useRealTimers(); + container.remove(); + }); + + it("renders the scheduled monitor details and check-now action", () => { + const onCheckNow = vi.fn(); + const root = createRoot(container); + + act(() => { + root.render(); + }); + + expect(container.textContent).toContain("Monitor scheduled"); + expect(container.textContent).toContain("Next check"); + expect(container.textContent).toContain("in 30m"); + expect(container.textContent).toContain("Check deployment health"); + + const button = Array.from(container.querySelectorAll("button")).find((candidate) => + candidate.textContent?.includes("Check now"), + ); + expect(button).toBeTruthy(); + + act(() => { + button?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(onCheckNow).toHaveBeenCalledTimes(1); + + act(() => root.unmount()); + }); + + it("renders nothing when the issue has no scheduled monitor", () => { + const root = createRoot(container); + + act(() => { + root.render( + , + ); + }); + + expect(container.textContent).toBe(""); + + act(() => root.unmount()); + }); +}); diff --git a/ui/src/components/IssueMonitorActivityCard.tsx b/ui/src/components/IssueMonitorActivityCard.tsx new file mode 100644 index 00000000000..c4cf74fea19 --- /dev/null +++ b/ui/src/components/IssueMonitorActivityCard.tsx @@ -0,0 +1,84 @@ +import type { Issue } from "@paperclipai/shared"; +import { Button } from "@/components/ui/button"; +import { formatDateTime } from "@/lib/utils"; + +function formatMonitorOffset(nextCheckAt: Date | string): string { + const deltaMs = new Date(nextCheckAt).getTime() - Date.now(); + const absMinutes = Math.round(Math.abs(deltaMs) / 60_000); + if (absMinutes <= 0) return "now"; + if (absMinutes < 60) return deltaMs >= 0 ? `in ${absMinutes}m` : `${absMinutes}m ago`; + + const absHours = Math.round(absMinutes / 60); + if (absHours < 24) return deltaMs >= 0 ? `in ${absHours}h` : `${absHours}h ago`; + + const absDays = Math.round(absHours / 24); + return deltaMs >= 0 ? `in ${absDays}d` : `${absDays}d ago`; +} + +function resolveScheduledMonitor(issue: Issue) { + const nextCheckAt = + issue.monitorNextCheckAt?.toISOString() ?? + issue.executionPolicy?.monitor?.nextCheckAt ?? + issue.executionState?.monitor?.nextCheckAt ?? + null; + if (!nextCheckAt) return null; + + return { + nextCheckAt, + notes: issue.executionPolicy?.monitor?.notes ?? issue.monitorNotes ?? issue.executionState?.monitor?.notes ?? null, + attemptCount: issue.monitorAttemptCount ?? issue.executionState?.monitor?.attemptCount ?? 0, + serviceName: issue.executionPolicy?.monitor?.serviceName ?? issue.executionState?.monitor?.serviceName ?? null, + externalRef: issue.executionPolicy?.monitor?.externalRef ?? issue.executionState?.monitor?.externalRef ?? null, + }; +} + +interface IssueMonitorActivityCardProps { + issue: Issue; + onCheckNow?: (() => void) | null; + checkingNow?: boolean; +} + +export function IssueMonitorActivityCard({ + issue, + onCheckNow = null, + checkingNow = false, +}: IssueMonitorActivityCardProps) { + const monitor = resolveScheduledMonitor(issue); + if (!monitor) return null; + + return ( +
+
+
+
Monitor scheduled
+
+ Next check {formatDateTime(monitor.nextCheckAt)} ({formatMonitorOffset(monitor.nextCheckAt)}) +
+ {monitor.notes ? ( +
{monitor.notes}
+ ) : null} + {monitor.serviceName || monitor.externalRef ? ( +
+ {[monitor.serviceName, monitor.externalRef].filter(Boolean).join(" - ")} +
+ ) : null} + {monitor.attemptCount > 0 ? ( +
Attempt {monitor.attemptCount}
+ ) : null} +
+ {onCheckNow ? ( + + ) : null} +
+
+ ); +} diff --git a/ui/src/components/IssueProperties.tsx b/ui/src/components/IssueProperties.tsx index 5ca89cce646..fac4ff7ef2c 100644 --- a/ui/src/components/IssueProperties.tsx +++ b/ui/src/components/IssueProperties.tsx @@ -233,6 +233,8 @@ export function IssueProperties({ const [newLabelColor, setNewLabelColor] = useState("#6366f1"); const [monitorAtInput, setMonitorAtInput] = useState(() => toDateTimeLocalValue(issue.executionPolicy?.monitor?.nextCheckAt)); const [monitorNotesInput, setMonitorNotesInput] = useState(issue.executionPolicy?.monitor?.notes ?? ""); + const [monitorServiceInput, setMonitorServiceInput] = useState(issue.executionPolicy?.monitor?.serviceName ?? ""); + const [monitorExternalRefInput, setMonitorExternalRefInput] = useState(issue.executionPolicy?.monitor?.externalRef ?? ""); const { data: session } = useQuery({ queryKey: queryKeys.auth.session, @@ -472,7 +474,14 @@ export function IssueProperties({ useEffect(() => { setMonitorAtInput(toDateTimeLocalValue(issue.executionPolicy?.monitor?.nextCheckAt)); setMonitorNotesInput(issue.executionPolicy?.monitor?.notes ?? ""); - }, [issue.executionPolicy?.monitor?.nextCheckAt, issue.executionPolicy?.monitor?.notes]); + setMonitorServiceInput(issue.executionPolicy?.monitor?.serviceName ?? ""); + setMonitorExternalRefInput(issue.executionPolicy?.monitor?.externalRef ?? ""); + }, [ + issue.executionPolicy?.monitor?.nextCheckAt, + issue.executionPolicy?.monitor?.notes, + issue.executionPolicy?.monitor?.serviceName, + issue.executionPolicy?.monitor?.externalRef, + ]); const updateMonitor = (nextMonitor: Issue["executionPolicy"] extends infer T ? T extends { monitor?: infer M | null } | null | undefined @@ -501,10 +510,15 @@ export function IssueProperties({ if (!monitorAtInput) return; const nextCheckAt = new Date(monitorAtInput); if (Number.isNaN(nextCheckAt.getTime())) return; + const serviceName = monitorServiceInput.trim() || null; + const externalRef = monitorExternalRefInput.trim() || null; updateMonitor({ nextCheckAt: nextCheckAt.toISOString(), notes: monitorNotesInput.trim() || null, scheduledBy: issue.executionPolicy?.monitor?.scheduledBy ?? "assignee", + kind: serviceName || externalRef ? "external_service" : null, + serviceName, + externalRef, }); }; const clearMonitor = () => updateMonitor(null); @@ -1334,6 +1348,22 @@ export function IssueProperties({ value={monitorNotesInput} onChange={(e) => setMonitorNotesInput(e.target.value)} /> + +
+ setMonitorServiceInput(e.target.value)} + /> + setMonitorExternalRefInput(e.target.value)} + />
)} + ); } @@ -1754,6 +1766,26 @@ export function IssueDetail() { updateChildIssue.mutate({ id, data }); }, [updateChildIssue]); + const checkIssueMonitorNow = useMutation({ + mutationFn: () => issuesApi.checkMonitorNow(issueId!), + onSuccess: () => { + invalidateIssueDetail(); + invalidateIssueRunState(); + invalidateIssueCollections(); + pushToast({ + title: "Monitor check queued", + tone: "success", + }); + }, + onError: (err) => { + pushToast({ + title: "Monitor check failed", + body: err instanceof Error ? err.message : "Unable to trigger the monitor right now", + tone: "error", + }); + }, + }); + const approvalDecision = useMutation({ mutationFn: async ({ approvalId, action }: { approvalId: string; action: "approve" | "reject" }) => { if (action === "approve") { @@ -3676,6 +3708,7 @@ export function IssueDetail() { {detailTab === "activity" ? ( { approvalDecision.mutate({ approvalId, action }); }} + onCheckMonitorNow={() => checkIssueMonitorNow.mutate()} + checkingMonitorNow={checkIssueMonitorNow.isPending} /> ) : null}