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:
@@ -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>";
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user