From ec0a8562225a1bb55e5503d06d7ba49938a296ae Mon Sep 17 00:00:00 2001 From: Tiago Peixoto Date: Mon, 5 Oct 2026 21:12:00 +0200 Subject: [PATCH 01/10] fix: refuse a confirming Enter on the Claude exit picker The background-task picker still classifies as pending, so a retried Enter confirms "Exit and stop tasks". Stop after the Enter that opened it, and raise the existing stale wake with the dialog name. A non-paused secondmate still skips that wake, so a secondmate parked on the picker still looks idle. Model-downgrade confirmation, MCP approval, and any other Claude exit confirmation are not covered, because there is no recorded screen for them. --- bin/backends/herdr.sh | 15 ++++++ bin/fm-composer-lib.sh | 77 +++++++++++++++++++++++++++ bin/fm-control.sh | 30 ++++++++++- bin/fm-tmux-lib.sh | 9 ++++ bin/fm-watch.sh | 13 +++++ docs/verification/runtime-backends.md | 14 +++++ tests/fm-backend-herdr.test.sh | 24 +++++++++ tests/fm-composer-lib.test.sh | 55 +++++++++++++++++++ tests/fm-control.test.sh | 52 ++++++++++++++++++ tests/fm-tmux-submit-busy.test.sh | 28 ++++++++++ tests/fm-watch-triage.test.sh | 67 +++++++++++++++++++++++ 11 files changed, 383 insertions(+), 1 deletion(-) diff --git a/bin/backends/herdr.sh b/bin/backends/herdr.sh index c0cdd0540db..aba0b7fc965 100644 --- a/bin/backends/herdr.sh +++ b/bin/backends/herdr.sh @@ -3377,6 +3377,7 @@ fm_backend_herdr_composer_content() { # else return 1 fi + fm_composer_note_blocking_dialog "$cap" || true FM_COMPOSER_GHOST_LUMA_MAX=0 fm_composer_extract_selected_content "$caps" "$cap" } @@ -3431,6 +3432,11 @@ fm_backend_herdr_composer_clear() { # fm_backend_herdr_send_text_submit() { # local target=$1 text=$2 retries=$3 sleep_s=$4 settle=$5 i=0 verdict baseline confirm_sleep local raw_status footer_baseline='' allow_rendered=0 enter_sent=0 identity proof=0 content + # The first Enter can open a picker. A later Enter would confirm it. + fm_composer_dialog_sink_prepare || { printf 'unknown'; return 0; } + if [ "$FM_COMPOSER_DIALOG_OWNED" = 1 ]; then + trap 'fm_composer_dialog_sink_release' RETURN + fi fm_backend_herdr_parse_target "$target" || { printf 'unknown'; return 0; } # Claude on Herdr is the live-verified truncation shape: Enter is withheld # unless the composer, empty before the send, shows this payload. A suffix @@ -3487,7 +3493,12 @@ fm_backend_herdr_send_text_submit() { # esac # Native stayed idle. Composer empty is positive delivery (a landed # Claude turn that never flipped agent_status). Proven pending retries. + # A picker that classifies pending must not receive that retry. verdict=$(fm_backend_herdr_composer_state "$target") + if fm_composer_blocking_dialog_noted >/dev/null; then + printf 'unknown' + return 0 + fi case "$verdict" in empty) printf 'empty'; return 0 ;; pending|pending-unproven) ;; @@ -3496,6 +3507,10 @@ fm_backend_herdr_send_text_submit() { # else sleep "$sleep_s" verdict=$(fm_backend_herdr_composer_state "$target") + if fm_composer_blocking_dialog_noted >/dev/null; then + printf 'unknown' + return 0 + fi if [ "$verdict" = pending ] && [ "$raw_status" != working ] \ && [ "$footer_baseline" = idle ] \ && [ "$(fm_backend_herdr_rendered_busy_state "$target")" = busy ]; then diff --git a/bin/fm-composer-lib.sh b/bin/fm-composer-lib.sh index 38285587980..3b47a523e05 100644 --- a/bin/fm-composer-lib.sh +++ b/bin/fm-composer-lib.sh @@ -1671,9 +1671,77 @@ EOF printf '%s\n' "$joined" | LC_ALL=C awk '{$1=$1; printf "%s", $0}' } +# fm_composer_blocking_dialog: name a screen whose next Enter would answer it. +# Prints the name and returns 0 only when every fixed string of one recorded +# dialog is present together. One string is not enough, because worker prose +# can quote a heading. A miss returns 1 and prints nothing. +# Recorded 2026-10-05 on Claude Code 2.1.289: /exit while a background shell +# is still running opens this picker, and its selected row is Exit and stop tasks. +fm_composer_blocking_dialog() { # -> dialog name + local screen=${1-} plain + [ -n "$screen" ] || return 1 + plain=$(printf '%s\n' "$screen" | fm_composer_strip_ansi) + if printf '%s\n' "$plain" | grep -F -q 'Background work is running' \ + && printf '%s\n' "$plain" | grep -F -q 'Exit and stop tasks' \ + && printf '%s\n' "$plain" | grep -F -q 'Enter to confirm'; then + printf '%s' 'Claude background-task exit picker' + return 0 + fi + return 1 +} + +# A command substitution drops a shell variable, and every composer read runs +# inside one. The name is therefore written to FM_COMPOSER_DIALOG_SINK when +# that path is set. The classifier verdict is unchanged. +fm_composer_note_blocking_dialog() { # + local name= + if name=$(fm_composer_blocking_dialog "$1"); then + if [ -n "${FM_COMPOSER_DIALOG_SINK:-}" ]; then + printf '%s' "$name" > "$FM_COMPOSER_DIALOG_SINK" || return 1 + fi + return 0 + fi + if [ -n "${FM_COMPOSER_DIALOG_SINK:-}" ]; then + : > "$FM_COMPOSER_DIALOG_SINK" || return 1 + fi + return 1 +} + +# fm_composer_blocking_dialog_noted: print the name the latest classify wrote +# to the sink. Returns 1 when the sink is unset or empty. +fm_composer_blocking_dialog_noted() { + [ -n "${FM_COMPOSER_DIALOG_SINK:-}" ] || return 1 + [ -s "$FM_COMPOSER_DIALOG_SINK" ] || return 1 + cat "$FM_COMPOSER_DIALOG_SINK" +} + +# Empty the sink, creating it when the caller has not. Sets +# FM_COMPOSER_DIALOG_OWNED=1 only for a sink this call created, so a caller +# that shares the path can still read the name after this function returns. +fm_composer_dialog_sink_prepare() { + FM_COMPOSER_DIALOG_OWNED=0 + if [ -z "${FM_COMPOSER_DIALOG_SINK:-}" ]; then + FM_COMPOSER_DIALOG_SINK=$(mktemp "${TMPDIR:-/tmp}/fm-composer-dialog.XXXXXX") || return 1 + FM_COMPOSER_DIALOG_OWNED=1 + return 0 + fi + : > "$FM_COMPOSER_DIALOG_SINK" +} + +fm_composer_dialog_sink_release() { + trap - RETURN + if [ "${FM_COMPOSER_DIALOG_OWNED:-}" = 1 ]; then + rm -f "$FM_COMPOSER_DIALOG_SINK" + FM_COMPOSER_DIALOG_SINK= + FM_COMPOSER_DIALOG_OWNED=0 + fi +} + fm_composer_classify_screen() { # [cursor_row] [identity] local caps=$1 screen=$2 cy=${3:-} identity=${4:-} local styled=0 cursor=0 has_identity=0 kv plain + # Note the dialog before any early return so a pending picker is still named. + fm_composer_note_blocking_dialog "$screen" || true while IFS= read -r kv; do case "$kv" in styled=1) styled=1 ;; @@ -1791,10 +1859,19 @@ EOF # fm_composer_queued_enter_verdict; no shape knowledge lives in any loop. fm_composer_submit_retry_core() { # [expected-label] local send_key_fn=$1 state_fn=$2 target=$3 retries=$4 sleep_s=$5 expected_label=${6:-} i=0 state + # The first Enter can open a picker. A later Enter would confirm it. + fm_composer_dialog_sink_prepare || { printf 'unknown'; return 0; } + if [ "$FM_COMPOSER_DIALOG_OWNED" = 1 ]; then + trap 'fm_composer_dialog_sink_release' RETURN + fi while :; do "$send_key_fn" "$target" Enter "$expected_label" || true sleep "$sleep_s" state=$("$state_fn" "$target" "$expected_label") + if fm_composer_blocking_dialog_noted >/dev/null; then + printf 'unknown' + return 0 + fi case "$state" in pending|pending-unproven) ;; *) printf '%s' "$state"; return 0 ;; diff --git a/bin/fm-control.sh b/bin/fm-control.sh index aa4c9af2004..85023d88d11 100755 --- a/bin/fm-control.sh +++ b/bin/fm-control.sh @@ -204,6 +204,9 @@ control_cleanup() { CONTROL_LOCK_HELD=0 fm_lock_release "$CONTROL_LOCK" || true fi + if [ -n "${FM_COMPOSER_DIALOG_SINK:-}" ]; then + rm -f "$FM_COMPOSER_DIALOG_SINK" + fi if declare -F fm_lease_guard_release >/dev/null 2>&1; then fm_lease_guard_release || true fi @@ -390,6 +393,13 @@ require_state_verified_backend() { # die "task $ID runs on the $BACKEND backend, which has no recovery-grade agent-state classifier, so '$1' cannot prove the agent actually stopped; refusing rather than reporting an unproven transition as done" } +# refuse_blocking_prompt: the screen is a dialog a confirming Enter would +# answer. Name it and stop. Do not type Escape or an option: both dismiss +# or choose. +refuse_blocking_prompt() { # + die "task $ID is blocked on a prompt: $1. Refusing to type Enter into it." +} + # rendered_matches : whether any row of the visible viewport matches. # An unreadable viewport is a no, so every caller treats it as missing proof. rendered_matches() { # @@ -557,7 +567,7 @@ retire_busy_incarnation() { # do_exit: stop the running agent, preserving endpoint and worktree. Prints # `already-stopped`, `endpoint-gone`, or `stopped`. do_exit() { - local state cmd hazard verdict composer_state cancel absence interrupt_result=not-needed + local state cmd hazard verdict composer_state cancel absence interrupt_result=not-needed dialog require_state_verified_backend exit state=$(agent_state) case "$state" in @@ -624,8 +634,17 @@ do_exit() { if [ -n "$hazard" ] && rendered_matches "$hazard"; then die "task $ID shows the $HARNESS revert picker, where typed text becomes a search and Enter reverts file changes; refusing to type the $cmd exit command. Close it with $(fm_control_interrupt_key "$HARNESS"), never Enter, then retry '$VERB'" fi + FM_COMPOSER_DIALOG_SINK=$STATE/$ID.composer-dialog + : > "$FM_COMPOSER_DIALOG_SINK" \ + || die "task $ID's dialog check could not be recorded" composer_state=$(fm_backend_composer_state "$BACKEND" "$T" "$LABEL" 2>/dev/null) \ || composer_state=unknown + # The classify that filled the sink ran in a subshell, so read the file + # rather than a function that subshell sourced. + if [ -s "${FM_COMPOSER_DIALOG_SINK:-}" ]; then + dialog=$(cat "$FM_COMPOSER_DIALOG_SINK") + refuse_blocking_prompt "$dialog" + fi case "$composer_state" in empty) ;; pending) @@ -645,6 +664,15 @@ do_exit() { || die "the exit command could not be sent to task $ID on $BACKEND" [ "$verdict" != send-failed ] \ || die "the exit command could not be sent to task $ID on $BACKEND" + # The submitting Enter can open the picker. The agent is still alive, and + # another Enter would confirm the selected row. A dead agent may leave the + # same text behind; that is not a prompt still waiting. + if [ -s "${FM_COMPOSER_DIALOG_SINK:-}" ]; then + dialog=$(cat "$FM_COMPOSER_DIALOG_SINK") + if [ "$(agent_state)" != dead ]; then + refuse_blocking_prompt "$dialog" + fi + fi state=$(wait_agent_state "$EXIT_WAIT" dead) || { die "exit-delivered $ID interrupt=$interrupt_result exit-command=delivered agent-state=$state exit=unconfirmed; the agent did not stop within ${EXIT_WAIT}s" } diff --git a/bin/fm-tmux-lib.sh b/bin/fm-tmux-lib.sh index f031e65870b..4694eff6c4f 100755 --- a/bin/fm-tmux-lib.sh +++ b/bin/fm-tmux-lib.sh @@ -246,10 +246,19 @@ fm_pane_is_busy() { # [harness] # transition evidence could mark an undelivered message delivered. fm_tmux_submit_enter_core() { # [baseline-idle] local target=$1 retries=$2 sleep_s=$3 baseline_idle=${4:-} i=0 j state busy_state + # The first Enter can open a picker. A later Enter would confirm it. + fm_composer_dialog_sink_prepare || { printf 'unknown'; return 0; } + if [ "$FM_COMPOSER_DIALOG_OWNED" = 1 ]; then + trap 'fm_composer_dialog_sink_release' RETURN + fi while :; do tmux send-keys -t "$target" Enter 2>/dev/null || true sleep "$sleep_s" state=$(fm_tmux_composer_state "$target") + if fm_composer_blocking_dialog_noted >/dev/null; then + printf 'unknown' + return 0 + fi case "$state" in pending|pending-unproven) ;; unknown) diff --git a/bin/fm-watch.sh b/bin/fm-watch.sh index dcd1f7c4933..0cad45879d9 100755 --- a/bin/fm-watch.sh +++ b/bin/fm-watch.sh @@ -215,6 +215,10 @@ WATCH_HOME_EXISTED=0 . "$SCRIPT_DIR/fm-pending-reply-lib.sh" # shellcheck source=bin/fm-busy-lib.sh . "$SCRIPT_DIR/fm-busy-lib.sh" +# Dialog recognition lives in the composer owner. shellcheck does not follow +# this source: the watcher graph must not absorb the classifier AST. +# shellcheck source=/dev/null +. "$SCRIPT_DIR/fm-composer-lib.sh" # Steering-inbox loss detection: bin/fm-task-inbox-lib.sh owns the record, # doorbell, re-ring ladder, and unavailable-endpoint contracts; this watcher # supplies their live endpoint and busy checks plus wake emission @@ -3042,6 +3046,15 @@ EOF paused) handle_paused_stale "$w" "$task" "$h" ;; *) clear_pause_tracking "$key" ;; esac + elif dialog=$(fm_composer_blocking_dialog "$tail40"); then + # A non-paused secondmate never reaches this branch. The idle + # exemption above still skips that pane, so a secondmate parked + # on this picker stays quiet. + if [ "$(cat "$sf" 2>/dev/null || true)" != "$h" ]; then + fm_wake_append stale "$w" "stale: $w (blocked on a prompt: $dialog)" || exit 1 + printf '%s' "$h" > "$sf" + wake "stale: $w (blocked on a prompt: $dialog)" + fi elif afk_present; then # Daemon owns triage: one-shot per distinct stale hash, as before, # except that a captain-held pane is never handed over while the diff --git a/docs/verification/runtime-backends.md b/docs/verification/runtime-backends.md index f45033ef120..32d1fea693e 100644 --- a/docs/verification/runtime-backends.md +++ b/docs/verification/runtime-backends.md @@ -1264,6 +1264,20 @@ FM_HERDR_SUBMIT_CONFIRM_LIVE=1 tests/fm-herdr-submit-confirm-live-e2e.test.sh ok - live Herdr submit confirm: Claude Code (2.1.283 (Claude Code)) on herdr 0.9.0 proves and submits a typed /exit behind its command popup ``` +### Claude background-task exit picker + +Measured 2026-10-05 against Claude Code 2.1.289 in an isolated tmux session. +The Herdr lab was not running, so the Herdr path is covered by the existing fakes. +Typing `/exit` while a background shell is still running opens a picker whose selected row is "Exit and stop tasks" and whose footer is "Enter to confirm · Esc to cancel". +That screen still classifies as pending, the same verdict as unsubmitted composer text. +A second Enter would confirm the selected row. +Submit retries now stop after the Enter that opened the picker and report unknown. +Exit reports that the worker is blocked on the Claude background-task exit picker and does not type another Enter. +The watcher raises the existing stale wake once per unchanged screen, with that dialog name in the reason. +A non-paused secondmate still skips pane staleness, so a secondmate parked on this picker still looks idle. +That exemption is unchanged. +No recorded screen was available for a model-downgrade confirmation, an MCP approval, or a Claude exit confirmation other than this picker, so those dialogs are not covered. + ### Prune and respawn The real label-collision reproduction is owned by: diff --git a/tests/fm-backend-herdr.test.sh b/tests/fm-backend-herdr.test.sh index 0911e981bcc..e46e4645e18 100755 --- a/tests/fm-backend-herdr.test.sh +++ b/tests/fm-backend-herdr.test.sh @@ -4431,6 +4431,29 @@ test_send_text_submit_popup_autocomplete_requires_second_enter() { pass "fm_backend_herdr_send_text_submit: a slash-command popup's placeholder fill on Enter #1 never flips agent_status to working, so it does not short-circuit as submitted; Enter #2 is retried and lands it" } +test_send_text_submit_refuses_confirming_enter_on_exit_picker() { + local dir log resp fb out enter_count + dir="$TMP_ROOT/submit-exit-picker"; mkdir -p "$dir/responses"; log="$dir/log"; resp="$dir/responses"; : > "$log" + herdr_submit_claude_prefix "$resp" "/exit" + printf '{"result":{"agent":{"agent":"claude","agent_status":"idle"}}}\n' > "$resp/5.out" + printf '{"result":{"agent":{"agent":"claude","agent_status":"idle"}}}\n' > "$resp/7.out" + printf '%s\n' \ + 'Background work is running' \ + '❯ 1. Exit and stop tasks' \ + 'The following will stop when you exit:' \ + 'shell · sleep 300' \ + ' 2. Move to background and exit' \ + ' 3. Stay' \ + 'Enter to confirm · Esc to cancel' > "$resp/8.out" + fb=$(make_herdr_fakebin "$dir") + out=$( PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" FM_BACKEND_HERDR_SUBMIT_POLLS=1 \ + bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_send_text_submit default:w1:p2 "/exit" 3 0.01 0.01' "$ROOT" ) + enter_count=$(grep -c $'\x1f''pane'$'\x1f''send-keys'$'\x1f''w1:p2'$'\x1f''enter' "$log" || true) + [ "$out" = unknown ] || fail "the exit picker should stop the retry as unknown, got '$out'; log: $(cat "$log")" + [ "$enter_count" -eq 1 ] || fail "the exit picker should get one Enter, got $enter_count; log: $(cat "$log")" + pass "fm_backend_herdr_send_text_submit: the Claude background-task exit picker gets no confirming Enter" +} + test_send_text_submit_confirms_blocked_after_enter() { local dir log resp fb out enter_count dir="$TMP_ROOT/submit-blocked"; mkdir -p "$dir/responses"; log="$dir/log"; resp="$dir/responses"; : > "$log" @@ -5954,6 +5977,7 @@ test_send_text_submit_detects_landed_send test_send_text_submit_detects_swallowed_enter test_send_text_submit_replays_literal_send_stderr test_send_text_submit_popup_autocomplete_requires_second_enter +test_send_text_submit_refuses_confirming_enter_on_exit_picker test_send_text_submit_confirms_blocked_after_enter test_send_text_submit_preexisting_working_pending_is_queued_enter test_send_text_submit_preexisting_working_does_not_confirm_failed_enter diff --git a/tests/fm-composer-lib.test.sh b/tests/fm-composer-lib.test.sh index 4dbddf14b82..14e104a57e9 100755 --- a/tests/fm-composer-lib.test.sh +++ b/tests/fm-composer-lib.test.sh @@ -1080,3 +1080,58 @@ test_queued_enter_verdict_does_not_convert_other_states() { test_queued_enter_verdict_busy_pending_is_empty test_queued_enter_verdict_idle_pending_stays_pending test_queued_enter_verdict_does_not_convert_other_states + +# The selected row sits on cursor row 1 so a tmux read whose cursor is that +# row, and a cursorless read, both still see unsubmitted text. +exit_picker_screen() { + printf '%s\n' \ + 'Background work is running' \ + '❯ 1. Exit and stop tasks' \ + 'The following will stop when you exit:' \ + 'shell · sleep 300' \ + ' 2. Move to background and exit' \ + ' 3. Stay' \ + 'Enter to confirm · Esc to cancel' +} + +fm_test_picker_send() { + printf 'Enter\n' >> "$FM_TEST_PICKER_ENTERS" +} + +fm_test_picker_state() { + fm_composer_classify_screen 'styled=1' "$FM_TEST_PICKER_SCREEN" 1 +} + +test_background_exit_picker_stays_pending_and_blocks_retry() { + local screen out rc sink enters + screen=$(exit_picker_screen) + out=$(fm_composer_blocking_dialog "$screen"); rc=$? + [ "$rc" -eq 0 ] || fail "the recorded picker should match" + [ "$out" = 'Claude background-task exit picker' ] || fail "dialog name was '$out'" + out=$(fm_composer_blocking_dialog 'Background work is running'); rc=$? + [ "$rc" -eq 1 ] || fail "a heading alone must not match" + [ -z "$out" ] || fail "a miss must print nothing, got '$out'" + out=$(fm_composer_blocking_dialog "$(printf '%s\n' 'Background work is running' 'Exit and stop tasks')"); rc=$? + [ "$rc" -eq 1 ] || fail "two of the three strings must not match" + sink=$(mktemp) + FM_COMPOSER_DIALOG_SINK=$sink + out=$(fm_composer_classify_screen 'styled=1' "$screen" 1) + [ "$out" = pending ] || fail "cursor on the selected row should stay pending, got '$out'" + [ "$(cat "$sink")" = 'Claude background-task exit picker' ] || fail "classify should note the dialog, got '$(cat "$sink")'" + out=$(fm_composer_classify_screen 'styled=1' "$screen") + [ "$out" = pending ] || fail "a styled cursorless picker should stay pending, got '$out'" + unset FM_COMPOSER_DIALOG_SINK + rm -f "$sink" + FM_TEST_PICKER_SCREEN=$screen + FM_TEST_PICKER_ENTERS=$(mktemp) + : > "$FM_TEST_PICKER_ENTERS" + out=$(fm_composer_submit_retry_core fm_test_picker_send fm_test_picker_state win 3 0) + enters=$(grep -c '^Enter$' "$FM_TEST_PICKER_ENTERS" || true) + [ "$out" = unknown ] || fail "a picker must stop the retry as unknown, got '$out'" + [ "$enters" -eq 1 ] || fail "a picker must receive one Enter, got $enters" + rm -f "$FM_TEST_PICKER_ENTERS" + unset FM_TEST_PICKER_SCREEN FM_TEST_PICKER_ENTERS + pass "the Claude background-task exit picker stays pending and receives no confirming Enter" +} + +test_background_exit_picker_stays_pending_and_blocks_retry diff --git a/tests/fm-control.test.sh b/tests/fm-control.test.sh index 832c3fd7a49..e9cb0184714 100755 --- a/tests/fm-control.test.sh +++ b/tests/fm-control.test.sh @@ -175,6 +175,10 @@ case "${1:-}" in done printf 'fakepane\n'; exit 0 ;; capture-pane) + if [ -f "$D/after-enter" ] && [ -f "$D/keys" ] && grep -qx Enter "$D/keys"; then + cat "$D/after-enter" + exit 0 + fi if [ -f "$D/devin" ]; then devin_screen "$(cat "$D/devin")"; elif [ -f "$D/pane" ]; then cat "$D/pane"; else printf '╭────╮\n│ │\n╰────╯\n'; fi exit 0 ;; list-windows) @@ -838,6 +842,52 @@ test_busy_agent_is_interrupted_before_the_exit_command() { pass "fm-control exit: a busy agent receives interrupt delivery before the exit command" } +exit_picker_screen() { + printf '%s\n' \ + 'Background work is running' \ + '❯ 1. Exit and stop tasks' \ + 'The following will stop when you exit:' \ + 'shell · sleep 300' \ + ' 2. Move to background and exit' \ + ' 3. Stay' \ + 'Enter to confirm · Esc to cancel' +} + +test_exit_refuses_an_open_background_picker() { + local dir out rc + dir=$(new_case open-picker) + add_task "$dir" t1 claude + alive_as "$dir" claude + exit_picker_screen > "$dir/fake/pane" + out=$(run_control "$dir" t1 exit); rc=$? + expect_code 1 "$rc" "an open exit picker should refuse"$'\n'"$out" + assert_contains "$out" "blocked on a prompt: Claude background-task exit picker" \ + "the refusal should name the dialog" + assert_not_contains "$out" "Esc" "the refusal must not name a dismissal key" + [ ! -s "$dir/fake/literal" ] || fail "an open picker must not be typed into" + [ ! -s "$dir/fake/keys" ] || fail "an open picker must receive no keys" + pass "fm-control exit: an already-open background-task picker is not typed into" +} + +test_exit_refuses_the_confirming_enter() { + local dir out rc enters + dir=$(new_case confirm-picker) + add_task "$dir" t1 claude + alive_as "$dir" claude + exit_picker_screen > "$dir/fake/after-enter" + out=$(env FM_FAKE_NEVER_DIES=1 PATH="$dir/fakebin:$PATH" FM_HOME="$dir/home" \ + FM_FAKE_DIR="$dir/fake" FM_CONTROL_POLL=0.01 FM_CONTROL_EXIT_WAIT=0.05 \ + "$CONTROL" t1 exit 2>&1); rc=$? + expect_code 1 "$rc" "the confirming Enter should refuse"$'\n'"$out" + assert_contains "$out" "blocked on a prompt: Claude background-task exit picker" \ + "the refusal should name the dialog" + assert_not_contains "$out" "Esc" "the refusal must not name a dismissal key" + [ "$(literals "$dir")" = /exit ] || fail "the exit command should still be typed, got '$(literals "$dir")'" + enters=$(grep -c '^Enter$' "$dir/fake/keys" || true) + [ "$enters" -eq 1 ] || fail "only the submitting Enter should be sent, got $enters" + pass "fm-control exit: the Enter that opens the background-task picker is not followed by a confirming Enter" +} + test_idle_agent_is_not_interrupted() { local dir out rc gen dir=$(new_case idle) @@ -1100,6 +1150,8 @@ test_interrupt_refuses_when_no_agent_runs test_ambiguous_endpoint_refuses test_busy_agent_is_interrupted_before_the_exit_command test_idle_agent_is_not_interrupted +test_exit_refuses_an_open_background_picker +test_exit_refuses_the_confirming_enter test_interrupt_without_acknowledgement_preserves_busy_state test_muse_interrupt_confirms_adapter_acknowledgement test_interrupt_revalidates_agent_after_acknowledgement_wait diff --git a/tests/fm-tmux-submit-busy.test.sh b/tests/fm-tmux-submit-busy.test.sh index 5290aaaea97..6fbee4d612f 100755 --- a/tests/fm-tmux-submit-busy.test.sh +++ b/tests/fm-tmux-submit-busy.test.sh @@ -344,6 +344,34 @@ test_claude_busy_signature_uses_real_capture_shapes() { test_busy_pane_pending_returns_empty test_idle_pane_pending_returns_pending + +test_exit_picker_refuses_confirming_enter() { + local dir fakebin composer sent vfile enters + dir="$TMP_ROOT/exit-picker" + fakebin=$(make_submit_mock "$dir") + composer="$dir/composer" + sent="$dir/sent.log" + vfile="$dir/verdict" + printf '%s\n' \ + 'Background work is running' \ + '❯ 1. Exit and stop tasks' \ + 'The following will stop when you exit:' \ + 'shell · sleep 300' \ + ' 2. Move to background and exit' \ + ' 3. Stay' \ + 'Enter to confirm · Esc to cancel' > "$composer" + : > "$sent" + touch "$dir/.swallow" + PATH="$fakebin:$PATH" FM_FAKE_COMPOSER="$composer" FM_FAKE_SENT="$sent" \ + FM_FAKE_SWALLOW="$dir/.swallow" FM_FAKE_PERSIST_SWALLOW=1 FM_FAKE_PANE_BUSY=0 \ + fm_tmux_submit_enter_core "win" 3 0 > "$vfile" 2>/dev/null + [ "$(cat "$vfile")" = unknown ] || fail "exit picker should return unknown, got '$(cat "$vfile")'" + enters=$(grep -c '^Enter$' "$sent" || true) + [ "$enters" -eq 1 ] || fail "exit picker should get one Enter, got $enters" + pass "fm_tmux_submit_enter_core: the Claude background-task exit picker gets no confirming Enter" +} + +test_exit_picker_refuses_confirming_enter test_wrapped_continuation_retries_swallowed_enter test_placeholder_like_bare_input_retries_swallowed_enter test_busy_pane_composer_clears_first_try diff --git a/tests/fm-watch-triage.test.sh b/tests/fm-watch-triage.test.sh index c2a2d60924d..5e8b1add67e 100755 --- a/tests/fm-watch-triage.test.sh +++ b/tests/fm-watch-triage.test.sh @@ -4286,6 +4286,71 @@ test_secondmate_nonpaused_stale_remains_suppressed() { pass "a non-paused secondmate retains normal stale suppression" } +exit_picker_capture() { + printf '%s\n' \ + 'Background work is running' \ + '❯ 1. Exit and stop tasks' \ + 'The following will stop when you exit:' \ + 'shell · sleep 300' \ + ' 2. Move to background and exit' \ + ' 3. Stay' \ + 'Enter to confirm · Esc to cancel' +} + +test_exit_picker_stale_names_the_dialog() { + local dir state fakebin out capture_file statusf window key sig pid + dir=$(make_case exit-picker-stale); state="$dir/state"; fakebin="$dir/fakebin" + out="$dir/watch.out"; capture_file="$dir/pane.txt"; statusf="$state/exit-picker.status" + window="test:fm-exit-picker" + exit_picker_capture > "$capture_file" + printf 'window=%s\nkind=ship\nharness=claude\nbackend=tmux\n' "$window" > "$state/exit-picker.meta" + printf 'working: implementing\n' > "$statusf" + sig=$(seen_sig "$statusf"); printf '%s' "$sig" > "$state/.seen-exit-picker_status" + key=$(printf '%s' "$window" | tr ':/.' '___') + printf '%s' "$(hash_text "$(cat "$capture_file")")" > "$state/.hash-$key" + printf '1\n' > "$state/.count-$key" + PATH="$fakebin:$PATH" FM_FAKE_TMUX_WINDOW="$window" FM_FAKE_TMUX_CAPTURE="$capture_file" \ + FM_FAKE_TMUX_CURRENT_COMMAND=claude \ + watch_bg "$state" "$fakebin" "$out" + pid=$! + wait_for_exit "$pid" 100 || { reap "$pid"; fail "exit picker did not wake: $(cat "$out")"; } + grep -Fx "stale: $window (blocked on a prompt: Claude background-task exit picker)" "$out" >/dev/null \ + || { reap "$pid"; fail "exit picker wake did not name the dialog: $(cat "$out")"; } + grep -F "stale: $window (blocked on a prompt: Claude background-task exit picker)" "$state/.wake-queue" >/dev/null \ + || fail "exit picker wake was not queued" + pass "an ordinary worker parked on the background-task exit picker raises a stale wake that names it" +} + +test_secondmate_exit_picker_stays_quiet() { + local dir state fakebin out capture_file statusf window key sig pid + dir=$(make_case secondmate-exit-picker); state="$dir/state"; fakebin="$dir/fakebin" + out="$dir/watch.out"; capture_file="$dir/pane.txt"; statusf="$state/secondmate-picker.status" + window="test:fm-secondmate-picker" + exit_picker_capture > "$capture_file" + printf 'window=%s\nkind=secondmate\nharness=claude\nbackend=tmux\n' "$window" > "$state/secondmate-picker.meta" + printf 'working: the parent supervises this secondmate\n' > "$statusf" + sig=$(seen_sig "$statusf"); printf '%s' "$sig" > "$state/.seen-secondmate-picker_status" + key=$(printf '%s' "$window" | tr ':/.' '___') + printf '%s' "$(hash_text "$(cat "$capture_file")")" > "$state/.hash-$key" + printf '1\n' > "$state/.count-$key" + PATH="$fakebin:$PATH" FM_FAKE_TMUX_WINDOW="$window" FM_FAKE_TMUX_CAPTURE="$capture_file" \ + FM_FAKE_TMUX_CURRENT_COMMAND=claude \ + watch_bg "$state" "$fakebin" "$out" + pid=$! + if ! wait_poll_cycle "$state" "$pid"; then + reap "$pid"; fail "watcher left a secondmate parked on the exit picker: $(cat "$out")" + fi + if grep -F 'blocked on a prompt' "$out" >/dev/null; then + reap "$pid" + fail "a secondmate exit picker was named: $(cat "$out")" + fi + if [ -f "$state/.wake-queue" ] && grep -F 'blocked on a prompt' "$state/.wake-queue" >/dev/null; then + fail "a secondmate exit picker was queued" + fi + reap "$pid" + pass "a non-paused secondmate parked on the exit picker stays quiet" +} + test_secondmate_unpause_clears_pause_tracking() { local dir state fakebin out statusf window key pid dir=$(make_case secondmate-unpause-clears); state="$dir/state"; fakebin="$dir/fakebin" @@ -6717,6 +6782,8 @@ test_reheld_captain_call_starts_its_own_resurface_window test_secondmate_paused_resurfaces_in_normal_mode test_secondmate_captain_held_resurfaces_in_normal_mode test_secondmate_nonpaused_stale_remains_suppressed +test_exit_picker_stale_names_the_dialog +test_secondmate_exit_picker_stays_quiet test_secondmate_unpause_clears_pause_tracking test_nonterminal_stale_pause_transitions_reclassify_unchanged_hash test_nonterminal_paused_rechecks_authoritative_state From 5185893c6b7bccd615a6f599226ba06e6e3fd514 Mon Sep 17 00:00:00 2001 From: Tiago Peixoto Date: Mon, 5 Oct 2026 21:50:29 +0200 Subject: [PATCH 02/10] no-mistakes(review): fix: anchor exit picker match and wake second mates --- bin/backends/herdr.sh | 6 --- bin/fm-backend.sh | 22 ++++++--- bin/fm-composer-lib.sh | 29 ++++++----- bin/fm-control.sh | 8 ++++ bin/fm-test-run.sh | 4 +- bin/fm-tmux-lib.sh | 6 +-- bin/fm-watch.sh | 25 +++++----- docs/verification/runtime-backends.md | 9 +++- tests/fm-backend-herdr.test.sh | 41 +++++++++++++++- tests/fm-composer-lib.test.sh | 59 +++++++++++++++++++++++ tests/fm-control.test.sh | 38 ++++++++++++++- tests/fm-test-run.test.sh | 11 +++++ tests/fm-tmux-submit-busy.test.sh | 2 + tests/fm-watch-triage.test.sh | 69 +++++++++++++++++++++++---- 14 files changed, 267 insertions(+), 62 deletions(-) diff --git a/bin/backends/herdr.sh b/bin/backends/herdr.sh index aba0b7fc965..15ae10a5800 100644 --- a/bin/backends/herdr.sh +++ b/bin/backends/herdr.sh @@ -3377,7 +3377,6 @@ fm_backend_herdr_composer_content() { # else return 1 fi - fm_composer_note_blocking_dialog "$cap" || true FM_COMPOSER_GHOST_LUMA_MAX=0 fm_composer_extract_selected_content "$caps" "$cap" } @@ -3432,11 +3431,6 @@ fm_backend_herdr_composer_clear() { # fm_backend_herdr_send_text_submit() { # local target=$1 text=$2 retries=$3 sleep_s=$4 settle=$5 i=0 verdict baseline confirm_sleep local raw_status footer_baseline='' allow_rendered=0 enter_sent=0 identity proof=0 content - # The first Enter can open a picker. A later Enter would confirm it. - fm_composer_dialog_sink_prepare || { printf 'unknown'; return 0; } - if [ "$FM_COMPOSER_DIALOG_OWNED" = 1 ]; then - trap 'fm_composer_dialog_sink_release' RETURN - fi fm_backend_herdr_parse_target "$target" || { printf 'unknown'; return 0; } # Claude on Herdr is the live-verified truncation shape: Enter is withheld # unless the composer, empty before the send, shows this payload. A suffix diff --git a/bin/fm-backend.sh b/bin/fm-backend.sh index 798bb5c599a..64b6ea49909 100644 --- a/bin/fm-backend.sh +++ b/bin/fm-backend.sh @@ -812,17 +812,25 @@ fm_backend_send_key() { # [expected-label] # retrying only the submission (never retyping). Echoes the backend's # proof-carrying verdict; callers require exact empty for confirmed delivery. fm_backend_send_text_submit() { # [expected-label] - local backend=$1 + local backend=$1 rc=0 shift fm_backend_source "$backend" || return 1 + # Every Enter loop below reads the dialog sink, so it must exist before + # any adapter types: a sink that fails here leaves the composer untouched. + fm_composer_dialog_sink_prepare || { + echo "error: the dialog check for a $backend submit could not be recorded" >&2 + return 1 + } case "$backend" in - tmux) fm_backend_tmux_send_text_submit "$@" ;; - herdr) fm_backend_herdr_send_text_submit "$@" ;; - zellij) fm_backend_zellij_send_text_submit "$@" ;; - orca) fm_backend_orca_send_text_submit "$@" ;; - cmux) fm_backend_cmux_send_text_submit "$@" ;; - *) echo "error: no send-text implementation for backend '$backend'" >&2; return 1 ;; + tmux) fm_backend_tmux_send_text_submit "$@" || rc=$? ;; + herdr) fm_backend_herdr_send_text_submit "$@" || rc=$? ;; + zellij) fm_backend_zellij_send_text_submit "$@" || rc=$? ;; + orca) fm_backend_orca_send_text_submit "$@" || rc=$? ;; + cmux) fm_backend_cmux_send_text_submit "$@" || rc=$? ;; + *) echo "error: no send-text implementation for backend '$backend'" >&2; rc=1 ;; esac + fm_composer_dialog_sink_release + return "$rc" } # fm_backend_kill: remove the task's session endpoint. An already-gone target diff --git a/bin/fm-composer-lib.sh b/bin/fm-composer-lib.sh index 3b47a523e05..fd7cae26003 100644 --- a/bin/fm-composer-lib.sh +++ b/bin/fm-composer-lib.sh @@ -1672,18 +1672,22 @@ EOF } # fm_composer_blocking_dialog: name a screen whose next Enter would answer it. -# Prints the name and returns 0 only when every fixed string of one recorded -# dialog is present together. One string is not enough, because worker prose -# can quote a heading. A miss returns 1 and prints nothing. +# Prints the name and returns 0 only for the recorded structure of one dialog: +# its heading, then its selected row alone on a row, with its footer as the +# last non-blank row. The strings alone are not enough, because a diff, a +# note, or a test fixture on the pane can quote all of them above a normal +# composer. A miss returns 1 and prints nothing. # Recorded 2026-10-05 on Claude Code 2.1.289: /exit while a background shell # is still running opens this picker, and its selected row is Exit and stop tasks. fm_composer_blocking_dialog() { # -> dialog name - local screen=${1-} plain + local screen=${1-} [ -n "$screen" ] || return 1 - plain=$(printf '%s\n' "$screen" | fm_composer_strip_ansi) - if printf '%s\n' "$plain" | grep -F -q 'Background work is running' \ - && printf '%s\n' "$plain" | grep -F -q 'Exit and stop tasks' \ - && printf '%s\n' "$plain" | grep -F -q 'Enter to confirm'; then + if printf '%s\n' "$screen" | fm_composer_strip_ansi | LC_ALL=C awk ' + index($0, "Background work is running") { heading = 1 } + heading && /^[ \t]*❯ 1\. Exit and stop tasks[ \t\r]*$/ { selected = 1 } + /[^ \t\r]/ { last = $0 } + END { exit !(selected && last ~ /^[ \t]*Enter to confirm/) } + '; then printf '%s' 'Claude background-task exit picker' return 0 fi @@ -1717,7 +1721,7 @@ fm_composer_blocking_dialog_noted() { # Empty the sink, creating it when the caller has not. Sets # FM_COMPOSER_DIALOG_OWNED=1 only for a sink this call created, so a caller -# that shares the path can still read the name after this function returns. +# that shares the path can still read the name after the release. fm_composer_dialog_sink_prepare() { FM_COMPOSER_DIALOG_OWNED=0 if [ -z "${FM_COMPOSER_DIALOG_SINK:-}" ]; then @@ -1729,7 +1733,6 @@ fm_composer_dialog_sink_prepare() { } fm_composer_dialog_sink_release() { - trap - RETURN if [ "${FM_COMPOSER_DIALOG_OWNED:-}" = 1 ]; then rm -f "$FM_COMPOSER_DIALOG_SINK" FM_COMPOSER_DIALOG_SINK= @@ -1859,15 +1862,11 @@ EOF # fm_composer_queued_enter_verdict; no shape knowledge lives in any loop. fm_composer_submit_retry_core() { # [expected-label] local send_key_fn=$1 state_fn=$2 target=$3 retries=$4 sleep_s=$5 expected_label=${6:-} i=0 state - # The first Enter can open a picker. A later Enter would confirm it. - fm_composer_dialog_sink_prepare || { printf 'unknown'; return 0; } - if [ "$FM_COMPOSER_DIALOG_OWNED" = 1 ]; then - trap 'fm_composer_dialog_sink_release' RETURN - fi while :; do "$send_key_fn" "$target" Enter "$expected_label" || true sleep "$sleep_s" state=$("$state_fn" "$target" "$expected_label") + # The first Enter can open a picker. A later Enter would confirm it. if fm_composer_blocking_dialog_noted >/dev/null; then printf 'unknown' return 0 diff --git a/bin/fm-control.sh b/bin/fm-control.sh index 85023d88d11..4d2d00d8b7a 100755 --- a/bin/fm-control.sh +++ b/bin/fm-control.sh @@ -674,6 +674,14 @@ do_exit() { fi fi state=$(wait_agent_state "$EXIT_WAIT" dead) || { + # A submit can return before any read sees the picker: a native busy + # verdict needs no composer read, and a cleared composer can be read + # before the picker renders. Read the screen once more here. + : > "$FM_COMPOSER_DIALOG_SINK" || true + fm_backend_composer_state "$BACKEND" "$T" "$LABEL" >/dev/null 2>&1 || true + if [ -s "$FM_COMPOSER_DIALOG_SINK" ]; then + refuse_blocking_prompt "$(cat "$FM_COMPOSER_DIALOG_SINK")" + fi die "exit-delivered $ID interrupt=$interrupt_result exit-command=delivered agent-state=$state exit=unconfirmed; the agent did not stop within ${EXIT_WAIT}s" } # The incarnation is over: retire its busy wiring so no stale record or diff --git a/bin/fm-test-run.sh b/bin/fm-test-run.sh index 0676f6dc517..61c754d418d 100755 --- a/bin/fm-test-run.sh +++ b/bin/fm-test-run.sh @@ -1621,9 +1621,11 @@ families_for_changed_path() { bin/fm-composer-lib.sh) # The shared shape catalogue is vendor-rendered signal; a change to it # re-selects the live guard (fm-composer-matrix-live-e2e) alongside the - # portable families. + # portable families. The watcher reads the dialog matcher for its + # stale wake (watcher-wake-lock). printf '%s\n' backend-dispatch printf '%s\n' pure-contract-unit + printf '%s\n' watcher-wake-lock printf '%s\n' live-harness-optin ;; bin/fm-spawn.sh|bin/fm-send.sh|bin/fm-harness.sh|\ diff --git a/bin/fm-tmux-lib.sh b/bin/fm-tmux-lib.sh index 4694eff6c4f..f7cb21f2c84 100755 --- a/bin/fm-tmux-lib.sh +++ b/bin/fm-tmux-lib.sh @@ -246,15 +246,11 @@ fm_pane_is_busy() { # [harness] # transition evidence could mark an undelivered message delivered. fm_tmux_submit_enter_core() { # [baseline-idle] local target=$1 retries=$2 sleep_s=$3 baseline_idle=${4:-} i=0 j state busy_state - # The first Enter can open a picker. A later Enter would confirm it. - fm_composer_dialog_sink_prepare || { printf 'unknown'; return 0; } - if [ "$FM_COMPOSER_DIALOG_OWNED" = 1 ]; then - trap 'fm_composer_dialog_sink_release' RETURN - fi while :; do tmux send-keys -t "$target" Enter 2>/dev/null || true sleep "$sleep_s" state=$(fm_tmux_composer_state "$target") + # The first Enter can open a picker. A later Enter would confirm it. if fm_composer_blocking_dialog_noted >/dev/null; then printf 'unknown' return 0 diff --git a/bin/fm-watch.sh b/bin/fm-watch.sh index 0cad45879d9..cb828117212 100755 --- a/bin/fm-watch.sh +++ b/bin/fm-watch.sh @@ -3010,17 +3010,19 @@ EOF if ! status_is_paused_or_captain_held "$last" && [ -e "$STATE/.paused-$key" ]; then clear_pause_tracking "$key" fi + tail40=$(fm_backend_capture "$(window_backend "$w")" "$w" 40 "$(window_label "$w")" 2>/dev/null) || continue + dialog=$(fm_composer_blocking_dialog "$tail40") || dialog= # An idle secondmate endpoint is healthy by design, so a mate is admitted to # the pane-stale path ONLY to serve a status-declared wait's bounded - # re-surface. This gate reads the shared predicate rather than the pause verb + # re-surface, or the wake for a recognised dialog on a pane that is not + # busy. This gate reads the shared predicate rather than the pause verb # alone so it includes a declared `captain-held` status. A hold recorded only # in the backlog while the mate still says `working:` or `done:` is outside # this guard: reaching it would require backlog reads for windows this gate # deliberately skips, putting that read on the ordinary poll hot path. if [ "$kind" = secondmate ] && ! status_is_paused_or_captain_held "$last"; then - continue + { [ -n "$dialog" ] && ! window_is_busy "$w" "$tail40"; } || continue fi - tail40=$(fm_backend_capture "$(window_backend "$w")" "$w" 40 "$(window_label "$w")" 2>/dev/null) || continue h=$(printf '%s' "$tail40" | hash_pane) hf="$STATE/.hash-$key" cf="$STATE/.count-$key" @@ -3041,20 +3043,19 @@ EOF if [ "$n" -ge 2 ] && [ "$busy_now" -ne 0 ]; then # The pane is idle/stale at hash $h. Triage decides whether this wakes # firstmate. Detection itself is unchanged from above. - if [ "$kind" = secondmate ]; then - case "$(pause_state_class "$w" "$task")" in - paused) handle_paused_stale "$w" "$task" "$h" ;; - *) clear_pause_tracking "$key" ;; - esac - elif dialog=$(fm_composer_blocking_dialog "$tail40"); then - # A non-paused secondmate never reaches this branch. The idle - # exemption above still skips that pane, so a secondmate parked - # on this picker stays quiet. + if [ -n "$dialog" ] && [ "$(fm_backend_agent_alive "$(window_backend "$w")" "$w" 2>/dev/null || true)" != dead ]; then + # A stopped agent can leave the picker text on its pane. That is + # not a prompt still waiting, so it keeps the ordinary triage below. if [ "$(cat "$sf" 2>/dev/null || true)" != "$h" ]; then fm_wake_append stale "$w" "stale: $w (blocked on a prompt: $dialog)" || exit 1 printf '%s' "$h" > "$sf" wake "stale: $w (blocked on a prompt: $dialog)" fi + elif [ "$kind" = secondmate ]; then + case "$(pause_state_class "$w" "$task")" in + paused) handle_paused_stale "$w" "$task" "$h" ;; + *) clear_pause_tracking "$key" ;; + esac elif afk_present; then # Daemon owns triage: one-shot per distinct stale hash, as before, # except that a captain-held pane is never handed over while the diff --git a/docs/verification/runtime-backends.md b/docs/verification/runtime-backends.md index 32d1fea693e..560b9cb7138 100644 --- a/docs/verification/runtime-backends.md +++ b/docs/verification/runtime-backends.md @@ -1271,12 +1271,17 @@ The Herdr lab was not running, so the Herdr path is covered by the existing fake Typing `/exit` while a background shell is still running opens a picker whose selected row is "Exit and stop tasks" and whose footer is "Enter to confirm · Esc to cancel". That screen still classifies as pending, the same verdict as unsubmitted composer text. A second Enter would confirm the selected row. +The picker is recognised by its recorded structure only: the heading, then the selected row alone on its row, with the footer as the last non-blank row. +The same strings quoted above a normal composer, as a diff, this note, or a test fixture shows them, are not a picker. Submit retries now stop after the Enter that opened the picker and report unknown. Exit reports that the worker is blocked on the Claude background-task exit picker and does not type another Enter. +A submit can return before any read sees the picker, so exit reads the screen once more when its wait for the agent to stop times out, and names the picker there too. The watcher raises the existing stale wake once per unchanged screen, with that dialog name in the reason. -A non-paused secondmate still skips pane staleness, so a secondmate parked on this picker still looks idle. -That exemption is unchanged. +A secondmate parked on this picker raises the same wake. +Every other non-paused secondmate still skips pane staleness. +A stopped agent whose pane still shows the picker text is not reported as blocked on a prompt. No recorded screen was available for a model-downgrade confirmation, an MCP approval, or a Claude exit confirmation other than this picker, so those dialogs are not covered. +Refusing an Enter that would confirm a dialog restores an existing safety path, so it is not gated behind a flag. ### Prune and respawn diff --git a/tests/fm-backend-herdr.test.sh b/tests/fm-backend-herdr.test.sh index e46e4645e18..acfe4614389 100755 --- a/tests/fm-backend-herdr.test.sh +++ b/tests/fm-backend-herdr.test.sh @@ -4433,7 +4433,7 @@ test_send_text_submit_popup_autocomplete_requires_second_enter() { test_send_text_submit_refuses_confirming_enter_on_exit_picker() { local dir log resp fb out enter_count - dir="$TMP_ROOT/submit-exit-picker"; mkdir -p "$dir/responses"; log="$dir/log"; resp="$dir/responses"; : > "$log" + dir="$TMP_ROOT/submit-exit-picker"; mkdir -p "$dir/responses" "$dir/tmp"; log="$dir/log"; resp="$dir/responses"; : > "$log" herdr_submit_claude_prefix "$resp" "/exit" printf '{"result":{"agent":{"agent":"claude","agent_status":"idle"}}}\n' > "$resp/5.out" printf '{"result":{"agent":{"agent":"claude","agent_status":"idle"}}}\n' > "$resp/7.out" @@ -4447,13 +4447,49 @@ test_send_text_submit_refuses_confirming_enter_on_exit_picker() { 'Enter to confirm · Esc to cancel' > "$resp/8.out" fb=$(make_herdr_fakebin "$dir") out=$( PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" FM_BACKEND_HERDR_SUBMIT_POLLS=1 \ - bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_send_text_submit default:w1:p2 "/exit" 3 0.01 0.01' "$ROOT" ) + TMPDIR="$dir/tmp" \ + bash -c '. "$0/bin/fm-backend.sh"; fm_backend_send_text_submit herdr default:w1:p2 "/exit" 3 0.01 0.01' "$ROOT" ) enter_count=$(grep -c $'\x1f''pane'$'\x1f''send-keys'$'\x1f''w1:p2'$'\x1f''enter' "$log" || true) [ "$out" = unknown ] || fail "the exit picker should stop the retry as unknown, got '$out'; log: $(cat "$log")" [ "$enter_count" -eq 1 ] || fail "the exit picker should get one Enter, got $enter_count; log: $(cat "$log")" + [ -z "$(ls -A "$dir/tmp")" ] || fail "the submit left its dialog record behind: $(ls -A "$dir/tmp")" pass "fm_backend_herdr_send_text_submit: the Claude background-task exit picker gets no confirming Enter" } +# Herdr can report `blocked` for a picker the submitting Enter opened. The +# submit then reports delivery with no composer read, so the picker is named +# only by the caller's next composer read, the one fm-control exit takes when +# its wait for the agent to stop times out. +test_blocked_submit_leaves_the_exit_picker_to_the_next_composer_read() { + local dir log resp fb out enter_count + dir="$TMP_ROOT/submit-blocked-picker"; mkdir -p "$dir/responses"; log="$dir/log"; resp="$dir/responses"; : > "$log" + herdr_submit_claude_prefix "$resp" "/exit" + printf '{"result":{"agent":{"agent":"claude","agent_status":"idle"}}}\n' > "$resp/5.out" + printf '{"result":{"agent":{"agent":"claude","agent_status":"blocked"}}}\n' > "$resp/7.out" + printf '%s\n' \ + 'Background work is running' \ + '❯ 1. Exit and stop tasks' \ + 'The following will stop when you exit:' \ + 'shell · sleep 300' \ + ' 2. Move to background and exit' \ + ' 3. Stay' \ + 'Enter to confirm · Esc to cancel' > "$resp/8.out" + fb=$(make_herdr_fakebin "$dir") + out=$( PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" FM_BACKEND_HERDR_SUBMIT_POLLS=1 \ + FM_COMPOSER_DIALOG_SINK="$dir/sink" \ + bash -c '. "$0/bin/fm-backend.sh" + verdict=$(fm_backend_send_text_submit herdr default:w1:p2 "/exit" 3 0.01 0.01) + printf "%s|%s|" "$verdict" "$(cat "$FM_COMPOSER_DIALOG_SINK")" + fm_backend_composer_state herdr default:w1:p2 >/dev/null + cat "$FM_COMPOSER_DIALOG_SINK"' "$ROOT" ) + enter_count=$(grep -c $'\x1f''pane'$'\x1f''send-keys'$'\x1f''w1:p2'$'\x1f''enter' "$log" || true) + [ "$out" = 'empty||Claude background-task exit picker' ] \ + || fail "a blocked submit should report delivery unnamed and the next composer read should name the picker, got '$out'; log: $(cat "$log")" + [ "$enter_count" -eq 1 ] || fail "a blocked submit should send one Enter, got $enter_count; log: $(cat "$log")" + [ -f "$dir/sink" ] || fail "a submit must not remove a dialog record its caller owns" + pass "fm_backend_send_text_submit (herdr): a picker behind a blocked verdict is named by the caller's next composer read" +} + test_send_text_submit_confirms_blocked_after_enter() { local dir log resp fb out enter_count dir="$TMP_ROOT/submit-blocked"; mkdir -p "$dir/responses"; log="$dir/log"; resp="$dir/responses"; : > "$log" @@ -5978,6 +6014,7 @@ test_send_text_submit_detects_swallowed_enter test_send_text_submit_replays_literal_send_stderr test_send_text_submit_popup_autocomplete_requires_second_enter test_send_text_submit_refuses_confirming_enter_on_exit_picker +test_blocked_submit_leaves_the_exit_picker_to_the_next_composer_read test_send_text_submit_confirms_blocked_after_enter test_send_text_submit_preexisting_working_pending_is_queued_enter test_send_text_submit_preexisting_working_does_not_confirm_failed_enter diff --git a/tests/fm-composer-lib.test.sh b/tests/fm-composer-lib.test.sh index 14e104a57e9..19b9b40886e 100755 --- a/tests/fm-composer-lib.test.sh +++ b/tests/fm-composer-lib.test.sh @@ -1113,6 +1113,8 @@ test_background_exit_picker_stays_pending_and_blocks_retry() { [ -z "$out" ] || fail "a miss must print nothing, got '$out'" out=$(fm_composer_blocking_dialog "$(printf '%s\n' 'Background work is running' 'Exit and stop tasks')"); rc=$? [ "$rc" -eq 1 ] || fail "two of the three strings must not match" + out=$(fm_composer_blocking_dialog "$(printf '%s\n' "$screen" '' '')"); rc=$? + [ "$rc" -eq 0 ] || fail "blank rows below the footer should still match" sink=$(mktemp) FM_COMPOSER_DIALOG_SINK=$sink out=$(fm_composer_classify_screen 'styled=1' "$screen" 1) @@ -1125,7 +1127,12 @@ test_background_exit_picker_stays_pending_and_blocks_retry() { FM_TEST_PICKER_SCREEN=$screen FM_TEST_PICKER_ENTERS=$(mktemp) : > "$FM_TEST_PICKER_ENTERS" + fm_composer_dialog_sink_prepare || fail "the dialog sink could not be prepared" + sink=$FM_COMPOSER_DIALOG_SINK out=$(fm_composer_submit_retry_core fm_test_picker_send fm_test_picker_state win 3 0) + fm_composer_dialog_sink_release + [ ! -e "$sink" ] || fail "the release should remove a sink that prepare created" + [ -z "${FM_COMPOSER_DIALOG_SINK:-}" ] || fail "the release should unset a sink that prepare created" enters=$(grep -c '^Enter$' "$FM_TEST_PICKER_ENTERS" || true) [ "$out" = unknown ] || fail "a picker must stop the retry as unknown, got '$out'" [ "$enters" -eq 1 ] || fail "a picker must receive one Enter, got $enters" @@ -1134,4 +1141,56 @@ test_background_exit_picker_stays_pending_and_blocks_retry() { pass "the Claude background-task exit picker stays pending and receives no confirming Enter" } +# The picker's own text, shown the way a worker pane shows it when it prints +# this repository's diff, verification note, or a test fixture: quoted above a +# normal composer. No picker is open, so the next Enter confirms nothing. +quoted_exit_picker_screen() { + printf '%s\n' \ + '● Here is the fixture the test uses:' \ + "+ 'Background work is running' \\" \ + "+ '❯ 1. Exit and stop tasks' \\" \ + "+ 'Enter to confirm · Esc to cancel'" \ + ' The selected row is "❯ 1. Exit and stop tasks" and the footer is "Enter to confirm · Esc to cancel".' \ + 'Background work is running' \ + '❯ 1. Exit and stop tasks' \ + 'Enter to confirm · Esc to cancel' \ + '' \ + '╭──────────────╮' \ + '│ > next steer │' \ + '╰──────────────╯' +} + +test_quoted_exit_picker_text_is_not_a_dialog() { + local screen out rc sink enters + screen=$(quoted_exit_picker_screen) + out=$(fm_composer_blocking_dialog "$screen"); rc=$? + [ "$rc" -eq 1 ] || fail "picker text quoted above a normal composer must not match" + [ -z "$out" ] || fail "a miss must print nothing, got '$out'" + out=$(fm_composer_blocking_dialog "$(printf '%s\n' \ + 'Background work is running' \ + "+ '❯ 1. Exit and stop tasks' \\" \ + 'Enter to confirm · Esc to cancel')"); rc=$? + [ "$rc" -eq 1 ] || fail "a selected row that is not alone on its row must not match" + out=$(fm_composer_blocking_dialog "$(printf '%s\n' \ + '❯ 1. Exit and stop tasks' \ + 'Background work is running' \ + 'Enter to confirm · Esc to cancel')"); rc=$? + [ "$rc" -eq 1 ] || fail "a selected row above the heading must not match" + FM_TEST_PICKER_SCREEN=$screen + FM_TEST_PICKER_ENTERS=$(mktemp) + : > "$FM_TEST_PICKER_ENTERS" + fm_composer_dialog_sink_prepare || fail "the dialog sink could not be prepared" + sink=$FM_COMPOSER_DIALOG_SINK + out=$(fm_composer_submit_retry_core fm_test_picker_send fm_test_picker_state win 3 0) + [ ! -s "$sink" ] || fail "quoted picker text must not be noted as a dialog, got '$(cat "$sink")'" + fm_composer_dialog_sink_release + enters=$(grep -c '^Enter$' "$FM_TEST_PICKER_ENTERS" || true) + [ "$out" = pending ] || fail "quoted picker text must keep the ordinary pending verdict, got '$out'" + [ "$enters" -eq 3 ] || fail "quoted picker text must keep the ordinary Enter retries, got $enters" + rm -f "$FM_TEST_PICKER_ENTERS" + unset FM_TEST_PICKER_SCREEN FM_TEST_PICKER_ENTERS + pass "picker text quoted above a normal composer is not read as a live picker" +} + test_background_exit_picker_stays_pending_and_blocks_retry +test_quoted_exit_picker_text_is_not_a_dialog diff --git a/tests/fm-control.test.sh b/tests/fm-control.test.sh index e9cb0184714..f34fb8acb91 100755 --- a/tests/fm-control.test.sh +++ b/tests/fm-control.test.sh @@ -176,8 +176,16 @@ case "${1:-}" in printf 'fakepane\n'; exit 0 ;; capture-pane) if [ -f "$D/after-enter" ] && [ -f "$D/keys" ] && grep -qx Enter "$D/keys"; then - cat "$D/after-enter" - exit 0 + # after-enter-late holds how many captures after Enter still show the + # ordinary pane, for a screen that renders after the submit has read it. + late=0 + [ ! -f "$D/after-enter-late" ] || late=$(cat "$D/after-enter-late") + if [ "$late" -gt 0 ]; then + printf '%s' "$((late - 1))" > "$D/after-enter-late" + else + cat "$D/after-enter" + exit 0 + fi fi if [ -f "$D/devin" ]; then devin_screen "$(cat "$D/devin")"; elif [ -f "$D/pane" ]; then cat "$D/pane"; else printf '╭────╮\n│ │\n╰────╯\n'; fi exit 0 ;; @@ -888,6 +896,31 @@ test_exit_refuses_the_confirming_enter() { pass "fm-control exit: the Enter that opens the background-task picker is not followed by a confirming Enter" } +# The submit reads a cleared composer before the picker renders, so it reports +# delivery and no read inside it sees the picker. Exit's own read after the +# stop wait times out must still name the dialog. +test_exit_names_a_picker_that_renders_after_the_submit() { + local dir out rc enters + dir=$(new_case late-picker) + add_task "$dir" t1 claude + alive_as "$dir" claude + exit_picker_screen > "$dir/fake/after-enter" + printf '1' > "$dir/fake/after-enter-late" + out=$(env FM_FAKE_NEVER_DIES=1 PATH="$dir/fakebin:$PATH" FM_HOME="$dir/home" \ + FM_FAKE_DIR="$dir/fake" FM_CONTROL_POLL=0.01 FM_CONTROL_EXIT_WAIT=0.05 \ + "$CONTROL" t1 exit 2>&1); rc=$? + expect_code 1 "$rc" "a picker that renders after the submit should refuse"$'\n'"$out" + [ "$(cat "$dir/fake/after-enter-late")" = 0 ] \ + || fail "the submit should have read the ordinary pane once after Enter" + assert_contains "$out" "blocked on a prompt: Claude background-task exit picker" \ + "the refusal should name the dialog" + assert_not_contains "$out" "did not stop within" \ + "a recognised picker must not fall back to the generic timeout message" + enters=$(grep -c '^Enter$' "$dir/fake/keys" || true) + [ "$enters" -eq 1 ] || fail "only the submitting Enter should be sent, got $enters" + pass "fm-control exit: a picker that renders after the submit returned is named when the stop wait times out" +} + test_idle_agent_is_not_interrupted() { local dir out rc gen dir=$(new_case idle) @@ -1152,6 +1185,7 @@ test_busy_agent_is_interrupted_before_the_exit_command test_idle_agent_is_not_interrupted test_exit_refuses_an_open_background_picker test_exit_refuses_the_confirming_enter +test_exit_names_a_picker_that_renders_after_the_submit test_interrupt_without_acknowledgement_preserves_busy_state test_muse_interrupt_confirms_adapter_acknowledgement test_interrupt_revalidates_agent_after_acknowledgement_wait diff --git a/tests/fm-test-run.test.sh b/tests/fm-test-run.test.sh index 5f70ea8a557..13f93bd1132 100755 --- a/tests/fm-test-run.test.sh +++ b/tests/fm-test-run.test.sh @@ -120,6 +120,7 @@ init_changed_fixture_repo() { fm-backend-cmux.test.sh \ fm-backend-zellij.test.sh \ fm-control-herdr-smoke.test.sh \ + fm-watch-triage.test.sh \ fm-backend-orca.test.sh; do printf '#!/usr/bin/env bash\n# tests/lib.sh\n' >"$repo/tests/$script" chmod +x "$repo/tests/$script" @@ -128,6 +129,7 @@ init_changed_fixture_repo() { : >"$repo/tests/fm-backend-herdr-eventwait.test.py" : >"$repo/bin/fm-supervisor-target-lib.sh" : >"$repo/bin/fm-control-lib.sh" + : >"$repo/bin/fm-composer-lib.sh" : >"$repo/bin/fm-timeout-lib.sh" : >"$repo/bin/fm-procevent-quota.sh" : >"$repo/bin/fm-quota-axi-lib.sh" @@ -405,6 +407,15 @@ test_changed_dependency_selection_and_unmapped_failure() { git -C "$repo" add bin/fm-control-lib.sh git -C "$repo" -c user.name=test -c user.email=test@example.invalid commit -qm control-lib-change + printf '\n' >>"$repo/bin/fm-composer-lib.sh" + listed=$(cd "$repo" && bin/fm-test-run.sh --list --changed --base HEAD) + assert_contains "$listed" "tests/fm-backend.test.sh" \ + "composer library keeps backend coverage" + assert_contains "$listed" "tests/fm-watch-triage.test.sh" \ + "composer library selects the watcher's dialog wake coverage" + git -C "$repo" add bin/fm-composer-lib.sh + git -C "$repo" -c user.name=test -c user.email=test@example.invalid commit -qm composer-lib-change + printf '\n' >>"$repo/bin/fm-timeout-lib.sh" listed=$(cd "$repo" && bin/fm-test-run.sh --list --changed --base HEAD) assert_contains "$listed" "tests/fm-procevent-quota.test.sh" \ diff --git a/tests/fm-tmux-submit-busy.test.sh b/tests/fm-tmux-submit-busy.test.sh index 6fbee4d612f..316ce9b3aae 100755 --- a/tests/fm-tmux-submit-busy.test.sh +++ b/tests/fm-tmux-submit-busy.test.sh @@ -362,9 +362,11 @@ test_exit_picker_refuses_confirming_enter() { 'Enter to confirm · Esc to cancel' > "$composer" : > "$sent" touch "$dir/.swallow" + fm_composer_dialog_sink_prepare || fail "the dialog sink could not be prepared" PATH="$fakebin:$PATH" FM_FAKE_COMPOSER="$composer" FM_FAKE_SENT="$sent" \ FM_FAKE_SWALLOW="$dir/.swallow" FM_FAKE_PERSIST_SWALLOW=1 FM_FAKE_PANE_BUSY=0 \ fm_tmux_submit_enter_core "win" 3 0 > "$vfile" 2>/dev/null + fm_composer_dialog_sink_release [ "$(cat "$vfile")" = unknown ] || fail "exit picker should return unknown, got '$(cat "$vfile")'" enters=$(grep -c '^Enter$' "$sent" || true) [ "$enters" -eq 1 ] || fail "exit picker should get one Enter, got $enters" diff --git a/tests/fm-watch-triage.test.sh b/tests/fm-watch-triage.test.sh index 5e8b1add67e..fac7c9bcf72 100755 --- a/tests/fm-watch-triage.test.sh +++ b/tests/fm-watch-triage.test.sh @@ -4321,7 +4321,7 @@ test_exit_picker_stale_names_the_dialog() { pass "an ordinary worker parked on the background-task exit picker raises a stale wake that names it" } -test_secondmate_exit_picker_stays_quiet() { +test_secondmate_exit_picker_names_the_dialog() { local dir state fakebin out capture_file statusf window key sig pid dir=$(make_case secondmate-exit-picker); state="$dir/state"; fakebin="$dir/fakebin" out="$dir/watch.out"; capture_file="$dir/pane.txt"; statusf="$state/secondmate-picker.status" @@ -4337,18 +4337,65 @@ test_secondmate_exit_picker_stays_quiet() { FM_FAKE_TMUX_CURRENT_COMMAND=claude \ watch_bg "$state" "$fakebin" "$out" pid=$! + wait_for_exit "$pid" 100 || { reap "$pid"; fail "a secondmate on the exit picker did not wake: $(cat "$out")"; } + grep -Fx "stale: $window (blocked on a prompt: Claude background-task exit picker)" "$out" >/dev/null \ + || fail "the secondmate exit picker wake did not name the dialog: $(cat "$out")" + grep -F "stale: $window (blocked on a prompt: Claude background-task exit picker)" "$state/.wake-queue" >/dev/null \ + || fail "the secondmate exit picker wake was not queued" + pass "a non-paused secondmate parked on the background-task exit picker raises a stale wake that names it" +} + +# The idle exemption still covers a mate whose pane only quotes the picker: +# the text sits above a normal composer, so no prompt is waiting. +test_secondmate_quoted_exit_picker_stays_quiet() { + local dir state fakebin out capture_file statusf window key sig pid + dir=$(make_case secondmate-quoted-picker); state="$dir/state"; fakebin="$dir/fakebin" + out="$dir/watch.out"; capture_file="$dir/pane.txt"; statusf="$state/secondmate-quoted.status" + window="test:fm-secondmate-quoted" + { exit_picker_capture; printf '%s\n' '' '╭──────╮' '│ > │' '╰──────╯'; } > "$capture_file" + printf 'window=%s\nkind=secondmate\nharness=claude\nbackend=tmux\n' "$window" > "$state/secondmate-quoted.meta" + printf 'working: the parent supervises this secondmate\n' > "$statusf" + sig=$(seen_sig "$statusf"); printf '%s' "$sig" > "$state/.seen-secondmate-quoted_status" + key=$(printf '%s' "$window" | tr ':/.' '___') + printf '%s' "$(hash_text "$(cat "$capture_file")")" > "$state/.hash-$key" + printf '1\n' > "$state/.count-$key" + PATH="$fakebin:$PATH" FM_FAKE_TMUX_WINDOW="$window" FM_FAKE_TMUX_CAPTURE="$capture_file" \ + FM_FAKE_TMUX_CURRENT_COMMAND=claude \ + watch_bg "$state" "$fakebin" "$out" + pid=$! if ! wait_poll_cycle "$state" "$pid"; then - reap "$pid"; fail "watcher left a secondmate parked on the exit picker: $(cat "$out")" + reap "$pid"; fail "watcher surfaced a secondmate whose pane only quotes the exit picker: $(cat "$out")" fi + [ ! -s "$out" ] || { reap "$pid"; fail "quoted exit picker text printed a wake reason: $(cat "$out")"; } + reap "$pid" + pass "a non-paused secondmate whose pane only quotes the exit picker stays quiet" +} + +# A stopped agent can leave the picker text on its pane. Nothing is waiting +# for an answer there, so the wake keeps its ordinary stale reason. +test_stopped_worker_exit_picker_text_is_not_a_prompt() { + local dir state fakebin out capture_file statusf window key sig pid + dir=$(make_case exit-picker-stopped); state="$dir/state"; fakebin="$dir/fakebin" + out="$dir/watch.out"; capture_file="$dir/pane.txt"; statusf="$state/exit-picker-stopped.status" + window="test:fm-exit-picker-stopped" + exit_picker_capture > "$capture_file" + printf 'window=%s\nkind=ship\nharness=claude\nbackend=tmux\n' "$window" > "$state/exit-picker-stopped.meta" + printf 'working: implementing\n' > "$statusf" + sig=$(seen_sig "$statusf"); printf '%s' "$sig" > "$state/.seen-exit-picker-stopped_status" + key=$(printf '%s' "$window" | tr ':/.' '___') + printf '%s' "$(hash_text "$(cat "$capture_file")")" > "$state/.hash-$key" + printf '1\n' > "$state/.count-$key" + PATH="$fakebin:$PATH" FM_FAKE_TMUX_WINDOW="$window" FM_FAKE_TMUX_CAPTURE="$capture_file" \ + FM_FAKE_TMUX_CURRENT_COMMAND=zsh FM_FAKE_CREW_STATE='state: stopped · source: pane · bare shell' \ + watch_bg "$state" "$fakebin" "$out" + pid=$! + wait_for_exit "$pid" 100 || { reap "$pid"; fail "a stopped worker's stale pane did not wake: $(cat "$out")"; } + grep -F "stale: $window" "$out" >/dev/null \ + || fail "a stopped worker's stale pane lost its ordinary wake: $(cat "$out")" if grep -F 'blocked on a prompt' "$out" >/dev/null; then - reap "$pid" - fail "a secondmate exit picker was named: $(cat "$out")" - fi - if [ -f "$state/.wake-queue" ] && grep -F 'blocked on a prompt' "$state/.wake-queue" >/dev/null; then - fail "a secondmate exit picker was queued" + fail "a stopped worker was reported as blocked on a prompt: $(cat "$out")" fi - reap "$pid" - pass "a non-paused secondmate parked on the exit picker stays quiet" + pass "a stopped worker whose pane still shows the exit picker text is not reported as blocked on a prompt" } test_secondmate_unpause_clears_pause_tracking() { @@ -6783,7 +6830,9 @@ test_secondmate_paused_resurfaces_in_normal_mode test_secondmate_captain_held_resurfaces_in_normal_mode test_secondmate_nonpaused_stale_remains_suppressed test_exit_picker_stale_names_the_dialog -test_secondmate_exit_picker_stays_quiet +test_secondmate_exit_picker_names_the_dialog +test_secondmate_quoted_exit_picker_stays_quiet +test_stopped_worker_exit_picker_text_is_not_a_prompt test_secondmate_unpause_clears_pause_tracking test_nonterminal_stale_pause_transitions_reclassify_unchanged_hash test_nonterminal_paused_rechecks_authoritative_state From e2e1fa1b467bfca305c950ac11820d6121ba723e Mon Sep 17 00:00:00 2001 From: Tiago Peixoto Date: Tue, 6 Oct 2026 00:17:37 +0200 Subject: [PATCH 03/10] fix: refuse a typed submit while the Claude exit picker is open A pane that already shows the picker must not receive the message or a confirming Enter. The match requires the recorded heading and footer lines, and it is skipped when no dialog name is being recorded. --- bin/fm-backend.sh | 19 ++++++++++++- bin/fm-composer-lib.sh | 26 ++++++++--------- docs/verification/runtime-backends.md | 3 +- tests/fm-composer-lib.test.sh | 41 +++++++++++++++++++++++++++ tests/fm-tmux-submit-busy.test.sh | 40 +++++++++++++++++++++++++- 5 files changed, 113 insertions(+), 16 deletions(-) diff --git a/bin/fm-backend.sh b/bin/fm-backend.sh index 64b6ea49909..916e3d2e353 100644 --- a/bin/fm-backend.sh +++ b/bin/fm-backend.sh @@ -811,9 +811,13 @@ fm_backend_send_key() { # [expected-label] # fm_backend_send_text_submit: type text once, then submit and verify, # retrying only the submission (never retyping). Echoes the backend's # proof-carrying verdict; callers require exact empty for confirmed delivery. +# A pane that already shows the recognised dialog is refused before any +# adapter types, so that submit neither types the text nor sends Enter. fm_backend_send_text_submit() { # [expected-label] - local backend=$1 rc=0 + local backend=$1 rc=0 target label dialog shift + target=$1 + label=${6:-} fm_backend_source "$backend" || return 1 # Every Enter loop below reads the dialog sink, so it must exist before # any adapter types: a sink that fails here leaves the composer untouched. @@ -821,6 +825,19 @@ fm_backend_send_text_submit() { # &2 return 1 } + # One composer read after the sink exists and before the adapter types. + # The classify writes the sink; a named dialog means the next Enter would + # answer it. + if [ -n "$label" ]; then + fm_backend_composer_state "$backend" "$target" "$label" >/dev/null || true + else + fm_backend_composer_state "$backend" "$target" >/dev/null || true + fi + if dialog=$(fm_composer_blocking_dialog_noted); then + fm_composer_dialog_sink_release + echo "error: blocked on a prompt: $dialog" >&2 + return 1 + fi case "$backend" in tmux) fm_backend_tmux_send_text_submit "$@" || rc=$? ;; herdr) fm_backend_herdr_send_text_submit "$@" || rc=$? ;; diff --git a/bin/fm-composer-lib.sh b/bin/fm-composer-lib.sh index fd7cae26003..1e2cb3b8371 100644 --- a/bin/fm-composer-lib.sh +++ b/bin/fm-composer-lib.sh @@ -1673,20 +1673,22 @@ EOF # fm_composer_blocking_dialog: name a screen whose next Enter would answer it. # Prints the name and returns 0 only for the recorded structure of one dialog: -# its heading, then its selected row alone on a row, with its footer as the -# last non-blank row. The strings alone are not enough, because a diff, a -# note, or a test fixture on the pane can quote all of them above a normal -# composer. A miss returns 1 and prints nothing. +# the heading on its own line, then its selected row alone on a row, with the +# recorded footer as the last non-blank row. A heading buried in a sentence, +# or a last line that only starts with the same words, is not that dialog. +# The strings alone are not enough, because a diff, a note, or a test fixture +# on the pane can quote all of them above a normal composer. A miss returns 1 +# and prints nothing. # Recorded 2026-10-05 on Claude Code 2.1.289: /exit while a background shell # is still running opens this picker, and its selected row is Exit and stop tasks. fm_composer_blocking_dialog() { # -> dialog name local screen=${1-} [ -n "$screen" ] || return 1 if printf '%s\n' "$screen" | fm_composer_strip_ansi | LC_ALL=C awk ' - index($0, "Background work is running") { heading = 1 } + /^[ \t]*Background work is running[ \t\r]*$/ { heading = 1 } heading && /^[ \t]*❯ 1\. Exit and stop tasks[ \t\r]*$/ { selected = 1 } /[^ \t\r]/ { last = $0 } - END { exit !(selected && last ~ /^[ \t]*Enter to confirm/) } + END { exit !(selected && last ~ /^[ \t]*Enter to confirm · Esc to cancel[ \t\r]*$/) } '; then printf '%s' 'Claude background-task exit picker' return 0 @@ -1696,18 +1698,16 @@ fm_composer_blocking_dialog() { # -> dialog name # A command substitution drops a shell variable, and every composer read runs # inside one. The name is therefore written to FM_COMPOSER_DIALOG_SINK when -# that path is set. The classifier verdict is unchanged. +# that path is set. The classifier verdict is unchanged. When the sink is +# unset the name would be discarded, so the match is skipped. fm_composer_note_blocking_dialog() { # local name= + [ -n "${FM_COMPOSER_DIALOG_SINK:-}" ] || return 1 if name=$(fm_composer_blocking_dialog "$1"); then - if [ -n "${FM_COMPOSER_DIALOG_SINK:-}" ]; then - printf '%s' "$name" > "$FM_COMPOSER_DIALOG_SINK" || return 1 - fi + printf '%s' "$name" > "$FM_COMPOSER_DIALOG_SINK" || return 1 return 0 fi - if [ -n "${FM_COMPOSER_DIALOG_SINK:-}" ]; then - : > "$FM_COMPOSER_DIALOG_SINK" || return 1 - fi + : > "$FM_COMPOSER_DIALOG_SINK" || return 1 return 1 } diff --git a/docs/verification/runtime-backends.md b/docs/verification/runtime-backends.md index 560b9cb7138..70d5683b3ee 100644 --- a/docs/verification/runtime-backends.md +++ b/docs/verification/runtime-backends.md @@ -1271,9 +1271,10 @@ The Herdr lab was not running, so the Herdr path is covered by the existing fake Typing `/exit` while a background shell is still running opens a picker whose selected row is "Exit and stop tasks" and whose footer is "Enter to confirm · Esc to cancel". That screen still classifies as pending, the same verdict as unsubmitted composer text. A second Enter would confirm the selected row. -The picker is recognised by its recorded structure only: the heading, then the selected row alone on its row, with the footer as the last non-blank row. +The picker is recognised by its recorded structure only: the heading on its own line, then the selected row alone on its row, with `Enter to confirm · Esc to cancel` as the last non-blank row. The same strings quoted above a normal composer, as a diff, this note, or a test fixture shows them, are not a picker. Submit retries now stop after the Enter that opened the picker and report unknown. +A typed submit to a pane that already shows the picker types nothing and sends no Enter. Exit reports that the worker is blocked on the Claude background-task exit picker and does not type another Enter. A submit can return before any read sees the picker, so exit reads the screen once more when its wait for the agent to stop times out, and names the picker there too. The watcher raises the existing stale wake once per unchanged screen, with that dialog name in the reason. diff --git a/tests/fm-composer-lib.test.sh b/tests/fm-composer-lib.test.sh index 19b9b40886e..83ce4599472 100755 --- a/tests/fm-composer-lib.test.sh +++ b/tests/fm-composer-lib.test.sh @@ -1160,6 +1160,45 @@ quoted_exit_picker_screen() { '╰──────────────╯' } +test_dialog_heading_and_footer_must_be_the_recorded_lines() { + local screen out rc + screen=$(printf '%s\n' \ + 'The fixture mentions Background work is running in a sentence' \ + '❯ 1. Exit and stop tasks' \ + 'Enter to confirm · Esc to cancel') + out=$(fm_composer_blocking_dialog "$screen"); rc=$? + [ "$rc" -eq 1 ] || fail "a heading buried in a sentence must not match" + [ -z "$out" ] || fail "a miss must print nothing, got '$out'" + screen=$(printf '%s\n' \ + 'Background work is running' \ + '❯ 1. Exit and stop tasks' \ + 'Enter to confirm the deployment') + out=$(fm_composer_blocking_dialog "$screen"); rc=$? + [ "$rc" -eq 1 ] || fail "a last line that only starts with the confirm words must not match" + [ -z "$out" ] || fail "a miss must print nothing, got '$out'" + pass "a buried heading or a different last line is not the exit picker" +} + +test_dialog_note_skips_the_match_when_no_sink_is_set() { + local screen out rc before after + screen=$(exit_picker_screen) + unset FM_COMPOSER_DIALOG_SINK + out=$(fm_composer_note_blocking_dialog "$screen"); rc=$? + [ "$rc" -eq 1 ] || fail "a note without a sink should return 1, got $rc" + [ -z "$out" ] || fail "a note without a sink should print nothing, got '$out'" + [ -z "${FM_COMPOSER_DIALOG_SINK:-}" ] || fail "a note without a sink must not create one" + out=$(fm_composer_classify_screen 'styled=1' "$screen" 1) + [ "$out" = pending ] || fail "classify without a sink should stay pending, got '$out'" + trap 'true' RETURN + before=$(trap -p RETURN) + fm_composer_dialog_sink_prepare || fail "the dialog sink could not be prepared" + fm_composer_dialog_sink_release + after=$(trap -p RETURN) + trap - RETURN + [ "$before" = "$after" ] || fail "release replaced the caller RETURN trap: $after" + pass "a dialog note without a sink skips the match, and release leaves a caller RETURN trap" +} + test_quoted_exit_picker_text_is_not_a_dialog() { local screen out rc sink enters screen=$(quoted_exit_picker_screen) @@ -1193,4 +1232,6 @@ test_quoted_exit_picker_text_is_not_a_dialog() { } test_background_exit_picker_stays_pending_and_blocks_retry +test_dialog_heading_and_footer_must_be_the_recorded_lines +test_dialog_note_skips_the_match_when_no_sink_is_set test_quoted_exit_picker_text_is_not_a_dialog diff --git a/tests/fm-tmux-submit-busy.test.sh b/tests/fm-tmux-submit-busy.test.sh index 316ce9b3aae..058c9f7ebb5 100755 --- a/tests/fm-tmux-submit-busy.test.sh +++ b/tests/fm-tmux-submit-busy.test.sh @@ -44,7 +44,14 @@ case "${1:-}" in send-keys) shift; is_enter=0 while [ "$#" -gt 0 ]; do - case "$1" in -t) shift ;; -l) ;; Enter) is_enter=1 ;; esac; shift + case "$1" in + -t) shift ;; + -l) ;; + Enter) is_enter=1 ;; + -*) ;; + *) [ -z "${FM_FAKE_SENT:-}" ] || printf 'typed %s\n' "$1" >> "$FM_FAKE_SENT" ;; + esac + shift done if [ "$is_enter" = 1 ]; then [ -z "${FM_FAKE_SENT:-}" ] || printf 'Enter\n' >> "$FM_FAKE_SENT" @@ -374,6 +381,37 @@ test_exit_picker_refuses_confirming_enter() { } test_exit_picker_refuses_confirming_enter + +test_typed_submit_on_open_exit_picker_types_nothing() { + local dir fakebin composer sent before rc=0 err + # shellcheck source=/dev/null + . "$ROOT/bin/fm-backend.sh" + dir="$TMP_ROOT/typed-on-picker" + fakebin=$(make_submit_mock "$dir") + composer="$dir/composer" + sent="$dir/sent.log" + err="$dir/err" + printf '%s\n' \ + 'Background work is running' \ + '❯ 1. Exit and stop tasks' \ + 'The following will stop when you exit:' \ + 'shell · sleep 300' \ + ' 2. Move to background and exit' \ + ' 3. Stay' \ + 'Enter to confirm · Esc to cancel' > "$composer" + before=$(cat "$composer") + : > "$sent" + PATH="$fakebin:$PATH" FM_FAKE_COMPOSER="$composer" FM_FAKE_SENT="$sent" \ + fm_backend_send_text_submit tmux win 'please continue' 3 0 0 >"$dir/out" 2>"$err" || rc=$? + [ "$rc" -ne 0 ] || fail "a typed submit onto an open picker should refuse" + [ ! -s "$sent" ] || fail "a typed submit onto an open picker sent input: $(cat "$sent")" + [ "$(cat "$composer")" = "$before" ] || fail "a typed submit onto an open picker changed the pane" + grep -F 'blocked on a prompt: Claude background-task exit picker' "$err" >/dev/null \ + || fail "the refusal should name the picker, got '$(cat "$err")'" + pass "fm_backend_send_text_submit: a typed message to the exit picker types nothing and sends no Enter" +} + +test_typed_submit_on_open_exit_picker_types_nothing test_wrapped_continuation_retries_swallowed_enter test_placeholder_like_bare_input_retries_swallowed_enter test_busy_pane_composer_clears_first_try From e13a367f16a7b703ca58aa907d5da5b5973f8d41 Mon Sep 17 00:00:00 2001 From: Tiago Peixoto Date: Tue, 6 Oct 2026 00:35:39 +0200 Subject: [PATCH 04/10] no-mistakes(review): restore watcher to main behaviour, drop dialog wake --- bin/fm-test-run.sh | 4 +- bin/fm-watch.sh | 22 +---- docs/verification/runtime-backends.md | 6 +- tests/fm-test-run.test.sh | 11 --- tests/fm-watch-triage.test.sh | 116 -------------------------- 5 files changed, 7 insertions(+), 152 deletions(-) diff --git a/bin/fm-test-run.sh b/bin/fm-test-run.sh index 61c754d418d..0676f6dc517 100755 --- a/bin/fm-test-run.sh +++ b/bin/fm-test-run.sh @@ -1621,11 +1621,9 @@ families_for_changed_path() { bin/fm-composer-lib.sh) # The shared shape catalogue is vendor-rendered signal; a change to it # re-selects the live guard (fm-composer-matrix-live-e2e) alongside the - # portable families. The watcher reads the dialog matcher for its - # stale wake (watcher-wake-lock). + # portable families. printf '%s\n' backend-dispatch printf '%s\n' pure-contract-unit - printf '%s\n' watcher-wake-lock printf '%s\n' live-harness-optin ;; bin/fm-spawn.sh|bin/fm-send.sh|bin/fm-harness.sh|\ diff --git a/bin/fm-watch.sh b/bin/fm-watch.sh index cb828117212..dcd1f7c4933 100755 --- a/bin/fm-watch.sh +++ b/bin/fm-watch.sh @@ -215,10 +215,6 @@ WATCH_HOME_EXISTED=0 . "$SCRIPT_DIR/fm-pending-reply-lib.sh" # shellcheck source=bin/fm-busy-lib.sh . "$SCRIPT_DIR/fm-busy-lib.sh" -# Dialog recognition lives in the composer owner. shellcheck does not follow -# this source: the watcher graph must not absorb the classifier AST. -# shellcheck source=/dev/null -. "$SCRIPT_DIR/fm-composer-lib.sh" # Steering-inbox loss detection: bin/fm-task-inbox-lib.sh owns the record, # doorbell, re-ring ladder, and unavailable-endpoint contracts; this watcher # supplies their live endpoint and busy checks plus wake emission @@ -3010,19 +3006,17 @@ EOF if ! status_is_paused_or_captain_held "$last" && [ -e "$STATE/.paused-$key" ]; then clear_pause_tracking "$key" fi - tail40=$(fm_backend_capture "$(window_backend "$w")" "$w" 40 "$(window_label "$w")" 2>/dev/null) || continue - dialog=$(fm_composer_blocking_dialog "$tail40") || dialog= # An idle secondmate endpoint is healthy by design, so a mate is admitted to # the pane-stale path ONLY to serve a status-declared wait's bounded - # re-surface, or the wake for a recognised dialog on a pane that is not - # busy. This gate reads the shared predicate rather than the pause verb + # re-surface. This gate reads the shared predicate rather than the pause verb # alone so it includes a declared `captain-held` status. A hold recorded only # in the backlog while the mate still says `working:` or `done:` is outside # this guard: reaching it would require backlog reads for windows this gate # deliberately skips, putting that read on the ordinary poll hot path. if [ "$kind" = secondmate ] && ! status_is_paused_or_captain_held "$last"; then - { [ -n "$dialog" ] && ! window_is_busy "$w" "$tail40"; } || continue + continue fi + tail40=$(fm_backend_capture "$(window_backend "$w")" "$w" 40 "$(window_label "$w")" 2>/dev/null) || continue h=$(printf '%s' "$tail40" | hash_pane) hf="$STATE/.hash-$key" cf="$STATE/.count-$key" @@ -3043,15 +3037,7 @@ EOF if [ "$n" -ge 2 ] && [ "$busy_now" -ne 0 ]; then # The pane is idle/stale at hash $h. Triage decides whether this wakes # firstmate. Detection itself is unchanged from above. - if [ -n "$dialog" ] && [ "$(fm_backend_agent_alive "$(window_backend "$w")" "$w" 2>/dev/null || true)" != dead ]; then - # A stopped agent can leave the picker text on its pane. That is - # not a prompt still waiting, so it keeps the ordinary triage below. - if [ "$(cat "$sf" 2>/dev/null || true)" != "$h" ]; then - fm_wake_append stale "$w" "stale: $w (blocked on a prompt: $dialog)" || exit 1 - printf '%s' "$h" > "$sf" - wake "stale: $w (blocked on a prompt: $dialog)" - fi - elif [ "$kind" = secondmate ]; then + if [ "$kind" = secondmate ]; then case "$(pause_state_class "$w" "$task")" in paused) handle_paused_stale "$w" "$task" "$h" ;; *) clear_pause_tracking "$key" ;; diff --git a/docs/verification/runtime-backends.md b/docs/verification/runtime-backends.md index 70d5683b3ee..ce429421552 100644 --- a/docs/verification/runtime-backends.md +++ b/docs/verification/runtime-backends.md @@ -1277,10 +1277,8 @@ Submit retries now stop after the Enter that opened the picker and report unknow A typed submit to a pane that already shows the picker types nothing and sends no Enter. Exit reports that the worker is blocked on the Claude background-task exit picker and does not type another Enter. A submit can return before any read sees the picker, so exit reads the screen once more when its wait for the agent to stop times out, and names the picker there too. -The watcher raises the existing stale wake once per unchanged screen, with that dialog name in the reason. -A secondmate parked on this picker raises the same wake. -Every other non-paused secondmate still skips pane staleness. -A stopped agent whose pane still shows the picker text is not reported as blocked on a prompt. +Exit does not report a stopped agent whose pane still shows the picker text as blocked on a prompt. +The watcher does not read the picker: a pane parked on it keeps the ordinary stale triage. No recorded screen was available for a model-downgrade confirmation, an MCP approval, or a Claude exit confirmation other than this picker, so those dialogs are not covered. Refusing an Enter that would confirm a dialog restores an existing safety path, so it is not gated behind a flag. diff --git a/tests/fm-test-run.test.sh b/tests/fm-test-run.test.sh index 13f93bd1132..5f70ea8a557 100755 --- a/tests/fm-test-run.test.sh +++ b/tests/fm-test-run.test.sh @@ -120,7 +120,6 @@ init_changed_fixture_repo() { fm-backend-cmux.test.sh \ fm-backend-zellij.test.sh \ fm-control-herdr-smoke.test.sh \ - fm-watch-triage.test.sh \ fm-backend-orca.test.sh; do printf '#!/usr/bin/env bash\n# tests/lib.sh\n' >"$repo/tests/$script" chmod +x "$repo/tests/$script" @@ -129,7 +128,6 @@ init_changed_fixture_repo() { : >"$repo/tests/fm-backend-herdr-eventwait.test.py" : >"$repo/bin/fm-supervisor-target-lib.sh" : >"$repo/bin/fm-control-lib.sh" - : >"$repo/bin/fm-composer-lib.sh" : >"$repo/bin/fm-timeout-lib.sh" : >"$repo/bin/fm-procevent-quota.sh" : >"$repo/bin/fm-quota-axi-lib.sh" @@ -407,15 +405,6 @@ test_changed_dependency_selection_and_unmapped_failure() { git -C "$repo" add bin/fm-control-lib.sh git -C "$repo" -c user.name=test -c user.email=test@example.invalid commit -qm control-lib-change - printf '\n' >>"$repo/bin/fm-composer-lib.sh" - listed=$(cd "$repo" && bin/fm-test-run.sh --list --changed --base HEAD) - assert_contains "$listed" "tests/fm-backend.test.sh" \ - "composer library keeps backend coverage" - assert_contains "$listed" "tests/fm-watch-triage.test.sh" \ - "composer library selects the watcher's dialog wake coverage" - git -C "$repo" add bin/fm-composer-lib.sh - git -C "$repo" -c user.name=test -c user.email=test@example.invalid commit -qm composer-lib-change - printf '\n' >>"$repo/bin/fm-timeout-lib.sh" listed=$(cd "$repo" && bin/fm-test-run.sh --list --changed --base HEAD) assert_contains "$listed" "tests/fm-procevent-quota.test.sh" \ diff --git a/tests/fm-watch-triage.test.sh b/tests/fm-watch-triage.test.sh index fac7c9bcf72..c2a2d60924d 100755 --- a/tests/fm-watch-triage.test.sh +++ b/tests/fm-watch-triage.test.sh @@ -4286,118 +4286,6 @@ test_secondmate_nonpaused_stale_remains_suppressed() { pass "a non-paused secondmate retains normal stale suppression" } -exit_picker_capture() { - printf '%s\n' \ - 'Background work is running' \ - '❯ 1. Exit and stop tasks' \ - 'The following will stop when you exit:' \ - 'shell · sleep 300' \ - ' 2. Move to background and exit' \ - ' 3. Stay' \ - 'Enter to confirm · Esc to cancel' -} - -test_exit_picker_stale_names_the_dialog() { - local dir state fakebin out capture_file statusf window key sig pid - dir=$(make_case exit-picker-stale); state="$dir/state"; fakebin="$dir/fakebin" - out="$dir/watch.out"; capture_file="$dir/pane.txt"; statusf="$state/exit-picker.status" - window="test:fm-exit-picker" - exit_picker_capture > "$capture_file" - printf 'window=%s\nkind=ship\nharness=claude\nbackend=tmux\n' "$window" > "$state/exit-picker.meta" - printf 'working: implementing\n' > "$statusf" - sig=$(seen_sig "$statusf"); printf '%s' "$sig" > "$state/.seen-exit-picker_status" - key=$(printf '%s' "$window" | tr ':/.' '___') - printf '%s' "$(hash_text "$(cat "$capture_file")")" > "$state/.hash-$key" - printf '1\n' > "$state/.count-$key" - PATH="$fakebin:$PATH" FM_FAKE_TMUX_WINDOW="$window" FM_FAKE_TMUX_CAPTURE="$capture_file" \ - FM_FAKE_TMUX_CURRENT_COMMAND=claude \ - watch_bg "$state" "$fakebin" "$out" - pid=$! - wait_for_exit "$pid" 100 || { reap "$pid"; fail "exit picker did not wake: $(cat "$out")"; } - grep -Fx "stale: $window (blocked on a prompt: Claude background-task exit picker)" "$out" >/dev/null \ - || { reap "$pid"; fail "exit picker wake did not name the dialog: $(cat "$out")"; } - grep -F "stale: $window (blocked on a prompt: Claude background-task exit picker)" "$state/.wake-queue" >/dev/null \ - || fail "exit picker wake was not queued" - pass "an ordinary worker parked on the background-task exit picker raises a stale wake that names it" -} - -test_secondmate_exit_picker_names_the_dialog() { - local dir state fakebin out capture_file statusf window key sig pid - dir=$(make_case secondmate-exit-picker); state="$dir/state"; fakebin="$dir/fakebin" - out="$dir/watch.out"; capture_file="$dir/pane.txt"; statusf="$state/secondmate-picker.status" - window="test:fm-secondmate-picker" - exit_picker_capture > "$capture_file" - printf 'window=%s\nkind=secondmate\nharness=claude\nbackend=tmux\n' "$window" > "$state/secondmate-picker.meta" - printf 'working: the parent supervises this secondmate\n' > "$statusf" - sig=$(seen_sig "$statusf"); printf '%s' "$sig" > "$state/.seen-secondmate-picker_status" - key=$(printf '%s' "$window" | tr ':/.' '___') - printf '%s' "$(hash_text "$(cat "$capture_file")")" > "$state/.hash-$key" - printf '1\n' > "$state/.count-$key" - PATH="$fakebin:$PATH" FM_FAKE_TMUX_WINDOW="$window" FM_FAKE_TMUX_CAPTURE="$capture_file" \ - FM_FAKE_TMUX_CURRENT_COMMAND=claude \ - watch_bg "$state" "$fakebin" "$out" - pid=$! - wait_for_exit "$pid" 100 || { reap "$pid"; fail "a secondmate on the exit picker did not wake: $(cat "$out")"; } - grep -Fx "stale: $window (blocked on a prompt: Claude background-task exit picker)" "$out" >/dev/null \ - || fail "the secondmate exit picker wake did not name the dialog: $(cat "$out")" - grep -F "stale: $window (blocked on a prompt: Claude background-task exit picker)" "$state/.wake-queue" >/dev/null \ - || fail "the secondmate exit picker wake was not queued" - pass "a non-paused secondmate parked on the background-task exit picker raises a stale wake that names it" -} - -# The idle exemption still covers a mate whose pane only quotes the picker: -# the text sits above a normal composer, so no prompt is waiting. -test_secondmate_quoted_exit_picker_stays_quiet() { - local dir state fakebin out capture_file statusf window key sig pid - dir=$(make_case secondmate-quoted-picker); state="$dir/state"; fakebin="$dir/fakebin" - out="$dir/watch.out"; capture_file="$dir/pane.txt"; statusf="$state/secondmate-quoted.status" - window="test:fm-secondmate-quoted" - { exit_picker_capture; printf '%s\n' '' '╭──────╮' '│ > │' '╰──────╯'; } > "$capture_file" - printf 'window=%s\nkind=secondmate\nharness=claude\nbackend=tmux\n' "$window" > "$state/secondmate-quoted.meta" - printf 'working: the parent supervises this secondmate\n' > "$statusf" - sig=$(seen_sig "$statusf"); printf '%s' "$sig" > "$state/.seen-secondmate-quoted_status" - key=$(printf '%s' "$window" | tr ':/.' '___') - printf '%s' "$(hash_text "$(cat "$capture_file")")" > "$state/.hash-$key" - printf '1\n' > "$state/.count-$key" - PATH="$fakebin:$PATH" FM_FAKE_TMUX_WINDOW="$window" FM_FAKE_TMUX_CAPTURE="$capture_file" \ - FM_FAKE_TMUX_CURRENT_COMMAND=claude \ - watch_bg "$state" "$fakebin" "$out" - pid=$! - if ! wait_poll_cycle "$state" "$pid"; then - reap "$pid"; fail "watcher surfaced a secondmate whose pane only quotes the exit picker: $(cat "$out")" - fi - [ ! -s "$out" ] || { reap "$pid"; fail "quoted exit picker text printed a wake reason: $(cat "$out")"; } - reap "$pid" - pass "a non-paused secondmate whose pane only quotes the exit picker stays quiet" -} - -# A stopped agent can leave the picker text on its pane. Nothing is waiting -# for an answer there, so the wake keeps its ordinary stale reason. -test_stopped_worker_exit_picker_text_is_not_a_prompt() { - local dir state fakebin out capture_file statusf window key sig pid - dir=$(make_case exit-picker-stopped); state="$dir/state"; fakebin="$dir/fakebin" - out="$dir/watch.out"; capture_file="$dir/pane.txt"; statusf="$state/exit-picker-stopped.status" - window="test:fm-exit-picker-stopped" - exit_picker_capture > "$capture_file" - printf 'window=%s\nkind=ship\nharness=claude\nbackend=tmux\n' "$window" > "$state/exit-picker-stopped.meta" - printf 'working: implementing\n' > "$statusf" - sig=$(seen_sig "$statusf"); printf '%s' "$sig" > "$state/.seen-exit-picker-stopped_status" - key=$(printf '%s' "$window" | tr ':/.' '___') - printf '%s' "$(hash_text "$(cat "$capture_file")")" > "$state/.hash-$key" - printf '1\n' > "$state/.count-$key" - PATH="$fakebin:$PATH" FM_FAKE_TMUX_WINDOW="$window" FM_FAKE_TMUX_CAPTURE="$capture_file" \ - FM_FAKE_TMUX_CURRENT_COMMAND=zsh FM_FAKE_CREW_STATE='state: stopped · source: pane · bare shell' \ - watch_bg "$state" "$fakebin" "$out" - pid=$! - wait_for_exit "$pid" 100 || { reap "$pid"; fail "a stopped worker's stale pane did not wake: $(cat "$out")"; } - grep -F "stale: $window" "$out" >/dev/null \ - || fail "a stopped worker's stale pane lost its ordinary wake: $(cat "$out")" - if grep -F 'blocked on a prompt' "$out" >/dev/null; then - fail "a stopped worker was reported as blocked on a prompt: $(cat "$out")" - fi - pass "a stopped worker whose pane still shows the exit picker text is not reported as blocked on a prompt" -} - test_secondmate_unpause_clears_pause_tracking() { local dir state fakebin out statusf window key pid dir=$(make_case secondmate-unpause-clears); state="$dir/state"; fakebin="$dir/fakebin" @@ -6829,10 +6717,6 @@ test_reheld_captain_call_starts_its_own_resurface_window test_secondmate_paused_resurfaces_in_normal_mode test_secondmate_captain_held_resurfaces_in_normal_mode test_secondmate_nonpaused_stale_remains_suppressed -test_exit_picker_stale_names_the_dialog -test_secondmate_exit_picker_names_the_dialog -test_secondmate_quoted_exit_picker_stays_quiet -test_stopped_worker_exit_picker_text_is_not_a_prompt test_secondmate_unpause_clears_pause_tracking test_nonterminal_stale_pause_transitions_reclassify_unchanged_hash test_nonterminal_paused_rechecks_authoritative_state From 401d893663de173c6c8eac9c8274b0c2147ccfb3 Mon Sep 17 00:00:00 2001 From: Tiago Peixoto Date: Tue, 6 Oct 2026 00:46:32 +0200 Subject: [PATCH 05/10] no-mistakes(test): test: align Herdr picker fixtures with the preflight read --- tests/fm-backend-herdr.test.sh | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tests/fm-backend-herdr.test.sh b/tests/fm-backend-herdr.test.sh index acfe4614389..01d0a7015f8 100755 --- a/tests/fm-backend-herdr.test.sh +++ b/tests/fm-backend-herdr.test.sh @@ -126,6 +126,14 @@ herdr_submit_claude_prefix() { # printf ' \xe2\x9d\xaf %s\n' "$text" > "$resp/4.out" } +# herdr_submit_preflight_prefix: fm_backend_send_text_submit reads the composer +# once before the adapter types. That read is call 1 and shows an empty +# composer, so every adapter call moves one slot later. +herdr_submit_preflight_prefix() { # + herdr_submit_shift "$1" 1 + printf ' \xe2\x9d\xaf\n' > "$1/1.out" +} + # make_herdr_server_env_fakebin: a stateful server stub that records only the # long-lived server launch environment, then reports the server as running. make_herdr_server_env_fakebin() { # -> echoes fakebin dir @@ -4445,6 +4453,7 @@ test_send_text_submit_refuses_confirming_enter_on_exit_picker() { ' 2. Move to background and exit' \ ' 3. Stay' \ 'Enter to confirm · Esc to cancel' > "$resp/8.out" + herdr_submit_preflight_prefix "$resp" fb=$(make_herdr_fakebin "$dir") out=$( PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" FM_BACKEND_HERDR_SUBMIT_POLLS=1 \ TMPDIR="$dir/tmp" \ @@ -4474,6 +4483,7 @@ test_blocked_submit_leaves_the_exit_picker_to_the_next_composer_read() { ' 2. Move to background and exit' \ ' 3. Stay' \ 'Enter to confirm · Esc to cancel' > "$resp/8.out" + herdr_submit_preflight_prefix "$resp" fb=$(make_herdr_fakebin "$dir") out=$( PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" FM_BACKEND_HERDR_SUBMIT_POLLS=1 \ FM_COMPOSER_DIALOG_SINK="$dir/sink" \ From c362cf7fd5ebe30274ab764cd21f3365ac19c966 Mon Sep 17 00:00:00 2001 From: Tiago Peixoto Date: Tue, 6 Oct 2026 00:54:17 +0200 Subject: [PATCH 06/10] no-mistakes(document): document exit refusal on a recognised dialog --- docs/agent-control.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/agent-control.md b/docs/agent-control.md index c949a13ba96..a543e881938 100644 --- a/docs/agent-control.md +++ b/docs/agent-control.md @@ -50,6 +50,9 @@ muse is the one verified adapter that restores the cancelled prompt back into it The clear is refused before anything is sent when the recorded backend cannot deliver it. `exit` reads the composer's state before typing the exit command and requires the exact `empty` verdict; a `pending` verdict refuses by naming the pending text, and any other verdict (`unknown`, `pending-unproven`, or an unreadable read) refuses as not proven empty, matching the fail-safe contract every other consumer that can overwrite composer input follows. +`exit` also refuses, naming the dialog as `blocked on a prompt`, when the screen shows a recognised dialog that a further Enter would answer, whether the dialog was open before the exit command was typed or the submitting Enter opened it; it sends no Escape and chooses no option, so closing the dialog is left to the operator. +A stopped agent whose pane still shows the dialog text is not refused. +[`fm_composer_blocking_dialog`](../bin/fm-composer-lib.sh) owns the recognised set, which today is only Claude's background-task exit picker; [its verification record](verification/runtime-backends.md#claude-background-task-exit-picker) lists the dialogs that are not covered. **Teardown and discard are not verbs and will not become verbs.** `exit` stops an agent and preserves everything else. From 493a44e4a91ffe76181a6416e25f6bbeef03b94b Mon Sep 17 00:00:00 2001 From: Tiago Peixoto Date: Tue, 6 Oct 2026 01:50:31 +0200 Subject: [PATCH 07/10] fix: remove the dialog file when exit runs in a subshell do_exit is invoked from a command substitution, so the parent cleanup never saw the path it is supposed to delete. --- bin/fm-control.sh | 3 +++ tests/fm-control-relaunch.test.sh | 19 +++++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/bin/fm-control.sh b/bin/fm-control.sh index 4d2d00d8b7a..dc63e11f2c5 100755 --- a/bin/fm-control.sh +++ b/bin/fm-control.sh @@ -313,6 +313,9 @@ ID=$RAW_ID fm_lease_guard "$ID" "lifecycle control (fm-control)" CONTROL_LOCK="$STATE/.control-$ID.lock" trap control_cleanup EXIT +# do_exit runs in a command substitution. That subshell does not run this +# EXIT trap, so the parent has to hold the path the trap removes. +FM_COMPOSER_DIALOG_SINK=$STATE/$ID.composer-dialog fm_lock_try_acquire "$CONTROL_LOCK" \ || die "another lifecycle action is already running for task $ID" CONTROL_LOCK_HELD=1 diff --git a/tests/fm-control-relaunch.test.sh b/tests/fm-control-relaunch.test.sh index 1242dc26bfe..29516faf75d 100755 --- a/tests/fm-control-relaunch.test.sh +++ b/tests/fm-control-relaunch.test.sh @@ -2386,6 +2386,25 @@ test_relaunch_moves_a_drifted_item_back_in_flight() { pass "relaunch heals an item that drifted out of In flight while the task stayed live" } +test_exit_and_relaunch_remove_the_dialog_file() { + local dir out rc + dir=$(new_case dialog-file-exit rl70) + add_ship_task "$dir" rl70 claude + out=$(run_control "$dir" rl70 exit); rc=$? + expect_code 0 "$rc" "exit should stop the agent"$'\n'"$out" + [ ! -e "$dir/home/state/rl70.composer-dialog" ] \ + || fail "exit should remove the dialog file" + + dir=$(new_case dialog-file-relaunch rl71) + add_ship_task "$dir" rl71 claude + out=$(run_control "$dir" rl71 relaunch --note "replace the agent"); rc=$? + expect_code 0 "$rc" "relaunch should replace the agent"$'\n'"$out" + [ ! -e "$dir/home/state/rl71.composer-dialog" ] \ + || fail "relaunch should remove the dialog file" + pass "fm-control removes the dialog file after exit and after relaunch" +} + +test_exit_and_relaunch_remove_the_dialog_file test_same_harness_relaunch_keeps_identity_and_reuses_the_endpoint test_relaunch_refuses_before_exit_when_the_composer_holds_pending_text test_relaunch_refuses_before_exit_when_the_composer_state_is_unproven From 374cef7ee40828e62686dac9db8741483f745ec8 Mon Sep 17 00:00:00 2001 From: Tiago Peixoto Date: Tue, 6 Oct 2026 02:06:01 +0200 Subject: [PATCH 08/10] no-mistakes(review): fix: set the dialog file path after the control lock --- bin/fm-control.sh | 7 +++---- tests/fm-control-relaunch.test.sh | 3 +++ 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/bin/fm-control.sh b/bin/fm-control.sh index dc63e11f2c5..78e73e69e6b 100755 --- a/bin/fm-control.sh +++ b/bin/fm-control.sh @@ -313,12 +313,12 @@ ID=$RAW_ID fm_lease_guard "$ID" "lifecycle control (fm-control)" CONTROL_LOCK="$STATE/.control-$ID.lock" trap control_cleanup EXIT -# do_exit runs in a command substitution. That subshell does not run this -# EXIT trap, so the parent has to hold the path the trap removes. -FM_COMPOSER_DIALOG_SINK=$STATE/$ID.composer-dialog fm_lock_try_acquire "$CONTROL_LOCK" \ || die "another lifecycle action is already running for task $ID" CONTROL_LOCK_HELD=1 +# do_exit runs in a command substitution. That subshell does not run this +# EXIT trap, so the parent has to hold the path the trap removes. +FM_COMPOSER_DIALOG_SINK=$STATE/$ID.composer-dialog META="$STATE/$ID.meta" if [ ! -f "$META" ]; then case "$RAW_ID" in @@ -637,7 +637,6 @@ do_exit() { if [ -n "$hazard" ] && rendered_matches "$hazard"; then die "task $ID shows the $HARNESS revert picker, where typed text becomes a search and Enter reverts file changes; refusing to type the $cmd exit command. Close it with $(fm_control_interrupt_key "$HARNESS"), never Enter, then retry '$VERB'" fi - FM_COMPOSER_DIALOG_SINK=$STATE/$ID.composer-dialog : > "$FM_COMPOSER_DIALOG_SINK" \ || die "task $ID's dialog check could not be recorded" composer_state=$(fm_backend_composer_state "$BACKEND" "$T" "$LABEL" 2>/dev/null) \ diff --git a/tests/fm-control-relaunch.test.sh b/tests/fm-control-relaunch.test.sh index 29516faf75d..0843cfa2e79 100755 --- a/tests/fm-control-relaunch.test.sh +++ b/tests/fm-control-relaunch.test.sh @@ -1627,6 +1627,7 @@ test_concurrent_relaunch_is_refused() { i=$((i + 1)) done [ -e "$lock" ] || { kill "$holder" 2>/dev/null; fail "could not stage a held control lock"; } + printf 'held\n' > "$dir/home/state/rl19.composer-dialog" out=$(run_control "$dir" rl19 relaunch --note "concurrent"); rc=$? kill "$holder" 2>/dev/null || true wait "$holder" 2>/dev/null || true @@ -1635,6 +1636,8 @@ test_concurrent_relaunch_is_refused() { "the refusal should name the concurrent action" [ "$(cat "$dir/fake/command")" = claude ] \ || fail "a refused concurrent relaunch must not stop the agent" + [ "$(cat "$dir/home/state/rl19.composer-dialog" 2>/dev/null)" = held ] \ + || fail "a refused concurrent relaunch must not remove the lock holder's dialog file" pass "fm-control relaunch: two control actions on one task serialize instead of interleaving" } From 4f50944cba880f23114adb8f307d0a834293f29c Mon Sep 17 00:00:00 2001 From: Tiago Peixoto Date: Tue, 6 Oct 2026 02:20:20 +0200 Subject: [PATCH 09/10] no-mistakes(document): document why the dialog file path follows the lock --- bin/fm-control.sh | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/bin/fm-control.sh b/bin/fm-control.sh index 78e73e69e6b..30d53b358bd 100755 --- a/bin/fm-control.sh +++ b/bin/fm-control.sh @@ -317,7 +317,9 @@ fm_lock_try_acquire "$CONTROL_LOCK" \ || die "another lifecycle action is already running for task $ID" CONTROL_LOCK_HELD=1 # do_exit runs in a command substitution. That subshell does not run this -# EXIT trap, so the parent has to hold the path the trap removes. +# EXIT trap, so the parent has to hold the path the trap removes. Set it +# only once the lock is held: a process that loses the lock runs the same +# trap, and would remove the file the lock holder is reading. FM_COMPOSER_DIALOG_SINK=$STATE/$ID.composer-dialog META="$STATE/$ID.meta" if [ ! -f "$META" ]; then From 5d85cee3c03ca7bad3447cbcd0c29befd8cc14d6 Mon Sep 17 00:00:00 2001 From: Tiago Peixoto Date: Tue, 6 Oct 2026 03:18:53 +0200 Subject: [PATCH 10/10] no-mistakes(ci): The exit cleanup in bin/fm-control.sh now deletes the dialog file first and releases the control lock second. The changes are in the worktree and are not committed. Invariant: only the process that holds the control lock for a task may write or delete that task's dialog file ($STATE/.composer-dialog, the file that carries the picker name from the composer read to the Enter checks). The old cleanup released the lock and then deleted the file, so the delete ran outside the lock. Places where this invariant must hold: control_cleanup is the only place that deletes the file, and the single assignment after CONTROL_LOCK_HELD=1 is the only place that names it. do_exit only truncates the file, and it runs while the parent holds the lock. A process that loses the lock never sets the path, so it deletes nothing (earlier fix, unchanged). No sibling site needed a change. Fix: in control_cleanup, the rm of the dialog file moved above the fm_lock_release call, with a two-line comment that gives the reason. No dialog recognition or supervision code changed. Test: added test_exit_removes_the_dialog_file_before_releasing_the_lock in tests/fm-control-relaunch.test.sh. It runs one `fm-control exit` with a recording rm on PATH. The lock release removes paths at or under the control lock with rm, so the recording rm writes whether the dialog file exists at that moment. The test asserts the last record is "absent". It starts no second command. Verification, all run locally: - Before the fix, the new test failed with "the dialog file must be gone when the control lock is released, got: present". - After the fix, tests/fm-control-relaunch.test.sh exits 0 with 75 ok lines and no "not ok" line, including the new test and the existing test that the file is gone after exit and after relaunch. - tests/fm-control.test.sh exits 0 with 44 ok lines. - shellcheck -x on both changed files exits 0 with no output. One thing I noticed and did not change: tests/fm-control-relaunch.test.sh prints 615 "rm: cannot remove ... Permission denied" lines during its temp-directory teardown. The count is the same with my change reverted, so this change does not cause it, and the suite still exits 0. I could not read the Greptile check log (the check run was not found), so I worked from the finding text and the user instructions --- bin/fm-control.sh | 8 +++++--- tests/fm-control-relaunch.test.sh | 32 +++++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/bin/fm-control.sh b/bin/fm-control.sh index 30d53b358bd..f61876bd0b9 100755 --- a/bin/fm-control.sh +++ b/bin/fm-control.sh @@ -200,13 +200,15 @@ control_cleanup() { && declare -F relaunch_rollback >/dev/null 2>&1; then relaunch_rollback || true fi + # Remove the dialog file while the lock is still held: once it is released, + # the next lifecycle command for this task writes the same path. + if [ -n "${FM_COMPOSER_DIALOG_SINK:-}" ]; then + rm -f "$FM_COMPOSER_DIALOG_SINK" + fi if [ "$CONTROL_LOCK_HELD" = 1 ]; then CONTROL_LOCK_HELD=0 fm_lock_release "$CONTROL_LOCK" || true fi - if [ -n "${FM_COMPOSER_DIALOG_SINK:-}" ]; then - rm -f "$FM_COMPOSER_DIALOG_SINK" - fi if declare -F fm_lease_guard_release >/dev/null 2>&1; then fm_lease_guard_release || true fi diff --git a/tests/fm-control-relaunch.test.sh b/tests/fm-control-relaunch.test.sh index 0843cfa2e79..a214f057b7e 100755 --- a/tests/fm-control-relaunch.test.sh +++ b/tests/fm-control-relaunch.test.sh @@ -2407,7 +2407,39 @@ test_exit_and_relaunch_remove_the_dialog_file() { pass "fm-control removes the dialog file after exit and after relaunch" } +# The lock release removes paths at or under the control lock with rm, so a +# recording rm sees the state directory at the moment of release without a +# second overlapping command. +test_exit_removes_the_dialog_file_before_releasing_the_lock() { + local dir out rc lock sink trace + dir=$(new_case dialog-file-order rl72) + add_ship_task "$dir" rl72 claude + lock="$dir/home/state/.control-rl72.lock" + sink="$dir/home/state/rl72.composer-dialog" + trace="$dir/fake/rm-trace" + cat > "$dir/fakebin/rm" <> "$trace" + break + ;; + esac +done +exec "$(command -v rm)" "\$@" +SH + chmod +x "$dir/fakebin/rm" + out=$(run_control "$dir" rl72 exit); rc=$? + expect_code 0 "$rc" "exit should stop the agent"$'\n'"$out" + [ ! -e "$lock" ] || fail "exit should release the control lock" + [ "$(tail -n 1 "$trace" 2>/dev/null)" = absent ] \ + || fail "the dialog file must be gone when the control lock is released, got: $(cat "$trace" 2>/dev/null)" + pass "fm-control exit removes the dialog file before it releases the control lock" +} + test_exit_and_relaunch_remove_the_dialog_file +test_exit_removes_the_dialog_file_before_releasing_the_lock test_same_harness_relaunch_keeps_identity_and_reuses_the_endpoint test_relaunch_refuses_before_exit_when_the_composer_holds_pending_text test_relaunch_refuses_before_exit_when_the_composer_state_is_unproven