mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-26 18:31:34 +01:00
feat(watchdogs): add experimental self-mode watchdogs
- Add enableWatchdogEverything experimental instance setting and UI toggle. - Add issue_watchdogs.mode and accept mode in the watchdog endpoint. - Wake task assignees directly for stopped self-mode watchdogs. - Add first-instruction guidance for assignment-shaped wakes. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
9574cad3e8
commit
7b4722e26c
21 files changed
+554
-4
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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 ?? "<your agent id>"}", "mode": "self", "instructions": "<the completion goal>"}.`,
|
||||
"- 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(
|
||||
"",
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "issue_watchdogs" ADD COLUMN IF NOT EXISTS "mode" text NOT NULL DEFAULT 'subtask';
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"),
|
||||
|
||||
@@ -104,6 +104,14 @@ export const INSTANCE_FEATURE_CATALOG: Record<InstanceFeatureKey, FeatureCatalog
|
||||
cloudDefault: false,
|
||||
selfHostedDefault: false,
|
||||
},
|
||||
enableWatchdogEverything: {
|
||||
title: "Watchdog Everything",
|
||||
description:
|
||||
"On new task assignments, instruct the assignee to triage whether the task needs a completion goal and, if so, register it as a self-mode watchdog on the task itself; the watchdog then wakes the same agent directly on the task to verify the goal — no separate watchdog sub-task.",
|
||||
tier: "managed",
|
||||
cloudDefault: false,
|
||||
selfHostedDefault: false,
|
||||
},
|
||||
enableIssuePlanDecompositions: {
|
||||
title: "Task Plan Decomposition Panel",
|
||||
description: "Show accepted-plan decomposition history on task detail pages.",
|
||||
|
||||
@@ -655,6 +655,7 @@ export type {
|
||||
IssueAttachment,
|
||||
IssueLabel,
|
||||
IssueWatchdog,
|
||||
IssueWatchdogMode,
|
||||
IssueWatchdogStatus,
|
||||
IssueWatchdogSummary,
|
||||
} from "./issue.js";
|
||||
|
||||
@@ -53,6 +53,7 @@ export interface InstanceExperimentalSettings {
|
||||
enableCases: boolean;
|
||||
enableConferenceRoomChat: boolean;
|
||||
enableTaskWatchdogs: boolean;
|
||||
enableWatchdogEverything: boolean;
|
||||
enableIssuePlanDecompositions: boolean;
|
||||
enableExperimentalFileViewer: boolean;
|
||||
enableCloudSync: boolean;
|
||||
|
||||
@@ -683,6 +683,14 @@ export interface IssueExecutionDecision {
|
||||
|
||||
export type IssueWatchdogStatus = "active" | "disabled";
|
||||
|
||||
// "subtask": stopped subtrees are reviewed through a separate reusable
|
||||
// watchdog issue assigned to the watchdog agent (default, original behavior).
|
||||
// "self": the watchdog agent is woken directly on the watched issue itself and
|
||||
// reviews/verifies in place — no watchdog sub-task is created (used by the
|
||||
// experimental "watchdog everything" flow where agents self-watchdog their
|
||||
// own tasks).
|
||||
export type IssueWatchdogMode = "subtask" | "self";
|
||||
|
||||
export interface IssueWatchdogSummary {
|
||||
id: string;
|
||||
companyId: string;
|
||||
@@ -690,6 +698,7 @@ export interface IssueWatchdogSummary {
|
||||
watchdogAgentId: string;
|
||||
instructions: string | null;
|
||||
status: IssueWatchdogStatus;
|
||||
mode?: IssueWatchdogMode;
|
||||
watchdogIssueId: string | null;
|
||||
lastObservedFingerprint: string | null;
|
||||
lastReviewedFingerprint: string | null;
|
||||
|
||||
@@ -130,6 +130,19 @@ describe("instance experimental settings validators", () => {
|
||||
});
|
||||
});
|
||||
|
||||
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({
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -497,6 +497,10 @@ export type CreateIssue = z.infer<typeof createIssueSchema>;
|
||||
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<typeof upsertIssueWatchdogSchema>;
|
||||
|
||||
Reference in new issue
Block a user