[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
+1
View File
@@ -6,6 +6,7 @@ export const API = {
agents: `${API_PREFIX}/agents`,
projects: `${API_PREFIX}/projects`,
issues: `${API_PREFIX}/issues`,
issueWatchdog: `${API_PREFIX}/issues/:issueId/watchdog`,
issueTreeControl: `${API_PREFIX}/issues/:issueId/tree-control`,
issueTreeHolds: `${API_PREFIX}/issues/:issueId/tree-holds`,
goals: `${API_PREFIX}/goals`,
+6
View File
@@ -254,6 +254,8 @@ export const ISSUE_THREAD_INTERACTION_CONTINUATION_POLICIES = [
export type IssueThreadInteractionContinuationPolicy =
(typeof ISSUE_THREAD_INTERACTION_CONTINUATION_POLICIES)[number];
export const TASK_WATCHDOG_PRODUCT_BUG_ORIGIN_KIND = "task_watchdog_product_bug";
export const ISSUE_ORIGIN_KINDS = [
"manual",
"routine_execution",
@@ -261,10 +263,14 @@ export const ISSUE_ORIGIN_KINDS = [
"harness_liveness_escalation",
"issue_productivity_review",
"stranded_issue_recovery",
"task_watchdog",
TASK_WATCHDOG_PRODUCT_BUG_ORIGIN_KIND,
] as const;
export type BuiltInIssueOriginKind = (typeof ISSUE_ORIGIN_KINDS)[number];
export type PluginIssueOriginKind = `plugin:${string}`;
export type IssueOriginKind = BuiltInIssueOriginKind | PluginIssueOriginKind;
export const ISSUE_WATCHDOG_DISCOVERY_KINDS = ["product_bug", "platform_bug"] as const;
export type IssueWatchdogDiscoveryKind = (typeof ISSUE_WATCHDOG_DISCOVERY_KINDS)[number];
export const ISSUE_SURFACE_VISIBILITIES = ["default", "plugin_operation"] as const;
export type IssueSurfaceVisibility = (typeof ISSUE_SURFACE_VISIBILITIES)[number];
+8
View File
@@ -72,6 +72,8 @@ export {
ISSUE_THREAD_INTERACTION_STATUSES,
ISSUE_THREAD_INTERACTION_CONTINUATION_POLICIES,
ISSUE_ORIGIN_KINDS,
TASK_WATCHDOG_PRODUCT_BUG_ORIGIN_KIND,
ISSUE_WATCHDOG_DISCOVERY_KINDS,
ISSUE_SURFACE_VISIBILITIES,
ISSUE_RECOVERY_ACTION_KINDS,
ISSUE_RECOVERY_ACTION_STATUSES,
@@ -200,6 +202,7 @@ export {
type BuiltInIssueOriginKind,
type PluginIssueOriginKind,
type IssueOriginKind,
type IssueWatchdogDiscoveryKind,
type IssueSurfaceVisibility,
type IssueRecoveryActionKind,
type IssueRecoveryActionStatus,
@@ -536,6 +539,9 @@ export type {
IssueProductivityReview,
IssueProductivityReviewTrigger,
IssueRecoveryAction,
IssueWatchdog,
IssueWatchdogStatus,
IssueWatchdogSummary,
SuccessfulRunHandoffState,
SuccessfulRunHandoffStateKind,
IssueScheduledRetry,
@@ -998,6 +1004,7 @@ export {
createAcceptedPlanDecompositionSchema,
resolveCreateIssueStatusDefault,
createIssueLabelSchema,
upsertIssueWatchdogSchema,
issueBlockedInboxAttentionSchema,
issueBlockedInboxIssueRefSchema,
issueBlockedInboxReasonSchema,
@@ -1104,6 +1111,7 @@ export {
type CreateIssueAttachmentMetadata,
type CreateIssueWorkProduct,
type UpdateIssueWorkProduct,
type UpsertIssueWatchdog,
type CompanyArtifactsQuery,
type UpdateExecutionWorkspace,
type WorkspaceFileListQuery,
+3
View File
@@ -357,6 +357,9 @@ export type {
IssueAncestorGoal,
IssueAttachment,
IssueLabel,
IssueWatchdog,
IssueWatchdogStatus,
IssueWatchdogSummary,
} from "./issue.js";
export type {
IssueTreeControlPreview,
+1
View File
@@ -51,6 +51,7 @@ export interface InstanceExperimentalSettings {
enableConferenceRoomChat: boolean;
enableIssuePlanDecompositions: boolean;
enableExperimentalFileViewer: boolean;
enableTaskWatchdogs: boolean;
enableCloudSync: boolean;
autoRestartDevServerWhenIdle: boolean;
enableIssueGraphLivenessAutoRecovery: boolean;
+29
View File
@@ -501,6 +501,34 @@ export interface IssueExecutionDecision {
updatedAt: Date;
}
export type IssueWatchdogStatus = "active" | "disabled";
export interface IssueWatchdogSummary {
id: string;
companyId: string;
issueId: string;
watchdogAgentId: string;
instructions: string | null;
status: IssueWatchdogStatus;
watchdogIssueId: string | null;
lastObservedFingerprint: string | null;
lastReviewedFingerprint: string | null;
lastTriggeredAt: Date | null;
lastCompletedAt: Date | null;
triggerCount: number;
createdAt: Date;
updatedAt: Date;
}
export interface IssueWatchdog extends IssueWatchdogSummary {
createdByAgentId: string | null;
createdByUserId: string | null;
createdByRunId: string | null;
updatedByAgentId: string | null;
updatedByUserId: string | null;
updatedByRunId: string | null;
}
export interface Issue {
id: string;
companyId: string;
@@ -555,6 +583,7 @@ export interface Issue {
productivityReview?: IssueProductivityReview | null;
activeRecoveryAction?: IssueRecoveryAction | null;
successfulRunHandoff?: SuccessfulRunHandoffState | null;
watchdog?: IssueWatchdogSummary | null;
scheduledRetry?: IssueScheduledRetry | null;
relatedWork?: IssueRelatedWorkSummary;
referencedIssueIdentifiers?: string[];
+2
View File
@@ -295,6 +295,7 @@ export {
issueDocumentKeySchema,
upsertIssueDocumentSchema,
restoreIssueDocumentRevisionSchema,
upsertIssueWatchdogSchema,
type CreateIssue,
type CreateChildIssue,
type CreateAcceptedPlanDecomposition,
@@ -315,6 +316,7 @@ export {
type IssueDocumentFormat,
type UpsertIssueDocument,
type RestoreIssueDocumentRevision,
type UpsertIssueWatchdog,
} from "./issue.js";
export {
@@ -45,6 +45,7 @@ export const instanceExperimentalSettingsSchema = z.object({
enableConferenceRoomChat: z.boolean().default(false),
enableIssuePlanDecompositions: z.boolean().default(false),
enableExperimentalFileViewer: z.boolean().default(false),
enableTaskWatchdogs: z.boolean().default(false),
enableCloudSync: z.boolean().default(false),
autoRestartDevServerWhenIdle: z.boolean().default(false),
enableIssueGraphLivenessAutoRecovery: z.boolean().default(false),
+18 -1
View File
@@ -24,6 +24,7 @@ import {
ISSUE_THREAD_INTERACTION_CONTINUATION_POLICIES,
ISSUE_THREAD_INTERACTION_KINDS,
ISSUE_THREAD_INTERACTION_STATUSES,
ISSUE_WATCHDOG_DISCOVERY_KINDS,
MODEL_PROFILE_KEYS,
REQUEST_CHECKBOX_CONFIRMATION_OPTION_LIMIT,
} from "../constants.js";
@@ -394,6 +395,14 @@ const createIssueBaseSchema = z.object({
executionWorkspacePreference: z.enum(ISSUE_EXECUTION_WORKSPACE_PREFERENCES).optional().nullable(),
executionWorkspaceSettings: issueExecutionWorkspaceSettingsSchema.optional().nullable(),
labelIds: z.array(z.string().uuid()).optional(),
watchdogDiscovery: z.object({
kind: z.enum(ISSUE_WATCHDOG_DISCOVERY_KINDS),
evidenceMarkdown: multilineTextSchema.optional().nullable(),
}).strict().optional().nullable(),
watchdog: z.object({
agentId: z.string().uuid(),
instructions: multilineTextSchema.optional().nullable(),
}).strict().optional().nullable(),
});
export const createIssueInputSchema = createIssueBaseSchema.extend({
@@ -404,10 +413,18 @@ export const createIssueSchema = withCreateIssueStatusDefault(createIssueBaseSch
export type CreateIssue = z.infer<typeof createIssueSchema>;
export const upsertIssueWatchdogSchema = z.object({
agentId: z.string().uuid(),
instructions: multilineTextSchema.optional().nullable(),
}).strict();
export type UpsertIssueWatchdog = z.infer<typeof upsertIssueWatchdogSchema>;
export const createChildIssueSchema = withCreateIssueStatusDefault(createIssueBaseSchema
.omit({
parentId: true,
inheritExecutionWorkspaceFromIssueId: true,
watchdogDiscovery: true,
})
.extend({
acceptanceCriteria: z.array(z.string().trim().min(1).max(500)).max(20).optional(),
@@ -430,7 +447,7 @@ export const createIssueLabelSchema = z.object({
export type CreateIssueLabel = z.infer<typeof createIssueLabelSchema>;
export const updateIssueSchema = createIssueBaseSchema.partial().extend({
export const updateIssueSchema = createIssueBaseSchema.omit({ watchdog: true }).partial().extend({
requestDepth: issueRequestDepthInputSchema.optional(),
assigneeAgentId: z.string().trim().min(1).optional().nullable(),
comment: multilineTextSchema.pipe(z.string().min(1)).optional(),