Merge pull request #3205: feat(eval): isolated/apply/merge options for agent() helper

Resolves conflict in test/task/worktree.test.ts by keeping both the
getRepoRoot (main) and applyNestedPatches (PR) describe blocks.

Extends the PR's Python/JS work to the remaining workflow runtimes:
- eval/rb/prelude.rb, eval/jl/prelude.jl: agent() now accepts and
  forwards isolated/apply/merge (as booleans) plus returnHandle, and the
  return_handle node carries isolated/patch_path/branch_name/
  nested_patches/changes_applied/isolation_summary.

Post-merge fixups:
- task/index.ts: drop dead commitStyle var (the dedup refactor reads
  task.isolation.commits inside makeIsolationCommitMessage).
- CHANGELOG: move the misplaced Added entry under [Unreleased], correct
  the stale "defaults track task.isolation.mode" wording to the final
  strict opt-in behavior, and note all four runtimes.

Fixes #3196
This commit is contained in:
can1357
2026-06-22 21:56:45 +02:00
15 changed files with 1511 additions and 292 deletions
+61 -207
View File
@@ -47,29 +47,22 @@ import type { AsyncJobManager } from "../async";
import type { LocalProtocolOptions } from "../internal-urls";
import { loadOverallPlanReference } from "../plan-mode/plan-handoff";
import { AgentRegistry, MAIN_AGENT_ID } from "../registry/agent-registry";
import { generateCommitMessage } from "../utils/commit-message-generator";
import * as git from "../utils/git";
import { type DiscoveryResult, discoverAgents, getAgent } from "./discovery";
import { runSubprocess } from "./executor";
import {
applyEligibleNestedPatches,
type IsolationContext,
makeIsolationCommitMessage,
mergeIsolatedChanges,
prepareIsolationContext,
runIsolatedSubprocess,
} from "./isolation-runner";
import { generateTaskName } from "./name-generator";
import { AgentOutputManager } from "./output-manager";
import { mapWithConcurrencyLimit, Semaphore } from "./parallel";
import { renderResult, renderCall as renderTaskCall } from "./render";
import { repairTaskParams } from "./repair-args";
import {
applyNestedPatches,
captureBaseline,
captureDeltaPatch,
cleanupIsolation,
cleanupTaskBranches,
commitToBranch,
ensureIsolation,
getRepoRoot,
type IsolationHandle,
mergeTaskBranches,
parseIsolationMode,
type WorktreeBaseline,
} from "./worktree";
import { parseIsolationMode } from "./worktree";
function renderSubagentUserPrompt(assignment: string): string {
return prompt.render(subagentUserPromptTemplate, {
@@ -1047,7 +1040,6 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
const isolationRequested = "isolated" in params ? params.isolated === true : false;
const isIsolated = isolationMode !== "none" && isolationRequested;
const mergeMode = this.session.settings.get("task.isolation.merge");
const commitStyle = this.session.settings.get("task.isolation.commits");
const taskDepth = this.session.taskDepth ?? 0;
const subagentLspEnabled = (this.session.enableLsp ?? true) && this.session.settings.get("task.enableLsp");
@@ -1118,12 +1110,10 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
// structured output go through eval agent(prompt, schema).
const effectiveOutputSchema = effectiveAgent.output ?? this.session.outputSchema;
let repoRoot: string | null = null;
let baseline: WorktreeBaseline | null = null;
let isolationContext: IsolationContext | null = null;
if (isIsolated) {
try {
repoRoot = await getRepoRoot(this.session.cwd);
baseline = await captureBaseline(repoRoot);
isolationContext = await prepareIsolationContext(this.session.cwd);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return {
@@ -1132,6 +1122,7 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
};
}
}
const repoRoot = isolationContext?.repoRoot ?? null;
const preferredIsolationBackend = parseIsolationMode(isolationMode);
@@ -1251,17 +1242,7 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
};
emitProgress();
const buildCommitMessageFn = () =>
commitStyle === "ai" && this.session.modelRegistry
? async (diff: string) => {
return generateCommitMessage(
diff,
this.session.modelRegistry!,
this.session.settings,
this.session.getSessionId?.() ?? undefined,
);
}
: undefined;
const buildCommitMessageFn = makeIsolationCommitMessage(this.session);
const sharedRunOptions = {
cwd: this.session.cwd,
@@ -1321,192 +1302,65 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
if (!isIsolated) {
return runSubprocess(sharedRunOptions);
}
const taskStart = Date.now();
let isolationHandle: IsolationHandle | undefined;
try {
if (!repoRoot || !baseline) {
throw new Error("Isolated task execution not initialized.");
}
const taskBaseline = structuredClone(baseline);
isolationHandle = await ensureIsolation(repoRoot, agentId, preferredIsolationBackend);
const isolationDir = isolationHandle.mergedDir;
// Isolated runs re-discover extensions/custom tools inside the
// worktree instead of reusing the parent's source paths.
const result = await runSubprocess({
...sharedRunOptions,
worktree: isolationDir,
preloadedExtensionPaths: undefined,
preloadedCustomToolPaths: undefined,
});
if (mergeMode === "branch" && result.exitCode === 0) {
try {
const commitResult = await commitToBranch(
isolationDir,
taskBaseline,
agentId,
params.description,
buildCommitMessageFn(),
);
return {
...result,
branchName: commitResult?.branchName,
nestedPatches: commitResult?.nestedPatches,
};
} catch (mergeErr) {
// Agent succeeded but branch commit failed — clean up stale branch
const branchName = `omp/task/${agentId}`;
await git.branch.tryDelete(repoRoot, branchName);
const msg = mergeErr instanceof Error ? mergeErr.message : String(mergeErr);
return { ...result, error: `Merge failed: ${msg}` };
}
}
if (result.exitCode === 0) {
try {
const delta = await captureDeltaPatch(isolationDir, taskBaseline);
const patchPath = path.join(effectiveArtifactsDir, `${agentId}.patch`);
await Bun.write(patchPath, delta.rootPatch);
return {
...result,
patchPath,
nestedPatches: delta.nestedPatches,
};
} catch (patchErr) {
const msg = patchErr instanceof Error ? patchErr.message : String(patchErr);
return { ...result, error: `Patch capture failed: ${msg}` };
}
}
return result;
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return {
index: spawnIndex,
id: agentId,
agent: agent.name,
agentSource: agent.source,
task: renderSubagentUserPrompt(assignment),
assignment,
description: params.description,
exitCode: 1,
output: "",
stderr: message,
truncated: false,
durationMs: Date.now() - taskStart,
tokens: 0,
requests: 0,
modelOverride,
error: message,
};
} finally {
if (isolationHandle) {
await cleanupIsolation(isolationHandle);
}
if (!isolationContext) {
throw new Error("Isolated task execution not initialized.");
}
const taskStart = Date.now();
return runIsolatedSubprocess({
baseOptions: sharedRunOptions,
context: isolationContext,
preferredBackend: preferredIsolationBackend,
agentId,
mergeMode,
artifactsDir: effectiveArtifactsDir,
description: params.description,
buildCommitMessage: buildCommitMessageFn,
buildFailureResult: err => {
const message = err instanceof Error ? err.message : String(err);
return {
index: spawnIndex,
id: agentId,
agent: agent.name,
agentSource: agent.source,
task: renderSubagentUserPrompt(assignment),
assignment,
description: params.description,
exitCode: 1,
output: "",
stderr: message,
truncated: false,
durationMs: Date.now() - taskStart,
tokens: 0,
requests: 0,
modelOverride,
error: message,
};
},
});
};
const result = await runTask();
let mergeSummary = "";
let changesApplied: boolean | null = null;
let hadAnyChanges = false;
let mergedBranchForNestedPatches = false;
if (isIsolated && repoRoot) {
try {
if (mergeMode === "branch") {
if (!result.branchName || result.exitCode !== 0 || result.aborted) {
changesApplied = true;
mergeSummary = "\n\nNo changes to apply.";
} else {
const mergeResult = await mergeTaskBranches(repoRoot, [
{ branchName: result.branchName, taskId: result.id, description: result.description },
]);
mergedBranchForNestedPatches = mergeResult.merged.includes(result.branchName);
changesApplied = mergeResult.failed.length === 0;
hadAnyChanges = changesApplied && mergeResult.merged.length > 0;
if (changesApplied) {
mergeSummary = hadAnyChanges
? `\n\nMerged branch: ${result.branchName}`
: "\n\nNo changes to apply.";
} else {
const conflictPart = mergeResult.conflict ? `\nConflict: ${mergeResult.conflict}` : "";
mergeSummary = `\n\n<system-notification>Branch merge failed: ${result.branchName}.${conflictPart}\nThe unmerged branch remains for manual resolution.</system-notification>`;
}
if (mergeResult.stashConflict) {
mergeSummary += `\n\n<system-notification>${mergeResult.stashConflict}</system-notification>`;
}
// Clean up the merged branch (keep failed ones for manual resolution)
if (changesApplied) {
await cleanupTaskBranches(repoRoot, [result.branchName]);
}
}
} else {
// Patch mode: apply the patch from a successful run. A failed or
// aborted run has nothing to apply and must not block the result.
const succeeded = result.exitCode === 0 && !result.error && !result.aborted;
if (!succeeded) {
changesApplied = true;
hadAnyChanges = false;
} else if (!result.patchPath) {
changesApplied = false;
hadAnyChanges = false;
} else {
const patchText = await Bun.file(result.patchPath).text();
if (!patchText.trim()) {
changesApplied = true;
hadAnyChanges = false;
} else {
const normalized = patchText.endsWith("\n") ? patchText : `${patchText}\n`;
changesApplied = await git.patch.canApplyText(repoRoot, normalized);
if (changesApplied) {
try {
await git.patch.applyText(repoRoot, normalized);
hadAnyChanges = true;
} catch {
changesApplied = false;
hadAnyChanges = false;
}
}
}
}
if (changesApplied) {
mergeSummary = hadAnyChanges ? "\n\nApplied patches: yes" : "\n\nNo changes to apply.";
} else {
const notification =
"<system-notification>Patches were not applied and must be handled manually.</system-notification>";
const patchList = result.patchPath ? `\n\nPatch artifact:\n- ${result.patchPath}` : "";
mergeSummary = `\n\n${notification}${patchList}`;
}
}
} catch (mergeErr) {
const msg = mergeErr instanceof Error ? mergeErr.message : String(mergeErr);
changesApplied = false;
hadAnyChanges = false;
mergeSummary = `\n\n<system-notification>Merge phase failed: ${msg}\nTask outputs are preserved but changes were not applied.</system-notification>`;
}
const outcome = await mergeIsolatedChanges({ result, repoRoot, mergeMode });
mergeSummary = outcome.summary;
changesApplied = outcome.changesApplied;
mergedBranchForNestedPatches = outcome.mergedBranchForNestedPatches;
}
// Apply nested repo patches (separate from parent git)
if (isIsolated && repoRoot && (mergeMode === "branch" || changesApplied !== false)) {
const nestedPatches = result.nestedPatches ?? [];
const eligible =
nestedPatches.length > 0 &&
result.exitCode === 0 &&
!result.aborted &&
(mergeMode !== "branch" || mergedBranchForNestedPatches);
if (eligible) {
try {
await applyNestedPatches(repoRoot, nestedPatches, buildCommitMessageFn());
} catch {
// Nested patch failures are non-fatal to the parent merge
mergeSummary +=
"\n\n<system-notification>Some nested repository patches failed to apply.</system-notification>";
}
}
// Apply nested repo patches (separate from parent git).
if (isIsolated && repoRoot) {
mergeSummary += await applyEligibleNestedPatches({
result,
repoRoot,
mergeMode,
changesApplied,
mergedBranchForNestedPatches,
commitMessage: buildCommitMessageFn(),
});
}
// Cleanup temp directory if used
@@ -0,0 +1,353 @@
/**
* Reusable isolation lifecycle for subagent execution.
*
* Both `TaskTool` and the eval `agent()` bridge spawn subagents that can run
* inside a copy-on-write worktree, capture their changes, and (optionally)
* apply those changes back to the parent repo. The orchestration is identical
* for both callers; this module hosts the shared lifecycle so eval `agent()`
* does not need to round-trip through `TaskTool.#runSpawn`.
*
* Shape:
* 1. {@link prepareIsolationContext} — resolve git root + capture baseline.
* 2. {@link runIsolatedSubprocess} — start worktree, run, capture
* branch/patch, tear worktree down.
* 3. {@link mergeIsolatedChanges} — apply captured changes back to the
* parent repo (skip when the caller
* opted out).
*
* Step 1 happens once per top-level call (the baseline is cloned per spawn
* before mutation); steps 2 and 3 are per-spawn.
*/
import * as path from "node:path";
import type * as natives from "@oh-my-pi/pi-natives";
import type { ToolSession } from "../tools";
import { generateCommitMessage } from "../utils/commit-message-generator";
import * as git from "../utils/git";
import type { ExecutorOptions } from "./executor";
import { runSubprocess } from "./executor";
import type { SingleResult } from "./types";
import {
applyNestedPatches,
captureBaseline,
captureDeltaPatch,
cleanupIsolation,
cleanupTaskBranches,
commitToBranch,
ensureIsolation,
getRepoRoot,
type IsolationHandle,
mergeTaskBranches,
type WorktreeBaseline,
} from "./worktree";
type IsoBackendKind = natives.IsoBackendKind;
/** Resolved repo + baseline used by every isolated spawn in a single call. */
export interface IsolationContext {
repoRoot: string;
baseline: WorktreeBaseline;
}
/**
* Resolve the git repo root and capture the worktree baseline used to diff
* each isolated spawn against. Throws when the cwd is not inside a git
* repository; callers surface the error as a task-tool failure.
*/
export async function prepareIsolationContext(cwd: string): Promise<IsolationContext> {
const repoRoot = await getRepoRoot(cwd);
const baseline = await captureBaseline(repoRoot);
return { repoRoot, baseline };
}
/** Build a commit-message callback for branch/nested commits; `undefined` ⇒ fall back to generic message. */
export type BuildCommitMessage = () => undefined | ((diff: string) => Promise<string | null>);
/**
* Construct the commit-message factory used by isolation branch commits and
* nested-repo patch commits. Returns a closure that, each time it's called,
* either yields an AI-backed `(diff) => Promise<string|null>` callback (when
* `task.isolation.commits === "ai"` and a model registry is available) or
* `undefined` so the caller falls back to a generic commit message.
*
* Centralized so `TaskTool` and the eval `agent()` bridge share one wiring;
* a drift here previously meant the two callers built subtly different
* generators for the same setting.
*/
export function makeIsolationCommitMessage(session: ToolSession): BuildCommitMessage {
return () => {
const style = session.settings.get("task.isolation.commits");
if (style !== "ai" || !session.modelRegistry) return undefined;
const registry = session.modelRegistry;
const settings = session.settings;
const sessionId = session.getSessionId?.() ?? undefined;
return async (diff: string) => generateCommitMessage(diff, registry, settings, sessionId);
};
}
export interface IsolatedRunOptions {
/**
* Base run options handed to the subagent subprocess. This helper sets
* `worktree`, clears `preloadedExtensionPaths` / `preloadedCustomToolPaths`
* (isolated runs re-discover inside the worktree), and forwards everything
* else unchanged.
*/
baseOptions: ExecutorOptions;
/** Context returned by {@link prepareIsolationContext}. Baseline is cloned per spawn. */
context: IsolationContext;
/** PAL backend hint from `parseIsolationMode(...)` (undefined ⇒ resolver picks). */
preferredBackend: IsoBackendKind | undefined;
/** Stable id used as the isolation worktree namespace and as the branch suffix. */
agentId: string;
/** Merge mode driving how changes are captured ("branch" commits, "patch" diffs). */
mergeMode: "patch" | "branch";
/** Output dir for `${agentId}.patch` artifacts (patch mode). */
artifactsDir: string;
/** Human description carried onto the branch commit (branch mode). */
description?: string;
/** Build a commit-message callback (`task.isolation.commits === "ai"`). */
buildCommitMessage?: BuildCommitMessage;
/**
* Construct a `SingleResult` when isolation setup throws — the caller has
* the full metadata (index, agent, assignment, modelOverride) needed to
* build a result shape consistent with their non-isolated path.
*/
buildFailureResult: (err: unknown) => SingleResult;
}
/**
* Run a subagent inside an isolation worktree and capture its changes.
*
* Branch mode: on success, commits the diff onto `omp/task/${agentId}` and
* returns `branchName` + `nestedPatches`. On commit failure the branch is
* deleted and `result.error` carries the merge-failure message.
*
* Patch mode: on success, writes `${artifactsDir}/${agentId}.patch` and
* returns `patchPath` + `nestedPatches`.
*
* Failure paths preserve the underlying `SingleResult` whenever possible so
* the caller can still surface the subagent's output; only isolation setup
* itself routes through {@link IsolatedRunOptions.buildFailureResult}.
*
* The isolation handle is always torn down in `finally`.
*/
export async function runIsolatedSubprocess(opts: IsolatedRunOptions): Promise<SingleResult> {
let handle: IsolationHandle | undefined;
try {
const taskBaseline = structuredClone(opts.context.baseline);
handle = await ensureIsolation(opts.context.repoRoot, opts.agentId, opts.preferredBackend);
const isolationDir = handle.mergedDir;
const result = await runSubprocess({
...opts.baseOptions,
worktree: isolationDir,
preloadedExtensionPaths: undefined,
preloadedCustomToolPaths: undefined,
});
if (opts.mergeMode === "branch" && result.exitCode === 0) {
try {
const commitResult = await commitToBranch(
isolationDir,
taskBaseline,
opts.agentId,
opts.description,
opts.buildCommitMessage?.(),
);
return {
...result,
branchName: commitResult?.branchName,
nestedPatches: commitResult?.nestedPatches,
};
} catch (mergeErr) {
// Agent succeeded but branch commit failed — clean up stale branch
const branchName = `omp/task/${opts.agentId}`;
await git.branch.tryDelete(opts.context.repoRoot, branchName);
const msg = mergeErr instanceof Error ? mergeErr.message : String(mergeErr);
return { ...result, error: `Merge failed: ${msg}` };
}
}
if (result.exitCode === 0) {
try {
const delta = await captureDeltaPatch(isolationDir, taskBaseline);
const patchPath = path.join(opts.artifactsDir, `${opts.agentId}.patch`);
await Bun.write(patchPath, delta.rootPatch);
return {
...result,
patchPath,
nestedPatches: delta.nestedPatches,
};
} catch (patchErr) {
const msg = patchErr instanceof Error ? patchErr.message : String(patchErr);
return { ...result, error: `Patch capture failed: ${msg}` };
}
}
return result;
} catch (err) {
return opts.buildFailureResult(err);
} finally {
if (handle) {
await cleanupIsolation(handle);
}
}
}
export interface IsolationMergeOptions {
result: SingleResult;
repoRoot: string;
mergeMode: "patch" | "branch";
}
export interface IsolationMergeOutcome {
/** Trailing summary appended to the subagent's result text. May be empty. */
summary: string;
/**
* Tri-state apply outcome:
* - `true` — merge ran (or had nothing to apply) and left the repo clean.
* - `false` — merge attempted and failed; artifacts are preserved.
* - `null` — caller skipped the merge phase entirely (e.g. `apply=false`).
*/
changesApplied: boolean | null;
hadAnyChanges: boolean;
/** True iff the root branch actually merged — gates nested-repo patch application. */
mergedBranchForNestedPatches: boolean;
}
/**
* Apply changes captured by {@link runIsolatedSubprocess} back to the parent
* repo: patch apply (patch mode) or cherry-pick + cleanup (branch mode).
*
* The caller decides whether to run this at all — eval `agent()` with
* `apply=False` skips this step and surfaces the patch artifact / branch name
* instead.
*/
export async function mergeIsolatedChanges(opts: IsolationMergeOptions): Promise<IsolationMergeOutcome> {
const { result, repoRoot, mergeMode } = opts;
try {
if (mergeMode === "branch") {
const canApplyNestedOnly =
!result.branchName && result.exitCode === 0 && !result.aborted && (result.nestedPatches?.length ?? 0) > 0;
if (!result.branchName || result.exitCode !== 0 || result.aborted) {
return {
summary: canApplyNestedOnly
? "\n\nNo root changes to apply; nested repository patches captured."
: "\n\nNo changes to apply.",
changesApplied: true,
hadAnyChanges: canApplyNestedOnly,
mergedBranchForNestedPatches: canApplyNestedOnly,
};
}
const mergeResult = await mergeTaskBranches(repoRoot, [
{ branchName: result.branchName, taskId: result.id, description: result.description },
]);
const mergedBranchForNestedPatches = mergeResult.merged.includes(result.branchName);
const changesApplied = mergeResult.failed.length === 0;
const hadAnyChanges = changesApplied && mergeResult.merged.length > 0;
let summary: string;
if (changesApplied) {
summary = hadAnyChanges ? `\n\nMerged branch: ${result.branchName}` : "\n\nNo changes to apply.";
} else {
const conflictPart = mergeResult.conflict ? `\nConflict: ${mergeResult.conflict}` : "";
summary = `\n\n<system-notification>Branch merge failed: ${result.branchName}.${conflictPart}\nThe unmerged branch remains for manual resolution.</system-notification>`;
}
if (mergeResult.stashConflict) {
summary += `\n\n<system-notification>${mergeResult.stashConflict}</system-notification>`;
}
// Clean up the merged branch (keep failed ones for manual resolution)
if (changesApplied) {
await cleanupTaskBranches(repoRoot, [result.branchName]);
}
return { summary, changesApplied, hadAnyChanges, mergedBranchForNestedPatches };
}
// Patch mode: apply the patch from a successful run. A failed or
// aborted run has nothing to apply and must not block the result.
let changesApplied: boolean;
let hadAnyChanges: boolean;
const succeeded = result.exitCode === 0 && !result.error && !result.aborted;
if (!succeeded) {
changesApplied = true;
hadAnyChanges = false;
} else if (!result.patchPath) {
changesApplied = false;
hadAnyChanges = false;
} else {
const patchText = await Bun.file(result.patchPath).text();
if (!patchText.trim()) {
changesApplied = true;
hadAnyChanges = false;
} else {
const normalized = patchText.endsWith("\n") ? patchText : `${patchText}\n`;
changesApplied = await git.patch.canApplyText(repoRoot, normalized);
hadAnyChanges = false;
if (changesApplied) {
try {
await git.patch.applyText(repoRoot, normalized);
hadAnyChanges = true;
} catch {
changesApplied = false;
}
}
}
}
let summary: string;
if (changesApplied) {
summary = hadAnyChanges ? "\n\nApplied patches: yes" : "\n\nNo changes to apply.";
} else {
const notification =
"<system-notification>Patches were not applied and must be handled manually.</system-notification>";
const patchList = result.patchPath ? `\n\nPatch artifact:\n- ${result.patchPath}` : "";
summary = `\n\n${notification}${patchList}`;
}
return { summary, changesApplied, hadAnyChanges, mergedBranchForNestedPatches: false };
} catch (mergeErr) {
const msg = mergeErr instanceof Error ? mergeErr.message : String(mergeErr);
return {
summary: `\n\n<system-notification>Merge phase failed: ${msg}\nTask outputs are preserved but changes were not applied.</system-notification>`,
changesApplied: false,
hadAnyChanges: false,
mergedBranchForNestedPatches: false,
};
}
}
export interface NestedPatchApplyOptions {
/** Subagent result carrying `nestedPatches`/`exitCode`/`aborted`. */
result: SingleResult;
repoRoot: string;
mergeMode: "patch" | "branch";
/** Parent merge outcome — patch mode skips nested apply when this is `false`. */
changesApplied: boolean | null;
/** Branch mode gates nested apply on whether the root branch merged. */
mergedBranchForNestedPatches: boolean;
/** Optional AI commit-message callback for nested commits; falls back to a generic message. */
commitMessage?: (diff: string) => Promise<string | null>;
}
/**
* Apply nested-repo patches after the parent merge phase. Centralizes the
* three-way gate (exitCode/aborted, patch-mode failed parent, branch-mode
* branch-merged) and the non-fatal failure handling so `TaskTool` and the
* eval `agent()` bridge use one implementation.
*
* Returns a system-notification suffix to append to the parent merge summary,
* or an empty string when nothing was applied or the nested apply succeeded.
*/
export async function applyEligibleNestedPatches(opts: NestedPatchApplyOptions): Promise<string> {
const { result, repoRoot, mergeMode, changesApplied, mergedBranchForNestedPatches, commitMessage } = opts;
if (mergeMode === "patch" && changesApplied === false) return "";
const nestedPatches = result.nestedPatches ?? [];
const eligible =
nestedPatches.length > 0 &&
result.exitCode === 0 &&
!result.aborted &&
(mergeMode !== "branch" || mergedBranchForNestedPatches);
if (!eligible) return "";
try {
await applyNestedPatches(repoRoot, nestedPatches, commitMessage);
return "";
} catch {
// Nested patch failures are non-fatal to the parent merge.
return "\n\n<system-notification>Some nested repository patches failed to apply.</system-notification>";
}
}
+34 -8
View File
@@ -194,6 +194,14 @@ export async function captureDeltaPatch(isolationDir: string, baseline: Worktree
/**
* Apply nested repo patches directly to their working directories after parent merge.
*
* Pre-existing dirty state in a nested repo is stashed before the patch is
* applied and popped back after the commit, so unrelated user edits never get
* folded into the agent's commit. A failing `git stash pop` (e.g. user edits
* collide with the patched lines) leaves the stash entry intact and emits a
* `logger.warn` — the caller's catch handler turns the broader nested-apply
* failure into a non-fatal system notification.
*
* @param commitMessage Optional async function to generate a commit message from the combined diff.
* If omitted or returns null, falls back to a generic message.
*/
@@ -220,15 +228,33 @@ export async function applyNestedPatches(
}
const combinedDiff = repoPatches.map(p => p.patch).join("\n");
for (const { patch } of repoPatches) {
await git.patch.applyText(nestedDir, patch);
}
// Commit so nested repo history reflects the task changes
if ((await git.status(nestedDir)).trim().length > 0) {
const msg = (await commitMessage?.(combinedDiff)) ?? "changes from isolated task(s)";
await git.stage.files(nestedDir);
await git.commit(nestedDir, msg);
// Preserve any pre-existing dirty state (tracked + untracked) so we
// commit only the agent delta, not the user's in-flight work.
const stashed =
(await git.status(nestedDir)).trim().length > 0
? await git.stash.push(nestedDir, `omp-isolation-${Snowflake.next()}`)
: false;
try {
for (const { patch } of repoPatches) {
await git.patch.applyText(nestedDir, patch);
}
if ((await git.status(nestedDir)).trim().length > 0) {
const msg = (await commitMessage?.(combinedDiff)) ?? "changes from isolated task(s)";
await git.stage.files(nestedDir);
await git.commit(nestedDir, msg);
}
} finally {
if (stashed) {
try {
await git.stash.pop(nestedDir, { index: true });
} catch (popErr) {
logger.warn("Pre-existing nested-repo dirty state could not be auto-restored", {
nestedDir,
error: popErr instanceof Error ? popErr.message : String(popErr),
});
}
}
}
}
}