diff --git a/packages/adapter-utils/src/server-utils.test.ts b/packages/adapter-utils/src/server-utils.test.ts index 77e620e1900..02ae7a5cee0 100644 --- a/packages/adapter-utils/src/server-utils.test.ts +++ b/packages/adapter-utils/src/server-utils.test.ts @@ -742,6 +742,70 @@ describe("renderPaperclipWakePrompt", () => { ); }); + it("renders the watchdog-everything setup directive as a first instruction", () => { + const payload = { + reason: "issue_assigned", + issue: { + id: "issue-1", + identifier: "PAP-1", + title: "Build the exporter", + description: "Build it.", + descriptionTruncated: false, + status: "in_progress", + }, + taskWatchdogEverything: { + watchedIssueId: "issue-1", + watchedIssueIdentifier: "PAP-1", + selfAgentId: "agent-1", + }, + commentWindow: { requestedCount: 0, includedCount: 0, missingCount: 0 }, + comments: [], + fallbackFetchNeeded: false, + }; + + const prompt = renderPaperclipWakePrompt(payload); + expect(prompt).toContain("## Watchdog Everything (experimental)"); + expect(prompt).toContain("triage whether this task needs a completion-goal watchdog"); + expect(prompt).toContain("do NOT need one"); + expect(prompt).toContain('PUT $PAPERCLIP_API_URL/api/issues/issue-1/watchdog'); + expect(prompt).toContain('"agentId": "agent-1", "mode": "self"'); + }); + + it("renders the self-review verification contract with the recorded goal", () => { + const payload = { + reason: "task_watchdog_self_review", + issue: { + id: "issue-1", + identifier: "PAP-1", + title: "Build the exporter", + description: "Build it.", + descriptionTruncated: false, + status: "done", + }, + taskWatchdogSelfReview: { + watchedIssueId: "issue-1", + watchedIssueIdentifier: "PAP-1", + watchedIssueTitle: "Build the exporter", + stopFingerprint: "task_watchdog_stop:abc123", + goalInstructions: "Exporter produces a CSV with 3 rows for the seed data.", + stoppedLeaves: [{ identifier: "PAP-1", title: "Build the exporter", status: "done" }], + }, + commentWindow: { requestedCount: 0, includedCount: 0, missingCount: 0 }, + comments: [], + fallbackFetchNeeded: false, + }; + + const prompt = renderPaperclipWakePrompt(payload); + expect(prompt).toContain("## Task Watchdog Self-Review (verify the completion goal)"); + expect(prompt).toContain("You are the self-mode watchdog for PAP-1 Build the exporter"); + expect(prompt).toContain("Exporter produces a CSV with 3 rows for the seed data."); + expect(prompt).toContain("Goal passes: post a short verification comment"); + expect(prompt).toContain("- Stop fingerprint: task_watchdog_stop:abc123"); + expect(prompt).toContain("- PAP-1 Build the exporter (done)"); + // Self review must not carry the subtask-mode watchdog mandate. + expect(prompt).not.toContain("## Task Watchdog Mandate"); + }); + it("suppresses the issue description when the prompt already carries the task-context markdown", () => { const payload = { reason: "issue_assigned", diff --git a/packages/adapter-utils/src/server-utils.ts b/packages/adapter-utils/src/server-utils.ts index 889c2bd5df9..325a32a04ca 100644 --- a/packages/adapter-utils/src/server-utils.ts +++ b/packages/adapter-utils/src/server-utils.ts @@ -655,6 +655,30 @@ type PaperclipWakeRecovery = { routingFallbackReason: string | null; }; +// Experimental "watchdog everything": assignment-shaped wake for a task with +// no watchdog yet — the assignee should triage whether the task needs a +// completion-goal watchdog and, if so, register it on itself in self mode. +type PaperclipWakeTaskWatchdogEverything = { + watchedIssueId: string | null; + watchedIssueIdentifier: string | null; + selfAgentId: string | null; +}; + +// Self-mode watchdog verification wake: the assignee is woken on its own task +// to verify the recorded completion goal after the subtree stopped. +type PaperclipWakeTaskWatchdogSelfReview = { + watchedIssueId: string | null; + watchedIssueIdentifier: string | null; + watchedIssueTitle: string | null; + stopFingerprint: string | null; + goalInstructions: string | null; + stoppedLeaves: Array<{ + identifier: string | null; + title: string | null; + status: string | null; + }>; +}; + type PaperclipWakePayload = { reason: string | null; recovery: PaperclipWakeRecovery | null; @@ -670,6 +694,8 @@ type PaperclipWakePayload = { planReviewContext: PaperclipWakePlanReviewContext | null; livenessContinuation: PaperclipWakeLivenessContinuation | null; taskWatchdog: PaperclipWakeTaskWatchdogContext | null; + taskWatchdogEverything: PaperclipWakeTaskWatchdogEverything | null; + taskWatchdogSelfReview: PaperclipWakeTaskWatchdogSelfReview | null; interactionKind: string | null; interactionStatus: string | null; checkboxSelection: PaperclipWakeCheckboxSelection | null; @@ -1191,6 +1217,52 @@ function normalizePaperclipWakeTaskWatchdog(value: unknown): PaperclipWakeTaskWa }; } +function normalizePaperclipWakeTaskWatchdogEverything(value: unknown): PaperclipWakeTaskWatchdogEverything | null { + const setup = parseObject(value); + const watchedIssueId = asString(setup.watchedIssueId, "").trim() || null; + const watchedIssueIdentifier = asString(setup.watchedIssueIdentifier, "").trim() || null; + const selfAgentId = asString(setup.selfAgentId, "").trim() || null; + if (!watchedIssueId && !watchedIssueIdentifier) return null; + return { watchedIssueId, watchedIssueIdentifier, selfAgentId }; +} + +function normalizePaperclipWakeTaskWatchdogSelfReview(value: unknown): PaperclipWakeTaskWatchdogSelfReview | null { + const review = parseObject(value); + const watchedIssueId = asString(review.watchedIssueId, "").trim() || null; + const watchedIssueIdentifier = asString(review.watchedIssueIdentifier, "").trim() || null; + const watchedIssueTitle = asString(review.watchedIssueTitle, "").trim() || null; + const stopFingerprint = asString(review.stopFingerprint, "").trim() || null; + const goalInstructionsRaw = asString(review.goalInstructions, "").trim(); + const goalInstructions = goalInstructionsRaw + ? goalInstructionsRaw.length > MAX_WATCHDOG_INSTRUCTIONS_CHARS + ? goalInstructionsRaw.slice(0, MAX_WATCHDOG_INSTRUCTIONS_CHARS) + : goalInstructionsRaw + : null; + const stoppedLeaves = Array.isArray(review.stoppedLeaves) + ? review.stoppedLeaves + .slice(0, MAX_WATCHDOG_LEAF_SUMMARIES) + .map((entry) => { + const leaf = parseObject(entry); + const identifier = asString(leaf.identifier, "").trim() || + asString(leaf.issueId, "").trim() || null; + const title = asString(leaf.title, "").trim() || null; + const status = asString(leaf.status, "").trim() || null; + return identifier || title || status ? { identifier, title, status } : null; + }) + .filter((entry): entry is { identifier: string | null; title: string | null; status: string | null } => + Boolean(entry)) + : []; + if (!watchedIssueId && !watchedIssueIdentifier && !stopFingerprint && !goalInstructions) return null; + return { + watchedIssueId, + watchedIssueIdentifier, + watchedIssueTitle, + stopFingerprint, + goalInstructions, + stoppedLeaves, + }; +} + function normalizePaperclipWakeExecutionStage(value: unknown): PaperclipWakeExecutionStage | null { const stage = parseObject(value); const wakeRoleRaw = asString(stage.wakeRole, "").trim().toLowerCase(); @@ -1281,6 +1353,8 @@ export function normalizePaperclipWakePayload(value: unknown): PaperclipWakePayl : []; const livenessContinuation = normalizePaperclipWakeLivenessContinuation(payload.livenessContinuation); const taskWatchdog = normalizePaperclipWakeTaskWatchdog(payload.taskWatchdog); + const taskWatchdogEverything = normalizePaperclipWakeTaskWatchdogEverything(payload.taskWatchdogEverything); + const taskWatchdogSelfReview = normalizePaperclipWakeTaskWatchdogSelfReview(payload.taskWatchdogSelfReview); const recovery = normalizePaperclipWakeRecovery(payload.recovery); const childIssueSummaries = Array.isArray(payload.childIssueSummaries) ? payload.childIssueSummaries @@ -1302,7 +1376,7 @@ export function normalizePaperclipWakePayload(value: unknown): PaperclipWakePayl const checkboxSelection = normalizePaperclipWakeCheckboxSelection(payload.checkboxSelection); const executionWorkspace = normalizePaperclipWakeExecutionWorkspace(payload.executionWorkspace); const agentMessage = normalizePaperclipWakeAgentMessage(payload.agentMessage); - if (comments.length === 0 && commentIds.length === 0 && annotationDeltas.length === 0 && childIssueSummaries.length === 0 && unresolvedBlockerIssueIds.length === 0 && unresolvedBlockerSummaries.length === 0 && !activeTreeHold && !executionStage && !continuationSummary && !planReviewContext && !livenessContinuation && !taskWatchdog && !checkboxSelection && !executionWorkspace && !agentMessage && !recovery && !normalizePaperclipWakeIssue(payload.issue)) { + if (comments.length === 0 && commentIds.length === 0 && annotationDeltas.length === 0 && childIssueSummaries.length === 0 && unresolvedBlockerIssueIds.length === 0 && unresolvedBlockerSummaries.length === 0 && !activeTreeHold && !executionStage && !continuationSummary && !planReviewContext && !livenessContinuation && !taskWatchdog && !taskWatchdogEverything && !taskWatchdogSelfReview && !checkboxSelection && !executionWorkspace && !agentMessage && !recovery && !normalizePaperclipWakeIssue(payload.issue)) { return null; } @@ -1322,6 +1396,8 @@ export function normalizePaperclipWakePayload(value: unknown): PaperclipWakePayl annotationDeltas, livenessContinuation, taskWatchdog, + taskWatchdogEverything, + taskWatchdogSelfReview, interactionKind: asString(payload.interactionKind, "").trim() || null, interactionStatus: asString(payload.interactionStatus, "").trim() || null, checkboxSelection, @@ -1837,6 +1913,63 @@ export function renderPaperclipWakePrompt( lines.push(""); } + if (normalized.taskWatchdogEverything) { + const setup = normalized.taskWatchdogEverything; + const issueLabel = setup.watchedIssueId ?? setup.watchedIssueIdentifier ?? "{issueId}"; + lines.push( + "", + "## Watchdog Everything (experimental)", + "", + "Watchdog-everything is enabled and this task has no watchdog yet. Your FIRST step, before doing the work, is to triage whether this task needs a completion-goal watchdog:", + "- Simple/trivial tasks (one obvious deliverable you will verify inline during this run) do NOT need one — skip the watchdog and just do the work.", + "- For anything more substantial, determine the completion goal first: the concrete, checkable criteria that prove the task is truly done (what to inspect, run, or observe).", + `- Register the goal on yourself as a self-mode watchdog: PUT ${"$"}PAPERCLIP_API_URL/api/issues/${issueLabel}/watchdog with JSON body {"agentId": "${setup.selfAgentId ?? ""}", "mode": "self", "instructions": ""}.`, + "- Then do the task normally. After the task's subtree stops (including after you mark it done), you will be woken once on this same task as its watchdog to verify the goal actually passed — no separate watchdog sub-task is created.", + "", + ); + } + + if (normalized.taskWatchdogSelfReview) { + const review = normalized.taskWatchdogSelfReview; + const watchedLabel = review.watchedIssueIdentifier ?? review.watchedIssueId ?? "this task"; + lines.push( + "", + "## Task Watchdog Self-Review (verify the completion goal)", + "", + `You are the self-mode watchdog for ${watchedLabel}${review.watchedIssueTitle ? ` ${review.watchedIssueTitle}` : ""}. Its subtree has no live execution path, so this wake is your verification pass on the task itself — no separate watchdog issue exists.`, + ); + if (review.goalInstructions) { + lines.push( + "", + "Completion goal to verify:", + markdownFencedText(review.goalInstructions), + ); + } else { + lines.push( + "", + "No explicit completion goal was recorded; verify the task delivered what its description and thread asked for.", + ); + } + lines.push( + "", + "Verification contract:", + "- Verify the goal concretely: inspect the deliverable and run the smallest check that proves it (or disproves it). Do not re-do finished work.", + "- Goal passes: post a short verification comment on this task stating what you checked, and keep (or set) the correct final disposition — usually `done`. Then stop; do not start new work.", + "- Goal fails or the work stalled without a valid waiting path: resume the work on this task, close the gap, and set a proper final disposition again.", + "- The stop is a legitimate waiting state (pending interaction, approval, or review): confirm that briefly in a comment and leave the waiting posture untouched.", + ); + if (review.stopFingerprint) { + lines.push(`- Stop fingerprint: ${review.stopFingerprint}`); + } + if (review.stoppedLeaves.length > 0) { + lines.push("", "Stopped leaves:"); + for (const leaf of review.stoppedLeaves) { + lines.push(`- ${leaf.identifier ?? "unknown"}${leaf.title ? ` ${leaf.title}` : ""}${leaf.status ? ` (${leaf.status})` : ""}`); + } + } + lines.push(""); + } + if (normalized.continuationSummary) { lines.push( "", diff --git a/packages/db/src/migrations/0196_issue_watchdog_mode.sql b/packages/db/src/migrations/0196_issue_watchdog_mode.sql new file mode 100644 index 00000000000..26ccdc83df4 --- /dev/null +++ b/packages/db/src/migrations/0196_issue_watchdog_mode.sql @@ -0,0 +1 @@ +ALTER TABLE "issue_watchdogs" ADD COLUMN IF NOT EXISTS "mode" text NOT NULL DEFAULT 'subtask'; diff --git a/packages/db/src/migrations/meta/_journal.json b/packages/db/src/migrations/meta/_journal.json index a62b0c9359d..2c394f799c3 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -1359,6 +1359,13 @@ "when": 1785170000001, "tag": "0195_built_in_agent_unique_marker", "breakpoints": true + }, + { + "idx": 196, + "version": "7", + "when": 1785170000002, + "tag": "0196_issue_watchdog_mode", + "breakpoints": true } ] } diff --git a/packages/db/src/schema/issue_watchdogs.ts b/packages/db/src/schema/issue_watchdogs.ts index ca5c1aa87cd..c472da140c8 100644 --- a/packages/db/src/schema/issue_watchdogs.ts +++ b/packages/db/src/schema/issue_watchdogs.ts @@ -14,6 +14,9 @@ export const issueWatchdogs = pgTable( watchdogAgentId: uuid("watchdog_agent_id").notNull().references(() => agents.id), instructions: text("instructions"), status: text("status").notNull().default("active"), + // "subtask" reviews stopped subtrees via a separate watchdog issue; + // "self" wakes the watchdog agent directly on the watched issue. + mode: text("mode").notNull().default("subtask"), watchdogIssueId: uuid("watchdog_issue_id").references(() => issues.id, { onDelete: "set null" }), lastObservedFingerprint: text("last_observed_fingerprint"), lastReviewedFingerprint: text("last_reviewed_fingerprint"), diff --git a/packages/shared/src/feature-catalog.ts b/packages/shared/src/feature-catalog.ts index 510679acac8..de8b0b51c08 100644 --- a/packages/shared/src/feature-catalog.ts +++ b/packages/shared/src/feature-catalog.ts @@ -104,6 +104,14 @@ export const INSTANCE_FEATURE_CATALOG: Record { }); }); + it("defaults watchdog everything off and accepts patches", () => { + const settings = instanceExperimentalSettingsSchema.parse({}); + expect(settings.enableWatchdogEverything).toBe(false); + + expect( + patchInstanceExperimentalSettingsSchema.parse({ + enableWatchdogEverything: true, + }), + ).toEqual({ + enableWatchdogEverything: true, + }); + }); + it("accepts apps patches", () => { expect( patchInstanceExperimentalSettingsSchema.parse({ diff --git a/packages/shared/src/validators/instance.ts b/packages/shared/src/validators/instance.ts index 72c8725daa8..f04f78ff762 100644 --- a/packages/shared/src/validators/instance.ts +++ b/packages/shared/src/validators/instance.ts @@ -47,6 +47,7 @@ export const instanceExperimentalSettingsSchema = z.object({ enableCases: z.boolean().default(false), enableConferenceRoomChat: z.boolean().default(false), enableTaskWatchdogs: z.boolean().default(false), + enableWatchdogEverything: z.boolean().default(false), enableIssuePlanDecompositions: z.boolean().default(false), enableExperimentalFileViewer: z.boolean().default(false), enableCloudSync: z.boolean().default(false), diff --git a/packages/shared/src/validators/issue.ts b/packages/shared/src/validators/issue.ts index ed935a8b38a..876a52d3c5d 100644 --- a/packages/shared/src/validators/issue.ts +++ b/packages/shared/src/validators/issue.ts @@ -497,6 +497,10 @@ export type CreateIssue = z.infer; export const upsertIssueWatchdogSchema = z.object({ agentId: z.string().uuid(), instructions: multilineTextSchema.optional().nullable(), + // "subtask" (default) reviews stopped subtrees through a separate watchdog + // issue; "self" wakes the watchdog agent directly on the watched issue + // (experimental "watchdog everything"). Omitted on update keeps the current mode. + mode: z.enum(["subtask", "self"]).optional(), }).strict(); export type UpsertIssueWatchdog = z.infer; diff --git a/server/src/__tests__/instance-settings-service.test.ts b/server/src/__tests__/instance-settings-service.test.ts index 8a384332272..00efc472a7c 100644 --- a/server/src/__tests__/instance-settings-service.test.ts +++ b/server/src/__tests__/instance-settings-service.test.ts @@ -37,6 +37,7 @@ describe("instance settings service", () => { enableIssuePlanDecompositions: true, enableExperimentalFileViewer: true, enableTaskWatchdogs: true, + enableWatchdogEverything: false, enableCloudSync: true, enableBuiltInAgents: true, enableBetaSkills: false, @@ -80,6 +81,14 @@ describe("instance settings service", () => { ).toBe(false); }); + it("defaults enableWatchdogEverything to false for empty and legacy stored settings", () => { + expect(normalizeExperimentalSettings(undefined).enableWatchdogEverything).toBe(false); + expect(normalizeExperimentalSettings({}).enableWatchdogEverything).toBe(false); + expect( + normalizeExperimentalSettings({ enableTaskWatchdogs: true }).enableWatchdogEverything, + ).toBe(false); + }); + it("defaults enableSmokeLab to false for empty and legacy stored settings", () => { expect(normalizeExperimentalSettings(undefined).enableSmokeLab).toBe(false); expect(normalizeExperimentalSettings({}).enableSmokeLab).toBe(false); diff --git a/server/src/__tests__/issue-watchdogs-routes.test.ts b/server/src/__tests__/issue-watchdogs-routes.test.ts index 3d2922f492b..b56cb19bb27 100644 --- a/server/src/__tests__/issue-watchdogs-routes.test.ts +++ b/server/src/__tests__/issue-watchdogs-routes.test.ts @@ -225,11 +225,12 @@ describeEmbeddedPostgres("issue watchdog routes", () => { watchdogAgentId: firstAgentId, instructions: "Check screenshots and tests.", status: "active", + mode: "subtask", }); const updated = await request(app) .put(`/api/issues/${issueId}/watchdog`) - .send({ agentId: secondAgentId, instructions: "Be skeptical." }); + .send({ agentId: secondAgentId, instructions: "Be skeptical.", mode: "self" }); expect(updated.status, JSON.stringify(updated.body)).toBe(200); expect(updated.body.id).toBe(created.body.id); @@ -238,11 +239,19 @@ describeEmbeddedPostgres("issue watchdog routes", () => { watchdogAgentId: secondAgentId, instructions: "Be skeptical.", status: "active", + mode: "self", }); + // Omitting mode on a later update keeps the stored mode. + const modeKept = await request(app) + .put(`/api/issues/${issueId}/watchdog`) + .send({ agentId: secondAgentId, instructions: "Be skeptical." }); + expect(modeKept.status, JSON.stringify(modeKept.body)).toBe(200); + expect(modeKept.body).toMatchObject({ id: created.body.id, mode: "self" }); + 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 }); + expect(read.body).toMatchObject({ id: created.body.id, watchdogAgentId: secondAgentId, mode: "self" }); const detail = await request(app).get(`/api/issues/${issueId}`); expect(detail.status, JSON.stringify(detail.body)).toBe(200); @@ -280,6 +289,7 @@ describeEmbeddedPostgres("issue watchdog routes", () => { expect(actionNames.filter((action) => action.startsWith("issue.watchdog_"))).toEqual([ "issue.watchdog_created", "issue.watchdog_updated", + "issue.watchdog_updated", "issue.watchdog_removed", ]); expect(actionNames).toContain("issue.task_watchdog_triggered"); diff --git a/server/src/__tests__/task-watchdogs-scheduler.test.ts b/server/src/__tests__/task-watchdogs-scheduler.test.ts index 9c407002a54..fe87c883183 100644 --- a/server/src/__tests__/task-watchdogs-scheduler.test.ts +++ b/server/src/__tests__/task-watchdogs-scheduler.test.ts @@ -144,13 +144,19 @@ describeEmbeddedPostgres("task watchdog scheduler", () => { }); } - async function seedWatchdog(companyId: string, issueId: string, agentId: string) { + async function seedWatchdog( + companyId: string, + issueId: string, + agentId: string, + overrides: Partial = {}, + ) { const [row] = await db.insert(issueWatchdogs).values({ companyId, issueId, watchdogAgentId: agentId, instructions: "Verify stopped work.", status: "active", + ...overrides, }).returning(); return row; } @@ -303,6 +309,68 @@ describeEmbeddedPostgres("task watchdog scheduler", () => { expect(watchdog?.triggerCount).toBe(2); }); + it("self mode posts the review on the watched issue and wakes the agent directly on it", async () => { + const companyId = await seedCompany(); + const agentId = await seedAgent(companyId); + const sourceId = await seedIssue(companyId, { + identifier: "WDOG-SELF", + status: "done", + assigneeAgentId: agentId, + }); + await seedWatchdog(companyId, sourceId, agentId, { + mode: "self", + instructions: "Verify the report exists and passes lint.", + }); + const { service, wakes } = createService(); + + const result = await service.reconcileTaskWatchdogs({ companyId }); + + expect(result).toMatchObject({ checked: 1, triggered: 1 }); + expect(result.watchdogIssueIds).toEqual([sourceId]); + + // No separate watchdog sub-task in self mode. + const watchdogIssues = await db + .select() + .from(issues) + .where(and(eq(issues.companyId, companyId), eq(issues.originKind, "task_watchdog"))); + expect(watchdogIssues).toHaveLength(0); + + expect(wakes).toHaveLength(1); + expect(wakes[0]?.agentId).toBe(agentId); + expect(wakes[0]?.opts?.reason).toBe("task_watchdog_self_review"); + expect(wakes[0]?.opts?.idempotencyKey).toMatch(/^task_watchdog:[^:]+:task_watchdog_stop:/); + expect(wakes[0]?.opts?.contextSnapshot).toMatchObject({ + issueId: sourceId, + taskId: sourceId, + wakeReason: "task_watchdog_self_review", + taskWatchdogSelfReview: { + watchedIssueId: sourceId, + watchedIssueIdentifier: "WDOG-SELF", + goalInstructions: "Verify the report exists and passes lint.", + }, + }); + // Self wakes must not carry the restricted task-watchdog mutation scope key. + expect((wakes[0]?.opts?.contextSnapshot as Record).taskWatchdog).toBeUndefined(); + + const comments = await db.select().from(issueComments).where(eq(issueComments.issueId, sourceId)); + expect(comments).toHaveLength(1); + expect(comments[0]?.body).toContain("Self-watchdog review triggered"); + expect(comments[0]?.body).toContain("Verify the report exists and passes lint."); + + const [watchdog] = await db.select().from(issueWatchdogs).where(eq(issueWatchdogs.issueId, sourceId)); + expect(watchdog?.lastObservedFingerprint).toMatch(/^task_watchdog_stop:/); + expect(watchdog?.lastReviewedFingerprint).toBe(watchdog?.lastObservedFingerprint); + expect(watchdog?.watchdogIssueId).toBeNull(); + expect(watchdog?.triggerCount).toBe(1); + + // The same stopped state is reviewed-at-trigger: no re-wake, no comment churn. + const second = await service.reconcileTaskWatchdogs({ companyId }); + expect(second).toMatchObject({ checked: 1, triggered: 0, alreadyReviewed: 1 }); + expect(wakes).toHaveLength(1); + const commentsAfter = await db.select().from(issueComments).where(eq(issueComments.issueId, sourceId)); + expect(commentsAfter).toHaveLength(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" }); diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index adaaa35aced..ddd40f28612 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -5518,6 +5518,7 @@ export function issueRoutes( const { watchdog, created } = await taskWatchdogsSvc.upsertForIssue(issue.companyId, issue.id, { agentId: req.body.agentId, instructions: req.body.instructions, + mode: req.body.mode, actor: { agentId: actor.agentId, userId: actor.actorType === "user" ? actor.actorId : null, @@ -5538,6 +5539,7 @@ export function issueRoutes( identifier: issue.identifier, watchdogId: watchdog.id, watchdogAgentId: watchdog.watchdogAgentId, + watchdogMode: watchdog.mode ?? "subtask", instructionsChanged: (existingWatchdog?.instructions ?? null) !== (watchdog.instructions ?? null), }, }); diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 968f1ffe7e1..cf51de59570 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -239,6 +239,7 @@ import { type SessionCompactionPolicy, } from "@paperclipai/adapter-utils"; import { + isAssignmentShapedPaperclipWakeReason, readPaperclipSkillSyncPreference, UNMANAGED_BACKGROUND_TASK_LIVENESS_REASON, UNMANAGED_BACKGROUND_TASK_STOP_REASON, @@ -4957,6 +4958,8 @@ export async function buildPaperclipWakePayload(input: { : [], executionStage: Object.keys(executionStage).length > 0 ? executionStage : null, taskWatchdog: (input.contextSnapshot.taskWatchdog ?? null) as unknown, + taskWatchdogEverything: (input.contextSnapshot.taskWatchdogEverything ?? null) as unknown, + taskWatchdogSelfReview: (input.contextSnapshot.taskWatchdogSelfReview ?? null) as unknown, skillTest: (input.contextSnapshot.paperclipSkillTest ?? null) as unknown, continuationSummary: safeContinuationSummary ? { @@ -12427,6 +12430,29 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) } else { delete context.paperclipSkillTest; } + // Experimental "watchdog everything": on assignment-shaped wakes for an + // issue with no active watchdog, the wake payload instructs the assignee + // to first triage whether the task needs a completion-goal watchdog and, + // if so, register it on itself in self mode before doing the work. + delete context.taskWatchdogEverything; + if ( + issueContext && + issueContext.assigneeAgentId === agent.id && + issueContext.originKind !== "task_watchdog" && + issueContext.status !== "done" && + issueContext.status !== "cancelled" && + isAssignmentShapedPaperclipWakeReason(readNonEmptyString(context.wakeReason)) && + (await instanceSettings.getExperimental()).enableWatchdogEverything === true + ) { + const activeWatchdog = await taskWatchdogs.getActiveForIssue(agent.companyId, issueContext.id); + if (!activeWatchdog) { + context.taskWatchdogEverything = { + watchedIssueId: issueContext.id, + watchedIssueIdentifier: issueContext.identifier, + selfAgentId: agent.id, + }; + } + } const paperclipWakePayload = await buildPaperclipWakePayload({ db, companyId: agent.companyId, diff --git a/server/src/services/instance-settings.ts b/server/src/services/instance-settings.ts index 0a99e87ba50..cb9a01c50f4 100644 --- a/server/src/services/instance-settings.ts +++ b/server/src/services/instance-settings.ts @@ -217,6 +217,7 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta enableIssuePlanDecompositions: parsed.data.enableIssuePlanDecompositions ?? false, enableExperimentalFileViewer: parsed.data.enableExperimentalFileViewer ?? false, enableTaskWatchdogs: parsed.data.enableTaskWatchdogs ?? false, + enableWatchdogEverything: parsed.data.enableWatchdogEverything ?? false, enableCloudSync: parsed.data.enableCloudSync ?? false, enableExternalObjects: parsed.data.enableExternalObjects ?? false, enableSmokeLab: parsed.data.enableSmokeLab ?? false, @@ -250,6 +251,7 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta enableCases: false, enableConferenceRoomChat: false, enableTaskWatchdogs: false, + enableWatchdogEverything: false, enableIssuePlanDecompositions: false, enableExperimentalFileViewer: false, enableCloudSync: false, diff --git a/server/src/services/task-watchdogs.ts b/server/src/services/task-watchdogs.ts index 44a95f0490b..48788bdf5d9 100644 --- a/server/src/services/task-watchdogs.ts +++ b/server/src/services/task-watchdogs.ts @@ -48,6 +48,8 @@ type ActorFields = { export type IssueWatchdogUpsertInput = { agentId: string; instructions?: string | null; + // Omitted keeps the existing mode on update and defaults to "subtask" on create. + mode?: "subtask" | "self" | null; actor?: ActorFields; }; @@ -232,6 +234,10 @@ function normalizeInstructions(value: string | null | undefined): string | null return trimmed.length > 0 ? trimmed : null; } +export function issueWatchdogMode(row: Pick): "subtask" | "self" { + return row.mode === "self" ? "self" : "subtask"; +} + export function summarizeIssueWatchdog(row: IssueWatchdogRow): IssueWatchdogSummary { return { id: row.id, @@ -240,6 +246,7 @@ export function summarizeIssueWatchdog(row: IssueWatchdogRow): IssueWatchdogSumm watchdogAgentId: row.watchdogAgentId, instructions: row.instructions, status: row.status as IssueWatchdogSummary["status"], + mode: issueWatchdogMode(row), watchdogIssueId: row.watchdogIssueId, lastObservedFingerprint: row.lastObservedFingerprint, lastReviewedFingerprint: row.lastReviewedFingerprint, @@ -650,6 +657,77 @@ function buildStoppedFingerprintComment(input: { ].filter((line) => line !== "").join("\n"); } +function buildSelfReviewComment(input: { + stopFingerprint: string; + stoppedLeaves: TaskWatchdogStoppedLeaf[]; + pendingInteractionsByIssueId: TaskWatchdogPendingInteractionsByIssueId; + goalInstructions: string | null; +}) { + const shortId = (id: string) => id.length > 8 ? `${id.slice(0, 8)}…` : id; + const leafLines = input.stoppedLeaves.slice(0, 12).map((leaf) => { + const interactionKinds = new Map( + (input.pendingInteractionsByIssueId[leaf.issueId] ?? []).map((wait) => [wait.id, wait.kind]), + ); + const waits = [ + ...leaf.pendingInteractionIds.map((id) => `${interactionKinds.get(id) ?? "interaction"} ${shortId(id)}`), + ...leaf.pendingApprovalIds.map((id) => `approval ${shortId(id)}`), + ]; + return `- ${leaf.identifier ?? leaf.issueId}: ${leaf.status}${waits.length > 0 ? ` (pending ${waits.join(", ")})` : ""}`; + }); + const more = input.stoppedLeaves.length > leafLines.length + ? `\n- ...and ${input.stoppedLeaves.length - leafLines.length} more stopped leaves` + : ""; + return [ + "Self-watchdog review triggered: this task's subtree has no live execution path. The assignee is being woken on this task to verify the completion goal.", + "", + `Stopped fingerprint: \`${input.stopFingerprint}\``, + ...(input.goalInstructions + ? ["", "Completion goal under verification:", markdownQuote(input.goalInstructions)] + : []), + "", + "Stopped leaves:", + ...(leafLines.length > 0 ? leafLines : ["- No open leaf issues (subtree is terminal)."]), + more, + ].filter((line) => line !== "").join("\n"); +} + +function markdownQuote(text: string) { + return text.split("\n").map((line) => `> ${line}`).join("\n"); +} + +// Self mode wakes the watchdog agent directly on the watched issue. This +// context deliberately does NOT use the `taskWatchdog` key: that key activates +// the restricted task-watchdog mutation scope for the run, which is wrong here +// — in self mode the watchdog agent is the issue's own assignee and operates +// under its normal permissions. +function selfWatchdogWakeContext(input: { + watchdog: IssueWatchdogRow; + sourceIssue: IssueRow; + classification: Extract; +}) { + return { + issueId: input.sourceIssue.id, + taskId: input.sourceIssue.id, + wakeReason: "task_watchdog_self_review", + source: "task_watchdog_self", + taskWatchdogSelfReview: { + watchdogId: input.watchdog.id, + watchedIssueId: input.sourceIssue.id, + watchedIssueIdentifier: input.sourceIssue.identifier, + watchedIssueTitle: input.sourceIssue.title, + stopFingerprint: input.classification.stopFingerprint, + goalInstructions: input.watchdog.instructions, + stoppedLeaves: input.classification.stoppedLeaves, + pendingInteractions: input.classification.pendingInteractionsByIssueId, + pendingApprovals: Object.fromEntries(Object.entries(input.classification.stopSnapshot.waitsByIssueId) + .filter(([, waits]) => waits.pendingApprovalIds.length > 0) + .map(([issueId, waits]) => [issueId, waits.pendingApprovalIds])), + }, + resumeIntent: true, + followUpRequested: true, + }; +} + function stoppedFingerprintMetadata(input: { sourceIssueId: string; stopFingerprint: string; @@ -792,6 +870,7 @@ async function updateIssueWatchdogRow( .set({ watchdogAgentId: input.agentId, instructions: normalizeInstructions(input.instructions), + mode: input.mode ?? issueWatchdogMode(existing), status: "active", updatedByAgentId: input.actor?.agentId ?? null, updatedByUserId: input.actor?.userId ?? null, @@ -831,6 +910,7 @@ export async function upsertIssueWatchdogForIssue( issueId, watchdogAgentId: input.agentId, instructions: normalizeInstructions(input.instructions), + mode: input.mode ?? "subtask", status: "active", createdByAgentId: input.actor?.agentId ?? null, createdByUserId: input.actor?.userId ?? null, @@ -1435,6 +1515,93 @@ export function taskWatchdogService(db: Db, deps: TaskWatchdogServiceDeps = {}) return created; } + // Self mode: no watchdog sub-task. Post the review context on the watched + // issue itself and wake the watchdog agent (normally the assignee) directly + // on it to verify the completion goal. The fingerprint is marked reviewed at + // trigger time so each stopped state produces exactly one verification wake + // — a simple task that genuinely finished never churns. + async function triggerSelfReview(input: { + watchdog: IssueWatchdogRow; + sourceIssue: IssueRow; + classification: Extract; + runId: string | null; + }) { + const { watchdog, sourceIssue, classification } = input; + await issuesSvc.addComment( + sourceIssue.id, + buildSelfReviewComment({ + stopFingerprint: classification.stopFingerprint, + stoppedLeaves: classification.stoppedLeaves, + pendingInteractionsByIssueId: classification.pendingInteractionsByIssueId, + goalInstructions: watchdog.instructions, + }), + { runId: input.runId }, + { + authorType: "system", + metadata: stoppedFingerprintMetadata({ + sourceIssueId: sourceIssue.id, + stopFingerprint: classification.stopFingerprint, + waitsByIssueId: classification.stopSnapshot.waitsByIssueId, + resumed: true, + }), + }, + ); + + const now = new Date(); + await db + .update(issueWatchdogs) + .set({ + lastObservedFingerprint: classification.stopFingerprint, + lastObservedStopSnapshot: classification.stopSnapshot, + lastReviewedFingerprint: classification.stopFingerprint, + lastReviewedStopSnapshot: classification.stopSnapshot, + lastTriggeredAt: now, + triggerCount: sql`${issueWatchdogs.triggerCount} + 1`, + updatedAt: now, + }) + .where(eq(issueWatchdogs.id, watchdog.id)); + + await logActivity(db, { + companyId: sourceIssue.companyId, + actorType: "system", + actorId: "system", + agentId: watchdog.watchdogAgentId, + runId: input.runId, + action: "issue.task_watchdog_triggered", + entityType: "issue", + entityId: sourceIssue.id, + details: { + source: "task_watchdogs.evaluate", + mode: "self", + watchdogId: watchdog.id, + stopFingerprint: classification.stopFingerprint, + stopSnapshot: classification.stopSnapshot, + stoppedLeaves: classification.stoppedLeaves, + }, + }); + + const context = selfWatchdogWakeContext({ watchdog, sourceIssue, classification }); + const wake = deps.enqueueWakeup + ? await deps.enqueueWakeup(watchdog.watchdogAgentId, { + source: "automation", + triggerDetail: "system", + reason: "task_watchdog_self_review", + payload: context, + contextSnapshot: context, + idempotencyKey: taskWatchdogWakeIdempotencyKey(watchdog.id, classification.stopFingerprint), + requestedByActorType: "system", + requestedByActorId: null, + }) + : null; + + return { + state: "triggered" as const, + classification, + watchdogIssueId: sourceIssue.id, + wakeupRunId: wake?.id ?? null, + }; + } + async function evaluateWatchdog(row: IssueWatchdogRow, opts: { runId?: string | null } = {}) { const watchdog = await markTerminalWatchdogIssueReviewed(row, opts); const sourceIssue = await db @@ -1452,6 +1619,15 @@ export function taskWatchdogService(db: Db, deps: TaskWatchdogServiceDeps = {}) return { state: classification.state, reason: classification.reason, classification }; } + if (issueWatchdogMode(watchdog) === "self") { + return triggerSelfReview({ + watchdog, + sourceIssue, + classification, + runId: opts.runId ?? null, + }); + } + const existingWatchdogIssueId = watchdog.watchdogIssueId ?? (await findTaskWatchdogIssue( watchdog.companyId, sourceIssue.id, diff --git a/ui/src/pages/InstanceExperimentalSettings.test.tsx b/ui/src/pages/InstanceExperimentalSettings.test.tsx index 31ad7b272e4..6ac6c26f55b 100644 --- a/ui/src/pages/InstanceExperimentalSettings.test.tsx +++ b/ui/src/pages/InstanceExperimentalSettings.test.tsx @@ -86,6 +86,7 @@ function defaultExperimentalSettings(): InstanceExperimentalSettingsPayload { enableDecisions: false, enableGoalsSidebarLink: false, enableTaskWatchdogs: false, + enableWatchdogEverything: false, enableCloudSync: false, enableServerInfoDebugView: false, enableSmokeLab: false, diff --git a/ui/src/pages/InstanceExperimentalSettings.tsx b/ui/src/pages/InstanceExperimentalSettings.tsx index 696ac104157..83513271521 100644 --- a/ui/src/pages/InstanceExperimentalSettings.tsx +++ b/ui/src/pages/InstanceExperimentalSettings.tsx @@ -368,6 +368,7 @@ export function InstanceExperimentalSettings() { const enableExperimentalFileViewer = experimentalQuery.data?.enableExperimentalFileViewer === true; const enableTaskWatchdogs = experimentalQuery.data?.enableTaskWatchdogs === true; + const enableWatchdogEverything = experimentalQuery.data?.enableWatchdogEverything === true; const enableCloudSync = experimentalQuery.data?.enableCloudSync === true; const enableExternalObjects = experimentalQuery.data?.enableExternalObjects === true; const enableBuiltInAgents = experimentalQuery.data?.enableBuiltInAgents === true; @@ -686,6 +687,16 @@ export function InstanceExperimentalSettings() { ariaLabel="Toggle task watchdogs experimental setting" /> + toggleMutation.mutate({ enableWatchdogEverything: checked })} + disabled={toggleMutation.isPending} + managed={managedKeys.enableWatchdogEverything} + ariaLabel="Toggle watchdog everything experimental setting" + /> +