diff --git a/packages/db/src/migrations/0111_task_watchdog_proof_outcomes.sql b/packages/db/src/migrations/0111_task_watchdog_proof_outcomes.sql new file mode 100644 index 00000000000..6d0e8ffe0b3 --- /dev/null +++ b/packages/db/src/migrations/0111_task_watchdog_proof_outcomes.sql @@ -0,0 +1,65 @@ +CREATE TABLE IF NOT EXISTS "issue_watchdog_proof_outcomes" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "company_id" uuid NOT NULL, + "watchdog_id" uuid NOT NULL, + "source_issue_id" uuid NOT NULL, + "watchdog_issue_id" uuid, + "target_issue_id" uuid, + "outcome" text NOT NULL, + "method" text NOT NULL, + "observed_at" timestamp with time zone DEFAULT now() NOT NULL, + "result_classification" text NOT NULL, + "redacted_details" jsonb DEFAULT '{}'::jsonb NOT NULL, + "stop_fingerprint" text NOT NULL, + "proof_obligation_fingerprint" text NOT NULL, + "created_by_run_id" uuid, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "issue_watchdog_proof_outcomes_outcome_chk" + CHECK ("outcome" IN ('accepted', 'restored', 'deferred', 'failed', 'dismissed')) +); +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "issue_watchdog_proof_outcomes" ADD CONSTRAINT "issue_watchdog_proof_outcomes_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "issue_watchdog_proof_outcomes" ADD CONSTRAINT "issue_watchdog_proof_outcomes_watchdog_id_issue_watchdogs_id_fk" FOREIGN KEY ("watchdog_id") REFERENCES "public"."issue_watchdogs"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "issue_watchdog_proof_outcomes" ADD CONSTRAINT "issue_watchdog_proof_outcomes_source_issue_id_issues_id_fk" FOREIGN KEY ("source_issue_id") REFERENCES "public"."issues"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "issue_watchdog_proof_outcomes" ADD CONSTRAINT "issue_watchdog_proof_outcomes_watchdog_issue_id_issues_id_fk" FOREIGN KEY ("watchdog_issue_id") REFERENCES "public"."issues"("id") ON DELETE set null ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "issue_watchdog_proof_outcomes" ADD CONSTRAINT "issue_watchdog_proof_outcomes_target_issue_id_issues_id_fk" FOREIGN KEY ("target_issue_id") REFERENCES "public"."issues"("id") ON DELETE set null ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "issue_watchdog_proof_outcomes" ADD CONSTRAINT "issue_watchdog_proof_outcomes_created_by_run_id_heartbeat_runs_id_fk" FOREIGN KEY ("created_by_run_id") REFERENCES "public"."heartbeat_runs"("id") ON DELETE set null ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "issue_watchdog_proof_outcomes_company_watchdog_observed_idx" + ON "issue_watchdog_proof_outcomes" USING btree ("company_id","watchdog_id","observed_at"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "issue_watchdog_proof_outcomes_company_source_idx" + ON "issue_watchdog_proof_outcomes" USING btree ("company_id","source_issue_id"); +--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "issue_watchdog_proof_outcomes_unique_proof_uq" + ON "issue_watchdog_proof_outcomes" USING btree ("company_id","watchdog_id","stop_fingerprint","proof_obligation_fingerprint"); diff --git a/packages/db/src/migrations/meta/_journal.json b/packages/db/src/migrations/meta/_journal.json index 16ba529fecf..8a120ada83b 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -778,6 +778,13 @@ "when": 1781902400000, "tag": "0110_document_company_cascade", "breakpoints": true + }, + { + "idx": 111, + "version": "7", + "when": 1782340000000, + "tag": "0111_task_watchdog_proof_outcomes", + "breakpoints": true } ] } diff --git a/packages/db/src/schema/index.ts b/packages/db/src/schema/index.ts index b4e61c35fb5..d3a54026a5f 100644 --- a/packages/db/src/schema/index.ts +++ b/packages/db/src/schema/index.ts @@ -33,6 +33,7 @@ export { projectGoals } from "./project_goals.js"; export { goals } from "./goals.js"; export { issues } from "./issues.js"; export { issueWatchdogs } from "./issue_watchdogs.js"; +export { issueWatchdogProofOutcomes } from "./issue_watchdog_proof_outcomes.js"; export { issuePlanDecompositions } from "./issue_plan_decompositions.js"; export { issueRecoveryActions } from "./issue_recovery_actions.js"; export { issueReferenceMentions } from "./issue_reference_mentions.js"; diff --git a/packages/db/src/schema/issue_watchdog_proof_outcomes.ts b/packages/db/src/schema/issue_watchdog_proof_outcomes.ts new file mode 100644 index 00000000000..4b9699a13f9 --- /dev/null +++ b/packages/db/src/schema/issue_watchdog_proof_outcomes.ts @@ -0,0 +1,49 @@ +import { sql } from "drizzle-orm"; +import { check, index, jsonb, pgTable, text, timestamp, uniqueIndex, uuid } from "drizzle-orm/pg-core"; +import { companies } from "./companies.js"; +import { heartbeatRuns } from "./heartbeat_runs.js"; +import { issues } from "./issues.js"; +import { issueWatchdogs } from "./issue_watchdogs.js"; + +export const issueWatchdogProofOutcomes = pgTable( + "issue_watchdog_proof_outcomes", + { + id: uuid("id").primaryKey().defaultRandom(), + companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }), + watchdogId: uuid("watchdog_id").notNull().references(() => issueWatchdogs.id, { onDelete: "cascade" }), + sourceIssueId: uuid("source_issue_id").notNull().references(() => issues.id, { onDelete: "cascade" }), + watchdogIssueId: uuid("watchdog_issue_id").references(() => issues.id, { onDelete: "set null" }), + targetIssueId: uuid("target_issue_id").references(() => issues.id, { onDelete: "set null" }), + outcome: text("outcome").notNull(), + method: text("method").notNull(), + observedAt: timestamp("observed_at", { withTimezone: true }).notNull().defaultNow(), + resultClassification: text("result_classification").notNull(), + redactedDetails: jsonb("redacted_details").$type>().notNull().default({}), + stopFingerprint: text("stop_fingerprint").notNull(), + proofObligationFingerprint: text("proof_obligation_fingerprint").notNull(), + createdByRunId: uuid("created_by_run_id").references(() => heartbeatRuns.id, { onDelete: "set null" }), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => ({ + companyWatchdogObservedIdx: index("issue_watchdog_proof_outcomes_company_watchdog_observed_idx").on( + table.companyId, + table.watchdogId, + table.observedAt, + ), + companySourceIdx: index("issue_watchdog_proof_outcomes_company_source_idx").on( + table.companyId, + table.sourceIssueId, + ), + uniqueProofIdx: uniqueIndex("issue_watchdog_proof_outcomes_unique_proof_uq").on( + table.companyId, + table.watchdogId, + table.stopFingerprint, + table.proofObligationFingerprint, + ), + outcomeCheck: check( + "issue_watchdog_proof_outcomes_outcome_chk", + sql`${table.outcome} IN ('accepted', 'restored', 'deferred', 'failed', 'dismissed')`, + ), + }), +); diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index 040dc348f56..0f8fee58319 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -271,6 +271,14 @@ export type PluginIssueOriginKind = `plugin:${string}`; export type IssueOriginKind = BuiltInIssueOriginKind | PluginIssueOriginKind; export const ISSUE_WATCHDOG_DISCOVERY_KINDS = ["product_bug", "platform_bug"] as const; export type IssueWatchdogDiscoveryKind = (typeof ISSUE_WATCHDOG_DISCOVERY_KINDS)[number]; +export const ISSUE_WATCHDOG_PROOF_OUTCOMES = [ + "accepted", + "restored", + "deferred", + "failed", + "dismissed", +] as const; +export type IssueWatchdogProofOutcome = (typeof ISSUE_WATCHDOG_PROOF_OUTCOMES)[number]; export const ISSUE_SURFACE_VISIBILITIES = ["default", "plugin_operation"] as const; export type IssueSurfaceVisibility = (typeof ISSUE_SURFACE_VISIBILITIES)[number]; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 0de59487b46..063f283e07a 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -74,6 +74,7 @@ export { ISSUE_ORIGIN_KINDS, TASK_WATCHDOG_PRODUCT_BUG_ORIGIN_KIND, ISSUE_WATCHDOG_DISCOVERY_KINDS, + ISSUE_WATCHDOG_PROOF_OUTCOMES, ISSUE_SURFACE_VISIBILITIES, ISSUE_RECOVERY_ACTION_KINDS, ISSUE_RECOVERY_ACTION_STATUSES, @@ -208,6 +209,7 @@ export { type PluginIssueOriginKind, type IssueOriginKind, type IssueWatchdogDiscoveryKind, + type IssueWatchdogProofOutcome, type IssueSurfaceVisibility, type IssueRecoveryActionKind, type IssueRecoveryActionStatus, diff --git a/packages/shared/src/types/issue.ts b/packages/shared/src/types/issue.ts index 2cc8d8b7c31..3ea060bd5e9 100644 --- a/packages/shared/src/types/issue.ts +++ b/packages/shared/src/types/issue.ts @@ -25,6 +25,7 @@ import type { IssueThreadInteractionKind, IssueThreadInteractionStatus, IssueStatus, + IssueWatchdogProofOutcome, } from "../constants.js"; import type { Goal } from "./goal.js"; import type { Project, ProjectWorkspace } from "./project.js"; @@ -503,6 +504,21 @@ export interface IssueExecutionDecision { export type IssueWatchdogStatus = "active" | "disabled"; +export interface IssueWatchdogProofOutcomeSummary { + id: string; + outcome: IssueWatchdogProofOutcome; + targetIssueId: string | null; + method: string; + observedAt: Date; + resultClassification: string; + redactedDetails: Record; + stopFingerprint: string; + proofObligationFingerprint: string; + createdByRunId: string | null; + createdAt: Date; + updatedAt: Date; +} + export interface IssueWatchdogSummary { id: string; companyId: string; @@ -515,6 +531,7 @@ export interface IssueWatchdogSummary { lastReviewedFingerprint: string | null; lastTriggeredAt: Date | null; lastCompletedAt: Date | null; + latestProofOutcome?: IssueWatchdogProofOutcomeSummary | null; triggerCount: number; createdAt: Date; updatedAt: Date; diff --git a/server/src/__tests__/task-watchdogs-classifier.test.ts b/server/src/__tests__/task-watchdogs-classifier.test.ts index aca41e096d2..75aaec1e748 100644 --- a/server/src/__tests__/task-watchdogs-classifier.test.ts +++ b/server/src/__tests__/task-watchdogs-classifier.test.ts @@ -25,6 +25,7 @@ function issue(overrides: Partial = {}): TaskWatchd function classify(overrides: Partial[0]> = {}) { return classifyTaskWatchdogSubtree({ watchdog: { + id: "watchdog-config-1", companyId, issueId: sourceId, lastReviewedFingerprint: null, @@ -62,6 +63,7 @@ describe("task watchdog subtree classifier", () => { expect(result.state).toBe("stopped"); if (result.state !== "stopped") return; expect(result.stopFingerprint).toMatch(/^task_watchdog_stop:/); + expect(result.proofObligationFingerprint).toMatch(/^task_watchdog_proof:/); expect(result.stoppedLeaves).toEqual([ expect.objectContaining({ issueId: childId, @@ -80,6 +82,7 @@ describe("task watchdog subtree classifier", () => { const reviewed = classify({ watchdog: { + id: "watchdog-config-1", companyId, issueId: sourceId, lastReviewedFingerprint: stopped.stopFingerprint, @@ -186,6 +189,7 @@ describe("task watchdog subtree classifier", () => { it("does not evaluate a task-watchdog issue as a watched source", () => { const result = classify({ watchdog: { + id: "watchdog-config-1", companyId, issueId: watchdogId, lastReviewedFingerprint: null, @@ -202,4 +206,63 @@ describe("task watchdog subtree classifier", () => { expect(result.state).toBe("not_applicable"); }); + + it("changes the stopped proof when blocker state changes", () => { + const blockedIssue = issue({ status: "blocked" }); + const blocked = classify({ + issues: [blockedIssue, issue({ id: "blocker-1", identifier: "PAP-5", title: "Blocker", status: "blocked" })], + blockers: [{ + companyId, + blockedIssueId: sourceId, + blockerIssueId: "blocker-1", + blockerStatus: "blocked", + blockerUpdatedAt: new Date("2026-06-17T21:00:00.000Z"), + }], + }); + const resolved = classify({ + issues: [blockedIssue, issue({ id: "blocker-1", identifier: "PAP-5", title: "Blocker", status: "done" })], + blockers: [{ + companyId, + blockedIssueId: sourceId, + blockerIssueId: "blocker-1", + blockerStatus: "done", + blockerUpdatedAt: new Date("2026-06-17T22:00:00.000Z"), + }], + }); + + expect(blocked.state).toBe("stopped"); + expect(resolved.state).toBe("stopped"); + if (blocked.state !== "stopped" || resolved.state !== "stopped") return; + expect(blocked.stoppedLeaves[0]?.blockers).toEqual([ + { issueId: "blocker-1", status: "blocked", updatedAt: "2026-06-17T21:00:00.000Z" }, + ]); + expect(resolved.stoppedLeaves[0]?.blockers).toEqual([ + { issueId: "blocker-1", status: "done", updatedAt: "2026-06-17T22:00:00.000Z" }, + ]); + expect(resolved.stopFingerprint).not.toBe(blocked.stopFingerprint); + expect(resolved.proofObligationFingerprint).not.toBe(blocked.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"); + if (stopped.state !== "stopped") return; + + const repeated = classify({ + issues: [issue({ status: "done" })], + proofOutcomes: [{ + companyId, + watchdogId: "watchdog-config-1", + stopFingerprint: stopped.stopFingerprint, + proofObligationFingerprint: stopped.proofObligationFingerprint, + outcome: "accepted", + }], + }); + + expect(repeated).toMatchObject({ + state: "already_reviewed", + stopFingerprint: stopped.stopFingerprint, + proofObligationFingerprint: stopped.proofObligationFingerprint, + }); + }); }); diff --git a/server/src/__tests__/task-watchdogs-scheduler.test.ts b/server/src/__tests__/task-watchdogs-scheduler.test.ts index 5800e151542..0f3646e02a0 100644 --- a/server/src/__tests__/task-watchdogs-scheduler.test.ts +++ b/server/src/__tests__/task-watchdogs-scheduler.test.ts @@ -11,6 +11,8 @@ import { heartbeatRuns, issueComments, issueDocuments, + issueRelations, + issueWatchdogProofOutcomes, issueWorkProducts, issues, issueWatchdogs, @@ -45,6 +47,8 @@ describeEmbeddedPostgres("task watchdog scheduler", () => { await db.delete(issueDocuments); await db.delete(documents); await db.delete(issueComments); + await db.delete(issueWatchdogProofOutcomes); + await db.delete(issueRelations); await db.delete(heartbeatRuns); await db.delete(agentWakeupRequests); await db.delete(issueWatchdogs); @@ -334,6 +338,16 @@ describeEmbeddedPostgres("task watchdog scheduler", () => { expect(reviewed).toMatchObject({ checked: 1, triggered: 0, alreadyReviewed: 1 }); const [reviewedWatchdog] = await db.select().from(issueWatchdogs).where(eq(issueWatchdogs.issueId, sourceId)); expect(reviewedWatchdog?.lastReviewedFingerprint).toBe(firstWatchdog?.lastObservedFingerprint); + const acceptedOutcomes = await db + .select() + .from(issueWatchdogProofOutcomes) + .where(eq(issueWatchdogProofOutcomes.watchdogId, firstWatchdog!.id)); + expect(acceptedOutcomes).toHaveLength(1); + expect(acceptedOutcomes[0]).toMatchObject({ + outcome: "accepted", + stopFingerprint: firstWatchdog?.lastObservedFingerprint, + resultClassification: "stopped", + }); await db .update(issues) @@ -356,6 +370,75 @@ describeEmbeddedPostgres("task watchdog scheduler", () => { expect(wakes.length).toBe(2); }); + it("persists deferred proof outcomes and rechecks once blocker evidence changes", async () => { + const companyId = await seedCompany(); + const sourceId = await seedIssue(companyId, { identifier: "WDOG-DEFER", status: "blocked" }); + const blockerId = await seedIssue(companyId, { identifier: "WDOG-BLOCKER", status: "blocked" }); + const agentId = await seedAgent(companyId); + await db.insert(issueRelations).values({ + companyId, + issueId: blockerId, + relatedIssueId: sourceId, + type: "blocks", + }); + await seedWatchdog(companyId, sourceId, agentId); + const { service, wakes } = createService(); + + const first = await service.reconcileTaskWatchdogs({ companyId }); + expect(first).toMatchObject({ checked: 1, triggered: 1 }); + const [watchdog] = await db.select().from(issueWatchdogs).where(eq(issueWatchdogs.issueId, sourceId)); + const watchdogIssueId = watchdog!.watchdogIssueId!; + const firstStopFingerprint = watchdog!.lastObservedFingerprint!; + 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", + }); + wakes.length = 0; + + const deferred = await service.reconcileTaskWatchdogs({ companyId }); + expect(deferred).toMatchObject({ checked: 1, triggered: 0, alreadyReviewed: 1 }); + expect(wakes).toHaveLength(0); + const [deferredWatchdog] = await db.select().from(issueWatchdogs).where(eq(issueWatchdogs.issueId, sourceId)); + expect(deferredWatchdog?.lastReviewedFingerprint).toBe(firstStopFingerprint); + let outcomes = await db + .select() + .from(issueWatchdogProofOutcomes) + .where(eq(issueWatchdogProofOutcomes.watchdogId, watchdog!.id)); + expect(outcomes).toHaveLength(1); + expect(outcomes[0]).toMatchObject({ + outcome: "deferred", + stopFingerprint: firstStopFingerprint, + resultClassification: "stopped", + }); + + const duplicate = await service.reconcileTaskWatchdogs({ companyId }); + expect(duplicate).toMatchObject({ checked: 1, triggered: 0, alreadyReviewed: 1 }); + outcomes = await db + .select() + .from(issueWatchdogProofOutcomes) + .where(eq(issueWatchdogProofOutcomes.watchdogId, watchdog!.id)); + expect(outcomes).toHaveLength(1); + + await db + .update(issues) + .set({ status: "done", updatedAt: new Date(Date.now() + 60_000) }) + .where(eq(issues.id, blockerId)); + const recheck = await service.reconcileTaskWatchdogs({ companyId }); + + expect(recheck).toMatchObject({ checked: 1, triggered: 1 }); + expect(wakes).toHaveLength(1); + const [afterResolution] = await db.select().from(issueWatchdogs).where(eq(issueWatchdogs.issueId, sourceId)); + expect(afterResolution?.lastObservedFingerprint).not.toBe(firstStopFingerprint); + outcomes = await db + .select() + .from(issueWatchdogProofOutcomes) + .where(eq(issueWatchdogProofOutcomes.watchdogId, watchdog!.id)); + expect(outcomes.map((outcome) => outcome.outcome).sort()).toEqual(["deferred", "failed"]); + }); + 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/routes/issues.ts b/server/src/routes/issues.ts index 2c2e6b32d6d..9f6017b3a86 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -65,6 +65,7 @@ import { type SourceTrustMetadata, type SuccessfulRunHandoffState, } from "@paperclipai/shared"; +import { canonicalizeExternalObjectUrl, findExternalObjectUrlMatches } from "@paperclipai/shared/external-objects-server"; import { trackAgentTaskCompleted } from "@paperclipai/shared/telemetry"; import { getTelemetryClient } from "../telemetry.js"; import type { StorageService } from "../storage/types.js"; @@ -258,6 +259,7 @@ function noopTaskWatchdogService(): TaskWatchdogService { reason: "Task watchdog service unavailable in this route context.", includedIssueIds: [], stopFingerprint: "task_watchdog_stop:unavailable", + proofObligationFingerprint: "task_watchdog_proof:unavailable", stoppedLeaves: [], }, }), @@ -2308,11 +2310,23 @@ export function issueRoutes( if (!kind) return null; const evidenceMarkdown = typeof record.evidenceMarkdown === "string" && record.evidenceMarkdown.trim().length > 0 - ? record.evidenceMarkdown.trim() + ? sanitizeWatchdogDiscoveryEvidenceMarkdown(record.evidenceMarkdown) : null; return { kind, evidenceMarkdown }; } + function sanitizeWatchdogDiscoveryEvidenceMarkdown(input: string) { + let sanitized = input; + const matches = findExternalObjectUrlMatches(input); + for (let index = matches.length - 1; index >= 0; index -= 1) { + const match = matches[index]!; + const replacement = canonicalizeExternalObjectUrl(match.matchedText)?.sanitizedCanonicalUrl ?? "[redacted-url]"; + sanitized = `${sanitized.slice(0, match.index)}${replacement}${sanitized.slice(match.index + match.length)}`; + } + const redacted = redactSensitiveText(sanitized).trim(); + return redacted.length > 0 ? redacted : null; + } + function issueMarkdownLink(issue: { id: string; identifier?: string | null }) { const identifier = issue.identifier?.trim(); if (!identifier) return `\`${issue.id}\``; diff --git a/server/src/services/task-watchdogs.ts b/server/src/services/task-watchdogs.ts index f5dc6eb3f4b..0244f40d0ef 100644 --- a/server/src/services/task-watchdogs.ts +++ b/server/src/services/task-watchdogs.ts @@ -1,5 +1,6 @@ import { createHash } from "node:crypto"; import { and, asc, eq, inArray, isNull, or, sql } from "drizzle-orm"; +import { alias } from "drizzle-orm/pg-core"; import type { Db } from "@paperclipai/db"; import { agentWakeupRequests, @@ -12,10 +13,11 @@ import { issueRelations, issues, issueThreadInteractions, + issueWatchdogProofOutcomes, issueWatchdogs, issueWorkProducts, } from "@paperclipai/db"; -import type { IssueWatchdog, IssueWatchdogSummary } from "@paperclipai/shared"; +import type { IssueWatchdog, IssueWatchdogProofOutcome, IssueWatchdogSummary } from "@paperclipai/shared"; import { conflict, notFound } from "../errors.js"; import { parseObject } from "../adapters/utils.js"; import { logActivity } from "./activity-log.js"; @@ -93,11 +95,13 @@ export type TaskWatchdogClassifierRelation = { companyId: string; blockerIssueId: string; blockedIssueId: string; + blockerStatus?: string | null; + blockerUpdatedAt?: Date | string | null; }; export type TaskWatchdogClassifierConfig = Pick< IssueWatchdogSummary, - "companyId" | "issueId" | "lastReviewedFingerprint" + "id" | "companyId" | "issueId" | "lastReviewedFingerprint" >; export type TaskWatchdogStoppedLeaf = { @@ -108,6 +112,11 @@ export type TaskWatchdogStoppedLeaf = { assigneeAgentId: string | null; assigneeUserId: string | null; blockerIssueIds: string[]; + blockers: Array<{ + issueId: string; + status: string | null; + updatedAt: string | null; + }>; pendingInteractionIds: string[]; pendingApprovalIds: string[]; updatedAt: string; @@ -116,6 +125,14 @@ export type TaskWatchdogStoppedLeaf = { latestWorkProductAt: string | null; }; +export type TaskWatchdogProofOutcomeRecord = { + companyId: string; + watchdogId: string; + stopFingerprint: string; + proofObligationFingerprint: string; + outcome: IssueWatchdogProofOutcome | string; +}; + export type TaskWatchdogClassifierResult = | { state: "not_applicable"; @@ -139,6 +156,7 @@ export type TaskWatchdogClassifierResult = reason: string; includedIssueIds: string[]; stopFingerprint: string; + proofObligationFingerprint: string; stoppedLeaves: TaskWatchdogStoppedLeaf[]; } | { @@ -146,6 +164,7 @@ export type TaskWatchdogClassifierResult = reason: string; includedIssueIds: string[]; stopFingerprint: string; + proofObligationFingerprint: string; stoppedLeaves: TaskWatchdogStoppedLeaf[]; }; @@ -157,6 +176,7 @@ export type TaskWatchdogClassifierInput = { blockers?: TaskWatchdogClassifierRelation[]; pendingInteractions?: TaskWatchdogClassifierWaitingPath[]; pendingApprovals?: TaskWatchdogClassifierWaitingPath[]; + proofOutcomes?: TaskWatchdogProofOutcomeRecord[]; // Timestamp the evaluation reads its snapshot at. When provided together // with a positive `firstRunGraceMs`, the classifier suppresses a // stopped-subtree verdict for issues created within the grace window that @@ -279,6 +299,36 @@ function stableStopFingerprint(input: { return `task_watchdog_stop:${createHash("sha256").update(payload).digest("hex")}`; } +function stableProofObligationFingerprint(input: { + companyId: string; + watchedIssueId: string; + stopFingerprint: string; + leaves: TaskWatchdogStoppedLeaf[]; +}) { + const payload = JSON.stringify({ + version: 1, + companyId: input.companyId, + watchedIssueId: input.watchedIssueId, + stopFingerprint: input.stopFingerprint, + targets: input.leaves.map((leaf) => ({ + issueId: leaf.issueId, + status: leaf.status, + blockerIssueIds: leaf.blockerIssueIds, + blockers: leaf.blockers, + pendingInteractionIds: leaf.pendingInteractionIds, + pendingApprovalIds: leaf.pendingApprovalIds, + latestCommentAt: leaf.latestCommentAt, + latestDocumentAt: leaf.latestDocumentAt, + latestWorkProductAt: leaf.latestWorkProductAt, + })), + }); + return `task_watchdog_proof:${createHash("sha256").update(payload).digest("hex")}`; +} + +function proofOutcomeSuppressesRepeat(outcome: string) { + return outcome === "accepted" || outcome === "deferred" || outcome === "dismissed"; +} + export function classifyTaskWatchdogSubtree(input: TaskWatchdogClassifierInput): TaskWatchdogClassifierResult { const issuesById = new Map(input.issues.map((issue) => [issue.id, issue])); const root = issuesById.get(input.watchdog.issueId); @@ -370,45 +420,70 @@ export function classifyTaskWatchdogSubtree(input: TaskWatchdogClassifierInput): list.push(issue.id); includedChildrenByParentId.set(issue.parentId, list); } - const blockersByIssueId = new Map(); + const blockersByIssueId = new Map(); for (const relation of input.blockers ?? []) { if (relation.companyId !== input.watchdog.companyId) continue; if (!includedIdSet.has(relation.blockedIssueId)) continue; const list = blockersByIssueId.get(relation.blockedIssueId) ?? []; - list.push(relation.blockerIssueId); + list.push(relation); blockersByIssueId.set(relation.blockedIssueId, list); } const leaves = included .filter((issue) => (includedChildrenByParentId.get(issue.id) ?? []).length === 0) .sort((left, right) => left.id.localeCompare(right.id)) - .map((issue) => ({ - issueId: issue.id, - identifier: issue.identifier, - title: issue.title, - status: issue.status, - assigneeAgentId: issue.assigneeAgentId, - assigneeUserId: issue.assigneeUserId, - blockerIssueIds: [...new Set(blockersByIssueId.get(issue.id) ?? [])].sort(), - pendingInteractionIds: waitingPathIds(input.pendingInteractions, input.watchdog.companyId, issue.id), - pendingApprovalIds: waitingPathIds(input.pendingApprovals, input.watchdog.companyId, issue.id), - updatedAt: issueUpdatedAtIso(issue), - latestCommentAt: optionalIso(issue.latestCommentAt), - latestDocumentAt: optionalIso(issue.latestDocumentAt), - latestWorkProductAt: optionalIso(issue.latestWorkProductAt), - })); + .map((issue) => { + const blockerEvidence = [...(blockersByIssueId.get(issue.id) ?? [])] + .sort((left, right) => left.blockerIssueId.localeCompare(right.blockerIssueId)) + .map((relation) => ({ + issueId: relation.blockerIssueId, + status: relation.blockerStatus ?? null, + updatedAt: optionalIso(relation.blockerUpdatedAt), + })); + return { + issueId: issue.id, + identifier: issue.identifier, + title: issue.title, + status: issue.status, + assigneeAgentId: issue.assigneeAgentId, + assigneeUserId: issue.assigneeUserId, + blockerIssueIds: blockerEvidence.map((blocker) => blocker.issueId), + blockers: blockerEvidence, + pendingInteractionIds: waitingPathIds(input.pendingInteractions, input.watchdog.companyId, issue.id), + pendingApprovalIds: waitingPathIds(input.pendingApprovals, input.watchdog.companyId, issue.id), + updatedAt: issueUpdatedAtIso(issue), + latestCommentAt: optionalIso(issue.latestCommentAt), + latestDocumentAt: optionalIso(issue.latestDocumentAt), + latestWorkProductAt: optionalIso(issue.latestWorkProductAt), + }; + }); const stopFingerprint = stableStopFingerprint({ companyId: input.watchdog.companyId, watchedIssueId: input.watchdog.issueId, leaves, }); + const proofObligationFingerprint = stableProofObligationFingerprint({ + companyId: input.watchdog.companyId, + watchedIssueId: input.watchdog.issueId, + stopFingerprint, + leaves, + }); - if (input.watchdog.lastReviewedFingerprint === stopFingerprint) { + const alreadyRecordedProofOutcome = (input.proofOutcomes ?? []).some((outcome) => + outcome.companyId === input.watchdog.companyId && + outcome.watchdogId === input.watchdog.id && + outcome.stopFingerprint === stopFingerprint && + outcome.proofObligationFingerprint === proofObligationFingerprint && + proofOutcomeSuppressesRepeat(String(outcome.outcome)) + ); + + if (input.watchdog.lastReviewedFingerprint === stopFingerprint || alreadyRecordedProofOutcome) { return { state: "already_reviewed", reason: "The current stopped subtree fingerprint was already reviewed by the watchdog.", includedIssueIds: includedIds, stopFingerprint, + proofObligationFingerprint, stoppedLeaves: leaves, }; } @@ -418,6 +493,7 @@ export function classifyTaskWatchdogSubtree(input: TaskWatchdogClassifierInput): reason: "No issue in the watched subtree has a live execution path.", includedIssueIds: includedIds, stopFingerprint, + proofObligationFingerprint, stoppedLeaves: leaves, }; } @@ -499,6 +575,7 @@ function taskWatchdogWakeIdempotencyKey(watchdogId: string, stopFingerprint: str function buildStoppedFingerprintComment(input: { sourceIssue: Pick; stopFingerprint: string; + proofObligationFingerprint: string; stoppedLeaves: TaskWatchdogStoppedLeaf[]; resumed: boolean; }) { @@ -513,6 +590,7 @@ function buildStoppedFingerprintComment(input: { "", `Watched issue: ${input.sourceIssue.identifier ?? input.sourceIssue.id}`, `Stopped fingerprint: \`${input.stopFingerprint}\``, + `Proof-obligation fingerprint: \`${input.proofObligationFingerprint}\``, "", "Stopped leaves:", ...(leafLines.length > 0 ? leafLines : ["- No leaf issues found."]), @@ -523,6 +601,7 @@ function buildStoppedFingerprintComment(input: { function stoppedFingerprintMetadata(input: { sourceIssueId: string; stopFingerprint: string; + proofObligationFingerprint: string; resumed: boolean; }) { return { @@ -533,6 +612,7 @@ function stoppedFingerprintMetadata(input: { rows: [ { type: "text" as const, label: "Watched issue", text: input.sourceIssueId }, { type: "text" as const, label: "Stopped fingerprint", text: input.stopFingerprint }, + { type: "text" as const, label: "Proof obligation", text: input.proofObligationFingerprint }, { type: "text" as const, label: "Resume intent", text: input.resumed ? "true" : "false" }, ], }, @@ -556,6 +636,7 @@ function watchdogWakeContext(input: { watchedIssueIdentifier: input.sourceIssue.identifier, watchedIssueTitle: input.sourceIssue.title, stopFingerprint: input.classification.stopFingerprint, + proofObligationFingerprint: input.classification.proofObligationFingerprint, capabilities: { targetScope: { watchedIssueId: input.sourceIssue.id, @@ -587,6 +668,7 @@ function watchdogWakeContext(input: { watchedIssueId: input.sourceIssue.id, watchedIssueIdentifier: input.sourceIssue.identifier, stopFingerprint: input.classification.stopFingerprint, + proofObligationFingerprint: input.classification.proofObligationFingerprint, stoppedLeaves: input.classification.stoppedLeaves, customInstructions: input.watchdog.instructions, resumeIntent: true, @@ -600,15 +682,6 @@ function isTerminalIssueStatus(status: string) { ); } -function isWatchdogReviewDisposition(issue: Pick< - IssueRow, - "status" | "assigneeUserId" | "executionState" | "monitorNextCheckAt" ->, hasPendingReviewPath: boolean) { - if (issue.status === "done" || issue.status === "blocked") return true; - if (issue.status !== "in_review") return false; - return Boolean(issue.assigneeUserId || issue.executionState || issue.monitorNextCheckAt || hasPendingReviewPath); -} - function isUniqueConstraintConflict(error: unknown, constraintName: string) { const queue: unknown[] = [error]; const messages: string[] = []; @@ -791,12 +864,14 @@ export function taskWatchdogService(db: Db, deps: TaskWatchdogServiceDeps = {}) blockers: [], pendingInteractions: [], pendingApprovals: [], + proofOutcomes: [], evaluatedAt: new Date(), firstRunGraceMs: TASK_WATCHDOG_FIRST_RUN_GRACE_MS, completedRunIssueIds: [], } satisfies TaskWatchdogClassifierInput; } + const blockerIssues = alias(issues, "task_watchdog_blocker_issues"); const [ activeRunRows, activeIssueRunRows, @@ -807,6 +882,7 @@ export function taskWatchdogService(db: Db, deps: TaskWatchdogServiceDeps = {}) commentActivityRows, documentActivityRows, workProductActivityRows, + proofOutcomeRows, ] = await Promise.all([ db .select({ @@ -862,8 +938,11 @@ export function taskWatchdogService(db: Db, deps: TaskWatchdogServiceDeps = {}) companyId: issueRelations.companyId, blockerIssueId: issueRelations.issueId, blockedIssueId: issueRelations.relatedIssueId, + blockerStatus: blockerIssues.status, + blockerUpdatedAt: blockerIssues.updatedAt, }) .from(issueRelations) + .innerJoin(blockerIssues, eq(issueRelations.issueId, blockerIssues.id)) .where(and( eq(issueRelations.companyId, companyId), eq(issueRelations.type, "blocks"), @@ -930,6 +1009,19 @@ export function taskWatchdogService(db: Db, deps: TaskWatchdogServiceDeps = {}) inArray(issueWorkProducts.issueId, subtreeIssueIds), )) .groupBy(issueWorkProducts.issueId), + db + .select({ + companyId: issueWatchdogProofOutcomes.companyId, + watchdogId: issueWatchdogProofOutcomes.watchdogId, + stopFingerprint: issueWatchdogProofOutcomes.stopFingerprint, + proofObligationFingerprint: issueWatchdogProofOutcomes.proofObligationFingerprint, + outcome: issueWatchdogProofOutcomes.outcome, + }) + .from(issueWatchdogProofOutcomes) + .where(and( + eq(issueWatchdogProofOutcomes.companyId, companyId), + eq(issueWatchdogProofOutcomes.watchdogId, watchdog.id), + )), ]); const latestCommentByIssueId = new Map(commentActivityRows.map((row) => [row.issueId, row.latestAt])); const latestDocumentByIssueId = new Map(documentActivityRows.map((row) => [row.issueId, row.latestAt])); @@ -972,6 +1064,7 @@ export function taskWatchdogService(db: Db, deps: TaskWatchdogServiceDeps = {}) blockers: blockerRows, pendingInteractions: interactionRows, pendingApprovals: approvalRows, + proofOutcomes: proofOutcomeRows, evaluatedAt, firstRunGraceMs: TASK_WATCHDOG_FIRST_RUN_GRACE_MS, completedRunIssueIds, @@ -1032,8 +1125,37 @@ export function taskWatchdogService(db: Db, deps: TaskWatchdogServiceDeps = {}) .then((rows) => rows[0] ?? null); } + async function issueHasUnresolvedBlocker(companyId: string, issueId: string) { + const blocker = alias(issues, "task_watchdog_waiting_blocker"); + return db + .select({ id: blocker.id }) + .from(issueRelations) + .innerJoin(blocker, eq(issueRelations.issueId, blocker.id)) + .where(and( + eq(issueRelations.companyId, companyId), + eq(issueRelations.type, "blocks"), + eq(issueRelations.relatedIssueId, issueId), + sql`${blocker.status} <> 'done'`, + isNull(blocker.hiddenAt), + )) + .limit(1) + .then((rows) => rows[0] ?? null); + } + async function hasLivePathForIssue(companyId: string, issueId: string) { - const [run, issueRun, wake] = await Promise.all([ + const [issue, run, issueRun, wake] = await Promise.all([ + db + .select({ + id: issues.id, + status: issues.status, + assigneeUserId: issues.assigneeUserId, + executionState: issues.executionState, + monitorNextCheckAt: issues.monitorNextCheckAt, + }) + .from(issues) + .where(and(eq(issues.companyId, companyId), eq(issues.id, issueId), isNull(issues.hiddenAt))) + .limit(1) + .then((rows) => rows[0] ?? null), db .select({ id: heartbeatRuns.id }) .from(heartbeatRuns) @@ -1070,7 +1192,13 @@ export function taskWatchdogService(db: Db, deps: TaskWatchdogServiceDeps = {}) .limit(1) .then((rows) => rows[0] ?? null), ]); - return Boolean(run || issueRun || wake); + if (run || issueRun || wake) return true; + if (!issue) return false; + if (issue.monitorNextCheckAt) return true; + if (issue.status === "blocked" && await issueHasUnresolvedBlocker(companyId, issueId)) return true; + if (issue.status !== "in_review") return false; + if (issue.assigneeUserId || issue.executionState) return true; + return watchdogIssueHasPendingReviewPath(companyId, issueId); } async function watchdogIssueHasPendingReviewPath(companyId: string, issueId: string) { @@ -1100,24 +1228,163 @@ export function taskWatchdogService(db: Db, deps: TaskWatchdogServiceDeps = {}) return Boolean(interaction || approval); } - async function markTerminalWatchdogIssueReviewed(watchdog: IssueWatchdogRow, opts: { runId?: string | null } = {}) { - if (!watchdog.watchdogIssueId || !watchdog.lastObservedFingerprint) return watchdog; + function fallbackProofObligationFingerprint(input: { + companyId: string; + watchdogId: string; + sourceIssueId: string; + stopFingerprint: string; + classificationState: string; + }) { + const payload = JSON.stringify({ version: 1, ...input }); + return `task_watchdog_proof:${createHash("sha256").update(payload).digest("hex")}`; + } + + async function recordWatchdogProofOutcome(input: { + watchdog: IssueWatchdogRow; + watchdogIssue: IssueRow; + outcome: IssueWatchdogProofOutcome; + method: string; + stopFingerprint: string; + proofObligationFingerprint: string; + resultClassification: string; + redactedDetails: Record; + runId?: string | null; + }) { + const now = new Date(); + const [row] = await db + .insert(issueWatchdogProofOutcomes) + .values({ + companyId: input.watchdog.companyId, + watchdogId: input.watchdog.id, + sourceIssueId: input.watchdog.issueId, + watchdogIssueId: input.watchdogIssue.id, + targetIssueId: input.watchdog.issueId, + outcome: input.outcome, + method: input.method, + observedAt: now, + resultClassification: input.resultClassification, + redactedDetails: input.redactedDetails, + stopFingerprint: input.stopFingerprint, + proofObligationFingerprint: input.proofObligationFingerprint, + createdByRunId: input.runId ?? null, + createdAt: now, + updatedAt: now, + }) + .onConflictDoUpdate({ + target: [ + issueWatchdogProofOutcomes.companyId, + issueWatchdogProofOutcomes.watchdogId, + issueWatchdogProofOutcomes.stopFingerprint, + issueWatchdogProofOutcomes.proofObligationFingerprint, + ], + set: { + watchdogIssueId: input.watchdogIssue.id, + targetIssueId: input.watchdog.issueId, + outcome: input.outcome, + method: input.method, + observedAt: now, + resultClassification: input.resultClassification, + redactedDetails: input.redactedDetails, + createdByRunId: input.runId ?? null, + updatedAt: now, + }, + }) + .returning(); + return row ?? null; + } + + async function markWatchdogProofOutcome( + watchdog: IssueWatchdogRow, + classification: TaskWatchdogClassifierResult, + opts: { runId?: string | null } = {}, + ) { + if (!watchdog.watchdogIssueId || !watchdog.lastObservedFingerprint) { + return { watchdog, outcome: null as IssueWatchdogProofOutcome | null, suppressesCurrentProof: false }; + } const watchdogIssue = await db .select() .from(issues) .where(and(eq(issues.companyId, watchdog.companyId), eq(issues.id, watchdog.watchdogIssueId))) .then((rows) => rows[0] ?? null); - if (!watchdogIssue) return watchdog; + if (!watchdogIssue) return { watchdog, outcome: null, suppressesCurrentProof: false }; const hasPendingReviewPath = watchdogIssue.status === "in_review" ? await watchdogIssueHasPendingReviewPath(watchdog.companyId, watchdogIssue.id) : false; - if (!isWatchdogReviewDisposition(watchdogIssue, hasPendingReviewPath)) return watchdog; - const reviewedFingerprint = reviewedFingerprintForWatchdogIssue(watchdogIssue); - if (!reviewedFingerprint || watchdog.lastReviewedFingerprint === reviewedFingerprint) return watchdog; + const hasUnresolvedBlocker = watchdogIssue.status === "blocked" + ? Boolean(await issueHasUnresolvedBlocker(watchdog.companyId, watchdogIssue.id)) + : false; + + const classifiedStopFingerprint = classification.state === "stopped" || classification.state === "already_reviewed" + ? classification.stopFingerprint + : null; + const stopFingerprint = reviewedFingerprintForWatchdogIssue(watchdogIssue) ?? + classifiedStopFingerprint ?? + watchdog.lastObservedFingerprint; + if (!stopFingerprint) return { watchdog, outcome: null, suppressesCurrentProof: false }; + + const proofObligationFingerprint = + (classification.state === "stopped" || classification.state === "already_reviewed") && + classifiedStopFingerprint === stopFingerprint + ? classification.proofObligationFingerprint + : fallbackProofObligationFingerprint({ + companyId: watchdog.companyId, + watchdogId: watchdog.id, + sourceIssueId: watchdog.issueId, + stopFingerprint, + classificationState: classification.state, + }); + + let outcome: IssueWatchdogProofOutcome | null = null; + if (watchdogIssue.status === "done") { + outcome = classification.state === "live" ? "restored" : "accepted"; + } else if (watchdogIssue.status === "blocked") { + outcome = hasUnresolvedBlocker ? "deferred" : "failed"; + } else if (watchdogIssue.status === "in_review") { + outcome = (watchdogIssue.assigneeUserId || watchdogIssue.executionState || watchdogIssue.monitorNextCheckAt || hasPendingReviewPath) + ? "deferred" + : "failed"; + } else if (watchdogIssue.status === "cancelled") { + outcome = "dismissed"; + } + + if (!outcome) return { watchdog, outcome: null, suppressesCurrentProof: false }; + + const method = `watchdog_issue_status:${watchdogIssue.status}`; + await recordWatchdogProofOutcome({ + watchdog, + watchdogIssue, + outcome, + method, + stopFingerprint, + proofObligationFingerprint, + resultClassification: classification.state, + redactedDetails: { + version: 1, + target: { + sourceIssueId: watchdog.issueId, + watchdogIssueId: watchdogIssue.id, + }, + method, + classification: classification.state, + watchdogIssueStatus: watchdogIssue.status, + hasUnresolvedBlocker, + hasPendingReviewPath, + hasReviewOwner: Boolean(watchdogIssue.assigneeUserId || watchdogIssue.executionState), + hasBoundedMonitor: Boolean(watchdogIssue.monitorNextCheckAt), + stopFingerprint, + proofObligationFingerprint, + }, + runId: opts.runId ?? null, + }); + + const shouldMarkReviewed = proofOutcomeSuppressesRepeat(outcome); + if (!shouldMarkReviewed || watchdog.lastReviewedFingerprint === stopFingerprint) { + return { watchdog, outcome, suppressesCurrentProof: shouldMarkReviewed && classifiedStopFingerprint === stopFingerprint }; + } const [updated] = await db .update(issueWatchdogs) .set({ - lastReviewedFingerprint: reviewedFingerprint, + lastReviewedFingerprint: stopFingerprint, lastCompletedAt: new Date(), updatedAt: new Date(), }) @@ -1136,12 +1403,18 @@ export function taskWatchdogService(db: Db, deps: TaskWatchdogServiceDeps = {}) source: "task_watchdogs.review_disposition", watchdogId: watchdog.id, watchdogIssueId: watchdogIssue.id, - reviewedFingerprint, + outcome, + reviewedFingerprint: stopFingerprint, + proofObligationFingerprint, lastObservedFingerprint: watchdog.lastObservedFingerprint, watchdogIssueStatus: watchdogIssue.status, }, }); - return updated ?? watchdog; + return { + watchdog: updated ?? watchdog, + outcome, + suppressesCurrentProof: classifiedStopFingerprint === stopFingerprint, + }; } async function ensureReusableWatchdogIssue(input: { @@ -1188,6 +1461,7 @@ export function taskWatchdogService(db: Db, deps: TaskWatchdogServiceDeps = {}) buildStoppedFingerprintComment({ sourceIssue: input.sourceIssue, stopFingerprint: input.classification.stopFingerprint, + proofObligationFingerprint: input.classification.proofObligationFingerprint, stoppedLeaves: input.classification.stoppedLeaves, resumed: true, }), @@ -1197,6 +1471,7 @@ export function taskWatchdogService(db: Db, deps: TaskWatchdogServiceDeps = {}) metadata: stoppedFingerprintMetadata({ sourceIssueId: input.sourceIssue.id, stopFingerprint: input.classification.stopFingerprint, + proofObligationFingerprint: input.classification.proofObligationFingerprint, resumed: true, }), }, @@ -1211,6 +1486,7 @@ export function taskWatchdogService(db: Db, deps: TaskWatchdogServiceDeps = {}) "", `Watched issue: ${input.sourceIssue.identifier ?? input.sourceIssue.id}`, `Stopped fingerprint: ${input.classification.stopFingerprint}`, + `Proof-obligation fingerprint: ${input.classification.proofObligationFingerprint}`, "", "The watchdog agent should verify the stopped subtree and either confirm the disposition or restore a valid live path.", ].join("\n"), @@ -1237,6 +1513,7 @@ export function taskWatchdogService(db: Db, deps: TaskWatchdogServiceDeps = {}) buildStoppedFingerprintComment({ sourceIssue: input.sourceIssue, stopFingerprint: input.classification.stopFingerprint, + proofObligationFingerprint: input.classification.proofObligationFingerprint, stoppedLeaves: input.classification.stoppedLeaves, resumed: false, }), @@ -1246,6 +1523,7 @@ export function taskWatchdogService(db: Db, deps: TaskWatchdogServiceDeps = {}) metadata: stoppedFingerprintMetadata({ sourceIssueId: input.sourceIssue.id, stopFingerprint: input.classification.stopFingerprint, + proofObligationFingerprint: input.classification.proofObligationFingerprint, resumed: false, }), }, @@ -1254,7 +1532,7 @@ export function taskWatchdogService(db: Db, deps: TaskWatchdogServiceDeps = {}) } async function evaluateWatchdog(row: IssueWatchdogRow, opts: { runId?: string | null } = {}) { - const watchdog = await markTerminalWatchdogIssueReviewed(row, opts); + let watchdog = row; const sourceIssue = await db .select() .from(issues) @@ -1266,6 +1544,14 @@ export function taskWatchdogService(db: Db, deps: TaskWatchdogServiceDeps = {}) const input = await collectClassifierInput(watchdog.companyId, watchdog); const classification = classifyTaskWatchdogSubtree(input); + const proofOutcome = await markWatchdogProofOutcome(watchdog, classification, opts); + watchdog = proofOutcome.watchdog; + if ( + proofOutcome.suppressesCurrentProof && + (classification.state === "stopped" || classification.state === "already_reviewed") + ) { + return { state: "already_reviewed" as const, reason: classification.reason, classification }; + } if (classification.state !== "stopped") { return { state: classification.state, reason: classification.reason, classification }; } @@ -1318,6 +1604,7 @@ export function taskWatchdogService(db: Db, deps: TaskWatchdogServiceDeps = {}) watchdogId: watchdog.id, watchdogIssueId: watchdogIssue.id, stopFingerprint: classification.stopFingerprint, + proofObligationFingerprint: classification.proofObligationFingerprint, stoppedLeaves: classification.stoppedLeaves, }, });