diff --git a/doc/plugins/PLUGIN_RUNNER_EXECUTION.md b/doc/plugins/PLUGIN_RUNNER_EXECUTION.md index 682d1f8138f..d1e567873c8 100644 --- a/doc/plugins/PLUGIN_RUNNER_EXECUTION.md +++ b/doc/plugins/PLUGIN_RUNNER_EXECUTION.md @@ -26,6 +26,8 @@ OpenCode and ACPX require the existing qualified provider profiles. Workspace-free providers are not supported here. Codex warm retention is opt-in through `warmTaskVersion: 1`; ACPX and OpenCode remain per-turn. The plugin owns project workspace preparation. Core does not prepare or reuse a Core Git worktree for a task-only environment. +Selecting a plain task directory does not add redundant default configuration. +Retaining that directory does not itself change the run configuration. For plugin-backed project storage, Core uses the workspace provider contract. The storage plugin must also provide the selected execution environment. Its declared workspace modes and strategies remain in effect. diff --git a/server/src/__tests__/heartbeat-execution-configuration.test.ts b/server/src/__tests__/heartbeat-execution-configuration.test.ts index bbd6d591506..bcfee2323b8 100644 --- a/server/src/__tests__/heartbeat-execution-configuration.test.ts +++ b/server/src/__tests__/heartbeat-execution-configuration.test.ts @@ -159,7 +159,7 @@ describePostgres("heartbeat execution configuration through adapter dispatch", ( config: { sourceWorkspaceConfig: { ...agent.adapterConfig, homeMode: "preconfigured" }, aiBinding: managed ? aiRuntimeConnectionBindingSchema.parse(agent.runtimeConfig!.aiConnection) : undefined }, policy: { trustPreset: { kind: "standard", preset: "standard", boundary: null, sourcePresets: {} }, - useIsolatedTaskDirectory: false, explicitTaskDirectory: false }, + useIsolatedTaskDirectory: false, explicitTaskDirectory: false, requestedExecutionWorkspaceMode: "shared_workspace" }, environment: { selectedEnvironmentId: null, selectedEnvironmentForConfig: null, resolvedInstanceSettings: await instanceSettingsService(db).get(), persistedNativeExecutionInput: persisted }, services: { secretsSvc: secretService(db), companySkills: companySkillService(db), appendRunEvent: vi.fn(), finalizeAiConnectionBusyDeferral: vi.fn() }, @@ -220,7 +220,7 @@ describePostgres("heartbeat execution configuration through adapter dispatch", ( } }); - async function configuration(f: Awaited>, config: Record, snapshot: Record = {}, type = "paperclip_runner") { + async function configuration(f: Awaited>, config: Record, snapshot: Record = {}, type = "paperclip_runner", explicitTaskDirectory = false, requestedExecutionWorkspaceMode: HeartbeatExecutionConfigurationInput["policy"]["requestedExecutionWorkspaceMode"] = "shared_workspace") { const [agent] = await db.update(agents).set({ adapterType: type, adapterConfig: config }).where(eq(agents.id, f.agentId)).returning(); const [run] = await db.insert(heartbeatRuns).values({ companyId: f.companyId, agentId: f.agentId, issueId: f.issueId, scopeKind: "issue", status: "running", contextSnapshot: snapshot }).returning(); const resolvedInstanceSettings = await instanceSettingsService(db).get(); @@ -230,7 +230,7 @@ describePostgres("heartbeat execution configuration through adapter dispatch", ( acceptedPlanContinuationWake: false, acceptedPlanWakeRoutingDecision: null, taskProjectContext: null, routineEnvContext: { routineId: null, env: null, responsibleUserId: null } }, config: { sourceWorkspaceConfig: config, aiBinding: undefined }, - policy: { trustPreset: { kind: "standard", preset: "standard", boundary: null, sourcePresets: {} }, useIsolatedTaskDirectory: false, explicitTaskDirectory: false }, + policy: { trustPreset: { kind: "standard", preset: "standard", boundary: null, sourcePresets: {} }, useIsolatedTaskDirectory: false, explicitTaskDirectory, requestedExecutionWorkspaceMode }, environment: { selectedEnvironmentId: null, selectedEnvironmentForConfig: null, resolvedInstanceSettings, persistedNativeExecutionInput: null }, services: { secretsSvc: secretService(db), companySkills: companySkillService(db), appendRunEvent: vi.fn(), finalizeAiConnectionBusyDeferral: vi.fn() }, @@ -238,6 +238,28 @@ describePostgres("heartbeat execution configuration through adapter dispatch", ( }) }; } + it("keeps default task-directory configuration stable when the directory is reused", async () => { + const f = await seed(); + const initial = await (await configuration(f, {}, {}, "paperclip_runner", true)).prepare(); + const reused = await (await configuration(f, {})).prepare(); + expect(initial.prepared).toBe(true); + expect(reused.prepared).toBe(true); + if (!initial.prepared || !reused.prepared) throw new Error("Configuration was not prepared"); + expect(initial.mergedConfig).toEqual(reused.mergedConfig); + expect(initial.configSnapshot).toEqual(reused.configSnapshot); + }); + + it.each([ + { mode: "shared_workspace" as const, config: { workspaceStrategy: { type: "git_worktree", baseRef: "main" } } }, + { mode: "agent_default" as const, config: {} }, + ])("still selects directory transport instead of a $mode strategy", async ({ mode, config }) => { + const f = await seed(); + const prepared = await (await configuration(f, config, {}, "paperclip_runner", true, mode)).prepare(); + expect(prepared.prepared).toBe(true); + if (!prepared.prepared) throw new Error("Configuration was not prepared"); + expect(prepared.mergedConfig.workspaceStrategy).toEqual({ type: "project_primary" }); + }); + it.each(["fresh", "saved empty"])("prepares a %s native skill selection while another run holds the company cache lock", async kind => { const f = await seed(); const selected = kind === "saved empty" ? await companySkillService(db).createLocalSkill(f.companyId, { name: "Later assignment" }) : null; diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 99060a28540..e6e41be53f3 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -2614,7 +2614,7 @@ export function heartbeatService( run, agent, task: { issueId, context, responsibleUserId, acceptedPlanContinuationWake, acceptedPlanWakeRoutingDecision, taskProjectContext, routineEnvContext }, config: { sourceWorkspaceConfig, aiBinding }, - policy: { trustPreset, useIsolatedTaskDirectory, explicitTaskDirectory }, + policy: { trustPreset, useIsolatedTaskDirectory, explicitTaskDirectory, requestedExecutionWorkspaceMode }, environment: { selectedEnvironmentId, selectedEnvironmentForConfig, resolvedInstanceSettings, persistedNativeExecutionInput }, services: { secretsSvc, companySkills, appendRunEvent, finalizeAiConnectionBusyDeferral }, onFailureSecretsResolved: (readSecrets) => { readFailureReportSecrets = () => [...readSecrets(), ...identityRedactor.values]; }, diff --git a/server/src/services/heartbeat/execution-configuration.ts b/server/src/services/heartbeat/execution-configuration.ts index 8597bdbf660..2fb2a0ef832 100644 --- a/server/src/services/heartbeat/execution-configuration.ts +++ b/server/src/services/heartbeat/execution-configuration.ts @@ -68,7 +68,7 @@ export interface HeartbeatExecutionConfigurationInput { aiBinding: AiConnectionBinding | undefined; }; policy: Pick & { explicitTaskDirectory: boolean }; + "trustPreset" | "useIsolatedTaskDirectory" | "requestedExecutionWorkspaceMode"> & { explicitTaskDirectory: boolean }; environment: Pick; services: { @@ -108,14 +108,17 @@ export async function prepareHeartbeatExecutionConfiguration(db: Db, input: Hear // preparation step can fail. Subsequent provider/identity values extend it. onFailureSecretsResolved(() => []); const aiBinding = preconfiguredHome ? undefined : input.config.aiBinding; - const { trustPreset, useIsolatedTaskDirectory, explicitTaskDirectory } = input.policy; + const { trustPreset, useIsolatedTaskDirectory, explicitTaskDirectory, requestedExecutionWorkspaceMode } = input.policy; const { secretsSvc, companySkills, appendRunEvent, finalizeAiConnectionBusyDeferral } = input.services; let managedAiRuntime: ManagedAiRuntime | undefined; + const forceDirectoryStrategy = useIsolatedTaskDirectory || (explicitTaskDirectory && ( + requestedExecutionWorkspaceMode === "agent_default" || Object.keys(parseObject(sourceWorkspaceConfig.workspaceStrategy)).length > 0 + )); const mergedConfig = { ...sourceWorkspaceConfig, // The base below is already task-owned. Keep directory transport while // preserving isolated mode and the mandatory sandbox preflight. - ...((useIsolatedTaskDirectory || explicitTaskDirectory) ? { workspaceStrategy: { type: "project_primary" } } : {}), + ...(forceDirectoryStrategy ? { workspaceStrategy: { type: "project_primary" } } : {}), }; const configSnapshot = buildExecutionWorkspaceConfigSnapshot( mergedConfig,