Skip to content

Commit 121247f

Browse files
committed
fix: preserve exact delegated review diffs
1 parent d1f8929 commit 121247f

7 files changed

Lines changed: 65 additions & 12 deletions

File tree

package-lock.json

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/pi-auto-dag/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@
3131
"@earendil-works/pi-coding-agent": "^0.84.3"
3232
},
3333
"dependencies": {
34-
"@henryqw/pi-subagent": "^4.0.0",
34+
"@henryqw/pi-subagent": "^4.1.0",
3535
"proper-lockfile": "^4.1.2",
3636
"typebox": "1.3.15"
3737
},

packages/pi-auto-dag/src/review.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -133,8 +133,9 @@ export async function persistReviewPatch(input: ReviewPatchInput): Promise<Revie
133133
const path = join(artifactDirectory, `${basename(temporaryDirectory)}.patch`);
134134
assertInsideRunDirectory(root, path);
135135
try {
136-
const result = await input.runner("git", ["diff", "--binary", `--output=${temporary}`, base, commit], { cwd: input.worktree });
137-
if (result.code !== 0) throw new Error(commandFailure("git", ["diff", "--binary", `--output=${temporary}`, base, commit], result));
136+
const args = ["diff", "--no-textconv", "--no-ext-diff", "--ignore-submodules=none", "--binary", `--output=${temporary}`, base, commit];
137+
const result = await input.runner("git", args, { cwd: input.worktree });
138+
if (result.code !== 0) throw new Error(commandFailure("git", args, result));
138139
await chmod(temporary, 0o600);
139140
const expected = await patchDigest(temporary);
140141
try {

packages/pi-auto-dag/test/orchestration.test.ts

Lines changed: 52 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,20 +3,20 @@ import { execFile as execFileCallback, spawn } from "node:child_process";
33
import { createHash } from "node:crypto";
44
import { once } from "node:events";
55
import { readFileSync } from "node:fs";
6-
import { mkdtemp, mkdir, readFile, rm, stat, utimes, writeFile } from "node:fs/promises";
6+
import { chmod, mkdtemp, mkdir, readFile, rm, stat, utimes, writeFile } from "node:fs/promises";
77
import { tmpdir } from "node:os";
88
import { join } from "node:path";
99
import test, { type TestContext } from "node:test";
1010
import { promisify } from "node:util";
1111
import { fakeHerdr } from "./support/fake-herdr.ts";
1212
import { testLaunchResolver } from "./support/roles.ts";
13-
import { recordedGateEvidence, type CommandRunner } from "../src/command.ts";
13+
import { recordedGateEvidence, runCommand, type CommandRunner } from "../src/command.ts";
1414
import { createCoreLifecycle, type CoreLifecycle } from "../src/lifecycle.ts";
1515
import { readDeliveryGraph } from "../src/graph.ts";
1616
import { preflightLocalRun } from "../src/intake.ts";
1717
import { childWorktreePath } from "../src/implementation-workers.ts";
1818
import { type RunState } from "../src/model.ts";
19-
import { reviewPromptMode } from "../src/review.ts";
19+
import { persistReviewPatch, reviewPromptMode } from "../src/review.ts";
2020
import { parseWorkerEnvelope } from "../src/orchestration.ts";
2121
import { actionTicketPath, eventReceiptPath, readActionTicket, readWorkerReceipt, reviewId, type ActionTicket, WorkerEnvelopeRejectedError, writeWorkerReceipt } from "../src/review-ticket.ts";
2222
import { readActiveRunId, readRunState, recordAcceptedWorkerEvent, runDirectory, stateRoot, writeRunState } from "../src/state.ts";
@@ -28,6 +28,54 @@ test("stale reviewer packet identity forces a full recovery packet", () => {
2828
assert.equal(reviewPromptMode(false, "existing", "old-base", "old-commit", "base", "commit"), "full");
2929
});
3030

31+
test("review patches ignore configured diff transforms and submodule omission", async (t) => {
32+
const root = await mkdtemp(join(tmpdir(), "pi-auto-dag-review-patch-"));
33+
t.after(async () => { await rm(root, { recursive: true, force: true }); });
34+
await git(root, "init", "-b", "main");
35+
await git(root, "config", "user.email", "test@example.com");
36+
await git(root, "config", "user.name", "Test User");
37+
await writeFile(join(root, ".gitattributes"), "fixture.txt diff=fixture\n");
38+
await writeFile(join(root, "fixture.txt"), "native base\n");
39+
await git(root, "add", ".");
40+
await git(root, "commit", "-m", "base");
41+
const base = await git(root, "rev-parse", "HEAD");
42+
const external = join(root, "external-diff");
43+
await writeFile(external, "#!/bin/sh\nprintf 'external diff\\n'\n");
44+
await chmod(external, 0o755);
45+
await git(root, "config", "diff.fixture.textconv", "sed s/native/transformed/");
46+
await git(root, "config", "diff.external", external);
47+
await git(root, "config", "diff.ignoreSubmodules", "all");
48+
await writeFile(join(root, "fixture.txt"), "native changed\n");
49+
const gitlink = "1".repeat(40);
50+
await git(root, "update-index", "--add", "--cacheinfo", `160000,${gitlink},vendor/review-fixture`);
51+
await git(root, "add", "fixture.txt");
52+
await git(root, "commit", "-m", "change");
53+
const commit = await git(root, "rev-parse", "HEAD");
54+
assert.match((await execFile("git", ["diff", "--textconv", "--no-ext-diff", "--ignore-submodules=none", base, commit], { cwd: root })).stdout, /transformed/);
55+
assert.match((await execFile("git", ["diff", "--ext-diff", base, commit], { cwd: root })).stdout, /external diff/);
56+
assert.doesNotMatch((await execFile("git", ["diff", "--no-textconv", "--no-ext-diff", "--ignore-submodules=all", base, commit], { cwd: root })).stdout, new RegExp(gitlink));
57+
await mkdir(runDirectory(root, RUN_ID), { recursive: true });
58+
const calls: ReadonlyArray<string>[] = [];
59+
const patch = await persistReviewPatch({
60+
runner: async (command, args, options) => {
61+
if (command === "git") calls.push(args);
62+
return await runCommand(command, args, options);
63+
},
64+
mainWorktree: root,
65+
runId: RUN_ID,
66+
worktree: root,
67+
base,
68+
commit,
69+
context: { type: "integration_head" },
70+
});
71+
const actual = await readFile(patch.path);
72+
const expected = (await execFile("git", ["diff", "--no-textconv", "--no-ext-diff", "--ignore-submodules=none", "--binary", base, commit], { cwd: root, encoding: "buffer" })).stdout;
73+
assert.deepEqual(actual, expected);
74+
assert.match(actual.toString(), /native changed/);
75+
assert.match(actual.toString(), new RegExp(gitlink));
76+
assert.ok(calls.some((args) => args.includes("--no-textconv") && args.includes("--no-ext-diff") && args.includes("--ignore-submodules=none")));
77+
});
78+
3179
test("a successor takes over after the starter is killed with a durably identified live worker", async (t) => {
3280
const project = await makeProject(t, graph(["alpha"]), 1, 1);
3381
const child = spawn(process.execPath, ["--input-type=module", "--eval", `
@@ -1267,7 +1315,7 @@ async function assertReviewPatch(
12671315
assert.equal(patch.base, base);
12681316
assert.equal(patch.commit, commit);
12691317
const actual = await readFile(patch.path);
1270-
const expected = (await execFile("git", ["diff", "--binary", base, commit], { cwd: root, encoding: "buffer" })).stdout;
1318+
const expected = (await execFile("git", ["diff", "--no-textconv", "--no-ext-diff", "--ignore-submodules=none", "--binary", base, commit], { cwd: root, encoding: "buffer" })).stdout;
12711319
assert.deepEqual(actual, expected);
12721320
assert.equal(patch.bytes, actual.length);
12731321
assert.equal(patch.sha256, createHash("sha256").update(actual).digest("hex"));

packages/pi-auto-dag/test/pr-lifecycle.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -785,7 +785,7 @@ async function assertReviewPatch(
785785
assert.equal(patch.base, base);
786786
assert.equal(patch.commit, commit);
787787
const actual = await readFile(patch.path);
788-
const expected = (await execFile("git", ["diff", "--binary", base, commit], { cwd: root, encoding: "buffer" })).stdout;
788+
const expected = (await execFile("git", ["diff", "--no-textconv", "--no-ext-diff", "--ignore-submodules=none", "--binary", base, commit], { cwd: root, encoding: "buffer" })).stdout;
789789
assert.deepEqual(actual, expected);
790790
assert.equal(patch.bytes, actual.length);
791791
assert.equal(patch.sha256, createHash("sha256").update(actual).digest("hex"));

packages/pi-subagent/skills/pi-subagent-delegated-development/SKILL.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,15 +33,15 @@ For each bounded unit:
3333
1. **Implement** — one `delegate_task` single call to `implementer`. The packet states the objective, touched scope, required validation, and recorded base commit. Require its output to identify the retained worktree path, branch, base commit, tip commit, changed files from the base-to-tip committed diff, and clean `git status --porcelain=v1 --untracked-files=all` result.
3434
2. **Verify and review** — refuse review or merge unless Main independently verifies that the worktree was retained and is clean (including untracked files), the reported base/branch/tip identities are complete and match Git, the tip is descended from the recorded base, and the reported changed files equal `git diff --name-only "$base" "$tip"`. All intended changes must be in that committed base-to-tip diff. If any evidence is missing or any check fails, send the work to a fresh implementer repair; never review dirty or uncommitted work.
3535

36-
Create a private temporary exact-patch artifact outside the repository from `git diff --binary "$base" "$tip"`. Independently regenerate that diff and byte-compare it with the artifact, then record its path, byte count, and SHA-256. If creation, regeneration, comparison, byte count, or checksum fails, stop and report the artifact failure clearly; do not review or merge. Do not put any complete patch content in `delegate_task` text or argv.
36+
Create a private temporary exact-patch artifact outside the repository from `git diff --no-textconv --no-ext-diff --ignore-submodules=none --binary "$base" "$tip"`. Independently regenerate that same diff and byte-compare it with the artifact, then record its path, byte count, and SHA-256. If creation, regeneration, comparison, byte count, or checksum fails, stop and report the artifact failure clearly; do not review or merge. Do not put any complete patch content in `delegate_task` text or argv.
3737

3838
Make one `delegate_task` single call to `reviewer` with only a bounded metadata packet: base, tip, review context `{type:'child_branch', branch}`, the verified complete patch file reference (`path`, `bytes`, `sha256`), and the verified changed paths. If that complete metadata cannot fit the task transport bound, stop and report it rather than truncating or inlining patch content. Chain entries do not share files: `{previous}` passes text only.
3939
3. **Merge** — only after an approving review, re-check Main's clean status, verify the branch tip still equals the reviewed tip commit, then merge that exact commit (not the branch name) into Main's current worktree and run focused validation there. Never merge on unresolved findings.
4040
4. **Clean up after success** — only after the exact reviewed tip is integrated and focused validation passes, remove each reported retained worktree, then safely delete its task branch. Include a superseded repair-round worktree only when its exact tip is an ancestor of integrated `HEAD`. For each candidate, verify ancestry first, use non-forced worktree removal followed by `git branch -d`, and stop/report cleanup failure without deleting later evidence. On any integration or validation failure, preserve every temporary patch artifact, retained worktree, and task branch for recovery. After integration and validation succeed, remove the temporary reviewer patch artifacts.
4141

4242
## Findings
4343

44-
Any reviewer finding goes back as a **fresh** `implementer` delegation containing the findings plus the reviewed base/branch/tip identities and complete exact patch file reference, followed by a fresh review of the new state. A fresh repair implementer starts in a new worktree from Main HEAD and does not contain the prior unit commit: the repair packet must first bring the reported predecessor commit into its fresh worktree (merge or cherry-pick as appropriate), then address the findings. Dirty or uncommitted work also goes only to this fresh repair path. Bound the loop (e.g. three rounds); past the bound, stop and report to the user instead of merging.
44+
Any reviewer finding goes back as a **fresh** `implementer` delegation containing the findings plus the reviewed base/branch/tip identities and complete exact patch file reference, followed by a fresh review of the new state. A fresh repair implementer starts in a new worktree from Main HEAD and does not contain the prior unit commit: the repair packet must first bring the whole reviewed `$base..$tip` range into its fresh worktree by merging the exact `$tip`, or cherry-picking every range commit in order. Cherry-pick `$tip` alone only after `git rev-list --count "$base..$tip"` verifies the range is exactly one commit; then address the findings. Dirty or uncommitted work also goes only to this fresh repair path. Bound the loop (e.g. three rounds); past the bound, stop and report to the user instead of merging.
4545

4646
## Parallelism
4747

packages/pi-subagent/test/examples.test.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,11 @@ test("bundled pi-subagent-delegated-development Skill is valid and registered",
2424
assert.match(skill, /git rev-parse --verify -q HEAD\^\{commit\}/);
2525
assert.match(skill, /refuse review or merge unless/i);
2626
assert.match(skill, /never review dirty or uncommitted work/i);
27-
assert.match(skill, /git diff --binary "\$base" "\$tip"/);
27+
assert.match(skill, /git diff --no-textconv --no-ext-diff --ignore-submodules=none --binary "\$base" "\$tip"/);
28+
assert.match(skill, /same diff and byte-compare it with the artifact/i);
29+
assert.match(skill, /whole reviewed `\$base\.\.\$tip` range/i);
30+
assert.match(skill, /cherry-picking every range commit in order/i);
31+
assert.match(skill, /git rev-list --count "\$base\.\.\$tip"/);
2832
assert.match(skill, /private temporary exact-patch artifact/i);
2933
assert.match(skill, /byte-compare it with the artifact/i);
3034
assert.match(skill, /review context `\{type:'child_branch', branch\}`/i);

0 commit comments

Comments
 (0)