mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-03 01:27:09 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Task watchdogs verify stopped issue subtrees and restore valid work paths. > - A watchdog must read the full subtree before it can prove that all paths stopped. > - The current query stops after 100 levels and does not report omitted children. > - A running child beyond this limit can be absent from both review dispatch and mutation checks. > - This pull request reports an incomplete scan and blocks both decisions. > - Complete subtrees keep their current behavior and stop fingerprints. ## Linked Issues or Issue Description Fixes: #15061 Related PRs: #11327 changes subtree loading but keeps the same depth limit without a completeness result. #10331 changes mutation freshness after watchdog writes. Neither patch covers this boundary. This patch does not take code from those proposals. ## What Changed - Keep the existing 100-level traversal limit. - Check for an eligible child beyond the boundary in the same SQL statement as the subtree read. - Apply the same company, visibility, harness, and watchdog-origin filters to this check. - Return `incomplete` without a stop fingerprint when the scan omits eligible children. - Skip review dispatch and reject watchdog mutation revalidation for an incomplete scan. - Report incomplete scans through their own count and watched issue IDs. Log each newly incomplete issue at startup or during periodic reconciliation. Suppress unchanged results. Allow a new warning after the issue recovers and becomes incomplete again. - Add real PostgreSQL regressions for dispatch, a previously valid mutation scope, the complete boundary leaf, and excluded children. - Document the incomplete-scan behavior. ## Verification - Base: `569c7203aa24b95440682983ce7940ba1d4247bd`. Head: `06f68199656f979ef5eb4897851f0fbcbca592d3`. - Before the fix, two real PostgreSQL regressions fail. The service dispatches a review for a partial subtree. It also accepts a prior scope after a running depth-100 child appears. The other 19 scheduler checks pass. - On the exact final head, `pnpm -r typecheck` and `pnpm build` pass on isolated Ubuntu with Node 24.19.0, pnpm 9.15.4, and runner Rust 1.97.1. - All 75 related tests pass. Run `pnpm --filter @paperclipai/server exec vitest run src/__tests__/task-watchdogs-classifier.test.ts src/__tests__/task-watchdogs-scheduler.test.ts src/__tests__/server-startup-feedback-export.test.ts src/__tests__/issue-watchdogs-routes.test.ts --maxWorkers=1`. - Full `test:run` coverage passes across 29 logical groups: all official stable-runner lanes plus supplemental service and script tests. The selection covers all 900 server source test files without duplicate or omitted selections. It also includes UI, CLI, and other workspace projects. - A separate validation tree applies both this PR and #15067 to upstream `994d6edcdd4e15d5f9cc5cf8c135ac599104b86a` without conflicts. Type checks, the build, and all 98 related tests across five files pass. Both public PR heads remain unchanged. - Four full server lanes required test-process environment corrections. The host DNS search suffix resolved a `.invalid` fixture to an address. Ancestor project authentication settings affected hire fixtures. The old path-isolation tool also failed test imports with `EFAULT`. The exact base has the same failures: 8 failed and 128 passed assertions without isolation, then three failed imports with the old tool. All 136 checks pass on both the base and this head with the corrected isolation. All four original full lanes pass with the corrected tool, a resolver without a search suffix, empty ancestor project settings, and the standard Node path. No assertion or wait limit changed. The host configuration remains unchanged. - Earlier interrupted Ubuntu and Docker runs are retained and are not counted as passes. This exact-head Ubuntu run passes the original chat, port-conflict, and serialized lanes. The causes of the earlier host-loss and timing failures are not fully established. - The startup import regression in an earlier revision is fixed by a diagnostics module with no database dependencies. All 21 startup checks pass. - Exact-head CI, including the added scans after marking the PR ready: 54 successful checks and 2 skipped checks. Greptile: 5/5, no outstanding findings or follow-ups. Both review threads are resolved. - The final audit rechecked issue authors, assignees, comments, 206 open watchdog search results, root functions and fields, source history, and related diffs. The latest upstream is `994d6edcdd4e15d5f9cc5cf8c135ac599104b86a`. Its new patches do not cover this root. No competing root repair was found within the audited scope. The issue has no assignee or new comment. - No model provider, production database, or user instance was used. This verifies service behavior, not a full HTTP authorization bypass. ## Risks - A subtree with eligible children beyond 100 levels now waits instead of running a review. This is a deliberate safety boundary. It remains blocked until the scan can include the full subtree. - The boundary check adds a child-existence lookup to the existing recursive query. - No schema migration is required. Complete subtrees retain their fingerprint format. - This does not add unlimited traversal or close the separate race between a subtree snapshot and later source mutations. - The configuration lifecycle defect in #15060 is handled by separate PR #15067. This branch does not include its schema or configuration changes. ## Model Used - OpenAI Codex, based on GPT-6, assisted with source analysis, implementation, and test execution. The exact serving model ID and context window are not exposed in this session. The agent used reasoning, shell tools, GitHub CLI, and real PostgreSQL tests. ## 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 - [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
432 lines
15 KiB
TypeScript
432 lines
15 KiB
TypeScript
import { describe, expect, it, vi } from "vitest";
|
|
import { classifyTaskWatchdogSubtree, type TaskWatchdogClassifierIssue } from "../services/task-watchdogs.ts";
|
|
|
|
import { createIncompleteTaskWatchdogScanReporter } from "../services/task-watchdog-diagnostics.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("reports an unchanged incomplete scan once across startup and a day of periodic ticks", () => {
|
|
const warn = vi.fn();
|
|
const report = createIncompleteTaskWatchdogScanReporter(warn);
|
|
report({ incomplete: 2, incompleteIssueIds: [sourceId, childId] }, "startup");
|
|
for (let tick = 0; tick < 2880; tick += 1) {
|
|
report({ incomplete: 2, incompleteIssueIds: [childId, sourceId] }, "periodic");
|
|
}
|
|
expect(warn).toHaveBeenCalledTimes(1);
|
|
expect(warn).toHaveBeenCalledWith(
|
|
{ incomplete: 2, newIncompleteIssueIds: [sourceId, childId] },
|
|
"startup task-watchdog reconciliation found incomplete subtree scans",
|
|
);
|
|
});
|
|
|
|
it("reports new incomplete issues and reports a resolved issue if it becomes incomplete again", () => {
|
|
const warn = vi.fn();
|
|
const report = createIncompleteTaskWatchdogScanReporter(warn);
|
|
report({ incomplete: 1, incompleteIssueIds: [sourceId] }, "startup");
|
|
report({ incomplete: 2, incompleteIssueIds: [sourceId, childId] }, "periodic");
|
|
expect(warn).toHaveBeenLastCalledWith(
|
|
{ incomplete: 2, newIncompleteIssueIds: [childId] },
|
|
"periodic task-watchdog reconciliation found incomplete subtree scans",
|
|
);
|
|
report({ incomplete: 0, incompleteIssueIds: [] }, "periodic");
|
|
expect(warn).toHaveBeenCalledTimes(2);
|
|
report({ incomplete: 1, incompleteIssueIds: [sourceId] }, "periodic");
|
|
expect(warn).toHaveBeenCalledTimes(3);
|
|
expect(warn).toHaveBeenLastCalledWith(
|
|
{ incomplete: 1, newIncompleteIssueIds: [sourceId] },
|
|
"periodic task-watchdog reconciliation found incomplete subtree scans",
|
|
);
|
|
});
|
|
|
|
it("does not produce a stopped fingerprint from an incomplete scan", () => {
|
|
const result = classify({ subtreeIncomplete: true });
|
|
expect(result.state).toBe("incomplete");
|
|
expect(result).not.toHaveProperty("stopFingerprint");
|
|
});
|
|
|
|
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");
|
|
});
|
|
});
|