mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-09 07:10:34 +01:00
## 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>
645 lines
27 KiB
TypeScript
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 };
|