diff --git a/.agents/skills/firstmate-orca/SKILL.md b/.agents/skills/firstmate-orca/SKILL.md index ae7d648c8ff..f8261cacdb1 100644 --- a/.agents/skills/firstmate-orca/SKILL.md +++ b/.agents/skills/firstmate-orca/SKILL.md @@ -51,7 +51,7 @@ Do not manually patch metadata to make an externally-created Orca terminal look ## Supervision Use `bin/fm-peek.sh`, `bin/fm-send.sh`, `bin/fm-crew-state.sh`, and `bin/fm-teardown.sh` for routine operation. -For steer messages, send short lines through `bin/fm-send.sh fm- '...'`. +For steer messages, send short lines through `bin/fm-send.sh '...'`; the stable `fm-` alias also works. Put long instructions in the task brief or a temporary file and point the crewmate at that file. When supervising, treat `state/.meta` as the routing record and Orca's own ids as backend implementation details. diff --git a/.agents/skills/harness-adapters/SKILL.md b/.agents/skills/harness-adapters/SKILL.md index fe862fc4cbf..89d9246d30c 100644 --- a/.agents/skills/harness-adapters/SKILL.md +++ b/.agents/skills/harness-adapters/SKILL.md @@ -120,7 +120,7 @@ After those settings are loaded, hook command resolution is still cwd-sensitive | Skill invocation | `$` (e.g. `$no-mistakes`); `/` is claude-only and codex rejects it as "Unrecognized command" | A `$` invocation opens a `$`-autocomplete (skill) popup, the same hazard as the `/` slash popup: submitting too fast lets the popup swallow the Enter, so the invocation never lands. -`fm-send` handles it the same way it handles `/` - it gives the popup a longer settle (1.2s) between typing and the first Enter, with the target backend's submit retry as the safety net - but the `$` settle is scoped to `harness=codex`, read from the target's `state/.meta`. +`fm-send` handles it the same way it handles `/` - it gives the popup a longer settle (1.2s) between typing and the first Enter, with the target backend's submit retry as the safety net - but the `$` settle is scoped to `harness=codex`, read from the target metadata for exact task ids or legacy `fm-` labels. That scope matters because, unlike `/`, a leading `$` commonly starts ordinary text (`$5/month`, `$HOME`), so a universal `$` rule would needlessly slow plain steers to claude/opencode/pi; only a codex target receiving a `$...` message gets the popup-settle. An explicit `session:window` target has no meta, so its harness is unknown and treated as non-codex (the safe fast-path default). This is why the validation trigger (`$no-mistakes`) to a codex crew now lands on the first Enter instead of biting the popup. diff --git a/AGENTS.md b/AGENTS.md index 7ca82d29290..042bee789b5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -186,7 +186,7 @@ Otherwise it prints one line per problem or capability fact; handle each: A secondmate that was skipped, already current, or whose advance changed no instructions is not listed and must not be disturbed. - `FMX: X mode on ...` / `FMX: X mode off ...` - bootstrap confirmed or removed the local X-mode poll artifacts; follow section 14 for watcher cadence restart only when a running watcher needs the transition applied immediately. -Bootstrap's fleet refresh is bounded by `FM_FLEET_SYNC_BOOTSTRAP_TIMEOUT` seconds, default 20; a timeout is reported as a `FLEET_SYNC` skip and does not block startup. +Bootstrap's fleet refresh is bounded by `FM_FLEET_SYNC_BOOTSTRAP_TIMEOUT` seconds when set, otherwise by a fleet-size-aware default with a 20 second floor; a timeout is reported as a `FLEET_SYNC` skip and does not block startup. The digest's context section already contains `data/projects.md`, the fleet registry of what each project is; `data/secondmates.md`, the registered secondmate routing table used to route work by scope (section 7); `data/captain.md`, this captain's curated preferences and working style; and `data/learnings.md`, fleet-local operational facts and gotchas this home has captured. Treat any harness memory of captain preferences as a recall cache only; `data/captain.md` is the canonical, harness-portable home. @@ -467,10 +467,10 @@ Read `data/secondmates.md` before dispatching and compare the work request to ea Route by the nature of the task, not just the project name. A project may appear in several `projects:` clone lists, so choose the secondmate whose natural-language scope actually fits the work, such as triage versus feature development. If the resolved project is `local-only`, keep the work with the main firstmate even when a secondmate scope sounds relevant. -If a secondmate's scope fits, steer that secondmate with one concise instruction via `bin/fm-send.sh fm- ''` and let it run the normal lifecycle inside its own home. -The bare `fm-` target resolves through this home's `state/.meta`; pass an explicit backend target only when intentionally targeting an endpoint outside this firstmate home. +If a secondmate's scope fits, steer that secondmate with one concise instruction via `bin/fm-send.sh ''` and let it run the normal lifecycle inside its own home. +The stable `fm-` label printed by lifecycle commands still works, but exact task ids resolve first through this home's `state/.meta`; pass an explicit backend target containing `:` only when intentionally targeting an endpoint outside this firstmate home. A secondmate is itself a firstmate, so a request reaches it in its own chat, which you never read - the return channel that wakes you is its status file. -So `fm-send` to a bare `fm-` whose meta is `kind=secondmate` automatically prepends a from-firstmate marker (`bin/fm-marker-lib.sh`); the secondmate recognizes it and returns its answer via its status file, or via a doc under its home plus a status pointer for a detailed response, never only in chat. +So `fm-send` to a task selector whose meta is `kind=secondmate` automatically prepends a from-firstmate marker (`bin/fm-marker-lib.sh`); the secondmate recognizes it and returns its answer via its status file, or via a doc under its home plus a status pointer for a detailed response, never only in chat. Expect and read that response on the status/doc path the same way you read any other status signal; do not peek the secondmate's chat for the answer. A captain typing directly into the secondmate's window is unmarked and stays a conversational captain intervention, so do not relay captain-destined chat through this path; the marker is applied only by `fm-send` to a `kind=secondmate` target. Do not spawn a direct crewmate for work that belongs to a secondmate scope unless the secondmate is blocked or the captain explicitly redirects it. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 25dc5191b85..de6b9b20f41 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -82,7 +82,7 @@ tests/fm-wake-daemon-lifecycle-e2e.test.sh # watcher + daemon lifecycle e2e: res tests/fm-composer-ghost.test.sh # dim-ghost stripping, ghost-only composer detection, and escape-free peek tests tests/fm-afk-inject-e2e.test.sh # private-socket end-to-end test of the afk injection path (partial-input deferral, swallowed-Enter retry) tests/fm-afk-inject-herdr-e2e.test.sh # real-herdr end-to-end test of the afk daemon's herdr transport, on an isolated throwaway HERDR_SESSION: partial-input deferral, swallowed-Enter retry, a normal digest, and the max-defer wedge alarm on a persistently pending composer -tests/fm-bootstrap.test.sh # bootstrap dependency, feature-probe, and crew-dispatch reporting tests +tests/fm-bootstrap.test.sh # bootstrap dependency, feature-probe, fleet-sync timeout, and crew-dispatch reporting tests tests/fm-session-start.test.sh # fm-session-start.sh: ABSENT vs empty-vs-present digest files, lock-refusal read-only path skipping every mutating step, diagnostics-first section ordering, status-tail bounding, tmux/herdr endpoint liveness, and composition of the real fm-lock/fm-bootstrap/fm-wake-drain scripts tests/fm-grok-harness.test.sh # grok adapter spawn hook, token guard, teardown cleanup, and session-lock detection tests tests/fm-fleet-sync.test.sh # project clone refresh: safe detached recovery, STUCK drift reports, benign skips, single-project name resolution, and bootstrap relay diff --git a/bin/backends/herdr.sh b/bin/backends/herdr.sh index 9889fff431a..87635b11fbc 100644 --- a/bin/backends/herdr.sh +++ b/bin/backends/herdr.sh @@ -25,8 +25,9 @@ # remainder is the whole pane id - fm_backend_herdr_parse_target splits on the # first colon only). This is the value stored in a herdr task's meta window= # field and is what fm_backend_resolve_selector already returns unchanged for -# both the fm- and explicit backend-target forms (that function has no -# herdr-specific logic; it just returns meta's window= verbatim). +# exact task-id, legacy fm-, and explicit backend-target forms (that +# function has no herdr-specific logic; it just returns meta's window= +# verbatim). # # Recovery/orphan discovery (ids may not deterministically match live state # after a server restart in a differently-configured session; see the diff --git a/bin/backends/tmux.sh b/bin/backends/tmux.sh index dc31f953054..da78f4457d4 100644 --- a/bin/backends/tmux.sh +++ b/bin/backends/tmux.sh @@ -22,7 +22,7 @@ . "$FM_BACKEND_LIB_DIR/fm-tmux-lib.sh" # fm_backend_tmux_resolve_bare_selector: the live-window-listing fallback for a -# selector that is neither "session:window" nor a bare "fm-" routed +# selector that is neither an explicit target nor a task selector routed # through meta - an ad hoc window name with no recorded task. Mirrors the # `tmux list-windows -a ... | grep` pipeline that used to live inline in # fm-send.sh's and fm-peek.sh's own (until now duplicated) resolve(). diff --git a/bin/backends/zellij.sh b/bin/backends/zellij.sh index 4df2ac21419..02fc5313fdc 100644 --- a/bin/backends/zellij.sh +++ b/bin/backends/zellij.sh @@ -83,8 +83,8 @@ # target. Mitigated: send/capture/cwd ops verify session liveness first # (fm_backend_zellij_session_exists, a passive list-sessions query, never # auto-creating), verify the specific pane still appears in list-panes JSON, -# and, for metadata-routed fm- operations, verify the pane's tab still -# matches the expected caller-facing task label through the home-scoped or +# and, for metadata-routed task selector operations, verify the pane's tab +# still matches the expected caller-facing task label through the home-scoped or # unambiguous legacy title before use. Kill verifies the session and, when # teardown supplies an expected tab label, verifies a tab id still matches # that label before closing it. Output-SHAPE validation (a bare integer tab @@ -595,8 +595,8 @@ fm_backend_zellij_list_live() { # # posture. Rare path in practice (zellij tasks normally carry meta); # best-effort. Not wired into fm_backend_resolve_selector's dispatcher # (bin/fm-backend.sh), mirroring herdr: that bare-selector fallback stays -# tmux-only by design, and zellij/herdr tasks are targeted via fm- meta or -# an explicit recorded target. +# tmux-only by design, and zellij/herdr tasks are targeted via task-selector +# meta or an explicit recorded target. fm_backend_zellij_resolve_bare_selector() { # local name=$1 scoped sessions session tabs tab_id count=0 pane_id bare_session='' bare_tab_id='' scoped=$(fm_backend_zellij_scoped_title "$name") diff --git a/bin/fm-backend.sh b/bin/fm-backend.sh index 8a69cafb13d..2aaeeaf30ad 100644 --- a/bin/fm-backend.sh +++ b/bin/fm-backend.sh @@ -335,14 +335,36 @@ fm_backend_meta_for_window() { # return 1 } -fm_backend_of_selector() { # - local raw=$1 resolved=$2 state=$3 meta +fm_backend_task_id_for_selector() { # + local raw=$1 state=$2 id + case "$raw" in + *:*) return 1 ;; + esac + if [ -f "$state/$raw.meta" ]; then + printf '%s' "$raw" + return 0 + fi case "$raw" in fm-*) - meta="$state/${raw#fm-}.meta" - [ -f "$meta" ] && { fm_backend_of_meta "$meta"; return 0; } + id=${raw#fm-} + [ -f "$state/$id.meta" ] || return 1 + printf '%s' "$id" + return 0 ;; esac + return 1 +} + +fm_backend_meta_for_selector() { # + local raw=$1 state=$2 id + id=$(fm_backend_task_id_for_selector "$raw" "$state") || return 1 + printf '%s/%s.meta' "$state" "$id" +} + +fm_backend_of_selector() { # + local raw=$1 resolved=$2 state=$3 meta + meta=$(fm_backend_meta_for_selector "$raw" "$state" 2>/dev/null || true) + [ -n "$meta" ] && { fm_backend_of_meta "$meta"; return 0; } if [ -n "$resolved" ]; then meta=$(fm_backend_meta_for_window "$resolved" "$state" 2>/dev/null || true) [ -n "$meta" ] && { fm_backend_of_meta "$meta"; return 0; } @@ -351,13 +373,10 @@ fm_backend_of_selector() { # } fm_backend_expected_label_of_selector() { # - local raw=$1 state=$2 meta - case "$raw" in - fm-*) - meta="$state/${raw#fm-}.meta" - [ -f "$meta" ] && printf '%s' "$raw" - ;; - esac + 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" + return 0 } # fm_backend_source: source the named backend's adapter file, once per shell. @@ -404,15 +423,18 @@ fm_backend_source() { # } # fm_backend_resolve_selector: resolve a raw fm-send.sh/fm-peek.sh style -# selector to a live session-provider target. Three forms, in order: +# selector to a live session-provider target. Four forms, in order: # target with ":" used as-is (the escape hatch for a window/pane outside # this firstmate home) - backend-independent, a literal string. -# "fm-" routed through /.meta's backend target +# exact task id routed through /.meta's backend target # (`window=` normally, `terminal=` for Orca) - # backend-independent, a stored value, NOT re-verified # against a live backend inventory (matches today's # behavior: tmux window names can be trusted from meta # without a live re-check). +# "fm-" legacy task window label fallback routed through +# /.meta when no exact +# /fm-.meta exists. # anything else first matched against recorded `window=`/`terminal=` # metadata, then treated as an ad hoc bare window name and # resolved by searching the legacy tmux live inventory. @@ -423,16 +445,18 @@ fm_backend_resolve_selector() { # printf '%s' "$raw" return 0 ;; + esac + meta=$(fm_backend_meta_for_selector "$raw" "$state" 2>/dev/null || true) + if [ -n "$meta" ]; then + window=$(fm_backend_target_of_meta "$meta") + [ -n "$window" ] || { echo "error: no backend target recorded in $meta" >&2; return 1; } + printf '%s' "$window" + return 0 + fi + case "$raw" in fm-*) - meta="$state/${raw#fm-}.meta" - if [ ! -f "$meta" ]; then - echo "error: no metadata for $raw in $state; pass session:window to target a window outside this firstmate home" >&2 - return 1 - fi - window=$(fm_backend_target_of_meta "$meta") - [ -n "$window" ] || { echo "error: no backend target recorded in $meta" >&2; return 1; } - printf '%s' "$window" - return 0 + echo "error: no metadata for $raw in $state; pass session:window to target a window outside this firstmate home" >&2 + return 1 ;; *) meta=$(fm_backend_meta_for_window "$raw" "$state" 2>/dev/null || true) diff --git a/bin/fm-bootstrap.sh b/bin/fm-bootstrap.sh index 8a113645071..625723c428c 100755 --- a/bin/fm-bootstrap.sh +++ b/bin/fm-bootstrap.sh @@ -55,7 +55,12 @@ # the relay poll shim and 30s cadence config, and prints an FMX line. # Fleet sync fetches, fast-forwards safe default-branch states, reports # recovered and STUCK clone drift, and prunes gone local branches; it is -# bounded by FM_FLEET_SYNC_BOOTSTRAP_TIMEOUT, default 20s. +# bounded by FM_FLEET_SYNC_BOOTSTRAP_TIMEOUT when it is a non-empty +# numeric override, while non-numeric values fall back to 20s. +# When the override is unset or blank, the timeout is +# max(20, 5 + 3 * origin-backed project clone count). A timed-out +# refresh relays any completed fm-fleet-sync.sh output before the +# aggregate timeout skip line with timeout and elapsed seconds. # Set FM_FLEET_PRUNE=0 to skip branch pruning during that refresh. # Set FM_BOOTSTRAP_DETECT_ONLY=1 to skip the four MUTATING sweeps # (secondmate_sync, secondmate_liveness_sweep, x_mode_setup, @@ -77,41 +82,92 @@ FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" PROJECTS="${FM_PROJECTS_OVERRIDE:-$FM_HOME/projects}" CONFIG="${FM_CONFIG_OVERRIDE:-$FM_HOME/config}" STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}" -# shellcheck source=bin/fm-tasks-axi-lib.sh +# shellcheck source=bin/fm-tasks-axi-lib.sh disable=SC1091 . "$SCRIPT_DIR/fm-tasks-axi-lib.sh" -# shellcheck source=bin/fm-tangle-lib.sh +# shellcheck source=bin/fm-tangle-lib.sh disable=SC1091 . "$SCRIPT_DIR/fm-tangle-lib.sh" -# shellcheck source=bin/fm-ff-lib.sh +# shellcheck source=bin/fm-ff-lib.sh disable=SC1091 . "$SCRIPT_DIR/fm-ff-lib.sh" -# shellcheck source=bin/fm-config-inherit-lib.sh +# shellcheck source=bin/fm-config-inherit-lib.sh disable=SC1091 . "$SCRIPT_DIR/fm-config-inherit-lib.sh" -# shellcheck source=bin/fm-x-lib.sh +# shellcheck source=bin/fm-x-lib.sh disable=SC1091 . "$SCRIPT_DIR/fm-x-lib.sh" -# shellcheck source=bin/fm-clickstack-lib.sh +# shellcheck source=bin/fm-clickstack-lib.sh disable=SC1091 . "$SCRIPT_DIR/fm-clickstack-lib.sh" -# shellcheck source=bin/fm-backend.sh +# shellcheck source=bin/fm-backend.sh disable=SC1091 . "$SCRIPT_DIR/fm-backend.sh" +fleet_sync_origin_backed_project_count() { + local count proj + count=0 + [ -d "$PROJECTS" ] || { echo 0; return 0; } + for proj in "$PROJECTS"/*; do + [ -d "$proj" ] || continue + git -C "$proj" rev-parse --git-dir >/dev/null 2>&1 || continue + git -C "$proj" remote get-url origin >/dev/null 2>&1 || continue + count=$((count + 1)) + done + echo "$count" +} + +fleet_sync_bootstrap_timeout() { + local count timeout + if [ -n "${FM_FLEET_SYNC_BOOTSTRAP_TIMEOUT:-}" ]; then + case "$FM_FLEET_SYNC_BOOTSTRAP_TIMEOUT" in + *[!0-9]*) echo 20 ;; + *) echo "$FM_FLEET_SYNC_BOOTSTRAP_TIMEOUT" ;; + esac + return 0 + fi + + count=$(fleet_sync_origin_backed_project_count) + timeout=$((5 + (3 * count))) + [ "$timeout" -ge 20 ] || timeout=20 + echo "$timeout" +} + +fleet_sync_relay_filtered_output() { + local tmp=$1 line + while IFS= read -r line; do + case "$line" in + *': skipped: local-only project') ;; + *': skipped: no origin remote') ;; + *': skipped:'*) echo "FLEET_SYNC: $line" ;; + *': STUCK:'*) echo "FLEET_SYNC: $line" ;; + *': recovered:'*) echo "FLEET_SYNC: $line" ;; + esac + done < "$tmp" +} + +fleet_sync_relay_all_output() { + local tmp=$1 line + while IFS= read -r line; do + [ -n "$line" ] || continue + echo "FLEET_SYNC: $line" + done < "$tmp" +} + fleet_sync() { [ -x "$FM_ROOT/bin/fm-fleet-sync.sh" ] || return 0 [ -d "$PROJECTS" ] || return 0 tmp=$(mktemp "${TMPDIR:-/tmp}/fm-fleet-sync.XXXXXX" 2>/dev/null) || return 0 + timeout=$(fleet_sync_bootstrap_timeout) monitor_was_on=0 case $- in *m*) monitor_was_on=1 ;; esac set -m 2>/dev/null || true "$FM_ROOT/bin/fm-fleet-sync.sh" >"$tmp" 2>/dev/null & pid=$! - timeout=${FM_FLEET_SYNC_BOOTSTRAP_TIMEOUT:-20} - case "$timeout" in ''|*[!0-9]*) timeout=20 ;; esac start=$SECONDS while jobs -r -p | grep -qx "$pid"; do - if [ $((SECONDS - start)) -ge "$timeout" ]; then + elapsed=$((SECONDS - start)) + if [ "$elapsed" -ge "$timeout" ]; then kill -TERM "-$pid" 2>/dev/null || kill "$pid" 2>/dev/null || true wait "$pid" 2>/dev/null || true [ "$monitor_was_on" -eq 1 ] || set +m 2>/dev/null || true - echo "FLEET_SYNC: fleet: skipped: bootstrap refresh timed out" + fleet_sync_relay_all_output "$tmp" + echo "FLEET_SYNC: fleet: skipped: bootstrap refresh timed out (timeout=${timeout}s elapsed=${elapsed}s)" rm -f "$tmp" return 0 fi @@ -120,15 +176,7 @@ fleet_sync() { wait "$pid" 2>/dev/null || true [ "$monitor_was_on" -eq 1 ] || set +m 2>/dev/null || true - while IFS= read -r line; do - case "$line" in - *': skipped: local-only project') ;; - *': skipped: no origin remote') ;; - *': skipped:'*) echo "FLEET_SYNC: $line" ;; - *': STUCK:'*) echo "FLEET_SYNC: $line" ;; - *': recovered:'*) echo "FLEET_SYNC: $line" ;; - esac - done < "$tmp" + fleet_sync_relay_filtered_output "$tmp" rm -f "$tmp" } diff --git a/bin/fm-peek.sh b/bin/fm-peek.sh index 8d6183a7a47..97d2ffe2d25 100755 --- a/bin/fm-peek.sh +++ b/bin/fm-peek.sh @@ -1,8 +1,8 @@ #!/usr/bin/env bash # Print the tail of a crewmate endpoint (bounded, for cheap diagnosis). # Usage: fm-peek.sh [lines=40] -# may be a bare firstmate task name (fm-xyz), resolved through -# this home's state/.meta, or an explicit backend target. +# may be an exact task id, a legacy fm- task label resolved +# through this home's state/.meta, or an explicit backend target. set -eu SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" diff --git a/bin/fm-send.sh b/bin/fm-send.sh index af887ef8e08..f0d85d22988 100755 --- a/bin/fm-send.sh +++ b/bin/fm-send.sh @@ -1,8 +1,8 @@ #!/usr/bin/env bash # Send one line of literal text to a crewmate endpoint, then Enter. # Usage: fm-send.sh -# may be a bare firstmate task name (fm-xyz), resolved through -# this home's state/.meta, or an explicit backend target. +# may be an exact task id, a legacy fm- task label resolved +# through this home's state/.meta, or an explicit backend target. # Special keys instead of text: fm-send.sh --key Enter # Key support is backend-specific: tmux/herdr support Escape, Enter, and C-c; # Orca currently supports Enter and C-c only, and rejects Escape. @@ -18,7 +18,7 @@ # Slash commands, and codex `$...` skill invocations resolved through harness # meta, get a longer pre-Enter settle so completion popups do not swallow Enter. # -# From-firstmate marker: when the resolved target is a bare `fm-` whose meta +# From-firstmate marker: when the resolved target is a task selector whose meta # records kind=secondmate, the text is prefixed with the from-firstmate marker # (bin/fm-marker-lib.sh) so the secondmate routes its reply via its status file # or a status-pointed doc instead of stranding it in chat the main firstmate @@ -46,40 +46,31 @@ STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}" RAW_TARGET=$1 T=$(fm_backend_resolve_selector "$1" "$STATE") +TARGET_META=$(fm_backend_meta_for_selector "$RAW_TARGET" "$STATE" 2>/dev/null || true) shift -# Mark a from-firstmate -> secondmate request. Only a bare `fm-` target, -# resolved through this home's meta and recording kind=secondmate, is marked: the +# Mark a from-firstmate -> secondmate request. Only a task selector resolved +# through this home's meta and recording kind=secondmate is marked: the # secondmate then routes its reply via the status path (see fm-marker-lib.sh). # An explicit backend target (the escape hatch for endpoints outside this home) # and any crewmate/scout target are left unmarked, and so is the --key path. MARK_PREFIX="" -case "$RAW_TARGET" in - fm-*) - meta="$STATE/${RAW_TARGET#fm-}.meta" - if [ -f "$meta" ] && grep -q '^kind=secondmate$' "$meta" 2>/dev/null; then - MARK_PREFIX="$FM_FROMFIRST_MARK" - fi - ;; -esac +if [ -n "$TARGET_META" ] && grep -q '^kind=secondmate$' "$TARGET_META" 2>/dev/null; then + MARK_PREFIX="$FM_FROMFIRST_MARK" +fi # Resolve the target's harness from its meta (recorded by fm-spawn), used only to -# scope the codex `$` popup-settle below. A bare fm- target carries +# scope the codex `$` popup-settle below. A task selector carries # meta; an explicit backend-target escape hatch has none, so its harness is # unknown and treated as non-codex (the safe default that keeps the fast path). -# The target's BACKEND comes from fm- meta, or from matching the resolved +# The target's BACKEND comes from selector meta, or from matching the resolved # explicit target back to recorded meta, then falls back to tmux. TARGET_HARNESS="" TARGET_BACKEND=$(fm_backend_of_selector "$RAW_TARGET" "$T" "$STATE") EXPECTED_LABEL=$(fm_backend_expected_label_of_selector "$RAW_TARGET" "$STATE") -case "$RAW_TARGET" in - fm-*) - meta="$STATE/${RAW_TARGET#fm-}.meta" - if [ -f "$meta" ]; then - TARGET_HARNESS=$(fm_meta_get "$meta" harness) - fi - ;; -esac +if [ -n "$TARGET_META" ]; then + TARGET_HARNESS=$(fm_meta_get "$TARGET_META" harness) +fi if [ "${1:-}" = "--key" ]; then fm_backend_send_key "$TARGET_BACKEND" "$T" "$2" "$EXPECTED_LABEL" diff --git a/docs/architecture.md b/docs/architecture.md index b1a41c973e7..dae1cb94b06 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -42,7 +42,7 @@ Its supervisor injection path supports tmux and herdr panes, with `FM_SUPERVISOR Pane existence, busy checks, composer checks, capture, and verified submit route through `bin/fm-backend.sh`: tmux keeps the same submit core used by the tmux send backend, while herdr uses native busy state, native agent-state submit confirmation on idle baselines, and its structural composer classifier for pending-input guards and submit fallback. Unsupported supervisor backends refuse at daemon startup. Stalled escalation delivery raises `state/.subsuper-inject-wedged` after `FM_MAX_DEFER_SECS` instead of silently deferring forever. -`fm-send.sh` selects a pre-Enter popup-settle for slash commands and for codex `$...` skill invocations using the target's recorded `harness=` meta, then adds its own `FM_SEND_SETTLE` pause after successful text sends so immediate peeks catch the receiving turn starting; the sub-supervisor uses only the shared submit core and does not pay that post-submit pause. +`fm-send.sh` selects a pre-Enter popup-settle for slash commands and for codex `$...` skill invocations using metadata-routed target `harness=` values, then adds its own `FM_SEND_SETTLE` pause after successful text sends so immediate peeks catch the receiving turn starting; the sub-supervisor uses only the shared submit core and does not pay that post-submit pause. ## Runtime session backends @@ -60,7 +60,7 @@ The deeper session-start agent-process liveness probe is separate from that busy Herdr is experimental and can be selected explicitly or by runtime auto-detection: treehouse remains the worktree provider for it exactly as it is for tmux (herdr is a session provider only), and its full verification - the container shape decision, created-vs-adopted default-tab prune safety, restored-layout husk respawn idempotency, verified CLI facts, a verified small-`--lines` capture bug and its workaround, and known gaps - is recorded in `docs/herdr-backend.md`. Herdr's container shape is workspace-per-home plus tab-per-task: the primary home uses workspace label `firstmate`, secondmate homes use `2ndmate-`, and recovery/list-live scopes to the current `FM_HOME`'s workspace. 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-`. +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 task selectors can use exact ids or stable `fm-` labels. 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'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. @@ -108,7 +108,7 @@ Seeding is transactional: if validation, cloning, initialization, or registry up `local-only` projects stay with the main first mate because they merge into the main local checkout instead of a remote-backed PR path. The same project may appear in multiple secondmate homes when their scopes differ, such as issue triage versus feature development. Secondmates are idle by default: after startup recovery reconciles only work already in their own home, an empty queue waits silently for routed tasks, and they never self-initiate surveys or audits. -Bare `fm-send.sh fm-` requests to a live `kind=secondmate` are prefixed with the from-firstmate marker from `bin/fm-marker-lib.sh`, so the secondmate returns terse answers through status lines and detailed answers through docs plus status pointers instead of replying only in its own chat. +Metadata-routed `fm-send.sh` requests to a live `kind=secondmate` are prefixed with the from-firstmate marker from `bin/fm-marker-lib.sh`, so the secondmate returns terse answers through status lines and detailed answers through docs plus status pointers instead of replying only in its own chat. Explicit backend-target sends and direct human typing stay unmarked, so captain intervention in a secondmate pane remains conversational. After seeding a secondmate, `fm-backlog-handoff.sh` moves already-judged in-scope queued items from the main backlog into that secondmate home so the domain queue starts in the right place. Idle secondmate panes are healthy; teardown is explicit and refuses while the secondmate home has in-flight work unless the captain has approved discard with `--force`. diff --git a/docs/cmux-backend.md b/docs/cmux-backend.md index 0c165b5d3bb..9c059aed41c 100644 --- a/docs/cmux-backend.md +++ b/docs/cmux-backend.md @@ -55,8 +55,8 @@ A cmux spawn refuses loudly, with an actionable message pointing back to this do No first-run provisioning beyond the socket-access setup above and having `jq` installed; firstmate creates the workspace it needs on first spawn, launching the app itself (`open -a cmux`) if it is not already running. Watching and attaching: firstmate uses one workspace per task in whatever cmux window is currently open. -Callers still use firstmate's universal `fm-` selector vocabulary, while the actual cmux workspace title is home-scoped as `fm--`, for example `fm-firstmate-<8hex>-cmux-e2e-t1` in the primary home or `fm-2ndmate--<8hex>-cmux-e2e-t1` in a secondmate home. -You do not need to bring the window forward for routine supervision: `bin/fm-peek.sh fm-` reads a task's surface without focusing it, and `bin/fm-send.sh fm- ""` steers it - workspace/surface/pane creation all default `focus` to `false`, so an unattended spawn never steals your view. +Callers can use exact task ids or stable `fm-` labels, while the actual cmux workspace title is home-scoped as `fm--`, for example `fm-firstmate-<8hex>-cmux-e2e-t1` in the primary home or `fm-2ndmate--<8hex>-cmux-e2e-t1` in a secondmate home. +You do not need to bring the window forward for routine supervision: `bin/fm-peek.sh ` reads a task's surface without focusing it, and `bin/fm-send.sh ""` steers it - workspace/surface/pane creation all default `focus` to `false`, so an unattended spawn never steals your view. Verify it works by spawning a trivial task with `--backend cmux` and confirming the task's meta records `backend=cmux` plus `cmux_workspace_id=` and `cmux_surface_id=`. The cmux sidebar should show a new `fm-firstmate-<8hex>-` workspace in the primary home. diff --git a/docs/configuration.md b/docs/configuration.md index 4c799771be2..b4ba1ce3db9 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -41,12 +41,18 @@ 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=`. +Task selectors for `fm-peek.sh`, `fm-send.sh`, and `fm-crew-state.sh` resolve centrally through `fm_backend_resolve_selector`. +A selector containing `:` is passed through as an explicit backend endpoint escape hatch. +Otherwise an exact task id matching `state/.meta` wins before the legacy `fm-` label fallback, so task ids that themselves start with `fm-` route to their own metadata instead of being stripped. +A metadata-routed selector returns the recorded backend target (`terminal=` for Orca, otherwise `window=`), and matching explicit targets can still recover the recorded backend when metadata contains the same endpoint. +Only metadata-routed task selectors carry secondmate-marker and Codex-harness context; explicit endpoint escape hatches do not. +`fm-teardown.sh ` takes a task id directly and uses the same recorded backend target fields after loading `state/.meta`. 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. Use the explicit guarded cleanup path described in [`docs/herdr-backend.md`](herdr-backend.md) instead of `herdr server stop`. For normal zellij operations, `FM_ZELLIJ_SESSION` selects the named session and defaults to `firstmate`. -Zellij has no per-home workspace split: primary and secondmate tasks share that one session, callers keep using `fm-`, and visible tab titles are scoped by the active `FM_HOME` readable label plus a short hash of the resolved `FM_ROOT` path as `fm--`. +Zellij has no per-home workspace split: primary and secondmate tasks share that one session, task selectors can be exact ids or stable `fm-` labels, and visible tab titles are scoped by the active `FM_HOME` readable label plus a short hash of the resolved `FM_ROOT` path as `fm--`. Use the guarded cleanup path described in [`docs/zellij-backend.md`](zellij-backend.md) instead of `kill-all-sessions` or `delete-all-sessions`. cmux has no session layer at all - one workspace per task, in whatever cmux window is open - and its socket password (when configured) is read from local, gitignored `config/cmux-socket-password` under the effective config directory, never committed. The caller-facing label remains `fm-`, but the actual cmux workspace title is scoped by the active `FM_HOME` readable label plus a short hash of the resolved `FM_ROOT` path as `fm--`. @@ -153,7 +159,9 @@ An absent `quota-axi` reports `MISSING: quota-axi (install: npm install -g quota Bootstrap also reports a `TANGLE:` line when `FM_ROOT` is on a named non-default branch; follow the printed checkout remediation rather than treating it as an installable tool problem. In a read-only session that did not get the fleet lock, the same line is advisory and omits the checkout command. The locked session-start bootstrap step also runs a best-effort project clone refresh through `fm-fleet-sync.sh`. -It emits `FLEET_SYNC:` for skipped refreshes that may matter, recovered self-heals, and `STUCK:` alarms; local-only and no-origin skips stay silent. +It emits `FLEET_SYNC:` for skipped refreshes that may matter, recovered self-heals, and `STUCK:` alarms. +Normal completed runs keep local-only and no-origin skips silent. +If bootstrap kills a timed-out refresh, it replays any completed `fm-fleet-sync.sh` output before the aggregate timeout skip so no finished result is lost. The locked session-start bootstrap step also runs the guarded local secondmate sync for recorded live secondmate homes, then propagates declared inheritable local config into each validated live home. It emits `SECONDMATE_SYNC:` only when a home was skipped for an actionable sync reason or config inheritance failed, and `NUDGE_SECONDMATES:` only when a running home advanced and its instruction surface (`AGENTS.md`, `bin/`, or `.agents/skills/`) changed. `NUDGE_SECONDMATES:` lists stable `fm-` task selectors; `AGENTS.md` section 3 owns the send procedure. @@ -271,7 +279,7 @@ FM_CAPTAIN_RE='done:|needs-decision:|blocked:|failed:|PR ready|checks green|read FM_STALE_ESCALATE_SECS=240 # idle seconds before a provably-working stale pane escalates; stale panes whose crew is not provably working surface immediately FM_WEDGE_DEMAND_INSPECT_COUNT=3 # consecutive provably-working stale escalations on the same unchanged pane before demand-deep-inspection is added FM_WATCH_TRIAGE_LOG_MAX_BYTES=262144 # size cap for the watcher's absorbed-wake debug log -FM_FLEET_SYNC_BOOTSTRAP_TIMEOUT=20 # seconds allowed for bootstrap's best-effort clone refresh +FM_FLEET_SYNC_BOOTSTRAP_TIMEOUT= # optional seconds allowed for bootstrap's best-effort clone refresh; unset/blank defaults to max(20, 5 + 3 * origin-backed-project-count) FM_FLEET_PRUNE=1 # set to 0 to skip pruning local branches whose upstream is gone FM_STALE_WORKTREE_LOCK_AGE_SECS=30 # min mtime age before fm-teardown.sh treats a leftover worktree git index.lock as provably stale FM_STALE_WORKTREE_LOCK_RETRY_WAIT_SECS=2 # seconds fm-teardown.sh waits before retrying a worktree return that failed on a git lock diff --git a/docs/herdr-backend.md b/docs/herdr-backend.md index 208e37e91ab..44cd17346fc 100644 --- a/docs/herdr-backend.md +++ b/docs/herdr-backend.md @@ -163,8 +163,9 @@ A workspace whose label this adapter did not derive (see "Label derivation" abov A herdr task's `window=` meta field holds `:`, for example `default:w1:p2`. The pane id itself contains a colon, so the adapter splits on the FIRST colon only, never on every colon. This mirrors tmux's `session:window` target shape closely enough that `fm_backend_resolve_selector` (in `bin/fm-backend.sh`) needed no backend-specific logic at all - it already just returns a task's recorded `window=` value verbatim. -Operational commands should prefer the bare `fm-` form, which resolves through this home's metadata. -An explicit herdr target also works when it exactly matches recorded metadata, but ad hoc non-`fm-` bare-name lookup remains the legacy tmux live-window fallback. +Operational commands should prefer the exact task id or stable `fm-` label, both of which resolve through this home's metadata. +Exact task ids win first, so ids beginning with `fm-` are not stripped as legacy labels. +An explicit herdr target also works when it exactly matches recorded metadata, but ad hoc bare-name lookup with no metadata remains the legacy tmux live-window fallback for non-`fm-` names. Herdr tasks additionally record: diff --git a/docs/orca-backend.md b/docs/orca-backend.md index ace5765464f..5c68473db94 100644 --- a/docs/orca-backend.md +++ b/docs/orca-backend.md @@ -25,7 +25,7 @@ Spawn fails closed if the runtime is not ready. The first spawn against a given project also auto-registers that project's repo in Orca (`orca repo add --path`) if it is not already registered - no manual registration step is needed. Watching and attaching: Orca owns both the worktree and the terminal for its tasks, so there is nothing to attach to outside the Orca app itself - open the app and find the terminal for the task (recorded as `terminal=` in the task's meta, with `window=fm-` as the shared firstmate alias). -You do not need to open the app for routine supervision: `bin/fm-peek.sh fm-` reads a task's terminal without opening Orca, and `bin/fm-send.sh fm- ""` steers it (Enter and Ctrl-C are supported; Escape is not). +You do not need to open the app for routine supervision: `bin/fm-peek.sh ` reads a task's terminal without opening Orca, and `bin/fm-send.sh ""` steers it (the stable `fm-` alias also works; Enter and Ctrl-C are supported; Escape is not). Verify it works by spawning a trivial task with `--backend orca` and confirming the task's meta records `backend=orca`, `terminal=`, `orca_worktree_id=`, and `worktree=`; the Orca app should show a new terminal for the task. @@ -60,7 +60,8 @@ orca_worktree_id= worktree= ``` -`window=` remains the shared firstmate selector field used by `fm-peek.sh`, `fm-send.sh`, `fm-watch.sh`, `fm-crew-state.sh`, and `fm-teardown.sh`. +`window=` remains the shared firstmate alias used by selector-driven supervision tools after a task selector has resolved through metadata. +`fm-teardown.sh ` uses the same recorded fields after loading `state/.meta`. For Orca, `window=` keeps the stable firstmate alias while `terminal=` carries the stable Orca terminal handle that backend operations use. The recorded `backend=orca` field tells shared call sites to route capture, send, interrupt, and close through `bin/backends/orca.sh` instead of tmux assumptions. diff --git a/docs/scripts.md b/docs/scripts.md index 82feaf7361b..b694df6c760 100644 --- a/docs/scripts.md +++ b/docs/scripts.md @@ -7,7 +7,7 @@ If you have changed away from the firstmate home in an interactive shell, invoke | Script | Description | | ------------------------ | ------------------------------------------------------------------------------------------------------------------- | | `fm-session-start.sh` | The one command AGENTS.md sections 3 and 5 run at every session start: composes `fm-lock.sh`, `fm-bootstrap.sh` (its four mutating sweeps gated on holding the lock via `FM_BOOTSTRAP_DETECT_ONLY`), and `fm-wake-drain.sh`, then prints a full context digest (`data/projects.md`, `data/secondmates.md`, `data/captain.md`, `data/learnings.md`, each `ABSENT`-marked when missing) and fleet-state digest (`data/backlog.md`, every `state/*.meta`, a bounded `state/*.status` tail, `state/.afk`, and a cheap per-task endpoint-liveness read); prints a loud read-only banner and skips every mutating step when the lock is held elsewhere; never arms the watcher itself | -| `fm-bootstrap.sh` | Detect required toolchain and version problems (including `tasks-axi`, `quota-axi`, and the other bootstrap AXI tools), dispatch profile JSON errors or active-rule blocks, default backlog-backend status, primary-checkout `TANGLE:` problems, and actionable clone refresh outcomes; refresh project clones best-effort; locally sync live secondmate homes and propagate declared inheritable config; run the secondmate agent-liveness sweep; set up opt-in X mode; install tools only after consent; `FM_BOOTSTRAP_DETECT_ONLY=1` skips the four mutating sweeps and prints advisory-only `TANGLE:` wording without a checkout command | +| `fm-bootstrap.sh` | Detect required toolchain and version problems (including `tasks-axi`, `quota-axi`, and the other bootstrap AXI tools), dispatch profile JSON errors or active-rule blocks, default backlog-backend status, primary-checkout `TANGLE:` problems, and actionable clone refresh outcomes or timeout summaries; refresh project clones best-effort under the configured bootstrap timeout; locally sync live secondmate homes and propagate declared inheritable config; run the secondmate agent-liveness sweep; set up opt-in X mode; install tools only after consent; `FM_BOOTSTRAP_DETECT_ONLY=1` skips the four mutating sweeps and prints advisory-only `TANGLE:` wording without a checkout command | | `fm-fleet-sync.sh` | Fetch all clones, or one clone selected by absolute path, relative path, bare project name, or `projects/` resolved against this home's projects dir; fast-forward safe default-branch states, self-heal clean detached ancestor drift, report unsafe drift as `STUCK:`, and safely prune branches whose remote is gone | | `fm-update.sh` | Self-update the running firstmate repo and registered secondmate homes with fast-forward-only pulls from origin | | `fm-backlog-handoff.sh` | Move already-judged in-scope queued backlog items from the main home into a seeded secondmate home | @@ -19,7 +19,7 @@ If you have changed away from the firstmate home in an interactive shell, invoke | `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; rejects `backend=codex-app`; 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-dispatch-select.sh` | Resolve one already-matched crew-dispatch rule to a concrete JSON profile; owns deterministic `quota-balanced` selection and quota-axi fallback behavior | -| `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; deliberately keeps `codex-app` out of known/spawn-capable backends; 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_agent_alive` is the deeper agent-process liveness probe used by the session-start secondmate sweep; `fm_backend_composer_state` exposes backend composer checks for pending-input guards and submit fallbacks | +| `fm-backend.sh` | Runtime session-provider backend selector with explicit/env/config/runtime auto-detection precedence, meta helper, exact-id-first selector resolver, spawn-capability validation, operation dispatcher, and shell-portable backend-name membership for bash-sourced scripts or zsh-sourced diagnostics; deliberately keeps `codex-app` out of known/spawn-capable backends; 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_agent_alive` is the deeper agent-process liveness probe used by the session-start secondmate sweep; `fm_backend_composer_state` exposes backend composer checks for pending-input guards and submit fallbacks | | `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, agent-process liveness, and kill primitives | | `backends/herdr.sh` | Experimental herdr session-provider adapter used by `fm-backend.sh`; owns version/tool gating, per-home workspace/tab creation, created-vs-adopted default-tab prune safety, restored-layout husk respawn replacement, agent-process liveness through the husk classifier, session-scoped CLI calls, send with native agent-state submit confirmation plus composer-state guard/fallback, capture, native busy-state, current-path, label-based live discovery, and kill primitives | @@ -44,7 +44,7 @@ If you have changed away from the firstmate home in an interactive shell, invoke | `fm-wake-drain.sh` | Atomically drain queued watcher wakes before handling supervision work, then run the watcher-liveness guard | | `fm-wake-lib.sh` | Shared durable wake queue, portable lock helpers, path-age helpers, and watcher identity/health helpers sourced by the watcher, drain, arm, guard, turn-end guard, daemon, and teardown | | `fm-classify-lib.sh` | Shared captain-relevant wake classifier sourced by the watcher and daemon, plus the watcher's provably-working predicate | -| `fm-send.sh` | Send one verified literal line or backend-supported `--key` through the target's recorded runtime backend; exits non-zero on confirmed swallowed Enter; bare `kind=secondmate` targets are marked as from-firstmate; slash commands and codex `$...` skill invocations get popup-settle before backend-specific submit verification; text sends pause `FM_SEND_SETTLE` seconds after success | +| `fm-send.sh` | Send one verified literal line or backend-supported `--key` through the target's recorded runtime backend; exits non-zero on confirmed swallowed Enter; metadata-routed `kind=secondmate` task selectors are marked as from-firstmate; slash commands and codex `$...` skill invocations get popup-settle before backend-specific submit verification; text sends pause `FM_SEND_SETTLE` seconds after success | | `fm-tmux-lib.sh` | Shared tmux pane primitives for busy detection, dim-ghost-aware and border-aware composer detection, and verified submit retry | | `fm-peek.sh` | Print a bounded tail of a crewmate endpoint through the target's recorded runtime backend | | `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 | diff --git a/docs/zellij-backend.md b/docs/zellij-backend.md index e130e6b8603..88779f2e8f2 100644 --- a/docs/zellij-backend.md +++ b/docs/zellij-backend.md @@ -57,7 +57,8 @@ No empirical evidence surfaced during verification that forces a different conta Because every task in every firstmate home - primary or secondmate - shares this ONE session's tab bar with no per-home container split, and zellij enforces no tab-name uniqueness at all (verified: two tabs can share a name), two firstmate homes whose task ids happen to collide could send/peek/close each other's tabs. This is the exact gap a captain-directed no-mistakes review gate caught for the cmux backend (`docs/cmux-backend.md` "Task container shape") - cmux's fix was ported here for the identical reason, sharing its tag-derivation code (`bin/fm-backend-hometag-lib.sh`). -The caller-facing task label stays `fm-` everywhere (meta, `fm-send.sh`/`fm-peek.sh` selectors, briefs), but the actual zellij tab title a NEW task's tab is created with is home-scoped: `fm--`. +The caller-facing task label stays `fm-` in meta and briefs, while `fm-send.sh` and `fm-peek.sh` also accept exact task ids before the legacy `fm-` selector fallback. +The actual zellij tab title a NEW task's tab is created with is home-scoped: `fm--`. `` is `firstmate` for the primary home, or `2ndmate-` when `$FM_HOME/.fm-secondmate-home` contains a secondmate id, plus a short stable hash of the resolved `FM_ROOT` path - the same identity scheme as cmux's home label (`docs/cmux-backend.md` "Task container shape"), so e.g. `fm-firstmate-a1b2c3d4-fix-login-k3` or `fm-2ndmate-sm1-9f8e7d6c-fix-login-k3`. The path hash means even two independent PRIMARY installations on one machine (each with no `.fm-secondmate-home` marker, so both would otherwise resolve to the same `firstmate` prefix) still get distinct tags. `fm_backend_zellij_create_task` creates every new tab with this scoped title and checks for a duplicate against the scoped title, never the bare label. @@ -77,7 +78,7 @@ This is accepted, exactly as it is for cmux: a task's own recorded worktree path A zellij task's `window=` meta field holds `:`, for example `firstmate:7`. The pane id is a bare non-negative integer with no embedded colon (simpler than herdr's own pane-id shape, which itself contains a colon), so splitting on the first colon is trivially correct. This mirrors tmux's `session:window` and herdr's `session:pane` target shapes closely enough that `fm_backend_resolve_selector` (`bin/fm-backend.sh`) needed no zellij-specific logic at all. -When a caller reaches a zellij endpoint through firstmate metadata (`fm-` or a meta scan), it also supplies the expected caller-facing tab label `fm-` to the zellij adapter, which internally checks it against the home-scoped title (falling back to the unambiguous-untagged legacy match described above). +When a caller reaches a zellij endpoint through firstmate metadata (an exact task id, a legacy `fm-` selector, or a meta scan), it also supplies the expected caller-facing tab label `fm-` to the zellij adapter, which internally checks it against the home-scoped title (falling back to the unambiguous-untagged legacy match described above). That label check prevents a stale numeric pane id from being trusted after an external session deletion/recreation, or from being trusted for a different firstmate home's same-named tab; explicit raw `session:pane` targets remain a pane-existence-only escape hatch because there is no metadata label to verify. Zellij tasks additionally record: diff --git a/tests/fm-backend.test.sh b/tests/fm-backend.test.sh index 20145d7dc10..b526cd0cde8 100755 --- a/tests/fm-backend.test.sh +++ b/tests/fm-backend.test.sh @@ -514,12 +514,30 @@ test_resolve_selector_three_forms() { local state=$TMP_ROOT/resolve-state fakebin out mkdir -p "$state" fm_write_meta "$state/task1.meta" "window=firstmate:fm-task1" + fm_write_meta "$state/dotfiles-d6.meta" "window=default:wA:p2" "backend=herdr" + fm_write_meta "$state/fm-turnend-all-harnesses-v9.meta" "window=default:wB:p3" "backend=herdr" [ "$(fm_backend_resolve_selector 'sess:win' "$state")" = "sess:win" ] \ || fail "explicit session:window should be used as-is" + [ "$(fm_backend_resolve_selector 'dotfiles-d6' "$state")" = "default:wA:p2" ] \ + || fail "bare non-fm task id should resolve through exact metadata" + [ "$(fm_backend_of_selector 'dotfiles-d6' 'default:wA:p2' "$state")" = herdr ] \ + || fail "bare non-fm task id should use its recorded backend" + [ "$(fm_backend_expected_label_of_selector 'dotfiles-d6' "$state")" = "fm-dotfiles-d6" ] \ + || fail "bare non-fm task id should report the spawned fm- label" + + [ "$(fm_backend_resolve_selector 'fm-turnend-all-harnesses-v9' "$state")" = "default:wB:p3" ] \ + || fail "exact fm-* task id should resolve through its exact metadata" + [ "$(fm_backend_of_selector 'fm-turnend-all-harnesses-v9' 'default:wB:p3' "$state")" = herdr ] \ + || fail "exact fm-* task id should use exact metadata without stripping fm-" + [ "$(fm_backend_expected_label_of_selector 'fm-turnend-all-harnesses-v9' "$state")" = "fm-fm-turnend-all-harnesses-v9" ] \ + || fail "exact fm-* task id should report the spawned fm- label" + [ "$(fm_backend_resolve_selector 'fm-task1' "$state")" = "firstmate:fm-task1" ] \ - || fail "fm- should resolve through meta's window=" + || fail "legacy fm- label should resolve through .meta's window=" + [ "$(fm_backend_expected_label_of_selector 'fm-task1' "$state")" = "fm-task1" ] \ + || fail "legacy fm- label should preserve its backend label" out=$(fm_backend_resolve_selector 'fm-missing' "$state" 2>&1) && fail "fm- with no meta should fail" assert_contains "$out" "no metadata for fm-missing" "missing-meta error text changed" @@ -535,26 +553,33 @@ SH chmod +x "$fakebin/tmux" out=$(PATH="$fakebin:$PATH" fm_backend_resolve_selector 'fm-adhoc' "$state" 2>&1) || true # fm-adhoc carries no meta file, so it is NOT the bare-name fallback path - it - # is the fm-* meta-miss error path (a bare fm-* selector always routes through - # meta; only a NON fm-* bare name falls through to the live-window search). + # is the fm-* meta-miss error path after exact-id and legacy-label metadata + # lookup both miss. + # Only a NON fm-* bare name falls through to the live-window search. assert_contains "$out" "no metadata for fm-adhoc" "an fm-* selector must always require meta, not silently fall back to a live search" out=$(PATH="$fakebin:$PATH" fm_backend_resolve_selector 'adhoc' "$state") [ "$out" = "firstmate:adhoc" ] || fail "an ad hoc bare name should resolve via the tmux live-window fallback, got '$out'" - pass "fm_backend_resolve_selector: session:window literal, fm- via meta (always, even when the meta is missing), ad hoc bare name via tmux list-windows" + pass "fm_backend_resolve_selector: session:window literal, exact task id first, legacy fm- label fallback, ad hoc bare name via tmux list-windows" } test_backend_of_selector_matches_explicit_target_meta() { local state=$TMP_ROOT/backend-selector-state mkdir -p "$state" fm_write_meta "$state/herdr-task.meta" "window=default:w1:p2" "backend=herdr" + fm_write_meta "$state/dotfiles-d6.meta" "window=default:wA:p2" "backend=herdr" + fm_write_meta "$state/fm-turnend-all-harnesses-v9.meta" "window=default:wB:p3" "backend=herdr" fm_write_meta "$state/tmux-task.meta" "window=firstmate:fm-tmux-task" fm_write_meta "$state/custom-window-task.meta" "window=custom-window" fm_write_meta "$state/orca-task.meta" "window=fm-orca-task" "terminal=term-orca-task" "backend=orca" + [ "$(fm_backend_of_selector 'dotfiles-d6' 'default:wA:p2' "$state")" = herdr ] \ + || fail "bare non-fm task id selector should use its recorded backend" + [ "$(fm_backend_of_selector 'fm-turnend-all-harnesses-v9' 'default:wB:p3' "$state")" = herdr ] \ + || fail "exact fm-* task id selector should use exact metadata before legacy stripping" [ "$(fm_backend_of_selector 'fm-herdr-task' 'default:w1:p2' "$state")" = herdr ] \ - || fail "bare fm- selector should use its recorded backend" + || fail "legacy fm- selector should use its recorded backend" [ "$(fm_backend_resolve_selector 'fm-orca-task' "$state")" = term-orca-task ] \ || fail "Orca fm- selector should resolve to terminal=, not window=" [ "$(fm_backend_resolve_selector 'term-orca-task' "$state")" = term-orca-task ] \ @@ -570,7 +595,7 @@ test_backend_of_selector_matches_explicit_target_meta() { [ "$(fm_backend_of_selector 'manual:outside' 'manual:outside' "$state")" = tmux ] \ || fail "explicit target with no matching metadata should keep the tmux compatibility default" - pass "fm_backend_of_selector: fm- and matching explicit targets inherit metadata backend" + pass "fm_backend_of_selector: exact task ids, legacy fm- labels, and matching explicit targets inherit metadata backend" } # --- old vs new: fm-send.sh -------------------------------------------------- diff --git a/tests/fm-bootstrap.test.sh b/tests/fm-bootstrap.test.sh index 6c7ea46ab88..f1d1bf0681f 100755 --- a/tests/fm-bootstrap.test.sh +++ b/tests/fm-bootstrap.test.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# Behavior tests for fm-bootstrap.sh tool detection. +# Behavior tests for fm-bootstrap.sh reporting and session-start clone refresh bounds. # # Bootstrap prints one block or line per problem or capability fact and is silent when all # is well. firstmate consumes the exact 'MISSING: treehouse (install: ...)', @@ -9,9 +9,12 @@ # advertises --lease, which (if any) tasks-axi version is on PATH, whether # quota-axi is on PATH, whether the local backend config opts out of tasks-axi # backlog mutations, and which no-mistakes version is on PATH. +# Dedicated fleet-sync cases pin the computed bootstrap timeout, explicit +# override, blank-env defaulting, partial-output relay, and pre-launch timeout +# scan. set -u -# shellcheck source=tests/lib.sh +# shellcheck source=tests/lib.sh disable=SC1091 . "$(dirname "${BASH_SOURCE[0]}")/lib.sh" BASE_PATH=${FM_TEST_BASE_PATH:-/usr/bin:/bin:/usr/sbin:/sbin} @@ -89,6 +92,95 @@ SH chmod +x "$fakebin/jq" } +make_fake_fleet_sync_root() { + local dir=$1 fake_root + fake_root="$dir/fake-root" + mkdir -p "$fake_root/bin" + cat > "$fake_root/bin/fm-fleet-sync.sh" <<'SH' +#!/usr/bin/env bash +[ -z "${FM_FAKE_FLEET_SYNC_STARTED_MARKER:-}" ] || : > "$FM_FAKE_FLEET_SYNC_STARTED_MARKER" +printf '%s\n' 'alpha: synced' +printf '%s\n' 'beta: skipped: no origin remote' +exec perl -e 'sleep 300' +SH + chmod +x "$fake_root/bin/fm-fleet-sync.sh" + printf '%s\n' "$fake_root" +} + +add_origin_backed_projects() { + local home=$1 count=$2 i repo + mkdir -p "$home/projects" + i=1 + while [ "$i" -le "$count" ]; do + repo=$(printf '%s/projects/repo-%02d' "$home" "$i") + git init -q "$repo" + git -C "$repo" remote add origin "file://$home/remotes/repo-$i.git" + i=$((i + 1)) + done +} + +add_no_origin_projects() { + local home=$1 count=$2 i repo + mkdir -p "$home/projects" + i=1 + while [ "$i" -le "$count" ]; do + repo=$(printf '%s/projects/local-%02d' "$home" "$i") + git init -q "$repo" + i=$((i + 1)) + done +} + +run_bootstrap_timeout_case() { + local home=$1 fake_root=$2 fakebin=$3 override started_marker git_record wait_for_marker + override=__unset__ + started_marker=${5:-} + git_record=${6:-} + wait_for_marker=${7:-0} + [ "$#" -lt 4 ] || override=$4 + ( + # shellcheck disable=SC2317,SC2329 # Exported and invoked by the bootstrap subprocess. + sleep() { + local inc=${1:-1} + SECONDS=$((SECONDS + inc)) + if [ "${FM_FAKE_SLEEP_YIELDS:-0}" -lt 5 ]; then + FM_FAKE_SLEEP_YIELDS=$((${FM_FAKE_SLEEP_YIELDS:-0} + 1)) + command sleep 0.01 + fi + } + # shellcheck disable=SC2317,SC2329 # Exported and invoked by the bootstrap subprocess. + git() { + local tries + if [ "${FM_FAKE_GIT_WAIT_FOR_FLEET_START:-}" = 1 ] && [ -n "${FM_FAKE_FLEET_SYNC_STARTED_MARKER:-}" ]; then + tries=0 + while [ "$tries" -lt 5 ] && [ ! -e "$FM_FAKE_FLEET_SYNC_STARTED_MARKER" ]; do + command sleep 0.01 + tries=$((tries + 1)) + done + fi + if [ -n "${FM_FAKE_GIT_SYNC_STARTED_RECORD:-}" ] && [ -n "${FM_FAKE_FLEET_SYNC_STARTED_MARKER:-}" ] && [ -e "$FM_FAKE_FLEET_SYNC_STARTED_MARKER" ]; then + printf '%s\n' "$*" >> "$FM_FAKE_GIT_SYNC_STARTED_RECORD" + fi + command git "$@" + } + export -f sleep + export -f git + if [ "$override" = __unset__ ]; then + PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FM_ROOT_OVERRIDE="$fake_root" \ + FM_FAKE_FLEET_SYNC_STARTED_MARKER="$started_marker" \ + FM_FAKE_GIT_SYNC_STARTED_RECORD="$git_record" \ + FM_FAKE_GIT_WAIT_FOR_FLEET_START="$wait_for_marker" \ + FM_FAKE_TREEHOUSE_LEASE_HELP=1 "$ROOT/bin/fm-bootstrap.sh" 2>/dev/null + else + PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FM_ROOT_OVERRIDE="$fake_root" \ + FM_FLEET_SYNC_BOOTSTRAP_TIMEOUT="$override" \ + FM_FAKE_FLEET_SYNC_STARTED_MARKER="$started_marker" \ + FM_FAKE_GIT_SYNC_STARTED_RECORD="$git_record" \ + FM_FAKE_GIT_WAIT_FOR_FLEET_START="$wait_for_marker" \ + FM_FAKE_TREEHOUSE_LEASE_HELP=1 "$ROOT/bin/fm-bootstrap.sh" 2>/dev/null + fi + ) +} + # Each row (fields are '^'-separated; the install URL contains a literal '|'): #