diff --git a/AGENTS.md b/AGENTS.md index eaf10bab73..83407e90ab 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -120,6 +120,7 @@ state/ volatile runtime signals; gitignored A `state/.status` line is a wake event, not current-state truth; `bin/fm-crew-state.sh` owns current-state reconciliation. Treat `data/captain.md` as the domain-local record of captain preferences, optional `data/captain-shared.md` as the main-authoritative shared captain-preference file for secondmate inheritance, and `data/learnings.md` as curated home-local knowledge, regardless of harness memory. +`state/.meta` records `kind=adopted` for a task registered by `bin/fm-adopt.sh` instead of spawned (section 7); see that script's header for its exact fields. ## 3. Session start (run once at every session start) @@ -194,6 +195,7 @@ Reconcile only this home's recorded direct reports and their recorded backend in For an ordinary direct report whose endpoint is dead or metadata has no window, load `stuck-crewmate-recovery` and preserve the recorded worktree and unlanded work while reconciling ownership. For a dead secondmate direct report, load `secondmate-provisioning` and reconcile only that secondmate, never its whole child tree from the main home. Each secondmate reconciles work already in its own home and then idles; recovery never authorizes it to invent work. +For a dead `kind=adopted` direct report, there is no worktree or branch to reconcile; re-run `bin/fm-adopt.sh` against the workspace's new id to re-register it, or tear the task down to un-register it. If away mode is present, load `/afk` and let its daemon own supervision rather than arming another cycle. Surface only captain-relevant decisions, review-ready PRs, failures, and credential needs; otherwise resume the emitted supervision protocol silently. @@ -344,6 +346,14 @@ Before treating the investigation or any visual review as complete, load `decisi When implementation is separately authorized, promote the existing scout through `bin/fm-promote.sh` rather than creating a duplicate task. The promoted worker must inventory scratch state, return to a clean default-branch base, carry over only intended fix changes, create the ship branch, and follow the project's selected delivery path while leaving scratch commits and debug edits behind and turning a reproduced bug into the regression test. +### Adopt (existing session) + +Adopt registers an already-running cmux workspace - one a human started, not a firstmate spawn - as a supervised task, so peek, send, current-state reads, and the watcher work on it without a firstmate worktree or branch. +Run `bin/fm-adopt.sh --workspace [--surface ]`; it is cmux-only and experimental, and the task is supervise-only, with no delivery mode, no validation, and no PR. +Teardown un-registers the task without ever closing the human's workspace, and the watcher exempts it from stale-pane wakes. +As with a spawn, the orchestrator adds the `data/backlog.md` In flight line, so record the task there after adopting. +See the script's header comment for the full mechanics. + ## 8. Supervision protocol Fleet supervision is an always-loaded operational contract; `docs/architecture.md`, `docs/turnend-guard.md`, the emitted session-start block, and script help own mechanisms and harness-specific recipes. diff --git a/bin/fm-adopt.sh b/bin/fm-adopt.sh new file mode 100755 index 0000000000..d190ef4d92 --- /dev/null +++ b/bin/fm-adopt.sh @@ -0,0 +1,148 @@ +#!/usr/bin/env bash +# Adopt an ALREADY-RUNNING cmux workspace as a supervised firstmate task. +# Usage: fm-adopt.sh --workspace [--surface ] [--backend cmux] +# +# Registers a human-started cmux workspace/surface (NOT firstmate-spawned) as a +# task, so fm-peek.sh, fm-send.sh, fm-crew-state.sh, and the watcher operate on +# it. Unlike a spawn there is NO treehouse worktree and NO branch: worktree= +# records the workspace's OWN live cwd (a real existing directory, never a +# firstmate-managed worktree), and the task is supervise-only - it carries no +# delivery mode and never produces a PR. It records kind=adopted (and +# mode=adopted) so bin/fm-teardown.sh un-registers the task WITHOUT ever closing +# the human's cmux workspace, and bin/fm-watch.sh exempts it from stale-pane +# wakes (a human-driven or idle session being quiet is healthy, not stuck). +# +# The human's cmux workspace title is kept exactly as-is - never renamed. +# Peek/send target the surface by pure UUID because kind=adopted makes the +# expected-label empty (bin/fm-backend.sh's fm_backend_expected_label_of_selector), +# so no firstmate-scoped title check can reject the human's own title. +# +# cmux only, EXPERIMENTAL (docs/cmux-backend.md); any other backend refuses with +# an actionable error. Absent an app relaunch, cmux workspace ids are stable for +# the session; adopted workspaces are not fm-titled, so title-based recovery +# cannot re-find them after a relaunch - re-run fm-adopt then. +# +# As with fm-spawn, adding the data/backlog.md "In flight" line is the +# orchestrator's job, not this script's. +# On success prints: adopted backend=cmux window=: worktree= +set -eu + +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}}" +STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}" + +# shellcheck source=bin/fm-backend.sh +. "$SCRIPT_DIR/fm-backend.sh" + +"$SCRIPT_DIR/fm-guard.sh" || true + +ID= +WS= +SURFACE= +BACKEND=cmux +want_value= +for a in "$@"; do + if [ -n "$want_value" ]; then + case "$a" in + --*) echo "error: --$want_value requires a value" >&2; exit 1 ;; + esac + case "$want_value" in + workspace) WS=$a ;; + surface) SURFACE=$a ;; + backend) BACKEND=$a ;; + esac + want_value= + continue + fi + case "$a" in + --workspace) want_value=workspace ;; + --workspace=*) WS=${a#--workspace=} ;; + --surface) want_value=surface ;; + --surface=*) SURFACE=${a#--surface=} ;; + --backend) want_value=backend ;; + --backend=*) BACKEND=${a#--backend=} ;; + --*) echo "error: unknown flag $a" >&2; exit 1 ;; + *) + if [ -z "$ID" ]; then + ID=$a + else + echo "error: unexpected extra argument '$a'" >&2; exit 1 + fi + ;; + esac +done +[ -z "$want_value" ] || { echo "error: --$want_value requires a value" >&2; exit 1; } + +[ -n "$ID" ] || { echo "usage: fm-adopt.sh --workspace [--surface ] [--backend cmux]" >&2; exit 1; } +[ -n "$WS" ] || { echo "error: --workspace is required" >&2; exit 1; } +[ ! -f "$STATE/$ID.meta" ] || { echo "error: task $ID already registered ($STATE/$ID.meta)" >&2; exit 1; } +if [ "$BACKEND" != cmux ]; then + echo "error: fm-adopt currently supports only the cmux backend; got '$BACKEND'" >&2 + exit 1 +fi + +fm_backend_validate_spawn cmux || exit 1 +fm_backend_source cmux || exit 1 +# Client version gate plus socket reachability (launches cmux if simply down). +fm_backend_cmux_container_ensure || exit 1 + +if [ -z "$SURFACE" ]; then + SURFACE=$(fm_backend_cmux_surface_id_for_workspace "$WS" || true) + [ -n "$SURFACE" ] || { echo "error: could not resolve a surface for cmux workspace $WS; pass --surface or check 'cmux workspace list'" >&2; exit 1; } +fi + +T="$WS:$SURFACE" +# Pure-UUID liveness check (no expected-label, so no title scoping): the surface +# must currently exist in the running app. +if ! fm_backend_target_exists cmux "$T"; then + echo "error: no live cmux surface $T; is the workspace open? run 'cmux workspace list'" >&2 + exit 1 +fi + +# Refuse if this exact surface is already registered under a DIFFERENT task id. +# The self-id collision is caught above ($STATE/$ID.meta); this catches adopting +# the same live cmux surface a second time under a new id, which would have two +# tasks supervise - and send into - one surface. Compare the full window= line +# (workspace:surface) as a fixed string, since WS/SURFACE are opaque uuids. +if [ -d "$STATE" ]; then + for other in "$STATE"/*.meta; do + [ -e "$other" ] || continue + [ "$other" = "$STATE/$ID.meta" ] && continue + if grep -qxF "window=$T" "$other"; then + echo "error: cmux surface $T is already registered as task '$(basename "$other" .meta)' ($other); tear that task down first, or adopt a different surface" >&2 + exit 1 + fi + done +fi + +# Derive worktree/project from the workspace's own live cwd. Adopt never creates +# a treehouse worktree; worktree= must be a real existing directory so +# fm-crew-state.sh can probe it, and it must never point at a firstmate-managed +# worktree. Fall back to $HOME with a loud notice when cmux reports no usable cwd. +WSCWD=$(fm_backend_cmux_cli workspace list --json --id-format uuids 2>/dev/null | jq -r --arg id "$WS" '.workspaces[]? | select(.id==$id) | .current_directory // empty' 2>/dev/null || true) +if [ -z "$WSCWD" ] || [ ! -d "$WSCWD" ]; then + echo "NOTICE: cmux workspace $WS reported no usable current_directory; falling back to \$HOME for worktree=, so fm-crew-state.sh's worktree probe may be limited." >&2 + WSCWD=$HOME +fi +WORKTREE=$WSCWD +PROJECT=$WSCWD + +mkdir -p "$STATE" +{ + echo "window=$WS:$SURFACE" + echo "endpoint_task_id=$ID" + echo "worktree=$WORKTREE" + echo "project=$PROJECT" + echo "harness=adopted" + echo "kind=adopted" + echo "mode=adopted" + echo "yolo=off" + echo "model=default" + echo "effort=default" + echo "backend=cmux" + echo "cmux_workspace_id=$WS" + echo "cmux_surface_id=$SURFACE" +} > "$STATE/$ID.meta" + +echo "adopted $ID backend=cmux window=$WS:$SURFACE worktree=$WORKTREE" diff --git a/bin/fm-backend.sh b/bin/fm-backend.sh index e505b99f75..75c948f038 100644 --- a/bin/fm-backend.sh +++ b/bin/fm-backend.sh @@ -584,7 +584,13 @@ fm_backend_of_selector() { # fm_backend_expected_label_of_selector() { # local raw=$1 state=$2 id id=$(fm_backend_task_id_for_selector "$raw" "$state" 2>/dev/null || true) - [ -n "$id" ] && printf 'fm-%s' "$id" + # An adopted task (kind=adopted) carries the human's OWN cmux workspace + # title, not a firstmate-scoped fm- title, so a scoped-title label + # check would wrongly reject it; leave the expected-label empty so + # peek/send target it by pure UUID (surface_exists), never by title. + if [ -n "$id" ] && [ "$(fm_meta_get "$state/$id.meta" kind)" != adopted ]; then + printf 'fm-%s' "$id" + fi return 0 } diff --git a/bin/fm-crew-state.sh b/bin/fm-crew-state.sh index 30fc7b7236..7d828042c6 100755 --- a/bin/fm-crew-state.sh +++ b/bin/fm-crew-state.sh @@ -144,10 +144,16 @@ LOG_VERB=$(status_line_verb "$LOG_LINE") # herdr task is read through fm_backend_capture instead of a bare tmux probe. TASK_BACKEND=$(fm_backend_of_meta "$META") BACKEND_TARGET=$(fm_backend_target_of_meta "$META") -EXPECTED_LABEL="fm-$ID" +EXPECTED_LABEL=$(fm_backend_expected_label_of_selector "fm-$ID" "$STATE") pane_readable() { # case "$TASK_BACKEND" in tmux) tmux display-message -p -t "$1" '#{pane_id}' >/dev/null 2>&1 ;; + # cmux liveness is a STRUCTURAL existence check (list-panes), never a content + # read: cmux read-screen fails on a genuinely fresh surface that has never + # been written to (docs/cmux-backend.md), so an adopted task's untouched + # surface would otherwise be misread as "backend target gone". EXPECTED_LABEL + # is empty for kind=adopted, so this targets by pure uuid. + cmux) fm_backend_target_exists cmux "$1" "$EXPECTED_LABEL" >/dev/null 2>&1 ;; *) fm_backend_capture "$TASK_BACKEND" "$1" 1 "$EXPECTED_LABEL" >/dev/null 2>&1 ;; esac } diff --git a/bin/fm-session-start.sh b/bin/fm-session-start.sh index e4f21d0358..4b326136e2 100755 --- a/bin/fm-session-start.sh +++ b/bin/fm-session-start.sh @@ -359,7 +359,8 @@ for meta in "$STATE"/*.meta; do target=$(fm_backend_target_of_meta "$meta") if [ -n "$window" ]; then backend=$(fm_backend_of_meta "$meta") - if fm_backend_target_exists "$backend" "${target:-$window}" "fm-$id"; then + label=$(fm_backend_expected_label_of_selector "fm-$id" "$STATE") + if fm_backend_target_exists "$backend" "${target:-$window}" "$label"; then printf 'endpoint: alive (backend=%s window=%s)\n' "$backend" "$window" else printf 'endpoint: dead (backend=%s window=%s)\n' "$backend" "$window" diff --git a/bin/fm-teardown.sh b/bin/fm-teardown.sh index 83fb8e5398..566ccfdb09 100755 --- a/bin/fm-teardown.sh +++ b/bin/fm-teardown.sh @@ -510,6 +510,13 @@ work_is_landed() { backlog_refresh_reminder() { local pr done_cmd report_path [ "$KIND" = secondmate ] && return 0 + # An adopted task is un-registered, not "completed": its cmux workspace was + # left running (never closed) and there is no PR or merge, so it never gets a + # tasks-axi done --pr prompt. + if [ "$KIND" = adopted ]; then + printf '%s\n' "Backlog: adopted task $ID released - its cmux workspace was left running (never closed) and $ID is un-registered from firstmate. If you tracked it in data/backlog.md, move it to Done manually (no PR or merge is involved), then re-scan Queued for work whose blockers are gone and date is due." + return 0 + fi if fm_tasks_axi_backend_available "$CONFIG"; then case "$KIND" in scout) @@ -1369,11 +1376,21 @@ if [ "$BACKEND" = orca ] && [ "$KIND" != scout ] && [ "$KIND" != secondmate ] && fi if [ -d "$WT" ] && [ "$FORCE" != "--force" ]; then - if validate_worktree_teardown_safety; then + if [ "$KIND" = adopted ]; then + # An adopted task owns no firstmate worktree - worktree= is the human's own + # live cwd - so there is nothing to land or dirty-check; teardown just + # un-registers it. Checked before validate_worktree_teardown_safety so its + # REFUSED side-effect never fires on the human's real, unlanded cwd. : else - safety_rc=$? - if [ "$safety_rc" -eq "$TEARDOWN_WORKTREE_SAFETY_LOCK_BLOCKED" ]; then + # Call as a standalone command guarded by ||, so set -e does not abort on a + # non-zero return and safety_rc captures the safety function's own exit code + # (not a preceding test's - the SC2319 trap the adopted-branch reorder fixed). + safety_rc=0 + validate_worktree_teardown_safety || safety_rc=$? + if [ "$safety_rc" -eq 0 ]; then + : + elif [ "$safety_rc" -eq "$TEARDOWN_WORKTREE_SAFETY_LOCK_BLOCKED" ]; then cleanup_stale_lock_for_safety_check "$WT" || exit 1 validate_worktree_teardown_safety || exit 1 else @@ -1417,7 +1434,7 @@ if [ "$BACKEND" = orca ] && [ "$KIND" != secondmate ]; then 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 +elif [ -d "$WT" ] && [ "$KIND" != secondmate ] && [ "$KIND" != adopted ]; 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 @@ -1477,7 +1494,7 @@ elif [ "$BACKEND" = herdr ]; then else echo "warning: herdr session presentation lock path is unavailable; skipping the pane close rather than closing unlocked" >&2 fi -elif [ "$BACKEND" != orca ]; then +elif [ "$BACKEND" != orca ] && [ "$KIND" != adopted ]; then fm_backend_kill "$BACKEND" "$T" "$(meta_value "$META" zellij_tab_id)" "fm-$ID" 2>/dev/null || true fi if [ "$HERDR_PRESENTATION_RETIRE_CANDIDATE" = 1 ]; then @@ -1523,7 +1540,7 @@ retire_busy_state "$STATE" "$ID" "$BUSY_GEN" || exit 1 rm -f "$STATE/$ID.status" "$STATE/$ID.turn-ended" "$STATE/$ID.meta" \ "$STATE/$ID.pi-ext.ts" "$STATE/$ID.grok-turnend-token" \ "$STATE/$ID.kimi-turnend-token" -if [ "$KIND" != scout ] && [ "$KIND" != secondmate ] && [ "$MODE" != local-only ]; then +if [ "$KIND" != scout ] && [ "$KIND" != secondmate ] && [ "$KIND" != adopted ] && [ "$MODE" != local-only ]; then "$FM_ROOT/bin/fm-fleet-sync.sh" "$PROJ" || true fi echo "teardown $ID complete (window $T, worktree $WT)" diff --git a/bin/fm-watch.sh b/bin/fm-watch.sh index 9f17cbc357..90dd24b2a5 100755 --- a/bin/fm-watch.sh +++ b/bin/fm-watch.sh @@ -858,6 +858,13 @@ EOF # remembers the hash already classified). while IFS= read -r w; do kind=$(window_kind "$w") + # An adopted human-driven or idle session being quiet is healthy - it is + # not a stuck crewmate. Its parent supervises through status writes and + # heartbeats, not pane-idle staleness, and it has no declared-pause + # concept, so it is exempt unconditionally. + if [ "$kind" = adopted ]; then + continue + fi task=$(window_to_task "$w" "$STATE") key=${w//:/_} key=${key//\//_} @@ -866,6 +873,9 @@ EOF if ! status_is_paused_or_captain_held "$last" && [ -e "$STATE/.paused-$key" ]; then clear_pause_tracking "$w" fi + # A secondmate idling on its own watcher is healthy too, unless it + # DECLARED a pause - then it falls through so the paused-recheck logic + # below can run instead of being skipped outright. if [ "$kind" = secondmate ] && ! status_is_paused "$last"; then continue fi diff --git a/docs/cmux-backend.md b/docs/cmux-backend.md index ac39d630fc..052036bbce 100644 --- a/docs/cmux-backend.md +++ b/docs/cmux-backend.md @@ -110,6 +110,52 @@ Firstmate does not attempt to close the macOS window because cmux's socket canno Real tests share the captain's running app rather than creating an isolated cmux session. `tests/cmux-test-safety.sh` permits cleanup only for an exact currently listed `fm-test-` workspace and never enumerates and closes unrelated workspaces or relaunches the app. +## Adopting an existing workspace (fm-adopt) + +`bin/fm-adopt.sh --workspace [--surface ]` registers an ALREADY-RUNNING cmux workspace - one a human started, not a firstmate spawn - as a supervised task. +It creates no treehouse worktree and no branch: it records `kind=adopted`, `mode=adopted`, `backend=cmux`, `window=:`, `cmux_workspace_id=`/`cmux_surface_id=`, and a `worktree=` that points at the workspace's own live `current_directory` (falling back to `$HOME` with a loud notice when cmux reports no usable cwd), so `fm-peek.sh`, `fm-send.sh`, `fm-crew-state.sh`, and the watcher all operate on the workspace. +It never renames the human's workspace: peek and send target the surface by pure UUID because `kind=adopted` makes `fm_backend_expected_label_of_selector` return an empty expected-label, so `fm_backend_cmux_target_ready` verifies liveness with `surface_exists` alone and never applies the scoped-title check that would reject the human's own title. +`bin/fm-teardown.sh` on a `kind=adopted` task un-registers it (removes its `state/.meta`) but NEVER runs `close-workspace` and NEVER runs `treehouse return`, so the human's workspace and its shell are left exactly as they were. +`bin/fm-watch.sh` exempts `kind=adopted` windows from stale-pane wakes, exactly like a secondmate, because a human-driven or idle session being quiet is healthy rather than stuck. + +KNOWN LIMITATION - adopted tasks are session-scoped and do not survive a cmux relaunch. +Workspace ids do not survive an app relaunch, and an adopted workspace keeps the human's own title rather than a firstmate `fm--` title, so title-based recovery (`fm_backend_cmux_list_live`) cannot re-find it after a relaunch the way it re-finds a spawned task. +After a cmux relaunch, re-run `fm-adopt` against the workspace's new UUID to re-register it. + +Empirical verification (2026-07-05, real cmux 0.64.17, macOS aarch64), touching only an `fm-test-`-prefixed throwaway workspace and closing only what this pass itself created: + +``` +$ cmux new-workspace --name fm-test-adopt-probe --focus false --id-format uuids +OK workspace:14 +$ cmux workspace list --json --id-format uuids | jq -r '.workspaces[]|select(.title=="fm-test-adopt-probe")|"\(.id)\t\(.current_directory)"' +1BC9DFBA-067B-4596-A2C7-DD46197D1597 /home/dev/example-project + +# Adopt it by UUID (surface auto-resolved), into a scratch FM_HOME/state: +$ bin/fm-adopt.sh probe-live --workspace 1BC9DFBA-067B-4596-A2C7-DD46197D1597 +adopted probe-live backend=cmux window=1BC9DFBA-067B-4596-A2C7-DD46197D1597:57F297C6-94CA-46A1-A7A2-3292F6B03CC5 worktree=/home/dev/example-project +# state/probe-live.meta recorded kind=adopted, mode=adopted, backend=cmux, +# worktree=/home/dev/example-project, and both cmux ids. + +# Steer and read it by pure UUID (empty expected-label, human title preserved): +$ bin/fm-send.sh fm-probe-live "echo adopted-probe-ok" # rc 0; text landed at the composer +$ bin/fm-peek.sh fm-probe-live 12 # rc 0; showed the live human surface + +# Teardown un-registers WITHOUT closing the workspace: +$ bin/fm-teardown.sh probe-live +teardown probe-live complete (window 1BC9DFBA-...:57F297C6-..., worktree /home/dev/example-project) +Backlog: adopted task probe-live released - its cmux workspace was left running (never closed) ... +# state/probe-live.meta removed; the workspace was still listed afterward: +$ cmux workspace list --json --id-format uuids | jq -r '.workspaces[]|select(.id=="1BC9DFBA-067B-4596-A2C7-DD46197D1597")|.title' +fm-test-adopt-probe + +# Cleanup performed by this verification pass itself (never firstmate teardown): +$ cmux close-workspace --workspace 1BC9DFBA-067B-4596-A2C7-DD46197D1597 +OK workspace:14 +``` + +The `send`/`peek` steps confirm the empty-expected-label path against the REAL app: the workspace's human title (`fm-test-adopt-probe`) is not a firstmate-scoped title, so a title check would have rejected it, yet both operations reached the surface by pure UUID. +The teardown step confirms the never-closes guarantee live: `state/probe-live.meta` was removed while `cmux workspace list` still reported the workspace present. + ## Active limits - cmux is experimental, macOS-only, GUI-first, and requires the app running. diff --git a/docs/scripts.md b/docs/scripts.md index 767980b75a..f85876b76d 100644 --- a/docs/scripts.md +++ b/docs/scripts.md @@ -39,6 +39,7 @@ The shared no-mistakes gate refusal for fleet lifecycle entrypoints is summarize | `fm-supervision-instructions.sh` | Render the session-start primary-harness supervision block or the one-line repair instruction | | `fm-home-seed.sh` | Transactionally provision a secondmate home and maintain `data/secondmates.md` | | `fm-spawn.sh` | Spawn crewmates, scouts, `id=repo` batches, and secondmates on the resolved harness and runtime backend | +| `fm-adopt.sh` | Register an already-running cmux workspace as a supervise-only `kind=adopted` task (no worktree, no branch, no delivery mode, no PR); cmux-only and experimental | | `fm-backend.sh` | Runtime-backend selection, meta helpers, selector resolution, and operation dispatch | | `fm-backend-hometag-lib.sh` | Shared per-installation home-tag derivation for zellij tab and cmux workspace titles | | `fm-composer-lib.sh` | Single fleet-wide owner of composer-content classification for all backends | diff --git a/tests/fm-adopt.test.sh b/tests/fm-adopt.test.sh new file mode 100755 index 0000000000..5a9c091588 --- /dev/null +++ b/tests/fm-adopt.test.sh @@ -0,0 +1,443 @@ +#!/usr/bin/env bash +# tests/fm-adopt.test.sh - bin/fm-adopt.sh (adopt an already-running cmux +# workspace as a supervised task) plus the additive kind=adopted behavior in +# bin/fm-teardown.sh, bin/fm-watch.sh, and bin/fm-backend.sh's +# fm_backend_expected_label_of_selector. +# +# Uses the same fake-cmux-CLI pattern as tests/fm-backend-cmux.test.sh (a small +# LOG-based canned-response `cmux` + real `jq`), the whole-script override +# pattern (FM_STATE_OVERRIDE/FM_ROOT_OVERRIDE), and a real fm-watch.sh subprocess +# for the stale-exemption check, mirroring tests/fm-watch-triage.test.sh. +set -u + +# shellcheck source=tests/lib.sh +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +command -v jq >/dev/null 2>&1 || { echo "skip: jq not found (required by the cmux adapter)"; exit 0; } + +ADOPT="$ROOT/bin/fm-adopt.sh" +TEARDOWN="$ROOT/bin/fm-teardown.sh" +PEEK="$ROOT/bin/fm-peek.sh" +SEND="$ROOT/bin/fm-send.sh" +WATCH="$ROOT/bin/fm-watch.sh" +CREW_STATE="$ROOT/bin/fm-crew-state.sh" + +TMP_ROOT=$(fm_test_tmproot fm-adopt-tests) +# Keep fm-guard.sh's worktree-tangle check inert: FM_ROOT_OVERRIDE points at a +# fresh non-git dir so the guard never alarms about the test runner's own branch, +# the same trick tests/wake-helpers.sh uses. fm-guard is called `|| true` by every +# script under test, so this only keeps stderr clean. +TANGLE_ROOT=$(fm_test_tmproot fm-adopt-tangle-root) + +WS_A="11111111-1111-1111-1111-111111111111" +SF_A="22222222-2222-2222-2222-222222222222" + +# make_cmux_fakebin: a `cmux` stub that logs every invocation (unit-separated +# args, with the socket password prefix) to $FM_CMUX_LOG and returns the canned +# ordered response from $FM_CMUX_RESPONSES/.out. version/ping are handled +# specially (not call-counted). Mirrors tests/fm-backend-cmux.test.sh. +make_cmux_fakebin() { # -> echoes fakebin dir + local dir=$1 fb="$1/fakebin" + mkdir -p "$fb" + cat > "$fb/cmux" <<'SH' +#!/usr/bin/env bash +set -u +LOG="${FM_CMUX_LOG:?}" +RESP="${FM_CMUX_RESPONSES:?}" +COUNT_FILE="$RESP/.count" +{ + printf 'CMUX_SOCKET_PASSWORD=%s' "${CMUX_SOCKET_PASSWORD:-}" + for a in "$@"; do printf '\x1f%s' "$a"; done + printf '\n' +} >> "$LOG" + +if [ "${1:-}" = version ]; then + printf 'cmux %s (97) [abcdef1]\n' "${FM_CMUX_FAKE_VERSION:-0.64.17}" + exit 0 +fi +if [ "${1:-}" = ping ]; then + printf '%s\n' "${FM_CMUX_FAKE_PING:-PONG}" + exit "${FM_CMUX_FAKE_PING_EXIT:-0}" +fi + +next=$(( $(cat "$COUNT_FILE" 2>/dev/null || echo 0) + 1 )) +n=$next +echo "$n" > "$COUNT_FILE" +if [ -f "$RESP/$n.exit" ]; then + exit "$(cat "$RESP/$n.exit")" +fi +[ -f "$RESP/$n.out" ] && cat "$RESP/$n.out" +exit 0 +SH + chmod +x "$fb/cmux" + printf '%s\n' "$fb" +} + +cmux_panes_response() { # + printf '{"panes":[{"selected_surface_id":"%s","surface_ids":["%s"]}]}' "$3" "$3" > "$1/responses/$2.out" +} + +cmux_panes_empty_response() { # + printf '{"panes":[]}' > "$1/responses/$2.out" +} + +cmux_read_screen_response() { # + jq -n --arg t "$3" '{text:$t}' > "$1/responses/$2.out" +} + +cmux_workspace_cwd_response() { # <current_directory> + jq -n --arg id "$3" --arg t "$4" --arg cwd "$5" \ + '{workspaces:[{id:$id,title:$t,current_directory:$cwd}]}' > "$1/responses/$2.out" +} + +# A logging `treehouse` stub: it must NEVER be invoked for an adopt or an +# adopted teardown. Any call leaves a marker file the test then asserts absent. +add_logging_treehouse() { # <fakebin> <marker> + local fb=$1 marker=$2 + cat > "$fb/treehouse" <<SH +#!/usr/bin/env bash +printf 'CALLED %s\n' "\$*" >> "$marker" +exit 0 +SH + chmod +x "$fb/treehouse" +} + +write_adopted_meta() { # <state> <id> <ws> <sf> <worktree> + fm_write_meta "$1/$2.meta" \ + "window=$3:$4" \ + "endpoint_task_id=$2" \ + "worktree=$5" \ + "project=$5" \ + "harness=adopted" \ + "kind=adopted" \ + "mode=adopted" \ + "yolo=off" \ + "model=default" \ + "effort=default" \ + "backend=cmux" \ + "cmux_workspace_id=$3" \ + "cmux_surface_id=$4" +} + +seen_sig() { # <file> + if [ "$(uname)" = Darwin ]; then stat -f '%z:%Fm' "$1" 2>/dev/null; else stat -c '%s:%Y' "$1" 2>/dev/null; fi +} + +hash_pane_like() { # <text> -> same digest fm-watch.sh's hash_pane produces + if command -v md5 >/dev/null 2>&1; then printf '%s' "$1" | md5 -q; else printf '%s' "$1" | md5sum | cut -d' ' -f1; fi +} + +wait_live() { # <pid> [ticks] + local pid=$1 limit=${2:-20} i=0 + while [ "$i" -lt "$limit" ]; do + kill -0 "$pid" 2>/dev/null || return 1 + sleep 0.1 + i=$((i + 1)) + done + return 0 +} + +reap() { kill "$1" 2>/dev/null || true; wait "$1" 2>/dev/null || true; } + +# --- 1. adopt writes the expected meta -------------------------------------- + +test_adopt_writes_meta() { + local dir state wt out + dir="$TMP_ROOT/writes-meta"; state="$dir/state"; wt="$dir/live-cwd" + mkdir -p "$state" "$dir/responses" "$wt" + touch "$state/.last-watcher-beat" + make_cmux_fakebin "$dir" >/dev/null + # 1: target_exists -> surface_exists (list-panes) finds the surface. + cmux_panes_response "$dir" 1 "$SF_A" + # 2: workspace list --json for the worktree cwd probe. + cmux_workspace_cwd_response "$dir" 2 "$WS_A" "my project shell" "$wt" + + out=$( FM_ROOT_OVERRIDE="$TANGLE_ROOT" FM_STATE_OVERRIDE="$state" PATH="$dir/fakebin:$PATH" \ + FM_CMUX_LOG="$dir/log" FM_CMUX_RESPONSES="$dir/responses" \ + "$ADOPT" adopt-x --workspace "$WS_A" --surface "$SF_A" ) \ + || fail "fm-adopt.sh should succeed for a live surface" + + assert_grep "window=$WS_A:$SF_A" "$state/adopt-x.meta" "meta missing window=<ws>:<sf>" + assert_grep "endpoint_task_id=adopt-x" "$state/adopt-x.meta" "meta missing endpoint_task_id" + assert_grep "backend=cmux" "$state/adopt-x.meta" "meta missing backend=cmux" + assert_grep "kind=adopted" "$state/adopt-x.meta" "meta missing kind=adopted" + assert_grep "mode=adopted" "$state/adopt-x.meta" "meta missing mode=adopted" + assert_grep "worktree=$wt" "$state/adopt-x.meta" "meta worktree= did not record the workspace's live cwd" + assert_grep "cmux_workspace_id=$WS_A" "$state/adopt-x.meta" "meta missing cmux_workspace_id" + assert_grep "cmux_surface_id=$SF_A" "$state/adopt-x.meta" "meta missing cmux_surface_id" + assert_no_grep "tasktmp=" "$state/adopt-x.meta" "adopt must not write tasktmp=" + assert_contains "$out" "adopted adopt-x backend=cmux window=$WS_A:$SF_A worktree=$wt" \ + "adopt success line did not mirror fm-spawn's shape" + pass "fm-adopt.sh: writes an adopted meta with window/backend/kind/worktree/cmux ids" +} + +# --- 2. refuses a surface that is not live ---------------------------------- + +test_adopt_refuses_absent_surface() { + local dir state out status + dir="$TMP_ROOT/absent-surface"; state="$dir/state" + mkdir -p "$state" "$dir/responses" + touch "$state/.last-watcher-beat" + make_cmux_fakebin "$dir" >/dev/null + # 1: target_exists -> surface_exists sees no matching surface. + cmux_panes_empty_response "$dir" 1 + + out=$( FM_ROOT_OVERRIDE="$TANGLE_ROOT" FM_STATE_OVERRIDE="$state" PATH="$dir/fakebin:$PATH" \ + FM_CMUX_LOG="$dir/log" FM_CMUX_RESPONSES="$dir/responses" \ + "$ADOPT" adopt-x --workspace "$WS_A" --surface "$SF_A" 2>&1 ) + status=$? + [ "$status" -ne 0 ] || fail "fm-adopt.sh should refuse a non-live surface" + assert_contains "$out" "no live cmux surface" "adopt refusal did not name the dead surface" + assert_absent "$state/adopt-x.meta" "adopt must not write meta when the surface is not live" + pass "fm-adopt.sh: refuses (non-zero + message) when the workspace/surface is not live" +} + +# --- 3. adopt never spawns a workspace and never touches treehouse ---------- + +test_adopt_no_new_workspace_no_treehouse() { + local dir state wt + dir="$TMP_ROOT/no-spawn"; state="$dir/state"; wt="$dir/live-cwd" + mkdir -p "$state" "$dir/responses" "$wt" + touch "$state/.last-watcher-beat" + make_cmux_fakebin "$dir" >/dev/null + add_logging_treehouse "$dir/fakebin" "$dir/treehouse-called" + cmux_panes_response "$dir" 1 "$SF_A" + cmux_workspace_cwd_response "$dir" 2 "$WS_A" "human shell" "$wt" + + FM_ROOT_OVERRIDE="$TANGLE_ROOT" FM_STATE_OVERRIDE="$state" PATH="$dir/fakebin:$PATH" \ + FM_CMUX_LOG="$dir/log" FM_CMUX_RESPONSES="$dir/responses" \ + "$ADOPT" adopt-x --workspace "$WS_A" --surface "$SF_A" >/dev/null \ + || fail "fm-adopt.sh should succeed" + + assert_no_grep $'\x1f''new-workspace' "$dir/log" "adopt must never create a cmux workspace" + assert_absent "$dir/treehouse-called" "adopt must never invoke treehouse (no worktree of its own)" + pass "fm-adopt.sh: never calls new-workspace and never invokes treehouse" +} + +# --- 4. teardown of an adopted task: no close-workspace, no treehouse, meta gone --- + +test_teardown_adopted_leaves_workspace_removes_meta() { + local dir state wt out rc + dir="$TMP_ROOT/teardown-adopted"; state="$dir/state"; wt="$dir/live-cwd" + mkdir -p "$state" "$dir/config" "$dir/responses" "$wt" + touch "$state/.last-watcher-beat" "$dir/log" + make_cmux_fakebin "$dir" >/dev/null + add_logging_treehouse "$dir/fakebin" "$dir/treehouse-called" + write_adopted_meta "$state" adopt-x "$WS_A" "$SF_A" "$wt" + + FM_ROOT_OVERRIDE="$TANGLE_ROOT" FM_STATE_OVERRIDE="$state" FM_CONFIG_OVERRIDE="$dir/config" \ + PATH="$dir/fakebin:$PATH" FM_CMUX_LOG="$dir/log" FM_CMUX_RESPONSES="$dir/responses" \ + "$TEARDOWN" adopt-x > "$dir/stdout" 2> "$dir/stderr" + rc=$? + out=$(cat "$dir/stdout") + + expect_code 0 "$rc" "teardown of an adopted task should succeed" + ! grep -q REFUSED "$dir/stderr" || fail "teardown of an adopted task printed a REFUSED line" + assert_no_grep $'\x1f''close-workspace' "$dir/log" "teardown must never close the human's cmux workspace" + assert_absent "$dir/treehouse-called" "teardown of an adopted task must not invoke treehouse" + assert_absent "$state/adopt-x.meta" "teardown must un-register the adopted task (remove its meta)" + assert_contains "$out" "released" "teardown reminder should say the adoption was released, not prompt a PR done" + pass "fm-teardown.sh: un-registers an adopted task without closing its workspace or running treehouse" +} + +# --- 5. watcher exempts adopted windows from stale-pane wakes ---------------- + +test_watcher_skips_stale_for_adopted() { + local dir state fb out capture window key pane_hash sig pid + dir="$TMP_ROOT/watcher-adopted"; state="$dir/state"; fb="$dir/fakebin" + out="$dir/watch.out"; capture="$dir/pane.txt" + mkdir -p "$state" "$fb" + window="test:fm-adopted-w" + printf 'idle prompt, finished' > "$capture" + # kind=adopted meta (tmux-shaped window so a broken skip WOULD capture+surface). + printf 'window=%s\nkind=adopted\n' "$window" > "$state/adopted-w.meta" + # A captain-relevant status with a primed .seen-* so the signal path stays quiet + # and only the stale path could fire (it must not, for an adopted window). + printf 'done: PR https://example.test/pr/9\n' > "$state/adopted-w.status" + sig=$(seen_sig "$state/adopted-w.status"); printf '%s' "$sig" > "$state/.seen-adopted-w_status" + key=$(printf '%s' "$window" | tr ':/.' '___') + pane_hash=$(hash_pane_like "idle prompt, finished") + printf '%s' "$pane_hash" > "$state/.hash-$key" + printf '1\n' > "$state/.count-$key" + # Minimal fake tmux: recorded_windows reads the meta, but a broken skip would + # capture via this before hashing. + cat > "$fb/tmux" <<'SH' +#!/usr/bin/env bash +set -u +case "${1:-}" in + list-windows) [ -n "${FM_FAKE_TMUX_WINDOW:-}" ] && printf '%s\n' "$FM_FAKE_TMUX_WINDOW"; exit 0 ;; + capture-pane) [ -n "${FM_FAKE_TMUX_CAPTURE:-}" ] && cat "$FM_FAKE_TMUX_CAPTURE"; exit 0 ;; +esac +exit 0 +SH + chmod +x "$fb/tmux" + # Fake fm-crew-state so a broken skip would read "not provably working" and surface. + cat > "$fb/fm-crew-state.sh" <<'SH' +#!/usr/bin/env bash +printf 'state: unknown · source: none · fake\n' +exit 0 +SH + chmod +x "$fb/fm-crew-state.sh" + + PATH="$fb:$PATH" FM_STATE_OVERRIDE="$state" FM_CREW_STATE_BIN="$fb/fm-crew-state.sh" \ + FM_FAKE_TMUX_WINDOW="$window" FM_FAKE_TMUX_CAPTURE="$capture" \ + FM_POLL=1 FM_SIGNAL_GRACE=1 FM_CHECK_INTERVAL=999999 FM_HEARTBEAT=999999 "$WATCH" > "$out" & + pid=$! + if ! wait_live "$pid" 25; then + reap "$pid"; fail "watcher exited for an adopted window's stale pane (should skip like a secondmate): $(cat "$out")" + fi + [ ! -s "$out" ] || { reap "$pid"; fail "adopted stale printed a wake reason: $(cat "$out")"; } + [ ! -s "$state/.wake-queue" ] || { reap "$pid"; fail "adopted stale enqueued a durable wake"; } + reap "$pid" + pass "fm-watch.sh: skips stale-pane wakes for kind=adopted windows (mirrors the secondmate exemption)" +} + +# --- 6. peek/send target an adopted task by pure UUID (empty expected-label) -- + +test_peek_adopted_targets_by_uuid() { + local dir state wt out + dir="$TMP_ROOT/peek-adopted"; state="$dir/state"; wt="$dir/live-cwd" + mkdir -p "$state" "$dir/responses" "$wt" + touch "$state/.last-watcher-beat" + make_cmux_fakebin "$dir" >/dev/null + write_adopted_meta "$state" adopt-x "$WS_A" "$SF_A" "$wt" + # 1: capture's target_ready -> surface_exists (list-panes). 2: read-screen. + cmux_panes_response "$dir" 1 "$SF_A" + cmux_read_screen_response "$dir" 2 $'hello from the adopted surface' + + out=$( FM_ROOT_OVERRIDE="$TANGLE_ROOT" FM_STATE_OVERRIDE="$state" PATH="$dir/fakebin:$PATH" \ + FM_CMUX_LOG="$dir/log" FM_CMUX_RESPONSES="$dir/responses" \ + "$PEEK" fm-adopt-x 5 ) || fail "fm-peek.sh should reach the surface for an adopted task" + + assert_contains "$out" "hello from the adopted surface" "peek did not return the adopted surface content" + assert_grep $'\x1f''read-screen' "$dir/log" "peek did not reach cmux read-screen" + # The empty expected-label means peek never does a scoped-title workspace-list + # check (which would reject the human's own title). + assert_no_grep $'\x1f''workspace'$'\x1f''list' "$dir/log" \ + "peek must target an adopted task by pure UUID, never a title check" + pass "fm-peek.sh: reads an adopted task by pure UUID with an empty expected-label" +} + +test_send_adopted_targets_by_uuid() { + local dir state wt + dir="$TMP_ROOT/send-adopted"; state="$dir/state"; wt="$dir/live-cwd" + mkdir -p "$state" "$dir/responses" "$wt" + touch "$state/.last-watcher-beat" + make_cmux_fakebin "$dir" >/dev/null + write_adopted_meta "$state" adopt-x "$WS_A" "$SF_A" "$wt" + # 1: send_literal target_ready; 2: send; 3: send_key target_ready; 4: send-key; + # 5: composer_state capture target_ready; 6: read-screen -> empty composer. + cmux_panes_response "$dir" 1 "$SF_A" + cmux_panes_response "$dir" 3 "$SF_A" + cmux_panes_response "$dir" 5 "$SF_A" + cmux_read_screen_response "$dir" 6 $' ╭────────────────────────╮\n │ ❯ │\n ╰──────── Composer ─────╯' + + FM_ROOT_OVERRIDE="$TANGLE_ROOT" FM_HOME="$dir" FM_STATE_OVERRIDE="$state" PATH="$dir/fakebin:$PATH" \ + FM_CMUX_LOG="$dir/log" FM_CMUX_RESPONSES="$dir/responses" \ + FM_SEND_RETRIES=1 FM_SEND_SLEEP=0.01 FM_SEND_SETTLE=0 \ + "$SEND" fm-adopt-x "hello captain" || fail "fm-send.sh should submit to an adopted task" + + assert_grep $'\x1f''send'$'\x1f''--workspace'$'\x1f'"$WS_A"$'\x1f''--surface'$'\x1f'"$SF_A"$'\x1f''--'$'\x1f''hello captain' "$dir/log" \ + "send did not type the literal text to the adopted surface by UUID" + assert_no_grep $'\x1f''workspace'$'\x1f''list' "$dir/log" \ + "send must target an adopted task by pure UUID, never a title check" + pass "fm-send.sh: submits to an adopted task by pure UUID with an empty expected-label" +} + +# --- refusal contract ------------------------------------------------------- + +test_adopt_refuses_non_cmux_backend() { + local dir state out status + dir="$TMP_ROOT/wrong-backend"; state="$dir/state" + mkdir -p "$state" + touch "$state/.last-watcher-beat" + out=$( FM_ROOT_OVERRIDE="$TANGLE_ROOT" FM_STATE_OVERRIDE="$state" \ + "$ADOPT" adopt-x --workspace "$WS_A" --backend tmux 2>&1 ) + status=$? + [ "$status" -ne 0 ] || fail "fm-adopt.sh should refuse a non-cmux backend" + assert_contains "$out" "only the cmux backend" "adopt refusal did not name the cmux-only constraint" + assert_absent "$state/adopt-x.meta" "adopt must not write meta on a rejected backend" + pass "fm-adopt.sh: refuses a non-cmux backend with an actionable error" +} + +test_adopt_refuses_already_registered() { + local dir state wt out status + dir="$TMP_ROOT/already-registered"; state="$dir/state"; wt="$dir/live-cwd" + mkdir -p "$state" "$wt" + touch "$state/.last-watcher-beat" + write_adopted_meta "$state" adopt-x "$WS_A" "$SF_A" "$wt" + out=$( FM_ROOT_OVERRIDE="$TANGLE_ROOT" FM_STATE_OVERRIDE="$state" \ + "$ADOPT" adopt-x --workspace "$WS_A" --surface "$SF_A" 2>&1 ) + status=$? + [ "$status" -ne 0 ] || fail "fm-adopt.sh should refuse re-registering an existing task" + assert_contains "$out" "already registered" "adopt refusal did not name the duplicate registration" + pass "fm-adopt.sh: refuses when state/<id>.meta already exists" +} + +# --- 10. refuses adopting the same live surface under a different id -------- + +test_adopt_refuses_duplicate_surface() { + local dir state wt out status + dir="$TMP_ROOT/dup-surface"; state="$dir/state"; wt="$dir/live-cwd" + mkdir -p "$state" "$dir/responses" "$wt" + touch "$state/.last-watcher-beat" + make_cmux_fakebin "$dir" >/dev/null + # The same WS:SF is already supervised under a DIFFERENT task id. + write_adopted_meta "$state" other-x "$WS_A" "$SF_A" "$wt" + # 1: target_exists -> surface_exists (list-panes) finds the live surface, so + # the duplicate-window scan is what must reject it (not the liveness check). + cmux_panes_response "$dir" 1 "$SF_A" + + out=$( FM_ROOT_OVERRIDE="$TANGLE_ROOT" FM_STATE_OVERRIDE="$state" PATH="$dir/fakebin:$PATH" \ + FM_CMUX_LOG="$dir/log" FM_CMUX_RESPONSES="$dir/responses" \ + "$ADOPT" adopt-x --workspace "$WS_A" --surface "$SF_A" 2>&1 ) + status=$? + [ "$status" -ne 0 ] || fail "fm-adopt.sh should refuse a surface already registered under another id" + assert_contains "$out" "already registered as task 'other-x'" \ + "adopt refusal did not name the task already supervising the surface" + assert_absent "$state/adopt-x.meta" "adopt must not write meta when the surface is a duplicate" + pass "fm-adopt.sh: refuses adopting a surface already registered under a different task id" +} + +# --- 11. crew-state liveness for a fresh adopted surface uses list-panes ----- +# Regression: pane_readable for cmux must probe liveness with the structural +# list-panes existence check, NOT read-screen. read-screen fails on a genuinely +# fresh surface that has never been written to (docs/cmux-backend.md), so the old +# capture-based probe misreported a freshly-adopted, untouched surface as gone. + +test_crew_state_fresh_adopted_surface_is_live() { + local dir state wt out + dir="$TMP_ROOT/crew-fresh"; state="$dir/state"; wt="$dir/live-cwd" + mkdir -p "$state" "$dir/responses" "$wt" + touch "$state/.last-watcher-beat" + make_cmux_fakebin "$dir" >/dev/null + write_adopted_meta "$state" adopt-x "$WS_A" "$SF_A" "$wt" + printf 'working: implementing\n' > "$state/adopt-x.status" + # 1: pane_readable -> surface_exists (list-panes) sees the live surface. + cmux_panes_response "$dir" 1 "$SF_A" + # 2: busy-check capture -> its own target_ready surface_exists (list-panes). + cmux_panes_response "$dir" 2 "$SF_A" + # 3: busy-check read-screen FAILS, exactly as it does on a fresh surface. + printf '1' > "$dir/responses/3.exit" + + out=$( FM_ROOT_OVERRIDE="$TANGLE_ROOT" FM_STATE_OVERRIDE="$state" PATH="$dir/fakebin:$PATH" \ + FM_CMUX_LOG="$dir/log" FM_CMUX_RESPONSES="$dir/responses" \ + "$CREW_STATE" adopt-x ) + + assert_not_contains "$out" "backend target gone" \ + "crew-state misread a fresh adopted surface as gone (read-screen liveness regression)" + assert_grep $'\x1f''list-panes' "$dir/log" "crew-state liveness did not use the structural list-panes probe" + pass "fm-crew-state.sh: a fresh adopted cmux surface stays live via list-panes, not read-screen" +} + +test_adopt_writes_meta +test_adopt_refuses_absent_surface +test_adopt_no_new_workspace_no_treehouse +test_teardown_adopted_leaves_workspace_removes_meta +test_watcher_skips_stale_for_adopted +test_peek_adopted_targets_by_uuid +test_send_adopted_targets_by_uuid +test_adopt_refuses_non_cmux_backend +test_adopt_refuses_already_registered +test_adopt_refuses_duplicate_surface +test_crew_state_fresh_adopted_surface_is_live