Files
PaperClipAI/server/src/__tests__/task-watchdogs-classifier.test.ts
T
DottaandPaperclip c481be44e3 fix(task-watchdogs): deduplicate unchanged stopped-state wakes (#10207)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Task watchdogs review issue subtrees when no run or queued wake
keeps work live
> - Pending human interactions and approvals are valid stopped states
that still need one watchdog review
> - The existing fingerprint included volatile activity timestamps, so
unchanged stopped trees could wake repeatedly after comments, documents,
work products, or sibling completions
> - This pull request fingerprints only review-material leaf and wait
state, persists the reviewed snapshot, and suppresses shrink-only
repeats
> - The benefit is one review per materially new stopped state without
weakening liveness classification or hiding human waits

## Linked Issues or Issue Description

### What happened?

Task-watchdog stop fingerprints changed for metadata-only activity and
completed siblings, producing duplicate wakes after an unchanged stop
had already been reviewed.

### Expected behavior

Pending interactions and approvals remain classified as stopped, but a
reviewed stopped state only wakes again when waits, non-terminal leaves,
status, assignment, or blockers gain material changes.

### Steps to reproduce

1. Review a stopped watched subtree with a pending human wait or
multiple non-terminal stopped leaves.
2. Add only comment/document/work-product activity, or complete one
stopped sibling without changing the wait set.
3. Observe a duplicate wake from the timestamp-heavy fingerprint.

Related public work: Refs #9452 for overlapping task-watchdog service
edits and #10043 for related no-op fingerprint suppression.

## What Changed

- Added fingerprint v2 over non-terminal material leaves plus
subtree-wide pending wait ids, excluding volatile timestamps while
retaining them in wake context.
- Added nullable observed/reviewed JSONB stop snapshots and shrink-only
reviewed-state suppression with legacy exact-fingerprint fallback.
- Added pending interaction kinds and approval ids to watchdog wake
context, review comments, and comment metadata.
- Added classifier and scheduler coverage for waiting-leaf liveness,
metadata stability, sibling shrink suppression, material changes,
snapshot promotion, legacy rows, and unchanged idempotency keys.

## Verification

- `pnpm --filter @paperclipai/server exec vitest run
src/__tests__/task-watchdogs-classifier.test.ts
src/__tests__/task-watchdogs-scheduler.test.ts` — 2 files, 36 tests
passed.
- `pnpm --filter @paperclipai/server typecheck` — passed.
- `git diff --check origin/master...HEAD` — passed.

## Risks

- Fingerprint version 2 intentionally re-fingerprints every currently
stopped watched tree once after deployment, causing a one-time wake
burst before the new reviewed snapshots are established.
- Migration `0191_task_watchdog_stop_snapshots.sql` only adds two
nullable JSONB columns with no backfill; legacy rows continue
exact-fingerprint behavior until a post-deploy review promotes a
snapshot.
- PR #9452 edits the same service file; whichever lands second may need
a trivial rebase.

> 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

- OpenAI Codex, exact model ID `gpt-5.6-sol`, high reasoning mode, with
repository tool use and code execution. The runtime did not expose a
context-window size.

## 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)
- [x] 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
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] 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

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-07-25 08:34:41 -05:00

391 lines
13 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { classifyTaskWatchdogSubtree, type TaskWatchdogClassifierIssue } from "../services/task-watchdogs.ts";
const companyId = "company-1";
const sourceId = "source-1";
const childId = "child-1";
const watchdogId = "watchdog-1";
function issue(overrides: Partial<TaskWatchdogClassifierIssue> = {}): TaskWatchdogClassifierIssue {
return {
id: sourceId,
companyId,
identifier: "PAP-1",
title: "Source",
status: "todo",
parentId: null,
assigneeAgentId: "agent-1",
assigneeUserId: null,
originKind: "manual",
updatedAt: new Date("2026-06-17T20:00:00.000Z"),
...overrides,
};
}
function classify(overrides: Partial<Parameters<typeof classifyTaskWatchdogSubtree>[0]> = {}) {
return classifyTaskWatchdogSubtree({
watchdog: {
companyId,
issueId: sourceId,
lastReviewedFingerprint: null,
},
issues: [issue()],
...overrides,
});
}
describe("task watchdog subtree classifier", () => {
it("suppresses watchdog wakeups while watched subtree work has a live path", () => {
const result = classify({
issues: [
issue(),
issue({ id: childId, identifier: "PAP-2", parentId: sourceId, status: "in_progress" }),
],
activeRuns: [{ companyId, issueId: childId, agentId: "agent-1", status: "running" }],
});
expect(result).toMatchObject({
state: "live",
liveIssueIds: [childId],
});
});
it("treats terminal and waiting leaves as stopped work that needs verification", () => {
const result = classify({
issues: [
issue({ status: "done" }),
issue({ id: childId, identifier: "PAP-2", parentId: sourceId, status: "in_review" }),
],
pendingInteractions: [{
companyId,
issueId: childId,
id: "interaction-1",
kind: "request_confirmation",
status: "pending",
}],
});
expect(result.state).toBe("stopped");
if (result.state !== "stopped") return;
expect(result.stopFingerprint).toMatch(/^task_watchdog_stop:/);
expect(result.stoppedLeaves).toEqual([
expect.objectContaining({
issueId: childId,
status: "in_review",
pendingInteractionIds: ["interaction-1"],
}),
]);
expect(result.stopSnapshot.waitsByIssueId).toEqual({
[childId]: {
pendingInteractionIds: ["interaction-1"],
pendingApprovalIds: [],
},
});
expect(result.pendingInteractionsByIssueId).toEqual({
[childId]: [{ id: "interaction-1", kind: "request_confirmation" }],
});
});
it("keeps the material fingerprint stable across metadata-only ticks", () => {
const initial = classify({
issues: [issue({
status: "blocked",
latestCommentAt: "2026-06-17T20:01:00.000Z",
latestDocumentAt: "2026-06-17T20:02:00.000Z",
latestWorkProductAt: "2026-06-17T20:03:00.000Z",
})],
});
const ticked = classify({
issues: [issue({
status: "blocked",
updatedAt: "2026-06-18T20:00:00.000Z",
latestCommentAt: "2026-06-18T20:01:00.000Z",
latestDocumentAt: "2026-06-18T20:02:00.000Z",
latestWorkProductAt: "2026-06-18T20:03:00.000Z",
})],
});
expect(initial.state).toBe("stopped");
expect(ticked.state).toBe("stopped");
if (initial.state !== "stopped" || ticked.state !== "stopped") return;
expect(ticked.stopFingerprint).toBe(initial.stopFingerprint);
expect(ticked.stoppedLeaves[0]?.updatedAt).not.toBe(initial.stoppedLeaves[0]?.updatedAt);
});
it("suppresses a shrink-only stopped state after a sibling completes", () => {
const siblingId = "child-2";
const initial = classify({
issues: [
issue({ status: "in_progress" }),
issue({ id: childId, parentId: sourceId, status: "in_review" }),
issue({ id: siblingId, parentId: sourceId, status: "blocked" }),
],
pendingInteractions: [{ companyId, issueId: childId, id: "interaction-1", status: "pending" }],
});
expect(initial.state).toBe("stopped");
if (initial.state !== "stopped") return;
const shrunk = classify({
watchdog: {
companyId,
issueId: sourceId,
lastReviewedFingerprint: initial.stopFingerprint,
lastReviewedStopSnapshot: initial.stopSnapshot,
},
issues: [
issue({ status: "in_progress" }),
issue({ id: childId, parentId: sourceId, status: "in_review" }),
issue({ id: siblingId, parentId: sourceId, status: "done" }),
],
pendingInteractions: [{ companyId, issueId: childId, id: "interaction-1", status: "pending" }],
});
expect(shrunk.state).toBe("already_reviewed");
if (shrunk.state !== "already_reviewed") return;
expect(shrunk.stopFingerprint).not.toBe(initial.stopFingerprint);
});
it("uses exact-hash behavior when the legacy reviewed snapshot is null", () => {
const siblingId = "child-2";
const initial = classify({
issues: [
issue({ status: "in_progress" }),
issue({ id: childId, parentId: sourceId, status: "blocked" }),
issue({ id: siblingId, parentId: sourceId, status: "blocked" }),
],
});
expect(initial.state).toBe("stopped");
if (initial.state !== "stopped") return;
const changed = classify({
watchdog: {
companyId,
issueId: sourceId,
lastReviewedFingerprint: initial.stopFingerprint,
lastReviewedStopSnapshot: null,
},
issues: [
issue({ status: "in_progress" }),
issue({ id: childId, parentId: sourceId, status: "blocked" }),
issue({ id: siblingId, parentId: sourceId, status: "done" }),
],
});
expect(changed.state).toBe("stopped");
});
it("includes waits on non-leaf issues in the material snapshot", () => {
const initial = classify({
issues: [
issue({ status: "in_review" }),
issue({ id: childId, parentId: sourceId, status: "blocked" }),
],
pendingApprovals: [{ companyId, issueId: sourceId, id: "approval-1", status: "pending" }],
});
expect(initial.state).toBe("stopped");
if (initial.state !== "stopped") return;
expect(initial.stopSnapshot.waitsByIssueId).toEqual({
[sourceId]: {
pendingInteractionIds: [],
pendingApprovalIds: ["approval-1"],
},
});
const changed = classify({
watchdog: {
companyId,
issueId: sourceId,
lastReviewedFingerprint: initial.stopFingerprint,
lastReviewedStopSnapshot: initial.stopSnapshot,
},
issues: [
issue({ status: "in_review" }),
issue({ id: childId, parentId: sourceId, status: "blocked" }),
],
pendingApprovals: [{ companyId, issueId: sourceId, id: "approval-2", status: "pending" }],
});
expect(changed.state).toBe("stopped");
});
it.each([
["wait set", {
pendingInteractions: [{ companyId, issueId: childId, id: "interaction-2", status: "pending" }],
}],
["leaf status", {
issues: [issue({ status: "in_progress" }), issue({ id: childId, parentId: sourceId, status: "todo" })],
}],
["leaf assignee", {
issues: [
issue({ status: "in_progress" }),
issue({ id: childId, parentId: sourceId, status: "blocked", assigneeAgentId: "agent-2" }),
],
}],
["leaf blocker", {
blockers: [{ companyId, blockedIssueId: childId, blockerIssueId: "blocker-2" }],
}],
["new stopped leaf", {
issues: [
issue({ status: "in_progress" }),
issue({ id: childId, parentId: sourceId, status: "blocked" }),
issue({ id: "child-2", parentId: sourceId, status: "blocked" }),
],
}],
])("triggers a fresh stop when the %s changes", (_label, overrides) => {
const baseInput = {
issues: [issue({ status: "in_progress" }), issue({ id: childId, parentId: sourceId, status: "blocked" })],
pendingInteractions: [{ companyId, issueId: childId, id: "interaction-1", status: "pending" }],
blockers: [{ companyId, blockedIssueId: childId, blockerIssueId: "blocker-1" }],
};
const initial = classify(baseInput);
expect(initial.state).toBe("stopped");
if (initial.state !== "stopped") return;
const changed = classify({
...baseInput,
...overrides,
watchdog: {
companyId,
issueId: sourceId,
lastReviewedFingerprint: initial.stopFingerprint,
lastReviewedStopSnapshot: initial.stopSnapshot,
},
});
expect(changed.state).toBe("stopped");
});
it("suppresses an unchanged stopped fingerprint once the watchdog reviewed it", () => {
const stopped = classify({
issues: [issue({ status: "blocked" })],
});
expect(stopped.state).toBe("stopped");
if (stopped.state !== "stopped") return;
const reviewed = classify({
watchdog: {
companyId,
issueId: sourceId,
lastReviewedFingerprint: stopped.stopFingerprint,
},
issues: [issue({ status: "blocked" })],
});
expect(reviewed).toMatchObject({
state: "already_reviewed",
stopFingerprint: stopped.stopFingerprint,
});
});
it("excludes task-watchdog issues and their descendants from watched subtree scans", () => {
const result = classify({
issues: [
issue({ status: "done" }),
issue({
id: watchdogId,
identifier: "PAP-3",
title: "Watchdog",
parentId: sourceId,
originKind: "task_watchdog",
status: "in_progress",
}),
issue({
id: "watchdog-child-1",
identifier: "PAP-4",
title: "Nested watchdog work",
parentId: watchdogId,
originKind: "manual",
status: "in_progress",
}),
],
activeRuns: [{ companyId, issueId: "watchdog-child-1", agentId: "agent-1", status: "running" }],
});
expect(result.state).toBe("stopped");
expect(result.includedIssueIds).toEqual([sourceId]);
});
it("defers a stopped verdict for an issue created inside the first-run grace window", () => {
const createdAt = new Date("2026-06-18T16:32:45.731Z");
const result = classify({
issues: [issue({ status: "todo", createdAt })],
// Evaluation races the issue's own assignment run ~100ms after creation.
evaluatedAt: new Date("2026-06-18T16:32:45.835Z"),
firstRunGraceMs: 15_000,
});
expect(result.state).toBe("pending_first_run");
if (result.state !== "pending_first_run") return;
expect(result.pendingIssueIds).toEqual([sourceId]);
});
it("does not defer when a recently-created issue already completed a run", () => {
const createdAt = new Date("2026-06-18T16:32:45.731Z");
const result = classify({
issues: [issue({ status: "blocked", createdAt })],
evaluatedAt: new Date("2026-06-18T16:32:48.000Z"),
firstRunGraceMs: 15_000,
completedRunIssueIds: [sourceId],
});
expect(result.state).toBe("stopped");
});
it("treats a queued assignment run inside the create-race window as live", () => {
const createdAt = new Date("2026-06-18T16:32:45.731Z");
const result = classify({
issues: [issue({ status: "todo", createdAt })],
activeRuns: [{ companyId, issueId: sourceId, agentId: "agent-1", status: "queued" }],
evaluatedAt: new Date("2026-06-18T16:32:45.835Z"),
firstRunGraceMs: 15_000,
});
expect(result).toMatchObject({ state: "live", liveIssueIds: [sourceId] });
});
it("treats a queued assignment wake inside the create-race window as live", () => {
const createdAt = new Date("2026-06-18T16:32:45.731Z");
const result = classify({
issues: [issue({ status: "todo", createdAt })],
queuedWakeRequests: [{ companyId, issueId: sourceId, agentId: "agent-1", status: "queued" }],
evaluatedAt: new Date("2026-06-18T16:32:45.835Z"),
firstRunGraceMs: 15_000,
});
expect(result).toMatchObject({ state: "live", liveIssueIds: [sourceId] });
});
it("triggers a genuinely idle assigned issue once the grace window has elapsed", () => {
const createdAt = new Date("2026-06-18T16:32:45.731Z");
const result = classify({
issues: [issue({ status: "todo", createdAt })],
// 60s later: no run, no wake, past the grace window.
evaluatedAt: new Date("2026-06-18T16:33:45.731Z"),
firstRunGraceMs: 15_000,
});
expect(result.state).toBe("stopped");
});
it("does not evaluate a task-watchdog issue as a watched source", () => {
const result = classify({
watchdog: {
companyId,
issueId: watchdogId,
lastReviewedFingerprint: null,
},
issues: [
issue({
id: watchdogId,
identifier: "PAP-3",
title: "Watchdog",
originKind: "task_watchdog",
}),
],
});
expect(result.state).toBe("not_applicable");
});
});