mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-14 08:53:19 +01:00
## Thinking Path
> - Paperclip is the open source app people use to manage AI agents for
work.
> - The restart-chaos harness (`tests/restart-chaos/`) restarts the
control plane while real agent tasks run. It grades whether every task
finishes without a human Retry.
> - The hosted staging stack deploys about every 40 minutes. In 48
hours, 276 runs failed and 63 tasks stayed blocked. About a quarter of
the failures came within 10 minutes of a deploy. The harness passed the
same areas.
> - A hosted deploy replaces the server container. The new server runs
in another process space and has a new deployment id. The PID-based "the
earlier owner exited" proofs never apply across that boundary. A harness
restart on one machine keeps the process space, so every PID proof
succeeds and the hosted recovery path is never tested.
> - This pull request adds K15, a redeploy fault. It also adds the
staging census that shows the gap.
> - The benefit: chaos waves can now exercise the recovery path that
staging runs after every deploy, on local and Daytona.
## Linked Issues or Issue Description
**What happened?**
Many staging tasks that a deploy crossed ended blocked. Examples are
`native_remote_recovery_lease_mismatch`,
`runner_remote_process_identity_unavailable`,
`prior_owner_terminal_state_indeterminate` and `PRP provider resume
state is unavailable`. The restart-chaos Daytona waves passed for the
same adapters.
**What did you expect?**
The chaos harness should reproduce the deploy conditions that staging
has, so that these failure classes show up in a wave first.
**Steps to reproduce**
1. Compare the staging run records' `controllerProcess.hostHash` and
`deployment.deploymentId` across a deploy. Both change on every deploy.
2. Compare the same fields across a harness K2 restart. Both stay the
same.
3. Read `controllerProcessExited` and `sameProcessSpace` in
`server/src/services/controller-process-identity.ts`. A different
`hostHash` means that no PID proof applies.
**Additional context**
The full census, the failure classes and the per-class harness gaps are
in `doc/plans/2026-11-11-staging-failures-vs-chaos-coverage.md`. The
task identifiers in it are neutral labels.
## What Changed
- New fault `K15` (redeploy), in `tests/restart-chaos/redeploy.ts` and
`redeploy-preload.mjs`. A K15 wave gives every server an emulated host
name through a `--import` preload, which changes the process-space hash.
It also gives Railway-shaped deployment variables with the instance home
as the exclusive volume. A K15 stops the server with SIGTERM and starts
the next generation, with a new host name and deployment id.
- `supervisor.ts`, `main.ts`, `config.ts` and `types.ts`: select K15
with `--faults K15`. It works on `--env local` and `--env daytona`. The
receipt records `redeploy` and the timeline records `redeploy_switched`.
Config refuses K15 together with MS1–MS3.
- New opt-in workload `F1` (`workloads.ts`): the agent keeps an absolute
symlink in its repository workspace. A staging save-back refused such a
link and blocked the task. The oracle needs `done` and the saved notes
file, with no Retry. `f1-symlink.test.ts` covers the prompt, the grading
and the option planning.
- `redeploy.test.ts`: identity derivation, the environment, the argument
order, a real child process that proves the preload changes both CJS and
ESM `hostname()`, option parsing and plan detection.
- `tests/restart-chaos/README.md`: a K15 section and a row in the
"Pieces" table.
- `doc/plans/2026-11-11-staging-failures-vs-chaos-coverage.md`: the
48-hour staging census, 12 failure classes, the deploy timeline, the
reason the harness missed each class, and proposed scenarios.
## Verification
- `pnpm exec vitest run --config tests/restart-chaos/vitest.config.ts
tests/restart-chaos/redeploy.test.ts
tests/restart-chaos/scheduler.test.ts
tests/restart-chaos/repeated-restarts.test.ts`: 68 tests pass. The full
restart-chaos unit suite passes: 43 files, 516 tests.
- `node scripts/typecheck-test-projects.mjs tests/restart-chaos`:
passes.
- Live Daytona waves on 8f6ca25976, image
`paperclip-daytona-runner@sha256:bc7ca92d…`:
| Wave | Faults and cells | Result |
| --- | --- | --- |
| `w-20261111T002313-s9301` | K15, opencode and claude, A1 and C1, reuse
lease, 6 tasks | 5 of 6 pass. t05 (OpenCode, C1) failed R5
`silent_dead_run`: 183 s without evidence after the redeploy, then
succeeded. |
| `w-20261111T004241-s9301` | K2 control, same seed | 6 of 6 pass |
| `w-20261111T005008-s9302` | K15, warm sandbox, comment trigger, 4
tasks | 4 of 4 pass |
| `w-20261111T010000-s9303` | K15, opencode only, 8 tasks | 8 of 8 pass
|
| `w-20261111T011044-s9303` | K2 control, same seed | 8 of 8 pass |
| `w-20261111T012523-s9304` | F1 with K2, codex and opencode, `--storage
s3` | 2 of 2 pass |
- Every K15 fault switched the generation, with a new host name and
deployment id on the same volume. The server was ready again in 14–24 s.
- K15 does not yet reproduce the staging refusal codes on current
master. The census document explains the open conditions, such as a code
change at the deploy.
## Risks
- Low risk. The change is in the test harness and docs only. No product
code changes.
- K15 is an emulation. It changes the host name only in the server
process (and forked Node children). It does not emulate a new kernel
boot or PID namespace. The cloud stop-proof service is not emulated, so
stop proofs that need it stay unsupported, as on a self-hosted Railway
deploy. The README lists these limits.
## Model Used
- Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, in Claude Code with
tool use (shell, file edit, GitHub CLI).
## 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 <noreply@anthropic.com>
94 lines
5.6 KiB
TypeScript
94 lines
5.6 KiB
TypeScript
import { spawnSync } from "node:child_process";
|
|
import { describe, expect, it } from "vitest";
|
|
import { parseCommand } from "./config.js";
|
|
import {
|
|
REDEPLOY_HOSTNAME_ENV, REDEPLOY_PRELOAD_PATH, nextRedeployIdentity, redeployEnvironment, redeployIdentity, redeployNodeArgs,
|
|
redeployReceipt, redeployWave,
|
|
} from "./redeploy.js";
|
|
import { planTasks } from "./scheduler.js";
|
|
import { FAULT_SIGNAL } from "./supervisor.js";
|
|
import type { WavePlan } from "./types.js";
|
|
|
|
const UUID = /^[a-f0-9]{8}-[a-f0-9]{4}-5[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/;
|
|
const IMAGE = `ghcr.io/paperclipai/paperclip-daytona-runner@sha256:${"a".repeat(64)}`;
|
|
|
|
describe("K15 redeploy identity", () => {
|
|
it("keeps the service and volume and changes the host and deployment on every generation", () => {
|
|
const first = redeployIdentity("w-1", 0);
|
|
const second = nextRedeployIdentity("w-1", first);
|
|
expect(second.generation).toBe(1);
|
|
expect(second.hostname).not.toBe(first.hostname);
|
|
expect(second.deploymentId).not.toBe(first.deploymentId);
|
|
for (const key of ["projectId", "environmentId", "serviceId", "volumeId"] as const) expect(second[key]).toBe(first[key]);
|
|
for (const id of [first.projectId, first.environmentId, first.serviceId, first.deploymentId, first.volumeId]) expect(id).toMatch(UUID);
|
|
});
|
|
|
|
it("is deterministic per wave, so a replay derives the same identities", () => {
|
|
expect(redeployIdentity("w-1", 3)).toEqual(redeployIdentity("w-1", 3));
|
|
expect(redeployIdentity("w-2", 0).deploymentId).not.toBe(redeployIdentity("w-1", 0).deploymentId);
|
|
expect(() => redeployIdentity("w-1", -1)).toThrow(/non-negative/);
|
|
});
|
|
|
|
it("names the Railway variables the server reads as its deployment, with the instance home as the exclusive volume", () => {
|
|
const identity = redeployIdentity("w-1", 2);
|
|
expect(redeployEnvironment(identity, "/tmp/chaos/home")).toEqual({
|
|
[REDEPLOY_HOSTNAME_ENV]: identity.hostname,
|
|
RAILWAY_PROJECT_ID: identity.projectId, RAILWAY_ENVIRONMENT_ID: identity.environmentId, RAILWAY_SERVICE_ID: identity.serviceId,
|
|
RAILWAY_DEPLOYMENT_ID: identity.deploymentId, RAILWAY_VOLUME_ID: identity.volumeId, RAILWAY_VOLUME_MOUNT_PATH: "/tmp/chaos/home",
|
|
});
|
|
expect(redeployReceipt(redeployIdentity("w-1", 0), identity)).toMatchObject({ fromGeneration: 0, toGeneration: 2,
|
|
toDeploymentId: identity.deploymentId, volumeId: identity.volumeId });
|
|
});
|
|
|
|
it("loads the preload ahead of the server's own --import loader", () => {
|
|
expect(redeployNodeArgs(["--import", "/x/tsx/loader.mjs", "entry.ts"])).toEqual(["--import", REDEPLOY_PRELOAD_PATH, "--import", "/x/tsx/loader.mjs", "entry.ts"]);
|
|
});
|
|
|
|
it("changes os.hostname for both the CommonJS object and ESM named imports, as processSpaceHash reads it", () => {
|
|
const script = "import os from 'node:os'; import { hostname } from 'node:os'; console.log(JSON.stringify([os.hostname(), hostname()]));";
|
|
const run = (env: NodeJS.ProcessEnv) => spawnSync(process.execPath, ["--import", REDEPLOY_PRELOAD_PATH, "--input-type=module", "-e", script],
|
|
{ env: { PATH: process.env.PATH, ...env }, encoding: "utf8" });
|
|
const emulated = run({ [REDEPLOY_HOSTNAME_ENV]: "chaos-deploy-test" });
|
|
expect(emulated.status).toBe(0);
|
|
expect(JSON.parse(emulated.stdout)).toEqual(["chaos-deploy-test", "chaos-deploy-test"]);
|
|
// Without the variable the preload changes nothing.
|
|
const real = run({});
|
|
expect(real.status).toBe(0);
|
|
const [cjs, esm] = JSON.parse(real.stdout) as string[];
|
|
expect(cjs).toBe(esm);
|
|
expect(cjs).not.toBe("chaos-deploy-test");
|
|
});
|
|
});
|
|
|
|
describe("K15 wave options", () => {
|
|
it("is a graceful stop, selectable on local and Daytona like K2", () => {
|
|
expect(FAULT_SIGNAL.K15).toBe("SIGTERM");
|
|
const local = parseCommand(["--faults", "K15", "--adapters", "opencode", "--workloads", "A1,C1", "--seed", "1"]);
|
|
expect(local.mode === "wave" && local.options.faults).toEqual(["K15"]);
|
|
const daytona = parseCommand(["--env", "daytona", "--daytona-image", IMAGE, "--faults", "K2,K15", "--adapters", "opencode",
|
|
"--workloads", "C1", "--daytona-reuse-lease", "--seed", "1"]);
|
|
if (daytona.mode !== "wave") throw new Error("expected a wave");
|
|
expect(daytona.options.faults).toEqual(["K2", "K15"]);
|
|
expect(daytona.options.daytonaReuseLease).toBe(true);
|
|
});
|
|
|
|
it("refuses a second server beside an emulated deploy", () => {
|
|
expect(() => parseCommand(["--faults", "K15,MS1", "--workloads", "B5,Q1", "--concurrency", "1", "--seed", "1"])).toThrow(/K15 \(redeploy\) does not combine with MS1-MS3/);
|
|
});
|
|
|
|
it("plans K15 cells with no hold and marks the wave as a redeploy wave", () => {
|
|
const parsed = parseCommand(["--faults", "K15", "--adapters", "codex,opencode", "--workloads", "A1,C1", "--tasks", "4", "--seed", "7"]);
|
|
if (parsed.mode !== "wave") throw new Error("expected a wave");
|
|
const tasks = planTasks(parsed.options);
|
|
expect(tasks.every(task => task.fault === "K15" && task.holdMs === 0)).toBe(true);
|
|
const plan = { options: parsed.options, tasks } as Pick<WavePlan, "options" | "tasks" | "storm">;
|
|
expect(redeployWave(plan)).toBe(true);
|
|
const k2 = parseCommand(["--faults", "K2", "--workloads", "A1", "--tasks", "2", "--seed", "7"]);
|
|
if (k2.mode !== "wave") throw new Error("expected a wave");
|
|
expect(redeployWave({ options: k2.options, tasks: planTasks(k2.options) })).toBe(false);
|
|
// A replay of a K2 task that a K15 crossed still needs the emulated identity.
|
|
expect(redeployWave({ options: k2.options, tasks: [{ ...planTasks(k2.options)[0]!,
|
|
replayFaults: [{ kind: "K15", holdMs: 0, atMs: 1_000 }] }] })).toBe(true);
|
|
});
|
|
});
|