mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-08 22:13:59 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Native recovery must preserve the original execution and provider state. > - A failed remote run can retain its provider PID after heartbeat releases the task execution lock. > - Normal resumption excludes that PID, while restart recovery rejects the released lock. > - Recovery must reclaim that exact execution only after current admission and durable ownership checks pass. > - Failed retries and admission holds need separate finite budgets. Neither can prove a provider stopped. ## Linked Issues or Issue Description **What happened?** A failed remote native run remains in retryable failure without a task execution owner. Normal resumption and restart recovery both reject it before inspecting the preserved remote provider. The current remote workspace reference format also misses the due-retry routing path. **Expected behavior** A due exact-checkpoint retry reclaims its original task execution only when current ownership and safety gates permit it. Newer task input prevents an old claim. Lost or ambiguous provider state still prevents replay or replacement. Ineligible recovery eventually becomes a quiet manual reconciliation obligation. **Steps to reproduce** 1. Start a remote native run and persist its provider checkpoint and encrypted Runner authority. 2. Fail with exact-checkpoint recovery and a retained remote PID. 3. Release the task execution lock at heartbeat completion. 4. Run ordinary resumption and restart recovery after the retry deadline. 5. Observe that both reject the same eligible run before remote inspection. **Paperclip version or commit** Reproduced from source at `d59791828482`; integrated with upstream `db9be4c5a6`. **Deployment mode** Remote workspaces with PostgreSQL. Local regressions use disposable PostgreSQL and encrypted checkpoint persistence. Related public repairs #18846, #18854, #18860 and #18841 cover finished per-turn owners, pre-provider failures, unstarted follow-ups and storage migration. They do not reclaim this failed run's released execution lock. Merged #18879 also adds scheduled-resume lock retention and reclaim. This PR keeps that compatibility and requires encrypted Runner authority, canonical source ordering and a charged failed retry before reclaim. Its scheduled-resume regression now uses a real encrypted authority and retains a snapshot-only refusal control. ## What Changed - Share normal admission checks and one guarded execution-binding restore/event helper between normal and remote resumption. - Keep agent-name normalization in a module with no imports. Preserve heartbeat's existing export. Native admission no longer loads heartbeat orchestration for a string key. - Recheck company, assignment, agent eligibility, Stop, pause, approval, dependencies, execution and checkout ownership before reclaim. - Reject newer task input using PostgreSQL creation-time and ID ordering, preserving timestamp precision. - Require the recorded remote target, encrypted Runner authority, exact checkpoint identity and existing controller fence. - Commit task binding, controller lease, run status and recovery event atomically. Recognize both remote workspace reference formats. - Charge failed reattachments against the existing three-provider-attempt budget, including retained-lock crash windows. Active controller adoption preserves its charged attempt. - Use the existing five-retry/30-minute system-recovery policy for admission holds. Ended tasks and newer sources exhaust it immediately. Preserve PID, checkpoint and task bindings, suppress resource settlement, and assign manual reconciliation with executor stop/state explicitly unverified. - Register recovery intent before deadline writes; preserve live/future deadlines quietly and roll back zero-row writes. Record changed hold reasons once and terminal dispositions separately. - Keep historical provider-budget exhaustion behind current admission/source/controller/authority checks. Make an existing orphan-cleanup fixture explicitly due. - Include exact plugin task leases that have no workspace reference. Reuse every reclaim and retry-budget guard, with a bounded company/task/run lease projection. - Share strict manual ownership proof across stale-lock cleanup, adoption, queue release and wake admission. Require termination receipts for every exact remote lease before normal cleanup can release either binding. - Preserve governing Stop through the existing audited cancellation path. Its acknowledgement does not release an unverified provider. Ordinary Retry can wait for exact Stop and file preservation, then queue one continuation. ## Verification - Current head is `5d7baf74aa89d00d7ac6e1493d38a6ce2ae7b7b5`. Its [current CI matrix](https://github.com/paperclipai/paperclip/actions/runs/41782198858) is terminal and clean: 54 successful, three skipped and two neutral contexts. Required `ci / verify` and `ci / e2e` pass. Greptile is 5/5 on this exact head with no open findings. No fresh Paperclip 5/5 assessment is claimed. - The [prior complete CI matrix](https://github.com/paperclipai/paperclip/actions/runs/41754365388) passed on `3e641466d3edcaa6dd5021d979627082d388eb05`, with Greptile and Paperclip both 5/5 and no open threads. - All 233 tests across the six affected heartbeat, restart-isolation, remote-admission, scheduled-resume and session-binding families pass on production-identical `a5104fa89f9cb493091e000bf21ed3858871ae46` under Node 24.22.2 and pnpm 9.15.4. They cover both workspace formats and plugin task leases, competing controllers, exact rollback, Stop, assignment/company boundaries, attempts, work-intent ordering, finite backoff and quiet held state. - The merge preserves #18931's per-run audit-failure isolation and this PR's unchanged-transition hold. Two initial composition cases failed because their setup or expected result described the older contract. The rollback test now expects the exact per-run deferral while retaining full coordinator equality and no work intent. The routine fixture writes a real encrypted checkpoint through the normal owner before reaching the actual routine unique constraint. No production admission guard or rollback assertion was weakened. A separate agent reviewed this exact merge and fixture delta without finding a blocker. - Full recursive `pnpm -r typecheck` and `pnpm build` pass on production merge `89c616fe45ceff75db450f67fd34224b4df7441d`. Subsequent commits change only three test files. Both the two-file `a5104fa` and final `5d7baf` server test-layer typechecks pass. - Four production-identical `a5104fa` probes pass with the rebuilt actual Rust Runner, a scripted ACPX subprocess and encrypted PostgreSQL checkpoints under Node 24.22.2. Workspace-reference and plugin-lease admission each cover surviving and lost providers. Replacement preserves one provider launch, one original side effect, the charged attempt and restored task execution binding. Lost providers reject snapshot recovery without replacement. This is protocol-boundary evidence, not a live Claude or hosted provisioning claim. - Initial `a5104fa` CI found one additional routine integration fixture missing the persisted `exact_checkpoint_resume` mode. Its encrypted checkpoint was already present. The exact case fails locally before repair and passes after recording that mode and expecting the charged provider attempt. Every exact routine conflict, rollback, checkpoint, task binding, successor-count and sibling-startup assertion remains. Final head changes only that fixture. The correct native-server group passes all nine files: 175 executed cases and 14 container-only skips, matching the now-successful native-server CI gate. - The earlier canonical HTTP Retry acceptance passed on the prior reviewed head with real PostgreSQL, the recovery worker and real files. Pending Stop protects both bindings and starts no successor. Exact termination and copyback queue one preserved-workspace continuation. No browser or live-model outcome is inferred from that test. - The predecessor's full local `pnpm test:run` failed in an unchanged Discord Gateway fixture with a 200 ms lease and a 422 initial ownership error. Focused predecessor and untouched-base controls pass; that full-run cause remains unproved. The required command was also started on production-identical `a5104fa`. All six local chat shards passed, covering 1,850 cases. The duplicate local invocation was interrupted with SIGINT while the general-server group was still running after this head's complete CI and the combined repair qualification had passed. The wrapper exited 1 on cancellation, no process retained its test log, and no full local pass is claimed. An earlier attempt to select the native lane through the CI wrapper instead selected local package sharding: 2,631 cases passed and 13 failed. Those separate failures are retained without causal attribution or unrelated source changes; that run is not counted as native-server qualification. The direct native group and current complete CI matrix pass. - Live Claude Opus 5.5 continuation, hosted plugin provisioning, deployment and recovery of historical tasks remain unverified. No original task was retried or mutated for this verification. ## Risks - Restoring a task binding grants remote inspection; it does not prove that the provider survived. - Unknown effects, lost providers and missing semantic results retain their existing reconciliation requirements. - Exhausted admission holds preserve execution bindings and resource evidence for manual reconciliation. They do not authorize a fresh executor or provider/resource release. - The source guard is conservative: newer task input prevents recovery of an older failed source. - No database migration or provider profile change is required. ## Model Used OpenAI Codex, based on GPT-6, with reasoning, tool use, code editing and shell execution. The runtime does not expose the exact model snapshot 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>
767 lines
33 KiB
TypeScript
767 lines
33 KiB
TypeScript
import { notifyLiveRunRecovery } from "../live-run-recovery-notifications.js";
|
|
import { notifyExternalAgentSettlement } from "../../modules/external-agents/index.js";
|
|
import { pendingNativeRunProjectionCondition } from "./native-run-projection.js";
|
|
import { nativeStatusReassessmentSourceIsSuperseded, reassessNativeStatusFromSource } from "./native-status-reassessment.js";
|
|
import { resolveNativeReconciliationStatus } from "./native-reconciliation-status.js";
|
|
export { resolveNativeReconciliationStatus, type NativeReconciliationFacts } from "./native-reconciliation-status.js";
|
|
import { pendingNativeStatusEffectsCondition } from "./native-pending-effect-work.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 type { WorkspaceWorkers } from "../plugin-workspaces.js";
|
|
import { recoverLegacyUnsafeWorkspaceExports } from "./native-workspace-export-recovery.js";
|
|
import { dismissAutomaticCompletionReviews } from "./automatic-completion-reviews.js";
|
|
import { logger } from "../../middleware/logger.js";
|
|
import { DELIVERY_QUEUES, notifyDeliveryWork } from "../delivery-work-notifications.js";
|
|
import { createHash, randomUUID } from "node:crypto";
|
|
import { and, asc, desc, eq, gt, inArray, isNotNull, isNull, lt, lte, not, notInArray, or, sql, type SQL } from "drizzle-orm";
|
|
import type { Db } from "@paperclipai/db";
|
|
import {
|
|
heartbeatRunEvents,
|
|
heartbeatRuns,
|
|
issues,
|
|
nativeRunFinalizations,
|
|
nativeRunResults,
|
|
statusDecisionEffects,
|
|
statusDecisions,
|
|
workspaceOperations,
|
|
} from "@paperclipai/db";
|
|
import {
|
|
finalizeNativeRun,
|
|
recordNativeFinalizationFailure,
|
|
repairCommittedNativeReviewResponse,
|
|
repairCommittedNativeChatResponse,
|
|
} from "./native-run-finalizer.js";
|
|
import {
|
|
dispatchPendingNativeStatusEffects,
|
|
} from "./status-decision-committer.js";
|
|
import { issueRecoveryActionService } from "../issue-recovery-actions.js";
|
|
import { issueService } from "../issues.js";
|
|
import { emitAgentTaskRun } from "../agent-task-run-telemetry.js";
|
|
import { isNewTerminalRunFailure, reportRunFailure } from "../run-failure-report.js";
|
|
import { resumeNativeWorkspaceFinalization, recordNativeWorkspaceFinalizationFailure } from "./native-workspace-finalizer.js";
|
|
import { dismissObsoleteNativePolicyReviews } from "./obsolete-policy-reviews.js";
|
|
import {
|
|
cleanupNativeWorkspaceSync,
|
|
readNativeWorkspaceSyncReference,
|
|
} from "./native-workspace-sync.js";
|
|
import type { EnvironmentRuntimeService } from "../environment-runtime.js";
|
|
import { createNativeReconciliationPassTiming } from "./native-reconciliation-timing.js";
|
|
import {
|
|
isNativeRunnerOwnershipHeld,
|
|
nativeRunnerOwnershipNotHeldCondition,
|
|
} from "./native-runner-ownership.js";
|
|
import {
|
|
type NativeAuthoritativeIssueStatus,
|
|
} from "./status-arbiter.js";
|
|
|
|
import { readNativeSessionResumptionAgent, restoreNativeSessionExecution } from "./native-session-resumption-admission.js";
|
|
import { retireSupersededNativeRetry } from "./native-superseded-retry.js";
|
|
import { legacyWorkspaceRecoveryNotMigrated } from "../legacy-workspace-recovery-migration.js";
|
|
|
|
function record(value: unknown): Record<string, unknown> {
|
|
return value && typeof value === "object" && !Array.isArray(value)
|
|
? value as Record<string, unknown>
|
|
: {};
|
|
}
|
|
|
|
export type NativeSessionResumeClaim = { runId: string; leaseOwner: string };
|
|
|
|
export { reconcileRetainedNativeSessionCleanups } from "./retained-native-session-cleanup.js";
|
|
|
|
export async function dispatchNativeSessionResumptions(input: {
|
|
db: Db;
|
|
runnerInstanceId: string;
|
|
runIds?: string[];
|
|
now?: Date;
|
|
limit?: number;
|
|
dispatch: (claim: NativeSessionResumeClaim) => void;
|
|
deferIneligible?: boolean;
|
|
onError?: (error: unknown) => void;
|
|
}) {
|
|
const claims = await claimNativeSessionResumptions(input);
|
|
for (const claim of claims) input.dispatch(claim);
|
|
return claims;
|
|
}
|
|
|
|
/**
|
|
* Claims explicit, result-less retryable failures for same-run recovery.
|
|
* Merely expired `observed` ownership stays blocked because lease expiry alone
|
|
* does not prove that the original provider owner stopped. Eligibility remains
|
|
* independent of the live feature flag and legacy scheduler.
|
|
*/
|
|
export async function claimNativeSessionResumptions(input: {
|
|
db: Db;
|
|
runnerInstanceId: string;
|
|
runIds?: string[];
|
|
now?: Date;
|
|
limit?: number;
|
|
deferIneligible?: boolean;
|
|
onError?: (error: unknown) => void;
|
|
}): Promise<NativeSessionResumeClaim[]> {
|
|
const now = input.now ?? new Date();
|
|
const candidates = await input.db
|
|
.select({ runId: heartbeatRuns.id, companyId: heartbeatRuns.companyId, issueId: nativeRunFinalizations.issueId })
|
|
.from(heartbeatRuns)
|
|
.innerJoin(
|
|
nativeRunFinalizations,
|
|
eq(nativeRunFinalizations.runId, heartbeatRuns.id),
|
|
)
|
|
.where(
|
|
and(
|
|
eq(heartbeatRuns.runtimeMode, "native"),
|
|
legacyWorkspaceRecoveryNotMigrated(),
|
|
nativeRunnerOwnershipNotHeldCondition(),
|
|
isNull(heartbeatRuns.processPid),
|
|
isNull(heartbeatRuns.processGroupId),
|
|
isNull(nativeRunFinalizations.resultId),
|
|
eq(nativeRunFinalizations.phase, "retryable_failure"),
|
|
or(
|
|
isNull(nativeRunFinalizations.nextAttemptAt),
|
|
lte(nativeRunFinalizations.nextAttemptAt, now),
|
|
),
|
|
or(
|
|
isNull(nativeRunFinalizations.leaseOwner),
|
|
isNull(nativeRunFinalizations.leaseExpiresAt),
|
|
lte(nativeRunFinalizations.leaseExpiresAt, now),
|
|
),
|
|
...(input.runIds?.length
|
|
? [inArray(heartbeatRuns.id, input.runIds)]
|
|
: []),
|
|
),
|
|
)
|
|
.orderBy(sql`${nativeRunFinalizations.nextAttemptAt} asc nulls first`, asc(nativeRunFinalizations.runId))
|
|
.limit(input.limit ?? 25);
|
|
|
|
const claims: NativeSessionResumeClaim[] = [];
|
|
for (const candidate of candidates) {
|
|
const leaseOwner = `${input.runnerInstanceId}:resume:${randomUUID()}`;
|
|
let terminalRunToEmit: typeof heartbeatRuns.$inferSelect | null = null;
|
|
let claimFailed = false;
|
|
const claimed = await input.db.transaction(async (tx) => {
|
|
await tx.execute(sql`select set_config('statement_timeout', '10000', true), set_config('lock_timeout', '2000', true)`);
|
|
const [issue] = await tx.select().from(issues).where(and(
|
|
eq(issues.companyId, candidate.companyId), eq(issues.id, candidate.issueId),
|
|
)).for("update").limit(1);
|
|
if (!issue) return false;
|
|
const [coordinator] = await tx.select().from(nativeRunFinalizations).where(and(
|
|
eq(nativeRunFinalizations.companyId, candidate.companyId), eq(nativeRunFinalizations.runId, candidate.runId),
|
|
eq(nativeRunFinalizations.issueId, issue.id),
|
|
)).for("update").limit(1);
|
|
const [run] = await tx.select().from(heartbeatRuns).where(and(
|
|
eq(heartbeatRuns.companyId, candidate.companyId), eq(heartbeatRuns.id, candidate.runId),
|
|
eq(heartbeatRuns.nativeIssueId, issue.id),
|
|
)).for("update").limit(1);
|
|
if (!run || !coordinator || run.runnerProfileJson?.legacyWorkspaceRecoveryMigration != null) return false;
|
|
if (await retireSupersededNativeRetry(tx as unknown as Db, {
|
|
run, coordinator, issueExecutionRunId: issue.executionRunId, now,
|
|
})) return false;
|
|
if (!["todo", "in_progress", "in_review"].includes(issue.status)) return false;
|
|
const row = { run, coordinator };
|
|
if (
|
|
row.run.runtimeMode !== "native" ||
|
|
isNativeRunnerOwnershipHeld(row.run) ||
|
|
row.run.processPid !== null ||
|
|
row.run.processGroupId !== null ||
|
|
row.coordinator.resultId ||
|
|
row.coordinator.phase !== "retryable_failure" ||
|
|
(row.coordinator.nextAttemptAt &&
|
|
row.coordinator.nextAttemptAt > now) ||
|
|
(row.coordinator.leaseOwner &&
|
|
row.coordinator.leaseExpiresAt &&
|
|
row.coordinator.leaseExpiresAt > now) ||
|
|
!["running", "failed"].includes(row.run.status)
|
|
)
|
|
return false;
|
|
|
|
const agent = await readNativeSessionResumptionAgent(tx as unknown as Db, { issue, run, coordinator });
|
|
if (!agent) return false;
|
|
|
|
const profile = row.run.runnerProfileJson ?? {};
|
|
const persistedInput = profile.nativeExecutionInput;
|
|
const checkpoint = profile.sessionCheckpoint;
|
|
const failureDetail = record(row.coordinator.failureDetail);
|
|
const originalFailureCode =
|
|
typeof failureDetail.originalFailureCode === "string"
|
|
? failureDetail.originalFailureCode
|
|
: row.run.errorCode ?? row.coordinator.failureCode
|
|
?? "native_session_interrupted";
|
|
const providerEvent = await tx
|
|
.select({ id: heartbeatRunEvents.id })
|
|
.from(heartbeatRunEvents)
|
|
.where(
|
|
and(
|
|
eq(heartbeatRunEvents.runId, row.run.id),
|
|
inArray(heartbeatRunEvents.eventType, [
|
|
"harness.ready",
|
|
"session.started",
|
|
"session.resumed",
|
|
"session.updated",
|
|
"turn.started",
|
|
"provider.event",
|
|
"provider.rpc_result",
|
|
]),
|
|
),
|
|
)
|
|
.limit(1)
|
|
.then((rows) => rows[0] ?? null);
|
|
const checkpointRecord = record(checkpoint);
|
|
const checkpointHasProviderIdentity =
|
|
(typeof checkpointRecord.providerSessionId === "string"
|
|
&& checkpointRecord.providerSessionId.length > 0)
|
|
|| Object.keys(record(checkpointRecord.providerIdentity)).length > 0;
|
|
const bootstrapRetry =
|
|
!!persistedInput
|
|
&& !checkpoint
|
|
&& !providerEvent
|
|
&& failureDetail.recoveryMode === "bootstrap_retry"
|
|
&& failureDetail.providerSessionEstablished === false;
|
|
if (
|
|
!persistedInput
|
|
|| (!checkpoint && !bootstrapRetry)
|
|
|| (!!checkpoint && !checkpointHasProviderIdentity)
|
|
) {
|
|
const failureCode = originalFailureCode;
|
|
await notifyDeliveryWork(tx, DELIVERY_QUEUES.accounting, row.run.companyId);
|
|
await notifyLiveRunRecovery(tx, { companyId: row.run.companyId, runId: row.run.id, agentId: row.run.agentId });
|
|
await tx.update(nativeRunFinalizations).set({
|
|
phase: "terminal_failure",
|
|
leaseOwner: null,
|
|
leaseExpiresAt: null,
|
|
failureCode,
|
|
failureDetail: {
|
|
...failureDetail,
|
|
originalFailureCode,
|
|
recoveryMode: "ambiguous_state",
|
|
providerSessionEstablished:
|
|
checkpointHasProviderIdentity || providerEvent !== null,
|
|
recoveryOwner: { kind: "board" },
|
|
nextAction: "Inspect the original provider failure; durable state is missing or ambiguous and opening a replacement provider session is forbidden.",
|
|
},
|
|
nextAttemptAt: null,
|
|
updatedAt: now,
|
|
}).where(eq(nativeRunFinalizations.runId, row.run.id));
|
|
await notifyExternalAgentSettlement(tx, { companyId: row.run.companyId, runId: row.run.id });
|
|
await notifyInstructionCollection(tx, { companyId: row.run.companyId, runId: row.run.id });
|
|
await notifyCancelledWorkspacePreservation(tx, { companyId: row.run.companyId, runId: row.run.id });
|
|
await notifyUnsafeWorkspaceExportRunChange(tx, row.run);
|
|
const [updatedRun] = await tx.update(heartbeatRuns).set({
|
|
status: "failed",
|
|
finishedAt: now,
|
|
nativePhase: "terminal_failure",
|
|
nativePhaseUpdatedAt: now,
|
|
errorCode: failureCode,
|
|
error: typeof failureDetail.message === "string"
|
|
? failureDetail.message
|
|
: "Persisted native session state is ambiguous and cannot be resumed safely",
|
|
updatedAt: now,
|
|
}).where(eq(heartbeatRuns.id, row.run.id)).returning();
|
|
// Only a genuine transition into "failed" is a new terminal failure.
|
|
// A candidate that is already "failed" (the filter above admits
|
|
// both "running" and "failed") must not send a second Sentry event,
|
|
// unless that earlier write was an unreported pending retry.
|
|
terminalRunToEmit =
|
|
updatedRun && isNewTerminalRunFailure(row.run, updatedRun) ? updatedRun : null;
|
|
await issueService(tx as unknown as Db).update(
|
|
row.coordinator.issueId,
|
|
{ status: "blocked" },
|
|
tx,
|
|
);
|
|
await issueRecoveryActionService(tx as unknown as Db).upsertSourceScoped({
|
|
companyId: row.run.companyId,
|
|
sourceIssueId: row.coordinator.issueId,
|
|
kind: "active_run_watchdog",
|
|
ownerType: "board",
|
|
ownerAgentId: null,
|
|
returnOwnerAgentId: row.run.agentId,
|
|
cause: failureCode,
|
|
fingerprint: createHash("sha256").update(`${row.run.id}:${failureCode}`).digest("hex"),
|
|
evidence: {
|
|
runId: row.run.id,
|
|
hasEnvelope: !!persistedInput,
|
|
hasCheckpoint: !!checkpoint,
|
|
providerEventsExist: providerEvent !== null,
|
|
originalFailureCode,
|
|
},
|
|
nextAction: "Inspect the original provider failure and explicitly resolve recovery; do not open a duplicate provider session.",
|
|
wakePolicy: null,
|
|
maxAttempts: 3,
|
|
supersedeOnIdentityChange: true,
|
|
});
|
|
return false;
|
|
}
|
|
|
|
const leaseExpiresAt = new Date(now.getTime() + 20 * 60_000);
|
|
await notifyLiveRunRecovery(tx, { companyId: row.run.companyId, runId: row.run.id, agentId: row.run.agentId });
|
|
const updated = await tx.update(nativeRunFinalizations).set({
|
|
phase: "observed",
|
|
leaseOwner,
|
|
leaseExpiresAt,
|
|
failureCode: null,
|
|
failureDetail: null,
|
|
nextAttemptAt: null,
|
|
updatedAt: now,
|
|
}).where(and(
|
|
eq(nativeRunFinalizations.runId, row.run.id),
|
|
eq(nativeRunFinalizations.phase, row.coordinator.phase),
|
|
)).returning({ runId: nativeRunFinalizations.runId }).then((rows) => rows[0] ?? null);
|
|
if (!updated) return false;
|
|
await tx.update(heartbeatRuns).set({
|
|
status: "running",
|
|
finishedAt: null,
|
|
error: null,
|
|
errorCode: null,
|
|
nativePhase: "observed",
|
|
nativePhaseUpdatedAt: now,
|
|
updatedAt: now,
|
|
}).where(eq(heartbeatRuns.id, row.run.id));
|
|
await restoreNativeSessionExecution(tx as unknown as Db, { issue, run, agentName: agent.name, now });
|
|
return true;
|
|
}).catch((error: unknown) => {
|
|
claimFailed = true;
|
|
input.onError?.(error);
|
|
terminalRunToEmit = null;
|
|
logger.warn({ err: error, companyId: candidate.companyId, runId: candidate.runId },
|
|
"failed to claim native session recovery candidate");
|
|
return false;
|
|
});
|
|
// Telemetry is best-effort background work; it must not delay claiming
|
|
// the remaining candidates in this loop, so fire it and do not await it.
|
|
if (terminalRunToEmit) {
|
|
void emitAgentTaskRun(input.db, terminalRunToEmit);
|
|
void reportRunFailure(input.db, terminalRunToEmit);
|
|
}
|
|
if (claimed) claims.push({ runId: candidate.runId, leaseOwner });
|
|
else if (claimFailed || input.deferIneligible !== false) await input.db.transaction(async tx => {
|
|
await tx.execute(sql`select set_config('statement_timeout', '10000', true), set_config('lock_timeout', '2000', true)`);
|
|
await notifyLiveRunRecovery(tx, { companyId: candidate.companyId, runId: candidate.runId });
|
|
await tx.update(nativeRunFinalizations).set({
|
|
nextAttemptAt: new Date(now.getTime() + 30_000),
|
|
updatedAt: now,
|
|
}).where(and(
|
|
eq(nativeRunFinalizations.companyId, candidate.companyId),
|
|
eq(nativeRunFinalizations.issueId, candidate.issueId),
|
|
eq(nativeRunFinalizations.runId, candidate.runId),
|
|
eq(nativeRunFinalizations.phase, "retryable_failure"),
|
|
isNull(nativeRunFinalizations.resultId),
|
|
or(isNull(nativeRunFinalizations.nextAttemptAt), lte(nativeRunFinalizations.nextAttemptAt, now)),
|
|
or(isNull(nativeRunFinalizations.leaseOwner), isNull(nativeRunFinalizations.leaseExpiresAt), lte(nativeRunFinalizations.leaseExpiresAt, now)),
|
|
));
|
|
}).catch((error: unknown) => {
|
|
input.onError?.(error);
|
|
logger.warn({ err: error, companyId: candidate.companyId, runId: candidate.runId },
|
|
"failed to defer native session recovery candidate");
|
|
});
|
|
}
|
|
return claims;
|
|
}
|
|
|
|
type NativeReconciliationOptions = {
|
|
skipObsoletePolicyReviews?: boolean;
|
|
skipAutomaticCompletionReviews?: boolean;
|
|
skipUnsafeWorkspaceExports?: boolean;
|
|
skipNativePendingEffects?: boolean;
|
|
skipNativeResponsePresentation?: boolean;
|
|
skipNativeReviewResponse?: boolean;
|
|
skipNativeStatusReassessment?: boolean;
|
|
skipNativeWorkspaceFinalization?: boolean;
|
|
skipNativeResultCommit?: boolean;
|
|
skipNativeRunProjection?: boolean;
|
|
skipExternalConversationSettlement?: boolean;
|
|
skipExternalAgentSettlement?: boolean;
|
|
skipNativeRecoveryNotices?: boolean;
|
|
pluginWorkerManager?: WorkspaceWorkers;
|
|
environmentRuntime?: EnvironmentRuntimeService;
|
|
onWorkspaceSettled?: (input: {
|
|
runId: string;
|
|
companyId: string;
|
|
agentId: string;
|
|
succeeded: boolean;
|
|
}) => Promise<void>;
|
|
};
|
|
|
|
type NativeReconciliationPassTiming = ReturnType<typeof createNativeReconciliationPassTiming>;
|
|
|
|
export const NATIVE_SETTLED_COMMITTED_GRACE_MS = 60 * 60 * 1000;
|
|
export const NATIVE_SETTLED_COMMITTED_UPKEEP_PER_PASS = 10;
|
|
const NATIVE_SETTLED_RUN_STATUSES = ["succeeded", "failed", "cancelled", "timed_out"];
|
|
let settledCommittedUpkeepCursor: string | null = null;
|
|
|
|
export function resetSettledCommittedUpkeepForTests() {
|
|
settledCommittedUpkeepCursor = null;
|
|
}
|
|
|
|
function settledCommittedFinalization(now: Date): SQL {
|
|
return and(
|
|
eq(nativeRunFinalizations.phase, "committed"),
|
|
inArray(heartbeatRuns.status, NATIVE_SETTLED_RUN_STATUSES),
|
|
isNotNull(heartbeatRuns.finishedAt),
|
|
lt(heartbeatRuns.finishedAt, new Date(now.getTime() - NATIVE_SETTLED_COMMITTED_GRACE_MS)),
|
|
sql<boolean>`not exists (
|
|
select 1 from ${statusDecisionEffects} effect
|
|
where effect.company_id = ${nativeRunFinalizations.companyId}
|
|
and effect.issue_id = ${nativeRunFinalizations.issueId}
|
|
and effect.decision_id = ${nativeRunFinalizations.decisionId}
|
|
and effect.delivery_state in ('pending', 'failed')
|
|
)`,
|
|
)!;
|
|
}
|
|
|
|
async function nextSettledCommittedUpkeep(db: Db, now: Date): Promise<string[]> {
|
|
const page = (after: string | null) => db
|
|
.select({ runId: heartbeatRuns.id })
|
|
.from(heartbeatRuns)
|
|
.innerJoin(nativeRunFinalizations, eq(nativeRunFinalizations.runId, heartbeatRuns.id))
|
|
.where(and(
|
|
eq(heartbeatRuns.runtimeMode, "native"),
|
|
isNotNull(nativeRunFinalizations.resultId),
|
|
settledCommittedFinalization(now),
|
|
...(after ? [gt(heartbeatRuns.id, after)] : []),
|
|
))
|
|
.orderBy(asc(heartbeatRuns.id))
|
|
.limit(NATIVE_SETTLED_COMMITTED_UPKEEP_PER_PASS);
|
|
let rows = await page(settledCommittedUpkeepCursor);
|
|
if (rows.length === 0 && settledCommittedUpkeepCursor !== null) rows = await page(null);
|
|
settledCommittedUpkeepCursor = rows.length === NATIVE_SETTLED_COMMITTED_UPKEEP_PER_PASS
|
|
? rows[rows.length - 1]!.runId
|
|
: null;
|
|
return rows.map((row) => row.runId);
|
|
}
|
|
|
|
/** Recovery is keyed only by persisted mode/coordinator state, never the live flag. */
|
|
export async function reconcileNativeFinalizations(
|
|
db: Db,
|
|
runIds?: string[],
|
|
options: NativeReconciliationOptions = {},
|
|
) {
|
|
const timing = createNativeReconciliationPassTiming();
|
|
let outcome: "ok" | "failed" = "ok";
|
|
try {
|
|
return await reconcileNativeFinalizationsPass(db, runIds, options, timing);
|
|
} catch (error) {
|
|
outcome = "failed";
|
|
throw error;
|
|
} finally {
|
|
timing.finish({ outcome, scoped: Boolean(runIds?.length) });
|
|
}
|
|
}
|
|
|
|
async function reconcileNativeFinalizationsPass(
|
|
db: Db,
|
|
runIds: string[] | undefined,
|
|
options: NativeReconciliationOptions,
|
|
timing: NativeReconciliationPassTiming,
|
|
) {
|
|
if (!options.skipUnsafeWorkspaceExports) {
|
|
timing.phase("legacy_workspace_exports");
|
|
await recoverLegacyUnsafeWorkspaceExports(db, runIds).catch((err) => {
|
|
logger.warn({ err }, "Historical unsafe export recovery remains pending");
|
|
});
|
|
}
|
|
if (!options.skipObsoletePolicyReviews) {
|
|
timing.phase("obsolete_policy_reviews");
|
|
await dismissObsoleteNativePolicyReviews(db, runIds).catch((err) => {
|
|
logger.warn({ err }, "Obsolete native policy review lookup failed; continuing native reconciliation");
|
|
});
|
|
}
|
|
if (!options.skipAutomaticCompletionReviews) {
|
|
timing.phase("automatic_completion_reviews");
|
|
if (runIds?.length) {
|
|
const scopes = await db.select({ issueId: nativeRunFinalizations.issueId }).from(nativeRunFinalizations)
|
|
.where(inArray(nativeRunFinalizations.runId, runIds));
|
|
for (const scope of scopes) await dismissAutomaticCompletionReviews(db, scope.issueId);
|
|
} else {
|
|
await dismissAutomaticCompletionReviews(db);
|
|
}
|
|
}
|
|
timing.phase("candidate_scan");
|
|
const now = new Date();
|
|
const unscoped = !(runIds && runIds.length > 0);
|
|
const upkeepRunIds = unscoped ? await nextSettledCommittedUpkeep(db, now) : [];
|
|
const rows = await db
|
|
.select({
|
|
runId: heartbeatRuns.id,
|
|
companyId: heartbeatRuns.companyId,
|
|
agentId: heartbeatRuns.agentId,
|
|
issueId: nativeRunFinalizations.issueId,
|
|
issueStatus: issues.status,
|
|
issueStatusVersion: issues.statusVersion,
|
|
issueDecisionId: issues.lastStatusDecisionId,
|
|
coordinatorPhase: nativeRunFinalizations.phase,
|
|
runProjectionPending: options.skipNativeRunProjection ? pendingNativeRunProjectionCondition(db) : sql<boolean>`false`,
|
|
resultId: nativeRunFinalizations.resultId,
|
|
assessmentId: nativeRunFinalizations.assessmentId,
|
|
decisionId: nativeRunFinalizations.decisionId,
|
|
nativeWorkspaceSync: sql<unknown>`${heartbeatRuns.runnerProfileJson}->'nativeWorkspaceSync'`,
|
|
nativeStatusWakeIntentId: sql<unknown>`${heartbeatRuns.contextSnapshot}->'nativeStatusWakeIntentId'`,
|
|
})
|
|
.from(heartbeatRuns)
|
|
.innerJoin(nativeRunFinalizations, eq(nativeRunFinalizations.runId, heartbeatRuns.id))
|
|
.innerJoin(issues, and(
|
|
eq(issues.id, nativeRunFinalizations.issueId),
|
|
eq(issues.companyId, heartbeatRuns.companyId),
|
|
))
|
|
.where(and(
|
|
eq(heartbeatRuns.runtimeMode, "native"),
|
|
isNotNull(nativeRunFinalizations.resultId),
|
|
notInArray(nativeRunFinalizations.phase, ["terminal_failure"]),
|
|
options.skipNativePendingEffects ? not(pendingNativeStatusEffectsCondition(db)) : undefined,
|
|
or(
|
|
isNull(nativeRunFinalizations.nextAttemptAt),
|
|
lte(nativeRunFinalizations.nextAttemptAt, new Date()),
|
|
),
|
|
or(
|
|
isNull(nativeRunFinalizations.leaseOwner),
|
|
isNull(nativeRunFinalizations.leaseExpiresAt),
|
|
lte(nativeRunFinalizations.leaseExpiresAt, new Date()),
|
|
),
|
|
...(runIds && runIds.length > 0 ? [inArray(heartbeatRuns.id, runIds)] : []),
|
|
...(unscoped
|
|
? [upkeepRunIds.length > 0
|
|
? or(not(settledCommittedFinalization(now)), inArray(heartbeatRuns.id, upkeepRunIds))!
|
|
: not(settledCommittedFinalization(now))]
|
|
: []),
|
|
));
|
|
timing.candidates(rows.length);
|
|
timing.phase("candidates");
|
|
const results = [];
|
|
for (const row of rows) {
|
|
const pendingEffects = row.decisionId
|
|
? await db.select({ id: statusDecisionEffects.id }).from(statusDecisionEffects).where(and(
|
|
eq(statusDecisionEffects.companyId, row.companyId),
|
|
eq(statusDecisionEffects.issueId, row.issueId),
|
|
eq(statusDecisionEffects.decisionId, row.decisionId),
|
|
inArray(statusDecisionEffects.deliveryState, ["pending", "failed"]),
|
|
))
|
|
: [];
|
|
if (row.decisionId && pendingEffects.length > 0) {
|
|
if (options.skipNativePendingEffects) continue;
|
|
const decision = resolveNativeReconciliationStatus({
|
|
facts: { undeliveredEffectCount: pendingEffects.length },
|
|
priorIssueStatus: row.issueStatus as NativeAuthoritativeIssueStatus,
|
|
agentId: row.agentId,
|
|
});
|
|
if (!decision.effects.some((effect) => effect.kind === "dispatch_pending_effect")) {
|
|
throw new Error("native_reconciliation_pending_effect_policy_missing");
|
|
}
|
|
const deliveredEffectIds = await dispatchPendingNativeStatusEffects({
|
|
db,
|
|
companyId: row.companyId,
|
|
issueId: row.issueId,
|
|
decisionId: row.decisionId,
|
|
runId: row.runId,
|
|
});
|
|
results.push({ runId: row.runId, phase: "committed", deliveredEffectIds });
|
|
continue;
|
|
}
|
|
if (row.assessmentId) {
|
|
const currentDecision = !options.skipNativeReviewResponse && row.coordinatorPhase === "committed" && row.decisionId
|
|
? await db.select().from(statusDecisions).where(and(
|
|
eq(statusDecisions.id, row.decisionId), eq(statusDecisions.companyId, row.companyId),
|
|
eq(statusDecisions.issueId, row.issueId),
|
|
)).limit(1).then(entries => entries[0] ?? null)
|
|
: null;
|
|
if (row.coordinatorPhase === "committed" && !options.skipNativeResponsePresentation) {
|
|
// A later decision can supersede this run's task status, but cannot
|
|
// erase its accepted, still-authorized response. Repair presentation
|
|
// before the status-only early return below, without rerunning work.
|
|
await repairCommittedNativeChatResponse(db, {
|
|
companyId: row.companyId, issueId: row.issueId, runId: row.runId,
|
|
});
|
|
}
|
|
if (
|
|
!options.skipNativeReviewResponse &&
|
|
row.coordinatorPhase === "committed" &&
|
|
row.decisionId &&
|
|
row.resultId &&
|
|
row.assessmentId &&
|
|
record(currentDecision?.decisionJson).externalChatReviewPresentation
|
|
) {
|
|
// A later chat turn may retain the same pending review. Recover the
|
|
// earlier response independently before status reconciliation skips
|
|
// its superseded decision; this cannot change issue disposition.
|
|
await repairCommittedNativeReviewResponse(db, {
|
|
companyId: row.companyId,
|
|
issueId: row.issueId,
|
|
runId: row.runId,
|
|
decisionId: row.decisionId,
|
|
resultId: row.resultId,
|
|
assessmentId: row.assessmentId,
|
|
});
|
|
}
|
|
const source = { ...row, assessmentId: row.assessmentId };
|
|
if (options.skipNativeStatusReassessment) {
|
|
if (await nativeStatusReassessmentSourceIsSuperseded(db, source)) continue;
|
|
} else {
|
|
const reassessed = await reassessNativeStatusFromSource(db, source);
|
|
if (reassessed.kind !== "unchanged") {
|
|
if (reassessed.kind === "committed") results.push(reassessed.result);
|
|
continue;
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
if (options.skipNativeRunProjection && row.coordinatorPhase === "committed" && row.runProjectionPending) continue;
|
|
const barrier = await db.select({ status: workspaceOperations.status })
|
|
.from(workspaceOperations)
|
|
.where(and(
|
|
eq(workspaceOperations.companyId, row.companyId),
|
|
eq(workspaceOperations.issueId, row.issueId),
|
|
eq(workspaceOperations.heartbeatRunId, row.runId),
|
|
eq(workspaceOperations.phase, "workspace_finalize"),
|
|
))
|
|
|
|
|
|
.orderBy(desc(sql`${workspaceOperations.status} = 'succeeded'`), desc(workspaceOperations.createdAt))
|
|
.limit(1)
|
|
.then((entries) => entries[0] ?? null);
|
|
if (!barrier || barrier.status !== "succeeded") {
|
|
if (options.skipNativeWorkspaceFinalization) continue;
|
|
const recoveryDecision = resolveNativeReconciliationStatus({
|
|
facts: { workspaceOperationPending: true },
|
|
priorIssueStatus: row.issueStatus as NativeAuthoritativeIssueStatus,
|
|
agentId: row.agentId,
|
|
});
|
|
if (!recoveryDecision.effects.some((effect) => effect.kind === "resume_workspace_operation")) {
|
|
throw new Error("native_reconciliation_workspace_resume_policy_missing");
|
|
}
|
|
const operation = await resumeNativeWorkspaceFinalization({
|
|
db,
|
|
runId: row.runId,
|
|
environmentRuntime: options.environmentRuntime,
|
|
pluginWorkerManager: options.pluginWorkerManager,
|
|
});
|
|
// Busy is ownership, not another failed export or retry-budget debit.
|
|
if (!operation) continue;
|
|
const workspaceFinalizeStatus =
|
|
operation.status === "succeeded" ? "succeeded" : "failed";
|
|
if (workspaceFinalizeStatus === "failed") {
|
|
const failure = await recordNativeWorkspaceFinalizationFailure(db, row.runId, operation);
|
|
results.push({
|
|
...failure,
|
|
reconciliationAction: "resume_workspace_operation" as const,
|
|
workspaceOperationId: operation.id,
|
|
workspaceFinalizeStatus,
|
|
reconciliationDecision: recoveryDecision,
|
|
});
|
|
if (failure.phase === "terminal_failure") {
|
|
await options
|
|
.onWorkspaceSettled?.({
|
|
runId: row.runId,
|
|
companyId: row.companyId,
|
|
agentId: row.agentId,
|
|
succeeded: false,
|
|
})
|
|
.catch(() => undefined);
|
|
}
|
|
continue;
|
|
}
|
|
if (options.skipNativeResultCommit && row.coordinatorPhase !== "committed") continue;
|
|
try {
|
|
const finalized = await finalizeNativeRun({
|
|
db,
|
|
runId: row.runId,
|
|
workspaceFinalizeStatus,
|
|
projectRunStatus: true,
|
|
skipNativeResponsePresentation: options.skipNativeResponsePresentation,
|
|
skipNativeReviewResponse: options.skipNativeReviewResponse,
|
|
skipCommittedRunProjection: options.skipNativeRunProjection,
|
|
skipExternalConversationSettlement: options.skipExternalConversationSettlement,
|
|
skipExternalAgentSettlement: options.skipExternalAgentSettlement,
|
|
skipNativeRecoveryNotices: options.skipNativeRecoveryNotices,
|
|
preserveProviderAttempt: Boolean(
|
|
readNativeWorkspaceSyncReference(
|
|
row.nativeWorkspaceSync,
|
|
),
|
|
),
|
|
});
|
|
results.push({
|
|
...finalized,
|
|
reconciliationAction: "resume_workspace_operation" as const,
|
|
workspaceOperationId: operation.id,
|
|
workspaceFinalizeStatus,
|
|
reconciliationDecision: recoveryDecision,
|
|
});
|
|
if (
|
|
workspaceFinalizeStatus === "succeeded" &&
|
|
finalized.phase === "committed"
|
|
) {
|
|
await cleanupNativeWorkspaceSync(row.runId).catch(() => undefined);
|
|
await options
|
|
.onWorkspaceSettled?.({
|
|
runId: row.runId,
|
|
companyId: row.companyId,
|
|
agentId: row.agentId,
|
|
succeeded: true,
|
|
})
|
|
.catch(() => undefined);
|
|
}
|
|
} catch (error) {
|
|
const failure = await recordNativeFinalizationFailure({
|
|
db,
|
|
runId: row.runId,
|
|
error,
|
|
projectRunStatus: true,
|
|
});
|
|
results.push({
|
|
...failure,
|
|
reconciliationAction: "resume_workspace_operation" as const,
|
|
workspaceOperationId: operation.id,
|
|
workspaceFinalizeStatus,
|
|
reconciliationDecision: recoveryDecision,
|
|
});
|
|
}
|
|
continue;
|
|
}
|
|
if (options.skipNativeResultCommit && row.coordinatorPhase !== "committed") continue;
|
|
const replayDecision = resolveNativeReconciliationStatus({
|
|
facts: { canonicalReplay: true },
|
|
priorIssueStatus: row.issueStatus as NativeAuthoritativeIssueStatus,
|
|
agentId: row.agentId,
|
|
});
|
|
if (replayDecision.reasonCode !== "completion_contract_satisfied") {
|
|
throw new Error("native_reconciliation_replay_policy_invalid");
|
|
}
|
|
try {
|
|
const finalized = await finalizeNativeRun({
|
|
db,
|
|
runId: row.runId,
|
|
workspaceFinalizeStatus: barrier.status as "succeeded" | "failed",
|
|
projectRunStatus: true,
|
|
skipNativeResponsePresentation: options.skipNativeResponsePresentation,
|
|
skipNativeReviewResponse: options.skipNativeReviewResponse,
|
|
skipCommittedRunProjection: options.skipNativeRunProjection,
|
|
skipExternalConversationSettlement: options.skipExternalConversationSettlement,
|
|
skipExternalAgentSettlement: options.skipExternalAgentSettlement,
|
|
skipNativeRecoveryNotices: options.skipNativeRecoveryNotices,
|
|
preserveProviderAttempt: Boolean(
|
|
readNativeWorkspaceSyncReference(
|
|
row.nativeWorkspaceSync,
|
|
),
|
|
),
|
|
});
|
|
results.push(finalized);
|
|
if (finalized.phase === "committed") {
|
|
await cleanupNativeWorkspaceSync(row.runId).catch(() => undefined);
|
|
await options
|
|
.onWorkspaceSettled?.({
|
|
runId: row.runId,
|
|
companyId: row.companyId,
|
|
agentId: row.agentId,
|
|
succeeded: true,
|
|
})
|
|
.catch(() => undefined);
|
|
}
|
|
} catch (error) {
|
|
results.push(await recordNativeFinalizationFailure({
|
|
db,
|
|
runId: row.runId,
|
|
error,
|
|
projectRunStatus: true,
|
|
}));
|
|
}
|
|
}
|
|
return results;
|
|
}
|