Merge PR #6227: fix(coding-agent): replay committed tasks over dirty parent (@roboomp)

This commit is contained in:
can1357
2026-07-22 21:13:16 +02:00
3 changed files with 62 additions and 11 deletions
+2
View File
@@ -54,6 +54,8 @@
### Fixed
- Fixed isolated branch merge-back rejecting committed agent edits when the parent had unrelated uncommitted changes in the same file; dirty-baseline blobs are now seeded into the parent object database and replayed with a 3-way synthetic-tree apply ([#6135](https://github.com/can1357/oh-my-pi/issues/6135)).
- Fixed the interactive `!`/`!!` shell shortcut spawning fish as a login shell (`fish -l -c …`), which fired `status is-login` blocks in user config (agent/keychain setup, PATH mutation) on every command. fish is now started with `-i` instead — interactive shells source the same `config.fish`/`conf.d` files (so aliases and functions from #1816 keep working) without login-shell side effects. zsh behavior (`-l -i`) is unchanged.
- Fixed the status-line `tok/s` badge ignoring vibe worker sessions: in `/vibe` mode the director is often idle while workers stream, so the badge showed a stale/zero rate while parallel work was actively generating tokens. The rate now aggregates the main session's live tok/s with every live vibe worker's tok/s, and falls back to the main session's own cached rate when no workers are streaming.
- Fixed `plan.defaultOnStartup` being ignored by headless `omp -p` sessions, so the initial prompt now runs in plan mode and the persisted session remains in plan mode for later review ([#6017](https://github.com/can1357/oh-my-pi/issues/6017)).
+25 -11
View File
@@ -116,7 +116,16 @@ async function captureRepoBaseline(repoRoot: string): Promise<RepoBaseline> {
return { repoRoot, headCommit, staged, unstaged, untracked, untrackedPatch };
}
async function writeSyntheticTree(repoDir: string, baseTreeish: string, patches: readonly string[]): Promise<string> {
interface SyntheticTreeOptions {
readonly threeWay?: boolean;
}
async function writeSyntheticTree(
repoDir: string,
baseTreeish: string,
patches: readonly string[],
options: SyntheticTreeOptions = {},
): Promise<string> {
const tempIndex = path.join(os.tmpdir(), `omp-task-index-${Snowflake.next()}`);
try {
await git.readTree(repoDir, baseTreeish, {
@@ -127,6 +136,7 @@ async function writeSyntheticTree(repoDir: string, baseTreeish: string, patches:
await git.patch.applyText(repoDir, patch, {
cached: true,
env: { GIT_INDEX_FILE: tempIndex },
threeWay: options.threeWay,
});
}
return await git.writeTree(repoDir, {
@@ -653,11 +663,12 @@ async function replayFilteredAgentCommits(opts: FilteredAgentReplayOptions): Pro
try {
await git.worktree.add(opts.repoRoot, tmpDir, opts.branchName);
const agentCommits = await git.revList.range(opts.isolationDir, baselineSha, opts.isolationHead);
const dirtyBaselineTree = await writeSyntheticTree(opts.isolationDir, baselineSha, [
opts.baseline.root.staged,
opts.baseline.root.unstaged,
opts.baseline.root.untrackedPatch,
]);
const baselineWip = [opts.baseline.root.staged, opts.baseline.root.unstaged, opts.baseline.root.untrackedPatch];
// Seed the parent ODB with the dirty-side blobs needed by `git apply
// --3way`. Isolation repositories can read parent objects, but the parent
// cannot read objects created only inside isolation.
await writeSyntheticTree(opts.repoRoot, baselineSha, baselineWip);
const dirtyBaselineTree = await writeSyntheticTree(opts.isolationDir, baselineSha, baselineWip);
let previousFilteredTree = baselineSha;
let filteredCommitsApplied = 0;
@@ -666,7 +677,9 @@ async function replayFilteredAgentCommits(opts: FilteredAgentReplayOptions): Pro
allowFailure: true,
binary: true,
});
const currentFilteredTree = await writeSyntheticTree(opts.repoRoot, baselineSha, [taskStatePatch]);
const currentFilteredTree = await writeSyntheticTree(opts.repoRoot, baselineSha, [taskStatePatch], {
threeWay: true,
});
const commitPatch = await git.diff.tree(opts.repoRoot, previousFilteredTree, currentFilteredTree, {
allowFailure: true,
binary: true,
@@ -699,10 +712,11 @@ async function replayFilteredAgentCommits(opts: FilteredAgentReplayOptions): Pro
await commitPatchToBranchWorktree(tmpDir, opts.taskId, opts.rootPatch, msg, undefined, opts.baseline.root);
}
} else {
// A filtered commit landed; tmpDir has advanced past baselineSha and
// previousFilteredTree is HEAD-derived, so writeSyntheticTree +
// leftoverPatch stay HEAD-based and no WIP seed is needed.
const finalFilteredTree = await writeSyntheticTree(opts.repoRoot, baselineSha, [opts.rootPatch]);
// A filtered commit landed; reconstruct the final HEAD-derived tree
// with the same dirty-side blobs and 3-way synthesis used above.
const finalFilteredTree = await writeSyntheticTree(opts.repoRoot, baselineSha, [opts.rootPatch], {
threeWay: true,
});
const leftoverPatch = await git.diff.tree(opts.repoRoot, previousFilteredTree, finalFilteredTree, {
allowFailure: true,
binary: true,
@@ -1080,6 +1080,41 @@ describe("commitToBranch preserves agent commits", () => {
expect(fixture).toContain("LINE5-AGENT-WITH-MESSAGE");
});
it("merges a committed agent edit beside unrelated dirty-parent lines", async () => {
const parentLines = [
"line1",
"LINE2-DIRTY-PARENT",
"line3",
"line4",
"line5",
"line6",
"line7",
"line8",
"line9",
"line10",
"",
];
await fs.writeFile(path.join(parent, "EXP_CLEAN_COMMIT.txt"), parentLines.join("\n"));
await fs.writeFile(path.join(isolation, "EXP_CLEAN_COMMIT.txt"), parentLines.join("\n"));
const baseline = await captureBaseline(parent);
const agentLines = parentLines.slice();
agentLines[4] = "LINE5-AGENT-EDIT";
await fs.writeFile(path.join(isolation, "EXP_CLEAN_COMMIT.txt"), agentLines.join("\n"));
await gitr(isolation, ["add", "EXP_CLEAN_COMMIT.txt"]);
await gitr(isolation, ["commit", "-q", "-m", "agent: edit line 5"]);
const taskId = "dirty-parent-committed-agent";
const result = await commitToBranch(isolation, baseline, taskId, undefined);
expect(result?.branchName).toBe(`omp/task/${taskId}`);
const merge = await mergeTaskBranches(parent, [
{ branchName: result!.branchName!, taskId, baseSha: result!.baseSha! },
]);
expect(merge).toEqual({ failed: [], merged: [result!.branchName!] });
expect(await fs.readFile(path.join(parent, "EXP_CLEAN_COMMIT.txt"), "utf8")).toBe(agentLines.join("\n"));
});
it("falls back to the AI-generated message when the agent never committed", async () => {
const baseline = await captureBaseline(parent);