Files
PaperClipAI/server/src/__tests__/recovery-observability.test.ts
T
DottaandPaperclip f572e08678 fix(recovery): stop automatic stranded-task takeovers (#11961)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - The recovery service restores execution when a task loses its live
path.
> - The service retries the original agent for a limited number of
attempts.
> - The old fallback could select a manager or an executive and wake
that agent.
> - That fallback changed the effective recovery owner without a board
decision.
> - This pull request keeps the source owner and gives the exhausted
recovery decision to the board.
> - The benefit is a clear ownership rule with no automatic task
takeover.

## Linked Issues or Issue Description

Refs: #11807

Refs: #11817

**What existing behavior does this improve?**

This improves stranded-task recovery in the server and the recovery
action card in the board UI.

**Subsystem affected**

Cross-cutting: server recovery orchestration, recovery observability,
board UI, and execution documentation.

**Current behavior**

Paperclip retries the original agent for a limited number of attempts.
After the retry limit, it can select a manager, task creator, CTO, or
CEO as a recovery owner. It can then wake that substitute agent. The
source task keeps its assignee, but the automatic substitute wake
creates an implicit takeover path.

**Proposed behavior**

Paperclip keeps the limited retry path for the original agent. If
recovery is exhausted or unsafe, Paperclip creates one board-owned
source recovery action. It keeps both source assignee fields. It does
not wake a substitute agent. The board can repair, retry the original
owner, explicitly reassign, or resolve the task.

**Reason and benefit**

Source task ownership must remain stable until a person or an approved
policy changes it. The new rule removes implicit manager and executive
takeover. It also gives operators clear evidence through the
`board_escalation_no_takeover_v1` routing marker.

**Breaking changes**

Automatic recovery no longer wakes a manager or executive after the
original-agent retry limit. Existing active agent-owned recovery actions
remain visible and can resolve. Paperclip does not schedule a new
takeover wake for those legacy actions.

## What Changed

- Route exhausted and unsafe stranded recovery to a board-owned source
action.
- Preserve agent and user assignee fields during automatic escalation.
- Keep limited same-agent continuity repair and provider quota
monitoring.
- Stop new manager, creator, CTO, and CEO recovery wakes.
- Keep legacy agent-owned recovery actions readable and resolvable.
- Add the routing marker to new board escalation evidence and
observability.
- Update recovery notices, the board UI card, tests, and execution
documentation.

## Verification

- Run `pnpm -r typecheck`.
- Run `pnpm build`.
- Run `pnpm check:token-gates`.
- Run `pnpm --filter @paperclipai/server exec vitest run
src/__tests__/heartbeat-process-recovery.test.ts`.
- Run `pnpm --filter @paperclipai/server exec vitest run
src/__tests__/heartbeat-workspace-branch-containment.test.ts`.
- Run the focused recovery and UI Vitest files changed by this pull
request.
- Confirm that a paused or over-budget source owner creates one board
action, keeps the source assignee, and creates no substitute wake.

## Risks

- Operators must now make the final recovery decision after the
original-agent limit.
- Legacy agent-owned actions use their stored contract. This avoids a
rollout-time ownership rewrite.
- No database migration or API response shape changes are included.
- The tests cover concurrent escalation, paused and over-budget owners,
legacy actions, provider quota monitoring, and UI presentation.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

OpenAI Codex with GPT-5. The hosted exact model revision and context
window are not exposed. Reasoning, tool use, and code execution were
enabled.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-08-22 11:41:24 -05:00

423 lines
14 KiB
TypeScript

import { randomUUID } from "node:crypto";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import {
agents,
companies,
createDb,
heartbeatRuns,
issueRecoveryActions,
issues,
} from "@paperclipai/db";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
import {
classifyRecoveryHandoff,
evaluateRecoveryRateAlert,
MAX_WINDOW_WEEKS,
recoveryObservabilityService,
type WeeklyRecoveryRate,
} from "../services/recovery-observability.ts";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
if (!embeddedPostgresSupport.supported) {
console.warn(
`Skipping embedded Postgres recovery observability tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
);
}
function week(runs: number, recoveryActions: number, weekStart: string): WeeklyRecoveryRate {
return {
weekStart,
runs,
recoveryActions,
ratePercent: runs > 0 ? Math.round((recoveryActions / runs) * 10000) / 100 : 0,
};
}
describe("evaluateRecoveryRateAlert", () => {
it("fires when a week crosses the 2% threshold", () => {
const weekly = [
week(1000, 12, "2026-06-01"), // 1.20%
week(2364, 77, "2026-07-06"), // 3.26% — the regression week
week(1000, 10, "2026-07-13"), // 1.00%
];
const alert = evaluateRecoveryRateAlert(weekly, 2);
expect(alert.breached).toBe(true);
expect(alert.breachedWeeks.map((w) => w.weekStart)).toEqual(["2026-07-06"]);
expect(alert.latestWeek?.weekStart).toBe("2026-07-13");
expect(alert.latestWeekBreached).toBe(false);
});
it("stays quiet when every week is under the threshold", () => {
const weekly = [week(1000, 13, "2026-06-01"), week(1000, 19, "2026-06-08")];
const alert = evaluateRecoveryRateAlert(weekly, 2);
expect(alert.breached).toBe(false);
expect(alert.breachedWeeks).toHaveLength(0);
expect(alert.latestWeekBreached).toBe(false);
});
it("flags the latest week when it is the one that regresses", () => {
const weekly = [week(1000, 10, "2026-07-06"), week(1000, 30, "2026-07-13")]; // 1% then 3%
const alert = evaluateRecoveryRateAlert(weekly, 2);
expect(alert.latestWeekBreached).toBe(true);
});
});
describe("classifyRecoveryHandoff", () => {
const base = {
status: "resolved",
outcome: "restored",
ownerAgentId: "manager",
returnOwnerAgentId: "coder",
finalAssigneeAgentId: "coder",
finalIssueStatus: "done",
};
it("marks a manager who kept and completed the work as owner_completed", () => {
expect(
classifyRecoveryHandoff({ ...base, finalAssigneeAgentId: "manager", finalIssueStatus: "done" }),
).toBe("owner_completed");
});
it("does not treat in-review work as a completed owner handoff", () => {
expect(
classifyRecoveryHandoff({ ...base, finalAssigneeAgentId: "manager", finalIssueStatus: "in_review" }),
).toBe("other");
});
it("marks work returned to the original assignee as handed_back", () => {
expect(classifyRecoveryHandoff({ ...base, finalAssigneeAgentId: "coder" })).toBe("handed_back");
});
it("marks work delegated to a different specialist as handed_back", () => {
expect(classifyRecoveryHandoff({ ...base, finalAssigneeAgentId: "other-agent" })).toBe(
"handed_back",
);
});
it("marks the original agent recovering its own issue as self_recovery", () => {
expect(
classifyRecoveryHandoff({ ...base, ownerAgentId: "coder", returnOwnerAgentId: "coder" }),
).toBe("self_recovery");
});
it("treats still-active actions as active regardless of assignee", () => {
expect(classifyRecoveryHandoff({ ...base, status: "active" })).toBe("active");
});
});
describeEmbeddedPostgres("recovery observability report", () => {
let db!: ReturnType<typeof createDb>;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
const now = new Date("2026-07-15T12:00:00.000Z");
// Monday of each relevant week (matches Postgres date_trunc('week', ...)).
const regressionWeek = new Date("2026-07-08T12:00:00.000Z"); // in week 2026-07-06
const latestWeek = new Date("2026-07-14T12:00:00.000Z"); // in week 2026-07-13
beforeAll(async () => {
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-recovery-observability-");
db = createDb(tempDb.connectionString);
}, 20_000);
afterEach(async () => {
await db.delete(issueRecoveryActions);
await db.delete(heartbeatRuns);
await db.delete(issues);
await db.delete(agents);
await db.delete(companies);
});
afterAll(async () => {
await tempDb?.cleanup();
});
async function seedBaseline() {
const companyId = randomUUID();
const managerId = randomUUID();
const coderId = randomUUID();
const otherId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await db.insert(agents).values(
[managerId, coderId, otherId].map((id, index) => ({
id,
companyId,
name: `agent-${index}`,
role: "engineer",
status: "running",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
})),
);
// 100 runs in the regression week, 100 in the latest week.
await db.insert(heartbeatRuns).values([
...Array.from({ length: 100 }, () => ({
id: randomUUID(),
companyId,
agentId: coderId,
invocationSource: "assignment",
status: "succeeded",
createdAt: regressionWeek,
})),
...Array.from({ length: 100 }, () => ({
id: randomUUID(),
companyId,
agentId: coderId,
invocationSource: "assignment",
status: "succeeded",
createdAt: latestWeek,
})),
]);
return { companyId, managerId, coderId, otherId };
}
async function seedRecoveryAction(input: {
companyId: string;
n: number;
createdAt: Date;
cause: string;
errorCode: string;
status: string;
outcome: string | null;
ownerAgentId: string | null;
returnOwnerAgentId: string | null;
finalAssigneeAgentId: string | null;
finalIssueStatus: string;
}) {
const sourceIssueId = randomUUID();
await db.insert(issues).values({
id: sourceIssueId,
companyId: input.companyId,
title: `Source ${input.n}`,
status: input.finalIssueStatus,
priority: "medium",
assigneeAgentId: input.finalAssigneeAgentId,
issueNumber: input.n,
identifier: `SRC-${input.n}`,
});
await db.insert(issueRecoveryActions).values({
id: randomUUID(),
companyId: input.companyId,
sourceIssueId,
kind: "stranded_assigned_issue",
status: input.status,
ownerType: input.ownerAgentId ? "agent" : "board",
ownerAgentId: input.ownerAgentId,
returnOwnerAgentId: input.returnOwnerAgentId,
previousOwnerAgentId: input.returnOwnerAgentId,
cause: input.cause,
fingerprint: `fp-${input.n}`,
evidence: { latestRunErrorCode: input.errorCode },
nextAction: "recover",
outcome: input.outcome,
createdAt: input.createdAt,
updatedAt: input.createdAt,
});
}
it("fires the threshold alert on synthetic data crossing 2% of runs", async () => {
const { companyId, managerId, coderId } = await seedBaseline();
// Regression week: 3 recovery actions against 100 runs => 3% > 2%.
for (let i = 0; i < 3; i += 1) {
await seedRecoveryAction({
companyId,
n: i + 1,
createdAt: regressionWeek,
cause: "stranded_assigned_issue",
errorCode: "process_lost",
status: "resolved",
outcome: "restored",
ownerAgentId: managerId,
returnOwnerAgentId: coderId,
finalAssigneeAgentId: managerId,
finalIssueStatus: "done",
});
}
// Latest week: 1 recovery action against 100 runs => 1% (under threshold).
await seedRecoveryAction({
companyId,
n: 99,
createdAt: latestWeek,
cause: "stranded_assigned_issue",
errorCode: "process_lost",
status: "resolved",
outcome: "restored",
ownerAgentId: coderId,
returnOwnerAgentId: coderId,
finalAssigneeAgentId: coderId,
finalIssueStatus: "done",
});
const report = await recoveryObservabilityService(db).report(companyId, {
now,
weeks: 8,
thresholdPercent: 2,
});
expect(report.alert.breached).toBe(true);
expect(report.alert.breachedWeeks.map((w) => w.weekStart)).toEqual(["2026-07-06"]);
const regression = report.weekly.find((w) => w.weekStart === "2026-07-06");
expect(regression).toMatchObject({ runs: 100, recoveryActions: 3, ratePercent: 3 });
const latest = report.weekly.find((w) => w.weekStart === "2026-07-13");
expect(latest).toMatchObject({ runs: 100, recoveryActions: 1, ratePercent: 1 });
expect(report.alert.latestWeekBreached).toBe(false);
});
it("computes the handed_back vs owner_completed ratio and per-cause routing", async () => {
const { companyId, managerId, coderId, otherId } = await seedBaseline();
// Two manager takeovers where the manager kept and completed the work.
for (let i = 0; i < 2; i += 1) {
await seedRecoveryAction({
companyId,
n: i + 1,
createdAt: regressionWeek,
cause: "stranded_assigned_issue",
errorCode: "adapter_failed",
status: "resolved",
outcome: "restored",
ownerAgentId: managerId,
returnOwnerAgentId: coderId,
finalAssigneeAgentId: managerId,
finalIssueStatus: "done",
});
}
// One takeover handed back to the original assignee.
await seedRecoveryAction({
companyId,
n: 3,
createdAt: regressionWeek,
cause: "stranded_assigned_issue",
errorCode: "adapter_failed",
status: "resolved",
outcome: "restored",
ownerAgentId: managerId,
returnOwnerAgentId: coderId,
finalAssigneeAgentId: coderId,
finalIssueStatus: "in_progress",
});
// One handed to a different specialist (still counts as handed back).
await seedRecoveryAction({
companyId,
n: 4,
createdAt: regressionWeek,
cause: "stranded_assigned_issue",
errorCode: "adapter_failed",
status: "resolved",
outcome: "restored",
ownerAgentId: managerId,
returnOwnerAgentId: coderId,
finalAssigneeAgentId: otherId,
finalIssueStatus: "in_progress",
});
// Original agent recovered its own process_lost issue (self recovery).
await seedRecoveryAction({
companyId,
n: 5,
createdAt: regressionWeek,
cause: "process_lost",
errorCode: "process_lost",
status: "resolved",
outcome: "restored",
ownerAgentId: coderId,
returnOwnerAgentId: coderId,
finalAssigneeAgentId: coderId,
finalIssueStatus: "done",
});
// Escalated actions are still active, but they need their own routing counter.
await seedRecoveryAction({
companyId,
n: 6,
createdAt: regressionWeek,
cause: "stranded_assigned_issue",
errorCode: "manual_escalation",
status: "escalated",
outcome: null,
ownerAgentId: managerId,
returnOwnerAgentId: coderId,
finalAssigneeAgentId: managerId,
finalIssueStatus: "in_progress",
});
const report = await recoveryObservabilityService(db).report(companyId, { now, weeks: 8 });
expect(report.handoff.ownerCompleted).toBe(2);
expect(report.handoff.handedBack).toBe(2);
expect(report.handoff.selfRecovery).toBe(1);
expect(report.handoff.resolvedTakeovers).toBe(4);
expect(report.handoff.handedBackRatio).toBe(50);
expect(report.handoff.ownerCompletedRatio).toBe(50);
const strandedCause = report.byCause.find(
(c) => c.cause === "stranded_assigned_issue" && c.latestRunErrorCode === "adapter_failed",
);
expect(strandedCause?.count).toBe(4);
const processLostRouting = report.perCauseRouting.find((r) => r.cause === "process_lost");
expect(processLostRouting?.retriedByOriginalSucceeded).toBe(1);
const strandedRouting = report.perCauseRouting.find(
(r) => r.cause === "stranded_assigned_issue",
);
expect(strandedRouting?.ownerCompleted).toBe(2);
expect(strandedRouting?.handedBack).toBe(2);
expect(strandedRouting?.active).toBe(1);
expect(strandedRouting?.escalated).toBe(1);
});
it("caps the reporting window so a huge `weeks` value can't over-allocate", async () => {
const { companyId } = await seedBaseline();
const report = await recoveryObservabilityService(db).report(companyId, {
now,
weeks: 100_000,
});
expect(report.window.weeks).toBe(MAX_WINDOW_WEEKS);
expect(report.window.since).toBe(report.weekly[0]?.weekStart);
expect(report.window.since).not.toContain("T");
expect(report.weekly).toHaveLength(MAX_WINDOW_WEEKS);
});
it("counts an active board recovery action without reporting an active takeover", async () => {
const { companyId, coderId } = await seedBaseline();
await seedRecoveryAction({
companyId,
n: 101,
createdAt: latestWeek,
cause: "process_lost",
errorCode: "process_lost",
status: "active",
outcome: null,
ownerAgentId: null,
returnOwnerAgentId: coderId,
finalAssigneeAgentId: coderId,
finalIssueStatus: "blocked",
});
const report = await recoveryObservabilityService(db).report(companyId, { now, weeks: 8 });
expect(report.handoff).toMatchObject({ boardOwned: 1, activeTakeovers: 0 });
expect(report.perCauseRouting.find((entry) => entry.cause === "process_lost")).toMatchObject({
active: 1,
});
});
});