diff --git a/packages/haiku/src/statusline/state.ts b/packages/haiku/src/statusline/state.ts index d401a20fa..d4df3c802 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.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..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, @@ -41,6 +42,7 @@ import { import { findHaikuRoot, gitCommitState, + isGitRepo, parseFrontmatter, setFrontmatterField, } from "../../state-tools.js" @@ -131,8 +133,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..634605309 --- /dev/null +++ b/packages/haiku/test/drop-stage-lands-on-main.test.mjs @@ -0,0 +1,152 @@ +// 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 { 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)) +// 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) + } +})