feat(watchdogs): split same-task watchdogs from watchdog everything

Dotta wants the two experimental concerns separated: enableSameTaskWatchdogs
now gates same-task (self mode) watchdogs as a manual capability — the
watchdog editor gets a 'Run on this task' toggle that locks the watchdog to
the assignee — while enableWatchdogEverything keeps only the automatic
first-instruction goal-setting flow (and still implies same-task capability).
Switching a watchdog to self mode via PUT /issues/:id/watchdog now requires
one of the two toggles; updates that keep an existing self watchdog in self
mode stay allowed so disabling the toggles never bricks edits.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
DottaandClaude Fable 5 committed 2026-07-31 05:37:22 +00:00
1 parent 026fc3c4e0
commit fcfd24329c
11 files changed
+151 -4

No files matched your search

+9 -1
View File
@@ -104,10 +104,18 @@ export const INSTANCE_FEATURE_CATALOG: Record<InstanceFeatureKey, FeatureCatalog
cloudDefault: false,
selfHostedDefault: false,
},
enableSameTaskWatchdogs: {
title: "Same-Task Watchdogs",
description:
"Allow a task's watchdog to run on the task itself: the review posts on the watched task and wakes its assignee directly instead of going through a separate watchdog sub-task. Adds a same-task option to the watchdog editor; nothing is registered automatically.",
tier: "managed",
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.",
"On new task assignments, instruct the assignee to triage whether the task needs a completion goal and, if so, register it as a same-task watchdog on itself; the watchdog then wakes the same agent directly on the task to verify the goal — no separate watchdog sub-task. Implies same-task watchdog capability even when that toggle is off.",
tier: "managed",
cloudDefault: false,
selfHostedDefault: false,
+1
View File
@@ -53,6 +53,7 @@ export interface InstanceExperimentalSettings {
enableCases: boolean;
enableConferenceRoomChat: boolean;
enableTaskWatchdogs: boolean;
enableSameTaskWatchdogs: boolean;
enableWatchdogEverything: boolean;
enableIssuePlanDecompositions: boolean;
enableExperimentalFileViewer: boolean;
@@ -143,6 +143,19 @@ describe("instance experimental settings validators", () => {
});
});
it("defaults same-task watchdogs off and accepts patches", () => {
const settings = instanceExperimentalSettingsSchema.parse({});
expect(settings.enableSameTaskWatchdogs).toBe(false);
expect(
patchInstanceExperimentalSettingsSchema.parse({
enableSameTaskWatchdogs: true,
}),
).toEqual({
enableSameTaskWatchdogs: 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),
enableSameTaskWatchdogs: z.boolean().default(false),
enableWatchdogEverything: z.boolean().default(false),
enableIssuePlanDecompositions: z.boolean().default(false),
enableExperimentalFileViewer: z.boolean().default(false),
@@ -37,6 +37,7 @@ describe("instance settings service", () => {
enableIssuePlanDecompositions: true,
enableExperimentalFileViewer: true,
enableTaskWatchdogs: true,
enableSameTaskWatchdogs: false,
enableWatchdogEverything: false,
enableCloudSync: true,
enableBuiltInAgents: true,
@@ -89,6 +90,14 @@ describe("instance settings service", () => {
).toBe(false);
});
it("defaults enableSameTaskWatchdogs to false for empty and legacy stored settings", () => {
expect(normalizeExperimentalSettings(undefined).enableSameTaskWatchdogs).toBe(false);
expect(normalizeExperimentalSettings({}).enableSameTaskWatchdogs).toBe(false);
expect(
normalizeExperimentalSettings({ enableWatchdogEverything: true }).enableSameTaskWatchdogs,
).toBe(false);
});
it("defaults enableSmokeLab to false for empty and legacy stored settings", () => {
expect(normalizeExperimentalSettings(undefined).enableSmokeLab).toBe(false);
expect(normalizeExperimentalSettings({}).enableSmokeLab).toBe(false);
@@ -26,6 +26,7 @@ import {
import { errorHandler } from "../middleware/index.js";
import { issueRoutes } from "../routes/issues.js";
import { ensureHumanRoleDefaultGrants } from "../services/principal-access-compatibility.js";
import { instanceSettingsService } from "../services/instance-settings.js";
import { taskWatchdogService } from "../services/task-watchdogs.js";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
@@ -218,6 +219,7 @@ describeEmbeddedPostgres("issue watchdog routes", () => {
assigneeAgentId: secondAgentId,
});
const app = createApp(companyId);
await instanceSettingsService(db).updateExperimental({ enableSameTaskWatchdogs: true });
const created = await request(app)
.put(`/api/issues/${issueId}/watchdog`)
@@ -305,6 +307,47 @@ describeEmbeddedPostgres("issue watchdog routes", () => {
expect(actionNames).toContain("issue.task_watchdog_triggered");
});
it("gates switching a watchdog to self mode on the same-task experimental toggles", async () => {
const companyId = await seedCompany();
const assigneeId = await seedAgent(companyId, { name: "Assignee Watchdog" });
const issueId = await seedIssue(companyId, {
identifier: "WDOG-GATE",
issueNumber: 77,
assigneeAgentId: assigneeId,
});
const app = createApp(companyId);
const settings = instanceSettingsService(db);
await settings.updateExperimental({ enableSameTaskWatchdogs: false, enableWatchdogEverything: false });
// Self mode is rejected while both toggles are off; subtask mode stays available.
const rejected = await request(app)
.put(`/api/issues/${issueId}/watchdog`)
.send({ agentId: assigneeId, mode: "self" });
expect(rejected.status, JSON.stringify(rejected.body)).toBe(422);
const subtask = await request(app)
.put(`/api/issues/${issueId}/watchdog`)
.send({ agentId: assigneeId, mode: "subtask" });
expect(subtask.status, JSON.stringify(subtask.body)).toBe(200);
expect(subtask.body).toMatchObject({ mode: "subtask" });
// Watchdog Everything alone implies the same-task capability.
await settings.updateExperimental({ enableWatchdogEverything: true });
const allowed = await request(app)
.put(`/api/issues/${issueId}/watchdog`)
.send({ agentId: assigneeId, mode: "self" });
expect(allowed.status, JSON.stringify(allowed.body)).toBe(200);
expect(allowed.body).toMatchObject({ mode: "self" });
// Turning the toggles back off does not brick edits that keep self mode.
await settings.updateExperimental({ enableWatchdogEverything: false });
const kept = await request(app)
.put(`/api/issues/${issueId}/watchdog`)
.send({ agentId: assigneeId, instructions: "Still self.", mode: "self" });
expect(kept.status, JSON.stringify(kept.body)).toBe(200);
expect(kept.body).toMatchObject({ mode: "self", instructions: "Still self." });
});
it("handles concurrent first-time watchdog upserts without duplicate-key failures", async () => {
const companyId = await seedCompany();
const issueId = await seedIssue(companyId, { identifier: "WDOG-RACE", issueNumber: 99 });
+14
View File
@@ -5517,6 +5517,20 @@ export function issueRoutes(
const actor = getActorInfo(req);
const existingWatchdog = await taskWatchdogsSvc.getActiveForIssue(issue.companyId, issue.id);
// Same-task (self mode) watchdogs are experimental: switching a watchdog
// to self mode requires the Same-Task Watchdogs toggle (or Watchdog
// Everything, which registers self-mode watchdogs automatically). Updates
// that keep an already-self watchdog in self mode stay allowed so
// disabling the toggles never bricks existing watchdog edits.
if (req.body.mode === "self" && existingWatchdog?.mode !== "self") {
const experimental = await instanceSettings.getExperimental();
if (experimental.enableSameTaskWatchdogs !== true && experimental.enableWatchdogEverything !== true) {
throw unprocessable(
"Same-task watchdogs are disabled. Enable the Same-Task Watchdogs (or Watchdog Everything) experimental setting to run a watchdog on the task itself.",
{ issueId: issue.id },
);
}
}
const { watchdog, created } = await taskWatchdogsSvc.upsertForIssue(issue.companyId, issue.id, {
agentId: req.body.agentId,
instructions: req.body.instructions,
+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,
enableSameTaskWatchdogs: parsed.data.enableSameTaskWatchdogs ?? false,
enableWatchdogEverything: parsed.data.enableWatchdogEverything ?? false,
enableCloudSync: parsed.data.enableCloudSync ?? false,
enableExternalObjects: parsed.data.enableExternalObjects ?? false,
@@ -251,6 +252,7 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta
enableCases: false,
enableConferenceRoomChat: false,
enableTaskWatchdogs: false,
enableSameTaskWatchdogs: false,
enableWatchdogEverything: false,
enableIssuePlanDecompositions: false,
enableExperimentalFileViewer: false,
@@ -163,6 +163,11 @@ export function IssueProperties({
queryFn: () => instanceSettingsApi.getExperimental(),
});
const taskWatchdogsEnabled = experimentalSettings?.enableTaskWatchdogs === true;
// Watchdog Everything registers same-task watchdogs automatically, so it
// implies the same-task capability even when that toggle is off.
const sameTaskWatchdogsEnabled =
experimentalSettings?.enableSameTaskWatchdogs === true ||
experimentalSettings?.enableWatchdogEverything === true;
const [assigneeOpen, setAssigneeOpen] = useState(false);
const [assigneeSearch, setAssigneeSearch] = useState("");
/** When a run is live, a selection is staged here until the operator confirms
@@ -205,6 +210,9 @@ export function IssueProperties({
const [watchdogOpen, setWatchdogOpen] = useState(false);
const [watchdogAgentInput, setWatchdogAgentInput] = useState(issue.watchdog?.watchdogAgentId ?? "");
const [watchdogInstructionsInput, setWatchdogInstructionsInput] = useState(issue.watchdog?.instructions ?? "");
const [watchdogModeInput, setWatchdogModeInput] = useState<"subtask" | "self">(
issue.watchdog?.mode === "self" ? "self" : "subtask",
);
const normalizedBlockedBySearch = blockedBySearch.trim();
useEffect(() => {
@@ -803,7 +811,8 @@ export function IssueProperties({
if (watchdogOpen) return;
setWatchdogAgentInput(issue.watchdog?.watchdogAgentId ?? "");
setWatchdogInstructionsInput(issue.watchdog?.instructions ?? "");
}, [issue.watchdog?.watchdogAgentId, issue.watchdog?.instructions, watchdogOpen]);
setWatchdogModeInput(issue.watchdog?.mode === "self" ? "self" : "subtask");
}, [issue.watchdog?.watchdogAgentId, issue.watchdog?.instructions, issue.watchdog?.mode, watchdogOpen]);
const watchdogAgentOptions = useMemo<InlineEntityOption[]>(
() =>
@@ -817,7 +826,7 @@ export function IssueProperties({
[agents],
);
const upsertWatchdog = useMutation({
mutationFn: (data: { agentId: string; instructions: string | null }) =>
mutationFn: (data: { agentId: string; instructions: string | null; mode: "subtask" | "self" }) =>
issuesApi.upsertWatchdog(issue.id, data),
onSuccess: (watchdog) => {
queryClient.setQueryData<Issue>(queryKeys.issues.detail(issue.id), (current) =>
@@ -842,6 +851,7 @@ export function IssueProperties({
upsertWatchdog.mutate({
agentId: watchdogAgentInput,
instructions: watchdogInstructionsInput.trim() || null,
mode: watchdogModeInput,
});
};
const removeWatchdog = () => {
@@ -852,6 +862,7 @@ export function IssueProperties({
}
setWatchdogAgentInput("");
setWatchdogInstructionsInput("");
setWatchdogModeInput("subtask");
};
const watchdogMutationError =
upsertWatchdog.error instanceof Error
@@ -869,6 +880,9 @@ export function IssueProperties({
return agent ? <AgentIcon icon={agent.icon} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" /> : null;
})()}
<span className="shrink-0 max-w-40 truncate">{agentName(issue.watchdog.watchdogAgentId)}</span>
{issue.watchdog.mode === "self" ? (
<span className="shrink-0 text-xs text-muted-foreground">(on this task)</span>
) : null}
{issue.watchdog.instructions?.trim() ? (
<span className="min-w-0 flex-1 truncate text-muted-foreground">
· {issue.watchdog.instructions.trim()}
@@ -881,8 +895,37 @@ export function IssueProperties({
) : (
<span className="text-sm text-muted-foreground">None</span>
);
const watchdogSelfMode = watchdogModeInput === "self";
const watchdogContent = (
<div className="space-y-3 p-2">
{sameTaskWatchdogsEnabled || watchdogSelfMode ? (
<div className="flex items-start justify-between gap-3">
<div className="space-y-0.5">
<div className="text-xs font-medium text-foreground">Run on this task</div>
<div className="text-xs text-muted-foreground">
Reviews post on this task and wake the assignee directly — no watchdog sub-task.
</div>
</div>
<ToggleSwitch
checked={watchdogSelfMode}
onCheckedChange={(checked) => {
setWatchdogModeInput(checked ? "self" : "subtask");
if (checked) setWatchdogAgentInput(issue.assigneeAgentId ?? "");
}}
aria-label="Toggle same-task watchdog mode"
/>
</div>
) : null}
{watchdogSelfMode ? (
<div className="space-y-1.5">
<div className="text-xs font-medium text-foreground">Watchdog agent</div>
<div className="text-xs text-muted-foreground">
{issue.assigneeAgentId
? `Runs as the task assignee: ${agentName(issue.assigneeAgentId)}`
: "Assign this task to an agent first — same-task watchdogs run as the assignee."}
</div>
</div>
) : (
<div className="space-y-1.5">
<div className="text-xs font-medium text-foreground">Watchdog agent</div>
<InlineEntitySelector
@@ -914,6 +957,7 @@ export function IssueProperties({
}}
/>
</div>
)}
<div className="space-y-1.5">
<div className="text-xs font-medium text-foreground">
Instructions <span className="font-normal text-muted-foreground">(optional)</span>
@@ -86,6 +86,7 @@ function defaultExperimentalSettings(): InstanceExperimentalSettingsPayload {
enableDecisions: false,
enableGoalsSidebarLink: false,
enableTaskWatchdogs: false,
enableSameTaskWatchdogs: false,
enableWatchdogEverything: false,
enableCloudSync: false,
enableServerInfoDebugView: false,
+12 -1
View File
@@ -368,6 +368,7 @@ export function InstanceExperimentalSettings() {
const enableExperimentalFileViewer =
experimentalQuery.data?.enableExperimentalFileViewer === true;
const enableTaskWatchdogs = experimentalQuery.data?.enableTaskWatchdogs === true;
const enableSameTaskWatchdogs = experimentalQuery.data?.enableSameTaskWatchdogs === true;
const enableWatchdogEverything = experimentalQuery.data?.enableWatchdogEverything === true;
const enableCloudSync = experimentalQuery.data?.enableCloudSync === true;
const enableExternalObjects = experimentalQuery.data?.enableExternalObjects === true;
@@ -687,9 +688,19 @@ export function InstanceExperimentalSettings() {
ariaLabel="Toggle task watchdogs experimental setting"
/>
<ExperimentalToggleCard
title="Same-Task Watchdogs"
description="Allow a task's watchdog to run on the task itself: the review posts on the watched task and wakes its assignee directly instead of going through a separate watchdog sub-task. Adds a same-task option to the watchdog editor; nothing is registered automatically."
checked={enableSameTaskWatchdogs}
onCheckedChange={(checked) => toggleMutation.mutate({ enableSameTaskWatchdogs: checked })}
disabled={toggleMutation.isPending}
managed={managedKeys.enableSameTaskWatchdogs}
ariaLabel="Toggle same-task watchdogs experimental setting"
/>
<ExperimentalToggleCard
title="Watchdog Everything"
description="On new task assignments, instruct the assignee to first decide 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 passed — no separate watchdog sub-task is created. Simple tasks are allowed to skip the watchdog."
description="On new task assignments, instruct the assignee to first decide whether the task needs a completion goal and, if so, register it as a same-task watchdog on itself. The watchdog then wakes the same agent directly on the task to verify the goal passed — no separate watchdog sub-task is created. Simple tasks are allowed to skip the watchdog. Implies same-task watchdog capability even when that toggle is off."
checked={enableWatchdogEverything}
onCheckedChange={(checked) => toggleMutation.mutate({ enableWatchdogEverything: checked })}
disabled={toggleMutation.isPending}