mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-14 08:53:19 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Heartbeat run settlement records why a run failed, and the board shows that record. > - Runs that fail without a named code settle as `adapter_failed` (or `setup_failed`) with no cause. > - A user, an operator, and the restart chaos harness cannot tell what went wrong from such a run. > - Some run errors also show raw SQL statement text to users. > - This pull request records a bounded `failureCause` code for generic failures, shows it on the board, and keeps SQL text out of run errors. > - The benefit is that a generic failure now names its cause, for example "adapter failed (40001)", without leaking messages, SQL, or secrets. ## Linked Issues or Issue Description Refs #20385 (the nested-cause extraction this change reuses). Related: #20421 (restart chaos universal checks U1-U4). **What happened?** The restart chaos audit of 79 saved waves found 30 failed runs with `errorCode: "adapter_failed"` and a null or generic error. Every U3 ("diagnosable") warning was `adapter_failed`. Nobody could tell what failed. Some board run errors also showed raw SQL statement text from wrapped database errors (Drizzle `Failed query: ... params: ...`). **Expected behavior** A run that fails with a generic code names a short, safe cause. The board shows the cause next to the stop reason. Run error text never shows SQL statements or their parameters. **Steps to reproduce** Make an adapter throw a wrapped database error (an `Error` whose nested `cause` has `code: "40001"` and whose message has a Drizzle `Failed query:` statement). Before this change, the run settles as `adapter_failed` with no cause, and `heartbeat_runs.error` contains the SQL statement and parameters. The new tests in `server/src/services/heartbeat/run-completion.test.ts` reproduce this. ## What Changed - Runner: move the #20385 event-commit cause extraction into `safeErrorCauseCode` (`packages/paperclip-runner/src/control-plane/safe-error-cause.ts`) and export it. The Runner's own diagnostics use the same function, unchanged in behavior. - Shared: add the `failureCause` contract (`packages/shared/src/run-failure-cause.ts`): one `[A-Za-z0-9_.:-]` token of at most 80 characters, `normalizeRunFailureCause`, `readRunFailureCause`, and `withoutSqlStatementText`. - Server: add `server/src/services/heartbeat/run-failure-cause.ts`. Settlement records `resultJson.failureCause` only when the code is generic (`adapter_failed`, `setup_failed`). `errorCode` stays the same. - Thrown errors (`failRun`, `failRunSetup`): `provider_http_<status>`, else `provider_<category>`, else the first safe code in the error and up to three nested causes (`40001`, `ECONNRESET`), else the error class name, else `unknown_error`. - Adapter results without a code (`completeRun`): the error family, `signal_<name>`, `process_cancellation_failed`, `exit_code_<n>`, `adapter_reported_error`, or `unknown_error`. - The failure log lines (`heartbeat execution failed`, `heartbeat execution setup failed`, and a new `heartbeat run failed` warning for generic adapter results) include `failureCause` and `errorCode`. - Server: replace SQL statement text in run error text with "Database query failed (<cause>)" at all three settlement paths, including the cancelled branch. The execution blocker's `runError`, the attention inbox (failed-run excerpt and metadata), and continuation summaries apply the same replacement on read, so older rows also stop showing SQL there. - Server: Retry admission and failed-preparation replay accept only a closed set of `resultJson` keys. `failureCause` is added to each of those lists (`task-retry.ts`, `chat-channels/failed-run-retry.ts`, `cancelled-native-startup.ts`, `fresh-workspace-continuation.ts`). It is a diagnostic code with no execution or replay authority, so a failed-preparation run stays retryable. - Server: `runsForIssue` returns a validated top-level `failureCause`. It is null for older rows, non-string JSON values, and malformed values. - UI: `RunForIssue.failureCause`; `IssueRunLedger` shows "adapter failed (40001)". Older runs keep "adapter failed". The Storybook run-ledger fixture has a failed run with a cause. - Restart chaos harness: run samples keep `failureCause`. U3 treats a generic code with a specific `failureCause` as diagnosable. A bare `Error`, `unknown_error`, or the no-code fallback `adapter_reported_error` still counts as vague. U3 stays a warning. - Docs: `doc/run-log-events.md` and the harness README describe the field and the SQL replacement. ## Verification - Red before the fix: the four new `run-completion.test.ts` cases failed against `origin/master`'s `run-completion.ts` (no `failureCause`, SQL text in `error`). - Green: - `server/src/services/heartbeat/run-completion.test.ts`: all tests passed (real embedded PostgreSQL). New cases cover a nested `40001` three levels deep for execution and setup failures, SQL and a secret parameter absent from `error` and `resultJson`, the bound (an 81-character code and a SQL "code" fall back to the class name), `exit_code_137` for a code-less adapter result, a cancelled result with SQL text, no cause for a named code, and the log line. On this loaded host the suite's `afterAll` database teardown hit its 30 s hook limit after every test passed (the known local teardown limit, also seen in #20385). CI runs the suite. - `server/src/services/heartbeat/run-failure-cause.test.ts` (4), `server/src/__tests__/activity-service.test.ts` (recorded, legacy, malformed, overlong, boolean, and number cause projections), `server/src/__tests__/issue-continuation-summary.test.ts` (legacy SQL row). - `packages/shared/src/run-failure-cause.test.ts` (6), `ui/src/components/IssueRunLedger.test.tsx` (21), restart chaos `universal.test.ts` and `observer.test.ts`. - `packages/paperclip-runner/src/control-plane/durable-prp-control-plane.test.ts`: 207 passed (the stall-diagnostic cases still read `40001` and `ECONNRESET`). - Typecheck: shared, runner, server (all layers), UI, restart chaos harness. `pnpm check:token-gates`: all gates clean. - `pnpm verify` returned exit 2 (global doc input changed); required CI supplies broad coverage. - CI on `6295f8d` found that the new key made failed-preparation runs ineligible for Retry (`chat-channels.integration.test.ts` saved board-message Retry got 409; `workspace-recovery-preparation-dispatch.test.ts` dispatched 0 Retries). Both reproduced locally and pass after the allowlist fix, with the other 459 Retry cases in `chat-channels.integration.test.ts` and all 25 in `workspace-recovery-preparation-dispatch.test.ts`. - No chaos waves were run. Storybook was not opened in a browser; the fixture typechecks and the label is covered by the UI test. ## Risks - Low risk. `errorCode` values do not change, so retry, recovery, and comparison logic is unaffected. - No schema change and no migration. `failureCause` is a new optional `resultJson` key. Rows saved by the previous release have no key and render as before; a new row read by an old server is ignored. - The SQL replacement only acts on a Drizzle `Failed query:` marker, or on `$n` placeholder text with a statement shape. Ordinary error text does not change. When it acts, it also drops the text after the statement. - Code that checks `resultJson` against a closed key list must allow `failureCause`. I found and updated the four lists on the Retry path; a list I missed would refuse a Retry for a run with a generic failure. - This is the run log / instance database path. It adds no Telemetry event. - The server failure log line still logs the raw error message (unchanged behavior); only the board-facing text is changed. ## Model Used - Claude Opus 5.5 (Anthropic), model ID `claude-opus-5-5`, used through Claude Code with tool use (shell, file editing) and code execution for tests. ## 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: Claude <noreply@anthropic.com>
302 lines
23 KiB
TypeScript
302 lines
23 KiB
TypeScript
import { isRetainedWorkspaceWorkerStopProof } from "./native-runtime/native-workspace-platform-proof.js";
|
|
import { isPreDispatchReviewWait } from "./pre-dispatch-review-wait.js";
|
|
import { createHash } from "node:crypto";
|
|
import { and, eq, gt, inArray, isNotNull, isNull, lte, sql } from "drizzle-orm";
|
|
import { environmentLeases, heartbeatRunEvents, heartbeatRuns, issueTreeHolds, issueTreeHoldMembers, nativeRunFinalizations, nativeRunResults, type Db } from "@paperclipai/db";
|
|
import { isUuidLike } from "@paperclipai/shared";
|
|
import { claimedAdapterType } from "./conversation-continuation.js";
|
|
import { hasRemoteTerminationReceipt } from "./remote-execution-termination.js";
|
|
import { canContinueCancelledRun, readRunCancellation } from "./run-cancellation.js";
|
|
import { legacyControllerBootId } from "./legacy-controller-lease.js";
|
|
import { hasRequiredWorkspaceRecovery } from "./workspace-restore-recovery-state.js";
|
|
import { hasVerifiedWorkspaceRepair } from "./workspace-recovery-receipt.js";
|
|
import { readFailedPreparationSettlement } from "./failed-preparation-settlement.js";
|
|
import { hasVerifiedPreparationLog } from "./verified-preparation-log.js";
|
|
import { executionStartedEventPredicate, isPreDispatchResourceWait, noRetainedExecutionPredicate } from "./pre-dispatch-execution-evidence.js";
|
|
|
|
type Run = Pick<typeof heartbeatRuns.$inferSelect,
|
|
"id" | "companyId" | "status" | "startedAt" | "finishedAt" | "runtimeMode" |
|
|
"runtimeModeResolvedAt" | "errorCode" | "error" | "resultJson" | "runnerProfileJson" |
|
|
"contextSnapshot" | "processPid" | "processGroupId" | "processStartedAt" |
|
|
"sessionIdAfter" | "issueId" | "nativeIssueId" | "nativeSessionId" | "executionStage" |
|
|
"controllerBootId" | "controllerLeaseExpiresAt">;
|
|
type Coordinator = typeof nativeRunFinalizations.$inferSelect;
|
|
const EMPTY_LOG_SHA256 = createHash("sha256").digest("hex");
|
|
|
|
export async function isFailedNativePreparation(db: Db, run: Run, coordinator: Coordinator | undefined) {
|
|
return failedNativePreparation(db, run, coordinator, false);
|
|
}
|
|
|
|
export async function isFailedNativePreparationRetryAncestor(db: Db, run: Run, coordinator: Coordinator | undefined) {
|
|
return failedNativePreparation(db, run, coordinator, true);
|
|
}
|
|
|
|
async function failedNativePreparation(db: Db, run: Run, coordinator: Coordinator | undefined, historicalRetryAncestor: boolean) {
|
|
if (!historicalRetryAncestor && !coordinator && await isInterruptedNativePreparation(db, run)) return true;
|
|
const recovery = run.resultJson?.executionRecovery as Record<string, unknown> | undefined;
|
|
const preparationFailure = run.errorCode === "setup_failed" || historicalRetryAncestor && run.errorCode === "adapter_failed";
|
|
if (run.status !== "failed" || !run.finishedAt || run.runtimeMode !== "legacy" || run.runtimeModeResolvedAt
|
|
|| !preparationFailure || run.executionStage !== "preparing"
|
|
|| claimedAdapterType(run) !== "paperclip_runner" || coordinator
|
|
|| recovery?.kind !== "bootstrap" || recovery.providerWorkStarted !== false
|
|
|| run.processPid || run.processGroupId || run.processStartedAt || run.sessionIdAfter
|
|
|| run.nativeIssueId || run.nativeSessionId || run.runnerProfileJson?.nativeExecutionInput != null
|
|
|| run.runnerProfileJson?.nativeWorkspaceSync != null
|
|
|| run.controllerLeaseExpiresAt && run.controllerLeaseExpiresAt > new Date()) return false;
|
|
const leases = await db.select().from(environmentLeases).where(and(
|
|
eq(environmentLeases.companyId, run.companyId), eq(environmentLeases.heartbeatRunId, run.id),
|
|
));
|
|
const [result] = await db.select({ id: nativeRunResults.id }).from(nativeRunResults).where(and(
|
|
eq(nativeRunResults.companyId, run.companyId), eq(nativeRunResults.runId, run.id),
|
|
)).limit(1);
|
|
if (result || await nativeStartupHasExecutionEvidence(db, run)) return false;
|
|
if (!leases.length && run.runnerProfileJson?.remoteWorkspace == null) return !historicalRetryAncestor;
|
|
const issueId = run.nativeIssueId ?? run.issueId ?? run.contextSnapshot?.issueId;
|
|
return typeof issueId === "string" && leases.length > 0 && leases.every(lease => lease.issueId === issueId
|
|
&& lease.metadata?.pendingCleanupInFlight !== true && hasRemoteTerminationReceipt(lease))
|
|
&& await hasSettledFailedPreparationEvidence(db, run, historicalRetryAncestor)
|
|
&& await hasVerifiedWorkspaceRepair(db, run);
|
|
}
|
|
|
|
async function hasSettledFailedPreparationEvidence(db: Db, run: Run, historicalRetryAncestor = false): Promise<boolean> {
|
|
const [source] = await db.select({ agentId: heartbeatRuns.agentId,
|
|
contextSnapshot: sql<Record<string, unknown>>`jsonb_build_object(
|
|
'skillSelectionSnapshot', ${heartbeatRuns.contextSnapshot}->'skillSelectionSnapshot',
|
|
'skillVersions', ${heartbeatRuns.contextSnapshot}->'skillVersions')`,
|
|
runtimeModeResolverVersion: heartbeatRuns.runtimeModeResolverVersion, runtimeModeReason: heartbeatRuns.runtimeModeReason,
|
|
runnerInstanceId: heartbeatRuns.runnerInstanceId, nativePhase: heartbeatRuns.nativePhase,
|
|
driverKind: heartbeatRuns.driverKind, driverVersion: heartbeatRuns.driverVersion,
|
|
completionContractId: heartbeatRuns.completionContractId, completionContractSha256: heartbeatRuns.completionContractSha256,
|
|
externalRunId: heartbeatRuns.externalRunId,
|
|
logStore: heartbeatRuns.logStore, logRef: heartbeatRuns.logRef, logBytes: heartbeatRuns.logBytes,
|
|
logSha256: heartbeatRuns.logSha256, logCompressed: heartbeatRuns.logCompressed,
|
|
stdoutExcerpt: heartbeatRuns.stdoutExcerpt, stderrExcerpt: heartbeatRuns.stderrExcerpt,
|
|
lastOutputAt: heartbeatRuns.lastOutputAt, lastOutputSeq: heartbeatRuns.lastOutputSeq,
|
|
lastOutputStream: heartbeatRuns.lastOutputStream, lastOutputBytes: heartbeatRuns.lastOutputBytes,
|
|
usageJson: heartbeatRuns.usageJson, exitCode: heartbeatRuns.exitCode, signal: heartbeatRuns.signal,
|
|
}).from(heartbeatRuns).where(and(eq(heartbeatRuns.companyId, run.companyId), eq(heartbeatRuns.id, run.id)));
|
|
const emptyLog = source && source.logStore === "s3_segments"
|
|
&& source.logRef === `${run.companyId}/${source.agentId}/${run.id}`
|
|
&& source.logBytes === 0 && source.logSha256 === EMPTY_LOG_SHA256
|
|
&& [source.stdoutExcerpt, source.stderrExcerpt].every(value => value === null || value === "");
|
|
const preparationLog = historicalRetryAncestor && source &&
|
|
await hasVerifiedPreparationLog({ ...source, id: run.id, companyId: run.companyId });
|
|
if (!source || source.logCompressed || !preparationLog && source.lastOutputSeq !== 0 || Object.entries(source).some(([key, value]) =>
|
|
!["agentId", "contextSnapshot", "logCompressed", "lastOutputSeq", "usageJson"].includes(key) &&
|
|
!(historicalRetryAncestor && key === "sessionIdBefore") &&
|
|
!(preparationLog && ["logStore", "logRef", "logBytes", "logSha256", "stdoutExcerpt", "stderrExcerpt",
|
|
"lastOutputAt", "lastOutputStream", "lastOutputBytes"].includes(key)) &&
|
|
!(emptyLog && ["logStore", "logRef", "logBytes", "logSha256", "stdoutExcerpt", "stderrExcerpt"].includes(key)) && value !== null)
|
|
|| !hasOnlyPreparationAccountingContext(source.usageJson, run.issueId ?? run.contextSnapshot?.issueId)
|
|
|| Object.entries(run.runnerProfileJson ?? {}).some(([key, value]) => !["adapterDispatch", "remoteWorkspace",
|
|
"chatControlRecoveryAdmission"].includes(key) && !(key === "nativeWorkspaceOwnerStopProof"
|
|
&& isRetainedWorkspaceWorkerStopProof(value, run.id)))
|
|
|| run.resultJson?.stopReason !== "adapter_failed"
|
|
|| Object.keys(run.resultJson).some(key => !["stopReason", "timeoutFired", "timeoutSource", "executionRecovery",
|
|
"timeoutConfigured", "effectiveTimeoutSec", "systemRecoveryAdoption", "workspaceRestoreFailure",
|
|
// A bounded diagnostic code; it carries no execution or replay authority.
|
|
"workspaceRestoreRecovery", "failureCause"].includes(key))) return false;
|
|
return Boolean(await readFailedPreparationSettlement(db, { ...run, agentId: source.agentId }, historicalRetryAncestor));
|
|
}
|
|
|
|
function hasOnlyPreparationAccountingContext(usage: unknown, issueId: unknown): boolean {
|
|
if (usage === null) return true;
|
|
if (!usage || typeof usage !== "object" || Array.isArray(usage)
|
|
|| Object.keys(usage).length !== 1 || !Object.hasOwn(usage, "ledgerScope")) return false;
|
|
const scope = (usage as Record<string, unknown>).ledgerScope as Record<string, unknown> | null;
|
|
return Boolean(scope && typeof scope === "object" && !Array.isArray(scope) && Object.keys(scope).length === 3
|
|
&& scope.issueId === issueId && (scope.projectId === null || typeof scope.projectId === "string" && isUuidLike(scope.projectId))
|
|
&& (scope.billingCode === null || typeof scope.billingCode === "string"));
|
|
}
|
|
|
|
/** Candidate state only. Admission must also verify ownership, execution records,
|
|
* stopped leases, and any saved-file obligation below. */
|
|
export function isInterruptedNativePreparationCandidate(run: Run): boolean {
|
|
const cancellation = readRunCancellation(run.resultJson);
|
|
const recovery = run.resultJson?.executionRecovery as Record<string, unknown> | undefined;
|
|
// Restart recovery records this certificate only after revoking a preparing
|
|
// controller. A generic process-lost error cannot establish safe replay.
|
|
const lostPreparation = run.status === "failed" && run.errorCode === "process_lost"
|
|
&& recovery?.kind === "bootstrap" && recovery.providerWorkStarted === false;
|
|
// Older shutdown records predate structured cancellation. Current records
|
|
// identify unexpected system interruption without relying on an error label.
|
|
const systemInterruption = cancellation
|
|
? cancellation.initiator.type === "system" && cancellation.source !== "operator" &&
|
|
(cancellation.source === "shutdown" || cancellation.expected === false)
|
|
: run.errorCode === "server_shutdown_interrupted" || lostPreparation;
|
|
return Boolean(systemInterruption && (run.status === "interrupted" || lostPreparation) && run.finishedAt
|
|
&& run.runtimeMode === "legacy" && !run.runtimeModeResolvedAt && run.executionStage === "preparing"
|
|
&& claimedAdapterType(run) === "paperclip_runner" && !run.processPid && !run.processGroupId
|
|
&& !run.processStartedAt && !run.sessionIdAfter && !run.nativeIssueId && !run.nativeSessionId
|
|
&& run.runnerProfileJson?.nativeExecutionInput == null && recovery?.providerWorkStarted !== true);
|
|
}
|
|
|
|
export async function isInterruptedNativePreparation(db: Db, run: Run): Promise<boolean> {
|
|
const recovery = run.resultJson?.executionRecovery as Record<string, unknown> | undefined;
|
|
const settled = recovery?.kind === "bootstrap" && recovery.providerWorkStarted === false;
|
|
if (!isInterruptedNativePreparationCandidate(run)
|
|
|| run.controllerLeaseExpiresAt && run.controllerLeaseExpiresAt > new Date()
|
|
|| !settled && (!run.controllerBootId || run.controllerBootId === legacyControllerBootId)) return false;
|
|
const [coordinator] = await db.select({ id: nativeRunFinalizations.runId }).from(nativeRunFinalizations).where(and(
|
|
eq(nativeRunFinalizations.companyId, run.companyId), eq(nativeRunFinalizations.runId, run.id),
|
|
)).limit(1);
|
|
const [result] = await db.select({ id: nativeRunResults.id }).from(nativeRunResults).where(and(
|
|
eq(nativeRunResults.companyId, run.companyId), eq(nativeRunResults.runId, run.id),
|
|
)).limit(1);
|
|
if (coordinator || result || await nativeStartupHasExecutionEvidence(db, run)) return false;
|
|
const leases = await db.select().from(environmentLeases).where(and(
|
|
eq(environmentLeases.companyId, run.companyId), eq(environmentLeases.heartbeatRunId, run.id),
|
|
));
|
|
if (leases.some(lease => !lease.releasedAt || lease.status === "pending_cleanup" || lease.cleanupStatus === "failed"
|
|
|| lease.metadata?.pendingCleanupInFlight === true
|
|
|| lease.provider !== "local" && !hasRemoteTerminationReceipt(lease))) return false;
|
|
if (hasRequiredWorkspaceRecovery(run.resultJson) || run.runnerProfileJson?.remoteWorkspace != null
|
|
|| run.runnerProfileJson?.nativeWorkspaceSync != null) return hasVerifiedWorkspaceRepair(db, run);
|
|
return run.resultJson?.workspaceRestoreFailure == null;
|
|
}
|
|
|
|
/** Candidate for explicit user Retry only, never automatic replay after Stop. */
|
|
export async function canRetryStoppedRun(db: Db, run: Run): Promise<boolean> {
|
|
if (run.errorCode === "native_session_cleanup_quarantined") return false;
|
|
if (["failed", "timed_out"].includes(run.status) || canContinueCancelledRun(run)) return true;
|
|
if (run.status !== "cancelled" || !run.finishedAt || run.processPid || run.processGroupId ||
|
|
run.processStartedAt || run.sessionIdAfter) return false;
|
|
const [coordinator] = await db.select().from(nativeRunFinalizations).where(and(
|
|
eq(nativeRunFinalizations.companyId, run.companyId), eq(nativeRunFinalizations.runId, run.id),
|
|
));
|
|
return isCancelledNativeStartup(db, run, coordinator);
|
|
}
|
|
|
|
function noProviderOutputPredicate() {
|
|
return and(...[
|
|
heartbeatRuns.runtimeModeResolverVersion, heartbeatRuns.runtimeModeReason, heartbeatRuns.runnerInstanceId,
|
|
heartbeatRuns.nativePhase, heartbeatRuns.driverKind, heartbeatRuns.driverVersion,
|
|
heartbeatRuns.completionContractId, heartbeatRuns.completionContractSha256, heartbeatRuns.externalRunId,
|
|
heartbeatRuns.logStore, heartbeatRuns.logRef, heartbeatRuns.logBytes, heartbeatRuns.logSha256,
|
|
heartbeatRuns.stdoutExcerpt, heartbeatRuns.stderrExcerpt, heartbeatRuns.lastOutputAt,
|
|
heartbeatRuns.lastOutputStream, heartbeatRuns.lastOutputBytes, heartbeatRuns.exitCode, heartbeatRuns.signal,
|
|
].map(column => isNull(column)), eq(heartbeatRuns.lastOutputSeq, 0), eq(heartbeatRuns.logCompressed, false));
|
|
}
|
|
|
|
async function isCancelledUnclaimedWorkspaceWaitRetry(db: Db, run: Run): Promise<boolean> {
|
|
const cancellation = readRunCancellation(run.resultJson);
|
|
const issueId = run.issueId ?? run.contextSnapshot?.issueId;
|
|
if (run.runtimeMode !== "legacy" || run.startedAt !== null || run.executionStage !== null
|
|
|| run.runnerProfileJson !== null || typeof issueId !== "string" || !isUuidLike(issueId)) return false;
|
|
const systemCancellation = run.errorCode === "cancelled" && run.resultJson?.stopReason === "cancelled"
|
|
&& cancellation?.source === "control_plane" && cancellation.expected === true && cancellation.initiator.type === "system";
|
|
const pausedBeforePromotion = run.errorCode === "issue_paused" && run.resultJson === null && run.finishedAt
|
|
? await db.select({ id: heartbeatRunEvents.id }).from(heartbeatRunEvents)
|
|
.innerJoin(issueTreeHolds, and(eq(issueTreeHolds.companyId, run.companyId),
|
|
sql`${issueTreeHolds.id}::text = ${heartbeatRunEvents.payload}->>'holdId'`,
|
|
sql`${issueTreeHolds.rootIssueId}::text = ${heartbeatRunEvents.payload}->>'rootIssueId'`))
|
|
.innerJoin(issueTreeHoldMembers, and(eq(issueTreeHoldMembers.companyId, run.companyId),
|
|
eq(issueTreeHoldMembers.holdId, issueTreeHolds.id), eq(issueTreeHoldMembers.issueId, issueId),
|
|
eq(issueTreeHoldMembers.skipped, false)))
|
|
.where(and(eq(heartbeatRunEvents.companyId, run.companyId), eq(heartbeatRunEvents.runId, run.id),
|
|
eq(heartbeatRunEvents.eventType, "lifecycle"),
|
|
eq(heartbeatRunEvents.stream, "system"), eq(heartbeatRunEvents.level, "warn"),
|
|
eq(heartbeatRunEvents.message, "Scheduled retry suppressed because the issue is held by an active subtree pause hold"),
|
|
sql`${heartbeatRunEvents.payload}->>'issueId' = ${issueId}`,
|
|
sql`${heartbeatRunEvents.payload}->>'scheduledRetryReason' = 'workspace_busy'`,
|
|
eq(issueTreeHolds.mode, "pause"), eq(issueTreeHolds.status, "released"),
|
|
lte(issueTreeHolds.createdAt, run.finishedAt), isNotNull(issueTreeHolds.releasedAt),
|
|
gt(issueTreeHolds.releasedAt, run.finishedAt))).limit(1).then(rows => rows.length > 0)
|
|
: false;
|
|
if (!systemCancellation && !pausedBeforePromotion) return false;
|
|
const [source] = await db.select({ agentId: heartbeatRuns.agentId, createdAt: heartbeatRuns.createdAt,
|
|
retryOfRunId: heartbeatRuns.retryOfRunId,
|
|
}).from(heartbeatRuns).where(and(eq(heartbeatRuns.companyId, run.companyId), eq(heartbeatRuns.id, run.id),
|
|
eq(heartbeatRuns.scheduledRetryReason, "workspace_busy"),
|
|
sql`${heartbeatRuns.contextSnapshot}->>'retryReason' = 'workspace_busy'`,
|
|
sql`${heartbeatRuns.contextSnapshot}->>'retryOfRunId' = ${heartbeatRuns.retryOfRunId}::text`,
|
|
pausedBeforePromotion ? isNull(heartbeatRuns.resultJson)
|
|
: sql`(${heartbeatRuns.resultJson} - array['stopReason', 'cancellation', 'startupCancellation',
|
|
'timeoutFired', 'timeoutSource', 'timeoutConfigured', 'effectiveTimeoutSec']) = '{}'::jsonb`,
|
|
noRetainedExecutionPredicate(), noProviderOutputPredicate(), isNull(heartbeatRuns.runnerProfileJson),
|
|
isNull(heartbeatRuns.usageJson)));
|
|
if (!source?.retryOfRunId) return false;
|
|
const [parent] = await db.select({ id: heartbeatRuns.id, usageJson: heartbeatRuns.usageJson }).from(heartbeatRuns).where(and(
|
|
eq(heartbeatRuns.companyId, run.companyId), eq(heartbeatRuns.agentId, source.agentId), eq(heartbeatRuns.id, source.retryOfRunId),
|
|
sql`coalesce(${heartbeatRuns.issueId}::text, ${heartbeatRuns.contextSnapshot}->>'issueId') = ${issueId}`,
|
|
eq(heartbeatRuns.runtimeMode, "legacy"), eq(heartbeatRuns.status, "cancelled"), eq(heartbeatRuns.errorCode, "workspace_busy"),
|
|
eq(heartbeatRuns.executionStage, "settled"), isNotNull(heartbeatRuns.finishedAt), lte(heartbeatRuns.finishedAt, source.createdAt),
|
|
isNull(heartbeatRuns.runtimeModeResolvedAt), isNull(heartbeatRuns.controllerLeaseExpiresAt),
|
|
...[heartbeatRuns.processPid, heartbeatRuns.processGroupId, heartbeatRuns.processStartedAt,
|
|
heartbeatRuns.nativeIssueId, heartbeatRuns.nativeSessionId, heartbeatRuns.sessionIdAfter].map(column => isNull(column)),
|
|
noProviderOutputPredicate(),
|
|
sql`${heartbeatRuns.runnerProfileJson} #>> '{adapterDispatch,adapterType}' = 'paperclip_runner'`,
|
|
sql`(${heartbeatRuns.runnerProfileJson} - 'adapterDispatch' - 'chatControlRecoveryAdmission') = '{}'::jsonb`,
|
|
sql`${heartbeatRuns.resultJson}->'executionRecovery'->>'kind' = 'workspace_wait'`,
|
|
sql`${heartbeatRuns.resultJson}->'executionRecovery'->'providerWorkStarted' = 'false'::jsonb`,
|
|
sql`(${heartbeatRuns.resultJson} - 'cancellation' - 'workspaceBusy' - 'executionRecovery') = '{}'::jsonb`,
|
|
sql`not exists (select 1 from ${environmentLeases} where ${environmentLeases.companyId} = ${heartbeatRuns.companyId}
|
|
and ${environmentLeases.heartbeatRunId} = ${heartbeatRuns.id})`,
|
|
sql`not exists (select 1 from ${nativeRunFinalizations} where ${nativeRunFinalizations.companyId} = ${heartbeatRuns.companyId}
|
|
and ${nativeRunFinalizations.runId} = ${heartbeatRuns.id})`,
|
|
sql`not exists (select 1 from ${heartbeatRunEvents} where ${and(eq(heartbeatRunEvents.companyId, heartbeatRuns.companyId),
|
|
eq(heartbeatRunEvents.runId, heartbeatRuns.id), executionStartedEventPredicate())})`,
|
|
));
|
|
if (!parent || !hasOnlyPreparationAccountingContext(parent.usageJson, issueId)) return false;
|
|
const [result] = await db.select({ id: nativeRunResults.id }).from(nativeRunResults).where(and(
|
|
eq(nativeRunResults.companyId, run.companyId), inArray(nativeRunResults.runId, [run.id, parent.id]),
|
|
)).limit(1);
|
|
return !result;
|
|
}
|
|
|
|
/** Caller holds the coordinator and run locks when using this proof to admit
|
|
* work. Attempt zero is a durable never-claimed receipt: every native executor
|
|
* commits its first claim before it can start or attach a provider. */
|
|
export async function isCancelledNativeStartup(db: Db, run: Run, coordinator: Coordinator | undefined) {
|
|
if (run.status !== "cancelled" || !run.finishedAt || run.processPid || run.processGroupId ||
|
|
run.processStartedAt || run.sessionIdAfter) return false;
|
|
const cancellation = run.resultJson?.startupCancellation as Record<string, unknown> | undefined;
|
|
// Older builds could omit the cancellation fence or its unwind marker. Their immutable
|
|
// native-adapter claim and unresolved preparation stage still prove that
|
|
// provider dispatch did not begin. Require an expired owner from another
|
|
// server boot; neither a missing PID nor mutable agent settings is proof.
|
|
const historicalBeforeSelection = run.runtimeMode === "legacy" && !run.runtimeModeResolvedAt &&
|
|
run.executionStage === "preparing" && !run.nativeIssueId && !run.nativeSessionId && !coordinator &&
|
|
claimedAdapterType(run) === "paperclip_runner" && run.errorCode === "operator_interrupted" &&
|
|
(run.resultJson === null || cancellation?.beforeNativeSelection === true) &&
|
|
Boolean(run.controllerBootId && run.controllerBootId !== legacyControllerBootId &&
|
|
run.controllerLeaseExpiresAt && run.controllerLeaseExpiresAt <= new Date());
|
|
const beforeDispatch = !coordinator && (isPreDispatchReviewWait(run) ||
|
|
isPreDispatchResourceWait(run) && !run.runtimeModeResolvedAt && !run.nativeIssueId && !run.nativeSessionId &&
|
|
["preparing", "settled"].includes(run.executionStage ?? "") &&
|
|
run.runnerProfileJson?.nativeExecutionInput == null && run.runnerProfileJson?.nativeWorkspaceSync == null &&
|
|
run.runnerProfileJson?.remoteWorkspace == null && run.resultJson?.workspaceRestoreFailure == null &&
|
|
!hasRequiredWorkspaceRecovery(run.resultJson));
|
|
const unclaimedWorkspaceRetry = !coordinator && await isCancelledUnclaimedWorkspaceWaitRetry(db, run);
|
|
const beforeSelection = beforeDispatch || unclaimedWorkspaceRetry || historicalBeforeSelection || run.runtimeMode === "legacy" && !run.runtimeModeResolvedAt &&
|
|
!run.nativeSessionId && !coordinator && claimedAdapterType(run) === "paperclip_runner" &&
|
|
cancellation?.beforeNativeSelection === true;
|
|
const neverClaimed = run.runtimeMode === "native" && coordinator &&
|
|
["observed", "terminal_failure"].includes(coordinator.phase) && coordinator.attempt === 0 &&
|
|
coordinator.controllerGeneration === 0 && !coordinator.controllerBootId &&
|
|
!coordinator.controllerPid && !coordinator.leaseOwner && !coordinator.leaseExpiresAt &&
|
|
!coordinator.resultId && !coordinator.failureDetail?.successorRunId;
|
|
if (!beforeSelection && !neverClaimed) return false;
|
|
const settled = beforeDispatch || unclaimedWorkspaceRetry || typeof run.resultJson?.startupPreparationSettledAt === "string";
|
|
// The old preparer can still be unwinding even though the run is terminal.
|
|
if (run.controllerLeaseExpiresAt && run.controllerLeaseExpiresAt > new Date()) return false;
|
|
const leases = await db.select().from(environmentLeases).where(and(
|
|
eq(environmentLeases.companyId, run.companyId), eq(environmentLeases.heartbeatRunId, run.id),
|
|
));
|
|
if ((!settled && leases.length === 0 && !historicalBeforeSelection) || leases.some(lease =>
|
|
lease.metadata?.pendingCleanupInFlight === true || (lease.provider === "local"
|
|
? !lease.releasedAt || lease.status === "pending_cleanup" || lease.cleanupStatus === "failed"
|
|
: !hasRemoteTerminationReceipt(lease)))) return false;
|
|
// Reject contradictory retained evidence, including a crash after a launch
|
|
// request but before the PID callback. Provider events never certify a stop.
|
|
return !await nativeStartupHasExecutionEvidence(db, run);
|
|
}
|
|
|
|
async function nativeStartupHasExecutionEvidence(db: Db, run: Run) {
|
|
const [execution] = await db.select({ id: heartbeatRunEvents.id }).from(heartbeatRunEvents).where(and(
|
|
eq(heartbeatRunEvents.companyId, run.companyId), eq(heartbeatRunEvents.runId, run.id),
|
|
executionStartedEventPredicate(),
|
|
)).limit(1);
|
|
return Boolean(execution);
|
|
}
|