mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-11 21:59:38 +01:00
## Thinking Path
> - Paperclip is the open source app people use to manage AI agents for
work
> - Native runs keep a finalization row. Its lease owner tells the
control plane that an execution may still be live.
> - Pausing an agent waits for `agentExecutionsHaveStopped`. That check
refuses to finish while any run of the agent has a lease owner, unless
the run was cancelled and has stop proof.
> - A same-run session resume claims that lease before it dispatches the
run. If the run then fails before the native executor takes its own
provider claim, nothing releases the resume owner.
> - The leaked owner blocks pause and resume for good, and the task
keeps looking like it has a live recovery path.
> - This pull request settles those rows at the source, and it adds a
stop-proof-gated release for rows that already leaked.
> - The benefit is that agents can pause and resume after an ordinary
resume failure, without database edits, and the cancelled-run stop-proof
rule stays as it is.
## Linked Issues or Issue Description
**What happened?**
An agent stayed in `pausing` with `hostComplete=false` and climbing
attempts. Its runs had `native_run_finalizations` rows with `phase:
observed`, `recovery_state: resuming_session`, a `lease_owner` like
`default:resume:<uuid>`, and no `result_id`. Each heartbeat run had
ended `failed`: either `setup_failed` (`Failed to acquire lease for
environment … Sandbox creation has failed`,
`native_remote_recovery_lease_mismatch`) or `adapter_failed`
(`runner_state_identity_mismatch: …`). The lease expired about 20
minutes after the run finished. Nothing settled the row. The only fix
was to set `lease_owner` to null by hand.
**Expected behavior**
When a resumed run fails before any provider turn starts, its
finalization row goes to `terminal_failure` and its lease owner is
released, as the native executor does for failures it sees. A row that
leaked before this fix is released in the background once its lease
expires and the run has stop proof. Pause then completes.
**Steps to reproduce**
1. Leave a native run in `retryable_failure` with a persisted
checkpoint, on a task that already has an active recovery incident.
2. Let session resume claim it (`claimNativeSessionResumptions` sets
`phase: observed` and a `…:resume:…` owner).
3. Make the resumed run fail before the executor claims it, for example
when the environment driver cannot create the sandbox.
4. The heartbeat failure path marks the run `failed`. The issue release
path does not settle the row, because it settles only when it opens a
new incident. The owner stays set, and `agentExecutionsHaveStopped`
returns false indefinitely.
**Paperclip version or commit**
`master` at 5ac05871b5.
**Additional context**
Root cause: two writers can end a resumed run without the executor.
`failRun` and the setup-failure path in `heartbeat/run-completion.ts`
never touch the coordinator. `recordNativeTerminalRecoveryIfNeeded` in
the wake-queue adapter settles the coordinator only when no recovery
incident exists yet. Agents that need their computer connection paired
again usually already have an incident, so the owner leaks.
Related, but different: #18905, #18866, #18072, and #17692 change how
the stop check judges runs. This pull request does not change the stop
check.
## What Changed
-
`server/src/services/native-runtime/native-failed-run-owner-release.ts`
(new): one settlement for a result-less `observed` owner on a run that
ended `failed`, `timed_out`, or `interrupted`. It writes
`terminal_failure`, null lease fields, `recoveryState: "blocked"`, the
run's failure code, and a `releasedOwner` record. It sets `nativePhase`,
sends the same finalization and execution work notifications as the
executor's terminal path, and logs `native.failed_run_owner_released`
activity. Locks follow the issue, coordinator, run order.
- `settleUnstartedNativeSessionResume`: runs after a dispatched session
resume returns. It matches only the exact claim owner and the attempt
the claim saw. The executor increments the attempt when it takes
provider ownership, so an unchanged attempt proves that no provider turn
started under this owner. The executor's own failures no longer match.
- `releaseAbandonedNativeOwners`: the background release for rows that
already leaked. It requires an expired lease and all of the stop proof
that `agentExecutionsHaveStopped` checks: settled environment leases, no
live run process or process group, no live controller lease, and no live
controller process. This process answers from its in-memory execution
registries. It skips cancelled runs, pending native Stops, and pinned
workspace restores, because those have their own stop-proof owners.
- `native-finalization-reconciler.ts`: `NativeSessionResumeClaim` now
carries `attempt`.
- `heartbeat.ts` and `heartbeat/recovery.ts`: both session-resume
dispatch sites settle the claim after `executeRun` returns. This is one
`.finally` per site.
- `execution-control-reconciliation.ts` and `execution-control-work.ts`:
the execution-finalization queue runs the background release. Its
deadline includes the earliest lease expiry among eligible rows, so the
release does not poll an idle instance. Rows with unreleased sandboxes
are excluded from the deadline.
- `agent-execution-stop.ts` is unchanged. The cancelled-only release
keeps its stop-proof rule.
## Verification
- `npx vitest run
src/__tests__/heartbeat-native-resume-lease-failure.test.ts` drives the
real heartbeat resume dispatch (`reapOrphanedRuns`). A sandbox driver's
`acquireRunLease` throws `Sandbox creation has failed`, and the task has
a recovery incident. The run fails with `Failed to acquire lease for
environment "Managed computer" (sandbox): Sandbox creation has failed`,
and the coordinator ends `terminal_failure` with no lease owner. I
removed the `.finally` wiring and ran the test again. It then failed,
and the row stayed `observed` with a `default:resume:` owner and a
20-minute expiry, which is the production state.
- `npx vitest run
src/services/native-runtime/native-failed-run-owner-release.test.ts` (10
tests):
- A claimed resume that fails at lease acquisition is settled.
`agentExecutionsHaveStopped` changes from false to true. A repeated
settlement does nothing.
- An owner the executor claimed (incremented attempt) and a run that is
still running are not changed.
- A leaked `runner_state_identity_mismatch` row is released by
`reconcileAbandonedExecutionControl`, and then
`agentExecutionsHaveStopped` returns true.
- An unexpired owner still blocks the stop check. Its expiry becomes the
finalization queue deadline.
- An active environment lease and a live controller process block
release. After both stop, the row is released.
- Cancelled runs and pending native Stops are not changed.
- Added during review:
- Releasing an owner wakes the cost-accounting worker.
- Releasing a run's environment lease wakes the finalization worker, and
the owner is then released.
- A recorded PID and group that now belong to an unrelated process do
not hold the owner.
- Bounded passes rotate past a held row (`[0, 1, 1]` with page size 1),
then wrap back to it.
- The immediate settle keeps the owner while the run's recorded process
is alive.
- Suites that cover the changed files pass together: the two new files,
`native-session-resumption.test.ts` (with the claim shape updated),
`execution-control-reconciliation.test.ts`,
`execution-control-work.test.ts`, and
`agent-lifecycle/__tests__/lifecycle.test.ts`. With
`environment-cleanup-work.test.ts` added, that is 7 files and 156 tests.
I did not run the full `heartbeat-process-recovery.test.ts` suite
locally. CI runs it.
- `pnpm --filter @paperclipai/server typecheck` passes.
## Risks
- Behaviour change: a resumed run that fails during setup now ends with
a terminal coordinator in every case. Before, this happened only when
the issue release path opened a new incident. After a restart, restart
recovery no longer sees such a row as `observed`. This matches what the
release path already does when no incident exists.
- The background release trusts an expired lease on a run that is
already terminal, together with the same process and lease evidence that
the stop check uses. A controller on another host cannot be probed by
PID. The stop check has the same limit today. A live owner still renews
its lease, and lease-gated writes from an expired owner are already
refused.
- If an expired row's process or controller still looks live, the
release retries at the shared 15-second finalization cadence until that
process exits. Each pass handles one bounded page, and a rotating cursor
moves to the next page on each pass.
- A run-scoped environment cleanup notification now also wakes the
execution-finalization queue. Passes are coalesced, and a pass is a
cheap deadline query when nothing is eligible.
- No schema change and no migration.
## Model Used
- Claude Opus 5.5 (`claude-opus-5-5`) through Claude Code, with tool use
(shell, file edits, local test runs).
## 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
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
358 lines
16 KiB
TypeScript
358 lines
16 KiB
TypeScript
import { notifyNativeEnvironmentSettlement } from "./native-environment-settlement-notifications.js";
|
|
import { notifyExternalAgentSettlement } from "../modules/external-agents/index.js";
|
|
import { notifyNativeStatusReassessment } from "./native-status-reassessment-notifications.js";
|
|
import { notifyNativeFinalizationWork } from "./native-finalization-notifications.js";
|
|
import { notifyUnsafeWorkspaceExportRunChange } from "./unsafe-workspace-export-notifications.js";
|
|
import { notifyCancelledWorkspacePreservation } from "./cancelled-workspace-preservation-notifications.js";
|
|
import { notifyInstructionCollection } from "./instruction-collection-notifications.js";
|
|
import { notifyExecutionRunWork } from "./execution-control-notifications.js";
|
|
import { randomUUID } from "node:crypto";
|
|
import { logger } from "../middleware/logger.js";
|
|
import { and, asc, eq, isNotNull, lte, sql } from "drizzle-orm";
|
|
import {
|
|
agents,
|
|
heartbeatRuns,
|
|
issues,
|
|
nativeRunFinalizations,
|
|
type Db,
|
|
} from "@paperclipai/db";
|
|
import { parseIssueExecutionState } from "./issue-execution-policy.js";
|
|
import { issueRecoveryActionService } from "./issue-recovery-actions.js";
|
|
import { hasLiveLegacyController } from "./legacy-controller-lease.js";
|
|
import { reportRunFailure } from "./run-failure-report.js";
|
|
import { getNativeReviewAssignment, readNativeReviewAssignmentContext } from "./native-runtime/native-review-participant.js";
|
|
import { preserveNativeFailedWorkspace } from "./native-runtime/native-failed-workspace-recovery.js";
|
|
import { EXECUTION_RECONCILIATION_INTERVAL_MS } from "./execution-control-deadline.js";
|
|
import { DELIVERY_QUEUES, notifyDeliveryWork } from "./delivery-work-notifications.js";
|
|
import { NATIVE_STARTUP_CLEANUP_PENDING, readNativeStartupContext, promoteNativeStartupCleanup } from "./native-runtime/native-startup-cleanup.js";
|
|
import { releaseAbandonedNativeOwners } from "./native-runtime/native-failed-run-owner-release.js";
|
|
|
|
/** Only newly recorded control deadlines are eligible. Upgrades never replay ambiguous historical runs. */
|
|
export async function reconcileAbandonedExecutionControl(
|
|
db: Db,
|
|
now = new Date(),
|
|
options: { startupCleanup?: (scope: { companyId: string; runId: string }) => Promise<unknown> } = {},
|
|
) {
|
|
const nativeDue = await db
|
|
.select({
|
|
runId: nativeRunFinalizations.runId,
|
|
issueId: nativeRunFinalizations.issueId,
|
|
companyId: nativeRunFinalizations.companyId,
|
|
failureCode: nativeRunFinalizations.failureCode,
|
|
leaseOwner: nativeRunFinalizations.leaseOwner,
|
|
controllerGeneration: nativeRunFinalizations.controllerGeneration,
|
|
attempt: nativeRunFinalizations.attempt,
|
|
})
|
|
.from(nativeRunFinalizations)
|
|
.where(
|
|
and(
|
|
isNotNull(nativeRunFinalizations.controlDeadlineAt),
|
|
lte(nativeRunFinalizations.controlDeadlineAt, now),
|
|
),
|
|
)
|
|
.orderBy(asc(nativeRunFinalizations.controlDeadlineAt), asc(nativeRunFinalizations.runId))
|
|
.limit(50);
|
|
const controlDue = await db
|
|
.select({
|
|
runId: heartbeatRuns.id,
|
|
companyId: heartbeatRuns.companyId,
|
|
context: heartbeatRuns.contextSnapshot,
|
|
nativeIssueId: nativeRunFinalizations.issueId,
|
|
failureCode: nativeRunFinalizations.failureCode,
|
|
leaseOwner: nativeRunFinalizations.leaseOwner,
|
|
controllerGeneration: nativeRunFinalizations.controllerGeneration,
|
|
attempt: nativeRunFinalizations.attempt,
|
|
})
|
|
.from(heartbeatRuns)
|
|
.leftJoin(nativeRunFinalizations, eq(nativeRunFinalizations.runId, heartbeatRuns.id))
|
|
.where(
|
|
and(
|
|
isNotNull(heartbeatRuns.executionControlDeadlineAt),
|
|
lte(heartbeatRuns.executionControlDeadlineAt, now),
|
|
),
|
|
)
|
|
.orderBy(asc(heartbeatRuns.executionControlDeadlineAt), asc(heartbeatRuns.id))
|
|
.limit(50);
|
|
const due = [
|
|
...new Map(
|
|
[
|
|
...nativeDue,
|
|
...controlDue.map((row) => ({
|
|
...row,
|
|
issueId: row.nativeIssueId ?? (
|
|
typeof row.context?.issueId === "string"
|
|
? row.context.issueId
|
|
: null),
|
|
})),
|
|
].map((row) => [row.runId, row]),
|
|
).values(),
|
|
];
|
|
let surfaced = 0;
|
|
// Bound contention latency across independent tasks: a locked task must not
|
|
// consume the entire reconciliation window for every task behind it.
|
|
let nextCandidate = 0;
|
|
await Promise.all(Array.from({ length: Math.min(5, due.length) }, async () => {
|
|
while (nextCandidate < due.length) {
|
|
const candidate = due[nextCandidate++]!;
|
|
try {
|
|
if (candidate.failureCode === NATIVE_STARTUP_CLEANUP_PENDING) {
|
|
if (!options.startupCleanup) continue;
|
|
try {
|
|
await options.startupCleanup({ companyId: candidate.companyId, runId: candidate.runId });
|
|
} finally {
|
|
await db.transaction(async tx => {
|
|
if (candidate.issueId) await tx.select({ id: issues.id }).from(issues).where(eq(issues.id, candidate.issueId)).for("update");
|
|
const [owner] = await tx.select().from(nativeRunFinalizations).where(eq(nativeRunFinalizations.runId, candidate.runId)).for("update");
|
|
if (owner?.failureCode !== NATIVE_STARTUP_CLEANUP_PENDING || !owner.controlDeadlineAt || owner.controlDeadlineAt > now ||
|
|
owner.leaseOwner !== candidate.leaseOwner || owner.controllerGeneration !== candidate.controllerGeneration || owner.attempt !== candidate.attempt) return;
|
|
const cleanupRetryAt = new Date(Math.max(now.getTime(), Date.now()) + EXECUTION_RECONCILIATION_INTERVAL_MS);
|
|
await notifyDeliveryWork(tx, DELIVERY_QUEUES.executionFinalization);
|
|
await tx.update(nativeRunFinalizations).set({ controlDeadlineAt: cleanupRetryAt }).where(eq(nativeRunFinalizations.runId, candidate.runId));
|
|
await tx.update(heartbeatRuns).set({ executionControlDeadlineAt: cleanupRetryAt }).where(and(eq(heartbeatRuns.id, candidate.runId),
|
|
isNotNull(heartbeatRuns.executionControlDeadlineAt), lte(heartbeatRuns.executionControlDeadlineAt, now)));
|
|
});
|
|
}
|
|
continue;
|
|
}
|
|
// Set inside the transaction only when the write below genuinely
|
|
// transitions the run into "failed". Read after the transaction
|
|
// commits, so a rolled-back write never reports a false failure.
|
|
let terminalRunToReport: typeof heartbeatRuns.$inferSelect | null = null;
|
|
const repaired = await db.transaction(async (tx) => {
|
|
await tx.execute(
|
|
sql`select set_config('statement_timeout', '15000', true), set_config('lock_timeout', '1000', true)`,
|
|
);
|
|
const task = candidate.issueId
|
|
? (
|
|
await tx
|
|
.select()
|
|
.from(issues)
|
|
.where(
|
|
and(
|
|
eq(issues.id, candidate.issueId),
|
|
eq(issues.companyId, candidate.companyId),
|
|
),
|
|
)
|
|
.for("update")
|
|
)[0]
|
|
: null;
|
|
const [coordinator] = await tx
|
|
.select()
|
|
.from(nativeRunFinalizations)
|
|
.where(
|
|
and(
|
|
eq(nativeRunFinalizations.runId, candidate.runId),
|
|
eq(nativeRunFinalizations.companyId, candidate.companyId),
|
|
),
|
|
)
|
|
.for("update");
|
|
const [run] = await tx
|
|
.select()
|
|
.from(heartbeatRuns)
|
|
.where(
|
|
and(
|
|
eq(heartbeatRuns.id, candidate.runId),
|
|
eq(heartbeatRuns.companyId, candidate.companyId),
|
|
),
|
|
)
|
|
.for("update");
|
|
if (
|
|
!run ||
|
|
![
|
|
coordinator?.controlDeadlineAt,
|
|
run.executionControlDeadlineAt,
|
|
].some((deadline) => deadline && deadline <= now)
|
|
)
|
|
return false;
|
|
if (coordinator?.failureCode === NATIVE_STARTUP_CLEANUP_PENDING) return false;
|
|
const startupContext = coordinator && readNativeStartupContext(run, coordinator);
|
|
if (startupContext) {
|
|
if (!await promoteNativeStartupCleanup(tx as unknown as Db, run, coordinator!, now)) {
|
|
const retryAt = new Date(Math.max(now.getTime() + EXECUTION_RECONCILIATION_INTERVAL_MS,
|
|
startupContext.deadlineAtMs, coordinator!.leaseExpiresAt?.getTime() ?? 0));
|
|
await notifyDeliveryWork(tx, DELIVERY_QUEUES.executionFinalization);
|
|
await tx.update(nativeRunFinalizations).set({ controlDeadlineAt: retryAt }).where(eq(nativeRunFinalizations.runId, run.id));
|
|
await tx.update(heartbeatRuns).set({ executionControlDeadlineAt: retryAt }).where(eq(heartbeatRuns.id, run.id));
|
|
}
|
|
return false;
|
|
}
|
|
await tx
|
|
.update(heartbeatRuns)
|
|
.set({ executionControlDeadlineAt: null })
|
|
.where(eq(heartbeatRuns.id, run.id));
|
|
if (
|
|
[
|
|
"succeeded",
|
|
"failed",
|
|
"cancelled",
|
|
"timed_out",
|
|
"interrupted",
|
|
].includes(run.status)
|
|
) {
|
|
if (coordinator?.controlDeadlineAt)
|
|
await tx
|
|
.update(nativeRunFinalizations)
|
|
.set({ controlDeadlineAt: null })
|
|
.where(eq(nativeRunFinalizations.runId, run.id));
|
|
return true;
|
|
}
|
|
// A persisted result belongs to the existing finalizer, not a replacement provider.
|
|
if (coordinator?.resultId) {
|
|
await notifyNativeFinalizationWork(tx, { companyId: run.companyId, runId: run.id });
|
|
await tx
|
|
.update(nativeRunFinalizations)
|
|
.set({
|
|
controlDeadlineAt: null,
|
|
leaseOwner: null,
|
|
leaseExpiresAt: null,
|
|
updatedAt: now,
|
|
})
|
|
.where(eq(nativeRunFinalizations.runId, run.id));
|
|
return true;
|
|
}
|
|
if (await hasLiveLegacyController(tx as unknown as Db, run)) {
|
|
await tx
|
|
.update(heartbeatRuns)
|
|
.set({ executionControlDeadlineAt: run.controllerLeaseExpiresAt })
|
|
.where(eq(heartbeatRuns.id, run.id));
|
|
return false;
|
|
}
|
|
const cause = "execution_finalization_deadline_exceeded";
|
|
const nextAction =
|
|
"Inspect the failed run and verify its provider has stopped. Reconcile any uncertain external action before explicitly continuing this task.";
|
|
// The deadline replaces the run's error code. Keep the earlier
|
|
// diagnosis as its root cause, as the native finalizer does.
|
|
const priorFailureCode = coordinator?.failureCode ?? run.errorCode;
|
|
const recordsOriginalFailure = Boolean(priorFailureCode) && priorFailureCode !== cause &&
|
|
typeof run.resultJson?.originalFailureCode !== "string";
|
|
if (coordinator)
|
|
await tx
|
|
.update(nativeRunFinalizations)
|
|
.set({
|
|
phase: "terminal_failure",
|
|
controlDeadlineAt: null,
|
|
leaseOwner: null,
|
|
leaseExpiresAt: null,
|
|
recoveryState: "blocked",
|
|
nextAttemptAt: null,
|
|
failureCode: cause,
|
|
failureDetail: {
|
|
...coordinator.failureDetail,
|
|
nextAction,
|
|
recoveryOwner: { kind: "board" },
|
|
},
|
|
updatedAt: now,
|
|
})
|
|
.where(eq(nativeRunFinalizations.runId, run.id));
|
|
await notifyInstructionCollection(tx, { companyId: run.companyId, runId: run.id });
|
|
await notifyExternalAgentSettlement(tx, { companyId: run.companyId, runId: run.id });
|
|
await notifyNativeEnvironmentSettlement(tx, { companyId: run.companyId, runId: run.id });
|
|
await notifyCancelledWorkspacePreservation(tx, { companyId: run.companyId, runId: run.id });
|
|
await notifyUnsafeWorkspaceExportRunChange(tx, run);
|
|
await notifyExecutionRunWork(tx, { companyId: run.companyId });
|
|
const [updatedRun] = await tx
|
|
.update(heartbeatRuns)
|
|
.set({
|
|
status: "failed",
|
|
executionStatusDeliveryId: randomUUID(),
|
|
finishedAt: now,
|
|
...(coordinator
|
|
? { nativePhase: "terminal_failure", nativePhaseUpdatedAt: now }
|
|
: {}),
|
|
errorCode: cause,
|
|
error: nextAction,
|
|
nextAction,
|
|
...(recordsOriginalFailure
|
|
? { resultJson: { ...run.resultJson, originalFailureCode: priorFailureCode } }
|
|
: {}),
|
|
updatedAt: now,
|
|
})
|
|
.where(eq(heartbeatRuns.id, run.id))
|
|
.returning();
|
|
if (updatedRun && updatedRun.status !== run.status) {
|
|
terminalRunToReport = updatedRun;
|
|
}
|
|
await tx
|
|
.update(agents)
|
|
.set({ status: "idle", updatedAt: now })
|
|
.where(
|
|
and(
|
|
eq(agents.id, run.agentId),
|
|
eq(agents.companyId, run.companyId),
|
|
eq(agents.status, "running"),
|
|
eq(agents.lifecycleState, "ready"),
|
|
sql`not exists (select 1 from ${heartbeatRuns} where ${heartbeatRuns.agentId} = ${run.agentId} and ${heartbeatRuns.status} = 'running')`,
|
|
),
|
|
);
|
|
const nativeReviewContext = readNativeReviewAssignmentContext(run.contextSnapshot);
|
|
const nativeReviewAssignment = nativeReviewContext && task
|
|
? await getNativeReviewAssignment(tx as unknown as Db, {
|
|
companyId: run.companyId,
|
|
issueId: task.id,
|
|
agentId: run.agentId,
|
|
contextSnapshot: nativeReviewContext,
|
|
})
|
|
: null;
|
|
const review = task?.status === "in_review" ? parseIssueExecutionState(task.executionState) : null;
|
|
const isCurrentReviewer = (review?.status === "pending" &&
|
|
review.currentParticipant?.type === "agent" && review.currentParticipant.agentId === run.agentId)
|
|
|| nativeReviewAssignment?.interaction.status === "pending";
|
|
if (
|
|
!task ||
|
|
(task.assigneeAgentId !== run.agentId && !isCurrentReviewer) ||
|
|
["done", "cancelled"].includes(task.status) ||
|
|
(task.executionRunId && task.executionRunId !== run.id) ||
|
|
(task.checkoutRunId && task.checkoutRunId !== run.id)
|
|
) {
|
|
if (updatedRun) await preserveNativeFailedWorkspace(tx as unknown as Db, updatedRun);
|
|
return true;
|
|
}
|
|
await notifyNativeStatusReassessment(tx, { companyId: task.companyId, issueId: task.id });
|
|
await tx
|
|
.update(issues)
|
|
.set({ executionRunId: null, checkoutRunId: null, updatedAt: now })
|
|
.where(eq(issues.id, task.id));
|
|
await issueRecoveryActionService(
|
|
tx as unknown as Db,
|
|
).upsertSourceScoped({
|
|
companyId: run.companyId,
|
|
sourceIssueId: task.id,
|
|
kind: "active_run_watchdog",
|
|
ownerType: "board",
|
|
ownerAgentId: null,
|
|
returnOwnerAgentId: task.assigneeAgentId,
|
|
cause,
|
|
fingerprint: `execution-control:${run.id}`,
|
|
evidence: {
|
|
runId: run.id,
|
|
...(isCurrentReviewer ? { reviewParticipantAgentId: run.agentId } : {}),
|
|
originalFailureCode: coordinator?.failureCode ?? run.errorCode,
|
|
providerOwnership: "unverified",
|
|
},
|
|
nextAction,
|
|
wakePolicy: null,
|
|
maxAttempts: 3,
|
|
supersedeOnIdentityChange: true,
|
|
});
|
|
if (updatedRun) await preserveNativeFailedWorkspace(tx as unknown as Db, updatedRun);
|
|
return true;
|
|
});
|
|
if (repaired) surfaced += 1;
|
|
if (terminalRunToReport) void reportRunFailure(db, terminalRunToReport);
|
|
} catch {
|
|
logger.warn(
|
|
{ runId: candidate.runId },
|
|
"Execution finalization reconciliation remains pending; continuing with other runs",
|
|
);
|
|
}
|
|
}
|
|
}));
|
|
// Owners left on runs that already ended outside the native executor have no
|
|
// control deadline; their expired lease is the due time.
|
|
const releasedOwners = await releaseAbandonedNativeOwners(db, now).catch((error: unknown) => {
|
|
logger.warn({ err: error }, "Abandoned native owner release remains pending");
|
|
return 0;
|
|
});
|
|
return { scanned: due.length, surfaced, releasedOwners };
|
|
}
|