Files
PaperClipAI/server/src/services/environment-run-orchestrator.ts
T
c8b7afee06 fix(server): schedule native environment lease settlement (#17801)
<!-- entire-trail-link-start -->
https://entire.com/gh/paperclipai/-/trails/24/changes/paperclip/24
<!-- entire-trail-link-end -->

## Thinking Path

> - Paperclip manages AI agents and the resources used by their work.
> - Native run completion has several independent settlement duties.
> - The recurring run reaper repeated environment-release discovery for
completed native history.
> - Existing lease rows already record releases that still need work.
> - This pull request wakes the existing release owners from committed
changes and schedules only unfinished release debt.
> - Completed history becomes quiet while stop evidence, workspace
preservation, and retention policy keep their existing owners.

## Linked Issues or Issue Description

Refs: #17793, #17717.

**What problem does this solve?**

Periodic native finalization replay calls environment release even when
no lease needs work. This adds recurring database traffic to an idle
instance.

**What is the proposed solution?**

Register one scoped worker on the existing delivery coordinator.
Discover work from native finalizations and existing environment leases.
Wake it from native, lease, and settings transactions. Keep the release
owner's saved pending-cleanup deadlines. Keep retries only while a
concrete lease still needs settlement.

**What alternatives have you considered?**

A slower unconditional scan still queries idle history. A new job table
duplicates existing lease truth. Replacing the compound callback would
also change runtime-service and agent-status duties; those remain
separate slices.

This is slice 58. Parent #17793 has merged. This publication targets
master and preserves that landed prerequisite. Fresh current-head checks
and review remain required.

## What Changed

- Keep ordinary company-only finalization hints out of environment
settlement. Prove zero candidate SQL after quiet and preserve real lease
delivery.

- Reuse the existing durable destroy claim for run-owned cleanup,
excluding concurrent attempts and fencing late receipts while preserving
cleanup recovery.

- Scope lease cleanup notifications to their company/run so settlement
does not rediscover the entire backlog after each release.

- Read only `nativeWorkspaceSync` and `workspaceExportRetry` JSON fields
when discovering environment cleanup. Preserve saved run data and
existing release decisions.

- Reuse the environment runtime's release predicate for discovery. Add
no table or independent timer.
- Wake from existing finalization, projection, lifecycle, recovery,
lease, and settings transaction boundaries. Simple lease acquisition now
uses a transaction too.
- Preserve recorded retention policy, pending-cleanup deadlines,
original lease/provider ownership, and current release guards.
- Re-read durable lease state after a release. A logged or swallowed
per-lease failure does not discharge the obligation.
- Delegate only periodic environment release. Preserve default
live/manual behavior, runtime-service release, and agent-status
callbacks.
- Add an owner/producer/lifecycle audit and real database tests.

## Verification

Accepted production source: `9596fa022b57feea026c4728c80bdc36037b804f`.
Publication head: `fb252c8b0e52ce355e7d98db2e392e4831f99c61`. The actual
parent squash is adopted by a normal merge with the complete accepted
child tree unchanged. Only audit text follows.

- **4,172 distinct cases in 64 complete server files pass**, plus **20
selected chat cases** (4,192 selected total). The main batch reports
3,440 passes and 49 skips. Both complete Stop/read files then pass 51 +
6 cases on separate fresh databases through their supported override.
Their eight pure cases are counted once; skipped cases are never counted
as passes. Separate process recovery passes 410; review response passes
273. The other 1,794 chat cases were not run locally.
- **Quiet producer proof:** the controlled unscoped baseline fails with
three candidate SELECTs after three routine company-only hints. The
scoped case performs zero, releases a real eligible committed-run lease,
and returns to quiet. The same test hash spans red and green.
- **Static gates pass:** database build; server typecheck/build; module
boundaries; migration order; current Copilot profile identity. Packages
equal the accepted parent byte for byte.
- **Fresh fixtures:** 39 prepared PostgreSQL 17 databases plus fresh
helper-owned databases. The schema has all 412 journal entries through
index/tag 0413_tired_kronos. All 65 original test hashes restore
exactly; the indirect Stop fixture is also hashed. No assertion or
timeout changes.
- **Preserved records:** original 49 skips and their completed coverage
remain documented. Parent handoff timeout and prior chat-idle failure
records remain in their owning audits. This does not claim full
repository, full chat, live provider, or deployed sleep acceptance.
- [Current integration and native lease
audit](https://github.com/paperclipai/paperclip/blob/fb252c8b0e52ce355e7d98db2e392e4831f99c61/doc/plans/2026-10-26-native-environment-settlement-sleep.md#current-master-integration-and-native-lease-quiet-proof-2026-10-29).
Fresh CI, security, and exact-head Greptile 5/5 must pass before merge.

<details><summary>Earlier revision verification and failure
history</summary>

Receiver failure fairness: a controlled 1,100 ms batch reproduced a
30-second delay for healthy due receivers. The worker now supplies its
existing per-item deadline through `nextRunAtOnError`. All 20 receiver
lifecycle tests pass on the owner and combined tip
`31c12f1d68ff4a51e06fc2be2abf2c2aee9f9d3a`; server production
typechecking passes on the owner. The failed receiver retains its own
backoff, and settled receivers return to zero recurring SQL. The
five-second assertion budget is unchanged. See [the owning
audit](https://github.com/paperclipai/paperclip/blob/6f3a0b8de149fb5620f15909473eb7c4455b9824/doc/plans/2026-10-25-chat-receiver-discovery-sleep-audit.md#healthy-receiver-progress-after-a-slow-failed-batch).
Current-head CI and review remain required.

Approved delegated Test-tab calls now evaluate caller and target
policies in the existing claim transaction. Real HTTP regressions first
showed a missing caller charge and shared-limit self-rejection. All 78
connection-management cases and 92 gateway cases now pass on the owning
branch, along with server production typechecking. All 78
connection-management cases also pass at combined tip
`d25f830db89c1623bff3547bfc68015c1c66e0cf`. The three new cases cover
shared charges, separate charges, rollback on denial, duplicate
approval, and rejection of a later authorized call. Intermediate
counter/identity fixture failures and a reused-database Slack collision
are recorded in [the owning
audit](https://github.com/paperclipai/paperclip/blob/ae9bff1c06e34702f64a399e58b45a657c133bd1/doc/idle-approved-tool-recovery.md#delegated-test-tab-admission-after-policy-changes).
The earlier combined checks below remain evidence for their stated
revisions. Current-head CI and review remain required.

Inherited voice-cleanup verification: the controlled slow-provider
regression and all 13 lifecycle tests passed on its owner and combined
tip `ab22d8ed0c09028315607414b83b375e5cc0a60a`. That repair changed only
the fixture clock advancement and its audit note.

Current head: `6f3a0b8de149fb5620f15909473eb7c4455b9824`. This remains a
single-duty PR stacked on #17793.

- Integrated pinned master `8548173fc9efdc0fa07644fb92dcc0b6851a95e5`
with normal merges. Every branch passed generated-schema checks and its
target-branch migration-order gate. Existing SQL is unchanged; the 25
stack migrations follow master’s new migration.
- Duty-specific producer coverage, lifecycle tests, and earlier failure
records:
[2026-10-24-recurring-server-sleep-stack.md](https://github.com/paperclipai/paperclip/blob/7e619453567e078a7d745f28bfe8e5ede571f282/doc/plans/2026-10-24-recurring-server-sleep-stack.md),
[2026-10-26-native-environment-settlement-sleep.md](https://github.com/paperclipai/paperclip/blob/7e619453567e078a7d745f28bfe8e5ede571f282/doc/plans/2026-10-26-native-environment-settlement-sleep.md).
- Combined tip `51e6cbe67979b55b340ea9f0f434c5b5c682ccd6`: **355
startup/cancellation/recovery/dispatch/preservation tests**, **75
connection-management tests**, and production server typechecking pass.
These combined checks do not mean every intermediate PR independently
ran the full repository suite.
- Real master-to-stack database upgrade passes: **379 unchanged prior
migration receipts plus 25 new migrations**, with the gateway
responsibility fixture preserved.
- Owning-branch checks: 84 runner installation/settlement cases plus
runner typechecking and generated TypeScript/Rust profile agreement; 253
GitHub review workflow cases. Stop-attribution and held-worker readiness
regressions failed before their fixes.
- Failure accounting: the initial combined run passed 349 cases and
failed six idle assertions in a reused preservation database containing
old cancelled runs. Repeating the combined command with a fresh
preservation database passed. Earlier invalid project selection, merge
import/type errors, and the interrupted full local suite are not counted
as passing checks.
- **Latest-head CI and review remain required.** Prior CI findings
include GitHub idle observation, route teardown, attention cleanup,
touch-picker E2E, workspace health, and voice-cleanup timing; local
passes do not erase those failures. No full local repository-suite,
deployed sleep, fleet cost, or live Copilot qualification claim is made.


</details>

## Risks

- Existing lease owners retain destruction claims, provider identity,
stop evidence, retention policy, and pending-cleanup deadlines.
Incorrect changes here can release a live resource or lose a cleanup
obligation.
- Logged or swallowed release failures remain concrete debt. Real
retries must retain their saved floor across unrelated hints.
- In-process signals do not cover independent SQL or other replicas
without ingress.
- This delegates only periodic environment settlement. Runtime-service
and agent-status duties remain separate. Existing Cloud blockers remain;
no deployed sleep, database suspension, external alarm coverage, or cost
savings is claimed.

## Model Used

OpenAI Codex, GPT-6 family, with reasoning, code execution, real
PostgreSQL tests, and GitHub tools. The exact runtime model identifier
and context-window size are not exposed in this session.

## 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 URLs)
- [x] My branch name describes the change 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

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
Co-authored-by: Codex <noreply@openai.com>
2026-10-29 06:37:48 -05:00

763 lines
30 KiB
TypeScript

import { freshWorkspaceSchema } from "@paperclipai/plugin-sdk";
/**
* 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 type {
Environment,
EnvironmentLease,
EnvironmentLeasePolicy,
EnvironmentLeaseStatus,
ExecutionWorkspace,
ExecutionWorkspaceConfig,
IssueExecutionWorkspaceSettings,
} from "@paperclipai/shared";
import { resolveRunnerEnvironmentForRun } 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;
}): Promise<EnvironmentRuntimeLeaseRecord> {
try {
return await environmentRuntime.acquireRunLease(input);
} catch (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: 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 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,
};
let leaseRecord: EnvironmentRuntimeLeaseRecord;
if (input.reattachRemoteLease) {
// Reattachment is inspection of an already running sandbox, never a new
// acquisition or a lifecycle resume. Those paths can create a replacement
// when the admitted per-turn policy deliberately disables reusable leases.
const expected = input.reattachRemoteLease;
const lease = await environmentsSvc.getLeaseById(expected.leaseId);
if (!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.status !== "active" ||
lease.releasedAt !== null || lease.cleanupStatus !== null ||
(lease.expiresAt !== null && new Date(lease.expiresAt).getTime() <= Date.now()) ||
(expected.kind === "plugin_task"
? environment.driver !== "plugin" || input.adapterType !== "paperclip_runner"
|| lease.metadata?.runnerTaskLease !== true || lease.metadata?.runnerTaskDispatched !== true
|| lease.metadata?.runnerTaskExecutionStopped === true
: environment.driver !== "sandbox" || lease.provider !== environment.config.provider
|| lease.providerLeaseId !== expected.providerLeaseId || lease.metadata?.remoteCwd !== expected.remoteCwd)) {
throw new Error("native_remote_recovery_lease_mismatch");
}
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[];
/**
* 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;
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>;