* fix(#2990): gsd-code-fixer worktree attaches to a new branch, not the user-checked-out one The agent's setup_worktree step ran 'git worktree add "$wt" "$branch"' where $branch was the user's currently-checked-out branch in the main repo. Git refuses to check out the same branch in two worktrees by default, so the call failed before any review fix could be applied. This is the next-layer failure after #2686 (foreground/background race) and #2839 (transactional cleanup): the isolation strategy was correct in design, blocked only by git's same-branch protection. Fix: - Create a new branch 'gsd-reviewfix/${padded_phase}-$$' from the current branch tip and attach the worktree to it via 'git worktree add -b "$reviewfix_branch" "$wt" "$branch"'. - Cleanup tail is now four steps: 1. 'git -C "$main_repo" merge --ff-only "$reviewfix_branch"' -- captures the agent's commits on the user's branch. --ff-only fails loudly on divergence (concurrent commits to $branch); the temp branch is preserved for manual merge. 2. 'git worktree remove "$wt" --force'. 3. 'git -C "$main_repo" branch -D "$reviewfix_branch"' ONLY if ff-only succeeded. 4. 'rm -f "$sentinel"' last (preserves #2839 transactional ordering). - Recovery sentinel JSON now records reviewfix_branch alongside worktree_path so a re-run after interruption cleans both the orphan worktree and the orphan temp branch. Regression test: tests/bug-2990-code-fixer-worktree-branch.test.cjs parses the agent .md into structured 'git worktree add' invocation records (skipping occurrences inside markdown inline-code or bash comments -- those are citations of the OLD pattern, not executable) and asserts the structural invariants on the new pattern. Closes #2990 * chore(#2990): add changeset fragment for PR #3001 * chore(#2990): add changeset fragment for PR #3001 * fix(#2990): correct main_repo parsing and ff_status capture (CR feedback) CodeRabbit on PR #3001 caught two real bugs in the cleanup tail: 1. `awk '/^worktree / { print $2 }'` truncates paths containing spaces. /path/with spaces/repo becomes /path/with. Replaced with `sub(/^worktree /, ''); print` which strips the prefix and preserves the full path. 2. `if ! git merge ...; then ff_status=$?` captures the exit of the `!` operator (always 1 on failure), not the merge command's exit code. Restructured to `if cmd; then ff_status=0; else ff_status=$?` so the else-branch captures the real merge exit code. Tests still pass: bug-2990 structural assertions on the agent .md content unchanged. * fix(#2990): recovery extracts reviewfix_branch and deletes orphan branch (CR) CodeRabbit on PR #3001 found two issues: 1. (Major) Recovery code only extracted worktree_path from the sentinel. If a prior run died after `git worktree remove` but before `git branch -D`, the orphan reviewfix branch survived forever. The sentinel records reviewfix_branch (line 272) and the docs claim recovery deletes it, but the code didn't. Fixed: emit BOTH worktree_path and reviewfix_branch from the parser (newline-separated), capture each into shell vars, and call `git branch -D "$prior_branch" 2>/dev/null || true` after worktree removal but before sentinel deletion. 2. (Quick win) The bug-2990 test used regex .test() against the raw markdown, which would have been satisfied by prose mentioning the token. Restructured to: - parseCleanupGitInvocations() returns ordered records with structured fields (verb, targetsReviewfixBranch, isMergeFfOnly, isBranchDelete) - assert exactly-one merge --ff-only AND exactly-one branch -D - assert merge precedes branch-delete in execution order - parse the sentinel JSON.stringify call to extract field names and assert reviewfix_branch is among them Added 2 new tests for the recovery-block invariant: parses the recovery node -e block and asserts it extracts parsed.reviewfix_branch alongside parsed.worktree_path; and asserts the recovery shell calls `git branch -D "$prior_branch"`. * test(#2990): add allow-test-rule annotation for product-text parsing (CR follow-up) The lint-tests CI catch flagged md.match() in the new structural-IR test suite. The .match() calls extract typed fields (cleanup-tail git invocation records, sentinel JSON field names, recovery-block node script content) from agents/gsd-code-fixer.md — which IS the deployed agent product. Asserting on those typed fields tests the runtime contract, not source code internals. source-text-is-the-product is the correct classification per the existing convention (matches thread-session-management.test.cjs and the others reclassified in PR #2985's CR follow-up). * chore(#3001): drop direct CHANGELOG.md edit; release entry now lives in .changeset/ The changeset-fragment workflow (#2975) renders fragments into CHANGELOG.md at release time. Direct edits to [Unreleased] on each PR caused merge conflicts on every concurrent PR. This commit restores CHANGELOG.md to match origin/main; the release entry for this fix is preserved in the .changeset/*.md fragment(s) on this branch, which the release workflow consolidates.
This commit is contained in:
@@ -231,39 +231,63 @@ test -n "$branch" || { echo "Detached HEAD is not supported for review-fix (#268
|
||||
sentinel="${phase_dir}/.review-fix-recovery-pending.json"
|
||||
if [ -f "$sentinel" ]; then
|
||||
echo "Detected pre-existing recovery sentinel from a prior interrupted run: $sentinel"
|
||||
prior_wt=$(node -e '
|
||||
# Recovery must extract BOTH worktree_path AND reviewfix_branch (#3001 CR):
|
||||
# if a prior run died after `git worktree remove` but before
|
||||
# `git branch -D`, the orphan branch survives and clutters `git branch`
|
||||
# output forever. Emit both fields newline-separated so we can read them
|
||||
# independently.
|
||||
prior_recovery=$(node -e '
|
||||
const fs = require("fs");
|
||||
try {
|
||||
const parsed = JSON.parse(fs.readFileSync(process.argv[1], "utf-8"));
|
||||
process.stdout.write(parsed.worktree_path || "");
|
||||
process.stdout.write((parsed.worktree_path || "") + "\n" + (parsed.reviewfix_branch || ""));
|
||||
} catch (err) {
|
||||
process.stderr.write(`Warning: malformed recovery sentinel ${process.argv[1]}: ${err.message}\n`);
|
||||
process.stdout.write("");
|
||||
process.stdout.write("\n");
|
||||
}
|
||||
' "$sentinel")
|
||||
prior_wt="$(printf '%s' "$prior_recovery" | sed -n '1p')"
|
||||
prior_branch="$(printf '%s' "$prior_recovery" | sed -n '2p')"
|
||||
if [ -n "$prior_wt" ] && git worktree list --porcelain | grep -q "^worktree $prior_wt$"; then
|
||||
echo "Removing orphan worktree from prior run: $prior_wt"
|
||||
git worktree remove "$prior_wt" --force || true
|
||||
fi
|
||||
if [ -n "$prior_branch" ]; then
|
||||
# Best-effort: branch may already be gone (cleaned by an earlier
|
||||
# partial recovery, or never created if `git worktree add -b` itself
|
||||
# failed). `|| true` keeps recovery non-fatal.
|
||||
echo "Removing orphan reviewfix branch from prior run: $prior_branch"
|
||||
git branch -D "$prior_branch" 2>/dev/null || true
|
||||
fi
|
||||
rm -f "$sentinel"
|
||||
fi
|
||||
|
||||
wt=$(mktemp -d "/tmp/sv-${padded_phase}-reviewfix-XXXXXX")
|
||||
git worktree add "$wt" "$branch"
|
||||
|
||||
# Create a temp branch from the current branch tip so the worktree
|
||||
# attaches to that NEW branch rather than the user's currently-checked-out
|
||||
# branch (#2990: git refuses to check out the same branch in two
|
||||
# worktrees by default; the original `git worktree add "$wt" "$branch"`
|
||||
# failed before the agent could do any work). The temp branch shares
|
||||
# history with $branch up to the moment of creation, so commits made
|
||||
# inside the worktree fast-forward $branch on cleanup.
|
||||
reviewfix_branch="gsd-reviewfix/${padded_phase}-$$"
|
||||
git worktree add -b "$reviewfix_branch" "$wt" "$branch"
|
||||
|
||||
# Write the recovery sentinel ONLY AFTER `git worktree add` succeeds.
|
||||
# Writing it before would leave a sentinel pointing at a worktree that does
|
||||
# not exist if `git worktree add` itself failed.
|
||||
node -e '
|
||||
const fs = require("fs");
|
||||
const [sentinelPath, worktree_path, branch, padded_phase] = process.argv.slice(1);
|
||||
const [sentinelPath, worktree_path, branch, reviewfix_branch, padded_phase] = process.argv.slice(1);
|
||||
fs.writeFileSync(sentinelPath, JSON.stringify({
|
||||
worktree_path,
|
||||
branch,
|
||||
reviewfix_branch,
|
||||
padded_phase,
|
||||
started_at: new Date().toISOString()
|
||||
}, null, 2));
|
||||
' "$sentinel" "$wt" "$branch" "$padded_phase"
|
||||
' "$sentinel" "$wt" "$branch" "$reviewfix_branch" "$padded_phase"
|
||||
|
||||
cd "$wt"
|
||||
```
|
||||
@@ -271,32 +295,64 @@ cd "$wt"
|
||||
Concrete steps:
|
||||
1. Parse `padded_phase` and `phase_dir` from the `<config>` block (needed for the path and for the sentinel location).
|
||||
2. Resolve the current branch: `branch=$(git branch --show-current)`. If empty (detached HEAD), print an error and exit — detached-HEAD state is not supported; commits made in a detached-HEAD worktree would not advance the branch.
|
||||
3. **Recovery check (#2839):** If `${phase_dir}/.review-fix-recovery-pending.json` already exists, a prior run was interrupted. Parse the JSON, attempt to remove the orphan worktree it points at (best-effort, with `--force`), then delete the stale sentinel before continuing. This makes a re-run of `/gsd-code-review-fix` self-healing.
|
||||
3. **Recovery check (#2839, #2990):** If `${phase_dir}/.review-fix-recovery-pending.json` already exists, a prior run was interrupted. Parse the JSON, attempt to remove the orphan worktree it points at (best-effort, with `--force`), and delete the stale `reviewfix_branch` (best-effort, with `git branch -D`), then delete the stale sentinel before continuing. This makes a re-run of `/gsd-code-review-fix` self-healing.
|
||||
4. Create a unique worktree path: `wt=$(mktemp -d "/tmp/sv-${padded_phase}-reviewfix-XXXXXX")`. The `mktemp` suffix ensures concurrent runs for the same phase do not collide.
|
||||
5. Run `git worktree add "$wt" "$branch"` — this attaches the worktree to the current branch so commits advance it.
|
||||
6. **Write the recovery sentinel** at `${phase_dir}/.review-fix-recovery-pending.json` containing `{worktree_path, branch, padded_phase, started_at}`. Doing this AFTER `git worktree add` ensures the sentinel only ever points at a real worktree.
|
||||
7. All subsequent file reads, edits, and commits happen inside `$wt`.
|
||||
5. Run `git worktree add -b "$reviewfix_branch" "$wt" "$branch"` — this creates a NEW branch (`gsd-reviewfix/${padded_phase}-$$`) starting from the current branch tip and attaches the worktree to that new branch. Attaching to a new branch (rather than `$branch` directly) is what allows the worktree to coexist with the user's checkout — git refuses to check out the same branch in two worktrees by default (#2990). Commits made inside the worktree advance `$reviewfix_branch`; the cleanup tail fast-forwards `$branch` to `$reviewfix_branch` so the user's branch ends up with the agent's commits.
|
||||
6. **Write the recovery sentinel** at `${phase_dir}/.review-fix-recovery-pending.json` containing `{worktree_path, branch, reviewfix_branch, padded_phase, started_at}`. Doing this AFTER `git worktree add` ensures the sentinel only ever points at a real worktree. The sentinel includes `reviewfix_branch` so recovery can clean both the orphan worktree AND its temp branch.
|
||||
7. All subsequent file reads, edits, and commits happen inside `$wt` (which is on `$reviewfix_branch`, not `$branch`).
|
||||
|
||||
**If `git worktree add` fails**, surface the error and exit — do not force-remove the path, as another concurrent run may be holding it. Do not write the sentinel (the worktree does not exist).
|
||||
**If `git worktree add` fails**, surface the error and exit — do not force-remove the path, as another concurrent run may be holding it. Do not write the sentinel (the worktree does not exist). Do not delete `$reviewfix_branch` either; if `-b` failed, no temp branch was created.
|
||||
|
||||
**Cleanup tail (transactional, ALWAYS — even on failure):** After writing REVIEW-FIX.md and before returning to the orchestrator, run the two-step cleanup in this exact order:
|
||||
**Cleanup tail (transactional, ALWAYS — even on failure):** After writing REVIEW-FIX.md and before returning to the orchestrator, run the cleanup in this exact order:
|
||||
|
||||
```bash
|
||||
# Step 1: drop the worktree FIRST. If this succeeds and the process is then
|
||||
# killed, the next run finds a sentinel pointing at a worktree that no longer
|
||||
# exists — the recovery branch handles this gracefully (best-effort remove +
|
||||
# sentinel delete). If we reversed the order (sentinel removed first, then
|
||||
# worktree remove), an interruption between the two steps would leave NO
|
||||
# sentinel and an orphan worktree — exactly the bug from #2839.
|
||||
# Step 1 (#2990): fast-forward $branch to capture the commits the agent
|
||||
# made on $reviewfix_branch. Run from the main repo (not $wt) — the user's
|
||||
# checkout owns $branch. --ff-only ensures we never silently drop or
|
||||
# rewrite history if the user committed to $branch concurrently; on
|
||||
# divergence, this fails loudly and the temp branch is left for the
|
||||
# user to inspect/merge manually. We deliberately resolve the main repo
|
||||
# path via `git worktree list --porcelain` rather than assuming $PWD,
|
||||
# because the agent ran inside $wt.
|
||||
# Strip the literal "worktree " prefix and print the rest of the line, then
|
||||
# exit on the first match. This preserves paths that contain spaces
|
||||
# (awk '$2' would truncate "/path/with spaces/repo" to "/path/with").
|
||||
main_repo="$(git worktree list --porcelain | awk '/^worktree / { sub(/^worktree /, ""); print; exit }')"
|
||||
ff_status=0
|
||||
# Capture the exit code of `git merge` directly. `if ! cmd; then ff_status=$?`
|
||||
# captures the exit code of the `!` operator (always 1 when the inner cmd
|
||||
# failed) — masking the real merge exit code. Use the success/else split
|
||||
# instead so $? in the else-branch is the merge command's exit code.
|
||||
if git -C "$main_repo" merge --ff-only "$reviewfix_branch" 2>&1; then
|
||||
ff_status=0
|
||||
else
|
||||
ff_status=$?
|
||||
echo "WARN: could not fast-forward $branch to $reviewfix_branch (exit $ff_status)."
|
||||
echo " The temp branch $reviewfix_branch is preserved for manual merge."
|
||||
fi
|
||||
|
||||
# Step 2: drop the worktree. If this succeeds and the process is then
|
||||
# killed, the next run finds a sentinel pointing at a worktree that no
|
||||
# longer exists — the recovery branch handles this gracefully (best-effort
|
||||
# remove + sentinel delete). If we reversed the order (sentinel removed
|
||||
# first, then worktree remove), an interruption between the two steps
|
||||
# would leave NO sentinel and an orphan worktree — exactly the bug from
|
||||
# #2839.
|
||||
git worktree remove "$wt" --force
|
||||
|
||||
# Step 2: drop the recovery sentinel ONLY after `git worktree remove` returns
|
||||
# successfully. This atomic-ish ordering is what makes the cleanup tail
|
||||
# transactional from the orchestrator's perspective.
|
||||
# Step 3: delete the temp branch ONLY if the fast-forward succeeded. If
|
||||
# it didn't, leaving the branch lets the user inspect/merge manually.
|
||||
if [ "$ff_status" -eq 0 ]; then
|
||||
git -C "$main_repo" branch -D "$reviewfix_branch" || true
|
||||
fi
|
||||
|
||||
# Step 4: drop the recovery sentinel ONLY after `git worktree remove`
|
||||
# returns successfully. This atomic-ish ordering is what makes the
|
||||
# cleanup tail transactional from the orchestrator's perspective.
|
||||
rm -f "$sentinel"
|
||||
```
|
||||
|
||||
This cleanup is unconditional — register it mentally as a finally-block obligation. If the agent exits early (config error, no findings, etc.), still run the two-step cleanup tail (`git worktree remove "$wt" --force` followed by `rm -f "$sentinel"`) before exit. The sentinel must NEVER be removed before `git worktree remove` succeeds.
|
||||
This cleanup is unconditional — register it mentally as a finally-block obligation. If the agent exits early (config error, no findings, etc.), still run the cleanup tail in order (fast-forward → worktree remove → temp branch delete → sentinel rm) before exit. The sentinel must NEVER be removed before `git worktree remove` succeeds. The temp branch must NEVER be deleted while the fast-forward is in a diverged state.
|
||||
</step>
|
||||
|
||||
<step name="load_context">
|
||||
@@ -528,9 +584,9 @@ _Iteration: {N}_
|
||||
|
||||
<critical_rules>
|
||||
|
||||
**ALWAYS run inside the isolated worktree** — set up via `branch=$(git branch --show-current)` + `wt=$(mktemp -d "/tmp/sv-${padded_phase}-reviewfix-XXXXXX")` + `git worktree add "$wt" "$branch"` at the very start (see `setup_worktree` step). Using `mktemp` ensures concurrent runs do not collide. Attaching to `$branch` (not `HEAD`) ensures commits advance the branch. Every file read, edit, and commit must happen inside `$wt`. Run `git worktree remove "$wt" --force` unconditionally when done (treat it as a finally block). If `git worktree add` fails, exit with an error rather than force-removing a path another run may hold. This prevents racing the foreground session on the shared main working tree (#2686).
|
||||
**ALWAYS run inside the isolated worktree** — set up via `branch=$(git branch --show-current)` + `wt=$(mktemp -d "/tmp/sv-${padded_phase}-reviewfix-XXXXXX")` + `git worktree add -b "$reviewfix_branch" "$wt" "$branch"` at the very start (see `setup_worktree` step). Using `mktemp` ensures concurrent runs do not collide. Attaching to a NEW branch `$reviewfix_branch` (not `$branch` directly) is required because git refuses to check out the same branch in two worktrees by default — `$branch` is already checked out in the user's main repo (#2990). Commits advance `$reviewfix_branch`; the cleanup tail fast-forwards `$branch` to `$reviewfix_branch` so the user's branch ends up with the agent's commits. Every file read, edit, and commit must happen inside `$wt`. Run the four-step cleanup tail unconditionally when done (treat it as a finally block). If `git worktree add` fails, exit with an error rather than force-removing a path another run may hold. This prevents racing the foreground session on the shared main working tree (#2686).
|
||||
|
||||
**ALWAYS run the transactional cleanup tail in order** (#2839): `git worktree remove "$wt" --force` MUST happen BEFORE `rm -f "$sentinel"` (the recovery sentinel at `${phase_dir}/.review-fix-recovery-pending.json`). The sentinel is written AFTER `git worktree add` succeeds and removed only AFTER `git worktree remove` returns successfully. This ordering is what makes the cleanup tail transactional — an interruption between commits and `git worktree remove` leaves the sentinel behind so a future run, `/gsd-resume-work`, or `/gsd-progress` can detect and complete the recovery. Reversing the order recreates the orphan-worktree bug.
|
||||
**ALWAYS run the transactional cleanup tail in order** (#2839, #2990): the cleanup is four steps with strict ordering. (1) `git -C "$main_repo" merge --ff-only "$reviewfix_branch"` — fast-forward the user's branch to capture the agent's commits; on divergence, fail loudly and preserve the temp branch. (2) `git worktree remove "$wt" --force`. (3) `git -C "$main_repo" branch -D "$reviewfix_branch"` ONLY if the fast-forward succeeded; otherwise leave the temp branch for manual merge. (4) `rm -f "$sentinel"` (the recovery sentinel at `${phase_dir}/.review-fix-recovery-pending.json`). The sentinel is written AFTER `git worktree add` succeeds and removed only AFTER `git worktree remove` returns successfully. The temp branch is deleted only when the fast-forward succeeded. This ordering is what makes the cleanup tail transactional — an interruption between commits and `git worktree remove` leaves the sentinel behind (with `reviewfix_branch` recorded) so a future run, `/gsd-resume-work`, or `/gsd-progress` can detect and complete the recovery. Reversing the order recreates the orphan-worktree bug.
|
||||
|
||||
**ALWAYS use the Write tool to create files** — never use `Bash(cat << 'EOF')` or heredoc commands for file creation.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user