From 83d8cd25886cab3e7130b556230eed8888bdcd68 Mon Sep 17 00:00:00 2001 From: jwaldrip Date: Thu, 28 May 2026 15:05:19 -0600 Subject: [PATCH 1/2] fix(drop-stage): land the drop on intent main so the next tick doesn't hang MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The keep-or-drop offer parks the checkout on the optional stage's own branch (the cursor's post-action branch switch). haiku_drop_stage committed the intent.stages edit to whatever branch was checked out — i.e. that doomed stage branch. But every future stage branch forks from intent main (git branch
) and the per-tick sync only flows main -> stage, and a dropped stage never completes (so it never merges up). Intent main therefore never saw the drop: the next stage forked from a main that still listed the dropped stage, findCurrentStage flip-flopped dropped <-> next every tick, and the deadlock detector halted the loop — the "haiku next hangs after a drop" report. Fix: switch back to intent main, land the drop there, and reap the orphan stage branch so it can't reassert the stale plan through a downstream sync. Guard 3 already proves the stage never started, so its branch holds no work to preserve. No-op in filesystem mode. Statusline: render the optional stage's pending keep-or-drop offer as "keep / drop?" (gated) instead of a generic "elaborate", so the strip shows the engine is waiting on a decision. After a drop the dropped stage simply falls out of the pipeline (resolveIntentStages) — no tombstone. Adds drop-stage-lands-on-main.test.mjs: red against the old behavior ("drop must leave the checkout on intent main"), green with the fix — asserts the drop lands on main, the branch is reaped, and the cursor advances instead of flipping back to the dropped stage. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/haiku/src/statusline/state.ts | 7 + .../tools/orchestrator/haiku_drop_stage.ts | 37 +++++ .../test/drop-stage-lands-on-main.test.mjs | 145 ++++++++++++++++++ 3 files changed, 189 insertions(+) create mode 100644 packages/haiku/test/drop-stage-lands-on-main.test.mjs diff --git a/packages/haiku/src/statusline/state.ts b/packages/haiku/src/statusline/state.ts index d401a20fa..bd7192b18 100644 --- a/packages/haiku/src/statusline/state.ts +++ b/packages/haiku/src/statusline/state.ts @@ -213,6 +213,13 @@ function describeAction(action: CursorAction | null): { } switch (action.kind) { case "elaborate_loop": + // First arrival at an OPTIONAL stage surfaces a keep-or-drop + // decision (cursor stamps `optional_offer`). It's a gated choice, + // not mid-elaboration — render it distinctly so the strip shows + // the engine is waiting on the user/agent, not working. + if ((action as { optional_offer?: boolean }).optional_offer === true) { + return { kind: "elaborate", label: "keep / drop?", gated: true } + } return { kind: "elaborate", label: "elaborate", gated: false } case "start_unit_hat": return { kind: "execute", label: "execute", gated: false } diff --git a/packages/haiku/src/tools/orchestrator/haiku_drop_stage.ts b/packages/haiku/src/tools/orchestrator/haiku_drop_stage.ts index 3ce894bea..bcf1c2982 100644 --- a/packages/haiku/src/tools/orchestrator/haiku_drop_stage.ts +++ b/packages/haiku/src/tools/orchestrator/haiku_drop_stage.ts @@ -38,9 +38,14 @@ import { jsonSchemaOf, validateToolInput, } from "../../state/schemas/inputs/_validate.js" +import { + deleteStageBranch, + ensureOnStageBranch, +} from "../../git-worktree.js" import { findHaikuRoot, gitCommitState, + isGitRepo, parseFrontmatter, setFrontmatterField, } from "../../state-tools.js" @@ -131,8 +136,40 @@ export default defineTool({ const droppedIdx = planStages.indexOf(stage) const nextStage = planStages[droppedIdx + 1] const nextStages = planStages.filter((s) => s !== stage) + + // Land the drop on INTENT MAIN — the fork source for every future + // stage branch (`ensureStageBranch` does `git branch
`). + // `intent.stages` is engine-owned FSM state; the keep-or-drop offer + // parked the checkout on the optional stage's own branch (the cursor's + // post-action branch switch), so a naive write would commit the drop + // there and strand it. The per-tick downstream sync only flows + // main → stage, and a dropped stage never completes (so it never + // merges up), so intent main would never see the drop: the next stage + // forks from a main that still lists the dropped stage, the cursor + // flip-flops dropped ⇆ next every tick, and the deadlock detector + // halts the loop — the "haiku next hangs after a drop" report. + // + // Guard 3 already proved the stage never started, so its branch holds + // no work to preserve. Switch back to intent main, write the drop + // there, then reap the abandoned optional-stage branch so it can't + // reassert the stale plan through a later sync. No-op in fs mode (no + // branches) — the single intent.md is authoritative as-is. + if (isGitRepo()) { + const mainGuard = ensureOnStageBranch(slug, undefined) + if (!mainGuard.ok) { + return text( + JSON.stringify({ + error: "drop_stage_branch_switch_failed", + message: `Could not switch to intent main to land the drop of '${stage}': ${mainGuard.message}. Resolve the working-tree state (commit or stash any stray changes) and retry.`, + }), + ) + } + } setFrontmatterField(intentFile, "stages", nextStages) gitCommitState(`haiku: drop optional stage ${stage} from ${slug}`) + // Reap the optional stage's now-orphaned branch (we're off it, on + // intent main). Best-effort — deleteStageBranch never throws. + if (isGitRepo()) deleteStageBranch(slug, stage) emitTelemetry("haiku.stage.dropped", { intent: slug, stage, studio }) return text( diff --git a/packages/haiku/test/drop-stage-lands-on-main.test.mjs b/packages/haiku/test/drop-stage-lands-on-main.test.mjs new file mode 100644 index 000000000..0c31f6acc --- /dev/null +++ b/packages/haiku/test/drop-stage-lands-on-main.test.mjs @@ -0,0 +1,145 @@ +// drop-stage-lands-on-main.test.mjs +// +// Regression for "haiku next hangs after a stage is dropped". +// +// The keep-or-drop offer parks the checkout on the optional stage's OWN +// branch (the cursor's post-action branch switch). `haiku_drop_stage` used +// to commit the `intent.stages` edit to whatever branch was checked out — +// i.e. that doomed stage branch. But every future stage branch forks from +// intent main (`git branch
`), and the per-tick sync only +// flows main → stage, so intent main never saw the drop: the next stage +// forked from a main that still listed the dropped stage, `findCurrentStage` +// flip-flopped dropped ⇆ next every tick, and the deadlock detector halted +// the loop (the "hang"). +// +// The fix lands the drop on intent main and reaps the orphan stage branch. +// This test proves both, plus that the cursor advances cleanly afterward. + +import assert from "node:assert/strict" +import { execFileSync } from "node:child_process" +import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import { dirname, join, resolve } from "node:path" +import { fileURLToPath } from "node:url" +import { test } from "node:test" + +const SRC = new URL("../src/", import.meta.url).pathname +const __dirname = dirname(fileURLToPath(import.meta.url)) +// Point studio resolution at the repo's plugin dir — the test chdirs into a +// temp repo, so cwd-relative studio lookup wouldn't find `software` otherwise. +process.env.CLAUDE_PLUGIN_ROOT = resolve(__dirname, "..", "..", "..", "plugin") + +function git(cwd, ...args) { + execFileSync("git", args, { cwd, stdio: ["ignore", "pipe", "pipe"] }) +} + +function parseToolJson(res) { + return JSON.parse(res.content[0].text) +} + +test("haiku_drop_stage lands the drop on intent main and reaps the stage branch", async () => { + const tmp = mkdtempSync(join(tmpdir(), "haiku-drop-stage-")) + git(tmp, "init", "-q") + git(tmp, "config", "user.email", "t@t.t") + git(tmp, "config", "user.name", "t") + git(tmp, "config", "commit.gpgsign", "false") + writeFileSync(join(tmp, "README.md"), "seed\n") + git(tmp, "add", "-A") + git(tmp, "commit", "-q", "-m", "seed") + + const slug = "demo-drop" + // Build intent main with an intent whose plan reaches the optional + // `design` stage first (design is `optional: true` in the software + // studio; `development` after it is mandatory). No units / elaboration → + // `findCurrentStage` returns `design` and the drop guards all pass. + git(tmp, "checkout", "-q", "-b", `haiku/${slug}/main`) + const iDir = join(tmp, ".haiku", "intents", slug) + mkdirSync(iDir, { recursive: true }) + writeFileSync( + join(iDir, "intent.md"), + [ + "---", + "studio: software", + "mode: continuous", + "stages:", + " - design", + " - development", + "title: demo drop", + "status: active", + "---", + "", + "Drop-stage regression fixture.", + "", + ].join("\n"), + ) + git(tmp, "add", "-A") + git(tmp, "commit", "-q", "-m", "intent") + + // Fork the optional stage's branch from main and park the checkout on it + // — exactly the state the post-cursor branch switch leaves us in when the + // keep-or-drop offer is handed to the agent. + git(tmp, "branch", `haiku/${slug}/design`, `haiku/${slug}/main`) + git(tmp, "checkout", "-q", `haiku/${slug}/design`) + + const orig = process.cwd() + process.chdir(tmp) + try { + const { getCurrentBranch, branchExists } = await import( + `${SRC}git-worktree.ts` + ) + const { findCurrentStage } = await import( + `${SRC}orchestrator/workflow/cursor.ts` + ) + // Sanity: we really are parked on the doomed stage branch, and the + // cursor sees `design` as active. + assert.equal(getCurrentBranch(), `haiku/${slug}/design`) + assert.equal(findCurrentStage(slug, "software"), "design") + + const dropTool = (await import(`${SRC}tools/orchestrator/haiku_drop_stage.ts`)) + .default + const res = parseToolJson(await dropTool.handle({ intent: slug, stage: "design" })) + assert.equal(res.action, "stage_dropped", `unexpected: ${JSON.stringify(res)}`) + assert.deepEqual(res.stages, ["development"]) + + // 1. The drop landed on intent MAIN (the fork source), and the + // checkout was moved back there. + assert.equal( + getCurrentBranch(), + `haiku/${slug}/main`, + "drop must leave the checkout on intent main", + ) + const mainIntent = execFileSync( + "git", + ["show", `haiku/${slug}/main:.haiku/intents/${slug}/intent.md`], + { cwd: tmp, encoding: "utf8" }, + ) + assert.match( + mainIntent, + /stages:\s*\n\s*-\s*development\s*\n/, + "intent main's plan must drop `design` and keep `development`", + ) + assert.ok( + !/-\s*design/.test(mainIntent), + "`design` must be gone from intent main's plan", + ) + + // 2. The orphan stage branch is reaped — it can no longer reassert the + // stale plan through a downstream sync. + assert.equal( + branchExists(`haiku/${slug}/design`), + false, + "the dropped stage's branch must be deleted", + ) + + // 3. The cursor advances to the next stage with no oscillation: from + // intent main (which now carries the drop) `findCurrentStage` + // returns `development`, never `design`. + assert.equal( + findCurrentStage(slug, "software"), + "development", + "cursor must advance to the next stage, not flip back to the dropped one", + ) + } finally { + process.chdir(orig) + } +}) From f35e809bb29aeb55d49c21e06b1810e3f68e10b1 Mon Sep 17 00:00:00 2001 From: jwaldrip Date: Thu, 28 May 2026 15:11:23 -0600 Subject: [PATCH 2/2] fix(drop-stage): biome format + drop redundant cast per PR review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - biome check packages/haiku/src now clean (import ordering on the new git-worktree import block fixed the failing Biome Lint job) - statusline: drop the redundant `as { optional_offer?: boolean }` cast — the switch-case already narrows `action` to the elaborate_loop variant, which declares optional_offer (cursor.ts:166) - format the regression test to biome style Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/haiku/src/statusline/state.ts | 2 +- .../src/tools/orchestrator/haiku_drop_stage.ts | 5 +---- .../test/drop-stage-lands-on-main.test.mjs | 17 ++++++++++++----- 3 files changed, 14 insertions(+), 10 deletions(-) diff --git a/packages/haiku/src/statusline/state.ts b/packages/haiku/src/statusline/state.ts index bd7192b18..d4df3c802 100644 --- a/packages/haiku/src/statusline/state.ts +++ b/packages/haiku/src/statusline/state.ts @@ -217,7 +217,7 @@ function describeAction(action: CursorAction | null): { // decision (cursor stamps `optional_offer`). It's a gated choice, // not mid-elaboration — render it distinctly so the strip shows // the engine is waiting on the user/agent, not working. - if ((action as { optional_offer?: boolean }).optional_offer === true) { + if (action.optional_offer === true) { return { kind: "elaborate", label: "keep / drop?", gated: true } } return { kind: "elaborate", label: "elaborate", gated: false } diff --git a/packages/haiku/src/tools/orchestrator/haiku_drop_stage.ts b/packages/haiku/src/tools/orchestrator/haiku_drop_stage.ts index bcf1c2982..85c2c3ce7 100644 --- a/packages/haiku/src/tools/orchestrator/haiku_drop_stage.ts +++ b/packages/haiku/src/tools/orchestrator/haiku_drop_stage.ts @@ -21,6 +21,7 @@ import { existsSync, readFileSync } from "node:fs" import { join } from "node:path" +import { deleteStageBranch, ensureOnStageBranch } from "../../git-worktree.js" import { resolveIntentStages, resolveStageOptional, @@ -38,10 +39,6 @@ import { jsonSchemaOf, validateToolInput, } from "../../state/schemas/inputs/_validate.js" -import { - deleteStageBranch, - ensureOnStageBranch, -} from "../../git-worktree.js" import { findHaikuRoot, gitCommitState, diff --git a/packages/haiku/test/drop-stage-lands-on-main.test.mjs b/packages/haiku/test/drop-stage-lands-on-main.test.mjs index 0c31f6acc..634605309 100644 --- a/packages/haiku/test/drop-stage-lands-on-main.test.mjs +++ b/packages/haiku/test/drop-stage-lands-on-main.test.mjs @@ -20,8 +20,8 @@ import { execFileSync } from "node:child_process" import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs" import { tmpdir } from "node:os" import { dirname, join, resolve } from "node:path" -import { fileURLToPath } from "node:url" import { test } from "node:test" +import { fileURLToPath } from "node:url" const SRC = new URL("../src/", import.meta.url).pathname const __dirname = dirname(fileURLToPath(import.meta.url)) @@ -95,10 +95,17 @@ test("haiku_drop_stage lands the drop on intent main and reaps the stage branch" assert.equal(getCurrentBranch(), `haiku/${slug}/design`) assert.equal(findCurrentStage(slug, "software"), "design") - const dropTool = (await import(`${SRC}tools/orchestrator/haiku_drop_stage.ts`)) - .default - const res = parseToolJson(await dropTool.handle({ intent: slug, stage: "design" })) - assert.equal(res.action, "stage_dropped", `unexpected: ${JSON.stringify(res)}`) + const dropTool = ( + await import(`${SRC}tools/orchestrator/haiku_drop_stage.ts`) + ).default + const res = parseToolJson( + await dropTool.handle({ intent: slug, stage: "design" }), + ) + assert.equal( + res.action, + "stage_dropped", + `unexpected: ${JSON.stringify(res)}`, + ) assert.deepEqual(res.stages, ["development"]) // 1. The drop landed on intent MAIN (the fork source), and the