Add watchdog follow-ups and issue reopen fixes

This commit is contained in:
Dotta committed 2026-04-24 00:26:41 -05:00
1 parent 67a5d2c469
commit c524cd2a06
13 files changed
+727 -207

No files matched your search

@@ -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();
@@ -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<string, unknown> = {}) {
};
}
let currentAccessModule: Awaited<ReturnType<typeof vi.importActual<typeof import("../routes/access.js")>>> | null = null;
async function createApp(
db: Record<string, unknown>,
network: {
@@ -54,11 +52,9 @@ async function createApp(
},
) {
const [access, middleware] = await Promise.all([
vi.importActual<typeof import("../routes/access.js")>("../routes/access.js"),
vi.importActual<typeof import("../middleware/index.js")>("../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") })]],
@@ -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<string, unknown>) {
const [{ issueRoutes }, { errorHandler }] = await Promise.all([
vi.importActual<typeof import("../routes/issues.js")>("../routes/issues.js"),
vi.importActual<typeof import("../middleware/index.js")>("../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<string, unknown>) => ({
...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", () => {
}),
}),
}),
);
));
});
});
+17 -8
View File
@@ -2270,11 +2270,14 @@ export function setInviteResolutionNetworkForTest(
: defaultInviteResolutionNetwork;
}
async function lookupInviteResolutionHostname(hostname: string) {
async function lookupInviteResolutionHostname(
hostname: string,
network: InviteResolutionNetwork = inviteResolutionNetwork
) {
let timeout: ReturnType<typeof setTimeout> | null = null;
try {
return await Promise.race([
inviteResolutionNetwork.lookup(hostname),
network.lookup(hostname),
new Promise<never>((_, reject) => {
timeout = setTimeout(
() =>
@@ -2296,7 +2299,8 @@ async function lookupInviteResolutionHostname(hostname: string) {
}
async function resolveInviteResolutionTarget(
url: URL
url: URL,
network: InviteResolutionNetwork = inviteResolutionNetwork
): Promise<ResolvedInviteResolutionTarget> {
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<InviteResolutionProbe> {
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<InviteResolutionNetwork>;
}
) {
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`,
+3 -1
View File
@@ -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;
}
+43 -28
View File
@@ -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();
}
// ---------------------------------------------------------------------------
+23 -1
View File
@@ -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<void>(`/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<LiveRunForIssue[]>(`/issues/${issueId}/live-runs`),
activeRunForIssue: (issueId: string) =>
+60 -1
View File
@@ -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> = {}): Issue {
};
}
function createActiveRun(overrides: Partial<ActiveRunForIssue> = {}): 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<ComponentProps<typeof IssueRunLedgerContent>> = {}) {
render(
<IssueRunLedgerContent
@@ -108,6 +138,8 @@ function renderLedger(props: Partial<ComponentProps<typeof IssueRunLedgerContent
issueStatus={props.issueStatus ?? "in_progress"}
childIssues={props.childIssues ?? []}
agentMap={props.agentMap ?? new Map([["agent-1", { name: "CodexCoder" }]])}
pendingWatchdogDecision={props.pendingWatchdogDecision}
onWatchdogDecision={props.onWatchdogDecision}
/>,
);
}
@@ -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",
});
});
});
+165 -5
View File
@@ -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<string, Pick<Agent, "name">>;
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<Issue["status"]>(["done", "cancelled"]);
const ACTIVE_RUN_STATUSES = new Set(["queued", "running"]);
type RunOutputSilenceLevel = NonNullable<ActiveRunForIssue["outputSilence"]>["level"];
type RunOutputSilenceCopy = {
label: string;
tone: string;
};
const RUN_OUTPUT_SILENCE_COPY: Partial<Record<RunOutputSilenceLevel, RunOutputSilenceCopy>> = {
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<string, unknown> | null {
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
return value as Record<string, unknown>;
@@ -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<string, Pick<Age
return run.agentName ?? agentMap.get(run.agentId)?.name ?? run.agentId.slice(0, 8);
}
function formatSilenceAge(ms: number | null | undefined) {
if (!ms || ms <= 0) return null;
const totalMinutes = Math.floor(ms / 60_000);
if (totalMinutes < 1) return "under 1 minute";
if (totalMinutes < 60) return `${totalMinutes} minute${totalMinutes === 1 ? "" : "s"}`;
const hours = Math.floor(totalMinutes / 60);
const minutes = totalMinutes % 60;
if (minutes === 0) return `${hours} hour${hours === 1 ? "" : "s"}`;
return `${hours}h ${minutes}m`;
}
export function IssueRunLedger({
issueId,
issueStatus,
@@ -259,6 +316,7 @@ export function IssueRunLedger({
agentMap,
hasLiveRuns,
}: IssueRunLedgerProps) {
const queryClient = useQueryClient();
const { data: runs } = useQuery({
queryKey: queryKeys.issues.runs(issueId),
queryFn: () => activityApi.runsForIssue(issueId),
@@ -279,6 +337,13 @@ export function IssueRunLedger({
refetchInterval: hasLiveRuns ? false : 3000,
placeholderData: keepPreviousDataForSameQueryTail<ActiveRunForIssue | null>(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 (
<IssueRunLedgerContent
@@ -288,6 +353,8 @@ export function IssueRunLedger({
issueStatus={issueStatus}
childIssues={childIssues}
agentMap={agentMap}
pendingWatchdogDecision={watchdogDecision.variables?.decision ?? null}
onWatchdogDecision={(input) => 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({
</div>
) : null}
{latestSilentRun?.outputSilence ? (
<div className="rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-xs text-amber-900 dark:text-amber-200">
<p className="font-medium">
{latestSilentRun.outputSilence.level === "critical"
? "Stale-run watchdog alert"
: "Output silence watchdog warning"}
</p>
<p className="mt-1">
Latest active run has been silent for{" "}
{formatSilenceAge(latestSilentRun.outputSilence.silenceAgeMs) ?? "an extended period"}.
{latestSilentRun.outputSilence.evaluationIssueIdentifier ? (
<>
{" "}
Review{" "}
<Link
to={`/issues/${latestSilentRun.outputSilence.evaluationIssueIdentifier}`}
className="font-medium underline underline-offset-2"
>
{latestSilentRun.outputSilence.evaluationIssueIdentifier}
</Link>
{" "}for recovery context.
</>
) : null}
</p>
{onWatchdogDecision ? (
<div className="mt-2 flex flex-wrap gap-1.5">
<button
type="button"
className="rounded-md border border-border bg-background/80 px-2 py-1 text-[11px] text-foreground hover:bg-background"
onClick={() =>
onWatchdogDecision({
runId: latestSilentRun.runId,
decision: "continue",
evaluationIssueId: latestSilentRun.outputSilence?.evaluationIssueId ?? null,
})}
disabled={pendingWatchdogDecision != null}
>
Continue monitoring
</button>
<button
type="button"
className="rounded-md border border-border bg-background/80 px-2 py-1 text-[11px] text-foreground hover:bg-background"
onClick={() =>
onWatchdogDecision({
runId: latestSilentRun.runId,
decision: "snooze",
evaluationIssueId: latestSilentRun.outputSilence?.evaluationIssueId ?? null,
snoozedUntil: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
reason: "Snoozed from issue run ledger",
})}
disabled={pendingWatchdogDecision != null}
>
Snooze 1h
</button>
<button
type="button"
className="rounded-md border border-border bg-background/80 px-2 py-1 text-[11px] text-foreground hover:bg-background"
onClick={() =>
onWatchdogDecision({
runId: latestSilentRun.runId,
decision: "dismissed_false_positive",
evaluationIssueId: latestSilentRun.outputSilence?.evaluationIssueId ?? null,
reason: "Dismissed from issue run ledger",
})}
disabled={pendingWatchdogDecision != null}
>
Mark false positive
</button>
</div>
) : null}
</div>
) : null}
{ledgerRuns.length === 0 ? (
<div className="rounded-md border border-dashed border-border px-3 py-3 text-sm text-muted-foreground">
Historical runs without liveness metadata will appear here once linked to this issue.
@@ -418,6 +568,16 @@ export function IssueRunLedgerContent({
{retryState.badgeLabel}
</span>
) : null}
{run.outputSilence && RUN_OUTPUT_SILENCE_COPY[run.outputSilence.level] ? (
<span
className={cn(
"rounded-md border px-1.5 py-0.5 text-[11px] font-medium",
RUN_OUTPUT_SILENCE_COPY[run.outputSilence.level]?.tone,
)}
>
{RUN_OUTPUT_SILENCE_COPY[run.outputSilence.level]?.label}
</span>
) : null}
</div>
<div className="grid gap-2 text-xs text-muted-foreground sm:grid-cols-3">
+2 -2
View File
@@ -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(() => {
+2 -1
View File
@@ -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;
+2 -1
View File
@@ -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.",
});
});
});
+4 -2
View File
@@ -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,
};
}