Files
PaperClipAI/server/src/services/execution-recovery-resolution.ts
T
DottaandPaperclip bd64eb27b0 perf(recovery): use indexed execution identity lookups (#16291)
## Thinking Path

> - Paperclip manages AI agents and their execution.
> - Recovery must keep old execution ownership visible before new work
starts.
> - Ownership reads filter large run contexts through a combined text
expression.
> - Recovery and status delivery also cast indexed UUID columns to text.
> - These reads can scan history before finding the requested execution.
> - This pull request preserves identity and ownership rules while using
existing indexes.
> - The benefit is less database work during recovery and task polling.

## Linked Issues or Issue Description

Refs #14406. Related community report: #15275. This is an independent
implementation. It does not use that pull request's code or commits. It
covers the reported execution joins and the additional ownership query
hotspot.

**What happened?**

Accumulated staging query statistics showed 11,782 ownership reads at 51
ms each. A paired recovery-action read averaged 22 ms. Both queried
large run JSON. Text casts also prevented indexed UUID lookups in
recovery and status delivery.

**What did you expect to happen?**

Recovery must find the same execution evidence through existing indexes.
Malformed identifiers must match nothing without a cast error. Native
issue bindings must retain precedence. Missing termination evidence must
continue to block new work.

**Steps to reproduce**

Run the disposable benchmark below. It loads the original service
modules from the baseline revision. It compares their exact ownership,
blocker action and status-delivery SQL with the current modules on 1,400
synthetic runs, 350 historical recovery actions and 192 KiB contexts.

## What Changed

- Convert saved text to UUID only after checking its canonical lowercase
form.
- Keep indexed run and task UUID columns unchanged in execution joins.
- Split native and legacy issue predicates so existing indexes can
filter ownership candidates.
- Preserve company scope, native precedence, process and lease holds,
recovery eligibility and delivery receipts.
- Add real PostgreSQL identity and ownership regressions, a reproducible
benchmark and database documentation.

## Verification

- Local service SQL benchmark: ownership 78.30 ms to 1.14 ms, with the
same two complete rows. Blocker action read 76.94 ms to 22.55 ms, with
the same complete row. These are synthetic SQL timings, excluding
process probes, provider calls and HTTP.
- Status delivery returned the same 100 rows. Timing was neutral on the
small-task fixture: 7.85 ms before and 8.30 ms after. No speedup is
claimed for that case.
- 231 local tests passed across six server suites. They cover malformed
identifiers, canonical spelling, company scope, native precedence,
retained processes, workspace restore, native replacement, conversations
and delivery.
- `pnpm -r typecheck` and `pnpm build` passed.
- `git diff --check` passed.
- All current-head CI and security checks passed, including the full
general and serialized suites, browser shards, Runner verification and
build. Greptile reviewed the exact commit at 5/5 with no actionable
findings.
- The older full local sweep in the companion worktree was stopped after
final-commit complete CI passed. It is not claimed as a completed local
pass. The 231 focused tests above passed locally on this branch.

```sh
pnpm --filter @paperclipai/server exec vitest run src/services/conversation-continuation.test.ts src/services/legacy-execution-recovery.test.ts src/__tests__/legacy-workspace-restore-recovery.test.ts src/__tests__/chat-completion-delivery.test.ts src/__tests__/agent-conversations.test.ts src/services/native-runtime/native-safe-replacement.test.ts
pnpm --filter @paperclipai/server exec tsx ../scripts/benchmark-recovery-queries.ts --baseline-ref=1e731520bb
```

## Risks

- PostgreSQL accepts more UUID spellings than the old text joins. The
explicit guard preserves the old lowercase spelling requirement.
- Query plans depend on the data distribution. Small task sets can show
neutral status-delivery timing. The benchmark is not a production
latency claim.
- Recovery ownership checks remain conservative. Process, lease,
workspace and no-replay rules are unchanged.
- No migration, new index, cache, cleanup change or lock removal is
included.

## Model Used

OpenAI GPT-6 through Codex, with reasoning, repository inspection, shell
tools and local code execution. The runtime does not expose a more
specific backend model ID or context-window size.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-10-14 10:14:58 -05:00

612 lines
23 KiB
TypeScript

import { hasWorkspaceRestoreFailure } from "@paperclipai/shared";
import { randomUUID } from "node:crypto";
import { canonicalExecutionUuid } from "./execution-identity.js";
import { claimedAdapterType, conversationRecoveryActionPredicate, getConversationOwnershipBlocker } from "./conversation-continuation.js";
import { persistActivity } from "./activity-log.js";
import { appendHeartbeatRunEvent } from "./heartbeat-run-events.js";
import { logger } from "../middleware/logger.js";
import { and, eq, inArray, isNull, not, or, sql } from "drizzle-orm";
import {
chatActions,
environmentLeases,
heartbeatRuns,
issueRecoveryActions,
issues,
nativeRunFinalizations,
type Db,
} from "@paperclipai/db";
import { conflict } from "../errors.js";
import { buildExecutionContinuation } from "./execution-continuation.js";
import {
EXECUTION_RECONCILIATION_CAUSES,
type ExecutionReconciliation,
} from "@paperclipai/shared";
import { parseIssueExecutionState } from "./issue-execution-policy.js";
import { isSupersededConversationRun } from "./agent-conversations.js";
import { hasRequiredWorkspaceRecovery } from "./workspace-restore-recovery-state.js";
import { hasRemoteTerminationReceipt } from "./remote-execution-termination.js";
/** An operator records observed outcomes; this is not permission to blindly retry. */
export async function validateExecutionReconciliation(input: {
db: Db;
companyId: string;
issueId: string;
agentId: string | null;
sourceRunId: unknown;
decision: ExecutionReconciliation | undefined;
/** Board-only source repair: never authorize a continuation of this run. */
workspaceRepairOnly?: boolean;
}) {
const { db, companyId, issueId, agentId, decision } = input;
if (!decision || decision.runId !== input.sourceRunId || (!agentId && !input.workspaceRepairOnly)) {
throw conflict(
"Reconcile the recorded execution and its action outcomes before continuing this task.",
);
}
const [run] = await db
.select()
.from(heartbeatRuns)
.where(
and(
eq(heartbeatRuns.companyId, companyId),
eq(heartbeatRuns.id, decision.runId),
),
);
const [task] = await db
.select()
.from(issues)
.where(and(eq(issues.companyId, companyId), eq(issues.id, issueId)));
const review =
task?.status === "in_review"
? parseIssueExecutionState(task.executionState)
: null;
const isCurrentReviewer =
review?.status === "pending" &&
review.currentParticipant?.type === "agent" &&
review.currentParticipant.agentId === run?.agentId;
const retainedWorkspace = hasRequiredWorkspaceRecovery(run?.resultJson);
const repairOnly = input.workspaceRepairOnly === true && retainedWorkspace;
const terminalRepairSource = ["failed", "interrupted", "timed_out", "cancelled"].includes(run?.status ?? "") ||
(repairOnly && run?.runtimeMode === "legacy" && run.status === "succeeded");
if (
(input.workspaceRepairOnly && !retainedWorkspace) ||
!run ||
!task ||
(!repairOnly && (task.assigneeAgentId !== agentId || (run.agentId !== agentId && !isCurrentReviewer))) ||
(run.nativeIssueId ?? run.contextSnapshot?.issueId) !== issueId ||
!terminalRepairSource
) {
throw conflict(
"The recovery source or task owner changed. Inspect the current execution before continuing.",
);
}
if (hasWorkspaceRestoreFailure(run.resultJson) &&
(!decision.workspaceRepairEvidence || decision.workspaceRepairEvidence.trim().length < 20)) {
throw conflict("Verify safe workspace staging or repair and record workspaceRepairEvidence before continuing this run.");
}
let checkLocalProcesses = true;
if (retainedWorkspace) {
const leases = await db.select().from(environmentLeases).where(and(
eq(environmentLeases.companyId, companyId), eq(environmentLeases.heartbeatRunId, run.id),
));
const recovery = run.resultJson!.workspaceRestoreRecovery as { leaseIds?: unknown };
const retainedIds = recovery.leaseIds;
// The terminal transaction records the exact source allocations. A release
// timestamp is written before stop dispatch, so only matching provider
// acknowledgements can establish that repair will not race active writes.
if (!Array.isArray(retainedIds) || !retainedIds.length ||
!retainedIds.every(id => typeof id === "string" && leases.some(lease =>
lease.id === id && hasRemoteTerminationReceipt(lease))) ||
leases.some(lease => lease.provider === "local"
? !lease.releasedAt || lease.status === "pending_cleanup" || lease.cleanupStatus === "failed"
: !hasRemoteTerminationReceipt(lease))) {
throw conflict("The retained workspace environment has not confirmed that it stopped. Wait for cleanup before recording workspace repair.");
}
checkLocalProcesses = leases.some(lease => lease.provider === "local");
}
// A retained remote source's PIDs belong to its sandbox, not this server.
// Its exact lease receipts above replace host-local process probes.
for (const pid of checkLocalProcesses ? [
run.processPid,
run.processGroupId ? -run.processGroupId : null,
] : []) {
if (!pid) continue;
try {
process.kill(pid, 0);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ESRCH") continue;
throw conflict(
"The previous provider's process ownership cannot be verified.",
);
}
throw conflict(
"The previous provider is still running. Stop it before continuing.",
);
}
const [coordinator] = await db
.select()
.from(nativeRunFinalizations)
.where(
and(
eq(nativeRunFinalizations.companyId, companyId),
eq(nativeRunFinalizations.runId, run.id),
),
);
if (coordinator?.leaseOwner || coordinator?.failureDetail?.successorRunId)
throw conflict(
"This execution still has a coordinator or a linked continuation. Inspect that run first.",
);
const leases = await db
.select({ id: environmentLeases.id })
.from(environmentLeases)
.where(
and(
eq(environmentLeases.companyId, companyId),
eq(environmentLeases.heartbeatRunId, run.id),
isNull(environmentLeases.releasedAt),
),
)
.limit(1);
if (leases.length)
throw conflict(
"The previous execution environment has not finished releasing its authority.",
);
if (!repairOnly) await buildExecutionContinuation({
db,
companyId,
issueId,
agentId: agentId!,
context: { previousRunId: run.id },
summary: null,
exposeLowTrustRaw: false,
});
return run;
}
/** Durable delivery marker lives on the existing source-scoped recovery action. */
export async function markExecutionReconciliation(
db: Db,
action: Pick<
typeof issueRecoveryActions.$inferSelect,
"companyId" | "id" | "evidence" | "sourceIssueId"
>,
decision: ExecutionReconciliation,
actorId: string,
deliveryOwner?: { kind: "chat_failed_run_retry"; actionId: string },
options?: { workspaceRepairOnly?: boolean },
) {
if (options?.workspaceRepairOnly && (deliveryOwner || !hasRequiredWorkspaceRecovery(action.evidence))) {
throw conflict("Workspace repair must target the retained source without a continuation owner.");
}
if (deliveryOwner) {
const [retry] = await db
.select()
.from(chatActions)
.where(
and(
eq(chatActions.companyId, action.companyId),
eq(chatActions.id, deliveryOwner.actionId),
),
);
if (
deliveryOwner.kind !== "chat_failed_run_retry" ||
!retry ||
retry.kind !== "failed_run_retry" ||
!["issued", "processing", "processed"].includes(retry.status) ||
retry.payload.version !== 1 ||
retry.payload.failedRunId !== decision.runId ||
retry.payload.issueId !== action.sourceIssueId
) {
throw conflict("The authorized chat retry owner is no longer valid.");
}
}
await db
.update(nativeRunFinalizations)
.set({
failureDetail: sql`coalesce(${nativeRunFinalizations.failureDetail}, '{}'::jsonb) || ${JSON.stringify({ replacementDenied: "operator_reconciled" })}::jsonb`,
})
.where(
and(
eq(nativeRunFinalizations.companyId, action.companyId),
eq(nativeRunFinalizations.runId, decision.runId),
),
);
await db
.update(issueRecoveryActions)
.set({
evidence: {
...action.evidence,
automaticRecovery: undefined,
executionReconciliation: {
...decision,
actorId,
recordedAt: new Date().toISOString(),
},
continuationDelivery: options?.workspaceRepairOnly ? "not_requested" : deliveryOwner ? "delegated" : "pending",
...(deliveryOwner ? { continuationDeliveryOwner: deliveryOwner } : {}),
},
})
.where(
and(
eq(issueRecoveryActions.companyId, action.companyId),
eq(issueRecoveryActions.id, action.id),
),
);
}
export async function deliverReconciledExecutions(
db: Db,
wake: ReturnType<typeof import("./heartbeat.js").heartbeatService>["wakeup"],
) {
const pending = await db
.select()
.from(issueRecoveryActions)
.where(
and(
eq(issueRecoveryActions.status, "resolved"),
sql`${issueRecoveryActions.evidence}->>'continuationDelivery' = 'pending'`,
),
)
.limit(25);
for (const action of pending) {
try {
const decision = action.evidence.executionReconciliation as
ExecutionReconciliation | undefined;
if (!decision || !action.returnOwnerAgentId) continue;
const pendingDecision = and(
eq(issueRecoveryActions.companyId, action.companyId),
eq(issueRecoveryActions.id, action.id),
eq(issueRecoveryActions.status, "resolved"),
sql`${issueRecoveryActions.evidence}->>'continuationDelivery' = 'pending'`,
sql`${issueRecoveryActions.evidence}->'executionReconciliation' = ${JSON.stringify(decision)}::jsonb`,
);
const [task] = await db
.select()
.from(issues)
.where(
and(
eq(issues.companyId, action.companyId),
eq(issues.id, action.sourceIssueId),
),
);
if (
!task ||
task.assigneeAgentId !== action.returnOwnerAgentId ||
["done", "cancelled"].includes(task.status)
) {
await db
.update(issueRecoveryActions)
.set({
evidence: sql`${issueRecoveryActions.evidence} || '{"continuationDelivery":"invalidated"}'::jsonb`,
})
.where(pendingDecision);
continue;
}
const run = await wake(action.returnOwnerAgentId, {
source: "automation",
triggerDetail: "system",
reason: "issue_recovery_action_restored",
idempotencyKey: `execution-reconciliation:${action.id}`,
payload: { issueId: task.id, recoveryActionId: action.id },
requestedByActorType: "system",
requestedByActorId: "execution-recovery",
contextSnapshot: {
issueId: task.id,
taskId: task.id,
recoveryActionId: action.id,
previousRunId: decision.runId,
retryOfRunId: decision.runId,
forceFreshSession: true,
wakeReason: "issue_recovery_action_restored",
source: "execution.reconciled",
},
});
if (run)
await db.transaction(async (tx) => {
await tx
.update(heartbeatRuns)
.set({ retryOfRunId: decision.runId })
.where(
and(
eq(heartbeatRuns.companyId, action.companyId),
eq(heartbeatRuns.id, run.id),
eq(heartbeatRuns.agentId, action.returnOwnerAgentId!),
sql`${heartbeatRuns.contextSnapshot}->>'recoveryActionId' = ${action.id}`,
sql`${heartbeatRuns.contextSnapshot}->>'previousRunId' = ${decision.runId}`,
),
);
await tx
.update(issueRecoveryActions)
.set({
evidence: sql`${issueRecoveryActions.evidence} || ${JSON.stringify(
{
continuationDelivery: "delivered",
continuationRunId: run.id,
},
)}::jsonb`,
})
.where(pendingDecision);
});
} catch {
logger.warn(
{ recoveryActionId: action.id },
"Reconciled execution continuation remains pending for retry",
);
}
}
}
/**
* Failed execution is a system responsibility, not a user questionnaire. After
* automatic recovery is ruled out, preserve evidence and stop without replay.
* This is NOT evidence that an external action succeeded or never happened.
* The resolved record retains a dispatch hold until actual evidence clears it.
*/
export async function settleUnrecoverableExecutions(
db: Db,
now = new Date(),
options: { failpoint?: (phase: "persisted") => void } = {},
) {
// Fold obsolete conversation holds without waking historical work on upgrade.
// Keep their evidence and record the policy change in the task's activity log.
const obsoleteConversationHold = and(
conversationRecoveryActionPredicate(),
or(
inArray(issueRecoveryActions.status, ["active", "escalated"]),
sql`${issueRecoveryActions.evidence}->'automaticRecovery'->>'replay' = 'blocked'`,
),
);
await db.transaction(async tx => {
const foldable = await tx.select().from(issueRecoveryActions).where(obsoleteConversationHold)
.limit(25).for("update", { skipLocked: true });
for (const candidate of foldable) {
if (await getConversationOwnershipBlocker(tx as unknown as Db, candidate.companyId, candidate.sourceIssueId)) continue;
const [action] = await tx.update(issueRecoveryActions).set({
status: "resolved",
outcome: "cancelled",
resolvedAt: now,
updatedAt: now,
nextAction: "Automatic attempts stopped. Send a new message to continue the conversation.",
resolutionNote: "Conversation continuation does not replay prior tool calls.",
wakePolicy: null,
monitorPolicy: null,
evidence: sql`case when ${issueRecoveryActions.evidence} ? 'automaticRecovery'
then jsonb_set(${issueRecoveryActions.evidence}, '{automaticRecovery,replay}', '"conversation_continuation"'::jsonb)
else ${issueRecoveryActions.evidence} end`,
}).where(and(obsoleteConversationHold, eq(issueRecoveryActions.id, candidate.id))).returning();
if (!action) continue;
await persistActivity(tx as unknown as Db, {
companyId: action.companyId,
actorType: "system",
actorId: "execution-recovery",
action: "issue.execution_recovery_settled",
entityType: "issue",
entityId: action.sourceIssueId,
details: { recoveryActionId: action.id, outcome: "cancelled", continuation: "conversation" },
});
}
});
// Filter eligibility before applying the batch limit. A queue of sessions
// awaiting replacement must not starve settled incidents behind it.
const candidates = await db
.select({ action: issueRecoveryActions })
.from(issueRecoveryActions)
.innerJoin(
heartbeatRuns,
and(
eq(heartbeatRuns.companyId, issueRecoveryActions.companyId),
eq(heartbeatRuns.id, canonicalExecutionUuid(sql`${issueRecoveryActions.evidence}->>'runId'`)),
or(
eq(heartbeatRuns.nativeIssueId, issueRecoveryActions.sourceIssueId),
and(isNull(heartbeatRuns.nativeIssueId), sql`${heartbeatRuns.contextSnapshot}->>'issueId' = ${issueRecoveryActions.sourceIssueId}::text`),
),
),
)
.leftJoin(
nativeRunFinalizations,
and(
eq(nativeRunFinalizations.companyId, heartbeatRuns.companyId),
eq(nativeRunFinalizations.runId, heartbeatRuns.id),
),
)
.where(
and(
not(conversationRecoveryActionPredicate()!),
inArray(issueRecoveryActions.status, ["active", "escalated"]),
eq(issueRecoveryActions.kind, "active_run_watchdog"),
inArray(issueRecoveryActions.cause, [
...EXECUTION_RECONCILIATION_CAUSES,
]),
inArray(heartbeatRuns.status, [
"failed",
"timed_out",
"interrupted",
"cancelled",
]),
isNull(nativeRunFinalizations.leaseOwner),
isNull(nativeRunFinalizations.resultId),
or(
isNull(nativeRunFinalizations.runId),
eq(nativeRunFinalizations.phase, "terminal_failure"),
),
sql`coalesce(${nativeRunFinalizations.failureDetail}->>'successorRunId', '') = ''`,
sql`(${heartbeatRuns.runtimeMode} <> 'native' or coalesce(${nativeRunFinalizations.failureCode}, '') <> 'native_provider_terminal_failed'
or coalesce(${nativeRunFinalizations.failureDetail}->>'replacementDenied', '') <> '')`,
),
)
.limit(25);
for (const { action: candidate } of candidates) {
const runId = candidate.evidence.runId;
if (typeof runId !== "string") continue;
try {
await db.transaction(async (tx) => {
await tx.execute(
sql`select set_config('statement_timeout', '15000', true), set_config('lock_timeout', '1000', true)`,
);
// Same issue -> coordinator -> run ordering as replacement/finalization.
const [task] = await tx
.select()
.from(issues)
.where(
and(
eq(issues.companyId, candidate.companyId),
eq(issues.id, candidate.sourceIssueId),
),
)
.for("update");
const [coordinator] = await tx
.select()
.from(nativeRunFinalizations)
.where(
and(
eq(nativeRunFinalizations.companyId, candidate.companyId),
eq(nativeRunFinalizations.runId, runId),
),
)
.for("update");
const [run] = await tx
.select()
.from(heartbeatRuns)
.where(
and(
eq(heartbeatRuns.companyId, candidate.companyId),
eq(heartbeatRuns.id, runId),
),
)
.for("update");
const [action] = await tx
.select()
.from(issueRecoveryActions)
.where(eq(issueRecoveryActions.id, candidate.id))
.for("update");
if (
!task ||
!run ||
!action ||
action.evidence.runId !== runId ||
!EXECUTION_RECONCILIATION_CAUSES.includes(
action.cause as (typeof EXECUTION_RECONCILIATION_CAUSES)[number],
) ||
!["active", "escalated"].includes(action.status) ||
(run.nativeIssueId ?? run.contextSnapshot?.issueId) !== task.id ||
!["failed", "timed_out", "interrupted", "cancelled"].includes(
run.status,
)
)
return;
// Give durable native recovery its chance; never preempt a resume,
// replacement, result finalizer, or still-owned execution.
if (
coordinator?.leaseOwner ||
coordinator?.resultId ||
coordinator?.failureDetail?.successorRunId ||
(coordinator && coordinator.phase !== "terminal_failure") ||
(run.runtimeMode === "native" &&
coordinator?.failureCode === "native_provider_terminal_failed" &&
!coordinator.failureDetail?.replacementDenied)
)
return;
const current =
!isSupersededConversationRun(task, run) &&
action.returnOwnerAgentId !== null &&
task.assigneeAgentId === action.returnOwnerAgentId &&
!["done", "cancelled"].includes(task.status) &&
(!task.executionRunId || task.executionRunId === run.id) &&
(!task.checkoutRunId || task.checkoutRunId === run.id);
const note = current
? hasWorkspaceRestoreFailure(run.resultJson)
? "Workspace repair required. Verify safe staging or repair before continuing. Saved work and approval decisions remain in force."
: "Automatic recovery stopped. Recorded work is preserved; actions with unverified outcomes will not be repeated."
: "Recovery closed because the task's owner, execution, or status changed. No work was replayed.";
let nativeFailureBlock = action.evidence.nativeFailureBlock;
let nativeBootstrapFailureBlock = action.evidence.nativeBootstrapFailureBlock;
if (current) {
const [projected] = await tx
.update(issues)
.set({
status: "blocked",
executionRunId: null,
checkoutRunId: null,
updatedAt: now,
})
.where(eq(issues.id, task.id)).returning();
// Only a transition owned by this failure grants a recovery receipt.
// An already-blocked task may have a separate human/dependency hold.
if (task.status !== "blocked" && run.runtimeMode === "native") {
nativeFailureBlock = { runId: run.id, statusVersion: projected!.statusVersion };
}
if (task.status !== "blocked" && run.runtimeMode === "legacy" && !run.runtimeModeResolvedAt &&
run.errorCode === "server_shutdown_interrupted" && claimedAdapterType(run) === "paperclip_runner") {
nativeBootstrapFailureBlock = { runId: run.id, statusVersion: projected!.statusVersion, previousStatus: task.status };
}
}
await tx
.update(issueRecoveryActions)
.set({
status: "resolved",
outcome: current ? "blocked" : "cancelled",
resolvedAt: now,
updatedAt: now,
nextAction: note,
resolutionNote: note,
wakePolicy: null,
monitorPolicy: null,
evidence: {
...action.evidence,
...(nativeFailureBlock ? { nativeFailureBlock } : {}),
...(nativeBootstrapFailureBlock ? { nativeBootstrapFailureBlock } : {}),
automaticRecovery: {
policy: "preserve_without_replay_v1",
runId: run.id,
replay: "blocked",
actionOutcome: "unknown",
recordedAt: now.toISOString(),
},
},
})
.where(eq(issueRecoveryActions.id, action.id));
await persistActivity(tx as unknown as Db, {
companyId: run.companyId,
actorType: "system",
actorId: "execution-recovery",
action: "issue.execution_recovery_settled",
entityType: "issue",
entityId: task.id,
runId: run.id,
details: {
recoveryActionId: action.id,
outcome: current ? "blocked" : "cancelled",
replay: "not_authorized",
},
});
await tx
.update(heartbeatRuns)
.set({ executionStatusDeliveryId: randomUUID() })
.where(eq(heartbeatRuns.id, run.id));
await appendHeartbeatRunEvent(tx as unknown as Db, {
companyId: run.companyId,
agentId: run.agentId,
runId: run.id,
eventType: "lifecycle",
stream: "system",
level: "warn",
message: note,
payload: {
recoveryActionId: action.id,
cause: action.cause,
automaticRecovery: "preserve_without_replay_v1",
replay: "blocked",
},
});
options.failpoint?.("persisted");
});
} catch (err) {
if (options.failpoint) throw err;
logger.warn(
{ err, recoveryActionId: candidate.id },
"Automatic recovery disposition remains pending",
);
}
}
}