mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-19 10:36:26 +02:00
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:
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" });
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user