From 33bcaa5a4c0ab2a8b8484efac99b503c0fe3eda2 Mon Sep 17 00:00:00 2001
From: Dotta
Date: Wed, 29 Apr 2026 14:00:36 -0500
Subject: [PATCH] Allow recovery owners to cancel stale runs
Co-Authored-By: Paperclip
---
.../agent-permissions-routes.test.ts | 127 ++++++++++++++++
...artbeat-active-run-output-watchdog.test.ts | 138 ++++++++++++++++++
server/src/routes/agents.ts | 52 ++++++-
server/src/services/heartbeat.ts | 2 +-
server/src/services/recovery/service.ts | 28 +++-
ui/src/api/heartbeats.ts | 2 +-
ui/src/components/IssueRunLedger.test.tsx | 16 ++
ui/src/components/IssueRunLedger.tsx | 19 +++
8 files changed, 373 insertions(+), 11 deletions(-)
diff --git a/server/src/__tests__/agent-permissions-routes.test.ts b/server/src/__tests__/agent-permissions-routes.test.ts
index 11f039af1db..5339f59689f 100644
--- a/server/src/__tests__/agent-permissions-routes.test.ts
+++ b/server/src/__tests__/agent-permissions-routes.test.ts
@@ -66,6 +66,10 @@ const mockHeartbeatService = vi.hoisted(() => ({
cancelRun: vi.fn(),
}));
+const mockRecoveryService = vi.hoisted(() => ({
+ recordWatchdogDecision: vi.fn(),
+}));
+
const mockIssueApprovalService = vi.hoisted(() => ({
linkManyForApproval: vi.fn(),
}));
@@ -142,6 +146,10 @@ function registerModuleMocks() {
heartbeatService: () => mockHeartbeatService,
}));
+ vi.doMock("../services/recovery/service.js", () => ({
+ recoveryService: () => mockRecoveryService,
+ }));
+
vi.doMock("../services/issue-approvals.js", () => ({
issueApprovalService: () => mockIssueApprovalService,
}));
@@ -271,6 +279,7 @@ describe.sequential("agent permission routes", () => {
vi.doUnmock("../services/budgets.js");
vi.doUnmock("../services/company-skills.js");
vi.doUnmock("../services/heartbeat.js");
+ vi.doUnmock("../services/recovery/service.js");
vi.doUnmock("../services/index.js");
vi.doUnmock("../services/instance-settings.js");
vi.doUnmock("../services/issue-approvals.js");
@@ -306,6 +315,7 @@ describe.sequential("agent permission routes", () => {
mockHeartbeatService.resetRuntimeSession.mockReset();
mockHeartbeatService.getRun.mockReset();
mockHeartbeatService.cancelRun.mockReset();
+ mockRecoveryService.recordWatchdogDecision.mockReset();
mockIssueApprovalService.linkManyForApproval.mockReset();
mockIssueService.list.mockReset();
mockSecretService.normalizeAdapterConfigForPersistence.mockReset();
@@ -374,6 +384,13 @@ describe.sequential("agent permission routes", () => {
censorUsernameInLogs: false,
});
mockLogActivity.mockResolvedValue(undefined);
+ mockRecoveryService.recordWatchdogDecision.mockResolvedValue({
+ id: "watchdog-decision-1",
+ runId: "run-1",
+ evaluationIssueId: "evaluation-1",
+ decision: "cancel_run",
+ reason: "The stale run is stuck",
+ });
});
it("redacts agent detail for authenticated company members without agent admin permission", async () => {
@@ -1194,4 +1211,114 @@ describe.sequential("agent permission routes", () => {
expect(res.status).toBe(403);
expect(mockHeartbeatService.cancelRun).not.toHaveBeenCalled();
});
+
+ it("keeps generic heartbeat cancellation board-only", async () => {
+ const app = await createApp({
+ type: "agent",
+ agentId,
+ companyId,
+ source: "agent_key",
+ runId: "actor-run-1",
+ });
+
+ const res = await requestApp(app, (baseUrl) => request(baseUrl)
+ .post("/api/heartbeat-runs/run-1/cancel")
+ .send({}));
+
+ expect(res.status).toBe(403);
+ expect(mockHeartbeatService.cancelRun).not.toHaveBeenCalled();
+ });
+
+ it("lets an agent use the watchdog recovery cancel route without board access", async () => {
+ mockHeartbeatService.getRun.mockResolvedValue({
+ id: "run-1",
+ companyId,
+ agentId,
+ status: "running",
+ });
+ mockHeartbeatService.cancelRun.mockResolvedValue({
+ id: "run-1",
+ companyId,
+ agentId,
+ status: "cancelled",
+ });
+
+ const app = await createApp({
+ type: "agent",
+ agentId,
+ companyId,
+ source: "agent_key",
+ runId: "actor-run-1",
+ });
+
+ const res = await requestApp(app, (baseUrl) => request(baseUrl)
+ .post("/api/heartbeat-runs/run-1/watchdog-decisions")
+ .send({
+ decision: "cancel_run",
+ evaluationIssueId: "evaluation-1",
+ reason: "The stale run is stuck",
+ }));
+
+ expect(res.status).toBe(200);
+ expect(mockRecoveryService.recordWatchdogDecision).toHaveBeenCalledWith({
+ runId: "run-1",
+ actor: expect.objectContaining({
+ type: "agent",
+ agentId,
+ runId: "actor-run-1",
+ }),
+ decision: "cancel_run",
+ evaluationIssueId: "evaluation-1",
+ reason: "The stale run is stuck",
+ snoozedUntil: null,
+ createdByRunId: "actor-run-1",
+ });
+ expect(mockHeartbeatService.cancelRun).toHaveBeenCalledWith("run-1", "The stale run is stuck");
+ expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
+ actorType: "agent",
+ actorId: agentId,
+ agentId,
+ runId: "run-1",
+ action: "heartbeat.watchdog_cancelled",
+ entityType: "heartbeat_run",
+ entityId: "run-1",
+ details: expect.objectContaining({
+ decision: "cancel_run",
+ evaluationIssueId: "evaluation-1",
+ reason: "The stale run is stuck",
+ createdByRunId: "actor-run-1",
+ targetRunId: "run-1",
+ previousStatus: "running",
+ resultStatus: "cancelled",
+ }),
+ }));
+ });
+
+ it("requires an evaluation issue on watchdog recovery cancellation", async () => {
+ mockHeartbeatService.getRun.mockResolvedValue({
+ id: "run-1",
+ companyId,
+ agentId,
+ status: "running",
+ });
+
+ const app = await createApp({
+ type: "agent",
+ agentId,
+ companyId,
+ source: "agent_key",
+ runId: "actor-run-1",
+ });
+
+ const res = await requestApp(app, (baseUrl) => request(baseUrl)
+ .post("/api/heartbeat-runs/run-1/watchdog-decisions")
+ .send({
+ decision: "cancel_run",
+ reason: "The stale run is stuck",
+ }));
+
+ expect(res.status).toBe(400);
+ expect(mockRecoveryService.recordWatchdogDecision).not.toHaveBeenCalled();
+ expect(mockHeartbeatService.cancelRun).not.toHaveBeenCalled();
+ });
});
diff --git a/server/src/__tests__/heartbeat-active-run-output-watchdog.test.ts b/server/src/__tests__/heartbeat-active-run-output-watchdog.test.ts
index 45ba2c5d655..4f8d0d287c3 100644
--- a/server/src/__tests__/heartbeat-active-run-output-watchdog.test.ts
+++ b/server/src/__tests__/heartbeat-active-run-output-watchdog.test.ts
@@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto";
import { and, eq, sql } from "drizzle-orm";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import {
+ activityLog,
agents,
companies,
createDb,
@@ -353,6 +354,87 @@ describeEmbeddedPostgres("active-run output watchdog", () => {
});
});
+ it("lets the assigned recovery owner record cancel_run and uses the normal cancellation path", async () => {
+ const now = new Date("2026-04-22T20:00:00.000Z");
+ const { companyId, managerId, runId, issueId } = await seedRunningRun({
+ now,
+ ageMs: ACTIVE_RUN_OUTPUT_CRITICAL_THRESHOLD_MS + 60_000,
+ });
+ const heartbeat = heartbeatService(db);
+ const recovery = recoveryService(db, { enqueueWakeup: vi.fn() });
+
+ const scan = await heartbeat.scanSilentActiveRuns({ now, companyId });
+ const evaluationIssueId = scan.evaluationIssueIds[0];
+ expect(evaluationIssueId).toBeTruthy();
+
+ const managerRunId = randomUUID();
+ await db.insert(heartbeatRuns).values({
+ id: managerRunId,
+ companyId,
+ agentId: managerId,
+ status: "succeeded",
+ invocationSource: "assignment",
+ triggerDetail: "system",
+ startedAt: now,
+ finishedAt: now,
+ processStartedAt: now,
+ lastOutputAt: now,
+ lastOutputSeq: 1,
+ lastOutputStream: "stdout",
+ contextSnapshot: {},
+ logBytes: 0,
+ });
+
+ const decision = await recovery.recordWatchdogDecision({
+ runId,
+ actor: { type: "agent", agentId: managerId, runId: managerRunId },
+ decision: "cancel_run",
+ evaluationIssueId,
+ reason: "Run is silent and evidence shows it is stuck",
+ });
+
+ expect(decision).toMatchObject({
+ runId,
+ evaluationIssueId,
+ decision: "cancel_run",
+ reason: "Run is silent and evidence shows it is stuck",
+ createdByAgentId: managerId,
+ createdByRunId: managerRunId,
+ });
+
+ const cancelled = await heartbeat.cancelRun(runId, decision.reason ?? undefined);
+ expect(cancelled).toMatchObject({
+ id: runId,
+ status: "cancelled",
+ error: "Run is silent and evidence shows it is stuck",
+ errorCode: "cancelled",
+ });
+
+ const [sourceIssue] = await db.select().from(issues).where(eq(issues.id, issueId));
+ expect(sourceIssue?.executionRunId).toBeNull();
+ expect(sourceIssue?.executionLockedAt).toBeNull();
+
+ const [audit] = await db
+ .select()
+ .from(activityLog)
+ .where(and(eq(activityLog.companyId, companyId), eq(activityLog.action, "heartbeat.watchdog_cancel_requested")));
+ expect(audit).toMatchObject({
+ actorType: "agent",
+ actorId: managerId,
+ agentId: managerId,
+ runId,
+ entityType: "heartbeat_run",
+ entityId: runId,
+ });
+ expect(audit?.details).toMatchObject({
+ decision: "cancel_run",
+ evaluationIssueId,
+ targetRunId: runId,
+ reason: "Run is silent and evidence shows it is stuck",
+ createdByRunId: managerRunId,
+ });
+ });
+
it("re-arms continue decisions after the default quiet window", async () => {
const now = new Date("2026-04-22T20:00:00.000Z");
const { companyId, managerId, runId } = await seedRunningRun({
@@ -470,6 +552,7 @@ describeEmbeddedPostgres("active-run output watchdog", () => {
snoozedUntil: new Date(now.getTime() + 60 * 60 * 1000),
},
{ decision: "continue" as const, evaluationIssueId: otherEvaluationIssueId },
+ { decision: "cancel_run" as const, evaluationIssueId: otherEvaluationIssueId },
];
for (const attempt of attempts) {
@@ -495,6 +578,61 @@ describeEmbeddedPostgres("active-run output watchdog", () => {
).rejects.toMatchObject({ status: 403 });
});
+ it("rejects cancel_run for terminal runs before writing a decision", async () => {
+ const now = new Date("2026-04-22T20:00:00.000Z");
+ const { companyId, managerId, runId } = await seedRunningRun({
+ now,
+ ageMs: ACTIVE_RUN_OUTPUT_SUSPICION_THRESHOLD_MS + 60_000,
+ });
+ const heartbeat = heartbeatService(db);
+ const recovery = recoveryService(db, { enqueueWakeup: vi.fn() });
+
+ const scan = await heartbeat.scanSilentActiveRuns({ now, companyId });
+ const evaluationIssueId = scan.evaluationIssueIds[0];
+ expect(evaluationIssueId).toBeTruthy();
+ await db.update(heartbeatRuns).set({ status: "succeeded", finishedAt: now }).where(eq(heartbeatRuns.id, runId));
+
+ await expect(
+ recovery.recordWatchdogDecision({
+ runId,
+ actor: { type: "agent", agentId: managerId },
+ decision: "cancel_run",
+ evaluationIssueId,
+ reason: "Too late to cancel",
+ }),
+ ).rejects.toMatchObject({ status: 409 });
+
+ const rows = await db
+ .select()
+ .from(heartbeatRunWatchdogDecisions)
+ .where(and(eq(heartbeatRunWatchdogDecisions.runId, runId), eq(heartbeatRunWatchdogDecisions.decision, "cancel_run")));
+ expect(rows).toHaveLength(0);
+ });
+
+ it("requires an evaluation issue for cancel_run decisions", async () => {
+ const now = new Date("2026-04-22T20:00:00.000Z");
+ const { managerId, runId } = await seedRunningRun({
+ now,
+ ageMs: ACTIVE_RUN_OUTPUT_SUSPICION_THRESHOLD_MS + 60_000,
+ });
+ const recovery = recoveryService(db, { enqueueWakeup: vi.fn() });
+
+ await expect(
+ recovery.recordWatchdogDecision({
+ runId,
+ actor: { type: "agent", agentId: managerId },
+ decision: "cancel_run",
+ reason: "No evaluation issue binding",
+ }),
+ ).rejects.toMatchObject({ status: 400 });
+
+ const rows = await db
+ .select()
+ .from(heartbeatRunWatchdogDecisions)
+ .where(and(eq(heartbeatRunWatchdogDecisions.runId, runId), eq(heartbeatRunWatchdogDecisions.decision, "cancel_run")));
+ expect(rows).toHaveLength(0);
+ });
+
it("validates createdByRunId before storing watchdog decisions", async () => {
const now = new Date("2026-04-22T20:00:00.000Z");
const { companyId, managerId, runId } = await seedRunningRun({
diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts
index 78172f321f1..4a551b86221 100644
--- a/server/src/routes/agents.ts
+++ b/server/src/routes/agents.ts
@@ -85,6 +85,9 @@ import { recoveryService } from "../services/recovery/service.js";
const RUN_LOG_DEFAULT_LIMIT_BYTES = 256_000;
const RUN_LOG_MAX_LIMIT_BYTES = 1024 * 1024;
+const LIVE_RUNS_DEFAULT_LIMIT = 50;
+const WATCHDOG_DECISIONS = ["snooze", "continue", "dismissed_false_positive", "cancel_run"] as const;
+type WatchdogDecision = (typeof WATCHDOG_DECISIONS)[number];
function readRunLogLimitBytes(value: unknown) {
const parsed = Number(value ?? RUN_LOG_DEFAULT_LIMIT_BYTES);
@@ -2572,7 +2575,7 @@ export function agentRoutes(
assertCompanyAccess(req, companyId);
const minCount = readLiveRunsQueryInt(req.query.minCount, 50);
- const limit = readLiveRunsQueryInt(req.query.limit, 50);
+ const limit = readLiveRunsQueryInt(req.query.limit, LIVE_RUNS_DEFAULT_LIMIT, LIVE_RUNS_DEFAULT_LIMIT);
const columns = {
id: heartbeatRuns.id,
@@ -2695,12 +2698,20 @@ export function agentRoutes(
}
assertCompanyAccess(req, existing.companyId);
const decision = typeof req.body?.decision === "string" ? req.body.decision : "";
- if (!["snooze", "continue", "dismissed_false_positive"].includes(decision)) {
+ if (!WATCHDOG_DECISIONS.includes(decision as WatchdogDecision)) {
res.status(400).json({ error: "Unsupported watchdog decision" });
return;
}
const evaluationIssueId = typeof req.body?.evaluationIssueId === "string" ? req.body.evaluationIssueId : null;
const reason = typeof req.body?.reason === "string" ? req.body.reason.slice(0, 4000) : null;
+ if (decision === "cancel_run" && !reason?.trim()) {
+ res.status(400).json({ error: "Cancellation reason is required" });
+ return;
+ }
+ if (decision === "cancel_run" && !evaluationIssueId) {
+ res.status(400).json({ error: "evaluationIssueId is required for cancellation" });
+ return;
+ }
const snoozedUntil = decision === "snooze"
? new Date(String(req.body?.snoozedUntil ?? ""))
: null;
@@ -2712,14 +2723,47 @@ export function agentRoutes(
const row = await recovery.recordWatchdogDecision({
runId: existing.id,
actor: req.actor,
- decision: decision as "snooze" | "continue" | "dismissed_false_positive",
+ decision: decision as WatchdogDecision,
evaluationIssueId,
reason,
snoozedUntil,
createdByRunId: req.actor.runId ?? null,
});
- res.json(row);
+ if (decision !== "cancel_run") {
+ res.json(row);
+ return;
+ }
+
+ const cancelledRun = await heartbeat.cancelRun(existing.id, reason?.trim());
+ if (cancelledRun) {
+ await logActivity(db, {
+ companyId: cancelledRun.companyId,
+ actorType: req.actor.type === "agent" ? "agent" : "user",
+ actorId: req.actor.type === "agent"
+ ? req.actor.agentId ?? "agent"
+ : req.actor.type === "board"
+ ? req.actor.userId ?? "board"
+ : "unknown",
+ agentId: req.actor.type === "agent" ? req.actor.agentId ?? null : null,
+ runId: cancelledRun.id,
+ action: "heartbeat.watchdog_cancelled",
+ entityType: "heartbeat_run",
+ entityId: cancelledRun.id,
+ details: {
+ source: "routes.heartbeat_runs.watchdog_decisions",
+ decision,
+ evaluationIssueId,
+ reason: reason?.trim() ?? null,
+ createdByRunId: req.actor.runId ?? null,
+ targetRunId: cancelledRun.id,
+ previousStatus: existing.status,
+ resultStatus: cancelledRun.status,
+ },
+ });
+ }
+
+ res.json({ decision: row, run: cancelledRun });
});
router.get("/heartbeat-runs/:runId/events", async (req, res) => {
diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts
index d26f349567c..a79c8f79b08 100644
--- a/server/src/services/heartbeat.ts
+++ b/server/src/services/heartbeat.ts
@@ -7732,7 +7732,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
return { checked, enqueued, skipped };
},
- cancelRun: (runId: string) => cancelRunInternal(runId),
+ cancelRun: (runId: string, reason?: string) => cancelRunInternal(runId, reason),
cancelActiveForAgent: (agentId: string) => cancelActiveForAgentInternal(agentId),
diff --git a/server/src/services/recovery/service.ts b/server/src/services/recovery/service.ts
index 7886c40adc0..8be5b31997d 100644
--- a/server/src/services/recovery/service.ts
+++ b/server/src/services/recovery/service.ts
@@ -24,7 +24,7 @@ import {
} from "@paperclipai/db";
import { parseObject, asBoolean, asNumber } from "../../adapters/utils.js";
import { runningProcesses } from "../../adapters/index.js";
-import { forbidden, notFound } from "../../errors.js";
+import { badRequest, conflict, forbidden, notFound } from "../../errors.js";
import { logger } from "../../middleware/logger.js";
import { redactCurrentUserText } from "../../log-redaction.js";
import { redactSensitiveText } from "../../redaction.js";
@@ -64,6 +64,7 @@ import {
} from "../issue-execution-disposition.js";
const EXECUTION_PATH_HEARTBEAT_RUN_STATUSES = ["queued", "running", "scheduled_retry"] as const;
+const CANCELLABLE_ACTIVE_RUN_RECOVERY_STATUSES = ["queued", "running", "scheduled_retry"] as const;
const UNSUCCESSFUL_HEARTBEAT_RUN_TERMINAL_STATUSES = ["failed", "cancelled", "timed_out"] as const;
export const ACTIVE_RUN_OUTPUT_SUSPICION_THRESHOLD_MS = 60 * 60 * 1000;
export const ACTIVE_RUN_OUTPUT_CRITICAL_THRESHOLD_MS = 4 * 60 * 60 * 1000;
@@ -112,6 +113,8 @@ type WatchdogDecisionActor =
| { type: "agent"; agentId?: string | null; runId?: string | null }
| { type: "none" };
+type WatchdogDecision = "snooze" | "continue" | "dismissed_false_positive" | "cancel_run";
+
export type RunOutputSilenceSummary = {
lastOutputAt: Date | null;
lastOutputSeq: number;
@@ -1342,7 +1345,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
async function recordWatchdogDecision(input: {
runId: string;
actor: WatchdogDecisionActor;
- decision: "snooze" | "continue" | "dismissed_false_positive";
+ decision: WatchdogDecision;
evaluationIssueId?: string | null;
reason?: string | null;
snoozedUntil?: Date | null;
@@ -1356,6 +1359,15 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
.limit(1);
if (!run) throw notFound("Heartbeat run not found");
+ const normalizedReason = typeof input.reason === "string" ? input.reason.trim() : "";
+ if (input.decision === "cancel_run") {
+ if (!normalizedReason) throw badRequest("Cancellation reason is required");
+ if (!input.evaluationIssueId) throw badRequest("evaluationIssueId is required for cancellation");
+ if (!CANCELLABLE_ACTIVE_RUN_RECOVERY_STATUSES.includes(run.status as (typeof CANCELLABLE_ACTIVE_RUN_RECOVERY_STATUSES)[number])) {
+ throw conflict("Heartbeat run is not cancellable", { status: run.status });
+ }
+ }
+
let evaluationIssue: {
id: string;
assigneeAgentId: string | null;
@@ -1442,7 +1454,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
evaluationIssueId: input.evaluationIssueId ?? null,
decision: input.decision,
snoozedUntil: effectiveSnoozedUntil,
- reason: input.reason ?? null,
+ reason: input.decision === "cancel_run" ? normalizedReason : input.reason ?? null,
createdByAgentId: input.actor.type === "agent" ? input.actor.agentId ?? null : null,
createdByUserId: input.actor.type === "board" ? input.actor.userId ?? null : null,
createdByRunId,
@@ -1459,15 +1471,21 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
: "unknown",
agentId: input.actor.type === "agent" ? input.actor.agentId ?? null : null,
runId: run.id,
- action: input.decision === "snooze" ? "heartbeat.watchdog_snoozed" : "heartbeat.watchdog_decision_recorded",
+ action: input.decision === "snooze"
+ ? "heartbeat.watchdog_snoozed"
+ : input.decision === "cancel_run"
+ ? "heartbeat.watchdog_cancel_requested"
+ : "heartbeat.watchdog_decision_recorded",
entityType: "heartbeat_run",
entityId: run.id,
details: {
source: "recovery.record_watchdog_decision",
decision: input.decision,
evaluationIssueId: input.evaluationIssueId ?? null,
+ targetRunId: run.id,
snoozedUntil: effectiveSnoozedUntil?.toISOString() ?? null,
- reason: input.reason ?? null,
+ reason: input.decision === "cancel_run" ? normalizedReason : input.reason ?? null,
+ createdByRunId,
},
});
diff --git a/ui/src/api/heartbeats.ts b/ui/src/api/heartbeats.ts
index b4bf89c81d7..67137d847f3 100644
--- a/ui/src/api/heartbeats.ts
+++ b/ui/src/api/heartbeats.ts
@@ -60,7 +60,7 @@ export interface LiveRunForIssue {
export interface WatchdogDecisionInput {
runId: string;
- decision: "snooze" | "continue" | "dismissed_false_positive";
+ decision: "snooze" | "continue" | "dismissed_false_positive" | "cancel_run";
evaluationIssueId?: string | null;
reason?: string | null;
snoozedUntil?: string | null;
diff --git a/ui/src/components/IssueRunLedger.test.tsx b/ui/src/components/IssueRunLedger.test.tsx
index 90d5bbb37a4..328e03a742e 100644
--- a/ui/src/components/IssueRunLedger.test.tsx
+++ b/ui/src/components/IssueRunLedger.test.tsx
@@ -411,6 +411,7 @@ describe("IssueRunLedger", () => {
expect(container.textContent).toContain("Stale-run watchdog alert");
expect(container.textContent).toContain("PAP-404");
expect(container.textContent).toContain("Stale run");
+ expect(container.textContent).toContain("assigned recovery owner can cancel this bound run");
const watchdogBanner = Array.from(container.querySelectorAll("p"))
.find((node) => node.textContent?.includes("Stale-run watchdog alert"))
?.closest("div");
@@ -429,6 +430,20 @@ describe("IssueRunLedger", () => {
decision: "continue",
evaluationIssueId: "issue-eval-1",
});
+
+ const cancelButton = Array.from(container.querySelectorAll("button")).find(
+ (button) => button.textContent?.includes("Cancel run"),
+ );
+ expect(cancelButton).not.toBeUndefined();
+ act(() => {
+ cancelButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
+ });
+ expect(onWatchdogDecision).toHaveBeenCalledWith({
+ runId: "run-live-1",
+ decision: "cancel_run",
+ evaluationIssueId: "issue-eval-1",
+ reason: "Cancelled from stale-run recovery banner",
+ });
});
it("hides watchdog decision actions for known non-owner viewers", () => {
@@ -445,6 +460,7 @@ describe("IssueRunLedger", () => {
expect(container.textContent).not.toContain("Continue monitoring");
expect(container.textContent).not.toContain("Snooze 1h");
expect(container.textContent).not.toContain("Mark false positive");
+ expect(container.textContent).not.toContain("Cancel run");
expect(container.querySelectorAll("button")).toHaveLength(0);
expect(onWatchdogDecision).not.toHaveBeenCalled();
});
diff --git a/ui/src/components/IssueRunLedger.tsx b/ui/src/components/IssueRunLedger.tsx
index 9a28e389c25..6d2c9433e77 100644
--- a/ui/src/components/IssueRunLedger.tsx
+++ b/ui/src/components/IssueRunLedger.tsx
@@ -575,6 +575,9 @@ export function IssueRunLedgerContent({
{" "}for recovery context.
>
) : null}
+ {latestSilentRun.outputSilence.level === "critical" ? (
+ <> The assigned recovery owner can cancel this bound run if the evidence shows it is stuck.>
+ ) : null}
{onWatchdogDecision && canRecordWatchdogDecisions ? (
@@ -620,6 +623,22 @@ export function IssueRunLedgerContent({
>
Mark false positive
+ {latestSilentRun.outputSilence.level === "critical" ? (
+
+ ) : null}
) : null}
{watchdogDecisionError ? (