diff --git a/server/src/__tests__/task-watchdogs-scheduler.test.ts b/server/src/__tests__/task-watchdogs-scheduler.test.ts index 9c407002a54..1124c163eb2 100644 --- a/server/src/__tests__/task-watchdogs-scheduler.test.ts +++ b/server/src/__tests__/task-watchdogs-scheduler.test.ts @@ -18,6 +18,10 @@ import { issues, issueWatchdogs, } from "@paperclipai/db"; +import { + normalizePaperclipWakePayload, + renderPaperclipWakePrompt, +} from "@paperclipai/adapter-utils/server-utils"; import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase, @@ -144,12 +148,17 @@ describeEmbeddedPostgres("task watchdog scheduler", () => { }); } - async function seedWatchdog(companyId: string, issueId: string, agentId: string) { + async function seedWatchdog( + companyId: string, + issueId: string, + agentId: string, + instructions = "Verify stopped work.", + ) { const [row] = await db.insert(issueWatchdogs).values({ companyId, issueId, watchdogAgentId: agentId, - instructions: "Verify stopped work.", + instructions, status: "active", }).returning(); return row; @@ -229,6 +238,46 @@ describeEmbeddedPostgres("task watchdog scheduler", () => { expect(watchdog?.triggerCount).toBe(1); }); + it("delivers board-supplied watchdog instructions all the way into the rendered mandate", async () => { + const boardInstructions = + "Escalate to Luna before any production deploy; you do not hold Level B authority."; + const companyId = await seedCompany(); + const sourceId = await seedIssue(companyId, { identifier: "WDOG-INSTR", status: "done" }); + const leafId = await seedIssue(companyId, { + parentId: sourceId, + identifier: "WDOG-INSTR-LEAF", + status: "todo", + }); + const agentId = await seedAgent(companyId); + await seedWatchdog(companyId, sourceId, agentId, boardInstructions); + const { service, wakes } = createService(); + + await service.reconcileTaskWatchdogs({ companyId }); + expect(wakes).toHaveLength(1); + + // The producer must place the instructions inside `taskWatchdog`, which is + // the only object the wake normalizer reads. + const snapshot = wakes[0]?.opts?.contextSnapshot; + expect(snapshot).toMatchObject({ taskWatchdog: { customInstructions: boardInstructions } }); + + // End-to-end: the text the agent actually receives. + const normalized = normalizePaperclipWakePayload(snapshot); + expect(normalized?.taskWatchdog?.customInstructions).toBe(boardInstructions); + + const prompt = renderPaperclipWakePrompt(normalized); + expect(prompt).toContain("## Task Watchdog Mandate"); + expect(prompt).toContain(boardInstructions); + expect(prompt).not.toContain("No board-supplied watchdog instructions"); + + // Same producer/normalizer contract: the stopped leaves the watchdog is + // asked to verify must reach the mandate too. + expect(normalized?.taskWatchdog?.terminalLeafSummaries).toEqual([ + expect.objectContaining({ id: leafId, identifier: "WDOG-INSTR-LEAF", status: "todo" }), + ]); + expect(prompt).toContain("Terminal / stopped leaves to verify:"); + expect(prompt).toContain("WDOG-INSTR-LEAF"); + }); + it("does not append duplicate review comments for an already-open same-fingerprint review", async () => { const companyId = await seedCompany(); const sourceId = await seedIssue(companyId, { identifier: "WDOG-DUPE", status: "done" }); diff --git a/server/src/services/task-watchdogs.ts b/server/src/services/task-watchdogs.ts index 154df0bb19a..e9627cf74bb 100644 --- a/server/src/services/task-watchdogs.ts +++ b/server/src/services/task-watchdogs.ts @@ -692,6 +692,19 @@ function watchdogWakeContext(input: { watchedIssueIdentifier: input.sourceIssue.identifier, watchedIssueTitle: input.sourceIssue.title, stopFingerprint: input.classification.stopFingerprint, + // The wake normalizer only reads `taskWatchdog`, so board instructions + // must live here. A top-level copy is silently dropped and the mandate + // renders "No board-supplied watchdog instructions" instead. + customInstructions: input.watchdog.instructions, + // Same contract: the mandate's "Terminal / stopped leaves to verify" + // section reads `terminalLeafSummaries` here, keyed on `id`, not the + // top-level `stoppedLeaves` array keyed on `issueId`. + terminalLeafSummaries: input.classification.stoppedLeaves.map((leaf) => ({ + id: leaf.issueId, + identifier: leaf.identifier, + title: leaf.title, + status: leaf.status, + })), pendingInteractions: input.classification.pendingInteractionsByIssueId, pendingApprovals: Object.fromEntries(Object.entries(input.classification.stopSnapshot.waitsByIssueId) .filter(([, waits]) => waits.pendingApprovalIds.length > 0)