Files
PaperClipAI/server/src/services/environment-run-orchestrator.ts
T
Devin Foley 547463d3a2 refactor(environments): make execution environments instance-scoped (#8375)
## Thinking Path

> - Paperclip is the control plane for AI-agent companies, so execution
environment selection has to stay inspectable and predictable across
companies, agents, and runs.
> - The environment subsystem decides where an agent heartbeat actually
runs and how remote sandbox state is realized and restored.
> - That subsystem previously mixed company-scoped environment catalogs
with issue-level environment stamping, so a reassigned issue could keep
executing in the previous assignee's sandbox.
> - That behavior breaks the control-plane contract: changing the
assignee should change the executing agent/environment path unless there
is an explicit current override.
> - Fixing it cleanly required more than a narrow patch; the environment
model had to move to instance scope with a single inherited default and
per-agent override semantics.
> - This pull request rewires the schema, server/API surface, runtime
resolution, and UI around that model, then adds regression coverage for
cross-company inheritance and per-agent isolation.
> - The benefit is that environment choice now follows the approved
instance/agent configuration path instead of stale issue state, while
shared environments only need to be configured once per instance.

## Linked Issues or Issue Description

- No directly matching public GitHub issue or PR was found while
searching for this refactor.

### What happened?

Reassigning work between agents with different execution environments
could keep running in the previous sandbox because environment choice
was stamped onto the issue and outranked the current assignee. The same
subsystem also forced environment catalogs to be duplicated per company
even though the underlying execution environments were instance-wide
resources.

### Expected behavior

Execution should resolve through the current instance and agent
configuration path, with one instance-scoped environment catalog, one
instance default, optional per-agent override, and no stale issue-level
environment authority surviving reassignment.

### Steps to reproduce

1. Configure two agents to use different execution environments.
2. Assign an issue to the first agent so the issue records execution
state in that environment.
3. Reassign the same issue to the second agent and run another
heartbeat.
4. Observe that the pre-fix runtime can still sync or execute in the
original sandbox instead of the second agent's environment.

### Paperclip version or commit

Current `master` before this PR.

### Deployment mode

Self-hosted server.

### Installation method

Built from source (`pnpm dev` / `pnpm build`).

### Agent adapter(s) involved

- Claude Code
- Not adapter-specific (core bug in environment authority / resolution)

### Database mode

External Postgres.

### Access context

Both board reassignment and agent heartbeats were involved.

## What Changed

- Moved environments and their default selection contract to instance
scope in DB/shared types, including the migration that dedupes legacy
per-company environments and seeds the instance local default.
- Reworked environment CRUD/auth flows to use instance-scoped APIs and
added route/service coverage for instance-level environment management.
- Changed runtime resolution to prefer `agent default -> instance
default -> built-in local`, removed issue-level environment stamping
from the active execution path, and isolated sandbox/plugin leases by
`(executionWorkspaceId, agentId)`.
- Added environment env-var runtime precedence so environment-provided
values act as the baseline for agent execution.
- Moved the environment UI into instance settings and updated agent
configuration surfaces to reflect inherit/override behavior.
- Added regression coverage for instance-default inheritance across
companies and for the new runtime resolution behavior.
- Fixed a rebase-only duplicate `enableTaskWatchdogs` flag regression in
instance settings types/validators/services so the branch typechecks
cleanly on current `master`.
- Updated stale server tests so CI matches the shipped instance-scoped
environment contract.

## Verification

- `git diff --check`
- `pnpm --filter @paperclipai/shared typecheck`
- `pnpm --filter @paperclipai/db typecheck`
- `pnpm exec vitest run
server/src/__tests__/environment-runtime-driver-contract.test.ts
server/src/__tests__/agent-permissions-routes.test.ts
server/src/__tests__/environment-routes.test.ts
server/src/__tests__/environment-instance-routes.test.ts
server/src/__tests__/execution-workspace-policy.test.ts
server/src/__tests__/heartbeat-plugin-environment.test.ts
server/src/__tests__/instance-settings-routes.test.ts`

## Risks

- The migration changes environment scope and dedupes existing rows, so
installs with unusual legacy environment combinations should be reviewed
carefully during upgrade.
- Remote execution behavior now depends on instance-default inheritance
semantics instead of issue-level stamping, so any remaining code paths
that still assume issue-scoped environment authority would surface as
follow-up bugs.
- This PR includes both server/runtime behavior and UI relocation, so
reviewers should watch for authorization edge cases around instance
settings and environment management.

> I checked [`ROADMAP.md`](ROADMAP.md). This work fits the existing
Cloud / Sandbox agents direction as a bug-fix/refactor to current
behavior, not a new parallel product surface.

## Model Used

- OpenAI Codex coding agent in this Paperclip/Codex session; GPT-5-class
tool-using model with code execution and shell access. The exact backend
model ID is not exposed to the session runtime.

## 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
- [ ] If this change affects the UI, I have included before/after
screenshots
- [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
2026-06-20 09:42:53 -07:00

573 lines
19 KiB
TypeScript

/**
* 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 type { Db } from "@paperclipai/db";
import type {
Environment,
EnvironmentLease,
EnvironmentLeasePolicy,
EnvironmentLeaseStatus,
ExecutionWorkspace,
ExecutionWorkspaceConfig,
} from "@paperclipai/shared";
import { environmentService } from "./environments.js";
import {
environmentRuntimeService,
buildEnvironmentLeaseContext,
type EnvironmentRuntimeLeaseRecord,
type EnvironmentRuntimeService,
} from "./environment-runtime.js";
import {
resolveEnvironmentExecutionTarget,
resolveEnvironmentExecutionTransport,
} from "./environment-execution-target.js";
import {
adapterExecutionTargetToRemoteSpec,
type AdapterExecutionTarget,
type AdapterRemoteExecutionSpec,
} from "@paperclipai/adapter-utils/execution-target";
import { buildWorkspaceRealizationRequest } from "./workspace-realization.js";
import { executionWorkspaceService } from "./execution-workspaces.js";
import { logActivity } from "./activity-log.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;
issueId: string | null;
agentId: string;
heartbeatRunId: string;
persistedExecutionWorkspace: Pick<ExecutionWorkspace, "id" | "mode"> | null;
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;
issueId: string | null;
heartbeatRunId: string;
agentId: string;
persistedExecutionWorkspace: Pick<ExecutionWorkspace, "id" | "mode"> | null;
}): Promise<EnvironmentAcquisitionResult> {
// Step 1: Resolve environment
const environment = await resolveEnvironment({
companyId: input.companyId,
selectedEnvironmentId: input.selectedEnvironmentId,
localEnvironmentId: input.localEnvironmentId,
});
// Step 2: Acquire lease
const leaseRecord = await acquireLease({
companyId: input.companyId,
environment,
issueId: input.issueId,
agentId: input.agentId,
heartbeatRunId: input.heartbeatRunId,
persistedExecutionWorkspace: input.persistedExecutionWorkspace,
adapterType: input.adapterType ?? null,
});
// 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,
details: {
environmentId: environment.id,
driver: environment.driver,
leasePolicy: leaseRecord.lease.leasePolicy,
provider: leaseRecord.lease.provider,
executionWorkspaceId: leaseRecord.leaseContext.executionWorkspaceId,
issueId: input.issueId,
},
});
// Step 4: Resolve execution transport
const executionTransport = 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;
}): Promise<EnvironmentRealizationResult> {
const {
environment,
adapterType,
companyId,
issueId,
heartbeatRunId,
executionWorkspace,
effectiveExecutionWorkspaceMode,
} = input;
let { lease, persistedExecutionWorkspace } = input;
// 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;
if (
environment.driver === "local" ||
environment.driver === "ssh" ||
environment.driver === "sandbox"
) {
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);
if (provisionCommand && environment.driver !== "local") {
try {
const provisionResult = await environmentRuntime.execute({
environment,
lease,
command: "bash",
args: ["-lc", provisionCommand],
cwd: realizedCwd,
env: {
SHELL: "/bin/bash",
},
timeoutMs: 300_000,
});
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,
});
} 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;
}): Promise<EnvironmentReleaseResult> {
const status = input.status ?? "released";
const result: EnvironmentReleaseResult = { released: [], errors: [] };
let releasedLeases: EnvironmentRuntimeLeaseRecord[];
try {
releasedLeases = await environmentRuntime.releaseRunLeases(input.heartbeatRunId, status);
} 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,
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,
},
});
} 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>;