fix(sentry): keep low-trust project denials on the task (#19531)

Keep exact pre-dispatch project access denials on their task instead of the unexpected Sentry failure stream. Validation: 301 focused tests and all applicable CI pass.

Greptile scored the exact head 5/5, and no review threads remain unresolved.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Devin FoleyandPaperclip authored and GitHub committed 2026-11-06 12:04:51 -08:00
1 parent 24508a8ba5
commit 91b641757b
3 files changed
+150

No files matched your search

@@ -0,0 +1,128 @@
import { randomUUID } from "node:crypto";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { agents, applyPendingMigrations, closeRegisteredClients, companies, createDb, heartbeatRuns } from "@paperclipai/db";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "../../__tests__/helpers/embedded-postgres.js";
const mockCaptureRunFailure = vi.hoisted(() => vi.fn());
vi.mock("../../sentry.js", () => ({
captureRunFailure: mockCaptureRunFailure,
}));
import { reportRunFailure } from "../run-failure-report.js";
import { PROJECT_ACCESS_DENIED } from "../project-access-denial.js";
const testDatabaseUrl = process.env.PAPERCLIP_TEST_DATABASE_URL;
const embeddedPostgresSupport = testDatabaseUrl ? { supported: true } : await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
describeEmbeddedPostgres("reportRunFailure low-trust project denials", () => {
let db!: ReturnType<typeof createDb>;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
let companyId!: string;
let agentId!: string;
let inheritedEnv: NodeJS.ProcessEnv;
beforeAll(async () => {
if (testDatabaseUrl) {
await applyPendingMigrations(testDatabaseUrl);
db = createDb(testDatabaseUrl);
} else {
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-run-failure-project-access-");
db = createDb(tempDb.connectionString);
}
}, 20_000);
beforeEach(async () => {
inheritedEnv = process.env;
process.env = Object.fromEntries(
["PATH", "HOME", "USER", "USERNAME", "LOGNAME", "USERPROFILE", "TMPDIR", "TEMP", "TMP"]
.flatMap((key) => inheritedEnv[key] === undefined ? [] : [[key, inheritedEnv[key]]]),
);
process.env.PAPERCLIP_SENTRY_RUN_FAILURES = "unexpected";
companyId = randomUUID();
agentId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
defaultResponsibleUserId: "responsible-user",
});
await db.insert(agents).values({
id: agentId,
companyId,
name: "OpenCodeCoder",
role: "engineer",
status: "active",
adapterType: "opencode_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
});
});
afterEach(async () => {
vi.clearAllMocks();
vi.unstubAllEnvs();
process.env = inheritedEnv;
await db.delete(heartbeatRuns);
await db.delete(agents);
await db.delete(companies);
});
afterAll(async () => {
if (testDatabaseUrl) await closeRegisteredClients(testDatabaseUrl);
await tempDb?.cleanup();
});
function projectAccessDeniedRun(
overrides: Partial<typeof heartbeatRuns.$inferSelect> = {},
): typeof heartbeatRuns.$inferSelect {
return {
id: randomUUID(),
companyId,
agentId,
status: "failed",
error: "Project is outside this low-trust boundary.",
errorCode: PROJECT_ACCESS_DENIED,
executionStage: "preparing",
nativeIssueId: randomUUID(),
contextSnapshot: null,
resultJson: {
projectAccessDenied: { projectId: randomUUID() },
executionRecovery: { kind: "bootstrap", providerWorkStarted: false },
},
...overrides,
} as unknown as typeof heartbeatRuns.$inferSelect;
}
it("keeps a low-trust project denial before dispatch on the task", async () => {
await reportRunFailure(db, projectAccessDeniedRun(), { phase: "setup" });
expect(mockCaptureRunFailure).not.toHaveBeenCalled();
});
it.each([
{ status: "timed_out" }, { errorCode: "setup_failed" }, { executionStage: "executing" }, { exitCode: 1 }, { signal: "SIGTERM" },
{ resultJson: { projectAccessDenied: { projectId: randomUUID() } } },
{ resultJson: { projectAccessDenied: { projectId: randomUUID() }, executionRecovery: { kind: "bootstrap", providerWorkStarted: true } } },
{ resultJson: { projectAccessDenied: {}, executionRecovery: { kind: "bootstrap", providerWorkStarted: false } } },
])("reports project denial markers without an exact pre-dispatch outcome: %j", async (overrides) => {
await reportRunFailure(db, projectAccessDeniedRun(overrides), { phase: "setup" });
expect(mockCaptureRunFailure).toHaveBeenCalledTimes(1);
});
it.each([undefined, "execute"] as const)("reports project denial markers outside the setup report: %s", async (phase) => {
await reportRunFailure(db, projectAccessDeniedRun(), { phase });
expect(mockCaptureRunFailure).toHaveBeenCalledTimes(1);
});
it("reports project denials when every run failure is reported", async () => {
vi.stubEnv("PAPERCLIP_SENTRY_RUN_FAILURES", "all");
await reportRunFailure(db, projectAccessDeniedRun(), { phase: "setup" });
expect(mockCaptureRunFailure).toHaveBeenCalledTimes(1);
});
});
+14
View File
@@ -17,6 +17,7 @@ import { readRunFailureReportingMode, type RunFailureReportingMode } from "../se
import { RESTRICTED_WORKSPACE_SOURCE_REASON } from "./workspace-source-restriction.js";
import { PLUGIN_RUNNER_AI_CONNECTION_REQUIRED } from "./plugin-runner-ai-connection.js";
import { NATIVE_PROVIDER_OVERLOADED_CODE } from "./native-runtime/native-provider-failure.js";
import { PROJECT_ACCESS_DENIED } from "./project-access-denial.js";
type HeartbeatRun = typeof heartbeatRuns.$inferSelect;
@@ -173,6 +174,18 @@ function isPluginRunnerAiConnectionRequiredBlocker(run: HeartbeatRun, options: R
recovery?.kind === "bootstrap" && recovery.providerWorkStarted === false;
}
function isProjectAccessDenialBlocker(run: HeartbeatRun, options: RunFailureReportOptions): boolean {
if (
run.status !== "failed" || run.errorCode !== PROJECT_ACCESS_DENIED ||
run.executionStage !== "preparing" || options.phase !== "setup" ||
run.exitCode != null || run.signal != null
) return false;
const denial = asRecord(run.resultJson?.projectAccessDenied);
const recovery = asRecord(run.resultJson?.executionRecovery);
return typeof denial?.projectId === "string" &&
recovery?.kind === "bootstrap" && recovery.providerWorkStarted === false;
}
/** Only the managed-clone producer can attach this bounded pre-dispatch evidence. */
function isGitConnectionFailure(run: HeartbeatRun, options: RunFailureReportOptions): boolean {
const connection = readConnectionFailure(run.resultJson?.connectionFailure);
@@ -333,6 +346,7 @@ export function reportRunFailure(db: Db, run: HeartbeatRun, options: RunFailureR
isLocalPathWorkspaceConfigurationBlocker(run, options) ||
isRestrictedWorkspaceSourceBlocker(run, options) ||
isPluginRunnerAiConnectionRequiredBlocker(run, options) ||
isProjectAccessDenialBlocker(run, options) ||
isGitConnectionFailure(run, options) ||
isProviderQuotaFailure(run, options) ||
isCodexAcpModelCapacityFailure(run, options) ||