mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 00:05:49 +02:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Agent runs retain execution results and output in the instance. > - Operators can report run failures to Sentry for investigation. > - A backend Sentry DSN currently enables those reports with no separate choice. > - A repair bot also needs the exact instance, company, agent, and saved run. > - This pull request adds an explicit reporting mode and authenticated run references. > - The benefit is that operators can investigate every failure on selected deployments while keeping other deployments quiet. ## Linked Issues or Issue Description **What existing behavior does this improve?** Optional server reporting of terminal agent run failures to Sentry. **Current behavior** A backend DSN enables run failure reports. Known account, quota, and connection failures are excluded. Reports omit company and agent references. Runs without a task are skipped. Tool-name collision errors omit the conflicting bindings. **Proposed behavior** Set `PAPERCLIP_SENTRY_RUN_FAILURES=unexpected` to retain the existing filters. Set it to `all` to report every failed or timed-out run, including known setup and quota failures. The default is `off`. Expected stops stay excluded. Each report supplies identifiers and API paths that a bot can use with its own authorized Paperclip connection. **Reason and benefit** Operators can enable broad failure reporting on one staging deployment. A bot can fetch the saved run, events, and logs instead of relying on a short error label or copying complete transcripts into Sentry. **Breaking changes** A backend DSN alone no longer enables run failure reports. Existing operators must set `PAPERCLIP_SENTRY_RUN_FAILURES=unexpected` to retain the previous policy. Ordinary server exception capture remains enabled by the DSN. Failure grouping gains the phase and original cause code when available. Refs #16281, #16297, #16298. These cover related tool discovery and failure diagnostics. This change retains their existing boundaries and does not depend on their open changes. ## What Changed - Add `off`, `unexpected`, and `all` modes. Missing and invalid values keep reporting off before diagnostic reads. - Keep the Sentry backend DSN and audited optional SDK requirements. - Include company, agent, run, and optional task identifiers. Include versioned paths for the saved run, events, log, and task. Add absolute run URLs only for a valid public HTTP(S) origin. - Report company runs without a task. Prefer the first-class issue binding when selecting the task reference. - Select saved native provider/model settings, timestamps, and original failure codes. Preserve gateway reason codes through redacted exception projection. - Group by error code, adapter, and available phase/cause. Keep run IDs and error text out of grouping. - Include the tool names and connection bindings in collision errors without copying tool schemas or provider metadata. - Document configuration, upgrade behavior, bot access, redaction, retention, and best-effort delivery. ## Verification - All 56 checks passed for `cccb47a721eaa1cc36e63278fc177b9101477541`. Two unrelated Storybook jobs were skipped by their path filters. No checks remain pending. - Complete [Paperclip CI run](https://github.com/paperclipai/paperclip/actions/runs/38591422284): passed. This covers general and serialized tests, Runner checks, browser E2E shards, typecheck, build, release-registry checks, canary dry run, and Docker checks. - Greptile reviewed that exact head: 5/5, with no unresolved threads or actionable findings. Superagent, Socket, and Snyk checks passed. - Local focused reporting, diagnostics, collision, and real SDK contract tests: 410 passed across five suites. This includes the external-wake filter in both reporting modes. - The audited `@sentry/node@10.71.0` tests use an in-memory transport. No events were sent to Sentry. - `pnpm -r typecheck` and `pnpm build`: passed locally after the final rebase. - `node scripts/check-module-boundaries.mjs` and `git diff --check`: passed. - The full local `pnpm test:run` command was stopped to avoid duplicating the complete CI shards. It did not finish locally. The full suite passed in CI. ## Risks - The new default disables existing run failure exports until the operator selects a mode. The upgrade setting is documented. - `all` can increase event volume and include known user-action failures. Secret redaction and diagnostic limits still apply. - Reporting remains asynchronous and best effort. This PR adds no durable delivery queue and does not install a bot or Sentry alert rule. - API references grant no authority. Existing company, private-task, and run-output checks still apply. Complete transcripts stay in Paperclip. - This changes optional Sentry error reporting. It changes neither first-party Telemetry nor OpenTelemetry span exports. It adds no database migration. ## Model Used - OpenAI GPT-6 through Codex, with reasoning, shell, file editing, and test execution. The exact backend deployment ID and context window 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 - [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>
194 lines
9.7 KiB
TypeScript
194 lines
9.7 KiB
TypeScript
import { enableRunFailureReporting } from "../__tests__/helpers/run-failure-reporting.js";
|
|
import { randomUUID } from "node:crypto";
|
|
import { eq } from "drizzle-orm";
|
|
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
|
import { agents, companies, completionContracts, createDb, heartbeatRuns, issues, issueRecoveryActions, issueThreadInteractions, nativeRunResults, statusDecisions, workAssessments } from "@paperclipai/db";
|
|
import {
|
|
getEmbeddedPostgresTestSupport,
|
|
startEmbeddedPostgresTestDatabase,
|
|
} from "../__tests__/helpers/embedded-postgres.js";
|
|
|
|
const mockCaptureRunFailure = vi.hoisted(() => vi.fn());
|
|
vi.mock("../sentry.js", async () => {
|
|
const actual = await vi.importActual<typeof import("../sentry.js")>("../sentry.js");
|
|
return {
|
|
...actual,
|
|
captureRunFailure: mockCaptureRunFailure,
|
|
};
|
|
});
|
|
|
|
import { reconcileAbandonedExecutionControl } from "./execution-control-reconciliation.js";
|
|
|
|
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
|
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
|
|
|
describeEmbeddedPostgres("reconcileAbandonedExecutionControl reports a genuine failed transition", () => {
|
|
let db!: ReturnType<typeof createDb>;
|
|
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
|
|
|
beforeAll(async () => {
|
|
tempDb = await startEmbeddedPostgresTestDatabase("execution-control-reconciliation-");
|
|
db = createDb(tempDb.connectionString);
|
|
}, 30_000);
|
|
|
|
afterAll(async () => {
|
|
await tempDb?.cleanup();
|
|
});
|
|
|
|
async function seedAbandonedRunFixture() {
|
|
const companyId = randomUUID();
|
|
const agentId = randomUUID();
|
|
const issueId = randomUUID();
|
|
const runId = randomUUID();
|
|
const pastDeadline = new Date(Date.now() - 60_000);
|
|
|
|
await db.insert(companies).values({
|
|
id: companyId,
|
|
name: "Execution Control Reconciliation",
|
|
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
|
});
|
|
await db.insert(agents).values({
|
|
id: agentId,
|
|
companyId,
|
|
name: "Stuck worker",
|
|
adapterType: "codex_local",
|
|
status: "running",
|
|
});
|
|
await db.insert(heartbeatRuns).values({
|
|
id: runId,
|
|
companyId,
|
|
agentId,
|
|
status: "running",
|
|
executionControlDeadlineAt: pastDeadline,
|
|
contextSnapshot: { issueId },
|
|
});
|
|
await db.insert(issues).values({
|
|
id: issueId,
|
|
companyId,
|
|
title: "Abandoned execution control fixture",
|
|
status: "in_progress",
|
|
assigneeAgentId: agentId,
|
|
executionRunId: runId,
|
|
checkoutRunId: runId,
|
|
});
|
|
|
|
return { companyId, agentId, issueId, runId };
|
|
}
|
|
|
|
it("reports exactly one Sentry event for a genuine finalization-deadline failure", async () => {
|
|
const { runId } = await seedAbandonedRunFixture();
|
|
const captureCallsBefore = mockCaptureRunFailure.mock.calls.length;
|
|
|
|
const result = await reconcileAbandonedExecutionControl(db);
|
|
|
|
expect(result.surfaced).toBe(1);
|
|
const [run] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, runId));
|
|
expect(run?.status).toBe("failed");
|
|
expect(run?.errorCode).toBe("execution_finalization_deadline_exceeded");
|
|
|
|
await vi.waitFor(() => {
|
|
expect(mockCaptureRunFailure.mock.calls.slice(captureCallsBefore)).toHaveLength(1);
|
|
}, { timeout: 5_000 });
|
|
const newCaptures = mockCaptureRunFailure.mock.calls.slice(captureCallsBefore);
|
|
expect(newCaptures[0]?.[0]).toMatchObject({
|
|
runId,
|
|
runStatus: "failed",
|
|
errorCode: "execution_finalization_deadline_exceeded",
|
|
});
|
|
});
|
|
|
|
it("reports zero events for a repeated sweep over the same already-failed run", async () => {
|
|
const { runId } = await seedAbandonedRunFixture();
|
|
await reconcileAbandonedExecutionControl(db);
|
|
// The first report is fire-and-forget. Observe it before taking the
|
|
// second sweep's baseline, so a late first report is not a duplicate.
|
|
await vi.waitFor(() => {
|
|
expect(mockCaptureRunFailure.mock.calls.filter(([event]) => event.runId === runId)).toHaveLength(1);
|
|
}, { timeout: 5_000 });
|
|
// The first sweep already cleared executionControlDeadlineAt and moved the
|
|
// run to "failed". Restore the deadline to simulate a second sweep still
|
|
// observing the same run as a candidate.
|
|
await db
|
|
.update(heartbeatRuns)
|
|
.set({ executionControlDeadlineAt: new Date(Date.now() - 1_000) })
|
|
.where(eq(heartbeatRuns.id, runId));
|
|
|
|
const captureCallsBefore = mockCaptureRunFailure.mock.calls.length;
|
|
const result = await reconcileAbandonedExecutionControl(db);
|
|
|
|
// The run is already terminal ("failed"), so the early terminal-status
|
|
// guard applies and no second "failed" write happens.
|
|
expect(result.surfaced).toBe(1);
|
|
expect(mockCaptureRunFailure.mock.calls.slice(captureCallsBefore)).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
describeEmbeddedPostgres("native review execution reconciliation admission", () => {
|
|
let db!: ReturnType<typeof createDb>;
|
|
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
|
beforeAll(async () => {
|
|
tempDb = await startEmbeddedPostgresTestDatabase("execution-control-review-");
|
|
db = createDb(tempDb.connectionString);
|
|
}, 30_000);
|
|
afterAll(async () => { await tempDb?.cleanup(); });
|
|
|
|
it.each([true, false])("only releases the exact admitted review (admitted=%s)", async (admitted) => {
|
|
const companyId = randomUUID();
|
|
const workerId = randomUUID();
|
|
const reviewerId = randomUUID();
|
|
const issueId = randomUUID();
|
|
const runId = randomUUID();
|
|
const sourceRunId = randomUUID();
|
|
const decisionId = randomUUID();
|
|
const interactionId = randomUUID();
|
|
const now = new Date();
|
|
await db.insert(companies).values({ id: companyId, name: "Reviewer recovery", issuePrefix: admitted ? "RRA" : "RRN" });
|
|
await db.insert(agents).values([
|
|
{ id: workerId, companyId, name: "Worker", adapterType: "codex_local", status: "idle" },
|
|
{ id: reviewerId, companyId, name: "Reviewer", adapterType: "codex_local", status: "running" },
|
|
]);
|
|
await db.insert(heartbeatRuns).values({
|
|
id: runId, companyId, agentId: reviewerId, status: "running", runtimeMode: "native",
|
|
nativeIssueId: issueId, executionControlDeadlineAt: new Date(now.getTime() - 60_000),
|
|
contextSnapshot: { issueId, ...(admitted ? { nativeReviewInteractionId: interactionId, nativeReviewDecisionId: decisionId } : {}) },
|
|
});
|
|
await db.insert(heartbeatRuns).values({ id: sourceRunId, companyId, agentId: workerId, status: "succeeded", runtimeMode: "native", nativeIssueId: issueId });
|
|
await db.insert(issues).values({
|
|
id: issueId, companyId, title: "Review task", status: "in_review", statusVersion: 1,
|
|
lastStatusDecisionId: decisionId, assigneeAgentId: workerId, executionRunId: runId, checkoutRunId: runId,
|
|
});
|
|
const contractId = randomUUID();
|
|
const resultId = randomUUID();
|
|
const assessmentId = randomUUID();
|
|
await db.insert(completionContracts).values({ id: contractId, companyId, issueId, revision: 1, schemaVersion: "1", policyVersion: "test", risk: "low", completionAuthority: "agent_claim_policy", incompleteCriteriaPolicy: "block", contractJson: {}, canonicalSha256: randomUUID(), createdByActorType: "agent", createdByActorId: workerId });
|
|
await db.update(heartbeatRuns).set({ completionContractId: contractId }).where(eq(heartbeatRuns.id, sourceRunId));
|
|
await db.insert(nativeRunResults).values({ id: resultId, companyId, issueId, runId: sourceRunId, completionContractId: contractId, serverFingerprint: randomUUID(), schemaStatus: "valid", resultJson: {}, canonicalSha256: randomUUID() });
|
|
await db.insert(workAssessments).values({ id: assessmentId, companyId, issueId, runId: sourceRunId, contractId, resultId, triggerKind: "test", triggerActorCompanyId: companyId, priorIssueStatus: "in_progress", priorStatusVersion: 0, policyVersion: "test", assessmentJson: {}, inputDigest: randomUUID() });
|
|
await db.insert(statusDecisions).values({
|
|
id: decisionId, companyId, issueId, runId: sourceRunId, assessmentId: assessmentId, decisionVersion: 1,
|
|
policyVersion: "test", fromStatus: "in_progress", toStatus: "in_review", reasonCode: "completion_review",
|
|
decisionJson: { projectedStatusVersion: 1 }, decisionDigest: randomUUID(), applicationState: "applied",
|
|
});
|
|
await db.insert(issueThreadInteractions).values({
|
|
id: interactionId, companyId, issueId, kind: "request_confirmation", status: "pending",
|
|
continuationPolicy: "wake_assignee", requestedResolverPolicy: "anyone", effectiveResolverPolicy: "anyone",
|
|
resolverPolicyProvenance: "inherited", sourceRunId: sourceRunId, addresseeAgentId: reviewerId,
|
|
payload: { version: 1, prompt: "Review", target: { type: "custom", key: "native_completion_review", revisionId: decisionId } },
|
|
});
|
|
await reconcileAbandonedExecutionControl(db, now);
|
|
await reconcileAbandonedExecutionControl(db, now);
|
|
const [updatedIssue] = await db.select().from(issues).where(eq(issues.id, issueId));
|
|
const [updatedRun] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, runId));
|
|
const [review] = await db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.id, interactionId));
|
|
expect(updatedIssue).toMatchObject({ assigneeAgentId: workerId, executionRunId: admitted ? null : runId, checkoutRunId: admitted ? null : runId });
|
|
const recovery = await db.select().from(issueRecoveryActions).where(eq(issueRecoveryActions.companyId, companyId));
|
|
expect(recovery).toHaveLength(admitted ? 1 : 0);
|
|
if (admitted) expect(recovery[0]).toMatchObject({ maxAttempts: 3, wakePolicy: null, returnOwnerAgentId: workerId });
|
|
expect(updatedRun?.status).toBe("failed");
|
|
expect(review?.status).toBe("pending");
|
|
});
|
|
});
|
|
|
|
|
|
enableRunFailureReporting();
|