[codex] feat(watchdog): add task watchdog control plane (#8339)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - The task lifecycle and recovery subsystems decide when agent work is
still productive, stalled, or ready for review.
> - Existing recovery paths can observe stopped or incomplete work, but
there was no first-class per-task watchdog model with scoped review
permissions.
> - Watchdog follow-ups also need strict boundaries so
recovery/status-only runs cannot mutate approvals or perform deliverable
work.
> - This pull request adds the task watchdog data model, API/service
layer, scheduler/review flow, adapter wake context, UI configuration
surfaces, and docs.
> - The branch has been rebased onto current `paperclipai/paperclip`
`master`; the watchdog migration is now ordered after master's latest
migrations as `0104_issue_watchdogs`.
> - The benefit is a more explicit task-review loop that preserves
Paperclip's single-assignee and governance invariants while making
stalled work easier to route.

## Linked Issues or Issue Description

No linked GitHub issue. Paperclip task:
[PAP-11275](/PAP/issues/PAP-11275).

## Problem or motivation

Task recovery needs a first-class watchdog path that can inspect stopped
work and create scoped follow-ups without bypassing normal task
ownership. Board/UI users need a way to configure watchdogs on tasks and
see watchdog-related live work. Recovery/status-only runs must remain
limited to status reporting and must not create approvals, link
approvals, or submit approval comments.

## Proposed solution

Add a task-watchdog data model, scheduler/classifier, scoped mutation
guard, adapter wake context, API/UI configuration surfaces, and
documentation so watchdog agents can review stopped task subtrees under
explicit boundaries.

## Alternatives considered

Reuse the existing recovery-action flow only. That would keep
stopped-work detection implicit, make per-task watchdog assignment
harder to expose in the UI, and would not provide a durable
scoped-review issue for stalled task trees.

## Roadmap alignment

This is Paperclip control-plane lifecycle infrastructure for task
execution and recovery. I checked `ROADMAP.md`; this PR does not
duplicate an existing planned core item.

## What Changed

- Added issue watchdog schema, migration, shared contracts, validators,
CRUD API, and service support.
- Added task watchdog scheduler/classifier behavior, scoped mutation
enforcement, adapter wake context, and default watchdog mandate
guidance.
- Added UI surfaces for configuring watchdogs on new/existing tasks,
viewing watchdog activity, and exposing the experimental setting.
- Added docs for the user-facing task watchdog workflow and
implementation semantics.
- Gated new-task watchdog setup behind `enableTaskWatchdogs` and blocked
cheap status-only recovery runs from approval mutations.
- Rebased onto current `master` and renumbered the idempotent watchdog
migration from the branch-local `0102_issue_watchdogs` slot to
`0104_issue_watchdogs`.
- Addressed Greptile feedback by loading watchdog classifier input with
a recursive subtree query and centralizing the watchdog origin-kind
constant.
- Added and updated focused server/UI tests for watchdog routes,
scheduler/classifier behavior, scope boundaries, live task visibility,
settings, and new issue dialog behavior.

## Verification

- `pnpm vitest run server/src/__tests__/task-watchdogs-scheduler.test.ts
server/src/__tests__/task-watchdogs-classifier.test.ts`
- `pnpm vitest run
server/src/__tests__/approval-routes-idempotency.test.ts
server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts`
- `pnpm vitest run ui/src/components/NewIssueDialog.test.tsx`
- `pnpm --filter @paperclipai/server typecheck`
- `git diff --check`
- Verified the PR diff does not include `pnpm-lock.yaml` or
`.github/workflows`.

## Risks

- Medium risk: this introduces a new task lifecycle surface touching DB
schema, server routes/services, adapter wake context, and UI task
configuration.
- Watchdog scheduling behavior depends on the new experimental setting
and runtime context checks behaving consistently across local and
production agents.
- The watchdog migration is idempotent (`IF NOT EXISTS` /
duplicate-object guards) so users who tried the previous branch-local
migration number should not get duplicate-object failures.
- CI and the second Greptile pass are pending after the latest
review-fix push.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

OpenAI Codex, GPT-5-class coding agent in the Paperclip workspace. Exact
runtime model id and context window were not exposed to the agent; tool
use and local command execution were enabled.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] If this change affects the UI, I have included before/after
screenshots — N/A per Paperclip task instruction: do not add
screenshots/images to this PR unless they are specifically part of the
work.
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Dotta
2026-06-19 15:38:52 -05:00
committed by GitHub
parent 8f4b491d9a
commit a71c4b6782
56 changed files with 7231 additions and 68 deletions
+81
View File
@@ -0,0 +1,81 @@
// @vitest-environment jsdom
import { createRoot } from "react-dom/client";
import { flushSync } from "react-dom";
import { afterEach, describe, expect, it } from "vitest";
import type { Issue } from "@paperclipai/shared";
import { InboxIssueMetaLeading } from "./IssueColumns";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
function act(callback: () => void): void {
flushSync(callback);
}
function makeIssue(overrides: Partial<Issue>): Issue {
return {
id: "issue-id",
identifier: "PAP-1",
status: "in_progress",
blockerAttention: false,
...overrides,
} as unknown as Issue;
}
let container: HTMLDivElement | null = null;
let root: ReturnType<typeof createRoot> | null = null;
afterEach(() => {
if (root) act(() => root!.unmount());
root = null;
container?.remove();
container = null;
});
function renderLeading(element: React.ReactElement): string {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
act(() => root!.render(element));
return container.textContent ?? "";
}
describe("InboxIssueMetaLeading live state", () => {
it("shows the own Live chip for a running issue and never the subtree chip", () => {
const text = renderLeading(
<InboxIssueMetaLeading
issue={makeIssue({ id: "child", identifier: "PAP-2", status: "in_progress" })}
isLive
subtreeLiveCount={3}
/>,
);
expect(text).toContain("Live");
expect(text).not.toContain("live below");
});
it("shows the distinct subtree chip for a done parent with live descendants", () => {
const text = renderLeading(
<InboxIssueMetaLeading
issue={makeIssue({ id: "parent", identifier: "PAP-1", status: "done" })}
isLive={false}
subtreeLiveCount={2}
/>,
);
// The done parent must NOT borrow the running child's "Live" chip.
expect(text).toContain("2 live below");
expect(text).not.toMatch(/(^|[^a-z])Live([^a-z]|$)/);
});
it("renders no live treatment when the issue and its subtree are idle", () => {
const text = renderLeading(
<InboxIssueMetaLeading
issue={makeIssue({ id: "idle", identifier: "PAP-3", status: "done" })}
isLive={false}
subtreeLiveCount={0}
/>,
);
expect(text).not.toContain("Live");
expect(text).not.toContain("live below");
});
});
+22
View File
@@ -136,6 +136,7 @@ export function IssueColumnPicker({
export function InboxIssueMetaLeading({
issue,
isLive,
subtreeLiveCount = 0,
showStatus = true,
showIdentifier = true,
statusSlot,
@@ -143,6 +144,7 @@ export function InboxIssueMetaLeading({
}: {
issue: Issue;
isLive: boolean;
subtreeLiveCount?: number;
showStatus?: boolean;
showIdentifier?: boolean;
statusSlot?: ReactNode;
@@ -191,6 +193,26 @@ export function InboxIssueMetaLeading({
</span>
</span>
)}
{!isLive && subtreeLiveCount > 0 && (
<span
className={cn(
"inline-flex items-center gap-1 rounded-full border px-1.5 py-0.5 sm:gap-1.5 sm:px-2",
"border-border bg-transparent",
)}
title={`${subtreeLiveCount} sub-task${subtreeLiveCount === 1 ? "" : "s"} running below`}
>
<span
className={cn(
"h-2 w-2 shrink-0 rounded-full border",
"border-muted-foreground/60 bg-transparent",
)}
aria-hidden="true"
/>
<span className="hidden text-[11px] font-medium text-muted-foreground sm:inline">
{subtreeLiveCount} live below
</span>
</span>
)}
</>
);
}
+215
View File
@@ -30,12 +30,18 @@ const mockIssuesApi = vi.hoisted(() => ({
list: vi.fn(),
listLabels: vi.fn(),
createLabel: vi.fn(),
upsertWatchdog: vi.fn(),
deleteWatchdog: vi.fn(),
}));
const mockAuthApi = vi.hoisted(() => ({
getSession: vi.fn(),
}));
const mockInstanceSettingsApi = vi.hoisted(() => ({
getExperimental: vi.fn(),
}));
vi.mock("../context/CompanyContext", () => ({
useCompany: () => ({
selectedCompanyId: "company-1",
@@ -58,6 +64,10 @@ vi.mock("../api/auth", () => ({
authApi: mockAuthApi,
}));
vi.mock("../api/instanceSettings", () => ({
instanceSettingsApi: mockInstanceSettingsApi,
}));
vi.mock("../context/ToastContext", () => ({
useToastActions: () => ({ pushToast: vi.fn() }),
}));
@@ -386,7 +396,12 @@ describe("IssueProperties", () => {
name: "New label",
color: "#6366f1",
}));
mockIssuesApi.upsertWatchdog.mockResolvedValue({});
mockIssuesApi.deleteWatchdog.mockResolvedValue({ ok: true });
mockAuthApi.getSession.mockResolvedValue({ user: { id: "user-1" } });
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
enableTaskWatchdogs: false,
});
});
afterEach(() => {
@@ -533,6 +548,40 @@ describe("IssueProperties", () => {
act(() => root.unmount());
});
it("hides watchdog setup controls while the experimental flag is off", async () => {
const root = renderProperties(container, {
issue: createIssue(),
childIssues: [],
onUpdate: vi.fn(),
});
await flush();
expect(container.textContent).not.toContain("Watchdog");
expect(container.textContent).not.toContain("Set watchdog");
act(() => root.unmount());
});
it("shows watchdog setup controls when the experimental flag is enabled", async () => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
enableTaskWatchdogs: true,
});
const root = renderProperties(container, {
issue: createIssue(),
childIssues: [],
onUpdate: vi.fn(),
inline: true,
});
await flush();
await waitForAssertion(() => {
expect(container.textContent).toContain("Watchdog");
expect(container.textContent).toContain("Set watchdog");
});
act(() => root.unmount());
});
it("passes blocker attention to the sidebar status icon", async () => {
const root = renderProperties(container, {
issue: createIssue({
@@ -1439,4 +1488,170 @@ describe("IssueProperties", () => {
act(() => root.unmount());
});
const watchdogAgent = {
id: "agent-1",
name: "ClaudeCoder",
role: "",
title: null,
icon: null,
status: "active",
orgChainHealth: { status: "ok" },
} as unknown as Parameters<typeof mockAgentsApi.list.mockResolvedValue>[0][number];
function createWatchdogSummary(overrides: Record<string, unknown> = {}) {
return {
id: "watchdog-1",
companyId: "company-1",
issueId: "issue-1",
watchdogAgentId: "agent-1",
instructions: "Keep the tree moving.",
status: "active",
watchdogIssueId: null,
lastObservedFingerprint: null,
lastReviewedFingerprint: null,
lastTriggeredAt: null,
lastCompletedAt: null,
triggerCount: 0,
createdAt: new Date("2026-04-06T12:00:00.000Z"),
updatedAt: new Date("2026-04-06T12:00:00.000Z"),
...overrides,
} as unknown as NonNullable<Issue["watchdog"]>;
}
it("shows the empty watchdog state and saves a new watchdog via the API", async () => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
enableTaskWatchdogs: true,
});
mockAgentsApi.list.mockResolvedValue([watchdogAgent]);
const onUpdate = vi.fn();
const root = renderProperties(container, {
issue: createIssue({ watchdog: null }),
childIssues: [],
onUpdate,
inline: true,
});
await flush();
let trigger: HTMLButtonElement | undefined;
await waitForAssertion(() => {
expect(container.textContent).toContain("Watchdog");
trigger = Array.from(container.querySelectorAll("button"))
.find((button) => button.textContent?.includes("Set watchdog"));
expect(trigger).toBeTruthy();
});
await act(async () => {
trigger!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flush();
// Choose the agent through the inline selector, then save.
let agentOption: HTMLElement | undefined;
await waitForAssertion(() => {
agentOption = Array.from(container.querySelectorAll("button, [role='option']"))
.find((node) => node.textContent?.includes("ClaudeCoder")) as HTMLElement | undefined;
expect(agentOption).toBeTruthy();
});
// Open the selector if the option is not yet visible, then click it.
await act(async () => {
agentOption!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flush();
const instructions = Array.from(container.querySelectorAll("textarea"))
.find((node) => node.getAttribute("placeholder")?.includes("watchdog"));
expect(instructions).toBeTruthy();
await act(async () => {
const setter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, "value")!.set!;
setter.call(instructions!, "Watch the deploy");
instructions!.dispatchEvent(new Event("input", { bubbles: true }));
});
await flush();
const saveButton = Array.from(container.querySelectorAll("button"))
.find((button) => /Set watchdog|Update/.test(button.textContent ?? "") && button.closest("[class*='space-y']"));
const finalSave = Array.from(container.querySelectorAll("button"))
.find((button) => button.textContent === "Set watchdog" && button !== trigger) ?? saveButton;
expect(finalSave).toBeTruthy();
await act(async () => {
finalSave!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flush();
expect(mockIssuesApi.upsertWatchdog).toHaveBeenCalledWith(
"issue-1",
expect.objectContaining({ agentId: "agent-1" }),
);
act(() => root.unmount());
});
it("renders an existing watchdog and removes it via the API", async () => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
enableTaskWatchdogs: true,
});
mockAgentsApi.list.mockResolvedValue([watchdogAgent]);
const onUpdate = vi.fn();
const root = renderProperties(container, {
issue: createIssue({ watchdog: createWatchdogSummary() }),
childIssues: [],
onUpdate,
inline: true,
});
await flush();
await waitForAssertion(() => {
expect(container.textContent).toContain("ClaudeCoder");
});
const trigger = Array.from(container.querySelectorAll("button"))
.find((button) => button.textContent?.includes("ClaudeCoder"));
await act(async () => {
trigger!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flush();
const removeButton = Array.from(container.querySelectorAll("button"))
.find((button) => button.textContent?.includes("Remove"));
expect(removeButton).toBeTruthy();
await act(async () => {
removeButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flush();
expect(mockIssuesApi.deleteWatchdog).toHaveBeenCalledWith("issue-1");
act(() => root.unmount());
});
it("links to the generated watchdog task when one exists", async () => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
enableTaskWatchdogs: true,
});
mockAgentsApi.list.mockResolvedValue([watchdogAgent]);
const root = renderProperties(container, {
issue: createIssue({ watchdog: createWatchdogSummary({ watchdogIssueId: "issue-wd" }) }),
childIssues: [
createIssue({
id: "issue-wd",
identifier: "PAP-42",
title: "Watchdog: Parent issue",
originKind: "task_watchdog",
}),
],
onUpdate: vi.fn(),
inline: true,
});
await flush();
await waitForAssertion(() => {
const link = Array.from(container.querySelectorAll("a"))
.find((anchor) => anchor.getAttribute("href") === "/issues/issue-wd");
expect(link).toBeTruthy();
expect(link!.textContent).toContain("PAP-42");
});
act(() => root.unmount());
});
});
+191 -1
View File
@@ -7,6 +7,7 @@ import type { AdapterModel } from "../api/agents";
import { accessApi } from "../api/access";
import { agentsApi } from "../api/agents";
import { authApi } from "../api/auth";
import { instanceSettingsApi } from "../api/instanceSettings";
import { issuesApi } from "../api/issues";
import { projectsApi } from "../api/projects";
import { useCompany } from "../context/CompanyContext";
@@ -47,8 +48,9 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { Separator } from "@/components/ui/separator";
import { Textarea } from "@/components/ui/textarea";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { User, Hexagon, ArrowUpRight, Tag, Plus, GitBranch, FolderOpen, Check, ExternalLink, X, Clock, RotateCcw, Loader2, CheckCircle2 } from "lucide-react";
import { User, Hexagon, ArrowUpRight, Tag, Plus, GitBranch, FolderOpen, Check, ExternalLink, X, Clock, RotateCcw, Loader2, CheckCircle2, ScanEye } from "lucide-react";
import { AgentIcon } from "./AgentIconPicker";
import { InlineEntitySelector, type InlineEntityOption } from "./InlineEntitySelector";
import {
@@ -394,6 +396,11 @@ export function IssueProperties({
const { selectedCompanyId } = useCompany();
const queryClient = useQueryClient();
const companyId = issue.companyId ?? selectedCompanyId;
const { data: experimentalSettings } = useQuery({
queryKey: queryKeys.instance.experimentalSettings,
queryFn: () => instanceSettingsApi.getExperimental(),
});
const taskWatchdogsEnabled = experimentalSettings?.enableTaskWatchdogs === true;
const [assigneeOpen, setAssigneeOpen] = useState(false);
const [assigneeSearch, setAssigneeSearch] = useState("");
/** When a run is live, a selection is staged here until the operator confirms
@@ -424,6 +431,9 @@ export function IssueProperties({
const [monitorAtInput, setMonitorAtInput] = useState(() => toDateTimeLocalValue(issue.executionPolicy?.monitor?.nextCheckAt));
const [monitorNotesInput, setMonitorNotesInput] = useState(issue.executionPolicy?.monitor?.notes ?? "");
const [monitorServiceInput, setMonitorServiceInput] = useState(issue.executionPolicy?.monitor?.serviceName ?? "");
const [watchdogOpen, setWatchdogOpen] = useState(false);
const [watchdogAgentInput, setWatchdogAgentInput] = useState(issue.watchdog?.watchdogAgentId ?? "");
const [watchdogInstructionsInput, setWatchdogInstructionsInput] = useState(issue.watchdog?.instructions ?? "");
const normalizedBlockedBySearch = blockedBySearch.trim();
const { data: session } = useQuery({
@@ -922,6 +932,160 @@ export function IssueProperties({
issue.executionPolicy?.monitor?.notes,
issue.executionPolicy?.monitor?.serviceName,
]);
// Re-sync watchdog editor inputs when the persisted watchdog changes (and reset on close).
useEffect(() => {
if (watchdogOpen) return;
setWatchdogAgentInput(issue.watchdog?.watchdogAgentId ?? "");
setWatchdogInstructionsInput(issue.watchdog?.instructions ?? "");
}, [issue.watchdog?.watchdogAgentId, issue.watchdog?.instructions, watchdogOpen]);
const watchdogAgentOptions = useMemo<InlineEntityOption[]>(
() =>
(agents ?? [])
.filter(isAgentTaskTarget)
.map((agent) => ({
id: agent.id,
label: agent.name,
searchText: `${agent.name} ${agent.role} ${agent.title ?? ""}`,
})),
[agents],
);
const upsertWatchdog = useMutation({
mutationFn: (data: { agentId: string; instructions: string | null }) =>
issuesApi.upsertWatchdog(issue.id, data),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(issue.id) });
setWatchdogOpen(false);
},
});
const deleteWatchdog = useMutation({
mutationFn: () => issuesApi.deleteWatchdog(issue.id),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(issue.id) });
setWatchdogOpen(false);
},
});
const saveWatchdog = () => {
if (!watchdogAgentInput) return;
upsertWatchdog.mutate({
agentId: watchdogAgentInput,
instructions: watchdogInstructionsInput.trim() || null,
});
};
const removeWatchdog = () => {
if (issue.watchdog) {
deleteWatchdog.mutate();
} else {
setWatchdogOpen(false);
}
setWatchdogAgentInput("");
setWatchdogInstructionsInput("");
};
const watchdogMutationError =
upsertWatchdog.error instanceof Error
? upsertWatchdog.error.message
: deleteWatchdog.error instanceof Error
? deleteWatchdog.error.message
: null;
const watchdogIssueRef = (childIssues ?? []).find(
(child) => child.id === issue.watchdog?.watchdogIssueId,
);
const watchdogTrigger = issue.watchdog ? (
<span className="inline-flex min-w-0 items-center gap-1.5 text-sm">
{(() => {
const agent = (agents ?? []).find((candidate) => candidate.id === issue.watchdog?.watchdogAgentId);
return agent ? <AgentIcon icon={agent.icon} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" /> : null;
})()}
<span className="truncate">{agentName(issue.watchdog.watchdogAgentId)}</span>
{issue.watchdog.instructions?.trim() ? (
<span className="truncate text-muted-foreground">· {issue.watchdog.instructions.trim()}</span>
) : null}
{issue.watchdog.status === "disabled" ? (
<span className="shrink-0 text-xs text-muted-foreground">(disabled)</span>
) : null}
</span>
) : (
<span className="text-sm text-muted-foreground">Set watchdog</span>
);
const watchdogContent = (
<div className="space-y-3 p-2">
<div className="space-y-1.5">
<div className="text-xs font-medium text-foreground">Watchdog agent</div>
<InlineEntitySelector
value={watchdogAgentInput}
options={watchdogAgentOptions}
placeholder="Select agent"
noneLabel="No watchdog agent"
searchPlaceholder="Search agents..."
emptyMessage="No agents found."
onChange={setWatchdogAgentInput}
renderTriggerValue={(option) => {
if (!option) return <span className="text-muted-foreground">Select agent</span>;
const agent = (agents ?? []).find((candidate) => candidate.id === option.id);
return (
<>
{agent ? <AgentIcon icon={agent.icon} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" /> : null}
<span className="truncate">{option.label}</span>
</>
);
}}
renderOption={(option) => {
const agent = (agents ?? []).find((candidate) => candidate.id === option.id);
return (
<>
{agent ? <AgentIcon icon={agent.icon} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" /> : null}
<span className="truncate">{option.label}</span>
</>
);
}}
/>
</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>
</div>
<Textarea
value={watchdogInstructionsInput}
onChange={(event) => setWatchdogInstructionsInput(event.target.value)}
placeholder="What should the watchdog watch for and how should it keep work moving?"
rows={4}
className="text-xs"
/>
</div>
{watchdogIssueRef ? (
<div className="text-xs text-muted-foreground">
Watchdog task:{" "}
<Link to={`/issues/${watchdogIssueRef.id}`} className="text-primary hover:underline">
{watchdogIssueRef.identifier ?? "View task"}
</Link>
</div>
) : null}
{watchdogMutationError ? (
<div className="rounded border border-destructive/40 bg-destructive/10 px-2 py-1 text-xs text-destructive">
{watchdogMutationError}
</div>
) : null}
<div className="flex items-center justify-between">
<button
type="button"
className="text-xs text-muted-foreground hover:text-destructive transition-colors disabled:opacity-50"
disabled={deleteWatchdog.isPending || (!issue.watchdog && !watchdogAgentInput)}
onClick={removeWatchdog}
>
{deleteWatchdog.isPending ? "Removing…" : "Remove"}
</button>
<Button
type="button"
size="sm"
className="h-7 text-xs"
disabled={!watchdogAgentInput || upsertWatchdog.isPending}
onClick={saveWatchdog}
>
{upsertWatchdog.isPending ? "Saving…" : issue.watchdog ? "Update" : "Set watchdog"}
</Button>
</div>
</div>
);
const updateMonitor = (nextMonitor: Issue["executionPolicy"] extends infer T
? T extends { monitor?: infer M | null } | null | undefined
@@ -2130,6 +2294,32 @@ export function IssueProperties({
{monitorContent}
</PropertyPicker>
{taskWatchdogsEnabled ? (
<PropertyPicker
inline={inline}
label="Watchdog"
open={watchdogOpen}
onOpenChange={setWatchdogOpen}
triggerContent={watchdogTrigger}
triggerClassName="min-w-0 max-w-full"
popoverClassName={cn("max-w-full", inline ? "w-full" : "w-80 sm:w-96")}
extra={
watchdogIssueRef ? (
<Link
to={`/issues/${watchdogIssueRef.id}`}
className="ml-1 inline-flex shrink-0 items-center gap-0.5 rounded-full border border-border px-1.5 py-0.5 text-[11px] text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground"
title="Open watchdog task"
>
<ScanEye className="h-3 w-3" />
{watchdogIssueRef.identifier ?? "Task"}
</Link>
) : undefined
}
>
{watchdogContent}
</PropertyPicker>
) : null}
{issue.requestDepth > 0 && (
<PropertyRow label="Depth">
<span className="text-sm font-mono">{issue.requestDepth}</span>
+6
View File
@@ -42,6 +42,7 @@ import {
type InboxIssueColumn,
} from "../lib/inbox";
import { cn, formatDurationMs, formatTokens } from "../lib/utils";
import { collectSubtreeLiveCounts } from "../lib/liveIssueIds";
import {
InboxIssueMetaLeading,
InboxIssueTrailingColumns,
@@ -920,6 +921,10 @@ export function IssuesList({
[isolatedWorkspacesEnabled],
);
const availableIssueColumnSet = useMemo(() => new Set(availableIssueColumns), [availableIssueColumns]);
const subtreeLiveCounts = useMemo(
() => collectSubtreeLiveCounts(issues, liveIssueIds ?? new Set<string>()),
[issues, liveIssueIds],
);
const visibleTrailingIssueColumns = useMemo(
() => issueTrailingColumns.filter((column) => visibleIssueColumnSet.has(column) && availableIssueColumnSet.has(column)),
[availableIssueColumnSet, visibleIssueColumnSet],
@@ -1808,6 +1813,7 @@ export function IssuesList({
<InboxIssueMetaLeading
issue={issue}
isLive={liveIssueIds?.has(issue.id) === true}
subtreeLiveCount={subtreeLiveCounts.get(issue.id) ?? 0}
showStatus={visibleIssueColumnSet.has("status") && availableIssueColumnSet.has("status")}
showIdentifier={visibleIssueColumnSet.has("id") && availableIssueColumnSet.has("id")}
checklistStepNumber={checklistStepNumber}
+21
View File
@@ -23,6 +23,7 @@ import { Identity } from "./Identity";
import type { Issue, IssueStatus } from "@paperclipai/shared";
import { AlertTriangle } from "lucide-react";
import { isSuccessfulRunHandoffRequired } from "../lib/successful-run-handoff";
import { collectSubtreeLiveCounts } from "../lib/liveIssueIds";
export const KANBAN_BOARD_HIGH_VOLUME_THRESHOLD = 100;
export const KANBAN_COLUMN_PAGE_SIZE_OPTIONS = [10, 25, 50] as const;
@@ -76,6 +77,7 @@ function KanbanColumn({
issues,
agents,
liveIssueIds,
subtreeLiveCounts,
compactCards = false,
collapsed = false,
visibleCount,
@@ -86,6 +88,7 @@ function KanbanColumn({
issues: Issue[];
agents?: Agent[];
liveIssueIds?: Set<string>;
subtreeLiveCounts?: ReadonlyMap<string, number>;
compactCards?: boolean;
collapsed?: boolean;
visibleCount: number;
@@ -151,6 +154,7 @@ function KanbanColumn({
issue={issue}
agents={agents}
isLive={liveIssueIds?.has(issue.id)}
subtreeLiveCount={subtreeLiveCounts?.get(issue.id) ?? 0}
compact={compactCards}
/>
))}
@@ -180,12 +184,14 @@ function KanbanCard({
issue,
agents,
isLive,
subtreeLiveCount = 0,
isOverlay,
compact = false,
}: {
issue: Issue;
agents?: Agent[];
isLive?: boolean;
subtreeLiveCount?: number;
isOverlay?: boolean;
compact?: boolean;
}) {
@@ -252,6 +258,15 @@ function KanbanCard({
{compact ? "Live" : null}
</span>
)}
{!isLive && subtreeLiveCount > 0 && (
<span
className="inline-flex shrink-0 items-center gap-1 rounded-full border border-border px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground"
title={`${subtreeLiveCount} sub-task${subtreeLiveCount === 1 ? "" : "s"} running below`}
>
<span className="h-2 w-2 shrink-0 rounded-full border border-muted-foreground/60" aria-hidden="true" />
{subtreeLiveCount} live below
</span>
)}
</div>
<p className={`${compact ? "mb-1.5 text-xs" : "mb-2 text-sm"} leading-snug line-clamp-2`}>{issue.title}</p>
<div className="flex items-center gap-2 min-w-0">
@@ -314,6 +329,11 @@ export function KanbanBoard({
[activeId, issues]
);
const subtreeLiveCounts = useMemo(
() => collectSubtreeLiveCounts(issues, liveIssueIds ?? new Set<string>()),
[issues, liveIssueIds],
);
function handleDragStart(event: DragStartEvent) {
setActiveId(event.active.id as string);
}
@@ -355,6 +375,7 @@ export function KanbanBoard({
issues={columnIssues[status] ?? []}
agents={agents}
liveIssueIds={liveIssueIds}
subtreeLiveCounts={subtreeLiveCounts}
compactCards={compactCards}
collapsed={collapsedStatusSet.has(status)}
visibleCount={visibleCountByStatus[status] ?? initialVisibleCount}
+87 -2
View File
@@ -993,6 +993,86 @@ describe("NewIssueDialog", () => {
act(() => root.unmount());
});
it("reveals the watchdog editor from the overflow menu", async () => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
enableIsolatedWorkspaces: false,
enableTaskWatchdogs: true,
});
const { root } = renderDialog(container);
await flush();
// The watchdog row is hidden until the menu item is toggled on.
expect(container.querySelector('textarea[placeholder^="What should the watchdog"]')).toBeNull();
const watchdogMenuItem = Array.from(container.querySelectorAll("button"))
.find((button) => button.textContent?.trim() === "Watchdog");
expect(watchdogMenuItem).not.toBeUndefined();
await act(async () => {
watchdogMenuItem!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flush();
expect(container.textContent).toContain("Set watchdog");
expect(container.querySelector('textarea[placeholder^="What should the watchdog"]')).not.toBeNull();
act(() => root.unmount());
});
it("submits the configured watchdog from a restored draft", async () => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
enableIsolatedWorkspaces: false,
enableTaskWatchdogs: true,
});
localStorage.setItem(
"paperclip:issue-draft",
JSON.stringify({
title: "Watched task",
description: "",
status: "todo",
priority: "medium",
assigneeValue: "",
reviewerValue: "",
approverValue: "",
watchdogAgentId: "agent-9",
watchdogInstructions: "Keep it moving",
projectId: "",
assigneeModelOverride: "",
assigneeThinkingEffort: "",
assigneeChrome: false,
workMode: "standard",
}),
);
const { root } = renderDialog(container);
await flush();
expect(container.textContent).toContain("Keep it moving");
const submitButton = Array.from(container.querySelectorAll("button"))
.find((button) => button.textContent?.includes("Create Task"));
expect(submitButton).not.toBeUndefined();
await vi.waitFor(() => {
expect(submitButton?.hasAttribute("disabled")).toBe(false);
});
await act(async () => {
submitButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flush();
expect(mockIssuesApi.create).toHaveBeenCalledWith(
"company-1",
expect.objectContaining({
title: "Watched task",
watchdog: { agentId: "agent-9", instructions: "Keep it moving" },
}),
);
act(() => root.unmount());
});
// PAP-139/PAP-140: work-mode labels and status hues branch on the Conference
// Room Chat experimental flag — OFF (default) must match master exactly.
describe("Conference Room Chat flag parity (PAP-140)", () => {
@@ -1030,10 +1110,15 @@ describe("NewIssueDialog", () => {
});
it("uses NUX work-mode labels and brand status hues when the flag is on", async () => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableConferenceRoomChat: true });
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
enableConferenceRoomChat: true,
enableIsolatedWorkspaces: false,
});
const { root } = renderDialog(container);
await flush();
await waitForAssertion(() => {
expect(workModeOption("standard")?.textContent).toContain("Agent mode");
});
expect(workModeOption("standard")?.textContent).toContain("Agent mode");
expect(workModeOption("ask")?.textContent).toContain("Ask mode");
+160 -1
View File
@@ -61,7 +61,9 @@ import {
Eye,
ShieldAlert,
ShieldCheck,
ScanEye,
} from "lucide-react";
import { Textarea } from "@/components/ui/textarea";
import { cn } from "../lib/utils";
import { extractProviderIdWithFallback } from "../lib/model-utils";
import { issueStatusText, issueStatusTextClassic, issueStatusTextDefault, priorityColor, priorityColorDefault } from "../lib/status-colors";
@@ -84,6 +86,8 @@ interface IssueDraft {
assigneeValue: string;
reviewerValue: string;
approverValue: string;
watchdogAgentId?: string;
watchdogInstructions?: string;
assigneeId?: string;
projectId: string;
projectWorkspaceId?: string;
@@ -420,6 +424,10 @@ export function NewIssueDialog() {
const [approverValue, setApproverValue] = useState("");
const [showReviewerRow, setShowReviewerRow] = useState(false);
const [showApproverRow, setShowApproverRow] = useState(false);
const [watchdogAgentId, setWatchdogAgentId] = useState("");
const [watchdogInstructions, setWatchdogInstructions] = useState("");
const [showWatchdogRow, setShowWatchdogRow] = useState(false);
const [watchdogEditorOpen, setWatchdogEditorOpen] = useState(false);
const [participantMenuOpen, setParticipantMenuOpen] = useState(false);
const [projectId, setProjectId] = useState("");
const [projectWorkspaceId, setProjectWorkspaceId] = useState("");
@@ -650,6 +658,8 @@ export function NewIssueDialog() {
assigneeValue,
reviewerValue,
approverValue,
watchdogAgentId,
watchdogInstructions,
projectId,
projectWorkspaceId,
assigneeModelLane,
@@ -668,6 +678,8 @@ export function NewIssueDialog() {
assigneeValue,
reviewerValue,
approverValue,
watchdogAgentId,
watchdogInstructions,
projectId,
projectWorkspaceId,
assigneeModelOverride,
@@ -704,6 +716,8 @@ export function NewIssueDialog() {
assigneeValue,
reviewerValue,
approverValue,
watchdogAgentId,
watchdogInstructions,
projectId,
projectWorkspaceId,
assigneeModelLane,
@@ -769,6 +783,9 @@ export function NewIssueDialog() {
setApproverValue("");
setShowReviewerRow(false);
setShowApproverRow(false);
setWatchdogAgentId("");
setWatchdogInstructions("");
setShowWatchdogRow(false);
setAssigneeModelOverride("");
setAssigneeThinkingEffort("");
setAssigneeChrome(false);
@@ -797,6 +814,9 @@ export function NewIssueDialog() {
setApproverValue(draft.approverValue ?? "");
setShowReviewerRow(!!(draft.reviewerValue));
setShowApproverRow(!!(draft.approverValue));
setWatchdogAgentId(draft.watchdogAgentId ?? "");
setWatchdogInstructions(draft.watchdogInstructions ?? "");
setShowWatchdogRow(!!(draft.watchdogAgentId));
setProjectId(restoredProjectId);
setProjectWorkspaceId(
hasExplicitProjectWorkspaceId
@@ -839,6 +859,9 @@ export function NewIssueDialog() {
setApproverValue("");
setShowReviewerRow(false);
setShowApproverRow(false);
setWatchdogAgentId("");
setWatchdogInstructions("");
setShowWatchdogRow(false);
setAssigneeModelOverride("");
setAssigneeThinkingEffort("");
setAssigneeChrome(false);
@@ -896,6 +919,9 @@ export function NewIssueDialog() {
setApproverValue("");
setShowReviewerRow(false);
setShowApproverRow(false);
setWatchdogAgentId("");
setWatchdogInstructions("");
setShowWatchdogRow(false);
setProjectId("");
setProjectWorkspaceId("");
setAssigneeOptionsOpen(false);
@@ -924,6 +950,9 @@ export function NewIssueDialog() {
setApproverValue("");
setShowReviewerRow(false);
setShowApproverRow(false);
setWatchdogAgentId("");
setWatchdogInstructions("");
setShowWatchdogRow(false);
setProjectId("");
setProjectWorkspaceId("");
setAssigneeModelLane("primary");
@@ -997,6 +1026,9 @@ export function NewIssueDialog() {
: {}),
...(executionWorkspaceSettings ? { executionWorkspaceSettings } : {}),
...(executionPolicy ? { executionPolicy } : {}),
...(taskWatchdogsEnabled && watchdogAgentId
? { watchdog: { agentId: watchdogAgentId, instructions: watchdogInstructions.trim() || null } }
: {}),
});
}
@@ -1088,6 +1120,7 @@ export function NewIssueDialog() {
? currentProject?.executionWorkspacePolicy ?? null
: null;
const currentProjectSupportsExecutionWorkspace = Boolean(currentProjectExecutionWorkspacePolicy?.enabled);
const taskWatchdogsEnabled = experimentalSettings?.enableTaskWatchdogs === true;
const deduplicatedReusableWorkspaces = useMemo(() => {
return orderReusableExecutionWorkspaces(reusableExecutionWorkspaces ?? []);
}, [reusableExecutionWorkspaces]);
@@ -1136,6 +1169,19 @@ export function NewIssueDialog() {
],
[agents, companyMembers?.users, currentUserId, recentAssigneeIds],
);
const watchdogAgentOptions = useMemo<InlineEntityOption[]>(
() =>
sortAgentsByRecency((agents ?? []).filter(isAgentTaskTarget), recentAssigneeIds).map((agent) => ({
id: agent.id,
label: agent.name,
searchText: `${agent.name} ${agent.role} ${agent.title ?? ""}`,
})),
[agents, recentAssigneeIds],
);
const selectedWatchdogAgent = useMemo(
() => (watchdogAgentId ? (agents ?? []).find((agent) => agent.id === watchdogAgentId) ?? null : null),
[agents, watchdogAgentId],
);
const projectOptions = useMemo<InlineEntityOption[]>(
() =>
orderedProjects.map((project) => ({
@@ -1448,7 +1494,7 @@ export function NewIssueDialog() {
<button
type="button"
className="inline-flex items-center justify-center rounded-md p-1 text-muted-foreground hover:bg-accent/50 transition-colors"
title="Add reviewer or approver"
title={taskWatchdogsEnabled ? "Add reviewer, approver, or watchdog" : "Add reviewer or approver"}
>
<MoreHorizontal className="h-4 w-4" />
</button>
@@ -1482,6 +1528,29 @@ export function NewIssueDialog() {
<ShieldCheck className="h-3 w-3" />
Approver
</button>
{taskWatchdogsEnabled && (
<button
className={cn(
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
showWatchdogRow && "bg-accent",
)}
onClick={() => {
if (showWatchdogRow) {
setShowWatchdogRow(false);
setWatchdogAgentId("");
setWatchdogInstructions("");
setWatchdogEditorOpen(false);
} else {
setShowWatchdogRow(true);
setWatchdogEditorOpen(true);
}
setParticipantMenuOpen(false);
}}
>
<ScanEye className="h-3 w-3" />
Watchdog
</button>
)}
</PopoverContent>
</Popover>
</div>
@@ -1576,6 +1645,96 @@ export function NewIssueDialog() {
/>
</div>
)}
{/* Watchdog row */}
{taskWatchdogsEnabled && showWatchdogRow && (
<div className="flex items-center gap-2 text-sm text-muted-foreground mt-1">
<span className="w-6 shrink-0 flex items-center justify-center"><ScanEye className="h-3.5 w-3.5" /></span>
<Popover open={watchdogEditorOpen} onOpenChange={setWatchdogEditorOpen}>
<PopoverTrigger asChild>
<button
type="button"
className="inline-flex max-w-full items-center gap-1.5 rounded-md border border-border px-2 py-1 text-xs hover:bg-accent/50 transition-colors min-w-0"
title="Configure watchdog"
>
{selectedWatchdogAgent ? (
<>
<AgentIcon icon={selectedWatchdogAgent.icon} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span className="truncate text-foreground">{selectedWatchdogAgent.name}</span>
{watchdogInstructions.trim() ? (
<span className="truncate text-muted-foreground">· {watchdogInstructions.trim()}</span>
) : null}
</>
) : (
<span className="text-muted-foreground">Set watchdog</span>
)}
</button>
</PopoverTrigger>
<PopoverContent className="w-80 p-3 space-y-3" align="start">
<div className="space-y-1.5">
<div className="text-xs font-medium text-foreground">Watchdog agent</div>
<InlineEntitySelector
value={watchdogAgentId}
options={watchdogAgentOptions}
placeholder="Select agent"
noneLabel="No watchdog agent"
searchPlaceholder="Search agents..."
emptyMessage="No agents found."
onChange={setWatchdogAgentId}
renderTriggerValue={(option) =>
option ? (
<>
{selectedWatchdogAgent ? (
<AgentIcon icon={selectedWatchdogAgent.icon} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
) : null}
<span className="truncate">{option.label}</span>
</>
) : (
<span className="text-muted-foreground">Select agent</span>
)
}
renderOption={(option) => {
const agent = (agents ?? []).find((a) => a.id === option.id);
return (
<>
{agent ? <AgentIcon icon={agent.icon} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" /> : null}
<span className="truncate">{option.label}</span>
</>
);
}}
/>
</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></div>
<Textarea
value={watchdogInstructions}
onChange={(event) => setWatchdogInstructions(event.target.value)}
placeholder="What should the watchdog watch for and how should it keep work moving?"
rows={4}
className="text-xs"
/>
</div>
<div className="flex items-center justify-between">
<button
type="button"
className="text-xs text-muted-foreground hover:text-destructive transition-colors"
onClick={() => {
setWatchdogAgentId("");
setWatchdogInstructions("");
setShowWatchdogRow(false);
setWatchdogEditorOpen(false);
}}
>
Remove
</button>
<Button type="button" size="sm" className="h-7 text-xs" onClick={() => setWatchdogEditorOpen(false)}>
Done
</Button>
</div>
</PopoverContent>
</Popover>
</div>
)}
</div>
{isSubIssueMode ? (