mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 08:22:20 +02:00
fix(sentry): report a native retry failure only when the retry ends (#16865)
## Thinking Path
> - Paperclip is the open source app people use to manage AI agents for
work.
> - The server sends a Sentry `run_failure` event when an agent run
reaches a terminal failure.
> - A native run that waits for a same-run retry is stored as `failed`
with `nativePhase: "retryable_failure"`. That state is not terminal.
> - `reportRunFailure` reported that pending state. Runs that later
recovered, for example after a workspace copy-back retry, still sent a
run failure event.
> - These events make one catch-all Sentry group noisy and hide the real
terminal failures in it.
> - This pull request skips the pending retry state and reports once
when the retry ends as a terminal failure.
> - The benefit is that run failure events match runs that really
failed.
## Linked Issues or Issue Description
**What happened?**
Two staging runs had a workspace copy-back failure during settlement
(`Daytona syncOut tar command failed ... No space left on device`). The
executor stored each run as `failed` with `nativePhase:
"retryable_failure"` and `errorCode: native_session_interrupted`. Sentry
received a `run_failure` event for each run. About two minutes later,
the copy-back retry succeeded and both runs finished as `succeeded` with
`workspaceFinalizeStatus: "succeeded"`.
**Expected behavior**
A run that waits for a same-run retry must not send a terminal run
failure event. If the retry ends as a terminal failure, the server must
send one event at that time.
**Steps to reproduce**
1. Set `PAPERCLIP_SENTRY_RUN_FAILURES=unexpected`.
2. Start a native run that fails retryably. For example, make the
workspace copy-back fail once.
3. See the `run_failure` event in Sentry, and then see the run finish as
`succeeded` after the retry.
## What Changed
- `reportRunFailure` returns without a report for a native run that is
`failed` with `nativePhase: "retryable_failure"`.
- New `isNewTerminalRunFailure(before, after)`: a status change counts
as before. A failed run that leaves the pending retry state but stays
`failed` also counts.
- Three terminal writers can keep the status `failed` → `failed`. They
now use `isNewTerminalRunFailure`, so they still report once:
- the resume reconciler's ambiguous-state write;
- the restart recovery `blocked` write;
- the committed-result projection (`projectCommittedRun`).
- Result-less retries already reset the run to `running` before they
start, so their terminal write still reports through the existing
status-change guard.
- `doc/observability.md` describes the new rule.
## Verification
- `pnpm exec vitest run
src/services/__tests__/run-failure-report.test.ts
src/__tests__/run-failure-sentry.test.ts
src/__tests__/native-session-resumption.test.ts
src/services/native-runtime/native-run-finalizer-telemetry.test.ts
src/services/native-runtime/native-runner-restart-recovery.integration.test.ts
src/__tests__/heartbeat-status-preserving-telemetry.test.ts
src/services/execution-control-reconciliation.test.ts` (from `server/`):
7 files, 363 passed, 9 skipped.
- New tests:
- `run-failure-report.test.ts`: a pending native retry does not report,
and a `terminal_failure` run does. Unit cases cover
`isNewTerminalRunFailure`.
- `native-session-resumption.test.ts`: a sweep that ends an unreported
pending retry sends one event.
- `native-run-finalizer-telemetry.test.ts`: a committed failure for a
pending-retry row sends one event, and a replay sends none.
- `native-runner-restart-recovery.integration.test.ts`: a restart
recovery `blocked` write for a pending-retry row sends one event.
- I removed each of the three writer changes one at a time. The matching
new test failed each time (`expected [] to have a length of 1` / `to
deeply equal [ [ ObjectContaining{…} ] ]`).
- I did not run the full server `tsc --noEmit` locally. The local
container has a 4 GiB memory limit, and the process was killed (exit
137). CI runs the typecheck.
## Risks
- A run that stays in the pending retry state and never resumes no
longer sends a run failure event. The run stays visible on the board as
failed with a scheduled retry. A later terminal write still reports it.
- The finalizer's `failed` + `retryable_failure` write for a failed
provider result also stops reporting. The terminal write that ends that
retry reports instead: retry exhaustion in `recordRetryableFailure`
reports unconditionally, and commit goes through `finalizeNativeRun` or
`projectCommittedRun`.
- A run that was reported in the pending state before this change can
report once more when it becomes terminal after deploy. This affects
only runs in flight during the upgrade.
- There are no schema, migration, or API changes. Run status, retries,
and recovery do not change.
> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.
## Model Used
- Anthropic Claude Opus 5.5 (`claude-opus-5-5`) in Claude Code, with
tool use (shell, code search, test runs) and a read-only code
exploration subagent. Context window: the standard window for that
model.
## 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
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: devinfoley <139239+devinfoley@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
9 files changed
+155
-10
No files matched your search
@@ -715,7 +715,9 @@ backend Sentry DSN and the audited SDK. Set `PAPERCLIP_SENTRY_RUN_FAILURES` to:
|
||||
|
||||
Missing, blank, or unrecognized values keep run reporting off. Expected operator
|
||||
stops, shutdown interruptions, successful runs, and queued work do not report in
|
||||
either enabled mode. The mode does not change run status, retries, recovery,
|
||||
either enabled mode. A native run that is `failed` while it waits for a same-run
|
||||
retry (`nativePhase` `retryable_failure`) does not report either. It reports once
|
||||
if a later write ends the retry as a terminal failure. The mode does not change run status, retries, recovery,
|
||||
Telemetry, or OpenTelemetry export. It is process-wide, so an operator can enable
|
||||
it on one staging instance without enabling it on other deployments. Restart the
|
||||
server after changing deployment environment variables.
|
||||
|
||||
@@ -622,6 +622,46 @@ describe("P6-25 pre-result native session recovery", () => {
|
||||
|
||||
expect(mockCaptureRunFailure.mock.calls.slice(captureCallsBefore)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("sends one Sentry event when a sweep ends an unreported pending retry", async () => {
|
||||
const freshRunId = "79000000-0000-4000-8000-000000000103";
|
||||
await db.insert(issues).values({ id: freshRunId, companyId, title: "Independent failure report",
|
||||
status: "in_progress", assigneeAgentId: agentId });
|
||||
// The executor stores a scheduled same-run retry as failed + retryable_failure,
|
||||
// and reportRunFailure skips that write. The sweep's terminal write reports it.
|
||||
await db.insert(heartbeatRuns).values({
|
||||
id: freshRunId,
|
||||
companyId,
|
||||
agentId,
|
||||
nativeIssueId: freshRunId,
|
||||
status: "failed",
|
||||
nativePhase: "retryable_failure",
|
||||
runtimeMode: "native",
|
||||
runtimeModeResolvedAt: new Date(),
|
||||
runnerProfileJson: {
|
||||
nativeExecutionInput: {
|
||||
...persistedProfile.nativeExecutionInput,
|
||||
binding: { runId: freshRunId },
|
||||
},
|
||||
},
|
||||
contextSnapshot: { issueId: freshRunId },
|
||||
});
|
||||
await db.insert(nativeRunFinalizations).values({
|
||||
runId: freshRunId,
|
||||
companyId,
|
||||
issueId: freshRunId,
|
||||
phase: "retryable_failure",
|
||||
attempt: 1,
|
||||
});
|
||||
const captureCallsBefore = mockCaptureRunFailure.mock.calls.length;
|
||||
|
||||
await claimNativeSessionResumptions({ db, runnerInstanceId: "reaper", runIds: [freshRunId] });
|
||||
await waitForPendingRunFailureReports();
|
||||
|
||||
const newCaptures = mockCaptureRunFailure.mock.calls.slice(captureCallsBefore);
|
||||
expect(newCaptures).toHaveLength(1);
|
||||
expect(newCaptures[0]?.[0]).toMatchObject({ runId: freshRunId, runStatus: "failed" });
|
||||
});
|
||||
});
|
||||
|
||||
describe.each(["unchanged", "newer_active", "stale_idle"] as const)(
|
||||
|
||||
@@ -29,7 +29,7 @@ vi.mock("../../log-redaction.js", async (importOriginal) => {
|
||||
return { ...actual, redactCurrentUserText: mockRedactCurrentUserText };
|
||||
});
|
||||
|
||||
import { reportRunFailure, waitForPendingRunFailureReports } from "../run-failure-report.js";
|
||||
import { isNewTerminalRunFailure, reportRunFailure, waitForPendingRunFailureReports } from "../run-failure-report.js";
|
||||
import { controllerLeaseLossCancellation } from "../run-cancellation.js";
|
||||
import { REDACTED_EVENT_VALUE } from "../../redaction.js";
|
||||
|
||||
@@ -981,6 +981,26 @@ describeEmbeddedPostgres("reportRunFailure", () => {
|
||||
.toBe(`the provider process exited with code ${REDACTED_EVENT_VALUE}`);
|
||||
});
|
||||
|
||||
it("waits for the terminal write when a native run is failed pending a same-run retry", async () => {
|
||||
await seedCompanyAndAgent();
|
||||
await reportRunFailure(db, buildRun({ runtimeMode: "native", nativePhase: "retryable_failure" }));
|
||||
expect(mockCaptureRunFailure).not.toHaveBeenCalled();
|
||||
|
||||
await reportRunFailure(db, buildRun({ runtimeMode: "native", nativePhase: "terminal_failure" }));
|
||||
expect(mockCaptureRunFailure).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("treats leaving a pending native retry as a new terminal failure", () => {
|
||||
const pending = { status: "failed", runtimeMode: "native", nativePhase: "retryable_failure" } as const;
|
||||
expect(isNewTerminalRunFailure({ ...pending, status: "running", nativePhase: "observed" }, pending)).toBe(true);
|
||||
expect(isNewTerminalRunFailure(pending, { ...pending, nativePhase: "terminal_failure" })).toBe(true);
|
||||
expect(isNewTerminalRunFailure(pending, { ...pending, nativePhase: "committed" })).toBe(true);
|
||||
expect(isNewTerminalRunFailure(pending, pending)).toBe(false);
|
||||
expect(isNewTerminalRunFailure({ ...pending, nativePhase: null }, { ...pending, nativePhase: "terminal_failure" })).toBe(false);
|
||||
expect(isNewTerminalRunFailure({ ...pending, runtimeMode: "legacy" }, { ...pending, runtimeMode: "legacy", nativePhase: null }))
|
||||
.toBe(false);
|
||||
});
|
||||
|
||||
it("captures nothing for succeeded, cancelled, and interrupted", async () => {
|
||||
await seedCompanyAndAgent();
|
||||
for (const status of ["succeeded", "cancelled", "interrupted"] as const) {
|
||||
|
||||
@@ -35,7 +35,7 @@ import {
|
||||
import { issueRecoveryActionService } from "../issue-recovery-actions.js";
|
||||
import { issueService } from "../issues.js";
|
||||
import { emitAgentTaskRun } from "../agent-task-run-telemetry.js";
|
||||
import { reportRunFailure } from "../run-failure-report.js";
|
||||
import { isNewTerminalRunFailure, reportRunFailure } from "../run-failure-report.js";
|
||||
import { resumeNativeWorkspaceFinalization } from "./native-workspace-finalizer.js";
|
||||
import { dismissObsoleteNativePolicyReviews } from "./obsolete-policy-reviews.js";
|
||||
import {
|
||||
@@ -519,9 +519,10 @@ export async function claimNativeSessionResumptions(input: {
|
||||
}).where(eq(heartbeatRuns.id, row.run.id)).returning();
|
||||
// Only a genuine transition into "failed" is a new terminal failure.
|
||||
// A candidate that is already "failed" (the filter above admits
|
||||
// both "running" and "failed") must not send a second Sentry event.
|
||||
// both "running" and "failed") must not send a second Sentry event,
|
||||
// unless that earlier write was an unreported pending retry.
|
||||
terminalRunToEmit =
|
||||
updatedRun && updatedRun.status !== row.run.status ? updatedRun : null;
|
||||
updatedRun && isNewTerminalRunFailure(row.run, updatedRun) ? updatedRun : null;
|
||||
await issueService(tx as unknown as Db).update(
|
||||
row.coordinator.issueId,
|
||||
{ status: "blocked" },
|
||||
|
||||
@@ -19,7 +19,7 @@ import { readProcessStartedAt } from "../hot-restart.js";
|
||||
import { getServerInfoSnapshot } from "../../server-info.js";
|
||||
import { redactSensitiveText } from "../../redaction.js";
|
||||
import { issueRecoveryActionService } from "../issue-recovery-actions.js";
|
||||
import { reportRunFailure } from "../run-failure-report.js";
|
||||
import { isNewTerminalRunFailure, reportRunFailure } from "../run-failure-report.js";
|
||||
import { isNativeRunnerOwnershipHeld } from "./native-runner-ownership.js";
|
||||
import { nativeRestartInterruptedTurnId, type PersistedNativeSession } from "../../vendor/paperclip-runner/index.js";
|
||||
|
||||
@@ -895,7 +895,7 @@ export async function claimNativeRestartRecoveries(input: {
|
||||
})
|
||||
.where(eq(heartbeatRuns.id, row.run.id))
|
||||
.returning();
|
||||
if (updatedRun && updatedRun.status !== row.run.status) {
|
||||
if (updatedRun && isNewTerminalRunFailure(row.run, updatedRun)) {
|
||||
terminalRunToReport = updatedRun;
|
||||
}
|
||||
await tx
|
||||
|
||||
@@ -58,6 +58,7 @@ import { restoreNativeWorkspaceBestEffort } from "./native-workspace-best-effort
|
||||
import { reconcileNativeFinalizations } from "./native-finalization-reconciler.js";
|
||||
import { recoverLegacyUnsafeWorkspaceExports } from "./native-workspace-export-recovery.js";
|
||||
import { finalizeNativeRun, recordNativeFinalizationFailure } from "./native-run-finalizer.js";
|
||||
import { waitForPendingRunFailureReports } from "../run-failure-report.js";
|
||||
import { deliverExecutionStatuses } from "../execution-status-delivery.js";
|
||||
import { commitNativeStatusDecision } from "./status-decision-committer.js";
|
||||
import { NATIVE_STATUS_ARBITER_POLICY_VERSION, type NativeStatusDecision } from "./status-arbiter.js";
|
||||
@@ -555,6 +556,34 @@ describeEmbeddedPostgres("native run finalizer / status decision committer — a
|
||||
expect(replayed).toEqual(repaired);
|
||||
});
|
||||
|
||||
it("reports a committed failure once when the run row was an unreported pending retry", async () => {
|
||||
const fixture = await seedNativeRun();
|
||||
await driveToCompleteResult(fixture, {
|
||||
...CONTROL_PLANE_CONFORMANCE_TERMINAL,
|
||||
turnTerminalState: "failed",
|
||||
runTerminalState: "failed",
|
||||
});
|
||||
const finalize = () => finalizeNativeRun({ db, runId: fixture.runId, workspaceFinalizeStatus: "succeeded", projectRunStatus: true });
|
||||
await finalize();
|
||||
// The executor stores a scheduled same-run retry as failed + retryable_failure,
|
||||
// and reportRunFailure skips that write.
|
||||
await db.update(heartbeatRuns).set({
|
||||
nativePhase: "retryable_failure",
|
||||
}).where(eq(heartbeatRuns.id, fixture.runId));
|
||||
await waitForPendingRunFailureReports();
|
||||
const captureCallsBefore = mockCaptureRunFailure.mock.calls.length;
|
||||
|
||||
await finalize();
|
||||
await waitForPendingRunFailureReports();
|
||||
expect(captureRunFailureCallsFrom(captureCallsBefore)).toEqual([
|
||||
[expect.objectContaining({ runId: fixture.runId, runStatus: "failed" })],
|
||||
]);
|
||||
|
||||
await finalize();
|
||||
await waitForPendingRunFailureReports();
|
||||
expect(captureRunFailureCallsFrom(captureCallsBefore)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("retires this run's scheduled finalization retry after successful commit", async () => {
|
||||
const fixture = await seedNativeRun();
|
||||
await driveToCompleteResult(fixture, CONTROL_PLANE_CONFORMANCE_TERMINAL);
|
||||
|
||||
@@ -54,7 +54,7 @@ import {
|
||||
readNativeBoardResponseWaitSource,
|
||||
} from "./native-board-response-wait.js";
|
||||
import { emitAgentTaskRun } from "../agent-task-run-telemetry.js";
|
||||
import { reportRunFailure } from "../run-failure-report.js";
|
||||
import { isNewTerminalRunFailure, reportRunFailure } from "../run-failure-report.js";
|
||||
import { resolveExternalChatResponseWaitAuthorization } from "./chat-attachment-reuse.js";
|
||||
import {
|
||||
authorizeNativeChatReviewPresentation,
|
||||
@@ -720,10 +720,13 @@ async function projectCommittedRun(input: {
|
||||
)
|
||||
.returning();
|
||||
// Metadata repairs can preserve the terminal status. Only a genuine status
|
||||
// transition should emit another terminal event.
|
||||
// transition should emit another terminal event. A failed run that leaves a
|
||||
// pending retry still needs its first failure report.
|
||||
if (updatedRun && updatedRun.status !== input.run.status) {
|
||||
await emitAgentTaskRun(input.db, updatedRun);
|
||||
void reportRunFailure(input.db, updatedRun);
|
||||
} else if (updatedRun && isNewTerminalRunFailure(input.run, updatedRun)) {
|
||||
void reportRunFailure(input.db, updatedRun);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -50,6 +50,7 @@ import { materializeAsset } from "./runtime-context.js";
|
||||
import { createRunnerTestTlsTunnel, dockerCommandRunner } from "../../__tests__/helpers/docker-command-runner.js";
|
||||
import { createIsolatedTestHome } from "../../__tests__/helpers/isolated-test-home.js";
|
||||
import { finalizeNativeRun } from "./native-run-finalizer.js";
|
||||
import { waitForPendingRunFailureReports } from "../run-failure-report.js";
|
||||
import { closeIdleWarmNativeSessionsForRestart, executePaperclipNativeSession, NativeControllerDetachedForRestartError } from "./native-session-executor.js";
|
||||
|
||||
const mockCaptureRunFailure = vi.hoisted(() => vi.fn());
|
||||
@@ -1455,6 +1456,31 @@ describeEmbeddedPostgres("native runner restart recovery with real processes", (
|
||||
expect(await fixture.db.select().from(issueRecoveryActions).where(eq(issueRecoveryActions.sourceIssueId, fixture.issueId))).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("reports one event when it blocks a failed run that was an unreported pending retry", async () => {
|
||||
const fixture = await seedRun("PENDING-RETRY-BLOCKED");
|
||||
// The executor stores a scheduled same-run retry as failed + retryable_failure,
|
||||
// and reportRunFailure skips that write. The blocked write reports it.
|
||||
await fixture.db.update(heartbeatRuns).set({
|
||||
status: "failed",
|
||||
nativePhase: "retryable_failure",
|
||||
runnerProfileJson: { sessionCheckpoint: {
|
||||
terminal: { runTerminalState: "failed", turnTerminalState: "failed" },
|
||||
providerSessionId: "unusable-provider-session",
|
||||
} },
|
||||
}).where(eq(heartbeatRuns.id, fixture.runId));
|
||||
await fixture.db.update(nativeRunFinalizations).set({ attempt: 3 }).where(eq(nativeRunFinalizations.runId, fixture.runId));
|
||||
const captureCallsBefore = mockCaptureRunFailure.mock.calls.length;
|
||||
|
||||
expect(await claimNativeRestartRecoveries({
|
||||
db: fixture.db, controller: successor, restartKind: "hard", runIds: [fixture.runId],
|
||||
})).toEqual([{ kind: "blocked", runId: fixture.runId, reason: "provider_checkpoint_permanently_failed" }]);
|
||||
await waitForPendingRunFailureReports();
|
||||
|
||||
expect(mockCaptureRunFailure.mock.calls.slice(captureCallsBefore)).toEqual([
|
||||
[expect.objectContaining({ runId: fixture.runId, runStatus: "failed", errorCode: "native_restart_recovery_blocked" })],
|
||||
]);
|
||||
});
|
||||
|
||||
it("classifies every requested recovery candidate without an implicit 100-run cap", async () => {
|
||||
const db = createDb(temporary.connectionString);
|
||||
const fixtures: Array<Awaited<ReturnType<typeof seedRun>>> = [];
|
||||
|
||||
@@ -38,6 +38,27 @@ function isRunFailureStatus(status: string): status is RunFailureStatus {
|
||||
return status === "failed" || status === "timed_out" || status === "cancelled";
|
||||
}
|
||||
|
||||
type RunFailureTransitionState = Pick<HeartbeatRun, "status" | "runtimeMode" | "nativePhase">;
|
||||
|
||||
/**
|
||||
* A native run that waits for a same-run retry is stored as `failed` with
|
||||
* `nativePhase: "retryable_failure"`. That is not terminal: the retry or the
|
||||
* workspace copy-back retry can still finish the run as succeeded.
|
||||
*/
|
||||
function isPendingNativeRetry(run: RunFailureTransitionState): boolean {
|
||||
return run.status === "failed" && run.runtimeMode === "native" && run.nativePhase === "retryable_failure";
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a status write is a new terminal failure to pass to `reportRunFailure`.
|
||||
* A status change counts. A run that leaves a pending native retry but stays
|
||||
* `failed` also counts, because `reportRunFailure` skipped its earlier write.
|
||||
*/
|
||||
export function isNewTerminalRunFailure(before: RunFailureTransitionState, after: RunFailureTransitionState): boolean {
|
||||
if (after.status !== before.status) return true;
|
||||
return isPendingNativeRetry(before) && !isPendingNativeRetry(after);
|
||||
}
|
||||
|
||||
function readTaskId(run: HeartbeatRun): string | null {
|
||||
if (run.issueId) return run.issueId;
|
||||
if (run.nativeIssueId) return run.nativeIssueId;
|
||||
@@ -238,7 +259,8 @@ async function isRejectedExternalBotWake(db: Db, run: HeartbeatRun): Promise<boo
|
||||
|
||||
/**
|
||||
* Report a terminal run failure to Sentry. Returns at once for any status
|
||||
* other than failures and unexpected started cancellations. Never throws — a Sentry failure or a
|
||||
* other than failures and unexpected started cancellations, and for a native
|
||||
* run that still waits for a same-run retry. Never throws — a Sentry failure or a
|
||||
* database read failure must not change the caller's control flow.
|
||||
*
|
||||
* Call this beside the caller's own terminal-status write, with
|
||||
@@ -252,6 +274,8 @@ export function reportRunFailure(db: Db, run: HeartbeatRun, options: RunFailureR
|
||||
if (mode === "off") return Promise.resolve();
|
||||
if (!isRunFailureStatus(run.status)) return Promise.resolve();
|
||||
if (run.status === "cancelled" && !isUnexpectedRunCancellation(run)) return Promise.resolve();
|
||||
// The terminal write that ends the retry reports instead (`isNewTerminalRunFailure`).
|
||||
if (isPendingNativeRetry(run)) return Promise.resolve();
|
||||
if (mode === "unexpected" && (
|
||||
isMissingSecretConfigurationBlocker(run, options) ||
|
||||
isAiConnectionSelectionBlocker(run, options) ||
|
||||
|
||||
Reference in new issue
Block a user