diff --git a/.agents/skills/afk/SKILL.md b/.agents/skills/afk/SKILL.md index ba7546c1600..bb966456e0c 100644 --- a/.agents/skills/afk/SKILL.md +++ b/.agents/skills/afk/SKILL.md @@ -123,7 +123,8 @@ Enter is retried (Enter only, never a retype) until the backend confirms the submit landed. For tmux that confirmation is normally a proven cleared composer from the shared classifier; an idle baseline transitioning to busy across this submit's own Enter also confirms that the turn started when a working harness hides its composer. Without that baseline, busy state never converts an `unknown` composer into confirmation. -For herdr, idle-baseline submits first seek native agent-state showing a real turn started, then use the shared classifier when native state remains idle: a cleared composer confirms delivery, while pending text retries Enter and reaches the shared busy-queue verdict only after the retry budget. +For herdr, idle-baseline submits first seek native agent-state showing a real turn started, then use the shared classifier when native state remains idle: Pi takes an identity-corroborated structural-composer path, while other harnesses use the general composer fallback; cleared content confirms delivery, while pending text retries Enter and reaches the shared busy-queue verdict only after the retry budget. +[`docs/herdr-backend.md`](../../../docs/herdr-backend.md#current-transport-behavior) owns the backend-specific native-state, rendered-footer, Pi, and queued-Enter confirmation paths. A bordered-empty or ghost-only composer is recognized as empty where that backend uses composer confirmation, rather than mistaken for a swallowed Enter. `fm-send.sh` uses the same primitive and exits non-zero when a steer's Enter is positively swallowed, so firstmate learns an instruction @@ -191,7 +192,8 @@ the operational prefix lets firstmate distinguish it from a real captain message Enter is retried, Enter only and never a retype, until the backend submit primitive reports `empty` as its caller-facing success verdict. For tmux that verdict normally means the shared classifier proved the composer cleared; a baseline-gated idle-to-busy transition may instead prove this Enter started the turn. - For herdr's idle-baseline path it means native agent-state observed a turn start, the shared classifier proved the composer cleared, or the shared queued-Enter verdict proved delivery while busy. + For herdr's idle-baseline path it means native agent-state observed a turn start, the identity-corroborated Pi path or general composer fallback proved the composer cleared, or the shared queued-Enter verdict proved delivery while busy. + The backend-specific paths are owned by [`docs/herdr-backend.md`](../../../docs/herdr-backend.md#current-transport-behavior). This lets ghost-only or bordered-empty composers count as empty where a composer read is the active confirmation signal. - **Marker strip** - `strip_injection_marker` removes the current operational prefix or legacy bare marker before classification or relay, so the digest diff --git a/bin/backends/herdr.sh b/bin/backends/herdr.sh index c5f270bdaf9..3fdc0673ba8 100644 --- a/bin/backends/herdr.sh +++ b/bin/backends/herdr.sh @@ -2663,6 +2663,22 @@ fm_backend_herdr_composer_state() { # -> empty|pending|pending-unprove printf '%s' "$verdict" } +# Confirm the Pi-specific idle path from the same native identity and a +# structurally empty composer. A non-Pi target stays on native confirmation. +fm_backend_herdr_pi_idle_composer_state() { # -> empty|pending|unknown|not-pi + local target=$1 identity agent agent_status + fm_backend_herdr_parse_target "$target" || { printf 'unknown'; return 0; } + identity=$(fm_backend_herdr_agent_identity_raw "$FM_BACKEND_HERDR_SESSION" "$FM_BACKEND_HERDR_PANE" 2>/dev/null || true) + IFS=$'\t' read -r agent agent_status < [harness] -> busy|idle|unkn # fm_backend_herdr_send_text_submit: type into once (raw, # unsubmitted, via send_literal), then submit with a named Enter key, retried -# (Enter only, never retyped) until native agent-state, a cleared composer, or -# fm_composer_queued_enter_verdict confirms delivery. Verified hazard -# (herdr-verification-p2.md "slash/$ autocomplete popup"): a `/`- or -# `$`-prefixed send opens a completion popup within ~0.1s, exactly like tmux's -# claude/codex popups, so the caller's before the first Enter matters -# here the same way it does for tmux. +# (Enter only, never retyped) until native agent-state, the Pi-specific or +# general cleared-composer path, or fm_composer_queued_enter_verdict confirms +# delivery. Verified hazard (herdr-verification-p2.md "slash/$ autocomplete +# popup"): a `/`- or `$`-prefixed send opens a completion popup within ~0.1s, +# exactly like tmux's claude/codex popups, so the caller's before the +# first Enter matters here the same way it does for tmux. # # Confirmation signal: when the target is legibly idle before Enter, # submission is confirmed by fm_backend_herdr_wait_for_working observing a -# submit-active agent_status after Enter. Live Claude on Herdr 0.8.0 can -# keep agent_status idle for a whole landed turn, so an idle native result -# falls through to the shared composer verdict: empty is positive delivery, -# proven pending retries Enter, and retries-exhausted pending plus a -# generating busy signal is a queued Enter via -# fm_composer_queued_enter_verdict (bin/fm-composer-lib.sh). +# submit-active agent_status after Enter. Pi and live Claude on Herdr can keep +# agent_status idle for a landed turn, so an idle native result falls through +# to composer confirmation. Pi first requires the same native Pi identity and +# then a structurally empty composer; other harnesses use the general shared +# composer verdict. Empty is positive delivery, proven pending retries Enter, +# and retries-exhausted pending plus a generating busy signal is a queued Enter +# via fm_composer_queued_enter_verdict (bin/fm-composer-lib.sh). # -# Incident (2026-07-07, followed up on 2026-07-08): a redelivery loop in the -# away-mode daemon. Root cause: composer-content submit confirmation was too -# sensitive to harness rendering details. Real claude/codex use bare prompt -# rows, and real codex adds dynamic idle suggestions after `›`; the later -# ANSI-aware composer classifier now handles that Codex shape, and idle-baseline -# submit confirmation still prefers native agent-state so a faint idle tip -# cannot block a landed send. Composer content is consulted only after native -# state stays idle, as the empty/pending owner, and for submit attempts whose -# pre-Enter agent-state baseline is not legibly idle. +# Incident (2026-07-07, followed up on 2026-08-09): a redelivery loop in the +# away-mode daemon. Native agent-state confirmation fixed composer-rendering +# false negatives for Claude and Codex, but Pi's Herdr state can remain idle +# after Pi consumes a user message, and later live Claude was also observed +# staying idle for a whole landed turn. The ANSI-aware composer classifier +# remains the pre-injection guard and is consulted only after native state stays +# idle, as the identity-corroborated Pi or general empty/pending owner, and for +# submit attempts whose pre-Enter agent-state baseline is not legibly idle. # # This also still correctly handles the earlier 2026-07-03 incident (a # slash-command popup selection/placeholder-fill on the FIRST Enter is not a @@ -2759,7 +2775,10 @@ fm_backend_herdr_rendered_busy_state() { # [harness] -> busy|idle|unkn # supplies the busy primitive. # 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. +# each backend confirms it is an internal decision. Herdr prefers native +# agent-state, uses an identity-corroborated structural composer verdict for Pi +# or the general composer fallback for other idle-native harnesses, and uses +# the rendered busy-footer and queued-Enter paths described above. # # fm_backend_herdr_queued_enter_busy: delivery-busy for the shared queued-Enter # conversion. Native agent_status=working is generating; blocked is not (a @@ -2780,6 +2799,7 @@ fm_backend_herdr_queued_enter_busy() { # fi } + 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 @@ -2815,9 +2835,10 @@ fm_backend_herdr_send_text_submit() { # busy) printf 'empty'; return 0 ;; unknown) printf 'unknown'; return 0 ;; esac - # Native stayed idle. Composer empty is positive delivery (a landed - # Claude turn that never flipped agent_status). Proven pending retries. - verdict=$(fm_backend_herdr_composer_state "$target") + # Native stayed idle. Pi requires identity corroboration before the + # structural composer verdict; other harnesses use the general fallback. + verdict=$(fm_backend_herdr_pi_idle_composer_state "$target") + [ "$verdict" = not-pi ] && verdict=$(fm_backend_herdr_composer_state "$target") case "$verdict" in empty) printf 'empty'; return 0 ;; pending|pending-unproven) ;; diff --git a/bin/fm-install-herdr.sh b/bin/fm-install-herdr.sh index 6ce19637613..4545501092c 100755 --- a/bin/fm-install-herdr.sh +++ b/bin/fm-install-herdr.sh @@ -60,8 +60,15 @@ trap 'rm -rf "$TMP"' EXIT printf 'fm-install-herdr.sh: downloading %s from %s\n' "$ASSET" "$URL" >&2 # --fail: HTTP errors; --location: follow redirects; --max-filesize: bound. -curl -fsSL --max-filesize "$FM_HERDR_CI_MAX_BYTES" "$URL" -o "$TMP/$ASSET" \ - || die "download failed for $URL (bounded at $FM_HERDR_CI_MAX_BYTES bytes)" +DOWNLOAD_ATTEMPTS=6 +download_attempt=1 +while ! curl -fsSL --max-filesize "$FM_HERDR_CI_MAX_BYTES" "$URL" -o "$TMP/$ASSET"; do + [ "$download_attempt" -lt "$DOWNLOAD_ATTEMPTS" ] \ + || die "download failed for $URL after $DOWNLOAD_ATTEMPTS attempts (bounded at $FM_HERDR_CI_MAX_BYTES bytes)" + printf 'fm-install-herdr.sh: download attempt %s failed; retrying\n' "$download_attempt" >&2 + sleep $((1 << (download_attempt - 1))) + download_attempt=$((download_attempt + 1)) +done if command -v sha256sum >/dev/null 2>&1; then ACTUAL_SHA256=$(sha256sum "$TMP/$ASSET" | awk '{print $1}') diff --git a/bin/fm-test-run.sh b/bin/fm-test-run.sh index ef21cda8335..e04246fb781 100755 --- a/bin/fm-test-run.sh +++ b/bin/fm-test-run.sh @@ -158,7 +158,7 @@ family_for_basename() { fm-watcher-lock.test.sh|fm-inactive-reconcile.test.sh) printf '%s\n' watcher-wake-lock ;; - fm-afk-inject-herdr-e2e.test.sh|fm-afk-launch.test.sh|fm-backend-autodetect-smoke.test.sh|\ + fm-afk-inject-herdr-e2e.test.sh|fm-afk-pi-herdr-ack-e2e.test.sh|fm-afk-launch.test.sh|fm-backend-autodetect-smoke.test.sh|\ fm-backend-herdr-eventwait-smoke.test.sh|fm-backend-herdr-presentation-e2e.test.sh|\ fm-backend-herdr-launcher-workspace-e2e.test.sh|\ fm-backend-herdr-prune-safety-e2e.test.sh|fm-backend-herdr-respawn-idem-e2e.test.sh|\ diff --git a/docs/architecture.md b/docs/architecture.md index a1d7d77753f..c1525552782 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -93,8 +93,9 @@ 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 agent-state submit confirmation on idle baselines, a composer empty fallback when native stays idle, and a pre-Enter rendered-footer transition when that baseline is unavailable. -The retries-exhausted queued-Enter decision is owned by `fm_composer_queued_enter_verdict` in `bin/fm-composer-lib.sh`; tmux and herdr provide only their backend-specific busy signals. +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 agent-state submit confirmation on idle baselines, an identity-corroborated Pi structural-composer path or general composer-empty fallback when native stays idle, and a pre-Enter rendered-footer transition when that baseline is unavailable. +The retries-exhausted queued-Enter decision is owned by `fm_composer_queued_enter_verdict` in `bin/fm-composer-lib.sh`; tmux and Herdr provide only their backend-specific busy signals. +Herdr's confirmation paths are documented in [herdr-backend.md](herdr-backend.md#current-transport-behavior). Composer classification has one shared owner, `bin/fm-composer-lib.sh`: tmux, herdr, Zellij, Orca, and cmux contribute only a screen capture plus declarative styled, cursor, identity, and row capabilities, while the shared classifier owns every shape and the `empty`/`pending`/`pending-unproven`/`unknown` verdict. `fm-spawn.sh` also routes Kimi launch readiness through that classifier instead of carrying another shape copy. The daemon injects only into an affirmatively `empty` composer, so every other or future verdict defers; positive container proof is required, and a blank unidentified row or bare dead-shell prompt cannot receive an escalation. diff --git a/docs/demo-afk-pi-herdr-ack/3b11f9be-2026-08-10.png b/docs/demo-afk-pi-herdr-ack/3b11f9be-2026-08-10.png new file mode 100644 index 00000000000..6a7ee69556b Binary files /dev/null and b/docs/demo-afk-pi-herdr-ack/3b11f9be-2026-08-10.png differ diff --git a/docs/demo-afk-pi-herdr-ack/7d052665-2026-08-10.png b/docs/demo-afk-pi-herdr-ack/7d052665-2026-08-10.png new file mode 100644 index 00000000000..80fa3774f22 Binary files /dev/null and b/docs/demo-afk-pi-herdr-ack/7d052665-2026-08-10.png differ diff --git a/docs/demo-afk-pi-herdr-ack/after-fix.png b/docs/demo-afk-pi-herdr-ack/after-fix.png new file mode 100644 index 00000000000..6a7ee69556b Binary files /dev/null and b/docs/demo-afk-pi-herdr-ack/after-fix.png differ diff --git a/docs/demo-afk-pi-herdr-ack/before-fix.png b/docs/demo-afk-pi-herdr-ack/before-fix.png new file mode 100644 index 00000000000..80fa3774f22 Binary files /dev/null and b/docs/demo-afk-pi-herdr-ack/before-fix.png differ diff --git a/docs/demo-afk-pi-herdr-ack/demo.md b/docs/demo-afk-pi-herdr-ack/demo.md new file mode 100644 index 00000000000..caf782f5bd1 --- /dev/null +++ b/docs/demo-afk-pi-herdr-ack/demo.md @@ -0,0 +1,42 @@ +# Pi on Herdr away-mode acknowledgement + +*2026-08-10T00:23:10Z by Showboat 0.6.1* + + +The bounded real reproduction reached Pi visibly while Herdr kept the native agent idle and reported the submit as pending. + +```bash {image} +before-fix.png +``` + +![7d052665-2026-08-10](7d052665-2026-08-10.png) + +The recorded pre-fix negative control returned pending with an empty Pi composer and retained the unchanged buffer. + +```bash +printf "pre-fix-negative-control=pending\n" +``` + +```output +pre-fix-negative-control=pending +``` + +The corrected path keeps native Herdr confirmation for non-Pi agents and adds only the identity-corroborated Pi composer acknowledgement. + +```bash +FM_AFK_PI_HERDR_ACK_E2E=1 ../../tests/fm-afk-pi-herdr-ack-e2e.test.sh 2>/dev/null +``` + +```output +ok - real Pi/Herdr idle-native delivery clears the buffer after one typed digest +ok - real Pi/Herdr unsubmitted input preserves the pending buffer +evidence: pi=0.84.1 herdr=0.7.4 protocol=16 successful_send_texts=1 +``` + +```bash {image} +after-fix.png +``` + +![3b11f9be-2026-08-10](3b11f9be-2026-08-10.png) + +The real control leaves an unsubmitted draft pending, while one delivered digest produces exactly one typed send. diff --git a/docs/documentation-audiences.json b/docs/documentation-audiences.json index 4889467bd41..8bb001bb651 100644 --- a/docs/documentation-audiences.json +++ b/docs/documentation-audiences.json @@ -248,6 +248,10 @@ "path": "docs/decision-hold-lifecycle.md", "audience": "maintainer-architecture" }, + { + "path": "docs/demo-afk-pi-herdr-ack/demo.md", + "audience": "maintainer-verification" + }, { "path": "docs/documentation-audiences.md", "audience": "maintainer-architecture" diff --git a/docs/herdr-backend.md b/docs/herdr-backend.md index 22f9d967aaf..a8d6929b2c5 100644 --- a/docs/herdr-backend.md +++ b/docs/herdr-backend.md @@ -213,13 +213,14 @@ 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 first waits for `working` or `blocked` across a bounded polling window. -If native status stays idle, the shared composer verdict is the next positive signal: a cleared composer is delivery, and proven pending text retries Enter. +If native status stays idle, the shared composer verdict is the next positive signal: Pi first requires the same native Pi identity, while other harnesses use the general composer fallback; a cleared composer is delivery, and proven pending text retries Enter. +A pending or unreadable Pi composer remains unconfirmed and retains the buffer. After the retry budget, `fm_composer_queued_enter_verdict` treats proven pending text plus a generating busy signal as a queued delivered Enter, and keeps an idle pending composer as a genuine swallow. On an already active or unreadable baseline, the adapter falls back to conservative composer clearance, with a pre-Enter rendered-footer transition when that baseline is unavailable. A fully unreadable target stops retrying and reports unknown. blocked is not treated as a queued-Enter busy signal, so a Cursor pane that reports blocked in every state does not receive that conversion. -Some harnesses never present a legibly idle native baseline at all, so the composer fallback is their only path. +Some harnesses never present a legibly idle native baseline at all, so these fallback signals are their only path. Herdr reports a Cursor pane `blocked` in every state, and Cursor's mid-turn composer renders its placeholder beside a right-aligned busy token, which is composer content and therefore `pending` on a composer that holds no user text. That fallback alone reported every delivered steer as unconfirmed, so it is paired with a rendered-footer transition: the pane's verified busy footer is read once before the first Enter, and an idle-to-busy transition across that Enter confirms the submit. It is the same semantic signal the native path uses and the same one the tmux submit core reads. @@ -338,6 +339,7 @@ tests/fm-backend-herdr-eventwait-smoke.test.sh tests/fm-herdr-session-cleanup.test.sh tests/fm-herdr-session-cleanup-e2e.test.sh tests/fm-afk-inject-herdr-e2e.test.sh +tests/fm-afk-pi-herdr-ack-e2e.test.sh tests/fm-afk-pi-herdr-return-e2e.test.sh ``` diff --git a/docs/verification/runtime-backends.md b/docs/verification/runtime-backends.md index a413e9ffd73..248329b96a1 100644 --- a/docs/verification/runtime-backends.md +++ b/docs/verification/runtime-backends.md @@ -246,8 +246,21 @@ No ambient `herdr server stop` command is a supported test operation. ### Submit confirmation -Measured 2026-08-19 against Herdr 0.8.0 and Claude Code 2.1.236 in an isolated `fm-lab-` session. +A bounded end-to-end run on 2026-08-09 used Pi 0.84.1 and Herdr 0.7.4 protocol 16 in the named `fix-afk-pi-herdr-ack` lab session. +The real Pi pane remained native-idle after receiving the away digest, while the Herdr adapter confirmed the identity-corroborated empty Pi composer and cleared the buffer after one typed digest. +A second real control left a human draft unsubmitted, and the adapter retained the new digest without merging into that draft. +```sh +FM_AFK_PI_HERDR_ACK_E2E=1 tests/fm-afk-pi-herdr-ack-e2e.test.sh +``` + +```text +ok - real Pi/Herdr idle-native delivery clears the buffer after one typed digest +ok - real Pi/Herdr unsubmitted input preserves the pending buffer +evidence: pi=0.84.1 herdr=0.7.4 protocol=16 successful_send_texts=1 +``` + +A separate measurement on 2026-08-19 used Herdr 0.8.0 and Claude Code 2.1.236 in an isolated `fm-lab-` session. `herdr agent get` reported `agent_status=idle` on every sample across a landed one-word turn and an 8-second `sleep` tool call, while the pane rendered `Pontificating…` then `Sock-hopping… (11s · ↓ 234 tokens)`. `fm_backend_herdr_send_text_submit` therefore cannot treat native idle as proof of a swallow. The portable regressions in `tests/fm-backend-herdr.test.sh` and `tests/fm-composer-lib.test.sh` pin the verdicts: native idle plus a cleared composer is delivery, proven pending plus idle is a swallow, and proven pending plus a generating busy signal is a queued Enter. @@ -263,6 +276,8 @@ Observed 2026-08-19: ok - live Herdr submit confirm: Claude Code (2.1.236 (Claude Code)) on herdr 0.8.0 reports empty for a landed idle steer ``` +The focused adapter suite also covers a consumed idle Pi message, a swallowed Pi Enter, native Herdr confirmation, and type-once retry behavior. +Pi and pi-signed retain the identity-corroborated path, while Claude's new general idle-composer fallback and the existing Cursor rendered-footer path remain distinct. ### Prune and respawn The real label-collision reproduction is owned by: diff --git a/tests/fm-afk-pi-herdr-ack-e2e.test.sh b/tests/fm-afk-pi-herdr-ack-e2e.test.sh new file mode 100755 index 00000000000..5f95c94f162 --- /dev/null +++ b/tests/fm-afk-pi-herdr-ack-e2e.test.sh @@ -0,0 +1,199 @@ +#!/usr/bin/env bash +# Real bounded Pi-on-Herdr transport-ack regression for issue #1859. +# +# Opt in with FM_AFK_PI_HERDR_ACK_E2E=1. +# The test uses a real Pi primary and real away daemon inside a named Herdr lab, +# but gives Pi no provider credentials so the transport boundary stays bounded. +# It proves one delivered digest clears exactly once, while a swallowed Enter +# leaves the buffer pending. +set -u + +# shellcheck source=tests/lib.sh +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +if [ "${FM_AFK_PI_HERDR_ACK_E2E:-0}" != 1 ]; then + echo "skip: set FM_AFK_PI_HERDR_ACK_E2E=1 to run the real Pi/Herdr acknowledgement regression" + exit 0 +fi + +for tool in herdr jq pi python3; do + command -v "$tool" >/dev/null 2>&1 || { echo "skip: $tool not found"; exit 0; } +done + +ROOT=${ROOT:?} +HERDR_LAB_HELPER="$ROOT/bin/fm-herdr-lab.sh" +HERDR_LAB_SESSION=$("$HERDR_LAB_HELPER" name fix-afk-pi-herdr-ack) +trap '"$HERDR_LAB_HELPER" teardown "$HERDR_LAB_SESSION"' EXIT +"$HERDR_LAB_HELPER" provision "$HERDR_LAB_SESSION" >/dev/null + +TMP_ROOT=$(fm_test_tmproot fm-afk-pi-herdr-ack-e2e) +HOME_DIR="$TMP_ROOT/home" +STATE="$HOME_DIR/state" +PROJECT="$TMP_ROOT/project" +PI_DIR="$TMP_ROOT/pi-agent" +FAKEBIN="$TMP_ROOT/fakebin" +CAPTURE="$TMP_ROOT/pi-prompts.jsonl" +HERDR_LOG="$TMP_ROOT/herdr-calls.log" +ORIGINAL_PATH=$PATH +PRIMARY_PANE= +PRIMARY_TARGET= +DAEMON_STARTED=0 + +# shellcheck disable=SC2329 # invoked indirectly through the EXIT trap below +cleanup() { + local rc=$? + trap - EXIT + if [ "$DAEMON_STARTED" -eq 1 ]; then + PATH="$FAKEBIN:$ORIGINAL_PATH" HERDR_SESSION="$HERDR_LAB_SESSION" FM_HOME="$HOME_DIR" FM_STATE_OVERRIDE="$STATE" \ + FM_SUPERVISOR_BACKEND=herdr FM_SUPERVISOR_TARGET="$PRIMARY_TARGET" \ + "$ROOT/bin/fm-afk-launch.sh" stop >/dev/null 2>&1 || rc=1 + fi + "$HERDR_LAB_HELPER" teardown "$HERDR_LAB_SESSION" || rc=1 + rm -rf "$TMP_ROOT" + exit "$rc" +} +trap cleanup EXIT + +mkdir -p "$HOME_DIR"/{state,data,config,projects} "$PROJECT" "$PI_DIR" "$FAKEBIN" +printf '# Pi Herdr acknowledgement fixture\n' > "$PROJECT/AGENTS.md" +cat > "$TMP_ROOT/capture-extension.ts" <<'EOF' +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; +import { appendFileSync } from "node:fs"; +const capturePath = process.env.FM_PI_CAPTURE_PATH!; +export default function (pi: ExtensionAPI) { + pi.on("project_trust", () => ({ trusted: "yes", remember: false })); + pi.on("before_agent_start", (event, ctx) => { + appendFileSync(capturePath, `${JSON.stringify({ prompt: event.prompt, hex: Buffer.from(event.prompt, "utf8").toString("hex") })}\n`); + ctx.abort(); + }); +} +EOF + +# Route all adapter Herdr calls through the guarded lab helper and record only +# the public command shape needed to prove exactly-once transport. +cat > "$FAKEBIN/herdr" <&2; exit 97; } + args=("\${args[@]:0:\$((n-2))}") +else + [ "\${HERDR_SESSION:-}" = "\$session" ] || { echo 'wrapper requires isolated session' >&2; exit 98; } +fi +printf '%s\\n' "\${args[*]}" >> "\$log" +PATH="\$real_path" exec "\$helper" run "\$session" "\${args[@]}" +EOF +chmod +x "$FAKEBIN/herdr" + +cat > "$TMP_ROOT/daemon-entry" </dev/null + +wait_for_idle() { + local stable=0 status _ + for _ in $(seq 1 240); do + status=$("$HERDR_LAB_HELPER" run "$HERDR_LAB_SESSION" agent get "$PRIMARY_PANE" 2>/dev/null | jq -r '.result.agent.agent_status // empty' 2>/dev/null || true) + case "$status" in + idle|done|blocked) stable=$((stable + 1)); [ "$stable" -ge 4 ] && return 0 ;; + *) stable=0 ;; + esac + sleep 0.25 + done + return 1 +} + +wait_for_idle || { echo "not ok - real Pi did not become idle" >&2; exit 1; } + +CHILD_OUT=$("$HERDR_LAB_HELPER" run "$HERDR_LAB_SESSION" tab create --workspace "$WORKSPACE" --cwd "$PROJECT" --label fm-ack-task --no-focus) +CHILD_PANE=$(printf '%s' "$CHILD_OUT" | jq -r '.result.root_pane.pane_id') +CHILD_TARGET="$HERDR_LAB_SESSION:$CHILD_PANE" +cat > "$STATE/ack-task.meta" < "$STATE/ack-task.status" <<'EOF' +working: waiting for transport acknowledgement +EOF + +PATH="$FAKEBIN:$ORIGINAL_PATH" HERDR_SESSION="$HERDR_LAB_SESSION" FM_HOME="$HOME_DIR" FM_STATE_OVERRIDE="$STATE" \ + FM_SUPERVISOR_BACKEND=herdr FM_SUPERVISOR_TARGET="$PRIMARY_TARGET" FM_AFK_LAUNCH_ENTRY="$TMP_ROOT/daemon-entry" \ + "$ROOT/bin/fm-afk-launch.sh" start >/dev/null +DAEMON_STARTED=1 +for _ in $(seq 1 100); do [ -s "$STATE/.supervise-daemon.pid" ] && break; sleep 0.1; done +[ -s "$STATE/.supervise-daemon.pid" ] || { echo "not ok - away daemon did not start" >&2; exit 1; } + +# Successful Pi delivery: Pi stays native-idle, but consumes the marked message +# and exposes a structurally empty Pi composer after the single Enter. +printf 'blocked [key=delivered]: unchanged digest\n' >> "$STATE/ack-task.status" +for _ in $(seq 1 180); do + sends=$(grep -c '^pane send-text ' "$HERDR_LOG" 2>/dev/null || true) + if [ "$sends" -ge 1 ] && [ ! -s "$STATE/.subsuper-escalations" ]; then break; fi + sleep 0.1 +done +sends=$(grep -c '^pane send-text ' "$HERDR_LOG" 2>/dev/null || true) +[ "$sends" -eq 1 ] || { echo "not ok - delivered digest was typed $sends times" >&2; exit 1; } +[ ! -s "$STATE/.subsuper-escalations" ] || { echo "not ok - delivered digest remained buffered" >&2; exit 1; } +composer=$(PATH="$FAKEBIN:$ORIGINAL_PATH" HERDR_SESSION="$HERDR_LAB_SESSION" FM_HOME="$HOME_DIR" FM_STATE_OVERRIDE="$STATE" \ + bash -c '. "$0/bin/fm-backend.sh"; fm_backend_composer_state herdr "$1"' "$ROOT" "$PRIMARY_TARGET") +[ "$composer" = empty ] || { echo "not ok - delivered Pi composer was not empty: $composer" >&2; exit 1; } +printf 'ok - real Pi/Herdr idle-native delivery clears the buffer after one typed digest\n' + +# Unsubmitted-input control: a human draft makes the same Pi composer pending, +# so the daemon must retain the new digest rather than merging or clearing it. +"$HERDR_LAB_HELPER" run "$HERDR_LAB_SESSION" pane send-text "$PRIMARY_PANE" 'unsubmitted human draft' >/dev/null +for _ in $(seq 1 80); do + composer=$(PATH="$FAKEBIN:$ORIGINAL_PATH" HERDR_SESSION="$HERDR_LAB_SESSION" FM_HOME="$HOME_DIR" FM_STATE_OVERRIDE="$STATE" \ + bash -c '. "$0/bin/fm-backend.sh"; fm_backend_composer_state herdr "$1"' "$ROOT" "$PRIMARY_TARGET") + [ "$composer" = pending ] && break + sleep 0.1 +done +[ "$composer" = pending ] || { echo "not ok - Pi draft control did not become pending: $composer" >&2; exit 1; } +printf 'blocked [key=unsubmitted]: unchanged digest\n' >> "$STATE/ack-task.status" +for _ in $(seq 1 100); do + [ -s "$STATE/.subsuper-escalations" ] && break + sleep 0.1 +done +[ -s "$STATE/.subsuper-escalations" ] || { echo "not ok - unsubmitted Pi input did not retain the digest" >&2; exit 1; } +printf 'ok - real Pi/Herdr unsubmitted input preserves the pending buffer\n' + +# Clear the real draft before the guarded daemon teardown. +"$HERDR_LAB_HELPER" run "$HERDR_LAB_SESSION" pane send-keys "$PRIMARY_PANE" ctrl+c >/dev/null +PATH="$FAKEBIN:$ORIGINAL_PATH" HERDR_SESSION="$HERDR_LAB_SESSION" FM_HOME="$HOME_DIR" FM_STATE_OVERRIDE="$STATE" \ + FM_SUPERVISOR_BACKEND=herdr FM_SUPERVISOR_TARGET="$PRIMARY_TARGET" "$ROOT/bin/fm-afk-launch.sh" stop >/dev/null +DAEMON_STARTED=0 +printf 'evidence: pi=%s herdr=%s protocol=%s successful_send_texts=%s\n' \ + "$(pi --version)" \ + "$("$HERDR_LAB_HELPER" run "$HERDR_LAB_SESSION" status --json | jq -r '.client.version')" \ + "$("$HERDR_LAB_HELPER" run "$HERDR_LAB_SESSION" status --json | jq -r '.client.protocol')" \ + "$sends" +exit 0 diff --git a/tests/fm-backend-herdr.test.sh b/tests/fm-backend-herdr.test.sh index dc1be58f9c5..851659d865a 100755 --- a/tests/fm-backend-herdr.test.sh +++ b/tests/fm-backend-herdr.test.sh @@ -3429,6 +3429,68 @@ test_send_text_submit_detects_landed_send() { pass "fm_backend_herdr_send_text_submit: reports 'empty' once agent_status reports working after one Enter, without ever reading the composer" } +test_send_text_submit_pi_idle_composer_confirms_landed_send() { + local dir out enters + dir="$TMP_ROOT/submit-pi-idle-composer" + mkdir -p "$dir" + : > "$dir/enters" + out=$(FM_TEST_ENTER_LOG="$dir/enters" bash -c ' + . "$0/bin/backends/herdr.sh" + fm_backend_herdr_send_literal() { printf "%s\\n" "$2" >> "$FM_TEST_ENTER_LOG"; } + fm_backend_herdr_send_key() { printf "%s\\n" "$2" >> "$FM_TEST_ENTER_LOG"; } + fm_backend_herdr_agent_status_raw() { printf "idle"; } + fm_backend_herdr_wait_for_working() { printf "idle"; } + fm_backend_herdr_agent_identity_raw() { printf "pi\\tidle"; } + fm_backend_herdr_composer_state() { printf "empty"; } + fm_backend_herdr_send_text_submit default:w1:p2 "hello captain" 3 0.01 0.01 + ' "$ROOT") + [ "$out" = empty ] || fail "an idle Pi whose composer is empty after Enter must confirm delivery, got $out" + enters=$(grep -c '^Enter$' "$dir/enters") + [ "$enters" -eq 1 ] || fail "a confirmed Pi delivery must submit exactly once, sent $enters Enters" + [ "$(grep -c '^hello captain$' "$dir/enters")" -eq 1 ] || fail "Pi confirmation must type the payload exactly once" + pass "fm_backend_herdr_send_text_submit: confirms one consumed Pi message when native Pi state stays idle" +} + +test_send_text_submit_pi_swallowed_enter_stays_pending() { + local dir out enters + dir="$TMP_ROOT/submit-pi-swallowed" + mkdir -p "$dir" + : > "$dir/enters" + out=$(FM_TEST_ENTER_LOG="$dir/enters" bash -c ' + . "$0/bin/backends/herdr.sh" + fm_backend_herdr_send_literal() { printf "%s\\n" "$2" >> "$FM_TEST_ENTER_LOG"; } + fm_backend_herdr_send_key() { printf "%s\\n" "$2" >> "$FM_TEST_ENTER_LOG"; } + fm_backend_herdr_agent_status_raw() { printf "idle"; } + fm_backend_herdr_wait_for_working() { printf "idle"; } + fm_backend_herdr_agent_identity_raw() { printf "pi\\tidle"; } + fm_backend_herdr_composer_state() { printf "pending"; } + fm_backend_herdr_send_text_submit default:w1:p2 "hello captain" 2 0.01 0.01 + ' "$ROOT") + [ "$out" = pending ] || fail "a swallowed Pi Enter with pending composer text must remain pending, got $out" + enters=$(grep -c '^Enter$' "$dir/enters") + [ "$enters" -eq 2 ] || fail "a pending Pi delivery should retry Enter without retyping, sent $enters Enters" + [ "$(grep -c '^hello captain$' "$dir/enters")" -eq 1 ] || fail "a pending Pi delivery must retain type-once behavior" + pass "fm_backend_herdr_send_text_submit: preserves the buffer for a swallowed Pi Enter" +} + +test_send_text_submit_native_idle_path_ignores_pi_fallback() { + local dir out + dir="$TMP_ROOT/submit-native-idle-unchanged" + mkdir -p "$dir" + out=$(bash -c ' + . "$0/bin/backends/herdr.sh" + fm_backend_herdr_send_literal() { :; } + fm_backend_herdr_send_key() { :; } + fm_backend_herdr_agent_status_raw() { printf "idle"; } + fm_backend_herdr_wait_for_working() { printf "busy"; } + fm_backend_herdr_agent_identity_raw() { printf "claude\\tworking"; } + fm_backend_herdr_composer_state() { printf "pending"; } + fm_backend_herdr_send_text_submit default:w1:p2 "hello captain" 2 0.01 0.01 + ' "$ROOT") + [ "$out" = empty ] || fail "native Herdr busy confirmation changed while adding Pi fallback, got $out" + pass "fm_backend_herdr_send_text_submit: native Herdr confirmation remains unchanged" +} + test_send_text_submit_detects_swallowed_enter() { local dir log resp fb out dir="$TMP_ROOT/submit-swallow"; mkdir -p "$dir/responses"; log="$dir/log"; resp="$dir/responses"; : > "$log" @@ -3436,11 +3498,13 @@ test_send_text_submit_detects_swallowed_enter() { # holds the typed text: a genuine swallow, not a queued Enter. printf '{"result":{"agent":{"agent_status":"idle"}}}\n' > "$resp/2.out" printf '{"result":{"agent":{"agent_status":"idle"}}}\n' > "$resp/4.out" - printf ' \xe2\x9d\xaf hello captain\n' > "$resp/5.out" - printf '{"result":{"agent":{"agent_status":"idle"}}}\n' > "$resp/7.out" - printf ' \xe2\x9d\xaf hello captain\n' > "$resp/8.out" - printf '{"result":{"agent":{"agent_status":"idle"}}}\n' > "$resp/9.out" - printf ' ready\n' > "$resp/10.out" + printf '{"result":{"agent":{"agent":"claude","agent_status":"idle"}}}\n' > "$resp/5.out" + printf ' \xe2\x9d\xaf hello captain\n' > "$resp/6.out" + printf '{"result":{"agent":{"agent_status":"idle"}}}\n' > "$resp/8.out" + printf '{"result":{"agent":{"agent":"claude","agent_status":"idle"}}}\n' > "$resp/9.out" + printf ' \xe2\x9d\xaf hello captain\n' > "$resp/10.out" + printf '{"result":{"agent":{"agent_status":"idle"}}}\n' > "$resp/11.out" + printf ' ready\n' > "$resp/12.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" 2 0.01 0.01' "$ROOT" ) @@ -3463,12 +3527,14 @@ test_send_text_submit_popup_autocomplete_requires_second_enter() { # 4: agent get -> idle (not submitted yet) printf '{"result":{"agent":{"agent_status":"idle"}}}\n' > "$resp/2.out" printf '{"result":{"agent":{"agent_status":"idle"}}}\n' > "$resp/4.out" - # 5: composer still holds the placeholder fill; native idle falls through + # 5: identity probe confirms this is not Pi. + printf '{"result":{"agent":{"agent":"claude","agent_status":"idle"}}}\n' > "$resp/5.out" + # 6: composer still holds the placeholder fill; native idle falls through # to the shared composer verdict, which retries rather than confirming. - printf ' \xe2\x9d\xaf /compact\n' > "$resp/5.out" - # 6: send-keys enter (#2) - actually submits - # 7: agent get -> working (submitted) - printf '{"result":{"agent":{"agent_status":"working"}}}\n' > "$resp/7.out" + printf ' \xe2\x9d\xaf /compact\n' > "$resp/6.out" + # 7: send-keys enter (#2) - actually submits + # 8: agent get -> working (submitted) + printf '{"result":{"agent":{"agent_status":"working"}}}\n' > "$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 "/compact" 3 0.01 1.2' "$ROOT" ) @@ -3554,7 +3620,8 @@ test_send_text_submit_idle_native_empty_composer_confirms_delivery() { # that empty verdict is positive delivery, not a swallow. printf '{"result":{"agent":{"agent_status":"idle"}}}\n' > "$resp/2.out" printf '{"result":{"agent":{"agent_status":"idle"}}}\n' > "$resp/4.out" - printf ' \xe2\x9d\xaf\n' > "$resp/5.out" + printf '{"result":{"agent":{"agent":"claude","agent_status":"idle"}}}\n' > "$resp/5.out" + printf ' \xe2\x9d\xaf\n' > "$resp/6.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" ) @@ -3571,9 +3638,10 @@ test_send_text_submit_idle_native_pending_plus_rendered_busy_is_queued() { # and a generating footer after retries is a queued follow-up Enter. printf '{"result":{"agent":{"agent_status":"idle"}}}\n' > "$resp/2.out" printf '{"result":{"agent":{"agent_status":"idle"}}}\n' > "$resp/4.out" - printf ' \xe2\x9d\xaf hello captain\n' > "$resp/5.out" - printf '{"result":{"agent":{"agent_status":"idle"}}}\n' > "$resp/6.out" - printf 'thinking... esc to interrupt\n' > "$resp/7.out" + printf '{"result":{"agent":{"agent":"claude","agent_status":"idle"}}}\n' > "$resp/5.out" + printf ' \xe2\x9d\xaf hello captain\n' > "$resp/6.out" + printf '{"result":{"agent":{"agent_status":"idle"}}}\n' > "$resp/7.out" + printf 'thinking... esc to interrupt\n' > "$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 "hello captain" 1 0.01 0.01' "$ROOT" ) @@ -4542,6 +4610,9 @@ test_wait_for_working_returns_idle_when_never_busy_but_readable test_wait_for_working_returns_unknown_when_never_readable test_wait_for_working_treats_blocked_as_submit_active test_send_text_submit_detects_landed_send +test_send_text_submit_pi_idle_composer_confirms_landed_send +test_send_text_submit_pi_swallowed_enter_stays_pending +test_send_text_submit_native_idle_path_ignores_pi_fallback test_send_text_submit_detects_swallowed_enter test_send_text_submit_popup_autocomplete_requires_second_enter test_send_text_submit_confirms_blocked_after_enter