feat(coding-agent): configure isolated task apply behavior

This commit is contained in:
Kormákur
2026-07-23 00:48:48 +00:00
parent 1f7d2ff9ea
commit c2fde74ef0
9 changed files with 111 additions and 6 deletions
+4
View File
@@ -2,6 +2,10 @@
## [Unreleased]
### Added
- Added `task.isolation.apply` (default `true`) to choose whether successful isolated `task` runs automatically apply their changes to the parent checkout or retain patch/branch artifacts for later integration.
## [17.0.8] - 2026-07-22
### Added
@@ -4089,6 +4089,18 @@ export const SETTINGS_SCHEMA = {
},
},
"task.isolation.apply": {
type: "boolean",
default: true,
ui: {
tab: "tasks",
group: "Isolation",
label: "Apply Isolated Changes",
description:
"Automatically apply successful isolated task changes to the parent checkout; disable to retain patch or branch artifacts",
},
},
"task.isolation.merge": {
type: "enum",
values: ["patch", "branch"] as const,
+1
View File
@@ -134,6 +134,7 @@ async function checkForNewVersion(currentVersion: string): Promise<string | unde
// embedders need project-level opt-outs for reminder/prelude prompt injection.
const HOST_DEFAULTED_SETTING_PATHS: SettingPath[] = [
"task.isolation.mode",
"task.isolation.apply",
"task.isolation.merge",
"task.isolation.commits",
"task.eager",
@@ -25,7 +25,11 @@ Agents marked BLOCKING run inline — results return in this call; non-blocking
- `outputSchema`: Invocation-specific JSON Schema. Overrides the selected agent and parent-session schemas.
- `schemaMode`: `"permissive"` (default) accepts a retry-exhausted invalid result with a warning; `"strict"` fails it.
{{#if isolationEnabled}}
- `isolated`: Run in dedicated worktree, return patches. Destroyed on completion, cannot be addressed afterward.
{{#if applyIsolatedChanges}}
- `isolated`: Run in a dedicated worktree; successful changes are automatically applied to the parent checkout.
{{else}}
- `isolated`: Run in a dedicated worktree; changes are retained as patch or branch artifacts without modifying the parent checkout.
{{/if}}
{{/if}}
{{else}}
- `name`: A stable CamelCase identifier (≤32 chars), used to address the agent (IRC, job ids). Generated automatically if omitted.
@@ -34,7 +38,11 @@ Agents marked BLOCKING run inline — results return in this call; non-blocking
- `outputSchema`: Invocation-specific JSON Schema. Overrides the selected agent and parent-session schemas.
- `schemaMode`: `"permissive"` (default) accepts a retry-exhausted invalid result with a warning; `"strict"` fails it.
{{#if isolationEnabled}}
- `isolated`: Run in dedicated worktree, return patches.
{{#if applyIsolatedChanges}}
- `isolated`: Run in a dedicated worktree; successful changes are automatically applied to the parent checkout.
{{else}}
- `isolated`: Run in a dedicated worktree; changes are retained as patch or branch artifacts without modifying the parent checkout.
{{/if}}
{{/if}}
{{/if}}
+3
View File
@@ -160,6 +160,7 @@ export function formatResultOutputFallback(result: Pick<SingleResult, "output" |
function renderDescription(
agents: AgentDefinition[],
isolationEnabled: boolean,
applyIsolatedChanges: boolean,
disabledAgents: string[],
batchEnabled: boolean,
asyncEnabled: boolean,
@@ -187,6 +188,7 @@ function renderDescription(
defaultAgent: spawnPolicy.defaultAgent,
allowedAgentsText: spawnPolicy.allowedPromptText,
isolationEnabled,
applyIsolatedChanges,
batchEnabled,
asyncEnabled,
hasBlockingAgents: renderedAgents.some(agent => agent.blocking),
@@ -566,6 +568,7 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
return renderDescription(
this.#discoveredAgents,
!planMode && isolationMode !== "none",
this.session.settings.get("task.isolation.apply"),
disabledAgents,
this.#isBatchEnabled(),
this.session.settings.get("async.enabled"),
@@ -300,7 +300,9 @@ export async function resolveEffectiveSubagentPolicy(
planMode,
isIsolated,
mergeMode: request.isolation?.merge ?? request.session.settings.get("task.isolation.merge"),
applyChanges: request.isolation?.apply !== false,
applyChanges:
request.isolation?.apply ??
(request.invocationKind === "task" ? request.session.settings.get("task.isolation.apply") : true),
enableLsp:
!planMode &&
(request.enableLsp ?? ((request.session.enableLsp ?? true) && request.session.settings.get("task.enableLsp"))),
@@ -251,6 +251,7 @@ describe("ACP lazy startup", () => {
const explicit = {
"task.isolation.mode": "rcopy",
"task.isolation.apply": false,
"task.isolation.merge": "branch",
"task.isolation.commits": "ai",
"task.eager": "always",
@@ -31,7 +31,13 @@ const AGENT: AgentDefinition = {
};
function session(
options: { planMode?: boolean; outputSchema?: unknown; maxDepth?: number; isolationMode?: "none" | "worktree" } = {},
options: {
planMode?: boolean;
outputSchema?: unknown;
maxDepth?: number;
isolationMode?: "none" | "worktree";
isolationApply?: boolean;
} = {},
): ToolSession {
return {
cwd: "/tmp",
@@ -41,6 +47,7 @@ function session(
"task.maxRecursionDepth": options.maxDepth ?? 2,
"task.isolation.mode": options.isolationMode ?? "none",
"task.enableLsp": true,
...(options.isolationApply !== undefined ? { "task.isolation.apply": options.isolationApply } : {}),
}),
getSessionFile: () => null,
getSessionSpawns: () => "*",
@@ -397,4 +404,54 @@ describe("structured subagent primitive", () => {
expect(await fs.stat(artifactsDir ?? "")).toBeDefined();
await fs.rm(settled.artifactsDir, { recursive: true, force: true });
});
it("defaults task isolation to auto-apply and lets config retain artifacts", async () => {
mockDiscovery();
const defaultPolicy = await resolveEffectiveSubagentPolicy(
request({ session: session({ isolationMode: "worktree" }), isolation: { requested: true } }),
);
expect(defaultPolicy.applyChanges).toBe(true);
const capturePolicy = await resolveEffectiveSubagentPolicy(
request({
session: session({ isolationMode: "worktree", isolationApply: false }),
isolation: { requested: true },
}),
);
expect(capturePolicy.applyChanges).toBe(false);
const evalPolicy = await resolveEffectiveSubagentPolicy(
request({
invocationKind: "eval",
session: session({ isolationMode: "worktree", isolationApply: false }),
isolation: { requested: true },
}),
);
expect(evalPolicy.applyChanges).toBe(true);
});
it("retains successful isolated task artifacts when auto-apply is disabled", async () => {
mockDiscovery();
let artifactsDir: string | undefined;
vi.spyOn(isolationRunner, "prepareIsolationContext").mockResolvedValue({ repoRoot: "/tmp" } as never);
vi.spyOn(isolationRunner, "runIsolatedSubprocess").mockImplementation(async ({ baseOptions }) => {
artifactsDir = baseOptions.artifactsDir;
return { ...result(), patchPath: "/recovery/Worker.patch" };
});
const merge = vi.spyOn(isolationRunner, "mergeIsolatedChanges");
const settled = await runStructuredSubagent(
request({
session: session({ isolationMode: "worktree", isolationApply: false }),
isolation: { requested: true },
}),
);
expect(merge).not.toHaveBeenCalled();
expect(settled.changesApplied).toBeNull();
expect(settled.mergeSummary).toContain("/recovery/Worker.patch");
expect(artifactsDirsFromRegistry()).toContain(settled.artifactsDir);
expect(await fs.stat(artifactsDir ?? "")).toBeDefined();
await fs.rm(settled.artifactsDir, { recursive: true, force: true });
});
});
@@ -125,7 +125,7 @@ describe("task.batch schema gating", () => {
expect(items?.properties?.schemaMode).toBeDefined();
});
it("places isolated per item in the batch shape when isolation is enabled", async () => {
it("keeps isolation boolean-only and describes the configured apply behavior", async () => {
mockDiscovery();
const tool = await TaskTool.create(
@@ -134,7 +134,24 @@ describe("task.batch schema gating", () => {
const properties = getSchemaProperties(tool);
expect(properties.isolated).toBeUndefined();
const items = (properties.tasks as { items?: { properties?: Record<string, unknown> } }).items;
expect(items?.properties?.isolated).toBeDefined();
const isolatedSchema = items?.properties?.isolated;
if (!isolatedSchema || typeof isolatedSchema !== "object" || !("type" in isolatedSchema)) {
throw new Error("Expected isolated to be a boolean schema");
}
expect(isolatedSchema.type).toBe("boolean");
expect(items?.properties?.apply).toBeUndefined();
expect(tool.description).toContain("automatically applied to the parent checkout");
const captureTool = await TaskTool.create(
createSession({
settings: {
"task.batch": true,
"task.isolation.mode": "auto",
"task.isolation.apply": false,
},
}),
);
expect(captureTool.description).toContain("without modifying the parent checkout");
});
it("hides isolation from the dynamic batch schema in plan mode", async () => {