Files
PaperClipAI/server/src/services/issue-recovery-actions.ts
T
DottaandPaperclip 057898530c fix(server): handle wrapped recovery action conflicts (#18885)
## Thinking Path

> - Paperclip coordinates durable agent work and recovery.
> - Independent workers can find the same task needs recovery.
> - The database permits one active recovery action for that task.
> - A losing insert must read the action that already committed.
> - Drizzle wraps the PostgreSQL unique violation in its cause.
> - The current classifier reads only the outer error and misses that
conflict.
> - This PR recognizes the same allowed conflict through a bounded cause
chain.
> - Both workers can then return the one durable action through the
existing retry path.

## Linked Issues or Issue Description

Refs: #18783.

**What happened?**

The saved-disposition-debt recovery regression failed in CI
`41733864320`. Two recovery workers collided on
`issue_recovery_actions_active_source_uq`. The fixture captured an
unhandled PostgreSQL `23505` error. A separate canceled shard in that
run lost its AWS Spot instance; that interruption is a different
failure.

**Expected behavior**

Two service instances that race to create the same active action must
return that one action. Unrelated database failures must still
propagate. Existing transaction and ownership rules must stay in force.

**Steps to reproduce**

Hold a PostgreSQL SHARE lock on the recovery-action table. Start the
same source-scoped upsert on two independent service instances. Confirm
both real INSERTs are waiting in `pg_stat_activity`, then release the
lock. The original code persists one action but rejects one caller with
a `DrizzleQueryError`. Its outer code is absent; its `PostgresError`
cause has SQLSTATE `23505` and the expected active-source constraint.

**Paperclip version or commit**

Supporting branch starts at master
`54782706e1d83d92295246e499627930df03662d`. It now contains a normal
merge of master `8771606fd87f69ed4057779e3dce3a9bca8088e6`. The
classifier correction stays unchanged. The PR diff against that master
still contains only the recovery service and its tests.

## What Changed

- Inspect up to eight error causes with cycle detection.
- Require SQLSTATE `23505` and one of the same two recovery-action
constraint names on the same error. Support the PostgreSQL driver's
`constraint_name` field.
- Reuse the existing source-scoped retry path and its unchanged
three-retry limit.
- Add a deterministic real-database collision regression and no-retry
controls for unrelated uniqueness, other SQLSTATEs and cyclic errors.
- Keep the original recovery regression, assertions and timeouts
unchanged. Keep the saved-skill journey separate in #18783.

## Verification

- Review follow-up: the real collision test now checks the intended
retry merge. The surviving action has attempt count 2, active status,
the default unlimited budget, the expected owner, supplied evidence and
supplied attempt timestamp. Its wake and monitor fields remain null.
Existing identity and convergence assertions run first.
- A disposable counterfactual returned the winner unchanged after the
lost insert. It passed the original barrier, one-row, no-rejection and
same-ID assertions, then failed the new attempt-count check with 1
instead of 2. The counterfactual was not committed. The production
service was restored byte-for-byte. The actual service passed all seven
focused race, no-retry and sequential controls. The affected canonical
test-tree-2 typecheck passed against verified unchanged local
declarations.
- Parent `4f8abfe4f2244649219ef6ced3534634193f0b53` full CI
`41746314291` passed all 48 selected jobs with one intended skip. Its
5/5 summary also had two duplicate unresolved reliability comments about
the missing state assertions. This test-only follow-up addresses both.
Fresh full CI and current-head review are required again.
- The deterministic real-database regression failed before the
correction. Both inserts reached the lock and exactly one active action
persisted. The losing error was a `DrizzleQueryError` with a
`PostgresError` cause, SQLSTATE `23505` and constraint
`issue_recovery_actions_active_source_uq`.
- **11 focused checks passed across two files on the integrated head**
(181.72 seconds). This includes the real-database collision, no-retry
controls and all four unchanged recovery producer/dependency
regressions, including the original failed real-clock saved-debt case.
Both callers return the one persisted action.
- Current-head canonical server typecheck passed on Node24.22.2 with all
seven layers: below, cycle, above, tests and test-tree-0 through
test-tree-2. Canonical shared, SDK, DB and Runner TypeScript
prerequisites passed. The first filtered installs lacked CLI and UI
imports required by the new test layers. Those module-resolution
failures remain recorded as setup failures. A full frozen workspace
install with lifecycle scripts disabled supplied the same locked
dependencies. No source or type-configuration workaround was used. No
Rust or broad workspace build ran locally; full repository typecheck,
tests and build remain hosted-CI gates.
- The actual supporting master baseline reproduced the same wrapped23505
failure before the correction. Earlier configuration-resolution, local
IPC and native-library prerequisites remain separate setup failure
evidence. No test failure was reclassified as a pass.
- First supporting CI `41739489620` was canceled before its build/test
jobs completed. Its later automatic full-CI run `41739747947` failed.
The recovery server shard passed. The Runner failure was the Cursor
fixture case repaired upstream in #18782, now included by the normal
master merge. That CI also observed an attachment-browser timeout, a
repository-rehome timeout and a lost-runner connection. Their underlying
causes remain unknown. A later pass will establish recovery, not explain
those earlier failures. These original results remain retained.
- Current `fbd392b9b65dbcf4ac078825096037ed0d490a0b` full CI
[41751706634 attempt
2](https://github.com/paperclipai/paperclip/actions/runs/41751706634/attempts/2)
passed: 48 successful jobs and one intended skip. Attempt 1 remains
retained: ten primary jobs lost their bound Spot capacity and the verify
job failed as a dependent. A single ordinary failed-job recheck produced
eleven fresh successful executions (ten primaries and verify);
timestamps prove 37 successful executions were retained. The skip is not
counted as execution. Current Greptile is 5/5 with both addressed
reliability threads resolved and no unresolved findings. All 57 current
check runs are successful, neutral or skipped, and Snyk is successful.
This is CI recovery; it does not explain the separately retained older
attachment and repository timeouts.
- The original CI failure and both failed saved-skill QA attempts remain
unchanged. This PR does not establish a live QA pass or deploy a
production change.

## Risks

Conflict classification must not turn unrelated errors into retries. The
new controls require those errors to propagate after one insert attempt.
The traversal is bounded and stops at cycles. Transaction, company
scope, ownership, notifications, retry count and database constraints
are unchanged. No schema or public API change is required.

## Model Used

OpenAI GPT-6 through Codex with reasoning and code execution. The
runtime does not expose a more specific model ID or context-window size.
These checks make no model or provider calls.

## 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>
2026-11-03 19:51:37 -06:00

641 lines
27 KiB
TypeScript

import { notifyAttentionRetentionWork } from "./retention-work-notifications.js";
import { notifyIssueRecovery } from "./issue-recovery-notifications.js";
import { notifyNativeRecoveryNotices } from "./native-recovery-notice-notifications.js";
import { notifyUnsafeWorkspaceExportRecovery } from "./unsafe-workspace-export-notifications.js";
import { writeExecutionControl } from "./execution-control-notifications.js";
import { DELIVERY_QUEUES, notifyDeliveryWork } from "./delivery-work-notifications.js";
import { and, desc, eq, inArray, isNull, isNotNull, or, sql } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import { heartbeatRuns, issueRecoveryActions, workspaceOperations } from "@paperclipai/db";
import { isUuidLike } from "@paperclipai/shared";
import type {
IssueRecoveryAction,
IssueRecoveryActionKind,
IssueRecoveryActionOwnerType,
IssueRecoveryActionOutcome,
IssueRecoveryActionStatus,
} from "@paperclipai/shared";
import { isNativeWorkspaceFinalizationOperationActive } from "./workspace-operations.js";
const ACTIVE_RECOVERY_ACTION_STATUSES = ["active", "escalated"] as const satisfies readonly IssueRecoveryActionStatus[];
const MAX_UPSERT_RETRIES = 3;
type IssueRecoveryActionRow = typeof issueRecoveryActions.$inferSelect;
type DbTransaction = Parameters<Parameters<Db["transaction"]>[0]>[0];
type DbOrTransaction = Db | DbTransaction;
function asDatabaseDate(value: string | Date | null) {
return typeof value === "string" ? new Date(value) : value;
}
function isRecoveryBudgetExhausted(evidence: Record<string, unknown>) {
const budget = evidence.recoveryBudget;
return Boolean(
budget &&
typeof budget === "object" &&
!Array.isArray(budget) &&
(budget as Record<string, unknown>).state === "exhausted",
);
}
export type UpsertIssueRecoveryActionInput = {
companyId: string;
sourceIssueId: string;
recoveryIssueId?: string | null;
kind: IssueRecoveryActionKind;
ownerType?: IssueRecoveryActionOwnerType;
ownerAgentId?: string | null;
ownerUserId?: string | null;
previousOwnerAgentId?: string | null;
returnOwnerAgentId?: string | null;
cause: string;
fingerprint: string;
evidence?: Record<string, unknown>;
/** Evidence written only when this upsert creates a new action row. */
evidenceOnCreate?: Record<string, unknown>;
nextAction: string;
wakePolicy?: Record<string, unknown> | null;
monitorPolicy?: Record<string, unknown> | null;
maxAttempts?: number | null;
timeoutAt?: Date | null;
lastAttemptAt?: Date | null;
attemptCount?: number;
// When true, a change of (cause, fingerprint) does not overwrite the active
// action in place. The service resolves the prior action and inserts a new
// one. The new failure then gets a distinct recovery identity and a fresh
// operator notice, and the prior identity stays as a resolved record.
supersedeOnIdentityChange?: boolean;
// Rollout compatibility for active pre-policy actions. Refresh their
// evidence/attempt metadata without silently changing the recorded owner or
// the wake/monitor contract that made that owner authoritative.
preserveExistingOwner?: boolean;
};
export type ResolveIssueRecoveryActionInput = {
companyId: string;
sourceIssueId: string;
actionId?: string | null;
kind?: IssueRecoveryActionKind | null;
cause?: string | null;
fingerprint?: string | null;
status: Extract<IssueRecoveryActionStatus, "resolved" | "cancelled">;
outcome: IssueRecoveryActionOutcome;
resolutionNote?: string | null;
};
function toReadModel(row: IssueRecoveryActionRow): IssueRecoveryAction {
return {
id: row.id,
companyId: row.companyId,
sourceIssueId: row.sourceIssueId,
recoveryIssueId: row.recoveryIssueId,
kind: row.kind as IssueRecoveryAction["kind"],
status: row.status as IssueRecoveryAction["status"],
ownerType: row.ownerType as IssueRecoveryAction["ownerType"],
ownerAgentId: row.ownerAgentId,
ownerUserId: row.ownerUserId,
previousOwnerAgentId: row.previousOwnerAgentId,
returnOwnerAgentId: row.returnOwnerAgentId,
cause: row.cause,
fingerprint: row.fingerprint,
evidence: row.evidence,
nextAction: row.nextAction,
wakePolicy: row.wakePolicy,
monitorPolicy: row.monitorPolicy,
attemptCount: row.attemptCount,
maxAttempts: row.maxAttempts,
timeoutAt: row.timeoutAt,
lastAttemptAt: row.lastAttemptAt,
outcome: row.outcome as IssueRecoveryAction["outcome"],
resolutionNote: row.resolutionNote,
resolvedAt: row.resolvedAt,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
}
function isUniqueRecoveryActionConflict(error: unknown) {
const seen = new Set<unknown>();
let candidate = error;
// Drizzle wraps the driver error in cause. Only this specific uniqueness
// conflict can reuse the existing bounded source-scoped upsert retry.
for (let depth = 0; depth < 8 && candidate && typeof candidate === "object"; depth++) {
if (seen.has(candidate)) break;
seen.add(candidate);
const maybe = candidate as { code?: string; constraint?: string; constraint_name?: string; message?: string; cause?: unknown };
if (maybe.code === "23505" && (
maybe.constraint === "issue_recovery_actions_active_source_uq" ||
maybe.constraint === "issue_recovery_actions_active_fingerprint_uq" ||
maybe.constraint_name === "issue_recovery_actions_active_source_uq" ||
maybe.constraint_name === "issue_recovery_actions_active_fingerprint_uq" ||
typeof maybe.message === "string" && (
maybe.message.includes("issue_recovery_actions_active_source_uq") ||
maybe.message.includes("issue_recovery_actions_active_fingerprint_uq")
)
)) return true;
candidate = maybe.cause;
}
return false;
}
export function issueRecoveryActionService(db: DbOrTransaction) {
const upsertQueues = new Map<string, Promise<void>>();
async function runExclusiveUpsert<T>(
input: UpsertIssueRecoveryActionInput,
task: () => Promise<T>,
): Promise<T> {
const key = `${input.companyId}:${input.sourceIssueId}`;
const previous = upsertQueues.get(key) ?? Promise.resolve();
let release: () => void = () => {};
const current = new Promise<void>((resolve) => {
release = resolve;
});
const next = previous.catch(() => undefined).then(() => current);
upsertQueues.set(key, next);
await previous.catch(() => undefined);
try {
return await task();
} finally {
release();
if (upsertQueues.get(key) === next) {
upsertQueues.delete(key);
}
}
}
// Coordinator retries reuse the original native run. They are not legacy
// scheduled retries, and workspace export can run while the heartbeat still
// records its earlier failure. Project their actual activity for every reader.
async function projectNativeRunActivity(
companyId: string,
actions: IssueRecoveryAction[],
dbOrTx: DbOrTransaction,
) {
const nativeActions = actions.filter((action) => action.wakePolicy?.kind === "resume_native_run");
for (const action of nativeActions) action.nativeRunActivity = null;
const runIds = [...new Set(nativeActions.flatMap((action) => {
const runId = action.wakePolicy?.runId;
return typeof runId === "string" && isUuidLike(runId) ? [runId] : [];
}))];
if (runIds.length === 0) return;
const activity = await dbOrTx.select({
runId: heartbeatRuns.id,
issueId: heartbeatRuns.nativeIssueId,
status: heartbeatRuns.status,
finishedAt: heartbeatRuns.finishedAt,
workspaceOperationId: workspaceOperations.id,
}).from(heartbeatRuns).leftJoin(workspaceOperations, and(
eq(workspaceOperations.companyId, heartbeatRuns.companyId),
eq(workspaceOperations.heartbeatRunId, heartbeatRuns.id),
eq(workspaceOperations.issueId, heartbeatRuns.nativeIssueId),
eq(workspaceOperations.phase, "workspace_finalize"),
eq(workspaceOperations.status, "running"),
isNull(workspaceOperations.finishedAt),
sql`${workspaceOperations.metadata}->>'owningService' = 'native_workspace_finalizer'`,
)).where(and(
eq(heartbeatRuns.companyId, companyId),
eq(heartbeatRuns.runtimeMode, "native"),
inArray(heartbeatRuns.id, runIds),
or(
and(inArray(heartbeatRuns.status, ["queued", "running"]), isNull(heartbeatRuns.finishedAt)),
isNotNull(workspaceOperations.id),
),
)).orderBy(desc(workspaceOperations.startedAt));
const activityByRun = new Map<string, (typeof activity)[number]>();
for (const row of activity) {
const liveOperation = row.workspaceOperationId && row.issueId
&& isNativeWorkspaceFinalizationOperationActive({
operationId: row.workspaceOperationId, companyId, runId: row.runId, issueId: row.issueId,
});
const liveHeartbeat = ["queued", "running"].includes(row.status) && row.finishedAt === null;
if (!liveHeartbeat && !liveOperation) continue;
const projected = { ...row, workspaceOperationId: liveOperation ? row.workspaceOperationId : null };
if (!activityByRun.has(row.runId) || (!activityByRun.get(row.runId)?.workspaceOperationId && liveOperation)) {
activityByRun.set(row.runId, projected);
}
}
for (const action of nativeActions) {
const row = activityByRun.get(String(action.wakePolicy?.runId).toLowerCase());
if (!row || row.issueId !== action.sourceIssueId) continue;
action.nativeRunActivity = {
runId: row.runId,
status: row.workspaceOperationId || row.status === "running" ? "running" : "queued",
workspaceOperationId: row.workspaceOperationId,
};
}
}
async function getActiveForIssue(
companyId: string,
sourceIssueId: string,
dbOrTx: DbOrTransaction = db,
): Promise<IssueRecoveryAction | null> {
const row = await dbOrTx
.select()
.from(issueRecoveryActions)
.where(
and(
eq(issueRecoveryActions.companyId, companyId),
eq(issueRecoveryActions.sourceIssueId, sourceIssueId),
inArray(issueRecoveryActions.status, [...ACTIVE_RECOVERY_ACTION_STATUSES]),
),
)
.orderBy(desc(issueRecoveryActions.updatedAt))
.limit(1)
.then((rows) => rows[0] ?? null);
if (!row) return null;
const action = toReadModel(row);
await projectNativeRunActivity(companyId, [action], dbOrTx);
return action;
}
async function listActiveForIssues(companyId: string, sourceIssueIds: string[]) {
if (sourceIssueIds.length === 0) return new Map<string, IssueRecoveryAction>();
const rows = await db
.select()
.from(issueRecoveryActions)
.where(
and(
eq(issueRecoveryActions.companyId, companyId),
inArray(issueRecoveryActions.sourceIssueId, [...new Set(sourceIssueIds)]),
inArray(issueRecoveryActions.status, [...ACTIVE_RECOVERY_ACTION_STATUSES]),
),
)
.orderBy(desc(issueRecoveryActions.updatedAt));
const result = new Map<string, IssueRecoveryAction>();
for (const row of rows) {
if (!result.has(row.sourceIssueId)) result.set(row.sourceIssueId, toReadModel(row));
}
await projectNativeRunActivity(companyId, [...result.values()], db);
return result;
}
async function retryUpsertSourceScoped(
input: UpsertIssueRecoveryActionInput,
retryCount: number,
error?: unknown,
): Promise<IssueRecoveryAction> {
if (retryCount >= MAX_UPSERT_RETRIES) {
if (error) throw error;
throw new Error(
`Failed to upsert active recovery action for issue ${input.sourceIssueId} after ${MAX_UPSERT_RETRIES} retries`,
);
}
return upsertSourceScopedUnlocked(input, retryCount + 1);
}
function buildInsertValues(
input: UpsertIssueRecoveryActionInput,
ownerType: IssueRecoveryActionOwnerType,
now: Date,
) {
return {
companyId: input.companyId,
sourceIssueId: input.sourceIssueId,
recoveryIssueId: input.recoveryIssueId ?? null,
kind: input.kind,
status: "active" as const,
ownerType,
ownerAgentId: input.ownerAgentId ?? null,
ownerUserId: input.ownerUserId ?? null,
previousOwnerAgentId: input.previousOwnerAgentId ?? null,
returnOwnerAgentId: input.returnOwnerAgentId ?? null,
cause: input.cause,
fingerprint: input.fingerprint,
evidence: {
...(input.evidence ?? {}),
...(input.evidenceOnCreate ?? {}),
},
nextAction: input.nextAction,
wakePolicy: input.wakePolicy ?? null,
monitorPolicy: input.monitorPolicy ?? null,
attemptCount: input.attemptCount ?? 1,
maxAttempts: input.maxAttempts ?? null,
timeoutAt: input.timeoutAt ?? null,
lastAttemptAt: input.lastAttemptAt ?? now,
};
}
// Resolve the prior active action, then insert a new one in one transaction.
// The prior identity stays as a cancelled record and the new failure gets a
// fresh action row with its own id. The partial unique index on the active
// status stays satisfied because only the new row is active at commit.
async function supersedePriorAndInsert(
input: UpsertIssueRecoveryActionInput,
priorActionId: string,
ownerType: IssueRecoveryActionOwnerType,
now: Date,
retryCount: number,
): Promise<IssueRecoveryAction> {
try {
const created = await db.transaction(async (tx) => {
await notifyAttentionRetentionWork(tx, { companyId: input.companyId });
await notifyIssueRecovery(tx, { companyId: input.companyId });
await notifyNativeRecoveryNotices(tx, { companyId: input.companyId, issueId: input.sourceIssueId });
await notifyDeliveryWork(tx, DELIVERY_QUEUES.executionDisposition);
await notifyDeliveryWork(tx, DELIVERY_QUEUES.executionContinuation);
await notifyUnsafeWorkspaceExportRecovery(tx, { companyId: input.companyId, issueId: input.sourceIssueId });
const [superseded] = await tx
.update(issueRecoveryActions)
.set({
status: "cancelled",
outcome: "cancelled",
resolutionNote: "A new failure with a different identity superseded this recovery action.",
resolvedAt: now,
updatedAt: now,
})
.where(
and(
eq(issueRecoveryActions.id, priorActionId),
inArray(issueRecoveryActions.status, [...ACTIVE_RECOVERY_ACTION_STATUSES]),
),
)
.returning();
// Another writer resolved the prior action first. Abort and retry the
// whole upsert so the retry reads the current active state.
if (!superseded) return null;
const [row] = await tx
.insert(issueRecoveryActions)
.values(buildInsertValues(input, ownerType, now))
.returning();
return row ?? null;
});
if (!created) return retryUpsertSourceScoped(input, retryCount);
return toReadModel(created);
} catch (error) {
if (!isUniqueRecoveryActionConflict(error)) throw error;
return retryUpsertSourceScoped(input, retryCount, error);
}
}
async function upsertSourceScopedUnlocked(
input: UpsertIssueRecoveryActionInput,
retryCount = 0,
): Promise<IssueRecoveryAction> {
const existing = await getActiveForIssue(input.companyId, input.sourceIssueId);
const now = new Date();
const ownerType = input.ownerType ?? (input.ownerAgentId ? "agent" : "board");
if (existing) {
// A distinct failure identity must not overwrite the active action of a
// prior identity. Resolve the prior action and insert a new one, so the
// operator gets a new notice for the new failure.
if (
input.supersedeOnIdentityChange &&
(existing.cause !== input.cause || existing.fingerprint !== input.fingerprint)
) {
return supersedePriorAndInsert(input, existing.id, ownerType, now, retryCount);
}
// `maxAttempts` is an execution budget, not display metadata. Once the
// same recovery identity consumes it, retain one inspectable board-owned
// action but remove every automatic wake/monitor path. Repeated sweep or
// finalizer writes then become idempotent instead of silently advancing
// beyond the advertised cap. A distinct identity can still supersede the
// exhausted action through the branch above.
if (isRecoveryBudgetExhausted(existing.evidence ?? {})) {
// Older sweeps replaced this operator-only hold with instructions to
// start another run. Restore the exact copyback recovery instructions
// without reopening its budget or erasing a recorded stop confirmation.
if (existing.cause === "native_workspace_finalization_owner_unverified"
&& input.cause === existing.cause && input.fingerprint === existing.fingerprint
&& !existing.evidence?.workspaceOwnerStop && existing.nextAction !== input.nextAction) {
const [updated] = await writeExecutionControl(db, [DELIVERY_QUEUES.executionDisposition, DELIVERY_QUEUES.executionContinuation], tx => tx.update(issueRecoveryActions).set({ nextAction: input.nextAction, updatedAt: now })
.where(and(eq(issueRecoveryActions.id, existing.id),
inArray(issueRecoveryActions.status, [...ACTIVE_RECOVERY_ACTION_STATUSES]),
sql`${issueRecoveryActions.evidence}->'workspaceOwnerStop' is null`)).returning(), { companyId: input.companyId });
if (updated) return toReadModel(updated);
}
return existing;
}
const nextAttemptCount =
input.attemptCount ?? existing.attemptCount + 1;
const effectiveMaxAttempts = input.preserveExistingOwner
? existing.maxAttempts
: input.maxAttempts === undefined
? existing.maxAttempts
: input.maxAttempts;
if (
effectiveMaxAttempts !== null &&
nextAttemptCount >= effectiveMaxAttempts
) {
const attemptsUsed = Math.max(
existing.attemptCount,
Math.min(nextAttemptCount, effectiveMaxAttempts),
);
const [exhausted] = await writeExecutionControl(db, [DELIVERY_QUEUES.executionDisposition, DELIVERY_QUEUES.executionContinuation], async tx => {
await notifyIssueRecovery(tx, { companyId: input.companyId });
await notifyNativeRecoveryNotices(tx, { companyId: input.companyId, issueId: input.sourceIssueId });
if ((!input.preserveExistingOwner && existing.cause !== input.cause)
|| (input.evidence?.runId !== undefined && input.evidence.runId !== existing.evidence?.runId)) {
await notifyUnsafeWorkspaceExportRecovery(tx, { companyId: input.companyId, issueId: input.sourceIssueId });
}
return tx
.update(issueRecoveryActions)
.set({
status: "escalated",
ownerType: existing.ownerType === "system" || input.ownerType === "system" ? "system" : "board",
ownerAgentId: null,
ownerUserId: null,
previousOwnerAgentId:
existing.ownerAgentId ?? existing.previousOwnerAgentId,
returnOwnerAgentId:
input.returnOwnerAgentId ??
existing.returnOwnerAgentId ??
existing.ownerAgentId,
evidence: {
...(existing.evidence ?? {}),
...(input.evidence ?? {}),
recoveryBudget: {
state: "exhausted",
attemptsUsed,
maxAttempts: effectiveMaxAttempts,
exhaustedAt: now.toISOString(),
cause: existing.cause,
fingerprint: existing.fingerprint,
},
},
nextAction: existing.cause === "native_workspace_finalization_owner_unverified"
? input.nextAction
: `Automatic recovery exhausted after ${attemptsUsed}/${effectiveMaxAttempts} attempts. ` +
"Review the infrastructure failure and explicitly choose a replacement run or provider configuration.",
wakePolicy: null,
monitorPolicy: null,
attemptCount: attemptsUsed,
maxAttempts: effectiveMaxAttempts,
timeoutAt: null,
lastAttemptAt: input.lastAttemptAt ?? now,
outcome: "escalated",
resolutionNote: null,
resolvedAt: null,
updatedAt: now,
})
.where(
and(
eq(issueRecoveryActions.id, existing.id),
inArray(issueRecoveryActions.status, [
...ACTIVE_RECOVERY_ACTION_STATUSES,
]),
),
)
.returning();
}, { companyId: input.companyId });
if (!exhausted) {
return retryUpsertSourceScoped(input, retryCount);
}
return toReadModel(exhausted);
}
const [updated] = await writeExecutionControl(db, [DELIVERY_QUEUES.executionDisposition, DELIVERY_QUEUES.executionContinuation], async tx => {
await notifyIssueRecovery(tx, { companyId: input.companyId });
await notifyNativeRecoveryNotices(tx, { companyId: input.companyId, issueId: input.sourceIssueId });
if ((!input.preserveExistingOwner && existing.cause !== input.cause)
|| (input.evidence?.runId !== undefined && input.evidence.runId !== existing.evidence?.runId)) {
await notifyUnsafeWorkspaceExportRecovery(tx, { companyId: input.companyId, issueId: input.sourceIssueId });
}
return tx
.update(issueRecoveryActions)
.set({
recoveryIssueId: input.preserveExistingOwner
? existing.recoveryIssueId
: input.recoveryIssueId ?? null,
kind: input.preserveExistingOwner ? existing.kind : input.kind,
status: input.preserveExistingOwner ? existing.status : "active",
ownerType: input.preserveExistingOwner ? existing.ownerType : ownerType,
ownerAgentId: input.preserveExistingOwner
? existing.ownerAgentId
: input.ownerAgentId ?? null,
ownerUserId: input.preserveExistingOwner
? existing.ownerUserId
: input.ownerUserId ?? null,
previousOwnerAgentId: input.preserveExistingOwner
? existing.previousOwnerAgentId
: input.previousOwnerAgentId ?? existing.previousOwnerAgentId,
returnOwnerAgentId: input.preserveExistingOwner
? existing.returnOwnerAgentId
: input.returnOwnerAgentId ?? existing.returnOwnerAgentId,
cause: input.preserveExistingOwner ? existing.cause : input.cause,
fingerprint: input.preserveExistingOwner ? existing.fingerprint : input.fingerprint,
evidence: input.preserveExistingOwner
? {
...(existing.evidence ?? {}),
...(input.evidence ?? {}),
}
: input.evidence ?? existing.evidence,
nextAction: input.preserveExistingOwner ? existing.nextAction : input.nextAction,
wakePolicy: input.preserveExistingOwner
? existing.wakePolicy
: input.wakePolicy ?? null,
monitorPolicy: input.preserveExistingOwner
? existing.monitorPolicy
: input.monitorPolicy ?? null,
attemptCount: nextAttemptCount,
maxAttempts: input.preserveExistingOwner
? existing.maxAttempts
: input.maxAttempts === undefined
? existing.maxAttempts
: input.maxAttempts,
timeoutAt: input.preserveExistingOwner
? asDatabaseDate(existing.timeoutAt)
: input.timeoutAt ?? null,
lastAttemptAt: input.preserveExistingOwner
? asDatabaseDate(existing.lastAttemptAt)
: input.lastAttemptAt ?? now,
outcome: input.preserveExistingOwner ? existing.outcome : null,
resolutionNote: input.preserveExistingOwner ? existing.resolutionNote : null,
resolvedAt: null,
updatedAt: now,
})
.where(
and(
eq(issueRecoveryActions.id, existing.id),
inArray(issueRecoveryActions.status, [...ACTIVE_RECOVERY_ACTION_STATUSES]),
),
)
.returning();
}, { companyId: input.companyId });
if (!updated) {
return retryUpsertSourceScoped(input, retryCount);
}
return toReadModel(updated!);
}
try {
const [created] = await writeExecutionControl(db, [DELIVERY_QUEUES.executionDisposition, DELIVERY_QUEUES.executionContinuation], async tx => {
await notifyIssueRecovery(tx, { companyId: input.companyId });
await notifyNativeRecoveryNotices(tx, { companyId: input.companyId, issueId: input.sourceIssueId });
await notifyUnsafeWorkspaceExportRecovery(tx, { companyId: input.companyId, issueId: input.sourceIssueId });
return tx
.insert(issueRecoveryActions)
.values(buildInsertValues(input, ownerType, now))
.returning();
}, { companyId: input.companyId });
return toReadModel(created!);
} catch (error) {
if (!isUniqueRecoveryActionConflict(error)) throw error;
return retryUpsertSourceScoped(input, retryCount, error);
}
}
async function upsertSourceScoped(
input: UpsertIssueRecoveryActionInput,
): Promise<IssueRecoveryAction> {
return runExclusiveUpsert(input, () => upsertSourceScopedUnlocked(input));
}
async function resolveActiveForIssue(
input: ResolveIssueRecoveryActionInput,
dbOrTx: DbOrTransaction = db,
): Promise<IssueRecoveryAction | null> {
const now = new Date();
const predicates = [
eq(issueRecoveryActions.companyId, input.companyId),
eq(issueRecoveryActions.sourceIssueId, input.sourceIssueId),
inArray(issueRecoveryActions.status, [...ACTIVE_RECOVERY_ACTION_STATUSES]),
];
if (input.actionId) {
predicates.push(eq(issueRecoveryActions.id, input.actionId));
}
if (input.kind) {
predicates.push(eq(issueRecoveryActions.kind, input.kind));
}
if (input.cause) {
predicates.push(eq(issueRecoveryActions.cause, input.cause));
}
if (input.fingerprint) {
predicates.push(eq(issueRecoveryActions.fingerprint, input.fingerprint));
}
const [updated] = await writeExecutionControl(dbOrTx, [DELIVERY_QUEUES.executionDisposition, DELIVERY_QUEUES.executionContinuation], async tx => {
const pending = await tx.select({ id: issueRecoveryActions.id }).from(issueRecoveryActions)
.where(and(...predicates)).limit(1);
if (!pending.length) return [];
await notifyIssueRecovery(tx, { companyId: input.companyId });
await notifyNativeRecoveryNotices(tx, { companyId: input.companyId, issueId: input.sourceIssueId });
await notifyUnsafeWorkspaceExportRecovery(tx, { companyId: input.companyId, issueId: input.sourceIssueId });
return tx
.update(issueRecoveryActions)
.set({
status: input.status,
outcome: input.outcome,
resolutionNote: input.resolutionNote ?? null,
resolvedAt: now,
updatedAt: now,
})
.where(and(...predicates))
.returning();
}, { companyId: input.companyId });
return updated ? toReadModel(updated) : null;
}
return {
getActiveForIssue,
listActiveForIssues,
resolveActiveForIssue,
upsertSourceScoped,
};
}
export { toReadModel as issueRecoveryActionReadModel };