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={} - statement={outcomeStatement(proof)} + statement={outcomeStatement(proof, { hasOpenBlockers: openBlockers.length > 0, hasMonitor })} />
) : null} @@ -355,24 +363,21 @@ export function WatchdogStateCallout({ } function OutstandingDeferred({ - blockers, + openBlockers, monitorNextCheckAt, - watchdogTaskHref, }: { - blockers?: IssueRelationIssueSummary[]; + openBlockers: IssueRelationIssueSummary[]; monitorNextCheckAt?: Date | string | null; - watchdogTaskHref: string | null; }) { - const open = (blockers ?? []).filter((b) => b.status !== "done" && b.status !== "cancelled"); const hasMonitor = !!monitorNextCheckAt; - if (open.length === 0 && !hasMonitor && !watchdogTaskHref) return null; + if (openBlockers.length === 0 && !hasMonitor) return null; return (
- {open.length > 0 ? ( + {openBlockers.length > 0 ? (
Blocked by - {open.map((b) => ( + {openBlockers.map((b) => (