From 3a5e75425b77851198aaa8d81591f7ff6dd3ff13 Mon Sep 17 00:00:00 2001 From: Dotta Date: Tue, 28 Jul 2026 20:53:20 +0000 Subject: [PATCH] fix(watchdogs): atomically claim self reviews Prevent concurrent watchdog evaluations from duplicating comments, trigger bookkeeping, activity, and wake requests for the same stopped fingerprint. Co-Authored-By: Paperclip --- .../task-watchdogs-scheduler.test.ts | 28 +++++++++++++ server/src/services/task-watchdogs.ts | 39 ++++++++++++------- 2 files changed, 53 insertions(+), 14 deletions(-) diff --git a/server/src/__tests__/task-watchdogs-scheduler.test.ts b/server/src/__tests__/task-watchdogs-scheduler.test.ts index fe87c883183..e828526f6e5 100644 --- a/server/src/__tests__/task-watchdogs-scheduler.test.ts +++ b/server/src/__tests__/task-watchdogs-scheduler.test.ts @@ -371,6 +371,34 @@ describeEmbeddedPostgres("task watchdog scheduler", () => { expect(commentsAfter).toHaveLength(1); }); + it("self mode atomically claims a stopped fingerprint across concurrent reconciliations", async () => { + const companyId = await seedCompany(); + const agentId = await seedAgent(companyId); + const sourceId = await seedIssue(companyId, { + identifier: "WDOG-SELF-RACE", + status: "done", + assigneeAgentId: agentId, + }); + await seedWatchdog(companyId, sourceId, agentId, { + mode: "self", + instructions: "Verify the report exists and passes lint.", + }); + const { service, wakes } = createService(); + + const results = await Promise.all([ + service.reconcileTaskWatchdogs({ companyId }), + service.reconcileTaskWatchdogs({ companyId }), + ]); + + expect(results.reduce((total, result) => total + result.triggered, 0)).toBe(1); + expect(results.reduce((total, result) => total + result.alreadyReviewed, 0)).toBe(1); + expect(wakes).toHaveLength(1); + const comments = await db.select().from(issueComments).where(eq(issueComments.issueId, sourceId)); + expect(comments).toHaveLength(1); + const [watchdog] = await db.select().from(issueWatchdogs).where(eq(issueWatchdogs.issueId, sourceId)); + expect(watchdog?.triggerCount).toBe(1); + }); + it("does not trigger while a non-watchdog descendant has live work", async () => { const companyId = await seedCompany(); const sourceId = await seedIssue(companyId, { identifier: "WDOG-2", status: "in_progress" }); diff --git a/server/src/services/task-watchdogs.ts b/server/src/services/task-watchdogs.ts index 48788bdf5d9..4fb9de153c0 100644 --- a/server/src/services/task-watchdogs.ts +++ b/server/src/services/task-watchdogs.ts @@ -1527,6 +1527,31 @@ export function taskWatchdogService(db: Db, deps: TaskWatchdogServiceDeps = {}) runId: string | null; }) { const { watchdog, sourceIssue, classification } = input; + const now = new Date(); + const claimed = await db + .update(issueWatchdogs) + .set({ + lastObservedFingerprint: classification.stopFingerprint, + lastObservedStopSnapshot: classification.stopSnapshot, + lastReviewedFingerprint: classification.stopFingerprint, + lastReviewedStopSnapshot: classification.stopSnapshot, + lastTriggeredAt: now, + triggerCount: sql`${issueWatchdogs.triggerCount} + 1`, + updatedAt: now, + }) + .where(and( + eq(issueWatchdogs.id, watchdog.id), + sql`${issueWatchdogs.lastReviewedFingerprint} is distinct from ${classification.stopFingerprint}`, + )) + .returning({ id: issueWatchdogs.id }); + if (claimed.length === 0) { + return { + ...classification, + state: "already_reviewed" as const, + reason: "The current stopped subtree fingerprint was already claimed for self review.", + }; + } + await issuesSvc.addComment( sourceIssue.id, buildSelfReviewComment({ @@ -1547,20 +1572,6 @@ export function taskWatchdogService(db: Db, deps: TaskWatchdogServiceDeps = {}) }, ); - const now = new Date(); - await db - .update(issueWatchdogs) - .set({ - lastObservedFingerprint: classification.stopFingerprint, - lastObservedStopSnapshot: classification.stopSnapshot, - lastReviewedFingerprint: classification.stopFingerprint, - lastReviewedStopSnapshot: classification.stopSnapshot, - lastTriggeredAt: now, - triggerCount: sql`${issueWatchdogs.triggerCount} + 1`, - updatedAt: now, - }) - .where(eq(issueWatchdogs.id, watchdog.id)); - await logActivity(db, { companyId: sourceIssue.companyId, actorType: "system",