From d24d462fc507cc73617a783cc37d24d12cc8c435 Mon Sep 17 00:00:00 2001 From: knowttl Date: Tue, 28 Jul 2026 15:56:52 -0700 Subject: [PATCH 1/3] herdr: confirm Pi submissions accepted while the agent is generating An ordinary instruction sent to a Pi agent that was already responding was accepted into Pi's steering queue and acted on, but fm-send reported it as unsubmitted and exited nonzero. A busy baseline could only fall back to composer clearance, and a busy Pi's composer is cleared by the very submission being confirmed, so nothing on that path could observe the delivery. A busy Pi submit is now confirmed from Pi's own queued-input rows, bound to the current send by a new matching entry so an older identical row cannot create false success. Input that Pi consumes while busy without queueing it reports the new busy-unqueued verdict, and fm-send turns that into an actionable retry-when-idle failure instead of claiming delivery. The rule is transport-generic: no command name or harness message is inspected. Real unsubmitted composer text still reports pending, unreadable panes still report unknown, and the idle Pi path plus every non-Pi busy path are unchanged. The message is still typed exactly once. --- bin/backends/herdr.sh | 156 +++++++++++++++++++++++-- bin/fm-send.sh | 7 ++ docs/herdr-backend.md | 6 +- tests/fm-backend-herdr.test.sh | 201 +++++++++++++++++++++++++++++++++ 4 files changed, 362 insertions(+), 8 deletions(-) diff --git a/bin/backends/herdr.sh b/bin/backends/herdr.sh index 3a28daaa336..f7d4634a730 100644 --- a/bin/backends/herdr.sh +++ b/bin/backends/herdr.sh @@ -1898,6 +1898,105 @@ fm_backend_herdr_agent_identity_raw() { # -> \t printf '%s' "$out" | jq -r '[.result.agent.agent // "", .result.agent.agent_status // ""] | @tsv' 2>/dev/null } +# --- Pi's queued-input evidence (busy submit confirmation) ------------------- +# Pi accepts ordinary input WHILE it is generating: the submission is added to +# its steering/follow-up queue and rendered as a de-emphasized entry row above +# the composer, and the composer is cleared. Native agent-state cannot confirm +# that submit (the pane was already `working` before Enter and stays `working` +# after it), and the composer is empty either way, so the queue rows are the +# only positive evidence that THIS send landed. +# Deliberately generic: any input Pi queues while busy confirms, and any input +# Pi consumes without queueing (a built-in command it runs or refuses +# immediately) does not. Nothing here inspects which command was sent or what +# Pi printed about it. +# The capture window must cover the queue rows, Pi's status row, and the +# composer region beneath them, so it is wider than the composer-only window. +FM_BACKEND_HERDR_PI_QUEUE_LINES=${FM_BACKEND_HERDR_PI_QUEUE_LINES:-40} +# Entry prefixes Pi renders for queued input, matched after ANSI stripping and +# trimming. Extend this if Pi adds another queued-input row shape. +FM_BACKEND_HERDR_PI_QUEUE_PREFIXES=${FM_BACKEND_HERDR_PI_QUEUE_PREFIXES:-'Steering: |Follow-up: '} +# How much of the sent text has to match a queued row. Pi truncates a long +# queued row to the pane width, so the comparison is a bounded prefix of the +# whitespace-normalized text rather than the whole message. +FM_BACKEND_HERDR_PI_QUEUE_KEY_CHARS=${FM_BACKEND_HERDR_PI_QUEUE_KEY_CHARS:-24} + +# fm_backend_herdr_pi_queue_norm: collapse newlines, tabs, and runs of spaces to +# single spaces and trim, so a captured row and the sent text are compared in +# the same shape (Pi re-flows what it renders). +fm_backend_herdr_pi_queue_norm() { # + local norm + norm=$(printf '%s' "$1" | tr '\n\t' ' ' | tr -s ' ') + norm="${norm#"${norm%%[![:space:]]*}"}" + norm="${norm%"${norm##*[![:space:]]}"}" + printf '%s' "$norm" +} + +# fm_backend_herdr_pi_queue_key: the bounded normalized prefix of that a +# queued row must start with to be attributed to this send. Empty when the text +# carries no comparable content, which disables queue confirmation entirely. +fm_backend_herdr_pi_queue_key() { # + local norm + norm=$(fm_backend_herdr_pi_queue_norm "$1") + printf '%s' "${norm:0:$FM_BACKEND_HERDR_PI_QUEUE_KEY_CHARS}" +} + +# fm_backend_herdr_pi_queue_count: how many queued-input rows in +# carry text starting with . Counting (rather than testing presence) is +# what binds confirmation to the CURRENT send: an identical older queued row is +# already in the pre-Enter count, so only an INCREASE proves a new entry. +fm_backend_herdr_pi_queue_count() { # + local cap=$1 key=$2 line plain body count=0 prefix + [ -n "$key" ] || { printf '0'; return 0; } + while IFS= read -r line; do + plain=$(fm_backend_herdr_strip_ansi "$line") + plain=$(fm_backend_herdr_pi_queue_norm "$plain") + body="" + while IFS= read -r prefix; do + [ -n "$prefix" ] || continue + case "$plain" in + "$prefix"*) body=${plain#"$prefix"} ; break ;; + esac + done < + local target=$1 key=$2 before=$3 cap now stripped composer + cap=$(fm_backend_herdr_capture_ansi "$target" "$FM_BACKEND_HERDR_PI_QUEUE_LINES" 2>/dev/null) \ + || { printf 'unknown'; return 0; } + now=$(fm_backend_herdr_pi_queue_count "$cap" "$key") + if [ "$now" -gt "$before" ]; then printf 'queued'; return 0; fi + fm_backend_herdr_pi_composer_find "$cap" + if [ "$FM_BACKEND_HERDR_PI_PAIR_FOUND" -ne 1 ] || [ "$FM_BACKEND_HERDR_PI_PAIR_VALID" -ne 1 ]; then + printf 'unknown'; return 0 + fi + stripped=$(printf '%s\n' "$FM_BACKEND_HERDR_PI_CONTENT" | fm_composer_strip_ghost) + composer=$(fm_composer_classify_content 1 "$stripped" "$FM_BACKEND_HERDR_IDLE_RE") + case "$composer" in + empty) printf 'consumed' ;; + pending) printf 'pending' ;; + *) printf 'unknown' ;; + esac +} + fm_backend_herdr_composer_state() { # -> empty|pending|unknown local target=$1 session pane cap line trimmed found=0 shape="" raw_match="" bordered=0 stripped local identity agent agent_status row=0 generic_line=0 @@ -2057,23 +2156,57 @@ EOF # re-invokes this function from scratch with the same text after seeing # an error, which is a human/escalation decision, not an automatic # retry). -# Echoes empty|pending|unknown|send-failed, a subset of the proof-carrying -# submit vocabulary. Empty means confirmed submitted for every backend; how -# each backend confirms it is an internal decision, and herdr's is no longer -# literally "the composer read empty". +# +# Busy Pi baseline (2026-07-28 incident): a Pi target that is already +# generating ACCEPTS an ordinary instruction into its queue and clears its +# composer, so neither native agent-state (already `working` before Enter, still +# `working` after it) nor composer content can see that the submit landed - the +# send read as unconfirmed even though the instruction was delivered and later +# acted on. Such a target is therefore confirmed from Pi's own queued-input +# rows (fm_backend_herdr_pi_busy_submit_state), bound to THIS send by a new +# matching entry rather than by any row that was already on screen. The +# distinction is transport-generic: input Pi queues is delivered, and input Pi +# consumes immediately without queueing (a built-in command it runs or refuses +# while busy) is reported as busy-unqueued, never as delivery. Nothing on this +# path inspects which command was sent or what Pi printed about it. +# Residual, deliberately conservative: a target that leaves the busy baseline +# before Enter, or whose queued entry is consumed before the confirming read, +# reports non-delivery rather than risking a false success. +# +# Echoes empty|pending|busy-unqueued|unknown|send-failed, a subset of the +# proof-carrying submit vocabulary. Empty means confirmed submitted for every +# backend; how each backend confirms it is an internal decision, and herdr's is +# no longer literally "the composer read empty". 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 identity agent raw_status pi_busy=0 queue_key queue_before cap consumed=0 fm_backend_herdr_parse_target "$target" || { printf 'unknown'; return 0; } fm_backend_herdr_send_literal "$target" "$text" || { printf 'send-failed'; return 0; } sleep "$settle" - baseline=$(fm_backend_herdr_classify_submit_agent_status \ - "$(fm_backend_herdr_agent_status_raw "$FM_BACKEND_HERDR_SESSION" "$FM_BACKEND_HERDR_PANE")") + identity=$(fm_backend_herdr_agent_identity_raw "$FM_BACKEND_HERDR_SESSION" "$FM_BACKEND_HERDR_PANE" 2>/dev/null || true) + # Split on the first tab explicitly: `read` with a tab IFS would drop the + # empty agent field of a pane that reports a status but no agent name. + agent=${identity%%$'\t'*} + raw_status=${identity#*$'\t'} + [ "$identity" != "$raw_status" ] || raw_status="" + baseline=$(fm_backend_herdr_classify_submit_agent_status "$raw_status") confirm_sleep=$(fm_backend_herdr_submit_confirm_budget "$sleep_s") + if [ "$baseline" != idle ] && [ "$agent" = pi ] && [ "$raw_status" = working ]; then + queue_key=$(fm_backend_herdr_pi_queue_key "$text") + if [ -n "$queue_key" ] \ + && cap=$(fm_backend_herdr_capture_ansi "$target" "$FM_BACKEND_HERDR_PI_QUEUE_LINES" 2>/dev/null); then + queue_before=$(fm_backend_herdr_pi_queue_count "$cap" "$queue_key") + pi_busy=1 + fi + fi while :; do fm_backend_herdr_send_key "$target" Enter || true if [ "$baseline" = idle ]; then verdict=$(fm_backend_herdr_wait_for_working "$FM_BACKEND_HERDR_SESSION" "$FM_BACKEND_HERDR_PANE" \ "$confirm_sleep" "$FM_BACKEND_HERDR_SUBMIT_POLLS") + elif [ "$pi_busy" = 1 ]; then + sleep "$sleep_s" + verdict=$(fm_backend_herdr_pi_busy_submit_state "$target" "$queue_key" "$queue_before") else sleep "$sleep_s" verdict=$(fm_backend_herdr_composer_state "$target") @@ -2081,10 +2214,19 @@ fm_backend_herdr_send_text_submit() { # case "$verdict" in busy) printf 'empty'; return 0 ;; empty) printf 'empty'; return 0 ;; + queued) printf 'empty'; return 0 ;; unknown) printf 'unknown'; return 0 ;; + # A consumed submission may still be a queue row that has not rendered + # yet, so the attempt is retried; a retried Enter on the cleared composer + # submits nothing and cannot duplicate the text. + consumed) consumed=1 ;; + *) consumed=0 ;; esac i=$((i + 1)) - [ "$i" -lt "$retries" ] || { printf 'pending'; return 0; } + if [ "$i" -ge "$retries" ]; then + if [ "$consumed" = 1 ]; then printf 'busy-unqueued'; else printf 'pending'; fi + return 0 + fi done } diff --git a/bin/fm-send.sh b/bin/fm-send.sh index dfae6f49e64..d85517aa66b 100755 --- a/bin/fm-send.sh +++ b/bin/fm-send.sh @@ -287,6 +287,13 @@ else echo "error: text not sent to $T ($TARGET_BACKEND send failed; tried $RESOLUTION_TRIED)" >&2 exit 1 ;; + busy-unqueued) + if [ "$PENDING_REPLY_CREATED" = 1 ] && [ -n "$PENDING_REPLY_CORR" ]; then + fm_pending_reply_discard_undelivered "$STATE" "$PENDING_REPLY_CORR" || true + fi + echo "error: text not submitted to $T (the target consumed it while busy without queueing it, so it was not delivered as an instruction and any effect it had is unconfirmed; retry once the target is idle; verdict=$verdict; tried $RESOLUTION_TRIED)" >&2 + exit 1 + ;; *) if [ "$PENDING_REPLY_CREATED" = 1 ] && [ -n "$PENDING_REPLY_CORR" ]; then fm_pending_reply_discard_undelivered "$STATE" "$PENDING_REPLY_CORR" || true diff --git a/docs/herdr-backend.md b/docs/herdr-backend.md index c8c06e5e71a..85bad3c8f8c 100644 --- a/docs/herdr-backend.md +++ b/docs/herdr-backend.md @@ -167,7 +167,10 @@ Slash and dollar-prefixed input uses the shared harness-aware settle before the Text is typed once; only Enter is retried. On an idle or done native baseline, submit confirmation waits for `working` or `blocked` across a bounded polling window. -On an already active or unreadable baseline, it falls back to conservative composer clearance. +On an already generating Pi baseline, native state cannot change and the composer is cleared by the submission itself, so confirmation comes from Pi's own queued-input rows and requires a new entry carrying this send's text. +Input that Pi consumes while busy without queueing it, such as a command it runs or refuses immediately, is reported as unqueued rather than delivered, and the caller is told to retry once the target is idle. +That distinction is transport-generic: no command name, warning text, or other harness message is inspected. +On any other already active or unreadable baseline, confirmation falls back to conservative composer clearance. A fully unreadable target stops retrying and reports unknown. The poll density bounds the residual possibility of an extremely fast complete turn; a missed transition can cause only a redundant Enter on an empty composer, never duplicate message text. @@ -266,6 +269,7 @@ Tests use thin compatibility wrappers in `tests/herdr-test-safety.sh` and never - Mid-session secondmate liveness is not implemented. - OpenCode 1.18.4 can accept Enter while busy without clearing the composer. The tmux backend has a busy-queue fallback, but Herdr still reports this case as submit pending and needs a separate adapter fix. +- A Pi target that stops generating just before its submission lands, or whose queued entry is consumed before the confirming read, is reported as not delivered rather than risking a false success. - Only tmux and Herdr can host the away-mode supervisor terminal. ## Regression entry points diff --git a/tests/fm-backend-herdr.test.sh b/tests/fm-backend-herdr.test.sh index 6c4cec3134d..645331706ba 100755 --- a/tests/fm-backend-herdr.test.sh +++ b/tests/fm-backend-herdr.test.sh @@ -2362,6 +2362,199 @@ test_send_text_submit_unknown_on_capture_failure() { pass "fm_backend_herdr_send_text_submit: reports 'unknown' when the post-Enter agent-get read fails (never retries past an unreadable target)" } +# --- send_text_submit: a Pi target that is ALREADY generating --------------- +# Live-verified incident (2026-07-28, real Pi on herdr 0.7.5): an ordinary +# instruction sent to a Pi agent that was mid-response was ACCEPTED into Pi's +# steering queue and acted on, but fm-send reported it as unsubmitted. The +# busy baseline could only fall back to composer content, and a busy Pi's +# composer is cleared by the very submission being confirmed, so no signal on +# that path could see the delivery. Confirmation now comes from Pi's own queued +# entry rows, bound to this send by a NEW matching entry. +# Fixture shape below is the real Pi-on-herdr rendering: dim truecolor queue +# rows above a dim status row, then the composer between two blue separators. +make_pi_busy_pane() { # [queued-entry ...] + local composer=$1 entry + shift + printf '\x1b[0m\x1b[38;2;212;212;212mtranscript row above the queue\x1b[0m\n' + for entry in "$@"; do + printf '\x1b[0m\x1b[38;2;102;102;102m Steering: %s\x1b[0m\n' "$entry" + done + [ "$#" -eq 0 ] \ + || printf '\x1b[0m\x1b[38;2;102;102;102m \xe2\x86\xb3 ctrl+g to edit all queued messages\x1b[0m\n' + printf ' \x1b[0m\x1b[38;2;138;190;183m\xe2\xa0\xb4\x1b[0m \x1b[0m\x1b[38;2;128;128;128mWorking (medium effort)...\x1b[0m\n' + printf '\x1b[0m\x1b[38;2;129;162;190m─────────────────────────────────────────────────────\x1b[0m\n' + if [ -n "$composer" ]; then + printf '\x1b[0m%s\x1b[7m \x1b[0m\n' "$composer" + else + printf '\x1b[0m\x1b[7m \x1b[0m \n' + fi + printf '\x1b[0m\x1b[38;2;129;162;190m─────────────────────────────────────────────────────\x1b[0m\n' + printf '\x1b[0m\x1b[38;2;102;102;102m~/synthetic-primary (main)\x1b[0m\n' +} + +test_send_text_submit_busy_pi_steering_entry_confirms() { + local dir log resp fb out enter_count type_count + dir="$TMP_ROOT/submit-pi-busy-queued"; mkdir -p "$dir/responses"; log="$dir/log"; resp="$dir/responses"; : > "$log" + # 1: send-text 2: agent get -> pi working (busy baseline) 3: pre-Enter + # capture (text still in the composer, nothing queued) 4: send-keys enter + # 5: post-Enter capture (composer cleared, the text is queued) + printf '{"result":{"agent":{"agent":"pi","agent_status":"working"}}}\n' > "$resp/2.out" + make_pi_busy_pane 'reply with just OK' > "$resp/3.out" + make_pi_busy_pane '' 'reply with just OK' > "$resp/5.out" + fb=$(make_herdr_fakebin "$dir") + out=$( PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" \ + bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_send_text_submit default:w1:p2 "reply with just OK" 3 0.01 0.01' "$ROOT" ) + [ "$out" = empty ] || fail "an instruction accepted into a busy Pi's steering queue must confirm as delivered, got '$out'" + enter_count=$(grep -c $'\x1f''pane'$'\x1f''send-keys'$'\x1f''w1:p2'$'\x1f''enter' "$log") + [ "$enter_count" -eq 1 ] || fail "a queued instruction must not provoke a retry, sent $enter_count Enter(s)" + type_count=$(grep -c $'\x1f''pane'$'\x1f''send-text' "$log") + [ "$type_count" -eq 1 ] || fail "the message must be typed exactly once, typed $type_count time(s)" + pass "fm_backend_herdr_send_text_submit: an instruction accepted into a busy Pi's steering queue confirms as delivered" +} + +test_send_text_submit_busy_pi_late_render_confirms_without_retyping() { + local dir log resp fb out type_count enter_count + dir="$TMP_ROOT/submit-pi-busy-late"; mkdir -p "$dir/responses"; log="$dir/log"; resp="$dir/responses"; : > "$log" + printf '{"result":{"agent":{"agent":"pi","agent_status":"working"}}}\n' > "$resp/2.out" + make_pi_busy_pane 'reply with just OK' > "$resp/3.out" + # The composer is already cleared but the queue row has not rendered yet. + make_pi_busy_pane '' > "$resp/5.out" + make_pi_busy_pane '' 'reply with just OK' > "$resp/7.out" + fb=$(make_herdr_fakebin "$dir") + out=$( PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" \ + bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_send_text_submit default:w1:p2 "reply with just OK" 3 0.01 0.01' "$ROOT" ) + [ "$out" = empty ] || fail "a queue row that renders on a later observation must still confirm, got '$out'" + type_count=$(grep -c $'\x1f''pane'$'\x1f''send-text' "$log") + [ "$type_count" -eq 1 ] || fail "a retried Enter must never retype the message, typed $type_count time(s)" + enter_count=$(grep -c $'\x1f''pane'$'\x1f''send-keys'$'\x1f''w1:p2'$'\x1f''enter' "$log") + [ "$enter_count" -eq 2 ] || fail "expected exactly one retried Enter before the queue row appeared, sent $enter_count" + pass "fm_backend_herdr_send_text_submit: a late-rendering queue row confirms on a retried Enter without ever retyping the message" +} + +# Generic command-rejection coverage: Pi runs (or refuses) a built-in command +# immediately instead of queueing it, clearing the composer either way. The +# adapter must not read that cleared composer as delivery, and must not learn +# any single command name or warning sentence to reach that verdict - both the +# observed `/reload` reproduction and a synthetic command behave identically. +test_send_text_submit_busy_pi_unqueued_command_is_not_delivery() { + local dir log resp fb out type_count enter_count command + for command in '/reload' '/synthetic-busy-command'; do + dir="$TMP_ROOT/submit-pi-busy-unqueued-${command#/}"; mkdir -p "$dir/responses" + log="$dir/log"; resp="$dir/responses"; : > "$log" + printf '{"result":{"agent":{"agent":"pi","agent_status":"working"}}}\n' > "$resp/2.out" + make_pi_busy_pane "$command" > "$resp/3.out" + make_pi_busy_pane '' > "$resp/5.out" + make_pi_busy_pane '' > "$resp/7.out" + fb=$(make_herdr_fakebin "$dir") + out=$( PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" \ + bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_send_text_submit default:w1:p2 "$1" 2 0.01 0.01' "$ROOT" "$command" ) + [ "$out" = busy-unqueued ] || fail "'$command' consumed by a busy Pi without being queued must not read as delivered, got '$out'" + type_count=$(grep -c $'\x1f''pane'$'\x1f''send-text' "$log") + [ "$type_count" -eq 1 ] || fail "'$command' must be typed exactly once, typed $type_count time(s)" + enter_count=$(grep -c $'\x1f''pane'$'\x1f''send-keys'$'\x1f''w1:p2'$'\x1f''enter' "$log") + [ "$enter_count" -eq 2 ] || fail "'$command' should exhaust its Enter retries, sent $enter_count" + done + pass "fm_backend_herdr_send_text_submit: input a busy Pi consumes without queueing reports busy-unqueued for any command, never delivery" +} + +# Criterion: confirmation is bound to THIS send. An identical queue row left +# over from an earlier send is already present before Enter, so it can never +# stand in for a new one. +test_send_text_submit_busy_pi_stale_steering_row_never_confirms() { + local dir log resp fb out case_id post_composer expected + for case_id in composer-holds-text composer-cleared; do + dir="$TMP_ROOT/submit-pi-busy-stale-$case_id"; mkdir -p "$dir/responses" + log="$dir/log"; resp="$dir/responses"; : > "$log" + if [ "$case_id" = composer-holds-text ]; then + post_composer='reply with just OK'; expected=pending + else + post_composer=''; expected=busy-unqueued + fi + printf '{"result":{"agent":{"agent":"pi","agent_status":"working"}}}\n' > "$resp/2.out" + make_pi_busy_pane 'reply with just OK' 'reply with just OK' > "$resp/3.out" + make_pi_busy_pane "$post_composer" 'reply with just OK' > "$resp/5.out" + make_pi_busy_pane "$post_composer" 'reply with just OK' > "$resp/7.out" + fb=$(make_herdr_fakebin "$dir") + out=$( PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" \ + bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_send_text_submit default:w1:p2 "reply with just OK" 2 0.01 0.01' "$ROOT" ) + [ "$out" = "$expected" ] || fail "a pre-existing identical queue row must not confirm case '$case_id', expected '$expected', got '$out'" + done + pass "fm_backend_herdr_send_text_submit: an older identical steering row can never confirm the current send" +} + +test_send_text_submit_busy_pi_unreadable_pane_stays_unknown() { + local dir log resp fb out + dir="$TMP_ROOT/submit-pi-busy-unreadable"; mkdir -p "$dir/responses"; log="$dir/log"; resp="$dir/responses"; : > "$log" + printf '{"result":{"agent":{"agent":"pi","agent_status":"working"}}}\n' > "$resp/2.out" + make_pi_busy_pane 'reply with just OK' > "$resp/3.out" + printf 'no composer structure at all\n' > "$resp/5.out" + fb=$(make_herdr_fakebin "$dir") + out=$( PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" \ + bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_send_text_submit default:w1:p2 "reply with just OK" 3 0.01 0.01' "$ROOT" ) + [ "$out" = unknown ] || fail "an unreadable busy Pi pane must stay unknown, got '$out'" + pass "fm_backend_herdr_send_text_submit: an ambiguous busy Pi pane stays unknown instead of claiming delivery" +} + +test_send_text_submit_idle_pi_keeps_native_agent_state_path() { + local dir log resp fb out + dir="$TMP_ROOT/submit-pi-idle"; mkdir -p "$dir/responses"; log="$dir/log"; resp="$dir/responses"; : > "$log" + printf '{"result":{"agent":{"agent":"pi","agent_status":"idle"}}}\n' > "$resp/2.out" + printf '{"result":{"agent":{"agent":"pi","agent_status":"working"}}}\n' > "$resp/4.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 "hello captain" 3 0.01 0.01' "$ROOT" ) + [ "$out" = empty ] || fail "an idle Pi submit must still confirm from native agent-state, got '$out'" + [ "$(grep -c $'\x1f''pane'$'\x1f''read' "$log")" -eq 0 ] || fail "an idle Pi submit must never read the pane for confirmation" + pass "fm_backend_herdr_send_text_submit: an idle Pi baseline keeps the native agent-state confirmation path unchanged" +} + +test_send_text_submit_busy_non_pi_keeps_composer_fallback() { + local dir log resp fb out read_count + dir="$TMP_ROOT/submit-busy-claude"; mkdir -p "$dir/responses"; log="$dir/log"; resp="$dir/responses"; : > "$log" + printf '{"result":{"agent":{"agent":"claude","agent_status":"working"}}}\n' > "$resp/2.out" + printf ' \xe2\x9d\xaf hello captain\n' > "$resp/4.out" + printf ' \xe2\x9d\xaf hello captain\n' > "$resp/6.out" + fb=$(make_herdr_fakebin "$dir") + out=$( PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" \ + bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_send_text_submit default:w1:p2 "hello captain" 2 0.01 0.01' "$ROOT" ) + [ "$out" = pending ] || fail "a busy non-Pi target must keep its composer-clearance fallback, got '$out'" + read_count=$(grep -c $'\x1f''pane'$'\x1f''read' "$log") + [ "$read_count" -eq 2 ] || fail "a busy non-Pi target should make one composer read per Enter attempt, made $read_count" + pass "fm_backend_herdr_send_text_submit: a busy non-Pi target keeps the existing composer-clearance fallback" +} + +test_fm_send_busy_pi_outcomes() { + local dir log resp fb state neutral rc err + dir="$TMP_ROOT/fm-send-pi-busy"; mkdir -p "$dir/responses" "$dir/state" + log="$dir/log"; resp="$dir/responses"; state="$dir/state"; err="$dir/send.err"; : > "$log" + neutral="$dir/neutral-root"; mkdir -p "$neutral" + fm_write_meta "$state/pi-busy.meta" "window=default:w1:p2" "backend=herdr" "kind=ship" "harness=pi" + printf '{"result":{"agent":{"agent":"pi","agent_status":"working"}}}\n' > "$resp/2.out" + make_pi_busy_pane 'hello captain' > "$resp/3.out" + make_pi_busy_pane '' 'hello captain' > "$resp/5.out" + fb=$(make_herdr_fakebin "$dir") + PATH="$fb:$PATH" FM_ROOT_OVERRIDE="$neutral" FM_HOME="$neutral" FM_STATE_OVERRIDE="$state" \ + FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" FM_SEND_SETTLE=0 \ + "$ROOT/bin/fm-send.sh" pi-busy "hello captain" >/dev/null 2>"$err"; rc=$? + expect_code 0 "$rc" "fm-send should succeed when a busy Pi queues the instruction: $(cat "$err")" + + : > "$log"; rm -f "$resp/.count" "$resp"/*.out + printf '{"result":{"agent":{"agent":"pi","agent_status":"working"}}}\n' > "$resp/2.out" + make_pi_busy_pane '/reload' > "$resp/3.out" + make_pi_busy_pane '' > "$resp/5.out" + make_pi_busy_pane '' > "$resp/7.out" + make_pi_busy_pane '' > "$resp/9.out" + PATH="$fb:$PATH" FM_ROOT_OVERRIDE="$neutral" FM_HOME="$neutral" FM_STATE_OVERRIDE="$state" \ + FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" FM_SEND_SETTLE=0 \ + "$ROOT/bin/fm-send.sh" pi-busy "/reload" >/dev/null 2>"$err"; rc=$? + [ "$rc" -ne 0 ] || fail "fm-send must not report a busy-rejected command as delivered" + assert_contains "$(cat "$err")" "retry once the target is idle" \ + "the busy-unqueued diagnostic should tell the caller to retry when the target is idle" + assert_contains "$(cat "$err")" "verdict=busy-unqueued" "the busy-unqueued diagnostic should name its verdict" + pass "fm-send: a busy Pi's queued instruction succeeds while an unqueued command reports an actionable retry-when-idle failure" +} + + # --- fm-backend.sh dispatch wiring ------------------------------------------- test_dispatch_routes_herdr_backend() { @@ -3179,6 +3372,14 @@ test_composer_state_guard_still_refuses_real_pending_text_after_submit_confirmat test_send_text_submit_slow_transition_within_one_enter_needs_no_extra_enter test_send_text_submit_send_failed test_send_text_submit_unknown_on_capture_failure +test_send_text_submit_busy_pi_steering_entry_confirms +test_send_text_submit_busy_pi_late_render_confirms_without_retyping +test_send_text_submit_busy_pi_unqueued_command_is_not_delivery +test_send_text_submit_busy_pi_stale_steering_row_never_confirms +test_send_text_submit_busy_pi_unreadable_pane_stays_unknown +test_send_text_submit_idle_pi_keeps_native_agent_state_path +test_send_text_submit_busy_non_pi_keeps_composer_fallback +test_fm_send_busy_pi_outcomes test_dispatch_routes_herdr_backend test_dispatch_busy_state_unknown_for_tmux test_agent_confirmed_absent_dead_agent_with_busy_frame From 73f1a2f337ccd0c46ec05fcf5b9cd2ce36e8070e Mon Sep 17 00:00:00 2001 From: knowttl Date: Tue, 28 Jul 2026 16:56:14 -0700 Subject: [PATCH 2/3] no-mistakes(review): bind Pi queue key to first line, fix identity split and inject log --- bin/backends/herdr.sh | 24 +++++++++++++++++------- bin/fm-supervise-daemon.sh | 6 +++++- tests/fm-backend-herdr.test.sh | 31 +++++++++++++++++++++++++++++++ 3 files changed, 53 insertions(+), 8 deletions(-) diff --git a/bin/backends/herdr.sh b/bin/backends/herdr.sh index f7d4634a730..8d5003d455a 100644 --- a/bin/backends/herdr.sh +++ b/bin/backends/herdr.sh @@ -1932,11 +1932,19 @@ fm_backend_herdr_pi_queue_norm() { # } # fm_backend_herdr_pi_queue_key: the bounded normalized prefix of that a -# queued row must start with to be attributed to this send. Empty when the text -# carries no comparable content, which disables queue confirmation entirely. +# queued row must start with to be attributed to this send. Only the first +# non-blank LINE of the text feeds the key: a queued row that renders just that +# line and one that re-flows the whole message both START with it, so the key +# stays valid under either rendering. Empty when the text carries no comparable +# content, which disables queue confirmation entirely. fm_backend_herdr_pi_queue_key() { # - local norm - norm=$(fm_backend_herdr_pi_queue_norm "$1") + local line norm="" + while IFS= read -r line; do + norm=$(fm_backend_herdr_pi_queue_norm "$line") + [ -z "$norm" ] || break + done < -> empty|pending|unknown && [ "$FM_BACKEND_HERDR_PI_PAIR_LINE" -gt "$generic_line" ] \ && [ "$generic_line" -lt "$FM_BACKEND_HERDR_PI_PAIR_OPEN_LINE" ]; then identity=$(fm_backend_herdr_agent_identity_raw "$session" "$pane" 2>/dev/null || true) - IFS=$'\t' read -r agent agent_status < [state] if [ "$verdict" = empty ]; then return 0 # Backend confirmed the submit. fi - log "inject failed: submit unconfirmed after $retries retries (verdict=$verdict, text may be in composer)" + if [ "$verdict" = busy-unqueued ]; then + log "inject failed: busy target consumed the text without queueing it (verdict=$verdict, composer is clear; retry when idle)" + else + log "inject failed: submit unconfirmed after $retries retries (verdict=$verdict, text may be in composer)" + fi return 1 } diff --git a/tests/fm-backend-herdr.test.sh b/tests/fm-backend-herdr.test.sh index 645331706ba..f420d6d03d3 100755 --- a/tests/fm-backend-herdr.test.sh +++ b/tests/fm-backend-herdr.test.sh @@ -2482,6 +2482,36 @@ test_send_text_submit_busy_pi_stale_steering_row_never_confirms() { pass "fm_backend_herdr_send_text_submit: an older identical steering row can never confirm the current send" } +# Criterion: a multiline submission whose FIRST line is shorter than the queue +# key bound must still confirm. Pi may render only that first line in the queued +# row, so a key drawn from the whole re-flowed message could never match it. The +# stale-row half of the case proves the shorter key stays bound to this send. +test_send_text_submit_busy_pi_multiline_short_first_line_confirms() { + local dir log resp fb out msg case_id expected type_count + msg=$'Escalation\nqueue depth is 4 and the reviewer has been waiting 20 minutes' + for case_id in new-row stale-row-only; do + dir="$TMP_ROOT/submit-pi-busy-multiline-$case_id"; mkdir -p "$dir/responses" + log="$dir/log"; resp="$dir/responses"; : > "$log" + printf '{"result":{"agent":{"agent":"pi","agent_status":"working"}}}\n' > "$resp/2.out" + make_pi_busy_pane 'Escalation' 'Escalation' > "$resp/3.out" + if [ "$case_id" = new-row ]; then + expected=empty + make_pi_busy_pane '' 'Escalation' 'Escalation' > "$resp/5.out" + else + expected=busy-unqueued + make_pi_busy_pane '' 'Escalation' > "$resp/5.out" + make_pi_busy_pane '' 'Escalation' > "$resp/7.out" + fi + fb=$(make_herdr_fakebin "$dir") + out=$( PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" \ + bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_send_text_submit default:w1:p2 "$1" 2 0.01 0.01' "$ROOT" "$msg" ) + [ "$out" = "$expected" ] || fail "a multiline send with a short first line and a stale matching row must report '$expected' for case '$case_id', got '$out'" + type_count=$(grep -c $'\x1f''pane'$'\x1f''send-text' "$log") + [ "$type_count" -eq 1 ] || fail "case '$case_id' must type the message exactly once, typed $type_count time(s)" + done + pass "fm_backend_herdr_send_text_submit: a multiline send whose first line is shorter than the queue key bound confirms from a new queued row only" +} + test_send_text_submit_busy_pi_unreadable_pane_stays_unknown() { local dir log resp fb out dir="$TMP_ROOT/submit-pi-busy-unreadable"; mkdir -p "$dir/responses"; log="$dir/log"; resp="$dir/responses"; : > "$log" @@ -3376,6 +3406,7 @@ test_send_text_submit_busy_pi_steering_entry_confirms test_send_text_submit_busy_pi_late_render_confirms_without_retyping test_send_text_submit_busy_pi_unqueued_command_is_not_delivery test_send_text_submit_busy_pi_stale_steering_row_never_confirms +test_send_text_submit_busy_pi_multiline_short_first_line_confirms test_send_text_submit_busy_pi_unreadable_pane_stays_unknown test_send_text_submit_idle_pi_keeps_native_agent_state_path test_send_text_submit_busy_non_pi_keeps_composer_fallback From c8dca18d4b0f1f392c86565db3e4fad43c62a8c3 Mon Sep 17 00:00:00 2001 From: knowttl Date: Tue, 28 Jul 2026 17:09:08 -0700 Subject: [PATCH 3/3] no-mistakes(document): document busy-Pi steering-queue submit confirmation --- .agents/skills/afk/SKILL.md | 3 ++- .agents/skills/harness-adapters/SKILL.md | 5 +++++ docs/architecture.md | 3 ++- docs/configuration.md | 3 +++ 4 files changed, 12 insertions(+), 2 deletions(-) diff --git a/.agents/skills/afk/SKILL.md b/.agents/skills/afk/SKILL.md index 95f64b11e03..e816d134c70 100644 --- a/.agents/skills/afk/SKILL.md +++ b/.agents/skills/afk/SKILL.md @@ -203,8 +203,9 @@ the operational prefix lets firstmate distinguish it from a real captain message primitive reports `empty` as its caller-facing success verdict. For tmux that verdict means the shared-ghost-aware and border-aware composer cleared. - For herdr's normal idle-baseline path it means native agent-state observed a real turn start; herdr uses the ANSI-aware structural classifier for the pre-injection composer guard and fallback paths. + For herdr's normal idle-baseline path it means native agent-state observed a real turn start; herdr uses the ANSI-aware structural classifier for the pre-injection composer guard and its remaining fallback paths. This lets ghost-only or bordered-empty composers count as empty where a composer read is the active confirmation signal. + An already generating Pi target is confirmed from Pi's queued-input rows instead, and input such a target consumed without queueing fails the inject with a distinct retry-when-idle log line, per [`docs/herdr-backend.md`](../../../docs/herdr-backend.md#current-transport-behavior). - **Marker strip** - `strip_injection_marker` removes the current operational prefix or legacy bare marker before classification or relay, so the digest text firstmate sees is clean. diff --git a/.agents/skills/harness-adapters/SKILL.md b/.agents/skills/harness-adapters/SKILL.md index 429907041a3..8c4263ac6c7 100644 --- a/.agents/skills/harness-adapters/SKILL.md +++ b/.agents/skills/harness-adapters/SKILL.md @@ -288,6 +288,11 @@ The decision persists per path in `~/.pi/agent/trust.json`, so later spawns in t The extension must listen for pi's `turn_end` event, not `agent_end`, so the watcher wakes after each completed turn instead of only when the whole agent run exits. Pi sets `PI_CODING_AGENT=true` for its children; this is its harness-detection env marker. +**Busy-input behavior (verified 2026-07-28).** +A Pi target that is mid-turn accepts an ordinary instruction: it enters Pi's visible steering queue as a de-emphasized `Steering: ` row above the composer, the composer clears immediately, and Pi acts on the entry after the current turn. +Other input, such as a built-in command Pi runs or refuses while busy, is consumed without ever becoming a queued row, so it is not delivered as an instruction and must be resent once the target is idle. +`docs/herdr-backend.md` "Current transport behavior" owns how submit confirmation distinguishes the two and what `fm-send` reports for each. + **Primary-session guard fact (verified 2026-07-09, Pi 0.80.5).** The firstmate PRIMARY's own `.pi/extensions/fm-primary-turnend-guard.ts` listens for logical-run `agent_settled`, not per-tool-loop `turn_end`, and uses `pi.sendUserMessage(..., { deliverAs: "followUp" })` to force one guarded follow-up when `bin/fm-turnend-guard.sh` returns 2. Without `deliverAs: "followUp"`, Pi rejects the send while the agent is still processing. diff --git a/docs/architecture.md b/docs/architecture.md index d1bcb83c565..265c7c95118 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -76,7 +76,8 @@ The always-on watcher also uses that library's absorb classification on no-verb In away mode, seen-status dedupe does not clear possible-wedge aging for nonterminal progress, so housekeeping still re-escalates an unchanged idle pane at the configured bound. The daemon escalates captain-relevant events, plus a bounded recheck for a declared pause that remains idle, as one batched, single-line digest using the canonical `away-supervisor` kind from `bin/fm-operational-input.sh` so firstmate can distinguish it structurally from real messages. Its supervisor injection path supports tmux and herdr panes, with `FM_SUPERVISOR_BACKEND` and `FM_SUPERVISOR_TARGET` resolved independently from the task-spawn backend. -Pane existence, busy checks, composer checks, capture, and verified submit route through `bin/fm-backend.sh`: tmux keeps the same submit core used by the tmux send backend, while herdr uses native busy state, native agent-state submit confirmation on idle baselines, and its ANSI-aware structural composer classifier for pending-input guards and submit fallback. +Pane existence, busy checks, composer checks, capture, and verified submit route through `bin/fm-backend.sh`: tmux keeps the same submit core used by the tmux send backend, while herdr uses native busy state, native agent-state submit confirmation on idle baselines, Pi's own queued-input rows on an already generating Pi baseline, and its ANSI-aware structural composer classifier for pending-input guards and every other submit fallback. +That busy-baseline submit boundary, including the unqueued outcome that makes `fm-send` and the daemon report a retry-when-idle failure instead of delivery, is owned by [`herdr-backend.md`](herdr-backend.md#current-transport-behavior). The tmux submit core (shared `fm_tmux_submit_enter_core`) treats a busy pane + retries-exhausted + composer-still-pending as a queued Enter (opencode 1.18.4 accepts Enter mid-turn and queues it for after the turn), reported as `empty` so the daemon and `fm-send` do not re-send; an idle pane keeps the `pending` verdict as a genuine swallow. The same opencode busy-queue case is a known gap on the herdr adapter and is recorded in `docs/herdr-backend.md` rather than patched here. Composer-content classification has one shared owner, `bin/fm-composer-lib.sh`, used by tmux, herdr, Orca, and cmux after each adapter performs its own capture and composer-row recognition. The daemon injects only into an affirmatively `empty` composer, so both `pending` and `unknown` defer and a bare dead-shell prompt cannot receive an escalation; the current boundary is in [Composer and injection safety](herdr-backend.md#composer-and-injection-safety). diff --git a/docs/configuration.md b/docs/configuration.md index d9a06bf4cad..ee2d89b0dcf 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -381,6 +381,9 @@ FM_BACKEND_HERDR_COMPOSER_LINES=20 # herdr-only: tail lines scanned by composer FM_BACKEND_HERDR_IDLE_RE='^Type a message\.\.\.$' # herdr-only: empty-composer placeholder regex after shared ghost extraction plus border and prompt stripping FM_BACKEND_HERDR_BARE_PROMPT_RE='^[❯›]' # herdr-only: verified agent glyphs recognized as an UNBORDERED (bare) composer row, e.g. Claude's ❯ or Codex's ›; shell glyphs remain unknown rather than empty, and de-emphasised ghost/placeholder text reads empty through shared fm_composer_strip_ghost (docs/herdr-backend.md "Composer and injection safety") FM_BACKEND_HERDR_PI_COMPOSER_MAX_LINES=8 # herdr-only: maximum rows admitted between Pi's native-identity-corroborated separator pair; taller or ambiguous candidates stay unknown (docs/herdr-backend.md "Composer and injection safety") +FM_BACKEND_HERDR_PI_QUEUE_LINES=40 # herdr-only: tail lines scanned for Pi's queued-input rows when confirming a submit to an already generating Pi target (docs/herdr-backend.md "Current transport behavior") +FM_BACKEND_HERDR_PI_QUEUE_PREFIXES='Steering: |Follow-up: ' # herdr-only: pipe-separated row prefixes Pi renders for input it queued while busy; extend it if Pi adds another queued-input row shape +FM_BACKEND_HERDR_PI_QUEUE_KEY_CHARS=24 # herdr-only: how many leading characters of the sent text a queued row must carry before it confirms that send, since Pi truncates a queued row to the pane width FM_BACKEND_HERDR_SUBMIT_POLLS=6 # herdr-only: agent-state samples spread across each Enter attempt's budget when confirming a submit (docs/herdr-backend.md "Current transport behavior") FM_BACKEND_HERDR_SUBMIT_MIN_SLEEP=0.6 # herdr-only: minimum per-Enter confirmation budget before polling agent-state after an idle baseline FM_BACKEND_ORCA_COMPOSER_LINES=200 # orca-only: terminal-read lines scanned to locate the composer row for submit verification