From 550e959934df71ef9d4e28fa6eb6656804d411a1 Mon Sep 17 00:00:00 2001 From: Ash Simons <83401983+simberthon@users.noreply.github.com> Date: Fri, 16 Oct 2026 05:47:54 +0700 Subject: [PATCH] fix(task-watchdogs): deliver board instructions and stopped leaves in the wake payload (#13771) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Task watchdogs wake an agent when a watched issue subtree stops moving. > - An operator can write `instructions` on the watchdog record. The wake prompt has a slot for this text in the "Task Watchdog Mandate" block. > - The producer writes `customInstructions` and `stoppedLeaves` at the top level of the wake payload. The normalizer reads them only from the nested `taskWatchdog` object. Thus the normalizer drops both fields on every watchdog wake. > - The agent then sees "No board-supplied watchdog instructions" although the record holds an instruction. The operator cannot see this failure, and the agent cannot see it either. > - This pull request puts both fields in the nested `taskWatchdog` object. > - The benefit is that operator instructions and the stopped-leaf list reach the agent again. ## Linked Issues or Issue Description No public issue exists. This is the bug description (bug_report template). **What happened?** `watchdogWakeContext()` in `server/src/services/task-watchdogs.ts` writes `customInstructions` and `stoppedLeaves` at the top level of the wake payload. `normalizePaperclipWakePayload()` in `packages/adapter-utils/src/server-utils.ts` reads `payload.taskWatchdog` only. The two fields are lost. The "Task Watchdog Mandate" block always shows "No board-supplied watchdog instructions. Apply the mandate above." The "Terminal / stopped leaves to verify" section never shows. **Expected behavior** When the watchdog record has non-empty `instructions`, the wake prompt shows that text. When the subtree has stopped leaves, the prompt lists them. **Steps to reproduce** 1. Create a task watchdog on an issue. Set a non-empty `instructions` value. 2. Let the watched subtree stop, so that the watchdog triggers. 3. Read the wake prompt of the watchdog agent. It shows the "No board-supplied watchdog instructions" line. 4. `GET /api/issues/{id}/watchdog` still returns the non-empty `instructions` value. **Paperclip version or commit** Reproduced on `master` before this change. The new test in this PR fails on `master`. **Agent adapter(s) involved** Not adapter-specific (core bug). The drop occurs in the shared wake-payload normalizer. ## What Changed - `server/src/services/task-watchdogs.ts`: add `customInstructions` and `stoppedLeaves` to the nested `taskWatchdog` object. Map each stopped leaf to the shape the normalizer expects (`issueId` becomes `id`). Keep the top-level copies, so that other consumers do not change. - Add a test for the full path: real service, real wake payload, `normalizePaperclipWakePayload`, then `renderPaperclipWakePrompt`. The test also asserts that the "No board-supplied watchdog instructions" line is absent. ## Verification - Run from the repo root: ``` npx vitest run server/src/__tests__/task-watchdogs-scheduler.test.ts \ server/src/__tests__/task-watchdogs-classifier.test.ts \ server/src/__tests__/task-watchdogs-incident-fixture.test.ts \ server/src/__tests__/issue-watchdogs-routes.test.ts npx vitest run packages/adapter-utils/src/server-utils.test.ts ``` - Local result: watchdog suites 52 of 52 pass. `server-utils.test.ts` 117 of 117 pass. - The new test fails on `master` because `taskWatchdog.customInstructions` is absent. It passes on this branch. - All Paperclip CI gates on this PR are green. Greptile gives 5/5. ## Risks - Low risk. The change adds fields to a nested object. It does not remove fields. - No schema change, no migration, no data write. - Behavior change: agents now receive operator instructions that they did not receive before. This is the intended result. - Rollback: revert the single commit. - Note: the normalizer field name is `terminalLeafSummaries`, but the classifier fills `stoppedLeaves` with non-terminal stopped leaves. The renderer heading ("Terminal / stopped leaves to verify") covers both. This PR does not rename a public payload field. Maintainers can align the names in a separate change. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - Provider: Anthropic Claude, Opus family, used through Claude Code as an agent with tool use and shell execution. - This PR description revision: `claude-opus-5-5`, 1M context window. - The model found the cause, wrote the fix and the test, and ran the test suites. A human owns the account that opened the PR. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [ ] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [ ] I have updated relevant documentation to reflect my changes (not applicable: no documented behavior changed) - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge Duplicate search: I searched open and closed PRs and issues for "watchdog instructions" and "watchdog customInstructions". I found no related PR or issue. Branch name: the branch name has an internal prefix. If the maintainers want it, I can open the same commit again from a `fix/...` branch. --- .../task-watchdogs-scheduler.test.ts | 53 ++++++++++++++++++- server/src/services/task-watchdogs.ts | 13 +++++ 2 files changed, 64 insertions(+), 2 deletions(-) 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)