From 6a68056408920adb8b11d324d3e7089a0177fea6 Mon Sep 17 00:00:00 2001 From: AG Werschky Date: Sun, 2 Aug 2026 21:16:57 -0600 Subject: [PATCH] fix: detect Grok 4.5 busy from Esc:cancel without dropping older footers Grok Build 0.2.118 / Grok 4.5 shows Esc:cancel mid-turn instead of Ctrl+c:cancel, so busy detection missed every live turn and idle alarms could fire on healthy workers. Match both verified cancel-footer forms as an alternation, correct the Escape interrupt fact, and cover busy/idle for each form in the existing busy-state tests. --- .agents/skills/harness-adapters/SKILL.md | 8 ++++---- bin/fm-busy-lib.sh | 7 +++++-- bin/fm-tmux-lib.sh | 15 ++++++++++++--- tests/fm-busy-adapter-wiring.test.sh | 6 +++++- tests/fm-busy-state.test.sh | 14 ++++++++++++-- tests/fm-crew-state.test.sh | 23 ++++++++++++++++++----- tests/fm-tmux-submit-busy.test.sh | 10 +++++++++- 7 files changed, 65 insertions(+), 18 deletions(-) diff --git a/.agents/skills/harness-adapters/SKILL.md b/.agents/skills/harness-adapters/SKILL.md index 03735fefb1..fde39ad5dc 100644 --- a/.agents/skills/harness-adapters/SKILL.md +++ b/.agents/skills/harness-adapters/SKILL.md @@ -301,7 +301,7 @@ The model arms through `fm_watch_arm_pi`, never a foreground bash arm; the watch `bin/fm-session-start.sh` reports when the live Pi-family session has not loaded both the turn-end guard and watcher extensions, and points at the selected executable after project trust as the fix, with `-e` as a trust-free fallback. When a secondmate is launched on Pi or pi-signed, `fm-spawn.sh --secondmate` launches the selected executable with both `-e .pi/extensions/fm-primary-turnend-guard.ts` and `-e .pi/extensions/fm-primary-pi-watch.ts`, both already present in the secondmate home's git worktree. -## grok (VERIFIED 2026-06-29, grok 0.2.73; slash-submit re-verified 2026-07-03 on 0.2.82; reasoning-effort ceiling re-verified 2026-07-13 on 0.2.99; exit paths re-verified 2026-07-19 on grok 0.2.103) +## grok (VERIFIED 2026-06-29, grok 0.2.73; slash-submit re-verified 2026-07-03 on 0.2.82; reasoning-effort ceiling re-verified 2026-07-13 on 0.2.99; exit paths re-verified 2026-07-19 on grok 0.2.103; busy-footer and interrupt re-verified 2026-08-02 on Grok 4.5 / Build 0.2.118) Grok Build TUI (`grok`), a Claude-Code-compatible CLI from xAI. Launch with a positional prompt: `grok --always-approve "$(cat )"`. @@ -309,9 +309,9 @@ For Grok's supported reasoning-effort values and omission behavior, see the [lau | Fact | Value | |---|---| -| Busy state | The one remaining rendered-tail fallback, isolated to Grok until its structured lifecycle is live-verified: `Ctrl+c:cancel`, the mid-turn cancel hint shown in grok's keybind bar iff a turn is running. The idle bar shows only `Shift+Tab:mode │ Ctrl+.:shortcuts`. ASCII is matched rather than the braille spinner to avoid locale fragility. | -| Exit command | `/exit` typed into the composer exits the TUI cleanly and prints `Resume this session with: grok --resume `; `Ctrl+Q` double-press within 1000ms remains a fallback; `Ctrl+D` is the quit key in VS Code family terminals; `Ctrl+C` is the interrupt, not the exit. | -| Interrupt | single `Ctrl+C` (cancels the current turn; the footer shows `Ctrl+c:cancel` mid-turn). `Esc` only moves focus to the scrollback, it does NOT interrupt. | +| Busy state | The one remaining rendered-tail fallback, isolated to Grok until its structured lifecycle is live-verified. Match the mid-turn cancel keybind in the keybind bar (present only while a turn is running). Verified forms are an alternation in `FM_TMUX_GROK_BUSY_REGEX_DEFAULT` (`bin/fm-tmux-lib.sh`, same fallback in `bin/fm-busy-lib.sh`): older `Ctrl+c:cancel`, and Grok 4.5 / Build 0.2.118 `Esc:cancel` (live busy footer `Shift+Tab:mode │ Esc:cancel │ Ctrl+x:shortcuts`). Idle bar has no `:cancel` token (`Shift+Tab:mode │ Ctrl+x:shortcuts` on 0.2.118). ASCII keybind text is matched rather than the braille spinner to avoid locale fragility. Add a new form when the TUI renames the busy footer; do not replace one form with another. | +| Exit command | `/exit` typed into the composer exits the TUI cleanly and prints `Resume this session with: grok --resume `; `Ctrl+Q` double-press within 1000ms remains a fallback; `Ctrl+D` is the quit key in VS Code family terminals; a double `Ctrl+C` while idle is quit (`Ctrl+c:press again to quit`), not the mid-turn cancel. | +| Interrupt | single Escape cancels the running turn on Grok 4.5 (footer shows `Esc:cancel` mid-turn; verified 2026-08-02 on Build 0.2.118). A single `Ctrl+C` also cancels a running turn on 4.5. Prefer Escape when the live footer shows `Esc:cancel`. Escape is the cancel key mid-turn, not a scrollback-only focus move. | | Skill invocation | `/` (e.g. `/no-mistakes`), same as claude. Opens a slash-autocomplete popup, so a too-fast Enter selects the popup entry instead of sending. For an argument-taking command that first Enter does not submit at all - it expands the selection into an argument-hint placeholder in the composer (e.g. `/compact` -> `/compact compaction instructions`, live-verified), leaving real text still sitting there unsubmitted; a genuine second Enter is required. `fm-send`'s retried Enter lands it on BOTH backends, but only because each backend's own submit-verification correctly recognizes that placeholder-filled text as still-pending - see the incident below. | | Autonomy | `--always-approve` (footer shows `· always-approve`); auto-approves every tool execution, verified to run fully unattended. `--permission-mode bypassPermissions` is the stronger equivalent. | | Env marker | `GROK_AGENT=1`, set for child/tool processes. grok does NOT set `CLAUDECODE` despite Claude compatibility, so the marker is unambiguous. | diff --git a/bin/fm-busy-lib.sh b/bin/fm-busy-lib.sh index d12cebc304..6c691a971b 100755 --- a/bin/fm-busy-lib.sh +++ b/bin/fm-busy-lib.sh @@ -247,12 +247,15 @@ fm_busy_record_read() { # } # fm_busy_grok_tail_busy: the Grok-only temporary rendered-tail fallback. -# Consumes the tail on stdin; 0 when Grok's verified busy signature matches. +# Consumes the tail on stdin; 0 when any verified Grok mid-turn cancel +# keybind form matches. Forms are an ERE alternation owned with +# FM_TMUX_GROK_BUSY_REGEX_DEFAULT in bin/fm-tmux-lib.sh (keep both in sync): +# Ctrl+c:cancel (older) and Esc:cancel (Grok 4.5 / Build 0.2.118+). # FM_BUSY_REGEX still globally overrides the signature, mirroring the # historical operator escape hatch. fm_busy_grok_tail_busy() { grep -v '^[[:space:]]*$' | tail -12 \ - | grep -qiE "${FM_BUSY_REGEX:-${FM_TMUX_GROK_BUSY_REGEX_DEFAULT:-Ctrl\\+c:cancel}}" + | grep -qiE "${FM_BUSY_REGEX:-${FM_TMUX_GROK_BUSY_REGEX_DEFAULT:-(Ctrl\\+c|Esc):cancel}}" } # fm_busy_classify: semantic classification for a task whose endpoint the diff --git a/bin/fm-tmux-lib.sh b/bin/fm-tmux-lib.sh index e8284ba1e0..577a922669 100755 --- a/bin/fm-tmux-lib.sh +++ b/bin/fm-tmux-lib.sh @@ -67,7 +67,8 @@ . "$(dirname -- "${BASH_SOURCE[0]}")/fm-composer-lib.sh" # Delivery-only rendered busy footers per harness. claude/codex: "esc to -# interrupt"; opencode: "esc interrupt"; pi: "Working..."; grok: "Ctrl+c:cancel". +# interrupt"; opencode: "esc interrupt"; pi: "Working..."; grok: mid-turn +# cancel keybind (see FM_TMUX_GROK_BUSY_REGEX_DEFAULT). # Claude's current spinner has a rotating glyph and word, but every active-turn # line has an ellipsis followed by a parenthesized elapsed duration. Keep this # signature separate from the shared default because that shape is not generic @@ -82,12 +83,20 @@ # busy signals on their own. # The full moon-phase set remains locale- and emoji-font-sensitive because Kimi # exposes no stable ASCII busy token. -FM_TMUX_BUSY_REGEX_DEFAULT='esc (to )?interrupt|Working\.\.\.|Ctrl\+c:cancel' +# +# Grok mid-turn cancel keybind forms are an ERE alternation, not one literal. +# Add a new form when the TUI renames the busy footer; keep prior forms so a +# mixed fleet keeps working. Live-verified 2026-08-02 on Grok Build 0.2.118 +# (Grok 4.5): busy footer is "Shift+Tab:mode | Esc:cancel | Ctrl+x:shortcuts", +# idle is the same without Esc:cancel. Older verified form: Ctrl+c:cancel. +# Keep this string identical to the fallback in bin/fm-busy-lib.sh +# fm_busy_grok_tail_busy (semantic task state uses the same forms). +FM_TMUX_GROK_BUSY_REGEX_DEFAULT='(Ctrl\+c|Esc):cancel' +FM_TMUX_BUSY_REGEX_DEFAULT="esc (to )?interrupt|Working\\.\\.\\.|${FM_TMUX_GROK_BUSY_REGEX_DEFAULT}" FM_TMUX_CLAUDE_BUSY_REGEX_DEFAULT='esc to interrupt|…[[:space:]]+\([0-9]+[smh]' FM_TMUX_CODEX_BUSY_REGEX_DEFAULT='esc to interrupt' FM_TMUX_OPENCODE_BUSY_REGEX_DEFAULT='esc interrupt' FM_TMUX_PI_BUSY_REGEX_DEFAULT='Working\.\.\.' -FM_TMUX_GROK_BUSY_REGEX_DEFAULT='Ctrl\+c:cancel' FM_TMUX_KIMI_BUSY_REGEX_DEFAULT='^[[:space:]]*(🌑|🌒|🌓|🌔|🌕|🌖|🌗|🌘)[[:space:]]+·[[:space:]]+' fm_busy_lines_match() { # [harness] diff --git a/tests/fm-busy-adapter-wiring.test.sh b/tests/fm-busy-adapter-wiring.test.sh index 4636da5f79..eab84c9005 100755 --- a/tests/fm-busy-adapter-wiring.test.sh +++ b/tests/fm-busy-adapter-wiring.test.sh @@ -334,7 +334,11 @@ test_kimi_and_grok_install_no_unverified_wiring() { out=$(fm_busy_classify tmux fake:w kimi gate-k "$state" '🌒 · thinking') [ "$out" = "unknown kimi-unverified" ] || fail "kimi must classify unknown, not from its spinner, got '$out'" out=$(fm_busy_classify tmux fake:w grok gate-g "$state" 'Ctrl+c:cancel') - [ "$out" = "busy grok-regex" ] || fail "grok must classify through its isolated fallback, got '$out'" + [ "$out" = "busy grok-regex" ] || fail "grok older footer must classify through its isolated fallback, got '$out'" + out=$(fm_busy_classify tmux fake:w grok gate-g45 "$state" 'Esc:cancel') + [ "$out" = "busy grok-regex" ] || fail "grok 4.5 Esc:cancel must classify through its isolated fallback, got '$out'" + out=$(fm_busy_classify tmux fake:w grok gate-idle "$state" 'Shift+Tab:mode │ Ctrl+x:shortcuts') + [ "$out" = "idle grok-regex" ] || fail "grok idle footer without :cancel must be idle, got '$out'" pass "kimi and grok install no unverified semantic wiring and classify through their own gates" } diff --git a/tests/fm-busy-state.test.sh b/tests/fm-busy-state.test.sh index a6777a6b93..63a29e8a41 100755 --- a/tests/fm-busy-state.test.sh +++ b/tests/fm-busy-state.test.sh @@ -234,16 +234,26 @@ Ctrl+c:cancel' test_grok_regex_isolated() { local state out state=$(new_state_dir grok-arm) + # Older footer form still classifies busy (mixed-fleet compatibility). out=$(fm_busy_classify tmux w1 grok t1 "$state" 'thinking hard Ctrl+c:cancel') - [ "$out" = "busy grok-regex" ] || fail "grok busy tail must classify 'busy grok-regex', got '$out'" + [ "$out" = "busy grok-regex" ] || fail "older Ctrl+c:cancel busy tail must classify 'busy grok-regex', got '$out'" + # Grok 4.5 / Build 0.2.118 live busy footer (2026-08-02): Esc:cancel. + out=$(fm_busy_classify tmux w1 grok t1 "$state" 'Shift+Tab:mode │ Esc:cancel │ Ctrl+x:shortcuts') + [ "$out" = "busy grok-regex" ] || fail "Grok 4.5 Esc:cancel busy footer must classify 'busy grok-regex', got '$out'" + out=$(fm_busy_classify tmux w1 grok t1 "$state" 'Waiting for response… +Esc:cancel') + [ "$out" = "busy grok-regex" ] || fail "bare Esc:cancel busy token must classify 'busy grok-regex', got '$out'" + # Idle directions: bare prompt, and the live 4.5 idle keybind bar (no :cancel). out=$(fm_busy_classify tmux w1 grok t1 "$state" 'done. > ') [ "$out" = "idle grok-regex" ] || fail "grok idle tail must classify 'idle grok-regex', got '$out'" + out=$(fm_busy_classify tmux w1 grok t1 "$state" 'Shift+Tab:mode │ Ctrl+x:shortcuts') + [ "$out" = "idle grok-regex" ] || fail "Grok 4.5 idle footer without :cancel must classify 'idle grok-regex', got '$out'" # Another adapter's footer never makes grok busy either. out=$(fm_busy_classify tmux w1 grok t1 "$state" '• Working (6s • esc to interrupt)') [ "$out" = "idle grok-regex" ] || fail "a claude footer must not classify grok busy, got '$out'" - pass "the grok fallback is regex-scoped to grok and classifies only grok tasks" + pass "the grok fallback matches both cancel-footer forms and classifies idle without them" } # --- kimi verification gate ----------------------------------------------------- diff --git a/tests/fm-crew-state.test.sh b/tests/fm-crew-state.test.sh index 8f986b6139..924dd79c0f 100755 --- a/tests/fm-crew-state.test.sh +++ b/tests/fm-crew-state.test.sh @@ -827,22 +827,35 @@ test_no_run_footer_text_alone_is_not_working() { # Grok keeps its isolated temporary rendered-tail fallback until its structured # lifecycle is live-verified, so a grok crew still reads working from its own -# verified signature. +# verified signature (both older and Grok 4.5 cancel-footer forms). test_no_run_grok_uses_isolated_fallback() { reset_fakes - local d; d=$(new_case busy-grok) + local d out + d=$(new_case busy-grok) make_repo_on_branch "$d/wt" fm/feat-h3 make_fakebin "$d" >/dev/null fm_write_meta "$d/state/feat-h3.meta" "window=fm:fm-feat-h3" "worktree=$d/wt" "kind=ship" "harness=grok" FM_FAKE_AXI_STATUS="" FM_FAKE_RUNS_LIST="" FM_FAKE_BUSY=1 + # Older form. FM_FAKE_BUSY_TEXT='Ctrl+c:cancel' export FM_FAKE_BUSY_TEXT - local out; out=$(run_crew_state "$d" feat-h3) - assert_contains "$out" "state: working" "grok busy tail -> working" + out=$(run_crew_state "$d" feat-h3) + assert_contains "$out" "state: working" "grok older Ctrl+c:cancel busy tail -> working" assert_contains "$out" "grok-regex" "the grok verdict names its isolated fallback source" - pass "grok still reads working through its isolated rendered-tail fallback" + # Grok 4.5 form (live 2026-08-02). + FM_FAKE_BUSY_TEXT='Shift+Tab:mode │ Esc:cancel │ Ctrl+x:shortcuts' + export FM_FAKE_BUSY_TEXT + out=$(run_crew_state "$d" feat-h3) + assert_contains "$out" "state: working" "grok 4.5 Esc:cancel busy footer -> working" + assert_contains "$out" "grok-regex" "the grok 4.5 verdict names its isolated fallback source" + # Idle 4.5 footer must not report working from the rendered tail alone. + FM_FAKE_BUSY_TEXT='Shift+Tab:mode │ Ctrl+x:shortcuts' + export FM_FAKE_BUSY_TEXT + out=$(run_crew_state "$d" feat-h3) + assert_not_contains "$out" "state: working" "grok 4.5 idle footer must not read working" + pass "grok still reads working through both verified cancel-footer forms" } test_no_run_herdr_unknown_uses_backend_capture() { diff --git a/tests/fm-tmux-submit-busy.test.sh b/tests/fm-tmux-submit-busy.test.sh index f3eb49a7eb..2d217ad88b 100755 --- a/tests/fm-tmux-submit-busy.test.sh +++ b/tests/fm-tmux-submit-busy.test.sh @@ -252,8 +252,16 @@ test_claude_busy_signature_uses_real_capture_shapes() { printf 'Working...\n' > "$composer" pane_busy pi pi || fail "Pi Working footer should be busy" pane_busy pi-signed pi-signed || fail "pi-signed should share Pi's exact Working footer" + # Grok: both verified mid-turn cancel forms classify busy; idle bar does not. printf 'Ctrl+c:cancel\n' > "$composer" - pane_busy grok grok || fail "Grok cancel footer should be busy" + pane_busy grok-old grok || fail "older Grok Ctrl+c:cancel footer should be busy" + printf 'Shift+Tab:mode │ Esc:cancel │ Ctrl+x:shortcuts\n' > "$composer" + pane_busy grok-45 grok || fail "Grok 4.5 Esc:cancel busy footer should be busy" + printf 'Shift+Tab:mode │ Ctrl+x:shortcuts\n' > "$composer" + pane_busy grok-idle grok && fail "Grok 4.5 idle footer without :cancel must not be busy" + # Shared no-harness fallback still matches both Grok forms. + printf 'Esc:cancel\n' > "$composer" + pane_busy fallback-esc || fail "no-harness fallback should retain Grok 4.5 Esc:cancel" pass "fm_pane_is_busy: Claude spinner is scoped, multi-frame, and backward-compatible" }