mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-12 14:19:00 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - A chat follow-up on a remote native task runs as a continuation: it starts from its predecessor's saved provider session and, for a warm session, copies the predecessor's runner process identity onto the new run > - When the continuation fails before it asks for a runner of its own, its due same-run retry sees a recorded process identity and reattaches to "this run's" runner. That runner never existed, so reattachment always fails with `native_remote_recovery_lease_mismatch` and the task is blocked > - On staging, 34 OpenCode runs on 31 tasks failed this way in one evening. Each first attempt failed while staging its provider pack, recorded no runner start, and kept its own sandbox lease active > - This pull request proves that shape from durable state, retires the inherited identity, and resumes the run the way its failed attempt ran, in its own retained sandbox > - The benefit is that these retries recover automatically, without a second sandbox and without the unsaved-work hold, while every run that may hold its own provider work keeps verified reattachment ## Linked Issues or Issue Description Refs #19839, Refs #19399, Refs #19755, Refs #19779, Refs #19827 **What happened?** A remote OpenCode chat continuation failed during provider-pack staging. The run had no runner start event, no process identity event, no controller checkpoint, and no provider event of its own. Its saved session (no active turn, no pending request) and its process identity were inherited from the previous run. Its failure detail recorded `providerEventsExist: false`, and its own sandbox lease stayed active. The due same-run retry chose `reattach_remote_runner` because a process identity was recorded. Reattachment failed with `native_remote_recovery_lease_mismatch`, and the run ended in `native_continuation_requires_reconciliation`. **Steps to reproduce** 1. Continue a warm remote native session so the new run inherits the predecessor's runner process identity and saved session. 2. Fail the new run with a retryable error before it requests a runner (for example during provider-pack staging). 3. Let the same-run retry become due. Recovery requests `reattach_remote_runner`, and reattachment fails. **Expected behavior** The retry resumes the run the way its failed attempt ran, in the same sandbox, and continues the inherited provider session. A run with any provider work, runner start, or controller checkpoint of its own keeps verified reattachment. ## What Changed - New `server/src/services/native-runtime/native-inherited-runner-retry.ts`: - `retireInheritedRemoteRunnerIdentity` locks the issue, the coordinator, then the run (the same order as same-run resumption). It retires the recorded process identity only when all of these hold: the coordinator is a result-less, unowned `exact_checkpoint_resume` retry with `providerEventsExist: false`; the run has no runner start, process identity, adapter invocation, tool start, runner event, controller checkpoint, provider event or provider session of its own; the saved session has no active turn and no pending runtime request; and a settled predecessor of the same native session recorded the exact same process identity (PID, group, start time). - It records `nativeInheritedRunnerRetirement` on the run profile, with the run's own sandbox location when that lease is still active, and an `inherited_runner_never_launched` lifecycle event. - `readInheritedRunnerRetainedSandbox` returns that sandbox only while it still matches the run's saved remote location. - `heartbeat/recovery.ts`: a due remote retry calls the retirement first. It returns `retired`, `not_applicable` or `transient`. A retired run (including one a concurrent sweep already retired) goes to the ordinary same-run resume (`claimNativeSessionResumptions`), with every existing admission gate. A lock timeout or database error is `transient`: the sweep defers the run and checks again, and never falls back to reattachment. Any other run keeps verified reattachment. - `heartbeat/workspace-preparation.ts`: an ordinary same-run resume with a retired inherited runner acquires its retained sandbox through the existing exact-lease path (`reattachRemoteLease`), which validates and resumes that lease and never acquires a replacement. It acquires as an ordinary resume does only when the exact-lease failure proves the sandbox is unusable (`stage=environment_acquire|environment_resume`, `field=lease|lease_status|lease_expired|provider_lease_id`). Any other failure, such as a scope mismatch on a live lease, is kept and acquires nothing. - New `server/src/services/retained-sandbox-absent-error.ts`: a sandbox provider's resume reply with no provider lease and `expired: true` (the reply Daytona and other providers send when the sandbox is gone) now throws `RetainedSandboxAbsentError`. Every other mismatched reply keeps the existing error. The exact-lease reattachment in `environment-run-orchestrator.ts` maps it to `native_remote_recovery_lease_mismatch: stage=environment_resume field=lease_expired`, so the same-run resume above acquires as an ordinary resume when the provider has removed the retained sandbox. A transient resume failure is not treated as absence and never acquires another sandbox. - `native-restart-recovery.ts` exports the existing `hasNativeExecutionStartEvidence` so both paths use one definition of "execution reached a runner". - `doc/execution-semantics.md` documents the rule. ## Previous-release state - No schema change. The new profile field is additive. - A run saved by an older release without `providerEventsExist`, without a native session ID, or without a matching predecessor keeps verified reattachment. That path is unchanged. When it loses its runner, it still requests the user's Retry once (#19399). - A run that an older release left with its own runner start, controller checkpoint or provider event keeps verified reattachment, because its outcome may be ambiguous. - A profile without the new marker never changes acquisition. ## Verification - `npx vitest run server/src/services/heartbeat/recovery.test.ts -t "never launched its own runner|keeps verified reattachment"` (embedded PostgreSQL): - Staging shape (inherited session and process identity, no runner start of its own, saved remote workspace, active own lease, recorded PID). Before the fix: fails, because recovery requested reattachment. After the fix: passes. The PID fields are cleared, the marker names the predecessor and the retained lease, `executeRun` is called once with a plain `nativeLeaseOwner` claim, the run still has exactly one (active) lease, and no process is signalled. - Negatives, passing before and after: a provider turn of its own, a runner start of its own, a controller checkpoint of its own, no matching predecessor, a pending runtime request in the saved session, and a failure detail without `providerEventsExist`. Each keeps verified reattachment and gets no marker. - `npx vitest run server/src/services/heartbeat/workspace-preparation.test.ts -t "retained sandbox|retired inherited"`: - A retired inherited runner acquires its retained sandbox with `reattachRemoteLease`. Before: fails. After: passes. - When the retained sandbox is gone, it acquires as an ordinary resume. Before: fails. After: passes. - Without the marker, acquisition is unchanged (passes before and after). - A `nativeWorkspaceSync` reference also resumes its retained sandbox. A live lease's `lease_scope` failure is kept and acquires nothing. - `recovery.test.ts` also covers a second retirement call (returns `retired`, one event) and a held issue lock (defers; no reattachment, no dispatch, PID kept). - `environment-runtime.test.ts`: on exact resume, only a provider reply with no lease and `expired: true` (with or without a workspace sentinel mismatch) is `RetainedSandboxAbsentError`; a different lease or a reply without `expired` keeps the existing error. `environment-run-orchestrator.test.ts`: confirmed absence becomes `stage=environment_resume field=lease_expired` and acquires nothing. - Affected suites pass locally: `recovery.test.ts`, `workspace-preparation.test.ts`, `heartbeat-native-remote-failure-retention.test.ts`, `workspace-recovery-retry-copyback.test.ts`, `native-runner-restart-recovery.integration.test.ts`. `pnpm --filter @paperclipai/server typecheck` passes. ## Risks - **No second executor.** The rule applies only when this run never requested a runner, so no runner of this run exists. The predecessor must be settled, and its identity is matched exactly. The resume then takes the normal same-run claim, which holds the issue lock and admission gates. - **No repeated side effects.** The run has no provider event, turn or tool start of its own. A saved session with a pending runtime request or an active turn keeps verified reattachment. The resumed provider session goes through the existing provider recovery, which refuses a pending side-effecting call with a permanent, named code. - **Bounded.** The retirement takes effect once per run, because afterwards the run has no recorded identity. A contended retirement waits for the next sweep. Further failures use the existing three-attempt provider budget. - I did not run a live Daytona sandbox, or a full preparation through a real sandbox plugin, through this path. The orchestrator's exact-lease checks are covered by its own unit tests. The evidence is recovery and preparation tests on a real database with the staging run shape. ## Model Used - Claude Opus 5.5 (Anthropic), model ID `claude-opus-5-5`, run in Claude Code with tool use (shell, file editing, read-only browser access to staging). ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
863 lines
37 KiB
TypeScript
863 lines
37 KiB
TypeScript
import { RetainedSandboxAbsentError } from "./retained-sandbox-absent-error.js";
|
|
import { freshWorkspaceSchema } from "@paperclipai/plugin-sdk";
|
|
import { prepareRemoteWorkspace, readRemoteWorkspaceAuthority, remoteWorkspaceRepositoryPath } from "./remote-workspaces.js";
|
|
/**
|
|
* Centralized environment run orchestrator.
|
|
*
|
|
* Owns the full environment lifecycle for a heartbeat run:
|
|
* 1. Resolve selected environment
|
|
* 2. Validate environment is active and allowed
|
|
* 3. Acquire or resume lease
|
|
* 4. Realize workspace in the environment
|
|
* 5. Resolve execution target for the adapter
|
|
* 6. Release / retain / fail lease according to policy
|
|
* 7. Record activity and operator-visible status
|
|
*
|
|
* Heartbeat callers delegate to this service instead of inlining
|
|
* environment resolution, lease management, workspace realization,
|
|
* and transport logic.
|
|
*/
|
|
|
|
import { environmentLeases, type Db } from "@paperclipai/db";
|
|
import { and, eq } from "drizzle-orm";
|
|
import { remoteExecutionHasStopped } from "./remote-execution-termination.js";
|
|
import { remoteRecoveryLeaseMismatch, type RemoteRecoveryLeaseMismatchField } from "./native-runtime/remote-recovery-lease-mismatch.js";
|
|
import { EnvironmentProviderUnavailableError, readTransientEnvironmentProviderFailure } from "./environment-provider-unavailable-error.js";
|
|
import { findReusableSandboxResumeError } from "./reusable-sandbox-resume-error.js";
|
|
import { findPluginWorkerUnavailableError } from "./plugin-worker-unavailable-error.js";
|
|
import type {
|
|
Environment,
|
|
EnvironmentLease,
|
|
EnvironmentLeasePolicy,
|
|
EnvironmentLeaseStatus,
|
|
ExecutionWorkspace,
|
|
ExecutionWorkspaceConfig,
|
|
IssueExecutionWorkspaceSettings,
|
|
} from "@paperclipai/shared";
|
|
import {
|
|
resolveRunnerEnvironmentForRun,
|
|
resolveRunnerReconnectGraceMsForRun,
|
|
resolveWarmRunnerIdleTimeoutMsForRun,
|
|
} from "./runner-environment-lifecycle.js";
|
|
import { environmentService } from "./environments.js";
|
|
import {
|
|
environmentRuntimeService,
|
|
buildEnvironmentLeaseContext,
|
|
type EnvironmentRuntimeLeaseRecord,
|
|
type EnvironmentRuntimeService,
|
|
type ProviderResourceDisposition,
|
|
} from "./environment-runtime.js";
|
|
import { ENVIRONMENT_DRIVER_TRAITS } from "./environment-driver-traits.js";
|
|
import {
|
|
resolveEnvironmentExecutionTarget,
|
|
resolveEnvironmentExecutionTransport,
|
|
} from "./environment-execution-target.js";
|
|
import {
|
|
adapterExecutionTargetToRemoteSpec,
|
|
type AdapterExecutionTarget,
|
|
type AdapterRemoteExecutionSpec,
|
|
type AdapterWorkspaceRealization,
|
|
} from "@paperclipai/adapter-utils/execution-target";
|
|
import type { DuplexObservabilityRecorder } from "@paperclipai/adapter-utils/duplex-observability";
|
|
import { buildWorkspaceRealizationRequest } from "./workspace-realization.js";
|
|
import { executionWorkspaceService } from "./execution-workspaces.js";
|
|
import { logActivity } from "./activity-log.js";
|
|
import { logger } from "../middleware/logger.js";
|
|
import { parseObject } from "../adapters/utils.js";
|
|
import type { RealizedExecutionWorkspace } from "./workspace-runtime.js";
|
|
import type { PluginWorkerManager } from "./plugin-worker-manager.js";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Error types
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export type EnvironmentErrorCode =
|
|
| "environment_not_found"
|
|
| "environment_inactive"
|
|
| "unsupported_environment"
|
|
| "unsupported_adapter_environment"
|
|
| "probe_failed"
|
|
| "lease_acquire_failed"
|
|
| "workspace_realization_failed"
|
|
| "transport_resolution_failed"
|
|
| "lease_release_failed"
|
|
| "lease_cleanup_failed";
|
|
|
|
export class EnvironmentRunError extends Error {
|
|
code: EnvironmentErrorCode;
|
|
environmentId?: string;
|
|
driver?: string;
|
|
provider?: string;
|
|
cause?: unknown;
|
|
|
|
constructor(
|
|
code: EnvironmentErrorCode,
|
|
message: string,
|
|
details?: {
|
|
environmentId?: string;
|
|
driver?: string;
|
|
provider?: string;
|
|
cause?: unknown;
|
|
},
|
|
) {
|
|
super(message);
|
|
this.name = "EnvironmentRunError";
|
|
this.code = code;
|
|
this.environmentId = details?.environmentId;
|
|
this.driver = details?.driver;
|
|
this.provider = details?.provider;
|
|
this.cause = details?.cause;
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Orchestration result types
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export interface EnvironmentAcquisitionResult {
|
|
environment: Environment;
|
|
lease: EnvironmentLease;
|
|
leaseContext: ReturnType<typeof buildEnvironmentLeaseContext>;
|
|
executionTransport: Record<string, unknown> | null;
|
|
}
|
|
|
|
export interface EnvironmentRealizationResult {
|
|
lease: EnvironmentLease;
|
|
workspaceRealization: Record<string, unknown>;
|
|
executionTarget: AdapterExecutionTarget | null;
|
|
remoteExecution: AdapterRemoteExecutionSpec | null;
|
|
persistedExecutionWorkspace: ExecutionWorkspace | null;
|
|
}
|
|
|
|
export interface EnvironmentReleaseResult {
|
|
released: EnvironmentRuntimeLeaseRecord[];
|
|
errors: Array<{ leaseId: string; error: unknown }>;
|
|
}
|
|
|
|
function firstNonEmptyLine(text: string | null | undefined): string | null {
|
|
if (!text) return null;
|
|
for (const rawLine of text.split(/\r?\n/)) {
|
|
const line = rawLine.trim();
|
|
if (line) return line;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function formatProvisionFailureDetail(result: {
|
|
exitCode: number | null;
|
|
signal?: string | null;
|
|
timedOut: boolean;
|
|
stdout: string;
|
|
stderr: string;
|
|
}): string {
|
|
if (result.timedOut) {
|
|
return "provision command timed out";
|
|
}
|
|
const signal = typeof result.signal === "string" && result.signal.trim().length > 0
|
|
? ` (signal ${result.signal.trim()})`
|
|
: "";
|
|
const detail = firstNonEmptyLine(result.stderr) ?? firstNonEmptyLine(result.stdout);
|
|
const status = `exit code ${result.exitCode ?? "null"}${signal}`;
|
|
return detail ? `${status}: ${detail}` : status;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Service factory
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export function environmentRunOrchestrator(
|
|
db: Db,
|
|
options: {
|
|
pluginWorkerManager?: PluginWorkerManager;
|
|
environmentRuntime?: EnvironmentRuntimeService;
|
|
} = {},
|
|
) {
|
|
const environmentsSvc = environmentService(db);
|
|
const executionWorkspacesSvc = executionWorkspaceService(db);
|
|
const environmentRuntime = options.environmentRuntime ?? environmentRuntimeService(db, {
|
|
pluginWorkerManager: options.pluginWorkerManager,
|
|
});
|
|
|
|
/**
|
|
* Resolve the selected environment for a run. The caller passes the concrete
|
|
* selected environment id plus the built-in local fallback id used to lazily
|
|
* ensure the local environment row exists.
|
|
*/
|
|
async function resolveEnvironment(input: {
|
|
companyId: string;
|
|
selectedEnvironmentId: string;
|
|
localEnvironmentId: string;
|
|
}): Promise<Environment> {
|
|
const environmentId = input.selectedEnvironmentId || input.localEnvironmentId;
|
|
|
|
const environment =
|
|
environmentId === input.localEnvironmentId
|
|
? await environmentsSvc.ensureLocalEnvironment(input.companyId)
|
|
: await environmentsSvc.getById(environmentId);
|
|
|
|
if (!environment) {
|
|
throw new EnvironmentRunError("environment_not_found", `Environment "${environmentId}" not found.`, {
|
|
environmentId,
|
|
});
|
|
}
|
|
|
|
if (environment.status !== "active") {
|
|
throw new EnvironmentRunError("environment_inactive", `Environment "${environment.name}" is not active (status: ${environment.status}).`, {
|
|
environmentId: environment.id,
|
|
driver: environment.driver,
|
|
});
|
|
}
|
|
|
|
return environment;
|
|
}
|
|
|
|
/**
|
|
* Acquire an environment lease for a heartbeat run.
|
|
* Wraps the runtime driver's acquire call with standardized error handling.
|
|
*/
|
|
async function acquireLease(input: {
|
|
companyId: string;
|
|
environment: Environment;
|
|
configuredEnvironment?: Environment;
|
|
issueId: string | null;
|
|
agentId: string;
|
|
heartbeatRunId: string;
|
|
persistedExecutionWorkspace: Pick<ExecutionWorkspace, "id" | "mode"> | null;
|
|
executionWorkspaceSettings: IssueExecutionWorkspaceSettings | null;
|
|
runnerTaskProjectIds?: readonly string[];
|
|
adapterType: string | null;
|
|
warmRunnerIdleTimeoutMs?: number;
|
|
runnerReconnectGraceMs?: number;
|
|
}): Promise<EnvironmentRuntimeLeaseRecord> {
|
|
try {
|
|
return await environmentRuntime.acquireRunLease(input);
|
|
} catch (err) {
|
|
// A transient provider refusal (HTTP 5xx/429/timeout) is retried by the
|
|
// setup path with its bounded bootstrap budget. Saved-sandbox resume and
|
|
// stopped-worker refusals keep their own, more specific classifications.
|
|
const transient = findReusableSandboxResumeError(err) || findPluginWorkerUnavailableError(err)
|
|
? null : readTransientEnvironmentProviderFailure(err);
|
|
throw new EnvironmentRunError(
|
|
"lease_acquire_failed",
|
|
`Failed to acquire lease for environment "${input.environment.name}" (${input.environment.driver}): ${err instanceof Error ? err.message : String(err)}`,
|
|
{
|
|
environmentId: input.environment.id,
|
|
driver: input.environment.driver,
|
|
cause: transient
|
|
? new EnvironmentProviderUnavailableError({ environmentName: input.environment.name,
|
|
driver: input.environment.driver, evidence: transient, cause: err })
|
|
: err,
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Resolve the execution transport for an adapter based on the acquired lease.
|
|
*/
|
|
async function resolveTransport(input: {
|
|
companyId: string;
|
|
adapterType: string;
|
|
environment: Environment;
|
|
leaseMetadata: Record<string, unknown> | null;
|
|
}): Promise<Record<string, unknown> | null> {
|
|
try {
|
|
return await resolveEnvironmentExecutionTransport({
|
|
db,
|
|
companyId: input.companyId,
|
|
adapterType: input.adapterType,
|
|
environment: input.environment,
|
|
leaseMetadata: input.leaseMetadata,
|
|
});
|
|
} catch (err) {
|
|
throw new EnvironmentRunError(
|
|
"transport_resolution_failed",
|
|
`Failed to resolve execution transport for "${input.environment.name}": ${err instanceof Error ? err.message : String(err)}`,
|
|
{
|
|
environmentId: input.environment.id,
|
|
driver: input.environment.driver,
|
|
cause: err,
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Full acquisition flow: resolve environment, acquire lease, resolve transport.
|
|
* This is the primary entry point for heartbeat run setup.
|
|
*/
|
|
async function acquireForRun(input: {
|
|
companyId: string;
|
|
selectedEnvironmentId: string;
|
|
localEnvironmentId: string;
|
|
adapterType: string;
|
|
adapterConfig?: Record<string, unknown>;
|
|
admittedLifecycleMode?: "warm" | "per_turn";
|
|
managedAiCredentialIdentity?: string;
|
|
issueId: string | null;
|
|
heartbeatRunId: string;
|
|
agentId: string;
|
|
persistedExecutionWorkspace: Pick<ExecutionWorkspace, "id" | "mode"> | null;
|
|
executionWorkspaceSettings: IssueExecutionWorkspaceSettings | null;
|
|
runnerTaskProjectIds?: readonly string[];
|
|
/** Existing live runner recovery must use its original active lease, including ephemeral leases. */
|
|
reattachRemoteLease?: { kind?: "sandbox"; leaseId: string; providerLeaseId: string; remoteCwd: string }
|
|
| { kind: "plugin_task"; leaseId: string };
|
|
/** A new controller must stop the previous plugin task before allocating a replacement. */
|
|
recoverPluginTask?: boolean;
|
|
}): Promise<EnvironmentAcquisitionResult> {
|
|
// Step 1: Resolve environment
|
|
const selectedEnvironment = await resolveEnvironment({
|
|
companyId: input.companyId,
|
|
selectedEnvironmentId: input.selectedEnvironmentId,
|
|
localEnvironmentId: input.localEnvironmentId,
|
|
});
|
|
|
|
const environment = resolveRunnerEnvironmentForRun(
|
|
selectedEnvironment, input.adapterType, input.adapterConfig, input.admittedLifecycleMode, input.managedAiCredentialIdentity,
|
|
);
|
|
|
|
if (input.recoverPluginTask && environment.driver === "plugin" && input.adapterType === "paperclip_runner") {
|
|
const prior = await db.select().from(environmentLeases).where(and(
|
|
eq(environmentLeases.companyId, input.companyId), eq(environmentLeases.heartbeatRunId, input.heartbeatRunId),
|
|
));
|
|
if (prior.some(lease => lease.metadata?.runnerTaskLease !== true || lease.environmentId !== environment.id ||
|
|
lease.issueId !== input.issueId || lease.executionWorkspaceId !== (input.persistedExecutionWorkspace?.id ?? null) ||
|
|
lease.metadata?.agentId !== input.agentId)) throw new Error("native_plugin_recovery_lease_mismatch");
|
|
const errors: unknown[] = [];
|
|
await environmentRuntime.releaseRunLeases(input.heartbeatRunId, "released", (_id, error) => { errors.push(error); });
|
|
if (errors.length || (prior.length && !await remoteExecutionHasStopped(db, input.companyId, input.heartbeatRunId))) {
|
|
throw new Error("native_plugin_recovery_termination_unconfirmed", { cause: errors[0] });
|
|
}
|
|
}
|
|
|
|
// Step 2: Acquire lease
|
|
const warmRunnerIdleTimeoutMs = resolveWarmRunnerIdleTimeoutMsForRun(
|
|
selectedEnvironment, input.adapterType, input.adapterConfig, input.admittedLifecycleMode,
|
|
);
|
|
const runnerReconnectGraceMs = input.adapterType
|
|
? resolveRunnerReconnectGraceMsForRun(selectedEnvironment, input.adapterType)
|
|
: undefined;
|
|
const acquisitionInput = {
|
|
companyId: input.companyId,
|
|
environment,
|
|
configuredEnvironment: selectedEnvironment,
|
|
issueId: input.issueId,
|
|
agentId: input.agentId,
|
|
heartbeatRunId: input.heartbeatRunId,
|
|
persistedExecutionWorkspace: input.persistedExecutionWorkspace,
|
|
executionWorkspaceSettings: input.executionWorkspaceSettings,
|
|
runnerTaskProjectIds: input.runnerTaskProjectIds,
|
|
adapterType: input.adapterType ?? null,
|
|
warmRunnerIdleTimeoutMs,
|
|
runnerReconnectGraceMs,
|
|
};
|
|
let leaseRecord: EnvironmentRuntimeLeaseRecord;
|
|
if (input.reattachRemoteLease) {
|
|
// Reattachment keeps the original lease, including ephemeral leases.
|
|
// Never reacquire: acquisition can replace a non-reusable sandbox.
|
|
const expected = input.reattachRemoteLease;
|
|
const lease = await environmentsSvc.getLeaseById(expected.leaseId);
|
|
const mismatch: RemoteRecoveryLeaseMismatchField | null = !lease ? "lease"
|
|
: lease.companyId !== input.companyId || lease.environmentId !== environment.id ||
|
|
lease.heartbeatRunId !== input.heartbeatRunId || lease.issueId !== input.issueId ||
|
|
lease.executionWorkspaceId !== (input.persistedExecutionWorkspace?.id ?? null) ||
|
|
lease.metadata?.agentId !== input.agentId ? "lease_scope"
|
|
: lease.status !== "active" || lease.releasedAt !== null || lease.cleanupStatus !== null ? "lease_status"
|
|
: lease.expiresAt !== null && new Date(lease.expiresAt).getTime() <= Date.now() ? "lease_expired"
|
|
: expected.kind === "plugin_task"
|
|
? environment.driver !== "plugin" ? "environment_driver"
|
|
: input.adapterType !== "paperclip_runner" ? "adapter_type"
|
|
: lease.metadata?.runnerTaskLease !== true || lease.metadata?.runnerTaskDispatched !== true
|
|
|| lease.metadata?.runnerTaskExecutionStopped === true ? "plugin_task" : null
|
|
: environment.driver !== "sandbox" ? "environment_driver"
|
|
: lease.provider !== environment.config.provider ? "provider"
|
|
: lease.providerLeaseId !== expected.providerLeaseId ? "provider_lease_id"
|
|
: lease.metadata?.remoteCwd !== expected.remoteCwd ? "remote_cwd" : null;
|
|
if (mismatch || !lease) {
|
|
throw remoteRecoveryLeaseMismatch("environment_acquire", mismatch ?? "lease");
|
|
}
|
|
if (expected.kind !== "plugin_task") {
|
|
// The provider may have archived this retained sandbox while its
|
|
// controller was offline. Ready the exact lease under its lifecycle
|
|
// budget before short preparation commands (such as project-auth
|
|
// checks) can run. This API cannot acquire a replacement.
|
|
const resumed = await environmentRuntime.resumeRunLease({
|
|
environment, lease, requestedCwd: expected.remoteCwd,
|
|
...(warmRunnerIdleTimeoutMs !== undefined ? { warmRunnerIdleTimeoutMs } : {}),
|
|
...(runnerReconnectGraceMs !== undefined ? { runnerReconnectGraceMs } : {}),
|
|
}).catch((error: unknown) => {
|
|
// Confirmed provider absence is a named lease mismatch; other
|
|
// failures (for example a transient refusal) keep their own error.
|
|
if (error instanceof RetainedSandboxAbsentError) throw remoteRecoveryLeaseMismatch("environment_resume", "lease_expired");
|
|
throw error;
|
|
});
|
|
if (!resumed) throw remoteRecoveryLeaseMismatch("environment_resume", "lease");
|
|
if (resumed.providerLeaseId !== expected.providerLeaseId) {
|
|
throw remoteRecoveryLeaseMismatch("environment_resume", "provider_lease_id");
|
|
}
|
|
if (resumed.metadata?.expired === true) throw remoteRecoveryLeaseMismatch("environment_resume", "lease_expired");
|
|
// Resume confirms allocation readiness. Its optional metadata may
|
|
// describe the allocation root; keep the validated lease's workspace.
|
|
}
|
|
leaseRecord = { environment, lease, leaseContext: buildEnvironmentLeaseContext(input) };
|
|
} else {
|
|
leaseRecord = await acquireLease(acquisitionInput);
|
|
}
|
|
|
|
// Step 3: Log lease acquisition activity
|
|
await logActivity(db, {
|
|
companyId: input.companyId,
|
|
actorType: "agent",
|
|
actorId: input.agentId,
|
|
agentId: input.agentId,
|
|
runId: input.heartbeatRunId,
|
|
action: "environment.lease_acquired",
|
|
entityType: "environment_lease",
|
|
entityId: leaseRecord.lease.id,
|
|
issueId: input.issueId,
|
|
details: {
|
|
environmentId: environment.id,
|
|
driver: environment.driver,
|
|
leasePolicy: leaseRecord.lease.leasePolicy,
|
|
provider: leaseRecord.lease.provider,
|
|
executionWorkspaceId: leaseRecord.leaseContext.executionWorkspaceId,
|
|
issueId: input.issueId,
|
|
networkEgress: input.executionWorkspaceSettings?.networkEgress ?? null,
|
|
},
|
|
});
|
|
|
|
// Step 4: Resolve execution transport
|
|
const executionTransport = environment.driver === "plugin" && input.adapterType === "paperclip_runner" ? null : await resolveTransport({
|
|
companyId: input.companyId,
|
|
adapterType: input.adapterType,
|
|
environment,
|
|
leaseMetadata: leaseRecord.lease.metadata,
|
|
});
|
|
|
|
return {
|
|
environment,
|
|
lease: leaseRecord.lease,
|
|
leaseContext: leaseRecord.leaseContext,
|
|
executionTransport,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Realize workspace in the environment and resolve the execution target.
|
|
*
|
|
* After lease acquisition, this method:
|
|
* 1. Builds a workspace realization request
|
|
* 2. Calls the environment runtime driver to realize the workspace
|
|
* 3. Persists realization metadata on the lease and execution workspace
|
|
* 4. Resolves the adapter execution target (local/ssh/sandbox)
|
|
*
|
|
* Returns the updated lease, realization metadata, and the execution
|
|
* target spec that the adapter needs to run.
|
|
*/
|
|
async function realizeForRun(input: {
|
|
environment: Environment;
|
|
lease: EnvironmentLease;
|
|
adapterType: string;
|
|
companyId: string;
|
|
issueId: string | null;
|
|
heartbeatRunId: string;
|
|
executionWorkspace: RealizedExecutionWorkspace;
|
|
effectiveExecutionWorkspaceMode: string | null;
|
|
persistedExecutionWorkspace: ExecutionWorkspace | null;
|
|
runnerTaskProjectIds?: readonly string[];
|
|
assertOwned?: () => Promise<void>;
|
|
/**
|
|
* The host duplex observability recorder for this run. The orchestrator threads
|
|
* it to `resolveEnvironmentExecutionTarget`, which stamps it on the sandbox
|
|
* target. Absent keeps the safe no-op default in the bridge.
|
|
*/
|
|
duplexObservabilityRecorder?: DuplexObservabilityRecorder | null;
|
|
}): Promise<EnvironmentRealizationResult> {
|
|
const {
|
|
environment,
|
|
adapterType,
|
|
companyId,
|
|
issueId,
|
|
heartbeatRunId,
|
|
executionWorkspace,
|
|
effectiveExecutionWorkspaceMode,
|
|
} = input;
|
|
let { lease, persistedExecutionWorkspace } = input;
|
|
const remoteAuthority = readRemoteWorkspaceAuthority(persistedExecutionWorkspace?.metadata);
|
|
if (remoteAuthority && persistedExecutionWorkspace) {
|
|
if (adapterType !== "paperclip_runner" || environment.driver !== "sandbox") {
|
|
throw new EnvironmentRunError("unsupported_adapter_environment", "Remote workspace storage requires a qualified native sandbox");
|
|
}
|
|
const target = await resolveEnvironmentExecutionTarget({
|
|
db, companyId, adapterType, environment, leaseId: lease.id, leaseMetadata: lease.metadata,
|
|
lease, environmentRuntime, duplexObservabilityRecorder: input.duplexObservabilityRecorder ?? null,
|
|
});
|
|
if (!target || target.kind !== "remote" || target.transport !== "sandbox" || !target.runner || target.runnerTask) {
|
|
throw new EnvironmentRunError("transport_resolution_failed", "Remote workspace storage requires a sandbox file transport");
|
|
}
|
|
const prepared = await prepareRemoteWorkspace({
|
|
db, workspace: persistedExecutionWorkspace, runId: heartbeatRunId, lease, target,
|
|
source: remoteAuthority.source,
|
|
assertOwned: input.assertOwned,
|
|
provisionCommand: persistedExecutionWorkspace.config?.provisionCommand,
|
|
runtimeProvisionCommand: persistedExecutionWorkspace.config?.runtimeProvisionCommand,
|
|
});
|
|
lease = prepared.lease;
|
|
const workspaceRealization = {
|
|
version: 1, mode: "in_place", authoritativeRoot: prepared.remoteRoot,
|
|
pathAliases: [], outboundRestorePaths: [], provider: lease.provider,
|
|
environmentId: environment.id, leaseId: lease.id, providerLeaseId: lease.providerLeaseId,
|
|
additional: (remoteAuthority.source.repositories ?? []).filter(repository => repository.readOnly)
|
|
.map(repository => ({ path: remoteWorkspaceRepositoryPath(prepared.remoteRoot, repository),
|
|
projectId: repository.projectId ?? null, projectWorkspaceId: repository.projectWorkspaceId ?? null,
|
|
repoUrl: repository.repoUrl, repoRef: repository.ref })),
|
|
};
|
|
const updatedLease = await environmentsSvc.updateLeaseMetadata(lease.id, {
|
|
...lease.metadata, remoteCwd: prepared.remoteRoot, workspaceRealization,
|
|
});
|
|
if (!updatedLease) throw new Error("Remote workspace lease disappeared during preparation");
|
|
lease = updatedLease;
|
|
persistedExecutionWorkspace = await executionWorkspacesSvc.getStoredById(companyId, prepared.workspace.id);
|
|
if (!persistedExecutionWorkspace) throw new Error("Remote workspace disappeared during preparation");
|
|
input.executionWorkspace.cwd = prepared.remoteRoot;
|
|
const executionTarget: AdapterExecutionTarget = {
|
|
...target, remoteCwd: prepared.remoteRoot,
|
|
workspaceRealization: { mode: "in_place", authoritativeRoot: prepared.remoteRoot,
|
|
pathAliases: [], outboundRestorePaths: [] },
|
|
};
|
|
return { lease, workspaceRealization, executionTarget,
|
|
remoteExecution: adapterExecutionTargetToRemoteSpec(executionTarget), persistedExecutionWorkspace };
|
|
}
|
|
|
|
if (executionWorkspace.providerBinding) {
|
|
const binding = executionWorkspace.providerBinding;
|
|
if (environment.driver !== "plugin" || adapterType !== "paperclip_runner" || lease.metadata?.pluginId !== binding.pluginId || !await environmentRuntime.supportsTasks({ lease })) {
|
|
throw new EnvironmentRunError("workspace_realization_failed", "The execution plugin cannot access this managed workspace.", { environmentId: environment.id });
|
|
}
|
|
const updated = await environmentsSvc.updateLeaseMetadata(lease.id, { ...lease.metadata, workspaceProvider: binding });
|
|
if (!updated) throw new Error("Workspace execution lease disappeared");
|
|
lease = updated;
|
|
}
|
|
const freshWorkspace = freshWorkspaceSchema.safeParse(persistedExecutionWorkspace?.metadata?.workspaceProviderFresh ??
|
|
persistedExecutionWorkspace?.metadata?.runnerTaskFreshWorkspace);
|
|
if (environment.driver === "plugin" && freshWorkspace.success) {
|
|
const updated = await environmentsSvc.updateLeaseMetadata(lease.id, { ...lease.metadata, freshWorkspace: freshWorkspace.data });
|
|
if (!updated) throw new Error("Workspace execution lease disappeared");
|
|
lease = updated;
|
|
}
|
|
// Step 1: Build workspace realization request
|
|
const workspaceRealizationRequest = buildWorkspaceRealizationRequest({
|
|
adapterType,
|
|
companyId,
|
|
environmentId: environment.id,
|
|
executionWorkspaceId: persistedExecutionWorkspace?.id ?? null,
|
|
issueId,
|
|
heartbeatRunId,
|
|
requestedMode: persistedExecutionWorkspace?.mode ?? effectiveExecutionWorkspaceMode,
|
|
workspace: executionWorkspace,
|
|
workspaceConfig: persistedExecutionWorkspace?.config ?? null,
|
|
});
|
|
|
|
// Step 2: Realize workspace in the environment via the runtime driver
|
|
let workspaceRealization: Record<string, unknown> = {};
|
|
let realizedWorkspaceCwd: string | null = null;
|
|
const pluginTaskWorkspace = environment.driver === "plugin" && adapterType === "paperclip_runner";
|
|
if (pluginTaskWorkspace && !await environmentRuntime.supportsTasks({ lease })) {
|
|
throw new EnvironmentRunError("unsupported_adapter_environment", "Plugin environment does not provide native Runner task execution");
|
|
}
|
|
if (ENVIRONMENT_DRIVER_TRAITS[environment.driver].realizesWorkspace) {
|
|
try {
|
|
const remoteCwd =
|
|
typeof lease.metadata?.remoteCwd === "string" && lease.metadata.remoteCwd.trim().length > 0
|
|
? lease.metadata.remoteCwd
|
|
: undefined;
|
|
const workspaceRealizationResult = await environmentRuntime.realizeWorkspace({
|
|
environment,
|
|
lease,
|
|
workspace: {
|
|
localPath: executionWorkspace.cwd,
|
|
remotePath: remoteCwd,
|
|
mode: persistedExecutionWorkspace?.mode ?? effectiveExecutionWorkspaceMode ?? undefined,
|
|
metadata: {
|
|
workspaceRealizationRequest,
|
|
},
|
|
},
|
|
});
|
|
realizedWorkspaceCwd =
|
|
typeof workspaceRealizationResult.cwd === "string" && workspaceRealizationResult.cwd.trim().length > 0
|
|
? workspaceRealizationResult.cwd.trim()
|
|
: null;
|
|
workspaceRealization = parseObject(workspaceRealizationResult.metadata?.workspaceRealization);
|
|
} catch (err) {
|
|
throw new EnvironmentRunError(
|
|
"workspace_realization_failed",
|
|
`Failed to realize workspace for environment "${environment.name}" (${environment.driver}): ${err instanceof Error ? err.message : String(err)}`,
|
|
{
|
|
environmentId: environment.id,
|
|
driver: environment.driver,
|
|
cause: err,
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
const provisionCommand = workspaceRealizationRequest.runtimeOverlay.provisionCommand?.trim() ?? "";
|
|
const realizedCwd =
|
|
realizedWorkspaceCwd ??
|
|
(typeof lease.metadata?.remoteCwd === "string" && lease.metadata.remoteCwd.trim().length > 0
|
|
? lease.metadata.remoteCwd.trim()
|
|
: executionWorkspace.cwd);
|
|
// The host `provisionCommand` runs on the host worktree during the
|
|
// `workspace_provision` step, before the run reaches the environment.
|
|
// A `sandbox`-driver environment does not receive the repo tree here. The
|
|
// sandbox driver `realizeWorkspace` step only creates the remote folder.
|
|
// The adapter uploads the provisioned tree later, in its `stage.sync` step.
|
|
// So the host command must not run inside the still-empty sandbox; it fails
|
|
// there (exit 127). Skip the step for `sandbox`, and keep the existing skip
|
|
// for `local`. Keep the step for `ssh`, which runs the command on the
|
|
// remote host that shares the workspace path.
|
|
const driverSkipsHostProvision =
|
|
environment.driver === "local" || environment.driver === "sandbox" || pluginTaskWorkspace;
|
|
if (provisionCommand && environment.driver === "sandbox") {
|
|
logger.info(
|
|
{
|
|
environmentId: environment.id,
|
|
driver: environment.driver,
|
|
issueId,
|
|
heartbeatRunId,
|
|
},
|
|
"Skip host provisionCommand for sandbox-driver environment; the adapter stage.sync step delivers the provisioned tree",
|
|
);
|
|
}
|
|
if (provisionCommand && !executionWorkspace.providerBinding && !driverSkipsHostProvision) {
|
|
try {
|
|
const provisionResult = await environmentRuntime.execute({
|
|
environment,
|
|
lease,
|
|
command: "bash",
|
|
args: ["-lc", provisionCommand],
|
|
cwd: realizedCwd,
|
|
env: {
|
|
SHELL: "/bin/bash",
|
|
},
|
|
timeoutMs: 300_000,
|
|
// The provision command runs before the run opens its trace root, so it
|
|
// carries no run parent. A sandbox provider that opens a persistent
|
|
// session on the first command must not open the session here, or the
|
|
// session-setup span loses its parent and the span backend drops it.
|
|
// Bypass the session for this command; the session opens on the first
|
|
// in-run command instead, whose setup span parents to the run trace.
|
|
bypassSession: true,
|
|
});
|
|
if (provisionResult.exitCode !== 0 || provisionResult.timedOut) {
|
|
throw new Error(formatProvisionFailureDetail(provisionResult));
|
|
}
|
|
} catch (err) {
|
|
throw new EnvironmentRunError(
|
|
"workspace_realization_failed",
|
|
`Failed to provision workspace for environment "${environment.name}" (${environment.driver}): ${err instanceof Error ? err.message : String(err)}`,
|
|
{
|
|
environmentId: environment.id,
|
|
driver: environment.driver,
|
|
cause: err,
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
// Step 3: Persist realization metadata on lease and execution workspace
|
|
if (Object.keys(workspaceRealization).length > 0) {
|
|
const nextLeaseMetadata = {
|
|
...(lease.metadata ?? {}),
|
|
workspaceRealization,
|
|
};
|
|
const updatedLease = await environmentsSvc.updateLeaseMetadata(lease.id, nextLeaseMetadata);
|
|
if (updatedLease) {
|
|
lease = updatedLease;
|
|
}
|
|
if (persistedExecutionWorkspace) {
|
|
const updatedEw = await executionWorkspacesSvc.update(persistedExecutionWorkspace.id, {
|
|
metadata: {
|
|
...(persistedExecutionWorkspace.metadata ?? {}),
|
|
workspaceRealizationRequest,
|
|
workspaceRealization,
|
|
},
|
|
});
|
|
if (updatedEw) {
|
|
persistedExecutionWorkspace = updatedEw;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Step 4: Resolve execution target for the adapter
|
|
let executionTarget: AdapterExecutionTarget | null;
|
|
try {
|
|
executionTarget = await resolveEnvironmentExecutionTarget({
|
|
db,
|
|
companyId,
|
|
adapterType,
|
|
environment,
|
|
leaseId: lease.id,
|
|
leaseMetadata: (lease.metadata as Record<string, unknown> | null) ?? null,
|
|
lease,
|
|
environmentRuntime,
|
|
duplexObservabilityRecorder: input.duplexObservabilityRecorder ?? null,
|
|
});
|
|
// The native plugin task bridge owns guest paths. Never fall back to host execution.
|
|
if (executionWorkspace.providerBinding && (executionTarget?.kind !== "remote" ||
|
|
!("runnerTask" in executionTarget) || typeof executionTarget.runnerTask !== "function")) {
|
|
throw new Error("Managed workspace execution requires the native plugin task bridge");
|
|
}
|
|
const realizationMode = pluginTaskWorkspace || workspaceRealization.mode === "in_place" ? "in_place" : "copy";
|
|
const authoritativeRoot =
|
|
typeof workspaceRealization.authoritativeRoot === "string" && workspaceRealization.authoritativeRoot.trim().length > 0
|
|
? workspaceRealization.authoritativeRoot.trim()
|
|
: realizedCwd;
|
|
const workspaceTargetMetadata: AdapterWorkspaceRealization = {
|
|
mode: realizationMode,
|
|
authoritativeRoot,
|
|
pathAliases: Array.isArray(workspaceRealization.pathAliases)
|
|
? workspaceRealization.pathAliases.filter(
|
|
(entry): entry is { path: string; target: string } =>
|
|
typeof entry === "object" && entry !== null &&
|
|
typeof (entry as { path?: unknown }).path === "string" &&
|
|
typeof (entry as { target?: unknown }).target === "string",
|
|
)
|
|
: [],
|
|
outboundRestorePaths: Array.isArray(workspaceRealization.outboundRestorePaths)
|
|
? workspaceRealization.outboundRestorePaths.filter((entry): entry is string => typeof entry === "string")
|
|
: [],
|
|
};
|
|
if (executionTarget) {
|
|
executionTarget = {
|
|
...executionTarget,
|
|
...(executionTarget.kind === "remote" && executionTarget.transport === "sandbox" && executionTarget.runnerTask
|
|
? { runnerTaskProjectIds: [...new Set([executionWorkspace.projectId,
|
|
...input.runnerTaskProjectIds ?? [],
|
|
...(executionWorkspace.additionalWorkspaces ?? []).map(workspace => workspace.projectId)]
|
|
.filter((id): id is string => Boolean(id)))] } : {}),
|
|
...(executionTarget.kind === "remote" && realizationMode === "in_place"
|
|
? { remoteCwd: authoritativeRoot }
|
|
: {}),
|
|
workspaceRealization: workspaceTargetMetadata,
|
|
} as AdapterExecutionTarget;
|
|
}
|
|
} catch (err) {
|
|
throw new EnvironmentRunError(
|
|
"transport_resolution_failed",
|
|
`Failed to resolve execution target for "${environment.name}": ${err instanceof Error ? err.message : String(err)}`,
|
|
{
|
|
environmentId: environment.id,
|
|
driver: environment.driver,
|
|
cause: err,
|
|
},
|
|
);
|
|
}
|
|
|
|
return {
|
|
lease,
|
|
workspaceRealization,
|
|
executionTarget,
|
|
remoteExecution: adapterExecutionTargetToRemoteSpec(executionTarget),
|
|
persistedExecutionWorkspace,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Release all active leases for a heartbeat run.
|
|
* Tracks cleanup status per lease. Errors during individual lease release
|
|
* are captured but do not prevent other leases from being released.
|
|
* The original run failure (if any) is never hidden by cleanup errors.
|
|
*/
|
|
async function releaseForRun(input: {
|
|
heartbeatRunId: string;
|
|
companyId: string;
|
|
agentId: string;
|
|
status?: Extract<EnvironmentLeaseStatus, "released" | "expired" | "failed">;
|
|
failureReason?: string;
|
|
/** Explicit Stop during adapter startup; never used for ordinary cleanup. */
|
|
cancelActiveWork?: boolean;
|
|
|
|
respectCleanupDeadlines?: boolean;
|
|
/** Explicit paperclip_runner resource lifecycle. Omitted for legacy adapters. */
|
|
providerResourceDisposition?: ProviderResourceDisposition;
|
|
nativeLifecycleTelemetry?: {
|
|
provider: string;
|
|
harness: string;
|
|
lifecycleMode: "per_turn" | "warm";
|
|
sandboxResource:
|
|
| "keep_running"
|
|
| "stop_and_reuse"
|
|
| "destroy_after_turn";
|
|
};
|
|
}): Promise<EnvironmentReleaseResult> {
|
|
const status = input.status ?? "released";
|
|
const result: EnvironmentReleaseResult = { released: [], errors: [] };
|
|
|
|
let releasedLeases: EnvironmentRuntimeLeaseRecord[];
|
|
try {
|
|
const releaseArgs: Parameters<typeof environmentRuntime.releaseRunLeases> = [
|
|
input.heartbeatRunId,
|
|
status,
|
|
(leaseId, error) => result.errors.push({ leaseId, error }),
|
|
input.providerResourceDisposition,
|
|
];
|
|
if (input.cancelActiveWork) releaseArgs[4] = true;
|
|
if (input.respectCleanupDeadlines) releaseArgs[5] = { respectCleanupDeadlines: true };
|
|
releasedLeases = await environmentRuntime.releaseRunLeases(...releaseArgs);
|
|
} catch (err) {
|
|
result.errors.push({ leaseId: "*", error: err });
|
|
return result;
|
|
}
|
|
|
|
for (const released of releasedLeases) {
|
|
try {
|
|
await logActivity(db, {
|
|
companyId: input.companyId,
|
|
actorType: "agent",
|
|
actorId: input.agentId,
|
|
agentId: input.agentId,
|
|
runId: input.heartbeatRunId,
|
|
action: "environment.lease_released",
|
|
entityType: "environment_lease",
|
|
entityId: released.lease.id,
|
|
issueId: released.lease.issueId,
|
|
details: {
|
|
environmentId: released.lease.environmentId,
|
|
driver: released.environment.driver,
|
|
leasePolicy: released.lease.leasePolicy,
|
|
provider: released.lease.provider,
|
|
executionWorkspaceId: released.lease.executionWorkspaceId,
|
|
issueId: released.lease.issueId,
|
|
status: released.lease.status,
|
|
cleanupStatus: released.lease.cleanupStatus,
|
|
failureReason: input.failureReason ?? released.lease.failureReason,
|
|
providerResourceDisposition:
|
|
input.providerResourceDisposition ?? "legacy_default",
|
|
},
|
|
});
|
|
} catch {
|
|
// Activity logging failure should not block lease release
|
|
}
|
|
result.released.push(released);
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
return {
|
|
resolveEnvironment,
|
|
acquireLease,
|
|
resolveTransport,
|
|
acquireForRun,
|
|
realizeForRun,
|
|
releaseForRun,
|
|
|
|
// Expose the underlying runtime for cases that need direct driver access
|
|
runtime: environmentRuntime,
|
|
};
|
|
}
|
|
|
|
export type EnvironmentRunOrchestrator = ReturnType<typeof environmentRunOrchestrator>;
|