fix(heartbeat): retry every pre-provider lease acquisition failure and rest it on Retry (#20857)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - A task run takes an environment lease (for example a Daytona
sandbox) before the agent starts. No provider work happens before that
lease exists.
> - Only a provider HTTP status or a connection errno counted as a
transient lease failure. #20828 (merged while this was open) added the
Daytona acquisition deadline. A database error while the lease was
recorded, a plugin RPC deadline, and an unfinished provisional creation
still did not count.
> - Those failures became a generic `setup_failed` with no bounded
retry. And when the bounded retries of any
`environment_provider_unavailable` failure were used up, recovery still
put the task on a board-owned `stranded_assigned_issue` action.
> - A hosted instance saw this for six runs in 45 minutes when
concurrent runs rose from about 8 to about 20. Two tasks stayed blocked
and waited for the board.
> - This pull request classifies every lease acquisition failure that
has structured transient evidence as `environment_provider_unavailable`.
When the bounded retries are used up, the task rests on a system-owned
action and a working Retry.
> - It also adds the restart-chaos fault EP1, so the harness covers
lease acquisition failures from now on.
> - The benefit is that a busy or slow sandbox provider no longer
strands tasks on the board.

## Linked Issues or Issue Description

No public issue exists.

**What happened?**

Five runs failed with `Failed to acquire lease for environment
"Paperclip Sandbox" (sandbox): Daytona lease acquisition timed out;
allocation cleanup is pending` after 5 to 6 minutes in `preparing`. One
run failed with `Database query failed (lease_acquire_failed)` within 10
seconds. Every run recorded `executionRecovery: { kind: "bootstrap",
providerWorkStarted: false }`, but the error code was `setup_failed`. No
run got a bounded retry. Two tasks were left `blocked` with a
board-owned `stranded_assigned_issue` recovery action.

**Expected behavior**

A lease acquisition failure happens before any provider work. The run
must retry with a bounded, visible backoff. When the bound is used up,
the task must rest with a Retry that works, and no board-owned recovery
action.

**Steps to reproduce**

1. Make lease acquisition fail with the provider's acquisition deadline
(the validated acquisition diagnostic that the environment runtime
records), or with a database driver error.
2. Let the run start.
3. Before this change, the run fails with `setup_failed`, no retry is
scheduled, and recovery creates a board-owned `stranded_assigned_issue`
action.

## What Changed

**How this PR and #20828 divide the problem.** #20828 (merged) makes the
Daytona plugin end a stuck create early and classifies the
acquisition-deadline envelope as `environment_provider_unavailable`.
This PR does not change the Daytona plugin or that deadline path. It
adds only what #20828 leaves out:
- a lease-acquisition failure that is not a create timeout: the plugin
RPC deadline (`-32003`) and an unfinished provisional creation recorded
for this run;
- a transient database error while the lease is recorded;
- the exhausted state: a system-owned recovery action and Retry instead
of a board-owned `stranded_assigned_issue`, for every
`environment_provider_unavailable` cause (including #20828's);
- the chaos fault EP1 (EP1, EP1N, EP1D).

- `readTransientLeaseAcquisitionFailure`
(`server/src/services/environment-provider-unavailable-error.ts`)
classifies a lease acquisition failure as transient when it has
structured evidence: the provider status or errno (as before), the run's
own validated acquisition diagnostic (deadline or unfinished provisional
creation), the plugin RPC deadline, or a transient database error (a
postgres.js connection code, or SQLSTATE class 08 or 53, 57P01-57P03,
57014, 55P03, 40001, 40P01). A deterministic SQL error (syntax, schema,
constraint) is not retried. A definite non-transient status (for example
HTTP 401 or 404) is still `setup_failed`. Message text is not parsed.
- `environment-run-orchestrator.ts` uses this classifier, so these
failures get `environment_provider_unavailable` and the existing bounded
bootstrap retry (30 s, then 60 s, with jitter). The task shows "retry
scheduled" while it waits.
- When the retries are used up, recovery uses the cause
`environment_provider_unavailable` with a system-owned action and no
board routing. The notice says "Retry when ready" and does not ask for a
board decision. The task's Retry starts a new acquisition.
- Restart-chaos fault EP1: a test-mode server seam
(`environment-lease-fault-injection.ts`, read from
`PAPERCLIP_CHAOS_LEASE_FAULT_CONTROL`, ignored in production) throws the
staging error shapes before any provider call.
`tests/restart-chaos/env-provider-wave.ts` runs EP1 (deadline twice,
then success), EP1N (deadline forever, then Retry after disarm) and EP1D
(database error once). The oracle fails on any `setup_failed` run, any
board-owned recovery action, a wrong retry count, or a task that does
not complete.
- Docs: `doc/execution-semantics.md` and
`tests/restart-chaos/README.md`.

## Verification

- RED on `origin/master` (`1d67e309e0`, before #20828): the three new
tests in `heartbeat-process-recovery.test.ts` ("transient sandbox
provider refusal during lease acquisition") fail with the staging
result: `expected [ [ 'failed', 'setup_failed' ] ] to deeply equal [ [
'failed', 'environment_provider_unavailable' ], [ 'scheduled_retry',
null ] ]`. On current master the deadline now retries (#20828), but the
exhausted case still rests on a board-owned `stranded_assigned_issue`
action (the existing HTTP 502 exhaustion test showed `ownerType:
"board"` before the recovery change), and the database error is still
`setup_failed`.
- GREEN: `pnpm --filter @paperclipai/server exec vitest run
src/__tests__/heartbeat-process-recovery.test.ts -t "lease acquisition"`
passes 5 of 5. This includes the deadline and the database error that
retry once and then complete, and the deadline that never clears. That
task rests `blocked` with a system-owned action, `canRetry: true`, no
execution blocker, and Retry completes it.
- Unit tests: `environment-provider-unavailable-error.test.ts` +
`environment-lease-fault-injection.test.ts` (56),
`tests/restart-chaos/env-provider-faults.test.ts` (6) pass.
- Related suites pass: `environment-run-orchestrator`,
`task-interruption-query`, `issue-recovery-actions`,
`issue-recovery-deadlines`, `stranded-notice`,
`heartbeat-native-resume-lease-failure`.
- `server` and `tests/restart-chaos` typecheck pass.
- Chaos waves (local, Codex `gpt-6.1-sol`, head `1bd387e98f`; the later
head changes only database classification and the notice), all pass:
- EP1 `w-20261111T171803-s71-ep`, 2 tasks: each had exactly 2
`environment_provider_unavailable` runs, then succeeded with no user
action, no board-owned recovery action, brief oracle pass.
- EP1D `w-20261111T172850-s73-ep`, 1 task: 1 failed run on the database
error, then succeeded.
- EP1N `w-20261111T173346-s74-ep` (and `w-20261111T162752-s72-ep` before
the rebase): 3 failed runs, the task rested `blocked` with `canRetry:
true` and no board-owned action; after disarm, Retry completed it.
- No Daytona wave was run. The fault is injected in the server before
the provider call, so a local instance reaches the same retry and
recovery code.

## Risks

- More failures now retry, all bounded. Only transient SQLSTATEs retry.
The staging database error recorded no SQLSTATE. If it was a
deterministic SQL error, it still fails `setup_failed` and goes to the
board as before; that is intended, because a retry cannot fix it.
- Each retry is a new creation attempt with its own creation record. A
provisional allocation whose cleanup is pending stays with its record
and the creation reconciler, as before.
- The retry bound is the shared bootstrap bound (two retries). A
provider outage longer than about 15 minutes still rests on Retry, but
no longer on a board action.
- The fault seam reads a file only when the environment variable is set,
and never when `NODE_ENV=production`.
- Saved state from the previous release: no persisted shape changes. A
task already stranded on a board action keeps it
(`preserveExistingOwner`). New failures use the new cause.

> 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 (Anthropic) through Claude Code, model ID `claude-opus-5-5`,
with tool use and code execution.

## 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 Fable 5.2 <noreply@anthropic.com>
This commit is contained in:
DottaandClaude Fable 5.2 authored and GitHub committed 2026-11-11 12:05:54 -06:00
1 parent b2edea5754
commit ffe05b1b7c
14 files changed
+825 -13

No files matched your search

+10
View File
@@ -1458,6 +1458,16 @@ provider failure as the reason, retries automatically, and on exhaustion becomes
`blocked` with a specific recovery notice and a Retry. Because no provider work
started, no execution-reconciliation hold is created.
Lease acquisition precedes provider work by construction, so the same budget
covers every lease acquisition failure that carries structured transient evidence,
not only a provider status: the provider's acquisition deadline or an unfinished
provisional creation (the validated acquisition diagnostic the runtime recorded
for that run), the plugin RPC deadline, and a database driver error while the
lease is recorded. A definite non-transient status (for example HTTP 401 or 404)
still fails as `setup_failed`. On exhaustion the task's recovery action is
system-owned (cause `environment_provider_unavailable`), not a board escalation:
nothing needs board repair, and the task's Retry starts a new acquisition.
A saved workspace that cannot be restored into a new allocation before provider
dispatch fails with `remote_workspace_restore_failed`. The run error names the
server step (`snapshot_lookup`, `snapshot_download`, or `sandbox_restore`) and,
@@ -28,6 +28,7 @@ import { recordLegacyWorkspaceRestoreFailure, terminalizeLegacyExecution } from
import { issueService } from "../services/issues.js";
import { getExecutionBlocker } from "../services/execution-blocker.js";
import { JsonRpcCallError } from "@paperclipai/plugin-sdk";
import { leaseFaultError } from "../services/environment-lease-fault-injection.js";
import { taskInterruptionPresentation } from "../services/task-interruption-presentation.js";
import { requestTaskRetry } from "../services/task-retry.js";
import { adapterExecutionControls, createAdapterExecutionControl } from "../services/adapter-execution-control.js";
@@ -5903,7 +5904,20 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
message: "Daytona sandbox creation failed (HTTP 502); cleanup could not be confirmed for paperclip-create-05f81d77",
data: { schema: "paperclip/environment-acquire-error/v1", providerDiagnostic: { httpStatus: 502 } } });
function failLeaseAcquisition(times: number) {
// EP1: Daytona's own acquisition deadline expired while the provisional
// sandbox was still being created, as staging recorded at 14:00-14:45Z when
// concurrent runs rose from about 8 to about 20. The environment runtime
// records the validated acquisition diagnostic for this run before rethrowing.
// The chaos fault EP1 injects these same shapes (environment-lease-fault-injection.ts).
type LeaseFault = (input: { companyId: string; environment: { id: string }; heartbeatRunId?: string | null }) => Error;
const daytonaAcquisitionDeadline: LeaseFault = input => leaseFaultError("timeout",
{ companyId: input.companyId, environmentId: input.environment.id, heartbeatRunId: input.heartbeatRunId! });
// EP1 database: a control-plane query failed while the lease was being
// recorded (staging: "Database query failed (lease_acquire_failed)").
const leaseRecordQueryFailure: LeaseFault = input => leaseFaultError("database",
{ companyId: input.companyId, environmentId: input.environment.id, heartbeatRunId: input.heartbeatRunId! });
function failLeaseAcquisition(times: number, fault: LeaseFault = daytonaCreate502) {
let remaining = times;
const acquisitions = { refused: 0, delegated: 0 };
const originalFactory = environmentOrchestrator.environmentRunOrchestrator;
@@ -5913,7 +5927,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
if (remaining > 0) {
remaining -= 1;
acquisitions.refused += 1;
throw daytonaCreate502();
throw fault(input);
}
acquisitions.delegated += 1;
return runtime.acquireRunLease(input);
@@ -6028,6 +6042,81 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
mockAdapterExecute.mockClear();
}
}, 90_000);
it.each([
["a provider acquisition deadline", daytonaAcquisitionDeadline, "timeout"],
["a database error while recording the lease", leaseRecordQueryFailure, "overloaded"],
] as const)("retries %s before provider work with a bounded, visible backoff", async (_label, fault, category) => {
const { companyId, agentId, runId, issueId } = await seedQueuedIssueRunFixture();
const lease = failLeaseAcquisition(1, fault);
try {
const heartbeat = heartbeatService(db);
await heartbeat.resumeQueuedRuns();
await heartbeat.drainActiveRunExecutions();
const afterFailure = await runsFor(agentId);
expect(afterFailure.map(run => [run.status, run.errorCode])).toEqual([
["failed", "environment_provider_unavailable"], ["scheduled_retry", null]]);
expect(afterFailure[0]!.resultJson).toMatchObject({ providerFailure: { phase: "startup", category, retryability: "transient" },
executionRecovery: { kind: "bootstrap", providerWorkStarted: false } });
expect(afterFailure[1]).toMatchObject({ retryOfRunId: runId, scheduledRetryAttempt: 1, scheduledRetryReason: "transient_failure" });
const [waiting] = await db.select().from(issues).where(eq(issues.id, issueId));
expect(waiting).toMatchObject({ status: "in_progress", executionRunId: afterFailure[1]!.id });
expect(await taskInterruptionPresentation(db, waiting!)).toMatchObject({ state: "retry_scheduled", responsibility: "system" });
expect(await getExecutionBlocker(db, companyId, issueId)).toBeNull();
await promoteLatestScheduledRetry(heartbeat, agentId);
const successors = (await runsFor(agentId)).filter(run => run.retryOfRunId === runId);
expect(successors).toHaveLength(1);
expect(successors[0]).toMatchObject({ status: "succeeded" });
expect(lease.acquisitions.refused).toBe(1);
// The mock adapter's later disposition repair is unrelated; no setup failure reached recovery.
expect((await selectRecoveryActionsForIssue(issueId)).filter(action => action.kind === "stranded_assigned_issue")).toEqual([]);
} finally {
lease.restore();
mockAdapterExecute.mockClear();
}
}, 60_000);
it("rests a provider deadline that never clears on a working Retry, never on a board recovery action", async () => {
const { companyId, agentId, issueId } = await seedQueuedIssueRunFixture();
const lease = failLeaseAcquisition(BOUNDED_TRANSIENT_HEARTBEAT_RETRY_MAX_ATTEMPTS + 1, daytonaAcquisitionDeadline);
try {
const heartbeat = heartbeatService(db);
await heartbeat.resumeQueuedRuns();
await heartbeat.drainActiveRunExecutions();
for (let attempt = 0; attempt < BOUNDED_TRANSIENT_HEARTBEAT_RETRY_MAX_ATTEMPTS; attempt += 1) {
await promoteLatestScheduledRetry(heartbeat, agentId);
}
const exhausted = await runsFor(agentId);
expect(exhausted.map(run => run.errorCode)).toEqual(
Array(BOUNDED_TRANSIENT_HEARTBEAT_RETRY_MAX_ATTEMPTS + 1).fill("environment_provider_unavailable"));
expect(mockAdapterExecute).not.toHaveBeenCalled();
const blocked = await waitForValue(async () => {
const [row] = await db.select().from(issues).where(eq(issues.id, issueId));
return row?.status === "blocked" ? row : null;
});
const presentation = await taskInterruptionPresentation(db, blocked!);
expect(presentation).toMatchObject({ state: "failed", responsibility: "system", canRetry: true,
observedRunId: exhausted.at(-1)!.id, providerFailure: { category: "timeout", retryability: "transient" } });
expect(await getExecutionBlocker(db, companyId, issueId)).toBeNull();
// The task rests on a system-owned action and Retry; nothing waits on the board.
expect((await selectRecoveryActionsForIssue(issueId)).map(action => [action.ownerType, action.cause]))
.toEqual([["system", "environment_provider_unavailable"]]);
const receipt = await requestTaskRetry({ db, companyId, issueId, actorId: "responsible-user",
idempotencyKey: randomUUID(), observedRunId: presentation!.observedRunId });
expect(receipt.accepted).toBe(true);
await heartbeat.resumeTaskRetry(companyId, receipt.requestId!);
await heartbeat.drainActiveRunExecutions();
expect(await waitForValue(async () => (await runsFor(agentId)).find(run => run.status === "succeeded") ?? null, 10_000))
.not.toBeNull();
expect(lease.acquisitions.delegated).toBe(1);
} finally {
lease.restore();
mockAdapterExecute.mockClear();
}
}, 90_000);
});
it("classifies only the installed-but-not-ready sandbox provider plugin message as a configuration gap", () => {
@@ -0,0 +1,63 @@
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { parseLeaseFaultControl } from "./environment-lease-fault-injection.js";
describe("lease acquisition fault injection (chaos EP1)", () => {
let directory: string;
let control: string;
let seam: typeof import("./environment-lease-fault-injection.js");
// Loaded after the reset, so it shares the seam's diagnostic receipts.
let readTransientLeaseAcquisitionFailure: typeof import("./environment-provider-unavailable-error.js").readTransientLeaseAcquisitionFailure;
const input = (issueId: string | null = "issue-1") => ({ companyId: "company-1", environmentId: "env-1", heartbeatRunId: "run-1", issueId });
beforeAll(async () => {
directory = await mkdtemp(path.join(os.tmpdir(), "lease-fault-"));
control = path.join(directory, "control.json");
vi.stubEnv("PAPERCLIP_CHAOS_LEASE_FAULT_CONTROL", control);
vi.resetModules();
seam = await import("./environment-lease-fault-injection.js");
({ readTransientLeaseAcquisitionFailure } = await import("./environment-provider-unavailable-error.js"));
});
afterAll(async () => {
vi.unstubAllEnvs();
await rm(directory, { recursive: true, force: true });
});
it("parses only a well-formed control", () => {
expect(parseLeaseFaultControl('{"token":"a","kind":"timeout","failures":2}')).toEqual({ token: "a", kind: "timeout", failures: 2 });
expect(parseLeaseFaultControl('{"token":"a","kind":"database","failures":null}')).toEqual({ token: "a", kind: "database", failures: null });
expect(parseLeaseFaultControl('{"token":"a","kind":"other","failures":1}')).toBeNull();
expect(parseLeaseFaultControl('{"token":"a b","kind":"timeout","failures":1}')).toBeNull();
expect(parseLeaseFaultControl("")).toBeNull();
});
it("is disarmed without a control file", () => {
expect(() => seam.injectLeaseAcquisitionFault(input())).not.toThrow();
});
it("fails each task's first N acquisitions with a retryable staging shape, then lets them through", async () => {
await writeFile(control, JSON.stringify({ token: "t1", kind: "timeout", failures: 2 }));
for (let attempt = 0; attempt < 2; attempt += 1) {
let thrown: unknown;
try { seam.injectLeaseAcquisitionFault(input()); } catch (error) { thrown = error; }
expect(readTransientLeaseAcquisitionFailure(thrown, { id: "run-1", companyId: "company-1" })).toEqual({ acquisitionTimedOut: true });
}
expect(() => seam.injectLeaseAcquisitionFault(input())).not.toThrow();
// Another task keeps its own count; a run with no task is never faulted.
expect(() => seam.injectLeaseAcquisitionFault(input("issue-2"))).toThrow(/lease acquisition timed out/);
expect(() => seam.injectLeaseAcquisitionFault(input(null))).not.toThrow();
});
it("re-arms with a new token, fails forever with null, and disarms on an empty file", async () => {
await writeFile(control, JSON.stringify({ token: "t2", kind: "database", failures: null }));
for (let attempt = 0; attempt < 5; attempt += 1) {
let thrown: unknown;
try { seam.injectLeaseAcquisitionFault(input()); } catch (error) { thrown = error; }
expect(readTransientLeaseAcquisitionFailure(thrown, { id: "run-1", companyId: "company-1" })).toEqual({ reason: "database_error" });
}
await writeFile(control, "");
expect(() => seam.injectLeaseAcquisitionFault(input())).not.toThrow();
});
});
@@ -0,0 +1,76 @@
/**
* Restart-chaos fault EP1 (tests/restart-chaos/env-provider-faults.ts): make a
* run's lease acquisition fail before any provider call, in the exact shapes
* staging recorded, so the bounded retry and Retry paths can be exercised on a
* local instance without a paid sandbox provider.
*
* Arming (test mode only; ignored when NODE_ENV=production):
*
* PAPERCLIP_CHAOS_LEASE_FAULT_CONTROL=<file>
*
* Each acquisition reads the file. Missing, empty or malformed means disarmed.
* Content: {"token":"<id>","kind":"timeout"|"database","failures":<n>|null}.
* The first <n> acquisitions of each task under one token fail (null: every
* one), so writing a new token re-arms with fresh counts and an empty file
* disarms. Counts live in this process, so a restart starts them again.
*
* This is test instrumentation, not Telemetry or Observability
* (rules/data-paths.mdc): nothing is recorded or sent anywhere.
*/
import { readFileSync } from "node:fs";
import { JsonRpcCallError, PLUGIN_RPC_ERROR_CODES, readEnvironmentAcquisitionDiagnostic } from "@paperclipai/plugin-sdk";
import { captureEnvironmentAcquisitionDiagnostic } from "./environment-acquisition-diagnostics.js";
export const LEASE_FAULT_CONTROL_ENV = "PAPERCLIP_CHAOS_LEASE_FAULT_CONTROL";
export type LeaseFaultControl = { token: string; kind: "timeout" | "database"; failures: number | null };
export function parseLeaseFaultControl(text: string): LeaseFaultControl | null {
try {
const value = JSON.parse(text) as Record<string, unknown>;
if (typeof value.token !== "string" || !/^[A-Za-z0-9_-]{1,80}$/.test(value.token)) return null;
if (value.kind !== "timeout" && value.kind !== "database") return null;
const failures = value.failures;
if (failures !== null && !(Number.isSafeInteger(failures) && (failures as number) >= 0)) return null;
return { token: value.token, kind: value.kind, failures: failures as number | null };
} catch { return null; }
}
const controlPath = process.env.NODE_ENV === "production" ? null : process.env[LEASE_FAULT_CONTROL_ENV]?.trim() || null;
const counts = new Map<string, number>();
/** The staging shapes: Daytona's acquisition deadline, and a failed control-plane query. */
export function leaseFaultError(kind: LeaseFaultControl["kind"], input: {
companyId: string; environmentId: string; heartbeatRunId: string;
}): Error {
if (kind === "database") {
return new Error("Failed query: insert into \"environment_leases\" (\"id\") values ($1)\nparams: <chaos>", {
cause: Object.assign(new Error("terminating connection due to administrator command"), { name: "PostgresError", code: "57P01" }),
});
}
const error = new JsonRpcCallError({ code: PLUGIN_RPC_ERROR_CODES.WORKER_ERROR,
message: "Daytona lease acquisition timed out; allocation cleanup is pending",
data: { schema: "paperclip/environment-creation-cleanup/v1",
cleanup: { providerLeaseId: `chaos-ep1-${input.heartbeatRunId.slice(0, 8)}`, companyId: input.companyId,
environmentId: input.environmentId, runId: input.heartbeatRunId, attemptId: input.heartbeatRunId,
accountFingerprint: "0".repeat(64), labels: {} },
acquisitionDiagnostic: { phase: "create", elapsedMs: 300_000, budgetMs: 300_000, providerState: "creating" } } });
captureEnvironmentAcquisitionDiagnostic(error, { companyId: input.companyId, environmentId: input.environmentId,
runId: input.heartbeatRunId }, readEnvironmentAcquisitionDiagnostic(error));
return error;
}
/** Throws the armed fault for this acquisition; a single null check when disarmed. */
export function injectLeaseAcquisitionFault(input: {
companyId: string; environmentId: string; heartbeatRunId: string; issueId: string | null;
}): void {
if (controlPath === null || !input.issueId) return;
let control: LeaseFaultControl | null;
try { control = parseLeaseFaultControl(readFileSync(controlPath, "utf8")); } catch { return; }
if (!control) return;
const key = `${control.token}:${input.issueId}`;
const seen = counts.get(key) ?? 0;
if (control.failures !== null && seen >= control.failures) return;
counts.set(key, seen + 1);
throw leaseFaultError(control.kind, input);
}
@@ -1,8 +1,11 @@
import { describe, expect, it } from "vitest";
import { JsonRpcCallError, PLUGIN_RPC_ERROR_CODES, readEnvironmentAcquisitionDiagnostic } from "@paperclipai/plugin-sdk";
import { captureEnvironmentAcquisitionDiagnostic } from "./environment-acquisition-diagnostics.js";
import {
EnvironmentProviderUnavailableError,
findEnvironmentProviderUnavailableError,
readTransientEnvironmentProviderFailure,
readTransientLeaseAcquisitionFailure,
} from "./environment-provider-unavailable-error.js";
function withStatus(status: number, key: "status" | "statusCode" = "status") {
@@ -112,3 +115,78 @@ describe("EnvironmentProviderUnavailableError", () => {
expect(findEnvironmentProviderUnavailableError(new Error("plain"))).toBeNull();
});
});
describe("readTransientLeaseAcquisitionFailure", () => {
const run = { id: "run-1", companyId: "company-1" };
const creationFailure = (elapsedMs: number, runId = run.id) => {
const error = new JsonRpcCallError({ code: PLUGIN_RPC_ERROR_CODES.WORKER_ERROR,
message: "Daytona lease acquisition timed out; allocation cleanup is pending",
data: { schema: "paperclip/environment-creation-cleanup/v1",
cleanup: { providerLeaseId: "paperclip-create-1", companyId: run.companyId, environmentId: "env-1", runId,
attemptId: "attempt-1", accountFingerprint: "a".repeat(64), labels: {} },
acquisitionDiagnostic: { phase: "create", elapsedMs, budgetMs: 300_000 } } });
captureEnvironmentAcquisitionDiagnostic(error, { companyId: run.companyId, environmentId: "env-1", runId },
readEnvironmentAcquisitionDiagnostic(error));
return error;
};
it("keeps the provider's transient status", () => {
expect(readTransientLeaseAcquisitionFailure(withStatus(503), run)).toEqual({ httpStatus: 503 });
});
it("retries a creation that hit the acquisition deadline", () => {
expect(readTransientLeaseAcquisitionFailure(new Error("wrapped", { cause: creationFailure(300_000) }), run))
.toEqual({ acquisitionTimedOut: true });
});
it("retries a provisional creation that did not finish", () => {
expect(readTransientLeaseAcquisitionFailure(creationFailure(12_000), run)).toEqual({ reason: "acquisition_incomplete" });
});
it("ignores an unfinished-creation diagnostic recorded for another run", () => {
expect(readTransientLeaseAcquisitionFailure(creationFailure(12_000, "run-2"), run)).toBeNull();
});
it("retries a plugin RPC deadline", () => {
const timeout = new JsonRpcCallError({ code: PLUGIN_RPC_ERROR_CODES.TIMEOUT, message: "RPC call timed out" });
expect(readTransientLeaseAcquisitionFailure(timeout, run)).toEqual({ acquisitionTimedOut: true });
});
const pg = (code: string) => Object.assign(new Error("database error"), { name: "PostgresError", code });
it.each([
["a terminated connection", pg("57P01")],
["a connection failure", pg("08006")],
["too many connections", pg("53300")],
["a lock timeout", pg("55P03")],
["a closed connection", Object.assign(new Error("closed"), { code: "CONNECTION_CLOSED" })],
["a Drizzle wrapper around a statement timeout", new Error("Failed query: select 1\nparams: ", { cause: pg("57014") })],
])("retries %s while recording the lease", (_label, cause) => {
expect(readTransientLeaseAcquisitionFailure(new Error("lease", { cause }), run)).toEqual({ reason: "database_error" });
});
it.each([
["a unique violation", pg("23505")],
["an undefined column", pg("42703")],
["a Drizzle wrapper around a syntax error", new Error("Failed query: select\nparams: ", { cause: pg("42601") })],
["a bare Drizzle wrapper", new Error("Failed query: select 1\nparams: ")],
["an errno that looks like a SQLSTATE", Object.assign(new Error("x"), { code: "EPERM" })],
])("does not retry %s", (_label, cause) => {
expect(readTransientLeaseAcquisitionFailure(new Error("lease", { cause }), run)).toBeNull();
});
it("does not retry a definite non-transient status, even with other evidence", () => {
const refused = Object.assign(new Error("unauthorized", { cause: creationFailure(300_000) }), { status: 401 });
expect(readTransientLeaseAcquisitionFailure(refused, run)).toBeNull();
});
it("does not retry an error without structured evidence", () => {
expect(readTransientLeaseAcquisitionFailure(new Error("Daytona lease acquisition timed out"), run)).toBeNull();
});
it("names the reason in the board-facing failure", () => {
const error = new EnvironmentProviderUnavailableError({ environmentName: "Sandbox", driver: "daytona",
evidence: { reason: "database_error" }, cause: null });
expect(error.providerFailure).toMatchObject({ category: "overloaded", providerCode: "database_error" });
expect(error.message).toContain("a database query failed while recording the lease");
});
});
@@ -1,5 +1,6 @@
import { readEnvironmentAcquireErrorDiagnostic, readEnvironmentAcquisitionDiagnostic } from "@paperclipai/plugin-sdk";
import { JsonRpcCallError, PLUGIN_RPC_ERROR_CODES, readEnvironmentAcquireErrorDiagnostic, readEnvironmentAcquisitionDiagnostic } from "@paperclipai/plugin-sdk";
import type { ProviderFailure } from "@paperclipai/shared";
import { getEnvironmentAcquisitionDiagnostic } from "./environment-acquisition-diagnostics.js";
/**
* Setup failure code for a lease acquisition that the environment provider
@@ -21,7 +22,13 @@ const TRANSIENT_ERROR_CODES = new Set([
"UND_ERR_CONNECT_TIMEOUT", "UND_ERR_SOCKET",
]);
type TransientProviderEvidence = { httpStatus?: number; errorCode?: string; acquisitionTimedOut?: true };
/**
* `reason` names evidence that has neither a provider status nor a deadline:
* the provider reported a provisional creation that did not finish, or a
* control-plane database query failed while the lease was taken.
*/
type TransientLeaseFailureReason = "acquisition_incomplete" | "database_error";
type TransientProviderEvidence = { httpStatus?: number; errorCode?: string; acquisitionTimedOut?: true; reason?: TransientLeaseFailureReason };
function own(value: unknown, key: string): unknown {
try {
@@ -54,6 +61,69 @@ export function readTransientEnvironmentProviderFailure(error: unknown): Transie
return null;
}
/** A definite non-transient HTTP status anywhere on the chain is authoritative: retrying cannot change it. */
function definiteNonTransientStatus(error: unknown): boolean {
for (let cause = error, depth = 0; cause && depth < 5; cause = own(cause, "cause"), depth++) {
const status = readEnvironmentAcquireErrorDiagnostic(cause)?.httpStatus
?? [own(cause, "status"), own(cause, "statusCode")].find(value => Number.isInteger(value));
if (typeof status === "number" && status >= 400 && !TRANSIENT_HTTP_STATUSES.has(status)) return true;
}
return false;
}
// postgres.js connection-level codes, and SQLSTATEs a repeated statement can
// pass: connection (08), insufficient resources (53), operator intervention
// (57P01-57P03, 57014 query cancelled or timed out), lock not available (55P03),
// serialization failure and deadlock (40001, 40P01). A deterministic SQL error
// (syntax, schema, constraint) is not retried.
const DATABASE_CONNECTION_CODES = new Set(["CONNECTION_CLOSED", "CONNECTION_ENDED", "CONNECTION_DESTROYED", "CONNECT_TIMEOUT"]);
const TRANSIENT_SQLSTATES = new Set(["57P01", "57P02", "57P03", "57014", "55P03", "40001", "40P01"]);
function transientDatabaseFailure(error: unknown): boolean {
for (let cause = error, depth = 0; cause && depth < 5; cause = own(cause, "cause"), depth++) {
const code = own(cause, "code");
if (typeof code !== "string") continue;
if (DATABASE_CONNECTION_CODES.has(code)) return true;
if (/^[0-9A-Z]{5}$/.test(code) && (code.startsWith("08") || code.startsWith("53") || TRANSIENT_SQLSTATES.has(code))) {
// A five-character code is a SQLSTATE only on a database driver error.
const name = own(cause, "name");
if (name === "PostgresError" || name === "DatabaseError" || own(cause, "severity") !== undefined) return true;
}
}
return false;
}
/**
* Classify a failed lease acquisition for the bounded bootstrap retry. Lease
* acquisition precedes any provider (agent) work by construction, and every
* retry is a new creation attempt with its own idempotent identity, so a
* failure is retried unless it carries a definite non-transient status. Only
* structured evidence is read: the provider's status or errno, the validated
* acquisition diagnostic the runtime recorded for this run, the plugin RPC
* deadline, or a transient database driver code. Message text is never parsed.
*/
export function readTransientLeaseAcquisitionFailure(
error: unknown,
run: { id: string; companyId: string },
): TransientProviderEvidence | null {
const provider = readTransientEnvironmentProviderFailure(error);
if (provider) return provider;
if (definiteNonTransientStatus(error)) return null;
for (let cause = error, depth = 0; cause && depth < 5; cause = own(cause, "cause"), depth++) {
const acquisition = getEnvironmentAcquisitionDiagnostic(cause, run);
if (acquisition) {
return acquisition.elapsedMs >= acquisition.budgetMs ? { acquisitionTimedOut: true } : { reason: "acquisition_incomplete" };
}
if (cause instanceof JsonRpcCallError && cause.code === PLUGIN_RPC_ERROR_CODES.TIMEOUT) return { acquisitionTimedOut: true };
}
return transientDatabaseFailure(error) ? { reason: "database_error" } : null;
}
const REASON_LABELS: Record<TransientLeaseFailureReason, string> = {
acquisition_incomplete: "sandbox creation did not finish",
database_error: "a database query failed while recording the lease",
};
function category(evidence: TransientProviderEvidence): ProviderFailure["category"] {
if (evidence.httpStatus === 429) return "rate_limit";
if (evidence.acquisitionTimedOut || evidence.httpStatus === 408 || evidence.httpStatus === 504
@@ -70,13 +140,15 @@ export class EnvironmentProviderUnavailableError extends Error {
readonly providerFailure: ProviderFailure;
constructor(input: { environmentName: string; driver: string; evidence: TransientProviderEvidence; cause: unknown }) {
const status = input.evidence.httpStatus !== undefined ? `HTTP ${input.evidence.httpStatus}` : input.evidence.errorCode;
const status = input.evidence.httpStatus !== undefined ? `HTTP ${input.evidence.httpStatus}`
: input.evidence.errorCode ?? (input.evidence.reason ? REASON_LABELS[input.evidence.reason] : undefined);
super(status
? `Environment provider for "${input.environmentName}" was temporarily unavailable (${status}).`
: `Environment provider for "${input.environmentName}" did not finish starting the sandbox in time.`, { cause: input.cause });
this.name = "EnvironmentProviderUnavailableError";
this.httpStatus = input.evidence.httpStatus ?? null;
this.errorCode = input.evidence.errorCode ?? null;
const providerCode = input.evidence.errorCode ?? (input.evidence.httpStatus === undefined ? input.evidence.reason : undefined);
this.providerFailure = {
schema: "paperclip.provider-failure.v1",
provider: /^[a-zA-Z0-9_.:/-]{1,80}$/.test(input.driver) ? input.driver : "environment",
@@ -87,7 +159,7 @@ export class EnvironmentProviderUnavailableError extends Error {
: `The sandbox for "${input.environmentName}" did not finish starting in time. `)
+ "No agent work had started. Paperclip retries automatically; if those retries are used up, use Retry once the provider is available.",
...(input.evidence.httpStatus !== undefined ? { httpStatus: input.evidence.httpStatus } : {}),
...(input.evidence.errorCode !== undefined ? { providerCode: input.evidence.errorCode } : {}),
...(providerCode !== undefined ? { providerCode } : {}),
retryability: "transient",
};
}
@@ -22,7 +22,8 @@ import { environmentLeases, type Db } from "@paperclipai/db";
import { and, eq } from "drizzle-orm";
import { remoteExecutionHasStopped } from "./remote-execution-termination.js";
import { remoteRecoveryLeaseMismatch, type RemoteRecoveryLeaseMismatchField } from "./native-runtime/remote-recovery-lease-mismatch.js";
import { EnvironmentProviderUnavailableError, readTransientEnvironmentProviderFailure } from "./environment-provider-unavailable-error.js";
import { EnvironmentProviderUnavailableError, readTransientLeaseAcquisitionFailure } from "./environment-provider-unavailable-error.js";
import { injectLeaseAcquisitionFault } from "./environment-lease-fault-injection.js";
import { findReusableSandboxResumeError } from "./reusable-sandbox-resume-error.js";
import { findPluginWorkerUnavailableError } from "./plugin-worker-unavailable-error.js";
import type {
@@ -232,13 +233,18 @@ export function environmentRunOrchestrator(
runnerReconnectGraceMs?: number;
}): Promise<EnvironmentRuntimeLeaseRecord> {
try {
// Restart-chaos EP1 only; disarmed (always in production) this is one null check.
injectLeaseAcquisitionFault({ companyId: input.companyId, environmentId: input.environment.id,
heartbeatRunId: input.heartbeatRunId, issueId: input.issueId });
return await environmentRuntime.acquireRunLease({ ...input, assertCompanyBinding: true });
} catch (err) {
// A transient provider refusal (HTTP 5xx/429/timeout) is retried by the
// setup path with its bounded bootstrap budget. Saved-sandbox resume and
// Lease acquisition precedes any provider work, so a failure without a
// definite non-transient status (HTTP 5xx/429, errno, acquisition
// deadline, unfinished creation, database error) is retried by the setup
// path with its bounded bootstrap budget. Saved-sandbox resume and
// stopped-worker refusals keep their own, more specific classifications.
const transient = findReusableSandboxResumeError(err) || findPluginWorkerUnavailableError(err)
? null : readTransientEnvironmentProviderFailure(err);
? null : readTransientLeaseAcquisitionFailure(err, { id: input.heartbeatRunId, companyId: input.companyId });
throw new EnvironmentRunError(
"lease_acquire_failed",
`Failed to acquire lease for environment "${input.environment.name}" (${input.environment.driver}): ${err instanceof Error ? err.message : String(err)}`,
+20 -2
View File
@@ -299,6 +299,7 @@ export type StrandedRecoveryCause =
| "deliberate_wait_without_target"
| "process_lost"
| "provider_quota"
| typeof ENVIRONMENT_PROVIDER_UNAVAILABLE_CODE
| "codex_output_inactivity_monitor"
| "workspace_validation_failed"
| "configuration_incomplete"
@@ -444,12 +445,24 @@ function isProviderQuotaRecovery(latestRun: LatestIssueRun) {
);
}
/**
* Lease acquisition failed before any provider work, and its bounded retry
* budget is spent. Nothing needs inspection or repair by the board: the task
* rests on a system-owned action and the normal Retry, never on a board one.
*/
function isPreDispatchEnvironmentProviderFailure(latestRun: LatestIssueRun) {
if (latestRun?.errorCode !== ENVIRONMENT_PROVIDER_UNAVAILABLE_CODE) return false;
const recovery = parseObject(parseObject(latestRun.resultJson).executionRecovery);
return recovery.kind === "bootstrap" && recovery.providerWorkStarted === false;
}
function resolveStrandedRecoveryCause(
latestRun: LatestIssueRun,
explicitCause?: StrandedRecoveryCause,
): StrandedRecoveryCause {
if (explicitCause) return explicitCause;
if (isProviderQuotaRecovery(latestRun)) return "provider_quota";
if (isPreDispatchEnvironmentProviderFailure(latestRun)) return ENVIRONMENT_PROVIDER_UNAVAILABLE_CODE;
if (latestRun?.errorCode === "process_lost") return "process_lost";
if (latestRun?.errorCode === "codex_output_inactivity_monitor") {
return "codex_output_inactivity_monitor";
@@ -2553,6 +2566,7 @@ export function recoveryService(
latestRun: input.latestRun,
});
const isProviderQuotaWait = recoveryCause === "provider_quota";
const isEnvironmentProviderWait = recoveryCause === ENVIRONMENT_PROVIDER_UNAVAILABLE_CODE;
const now = new Date();
const actions = database === db ? recoveryActionsSvc : issueRecoveryActionService(database);
const action = await actions.upsertSourceScoped({
@@ -2565,7 +2579,7 @@ export function recoveryService(
supersedeOnIdentityChange: recoveryCause === "configuration_incomplete",
preserveExistingOwner: true,
kind: strandedRecoveryActionKind(recoveryCause),
ownerType: isProviderQuotaWait ? "system" : "board",
ownerType: isProviderQuotaWait || isEnvironmentProviderWait ? "system" : "board",
ownerAgentId: null,
ownerUserId: null,
previousOwnerAgentId: input.issue.assigneeAgentId,
@@ -2587,7 +2601,7 @@ export function recoveryService(
failureSummary:
summarizeRunFailureForIssueComment(input.latestRun)?.trim() ?? null,
},
evidenceOnCreate: isProviderQuotaWait
evidenceOnCreate: isProviderQuotaWait || isEnvironmentProviderWait
? {}
: { routingPolicy: STRANDED_BOARD_ESCALATION_POLICY },
nextAction:
@@ -2597,6 +2611,8 @@ export function recoveryService(
? "Board operator: inspect the retry history, then explicitly retry the original owner, reassign, or intentionally resolve the task."
: recoveryCause === "provider_quota"
? "Wait for provider quota recovery, then retry the original assignee; do not wake a takeover owner."
: isEnvironmentProviderWait
? "No agent work started. Retry the task once the environment provider is available."
: recoveryCause === "codex_output_inactivity_monitor"
? "Board operator: inspect the inactivity evidence, then explicitly retry the original owner, reassign, or intentionally resolve the task."
: recoveryCause === "workspace_validation_failed"
@@ -2632,6 +2648,8 @@ export function recoveryService(
type: "monitor_only",
reason: recoveryCause,
}
: isEnvironmentProviderWait
? { type: "manual_retry", reason: recoveryCause, preservesSourceAssignee: true }
: {
type: "board_escalation",
reason: recoveryCause,
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import { noticeMetadataReferencesRecoveryAction } from "./successful-run-handoff.js";
import { ENVIRONMENT_PROVIDER_UNAVAILABLE_CODE } from "../environment-provider-unavailable-error.js";
import {
DEFAULT_STRANDED_RECOVERY_NOTICE_BODY,
buildConfigurationIncompleteRecoveryNoticeSeed,
@@ -147,6 +148,19 @@ describe("buildStrandedRecoveryEscalationNotice", () => {
expect(notice.body).toContain("before the agent started");
expect(JSON.stringify(notice.metadata)).toContain("worker status");
});
it("asks for Retry, not a board decision, when the environment provider stayed unavailable", () => {
const notice = buildStrandedRecoveryEscalationNotice({
seed: buildImmediateExecutionPathRecoveryNoticeSeed({ status: "in_progress" }),
recoveryCause: ENVIRONMENT_PROVIDER_UNAVAILABLE_CODE,
recoveryActionId: "provider-recovery",
recoveryOwner: null,
sourceRun: { id: "failed-run", status: "failed", errorCode: ENVIRONMENT_PROVIDER_UNAVAILABLE_CODE },
});
const metadata = JSON.stringify(notice.metadata);
expect(notice.presentation.title).toBe("Environment provider was temporarily unavailable");
expect(metadata).toContain("Retry when ready");
expect(metadata).not.toContain("Board decision required");
});
const actionId = "6a2f8e64-6f5e-4b58-b7fd-111111111111";
const owner = { id: "9b1c2d3e-4f50-4a61-8b72-222222222222", name: "CTO" };
const sourceRun = {
@@ -1,4 +1,5 @@
import type { IssueCommentMetadata, IssueCommentPresentation } from "@paperclipai/shared";
import { ENVIRONMENT_PROVIDER_UNAVAILABLE_CODE } from "../environment-provider-unavailable-error.js";
import {
agentLinkRow,
keyValueRow,
@@ -281,7 +282,8 @@ export function buildStrandedRecoveryEscalationNotice(input: {
? agentLinkRow("Recovery owner", input.recoveryOwner)
: keyValueRow(
"Recovery owner",
"Board decision required",
// A pre-dispatch provider outage needs no board decision, only Retry.
input.recoveryCause === ENVIRONMENT_PROVIDER_UNAVAILABLE_CODE ? "Retry when ready" : "Board decision required",
),
keyValueRow(
"Next action",
+24
View File
@@ -461,6 +461,30 @@ The observer clicks Retry in the board UI, as for every other cell. In waves `w-
and `w-20261111T134316-s9413` the disk-full rest showed "Recover saved files", not a Retry button, while the API reported
`canRetry: true`; R3 records this as `ui_retry_button_missing`.
## Environment-provider faults (EP1, `env-provider-wave.ts`)
```sh
node cli/node_modules/tsx/dist/cli.mjs tests/restart-chaos/env-provider-wave.ts --fault EP1 --adapters codex --tasks 2 --seed 71
node cli/node_modules/tsx/dist/cli.mjs tests/restart-chaos/env-provider-wave.ts --fault EP1N --tasks 1 --seed 72
node cli/node_modules/tsx/dist/cli.mjs tests/restart-chaos/env-provider-wave.ts --fault EP1D --tasks 1 --seed 73
```
Lease acquisition fails before any provider call, in the shapes staging recorded
on 2026-11-11: Daytona's acquisition deadline ("lease acquisition timed out;
allocation cleanup is pending") and a failed database query. The server seam
`server/src/services/environment-lease-fault-injection.ts` (test mode only) reads
`PAPERCLIP_CHAOS_LEASE_FAULT_CONTROL` and fails each task's first N acquisitions.
It runs on a local instance; no sandbox provider is needed. One fault per wave.
| Fault | Shape | Pass |
| --- | --- | --- |
| EP1 | Deadline, 2 times (the automatic bound), then succeeds | Exactly 2 `environment_provider_unavailable` runs, no `setup_failed`, completes with no user action |
| EP1N | Deadline on every attempt | 3 failed runs, the task rests offering Retry; the harness disarms, clicks Retry, and the task completes |
| EP1D | Database error once, then succeeds | 1 failed run, completes with no user action |
Every fault also fails if a board-owned recovery action is ever active on the
task, and the A1 brief oracle must pass. Results go to `results/<wave>-ep/`.
## Config faults (CF1–CF3, `config-wave.ts`)
```sh
@@ -0,0 +1,49 @@
import { describe, expect, it } from "vitest";
import { BOUNDED_TRANSIENT_HEARTBEAT_RETRY_MAX_ATTEMPTS } from "../../server/src/services/heartbeat/retries.js";
import { parseLeaseFaultControl } from "../../server/src/services/environment-lease-fault-injection.js";
import { AUTOMATIC_RETRY_BOUND, ENV_PROVIDER_FAULTS, gradeEnvProviderTask, leaseFaultControl, type EpTaskRecord } from "./env-provider-faults.js";
const base = (overrides: Partial<EpTaskRecord>): EpTaskRecord => ({
taskId: "t01", nonce: "n", fault: "EP1", issueId: "issue", harnessError: null, runs: [], activeRecoveryActions: [],
recoveryReads: 3, recoveryReadFailures: 0, rested: null, retryClicked: false, finalStatus: "done", briefOk: true, ...overrides,
});
const failedRun = (id: string, errorCode = "environment_provider_unavailable") => ({ id, status: "failed", errorCode });
describe("EP1 environment-provider faults", () => {
it("tracks the server's automatic retry bound", () => {
expect(AUTOMATIC_RETRY_BOUND).toBe(BOUNDED_TRANSIENT_HEARTBEAT_RETRY_MAX_ATTEMPTS);
});
it("writes a control the server seam accepts", () => {
for (const fault of Object.values(ENV_PROVIDER_FAULTS)) {
expect(parseLeaseFaultControl(leaseFaultControl(fault, "wave-1"))).toEqual({ token: "wave-1", kind: fault.shape, failures: fault.failures });
}
});
it("passes a task that retried within the bound and completed by itself", () => {
const grade = gradeEnvProviderTask(base({ runs: [failedRun("a"), failedRun("b"), { id: "c", status: "succeeded", errorCode: null }] }));
expect(grade.pass).toBe(true);
});
it("fails the staging shape: a generic setup failure stranded on a board action", () => {
const grade = gradeEnvProviderTask(base({ runs: [failedRun("a", "setup_failed")], finalStatus: "blocked", briefOk: false,
activeRecoveryActions: [{ ownerType: "board", kind: "stranded_assigned_issue", cause: "stranded_assigned_issue" }] }));
expect(grade.checks.filter(check => !check.pass).map(check => check.name))
.toEqual(["bounded_retry", "no_board_recovery_action", "completes_without_user", "brief_oracle"]);
});
it("fails when recovery actions could not be read", () => {
const runs = [failedRun("a"), failedRun("b"), { id: "c", status: "succeeded", errorCode: null }];
expect(gradeEnvProviderTask(base({ runs, recoveryReadFailures: 1 })).checks.find(check => check.name === "recovery_evidence_read")?.pass).toBe(false);
expect(gradeEnvProviderTask(base({ runs, recoveryReads: 0 })).pass).toBe(false);
});
it("requires a never-clearing fault to rest on Retry and finish after it", () => {
const runs = [failedRun("a"), failedRun("b"), failedRun("c"), { id: "d", status: "succeeded", errorCode: null }];
expect(gradeEnvProviderTask(base({ fault: "EP1N", runs, rested: { issueStatus: "blocked", canRetry: true, observedRunId: "c" },
retryClicked: true, activeRecoveryActions: [{ ownerType: "system", kind: "stranded_assigned_issue", cause: "environment_provider_unavailable" }] })).pass)
.toBe(true);
expect(gradeEnvProviderTask(base({ fault: "EP1N", runs, rested: { issueStatus: "blocked", canRetry: false, observedRunId: null } })).pass)
.toBe(false);
});
});
+106
View File
@@ -0,0 +1,106 @@
/**
* Environment-provider fault family (EP1): pure catalog, control format and oracle.
*
* Staging (2026-11-11, 14:00-14:45Z) had five Daytona lease acquisitions time
* out ("Daytona lease acquisition timed out; allocation cleanup is pending")
* and one fail on a database query, all before any provider work. None got a
* bounded retry, and two tasks were left blocked on a board-owned
* `stranded_assigned_issue` recovery action. Every other harness fault hits
* the control plane, storage or the provider stream; nothing failed lease
* acquisition itself.
*
* The server seam is `server/src/services/environment-lease-fault-injection.ts`
* (test mode only): it throws the staging error shapes for each task's first
* N acquisitions, before any provider call, so a local wave exercises the
* same retry and Retry paths a Daytona outage does.
*
* Oracle, per task:
* 1. bounded retry: exactly the expected number of runs fail with
* `environment_provider_unavailable`, never a generic `setup_failed`;
* 2. no board-owned recovery action is ever active on the task;
* 3. EP1/EP1D: the task completes with no user action;
* EP1N (never clears): the task rests offering Retry, and after the
* harness disarms the fault and clicks Retry the task completes;
* 4. the A1 brief oracle passes.
*
* I/O lives in env-provider-wave.ts; this file is credential-free and unit tested.
*/
export const ENV_PROVIDER_FAULT_KINDS = ["EP1", "EP1N", "EP1D"] as const;
export type EnvProviderFaultKind = (typeof ENV_PROVIDER_FAULT_KINDS)[number];
/** BOUNDED_TRANSIENT_HEARTBEAT_RETRY_MAX_ATTEMPTS in server/src/services/heartbeat/retries.ts. */
export const AUTOMATIC_RETRY_BOUND = 2;
export interface EnvProviderFault {
kind: EnvProviderFaultKind;
label: string;
/** The server seam's error shape. */
shape: "timeout" | "database";
/** Failures per task; null never clears until the harness disarms it. */
failures: number | null;
}
export const ENV_PROVIDER_FAULTS: Record<EnvProviderFaultKind, EnvProviderFault> = {
EP1: { kind: "EP1", label: "Lease acquisition times out N times, then succeeds", shape: "timeout", failures: AUTOMATIC_RETRY_BOUND },
EP1N: { kind: "EP1N", label: "Lease acquisition times out on every attempt", shape: "timeout", failures: null },
EP1D: { kind: "EP1D", label: "A database query fails during lease acquisition once", shape: "database", failures: 1 },
};
/** The control file the seam reads (PAPERCLIP_CHAOS_LEASE_FAULT_CONTROL). An empty file disarms. */
export function leaseFaultControl(fault: EnvProviderFault, token: string): string {
return JSON.stringify({ token, kind: fault.shape, failures: fault.failures });
}
/** Runs of the task in creation order, as `/api/issues/:id/runs` reports them. */
export interface EpRunSample { id: string; status: string; errorCode: string | null }
export interface EpTaskRecord {
taskId: string;
nonce: string;
fault: EnvProviderFaultKind;
issueId: string | null;
harnessError: string | null;
runs: EpRunSample[];
/** Every recovery action seen active on the task at any poll. */
activeRecoveryActions: Array<{ ownerType: string; kind: string; cause: string | null }>;
/** Polls that read the task's recovery actions, and polls where that read failed. */
recoveryReads: number;
recoveryReadFailures: number;
/** EP1N: the resting state before Retry. */
rested: { issueStatus: string; canRetry: boolean; observedRunId: string | null } | null;
retryClicked: boolean;
finalStatus: string | null;
briefOk: boolean | null;
}
export interface EpCheck { name: string; pass: boolean; detail: string }
export interface EpGrade { pass: boolean; checks: EpCheck[] }
export function gradeEnvProviderTask(record: EpTaskRecord): EpGrade {
const fault = ENV_PROVIDER_FAULTS[record.fault];
const failed = record.runs.filter(run => run.errorCode === "environment_provider_unavailable").length;
const generic = record.runs.filter(run => run.errorCode === "setup_failed").length;
const expected = fault.failures ?? AUTOMATIC_RETRY_BOUND + 1;
const board = record.activeRecoveryActions.filter(action => action.ownerType === "board");
const checks: EpCheck[] = [
{ name: "no_harness_error", pass: record.harnessError === null, detail: record.harnessError ?? "ok" },
{ name: "bounded_retry", pass: failed === expected && generic === 0,
detail: `${failed} environment_provider_unavailable run(s), expected ${expected}; ${generic} setup_failed` },
{ name: "recovery_evidence_read", pass: record.recoveryReads > 0 && record.recoveryReadFailures === 0,
detail: `${record.recoveryReads} read(s), ${record.recoveryReadFailures} failed` },
{ name: "no_board_recovery_action", pass: board.length === 0,
detail: board.length ? board.map(action => `${action.kind}/${action.cause ?? "-"}`).join(", ") : "none" },
];
if (fault.failures === null) {
checks.push({ name: "rests_with_retry", pass: record.rested !== null && record.rested.canRetry,
detail: record.rested ? `${record.rested.issueStatus}, canRetry=${String(record.rested.canRetry)}` : "never rested" });
checks.push({ name: "retry_completes", pass: record.retryClicked && record.finalStatus === "done",
detail: `retry ${record.retryClicked ? "clicked" : "not clicked"}, final ${record.finalStatus ?? "unknown"}` });
} else {
checks.push({ name: "completes_without_user", pass: !record.retryClicked && record.finalStatus === "done",
detail: `final ${record.finalStatus ?? "unknown"}` });
}
checks.push({ name: "brief_oracle", pass: record.briefOk === true, detail: record.briefOk === null ? "not read" : String(record.briefOk) });
return { pass: checks.every(check => check.pass), checks };
}
+205
View File
@@ -0,0 +1,205 @@
/**
* Environment-provider fault waves (EP1). See env-provider-faults.ts for the
* oracle and README.md ("Environment-provider faults") for usage.
*
* node cli/node_modules/tsx/dist/cli.mjs tests/restart-chaos/env-provider-wave.ts \
* --fault EP1 --adapters codex --tasks 2 --seed 71
*
* One fault per wave: the server seam reads one control file, and every task's
* own first acquisitions fail. The harness launches A1 brief tasks, watches
* their runs and recovery actions, and for EP1N disarms the fault once every
* task rests and then uses the task's Retry.
*/
import { spawnSync } from "node:child_process";
import { randomUUID } from "node:crypto";
import { existsSync } from "node:fs";
import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { parseArgs } from "node:util";
import { setTimeout as delay } from "node:timers/promises";
import { releaseInstance } from "../runner-e2e/instance-retention.js";
import { ChaosApi } from "./api.js";
import { CREDENTIAL_FOR_ADAPTER, waveIdFor } from "./config.js";
import {
ENV_PROVIDER_FAULT_KINDS, ENV_PROVIDER_FAULTS, gradeEnvProviderTask, leaseFaultControl,
type EnvProviderFaultKind, type EpGrade, type EpTaskRecord,
} from "./env-provider-faults.js";
import { knownSecretValues, redactKnownSecrets } from "./evidence.js";
import { seedChaosLab, type ChaosLab } from "./seed.js";
import { ChaosSupervisor } from "./supervisor.js";
import { Timeline } from "./timeline.js";
import { ADAPTER_IDS, type AdapterId } from "./types.js";
import { workloadDefinitions } from "./workloads.js";
const repositoryRoot = path.resolve(import.meta.dirname, "../..");
const resultsRoot = path.resolve(process.env.RESTART_CHAOS_RESULTS_DIR ?? path.join(import.meta.dirname, "results"));
const POLL_MS = 4_000;
type Json = Record<string, any>;
interface WaveOptions { fault: EnvProviderFaultKind; adapters: AdapterId[]; tasks: number; seed: number; restBoundMs: number; doneBoundMs: number }
export function parseEnvProviderWaveArgs(argv: readonly string[]): { options: WaveOptions; keep: boolean } {
const { values } = parseArgs({ args: argv[0] === "--" ? argv.slice(1) : [...argv], strict: true, options: {
fault: { type: "string" }, adapters: { type: "string" }, tasks: { type: "string" }, seed: { type: "string" },
"rest-seconds": { type: "string" }, "done-seconds": { type: "string" }, keep: { type: "boolean" },
} });
const fault = (values.fault ?? "EP1") as EnvProviderFaultKind;
if (!ENV_PROVIDER_FAULT_KINDS.includes(fault)) throw new Error(`--fault must be one of ${ENV_PROVIDER_FAULT_KINDS.join(", ")}`);
const adapters = (values.adapters ?? "codex").split(",").map(item => item.trim()).filter(Boolean) as AdapterId[];
for (const adapter of adapters) if (!ADAPTER_IDS.includes(adapter)) throw new Error(`Unknown adapter "${adapter}"`);
const int = (raw: string | undefined, name: string, fallback: number, min: number, max: number) => {
if (raw === undefined) return fallback;
const value = Number(raw);
if (!Number.isSafeInteger(value) || value < min || value > max) throw new Error(`--${name} must be an integer between ${min} and ${max}`);
return value;
};
return { keep: values.keep === true, options: { fault, adapters, tasks: int(values.tasks, "tasks", 2, 1, 6),
seed: int(values.seed, "seed", Date.now() % 100_000, 0, 2 ** 31 - 1),
// Two automatic retries back off about 30 s and 60 s (with jitter) after each failure.
restBoundMs: int(values["rest-seconds"], "rest-seconds", 420, 120, 1_800) * 1_000,
doneBoundMs: int(values["done-seconds"], "done-seconds", 900, 120, 3_600) * 1_000 } };
}
async function sample(api: ChaosApi, record: EpTaskRecord): Promise<{ issue: Json; interruption: Json | null } | null> {
try {
const [issue, runs, recovery] = await Promise.all([
api.get<Json>(`/api/issues/${record.issueId}`, 5_000),
api.get<Json[]>(`/api/issues/${record.issueId}/runs`, 5_000),
// A failed read is recorded, never taken as "no actions": the grade needs this evidence.
api.get<Json>(`/api/issues/${record.issueId}/recovery-actions`, 5_000).catch(() => null),
]);
if (recovery && Array.isArray(recovery.actions)) record.recoveryReads += 1;
else record.recoveryReadFailures += 1;
const known = new Map(record.runs.map(run => [run.id, run]));
for (const run of runs ?? []) {
const id = String(run.runId ?? run.id);
known.set(id, { id, status: String(run.status), errorCode: run.errorCode ?? null });
}
record.runs = [...known.values()];
for (const action of (recovery?.actions ?? []) as Json[]) {
if (!["active", "escalated"].includes(String(action.status))) continue;
const seen = { ownerType: String(action.ownerType), kind: String(action.kind), cause: action.cause ?? null };
if (!record.activeRecoveryActions.some(prior => prior.ownerType === seen.ownerType && prior.kind === seen.kind && prior.cause === seen.cause)) {
record.activeRecoveryActions.push(seen);
}
}
return { issue, interruption: (issue.interruption ?? null) as Json | null };
} catch {
return null;
}
}
async function waitFor(api: ChaosApi, record: EpTaskRecord, boundMs: number, done: (state: { issue: Json; interruption: Json | null }) => boolean) {
const deadline = Date.now() + boundMs;
while (Date.now() < deadline) {
const state = await sample(api, record);
if (state) {
record.finalStatus = String(state.issue.status);
if (done(state)) return state;
}
await delay(POLL_MS);
}
return null;
}
async function runWave(options: WaveOptions, waveDir: string, timeline: Timeline, keep: boolean): Promise<Array<{ record: EpTaskRecord; grade: EpGrade }>> {
const fault = ENV_PROVIDER_FAULTS[options.fault];
const supervisor = await ChaosSupervisor.create({ repositoryRoot, waveDir, timeline });
const control = path.join(supervisor.paths.temporaryRoot, "lease-fault-control.json");
await writeFile(control, leaseFaultControl(fault, `wave${options.seed}`), { mode: 0o600 });
let stopFailed = false;
const records: EpTaskRecord[] = [];
try {
await supervisor.start({ PAPERCLIP_CHAOS_LEASE_FAULT_CONTROL: control });
const api = new ChaosApi(supervisor.baseUrl);
const lab: ChaosLab = await seedChaosLab({ api, env: "local", waitForReadyAgents: true, adapters: [...new Set(options.adapters)],
concurrency: options.tasks, budgetCents: 3_000, fixtureRoot: path.join(supervisor.paths.temporaryRoot, "fixtures"),
waveId: path.basename(waveDir), timeline });
const a1 = workloadDefinitions.A1;
for (let index = 0; index < options.tasks; index += 1) {
const adapter = options.adapters[index % options.adapters.length]!;
const nonce = randomUUID().slice(0, 8);
const record: EpTaskRecord = { taskId: `t${String(index + 1).padStart(2, "0")}`, nonce, fault: options.fault, issueId: null, harnessError: null,
runs: [], activeRecoveryActions: [], recoveryReads: 0, recoveryReadFailures: 0, rested: null, retryClicked: false, finalStatus: null, briefOk: null };
records.push(record);
try {
const issue = await api.post<Json>(`/api/companies/${lab.companyId}/issues`, { title: `Chaos ${options.fault} ${nonce}`,
description: a1.description(nonce), status: "todo", priority: "medium", assigneeAgentId: lab.agents[adapter].id, projectId: lab.projectId });
record.issueId = String(issue.id);
timeline.add("ep_launched", { taskId: record.taskId, issueId: record.issueId, adapter });
} catch (error) {
record.harnessError = `launch: ${error instanceof Error ? error.message : String(error)}`.slice(0, 600);
}
}
const live = records.filter(record => record.issueId);
if (fault.failures === null) {
// Never clears: each task must rest offering Retry within the bound.
await Promise.all(live.map(async record => {
const rested = await waitFor(api, record, options.restBoundMs, state => state.interruption?.canRetry === true
&& ["blocked", "todo", "in_progress"].includes(String(state.issue.status))
&& !record.runs.some(run => ["queued", "running", "scheduled_retry"].includes(run.status)));
record.rested = rested ? { issueStatus: String(rested.issue.status), canRetry: rested.interruption?.canRetry === true,
observedRunId: rested.interruption?.observedRunId ?? null } : null;
timeline.add("ep_rested", { taskId: record.taskId, rested: record.rested });
}));
await writeFile(control, "", { mode: 0o600 });
timeline.add("ep_disarmed", {});
for (const record of live) {
if (!record.rested?.observedRunId) continue;
const response = await api.post<Json>(`/api/issues/${record.issueId}/retry`, { idempotencyKey: randomUUID(), observedRunId: record.rested.observedRunId });
record.retryClicked = response?.accepted === true;
timeline.add("ep_retry_clicked", { taskId: record.taskId, accepted: response?.accepted ?? null });
}
}
await Promise.all(live.map(record => waitFor(api, record, options.doneBoundMs, state => state.issue.status === "done")));
for (const record of live) {
const brief = await api.get<Json>(`/api/issues/${record.issueId}/documents/brief`).then(row => typeof row?.body === "string" ? row.body : null).catch(() => null);
record.briefOk = a1.grade(record.nonce, { issueStatus: record.finalStatus, documents: { brief }, agentComments: [], files: {}, commands: {}, workspacePath: null }).verdict === "pass";
}
} finally {
try { await supervisor.stop(); } catch { stopFailed = true; }
await releaseInstance(supervisor.paths.temporaryRoot, { keep, stopFailed, owner: path.basename(waveDir) })
.catch(error => timeline.add("instance_cleanup_failed", { error: String(error) }));
}
return records.map(record => ({ record, grade: gradeEnvProviderTask(record) }));
}
export function envProviderSummaryMarkdown(waveId: string, gitSha: string, fault: EnvProviderFaultKind, rows: Array<{ record: EpTaskRecord; grade: EpGrade }>): string {
const lines = [`# Environment-provider fault wave ${waveId}`, "", `Commit ${gitSha}`, "", `Fault ${fault}: ${ENV_PROVIDER_FAULTS[fault].label}`, "",
"| Task | Runs (errorCode) | Final | Pass |", "| --- | --- | --- | --- |"];
for (const { record, grade } of rows) {
lines.push(`| ${record.taskId} | ${record.runs.map(run => `${run.status}${run.errorCode ? `/${run.errorCode}` : ""}`).join(", ")} | ${record.finalStatus ?? "?"} | ${grade.pass ? "pass" : "FAIL"} |`);
}
lines.push("", "## Checks", "");
for (const { record, grade } of rows) {
lines.push(`### ${record.taskId}`, "", ...grade.checks.map(check => `- ${check.pass ? "pass" : "FAIL"} ${check.name}: ${check.detail}`), "");
}
return lines.join("\n");
}
async function main(): Promise<number> {
const { options, keep } = parseEnvProviderWaveArgs(process.argv.slice(2));
const missing = [...new Set(options.adapters.map(adapter => CREDENTIAL_FOR_ADAPTER[adapter]))].filter(key => !process.env[key]);
if (missing.length) throw new Error(`Missing credentials ${missing.join(", ")}. Run: set -a; source ~/.secrets; set +a`);
const runner = process.env.PAPERCLIP_RUNNER_BINARY ?? path.join(repositoryRoot, "packages/paperclip-runner/runner/target/debug/paperclip-runnerd");
if (!existsSync(runner)) throw new Error(`Runner binary missing at ${runner}. Run: node scripts/agent-preflight.mjs`);
const waveId = `${waveIdFor(options.seed)}-ep`;
const waveDir = path.join(resultsRoot, waveId);
await mkdir(waveDir, { recursive: true, mode: 0o700 });
const timeline = new Timeline(path.join(waveDir, "timeline.jsonl"));
const gitSha = spawnSync("git", ["rev-parse", "HEAD"], { cwd: repositoryRoot, encoding: "utf8" }).stdout.trim();
timeline.add("ep_wave_planned", { waveId, gitSha, options });
const rows = await runWave(options, waveDir, timeline, keep);
for (const row of rows) {
await writeFile(path.join(waveDir, `${row.record.taskId}.ep-grade.json`),
`${redactKnownSecrets(JSON.stringify(row, null, 2), knownSecretValues())}\n`, { mode: 0o600 });
}
const summary = envProviderSummaryMarkdown(waveId, gitSha, options.fault, rows);
await writeFile(path.join(waveDir, "SUMMARY.md"), summary, { mode: 0o600 });
console.info(`\n${summary}\nWave ${waveId}: results ${waveDir}`);
return rows.length === options.tasks && rows.every(row => row.grade.pass) ? 0 : 1;
}
if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(import.meta.filename)) {
process.exit(await main().catch(error => { console.error(error); return 2; }));
}