Files
PaperClipAI/server/src/services/__tests__/process-loss-diagnostics.test.ts
T
Devin FoleyandPaperclip 128c95837b Retain bounded orphan process-loss diagnostics (#15475)
Record bounded observer uptime, run and output ages, process-check observations and retry eligibility before orphan cleanup changes the evidence. Preserve existing recovery and reporting behavior and omit process IDs, raw paths and credentials.

Verified focused helper, actual reaper and real SDK regressions, full typecheck/build, exact-head CI and independent review. An unchanged Cursor timeout passed its isolated retry; unrelated local Slack timing failure is documented separately. Greptile 5/5 with no unresolved comments.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-10-07 12:05:21 -07:00

57 lines
3.3 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { buildProcessLossDiagnostic, readProcessLossDiagnostic } from "../process-loss-diagnostics.js";
const nowMs = Date.UTC(2026, 0, 1, 12);
const run = {
startedAt: new Date(nowMs - 10 * 60_000), lastOutputAt: new Date(nowMs - 2 * 60_000),
processPid: 123, processGroupId: null,
};
const input = { run, nowMs, observerStartedAtMs: nowMs - 30_000,
checksPersistedChildLiveness: true, retryEligible: true };
describe("orphan process-loss evidence", () => {
it("distinguishes a run from the previous observer without inventing a restart cause", () => {
expect(buildProcessLossDiagnostic(input)).toEqual({
pidRecorded: true, groupRecorded: false, localCheck: "not_observed_alive", retryEligible: true,
runPredatesObserver: true, lastOutputAgeMs: 120_000, observerUptimeMs: 30_000,
});
const currentObserverRun = buildProcessLossDiagnostic({ ...input, observerStartedAtMs: nowMs - 20 * 60_000 });
expect(currentObserverRun.runPredatesObserver).toBe(false);
expect(currentObserverRun).not.toHaveProperty("restartCause");
});
it("does not describe missing or unchecked identifiers as confirmed child death", () => {
expect(buildProcessLossDiagnostic({ ...input, checksPersistedChildLiveness: false }).localCheck).toBe("not_checked");
expect(buildProcessLossDiagnostic({ ...input, run: { ...run, processPid: null }, retryEligible: false }))
.toMatchObject({ pidRecorded: false, groupRecorded: false, localCheck: "no_identifiers", retryEligible: false });
expect(buildProcessLossDiagnostic({ ...input, run: { ...run, processPid: null, processGroupId: 456 } }))
.toMatchObject({ pidRecorded: false, groupRecorded: true, localCheck: "not_observed_alive" });
});
it("omits unavailable, future and unbounded ages", () => {
for (const lastOutputAt of [null, new Date(NaN), new Date(nowMs + 1), new Date(nowMs - 604_800_001)]) {
expect(buildProcessLossDiagnostic({ ...input, run: { ...run, lastOutputAt } })).not.toHaveProperty("lastOutputAgeMs");
}
for (const observerStartedAtMs of [NaN, Infinity, nowMs + 1]) {
const diagnostic = buildProcessLossDiagnostic({ ...input, observerStartedAtMs });
expect(diagnostic).not.toHaveProperty("observerUptimeMs");
expect(diagnostic).not.toHaveProperty("runPredatesObserver");
}
expect(buildProcessLossDiagnostic({ ...input, run: { ...run, startedAt: new Date(nowMs + 1) } }))
.not.toHaveProperty("runPredatesObserver");
expect(buildProcessLossDiagnostic({ ...input, observerStartedAtMs: nowMs - 1.5 }).observerUptimeMs).toBe(2);
});
it("reads only bounded scalar evidence, including hostile persisted input", () => {
const diagnostic = buildProcessLossDiagnostic(input);
expect(readProcessLossDiagnostic({ ...diagnostic, pid: 123, prompt: "private-prompt", path: "/private/path" }))
.toEqual(diagnostic);
for (const value of [null, [], "private", { localCheck: "private", pidRecorded: 1,
groupRecorded: "false", retryEligible: "private", runPredatesObserver: null,
observerUptimeMs: 604_800_001, lastOutputAgeMs: -1 },
Object.defineProperty({}, "localCheck", { get() { throw new Error("private"); } })]) {
expect(readProcessLossDiagnostic(value)).toEqual({});
}
});
});