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:
DottaandPaperclip committed 2026-07-29 19:07:29 +00:00
1 parent 9574cad3e8
commit 7b4722e26c
21 files changed
+554 -4

No files matched your search

@@ -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);
@@ -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");
@@ -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<typeof issueWatchdogs.$inferInsert> = {},
) {
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<string, unknown>).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" });
+2
View File
@@ -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),
},
});
+26
View File
@@ -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,
+2
View File
@@ -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,
+176
View File
@@ -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<IssueWatchdogRow, "mode">): "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<TaskWatchdogClassifierResult, { state: "stopped" }>;
}) {
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<TaskWatchdogClassifierResult, { state: "stopped" }>;
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,