diff --git a/packages/shared/src/feature-catalog.ts b/packages/shared/src/feature-catalog.ts index de8b0b51c08..0c1c36649bf 100644 --- a/packages/shared/src/feature-catalog.ts +++ b/packages/shared/src/feature-catalog.ts @@ -104,10 +104,18 @@ export const INSTANCE_FEATURE_CATALOG: Record { }); }); + 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({ diff --git a/packages/shared/src/validators/instance.ts b/packages/shared/src/validators/instance.ts index f04f78ff762..1941b63d24d 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), + enableSameTaskWatchdogs: z.boolean().default(false), enableWatchdogEverything: z.boolean().default(false), enableIssuePlanDecompositions: z.boolean().default(false), enableExperimentalFileViewer: z.boolean().default(false), diff --git a/server/src/__tests__/instance-settings-service.test.ts b/server/src/__tests__/instance-settings-service.test.ts index 00efc472a7c..ef3316f1eee 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, + 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); diff --git a/server/src/__tests__/issue-watchdogs-routes.test.ts b/server/src/__tests__/issue-watchdogs-routes.test.ts index d76c17356ae..8873e0c19a7 100644 --- a/server/src/__tests__/issue-watchdogs-routes.test.ts +++ b/server/src/__tests__/issue-watchdogs-routes.test.ts @@ -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 }); diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 2c4917b8594..ee3b231e01f 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -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, diff --git a/server/src/services/instance-settings.ts b/server/src/services/instance-settings.ts index cb9a01c50f4..61b9637f441 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, + 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, diff --git a/ui/src/components/issue-properties/IssueProperties.tsx b/ui/src/components/issue-properties/IssueProperties.tsx index acf0131eae2..0e6446328dc 100644 --- a/ui/src/components/issue-properties/IssueProperties.tsx +++ b/ui/src/components/issue-properties/IssueProperties.tsx @@ -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( () => @@ -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(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 ? : null; })()} {agentName(issue.watchdog.watchdogAgentId)} + {issue.watchdog.mode === "self" ? ( + (on this task) + ) : null} {issue.watchdog.instructions?.trim() ? ( · {issue.watchdog.instructions.trim()} @@ -881,8 +895,37 @@ export function IssueProperties({ ) : ( None ); + const watchdogSelfMode = watchdogModeInput === "self"; const watchdogContent = (
+ {sameTaskWatchdogsEnabled || watchdogSelfMode ? ( +
+
+
Run on this task
+
+ Reviews post on this task and wake the assignee directly — no watchdog sub-task. +
+
+ { + setWatchdogModeInput(checked ? "self" : "subtask"); + if (checked) setWatchdogAgentInput(issue.assigneeAgentId ?? ""); + }} + aria-label="Toggle same-task watchdog mode" + /> +
+ ) : null} + {watchdogSelfMode ? ( +
+
Watchdog agent
+
+ {issue.assigneeAgentId + ? `Runs as the task assignee: ${agentName(issue.assigneeAgentId)}` + : "Assign this task to an agent first — same-task watchdogs run as the assignee."} +
+
+ ) : (
Watchdog agent
+ )}
Instructions (optional) diff --git a/ui/src/pages/InstanceExperimentalSettings.test.tsx b/ui/src/pages/InstanceExperimentalSettings.test.tsx index 6ac6c26f55b..7fecb152b2b 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, + enableSameTaskWatchdogs: false, enableWatchdogEverything: false, enableCloudSync: false, enableServerInfoDebugView: false, diff --git a/ui/src/pages/InstanceExperimentalSettings.tsx b/ui/src/pages/InstanceExperimentalSettings.tsx index 83513271521..2e15b73ff85 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 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" /> + toggleMutation.mutate({ enableSameTaskWatchdogs: checked })} + disabled={toggleMutation.isPending} + managed={managedKeys.enableSameTaskWatchdogs} + ariaLabel="Toggle same-task watchdogs experimental setting" + /> + toggleMutation.mutate({ enableWatchdogEverything: checked })} disabled={toggleMutation.isPending}