mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-08 22:13:59 +01:00
## 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>
641 lines
27 KiB
TypeScript
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 };
|