diff --git a/server/src/__tests__/heartbeat-comment-wake-batching.test.ts b/server/src/__tests__/heartbeat-comment-wake-batching.test.ts index 57ce9a054c8..02cdf8c7738 100644 --- a/server/src/__tests__/heartbeat-comment-wake-batching.test.ts +++ b/server/src/__tests__/heartbeat-comment-wake-batching.test.ts @@ -786,6 +786,206 @@ describe("heartbeat comment wake batching", () => { } }, 120_000); + it("does not reopen a finished issue when the deferred comment wake came from another agent", async () => { + const gateway = await createControlledGatewayServer(); + const companyId = randomUUID(); + const assigneeAgentId = randomUUID(); + const mentionedAgentId = randomUUID(); + const issueId = randomUUID(); + const issuePrefix = `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`; + const heartbeat = heartbeatService(db); + + try { + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix, + requireBoardApprovalForNewAgents: false, + }); + + await db.insert(agents).values([ + { + id: assigneeAgentId, + companyId, + name: "Primary Agent", + role: "engineer", + status: "idle", + adapterType: "openclaw_gateway", + adapterConfig: { + url: gateway.url, + headers: { + "x-openclaw-token": "gateway-token", + }, + payloadTemplate: { + message: "wake now", + }, + waitTimeoutMs: 2_000, + }, + runtimeConfig: {}, + permissions: {}, + }, + { + id: mentionedAgentId, + companyId, + name: "Mentioned Agent", + role: "engineer", + status: "idle", + adapterType: "openclaw_gateway", + adapterConfig: { + url: gateway.url, + headers: { + "x-openclaw-token": "gateway-token", + }, + payloadTemplate: { + message: "wake now", + }, + waitTimeoutMs: 2_000, + }, + runtimeConfig: {}, + permissions: {}, + }, + ]); + + await db.insert(issues).values({ + id: issueId, + companyId, + title: "Do not reopen from agent mention", + status: "todo", + priority: "medium", + assigneeAgentId, + issueNumber: 1, + identifier: `${issuePrefix}-1`, + }); + + const firstRun = await heartbeat.wakeup(assigneeAgentId, { + source: "assignment", + triggerDetail: "system", + reason: "issue_assigned", + payload: { issueId }, + contextSnapshot: { + issueId, + taskId: issueId, + wakeReason: "issue_assigned", + }, + requestedByActorType: "system", + requestedByActorId: null, + }); + + expect(firstRun).not.toBeNull(); + await waitFor(async () => { + const run = await db + .select({ status: heartbeatRuns.status }) + .from(heartbeatRuns) + .where(eq(heartbeatRuns.id, firstRun!.id)) + .then((rows) => rows[0] ?? null); + return run?.status === "running"; + }); + + const comment = await db + .insert(issueComments) + .values({ + companyId, + issueId, + authorAgentId: assigneeAgentId, + createdByRunId: firstRun?.id ?? null, + body: "@Mentioned Agent please review after I finish", + }) + .returning() + .then((rows) => rows[0]); + + const deferredRun = await heartbeat.wakeup(mentionedAgentId, { + source: "automation", + triggerDetail: "system", + reason: "issue_comment_mentioned", + payload: { issueId, commentId: comment.id }, + contextSnapshot: { + issueId, + taskId: issueId, + commentId: comment.id, + wakeCommentId: comment.id, + wakeReason: "issue_comment_mentioned", + source: "comment.mention", + }, + requestedByActorType: "agent", + requestedByActorId: assigneeAgentId, + }); + + expect(deferredRun).toBeNull(); + + await waitFor(async () => { + const deferred = await db + .select() + .from(agentWakeupRequests) + .where( + and( + eq(agentWakeupRequests.companyId, companyId), + eq(agentWakeupRequests.agentId, mentionedAgentId), + eq(agentWakeupRequests.status, "deferred_issue_execution"), + ), + ) + .then((rows) => rows[0] ?? null); + return Boolean(deferred); + }); + + await db + .update(issues) + .set({ + status: "done", + completedAt: new Date(), + executionRunId: null, + executionAgentNameKey: null, + executionLockedAt: null, + updatedAt: new Date(), + }) + .where(eq(issues.id, issueId)); + + gateway.releaseFirstWait(); + + await waitFor(() => gateway.getAgentPayloads().length === 2, 90_000); + await waitFor(async () => { + const runs = await db + .select() + .from(heartbeatRuns) + .where(eq(heartbeatRuns.companyId, companyId)); + return runs.length === 2 && runs.every((run) => run.status === "succeeded"); + }, 90_000); + + const issueAfterPromotion = await db + .select({ + status: issues.status, + completedAt: issues.completedAt, + }) + .from(issues) + .where(eq(issues.id, issueId)) + .then((rows) => rows[0] ?? null); + + expect(issueAfterPromotion).toMatchObject({ + status: "done", + }); + expect(issueAfterPromotion?.completedAt).not.toBeNull(); + + const secondPayload = gateway.getAgentPayloads()[1] ?? {}; + expect(secondPayload.paperclip).toMatchObject({ + wake: { + reason: "issue_comment_mentioned", + commentIds: [comment.id], + latestCommentId: comment.id, + issue: { + id: issueId, + identifier: `${issuePrefix}-1`, + title: "Do not reopen from agent mention", + status: "done", + priority: "medium", + }, + }, + }); + expect(String(secondPayload.message ?? "")).toContain("please review after I finish"); + } finally { + gateway.releaseFirstWait(); + await gateway.close(); + } + }, 120_000); + it("queues exactly one follow-up run when an issue-bound run exits without a comment", async () => { const gateway = await createControlledGatewayServer(); const companyId = randomUUID(); diff --git a/server/src/__tests__/invite-test-resolution-route.test.ts b/server/src/__tests__/invite-test-resolution-route.test.ts index a89449b3d16..7ca22a99c4f 100644 --- a/server/src/__tests__/invite-test-resolution-route.test.ts +++ b/server/src/__tests__/invite-test-resolution-route.test.ts @@ -1,6 +1,6 @@ import express from "express"; import request from "supertest"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; function createSelectChain(rows: unknown[]) { const query = { @@ -44,8 +44,6 @@ function createInvite(overrides: Record = {}) { }; } -let currentAccessModule: Awaited>> | null = null; - async function createApp( db: Record, network: { @@ -54,11 +52,9 @@ async function createApp( }, ) { const [access, middleware] = await Promise.all([ - vi.importActual("../routes/access.js"), - vi.importActual("../middleware/index.js"), + import("../routes/access.js"), + import("../middleware/index.js"), ]); - currentAccessModule = access; - access.setInviteResolutionNetworkForTest(network); const app = express(); app.use((req, _res, next) => { (req as any).actor = { type: "anon" }; @@ -71,6 +67,7 @@ async function createApp( deploymentExposure: "private", bindHost: "127.0.0.1", allowedHostnames: [], + inviteResolutionNetwork: network, }), ); app.use(middleware.errorHandler); @@ -79,43 +76,43 @@ async function createApp( describe.sequential("GET /invites/:token/test-resolution", () => { beforeEach(() => { - currentAccessModule = null; + vi.clearAllMocks(); }); - afterEach(async () => { - currentAccessModule?.setInviteResolutionNetworkForTest(null); - }); + it("rejects private, local, multicast, and reserved targets before probing", async () => { + const cases = [ + ["localhost", "http://localhost:3100/api/health", "127.0.0.1"], + ["IPv4 loopback", "http://127.0.0.1:3100/api/health", "127.0.0.1"], + ["IPv6 loopback", "http://[::1]:3100/api/health", "::1"], + ["IPv4-mapped IPv6 loopback hex", "http://[::ffff:7f00:1]/api/health", "::ffff:7f00:1"], + ["IPv4-mapped IPv6 RFC1918 hex", "http://[::ffff:c0a8:101]/api/health", "::ffff:c0a8:101"], + ["RFC1918 10/8", "http://10.0.0.5/api/health", "10.0.0.5"], + ["RFC1918 172.16/12", "http://172.16.10.5/api/health", "172.16.10.5"], + ["RFC1918 192.168/16", "http://192.168.1.10/api/health", "192.168.1.10"], + ["link-local metadata", "http://169.254.169.254/latest/meta-data", "169.254.169.254"], + ["multicast", "http://224.0.0.1/probe", "224.0.0.1"], + ["NAT64 well-known prefix", "https://gateway.example.test/health", "64:ff9b::0a00:0001"], + ["NAT64 local-use prefix", "https://gateway.example.test/health", "64:ff9b:1::0a00:0001"], + ] as const; - it.each([ - ["localhost", "http://localhost:3100/api/health", "127.0.0.1"], - ["IPv4 loopback", "http://127.0.0.1:3100/api/health", "127.0.0.1"], - ["IPv6 loopback", "http://[::1]:3100/api/health", "::1"], - ["IPv4-mapped IPv6 loopback hex", "http://[::ffff:7f00:1]/api/health", "::ffff:7f00:1"], - ["IPv4-mapped IPv6 RFC1918 hex", "http://[::ffff:c0a8:101]/api/health", "::ffff:c0a8:101"], - ["RFC1918 10/8", "http://10.0.0.5/api/health", "10.0.0.5"], - ["RFC1918 172.16/12", "http://172.16.10.5/api/health", "172.16.10.5"], - ["RFC1918 192.168/16", "http://192.168.1.10/api/health", "192.168.1.10"], - ["link-local metadata", "http://169.254.169.254/latest/meta-data", "169.254.169.254"], - ["multicast", "http://224.0.0.1/probe", "224.0.0.1"], - ["NAT64 well-known prefix", "https://gateway.example.test/health", "64:ff9b::0a00:0001"], - ["NAT64 local-use prefix", "https://gateway.example.test/health", "64:ff9b:1::0a00:0001"], - ])("rejects %s targets before probing", async (_label, url, address) => { - const lookup = vi.fn().mockResolvedValue([{ address, family: address.includes(":") ? 6 : 4 }]); - const requestHead = vi.fn(); - const app = await createApp(createDbStub([createInvite()]), { lookup, requestHead }); + for (const [label, url, address] of cases) { + const lookup = vi.fn().mockResolvedValue([{ address, family: address.includes(":") ? 6 : 4 }]); + const requestHead = vi.fn(); + const app = await createApp(createDbStub([createInvite()]), { lookup, requestHead }); - const res = await request(app) - .get("/api/invites/pcp_invite_test/test-resolution") - .query({ url }); + const res = await request(app) + .get("/api/invites/pcp_invite_test/test-resolution") + .query({ url }); - expect(res.status).toBe(400); - expect(res.body.error).toBe( - "url resolves to a private, local, multicast, or reserved address", - ); - expect(requestHead).not.toHaveBeenCalled(); - }, 15_000); + expect(res.status, label).toBe(400); + expect(res.body.error).toBe( + "url resolves to a private, local, multicast, or reserved address", + ); + expect(requestHead).not.toHaveBeenCalled(); + } + }, 20_000); - it("rejects hostnames that resolve to private addresses", async () => { + it.sequential("rejects hostnames that resolve to private addresses", async () => { const lookup = vi.fn().mockResolvedValue([{ address: "10.1.2.3", family: 4 }]); const requestHead = vi.fn(); const app = await createApp(createDbStub([createInvite()]), { lookup, requestHead }); @@ -132,7 +129,7 @@ describe.sequential("GET /invites/:token/test-resolution", () => { expect(requestHead).not.toHaveBeenCalled(); }); - it("rejects hostnames when any resolved address is private", async () => { + it.sequential("rejects hostnames when any resolved address is private", async () => { const lookup = vi.fn().mockResolvedValue([ { address: "127.0.0.1", family: 4 }, { address: "93.184.216.34", family: 4 }, @@ -148,7 +145,7 @@ describe.sequential("GET /invites/:token/test-resolution", () => { expect(requestHead).not.toHaveBeenCalled(); }); - it("allows public HTTPS targets through the resolved and pinned probe path", async () => { + it.sequential("allows public HTTPS targets through the resolved and pinned probe path", async () => { const lookup = vi.fn().mockResolvedValue([{ address: "93.184.216.34", family: 4 }]); const requestHead = vi.fn().mockResolvedValue({ httpStatus: 204 }); const app = await createApp(createDbStub([createInvite()]), { lookup, requestHead }); @@ -177,7 +174,7 @@ describe.sequential("GET /invites/:token/test-resolution", () => { ); }); - it.each([ + it.sequential.each([ ["missing invite", []], ["revoked invite", [createInvite({ revokedAt: new Date("2026-03-07T00:05:00.000Z") })]], ["expired invite", [createInvite({ expiresAt: new Date("2020-03-07T00:10:00.000Z") })]], diff --git a/server/src/__tests__/issue-comment-reopen-routes.test.ts b/server/src/__tests__/issue-comment-reopen-routes.test.ts index a821d533324..eddd32d43c0 100644 --- a/server/src/__tests__/issue-comment-reopen-routes.test.ts +++ b/server/src/__tests__/issue-comment-reopen-routes.test.ts @@ -62,79 +62,77 @@ const mockIssueThreadInteractionService = vi.hoisted(() => ({ expireStaleRequestConfirmationsForIssueDocument: vi.fn(async () => []), })); -function registerModuleMocks() { - vi.doMock("@paperclipai/shared/telemetry", () => ({ - trackAgentTaskCompleted: vi.fn(), - trackErrorHandlerCrash: vi.fn(), - })); +vi.mock("@paperclipai/shared/telemetry", () => ({ + trackAgentTaskCompleted: vi.fn(), + trackErrorHandlerCrash: vi.fn(), +})); - vi.doMock("../telemetry.js", () => ({ - getTelemetryClient: vi.fn(() => ({ track: vi.fn() })), - })); +vi.mock("../telemetry.js", () => ({ + getTelemetryClient: vi.fn(() => ({ track: vi.fn() })), +})); - vi.doMock("../services/access.js", () => ({ - accessService: () => mockAccessService, - })); +vi.mock("../services/access.js", () => ({ + accessService: () => mockAccessService, +})); - vi.doMock("../services/activity-log.js", () => ({ - logActivity: mockLogActivity, - })); +vi.mock("../services/activity-log.js", () => ({ + logActivity: mockLogActivity, +})); - vi.doMock("../services/agents.js", () => ({ - agentService: () => mockAgentService, - })); +vi.mock("../services/agents.js", () => ({ + agentService: () => mockAgentService, +})); - vi.doMock("../services/feedback.js", () => ({ - feedbackService: () => mockFeedbackService, - })); +vi.mock("../services/feedback.js", () => ({ + feedbackService: () => mockFeedbackService, +})); - vi.doMock("../services/heartbeat.js", () => ({ - heartbeatService: () => mockHeartbeatService, - })); +vi.mock("../services/heartbeat.js", () => ({ + heartbeatService: () => mockHeartbeatService, +})); - vi.doMock("../services/instance-settings.js", () => ({ - instanceSettingsService: () => mockInstanceSettingsService, - })); +vi.mock("../services/instance-settings.js", () => ({ + instanceSettingsService: () => mockInstanceSettingsService, +})); - vi.doMock("../services/issues.js", () => ({ - issueService: () => mockIssueService, - })); +vi.mock("../services/issues.js", () => ({ + issueService: () => mockIssueService, +})); - vi.doMock("../services/routines.js", () => ({ - routineService: () => mockRoutineService, - })); +vi.mock("../services/routines.js", () => ({ + routineService: () => mockRoutineService, +})); - vi.doMock("../services/index.js", () => ({ - accessService: () => mockAccessService, - agentService: () => mockAgentService, - documentService: () => ({}), - executionWorkspaceService: () => ({}), - feedbackService: () => mockFeedbackService, - goalService: () => ({}), - heartbeatService: () => mockHeartbeatService, - instanceSettingsService: () => mockInstanceSettingsService, - issueApprovalService: () => ({}), - issueReferenceService: () => ({ - deleteDocumentSource: async () => undefined, - diffIssueReferenceSummary: () => ({ - addedReferencedIssues: [], - removedReferencedIssues: [], - currentReferencedIssues: [], - }), - emptySummary: () => ({ outbound: [], inbound: [] }), - listIssueReferenceSummary: async () => ({ outbound: [], inbound: [] }), - syncComment: async () => undefined, - syncDocument: async () => undefined, - syncIssue: async () => undefined, +vi.mock("../services/index.js", () => ({ + accessService: () => mockAccessService, + agentService: () => mockAgentService, + documentService: () => ({}), + executionWorkspaceService: () => ({}), + feedbackService: () => mockFeedbackService, + goalService: () => ({}), + heartbeatService: () => mockHeartbeatService, + instanceSettingsService: () => mockInstanceSettingsService, + issueApprovalService: () => ({}), + issueReferenceService: () => ({ + deleteDocumentSource: async () => undefined, + diffIssueReferenceSummary: () => ({ + addedReferencedIssues: [], + removedReferencedIssues: [], + currentReferencedIssues: [], }), - issueService: () => mockIssueService, - issueThreadInteractionService: () => mockIssueThreadInteractionService, - logActivity: mockLogActivity, - projectService: () => ({}), - routineService: () => mockRoutineService, - workProductService: () => ({}), - })); -} + emptySummary: () => ({ outbound: [], inbound: [] }), + listIssueReferenceSummary: async () => ({ outbound: [], inbound: [] }), + syncComment: async () => undefined, + syncDocument: async () => undefined, + syncIssue: async () => undefined, + }), + issueService: () => mockIssueService, + issueThreadInteractionService: () => mockIssueThreadInteractionService, + logActivity: mockLogActivity, + projectService: () => ({}), + routineService: () => mockRoutineService, + workProductService: () => ({}), +})); function createApp() { const app = express(); @@ -144,8 +142,8 @@ function createApp() { async function installActor(app: express.Express, actor?: Record) { const [{ issueRoutes }, { errorHandler }] = await Promise.all([ - vi.importActual("../routes/issues.js"), - vi.importActual("../middleware/index.js"), + import("../routes/issues.js"), + import("../middleware/index.js"), ]); app.use((req, _res, next) => { (req as any).actor = actor ?? { @@ -186,25 +184,13 @@ function makeIssue(status: "todo" | "done" | "blocked") { }; } -describe("issue comment reopen routes", () => { +async function waitForWakeup(assertion: () => void) { + await vi.waitFor(assertion); +} + +describe.sequential("issue comment reopen routes", () => { beforeEach(() => { - vi.resetModules(); - vi.doUnmock("@paperclipai/shared/telemetry"); - vi.doUnmock("../telemetry.js"); - vi.doUnmock("../services/access.js"); - vi.doUnmock("../services/activity-log.js"); - vi.doUnmock("../services/agents.js"); - vi.doUnmock("../services/feedback.js"); - vi.doUnmock("../services/heartbeat.js"); - vi.doUnmock("../services/index.js"); - vi.doUnmock("../services/instance-settings.js"); - vi.doUnmock("../services/issues.js"); - vi.doUnmock("../services/routines.js"); - vi.doUnmock("../routes/issues.js"); - vi.doUnmock("../routes/authz.js"); - vi.doUnmock("../middleware/index.js"); - registerModuleMocks(); - vi.resetAllMocks(); + vi.clearAllMocks(); mockIssueService.getById.mockReset(); mockIssueService.assertCheckoutOwner.mockReset(); mockIssueService.update.mockReset(); @@ -280,12 +266,24 @@ describe("issue comment reopen routes", () => { mockAccessService.canUser.mockResolvedValue(false); mockAccessService.hasPermission.mockResolvedValue(false); mockAgentService.getById.mockResolvedValue(null); - mockAgentService.resolveByReference.mockImplementation(async (_companyId: string, reference: string) => ({ - ambiguous: false, - agent: { - id: reference, - }, - })); + mockAgentService.resolveByReference.mockImplementation(async (_companyId: string, reference: string) => { + if (reference === "ambiguous-codex") { + return { ambiguous: true, agent: null }; + } + if (reference === "missing-codex") { + return { ambiguous: false, agent: null }; + } + if (reference === "codexcoder") { + return { + ambiguous: false, + agent: { id: "33333333-3333-4333-8333-333333333333" }, + }; + } + return { + ambiguous: false, + agent: { id: reference }, + }; + }); }); it("treats reopen=true as a no-op when the issue is already open", async () => { @@ -350,10 +348,6 @@ describe("issue comment reopen routes", () => { ...makeIssue("todo"), ...patch, })); - mockAgentService.resolveByReference.mockResolvedValue({ - ambiguous: false, - agent: { id: "33333333-3333-4333-8333-333333333333" }, - }); const res = await request(await installActor(createApp())) .patch("/api/issues/11111111-1111-4111-8111-111111111111") @@ -371,14 +365,10 @@ describe("issue comment reopen routes", () => { it("rejects ambiguous assignee shortnames", async () => { mockIssueService.getById.mockResolvedValue(makeIssue("todo")); - mockAgentService.resolveByReference.mockResolvedValue({ - ambiguous: true, - agent: null, - }); const res = await request(await installActor(createApp())) .patch("/api/issues/11111111-1111-4111-8111-111111111111") - .send({ assigneeAgentId: "codexcoder" }); + .send({ assigneeAgentId: "ambiguous-codex" }); expect(res.status).toBe(409); expect(res.body.error).toContain("ambiguous"); @@ -387,14 +377,10 @@ describe("issue comment reopen routes", () => { it("rejects missing assignee shortnames", async () => { mockIssueService.getById.mockResolvedValue(makeIssue("todo")); - mockAgentService.resolveByReference.mockResolvedValue({ - ambiguous: false, - agent: null, - }); const res = await request(await installActor(createApp())) .patch("/api/issues/11111111-1111-4111-8111-111111111111") - .send({ assigneeAgentId: "codexcoder" }); + .send({ assigneeAgentId: "missing-codex" }); expect(res.status).toBe(404); expect(res.body.error).toBe("Agent not found"); @@ -450,7 +436,7 @@ describe("issue comment reopen routes", () => { "11111111-1111-4111-8111-111111111111", { status: "todo" }, ); - expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith( + await waitForWakeup(() => expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith( "22222222-2222-4222-8222-222222222222", expect.objectContaining({ reason: "issue_reopened_via_comment", @@ -458,7 +444,38 @@ describe("issue comment reopen routes", () => { reopenedFrom: "done", }), }), + )); + }); + + it("does not implicitly reopen closed issues via POST comments for agent-authored comments", async () => { + mockIssueService.getById.mockResolvedValue(makeIssue("done")); + mockIssueService.addComment.mockResolvedValue({ + id: "comment-1", + issueId: "11111111-1111-4111-8111-111111111111", + companyId: "company-1", + body: "hello", + createdAt: new Date(), + updatedAt: new Date(), + authorAgentId: "33333333-3333-4333-8333-333333333333", + authorUserId: null, + }); + + const res = await request(await installActor(createApp(), { + type: "agent", + agentId: "33333333-3333-4333-8333-333333333333", + companyId: "company-1", + source: "agent_key", + runId: "77777777-7777-4777-8777-777777777777", + })) + .post("/api/issues/11111111-1111-4111-8111-111111111111/comments") + .send({ body: "hello" }); + + expect(res.status).toBe(201); + expect(mockIssueService.update).not.toHaveBeenCalledWith( + "11111111-1111-4111-8111-111111111111", + { status: "todo" }, ); + expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled(); }); it("moves assigned blocked issues back to todo via POST comments", async () => { @@ -477,7 +494,7 @@ describe("issue comment reopen routes", () => { "11111111-1111-4111-8111-111111111111", { status: "todo" }, ); - expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith( + await waitForWakeup(() => expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith( "22222222-2222-4222-8222-222222222222", expect.objectContaining({ reason: "issue_reopened_via_comment", @@ -493,7 +510,7 @@ describe("issue comment reopen routes", () => { reopenedFrom: "blocked", }), }), - ); + )); }); it("does not move dependency-blocked issues to todo via POST comments", async () => { @@ -513,7 +530,7 @@ describe("issue comment reopen routes", () => { expect(res.status).toBe(201); expect(mockIssueService.update).not.toHaveBeenCalled(); - expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith( + await waitForWakeup(() => expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith( "22222222-2222-4222-8222-222222222222", expect.objectContaining({ reason: "issue_commented", @@ -527,7 +544,7 @@ describe("issue comment reopen routes", () => { wakeReason: "issue_commented", }), }), - ); + )); }); it("does not implicitly reopen closed issues via POST comments when no agent is assigned", async () => { @@ -565,7 +582,7 @@ describe("issue comment reopen routes", () => { actorUserId: "local-board", }), ); - expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith( + await waitForWakeup(() => expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith( "22222222-2222-4222-8222-222222222222", expect.objectContaining({ reason: "issue_reopened_via_comment", @@ -575,7 +592,42 @@ describe("issue comment reopen routes", () => { mutation: "comment", }), }), + )); + }); + + it("does not implicitly reopen closed issues via the PATCH comment path for agent-authored comments", async () => { + mockIssueService.getById.mockResolvedValue(makeIssue("done")); + mockIssueService.addComment.mockResolvedValue({ + id: "comment-1", + issueId: "11111111-1111-4111-8111-111111111111", + companyId: "company-1", + body: "hello", + createdAt: new Date(), + updatedAt: new Date(), + authorAgentId: "33333333-3333-4333-8333-333333333333", + authorUserId: null, + }); + mockIssueService.update.mockImplementation(async (_id: string, patch: Record) => ({ + ...makeIssue("done"), + ...patch, + })); + + const res = await request(await installActor(createApp(), { + type: "agent", + agentId: "33333333-3333-4333-8333-333333333333", + companyId: "company-1", + source: "agent_key", + runId: "88888888-8888-4888-8888-888888888888", + })) + .patch("/api/issues/11111111-1111-4111-8111-111111111111") + .send({ comment: "hello" }); + + expect(res.status).toBe(200); + expect(mockIssueService.update).not.toHaveBeenCalledWith( + "11111111-1111-4111-8111-111111111111", + expect.objectContaining({ status: "todo" }), ); + expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled(); }); it("does not move dependency-blocked issues to todo via the PATCH comment path", async () => { @@ -609,7 +661,7 @@ describe("issue comment reopen routes", () => { "11111111-1111-4111-8111-111111111111", expect.objectContaining({ status: "todo" }), ); - expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith( + await waitForWakeup(() => expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith( "22222222-2222-4222-8222-222222222222", expect.objectContaining({ reason: "issue_commented", @@ -618,7 +670,7 @@ describe("issue comment reopen routes", () => { mutation: "comment", }), }), - ); + )); }); it("wakes the assignee when an assigned blocked issue moves back to todo", async () => { @@ -635,7 +687,7 @@ describe("issue comment reopen routes", () => { .send({ status: "todo" }); expect(res.status).toBe(200); - expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith( + await waitForWakeup(() => expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith( "22222222-2222-4222-8222-222222222222", expect.objectContaining({ source: "automation", @@ -646,7 +698,7 @@ describe("issue comment reopen routes", () => { mutation: "update", }), }), - ); + )); }); it("interrupts an active run before a combined comment update", async () => { const issue = { @@ -812,7 +864,7 @@ describe("issue comment reopen routes", () => { agentId: "22222222-2222-4222-8222-222222222222", }, }); - expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith( + await waitForWakeup(() => expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith( "33333333-3333-4333-8333-333333333333", expect.objectContaining({ reason: "execution_review_requested", @@ -825,7 +877,7 @@ describe("issue comment reopen routes", () => { }), }), }), - ); + )); }); it("wakes the return assignee with execution_changes_requested", async () => { @@ -877,7 +929,7 @@ describe("issue comment reopen routes", () => { }); expect(res.status).toBe(200); - expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith( + await waitForWakeup(() => expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith( "22222222-2222-4222-8222-222222222222", expect.objectContaining({ reason: "execution_changes_requested", @@ -891,6 +943,6 @@ describe("issue comment reopen routes", () => { }), }), }), - ); + )); }); }); diff --git a/server/src/routes/access.ts b/server/src/routes/access.ts index 86c93c6d934..3df49fd6dbf 100644 --- a/server/src/routes/access.ts +++ b/server/src/routes/access.ts @@ -2270,11 +2270,14 @@ export function setInviteResolutionNetworkForTest( : defaultInviteResolutionNetwork; } -async function lookupInviteResolutionHostname(hostname: string) { +async function lookupInviteResolutionHostname( + hostname: string, + network: InviteResolutionNetwork = inviteResolutionNetwork +) { let timeout: ReturnType | null = null; try { return await Promise.race([ - inviteResolutionNetwork.lookup(hostname), + network.lookup(hostname), new Promise((_, reject) => { timeout = setTimeout( () => @@ -2296,7 +2299,8 @@ async function lookupInviteResolutionHostname(hostname: string) { } async function resolveInviteResolutionTarget( - url: URL + url: URL, + network: InviteResolutionNetwork = inviteResolutionNetwork ): Promise { const hostname = hostnameForResolution(url); if (parseIpv4Address(hostname)) { @@ -2328,7 +2332,7 @@ async function resolveInviteResolutionTarget( tlsServername: undefined, }; } - const results = await lookupInviteResolutionHostname(hostname); + const results = await lookupInviteResolutionHostname(hostname, network); if (results.length === 0) { throw badRequest("url hostname did not resolve to any addresses"); } @@ -2354,11 +2358,12 @@ async function resolveInviteResolutionTarget( async function probeInviteResolutionTarget( target: ResolvedInviteResolutionTarget, - timeoutMs: number + timeoutMs: number, + network: InviteResolutionNetwork = inviteResolutionNetwork ): Promise { const startedAt = Date.now(); try { - const response = await inviteResolutionNetwork.requestHead(target, timeoutMs); + const response = await network.requestHead(target, timeoutMs); const durationMs = Date.now() - startedAt; if ( response.httpStatus !== null && @@ -2421,12 +2426,16 @@ export function accessRoutes( deploymentExposure: DeploymentExposure; bindHost: string; allowedHostnames: string[]; + inviteResolutionNetwork?: Partial; } ) { const router = Router(); const access = accessService(db); const boardAuth = boardAuthService(db); const agents = agentService(db); + const routeInviteResolutionNetwork = opts.inviteResolutionNetwork + ? { ...defaultInviteResolutionNetwork, ...opts.inviteResolutionNetwork } + : inviteResolutionNetwork; async function assertInstanceAdmin(req: Request) { if (req.actor.type !== "board") throw unauthorized(); @@ -3175,8 +3184,8 @@ export function accessRoutes( const timeoutMs = Number.isFinite(parsedTimeoutMs) ? Math.max(1000, Math.min(15000, Math.floor(parsedTimeoutMs))) : 5000; - const resolvedTarget = await resolveInviteResolutionTarget(target); - const probe = await probeInviteResolutionTarget(resolvedTarget, timeoutMs); + const resolvedTarget = await resolveInviteResolutionTarget(target, routeInviteResolutionNetwork); + const probe = await probeInviteResolutionTarget(resolvedTarget, timeoutMs, routeInviteResolutionNetwork); res.json({ inviteId: invite.id, testResolutionPath: `/api/invites/${token}/test-resolution`, diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index aef722aa3af..ef838e2c04a 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -186,9 +186,11 @@ function shouldImplicitlyMoveCommentedIssueToTodoForAgent(input: { actorType: "agent" | "user"; actorId: string; }) { + // Only human comments should implicitly reopen finished work. + // Agent-authored comments remain communicative unless reopen was explicit. + if (input.actorType !== "user") return false; if (!isClosedIssueStatus(input.issueStatus) && input.issueStatus !== "blocked") return false; if (typeof input.assigneeAgentId !== "string" || input.assigneeAgentId.length === 0) return false; - if (input.actorType === "agent" && input.actorId === input.assigneeAgentId) return false; return true; } diff --git a/server/src/services/adapter-plugin-store.ts b/server/src/services/adapter-plugin-store.ts index 8c26abe841d..13369323d4f 100644 --- a/server/src/services/adapter-plugin-store.ts +++ b/server/src/services/adapter-plugin-store.ts @@ -14,7 +14,7 @@ import fs from "node:fs"; import path from "node:path"; -import os from "node:os"; +import { resolvePaperclipHomeDir } from "../home-paths.js"; // --------------------------------------------------------------------------- // Types @@ -43,25 +43,30 @@ interface AdapterSettings { // Paths // --------------------------------------------------------------------------- -const PAPERCLIP_DIR = path.join(os.homedir(), ".paperclip"); -const ADAPTER_PLUGINS_DIR = path.join(PAPERCLIP_DIR, "adapter-plugins"); -const ADAPTER_PLUGINS_STORE_PATH = path.join(PAPERCLIP_DIR, "adapter-plugins.json"); -const ADAPTER_SETTINGS_PATH = path.join(PAPERCLIP_DIR, "adapter-settings.json"); +function adapterPluginPaths() { + const paperclipDir = resolvePaperclipHomeDir(); + return { + adapterPluginsDir: path.join(paperclipDir, "adapter-plugins"), + adapterPluginsStorePath: path.join(paperclipDir, "adapter-plugins.json"), + adapterSettingsPath: path.join(paperclipDir, "adapter-settings.json"), + }; +} // --------------------------------------------------------------------------- // In-memory caches (invalidated on write) // --------------------------------------------------------------------------- -let storeCache: AdapterPluginRecord[] | null = null; -let settingsCache: AdapterSettings | null = null; +let storeCache: { path: string; records: AdapterPluginRecord[] } | null = null; +let settingsCache: { path: string; settings: AdapterSettings } | null = null; // --------------------------------------------------------------------------- // Store functions // --------------------------------------------------------------------------- -function ensureDirs(): void { - fs.mkdirSync(ADAPTER_PLUGINS_DIR, { recursive: true }); - const pkgJsonPath = path.join(ADAPTER_PLUGINS_DIR, "package.json"); +function ensureDirs(): string { + const { adapterPluginsDir } = adapterPluginPaths(); + fs.mkdirSync(adapterPluginsDir, { recursive: true }); + const pkgJsonPath = path.join(adapterPluginsDir, "package.json"); if (!fs.existsSync(pkgJsonPath)) { fs.writeFileSync(pkgJsonPath, JSON.stringify({ name: "paperclip-adapter-plugins", @@ -70,44 +75,55 @@ function ensureDirs(): void { description: "Managed directory for Paperclip external adapter plugins. Do not edit manually.", }, null, 2) + "\n"); } + return adapterPluginsDir; } function readStore(): AdapterPluginRecord[] { - if (storeCache) return storeCache; + const { adapterPluginsStorePath } = adapterPluginPaths(); + if (storeCache?.path === adapterPluginsStorePath) return storeCache.records; try { - const raw = fs.readFileSync(ADAPTER_PLUGINS_STORE_PATH, "utf-8"); + const raw = fs.readFileSync(adapterPluginsStorePath, "utf-8"); const parsed = JSON.parse(raw); - storeCache = Array.isArray(parsed) ? (parsed as AdapterPluginRecord[]) : []; + storeCache = { + path: adapterPluginsStorePath, + records: Array.isArray(parsed) ? (parsed as AdapterPluginRecord[]) : [], + }; } catch { - storeCache = []; + storeCache = { path: adapterPluginsStorePath, records: [] }; } - return storeCache; + return storeCache.records; } function writeStore(records: AdapterPluginRecord[]): void { ensureDirs(); - fs.writeFileSync(ADAPTER_PLUGINS_STORE_PATH, JSON.stringify(records, null, 2), "utf-8"); - storeCache = records; + const { adapterPluginsStorePath } = adapterPluginPaths(); + fs.writeFileSync(adapterPluginsStorePath, JSON.stringify(records, null, 2), "utf-8"); + storeCache = { path: adapterPluginsStorePath, records }; } function readSettings(): AdapterSettings { - if (settingsCache) return settingsCache; + const { adapterSettingsPath } = adapterPluginPaths(); + if (settingsCache?.path === adapterSettingsPath) return settingsCache.settings; try { - const raw = fs.readFileSync(ADAPTER_SETTINGS_PATH, "utf-8"); + const raw = fs.readFileSync(adapterSettingsPath, "utf-8"); const parsed = JSON.parse(raw); - settingsCache = parsed && Array.isArray(parsed.disabledTypes) - ? (parsed as AdapterSettings) - : { disabledTypes: [] }; + settingsCache = { + path: adapterSettingsPath, + settings: parsed && Array.isArray(parsed.disabledTypes) + ? (parsed as AdapterSettings) + : { disabledTypes: [] }, + }; } catch { - settingsCache = { disabledTypes: [] }; + settingsCache = { path: adapterSettingsPath, settings: { disabledTypes: [] } }; } - return settingsCache; + return settingsCache.settings; } function writeSettings(settings: AdapterSettings): void { ensureDirs(); - fs.writeFileSync(ADAPTER_SETTINGS_PATH, JSON.stringify(settings, null, 2), "utf-8"); - settingsCache = settings; + const { adapterSettingsPath } = adapterPluginPaths(); + fs.writeFileSync(adapterSettingsPath, JSON.stringify(settings, null, 2), "utf-8"); + settingsCache = { path: adapterSettingsPath, settings }; } // --------------------------------------------------------------------------- @@ -143,8 +159,7 @@ export function getAdapterPluginByType(type: string): AdapterPluginRecord | unde } export function getAdapterPluginsDir(): string { - ensureDirs(); - return ADAPTER_PLUGINS_DIR; + return ensureDirs(); } // --------------------------------------------------------------------------- diff --git a/ui/src/api/heartbeats.ts b/ui/src/api/heartbeats.ts index ea7ae5d6ed2..dc832dba549 100644 --- a/ui/src/api/heartbeats.ts +++ b/ui/src/api/heartbeats.ts @@ -1,4 +1,9 @@ -import type { HeartbeatRun, HeartbeatRunEvent, InstanceSchedulerHeartbeatAgent, WorkspaceOperation } from "@paperclipai/shared"; +import type { + HeartbeatRun, + HeartbeatRunEvent, + InstanceSchedulerHeartbeatAgent, + WorkspaceOperation, +} from "@paperclipai/shared"; import { api } from "./client"; export interface RunLivenessFields { @@ -28,6 +33,7 @@ export interface ActiveRunForIssue { continuationAttempt?: number; lastUsefulActionAt?: string | Date | null; nextAction?: string | null; + outputSilence?: HeartbeatRun["outputSilence"]; } export interface LiveRunForIssue { @@ -49,6 +55,15 @@ export interface LiveRunForIssue { continuationAttempt?: number; lastUsefulActionAt?: string | null; nextAction?: string | null; + outputSilence?: HeartbeatRun["outputSilence"]; +} + +export interface WatchdogDecisionInput { + runId: string; + decision: "snooze" | "continue" | "dismissed_false_positive"; + evaluationIssueId?: string | null; + reason?: string | null; + snoozedUntil?: string | null; } export const heartbeatsApi = { @@ -75,6 +90,13 @@ export const heartbeatsApi = { `/workspace-operations/${operationId}/log?offset=${encodeURIComponent(String(offset))}&limitBytes=${encodeURIComponent(String(limitBytes))}`, ), cancel: (runId: string) => api.post(`/heartbeat-runs/${runId}/cancel`, {}), + recordWatchdogDecision: (input: WatchdogDecisionInput) => + api.post(`/heartbeat-runs/${input.runId}/watchdog-decisions`, { + decision: input.decision, + evaluationIssueId: input.evaluationIssueId ?? null, + reason: input.reason ?? null, + snoozedUntil: input.snoozedUntil ?? null, + }), liveRunsForIssue: (issueId: string) => api.get(`/issues/${issueId}/live-runs`), activeRunForIssue: (issueId: string) => diff --git a/ui/src/components/IssueRunLedger.test.tsx b/ui/src/components/IssueRunLedger.test.tsx index 25527066a6c..1452187b125 100644 --- a/ui/src/components/IssueRunLedger.test.tsx +++ b/ui/src/components/IssueRunLedger.test.tsx @@ -6,6 +6,7 @@ import { createRoot, type Root } from "react-dom/client"; import type { Issue, RunLivenessState } from "@paperclipai/shared"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { RunForIssue } from "../api/activity"; +import type { ActiveRunForIssue } from "../api/heartbeats"; import { IssueRunLedgerContent } from "./IssueRunLedger"; vi.mock("@/lib/router", () => ({ @@ -99,6 +100,35 @@ function createIssue(overrides: Partial = {}): Issue { }; } +function createActiveRun(overrides: Partial = {}): ActiveRunForIssue { + return { + id: "run-live-1", + status: "running", + invocationSource: "assignment", + triggerDetail: null, + startedAt: "2026-04-18T19:58:00.000Z", + finishedAt: null, + createdAt: "2026-04-18T19:58:00.000Z", + agentId: "agent-1", + agentName: "CodexCoder", + adapterType: "codex_local", + outputSilence: { + lastOutputAt: "2026-04-18T19:00:00.000Z", + lastOutputSeq: 4, + lastOutputStream: "stdout", + silenceStartedAt: "2026-04-18T19:30:00.000Z", + silenceAgeMs: 45 * 60 * 1000, + level: "critical", + suspicionThresholdMs: 10 * 60 * 1000, + criticalThresholdMs: 30 * 60 * 1000, + snoozedUntil: null, + evaluationIssueId: "issue-eval-1", + evaluationIssueIdentifier: "PAP-404", + }, + ...overrides, + }; +} + function renderLedger(props: Partial> = {}) { render( , ); } @@ -223,7 +255,8 @@ describe("IssueRunLedger", () => { expect(container.textContent).toContain("Transient failure"); expect(container.textContent).toContain("Next retry"); expect(container.textContent).toContain("Retry exhausted"); - expect(container.textContent).toContain("No further automatic retry queued"); + expect(container.textContent).toContain("no further automatic retry will be queued"); + expect(container.textContent).toContain("Manual intervention required"); }); it("shows timeout, cancel, and budget stop reasons without raw logs", () => { @@ -302,4 +335,30 @@ describe("IssueRunLedger", () => { expect(container.textContent).toContain("2 older runs not shown"); }); + + it("renders stale-run banner, watchdog actions, and silence badge for live runs", () => { + const onWatchdogDecision = vi.fn(); + renderLedger({ + runs: [createRun({ runId: "run-live-1", status: "running", finishedAt: null })], + activeRun: createActiveRun(), + onWatchdogDecision, + }); + + expect(container.textContent).toContain("Stale-run watchdog alert"); + expect(container.textContent).toContain("PAP-404"); + expect(container.textContent).toContain("Stale run"); + + const continueButton = Array.from(container.querySelectorAll("button")).find( + (button) => button.textContent?.includes("Continue monitoring"), + ); + expect(continueButton).not.toBeUndefined(); + act(() => { + continueButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + expect(onWatchdogDecision).toHaveBeenCalledWith({ + runId: "run-live-1", + decision: "continue", + evaluationIssueId: "issue-eval-1", + }); + }); }); diff --git a/ui/src/components/IssueRunLedger.tsx b/ui/src/components/IssueRunLedger.tsx index ec34bc58e1b..5ad4bfbfe3c 100644 --- a/ui/src/components/IssueRunLedger.tsx +++ b/ui/src/components/IssueRunLedger.tsx @@ -1,9 +1,14 @@ import { useMemo } from "react"; import type { Issue, Agent } from "@paperclipai/shared"; -import { useQuery } from "@tanstack/react-query"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Link } from "@/lib/router"; import { activityApi, type RunForIssue, type RunLivenessState } from "../api/activity"; -import { heartbeatsApi, type ActiveRunForIssue, type LiveRunForIssue } from "../api/heartbeats"; +import { + heartbeatsApi, + type ActiveRunForIssue, + type LiveRunForIssue, + type WatchdogDecisionInput, +} from "../api/heartbeats"; import { cn, relativeTime } from "../lib/utils"; import { queryKeys } from "../lib/queryKeys"; import { keepPreviousDataForSameQueryTail } from "../lib/query-placeholder-data"; @@ -24,11 +29,14 @@ type IssueRunLedgerContentProps = { issueStatus: Issue["status"]; childIssues: Issue[]; agentMap: ReadonlyMap>; + pendingWatchdogDecision?: WatchdogDecisionInput["decision"] | null; + onWatchdogDecision?: (input: WatchdogDecisionInput) => void; }; type LedgerRun = RunForIssue & { isLive?: boolean; agentName?: string; + outputSilence?: ActiveRunForIssue["outputSilence"]; }; type LivenessCopy = { @@ -96,6 +104,28 @@ const MISSING_LIVENESS_COPY: LivenessCopy = { const TERMINAL_CHILD_STATUSES = new Set(["done", "cancelled"]); const ACTIVE_RUN_STATUSES = new Set(["queued", "running"]); +type RunOutputSilenceLevel = NonNullable["level"]; + +type RunOutputSilenceCopy = { + label: string; + tone: string; +}; + +const RUN_OUTPUT_SILENCE_COPY: Partial> = { + suspicious: { + label: "Silence watch", + tone: "border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-300", + }, + critical: { + label: "Stale run", + tone: "border-red-500/30 bg-red-500/10 text-red-700 dark:text-red-300", + }, + snoozed: { + label: "Silence snoozed", + tone: "border-cyan-500/30 bg-cyan-500/10 text-cyan-700 dark:text-cyan-300", + }, +}; + function asRecord(value: unknown): Record | null { if (typeof value !== "object" || value === null || Array.isArray(value)) return null; return value as Record; @@ -143,6 +173,7 @@ function liveRunToLedgerRun(run: LiveRunForIssue | ActiveRunForIssue): LedgerRun usageJson: null, resultJson: null, isLive: run.status === "queued" || run.status === "running", + outputSilence: run.outputSilence, }; } @@ -155,10 +186,25 @@ function mergeRuns( for (const run of runs) byId.set(run.runId, run); for (const run of liveRuns ?? []) { const existing = byId.get(run.id); - byId.set(run.id, existing ? { ...existing, isLive: true, agentName: run.agentName } : liveRunToLedgerRun(run)); + byId.set( + run.id, + existing + ? { ...existing, isLive: true, agentName: run.agentName, outputSilence: run.outputSilence } + : liveRunToLedgerRun(run), + ); } - if (activeRun && !byId.has(activeRun.id)) { - byId.set(activeRun.id, liveRunToLedgerRun(activeRun)); + if (activeRun) { + const existing = byId.get(activeRun.id); + if (existing) { + byId.set(activeRun.id, { + ...existing, + isLive: isActiveRun(existing) || isActiveRun(activeRun), + agentName: activeRun.agentName, + outputSilence: activeRun.outputSilence, + }); + } else { + byId.set(activeRun.id, liveRunToLedgerRun(activeRun)); + } } return [...byId.values()].sort((a, b) => { @@ -252,6 +298,17 @@ function compactAgentName(run: LedgerRun, agentMap: ReadonlyMap activityApi.runsForIssue(issueId), @@ -279,6 +337,13 @@ export function IssueRunLedger({ refetchInterval: hasLiveRuns ? false : 3000, placeholderData: keepPreviousDataForSameQueryTail(issueId), }); + const watchdogDecision = useMutation({ + mutationFn: (input: WatchdogDecisionInput) => heartbeatsApi.recordWatchdogDecision(input), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.issues.activeRun(issueId) }); + queryClient.invalidateQueries({ queryKey: queryKeys.issues.liveRuns(issueId) }); + }, + }); return ( watchdogDecision.mutate(input)} /> ); } @@ -299,9 +366,19 @@ export function IssueRunLedgerContent({ issueStatus, childIssues, agentMap, + pendingWatchdogDecision, + onWatchdogDecision, }: IssueRunLedgerContentProps) { const ledgerRuns = useMemo(() => mergeRuns(runs, liveRuns, activeRun), [activeRun, liveRuns, runs]); const latestRun = ledgerRuns[0] ?? null; + const latestSilentRun = useMemo( + () => + ledgerRuns.find((run) => + isActiveRun(run) + && (run.outputSilence?.level === "critical" || run.outputSilence?.level === "suspicious"), + ) ?? null, + [ledgerRuns], + ); const children = childIssueSummary(childIssues); return ( @@ -360,6 +437,79 @@ export function IssueRunLedgerContent({ ) : null} + {latestSilentRun?.outputSilence ? ( +
+

+ {latestSilentRun.outputSilence.level === "critical" + ? "Stale-run watchdog alert" + : "Output silence watchdog warning"} +

+

+ Latest active run has been silent for{" "} + {formatSilenceAge(latestSilentRun.outputSilence.silenceAgeMs) ?? "an extended period"}. + {latestSilentRun.outputSilence.evaluationIssueIdentifier ? ( + <> + {" "} + Review{" "} + + {latestSilentRun.outputSilence.evaluationIssueIdentifier} + + {" "}for recovery context. + + ) : null} +

+ {onWatchdogDecision ? ( +
+ + + +
+ ) : null} +
+ ) : null} + {ledgerRuns.length === 0 ? (
Historical runs without liveness metadata will appear here once linked to this issue. @@ -418,6 +568,16 @@ export function IssueRunLedgerContent({ {retryState.badgeLabel} ) : null} + {run.outputSilence && RUN_OUTPUT_SILENCE_COPY[run.outputSilence.level] ? ( + + {RUN_OUTPUT_SILENCE_COPY[run.outputSilence.level]?.label} + + ) : null}
diff --git a/ui/src/components/IssuesList.test.tsx b/ui/src/components/IssuesList.test.tsx index 52f6f42f571..796f80fe806 100644 --- a/ui/src/components/IssuesList.test.tsx +++ b/ui/src/components/IssuesList.test.tsx @@ -425,8 +425,8 @@ describe("IssuesList", () => { ); await waitForAssertion(() => { - expect(container.querySelectorAll('[data-testid="issue-row"]')).toHaveLength(150); - expect(container.textContent).toContain("Rendering 150 of 220 issues"); + expect(container.querySelectorAll('[data-testid="issue-row"]')).toHaveLength(100); + expect(container.textContent).toContain("Rendering 100 of 220 issues"); }); act(() => { diff --git a/ui/src/components/IssuesList.tsx b/ui/src/components/IssuesList.tsx index a9bc23823ab..c2e6050179f 100644 --- a/ui/src/components/IssuesList.tsx +++ b/ui/src/components/IssuesList.tsx @@ -60,7 +60,8 @@ import { statusBadge } from "../lib/status-colors"; import type { Issue, Project } from "@paperclipai/shared"; const ISSUE_SEARCH_DEBOUNCE_MS = 250; const ISSUE_SEARCH_RESULT_LIMIT = 200; -const INITIAL_ISSUE_ROW_RENDER_LIMIT = 150; +const ISSUE_BOARD_COLUMN_RESULT_LIMIT = 200; +const INITIAL_ISSUE_ROW_RENDER_LIMIT = 100; const ISSUE_ROW_RENDER_BATCH_SIZE = 150; const ISSUE_ROW_RENDER_BATCH_DELAY_MS = 0; diff --git a/ui/src/lib/runRetryState.test.ts b/ui/src/lib/runRetryState.test.ts index e83de06900d..cc6ae84cfe9 100644 --- a/ui/src/lib/runRetryState.test.ts +++ b/ui/src/lib/runRetryState.test.ts @@ -36,7 +36,8 @@ describe("runRetryState", () => { ).toMatchObject({ kind: "exhausted", badgeLabel: "Retry exhausted", - detail: "Attempt 4 · Transient failure · No further automatic retry queued", + detail: "Attempt 4 · Transient failure · Automatic retries exhausted", + secondary: "Bounded retry exhausted after 4 scheduled attempts; no further automatic retry will be queued Manual intervention required.", }); }); }); diff --git a/ui/src/lib/runRetryState.ts b/ui/src/lib/runRetryState.ts index a3c1b35a179..1df9c7883c4 100644 --- a/ui/src/lib/runRetryState.ts +++ b/ui/src/lib/runRetryState.ts @@ -76,8 +76,10 @@ export function describeRunRetryState(run: RetryAwareRun): RunRetryStateSummary kind: "exhausted", badgeLabel: "Retry exhausted", tone: "border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-300", - detail: joinFragments([attemptLabel, reasonLabel, "No further automatic retry queued"]), - secondary: exhaustedReason, + detail: joinFragments([attemptLabel, reasonLabel, "Automatic retries exhausted"]), + secondary: exhaustedReason.includes("Manual intervention required") + ? exhaustedReason + : `${exhaustedReason} Manual intervention required.`, retryOfRunId, }; }