diff --git a/handlers/wave_init.ts b/handlers/wave_init.ts index a2d12f8..e1af5c5 100644 --- a/handlers/wave_init.ts +++ b/handlers/wave_init.ts @@ -1,6 +1,29 @@ // Wave-init handler — platform-agnostic shell. Platform calls go through // `getAdapter()`; plan/state helpers live in `lib/wave_init_plan.ts`. // Story 2.22 (#316). +// +// Atomicity guarantee (#378). The handler interleaves the kahuna bootstrap +// around the `wave-status init` plan-persist call so a kahuna failure can +// never leave a half-state where the plan is on disk but the kahuna branch +// is missing from remote. Sequence: +// +// 1. Validate input (zod) and run extend-mode pre-scan against existing +// state.json (read-only — no mutation). +// 2. Pre-check kahuna state and CREATE the kahuna branch on remote (if +// `kahuna` arg provided). On failure → return error; nothing was +// persisted to disk. +// 3. Run `wave-status init` to persist the plan to disk (creates +// state.json + phases-waves.json). +// 4. Record the kahuna branch in state.json via `wave-status +// set-kahuna-branch` (must run after step 3 — set-kahuna-branch +// requires state.json to exist). +// +// Failure semantics: +// - Step 2 fails → no plan persisted. Retry converges trivially. +// - Step 3 fails → branch exists on remote, no state.json. Retry's step 2 +// sees the existing branch and claims it as idempotent reuse (see the +// "recorded === null + branch present" path in `bootstrapKahunaBranchRemote`), +// then proceeds to retry step 3. import { execSync } from 'child_process'; import { join } from 'path'; import { z } from 'zod'; @@ -9,8 +32,9 @@ import { parseRepoSlug } from '../lib/shared/parse-repo-slug.js'; import { getAdapter } from '../lib/adapters/index.js'; import { projectDir, writePlanFile, statusDir, readJson, countIssuesFromPlan, - extendModePrescan, readPhasesWavesTotals, bootstrapKahunaBranch, - branchExistsOnRemote, type PlanData, type StateData, + extendModePrescan, readPhasesWavesTotals, bootstrapKahunaBranchRemote, + recordKahunaBranchInState, branchExistsOnRemote, fileExists, + type PlanData, type StateData, } from '../lib/wave_init_plan.js'; import { repoOptionalSchema } from '../lib/schemas/repo.js'; @@ -29,12 +53,26 @@ const inputSchema = z.object({ const envelope = (payload: unknown) => ({ content: [{ type: 'text' as const, text: JSON.stringify(payload) }] }); const shellQuote = (s: string) => `'${s.replace(/'/g, `'\\''`)}'`; +/** + * Read state.json, tolerating its absence (fresh-init mode where the file + * doesn't exist yet because `wave-status init` hasn't run). Returns the + * empty default `{kahuna_branch: null}` when missing — callers feed this + * into `bootstrapKahunaBranchRemote` which treats null-recorded as "fresh + * or recoverable claim" depending on remote state. + */ +async function readStateOrDefault(statePath: string): Promise { + if (!(await fileExists(statePath))) return { kahuna_branch: null }; + return (await readJson(statePath)) as StateData; +} + const waveInitHandler: HandlerDef = { name: 'wave_init', description: 'Initialize a wave plan from structured JSON; supports --extend mode. ' + 'Optional `kahuna` argument bootstraps a `kahuna/-` branch ' + - 'off the plan\'s base_branch (default `main`) and records it in wave state.', + 'off the plan\'s base_branch (default `main`) and records it in wave state. ' + + 'Atomic across kahuna bootstrap + plan persist (#378): kahuna failure does ' + + 'not persist the plan; plan-persist failure leaves the branch claimable on retry.', inputSchema, async execute(rawArgs: unknown) { let args: z.infer; @@ -47,29 +85,51 @@ const waveInitHandler: HandlerDef = { if (!pre.ok) return envelope(pre); } try { - const planFile = writePlanFile(args.plan_json); - const extendFlag = args.extend ? ' --extend' : ''; - const repoFlag = args.repo ? ` --repo ${shellQuote(args.repo)}` : ''; - execSync(`wave-status init${extendFlag}${repoFlag} ${planFile}`, { cwd, encoding: 'utf8' }); - const plan = JSON.parse(args.plan_json) as PlanData; - const counts = countIssuesFromPlan(plan); - const totals = await readPhasesWavesTotals(cwd); - let kahuna: { kahuna_branch: string; kahuna_created: boolean } | undefined; + // ---- Step 2: kahuna bootstrap (REMOTE only — no state writes) ------- + // Runs BEFORE plan persist so a kahuna failure doesn't strand state. + let kahunaBranch: string | undefined; + let kahunaCreated = false; + let kahunaPreviouslyRecorded = false; if (args.kahuna !== undefined) { + const slug = args.repo ?? parseRepoSlug() ?? undefined; const baseBranch = typeof plan.base_branch === 'string' && plan.base_branch.length > 0 ? plan.base_branch : 'main'; const statePath = join(await statusDir(cwd), 'state.json'); - const slug = args.repo ?? parseRepoSlug() ?? undefined; - const result = await bootstrapKahunaBranch(cwd, args.kahuna, baseBranch, - async () => (await readJson(statePath)) as StateData, + const remote = await bootstrapKahunaBranchRemote(cwd, args.kahuna, baseBranch, + () => readStateOrDefault(statePath), { adapter: getAdapter({ repo: slug }), slug, - recordKahunaBranch: (b) => { execSync(`wave-status set-kahuna-branch ${shellQuote(b)}`, { cwd, encoding: 'utf8' }); }, branchPresentOnRemote: (b) => branchExistsOnRemote(cwd, b), }); - if (!result.ok) return envelope({ ok: false, error: result.error }); - kahuna = { kahuna_branch: result.kahuna_branch, kahuna_created: result.created }; + if (!remote.ok) return envelope({ ok: false, error: remote.error }); + kahunaBranch = remote.kahuna_branch; + kahunaCreated = remote.created; + kahunaPreviouslyRecorded = remote.previously_recorded; + } + + // ---- Step 3: persist plan to disk ----------------------------------- + const planFile = writePlanFile(args.plan_json); + const extendFlag = args.extend ? ' --extend' : ''; + const repoFlag = args.repo ? ` --repo ${shellQuote(args.repo)}` : ''; + execSync(`wave-status init${extendFlag}${repoFlag} ${planFile}`, { cwd, encoding: 'utf8' }); + + const counts = countIssuesFromPlan(plan); + const totals = await readPhasesWavesTotals(cwd); + + // ---- Step 4: record kahuna branch in state.json --------------------- + // `set-kahuna-branch` requires state.json (created by step 3). Skipped + // when state.json already had the matching branch recorded — no work + // to do, and the `wave-status init` for a fresh init wouldn't have + // preserved it anyway (only relevant in extend mode). + let kahuna: { kahuna_branch: string; kahuna_created: boolean } | undefined; + if (kahunaBranch !== undefined) { + if (!kahunaPreviouslyRecorded) { + const recordResult = recordKahunaBranchInState(kahunaBranch, + (b) => { execSync(`wave-status set-kahuna-branch ${shellQuote(b)}`, { cwd, encoding: 'utf8' }); }); + if (!recordResult.ok) return envelope({ ok: false, error: recordResult.error }); + } + kahuna = { kahuna_branch: kahunaBranch, kahuna_created: kahunaCreated }; } return envelope({ ok: true, mode: args.extend ? 'extend' : 'init', ...counts, ...totals, ...(kahuna ?? {}) }); diff --git a/lib/wave_init_plan.ts b/lib/wave_init_plan.ts index 66b8874..713198b 100644 --- a/lib/wave_init_plan.ts +++ b/lib/wave_init_plan.ts @@ -159,33 +159,82 @@ export async function readPhasesWavesTotals( // --------------------------------------------------------------------------- // KAHUNA bootstrap — platform-facing calls go through the `PlatformAdapter` // so the handler remains free of platform branching (R-09). +// +// Atomicity (#378): the bootstrap is split into two phases so the handler +// can interleave them around the `wave-status init` plan-persist step: +// +// 1. `bootstrapKahunaBranchRemote` — pre-check + create branch on remote. +// Reads state.json if present (extend mode), tolerates absence (fresh +// init). Returns `{ok, kahuna_branch, created}` or error. NO state.json +// writes — safe to run BEFORE `wave-status init` exists. +// 2. Handler runs `wave-status init` to persist the plan to disk. +// 3. `recordKahunaBranchInState` — writes the branch name into state.json +// via `wave-status set-kahuna-branch`. Must run AFTER step 2 because +// `set-kahuna-branch` requires state.json to exist. +// +// Failure modes after resequencing: +// - Phase 1 fails → no plan persisted, no remote branch (createBranch is +// atomic at the platform API level). Retry converges trivially. +// - Phase 2 fails → remote branch exists but state empty. Retry's phase 1 +// sees `recorded === null` AND remote has desired → claims as idempotent +// reuse (NOT orphan-refused). This is the key behavior change vs the +// pre-#378 single-phase function: "orphan with matching name" is now a +// successful claim, not an error, because the branch name is fully +// determined by `kahuna/-` and a name collision across +// different plans is impossible (plan_id is the unique tracking-issue +// number for the master plan). +// - Phase 3 fails → `wave-status set-kahuna-branch` is a local file write +// and rarely fails; if it does, the state has no kahuna_branch field but +// the remote branch exists. Retry would hit `wave-status init`'s "already +// initialized" guard. Out of scope for #378; tracked separately if it +// ever arises in practice. // --------------------------------------------------------------------------- export interface KahunaBootstrapResult { ok: true; kahuna_branch: string; + /** `true` if the adapter created the remote branch in this call; `false` if a + * matching branch already existed (claim/reuse). */ created: boolean; + /** `true` if state.json's `kahuna_branch` already matched `desired` before + * this call (idempotent-reuse path). Handler uses this to skip the redundant + * `wave-status set-kahuna-branch` write. `false` when state was empty or the + * call created a new branch — in both cases the handler must record. */ + previously_recorded: boolean; } export interface KahunaBootstrapError { ok: false; error: string; } -export interface KahunaBootstrapDeps { +export interface KahunaBootstrapRemoteDeps { adapter: Pick; - /** CLI shell-out — `wave-status set-kahuna-branch `. Injectable for testing. */ - recordKahunaBranch: (branch: string) => void; /** `git ls-remote` probe — local git, not a platform API. Injectable for testing. */ branchPresentOnRemote: (branch: string) => boolean; slug: string | undefined; } -export async function bootstrapKahunaBranch( +/** Back-compat alias — pre-#378 callers passed a `recordKahunaBranch` callback. */ +export interface KahunaBootstrapDeps extends KahunaBootstrapRemoteDeps { + /** CLI shell-out — `wave-status set-kahuna-branch `. Injectable for testing. */ + recordKahunaBranch: (branch: string) => void; +} + +/** + * Phase 1 of #378's atomic kahuna bootstrap: pre-check state + create the + * branch on remote, but do NOT write to state.json. Safe to call BEFORE + * `wave-status init` (state.json may not yet exist). + * + * `readState` should return `{kahuna_branch: null}` when state.json is + * absent (fresh init). Callers in the handler use a wrapper that swallows + * ENOENT and yields the empty default. + */ +export async function bootstrapKahunaBranchRemote( cwd: string, kahuna: { plan_id: number; slug: string }, baseBranch: string, readState: () => Promise<{ kahuna_branch?: string | null }>, - deps: KahunaBootstrapDeps, + deps: KahunaBootstrapRemoteDeps, ): Promise { void cwd; const desired = `kahuna/${kahuna.plan_id}-${kahuna.slug}`; @@ -194,7 +243,7 @@ export async function bootstrapKahunaBranch( if (recorded === desired) { if (deps.branchPresentOnRemote(desired)) { - return { ok: true, kahuna_branch: desired, created: false }; + return { ok: true, kahuna_branch: desired, created: false, previously_recorded: true }; } return { ok: false, @@ -208,12 +257,18 @@ export async function bootstrapKahunaBranch( }; } - // recorded === null: state unset. Check the remote for an orphan. + // recorded === null: state unset. Check the remote for an existing branch + // matching `desired`. + // + // #378: if the remote has the EXACT desired branch (`kahuna/-`), + // claim it as idempotent reuse rather than refusing as an orphan. The branch + // name is fully determined by request inputs; a true "orphan from a different + // plan" is impossible because plan_id is the unique tracking-issue number for + // the master plan, and (plan_id, slug) is a deterministic mapping. This makes + // wave_init retry-safe after a phase-2 (`wave-status init`) failure that left + // the branch on remote but no state.json. if (deps.branchPresentOnRemote(desired)) { - return { - ok: false, - error: `orphan kahuna branch ${desired} exists on remote but is not recorded in state — manual triage required`, - }; + return { ok: true, kahuna_branch: desired, created: false, previously_recorded: false }; } // Fresh creation path — adapter reads main HEAD SHA + creates the branch. @@ -233,15 +288,52 @@ export async function bootstrapKahunaBranch( return { ok: false, error: createResult.error }; } + return { ok: true, kahuna_branch: desired, created: true, previously_recorded: false }; +} + +/** + * Phase 3 of #378's atomic kahuna bootstrap: record the branch name in + * state.json via `wave-status set-kahuna-branch`. Must run AFTER + * `wave-status init` (which creates state.json). Idempotent — safe to call + * even when the branch was discovered as already-existing in phase 1. + */ +export function recordKahunaBranchInState( + branch: string, + recordKahunaBranch: (branch: string) => void, +): { ok: true } | KahunaBootstrapError { try { - deps.recordKahunaBranch(desired); + recordKahunaBranch(branch); + return { ok: true }; } catch (err) { return { ok: false, error: `wave-status set-kahuna-branch failed: ${err instanceof Error ? err.message : String(err)}`, }; } - return { ok: true, kahuna_branch: desired, created: true }; +} + +/** + * Pre-#378 single-phase entry point — kept for back-compat with any caller + * outside the wave_init handler. New callers should use the resequenced + * pair (`bootstrapKahunaBranchRemote` + `recordKahunaBranchInState`) so + * plan-persist failures don't strand a branch on remote with no state. + * + * @deprecated Use the two-phase API for atomic bootstrap. See #378. + */ +export async function bootstrapKahunaBranch( + cwd: string, + kahuna: { plan_id: number; slug: string }, + baseBranch: string, + readState: () => Promise<{ kahuna_branch?: string | null }>, + deps: KahunaBootstrapDeps, +): Promise { + const remote = await bootstrapKahunaBranchRemote(cwd, kahuna, baseBranch, readState, deps); + if (!remote.ok) return remote; + if (!remote.previously_recorded) { + const recorded = recordKahunaBranchInState(remote.kahuna_branch, deps.recordKahunaBranch); + if (!recorded.ok) return recorded; + } + return remote; } function extractSha( diff --git a/tests/wave_init.test.ts b/tests/wave_init.test.ts index 7a4e25a..6a3e84a 100644 --- a/tests/wave_init.test.ts +++ b/tests/wave_init.test.ts @@ -354,22 +354,36 @@ describe('wave_init handler', () => { expect(calls.some(c => c.includes('set-kahuna-branch'))).toBe(false); }); - test('kahuna bootstrap — orphan refused: branch on remote but state empty', async () => { + test('kahuna bootstrap — orphan-with-matching-name claimed (#378): retry-after-failed-init converges', async () => { + // #378 behavior change: when state has no kahuna_branch but the remote + // already has the EXACT desired branch (`kahuna/-`), + // claim it as idempotent reuse rather than refusing as an orphan. This + // makes wave_init retry-safe after a `wave-status init` failure that + // left the kahuna branch on remote but never persisted state.json's + // kahuna_branch field. await setupStatusFixture({ kahuna_branch: null }); setExecRoutes([ { match: 'git remote get-url', respond: 'git@github.com:Wave-Engineering/mcp-server-sdlc.git' }, - { match: 'git ls-remote --heads origin', respond: 'abc123\trefs/heads/kahuna/42-orphan' }, + { match: 'git ls-remote --heads origin', respond: 'abc123\trefs/heads/kahuna/42-foo' }, + { match: 'wave-status set-kahuna-branch', respond: '' }, ]); const result = await handler.execute({ plan_json: JSON.stringify({ phases: [] }), - kahuna: { plan_id: 42, slug: 'orphan' }, + kahuna: { plan_id: 42, slug: 'foo' }, }); const parsed = parseResult(result); - expect(parsed.ok).toBe(false); - expect(parsed.error as string).toContain('orphan'); - expect(parsed.error as string).toContain('kahuna/42-orphan'); + expect(parsed.ok).toBe(true); + expect(parsed.kahuna_branch).toBe('kahuna/42-foo'); + expect(parsed.kahuna_created).toBe(false); + + // No new branch creation API call (we claimed the existing one) + const calls = mockExecSync.mock.calls.map(c => unquote(c[0] as string)); + expect(calls.some(c => c.includes('git/refs -X POST'))).toBe(false); + // But the branch IS recorded in state because state had it as null + const rawCalls = mockExecSync.mock.calls.map(c => c[0] as string); + expect(rawCalls.some(c => c.includes("wave-status set-kahuna-branch 'kahuna/42-foo'"))).toBe(true); }); test('kahuna bootstrap — state-mismatch refused: state has different branch', async () => { @@ -576,6 +590,210 @@ describe('wave_init handler', () => { expect(parsed.error as string).toContain('set-kahuna-branch'); }); + // ---- atomicity (#378) — kahuna bootstrap before plan persist ------------ + // + // The handler resequenced its steps so the kahuna branch is created on the + // remote BEFORE `wave-status init` persists the plan to disk. This block + // pins the ordering and the failure semantics that make wave_init atomic. + + test('atomicity (#378) — ordering: kahuna branch create runs BEFORE wave-status init', async () => { + await setupStatusFixture({ kahuna_branch: null }); + setExecRoutes([ + { match: 'git remote get-url', respond: 'git@github.com:org/repo.git' }, + { match: 'git ls-remote --heads origin', respond: '' }, + { match: 'gh api repos/org/repo/git/refs/heads/main', respond: '5555555555555555555555555555555555555555' }, + { match: 'gh api repos/org/repo/git/refs -X POST', respond: '' }, + { match: 'wave-status set-kahuna-branch', respond: '' }, + ]); + + const result = await handler.execute({ + plan_json: JSON.stringify({ phases: [] }), + kahuna: { plan_id: 7, slug: 'order-test' }, + }); + const parsed = parseResult(result); + expect(parsed.ok).toBe(true); + + const calls = mockExecSync.mock.calls.map(c => unquote(c[0] as string)); + const createBranchIdx = calls.findIndex(c => c.includes('gh api repos/org/repo/git/refs -X POST')); + const initIdx = calls.findIndex(c => c.includes('wave-status init')); + const setKahunaIdx = calls.findIndex(c => c.includes('wave-status set-kahuna-branch')); + expect(createBranchIdx).toBeGreaterThanOrEqual(0); + expect(initIdx).toBeGreaterThan(createBranchIdx); + expect(setKahunaIdx).toBeGreaterThan(initIdx); + }); + + test('atomicity (#378) — kahuna failure: branch creation fails → wave-status init NEVER runs', async () => { + // Inject a failure in the branch-create platform call. The handler must + // bail before `wave-status init` is invoked so the plan is not persisted + // and the half-state ("plan on disk + kahuna missing") is impossible. + await setupStatusFixture({ kahuna_branch: null }); + setExecRoutes([ + { match: 'git remote get-url', respond: 'git@github.com:org/repo.git' }, + { match: 'git ls-remote --heads origin', respond: '' }, + { match: 'gh api repos/org/repo/git/refs/heads/main', respond: '6666666666666666666666666666666666666666' }, + { match: 'gh api repos/org/repo/git/refs -X POST', respond: () => { throw new Error('GraphQL: branch creation refused'); } }, + ]); + + const result = await handler.execute({ + plan_json: JSON.stringify({ phases: [], project: 'foo' }), + kahuna: { plan_id: 8, slug: 'fail-create' }, + }); + const parsed = parseResult(result); + expect(parsed.ok).toBe(false); + + // Critical: `wave-status init` was NEVER called — plan not persisted. + const calls = mockExecSync.mock.calls.map(c => c[0] as string); + expect(calls.some(c => c.includes('wave-status init'))).toBe(false); + expect(calls.some(c => c.includes('wave-status set-kahuna-branch'))).toBe(false); + }); + + test('atomicity (#378) — kahuna failure: SHA resolve fails → wave-status init NEVER runs', async () => { + // Same guarantee, different injection point: base_branch SHA lookup fails + // (e.g. branch doesn't exist or auth error). Plan must not be persisted. + await setupStatusFixture({ kahuna_branch: null }); + setExecRoutes([ + { match: 'git remote get-url', respond: 'git@github.com:org/repo.git' }, + { match: 'git ls-remote --heads origin', respond: '' }, + { match: 'gh api repos/org/repo/git/refs/heads/main', respond: () => { throw new Error('HTTP 404: Not Found'); } }, + ]); + + const result = await handler.execute({ + plan_json: JSON.stringify({ phases: [] }), + kahuna: { plan_id: 9, slug: 'fail-sha' }, + }); + const parsed = parseResult(result); + expect(parsed.ok).toBe(false); + expect(parsed.error as string).toContain('failed to read main HEAD SHA'); + + const calls = mockExecSync.mock.calls.map(c => c[0] as string); + expect(calls.some(c => c.includes('wave-status init'))).toBe(false); + expect(calls.some(c => c.includes('git/refs -X POST'))).toBe(false); + }); + + test('atomicity (#378) — state-mismatch refusal: extend mode with stale recorded branch → init NEVER runs', async () => { + // Pre-existing kahuna_branch in state.json that doesn't match the new + // request's plan_id+slug. This is the "stale kahuna from prior epic" case + // from issue #378's session 1 repro. wave-status init must NOT run. + await setupStatusFixture({ waves: {}, kahuna_branch: 'kahuna/41-prior-epic' }, { phases: [] }); + setExecRoutes([ + { match: 'git remote get-url', respond: 'git@github.com:org/repo.git' }, + ]); + + const result = await handler.execute({ + plan_json: JSON.stringify({ phases: [{ name: 'p', waves: [{ id: 'W-99', issues: [] }] }] }), + extend: true, + kahuna: { plan_id: 42, slug: 'new-epic' }, + }); + const parsed = parseResult(result); + expect(parsed.ok).toBe(false); + + // wave-status init must not have run — the prescan is the only state- + // touching CLI invocation that's allowed before the kahuna pre-check, and + // the prescan is read-only. + const calls = mockExecSync.mock.calls.map(c => c[0] as string); + expect(calls.some(c => c.includes('wave-status init'))).toBe(false); + expect(calls.some(c => c.includes('wave-status set-kahuna-branch'))).toBe(false); + }); + + test('atomicity (#378) — retry semantics: orphan branch on remote + empty state → idempotent claim, init runs', async () => { + // Models the post-failure retry: prior wave_init's `wave-status init` step + // failed AFTER the kahuna branch was created on remote, so the remote has + // the branch but state.json's kahuna_branch is empty. On retry, the new + // call must claim the orphan as idempotent reuse and then proceed to + // re-attempt the plan persist. + await setupStatusFixture({ kahuna_branch: null }); + setExecRoutes([ + { match: 'git remote get-url', respond: 'git@github.com:org/repo.git' }, + { match: 'git ls-remote --heads origin', respond: 'abc123\trefs/heads/kahuna/10-retry' }, + { match: 'wave-status set-kahuna-branch', respond: '' }, + ]); + + const result = await handler.execute({ + plan_json: JSON.stringify({ phases: [] }), + kahuna: { plan_id: 10, slug: 'retry' }, + }); + const parsed = parseResult(result); + expect(parsed.ok).toBe(true); + expect(parsed.kahuna_branch).toBe('kahuna/10-retry'); + expect(parsed.kahuna_created).toBe(false); + + // Plan persist DID run (retry converged), and the orphan was recorded. + const rawCalls = mockExecSync.mock.calls.map(c => c[0] as string); + expect(rawCalls.some(c => c.includes('wave-status init'))).toBe(true); + expect(rawCalls.some(c => c.includes("wave-status set-kahuna-branch 'kahuna/10-retry'"))).toBe(true); + // No NEW branch creation — claimed the existing one + expect(rawCalls.some(c => c.includes('git/refs -X POST'))).toBe(false); + }); + + test('atomicity (#378) — extend retry after kahuna fail: no wave-ID collision because plan never persisted', async () => { + // The original repro: extend-mode wave_init fails on kahuna step. The + // wave IDs in the new plan must NOT have been added to state.json — so a + // retry with the same plan does not trip the wave-ID-collision prescan. + await setupStatusFixture( + { waves: { 'W-1': { status: 'completed' } }, kahuna_branch: null }, + { phases: [] } + ); + setExecRoutes([ + { match: 'git remote get-url', respond: 'git@github.com:org/repo.git' }, + { match: 'git ls-remote --heads origin', respond: '' }, + // First call: SHA resolve fails (simulates network blip) + { match: 'gh api repos/org/repo/git/refs/heads/main', respond: () => { throw new Error('HTTP 503'); } }, + ]); + + const planJson = JSON.stringify({ + phases: [{ name: 'p2', waves: [{ id: 'W-2', issues: [{ number: 20 }] }] }], + }); + const firstResult = await handler.execute({ + plan_json: planJson, + extend: true, + kahuna: { plan_id: 11, slug: 'collision-test' }, + }); + const firstParsed = parseResult(firstResult); + expect(firstParsed.ok).toBe(false); + + // First call did NOT call `wave-status init`, so the W-2 wave ID is NOT + // in state.json. The second call's extend-mode prescan must not flag + // W-2 as colliding. + const firstCalls = mockExecSync.mock.calls.map(c => c[0] as string); + expect(firstCalls.some(c => c.includes('wave-status init'))).toBe(false); + + // Second call: same input, this time the SHA resolves and create succeeds. + mockExecSync.mockClear(); + setExecRoutes([ + { match: 'git remote get-url', respond: 'git@github.com:org/repo.git' }, + { match: 'git ls-remote --heads origin', respond: '' }, + { match: 'gh api repos/org/repo/git/refs/heads/main', respond: '7777777777777777777777777777777777777777' }, + { match: 'gh api repos/org/repo/git/refs -X POST', respond: '' }, + { match: 'wave-status set-kahuna-branch', respond: '' }, + ]); + + const secondResult = await handler.execute({ + plan_json: planJson, + extend: true, + kahuna: { plan_id: 11, slug: 'collision-test' }, + }); + const secondParsed = parseResult(secondResult); + expect(secondParsed.ok).toBe(true); + expect(secondParsed.kahuna_branch).toBe('kahuna/11-collision-test'); + expect(secondParsed.kahuna_created).toBe(true); + + // Critically, the prescan did NOT flag a collision — W-2 was never on disk. + expect(secondParsed.colliding_ids).toBeUndefined(); + }); + + test('atomicity (#378) — fresh init: no kahuna arg → only ONE execSync (back-compat)', async () => { + // When `kahuna` is omitted, the handler does NOT call parseRepoSlug or + // any platform API — only the `wave-status init` call. This pins the + // back-compat happy path (no kahuna, no slug detection overhead). + await setupStatusFixture(null); + const planJson = JSON.stringify({ project: 'foo', phases: [] }); + const result = await handler.execute({ plan_json: planJson }); + const parsed = parseResult(result); + expect(parsed.ok).toBe(true); + expect(mockExecSync.mock.calls.length).toBe(1); + expect((mockExecSync.mock.calls[0][0] as string)).toContain('wave-status init'); + }); + // ---- extend_missing_state ----------------------------------------------- test('extend_missing_state — returns ok:false without throwing', async () => { // Point at a fresh empty tempdir; no state.json exists.