Skip to content
Closed
Show file tree
Hide file tree
Changes from 7 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
10 changes: 10 additions & 0 deletions bin/fm-spawn.sh
Original file line number Diff line number Diff line change
Expand Up @@ -2259,6 +2259,16 @@ elif [ "$KIND" != secondmate ] && [ "$BACKEND" != orca ]; then
fi

validate_spawn_worktree "treehouse get" "$T"

if [ -x "$SCRIPT_DIR/fm-treehouse-pool-sweep.sh" ]; then
sweep_rc=0
FM_CONFIG_OVERRIDE="$CONFIG" \
"$SCRIPT_DIR/fm-treehouse-pool-sweep.sh" "$WT" || sweep_rc=$?
if [ "$sweep_rc" -ne 0 ]; then
echo "error: worktree pool sweep refused worktree $WT (exit $sweep_rc); inspect unsafe state or disable sweep in config/worktree-pool-sweep" >&2
exit 1
fi
fi
fi
if [ "$RELAUNCH" -eq 0 ] && [ "$KIND" != secondmate ]; then
freshen_spawn_worktree_base "$WT" || exit 1
Expand Down
197 changes: 197 additions & 0 deletions bin/fm-treehouse-pool-sweep.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
#!/usr/bin/env bash
# bin/fm-treehouse-pool-sweep.sh - Pre-acquire worktree pool safety sweep.
#
# This is a MITIGATION (not a fix) for the worktree reuse incident. It inspects
# pooled worktrees before acquisition and refuses to request one when unsafe pool
# state is observed. The upstream invariant is: "No consumer can reuse a worktree
# whose state is unsafe." This mitigation can only observe and refuse; it cannot
# enforce the invariant across all consumers.
#
# Two structural gaps this mitigation cannot close:
# 1. A direct `treehouse get` by anything other than firstmate bypasses this sweep.
# 2. Another firstmate home can race between sweep and acquire.
#
# Usage: fm-treehouse-pool-sweep.sh <worktree-path>
# Exit codes:
# 0 - Worktree is safe to acquire (or sweep is disabled)
# 1 - Worktree is unsafe: dirty
# 2 - Worktree is unsafe: HEAD contains commits not reachable from durable refs
# 3 - Worktree is unsafe: HEAD covered only by remote-tracking refs (prunable)
# 4 - Worktree does not exist
# 64 - Usage error (no worktree path given)
set -euo pipefail

usage() {
cat <<EOF
fm-treehouse-pool-sweep.sh - Pre-acquire worktree pool safety sweep

This is a MITIGATION for the worktree reuse incident (not a fix for the
underlying invariant). It inspects pooled worktrees before acquisition and
refuses to request one when unsafe pool state is observed.

Usage: fm-treehouse-pool-sweep.sh <worktree-path>

The sweep checks two conditions and refuses on either:
1. Dirty worktree: tracked modifications, staged changes, or untracked
non-ignored files.
2. HEAD contains at least one commit not reachable from an approved durable ref:
- refs/heads/* (local branches)
- refs/tags/* (tags)
- refs/firstmate/rescue/* (reserved rescue namespace)

Reflogs are NOT refs. A commit reachable only from a reflog is unreferenced.

For refs/remotes/*: they are counted for reachability so an ordinary freshly-
checked-out pool worktree is not falsely refused, but the case where HEAD's
commits are covered ONLY by remote-tracking refs (and no local head or tag) is
classified as unsafe.

Exit codes:
0 - Worktree is safe to acquire (or sweep is disabled)
1 - Worktree is unsafe: dirty
2 - Worktree is unsafe: HEAD contains commits not reachable from durable refs
3 - Worktree is unsafe: HEAD covered only by remote-tracking refs (prunable)
4 - Worktree does not exist
64 - Usage error (no worktree path given)

Activation:
The sweep is disabled by default. To enable, create:
\$FM_HOME/config/worktree-pool-sweep
containing "on" (or any non-empty value other than "off").
The config dir is \$FM_CONFIG_OVERRIDE when set, otherwise \$FM_HOME/config,
and \$FM_HOME defaults to the firstmate repo root - the same resolution every
other firstmate script uses, so an enable written for one home applies to
that home only.
A missing file, an empty file, or the value "off" leaves the sweep disabled.

This mitigation is distinct from the upstream Treehouse invariant:
- MITIGATION: "Firstmate refuses to request a worktree when it observes unsafe pool state."
- INVARIANT: "No consumer can reuse a worktree whose state is unsafe."

Structural gaps this mitigation cannot close:
1. A direct treehouse get by anything other than firstmate bypasses the sweep.
2. Another firstmate home can race between sweep and acquire:

T1 Firstmate A sweeps -> safe
T2 Firstmate B acquires/modifies the same pool
T3 Firstmate A calls treehouse get

This race can cause the worktree to be unsafe when Firstmate A uses it.
The eventual Treehouse fix must kill this atomically at allocation time.
EOF
}

if [ "${1:-}" = "-h" ] || [ "${1:-}" = "--help" ]; then
usage
exit 0
fi

WT="${1:-}"
if [ -z "$WT" ]; then
echo "error: worktree path required" >&2
usage >&2
exit 64
fi

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd "$SCRIPT_DIR/.." && pwd)}"
FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}"
CONFIG_DIR="${FM_CONFIG_OVERRIDE:-$FM_HOME/config}"
SWEEP_CONFIG="$CONFIG_DIR/worktree-pool-sweep"

is_sweep_enabled() {
if [ -f "$SWEEP_CONFIG" ]; then
local val
val=$(cat "$SWEEP_CONFIG" 2>/dev/null | tr -d '[:space:]')
[ -n "$val" ] && [ "$val" != "off" ]
else
return 1
fi
}

if ! is_sweep_enabled; then
exit 0
fi

if [ ! -d "$WT" ]; then
exit 4
fi

is_dirty() {
local wt=$1
git -C "$wt" update-index -q --ignore-submodules --refresh >/dev/null 2>&1 || true
if ! git -C "$wt" diff-index --quiet --ignore-submodules HEAD 2>/dev/null; then
return 0
fi
if ! git -C "$wt" diff-index --quiet --ignore-submodules --cached HEAD 2>/dev/null; then
return 0
fi
local untracked
untracked=$(git -C "$wt" ls-files --others --exclude-standard 2>/dev/null)
if [ -n "$untracked" ]; then
return 0
fi
return 1
}

count_refs() {
local wt=$1 pattern=$2
git -C "$wt" for-each-ref --format='%(refname)' "$pattern" 2>/dev/null | wc -l
}

has_durable_refs() {
local wt=$1
local count
count=$(count_refs "$wt" 'refs/heads/')
count=$((count + $(count_refs "$wt" 'refs/tags/')))
count=$((count + $(count_refs "$wt" 'refs/firstmate/rescue/')))
[ "$count" -gt 0 ]
}

head_covered_by_remotes() {
local wt=$1
local unremoted
unremoted=$(git -C "$wt" rev-list --count HEAD --not --remotes 2>/dev/null) || return 1
case "$unremoted" in
'' | *[!0-9]*) return 1 ;;
esac
[ "$unremoted" -eq 0 ]
}

check_head_reachable() {
local wt=$1
local unique_count
if ! has_durable_refs "$wt"; then
if head_covered_by_remotes "$wt"; then
return 3
fi
return 2
fi
unique_count=$(git -C "$wt" rev-list --count HEAD --not --branches --tags \
--glob='refs/firstmate/rescue/*' 2>/dev/null) || return 2

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve commits on the checked-out branch

Captain, when an acquired pool worktree is still checked out on a local task branch with unique committed work, --branches subtracts that branch and the sweep returns safe; freshen_spawn_worktree_base then runs reset --hard origin/<default> while still on that branch, moving its only durable ref and leaving those commits reachable only through the reflog. Exclude the currently checked-out branch from this reachability proof or detach without deleting its ref before resetting, so this mitigation cannot discard unlanded commits.

AGENTS.md reference: AGENTS.md:L30-L32

Useful? React with 👍 / 👎.

case "$unique_count" in
'' | *[!0-9]*) return 2 ;;
esac
if [ "$unique_count" -gt 0 ]; then
return 2
fi
return 0
}

if is_dirty "$WT"; then
echo "unsafe: dirty worktree at $WT" >&2
exit 1
fi

rc=0
check_head_reachable "$WT" || rc=$?

if [ $rc -eq 2 ]; then
echo "unsafe: HEAD contains commits not reachable from durable refs in $WT" >&2
exit 2
elif [ $rc -eq 3 ]; then
echo "unsafe: HEAD commits covered only by remote-tracking refs (prunable) in $WT" >&2
exit 3
fi

exit 0
16 changes: 16 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,22 @@ A Secondmate on a remote route is covered the same way: the primary resolves and
The presence flag is session-scoped enablement, so it transfers at launch and is left unchanged by live convergence into a running home.
See [`trace-context.md`](trace-context.md) for carrier semantics, supported routes, the manual fleet-restart requirement, the session boundary, and safety limits; `bin/fm-trace-context-lib.sh`'s header owns the exact mechanics, and [`verification/trace-context.md`](verification/trace-context.md) records repeatable evidence.

## Worktree pool sweep (config/worktree-pool-sweep)

The optional local, gitignored `config/worktree-pool-sweep` file enables the pre-acquire worktree pool safety sweep, which is shipped deactivated.
A missing file, an empty file, or the value `off` leaves the sweep inert and acquisition unchanged; any other non-empty value activates it.
`config/` resolves the same way as for every other script - `FM_CONFIG_OVERRIDE` when set, otherwise `$FM_HOME/config` - so the enable is per firstmate home, and `bin/fm-spawn.sh` passes its own resolved config dir down to the sweep.

When active, `bin/fm-spawn.sh` runs `bin/fm-treehouse-pool-sweep.sh` against the pooled worktree before the spawn uses it and refuses on either unsafe condition:

- Dirty worktree: tracked modifications, staged changes, or untracked non-ignored files.
- HEAD contains commits not reachable from an approved durable ref: local branches (`refs/heads/*`), tags (`refs/tags/*`), or rescue refs (`refs/firstmate/rescue/*`). Reflogs are not refs, so a commit reachable only from a reflog is unreferenced.

A refusal aborts the spawn with an error naming the worktree, the sweep exit code, and this config file; the exit codes and the remote-tracking-ref reachability rules are owned by `bin/fm-treehouse-pool-sweep.sh`'s header (`bin/fm-treehouse-pool-sweep.sh --help`).

This is a MITIGATION for the worktree reuse incident, not a fix for the underlying Treehouse invariant that no consumer can reuse an unsafe worktree.
Two structural gaps it cannot close: a direct `treehouse get` by anything other than firstmate bypasses the sweep, and another firstmate home can race between sweep and acquire.

## Gate defaults (.no-mistakes.yaml)

The tracked `.no-mistakes.yaml` sets `test.evidence.store_in_repo: true` and pins `commands.lint` to `bin/fm-lint.sh` so local lint matches CI.
Expand Down
Loading
Loading