[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:
@@ -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`,
|
||||
|
||||
@@ -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];
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -357,6 +357,9 @@ export type {
|
||||
IssueAncestorGoal,
|
||||
IssueAttachment,
|
||||
IssueLabel,
|
||||
IssueWatchdog,
|
||||
IssueWatchdogStatus,
|
||||
IssueWatchdogSummary,
|
||||
} from "./issue.js";
|
||||
export type {
|
||||
IssueTreeControlPreview,
|
||||
|
||||
@@ -51,6 +51,7 @@ export interface InstanceExperimentalSettings {
|
||||
enableConferenceRoomChat: boolean;
|
||||
enableIssuePlanDecompositions: boolean;
|
||||
enableExperimentalFileViewer: boolean;
|
||||
enableTaskWatchdogs: boolean;
|
||||
enableCloudSync: boolean;
|
||||
autoRestartDevServerWhenIdle: boolean;
|
||||
enableIssueGraphLivenessAutoRecovery: boolean;
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user