The workflowz eval path bypasses the task tool's isolation wrapper and calls runSubprocess() directly, so parallel agent() fan-outs that edit overlapping files all land in the parent worktree. Extends the eval agent bridge schema with isolated/apply/merge, forwards them through the Python and JS preludes, and adds a shared task/isolation-runner.ts so the lifecycle (prepare context → run in worktree → capture patch/branch → merge → cleanup) is implemented once for both TaskTool and the bridge. Default mirrors task.isolation.mode: isolated by default when settings allow it, off when mode === 'none'. isolated=False explicitly disables; isolated=True with mode === 'none' errors out to match the task tool. apply=false keeps captured changes inside the worktree and surfaces the patch path / branch name in details. merge=false forces patch mode even when task.isolation.merge === 'branch'. Fixes #3196
284 lines
11 KiB
TypeScript
284 lines
11 KiB
TypeScript
/**
|
|
* 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 * as natives from "@oh-my-pi/pi-natives";
|
|
import * as git from "../utils/git";
|
|
import type { ExecutorOptions } from "./executor";
|
|
import { runSubprocess } from "./executor";
|
|
import type { SingleResult } from "./types";
|
|
import {
|
|
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. */
|
|
type BuildCommitMessage = () => undefined | ((diff: string) => Promise<string | null>);
|
|
|
|
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") {
|
|
if (!result.branchName || result.exitCode !== 0 || result.aborted) {
|
|
return {
|
|
summary: "\n\nNo changes to apply.",
|
|
changesApplied: true,
|
|
hadAnyChanges: false,
|
|
mergedBranchForNestedPatches: false,
|
|
};
|
|
}
|
|
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,
|
|
};
|
|
}
|
|
}
|