diff --git a/doc/execution-semantics.md b/doc/execution-semantics.md index b65f9b8f347..9b561f2852c 100644 --- a/doc/execution-semantics.md +++ b/doc/execution-semantics.md @@ -222,12 +222,17 @@ Use a monitor when the current assignee owns a future check against an async sys Monitor policy lives under `executionPolicy.monitor` and includes: - `nextCheckAt`: when Paperclip should wake the assignee -- `notes`: what the assignee should check -- `serviceName` and `externalRef`: optional structured external-service context +- `notes`: non-secret instructions for what the assignee should check +- `serviceName`: optional non-secret external-service context +- `externalRef`: optional external-service reference input; Paperclip treats it as secret-adjacent, redacts it before persistence/visibility, and omits it from activity and wake payloads - `timeoutAt`, `maxAttempts`, and `recoveryPolicy`: optional recovery hints for bounded waits Monitors are not recurring intervals. When a monitor fires, Paperclip clears the scheduled monitor and queues an `issue_monitor_due` wake for the assignee. If the external service is still pending, the assignee must explicitly re-arm the monitor with a new `nextCheckAt`. If the issue moves to `done`, `cancelled`, an invalid status, or a human/unassigned owner, the monitor is cleared. +Because `serviceName` and `notes` remain visible in issue activity and wake context, operators should keep them short and non-secret. Put enough context for the assignee to know what to inspect, but do not include signed URLs, bearer tokens, customer secrets, tenant-private identifiers, or provider links with embedded credentials. + +Monitor bounds are enforced. Paperclip rejects attempts to re-arm a monitor whose `timeoutAt` or `maxAttempts` is already exhausted. When a scheduled monitor reaches an exhausted bound at trigger time, Paperclip clears it and follows `recoveryPolicy`: `wake_owner` queues a bounded recovery wake for the assignee, `create_recovery_issue` opens visible recovery work, and `escalate_to_board` records a board-visible escalation comment/activity. + Use `blocked` instead of a monitor when no Paperclip assignee owns a responsible polling path. In that case, name the external owner/action or create first-class recovery/blocker work. ### `blocked` diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index 7f5ede9a502..3724d42d8ab 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -249,6 +249,8 @@ export const ISSUE_EXECUTION_MONITOR_CLEAR_REASONS = [ "invalid_status", "invalid_assignee", "dispatch_skipped", + "timeout_exceeded", + "max_attempts_exhausted", ] as const; export type IssueExecutionMonitorClearReason = (typeof ISSUE_EXECUTION_MONITOR_CLEAR_REASONS)[number]; diff --git a/server/src/__tests__/issue-execution-policy-routes.test.ts b/server/src/__tests__/issue-execution-policy-routes.test.ts index 15ee31d5181..b705c1fed94 100644 --- a/server/src/__tests__/issue-execution-policy-routes.test.ts +++ b/server/src/__tests__/issue-execution-policy-routes.test.ts @@ -7,6 +7,7 @@ const mockIssueService = vi.hoisted(() => ({ getById: vi.fn(), assertCheckoutOwner: vi.fn(), update: vi.fn(), + createChild: vi.fn(), addComment: vi.fn(), findMentionedAgents: vi.fn(), getRelationSummaries: vi.fn(), @@ -23,15 +24,19 @@ const mockHeartbeatService = vi.hoisted(() => ({ cancelRun: vi.fn(async () => null), })); +const mockAccessService = vi.hoisted(() => ({ + canUser: vi.fn(async () => false), + hasPermission: vi.fn(async () => false), +})); + +const mockLogActivity = vi.hoisted(() => vi.fn(async () => undefined)); + function registerModuleMocks() { vi.doMock("../services/index.js", () => ({ companyService: () => ({ getById: vi.fn(async () => ({ id: "company-1", attachmentMaxBytes: 10 * 1024 * 1024 })), }), - accessService: () => ({ - canUser: vi.fn(async () => false), - hasPermission: vi.fn(async () => false), - }), + accessService: () => mockAccessService, agentService: () => ({ getById: vi.fn(async () => null), }), @@ -43,6 +48,9 @@ function registerModuleMocks() { }), goalService: () => ({}), heartbeatService: () => mockHeartbeatService, + environmentService: () => ({ + getById: vi.fn(async () => null), + }), instanceSettingsService: () => ({ get: vi.fn(async () => ({ id: "instance-settings-1", @@ -68,7 +76,7 @@ function registerModuleMocks() { syncIssue: async () => undefined, }), issueService: () => mockIssueService, - logActivity: vi.fn(async () => undefined), + logActivity: mockLogActivity, projectService: () => ({}), routineService: () => ({ syncRunStatusForIssue: vi.fn(async () => undefined), @@ -77,7 +85,22 @@ function registerModuleMocks() { })); } -async function createApp() { +type TestActor = + | { + type: "board"; + userId: string; + companyIds: string[]; + source: "local_implicit"; + isInstanceAdmin: boolean; + } + | { + type: "agent"; + agentId: string; + companyId: string; + runId: string | null; + }; + +async function createApp(actor?: TestActor) { const [{ errorHandler }, { issueRoutes }] = await Promise.all([ import("../middleware/index.js"), import("../routes/issues.js"), @@ -85,7 +108,7 @@ async function createApp() { const app = express(); app.use(express.json()); app.use((req, _res, next) => { - (req as any).actor = { + (req as any).actor = actor ?? { type: "board", userId: "local-board", companyIds: ["company-1"], @@ -112,6 +135,17 @@ describe("issue execution policy routes", () => { mockIssueService.getRelationSummaries.mockResolvedValue({ blockedBy: [], blocks: [] }); mockIssueService.listWakeableBlockedDependents.mockResolvedValue([]); mockIssueService.getWakeableParentAfterChildCompletion.mockResolvedValue(null); + mockIssueService.createChild.mockResolvedValue({ + issue: { + id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", + companyId: "company-1", + identifier: "PAP-1002", + title: "Child issue", + }, + parentBlockerAdded: false, + }); + mockAccessService.canUser.mockResolvedValue(false); + mockAccessService.hasPermission.mockResolvedValue(false); }); it("does not auto-start execution review when reviewers are added to an already in_review issue", async () => { @@ -200,4 +234,138 @@ describe("issue execution policy routes", () => { }), ); }); + + it("lets a board user create a child issue with a scheduled monitor", async () => { + mockIssueService.getById.mockResolvedValue({ + id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + companyId: "company-1", + status: "in_progress", + assigneeAgentId: "11111111-1111-4111-8111-111111111111", + assigneeUserId: null, + createdByUserId: "local-board", + identifier: "PAP-1001", + title: "Parent issue", + executionPolicy: null, + executionState: null, + }); + + const res = await request(await createApp()) + .post("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/children") + .send({ + title: "Child monitor", + status: "in_review", + assigneeAgentId: "33333333-3333-4333-8333-333333333333", + executionPolicy: { + monitor: { + nextCheckAt: "2026-04-11T12:30:00.000Z", + scheduledBy: "assignee", + }, + }, + }); + + expect(res.status).toBe(201); + const createPayload = mockIssueService.createChild.mock.calls[0]?.[1] as { + executionPolicy: { monitor: { scheduledBy: string } }; + }; + expect(createPayload.executionPolicy.monitor.scheduledBy).toBe("board"); + expect(mockLogActivity).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + action: "issue.monitor_scheduled", + details: expect.objectContaining({ + scheduledBy: "board", + }), + }), + ); + }); + + it("rejects child monitor scheduling by a non-assignee agent even with task assignment permission", async () => { + mockAccessService.hasPermission.mockResolvedValue(true); + mockIssueService.getById.mockResolvedValue({ + id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + companyId: "company-1", + status: "in_progress", + assigneeAgentId: "11111111-1111-4111-8111-111111111111", + assigneeUserId: null, + createdByUserId: "local-board", + identifier: "PAP-1001", + title: "Parent issue", + executionPolicy: null, + executionState: null, + }); + + const res = await request(await createApp({ + type: "agent", + agentId: "22222222-2222-4222-8222-222222222222", + companyId: "company-1", + runId: "run-1", + })) + .post("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/children") + .send({ + title: "Child monitor", + status: "in_review", + assigneeAgentId: "33333333-3333-4333-8333-333333333333", + executionPolicy: { + monitor: { + nextCheckAt: "2026-04-11T12:30:00.000Z", + scheduledBy: "board", + }, + }, + }); + + expect(res.status).toBe(403); + expect(res.body.error).toBe("Only the assignee agent or a board user can manage issue monitors"); + expect(mockIssueService.createChild).not.toHaveBeenCalled(); + }); + + it("normalizes spoofed child monitor scheduledBy to the assignee actor", async () => { + mockAccessService.hasPermission.mockResolvedValue(true); + mockIssueService.getById.mockResolvedValue({ + 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: "Parent issue", + executionPolicy: null, + executionState: null, + }); + + const res = await request(await createApp({ + type: "agent", + agentId: "33333333-3333-4333-8333-333333333333", + companyId: "company-1", + runId: "run-1", + })) + .post("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/children") + .send({ + title: "Child monitor", + status: "in_review", + assigneeAgentId: "33333333-3333-4333-8333-333333333333", + executionPolicy: { + monitor: { + nextCheckAt: "2026-04-11T12:30:00.000Z", + scheduledBy: "board", + externalRef: "https://example.test/deploy?token=secret", + }, + }, + }); + + expect(res.status).toBe(201); + const createPayload = mockIssueService.createChild.mock.calls[0]?.[1] as { + executionPolicy: { monitor: { scheduledBy: string; externalRef: string | null } }; + }; + expect(createPayload.executionPolicy.monitor.scheduledBy).toBe("assignee"); + expect(createPayload.executionPolicy.monitor.externalRef).toBe("[redacted]"); + expect(mockLogActivity).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + action: "issue.monitor_scheduled", + entityId: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", + details: expect.not.objectContaining({ externalRef: expect.anything() }), + }), + ); + }); }); diff --git a/server/src/__tests__/issue-execution-policy.test.ts b/server/src/__tests__/issue-execution-policy.test.ts index e3267eb46bd..c66dde8f53b 100644 --- a/server/src/__tests__/issue-execution-policy.test.ts +++ b/server/src/__tests__/issue-execution-policy.test.ts @@ -118,6 +118,7 @@ describe("normalizeIssueExecutionPolicy", () => { monitor: { nextCheckAt: "2026-04-11T12:30:00.000Z", notes: "Check deployment", + externalRef: "https://example.test/deploy?token=secret", }, stages: [], }); @@ -127,6 +128,7 @@ describe("normalizeIssueExecutionPolicy", () => { nextCheckAt: "2026-04-11T12:30:00.000Z", notes: "Check deployment", scheduledBy: "assignee", + externalRef: "[redacted]", }, }); }); @@ -1410,5 +1412,38 @@ describe("issue execution policy transitions", () => { }), ).toThrow("Monitor can only be scheduled"); }); + + it("rejects explicitly re-arming a monitor after max attempts are exhausted", () => { + const policy = normalizeIssueExecutionPolicy({ + stages: [], + monitor: { + nextCheckAt: "2099-04-11T12:30:00.000Z", + maxAttempts: 1, + scheduledBy: "assignee", + }, + })!; + + expect(() => + applyIssueExecutionPolicyTransition({ + issue: { + status: "in_review", + assigneeAgentId: coderAgentId, + assigneeUserId: null, + executionPolicy: null, + executionState: null, + monitorAttemptCount: 1, + monitorNextCheckAt: null, + monitorLastTriggeredAt: null, + monitorNotes: null, + monitorScheduledBy: "assignee", + }, + policy, + previousPolicy: null, + requestedAssigneePatch: {}, + actor: { agentId: coderAgentId }, + monitorExplicitlyUpdated: true, + }), + ).toThrow("Monitor bounds are already exhausted"); + }); }); }); diff --git a/server/src/__tests__/issue-monitor-scheduler.test.ts b/server/src/__tests__/issue-monitor-scheduler.test.ts index a39f16d52f6..44fa27e0968 100644 --- a/server/src/__tests__/issue-monitor-scheduler.test.ts +++ b/server/src/__tests__/issue-monitor-scheduler.test.ts @@ -1,5 +1,5 @@ import { randomUUID } from "node:crypto"; -import { eq } from "drizzle-orm"; +import { eq, sql } from "drizzle-orm"; import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; import { activityLog, @@ -9,9 +9,15 @@ import { companies, companySkills, createDb, + documentRevisions, + documents, + environmentLeases, heartbeatRunEvents, heartbeatRuns, + issueComments, + issueDocuments, issues, + workspaceRuntimeServices, } from "@paperclipai/db"; import { getEmbeddedPostgresTestSupport, @@ -39,22 +45,90 @@ describeEmbeddedPostgres("issue monitor scheduler", () => { db = createDb(tempDb.connectionString); }, 20_000); - afterEach(async () => { - const heartbeat = heartbeatService(db); - for (const agentId of seededAgentIds) { - await heartbeat.cancelActiveForAgent(agentId).catch(() => undefined); + async function waitForHeartbeatIdle(timeoutMs = 3_000) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const active = await db + .select({ id: heartbeatRuns.id }) + .from(heartbeatRuns) + .where(sql`${heartbeatRuns.status} in ('queued', 'running', 'scheduled_retry')`); + if (active.length === 0) return; + await new Promise((resolve) => setTimeout(resolve, 50)); } - seededAgentIds.clear(); - await new Promise((resolve) => setTimeout(resolve, 50)); - await db.delete(issues); + throw new Error("Timed out waiting for issue monitor heartbeat runs to settle"); + } + + async function heartbeatSideEffectFingerprint() { + const [active, events, activity, leases, runtimeServices] = await Promise.all([ + db + .select({ count: sql`count(*)` }) + .from(heartbeatRuns) + .where(sql`${heartbeatRuns.status} in ('queued', 'running', 'scheduled_retry')`), + db.select({ count: sql`count(*)` }).from(heartbeatRunEvents), + db.select({ count: sql`count(*)` }).from(activityLog), + db.select({ count: sql`count(*)` }).from(environmentLeases), + db.select({ count: sql`count(*)` }).from(workspaceRuntimeServices), + ]); + + return [ + active[0]?.count ?? 0, + events[0]?.count ?? 0, + activity[0]?.count ?? 0, + leases[0]?.count ?? 0, + runtimeServices[0]?.count ?? 0, + ].join(":"); + } + + async function waitForHeartbeatSideEffectsSettled(timeoutMs = 5_000, quietMs = 500) { + const deadline = Date.now() + timeoutMs; + let previous = ""; + let stableSince = Date.now(); + while (Date.now() < deadline) { + const current = await heartbeatSideEffectFingerprint(); + const activeCount = Number(current.split(":")[0] ?? 0); + if (current !== previous || activeCount > 0) { + previous = current; + stableSince = Date.now(); + } else if (Date.now() - stableSince >= quietMs) { + return; + } + await new Promise((resolve) => setTimeout(resolve, 50)); + } + throw new Error("Timed out waiting for issue monitor heartbeat side effects to settle"); + } + + async function cleanupRows() { + await waitForHeartbeatSideEffectsSettled(); await db.delete(heartbeatRunEvents); + await db.delete(issueComments); + await db.delete(documentRevisions); + await db.delete(issueDocuments); + await db.delete(documents); await db.delete(activityLog); + await db.delete(environmentLeases); + await db.delete(workspaceRuntimeServices); + await db.delete(issues); await db.delete(heartbeatRuns); await db.delete(agentWakeupRequests); await db.delete(agentRuntimeState); await db.delete(agents); await db.delete(companySkills); await db.delete(companies); + } + + afterEach(async () => { + seededAgentIds.clear(); + let lastError: unknown = null; + for (let attempt = 0; attempt < 3; attempt += 1) { + try { + await cleanupRows(); + return; + } catch (error) { + lastError = error; + await new Promise((resolve) => setTimeout(resolve, 100)); + } + } + throw lastError; }); afterAll(async () => { @@ -64,6 +138,8 @@ describeEmbeddedPostgres("issue monitor scheduler", () => { async function seedFixture(input?: { agentStatus?: "active" | "paused"; issueStatus?: "in_progress" | "in_review"; + monitorAttemptCount?: number; + monitor?: Record; }) { const companyId = randomUUID(); const agentId = randomUUID(); @@ -71,6 +147,14 @@ describeEmbeddedPostgres("issue monitor scheduler", () => { const nextCheckAt = new Date("2026-04-11T12:30:00.000Z"); const issuePrefix = `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`; + const monitorAttemptCount = input?.monitorAttemptCount ?? 0; + const monitor = { + nextCheckAt: nextCheckAt.toISOString(), + notes: "Check deploy", + scheduledBy: "assignee", + ...(input?.monitor ?? {}), + }; + await db.insert(companies).values({ id: companyId, name: "Paperclip", @@ -87,7 +171,7 @@ describeEmbeddedPostgres("issue monitor scheduler", () => { adapterType: "process", adapterConfig: { command: process.execPath, - args: ["-e", "process.exit(0)"], + args: ["-e", ""], cwd: process.cwd(), }, runtimeConfig: { @@ -113,11 +197,7 @@ describeEmbeddedPostgres("issue monitor scheduler", () => { mode: "normal", commentRequired: true, stages: [], - monitor: { - nextCheckAt: nextCheckAt.toISOString(), - notes: "Check deploy", - scheduledBy: "assignee", - }, + monitor, }, executionState: { status: "idle", @@ -133,15 +213,20 @@ describeEmbeddedPostgres("issue monitor scheduler", () => { status: "scheduled", nextCheckAt: nextCheckAt.toISOString(), lastTriggeredAt: null, - attemptCount: 0, + attemptCount: monitorAttemptCount, notes: "Check deploy", scheduledBy: "assignee", + serviceName: typeof monitor.serviceName === "string" ? monitor.serviceName : null, + externalRef: typeof monitor.externalRef === "string" ? monitor.externalRef : null, + timeoutAt: typeof monitor.timeoutAt === "string" ? monitor.timeoutAt : null, + maxAttempts: typeof monitor.maxAttempts === "number" ? monitor.maxAttempts : null, + recoveryPolicy: typeof monitor.recoveryPolicy === "string" ? monitor.recoveryPolicy : null, clearedAt: null, clearReason: null, }, }, monitorNextCheckAt: nextCheckAt, - monitorAttemptCount: 0, + monitorAttemptCount, monitorNotes: "Check deploy", monitorScheduledBy: "assignee", }); @@ -253,4 +338,111 @@ describeEmbeddedPostgres("issue monitor scheduler", () => { .then((rows) => rows.map((row) => row.action)); expect(activity).toContain("issue.monitor_skipped"); }); + + it("clears exhausted monitors and queues bounded owner recovery instead of another due check", async () => { + const { issueId, agentId } = await seedFixture({ + monitorAttemptCount: 1, + monitor: { + maxAttempts: 1, + recoveryPolicy: "wake_owner", + }, + }); + const heartbeat = heartbeatService(db); + const tickAt = new Date("2026-04-11T12:31:00.000Z"); + + const result = await heartbeat.tickTimers(tickAt); + + expect(result.enqueued).toBe(0); + expect(result.skipped).toBe(1); + + const issue = await db.select().from(issues).where(eq(issues.id, issueId)).then((rows) => rows[0]!); + expect(issue.monitorNextCheckAt).toBeNull(); + expect(parseIssueExecutionState(issue.executionState)?.monitor).toMatchObject({ + status: "cleared", + clearReason: "max_attempts_exhausted", + }); + + const wakeup = await db + .select() + .from(agentWakeupRequests) + .where(eq(agentWakeupRequests.agentId, agentId)) + .then((rows) => rows[0] ?? null); + expect(wakeup?.reason).toBe("issue_monitor_recovery"); + expect(wakeup?.payload).toMatchObject({ + issueId, + clearReason: "max_attempts_exhausted", + maxAttempts: 1, + }); + + const activity = await db + .select() + .from(activityLog) + .where(eq(activityLog.entityId, issueId)) + .then((rows) => rows.map((row) => row.action)); + expect(activity).toContain("issue.monitor_exhausted"); + expect(activity).toContain("issue.monitor_recovery_wake_queued"); + expect(activity).not.toContain("issue.monitor_triggered"); + }); + + it("clears timed-out monitors and creates a visible recovery issue when requested", async () => { + const { issueId, companyId } = await seedFixture({ + monitor: { + timeoutAt: "2026-04-11T12:00:00.000Z", + recoveryPolicy: "create_recovery_issue", + }, + }); + const heartbeat = heartbeatService(db); + const tickAt = new Date("2026-04-11T12:31:00.000Z"); + + const result = await heartbeat.tickTimers(tickAt); + + expect(result.enqueued).toBe(0); + expect(result.skipped).toBe(1); + + const issue = await db.select().from(issues).where(eq(issues.id, issueId)).then((rows) => rows[0]!); + expect(issue.monitorNextCheckAt).toBeNull(); + expect(parseIssueExecutionState(issue.executionState)?.monitor).toMatchObject({ + status: "cleared", + clearReason: "timeout_exceeded", + }); + + const recoveryIssue = await db + .select() + .from(issues) + .where(eq(issues.originId, issueId)) + .then((rows) => rows.find((row) => row.companyId === companyId && row.originKind === "stranded_issue_recovery") ?? null); + expect(recoveryIssue).toMatchObject({ + parentId: issueId, + priority: "high", + }); + expect(["todo", "in_progress"]).toContain(recoveryIssue?.status); + }); + + it("omits external monitor refs from wake payloads and activity details", async () => { + const { issueId, agentId } = await seedFixture({ + monitor: { + serviceName: "Deploy provider", + externalRef: "https://provider.example/deploy/123?token=secret", + }, + }); + const heartbeat = heartbeatService(db); + const tickAt = new Date("2026-04-11T12:31:00.000Z"); + + await heartbeat.tickTimers(tickAt); + + const wakeup = await db + .select() + .from(agentWakeupRequests) + .where(eq(agentWakeupRequests.agentId, agentId)) + .then((rows) => rows[0] ?? null); + expect(JSON.stringify(wakeup?.payload)).not.toContain("provider.example"); + expect(wakeup?.payload).not.toHaveProperty("externalRef"); + + const activity = await db + .select() + .from(activityLog) + .where(eq(activityLog.entityId, issueId)); + expect(JSON.stringify(activity.map((row) => row.details))).not.toContain("provider.example"); + expect(activity.find((row) => row.action === "issue.monitor_triggered")?.details).not.toHaveProperty("externalRef"); + }); }); diff --git a/server/src/__tests__/recovery-classifiers.test.ts b/server/src/__tests__/recovery-classifiers.test.ts index 1f9b832007b..72243d3f76f 100644 --- a/server/src/__tests__/recovery-classifiers.test.ts +++ b/server/src/__tests__/recovery-classifiers.test.ts @@ -76,6 +76,7 @@ describe("recovery classifier boundary", () => { it("treats a scheduled monitor as an explicit review action path", () => { const findings = classifyIssueGraphLiveness({ + now: "2026-04-30T18:00:00.000Z", issues: [ { id: issueId, @@ -107,6 +108,66 @@ describe("recovery classifier boundary", () => { expect(findings).toEqual([]); }); + it("does not treat overdue or exhausted monitors as explicit waiting paths", () => { + const baseIssue = { + id: issueId, + companyId, + identifier: "PAP-2945", + title: "Wait for external review", + status: "in_review", + assigneeAgentId: agentId, + assigneeUserId: null, + createdByAgentId: null, + createdByUserId: null, + }; + const agents = [ + { + id: agentId, + companyId, + name: "Coder", + role: "engineer", + status: "idle", + reportsTo: managerId, + }, + ]; + + const overdue = classifyIssueGraphLiveness({ + now: "2026-04-30T20:00:00.000Z", + issues: [ + { + ...baseIssue, + executionState: null, + monitorNextCheckAt: "2026-04-30T19:00:00.000Z", + }, + ], + relations: [], + agents, + }); + + const exhausted = classifyIssueGraphLiveness({ + now: "2026-04-30T18:00:00.000Z", + issues: [ + { + ...baseIssue, + executionPolicy: { + monitor: { + nextCheckAt: "2026-04-30T19:00:00.000Z", + maxAttempts: 1, + }, + }, + executionState: null, + monitorNextCheckAt: "2026-04-30T19:00:00.000Z", + monitorAttemptCount: 1, + }, + ], + relations: [], + agents, + }); + + expect(overdue[0]?.state).toBe("in_review_without_action_path"); + expect(exhausted[0]?.state).toBe("in_review_without_action_path"); + }); + 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 5be078bee4c..4a6492b531c 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -81,6 +81,7 @@ import { applyIssueExecutionPolicyTransition, normalizeIssueExecutionPolicy, parseIssueExecutionState, + redactIssueMonitorExternalRef, setIssueExecutionPolicyMonitorScheduledBy, } from "../services/issue-execution-policy.js"; import type { PluginWorkerManager } from "../services/plugin-worker-manager.js"; @@ -204,7 +205,7 @@ function summarizeIssueMonitor( 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, + externalRef: redactIssueMonitorExternalRef(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, @@ -1915,7 +1916,6 @@ export function issueRoutes( 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, @@ -1955,7 +1955,11 @@ export function issueRoutes( await assertIssueEnvironmentSelection(parent.companyId, req.body.executionWorkspaceSettings?.environmentId); const actor = getActorInfo(req); - const executionPolicy = normalizeIssueExecutionPolicy(req.body.executionPolicy); + const executionPolicy = applyActorMonitorScheduledBy( + normalizeIssueExecutionPolicy(req.body.executionPolicy), + actor.actorType, + ); + assertCanManageIssueMonitor(req, req.body.assigneeAgentId ?? null, Boolean(executionPolicy?.monitor)); const { issue, parentBlockerAdded } = await svc.createChild(parent.id, { ...req.body, executionPolicy, @@ -1984,6 +1988,30 @@ export function issueRoutes( }, }); + if (executionPolicy?.monitor) { + await logActivity(db, { + companyId: parent.companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: "issue.monitor_scheduled", + entityType: "issue", + entityId: issue.id, + details: { + identifier: issue.identifier, + parentId: parent.id, + nextCheckAt: executionPolicy.monitor.nextCheckAt, + notes: executionPolicy.monitor.notes, + scheduledBy: executionPolicy.monitor.scheduledBy, + serviceName: executionPolicy.monitor.serviceName ?? null, + timeoutAt: executionPolicy.monitor.timeoutAt ?? null, + maxAttempts: executionPolicy.monitor.maxAttempts ?? null, + recoveryPolicy: executionPolicy.monitor.recoveryPolicy ?? null, + }, + }); + } + void queueIssueAssignmentWakeup({ heartbeat, issue, @@ -2497,7 +2525,6 @@ export function issueRoutes( notes: nextMonitor.notes, scheduledBy: nextMonitor.scheduledBy, serviceName: nextMonitor.serviceName, - externalRef: nextMonitor.externalRef, timeoutAt: nextMonitor.timeoutAt, maxAttempts: nextMonitor.maxAttempts, recoveryPolicy: nextMonitor.recoveryPolicy, diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 3fdccf9be61..4d335f5ed4e 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -14,6 +14,9 @@ import { type EnvironmentLeaseStatus, type ExecutionWorkspace, type ExecutionWorkspaceConfig, + type IssueExecutionMonitorClearReason, + type IssueExecutionMonitorPolicy, + type IssueExecutionMonitorRecoveryPolicy, type ModelProfileKey, type RunLivenessState, } from "@paperclipai/shared"; @@ -2336,11 +2339,15 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) const issueMonitorDispatchColumns = { id: issues.id, companyId: issues.companyId, + projectId: issues.projectId, + goalId: issues.goalId, identifier: issues.identifier, title: issues.title, status: issues.status, + priority: issues.priority, assigneeAgentId: issues.assigneeAgentId, assigneeUserId: issues.assigneeUserId, + billingCode: issues.billingCode, executionPolicy: issues.executionPolicy, executionState: issues.executionState, monitorNextCheckAt: issues.monitorNextCheckAt, @@ -2354,11 +2361,15 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) interface IssueMonitorDispatchRow { id: string; companyId: string; + projectId: string | null; + goalId: string | null; identifier: string | null; title: string; status: string; + priority: string; assigneeAgentId: string | null; assigneeUserId: string | null; + billingCode: string | null; executionPolicy: Record | null; executionState: Record | null; monitorNextCheckAt: Date | null; @@ -2369,6 +2380,319 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) monitorScheduledBy: string | null; } + function parseMonitorDate(value: string | null | undefined) { + if (!value) return null; + const date = new Date(value); + return Number.isNaN(date.getTime()) ? null : date; + } + + function issueMonitorLimitClearReason(input: { + monitor: IssueExecutionMonitorPolicy | null; + nextAttemptCount: number; + now: Date; + }): IssueExecutionMonitorClearReason | null { + const timeoutAt = parseMonitorDate(input.monitor?.timeoutAt ?? null); + if (timeoutAt && input.now.getTime() >= timeoutAt.getTime()) { + return "timeout_exceeded"; + } + const maxAttempts = input.monitor?.maxAttempts ?? null; + if (maxAttempts !== null && input.nextAttemptCount > maxAttempts) { + return "max_attempts_exhausted"; + } + return null; + } + + function monitorRecoveryPolicy( + monitor: IssueExecutionMonitorPolicy | null, + ): IssueExecutionMonitorRecoveryPolicy { + return monitor?.recoveryPolicy ?? "wake_owner"; + } + + function monitorRecoveryDetails(input: { + claimed: IssueMonitorDispatchRow; + scheduledAtIso: string; + nextAttemptCount: number; + clearReason: IssueExecutionMonitorClearReason; + recoveryPolicy: IssueExecutionMonitorRecoveryPolicy; + monitor: IssueExecutionMonitorPolicy | null; + source: "manual" | "scheduled"; + }) { + return { + identifier: input.claimed.identifier, + nextCheckAt: input.scheduledAtIso, + attemptedAttemptCount: input.nextAttemptCount, + notes: input.claimed.monitorNotes ?? null, + serviceName: input.monitor?.serviceName ?? null, + timeoutAt: input.monitor?.timeoutAt ?? null, + maxAttempts: input.monitor?.maxAttempts ?? null, + clearReason: input.clearReason, + recoveryPolicy: input.recoveryPolicy, + source: input.source, + }; + } + + function formatIssueIdentifierLink(identifier: string | null, fallback: string) { + if (!identifier) return fallback; + const prefix = identifier.split("-")[0]; + if (!prefix || !/^[A-Z][A-Z0-9]*-\d+$/.test(identifier)) return identifier; + return `[${identifier}](/${prefix}/issues/${identifier})`; + } + + function monitorRecoveryComment(input: { + issue: IssueMonitorDispatchRow; + clearReason: IssueExecutionMonitorClearReason; + recoveryPolicy: IssueExecutionMonitorRecoveryPolicy; + nextAttemptCount: number; + }) { + const label = formatIssueIdentifierLink(input.issue.identifier, input.issue.id); + const reason = + input.clearReason === "timeout_exceeded" + ? "its timeout was reached" + : "its maximum attempt count was reached"; + return [ + `Paperclip cleared the scheduled external-service monitor for ${label} because ${reason}.`, + "", + `- Attempt count: ${input.nextAttemptCount}`, + `- Recovery policy: ${input.recoveryPolicy}`, + "", + "Next action: inspect the external service state, record the result on this issue, and restore an explicit execution or waiting path if more work remains.", + ].join("\n"); + } + + async function findOpenIssueMonitorRecoveryIssue(claimed: IssueMonitorDispatchRow) { + return db + .select() + .from(issues) + .where( + and( + eq(issues.companyId, claimed.companyId), + eq(issues.originKind, RECOVERY_ORIGIN_KINDS.strandedIssueRecovery), + eq(issues.originId, claimed.id), + isNull(issues.hiddenAt), + notInArray(issues.status, ["done", "cancelled"]), + ), + ) + .orderBy(desc(issues.createdAt)) + .limit(1) + .then((rows) => rows[0] ?? null); + } + + async function performIssueMonitorRecovery(input: { + claimed: IssueMonitorDispatchRow; + scheduledAtIso: string; + nextAttemptCount: number; + clearReason: IssueExecutionMonitorClearReason; + recoveryPolicy: IssueExecutionMonitorRecoveryPolicy; + monitor: IssueExecutionMonitorPolicy | null; + actorType: "user" | "agent" | "system"; + actorId: string; + agentId: string | null; + runId: string | null; + activitySource: "manual" | "scheduled"; + }) { + const details = monitorRecoveryDetails({ + claimed: input.claimed, + scheduledAtIso: input.scheduledAtIso, + nextAttemptCount: input.nextAttemptCount, + clearReason: input.clearReason, + recoveryPolicy: input.recoveryPolicy, + monitor: input.monitor, + source: input.activitySource, + }); + + if (input.recoveryPolicy === "create_recovery_issue") { + let recoveryIssue = await findOpenIssueMonitorRecoveryIssue(input.claimed); + if (!recoveryIssue) { + recoveryIssue = await issuesSvc.create(input.claimed.companyId, { + title: `Recover external-service monitor for ${input.claimed.identifier ?? input.claimed.title}`, + description: monitorRecoveryComment({ + issue: input.claimed, + clearReason: input.clearReason, + recoveryPolicy: input.recoveryPolicy, + nextAttemptCount: input.nextAttemptCount, + }), + status: "todo", + priority: "high", + parentId: input.claimed.id, + projectId: input.claimed.projectId, + goalId: input.claimed.goalId, + assigneeAgentId: input.claimed.assigneeAgentId, + originKind: RECOVERY_ORIGIN_KINDS.strandedIssueRecovery, + originId: input.claimed.id, + originFingerprint: `issue_monitor:${input.clearReason}`, + billingCode: input.claimed.billingCode, + }); + } + + if (recoveryIssue.assigneeAgentId) { + await enqueueWakeup(recoveryIssue.assigneeAgentId, { + source: "automation", + triggerDetail: "system", + reason: "issue_monitor_recovery_issue", + idempotencyKey: `issue-monitor-recovery-issue:${input.claimed.id}:${input.clearReason}:${input.scheduledAtIso}`, + payload: { issueId: recoveryIssue.id, sourceIssueId: input.claimed.id }, + requestedByActorType: input.actorType, + requestedByActorId: input.actorId, + contextSnapshot: { + issueId: recoveryIssue.id, + sourceIssueId: input.claimed.id, + source: "issue.monitor.recovery_issue", + wakeReason: "issue_monitor_recovery_issue", + }, + }); + } + + await logActivity(db, { + companyId: input.claimed.companyId, + actorType: input.actorType, + actorId: input.actorId, + agentId: input.agentId, + runId: input.runId, + action: "issue.monitor_recovery_issue_created", + entityType: "issue", + entityId: input.claimed.id, + details: { + ...details, + recoveryIssueId: recoveryIssue.id, + recoveryIdentifier: recoveryIssue.identifier, + }, + }); + return; + } + + if (input.recoveryPolicy === "escalate_to_board") { + await db.insert(issueComments).values({ + companyId: input.claimed.companyId, + issueId: input.claimed.id, + body: monitorRecoveryComment({ + issue: input.claimed, + clearReason: input.clearReason, + recoveryPolicy: input.recoveryPolicy, + nextAttemptCount: input.nextAttemptCount, + }), + }); + + await logActivity(db, { + companyId: input.claimed.companyId, + actorType: input.actorType, + actorId: input.actorId, + agentId: input.agentId, + runId: input.runId, + action: "issue.monitor_escalated_to_board", + entityType: "issue", + entityId: input.claimed.id, + details, + }); + return; + } + + await enqueueWakeup(input.claimed.assigneeAgentId!, { + source: "automation", + triggerDetail: "system", + reason: "issue_monitor_recovery", + idempotencyKey: `issue-monitor-recovery:${input.claimed.id}:${input.clearReason}:${input.scheduledAtIso}`, + payload: { + issueId: input.claimed.id, + monitorAttemptCount: input.nextAttemptCount, + monitorNotes: input.claimed.monitorNotes ?? null, + clearReason: input.clearReason, + serviceName: input.monitor?.serviceName ?? null, + timeoutAt: input.monitor?.timeoutAt ?? null, + maxAttempts: input.monitor?.maxAttempts ?? null, + }, + requestedByActorType: input.actorType, + requestedByActorId: input.actorId, + contextSnapshot: { + issueId: input.claimed.id, + source: "issue.monitor.recovery", + wakeReason: "issue_monitor_recovery", + monitorAttemptCount: input.nextAttemptCount, + monitorNotes: input.claimed.monitorNotes ?? null, + clearReason: input.clearReason, + serviceName: input.monitor?.serviceName ?? null, + timeoutAt: input.monitor?.timeoutAt ?? null, + maxAttempts: input.monitor?.maxAttempts ?? null, + }, + }); + + await logActivity(db, { + companyId: input.claimed.companyId, + actorType: input.actorType, + actorId: input.actorId, + agentId: input.agentId, + runId: input.runId, + action: "issue.monitor_recovery_wake_queued", + entityType: "issue", + entityId: input.claimed.id, + details, + }); + } + + async function clearIssueMonitorAndRecover(input: { + claimed: IssueMonitorDispatchRow; + policy: ReturnType; + scheduledAtIso: string; + nextAttemptCount: number; + clearReason: IssueExecutionMonitorClearReason; + recoveryPolicy: IssueExecutionMonitorRecoveryPolicy; + monitor: IssueExecutionMonitorPolicy | null; + now: Date; + actorType: "user" | "agent" | "system"; + actorId: string; + agentId: string | null; + runId: string | null; + activitySource: "manual" | "scheduled"; + }) { + await db + .update(issues) + .set({ + ...buildIssueMonitorClearedPatch({ + issue: input.claimed, + policy: input.policy, + clearReason: input.clearReason, + clearedAt: input.now, + }), + updatedAt: input.now, + }) + .where(eq(issues.id, input.claimed.id)); + + await logActivity(db, { + companyId: input.claimed.companyId, + actorType: input.actorType, + actorId: input.actorId, + agentId: input.agentId, + runId: input.runId, + action: "issue.monitor_exhausted", + entityType: "issue", + entityId: input.claimed.id, + details: monitorRecoveryDetails({ + claimed: input.claimed, + scheduledAtIso: input.scheduledAtIso, + nextAttemptCount: input.nextAttemptCount, + clearReason: input.clearReason, + recoveryPolicy: input.recoveryPolicy, + monitor: input.monitor, + source: input.activitySource, + }), + }); + + await performIssueMonitorRecovery({ + claimed: input.claimed, + scheduledAtIso: input.scheduledAtIso, + nextAttemptCount: input.nextAttemptCount, + clearReason: input.clearReason, + recoveryPolicy: input.recoveryPolicy, + monitor: input.monitor, + actorType: input.actorType, + actorId: input.actorId, + agentId: input.agentId, + runId: input.runId, + activitySource: input.activitySource, + }); + + return { outcome: "skipped" as const, reason: input.clearReason }; + } + async function dispatchClaimedIssueMonitor( claimed: IssueMonitorDispatchRow, input: { @@ -2392,14 +2716,33 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) const nextAttemptCount = (claimed.monitorAttemptCount ?? 0) + 1; const policy = normalizeIssueExecutionPolicy(claimed.executionPolicy ?? null); const monitor = policy?.monitor ?? null; + const clearReason = issueMonitorLimitClearReason({ monitor, nextAttemptCount, now: input.now }); + const recoveryPolicy = monitorRecoveryPolicy(monitor); const monitorMetadata = { serviceName: monitor?.serviceName ?? null, - externalRef: monitor?.externalRef ?? null, timeoutAt: monitor?.timeoutAt ?? null, maxAttempts: monitor?.maxAttempts ?? null, recoveryPolicy: monitor?.recoveryPolicy ?? null, }; + if (clearReason) { + return clearIssueMonitorAndRecover({ + claimed, + policy, + scheduledAtIso, + nextAttemptCount, + clearReason, + recoveryPolicy, + monitor, + now: input.now, + actorType: input.actorType, + actorId: input.actorId, + agentId: input.agentId, + runId: input.runId, + activitySource: input.activitySource, + }); + } + try { await enqueueWakeup(claimed.assigneeAgentId, { source: input.source, diff --git a/server/src/services/issue-execution-policy.ts b/server/src/services/issue-execution-policy.ts index cd0a1e86897..ec19958fa3c 100644 --- a/server/src/services/issue-execution-policy.ts +++ b/server/src/services/issue-execution-policy.ts @@ -62,6 +62,8 @@ const COMPLETED_STATUS: IssueExecutionState["status"] = "completed"; const PENDING_STATUS: IssueExecutionState["status"] = "pending"; const CHANGES_REQUESTED_STATUS: IssueExecutionState["status"] = "changes_requested"; const MONITOR_INVALID_MESSAGE = "Monitor can only be scheduled on issues assigned to an agent in in_progress or in_review"; +const MONITOR_BOUNDS_EXHAUSTED_MESSAGE = "Monitor bounds are already exhausted"; +export const REDACTED_ISSUE_MONITOR_EXTERNAL_REF = "[redacted]"; function normalizeMonitorNotes(notes: string | null | undefined) { if (typeof notes !== "string") return null; @@ -75,11 +77,15 @@ function normalizeMonitorText(value: string | null | undefined) { return trimmed.length > 0 ? trimmed : null; } +export function redactIssueMonitorExternalRef(value: string | null | undefined) { + return normalizeMonitorText(value) ? REDACTED_ISSUE_MONITOR_EXTERNAL_REF : null; +} + function monitorMetadataFromPolicy(monitor: IssueExecutionMonitorPolicy) { return { kind: monitor.kind ?? null, serviceName: normalizeMonitorText(monitor.serviceName), - externalRef: normalizeMonitorText(monitor.externalRef), + externalRef: redactIssueMonitorExternalRef(monitor.externalRef), timeoutAt: monitor.timeoutAt ?? null, maxAttempts: monitor.maxAttempts ?? null, recoveryPolicy: monitor.recoveryPolicy ?? null, @@ -90,7 +96,7 @@ function monitorMetadataFromState(state: IssueExecutionMonitorState | null | und return { kind: state?.kind ?? null, serviceName: normalizeMonitorText(state?.serviceName), - externalRef: normalizeMonitorText(state?.externalRef), + externalRef: redactIssueMonitorExternalRef(state?.externalRef), timeoutAt: state?.timeoutAt ?? null, maxAttempts: state?.maxAttempts ?? null, recoveryPolicy: state?.recoveryPolicy ?? null, @@ -263,6 +269,28 @@ function monitorClearReasonForIssue( return null; } +function parseMonitorDate(value: string | null | undefined) { + if (!value) return null; + const date = new Date(value); + return Number.isNaN(date.getTime()) ? null : date; +} + +function exhaustedMonitorClearReason(input: { + monitor: IssueExecutionMonitorPolicy; + attemptCount: number; + now: Date; +}): IssueExecutionMonitorClearReason | null { + const timeoutAt = parseMonitorDate(input.monitor.timeoutAt ?? null); + if (timeoutAt && input.now.getTime() >= timeoutAt.getTime()) { + return "timeout_exceeded"; + } + const maxAttempts = input.monitor.maxAttempts ?? null; + if (maxAttempts !== null && input.attemptCount >= maxAttempts) { + return "max_attempts_exhausted"; + } + return null; +} + function nextAssigneeIds(input: { issue: IssueLike; requestedAssigneePatch: RequestedAssigneePatch; @@ -352,7 +380,7 @@ export function normalizeIssueExecutionPolicy(input: unknown): IssueExecutionPol scheduledBy: parsed.data.monitor.scheduledBy, kind: parsed.data.monitor.kind ?? null, serviceName: normalizeMonitorText(parsed.data.monitor.serviceName), - externalRef: normalizeMonitorText(parsed.data.monitor.externalRef), + externalRef: redactIssueMonitorExternalRef(parsed.data.monitor.externalRef), timeoutAt: parsed.data.monitor.timeoutAt ?? null, maxAttempts: parsed.data.monitor.maxAttempts ?? null, recoveryPolicy: parsed.data.monitor.recoveryPolicy ?? null, @@ -884,11 +912,30 @@ function applyMonitorTransition(input: TransitionInput, stagePatch: Record | null; executionState?: Record | null; monitorNextCheckAt?: Date | string | null; + monitorAttemptCount?: number | null; } export interface IssueLivenessRelationInput { @@ -100,6 +102,7 @@ export interface IssueGraphLivenessInput { pendingInteractions?: IssueLivenessWaitingPathInput[]; pendingApprovals?: IssueLivenessWaitingPathInput[]; openRecoveryIssues?: IssueLivenessWaitingPathInput[]; + now?: Date | string; } const INVOKABLE_AGENT_STATUSES = new Set(["active", "idle", "running", "error"]); @@ -141,8 +144,43 @@ function hasWaitingPath( return waitingPaths.some((entry) => entry.companyId === companyId && entry.issueId === issueId); } -function hasScheduledMonitor(issue: IssueLivenessIssueInput) { - return Boolean(issue.monitorNextCheckAt); +function readRecord(value: unknown): Record | null { + return value && typeof value === "object" && !Array.isArray(value) + ? value as Record + : null; +} + +function readPositiveInteger(value: unknown): number | null { + return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : null; +} + +function readDateMs(value: unknown): number | null { + if (!(typeof value === "string" || value instanceof Date)) return null; + const date = value instanceof Date ? value : new Date(value); + const time = date.getTime(); + return Number.isNaN(time) ? null : time; +} + +function monitorFromIssue(issue: IssueLivenessIssueInput) { + const policyMonitor = readRecord(readRecord(issue.executionPolicy)?.monitor); + const stateMonitor = readRecord(readRecord(issue.executionState)?.monitor); + return { policyMonitor, stateMonitor }; +} + +function hasScheduledMonitor(issue: IssueLivenessIssueInput, nowMs: number) { + const nextCheckAtMs = readDateMs(issue.monitorNextCheckAt); + if (nextCheckAtMs === null || nextCheckAtMs <= nowMs) return false; + + const { policyMonitor, stateMonitor } = monitorFromIssue(issue); + const timeoutAtMs = readDateMs(policyMonitor?.timeoutAt ?? stateMonitor?.timeoutAt); + if (timeoutAtMs !== null && timeoutAtMs <= nowMs) return false; + + const maxAttempts = readPositiveInteger(policyMonitor?.maxAttempts ?? stateMonitor?.maxAttempts); + const stateAttemptCount = readPositiveInteger(stateMonitor?.attemptCount) ?? 0; + const attemptCount = issue.monitorAttemptCount ?? stateAttemptCount; + if (maxAttempts !== null && attemptCount >= maxAttempts) return false; + + return true; } function readPrincipalAgentId(principal: unknown): string | null { @@ -313,6 +351,7 @@ function finding(input: { } export function classifyIssueGraphLiveness(input: IssueGraphLivenessInput): IssueLivenessFinding[] { + const nowMs = readDateMs(input.now ?? new Date()) ?? Date.now(); const issuesById = new Map(input.issues.map((issue) => [issue.id, issue])); const agentsById = new Map(input.agents.map((agent) => [agent.id, agent])); const blockersByBlockedIssueId = new Map(); @@ -356,7 +395,7 @@ export function classifyIssueGraphLiveness(input: IssueGraphLivenessInput): Issu function hasExplicitWaitingPath(issue: IssueLivenessIssueInput) { return Boolean(issue.assigneeUserId) || - hasScheduledMonitor(issue) || + hasScheduledMonitor(issue, nowMs) || 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 5ab183ac83e..23f2922d34e 100644 --- a/server/src/services/recovery/service.ts +++ b/server/src/services/recovery/service.ts @@ -1836,8 +1836,10 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) assigneeUserId: issues.assigneeUserId, createdByAgentId: issues.createdByAgentId, createdByUserId: issues.createdByUserId, + executionPolicy: issues.executionPolicy, executionState: issues.executionState, monitorNextCheckAt: issues.monitorNextCheckAt, + monitorAttemptCount: issues.monitorAttemptCount, }) .from(issues) .where( @@ -1967,6 +1969,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) pendingInteractions: interactionRows, pendingApprovals: approvalRows, openRecoveryIssues, + now: new Date(), }); } diff --git a/ui/src/components/IssueMonitorActivityCard.test.tsx b/ui/src/components/IssueMonitorActivityCard.test.tsx index b6b67ccb347..d00aa464a53 100644 --- a/ui/src/components/IssueMonitorActivityCard.test.tsx +++ b/ui/src/components/IssueMonitorActivityCard.test.tsx @@ -126,6 +126,37 @@ describe("IssueMonitorActivityCard", () => { act(() => root.unmount()); }); + it("does not render external references from monitor metadata", () => { + const root = createRoot(container); + + act(() => { + root.render( + , + ); + }); + + expect(container.textContent).toContain("Deploy provider"); + expect(container.textContent).not.toContain("provider.example"); + expect(container.textContent).not.toContain("token=secret"); + + act(() => root.unmount()); + }); + it("renders nothing when the issue has no scheduled monitor", () => { const root = createRoot(container); diff --git a/ui/src/components/IssueMonitorActivityCard.tsx b/ui/src/components/IssueMonitorActivityCard.tsx index c4cf74fea19..2cf845ea672 100644 --- a/ui/src/components/IssueMonitorActivityCard.tsx +++ b/ui/src/components/IssueMonitorActivityCard.tsx @@ -28,7 +28,6 @@ function resolveScheduledMonitor(issue: Issue) { 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, }; } @@ -57,9 +56,9 @@ export function IssueMonitorActivityCard({ {monitor.notes ? (
{monitor.notes}
) : null} - {monitor.serviceName || monitor.externalRef ? ( + {monitor.serviceName ? (
- {[monitor.serviceName, monitor.externalRef].filter(Boolean).join(" - ")} + {monitor.serviceName}
) : null} {monitor.attemptCount > 0 ? ( diff --git a/ui/src/components/IssueProperties.tsx b/ui/src/components/IssueProperties.tsx index 26642f76c49..564cfc6422d 100644 --- a/ui/src/components/IssueProperties.tsx +++ b/ui/src/components/IssueProperties.tsx @@ -248,7 +248,6 @@ export function IssueProperties({ 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, @@ -489,12 +488,10 @@ export function IssueProperties({ setMonitorAtInput(toDateTimeLocalValue(issue.executionPolicy?.monitor?.nextCheckAt)); setMonitorNotesInput(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 @@ -525,14 +522,13 @@ export function IssueProperties({ 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, + kind: serviceName ? "external_service" : null, serviceName, - externalRef, + externalRef: null, }); setMonitorOpen(false); }; @@ -604,13 +600,6 @@ export function IssueProperties({ value={monitorServiceInput} onChange={(e) => setMonitorServiceInput(e.target.value)} /> - setMonitorExternalRefInput(e.target.value)} - />