From 7396367523c3a50c474b41f8d6a0f6c6909a9930 Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Thu, 10 Sep 2026 12:24:07 +1000 Subject: [PATCH 01/17] chore(porch): bugfix-1637 init bugfix --- .../status.yaml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 codev/projects/bugfix-1637-afx-spawn-open-pr-collision-gu/status.yaml diff --git a/codev/projects/bugfix-1637-afx-spawn-open-pr-collision-gu/status.yaml b/codev/projects/bugfix-1637-afx-spawn-open-pr-collision-gu/status.yaml new file mode 100644 index 000000000..0b3654da5 --- /dev/null +++ b/codev/projects/bugfix-1637-afx-spawn-open-pr-collision-gu/status.yaml @@ -0,0 +1,14 @@ +id: bugfix-1637 +title: afx-spawn-open-pr-collision-gu +protocol: bugfix +phase: investigate +plan_phases: [] +current_plan_phase: null +gates: + pr: + status: pending +iteration: 1 +build_complete: false +history: [] +started_at: '2026-09-10T02:24:06.981Z' +updated_at: '2026-09-10T02:24:06.982Z' From e8f4ee0377adbda5ddd693d0def0013fc8899c08 Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Thu, 10 Sep 2026 12:27:31 +1000 Subject: [PATCH 02/17] chore(porch): bugfix-1637 fix phase-transition --- .../bugfix-1637-afx-spawn-open-pr-collision-gu/status.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/codev/projects/bugfix-1637-afx-spawn-open-pr-collision-gu/status.yaml b/codev/projects/bugfix-1637-afx-spawn-open-pr-collision-gu/status.yaml index 0b3654da5..00d304379 100644 --- a/codev/projects/bugfix-1637-afx-spawn-open-pr-collision-gu/status.yaml +++ b/codev/projects/bugfix-1637-afx-spawn-open-pr-collision-gu/status.yaml @@ -1,7 +1,7 @@ id: bugfix-1637 title: afx-spawn-open-pr-collision-gu protocol: bugfix -phase: investigate +phase: fix plan_phases: [] current_plan_phase: null gates: @@ -11,4 +11,4 @@ iteration: 1 build_complete: false history: [] started_at: '2026-09-10T02:24:06.981Z' -updated_at: '2026-09-10T02:24:06.982Z' +updated_at: '2026-09-10T02:27:31.138Z' From 64bf84339e58059baa445b67e8ab65c11b5da0aa Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Thu, 10 Sep 2026 12:38:50 +1000 Subject: [PATCH 03/17] Fix #1637: spawn open-PR collision guard filters to OPEN state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pr-search runs --state all (#759/#1619) so consult can find merged PRs, but the spawn collision guard counted every result as an open PR — refusing afx spawn when the referenced PRs were already merged. - github/gitlab pr-search.sh now emit a normalized `state` field (OPEN/MERGED/CLOSED); gitlab maps glab's lowercase `opened` to OPEN. - PrSearchItem gains optional `state`. - The guard filters to state === 'OPEN' before counting; a missing state (stale pr-search.sh override) is treated as open, preserving pre-fix behavior. --state all stays in the scripts; consult is unaffected. - Regression tests pair both consumers: guard ignores merged/closed, scripts keep merged MRs/PRs visible for consult. --- .../codev/scripts/forge/github/pr-search.sh | 6 ++- .../codev/scripts/forge/gitlab/pr-search.sh | 6 ++- .../__tests__/spawn-worktree.test.ts | 49 +++++++++++++++++++ .../src/agent-farm/commands/spawn-worktree.ts | 26 ++++++---- .../bugfix-759-pr-search-state-all.test.ts | 16 ++++++ packages/codev/src/lib/forge-contracts.ts | 9 ++++ 6 files changed, 100 insertions(+), 12 deletions(-) diff --git a/packages/codev/scripts/forge/github/pr-search.sh b/packages/codev/scripts/forge/github/pr-search.sh index edc2736d9..211a7f0bc 100755 --- a/packages/codev/scripts/forge/github/pr-search.sh +++ b/packages/codev/scripts/forge/github/pr-search.sh @@ -1,7 +1,9 @@ #!/bin/sh # Forge concept: pr-search (GitHub via gh CLI) # Input: CODEV_SEARCH_QUERY -# Output: JSON [{number, headRefName, baseRefName}] +# Output: JSON [{number, headRefName, baseRefName, state}] # --state all is required so the search includes merged/closed PRs; without it # `gh pr list` defaults to --state open and post-merge lookups return nothing (#759). -exec gh pr list --state all --search "$CODEV_SEARCH_QUERY" --json number,headRefName,baseRefName +# `state` (OPEN/MERGED/CLOSED) lets consumers that only want open PRs filter +# client-side — the spawn collision guard must ignore merged/closed ones (#1637). +exec gh pr list --state all --search "$CODEV_SEARCH_QUERY" --json number,headRefName,baseRefName,state diff --git a/packages/codev/scripts/forge/gitlab/pr-search.sh b/packages/codev/scripts/forge/gitlab/pr-search.sh index 8da4306c9..376777445 100755 --- a/packages/codev/scripts/forge/gitlab/pr-search.sh +++ b/packages/codev/scripts/forge/gitlab/pr-search.sh @@ -2,4 +2,8 @@ # Forge concept: pr-search (GitLab via glab CLI) # --all is required so the search includes merged/closed MRs; without it # `glab mr list` defaults to opened only and post-merge lookups return nothing (#759). -exec glab mr list --all --search "$CODEV_SEARCH_QUERY" --output json +# glab reports state lowercase (opened/merged/closed/locked); normalize to the +# GitHub convention (OPEN/MERGED/CLOSED) so consumers filter with one comparison. +# The spawn collision guard keys off state === "OPEN" to ignore merged MRs (#1637). +glab mr list --all --search "$CODEV_SEARCH_QUERY" --output json \ + | jq 'map(. + {state: (if ((.state // "") | ascii_downcase) == "opened" then "OPEN" else ((.state // "") | ascii_upcase) end)})' diff --git a/packages/codev/src/agent-farm/__tests__/spawn-worktree.test.ts b/packages/codev/src/agent-farm/__tests__/spawn-worktree.test.ts index 7424b8675..ec23b88a1 100644 --- a/packages/codev/src/agent-farm/__tests__/spawn-worktree.test.ts +++ b/packages/codev/src/agent-farm/__tests__/spawn-worktree.test.ts @@ -650,6 +650,55 @@ describe('spawn-worktree', () => { ); }); + it('ignores merged PRs referencing the issue (#1637)', async () => { + // pr-search runs --state all (#759), so merged PRs come back too. The guard + // must not count them as open, else it blocks spawns on already-resolved work. + const { existsSync } = await import('node:fs'); + vi.mocked(existsSync).mockReturnValueOnce(false); + executeForgeCommandMock.mockResolvedValueOnce([ + { number: 4747, headRefName: 'fix-a', state: 'MERGED' }, + { number: 4766, headRefName: 'fix-b', state: 'CLOSED' }, + ]); + const { fatal } = await import('../utils/logger.js'); + await expect( + checkBugfixCollisions(4750, '/tmp/wt', baseIssue, false), + ).resolves.toBeUndefined(); + expect(fatal).not.toHaveBeenCalled(); + }); + + it('still fatals for an open PR referencing the issue (#1637)', async () => { + const { existsSync } = await import('node:fs'); + vi.mocked(existsSync).mockReturnValueOnce(false); + executeForgeCommandMock.mockResolvedValueOnce([{ number: 99, headRefName: 'fix-42', state: 'OPEN' }]); + const { fatal } = await import('../utils/logger.js'); + await checkBugfixCollisions(42, '/tmp/wt', baseIssue, false); + expect(fatal).toHaveBeenCalledWith(expect.stringContaining('open PR')); + }); + + it('counts only the open PRs when results mix open and merged (#1637)', async () => { + const { existsSync } = await import('node:fs'); + vi.mocked(existsSync).mockReturnValueOnce(false); + executeForgeCommandMock.mockResolvedValueOnce([ + { number: 10, headRefName: 'merged', state: 'MERGED' }, + { number: 11, headRefName: 'open', state: 'OPEN' }, + ]); + const { fatal } = await import('../utils/logger.js'); + await checkBugfixCollisions(42, '/tmp/wt', baseIssue, false); + expect(fatal).toHaveBeenCalledWith(expect.stringContaining('Found 1 open PR(s)')); + }); + + it('treats a missing state as open for stale pr-search overrides (#1637)', async () => { + // A project-local pr-search.sh predating the state field returns no state; + // the guard keeps the conservative pre-fix behavior rather than silently + // dropping the collision check. + const { existsSync } = await import('node:fs'); + vi.mocked(existsSync).mockReturnValueOnce(false); + executeForgeCommandMock.mockResolvedValueOnce([{ number: 99, headRefName: 'fix-42' }]); + const { fatal } = await import('../utils/logger.js'); + await checkBugfixCollisions(42, '/tmp/wt', baseIssue, false); + expect(fatal).toHaveBeenCalledWith(expect.stringContaining('open PR')); + }); + it('warns when issue is already closed', async () => { const { existsSync } = await import('node:fs'); vi.mocked(existsSync).mockReturnValueOnce(false); diff --git a/packages/codev/src/agent-farm/commands/spawn-worktree.ts b/packages/codev/src/agent-farm/commands/spawn-worktree.ts index 471cd4bc8..bf2d9fda0 100644 --- a/packages/codev/src/agent-farm/commands/spawn-worktree.ts +++ b/packages/codev/src/agent-farm/commands/spawn-worktree.ts @@ -587,20 +587,28 @@ export async function checkBugfixCollisions( } } - // 3. Check for open PRs referencing this issue via pr-search concept + // 3. Check for open PRs referencing this issue via pr-search concept. + // pr-search runs `--state all` (#759) so consult can find merged PRs; this guard + // must ignore merged/closed ones, else it blocks spawns on already-resolved work + // (#1637). Filter to state === "OPEN"; treat a missing state (stale pr-search.sh + // override predating the field) as unknown → keep counting it, the conservative + // pre-#1637 behavior, with --force still available. try { const result = await executeForgeCommand('pr-search', { CODEV_SEARCH_QUERY: `in:body #${issueNumber}`, }, { forgeConfig }); - if (result && Array.isArray(result) && result.length > 0) { - const openPRs = result as Array<{ number: number; title?: string; headRefName?: string }>; - if (!force) { - const prList = openPRs.slice(0, 5).map((pr) => - ` - PR #${pr.number}${pr.title ? `: ${pr.title}` : ''}`, - ).join('\n'); - fatal(`Found ${openPRs.length} open PR(s) referencing issue #${issueNumber}:\n${prList}\nUse --force to proceed anyway.`); + if (result && Array.isArray(result)) { + const prs = result as Array<{ number: number; title?: string; headRefName?: string; state?: string }>; + const openPRs = prs.filter((pr) => pr.state === undefined || pr.state === 'OPEN'); + if (openPRs.length > 0) { + if (!force) { + const prList = openPRs.slice(0, 5).map((pr) => + ` - PR #${pr.number}${pr.title ? `: ${pr.title}` : ''}`, + ).join('\n'); + fatal(`Found ${openPRs.length} open PR(s) referencing issue #${issueNumber}:\n${prList}\nUse --force to proceed anyway.`); + } + logger.warn(`Warning: Found ${openPRs.length} open PR(s) referencing issue - proceeding with --force`); } - logger.warn(`Warning: Found ${openPRs.length} open PR(s) referencing issue - proceeding with --force`); } } catch { // Non-fatal: continue if PR search concept unavailable diff --git a/packages/codev/src/commands/porch/__tests__/bugfix-759-pr-search-state-all.test.ts b/packages/codev/src/commands/porch/__tests__/bugfix-759-pr-search-state-all.test.ts index bcbf11e32..b78cfd20d 100644 --- a/packages/codev/src/commands/porch/__tests__/bugfix-759-pr-search-state-all.test.ts +++ b/packages/codev/src/commands/porch/__tests__/bugfix-759-pr-search-state-all.test.ts @@ -32,6 +32,14 @@ describe('pr-search forge scripts', () => { const content = fs.readFileSync(scriptPath, 'utf-8'); expect(content).toContain('--search "$CODEV_SEARCH_QUERY"'); }); + + it('emits state so the spawn guard can filter merged PRs (#1637)', () => { + // Paired-consumer contract: --state all keeps merged PRs visible for consult + // (#759), while the `state` field lets the spawn collision guard ignore them. + const content = fs.readFileSync(scriptPath, 'utf-8'); + expect(content).toContain('--state all'); + expect(content).toMatch(/--json\s+\S*\bstate\b/); + }); }); describe('gitlab/pr-search.sh', () => { @@ -50,5 +58,13 @@ describe('pr-search forge scripts', () => { const content = fs.readFileSync(scriptPath, 'utf-8'); expect(content).toContain('--search "$CODEV_SEARCH_QUERY"'); }); + + it('normalizes opened MRs to the OPEN state so the guard can filter (#1637)', () => { + // glab reports state lowercase; the script maps opened -> OPEN so the spawn + // collision guard's state === "OPEN" comparison is forge-agnostic. + const content = fs.readFileSync(scriptPath, 'utf-8'); + expect(content).toContain('--all'); + expect(content).toContain('"OPEN"'); + }); }); }); diff --git a/packages/codev/src/lib/forge-contracts.ts b/packages/codev/src/lib/forge-contracts.ts index 079c4be05..0f6bb19c1 100644 --- a/packages/codev/src/lib/forge-contracts.ts +++ b/packages/codev/src/lib/forge-contracts.ts @@ -149,6 +149,15 @@ export type RecentlyMergedResult = MergedPrItem[]; export interface PrSearchItem { number: number; headRefName: string; + /** + * PR/MR state, normalized to the GitHub convention: `OPEN` | `MERGED` | + * `CLOSED`. Because `pr-search` runs `--state all` (#759), consumers that + * only want open PRs must filter on this — the spawn collision guard ignores + * non-open results (#1637). Optional: a stale project-local override of + * `pr-search.sh` may predate this field, so consumers treat its absence as + * "unknown" rather than assuming a state. + */ + state?: string; } /** Output of the `pr-search` concept command. */ From 0b16436b571edcae00c9e9701fd837a235cc8ec1 Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Thu, 10 Sep 2026 12:39:40 +1000 Subject: [PATCH 04/17] chore(porch): bugfix-1637 pr phase-transition --- .../bugfix-1637-afx-spawn-open-pr-collision-gu/status.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/codev/projects/bugfix-1637-afx-spawn-open-pr-collision-gu/status.yaml b/codev/projects/bugfix-1637-afx-spawn-open-pr-collision-gu/status.yaml index 00d304379..b00800673 100644 --- a/codev/projects/bugfix-1637-afx-spawn-open-pr-collision-gu/status.yaml +++ b/codev/projects/bugfix-1637-afx-spawn-open-pr-collision-gu/status.yaml @@ -1,7 +1,7 @@ id: bugfix-1637 title: afx-spawn-open-pr-collision-gu protocol: bugfix -phase: fix +phase: pr plan_phases: [] current_plan_phase: null gates: @@ -11,4 +11,4 @@ iteration: 1 build_complete: false history: [] started_at: '2026-09-10T02:24:06.981Z' -updated_at: '2026-09-10T02:27:31.138Z' +updated_at: '2026-09-10T02:39:39.991Z' From 4e8fffa83e598f9361f8dd7f688794e90717004f Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Thu, 10 Sep 2026 12:40:32 +1000 Subject: [PATCH 05/17] chore(porch): bugfix-1637 record PR #1673 --- .../bugfix-1637-afx-spawn-open-pr-collision-gu/status.yaml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/codev/projects/bugfix-1637-afx-spawn-open-pr-collision-gu/status.yaml b/codev/projects/bugfix-1637-afx-spawn-open-pr-collision-gu/status.yaml index b00800673..233c67fa1 100644 --- a/codev/projects/bugfix-1637-afx-spawn-open-pr-collision-gu/status.yaml +++ b/codev/projects/bugfix-1637-afx-spawn-open-pr-collision-gu/status.yaml @@ -11,4 +11,9 @@ iteration: 1 build_complete: false history: [] started_at: '2026-09-10T02:24:06.981Z' -updated_at: '2026-09-10T02:39:39.991Z' +updated_at: '2026-09-10T02:40:32.739Z' +pr_history: + - phase: pr + pr_number: 1673 + branch: builder/bugfix-1637 + created_at: '2026-09-10T02:40:32.738Z' From b268b2ec3588d1f20de5f9b0f10609ac7b6a3478 Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Thu, 10 Sep 2026 12:40:38 +1000 Subject: [PATCH 06/17] [Bugfix #1637] Add builder thread log --- codev/state/bugfix-1637_thread.md | 60 +++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 codev/state/bugfix-1637_thread.md diff --git a/codev/state/bugfix-1637_thread.md b/codev/state/bugfix-1637_thread.md new file mode 100644 index 000000000..9841e9c65 --- /dev/null +++ b/codev/state/bugfix-1637_thread.md @@ -0,0 +1,60 @@ +# bugfix-1637 — spawn open-PR collision guard counts merged PRs + +Issue #1637. Strict BUGFIX. Worktree: .builders/bugfix-1637. + +## Investigate (complete) + +**Symptom**: `afx spawn` refuses with "Found N open PR(s) referencing issue #X" when the +referenced PRs are actually *merged*, forcing `--force`. + +**Root cause** — a #1619 (3.3.3) side effect: +- Guard: `checkBugfixCollisions` in `packages/codev/src/agent-farm/commands/spawn-worktree.ts` + lines 590-607. It calls the `pr-search` forge concept and treats **every** result as an + open PR (`openPRs.length` → fatal). +- `pr-search` was changed to `--state all` (github) / `--all` (gitlab) by #1619 so + `consult --type pr` finds merged PRs (#759). Correct for consult; the guard inherited + merged/closed PRs into its "open" count. +- Compounding: `scripts/forge/github/pr-search.sh` outputs only + `number,headRefName,baseRefName` — **no `state`** — so the guard can't filter client-side. + +**Two consumers, opposite needs** (pinned by a paired regression test): +- consult `findPRForIssue` (consult/index.ts:2055) — wants `--state all`; takes first result. +- spawn guard — must ignore non-OPEN. + +**Fix plan** (scope ~small, fits BUGFIX): +1. `scripts/forge/github/pr-search.sh`: add `state` to `--json` fields (gh → OPEN/MERGED/CLOSED). +2. `scripts/forge/gitlab/pr-search.sh`: add `state`, normalize glab's lowercase + `opened`→`OPEN` via jq to match github convention. +3. `PrSearchItem` contract (forge-contracts.ts:149): add optional `state?: string`. +4. Guard: filter to items that are OPEN **or missing state** (conservative — legacy overrides + without `state` keep pre-fix behavior; existing test at spawn-worktree.test.ts:642 passes an + item with no `state` and expects a fatal, so "missing = assume open" is required for + backward-compat). Keep `--state all` in scripts — consult needs it, the guard filters. +5. Regression test pairing both consumers: guard ignores MERGED/CLOSED, consult still sees them. + +Scripts ship via `packages/codev/package.json` `files: [scripts/forge]`; no codev-skeleton copy. + +## Fix (complete) + +Implemented exactly the 5-point plan: +- github/pr-search.sh: `--json …,state` (gh → OPEN/MERGED/CLOSED). +- gitlab/pr-search.sh: jq normalizes glab's lowercase `opened`→`OPEN` (missing → ""). +- forge-contracts.ts: `PrSearchItem.state?: string`, documented as optional/normalized. +- spawn-worktree.ts guard: `filter(pr => pr.state === undefined || pr.state === 'OPEN')`; + missing state kept as "assume open" (conservative — existing test passes a stateless item + and expects a fatal). +- Tests: 4 new guard cases in spawn-worktree.test.ts (merged ignored, open fatals, mixed + counts only open, missing-state still fatals) + 2 script-contract cases in + bugfix-759-pr-search-state-all.test.ts (github emits state; gitlab maps to OPEN). + +**Verification**: +- Regression proven: temporarily reverted the filter → the merged-ignored and mixed-count + tests FAIL; restored → all 4 PASS. +- tsc --noEmit clean. Full `pnpm build` clean (skeleton + dashboard copy). +- Full non-e2e suite from packages/codev: 6059 passed, 0 failed. Remaining failures are all + `.e2e.test.ts`/tower-shellper integration needing live services — pre-existing, environmental, + none touch forge/pr-search/spawn. + +NOTE on running tests: run vitest FROM packages/codev (the vitest-setup.ts sandbox-pin path is +cwd-relative); a root-level `npx vitest packages/codev` spuriously fails the isolation/metrics +tests because the setup file doesn't resolve. From a3b072ab872b2f02620c6c78c716c4338b5f8e6c Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Thu, 10 Sep 2026 12:45:56 +1000 Subject: [PATCH 07/17] [Bugfix #1637] CMAP: harden gitlab exit status and blank-state fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses non-blocking CMAP (claude) nits: - gitlab/pr-search.sh captures glab output before piping to jq, so a failed glab surfaces instead of masquerading as an empty list (POSIX sh has no pipefail) — matching the #1645 convention in pr-list.sh. - Guard predicate broadened from `state === undefined` to `!pr.state` so the gitlab jq's empty-string fallback is also treated as open (conservative), matching the documented intent. Adds a blank-state regression test. --- packages/codev/scripts/forge/gitlab/pr-search.sh | 6 ++++-- .../src/agent-farm/__tests__/spawn-worktree.test.ts | 11 +++++++++++ .../codev/src/agent-farm/commands/spawn-worktree.ts | 9 +++++---- packages/codev/src/lib/forge-contracts.ts | 3 ++- 4 files changed, 22 insertions(+), 7 deletions(-) diff --git a/packages/codev/scripts/forge/gitlab/pr-search.sh b/packages/codev/scripts/forge/gitlab/pr-search.sh index 376777445..676a42b4a 100755 --- a/packages/codev/scripts/forge/gitlab/pr-search.sh +++ b/packages/codev/scripts/forge/gitlab/pr-search.sh @@ -5,5 +5,7 @@ # glab reports state lowercase (opened/merged/closed/locked); normalize to the # GitHub convention (OPEN/MERGED/CLOSED) so consumers filter with one comparison. # The spawn collision guard keys off state === "OPEN" to ignore merged MRs (#1637). -glab mr list --all --search "$CODEV_SEARCH_QUERY" --output json \ - | jq 'map(. + {state: (if ((.state // "") | ascii_downcase) == "opened" then "OPEN" else ((.state // "") | ascii_upcase) end)})' +# Not `glab … | jq`: POSIX sh has no pipefail, so the caller would see jq's exit +# status and a failed glab would look like a successful empty list (#1645). +out="$(glab mr list --all --search "$CODEV_SEARCH_QUERY" --output json)" || exit 1 +printf '%s' "$out" | jq 'map(. + {state: (if ((.state // "") | ascii_downcase) == "opened" then "OPEN" else ((.state // "") | ascii_upcase) end)})' diff --git a/packages/codev/src/agent-farm/__tests__/spawn-worktree.test.ts b/packages/codev/src/agent-farm/__tests__/spawn-worktree.test.ts index ec23b88a1..0bc8e5066 100644 --- a/packages/codev/src/agent-farm/__tests__/spawn-worktree.test.ts +++ b/packages/codev/src/agent-farm/__tests__/spawn-worktree.test.ts @@ -699,6 +699,17 @@ describe('spawn-worktree', () => { expect(fatal).toHaveBeenCalledWith(expect.stringContaining('open PR')); }); + it('treats a blank state as open (gitlab pr-search leaves it empty) (#1637)', async () => { + // gitlab/pr-search.sh maps a missing glab state to "" via jq; the guard must + // treat that the same as absent — conservatively counting it as open. + const { existsSync } = await import('node:fs'); + vi.mocked(existsSync).mockReturnValueOnce(false); + executeForgeCommandMock.mockResolvedValueOnce([{ number: 99, headRefName: 'fix-42', state: '' }]); + const { fatal } = await import('../utils/logger.js'); + await checkBugfixCollisions(42, '/tmp/wt', baseIssue, false); + expect(fatal).toHaveBeenCalledWith(expect.stringContaining('open PR')); + }); + it('warns when issue is already closed', async () => { const { existsSync } = await import('node:fs'); vi.mocked(existsSync).mockReturnValueOnce(false); diff --git a/packages/codev/src/agent-farm/commands/spawn-worktree.ts b/packages/codev/src/agent-farm/commands/spawn-worktree.ts index bf2d9fda0..b6bce2b4f 100644 --- a/packages/codev/src/agent-farm/commands/spawn-worktree.ts +++ b/packages/codev/src/agent-farm/commands/spawn-worktree.ts @@ -590,16 +590,17 @@ export async function checkBugfixCollisions( // 3. Check for open PRs referencing this issue via pr-search concept. // pr-search runs `--state all` (#759) so consult can find merged PRs; this guard // must ignore merged/closed ones, else it blocks spawns on already-resolved work - // (#1637). Filter to state === "OPEN"; treat a missing state (stale pr-search.sh - // override predating the field) as unknown → keep counting it, the conservative - // pre-#1637 behavior, with --force still available. + // (#1637). Filter to state === "OPEN"; treat an absent/empty state (a stale + // pr-search.sh override predating the field, or a forge script that leaves it + // blank) as unknown → keep counting it, the conservative pre-#1637 behavior, + // with --force still available. try { const result = await executeForgeCommand('pr-search', { CODEV_SEARCH_QUERY: `in:body #${issueNumber}`, }, { forgeConfig }); if (result && Array.isArray(result)) { const prs = result as Array<{ number: number; title?: string; headRefName?: string; state?: string }>; - const openPRs = prs.filter((pr) => pr.state === undefined || pr.state === 'OPEN'); + const openPRs = prs.filter((pr) => !pr.state || pr.state === 'OPEN'); if (openPRs.length > 0) { if (!force) { const prList = openPRs.slice(0, 5).map((pr) => diff --git a/packages/codev/src/lib/forge-contracts.ts b/packages/codev/src/lib/forge-contracts.ts index 0f6bb19c1..8ba1b600a 100644 --- a/packages/codev/src/lib/forge-contracts.ts +++ b/packages/codev/src/lib/forge-contracts.ts @@ -154,7 +154,8 @@ export interface PrSearchItem { * `CLOSED`. Because `pr-search` runs `--state all` (#759), consumers that * only want open PRs must filter on this — the spawn collision guard ignores * non-open results (#1637). Optional: a stale project-local override of - * `pr-search.sh` may predate this field, so consumers treat its absence as + * `pr-search.sh` may predate this field (and the gitlab script leaves it + * blank when glab omits it), so consumers treat an absent or empty value as * "unknown" rather than assuming a state. */ state?: string; From 84ce1fb94201115a09f2d156e8a9f0cd2cdd3e24 Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Thu, 10 Sep 2026 12:46:07 +1000 Subject: [PATCH 08/17] [Bugfix #1637] Thread: record PR phase + CMAP verdicts --- codev/state/bugfix-1637_thread.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/codev/state/bugfix-1637_thread.md b/codev/state/bugfix-1637_thread.md index 9841e9c65..854a95592 100644 --- a/codev/state/bugfix-1637_thread.md +++ b/codev/state/bugfix-1637_thread.md @@ -58,3 +58,20 @@ Implemented exactly the 5-point plan: NOTE on running tests: run vitest FROM packages/codev (the vitest-setup.ts sandbox-pin path is cwd-relative); a root-level `npx vitest packages/codev` spuriously fails the isolation/metrics tests because the setup file doesn't resolve. + +## PR (complete) + +PR #1673 opened (`Fixes #1637`), recorded with porch. Thread committed. + +CMAP (all scoped with `--project-id bugfix-1637`; bare invocation hit "Multiple +projects found"): +- gemini = APPROVE (HIGH, no key issues) +- codex = APPROVE (HIGH, no key issues) +- claude = APPROVE (HIGH) — 2 non-blocking nits addressed in commit a3b072ab8: + (1) gitlab/pr-search.sh now captures glab output before piping to jq (#1645 + convention) so a glab failure surfaces; (2) guard predicate broadened to + `!pr.state` so gitlab's empty-string state fallback is treated as open + (conservative), with a blank-state regression test. + +All three APPROVE. Handing off at the pr gate — awaiting architect approval +before merge (CMAP APPROVE is not merge authorization). From 83756b9c5f9335565e23fe4c40fb81d652120b0d Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Thu, 10 Sep 2026 12:46:18 +1000 Subject: [PATCH 09/17] chore(porch): bugfix-1637 pr gate-requested --- .../bugfix-1637-afx-spawn-open-pr-collision-gu/status.yaml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/codev/projects/bugfix-1637-afx-spawn-open-pr-collision-gu/status.yaml b/codev/projects/bugfix-1637-afx-spawn-open-pr-collision-gu/status.yaml index 233c67fa1..18a5b41a6 100644 --- a/codev/projects/bugfix-1637-afx-spawn-open-pr-collision-gu/status.yaml +++ b/codev/projects/bugfix-1637-afx-spawn-open-pr-collision-gu/status.yaml @@ -7,13 +7,15 @@ current_plan_phase: null gates: pr: status: pending + requested_at: '2026-09-10T02:46:18.290Z' iteration: 1 build_complete: false history: [] started_at: '2026-09-10T02:24:06.981Z' -updated_at: '2026-09-10T02:40:32.739Z' +updated_at: '2026-09-10T02:46:18.291Z' pr_history: - phase: pr pr_number: 1673 branch: builder/bugfix-1637 created_at: '2026-09-10T02:40:32.738Z' +pr_ready_for_human: true From a834106b1f6d3d228d59ec5fc60370d1322d6dee Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Thu, 10 Sep 2026 12:51:41 +1000 Subject: [PATCH 10/17] [Bugfix #1637] Fix CI: declare glab as gitlab pr-search executable (#1645) The capture-then-pipe shape from the previous commit hid `glab` from extractExecutable's first-line heuristic (it skips the `out=` assignment and lands on the `printf` builtin), so forge.test.ts's #1645 check resolved the concept to a builtin. Add an explicit `# forge-executable: glab` declaration, matching github/pr-list.sh's convention. --- packages/codev/scripts/forge/gitlab/pr-search.sh | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/codev/scripts/forge/gitlab/pr-search.sh b/packages/codev/scripts/forge/gitlab/pr-search.sh index 676a42b4a..f1a4b54a5 100755 --- a/packages/codev/scripts/forge/gitlab/pr-search.sh +++ b/packages/codev/scripts/forge/gitlab/pr-search.sh @@ -7,5 +7,9 @@ # The spawn collision guard keys off state === "OPEN" to ignore merged MRs (#1637). # Not `glab … | jq`: POSIX sh has no pipefail, so the caller would see jq's exit # status and a failed glab would look like a successful empty list (#1645). +# The capture-then-pipe shape hides `glab` from extractExecutable's first-line +# heuristic (it lands on the `printf` builtin), so declare the backend explicitly +# — same as github/pr-list.sh (#1645). +# forge-executable: glab out="$(glab mr list --all --search "$CODEV_SEARCH_QUERY" --output json)" || exit 1 printf '%s' "$out" | jq 'map(. + {state: (if ((.state // "") | ascii_downcase) == "opened" then "OPEN" else ((.state // "") | ascii_upcase) end)})' From 7b26c767b87478b712cdadd86a01dab016213c23 Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Thu, 10 Sep 2026 12:52:09 +1000 Subject: [PATCH 11/17] [Bugfix #1637] Thread: record CI #1645 fix --- codev/state/bugfix-1637_thread.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/codev/state/bugfix-1637_thread.md b/codev/state/bugfix-1637_thread.md index 854a95592..5004dae1c 100644 --- a/codev/state/bugfix-1637_thread.md +++ b/codev/state/bugfix-1637_thread.md @@ -75,3 +75,17 @@ projects found"): All three APPROVE. Handing off at the pr gate — awaiting architect approval before merge (CMAP APPROVE is not merge authorization). + +## CI fix (post-gate-request) + +Architect flagged real CI red on 83756b9c5: `forge.test.ts` #1645 check +'no built-in provider resolves a concept to a shell builtin'. Cause: my CMAP +follow-up (a3b072ab8) reshaped gitlab/pr-search.sh to capture-then-pipe, which +hid `glab` from extractExecutable's first-line heuristic (it skips the `out=` +assignment, lands on the `printf` builtin). Fix (a834106b1): added explicit +`# forge-executable: glab` declaration, matching github/pr-list.sh. + +Lesson: after changing a forge SCRIPT's shape, re-run forge.test.ts — I only +re-ran guard+script tests on the CMAP follow-up. Full non-e2e suite now 6060 +passing; forge.test.ts 80 passing. Awaiting CI 7/7 on a834106b1 before +re-requesting the gate. From 48df843c35adab2e6cb667dedba9627ead351bc5 Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Fri, 11 Sep 2026 20:10:44 +1000 Subject: [PATCH 12/17] [Bugfix #1637] gitlab: map locked MR state to OPEN (err toward safety) GitLab's MR state enum is opened/closed/merged/locked. `locked` is a transient merging state that is still effectively open, so normalize it to OPEN alongside `opened`. The spawn guard then counts a locked MR as a collision (recoverable via --force) rather than silently skipping a live one. Pins the mapping in the gitlab pr-search contract test. --- packages/codev/scripts/forge/gitlab/pr-search.sh | 4 +++- .../porch/__tests__/bugfix-759-pr-search-state-all.test.ts | 7 +++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/codev/scripts/forge/gitlab/pr-search.sh b/packages/codev/scripts/forge/gitlab/pr-search.sh index f1a4b54a5..9d9e845e9 100755 --- a/packages/codev/scripts/forge/gitlab/pr-search.sh +++ b/packages/codev/scripts/forge/gitlab/pr-search.sh @@ -5,6 +5,8 @@ # glab reports state lowercase (opened/merged/closed/locked); normalize to the # GitHub convention (OPEN/MERGED/CLOSED) so consumers filter with one comparison. # The spawn collision guard keys off state === "OPEN" to ignore merged MRs (#1637). +# `locked` (a transient merging state) maps to OPEN too: the guard should err +# toward the recoverable --force prompt, never silently skip a live collision. # Not `glab … | jq`: POSIX sh has no pipefail, so the caller would see jq's exit # status and a failed glab would look like a successful empty list (#1645). # The capture-then-pipe shape hides `glab` from extractExecutable's first-line @@ -12,4 +14,4 @@ # — same as github/pr-list.sh (#1645). # forge-executable: glab out="$(glab mr list --all --search "$CODEV_SEARCH_QUERY" --output json)" || exit 1 -printf '%s' "$out" | jq 'map(. + {state: (if ((.state // "") | ascii_downcase) == "opened" then "OPEN" else ((.state // "") | ascii_upcase) end)})' +printf '%s' "$out" | jq 'map(. + {state: ((.state // "") | ascii_downcase as $s | if $s == "opened" or $s == "locked" then "OPEN" else ($s | ascii_upcase) end)})' diff --git a/packages/codev/src/commands/porch/__tests__/bugfix-759-pr-search-state-all.test.ts b/packages/codev/src/commands/porch/__tests__/bugfix-759-pr-search-state-all.test.ts index b78cfd20d..4548552c9 100644 --- a/packages/codev/src/commands/porch/__tests__/bugfix-759-pr-search-state-all.test.ts +++ b/packages/codev/src/commands/porch/__tests__/bugfix-759-pr-search-state-all.test.ts @@ -59,12 +59,15 @@ describe('pr-search forge scripts', () => { expect(content).toContain('--search "$CODEV_SEARCH_QUERY"'); }); - it('normalizes opened MRs to the OPEN state so the guard can filter (#1637)', () => { + it('normalizes opened/locked MRs to the OPEN state so the guard can filter (#1637)', () => { // glab reports state lowercase; the script maps opened -> OPEN so the spawn - // collision guard's state === "OPEN" comparison is forge-agnostic. + // collision guard's state === "OPEN" comparison is forge-agnostic. `locked` + // (a transient merging state) also maps to OPEN so the guard errs toward the + // recoverable --force prompt rather than silently skipping a live collision. const content = fs.readFileSync(scriptPath, 'utf-8'); expect(content).toContain('--all'); expect(content).toContain('"OPEN"'); + expect(content).toMatch(/"opened"\s+or\s+\$s\s*==\s*"locked"/); }); }); }); From e292b18897163b926817f314482c40fa532e0654 Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Fri, 11 Sep 2026 20:11:00 +1000 Subject: [PATCH 13/17] [Bugfix #1637] Thread: provider audit + locked-state decision --- codev/state/bugfix-1637_thread.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/codev/state/bugfix-1637_thread.md b/codev/state/bugfix-1637_thread.md index 5004dae1c..7a5a669cb 100644 --- a/codev/state/bugfix-1637_thread.md +++ b/codev/state/bugfix-1637_thread.md @@ -89,3 +89,23 @@ Lesson: after changing a forge SCRIPT's shape, re-run forge.test.ts — I only re-ran guard+script tests on the CMAP follow-up. Full non-e2e suite now 6060 passing; forge.test.ts 80 passing. Awaiting CI 7/7 on a834106b1 before re-requesting the gate. + +## Provider audit + locked-state safety (owner-requested) + +Audited all four forge providers' pr-search resolution (verified empirically via +resolveAllConcepts): +- github -> github/pr-search.sh (gh: OPEN/CLOSED/MERGED) ✅ +- gitlab -> gitlab/pr-search.sh (jq normalization) ✅ +- gitea -> DISABLED in preset; guard never calls pr-search, whole check skipped — no regression +- linear -> falls through to github/pr-search.sh (Linear PRs live on GitHub) ✅ + +Owner asked to lock in the gitlab `locked` safety change: `locked` (transient +merging state) now maps to OPEN alongside `opened`, so the guard errs toward the +recoverable --force prompt instead of silently skipping a live collision +(commit 48df843c3). + +KNOWN pre-existing gap (NOT introduced here, flag for separate issue): gitlab's +`glab mr list --output json` returns GitLab-shaped objects (iid/source_branch), +not the contract's number/headRefName — so the guard's message reads +`PR #undefined` on gitlab. forge.ts documents non-github presets as best-effort +/ may-not-conform. State filtering works; field mapping is the older gap. From fced6d1a11784cc781c583740016647077943329 Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Fri, 11 Sep 2026 20:16:17 +1000 Subject: [PATCH 14/17] [Bugfix #1637] gitlab: map iid/source_branch/target_branch to PrSearchItem glab's `mr list --output json` emits GitLab-shaped fields (iid, source_branch, target_branch), not the contract's number/headRefName/baseRefName, so the spawn guard printed `PR #undefined` and consult's findPRForIssue couldn't resolve the MR base branch on GitLab. Map them in the jq pass, matching the normalization pattern already in gitlab/pr-list.sh and pr-view.sh. Marked UNVERIFIED per the #920 convention (no live glab in the authoring/CI environment). --- .../codev/scripts/forge/gitlab/pr-search.sh | 25 +++++++++++++++++-- .../bugfix-759-pr-search-state-all.test.ts | 10 ++++++++ 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/packages/codev/scripts/forge/gitlab/pr-search.sh b/packages/codev/scripts/forge/gitlab/pr-search.sh index 9d9e845e9..60c6db0c8 100755 --- a/packages/codev/scripts/forge/gitlab/pr-search.sh +++ b/packages/codev/scripts/forge/gitlab/pr-search.sh @@ -1,12 +1,29 @@ #!/bin/sh -# Forge concept: pr-search (GitLab via glab CLI) +# Forge concept: pr-search (GitLab via glab CLI — merge requests) +# Output: JSON [{number, headRefName, baseRefName, state, ...glab fields}] +# +# ⚠️ UNVERIFIED — `glab` is not available in the authoring environment (#920); +# written against glab's documented `mr list --output json` shape (confirmed +# against the field mappings already documented in gitlab/pr-list.sh and +# gitlab/pr-exists.sh). Smoke-test before relying on it. +# # --all is required so the search includes merged/closed MRs; without it # `glab mr list` defaults to opened only and post-merge lookups return nothing (#759). +# +# Maps glab's GitLab-shaped fields into the forge-neutral PrSearchItem contract +# (forge-contracts.ts); `. + {…}` is non-destructive for keys we don't touch: +# iid -> number (the MR number glab mr view/merge expects) +# source_branch -> headRefName +# target_branch -> baseRefName +# Without this the spawn guard's message reads `PR #undefined` and consult's +# findPRForIssue can't resolve the MR's base branch on GitLab. +# # glab reports state lowercase (opened/merged/closed/locked); normalize to the # GitHub convention (OPEN/MERGED/CLOSED) so consumers filter with one comparison. # The spawn collision guard keys off state === "OPEN" to ignore merged MRs (#1637). # `locked` (a transient merging state) maps to OPEN too: the guard should err # toward the recoverable --force prompt, never silently skip a live collision. +# # Not `glab … | jq`: POSIX sh has no pipefail, so the caller would see jq's exit # status and a failed glab would look like a successful empty list (#1645). # The capture-then-pipe shape hides `glab` from extractExecutable's first-line @@ -14,4 +31,8 @@ # — same as github/pr-list.sh (#1645). # forge-executable: glab out="$(glab mr list --all --search "$CODEV_SEARCH_QUERY" --output json)" || exit 1 -printf '%s' "$out" | jq 'map(. + {state: ((.state // "") | ascii_downcase as $s | if $s == "opened" or $s == "locked" then "OPEN" else ($s | ascii_upcase) end)})' +printf '%s' "$out" | jq 'map(. + + {number: .iid} + + {headRefName: .source_branch} + + {baseRefName: .target_branch} + + {state: ((.state // "") | ascii_downcase as $s | if $s == "opened" or $s == "locked" then "OPEN" else ($s | ascii_upcase) end)})' diff --git a/packages/codev/src/commands/porch/__tests__/bugfix-759-pr-search-state-all.test.ts b/packages/codev/src/commands/porch/__tests__/bugfix-759-pr-search-state-all.test.ts index 4548552c9..56086e72b 100644 --- a/packages/codev/src/commands/porch/__tests__/bugfix-759-pr-search-state-all.test.ts +++ b/packages/codev/src/commands/porch/__tests__/bugfix-759-pr-search-state-all.test.ts @@ -69,5 +69,15 @@ describe('pr-search forge scripts', () => { expect(content).toContain('"OPEN"'); expect(content).toMatch(/"opened"\s+or\s+\$s\s*==\s*"locked"/); }); + + it('maps glab fields to the PrSearchItem contract (#1637)', () => { + // glab emits iid/source_branch/target_branch, not number/headRefName/ + // baseRefName. Without the mapping the spawn guard prints `PR #undefined` + // and consult can't resolve the MR base branch. + const content = fs.readFileSync(scriptPath, 'utf-8'); + expect(content).toContain('number: .iid'); + expect(content).toContain('headRefName: .source_branch'); + expect(content).toContain('baseRefName: .target_branch'); + }); }); }); From 2c97825725e0a36a6f670ef570c3fe037a1f0631 Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Fri, 11 Sep 2026 20:16:23 +1000 Subject: [PATCH 15/17] [Bugfix #1637] Thread: record gitlab field-mapping fix --- codev/state/bugfix-1637_thread.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/codev/state/bugfix-1637_thread.md b/codev/state/bugfix-1637_thread.md index 7a5a669cb..328c3fab7 100644 --- a/codev/state/bugfix-1637_thread.md +++ b/codev/state/bugfix-1637_thread.md @@ -109,3 +109,12 @@ KNOWN pre-existing gap (NOT introduced here, flag for separate issue): gitlab's not the contract's number/headRefName — so the guard's message reads `PR #undefined` on gitlab. forge.ts documents non-github presets as best-effort / may-not-conform. State filtering works; field mapping is the older gap. + +## gitlab field-mapping fix (owner-requested "fix it now") + +Grounded the gitlab shape against the repo's own authority (gitlab/pr-list.sh +comment + pr-exists.sh): glab emits iid/source_branch/target_branch, not +number/headRefName/baseRefName. Fixed pr-search.sh to map all three in the jq +pass (commit fced6d1a1), so the guard shows the real MR number and consult's +findPRForIssue can resolve the base branch. Marked UNVERIFIED per #920 (no live +glab in authoring/CI). CI baseline before this: 7/7 green on e292b1889. From bbcc4bc776349e07e05467e9d805ead9d5a03b42 Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Fri, 11 Sep 2026 20:21:55 +1000 Subject: [PATCH 16/17] chore(porch): bugfix-1637 pr gate-approved --- .../bugfix-1637-afx-spawn-open-pr-collision-gu/status.yaml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/codev/projects/bugfix-1637-afx-spawn-open-pr-collision-gu/status.yaml b/codev/projects/bugfix-1637-afx-spawn-open-pr-collision-gu/status.yaml index 18a5b41a6..3fc53eb7a 100644 --- a/codev/projects/bugfix-1637-afx-spawn-open-pr-collision-gu/status.yaml +++ b/codev/projects/bugfix-1637-afx-spawn-open-pr-collision-gu/status.yaml @@ -6,16 +6,17 @@ plan_phases: [] current_plan_phase: null gates: pr: - status: pending + status: approved requested_at: '2026-09-10T02:46:18.290Z' + approved_at: '2026-09-11T10:21:55.540Z' iteration: 1 build_complete: false history: [] started_at: '2026-09-10T02:24:06.981Z' -updated_at: '2026-09-10T02:46:18.291Z' +updated_at: '2026-09-11T10:21:55.540Z' pr_history: - phase: pr pr_number: 1673 branch: builder/bugfix-1637 created_at: '2026-09-10T02:40:32.738Z' -pr_ready_for_human: true +pr_ready_for_human: false From 8b49747f5dd18bff515f622cc5c6a7b94fb1a905 Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Fri, 11 Sep 2026 20:22:00 +1000 Subject: [PATCH 17/17] chore(porch): bugfix-1637 protocol complete --- .../bugfix-1637-afx-spawn-open-pr-collision-gu/status.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/codev/projects/bugfix-1637-afx-spawn-open-pr-collision-gu/status.yaml b/codev/projects/bugfix-1637-afx-spawn-open-pr-collision-gu/status.yaml index 3fc53eb7a..393027d57 100644 --- a/codev/projects/bugfix-1637-afx-spawn-open-pr-collision-gu/status.yaml +++ b/codev/projects/bugfix-1637-afx-spawn-open-pr-collision-gu/status.yaml @@ -1,7 +1,7 @@ id: bugfix-1637 title: afx-spawn-open-pr-collision-gu protocol: bugfix -phase: pr +phase: verified plan_phases: [] current_plan_phase: null gates: @@ -13,7 +13,7 @@ iteration: 1 build_complete: false history: [] started_at: '2026-09-10T02:24:06.981Z' -updated_at: '2026-09-11T10:21:55.540Z' +updated_at: '2026-09-11T10:22:00.571Z' pr_history: - phase: pr pr_number: 1673