mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-15 01:52:55 +01:00
## Thinking Path
> - Paperclip is the open source app people use to manage AI agents for
work
> - After a native turn in a remote sandbox, closing the session saves
the provider session (the harness backup) to object storage. The next
attempt resumes from it.
> - On staging, object storage throttled that save ("Please reduce your
request rate", S3 `SlowDown`). The close failed, was retried 3 times 1 s
apart, and the session was quarantined. The next attempt of the same run
was refused with `native_session_cleanup_quarantined`.
> - The retries could never succeed: the Runner transport memoizes
`close()`, so every retry returned the first rejected promise and never
ran the checkpoint again. Quarantine recovery and admission recovery
call the same `close()`.
> - Also, the backup upload did not wait out a throttle or outage longer
than one request's own retries.
> - This pull request makes a close that failed only in its checkpoint
run that checkpoint again on the next `close()`, and makes the backup
upload wait out storage unavailability inside the existing bounded
storage wait.
> - The benefit is that a transient storage throttle no longer ends in a
quarantine that refuses the next attempt of the same run.
## Linked Issues or Issue Description
Refs #20772 (made the backup upload resendable after `SlowDown`). Refs
#20165 (per-request throttle backoff). Refs #20833 (earlier steps of the
same failure chain).
**What happened?**
1. A staging Codex run on Daytona finished its turn. The post-turn close
ran the remote checkpoint.
2. The checkpoint failed: `remote checkpoint failed: Please reduce your
request rate.`
3. The Runner retried the close 3 times 1 s apart (`native session
cleanup recovery after close failure (1..3)`), then 3 quarantine
recoveries. All failed within about 5 s.
4. The next attempt of the same run was refused:
`native_session_cleanup_quarantined: prior session cleanup remains
incomplete`.
**Expected behavior**
A throttle slows the save down. A retry of the close saves the session
again. The next attempt of the same run proceeds once the save succeeds.
**Steps to reproduce**
1. Make a transport close whose checkpoint throws once.
2. Call `close()` again.
3. On master the second call returns the first failure without running
the checkpoint again.
4. Separately, answer each harness-backup `PUT` with 9 `503 SlowDown`
responses. On master `persistNativeHarnessBackup` throws `SlowDown:
Please reduce your request rate.`
**Paperclip version or commit**
master at 381b862135.
## What Changed
- `packages/paperclip-runner/src/live/runnerd-codex-transport.ts`: when
a close fails because its checkpoint failed (and the control-route
release did not fail), the transport keeps the checkpoint and the rest
of the close. The next `close()` runs the checkpoint again and then
finishes the close. All other close failures (for example a missing
suspension proof) stay memoized as before.
- `server/src/services/native-runtime/native-harness-backup-storage.ts`:
`persistNativeHarnessBackup` runs inside the existing storage outage
wait (`waitOutStorageOutages`, purpose "saving the provider session"). A
storage-unavailable failure (outage, throttle, rejected credentials)
that outlasts one request's retries is retried with the bounded
exponential storage wait (2 s doubling to 30 s, 5 minutes in total).
Each request still uses the jittered per-request throttle backoff from
#20165.
- Restart chaos: storage fault `ST8` answers the next 9 to 12 (seeded)
harness-backup `PUT`s with `503 SlowDown`. The storage audit reports it
when it never changed a request. The README documents it (for `--env
daytona --storage s3`).
## Verification
- RED on master (fix reverted, new tests kept):
- `runnerd-codex-transport-suspension.test.ts` "runs a failed close
checkpoint again on the next close": the second `close()` returns the
first `SlowDown` error.
- `native-harness-backup-throttle.integration.test.ts` "waits out a
throttle that outlasts one request's backoff": `SlowDown: Please reduce
your request rate.`
- GREEN: both pass. `native-harness-backup-throttle.integration.test.ts`
3 passed. `runnerd-codex-transport-suspension.test.ts` 12 passed.
`runnerd-codex-transport-release-resume.test.ts` 22 passed (twice).
`vitest --config tests/restart-chaos/vitest.config.ts`: 46 files, 554
tests passed, including the new ST8 planning and proxy test.
- `tsc --noEmit` clean for `packages/paperclip-runner`, `server` and
`tests/restart-chaos`.
- `runnerd-codex-transport-settlement.test.ts` has one case that also
fails on unchanged master in this worktree.
## Risks
- A checkpoint retry runs the same server checkpoint callback again
after the runner process was stopped. It reads the settled state from
the sandbox. If the sandbox is gone, it fails as before and the
quarantine stays.
- A close attempt can now take up to the 5-minute storage wait during a
long outage. This is on the post-turn path only, not before
`turn.start`.
- No change to the quarantine admission rules: admission still
accelerates one bounded recovery batch and refuses if it fails. Those
recoveries now really retry the checkpoint.
## Model Used
- Claude Opus 5.5 (`claude-opus-5-5`), Claude Code agent 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>
162 lines
8.9 KiB
TypeScript
162 lines
8.9 KiB
TypeScript
import { Agent, createServer, request, type IncomingMessage, type ServerResponse } from "node:http";
|
|
import { once } from "node:events";
|
|
import type { Socket } from "node:net";
|
|
import { linkRates, paced, releaseLinks, type LinkRates } from "./storage-bandwidth.js";
|
|
import { ReadAfterWriteTracker } from "./storage-lag.js";
|
|
import { partNumberOf, s3Operation, type S3Operation } from "./storage-s3-ops.js";
|
|
|
|
export type StorageFault =
|
|
| { kind: "pass" }
|
|
| { kind: "outage"; status?: number }
|
|
| { kind: "latency"; ms: number; jitterMs?: number; bytesPerSecond?: number }
|
|
| { kind: "errors"; rate: number; status?: number; slowDown?: boolean }
|
|
| { kind: "reset" }
|
|
| { kind: "expired_credentials"; code?: CredentialErrorCode }
|
|
| { kind: "credential_rotation" }
|
|
| { kind: "truncate"; afterBytes: number }
|
|
| { kind: "read_after_write_lag"; lagMs: number }
|
|
| { kind: "throttle" };
|
|
|
|
export const CREDENTIAL_ERROR_CODES = ["ExpiredToken", "InvalidAccessKeyId", "SignatureDoesNotMatch"] as const;
|
|
export type CredentialErrorCode = typeof CREDENTIAL_ERROR_CODES[number];
|
|
const CREDENTIAL_ERROR_MESSAGES: Record<CredentialErrorCode, string> = {
|
|
ExpiredToken: "The provided token has expired.",
|
|
InvalidAccessKeyId: "The Access Key Id you provided does not exist in our records.",
|
|
SignatureDoesNotMatch: "The request signature we calculated does not match the signature you provided.",
|
|
};
|
|
|
|
export interface StorageFaultRule { fault: StorageFault; methods?: readonly string[]; pathIncludes?: string; remaining?: number;
|
|
operations?: readonly S3Operation[]; partNumbers?: readonly number[]; firstObjectOnly?: boolean; pinnedObject?: string }
|
|
|
|
export type StorageRequestListener = (incoming: IncomingMessage, outgoing: ServerResponse) => number | void;
|
|
|
|
export interface StorageProxyCounts { requests: number; injected: number; byMethod: Record<string, number>; byStatus: Record<string, number>;
|
|
injectedBy?: Record<string, number> }
|
|
|
|
const XML = "application/xml";
|
|
function s3Error(code: string, message: string) {
|
|
return `<?xml version="1.0" encoding="UTF-8"?><Error><Code>${code}</Code><Message>${message}</Message></Error>`;
|
|
}
|
|
|
|
export function faultResponse(fault: StorageFault): { status: number; body: string } | "reset" | null {
|
|
switch (fault.kind) {
|
|
case "outage": return { status: fault.status ?? 503, body: s3Error("ServiceUnavailable", "Injected storage outage") };
|
|
case "expired_credentials": {
|
|
const code = fault.code ?? "ExpiredToken";
|
|
return { status: 403, body: s3Error(code, CREDENTIAL_ERROR_MESSAGES[code]) };
|
|
}
|
|
case "reset": return "reset";
|
|
default: return null;
|
|
}
|
|
}
|
|
|
|
export function matches(rule: StorageFaultRule, method: string, url: string): boolean {
|
|
if (rule.remaining !== undefined && rule.remaining <= 0) return false;
|
|
if (rule.methods && !rule.methods.includes(method)) return false;
|
|
if (rule.operations && !rule.operations.includes(s3Operation(method, url))) return false;
|
|
if (rule.partNumbers && !rule.partNumbers.includes(partNumberOf(url) ?? 0)) return false;
|
|
if (rule.pinnedObject !== undefined && url.split("?")[0] !== rule.pinnedObject) return false;
|
|
return !rule.pathIncludes || url.includes(rule.pathIncludes);
|
|
}
|
|
|
|
export async function startStorageFaultProxy(endpoint: string, random: () => number = Math.random,
|
|
writes: ReadAfterWriteTracker = new ReadAfterWriteTracker()) {
|
|
const target = new URL(endpoint);
|
|
if (target.protocol !== "http:" || !["127.0.0.1", "localhost", "[::1]"].includes(target.hostname)) {
|
|
throw new Error("Storage fault injection requires a loopback store");
|
|
}
|
|
let rules: StorageFaultRule[] = [];
|
|
let listener: StorageRequestListener | null = null;
|
|
const counts: StorageProxyCounts = { requests: 0, injected: 0, byMethod: {}, byStatus: {}, injectedBy: {} };
|
|
const sockets = new Set<Socket>();
|
|
const delayed = new Set<ReturnType<typeof setTimeout>>();
|
|
const agent = new Agent({ keepAlive: true });
|
|
const links = new Map<StorageFaultRule, LinkRates>();
|
|
const endLinks = () => { releaseLinks(links.values()); links.clear(); };
|
|
const note = (method: string, status: number | "reset", injected: boolean, cause?: string) => {
|
|
counts.requests += 1;
|
|
counts.byMethod[method] = (counts.byMethod[method] ?? 0) + 1;
|
|
counts.byStatus[String(status)] = (counts.byStatus[String(status)] ?? 0) + 1;
|
|
if (injected) counts.injected += 1;
|
|
if (cause) counts.injectedBy![cause] = (counts.injectedBy![cause] ?? 0) + 1;
|
|
};
|
|
const forward = (incoming: IncomingMessage, outgoing: ServerResponse, link?: LinkRates) => {
|
|
const upstream = request(target, { method: incoming.method, path: incoming.url, headers: incoming.headers, agent }, response => {
|
|
response.on("error", () => outgoing.destroy());
|
|
note(incoming.method ?? "GET", response.statusCode ?? 502, false);
|
|
writes.note(incoming.method ?? "GET", incoming.url ?? "/", response.statusCode ?? 502);
|
|
outgoing.writeHead(response.statusCode ?? 502, response.headers);
|
|
(link ? response.pipe(paced(link.down)) : response).pipe(outgoing);
|
|
});
|
|
upstream.on("error", () => {
|
|
if (outgoing.destroyed) return;
|
|
if (outgoing.headersSent) { outgoing.destroy(); return; }
|
|
note(incoming.method ?? "GET", 502, false);
|
|
outgoing.writeHead(502).end();
|
|
});
|
|
incoming.on("aborted", () => upstream.destroy());
|
|
outgoing.on("close", () => { if (!outgoing.writableFinished) upstream.destroy(); });
|
|
(link ? incoming.pipe(paced(link.up)) : incoming).pipe(upstream);
|
|
};
|
|
const server = createServer((incoming, outgoing) => {
|
|
const method = incoming.method ?? "GET";
|
|
const heldMs = listener?.(incoming, outgoing) || 0;
|
|
const rule = heldMs ? undefined : rules.find(candidate => matches(candidate, method, incoming.url ?? "/"));
|
|
if (rule?.remaining !== undefined) rule.remaining -= 1;
|
|
if (rule?.firstObjectOnly && rule.pinnedObject === undefined) rule.pinnedObject = (incoming.url ?? "/").split("?")[0];
|
|
const fault: StorageFault = heldMs ? { kind: "latency", ms: heldMs } : rule?.fault ?? { kind: "pass" };
|
|
if (fault.kind === "latency") {
|
|
const delay = fault.ms + Math.round((fault.jitterMs ?? 0) * random());
|
|
if (fault.bytesPerSecond && !links.has(rule!)) links.set(rule!, linkRates(fault.bytesPerSecond));
|
|
incoming.pause();
|
|
const timer = setTimeout(() => {
|
|
delayed.delete(timer);
|
|
if (outgoing.destroyed || incoming.destroyed) return;
|
|
incoming.resume();
|
|
forward(incoming, outgoing, links.get(rule!));
|
|
}, delay);
|
|
delayed.add(timer);
|
|
outgoing.on("close", () => { clearTimeout(timer); delayed.delete(timer); });
|
|
return;
|
|
}
|
|
if (fault.kind === "errors" && random() >= fault.rate) { forward(incoming, outgoing); return; }
|
|
if (fault.kind === "read_after_write_lag" && !writes.lagging(incoming.url ?? "/", fault.lagMs)) { forward(incoming, outgoing); return; }
|
|
const cause = `${s3Operation(method, incoming.url ?? "/")}:${fault.kind}`;
|
|
if (fault.kind === "truncate") {
|
|
let seen = 0;
|
|
const cut = () => { if (incoming.socket.destroyed) return; note(method, "reset", true, cause); incoming.socket.destroy(); };
|
|
incoming.on("data", (chunk: Buffer) => { seen += chunk.length; if (seen >= fault.afterBytes) cut(); });
|
|
forward(incoming, outgoing);
|
|
return;
|
|
}
|
|
const injected = fault.kind === "throttle" ? { status: 503, body: s3Error("SlowDown", "Please reduce your request rate.") }
|
|
: fault.kind === "errors"
|
|
? { status: fault.status ?? 503, body: fault.slowDown ? s3Error("SlowDown", "Please reduce your request rate.") : s3Error("InternalError", "Injected storage error") }
|
|
: fault.kind === "read_after_write_lag" ? { status: 404, body: s3Error("NoSuchKey", "The specified key does not exist.") }
|
|
: faultResponse(fault);
|
|
if (!injected) { forward(incoming, outgoing); return; }
|
|
incoming.resume();
|
|
if (injected === "reset") { note(method, "reset", true, cause); incoming.socket.destroy(); return; }
|
|
note(method, injected.status, true, cause);
|
|
outgoing.writeHead(injected.status, { "content-type": XML, "content-length": Buffer.byteLength(injected.body) }).end(injected.body);
|
|
});
|
|
server.on("connection", socket => { sockets.add(socket); socket.on("close", () => sockets.delete(socket)); });
|
|
server.listen(0, "127.0.0.1"); await once(server, "listening");
|
|
const address = server.address();
|
|
if (!address || typeof address === "string") throw new Error("Storage proxy did not bind");
|
|
return {
|
|
endpoint: `http://127.0.0.1:${address.port}`,
|
|
counts: () => ({ ...structuredClone(counts), lagged: writes.lagged }),
|
|
setRules(next: readonly StorageFaultRule[]) { endLinks(); rules = next.map(rule => ({ ...rule })); },
|
|
clear() { endLinks(); rules = []; },
|
|
observe(next: StorageRequestListener | null) { listener = next; },
|
|
async close() {
|
|
for (const timer of delayed) clearTimeout(timer);
|
|
delayed.clear();
|
|
const closed = once(server, "close"); server.close(); agent.destroy();
|
|
for (const socket of sockets) socket.destroy();
|
|
await closed;
|
|
},
|
|
};
|
|
}
|