Implement typed task watchdog proof outcomes

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-06-24 21:49:32 +00:00
1 parent 189fc9b881
commit b7d72340d5
11 files changed
+638 -42

No files matched your search

@@ -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");
@@ -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
}
]
}
+1
View File
@@ -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";
@@ -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<Record<string, unknown>>().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')`,
),
}),
);
+8
View File
@@ -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];
+2
View File
@@ -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,
+17
View File
@@ -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<string, unknown>;
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;
@@ -25,6 +25,7 @@ function issue(overrides: Partial<TaskWatchdogClassifierIssue> = {}): TaskWatchd
function classify(overrides: Partial<Parameters<typeof classifyTaskWatchdogSubtree>[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,
});
});
});
@@ -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" });
+15 -1
View File
@@ -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}\``;
+328 -41
View File
@@ -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<string, string[]>();
const blockersByIssueId = new Map<string, TaskWatchdogClassifierRelation[]>();
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<IssueRow, "identifier" | "id">;
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<string, unknown>;
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,
},
});