Add issue monitor follow-up controls

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-05-01 20:58:29 -05:00
1 parent ba7486090f
commit 1b46ba6674
19 files changed
+852 -116

No files matched your search

+11
View File
@@ -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];
+4
View File
@@ -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,
+14
View File
@@ -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;
}
+14
View File
@@ -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(),
});
@@ -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,
}),
);
});
});
@@ -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);
@@ -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: {
+37
View File
@@ -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) {
+275 -114
View File
@@ -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<string, unknown> | null;
executionState: Record<string, unknown> | 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,
@@ -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;
+6
View File
@@ -1422,6 +1422,12 @@ const issueListSelect = {
assigneeAdapterOverrides: issues.assigneeAdapterOverrides,
executionPolicy: sql<null>`null`,
executionState: sql<null>`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>`null`,
@@ -23,6 +23,7 @@ export interface IssueLivenessIssueInput {
createdByAgentId?: string | null;
createdByUserId?: string | null;
executionState?: Record<string, unknown> | 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<string, unknown>;
@@ -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) ||
+1
View File
@@ -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(
+1
View File
@@ -126,6 +126,7 @@ export const issuesApi = {
}>(`/issues/${id}/tree-control/state`),
releaseTreeHold: (id: string, holdId: string, data: ReleaseIssueTreeHold) =>
api.post<IssueTreeHold>(`/issues/${id}/tree-holds/${holdId}/release`, data),
checkMonitorNow: (id: string) => api.post<{ ok: true }>(`/issues/${id}/monitor/check-now`, {}),
remove: (id: string) => api.delete<Issue>(`/issues/${id}`),
checkout: (id: string, agentId: string) =>
api.post<Issue>(`/issues/${id}/checkout`, {
@@ -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> = {}): 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(<IssueMonitorActivityCard issue={createIssue()} onCheckNow={onCheckNow} />);
});
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(
<IssueMonitorActivityCard
issue={createIssue({
executionPolicy: {
mode: "normal",
commentRequired: true,
stages: [],
},
executionState: {
status: "idle",
currentStageId: null,
currentStageIndex: null,
currentStageType: null,
currentParticipant: null,
returnAssignee: null,
reviewRequest: null,
completedStageIds: [],
lastDecisionId: null,
lastDecisionOutcome: null,
monitor: null,
},
monitorNextCheckAt: null,
monitorNotes: null,
})}
/>,
);
});
expect(container.textContent).toBe("");
act(() => root.unmount());
});
});
@@ -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 (
<div className="mb-3 rounded-lg border border-border bg-muted/30 px-3 py-2">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0">
<div className="text-sm font-medium text-foreground">Monitor scheduled</div>
<div className="text-xs text-muted-foreground">
Next check {formatDateTime(monitor.nextCheckAt)} ({formatMonitorOffset(monitor.nextCheckAt)})
</div>
{monitor.notes ? (
<div className="mt-1 text-xs text-muted-foreground">{monitor.notes}</div>
) : null}
{monitor.serviceName || monitor.externalRef ? (
<div className="mt-1 text-xs text-muted-foreground">
{[monitor.serviceName, monitor.externalRef].filter(Boolean).join(" - ")}
</div>
) : null}
{monitor.attemptCount > 0 ? (
<div className="mt-1 text-xs text-muted-foreground">Attempt {monitor.attemptCount}</div>
) : null}
</div>
{onCheckNow ? (
<Button
type="button"
variant="outline"
size="sm"
className="shrink-0 shadow-none"
onClick={onCheckNow}
disabled={checkingNow}
>
{checkingNow ? "Checking..." : "Check now"}
</Button>
) : null}
</div>
</div>
);
}
+31 -1
View File
@@ -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)}
/>
</div>
<div className="flex flex-col gap-2 md:flex-row">
<input
type="text"
className="min-w-0 flex-1 rounded-md border border-border bg-transparent px-2 py-1 text-xs"
placeholder="External service"
value={monitorServiceInput}
onChange={(e) => setMonitorServiceInput(e.target.value)}
/>
<input
type="text"
className="min-w-0 flex-1 rounded-md border border-border bg-transparent px-2 py-1 text-xs"
placeholder="External ref or URL"
value={monitorExternalRefInput}
onChange={(e) => setMonitorExternalRefInput(e.target.value)}
/>
<div className="flex items-center gap-2">
<button
type="button"
+8
View File
@@ -304,6 +304,14 @@ export function formatIssueActivityAction(
});
if (structuredChange) return structuredChange;
if (action.startsWith("issue.monitor_") && details) {
const serviceName = typeof details.serviceName === "string" && details.serviceName.trim()
? details.serviceName.trim()
: null;
const base = ISSUE_ACTIVITY_LABELS[action] ?? action.replace(/[._]/g, " ");
return serviceName ? `${base} for ${serviceName}` : base;
}
if (
(action === "issue.document_created" || action === "issue.document_updated" || action === "issue.document_deleted") &&
details
+35
View File
@@ -70,6 +70,7 @@ import { IssuesList } from "../components/IssuesList";
import { AgentIcon } from "../components/AgentIconPicker";
import { IssueReferenceActivitySummary } from "../components/IssueReferenceActivitySummary";
import { IssueRelatedWorkPanel } from "../components/IssueRelatedWorkPanel";
import { IssueMonitorActivityCard } from "../components/IssueMonitorActivityCard";
import { IssueProperties } from "../components/IssueProperties";
import { IssueRunLedger } from "../components/IssueRunLedger";
import { IssueWorkspaceCard } from "../components/IssueWorkspaceCard";
@@ -881,6 +882,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
});
type IssueDetailActivityTabProps = {
issue: Issue;
issueId: string;
companyId: string;
issueStatus: Issue["status"];
@@ -891,10 +893,13 @@ type IssueDetailActivityTabProps = {
userProfileMap: Map<string, import("../lib/company-members").CompanyUserProfile>;
pendingApprovalAction: { approvalId: string; action: "approve" | "reject" } | null;
onApprovalAction: (approvalId: string, action: "approve" | "reject") => void;
onCheckMonitorNow: () => void;
checkingMonitorNow: boolean;
handoffFocusSignal?: number;
};
function IssueDetailActivityTab({
issue,
issueId,
companyId,
issueStatus,
@@ -905,6 +910,8 @@ function IssueDetailActivityTab({
userProfileMap,
pendingApprovalAction,
onApprovalAction,
onCheckMonitorNow,
checkingMonitorNow,
handoffFocusSignal = 0,
}: IssueDetailActivityTabProps) {
const { data: activity, isLoading: activityLoading } = useQuery({
@@ -1091,6 +1098,11 @@ function IssueDetailActivityTab({
</div>
)}
<IssueContinuationHandoff document={continuationHandoff} focusSignal={handoffFocusSignal} />
<IssueMonitorActivityCard
issue={issue}
onCheckNow={onCheckMonitorNow}
checkingNow={checkingMonitorNow}
/>
</>
);
}
@@ -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() {
<TabsContent value="activity">
{detailTab === "activity" ? (
<IssueDetailActivityTab
issue={issue}
issueId={issue.id}
companyId={issue.companyId}
issueStatus={issue.status}
@@ -3689,6 +3722,8 @@ export function IssueDetail() {
onApprovalAction={(approvalId, action) => {
approvalDecision.mutate({ approvalId, action });
}}
onCheckMonitorNow={() => checkIssueMonitorNow.mutate()}
checkingMonitorNow={checkIssueMonitorNow.isPending}
/>
) : null}
</TabsContent>