feat(coding-agent): configure isolated task apply behavior
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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}}
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user