diff --git a/.claude/skills/orchestrate/SKILL.md b/.claude/skills/orchestrate/SKILL.md index af62b34724..10a1a051c6 100644 --- a/.claude/skills/orchestrate/SKILL.md +++ b/.claude/skills/orchestrate/SKILL.md @@ -1,6 +1,6 @@ --- name: orchestrate -description: Linear project (or single ticket) in, reviewed PRs out, wave by wave. Computes the merge-gated DAG with tools/wave-plan.mjs, reconciles each ticket against the code (D8), launches one Orca worktree + worker per ticket (engine from .claude/orchestrator.json, claude or codex), babysits CI and review, enforces the evidence gate (D7) and two-strikes (D9). A human merge is the only thing that advances a wave (D3), unless --sleep is passed. Scope is the whole project unless --single bounds it to one ticket. Use after /feature or /bug created the tickets. +description: Linear project (or single ticket) in, reviewed PRs out, wave by wave. Computes the merge-gated DAG with tools/wave-plan.mjs, reconciles each ticket against the code (D8), launches one Orca worktree + worker per ticket (engine from .claude/orchestrator.json, claude or codex), babysits CI and review, enforces the evidence gate (D7) and two-strikes (D9), then tears down each worktree immediately after verified Done. A human merge is the only thing that advances a wave (D3), unless --sleep is passed. Scope is the whole project unless --single bounds it to one ticket. Use after /feature or /bug created the tickets. argument-hint: [--single] [--sleep] effort: high --- @@ -266,11 +266,15 @@ somebody remembered. ## 4. Advance **Project scope, the default.** Thomas merges. On his word (or on observing merges), -fetch, re-run wave-plan, and launch the newly launchable set. Repeat until the -project has no unfinished tickets, then print the final ledger: ticket, PR, merge -SHA, evidence link. This holds whether the run was invoked with a project name or -with a single `ORB-N`, because without `--single` a ticket argument names where to -start, not where to stop. +fetch, verify the Linear issue is Done, then immediately run +`node tools/teardown-worktree.mjs --issue ORB-N` for that ticket. It is evidence-gated: +if it refuses, leave the tree untouched, record the failed check, and do not call the +ticket cleaned up. On confirmed removal, record the removed worktree path in that +ticket's ledger row. Then re-run wave-plan and launch the newly launchable set. Repeat +until the project has no unfinished tickets, then print the final ledger: ticket, PR, +merge SHA, evidence link, removed worktree. This holds whether the run was invoked with +a project name or with a single `ORB-N`, because without `--single` a ticket argument +names where to start, not where to stop. **`--single` ends here instead.** The run is complete once that one ticket's PR is open and its issue is In Review with the PR attached (plus the screenshot when it diff --git a/tools/README.md b/tools/README.md index 00d0bada42..aaa59c633c 100644 --- a/tools/README.md +++ b/tools/README.md @@ -6,6 +6,7 @@ > - POSIX `.sh` is the baseline; add a `.ps1` twin only when it must run in the user's PowerShell shell. > - Add a new tool with `/make-tool`; add its row to the catalog below in the same change. > - `test-tools.mjs` EXECUTES every script here (Harness Execution CI job). A new tool with no coverage entry fails it, so coverage lands in the same PR as the tool. +> - `teardown-worktree.mjs` removes a completed ticket's Orca worktree immediately after verified Done, only when its evidence checks pass. Reusable scripts an agent (or a human) invokes from the CLI. The bar for landing a file here: it has a single clear purpose and you will run it again. One-off commands stay in your shell history or the scratchpad. @@ -34,6 +35,7 @@ Read `CONVENTIONS.md` before adding one. Use the `/make-tool` skill to scaffold | `wave-plan.mjs` | Reads a Linear project's tickets + `blockedBy` relations via the orca CLI and prints the merge-gated wave table. Backs `/orchestrate` (which then launches workers) and `/next` (which stops at the answer). Relation reads run in a bounded pool while the table ordering stays unchanged. | `node tools/wave-plan.mjs --help` | | `compose-prompt.mjs` | Composes the worker prompt from a Linear issue's verbatim body plus every chronological comment, preserving comment Markdown. Write outside an Orbit repository so the prompt cannot be committed. | `node tools/compose-prompt.mjs --issue ORB-N --output ` | | `launch-worker.mjs` | Launches one ticket's Orca worktree + TUI worker end to end (`/orchestrate` step 2), and is the single place the four measured launch gotchas are handled: `worktree create` needs `--name`, a fresh checkout blocks on the workspace-trust prompt, Orca's `/` branch is not the contract branch, and a multi-line prompt through `terminal send` arrives mangled. Reads the engine, model routing and repo paths from `.claude/orchestrator.json`, and refuses any engine that does not declare `interactive: true` (a headless worker cannot be supervised) or whose command or args carry a headless token; prints handle + worktree path + branch as JSON. Exit 0 means the worker ACCEPTED the prompt as a user turn, read back off the TUI after the send, because orca accepting a `terminal send` is not delivery: measured on ORB-88, the composer swallowed the pointer and the launch reported success on a worker sitting idle with no work. Any non-zero exit rolls the worktree and its new branches back out, so a relaunch starts clean. | `node tools/launch-worker.mjs --issue ORB-N --prompt-file ` (`--dry-run`, `--help`) | +| `teardown-worktree.mjs` | Removes one completed ticket's Orca worktree, terminals, and local branch immediately after the Linear issue is verified Done. Refuses unless the worktree is clean, its branch tree is present in the target branch, the live issue is Done, and no terminal is repainting. Verifies removal from the filesystem and `git worktree list`, rather than trusting Orca's removal response. | `node tools/teardown-worktree.mjs --issue ORB-N` (or a worktree selector; `--help`) | | `nudge-worker.mjs` | Delivers a message to a running TUI worker only when it is tui-idle, and exits 1 sending NOTHING while it is mid-turn (a send while busy is queued and cuts the running turn short). Appends the update to the worker's prompt file from stdin and sends a one-line re-read pointer; rejects multi-line `--text`. | `node tools/nudge-worker.mjs --terminal --prompt-file < update.md` (`--text`, `--help`) | | `worker-status.mjs` | The mechanical answer to "is the worker actually done", because tui-idle means stopped, not finished. Derives the verdict from artifacts (commits above the freshly fetched `origin/`, a clean worktree, the branch pushed, a PR open against the target, the issue In Review with the PR attached, an image attached when `visible-effect`, D7) and exits 1 listing exactly what is unmet. | `node tools/worker-status.mjs --worktree --issue ORB-N` (`--base`, `--json`, `--help`) | | `worker-watch.mjs` | The LIVENESS half of babysitting, alongside `worker-status.mjs`'s delivery verdict: for every Orca worktree in the Orbit repos, the ticket, branch, Linear state, BUSY or IDLE classified by repaint delta across two `terminal list` samples, the last meaningful output lines with TUI repaint noise stripped, and the contract verdict. Reports only; deciding what to send a stalled worker stays the orchestrator's judgement. Backs `/watch`. | `node tools/worker-watch.mjs` (`--repo`, `--lines`, `--no-contract`, `--json`, `--help`) | diff --git a/tools/teardown-worktree.mjs b/tools/teardown-worktree.mjs new file mode 100644 index 0000000000..66e8ec3d8f --- /dev/null +++ b/tools/teardown-worktree.mjs @@ -0,0 +1,147 @@ +#!/usr/bin/env node +/** + * Remove one completed Orca worktree only after independently checking that no + * work can be lost. Orca may drop its runtime connection after completing a + * removal, so success is verified from the filesystem and Git, never its reply. + */ + +import { execFileSync, spawnSync } from "node:child_process" +import { existsSync } from "node:fs" +import { resolve } from "node:path" + +import { isRepainting } from "./lib/tui-repaint.mjs" + +const USAGE = `usage: teardown-worktree.mjs (--issue ORB-N | --worktree ) [--base ] + + --issue ORB-N remove the Orca worktree linked to this Linear issue + --worktree remove this Orca child worktree, using its linked Linear issue + --base target branch whose tree must contain the worktree branch (default: worktree base or main) + --help, -h print this usage and exit 0 + +All four checks must pass before anything is removed: the tree is clean, its branch tree is +present in the target branch, the linked Linear issue is Done, and no terminal is mid-turn. +Removal is successful only when the path is gone and git worktree list no longer names it. + +exit codes: 0 removed and verified, 1 evidence or removal verification failed, 2 usage error, + 3 an orca or git command could not be read` + +if (process.argv.includes("--help") || process.argv.includes("-h")) { + console.log(USAGE) + process.exit(0) +} + +const ORCA = process.env.ORCA_BIN || "C:\\Users\\thoma\\AppData\\Local\\Programs\\orca\\resources\\bin\\orca" +const GIT = process.env.GIT_BIN || "git" +const fail = (code, message) => { + console.error(message) + process.exit(code) +} +const argOf = (flag) => { + const index = process.argv.indexOf(flag) + if (index === -1) return null + const value = process.argv[index + 1] + return value === undefined || value.startsWith("-") ? undefined : value +} +const KNOWN_FLAGS = new Set(["--issue", "--worktree", "--base", "--help", "-h"]) +const unknown = process.argv.slice(2).filter((token) => token.startsWith("-") && !KNOWN_FLAGS.has(token)) +if (unknown.length > 0) fail(2, `${USAGE}\n\nunknown option(s): ${unknown.join(" ")}`) + +const requestedIssue = argOf("--issue") +const requestedWorktree = argOf("--worktree") +const requestedBase = argOf("--base") +if ([requestedIssue, requestedWorktree, requestedBase].some((value) => value === undefined)) fail(2, `${USAGE}\n\nselector flags require a value`) +if ((requestedIssue && requestedWorktree) || (!requestedIssue && !requestedWorktree)) fail(2, `${USAGE}\n\nprovide exactly one selector`) +if (requestedIssue && !/^[A-Z]+-\d+$/.test(requestedIssue)) fail(2, `${USAGE}\n\n--issue must be a Linear identifier such as ORB-75`) + +const orca = (args) => { + let raw + try { + raw = execFileSync(ORCA, [...args, "--json"], { encoding: "utf8", maxBuffer: 32 * 1024 * 1024 }) + } catch (error) { + const payload = error.stdout?.toString().trim() ?? "" + return fail(3, `orca ${args.join(" ")} failed: ${payload || error.stderr?.toString().trim() || error.message}`) + } + try { + const parsed = JSON.parse(raw) + if (parsed.ok === false) fail(3, `orca ${args.join(" ")} failed: ${parsed.error?.message ?? parsed.message ?? "unknown error"}`) + return parsed.result ?? parsed + } catch (error) { + return fail(3, `orca ${args.join(" ")} returned unparseable output: ${raw.slice(0, 300)}`) + } +} +const git = (path, args, { allowFailure = false } = {}) => { + const result = spawnSync(GIT, ["-C", path, ...args], { encoding: "utf8", maxBuffer: 32 * 1024 * 1024 }) + if (result.status === 0) return result.stdout.trim() + if (allowFailure) return null + fail(3, `git ${args.join(" ")} failed: ${(result.stderr || result.stdout || "unknown error").trim()}`) +} +const selectorPath = (value) => value?.replace(/^path:/, "") +const normalize = (path) => (typeof path === "string" ? resolve(selectorPath(path)) : "").replaceAll("\\", "/").replace(/\/+$/, "").toLowerCase() + +const worktrees = orca(["worktree", "list"]).worktrees ?? [] +const worktree = requestedIssue + ? worktrees.find((entry) => !entry.isMainWorktree && !entry.isArchived && entry.linkedLinearIssue === requestedIssue) + : worktrees.find((entry) => normalize(entry.path) === normalize(requestedWorktree)) +if (!worktree) fail(1, requestedIssue ? `no active Orca worktree is linked to ${requestedIssue}` : `no active Orca worktree matches ${requestedWorktree}`) +if (worktree.isMainWorktree) fail(1, "refusing to remove a primary checkout") +if (!worktree.linkedLinearIssue || !/^[A-Z]+-\d+$/.test(worktree.linkedLinearIssue)) fail(1, "refusing a worktree without a linked Linear issue") + +const path = worktree.path +const selector = `path:${path}` +const issue = worktree.linkedLinearIssue +const branch = (worktree.branch ?? git(path, ["rev-parse", "--abbrev-ref", "HEAD"])).replace(/^refs\/heads\//, "") +const base = requestedBase ?? worktree.baseRef ?? "main" +const dirty = git(path, ["status", "--short"]).split("\n").filter(Boolean) +const terminals = (orca(["terminal", "list"]).terminals ?? []).filter((terminal) => normalize(terminal.worktreePath) === normalize(path)) +const busy = terminals.filter((terminal) => isRepainting(orca, terminal.handle)) +const detail = orca(["linear", "issue", issue]) +const linearIssue = detail.issue ?? detail +const state = linearIssue.state?.name ?? linearIssue.state + +git(path, ["fetch", "--quiet", "origin", base], { allowFailure: true }) +const baseRef = git(path, ["rev-parse", "--verify", "--quiet", `origin/${base}`], { allowFailure: true }) ? `origin/${base}` : base +const mergeBase = git(path, ["merge-base", baseRef, branch]) +const branchPaths = git(path, ["diff", "--name-only", mergeBase, branch]).split("\n").filter(Boolean) +const treePresent = branchPaths.length === 0 || git(path, ["diff", "--quiet", baseRef, branch, "--", ...branchPaths], { allowFailure: true }) !== null +const checks = [ + { name: "worktree-clean", ok: dirty.length === 0, detail: dirty.length ? `uncommitted paths: ${dirty.join(", ")}` : "no uncommitted work" }, + { name: "tree-present-in-target", ok: treePresent, detail: treePresent ? `${branch}'s content is present in ${baseRef}` : `${branch}'s content is not present in ${baseRef}` }, + { name: "linear-done", ok: state === "Done", detail: `issue is ${state ?? "unknown"}, expected Done` }, + { name: "terminals-idle", ok: busy.length === 0, detail: busy.length ? `worker is still working: ${busy.map((terminal) => terminal.handle).join(", ")}` : `${terminals.length} terminal(s) idle` }, +] +const unmet = checks.filter((check) => !check.ok) +if (unmet.length > 0) { + for (const check of unmet) console.error(`UNMET ${check.name}: ${check.detail}`) + process.exit(1) +} + +const commonDirRaw = git(path, ["rev-parse", "--git-common-dir"]) +const commonDir = resolve(path, commonDirRaw) +const gitCommon = (args, { allowFailure = false } = {}) => { + const result = spawnSync(GIT, [`--git-dir=${commonDir}`, ...args], { encoding: "utf8", maxBuffer: 32 * 1024 * 1024 }) + if (result.status === 0) return result.stdout.trim() + if (allowFailure) return null + fail(3, `git ${args.join(" ")} failed: ${(result.stderr || result.stdout || "unknown error").trim()}`) +} +orca(["terminal", "stop", "--worktree", selector]) +try { + execFileSync(ORCA, ["worktree", "rm", "--worktree", selector, "--force", "--json"], { encoding: "utf8", maxBuffer: 32 * 1024 * 1024 }) +} catch { + // Verification below decides whether a dropped Orca runtime connection was harmless. +} +gitCommon(["worktree", "prune"], { allowFailure: true }) +const gitWorktrees = gitCommon(["worktree", "list", "--porcelain"]) +const stillListed = gitWorktrees.split("\n").some((line) => line === `worktree ${path}` || normalize(line.replace(/^worktree /, "")) === normalize(path)) +const pathGone = !existsSync(path) +if (!pathGone || stillListed) fail(1, `removal verification failed: filesystem=${pathGone ? "gone" : "present"}, git-worktree-list=${stillListed ? "present" : "gone"}`) + +const branchExists = gitCommon(["show-ref", "--verify", "--quiet", `refs/heads/${branch}`], { allowFailure: true }) !== null +if (branchExists) { + const dropped = gitCommon(["branch", "-D", branch], { allowFailure: true }) + if (dropped === null) fail(1, `removed worktree but could not delete local branch ${branch}`) +} +const branchRemaining = gitCommon(["show-ref", "--verify", "--quiet", `refs/heads/${branch}`], { allowFailure: true }) !== null +if (branchRemaining) fail(1, `removed worktree but local branch ${branch} still exists`) +console.log(`REMOVED worktree ${path}`) +console.log(`REMOVED terminals for ${path}`) +console.log(`REMOVED local branch ${branch}`) diff --git a/tools/test-tools.mjs b/tools/test-tools.mjs index 0948d0c49d..a16bb5c5ad 100644 --- a/tools/test-tools.mjs +++ b/tools/test-tools.mjs @@ -24,7 +24,7 @@ */ import { spawnSync } from "node:child_process" -import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs" +import { chmodSync, cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs" import { tmpdir } from "node:os" import { dirname, join, resolve } from "node:path" import { fileURLToPath } from "node:url" @@ -109,7 +109,7 @@ const BASH = resolveBash() */ const ORCA_SHIM = stage( "orca-shim.cjs", - `const { appendFileSync, existsSync, readFileSync } = require("node:fs") + `const { appendFileSync, existsSync, readFileSync, rmSync } = require("node:fs") const argv = process.argv.slice(1) if (argv[0] && existsSync(argv[0])) return const line = argv.join(" ") @@ -120,6 +120,7 @@ if (!match) { process.exit(9) } if (match.delayMs) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, match.delayMs) +if (match.removePath) rmSync(match.removePath, { recursive: true, force: true }) let out = match.stdout if (Array.isArray(match.sequence)) { const log = process.env.ORBIT_ORCA_LOG @@ -773,6 +774,187 @@ const workerWatchCases = () => { check("worker-watch.mjs", "refuses a non-positive --lines", ["--lines", "0"], { status: 2, stderr: /positive integer/ }) } +/** A linked child checkout is the smallest real Git fixture that can prove teardown verification. */ +const stageTeardownWorktree = (label, { dirty = false, changed = false, squashMerged = false, siblingTargetAdvance = false, branchDeleteMode } = {}) => { + const primary = join(root, "teardown", label, "primary") + const child = join(root, "teardown", label, "child") + mkdirSync(primary, { recursive: true }) + const git = (cwd, args) => spawnSync("git", ["-C", cwd, ...args], { encoding: "utf8" }) + for (const args of [["init", "-q", "--initial-branch=main"], ["config", "user.email", "gate@orbit.test"], ["config", "user.name", "Orbit Gate"], ["commit", "-q", "--allow-empty", "-m", "base"], ["worktree", "add", "-q", "-b", "feature/orb-124-teardown", child]]) { + if (git(primary, args).status !== 0) return null + } + if (changed) { + writeFileSync(join(child, "captured.txt"), "not in main\n") + if (git(child, ["add", "captured.txt"]).status !== 0 || git(child, ["commit", "-q", "-m", "captured work"]).status !== 0) return null + if (squashMerged) { + writeFileSync(join(primary, "captured.txt"), "not in main\n") + if (git(primary, ["add", "captured.txt"]).status !== 0 || git(primary, ["commit", "-q", "-m", "squashed capture"]).status !== 0) return null + } + if (siblingTargetAdvance) { + writeFileSync(join(primary, "sibling-ticket.txt"), "already in main\n") + if (git(primary, ["add", "sibling-ticket.txt"]).status !== 0 || git(primary, ["commit", "-q", "-m", "sibling ticket"]).status !== 0) return null + } + } + if (dirty) writeFileSync(join(child, "dirty.txt"), "uncommitted\n") + if (branchDeleteMode) { + const branchRef = "refs/heads/feature/orb-124-teardown" + const hook = join(primary, ".git", "hooks", "reference-transaction") + const head = git(primary, ["rev-parse", "main"]).stdout.trim() + const body = branchDeleteMode === "fail" + ? `#!/bin/sh\nif [ "$1" = "prepared" ]; then\n while read old new ref; do\n if [ "$ref" = "${branchRef}" ] && [ "$new" = "0000000000000000000000000000000000000000" ]; then exit 1; fi\n done\nfi\n` + : `#!/bin/sh\nmarker="$GIT_DIR/teardown-branch-recreated"\nif [ "$1" = "committed" ] && [ ! -f "$marker" ]; then\n while read old new ref; do\n if [ "$ref" = "${branchRef}" ] && [ "$new" = "0000000000000000000000000000000000000000" ]; then\n touch "$marker"\n git update-ref "${branchRef}" "${head}"\n fi\n done\nfi\n` + writeFileSync(hook, body) + chmodSync(hook, 0o755) + } + return { primary, child, branch: "feature/orb-124-teardown" } +} + +const teardownWorktreeRecord = (fixture) => ({ + path: fixture.child, + isMainWorktree: false, + isArchived: false, + linkedLinearIssue: "ORB-124", + branch: `refs/heads/${fixture.branch}`, + baseRef: "main", +}) + +const teardownPlan = (fixture, { state = "Done", terminals = [], removePath, removal = JSON.stringify({ ok: true, result: {} }), removalExit = 0 } = {}) => [ + { match: "worktree list", stdout: JSON.stringify({ ok: true, result: { worktrees: [teardownWorktreeRecord(fixture)] } }) }, + { match: "terminal list", stdout: JSON.stringify({ ok: true, result: { terminals } }) }, + { match: "linear issue ORB-124", stdout: JSON.stringify({ ok: true, result: { issue: { identifier: "ORB-124", state: { name: state } } } }) }, + { match: "terminal stop", stdout: JSON.stringify({ ok: true, result: {} }) }, + { match: "worktree rm", stdout: removal, exit: removalExit, ...(removePath ? { removePath } : {}) }, +] + +const teardownWorktreeCases = () => { + check("teardown-worktree.mjs", "refuses no selector", [], { status: 2, stderr: /provide exactly one selector/ }) + check("teardown-worktree.mjs", "refuses both selectors", ["--issue", "ORB-124", "--worktree", "path:C:/other"], { status: 2, stderr: /provide exactly one selector/ }) + check("teardown-worktree.mjs", "refuses a malformed Linear issue selector", ["--issue", "orb-124"], { status: 2, stderr: /--issue must be a Linear identifier/ }) + check("teardown-worktree.mjs", "refuses a valueless issue selector", ["--issue"], { status: 2, stderr: /selector flags require a value/ }) + check("teardown-worktree.mjs", "refuses a valueless worktree selector", ["--worktree"], { status: 2, stderr: /selector flags require a value/ }) + check("teardown-worktree.mjs", "refuses a valueless base", ["--issue", "ORB-124", "--base"], { status: 2, stderr: /selector flags require a value/ }) + check( + "teardown-worktree.mjs", + "refuses an issue with no active worktree", + ["--issue", "ORB-124"], + { status: 1, stderr: /no active Orca worktree is linked to ORB-124/ }, + { env: orcaEnv([{ match: "worktree list", stdout: JSON.stringify({ ok: true, result: { worktrees: [] } }) }]) }, + ) + + const allGood = stageTeardownWorktree("all-good") + if (!allGood) { + T("teardown-worktree.mjs: real git fixture is available", false, "could not create a linked Git worktree") + return + } + const primaryRefusal = stageTeardownWorktree("primary-refusal") + const primaryRecord = { ...teardownWorktreeRecord(primaryRefusal), path: primaryRefusal.primary, isMainWorktree: true } + check( + "teardown-worktree.mjs", + "refuses a primary checkout", + ["--worktree", `path:${primaryRefusal.primary}`], + { status: 1, stderr: /refusing to remove a primary checkout/ }, + { env: orcaEnv([{ match: "worktree list", stdout: JSON.stringify({ ok: true, result: { worktrees: [primaryRecord] } }) }]) }, + ) + + const unlinkedRefusal = stageTeardownWorktree("unlinked-refusal") + const unlinkedRecord = { ...teardownWorktreeRecord(unlinkedRefusal), linkedLinearIssue: null } + check( + "teardown-worktree.mjs", + "refuses a worktree without a linked Linear issue", + ["--worktree", `path:${unlinkedRefusal.child}`], + { status: 1, stderr: /refusing a worktree without a linked Linear issue/ }, + { env: orcaEnv([{ match: "worktree list", stdout: JSON.stringify({ ok: true, result: { worktrees: [unlinkedRecord] } }) }]) }, + ) + const unavailable = check( + "teardown-worktree.mjs", + "runtime_unavailable is success when filesystem and git verification prove removal", + ["--issue", "ORB-124"], + { status: 0, stdout: /REMOVED worktree[\s\S]*REMOVED terminals[\s\S]*REMOVED local branch/ }, + { + env: orcaEnv( + teardownPlan(allGood, { + removePath: allGood.child, + removal: JSON.stringify({ ok: false, code: "runtime_unavailable", message: "connection closed" }), + removalExit: 1, + }), + ), + }, + ) + T("teardown-worktree.mjs: verified removal actually deleted the fixture", !existsSync(allGood.child), unavailable.stderr) + + const missingTerminalPath = stageTeardownWorktree("missing-terminal-path") + check( + "teardown-worktree.mjs", + "ignores another fleet terminal without a worktree path", + ["--issue", "ORB-124"], + { status: 0, stdout: /REMOVED worktree/ }, + { + env: orcaEnv([ + ...teardownPlan(missingTerminalPath, { + terminals: [{ handle: "term_other_worktree", title: "other worktree" }, { handle: "term_target", worktreePath: missingTerminalPath.child, title: "target worktree" }], + removePath: missingTerminalPath.child, + }), + { match: "terminal show", stdout: JSON.stringify({ ok: true, result: { terminal: { lastOutputAt: 1 } } }) }, + ]), + }, + ) + + const dirty = stageTeardownWorktree("dirty", { dirty: true }) + check("teardown-worktree.mjs", "a dirty tree is refused with its uncommitted path", ["--issue", "ORB-124"], { status: 1, stderr: /worktree-clean[\s\S]*dirty\.txt/ }, { env: orcaEnv(teardownPlan(dirty, { removePath: dirty.child })) }) + T("teardown-worktree.mjs: dirty refusal leaves the tree untouched", existsSync(dirty.child), "the dirty fixture was removed") + + const unmerged = stageTeardownWorktree("unmerged", { changed: true }) + check("teardown-worktree.mjs", "content absent from the target branch is refused", ["--issue", "ORB-124"], { status: 1, stderr: /tree-present-in-target/ }, { env: orcaEnv(teardownPlan(unmerged, { removePath: unmerged.child })) }) + + const notDone = stageTeardownWorktree("not-done") + check("teardown-worktree.mjs", "a closed-looking but non-Done Linear issue is refused", ["--issue", "ORB-124"], { status: 1, stderr: /linear-done[\s\S]*In Review/ }, { env: orcaEnv(teardownPlan(notDone, { state: "In Review", removePath: notDone.child })) }) + + const repainting = stageTeardownWorktree("repainting") + const log = join(root, "teardown", "repainting.log") + check( + "teardown-worktree.mjs", + "a repainting terminal is refused because the worker is still working", + ["--issue", "ORB-124"], + { status: 1, stderr: /terminals-idle[\s\S]*worker is still working/ }, + { + env: { + ...orcaEnv([ + ...teardownPlan(repainting, { terminals: [{ handle: "term_busy", worktreePath: repainting.child }] }), + { match: "terminal show", sequence: [JSON.stringify({ ok: true, result: { terminal: { lastOutputAt: 1 } } }), JSON.stringify({ ok: true, result: { terminal: { lastOutputAt: 2 } } })] }, + ]), + ORBIT_ORCA_LOG: log, + }, + }, + ) + + const survives = stageTeardownWorktree("survives") + check("teardown-worktree.mjs", "an ok removal response is failure when the directory survives", ["--issue", "ORB-124"], { status: 1, stderr: /removal verification failed/ }, { env: orcaEnv(teardownPlan(survives)) }) + + const selector = stageTeardownWorktree("selector", { changed: true, squashMerged: true }) + check("teardown-worktree.mjs", "a path selector accepts a squash-merged tree without ancestry", ["--worktree", `path:${selector.child}`], { status: 0 }, { env: orcaEnv(teardownPlan(selector, { removePath: selector.child })) }) + + const siblingAdvanced = stageTeardownWorktree("sibling-advance", { changed: true, squashMerged: true, siblingTargetAdvance: true }) + check("teardown-worktree.mjs", "a squash-merged tree is present when the target advanced on unrelated paths", ["--issue", "ORB-124"], { status: 0, stdout: /REMOVED worktree/ }, { env: orcaEnv(teardownPlan(siblingAdvanced, { removePath: siblingAdvanced.child })) }) + + const branchDeleteFails = stageTeardownWorktree("branch-delete-fails", { branchDeleteMode: "fail" }) + check( + "teardown-worktree.mjs", + "reports a branch deletion failure after removing the worktree", + ["--issue", "ORB-124"], + { status: 1, stderr: /removed worktree but could not delete local branch feature\/orb-124-teardown/ }, + { env: orcaEnv(teardownPlan(branchDeleteFails, { removePath: branchDeleteFails.child })) }, + ) + + const branchRemains = stageTeardownWorktree("branch-remains", { branchDeleteMode: "retain" }) + check( + "teardown-worktree.mjs", + "reports a branch that remains after deletion", + ["--issue", "ORB-124"], + { status: 1, stderr: /removed worktree but local branch feature\/orb-124-teardown still exists/ }, + { env: orcaEnv(teardownPlan(branchRemains, { removePath: branchRemains.child })) }, + ) +} + const orcaWebPortCases = () => { const portFor = (name) => Number(run("orca-web-port.mjs", ["--derive", "--name", name]).stdout.trim()) const names = Array.from({ length: 256 }, (_, index) => `generated-worktree-${index}`) @@ -984,6 +1166,7 @@ const gateCases = { "nudge-worker.mjs": nudgeWorkerCases, "pr-watch.mjs": prWatchCases, "worker-watch.mjs": workerWatchCases, + "teardown-worktree.mjs": teardownWorktreeCases, "orca-web-port.mjs": orcaWebPortCases, "worker-status.mjs": () => { check("worker-status.mjs", "requires --worktree", ["--issue", "ORB-75"], { status: 2, stderr: /--worktree is required/ }) @@ -1062,6 +1245,7 @@ const INVALID_INPUT = { "redesign-coverage.mjs": { argv: ["--orbit-not-a-flag"], status: 2 }, "rollup.sh": { argv: ["--orbit-not-a-flag"], status: 2 }, "surface-manifest.mjs": { argv: ["--orbit-not-a-flag"], status: 2 }, + "teardown-worktree.mjs": { argv: ["--orbit-not-a-flag"], status: 2 }, "wave-plan.mjs": { argv: ["--orbit-not-a-flag"], status: 2 }, "worker-status.mjs": { argv: ["--orbit-not-a-flag"], status: 2 }, "worker-watch.mjs": { argv: ["--orbit-not-a-flag"], status: 2 },