[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>
This commit is contained in:
Dotta
2026-06-19 15:38:52 -05:00
committed by GitHub
parent 8f4b491d9a
commit a71c4b6782
56 changed files with 7231 additions and 68 deletions
@@ -61,12 +61,32 @@ async function createApp(actorOverrides: Record<string, unknown> = {}) {
};
next();
});
app.use("/api", approvalRoutes({} as any));
app.use("/api", approvalRoutes(createRouteDb()));
app.use(errorHandler);
return app;
}
async function createAgentApp() {
function createRouteDb(contextSnapshot: Record<string, unknown> = {}, runId = "run-1", agentId = "agent-1") {
const runRows = [{
id: runId,
companyId: "company-1",
agentId,
contextSnapshot,
}];
return {
select: vi.fn((selection: Record<string, unknown> = {}) => ({
from: vi.fn(() => ({
where: vi.fn(() => ({
then: async (resolve: (rows: unknown[]) => unknown) => resolve(
Object.keys(selection).includes("contextSnapshot") ? runRows : [],
),
})),
})),
})),
} as any;
}
async function createAgentApp(options: { runId?: string; contextSnapshot?: Record<string, unknown> } = {}) {
const [{ errorHandler }, { approvalRoutes }] = await Promise.all([
import("../middleware/index.js"),
import("../routes/approvals.js"),
@@ -78,12 +98,13 @@ async function createAgentApp() {
type: "agent",
agentId: "agent-1",
companyId: "company-1",
runId: options.runId ?? "run-1",
source: "api_key",
isInstanceAdmin: false,
};
next();
});
app.use("/api", approvalRoutes({} as any));
app.use("/api", approvalRoutes(createRouteDb(options.contextSnapshot, options.runId ?? "run-1")));
app.use(errorHandler);
return app;
}
@@ -347,4 +368,80 @@ describe("approval routes idempotent retries", () => {
}),
);
});
it("blocks status-only recovery runs from creating approvals", async () => {
const res = await request(await createAgentApp({
contextSnapshot: {
modelProfile: "cheap",
recoveryIntent: "status_only",
allowDeliverableWork: false,
allowDocumentUpdates: false,
resumeRequiresNormalModel: true,
},
}))
.post("/api/companies/company-1/approvals")
.send({
type: "request_board_approval",
payload: { title: "Approve hosting spend" },
});
expect(res.status, JSON.stringify(res.body)).toBe(403);
expect(res.body.error).toContain("Cheap status-only recovery runs cannot create or modify approvals");
expect(mockApprovalService.create).not.toHaveBeenCalled();
expect(mockIssueApprovalService.linkManyForApproval).not.toHaveBeenCalled();
});
it("blocks status-only recovery runs from resubmitting approvals", async () => {
mockApprovalService.getById.mockResolvedValue({
id: "approval-7",
companyId: "company-1",
type: "request_board_approval",
status: "revision_requested",
payload: {},
requestedByAgentId: "agent-1",
});
const res = await request(await createAgentApp({
contextSnapshot: {
modelProfile: "cheap",
recoveryIntent: "status_only",
allowDeliverableWork: false,
allowDocumentUpdates: false,
resumeRequiresNormalModel: true,
},
}))
.post("/api/approvals/approval-7/resubmit")
.send({ payload: { title: "Retry" } });
expect(res.status, JSON.stringify(res.body)).toBe(403);
expect(res.body.error).toContain("Cheap status-only recovery runs cannot create or modify approvals");
expect(mockApprovalService.resubmit).not.toHaveBeenCalled();
});
it("blocks status-only recovery runs from commenting on approvals", async () => {
mockApprovalService.getById.mockResolvedValue({
id: "approval-8",
companyId: "company-1",
type: "request_board_approval",
status: "pending",
payload: {},
requestedByAgentId: "agent-1",
});
const res = await request(await createAgentApp({
contextSnapshot: {
modelProfile: "cheap",
recoveryIntent: "status_only",
allowDeliverableWork: false,
allowDocumentUpdates: false,
resumeRequiresNormalModel: true,
},
}))
.post("/api/approvals/approval-8/comments")
.send({ body: "please approve" });
expect(res.status, JSON.stringify(res.body)).toBe(403);
expect(res.body.error).toContain("Cheap status-only recovery runs cannot create or modify approvals");
expect(mockApprovalService.addComment).not.toHaveBeenCalled();
});
});
@@ -66,6 +66,7 @@ describe("instance settings routes", () => {
enableIsolatedWorkspaces: false,
enableIssuePlanDecompositions: false,
enableExperimentalFileViewer: false,
enableTaskWatchdogs: false,
enableCloudSync: false,
autoRestartDevServerWhenIdle: false,
enableIssueGraphLivenessAutoRecovery: true,
@@ -86,6 +87,7 @@ describe("instance settings routes", () => {
enableIsolatedWorkspaces: true,
enableIssuePlanDecompositions: true,
enableExperimentalFileViewer: true,
enableTaskWatchdogs: true,
enableCloudSync: true,
autoRestartDevServerWhenIdle: false,
enableIssueGraphLivenessAutoRecovery: true,
@@ -131,6 +133,7 @@ describe("instance settings routes", () => {
enableIsolatedWorkspaces: false,
enableIssuePlanDecompositions: false,
enableExperimentalFileViewer: false,
enableTaskWatchdogs: false,
enableCloudSync: false,
autoRestartDevServerWhenIdle: false,
enableIssueGraphLivenessAutoRecovery: true,
@@ -248,6 +251,43 @@ describe("instance settings routes", () => {
});
});
it("allows local board users to update task watchdog controls", async () => {
const app = await createApp({
type: "board",
userId: "local-board",
source: "local_implicit",
isInstanceAdmin: true,
});
await request(app)
.patch("/api/instance/settings/experimental")
.send({ enableTaskWatchdogs: true })
.expect(200);
expect(mockInstanceSettingsService.updateExperimental).toHaveBeenCalledWith({
enableTaskWatchdogs: true,
});
});
it("allows non-admin board users with company access to read but not update experimental settings", async () => {
const app = await createApp({
type: "board",
userId: "user-1",
source: "session",
isInstanceAdmin: false,
companyIds: ["company-1"],
});
await request(app).get("/api/instance/settings/experimental").expect(200);
await request(app)
.patch("/api/instance/settings/experimental")
.send({ enableTaskWatchdogs: true })
.expect(403);
expect(mockInstanceSettingsService.updateExperimental).not.toHaveBeenCalled();
});
it("allows local board users to read and update general settings", async () => {
const app = await createApp({
type: "board",
@@ -8,6 +8,7 @@ describe("instance settings service", () => {
enableIsolatedWorkspaces: true,
enableIssuePlanDecompositions: true,
enableExperimentalFileViewer: true,
enableTaskWatchdogs: true,
enableCloudSync: true,
autoRestartDevServerWhenIdle: true,
enableIssueGraphLivenessAutoRecovery: true,
@@ -20,6 +21,7 @@ describe("instance settings service", () => {
enableConferenceRoomChat: false,
enableIssuePlanDecompositions: true,
enableExperimentalFileViewer: true,
enableTaskWatchdogs: true,
enableCloudSync: true,
autoRestartDevServerWhenIdle: true,
enableIssueGraphLivenessAutoRecovery: true,
@@ -36,6 +38,14 @@ describe("instance settings service", () => {
).toBe(false);
});
it("defaults enableTaskWatchdogs to false for empty and legacy stored settings", () => {
expect(normalizeExperimentalSettings(undefined).enableTaskWatchdogs).toBe(false);
expect(normalizeExperimentalSettings({}).enableTaskWatchdogs).toBe(false);
expect(
normalizeExperimentalSettings({ enableExperimentalFileViewer: true }).enableTaskWatchdogs,
).toBe(false);
});
it("round-trips an enableConferenceRoomChat patch through the update merge", () => {
// updateExperimental merges `{ ...normalize(current), ...patch }` and
// re-normalizes; emulate that to prove the flag survives the roundtrip
@@ -15,6 +15,7 @@ const mockIssueService = vi.hoisted(() => ({
assertCheckoutOwner: vi.fn(),
create: vi.fn(),
createChild: vi.fn(),
decomposeAcceptedPlan: vi.fn(),
getAttachmentById: vi.fn(),
getByIdentifier: vi.fn(),
getById: vi.fn(),
@@ -66,12 +67,33 @@ const mockStorageService = vi.hoisted(() => ({
const mockIssueThreadInteractionService = vi.hoisted(() => ({
expireRequestConfirmationsSupersededByComment: vi.fn(async () => []),
expireStaleRequestConfirmationsForIssueDocument: vi.fn(async () => []),
listForIssue: vi.fn(async () => []),
}));
const mockIssueApprovalService = vi.hoisted(() => ({
link: vi.fn(),
unlink: vi.fn(),
listApprovalsForIssue: vi.fn(async () => []),
}));
const mockIssueRecoveryActionService = vi.hoisted(() => ({
getActiveForIssue: vi.fn(async () => null),
listActiveForIssues: vi.fn(async () => new Map()),
resolveActiveForIssue: vi.fn(async () => null),
}));
const mockTaskWatchdogService = vi.hoisted(() => ({
getActiveForIssue: vi.fn(async () => null),
revalidateMutationScope: vi.fn(async () => ({
allowed: true,
classification: { state: "stopped", stopFingerprint: "task_watchdog_stop:test" },
})),
reconcileForIssueAndAncestors: vi.fn(async () => ({
checked: 0,
triggered: 0,
skipped: 0,
watchdogIssueIds: [],
})),
upsertForIssue: vi.fn(),
disableForIssue: vi.fn(async () => null),
}));
const mockHeartbeatService = vi.hoisted(() => ({
wakeup: vi.fn(async () => undefined),
reportRunActivity: vi.fn(async () => undefined),
@@ -141,7 +163,7 @@ function registerRouteMocks() {
})),
listCompanyIds: vi.fn(async () => [companyId]),
}),
issueApprovalService: () => ({}),
issueApprovalService: () => mockIssueApprovalService,
issueRecoveryActionService: () => mockIssueRecoveryActionService,
issueReferenceService: () => ({
deleteDocumentSource: async () => undefined,
@@ -158,6 +180,7 @@ function registerRouteMocks() {
}),
issueService: () => mockIssueService,
issueThreadInteractionService: () => mockIssueThreadInteractionService,
taskWatchdogService: () => mockTaskWatchdogService,
logActivity: vi.fn(async () => undefined),
projectService: () => ({}),
routineService: () => ({
@@ -344,6 +367,7 @@ describe("agent issue mutation checkout ownership", () => {
mockIssueService.assertCheckoutOwner.mockReset();
mockIssueService.create.mockReset();
mockIssueService.createChild.mockReset();
mockIssueService.decomposeAcceptedPlan.mockReset();
mockIssueService.getAttachmentById.mockReset();
mockIssueService.getByIdentifier.mockReset();
mockIssueService.getById.mockReset();
@@ -385,6 +409,23 @@ describe("agent issue mutation checkout ownership", () => {
createdAt: new Date("2026-05-13T17:55:00.000Z"),
updatedAt: new Date("2026-05-13T18:05:00.000Z"),
});
mockTaskWatchdogService.getActiveForIssue.mockReset();
mockTaskWatchdogService.getActiveForIssue.mockResolvedValue(null);
mockTaskWatchdogService.revalidateMutationScope.mockReset();
mockTaskWatchdogService.revalidateMutationScope.mockResolvedValue({
allowed: true,
classification: { state: "stopped", stopFingerprint: "task_watchdog_stop:test" },
});
mockTaskWatchdogService.reconcileForIssueAndAncestors.mockReset();
mockTaskWatchdogService.reconcileForIssueAndAncestors.mockResolvedValue({
checked: 0,
triggered: 0,
skipped: 0,
watchdogIssueIds: [],
});
mockTaskWatchdogService.upsertForIssue.mockReset();
mockTaskWatchdogService.disableForIssue.mockReset();
mockTaskWatchdogService.disableForIssue.mockResolvedValue(null);
mockHeartbeatService.wakeup.mockReset();
mockHeartbeatService.wakeup.mockResolvedValue(undefined);
mockHeartbeatService.reportRunActivity.mockReset();
@@ -395,6 +436,12 @@ describe("agent issue mutation checkout ownership", () => {
mockHeartbeatService.getActiveRunForAgent.mockResolvedValue(null);
mockHeartbeatService.cancelRun.mockReset();
mockHeartbeatService.cancelRun.mockResolvedValue(null);
mockIssueApprovalService.link.mockReset();
mockIssueApprovalService.unlink.mockReset();
mockIssueApprovalService.listApprovalsForIssue.mockReset();
mockIssueApprovalService.listApprovalsForIssue.mockResolvedValue([]);
mockIssueThreadInteractionService.listForIssue.mockReset();
mockIssueThreadInteractionService.listForIssue.mockResolvedValue([]);
mockIssueService.remove.mockReset();
mockIssueService.removeAttachment.mockReset();
mockIssueService.update.mockReset();
@@ -447,6 +494,27 @@ describe("agent issue mutation checkout ownership", () => {
},
parentBlockerAdded: false,
}));
mockIssueService.decomposeAcceptedPlan.mockImplementation(async (_sourceIssueId: string, input: Record<string, unknown>) => {
const children = input.children as Record<string, unknown>[];
return {
decomposition: {
id: "decomposition-1",
status: "completed",
childIssueIds: children.map((child) => child.id),
},
childIssueIds: children.map((child) => child.id),
newlyCreatedIssues: children.map((child) => ({
...makeIssue({
id: child.id,
parentId: issueId,
status: child.status,
assigneeAgentId: child.assigneeAgentId ?? null,
}),
...child,
companyId,
})),
};
});
mockIssueService.getRelationSummaries.mockResolvedValue({ blockedBy: [], blocks: [] });
mockIssueService.listWakeableBlockedDependents.mockResolvedValue([]);
mockIssueService.getWakeableParentAfterChildCompletion.mockResolvedValue(null);
@@ -727,9 +795,18 @@ describe("agent issue mutation checkout ownership", () => {
provider: "test",
title: "Artifact",
}),
"Cheap status-only recovery runs cannot update issue documents",
],
[
"work product update",
(app: express.Express) => request(app).patch("/api/work-products/product-1").send({ title: "Blocked" }),
"Cheap status-only recovery runs cannot update issue documents",
],
[
"work product delete",
(app: express.Express) => request(app).delete("/api/work-products/product-1"),
"Cheap status-only recovery runs cannot update issue documents",
],
["work product update", (app: express.Express) => request(app).patch("/api/work-products/product-1").send({ title: "Blocked" })],
["work product delete", (app: express.Express) => request(app).delete("/api/work-products/product-1")],
[
"low-trust promotion",
(app: express.Express) =>
@@ -739,6 +816,7 @@ describe("agent issue mutation checkout ownership", () => {
title: "Promoted artifact",
summary: "Sanitized output",
}),
"Cheap status-only recovery runs cannot update issue documents",
],
[
"attachment upload",
@@ -746,9 +824,28 @@ describe("agent issue mutation checkout ownership", () => {
request(app)
.post(`/api/companies/${companyId}/issues/${issueId}/attachments`)
.attach("file", Buffer.from("report"), { filename: "report.txt", contentType: "text/plain" }),
"Cheap status-only recovery runs cannot update issue documents",
],
["attachment delete", (app: express.Express) => request(app).delete("/api/attachments/attachment-1")],
])("blocks cheap status-only recovery runs from %s", async (_name, sendRequest) => {
[
"attachment delete",
(app: express.Express) => request(app).delete("/api/attachments/attachment-1"),
"Cheap status-only recovery runs cannot update issue documents",
],
[
"issue approval link",
(app: express.Express) =>
request(app).post(`/api/issues/${issueId}/approvals`).send({
approvalId: "88888888-8888-4888-8888-888888888888",
}),
"Cheap status-only recovery runs cannot create or modify approvals",
],
[
"issue approval unlink",
(app: express.Express) =>
request(app).delete(`/api/issues/${issueId}/approvals/88888888-8888-4888-8888-888888888888`),
"Cheap status-only recovery runs cannot create or modify approvals",
],
])("blocks cheap status-only recovery runs from %s", async (_name, sendRequest, expectedError) => {
const app = await createApp(
ownerActor(),
createRunContextDb({
@@ -763,7 +860,7 @@ describe("agent issue mutation checkout ownership", () => {
const res = await sendRequest(app);
expect(res.status, JSON.stringify(res.body)).toBe(403);
expect(res.body.error).toContain("Cheap status-only recovery runs cannot update issue documents");
expect(res.body.error).toContain(expectedError);
expect(mockIssueService.assertCheckoutOwner).toHaveBeenCalledWith(issueId, ownerAgentId, ownerRunId);
expect(mockWorkProductService.createForIssue).not.toHaveBeenCalled();
expect(mockWorkProductService.update).not.toHaveBeenCalled();
@@ -771,6 +868,8 @@ describe("agent issue mutation checkout ownership", () => {
expect(mockStorageService.putFile).not.toHaveBeenCalled();
expect(mockStorageService.deleteObject).not.toHaveBeenCalled();
expect(mockIssueService.removeAttachment).not.toHaveBeenCalled();
expect(mockIssueApprovalService.link).not.toHaveBeenCalled();
expect(mockIssueApprovalService.unlink).not.toHaveBeenCalled();
});
it.each([
@@ -1147,4 +1246,363 @@ describe("agent issue mutation checkout ownership", () => {
}));
expect(mockIssueService.update).not.toHaveBeenCalled();
});
describe("task watchdog scope grants", () => {
const watchdogRunId = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaab";
const watchdogReportIssueId = "cccccccc-cccc-4ccc-8ccc-cccccccccccd";
// The watchdog agent (peerAgentId) is NOT the assignee of the watched issue
// (ownerAgentId), so the base authorization boundary (issue:mutate) denies.
// The watchdog scope must grant the mutation regardless.
function watchdogActor(runId: string = watchdogRunId) {
return {
type: "agent",
agentId: peerAgentId,
companyId,
source: "agent_key",
runId,
};
}
function createWatchdogDb(options: {
watchedIssueId?: string;
watchdogIssueId?: string | null;
ancestryParentId?: string | null;
watchdogRows?: Record<string, unknown>[];
} = {}) {
const watchedIssueId = options.watchedIssueId ?? issueId;
const runRows = [{
id: watchdogRunId,
companyId,
agentId: peerAgentId,
contextSnapshot: { taskWatchdog: { watchedIssueId, stopFingerprint: "task_watchdog_stop:test" } },
}];
const watchdogRows = options.watchdogRows ?? [{
id: "dddddddd-dddd-4ddd-8ddd-ddddddddddde",
companyId,
issueId: watchedIssueId,
watchdogAgentId: peerAgentId,
watchdogIssueId: options.watchdogIssueId ?? watchdogReportIssueId,
status: "active",
}];
const ancestryRows = [{
id: "ancestry",
companyId,
parentId: options.ancestryParentId ?? null,
}];
const rowsForSelection = (selection: Record<string, unknown>) => {
const keys = Object.keys(selection);
if (keys.includes("entityId")) return [];
if (keys.includes("contextSnapshot")) return runRows;
if (keys.includes("watchdogAgentId")) return watchdogRows;
if (keys.includes("parentId")) return ancestryRows;
if (keys.includes("status")) return [];
if (keys.includes("agentCompanyId")) return runRows;
return [{ id: peerAgentId, companyId, permissions: {}, role: "engineer", reportsTo: null }];
};
const buildQuery = (selection: Record<string, unknown>) => {
const whereResult = {
orderBy: vi.fn(async () => []),
then: async (resolve: (rows: unknown[]) => unknown) => resolve(rowsForSelection(selection)),
};
const query = {
innerJoin: vi.fn(() => query),
where: vi.fn(() => whereResult),
};
return query;
};
return {
transaction: async (callback: (tx: Record<string, never>) => Promise<unknown>) => callback({}),
select: vi.fn((selection: Record<string, unknown> = {}) => ({
from: vi.fn(() => buildQuery(selection)),
})),
};
}
// The base boundary always denies a cross-agent issue:mutate; only the
// watchdog scope can widen access. Denying it here proves the grant works.
function denyBaseBoundary() {
mockAccessService.decide.mockImplementation(async (input: { action: string }) => ({
allowed: input.action === "company_scope:read" || input.action === "issue:read" || input.action === "tasks:assign",
action: input.action,
reason:
input.action === "company_scope:read" || input.action === "issue:read" || input.action === "tasks:assign"
? "allow_explicit_grant"
: "deny_missing_grant",
explanation: "Watchdog test boundary default.",
}));
}
it("lets a watchdog run comment on a watched issue assigned to a different agent", async () => {
denyBaseBoundary();
mockIssueService.getById.mockResolvedValue(makeIssue({ assigneeAgentId: ownerAgentId }));
const app = await createApp(watchdogActor(), createWatchdogDb());
const res = await request(app).post(`/api/issues/${issueId}/comments`).send({ body: "Watchdog finding" });
expect(res.status, JSON.stringify(res.body)).toBe(201);
expect(mockIssueService.addComment).toHaveBeenCalledWith(
issueId,
"Watchdog finding",
expect.any(Object),
expect.any(Object),
);
});
it.each([
["in_progress"],
["blocked"],
["todo"],
])("lets a watchdog run transition a watched issue to %s", async (status) => {
denyBaseBoundary();
mockIssueService.getById.mockResolvedValue(makeIssue({ status: "in_progress", assigneeAgentId: ownerAgentId }));
mockIssueService.update.mockImplementation(async (_id: string, patch: Record<string, unknown>) => ({
...makeIssue({ assigneeAgentId: ownerAgentId }),
...patch,
}));
const app = await createApp(watchdogActor(), createWatchdogDb());
const res = await request(app).patch(`/api/issues/${issueId}`).send({ status });
expect(res.status, JSON.stringify(res.body)).toBe(200);
expect(mockIssueService.update).toHaveBeenCalledWith(issueId, expect.objectContaining({ status }));
});
it("lets a watchdog run transition a watched issue to in_review with a live review path", async () => {
denyBaseBoundary();
mockIssueService.getById.mockResolvedValue(makeIssue({ status: "in_progress", assigneeAgentId: ownerAgentId }));
mockIssueService.update.mockImplementation(async (_id: string, patch: Record<string, unknown>) => ({
...makeIssue({ assigneeAgentId: ownerAgentId }),
...patch,
}));
// A pending interaction is a valid review path, so the agent in_review guard
// is satisfied — this isolates the test to the watchdog boundary grant.
mockIssueThreadInteractionService.listForIssue.mockResolvedValue([{ status: "pending" }] as never);
const app = await createApp(watchdogActor(), createWatchdogDb());
const res = await request(app).patch(`/api/issues/${issueId}`).send({ status: "in_review" });
expect(res.status, JSON.stringify(res.body)).toBe(200);
expect(mockIssueService.update).toHaveBeenCalledWith(issueId, expect.objectContaining({ status: "in_review" }));
});
it("rejects stale watchdog source mutations when revalidation finds a live path", async () => {
denyBaseBoundary();
mockIssueService.getById.mockResolvedValue(makeIssue({ status: "in_progress", assigneeAgentId: ownerAgentId }));
mockTaskWatchdogService.revalidateMutationScope.mockResolvedValueOnce({
allowed: false,
reason:
"Task-watchdog review is stale because the watched subtree now has a live, waiting, already-reviewed, or not-applicable path; refresh the source state before mutating it.",
classification: { state: "live", liveIssueIds: [issueId] },
});
const app = await createApp(watchdogActor(), createWatchdogDb());
const res = await request(app).patch(`/api/issues/${issueId}`).send({ status: "blocked" });
expect(res.status, JSON.stringify(res.body)).toBe(409);
expect(res.body.error).toContain("Task-watchdog review is stale");
expect(mockIssueService.update).not.toHaveBeenCalled();
});
it("suppresses watchdog follow-up creation when current source revalidation is live", async () => {
denyBaseBoundary();
mockIssueService.getById.mockResolvedValue(makeIssue({ assigneeAgentId: ownerAgentId }));
mockTaskWatchdogService.revalidateMutationScope.mockResolvedValueOnce({
allowed: false,
reason:
"Task-watchdog review is stale because the watched subtree now has a live, waiting, already-reviewed, or not-applicable path; refresh the source state before mutating it.",
classification: { state: "live", liveIssueIds: [issueId] },
});
const app = await createApp(watchdogActor(), createWatchdogDb());
const res = await request(app)
.post(`/api/issues/${issueId}/children`)
.send({ title: "Stale follow-up", status: "todo" });
expect(res.status, JSON.stringify(res.body)).toBe(409);
expect(res.body.error).toContain("Task-watchdog review is stale");
expect(mockIssueService.createChild).not.toHaveBeenCalled();
});
it("serializes watchdog accepted-plan follow-ups behind one active child lane", async () => {
denyBaseBoundary();
mockIssueService.list.mockResolvedValue([]);
mockAgentService.resolveByReference.mockImplementation(async (_companyId: string, reference: string) => ({
ambiguous: false,
agent: reference === ownerAgentId ? makeAgent(ownerAgentId) : null,
}));
mockIssueService.getById.mockImplementation(async (id: string) => {
if (id === watchdogReportIssueId) {
return makeIssue({
id: watchdogReportIssueId,
originKind: "task_watchdog",
status: "in_progress",
assigneeAgentId: peerAgentId,
});
}
return makeIssue({ assigneeAgentId: ownerAgentId });
});
const app = await createApp(watchdogActor(), createWatchdogDb());
const res = await request(app)
.post(`/api/issues/${issueId}/accepted-plan-decompositions`)
.send({
acceptedPlanRevisionId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
children: [
{ title: "Fix watchdog authorization", assigneeAgentId: ownerAgentId },
{ title: "Fix watchdog startup race", assigneeAgentId: ownerAgentId },
],
});
expect(res.status, JSON.stringify(res.body)).toBe(200);
const decompositionInput = mockIssueService.decomposeAcceptedPlan.mock.calls[0]?.[1];
const children = decompositionInput.children as Array<Record<string, unknown>>;
expect(children).toHaveLength(2);
expect(children[0]).toEqual(expect.objectContaining({
title: "Fix watchdog authorization",
status: "todo",
assigneeAgentId: ownerAgentId,
}));
expect(children[1]).toEqual(expect.objectContaining({
title: "Fix watchdog startup race",
status: "blocked",
assigneeAgentId: ownerAgentId,
blockedByIssueIds: [children[0]?.id],
}));
expect(mockHeartbeatService.wakeup).toHaveBeenCalledTimes(1);
expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(
ownerAgentId,
expect.objectContaining({
payload: expect.objectContaining({ issueId: children[0]?.id }),
}),
);
expect(mockIssueService.update).toHaveBeenCalledWith(
watchdogReportIssueId,
expect.objectContaining({
status: "blocked",
blockedByIssueIds: [children[0]?.id],
actorAgentId: peerAgentId,
}),
);
});
it("preserves normal accepted-plan decomposition parallel wakeups outside watchdog context", async () => {
mockAgentService.resolveByReference.mockImplementation(async (_companyId: string, reference: string) => ({
ambiguous: false,
agent: reference === ownerAgentId ? makeAgent(ownerAgentId) : null,
}));
const app = await createApp(ownerActor());
const res = await request(app)
.post(`/api/issues/${issueId}/accepted-plan-decompositions`)
.send({
acceptedPlanRevisionId: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
children: [
{ title: "Implement backend", assigneeAgentId: ownerAgentId },
{ title: "Implement frontend", assigneeAgentId: ownerAgentId },
],
});
expect(res.status, JSON.stringify(res.body)).toBe(200);
const decompositionInput = mockIssueService.decomposeAcceptedPlan.mock.calls[0]?.[1];
const children = decompositionInput.children as Array<Record<string, unknown>>;
expect(children).toHaveLength(2);
expect(children[0]).toEqual(expect.objectContaining({ status: "todo" }));
expect(children[1]).toEqual(expect.objectContaining({ status: "todo" }));
expect(children[1]?.blockedByIssueIds).toBeUndefined();
expect(mockHeartbeatService.wakeup).toHaveBeenCalledTimes(2);
expect(mockHeartbeatService.wakeup).toHaveBeenNthCalledWith(
1,
ownerAgentId,
expect.objectContaining({
payload: expect.objectContaining({ issueId: children[0]?.id }),
}),
);
expect(mockHeartbeatService.wakeup).toHaveBeenNthCalledWith(
2,
ownerAgentId,
expect.objectContaining({
payload: expect.objectContaining({ issueId: children[1]?.id }),
}),
);
expect(mockIssueService.update).not.toHaveBeenCalledWith(
watchdogReportIssueId,
expect.anything(),
);
});
it("lets a watchdog run reassign a watched issue to an active same-company agent", async () => {
denyBaseBoundary();
mockIssueService.getById.mockResolvedValue(makeIssue({ assigneeAgentId: ownerAgentId }));
mockIssueService.update.mockImplementation(async (_id: string, patch: Record<string, unknown>) => ({
...makeIssue({ assigneeAgentId: ownerAgentId }),
...patch,
}));
mockAgentService.resolveByReference.mockResolvedValue({ ambiguous: false, agent: makeAgent(peerAgentId) });
const app = await createApp(watchdogActor(), createWatchdogDb());
const res = await request(app).patch(`/api/issues/${issueId}`).send({ assigneeAgentId: peerAgentId });
expect(res.status, JSON.stringify(res.body)).toBe(200);
expect(mockIssueService.update).toHaveBeenCalledWith(
issueId,
expect.objectContaining({ assigneeAgentId: peerAgentId }),
);
});
it("still denies a watchdog run mutating an issue outside the watched subtree", async () => {
denyBaseBoundary();
mockIssueService.getById.mockResolvedValue(makeIssue({ assigneeAgentId: ownerAgentId }));
// The watched issue is a different issue, and the target's ancestry chain
// (parentId === null) never reaches it, so it is outside the subtree.
const outsideWatched = "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeef";
const app = await createApp(
watchdogActor(),
createWatchdogDb({ watchedIssueId: outsideWatched, ancestryParentId: null }),
);
const res = await request(app).patch(`/api/issues/${issueId}`).send({ status: "blocked" });
expect(res.status, JSON.stringify(res.body)).toBe(403);
expect(res.body.error).toBe("Task-watchdog runs can only mutate the watched issue subtree.");
expect(mockIssueService.update).not.toHaveBeenCalled();
});
it("still enforces normal assignment guards for watchdog reassignment", async () => {
// Base boundary denied AND tasks:assign denied: the watchdog grant lets the
// mutation past the ownership boundary, but the assignment guard must still bite.
mockAccessService.decide.mockImplementation(async (input: { action: string }) => ({
allowed: input.action === "company_scope:read",
action: input.action,
reason: input.action === "company_scope:read" ? "allow_explicit_grant" : "deny_policy_restricted",
explanation:
input.action === "tasks:assign"
? "Target agent requires approval before task assignment."
: "Watchdog test boundary default.",
}));
mockIssueService.getById.mockResolvedValue(makeIssue({ assigneeAgentId: ownerAgentId }));
mockAgentService.resolveByReference.mockResolvedValue({ ambiguous: false, agent: makeAgent(peerAgentId) });
const app = await createApp(watchdogActor(), createWatchdogDb());
const res = await request(app).patch(`/api/issues/${issueId}`).send({ assigneeAgentId: peerAgentId });
expect(res.status, JSON.stringify(res.body)).toBe(403);
expect(res.body.error).toContain("requires approval");
expect(mockIssueService.update).not.toHaveBeenCalled();
});
it("denies an invalid watchdog run context even when the base boundary would allow it", async () => {
// Run context claims a watched issue, but no active persisted watchdog backs it.
const app = await createApp(
watchdogActor(),
createWatchdogDb({ watchdogRows: [] }),
);
mockIssueService.getById.mockResolvedValue(makeIssue({ assigneeAgentId: peerAgentId }));
const res = await request(app).patch(`/api/issues/${issueId}`).send({ status: "blocked" });
expect(res.status, JSON.stringify(res.body)).toBe(403);
expect(res.body.error).toBe("Task-watchdog run context is not backed by an active persisted watchdog.");
expect(mockIssueService.update).not.toHaveBeenCalled();
});
});
});
@@ -112,6 +112,11 @@ function registerModuleMocks() {
}),
issueService: () => mockIssueService,
issueThreadInteractionService: () => mockInteractionService,
taskWatchdogService: () => ({
getActiveForIssue: vi.fn(async () => null),
upsertForIssue: vi.fn(),
disableForIssue: vi.fn(async () => null),
}),
logActivity: mockLogActivity,
projectService: () => ({}),
routineService: () => ({
@@ -0,0 +1,632 @@
import { randomUUID } from "node:crypto";
import express from "express";
import request from "supertest";
import { and, eq } from "drizzle-orm";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import {
activityLog,
agentWakeupRequests,
agents,
companies,
companyMemberships,
createDb,
heartbeatRunEvents,
heartbeatRuns,
issueComments,
issueRelations,
issueWatchdogs,
issues,
principalPermissionGrants,
} from "@paperclipai/db";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
import { errorHandler } from "../middleware/index.js";
import { issueRoutes } from "../routes/issues.js";
import { ensureHumanRoleDefaultGrants } from "../services/principal-access-compatibility.js";
import { taskWatchdogService } from "../services/task-watchdogs.js";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
if (!embeddedPostgresSupport.supported) {
console.warn(
`Skipping embedded Postgres issue watchdog route tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
);
}
describeEmbeddedPostgres("issue watchdog routes", () => {
let db!: ReturnType<typeof createDb>;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
beforeAll(async () => {
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-issue-watchdogs-routes-");
db = createDb(tempDb.connectionString);
}, 20_000);
afterEach(async () => {
await db.delete(activityLog);
await db.delete(issueComments);
await db.delete(heartbeatRunEvents);
await db.delete(heartbeatRuns);
await db.delete(agentWakeupRequests);
await db.delete(issueRelations);
await db.delete(issueWatchdogs);
await db.delete(issues);
await db.delete(agents);
await db.delete(principalPermissionGrants);
await db.delete(companyMemberships);
await db.delete(companies);
});
afterAll(async () => {
await tempDb?.cleanup();
});
function createApp(companyId: string, actor?: Record<string, unknown>) {
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
(req as any).actor = actor ?? {
type: "board",
userId: "cloud-user-1",
companyIds: [companyId],
memberships: [{ companyId, membershipRole: "owner", status: "active" }],
source: "cloud_tenant",
isInstanceAdmin: false,
};
next();
});
app.use("/api", issueRoutes(db, {} as any, { taskWatchdogEnqueueWakeup: null }));
app.use(errorHandler);
return app;
}
function uniqueIssuePrefix() {
return `W${randomUUID().replace(/-/g, "").slice(0, 5).toUpperCase()}`;
}
async function seedCloudTenantMember(companyId: string) {
await db.insert(companyMemberships).values({
companyId,
principalType: "user",
principalId: "cloud-user-1",
status: "active",
membershipRole: "owner",
updatedAt: new Date(),
});
await ensureHumanRoleDefaultGrants(db, {
companyId,
principalId: "cloud-user-1",
membershipRole: "owner",
grantedByUserId: null,
});
}
async function seedCompany(name = "Paperclip") {
const companyId = randomUUID();
await db.insert(companies).values({
id: companyId,
name,
issuePrefix: uniqueIssuePrefix(),
requireBoardApprovalForNewAgents: false,
});
await seedCloudTenantMember(companyId);
return companyId;
}
async function seedAgent(companyId: string, overrides: Partial<typeof agents.$inferInsert> = {}) {
const id = overrides.id ?? randomUUID();
await db.insert(agents).values({
id,
companyId,
name: overrides.name ?? "Watchdog Agent",
role: overrides.role ?? "engineer",
status: overrides.status ?? "active",
adapterType: overrides.adapterType ?? "codex_local",
adapterConfig: overrides.adapterConfig ?? {},
runtimeConfig: overrides.runtimeConfig ?? {},
permissions: overrides.permissions ?? {},
reportsTo: overrides.reportsTo,
});
return id;
}
async function seedIssue(companyId: string, overrides: Partial<typeof issues.$inferInsert> = {}) {
const id = overrides.id ?? randomUUID();
await db.insert(issues).values({
id,
companyId,
title: overrides.title ?? "Watched task",
status: overrides.status ?? "todo",
priority: overrides.priority ?? "medium",
identifier: overrides.identifier,
issueNumber: overrides.issueNumber,
assigneeAgentId: overrides.assigneeAgentId,
parentId: overrides.parentId,
projectId: overrides.projectId,
goalId: overrides.goalId,
originKind: overrides.originKind,
originId: overrides.originId,
// Default to an "established" issue (created before the first-run grace
// window) so attaching a watchdog evaluates immediately instead of being
// deferred by the pending-first-run guard.
createdAt: overrides.createdAt ?? new Date(Date.now() - 60 * 60 * 1000),
});
return id;
}
async function seedWatchdogRun(input: {
companyId: string;
watchdogAgentId: string;
watchedIssueId: string;
watchdogIssueId: string;
}) {
await db.insert(issueWatchdogs).values({
companyId: input.companyId,
issueId: input.watchedIssueId,
watchdogAgentId: input.watchdogAgentId,
watchdogIssueId: input.watchdogIssueId,
status: "active",
});
await taskWatchdogService(db).reconcileTaskWatchdogs({ companyId: input.companyId });
const [watchdog] = await db
.select({ lastObservedFingerprint: issueWatchdogs.lastObservedFingerprint })
.from(issueWatchdogs)
.where(and(eq(issueWatchdogs.companyId, input.companyId), eq(issueWatchdogs.issueId, input.watchedIssueId)));
const runId = randomUUID();
await db.insert(heartbeatRuns).values({
id: runId,
companyId: input.companyId,
agentId: input.watchdogAgentId,
status: "running",
contextSnapshot: {
issueId: input.watchdogIssueId,
taskWatchdog: {
watchedIssueId: input.watchedIssueId,
watchedIssueIdentifier: "WDOG-ROOT",
watchedIssueTitle: "Watched root",
stopFingerprint: watchdog?.lastObservedFingerprint,
},
},
});
return runId;
}
async function waitForAssignmentWakeup(companyId: string) {
for (let attempt = 0; attempt < 20; attempt += 1) {
const rows = await db
.select({ id: heartbeatRuns.id })
.from(heartbeatRuns)
.where(eq(heartbeatRuns.companyId, companyId))
.limit(1);
if (rows.length > 0) return;
await new Promise((resolve) => setTimeout(resolve, 10));
}
}
it("creates, updates, reads, lists, and removes an issue watchdog with activity logs", async () => {
const companyId = await seedCompany();
const issueId = await seedIssue(companyId, { identifier: "WDOG-1", issueNumber: 1 });
const firstAgentId = await seedAgent(companyId, { name: "First Watchdog" });
const secondAgentId = await seedAgent(companyId, { name: "Second Watchdog" });
const app = createApp(companyId);
const created = await request(app)
.put(`/api/issues/${issueId}/watchdog`)
.send({ agentId: firstAgentId, instructions: "Check screenshots and tests." });
expect(created.status, JSON.stringify(created.body)).toBe(200);
expect(created.body).toMatchObject({
issueId,
watchdogAgentId: firstAgentId,
instructions: "Check screenshots and tests.",
status: "active",
});
const updated = await request(app)
.put(`/api/issues/${issueId}/watchdog`)
.send({ agentId: secondAgentId, instructions: "Be skeptical." });
expect(updated.status, JSON.stringify(updated.body)).toBe(200);
expect(updated.body.id).toBe(created.body.id);
expect(updated.body).toMatchObject({
issueId,
watchdogAgentId: secondAgentId,
instructions: "Be skeptical.",
status: "active",
});
const read = await request(app).get(`/api/issues/${issueId}/watchdog`);
expect(read.status, JSON.stringify(read.body)).toBe(200);
expect(read.body).toMatchObject({ id: created.body.id, watchdogAgentId: secondAgentId });
const detail = await request(app).get(`/api/issues/${issueId}`);
expect(detail.status, JSON.stringify(detail.body)).toBe(200);
expect(detail.body.watchdog).toMatchObject({ id: created.body.id, watchdogAgentId: secondAgentId });
const list = await request(app).get(`/api/companies/${companyId}/issues`);
expect(list.status, JSON.stringify(list.body)).toBe(200);
expect(list.body.find((issue: { id: string }) => issue.id === issueId)?.watchdog)
.toMatchObject({ id: created.body.id, watchdogAgentId: secondAgentId });
const removed = await request(app).delete(`/api/issues/${issueId}/watchdog`);
expect(removed.status, JSON.stringify(removed.body)).toBe(200);
expect(removed.body).toEqual({ ok: true });
const afterDelete = await request(app).get(`/api/issues/${issueId}/watchdog`);
expect(afterDelete.status, JSON.stringify(afterDelete.body)).toBe(200);
expect(afterDelete.body).toBeNull();
const stored = await db
.select()
.from(issueWatchdogs)
.where(and(eq(issueWatchdogs.companyId, companyId), eq(issueWatchdogs.issueId, issueId)))
.then((rows) => rows[0] ?? null);
expect(stored).toMatchObject({
id: created.body.id,
status: "disabled",
watchdogAgentId: secondAgentId,
});
const actions = await db
.select({ action: activityLog.action })
.from(activityLog)
.where(eq(activityLog.entityId, issueId));
const actionNames = actions.map((row) => row.action);
expect(actionNames.filter((action) => action.startsWith("issue.watchdog_"))).toEqual([
"issue.watchdog_created",
"issue.watchdog_updated",
"issue.watchdog_removed",
]);
expect(actionNames).toContain("issue.task_watchdog_triggered");
});
it("handles concurrent first-time watchdog upserts without duplicate-key failures", async () => {
const companyId = await seedCompany();
const issueId = await seedIssue(companyId, { identifier: "WDOG-RACE", issueNumber: 99 });
const agentId = await seedAgent(companyId, { name: "Race Watchdog" });
const app = createApp(companyId);
const responses = await Promise.all(
Array.from({ length: 12 }, (_, index) =>
request(app)
.put(`/api/issues/${issueId}/watchdog`)
.send({ agentId, instructions: `Concurrent instructions ${index}` }),
),
);
expect(responses.map((res) => res.status), JSON.stringify(responses.map((res) => res.body)))
.toEqual(Array(12).fill(200));
const stored = await db
.select()
.from(issueWatchdogs)
.where(and(eq(issueWatchdogs.companyId, companyId), eq(issueWatchdogs.issueId, issueId)));
expect(stored).toHaveLength(1);
expect(stored[0]).toMatchObject({ status: "active", watchdogAgentId: agentId });
});
it("creates an issue and watchdog atomically from the create issue route", async () => {
const companyId = await seedCompany();
const agentId = await seedAgent(companyId);
const app = createApp(companyId);
const res = await request(app)
.post(`/api/companies/${companyId}/issues`)
.send({
title: "Create with watchdog",
watchdog: {
agentId,
instructions: "Confirm the final state.",
},
});
expect(res.status, JSON.stringify(res.body)).toBe(201);
expect(res.body.watchdog).toMatchObject({
issueId: res.body.id,
watchdogAgentId: agentId,
instructions: "Confirm the final state.",
status: "active",
});
const rows = await db
.select()
.from(issueWatchdogs)
.where(eq(issueWatchdogs.issueId, res.body.id));
expect(rows).toHaveLength(1);
const activityRows = await db
.select({ action: activityLog.action })
.from(activityLog)
.where(eq(activityLog.entityId, res.body.id));
expect(activityRows.map((row) => row.action)).toContain("issue.watchdog_created");
});
it("does not create an immediate watchdog review for a newly assigned issue", async () => {
const companyId = await seedCompany();
const workerAgentId = await seedAgent(companyId, { name: "Worker" });
const watchdogAgentId = await seedAgent(companyId, { name: "Watchdog" });
const app = createApp(companyId);
const res = await request(app)
.post(`/api/companies/${companyId}/issues`)
.send({
title: "Assigned issue with watchdog",
assigneeAgentId: workerAgentId,
watchdog: {
agentId: watchdogAgentId,
instructions: "Confirm whether the worker got started.",
},
});
expect(res.status, JSON.stringify(res.body)).toBe(201);
await waitForAssignmentWakeup(companyId);
expect(res.body).toMatchObject({
assigneeAgentId: workerAgentId,
watchdog: {
issueId: res.body.id,
watchdogAgentId,
status: "active",
},
});
const watchdogReviewIssues = await db
.select()
.from(issues)
.where(and(eq(issues.companyId, companyId), eq(issues.originKind, "task_watchdog")));
expect(watchdogReviewIssues).toHaveLength(0);
const [watchdog] = await db
.select()
.from(issueWatchdogs)
.where(and(eq(issueWatchdogs.companyId, companyId), eq(issueWatchdogs.issueId, res.body.id)));
expect(watchdog?.triggerCount).toBe(0);
const taskWatchdogActivity = await db
.select({ action: activityLog.action })
.from(activityLog)
.where(and(eq(activityLog.entityId, res.body.id), eq(activityLog.action, "issue.task_watchdog_triggered")));
expect(taskWatchdogActivity).toHaveLength(0);
});
it("enforces persisted watchdog scope for issue mutations and child creation", async () => {
const companyId = await seedCompany();
const watchdogAgentId = await seedAgent(companyId, { name: "Scoped Watchdog" });
const watchedRootId = await seedIssue(companyId, { title: "Watched root", identifier: "WDOG-ROOT" });
const watchedChildId = await seedIssue(companyId, { title: "Watched child", parentId: watchedRootId });
const unrelatedRootId = await seedIssue(companyId, { title: "Unrelated root" });
const watchdogIssueId = await seedIssue(companyId, {
title: "Reusable watchdog issue",
parentId: watchedRootId,
assigneeAgentId: watchdogAgentId,
originKind: "task_watchdog",
originId: watchedRootId,
});
const watchdogIssueChildId = await seedIssue(companyId, {
title: "Watchdog issue child",
parentId: watchdogIssueId,
});
const runId = await seedWatchdogRun({
companyId,
watchdogAgentId,
watchedIssueId: watchedRootId,
watchdogIssueId,
});
const app = createApp(companyId, {
type: "agent",
agentId: watchdogAgentId,
companyId,
runId,
source: "agent_jwt",
});
const watchdogIssuePatch = await request(app)
.patch(`/api/issues/${watchdogIssueId}`)
.send({ title: "Reusable watchdog issue completed" });
expect(watchdogIssuePatch.status, JSON.stringify(watchdogIssuePatch.body)).toBe(200);
const deniedWatchdogDescendantPatch = await request(app)
.patch(`/api/issues/${watchdogIssueChildId}`)
.send({ title: "Denied watchdog descendant mutation" });
expect(deniedWatchdogDescendantPatch.status, JSON.stringify(deniedWatchdogDescendantPatch.body)).toBe(403);
expect(deniedWatchdogDescendantPatch.body.error).toBe(
"Task-watchdog runs can only mutate the watched issue subtree.",
);
const deniedPatch = await request(app)
.patch(`/api/issues/${unrelatedRootId}`)
.send({ title: "Out-of-scope mutation" });
expect(deniedPatch.status, JSON.stringify(deniedPatch.body)).toBe(403);
expect(deniedPatch.body.error).toBe("Task-watchdog runs can only mutate the watched issue subtree.");
const deniedChild = await request(app)
.post(`/api/issues/${unrelatedRootId}/children`)
.send({ title: "Denied unrelated child" });
expect(deniedChild.status, JSON.stringify(deniedChild.body)).toBe(403);
expect(deniedChild.body.error).toBe("Task-watchdog runs can only mutate the watched issue subtree.");
const deniedWatchdogIssueChild = await request(app)
.post(`/api/issues/${watchdogIssueId}/children`)
.send({ title: "Denied watchdog issue child" });
expect(deniedWatchdogIssueChild.status, JSON.stringify(deniedWatchdogIssueChild.body)).toBe(403);
const deniedVisibleProbeIssues = await db
.select({ id: issues.id })
.from(issues)
.where(and(eq(issues.companyId, companyId), eq(issues.title, "Denied watchdog issue child")));
expect(deniedVisibleProbeIssues).toHaveLength(0);
const deniedParentCreate = await request(app)
.post(`/api/companies/${companyId}/issues`)
.send({ title: "Denied parent create", parentId: unrelatedRootId });
expect(deniedParentCreate.status, JSON.stringify(deniedParentCreate.body)).toBe(403);
expect(deniedParentCreate.body.error).toBe("Task-watchdog runs can only mutate the watched issue subtree.");
const deniedNestedWatchdog = await request(app)
.put(`/api/issues/${watchedChildId}/watchdog`)
.send({ agentId: watchdogAgentId, instructions: "Create a nested watchdog" });
expect(deniedNestedWatchdog.status, JSON.stringify(deniedNestedWatchdog.body)).toBe(403);
expect(deniedNestedWatchdog.body.error).toBe("Task-watchdog runs cannot change watchdog configuration.");
const deniedWatchdogRemoval = await request(app).delete(`/api/issues/${watchedRootId}/watchdog`);
expect(deniedWatchdogRemoval.status, JSON.stringify(deniedWatchdogRemoval.body)).toBe(403);
expect(deniedWatchdogRemoval.body.error).toBe("Task-watchdog runs cannot change watchdog configuration.");
const nestedWatchdogs = await db
.select({ id: issueWatchdogs.id })
.from(issueWatchdogs)
.where(and(eq(issueWatchdogs.companyId, companyId), eq(issueWatchdogs.issueId, watchedChildId)));
expect(nestedWatchdogs).toHaveLength(0);
const allowedChild = await request(app)
.post(`/api/issues/${watchedChildId}/children`)
.send({ title: "Allowed watched child" });
expect(allowedChild.status, JSON.stringify(allowedChild.body)).toBe(201);
expect(allowedChild.body.parentId).toBe(watchedChildId);
});
it("routes watchdog-discovered product bugs outside the watched source tree with evidence links", async () => {
const companyId = await seedCompany();
const watchdogAgentId = await seedAgent(companyId, { name: "Product Bug Watchdog" });
const watchedRootId = await seedIssue(companyId, {
title: "Watched root",
identifier: "PAP-100",
issueNumber: 100,
});
const watchedChildId = await seedIssue(companyId, {
title: "Watched child",
identifier: "PAP-101",
issueNumber: 101,
parentId: watchedRootId,
});
const watchdogIssueId = await seedIssue(companyId, {
title: "Reusable watchdog issue",
identifier: "PAP-102",
issueNumber: 102,
parentId: watchedRootId,
assigneeAgentId: watchdogAgentId,
originKind: "task_watchdog",
originId: watchedRootId,
});
const runId = await seedWatchdogRun({
companyId,
watchdogAgentId,
watchedIssueId: watchedRootId,
watchdogIssueId,
});
const app = createApp(companyId, {
type: "agent",
agentId: watchdogAgentId,
companyId,
runId,
source: "agent_jwt",
});
const res = await request(app)
.post(`/api/companies/${companyId}/issues`)
.send({
title: "Fix watchdog source-tree pollution",
description: "Watchdog found a Paperclip follow-up routing bug.",
parentId: watchedChildId,
watchdogDiscovery: {
kind: "product_bug",
evidenceMarkdown: "The watchdog would otherwise create this under the watched child.",
},
});
expect(res.status, JSON.stringify(res.body)).toBe(201);
expect(res.body).toMatchObject({
title: "Fix watchdog source-tree pollution",
parentId: null,
originKind: "task_watchdog_product_bug",
originId: watchedRootId,
originRunId: runId,
});
expect(res.body.description).toContain("## Watchdog Discovery");
expect(res.body.description).toContain("Watched source issue: [PAP-100](/PAP/issues/PAP-100)");
expect(res.body.description).toContain("Watchdog issue: [PAP-102](/PAP/issues/PAP-102)");
expect(res.body.referencedIssueIdentifiers).toEqual(expect.arrayContaining(["PAP-100", "PAP-102"]));
const watchedSourceChildren = await db
.select({ id: issues.id, title: issues.title })
.from(issues)
.where(and(eq(issues.companyId, companyId), eq(issues.parentId, watchedChildId)));
expect(watchedSourceChildren).toHaveLength(0);
const [createdActivity] = await db
.select({ details: activityLog.details })
.from(activityLog)
.where(and(eq(activityLog.companyId, companyId), eq(activityLog.entityId, res.body.id)));
expect(createdActivity?.details).toMatchObject({
watchdogDiscovery: {
kind: "product_bug",
sourceIssueId: watchedRootId,
watchdogIssueId,
},
});
});
it("rejects watchdog interaction-resolution attempts outside the persisted watched subtree", async () => {
const companyId = await seedCompany();
const watchdogAgentId = await seedAgent(companyId, { name: "Interaction Watchdog" });
const watchedRootId = await seedIssue(companyId, { title: "Watched root" });
const unrelatedRootId = await seedIssue(companyId, { title: "Unrelated root" });
const watchdogIssueId = await seedIssue(companyId, { title: "Reusable watchdog issue" });
const runId = await seedWatchdogRun({
companyId,
watchdogAgentId,
watchedIssueId: watchedRootId,
watchdogIssueId,
});
const app = createApp(companyId, {
type: "agent",
agentId: watchdogAgentId,
companyId,
runId,
source: "agent_jwt",
});
const res = await request(app)
.post(`/api/issues/${unrelatedRootId}/interactions/${randomUUID()}/accept`)
.send({});
expect(res.status, JSON.stringify(res.body)).toBe(403);
expect(res.body.error).toBe("Task-watchdog runs can only mutate the watched issue subtree.");
});
it("rejects cross-company watched issues and watchdog agents", async () => {
const companyId = await seedCompany("Allowed company");
const otherCompanyId = await seedCompany("Other company");
const issueId = await seedIssue(companyId);
const otherIssueId = await seedIssue(otherCompanyId);
const otherAgentId = await seedAgent(otherCompanyId);
const app = createApp(companyId);
const foreignIssue = await request(app)
.put(`/api/issues/${otherIssueId}/watchdog`)
.send({ agentId: otherAgentId });
expect(foreignIssue.status, JSON.stringify(foreignIssue.body)).toBe(403);
const foreignAgent = await request(app)
.put(`/api/issues/${issueId}/watchdog`)
.send({ agentId: otherAgentId });
expect(foreignAgent.status, JSON.stringify(foreignAgent.body)).toBe(404);
});
it.each(["paused", "terminated", "pending_approval"])(
"rejects %s watchdog agents",
async (status) => {
const companyId = await seedCompany();
const issueId = await seedIssue(companyId);
const agentId = await seedAgent(companyId, { status });
const app = createApp(companyId);
const res = await request(app)
.put(`/api/issues/${issueId}/watchdog`)
.send({ agentId });
expect(res.status, JSON.stringify(res.body)).toBe(409);
expect(res.body.error).toBe("Cannot assign watchdog to an agent that is not invokable");
},
);
});
@@ -0,0 +1,205 @@
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");
});
});
@@ -0,0 +1,592 @@
import { randomUUID } from "node:crypto";
import { and, eq } from "drizzle-orm";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import {
activityLog,
agentWakeupRequests,
agents,
companies,
createDb,
documents,
heartbeatRuns,
issueComments,
issueDocuments,
issueWorkProducts,
issues,
issueWatchdogs,
} from "@paperclipai/db";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
import { taskWatchdogService } from "../services/task-watchdogs.ts";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
if (!embeddedPostgresSupport.supported) {
console.warn(
`Skipping embedded Postgres task watchdog scheduler tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
);
}
describeEmbeddedPostgres("task watchdog scheduler", () => {
let db!: ReturnType<typeof createDb>;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
beforeAll(async () => {
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-task-watchdogs-scheduler-");
db = createDb(tempDb.connectionString);
}, 20_000);
afterEach(async () => {
await db.delete(activityLog);
await db.delete(issueWorkProducts);
await db.delete(issueDocuments);
await db.delete(documents);
await db.delete(issueComments);
await db.delete(heartbeatRuns);
await db.delete(agentWakeupRequests);
await db.delete(issueWatchdogs);
await db.delete(issues);
await db.delete(agents);
await db.delete(companies);
});
afterAll(async () => {
await tempDb?.cleanup();
});
async function seedCompany() {
const companyId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Watchdog Co",
issuePrefix: `WD${randomUUID().replace(/-/g, "").slice(0, 4).toUpperCase()}`,
issueCounter: 0,
requireBoardApprovalForNewAgents: false,
});
return companyId;
}
async function seedAgent(companyId: string, overrides: Partial<typeof agents.$inferInsert> = {}) {
const id = overrides.id ?? randomUUID();
await db.insert(agents).values({
id,
companyId,
name: overrides.name ?? "Watchdog Agent",
role: overrides.role ?? "engineer",
status: overrides.status ?? "active",
adapterType: overrides.adapterType ?? "codex_local",
adapterConfig: overrides.adapterConfig ?? {},
runtimeConfig: overrides.runtimeConfig ?? {},
permissions: overrides.permissions ?? {},
reportsTo: overrides.reportsTo,
});
return id;
}
async function seedIssue(companyId: string, overrides: Partial<typeof issues.$inferInsert> = {}) {
const id = overrides.id ?? randomUUID();
await db.insert(issues).values({
id,
companyId,
title: overrides.title ?? "Watched issue",
status: overrides.status ?? "done",
priority: overrides.priority ?? "medium",
identifier: overrides.identifier ?? `WDOG-${Math.floor(Math.random() * 10_000)}`,
issueNumber: overrides.issueNumber ?? Math.floor(Math.random() * 10_000),
parentId: overrides.parentId,
assigneeAgentId: overrides.assigneeAgentId,
originKind: overrides.originKind,
originId: overrides.originId,
originFingerprint: overrides.originFingerprint,
updatedAt: overrides.updatedAt,
// Default to an "established" issue (created well before the first-run
// grace window) so the pending-first-run guard does not defer it. Tests
// exercising the create-race pass an explicit recent `createdAt`.
createdAt: overrides.createdAt ?? new Date(Date.now() - 60 * 60 * 1000),
});
return id;
}
async function seedIssueDocument(companyId: string, issueId: string, updatedAt: Date) {
const [document] = await db.insert(documents).values({
companyId,
title: "Plan",
latestBody: "Plan body",
updatedAt,
}).returning();
await db.insert(issueDocuments).values({
companyId,
issueId,
documentId: document!.id,
key: "plan",
updatedAt,
});
}
async function seedIssueWorkProduct(companyId: string, issueId: string, updatedAt: Date) {
await db.insert(issueWorkProducts).values({
companyId,
issueId,
type: "artifact",
provider: "test",
title: "Report",
status: "ready",
updatedAt,
});
}
async function seedWatchdog(companyId: string, issueId: string, agentId: string) {
const [row] = await db.insert(issueWatchdogs).values({
companyId,
issueId,
watchdogAgentId: agentId,
instructions: "Verify stopped work.",
status: "active",
}).returning();
return row;
}
function createService() {
const wakes: Array<{ agentId: string; opts: Record<string, unknown> | undefined }> = [];
const service = taskWatchdogService(db, {
enqueueWakeup: async (agentId, opts) => {
wakes.push({ agentId, opts });
return { id: randomUUID() };
},
});
return { service, wakes };
}
it("creates one reusable watchdog issue and wakes the watchdog on the initial stopped state", async () => {
const companyId = await seedCompany();
const sourceId = await seedIssue(companyId, { identifier: "WDOG-1", status: "done" });
const agentId = await seedAgent(companyId);
await seedWatchdog(companyId, sourceId, agentId);
const { service, wakes } = createService();
const result = await service.reconcileTaskWatchdogs({ companyId });
expect(result).toMatchObject({ checked: 1, triggered: 1 });
expect(wakes).toHaveLength(1);
expect(wakes[0]?.agentId).toBe(agentId);
expect(wakes[0]?.opts?.reason).toBe("task_watchdog_stopped_subtree");
expect(wakes[0]?.opts?.contextSnapshot).toMatchObject({
taskWatchdog: {
watchedIssueId: sourceId,
watchedIssueIdentifier: "WDOG-1",
capabilities: {
targetScope: {
watchedIssueId: sourceId,
includeNonWatchdogDescendants: true,
excludedOriginKinds: ["task_watchdog"],
},
operations: expect.arrayContaining([
"comment_on_watched_subtree_issues",
"create_child_issues_under_non_watchdog_watched_subtree",
"create_product_bug_followups_outside_watched_subtree",
"update_reusable_watchdog_issue",
]),
deniedOperations: expect.arrayContaining([
"create_visible_probe_issues_or_throwaway_tasks",
"create_product_bug_followups_as_source_tree_children",
"mutate_task_watchdog_descendants",
]),
},
},
});
const watchdogIssues = await db
.select()
.from(issues)
.where(and(eq(issues.companyId, companyId), eq(issues.originKind, "task_watchdog")));
expect(watchdogIssues).toHaveLength(1);
expect(watchdogIssues[0]).toMatchObject({
parentId: sourceId,
originId: sourceId,
assigneeAgentId: agentId,
status: "todo",
});
const [watchdog] = await db.select().from(issueWatchdogs).where(eq(issueWatchdogs.issueId, sourceId));
expect(watchdog?.watchdogIssueId).toBe(watchdogIssues[0]?.id);
expect(watchdog?.lastObservedFingerprint).toMatch(/^task_watchdog_stop:/);
expect(watchdog?.triggerCount).toBe(1);
});
it("does not trigger while a non-watchdog descendant has live work", async () => {
const companyId = await seedCompany();
const sourceId = await seedIssue(companyId, { identifier: "WDOG-2", status: "in_progress" });
const childId = await seedIssue(companyId, { parentId: sourceId, status: "in_progress" });
const agentId = await seedAgent(companyId);
await seedWatchdog(companyId, sourceId, agentId);
await db.insert(heartbeatRuns).values({
companyId,
agentId,
status: "queued",
invocationSource: "assignment",
contextSnapshot: { issueId: childId },
});
const { service, wakes } = createService();
const result = await service.reconcileTaskWatchdogs({ companyId });
expect(result).toMatchObject({ checked: 1, triggered: 0, live: 1 });
expect(wakes).toHaveLength(0);
const watchdogIssues = await db
.select({ id: issues.id })
.from(issues)
.where(and(eq(issues.companyId, companyId), eq(issues.originKind, "task_watchdog")));
expect(watchdogIssues).toHaveLength(0);
});
it("does not trigger while a descendant has a queued assignment wake", async () => {
const companyId = await seedCompany();
const sourceId = await seedIssue(companyId, { identifier: "WDOG-WAKE", status: "in_progress" });
const childId = await seedIssue(companyId, { parentId: sourceId, status: "todo" });
const agentId = await seedAgent(companyId);
await seedWatchdog(companyId, sourceId, agentId);
await db.insert(agentWakeupRequests).values({
companyId,
agentId,
status: "queued",
source: "assignment",
triggerDetail: "system",
reason: "issue_assigned",
payload: { issueId: childId },
});
const { service, wakes } = createService();
const result = await service.reconcileTaskWatchdogs({ companyId });
expect(result).toMatchObject({ checked: 1, triggered: 0, live: 1 });
expect(wakes).toHaveLength(0);
const watchdogIssues = await db
.select({ id: issues.id })
.from(issues)
.where(and(eq(issues.companyId, companyId), eq(issues.originKind, "task_watchdog")));
expect(watchdogIssues).toHaveLength(0);
});
it("does not keep the source live for runs under a nested task-watchdog issue", async () => {
const companyId = await seedCompany();
const sourceId = await seedIssue(companyId, { identifier: "WDOG-NEST", status: "done" });
const agentId = await seedAgent(companyId);
const nestedWatchdogIssueId = await seedIssue(companyId, {
parentId: sourceId,
status: "in_progress",
originKind: "task_watchdog",
originId: sourceId,
originFingerprint: `task_watchdog:${companyId}:${sourceId}`,
});
const nestedChildId = await seedIssue(companyId, {
parentId: nestedWatchdogIssueId,
status: "in_progress",
});
await seedWatchdog(companyId, sourceId, agentId);
await db.insert(heartbeatRuns).values({
companyId,
agentId,
status: "running",
invocationSource: "assignment",
contextSnapshot: { issueId: nestedChildId },
});
const { service, wakes } = createService();
const result = await service.reconcileTaskWatchdogs({ companyId });
expect(result).toMatchObject({ checked: 1, triggered: 1, live: 0 });
expect(wakes).toHaveLength(1);
});
it("reconciles ancestor watchdogs for a descendant issue mutation", async () => {
const companyId = await seedCompany();
const sourceId = await seedIssue(companyId, { identifier: "WDOG-ANCESTOR", status: "done" });
const childId = await seedIssue(companyId, { parentId: sourceId, status: "done" });
const agentId = await seedAgent(companyId);
await seedWatchdog(companyId, sourceId, agentId);
const { service, wakes } = createService();
const result = await service.reconcileForIssueAndAncestors(companyId, childId);
expect(result).toMatchObject({ checked: 1, triggered: 1 });
expect(wakes).toHaveLength(1);
});
it("marks a completed watchdog fingerprint reviewed, then reuses the same issue for a later stopped state", async () => {
const companyId = await seedCompany();
const sourceId = await seedIssue(companyId, { identifier: "WDOG-3", status: "done" });
const childId = await seedIssue(companyId, { parentId: sourceId, status: "done" });
const agentId = await seedAgent(companyId);
await seedWatchdog(companyId, sourceId, agentId);
const { service, wakes } = createService();
await service.reconcileTaskWatchdogs({ companyId });
const [firstWatchdog] = await db.select().from(issueWatchdogs).where(eq(issueWatchdogs.issueId, sourceId));
const watchdogIssueId = firstWatchdog!.watchdogIssueId!;
const [firstWatchdogIssue] = await db.select().from(issues).where(eq(issues.id, watchdogIssueId));
expect(firstWatchdogIssue?.originFingerprint).toBe(firstWatchdog?.lastObservedFingerprint);
await db.update(issues).set({ status: "done", updatedAt: new Date() }).where(eq(issues.id, watchdogIssueId));
const reviewed = await service.reconcileTaskWatchdogs({ companyId });
expect(reviewed).toMatchObject({ checked: 1, triggered: 0, alreadyReviewed: 1 });
const [reviewedWatchdog] = await db.select().from(issueWatchdogs).where(eq(issueWatchdogs.issueId, sourceId));
expect(reviewedWatchdog?.lastReviewedFingerprint).toBe(firstWatchdog?.lastObservedFingerprint);
await db
.update(issues)
.set({ status: "blocked", updatedAt: new Date(Date.now() + 60_000) })
.where(eq(issues.id, childId));
const retriggered = await service.reconcileTaskWatchdogs({ companyId });
expect(retriggered).toMatchObject({ checked: 1, triggered: 1 });
const watchdogIssues = await db
.select()
.from(issues)
.where(and(eq(issues.companyId, companyId), eq(issues.originKind, "task_watchdog")));
expect(watchdogIssues).toHaveLength(1);
expect(watchdogIssues[0]).toMatchObject({ id: watchdogIssueId, status: "todo" });
const comments = await db
.select()
.from(issueComments)
.where(eq(issueComments.issueId, watchdogIssueId));
expect(comments.some((comment) => comment.body.includes("Stopped fingerprint"))).toBe(true);
expect(wakes.length).toBe(2);
});
it("does not let an old terminal watchdog review mark a newer observed fingerprint reviewed", async () => {
const companyId = await seedCompany();
const sourceId = await seedIssue(companyId, { identifier: "WDOG-STALE", status: "done" });
const childId = await seedIssue(companyId, { parentId: sourceId, status: "done" });
const agentId = await seedAgent(companyId);
await seedWatchdog(companyId, sourceId, agentId);
const { service, wakes } = createService();
await service.reconcileTaskWatchdogs({ companyId });
const [firstWatchdog] = await db.select().from(issueWatchdogs).where(eq(issueWatchdogs.issueId, sourceId));
const oldFingerprint = firstWatchdog!.lastObservedFingerprint!;
const watchdogIssueId = firstWatchdog!.watchdogIssueId!;
const watchdogRunId = randomUUID();
await db.insert(heartbeatRuns).values({
id: watchdogRunId,
companyId,
agentId,
status: "running",
invocationSource: "assignment",
contextSnapshot: { issueId: watchdogIssueId },
});
await db
.update(issues)
.set({ status: "blocked", updatedAt: new Date(Date.now() + 60_000) })
.where(eq(issues.id, childId));
const changedWhileReviewLive = await service.reconcileTaskWatchdogs({ companyId });
expect(changedWhileReviewLive).toMatchObject({ checked: 1, triggered: 0, live: 1 });
const [observedWhileLive] = await db.select().from(issueWatchdogs).where(eq(issueWatchdogs.issueId, sourceId));
const newerFingerprint = observedWhileLive!.lastObservedFingerprint!;
expect(newerFingerprint).not.toBe(oldFingerprint);
const [stillBoundReview] = await db.select().from(issues).where(eq(issues.id, watchdogIssueId));
expect(stillBoundReview?.originFingerprint).toBe(oldFingerprint);
await db.update(heartbeatRuns).set({ status: "succeeded" }).where(eq(heartbeatRuns.id, watchdogRunId));
await db.update(issues).set({ status: "done", updatedAt: new Date() }).where(eq(issues.id, watchdogIssueId));
const afterOldReviewCompletes = await service.reconcileTaskWatchdogs({ companyId });
expect(afterOldReviewCompletes).toMatchObject({ checked: 1, triggered: 1 });
const [reviewedWatchdog] = await db.select().from(issueWatchdogs).where(eq(issueWatchdogs.issueId, sourceId));
expect(reviewedWatchdog?.lastReviewedFingerprint).toBe(oldFingerprint);
expect(reviewedWatchdog?.lastReviewedFingerprint).not.toBe(newerFingerprint);
const [reopenedWatchdogIssue] = await db.select().from(issues).where(eq(issues.id, watchdogIssueId));
expect(reopenedWatchdogIssue).toMatchObject({
status: "todo",
originFingerprint: newerFingerprint,
});
const reviewActivities = await db
.select()
.from(activityLog)
.where(and(eq(activityLog.entityId, sourceId), eq(activityLog.action, "issue.task_watchdog_fingerprint_reviewed")));
expect(reviewActivities).toHaveLength(1);
expect(reviewActivities[0]?.details).toMatchObject({
reviewedFingerprint: oldFingerprint,
lastObservedFingerprint: newerFingerprint,
});
expect(wakes.length).toBe(2);
});
it("revalidates stale watchdog reviews against current source evidence before allowing mutations", async () => {
const companyId = await seedCompany();
const sourceId = await seedIssue(companyId, { identifier: "WDOG-REVALIDATE", status: "blocked" });
const agentId = await seedAgent(companyId);
await seedWatchdog(companyId, sourceId, agentId);
const { service } = createService();
await service.reconcileTaskWatchdogs({ companyId });
const [watchdog] = await db.select().from(issueWatchdogs).where(eq(issueWatchdogs.issueId, sourceId));
const originalFingerprint = watchdog!.lastObservedFingerprint!;
expect(originalFingerprint).toMatch(/^task_watchdog_stop:/);
const later = new Date(Date.now() + 60_000);
await db.insert(issueComments).values({
companyId,
issueId: sourceId,
authorType: "agent",
body: "Fresh source evidence.",
updatedAt: later,
createdAt: later,
});
await seedIssueDocument(companyId, sourceId, new Date(later.getTime() + 1_000));
await seedIssueWorkProduct(companyId, sourceId, new Date(later.getTime() + 2_000));
const revalidated = await service.revalidateMutationScope({
kind: "watchdog",
watchdogId: watchdog!.id,
companyId,
watchedIssueId: sourceId,
stopFingerprint: originalFingerprint,
});
expect(revalidated.allowed).toBe(false);
expect(revalidated.reason).toContain("stop fingerprint changed");
expect(revalidated.classification?.state).toBe("stopped");
if (revalidated.classification?.state !== "stopped") throw new Error("Expected stopped classification");
expect(revalidated.classification.stopFingerprint).not.toBe(originalFingerprint);
expect(revalidated.classification.stoppedLeaves[0]).toMatchObject({
latestCommentAt: later.toISOString(),
latestDocumentAt: new Date(later.getTime() + 1_000).toISOString(),
latestWorkProductAt: new Date(later.getTime() + 2_000).toISOString(),
});
});
it("revalidates a stale watchdog review as live when the source gets a fresh run path", async () => {
const companyId = await seedCompany();
const sourceId = await seedIssue(companyId, { identifier: "WDOG-LIVE-REVALIDATE", status: "blocked" });
const agentId = await seedAgent(companyId);
await seedWatchdog(companyId, sourceId, agentId);
const { service } = createService();
await service.reconcileTaskWatchdogs({ companyId });
const [watchdog] = await db.select().from(issueWatchdogs).where(eq(issueWatchdogs.issueId, sourceId));
const originalFingerprint = watchdog!.lastObservedFingerprint!;
await db.insert(heartbeatRuns).values({
companyId,
agentId,
status: "running",
invocationSource: "assignment",
contextSnapshot: { issueId: sourceId },
});
const revalidated = await service.revalidateMutationScope({
kind: "watchdog",
watchdogId: watchdog!.id,
companyId,
watchedIssueId: sourceId,
stopFingerprint: originalFingerprint,
});
expect(revalidated.allowed).toBe(false);
expect(revalidated.reason).toContain("now has a live");
expect(revalidated.classification?.state).toBe("live");
});
it("does not raise a stopped-subtree review while a freshly-created assigned issue's first run is starting", async () => {
const companyId = await seedCompany();
const agentId = await seedAgent(companyId);
// Issue + watchdog created in the same flow; the assignment run row is not
// yet visible to this evaluation (create-race).
const sourceId = await seedIssue(companyId, {
identifier: "WDOG-RACE",
status: "todo",
assigneeAgentId: agentId,
createdAt: new Date(),
});
await seedWatchdog(companyId, sourceId, agentId);
const { service, wakes } = createService();
const result = await service.reconcileTaskWatchdogs({ companyId });
expect(result).toMatchObject({ checked: 1, triggered: 0, pendingFirstRun: 1 });
expect(wakes).toHaveLength(0);
const watchdogIssues = await db
.select({ id: issues.id })
.from(issues)
.where(and(eq(issues.companyId, companyId), eq(issues.originKind, "task_watchdog")));
expect(watchdogIssues).toHaveLength(0);
const [watchdog] = await db.select().from(issueWatchdogs).where(eq(issueWatchdogs.issueId, sourceId));
expect(watchdog?.triggerCount).toBe(0);
});
it("still triggers a genuinely idle assigned issue once it is past the first-run grace window", async () => {
const companyId = await seedCompany();
const agentId = await seedAgent(companyId);
// Established issue (default createdAt is an hour ago), assigned, non-terminal,
// with no live run or queued wake.
const sourceId = await seedIssue(companyId, {
identifier: "WDOG-IDLE",
status: "todo",
assigneeAgentId: agentId,
});
await seedWatchdog(companyId, sourceId, agentId);
const { service, wakes } = createService();
const result = await service.reconcileTaskWatchdogs({ companyId });
expect(result).toMatchObject({ checked: 1, triggered: 1 });
expect(wakes).toHaveLength(1);
});
it("does not defer once the freshly-created issue has a terminal run on record", async () => {
const companyId = await seedCompany();
const agentId = await seedAgent(companyId);
const sourceId = await seedIssue(companyId, {
identifier: "WDOG-RAN",
status: "blocked",
assigneeAgentId: agentId,
createdAt: new Date(),
});
await seedWatchdog(companyId, sourceId, agentId);
// A run for this issue already reached a terminal status, so the stop is
// genuine even though the issue was just created.
await db.insert(heartbeatRuns).values({
companyId,
agentId,
status: "succeeded",
invocationSource: "assignment",
contextSnapshot: { issueId: sourceId },
});
const { service, wakes } = createService();
const result = await service.reconcileTaskWatchdogs({ companyId });
expect(result).toMatchObject({ checked: 1, triggered: 1 });
expect(wakes).toHaveLength(1);
});
it("does not recursively trigger a watchdog configured on a task-watchdog issue", async () => {
const companyId = await seedCompany();
const agentId = await seedAgent(companyId);
const sourceId = await seedIssue(companyId, { identifier: "WDOG-4", status: "done" });
const watchdogIssueId = await seedIssue(companyId, {
parentId: sourceId,
status: "done",
originKind: "task_watchdog",
originId: sourceId,
originFingerprint: `task_watchdog:${companyId}:${sourceId}`,
});
await seedIssue(companyId, { parentId: watchdogIssueId, status: "done" });
await seedWatchdog(companyId, watchdogIssueId, agentId);
const { service, wakes } = createService();
const result = await service.reconcileTaskWatchdogs({ companyId });
expect(result).toMatchObject({ checked: 1, triggered: 0 });
expect(wakes).toHaveLength(0);
const watchdogIssues = await db
.select()
.from(issues)
.where(and(eq(issues.companyId, companyId), eq(issues.originKind, "task_watchdog")));
expect(watchdogIssues).toHaveLength(1);
});
});
+14
View File
@@ -802,6 +802,14 @@ export async function startServer(): Promise<StartedServer> {
);
}
const taskWatchdogsReconciled = await heartbeat.reconcileTaskWatchdogs();
if (taskWatchdogsReconciled.triggered > 0) {
logger.warn(
{ ...taskWatchdogsReconciled },
"startup task-watchdog reconciliation triggered watchdog work",
);
}
const scanned = await heartbeat.scanSilentActiveRuns();
if (scanned.created > 0 || scanned.escalated > 0) {
logger.warn({ ...scanned }, "startup active-run output watchdog created review work");
@@ -871,6 +879,12 @@ export async function startServer(): Promise<StartedServer> {
logger.warn({ ...reconciled }, "periodic issue-graph liveness reconciliation created escalations");
}
})
.then(async () => {
const reconciled = await heartbeat.reconcileTaskWatchdogs();
if (reconciled.triggered > 0) {
logger.warn({ ...reconciled }, "periodic task-watchdog reconciliation triggered watchdog work");
}
})
.then(async () => {
const scanned = await heartbeat.scanSilentActiveRuns();
if (scanned.created > 0 || scanned.escalated > 0) {
+46 -1
View File
@@ -1,5 +1,6 @@
import { Router, type Request } from "express";
import type { Db } from "@paperclipai/db";
import { eq } from "drizzle-orm";
import { heartbeatRuns, type Db } from "@paperclipai/db";
import {
addApprovalCommentSchema,
createApprovalSchema,
@@ -28,6 +29,16 @@ function redactApprovalPayload<T extends { payload: Record<string, unknown> }>(a
};
}
function isStatusOnlyCheapRecoveryContext(contextSnapshot: unknown) {
if (!contextSnapshot || typeof contextSnapshot !== "object" || Array.isArray(contextSnapshot)) return false;
const context = contextSnapshot as Record<string, unknown>;
return context.modelProfile === "cheap" &&
context.recoveryIntent === "status_only" &&
context.allowDeliverableWork === false &&
context.allowDocumentUpdates === false &&
context.resumeRequiresNormalModel === true;
}
export function approvalRoutes(
db: Db,
options: { pluginWorkerManager?: PluginWorkerManager } = {},
@@ -62,6 +73,37 @@ export function approvalRoutes(
return false;
}
async function assertApprovalMutationAllowedByRunContext(req: Request, res: any, companyId: string) {
if (req.actor.type !== "agent") return true;
const runId = req.actor.runId?.trim();
if (!runId || !req.actor.agentId) return true;
const run = await db
.select({
id: heartbeatRuns.id,
companyId: heartbeatRuns.companyId,
agentId: heartbeatRuns.agentId,
contextSnapshot: heartbeatRuns.contextSnapshot,
})
.from(heartbeatRuns)
.where(eq(heartbeatRuns.id, runId))
.then((rows) => rows[0] ?? null);
if (!run || run.companyId !== companyId || run.agentId !== req.actor.agentId) return true;
if (!isStatusOnlyCheapRecoveryContext(run.contextSnapshot)) return true;
res.status(403).json({
error: "Cheap status-only recovery runs cannot create or modify approvals",
details: {
companyId,
runId: run.id,
modelProfile: "cheap",
recoveryIntent: "status_only",
resumeRequiresNormalModel: true,
},
});
return false;
}
router.get("/companies/:companyId/approvals", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
@@ -87,6 +129,7 @@ export function approvalRoutes(
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
if (!(await assertApprovalAccessAllowed(req, res, companyId))) return;
if (!(await assertApprovalMutationAllowedByRunContext(req, res, companyId))) return;
const rawIssueIds = req.body.issueIds;
const issueIds = Array.isArray(rawIssueIds)
? rawIssueIds.filter((value: unknown): value is string => typeof value === "string")
@@ -306,6 +349,7 @@ export function approvalRoutes(
return;
}
assertCompanyAccess(req, existing.companyId);
if (!(await assertApprovalMutationAllowedByRunContext(req, res, existing.companyId))) return;
if (req.actor.type === "agent" && req.actor.agentId !== existing.requestedByAgentId) {
res.status(403).json({ error: "Only requesting agent can resubmit this approval" });
@@ -356,6 +400,7 @@ export function approvalRoutes(
return;
}
assertCompanyAccess(req, approval.companyId);
if (!(await assertApprovalMutationAllowedByRunContext(req, res, approval.companyId))) return;
const actor = getActorInfo(req);
const comment = await svc.addComment(id, req.body.body, {
agentId: actor.agentId ?? undefined,
+2 -1
View File
@@ -64,7 +64,8 @@ export function instanceSettingsRoutes(db: Db) {
router.get("/instance/settings/experimental", async (req, res) => {
// Experimental settings are readable by any authenticated org member
// or instance admin. Only PATCH requires instance-admin.
// or instance admin. Updating them remains instance-admin only because
// this payload includes instance-wide operational controls.
assertBoardOrgAccess(req);
res.json(await svc.getExperimental());
});
File diff suppressed because it is too large Load Diff
+31
View File
@@ -26,6 +26,7 @@ import {
upsertIssueDocumentSchema,
restoreIssueDocumentRevisionSchema,
upsertIssueFeedbackVoteSchema,
upsertIssueWatchdogSchema,
// Project
createProjectSchema,
updateProjectSchema,
@@ -1380,6 +1381,36 @@ registry.registerPath({
responses: { 200: r.ok(), 401: r.unauthorized },
});
registry.registerPath({
method: "get",
path: "/api/issues/{id}/watchdog",
tags: ["issues"],
summary: "Get active issue watchdog",
request: { params: z.object({ id: z.string() }) },
responses: { 200: r.ok(), 401: r.unauthorized, 404: r.notFound },
});
registry.registerPath({
method: "put",
path: "/api/issues/{id}/watchdog",
tags: ["issues"],
summary: "Create or update an issue watchdog",
request: {
params: z.object({ id: z.string() }),
body: jsonBody(upsertIssueWatchdogSchema),
},
responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden, 404: r.notFound },
});
registry.registerPath({
method: "delete",
path: "/api/issues/{id}/watchdog",
tags: ["issues"],
summary: "Disable an issue watchdog",
request: { params: z.object({ id: z.string() }) },
responses: { 200: r.ok(), 401: r.unauthorized, 403: r.forbidden, 404: r.notFound },
});
registry.registerPath({
method: "get",
path: "/api/issues/{id}/work-products",
+9
View File
@@ -158,6 +158,7 @@ import {
} from "./recovery/model-profile-hint.js";
import { recoveryService } from "./recovery/service.js";
import { productivityReviewService } from "./productivity-review.js";
import { taskWatchdogService } from "./task-watchdogs.js";
import { withAgentStartLock } from "./agent-start-lock.js";
import {
evaluateAgentInvokability,
@@ -2792,6 +2793,7 @@ export async function buildPaperclipWakePayload(input: {
? input.contextSnapshot.unresolvedBlockerSummaries
: [],
executionStage: Object.keys(executionStage).length > 0 ? executionStage : null,
taskWatchdog: (input.contextSnapshot.taskWatchdog ?? null) as unknown,
continuationSummary: safeContinuationSummary
? {
key: safeContinuationSummary.key,
@@ -3219,6 +3221,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
const budgets = budgetService(db, budgetHooks);
const recovery = recoveryService(db, { enqueueWakeup });
const productivityReviews = productivityReviewService(db, { enqueueWakeup });
const taskWatchdogs = taskWatchdogService(db, { enqueueWakeup });
let unsafeTextProjectionPromise: Promise<boolean> | null = null;
async function releaseEnvironmentLeasesForRun(input: {
@@ -7744,6 +7747,10 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
return productivityReviews.reconcileProductivityReviews(opts);
}
async function reconcileTaskWatchdogs(opts?: { companyId?: string | null; runId?: string | null }) {
return taskWatchdogs.reconcileTaskWatchdogs(opts);
}
async function buildRunOutputSilence(
run: Pick<
typeof heartbeatRuns.$inferSelect,
@@ -11738,6 +11745,8 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
reconcileProductivityReviews,
reconcileTaskWatchdogs,
buildRunOutputSilence,
tickTimers: async (now = new Date()) => {
+6
View File
@@ -27,6 +27,12 @@ export { issueTreeControlService } from "./issue-tree-control.js";
export { issueApprovalService } from "./issue-approvals.js";
export { issueReferenceService } from "./issue-references.js";
export { issueRecoveryActionService } from "./issue-recovery-actions.js";
export { taskWatchdogService } from "./task-watchdogs.js";
export {
issueIsInTaskWatchdogSubtree,
resolveTaskWatchdogMutationScope,
taskWatchdogScopeAllowsIssueMutation,
} from "./task-watchdog-scope.js";
export { goalService } from "./goals.js";
export { activityService, type ActivityFilters } from "./activity.js";
export { approvalService } from "./approvals.js";
+2
View File
@@ -49,6 +49,7 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta
enableConferenceRoomChat: parsed.data.enableConferenceRoomChat ?? false,
enableIssuePlanDecompositions: parsed.data.enableIssuePlanDecompositions ?? false,
enableExperimentalFileViewer: parsed.data.enableExperimentalFileViewer ?? false,
enableTaskWatchdogs: parsed.data.enableTaskWatchdogs ?? false,
enableCloudSync: parsed.data.enableCloudSync ?? false,
autoRestartDevServerWhenIdle: parsed.data.autoRestartDevServerWhenIdle ?? false,
enableIssueGraphLivenessAutoRecovery: parsed.data.enableIssueGraphLivenessAutoRecovery ?? false,
@@ -64,6 +65,7 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta
enableConferenceRoomChat: false,
enableIssuePlanDecompositions: false,
enableExperimentalFileViewer: false,
enableTaskWatchdogs: false,
enableCloudSync: false,
autoRestartDevServerWhenIdle: false,
enableIssueGraphLivenessAutoRecovery: false,
+59 -2
View File
@@ -19,6 +19,7 @@ import {
issueAttachments,
issueInboxArchives,
issueLabels,
issueWatchdogs,
issuePlanDecompositions,
issueRecoveryActions,
issueRelations,
@@ -44,6 +45,7 @@ import type {
IssueProductivityReview,
IssueProductivityReviewTrigger,
IssueRelationIssueSummary,
IssueWatchdogSummary,
LowTrustBoundary,
SuccessfulRunHandoffState,
} from "@paperclipai/shared";
@@ -76,6 +78,10 @@ import { resolveIssueGoalId, resolveNextIssueGoalId } from "./issue-goal-fallbac
import { getRunLogStore } from "./run-log-store.js";
import { getDefaultCompanyGoal } from "./goals.js";
import { assertAssignableAgent } from "./agent-assignability.js";
import {
summarizeIssueWatchdog,
upsertIssueWatchdogForIssue,
} from "./task-watchdogs.js";
import {
isVerifiedIssueTreeControlInteractionWake,
issueTreeControlService,
@@ -302,7 +308,11 @@ type IssueScheduledRetryRow = {
error?: string | null;
errorCode?: string | null;
};
type IssueWithLabels = IssueRow & { labels: IssueLabelRow[]; labelIds: string[] };
type IssueWithLabels = IssueRow & {
labels: IssueLabelRow[];
labelIds: string[];
watchdog?: IssueWatchdogSummary | null;
};
type IssueWithLabelsAndRun = IssueWithLabels & { activeRun: IssueActiveRunRow | null };
type IssueUserCommentStats = {
issueId: string;
@@ -354,6 +364,8 @@ type IssueCreateInput = Omit<typeof issues.$inferInsert, "companyId"> & {
labelIds?: string[];
blockedByIssueIds?: string[];
inheritExecutionWorkspaceFromIssueId?: string | null;
watchdog?: { agentId: string; instructions?: string | null } | null;
watchdogActorRunId?: string | null;
};
type IssueChildCreateInput = IssueCreateInput & {
acceptanceCriteria?: string[];
@@ -1143,17 +1155,49 @@ async function labelMapForIssues(dbOrTx: any, issueIds: string[]): Promise<Map<s
async function withIssueLabels(dbOrTx: any, rows: IssueRow[]): Promise<IssueWithLabels[]> {
if (rows.length === 0) return [];
const labelsByIssueId = await labelMapForIssues(dbOrTx, rows.map((row) => row.id));
const issueIds = rows.map((row) => row.id);
const [labelsByIssueId, watchdogByIssueId] = await Promise.all([
labelMapForIssues(dbOrTx, issueIds),
watchdogMapForIssues(dbOrTx, rows),
]);
return rows.map((row) => {
const issueLabels = labelsByIssueId.get(row.id) ?? [];
return {
...row,
labels: issueLabels,
labelIds: issueLabels.map((label) => label.id),
watchdog: watchdogByIssueId.get(row.id) ?? null,
};
});
}
async function watchdogMapForIssues(dbOrTx: any, rows: IssueRow[]): Promise<Map<string, IssueWatchdogSummary>> {
const map = new Map<string, IssueWatchdogSummary>();
if (rows.length === 0) return map;
const byCompany = new Map<string, string[]>();
for (const row of rows) {
const ids = byCompany.get(row.companyId) ?? [];
ids.push(row.id);
byCompany.set(row.companyId, ids);
}
for (const [companyId, issueIds] of byCompany.entries()) {
for (const issueIdChunk of chunkList([...new Set(issueIds)], ISSUE_LIST_RELATED_QUERY_CHUNK_SIZE)) {
const watchdogRows = await dbOrTx
.select()
.from(issueWatchdogs)
.where(and(
eq(issueWatchdogs.companyId, companyId),
inArray(issueWatchdogs.issueId, issueIdChunk),
eq(issueWatchdogs.status, "active"),
));
for (const row of watchdogRows) {
map.set(row.issueId, summarizeIssueWatchdog(row));
}
}
}
return map;
}
const ACTIVE_RUN_STATUSES = ["queued", "running"];
const BLOCKER_ATTENTION_ACTIVE_RUN_STATUSES = ["queued", "running"];
const BLOCKER_ATTENTION_ACTIVE_WAKE_STATUSES = ["queued", "deferred_issue_execution"];
@@ -4857,6 +4901,8 @@ export function issueService(db: Db) {
labelIds: inputLabelIds,
blockedByIssueIds,
inheritExecutionWorkspaceFromIssueId,
watchdog,
watchdogActorRunId,
...issueData
} = data;
const isolatedWorkspacesEnabled = (await instanceSettings.getExperimental()).enableIsolatedWorkspaces;
@@ -5073,6 +5119,17 @@ export function issueService(db: Db) {
);
const [issue] = await tx.insert(issues).values(values).returning();
if (watchdog) {
await upsertIssueWatchdogForIssue(tx, companyId, issue.id, {
agentId: watchdog.agentId,
instructions: watchdog.instructions,
actor: {
agentId: issueData.createdByAgentId ?? null,
userId: issueData.createdByUserId ?? null,
runId: watchdogActorRunId ?? null,
},
});
}
if (inputLabelIds) {
await syncIssueLabels(issue.id, companyId, inputLabelIds, tx);
}
+174
View File
@@ -0,0 +1,174 @@
import { and, eq } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import { heartbeatRuns, issues, issueWatchdogs } from "@paperclipai/db";
const MAX_WATCHDOG_SCOPE_ANCESTRY_DEPTH = 100;
export const TASK_WATCHDOG_ORIGIN_KIND = "task_watchdog";
type AgentRunActor = {
type: string;
agentId?: string | null;
companyId?: string | null;
runId?: string | null;
};
type IssueScopeTarget = {
id: string;
companyId: string;
parentId?: string | null;
};
export type TaskWatchdogMutationScope =
| { kind: "none" }
| { kind: "invalid"; detail: string }
| {
kind: "watchdog";
watchdogId: string;
companyId: string;
watchedIssueId: string;
watchdogIssueId: string | null;
stopFingerprint: string | null;
};
function isPlainRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function readString(value: unknown): string | null {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}
function readTaskWatchdogContext(contextSnapshot: unknown) {
const context = isPlainRecord(contextSnapshot) ? contextSnapshot : null;
const taskWatchdog = isPlainRecord(context?.taskWatchdog) ? context.taskWatchdog : null;
if (!taskWatchdog && context?.taskWatchdog !== true) return null;
return {
watchedIssueId: readString(taskWatchdog?.watchedIssueId) ?? readString(context?.watchedIssueId),
stopFingerprint: readString(taskWatchdog?.stopFingerprint) ?? readString(context?.stopFingerprint),
};
}
export async function resolveTaskWatchdogMutationScope(
db: Db,
actor: AgentRunActor,
): Promise<TaskWatchdogMutationScope> {
if (actor.type !== "agent") return { kind: "none" };
const agentId = readString(actor.agentId);
const runId = readString(actor.runId);
const actorCompanyId = readString(actor.companyId);
if (!agentId || !runId) return { kind: "none" };
const run = await db
.select({
id: heartbeatRuns.id,
companyId: heartbeatRuns.companyId,
agentId: heartbeatRuns.agentId,
contextSnapshot: heartbeatRuns.contextSnapshot,
})
.from(heartbeatRuns)
.where(eq(heartbeatRuns.id, runId))
.then((rows) => rows[0] ?? null);
if (!run) return { kind: "none" };
const taskWatchdog = readTaskWatchdogContext(run.contextSnapshot);
if (!taskWatchdog) return { kind: "none" };
if (run.agentId !== agentId || (actorCompanyId && run.companyId !== actorCompanyId)) {
return {
kind: "invalid",
detail: "Task-watchdog run context does not belong to this agent.",
};
}
if (!taskWatchdog.watchedIssueId) {
return {
kind: "invalid",
detail: "Task-watchdog run context is missing a persisted watched issue id.",
};
}
const watchdog = await db
.select({
id: issueWatchdogs.id,
companyId: issueWatchdogs.companyId,
issueId: issueWatchdogs.issueId,
watchdogAgentId: issueWatchdogs.watchdogAgentId,
watchdogIssueId: issueWatchdogs.watchdogIssueId,
status: issueWatchdogs.status,
})
.from(issueWatchdogs)
.where(and(
eq(issueWatchdogs.companyId, run.companyId),
eq(issueWatchdogs.issueId, taskWatchdog.watchedIssueId),
eq(issueWatchdogs.watchdogAgentId, agentId),
eq(issueWatchdogs.status, "active"),
))
.then((rows) => rows[0] ?? null);
if (!watchdog) {
return {
kind: "invalid",
detail: "Task-watchdog run context is not backed by an active persisted watchdog.",
};
}
return {
kind: "watchdog",
watchdogId: watchdog.id,
companyId: watchdog.companyId,
watchedIssueId: watchdog.issueId,
watchdogIssueId: watchdog.watchdogIssueId ?? null,
stopFingerprint: taskWatchdog.stopFingerprint,
};
}
export async function issueIsInTaskWatchdogSubtree(
db: Db,
companyId: string,
issueId: string,
watchedIssueId: string,
) {
let currentId: string | null = issueId;
const seen = new Set<string>();
for (let depth = 0; currentId && depth < MAX_WATCHDOG_SCOPE_ANCESTRY_DEPTH; depth += 1) {
if (seen.has(currentId)) return false;
seen.add(currentId);
const parent: { id: string; companyId: string; parentId: string | null; originKind: string | null } | null = await db
.select({ id: issues.id, companyId: issues.companyId, parentId: issues.parentId, originKind: issues.originKind })
.from(issues)
.where(and(eq(issues.id, currentId), eq(issues.companyId, companyId)))
.then((rows) => rows[0] ?? null);
if (!parent) return false;
if (parent.originKind === TASK_WATCHDOG_ORIGIN_KIND) return false;
if (currentId === watchedIssueId) return true;
currentId = parent.parentId ?? null;
}
return false;
}
export async function taskWatchdogScopeAllowsIssueMutation(
db: Db,
scope: TaskWatchdogMutationScope,
issue: IssueScopeTarget,
opts: { allowWatchdogIssue?: boolean } = {},
) {
if (scope.kind !== "watchdog") return scope;
if (issue.companyId !== scope.companyId) {
return {
kind: "invalid" as const,
detail: "Task-watchdog mutation target is outside the watchdog company.",
};
}
if (opts.allowWatchdogIssue !== false && scope.watchdogIssueId && issue.id === scope.watchdogIssueId) {
return scope;
}
if (await issueIsInTaskWatchdogSubtree(db, scope.companyId, issue.id, scope.watchedIssueId)) {
return scope;
}
return {
kind: "invalid" as const,
detail: "Task-watchdog runs can only mutate the watched issue subtree.",
};
}
File diff suppressed because it is too large Load Diff