fix(coding-agent): preserve agent commits across isolated branch merges
When an isolated task agent commits its own changes before yielding, the
harness used to collapse the captured delta into one AI-summarized commit
and discard the agent's commit messages and authorship entirely. This
violated commit discipline for agentic swarms — multiple logical commits
("fix bug" + "add test") became a single opaque commit, and the
agent's commit object (which lived in isolation/.git/objects under
overlayfs/rcopy) was lost when cleanupIsolation tore down the overlay.
commitToBranch now detects when isolation HEAD moved past baseline.root
.headCommit. When it has, the function git-fetches the agent's HEAD into
the parent repo as omp/task/${taskId} so the commit objects survive
cleanupIsolation, and stamps the captured baselineSha onto the returned
CommitToBranchResult. mergeTaskBranches cherry-picks the inclusive range
baseSha..branchName when baseSha is provided, replaying each agent
commit verbatim with its original message and author. Any uncommitted
leftover (staged, unstaged, untracked) on top of the agent's last commit
becomes one trailing AI-summarized commit on the same branch.
Falls back to the legacy single-commit path when the agent never moved
HEAD (purely dirty working tree); existing patch-mode flow is untouched.
Fixes #3842
This commit is contained in:
@@ -6,6 +6,7 @@ import {
|
||||
applyNestedPatches,
|
||||
captureBaseline,
|
||||
captureDeltaPatch,
|
||||
commitToBranch,
|
||||
ensureIsolation,
|
||||
getGitNoIndexNullPath,
|
||||
getRepoRoot,
|
||||
@@ -426,3 +427,137 @@ describe("applyNestedPatches", () => {
|
||||
expect(stashList).toContain("omp-isolation-");
|
||||
});
|
||||
});
|
||||
|
||||
describe("commitToBranch preserves agent commits", () => {
|
||||
let parent: string;
|
||||
let isolation: string;
|
||||
|
||||
async function gitr(repo: string, args: string[]): Promise<string> {
|
||||
return runGit(repo, args);
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
parent = await fs.mkdtemp(path.join(os.tmpdir(), "omp-commit-parent-"));
|
||||
isolation = await fs.mkdtemp(path.join(os.tmpdir(), "omp-commit-iso-"));
|
||||
await gitr(parent, ["init", "-q", "-b", "main"]);
|
||||
await gitr(parent, ["config", "user.email", "user@example.com"]);
|
||||
await gitr(parent, ["config", "user.name", "Parent User"]);
|
||||
await fs.writeFile(
|
||||
path.join(parent, "EXP_CLEAN_COMMIT.txt"),
|
||||
"line1\nline2\nline3\nline4\nline5\nline6\nline7\nline8\nline9\nline10\n",
|
||||
);
|
||||
await gitr(parent, ["add", "."]);
|
||||
await gitr(parent, ["commit", "-q", "-m", "add clean test fixture"]);
|
||||
|
||||
// Simulate copy-on-write isolation: a real local clone so the agent's
|
||||
// commit objects live in `isolation/.git`, just like the overlay/rcopy
|
||||
// isolation backends would arrange them at runtime.
|
||||
await fs.rm(isolation, { recursive: true, force: true });
|
||||
await gitr(parent, ["clone", "-q", "--no-hardlinks", "--local", parent, isolation]);
|
||||
await gitr(isolation, ["config", "user.email", "agent@example.com"]);
|
||||
await gitr(isolation, ["config", "user.name", "Agent User"]);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all([removeWithRetries(parent), removeWithRetries(isolation)]);
|
||||
});
|
||||
|
||||
// Reproduces issue #3842: agent commits with a specific message inside
|
||||
// isolation; the merged commit on the parent branch must keep that exact
|
||||
// message instead of an AI-generated summary.
|
||||
it("preserves the agent's commit message after merge", async () => {
|
||||
const baseline = await captureBaseline(parent);
|
||||
|
||||
await fs.writeFile(
|
||||
path.join(isolation, "EXP_CLEAN_COMMIT.txt"),
|
||||
"line1\nline2\nline3\nline4\nLINE5-AGENT-WITH-MESSAGE\nline6\nline7\nline8\nline9\nline10\n",
|
||||
);
|
||||
await gitr(isolation, ["add", "EXP_CLEAN_COMMIT.txt"]);
|
||||
const agentMessage = "fix(test): agent committed with specific message for preservation check";
|
||||
await gitr(isolation, ["commit", "-q", "-m", agentMessage]);
|
||||
|
||||
const taskId = "preservation-check";
|
||||
const aiMessage = vi.fn(async () => "fix: update line5 in clean commit example");
|
||||
const result = await commitToBranch(isolation, baseline, taskId, undefined, aiMessage);
|
||||
|
||||
expect(result?.branchName).toBe(`omp/task/${taskId}`);
|
||||
expect(result?.baseSha).toBe(baseline.root.headCommit);
|
||||
// commitMessage callback must NOT have been invoked — the agent's
|
||||
// message is taken verbatim.
|
||||
expect(aiMessage).not.toHaveBeenCalled();
|
||||
|
||||
const branchSubject = await gitr(parent, ["log", "-1", "--pretty=%s", result!.branchName!]);
|
||||
expect(branchSubject).toBe(agentMessage);
|
||||
|
||||
const merge = await mergeTaskBranches(parent, [
|
||||
{ branchName: result!.branchName!, taskId, baseSha: result!.baseSha! },
|
||||
]);
|
||||
expect(merge.failed).toEqual([]);
|
||||
expect(merge.merged).toEqual([result!.branchName!]);
|
||||
|
||||
const headSubject = await gitr(parent, ["log", "-1", "--pretty=%s"]);
|
||||
expect(headSubject).toBe(agentMessage);
|
||||
});
|
||||
|
||||
it("preserves every message when the agent makes multiple commits", async () => {
|
||||
const baseline = await captureBaseline(parent);
|
||||
|
||||
await fs.writeFile(path.join(isolation, "a.txt"), "alpha\n");
|
||||
await gitr(isolation, ["add", "a.txt"]);
|
||||
await gitr(isolation, ["commit", "-q", "-m", "feat: add alpha file"]);
|
||||
await fs.writeFile(path.join(isolation, "b.txt"), "beta\n");
|
||||
await gitr(isolation, ["add", "b.txt"]);
|
||||
await gitr(isolation, ["commit", "-q", "-m", "test: add beta coverage"]);
|
||||
|
||||
const result = await commitToBranch(isolation, baseline, "multi", undefined);
|
||||
expect(result?.branchName).toBe("omp/task/multi");
|
||||
|
||||
const merge = await mergeTaskBranches(parent, [
|
||||
{ branchName: result!.branchName!, taskId: "multi", baseSha: result!.baseSha! },
|
||||
]);
|
||||
expect(merge).toEqual({ failed: [], merged: ["omp/task/multi"] });
|
||||
|
||||
const subjects = (await gitr(parent, ["log", "-2", "--pretty=%s"])).split("\n");
|
||||
expect(subjects).toEqual(["test: add beta coverage", "feat: add alpha file"]);
|
||||
});
|
||||
|
||||
it("appends one trailing commit when the agent leaves uncommitted work after committing", async () => {
|
||||
const baseline = await captureBaseline(parent);
|
||||
|
||||
await fs.writeFile(path.join(isolation, "a.txt"), "alpha\n");
|
||||
await gitr(isolation, ["add", "a.txt"]);
|
||||
await gitr(isolation, ["commit", "-q", "-m", "feat: add alpha file"]);
|
||||
// Uncommitted change on top of the agent's commit — should land as one
|
||||
// extra commit with the AI-generated message, NOT silently dropped.
|
||||
await fs.writeFile(path.join(isolation, "b.txt"), "beta\n");
|
||||
|
||||
const aiMessage = vi.fn(async () => "chore: leftover beta wip");
|
||||
const result = await commitToBranch(isolation, baseline, "leftover", undefined, aiMessage);
|
||||
expect(result?.branchName).toBe("omp/task/leftover");
|
||||
expect(aiMessage).toHaveBeenCalledTimes(1);
|
||||
|
||||
const subjects = (await gitr(parent, ["log", "-2", "--pretty=%s", result!.branchName!])).split("\n");
|
||||
expect(subjects).toEqual(["chore: leftover beta wip", "feat: add alpha file"]);
|
||||
});
|
||||
|
||||
it("falls back to the AI-generated message when the agent never committed", async () => {
|
||||
const baseline = await captureBaseline(parent);
|
||||
|
||||
await fs.writeFile(path.join(isolation, "a.txt"), "alpha\n");
|
||||
|
||||
const aiMessage = vi.fn(async () => "feat: add alpha");
|
||||
const result = await commitToBranch(isolation, baseline, "nocommit", undefined, aiMessage);
|
||||
|
||||
expect(result?.branchName).toBe("omp/task/nocommit");
|
||||
expect(aiMessage).toHaveBeenCalledTimes(1);
|
||||
|
||||
const branchSubject = await gitr(parent, ["log", "-1", "--pretty=%s", result!.branchName!]);
|
||||
expect(branchSubject).toBe("feat: add alpha");
|
||||
});
|
||||
|
||||
it("returns null when nothing changed in isolation", async () => {
|
||||
const baseline = await captureBaseline(parent);
|
||||
const result = await commitToBranch(isolation, baseline, "empty", undefined);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user