From b6d54ebbd17ccdc6b90800729483a1c21aac4546 Mon Sep 17 00:00:00 2001 From: Victor Garcia Date: Mon, 27 Jul 2026 17:15:57 -0600 Subject: [PATCH 1/9] ci: add minimal Swarm validation workflow Run the existing test suite and shell syntax checks on Ubuntu with Node 20 using immutable official action pins and read-only repository permissions. Add a dependency-free manifest validator and focused coverage for TOML parsing, version parity, local entrypoint containment, existence, and executable bits. --- .github/workflows/ci.yml | 28 ++++++ scripts/check-manifest.mjs | 171 ++++++++++++++++++++++++++++++++++ tests/check-manifest.test.mjs | 83 +++++++++++++++++ 3 files changed, 282 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100755 scripts/check-manifest.mjs create mode 100644 tests/check-manifest.test.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..2167fe5 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,28 @@ +name: CI + +on: + pull_request: + push: + +permissions: + contents: read + +jobs: + validate: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Check out repository + # v4.2.2 + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + - name: Set up Node.js + # v4.0.3 + uses: actions/setup-node@1e60f620b9541d16bece96c5465dc8ee9832be0b + with: + node-version: 20 + - name: Run tests + run: npm test + - name: Validate shell syntax + run: bash -n scripts/*.sh + - name: Validate plugin manifest + run: node scripts/check-manifest.mjs diff --git a/scripts/check-manifest.mjs b/scripts/check-manifest.mjs new file mode 100755 index 0000000..e1e3ffc --- /dev/null +++ b/scripts/check-manifest.mjs @@ -0,0 +1,171 @@ +#!/usr/bin/env node +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const COMMAND_SECTIONS = ["build", "startup", "actions", "panes", "events"]; +const TOML_TO_JSON = ` +import json +import sys +import tomllib + +with open(sys.argv[1], "rb") as manifest: + json.dump(tomllib.load(manifest), sys.stdout) +`; + +function parseManifest(manifestPath) { + const result = spawnSync("python3", ["-c", TOML_TO_JSON, manifestPath], { + encoding: "utf8", + }); + if (result.error) { + throw new Error( + `could not run python3 to parse the manifest: ${result.error.message}`, + ); + } + if (result.status !== 0) { + throw new Error(`manifest is not valid TOML: ${result.stderr.trim()}`); + } + try { + return JSON.parse(result.stdout); + } catch (error) { + throw new Error(`manifest parser returned invalid JSON: ${error.message}`); + } +} + +function manifestCommands(manifest, errors) { + const commands = []; + for (const section of COMMAND_SECTIONS) { + const entries = manifest[section] ?? []; + if (!Array.isArray(entries)) { + errors.push(`manifest section ${section} must be an array`); + continue; + } + for (const [index, entry] of entries.entries()) { + if ( + !entry || + !Array.isArray(entry.command) || + entry.command.length === 0 || + entry.command.some( + (argument) => typeof argument !== "string" || argument.length === 0, + ) + ) { + errors.push( + `${section}[${index}] must declare a non-empty string command array`, + ); + continue; + } + commands.push({ label: `${section}[${index}]`, command: entry.command }); + } + } + return commands; +} + +function commandEntrypoint(command) { + if (["bash", "node", "sh"].includes(command[0])) return command[1] ?? null; + if (command[0].includes("/")) return command[0]; + return undefined; +} + +function escapesRepository(root, target) { + const relative = path.relative(root, target); + return ( + path.isAbsolute(relative) || + relative === ".." || + relative.startsWith(`..${path.sep}`) + ); +} + +export function validateRepository(root) { + const errors = []; + let repositoryRoot; + let packageJson; + let manifest; + + try { + repositoryRoot = fs.realpathSync(root); + } catch (error) { + return { + errors: [`repository root could not be resolved: ${error.message}`], + entrypointCount: 0, + }; + } + + try { + packageJson = JSON.parse( + fs.readFileSync(path.join(repositoryRoot, "package.json"), "utf8"), + ); + } catch (error) { + errors.push(`package.json could not be parsed: ${error.message}`); + } + + try { + manifest = parseManifest(path.join(repositoryRoot, "herdr-plugin.toml")); + } catch (error) { + errors.push(error.message); + } + + if (!packageJson || !manifest) return { errors, entrypointCount: 0 }; + + if (typeof manifest.version !== "string") { + errors.push("manifest version must be a string"); + } else if (packageJson.version !== manifest.version) { + errors.push( + `version mismatch: package.json=${packageJson.version} herdr-plugin.toml=${manifest.version}`, + ); + } + + let entrypointCount = 0; + for (const { label, command } of manifestCommands(manifest, errors)) { + const entrypoint = commandEntrypoint(command); + if (entrypoint === undefined) continue; + if (entrypoint === null) { + errors.push(`${label} command is missing an entrypoint`); + continue; + } + + entrypointCount += 1; + const target = path.resolve(repositoryRoot, entrypoint); + if (escapesRepository(repositoryRoot, target)) { + errors.push(`${label} entrypoint escapes the repository: ${entrypoint}`); + continue; + } + + let realTarget; + try { + realTarget = fs.realpathSync(target); + } catch { + errors.push(`${label} entrypoint does not exist: ${entrypoint}`); + continue; + } + if (escapesRepository(repositoryRoot, realTarget)) { + errors.push(`${label} entrypoint resolves outside the repository: ${entrypoint}`); + continue; + } + + const stat = fs.statSync(realTarget); + if (!stat.isFile()) { + errors.push(`${label} entrypoint is not a file: ${entrypoint}`); + } else if ((stat.mode & 0o111) === 0) { + errors.push(`${label} entrypoint is not executable: ${entrypoint}`); + } + } + + return { errors, entrypointCount }; +} + +const sourcePath = fileURLToPath(import.meta.url); +if (process.argv[1] && path.resolve(process.argv[1]) === sourcePath) { + const root = path.resolve(path.dirname(sourcePath), ".."); + const result = validateRepository(root); + if (result.errors.length > 0) { + for (const error of result.errors) { + process.stderr.write(`error: ${error}\n`); + } + process.exitCode = 1; + } else { + process.stdout.write( + `Manifest valid: versions match and ${result.entrypointCount} entrypoints are contained, present, and executable.\n`, + ); + } +} diff --git a/tests/check-manifest.test.mjs b/tests/check-manifest.test.mjs new file mode 100644 index 0000000..d04289a --- /dev/null +++ b/tests/check-manifest.test.mjs @@ -0,0 +1,83 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { test } from "node:test"; +import { fileURLToPath } from "node:url"; +import { validateRepository } from "../scripts/check-manifest.mjs"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); + +function fixture(t, command = ["bash", "scripts/open.sh"]) { + const container = fs.mkdtempSync( + path.join(os.tmpdir(), "herdr-swarm-manifest-"), + ); + t.after(() => fs.rmSync(container, { recursive: true, force: true })); + const repository = path.join(container, "repository"); + fs.mkdirSync(path.join(repository, "scripts"), { recursive: true }); + fs.writeFileSync( + path.join(repository, "package.json"), + JSON.stringify({ version: "1.2.3" }), + ); + fs.writeFileSync( + path.join(repository, "herdr-plugin.toml"), + `version = "1.2.3"\n[[actions]]\nid = "open"\ncommand = ${JSON.stringify(command)}\n`, + ); + fs.writeFileSync(path.join(repository, "scripts", "open.sh"), "#!/bin/sh\n", { + mode: 0o755, + }); + return { container, repository }; +} + +test("repository manifest passes CI validation", () => { + const result = validateRepository(root); + assert.deepEqual(result.errors, []); + assert.equal(result.entrypointCount, 8); +}); + +test("manifest validation reports malformed TOML", (t) => { + const { repository } = fixture(t); + fs.writeFileSync(path.join(repository, "herdr-plugin.toml"), 'version = "'); + + assert.match(validateRepository(repository).errors[0], /not valid TOML/); +}); + +test("manifest validation reports package and manifest version mismatch", (t) => { + const { repository } = fixture(t); + fs.writeFileSync( + path.join(repository, "package.json"), + JSON.stringify({ version: "9.9.9" }), + ); + + assert.ok( + validateRepository(repository).errors.some((error) => + error.startsWith("version mismatch:"), + ), + ); +}); + +test("manifest validation reports missing and non-executable entrypoints", (t) => { + const { repository } = fixture(t); + fs.rmSync(path.join(repository, "scripts", "open.sh")); + let errors = validateRepository(repository).errors; + assert.ok(errors.some((error) => error.includes("entrypoint does not exist"))); + + fs.writeFileSync(path.join(repository, "scripts", "open.sh"), "#!/bin/sh\n", { + mode: 0o644, + }); + errors = validateRepository(repository).errors; + assert.ok(errors.some((error) => error.includes("entrypoint is not executable"))); +}); + +test("manifest validation rejects entrypoints outside the repository", (t) => { + const { container, repository } = fixture(t, ["bash", "../outside.sh"]); + fs.writeFileSync(path.join(container, "outside.sh"), "#!/bin/sh\n", { + mode: 0o755, + }); + + assert.ok( + validateRepository(repository).errors.some((error) => + error.includes("entrypoint escapes the repository"), + ), + ); +}); From 093b801fe796248f465aa17dd8252c1d2edf18f1 Mon Sep 17 00:00:00 2001 From: Victor Garcia Date: Mon, 27 Jul 2026 17:16:53 -0600 Subject: [PATCH 2/9] [pi] Implemented and committed Herdr Wave 0A minimal Sw... --- scripts/check-manifest.mjs | 4 +++- tests/check-manifest.test.mjs | 8 ++++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/scripts/check-manifest.mjs b/scripts/check-manifest.mjs index e1e3ffc..0328f0a 100755 --- a/scripts/check-manifest.mjs +++ b/scripts/check-manifest.mjs @@ -139,7 +139,9 @@ export function validateRepository(root) { continue; } if (escapesRepository(repositoryRoot, realTarget)) { - errors.push(`${label} entrypoint resolves outside the repository: ${entrypoint}`); + errors.push( + `${label} entrypoint resolves outside the repository: ${entrypoint}`, + ); continue; } diff --git a/tests/check-manifest.test.mjs b/tests/check-manifest.test.mjs index d04289a..f649e63 100644 --- a/tests/check-manifest.test.mjs +++ b/tests/check-manifest.test.mjs @@ -60,13 +60,17 @@ test("manifest validation reports missing and non-executable entrypoints", (t) = const { repository } = fixture(t); fs.rmSync(path.join(repository, "scripts", "open.sh")); let errors = validateRepository(repository).errors; - assert.ok(errors.some((error) => error.includes("entrypoint does not exist"))); + assert.ok( + errors.some((error) => error.includes("entrypoint does not exist")), + ); fs.writeFileSync(path.join(repository, "scripts", "open.sh"), "#!/bin/sh\n", { mode: 0o644, }); errors = validateRepository(repository).errors; - assert.ok(errors.some((error) => error.includes("entrypoint is not executable"))); + assert.ok( + errors.some((error) => error.includes("entrypoint is not executable")), + ); }); test("manifest validation rejects entrypoints outside the repository", (t) => { From abb108d270b4961f05df17bf73870b691277b4b9 Mon Sep 17 00:00:00 2001 From: Victor Garcia Date: Tue, 28 Jul 2026 02:18:45 -0600 Subject: [PATCH 3/9] [pi] Work in progress --- bin/renderer.mjs | 98 ++++++--- scripts/abort.sh | 114 +++++++++-- scripts/fanout-pane.sh | 26 ++- scripts/harvest-step.sh | 94 +++++++-- scripts/lib.sh | 137 ++++++++++++- scripts/preflight.sh | 117 +++++++++-- scripts/prune.sh | 66 +++--- scripts/safety-state.mjs | 311 ++++++++++++++++++++++++++++ tests/cleanup.test.mjs | 269 +++++++++++++++++-------- tests/harness.mjs | 16 +- tests/harvest.test.mjs | 269 +++++++++++++++++++------ tests/preflight.test.mjs | 35 +++- tests/run-finalization.test.mjs | 345 ++++++++++++++++++++++++++++++++ 13 files changed, 1613 insertions(+), 284 deletions(-) create mode 100755 scripts/safety-state.mjs create mode 100644 tests/run-finalization.test.mjs diff --git a/bin/renderer.mjs b/bin/renderer.mjs index ce8dc29..ad40599 100644 --- a/bin/renderer.mjs +++ b/bin/renderer.mjs @@ -206,13 +206,14 @@ export function renderStatus(rows, cols = 80) { const counts = `${r.committed ?? "-"}/${r.uncommitted ?? "-"}`; // state comes from herdr's agent_status — externally controlled text on // the same footing as a branch name, so it sanitizes like one. - const line = ` ${pad(r.slot, 3)}${pad(sanitizeText(r.state ?? ""), 9)}${pad(counts, 8)}${pad( - sanitizeText(r.label ?? ""), - 14, - )}${pad(sanitizeText(r.branch ?? ""), 28)}${sanitizeText(r.path ?? "-")}`.slice( - 0, - cols, - ); + const line = + ` ${pad(r.slot, 3)}${pad(sanitizeText(r.state ?? ""), 9)}${pad(counts, 8)}${pad( + sanitizeText(r.label ?? ""), + 14, + )}${pad(sanitizeText(r.branch ?? ""), 28)}${sanitizeText(r.path ?? "-")}`.slice( + 0, + cols, + ); lines.push( r.state === "blocked" ? `${ESC}[7m${ESC}[31m${line}${ESC}[0m` : line, ); @@ -322,7 +323,10 @@ export class Renderer { try { // Three-dot range against the recorded fork SHA — see diffRange(). facts.committed = parseDiffStat( - await this.git(["diff", "--stat", diffRange(manifest.fork_sha)], row.path), + await this.git( + ["diff", "--stat", diffRange(manifest.fork_sha)], + row.path, + ), ); } catch { facts.committed = null; @@ -379,7 +383,8 @@ export class Renderer { gitFacts[row.slot] = factsList[i]; }); this.rows = sortSlots(reconcileSlots(m.slots, agents, gitFacts)); - this.banner = agents === null ? "agent list unavailable — states shown as unknown" : ""; + this.banner = + agents === null ? "agent list unavailable — states shown as unknown" : ""; this.paint(); } @@ -559,7 +564,8 @@ export function renderHarvest(model, cols = 80) { )}` : " herdr-swarm harvest"; lines.push(`${ESC}[7m${title.slice(0, cols)}${ESC}[0m`); - if (model.banner) lines.push(` ! ${sanitizeText(model.banner)}`.slice(0, cols)); + if (model.banner) + lines.push(` ! ${sanitizeText(model.banner)}`.slice(0, cols)); lines.push( ` ${pad("#", 3)}${pad("state", 17)}${pad("dirty", 7)}${pad("label", 14)}branch`, ); @@ -570,7 +576,10 @@ export function renderHarvest(model, cols = 80) { const line = ` ${pad(r.slot, 3)}${pad(sanitizeText(state ?? "?"), 17)}${pad( p?.dirty ?? "-", 7, - )}${pad(sanitizeText(r.label ?? ""), 14)}${sanitizeText(r.branch ?? "")}`.slice(0, cols); + )}${pad(sanitizeText(r.label ?? ""), 14)}${sanitizeText(r.branch ?? "")}`.slice( + 0, + cols, + ); // Dirty and error rows need the user before any merge can happen — // same "loud" treatment blocked gets in status mode. lines.push( @@ -589,7 +598,9 @@ export function renderHarvest(model, cols = 80) { lines.push( ` RESUME: slot ${o.slot} has a completed but un-swapped merge commit ${sanitizeText(String(o.sha)).slice(0, 10)}.`, ); - lines.push(`${ESC}[2m [y]complete the swap [n]leave it journaled${ESC}[0m`); + lines.push( + `${ESC}[2m [y]complete the swap [n]leave it journaled${ESC}[0m`, + ); break; } case "stale": { @@ -618,7 +629,9 @@ export function renderHarvest(model, cols = 80) { lines.push( ` DISCARD slot ${ph.slot}: a snapshot ref is written first, but this deletes uncommitted work.`, ); - lines.push(` Type the slot branch name to confirm, Enter to submit, Esc to cancel:`); + lines.push( + ` Type the slot branch name to confirm, Enter to submit, Esc to cancel:`, + ); lines.push(` > ${sanitizeText(ph.typed)}`); break; case "confirm-user": @@ -628,7 +641,9 @@ export function renderHarvest(model, cols = 80) { lines.push( ` The tree was verified clean and will be re-verified at merge time.`, ); - lines.push(`${ESC}[2m [y]merge in my tree [any other key]cancel${ESC}[0m`); + lines.push( + `${ESC}[2m [y]merge in my tree [any other key]cancel${ESC}[0m`, + ); break; case "conflict": { lines.push( @@ -773,7 +788,11 @@ export class HarvestRenderer { return; } const m = parsed.manifest; - this.runInfo = { run_id: m.run_id, base_ref: m.base_ref, repo_root: m.repo_root }; + this.runInfo = { + run_id: m.run_id, + base_ref: m.base_ref, + repo_root: m.repo_root, + }; const rows = []; // Sequential on purpose: every preview verb takes the per-repo mutation // lock, so concurrency here would only contend on that lock. @@ -854,17 +873,24 @@ export class HarvestRenderer { } } - async doArchive(slot, ack = false) { + async doArchive(slot, approval = null) { const r = await this.step( "archive", [slot], - ack ? { HERDR_SWARM_ACK_IGNORED: "1" } : {}, + approval ? { HERDR_SWARM_CLEANUP_APPROVAL: approval } : {}, ); if (r.code === STEP_EC.IGNORED) { this.phase = { name: "ignored", slot, - files: (r.out.ignored ?? []).map((v) => v[0]), + approval: r.out.cleanup_approval?.[0]?.[0] ?? null, + files: (r.out.ignored_json ?? []).map((v) => { + try { + return JSON.parse(v[0]); + } catch { + return v[0]; + } + }), }; this.paint(); } else if (r.code === STEP_EC.DIRTY) { @@ -999,7 +1025,9 @@ export class HarvestRenderer { if (ch === "y" || ch === "Y") { const r = await this.step("resume", ["complete", offer.slot]); this.banner = - r.code === 0 ? `slot ${offer.slot} swap completed` : this.lastErrLine(r); + r.code === 0 + ? `slot ${offer.slot} swap completed` + : this.lastErrLine(r); } if (ph.idx + 1 < ph.offers.length) { this.phase = { ...ph, idx: ph.idx + 1 }; @@ -1030,12 +1058,16 @@ export class HarvestRenderer { case "dirty": if (ch === "w") { const r = await this.step("commit-wip", [ph.slot]); - this.banner = r.code === 0 ? `slot ${ph.slot} committed as WIP` : this.lastErrLine(r); + this.banner = + r.code === 0 + ? `slot ${ph.slot} committed as WIP` + : this.lastErrLine(r); this.phase = { name: "list" }; await this.reload(); } else if (ch === "s") { const r = await this.step("skip", [ph.slot]); - this.banner = r.code === 0 ? `slot ${ph.slot} skipped` : this.lastErrLine(r); + this.banner = + r.code === 0 ? `slot ${ph.slot} skipped` : this.lastErrLine(r); this.phase = { name: "list" }; await this.reload(); } else if (ch === "d") { @@ -1062,7 +1094,9 @@ export class HarvestRenderer { } else if (ch === "a") { const r = await this.step("abort-merge", [ph.slot]); this.banner = - r.code === 0 ? `slot ${ph.slot} merge aborted` : this.lastErrLine(r); + r.code === 0 + ? `slot ${ph.slot} merge aborted` + : this.lastErrLine(r); this.phase = { name: "list" }; await this.reload(); } else if (ch === "b" || ch === "\x1b") { @@ -1075,8 +1109,14 @@ export class HarvestRenderer { case "ignored": if (ch === "y" || ch === "Y") { const slot = ph.slot; + const approval = ph.approval; this.phase = { name: "list" }; - await this.doArchive(slot, true); + if (!approval) { + this.banner = "cleanup approval missing — re-preview required"; + this.paint(); + break; + } + await this.doArchive(slot, approval); await this.reload(); } else { this.banner = `slot ${ph.slot} kept — worktree not removed`; @@ -1107,7 +1147,12 @@ export class HarvestRenderer { screen() { const cols = process.stdout.columns || 80; return renderHarvest( - { runInfo: this.runInfo, banner: this.banner, phase: this.phase, rows: this.rows }, + { + runInfo: this.runInfo, + banner: this.banner, + phase: this.phase, + rows: this.rows, + }, cols, ); } @@ -1179,7 +1224,10 @@ export class HarvestRenderer { const dangling = r.out.resume_dangling ?? []; if (dangling.length) { this.banner = `DANGLING merge commit(s): ${dangling - .map((v) => `slot ${v[0]} @ ${String(v[1]).slice(0, 10)} (kept in ${v[2]})`) + .map( + (v) => + `slot ${v[0]} @ ${String(v[1]).slice(0, 10)} (kept in ${v[2]})`, + ) .join("; ")}`; } if (offers.length) this.phase = { name: "resume", offers, idx: 0 }; diff --git a/scripts/abort.sh b/scripts/abort.sh index 392e776..ad74cd6 100755 --- a/scripts/abort.sh +++ b/scripts/abort.sh @@ -43,11 +43,6 @@ print_summary() { echo "herdr-swarm: abort summary — panes closed $closed, worktrees removed $removed, kept $kept, already gone $gone, swarm branches remaining $branches_remaining (branches are deleted only by prune — R10)." } -# The same per-repo mutation lock fan-out, harvest verbs, and prune contend -# on: an abort must never reap a worktree mid-merge (destructive-surface KTD). -acquire_lock "mutate-$(ws_id)" || exit 1 -trap 'release_lock "mutate-$(ws_id)"' EXIT - rc=0 DOC="$(manifest_read)" || rc=$? case "$rc" in @@ -114,6 +109,50 @@ fi # was resolved before REPO_ROOT was known. SWARM_REPO="$REPO_ROOT" export SWARM_REPO +# The unlocked manifest read above discovers identity only. Serialize on the +# physical repository, re-read the exact live generation under that lock, and +# refuse all deletion when any live/archived bookkeeping is unknown. +MUTATION_LOCK="$(repo_mutation_lock_name "$REPO_ROOT")" || exit 1 +acquire_lock "$MUTATION_LOCK" || exit 1 +trap 'release_lock "$MUTATION_LOCK"' EXIT +DOC_LOCKED="$(manifest_read)" || exit $? +LOCKED_CTX="$(manifest_run_context "$DOC_LOCKED")" || exit 1 +IFS="$US" read -r LOCKED_RUN LOCKED_REPO _ <<<"$LOCKED_CTX" +if [ "$LOCKED_RUN" != "$RUN_ID" ] || [ "$LOCKED_REPO" != "$REPO_ROOT" ]; then + echo "herdr-swarm: manifest identity changed while acquiring the repository lock — abort refused." >&2 + print_summary + exit 3 +fi +DOC="$DOC_LOCKED" +SCAN="$(bookkeeping_scan "$REPO_ROOT")" || exit 1 +if ! bookkeeping_assert_known "$SCAN"; then + echo "herdr-swarm: abort REFUSES all destruction because repository bookkeeping is unknown." >&2 + print_summary + exit 3 +fi + +# --- Optional read-only cleanup preview ------------------------------------- +# Preview inventories every owned slot and exits before pane close, worktree +# removal, manifest update, exclude edit, or archival. One operation id is +# expanded per slot so each approval is exact and one-use. +if [ "${HERDR_SWARM_ABORT_PREVIEW:-}" = "yes" ]; then + PREVIEW_BASE="${HERDR_SWARM_CLEANUP_OPERATION_ID:-$(cleanup_operation_id)}" + printf '%s' "$DOC" | node -e ' + let d=""; process.stdin.on("data",c=>d+=c).on("end",()=>{ + for(const s of JSON.parse(d).slots||[]) if(s.status!=="archived") + console.log([s.slot,s.branch??"",s.path??""].join("\x1f")); + }); + ' | while IFS="$US" read -r pslot pbranch ppath; do + [ -n "$pslot" ] && [ -n "$ppath" ] && [ -d "$ppath" ] || continue + if verify_slot_ownership "$RUN_ID" "$pbranch" "$ppath" >/dev/null; then + pinv="$(slot_ignored_inventory "$REPO_ROOT" "$RUN_ID" "$pslot" "$ppath" "$PREVIEW_BASE-s$pslot")" || exit 1 + print_cleanup_inventory "$pinv" + fi + done + echo "herdr-swarm: abort cleanup preview only — zero resources removed." + print_summary + exit 0 +fi # --- (1) Panes: tracked records first, then the label sweep ------------------ @@ -166,7 +205,7 @@ SLOT_LINES="$(printf '%s' "$DOC" | node -e ' ')" reap_slot_worktree() { - local slot="$1" branch="$2" wtpath="$3" wsid="$4" wt="" herdr_ok=0 why + local slot="$1" branch="$2" wtpath="$3" wsid="$4" wt="" herdr_ok=0 why operation inventory count used rechecked before_digest after_digest # Ownership before destruction, same shared verifier harvest-step.sh's # read_slot uses (lib.sh) — the third instance of the drift pattern in # docs/solutions/best-practices/cross-script-invariant-drift.md, closed in @@ -218,6 +257,47 @@ reap_slot_worktree() { note_kept "slot $slot worktree $wt (uncommitted work)" return 0 fi + operation="$(printf '%s' "${HERDR_SWARM_CLEANUP_APPROVAL:-}" | node -e ' + let d="";process.stdin.on("data",c=>d+=c).on("end",()=>{try{const a=JSON.parse(d);if(a.operation_id)process.stdout.write(String(a.operation_id));}catch{}}); + ')" + [ -n "$operation" ] || operation="$(cleanup_operation_id)" || return 1 + inventory="$(slot_ignored_inventory "$REPO_ROOT" "$RUN_ID" "$slot" "$wt" "$operation")" || { + note_kept "slot $slot worktree $wt (ignored inventory failed)" + return 0 + } + count="$(cleanup_inventory_count "$inventory")" || return 1 + if [ "$count" -gt 0 ]; then + used="$(cleanup_approval_validate "$inventory")" || { + print_cleanup_inventory "$inventory" + echo "herdr-swarm: slot $slot KEPT — ignored files require the exact one-use cleanup approval emitted by preview." >&2 + note_kept "slot $slot worktree $wt (ignored files; cleanup digest not approved)" + return 0 + } + fi + if [ -n "${HERDR_SWARM_TEST_PAUSE_BEFORE_CLEANUP_RECHECK:-}" ]; then sleep "$HERDR_SWARM_TEST_PAUSE_BEFORE_CLEANUP_RECHECK"; fi + if ! why="$(verify_slot_ownership "$RUN_ID" "$branch" "$wt")"; then + echo "herdr-swarm: slot $slot KEPT — ownership changed immediately before removal: $why." >&2 + note_kept "slot $slot worktree $wt (ownership changed before removal)" + return 0 + fi + rechecked="$(slot_ignored_inventory "$REPO_ROOT" "$RUN_ID" "$slot" "$wt" "$operation")" || { + note_kept "slot $slot worktree $wt (ignored inventory recheck failed)" + return 0 + } + before_digest="$(printf '%s' "$inventory" | node -e 'let d="";process.stdin.on("data",c=>d+=c).on("end",()=>process.stdout.write(JSON.parse(d).digest))')" + after_digest="$(printf '%s' "$rechecked" | node -e 'let d="";process.stdin.on("data",c=>d+=c).on("end",()=>process.stdout.write(JSON.parse(d).digest))')" + if [ "$before_digest" != "$after_digest" ]; then + print_cleanup_inventory "$rechecked" + echo "herdr-swarm: slot $slot KEPT — cleanup inventory changed after preview; zero removal performed." >&2 + note_kept "slot $slot worktree $wt (cleanup inventory changed)" + return 0 + fi + if [ "$count" -gt 0 ]; then + cleanup_approval_consume "$used" || { + note_kept "slot $slot worktree $wt (cleanup approval already consumed)" + return 0 + } + fi if [ -n "$wsid" ]; then # The one-shot verb IS the stop mechanism here (spike (a)): it kills # the agent, closes the grouped workspace, and removes the worktree. @@ -386,21 +466,15 @@ if [ "$kept" -gt 0 ]; then exit "$ABORT_EC_KEPT" fi -# Rename, never delete: the archived manifest is the recovery record (and -# prune's source for recorded base refs). The .bak follows its manifest. -mf="$(manifest_path)" -arch="$(state_dir)/archived-$RUN_ID.json" -# run_id contains a timestamp+nonce, so a collision means a re-abort of the -# same recovered run — keep both generations rather than clobbering. -[ -e "$arch" ] && arch="$(state_dir)/archived-$RUN_ID.$$.json" -if mv "$mf" "$arch" 2>/dev/null; then - if [ -f "$mf.bak" ]; then - mv "$mf.bak" "$arch.bak" 2>/dev/null || true - fi - echo "herdr-swarm: manifest archived to $arch." -else - echo "herdr-swarm: warning: could not archive the manifest ($mf)." >&2 +# Exact-name, idempotent finalization is shared with full Harvest. Archive +# failure is fatal: a successful exit may never claim a run finished while +# its durable recovery record is still live or ambiguous. +if ! final_out="$(finalize_run "$REPO_ROOT" "$RUN_ID")"; then + echo "herdr-swarm: abort cleanup finished but run finalization/archive FAILED; the run remains recoverable and this abort is incomplete." >&2 + print_summary + exit 1 fi +[ -n "$final_out" ] && printf '%s\n' "$final_out" print_summary exit 0 diff --git a/scripts/fanout-pane.sh b/scripts/fanout-pane.sh index a27a366..32adc36 100755 --- a/scripts/fanout-pane.sh +++ b/scripts/fanout-pane.sh @@ -47,7 +47,7 @@ fatal() { local code="$1" shift || true [ $# -gt 0 ] && echo "$*" >&2 - release_lock "mutate-$(ws_id)" + [ -n "${MUTATION_LOCK:-}" ] && release_lock "$MUTATION_LOCK" pane_linger exit "$code" } @@ -377,12 +377,6 @@ rename_detritus() { # --- Main flow --------------------------------------------------------------- -# The mutation lock spans the WHOLE fan-out including preflight: if it -# covered only the create loop, two racing panes could both pass the -# active-run check and then serialize straight into a double run. -acquire_lock "mutate-$(ws_id)" || exit 1 -trap 'release_lock "mutate-$(ws_id)"' EXIT - # The repo every git call below targets, resolved from the herdr workspace # context and NOT from this process's cwd (a pane inherits the server's cwd, # which is routinely a different repo — lib.sh resolve_repo_root). Resolved @@ -392,6 +386,12 @@ SWARM_REPO="$(resolve_repo_root 2>/dev/null || true)" export SWARM_REPO preflight_check_repo || fatal $? +# The mutation lock spans the WHOLE fan-out including the repository-scoped +# active-run scan. Its key is the physical git common directory, never the +# Herdr workspace id, so aliased/reopened workspaces cannot race a second run. +MUTATION_LOCK="$(repo_mutation_lock_name "$SWARM_REPO")" || fatal 1 +acquire_lock "$MUTATION_LOCK" || fatal 1 +trap 'release_lock "$MUTATION_LOCK"' EXIT # Version before anything herdr-shaped: below the 0.7.4 floor nothing may be # created and nothing else is worth prompting for (R13). preflight_check_version || fatal $? @@ -556,14 +556,18 @@ repo_root="$SWARM_REPO" # against this, never the moving base tip (R5/R7). fork_sha="$(repo_git rev-parse --verify "$base_ref^{commit}")" || fatal 1 "herdr-swarm: could not resolve $base_ref." +identity="$(repo_identity_json "$repo_root")" || fatal 1 "herdr-swarm: could not resolve repository identity." if ! node -e ' - const [run_id, repo_root, base_ref, fork_sha, created_at] = process.argv.slice(1); + const [run_id, repo_root, base_ref, fork_sha, created_at, identity] = process.argv.slice(1); + const id = JSON.parse(identity); process.stdout.write(JSON.stringify( - { run_id, repo_root, base_ref, fork_sha, created_at, exclude_pattern_added: false, slots: [] }, + { run_id, repo_root, repo_key: id.repo_key, git_common_dir: id.git_common_dir, + base_ref, fork_sha, created_at, exclude_pattern_added: false, slots: [] }, null, 2)); -' "$run_id" "$repo_root" "$base_ref" "$fork_sha" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" | manifest_write; then +' "$run_id" "$repo_root" "$base_ref" "$fork_sha" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$identity" | manifest_write; then fatal 1 "herdr-swarm: could not write the run manifest." fi +active_index_write "$run_id" "$repo_root" || fatal 1 "herdr-swarm: could not write the repository active-run index." # Once, before the loop: the exclude file is shared repo-wide (resolved via # --git-path because .git is a file in linked worktrees), so one append @@ -695,7 +699,7 @@ fi # Keep a partial-failure summary readable: this pane closes when the process # exits. Lock released first — a lingering pane must never block abort. -release_lock "mutate-$(ws_id)" +release_lock "$MUTATION_LOCK" if [ "$failed" -gt 0 ]; then pane_linger fi diff --git a/scripts/harvest-step.sh b/scripts/harvest-step.sh index d57823f..b3e26fe 100755 --- a/scripts/harvest-step.sh +++ b/scripts/harvest-step.sh @@ -14,9 +14,9 @@ # # Output protocol: machine-readable "keyvalue…" lines on stdout, human # messages on stderr, typed exit codes (HS_EC_*) so the renderer branches on -# codes, never on prose. Every invocation takes the per-repo mutation lock — -# the same "mutate-$(ws_id)" every launcher uses — so an abort can never reap -# a worktree mid-merge. +# codes, never on prose. Every invocation takes the physical-repository +# mutation lock every launcher uses, so workspace aliases cannot race and an +# abort can never reap a worktree mid-merge. # # Env contract (all optional): # HERDR_SWARM_CONFIRM discard's confirmation token; must equal @@ -24,8 +24,8 @@ # user in the renderer, re-verified here — # the renderer's prompt alone is UI, not a # guard) -# HERDR_SWARM_ACK_IGNORED=1 archive: user acknowledged the -# ignored-file inventory +# HERDR_SWARM_CLEANUP_APPROVAL archive: exact one-use JSON approval +# emitted by the ignored inventory preview # HERDR_SWARM_HARVEST_WT_NO_HOOKS=1 disable repo hooks in the plugin-owned # harvest worktree ONLY (hook-policy KTD: # fresh worktrees lack node_modules, so @@ -65,11 +65,6 @@ VERB="${1-}" } shift -# One mutation lock per verb invocation (destructive-surface KTD): fan-out, -# harvest verbs, abort, and prune all contend on this name. -acquire_lock "mutate-$(ws_id)" || exit 1 -trap 'release_lock "mutate-$(ws_id)"' EXIT - # --- Run + slot context ------------------------------------------------------ # Manifest read up front: missing (2) or corrupt (3) refuses every verb with @@ -93,6 +88,22 @@ IFS="$US" read -r RUN_ID REPO_ROOT BASE_REF FORK_SHA <<<"$CTX" # resolve through SWARM_REPO, whose source-time default was cwd-based. SWARM_REPO="$REPO_ROOT" export SWARM_REPO +# Lock the physical repository identity, then immediately re-read and validate +# every live/archived manifest generation. The unlocked read above is only +# identity discovery and can never authorize a mutation. +MUTATION_LOCK="$(repo_mutation_lock_name "$REPO_ROOT")" || exit 1 +acquire_lock "$MUTATION_LOCK" || exit 1 +trap 'release_lock "$MUTATION_LOCK"' EXIT +DOC_LOCKED="$(manifest_read)" || exit $? +LOCKED_CTX="$(manifest_run_context "$DOC_LOCKED")" || exit 1 +IFS="$US" read -r LOCKED_RUN LOCKED_REPO _ <<<"$LOCKED_CTX" +if [ "$LOCKED_RUN" != "$RUN_ID" ] || [ "$LOCKED_REPO" != "$REPO_ROOT" ]; then + echo "herdr-swarm: manifest identity changed while acquiring the repository lock — refused." >&2 + exit "$HS_EC_REFUSED" +fi +DOC="$DOC_LOCKED" +SCAN="$(bookkeeping_scan "$REPO_ROOT")" || exit 1 +bookkeeping_assert_known "$SCAN" || exit $? # run_id is interpolated into the harvest worktree path ($(state_dir)/harvest- # $RUN_ID-s, fed straight to `git worktree add`/`remove`) and into the # backup ref namespace (refs/swarm-backups/$RUN_ID/). A run_id carrying @@ -646,6 +657,11 @@ do_archive() { read_slot "$1" || return $? case "$SLOT_STATUS" in merged | skipped | failed) ;; + archived) + finalize_run "$REPO_ROOT" "$RUN_ID" || return $? + printf 'archived\t%s\n' "$1" + return 0 + ;; *) echo "herdr-swarm: slot $1 is '$SLOT_STATUS' — only merged/skipped/failed slots can be archived." >&2 return "$HS_EC_REFUSED" @@ -679,18 +695,45 @@ do_archive() { # remove is not an error — just settle the bookkeeping. manifest_update_slot "$1" '{"status":"archived"}' || return 1 printf 'archived\t%s\n' "$1" + finalize_run "$REPO_ROOT" "$RUN_ID" || return $? return 0 fi - # Ignored files are the ONE class `worktree remove` deletes silently - # (spike (h)) — inventory first; the plugin's own task file is exempt. - local inv - inv="$(git -C "$SLOT_PATH" status --ignored --porcelain | - awk -v tf="$SWARM_TASK_FILE" '/^!! /{f=substr($0,4); if (f != tf) print f}')" - if [ -n "$inv" ] && [ "${HERDR_SWARM_ACK_IGNORED:-0}" != "1" ]; then - printf '%s\n' "$inv" | sed $'s/^/ignored\t/' - echo "herdr-swarm: slot $1 worktree holds ignored files that removal would silently delete — acknowledge to proceed." >&2 + # Cleanup is preview/apply, not a process-global boolean. The recursive + # inventory is canonicalized as NUL-delimited path bytes and its digest is + # bound to repo/run/slot/physical worktree/operation. Apply re-verifies + # ownership and recomputes immediately before the first removal call. + local operation inventory count used rechecked why + operation="$(printf '%s' "${HERDR_SWARM_CLEANUP_APPROVAL:-}" | node -e ' + let d=""; process.stdin.on("data",c=>d+=c).on("end",()=>{try{const a=JSON.parse(d);if(a.operation_id)process.stdout.write(String(a.operation_id));}catch{}}); + ')" + [ -n "$operation" ] || operation="$(cleanup_operation_id)" || return 1 + inventory="$(slot_ignored_inventory "$REPO_ROOT" "$RUN_ID" "$1" "$SLOT_PATH" "$operation")" || return 1 + count="$(cleanup_inventory_count "$inventory")" || return 1 + if [ "$count" -gt 0 ]; then + used="$(cleanup_approval_validate "$inventory")" || { + print_cleanup_inventory "$inventory" + echo "herdr-swarm: slot $1 worktree holds ignored files; apply requires the exact one-use cleanup approval emitted by this preview." >&2 + return "$HS_EC_IGNORED" + } + fi + if [ -n "${HERDR_SWARM_TEST_CLEANUP_READY_FILE:-}" ]; then : >"$HERDR_SWARM_TEST_CLEANUP_READY_FILE"; fi + if [ -n "${HERDR_SWARM_TEST_PAUSE_BEFORE_CLEANUP_RECHECK:-}" ]; then + sleep "$HERDR_SWARM_TEST_PAUSE_BEFORE_CLEANUP_RECHECK" + fi + if ! why="$(verify_slot_ownership "$RUN_ID" "$SLOT_BRANCH" "$SLOT_PATH")"; then + echo "herdr-swarm: slot $1 ownership changed before cleanup — $why; removal refused." >&2 + return "$HS_EC_REFUSED" + fi + rechecked="$(slot_ignored_inventory "$REPO_ROOT" "$RUN_ID" "$1" "$SLOT_PATH" "$operation")" || return 1 + if [ "$(printf '%s' "$inventory" | node -e 'let d="";process.stdin.on("data",c=>d+=c).on("end",()=>process.stdout.write(JSON.parse(d).digest))')" != \ + "$(printf '%s' "$rechecked" | node -e 'let d="";process.stdin.on("data",c=>d+=c).on("end",()=>process.stdout.write(JSON.parse(d).digest))')" ]; then + print_cleanup_inventory "$rechecked" + echo "herdr-swarm: cleanup inventory changed after preview — zero removal performed; review the new digest." >&2 return "$HS_EC_IGNORED" fi + if [ "$count" -gt 0 ]; then + cleanup_approval_consume "$used" || return "$HS_EC_REFUSED" + fi local out rc=0 if [ -n "$SLOT_WS" ]; then # The herdr verb stops the (idle) agent, closes the grouped workspace, @@ -722,8 +765,23 @@ do_archive() { return 1 fi fi + # Herdr success is not the disk authority (stale server responses and test + # doubles can leave the registered worktree behind). Re-verify and reconcile + # with plain git before declaring the slot archived/finalizable. + if [ -d "$SLOT_PATH" ]; then + if ! why="$(verify_slot_ownership "$RUN_ID" "$SLOT_BRANCH" "$SLOT_PATH")"; then + echo "herdr-swarm: slot $1 still exists after Herdr removal and ownership no longer matches — kept, not archived: $why" >&2 + return "$HS_EC_REFUSED" + fi + out="$(git -C "$REPO_ROOT" worktree remove "$SLOT_PATH" 2>&1)" || { + if [ -n "$(git -C "$SLOT_PATH" status --porcelain 2>/dev/null)" ]; then return "$HS_EC_DIRTY"; fi + printf '%s\n' "$out" >&2 + return 1 + } + fi manifest_update_slot "$1" '{"status":"archived"}' || return 1 printf 'archived\t%s\n' "$1" + finalize_run "$REPO_ROOT" "$RUN_ID" || return $? } do_abort_merge() { diff --git a/scripts/lib.sh b/scripts/lib.sh index 019e6ef..978b5b9 100644 --- a/scripts/lib.sh +++ b/scripts/lib.sh @@ -162,7 +162,136 @@ pane_alive() { [ -n "$1" ] && "$HERDR" pane read "$1" --lines 1 >/dev/null 2>&1 } -# --- Mutation lock ----------------------------------------------------------- +# --- Repository identity + mutation lock ------------------------------------ +# Physical git-common-dir identity is the ownership key. Workspace ids are +# observations only: two Herdr workspaces pointed at one repository must +# contend on one lock and discover one another's live manifests. +safety_state() { + node "${PLUGIN_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}/scripts/safety-state.mjs" "$@" +} + +repo_identity_json() { + require_node || return 1 + safety_state repo "$1" +} + +repo_identity_field() { + local doc="$1" field="$2" + printf '%s' "$doc" | node -e ' + let d = ""; + process.stdin.on("data", (c) => (d += c)).on("end", () => { + const value = JSON.parse(d)[process.argv[1]]; + if (typeof value !== "string" || value.length === 0) process.exit(1); + process.stdout.write(value); + }); + ' "$field" +} + +repo_mutation_lock_name() { + local identity key + identity="$(repo_identity_json "$1")" || return 1 + key="$(repo_identity_field "$identity" repo_key)" || return 1 + printf 'mutate-repo-%s\n' "$key" +} + +bookkeeping_scan() { + require_node || return 1 + safety_state scan "$(state_dir)" "$1" +} + +bookkeeping_assert_known() { + local scan="$1" + printf '%s' "$scan" | node -e ' + let d = ""; + process.stdin.on("data", (c) => (d += c)).on("end", () => { + const scan = JSON.parse(d); + if ((scan.errors || []).length === 0) return; + for (const error of scan.errors) console.error("herdr-swarm: bookkeeping_unknown: " + error); + process.exit(3); + }); + ' +} + +active_index_write() { + local run_id="$1" repo_root="$2" mf identity key dst tmp + mf="$(manifest_path)" || return 1 + identity="$(repo_identity_json "$repo_root")" || return 1 + key="$(repo_identity_field "$identity" repo_key)" || return 1 + dst="$(state_dir)/active-repo-$key.json" + tmp="$dst.tmp.$$" + node -e ' + const fs = require("fs"); + const [dst, runId, manifestPath, identity] = process.argv.slice(1); + const id = JSON.parse(identity); + const doc = { repo_key: id.repo_key, git_common_dir: id.git_common_dir, + run_id: runId, manifest_path: manifestPath }; + fs.writeFileSync(dst, JSON.stringify(doc, null, 2) + "\n", { mode: 0o600 }); + ' "$tmp" "$run_id" "$mf" "$identity" || { + rm -f "$tmp" + return 1 + } + mv "$tmp" "$dst" +} + +active_index_remove() { + local repo_root="$1" run_id="$2" identity key dst + identity="$(repo_identity_json "$repo_root")" || return 1 + key="$(repo_identity_field "$identity" repo_key)" || return 1 + dst="$(state_dir)/active-repo-$key.json" + [ -e "$dst" ] || return 0 + node -e ' + const fs = require("fs"); + const [file, key, runId] = process.argv.slice(1); + const stat = fs.lstatSync(file); + if (!stat.isFile() || stat.isSymbolicLink()) process.exit(2); + const doc = JSON.parse(fs.readFileSync(file, "utf8")); + if (doc.repo_key !== key || doc.run_id !== runId) process.exit(2); + fs.unlinkSync(file); + ' "$dst" "$key" "$run_id" +} + +cleanup_operation_id() { + safety_state operation-id +} + +slot_ignored_inventory() { + local repo_root="$1" run_id="$2" slot="$3" wt="$4" operation_id="$5" identity key common + identity="$(repo_identity_json "$repo_root")" || return 1 + key="$(repo_identity_field "$identity" repo_key)" || return 1 + common="$(repo_identity_field "$identity" git_common_dir)" || return 1 + safety_state inventory "$repo_root" "$key" "$common" "$run_id" "$slot" "$wt" "$operation_id" +} + +cleanup_inventory_count() { + printf '%s' "$1" | node -e 'let d=""; process.stdin.on("data",c=>d+=c).on("end",()=>process.stdout.write(String(JSON.parse(d).count)))' +} + +print_cleanup_inventory() { + printf '%s' "$1" | node -e ' + let d=""; + process.stdin.on("data",c=>d+=c).on("end",()=>{ + const i=JSON.parse(d); + console.log("cleanup_operation\t" + i.operation_id); + console.log("cleanup_digest\t" + i.digest); + const approval={approved:true}; + for (const k of ["repo_key","git_common_dir","run_id","slot","worktree","operation_id","digest"]) approval[k]=i[k]; + console.log("cleanup_approval\t" + JSON.stringify(approval)); + for (const p of i.paths_display) console.log("ignored_json\t" + JSON.stringify(p)); + }); + ' +} + +cleanup_approval_validate() { + local inventory="$1" approval="${HERDR_SWARM_CLEANUP_APPROVAL:-}" + [ -n "$approval" ] || return 2 + printf '%s' "$approval" | safety_state approval "$inventory" "$(state_dir)" +} + +cleanup_approval_consume() { + local used="$1" approval="${HERDR_SWARM_CLEANUP_APPROVAL:-}" + printf '%s' "$approval" | safety_state consume "$used" >/dev/null +} + # One mkdir+PID-token lock per caller-supplied name (e.g. the per-repo # mutation lock shared by fan-out, harvest, abort, and prune — an abort must # never reap a worktree mid-merge). mkdir is the portable atomic lock; the @@ -740,7 +869,11 @@ manifest_write() { manifest_read() { local mf mf="$(manifest_path)" || return 1 - [ -f "$mf" ] || return "$MANIFEST_EC_MISSING" + [ -e "$mf" ] || return "$MANIFEST_EC_MISSING" + if [ -L "$mf" ] || [ ! -f "$mf" ]; then + echo "herdr-swarm: manifest $mf is not a regular non-symlink file (corrupt); .bak is recovery inventory only" >&2 + return "$MANIFEST_EC_CORRUPT" + fi if [ ! -s "$mf" ]; then echo "herdr-swarm: manifest $mf is zero-length (corrupt); previous generation may be in $mf.bak" >&2 return "$MANIFEST_EC_CORRUPT" diff --git a/scripts/preflight.sh b/scripts/preflight.sh index 8324012..1c8840b 100644 --- a/scripts/preflight.sh +++ b/scripts/preflight.sh @@ -185,27 +185,18 @@ preflight_check_sparse() { return 0 } -# One active run per repo workspace (v1). Missing manifest = no run = pass; -# all-archived = run complete = pass; corrupt manifest = unknown state = -# refuse with the corrupt code (caller degrades to report_only_discovery — -# never "assume no run" when the bookkeeping is unreadable). +# One active run per physical repository, regardless of Herdr workspace id. +# Every live/archived candidate is semantically validated first: unreadable or +# identity-ambiguous bookkeeping is unknown state, never permission to start a +# second run or resolve detritus destructively. preflight_check_active_run() { - local doc rc=0 - doc="$(manifest_read)" || rc=$? - case "$rc" in - 0) ;; - "$MANIFEST_EC_MISSING") return 0 ;; - *) return "$rc" ;; - esac - if printf '%s' "$doc" | node -e ' - let d = ""; - process.stdin.on("data", (c) => (d += c)).on("end", () => { - const doc = JSON.parse(d); - const live = (doc.slots || []).filter((s) => s.status !== "archived"); - process.exit(live.length > 0 ? 0 : 1); - }); + local scan + scan="$(bookkeeping_scan "$SWARM_REPO")" || return 1 + bookkeeping_assert_known "$scan" || return $? + if printf '%s' "$scan" | node -e ' + let d=""; process.stdin.on("data",c=>d+=c).on("end",()=>process.exit(JSON.parse(d).live.length>0?0:1)); '; then - echo "herdr-swarm: an active run exists in this repo ($(manifest_path)) — harvest or abort it first." >&2 + echo "herdr-swarm: an active run exists for this repository (possibly in another workspace) — harvest or abort it first." >&2 return "$PF_EC_ACTIVE_RUN" fi return 0 @@ -311,6 +302,94 @@ remove_exclude_pattern() { return 0 } +# finalize_run : idempotently complete and archive a run +# after every slot is archived. The exact archive name is immutable: retries +# never create PID-suffixed generations or overwrite a prior recovery record. +# Callers hold the repository mutation lock. +finalize_run() { + local repo_root="$1" run_id="$2" mf arch doc updated scan rc=0 + mf="$(manifest_path)" || return 1 + arch="$(state_dir)/archived-$run_id.json" + if [ ! -e "$mf" ]; then + if [ -f "$arch" ] && [ ! -L "$arch" ] && node -e ' + const fs=require("fs"); const d=JSON.parse(fs.readFileSync(process.argv[1],"utf8")); + process.exit(d.run_id===process.argv[2] && d.status==="completed" ? 0 : 1); + ' "$arch" "$run_id" 2>/dev/null; then + # Retry after the archive rename: finish only idempotent bookkeeping + # tails, never rewrite the immutable archive. + active_index_remove "$repo_root" "$run_id" 2>/dev/null || true + if [ -f "$mf.bak" ] && [ ! -e "$arch.bak" ]; then mv "$mf.bak" "$arch.bak" || return 1; fi + return 0 + fi + echo "herdr-swarm: finalization refused — neither the live manifest nor the exact completed archive is available." >&2 + return 1 + fi + [ ! -e "$arch" ] || { + echo "herdr-swarm: finalization refused — archive $arch already exists; it will not be overwritten." >&2 + return 1 + } + doc="$(manifest_read)" || return $? + rc=0 + printf '%s' "$doc" | node -e ' + const fs=require("fs"); let d=""; process.stdin.on("data",c=>d+=c).on("end",()=>{ + const m=JSON.parse(d); + if (m.run_id !== process.argv[1] || !(m.slots||[]).every(s=>s.status==="archived" && !s.journal)) process.exit(1); + const present=(m.slots||[]).find(s=>s.path && fs.existsSync(s.path)); + if (present) { console.error("herdr-swarm: finalization refused — archived slot resource still exists at " + JSON.stringify(present.path)); process.exit(2); } + }); + ' "$run_id" || rc=$? + case "$rc" in + 0) ;; + 1) return 0 ;; + *) return "$rc" ;; + esac + for resource_path in "$(state_dir)"/harvest-"$run_id"-s*; do + [ -e "$resource_path" ] || continue + echo "herdr-swarm: finalization refused — harvest resource still exists at $resource_path." >&2 + return 1 + done + updated="$(printf '%s' "$doc" | node -e ' + let d=""; process.stdin.on("data",c=>d+=c).on("end",()=>{ + const m=JSON.parse(d), now=new Date().toISOString(); + if (m.status !== "completed") { + m.status="completed"; m.completed_at=now; m.completion_reason="all_slots_archived"; + m.completion_events=Array.isArray(m.completion_events)?m.completion_events:[]; + m.completion_events.push({type:"run.completed",at:now}); + } + process.stdout.write(JSON.stringify(m,null,2)); + }); + ')" || return 1 + printf '%s' "$updated" | manifest_write || return 1 + [ "${HERDR_SWARM_TEST_FAIL_FINALIZE_STEP:-}" = "after-complete" ] && return 99 + # No other live manifest may still need the shared task-file exclusion. + scan="$(bookkeeping_scan "$repo_root")" || return 1 + bookkeeping_assert_known "$scan" || return $? + if printf '%s' "$scan" | node -e ' + let d=""; process.stdin.on("data",c=>d+=c).on("end",()=>{ + const s=JSON.parse(d); process.exit(s.live.some(x=>x.run_id!==process.argv[1] && x.exclude_pattern_added)?1:0); + }); + ' "$run_id"; then + remove_exclude_pattern || return 1 + fi + [ "${HERDR_SWARM_TEST_FAIL_FINALIZE_STEP:-}" = "after-exclude" ] && return 99 + active_index_remove "$repo_root" "$run_id" || return 1 + [ "${HERDR_SWARM_TEST_FAIL_FINALIZE_STEP:-}" = "after-index" ] && return 99 + if ! mv "$mf" "$arch"; then + active_index_write "$run_id" "$repo_root" 2>/dev/null || true + echo "herdr-swarm: could not archive the completed manifest $mf to $arch." >&2 + return 1 + fi + [ "${HERDR_SWARM_TEST_FAIL_FINALIZE_STEP:-}" = "after-archive" ] && return 99 + if [ -f "$mf.bak" ]; then + mv "$mf.bak" "$arch.bak" || { + echo "herdr-swarm: could not archive the manifest backup $mf.bak." >&2 + return 1 + } + fi + printf 'run_archived\t%s\n' "$arch" + return 0 +} + # --- Corrupt-manifest degradation (R6) ---------------------------------------- # report_only_discovery: what a destructive caller *would* act on, listed diff --git a/scripts/prune.sh b/scripts/prune.sh index 7295a70..8e5565b 100755 --- a/scripts/prune.sh +++ b/scripts/prune.sh @@ -44,24 +44,30 @@ ack_reverted=0 # both. Deleting a snapshot is unrecoverable — it deserves its own keystroke. prune_backups=0 [ "${HERDR_SWARM_PRUNE_BACKUPS:-}" = "yes" ] && prune_backups=1 +requested_delete=0 +if [ "$confirm" -eq 1 ] || [ "$prune_backups" -eq 1 ]; then requested_delete=1; fi -# The live run's snapshots are never deletable at all — not even under -# PRUNE_BACKUPS: harvest may still be running and its discards are the only -# undo the active run has. A missing/corrupt manifest yields an empty id, so -# the guard simply does not fire (nothing claims to be active). -ACTIVE_RUN_ID="$(manifest_read 2>/dev/null | node -e ' - let d = ""; - process.stdin.on("data", (c) => (d += c)).on("end", () => { - try { process.stdout.write(String(JSON.parse(d).run_id || "")); } catch {} - }); -' 2>/dev/null || true)" - -# Same per-repo mutation lock as fan-out/harvest/abort: branch deletion must -# never interleave with a merge in flight (destructive-surface KTD). -acquire_lock "mutate-$(ws_id)" || exit 1 -trap 'release_lock "mutate-$(ws_id)"' EXIT +# Same physical-repository lock as fan-out/harvest/abort: workspace aliases +# must never prune while another workspace mutates the same common git dir. +MUTATION_LOCK="$(repo_mutation_lock_name "$REPO_ROOT")" || exit 1 +acquire_lock "$MUTATION_LOCK" || exit 1 +trap 'release_lock "$MUTATION_LOCK"' EXIT US=$'\x1f' +SCAN="$(bookkeeping_scan "$REPO_ROOT")" || exit 1 +bookkeeping_known=1 +if ! bookkeeping_assert_known "$SCAN"; then + bookkeeping_known=0 + echo "herdr-swarm: bookkeeping_unknown — prune is report-only; ALL branch and backup deletion is refused." >&2 +fi +ACTIVE_RUN_IDS="$(printf '%s' "$SCAN" | node -e ' + let d="";process.stdin.on("data",c=>d+=c).on("end",()=>{for(const x of JSON.parse(d).live||[])console.log(x.run_id)}); +')" +# Confirmation flags never override unknown bookkeeping. +if [ "$bookkeeping_known" -eq 0 ]; then + confirm=0 + prune_backups=0 +fi # branch -> recorded base_ref, harvested from the current manifest plus every # archived one (abort archives, never deletes, precisely so prune can still @@ -70,16 +76,16 @@ US=$'\x1f' # beats refusing prune outright over one unreadable record. collect_bases() { local f - for f in "$(manifest_path)" "$(state_dir)"/archived-*.json; do + [ "$bookkeeping_known" -eq 1 ] || return 0 + printf '%s' "$SCAN" | node -e ' + let d="";process.stdin.on("data",c=>d+=c).on("end",()=>{ + const s=JSON.parse(d); for(const x of [...(s.live||[]),...(s.archived||[])]) console.log(x.path); + }); + ' | while IFS= read -r f; do [ -f "$f" ] || continue node -e ' - const fs = require("fs"); - let doc; - try { doc = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); } - catch { process.exit(0); } - for (const s of doc.slots || []) { - if (s.branch && doc.base_ref) console.log(s.branch + "\x1f" + doc.base_ref); - } + const fs = require("fs"), doc = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); + for (const s of doc.slots || []) if (s.branch && doc.base_ref) console.log(s.branch + "\x1f" + doc.base_ref); ' "$f" done } @@ -93,11 +99,6 @@ recorded_base() { printf '%s\n' "$hit" } -# Fallback base when no manifest mentions a branch: the repo's CURRENT branch -# — said out loud on every such line, because a wrong implicit base is -# exactly how a not-actually-merged branch gets deleted. -cur_branch="$(git -C "$REPO_ROOT" symbolic-ref -q --short HEAD || echo HEAD)" - # The merge commit in base whose SECOND parent is the branch tip — the commit # a revert of the merge would name. Empty for ff/squash merges (then the # revert scan has nothing to match; coarse by design, per plan). @@ -119,9 +120,9 @@ while IFS= read -r b; do if base="$(recorded_base "$b")"; then : else - base="refs/heads/$cur_branch" - [ "$cur_branch" = "HEAD" ] && base="HEAD" # detached: only HEAD itself is usable - note=" [no manifest records this branch — using current branch '$cur_branch' as base]" + echo "kept $b — no validated manifest records its base; destructive current-branch fallback is forbidden" + n_unmerged=$((n_unmerged + 1)) + continue fi if ! git -C "$REPO_ROOT" rev-parse --verify -q "$base^{commit}" >/dev/null; then # A vanished base proves nothing about merged-ness — keep the branch. @@ -170,7 +171,7 @@ while IFS= read -r ref; do # Run id is the path component after the namespace: refs/swarm-backups//. ref_run="${ref#refs/swarm-backups/}" ref_run="${ref_run%%/*}" - if [ -n "$ACTIVE_RUN_ID" ] && [ "$ref_run" = "$ACTIVE_RUN_ID" ]; then + if printf '%s\n' "$ACTIVE_RUN_IDS" | grep -qFx "$ref_run"; then echo "backup $ref [ACTIVE RUN — kept; abort or harvest the run first]" n_refs_active=$((n_refs_active + 1)) continue @@ -199,4 +200,5 @@ if [ "$prune_backups" -eq 0 ] && [ "$n_refs" -gt 0 ]; then echo "herdr-swarm: backup refs were LISTED ONLY — set HERDR_SWARM_PRUNE_BACKUPS=yes to delete them (they are the last copy of discarded work)." fi echo "herdr-swarm: prune summary — merged $n_merged (deleted $n_deleted), unmerged kept $n_unmerged, skipped $n_skipped, backup refs $n_refs (deleted $n_refs_deleted, active-run kept $n_refs_active), archived manifests $n_manifests." +if [ "$bookkeeping_known" -eq 0 ] && [ "$requested_delete" -eq 1 ]; then exit 3; fi exit 0 diff --git a/scripts/safety-state.mjs b/scripts/safety-state.mjs new file mode 100755 index 0000000..2229d09 --- /dev/null +++ b/scripts/safety-state.mjs @@ -0,0 +1,311 @@ +#!/usr/bin/env node +import { createHash, randomBytes } from "node:crypto"; +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; + +function fail(message, code = 1) { + process.stderr.write(`herdr-swarm: ${message}\n`); + process.exit(code); +} + +function git(repo, args, encoding = "utf8") { + const result = spawnSync("git", ["-C", repo, ...args], { + encoding: encoding === null ? undefined : encoding, + maxBuffer: 64 * 1024 * 1024, + }); + if (result.status !== 0) { + fail( + `git ${args.join(" ")} failed for ${repo}: ${String(result.stderr).trim()}`, + ); + } + return result.stdout; +} + +function repoIdentity(repo) { + const root = fs.realpathSync( + git(repo, ["rev-parse", "--show-toplevel"]).trim(), + ); + let common = git(repo, ["rev-parse", "--git-common-dir"]).trim(); + if (!path.isAbsolute(common)) common = path.resolve(root, common); + common = fs.realpathSync(common); + const repoKey = createHash("sha256") + .update("herdr-swarm-repo-v1\0") + .update(common) + .digest("hex"); + return { repo_root: root, git_common_dir: common, repo_key: repoKey }; +} + +function isSafeId(value) { + return typeof value === "string" && /^[A-Za-z0-9_-]+$/.test(value); +} + +function validateManifest(file, doc) { + if (!doc || typeof doc !== "object" || Array.isArray(doc)) + throw new Error("root must be an object"); + if (!isSafeId(doc.run_id)) throw new Error("run_id is missing or unsafe"); + if (typeof doc.repo_root !== "string" || doc.repo_root.length === 0) + throw new Error("repo_root is missing"); + if ( + typeof doc.base_ref !== "string" || + !doc.base_ref.startsWith("refs/heads/") + ) + throw new Error("base_ref is invalid"); + if (!Array.isArray(doc.slots)) throw new Error("slots must be an array"); + const seen = new Set(); + for (const row of doc.slots) { + if (!row || typeof row !== "object" || Array.isArray(row)) + throw new Error("slot row must be an object"); + const slot = String(row.slot ?? ""); + if (!/^[0-9]+$/.test(slot) || seen.has(slot)) + throw new Error("slot ids must be unique positive integers"); + seen.add(slot); + if ( + typeof row.branch !== "string" || + !row.branch.startsWith(`swarm/${doc.run_id}/`) + ) { + throw new Error(`slot ${slot} branch is outside the run namespace`); + } + } + let identity; + try { + identity = repoIdentity(doc.repo_root); + } catch (error) { + throw new Error(`repo identity cannot be resolved: ${error.message}`); + } + if (doc.repo_key != null && doc.repo_key !== identity.repo_key) + throw new Error("repo_key does not match repo_root"); + if (doc.git_common_dir != null) { + let recorded; + try { + recorded = fs.realpathSync(doc.git_common_dir); + } catch { + throw new Error("git_common_dir cannot be resolved"); + } + if (recorded !== identity.git_common_dir) + throw new Error("git_common_dir does not match repo_root"); + } + return { file, doc, identity }; +} + +function manifestFiles(stateDir) { + return fs + .readdirSync(stateDir) + .filter((name) => + /^(?:run-[A-Za-z0-9_-]+|archived-[A-Za-z0-9_-]+)\.json$/.test(name), + ) + .sort() + .map((name) => path.join(stateDir, name)); +} + +function scan(stateDir, repo) { + const target = repoIdentity(repo); + const errors = []; + const live = []; + const archived = []; + const runIds = new Map(); + for (const file of manifestFiles(stateDir)) { + let stat; + try { + stat = fs.lstatSync(file); + } catch (error) { + errors.push(`${file}: cannot lstat: ${error.message}`); + continue; + } + if (stat.isSymbolicLink() || !stat.isFile()) { + errors.push(`${file}: bookkeeping must be a regular non-symlink file`); + continue; + } + if (stat.size === 0) { + errors.push(`${file}: bookkeeping is zero-length`); + continue; + } + let parsed; + try { + parsed = validateManifest( + file, + JSON.parse(fs.readFileSync(file, "utf8")), + ); + } catch (error) { + errors.push(`${file}: ${error.message}`); + continue; + } + if (parsed.identity.repo_key !== target.repo_key) continue; + const previous = runIds.get(parsed.doc.run_id); + if (previous) + errors.push( + `${file}: duplicate run_id ${parsed.doc.run_id} also appears in ${previous}`, + ); + else runIds.set(parsed.doc.run_id, file); + const item = { + path: file, + run_id: parsed.doc.run_id, + workspace_id: path + .basename(file) + .replace(/^run-/, "") + .replace(/\.json$/, ""), + exclude_pattern_added: parsed.doc.exclude_pattern_added === true, + }; + if (path.basename(file).startsWith("run-")) live.push(item); + else archived.push(item); + } + if (live.length > 1) + errors.push( + `multiple live manifests exist for repo ${target.repo_key}: ${live.map((x) => x.path).join(", ")}`, + ); + const activePath = path.join(stateDir, `active-repo-${target.repo_key}.json`); + if (fs.existsSync(activePath)) { + try { + const stat = fs.lstatSync(activePath); + if (stat.isSymbolicLink() || !stat.isFile() || stat.size === 0) + throw new Error("must be a non-empty regular non-symlink file"); + const active = JSON.parse(fs.readFileSync(activePath, "utf8")); + if ( + active.repo_key !== target.repo_key || + !isSafeId(active.run_id) || + typeof active.manifest_path !== "string" + ) { + throw new Error("identity fields are invalid"); + } + const match = live.find( + (x) => + x.run_id === active.run_id && + path.resolve(x.path) === path.resolve(active.manifest_path), + ); + if (!match) + throw new Error("does not point to the exact live manifest generation"); + } catch (error) { + errors.push(`${activePath}: active index ${error.message}`); + } + } + return { ...target, active_index_path: activePath, live, archived, errors }; +} + +function canonicalInventory(repo, binding) { + const output = git( + repo, + ["ls-files", "--others", "--ignored", "--exclude-standard", "-z"], + null, + ); + const paths = []; + let start = 0; + for (let i = 0; i < output.length; i += 1) { + if (output[i] !== 0) continue; + const item = output.subarray(start, i); + start = i + 1; + if (item.length === 0 || item.equals(Buffer.from(".swarm-task.md"))) + continue; + paths.push(Buffer.from(item)); + } + paths.sort(Buffer.compare); + const hash = createHash("sha256"); + hash.update("herdr-swarm-cleanup-v1\0"); + for (const value of [ + binding.repo_key, + binding.git_common_dir, + binding.run_id, + binding.slot, + binding.worktree, + binding.operation_id, + ]) { + const bytes = Buffer.from(String(value)); + const length = Buffer.alloc(8); + length.writeBigUInt64BE(BigInt(bytes.length)); + hash.update(length).update(bytes); + } + for (const item of paths) { + const length = Buffer.alloc(8); + length.writeBigUInt64BE(BigInt(item.length)); + hash.update(length).update(item); + } + return { + ...binding, + digest: hash.digest("hex"), + count: paths.length, + paths_base64: paths.map((item) => item.toString("base64")), + paths_display: paths.map((item) => item.toString("utf8")), + }; +} + +const [command, ...args] = process.argv.slice(2); +try { + switch (command) { + case "repo": + process.stdout.write(`${JSON.stringify(repoIdentity(args[0]))}\n`); + break; + case "scan": + process.stdout.write(`${JSON.stringify(scan(args[0], args[1]))}\n`); + break; + case "operation-id": + process.stdout.write( + `cleanup-${Date.now().toString(36)}-${randomBytes(8).toString("hex")}\n`, + ); + break; + case "inventory": { + const [repo, repoKey, common, runId, slot, worktree, operationId] = args; + if (!isSafeId(runId) || !isSafeId(operationId) || !/^[0-9]+$/.test(slot)) + fail("cleanup inventory binding is invalid"); + const identity = repoIdentity(repo); + if (identity.repo_key !== repoKey || identity.git_common_dir !== common) + fail("cleanup inventory repo identity changed"); + const physical = fs.realpathSync(worktree); + const binding = { + repo_key: repoKey, + git_common_dir: common, + run_id: runId, + slot, + worktree: physical, + operation_id: operationId, + }; + process.stdout.write( + `${JSON.stringify(canonicalInventory(physical, binding))}\n`, + ); + break; + } + case "approval": { + let raw = ""; + for await (const chunk of process.stdin) raw += chunk; + const [inventoryRaw, stateDir] = args; + const inventory = JSON.parse(inventoryRaw); + const approval = JSON.parse(raw); + for (const key of [ + "repo_key", + "git_common_dir", + "run_id", + "slot", + "worktree", + "operation_id", + "digest", + ]) { + if (String(approval[key] ?? "") !== String(inventory[key] ?? "")) + fail(`cleanup approval ${key} mismatch`, 2); + } + if (approval.approved !== true) + fail("cleanup approval is not approved", 2); + if (!isSafeId(approval.operation_id)) + fail("cleanup approval operation_id is unsafe", 2); + const used = path.join( + stateDir, + `cleanup-used-${approval.repo_key}-${approval.operation_id}.json`, + ); + if (fs.existsSync(used)) fail("cleanup approval was already consumed", 2); + process.stdout.write(`${used}\n`); + break; + } + case "consume": { + let raw = ""; + for await (const chunk of process.stdin) raw += chunk; + const [used] = args; + const fd = fs.openSync(used, "wx", 0o600); + fs.writeFileSync(fd, raw); + fs.fsyncSync(fd); + fs.closeSync(fd); + process.stdout.write(`${used}\n`); + break; + } + default: + fail(`unknown safety-state command '${command ?? ""}'`); + } +} catch (error) { + fail(error.message); +} diff --git a/tests/cleanup.test.mjs b/tests/cleanup.test.mjs index 033f399..923892b 100644 --- a/tests/cleanup.test.mjs +++ b/tests/cleanup.test.mjs @@ -40,9 +40,13 @@ const run = (r, script, extraEnv = {}, cwd = r.repo) => }); const branchExists = (repo, b) => - spawnSync("git", ["-C", repo, "rev-parse", "--verify", "-q", `refs/heads/${b}`], { - encoding: "utf8", - }).status === 0; + spawnSync( + "git", + ["-C", repo, "rev-parse", "--verify", "-q", `refs/heads/${b}`], + { + encoding: "utf8", + }, + ).status === 0; const count = (haystack, needle) => haystack.split(needle).length - 1; @@ -58,10 +62,18 @@ test("abort closes tracked panes exactly once and the label sweep spares the dec const a = run(r, "abort.sh"); assert.equal(a.status, 0, `${a.stdout}\n${a.stderr}`); const log = h.log(); - assert.equal(count(log, "pane close w9:p9"), 1, "tracked+swept pane closed once"); + assert.equal( + count(log, "pane close w9:p9"), + 1, + "tracked+swept pane closed once", + ); // w9:p4 is a plain user terminal pane in the stub pane list (no plugin // label) — the sweep matches manifest pane titles only, so it survives. - assert.equal(log.includes("pane close w9:p4"), false, "decoy user pane untouched"); + assert.equal( + log.includes("pane close w9:p4"), + false, + "decoy user pane untouched", + ); assert.equal(fs.existsSync(path.join(r.sdir, "status-pane-w9")), false); assert.match(a.stdout, /abort summary/); }); @@ -84,7 +96,7 @@ test("dirty slot worktree is KEPT and reported — never prompted, never forced" // what it cannot verify — but it also never lets one unverifiable row abandon // the rest of the teardown, so the mismatch KEEPS and reports rather than // exiting. -test("abort KEEPS a slot whose branch is outside the run namespace and still tears the rest down", () => { +test("abort refuses every deletion when a slot branch makes bookkeeping semantically unknown", () => { const r = mkRun({ slots: 2 }); // A cross-repo or hand-edited manifest row: this branch is not ours, so // neither is anything it names. @@ -92,14 +104,13 @@ test("abort KEEPS a slot whose branch is outside the run namespace and still tea d.slots.find((s) => s.slot === 1).branch = "swarm/some-other-run/s1"; }); const a = run(r, "abort.sh"); - assert.equal(a.status, 4, `${a.stdout}\n${a.stderr}`); + assert.equal(a.status, 3, `${a.stdout}\n${a.stderr}`); assert.equal(fs.existsSync(r.wt(1)), true, "unverifiable worktree survives"); - assert.match(a.stderr, /slot 1 KEPT — ownership check failed/); - assert.match(a.stderr, /outside this run's namespace/); - // The whole run is not abandoned over one bad row. - assert.equal(fs.existsSync(r.wt(2)), false, "slot 2 was still reaped"); - assert.match(a.stdout, /worktrees removed 1, kept 1/); - assert.equal(r.slotRow(1).status, "running", "kept slot is not marked archived"); + assert.equal(fs.existsSync(r.wt(2)), true, "all deletion is refused"); + assert.match(a.stderr, /bookkeeping_unknown/); + assert.match(a.stderr, /outside the run namespace/); + assert.doesNotMatch(h.log(), /worktree remove|pane close/); + assert.equal(r.slotRow(1).status, "running", "manifest is untouched"); }); test("abort KEEPS a slot whose recorded path is another branch's worktree", () => { @@ -116,7 +127,11 @@ test("abort KEEPS a slot whose recorded path is another branch's worktree", () = assert.equal(a.status, 4, `${a.stdout}\n${a.stderr}`); assert.match(a.stderr, /slot 1 KEPT — ownership check failed/); assert.match(a.stderr, /is not a worktree of .* checked out on/); - assert.equal(fs.existsSync(r.wt(2)), true, "the other slot's worktree untouched"); + assert.equal( + fs.existsSync(r.wt(2)), + true, + "the other slot's worktree untouched", + ); }); // The recovery route abort prints is Harvest, and Harvest reads the LIVE @@ -139,7 +154,11 @@ test("abort that KEPT a dirty slot leaves the run ACTIVE: manifest stays live an ); assert.match(a.stdout, /stays ACTIVE/); assert.ok(a.stdout.includes(r.wt(1)), "the kept worktree is listed by path"); - assert.equal(r.slotRow(1).status, "running", "kept slot is not marked archived"); + assert.equal( + r.slotRow(1).status, + "running", + "kept slot is not marked archived", + ); }); // The exclude file is shared repo-wide: dropping the pattern while a kept @@ -152,10 +171,14 @@ test("abort that KEPT a slot also keeps the .swarm-task.md exclude pattern", () const lines = fs .readFileSync(path.join(r.repo, ".git/info/exclude"), "utf8") .split("\n"); - assert.ok(lines.includes(".swarm-task.md"), "pattern survives a kept-work abort"); + assert.ok( + lines.includes(".swarm-task.md"), + "pattern survives a kept-work abort", + ); assert.equal( - spawnSync("git", ["-C", r.wt(1), "status", "--porcelain"], { encoding: "utf8" }) - .stdout.includes(".swarm-task.md"), + spawnSync("git", ["-C", r.wt(1), "status", "--porcelain"], { + encoding: "utf8", + }).stdout.includes(".swarm-task.md"), false, "task file still invisible to git status in the kept worktree", ); @@ -170,13 +193,19 @@ test("harvest worktree with unmerged paths is a LIVE conflict: kept untouched, n commitIn(r.wt(1), "conflict.txt", "slot side\n"); const hwt = path.join(r.sdir, `harvest-${r.runId}-s1`); h.git(r.repo, "worktree", "add", "-q", "--detach", hwt, "main"); - const m = spawnSync("git", ["-C", hwt, "merge", "--no-ff", "-m", "swarm merge", r.branch(1)], { - encoding: "utf8", - env: { PATH: "/usr/bin:/bin", HOME: os.homedir(), ...gitIdent }, - }); + const m = spawnSync( + "git", + ["-C", hwt, "merge", "--no-ff", "-m", "swarm merge", r.branch(1)], + { + encoding: "utf8", + env: { PATH: "/usr/bin:/bin", HOME: os.homedir(), ...gitIdent }, + }, + ); assert.notEqual(m.status, 0, "fixture must actually conflict"); assert.notEqual( - spawnSync("git", ["-C", hwt, "ls-files", "-u"], { encoding: "utf8" }).stdout.trim(), + spawnSync("git", ["-C", hwt, "ls-files", "-u"], { + encoding: "utf8", + }).stdout.trim(), "", "fixture must leave unmerged index entries", ); @@ -190,9 +219,15 @@ test("harvest worktree with unmerged paths is a LIVE conflict: kept untouched, n }); const a = run(r, "abort.sh"); assert.equal(a.status, 4, `${a.stdout}\n${a.stderr}`); - assert.equal(fs.existsSync(hwt), true, "conflicted harvest worktree survives"); + assert.equal( + fs.existsSync(hwt), + true, + "conflicted harvest worktree survives", + ); assert.notEqual( - spawnSync("git", ["-C", hwt, "ls-files", "-u"], { encoding: "utf8" }).stdout.trim(), + spawnSync("git", ["-C", hwt, "ls-files", "-u"], { + encoding: "utf8", + }).stdout.trim(), "", "the in-flight conflict resolution was NOT reset", ); @@ -204,7 +239,10 @@ test("crash abort (manifest present, herdr agents gone) completes, reaps both sl commitIn(r.wt(1), "a.txt"); commitIn(r.wt(2), "b.txt"); // Foreign branch + worktree: ownership rule says abort may never touch them. - const fwt = path.join(fs.mkdtempSync(path.join(os.tmpdir(), "hs-fwt-")), "fx"); + const fwt = path.join( + fs.mkdtempSync(path.join(os.tmpdir(), "hs-fwt-")), + "fx", + ); h.git(r.repo, "worktree", "add", "-q", "-b", "feature-x", fwt, r.fork); const a = run(r, "abort.sh"); assert.equal(a.status, 0, `${a.stdout}\n${a.stderr}`); @@ -239,8 +277,16 @@ test("pending-null-path row: worktree found by run-unique branch name and reaped }); const a = run(r, "abort.sh"); assert.equal(a.status, 0, `${a.stdout}\n${a.stderr}`); - assert.equal(fs.existsSync(wt), false, "worktree reconciled by branch and removed"); - assert.equal(h.log().includes("worktree remove"), false, "no workspace id -> plain git path"); + assert.equal( + fs.existsSync(wt), + false, + "worktree reconciled by branch and removed", + ); + assert.equal( + h.log().includes("worktree remove"), + false, + "no workspace id -> plain git path", + ); assert.match(a.stdout, /worktrees removed 1/); }); @@ -261,10 +307,18 @@ test("harvest worktree holding an un-swapped merge commit is reported loudly, ne }); const a = run(r, "abort.sh"); assert.equal(a.status, 4, `${a.stdout}\n${a.stderr}`); // kept work => ACTIVE - assert.equal(fs.existsSync(hwt), true, "worktree with the dangling commit survives"); + assert.equal( + fs.existsSync(hwt), + true, + "worktree with the dangling commit survives", + ); assert.match(a.stderr, /UN-SWAPPED/); assert.ok(a.stderr.includes(msha), "dangling SHA named in the report"); - assert.equal(fs.existsSync(r.wt(1)), false, "the clean slot worktree still gets reaped"); + assert.equal( + fs.existsSync(r.wt(1)), + false, + "the clean slot worktree still gets reaped", + ); assert.match(a.stdout, /kept 1/); }); @@ -273,7 +327,11 @@ test("corrupt manifest: report-only discovery, nothing destroyed, distinct exit fs.writeFileSync(path.join(r.sdir, "run-w9.json"), "{ definitely not json"); const a = run(r, "abort.sh"); assert.equal(a.status, 3, `${a.stdout}\n${a.stderr}`); - assert.match(a.stderr, /\.bak/, "recovery hint names the previous generation"); + assert.match( + a.stderr, + /\.bak/, + "recovery hint names the previous generation", + ); assert.match(a.stdout, /WOULD act on/); assert.ok(a.stdout.includes(r.branch(1)), "discovery lists the swarm branch"); assert.ok(a.stdout.includes(r.wt(1)), "discovery lists the swarm worktree"); @@ -284,7 +342,11 @@ test("corrupt manifest: report-only discovery, nothing destroyed, distinct exit "corrupt manifest kept in place (not archived, not deleted)", ); assert.equal(h.log().includes("pane close"), false, "no pane was closed"); - assert.match(a.stdout, /abort summary/, "summary printed on the refusal path too"); + assert.match( + a.stdout, + /abort summary/, + "summary printed on the refusal path too", + ); }); test("manually deleted worktree path: 'already gone' via the worktree-prune fallback", () => { @@ -334,7 +396,11 @@ test("abort archives the manifest (rename, never delete) with final slot states" assert.equal(a.status, 0, `${a.stdout}\n${a.stderr}`); assert.equal(fs.existsSync(path.join(r.sdir, "run-w9.json")), false); const doc = r.archived(); - assert.equal(doc.run_id, r.runId, "archived manifest parses and is the run's record"); + assert.equal( + doc.run_id, + r.runId, + "archived manifest parses and is the run's record", + ); assert.equal(doc.slots[0].status, "archived"); }); @@ -353,7 +419,11 @@ exit 0`, const a = run(r, "abort.sh"); h.writeHerdrStub(); // restore the shared default for later tests assert.equal(a.status, 0, `${a.stdout}\n${a.stderr}`); - assert.equal(fs.existsSync(r.wt(1)), false, "git fallback removed the worktree"); + assert.equal( + fs.existsSync(r.wt(1)), + false, + "git fallback removed the worktree", + ); assert.match(a.stderr, /falling back to plain git/); assert.match(h.log(), /workspace close w11/); }); @@ -411,28 +481,32 @@ function addBranch(p, branch, { merge = true } = {}) { function writeArchived(p, runId, branches, baseRef = "refs/heads/main") { fs.writeFileSync( path.join(p.sdir, `archived-${runId}.json`), - JSON.stringify({ - run_id: runId, - repo_root: p.repo, - base_ref: baseRef, - fork_sha: p.fork, - created_at: "2026-07-22T15:00:00Z", - exclude_pattern_added: false, - slots: branches.map((b, i) => ({ - slot: i + 1, - label: `s${i + 1}`, - branch: b, - path: null, - workspace_id: null, - pane_id: null, - terminal_id: null, - agent_name: "claude", - self_created: true, - status: "archived", - backup_ref: null, - journal: null, - })), - }, null, 2), + JSON.stringify( + { + run_id: runId, + repo_root: p.repo, + base_ref: baseRef, + fork_sha: p.fork, + created_at: "2026-07-22T15:00:00Z", + exclude_pattern_added: false, + slots: branches.map((b, i) => ({ + slot: i + 1, + label: `s${i + 1}`, + branch: b, + path: null, + workspace_id: null, + pane_id: null, + terminal_id: null, + agent_name: "claude", + self_created: true, + status: "archived", + backup_ref: null, + journal: null, + })), + }, + null, + 2, + ), ); } @@ -447,7 +521,10 @@ test("prune dry-run lists merged branch, backup refs, archived manifests — and assert.match(r.stdout, /backup\s+refs\/swarm-backups\/rp1\/1/); assert.match(r.stdout, /archived manifests: 1/); assert.match(r.stdout, /DRY RUN/); - assert.ok(branchExists(p.repo, "swarm/rp1/s1"), "branch survives the dry run"); + assert.ok( + branchExists(p.repo, "swarm/rp1/s1"), + "branch survives the dry run", + ); assert.equal( h.git(p.repo, "for-each-ref", "refs/swarm-backups").stdout.trim() === "", false, @@ -468,11 +545,19 @@ test("prune with confirm deletes merged branch and backup ref; unmerged and fore HERDR_SWARM_PRUNE_BACKUPS: "yes", }); assert.equal(r.status, 0, `${r.stdout}\n${r.stderr}`); - assert.equal(branchExists(p.repo, "swarm/rp2/s1"), false, "merged branch deleted"); + assert.equal( + branchExists(p.repo, "swarm/rp2/s1"), + false, + "merged branch deleted", + ); assert.ok(branchExists(p.repo, "swarm/rp2/s2"), "unmerged branch kept"); assert.match(r.stdout, /unmerged swarm\/rp2\/s2/); assert.ok(branchExists(p.repo, "feature-x"), "foreign branch untouched"); - assert.equal(r.stdout.includes("feature-x"), false, "foreign branch never even listed"); + assert.equal( + r.stdout.includes("feature-x"), + false, + "foreign branch never even listed", + ); assert.equal( h.git(p.repo, "for-each-ref", "refs/swarm-backups").stdout.trim(), "", @@ -490,11 +575,21 @@ test("PRUNE_CONFIRM alone lists backup refs but never deletes one", () => { h.git(p.repo, "update-ref", "refs/swarm-backups/rp6/1", p.fork); const r = run(p, "prune.sh", { HERDR_SWARM_PRUNE_CONFIRM: "yes" }); assert.equal(r.status, 0, `${r.stdout}\n${r.stderr}`); - assert.equal(branchExists(p.repo, "swarm/rp6/s1"), false, "the branch gate still works"); + assert.equal( + branchExists(p.repo, "swarm/rp6/s1"), + false, + "the branch gate still works", + ); assert.match(r.stdout, /backup\s+refs\/swarm-backups\/rp6\/1/, "listed"); - assert.equal(r.stdout.includes("deleted refs/swarm-backups"), false, "never deleted"); + assert.equal( + r.stdout.includes("deleted refs/swarm-backups"), + false, + "never deleted", + ); assert.ok( - h.git(p.repo, "for-each-ref", "refs/swarm-backups").stdout.includes("rp6/1"), + h + .git(p.repo, "for-each-ref", "refs/swarm-backups") + .stdout.includes("rp6/1"), "backup ref survives PRUNE_CONFIRM", ); assert.match(r.stdout, /HERDR_SWARM_PRUNE_BACKUPS/, "the real flag is named"); @@ -510,15 +605,19 @@ test("PRUNE_BACKUPS deletes archived-run snapshots but never the ACTIVE run's", const active = "rp7live"; fs.writeFileSync( path.join(p.sdir, "run-w9.json"), - JSON.stringify({ - run_id: active, - repo_root: p.repo, - base_ref: "refs/heads/main", - fork_sha: p.fork, - created_at: "2026-07-22T15:00:00Z", - exclude_pattern_added: false, - slots: [], - }, null, 2), + JSON.stringify( + { + run_id: active, + repo_root: p.repo, + base_ref: "refs/heads/main", + fork_sha: p.fork, + created_at: "2026-07-22T15:00:00Z", + exclude_pattern_added: false, + slots: [], + }, + null, + 2, + ), ); h.git(p.repo, "update-ref", `refs/swarm-backups/${active}/1`, p.fork); const r = run(p, "prune.sh", { @@ -542,12 +641,18 @@ test("ancestry is judged against the recorded base even with another branch chec h.git(p.repo, "checkout", "-q", "-b", "elsewhere", p.fork); const dry = run(p, "prune.sh"); assert.equal(dry.status, 0, `${dry.stdout}\n${dry.stderr}`); - assert.match(dry.stdout, /merged\s+swarm\/rp3\/s1 \(base refs\/heads\/main\)/); + assert.match( + dry.stdout, + /merged\s+swarm\/rp3\/s1 \(base refs\/heads\/main\)/, + ); // Under confirm, `git branch -d` (HEAD-relative) refuses — prune reports // and skips instead of escalating to -D. const del = run(p, "prune.sh", { HERDR_SWARM_PRUNE_CONFIRM: "yes" }); assert.equal(del.status, 0, `${del.stdout}\n${del.stderr}`); - assert.ok(branchExists(p.repo, "swarm/rp3/s1"), "branch survives the -d refusal"); + assert.ok( + branchExists(p.repo, "swarm/rp3/s1"), + "branch survives the -d refusal", + ); assert.match(del.stderr, /branch -d refused/); }); @@ -572,21 +677,29 @@ test("merged-then-reverted: flagged in dry-run; deletion additionally requires t const dry = run(p, "prune.sh"); assert.match(dry.stdout, /MERGED-THEN-REVERTED/); const noAck = run(p, "prune.sh", { HERDR_SWARM_PRUNE_CONFIRM: "yes" }); - assert.ok(branchExists(p.repo, "swarm/rp5/s1"), "confirm alone does not delete a reverted branch"); + assert.ok( + branchExists(p.repo, "swarm/rp5/s1"), + "confirm alone does not delete a reverted branch", + ); assert.match(noAck.stderr, /HERDR_SWARM_PRUNE_ACK_REVERTED/); const acked = run(p, "prune.sh", { HERDR_SWARM_PRUNE_CONFIRM: "yes", HERDR_SWARM_PRUNE_ACK_REVERTED: "yes", }); assert.equal(acked.status, 0, `${acked.stdout}\n${acked.stderr}`); - assert.equal(branchExists(p.repo, "swarm/rp5/s1"), false, "ack + confirm deletes it"); + assert.equal( + branchExists(p.repo, "swarm/rp5/s1"), + false, + "ack + confirm deletes it", + ); }); -test("branch no manifest mentions falls back to the current branch as base — and says so per line", () => { +test("branch no validated manifest mentions is kept without a destructive current-branch fallback", () => { const p = mkPruneRepo(); // state dir stays empty: no manifest knows this branch addBranch(p, "swarm/orphan/s1"); const r = run(p, "prune.sh"); assert.equal(r.status, 0, `${r.stdout}\n${r.stderr}`); - assert.match(r.stdout, /merged\s+swarm\/orphan\/s1/); - assert.match(r.stdout, /no manifest records this branch — using current branch 'main' as base/); + assert.match(r.stdout, /kept\s+swarm\/orphan\/s1/); + assert.match(r.stdout, /destructive current-branch fallback is forbidden/); + assert.ok(branchExists(p.repo, "swarm/orphan/s1")); }); diff --git a/tests/harness.mjs b/tests/harness.mjs index a636376..461c87a 100644 --- a/tests/harness.mjs +++ b/tests/harness.mjs @@ -71,7 +71,10 @@ export function makeFannedOutRun(h, opts = {}) { const slots = []; for (let i = 1; i <= nslots; i++) { const branch = `swarm/${runId}/s${i}`; - const wt = path.join(fs.mkdtempSync(path.join(os.tmpdir(), "hs-wt-")), `s${i}`); + const wt = path.join( + fs.mkdtempSync(path.join(os.tmpdir(), "hs-wt-")), + `s${i}`, + ); git(repo, "worktree", "add", "-q", "-b", branch, wt, fork); fs.writeFileSync(path.join(wt, ".swarm-task.md"), "task\n"); slots.push({ @@ -113,10 +116,15 @@ export function makeFannedOutRun(h, opts = {}) { branch: (i) => slots[i - 1].branch, manifest: () => JSON.parse(fs.readFileSync(path.join(sdir, "run-w9.json"), "utf8")), - slotRow: (i) => - JSON.parse(fs.readFileSync(path.join(sdir, "run-w9.json"), "utf8")).slots.find( + slotRow: (i) => { + const live = path.join(sdir, "run-w9.json"); + const file = fs.existsSync(live) + ? live + : path.join(sdir, `archived-${runId}.json`); + return JSON.parse(fs.readFileSync(file, "utf8")).slots.find( (s) => s.slot === i, - ), + ); + }, archived: () => JSON.parse( fs.readFileSync(path.join(sdir, `archived-${runId}.json`), "utf8"), diff --git a/tests/harvest.test.mjs b/tests/harvest.test.mjs index a1eeef6..16b1cec 100644 --- a/tests/harvest.test.mjs +++ b/tests/harvest.test.mjs @@ -45,18 +45,29 @@ function bareCommitOn(repo, sha, msg = "racer") { } const step = (run, verb, args = [], extraEnv = {}) => - h.runScript( - "harvest-step.sh", - [verb, ...args.map(String)], - { ...run.env, ...extraEnv }, - ); + h.runScript("harvest-step.sh", [verb, ...args.map(String)], { + ...run.env, + ...extraEnv, + }); + +function cleanupApproval(stdout) { + const line = stdout + .split("\n") + .find((entry) => entry.startsWith("cleanup_approval\t")); + assert.ok(line, `cleanup approval missing from preview:\n${stdout}`); + return line.slice("cleanup_approval\t".length); +} // Async variant for the interleave tests: the verb must be mid-flight while // the test mutates the repo. function stepAsync(run, verb, args = [], extraEnv = {}) { const child = spawn( "bash", - [path.join(repoRoot, "scripts", "harvest-step.sh"), verb, ...args.map(String)], + [ + path.join(repoRoot, "scripts", "harvest-step.sh"), + verb, + ...args.map(String), + ], { env: { ...run.env, ...extraEnv } }, ); let out = ""; @@ -151,7 +162,8 @@ test("locus: base NOT checked out -> detached harvest worktree, base advanced by assert.equal(h.git(run.repo, "rev-parse", "HEAD").stdout.trim(), run.fork); // Reflog breadcrumb (KTD): the recovery trail for the atomic swap. assert.match( - h.git(run.repo, "log", "-g", "-1", "--format=%gs", "refs/heads/main").stdout, + h.git(run.repo, "log", "-g", "-1", "--format=%gs", "refs/heads/main") + .stdout, new RegExp(`swarm: harvest merge 1 \\(run ${run.runId}\\)`), ); assert.ok( @@ -162,7 +174,8 @@ test("locus: base NOT checked out -> detached harvest worktree, base advanced by assert.equal(run.slotRow(1).journal, null); // R10: archive keeps branches; merge must not delete them either. assert.equal( - h.git(run.repo, "rev-parse", "--verify", `refs/heads/${run.branch(1)}`) + h + .git(run.repo, "rev-parse", "--verify", `refs/heads/${run.branch(1)}`) .stdout.trim(), tip, ); @@ -255,7 +268,9 @@ test("sequencer state in ANY worktree refuses the merge before any mutation", () commitIn(run.wt(1), "feat.txt"); // MERGE_HEAD in a linked worktree's private git dir (not the main one) — // the scan must cover worktrees/*, not just the common dir. - const wtGitDir = h.git(run.wt(1), "rev-parse", "--absolute-git-dir").stdout.trim(); + const wtGitDir = h + .git(run.wt(1), "rev-parse", "--absolute-git-dir") + .stdout.trim(); fs.writeFileSync(path.join(wtGitDir, "MERGE_HEAD"), `${run.fork}\n`); let r = step(run, "merge", [1, run.fork]); assert.equal(r.status, EC.SEQUENCER, `${r.stdout}\n${r.stderr}`); @@ -278,16 +293,28 @@ test("conflict in the detached locus: base and user checkout untouched, worktree const run = mkRun(); // Conflicting edits to the same file on slot and base. commitIn(run.wt(1), "README.md", "slot version\n", "slot edit"); - const baseTip = commitIn(run.repo, "README.md", "base version\n", "base edit"); + const baseTip = commitIn( + run.repo, + "README.md", + "base version\n", + "base edit", + ); h.git(run.repo, "checkout", "-q", "-b", "elsewhere"); const r = step(run, "merge", [1, baseTip]); assert.equal(r.status, EC.CONFLICT, `${r.stdout}\n${r.stderr}`); - assert.match(r.stdout, /conflict_file\tREADME\.md/, "conflicted files listed"); + assert.match( + r.stdout, + /conflict_file\tREADME\.md/, + "conflicted files listed", + ); const tree = /merge_tree\t(.*)/.exec(r.stdout)?.[1]; assert.ok(tree && fs.existsSync(tree), "merge tree left for inspection"); assert.ok( fs.existsSync( - path.join(h.git(tree, "rev-parse", "--absolute-git-dir").stdout.trim(), "MERGE_HEAD"), + path.join( + h.git(tree, "rev-parse", "--absolute-git-dir").stdout.trim(), + "MERGE_HEAD", + ), ), "merge is genuinely in progress in the harvest worktree", ); @@ -304,7 +331,11 @@ test("conflict in the detached locus: base and user checkout untouched, worktree // Abort cleans up: worktree removed, journal cleared, base still put. const a = step(run, "abort-merge", [1]); assert.equal(a.status, 0, `${a.stdout}\n${a.stderr}`); - assert.equal(fs.existsSync(tree), false, "harvest worktree reaped after abort"); + assert.equal( + fs.existsSync(tree), + false, + "harvest worktree reaped after abort", + ); assert.equal(run.slotRow(1).journal, null); assert.equal( h.git(run.repo, "rev-parse", "refs/heads/main").stdout.trim(), @@ -420,7 +451,7 @@ test("preview reports base_sha, locus, and the three-dot diffstat for a clean sl assert.equal(r.status, 0, `${r.stdout}\n${r.stderr}`); assert.match(r.stdout, new RegExp(`base_sha\t${run.fork}`)); assert.match(r.stdout, /state\tclean/); - assert.match(r.stdout, new RegExp(`locus\tuser-tree\t`)); + assert.match(r.stdout, /locus\tuser-tree\t/); assert.match(r.stdout, /stat\t.*feat\.txt/); // Detached once the user moves off base. h.git(run.repo, "checkout", "-q", "-b", "elsewhere"); @@ -438,7 +469,13 @@ test("commit-wip commits tracked and untracked work as one WIP commit", () => { h.git(run.wt(1), "log", "-1", "--format=%s").stdout.trim(), `swarm: WIP s1 (run ${run.runId})`, ); - const files = h.git(run.wt(1), "show", "--name-only", "--format=", "HEAD").stdout; + const files = h.git( + run.wt(1), + "show", + "--name-only", + "--format=", + "HEAD", + ).stdout; assert.match(files, /new\.txt/, "untracked work is in the WIP commit"); assert.doesNotMatch(files, /swarm-task/, "task file never committed"); }); @@ -450,7 +487,9 @@ test("snapshot captures untracked work in a backup ref without touching the real const before = h.git(run.wt(1), "status", "--porcelain").stdout; const r = step(run, "snapshot", [1]); assert.equal(r.status, 0, `${r.stdout}\n${r.stderr}`); - const m = /snapshot\t(refs\/swarm-backups\/\S+)\t([0-9a-f]{40})/.exec(r.stdout); + const m = /snapshot\t(refs\/swarm-backups\/\S+)\t([0-9a-f]{40})/.exec( + r.stdout, + ); assert.ok(m, `snapshot line missing in: ${r.stdout}`); const [, ref, sha] = m; assert.equal(ref, `refs/swarm-backups/${run.runId}/1`); @@ -464,10 +503,7 @@ test("snapshot captures untracked work in a backup ref without touching the real "precious\n", "untracked content captured (git stash create would have skipped it)", ); - assert.equal( - h.git(run.repo, "show", `${sha}:README.md`).stdout, - "edited\n", - ); + assert.equal(h.git(run.repo, "show", `${sha}:README.md`).stdout, "edited\n"); assert.throws( () => h.git(run.repo, "show", `${sha}:.swarm-task.md`), /git show.*failed/, @@ -488,7 +524,10 @@ test("discard refuses without a snapshot, refuses a bad token, then discards wit let r = step(run, "discard", [1], { HERDR_SWARM_CONFIRM: run.branch(1) }); assert.equal(r.status, EC.REFUSED, `${r.stdout}\n${r.stderr}`); assert.match(r.stderr, /no recorded snapshot/); - assert.ok(fs.existsSync(path.join(run.wt(1), "untracked.txt")), "tree untouched"); + assert.ok( + fs.existsSync(path.join(run.wt(1), "untracked.txt")), + "tree untouched", + ); assert.equal(step(run, "snapshot", [1]).status, 0); // Renderer typed the wrong thing (or a UI bug): the verb re-verifies. r = step(run, "discard", [1], { HERDR_SWARM_CONFIRM: "wrong-branch" }); @@ -516,7 +555,9 @@ test("kill between merge commit and swap: resume offers completion when base is commitIn(run.wt(1), "feat.txt"); h.git(run.repo, "checkout", "-q", "-b", "elsewhere"); // Deterministic crash seam right after the merge SHA is journaled. - let r = step(run, "merge", [1, run.fork], { HERDR_SWARM_TEST_DIE_BEFORE_SWAP: "1" }); + let r = step(run, "merge", [1, run.fork], { + HERDR_SWARM_TEST_DIE_BEFORE_SWAP: "1", + }); assert.equal(r.status, 99); const msha = run.slotRow(1).journal.merge_commit_sha; assert.match(msha, /^[0-9a-f]{40}$/); @@ -543,7 +584,9 @@ test("kill between merge commit and swap: base moved -> dangling SHA reported lo const run = mkRun(); commitIn(run.wt(1), "feat.txt"); h.git(run.repo, "checkout", "-q", "-b", "elsewhere"); - let r = step(run, "merge", [1, run.fork], { HERDR_SWARM_TEST_DIE_BEFORE_SWAP: "1" }); + let r = step(run, "merge", [1, run.fork], { + HERDR_SWARM_TEST_DIE_BEFORE_SWAP: "1", + }); assert.equal(r.status, 99); const msha = run.slotRow(1).journal.merge_commit_sha; const hwt = run.slotRow(1).journal.worktree; @@ -553,12 +596,19 @@ test("kill between merge commit and swap: base moved -> dangling SHA reported lo r = step(run, "resume"); assert.equal(r.status, 0); assert.match(r.stdout, new RegExp(`resume_dangling\t1\t${msha}`)); - assert.match(r.stderr, /will not be auto-deleted/, "loud, with the policy named"); + assert.match( + r.stderr, + /will not be auto-deleted/, + "loud, with the policy named", + ); assert.ok(fs.existsSync(hwt), "harvest worktree kept"); // Completing anyway must fail the CAS and change nothing. r = step(run, "resume", ["complete", 1]); assert.equal(r.status, EC.SWAP); - assert.equal(h.git(run.repo, "rev-parse", "refs/heads/main").stdout.trim(), racer); + assert.equal( + h.git(run.repo, "rev-parse", "refs/heads/main").stdout.trim(), + racer, + ); assert.ok(fs.existsSync(hwt)); }); @@ -569,7 +619,9 @@ test("two merges in one harvest re-check drift per merge: a stale expected SHA i h.git(run.repo, "checkout", "-q", "-b", "elsewhere"); let r = step(run, "merge", [1, run.fork]); assert.equal(r.status, 0, `${r.stdout}\n${r.stderr}`); - const afterFirst = h.git(run.repo, "rev-parse", "refs/heads/main").stdout.trim(); + const afterFirst = h + .git(run.repo, "rev-parse", "refs/heads/main") + .stdout.trim(); // Slot 2 with the PRE-first-merge SHA: the per-merge drift check refuses. r = step(run, "merge", [2, run.fork]); assert.equal(r.status, EC.DRIFT, `${r.stdout}\n${r.stderr}`); @@ -598,18 +650,28 @@ test("archive: inventory prompt on an agent-created ignored file, none on the ta fs.writeFileSync(path.join(run.wt(1), "debug.log"), "agent output\n"); let r = step(run, "archive", [1]); assert.equal(r.status, EC.IGNORED, `${r.stdout}\n${r.stderr}`); - assert.match(r.stdout, /ignored\tdebug\.log/, "inventory names the file"); + assert.match( + r.stdout, + /ignored_json\t"debug\.log"/, + "inventory names the file", + ); assert.equal(run.slotRow(1).status, "merged", "nothing archived yet"); assert.doesNotMatch(h.log(), /worktree remove/, "no removal before the ack"); - // Acknowledged: removal proceeds via the herdr verb (workspace-scoped, no - // --force), manifest goes archived, branch survives. - r = step(run, "archive", [1], { HERDR_SWARM_ACK_IGNORED: "1" }); + // Apply the exact digest-bound one-use approval emitted by preview. + const approval = cleanupApproval(r.stdout); + r = step(run, "archive", [1], { HERDR_SWARM_CLEANUP_APPROVAL: approval }); assert.equal(r.status, 0, `${r.stdout}\n${r.stderr}`); assert.match(h.log(), /herdr worktree remove --workspace w11 --json/); assert.doesNotMatch(h.log(), /--force/); assert.equal(run.slotRow(1).status, "archived"); assert.equal( - spawnSync("git", ["-C", run.repo, "rev-parse", "--verify", `refs/heads/${run.branch(1)}`]).status, + spawnSync("git", [ + "-C", + run.repo, + "rev-parse", + "--verify", + `refs/heads/${run.branch(1)}`, + ]).status, 0, "branch kept (R10: teardown decoupled from branch deletion)", ); @@ -635,7 +697,11 @@ exit 0`, let r = step(run, "archive", [1]); assert.equal(r.status, EC.REFUSED, `${r.stdout}\n${r.stderr}`); assert.match(r.stderr, /working/, "agent state named"); - assert.doesNotMatch(h.log(), /worktree remove/, "spike (a): never reached the killing verb"); + assert.doesNotMatch( + h.log(), + /worktree remove/, + "spike (a): never reached the killing verb", + ); // Dirty worktree: herdr refuses with the machine-readable code; the verb // routes to the uncommitted-work flow, never message-parses. h.writeStub( @@ -653,7 +719,7 @@ exit 0`, ); const run2 = mkRun({ status: "merged" }); fs.writeFileSync(path.join(run2.wt(1), "wip.txt"), "dirty\n"); - r = step(run2, "archive", [1], { HERDR_SWARM_ACK_IGNORED: "1" }); + r = step(run2, "archive", [1]); assert.equal(r.status, EC.DIRTY, `${r.stdout}\n${r.stderr}`); assert.match(r.stderr, /commit-WIP, skip, or discard/); assert.equal(run2.slotRow(1).status, "merged", "not archived"); @@ -773,7 +839,10 @@ test("resume complete never hands the user's checkout to swap_base's removal tai "the swap itself still completed", ); assert.ok(fs.existsSync(userWt), "the user's checkout survived the swap"); - assert.ok(fs.existsSync(path.join(userWt, "README.md")), "its files are intact"); + assert.ok( + fs.existsSync(path.join(userWt, "README.md")), + "its files are intact", + ); }); test("swap_base refuses any journaled worktree outside the plugin's harvest- namespace", () => { @@ -824,14 +893,15 @@ test("abort-merge keeps a harvest worktree whose HEAD is off base (merge commit assert.ok(fs.existsSync(hwt), "worktree holding the merge commit is kept"); assert.match(r.stderr, new RegExp(msha), "the recoverable SHA is reported"); assert.equal( - spawnSync("git", ["-C", run.repo, "cat-file", "-e", `${msha}^{commit}`]).status, + spawnSync("git", ["-C", run.repo, "cat-file", "-e", `${msha}^{commit}`]) + .status, 0, "the merge commit is still reachable", ); assert.ok(run.slotRow(1).journal, "journal kept — the state stays surfaced"); }); -test("a manifest run_id that escapes the path charset is refused before any git runs", () => { +test("a manifest run_id that escapes the path charset is refused as unknown bookkeeping before any git mutation", () => { const run = mkRun(); const p = path.join(run.sdir, "run-w9.json"); const doc = JSON.parse(fs.readFileSync(p, "utf8")); @@ -843,9 +913,9 @@ test("a manifest run_id that escapes the path charset is refused before any git h.writeStub("git", 'echo "git $@" >> "$STUB_LOG"\nexec /usr/bin/git "$@"'); try { const r = step(run, "preview", [1]); - assert.equal(r.status, 1, `${r.stdout}\n${r.stderr}`); - assert.match(r.stderr, /fails the path charset/); - assert.equal(h.log().trim(), "", "no git ran at all"); + assert.equal(r.status, 3, `${r.stdout}\n${r.stderr}`); + assert.match(r.stderr, /bookkeeping_unknown/); + assert.deepEqual(mutatingGitCalls(h.log()), [], "no git mutation ran"); } finally { fs.rmSync(path.join(h.stubDir, "git"), { force: true }); } @@ -933,10 +1003,31 @@ function withGitCallLog(fn) { // Subcommand-precise, deliberately not a substring match: `merge-base` and // `worktree list` are read-only and would both trip a naive /merge|worktree/. const MUTATING_SUBCOMMANDS = new Set([ - "add", "am", "branch", "checkout", "cherry-pick", "clean", "commit", - "commit-tree", "fetch", "init", "merge", "mv", "pull", "push", "read-tree", - "rebase", "reset", "restore", "revert", "rm", "stash", "switch", "tag", - "update-ref", "write-tree", + "add", + "am", + "branch", + "checkout", + "cherry-pick", + "clean", + "commit", + "commit-tree", + "fetch", + "init", + "merge", + "mv", + "pull", + "push", + "read-tree", + "rebase", + "reset", + "restore", + "revert", + "rm", + "stash", + "switch", + "tag", + "update-ref", + "write-tree", ]); function mutatingGitCalls(log) { const out = []; @@ -945,10 +1036,15 @@ function mutatingGitCalls(log) { const argv = line.slice(4).trim().split(/\s+/); let i = 0; // Skip the global options every call site uses to name its target. - while (argv[i]?.startsWith("-")) i += argv[i] === "-C" || argv[i] === "-c" ? 2 : 1; + while (argv[i]?.startsWith("-")) + i += argv[i] === "-C" || argv[i] === "-c" ? 2 : 1; const sub = argv[i]; if (sub === "worktree") { - if (["add", "remove", "prune", "lock", "move", "repair"].includes(argv[i + 1])) + if ( + ["add", "remove", "prune", "lock", "move", "repair"].includes( + argv[i + 1], + ) + ) out.push(line); } else if (MUTATING_SUBCOMMANDS.has(sub)) { out.push(line); @@ -969,7 +1065,7 @@ const SLOT_VERBS = [ ["abort-merge", [1], {}], ]; -test("a slot branch outside swarm//* is refused by every slot verb, before any git mutation", () => { +test("a slot branch outside swarm//* makes bookkeeping unknown for every slot verb before mutation", () => { withGitCallLog(() => { for (const [verb, args, env] of SLOT_VERBS) { const run = mkRun({ status: "merged" }); @@ -977,9 +1073,13 @@ test("a slot branch outside swarm//* is refused by every slot verb, before // run never minted, so nothing about the slot is ours to touch. patchSlot(run, 1, { branch: "swarm/some-other-run/s1" }); const r = step(run, verb, args, env); - assert.equal(r.status, EC.REFUSED, `${verb}: ${r.stdout}\n${r.stderr}`); - assert.match(r.stderr, /ownership check FAILED/, `${verb} says why`); - assert.match(r.stderr, /outside this run's namespace/, `${verb} names the mismatch`); + assert.equal(r.status, 3, `${verb}: ${r.stdout}\n${r.stderr}`); + assert.match(r.stderr, /bookkeeping_unknown/, `${verb} says why`); + assert.match( + r.stderr, + /outside the run namespace/, + `${verb} names the mismatch`, + ); assert.deepEqual( mutatingGitCalls(h.log()), [], @@ -1000,12 +1100,16 @@ test("merge and discard refuse a foreign slot branch too (the two rm -rf-class v backup_ref: base, // clears discard's snapshot precondition }); const m = step(run, "merge", [1, base]); - assert.equal(m.status, EC.REFUSED, `${m.stdout}\n${m.stderr}`); + assert.equal(m.status, 3, `${m.stdout}\n${m.stderr}`); const d = step(run, "discard", [1], { HERDR_SWARM_CONFIRM: "swarm/some-other-run/s1", }); - assert.equal(d.status, EC.REFUSED, `${d.stdout}\n${d.stderr}`); - assert.deepEqual(mutatingGitCalls(h.log()), [], "no reset --hard, no clean -fd"); + assert.equal(d.status, 3, `${d.stdout}\n${d.stderr}`); + assert.deepEqual( + mutatingGitCalls(h.log()), + [], + "no reset --hard, no clean -fd", + ); }); }); @@ -1021,7 +1125,10 @@ test("a slot path that is a REAL worktree of a DIFFERENT branch is refused (pair assert.equal(r.status, EC.REFUSED, `${r.stdout}\n${r.stderr}`); assert.match(r.stderr, /is not a worktree of .* checked out on/); assert.deepEqual(mutatingGitCalls(h.log()), []); - assert.ok(fs.existsSync(run.wt(2)), "slot 2's worktree survived slot 1's verb"); + assert.ok( + fs.existsSync(run.wt(2)), + "slot 2's worktree survived slot 1's verb", + ); }); }); @@ -1070,8 +1177,13 @@ test("STEP_EC is in lockstep with the HS_EC_* constants in harvest-step.sh", () "utf8", ); const bash = {}; - for (const m of src.matchAll(/^HS_EC_([A-Z]+)=(\d+)/gm)) bash[m[1]] = Number(m[2]); - assert.deepEqual(STEP_EC, bash, "renderer and verb script exit codes drifted"); + for (const m of src.matchAll(/^HS_EC_([A-Z]+)=(\d+)/gm)) + bash[m[1]] = Number(m[2]); + assert.deepEqual( + STEP_EC, + bash, + "renderer and verb script exit codes drifted", + ); assert.deepEqual(STEP_EC, EC, "this test file's own copy drifted"); }); @@ -1221,7 +1333,10 @@ test("shellInto restores terminal state around the PTY handoff, including when t const reenter = idx(([k, v]) => k === "w" && v.includes("\x1b[?1049h")); assert.ok(leave >= 0 && rawOff > leave, "alt screen left, then raw mode off"); assert.ok(shell > rawOff, "shell spawns only after the terminal is sane"); - assert.ok(rawOn > shell && reenter > rawOn, "raw mode and alt screen restored after exit"); + assert.ok( + rawOn > shell && reenter > rawOn, + "raw mode and alt screen restored after exit", + ); assert.ok( events.slice(reenter).some(([k, v]) => k === "w" && v.includes("CONFLICT")), "conflict view repainted after the handoff", @@ -1258,7 +1373,11 @@ test("HarvestRenderer against a real run: preview, drift re-preview + re-baselin await r.doMerge(1); assert.match(r.banner, /base moved/); row = r.rows.find((x) => x.slot === 1); - assert.equal(row.preview.baseSha, racer, "previews re-baselined to the new base"); + assert.equal( + row.preview.baseSha, + racer, + "previews re-baselined to the new base", + ); // Second attempt with the fresh SHA merges, then auto-archives (agent // absent in the stub, only the task file in the worktree -> no prompt). await r.doMerge(1); @@ -1286,7 +1405,10 @@ test("selectSlot routes a user-tree locus through the confirm phase; 'y' merges, ); await r.onKey("n"); assert.equal(r.phase.name, "list"); - assert.equal(h.git(run.repo, "rev-parse", "refs/heads/main").stdout.trim(), run.fork); + assert.equal( + h.git(run.repo, "rev-parse", "refs/heads/main").stdout.trim(), + run.fork, + ); await r.selectSlot(1); await r.onKey("y"); assert.notEqual( @@ -1311,15 +1433,26 @@ test("renderer discard flow: typed branch name gates it, snapshot lands before t for (const ch of "wrong-name") await r.onKey(ch); await r.onKey("\r"); assert.match(r.banner, /did not match/); - assert.ok(fs.existsSync(path.join(run.wt(1), "precious.txt")), "tree untouched"); - assert.equal(run.slotRow(1).backup_ref, null, "no snapshot for a cancelled discard"); + assert.ok( + fs.existsSync(path.join(run.wt(1), "precious.txt")), + "tree untouched", + ); + assert.equal( + run.slotRow(1).backup_ref, + null, + "no snapshot for a cancelled discard", + ); await r.onKey("1"); await r.onKey("d"); for (const ch of run.branch(1)) await r.onKey(ch); await r.onKey("\r"); assert.equal(fs.existsSync(path.join(run.wt(1), "precious.txt")), false); const sha = run.slotRow(1).backup_ref; - assert.match(sha ?? "", /^[0-9a-f]{40}$/, "snapshot recorded before the discard ran"); + assert.match( + sha ?? "", + /^[0-9a-f]{40}$/, + "snapshot recorded before the discard ran", + ); assert.equal( h.git(run.repo, "show", `${sha}:precious.txt`).stdout, "keep me\n", @@ -1363,11 +1496,15 @@ exit 0`, test("harvest-pane.sh lingers without a manifest; with a real run it execs the harvest renderer end to end", () => { h.writeHerdrStub(); const sdir = fs.mkdtempSync(path.join(os.tmpdir(), "hs-nomf-")); - let r = spawnSync("bash", [path.join(repoRoot, "scripts", "harvest-pane.sh")], { - env: h.freshEnv({ HERDR_PLUGIN_STATE_DIR: sdir }), - encoding: "utf8", - timeout: 1500, - }); + let r = spawnSync( + "bash", + [path.join(repoRoot, "scripts", "harvest-pane.sh")], + { + env: h.freshEnv({ HERDR_PLUGIN_STATE_DIR: sdir }), + encoding: "utf8", + timeout: 1500, + }, + ); assert.match(r.stdout, /nothing to harvest/); assert.equal(r.signal, "SIGTERM", "still lingering when the timeout hit"); // Real run: the pane must reach the harvest renderer's painted screen — diff --git a/tests/preflight.test.mjs b/tests/preflight.test.mjs index c591b69..e6b24d0 100644 --- a/tests/preflight.test.mjs +++ b/tests/preflight.test.mjs @@ -99,7 +99,8 @@ test("every preflight refusal has a distinct exit code and an actionable message code: 17, msg: /harvest or abort/, cwd: () => makeRepo(), - pre: () => fs.writeFileSync(manifestFile, sampleManifest()), + pre: (repo) => + fs.writeFileSync(manifestFile, sampleManifest({ repo_root: repo })), }, { // 0.7.3, not 0.7.5: since issue #1 the gate is a FLOOR, and 0.7.5 @@ -136,7 +137,7 @@ test("every preflight refusal has a distinct exit code and an actionable message for (const c of cases) { const cwd = c.cwd(); fs.rmSync(manifestFile, { force: true }); - if (c.pre) c.pre(); + if (c.pre) c.pre(cwd); const r = runPf(c.fn, freshEnv(c.env ?? {}), cwd); assert.equal( r.status, @@ -221,13 +222,13 @@ test("detritus check prunes stale worktree registrations instead of flagging the assert.ok(!list.includes("hs-wt-"), "stale registration pruned"); }); -test("active-run check passes when every slot is archived", () => { +test("all-archived live manifest remains active until idempotent finalization archives it", () => { const repo = makeRepo(); - const archived = JSON.parse(sampleManifest()); + const archived = JSON.parse(sampleManifest({ repo_root: repo })); for (const s of archived.slots) s.status = "archived"; fs.writeFileSync(manifestFile, JSON.stringify(archived)); const r = runPf("preflight_check_active_run", freshEnv(), repo); - assert.equal(r.status, 0, r.stderr); + assert.equal(r.status, 17, r.stderr); fs.rmSync(manifestFile, { force: true }); }); @@ -242,19 +243,35 @@ test("active-run check refuses a corrupt manifest with the corrupt code, not 'no test("HERDR_SWARM_MAX_SLOTS overrides the cap; garbage falls back to 6", () => { const repo = makeRepo(); assert.equal( - runPf("preflight_check_slot_cap 3", freshEnv({ HERDR_SWARM_MAX_SLOTS: "2" }), repo).status, + runPf( + "preflight_check_slot_cap 3", + freshEnv({ HERDR_SWARM_MAX_SLOTS: "2" }), + repo, + ).status, 19, ); assert.equal( - runPf("preflight_check_slot_cap 2", freshEnv({ HERDR_SWARM_MAX_SLOTS: "2" }), repo).status, + runPf( + "preflight_check_slot_cap 2", + freshEnv({ HERDR_SWARM_MAX_SLOTS: "2" }), + repo, + ).status, 0, ); assert.equal( - runPf("preflight_check_slot_cap 7", freshEnv({ HERDR_SWARM_MAX_SLOTS: "banana" }), repo).status, + runPf( + "preflight_check_slot_cap 7", + freshEnv({ HERDR_SWARM_MAX_SLOTS: "banana" }), + repo, + ).status, 19, ); assert.equal( - runPf("preflight_check_slot_cap 6", freshEnv({ HERDR_SWARM_MAX_SLOTS: "banana" }), repo).status, + runPf( + "preflight_check_slot_cap 6", + freshEnv({ HERDR_SWARM_MAX_SLOTS: "banana" }), + repo, + ).status, 0, ); }); diff --git a/tests/run-finalization.test.mjs b/tests/run-finalization.test.mjs new file mode 100644 index 0000000..db9e9b6 --- /dev/null +++ b/tests/run-finalization.test.mjs @@ -0,0 +1,345 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { spawn, spawnSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { createHarness, makeFannedOutRun, repoRoot } from "./harness.mjs"; + +const h = createHarness(); +h.writeHerdrStub(); +const mkRun = (opts = {}) => makeFannedOutRun(h, { prefix: "r-safe", ...opts }); +const step = (run, verb, args = [], extraEnv = {}) => + h.runScript("harvest-step.sh", [verb, ...args.map(String)], { + ...run.env, + ...extraEnv, + }); +const abort = (run, extraEnv = {}) => + spawnSync("bash", [path.join(repoRoot, "scripts/abort.sh")], { + cwd: run.repo, + env: { ...run.env, ...extraEnv }, + encoding: "utf8", + }); + +function approvalFrom(stdout) { + const line = stdout + .split("\n") + .find((entry) => entry.startsWith("cleanup_approval\t")); + assert.ok(line, `approval missing from preview:\n${stdout}`); + return line.slice("cleanup_approval\t".length); +} + +function sha256(file) { + return createHash("sha256").update(fs.readFileSync(file)).digest("hex"); +} + +function appendIgnore(run, pattern) { + fs.appendFileSync(path.join(run.repo, ".git/info/exclude"), `${pattern}\n`); +} + +test("archive preview recursively inventories ignored .env, nested files, and newline names without removal", () => { + const run = mkRun({ status: "merged" }); + appendIgnore(run, "ignored/**"); + appendIgnore(run, ".env"); + fs.mkdirSync(path.join(run.wt(1), "ignored/deep"), { recursive: true }); + fs.writeFileSync(path.join(run.wt(1), ".env"), "secret\n"); + fs.writeFileSync( + path.join(run.wt(1), "ignored/deep/result.txt"), + "evidence\n", + ); + fs.writeFileSync( + path.join(run.wt(1), "ignored/deep/line\nbreak.txt"), + "newline\n", + ); + const preview = step(run, "archive", [1]); + assert.equal(preview.status, 37, `${preview.stdout}\n${preview.stderr}`); + assert.match(preview.stdout, /ignored_json\t"\.env"/); + assert.match(preview.stdout, /ignored\/deep\/result\.txt/); + assert.match(preview.stdout, /line\\nbreak\.txt/); + assert.ok(fs.existsSync(run.wt(1))); + assert.doesNotMatch(h.log(), /worktree remove/); + + const template = JSON.parse(approvalFrom(preview.stdout)); + for (const [field, value] of [ + ["run_id", "wrong-run"], + ["slot", "2"], + ["worktree", `${run.wt(1)}-foreign`], + ]) { + const wrong = { ...template, [field]: value }; + const refused = step(run, "archive", [1], { + HERDR_SWARM_CLEANUP_APPROVAL: JSON.stringify(wrong), + }); + assert.equal(refused.status, 37, field); + assert.ok( + fs.existsSync(run.wt(1)), + `wrong ${field} binding performs zero removal`, + ); + assert.doesNotMatch(h.log(), /worktree remove/); + } +}); + +test("archive apply refuses stale and concurrently changed inventories before any removal", async () => { + const run = mkRun({ status: "merged" }); + appendIgnore(run, "*.log"); + fs.writeFileSync(path.join(run.wt(1), "first.log"), "one\n"); + let preview = step(run, "archive", [1]); + const staleApproval = approvalFrom(preview.stdout); + fs.writeFileSync(path.join(run.wt(1), "second.log"), "two\n"); + const refused = step(run, "archive", [1], { + HERDR_SWARM_CLEANUP_APPROVAL: staleApproval, + }); + assert.equal(refused.status, 37, `${refused.stdout}\n${refused.stderr}`); + assert.ok(fs.existsSync(run.wt(1))); + assert.doesNotMatch(h.log(), /worktree remove/); + + preview = step(run, "archive", [1]); + const approval = approvalFrom(preview.stdout); + const ready = path.join(run.sdir, "cleanup-ready"); + const child = spawn( + "bash", + [path.join(repoRoot, "scripts/harvest-step.sh"), "archive", "1"], + { + env: { + ...run.env, + HERDR_SWARM_CLEANUP_APPROVAL: approval, + HERDR_SWARM_TEST_CLEANUP_READY_FILE: ready, + HERDR_SWARM_TEST_PAUSE_BEFORE_CLEANUP_RECHECK: "1", + }, + }, + ); + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (chunk) => (stdout += chunk)); + child.stderr.on("data", (chunk) => (stderr += chunk)); + for (let i = 0; i < 100 && !fs.existsSync(ready); i += 1) { + await new Promise((resolve) => setTimeout(resolve, 25)); + } + assert.ok(fs.existsSync(ready), "apply reached the immediate recheck seam"); + fs.writeFileSync(path.join(run.wt(1), "concurrent.log"), "raced\n"); + const code = await new Promise((resolve) => child.on("close", resolve)); + assert.equal(code, 37, `${stdout}\n${stderr}`); + assert.match(stderr, /inventory changed/); + assert.ok(fs.existsSync(run.wt(1)), "concurrent writer wins safety refusal"); + assert.doesNotMatch(h.log(), /worktree remove/); +}); + +test("abort ignored cleanup is preview/apply and a generic legacy acknowledgment cannot delete", () => { + const run = mkRun(); + appendIgnore(run, ".env"); + fs.writeFileSync(path.join(run.wt(1), ".env"), "keep\n"); + let result = abort(run, { HERDR_SWARM_ACK_IGNORED: "1" }); + assert.equal(result.status, 4, `${result.stdout}\n${result.stderr}`); + assert.ok(fs.existsSync(run.wt(1))); + assert.doesNotMatch(h.log(), /worktree remove/); + + fs.writeFileSync(h.logFile, ""); + result = abort(run, { + HERDR_SWARM_ABORT_PREVIEW: "yes", + HERDR_SWARM_CLEANUP_OPERATION_ID: "abort-preview", + }); + assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`); + assert.match(result.stdout, /preview only — zero resources removed/); + assert.doesNotMatch(h.log(), /pane close|worktree remove/); + const approval = approvalFrom(result.stdout); + result = abort(run, { HERDR_SWARM_CLEANUP_APPROVAL: approval }); + assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`); + assert.equal(fs.existsSync(run.wt(1)), false); + assert.equal(fs.existsSync(path.join(run.sdir, "run-w9.json")), false); + assert.ok(fs.existsSync(path.join(run.sdir, `archived-${run.runId}.json`))); +}); + +test("full harvest archives exactly once, removes the live pointer/exclude, and retry is idempotent", () => { + const run = mkRun({ slots: 2, status: "skipped" }); + let result = step(run, "archive", [1]); + assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`); + assert.ok( + fs.existsSync(path.join(run.sdir, "run-w9.json")), + "first slot keeps run live", + ); + assert.ok( + fs + .readFileSync(path.join(run.repo, ".git/info/exclude"), "utf8") + .includes(".swarm-task.md"), + ); + result = step(run, "archive", [2]); + assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`); + const archive = path.join(run.sdir, `archived-${run.runId}.json`); + assert.equal(fs.existsSync(path.join(run.sdir, "run-w9.json")), false); + const doc = JSON.parse(fs.readFileSync(archive, "utf8")); + assert.equal(doc.status, "completed"); + assert.equal(doc.completion_reason, "all_slots_archived"); + assert.equal(doc.completion_events.length, 1); + assert.ok(doc.slots.every((slot) => slot.status === "archived")); + assert.equal( + fs + .readFileSync(path.join(run.repo, ".git/info/exclude"), "utf8") + .includes(".swarm-task.md"), + false, + ); + const before = sha256(archive); + const retry = h.runLib( + `export SWARM_REPO=${JSON.stringify(run.repo)}; finalize_run ${JSON.stringify(run.repo)} ${JSON.stringify(run.runId)}`, + run.env, + { sources: ["scripts/preflight.sh"] }, + ); + assert.equal(retry.status, 0, `${retry.stdout}\n${retry.stderr}`); + assert.equal(sha256(archive), before, "retry never rewrites the archive"); + assert.equal( + fs + .readdirSync(run.sdir) + .filter( + (name) => + name.startsWith(`archived-${run.runId}`) && name.endsWith(".json"), + ).length, + 1, + ); +}); + +test("repository identity makes workspace aliases share one lock and discover the same active run", () => { + const run = mkRun(); + const source = path.join(run.sdir, "run-w9.json"); + const other = path.join(run.sdir, "run-w2.json"); + fs.renameSync(source, other); + const lockA = h.runLib( + `repo_mutation_lock_name ${JSON.stringify(run.repo)}`, + run.env, + ); + const lockB = h.runLib( + `repo_mutation_lock_name ${JSON.stringify(run.repo)}`, + { ...run.env, HERDR_WORKSPACE_ID: "w2" }, + ); + assert.equal(lockA.status, 0, lockA.stderr); + assert.equal( + lockA.stdout, + lockB.stdout, + "workspace ids cannot partition the repo lock", + ); + const active = h.runLib( + `export SWARM_REPO=${JSON.stringify(run.repo)}; preflight_check_active_run`, + { ...run.env, HERDR_WORKSPACE_ID: "another-workspace" }, + { sources: ["scripts/preflight.sh"] }, + ); + assert.equal(active.status, 17, active.stderr); + assert.match(active.stderr, /another workspace/); +}); + +test("corrupt or symlinked archived bookkeeping refuses all prune deletion", () => { + for (const kind of ["corrupt", "symlink"]) { + const run = mkRun({ status: "archived" }); + fs.rmSync(run.wt(1), { recursive: true, force: true }); + h.git(run.repo, "worktree", "prune"); + const live = path.join(run.sdir, "run-w9.json"); + fs.renameSync(live, path.join(run.sdir, `archived-${run.runId}.json`)); + const unknown = path.join(run.sdir, `archived-bad-${kind}.json`); + if (kind === "corrupt") fs.writeFileSync(unknown, "{truncated"); + else + fs.symlinkSync( + path.join(run.sdir, `archived-${run.runId}.json`), + unknown, + ); + h.git( + run.repo, + "update-ref", + `refs/swarm-backups/${run.runId}/1`, + run.fork, + ); + const result = spawnSync( + "bash", + [path.join(repoRoot, "scripts/prune.sh")], + { + cwd: run.repo, + env: { + ...run.env, + HERDR_SWARM_PRUNE_CONFIRM: "yes", + HERDR_SWARM_PRUNE_BACKUPS: "yes", + }, + encoding: "utf8", + }, + ); + assert.equal( + result.status, + 3, + `${kind}: ${result.stdout}\n${result.stderr}`, + ); + assert.match(result.stderr, /bookkeeping_unknown/); + assert.match( + h.git( + run.repo, + "for-each-ref", + "--format=%(refname)", + "refs/swarm-backups", + ).stdout, + new RegExp(run.runId), + `${kind}: backup survives`, + ); + } +}); + +test("multiple live manifests fail closed and protect every live-run backup", () => { + const run = mkRun(); + const second = JSON.parse( + fs.readFileSync(path.join(run.sdir, "run-w9.json"), "utf8"), + ); + second.run_id = `${run.runId}b`; + second.slots[0].branch = `swarm/${second.run_id}/s1`; + fs.writeFileSync( + path.join(run.sdir, "run-w2.json"), + JSON.stringify(second, null, 2), + ); + for (const id of [run.runId, second.run_id]) { + h.git(run.repo, "update-ref", `refs/swarm-backups/${id}/1`, run.fork); + } + const result = spawnSync("bash", [path.join(repoRoot, "scripts/prune.sh")], { + cwd: run.repo, + env: { ...run.env, HERDR_SWARM_PRUNE_BACKUPS: "yes" }, + encoding: "utf8", + }); + assert.equal(result.status, 3, `${result.stdout}\n${result.stderr}`); + assert.match(result.stderr, /multiple live manifests/); + const refs = h.git( + run.repo, + "for-each-ref", + "--format=%(refname)", + "refs/swarm-backups", + ).stdout; + assert.ok(refs.includes(run.runId)); + assert.ok(refs.includes(second.run_id)); +}); + +test("finalization retries safely after every journaled step and never duplicates completion", () => { + for (const seam of [ + "after-complete", + "after-exclude", + "after-index", + "after-archive", + ]) { + const run = mkRun({ status: "archived" }); + fs.rmSync(run.wt(1), { recursive: true, force: true }); + h.git(run.repo, "worktree", "prune"); + const command = `export SWARM_REPO=${JSON.stringify(run.repo)}; finalize_run ${JSON.stringify(run.repo)} ${JSON.stringify(run.runId)}`; + const crashed = h.runLib( + command, + { ...run.env, HERDR_SWARM_TEST_FAIL_FINALIZE_STEP: seam }, + { sources: ["scripts/preflight.sh"] }, + ); + assert.equal( + crashed.status, + 99, + `${seam}: ${crashed.stdout}\n${crashed.stderr}`, + ); + const retry = h.runLib(command, run.env, { + sources: ["scripts/preflight.sh"], + }); + assert.equal(retry.status, 0, `${seam}: ${retry.stdout}\n${retry.stderr}`); + const archive = path.join(run.sdir, `archived-${run.runId}.json`); + const doc = JSON.parse(fs.readFileSync(archive, "utf8")); + assert.equal(doc.completion_events.length, 1, seam); + assert.equal( + fs + .readdirSync(run.sdir) + .filter((name) => name === `archived-${run.runId}.json`).length, + 1, + seam, + ); + } +}); From 1f02f7a54c6f02f8df7af67b0f3dba2b680aef12 Mon Sep 17 00:00:00 2001 From: Victor Garcia Date: Tue, 28 Jul 2026 02:37:16 -0600 Subject: [PATCH 4/9] test(fanout): bind active-run fixture to repository Update the active-run fixture to reference the real temporary repository now that preflight validates physical repository identity. This keeps the legacy fan-out refusal test exercising the repository-scoped live-manifest scan instead of failing during fixture identity resolution. --- tests/fanout.test.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/fanout.test.mjs b/tests/fanout.test.mjs index 0e21912..9d78c30 100644 --- a/tests/fanout.test.mjs +++ b/tests/fanout.test.mjs @@ -418,7 +418,7 @@ test("concurrent double-invoke: exactly one fan-out proceeds (mutation lock)", a test("an active run refuses fan-out with 'harvest or abort' before any create", () => { const { repo, env } = setup(); - fs.writeFileSync(manifestFile, sampleManifest()); + fs.writeFileSync(manifestFile, sampleManifest({ repo_root: repo })); const r = runPane(lines(["1", "", "Nope", ".", ""]), env, repo); assert.equal(r.status, 17, r.stderr); // PF_EC_ACTIVE_RUN assert.match(r.stderr, /harvest or abort/); From 24706b8b0a0f92c46223b388fa0df22dcc688066 Mon Sep 17 00:00:00 2001 From: Victor Garcia Date: Tue, 28 Jul 2026 02:40:51 -0600 Subject: [PATCH 5/9] [pi] Completed and pushed `wave0/swarm-safety`. --- tests/fanout.test.mjs | 303 ++++++++++++++++++++++++++++++++++-------- 1 file changed, 248 insertions(+), 55 deletions(-) diff --git a/tests/fanout.test.mjs b/tests/fanout.test.mjs index 9d78c30..3d537b3 100644 --- a/tests/fanout.test.mjs +++ b/tests/fanout.test.mjs @@ -7,7 +7,17 @@ import path from "node:path"; import { createHarness, repoRoot, sampleManifest } from "./harness.mjs"; const h = createHarness(); -const { stubDir, stateDir, writeStub, freshEnv, runScript, runLib, log, makeRepo, git } = h; +const { + stubDir, + stateDir, + writeStub, + freshEnv, + runScript, + runLib, + log, + makeRepo, + git, +} = h; const manifestFile = path.join(stateDir, "run-w9.json"); const paneScript = path.join(repoRoot, "scripts", "fanout-pane.sh"); @@ -205,7 +215,9 @@ function runPresets(snippet, env) { } test("presets: missing config yields the two built-in defaults", () => { - const env = freshEnv({ HERDR_PLUGIN_CONFIG_DIR: path.join(os.tmpdir(), "hs-no-such-cfg") }); + const env = freshEnv({ + HERDR_PLUGIN_CONFIG_DIR: path.join(os.tmpdir(), "hs-no-such-cfg"), + }); const l = runPresets("presets_list", env); assert.equal(l.status, 0, l.stderr); assert.equal(l.stdout, "claude\tclaude\ncodex\tcodex\n"); @@ -232,7 +244,10 @@ test("presets: config file parsed — comments/blanks skipped, kind carried, arg test("presets: an invalid name fails the whole catalog loudly, never skips", () => { const cfg = fs.mkdtempSync(path.join(os.tmpdir(), "hs-cfg-")); - fs.writeFileSync(path.join(cfg, "presets.conf"), "ok|argv|echo hi\nbad name|argv|echo boom\n"); + fs.writeFileSync( + path.join(cfg, "presets.conf"), + "ok|argv|echo hi\nbad name|argv|echo boom\n", + ); const env = freshEnv({ HERDR_PLUGIN_CONFIG_DIR: cfg }); const l = runPresets("presets_list", env); assert.notEqual(l.status, 0); @@ -245,7 +260,9 @@ test("presets: an invalid name fails the whole catalog loudly, never skips", () }); test("presets: unknown and hostile preset names are refused", () => { - const env = freshEnv({ HERDR_PLUGIN_CONFIG_DIR: path.join(os.tmpdir(), "hs-no-such-cfg") }); + const env = freshEnv({ + HERDR_PLUGIN_CONFIG_DIR: path.join(os.tmpdir(), "hs-no-such-cfg"), + }); const u = runPresets("preset_argv nope", env); assert.notEqual(u.status, 0); assert.match(u.stderr, /unknown preset 'nope'/); @@ -254,7 +271,12 @@ test("presets: unknown and hostile preset names are refused", () => { // accidental match ("../claude" must not become "claude"). const hostile = spawnSync( "bash", - ["-c", `. "${repoRoot}/scripts/presets.sh" && preset_argv "$1"`, "--", "../claude"], + [ + "-c", + `. "${repoRoot}/scripts/presets.sh" && preset_argv "$1"`, + "--", + "../claude", + ], { env, encoding: "utf8" }, ); assert.notEqual(hostile.status, 0); @@ -281,7 +303,10 @@ test("fanout.sh opens the fan-out pane on 0.7.5 as well", () => { const { env } = setup({ STUB_HERDR_VERSION: "0.7.5" }); const r = runScript("fanout.sh", [], env); assert.equal(r.status, 0, r.stderr); - assert.match(log(), /plugin pane open --plugin structupath\.swarm --entrypoint fanout-pane/); + assert.match( + log(), + /plugin pane open --plugin structupath\.swarm --entrypoint fanout-pane/, + ); }); test("fanout.sh refuses below the 0.7.4 floor before opening anything", () => { @@ -297,13 +322,28 @@ test("fanout.sh refuses below the 0.7.4 floor before opening anything", () => { test("N=3 fans out exactly 3 creates + 3 starts on distinct run-unique branches", () => { const { repo, wtRoot, env } = setup(); const r = runPane( - lines(["3", "", "", "", "Build the widget", "Second line", ".", "", "", ""]), + lines([ + "3", + "", + "", + "", + "Build the widget", + "Second line", + ".", + "", + "", + "", + ]), env, repo, ); assert.equal(r.status, 0, r.stderr); - const creates = log().split("\n").filter((l) => /worktree create/.test(l)); - const starts = log().split("\n").filter((l) => /agent start/.test(l)); + const creates = log() + .split("\n") + .filter((l) => /worktree create/.test(l)); + const starts = log() + .split("\n") + .filter((l) => /agent start/.test(l)); assert.equal(creates.length, 3); assert.equal(starts.length, 3); // Spike (k): --workspace alone leaves the agent in the server's cwd, so @@ -312,18 +352,28 @@ test("N=3 fans out exactly 3 creates + 3 starts on distinct run-unique branches" const m = readManifest(); assert.match(m.run_id, /^\d{8}-\d{6}-[0-9a-f]{4}$/, "timestamp+nonce run id"); assert.equal(m.slots.length, 3); - assert.equal(new Set(m.slots.map((s) => s.branch)).size, 3, "branches distinct"); + assert.equal( + new Set(m.slots.map((s) => s.branch)).size, + 3, + "branches distinct", + ); for (const s of m.slots) { assert.equal(s.status, "running"); assert.equal(s.branch, `swarm/${m.run_id}/s${s.slot}-claude`); - assert.ok(s.path && s.path.startsWith(wtRoot), `path from response: ${s.path}`); + assert.ok( + s.path && s.path.startsWith(wtRoot), + `path from response: ${s.path}`, + ); // Running rows carry the ids the START returned, not the root pane's. assert.equal(s.pane_id, "wD:p2"); assert.match(s.terminal_id, /^term_swarm-/); assert.match(s.agent_name, /^swarm-/); assert.equal(s.workspace_id, "wD"); } - assert.match(log(), /plugin pane open --plugin structupath\.swarm --entrypoint status-pane/); + assert.match( + log(), + /plugin pane open --plugin structupath\.swarm --entrypoint status-pane/, + ); assert.match(r.stdout, /created 3, started 3, failed 0/); }); @@ -337,7 +387,10 @@ test("write-ahead ordering: pending row before create, path before start, runnin // Snapshot the stub took INSIDE `worktree create`: the pending row was // already on disk, path still null — the manifest led the mutation. const atCreate = JSON.parse( - fs.readFileSync(path.join(stateDir, `snap-create-${s.branch.replace(/\//g, "-")}.json`), "utf8"), + fs.readFileSync( + path.join(stateDir, `snap-create-${s.branch.replace(/\//g, "-")}.json`), + "utf8", + ), ); const rowC = atCreate.slots.find((x) => x.branch === s.branch); assert.ok(rowC, `pending row on disk before create of ${s.branch}`); @@ -346,7 +399,10 @@ test("write-ahead ordering: pending row before create, path before start, runnin // Snapshot inside `agent start`: path recorded, but running only after // the start returns its ids. const atStart = JSON.parse( - fs.readFileSync(path.join(stateDir, `snap-start-${s.agent_name}.json`), "utf8"), + fs.readFileSync( + path.join(stateDir, `snap-start-${s.agent_name}.json`), + "utf8", + ), ); const rowS = atStart.slots.find((x) => x.branch === s.branch); assert.ok(rowS.path, "path recorded before agent start"); @@ -372,8 +428,18 @@ test("slot 2 create failure keeps slots 1 and 3 running and reports loudly (R4)" ["running", "failed", "running"], ); assert.equal(m.slots[1].path, null, "failed slot never got a path"); - assert.equal(log().split("\n").filter((l) => /worktree create/.test(l)).length, 3); - assert.equal(log().split("\n").filter((l) => /agent start/.test(l)).length, 2); + assert.equal( + log() + .split("\n") + .filter((l) => /worktree create/.test(l)).length, + 3, + ); + assert.equal( + log() + .split("\n") + .filter((l) => /agent start/.test(l)).length, + 2, + ); assert.match(r.stdout, /created 2, started 2, failed 1/); assert.match(r.stderr, /slot 2 FAILED/); assert.match(r.stderr, /WARNING: 1 slot\(s\) FAILED/); @@ -384,7 +450,11 @@ test("kill between create-return and path-record leaves a pending-null-path row, const r = await spawnPane(lines(["1", "", "Kill test", ".", ""]), env, repo, { detached: true, }); - assert.equal(r.signal, "SIGKILL", `expected the stub to kill the pane: ${r.stderr}`); + assert.equal( + r.signal, + "SIGKILL", + `expected the stub to kill the pane: ${r.stderr}`, + ); const m = readManifest(); assert.equal(m.slots.length, 1); // The write-ahead row survived the crash exactly as written: pending, @@ -442,17 +512,26 @@ test("stub 0.7.5: fan-out completes end-to-end with an arbitrary command as the const cfg = fs.mkdtempSync(path.join(os.tmpdir(), "hs-cfg-")); // Not one of herdr's ~14 integration kinds — exactly the case 0.7.5's // --kind whitelist cannot express. - fs.writeFileSync(path.join(cfg, "presets.conf"), "custom|argv|my-agent --loop\n"); + fs.writeFileSync( + path.join(cfg, "presets.conf"), + "custom|argv|my-agent --loop\n", + ); writeStub("my-agent", "exit 0"); const { repo, wtRoot, env } = setup({ STUB_HERDR_VERSION: "0.7.5", HERDR_PLUGIN_CONFIG_DIR: cfg, }); - const r = runPane(lines(["2", "", "", "Do the thing", ".", "", ""]), env, repo); + const r = runPane( + lines(["2", "", "", "Do the thing", ".", "", ""]), + env, + repo, + ); assert.equal(r.status, 0, `${r.stdout}\n${r.stderr}`); assert.match(r.stdout, /created 2, started 2, failed 0/); - const calls = log().split("\n").filter((l) => l.startsWith("herdr ")); + const calls = log() + .split("\n") + .filter((l) => l.startsWith("herdr ")); // Not a single agent start on this path — and never a --kind. assert.doesNotMatch(log(), /agent start/); assert.doesNotMatch(log(), /--kind/); @@ -476,11 +555,17 @@ test("stub 0.7.5: fan-out completes end-to-end with an arbitrary command as the // worktree and THIS slot's pane: split (in the worktree) → run (the // argv) → report-agent (working). A run before the split, or a split // without the worktree cwd, puts the agent in the server's cwd. - const iSplit = calls.findIndex((l) => l.includes(`pane split`) && l.includes(`--cwd ${s.path} `)); + const iSplit = calls.findIndex( + (l) => l.includes(`pane split`) && l.includes(`--cwd ${s.path} `), + ); assert.ok(iSplit >= 0, `split targeted ${s.path}:\n${calls.join("\n")}`); assert.match(calls[iSplit], /--no-focus/); - const iRun = calls.findIndex((l) => l.startsWith(`herdr pane run ${s.pane_id} `)); - const iRep = calls.findIndex((l) => l.startsWith(`herdr pane report-agent ${s.pane_id} `)); + const iRun = calls.findIndex((l) => + l.startsWith(`herdr pane run ${s.pane_id} `), + ); + const iRep = calls.findIndex((l) => + l.startsWith(`herdr pane report-agent ${s.pane_id} `), + ); assert.ok(iSplit < iRun, "split before run"); assert.ok(iRun < iRep, "run before report-agent"); // The preset's argv reaches the pane verbatim. @@ -492,12 +577,20 @@ test("stub 0.7.5: fan-out completes end-to-end with an arbitrary command as the } // Same write-ahead contract as the 0.7.4 branch: the pending row with a // recorded path was on disk before the pane that starts the agent existed. - const atSplit = JSON.parse(fs.readFileSync(path.join(stateDir, "snap-split-1.json"), "utf8")); + const atSplit = JSON.parse( + fs.readFileSync(path.join(stateDir, "snap-split-1.json"), "utf8"), + ); assert.equal(atSplit.slots[0].status, "pending"); - assert.ok(atSplit.slots[0].path, "path recorded before the slot's pane was split"); + assert.ok( + atSplit.slots[0].path, + "path recorded before the slot's pane was split", + ); // Real worktrees on real branches, same as 0.7.4. for (const s of m.slots) { - assert.ok(fs.existsSync(path.join(s.path, ".swarm-task.md")), `task file in ${s.path}`); + assert.ok( + fs.existsSync(path.join(s.path, ".swarm-task.md")), + `task file in ${s.path}`, + ); } }); @@ -508,10 +601,17 @@ test("stub 0.7.5: a pane-split failure fails only its own slot", () => { STUB_HERDR_VERSION: "0.7.5", STUB_FAIL_SPLIT_MATCH: "s2-", }); - const r = runPane(lines(["3", "", "", "", "Task", ".", "", "", ""]), env, repo); + const r = runPane( + lines(["3", "", "", "", "Task", ".", "", "", ""]), + env, + repo, + ); assert.equal(r.status, 1, `${r.stdout}\n${r.stderr}`); const m = readManifest(); - assert.deepEqual(m.slots.map((s) => s.status), ["running", "failed", "running"]); + assert.deepEqual( + m.slots.map((s) => s.status), + ["running", "failed", "running"], + ); // The worktree WAS created before the start failed, so the failed slot // keeps its recorded path — that is what makes it reapable by abort. assert.ok(m.slots[1].path, "failed slot keeps its write-ahead path"); @@ -523,8 +623,18 @@ test("stub 0.7.5: a pane-split failure fails only its own slot", () => { assert.match(r.stderr, /slot 2 FAILED/); // No orphan: the failing slot never got a pane, so nothing was left to run // argv in — and no report-agent advertised a slot that is not working. - assert.equal(log().split("\n").filter((l) => /pane run/.test(l)).length, 2); - assert.equal(log().split("\n").filter((l) => /pane report-agent/.test(l)).length, 2); + assert.equal( + log() + .split("\n") + .filter((l) => /pane run/.test(l)).length, + 2, + ); + assert.equal( + log() + .split("\n") + .filter((l) => /pane report-agent/.test(l)).length, + 2, + ); }); // --- fan-out pane: task file, overrides, presets, detritus -------------------- @@ -554,11 +664,16 @@ test("per-slot override lands in .swarm-task.md with the standing footer, exclud test("a configured preset's argv reaches agent start verbatim", () => { const cfg = fs.mkdtempSync(path.join(os.tmpdir(), "hs-cfg-")); - fs.writeFileSync(path.join(cfg, "presets.conf"), "fast|argv|echo hello-fast\n"); + fs.writeFileSync( + path.join(cfg, "presets.conf"), + "fast|argv|echo hello-fast\n", + ); const { repo, env } = setup({ HERDR_PLUGIN_CONFIG_DIR: cfg }); const r = runPane(lines(["1", "fast", "Preset test", ".", ""]), env, repo); assert.equal(r.status, 0, r.stderr); - const start = log().split("\n").find((l) => /agent start/.test(l)); + const start = log() + .split("\n") + .find((l) => /agent start/.test(l)); assert.match(start, / -- echo hello-fast$/); const m = readManifest(); assert.equal(m.slots[0].branch, `swarm/${m.run_id}/s1-fast`); @@ -570,7 +685,12 @@ test("detritus prompt: choosing delete clears the leftovers and the fan-out proc const r = runPane(lines(["d", "1", "", "Cleanup run", ".", ""]), env, repo); assert.equal(r.status, 0, `stderr: ${r.stderr}`); assert.match(r.stdout, /deleted branch swarm\/r0\/s1/); - const refs = git(repo, "for-each-ref", "--format=%(refname:short)", "refs/heads/swarm").stdout; + const refs = git( + repo, + "for-each-ref", + "--format=%(refname:short)", + "refs/heads/swarm", + ).stdout; assert.doesNotMatch(refs, /swarm\/r0\/s1/, "old branch gone"); assert.equal(readManifest().slots.length, 1, "new run created after cleanup"); }); @@ -589,7 +709,8 @@ function seedUnmergedLeftover(repo, branch = "swarm/r0/s1") { } const branchRefs = (repo) => - git(repo, "for-each-ref", "--format=%(refname:short)", "refs/heads/swarm").stdout; + git(repo, "for-each-ref", "--format=%(refname:short)", "refs/heads/swarm") + .stdout; test("detritus delete: an UNMERGED leftover is not deleted without the second typed confirmation", () => { const { repo, env } = setup(); @@ -653,7 +774,9 @@ test("detritus delete: an archived run recording the branch is named as the harv fork_sha: "a".repeat(40), created_at: "2026-07-22T15:00:00Z", exclude_pattern_added: false, - slots: [{ slot: 1, label: "s1", branch: "swarm/r0/s1", status: "archived" }], + slots: [ + { slot: 1, label: "s1", branch: "swarm/r0/s1", status: "archived" }, + ], }), ); const r = runPane(lines(["d", "n", "1", "", "Task", ".", ""]), env, repo); @@ -673,7 +796,10 @@ test("setup.sh hook runs in each worktree; failure warns but slots still start", assert.equal(r.status, 0, r.stderr); for (const s of readManifest().slots) { assert.equal(s.status, "running"); - assert.ok(fs.existsSync(path.join(s.path, ".setup-ran")), `marker in ${s.path}`); + assert.ok( + fs.existsSync(path.join(s.path, ".setup-ran")), + `marker in ${s.path}`, + ); } assert.doesNotMatch(r.stderr, /setup\.sh failed/); } @@ -740,9 +866,17 @@ test("fan-out targets the workspace's repo, not the process cwd", () => { assert.notEqual(m.fork_sha, headB, "fork_sha is NOT the cwd repo's HEAD"); // 2. Branch and worktree landed in A. - const branchesA = git(repoA, "for-each-ref", "--format=%(refname:short)", "refs/heads/swarm") - .stdout.trim(); - assert.equal(branchesA, `swarm/${m.run_id}/s1-claude`, "swarm branch created in A"); + const branchesA = git( + repoA, + "for-each-ref", + "--format=%(refname:short)", + "refs/heads/swarm", + ).stdout.trim(); + assert.equal( + branchesA, + `swarm/${m.run_id}/s1-claude`, + "swarm branch created in A", + ); assert.match( git(repoA, "worktree", "list", "--porcelain").stdout, new RegExp(`branch refs/heads/swarm/${m.run_id}/s1-claude`), @@ -753,18 +887,28 @@ test("fan-out targets the workspace's repo, not the process cwd", () => { // is the half that fails loudly if a single call site regresses to a // bare `git` while the rest of the flow still looks correct. assert.equal( - git(repoB, "for-each-ref", "--format=%(refname:short)", "refs/heads/swarm").stdout.trim(), + git( + repoB, + "for-each-ref", + "--format=%(refname:short)", + "refs/heads/swarm", + ).stdout.trim(), "", "no swarm branch in the cwd repo", ); assert.equal( - git(repoB, "worktree", "list", "--porcelain").stdout.trim().split("\n\n").length, + git(repoB, "worktree", "list", "--porcelain").stdout.trim().split("\n\n") + .length, 1, "cwd repo still has only its own working tree", ); const excludeA = path.join(repoA, ".git/info/exclude"); const excludeB = path.join(repoB, ".git/info/exclude"); - assert.match(fs.readFileSync(excludeA, "utf8"), /^\.swarm-task\.md$/m, "A excluded"); + assert.match( + fs.readFileSync(excludeA, "utf8"), + /^\.swarm-task\.md$/m, + "A excluded", + ); assert.doesNotMatch( fs.existsSync(excludeB) ? fs.readFileSync(excludeB, "utf8") : "", /\.swarm-task\.md/, @@ -796,14 +940,22 @@ test("env-driven: SLOTS+PRESETS+TASK fan out with no stdin at all", () => { assert.equal(m.slots[1].branch, `swarm/${m.run_id}/s2-codex`); for (const s of m.slots) { assert.equal(s.status, "running"); - assert.ok(s.path && s.path.startsWith(wtRoot), `path from response: ${s.path}`); + assert.ok( + s.path && s.path.startsWith(wtRoot), + `path from response: ${s.path}`, + ); assert.match( fs.readFileSync(path.join(s.path, ".swarm-task.md"), "utf8"), /Ship the thing/, ); } // Real branches in the real repo, not just manifest bookkeeping. - const refs = git(repo, "for-each-ref", "--format=%(refname:short)", "refs/heads/swarm").stdout; + const refs = git( + repo, + "for-each-ref", + "--format=%(refname:short)", + "refs/heads/swarm", + ).stdout; assert.match(refs, new RegExp(`swarm/${m.run_id}/s1-claude`)); assert.match(refs, new RegExp(`swarm/${m.run_id}/s2-codex`)); }); @@ -831,12 +983,20 @@ test("env-driven: a PRESETS/SLOTS count mismatch refuses before anything is crea }); const r = runPaneNoStdin(env, repo); assert.notEqual(r.status, 0); - assert.match(r.stderr, /lists 2 preset name\(s\) but the run has 3 slot\(s\)/); + assert.match( + r.stderr, + /lists 2 preset name\(s\) but the run has 3 slot\(s\)/, + ); // R3: refuse before creating. No worktree, no branch, no manifest. assert.doesNotMatch(log(), /worktree create/); assert.equal(fs.existsSync(manifestFile), false, "no manifest was written"); assert.equal( - git(repo, "for-each-ref", "--format=%(refname:short)", "refs/heads/swarm").stdout.trim(), + git( + repo, + "for-each-ref", + "--format=%(refname:short)", + "refs/heads/swarm", + ).stdout.trim(), "", ); }); @@ -850,12 +1010,18 @@ test("env-driven: an unknown preset name is refused, with the name in the messag const r = runPaneNoStdin(env, repo); assert.notEqual(r.status, 0); assert.match(r.stderr, /unknown preset 'no-such-preset'/); - assert.match(r.stderr, /HERDR_SWARM_PRESETS names an unusable preset 'no-such-preset'/); + assert.match( + r.stderr, + /HERDR_SWARM_PRESETS names an unusable preset 'no-such-preset'/, + ); assert.doesNotMatch(log(), /worktree create/); }); test("env-driven: TASK_FILE contents land in every slot with the standing footer, and beat TASK", () => { - const taskFile = path.join(fs.mkdtempSync(path.join(os.tmpdir(), "hs-task-")), "brief.md"); + const taskFile = path.join( + fs.mkdtempSync(path.join(os.tmpdir(), "hs-task-")), + "brief.md", + ); fs.writeFileSync(taskFile, "Line one of the brief\nLine two of the brief\n"); const { repo, env } = setup({ HERDR_SWARM_SLOTS: "2", @@ -873,7 +1039,10 @@ test("env-driven: TASK_FILE contents land in every slot with the standing footer assert.doesNotMatch(tf, /must lose/, "TASK_FILE wins over TASK"); assert.match(tf, /Commit completed work locally/); assert.match(tf, /Never push/); - assert.match(tf, new RegExp(`Work only in this worktree, on branch ${s.branch}`)); + assert.match( + tf, + new RegExp(`Work only in this worktree, on branch ${s.branch}`), + ); } }); @@ -914,7 +1083,10 @@ test("env-driven: an unknown DETRITUS value is refused", () => { git(repo, "branch", "swarm/r0/merged"); const r = runPaneNoStdin(env, repo); assert.notEqual(r.status, 0); - assert.match(r.stderr, /HERDR_SWARM_DETRITUS='nuke' is not one of delete \/ rename \/ abort/); + assert.match( + r.stderr, + /HERDR_SWARM_DETRITUS='nuke' is not one of delete \/ rename \/ abort/, + ); assert.match(branchRefs(repo), /swarm\/r0\/merged/, "nothing was touched"); }); @@ -972,7 +1144,12 @@ test("env-driven: DETRITUS=rename keeps the work and the fan-out proceeds", () = assert.equal(r.status, 0, `${r.stdout}\n${r.stderr}`); assert.match(r.stdout, /renamed swarm\/r0\/s1 -> swarm-kept\/r0\/s1/); assert.equal( - git(repo, "for-each-ref", "--format=%(refname:short)", "refs/heads/swarm-kept").stdout.trim(), + git( + repo, + "for-each-ref", + "--format=%(refname:short)", + "refs/heads/swarm-kept", + ).stdout.trim(), "swarm-kept/r0/s1", "the committed work is still reachable", ); @@ -990,7 +1167,11 @@ test("env-driven: a missing required var fails loudly by name instead of hanging // complete run still needs. }); const r = await runPaneStalledStdin(env, repo); - assert.equal(r.signal, null, "the pane must exit on its own, never be killed for hanging"); + assert.equal( + r.signal, + null, + "the pane must exit on its own, never be killed for hanging", + ); assert.notEqual(r.code, 0); assert.match(r.stderr, /needs HERDR_SWARM_TASK_FILE or HERDR_SWARM_TASK/); assert.match(r.stderr, /stdin is not a terminal/); @@ -1008,7 +1189,11 @@ test("env-driven: leftover detritus with no DETRITUS var fails by name, not by h }); git(repo, "branch", "swarm/r0/merged"); const r = await runPaneStalledStdin(env, repo); - assert.equal(r.signal, null, "the pane must exit on its own, never be killed for hanging"); + assert.equal( + r.signal, + null, + "the pane must exit on its own, never be killed for hanging", + ); assert.notEqual(r.code, 0); assert.match(r.stderr, /needs HERDR_SWARM_DETRITUS/); assert.match(branchRefs(repo), /swarm\/r0\/merged/, "nothing was touched"); @@ -1020,8 +1205,16 @@ test("env-driven: leftover detritus with no DETRITUS var fails by name, not by h // pins the SCRIPTED=0 seam itself). test("no env vars set: the interactive stdin protocol is untouched", () => { const { repo, env } = setup(); - const r = runPane(lines(["1", "", "Interactive still works", ".", ""]), env, repo); + const r = runPane( + lines(["1", "", "Interactive still works", ".", ""]), + env, + repo, + ); assert.equal(r.status, 0, `${r.stdout}\n${r.stderr}`); - assert.match(r.stdout, /Available presets:/, "the preset menu is still printed"); + assert.match( + r.stdout, + /Available presets:/, + "the preset menu is still printed", + ); assert.equal(readManifest().slots.length, 1); }); From 1982b44dfcdd44efac8a72de345b51286aaf3ff6 Mon Sep 17 00:00:00 2001 From: Victor Garcia Date: Tue, 28 Jul 2026 03:35:44 -0600 Subject: [PATCH 6/9] fix(safety): verify harvest cleanup resources exactly Route every detached harvest-worktree cleanup through one fail-closed verifier that binds repository, run, slot, canonical generation path, HEAD, registration, journal ownership, and recursive ignored inventory. Resolve the exact live manifest across workspace aliases under the repository lock and quarantine unresolved foreign legacy archives without blocking unrelated repositories.\n\nMake Abort propagate post-removal bookkeeping failures and require the exact completed archive, fsync one-use approval directories, replace the Python TOML validator with zero-dependency Node, document scripted cleanup, and add parity regressions across every removal and failure path. --- README.md | 70 ++++++-- scripts/abort.sh | 218 ++++++++++++------------- scripts/check-manifest.mjs | 74 ++++++--- scripts/harvest-pane.sh | 18 ++- scripts/harvest-step.sh | 177 ++++++++++----------- scripts/lib.sh | 123 +++++++++++++- scripts/preflight.sh | 32 ++-- scripts/safety-state.mjs | 273 ++++++++++++++++++++++++++------ scripts/status-pane.sh | 18 ++- tests/check-manifest.test.mjs | 16 ++ tests/harvest.test.mjs | 152 +++++++++++++++++- tests/lib.test.mjs | 23 +++ tests/renderer.test.mjs | 9 +- tests/run-finalization.test.mjs | 117 ++++++++++++++ 14 files changed, 1015 insertions(+), 305 deletions(-) diff --git a/README.md b/README.md index 81895ca..ee727d0 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,8 @@ Conductor). warning, never a refusal. - **git >= 2.38** recommended (relies on `git worktree`, three-arg `git update-ref` compare-and-swap, and `git merge-base --is-ancestor`). -- **Node.js >= 20** on your PATH (manifest handling and the pane renderers). +- **Node.js >= 20** on your PATH (manifest handling, manifest validation, and + pane renderers). No Python runtime or third-party parser is required. - macOS or Linux. ## Install @@ -88,13 +89,17 @@ add your own, see below): branch-name confirmation; a snapshot ref is written first). - *Conflict or hook failure* — classified distinctly; `s` shells into the merge tree, `a` aborts the merge (`git merge --abort`), `b` backs out. - - *Archive* — after merge/skip, the worktree is removed (branch kept); an - inventory of ignored files that removal would silently delete is shown - first and requires acknowledgment. + - *Archive* — after merge/skip, the worktree is removed (branch kept). + Recursive ignored-file inventory is byte-safe and requires the exact + digest-bound, one-use approval before ignored data can be removed. The + same guard covers plugin-owned detached merge worktrees during swap, + resume, and merge-abort cleanup. 4. **Abort** (`structupath.swarm.abort`) — abandon the run, mid-flight or - post-crash: stops agents, closes swarm panes/workspaces, removes clean - swarm worktrees, keeps everything questionable, prints a summary. Branches - are never deleted by abort. + post-crash: stops agents, closes swarm panes/workspaces, removes only exact + verified resources, keeps dirty/ignored/unresolved resources, and prints a + summary. Ignored cleanup is preview/apply, described below. Abort exits + nonzero unless every slot update succeeds and the exact completed archive + exists. Branches are never deleted by abort. 5. **Prune** (`structupath.swarm.prune`) — dry-run listing of fully-merged `swarm/*` branches, discard-snapshot backup refs, and archived run manifests. Deletion is gated per resource class, because the classes are @@ -118,7 +123,7 @@ exactly one prompt; anything you leave unset still prompts, so interactive use i unchanged. | Variable | Replaces | -|---|---| +| --- | --- | | `HERDR_SWARM_SLOTS` | slot count (same cap check, raise with `HERDR_SWARM_MAX_SLOTS`) | | `HERDR_SWARM_PRESETS` | comma-separated preset names, one per slot; a single name applies to all slots | | `HERDR_SWARM_TASK_FILE` | path to a file whose contents become the shared task | @@ -172,12 +177,52 @@ scripts directly rather than through `herdr plugin action invoke` (which forwards no environment): | Capability | Scriptable path | -|---|---| +| --- | --- | | Fan out | `scripts/fanout-pane.sh` with the variables above (zero-TTY) | | Harvest | `scripts/harvest-step.sh ` — a verb CLI with typed exit codes and `keyvalue` stdout | | Abort | `scripts/abort.sh` — a zero-TTY action, env-gated | | Prune | `scripts/prune.sh` — a zero-TTY action, dry run by default, env-gated per resource class | +Harvest, Status, and Abort resolve the active generation by physical Git +repository, not by the current Herdr workspace filename. Reopening the same +repository under another workspace ID therefore reaches the same run. The +resolution occurs under the repository lock and refuses rather than choosing +when multiple live manifests or an invalid active index exist. + +#### Scripted ignored-file cleanup + +Ignored-only work is **kept by default**. Cleanup is an exact two-step +preview/apply protocol; a generic yes/ack variable never authorizes deletion. +The relevant variables are: + +| Variable | Meaning | +| --- | --- | +| `HERDR_SWARM_ABORT_PREVIEW=yes` | read-only Abort preview; closes/removes/updates nothing | +| `HERDR_SWARM_CLEANUP_OPERATION_ID=` | stable caller-chosen preview operation ID; Abort derives one resource ID per slot | +| `HERDR_SWARM_CLEANUP_APPROVAL=''` | exact `cleanup_approval` JSON emitted by preview; bound to resource type, repository, run, slot, physical path, generation/HEAD, operation, and inventory digest | + +Example: + +```sh +HERDR_SWARM_ABORT_PREVIEW=yes \ +HERDR_SWARM_CLEANUP_OPERATION_ID=abort-review-1 \ + bash scripts/abort.sh + +# Copy one cleanup_approval JSON line from the preview, inspect every +# ignored_json line, then apply exactly that one resource approval: +HERDR_SWARM_CLEANUP_APPROVAL='{"approved":true,"...":"exact preview fields"}' \ + bash scripts/abort.sh +``` + +Apply immediately rechecks resource identity and recursively recomputes the +NUL-delimited ignored inventory. Any changed path, HEAD, registration, +generation, digest, symlink, duplicate owner, stale approval, or already-used +operation refuses removal. If several resources contain ignored data, repeat +preview/apply for each emitted approval. `harvest-step.sh archive` uses the +same output protocol (exit 37 when approval is required); detached merge +cleanup may emit an approval after the base swap lands, retains its journaled +worktree, and finishes on `harvest-step.sh resume` with that approval. + ### Keybinding ```toml @@ -255,9 +300,10 @@ starts the agent anyway; output lands in the plugin state dir. - **Squash merges are invisible.** A slot you squash-merged yourself still shows as pending and will conflict on re-merge — skip it by hand. Fast- forward and plain external merges *are* auto-detected via ancestry. -- **Ignored files sit outside every safety net** — not in WIP commits, not in - discard snapshots, not protected by no-`--force` removal. The archive-time - ignored-file inventory prompt is the only guard. +- **Ignored files are not in commits or discard snapshots.** Every slot or + detached-harvest worktree removal recursively inventories them and keeps the + resource by default. Deletion requires the exact one-use preview approval; + changed inventories refuse and must be previewed again. - **Closing the parent repo workspace kills swarm agents silently** (the worktree workspaces are grouped under it). Committed work survives and stays harvestable; uncommitted editor state in the agent does not. diff --git a/scripts/abort.sh b/scripts/abort.sh index ad74cd6..de89c4c 100755 --- a/scripts/abort.sh +++ b/scripts/abort.sh @@ -27,7 +27,7 @@ version_gate intersection || true # is still ACTIVE and the caller must not treat this as a clean teardown. ABORT_EC_KEPT=4 -closed=0 removed=0 kept=0 gone=0 branches_remaining=0 +closed=0 removed=0 kept=0 gone=0 branches_remaining=0 failures=0 # Human-readable inventory of everything KEPT — printed with the ACTIVE-run # notice below, because the summary counter alone does not say WHERE the work # survived. @@ -36,6 +36,10 @@ note_kept() { kept=$((kept + 1)) kept_paths="$kept_paths $1"$'\n' } +note_failure() { + failures=$((failures + 1)) + echo "herdr-swarm: abort bookkeeping failure — $1" >&2 +} # Always printed — success, refusal, corrupt, nothing-to-do (R11: cleanup # always reports what was closed/removed/kept; sibling close.sh convention). @@ -43,28 +47,32 @@ print_summary() { echo "herdr-swarm: abort summary — panes closed $closed, worktrees removed $removed, kept $kept, already gone $gone, swarm branches remaining $branches_remaining (branches are deleted only by prune — R10)." } +# The workspace manifest is a repository-discovery hint only. Select the exact +# live generation across aliases while holding the physical-repository lock. +US=$'\x1f' +REPO_HINT="$(discover_live_repo 2>/dev/null || true)" +if [ -z "$REPO_HINT" ]; then + echo "herdr-swarm: no active swarm run for this repository — nothing to abort." + print_summary + exit 0 +fi +SWARM_REPO="$REPO_HINT" +export SWARM_REPO +MUTATION_LOCK="$(repo_mutation_lock_name "$REPO_HINT")" || exit 1 +acquire_lock "$MUTATION_LOCK" || exit 1 +trap 'release_lock "$MUTATION_LOCK"' EXIT rc=0 -DOC="$(manifest_read)" || rc=$? +bind_live_manifest_locked "$REPO_HINT" || rc=$? case "$rc" in 0) ;; "$MANIFEST_EC_MISSING") - echo "herdr-swarm: no active swarm run for this workspace ($(manifest_path)) — nothing to abort." + echo "herdr-swarm: no active swarm run for this repository — nothing to abort." print_summary exit 0 ;; -"$MANIFEST_EC_CORRUPT") - # manifest_read already printed the .bak hint on stderr. Unknown state - # must never be guess-deleted: degrade to report-only discovery (U3 - # fallback), refuse ALL destruction, and exit the distinct corrupt code - # so a caller can branch without string-matching. - echo "herdr-swarm: manifest is CORRUPT — abort REFUSES all destruction. Restore the manifest (see the .bak hint above) and re-run." >&2 +*) + echo "herdr-swarm: repository live-run bookkeeping is unknown — abort REFUSES all destruction." >&2 echo "herdr-swarm: report-only discovery — what an abort WOULD act on:" - # The manifest is unreadable, so its repo_root is unavailable: seed the - # repo_git seam from the workspace context instead (lib.sh). Empty means - # not-a-repo, and discovery's git side then reports nothing rather than - # scanning whatever repo this action's cwd happened to be. - SWARM_REPO="$(resolve_repo_root 2>/dev/null || true)" - export SWARM_REPO report_only_discovery | while IFS=$'\t' read -r kind a b; do case "$kind" in branch) echo " branch $a (kept either way — abort never deletes branches)" ;; @@ -73,20 +81,17 @@ case "$rc" in esac done print_summary - exit "$MANIFEST_EC_CORRUPT" - ;; -*) - echo "herdr-swarm: could not read the manifest (exit $rc) — abort refused." >&2 - print_summary exit "$rc" ;; esac +DOC="$(manifest_read)" || { + rc=$? + echo "herdr-swarm: exact live manifest is unreadable — abort REFUSES all destruction." >&2 + print_summary + exit "$rc" +} # --- Run context ------------------------------------------------------------- -# \x1f-separated fields from the shared extractor (see lib.sh for why tab -# would silently shift nullable columns). The guard is the extractor's; the -# refusal wording is ours. -US=$'\x1f' CTX="$(manifest_run_context "$DOC")" || { echo "herdr-swarm: manifest has no usable run_id/repo_root — abort refused." >&2 print_summary @@ -103,33 +108,8 @@ if ! run_id_safe="$(sanitize_slug "$RUN_ID")" || [ "$run_id_safe" != "$RUN_ID" ] exit 1 fi -# The repo every preflight helper below acts on. abort learns the repo from -# the MANIFEST, not from cwd, so it pins the seam explicitly (lib.sh repo_git) -# instead of inheriting preflight's cwd-based default — the source-time default -# was resolved before REPO_ROOT was known. SWARM_REPO="$REPO_ROOT" export SWARM_REPO -# The unlocked manifest read above discovers identity only. Serialize on the -# physical repository, re-read the exact live generation under that lock, and -# refuse all deletion when any live/archived bookkeeping is unknown. -MUTATION_LOCK="$(repo_mutation_lock_name "$REPO_ROOT")" || exit 1 -acquire_lock "$MUTATION_LOCK" || exit 1 -trap 'release_lock "$MUTATION_LOCK"' EXIT -DOC_LOCKED="$(manifest_read)" || exit $? -LOCKED_CTX="$(manifest_run_context "$DOC_LOCKED")" || exit 1 -IFS="$US" read -r LOCKED_RUN LOCKED_REPO _ <<<"$LOCKED_CTX" -if [ "$LOCKED_RUN" != "$RUN_ID" ] || [ "$LOCKED_REPO" != "$REPO_ROOT" ]; then - echo "herdr-swarm: manifest identity changed while acquiring the repository lock — abort refused." >&2 - print_summary - exit 3 -fi -DOC="$DOC_LOCKED" -SCAN="$(bookkeeping_scan "$REPO_ROOT")" || exit 1 -if ! bookkeeping_assert_known "$SCAN"; then - echo "herdr-swarm: abort REFUSES all destruction because repository bookkeeping is unknown." >&2 - print_summary - exit 3 -fi # --- Optional read-only cleanup preview ------------------------------------- # Preview inventories every owned slot and exits before pane close, worktree @@ -149,6 +129,21 @@ if [ "${HERDR_SWARM_ABORT_PREVIEW:-}" = "yes" ]; then print_cleanup_inventory "$pinv" fi done + printf '%s' "$DOC" | node -e ' + let d="";process.stdin.on("data",c=>d+=c).on("end",()=>{ + for(const s of JSON.parse(d).slots||[]) { + const j=s.journal; + if(j?.locus==="detached"&&j.worktree) + console.log([s.slot,j.worktree,JSON.stringify(j)].join("\x1f")); + } + }); + ' | while IFS="$US" read -r pslot ppath pjournal; do + [ -d "$ppath" ] || continue + if verify_harvest_resource "$RUN_ID" "$pslot" "$ppath" "$pjournal" >/dev/null; then + pinv="$(harvest_ignored_inventory "$REPO_ROOT" "$RUN_ID" "$pslot" "$ppath" "$pjournal" "$PREVIEW_BASE-hs$pslot")" || exit 1 + print_cleanup_inventory "$pinv" + fi + done echo "herdr-swarm: abort cleanup preview only — zero resources removed." print_summary exit 0 @@ -199,7 +194,8 @@ SLOT_LINES="$(printf '%s' "$DOC" | node -e ' if (s.status === "archived") continue; const j = s.journal || {}; console.log([s.slot, s.branch ?? "", s.path ?? "", s.workspace_id ?? "", - j.locus ?? "", j.merge_commit_sha ?? "", j.worktree ?? ""].join("\x1f")); + j.locus ?? "", j.merge_commit_sha ?? "", j.worktree ?? "", + JSON.stringify(s.journal ?? null)].join("\x1f")); } }); ')" @@ -238,7 +234,8 @@ reap_slot_worktree() { git -C "$REPO_ROOT" worktree prune 2>/dev/null || true echo "herdr-swarm: slot $slot: worktree already gone." gone=$((gone + 1)) - manifest_update_slot "$slot" '{"status":"archived"}' 2>/dev/null || true + manifest_update_slot "$slot" '{"status":"archived"}' || + note_failure "slot $slot was already gone but could not be marked archived" return 0 fi # Re-verify the RESOLVED path, whichever route produced it. The @@ -324,7 +321,8 @@ reap_slot_worktree() { fi removed=$((removed + 1)) echo "herdr-swarm: slot $slot: removed worktree $wt (branch $branch kept — prune deletes merged branches)." - manifest_update_slot "$slot" '{"status":"archived"}' 2>/dev/null || true + manifest_update_slot "$slot" '{"status":"archived"}' || + note_failure "slot $slot was removed but could not be marked archived" } handled_hwts=" " # journaled harvest worktrees, so the leftover glob below skips them @@ -337,85 +335,77 @@ harvest_wt_in_conflict() { [ -n "$(git -C "$1" ls-files -u 2>/dev/null)" ] } reap_harvest_worktree() { - local slot="$1" jmsha="$2" jwt="$3" cur + local slot="$1" jmsha="$2" jwt="$3" journal="$4" cur cleanup_rc=0 [ -n "$jwt" ] || return 0 handled_hwts="$handled_hwts$jwt " + if [ ! -d "$jwt" ]; then + # A crash may occur after verified removal but before journal clear. No + # deletion is attempted; settle the exact slot update strictly. + if [ -n "$jmsha" ]; then + manifest_update_slot "$slot" '{"status":"archived","journal":null}' || note_failure "slot $slot harvest resource was absent but its landed journal could not be cleared" + else + manifest_update_slot "$slot" '{"journal":null}' || note_failure "slot $slot harvest resource was absent but its journal could not be cleared" + fi + return 0 + fi cur="$(git -C "$REPO_ROOT" rev-parse --verify "$BASE_REF^{commit}" 2>/dev/null || true)" if [ -n "$jmsha" ] && [ "$cur" != "$jmsha" ]; then - # The abort-merge rule, reused: a worktree holding an un-swapped merge - # commit is that commit's only obvious anchor — report the SHA loudly - # and keep it (plan risk: never silently unreachable, never auto-deleted). - echo "herdr-swarm: slot $slot has UN-SWAPPED merge commit $jmsha in $jwt — KEPT for recovery (reopen Harvest to resume the swap, or recover by hand)." >&2 + echo "herdr-swarm: slot $slot has UN-SWAPPED merge commit $jmsha in $jwt — KEPT for recovery." >&2 note_kept "harvest worktree $jwt (un-swapped merge commit $jmsha)" return 0 fi - if [ -d "$jwt" ] && harvest_wt_in_conflict "$jwt"; then - # Checked BEFORE the journal is cleared: the conflict resolution the - # user is in the middle of still belongs to that journaled merge. - echo "herdr-swarm: slot $slot has a LIVE conflict resolution in $jwt (unmerged paths present) — KEPT untouched; finish or abandon it in Harvest." >&2 + if harvest_wt_in_conflict "$jwt"; then + echo "herdr-swarm: slot $slot has a LIVE conflict resolution in $jwt — KEPT untouched." >&2 note_kept "harvest worktree $jwt (live conflict resolution)" return 0 fi - if [ -n "$jmsha" ]; then - # Base already equals the journaled commit: the swap landed before the - # crash and only bookkeeping is missing (same as resume_completed). - manifest_update_slot "$slot" '{"status":"merged","journal":null}' 2>/dev/null || true - else - manifest_update_slot "$slot" '{"journal":null}' 2>/dev/null || true + if ! verify_harvest_resource "$RUN_ID" "$slot" "$jwt" "$journal" >/dev/null; then + echo "herdr-swarm: slot $slot harvest resource identity FAILED — KEPT untouched." >&2 + note_kept "harvest worktree $jwt (exact resource identity failed)" + return 0 fi - if [ -d "$jwt" ]; then - # May be mid-conflict or clean — merge --abort is best-effort, then a - # no-force removal; a refusal keeps it and says so. + if [ -z "$jmsha" ]; then + # Verification precedes merge --abort because it resets tracked state. git -C "$jwt" merge --abort >/dev/null 2>&1 || true - if git -C "$REPO_ROOT" worktree remove "$jwt" >/dev/null 2>&1; then - removed=$((removed + 1)) - echo "herdr-swarm: removed harvest worktree $jwt." - else - echo "herdr-swarm: KEPT harvest worktree $jwt (removal refused; inspect by hand)." >&2 - note_kept "harvest worktree $jwt (removal refused)" - fi + fi + remove_harvest_resource "$RUN_ID" "$slot" "$jwt" "$journal" || cleanup_rc=$? + if [ "$cleanup_rc" -ne 0 ]; then + echo "herdr-swarm: KEPT harvest worktree $jwt (exact cleanup refused or needs approval)." >&2 + note_kept "harvest worktree $jwt (cleanup refused or approval required)" + return 0 + fi + removed=$((removed + 1)) + echo "herdr-swarm: removed harvest worktree $jwt." + if [ -n "$jmsha" ]; then + manifest_update_slot "$slot" '{"status":"archived","journal":null}' || note_failure "slot $slot harvest worktree was removed but landed journal update failed" + else + manifest_update_slot "$slot" '{"journal":null}' || note_failure "slot $slot harvest worktree was removed but journal clear failed" fi } -while IFS="$US" read -r slot branch wtpath wsid jlocus jmsha jwt; do +while IFS="$US" read -r slot branch wtpath wsid jlocus jmsha jwt journal; do [ -n "$slot" ] || continue reap_slot_worktree "$slot" "$branch" "$wtpath" "$wsid" # Only the detached locus owns a plugin worktree; the user-tree locus # journals the USER's checkout, which abort never touches (MERGE_HEAD # detection below is the only user-tree interaction, and it is read-only). if [ "$jlocus" = "detached" ]; then - reap_harvest_worktree "$slot" "$jmsha" "$jwt" + reap_harvest_worktree "$slot" "$jmsha" "$jwt" "$journal" fi done <<<"$SLOT_LINES" -# Leftover harvest worktrees whose journal was already cleared (crash between -# journal_clear and removal). Guard even here: a HEAD that is not an ancestor -# of base could be an un-swapped merge commit the bookkeeping lost — keep it. +# A harvest-looking path with no exact live journal has no generation/HEAD/ +# slot ownership proof. Route it through the shared verifier, which must fail, +# and quarantine it in place rather than resurrecting the old prefix-only rm. for d in "$(state_dir)"/harvest-"$RUN_ID"-s*; do [ -d "$d" ] || continue case "$handled_hwts" in *" $d "*) continue ;; esac - head_sha="$(git -C "$d" rev-parse --verify HEAD 2>/dev/null || true)" - if [ -n "$head_sha" ] && ! git -C "$REPO_ROOT" merge-base --is-ancestor "$head_sha" "$BASE_REF" 2>/dev/null; then - echo "herdr-swarm: KEPT leftover harvest worktree $d — its HEAD $head_sha is not on $BASE_REF (possible un-swapped merge commit)." >&2 - note_kept "leftover harvest worktree $d (HEAD $head_sha not on $BASE_REF)" - continue - fi - # Same live-conflict hazard as the journaled path: a mid-conflict merge - # leaves HEAD at base (no merge commit yet), so the ancestry guard above - # passes and only the unmerged-index check catches it. - if harvest_wt_in_conflict "$d"; then - echo "herdr-swarm: KEPT leftover harvest worktree $d — LIVE conflict resolution in progress (unmerged paths present)." >&2 - note_kept "leftover harvest worktree $d (live conflict resolution)" - continue - fi - git -C "$d" merge --abort >/dev/null 2>&1 || true - if git -C "$REPO_ROOT" worktree remove "$d" >/dev/null 2>&1; then - removed=$((removed + 1)) - echo "herdr-swarm: removed leftover harvest worktree $d." + if verify_harvest_resource "$RUN_ID" "0" "$d" "null" >/dev/null 2>&1; then + echo "herdr-swarm: internal error: unjournaled harvest resource unexpectedly verified — KEPT." >&2 else - echo "herdr-swarm: KEPT leftover harvest worktree $d (removal refused)." >&2 - note_kept "leftover harvest worktree $d (removal refused)" + echo "herdr-swarm: KEPT leftover harvest-looking worktree $d — no exact live resource journal owns it." >&2 fi + note_kept "leftover harvest-looking worktree $d (unresolved generation ownership)" done # --- (5) MERGE_HEAD in the user's tree: offer, never run --------------------- @@ -435,9 +425,10 @@ fi # abort, so leaving it is the cheap side of the trade. # remove_exclude_pattern resolves --git-path through repo_git, so it lands in # SWARM_REPO's exclude file regardless of abort's own cwd. -if [ "$kept" -eq 0 ]; then - remove_exclude_pattern || - echo "herdr-swarm: warning: could not remove the $SWARM_TASK_FILE exclude pattern." >&2 +if [ "$kept" -eq 0 ] && [ "$failures" -eq 0 ]; then + remove_exclude_pattern || { + note_failure "could not remove the $SWARM_TASK_FILE exclude pattern" + } else echo "herdr-swarm: kept the $SWARM_TASK_FILE exclude pattern — kept worktrees still contain that file, and un-excluding it would expose it to git status." fi @@ -458,6 +449,11 @@ fi # manifest at $(manifest_path); archiving it would leave the kept worktrees # unreachable through the tool that is supposed to rescue them. So when # kept > 0 the manifest stays exactly where it is and the run stays ACTIVE. +if [ "$failures" -gt 0 ]; then + echo "herdr-swarm: run $RUN_ID stays ACTIVE — $failures authoritative slot/bookkeeping update(s) failed after cleanup; Abort is incomplete." >&2 + print_summary + exit 1 +fi if [ "$kept" -gt 0 ]; then echo "herdr-swarm: run $RUN_ID stays ACTIVE — $kept item(s) were KEPT and the manifest is NOT archived, so Harvest can still reach them:" printf '%s' "$kept_paths" @@ -466,15 +462,19 @@ if [ "$kept" -gt 0 ]; then exit "$ABORT_EC_KEPT" fi -# Exact-name, idempotent finalization is shared with full Harvest. Archive -# failure is fatal: a successful exit may never claim a run finished while -# its durable recovery record is still live or ambiguous. -if ! final_out="$(finalize_run "$REPO_ROOT" "$RUN_ID")"; then +# Abort requires a complete slot set and the exact immutable archive. Partial +# Harvest's benign no-op finalization mode is explicitly not accepted here. +if ! final_out="$(finalize_run "$REPO_ROOT" "$RUN_ID" require-complete)"; then echo "herdr-swarm: abort cleanup finished but run finalization/archive FAILED; the run remains recoverable and this abort is incomplete." >&2 print_summary exit 1 fi [ -n "$final_out" ] && printf '%s\n' "$final_out" +if ! exact_run_archive_exists "$RUN_ID"; then + echo "herdr-swarm: abort finalization did not leave the exact completed archive; Abort is incomplete." >&2 + print_summary + exit 1 +fi print_summary exit 0 diff --git a/scripts/check-manifest.mjs b/scripts/check-manifest.mjs index 0328f0a..2f682cf 100755 --- a/scripts/check-manifest.mjs +++ b/scripts/check-manifest.mjs @@ -1,35 +1,67 @@ #!/usr/bin/env node -import { spawnSync } from "node:child_process"; import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; const COMMAND_SECTIONS = ["build", "startup", "actions", "panes", "events"]; -const TOML_TO_JSON = ` -import json -import sys -import tomllib -with open(sys.argv[1], "rb") as manifest: - json.dump(tomllib.load(manifest), sys.stdout) -`; +function stripTomlComment(line) { + let quoted = false; + let escaped = false; + for (let index = 0; index < line.length; index += 1) { + const char = line[index]; + if (escaped) { + escaped = false; + continue; + } + if (quoted && char === "\\") { + escaped = true; + continue; + } + if (char === '"') quoted = !quoted; + else if (char === "#" && !quoted) return line.slice(0, index); + } + if (quoted) throw new Error("unterminated string"); + return line; +} + +function parseTomlValue(raw) { + const value = raw.trim(); + if (value.startsWith('"') || value.startsWith("[")) return JSON.parse(value); + if (/^(?:true|false)$/.test(value)) return value === "true"; + if (/^-?[0-9]+$/.test(value)) return Number(value); + throw new Error(`unsupported value ${JSON.stringify(value)}`); +} +// Herdr's manifest contract here uses root scalars and arrays of tables with +// scalar/string-array fields. Parsing that declared subset in Node keeps the +// validator zero-dependency and makes Node >=20 the complete CI toolchain. function parseManifest(manifestPath) { - const result = spawnSync("python3", ["-c", TOML_TO_JSON, manifestPath], { - encoding: "utf8", - }); - if (result.error) { - throw new Error( - `could not run python3 to parse the manifest: ${result.error.message}`, - ); - } - if (result.status !== 0) { - throw new Error(`manifest is not valid TOML: ${result.stderr.trim()}`); - } + const document = {}; + let current = document; try { - return JSON.parse(result.stdout); + for (const source of fs.readFileSync(manifestPath, "utf8").split(/\r?\n/)) { + const line = stripTomlComment(source).trim(); + if (!line) continue; + const table = line.match(/^\[\[([A-Za-z0-9_-]+)\]\]$/); + if (table) { + const section = table[1]; + document[section] ??= []; + if (!Array.isArray(document[section])) + throw new Error(`${section} is not an array of tables`); + current = {}; + document[section].push(current); + continue; + } + const assignment = line.match(/^([A-Za-z0-9_-]+)\s*=\s*(.+)$/); + if (!assignment) throw new Error("unsupported statement"); + const [, key, raw] = assignment; + if (Object.hasOwn(current, key)) throw new Error(`duplicate key ${key}`); + current[key] = parseTomlValue(raw); + } + return document; } catch (error) { - throw new Error(`manifest parser returned invalid JSON: ${error.message}`); + throw new Error(`manifest is not valid TOML: ${error.message}`); } } diff --git a/scripts/harvest-pane.sh b/scripts/harvest-pane.sh index 748d71a..7a55b5c 100755 --- a/scripts/harvest-pane.sh +++ b/scripts/harvest-pane.sh @@ -6,6 +6,7 @@ # reads as a crash. set -uo pipefail +INVOCATION_CWD="$PWD" cd "${HERDR_PLUGIN_ROOT:-$(dirname "$0")/..}" || { # lib.sh (and pane_fatal with it) is unreachable without the plugin root, # so this one failure lingers inline. @@ -17,9 +18,22 @@ cd "${HERDR_PLUGIN_ROOT:-$(dirname "$0")/..}" || { pane_require_node "harvest pane" +repo_hint="" +if [ -n "${HERDR_PLUGIN_CONTEXT_JSON:-}" ] || [ -f "$(state_dir)/run-$(ws_id).json" ]; then + repo_hint="$(discover_live_repo 2>/dev/null || true)" +else + repo_hint="$(git -C "$INVOCATION_CWD" rev-parse --show-toplevel 2>/dev/null || true)" +fi +[ -n "$repo_hint" ] || pane_fatal "herdr-swarm: cannot resolve this workspace repository." +SWARM_REPO="$repo_hint" +export SWARM_REPO +lock="$(repo_mutation_lock_name "$repo_hint")" || pane_fatal "herdr-swarm: cannot resolve repository identity." +acquire_lock "$lock" || pane_fatal "herdr-swarm: repository is busy; reopen Harvest." +rc=0 +bind_live_manifest_locked "$repo_hint" || rc=$? +release_lock "$lock" +[ "$rc" -eq 0 ] || pane_fatal "herdr-swarm: no single validated active run exists for this repository (resolution exit $rc) — nothing to harvest." mf="$(manifest_path)" -[ -f "$mf" ] || - pane_fatal "herdr-swarm: no active swarm run for this workspace (no manifest at $mf) — nothing to harvest." # Spawn-time env via the shared contract (state-dir rule). A corrupt manifest # is NOT fatal here: the renderer treats corrupt as a first-class display diff --git a/scripts/harvest-step.sh b/scripts/harvest-step.sh index b3e26fe..353e8cd 100755 --- a/scripts/harvest-step.sh +++ b/scripts/harvest-step.sh @@ -67,43 +67,32 @@ shift # --- Run + slot context ------------------------------------------------------ -# Manifest read up front: missing (2) or corrupt (3) refuses every verb with -# the lib's own exit code — never guess against unreadable bookkeeping. -DOC="$(manifest_read)" || exit $? - # Internal field separator is ASCII unit separator (\x1f), NOT tab: tab is # IFS *whitespace*, so bash collapses runs of it and empty fields silently # shift every later field left — exactly the bug for nullable columns. US=$'\x1f' -# Run context from the shared extractor (lib.sh): the guard is the -# extractor's; the refusal wording is ours. +# The workspace-named manifest is only a repository-discovery hint. Select the +# one exact live generation for that physical repository under its lock, so a +# reopened/aliased workspace reaches the same run and multiples fail closed. +REPO_HINT="$(discover_live_repo)" || { + echo "herdr-swarm: cannot resolve the workspace repository — cannot harvest." >&2 + exit "$MANIFEST_EC_MISSING" +} +SWARM_REPO="$REPO_HINT" +export SWARM_REPO +MUTATION_LOCK="$(repo_mutation_lock_name "$REPO_HINT")" || exit 1 +acquire_lock "$MUTATION_LOCK" || exit 1 +trap 'release_lock "$MUTATION_LOCK"' EXIT +bind_live_manifest_locked "$REPO_HINT" || exit $? +DOC="$(manifest_read)" || exit $? CTX="$(manifest_run_context "$DOC")" || { echo "herdr-swarm: manifest has no usable run_id/repo_root — cannot harvest." >&2 exit 1 } IFS="$US" read -r RUN_ID REPO_ROOT BASE_REF FORK_SHA <<<"$CTX" -# Pin the repo_git seam (lib.sh) to the MANIFEST's repo: every call site here -# already passes `git -C "$REPO_ROOT"`, but preflight.sh helpers sourced above -# resolve through SWARM_REPO, whose source-time default was cwd-based. SWARM_REPO="$REPO_ROOT" export SWARM_REPO -# Lock the physical repository identity, then immediately re-read and validate -# every live/archived manifest generation. The unlocked read above is only -# identity discovery and can never authorize a mutation. -MUTATION_LOCK="$(repo_mutation_lock_name "$REPO_ROOT")" || exit 1 -acquire_lock "$MUTATION_LOCK" || exit 1 -trap 'release_lock "$MUTATION_LOCK"' EXIT -DOC_LOCKED="$(manifest_read)" || exit $? -LOCKED_CTX="$(manifest_run_context "$DOC_LOCKED")" || exit 1 -IFS="$US" read -r LOCKED_RUN LOCKED_REPO _ <<<"$LOCKED_CTX" -if [ "$LOCKED_RUN" != "$RUN_ID" ] || [ "$LOCKED_REPO" != "$REPO_ROOT" ]; then - echo "herdr-swarm: manifest identity changed while acquiring the repository lock — refused." >&2 - exit "$HS_EC_REFUSED" -fi -DOC="$DOC_LOCKED" -SCAN="$(bookkeeping_scan "$REPO_ROOT")" || exit 1 -bookkeeping_assert_known "$SCAN" || exit $? # run_id is interpolated into the harvest worktree path ($(state_dir)/harvest- # $RUN_ID-s, fed straight to `git worktree add`/`remove`) and into the # backup ref namespace (refs/swarm-backups/$RUN_ID/). A run_id carrying @@ -184,17 +173,21 @@ journal_field() { # record written BEFORE git mutates anything, so a crash inside the merge # critical section is detected and resumable on the next open (manifest KTD). journal_set() { - local slot="$1" patch + local slot="$1" locus="$2" expected="$3" merge_sha="$4" wt="$5" generation="${6-}" patch identity + identity="$(repo_identity_json "$REPO_ROOT")" || return 1 patch="$(node -e ' - const [locus, expected, mergeSha, wt] = process.argv.slice(1); - process.stdout.write(JSON.stringify({ journal: { - locus, - expected_base_sha: expected, - merge_commit_sha: mergeSha || null, - worktree: wt || null, - }})); - ' "$2" "$3" "$4" "$5")" || return 1 - manifest_update_slot "$slot" "$patch" + const [locus,expected,mergeSha,wt,generation,identity,runId,slot]=process.argv.slice(1); + const journal={locus,expected_base_sha:expected,merge_commit_sha:mergeSha||null,worktree:wt||null}; + if(locus==="detached") { + const id=JSON.parse(identity); + if(!generation) process.exit(2); + journal.resource={type:"harvest",repo_key:id.repo_key,git_common_dir:id.git_common_dir, + run_id:runId,slot,path:wt,generation,head:mergeSha||expected}; + } + process.stdout.write(JSON.stringify({journal})); + ' "$locus" "$expected" "$merge_sha" "$wt" "$generation" "$identity" "$RUN_ID" "$slot")" || return 1 + manifest_update_slot "$slot" "$patch" || return $? + SLOT_JOURNAL="$(printf '%s' "$patch" | node -e 'let d="";process.stdin.on("data",c=>d+=c).on("end",()=>process.stdout.write(JSON.stringify(JSON.parse(d).journal)))')" } journal_clear() { @@ -272,7 +265,16 @@ run_merge() { # merge and resume-complete. Three-arg update-ref IS the guarantee (KTD): a # check-then-plain-write has exactly the clobber window this exists to close. swap_base() { - local slot="$1" expected="$2" new="$3" hwt="$4" lp + local slot="$1" expected="$2" new="$3" hwt="$4" lp cleanup_rc=0 + # A resumed journal must prove the exact detached resource before it can + # advance the base ref. This prevents a forged worktree pointer from being + # treated as trusted merely because its merge commit is plausible. + if [ -n "$hwt" ]; then + verify_harvest_resource "$RUN_ID" "$slot" "$hwt" "$SLOT_JOURNAL" >/dev/null || { + echo "herdr-swarm: harvest resource identity failed before base swap; base unchanged and resource kept." >&2 + return "$HS_EC_REFUSED" + } + fi # Millisecond guard (KTD): the locus decision can go stale between the # merge and this write — a base checked out NOW means update-ref would # desync that checkout's index; refuse and let resume finish the swap. @@ -287,27 +289,19 @@ swap_base() { echo "herdr-swarm: base $BASE_REF moved during the merge — swap FAILED, base unchanged. Merge commit $new is journaled and kept${hwt:+ in $hwt}; reopen harvest to re-preview." >&2 return "$HS_EC_SWAP" fi - manifest_update_slot "$slot" '{"status":"merged","journal":null}' - # Ownership guard (belt-and-braces against the user-tree-locus journal): - # journal.worktree holds the USER's checkout for the user-tree locus, and - # `git worktree remove` DOES delete a base checked out in a linked worktree - # (only a MAIN working tree is refused) — so the removal below is a user- - # data-loss hazard for any path that is not plugin-owned. Callers already - # gate on locus = detached; this is the second lock on the same door. - if [ -n "$hwt" ]; then - case "$hwt" in - "$(state_dir)"/harvest-*) ;; - *) - echo "herdr-swarm: refusing to remove $hwt — not a plugin-owned harvest worktree (kept untouched)." >&2 - hwt="" - ;; - esac - fi + # Record the landed merge while retaining the exact resource journal until + # deletion succeeds. A crash or approval refusal therefore remains safely + # resumable instead of creating an unverifiable leftover path. + manifest_update_slot "$slot" '{"status":"merged"}' || return 1 if [ -n "$hwt" ] && [ -d "$hwt" ]; then - # Clean after a committed merge, so no --force is needed; a refusal - # here is bookkeeping noise, not data loss — report, never force. - git -C "$REPO_ROOT" worktree remove "$hwt" 2>/dev/null || - echo "herdr-swarm: note: kept harvest worktree $hwt (remove refused)" >&2 + remove_harvest_resource "$RUN_ID" "$slot" "$hwt" "$SLOT_JOURNAL" || cleanup_rc=$? + case "$cleanup_rc" in + 0) journal_clear "$slot" || return 1 ;; + "$HS_EC_IGNORED") echo "herdr-swarm: merge landed; harvest worktree kept until the emitted ignored-file approval is applied via resume." >&2 ;; + *) echo "herdr-swarm: merge landed; harvest worktree kept because exact cleanup verification/removal failed." >&2 ;; + esac + else + journal_clear "$slot" || return 1 fi printf 'merged\t%s\n' "$new" } @@ -500,15 +494,17 @@ do_merge() { } merge_detached() { - local slot="$1" expected="$2" hwt rc=0 new - hwt="$(state_dir)/harvest-$RUN_ID-s$slot" + local slot="$1" expected="$2" hwt generation rc=0 new + generation="$(cleanup_operation_id)" || return 1 + hwt="$(state_dir)/harvest-$RUN_ID-s$slot-$generation" if [ -e "$hwt" ]; then - echo "herdr-swarm: leftover harvest worktree $hwt — resume or abort-merge first." >&2 + echo "herdr-swarm: harvest generation path already exists at $hwt — refused." >&2 return "$HS_EC_REFUSED" fi # Intent journaled BEFORE the worktree exists: abort/resume must always - # over-approximate what might be on disk (manifest KTD). - journal_set "$slot" detached "$expected" "" "$hwt" || return 1 + # over-approximate what might be on disk (manifest KTD). The generation is + # part of both the exact path and the resource identity. + journal_set "$slot" detached "$expected" "" "$hwt" "$generation" || return 1 # Plain git worktree add --detach, never `herdr worktree create` — that # would mint a branch and open a workspace (merge-locus KTD). if ! git -C "$REPO_ROOT" worktree add --detach "$hwt" "$expected"; then @@ -527,7 +523,7 @@ merge_detached() { new="$(git -C "$hwt" rev-parse HEAD)" || return 1 # The merge commit's SHA lands in the journal the moment it exists: a # crash between here and the swap is detected by resume, never silent. - journal_set "$slot" detached "$expected" "$new" "$hwt" || return 1 + journal_set "$slot" detached "$expected" "$new" "$hwt" "$generation" || return 1 # Test seams for the crash window — see file header. if [ -n "${HERDR_SWARM_TEST_DIE_BEFORE_SWAP:-}" ]; then exit 99; fi if [ -n "${HERDR_SWARM_TEST_PAUSE_BEFORE_SWAP:-}" ]; then @@ -610,24 +606,28 @@ do_resume() { return $? fi # Scan every journaled slot; each line is a typed fact for the renderer. - local slot locus expected msha hwt - while IFS="$US" read -r slot locus expected msha hwt; do + local slot locus expected msha hwt journal cleanup_rc + while IFS="$US" read -r slot locus expected msha hwt journal; do [ -n "$slot" ] || continue + SLOT_JOURNAL="$journal" if [ -z "$msha" ]; then # Crash (or conflict-stall) before a merge commit existed. printf 'resume_stale\t%s\n' "$slot" echo "herdr-swarm: slot $slot has a journaled merge intent with no commit — abort-merge cleans it up." >&2 elif [ "$cur" = "$msha" ]; then - # The swap itself landed before the crash; only bookkeeping is - # missing — finish it. - manifest_update_slot "$slot" '{"status":"merged","journal":null}' || true - # locus gate (abort.sh:235's rule): only the detached locus journals - # a plugin-owned worktree. The user-tree locus journals the USER's - # checkout, and `git worktree remove` deletes a LINKED-worktree - # checkout of the base — removing it here would destroy user work. + # The swap itself landed before the crash; retain the journal until + # the exact generation is removed (or prove it is already absent). + manifest_update_slot "$slot" '{"status":"merged"}' || return 1 + cleanup_rc=0 if [ "$locus" = "detached" ] && [ -n "$hwt" ] && [ -d "$hwt" ]; then - git -C "$REPO_ROOT" worktree remove "$hwt" 2>/dev/null || - echo "herdr-swarm: note: kept harvest worktree $hwt" >&2 + remove_harvest_resource "$RUN_ID" "$slot" "$hwt" "$journal" || cleanup_rc=$? + if [ "$cleanup_rc" -eq 0 ]; then + journal_clear "$slot" || return 1 + else + echo "herdr-swarm: slot $slot landed merge remains journaled; exact harvest cleanup was refused or needs approval." >&2 + fi + else + journal_clear "$slot" || return 1 fi printf 'resume_completed\t%s\t%s\n' "$slot" "$msha" elif [ "$cur" = "$expected" ]; then @@ -646,7 +646,7 @@ do_resume() { if (!s.journal) continue; const j = s.journal; console.log([s.slot, j.locus ?? "", j.expected_base_sha ?? "", - j.merge_commit_sha ?? "", j.worktree ?? ""].join("\x1f")); + j.merge_commit_sha ?? "", j.worktree ?? "", JSON.stringify(j)].join("\x1f")); } }); ')" @@ -797,9 +797,12 @@ do_abort_merge() { cur="$(base_sha)" || return 1 if [ "$locus" = "detached" ]; then if [ -n "$msha" ] && [ "$cur" = "$msha" ]; then - # The swap already landed — this is resume_completed territory. - manifest_update_slot "$1" '{"status":"merged","journal":null}' || return 1 - [ -d "$wt" ] && { git -C "$REPO_ROOT" worktree remove "$wt" 2>/dev/null || true; } + # The swap already landed — settle only after exact cleanup succeeds. + manifest_update_slot "$1" '{"status":"merged"}' || return 1 + if [ -d "$wt" ]; then + remove_harvest_resource "$RUN_ID" "$1" "$wt" "$SLOT_JOURNAL" || return $? + fi + journal_clear "$1" || return 1 printf 'aborted\talready-swapped\n' return 0 fi @@ -811,25 +814,21 @@ do_abort_merge() { return "$HS_EC_REFUSED" fi if [ -d "$wt" ]; then - # An empty journal sha does NOT prove there is no merge commit: a - # crash between the merge commit's rev-parse and its journal_set - # leaves a real, un-swapped merge commit in a worktree the journal - # never learned about. Removing it makes that commit unreachable — - # the same hazard abort.sh:243-251 guards, with the same test: - # a HEAD that is not an ancestor of base is possibly un-swapped - # work, so keep it and report the SHA (never silently unreachable). local head_sha head_sha="$(git -C "$wt" rev-parse --verify HEAD 2>/dev/null || true)" if [ -n "$head_sha" ] && ! git -C "$REPO_ROOT" merge-base --is-ancestor "$head_sha" "$BASE_REF" 2>/dev/null; then - echo "herdr-swarm: slot $1 worktree $wt has HEAD $head_sha, which is NOT on $BASE_BRANCH — possibly an un-swapped merge commit the journal lost (crash before the SHA was recorded). KEPT for recovery; abort refused." >&2 + echo "herdr-swarm: slot $1 worktree $wt has HEAD $head_sha, which is NOT on $BASE_BRANCH — possibly an un-swapped merge commit the journal lost. KEPT for recovery." >&2 return "$HS_EC_REFUSED" fi - # May or may not be mid-merge (a crash before the merge started - # leaves a clean worktree) — abort is best-effort by design. + # Verify before aborting the merge: merge --abort itself resets tracked + # state and must never run against a forged foreign journal. + verify_harvest_resource "$RUN_ID" "$1" "$wt" "$SLOT_JOURNAL" >/dev/null || { + echo "herdr-swarm: slot $1 harvest resource identity failed — kept untouched." >&2 + return "$HS_EC_REFUSED" + } git -C "$wt" merge --abort 2>/dev/null || true - git -C "$REPO_ROOT" worktree remove "$wt" 2>/dev/null || - echo "herdr-swarm: kept $wt (dirty after abort — inspect by hand)" >&2 + remove_harvest_resource "$RUN_ID" "$1" "$wt" "$SLOT_JOURNAL" || return $? fi journal_clear "$1" || return 1 printf 'aborted\t%s\n' "$1" diff --git a/scripts/lib.sh b/scripts/lib.sh index 978b5b9..c156d8b 100644 --- a/scripts/lib.sh +++ b/scripts/lib.sh @@ -199,6 +199,49 @@ bookkeeping_scan() { safety_state scan "$(state_dir)" "$1" } +# Resolve the physical repository before selecting a live generation. The +# workspace-named manifest is only a discovery hint; repository aliases fall +# back to Herdr's workspace context/cwd, and neither route authorizes mutation. +discover_live_repo() { + local hint repo="" + hint="$(state_dir)/run-$(ws_id).json" + if [ -f "$hint" ] && [ ! -L "$hint" ]; then + repo="$(node -e ' + const fs=require("fs"); + try { const d=JSON.parse(fs.readFileSync(process.argv[1],"utf8")); + if(typeof d.repo_root==="string") process.stdout.write(d.repo_root); } + catch {} + ' "$hint" 2>/dev/null || true)" + fi + [ -n "$repo" ] && [ -d "$repo" ] || repo="$(resolve_repo_root 2>/dev/null || true)" + [ -n "$repo" ] || return 1 + printf '%s\n' "$repo" +} + +# Caller holds repo_mutation_lock_name(repo). Exactly one semantically valid +# live manifest may be selected; zero is MANIFEST_EC_MISSING and multiples or +# unknown bookkeeping fail closed through bookkeeping_assert_known. +resolve_live_manifest_locked() { + local repo_root="$1" scan + scan="$(bookkeeping_scan "$repo_root")" || return 1 + bookkeeping_assert_known "$scan" || return $? + printf '%s' "$scan" | node -e ' + let d=""; process.stdin.on("data",c=>d+=c).on("end",()=>{ + const live=JSON.parse(d).live||[]; + if(live.length===0) process.exit(2); + if(live.length!==1) process.exit(3); + process.stdout.write(live[0].path+"\n"); + }); + ' +} + +bind_live_manifest_locked() { + local selected + selected="$(resolve_live_manifest_locked "$1")" || return $? + HERDR_SWARM_MANIFEST_PATH="$selected" + export HERDR_SWARM_MANIFEST_PATH +} + bookkeeping_assert_known() { local scan="$1" printf '%s' "$scan" | node -e ' @@ -254,12 +297,74 @@ cleanup_operation_id() { safety_state operation-id } -slot_ignored_inventory() { - local repo_root="$1" run_id="$2" slot="$3" wt="$4" operation_id="$5" identity key common +slot_resource_binding() { + local repo_root="$1" run_id="$2" slot="$3" wt="$4" identity physical head identity="$(repo_identity_json "$repo_root")" || return 1 - key="$(repo_identity_field "$identity" repo_key)" || return 1 - common="$(repo_identity_field "$identity" git_common_dir)" || return 1 - safety_state inventory "$repo_root" "$key" "$common" "$run_id" "$slot" "$wt" "$operation_id" + physical="$(cd "$wt" 2>/dev/null && pwd -P)" || return 1 + head="$(git -C "$physical" rev-parse --verify 'HEAD^{commit}')" || return 1 + node -e ' + const [identity,runId,slot,worktree,head]=process.argv.slice(1), id=JSON.parse(identity); + process.stdout.write(JSON.stringify({resource_type:"slot",repo_key:id.repo_key, + git_common_dir:id.git_common_dir,run_id:runId,slot,worktree, + generation:"slot-worktree",head})); + ' "$identity" "$run_id" "$slot" "$physical" "$head" +} + +cleanup_inventory() { + local repo_root="$1" binding="$2" operation_id="$3" + safety_state inventory "$repo_root" "$binding" "$operation_id" +} + +slot_ignored_inventory() { + local repo_root="$1" run_id="$2" slot="$3" wt="$4" operation_id="$5" binding + binding="$(slot_resource_binding "$repo_root" "$run_id" "$slot" "$wt")" || return 1 + cleanup_inventory "$repo_root" "$binding" "$operation_id" +} + +verify_harvest_resource() { + local run_id="$1" slot="$2" wt="$3" journal="$4" + safety_state verify-harvest "$(state_dir)" "${SWARM_REPO:?}" "$run_id" "$slot" "$wt" "$journal" "$(manifest_path)" +} + +harvest_ignored_inventory() { + local repo_root="$1" run_id="$2" slot="$3" wt="$4" journal="$5" operation_id="$6" binding + binding="$(verify_harvest_resource "$run_id" "$slot" "$wt" "$journal")" || return 1 + cleanup_inventory "$repo_root" "$binding" "$operation_id" +} + +# The sole git-removal surface for detached harvest resources. Identity and +# recursive ignored inventory are checked twice, immediately around the +# no-force removal. Ignored data requires the exact one-use approval. +remove_harvest_resource() { + local run_id="$1" slot="$2" wt="$3" journal="$4" operation inventory count used rechecked before after binding + binding="$(verify_harvest_resource "$run_id" "$slot" "$wt" "$journal")" || return 36 + operation="$(printf '%s' "${HERDR_SWARM_CLEANUP_APPROVAL:-}" | node -e ' + let d="";process.stdin.on("data",c=>d+=c).on("end",()=>{try{const a=JSON.parse(d);if(a.operation_id)process.stdout.write(String(a.operation_id));}catch{}}); + ')" + [ -n "$operation" ] || operation="$(cleanup_operation_id)" || return 1 + inventory="$(cleanup_inventory "$SWARM_REPO" "$binding" "$operation")" || return 1 + count="$(cleanup_inventory_count "$inventory")" || return 1 + if [ "$count" -gt 0 ]; then + used="$(cleanup_approval_validate "$inventory")" || { + print_cleanup_inventory "$inventory" + echo "herdr-swarm: harvest worktree holds ignored files; apply requires this exact one-use cleanup approval." >&2 + return 37 + } + fi + if [ -n "${HERDR_SWARM_TEST_CLEANUP_READY_FILE:-}" ]; then : >"$HERDR_SWARM_TEST_CLEANUP_READY_FILE"; fi + if [ -n "${HERDR_SWARM_TEST_PAUSE_BEFORE_CLEANUP_RECHECK:-}" ]; then sleep "$HERDR_SWARM_TEST_PAUSE_BEFORE_CLEANUP_RECHECK"; fi + binding="$(verify_harvest_resource "$run_id" "$slot" "$wt" "$journal")" || return 36 + rechecked="$(cleanup_inventory "$SWARM_REPO" "$binding" "$operation")" || return 1 + before="$(printf '%s' "$inventory" | node -e 'let d="";process.stdin.on("data",c=>d+=c).on("end",()=>process.stdout.write(JSON.parse(d).digest))')" + after="$(printf '%s' "$rechecked" | node -e 'let d="";process.stdin.on("data",c=>d+=c).on("end",()=>process.stdout.write(JSON.parse(d).digest))')" + if [ "$before" != "$after" ]; then + print_cleanup_inventory "$rechecked" + echo "herdr-swarm: harvest cleanup inventory changed after preview; zero removal performed." >&2 + return 37 + fi + if [ "$count" -gt 0 ]; then cleanup_approval_consume "$used" || return 36; fi + git -C "$SWARM_REPO" worktree remove "$wt" || return 1 + safety_state verify-harvest-removed "$SWARM_REPO" "$binding" || return 1 } cleanup_inventory_count() { @@ -274,7 +379,7 @@ print_cleanup_inventory() { console.log("cleanup_operation\t" + i.operation_id); console.log("cleanup_digest\t" + i.digest); const approval={approved:true}; - for (const k of ["repo_key","git_common_dir","run_id","slot","worktree","operation_id","digest"]) approval[k]=i[k]; + for (const k of ["resource_type","repo_key","git_common_dir","run_id","slot","worktree","generation","head","operation_id","digest"]) approval[k]=i[k]; console.log("cleanup_approval\t" + JSON.stringify(approval)); for (const p of i.paths_display) console.log("ignored_json\t" + JSON.stringify(p)); }); @@ -822,7 +927,11 @@ require_node() { } manifest_path() { - printf '%s/run-%s.json\n' "$(state_dir)" "$(ws_id)" + if [ -n "${HERDR_SWARM_MANIFEST_PATH:-}" ]; then + printf '%s\n' "$HERDR_SWARM_MANIFEST_PATH" + else + printf '%s/run-%s.json\n' "$(state_dir)" "$(ws_id)" + fi } # manifest_write: full manifest JSON on stdin → $(manifest_path). The order diff --git a/scripts/preflight.sh b/scripts/preflight.sh index 1c8840b..32136fc 100644 --- a/scripts/preflight.sh +++ b/scripts/preflight.sh @@ -302,19 +302,25 @@ remove_exclude_pattern() { return 0 } -# finalize_run : idempotently complete and archive a run -# after every slot is archived. The exact archive name is immutable: retries -# never create PID-suffixed generations or overwrite a prior recovery record. -# Callers hold the repository mutation lock. +exact_run_archive_exists() { + local run_id="$1" arch + arch="$(state_dir)/archived-$run_id.json" + [ -f "$arch" ] && [ ! -L "$arch" ] && node -e ' + const fs=require("fs"); const d=JSON.parse(fs.readFileSync(process.argv[1],"utf8")); + process.exit(d.run_id===process.argv[2] && d.status==="completed" && + (d.slots||[]).every(s=>s.status==="archived"&&!s.journal) ? 0 : 1); + ' "$arch" "$run_id" 2>/dev/null +} + +# finalize_run [require-complete]: partial Harvest uses +# the default benign no-op; Abort passes require-complete and must fail unless +# every slot is archived/journal-free and the exact archive is durable. finalize_run() { - local repo_root="$1" run_id="$2" mf arch doc updated scan rc=0 + local repo_root="$1" run_id="$2" mode="${3-}" mf arch doc updated scan rc=0 mf="$(manifest_path)" || return 1 arch="$(state_dir)/archived-$run_id.json" if [ ! -e "$mf" ]; then - if [ -f "$arch" ] && [ ! -L "$arch" ] && node -e ' - const fs=require("fs"); const d=JSON.parse(fs.readFileSync(process.argv[1],"utf8")); - process.exit(d.run_id===process.argv[2] && d.status==="completed" ? 0 : 1); - ' "$arch" "$run_id" 2>/dev/null; then + if exact_run_archive_exists "$run_id"; then # Retry after the archive rename: finish only idempotent bookkeeping # tails, never rewrite the immutable archive. active_index_remove "$repo_root" "$run_id" 2>/dev/null || true @@ -340,7 +346,13 @@ finalize_run() { ' "$run_id" || rc=$? case "$rc" in 0) ;; - 1) return 0 ;; + 1) + if [ "$mode" = "require-complete" ]; then + echo "herdr-swarm: finalization refused — not every slot is archived and journal-free." >&2 + return 1 + fi + return 0 + ;; *) return "$rc" ;; esac for resource_path in "$(state_dir)"/harvest-"$run_id"-s*; do diff --git a/scripts/safety-state.mjs b/scripts/safety-state.mjs index 2229d09..f0a37ab 100755 --- a/scripts/safety-state.mjs +++ b/scripts/safety-state.mjs @@ -14,8 +14,11 @@ function git(repo, args, encoding = "utf8") { encoding: encoding === null ? undefined : encoding, maxBuffer: 64 * 1024 * 1024, }); + if (result.error) { + throw new Error(`git ${args.join(" ")} could not run for ${repo}: ${result.error.message}`); + } if (result.status !== 0) { - fail( + throw new Error( `git ${args.join(" ")} failed for ${repo}: ${String(result.stderr).trim()}`, ); } @@ -40,7 +43,7 @@ function isSafeId(value) { return typeof value === "string" && /^[A-Za-z0-9_-]+$/.test(value); } -function validateManifest(file, doc) { +function validateManifestShape(doc) { if (!doc || typeof doc !== "object" || Array.isArray(doc)) throw new Error("root must be an object"); if (!isSafeId(doc.run_id)) throw new Error("run_id is missing or unsafe"); @@ -57,16 +60,19 @@ function validateManifest(file, doc) { if (!row || typeof row !== "object" || Array.isArray(row)) throw new Error("slot row must be an object"); const slot = String(row.slot ?? ""); - if (!/^[0-9]+$/.test(slot) || seen.has(slot)) + if (!/^[1-9][0-9]*$/.test(slot) || seen.has(slot)) throw new Error("slot ids must be unique positive integers"); seen.add(slot); if ( typeof row.branch !== "string" || !row.branch.startsWith(`swarm/${doc.run_id}/`) - ) { + ) throw new Error(`slot ${slot} branch is outside the run namespace`); - } } + return doc; +} + +function validateManifestIdentity(doc) { let identity; try { identity = repoIdentity(doc.repo_root); @@ -85,7 +91,7 @@ function validateManifest(file, doc) { if (recorded !== identity.git_common_dir) throw new Error("git_common_dir does not match repo_root"); } - return { file, doc, identity }; + return identity; } function manifestFiles(stateDir) { @@ -101,10 +107,12 @@ function manifestFiles(stateDir) { function scan(stateDir, repo) { const target = repoIdentity(repo); const errors = []; + const quarantined = []; const live = []; const archived = []; const runIds = new Map(); for (const file of manifestFiles(stateDir)) { + const isArchive = path.basename(file).startsWith("archived-"); let stat; try { stat = fs.lstatSync(file); @@ -120,34 +128,53 @@ function scan(stateDir, repo) { errors.push(`${file}: bookkeeping is zero-length`); continue; } - let parsed; + let doc; try { - parsed = validateManifest( - file, - JSON.parse(fs.readFileSync(file, "utf8")), + doc = validateManifestShape(JSON.parse(fs.readFileSync(file, "utf8"))); + } catch (error) { + errors.push( + `${file}: corrupt manifest; previous generation may be in ${file}.bak: ${error.message}`, ); + continue; + } + + // Persisted repository keys let unrelated repositories be discarded + // before touching a historical repo_root that may no longer exist. + if (typeof doc.repo_key === "string" && doc.repo_key !== target.repo_key) + continue; + + let identity; + try { + identity = validateManifestIdentity(doc); } catch (error) { + // A legacy archive has no stable repository key. If its old checkout + // is gone, retain it as quarantined recovery inventory but do not let + // it disable an unrelated repository forever. Live generations and + // keyed records remain fail-closed because they may belong here. + if (isArchive && doc.repo_key == null) { + quarantined.push({ path: file, run_id: doc.run_id, reason: error.message }); + continue; + } errors.push(`${file}: ${error.message}`); continue; } - if (parsed.identity.repo_key !== target.repo_key) continue; - const previous = runIds.get(parsed.doc.run_id); + if (identity.repo_key !== target.repo_key) continue; + const previous = runIds.get(doc.run_id); if (previous) errors.push( - `${file}: duplicate run_id ${parsed.doc.run_id} also appears in ${previous}`, + `${file}: duplicate run_id ${doc.run_id} also appears in ${previous}`, ); - else runIds.set(parsed.doc.run_id, file); + else runIds.set(doc.run_id, file); const item = { path: file, - run_id: parsed.doc.run_id, - workspace_id: path - .basename(file) - .replace(/^run-/, "") - .replace(/\.json$/, ""), - exclude_pattern_added: parsed.doc.exclude_pattern_added === true, + run_id: doc.run_id, + workspace_id: isArchive + ? null + : path.basename(file).replace(/^run-/, "").replace(/\.json$/, ""), + exclude_pattern_added: doc.exclude_pattern_added === true, }; - if (path.basename(file).startsWith("run-")) live.push(item); - else archived.push(item); + if (isArchive) archived.push(item); + else live.push(item); } if (live.length > 1) errors.push( @@ -164,9 +191,8 @@ function scan(stateDir, repo) { active.repo_key !== target.repo_key || !isSafeId(active.run_id) || typeof active.manifest_path !== "string" - ) { + ) throw new Error("identity fields are invalid"); - } const match = live.find( (x) => x.run_id === active.run_id && @@ -178,7 +204,138 @@ function scan(stateDir, repo) { errors.push(`${activePath}: active index ${error.message}`); } } - return { ...target, active_index_path: activePath, live, archived, errors }; + return { + ...target, + active_index_path: activePath, + live, + archived, + quarantined, + errors, + }; +} + +function worktreeRegistrations(repo) { + const raw = git(repo, ["worktree", "list", "--porcelain", "-z"], null); + const rows = []; + let current = null; + for (const field of raw.toString("utf8").split("\0")) { + if (!field) continue; + if (field.startsWith("worktree ")) { + if (current) rows.push(current); + current = { path: field.slice(9), detached: false }; + } else if (current && field.startsWith("HEAD ")) current.head = field.slice(5); + else if (current && field === "detached") current.detached = true; + else if (current && field.startsWith("branch ")) current.branch = field.slice(7); + } + if (current) rows.push(current); + return rows; +} + +function parseHarvestJournal(raw) { + let journal; + try { + journal = JSON.parse(raw); + } catch { + throw new Error("harvest journal is not valid JSON"); + } + const resource = journal?.resource; + if (journal?.locus !== "detached" || !resource || resource.type !== "harvest") + throw new Error("journal does not describe a detached harvest resource"); + return { journal, resource }; +} + +function harvestBinding(stateDir, repo, runId, slot, worktree, journalRaw, manifestFile) { + if (!isSafeId(runId) || !/^[1-9][0-9]*$/.test(slot)) + throw new Error("harvest resource run/slot binding is invalid"); + const { journal, resource } = parseHarvestJournal(journalRaw); + const identity = repoIdentity(repo); + for (const [key, expected] of [ + ["repo_key", identity.repo_key], + ["git_common_dir", identity.git_common_dir], + ["run_id", runId], + ["slot", slot], + ]) { + if (String(resource[key] ?? "") !== String(expected)) + throw new Error(`harvest resource ${key} mismatch`); + } + if (!isSafeId(resource.generation)) + throw new Error("harvest resource generation is invalid"); + if (!/^[0-9a-f]{40,64}$/.test(String(resource.head ?? ""))) + throw new Error("harvest resource HEAD is invalid"); + if (journal.worktree !== worktree || resource.path !== worktree) + throw new Error("harvest resource path does not exactly match its journal"); + const expected = path.join( + fs.realpathSync(stateDir), + `harvest-${runId}-s${slot}-${resource.generation}`, + ); + const stat = fs.lstatSync(worktree); + if (stat.isSymbolicLink() || !stat.isDirectory()) + throw new Error("harvest resource must be a real directory, not a symlink"); + const physical = fs.realpathSync(worktree); + if (physical !== expected) + throw new Error(`harvest resource path is not the exact generation path ${expected}`); + const worktreeIdentity = repoIdentity(physical); + if ( + worktreeIdentity.repo_key !== identity.repo_key || + worktreeIdentity.git_common_dir !== identity.git_common_dir + ) + throw new Error("harvest resource belongs to a different repository"); + const head = git(physical, ["rev-parse", "--verify", "HEAD^{commit}"]).trim(); + if (head !== resource.head) throw new Error("harvest resource HEAD changed"); + const matches = worktreeRegistrations(repo).filter((row) => { + try { + return fs.realpathSync(row.path) === physical; + } catch { + return false; + } + }); + if (matches.length !== 1) + throw new Error("harvest resource registration is missing or duplicated"); + if (!matches[0].detached || matches[0].branch) + throw new Error("harvest resource registration is not detached"); + if (matches[0].head !== resource.head) + throw new Error("harvest resource registration HEAD mismatch"); + + const manifestStat = fs.lstatSync(manifestFile); + if (manifestStat.isSymbolicLink() || !manifestStat.isFile()) + throw new Error("live manifest is not a regular non-symlink file"); + const manifest = validateManifestShape( + JSON.parse(fs.readFileSync(manifestFile, "utf8")), + ); + if (manifest.run_id !== runId) + throw new Error("live manifest run does not match harvest resource"); + const owner = manifest.slots.find((row) => String(row.slot) === slot); + if (!owner || JSON.stringify(owner.journal ?? null) !== JSON.stringify(journal)) + throw new Error("harvest journal changed or is not owned by the exact slot"); + const duplicates = manifest.slots.filter((row) => { + const other = row.journal?.resource; + return ( + other?.type === "harvest" && + (other.path === resource.path || other.generation === resource.generation) + ); + }); + if (duplicates.length !== 1) + throw new Error("harvest resource has duplicate manifest ownership"); + return { + resource_type: "harvest", + repo_key: identity.repo_key, + git_common_dir: identity.git_common_dir, + run_id: runId, + slot, + worktree: physical, + generation: resource.generation, + head: resource.head, + }; +} + +function verifyHarvestRemoved(repo, binding) { + if (fs.existsSync(binding.worktree)) + throw new Error("harvest resource path still exists after removal"); + const registered = worktreeRegistrations(repo).some( + (row) => path.resolve(row.path) === path.resolve(binding.worktree), + ); + if (registered) + throw new Error("harvest resource registration still exists after removal"); } function canonicalInventory(repo, binding) { @@ -193,22 +350,24 @@ function canonicalInventory(repo, binding) { if (output[i] !== 0) continue; const item = output.subarray(start, i); start = i + 1; - if (item.length === 0 || item.equals(Buffer.from(".swarm-task.md"))) - continue; + if (item.length === 0 || item.equals(Buffer.from(".swarm-task.md"))) continue; paths.push(Buffer.from(item)); } paths.sort(Buffer.compare); const hash = createHash("sha256"); - hash.update("herdr-swarm-cleanup-v1\0"); + hash.update("herdr-swarm-cleanup-v2\0"); for (const value of [ + binding.resource_type, binding.repo_key, binding.git_common_dir, binding.run_id, binding.slot, binding.worktree, + binding.generation, + binding.head, binding.operation_id, ]) { - const bytes = Buffer.from(String(value)); + const bytes = Buffer.from(String(value ?? "")); const length = Buffer.alloc(8); length.writeBigUInt64BE(BigInt(bytes.length)); hash.update(length).update(bytes); @@ -241,24 +400,31 @@ try { `cleanup-${Date.now().toString(36)}-${randomBytes(8).toString("hex")}\n`, ); break; + case "verify-harvest": { + const [stateDir, repo, runId, slot, worktree, journalRaw, manifestFile] = args; + process.stdout.write( + `${JSON.stringify(harvestBinding(stateDir, repo, runId, slot, worktree, journalRaw, manifestFile))}\n`, + ); + break; + } + case "verify-harvest-removed": { + const [repo, bindingRaw] = args; + verifyHarvestRemoved(repo, JSON.parse(bindingRaw)); + break; + } case "inventory": { - const [repo, repoKey, common, runId, slot, worktree, operationId] = args; - if (!isSafeId(runId) || !isSafeId(operationId) || !/^[0-9]+$/.test(slot)) - fail("cleanup inventory binding is invalid"); + const [repo, bindingRaw, operationId] = args; + if (!isSafeId(operationId)) fail("cleanup inventory operation_id is invalid"); + const binding = JSON.parse(bindingRaw); const identity = repoIdentity(repo); - if (identity.repo_key !== repoKey || identity.git_common_dir !== common) + if ( + identity.repo_key !== binding.repo_key || + identity.git_common_dir !== binding.git_common_dir + ) fail("cleanup inventory repo identity changed"); - const physical = fs.realpathSync(worktree); - const binding = { - repo_key: repoKey, - git_common_dir: common, - run_id: runId, - slot, - worktree: physical, - operation_id: operationId, - }; + const physical = fs.realpathSync(binding.worktree); process.stdout.write( - `${JSON.stringify(canonicalInventory(physical, binding))}\n`, + `${JSON.stringify(canonicalInventory(physical, { ...binding, worktree: physical, operation_id: operationId }))}\n`, ); break; } @@ -269,19 +435,21 @@ try { const inventory = JSON.parse(inventoryRaw); const approval = JSON.parse(raw); for (const key of [ + "resource_type", "repo_key", "git_common_dir", "run_id", "slot", "worktree", + "generation", + "head", "operation_id", "digest", ]) { if (String(approval[key] ?? "") !== String(inventory[key] ?? "")) fail(`cleanup approval ${key} mismatch`, 2); } - if (approval.approved !== true) - fail("cleanup approval is not approved", 2); + if (approval.approved !== true) fail("cleanup approval is not approved", 2); if (!isSafeId(approval.operation_id)) fail("cleanup approval operation_id is unsafe", 2); const used = path.join( @@ -297,9 +465,18 @@ try { for await (const chunk of process.stdin) raw += chunk; const [used] = args; const fd = fs.openSync(used, "wx", 0o600); - fs.writeFileSync(fd, raw); - fs.fsyncSync(fd); - fs.closeSync(fd); + try { + fs.writeFileSync(fd, raw); + fs.fsyncSync(fd); + } finally { + fs.closeSync(fd); + } + const parent = fs.openSync(path.dirname(used), "r"); + try { + fs.fsyncSync(parent); + } finally { + fs.closeSync(parent); + } process.stdout.write(`${used}\n`); break; } diff --git a/scripts/status-pane.sh b/scripts/status-pane.sh index c92ae37..e574fe9 100755 --- a/scripts/status-pane.sh +++ b/scripts/status-pane.sh @@ -6,6 +6,7 @@ # which reads as a crash. set -uo pipefail +INVOCATION_CWD="$PWD" cd "${HERDR_PLUGIN_ROOT:-$(dirname "$0")/..}" || { # lib.sh (and pane_fatal with it) is unreachable without the plugin root, # so this one failure lingers inline. @@ -17,9 +18,22 @@ cd "${HERDR_PLUGIN_ROOT:-$(dirname "$0")/..}" || { pane_require_node "status pane" +repo_hint="" +if [ -n "${HERDR_PLUGIN_CONTEXT_JSON:-}" ] || [ -f "$(state_dir)/run-$(ws_id).json" ]; then + repo_hint="$(discover_live_repo 2>/dev/null || true)" +else + repo_hint="$(git -C "$INVOCATION_CWD" rev-parse --show-toplevel 2>/dev/null || true)" +fi +[ -n "$repo_hint" ] || pane_fatal "herdr-swarm: cannot resolve this workspace repository." +SWARM_REPO="$repo_hint" +export SWARM_REPO +lock="$(repo_mutation_lock_name "$repo_hint")" || pane_fatal "herdr-swarm: cannot resolve repository identity." +acquire_lock "$lock" || pane_fatal "herdr-swarm: repository is busy; reopen Status." +rc=0 +bind_live_manifest_locked "$repo_hint" || rc=$? +release_lock "$lock" +[ "$rc" -eq 0 ] || pane_fatal "herdr-swarm: no single validated active run exists for this repository (resolution exit $rc)." mf="$(manifest_path)" -[ -f "$mf" ] || - pane_fatal "herdr-swarm: no active swarm run for this workspace (no manifest at $mf). Run the fan-out action first." # repo_root for the renderer's branch-existence checks. A corrupt manifest is # NOT fatal here: the renderer treats corrupt as a first-class display state diff --git a/tests/check-manifest.test.mjs b/tests/check-manifest.test.mjs index f649e63..c0b0c0e 100644 --- a/tests/check-manifest.test.mjs +++ b/tests/check-manifest.test.mjs @@ -1,4 +1,5 @@ import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; @@ -35,6 +36,21 @@ test("repository manifest passes CI validation", () => { assert.equal(result.entrypointCount, 8); }); +test("manifest validation needs only Node and works with no Python on PATH", () => { + const result = spawnSync( + process.execPath, + [path.join(root, "scripts/check-manifest.mjs")], + { env: { ...process.env, PATH: "" }, encoding: "utf8" }, + ); + assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`); + assert.match(result.stdout, /Manifest valid/); + const source = fs.readFileSync( + path.join(root, "scripts/check-manifest.mjs"), + "utf8", + ); + assert.doesNotMatch(source, /python3|tomllib|node:child_process/); +}); + test("manifest validation reports malformed TOML", (t) => { const { repository } = fixture(t); fs.writeFileSync(path.join(repository, "herdr-plugin.toml"), 'version = "'); diff --git a/tests/harvest.test.mjs b/tests/harvest.test.mjs index 16b1cec..ff2e959 100644 --- a/tests/harvest.test.mjs +++ b/tests/harvest.test.mjs @@ -580,6 +580,76 @@ test("kill between merge commit and swap: resume offers completion when base is assert.equal(run.slotRow(1).journal, null); }); +test("swap and resume use exact ignored preview/apply before removing a harvest generation", () => { + const run = mkRun(); + commitIn(run.wt(1), "feat.txt"); + h.git(run.repo, "checkout", "-q", "-b", "elsewhere"); + let result = step(run, "merge", [1, run.fork], { + HERDR_SWARM_TEST_DIE_BEFORE_SWAP: "1", + }); + assert.equal(result.status, 99); + const hwt = run.slotRow(1).journal.worktree; + fs.appendFileSync(path.join(run.repo, ".git/info/exclude"), "hook-output.log\n"); + fs.writeFileSync(path.join(hwt, "hook-output.log"), "ignored hook artifact\n"); + + result = step(run, "resume", ["complete", 1]); + assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`); + assert.ok(fs.existsSync(hwt), "ignored data keeps the generation after swap"); + assert.ok(run.slotRow(1).journal, "journal remains until verified removal"); + const approval = cleanupApproval(result.stdout); + + result = step(run, "resume", [], { + HERDR_SWARM_CLEANUP_APPROVAL: approval, + }); + assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`); + assert.equal(fs.existsSync(hwt), false); + assert.equal(run.slotRow(1).journal, null); + const used = fs + .readdirSync(run.sdir) + .filter((name) => name.startsWith("cleanup-used-") && name.endsWith(".json")); + assert.equal(used.length, 1, "approval was durably consumed exactly once"); +}); + +test("harvest removal refuses every exact identity mismatch", () => { + for (const field of [ + "repo_key", + "run_id", + "slot", + "path", + "generation", + "head", + "registration", + "symlink", + ]) { + const run = mkRun(); + commitIn(run.wt(1), "feat.txt"); + h.git(run.repo, "checkout", "-q", "-b", "elsewhere"); + let result = step(run, "merge", [1, run.fork], { + HERDR_SWARM_TEST_DIE_BEFORE_SWAP: "1", + }); + assert.equal(result.status, 99); + const row = run.slotRow(1); + const journal = structuredClone(row.journal); + const hwt = journal.worktree; + h.git(run.repo, "update-ref", "refs/heads/main", journal.merge_commit_sha, run.fork); + if (field === "registration") { + h.git(hwt, "checkout", "-q", "-b", `foreign-${run.runId}`); + } else if (field === "symlink") { + const actual = `${hwt}-actual`; + fs.renameSync(hwt, actual); + fs.symlinkSync(actual, hwt, "dir"); + } else { + journal.resource[field] = + field === "slot" ? "999" : `${journal.resource[field]}-wrong`; + patchSlot(run, 1, { journal }); + } + result = step(run, "abort-merge", [1]); + assert.equal(result.status, EC.REFUSED, `${field}: ${result.stdout}\n${result.stderr}`); + assert.match(result.stderr, /identity|resource|symlink|registration/i, field); + assert.ok(fs.existsSync(hwt), `${field}: zero removal`); + } +}); + test("kill between merge commit and swap: base moved -> dangling SHA reported loudly, worktree never deleted", () => { const run = mkRun(); commitIn(run.wt(1), "feat.txt"); @@ -782,6 +852,77 @@ function linkedBaseCheckout(run) { return dir; } +function foreignHarvestWorktree(run, slot = 1, sha = run.fork) { + const dir = path.join(run.sdir, `harvest-${run.runId}-s${slot}`); + h.git(run.repo, "worktree", "add", "-q", "--detach", dir, sha); + fs.appendFileSync(path.join(run.repo, ".git/info/exclude"), "precious.secret\n"); + fs.writeFileSync(path.join(dir, "precious.secret"), "foreign ignored data\n"); + return dir; +} + +test("abort-merge refuses a same-prefix foreign detached worktree and its ignored data", () => { + const run = mkRun(); + const foreign = foreignHarvestWorktree(run); + patchSlot(run, 1, { + journal: { + locus: "detached", + expected_base_sha: run.fork, + merge_commit_sha: null, + worktree: foreign, + }, + }); + const result = step(run, "abort-merge", [1]); + assert.equal(result.status, EC.REFUSED, `${result.stdout}\n${result.stderr}`); + assert.match(result.stderr, /resource identity failed/); + assert.equal(fs.readFileSync(path.join(foreign, "precious.secret"), "utf8"), "foreign ignored data\n"); + assert.match(h.git(run.repo, "worktree", "list").stdout, new RegExp(foreign)); +}); + +test("resume scan refuses same-prefix foreign harvest cleanup after the base landed", () => { + const run = mkRun(); + const msha = bareCommitOn(run.repo, run.fork, "landed merge"); + h.git(run.repo, "update-ref", "refs/heads/main", msha, run.fork); + const foreign = foreignHarvestWorktree(run, 1, msha); + patchSlot(run, 1, { + journal: { + locus: "detached", + expected_base_sha: run.fork, + merge_commit_sha: msha, + worktree: foreign, + }, + }); + const result = step(run, "resume"); + assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`); + assert.match(result.stderr, /cleanup was refused/); + assert.ok(fs.existsSync(path.join(foreign, "precious.secret"))); + assert.ok(run.slotRow(1).journal, "failed cleanup retains exact recovery journal"); +}); + +test("Abort journal cleanup and leftover sweep both quarantine same-prefix foreign worktrees", () => { + for (const journaled of [true, false]) { + const run = mkRun(); + const foreign = foreignHarvestWorktree(run, journaled ? 1 : 999); + if (journaled) { + patchSlot(run, 1, { + journal: { + locus: "detached", + expected_base_sha: run.fork, + merge_commit_sha: null, + worktree: foreign, + }, + }); + } + const result = spawnSync( + "bash", + [path.join(repoRoot, "scripts/abort.sh")], + { cwd: run.repo, env: run.env, encoding: "utf8" }, + ); + assert.equal(result.status, 4, `${result.stdout}\n${result.stderr}`); + assert.ok(fs.existsSync(path.join(foreign, "precious.secret"))); + assert.match(result.stderr, journaled ? /identity FAILED/ : /no exact live resource journal/); + } +}); + test("resume scan never removes a user-tree-locus journal's worktree — that is the USER's checkout", () => { const run = mkRun(); const userWt = linkedBaseCheckout(run); @@ -845,7 +986,7 @@ test("resume complete never hands the user's checkout to swap_base's removal tai ); }); -test("swap_base refuses any journaled worktree outside the plugin's harvest- namespace", () => { +test("swap_base refuses a journal lacking exact harvest resource identity before moving base", () => { const run = mkRun(); const userWt = linkedBaseCheckout(run); const msha = bareCommitOn(run.repo, run.fork); @@ -862,8 +1003,13 @@ test("swap_base refuses any journaled worktree outside the plugin's harvest- nam }); h.git(userWt, "checkout", "-q", "-b", "sidework"); const r = step(run, "resume", ["complete", 1]); - assert.equal(r.status, 0, `${r.stdout}\n${r.stderr}`); - assert.match(r.stderr, /refusing to remove/, "the refusal is reported"); + assert.equal(r.status, EC.REFUSED, `${r.stdout}\n${r.stderr}`); + assert.match(r.stderr, /identity failed before base swap/, "the refusal is reported"); + assert.equal( + h.git(run.repo, "rev-parse", "refs/heads/main").stdout.trim(), + run.fork, + "forged resource cannot move the base", + ); assert.ok(fs.existsSync(userWt), "foreign worktree kept"); }); diff --git a/tests/lib.test.mjs b/tests/lib.test.mjs index 062f7d9..55bcc62 100644 --- a/tests/lib.test.mjs +++ b/tests/lib.test.mjs @@ -554,6 +554,29 @@ test("every script that mutates a manifest-named slot worktree calls verify_slot } }); +test("every harvest-worktree removal route uses the shared exact verifier/remover", () => { + const harvest = fs.readFileSync( + path.join(repoRoot, "scripts/harvest-step.sh"), + "utf8", + ); + const abort = fs.readFileSync(path.join(repoRoot, "scripts/abort.sh"), "utf8"); + const lib = fs.readFileSync(path.join(repoRoot, "scripts/lib.sh"), "utf8"); + assert.doesNotMatch(harvest, /worktree remove "\$(?:hwt|wt|jwt|d)"/); + assert.doesNotMatch(abort, /worktree remove "\$(?:hwt|jwt|d)"/); + assert.ok( + (harvest.match(/remove_harvest_resource/g) || []).length >= 3, + "swap, resume, and abort-merge share the remover", + ); + assert.match(abort, /remove_harvest_resource/); + assert.match(abort, /verify_harvest_resource[^\n]+"null"/); + assert.equal( + (lib.match(/git -C "\$SWARM_REPO" worktree remove "\$wt"/g) || []).length, + 1, + "one audited harvest git-removal call exists", + ); + assert.match(lib, /verify-harvest-removed/); +}); + // The pane titles in herdr-plugin.toml ARE the cleanup sweep labels (pane // list reports them as "label"), and preflight.sh re-declares them as a // hardcoded set for abort's corrupt-manifest sweep. A rename on one side diff --git a/tests/renderer.test.mjs b/tests/renderer.test.mjs index c882bf2..0e47e1c 100644 --- a/tests/renderer.test.mjs +++ b/tests/renderer.test.mjs @@ -584,7 +584,7 @@ test("status-pane.sh lingers with a friendly message instead of flash-closing", timeout: 1500, }, ); - assert.match(r.stdout, /no active swarm run/); + assert.match(r.stdout, /no single validated active run|no active swarm run/); assert.equal(r.signal, "SIGTERM", "still lingering when the timeout hit"); }); @@ -592,8 +592,13 @@ test("status-pane.sh execs the renderer with the resolved context (end to end)", h.writeHerdrStub(); fs.rmSync(path.join(h.stubDir, "git"), { force: true }); const sdir = fs.mkdtempSync(path.join(os.tmpdir(), "hs-e2e-")); + const repo = h.makeRepo(); + const fork = h.git(repo, "rev-parse", "HEAD").stdout.trim(); const env = h.freshEnv({ HERDR_PLUGIN_STATE_DIR: sdir }); - fs.writeFileSync(path.join(sdir, "run-w9.json"), sampleManifest()); + fs.writeFileSync( + path.join(sdir, "run-w9.json"), + sampleManifest({ repo_root: repo, fork_sha: fork }), + ); const r = spawnSync( "bash", [path.join(repoRoot, "scripts", "status-pane.sh")], diff --git a/tests/run-finalization.test.mjs b/tests/run-finalization.test.mjs index db9e9b6..28a5c95 100644 --- a/tests/run-finalization.test.mjs +++ b/tests/run-finalization.test.mjs @@ -148,6 +148,71 @@ test("abort ignored cleanup is preview/apply and a generic legacy acknowledgment assert.ok(fs.existsSync(path.join(run.sdir, `archived-${run.runId}.json`))); }); +test("Abort exits nonzero when post-removal or already-gone slot bookkeeping cannot persist", () => { + for (const alreadyGone of [false, true]) { + const run = mkRun(); + if (alreadyGone) { + fs.rmSync(run.wt(1), { recursive: true, force: true }); + h.git(run.repo, "worktree", "prune"); + } + const backup = path.join(run.sdir, "run-w9.json.bak"); + fs.rmSync(backup, { force: true }); + fs.symlinkSync(path.join(run.sdir, "missing-parent", "backup"), backup); + const result = abort(run); + assert.notEqual(result.status, 0, `${result.stdout}\n${result.stderr}`); + assert.match(result.stderr, /bookkeeping failure/); + assert.equal(fs.existsSync(run.wt(1)), false); + assert.ok(fs.existsSync(path.join(run.sdir, "run-w9.json"))); + assert.equal( + fs.existsSync(path.join(run.sdir, `archived-${run.runId}.json`)), + false, + ); + } +}); + +test("Abort removes a landed exact harvest generation and archives every slot", () => { + const run = mkRun(); + fs.writeFileSync(path.join(run.wt(1), "feature.txt"), "work\n"); + h.git(run.wt(1), "add", "feature.txt"); + h.git(run.wt(1), "commit", "-q", "-m", "feature"); + h.git(run.repo, "checkout", "-q", "-b", "elsewhere"); + let result = step(run, "merge", [1, run.fork], { + HERDR_SWARM_TEST_DIE_BEFORE_SWAP: "1", + }); + assert.equal(result.status, 99, `${result.stdout}\n${result.stderr}`); + const journal = run.slotRow(1).journal; + h.git(run.repo, "update-ref", "refs/heads/main", journal.merge_commit_sha, run.fork); + result = abort(run); + assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`); + assert.equal(fs.existsSync(journal.worktree), false); + assert.ok(run.archived().slots.every((slot) => slot.status === "archived")); +}); + +test("Abort exits nonzero when a removed exact harvest generation cannot clear its journal", () => { + const run = mkRun(); + fs.writeFileSync(path.join(run.wt(1), "feature.txt"), "work\n"); + h.git(run.wt(1), "add", "feature.txt"); + h.git(run.wt(1), "commit", "-q", "-m", "feature"); + h.git(run.repo, "checkout", "-q", "-b", "elsewhere"); + let result = step(run, "merge", [1, run.fork], { + HERDR_SWARM_TEST_DIE_BEFORE_SWAP: "1", + }); + assert.equal(result.status, 99, `${result.stdout}\n${result.stderr}`); + const journal = run.slotRow(1).journal; + h.git(run.repo, "update-ref", "refs/heads/main", journal.merge_commit_sha, run.fork); + const backup = path.join(run.sdir, "run-w9.json.bak"); + fs.rmSync(backup, { force: true }); + fs.symlinkSync(path.join(run.sdir, "missing-parent", "backup"), backup); + + result = abort(run); + assert.notEqual(result.status, 0, `${result.stdout}\n${result.stderr}`); + assert.equal(fs.existsSync(run.wt(1)), false, "slot removal completed"); + assert.equal(fs.existsSync(journal.worktree), false, "harvest removal completed"); + assert.match(result.stderr, /journal update failed|bookkeeping failure/); + assert.ok(fs.existsSync(path.join(run.sdir, "run-w9.json"))); + assert.equal(fs.existsSync(path.join(run.sdir, `archived-${run.runId}.json`)), false); +}); + test("full harvest archives exactly once, removes the live pointer/exclude, and retry is idempotent", () => { const run = mkRun({ slots: 2, status: "skipped" }); let result = step(run, "archive", [1]); @@ -195,6 +260,34 @@ test("full harvest archives exactly once, removes the live pointer/exclude, and ); }); +test("Harvest, Status, and Abort resolve the exact live run from a second workspace alias", () => { + const run = mkRun(); + const aliasEnv = { ...run.env, HERDR_WORKSPACE_ID: "w2" }; + const preview = spawnSync( + "bash", + [path.join(repoRoot, "scripts/harvest-step.sh"), "preview", "1"], + { cwd: run.repo, env: aliasEnv, encoding: "utf8" }, + ); + assert.equal(preview.status, 0, `${preview.stdout}\n${preview.stderr}`); + assert.match(preview.stdout, /^slot\t1$/m); + + const status = spawnSync( + "bash", + [path.join(repoRoot, "scripts/status-pane.sh")], + { cwd: run.repo, env: aliasEnv, encoding: "utf8", timeout: 1800 }, + ); + assert.match(status.stdout, new RegExp(`run:${run.runId}`)); + + const result = spawnSync("bash", [path.join(repoRoot, "scripts/abort.sh")], { + cwd: run.repo, + env: aliasEnv, + encoding: "utf8", + }); + assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`); + assert.equal(fs.existsSync(path.join(run.sdir, "run-w9.json")), false); + assert.ok(fs.existsSync(path.join(run.sdir, `archived-${run.runId}.json`))); +}); + test("repository identity makes workspace aliases share one lock and discover the same active run", () => { const run = mkRun(); const source = path.join(run.sdir, "run-w9.json"); @@ -223,6 +316,30 @@ test("repository identity makes workspace aliases share one lock and discover th assert.match(active.stderr, /another workspace/); }); +test("a stale foreign legacy archive is quarantined without bricking this repository", () => { + const run = mkRun(); + const stale = { + run_id: "stale-foreign", + repo_root: "/a/repository/that/no/longer/exists", + base_ref: "refs/heads/main", + fork_sha: "a".repeat(40), + created_at: "2026-01-01T00:00:00Z", + exclude_pattern_added: false, + slots: [{ slot: 1, branch: "swarm/stale-foreign/s1", status: "archived" }], + }; + fs.writeFileSync( + path.join(run.sdir, "archived-stale-foreign.json"), + JSON.stringify(stale), + ); + const scan = h.runLib(`bookkeeping_scan ${JSON.stringify(run.repo)}`, run.env); + assert.equal(scan.status, 0, scan.stderr); + const parsed = JSON.parse(scan.stdout); + assert.deepEqual(parsed.errors, []); + assert.equal(parsed.quarantined.length, 1); + assert.match(parsed.quarantined[0].reason, /cannot be resolved/); + assert.equal(parsed.live[0].run_id, run.runId); +}); + test("corrupt or symlinked archived bookkeeping refuses all prune deletion", () => { for (const kind of ["corrupt", "symlink"]) { const run = mkRun({ status: "archived" }); From 3e87e1e4938f7437f1966c9e015a17a75b18b0ff Mon Sep 17 00:00:00 2001 From: Victor Garcia Date: Tue, 28 Jul 2026 03:38:11 -0600 Subject: [PATCH 7/9] [pi] Implemented, committed, and pushed all review fixe... --- scripts/safety-state.mjs | 56 ++++++++++++++++----- tests/harvest.test.mjs | 60 ++++++++++++++++++----- tests/lib.test.mjs | 78 +++++++++++++++++++++++------ tests/renderer.test.mjs | 87 ++++++++++++++++++++++++++------- tests/run-finalization.test.mjs | 32 ++++++++++-- 5 files changed, 252 insertions(+), 61 deletions(-) diff --git a/scripts/safety-state.mjs b/scripts/safety-state.mjs index f0a37ab..4b83752 100755 --- a/scripts/safety-state.mjs +++ b/scripts/safety-state.mjs @@ -15,7 +15,9 @@ function git(repo, args, encoding = "utf8") { maxBuffer: 64 * 1024 * 1024, }); if (result.error) { - throw new Error(`git ${args.join(" ")} could not run for ${repo}: ${result.error.message}`); + throw new Error( + `git ${args.join(" ")} could not run for ${repo}: ${result.error.message}`, + ); } if (result.status !== 0) { throw new Error( @@ -152,7 +154,11 @@ function scan(stateDir, repo) { // it disable an unrelated repository forever. Live generations and // keyed records remain fail-closed because they may belong here. if (isArchive && doc.repo_key == null) { - quarantined.push({ path: file, run_id: doc.run_id, reason: error.message }); + quarantined.push({ + path: file, + run_id: doc.run_id, + reason: error.message, + }); continue; } errors.push(`${file}: ${error.message}`); @@ -170,7 +176,10 @@ function scan(stateDir, repo) { run_id: doc.run_id, workspace_id: isArchive ? null - : path.basename(file).replace(/^run-/, "").replace(/\.json$/, ""), + : path + .basename(file) + .replace(/^run-/, "") + .replace(/\.json$/, ""), exclude_pattern_added: doc.exclude_pattern_added === true, }; if (isArchive) archived.push(item); @@ -223,9 +232,11 @@ function worktreeRegistrations(repo) { if (field.startsWith("worktree ")) { if (current) rows.push(current); current = { path: field.slice(9), detached: false }; - } else if (current && field.startsWith("HEAD ")) current.head = field.slice(5); + } else if (current && field.startsWith("HEAD ")) + current.head = field.slice(5); else if (current && field === "detached") current.detached = true; - else if (current && field.startsWith("branch ")) current.branch = field.slice(7); + else if (current && field.startsWith("branch ")) + current.branch = field.slice(7); } if (current) rows.push(current); return rows; @@ -244,7 +255,15 @@ function parseHarvestJournal(raw) { return { journal, resource }; } -function harvestBinding(stateDir, repo, runId, slot, worktree, journalRaw, manifestFile) { +function harvestBinding( + stateDir, + repo, + runId, + slot, + worktree, + journalRaw, + manifestFile, +) { if (!isSafeId(runId) || !/^[1-9][0-9]*$/.test(slot)) throw new Error("harvest resource run/slot binding is invalid"); const { journal, resource } = parseHarvestJournal(journalRaw); @@ -273,7 +292,9 @@ function harvestBinding(stateDir, repo, runId, slot, worktree, journalRaw, manif throw new Error("harvest resource must be a real directory, not a symlink"); const physical = fs.realpathSync(worktree); if (physical !== expected) - throw new Error(`harvest resource path is not the exact generation path ${expected}`); + throw new Error( + `harvest resource path is not the exact generation path ${expected}`, + ); const worktreeIdentity = repoIdentity(physical); if ( worktreeIdentity.repo_key !== identity.repo_key || @@ -305,8 +326,13 @@ function harvestBinding(stateDir, repo, runId, slot, worktree, journalRaw, manif if (manifest.run_id !== runId) throw new Error("live manifest run does not match harvest resource"); const owner = manifest.slots.find((row) => String(row.slot) === slot); - if (!owner || JSON.stringify(owner.journal ?? null) !== JSON.stringify(journal)) - throw new Error("harvest journal changed or is not owned by the exact slot"); + if ( + !owner || + JSON.stringify(owner.journal ?? null) !== JSON.stringify(journal) + ) + throw new Error( + "harvest journal changed or is not owned by the exact slot", + ); const duplicates = manifest.slots.filter((row) => { const other = row.journal?.resource; return ( @@ -350,7 +376,8 @@ function canonicalInventory(repo, binding) { if (output[i] !== 0) continue; const item = output.subarray(start, i); start = i + 1; - if (item.length === 0 || item.equals(Buffer.from(".swarm-task.md"))) continue; + if (item.length === 0 || item.equals(Buffer.from(".swarm-task.md"))) + continue; paths.push(Buffer.from(item)); } paths.sort(Buffer.compare); @@ -401,7 +428,8 @@ try { ); break; case "verify-harvest": { - const [stateDir, repo, runId, slot, worktree, journalRaw, manifestFile] = args; + const [stateDir, repo, runId, slot, worktree, journalRaw, manifestFile] = + args; process.stdout.write( `${JSON.stringify(harvestBinding(stateDir, repo, runId, slot, worktree, journalRaw, manifestFile))}\n`, ); @@ -414,7 +442,8 @@ try { } case "inventory": { const [repo, bindingRaw, operationId] = args; - if (!isSafeId(operationId)) fail("cleanup inventory operation_id is invalid"); + if (!isSafeId(operationId)) + fail("cleanup inventory operation_id is invalid"); const binding = JSON.parse(bindingRaw); const identity = repoIdentity(repo); if ( @@ -449,7 +478,8 @@ try { if (String(approval[key] ?? "") !== String(inventory[key] ?? "")) fail(`cleanup approval ${key} mismatch`, 2); } - if (approval.approved !== true) fail("cleanup approval is not approved", 2); + if (approval.approved !== true) + fail("cleanup approval is not approved", 2); if (!isSafeId(approval.operation_id)) fail("cleanup approval operation_id is unsafe", 2); const used = path.join( diff --git a/tests/harvest.test.mjs b/tests/harvest.test.mjs index ff2e959..989060a 100644 --- a/tests/harvest.test.mjs +++ b/tests/harvest.test.mjs @@ -589,8 +589,14 @@ test("swap and resume use exact ignored preview/apply before removing a harvest }); assert.equal(result.status, 99); const hwt = run.slotRow(1).journal.worktree; - fs.appendFileSync(path.join(run.repo, ".git/info/exclude"), "hook-output.log\n"); - fs.writeFileSync(path.join(hwt, "hook-output.log"), "ignored hook artifact\n"); + fs.appendFileSync( + path.join(run.repo, ".git/info/exclude"), + "hook-output.log\n", + ); + fs.writeFileSync( + path.join(hwt, "hook-output.log"), + "ignored hook artifact\n", + ); result = step(run, "resume", ["complete", 1]); assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`); @@ -606,7 +612,9 @@ test("swap and resume use exact ignored preview/apply before removing a harvest assert.equal(run.slotRow(1).journal, null); const used = fs .readdirSync(run.sdir) - .filter((name) => name.startsWith("cleanup-used-") && name.endsWith(".json")); + .filter( + (name) => name.startsWith("cleanup-used-") && name.endsWith(".json"), + ); assert.equal(used.length, 1, "approval was durably consumed exactly once"); }); @@ -631,7 +639,13 @@ test("harvest removal refuses every exact identity mismatch", () => { const row = run.slotRow(1); const journal = structuredClone(row.journal); const hwt = journal.worktree; - h.git(run.repo, "update-ref", "refs/heads/main", journal.merge_commit_sha, run.fork); + h.git( + run.repo, + "update-ref", + "refs/heads/main", + journal.merge_commit_sha, + run.fork, + ); if (field === "registration") { h.git(hwt, "checkout", "-q", "-b", `foreign-${run.runId}`); } else if (field === "symlink") { @@ -644,8 +658,16 @@ test("harvest removal refuses every exact identity mismatch", () => { patchSlot(run, 1, { journal }); } result = step(run, "abort-merge", [1]); - assert.equal(result.status, EC.REFUSED, `${field}: ${result.stdout}\n${result.stderr}`); - assert.match(result.stderr, /identity|resource|symlink|registration/i, field); + assert.equal( + result.status, + EC.REFUSED, + `${field}: ${result.stdout}\n${result.stderr}`, + ); + assert.match( + result.stderr, + /identity|resource|symlink|registration/i, + field, + ); assert.ok(fs.existsSync(hwt), `${field}: zero removal`); } }); @@ -855,7 +877,10 @@ function linkedBaseCheckout(run) { function foreignHarvestWorktree(run, slot = 1, sha = run.fork) { const dir = path.join(run.sdir, `harvest-${run.runId}-s${slot}`); h.git(run.repo, "worktree", "add", "-q", "--detach", dir, sha); - fs.appendFileSync(path.join(run.repo, ".git/info/exclude"), "precious.secret\n"); + fs.appendFileSync( + path.join(run.repo, ".git/info/exclude"), + "precious.secret\n", + ); fs.writeFileSync(path.join(dir, "precious.secret"), "foreign ignored data\n"); return dir; } @@ -874,7 +899,10 @@ test("abort-merge refuses a same-prefix foreign detached worktree and its ignore const result = step(run, "abort-merge", [1]); assert.equal(result.status, EC.REFUSED, `${result.stdout}\n${result.stderr}`); assert.match(result.stderr, /resource identity failed/); - assert.equal(fs.readFileSync(path.join(foreign, "precious.secret"), "utf8"), "foreign ignored data\n"); + assert.equal( + fs.readFileSync(path.join(foreign, "precious.secret"), "utf8"), + "foreign ignored data\n", + ); assert.match(h.git(run.repo, "worktree", "list").stdout, new RegExp(foreign)); }); @@ -895,7 +923,10 @@ test("resume scan refuses same-prefix foreign harvest cleanup after the base lan assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`); assert.match(result.stderr, /cleanup was refused/); assert.ok(fs.existsSync(path.join(foreign, "precious.secret"))); - assert.ok(run.slotRow(1).journal, "failed cleanup retains exact recovery journal"); + assert.ok( + run.slotRow(1).journal, + "failed cleanup retains exact recovery journal", + ); }); test("Abort journal cleanup and leftover sweep both quarantine same-prefix foreign worktrees", () => { @@ -919,7 +950,10 @@ test("Abort journal cleanup and leftover sweep both quarantine same-prefix forei ); assert.equal(result.status, 4, `${result.stdout}\n${result.stderr}`); assert.ok(fs.existsSync(path.join(foreign, "precious.secret"))); - assert.match(result.stderr, journaled ? /identity FAILED/ : /no exact live resource journal/); + assert.match( + result.stderr, + journaled ? /identity FAILED/ : /no exact live resource journal/, + ); } }); @@ -1004,7 +1038,11 @@ test("swap_base refuses a journal lacking exact harvest resource identity before h.git(userWt, "checkout", "-q", "-b", "sidework"); const r = step(run, "resume", ["complete", 1]); assert.equal(r.status, EC.REFUSED, `${r.stdout}\n${r.stderr}`); - assert.match(r.stderr, /identity failed before base swap/, "the refusal is reported"); + assert.match( + r.stderr, + /identity failed before base swap/, + "the refusal is reported", + ); assert.equal( h.git(run.repo, "rev-parse", "refs/heads/main").stdout.trim(), run.fork, diff --git a/tests/lib.test.mjs b/tests/lib.test.mjs index 55bcc62..c164c10 100644 --- a/tests/lib.test.mjs +++ b/tests/lib.test.mjs @@ -27,11 +27,20 @@ test("sanitize_slug strips path-dangerous chars to [a-zA-Z0-9_-]", () => { // this function exists to neutralize. const r = spawnSync( "bash", - ["-c", `. "${repoRoot}/scripts/lib.sh" && sanitize_slug "$1"`, "--", input], + [ + "-c", + `. "${repoRoot}/scripts/lib.sh" && sanitize_slug "$1"`, + "--", + input, + ], { env: freshEnv(), encoding: "utf8" }, ); assert.equal(r.status, 0, `${input}: ${r.stderr}`); - assert.equal(r.stdout.trim(), want, `sanitize_slug(${JSON.stringify(input)})`); + assert.equal( + r.stdout.trim(), + want, + `sanitize_slug(${JSON.stringify(input)})`, + ); } }); @@ -50,14 +59,20 @@ test("sanitize_slug refuses input that strips to nothing (no silent default)", ( test("state_dir expands a literal leading ~ instead of creating ./~", () => { const rel = `.cache/hs-tilde-test-${process.pid}`; - const r = runLib("state_dir", freshEnv({ HERDR_PLUGIN_STATE_DIR: `~/${rel}` })); + const r = runLib( + "state_dir", + freshEnv({ HERDR_PLUGIN_STATE_DIR: `~/${rel}` }), + ); assert.equal(r.status, 0, r.stderr); assert.equal(r.stdout.trim(), path.join(os.homedir(), rel)); fs.rmSync(path.join(os.homedir(), rel), { recursive: true, force: true }); }); test("state_dir rejects relative paths and falls back to the default", () => { - const r = runLib("state_dir", freshEnv({ HERDR_PLUGIN_STATE_DIR: "rel/path" })); + const r = runLib( + "state_dir", + freshEnv({ HERDR_PLUGIN_STATE_DIR: "rel/path" }), + ); assert.equal(r.status, 0, r.stderr); assert.equal( r.stdout.trim(), @@ -237,7 +252,10 @@ test("herdr_agent_start drops --split-from on the 0.7.4 path", () => { `herdr_agent_start slot1 --split-from w9:p1 --cwd /tmp/wt/s1 --no-focus -- claude`, ); assert.equal(r.status, 0, r.stderr); - assert.match(log(), /herdr agent start slot1 --cwd \/tmp\/wt\/s1 --no-focus -- claude/); + assert.match( + log(), + /herdr agent start slot1 --cwd \/tmp\/wt\/s1 --no-focus -- claude/, + ); assert.doesNotMatch(log(), /--split-from/); }); @@ -253,12 +271,21 @@ test("herdr_agent_start on 0.7.5 splits, runs, and reports — never calls agent freshEnv({ STUB_HERDR_VERSION: "0.7.5" }), ); assert.equal(r.status, 0, r.stderr); - const calls = log().split("\n").filter((l) => l.startsWith("herdr ")); + const calls = log() + .split("\n") + .filter((l) => l.startsWith("herdr ")); const order = calls.filter((l) => /pane (split|run|report-agent)/.test(l)); - assert.equal(order.length, 3, `expected 3 pane calls, got:\n${calls.join("\n")}`); + assert.equal( + order.length, + 3, + `expected 3 pane calls, got:\n${calls.join("\n")}`, + ); // Order is load-bearing: the pane must exist before argv runs in it, and // the agent must not be advertised as working before its argv is running. - assert.match(order[0], new RegExp(`pane split w9:p1 --direction down --cwd ${wt} --no-focus`)); + assert.match( + order[0], + new RegExp(`pane split w9:p1 --direction down --cwd ${wt} --no-focus`), + ); assert.match(order[1], /pane run w9:p7 claude --model opus/); assert.match( order[2], @@ -290,7 +317,10 @@ test("the 0.7.5 path refuses before splitting when the worktree cwd is missing", // And without an anchor pane, `pane split` would split the user's own // focused pane (it has no --workspace). const wt = fs.mkdtempSync(path.join(os.tmpdir(), "hs-wt075-")); - const noAnchor = runLib(`herdr_agent_start swarm-r1-s1 --cwd ${wt} -- claude`, env); + const noAnchor = runLib( + `herdr_agent_start swarm-r1-s1 --cwd ${wt} -- claude`, + env, + ); assert.notEqual(noAnchor.status, 0); assert.match(noAnchor.stderr, /needs --split-from/); assert.doesNotMatch(log(), /pane split/); @@ -368,7 +398,10 @@ test("report_slot_agent_state reports on 0.7.5+ and no-ops on 0.7.4", () => { log(), /pane report-agent w9:p7 --source structupath\.swarm --agent swarm-r1-s1 --state idle/, ); - const off = runLib(`report_slot_agent_state w9:p7 swarm-r1-s1 idle`, freshEnv()); + const off = runLib( + `report_slot_agent_state w9:p7 swarm-r1-s1 idle`, + freshEnv(), + ); assert.equal(off.status, 0, off.stderr); assert.doesNotMatch(log(), /report-agent/); // A slot with no recorded pane (a pending row) is skipped, never reported @@ -390,7 +423,10 @@ test("herdr_agent_wait requires --timeout so no call site can wait forever", () `herdr_agent_wait term_abc123 --status idle --timeout 5000`, ); assert.equal(ok.status, 0, ok.stderr); - assert.match(log(), /herdr agent wait term_abc123 --status idle --timeout 5000/); + assert.match( + log(), + /herdr agent wait term_abc123 --status idle --timeout 5000/, + ); }); test("herdr_pane_open pins --plugin to this plugin's id", () => { @@ -559,7 +595,10 @@ test("every harvest-worktree removal route uses the shared exact verifier/remove path.join(repoRoot, "scripts/harvest-step.sh"), "utf8", ); - const abort = fs.readFileSync(path.join(repoRoot, "scripts/abort.sh"), "utf8"); + const abort = fs.readFileSync( + path.join(repoRoot, "scripts/abort.sh"), + "utf8", + ); const lib = fs.readFileSync(path.join(repoRoot, "scripts/lib.sh"), "utf8"); assert.doesNotMatch(harvest, /worktree remove "\$(?:hwt|wt|jwt|d)"/); assert.doesNotMatch(abort, /worktree remove "\$(?:hwt|jwt|d)"/); @@ -583,7 +622,10 @@ test("every harvest-worktree removal route uses the shared exact verifier/remove // alone silently narrows the safety net to nothing, with no runtime error — // this test is the lockstep. test("preflight's pane-title sweep set matches the manifest's [[panes]] titles exactly", () => { - const toml = fs.readFileSync(path.join(repoRoot, "herdr-plugin.toml"), "utf8"); + const toml = fs.readFileSync( + path.join(repoRoot, "herdr-plugin.toml"), + "utf8", + ); // [[panes]] blocks only: [[actions]] also has `title =` keys, and only the // pane titles are sweep labels. const paneTitles = toml @@ -594,7 +636,10 @@ test("preflight's pane-title sweep set matches the manifest's [[panes]] titles e .map((m) => m[1]); assert.equal(paneTitles.length, 3, "manifest declares all three panes"); - const pf = fs.readFileSync(path.join(repoRoot, "scripts", "preflight.sh"), "utf8"); + const pf = fs.readFileSync( + path.join(repoRoot, "scripts", "preflight.sh"), + "utf8", + ); const set = pf.match(/const titles = new Set\(\[([^\]]*)\]\)/); assert.ok(set, "preflight.sh still declares a hardcoded pane-title set"); const swept = [...set[1].matchAll(/"([^"]+)"/g)].map((m) => m[1]); @@ -607,7 +652,10 @@ test("preflight's pane-title sweep set matches the manifest's [[panes]] titles e }); test("every script the manifest references exists on disk", () => { - const toml = fs.readFileSync(path.join(repoRoot, "herdr-plugin.toml"), "utf8"); + const toml = fs.readFileSync( + path.join(repoRoot, "herdr-plugin.toml"), + "utf8", + ); const refs = [...toml.matchAll(/"scripts\/([^"]+)"/g)].map((m) => m[1]); assert.ok(refs.length >= 8, "manifest lists all actions and panes"); for (const ref of refs) { diff --git a/tests/renderer.test.mjs b/tests/renderer.test.mjs index 0e47e1c..dbaf44c 100644 --- a/tests/renderer.test.mjs +++ b/tests/renderer.test.mjs @@ -257,7 +257,10 @@ exit 0`, const r = quiet(mkRenderer(h.freshEnv())); r.gitBin = path.join(h.stubDir, "git"); const manifest = JSON.parse(sampleManifest()); - const facts = await r.gitFactsFor(manifest, { ...manifest.slots[1], path: wt }); + const facts = await r.gitFactsFor(manifest, { + ...manifest.slots[1], + path: wt, + }); assert.equal(facts.branchMissing, true); }); @@ -385,12 +388,12 @@ test("sanitizeText strips C0/C1, bidi overrides, and zero-width from a hostile b sanitizeText("swarm/\x1b]0;PWNED\x07r1/\x9bs1"), "swarm/]0;PWNEDr1/s1", ); + assert.equal(sanitizeText("swarm/good‮/1s/1r‬"), "swarm/good/1s/1r"); + assert.equal(sanitizeText("a​bc\td"), "abc d"); assert.equal( - sanitizeText("swarm/good‮/1s/1r‬"), - "swarm/good/1s/1r", + sanitizeText("plain — unicode ✓ stays"), + "plain — unicode ✓ stays", ); - assert.equal(sanitizeText("a​bc\td"), "abc d"); - assert.equal(sanitizeText("plain — unicode ✓ stays"), "plain — unicode ✓ stays"); }); test("renderStatus marks blocked rows loud (inverse+red) and shows every column", () => { @@ -492,7 +495,9 @@ exit 0`, ); let env = h.freshEnv(); let r = quiet(mkRenderer(env)); - r.rows = [{ slot: 2, pane_id: "w9:p4", workspace_id: "w9", state: "blocked" }]; + r.rows = [ + { slot: 2, pane_id: "w9:p4", workspace_id: "w9", state: "blocked" }, + ]; r.onKey("2"); // the actual key path users hit assert.ok( await until(() => h.log().includes("agent focus")), @@ -504,7 +509,9 @@ exit 0`, // Agent focus fails (agent gone, plugin-scoped reach): workspace fallback. env = h.freshEnv({ STUB_FOCUS_EXIT: "1" }); r = quiet(mkRenderer(env)); - r.rows = [{ slot: 2, pane_id: "w9:p4", workspace_id: "w9", state: "unknown" }]; + r.rows = [ + { slot: 2, pane_id: "w9:p4", workspace_id: "w9", state: "unknown" }, + ]; await r.jumpToSlot(2); assert.match(h.log(), /herdr agent focus w9:p4/); assert.match(h.log(), /herdr workspace focus w9/); @@ -648,7 +655,11 @@ test("harvest step is bounded: a hung verb is killed, busy clears, banner explai assert.equal(res.timedOut, true); assert.notEqual(res.code, 0, "a timeout is a failure, not a silent success"); assert.equal(r.busy, false, "busy must clear or the pane masks every key"); - assert.match(r.banner, /timed out/, "the user learns why the pane went quiet"); + assert.match( + r.banner, + /timed out/, + "the user learns why the pane went quiet", + ); assert.match(r.lastErrLine(res), /timed out/); assert.match(r.lastErrLine(res), /merge/, "the stuck verb is named"); // Destructive verbs legitimately take a while: the default stays generous. @@ -669,14 +680,21 @@ test("stale journal: the list phase offers abort-merge and 'a' dispatches it", a const view = renderHarvest({ phase: { name: "list" }, rows: [row] }, 120); assert.match(view, /a:abort stale merge \(slot 1\)/); assert.doesNotMatch( - renderHarvest({ phase: { name: "list" }, rows: [{ ...row, journal: null }] }, 120), + renderHarvest( + { phase: { name: "list" }, rows: [{ ...row, journal: null }] }, + 120, + ), /abort stale merge/, "no journal, no destructive affordance", ); const r = mkHarvest(); r.rows = [row]; await r.onKey("a"); - assert.deepEqual(r.calls, [["abort-merge", 1]], "routed through step(), not raw git"); + assert.deepEqual( + r.calls, + [["abort-merge", 1]], + "routed through step(), not raw git", + ); }); test("resume_stale becomes a stale phase whose 'a' clears the wedged journal", async () => { @@ -692,7 +710,11 @@ test("resume_stale becomes a stale phase whose 'a' clears the wedged journal", a assert.equal(r.phase.idx, 1, "the queue advances to the next stale slot"); await r.onKey("n"); // leave slot 3 journaled assert.deepEqual(r.calls, [["abort-merge", 2]], "'n' must not mutate"); - assert.equal(r.phase.name, "list", "the queue drains back to the resting phase"); + assert.equal( + r.phase.name, + "list", + "the queue drains back to the resting phase", + ); assert.equal(r.enterStalePhase(), false, "queue consumed exactly once"); }); @@ -708,17 +730,41 @@ test("the resume queue hands off to the stale queue instead of dropping it", asy test("terminal preview states — empty and external_merged included — archive", async () => { // The preview verb already wrote skipped/merged to the manifest for these // two; without them the only route to archiving was a manual re-preview. - for (const state of ["merged", "skipped", "failed", "empty", "external_merged"]) { + for (const state of [ + "merged", + "skipped", + "failed", + "empty", + "external_merged", + ]) { const r = mkHarvest(); r.rows = [ - { slot: 1, label: "s1", branch: "b", status: "merged", preview: { state, dirty: 0 } }, + { + slot: 1, + label: "s1", + branch: "b", + status: "merged", + preview: { state, dirty: 0 }, + }, ]; await r.selectSlot(1); - assert.deepEqual(r.calls, [["archive", 1]], `'${state}' must route to archive`); + assert.deepEqual( + r.calls, + [["archive", 1]], + `'${state}' must route to archive`, + ); } // Anything genuinely non-terminal still refuses to act. const r = mkHarvest(); - r.rows = [{ slot: 1, label: "s1", branch: "b", status: "running", preview: { state: "missing" } }]; + r.rows = [ + { + slot: 1, + label: "s1", + branch: "b", + status: "running", + preview: { state: "missing" }, + }, + ]; await r.selectSlot(1); assert.deepEqual(r.calls, [], "an unknown state must never trigger a verb"); assert.match(r.banner, /nothing to do here/); @@ -749,11 +795,18 @@ test("agent-supplied state text cannot smuggle escapes into any rendered view", // …and so does the resume offer's merge-commit SHA. const resume = renderHarvest( { - phase: { name: "resume", offers: [{ slot: 1, sha: "\x1b]0;pwn\x07dead" }], idx: 0 }, + phase: { + name: "resume", + offers: [{ slot: 1, sha: "\x1b]0;pwn\x07dead" }], + idx: 0, + }, rows: [], }, 120, ); - assert.ok(!resume.includes("\x1b]"), `escape survived the resume view:\n${resume}`); + assert.ok( + !resume.includes("\x1b]"), + `escape survived the resume view:\n${resume}`, + ); assert.ok(!resume.includes("\x07")); }); diff --git a/tests/run-finalization.test.mjs b/tests/run-finalization.test.mjs index 28a5c95..486a755 100644 --- a/tests/run-finalization.test.mjs +++ b/tests/run-finalization.test.mjs @@ -181,7 +181,13 @@ test("Abort removes a landed exact harvest generation and archives every slot", }); assert.equal(result.status, 99, `${result.stdout}\n${result.stderr}`); const journal = run.slotRow(1).journal; - h.git(run.repo, "update-ref", "refs/heads/main", journal.merge_commit_sha, run.fork); + h.git( + run.repo, + "update-ref", + "refs/heads/main", + journal.merge_commit_sha, + run.fork, + ); result = abort(run); assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`); assert.equal(fs.existsSync(journal.worktree), false); @@ -199,7 +205,13 @@ test("Abort exits nonzero when a removed exact harvest generation cannot clear i }); assert.equal(result.status, 99, `${result.stdout}\n${result.stderr}`); const journal = run.slotRow(1).journal; - h.git(run.repo, "update-ref", "refs/heads/main", journal.merge_commit_sha, run.fork); + h.git( + run.repo, + "update-ref", + "refs/heads/main", + journal.merge_commit_sha, + run.fork, + ); const backup = path.join(run.sdir, "run-w9.json.bak"); fs.rmSync(backup, { force: true }); fs.symlinkSync(path.join(run.sdir, "missing-parent", "backup"), backup); @@ -207,10 +219,17 @@ test("Abort exits nonzero when a removed exact harvest generation cannot clear i result = abort(run); assert.notEqual(result.status, 0, `${result.stdout}\n${result.stderr}`); assert.equal(fs.existsSync(run.wt(1)), false, "slot removal completed"); - assert.equal(fs.existsSync(journal.worktree), false, "harvest removal completed"); + assert.equal( + fs.existsSync(journal.worktree), + false, + "harvest removal completed", + ); assert.match(result.stderr, /journal update failed|bookkeeping failure/); assert.ok(fs.existsSync(path.join(run.sdir, "run-w9.json"))); - assert.equal(fs.existsSync(path.join(run.sdir, `archived-${run.runId}.json`)), false); + assert.equal( + fs.existsSync(path.join(run.sdir, `archived-${run.runId}.json`)), + false, + ); }); test("full harvest archives exactly once, removes the live pointer/exclude, and retry is idempotent", () => { @@ -331,7 +350,10 @@ test("a stale foreign legacy archive is quarantined without bricking this reposi path.join(run.sdir, "archived-stale-foreign.json"), JSON.stringify(stale), ); - const scan = h.runLib(`bookkeeping_scan ${JSON.stringify(run.repo)}`, run.env); + const scan = h.runLib( + `bookkeeping_scan ${JSON.stringify(run.repo)}`, + run.env, + ); assert.equal(scan.status, 0, scan.stderr); const parsed = JSON.parse(scan.stdout); assert.deepEqual(parsed.errors, []); From 02d831fbc57780a5ed9f692d15141b7438eb0909 Mon Sep 17 00:00:00 2001 From: Victor Garcia Date: Tue, 28 Jul 2026 03:54:09 -0600 Subject: [PATCH 8/9] Revert "[pi] Implemented, committed, and pushed all review fixe..." This reverts commit 3e87e1e4938f7437f1966c9e015a17a75b18b0ff. --- scripts/safety-state.mjs | 56 +++++---------------- tests/harvest.test.mjs | 60 +++++------------------ tests/lib.test.mjs | 78 ++++++----------------------- tests/renderer.test.mjs | 87 +++++++-------------------------- tests/run-finalization.test.mjs | 32 ++---------- 5 files changed, 61 insertions(+), 252 deletions(-) diff --git a/scripts/safety-state.mjs b/scripts/safety-state.mjs index 4b83752..f0a37ab 100755 --- a/scripts/safety-state.mjs +++ b/scripts/safety-state.mjs @@ -15,9 +15,7 @@ function git(repo, args, encoding = "utf8") { maxBuffer: 64 * 1024 * 1024, }); if (result.error) { - throw new Error( - `git ${args.join(" ")} could not run for ${repo}: ${result.error.message}`, - ); + throw new Error(`git ${args.join(" ")} could not run for ${repo}: ${result.error.message}`); } if (result.status !== 0) { throw new Error( @@ -154,11 +152,7 @@ function scan(stateDir, repo) { // it disable an unrelated repository forever. Live generations and // keyed records remain fail-closed because they may belong here. if (isArchive && doc.repo_key == null) { - quarantined.push({ - path: file, - run_id: doc.run_id, - reason: error.message, - }); + quarantined.push({ path: file, run_id: doc.run_id, reason: error.message }); continue; } errors.push(`${file}: ${error.message}`); @@ -176,10 +170,7 @@ function scan(stateDir, repo) { run_id: doc.run_id, workspace_id: isArchive ? null - : path - .basename(file) - .replace(/^run-/, "") - .replace(/\.json$/, ""), + : path.basename(file).replace(/^run-/, "").replace(/\.json$/, ""), exclude_pattern_added: doc.exclude_pattern_added === true, }; if (isArchive) archived.push(item); @@ -232,11 +223,9 @@ function worktreeRegistrations(repo) { if (field.startsWith("worktree ")) { if (current) rows.push(current); current = { path: field.slice(9), detached: false }; - } else if (current && field.startsWith("HEAD ")) - current.head = field.slice(5); + } else if (current && field.startsWith("HEAD ")) current.head = field.slice(5); else if (current && field === "detached") current.detached = true; - else if (current && field.startsWith("branch ")) - current.branch = field.slice(7); + else if (current && field.startsWith("branch ")) current.branch = field.slice(7); } if (current) rows.push(current); return rows; @@ -255,15 +244,7 @@ function parseHarvestJournal(raw) { return { journal, resource }; } -function harvestBinding( - stateDir, - repo, - runId, - slot, - worktree, - journalRaw, - manifestFile, -) { +function harvestBinding(stateDir, repo, runId, slot, worktree, journalRaw, manifestFile) { if (!isSafeId(runId) || !/^[1-9][0-9]*$/.test(slot)) throw new Error("harvest resource run/slot binding is invalid"); const { journal, resource } = parseHarvestJournal(journalRaw); @@ -292,9 +273,7 @@ function harvestBinding( throw new Error("harvest resource must be a real directory, not a symlink"); const physical = fs.realpathSync(worktree); if (physical !== expected) - throw new Error( - `harvest resource path is not the exact generation path ${expected}`, - ); + throw new Error(`harvest resource path is not the exact generation path ${expected}`); const worktreeIdentity = repoIdentity(physical); if ( worktreeIdentity.repo_key !== identity.repo_key || @@ -326,13 +305,8 @@ function harvestBinding( if (manifest.run_id !== runId) throw new Error("live manifest run does not match harvest resource"); const owner = manifest.slots.find((row) => String(row.slot) === slot); - if ( - !owner || - JSON.stringify(owner.journal ?? null) !== JSON.stringify(journal) - ) - throw new Error( - "harvest journal changed or is not owned by the exact slot", - ); + if (!owner || JSON.stringify(owner.journal ?? null) !== JSON.stringify(journal)) + throw new Error("harvest journal changed or is not owned by the exact slot"); const duplicates = manifest.slots.filter((row) => { const other = row.journal?.resource; return ( @@ -376,8 +350,7 @@ function canonicalInventory(repo, binding) { if (output[i] !== 0) continue; const item = output.subarray(start, i); start = i + 1; - if (item.length === 0 || item.equals(Buffer.from(".swarm-task.md"))) - continue; + if (item.length === 0 || item.equals(Buffer.from(".swarm-task.md"))) continue; paths.push(Buffer.from(item)); } paths.sort(Buffer.compare); @@ -428,8 +401,7 @@ try { ); break; case "verify-harvest": { - const [stateDir, repo, runId, slot, worktree, journalRaw, manifestFile] = - args; + const [stateDir, repo, runId, slot, worktree, journalRaw, manifestFile] = args; process.stdout.write( `${JSON.stringify(harvestBinding(stateDir, repo, runId, slot, worktree, journalRaw, manifestFile))}\n`, ); @@ -442,8 +414,7 @@ try { } case "inventory": { const [repo, bindingRaw, operationId] = args; - if (!isSafeId(operationId)) - fail("cleanup inventory operation_id is invalid"); + if (!isSafeId(operationId)) fail("cleanup inventory operation_id is invalid"); const binding = JSON.parse(bindingRaw); const identity = repoIdentity(repo); if ( @@ -478,8 +449,7 @@ try { if (String(approval[key] ?? "") !== String(inventory[key] ?? "")) fail(`cleanup approval ${key} mismatch`, 2); } - if (approval.approved !== true) - fail("cleanup approval is not approved", 2); + if (approval.approved !== true) fail("cleanup approval is not approved", 2); if (!isSafeId(approval.operation_id)) fail("cleanup approval operation_id is unsafe", 2); const used = path.join( diff --git a/tests/harvest.test.mjs b/tests/harvest.test.mjs index 989060a..ff2e959 100644 --- a/tests/harvest.test.mjs +++ b/tests/harvest.test.mjs @@ -589,14 +589,8 @@ test("swap and resume use exact ignored preview/apply before removing a harvest }); assert.equal(result.status, 99); const hwt = run.slotRow(1).journal.worktree; - fs.appendFileSync( - path.join(run.repo, ".git/info/exclude"), - "hook-output.log\n", - ); - fs.writeFileSync( - path.join(hwt, "hook-output.log"), - "ignored hook artifact\n", - ); + fs.appendFileSync(path.join(run.repo, ".git/info/exclude"), "hook-output.log\n"); + fs.writeFileSync(path.join(hwt, "hook-output.log"), "ignored hook artifact\n"); result = step(run, "resume", ["complete", 1]); assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`); @@ -612,9 +606,7 @@ test("swap and resume use exact ignored preview/apply before removing a harvest assert.equal(run.slotRow(1).journal, null); const used = fs .readdirSync(run.sdir) - .filter( - (name) => name.startsWith("cleanup-used-") && name.endsWith(".json"), - ); + .filter((name) => name.startsWith("cleanup-used-") && name.endsWith(".json")); assert.equal(used.length, 1, "approval was durably consumed exactly once"); }); @@ -639,13 +631,7 @@ test("harvest removal refuses every exact identity mismatch", () => { const row = run.slotRow(1); const journal = structuredClone(row.journal); const hwt = journal.worktree; - h.git( - run.repo, - "update-ref", - "refs/heads/main", - journal.merge_commit_sha, - run.fork, - ); + h.git(run.repo, "update-ref", "refs/heads/main", journal.merge_commit_sha, run.fork); if (field === "registration") { h.git(hwt, "checkout", "-q", "-b", `foreign-${run.runId}`); } else if (field === "symlink") { @@ -658,16 +644,8 @@ test("harvest removal refuses every exact identity mismatch", () => { patchSlot(run, 1, { journal }); } result = step(run, "abort-merge", [1]); - assert.equal( - result.status, - EC.REFUSED, - `${field}: ${result.stdout}\n${result.stderr}`, - ); - assert.match( - result.stderr, - /identity|resource|symlink|registration/i, - field, - ); + assert.equal(result.status, EC.REFUSED, `${field}: ${result.stdout}\n${result.stderr}`); + assert.match(result.stderr, /identity|resource|symlink|registration/i, field); assert.ok(fs.existsSync(hwt), `${field}: zero removal`); } }); @@ -877,10 +855,7 @@ function linkedBaseCheckout(run) { function foreignHarvestWorktree(run, slot = 1, sha = run.fork) { const dir = path.join(run.sdir, `harvest-${run.runId}-s${slot}`); h.git(run.repo, "worktree", "add", "-q", "--detach", dir, sha); - fs.appendFileSync( - path.join(run.repo, ".git/info/exclude"), - "precious.secret\n", - ); + fs.appendFileSync(path.join(run.repo, ".git/info/exclude"), "precious.secret\n"); fs.writeFileSync(path.join(dir, "precious.secret"), "foreign ignored data\n"); return dir; } @@ -899,10 +874,7 @@ test("abort-merge refuses a same-prefix foreign detached worktree and its ignore const result = step(run, "abort-merge", [1]); assert.equal(result.status, EC.REFUSED, `${result.stdout}\n${result.stderr}`); assert.match(result.stderr, /resource identity failed/); - assert.equal( - fs.readFileSync(path.join(foreign, "precious.secret"), "utf8"), - "foreign ignored data\n", - ); + assert.equal(fs.readFileSync(path.join(foreign, "precious.secret"), "utf8"), "foreign ignored data\n"); assert.match(h.git(run.repo, "worktree", "list").stdout, new RegExp(foreign)); }); @@ -923,10 +895,7 @@ test("resume scan refuses same-prefix foreign harvest cleanup after the base lan assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`); assert.match(result.stderr, /cleanup was refused/); assert.ok(fs.existsSync(path.join(foreign, "precious.secret"))); - assert.ok( - run.slotRow(1).journal, - "failed cleanup retains exact recovery journal", - ); + assert.ok(run.slotRow(1).journal, "failed cleanup retains exact recovery journal"); }); test("Abort journal cleanup and leftover sweep both quarantine same-prefix foreign worktrees", () => { @@ -950,10 +919,7 @@ test("Abort journal cleanup and leftover sweep both quarantine same-prefix forei ); assert.equal(result.status, 4, `${result.stdout}\n${result.stderr}`); assert.ok(fs.existsSync(path.join(foreign, "precious.secret"))); - assert.match( - result.stderr, - journaled ? /identity FAILED/ : /no exact live resource journal/, - ); + assert.match(result.stderr, journaled ? /identity FAILED/ : /no exact live resource journal/); } }); @@ -1038,11 +1004,7 @@ test("swap_base refuses a journal lacking exact harvest resource identity before h.git(userWt, "checkout", "-q", "-b", "sidework"); const r = step(run, "resume", ["complete", 1]); assert.equal(r.status, EC.REFUSED, `${r.stdout}\n${r.stderr}`); - assert.match( - r.stderr, - /identity failed before base swap/, - "the refusal is reported", - ); + assert.match(r.stderr, /identity failed before base swap/, "the refusal is reported"); assert.equal( h.git(run.repo, "rev-parse", "refs/heads/main").stdout.trim(), run.fork, diff --git a/tests/lib.test.mjs b/tests/lib.test.mjs index c164c10..55bcc62 100644 --- a/tests/lib.test.mjs +++ b/tests/lib.test.mjs @@ -27,20 +27,11 @@ test("sanitize_slug strips path-dangerous chars to [a-zA-Z0-9_-]", () => { // this function exists to neutralize. const r = spawnSync( "bash", - [ - "-c", - `. "${repoRoot}/scripts/lib.sh" && sanitize_slug "$1"`, - "--", - input, - ], + ["-c", `. "${repoRoot}/scripts/lib.sh" && sanitize_slug "$1"`, "--", input], { env: freshEnv(), encoding: "utf8" }, ); assert.equal(r.status, 0, `${input}: ${r.stderr}`); - assert.equal( - r.stdout.trim(), - want, - `sanitize_slug(${JSON.stringify(input)})`, - ); + assert.equal(r.stdout.trim(), want, `sanitize_slug(${JSON.stringify(input)})`); } }); @@ -59,20 +50,14 @@ test("sanitize_slug refuses input that strips to nothing (no silent default)", ( test("state_dir expands a literal leading ~ instead of creating ./~", () => { const rel = `.cache/hs-tilde-test-${process.pid}`; - const r = runLib( - "state_dir", - freshEnv({ HERDR_PLUGIN_STATE_DIR: `~/${rel}` }), - ); + const r = runLib("state_dir", freshEnv({ HERDR_PLUGIN_STATE_DIR: `~/${rel}` })); assert.equal(r.status, 0, r.stderr); assert.equal(r.stdout.trim(), path.join(os.homedir(), rel)); fs.rmSync(path.join(os.homedir(), rel), { recursive: true, force: true }); }); test("state_dir rejects relative paths and falls back to the default", () => { - const r = runLib( - "state_dir", - freshEnv({ HERDR_PLUGIN_STATE_DIR: "rel/path" }), - ); + const r = runLib("state_dir", freshEnv({ HERDR_PLUGIN_STATE_DIR: "rel/path" })); assert.equal(r.status, 0, r.stderr); assert.equal( r.stdout.trim(), @@ -252,10 +237,7 @@ test("herdr_agent_start drops --split-from on the 0.7.4 path", () => { `herdr_agent_start slot1 --split-from w9:p1 --cwd /tmp/wt/s1 --no-focus -- claude`, ); assert.equal(r.status, 0, r.stderr); - assert.match( - log(), - /herdr agent start slot1 --cwd \/tmp\/wt\/s1 --no-focus -- claude/, - ); + assert.match(log(), /herdr agent start slot1 --cwd \/tmp\/wt\/s1 --no-focus -- claude/); assert.doesNotMatch(log(), /--split-from/); }); @@ -271,21 +253,12 @@ test("herdr_agent_start on 0.7.5 splits, runs, and reports — never calls agent freshEnv({ STUB_HERDR_VERSION: "0.7.5" }), ); assert.equal(r.status, 0, r.stderr); - const calls = log() - .split("\n") - .filter((l) => l.startsWith("herdr ")); + const calls = log().split("\n").filter((l) => l.startsWith("herdr ")); const order = calls.filter((l) => /pane (split|run|report-agent)/.test(l)); - assert.equal( - order.length, - 3, - `expected 3 pane calls, got:\n${calls.join("\n")}`, - ); + assert.equal(order.length, 3, `expected 3 pane calls, got:\n${calls.join("\n")}`); // Order is load-bearing: the pane must exist before argv runs in it, and // the agent must not be advertised as working before its argv is running. - assert.match( - order[0], - new RegExp(`pane split w9:p1 --direction down --cwd ${wt} --no-focus`), - ); + assert.match(order[0], new RegExp(`pane split w9:p1 --direction down --cwd ${wt} --no-focus`)); assert.match(order[1], /pane run w9:p7 claude --model opus/); assert.match( order[2], @@ -317,10 +290,7 @@ test("the 0.7.5 path refuses before splitting when the worktree cwd is missing", // And without an anchor pane, `pane split` would split the user's own // focused pane (it has no --workspace). const wt = fs.mkdtempSync(path.join(os.tmpdir(), "hs-wt075-")); - const noAnchor = runLib( - `herdr_agent_start swarm-r1-s1 --cwd ${wt} -- claude`, - env, - ); + const noAnchor = runLib(`herdr_agent_start swarm-r1-s1 --cwd ${wt} -- claude`, env); assert.notEqual(noAnchor.status, 0); assert.match(noAnchor.stderr, /needs --split-from/); assert.doesNotMatch(log(), /pane split/); @@ -398,10 +368,7 @@ test("report_slot_agent_state reports on 0.7.5+ and no-ops on 0.7.4", () => { log(), /pane report-agent w9:p7 --source structupath\.swarm --agent swarm-r1-s1 --state idle/, ); - const off = runLib( - `report_slot_agent_state w9:p7 swarm-r1-s1 idle`, - freshEnv(), - ); + const off = runLib(`report_slot_agent_state w9:p7 swarm-r1-s1 idle`, freshEnv()); assert.equal(off.status, 0, off.stderr); assert.doesNotMatch(log(), /report-agent/); // A slot with no recorded pane (a pending row) is skipped, never reported @@ -423,10 +390,7 @@ test("herdr_agent_wait requires --timeout so no call site can wait forever", () `herdr_agent_wait term_abc123 --status idle --timeout 5000`, ); assert.equal(ok.status, 0, ok.stderr); - assert.match( - log(), - /herdr agent wait term_abc123 --status idle --timeout 5000/, - ); + assert.match(log(), /herdr agent wait term_abc123 --status idle --timeout 5000/); }); test("herdr_pane_open pins --plugin to this plugin's id", () => { @@ -595,10 +559,7 @@ test("every harvest-worktree removal route uses the shared exact verifier/remove path.join(repoRoot, "scripts/harvest-step.sh"), "utf8", ); - const abort = fs.readFileSync( - path.join(repoRoot, "scripts/abort.sh"), - "utf8", - ); + const abort = fs.readFileSync(path.join(repoRoot, "scripts/abort.sh"), "utf8"); const lib = fs.readFileSync(path.join(repoRoot, "scripts/lib.sh"), "utf8"); assert.doesNotMatch(harvest, /worktree remove "\$(?:hwt|wt|jwt|d)"/); assert.doesNotMatch(abort, /worktree remove "\$(?:hwt|jwt|d)"/); @@ -622,10 +583,7 @@ test("every harvest-worktree removal route uses the shared exact verifier/remove // alone silently narrows the safety net to nothing, with no runtime error — // this test is the lockstep. test("preflight's pane-title sweep set matches the manifest's [[panes]] titles exactly", () => { - const toml = fs.readFileSync( - path.join(repoRoot, "herdr-plugin.toml"), - "utf8", - ); + const toml = fs.readFileSync(path.join(repoRoot, "herdr-plugin.toml"), "utf8"); // [[panes]] blocks only: [[actions]] also has `title =` keys, and only the // pane titles are sweep labels. const paneTitles = toml @@ -636,10 +594,7 @@ test("preflight's pane-title sweep set matches the manifest's [[panes]] titles e .map((m) => m[1]); assert.equal(paneTitles.length, 3, "manifest declares all three panes"); - const pf = fs.readFileSync( - path.join(repoRoot, "scripts", "preflight.sh"), - "utf8", - ); + const pf = fs.readFileSync(path.join(repoRoot, "scripts", "preflight.sh"), "utf8"); const set = pf.match(/const titles = new Set\(\[([^\]]*)\]\)/); assert.ok(set, "preflight.sh still declares a hardcoded pane-title set"); const swept = [...set[1].matchAll(/"([^"]+)"/g)].map((m) => m[1]); @@ -652,10 +607,7 @@ test("preflight's pane-title sweep set matches the manifest's [[panes]] titles e }); test("every script the manifest references exists on disk", () => { - const toml = fs.readFileSync( - path.join(repoRoot, "herdr-plugin.toml"), - "utf8", - ); + const toml = fs.readFileSync(path.join(repoRoot, "herdr-plugin.toml"), "utf8"); const refs = [...toml.matchAll(/"scripts\/([^"]+)"/g)].map((m) => m[1]); assert.ok(refs.length >= 8, "manifest lists all actions and panes"); for (const ref of refs) { diff --git a/tests/renderer.test.mjs b/tests/renderer.test.mjs index dbaf44c..0e47e1c 100644 --- a/tests/renderer.test.mjs +++ b/tests/renderer.test.mjs @@ -257,10 +257,7 @@ exit 0`, const r = quiet(mkRenderer(h.freshEnv())); r.gitBin = path.join(h.stubDir, "git"); const manifest = JSON.parse(sampleManifest()); - const facts = await r.gitFactsFor(manifest, { - ...manifest.slots[1], - path: wt, - }); + const facts = await r.gitFactsFor(manifest, { ...manifest.slots[1], path: wt }); assert.equal(facts.branchMissing, true); }); @@ -388,12 +385,12 @@ test("sanitizeText strips C0/C1, bidi overrides, and zero-width from a hostile b sanitizeText("swarm/\x1b]0;PWNED\x07r1/\x9bs1"), "swarm/]0;PWNEDr1/s1", ); - assert.equal(sanitizeText("swarm/good‮/1s/1r‬"), "swarm/good/1s/1r"); - assert.equal(sanitizeText("a​bc\td"), "abc d"); assert.equal( - sanitizeText("plain — unicode ✓ stays"), - "plain — unicode ✓ stays", + sanitizeText("swarm/good‮/1s/1r‬"), + "swarm/good/1s/1r", ); + assert.equal(sanitizeText("a​bc\td"), "abc d"); + assert.equal(sanitizeText("plain — unicode ✓ stays"), "plain — unicode ✓ stays"); }); test("renderStatus marks blocked rows loud (inverse+red) and shows every column", () => { @@ -495,9 +492,7 @@ exit 0`, ); let env = h.freshEnv(); let r = quiet(mkRenderer(env)); - r.rows = [ - { slot: 2, pane_id: "w9:p4", workspace_id: "w9", state: "blocked" }, - ]; + r.rows = [{ slot: 2, pane_id: "w9:p4", workspace_id: "w9", state: "blocked" }]; r.onKey("2"); // the actual key path users hit assert.ok( await until(() => h.log().includes("agent focus")), @@ -509,9 +504,7 @@ exit 0`, // Agent focus fails (agent gone, plugin-scoped reach): workspace fallback. env = h.freshEnv({ STUB_FOCUS_EXIT: "1" }); r = quiet(mkRenderer(env)); - r.rows = [ - { slot: 2, pane_id: "w9:p4", workspace_id: "w9", state: "unknown" }, - ]; + r.rows = [{ slot: 2, pane_id: "w9:p4", workspace_id: "w9", state: "unknown" }]; await r.jumpToSlot(2); assert.match(h.log(), /herdr agent focus w9:p4/); assert.match(h.log(), /herdr workspace focus w9/); @@ -655,11 +648,7 @@ test("harvest step is bounded: a hung verb is killed, busy clears, banner explai assert.equal(res.timedOut, true); assert.notEqual(res.code, 0, "a timeout is a failure, not a silent success"); assert.equal(r.busy, false, "busy must clear or the pane masks every key"); - assert.match( - r.banner, - /timed out/, - "the user learns why the pane went quiet", - ); + assert.match(r.banner, /timed out/, "the user learns why the pane went quiet"); assert.match(r.lastErrLine(res), /timed out/); assert.match(r.lastErrLine(res), /merge/, "the stuck verb is named"); // Destructive verbs legitimately take a while: the default stays generous. @@ -680,21 +669,14 @@ test("stale journal: the list phase offers abort-merge and 'a' dispatches it", a const view = renderHarvest({ phase: { name: "list" }, rows: [row] }, 120); assert.match(view, /a:abort stale merge \(slot 1\)/); assert.doesNotMatch( - renderHarvest( - { phase: { name: "list" }, rows: [{ ...row, journal: null }] }, - 120, - ), + renderHarvest({ phase: { name: "list" }, rows: [{ ...row, journal: null }] }, 120), /abort stale merge/, "no journal, no destructive affordance", ); const r = mkHarvest(); r.rows = [row]; await r.onKey("a"); - assert.deepEqual( - r.calls, - [["abort-merge", 1]], - "routed through step(), not raw git", - ); + assert.deepEqual(r.calls, [["abort-merge", 1]], "routed through step(), not raw git"); }); test("resume_stale becomes a stale phase whose 'a' clears the wedged journal", async () => { @@ -710,11 +692,7 @@ test("resume_stale becomes a stale phase whose 'a' clears the wedged journal", a assert.equal(r.phase.idx, 1, "the queue advances to the next stale slot"); await r.onKey("n"); // leave slot 3 journaled assert.deepEqual(r.calls, [["abort-merge", 2]], "'n' must not mutate"); - assert.equal( - r.phase.name, - "list", - "the queue drains back to the resting phase", - ); + assert.equal(r.phase.name, "list", "the queue drains back to the resting phase"); assert.equal(r.enterStalePhase(), false, "queue consumed exactly once"); }); @@ -730,41 +708,17 @@ test("the resume queue hands off to the stale queue instead of dropping it", asy test("terminal preview states — empty and external_merged included — archive", async () => { // The preview verb already wrote skipped/merged to the manifest for these // two; without them the only route to archiving was a manual re-preview. - for (const state of [ - "merged", - "skipped", - "failed", - "empty", - "external_merged", - ]) { + for (const state of ["merged", "skipped", "failed", "empty", "external_merged"]) { const r = mkHarvest(); r.rows = [ - { - slot: 1, - label: "s1", - branch: "b", - status: "merged", - preview: { state, dirty: 0 }, - }, + { slot: 1, label: "s1", branch: "b", status: "merged", preview: { state, dirty: 0 } }, ]; await r.selectSlot(1); - assert.deepEqual( - r.calls, - [["archive", 1]], - `'${state}' must route to archive`, - ); + assert.deepEqual(r.calls, [["archive", 1]], `'${state}' must route to archive`); } // Anything genuinely non-terminal still refuses to act. const r = mkHarvest(); - r.rows = [ - { - slot: 1, - label: "s1", - branch: "b", - status: "running", - preview: { state: "missing" }, - }, - ]; + r.rows = [{ slot: 1, label: "s1", branch: "b", status: "running", preview: { state: "missing" } }]; await r.selectSlot(1); assert.deepEqual(r.calls, [], "an unknown state must never trigger a verb"); assert.match(r.banner, /nothing to do here/); @@ -795,18 +749,11 @@ test("agent-supplied state text cannot smuggle escapes into any rendered view", // …and so does the resume offer's merge-commit SHA. const resume = renderHarvest( { - phase: { - name: "resume", - offers: [{ slot: 1, sha: "\x1b]0;pwn\x07dead" }], - idx: 0, - }, + phase: { name: "resume", offers: [{ slot: 1, sha: "\x1b]0;pwn\x07dead" }], idx: 0 }, rows: [], }, 120, ); - assert.ok( - !resume.includes("\x1b]"), - `escape survived the resume view:\n${resume}`, - ); + assert.ok(!resume.includes("\x1b]"), `escape survived the resume view:\n${resume}`); assert.ok(!resume.includes("\x07")); }); diff --git a/tests/run-finalization.test.mjs b/tests/run-finalization.test.mjs index 486a755..28a5c95 100644 --- a/tests/run-finalization.test.mjs +++ b/tests/run-finalization.test.mjs @@ -181,13 +181,7 @@ test("Abort removes a landed exact harvest generation and archives every slot", }); assert.equal(result.status, 99, `${result.stdout}\n${result.stderr}`); const journal = run.slotRow(1).journal; - h.git( - run.repo, - "update-ref", - "refs/heads/main", - journal.merge_commit_sha, - run.fork, - ); + h.git(run.repo, "update-ref", "refs/heads/main", journal.merge_commit_sha, run.fork); result = abort(run); assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`); assert.equal(fs.existsSync(journal.worktree), false); @@ -205,13 +199,7 @@ test("Abort exits nonzero when a removed exact harvest generation cannot clear i }); assert.equal(result.status, 99, `${result.stdout}\n${result.stderr}`); const journal = run.slotRow(1).journal; - h.git( - run.repo, - "update-ref", - "refs/heads/main", - journal.merge_commit_sha, - run.fork, - ); + h.git(run.repo, "update-ref", "refs/heads/main", journal.merge_commit_sha, run.fork); const backup = path.join(run.sdir, "run-w9.json.bak"); fs.rmSync(backup, { force: true }); fs.symlinkSync(path.join(run.sdir, "missing-parent", "backup"), backup); @@ -219,17 +207,10 @@ test("Abort exits nonzero when a removed exact harvest generation cannot clear i result = abort(run); assert.notEqual(result.status, 0, `${result.stdout}\n${result.stderr}`); assert.equal(fs.existsSync(run.wt(1)), false, "slot removal completed"); - assert.equal( - fs.existsSync(journal.worktree), - false, - "harvest removal completed", - ); + assert.equal(fs.existsSync(journal.worktree), false, "harvest removal completed"); assert.match(result.stderr, /journal update failed|bookkeeping failure/); assert.ok(fs.existsSync(path.join(run.sdir, "run-w9.json"))); - assert.equal( - fs.existsSync(path.join(run.sdir, `archived-${run.runId}.json`)), - false, - ); + assert.equal(fs.existsSync(path.join(run.sdir, `archived-${run.runId}.json`)), false); }); test("full harvest archives exactly once, removes the live pointer/exclude, and retry is idempotent", () => { @@ -350,10 +331,7 @@ test("a stale foreign legacy archive is quarantined without bricking this reposi path.join(run.sdir, "archived-stale-foreign.json"), JSON.stringify(stale), ); - const scan = h.runLib( - `bookkeeping_scan ${JSON.stringify(run.repo)}`, - run.env, - ); + const scan = h.runLib(`bookkeeping_scan ${JSON.stringify(run.repo)}`, run.env); assert.equal(scan.status, 0, scan.stderr); const parsed = JSON.parse(scan.stdout); assert.deepEqual(parsed.errors, []); From 18fcc7b2958ff6001831d7986ab8f2e3dfe7b7ba Mon Sep 17 00:00:00 2001 From: Victor Garcia Date: Tue, 28 Jul 2026 04:06:02 -0600 Subject: [PATCH 9/9] fix(safety): bind cleanup to repository generations Make explicit plugin repository context authoritative and require every workspace-named legacy hint to match the exact live generation selected under the repository lock. Conflicting context now fails before Harvest, Status, or Abort can mutate either repository.\n\nDefer terminal slot archival while a detached harvest journal remains, retain exact cleanup approvals across Abort retries, and suppress approval objects for empty inventories. Add cross-repository zero-removal and ignored-harvest retry regressions, plus document the fail-closed and retry contracts. --- README.md | 17 +++-- scripts/abort.sh | 57 ++++++++++----- scripts/lib.sh | 40 +++++++++-- tests/run-finalization.test.mjs | 118 ++++++++++++++++++++++++++++++++ 4 files changed, 201 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index ee727d0..f448722 100644 --- a/README.md +++ b/README.md @@ -184,10 +184,13 @@ forwards no environment): | Prune | `scripts/prune.sh` — a zero-TTY action, dry run by default, env-gated per resource class | Harvest, Status, and Abort resolve the active generation by physical Git -repository, not by the current Herdr workspace filename. Reopening the same -repository under another workspace ID therefore reaches the same run. The -resolution occurs under the repository lock and refuses rather than choosing -when multiple live manifests or an invalid active index exist. +repository, not by the current Herdr workspace filename. An explicit +`HERDR_PLUGIN_CONTEXT_JSON.workspace_cwd` is authoritative; any legacy +workspace-named manifest must match the exact generation selected under that +repository's lock or the operation refuses without mutation. Reopening the +same repository under another workspace ID therefore reaches the same run. +The resolution refuses rather than choosing when multiple live manifests, a +conflicting workspace hint, or an invalid active index exists. #### Scripted ignored-file cleanup @@ -220,8 +223,10 @@ generation, digest, symlink, duplicate owner, stale approval, or already-used operation refuses removal. If several resources contain ignored data, repeat preview/apply for each emitted approval. `harvest-step.sh archive` uses the same output protocol (exit 37 when approval is required); detached merge -cleanup may emit an approval after the base swap lands, retains its journaled -worktree, and finishes on `harvest-step.sh resume` with that approval. +cleanup may emit an approval after the base swap lands and retains its exact +journaled worktree until approved. Retry `harvest-step.sh resume` for a +Harvest cleanup, or retry Abort with the exact approval Abort emitted; only a +verified removal clears the journal and permits terminal slot/run archival. ### Keybinding diff --git a/scripts/abort.sh b/scripts/abort.sh index de89c4c..9d60550 100755 --- a/scripts/abort.sh +++ b/scripts/abort.sh @@ -185,23 +185,26 @@ done <<<"$(report_only_discovery 2>/dev/null || true)" # --- (2) Slot worktrees + (4) harvest worktrees ------------------------------ -# One line per non-archived slot (pending/running/failed/settled/merged/ -# skipped — abort over-approximates; the archived filter is the only one). +# One line per slot that still has ordinary resources to reap, plus archived +# rows carrying an exact detached journal from an older interrupted cleanup. +# New cleanups defer terminal slot archival until detached generation removal +# succeeds, but retaining the archived+journal case keeps old recovery state +# reachable instead of demoting it to an unauthenticated leftover. SLOT_LINES="$(printf '%s' "$DOC" | node -e ' let d = ""; process.stdin.on("data", (c) => (d += c)).on("end", () => { for (const s of JSON.parse(d).slots || []) { - if (s.status === "archived") continue; const j = s.journal || {}; - console.log([s.slot, s.branch ?? "", s.path ?? "", s.workspace_id ?? "", - j.locus ?? "", j.merge_commit_sha ?? "", j.worktree ?? "", - JSON.stringify(s.journal ?? null)].join("\x1f")); + if (s.status === "archived" && j.locus !== "detached") continue; + console.log([s.slot, s.status ?? "", s.branch ?? "", s.path ?? "", + s.workspace_id ?? "", j.locus ?? "", j.merge_commit_sha ?? "", + j.worktree ?? "", JSON.stringify(s.journal ?? null)].join("\x1f")); } }); ')" reap_slot_worktree() { - local slot="$1" branch="$2" wtpath="$3" wsid="$4" wt="" herdr_ok=0 why operation inventory count used rechecked before_digest after_digest + local slot="$1" branch="$2" wtpath="$3" wsid="$4" defer_archive="$5" wt="" herdr_ok=0 why operation inventory count used rechecked before_digest after_digest # Ownership before destruction, same shared verifier harvest-step.sh's # read_slot uses (lib.sh) — the third instance of the drift pattern in # docs/solutions/best-practices/cross-script-invariant-drift.md, closed in @@ -234,8 +237,11 @@ reap_slot_worktree() { git -C "$REPO_ROOT" worktree prune 2>/dev/null || true echo "herdr-swarm: slot $slot: worktree already gone." gone=$((gone + 1)) - manifest_update_slot "$slot" '{"status":"archived"}' || - note_failure "slot $slot was already gone but could not be marked archived" + slot_reaped=1 + if [ "$defer_archive" != "yes" ]; then + manifest_update_slot "$slot" '{"status":"archived"}' || + note_failure "slot $slot was already gone but could not be marked archived" + fi return 0 fi # Re-verify the RESOLVED path, whichever route produced it. The @@ -320,9 +326,12 @@ reap_slot_worktree() { fi fi removed=$((removed + 1)) + slot_reaped=1 echo "herdr-swarm: slot $slot: removed worktree $wt (branch $branch kept — prune deletes merged branches)." - manifest_update_slot "$slot" '{"status":"archived"}' || - note_failure "slot $slot was removed but could not be marked archived" + if [ "$defer_archive" != "yes" ]; then + manifest_update_slot "$slot" '{"status":"archived"}' || + note_failure "slot $slot was removed but could not be marked archived" + fi } handled_hwts=" " # journaled harvest worktrees, so the leftover glob below skips them @@ -335,14 +344,14 @@ harvest_wt_in_conflict() { [ -n "$(git -C "$1" ls-files -u 2>/dev/null)" ] } reap_harvest_worktree() { - local slot="$1" jmsha="$2" jwt="$3" journal="$4" cur cleanup_rc=0 + local slot="$1" jmsha="$2" jwt="$3" journal="$4" archive_after="$5" cur cleanup_rc=0 [ -n "$jwt" ] || return 0 handled_hwts="$handled_hwts$jwt " if [ ! -d "$jwt" ]; then # A crash may occur after verified removal but before journal clear. No # deletion is attempted; settle the exact slot update strictly. - if [ -n "$jmsha" ]; then - manifest_update_slot "$slot" '{"status":"archived","journal":null}' || note_failure "slot $slot harvest resource was absent but its landed journal could not be cleared" + if [ "$archive_after" = "1" ]; then + manifest_update_slot "$slot" '{"status":"archived","journal":null}' || note_failure "slot $slot harvest resource was absent but its journal/final status could not be settled" else manifest_update_slot "$slot" '{"journal":null}' || note_failure "slot $slot harvest resource was absent but its journal could not be cleared" fi @@ -376,21 +385,31 @@ reap_harvest_worktree() { fi removed=$((removed + 1)) echo "herdr-swarm: removed harvest worktree $jwt." - if [ -n "$jmsha" ]; then - manifest_update_slot "$slot" '{"status":"archived","journal":null}' || note_failure "slot $slot harvest worktree was removed but landed journal update failed" + if [ "$archive_after" = "1" ]; then + manifest_update_slot "$slot" '{"status":"archived","journal":null}' || note_failure "slot $slot harvest worktree was removed but its journal/final status update failed" else manifest_update_slot "$slot" '{"journal":null}' || note_failure "slot $slot harvest worktree was removed but journal clear failed" fi } -while IFS="$US" read -r slot branch wtpath wsid jlocus jmsha jwt journal; do +while IFS="$US" read -r slot slot_status branch wtpath wsid jlocus jmsha jwt journal; do [ -n "$slot" ] || continue - reap_slot_worktree "$slot" "$branch" "$wtpath" "$wsid" + slot_reaped=0 + defer_archive=no + [ "$jlocus" = "detached" ] && defer_archive=yes + if [ "$slot_status" = "archived" ]; then + # Recovery compatibility for the old broken transition: the ordinary + # slot resource was already declared gone, but its exact harvest journal + # remains authoritative and must stay reachable. + slot_reaped=1 + else + reap_slot_worktree "$slot" "$branch" "$wtpath" "$wsid" "$defer_archive" + fi # Only the detached locus owns a plugin worktree; the user-tree locus # journals the USER's checkout, which abort never touches (MERGE_HEAD # detection below is the only user-tree interaction, and it is read-only). if [ "$jlocus" = "detached" ]; then - reap_harvest_worktree "$slot" "$jmsha" "$jwt" "$journal" + reap_harvest_worktree "$slot" "$jmsha" "$jwt" "$journal" "$slot_reaped" fi done <<<"$SLOT_LINES" diff --git a/scripts/lib.sh b/scripts/lib.sh index c156d8b..28e1650 100644 --- a/scripts/lib.sh +++ b/scripts/lib.sh @@ -199,11 +199,18 @@ bookkeeping_scan() { safety_state scan "$(state_dir)" "$1" } -# Resolve the physical repository before selecting a live generation. The -# workspace-named manifest is only a discovery hint; repository aliases fall -# back to Herdr's workspace context/cwd, and neither route authorizes mutation. +# Resolve the physical repository before selecting a live generation. An +# explicit Herdr workspace context is authoritative and must never be +# redirected by a stale workspace-named manifest. Without explicit context, +# the legacy manifest remains a discovery hint for reopened workspaces; cwd is +# the final fallback. The selected hint is validated again under the physical +# repository lock by bind_live_manifest_locked before it can authorize use. discover_live_repo() { local hint repo="" + if [ -n "${HERDR_PLUGIN_CONTEXT_JSON:-}" ]; then + resolve_repo_root + return $? + fi hint="$(state_dir)/run-$(ws_id).json" if [ -f "$hint" ] && [ ! -L "$hint" ]; then repo="$(node -e ' @@ -235,9 +242,28 @@ resolve_live_manifest_locked() { ' } +# A workspace-named legacy manifest may participate only when it is the exact +# generation selected by the locked repository scan. A foreign/stale hint is +# never ignored in favor of a convenient candidate: fail closed before any +# pane, Git, manifest, or archive mutation. +validate_workspace_manifest_hint_locked() { + local selected="$1" hint + hint="$(state_dir)/run-$(ws_id).json" + if [ -e "$hint" ] || [ -L "$hint" ]; then + if [ -L "$hint" ] || [ ! -f "$hint" ] || ! node -e ' + const path=require("path"); + process.exit(path.resolve(process.argv[1])===path.resolve(process.argv[2]) ? 0 : 1); + ' "$hint" "$selected"; then + echo "herdr-swarm: bookkeeping_unknown: workspace manifest hint $hint does not match the locked live generation $selected" >&2 + return "$MANIFEST_EC_CORRUPT" + fi + fi +} + bind_live_manifest_locked() { local selected selected="$(resolve_live_manifest_locked "$1")" || return $? + validate_workspace_manifest_hint_locked "$selected" || return $? HERDR_SWARM_MANIFEST_PATH="$selected" export HERDR_SWARM_MANIFEST_PATH } @@ -378,9 +404,11 @@ print_cleanup_inventory() { const i=JSON.parse(d); console.log("cleanup_operation\t" + i.operation_id); console.log("cleanup_digest\t" + i.digest); - const approval={approved:true}; - for (const k of ["resource_type","repo_key","git_common_dir","run_id","slot","worktree","generation","head","operation_id","digest"]) approval[k]=i[k]; - console.log("cleanup_approval\t" + JSON.stringify(approval)); + if (i.count > 0) { + const approval={approved:true}; + for (const k of ["resource_type","repo_key","git_common_dir","run_id","slot","worktree","generation","head","operation_id","digest"]) approval[k]=i[k]; + console.log("cleanup_approval\t" + JSON.stringify(approval)); + } for (const p of i.paths_display) console.log("ignored_json\t" + JSON.stringify(p)); }); ' diff --git a/tests/run-finalization.test.mjs b/tests/run-finalization.test.mjs index 28a5c95..f4ce9a5 100644 --- a/tests/run-finalization.test.mjs +++ b/tests/run-finalization.test.mjs @@ -188,6 +188,63 @@ test("Abort removes a landed exact harvest generation and archives every slot", assert.ok(run.archived().slots.every((slot) => slot.status === "archived")); }); +test("Abort keeps an ignored harvest journal reachable, then exact approval removes and archives once", () => { + const run = mkRun(); + fs.writeFileSync(path.join(run.wt(1), "feature.txt"), "work\n"); + h.git(run.wt(1), "add", "feature.txt"); + h.git(run.wt(1), "commit", "-q", "-m", "feature"); + h.git(run.repo, "checkout", "-q", "-b", "elsewhere"); + let result = step(run, "merge", [1, run.fork], { + HERDR_SWARM_TEST_DIE_BEFORE_SWAP: "1", + }); + assert.equal(result.status, 99, `${result.stdout}\n${result.stderr}`); + const journal = run.slotRow(1).journal; + h.git( + run.repo, + "update-ref", + "refs/heads/main", + journal.merge_commit_sha, + run.fork, + ); + appendIgnore(run, "*.secret"); + fs.writeFileSync(path.join(journal.worktree, "precious.secret"), "keep\n"); + + result = abort(run); + assert.equal(result.status, 4, `${result.stdout}\n${result.stderr}`); + assert.equal(fs.existsSync(run.wt(1)), false, "ordinary slot was reaped"); + assert.ok(fs.existsSync(journal.worktree), "ignored harvest generation kept"); + const keptRow = run.slotRow(1); + assert.notEqual( + keptRow.status, + "archived", + "slot is not terminal while its exact harvest journal remains", + ); + assert.deepEqual(keptRow.journal, journal); + const approvals = result.stdout + .split("\n") + .filter((line) => line.startsWith("cleanup_approval\t")); + assert.equal(approvals.length, 1, "zero-count slot inventory emits no approval"); + const approval = approvals[0].slice("cleanup_approval\t".length); + assert.equal(JSON.parse(approval).resource_type, "harvest"); + + result = abort(run, { HERDR_SWARM_CLEANUP_APPROVAL: approval }); + assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`); + assert.equal(fs.existsSync(journal.worktree), false); + assert.equal(fs.existsSync(path.join(journal.worktree, "precious.secret")), false); + assert.equal(fs.existsSync(path.join(run.sdir, "run-w9.json")), false); + const archive = run.archived(); + assert.equal(archive.slots[0].status, "archived"); + assert.equal(archive.slots[0].journal, null); + assert.equal(archive.completion_events.length, 1); + assert.equal( + fs + .readdirSync(run.sdir) + .filter((name) => name === `archived-${run.runId}.json`).length, + 1, + "run is archived exactly once", + ); +}); + test("Abort exits nonzero when a removed exact harvest generation cannot clear its journal", () => { const run = mkRun(); fs.writeFileSync(path.join(run.wt(1), "feature.txt"), "work\n"); @@ -288,6 +345,67 @@ test("Harvest, Status, and Abort resolve the exact live run from a second worksp assert.ok(fs.existsSync(path.join(run.sdir, `archived-${run.runId}.json`))); }); +test("explicit repository context refuses a conflicting workspace hint with zero removal", () => { + const foreign = mkRun({ prefix: "r-foreign" }); + const current = mkRun({ prefix: "r-current" }); + const currentManifest = path.join(current.sdir, "run-w9.json"); + const currentHint = path.join(foreign.sdir, "run-w2.json"); + fs.copyFileSync(currentManifest, currentHint); + const env = { + ...foreign.env, + HERDR_PLUGIN_CONTEXT_JSON: JSON.stringify({ workspace_cwd: current.repo }), + }; + + const harvest = spawnSync( + "bash", + [path.join(repoRoot, "scripts/harvest-step.sh"), "archive", "1"], + { cwd: current.repo, env, encoding: "utf8" }, + ); + assert.equal(harvest.status, 3, `${harvest.stdout}\n${harvest.stderr}`); + assert.match(harvest.stderr, /workspace manifest hint|bookkeeping_unknown/); + + const status = spawnSync( + "bash", + [path.join(repoRoot, "scripts/status-pane.sh")], + { + cwd: current.repo, + env: { ...env, HERDR_SWARM_LINGER_SECS: "1" }, + encoding: "utf8", + timeout: 3000, + }, + ); + assert.doesNotMatch(status.stdout, new RegExp(`run:${foreign.runId}`)); + assert.doesNotMatch(status.stdout, new RegExp(`run:${current.runId}`)); + assert.match(status.stdout, /no single validated active run/); + + const result = spawnSync("bash", [path.join(repoRoot, "scripts/abort.sh")], { + cwd: current.repo, + env, + encoding: "utf8", + }); + assert.equal(result.status, 3, `${result.stdout}\n${result.stderr}`); + for (const [label, run] of [ + ["current", current], + ["foreign", foreign], + ]) { + assert.ok(fs.existsSync(run.wt(1)), `${label} worktree survives`); + } + assert.ok(fs.existsSync(currentHint), "current live generation survives"); + assert.ok( + fs.existsSync(path.join(foreign.sdir, "run-w9.json")), + "foreign workspace hint survives", + ); + assert.equal( + fs.existsSync(path.join(foreign.sdir, `archived-${foreign.runId}.json`)), + false, + ); + assert.equal( + fs.existsSync(path.join(foreign.sdir, `archived-${current.runId}.json`)), + false, + ); + assert.doesNotMatch(h.log(), /worktree remove/); +}); + test("repository identity makes workspace aliases share one lock and discover the same active run", () => { const run = mkRun(); const source = path.join(run.sdir, "run-w9.json");