Files
PaperClipAI/server/src/__tests__/environment-sync-out-error.test.ts
T
Devin FoleyandPaperclip 1c4ce44e13 fix: retain bounded workspace transfer failure evidence (#15637)
## Thinking Path

> - Paperclip manages AI agents and the work they produce.
> - Sandbox work must return to the host when an agent run ends.
> - A failed transfer must keep its source available for recovery.
> - Some transfer errors lose their useful fields before restore
diagnostics reach Sentry.
> - This change records fixed transfer stages, failure kinds, and
numeric RPC codes.
> - The next failure can identify the operation that failed without
exposing workspace contents.

## Linked Issues or Issue Description

**What happened?**

A workspace transfer can fail after model execution succeeds. Several
command, archive validation, and download errors become plain errors.
The existing RPC envelope then reports `unknown`, with no transfer stage
or command exit code. A host RPC timeout also loses its code from
restore diagnostics.

**Expected behavior**

Keep bounded evidence through the provider, worker RPC, restore result,
and Sentry context. Keep the same exception, error message, retry rules,
and source retention policy. A transfer timeout must remain separate
from the model execution timeout flag.

**Steps to reproduce**

Return a nonzero exit code from the sandbox archive command, return a
per-file download error, exceed a tar listing limit, or time out the
sync-out RPC. Observe the missing fields in the saved restore
diagnostic. The added tests use local fixtures for these cases.

Related public work: #15479 preserves the source after restore failure.
#15481 carries bounded sync-out diagnostics through RPC. This change
supplies missing producer evidence and extends that same envelope. I
checked the roadmap and searched open PRs for duplicate transfer
diagnostic work.

## What Changed

- Add optional transfer stage and failure kind fields to the existing
diagnostic envelope. Capture command exits and listing deadlines at
their producers.
- Record only known codes from typed RPC errors at the sync-out
boundary. Revalidate every field before persistence and Sentry
projection.
- Keep annotations private to each outbound request and restore
settlement. Preserve frozen error identity and prevent evidence from
leaking across concurrent or later calls.
- Document the fields. Cover producer failures, quota limits, old
workers, concurrent error reuse, and redaction through the real Sentry
SDK.

## Verification

- Focused SDK, provider, host, persistence, and real Sentry tests: 449
passed, no skips. Independent review also ran the focused contracts and
all provider tests. The optional Sentry SDK is required for this check,
so the contract tests cannot skip.
- A compiled Daytona transfer and compiled SDK worker pass a local RPC
round trip. This uses the development TypeScript loader for workspace
dependency exports. The transfer and capture modules are compiled
JavaScript.
- `pnpm -r typecheck` and `pnpm build` passed. The excluded Daytona
package also passed `tsc --noEmit`.
- The initial local `pnpm test:run` stopped in the general-server group:
60 suites could not initialize embedded PostgreSQL because the isolated
install skipped its Darwin library-link setup. The existing package
postinstall repairs this; `initdb --version` now passes. All 60 affected
suites then passed: 1,342 tests, no skips. No local full aggregate pass
is claimed.
- Independent source review covers privacy, concurrency, packaging, and
recovery behavior.

## Risks

- These diagnostics help identify future failures. They do not establish
or repair the cause of a past transfer failure.
- New fields are optional. Older workers remain compatible. Unknown
values are omitted.
- The change adds a small request-local diagnostic scope. It keeps the
original thrown errors, archive confinement, cleanup order, timeout
values, retry count, and source retention policy.
- No schema or deployment changes are required. No live provider actions
are part of validation.

## Model Used

OpenAI Codex, based on GPT-6, with repository inspection, code
execution, and independent agent review. The exact deployed model ID and
context-window size are not exposed in this session.

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

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-10-08 19:27:39 -07:00

147 lines
7.8 KiB
TypeScript

import { createInterface } from "node:readline";
import { PassThrough } from "node:stream";
import { describe, expect, it } from "vitest";
import {
createRequest, definePlugin, isJsonRpcResponse, JsonRpcCallError, parseMessage,
readEnvironmentSyncErrorDiagnostic, withEnvironmentSyncTransferStep, recordEnvironmentSyncError,
PLUGIN_RPC_ERROR_CODES, serializeMessage, startWorkerRpcHost, type JsonRpcResponse,
} from "@paperclipai/plugin-sdk";
import {
getWorkspaceRestoreDiagnostic, withWorkspaceRestoreDiagnostics, withWorkspaceRestoreStep,
} from "@paperclipai/adapter-utils/workspace-restore-diagnostics";
import { preserveEnvironmentSyncOutErrorDiagnostic } from "../services/environment-sync-out-error.js";
async function roundTrip(method: string, error: unknown): Promise<{ error: JsonRpcCallError; wire: string }> {
const stdin = new PassThrough();
const stdout = new PassThrough();
const lines = createInterface({ input: stdout });
const worker = startWorkerRpcHost({ stdin, stdout, plugin: definePlugin({
async setup() {},
async onEnvironmentSyncOut() { throw error; },
async onEnvironmentSyncIn() { throw error; },
async onEnvironmentExecute() { throw error; },
}) });
try {
const response = new Promise<{ message: JsonRpcResponse; wire: string }>((resolve) => {
lines.on("line", (wire) => {
const message = parseMessage(wire);
if (isJsonRpcResponse(message)) resolve({ message, wire });
});
});
stdin.write(serializeMessage(createRequest(method, {}, "sync-out-test")));
const { message, wire } = await response;
if (!("error" in message) || !message.error) throw new Error("Expected RPC failure");
return { error: new JsonRpcCallError(message.error), wire };
} finally {
worker.stop(); lines.close(); stdin.destroy(); stdout.destroy();
}
}
describe("environment sync-out restore diagnostics", () => {
it("retains bounded evidence through the real worker RPC roundtrip and host restore classifier", async () => {
const failure = Object.assign(new Error("Workspace export failed"), {
name: "private-provider-class", code: "ETIMEDOUT", statusCode: 504,
response: { data: { apiKey: "private-token" } }, path: "/private-path",
cause: new Error("private-cause"), data: { credentials: "private-credentials" },
});
const { error, wire } = await roundTrip("environmentSyncOut", failure);
expect(wire).not.toContain("private-");
expect(wire.length).toBeLessThan(400);
expect(error.code).toBe(PLUGIN_RPC_ERROR_CODES.WORKER_ERROR);
expect(error.message).toBe(failure.message);
const before = structuredClone({ message: error.message, code: error.code, data: error.data });
const logs: string[] = [];
await expect(withWorkspaceRestoreDiagnostics("workspace", () => withWorkspaceRestoreStep("workspace_transfer", async () => {
throw preserveEnvironmentSyncOutErrorDiagnostic(error);
}), async (line) => { logs.push(line); })).rejects.toBe(error);
expect(getWorkspaceRestoreDiagnostic(error)).toEqual({ phase: "workspace", step: "workspace_transfer", errorCode: "ETIMEDOUT", httpStatus: 504, rpcCode: PLUGIN_RPC_ERROR_CODES.WORKER_ERROR });
expect(logs).toHaveLength(1);
expect(JSON.parse(logs[0].slice(logs[0].indexOf("{")))).toEqual(getWorkspaceRestoreDiagnostic(error));
expect({ message: error.message, code: error.code, data: error.data }).toEqual(before);
expect(error).not.toHaveProperty("cause");
});
it.each(["environmentSyncIn", "environmentExecute"])("does not add the sync-out envelope to %s", async (method) => {
const { error } = await roundTrip(method, Object.assign(new Error("Provider failure"), { code: "EACCES", status: 503 }));
expect(error.data).toBeUndefined();
expect(preserveEnvironmentSyncOutErrorDiagnostic(error)).toBe(error);
expect(getWorkspaceRestoreDiagnostic(error)).toBeUndefined();
});
it("keeps old workers and unknown diagnostics compatible without changing policy fields", async () => {
const { error } = await roundTrip("environmentSyncOut", new Error("daytona_sandbox_not_found"));
expect(error.data).toBeUndefined();
expect(preserveEnvironmentSyncOutErrorDiagnostic(error)).toBe(error);
expect(error.message).toBe("daytona_sandbox_not_found");
const original = Object.freeze(new JsonRpcCallError({ code: -32001, message: "Existing worker failure" }));
expect(preserveEnvironmentSyncOutErrorDiagnostic(original)).toBe(original);
expect(preserveEnvironmentSyncOutErrorDiagnostic(null)).toBeNull();
});
});
it("isolates overlapping and sequential worker calls that throw the same object", async () => {
const original = Object.freeze(new Error("Transfer failed"));
const before = Object.getOwnPropertyDescriptors(original);
const stdin = new PassThrough();
const stdout = new PassThrough();
const lines = createInterface({ input: stdout });
let release!: () => void;
const gate = new Promise<void>(resolve => { release = resolve; });
let started!: () => void;
const ready = new Promise<void>(resolve => { started = resolve; });
const worker = startWorkerRpcHost({ stdin, stdout, plugin: definePlugin({
async setup() {},
async onEnvironmentSyncOut(params) {
if (params.operations?.[0]?.operationId === "first") {
return withEnvironmentSyncTransferStep("archive_create", async () => {
recordEnvironmentSyncError(original, { exitCode: 2, transferFailureKind: "command_failed" });
started(); await gate; throw original;
});
}
if (params.operations?.[0]?.operationId === "second") {
return withEnvironmentSyncTransferStep("file_download", async () => { throw original; });
}
throw original;
},
async onEnvironmentSyncIn() { throw original; },
}) });
const call = (id: string, method = "environmentSyncOut") => new Promise<JsonRpcCallError>((resolve) => {
const listener = (wire: string) => {
const message = parseMessage(wire);
if (isJsonRpcResponse(message) && message.id === id && message.error) {
lines.off("line", listener); resolve(new JsonRpcCallError(message.error));
}
};
lines.on("line", listener);
stdin.write(serializeMessage(createRequest(method, { operations: [{ operationId: id, files: [] }] }, id)));
});
try {
const first = call("first");
await ready;
const second = await call("second");
expect(readEnvironmentSyncErrorDiagnostic(second)).toEqual({ errorCode: "unknown", transferStep: "file_download" });
release();
expect(readEnvironmentSyncErrorDiagnostic(await first)).toEqual({
errorCode: "unknown", transferStep: "archive_create", transferFailureKind: "command_failed", exitCode: 2,
});
expect((await call("third")).data).toBeUndefined();
expect((await call("unrelated", "environmentSyncIn")).data).toBeUndefined();
expect(Object.getOwnPropertyDescriptors(original)).toEqual(before);
} finally { release(); worker.stop(); lines.close(); stdin.destroy(); stdout.destroy(); }
});
it.each([
[new JsonRpcCallError({ code: PLUGIN_RPC_ERROR_CODES.TIMEOUT, message: "private-rpc-message" }), PLUGIN_RPC_ERROR_CODES.TIMEOUT],
[new JsonRpcCallError({ code: 123, message: "private-rpc-message" }), undefined],
[Object.assign(new Error("private-rpc-message"), { code: PLUGIN_RPC_ERROR_CODES.TIMEOUT }), undefined],
])("records known typed RPC codes without interpreting arbitrary errors", async (error, rpcCode) => {
const logs: string[] = [];
await expect(withWorkspaceRestoreDiagnostics("workspace", () => withWorkspaceRestoreStep("workspace_transfer", async () => {
throw preserveEnvironmentSyncOutErrorDiagnostic(error);
}), async line => { logs.push(line); })).rejects.toBe(error);
expect(getWorkspaceRestoreDiagnostic(error)?.rpcCode).toBe(rpcCode);
expect(getWorkspaceRestoreDiagnostic(error)?.transferStep).toBeUndefined();
expect(JSON.stringify(logs)).not.toContain("private-");
});