mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-14 08:26:46 +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>
138 lines
6.8 KiB
TypeScript
138 lines
6.8 KiB
TypeScript
import { AsyncLocalStorage } from "node:async_hooks";
|
|
|
|
/** Diagnostic evidence only. Never use this envelope to authorize cleanup or retry. */
|
|
export interface PluginEnvironmentSyncErrorDiagnostic {
|
|
errorCode: string;
|
|
httpStatus?: number;
|
|
exitCode?: number;
|
|
transferStep?: EnvironmentSyncTransferStep;
|
|
transferFailureKind?: EnvironmentSyncTransferFailureKind;
|
|
}
|
|
|
|
const TRANSFER_STEPS = new Set([
|
|
"sandbox_access", "sandbox_guard", "file_download", "file_finalize",
|
|
"archive_create", "archive_download", "archive_validate", "archive_extract",
|
|
] as const);
|
|
export type EnvironmentSyncTransferStep = typeof TRANSFER_STEPS extends Set<infer T> ? T : never;
|
|
const TRANSFER_FAILURE_KINDS = new Set([
|
|
"command_failed", "download_failed", "download_missing", "unsafe_archive",
|
|
"listing_timeout", "listing_entry_limit", "listing_byte_limit", "listing_line_limit", "listing_stderr_limit",
|
|
] as const);
|
|
export type EnvironmentSyncTransferFailureKind = typeof TRANSFER_FAILURE_KINDS extends Set<infer T> ? T : never;
|
|
interface Capture {
|
|
active: boolean;
|
|
sequence: number;
|
|
errors: WeakMap<object, { sequence: number; diagnostic: PluginEnvironmentSyncErrorDiagnostic }>;
|
|
}
|
|
const capture = new AsyncLocalStorage<Capture>();
|
|
|
|
/** Scope evidence to one outbound RPC, including its error serialization. */
|
|
export async function withEnvironmentSyncErrorCapture<T>(operation: () => Promise<T>): Promise<T> {
|
|
const scope: Capture = { active: true, sequence: 0, errors: new WeakMap() };
|
|
return capture.run(scope, async () => {
|
|
try { return await operation(); }
|
|
finally { scope.active = false; }
|
|
});
|
|
}
|
|
|
|
/** Producer-owned evidence only; never mutate, wrap, or retain a raw cause. */
|
|
export function recordEnvironmentSyncError<T>(error: T, diagnostic: Partial<PluginEnvironmentSyncErrorDiagnostic>): T {
|
|
const scope = capture.getStore();
|
|
const safe = sanitize(diagnostic);
|
|
if (scope?.active && error && typeof error === "object") {
|
|
if (safe) scope.errors.set(error, { sequence: ++scope.sequence, diagnostic: safe });
|
|
else scope.errors.delete(error);
|
|
}
|
|
return error;
|
|
}
|
|
|
|
/** A nested failing stage wins; a later attempt gets fresh attribution. */
|
|
export async function withEnvironmentSyncTransferStep<T>(step: EnvironmentSyncTransferStep, operation: () => Promise<T>): Promise<T> {
|
|
const scope = capture.getStore();
|
|
if (!scope?.active || !TRANSFER_STEPS.has(step)) return await operation();
|
|
const started = scope.sequence;
|
|
try { return await operation(); }
|
|
catch (error) {
|
|
if (scope.active && error && typeof error === "object") {
|
|
const saved = scope.errors.get(error);
|
|
const diagnostic = saved && saved.sequence > started ? saved.diagnostic : { errorCode: "unknown" };
|
|
scope.errors.set(error, {
|
|
sequence: ++scope.sequence, diagnostic: { ...diagnostic, transferStep: diagnostic.transferStep ?? step },
|
|
});
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
const SCHEMA = "paperclip/environment-sync-error/v1";
|
|
// Match the host's workspace restore diagnostic allowlist. Both sides validate
|
|
// independently: plugin-supplied data must never widen the host's log surface.
|
|
const ERROR_CODES = new Set([
|
|
"ENOENT", "EACCES", "EPERM", "ENOSPC", "EIO", "EXDEV", "ENOTDIR", "EISDIR",
|
|
"ECONNRESET", "ECONNREFUSED", "ETIMEDOUT", "EPIPE", "ENOTFOUND", "EAI_AGAIN",
|
|
"ABORT_ERR", "UND_ERR_CONNECT_TIMEOUT", "UND_ERR_SOCKET",
|
|
]);
|
|
|
|
function field(value: unknown, key: string): unknown {
|
|
try { return value && typeof value === "object" ? (value as Record<string, unknown>)[key] : undefined; }
|
|
catch { return undefined; }
|
|
}
|
|
|
|
function integer(value: unknown, minimum: number, maximum: number): number | undefined {
|
|
return typeof value === "number" && Number.isInteger(value) && value >= minimum && value <= maximum
|
|
? value : undefined;
|
|
}
|
|
|
|
function sanitize(value: unknown): PluginEnvironmentSyncErrorDiagnostic | undefined {
|
|
const code = field(value, "errorCode");
|
|
const errorCode = typeof code === "string" && ERROR_CODES.has(code) ? code : "unknown";
|
|
const httpStatus = integer(field(value, "httpStatus"), 400, 599);
|
|
const exitCode = integer(field(value, "exitCode"), 1, 255);
|
|
const step = field(value, "transferStep");
|
|
const kind = field(value, "transferFailureKind");
|
|
const transferStep = typeof step === "string" && TRANSFER_STEPS.has(step as EnvironmentSyncTransferStep) ? step as EnvironmentSyncTransferStep : undefined;
|
|
const transferFailureKind = typeof kind === "string" && TRANSFER_FAILURE_KINDS.has(kind as EnvironmentSyncTransferFailureKind) ? kind as EnvironmentSyncTransferFailureKind : undefined;
|
|
if (errorCode === "unknown" && httpStatus === undefined && exitCode === undefined && !transferStep && !transferFailureKind) return undefined;
|
|
return { errorCode, ...(httpStatus !== undefined ? { httpStatus } : {}), ...(exitCode !== undefined ? { exitCode } : {}),
|
|
...(transferStep ? { transferStep } : {}), ...(transferFailureKind ? { transferFailureKind } : {}),
|
|
};
|
|
}
|
|
|
|
/** Capture known codes and numbers, never arbitrary error data, text, or names. */
|
|
function captureDiagnostic(error: unknown): PluginEnvironmentSyncErrorDiagnostic | undefined {
|
|
const diagnostic: PluginEnvironmentSyncErrorDiagnostic = { errorCode: "unknown" };
|
|
const scope = capture.getStore();
|
|
if (scope?.active && error && typeof error === "object") {
|
|
Object.assign(diagnostic, scope.errors.get(error)?.diagnostic);
|
|
}
|
|
let current = error;
|
|
// Bound nested SDK causes, including cyclic errors and throwing getters.
|
|
for (let depth = 0; depth < 4 && current && typeof current === "object"; depth++) {
|
|
const code = field(current, "code");
|
|
if (diagnostic.errorCode === "unknown" && typeof code === "string" && ERROR_CODES.has(code)) diagnostic.errorCode = code;
|
|
diagnostic.httpStatus ??= integer(field(current, "status"), 400, 599)
|
|
?? integer(field(current, "statusCode"), 400, 599)
|
|
?? integer(field(field(current, "response"), "status"), 400, 599);
|
|
diagnostic.exitCode ??= integer(field(current, "exitCode"), 1, 255) ?? integer(code, 1, 255);
|
|
current = field(current, "cause");
|
|
}
|
|
return sanitize(diagnostic);
|
|
}
|
|
|
|
export function environmentSyncErrorData(error: unknown): unknown {
|
|
const diagnostic = captureDiagnostic(error);
|
|
return diagnostic ? { schema: SCHEMA, diagnostic } : undefined;
|
|
}
|
|
|
|
/** Retain bounded evidence across an existing wrapper, without adding a raw cause. */
|
|
export function preserveEnvironmentSyncErrorDiagnostic<T>(wrapper: T, source: unknown): T {
|
|
const diagnostic = captureDiagnostic(source);
|
|
return recordEnvironmentSyncError(wrapper, diagnostic ?? { errorCode: "unknown" });
|
|
}
|
|
|
|
/** Revalidate the worker envelope before adapting it into host-only diagnostics. */
|
|
export function readEnvironmentSyncErrorDiagnostic(error: unknown): PluginEnvironmentSyncErrorDiagnostic | undefined {
|
|
const data = field(error, "data");
|
|
return field(data, "schema") === SCHEMA ? sanitize(field(data, "diagnostic")) : undefined;
|
|
}
|