Files
PaperClipAI/server/src/__tests__/task-watchdogs-classifier.test.ts
T
a71c4b6782 [codex] feat(watchdog): add task watchdog control plane (#8339)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - The task lifecycle and recovery subsystems decide when agent work is
still productive, stalled, or ready for review.
> - Existing recovery paths can observe stopped or incomplete work, but
there was no first-class per-task watchdog model with scoped review
permissions.
> - Watchdog follow-ups also need strict boundaries so
recovery/status-only runs cannot mutate approvals or perform deliverable
work.
> - This pull request adds the task watchdog data model, API/service
layer, scheduler/review flow, adapter wake context, UI configuration
surfaces, and docs.
> - The branch has been rebased onto current `paperclipai/paperclip`
`master`; the watchdog migration is now ordered after master's latest
migrations as `0104_issue_watchdogs`.
> - The benefit is a more explicit task-review loop that preserves
Paperclip's single-assignee and governance invariants while making
stalled work easier to route.

## Linked Issues or Issue Description

No linked GitHub issue. Paperclip task:
[PAP-11275](/PAP/issues/PAP-11275).

## Problem or motivation

Task recovery needs a first-class watchdog path that can inspect stopped
work and create scoped follow-ups without bypassing normal task
ownership. Board/UI users need a way to configure watchdogs on tasks and
see watchdog-related live work. Recovery/status-only runs must remain
limited to status reporting and must not create approvals, link
approvals, or submit approval comments.

## Proposed solution

Add a task-watchdog data model, scheduler/classifier, scoped mutation
guard, adapter wake context, API/UI configuration surfaces, and
documentation so watchdog agents can review stopped task subtrees under
explicit boundaries.

## Alternatives considered

Reuse the existing recovery-action flow only. That would keep
stopped-work detection implicit, make per-task watchdog assignment
harder to expose in the UI, and would not provide a durable
scoped-review issue for stalled task trees.

## Roadmap alignment

This is Paperclip control-plane lifecycle infrastructure for task
execution and recovery. I checked `ROADMAP.md`; this PR does not
duplicate an existing planned core item.

## What Changed

- Added issue watchdog schema, migration, shared contracts, validators,
CRUD API, and service support.
- Added task watchdog scheduler/classifier behavior, scoped mutation
enforcement, adapter wake context, and default watchdog mandate
guidance.
- Added UI surfaces for configuring watchdogs on new/existing tasks,
viewing watchdog activity, and exposing the experimental setting.
- Added docs for the user-facing task watchdog workflow and
implementation semantics.
- Gated new-task watchdog setup behind `enableTaskWatchdogs` and blocked
cheap status-only recovery runs from approval mutations.
- Rebased onto current `master` and renumbered the idempotent watchdog
migration from the branch-local `0102_issue_watchdogs` slot to
`0104_issue_watchdogs`.
- Addressed Greptile feedback by loading watchdog classifier input with
a recursive subtree query and centralizing the watchdog origin-kind
constant.
- Added and updated focused server/UI tests for watchdog routes,
scheduler/classifier behavior, scope boundaries, live task visibility,
settings, and new issue dialog behavior.

## Verification

- `pnpm vitest run server/src/__tests__/task-watchdogs-scheduler.test.ts
server/src/__tests__/task-watchdogs-classifier.test.ts`
- `pnpm vitest run
server/src/__tests__/approval-routes-idempotency.test.ts
server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts`
- `pnpm vitest run ui/src/components/NewIssueDialog.test.tsx`
- `pnpm --filter @paperclipai/server typecheck`
- `git diff --check`
- Verified the PR diff does not include `pnpm-lock.yaml` or
`.github/workflows`.

## Risks

- Medium risk: this introduces a new task lifecycle surface touching DB
schema, server routes/services, adapter wake context, and UI task
configuration.
- Watchdog scheduling behavior depends on the new experimental setting
and runtime context checks behaving consistently across local and
production agents.
- The watchdog migration is idempotent (`IF NOT EXISTS` /
duplicate-object guards) so users who tried the previous branch-local
migration number should not get duplicate-object failures.
- CI and the second Greptile pass are pending after the latest
review-fix push.

> 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, GPT-5-class coding agent in the Paperclip workspace. Exact
runtime model id and context window were not exposed to the agent; tool
use and local command execution were enabled.

## 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 run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] If this change affects the UI, I have included before/after
screenshots — N/A per Paperclip task instruction: do not add
screenshots/images to this PR unless they are specifically part of the
work.
- [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
- [ ] 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>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 15:38:52 -05:00

206 lines
6.5 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", 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"],
}),
]);
});
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");
});
});