From 9f0129de36867a33e1e9e074d905bba71045b586 Mon Sep 17 00:00:00 2001 From: Pranay Pratyush Date: Mon, 31 Aug 2026 16:20:08 +0530 Subject: [PATCH] fix: defer Herdr away injection without atomic admission --- bin/backends/herdr.sh | 12 ++ bin/fm-supervise-daemon.sh | 93 ++++++----- docs/architecture.md | 11 +- docs/herdr-backend.md | 4 + docs/verification/runtime-backends.md | 20 ++- tests/fm-afk-inject-herdr-e2e.test.sh | 202 ++++++++--------------- tests/fm-afk-pi-herdr-return-e2e.test.sh | 32 ++-- tests/fm-backend-herdr.test.sh | 14 ++ tests/fm-daemon.test.sh | 57 +++++-- 9 files changed, 226 insertions(+), 219 deletions(-) diff --git a/bin/backends/herdr.sh b/bin/backends/herdr.sh index 55d4f684a1e..48438c0f8e6 100644 --- a/bin/backends/herdr.sh +++ b/bin/backends/herdr.sh @@ -2876,6 +2876,18 @@ EOF fm_composer_classify_content "$bordered" "$stripped" "$FM_BACKEND_HERDR_IDLE_RE" } +# fm_backend_herdr_admit_away_supervisor: atomically admit and submit one +# away-supervisor message, or refuse without changing the composer. +# Herdr exposes no verified conditional composer admission or reservation API. +# This fallback therefore performs no pane read, text send, or key send and +# returns atomic-unavailable. A future implementation may return admitted only +# after one Herdr-supported operation atomically proves an empty composer and +# submits ; a re-read or process-local lock is not an admission primitive. +fm_backend_herdr_admit_away_supervisor() { # -> admitted|atomic-unavailable|unknown + printf 'atomic-unavailable' +} + + # fm_backend_herdr_send_text_submit: type into once (raw, diff --git a/bin/fm-supervise-daemon.sh b/bin/fm-supervise-daemon.sh index cbee7cd0997..4b5d6774108 100755 --- a/bin/fm-supervise-daemon.sh +++ b/bin/fm-supervise-daemon.sh @@ -91,8 +91,9 @@ # FM_SUPERVISOR_HARNESS exact supervisor harness identity. The # detached launcher passes this explicitly; # otherwise startup derives it with -# bin/fm-harness.sh. Herdr injection refuses -# when the identity is unknown. +# bin/fm-harness.sh. Herdr defers typed +# injection when its API cannot prove atomic +# composer admission. # FM_INJECT_SKIP |-prefixes force-self-handle bypassing # classification (default "heartbeat"); empty # disables. Use sparingly: it overrides the @@ -138,16 +139,17 @@ # its watchdog terminates it and continues to the # next channel (default 10; invalid/zero uses the # default). -# FM_INJECT_CONFIRM_RETRIES Enter-retry attempts on a swallowed Enter -# (default 3); the digest is typed once, only -# Enter is retried. Composer-empty detection is -# structural and style-aware (bin/fm-tmux-lib.sh): -# it drops dim/faint ghost text and strips the +# FM_INJECT_CONFIRM_RETRIES tmux Enter-retry attempts on a swallowed +# Enter (default 3); the digest is typed once, +# only Enter is retried. Herdr uses its own +# atomic-admission contract and never treats +# this retry budget as permission to type. +# Composer-empty detection is structural and +# style-aware (bin/fm-tmux-lib.sh): it drops +# dim/faint ghost text and strips the # harness's box borders before deciding, so a # ghost-only or bordered-but-empty composer is # not misread as pending input. -# FM_INJECT_CONFIRM_SLEEP seconds between daemon submit checks -# (default 0.5) # FM_LOG_MAX_BYTES / FM_LOG_KEEP_LINES / FM_CRASH_* log + crash guards # FM_STATE_OVERRIDE alternate state dir (testing) # Logs each wake to state/.supervise-daemon.log (size-capped). Single @@ -1374,25 +1376,20 @@ window_for_task() { # [state] } # --- injection -------------------------------------------------------------- -# inject_msg: send one escalation digest to the supervisor pane. -# Returns 0 on successful inject (or empty buffer), non-zero if the pane is -# gone, the supervisor is busy, afk is inactive, or the verified submit cannot -# be confirmed after bounded retries. On non-zero the caller preserves -# the buffer so the escalation survives for the next cycle or the catch-up flush. +# inject_msg: admit one escalation digest to the supervisor pane. +# Returns 0 on successful admission (or empty buffer), non-zero if the pane is +# gone, the supervisor is busy, afk is inactive, or the backend cannot safely +# admit the digest. On non-zero the caller preserves the buffer so the +# escalation survives for the next cycle or the catch-up flush. # -# Submit model: -# - TYPE ONCE, then submit with Enter. Never retype the digest: a swallowed -# Enter leaves our text in the composer, and retyping would concatenate two -# sentinel-prefixed digests into one corrupted turn. -# - SUBMIT ACK = the backend submit primitive reports `empty` after Enter. -# For tmux that means a cleared composer; for herdr's normal idle-baseline -# path it means native agent-state observed a real turn start. -# Pending means Enter was swallowed; unknown is treated as undelivered by -# this strict daemon path. -# - COMPOSER GUARD before typing: if the cursor line already has real content -# after dim/faint ghost text and borders are ignored (a human's half-typed -# line, or a previous injection's unsent text), defer entirely - injecting -# would merge with the human's text. +# Admission model: +# - The shared composer guard rejects every state except affirmative empty. +# It is an early conservative rejection, not a reservation of the human +# input channel. +# - tmux types once, then retries only Enter after a bounded confirmation. +# - Herdr delegates the complete empty-composer admission decision to +# fm_backend_herdr_admit_away_supervisor. Its current fallback refuses +# typed admission because Herdr has no verified conditional operation. inject_msg() { # [state] local msg=$1 state target backend harness retries sleep_s verdict composer encoded omp_bun omp_bin identity state="${2:-$(_state_root)}" @@ -1420,15 +1417,15 @@ inject_msg() { # [state] log "inject deferred: supervisor pane busy (agent mid-turn)" return 1 fi - # b) Composer-guard: inject ONLY into a confirmed-empty GENUINE agent - # composer. The shared classifier (fm_backend_composer_state -> - # fm_composer_classify_content, bin/fm-composer-lib.sh) reports 'pending' - # for real unsubmitted text (a human's half-typed line, or a swallowed - # prior injection) and 'unknown' for a bare dead-shell prompt (the agent - # exited to its login shell) or an unreadable pane. Neither is a safe - # target - typing the escalation into a shell could execute it - so defer - # on anything that is not affirmatively 'empty'. A deferred escalation - # stays buffered for the next cycle or the catch-up flush. + # b) Composer-guard: reject any composer that is not a confirmed-empty + # GENUINE agent composer. The shared classifier + # (fm_backend_composer_state -> fm_composer_classify_content, + # bin/fm-composer-lib.sh) reports 'pending' for real unsubmitted text (a + # human's half-typed line, or a swallowed prior injection) and 'unknown' + # for a bare dead-shell prompt or an unreadable pane. Neither is safe. + # This guard remains an early conservative rejection only. For Herdr, + # `empty` never reserves the composer or authorizes a later typed send; + # its backend admission operation owns that decision. identity=$(supervisor_omp_identity "$state") IFS=$'\t' read -r omp_bun omp_bin < enter` call and forwards everything else to the real -# binary untouched. +# socket), Herdr already supports named-session isolation via --session. The +# daemon is pointed at FM_SUPERVISOR_BACKEND=herdr, +# FM_SUPERVISOR_TARGET=":", and HERDR_SESSION="". The current Herdr API has no atomic composer admission primitive, +# so this suite proves that both a pre-existing draft and a newly idle composer +# preserve the escalation without typing into the captain's input channel. # # The "supervisor pane" is a tiny deterministic bash loop (not a real harness # binary): it draws a bordered composer row ("│ > │") that exercises the # bordered branch of fm_backend_herdr_composer_state, and logs every submitted # line (hex + text + injection/user classification) - the same technique # tests/fm-afk-inject-e2e.test.sh uses for its tmux supervisor pane, so this -# test asserts on submitted CONTENT, not pane appearance. It ALSO registers -# itself as a real herdr agent via `herdr pane report-agent` and reports an -# idle/working/idle cycle around each submission, because -# fm_backend_herdr_send_text_submit's confirmation is native agent-state -# (agent get), not composer content, since the 2026-07-07 incident fix -# (docs/herdr-backend.md "Native agent-state submit confirmation") - a pane -# that only draws composer text without being a registered agent would read -# agent_not_found forever and never confirm a submission. +# test asserts on submitted content, not pane appearance. It registers itself +# as a real Herdr agent so the production busy and composer readers observe an +# actual agent identity. The fixture submits only explicit human input; every +# supervisor escalation must remain out of the composer until Herdr exposes a +# verified atomic admission primitive. set -u ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" @@ -38,21 +30,17 @@ DAEMON="$ROOT/bin/fm-supervise-daemon.sh" command -v herdr >/dev/null 2>&1 || { echo "skip: herdr not found"; exit 0; } command -v jq >/dev/null 2>&1 || { echo "skip: jq not found (required by the herdr adapter)"; exit 0; } -# shellcheck source=tests/herdr-test-safety.sh -. "$ROOT/tests/herdr-test-safety.sh" - -# This suite runs against its own isolated lab session, so a Herdr pane -# inherited from the terminal it was launched in must not follow spawn into it -# as a cross-session parent identity (tests/herdr-test-safety.sh). -herdr_forget_inherited_pane +ORIGINAL_PATH=$PATH +unset HERDR_ENV HERDR_PANE_ID HERDR_TAB_ID HERDR_WORKSPACE_ID HERDR_SOCKET_PATH HERDR_SESSION +LAB_HELPER=${HERDR_LAB_HELPER:-"$ROOT/bin/fm-herdr-lab.sh"} +SESSION=$(PATH="$ORIGINAL_PATH" "$LAB_HELPER" name fm-afk-inject-herdr-e2e) +export HERDR_SESSION="$SESSION" fail() { printf 'not ok - %s\n' "$1" >&2; cleanup_all; exit 1; } pass() { printf 'ok - %s\n' "$1"; } -SESSION="fm-lab-afk-herdr-e2e-$$" -export HERDR_SESSION="$SESSION" STATE_DIR= -HERDR_SHIM_DIR= +HERDR_WRAPPER_DIR= LOG_FILE= DAEMON_PID= SUPERVISOR_TARGET= @@ -60,19 +48,43 @@ PANE_ID= LOOP_SCRIPT= cleanup_all() { + local rc=$? + trap - EXIT if [ -n "${DAEMON_PID:-}" ]; then afk_exit "${STATE_DIR:-}" 2>/dev/null || true kill "$DAEMON_PID" 2>/dev/null || true wait "$DAEMON_PID" 2>/dev/null || true fi - herdr_safe_stop_and_delete "$SESSION" 2>/dev/null || true - rm -rf "${HERDR_SHIM_DIR:-}" 2>/dev/null || true - rm -rf "${STATE_DIR:-}" 2>/dev/null || true + if ! PATH="$ORIGINAL_PATH" "$LAB_HELPER" teardown "$SESSION"; then + printf 'not ok - guarded Herdr lab teardown failed\n' >&2 + return 1 + fi + rm -rf "${HERDR_WRAPPER_DIR:-}" "${STATE_DIR:-}" 2>/dev/null || true + return "$rc" } trap cleanup_all EXIT -fm_herdr_lab_prepare "$SESSION" || fail "could not prepare isolated Herdr lab session" +PATH="$ORIGINAL_PATH" "$LAB_HELPER" provision "$SESSION" || fail "could not provision isolated Herdr lab session" -# --- source the daemon (for afk_enter/afk_exit/FM_INJECT_MARK) + the backend - +HERDR_WRAPPER_DIR=$(mktemp -d "${TMPDIR:-/tmp}/fm-herdr-wrapper.XXXXXX") +cat > "$HERDR_WRAPPER_DIR/herdr" <&2; exit 97; } + args=("\${args[@]:0:\$((n-2))}") +else + [ "\${HERDR_SESSION:-}" = "\$session" ] || { echo 'wrapper requires isolated session' >&2; exit 98; } +fi +PATH="\$real_path" exec "\$helper" run "\$session" "\${args[@]}" +EOF +chmod +x "$HERDR_WRAPPER_DIR/herdr" +PATH="$HERDR_WRAPPER_DIR:$ORIGINAL_PATH" +export PATH # shellcheck source=/dev/null . "$DAEMON" fm_backend_source herdr || fail "fm_backend_source herdr failed" @@ -232,22 +244,6 @@ fm_backend_herdr_send_text_line "$SUPERVISOR_TARGET" "bash '$LOOP_SCRIPT' '$LOG_ || fail "could not start the supervisor-loop script in the scratch herdr pane" sleep 1 # let the loop start and settle -# --- herdr shim: forwards to the real binary, optionally swallows one Enter -- -REAL_HERDR=$(command -v herdr) -HERDR_SHIM_DIR=$(mktemp -d "${TMPDIR:-/tmp}/fm-herdr-shim.XXXXXX") -cat > "$HERDR_SHIM_DIR/herdr" </dev/null || true : > "$LOG_FILE" } @@ -328,7 +322,7 @@ selfcheck_pane_input_pending() { fm_backend_herdr_send_literal "$SUPERVISOR_TARGET" "$check_text" \ || fail "selfcheck: could not send literal text to the scratch pane" sleep 0.5 - if PATH="$HERDR_SHIM_DIR:$PATH" pane_input_pending "$SUPERVISOR_TARGET" herdr; then + if HERDR_SESSION="$SESSION" pane_input_pending "$SUPERVISOR_TARGET" herdr; then fm_backend_herdr_send_key "$SUPERVISOR_TARGET" Enter sleep 0.5 return 0 @@ -370,8 +364,11 @@ test_scenario_a() { grep -q 'human draft text' "$LOG_FILE" \ || fail "Scenario A: human text not in log after submit" - grep -q 'Supervisor escalate' "$LOG_FILE" \ - || fail "Scenario A: digest not injected after the pane went idle" + [ -s "$STATE_DIR/.subsuper-escalations" ] \ + || fail "Scenario A: the pending-then-idle composer lost the deferred escalation" + if grep -q 'Supervisor escalate' "$LOG_FILE"; then + fail "Scenario A: daemon injected after the human draft was cleared" + fi if grep -q 'human draft text.*Supervisor escalate' "$LOG_FILE" || \ grep -q 'Supervisor escalate.*human draft text' "$LOG_FILE"; then fail "Scenario A: human text and digest merged into one line (after idle)" @@ -384,89 +381,33 @@ test_scenario_a() { *) fail "Scenario A: human text misclassified (expected user): $human_line" ;; esac - local digest_line - digest_line=$(grep 'Supervisor escalate' "$LOG_FILE" | head -1) - case "$digest_line" in - *injection) ;; - *) fail "Scenario A: digest misclassified (expected injection): $digest_line" ;; - esac - stop_daemon - pass "real herdr Scenario A: partial input defers injection; digest arrives clean after idle" + pass "real herdr Scenario A: pending input defers, and a later empty composer still preserves the digest without typing" } -# --- Scenario B: swallowed-Enter -------------------------------------------- +# --- Scenario B: empty composer without atomic admission --------------------- -test_scenario_b() { +test_scenario_b_atomic_admission_unavailable() { reset_state afk_enter "$STATE_DIR" - - touch "$STATE_DIR/.swallow-enter" - start_daemon echo "done: PR https://example.test/pr/200" > "$STATE_DIR/fake-c1.status" - - sleep 10 - - local marker_count - marker_count=$(awk -F '\t' '{ hex=$1; count += gsub(/e281a3/, "", hex) } END { print count + 0 }' "$LOG_FILE") - [ "$marker_count" -eq 1 ] \ - || fail "Scenario B: expected exactly 1 U+2063 marker, got $marker_count (duplicate or lost)" - - local digest_line digest_hex - digest_line=$(grep 'Supervisor escalate' "$LOG_FILE" | head -1) - digest_hex=$(printf '%s' "$digest_line" | cut -f1) - case "$digest_hex" in - e281a3*) ;; - *) fail "Scenario B: digest does not start with the terminal-safe sentinel marker (hex: $digest_hex)" ;; - esac - - local user_count - user_count=$(grep -c $'\tuser$' "$LOG_FILE" || true) - [ "$user_count" -eq 0 ] \ - || fail "Scenario B: expected 0 user lines, got $user_count (spurious Enter submitted an empty line?)" - - stop_daemon - pass "real herdr Scenario B: swallowed Enter (via the herdr shim) produces exactly one clean digest" -} - -# --- Scenario C: normal digest ----------------------------------------------- - -test_scenario_c() { - reset_state - afk_enter "$STATE_DIR" - start_daemon - - echo "done: PR https://example.test/pr/300" > "$STATE_DIR/fake-c1.status" sleep 8 - local marker_count - marker_count=$(awk -F '\t' '{ hex=$1; count += gsub(/e281a3/, "", hex) } END { print count + 0 }' "$LOG_FILE") - [ "$marker_count" -eq 1 ] \ - || fail "Scenario C: expected exactly 1 U+2063 marker, got $marker_count" - - local digest_line digest_hex - digest_line=$(grep 'Supervisor escalate' "$LOG_FILE" | head -1) - case "$digest_line" in - *injection) ;; - *) fail "Scenario C: digest misclassified (expected injection): $digest_line" ;; - esac - digest_hex=$(printf '%s' "$digest_line" | cut -f1) - case "$digest_hex" in - e281a3*) ;; - *) fail "Scenario C: digest does not start with the terminal-safe sentinel marker (hex: $digest_hex)" ;; - esac - - local user_count - user_count=$(grep -c $'\tuser$' "$LOG_FILE" || true) - [ "$user_count" -eq 0 ] \ - || fail "Scenario C: expected 0 user lines, got $user_count (spurious submission?)" + [ ! -s "$LOG_FILE" ] \ + || fail "Scenario B: Herdr submitted text despite lacking atomic composer admission" + [ -s "$STATE_DIR/.subsuper-escalations" ] \ + || fail "Scenario B: atomic-admission deferral lost the escalation" + grep -F "Herdr API has no verified atomic composer admission; no text typed" \ + "$STATE_DIR/.supervise-daemon.log" >/dev/null \ + || fail "Scenario B: atomic-admission deferral was not recorded truthfully" stop_daemon - pass "real herdr Scenario C: a normal captain status injects exactly one clean single-line sentinel digest" + pass "real herdr Scenario B: an idle composer has no typed supervisor injection without atomic admission" } + # --- Scenario D: max-defer alarm on a persistently non-clearing composer ----- # A pending composer that NEVER clears (every Enter attempt leaves real text # behind) must never be silently swallowed: the daemon must alarm (write @@ -484,7 +425,6 @@ test_scenario_d_max_defer() { fm_backend_herdr_send_literal "$SUPERVISOR_TARGET" "stuck-in-the-box" sleep 0.5 - PATH="$HERDR_SHIM_DIR:$PATH" \ HERDR_SESSION="$SESSION" \ FM_STATE_OVERRIDE="$STATE_DIR" \ FM_SUPERVISOR_BACKEND=herdr \ @@ -525,13 +465,11 @@ test_scenario_d_max_defer() { } test_scenario_a -test_scenario_b -test_scenario_c +test_scenario_b_atomic_admission_unavailable test_scenario_d_max_defer echo "all real-herdr afk injection e2e tests passed" fm_backend_herdr_kill "$SUPERVISOR_TARGET" 2>/dev/null || true fm_backend_herdr_kill "$SESSION:$FAKE_CREW_PANE_ID" 2>/dev/null || true -cleanup_all -trap - EXIT +cleanup_all || exit 1 diff --git a/tests/fm-afk-pi-herdr-return-e2e.test.sh b/tests/fm-afk-pi-herdr-return-e2e.test.sh index 98c826085f7..75d91eb04c4 100755 --- a/tests/fm-afk-pi-herdr-return-e2e.test.sh +++ b/tests/fm-afk-pi-herdr-return-e2e.test.sh @@ -6,9 +6,9 @@ # Herdr call is routed through fm-herdr-lab.sh. The scenario proves: # - a live blocked status is classified and durably queued while away; # - a pending Pi composer refuses injection and receives no forced Enter; +# - clearing the draft retains the recovery episode because Herdr has no +# verified atomic composer-admission primitive; # - the existing wedge alarm remains observable and deduped; -# - clearing the draft makes the genuinely idle Pi composer injectable; -# - verified submit preserves the terminal-safe marker and clears delivery state; # - an unmarked return request opens the catch-up gate before Bearings; # - remediation/resolution clears the gate, and re-entry is idempotent. set -u @@ -218,7 +218,8 @@ assert_blocker_open 'while the Pi composer was pending' pass "real Pi/Herdr pending composer refuses injection without forced submit and raises one observable fallback" # Clear, never submit, the synthetic human draft. The same exact target now has -# native idle state plus a complete Pi separator composer and must accept quickly. +# native idle state plus a complete Pi separator composer, but Herdr must retain +# the buffered escalation because its installed API cannot atomically admit text. "$LAB_HELPER" run "$SESSION" pane send-keys "$PRIMARY_PANE" ctrl+c >/dev/null wait_for_idle || fail "real Pi did not return idle after clearing the draft" for _ in $(seq 1 80); do @@ -227,19 +228,14 @@ for _ in $(seq 1 80); do sleep 0.1 done [ "$composer" = empty ] || fail "genuinely idle Pi separator composer did not classify empty (got $composer)" -wait_for_prompt 'any(.[]; .prompt | startswith("\u2063Supervisor escalate"))' \ - || fail "real Pi did not receive the buffered escalation after becoming safely idle" -INJECT_HEX=$(jq -r 'select(.prompt | startswith("\u2063Supervisor escalate")) | .hex' "$CAPTURE" | tail -1) -case "$INJECT_HEX" in e281a3*) ;; *) fail "real Pi escalation lost the terminal-safe marker: $INJECT_HEX" ;; esac -for _ in $(seq 1 80); do [ ! -s "$STATE/.subsuper-escalations" ] && break; sleep 0.1; done -[ ! -s "$STATE/.subsuper-escalations" ] || fail "confirmed real Pi delivery did not clear the escalation buffer" -[ ! -e "$STATE/.subsuper-inject-wedged" ] || fail "confirmed real Pi delivery did not clear the old wedge marker" sleep 4 -[ "$(wc -l < "$NOTIFY_LOG" | tr -d ' ')" -eq 1 ] || fail "successful delivery emitted a duplicate wedge alert" -INJECT_PROMPT=$(jq -r 'select(.prompt | startswith("\u2063Supervisor escalate")) | .prompt' "$CAPTURE" | tail -1) -message_is_injection "$INJECT_PROMPT" || fail "terminal-delivered Pi escalation was not recognized as an internal marker" -assert_blocker_open 'after successful marked injection' -pass "real idle Pi/Herdr accepts one marked escalation promptly, verifies submit, clears wedge state, and emits no duplicate alert" +[ ! -s "$CAPTURE" ] || fail "real idle Pi received a supervisor escalation without atomic admission" +[ -s "$STATE/.subsuper-escalations" ] || fail "atomic-admission deferral cleared the buffered escalation" +[ -s "$STATE/.subsuper-inject-wedged" ] || fail "atomic-admission deferral cleared the active wedge signal" +NOTIFIER_COUNT=$(wc -l < "$NOTIFY_LOG" | tr -d ' ') +[ "$NOTIFIER_COUNT" -ge 1 ] || fail "atomic-admission deferral lost the active wedge notification" +assert_blocker_open 'after Herdr atomic-admission deferral' +pass "real idle Pi/Herdr retains the marked escalation and types nothing when atomic admission is unavailable" # The captain returns with an ordinary unmarked Bearings request. The request is # captured byte-exact, then the public return owner must gate it on the blocker. @@ -290,8 +286,8 @@ PATH="$FAKEBIN:$ORIGINAL_PATH" HERDR_SESSION="$SESSION" FM_ROOT_OVERRIDE="$PROJE FM_SUPERVISOR_BACKEND=herdr FM_SUPERVISOR_TARGET="$PRIMARY_TARGET" "$ROOT/bin/fm-afk-return.sh" begin >/dev/null \ || fail "clean away re-entry/return was not idempotent" DAEMON_STARTED=0 -[ "$(wc -l < "$NOTIFY_LOG" | tr -d ' ')" -eq 1 ] || fail "clean re-entry duplicated the historical wedge alert" +[ "$(wc -l < "$NOTIFY_LOG" | tr -d ' ')" = "$NOTIFIER_COUNT" ] || fail "clean re-entry duplicated the historical wedge alert" pass "resolved return catch-up allows Bearings and a clean idempotent away re-entry" -printf 'evidence: herdr=%s pi=%s target=%s inject-hex-prefix=%s notifier-count=1\n' \ - "$(herdr --version)" "$(pi --version)" "$PRIMARY_TARGET" "${INJECT_HEX:0:6}" +printf 'evidence: atomic-admission=unavailable target=%s notifier-count=%s\n' \ + "$PRIMARY_TARGET" "$NOTIFIER_COUNT" diff --git a/tests/fm-backend-herdr.test.sh b/tests/fm-backend-herdr.test.sh index bbed722143f..cb188771f75 100755 --- a/tests/fm-backend-herdr.test.sh +++ b/tests/fm-backend-herdr.test.sh @@ -4069,6 +4069,19 @@ test_send_text_submit_unknown_on_composer_capture_failure() { pass "fm_backend_herdr_send_text_submit: an unreadable composer stops Enter retries after native status stays idle" } +test_away_supervisor_admission_refuses_without_atomic_api() { + local dir out + dir="$TMP_ROOT/away-supervisor-admission" + mkdir -p "$dir/empty-fakebin" + out=$(PATH="$dir/empty-fakebin:$PATH" \ + bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_admit_away_supervisor default:w1:p2 "supervisor text"' \ + "$ROOT") + [ "$out" = atomic-unavailable ] \ + || fail "Herdr away-supervisor admission must refuse without a verified atomic API, got '$out'" + pass "fm_backend_herdr_admit_away_supervisor: refuses typed admission without invoking a recheck or pane transport" +} + + # --- fm-backend.sh dispatch wiring ------------------------------------------- test_dispatch_routes_herdr_backend() { @@ -4798,6 +4811,7 @@ test_composer_state_grok_bright_truecolor_real_text_is_pending test_composer_state_codex_bare_prompt_glyph_is_empty test_composer_state_codex_faint_suggestion_is_empty test_composer_state_codex_non_faint_same_text_is_pending +test_away_supervisor_admission_refuses_without_atomic_api test_wait_for_working_returns_busy_on_first_poll test_wait_for_working_catches_a_slow_transition_mid_window test_wait_for_working_samples_budget_endpoint_without_final_sleep diff --git a/tests/fm-daemon.test.sh b/tests/fm-daemon.test.sh index 71f03c9c446..e88407d5724 100755 --- a/tests/fm-daemon.test.sh +++ b/tests/fm-daemon.test.sh @@ -2145,32 +2145,55 @@ test_inject_msg_herdr_refuses_unknown_harness_before_submit() { pass "inject_msg: Herdr injection refuses before typing when supervisor harness identity is unknown" } -test_inject_msg_herdr_submits_through_backend_dispatch() { - local dir state - dir=$(make_supercase inject-herdr-submit) +test_inject_msg_herdr_atomic_admission_defers_late_captain_input() { + local dir state captain_composer sent + dir=$(make_supercase inject-herdr-atomic-admission) state="$dir/state" + captain_composer="$dir/captain-composer" + sent="$dir/submitted" + : > "$captain_composer" + : > "$sent" afk_enter "$state" + printf 'done: PR https://example.test/pr/atomic-admission\n' > "$state/.subsuper-escalations" ( + LOG="$state/.supervise-daemon.log" fm_backend_target_exists() { return 0; } pane_is_busy() { return 1; } - fm_backend_composer_state() { printf 'empty'; } + fm_backend_composer_state() { + [ ! -s "$captain_composer" ] \ + || fail "the initial composer guard did not run before the simulated captain draft" + printf 'empty' + } + fm_backend_herdr_admit_away_supervisor() { + [ "$1" = "default:w1:p2" ] || fail "unexpected atomic-admission target: $1" + case "$2" in *"atomic-admission"*) : ;; *) fail "atomic admission lost the buffered escalation";; esac + # This is the former read-to-send interval: the human starts typing only + # after the daemon observed an empty composer, so a separate send would + # merge the two messages. + printf 'captain return draft' > "$captain_composer" + printf 'atomic-unavailable' + } fm_backend_send_text_submit() { - [ "$1" = herdr ] && [ "$2" = "default:w1:p2" ] || fail "unexpected send_text_submit args: $1 $2" - case "$3" in *"hello"*) : ;; *) fail "digest text missing from send_text_submit: $3" ;; esac - [ "$#" -eq 10 ] && [ "$7" = "" ] || fail "expected-label placeholder shifted the harness argument (argc=$#)" - [ "$8" = omp ] || fail "OMP Herdr injection did not forward exact harness identity: ${8:-missing}" - [ "$9" = /verified/bun ] || fail "OMP Herdr injection did not forward its bound Bun identity: ${9:-missing}" - [ "${10:-}" = /verified/omp ] || fail "OMP Herdr injection did not forward its bound entrypoint identity: ${10:-missing}" + printf '%s\n' "$*" >> "$sent" printf 'empty' } - FM_SUPERVISOR_BACKEND=herdr FM_SUPERVISOR_TARGET="default:w1:p2" \ - FM_SUPERVISOR_HARNESS=omp FM_SUPERVISOR_OMP_BUN=/verified/bun \ - FM_SUPERVISOR_OMP_BIN=/verified/omp inject_msg "hello" "$state" \ - || fail "inject_msg should succeed when OMP-native confirmation reports delivery" - ) || fail "herdr successful-submit inject_msg subshell failed" - pass "inject_msg: OMP Herdr away delivery reaches the exact-runtime native confirmation path" + if FM_SUPERVISOR_BACKEND=herdr FM_SUPERVISOR_TARGET="default:w1:p2" \ + FM_SUPERVISOR_HARNESS=pi escalate_flush "$state"; then + fail "Herdr must not report delivery when atomic composer admission is unavailable" + fi + [ "$(cat "$captain_composer")" = "captain return draft" ] \ + || fail "the deterministic captain draft was not placed after the initial guard" + [ ! -s "$sent" ] || fail "Herdr typed or submitted after the captain draft entered the former race interval" + [ -s "$state/.subsuper-escalations" ] \ + || fail "atomic-admission deferral discarded the durable escalation" + grep -F "Herdr API has no verified atomic composer admission; no text typed" \ + "$state/.supervise-daemon.log" >/dev/null \ + || fail "atomic-admission deferral was not recorded truthfully" + ) || fail "Herdr atomic-admission race subshell failed" + pass "inject_msg: a captain draft entering after the empty guard defers Herdr delivery without typing or losing the escalation" } + # Safety-critical (task fm-composer-shellglyph-safety): the away-mode injector # must NEVER type an escalation into a dead-shell pane. A bare shell prompt # classifies `unknown` (not `pending`), and inject_msg now defers on anything @@ -2318,6 +2341,6 @@ test_inject_msg_herdr_busy_guard_defers test_inject_msg_herdr_composer_guard_defers test_inject_msg_herdr_pane_gone_defers test_inject_msg_herdr_refuses_unknown_harness_before_submit -test_inject_msg_herdr_submits_through_backend_dispatch +test_inject_msg_herdr_atomic_admission_defers_late_captain_input test_inject_msg_defers_on_dead_shell_unknown test_inject_msg_defers_on_unrecognized_composer_state