From 2c9914bc23b8a8a8635687eb4964637dc59497ac Mon Sep 17 00:00:00 2001 From: Dongkeun Lee Date: Fri, 31 Jul 2026 23:13:03 -0400 Subject: [PATCH 1/4] fix: isolate Treehouse pools per home --- .gitignore | 1 + bin/fm-checkout-refresh.sh | 173 +++++++++++++++++++++++++---- bin/fm-teardown.sh | 12 +- docs/configuration.md | 9 +- tests/fm-account-directory.test.sh | 53 ++++++++- tests/fm-checkout-refresh.test.sh | 74 ++++++++++++ tests/fm-teardown.test.sh | 39 +++++++ 7 files changed, 334 insertions(+), 27 deletions(-) diff --git a/.gitignore b/.gitignore index 8ca2ab89b7..041f1d9814 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ state/ data/ .no-mistakes/ .lavish/ +/.treehouse tools/lavish/node_modules/ .fm-secondmate-home .DS_Store diff --git a/bin/fm-checkout-refresh.sh b/bin/fm-checkout-refresh.sh index 91af403fb6..7760cc6234 100755 --- a/bin/fm-checkout-refresh.sh +++ b/bin/fm-checkout-refresh.sh @@ -6,7 +6,8 @@ # This script owns the broader covered-set discovery and the independent cadence: # # - projects/* under the active FM_HOME; -# - backing checkouts discovered from Treehouse's state under ~/.treehouse; +# - backing checkouts discovered from the active home's managed Treehouse pool +# and the legacy user-level pool under ~/.treehouse; # - exact Git worktree roots from `path ` entries in config/checkout-refresh; # - top-level clones under $HOME, plus explicit `scan ` roots, whose # origin URL matches one of the checkouts above. @@ -36,6 +37,10 @@ # fm-fleet-sync.sh owns the equivalent per-checkout refresh bound for every caller. # FM_TREEHOUSE_ACQUIRE_TIMEOUT applies the same process-tree ownership to the # synchronous durable lease acquired before a task endpoint is created. +# Firstmate acquires each new task lease from a persistent detached control +# worktree registered to the declared project clone. The control worktree carries +# the repo-level root setting that Treehouse requires, while the declared clone +# stays clean and every home gets its own pool under $FM_HOME/.treehouse. # # Cadence and spawn-preflight refreshes delegate to fm-fleet-sync.sh with pruning # disabled, while session-start pruning retains every branch whose landed state @@ -103,6 +108,21 @@ FM_HOME_PHYSICAL_KEY=$(fm_checkout_physical_path_key "$FM_HOME_CANONICAL" direct PROJECTS="${FM_PROJECTS_OVERRIDE:-$FM_HOME/projects}" CONFIG="${FM_CONFIG_OVERRIDE:-$FM_HOME/config}" CONFIG_FILE="${FM_CHECKOUT_REFRESH_CONFIG:-$CONFIG/checkout-refresh}" +MANAGED_TREEHOUSE_ROOT_RAW="$FM_HOME_CANONICAL/.treehouse" +MANAGED_TREEHOUSE_ROOT=$MANAGED_TREEHOUSE_ROOT_RAW +MANAGED_TREEHOUSE_ROOT_CANONICAL= +MANAGED_TREEHOUSE_ROOT_INVALID=0 +if ! MANAGED_TREEHOUSE_ROOT=$(fm_checkout_lexical_path "$MANAGED_TREEHOUSE_ROOT_RAW" 1); then + MANAGED_TREEHOUSE_ROOT_INVALID=1 + MANAGED_TREEHOUSE_ROOT=$MANAGED_TREEHOUSE_ROOT_RAW +elif [ -e "$MANAGED_TREEHOUSE_ROOT" ] && [ ! -d "$MANAGED_TREEHOUSE_ROOT" ]; then + MANAGED_TREEHOUSE_ROOT_INVALID=1 +elif [ -d "$MANAGED_TREEHOUSE_ROOT" ] \ + && MANAGED_TREEHOUSE_ROOT_CANONICAL=$(fm_checkout_trusted_dir "$MANAGED_TREEHOUSE_ROOT"); then + MANAGED_TREEHOUSE_ROOT=$MANAGED_TREEHOUSE_ROOT_CANONICAL +elif [ -e "$MANAGED_TREEHOUSE_ROOT" ]; then + MANAGED_TREEHOUSE_ROOT_INVALID=1 +fi if [ "${FM_TREEHOUSE_ROOT+x}" = x ]; then TREEHOUSE_ROOT_RAW=$FM_TREEHOUSE_ROOT else @@ -304,22 +324,35 @@ parse_config() { } treehouse_worktree_paths() { + local roots=() + if [ "$MANAGED_TREEHOUSE_ROOT_INVALID" = 1 ]; then + echo "checkout-refresh: skipped: incomplete Treehouse coverage because the managed home root is unsafe or unreadable: $MANAGED_TREEHOUSE_ROOT_RAW" >&2 + return 1 + fi if [ "$TREEHOUSE_ROOT_INVALID" = 1 ]; then echo "checkout-refresh: skipped: incomplete Treehouse coverage because the configured root is unsafe or unreadable: $TREEHOUSE_ROOT_RAW" >&2 return 1 fi - if [ ! -e "$TREEHOUSE_ROOT" ] && [ ! -L "$TREEHOUSE_ROOT" ]; then - return 0 + if [ -e "$MANAGED_TREEHOUSE_ROOT" ] || [ -L "$MANAGED_TREEHOUSE_ROOT" ]; then + [ -d "$MANAGED_TREEHOUSE_ROOT" ] || { + echo "checkout-refresh: skipped: incomplete Treehouse coverage because the managed home root is not a directory: $MANAGED_TREEHOUSE_ROOT" >&2 + return 1 + } + roots+=("$MANAGED_TREEHOUSE_ROOT") fi - [ -d "$TREEHOUSE_ROOT" ] || { - echo "checkout-refresh: skipped: incomplete Treehouse coverage because the root is not a directory: $TREEHOUSE_ROOT" >&2 - return 1 - } + if [ -e "$TREEHOUSE_ROOT" ] || [ -L "$TREEHOUSE_ROOT" ]; then + [ -d "$TREEHOUSE_ROOT" ] || { + echo "checkout-refresh: skipped: incomplete Treehouse coverage because the root is not a directory: $TREEHOUSE_ROOT" >&2 + return 1 + } + [ "$TREEHOUSE_ROOT" = "$MANAGED_TREEHOUSE_ROOT" ] || roots+=("$TREEHOUSE_ROOT") + fi + [ "${#roots[@]}" -ne 0 ] || return 0 command -v python3 >/dev/null 2>&1 || { echo "checkout-refresh: skipped: incomplete Treehouse coverage because python3 is unavailable" >&2 return 1 } - python3 - "$TREEHOUSE_ROOT" <<'PY' + python3 - "${roots[@]}" <<'PY' import json import os import stat @@ -341,18 +374,18 @@ def directory_entries(path, label): return sorted(entries, key=lambda entry: entry.name) -root = sys.argv[1] -try: - pool_entries = directory_entries(root, "Treehouse root") -except OSError as error: - failed = True - pool_entries = [] - print( - f"checkout-refresh: skipped: incomplete Treehouse coverage at {root}: {error}", - file=sys.stderr, - ) - state_paths = [] +pool_entries = [] +for root in sys.argv[1:]: + try: + pool_entries.extend(directory_entries(root, "Treehouse root")) + except OSError as error: + failed = True + print( + f"checkout-refresh: skipped: incomplete Treehouse coverage at {root}: {error}", + file=sys.stderr, + ) + for pool_entry in pool_entries: try: metadata = pool_entry.stat(follow_symlinks=False) @@ -896,8 +929,105 @@ EOF cleanup_discovery_tmp "$tmp" } +prepare_treehouse_source() { # + local expected_source=$1 expected_common source_key source_parent source_dir source_common + local source_name config config_tmp config_value dirty state_dir managed_root + [ "$MANAGED_TREEHOUSE_ROOT_INVALID" = 0 ] || { + echo "error: managed Treehouse root is unsafe or unreadable: $MANAGED_TREEHOUSE_ROOT_RAW" >&2 + return 1 + } + mkdir -p "$MANAGED_TREEHOUSE_ROOT" || return 1 + managed_root=$(fm_checkout_trusted_dir "$MANAGED_TREEHOUSE_ROOT") || { + echo "error: managed Treehouse root is unsafe or unreadable: $MANAGED_TREEHOUSE_ROOT" >&2 + return 1 + } + [ "$managed_root" = "$MANAGED_TREEHOUSE_ROOT" ] || { + echo "error: managed Treehouse root changed identity: $MANAGED_TREEHOUSE_ROOT" >&2 + return 1 + } + expected_common=$(fm_checkout_git_common_dir "$expected_source") || { + echo "error: cannot resolve Treehouse source repository identity for $expected_source" >&2 + return 1 + } + source_key=$(fm_checkout_hash_value "$expected_common" 24) || { + echo "error: cannot derive Treehouse source identity for $expected_source" >&2 + return 1 + } + state_dir="$FM_HOME_CANONICAL/state" + mkdir -p "$state_dir" || return 1 + state_dir=$(fm_checkout_trusted_dir "$state_dir") || { + echo "error: Treehouse source state root is unsafe: $FM_HOME_CANONICAL/state" >&2 + return 1 + } + source_parent="$state_dir/treehouse-sources/$source_key" + mkdir -p "$source_parent" || return 1 + source_parent=$(fm_checkout_trusted_dir "$source_parent") || { + echo "error: Treehouse source directory is unsafe: $source_parent" >&2 + return 1 + } + source_name=${expected_source##*/} + [ -n "$source_name" ] || { + echo "error: Treehouse source repository name is unavailable for $expected_source" >&2 + return 1 + } + source_dir="$source_parent/$source_name" + if [ -e "$source_dir" ] || [ -L "$source_dir" ]; then + source_dir=$(require_exact_git_root "$source_dir" "managed Treehouse source") || return 1 + else + git -C "$expected_source" worktree add --quiet --detach "$source_dir" HEAD || { + echo "error: cannot create the managed Treehouse source for $expected_source" >&2 + return 1 + } + source_dir=$(require_exact_git_root "$source_dir" "managed Treehouse source") || return 1 + fi + source_common=$(fm_checkout_git_common_dir "$source_dir") || return 1 + [ "$source_common" = "$expected_common" ] || { + echo "error: managed Treehouse source belongs to an unrelated repository: $source_dir" >&2 + return 1 + } + if git -C "$source_dir" symbolic-ref --quiet HEAD >/dev/null 2>&1; then + echo "error: managed Treehouse source is not detached: $source_dir" >&2 + return 1 + fi + config="$source_dir/treehouse.toml" + if git -C "$source_dir" ls-files --error-unmatch -- treehouse.toml >/dev/null 2>&1; then + echo "error: project tracks treehouse.toml; Firstmate cannot install its managed root in $source_dir" >&2 + return 1 + fi + if [ -e "$config" ] || [ -L "$config" ]; then + [ -f "$config" ] && [ ! -L "$config" ] || { + echo "error: managed Treehouse config is unsafe: $config" >&2 + return 1 + } + fi + config_value=$(fm_checkout_system_perl -MJSON::PP=encode_json -e 'print encode_json(shift)' "$FM_HOME_CANONICAL") || { + echo "error: cannot encode the managed Treehouse root for $FM_HOME_CANONICAL" >&2 + return 1 + } + config_tmp=$(mktemp "$source_dir/.firstmate-treehouse-config.XXXXXX") || return 1 + if ! printf 'root = %s\n' "$config_value" > "$config_tmp" \ + || ! chmod 600 "$config_tmp" \ + || ! mv "$config_tmp" "$config"; then + rm -f "$config_tmp" + echo "error: cannot publish the managed Treehouse config at $config" >&2 + return 1 + fi + dirty=$(git -C "$source_dir" status --porcelain=v1 --untracked-files=all 2>/dev/null) || { + echo "error: managed Treehouse source cleanliness is uninspectable: $source_dir" >&2 + return 1 + } + case "$dirty" in + ''|'?? treehouse.toml') ;; + *) + echo "error: managed Treehouse source contains unexpected changes: $source_dir ($(printf '%s\n' "$dirty" | sed -n '1p'))" >&2 + return 1 + ;; + esac + printf '%s\n' "$source_dir" +} + acquire_worktree() { - local expected_source=$1 lease_holder=$2 status=0 canonical + local expected_source=$1 lease_holder=$2 status=0 canonical treehouse_source canonical=$(require_exact_git_root "$expected_source" "Treehouse acquisition source") || return 1 expected_source=$canonical ( @@ -913,7 +1043,8 @@ acquire_worktree() { fi process_guard="${FM_LOCK_OWNER_DIR:?}/process-group" trap 'fm_lock_release "$checkout_lock"' EXIT - cd "$expected_source" || exit 1 + treehouse_source=$(prepare_treehouse_source "$expected_source") || exit 1 + cd "$treehouse_source" || exit 1 if FM_PROCESS_TREE_GUARD_FILE="$process_guard" \ fm_run_bounded "$ACQUIRE_TIMEOUT" treehouse get --lease --lease-holder "$lease_holder"; then status=0 diff --git a/bin/fm-teardown.sh b/bin/fm-teardown.sh index a76471c507..93b9f0d80a 100755 --- a/bin/fm-teardown.sh +++ b/bin/fm-teardown.sh @@ -299,6 +299,10 @@ if [ "$DIRECT_SPAWN_ENDPOINT" = not-created ]; then exit 1 } fi +SPAWN_NEVER_LAUNCHED=0 +if [ "$DIRECT_SPAWN_CLEANUP" = pending ] && [ "$DIRECT_SPAWN_ENDPOINT" = not-created ]; then + SPAWN_NEVER_LAUNCHED=1 +fi ORCA_CLEANUP_PENDING_COUNT=$(grep -c '^orca_cleanup_pending=' "$META" 2>/dev/null || true) ORCA_CLEANUP_PENDING=0 if [ "$ORCA_CLEANUP_PENDING_COUNT" -ne 0 ]; then @@ -930,6 +934,10 @@ work_is_landed() { backlog_refresh_reminder() { local pr done_cmd report_path [ "$KIND" = secondmate ] && return 0 + if [ "$SPAWN_NEVER_LAUNCHED" = 1 ]; then + printf '%s\n' "Backlog: $ID never launched. Retry its spawn or move it back to a ready state; no report was required for this cleanup." + return 0 + fi if fm_tasks_axi_backend_available "$CONFIG"; then case "$KIND" in scout) @@ -4553,7 +4561,7 @@ if [ "$KIND" = secondmate ]; then fi fi -if [ "$KIND" = scout ]; then +if [ "$KIND" = scout ] && [ "$SPAWN_NEVER_LAUNCHED" != 1 ]; then REPORT="$DATA/$ID/report.md" if [ ! -f "$REPORT" ]; then echo "REFUSED: scout task $ID has no report at $REPORT." >&2 @@ -4697,7 +4705,7 @@ fi # Report-gated tasks restore any pending rollback generation and fail closed on # their machine-global completion report before lease release or worktree removal. -if [ "$REPORT_GATED" = 1 ]; then +if [ "$REPORT_GATED" = 1 ] && [ "$SPAWN_NEVER_LAUNCHED" != 1 ]; then if [ "$MANAGED_ACCOUNT" = 1 ]; then reconcile_managed_account_rollback "$META" "$ID" "$DATA" || exit $? fi diff --git a/docs/configuration.md b/docs/configuration.md index 7e6159fcea..29020f95d1 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -287,7 +287,7 @@ Secondmate homes inherit this file from the primary, so a secondmate's own crewm `bin/fm-checkout-refresh.sh` keeps worktree seed checkouts current independently of Firstmate's own PR lifecycle. Its header owns the exact discovery, configuration-file, cadence, state, and command contracts. -The default covered set is every clone under the active home's `projects/`, every backing checkout referenced by a Treehouse pool under `~/.treehouse`, and every top-level clone under `$HOME` whose `origin` URL matches one of those tracked checkouts. +The default covered set is every clone under the active home's `projects/`, every backing checkout referenced by that home's managed `$FM_HOME/.treehouse` pool or the legacy user-level `~/.treehouse` pool, and every top-level clone under `$HOME` whose `origin` URL matches one of those tracked checkouts. Matching by origin discovers parallel clones such as `~/relvino` without embedding a captain-specific path in shared code. Optional `path` and shallow `scan` directives in the gitignored `config/checkout-refresh` file extend that set. Every declared checkout and matching-origin scan result must resolve to its exact canonical Git worktree root, so nested directories can never redirect refresh to an enclosing repository. @@ -322,6 +322,10 @@ An unproven branch is retained and surfaced as `STUCK:`. Treehouse v2.0 already excludes dirty pool entries, fetches `origin`, and resets only an available clean detached worktree to the freshest default ref. Firstmate surfaces matching dirty pool entries, acquires the selected path with `treehouse get --lease`, and verifies the durable lease before creating its endpoint. +Because Treehouse keys pool names by `origin`, Firstmate gives every home a separate `$FM_HOME/.treehouse` root before acquisition. +Treehouse has no root flag or environment override, so Firstmate runs acquisition from a persistent detached control worktree registered to the declared project clone and stores the generated repo-level `treehouse.toml` only in that ignored operational worktree under `$FM_HOME/state/treehouse-sources/`. +The declared project clone remains clean, while every acquired worktree retains the declared clone's exact Git common directory. +The legacy user-level pool remains scan-only coverage so existing leases can finish and return without migration, reclamation, or ownership changes. That synchronous acquisition holds the common-Git-directory mutation lock and is process-tree bounded by `FM_TREEHOUSE_ACQUIRE_TIMEOUT`, which defaults to 60 seconds. The accepted lease must be clean, belong to the requested repository, have the same origin identity, and match the live upstream default-branch tip. Remote-free `local-only` acquisitions use the same repository and cleanliness proof, with the requested checkout's local `main` or `master` tip as their freshness authority. @@ -338,7 +342,6 @@ Orca is an explicit legacy-recovery-only exception because this change creates n ### Limitations / deferred - A checkout without `origin` still uses the local default-tip proof without first proving that its registered project mode is explicitly `local-only`. -- Home-scoped refresh owners still enumerate the shared user-level Treehouse root without filtering pool entries by owning `FM_HOME`. - Secondmate home acquisition still relies on Treehouse's dirty-entry skip and does not run Firstmate's `pool-preflight` before requesting its durable lease. - The rare SIGKILL lock-to-guard handoff race, exact wrapped-exit-code fidelity, and setup-failure diagnostic precision remain deferred under `clone-refresh-followup-edges`; current fail-safe behavior may retain or refuse work or require retry, but must not claim healthy coverage or discard unlanded work. @@ -582,7 +585,7 @@ FM_CHECKOUT_REFRESH_PROBE_TIMEOUT=15 # seconds allowed for one upstream-tip pro FM_CHECKOUT_REFRESH_SYNC_TIMEOUT=60 # seconds allowed for one checkout refresh FM_TREEHOUSE_ACQUIRE_TIMEOUT=60 # seconds allowed for one durable task-worktree acquisition FM_TREEHOUSE_RETURN_TIMEOUT=60 # seconds allowed for one Treehouse worktree return -# FM_TREEHOUSE_ROOT is unset by default; setting it empty is malformed, while a non-empty value overrides ~/.treehouse +# FM_TREEHOUSE_ROOT is unset by default; setting it empty is malformed, while a non-empty value replaces the legacy ~/.treehouse scan root (the managed $FM_HOME/.treehouse root is always covered) 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 diff --git a/tests/fm-account-directory.test.sh b/tests/fm-account-directory.test.sh index ab4ad2cd2f..c275cf795a 100755 --- a/tests/fm-account-directory.test.sh +++ b/tests/fm-account-directory.test.sh @@ -540,7 +540,7 @@ set -u # `treehouse get --lease` reports the leased worktree on stdout, and fm-spawn # refuses to launch without it. The stub has to answer that the same way. [ "${1:-}" != get ] || { - printf '%s\n' "$*" >> "${FM_FAKE_TREEHOUSE_LOG:?}" + printf '%s\tcwd=%s\n' "$*" "$PWD" >> "${FM_FAKE_TREEHOUSE_LOG:?}" printf '%s\n' "${FM_FAKE_TREEHOUSE_WORKTREE:?}" exit 0 } @@ -644,6 +644,51 @@ test_spawn_uses_direct_codex_home_without_agent_fleet() { pass "new enforced Codex spawn uses CODEX_HOME and never enters Agent Fleet lease selection" } +test_main_home_ship_and_scout_use_managed_treehouse_source() { + local record id out meta source_config treehouse_log + reset_accounts + : > "$TMP_ROOT/agent-fleet.log" + + id=main-home-ship-z1 + record=$(make_spawn_case main-home-ship codex "$id") + read_spawn_case "$record" + : > "$TREEHOUSE_LOG" + out=$(run_direct_spawn "$SPAWN_HOME" "$SPAWN_WORKTREE" "$SPAWN_LAUNCH_LOG" \ + "$id" "$SPAWN_PROJECT" 2>&1) + meta="$SPAWN_HOME/state/$id.meta" + treehouse_log=$(cat "$TREEHOUSE_LOG") + assert_contains "$out" "spawned $id harness=codex kind=ship" \ + "main-home ship spawn regressed" + assert_grep 'kind=ship' "$meta" "main-home ship metadata changed kind" + assert_absent "$SPAWN_PROJECT/treehouse.toml" \ + "main-home ship spawn wrote Treehouse config into the project clone" + assert_contains "$treehouse_log" "cwd=$SPAWN_HOME/state/treehouse-sources/" \ + "main-home ship spawn did not acquire from its managed control worktree" + source_config=$(find "$SPAWN_HOME/state/treehouse-sources" -name treehouse.toml -type f -print) + assert_grep "root = \"$SPAWN_HOME\"" "$source_config" \ + "main-home ship control worktree did not select the home pool" + + id=main-home-scout-z1 + record=$(make_spawn_case main-home-scout codex "$id") + read_spawn_case "$record" + : > "$TREEHOUSE_LOG" + out=$(run_direct_spawn "$SPAWN_HOME" "$SPAWN_WORKTREE" "$SPAWN_LAUNCH_LOG" \ + "$id" "$SPAWN_PROJECT" --scout 2>&1) + meta="$SPAWN_HOME/state/$id.meta" + treehouse_log=$(cat "$TREEHOUSE_LOG") + assert_contains "$out" "spawned $id harness=codex kind=scout" \ + "main-home scout spawn regressed" + assert_grep 'kind=scout' "$meta" "main-home scout metadata changed kind" + assert_absent "$SPAWN_PROJECT/treehouse.toml" \ + "main-home scout spawn wrote Treehouse config into the project clone" + assert_contains "$treehouse_log" "cwd=$SPAWN_HOME/state/treehouse-sources/" \ + "main-home scout spawn did not acquire from its managed control worktree" + source_config=$(find "$SPAWN_HOME/state/treehouse-sources" -name treehouse.toml -type f -print) + assert_grep "root = \"$SPAWN_HOME\"" "$source_config" \ + "main-home scout control worktree did not select the home pool" + pass "main-home ship and scout spawns acquire through their managed per-home Treehouse source" +} + test_spawn_uses_direct_claude_fallback_and_hook() { local record id out launch meta reset_accounts @@ -1301,6 +1346,11 @@ if [ "${FM_TEST_FOCUSED:-}" = direct-recovery-lifecycle ]; then exit 0 fi +if [ "${FM_TEST_FOCUSED:-}" = treehouse-per-home ]; then + test_main_home_ship_and_scout_use_managed_treehouse_source + exit 0 +fi + test_codex_picks_highest_fresh_minimum_and_skips_no_window test_profile_eligibility_requires_enabled_worker test_claude_approval_marker_contract @@ -1315,6 +1365,7 @@ test_claude_uses_only_explicit_last_resort_after_primary_exhaustion test_codex_rotates_accounts_tied_for_best_fresh_score test_default_root_uses_passwd_home_not_ambient_home test_prepare_installs_and_verifies_per_account_herdr_hooks +test_main_home_ship_and_scout_use_managed_treehouse_source test_spawn_uses_direct_codex_home_without_agent_fleet test_spawn_uses_direct_claude_fallback_and_hook test_claude_spawn_rejects_mismatched_explicit_model diff --git a/tests/fm-checkout-refresh.test.sh b/tests/fm-checkout-refresh.test.sh index dd7a5172b2..0d91243c5e 100755 --- a/tests/fm-checkout-refresh.test.sh +++ b/tests/fm-checkout-refresh.test.sh @@ -1583,6 +1583,73 @@ SH pass "bounded refresh terminates and reaps its complete descendant tree" } +test_per_home_treehouse_sources_isolate_same_origin_clones() { + local remote home_a home_b source_a source_b fakebin legacy_root lease_a lease_b + local common_a common_b lease_common_a lease_common_b config_a config_b + remote=$(build_origin per-home-treehouse) + home_a="$TMP_ROOT/treehouse-home-a" + home_b="$TMP_ROOT/treehouse-home-b" + source_a="$home_a/projects/shared" + source_b="$home_b/projects/shared" + fakebin="$TMP_ROOT/per-home-treehouse-fakebin" + legacy_root="$TMP_ROOT/per-home-treehouse-legacy" + mkdir -p "$home_a/projects" "$home_a/config" "$home_b/projects" "$home_b/config" \ + "$fakebin" "$legacy_root" + clone_from "$remote" "$source_a" + clone_from "$remote" "$source_b" + cat > "$fakebin/treehouse" <<'SH' +#!/usr/bin/env bash +set -u +[ "${1:-}" = get ] && [ "${2:-}" = --lease ] || exit 64 +root=$(sed -n 's/^root = "\(.*\)"$/\1/p' "$PWD/treehouse.toml") +[ -n "$root" ] || exit 65 +worktree="$root/.treehouse/shared-test/1/shared" +if [ ! -d "$worktree" ]; then + mkdir -p "$(dirname "$worktree")" + git -C "$PWD" worktree add --quiet --detach "$worktree" HEAD || exit 66 +fi +printf '%s\n' "$worktree" +SH + chmod +x "$fakebin/treehouse" + + lease_a=$(HOME="$TEST_HOME" FM_HOME="$home_a" FM_ROOT_OVERRIDE="$ROOT" \ + FM_CHECKOUT_REFRESH_STATE_ROOT="$home_a/refresh-state" \ + FM_CHECKOUT_REFRESH_LOCK_ROOT="$home_a/locks" \ + FM_TREEHOUSE_ROOT="$legacy_root" PATH="$fakebin:$PATH" \ + "$ROOT/bin/fm-checkout-refresh.sh" acquire-worktree "$source_a" firstmate-home-a) + lease_b=$(HOME="$TEST_HOME" FM_HOME="$home_b" FM_ROOT_OVERRIDE="$ROOT" \ + FM_CHECKOUT_REFRESH_STATE_ROOT="$home_b/refresh-state" \ + FM_CHECKOUT_REFRESH_LOCK_ROOT="$home_b/locks" \ + FM_TREEHOUSE_ROOT="$legacy_root" PATH="$fakebin:$PATH" \ + "$ROOT/bin/fm-checkout-refresh.sh" acquire-worktree "$source_b" firstmate-home-b) + + common_a=$(git -C "$source_a" rev-parse --path-format=absolute --git-common-dir) + common_b=$(git -C "$source_b" rev-parse --path-format=absolute --git-common-dir) + lease_common_a=$(git -C "$lease_a" rev-parse --path-format=absolute --git-common-dir) + lease_common_b=$(git -C "$lease_b" rev-parse --path-format=absolute --git-common-dir) + [ "$lease_common_a" = "$common_a" ] \ + || fail "home A acquired a worktree outside its declared clone" + [ "$lease_common_b" = "$common_b" ] \ + || fail "home B acquired a worktree outside its declared clone" + [ "$lease_common_a" != "$lease_common_b" ] \ + || fail "same-origin homes still shared one Git common directory" + assert_contains "$lease_a" "$home_a/.treehouse/" \ + "home A lease did not use its managed Treehouse root" + assert_contains "$lease_b" "$home_b/.treehouse/" \ + "home B lease did not use its managed Treehouse root" + assert_absent "$source_a/treehouse.toml" \ + "managed Treehouse setup wrote into home A's declared project clone" + assert_absent "$source_b/treehouse.toml" \ + "managed Treehouse setup wrote into home B's declared project clone" + config_a=$(find "$home_a/state/treehouse-sources" -name treehouse.toml -type f -print) + config_b=$(find "$home_b/state/treehouse-sources" -name treehouse.toml -type f -print) + assert_grep "root = \"$home_a\"" "$config_a" \ + "home A control worktree did not carry its managed root" + assert_grep "root = \"$home_b\"" "$config_b" \ + "home B control worktree did not carry its managed root" + pass "same-origin clones acquire from separate per-home pools owned by their declaring clone" +} + test_acquisition_honors_shared_checkout_lock() { local source fakebin common key lock out status marker source="$TMP_ROOT/acquisition-lock-source" @@ -2355,6 +2422,12 @@ if [ "${FM_TEST_FOCUSED:-}" = review-round-6 ]; then exit 0 fi +if [ "${FM_TEST_FOCUSED:-}" = treehouse-per-home ]; then + test_per_home_treehouse_sources_isolate_same_origin_clones + test_acquisition_honors_shared_checkout_lock + exit 0 +fi + if [ "${FM_TEST_FOCUSED:-}" = review-round-7 ]; then test_discovery_rejects_nested_configured_and_scanned_paths exit 0 @@ -2431,6 +2504,7 @@ test_explicit_secondmate_home_requires_live_default_tip test_lock_owner_symlink_cannot_escape_state_directory test_worktree_freshness_verification_fails_closed test_bounded_refresh_terminates_descendants +test_per_home_treehouse_sources_isolate_same_origin_clones test_acquisition_honors_shared_checkout_lock test_launch_agent_definition_is_home_scoped_with_scheduler_seam test_logical_home_state_migrates_and_ambiguity_fails_closed diff --git a/tests/fm-teardown.test.sh b/tests/fm-teardown.test.sh index fee6e45c8d..a4e282ae04 100755 --- a/tests/fm-teardown.test.sh +++ b/tests/fm-teardown.test.sh @@ -3856,6 +3856,38 @@ test_never_created_direct_spawn_endpoint_is_not_quiesced() { pass "never-created direct-spawn endpoint skips endpoint quiescence" } +test_never_created_scout_without_report_cleans_bookkeeping() { + local case_dir meta_tmp rc + case_dir=$(make_case never-created-scout-without-report) + write_meta "$case_dir" local-only scout + meta_tmp=$(mktemp "$case_dir/state/.never-created-scout-meta.XXXXXX") + awk '!/^window=/ && !/^tmux_session_target=/' "$case_dir/state/task-x1.meta" > "$meta_tmp" + printf '%s\n' \ + 'window=' \ + 'account_home=/tmp/direct-account-home' \ + 'direct_spawn_endpoint=not-created' \ + 'direct_spawn_cleanup=pending' \ + 'rollback_pending=1' \ + 'report_required=1' >> "$meta_tmp" + mv "$meta_tmp" "$case_dir/state/task-x1.meta" + + set +e + run_teardown "$case_dir" --force > "$case_dir/stdout" 2> "$case_dir/stderr" + rc=$? + set -e + + expect_code 0 "$rc" "never-created scout teardown without report" + assert_no_grep 'has no report' "$case_dir/stderr" \ + "never-launched scout was incorrectly held to the executed-scout report gate" + assert_present "$case_dir/fakebin/.tmux-live" \ + "never-created scout cleanup acted on the currently focused tmux endpoint" + assert_absent "$case_dir/state/task-x1.meta" \ + "never-created scout cleanup left orphaned task metadata" + assert_grep 'task-x1 never launched' "$case_dir/stdout" \ + "never-created scout cleanup did not preserve the task's retry semantics" + pass "never-created scout cleanup skips only the report gates for a spawn that never launched" +} + test_secondmate_registry_duplicate_home_blocks_removal() { local case_dir home rc case_dir=$(make_case secondmate-registry-duplicate-home) @@ -5077,6 +5109,12 @@ if [ "${FM_TEST_FOCUSED:-}" = reclaim-regressions ]; then exit 0 fi +if [ "${FM_TEST_FOCUSED:-}" = treehouse-per-home ]; then + test_never_created_direct_spawn_endpoint_is_not_quiesced + test_never_created_scout_without_report_cleans_bookkeeping + exit 0 +fi + if [ "${FM_TEST_FOCUSED:-}" = already-returned ]; then test_already_returned_worktree_finishes_bookkeeping exit 0 @@ -5161,6 +5199,7 @@ test_secondmate_missing_treehouse_child_is_retained test_secondmate_registry_home_drift_blocks_removal test_retained_direct_spawn_requires_confirmed_endpoint_quiescence test_never_created_direct_spawn_endpoint_is_not_quiesced +test_never_created_scout_without_report_cleans_bookkeeping test_squash_merged_branch_deleted_allows test_squash_merged_pr_allows_when_head_ancestor_of_pr_head test_no_pr_recorded_discovers_merged_pr_by_branch_allows From eaeb8bdb529afe03d35f434c8f5aa437d2243af9 Mon Sep 17 00:00:00 2001 From: Dongkeun Lee Date: Fri, 31 Jul 2026 23:36:57 -0400 Subject: [PATCH 2/4] no-mistakes(review): Harden Treehouse source isolation and refusal diagnostics --- bin/fm-checkout-refresh.sh | 54 ++++++++++++++--------- tests/fm-checkout-refresh.test.sh | 71 +++++++++++++++++++++++++++++++ 2 files changed, 105 insertions(+), 20 deletions(-) diff --git a/bin/fm-checkout-refresh.sh b/bin/fm-checkout-refresh.sh index 7760cc6234..cde00cdc8b 100755 --- a/bin/fm-checkout-refresh.sh +++ b/bin/fm-checkout-refresh.sh @@ -251,6 +251,32 @@ require_exact_git_root() { printf '%s\n' "$canonical" } +ensure_managed_child_dir() { #