diff --git a/server/src/__tests__/environment-run-orchestrator.test.ts b/server/src/__tests__/environment-run-orchestrator.test.ts index 41c2640c1d..01bb0442a9 100644 --- a/server/src/__tests__/environment-run-orchestrator.test.ts +++ b/server/src/__tests__/environment-run-orchestrator.test.ts @@ -10,6 +10,7 @@ const mockBuildWorkspaceRealizationRequest = vi.hoisted(() => vi.fn()); const mockUpdateLeaseMetadata = vi.hoisted(() => vi.fn()); const mockUpdateExecutionWorkspace = vi.hoisted(() => vi.fn()); const mockLogActivity = vi.hoisted(() => vi.fn()); +const mockLoggerInfo = vi.hoisted(() => vi.fn()); vi.mock("../services/environment-execution-target.js", () => ({ resolveEnvironmentExecutionTarget: mockResolveEnvironmentExecutionTarget, @@ -44,6 +45,15 @@ vi.mock("../services/activity-log.js", () => ({ logActivity: mockLogActivity, })); +vi.mock("../middleware/logger.js", () => ({ + logger: { + info: mockLoggerInfo, + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, +})); + // --------------------------------------------------------------------------- // Imports after mocks // --------------------------------------------------------------------------- @@ -407,6 +417,84 @@ describe("environmentRunOrchestrator — realizeForRun", () => { }); it("runs a remote provision command after workspace realization when configured", async () => { + mockBuildWorkspaceRealizationRequest.mockReturnValue({ + version: 1, + adapterType: "claude_local", + companyId: "company-1", + environmentId: "env-1", + executionWorkspaceId: null, + issueId: null, + heartbeatRunId: "run-1", + requestedMode: null, + source: { + kind: "project_primary", + localPath: "/workspace/project", + projectId: null, + projectWorkspaceId: null, + repoUrl: null, + repoRef: null, + strategy: "project_primary", + branchName: null, + worktreePath: null, + }, + runtimeOverlay: { + provisionCommand: "npm install -g @anthropic-ai/claude-code", + }, + }); + mockResolveEnvironmentExecutionTarget.mockResolvedValue({ + kind: "remote", + transport: "ssh", + remoteCwd: "/remote/workspace", + environmentId: "env-1", + leaseId: "lease-1", + spec: { + host: "ssh.example.test", + port: 22, + username: "ssh-user", + remoteCwd: "/remote/workspace", + remoteWorkspacePath: "/remote/workspace", + privateKey: null, + knownHosts: null, + strictHostKeyChecking: true, + }, + }); + + const runtime = makeMockRuntime({ + realizeWorkspace: vi.fn().mockResolvedValue({ + cwd: "/remote/workspace", + metadata: { + workspaceRealization: { + version: 1, + transport: "ssh", + remote: { path: "/remote/workspace" }, + }, + }, + }), + }); + const orchestrator = environmentRunOrchestrator(mockDb, { environmentRuntime: runtime }); + + await orchestrator.realizeForRun(makeRealizeInput({ + environment: makeEnvironment("ssh"), + })); + + // The `ssh` driver runs the command on the remote host that shares the + // workspace path, so the configured provision command still runs there. + expect(runtime.execute).toHaveBeenCalledOnce(); + expect(runtime.execute).toHaveBeenCalledWith(expect.objectContaining({ + environment: expect.objectContaining({ driver: "ssh" }), + lease: expect.objectContaining({ id: "lease-1" }), + command: "bash", + args: ["-lc", "npm install -g @anthropic-ai/claude-code"], + cwd: "/remote/workspace", + env: { + SHELL: "/bin/bash", + }, + })); + // The sandbox skip log is specific to the sandbox driver; ssh stays quiet. + expect(mockLoggerInfo).not.toHaveBeenCalled(); + }); + + it("skips the host provision command for a sandbox environment and logs the skip", async () => { mockBuildWorkspaceRealizationRequest.mockReturnValue({ version: 1, adapterType: "claude_local", @@ -458,17 +546,59 @@ describe("environmentRunOrchestrator — realizeForRun", () => { environment: makeEnvironment("sandbox"), })); - expect(runtime.execute).toHaveBeenCalledOnce(); - expect(runtime.execute).toHaveBeenCalledWith(expect.objectContaining({ - environment: expect.objectContaining({ driver: "sandbox" }), - lease: expect.objectContaining({ id: "lease-1" }), - command: "bash", - args: ["-lc", "npm install -g @anthropic-ai/claude-code"], - cwd: "/remote/workspace", - env: { - SHELL: "/bin/bash", + // The sandbox receives the provisioned tree through the adapter stage.sync + // step, so the orchestrator must not run the host command in the sandbox. + expect(runtime.execute).not.toHaveBeenCalled(); + // The skip is observable: exactly one log line records it with the driver. + expect(mockLoggerInfo).toHaveBeenCalledOnce(); + expect(mockLoggerInfo).toHaveBeenCalledWith( + expect.objectContaining({ driver: "sandbox", environmentId: "env-1" }), + expect.stringContaining("Skip host provisionCommand"), + ); + }); + + it("does not rerun the provision command during local environment realization", async () => { + mockBuildWorkspaceRealizationRequest.mockReturnValue({ + version: 1, + adapterType: "claude_local", + companyId: "company-1", + environmentId: "env-1", + executionWorkspaceId: null, + issueId: null, + heartbeatRunId: "run-1", + requestedMode: null, + source: { + kind: "project_primary", + localPath: "/workspace/project", + projectId: null, + projectWorkspaceId: null, + repoUrl: null, + repoRef: null, + strategy: "project_primary", + branchName: null, + worktreePath: null, }, + runtimeOverlay: { + provisionCommand: "npm install -g @anthropic-ai/claude-code", + }, + }); + mockResolveEnvironmentExecutionTarget.mockResolvedValue({ + kind: "local", + environmentId: "env-1", + leaseId: "lease-1", + }); + + const runtime = makeMockRuntime(); + const orchestrator = environmentRunOrchestrator(mockDb, { environmentRuntime: runtime }); + + await orchestrator.realizeForRun(makeRealizeInput({ + environment: makeEnvironment("local"), })); + + // Local workspace provisioning already ran the command before realizeForRun. + expect(runtime.execute).not.toHaveBeenCalled(); + // The sandbox skip log is specific to the sandbox driver; local stays quiet. + expect(mockLoggerInfo).not.toHaveBeenCalled(); }); it("runs project-level provision commands for ssh environments", async () => { @@ -588,7 +718,7 @@ describe("environmentRunOrchestrator — realizeForRun", () => { const orchestrator = environmentRunOrchestrator(mockDb, { environmentRuntime: runtime }); await expect(orchestrator.realizeForRun(makeRealizeInput({ - environment: makeEnvironment("sandbox"), + environment: makeEnvironment("ssh"), }))).rejects.toSatisfy( (err: unknown) => err instanceof EnvironmentRunError && diff --git a/server/src/services/environment-run-orchestrator.ts b/server/src/services/environment-run-orchestrator.ts index 975358ec82..c907d4c177 100644 --- a/server/src/services/environment-run-orchestrator.ts +++ b/server/src/services/environment-run-orchestrator.ts @@ -45,6 +45,7 @@ import { 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"; @@ -420,7 +421,29 @@ export function environmentRunOrchestrator( (typeof lease.metadata?.remoteCwd === "string" && lease.metadata.remoteCwd.trim().length > 0 ? lease.metadata.remoteCwd.trim() : executionWorkspace.cwd); - if (provisionCommand && environment.driver !== "local") { + // 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"; + 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 && !driverSkipsHostProvision) { try { const provisionResult = await environmentRuntime.execute({ environment,