diff --git a/docs/verification/runtime-backends.md b/docs/verification/runtime-backends.md index bef0095bb91..ddb6ebcd449 100644 --- a/docs/verification/runtime-backends.md +++ b/docs/verification/runtime-backends.md @@ -1705,7 +1705,7 @@ herdr pane process-info --pane w1:p1 --session "$LAB" | jq -c '.result.process_i ``` Before the fix `fm_backend_agent_state herdr` read that second state as `alive`, so `bin/fm-control.sh relaunch` and `bin/fm-spawn.sh --relaunch` were refused for as long as the registration lived, which is hours. -The registration is still present after the wait, and Herdr's own `pane report-agent` leaves the same shape behind on any pane, which is what the lifecycle-control guard uses. +The registration is still present after the wait, and a `pane report-agent` registration whose agent process exits under a nested shell leaves the same shape behind on any pane, which is what the lifecycle-control guard uses; with no nested shell, Herdr releases that registration within about a second. Two vendor facts the fix rests on, both read from the outputs above and from `fm_backend_herdr_pane_process_state`'s `pane process-info` parse: @@ -1744,7 +1744,7 @@ ok - real herdr 0.9.0 + pi 0.85.1: a running registered pi classifies alive at p ok - real herdr 0.9.0 + pi 0.85.1: the registration left behind by a quit pi reads stale-agent and recovers as dead ``` -`tests/fm-control-herdr-smoke.test.sh` proves the same shape through the control plane with no harness launched (the two `stale` lines under "Agent lifecycle control" above): a registration over a real agent-named process reads `alive`, stopping that process makes the pane read `stale-agent` and recover as `dead` while `agent get` still reports the record, `exit` then reports `already-stopped`, and `--relaunch` reuses the same endpoint with the local copy intact. +`tests/fm-control-herdr-smoke.test.sh` proves the same shape through the control plane with no harness launched (the two `stale` lines under "Agent lifecycle control" above): a registration over a real agent-named process running under a nested shell reads `alive`, stopping that process makes the pane read `stale-agent` and recover as `dead` while `agent get` still reports the record, `exit` then reports `already-stopped`, and `--relaunch` reuses the same endpoint with the local copy intact. `tests/fm-backend-herdr.test.sh` pins the logic portably with canned `process-info` bodies over real processes, driving the signals apart: the identical shell-only foreground reads `stale-agent` for a childless shell and `live` when an agent-named process is still a descendant of that shell, a `working`, `done`, or `blocked` record over a shell-only pane reads the same as `idle`, an unreadable process view reads `unknown` and refuses husk closing, a transient prompt helper beside the shell settles into `stale-agent` on the next shell-only sample while a foreground that never settles within the bound still reads `live`, and `busy_state` verifies a `working` record before reporting busy. `tests/fm-crew-state.test.sh` pins the recovery classifier: a stale registration over a shell-only pane reports agent gone rather than alive or unreachable, and a stale `working` record never reports the pane working. A stale-registration pane is never a husk: create, reclaim, presentation recovery, and session cleanup keep refusing it, and only recovery reuses it. diff --git a/tests/fm-control-herdr-smoke.test.sh b/tests/fm-control-herdr-smoke.test.sh index 14749064d1b..cd17f7f52ee 100755 --- a/tests/fm-control-herdr-smoke.test.sh +++ b/tests/fm-control-herdr-smoke.test.sh @@ -83,6 +83,29 @@ $TASK_IDS EOF [ -n "$TAB_ID" ] && [ -n "$PANE_ID" ] || fail "create_task did not return tab/pane ids" +# Herdr starts the pane in the developer's login shell, whose prompt is not this +# test's to choose. A prompt drawn with an agent composer glyph - a `❯` prompt +# is exactly claude's bare composer row - anywhere in the viewport makes the +# final exit case read the composer as holding pending text instead of as +# unproven. An rc-free bash with a shell-glyph prompt on a cleared screen draws +# no recognized composer chrome, so every composer verdict below depends on this +# test alone. `exec` keeps the pane's shell pid. +BASH_BIN=$(command -v bash) || fail "bash not found" +printf -v BASH_Q '%q' "$BASH_BIN" +fm_backend_herdr_send_text_line "$SESSION:$PANE_ID" "exec env PS1='hsmoke\$ ' HISTFILE=/dev/null $BASH_Q --noprofile --norc" \ + || fail "could not pin a neutral shell prompt in the task pane" +fm_backend_herdr_send_text_line "$SESSION:$PANE_ID" "clear" \ + || fail "could not clear the login shell's output from the task pane" +neutral_screen_shown() { # the viewport holds nothing but the neutral prompt + fm_backend_herdr_visible_capture "$SESSION:$PANE_ID" 2>/dev/null \ + | awk 'NF { n++; last = $0 } END { exit(n == 1 && last ~ /^hsmoke\$ *$/ ? 0 : 1) }' +} +for _ in $(seq 1 50); do + ! neutral_screen_shown || break + sleep 0.1 +done +neutral_screen_shown || fail "the task pane did not settle on a cleared neutral shell prompt" + { echo "window=$SESSION:$PANE_ID" echo "endpoint_task_id=hsmoke" @@ -233,6 +256,14 @@ start_agent_process() { || version_fail "a real agent-named foreground process reads '$(fm_backend_herdr_pane_process_state "$SESSION" "$PANE_ID")' rather than 'agent' through pane process-info" } +# Herdr keeps a registration after its agent exits only when a nested +# interactive shell sits under the pane's top shell (issue #4115); an agent that +# exits straight back to the top shell is released within about a second, which +# would race the stale-registration case below. So the agent runs under a +# nested shell, the crew shape `treehouse get` leaves behind. It inherits the +# neutral PS1 and HISTFILE from the top shell's environment. +fm_backend_herdr_send_text_line "$SESSION:$PANE_ID" "$BASH_Q --noprofile --norc" \ + || fail "could not start a nested shell in the task pane" start_agent_process herdr pane report-agent "$PANE_ID" --source fm-control-smoke --agent fm-control-smoke-agent \ --state idle --session "$SESSION" >/dev/null 2>&1 \