fix(task-watchdogs): deliver board instructions and stopped leaves in the wake payload (#13771)

## 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.
This commit is contained in:
Ash Simons authored and GitHub committed 2026-10-15 15:47:54 -07:00
1 parent 92bedaac4a
commit 550e959934
2 files changed
+64 -2

No files matched your search

@@ -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" });
+13
View File
@@ -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)