mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-27 19:35:13 +01:00
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:
1 parent
026fc3c4e0
commit
fcfd24329c
11 files changed
+151
-4
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in new issue
Block a user