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/README.md b/README.md index 81895ca..f448722 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,57 @@ 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. 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 + +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 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 ```toml @@ -255,9 +305,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/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..9d60550 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,33 +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 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 - +# 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)" ;; @@ -78,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 @@ -108,13 +108,47 @@ 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 +# --- 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 + 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 +fi + # --- (1) Panes: tracked records first, then the label sweep ------------------ closed_ids=" " # space-delimited seen-set: the pidfile pane usually reappears in the sweep @@ -151,22 +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 ?? ""].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 + 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 @@ -199,7 +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"}' 2>/dev/null || true + 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 @@ -218,6 +260,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. @@ -243,8 +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"}' 2>/dev/null || true + 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 @@ -257,85 +344,87 @@ 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" 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 [ "$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 + 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 [ "$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; 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" + reap_harvest_worktree "$slot" "$jmsha" "$jwt" "$journal" "$slot_reaped" 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 --------------------- @@ -355,9 +444,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 @@ -378,6 +468,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" @@ -386,20 +481,18 @@ 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 +# 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 diff --git a/scripts/check-manifest.mjs b/scripts/check-manifest.mjs new file mode 100755 index 0000000..2f682cf --- /dev/null +++ b/scripts/check-manifest.mjs @@ -0,0 +1,205 @@ +#!/usr/bin/env node +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const COMMAND_SECTIONS = ["build", "startup", "actions", "panes", "events"]; + +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 document = {}; + let current = document; + try { + 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 is not valid TOML: ${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/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-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 d57823f..353e8cd 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,32 +65,32 @@ 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 -# 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 # run_id is interpolated into the harvest worktree path ($(state_dir)/harvest- @@ -173,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() { @@ -261,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. @@ -276,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" } @@ -489,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 @@ -516,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 @@ -599,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 @@ -635,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")); } }); ')" @@ -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() { @@ -739,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 @@ -753,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 019e6ef..28e1650 100644 --- a/scripts/lib.sh +++ b/scripts/lib.sh @@ -162,7 +162,269 @@ 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" +} + +# 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 ' + 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"); + }); + ' +} + +# 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 +} + +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_resource_binding() { + local repo_root="$1" run_id="$2" slot="$3" wt="$4" identity physical head + identity="$(repo_identity_json "$repo_root")" || return 1 + 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() { + 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); + 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)); + }); + ' +} + +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 @@ -693,7 +955,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 @@ -740,7 +1006,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..32136fc 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,106 @@ remove_exclude_pattern() { return 0 } +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" 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 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 + 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) + 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 + [ -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..f0a37ab --- /dev/null +++ b/scripts/safety-state.mjs @@ -0,0 +1,488 @@ +#!/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.error) { + throw new Error(`git ${args.join(" ")} could not run for ${repo}: ${result.error.message}`); + } + if (result.status !== 0) { + throw new Error( + `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 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"); + 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 (!/^[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); + } 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 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 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); + } 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 doc; + try { + 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 (identity.repo_key !== target.repo_key) continue; + const previous = runIds.get(doc.run_id); + if (previous) + errors.push( + `${file}: duplicate run_id ${doc.run_id} also appears in ${previous}`, + ); + else runIds.set(doc.run_id, file); + const item = { + path: file, + 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 (isArchive) archived.push(item); + else live.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, + 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) { + 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-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 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 "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, 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 !== binding.repo_key || + identity.git_common_dir !== binding.git_common_dir + ) + fail("cleanup inventory repo identity changed"); + const physical = fs.realpathSync(binding.worktree); + process.stdout.write( + `${JSON.stringify(canonicalInventory(physical, { ...binding, worktree: physical, operation_id: operationId }))}\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 [ + "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 (!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); + 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; + } + default: + fail(`unknown safety-state command '${command ?? ""}'`); + } +} catch (error) { + fail(error.message); +} 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 new file mode 100644 index 0000000..c0b0c0e --- /dev/null +++ b/tests/check-manifest.test.mjs @@ -0,0 +1,103 @@ +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"; +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 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 = "'); + + 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"), + ), + ); +}); 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/fanout.test.mjs b/tests/fanout.test.mjs index 0e21912..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, @@ -418,7 +488,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/); @@ -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); }); 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..ff2e959 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}$/); @@ -539,11 +580,83 @@ 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"); 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 +666,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 +689,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 +720,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 +767,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 +789,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"); @@ -716,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); @@ -773,10 +980,13 @@ 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", () => { +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); @@ -793,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"); }); @@ -824,14 +1039,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 +1059,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 +1149,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 +1182,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 +1211,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 +1219,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 +1246,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 +1271,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 +1323,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 +1479,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 +1519,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 +1551,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 +1579,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 +1642,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/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/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/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 new file mode 100644 index 0000000..f4ce9a5 --- /dev/null +++ b/tests/run-finalization.test.mjs @@ -0,0 +1,580 @@ +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("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 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"); + 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]); + 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("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("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"); + 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("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" }); + 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, + ); + } +});