Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 14 additions & 6 deletions bin/fm-backend.sh
Original file line number Diff line number Diff line change
Expand Up @@ -593,41 +593,49 @@ fm_backend_expected_label_of_selector() { # <raw-target> <state-dir>
# boundaries keep runtime dispatch from importing all five adapter ASTs into
# every dispatcher consumer while preserving the runtime source operations.
fm_backend_source() { # <name>
local name=$1
local name=$1 adapter
fm_backend_validate "$name" || return 1
adapter="$FM_BACKEND_LIB_DIR/backends/$name.sh"
# A missing file passed directly to `.` can terminate an errexit shell before
# the caller handles failure, so every first source proves readability first.
case "$name" in
tmux)
if [ -z "${_FM_BACKEND_TMUX_SOURCED:-}" ]; then
[ -r "$adapter" ] || return 1
# shellcheck source=/dev/null
. "$FM_BACKEND_LIB_DIR/backends/tmux.sh" || return 1
. "$adapter" || return 1
_FM_BACKEND_TMUX_SOURCED=1
fi
;;
herdr)
if [ -z "${_FM_BACKEND_HERDR_SOURCED:-}" ]; then
[ -r "$adapter" ] || return 1
# shellcheck source=/dev/null
. "$FM_BACKEND_LIB_DIR/backends/herdr.sh" || return 1
. "$adapter" || return 1
_FM_BACKEND_HERDR_SOURCED=1
fi
;;
zellij)
if [ -z "${_FM_BACKEND_ZELLIJ_SOURCED:-}" ]; then
[ -r "$adapter" ] || return 1
# shellcheck source=/dev/null
. "$FM_BACKEND_LIB_DIR/backends/zellij.sh" || return 1
. "$adapter" || return 1
_FM_BACKEND_ZELLIJ_SOURCED=1
fi
;;
orca)
if [ -z "${_FM_BACKEND_ORCA_SOURCED:-}" ]; then
[ -r "$adapter" ] || return 1
# shellcheck source=/dev/null
. "$FM_BACKEND_LIB_DIR/backends/orca.sh" || return 1
. "$adapter" || return 1
_FM_BACKEND_ORCA_SOURCED=1
fi
;;
cmux)
if [ -z "${_FM_BACKEND_CMUX_SOURCED:-}" ]; then
[ -r "$adapter" ] || return 1
# shellcheck source=/dev/null
. "$FM_BACKEND_LIB_DIR/backends/cmux.sh" || return 1
. "$adapter" || return 1
_FM_BACKEND_CMUX_SOURCED=1
fi
;;
Expand Down
75 changes: 75 additions & 0 deletions bin/fm-branch-merge-lib.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
#!/usr/bin/env bash
# Shared "is this branch provably safe to delete?" decision procedure.
#
# ONE owner for the merged-branch proof that fm-fleet-sync.sh's periodic
# fm/<task-id> sweep relies on (fm-teardown.sh's own inline branch-drop stays
# on its existing, stronger, GitHub-aware landedness proof - see that
# script's header - and is not re-derived here). A branch is provably safe to
# delete iff ALL of the following hold:
# 1. it exists as a local branch in the repo;
# 2. it is not currently checked out in ANY worktree of that repo, linked or
# main - deleting a checked-out branch would fail anyway, but checking
# first keeps this a pure decision procedure with no destructive side
# effect on the caller's own probing;
# 3. its tip is an ancestor of the given "merged into" ref (a clean
# fast-forward, or any non-squash merge, already contains it - git's own
# `branch -d` can verify this itself).
# Upstream "[gone]" is deliberately excluded: deletion of a remote ref does
# not prove that its work landed. The separately-owned prune_gone_branches
# flow covers squash-merged PR cleanup, while fm-teardown.sh uses its existing
# GitHub-aware landedness check for inline cleanup.
# ANY uncertainty - the branch does not exist, a worktree still has it out, or
# neither proof holds - returns non-zero (NOT safe): fail safe, never force a
# delete this cannot prove. The caller decides what "not safe" means (leave it
# alone and move on, never an error worth failing over).

# fm_branch_worktree_branches <repo>: newline list of branch shortnames
# currently checked out in any worktree of <repo> (the main checkout included).
fm_branch_worktree_branches() {
local repo=$1
git -C "$repo" worktree list --porcelain 2>/dev/null | sed -n 's#^branch refs/heads/##p'
}

# fm_branch_is_safely_merged <repo> <branch> <merged_into_ref> [expected_tip]:
# the proof itself (see header). When <expected_tip> is supplied, it additionally
# proves that is still the branch's current tip, binding a caller's later
# expected-old-value deletion to precisely the tip this proof examined. Never
# inspects or changes anything but refs. Refuses a
# <branch> that IS the "merged into" ref itself as a defensive guard against a
# caller accidentally naming the protected/default branch - every branch is
# trivially its own ancestor, so without this guard that self-comparison would
# otherwise look "safely merged".
fm_branch_is_safely_merged() {
local repo=$1 branch=$2 merged_into=$3 expected_tip=${4:-} tip
[ -n "$branch" ] || return 1
[ "refs/heads/$branch" != "$merged_into" ] || return 1
tip=$(git -C "$repo" rev-parse --verify --quiet "refs/heads/$branch") || return 1
[ -z "$expected_tip" ] || [ "$tip" = "$expected_tip" ] || return 1
fm_branch_worktree_branches "$repo" | grep -Fxq -- "$branch" && return 1
if [ -n "$merged_into" ] \
&& git -C "$repo" merge-base --is-ancestor "$tip" "$merged_into" 2>/dev/null; then
return 0
fi
return 1
}

# fm_branch_delete_if_safely_merged <repo> <branch> <merged_into_ref>: delete
# <branch> in <repo> only when fm_branch_is_safely_merged proves it, printing
# nothing itself (callers report their own outcome). `git branch -d` always
# judges mergedness against its checkout's HEAD, rather than an explicit ref.
# Use a short-lived detached worktree at <merged_into> so its final safe delete
# repeats the SAME proof as the caller, while still letting Git refuse deletion
# if another worktree checked out the branch after our proof. Returns non-zero,
# unchanged, for anything the proof does not cover.
fm_branch_delete_if_safely_merged() {
local repo=$1 branch=$2 merged_into=$3 git_dir prune_worktree delete_status
fm_branch_is_safely_merged "$repo" "$branch" "$merged_into" || return 1
git_dir=$(git -C "$repo" rev-parse --absolute-git-dir 2>/dev/null) || return 1
prune_worktree=$(mktemp -d "$git_dir/fm-branch-prune.XXXXXX") || return 1
rmdir "$prune_worktree" || return 1
git -C "$repo" worktree add --detach -q "$prune_worktree" "$merged_into" || return 1
git -C "$prune_worktree" branch -d -- "$branch" >/dev/null 2>&1
delete_status=$?
git -C "$repo" worktree remove "$prune_worktree" >/dev/null 2>&1 || true
return "$delete_status"
}
47 changes: 47 additions & 0 deletions bin/fm-fleet-sync.sh
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,13 @@
# repository (the firstmate checkout) and be synced under that directory's label.
# Anything else is reported as "skipped: not a clone root" naming the repository
# that would have been touched.
# Every project, local-only or not, also gets an unconditional git-only sweep of
# its own fm/<task-id> branches (prune_merged_fm_branches, backed by the shared
# proof in fm-branch-merge-lib.sh): a backstop for a branch fm-teardown.sh's own
# inline cleanup did not or could not reach - e.g. a local-only fast-forward merge
# whose branch survived past teardown, or a PR merged outside firstmate's own
# flow. It requires explicit FM_FLEET_PRUNE_MERGED=1 authority and is never a
# factor in the STUCK/self-heal decisions above.
# Pruning never deletes the checked-out branch or a branch that still has a
# worktree, so it cannot discard unlanded work; set FM_FLEET_PRUNE=0 to disable it.
# When the fetch fails on an orphaned .git/packed-refs.lock (left by a ref rewrite
Expand All @@ -40,6 +47,8 @@ FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}"
PROJECTS="${FM_PROJECTS_OVERRIDE:-$FM_HOME/projects}"
# shellcheck source=bin/fm-lock-lib.sh
. "$SCRIPT_DIR/fm-lock-lib.sh"
# shellcheck source=bin/fm-branch-merge-lib.sh
. "$SCRIPT_DIR/fm-branch-merge-lib.sh"
# Inert unless FM_TIMING_LOG names a file; only the deferred network stage sets it.
# shellcheck source=bin/fm-timing-lib.sh
. "$SCRIPT_DIR/fm-timing-lib.sh"
Expand Down Expand Up @@ -252,6 +261,38 @@ prune_gone_branches() {
--format='%(refname:short) %(upstream:track)' refs/heads 2>/dev/null)
}

# Backstop for the task-branch cleanup fm-teardown.sh normally does inline:
# delete an fm/<task-id> branch whose tip is provably merged (fm-branch-merge-lib.sh's
# shared ancestor proof - a fast-forward or non-squash merge already contains
# its tip), scoped to firstmate's own fm/* naming so this never touches a branch
# firstmate did not create. A "[gone]" upstream is deliberately not enough to
# prove merge; the separately-owned prune_gone_branches flow covers
# squash-merged PR cleanup. This is the ONLY sweep
# that covers a local-only-mode project (prune_gone_branches above never runs
# for one, since local-only skips the whole remote-backed sync below, and a
# purely local fast-forward merge leaves no "[gone]" upstream to notice) and a
# task whose worktree/teardown ran before the branch could be dropped inline.
# Never the checked-out branch, and never a branch that still has a worktree
# (a live or not-yet-torn-down task) - fm_branch_is_safely_merged enforces
# both. Uses the LOCAL default branch as the merged-into ref, so it runs with
# no fetch and no origin dependency; a remote-backed project whose local
# default is still behind origin simply catches up on the next sweep once the
# fast-forward below advances it. It is off until the captain explicitly sets
# FM_FLEET_PRUNE_MERGED=1.
prune_merged_fm_branches() {
[ "${FM_FLEET_PRUNE_MERGED:-0}" = "1" ] || return 0
local default branch
default=$(default_branch) || return 0
git -C "$PROJ" rev-parse --verify --quiet "refs/heads/$default^{commit}" >/dev/null || return 0
while IFS= read -r branch; do
[ -n "$branch" ] || continue
[ "$branch" != "$default" ] || continue
if fm_branch_delete_if_safely_merged "$PROJ" "$branch" "refs/heads/$default"; then
echo "$label: pruned $branch (merged into $default)"
fi
done < <(git -C "$PROJ" for-each-ref --format='%(refname:short)' 'refs/heads/fm/*' 2>/dev/null)
}

# True when some worktree of $PROJ has $DEFAULT checked out (so we cannot attach
# to it here). The current worktree is detached when this is consulted, so any
# match is necessarily another worktree.
Expand Down Expand Up @@ -324,6 +365,12 @@ sync_project() {
echo "$label: skipped: not a clone root (git would act on $proj_top)"
return 0
fi

# Runs before every mode/remote gate below - git-only, no fetch - so a
# local-only or no-remote project still gets its merged fm/* branches swept,
# which nothing else in this script otherwise does for those projects.
prune_merged_fm_branches || true

mode_line=$("$FM_ROOT/bin/fm-project-mode.sh" "$label" 2>/dev/null || echo "no-mistakes off")
mode=${mode_line%% *}
if [ "$mode" = "local-only" ]; then
Expand Down
37 changes: 25 additions & 12 deletions bin/fm-teardown.sh
Original file line number Diff line number Diff line change
Expand Up @@ -2519,6 +2519,15 @@ remove_secondmate_registry_entry() {
return "$rc"
}

# Prove the complete Herdr adapter surface is available before even the
# generic pre-teardown cleanup begins. The later target preflight acquires
# the presentation lock after the ordinary safety gates, but a missing adapter
# or confirmation helper is already enough to make every later mutation
# unsafe.
if [ "$BACKEND" = herdr ]; then
teardown_herdr_require_prerequisites "$ID" || exit 1
fi

validate_pr_poll_cleanup "$STATE" "$ID" || exit 1

if [ "$KIND" = secondmate ]; then
Expand Down Expand Up @@ -2670,32 +2679,36 @@ if [ "$BACKEND" = herdr ]; then
TEARDOWN_HERDR_PANE=$FM_BACKEND_HERDR_PANE
fi

# Detach and drop the task's own branch in <worktree> if it is currently on
# one (a detached HEAD, or a checkout failure, leaves it untouched). Reaching
# this point already means the earlier landed/discard-work safety checks
# passed (or --force skipped them per the captain's explicit discard), so
# this is the one owner both call sites below use to actually drop the ref -
# never a second, independently-derived copy of the same two lines.
teardown_drop_task_branch() {
local wt=$1 branch
branch=$(git -C "$wt" rev-parse --abbrev-ref HEAD 2>/dev/null || echo HEAD)
[ "$branch" != HEAD ] || return 0
git -C "$wt" checkout --detach -q 2>/dev/null || return 0
git -C "$wt" branch -D "$branch" >/dev/null 2>&1 || true
}

# Best-effort: drop the local task branch so the shared repo does not accumulate refs.
if [ "$BACKEND" = orca ] && [ "$KIND" != secondmate ]; then
if [ "$ORCA_PATH_MATCH_VERIFIED" != 1 ]; then
require_orca_worktree_path_match_if_present "$ORCA_WORKTREE_ID" "$WT" || exit 1
ORCA_PATH_MATCH_VERIFIED=1
fi
if [ -d "$WT" ]; then
branch=$(git -C "$WT" rev-parse --abbrev-ref HEAD 2>/dev/null || echo HEAD)
if [ "$branch" != "HEAD" ]; then
if git -C "$WT" checkout --detach -q 2>/dev/null; then
git -C "$WT" branch -D "$branch" >/dev/null 2>&1 || true
fi
fi
teardown_drop_task_branch "$WT"
rm -f "$WT/.claude/settings.local.json" "$WT/.opencode/plugins/fm-turn-end.js" \
"$WT/.opencode/plugins/fm-busy-state.js" \
"$WT/.fm-grok-turnend" "$WT/.fm-kimi-turnend"
fi
[ -z "$T_ORCA" ] || fm_backend_kill "$BACKEND" "$T" "$(meta_value "$META" zellij_tab_id)" "fm-$ID" 2>/dev/null || true
fm_backend_remove_worktree "$BACKEND" "$ORCA_WORKTREE_ID"
elif [ -d "$WT" ] && [ "$KIND" != secondmate ]; then
branch=$(git -C "$WT" rev-parse --abbrev-ref HEAD 2>/dev/null || echo HEAD)
if [ "$branch" != "HEAD" ]; then
if git -C "$WT" checkout --detach -q 2>/dev/null; then
git -C "$WT" branch -D "$branch" >/dev/null 2>&1 || true
fi
fi
teardown_drop_task_branch "$WT"
# Remove our hook file so a reused pool worktree cannot fire signals for a dead task.
rm -f "$WT/.claude/settings.local.json" "$WT/.opencode/plugins/fm-turn-end.js" \
"$WT/.fm-grok-turnend" "$WT/.fm-kimi-turnend"
Expand Down
5 changes: 3 additions & 2 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -338,8 +338,9 @@ Wake-time refreshes can target a single clone by project name, so the primary ho
Clean default-branch clones fast-forward to `origin/<default>`, and a clean detached HEAD that holds no unique commits is re-attached to the default branch before the same fast-forward path runs.
Dirty clones, non-default branches, detached HEADs with unique commits, diverged defaults, and default branches checked out in another worktree are reported as `STUCK:` with their behind count and left untouched.
Fetches blocked by an orphaned `.git/packed-refs.lock` use bounded retries and remove the lock only when the shared staleness proof can prove it abandoned; [configuration.md](configuration.md#toolchain) owns the recovery details and tuning knobs.
Local-only projects, clones without an origin remote, and fetch failures remain benign skips.
The refresh also prunes local branches whose remote is gone and that no worktree still needs.
Local-only projects, clones without an origin remote, and fetch failures remain benign skips for remote refreshes.
With explicit captain approval through `FM_FLEET_PRUNE_MERGED=1`, fleet sync also prunes firstmate-owned `fm/<task-id>` branches whose tips are already contained in the local default branch and are not checked out in any worktree, so the backstop covers local-only and no-origin projects without discarding unmerged work.
For remote-backed projects, it additionally prunes local branches whose upstream is gone and that no worktree still needs.

## Self-updates stay safe

Expand Down
1 change: 1 addition & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -717,6 +717,7 @@ FM_WORKTREE_WRITE_TIMEOUT=10 # wall-clock seconds that one walk may take,
FM_WATCH_TRIAGE_LOG_MAX_BYTES=262144 # size cap for the watcher's absorbed-wake debug log
FM_FLEET_SYNC_BOOTSTRAP_TIMEOUT= # optional seconds allowed for bootstrap's best-effort clone refresh; unset/blank defaults to max(20, 5 + 3 * origin-backed-project-count)
FM_FLEET_PRUNE=1 # set to 0 to skip pruning local branches whose upstream is gone
FM_FLEET_PRUNE_MERGED=0 # set to 1 only with captain approval to enable the fm/<task-id> backstop sweep for branches whose tips are ancestors of the local default branch, including local-only-mode and no-origin projects
FM_STALE_WORKTREE_LOCK_AGE_SECS=30 # min mtime age before fm-teardown.sh treats a leftover worktree git index.lock as provably stale
FM_TREEHOUSE_RETURN_LOCK_RETRIES=3 # retries after a treehouse return fails on the transient git index.lock signature
FM_TREEHOUSE_RETURN_LOCK_RETRY_WAIT_SECS=1 # seconds fm-teardown.sh waits before each retry after that signature
Expand Down
2 changes: 1 addition & 1 deletion docs/herdr-backend.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ Ordinary non-projected task removal serializes through the same session lock, ap
Task cleanup acquires that session lock before the task's isolated copy is returned, so a contended lock refuses up front while the copy, every durable record, and the endpoint are all intact for a plain rerun.
Forced secondmate cleanup recursively preflights every Herdr child endpoint and acquires every affected named-session lock before mutating any child, then retains each child's durable identity unless that exact pane returns structured not-found after its close.
Durable task records are erased only once the exact pane is confirmed gone through its structured presence: after every close path, only a structured not-found response counts as gone, while a present or unknown result retains every record with a visible, retryable error.
Missing or malformed endpoint identity and missing confirmation machinery are ambiguity, never proof of a gone pane, and refuse record removal the same way.
Missing or malformed endpoint identity, an unreadable Herdr adapter, and missing preflight or confirmation machinery are ambiguity, never proof of a gone pane, and refuse teardown before record removal.
If lock, snapshot, pane identity, or restoration is ambiguous, cleanup warns and preserves the journal for manual inspection.

Recovery is deliberately conservative and presentation-only.
Expand Down
1 change: 1 addition & 0 deletions docs/scripts.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ The shared no-mistakes gate refusal for fleet lifecycle entrypoints is summarize
| `fm-bootstrap.sh` | Detect toolchain and fleet problems, run the locked session-start sweeps, and install approved tools |
| `fm-startup-network.sh` | Run session start's network checks off its blocking path in a bounded detached worker, and publish the result inline or as a wake |
| `fm-fleet-sync.sh` | Refresh project clones with safe fast-forwards, self-heals, `STUCK:` reports, branch pruning, and bounded recovery from an orphaned `.git/packed-refs.lock` |
| `fm-branch-merge-lib.sh` | Shared proof and safe deletion helper for merged firstmate task branches |
| `fm-fleet-snapshot.sh` | Print the read-only structured fleet snapshot JSON (schema `fm-fleet-snapshot.v1`) |
| `fm-fleet-view.sh` | Render the fleet snapshot as a human Markdown view |
| `fm-bearings-snapshot.sh` | Project the fleet snapshot to the compact TOON bearings view; local-only unless `--include-prs` |
Expand Down
20 changes: 20 additions & 0 deletions tests/fm-backend.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -503,6 +503,25 @@ test_backend_source_shell_portable() {
pass "bash: fm_backend_source recognizes known backends and rejects unknown ones"
}

test_backend_source_missing_adapter_returns_to_caller() {
local test_root out status=0
test_root="$TMP_ROOT/source-missing-adapter"
mkdir -p "$test_root/bin"
cp "$ROOT/bin/fm-backend.sh" "$test_root/bin/fm-backend.sh"

out=$(bash -c 'set -eu
. "$1/bin/fm-backend.sh"
trap '\''status=$?; exit "$status"'\'' EXIT
if ! fm_backend_source herdr; then
printf "%s\n" "missing adapter returned to caller"
exit 23
fi' fm-backend-missing-adapter "$test_root" 2>&1) || status=$?
expect_code 23 "$status" "fm_backend_source should return control with failure when its adapter file is absent"
assert_contains "$out" "missing adapter returned to caller" \
"fm_backend_source let a missing adapter terminate the shell before its caller could refuse safely"
pass "fm_backend_source: a missing adapter returns failure to its caller without terminating the shell"
}

test_backend_validate_spawn_accepts_orca() {
local out
fm_backend_validate_spawn tmux 2>/dev/null || fail "fm_backend_validate_spawn should accept tmux"
Expand Down Expand Up @@ -1126,6 +1145,7 @@ test_backend_name_autodetect_notice
test_backend_name_explicit_beats_detection
test_backend_validate_refuses_unknown
test_backend_source_shell_portable
test_backend_source_missing_adapter_returns_to_caller
test_backend_validate_spawn_accepts_orca
test_meta_get_and_backend_of_meta
test_resolve_selector_three_forms
Expand Down
Loading