Files
PaperClipAI/server/src/services/issue-recovery-actions.ts
T
DottaandClaude Opus 5.5 2ce5f03cd8 fix(workspaces): name remote restore failures and leave a board recovery action (#19207)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - A remote task restores its saved workspace into a sandbox before the
agent starts.
> - When that restore fails, the run records only
`remote_workspace_operation_failed: operation=restore exit=1`.
> - The run then fails as `setup_failed`. No retry starts, and a
`blocked` task gets no recovery action.
> - An operator cannot tell which step failed, and nobody owns the next
step.
> - This pull request makes the sandbox scripts name their failed step
in a closed grammar, and it gives a restore failure a bounded retry or a
named board recovery action.
> - The benefit is a visible cause and a clear owner. Saved work stays
protected.

## Linked Issues or Issue Description

Refs #19152 and #19187. Those changes name copy-back (save) failures.
Refs #18852 and #19195. Those changes added the bounded bootstrap retry
that this change reuses.

**What happened?**

A board reply woke an agent on a `blocked` parent task. The run failed
during setup 13 seconds later. The only run event was
`remote_workspace_operation_failed: operation=restore exit=1`. The run
had no workspace operation row. The task stayed `blocked`, and its
recovery actions showed `active: null`. The same opaque family also
appeared for workspace preparation (`exit=1` only).

**Expected behavior**

The run names which restore step failed, without command output or
paths. A failure before provider work uses a bounded retry when a retry
can help. Otherwise, and when the retry budget is spent, the board gets
a named recovery action. The saved version is never changed or
overwritten.

**Steps to reproduce**

1. Save a remote workspace, then start a new run on a different
allocation.
2. Put a folder at the published workspace path in the sandbox before
restore.
3. Observe that the base code records only `operation=restore exit=1`,
then `setup_failed`, with no recovery action for a `blocked` task.

**Paperclip version or commit**

Base: `87dfba6818`.

**Deployment mode**

Legacy runtime with sandbox (Daytona) remote workspace storage.

## What Changed

- The prepare, repository, and restore sandbox scripts track their
current stage. On failure they print `remote_workspace_<op>_failed:
stage=<stage>` plus closed fields: `reason=` (the script's own
snake_case refusal), `archive=` (the archive helper's fixed reason),
`code=` (errno), `disk=full`, `git=` (a fixed label chosen from Git
output), and the existing `exit`/`signal`/`program` fields.
- The server accepts that text only when it matches a strict grammar for
the same operation. Other output is dropped, as before.
- Provider refusals during the restore upload and restore command are
recorded by closed labels, like copy-back.
- A failed saved-workspace restore raises `RemoteWorkspaceRestoreError`
with the server step (`snapshot_lookup`, `snapshot_download`,
`sandbox_restore`). It is retryable only when the owner re-reads the
same writer, epoch, and saved version with no unsaved work, and the
sandbox did not name a refusal reason.
- Setup failure handling classifies a pre-dispatch legacy restore
failure as `remote_workspace_restore_failed`. A retryable failure uses
the existing bounded bootstrap retry. A non-retryable or exhausted
failure records a board-owned recovery action with the redacted cause,
when stranded-work recovery does not scan the task status (for example
`blocked`).
- Stranded-work recovery gets a named notice for this code. Generic
continuation recovery does not grant it a second budget, and legacy
reconciliation does not invent provider actions for it.
- `doc/execution-semantics.md` documents the contract.

## Verification

- Red: the new transport tests failed on the base code with
`remote_workspace_operation_failed: operation=restore exit=1` (and the
same for prepare and repository). The new completion tests failed
because the run became `setup_failed` with no retry and no recovery
action.
- Green: `npx vitest run src/services/remote-workspace-transport.test.ts
src/services/heartbeat/run-completion.test.ts
src/services/remote-workspaces.integration.test.ts
src/__tests__/heartbeat-retry-scheduling.test.ts
src/services/legacy-execution-recovery.test.ts src/services/recovery
src/services/remote-workspace-save-concurrency.test.ts
src/services/native-runtime/native-workspace-finalizer-failure.test.ts
src/__tests__/native-workspace-finalization-work.test.ts` passes (563
tests).
- The transport tests use real sandbox commands. They prove an occupied
folder reports `stage=inspect_root reason=occupied_root` and keeps its
files. They prove a wrong manifest reports `stage=extract
archive=manifest_mismatch`. They prove a missing branch reports
`stage=checkout_branch`, and a changed origin reports
`stage=verify_origin`. Forged output with a path is dropped.
- The real-PostgreSQL cold-restore test proves an occupied root gives a
non-retryable `sandbox_restore` failure and keeps the unknown files.
- The completion tests cover retried, exhausted, refused, post-dispatch,
and `in_progress` cases.
- `tsc --noEmit` for the server reports no errors in the changed files.

## Risks

- A retry keeps the same restore path. If the cause is deterministic but
unnamed, the run uses its two retries before the board action. Named
refusals skip the retries.
- The board action is recorded only for statuses that stranded-work
recovery does not scan, and only when no other action is active. This
prevents a second recovery identity for one failure.
- Script stderr grammar is new. Old sandboxes run the script that the
server sends, so no runner update is necessary.
- No migration, deployment, or credential 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

Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, through Claude Code with
tool use, code execution, and browser tools for read-only staging
evidence. Context window and reasoning mode are host-managed.

## 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 Opus 5.5 <noreply@anthropic.com>
2026-11-05 02:40:01 -06:00

645 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;
// When true, an existing active action is returned unchanged. A concurrent
// writer that wins the insert keeps its owner, cause, and evidence.
createOnly?: 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();
if (existing && input.createOnly) return existing;
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 };