Avoid session resets when reusing default task directories (#17419)

This commit is contained in:
Nicky Leach authored and GitHub committed 2026-10-24 08:26:57 -07:00
1 parent f7716ec65b
commit e5c9e60c5a
4 files changed
+34 -7

No files matched your search

+2
View File
@@ -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.
@@ -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<ReturnType<typeof seed>>, config: Record<string, unknown>, snapshot: Record<string, unknown> = {}, type = "paperclip_runner") {
async function configuration(f: Awaited<ReturnType<typeof seed>>, config: Record<string, unknown>, snapshot: Record<string, unknown> = {}, 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;
+1 -1
View File
@@ -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]; },
@@ -68,7 +68,7 @@ export interface HeartbeatExecutionConfigurationInput {
aiBinding: AiConnectionBinding | undefined;
};
policy: Pick<HeartbeatWorkspacePreparationInput["policy"],
"trustPreset" | "useIsolatedTaskDirectory"> & { explicitTaskDirectory: boolean };
"trustPreset" | "useIsolatedTaskDirectory" | "requestedExecutionWorkspaceMode"> & { explicitTaskDirectory: boolean };
environment: Pick<EnvironmentSelection,
"selectedEnvironmentId" | "selectedEnvironmentForConfig" | "resolvedInstanceSettings" | "persistedNativeExecutionInput">;
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,