diff --git a/AGENTS.md b/AGENTS.md index 6fdaa675..72ee7dc3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -93,7 +93,7 @@ state/ volatile runtime signals; gitignored .status appended by crewmates: ": " wake-event lines, not current-state truth .turn-ended touched by turn-end hooks .grok-turnend-token firstmate-owned grok hook registry token for the task; removed by teardown - .meta written by fm-spawn: window=, worktree=, project=, harness=, model=, effort=, kind=, mode=, yolo=, tasktmp=; kind=secondmate also records home= and projects=; a non-default runtime backend records further backend-specific fields (docs/configuration.md "Runtime backend"; bin/fm-backend.sh, section 8); fm-pr-check, including through fm-pr-merge, appends pr= and GitHub's pr_head= when available; fm-x-link appends x_request=, x_request_ts=, and x_followups= for an X-mention-originated task (section 14) + .meta written by fm-spawn: window=, worktree=, project=, harness=, model=, effort=, kind=, mode=, yolo=, tasktmp=; kind=secondmate also records home= and projects=; kind=adopted marks an adopted existing session, written by bin/fm-adopt.sh instead of fm-spawn (section 7); a non-default runtime backend records further backend-specific fields (docs/configuration.md "Runtime backend"; bin/fm-backend.sh, section 8); fm-pr-check, including through fm-pr-merge, appends pr= and GitHub's pr_head= when available; fm-x-link appends x_request=, x_request_ts=, and x_followups= for an X-mention-originated task (section 14) .check.sh optional slow poll you write per task (e.g. merged-PR check) x-watch.check.sh generated X-mode relay poll shim; present only when opted in (section 14) x-inbox/ generated X-mode pending mention payloads; fmx-respond drains it (section 14) @@ -295,6 +295,7 @@ Reconcile reality with your records before doing anything else, working from the 6. For meta with no window, or an endpoint the digest reported dead, reconcile by kind. For ordinary crewmates, check the recorded backend metadata first; use `treehouse status` for treehouse-backed tasks, and the recorded `orca_worktree_id=`/`terminal=` for Orca tasks. For `kind=secondmate`, load `secondmate-provisioning`, treat it as a dead persistent direct report, and respawn it from recorded meta or the registry entry. + For `kind=adopted`, there is no treehouse worktree or branch and `treehouse status` does not apply; a dead endpoint means the human's cmux workspace is gone (typically an app relaunch discarding its UUID), so re-run `bin/fm-adopt.sh` against the workspace's new UUID to re-register it, or tear the task down to un-register it (section 7). 7. Do not reconstruct a secondmate's whole tree from the main home. The main firstmate reconciles only direct reports. Each secondmate is a firstmate in its own home, so it reconciles only work that is already its own and then idles; it never creates new work during recovery. @@ -616,6 +617,14 @@ The crewmate keeps its worktree, loaded context, and repro, but the ship branch The repro becomes the regression test. From there the task is an ordinary ship task through its mode-specific validation, PR or local merge, and Teardown. +### 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 The watcher is the backbone. @@ -688,7 +697,8 @@ Never rely on hooks or status files alone; when a heartbeat wake does reach you, Each task's backend live-task inventory is the ground truth (tmux when `backend=` is absent; a task's meta may record a different `backend=` - herdr, zellij, orca, and cmux are the other implemented, spawn-capable, experimental backends today, docs/herdr-backend.md, docs/zellij-backend.md, docs/orca-backend.md, and docs/cmux-backend.md). For `kind=secondmate`, an idle pane is healthy. A secondmate may be sitting on its own watcher with no visible pane changes, so parent supervision uses status writes plus heartbeat review, not pane-staleness. -`fm-watch.sh` therefore skips stale-pane wakes for windows whose meta records `kind=secondmate`. +For `kind=adopted`, an idle human-driven cmux workspace is healthy too. +`fm-watch.sh` therefore skips stale-pane wakes for windows whose meta records `kind=secondmate` or `kind=adopted`. This exception is narrow: ordinary crewmates still trip stale detection when their pane stops changing without a busy signature. **Watcher liveness is guarded, not just disciplined.** diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6e066fa8..953fddff 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -114,6 +114,7 @@ tests/fm-backend-orca.test.sh # fake Orca CLI unit tests for primiti tests/cmux-test-safety.sh # guarded cleanup helper for real-cmux tests, refusing to close anything except a matching fm-test- workspace tests/fm-backend-cmux.test.sh # fake cmux CLI unit tests for the experimental cmux adapter, including socket auth, title scoping, target recovery, fresh-surface liveness, current-path probing, structural composer verification, and secondmate refusal tests/fm-backend-cmux-smoke.test.sh # real cmux adapter smoke test, skipped when cmux or jq is unavailable or the socket is not password-mode authenticated, using fm-test- workspaces and guarded cleanup +tests/fm-adopt.test.sh # fm-adopt.sh adopt-an-existing-cmux-workspace tests: adopted-meta write, absent-surface/non-cmux/already-registered refusals, no new-workspace or treehouse, teardown un-registers without closing the workspace, watcher stale-pane exemption, and peek/send by pure UUID (empty expected-label) [ "$(readlink CLAUDE.md)" = "AGENTS.md" ] [ "$(readlink .claude/skills)" = "../.agents/skills" ] tmp=$(mktemp -d) && printf 'done: smoke\n' > "$tmp/smoke.status" && FM_STATE_OVERRIDE="$tmp" FM_SIGNAL_GRACE=1 FM_POLL=1 FM_HEARTBEAT=999999 bin/fm-watch-arm.sh # watcher re-arm smoke test (prints arm status, then an actionable signal) diff --git a/README.md b/README.md index d6d31cd0..f87be4aa 100644 --- a/README.md +++ b/README.md @@ -41,8 +41,9 @@ This is.. a directory that turns any agent into your firstmate, and you the capt - **One liaison** - you talk only to the first mate; it dispatches, supervises, escalates only real decisions, and reports plain outcomes. - **A visible crew** - every crewmate works in its own tmux window, experimental herdr/zellij tab, cmux workspace, or Orca terminal you can watch or type into; the first mate reconciles. -- **Disposable worktrees** - each task runs in a clean [treehouse](https://github.com/kunchenguid/treehouse) git worktree, or an Orca-managed worktree when `backend=orca`, so parallel work on one repo never collides. -- **Two task shapes** - ship tasks deliver a change; scout tasks investigate, plan, reproduce, or audit and leave a report. +- **Disposable worktrees** - each spawned ship or scout task runs in a clean [treehouse](https://github.com/kunchenguid/treehouse) git worktree, or an Orca-managed worktree when `backend=orca`, so parallel work on one repo never collides. +- **Two task shapes** - normal dispatched work is either a ship task that delivers a change or a scout task that investigates, plans, reproduces, or audits and leaves a report. +- **Supervise-only adoption** - on cmux, firstmate can register an already-running workspace for peek/send/watch without creating a worktree, branch, PR, or closing that workspace on release. - **Explicit project modes** - each project ships via `no-mistakes`, `direct-PR`, or `local-only`, with an optional `+yolo` autonomy flag. - **Optional secondmates** - opt in to persistent domain supervisors that run from isolated firstmate homes with their own `FM_HOME`, state, projects, and session lock, kept on the primary firstmate version by guarded local fast-forwards. - **Event-driven, zero-token supervision** - a bash watcher sleeps on the fleet and wakes the first mate only when something needs you; on Claude, a primary turn-end hook also blocks a blind stop when work is in flight and supervision is not live. @@ -99,7 +100,7 @@ Setup guides for tmux (the default) and every other supported backend (herdr, ze │crewmate│ │crewmate│ │crewmate│ one autonomous agent each └───┬────┘ └───┬────┘ └───┬────┘ ▼ ▼ ▼ - treehouse worktree, Orca worktree, or isolated secondmate home + treehouse worktree, Orca worktree, isolated secondmate home, or adopted cmux workspace │ ├─ ship: project mode ► PR/local merge ► teardown │ @@ -107,7 +108,8 @@ Setup guides for tmux (the default) and every other supported backend (herdr, ze ``` You chat with the first mate. -It routes each request to a crewmate in its own session endpoint and git worktree, supervises the fleet with a zero-token event-driven watcher, and brings you finished PRs, approved local merges, or investigation reports. +It routes each project request to a crewmate in its own session endpoint and isolated worktree, supervises the fleet with a zero-token event-driven watcher, and brings you finished PRs, approved local merges, or investigation reports. +It can also register an existing cmux workspace for supervise-only follow-along; that path has no branch, PR, or firstmate-owned worktree. Optional secondmates extend this to persistent domain supervisors, dispatch profiles let you steer which harness handles which task, and an opt-in X mode lets the same fleet answer public mentions. Full architecture - the supervision engine, worktree isolation, secondmates, dispatch profiles, project modes, optional X mode, fleet sync, and self-update - is in [docs/architecture.md](docs/architecture.md). @@ -143,7 +145,7 @@ Firstmate's skills live in two separate places with different audiences: - [docs/herdr-backend.md](docs/herdr-backend.md) - setup guide for the experimental herdr backend, plus its verification notes and known gaps. - [docs/zellij-backend.md](docs/zellij-backend.md) - setup guide for the experimental zellij backend, plus its verification notes and known gaps. - [docs/orca-backend.md](docs/orca-backend.md) - setup guide for the experimental Orca backend, plus its lifecycle notes and known gaps. -- [docs/cmux-backend.md](docs/cmux-backend.md) - setup guide for the experimental cmux backend, plus its verification notes and known gaps. +- [docs/cmux-backend.md](docs/cmux-backend.md) - setup guide for the experimental cmux backend, its `fm-adopt` supervise-only path, verification notes, and known gaps. - [docs/turnend-guard.md](docs/turnend-guard.md) - the primary session's structural "no turn ends blind" backstop: verified Claude Code Stop-hook mechanism, scoping, and known gaps. - [docs/scripts.md](docs/scripts.md) - the `bin/` toolbelt reference. - [`AGENTS.md`](AGENTS.md) - firstmate's full operating manual for the orchestrator agent. diff --git a/bin/fm-adopt.sh b/bin/fm-adopt.sh new file mode 100755 index 00000000..c6850d0e --- /dev/null +++ b/bin/fm-adopt.sh @@ -0,0 +1,147 @@ +#!/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; } + +registered_target_owner() { # + local target=$1 meta mw mt id + for meta in "$STATE"/*.meta; do + [ -e "$meta" ] || continue + mw=$(grep '^window=' "$meta" 2>/dev/null | tail -1 | cut -d= -f2- || true) + mt=$(grep '^terminal=' "$meta" 2>/dev/null | tail -1 | cut -d= -f2- || true) + [ "$mw" = "$target" ] || [ "$mt" = "$target" ] || continue + id=$(basename "$meta") + printf '%s' "${id%.meta}" + return 0 + done + return 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" +OWNER=$(registered_target_owner "$T" || true) +[ -z "$OWNER" ] || { echo "error: cmux surface $T already registered by task $OWNER ($STATE/$OWNER.meta)" >&2; exit 1; } +# 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 + +# 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 "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 ff0a9343..8832a7ac 100644 --- a/bin/fm-backend.sh +++ b/bin/fm-backend.sh @@ -30,9 +30,10 @@ # Compatibility contract: a task's meta may omit `backend=`; every reader here # treats that as `tmux` (fm_backend_of_meta), and fm-spawn.sh does not write # `backend=tmux` for a default-backend task, so existing and newly spawned -# default-path metas stay byte-identical. Only a task spawned on a non-tmux +# default-path metas stay byte-identical. Tasks spawned on a non-tmux # spawn-capable backend, currently experimental herdr, zellij, orca, or cmux, -# carries an explicit `backend=` line. +# carry an explicit `backend=` line; `fm-adopt.sh` also writes `backend=cmux` +# for supervise-only adopted workspaces. # # Event-source framing (herdr-addendum "Events as the core abstraction"): a # backend's supervision surface is conceptually an EVENT SOURCE - it produces @@ -352,7 +353,13 @@ fm_backend_expected_label_of_selector() { # case "$raw" in fm-*) meta="$state/${raw#fm-}.meta" - [ -f "$meta" ] && printf '%s' "$raw" + # 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 [ -f "$meta" ] && [ "$(fm_meta_get "$meta" kind)" != adopted ]; then + printf '%s' "$raw" + fi ;; esac } diff --git a/bin/fm-crew-state.sh b/bin/fm-crew-state.sh index 5c11c6ae..f9dcc5b9 100755 --- a/bin/fm-crew-state.sh +++ b/bin/fm-crew-state.sh @@ -130,12 +130,9 @@ LOG_VERB=$(log_verb_of "$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 ;; - *) fm_backend_capture "$TASK_BACKEND" "$1" 1 "$EXPECTED_LABEL" >/dev/null 2>&1 ;; - esac + fm_backend_target_exists "$TASK_BACKEND" "$1" "$EXPECTED_LABEL" } # crew_pane_is_busy: the busy-signature fallback, backend-aware the same way - # fm_backend_busy_state's native semantic state (herdr's agent.get) when diff --git a/bin/fm-session-start.sh b/bin/fm-session-start.sh index 76bd799f..f97a187b 100755 --- a/bin/fm-session-start.sh +++ b/bin/fm-session-start.sh @@ -194,7 +194,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 b52c4e75..575ac50a 100755 --- a/bin/fm-teardown.sh +++ b/bin/fm-teardown.sh @@ -1,8 +1,9 @@ #!/usr/bin/env bash # Tear down a finished task: return the treehouse worktree, release the Orca -# worktree, or retire a secondmate home; kill the recorded runtime endpoint, -# clear volatile state, refresh/prune the project's clone for PR-based ship -# tasks, then print a backlog-refresh reminder. +# worktree, retire a secondmate home, or un-register an adopted cmux workspace; +# kill the recorded runtime endpoint except for kind=adopted, clear volatile +# state, refresh/prune the project's clone for PR-based ship tasks, then print a +# backlog-refresh reminder. # REFUSES if the worktree holds work that has not LANDED, because cleanup # hard-resets/removes the worktree and kills its processes. Work has landed when it is # reachable from any remote-tracking branch (a fork counts as a remote, so @@ -30,6 +31,9 @@ # Orca tasks use the same safety checks, then close the recorded terminal and # remove the recorded worktree through `orca worktree rm`; teardown never guesses # an Orca target from ambient CLI state. +# Adopted tasks (kind=adopted in meta) own no firstmate worktree or runtime +# endpoint; teardown removes firstmate state only and never closes the human's +# cmux workspace. # Secondmates (kind=secondmate in meta) are retired explicitly. Normal # teardown refuses while their home has in-flight crewmate meta files; --force # is the approved discard path that prevalidates child removal targets, discards @@ -273,6 +277,13 @@ work_is_landed() { backlog_refresh_reminder() { local pr done_cmd report_path + # 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) @@ -714,6 +725,11 @@ if [ -d "$WT" ] && [ "$FORCE" != "--force" ]; then : elif [ "$KIND" = scout ]; then : + elif [ "$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. + : else # The fm-spawn hook file is ours, never work product; ignore it in the dirty check. dirty=$(git -C "$WT" status --porcelain 2>/dev/null | grep -vE '^\?\? (\.claude/|\.fm-grok-turnend$)' | head -1 || true) @@ -782,7 +798,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 @@ -797,7 +813,7 @@ elif [ -d "$WT" ] && [ "$KIND" != secondmate ]; then ( cd "$PROJ" && treehouse return --force "$WT" ) fi -if [ "$BACKEND" != orca ]; then +if [ "$BACKEND" != orca ] && [ "$KIND" != adopted ]; then fm_backend_kill "$BACKEND" "$T" "$(meta_value "$META" zellij_tab_id)" "fm-$ID" 2>/dev/null || true fi if [ "$KIND" = secondmate ]; then @@ -810,7 +826,7 @@ remove_grok_turnend_auth "$STATE" "$ID" # Read before the state-file rm below; empty (pre-fix tasks without tasktmp=) is a no-op. [ -n "$TASK_TMP" ] && rm -rf "$TASK_TMP" rm -f "$STATE/$ID.status" "$STATE/$ID.turn-ended" "$STATE/$ID.check.sh" "$STATE/$ID.meta" "$STATE/$ID.pi-ext.ts" "$STATE/$ID.grok-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 554f3d21..15514ba1 100755 --- a/bin/fm-watch.sh +++ b/bin/fm-watch.sh @@ -6,8 +6,10 @@ # is absorbed only when the crew shows POSITIVE evidence it is still working (an # actively-running no-mistakes step, or a backend busy signal), and surfaced # otherwise, so a crew that finishes (or stops and waits) without a current -# working signal is never silently swallowed. While state/.afk exists, the daemon -# owns triage and this watcher queues and exits on every wake. Printed reason lines: +# working signal is never silently swallowed. kind=secondmate and kind=adopted +# endpoints are exempt from stale-pane wakes because their idle panes are healthy. +# While state/.afk exists, the daemon owns triage and this watcher queues and exits +# on every wake. Printed reason lines: # signal: ... status/turn-end signals, surfaced when a listed status # has a captain-relevant verb OR a no-verb signal's crew # is not provably working, unless afk is active @@ -457,9 +459,11 @@ EOF # stale hash is surfaced, absorbed, or timed toward escalation once (.stale-* # remembers the hash already classified). while IFS= read -r w; do - # A secondmate idling on its own watcher is healthy. Its parent supervises - # it through status writes and heartbeats, not pane-idle staleness. - [ "$(window_kind "$w")" = secondmate ] && continue + # A secondmate idling on its own watcher is healthy, and an adopted + # human-driven or idle session being quiet is healthy too - neither is a + # stuck crewmate. Their parent supervises through status writes and + # heartbeats, not pane-idle staleness. + case "$(window_kind "$w")" in secondmate|adopted) continue ;; esac tail40=$(fm_backend_capture "$(window_backend "$w")" "$w" 40 "$(window_label "$w")" 2>/dev/null) || continue h=$(printf '%s' "$tail40" | hash_pane) key=$(printf '%s' "$w" | tr ':/.' '___') diff --git a/docs/architecture.md b/docs/architecture.md index 05972d29..cd7b05aa 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -10,6 +10,7 @@ firstmate's full operating manual for the orchestrator agent itself is [`AGENTS. A zero-token bash watcher (`bin/fm-watch.sh`) sleeps on the fleet, classifies detected wakes in bash, and wakes the first mate only when something is actionable. Actionable wakes include captain-relevant status signals, no-verb signals whose crew is not provably working, check-script output such as PR merge polling or an X mention, stale panes whose crew is not provably working whether their status log looks terminal or non-terminal, provably-working stale panes that persist past `FM_STALE_ESCALATE_SECS`, and heartbeat backstop hits. +Stale-pane detection skips persistent secondmates and supervise-only adopted cmux sessions because their idle panes are healthy by design. Those actionable wakes are written to a durable local queue (`state/.wake-queue`) before detector state advances, so a missed process exit can be recovered by draining the queue. No-verb wakes, such as `working:` notes and bare turn-ended signals, are benign only when `bin/fm-crew-state.sh` reports positive evidence that the crew is still working: an actively running no-mistakes step for that crew's branch or a backend busy signature. Fresh stale panes use the same current-state read before trusting the status log, so an active run or busy pane outranks an old captain-relevant status-log line left behind before validation. @@ -57,13 +58,14 @@ Herdr's container shape is workspace-per-home plus tab-per-task: the primary hom Zellij is experimental and selected only explicitly: treehouse remains its worktree provider too, and its full verification - the resolved "gaps to verify" list from the original design report, the unconditional-exit-0 CLI quirk and its mitigation, the focus-steal-on-new-tab finding, the home-scoped tab-title collision fix, and known gaps - is recorded in `docs/zellij-backend.md`. Zellij's container shape is simpler than herdr's: one shared `firstmate` session, one tab per task, with no per-home workspace split; visible tab titles are scoped by the active home label plus a short hash of the resolved `FM_ROOT` path while callers keep using `fm-`. Orca is experimental and selected only explicitly: Orca owns both worktree and terminal lifecycle, records `orca_worktree_id=` and `terminal=`, and removes worktrees through `orca worktree rm` only after the usual firstmate teardown checks pass. Its current behavior and limitations are recorded in `docs/orca-backend.md`. -cmux is experimental, GUI-first, macOS-only, and can be selected explicitly or by runtime auto-detection from its primary `CMUX_WORKSPACE_ID` marker plus documented fallback signals: treehouse remains its worktree provider (cmux is a session provider only, like herdr/zellij), and its full verification - the socket access setup requirement with Automation mode recommended, the read-screen-fails-on-a-fresh-surface finding, the close-surface-refuses-on-the-last-surface finding, the source-verified runtime marker and fallback behavior, and known gaps - is recorded in `docs/cmux-backend.md`. +cmux is experimental, GUI-first, macOS-only, and can be selected explicitly or by runtime auto-detection from its primary `CMUX_WORKSPACE_ID` marker plus documented fallback signals: treehouse remains its worktree provider for spawned ship/scout tasks (cmux is a session provider only, like herdr/zellij), and its full verification - the socket access setup requirement with Automation mode recommended, the read-screen-fails-on-a-fresh-surface finding, the close-surface-refuses-on-the-last-surface finding, the source-verified runtime marker and fallback behavior, the `fm-adopt` supervise-only path, and known gaps - is recorded in `docs/cmux-backend.md`. cmux's container shape is one workspace per task with one surface, no per-home container split; workspace titles are scoped by the active home label plus a short hash of the resolved `FM_ROOT` path, and `--secondmate` spawns are refused, mirroring Orca. ## Worktrees, not branches in your checkout -Crewmates never intentionally touch your project clone; [treehouse](https://github.com/kunchenguid/treehouse) pools clean worktrees for tmux, herdr, zellij, and cmux tasks, while Orca creates its own worktrees for `backend=orca`. +Crewmates never intentionally touch your project clone; [treehouse](https://github.com/kunchenguid/treehouse) pools clean worktrees for tmux, herdr, zellij, and cmux spawned ship/scout tasks, while Orca creates its own worktrees for `backend=orca`. For ship and scout work, `fm-spawn.sh` refuses to launch unless the resolved task path is a real git worktree root that is distinct from the project primary checkout. +Adopted cmux tasks are the deliberate exception: they register an already-running human workspace for supervision only, create no branch or firstmate-owned worktree, and do not ship project changes. The firstmate repo has one extra exposure because it can dispatch crewmates to work on itself. Its operating checkout (`FM_ROOT`) and the disposable crewmate worktrees are all linked git worktrees of the same repository, so the valid discriminator is branch state, not whether the checkout is linked. @@ -78,6 +80,7 @@ Ship briefs also tell the crewmate to verify `pwd -P` and `git rev-parse --show- ## Two task shapes Ship tasks change projects and ship by project mode (`no-mistakes`, `direct-PR`, or `local-only`); scout tasks investigate, plan, reproduce bugs, or audit, then leave a report at `data//report.md` and never push. +Adopted cmux sessions are not a delivery task shape; they are supervise-only registrations of existing workspaces with no validation, PR, or merge path. ## Dispatch profiles diff --git a/docs/cmux-backend.md b/docs/cmux-backend.md index b66cba30..05652b12 100644 --- a/docs/cmux-backend.md +++ b/docs/cmux-backend.md @@ -17,7 +17,7 @@ Prerequisites: - The cmux app itself, installed from [cmux.com](https://cmux.com) or `brew install --cask cmux`, version 0.64.17 or newer. - `jq`, required to parse cmux's JSON output: `brew install jq` (or your platform's package manager). -- The same universal requirements as tmux (a verified crew harness, git with GitHub auth, node, treehouse, no-mistakes, gh-axi, chrome-devtools-axi, and lavish-axi); treehouse still provides the worktree, cmux only provides the session. +- The same universal requirements as tmux (a verified crew harness, git with GitHub auth, node, treehouse, no-mistakes, gh-axi, chrome-devtools-axi, and lavish-axi); treehouse still provides the worktree for firstmate-spawned ship/scout tasks, while cmux only provides the session. - The cmux CLI binary is not guaranteed to be on `PATH` after a plain app install (see "CLI is not on PATH by default" below) - the adapter falls back to the well-known bundle path automatically, so this is not a blocker, just something to be aware of if you want to run `cmux` yourself from a shell. **One-time socket access setup (required, not optional):** cmux's control socket defaults to `automation.socketControlMode: "cmuxOnly"`, which rejects any CLI process not spawned inside cmux itself - firstmate always drives cmux from an external shell, so this must be changed before `backend=cmux` can work at all. @@ -130,17 +130,19 @@ Which signal is authoritative when: The positive ancestry walk itself is exercised by fake `ps`/`lsappinfo` unit tests rather than live (running a probe process genuinely parented under the captain's live cmux tabs was judged too intrusive, the same posture as this document's screenshot note); every negative live fact above - the strip, the wrapper ping failure, the tmux reparenting, the bundle-id inheritance, the lsappinfo resolution shapes - was verified against the real machine on 2026-07-04. -## Worktree provider stays treehouse +## Worktree provider stays treehouse for spawns cmux is a session provider only, exactly like herdr and zellij (unlike Orca, which also owns the task worktree). -Treehouse remains the worktree provider. +Treehouse remains the worktree provider for firstmate-spawned ship/scout tasks. +`bin/fm-adopt.sh` is the explicit exception: it registers an already-running human workspace and creates no treehouse worktree. The feasibility report searched cmux's source for a shipped git-worktree-owning feature and found only a prototype (`Sources/ExtensionWorktreePrototype.swift`) that is not wired into any CLI verb - `workspace.create --cwd ` just opens a terminal at an existing directory with no opinion about how that directory came to exist. ## Task container shape: one workspace per task, one surface cmux's hierarchy is macOS window -> workspace (a vertical-tab entry, cmux's rough analogue of a herdr/zellij tab) -> surface (a pane/split within that workspace). There is no "session" concept to multiplex the way tmux/herdr/zellij have - there is just "the app" (one running GUI instance, optionally split across native macOS windows). -firstmate uses **one cmux workspace per task**, keyed by the caller-facing `fm-` label, with exactly one surface inside it - mirroring tmux's one-window-per-task and zellij's one-tab-per-task shape. +For firstmate-spawned tasks, firstmate uses **one cmux workspace per task**, keyed by the caller-facing `fm-` label, with exactly one surface inside it - mirroring tmux's one-window-per-task and zellij's one-tab-per-task shape. +Adopted cmux tasks are not created, titled, or owned by this path; they keep the human workspace exactly as-is and are registered by UUID instead. The caller-facing task label stays `fm-`, but the visible cmux workspace title is `fm--`. The home label keeps the same readable identity as herdr's workspace split - `firstmate` for the primary home, or `2ndmate-` when `$FM_HOME/.fm-secondmate-home` contains a secondmate id - and appends a short stable hash of the resolved `FM_ROOT` path. That yields labels like `firstmate-<8hex>` or `2ndmate--<8hex>`, making the visible workspace title `fm-firstmate-<8hex>-` or `fm-2ndmate--<8hex>-`. @@ -154,7 +156,8 @@ There is still no per-home cmux container split (unlike herdr's later refinement A cmux task's `window=` meta field holds `:`, for example `F28BB910-E42C-40F6-AC5C-D92635581EED:A3E9D3A8-BE1D-4055-A567-3525320D2ABF`. Both are bare UUIDs with no embedded colon, so splitting on the first colon is trivially correct (mirrors herdr's/zellij's target-string convention). The meta target is still the UUID pair, not the human title. -The human title is reconstructed internally from the caller-facing `fm-` label as `fm--` whenever cmux needs to create, recover, or list a workspace. +For spawned tasks, the human title is reconstructed internally from the caller-facing `fm-` label as `fm--` whenever cmux needs to create, recover, or list a workspace. +For adopted tasks, no title is reconstructed or checked; operations use the recorded UUID pair with an empty expected label. `` includes the readable home prefix and the short `FM_ROOT` path hash described above. cmux tasks additionally record: @@ -298,6 +301,52 @@ All three tasks' cmux workspaces and worktrees were confirmed fully cleaned up a **Screenshot request (best-effort, explicitly skippable):** the captain separately asked for a screenshot of the cmux window while multiple concurrent tasks were running, to be kept only if it showed a genuinely healthy fleet with no visible errors. One `screencapture -x` (full-screen) attempt was made while all three tasks above were live. It did NOT capture cmux at all: because every firstmate cmux workspace is created with `--focus false` (finding: focus verified to default off), cmux is never the frontmost/active application, so a full-screen capture on this shared machine captured a completely different, unrelated live terminal session's frontmost window instead - one that turned out to show real, sensitive operational content (a different active firstmate/herdr fleet with real secondmate names and conversation). That file was deleted immediately without being viewed further or retained anywhere. No second attempt was made: bringing cmux to the foreground to make it capturable would mean actively focusing/raising its window, which would yank focus away from whatever the captain or another live session currently has in the foreground - the exact disruption `--focus false` exists to avoid - and enumerating other windows to target a background-window capture of just cmux risks the same kind of unrelated-content exposure. Per the captain's explicit allowance, this request was skipped rather than risk either disruption or another accidental capture. +## 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 (see "Workspace ids do not survive a relaunch" above), 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 /Users/thescoho/Developer/reddit-hackathon + +# 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=/Users/thescoho/Developer/reddit-hackathon +# state/probe-live.meta recorded kind=adopted, mode=adopted, backend=cmux, +# worktree=/Users/thescoho/Developer/reddit-hackathon, 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 /Users/thescoho/Developer/reddit-hackathon) +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. + ## Known gaps left for a follow-up - **No event push at all**, not even herdr's semantic busy-state: cmux has agent-awareness elsewhere (Claude Code hooks, session-resume) but nothing exposed over the socket API for generic busy/idle classification, so `fm-watch.sh`'s existing pane-hash + `FM_BUSY_REGEX` poll loop is the ONLY event source for this backend, identical to the tmux/zellij/Orca path. @@ -307,4 +356,5 @@ All three tasks' cmux workspaces and worktrees were confirmed fully cleaned up a - **`--secondmate` spawns are refused** (mirrors Orca's refusal) - no per-home container design (a herdr-style workspace-per-home split, or similar) has been designed or verified for cmux yet. - **The one-time socket-access setup is a real, undocumented-by-upstream onboarding step.** A captain who selects `backend=cmux` without first switching `automation.socketControlMode` away from its `cmuxOnly` default to a viable mode (Automation mode recommended; see "Setup") will see every spawn fail with an actionable error naming the viable modes and pointing back to this document, but there is no way for firstmate to complete that GUI-only setup step on the captain's behalf. - **`bin/fm-bootstrap.sh`'s required-tools list is unchanged** - it does not conditionally add `cmux`/`jq` when a backend selection resolves to cmux, mirroring the same accepted gap already documented for herdr/zellij; the version/tool/reachability gate happens at spawn time instead and refuses loudly. -- **A surface can still die in the brief window between `target_ready` succeeding and the operation's own call running.** That remaining race degrades to "the operation quietly did nothing" - the same class of gap firstmate already tolerates for an unverified send on any backend, caught downstream by `fm-spawn.sh`'s worktree-discovery poll timing out, `fm_backend_cmux_send_text_submit`'s retry loop (which reports `send-failed`/`pending`/`unknown` rather than a false "sent"), or the watcher's stale-pane detection. +- **A surface can still die in the brief window between `target_ready` succeeding and the operation's own call running.** That remaining race degrades to "the operation quietly did nothing" - the same class of gap firstmate already tolerates for an unverified send on any backend, caught downstream for spawned tasks by `fm-spawn.sh`'s worktree-discovery poll timing out, `fm_backend_cmux_send_text_submit`'s retry loop (which reports `send-failed`/`pending`/`unknown` rather than a false "sent"), or the watcher's stale-pane detection. + Adopted tasks deliberately skip stale-pane detection, so a missing adopted surface is reconciled by the next explicit endpoint liveness check or by re-running `fm-adopt` with the workspace's new UUID after a relaunch. diff --git a/docs/configuration.md b/docs/configuration.md index 516475f8..5fc05655 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -20,7 +20,7 @@ The file format is unchanged in both modes; tasks-axi and manual edits produce t For spawn-capable adapters, the runtime session-provider backend controls where task windows/endpoints are created, captured, sent to, watched, and killed. `tmux` is the verified reference backend (see [`docs/tmux-backend.md`](tmux-backend.md)); `herdr`, `zellij`, `orca`, and `cmux` are experimental spawn backends (see [`docs/herdr-backend.md`](herdr-backend.md), [`docs/zellij-backend.md`](zellij-backend.md), [`docs/orca-backend.md`](orca-backend.md), and [`docs/cmux-backend.md`](cmux-backend.md)). -Treehouse remains the worktree provider for tmux, herdr, zellij, and cmux, since herdr, zellij, and cmux are session providers only; Orca provides both the task worktree and terminal endpoint. +Treehouse remains the worktree provider for spawned tmux, herdr, zellij, and cmux ship/scout tasks, since herdr, zellij, and cmux are session providers only; Orca provides both the task worktree and terminal endpoint. New spawns choose the backend in this order: an explicit `--backend` flag firstmate passes when it spawns a task, then `FM_BACKEND`, then the first non-empty line of local gitignored `config/backend`, then runtime auto-detection from `$TMUX`, `HERDR_ENV=1`, or cmux runtime signals, then default `tmux`. If more than one runtime marker is present, detection resolves innermost-first: `$TMUX` is checked before `HERDR_ENV=1`, which is checked before cmux's primary `CMUX_WORKSPACE_ID` marker and its documented fallback signals - tmux or herdr started from inside a cmux terminal is the innermost, currently-executing layer, while cmux itself (a terminal application, not a nestable multiplexer) is always checked last. See [`docs/cmux-backend.md`](cmux-backend.md#runtime-auto-detection) for why cmux can be selected when `CMUX_WORKSPACE_ID` is absent. @@ -38,6 +38,7 @@ A herdr task additionally records `herdr_session=`, `herdr_workspace_id=`, `herd A zellij task additionally records `zellij_session=`, `zellij_tab_id=`, and `zellij_pane_id=`. An Orca task additionally records `orca_worktree_id=` and `terminal=`, with `window=fm-` kept as the shared firstmate alias. A cmux task additionally records `cmux_workspace_id=` and `cmux_surface_id=`. +`bin/fm-adopt.sh` writes the same cmux id fields for a `kind=adopted` task, but it records the human workspace's own live cwd as `worktree=` and does not create a firstmate worktree, branch, or PR path. Herdr workspaces are derived from `FM_HOME`: the primary home uses `firstmate`, and a secondmate home marked by `.fm-secondmate-home` uses `2ndmate-`. Spawn, list-live, and recovery paths read that label from the active home, so a secondmate's own crewmates stay inside that secondmate home's herdr space. For normal herdr operations, `HERDR_SESSION` selects the named session, but destructive test cleanup must not rely on `HERDR_SESSION` alone. diff --git a/docs/scripts.md b/docs/scripts.md index b8aa8aad..030399d2 100644 --- a/docs/scripts.md +++ b/docs/scripts.md @@ -17,6 +17,7 @@ If you have changed away from the firstmate home in an interactive shell, invoke | `fm-turnend-guard.sh` | Claude Code Stop hook, primary-scoped only: blocks (exit 2, exact reason) a primary turn end when work is in flight without a live identity-matched watcher lock and fresh beacon, using Claude Code's own `stop_hook_active` field so it never blocks twice in one turn (docs/turnend-guard.md) | | `fm-home-seed.sh` | Lease/provision a secondmate home transactionally, clone projects, initialize gates, and maintain `data/secondmates.md` | | `fm-spawn.sh` | Spawn one task, several `id=repo` pairs, or a persistent secondmate with `--secondmate`; accepts concrete `--harness`, `--model`, `--effort`, and `--backend` axes; ship/scout spawns require an explicit resolved harness when dispatch profiles are active and an isolated worktree, install per-harness turn-end signaling, and secondmate spawns resolve the secondmate harness plus optional `config/secondmate-harness` model/effort tokens, locally sync the home, propagate declared inheritable config, land herdr tabs in the target home's workspace, land home-scoped zellij tabs in the selected shared zellij session, land cmux workspaces in the shared cmux app, or create Orca worktrees/terminals before launch | +| `fm-adopt.sh` | Register an already-running cmux workspace as a supervise-only `kind=adopted` task (no treehouse worktree, no branch, no delivery mode, no PR), recording the workspace's own live cwd as `worktree=` plus `cmux_workspace_id=`/`cmux_surface_id=` so `fm-peek.sh`, `fm-send.sh`, `fm-crew-state.sh`, and the watcher operate on it by pure UUID; cmux-only and experimental | | `fm-backend.sh` | Runtime session-provider backend selector with explicit/env/config/runtime auto-detection precedence, meta helper, selector resolver, spawn-capability validation, operation dispatcher, and shell-portable backend-name membership for bash-sourced scripts or zsh-sourced diagnostics; defaults absent `backend=` meta to `tmux`; `fm_backend_target_exists` is a cheap read-only alive/dead endpoint check that never starts a server or session; `fm_backend_composer_state` exposes backend composer checks for guarded submit paths | | `fm-backend-hometag-lib.sh` | Shared home-tag derivation for zellij tab titles and cmux workspace titles, using the active `FM_HOME` label plus a short hash of the resolved `FM_ROOT` path | | `backends/tmux.sh` | Verified tmux session-provider adapter used by `fm-backend.sh`; owns create, send, capture, current-path, live-window, and kill primitives | @@ -30,7 +31,7 @@ If you have changed away from the firstmate home in an interactive shell, invoke | `fm-review-diff.sh` | Review a crewmate branch or recorded PR head against the authoritative base, warning and falling back to the local branch when an expected PR head cannot be resolved, with optional `--stat` output | | `fm-marker-lib.sh` | Shared from-firstmate request marker and detector sourced by `fm-send.sh`, `fm-brief.sh`, and tests | | `fm-watch-arm.sh` | Verified per-home watcher re-arm; reports `started`, `healthy`, or `FAILED`; `--restart` relaunches only this home's watcher | -| `fm-watch.sh` | Singleton-safe always-on watcher and default pull-based event source; uses backend-native busy state when available before the shared regex fallback, absorbs no-verb signal and stale wakes only when the crew is provably working, checks that evidence before trusting stale status-log terminality, queues and exits for actionable wakes, and reverts to daemon-owned one-shot behavior while `state/.afk` exists | +| `fm-watch.sh` | Singleton-safe always-on watcher and default pull-based event source; uses backend-native busy state when available before the shared regex fallback, absorbs no-verb signal and stale wakes only when the crew is provably working, skips stale-pane wakes for `kind=secondmate` and `kind=adopted`, checks evidence before trusting stale status-log terminality, queues and exits for actionable wakes, and reverts to daemon-owned one-shot behavior while `state/.afk` exists | | `fm-supervise-daemon.sh` | Presence-gated sub-supervisor for walk-away (`/afk`) supervision: wraps `fm-watch.sh`, uses the shared wake classifier, backend-aware stale rechecks, and tmux/herdr supervisor injection, self-handles routine wakes in bash, and escalates only captain-relevant events as one verified, batched, single-line digest prefixed with a sentinel marker | | `fm-crew-state.sh` | Print one stable current-state line for a crew by reconciling its matching no-mistakes run-step, including coarse cross-branch attribution from `no-mistakes runs`, even when the pane has closed, with backend-aware pane fallback that corroborates native idle/unknown verdicts before the status log | | `fm-tangle-lib.sh` | Shared default-branch resolution and primary-checkout tangle classification sourced by bootstrap and guard | @@ -47,7 +48,7 @@ If you have changed away from the firstmate home in an interactive shell, invoke | `fm-pr-check.sh` | Record `pr=` and GitHub's `pr_head=` when available for a PR-ready task, then arm the watcher's merge poll | | `fm-pr-merge.sh` | Require a full GitHub PR URL, record `pr=` and available `pr_head=` via `fm-pr-check.sh`, parse it into `gh-axi pr merge --repo /`, default to `--squash` unless a merge method is forwarded, and reject malformed URLs or repo overrides | | `fm-promote.sh` | Promote a scout task in place so it becomes a protected ship task | -| `fm-teardown.sh` | Return a clean, landed ship worktree or retire/release a secondmate home; requires scout reports, checks child work, removes firstmate-owned hook artifacts, closes recorded backend endpoints under their owning home context, releases Orca worktrees through `orca worktree rm`, and prints the backlog-backend reminder | +| `fm-teardown.sh` | Return a clean, landed ship worktree, retire/release a secondmate home, or un-register a `kind=adopted` task without ever closing its cmux workspace; requires scout reports, checks child work, removes firstmate-owned hook artifacts, closes non-adopted recorded backend endpoints under their owning home context, releases Orca worktrees through `orca worktree rm`, and prints the backlog-backend reminder | | `fm-harness.sh` | Detect the running harness; resolve the effective crewmate (`crew`) or secondmate-launch (`secondmate`) harness; expose optional `config/secondmate-harness` model and effort tokens with `secondmate-model` and `secondmate-effort` | | `fm-lock.sh` | Per-home firstmate session lock | | `fm-x-lib.sh` | Shared X-mode `.env`, alternate env-file, relay, dry-run config, reply-thread splitting, outbound image payloads, and task-to-X-request meta-link helpers | diff --git a/tests/fm-adopt.test.sh b/tests/fm-adopt.test.sh new file mode 100755 index 00000000..f605ff16 --- /dev/null +++ b/tests/fm-adopt.test.sh @@ -0,0 +1,424 @@ +#!/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" \ + "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 "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_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" +} + +test_adopt_refuses_target_registered_by_another_task() { + local dir state wt out status + dir="$TMP_ROOT/target-already-registered"; 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" existing-task "$WS_A" "$SF_A" "$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" 2>&1 ) + status=$? + [ "$status" -ne 0 ] || fail "fm-adopt.sh should refuse adopting a target already owned by another task" + assert_contains "$out" "already registered by task existing-task" "adopt refusal did not name the existing target owner" + assert_absent "$state/adopt-x.meta" "adopt must not write meta when another task already owns the target" + pass "fm-adopt.sh: refuses when another meta already owns the target window" +} + +test_crew_state_adopted_fresh_cmux_surface_uses_structural_liveness() { + local dir state wt out + dir="$TMP_ROOT/fresh-surface-state"; 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: adopted surface is live\n' > "$state/adopt-x.status" + cmux_panes_response "$dir" 1 "$SF_A" + printf '1\n' > "$dir/responses/2.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 ) || fail "fm-crew-state.sh should report an adopted fresh cmux surface" + + assert_contains "$out" "state: working" "crew-state did not fall through to the status log for an adopted fresh cmux surface" + assert_contains "$out" "source: status-log" "crew-state should use the status log after structural liveness succeeds" + assert_not_contains "$out" "backend target gone" "crew-state should not treat a fresh unreadable cmux surface as gone" + pass "fm-crew-state.sh: adopted cmux liveness uses target_exists before status-log fallback" +} + +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_target_registered_by_another_task +test_crew_state_adopted_fresh_cmux_surface_uses_structural_liveness