mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-17 20:09:05 +02:00
## 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>
147 lines
7.8 KiB
TypeScript
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-");
|
|
});
|