mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 09:41:04 +01:00
Implement typed task watchdog proof outcomes
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
189fc9b881
commit
b7d72340d5
11 files changed
+638
-42
No files matched your search
@@ -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" });
|
||||
|
||||
@@ -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}\``;
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user