mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-13 23:39:21 +02:00
## 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>
391 lines
13 KiB
TypeScript
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");
|
|
});
|
|
});
|