Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-05-15
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
## Why

`gx branch start` could silently hand a second agent a stale worktree whose branch had already been merged and cleaned. The dirty-worktree-reuse heuristic only filtered on `agent/<slug>/` prefix, dirty state, and token-match — it never asked "has this branch already landed?". In a recent 12-lane parallel dispatch, agent B's `gx branch start` matched the still-on-disk worktree of agent A (whose PR had merged minutes earlier), pointing the new task at agent A's merged HEAD. The mismatch was visible on first edit, so it was caught manually, but the same race in an autonomous fleet would have produced a PR-time conflict instead of a clean lane.

## What Changes

- Add a `branch_published_then_remote_pruned` filter inside `find_matching_dirty_agent_worktree` (`templates/scripts/agent-branch-start.sh`). A candidate worktree is skipped when its branch's upstream config is set (so the branch was pushed at some point) but the matching `refs/remotes/<remote>/<branch>` ref no longer exists locally (so `gx branch finish ... --cleanup` already pruned the remote).
- That combination only arises after a finish that successfully published, merged, and pruned the branch — a freshly-created agent branch never has an upstream until `push -u`, so the "started, dirty, no commits yet" reuse case keeps working unchanged.
- Emit a clear stderr line (`Skipping merged-and-cleaned worktree: …`) so operators see why a stale lane was bypassed.
- Add a regression test (`test/branch.test.js`) that simulates the post-cleanup state (upstream config + missing remote-tracking ref + dirty file) and asserts a fresh lane is created instead of the merged one being reused.

## Impact

- Affected surfaces: `gx branch start` worktree-reuse heuristic only. Finish flow, prune flow, and lock-claim flow are untouched.
- Risk: low. The filter is gated on both a config key (`branch.<branch>.remote`) AND the absence of `refs/remotes/<remote>/<branch>`. The existing "fresh agent, dirty, no commits yet" reuse test (`branch.test.js`) continues to pass because that scenario never sets `branch.<branch>.remote`.
- Rollout: no flag; once shipped, every `gx branch start` call benefits. Operator-visible change is the new "Skipping merged-and-cleaned worktree" stderr line in the case it triggers; otherwise output is unchanged.
- No version bump required (bug fix in shell logic; no CLI surface change, no schema/API change).
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
## ADDED Requirements

### Requirement: skip-merged-worktree-on-reuse behavior
The system SHALL enforce skip-merged-worktree-on-reuse behavior as defined by this change.

#### Scenario: Baseline acceptance
- **WHEN** skip-merged-worktree-on-reuse behavior is exercised
- **THEN** the expected outcome is produced
- **AND** regressions are covered by tests.
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
## Definition of Done

This change is complete only when **all** of the following are true:

- Every checkbox below is checked.
- The agent branch reaches `MERGED` state on `origin` and the PR URL + state are recorded in the completion handoff.
- If any step blocks (test failure, conflict, ambiguous result), append a `BLOCKED:` line under section 4 explaining the blocker and **STOP**. Do not tick remaining cleanup boxes; do not silently skip the cleanup pipeline.

## Handoff

- Handoff: change=`agent-claude-skip-merged-worktree-on-reuse-2026-05-16-00-37`; branch=`agent/<your-name>/<branch-slug>`; scope=`TODO`; action=`continue this sandbox or finish cleanup after a usage-limit/manual takeover`.
- Copy prompt: Continue `agent-claude-skip-merged-worktree-on-reuse-2026-05-16-00-37` on branch `agent/<your-name>/<branch-slug>`. Work inside the existing sandbox, review `openspec/changes/agent-claude-skip-merged-worktree-on-reuse-2026-05-16-00-37/tasks.md`, continue from the current state instead of creating a new sandbox, and when the work is done run `gx branch finish --branch agent/<your-name>/<branch-slug> --base dev --via-pr --wait-for-merge --cleanup`.

## 1. Specification

- [ ] 1.1 Finalize proposal scope and acceptance criteria for `agent-claude-skip-merged-worktree-on-reuse-2026-05-16-00-37`.
- [ ] 1.2 Define normative requirements in `specs/skip-merged-worktree-on-reuse/spec.md`.

## 2. Implementation

- [ ] 2.1 Implement scoped behavior changes.
- [ ] 2.2 Add/update focused regression coverage.

## 3. Verification

- [ ] 3.1 Run targeted project verification commands.
- [ ] 3.2 Run `openspec validate agent-claude-skip-merged-worktree-on-reuse-2026-05-16-00-37 --type change --strict`.
- [ ] 3.3 Run `openspec validate --specs`.

## 4. Cleanup (mandatory; run before claiming completion)

- [ ] 4.1 Run the cleanup pipeline: `gx branch finish --branch agent/<your-name>/<branch-slug> --base dev --via-pr --wait-for-merge --cleanup`. This handles commit -> push -> PR create -> merge wait -> worktree prune in one invocation.
- [ ] 4.2 Record the PR URL and final merge state (`MERGED`) in the completion handoff.
- [ ] 4.3 Confirm the sandbox worktree is gone (`git worktree list` no longer shows the agent path; `git branch -a` shows no surviving local/remote refs for the branch).
26 changes: 26 additions & 0 deletions templates/scripts/agent-branch-start.sh
Original file line number Diff line number Diff line change
Expand Up @@ -508,6 +508,25 @@ managed_worktree_roots() {
done
}

branch_published_then_remote_pruned() {
# Detect the post-`gx branch finish --via-pr --cleanup` state: the agent
# branch was published (so upstream config is set), but the remote-tracking
# ref no longer exists (because `push origin --delete` ran during cleanup).
# That combination only arises after a finish that successfully merged the
# PR and pruned the remote branch — a freshly-created agent branch never
# has an upstream until publish, so this never false-positives on the
# "started, dirty, no commits yet" case that we want to keep reusable.
local repo="$1"
local branch="$2"
local upstream
upstream="$(git -C "$repo" config --get "branch.${branch}.remote" 2>/dev/null || true)"
[[ -n "$upstream" ]] || return 1
if git -C "$repo" show-ref --verify --quiet "refs/remotes/${upstream}/${branch}"; then
return 1
fi
return 0
}

find_matching_dirty_agent_worktree() {
local repo="$1"
local worktree_root_rel="$2"
Expand All @@ -528,6 +547,13 @@ find_matching_dirty_agent_worktree() {
fi
[[ "$branch" == "agent/${agent_slug}/"* ]] || continue
has_local_changes "$entry" || continue
# Skip merged-and-cleaned worktrees that happen to still be on disk
# (e.g. operator's shell is cwd'd inside; cleanup deferred). Reusing
# such a worktree would silently hand the next agent a stale HEAD.
if branch_published_then_remote_pruned "$repo" "$branch"; then
echo "[agent-branch-start] Skipping merged-and-cleaned worktree: ${entry} (branch ${branch} has no remote tracking ref)" >&2
continue
fi

descriptor="${branch#agent/${agent_slug}/}"
score="$(token_match_score "$task_slug" "$descriptor")"
Expand Down
32 changes: 32 additions & 0 deletions test/branch.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,38 @@ test('agent-branch-start reuses a single dirty matching managed worktree from th
);
});

test('agent-branch-start skips a merged-and-cleaned worktree instead of reusing it for a new task', () => {
const { repoDir } = createBootstrappedRepo({ committed: true });

let result = runBranchStart(['--tier', 'T1', 'tui plan watcher validator', 'bot'], repoDir, {
GUARDEX_OPENSPEC_AUTO_INIT: 'true',
});
assert.equal(result.status, 0, result.stderr || result.stdout);
const firstBranch = extractCreatedBranch(result.stdout);
const firstWorktree = extractCreatedWorktree(result.stdout);

// Simulate post-`gx branch finish --via-pr --cleanup` state on the
// primary checkout: the branch had been published (upstream config set)
// and the remote-tracking ref was then deleted by `push --delete`.
let cmd = runCmd('git', ['config', `branch.${firstBranch}.remote`, 'origin'], repoDir);
assert.equal(cmd.status, 0, cmd.stderr || cmd.stdout);
cmd = runCmd('git', ['config', `branch.${firstBranch}.merge`, `refs/heads/${firstBranch}`], repoDir);
assert.equal(cmd.status, 0, cmd.stderr || cmd.stdout);
// Leave the worktree dirty so it would otherwise match the reuse heuristic.
fs.writeFileSync(path.join(firstWorktree, 'leftover.txt'), 'merged-but-not-yet-pruned\n', 'utf8');

// A new task whose slug tokens overlap with the merged-and-cleaned branch.
result = runBranchStart(['--tier', 'T1', 'tui plan validator', 'bot'], repoDir, {
GUARDEX_OPENSPEC_AUTO_INIT: 'true',
});
assert.equal(result.status, 0, result.stderr || result.stdout);
assert.doesNotMatch(result.stdout, /Matched dirty managed worktree for requested task/);
assert.match(result.stderr, /Skipping merged-and-cleaned worktree/);
assert.match(result.stdout, /Created branch: agent\/(codex|claude)\/tui-plan-validator-/);
assert.notEqual(extractCreatedBranch(result.stdout), firstBranch);
assert.notEqual(extractCreatedWorktree(result.stdout), firstWorktree);
});

test('agent-branch-start creates a fresh branch when dirty matching worktrees are ambiguous', () => {
const { repoDir } = createBootstrappedRepo({ committed: true });

Expand Down
Loading