diff --git a/.agents/skills/stuck-crewmate-recovery/SKILL.md b/.agents/skills/stuck-crewmate-recovery/SKILL.md index 3d7ac5e1d66..397e8fc1e42 100644 --- a/.agents/skills/stuck-crewmate-recovery/SKILL.md +++ b/.agents/skills/stuck-crewmate-recovery/SKILL.md @@ -71,6 +71,10 @@ Only positive socket refusal or absence is a daemon-down finding; escalate that Escalate in order: 1. Peek the pane, and check the task's steering inbox (`state/.inbox/`) for unhandled `*.msg` records - a stale wake naming an unread firstmate instruction means the worker never acknowledged a durable steer, and the record itself shows exactly what was intended. + If the endpoint is now proven alive and idle with an empty composer, retry the existing inbox doorbell once through `fm_task_inbox_ring` from `bin/fm-task-inbox-lib.sh`, using the recorded backend, endpoint, original oldest record, and expected label. + Preserve the original instruction and escalation marker: another enqueue duplicates the requested action, and resetting the watcher ladder gives an already escalated message a fresh retry budget. + Ringing is not acknowledgement or validation proof; inspect the existing record's move to `handled/` and the authoritative matching validation run before declaring progress or dispatching validation again. + If liveness, identity, or the composer is ambiguous, reconcile it before attempting this recovery; never ring a dead shell. 2. If the crewmate is waiting on a question its brief already answers, answer in one line via `FM_HOME= bin/fm-send.sh` from an active firstmate session unless `FM_HOME` is already set to the active firstmate home. 3. If the crewmate is confused or looping, interrupt with `FM_HOME= bin/fm-control.sh interrupt`, then redirect with one corrective line through `fm-send`. 4. If the crewmate is genuinely wedged after redirection, relaunch it with `FM_HOME= bin/fm-control.sh relaunch --note ''`, which stops the agent, carries the brief plus that note into a replacement in the same local copy, and restores the prior record if the replacement cannot start. diff --git a/bin/fm-composer-lib.sh b/bin/fm-composer-lib.sh index fbc86b17b9d..7769e3704d6 100644 --- a/bin/fm-composer-lib.sh +++ b/bin/fm-composer-lib.sh @@ -55,15 +55,17 @@ # writes its model name there); a titled bottom border that # still starts and ends with the family's rule glyph is # tolerated, including Grok 1.0.5's three-column title overhang. -# bare - an agent prompt glyph row with no border at all (claude `❯`, -# codex `›`, muse `⟩`, cursor `→`). The agent glyph is itself the container -# proof; a bare SHELL glyph (`>` `$` `%` `#`) never is. +# bare - an agent prompt glyph row with no border at all (claude and +# muse 1.3 `❯`, codex `›`, muse 0.1.0 `⟩`, cursor `→`). The +# agent glyph is itself the container proof; a bare SHELL glyph +# (`>` `$` `%` `#`) never is. # A bare composer's WRAP region (typed input continuing on the # rows beneath the glyph row) is bounded by blank rows, by # structural edges, and by the FURNITURE rows a harness draws -# directly below its composer - omp's status row and -# braille-only animation rows (declared once below, next to -# the idle placeholders) - none of which is ever typed input. +# directly below its composer - omp's status row, +# braille-only animation rows, and a row that is nothing but +# one of the idle placeholder hints (all declared once below, +# next to each other) - none of which is ever typed input. # left-bar - opencode: rows prefixed by a heavy left bar `┃` with no # closing border, holding the idle hint, blank rows, and a # mode/model footer line. @@ -116,12 +118,29 @@ # glyph deliberately outside the agent set, so no opencode shape recorded here # can prove a left-bar envelope and open a zone under it. # +# Muse variant - content rows between two horizontal `─` rules, with no glyph +# of the container's own and no side border. The CLOSING rule +# is always solid; the OPENING rule may instead carry a title +# embedded in its own rule glyphs, which is how muse 1.3 draws +# its composer (`── Voice input (⌥ + v to start) ───…`, verified +# live on Muse Code 1.3.0-R3401.1). A titled rule only OPENS a +# region: it never closes one and never carries the staleness +# evidence a solid rule does, so a titled heading drawn below a +# composer cannot defer that composer. +# pi's region is blank, so it is provable only with a live +# agent identity reporting an idle/done pi (herdr `agent get`; +# the tmux foreground-process probe) - a blank region between +# two transcript rules is otherwise exactly the strict rule's +# unidentifiable blank row. muse's region holds a bare agent +# glyph, and that glyph is its own proof (the bare rules below). +# # THE SAFETY RULE for glyphs: a bare shell prompt glyph (`>` `$` `%` `#`) - # what a pane shows once its agent has exited to a plain login shell - is a # genuine empty agent composer ONLY inside a bordered container. On a bare row # it is a dead-shell prompt and classifies `unknown` (never a safe injection -# target). The AGENT glyphs `❯` (claude), `›` (codex), `⟩` (U+27E9, muse), -# and `→` (U+2192, cursor) are a genuine empty agent composer either way. +# target). The AGENT glyphs `❯` (claude, and muse from 1.3), `›` (codex), +# `⟩` (U+27E9, muse through 0.1.0), and `→` (U+2192, cursor) are a genuine +# empty agent composer either way. # Both glyph sets are declared # exactly once below; every decision reaches them through the declarations. # @@ -247,11 +266,16 @@ fm_composer_normalize_trim_var() { # # no fleet harness uses it for ghost text, so it is kept (real text wins: # under-stripping merely defers, which the max-defer alarm surfaces, while # over-stripping would inject over real input). -# Raising FM_COMPOSER_GHOST_LUMA_MAX is not free: muse draws its `⟩` prompt glyph -# in truecolor 38;2;90;160;255, luminance ~149.9 (verified, muse 0.1.0-R708.1), -# the tightest margin over the 128 default in the fleet. Above ~150 that glyph is -# stripped as ghost text, which is why the bare-glyph fallback below must also -# recognise every agent glyph from the UNSTRIPPED plain row. +# Raising FM_COMPOSER_GHOST_LUMA_MAX is not free: muse 0.1.0-R708.1 drew its `⟩` +# prompt glyph in truecolor 38;2;90;160;255, luminance ~149.9, the tightest +# margin over the 128 default ever measured in the fleet. Muse 1.3.0-R3401.1 +# draws `❯` in 38;2;251;191;36 (luminance ~191.3) instead, so the margin is +# wider on the current release, but the 0.1.0 measurement is what the ceiling +# was chosen against - and 1.3 recolours `❯` back to that exact +# 38;2;90;160;255 blue while its pane is UNFOCUSED, which is the state +# firstmate reads a worker in, so the tight margin is the live one. Above ~150 +# that glyph is stripped as ghost text, which is why the bare-glyph fallback +# below must also recognise every agent glyph from the UNSTRIPPED plain row. # The dim/faint and dark-foreground states are tracked together as "de-emphasis"; # codes are processed left to right within a sequence, so "ESC[0;2m" reads as dim. # LC_ALL=C makes awk walk bytes, so multibyte glyphs (e.g. ❯) and de-emphasised @@ -457,9 +481,18 @@ FM_COMPOSER_SHELL_PROMPT_GLYPHS=$(printf '%s\n' '>' '$' '%' '#') # hence the unanchored tail). cursor-agent renders # two, both anchored: `Plan, search, build anything` in a fresh session and # `Add a follow-up` once a turn has completed (verified live on cursor-agent -# 2026.08.11-e8db854). FM_COMPOSER_IDLE_RE overrides for an unverified harness; -# matching is case-insensitive. -FM_COMPOSER_IDLE_RE_DEFAULT='^Type a message\.\.\.$|^Ask anything(\.\.\.|…)|^Plan, search, build anything$|^Add a follow-up$' +# 2026.08.11-e8db854). Muse rotates hints from its own tip catalogue around an +# empty composer, and the two entries here are the ones seen unrung on a live +# muse mate; they are taken byte-for-byte from the installed Muse 1.3.0-R3401.1 +# binary's catalogue, which is the same source the pane renders from. That +# catalogue holds roughly twenty entries, so a hint outside these two can still +# be drawn - see docs/verification/runtime-backends.md. +# This set has two consumers: the idle-placeholder decisions below, and +# _fm_composer_row_is_idle_hint, which makes a row that is nothing but one of +# these hints bound a bare composer's wrap region instead of reading as typed +# input. FM_COMPOSER_IDLE_RE overrides for an unverified harness; matching is +# case-insensitive. +FM_COMPOSER_IDLE_RE_DEFAULT='^Type a message\.\.\.$|^Ask anything(\.\.\.|…)|^Plan, search, build anything$|^Add a follow-up$|^Type @ to search and insert workspace file paths$|^/loop 10m schedules a recurring prompt$' # Opencode draws a mode/model footer line INSIDE its left-bar composer # ("Build · GPT-5.5 Fast OpenAI · high"). It is composer furniture, not typed @@ -744,6 +777,37 @@ _fm_composer_pi_separator_row() { # return 1 } +# _fm_composer_titled_rule_row: a horizontal `─` rule that carries a TITLE +# embedded in its own rule glyphs - muse 1.3 opens its composer with +# `── Voice input (⌥ + v to start) ───…` (verified live on Muse Code +# 1.3.0-R3401.1 at 44, 60, and 100 columns). The proof is deliberately narrow, +# for the reason _fm_composer_titled_bottom_ok records about grok's titled +# bottom border: the row must OPEN and CLOSE with the family's own rule glyph, +# must still carry a full-width run of it, and must carry no other structural +# glyph, so a box border row or an arbitrary transcript line can never pass. +# The title itself is not parsed, because muse renders the keybind in it and a +# keybind is exactly the part a release may respell. +_fm_composer_titled_rule_row() { # + local row=$1 title + case "$row" in + ──*──) ;; + *) return 1 ;; + esac + case "$row" in + *│*|*┃*|*║*|*╭*|*╮*|*╰*|*╯*|*┌*|*┐*|*└*|*┘*|\ + *┏*|*┓*|*┗*|*┛*|*╔*|*╗*|*╚*|*╝*|*━*|*═*|*▀*|*▄*) return 1 ;; + esac + # The same eight-column run floor the solid rule above uses, so a titled row + # too short to be a composer rule stays ordinary transcript text. + case "$row" in + *────────*) ;; + *) return 1 ;; + esac + title=${row//─/} + fm_composer_normalize_trim_var title + [ -n "$title" ] +} + # Row-scan results are returned through FM_COMPOSER_SCAN_* globals (bash 3.2 # has no nameref); they are internal to this owner. _fm_composer_scan_screen() { # [extract-wrap] @@ -844,6 +908,12 @@ _fm_composer_scan_screen() { # [extract-wrap] pi_lines=0 pi_glyph_row=-1 pi_glyph='' + elif _fm_composer_titled_rule_row "$trimmed"; then + # A titled rule opens a region but never closes one or proves staleness. + pi_open=$row + pi_lines=0 + pi_glyph_row=-1 + pi_glyph='' else if [ "$pi_open" -ge 0 ]; then pi_lines=$((pi_lines + 1)) @@ -1182,6 +1252,22 @@ _fm_composer_row_is_omp_status() { # fm_composer_idle_matches "$1" "${FM_COMPOSER_OMP_STATUS_RE:-$FM_COMPOSER_OMP_STATUS_RE_DEFAULT}" sensitive } +# _fm_composer_row_is_idle_hint: 0 when the WHOLE trimmed row is one of the +# fleet idle placeholder hints (FM_COMPOSER_IDLE_RE_DEFAULT above, whose +# entries are anchored). A harness that rotates hints around its empty +# composer draws them on their own rows below the prompt glyph, where a bare +# composer's wrap region would otherwise swallow them and report an idle pane +# `pending` - the false verdict that skipped three doorbells on a live muse +# mate, the same defect omp's status row above was taught to bound. Those +# hints are drawn at normal intensity, so ghost stripping cannot see them and +# only this shape test can. +_fm_composer_row_is_idle_hint() { # + local row=$1 + fm_composer_normalize_trim_var row + [ -n "$row" ] || return 1 + fm_composer_idle_matches "$row" "${FM_COMPOSER_IDLE_RE:-$FM_COMPOSER_IDLE_RE_DEFAULT}" insensitive +} + # _fm_composer_row_is_braille_furniture: 0 when the row is non-blank and its # non-whitespace content is entirely braille cells (fm_composer_strip_braille # above) - an animation row that never counts as typed content and bounds a @@ -1226,6 +1312,7 @@ _fm_composer_wrap_region_ok() { # if fm_composer_row_has_edge "$trimmed"; then return 1; fi if _fm_composer_row_is_omp_status "$trimmed"; then return 1; fi if _fm_composer_row_is_braille_furniture "$trimmed"; then return 1; fi + if _fm_composer_row_is_idle_hint "$trimmed"; then return 1; fi if fm_composer_leading_shell_glyph_var glyph "$trimmed"; then return 1; fi row=$((row + 1)) done @@ -1473,6 +1560,7 @@ _fm_composer_select_cursorless() { fm_composer_row_has_edge "$trimmed" && break _fm_composer_row_is_omp_status "$trimmed" && break _fm_composer_row_is_braille_furniture "$trimmed" && break + _fm_composer_row_is_idle_hint "$trimmed" && break FM_COMPOSER_SELECTED_LAST=$next next=$((next + 1)) done @@ -1755,6 +1843,35 @@ _fm_composer_classify_pi_rows() { # printf 'empty' } +# A native Pi binding can outlive its process after a Muse replacement. Only +# the current separated region's agent glyph AND adjacent Muse status footer +# can disambiguate that overlap; launch metadata or a model name in transcript +# cannot. This changes delivery classification, never native lifecycle state. +_fm_composer_muse_overlap() { # + local screen=$1 row=$2 plain glyph footer effort + [ "$FM_COMPOSER_SCAN_PI_PAIR_VALID" = 1 ] || return 1 + [ "$row" -eq "$((FM_COMPOSER_SCAN_PI_OPEN + 1))" ] || return 1 + [ "$row" -eq "$((FM_COMPOSER_SCAN_PI_CLOSE - 1))" ] || return 1 + plain=$(printf '%s\n' "$screen" | fm_composer_strip_ansi) + glyph=$(_fm_composer_screen_row "$row" "$plain") + fm_composer_normalize_trim_var glyph + case "$glyph" in ❯*) ;; *) return 1 ;; esac + footer=$(_fm_composer_screen_row "$((FM_COMPOSER_SCAN_PI_CLOSE + 1))" "$plain") + fm_composer_normalize_trim_var footer + # Two independent footer cells, in their rendered order. A single model + # mention is insufficient. Unknown future footer layouts fail closed. + case "$footer" in + muse-*' · '*' · '*) ;; + *) return 1 ;; + esac + effort=${footer#*' · '} + effort=${effort%%' · '*} + case "$effort" in + off|minimal|low|medium|high|xhigh|max) return 0 ;; + *) return 1 ;; + esac +} + _fm_composer_classify_bare_pi_overlap() { # local screen=$1 styled=$2 has_identity=$3 identity=$4 row=$5 agent if [ "$has_identity" != 1 ]; then @@ -1771,6 +1888,14 @@ _fm_composer_classify_bare_pi_overlap() { # } # --- 1. Every installed verified harness must reach a proven-empty composer -- +# Each harness is launched the way bin/fm-spawn.sh launches it, minus the brief. +# muse is the one that needs a flag: it gates every workspace no operator has +# opened by hand behind its own trust dialog, which the strict classifier +# correctly refuses to read as a composer, so a bare `muse` here could only ever +# fail on that dialog and never exercise the composer at all. `--yolo` is the +# flag the real spawn passes for exactly that reason, and this guard submits no +# prompt, so nothing runs in the trusted workspace. +harness_launch() { # -> launch argv on stdout, one word per line + case "$1" in + muse) printf '%s\n' muse --yolo ;; + *) printf '%s\n' "$1" ;; + esac +} + for h in claude codex opencode pi grok kimi muse; do if command -v "$h" >/dev/null 2>&1; then - check_harness_idle_empty "$h" "$h" + launch=() + while IFS= read -r word; do launch+=("$word"); done < <(harness_launch "$h") + check_harness_idle_empty "$h" "${launch[@]}" else note "harness absent, not verified here: $h" fi diff --git a/tests/fm-task-inbox.test.sh b/tests/fm-task-inbox.test.sh index 9ed62c5e009..6bdf430f414 100644 --- a/tests/fm-task-inbox.test.sh +++ b/tests/fm-task-inbox.test.sh @@ -131,6 +131,45 @@ age_path() { # (set mtime well past any grace under test) touch -t 202001010000 "$1" } +# The escalation marker transfers ownership to recovery, not another enqueue. +# Exercise the real classifier and existing ring API against the stale binding. +test_recovery_rings_original_escalated_record() ( + . "$ROOT/bin/fm-task-inbox-lib.sh" + . "$ROOT/bin/fm-composer-lib.sh" + local state rec screen log rc + state="$TMP_ROOT/recover-original/state" + mkdir -p "$state" + rec=$(fm_task_inbox_write "$state" t1 'start validation once') || fail "write failed" + age_path "$rec" + fm_task_inbox_record_escalated "$state" t1 "$rec" + [ "$(fm_task_inbox_due_action "$state" t1)" = quiet ] || fail "escalated record should stay quiet" + screen=$'────────────────────────────────────────────\n❯ \n────────────────────────────────────────────\nmuse-spark-1.3 · xhigh · project · YOLO' + log="$state/rings" + fm_backend_agent_state() { printf '%s' "${agent_state:-alive}"; } + fm_backend_composer_state() { + fm_composer_classify_screen $'styled=1\ncursor=0\nidentity=1\nrows=60' "$screen" '' $'pi\tdone' + } + fm_backend_send_text_submit() { printf '%s\n' "$3" >> "$log"; printf empty; } + fm_task_inbox_ring herdr 'session:pane' "$rec" || fail "corrected composer blocked original record" + [ "$(wc -l < "$log" | tr -d ' ')" = 1 ] || fail "recovery must ring once" + [ -f "$rec" ] || fail "ring is not acknowledgement" + [ "$(cat "$state/t1.inbox/.escalated")" = 001.msg ] || fail "ring reset escalation ownership" + [ "$(fm_task_inbox_due_action "$state" t1)" = quiet ] || fail "ring restarted an unbounded watcher ladder" + screen=${screen/❯ /❯ unfinished input} + rc=0; fm_task_inbox_ring herdr 'session:pane' "$rec" || rc=$? + [ "$rc" = 1 ] || fail "actual pending input was not protected" + agent_state=dead + rc=0; fm_task_inbox_ring herdr 'session:pane' "$rec" || rc=$? + [ "$rc" = 3 ] || fail "dead endpoint was not refused" + [ "$(wc -l < "$log" | tr -d ' ')" = 1 ] || fail "protected endpoint received another ring" + mkdir -p "$state/t1.inbox/handled" + mv "$rec" "$state/t1.inbox/handled/" + fm_task_inbox_oldest_unhandled "$state" t1 >/dev/null && fail "acknowledged validation would dispatch again" + [ "$(fm_task_inbox_due_action "$state" t1)" = quiet ] || fail "acknowledgement did not silence recovery" + [ "$(find "$state/t1.inbox" -name '*.msg' | wc -l | tr -d ' ')" = 1 ] || fail "recovery duplicated durable instruction" + pass "inbox: corrected composer re-rings original escalated record once, preserving acknowledgement and budget" +) + test_write_is_durable_and_exact() { local state rec rec2 doorbell doorbell2 doorbell3 expected actual expected2 actual2 text state="$TMP_ROOT/write/state"; mkdir -p "$state" @@ -697,6 +736,7 @@ test_watcher_dead_pane_ignores_stale_busy_state() { pass "watcher: dead-pane recovery overrides stale busy state" } +test_recovery_rings_original_escalated_record || exit 1 test_write_is_durable_and_exact test_doorbell_is_a_shell_noop test_doorbell_rejects_terminal_controls