mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-09 07:10:34 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - The server owns run recovery and task continuation after a restart. > - A shutdown can interrupt Runner preparation before any provider work starts. > - Workspace recovery can save the files but leave that task behind a legacy execution hold. > - Retry then accepts a request without starting a successor. Startup also leaves the task blocked. > - This pull request verifies the interrupted preparation, preserves its files, and admits one bounded continuation. > - It also keeps a dropped Sentry capture eligible for a later report. ## Linked Issues or Issue Description **What happened?** A hosted task remained blocked after a graceful server shutdown interrupted Runner preparation. Its remote workspace was recovered. Retry returned an accepted request, but no successor started. Startup did not resume it. The task also had no matching Sentry event. The recovery cause is verified. The exact cause of the missing historical Sentry event is unknown. A separate regression with the real Sentry SDK proves that processing backpressure can drop a capture while the task cache marks it as reported forever. **Expected behavior** Retry must continue eligible interrupted work from its saved workspace. Startup must schedule one bounded retry when durable evidence proves that no provider started. A later task read must be able to retry an unconfirmed Sentry report. **Steps to reproduce** 1. Interrupt Runner preparation during a server shutdown before native runtime selection. 2. Retain and recover its remote workspace under the legacy recovery hold. 3. Press Retry, or restart after file recovery has completed. 4. Observe that the task remains blocked without a successor. For the reporting case, hold the first Sentry event in its asynchronous enrichment step. Fill the SDK processing buffer. Capture a second task failure, release the first event, and read the second failed task again later. **Paperclip version or commit** Reproduced against source commit `4d31588dea`. The hosted incident served `6ef37398c8d799c5142a3a6590ecc70ad352b663`. **Deployment mode** Hosted Linux server with remote workspaces and external PostgreSQL. Local regressions use the source checkout on macOS arm64, Node 26.4.0, PostgreSQL 17.10, and the audited optional Sentry SDK 10.71.0. Related public changes: #18541 added task-state failure reports. #18678 retained remote exports before Retry. #18803 fenced remote workspace writers before continuation. The small backend-port fixture correction follows #18739. It is included here because the same existing setup defect blocked CI. ## What Changed - Verify pre-native shutdown interruption against the immutable adapter claim, controller ownership, run events, native records, stopped leases, and exact workspace recovery receipts. - Retire only the server's unchanged bootstrap failure block. Admit explicit Retry through the existing continuation path. - Discover completed workspace recovery at startup and schedule one retry under the existing failure budget. Let saved input own its continuation. - Apply the existing safe replacement dispatch checks to preparation retries. A later human block, competing execution, dependency, pause, approval, or budget gate must still prevent dispatch. - Keep execution evidence available through the bounded run projection. - Confirm Sentry task-report delivery from a successful SDK HTTP response. Keep unconfirmed captures pending for 60 seconds before a later task read can retry them. Both caches remain bounded and start no timer. - Document recovery and report delivery semantics. - Align existing handoff/budget assertions with the guarded preparation retry reason. Keep the owned-runtime fixture outside reserved proxy ports, with bounded allocation attempts. ## Verification - Red before the recovery fix: all three new Retry/startup cases failed because no successor was created. Green after the fix: all three passed with real saved workspace archives and PostgreSQL. - The pre-merge revision passes all 384 tests in the six affected recovery, run-control, run-state, retry, and dispatch suites. - The late-block regression failed before its repair. Its four focused recovery/dispatch cases then passed, including final dispatch cancellation. - Review found a second stop check that still rejected verified local and lease-free preparation. Both new cases failed before that repair. All 17 interrupted-preparation regressions passed on the pre-merge revision. - The Sentry regression failed with the real SDK before the fix. All 91 affected reporting tests then passed. The transport stays in memory. No test sends events to Sentry. - Full repository typecheck passed with `node scripts/typecheck-low-memory.mjs`. The full `pnpm build` and module boundary check passed. - CI found untyped delivery-hook parameters when the optional Sentry SDK was absent. Explicit callback types fix that build. The SDK-absent server build and all 91 SDK-present reporting tests pass. - Server typecheck passes with the SDK installed and the real Sentry SDK contract CI job passes. Greptile reviewed the pre-merge commit `103e175c44` at 5/5 with no open findings. - The local full-suite attempt initially overlapped the rebase and stopped at test-line selection. Its unchanged-head rerun passed the first 309-test chat shard. I stopped the old-head run before applying review fixes. Current-head CI owns the remaining full-suite verification. - CI exposed two old retry-reason expectations. The handoff and retry-budget checks pass with the new guarded reason. A separate existing process-adoption fixture chose port 42953 in the reserved 42000–42999 proxy range. The fixture now rejects reserved ports, following #18739. Its real process/adoption/termination test passes locally. - The final Runner package job [initially failed](https://github.com/paperclipai/paperclip/actions/runs/41718507919/job/126205912033) one unchanged Codex credential case after it could not acquire its port quorum, then timed out. The complete unchanged 42-case credential suite passes locally. One unchanged CI retry passed; the original competing port owner was not identified. No Runner implementation or credential test was changed. - Required CI passes on `103e175c4490e1bdf804c64a8635b0e4568545ae`, including `ci / verify` and `ci / e2e`. That revision has Greptile 5/5 with no open findings. The optional Paperclip Review check on that earlier head remained queued, waiting for its assigned agent. - Merged current `master` without replacing published commits. The retry gate retains both shutdown interruption checks and the new workspace preparation timeout policy. All 311 tests in the five combined recovery, retry scheduling, completion, and native startup suites passed after the conflict resolution. Server typecheck passed. Final head `005d380b00d45a2393c4a2a190eab27611168d3c` includes the next conflict-free master update. All 17 interrupted-preparation regressions passed again on that final head. Greptile reviewed that exact head at 5/5 with no new findings. Required CI passes on that exact head, including `ci / verify`, `ci / e2e`, typecheck, build, the full test shards, and the real Sentry SDK isolation check. The optional Paperclip Review remains queued, waiting for its assigned agent; it is not a required branch check. - On the final head, six test jobs were cancelled after their spot runners received shutdown signals at the same time. Another server shard passed 2,419 tests, but its unchanged 103-record cleanup backlog case exceeded the 15-second limit. The unchanged full cleanup suite then passed all 29 tests locally. One unchanged retry of only the seven unsuccessful jobs passed. The [original cancellation and timeout logs](https://github.com/paperclipai/paperclip/actions/runs/41724898038/attempts/1) remain in the workflow history. No cleanup implementation or test was changed. - The signed-in hosted UI reproduced the failed Retry before the fix. The fixed path is covered through the real HTTP Retry route and assembled heartbeat/recovery services. This PR has not been deployed to the hosted incident. A live repaired Retry remains unverified until rollout. ## Risks - Automatic continuation must not repeat provider work. It requires positive preparation evidence and rejects retained provider, process-launch, coordinator, result, live-controller, unsafe lease, or mismatched workspace evidence. - Repeated recovery calls must create one successor. Tests cover concurrent requests, repeated startup visits, pending Retry, retry exhaustion, and changed task ownership. - Sentry delivery state stays local to the process. A restart or cache eviction can report a failure again. An unconfirmed event needs a later task read to retry. - No schema or API contract changes are required. ## Model Used OpenAI GPT-6 through Codex. This session does not expose a more specific deployment ID or context window size. Reasoning, code editing, test execution, and browser diagnostics assisted the change. ## 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 - [x] 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>
340 lines
16 KiB
TypeScript
340 lines
16 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||
import type { RunFailureEvent } from "../sentry.js";
|
||
|
||
/**
|
||
* Tests for `captureRunFailure`, the Sentry report for a terminal run
|
||
* failure. `@sentry/node` is an optional runtime dependency and is not
|
||
* installed in this test environment, so each test mocks a fake copy of the
|
||
* package the same way `sentry.test.ts` does, then imports a fresh copy of
|
||
* `../sentry.js` with a backend DSN set. That opens the gate and lets the
|
||
* fake package's `captureException` spy records the real call
|
||
* shape.
|
||
*/
|
||
|
||
const DSN_ENV = "SENTRY_DSN";
|
||
const FRONTEND_DSN_ENV = "SENTRY_DSN_FRONTEND";
|
||
const BACKEND_DSN_ENV = "SENTRY_DSN_BACKEND";
|
||
const originalDsn = process.env[DSN_ENV];
|
||
const originalFrontendDsn = process.env[FRONTEND_DSN_ENV];
|
||
const originalBackendDsn = process.env[BACKEND_DSN_ENV];
|
||
|
||
function baseEvent(overrides: Partial<RunFailureEvent> = {}): RunFailureEvent {
|
||
return {
|
||
taskId: "11111111-1111-1111-1111-111111111111",
|
||
runId: "22222222-2222-2222-2222-222222222222",
|
||
errorMessage: "the provider process exited with code 1",
|
||
errorCode: "process_lost",
|
||
agentAdapter: "claude-code",
|
||
runStatus: "failed",
|
||
...overrides,
|
||
};
|
||
}
|
||
|
||
interface CaptureContext {
|
||
tags: Record<string, string>;
|
||
contexts: Record<string, Record<string, unknown>>;
|
||
fingerprint: string[];
|
||
}
|
||
|
||
/** Mirrors `sentry.test.ts`'s `mockSentryPackage`. */
|
||
function mockSentryPackage() {
|
||
const init = vi.fn();
|
||
const captureException = vi.fn((_error: unknown, _context?: CaptureContext) => "event-id");
|
||
const close = vi.fn(async () => true);
|
||
const httpIntegration = vi.fn((options: unknown) => ({ name: "Http", ...(options as object) }));
|
||
const onUnhandledRejectionIntegration = vi.fn((options: unknown) => ({
|
||
name: "OnUnhandledRejection",
|
||
...(options as object),
|
||
}));
|
||
|
||
vi.doMock("@sentry/node", () => ({
|
||
init,
|
||
getClient: () => ({ on: vi.fn() }),
|
||
captureException,
|
||
close,
|
||
httpIntegration,
|
||
onUnhandledRejectionIntegration,
|
||
}));
|
||
vi.doMock("../peer-version-check.js", () => ({
|
||
checkExactPeerVersions: () => ({ ok: true }),
|
||
}));
|
||
|
||
return { init, captureException, close };
|
||
}
|
||
|
||
async function importFreshSentryWithGateOpen() {
|
||
process.env[BACKEND_DSN_ENV] = "https://public@sentry.example.com/1";
|
||
const mocks = mockSentryPackage();
|
||
vi.resetModules();
|
||
const sentryModule = await import("../sentry.js");
|
||
await sentryModule.sentryReady;
|
||
return { sentryModule, ...mocks };
|
||
}
|
||
|
||
beforeEach(() => {
|
||
vi.stubEnv("PAPERCLIP_SENTRY_RUN_FAILURES", "unexpected");
|
||
delete process.env[DSN_ENV];
|
||
delete process.env[FRONTEND_DSN_ENV];
|
||
delete process.env[BACKEND_DSN_ENV];
|
||
});
|
||
|
||
afterEach(() => {
|
||
vi.unstubAllEnvs();
|
||
if (originalDsn === undefined) delete process.env[DSN_ENV];
|
||
else process.env[DSN_ENV] = originalDsn;
|
||
if (originalFrontendDsn === undefined) delete process.env[FRONTEND_DSN_ENV];
|
||
else process.env[FRONTEND_DSN_ENV] = originalFrontendDsn;
|
||
if (originalBackendDsn === undefined) delete process.env[BACKEND_DSN_ENV];
|
||
else process.env[BACKEND_DSN_ENV] = originalBackendDsn;
|
||
vi.restoreAllMocks();
|
||
vi.doUnmock("@sentry/node");
|
||
vi.doUnmock("../peer-version-check.js");
|
||
});
|
||
|
||
describe("captureRunFailure", () => {
|
||
it("reports a saved-message failure with the run-failure tag and the actual run status", async () => {
|
||
const { sentryModule, captureException } = await importFreshSentryWithGateOpen();
|
||
const event = { companyId: "company", agentId: "agent", taskId: "task", runId: "run",
|
||
agentAdapter: "process", runStatus: "succeeded" as const, intentId: "intent" };
|
||
sentryModule.captureTaskInterruptionFailure(event);
|
||
sentryModule.captureTaskInterruptionFailure(event);
|
||
expect(captureException).toHaveBeenCalledTimes(1);
|
||
expect(captureException.mock.calls[0]![0]).toMatchObject({
|
||
message: "Couldn’t finish this attempt. Your message is saved.",
|
||
});
|
||
expect(captureException.mock.calls[0]![1]).toMatchObject({
|
||
tags: { event_kind: "run_failure", failure_source: "task_interruption", run_id: "run", task_id: "task",
|
||
company_id: "company", agent_id: "agent", run_status: "succeeded", error_code: "task_attempt_failed" },
|
||
contexts: { paperclip: { runPath: "/api/heartbeat-runs/run", taskPath: "/api/issues/task" } },
|
||
fingerprint: ["task_attempt_failed", "process"],
|
||
});
|
||
sentryModule.captureTaskInterruptionFailure({ ...event, intentId: "next-intent" });
|
||
sentryModule.captureTaskInterruptionFailure({ ...event, runId: "next-run" });
|
||
expect(captureException).toHaveBeenCalledTimes(3);
|
||
});
|
||
|
||
it("does not consume a saved-message report while reporting is off or capture fails", async () => {
|
||
const { sentryModule, captureException } = await importFreshSentryWithGateOpen();
|
||
const event = { companyId: "company", agentId: "agent", taskId: "task", runId: "run",
|
||
agentAdapter: "process", runStatus: "failed" as const, intentId: null };
|
||
vi.stubEnv("PAPERCLIP_SENTRY_RUN_FAILURES", "off");
|
||
sentryModule.captureTaskInterruptionFailure(event);
|
||
expect(captureException).not.toHaveBeenCalled();
|
||
vi.stubEnv("PAPERCLIP_SENTRY_RUN_FAILURES", "unexpected");
|
||
captureException.mockImplementationOnce(() => { throw new Error("Sentry unavailable"); });
|
||
expect(() => sentryModule.captureTaskInterruptionFailure(event)).not.toThrow();
|
||
sentryModule.captureTaskInterruptionFailure(event);
|
||
sentryModule.captureTaskInterruptionFailure(event);
|
||
expect(captureException).toHaveBeenCalledTimes(2);
|
||
});
|
||
|
||
it.each([undefined, "off", "true", "invalid"])("keeps ordinary server errors enabled while run reporting is disabled: %s", async mode => {
|
||
const { sentryModule, captureException } = await importFreshSentryWithGateOpen();
|
||
vi.stubEnv("PAPERCLIP_SENTRY_RUN_FAILURES", mode);
|
||
if (mode === undefined) delete process.env.PAPERCLIP_SENTRY_RUN_FAILURES;
|
||
sentryModule.captureRunFailure(baseEvent());
|
||
const error = new Error("ordinary server failure");
|
||
sentryModule.captureException(error);
|
||
expect(captureException).toHaveBeenCalledExactlyOnceWith(error);
|
||
});
|
||
|
||
it("provides authenticated run references and groups repeated causes without leaking scope", async () => {
|
||
const { sentryModule, captureException } = await importFreshSentryWithGateOpen();
|
||
vi.stubEnv("PAPERCLIP_PUBLIC_URL", "https://paperclip.example.com");
|
||
const event = baseEvent({ companyId: "company", agentId: "agent", taskId: null,
|
||
diagnostics: { execution: { failurePhase: "setup", originalFailureCode: "tool_name_collision" }, adapter: {}, provider: {}, exceptions: [], truncatedFields: [] } });
|
||
sentryModule.captureRunFailure(event);
|
||
sentryModule.captureRunFailure({ ...event, runId: "another-run", errorMessage: "Another tool failed" });
|
||
sentryModule.captureException(new Error("unrelated"));
|
||
const first = captureException.mock.calls[0]![1]!;
|
||
expect(first.tags).toMatchObject({ event_kind: "run_failure", company_id: "company", agent_id: "agent", root_error_code: "tool_name_collision" });
|
||
expect(first.tags).not.toHaveProperty("task_id");
|
||
expect(first.contexts.paperclip).toEqual({ schemaVersion: 1, companyId: "company", agentId: "agent",
|
||
runPath: `/api/heartbeat-runs/${event.runId}`, eventsPath: `/api/heartbeat-runs/${event.runId}/events`, logPath: `/api/heartbeat-runs/${event.runId}/log`,
|
||
runUrl: `https://paperclip.example.com/api/heartbeat-runs/${event.runId}`, eventsUrl: `https://paperclip.example.com/api/heartbeat-runs/${event.runId}/events`,
|
||
logUrl: `https://paperclip.example.com/api/heartbeat-runs/${event.runId}/log` });
|
||
expect(first.fingerprint).toEqual(["process_lost", "claude-code", "setup", "tool_name_collision"]);
|
||
expect(captureException.mock.calls[1]![1]?.fingerprint).toEqual(first.fingerprint);
|
||
expect(captureException.mock.calls[2]![1]).toBeUndefined();
|
||
});
|
||
|
||
it.each([undefined, "invalid", "ftp://example.com", "https://user:password@example.com", "https://example.com?token=private", "https://example.com/#private", "https://example.com/private"])("omits unsafe or absent instance URLs: %s", async value => {
|
||
const { sentryModule, captureException } = await importFreshSentryWithGateOpen();
|
||
vi.stubEnv("PAPERCLIP_PUBLIC_URL", value);
|
||
sentryModule.captureRunFailure(baseEvent({ companyId: "company", agentId: "agent" }));
|
||
const context = captureException.mock.calls[0]![1]!.contexts.paperclip;
|
||
expect(context).toHaveProperty("runPath");
|
||
expect(context).not.toHaveProperty("runUrl");
|
||
expect(JSON.stringify(context)).not.toMatch(/password|private/);
|
||
});
|
||
|
||
it("sets the fingerprint [errorCode, agentAdapter] in that order", async () => {
|
||
const { sentryModule, captureException } = await importFreshSentryWithGateOpen();
|
||
|
||
sentryModule.captureRunFailure(baseEvent({ errorCode: "timeout", agentAdapter: "codex" }));
|
||
|
||
expect(captureException.mock.calls[0]![1]?.fingerprint).toEqual(["timeout", "codex"]);
|
||
});
|
||
|
||
it("keeps the error message out of the fingerprint for two runs with the same code and adapter but different messages", async () => {
|
||
const { sentryModule, captureException } = await importFreshSentryWithGateOpen();
|
||
|
||
sentryModule.captureRunFailure(
|
||
baseEvent({ errorMessage: "message one", errorCode: "timeout", agentAdapter: "codex" }),
|
||
);
|
||
sentryModule.captureRunFailure(
|
||
baseEvent({ errorMessage: "message two", errorCode: "timeout", agentAdapter: "codex" }),
|
||
);
|
||
|
||
expect(captureException.mock.calls[0]![1]?.fingerprint).toEqual(["timeout", "codex"]);
|
||
expect(captureException.mock.calls[1]![1]?.fingerprint).toEqual(["timeout", "codex"]);
|
||
});
|
||
|
||
it("sets the fingerprint element \"unknown\" when the error code is absent", async () => {
|
||
const { sentryModule, captureException } = await importFreshSentryWithGateOpen();
|
||
|
||
sentryModule.captureRunFailure(baseEvent({ errorCode: null }));
|
||
|
||
expect(captureException.mock.calls[0]![1]?.fingerprint).toEqual(["unknown", "claude-code"]);
|
||
});
|
||
|
||
it("keeps missing process exit evidence null on the run_failure context", async () => {
|
||
const { sentryModule, captureException } = await importFreshSentryWithGateOpen();
|
||
const event = baseEvent();
|
||
|
||
sentryModule.captureRunFailure(event);
|
||
|
||
expect(captureException.mock.calls[0]![1]?.contexts.run_failure).toEqual({
|
||
taskId: event.taskId,
|
||
runId: event.runId,
|
||
errorMessage: event.errorMessage,
|
||
errorCode: event.errorCode,
|
||
agentAdapter: event.agentAdapter,
|
||
exitCode: null,
|
||
signal: null,
|
||
});
|
||
});
|
||
|
||
it.each([
|
||
{ exitCode: 1, signal: null },
|
||
{ exitCode: 0, signal: null },
|
||
{ exitCode: -1, signal: null },
|
||
{ exitCode: -2147483648, signal: null },
|
||
{ exitCode: 2147483647, signal: null },
|
||
{ exitCode: null, signal: "SIGTERM" },
|
||
{ exitCode: null, signal: "SIGKILL" },
|
||
])("retains process exit evidence without changing grouping: %j", async (processExit) => {
|
||
const { sentryModule, captureException } = await importFreshSentryWithGateOpen();
|
||
const event = baseEvent({ ...processExit, errorMessage: "Adapter failed", errorCode: "adapter_failed" });
|
||
|
||
sentryModule.captureRunFailure(event);
|
||
|
||
const [error, context] = captureException.mock.calls[0]!;
|
||
expect((error as Error).message).toBe("Adapter failed");
|
||
expect(context?.contexts.run_failure).toMatchObject(processExit);
|
||
expect(context?.fingerprint).toEqual(["adapter_failed", event.agentAdapter]);
|
||
expect(context?.tags).not.toHaveProperty("exitCode");
|
||
expect(context?.tags).not.toHaveProperty("signal");
|
||
});
|
||
|
||
it.each([
|
||
["private-exit-payload", "SIGTERM private-signal-payload"],
|
||
["1", "constructor"],
|
||
[true, "__proto__"],
|
||
[1.5, "SIGCUSTOM"],
|
||
[NaN, ""],
|
||
[Infinity, 9],
|
||
[2147483648, { private: "signal-payload" }],
|
||
[-2147483649, ["SIGTERM"]],
|
||
])("rejects malformed process exit fields (%j, %j)", async (exitCode, signal) => {
|
||
const { sentryModule, captureException } = await importFreshSentryWithGateOpen();
|
||
|
||
sentryModule.captureRunFailure({ ...baseEvent(), exitCode, signal } as RunFailureEvent);
|
||
|
||
expect(captureException.mock.calls[0]![1]?.contexts.run_failure).toMatchObject({
|
||
exitCode: null,
|
||
signal: "unknown",
|
||
});
|
||
expect(JSON.stringify(captureException.mock.calls)).not.toContain("private-");
|
||
});
|
||
|
||
it("does not set an instance key on the run_failure context", async () => {
|
||
const { sentryModule, captureException } = await importFreshSentryWithGateOpen();
|
||
|
||
sentryModule.captureRunFailure(baseEvent());
|
||
|
||
const context = captureException.mock.calls[0]![1]?.contexts.run_failure;
|
||
expect(context).not.toHaveProperty("instance");
|
||
});
|
||
|
||
it("sets run_id, task_id, error_code, agent_adapter, and run_status as tags", async () => {
|
||
const { sentryModule, captureException } = await importFreshSentryWithGateOpen();
|
||
const event = baseEvent();
|
||
|
||
sentryModule.captureRunFailure(event);
|
||
|
||
expect(captureException.mock.calls[0]![1]?.tags).toEqual({
|
||
event_kind: "run_failure",
|
||
run_id: event.runId,
|
||
task_id: event.taskId,
|
||
error_code: event.errorCode,
|
||
agent_adapter: event.agentAdapter,
|
||
run_status: event.runStatus,
|
||
});
|
||
});
|
||
|
||
it("captures the redacted error message as the exception message", async () => {
|
||
const { sentryModule, captureException } = await importFreshSentryWithGateOpen();
|
||
const event = baseEvent({ errorMessage: "boom" });
|
||
|
||
sentryModule.captureRunFailure(event);
|
||
|
||
expect(captureException).toHaveBeenCalledTimes(1);
|
||
const [received] = captureException.mock.calls[0]!;
|
||
expect(received).toBeInstanceOf(Error);
|
||
expect((received as Error).message).toBe("boom");
|
||
});
|
||
|
||
it("uses sanitized original stacks and causes instead of the reporting stack", async () => {
|
||
const { sentryModule, captureException } = await importFreshSentryWithGateOpen();
|
||
sentryModule.captureRunFailure(baseEvent({ diagnostics: {
|
||
execution: { runtimeMode: "native", failurePhase: "setup" },
|
||
adapter: {}, provider: {}, truncatedFields: [],
|
||
exceptions: [
|
||
{ name: "TypeError", message: "setup failed", stack: "TypeError: setup failed\n at originalSetup (/app/setup.js:42:7)" },
|
||
{ name: "Error", message: "connection reset", stack: "Error: connection reset\n at socketRead (/app/network.js:9:4)", code: "ECONNRESET", requestId: "req-123" },
|
||
],
|
||
} }));
|
||
const [error, context] = captureException.mock.calls[0]!;
|
||
expect(error).toBeInstanceOf(Error);
|
||
expect(error).toMatchObject({ name: "TypeError", message: "setup failed", cause: { message: "connection reset" } });
|
||
expect((error as Error).stack).toContain("originalSetup");
|
||
expect((error as Error).stack).not.toContain("captureRunFailure");
|
||
expect(context?.contexts.run_exception_1).toMatchObject({ code: "ECONNRESET", requestId: "req-123" });
|
||
expect(context?.contexts.run_execution).toMatchObject({ failurePhase: "setup" });
|
||
});
|
||
|
||
it("does not invent a reporter stack for a saved result with no original exception", async () => {
|
||
const { sentryModule, captureException } = await importFreshSentryWithGateOpen();
|
||
sentryModule.captureRunFailure(baseEvent());
|
||
expect((captureException.mock.calls[0]![0] as Error).stack).toBeUndefined();
|
||
});
|
||
|
||
it("does not throw and captures nothing when the gate is closed", async () => {
|
||
vi.resetModules();
|
||
const sentryModule = await import("../sentry.js");
|
||
await sentryModule.sentryReady;
|
||
|
||
expect(() => sentryModule.captureRunFailure(baseEvent())).not.toThrow();
|
||
});
|
||
|
||
it("does not throw when the client throws", async () => {
|
||
const { sentryModule, captureException } = await importFreshSentryWithGateOpen();
|
||
captureException.mockImplementation(() => {
|
||
throw new Error("sentry client is down");
|
||
});
|
||
|
||
expect(() => sentryModule.captureRunFailure(baseEvent())).not.toThrow();
|
||
});
|
||
});
|