Merge PR #6227: fix(coding-agent): replay committed tasks over dirty parent (@roboomp)
This commit is contained in:
@@ -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)).
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user