mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-11 14:10:50 +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>
116 lines
7.0 KiB
TypeScript
116 lines
7.0 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import { preserveEnvironmentSyncErrorDiagnostic, environmentSyncErrorData, readEnvironmentSyncErrorDiagnostic, withEnvironmentSyncErrorCapture, withEnvironmentSyncTransferStep, recordEnvironmentSyncError } from "../src/environment-sync-error.js";
|
|
|
|
const schema = "paperclip/environment-sync-error/v1";
|
|
|
|
describe("environment sync diagnostic envelope", () => {
|
|
it("keeps only known codes and bounded numbers from nested provider errors", () => {
|
|
const error = Object.assign(new Error("private-message"), {
|
|
name: "private-name", code: "private-code", path: "/private-path",
|
|
response: { status: 503, data: { token: "private-token" } },
|
|
data: { credentials: "private-credentials" },
|
|
cause: { code: "ECONNRESET", exitCode: 7, stderr: "private-output" },
|
|
});
|
|
const data = environmentSyncErrorData(error);
|
|
expect(data).toEqual({ schema, diagnostic: { errorCode: "ECONNRESET", httpStatus: 503, exitCode: 7 } });
|
|
expect(JSON.stringify(data)).not.toContain("private-");
|
|
expect(readEnvironmentSyncErrorDiagnostic({ data })).toEqual({ errorCode: "ECONNRESET", httpStatus: 503, exitCode: 7 });
|
|
});
|
|
|
|
it.each([undefined, null, "private-error", { code: "PRIVATE_TOKEN" }, { status: 600 }, { status: 399 },
|
|
{ statusCode: "503" }, { status: 500.5 }, { exitCode: 0 }, { exitCode: 256 }, { code: -32001 },
|
|
{ exitCode: Infinity }, { data: { schema, diagnostic: { errorCode: "EACCES" } } },
|
|
])("omits unrecognized or unbounded provider values: %j", (error) => {
|
|
expect(environmentSyncErrorData(error)).toBeUndefined();
|
|
});
|
|
|
|
it("bounds cyclic causes and ignores throwing getters without changing the error", () => {
|
|
const error = Object.defineProperties({ statusCode: 429 } as Record<string, unknown>, {
|
|
code: { get() { throw new Error("private-code"); } },
|
|
response: { get() { throw new Error("private-response"); } },
|
|
});
|
|
error.cause = error;
|
|
expect(environmentSyncErrorData(error)).toEqual({ schema, diagnostic: { errorCode: "unknown", httpStatus: 429 } });
|
|
const wrap = (cause: unknown) => ({ cause });
|
|
expect(environmentSyncErrorData(wrap(wrap(wrap({ code: "EIO" }))))).toEqual({ schema, diagnostic: { errorCode: "EIO" } });
|
|
expect(environmentSyncErrorData(wrap(wrap(wrap(wrap({ code: "EIO" })))))).toBeUndefined();
|
|
});
|
|
|
|
it("revalidates worker fields and ignores arbitrary payload and protocol versions", () => {
|
|
expect(readEnvironmentSyncErrorDiagnostic({ data: { schema, diagnostic: {
|
|
errorCode: "EACCES", httpStatus: 403, exitCode: 1.5,
|
|
message: "private-message", path: "/private-path", credentials: "private-token",
|
|
} } })).toEqual({ errorCode: "EACCES", httpStatus: 403 });
|
|
expect(readEnvironmentSyncErrorDiagnostic({ data: { schema: "other", diagnostic: { errorCode: "EACCES" } } })).toBeUndefined();
|
|
expect(readEnvironmentSyncErrorDiagnostic({ data: { schema, diagnostic: { errorCode: "private-token", httpStatus: 900 } } })).toBeUndefined();
|
|
expect(readEnvironmentSyncErrorDiagnostic(Object.defineProperty({}, "data", { get() { throw new Error("private"); } }))).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
|
|
describe("request-local transfer evidence", () => {
|
|
it("preserves frozen error identity, nested attribution, and later-attempt attribution", async () => {
|
|
const error = Object.freeze(new Error("private-message"));
|
|
const before = Object.getOwnPropertyDescriptors(error);
|
|
await withEnvironmentSyncErrorCapture(async () => {
|
|
await expect(withEnvironmentSyncTransferStep("archive_download", () =>
|
|
withEnvironmentSyncTransferStep("sandbox_access", async () => { throw error; }))).rejects.toBe(error);
|
|
expect(readEnvironmentSyncErrorDiagnostic({ data: environmentSyncErrorData(error) })?.transferStep).toBe("sandbox_access");
|
|
await expect(withEnvironmentSyncTransferStep("archive_create", async () => { throw error; })).rejects.toBe(error);
|
|
expect(readEnvironmentSyncErrorDiagnostic({ data: environmentSyncErrorData(error) })?.transferStep).toBe("archive_create");
|
|
});
|
|
expect(environmentSyncErrorData(error)).toBeUndefined();
|
|
expect(Object.getOwnPropertyDescriptors(error)).toEqual(before);
|
|
await withEnvironmentSyncErrorCapture(async () => expect(environmentSyncErrorData(error)).toBeUndefined());
|
|
});
|
|
|
|
it("does not trust labels on raw errors, their causes, or unknown producer values", async () => {
|
|
const error = { transferStep: "archive_create", transferFailureKind: "command_failed",
|
|
cause: { transferStep: "archive_validate", message: "private-message" } };
|
|
await withEnvironmentSyncErrorCapture(async () => {
|
|
expect(environmentSyncErrorData(error)).toBeUndefined();
|
|
recordEnvironmentSyncError(error, { transferStep: "private-stage", transferFailureKind: "private-kind", exitCode: 900 } as never);
|
|
expect(environmentSyncErrorData(error)).toBeUndefined();
|
|
});
|
|
expect(readEnvironmentSyncErrorDiagnostic({ data: { schema, diagnostic: {
|
|
errorCode: "unknown", transferStep: "private-stage", transferFailureKind: "private-kind", rpcCode: -32003,
|
|
} } })).toBeUndefined();
|
|
});
|
|
|
|
it("omits evidence from detached work after the RPC scope closes", async () => {
|
|
const error = new Error("private-message");
|
|
let resume!: () => void;
|
|
const gate = new Promise<void>(resolve => { resume = resolve; });
|
|
let detached!: Promise<unknown>;
|
|
await withEnvironmentSyncErrorCapture(async () => {
|
|
detached = withEnvironmentSyncTransferStep("archive_create", async () => { await gate; throw error; })
|
|
.catch(failure => environmentSyncErrorData(failure));
|
|
});
|
|
resume();
|
|
expect(await detached).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
|
|
it("copies safe evidence into an existing wrapper only within its request", async () => {
|
|
const source = Object.freeze(Object.assign(new Error("private-message"), { status: 404, cause: { code: "EIO", token: "private-token" } }));
|
|
const wrapper = Object.freeze(new Error("stable-policy-message"));
|
|
await withEnvironmentSyncErrorCapture(async () => {
|
|
await expect(withEnvironmentSyncTransferStep("file_download", async () => { throw source; })).rejects.toBe(source);
|
|
expect(preserveEnvironmentSyncErrorDiagnostic(wrapper, source)).toBe(wrapper);
|
|
expect(readEnvironmentSyncErrorDiagnostic({ data: environmentSyncErrorData(wrapper) })).toEqual({
|
|
errorCode: "EIO", httpStatus: 404, transferStep: "file_download",
|
|
});
|
|
expect(wrapper).not.toHaveProperty("cause");
|
|
expect(wrapper.message).toBe("stable-policy-message");
|
|
// Reusing a wrapper for a later untyped failure must clear the earlier evidence.
|
|
preserveEnvironmentSyncErrorDiagnostic(wrapper, new Error("private-untyped-message"));
|
|
expect(environmentSyncErrorData(wrapper)).toBeUndefined();
|
|
});
|
|
expect(environmentSyncErrorData(wrapper)).toBeUndefined();
|
|
await withEnvironmentSyncErrorCapture(async () => {
|
|
preserveEnvironmentSyncErrorDiagnostic(wrapper, source);
|
|
expect(readEnvironmentSyncErrorDiagnostic({ data: environmentSyncErrorData(wrapper) })).toEqual({ errorCode: "EIO", httpStatus: 404 });
|
|
});
|
|
});
|