Files
PaperClipAI/server/src/__tests__/dashboard-service.test.ts
T
Andrii Furmanets 61ecc93033 fix: count a recovered shutdown interrupt as recovered (#15031)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - The dashboard success rate counts recovered runs as successes
> - A run is recovered when a later retry in its chain succeeds
> - The summary puts `failed` and `timed_out` into recovered
> - An `interrupted` run, including `server_shutdown_interrupted`, falls
through to other
> - The success rate stays down after the retry succeeds
> - This pull request counts a recovered interrupt in recovered, not in
other
> - The benefit is that a shutdown that was retried successfully no
longer looks like a failed day

## Linked Issues or Issue Description

No open issue covers this. I searched open and closed issues and pull
requests.

**What happened?**

`dashboardService.summary` puts only `failed` and `timed_out` into the
recovered bucket. An `interrupted` run, including
`server_shutdown_interrupted`, goes to other even when `recovered` is
set. The success rate uses `succeeded + recovered`, so the day stays low
after a successful retry.

**Expected behavior**

When the recovered flag is set, count `interrupted` in recovered. An
interrupt with no successful retry stays in other.

**Steps to reproduce**

1. Record an interrupted run with error code
`server_shutdown_interrupted`.
2. Record a succeeded retry of that run.
3. Open the dashboard summary for that day.
4. Observe that the interrupt is in other and the success rate does not
include it.

**Paperclip version or commit**

master at 215586d127

**Deployment mode**

Local dev (pnpm dev)

## What Changed

- When a run is recovered, count status `interrupted` in the recovered
bucket.
- Leave an unrecovered interrupt in other.
- Add a service test for a recovered shutdown and a stranded shutdown.

## Verification

- `pnpm exec vitest run src/__tests__/dashboard-service.test.ts --config
vitest.config.ts` in `server`
- Result: 1 file passed, 5 tests passed

## Risks

- Low risk. Unrecovered interrupts stay in other. Failed and timed-out
runs keep the same buckets.

## Model Used

- No model runtime is part of this change.

## 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
- [ ] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [ ] I will address all Greptile and reviewer comments before
requesting merge
2026-10-26 20:52:47 -07:00

328 lines
11 KiB
TypeScript

import { randomUUID } from "node:crypto";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import { agents, companies, createDb, heartbeatRuns } from "@paperclipai/db";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
import { dashboardService, getUtcMonthStart } from "../services/dashboard.ts";
import { selectDashboardRunIds } from "../services/dashboard-run-selection.ts";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
if (!embeddedPostgresSupport.supported) {
console.warn(
`Skipping embedded Postgres dashboard service tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
);
}
function utcDay(offsetDays: number): Date {
const now = new Date();
const day = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() + offsetDays, 12);
return new Date(day);
}
function utcDateKey(date: Date): string {
return date.toISOString().slice(0, 10);
}
describe("getUtcMonthStart", () => {
it("anchors the monthly spend window to UTC month boundaries", () => {
expect(getUtcMonthStart(new Date("2026-03-31T20:30:00.000-05:00")).toISOString()).toBe(
"2026-04-01T00:00:00.000Z",
);
expect(getUtcMonthStart(new Date("2026-04-01T00:30:00.000+14:00")).toISOString()).toBe(
"2026-03-01T00:00:00.000Z",
);
});
});
describeEmbeddedPostgres("dashboard service", () => {
let db!: ReturnType<typeof createDb>;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
beforeAll(async () => {
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-dashboard-service-");
db = createDb(tempDb.connectionString);
}, 20_000);
afterEach(async () => {
await db.delete(heartbeatRuns);
await db.delete(agents);
await db.delete(companies);
});
afterAll(async () => {
await tempDb?.cleanup();
});
it("selects distinct task cards before limiting and keeps taskless runs separate", async () => {
const companyId = randomUUID();
const otherCompanyId = randomUUID();
const agentId = randomUUID();
const otherAgentId = randomUUID();
const repeatedIssueId = randomUUID();
const otherIssueId = randomUUID();
const activeRunId = randomUUID();
const otherTaskRunId = randomUUID();
const tasklessRunId = randomUUID();
await db.insert(companies).values([
{ id: companyId, name: "Dashboard cards", issuePrefix: "CARDS" },
{ id: otherCompanyId, name: "Other company", issuePrefix: "OTHER" },
]);
await db.insert(agents).values([
{ id: agentId, companyId, name: "Builder", role: "engineer", adapterType: "codex_local", adapterConfig: {}, runtimeConfig: {}, permissions: {} },
{ id: otherAgentId, companyId: otherCompanyId, name: "Other", role: "engineer", adapterType: "codex_local", adapterConfig: {}, runtimeConfig: {}, permissions: {} },
]);
await db.insert(heartbeatRuns).values([
...Array.from({ length: 6 }, (_, index) => ({
id: randomUUID(), companyId, agentId, status: "succeeded",
contextSnapshot: { issueId: repeatedIssueId },
createdAt: new Date(`2026-04-10T10:0${index}:00.000Z`),
})),
{ id: activeRunId, companyId, agentId, status: "running", contextSnapshot: { issueId: repeatedIssueId }, createdAt: new Date("2026-04-10T08:00:00.000Z") },
{ id: otherTaskRunId, companyId, agentId, status: "succeeded", contextSnapshot: { issueId: otherIssueId }, createdAt: new Date("2026-04-10T09:04:00.000Z") },
{ id: tasklessRunId, companyId, agentId, status: "succeeded", createdAt: new Date("2026-04-10T09:03:00.000Z") },
{ id: randomUUID(), companyId: otherCompanyId, agentId: otherAgentId, status: "running", contextSnapshot: { issueId: otherIssueId }, createdAt: new Date("2026-04-10T11:00:00.000Z") },
]);
expect(await selectDashboardRunIds(db, companyId, 3)).toEqual([
activeRunId, otherTaskRunId, tasklessRunId,
]);
});
it("aggregates the full 14-day run activity window without recent-run truncation", async () => {
const companyId = randomUUID();
const otherCompanyId = randomUUID();
const agentId = randomUUID();
const otherAgentId = randomUUID();
const today = utcDay(0);
const weekAgo = utcDay(-7);
await db.insert(companies).values([
{
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
},
{
id: otherCompanyId,
name: "Other",
issuePrefix: `T${otherCompanyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
},
]);
await db.insert(agents).values([
{
id: agentId,
companyId,
name: "CodexCoder",
role: "engineer",
status: "running",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
},
{
id: otherAgentId,
companyId: otherCompanyId,
name: "OtherAgent",
role: "engineer",
status: "running",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
},
]);
await db.insert(heartbeatRuns).values([
...Array.from({ length: 105 }, () => ({
id: randomUUID(),
companyId,
agentId,
invocationSource: "assignment",
status: "succeeded",
createdAt: today,
})),
{
id: randomUUID(),
companyId,
agentId,
invocationSource: "assignment",
status: "failed",
createdAt: weekAgo,
},
{
id: randomUUID(),
companyId,
agentId,
invocationSource: "assignment",
status: "timed_out",
createdAt: weekAgo,
},
{
id: randomUUID(),
companyId,
agentId,
invocationSource: "assignment",
status: "cancelled",
createdAt: weekAgo,
},
{
id: randomUUID(),
companyId: otherCompanyId,
agentId: otherAgentId,
invocationSource: "assignment",
status: "succeeded",
createdAt: weekAgo,
},
]);
const summary = await dashboardService(db).summary(companyId);
expect(summary.runActivity).toHaveLength(14);
const todayBucket = summary.runActivity.find((bucket) => bucket.date === utcDateKey(today));
const weekAgoBucket = summary.runActivity.find((bucket) => bucket.date === utcDateKey(weekAgo));
expect(todayBucket).toMatchObject({
succeeded: 105,
failed: 0,
recovered: 0,
other: 0,
total: 105,
failedByErrorCode: {},
});
expect(weekAgoBucket).toMatchObject({
succeeded: 0,
failed: 2,
recovered: 0,
other: 1,
total: 3,
// failed + timed_out with no error code both bucket under "unknown"
failedByErrorCode: { unknown: 2 },
});
});
it("separates recovered restart kills from true failures and breaks failures down by error code", async () => {
const companyId = randomUUID();
const agentId = randomUUID();
const day = utcDay(-2);
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({
id: agentId,
companyId,
name: "CodexCoder",
role: "engineer",
status: "running",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
});
const base = {
companyId,
agentId,
invocationSource: "assignment",
createdAt: day,
};
// Direct recovery: a process-loss kill whose retry succeeded.
const original = randomUUID();
const retry = randomUUID();
// Chained recovery: kill -> failed retry -> succeeded retry (both kills recovered).
const chainedOriginal = randomUUID();
const chainedRetry = randomUUID();
const chainedRetrySuccess = randomUUID();
// A genuine, unrecovered failure that should remain in the failed count.
const trueFailure = randomUUID();
await db.insert(heartbeatRuns).values([
{ ...base, id: original, status: "failed", errorCode: "process_lost" },
{ ...base, id: retry, status: "succeeded", retryOfRunId: original },
{ ...base, id: chainedOriginal, status: "failed", errorCode: "process_lost" },
{ ...base, id: chainedRetry, status: "failed", errorCode: "process_lost", retryOfRunId: chainedOriginal },
{ ...base, id: chainedRetrySuccess, status: "succeeded", retryOfRunId: chainedRetry },
{ ...base, id: trueFailure, status: "failed", errorCode: "provider_quota" },
]);
const summary = await dashboardService(db).summary(companyId);
const bucket = summary.runActivity.find((b) => b.date === utcDateKey(day));
expect(bucket).toMatchObject({
succeeded: 2,
// original + chainedOriginal + chainedRetry all recovered via a later success
recovered: 3,
failed: 1,
other: 0,
total: 6,
failedByErrorCode: { provider_quota: 1 },
});
// process_lost kills that recovered must not leak into the failed breakdown.
expect(bucket?.failedByErrorCode.process_lost).toBeUndefined();
});
it("counts a recovered shutdown interrupt toward recovered, not other", async () => {
const companyId = randomUUID();
const agentId = randomUUID();
const day = utcDay(-1);
const shutdown = randomUUID();
const shutdownRetry = randomUUID();
const stranded = 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({
id: agentId,
companyId,
name: "CodexCoder",
role: "engineer",
status: "running",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
});
const base = {
companyId,
agentId,
invocationSource: "assignment" as const,
createdAt: day,
};
await db.insert(heartbeatRuns).values([
{ ...base, id: shutdown, status: "interrupted", errorCode: "server_shutdown_interrupted" },
{ ...base, id: shutdownRetry, status: "succeeded", retryOfRunId: shutdown },
{ ...base, id: stranded, status: "interrupted", errorCode: "server_shutdown_interrupted" },
]);
const summary = await dashboardService(db).summary(companyId);
const bucket = summary.runActivity.find((entry) => entry.date === utcDateKey(day));
expect(bucket).toMatchObject({
succeeded: 1,
recovered: 1,
failed: 0,
other: 1,
total: 3,
failedByErrorCode: {},
});
});
});