mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-31 22:24:16 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - The server records the state of each agent run. > - Terminal run failures need clear error tracking for operators. > - The server did not report terminal `failed` or `timed_out` transitions to Sentry. > - This pull request reports each genuine terminal failure transition with safe diagnostic data. > - The benefit is faster diagnosis without changing run control flow or exposing credentials. ## Linked Issues or Issue Description **What happened?** The server wrote terminal run failures but did not report them to Sentry. Operators could not see these failures in error tracking. **Expected behavior** The server should report each genuine transition to `failed` or `timed_out` to Sentry. **Steps to reproduce** 1. Run an agent task that reaches a terminal failure state. 2. Inspect the Sentry events for the server. 3. Observe that the terminal run failure has no matching Sentry event. **Paperclip version or commit** The change targets the current `master` branch. No public GitHub issue or pull request covers this change. ## What Changed - Add `captureRunFailure()` as a fail-open Sentry entry point. - Add `reportRunFailure()` to filter status, resolve the adapter, redact text, and report the failure. - Call `reportRunFailure()` beside each of the eight terminal status writers. - Report six diagnostic values: the instance host, task identifier, run identifier, error message, error code, and agent adapter. - Group events by error code and agent adapter while keeping the redacted message in the event. - Report only genuine transitions and avoid duplicate finalization events. - Keep Sentry failures outside run control flow. ## Verification - `pnpm vitest run server/src/services/__tests__/run-failure-report.test.ts server/src/__tests__/run-failure-sentry.test.ts server/src/__tests__/native-session-resumption.test.ts` - `pnpm vitest run server/src/services/execution-control-reconciliation.test.ts` - `pnpm --filter @paperclipai/server typecheck` - The full continuous-integration suite must run on this pull request. ## Risks - The report path can add diagnostic events when Sentry is configured. - The report path returns without action when Sentry is not configured. - Redaction runs before length limits and before the event leaves the process. - The change has no migration and no schema change. ## Model Used OpenAI Codex, GPT-5, tool use and code review support. The exact context window and reasoning configuration are not exposed by the runtime. ## 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>
189 lines
6.9 KiB
TypeScript
189 lines
6.9 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 `withScope`/`captureException` spies record 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,
|
|
};
|
|
}
|
|
|
|
/** Mirrors `sentry.test.ts`'s `mockSentryPackage`, plus a `withScope` spy. */
|
|
function mockSentryPackage() {
|
|
const scope = {
|
|
setTag: vi.fn(),
|
|
setContext: vi.fn(),
|
|
setFingerprint: vi.fn(),
|
|
};
|
|
const init = vi.fn();
|
|
const captureException = vi.fn(() => "event-id");
|
|
const withScope = vi.fn((callback: (s: typeof scope) => void) => callback(scope));
|
|
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,
|
|
captureException,
|
|
withScope,
|
|
close,
|
|
httpIntegration,
|
|
onUnhandledRejectionIntegration,
|
|
}));
|
|
vi.doMock("../peer-version-check.js", () => ({
|
|
checkExactPeerVersions: () => ({ ok: true }),
|
|
}));
|
|
|
|
return { scope, init, captureException, withScope, 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(() => {
|
|
delete process.env[DSN_ENV];
|
|
delete process.env[FRONTEND_DSN_ENV];
|
|
delete process.env[BACKEND_DSN_ENV];
|
|
});
|
|
|
|
afterEach(() => {
|
|
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("sets the fingerprint [errorCode, agentAdapter] in that order", async () => {
|
|
const { sentryModule, scope } = await importFreshSentryWithGateOpen();
|
|
|
|
sentryModule.captureRunFailure(baseEvent({ errorCode: "timeout", agentAdapter: "codex" }));
|
|
|
|
expect(scope.setFingerprint).toHaveBeenCalledWith(["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, scope } = await importFreshSentryWithGateOpen();
|
|
|
|
sentryModule.captureRunFailure(
|
|
baseEvent({ errorMessage: "message one", errorCode: "timeout", agentAdapter: "codex" }),
|
|
);
|
|
sentryModule.captureRunFailure(
|
|
baseEvent({ errorMessage: "message two", errorCode: "timeout", agentAdapter: "codex" }),
|
|
);
|
|
|
|
expect(scope.setFingerprint).toHaveBeenNthCalledWith(1, ["timeout", "codex"]);
|
|
expect(scope.setFingerprint).toHaveBeenNthCalledWith(2, ["timeout", "codex"]);
|
|
});
|
|
|
|
it("sets the fingerprint element \"unknown\" when the error code is absent", async () => {
|
|
const { sentryModule, scope } = await importFreshSentryWithGateOpen();
|
|
|
|
sentryModule.captureRunFailure(baseEvent({ errorCode: null }));
|
|
|
|
expect(scope.setFingerprint).toHaveBeenCalledWith(["unknown", "claude-code"]);
|
|
});
|
|
|
|
it("sets the five diagnostic values on the run_failure context", async () => {
|
|
const { sentryModule, scope } = await importFreshSentryWithGateOpen();
|
|
const event = baseEvent();
|
|
|
|
sentryModule.captureRunFailure(event);
|
|
|
|
expect(scope.setContext).toHaveBeenCalledWith("run_failure", {
|
|
taskId: event.taskId,
|
|
runId: event.runId,
|
|
errorMessage: event.errorMessage,
|
|
errorCode: event.errorCode,
|
|
agentAdapter: event.agentAdapter,
|
|
});
|
|
});
|
|
|
|
it("does not set an instance key on the run_failure context", async () => {
|
|
const { sentryModule, scope } = await importFreshSentryWithGateOpen();
|
|
|
|
sentryModule.captureRunFailure(baseEvent());
|
|
|
|
const [, context] = scope.setContext.mock.calls[0]!;
|
|
expect(context).not.toHaveProperty("instance");
|
|
});
|
|
|
|
it("sets run_id, task_id, error_code, agent_adapter, and run_status as tags", async () => {
|
|
const { sentryModule, scope } = await importFreshSentryWithGateOpen();
|
|
const event = baseEvent();
|
|
|
|
sentryModule.captureRunFailure(event);
|
|
|
|
expect(scope.setTag).toHaveBeenCalledWith("run_id", event.runId);
|
|
expect(scope.setTag).toHaveBeenCalledWith("task_id", event.taskId);
|
|
expect(scope.setTag).toHaveBeenCalledWith("error_code", event.errorCode);
|
|
expect(scope.setTag).toHaveBeenCalledWith("agent_adapter", event.agentAdapter);
|
|
expect(scope.setTag).toHaveBeenCalledWith("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("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, withScope } = await importFreshSentryWithGateOpen();
|
|
withScope.mockImplementation(() => {
|
|
throw new Error("sentry client is down");
|
|
});
|
|
|
|
expect(() => sentryModule.captureRunFailure(baseEvent())).not.toThrow();
|
|
});
|
|
});
|