diff --git a/server/src/__tests__/task-watchdogs-classifier.test.ts b/server/src/__tests__/task-watchdogs-classifier.test.ts
index 75aaec1e748..fd756fe7c71 100644
--- a/server/src/__tests__/task-watchdogs-classifier.test.ts
+++ b/server/src/__tests__/task-watchdogs-classifier.test.ts
@@ -243,6 +243,25 @@ describe("task watchdog subtree classifier", () => {
expect(resolved.proofObligationFingerprint).not.toBe(blocked.proofObligationFingerprint);
});
+ it("includes the watchdog id in proof obligation fingerprints", () => {
+ const first = classify({ issues: [issue({ status: "done" })] });
+ const second = classify({
+ watchdog: {
+ id: "watchdog-config-2",
+ companyId,
+ issueId: sourceId,
+ lastReviewedFingerprint: null,
+ },
+ issues: [issue({ status: "done" })],
+ });
+
+ expect(first.state).toBe("stopped");
+ expect(second.state).toBe("stopped");
+ if (first.state !== "stopped" || second.state !== "stopped") return;
+ expect(second.stopFingerprint).toBe(first.stopFingerprint);
+ expect(second.proofObligationFingerprint).not.toBe(first.proofObligationFingerprint);
+ });
+
it("suppresses a repeated proof obligation when a typed outcome already exists", () => {
const stopped = classify({ issues: [issue({ status: "done" })] });
expect(stopped.state).toBe("stopped");
diff --git a/server/src/__tests__/task-watchdogs-scheduler.test.ts b/server/src/__tests__/task-watchdogs-scheduler.test.ts
index 0f3646e02a0..fdc8c26eb68 100644
--- a/server/src/__tests__/task-watchdogs-scheduler.test.ts
+++ b/server/src/__tests__/task-watchdogs-scheduler.test.ts
@@ -439,6 +439,38 @@ describeEmbeddedPostgres("task watchdog scheduler", () => {
expect(outcomes.map((outcome) => outcome.outcome).sort()).toEqual(["deferred", "failed"]);
});
+ it("does not defer a blocked watchdog issue when its only blocker was cancelled", async () => {
+ const companyId = await seedCompany();
+ const sourceId = await seedIssue(companyId, { identifier: "WDOG-CANCELLED-BLOCKER", status: "blocked" });
+ const blockerId = await seedIssue(companyId, { identifier: "WDOG-CANCELLED", status: "cancelled" });
+ const agentId = await seedAgent(companyId);
+ await seedWatchdog(companyId, sourceId, agentId);
+ const { service } = createService();
+
+ await service.reconcileTaskWatchdogs({ companyId });
+ const [watchdog] = await db.select().from(issueWatchdogs).where(eq(issueWatchdogs.issueId, sourceId));
+ const watchdogIssueId = watchdog!.watchdogIssueId!;
+ await db.update(issues).set({ status: "blocked", updatedAt: new Date() }).where(eq(issues.id, watchdogIssueId));
+ await db.insert(issueRelations).values({
+ companyId,
+ issueId: blockerId,
+ relatedIssueId: watchdogIssueId,
+ type: "blocks",
+ });
+
+ await service.reconcileTaskWatchdogs({ companyId });
+
+ const outcomes = await db
+ .select()
+ .from(issueWatchdogProofOutcomes)
+ .where(eq(issueWatchdogProofOutcomes.watchdogId, watchdog!.id));
+ expect(outcomes).toHaveLength(1);
+ expect(outcomes[0]).toMatchObject({
+ outcome: "failed",
+ redactedDetails: expect.objectContaining({ hasUnresolvedBlocker: false }),
+ });
+ });
+
it("does not let an old terminal watchdog review mark a newer observed fingerprint reviewed", async () => {
const companyId = await seedCompany();
const sourceId = await seedIssue(companyId, { identifier: "WDOG-STALE", status: "done" });
diff --git a/server/src/services/task-watchdogs.ts b/server/src/services/task-watchdogs.ts
index c19aa5d8a6d..815c6b445ac 100644
--- a/server/src/services/task-watchdogs.ts
+++ b/server/src/services/task-watchdogs.ts
@@ -329,6 +329,7 @@ function stableStopFingerprint(input: {
function stableProofObligationFingerprint(input: {
companyId: string;
+ watchdogId: string;
watchedIssueId: string;
stopFingerprint: string;
leaves: TaskWatchdogStoppedLeaf[];
@@ -336,6 +337,7 @@ function stableProofObligationFingerprint(input: {
const payload = JSON.stringify({
version: 1,
companyId: input.companyId,
+ watchdogId: input.watchdogId,
watchedIssueId: input.watchedIssueId,
stopFingerprint: input.stopFingerprint,
targets: input.leaves.map((leaf) => ({
@@ -492,6 +494,7 @@ export function classifyTaskWatchdogSubtree(input: TaskWatchdogClassifierInput):
});
const proofObligationFingerprint = stableProofObligationFingerprint({
companyId: input.watchdog.companyId,
+ watchdogId: input.watchdog.id,
watchedIssueId: input.watchdog.issueId,
stopFingerprint,
leaves,
@@ -1163,7 +1166,7 @@ export function taskWatchdogService(db: Db, deps: TaskWatchdogServiceDeps = {})
eq(issueRelations.companyId, companyId),
eq(issueRelations.type, "blocks"),
eq(issueRelations.relatedIssueId, issueId),
- sql`${blocker.status} <> 'done'`,
+ sql`${blocker.status} not in ('done', 'cancelled')`,
isNull(blocker.hiddenAt),
))
.limit(1)
diff --git a/ui/src/components/WatchdogStateCallout.test.tsx b/ui/src/components/WatchdogStateCallout.test.tsx
index 1fa27d41c1c..45807c346ae 100644
--- a/ui/src/components/WatchdogStateCallout.test.tsx
+++ b/ui/src/components/WatchdogStateCallout.test.tsx
@@ -162,6 +162,15 @@ describe("WatchdogStateCallout", () => {
expect(container.textContent).toContain("PAP-500");
});
+ it("does not show a phantom outstanding blocker section for deferred review paths", async () => {
+ getWatchdogMock.mockResolvedValue(watchdogWith("deferred"));
+ renderCallout();
+ await waitForText("Deferred");
+ expect(container.textContent).toContain("waiting path must complete");
+ expect(container.textContent).not.toContain("Outstanding");
+ expect(container.textContent).not.toContain("next check on blocker resolve");
+ });
+
it("shows the failed outcome with the recovery action owner and next step", async () => {
getWatchdogMock.mockResolvedValue(watchdogWith("failed"));
const recoveryAction = {
diff --git a/ui/src/components/WatchdogStateCallout.tsx b/ui/src/components/WatchdogStateCallout.tsx
index 206b52fb2df..04d5c5d4b47 100644
--- a/ui/src/components/WatchdogStateCallout.tsx
+++ b/ui/src/components/WatchdogStateCallout.tsx
@@ -68,6 +68,7 @@ function issueLink(id: string, identifier: string | null | undefined): string {
function outcomeStatement(
outcome: IssueWatchdogProofOutcomeSummary,
+ context: { hasOpenBlockers?: boolean; hasMonitor?: boolean } = {},
): string {
const classification = outcome.resultClassification;
switch (outcome.outcome) {
@@ -76,7 +77,13 @@ function outcomeStatement(
case "restored":
return "Restored live work inside the watched subtree and woke the responsible owner.";
case "deferred":
- return `Deferred verification — the proof obligation can't be checked yet (${classification}). It will re-check once, automatically, when the blocker resolves.`;
+ if (context.hasOpenBlockers) {
+ return `Deferred verification — the proof obligation can't be checked yet (${classification}). It will re-check once, automatically, when the blocker resolves.`;
+ }
+ if (context.hasMonitor) {
+ return `Deferred verification — the proof obligation can't be checked yet (${classification}). A scheduled follow-up will re-check it.`;
+ }
+ return `Deferred verification — the proof obligation can't be checked yet (${classification}). The waiting path must complete before verification can resume.`;
case "failed":
return `The proof obligation failed (${classification}). A recovery action was opened with the owner and next step.`;
case "dismissed":
@@ -219,6 +226,8 @@ export function WatchdogStateCallout({
const proof = data.latestProofOutcome ?? null;
const wdAgent = agentName(data.watchdogAgentId, agentMap);
const watchdogTaskHref = data.watchdogIssueId ? `/issues/${data.watchdogIssueId}` : null;
+ const openBlockers = (blockers ?? []).filter((b) => b.status !== "done" && b.status !== "cancelled");
+ const hasMonitor = !!monitorNextCheckAt;
// State 6 — armed but no review yet
if (!proof) {
@@ -272,7 +281,7 @@ export function WatchdogStateCallout({
>
}
badge={