Files
PaperClipAI/tests/runner-e2e/server-restart-request.test.ts
T
DottaandClaude Opus 5.5 9cc3760552 test(runner-e2e): allow controller outages up to ten minutes (#19797)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - The restart chaos harness and the runner-e2e recover-controller
cells stop the control plane with `replaceController`
(`tests/runner-e2e/controller-outage.ts`), hold it down, then start it
again
> - `parseControllerOutage` refuses holds longer than 300000 ms
> - Restart chaos needs longer holds. One example is proving that a
Daytona sandbox survives an outage longer than its old 5-minute
auto-stop but inside the 7-minute runner reconnect grace (RC-54)
> - With the cap, such a fault errors before the controller stops, so
the scenario cannot run
> - This pull request raises the test-harness cap to 600000 ms
> - The benefit is that chaos runs can now inject 5–10 minute outages;
production code and qualification contracts are unchanged

## Linked Issues or Issue Description

**Bug description**
A restart chaos replay with a 360000 ms K4 hold failed with "Controller
outage requires SIGTERM/SIGKILL and 0..300000 integer milliseconds". The
controller was never stopped.

**Expected behavior**
The test harness accepts outages of up to 10 minutes. The
recover-controller receipt check keeps its own readiness limit
(`readyAfterMs < 420000`).

Refs #19544

## What Changed

- `tests/runner-e2e/controller-outage.ts`: adds
`MAX_CONTROLLER_OUTAGE_MS = 600_000` and uses it in
`parseControllerOutage` and its error message.
`validControllerOutageReceipt` is unchanged and still requires readiness
inside the 7-minute grace.
- `tests/runner-e2e/controller-outage.test.ts`: the rejection cases now
use 600001. A new test accepts 360000 (SIGKILL) and 600000 (SIGTERM).
The existing five-minute contract test is unchanged.

## Verification

- `vitest run tests/runner-e2e/controller-outage.test.ts
tests/runner-e2e/server-restart-request.test.ts`: 52/52 pass.
- `server-restart-request.test.ts` now uses 600001 as its out-of-range
`controllerOutage`. The separate restart-request `controllerDowntimeMs`
limit (five minutes) is unchanged.

## Risks

- This change is test-only. No product or server code changes.
- A suite that sets `PAPERCLIP_RUNNER_E2E_CONTROLLER_OUTAGE_MS` above
300000 now runs instead of being refused. The recover-controller receipt
check still fails any receipt whose readiness is outside the grace.

> 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

- Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, in Claude Code with
tool use (shell, file edits, GitHub CLI). Default context window,
extended thinking.

## 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
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 03:11:10 -06:00

116 lines
7.2 KiB
TypeScript

import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { runnerMatrix } from "./catalog.js";
import { controllerOutageForExecution } from "./controller-outage.js";
import { readRestartRequest, kimiCandidateAdmissionForExecution } from "./server-restart-request.js";
let root: string;
let requestPath: string;
const profilesModule = "../../packages/paperclip-runner/src/drivers/acpx/qualified-profiles.js";
beforeEach(async () => {
root = await mkdtemp(join(tmpdir(), "paperclip-supervisor-request-"));
requestPath = join(root, "restart.json");
});
afterEach(async () => {
vi.doUnmock(profilesModule);
vi.resetModules();
await rm(root, { recursive: true, force: true });
});
it.each(["rolling-web-deployment", "rolling-web-deployment-warm"])("accepts the assembled %s restart request", async suite => {
const execution = runnerMatrix.find(row => row.suite.id === suite && row.profile.id === "runner-codex")!;
const recoveryBinding = { companyId: "11111111-1111-4111-8111-111111111111",
issueId: "22222222-2222-4222-8222-222222222222", runId: "33333333-3333-4333-8333-333333333333" };
await writeFile(requestPath, JSON.stringify({ requestId: "rolling-request", rollingDeployment: true,
discardControllerCache: true, controllerOutage: controllerOutageForExecution(execution, {}), recoveryBinding }));
const request = await readRestartRequest(requestPath, [execution.id]);
expect(request).toMatchObject({ rollingDeployment: true, discardControllerCache: true, recoveryBinding });
expect(request).not.toHaveProperty("controllerOutage");
});
const cells = [
{ suite: "everyday-workflows", provider: "codex" },
{ suite: "everyday-workflows", provider: "acpx" },
{ suite: "daytona-restart-continuity", provider: "codex" },
].map(({ suite, provider }) => {
const execution = runnerMatrix.find(row => row.suite.id === suite && row.task.id === "recover-controller"
&& row.environment.id === "daytona" && row.profile.generation === "native" && row.profile.provider === provider);
if (!execution) throw new Error(`Missing real ${suite}/${provider} continuity cell`);
return execution;
});
it.each(cells)("accepts the real supervisor request file for $id", async execution => {
const outage = controllerOutageForExecution(execution, {})!;
await writeFile(requestPath, JSON.stringify({ requestId: "actual-restart-request",
discardControllerCache: true, controllerOutage: outage }));
await expect(readRestartRequest(requestPath, [execution.id])).resolves.toMatchObject({
requestId: "actual-restart-request", discardControllerCache: true, controllerOutage: outage,
});
});
it("rejects cache replacement for unselected, local and legacy cells", async () => {
await writeFile(requestPath, JSON.stringify({ requestId: "unsupported-restart", discardControllerCache: true }));
const local = runnerMatrix.find(row => row.environment.id === "local")!;
const legacy = runnerMatrix.find(row => row.profile.generation === "legacy")!;
expect(local).toBeDefined(); expect(legacy).toBeDefined();
for (const ids of [[], ["not-a-cell"], [local.id], [legacy.id]]) {
await expect(readRestartRequest(requestPath, ids)).rejects.toThrow(/cache replacement requires/i);
}
});
it.each([
{ controllerOutage: { signal: "SIGKILL", holdMs: 125_000 }, discardControllerCache: false },
{ controllerOutage: { signal: "SIGKILL", holdMs: 600_001 }, discardControllerCache: true },
{ controllerOutage: { signal: "SIGKILL", holdMs: 125_000 }, discardControllerCache: true, preserveRunner: {} },
{ controllerOutage: { signal: "SIGKILL", holdMs: 125_000 }, discardControllerCache: true, crashAfterResult: {} },
{ recoveryBinding: {} },
{ controllerDowntimeMs: -1 },
])("keeps invalid and conflicting fault boundaries rejected: %j", async fault => {
await writeFile(requestPath, JSON.stringify({ requestId: "conflicting-restart", ...fault }));
await expect(readRestartRequest(requestPath, [cells[0].id])).rejects.toThrow();
});
it("ignores absent and incomplete atomic request files", async () => {
await expect(readRestartRequest(requestPath, [cells[0].id])).resolves.toBeNull();
await writeFile(requestPath, "{");
await expect(readRestartRequest(requestPath, [cells[0].id])).resolves.toBeNull();
});
it.each(["local", "daytona"])("uses ordinary released Kimi restart without candidate authority on %s", async environment => {
const execution = runnerMatrix.find(row => row.suite.id === "kimi-qualification" && row.environment.id === environment)!;
const admission = kimiCandidateAdmissionForExecution(execution, "11111111-1111-4111-8111-111111111111");
expect(admission).toBeUndefined();
await writeFile(requestPath, JSON.stringify({ requestId: "kimi-released", kimiCandidateAdmission: admission }));
const request = await readRestartRequest(requestPath, [execution.id]);
expect(request).toMatchObject({ requestId: "kimi-released" });
expect(request).not.toHaveProperty("kimiCandidateAdmission");
});
it.each(["local", "daytona"])("stages only exact pending Kimi company/model/profile/target via owned restart on %s", async environment => {
const actual = await vi.importActual<typeof import("../../packages/paperclip-runner/src/drivers/acpx/qualified-profiles.js")>(profilesModule);
vi.doMock(profilesModule, () => ({ ...actual, QUALIFIED_ACPX_PROFILES: { ...actual.QUALIFIED_ACPX_PROFILES,
kimi: { ...actual.QUALIFIED_ACPX_PROFILES.kimi, qualificationStatus: "pending" } } }));
vi.resetModules();
const { readRestartRequest, kimiCandidateAdmissionForExecution } = await import("./server-restart-request.js");
const execution = runnerMatrix.find(row => row.suite.id === "kimi-qualification" && row.environment.id === environment)!;
const admission = kimiCandidateAdmissionForExecution(execution, "11111111-1111-4111-8111-111111111111")!;
expect(admission).toBeDefined();
await writeFile(requestPath, JSON.stringify({ requestId: "kimi-startup", kimiCandidateAdmission: admission }));
await expect(readRestartRequest(requestPath, [execution.id])).resolves.toMatchObject({ requestId: "kimi-startup", kimiCandidateAdmission: admission });
expect(admission).not.toHaveProperty("runId");
for (const change of [{ companyId: "foreign" }, { agent: "antigravity" }, { model: "different" },
{ profileDigest: `sha256:${"0".repeat(64)}` }, { nativeClosureDigest: `sha256:${"0".repeat(64)}` },
{ expiresAt: 0 }, { runId: "injected-run" }, { architecture: "arm64", platform: "linux" }]) {
await writeFile(requestPath, JSON.stringify({ requestId: "wrong-kimi", kimiCandidateAdmission: { ...admission, ...change } }));
await expect(readRestartRequest(requestPath, [execution.id])).rejects.toThrow();
}
await writeFile(requestPath, JSON.stringify({ requestId: "unselected-kimi", kimiCandidateAdmission: admission }));
for (const ids of [[], [cells[0].id]]) await expect(readRestartRequest(requestPath, ids)).rejects.toThrow();
for (const change of [{ controllerDowntimeMs: 1 }, { preserveRunner: {} }, { discardControllerCache: true }]) {
await writeFile(requestPath, JSON.stringify({ requestId: "mixed-kimi", kimiCandidateAdmission: admission, ...change }));
await expect(readRestartRequest(requestPath, [execution.id])).rejects.toThrow();
}
});