diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 791f3411edd..f84ad0607a8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -112,10 +112,14 @@ jobs: set -eu npm install -g tasks-axi tasks-axi --version + # Pinned to the Pi release the fleet runs and these tests were last green + # on. Unpinned, CI picked up Pi 0.99.1, whose changed stock rendering fails + # fm-calm-pi-extension and fm-pi-branch-extension; checking compatibility + # with the latest Pi is the filed follow-up (backlog: firstmate-pi-099-tests). - name: Install the Pi package for the Pi extension tests run: | set -eu - npm install -g @earendil-works/pi-coding-agent + npm install -g @earendil-works/pi-coding-agent@0.87.1 npm ls -g --depth 0 @earendil-works/pi-coding-agent - name: Run portable parallel shard 1 run: | @@ -216,10 +220,14 @@ jobs: # The Pi extension tests read the installed Pi package's own types and # runtime, so without it they gate-skip and pass silently. It is a public # npm package and needs no credential, so CI can hold the real thing. + # Pinned to the Pi release the fleet runs and these tests were last green + # on. Unpinned, CI picked up Pi 0.99.1, whose changed stock rendering fails + # fm-calm-pi-extension and fm-pi-branch-extension; checking compatibility + # with the latest Pi is the filed follow-up (backlog: firstmate-pi-099-tests). - name: Install the Pi package for the Pi extension tests run: | set -eu - npm install -g @earendil-works/pi-coding-agent + npm install -g @earendil-works/pi-coding-agent@0.87.1 npm ls -g --depth 0 @earendil-works/pi-coding-agent - name: Run portable serial shard ${{ matrix.shard }} env: diff --git a/bin/backends/herdr.sh b/bin/backends/herdr.sh index cf908fa11e8..9e42d67e009 100644 --- a/bin/backends/herdr.sh +++ b/bin/backends/herdr.sh @@ -3082,6 +3082,27 @@ fm_backend_herdr_send_literal() { # return "$rc" } +# fm_backend_herdr_launch_line: the short line fm-spawn.sh types to start a +# staged launch file in a Herdr pane. Other backends type `. ''`; Herdr +# instead sources the file inside a /bin/sh that has job control on (set -m), +# so the agent command becomes its own process group and takes the terminal +# foreground the moment it starts, whatever the pane shell is. +# Herdr registers an agent only by probing the pane's foreground process group, +# and it re-probes an agent-free pane only when that group changes or in a +# short window after the screen has been still for a moment. A pane shell +# without job control for sourced commands (fish, or a shell with monitor mode +# off) runs the agent inside its own group, so no group change happens, and an +# agent that keeps redrawing never leaves the screen still: the pane stays +# agent-free and every hook report is held back +# (docs/herdr-backend.md "Agent registration at launch"). +# The file path is the single positional operand, quoted for any pane shell. +fm_backend_herdr_launch_line() { # + local quoted + quoted=$(printf '%s' "$1" | sed "s/'/'\\\\''/g") + # shellcheck disable=SC2016 # $0 is expanded by the job-control sh, not here + printf '%s' "/bin/sh -c 'set -m; . \"\$0\"' '$quoted'" +} + # fm_backend_herdr_normalize_key: map firstmate's key vocabulary (Enter, # Escape, C-c, as used by fm-send.sh --key and stuck-crewmate-recovery) onto # herdr's `pane send-keys` names. Verified empirically: enter, escape/esc, and diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index 5f9c6ecc950..532c5a0b23f 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -272,7 +272,11 @@ # Launch delivery: # Every harness and backend receives its complete launch command from a # never-reused 0600 file in a 0700 home-scoped task namespace under /tmp, while -# the pane receives only a short source line. +# the pane receives only a short line that sources it. On Herdr that line +# sources the file inside a job-controlled /bin/sh so the agent takes the +# terminal foreground as its own process group, which is what Herdr's agent +# registration waits for (bin/backends/herdr.sh fm_backend_herdr_launch_line); +# the launch command therefore runs under /bin/sh there, not the pane shell. # This keeps commands beyond the terminal's roughly 1,024-byte input boundary # intact, prevents a delayed source line from being rebound by a relaunch, and # prevents equal task ids in different Firstmate homes from sharing a file. @@ -300,7 +304,8 @@ # even on a host that never had it set. # An enabled task trace also retains TRACEPARENT. Explicit Firstmate launch # assignments still apply inside the filtered environment. Raw commands must -# be POSIX sh compatible under this opt-in; the absent-file path is unchanged. +# be POSIX sh compatible under this opt-in, and on Herdr always; elsewhere the +# absent-file path is unchanged. # This is an exec environment boundary, not a sandbox for the pane's startup # shell, credential files, same-user processes, or later shell initialization. # See docs/configuration.md for provider/Git setup and supported limits. @@ -5325,7 +5330,11 @@ if ! (umask 077 && printf '%s\n' "$LAUNCH" >"$LAUNCH_STAGE" && fi sleep 0.3 SPAWN_LAUNCH_SENT=1 -spawn_send_literal "$T" ". $(shell_quote "$LAUNCH_FILE")" +if [ "$BACKEND" = herdr ]; then + spawn_send_literal "$T" "$(fm_backend_herdr_launch_line "$LAUNCH_FILE")" +else + spawn_send_literal "$T" ". $(shell_quote "$LAUNCH_FILE")" +fi sleep 0.3 if [ "${HERDR_PROJECTED:-0}" -eq 1 ]; then HERDR_PROJECTION_ABORT_CLEANUP=0 diff --git a/bin/fm-test-run.sh b/bin/fm-test-run.sh index f607ccdb09c..59f317a1f63 100755 --- a/bin/fm-test-run.sh +++ b/bin/fm-test-run.sh @@ -359,6 +359,7 @@ family_for_basename() { fm-launch-prompt-signals-live-e2e.test.sh|\ fm-herdr-version-floor-live-e2e.test.sh|\ fm-herdr-pi-stale-registration-live-e2e.test.sh|\ + fm-herdr-pi-launch-registration-live-e2e.test.sh|\ fm-worker-account-live-e2e.test.sh|\ fm-opencode-primary-live-e2e.test.sh|fm-pi-branch-live-e2e.test.sh|\ fm-pi-branch-responsiveness-live-e2e.test.sh|\ @@ -374,7 +375,8 @@ family_for_basename() { fm-herdr-submit-confirm-live-e2e.test.sh) printf '%s\n' live-harness-optin ;; - fm-backend-herdr.test.sh|fm-backend-tmux-smoke.test.sh|fm-backend.test.sh|\ + fm-backend-herdr.test.sh|fm-backend-herdr-launch-line.test.sh|\ + fm-backend-tmux-smoke.test.sh|fm-backend.test.sh|\ fm-tmux-agent-liveness.test.sh|\ fm-control.test.sh|fm-control-relaunch.test.sh|\ fm-herdr-session-cleanup.test.sh|fm-send-resolve-key.test.sh|fm-send-strict.test.sh|\ diff --git a/docs/configuration.md b/docs/configuration.md index e52f2e5a349..3b7eeec0574 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -971,6 +971,7 @@ Choose the minimum additions for the authentication method actually in use: Verify the selected provider login and Git transport after opting in; Firstmate does not infer credentials from model names or install a secret manager. Raw launch commands run under noninteractive POSIX `sh` with this option and must use compatible syntax. +On the Herdr backend they always run under POSIX `sh` ([Herdr backend](herdr-backend.md#agent-registration-at-launch)). The filter runs at the worker command boundary, after the terminal daemon and pane shell have started; it does not scrub either of those processes. This is not a sandbox: it cannot revoke same-user access to credential files, prevent tools or later shells from loading credentials again, or isolate processes from the same user's other processes. diff --git a/docs/herdr-backend.md b/docs/herdr-backend.md index 22ad9d06436..2cef9865df6 100644 --- a/docs/herdr-backend.md +++ b/docs/herdr-backend.md @@ -22,6 +22,7 @@ Herdr provides the terminal session while Treehouse continues to provide task wo | Why a seeded default tab is or is not closed | [Default-tab prune safety](#default-tab-prune-safety) | | What task metadata records for a Herdr endpoint | [Endpoint metadata](#endpoint-metadata) | | How text and keys reach a worker and how delivery is confirmed | [Current transport behavior](#current-transport-behavior) and [Composer and injection safety](#composer-and-injection-safety) | +| Why a new worker registers as an agent, or reads as no agent | [Agent registration at launch](#agent-registration-at-launch) | | What happens after a Herdr server restart and how liveness is judged | [Restart and liveness behavior](#restart-and-liveness-behavior) | | How blocked transitions arrive and what happens without protocol 16 | [Push events and polling fallback](#push-events-and-polling-fallback) | | Where the away daemon runs and how it stops | [Away-mode supervisor support](#away-mode-supervisor-support) | @@ -657,6 +658,27 @@ Claude Code itself then removes it from the submitted prompt, so a Claude Code p `bin/fm-operational-input.sh` owns current operational construction and parsing, and the AFK skill owns legacy away-input compatibility. No Herdr-specific copy of that protocol exists. +## Agent registration at launch + +Herdr registers an agent in a pane only by probing that pane's foreground process group. +It probes an agent-free pane again only when that group changes, or during a short window that opens when the screen changes after being still (8 seconds after 2 still seconds in the Herdr 0.9.2 source). +Until a probe finds the agent, Herdr also holds back the Pi integration's lifecycle reports, so the pane reads no agent and `agent_status` `unknown`. + +A pane shell that runs sourced commands inside its own process group therefore hides a new agent from that probe. +fish does this for every sourced command, and so does any shell with monitor mode off. +When the launch runs past the probe window and the agent then keeps redrawing, as a Pi working on its brief does, the pane stays agent-free for as long as the agent works. +Steering then treats the live worker as exited and rings no doorbell, and liveness reads misjudge the pane. + +So on Herdr, `bin/fm-spawn.sh` does not type `. ''`. +It types the line that `fm_backend_herdr_launch_line` in `bin/backends/herdr.sh` builds, which sources the same staged file inside a `/bin/sh` with job control on. +Each command in the file, the agent included, then runs as its own process group and takes the terminal foreground as it starts, so Herdr probes it at once whatever the pane shell is. +The terminal returns to the pane shell when the agent exits, and the line returns the agent's exit status. +This covers every fresh spawn and relaunch of every harness on Herdr. +The launch command therefore runs under POSIX `sh` on Herdr rather than the pane shell, so a raw launch command must use POSIX `sh` syntax there. + +`tests/fm-backend-herdr-launch-line.test.sh` pins the process-group guarantee in a real pseudo-terminal without Herdr. +`tests/fm-herdr-pi-launch-registration-live-e2e.test.sh` proves the registration against the real Herdr and Pi, and [verification](verification/runtime-backends.md#agent-registration-at-launch) records the measurement. + ## Restart and liveness behavior ### Husks after a server restart @@ -845,7 +867,9 @@ tests/fm-backend-herdr-workspace-per-home-e2e.test.sh tests/fm-backend-herdr-launcher-workspace-e2e.test.sh tests/fm-backend-herdr-presentation-e2e.test.sh tests/fm-backend-herdr-agent-exit-shell-e2e.test.sh +tests/fm-backend-herdr-launch-line.test.sh tests/fm-herdr-pi-stale-registration-live-e2e.test.sh +tests/fm-herdr-pi-launch-registration-live-e2e.test.sh tests/fm-backend-herdr-eventwait-smoke.test.sh tests/fm-control-herdr-smoke.test.sh tests/fm-herdr-session-cleanup.test.sh diff --git a/docs/verification/runtime-backends.md b/docs/verification/runtime-backends.md index 807d1272052..ad924dbab08 100644 --- a/docs/verification/runtime-backends.md +++ b/docs/verification/runtime-backends.md @@ -1734,6 +1734,48 @@ poll 8: {"agent_status":"working","session":".../2026-09-21T14-10-08-776Z_01a0c4 The read that supplies the reference is `bin/backends/herdr.sh`'s `fm_backend_herdr_pane_agent_session_ref`, the per-harness rule is `bin/fm-control-lib.sh`'s `fm_control_relaunch_resume_flag`, and the launch argument is composed by `relaunch_resume_args` in `bin/fm-spawn.sh`; `docs/herdr-backend.md` "Agent status authority and relaunch" owns the contract. Nothing here changes `resume` as a control verb, and only a relaunch asks for it. +### Agent registration at launch + +Measured 2026-09-29 on Linux x86_64 against Herdr 0.9.2 and Pi 0.87.1, in an isolated `fm-lab-` session (`bin/fm-herdr-lab.sh`), with the Herdr Pi integration version 9 installed. + +A freshly launched Pi stays unregistered when the pane shell sources the launch without job control and the Pi keeps redrawing. +The staged launch keeps the screen busy for 10 seconds (past Herdr's acquisition window), then starts Pi with no prompt and an extension that sets a status line every 150 ms, which stands in for a Pi working on its brief: + +```sh +# /tmp/fm-hpaf/launch-bug.sh +export COMPACT_ADVISER_DISABLE=1; sh -c 'i=0; while [ $i -lt 50 ]; do printf .; sleep 0.2; i=$((i+1)); done; echo'; env -u CURSOR_AGENT FM_PI_HARNESS=pi "$PI" --no-session --no-context-files -e /tmp/fm-hpaf/churn.ts +``` + +Each pane below first ran a nested shell (`fish`, or `bash --norc --noprofile` followed by `set +m`), then the typed line, then was read every second with `herdr pane get --session "$LAB" | jq -c '.result.pane | {agent, agent_status}'` and `herdr pane process-info --pane --session "$LAB"`: + +| Nested shell | Typed line | Registration | Foreground group | +| --- | --- | --- | --- | +| fish | `. /tmp/fm-hpaf/launch-bug.sh` | `{"agent":null,"agent_status":"unknown"}` for 35 s | `fish`, `pi` | +| bash, `set +m` | `. /tmp/fm-hpaf/launch-bug.sh` | `{"agent":null,"agent_status":"unknown"}` for 20 s | `bash`, `pi` | +| bash, job control on | `. /tmp/fm-hpaf/launch-bug.sh` | `{"agent":"pi","agent_status":"idle"}` from 12 s | `pi` | +| fish | `/bin/sh -c 'set -m; . "$0"' '/tmp/fm-hpaf/launch-bug.sh'` | `{"agent":"pi","agent_status":"idle"}` from 12 s | `pi` | +| bash, `set +m` | `/bin/sh -c 'set -m; . "$0"' '/tmp/fm-hpaf/launch-bug.sh'` | `{"agent":"pi","agent_status":"idle"}` from 12 s | `pi` | + +After `/quit` in a fixed pane, the foreground returned to the nested shell and a typed `echo alive-$?` printed `alive-0`. +Typing `sh /tmp/fm-hpaf/launch-bug.sh` from fish did not register Pi either: that gives the whole launch one new group when it starts, and the busy preamble spends Herdr's window before Pi appears inside the same group. + +The live guard that refreshes this record runs by default wherever Herdr and Pi are installed, spends no model token, and fails naming both versions: + +```sh +tests/fm-herdr-pi-launch-registration-live-e2e.test.sh +``` + +Observed 2026-09-29: + +```text +# pi 0.87.1 under herdr 0.9.2: launch-line pane registered pi, foreground [{"name":"pi","argv0":null}] +ok - real herdr 0.9.2 + pi 0.87.1: a Pi started through the Herdr launch line registers as an agent even when the pane shell has no job control +# herdr 0.9.2 still leaves a sourced, continuously redrawing Pi unregistered under a shell without job control (foreground [{"name":"bash","argv0":null},{"name":"pi","argv0":null}]): the launch line is what registers it +``` + +`tests/fm-backend-herdr-launch-line.test.sh` pins the portable half in a real pseudo-terminal without Herdr: from a bash with job control off, the typed line gives a stand-in agent its own process group holding the terminal foreground, returns the terminal and the agent's exit status to the shell, and the plain source line from the same shell keeps the agent in the shell's group. +`docs/herdr-backend.md` "Agent registration at launch" owns the contract. + ### Away-mode transport The away daemon is no longer launched on Pi; the away posture there is the record `bin/fm-afk-contract.sh` owns. diff --git a/tests/fm-backend-herdr-launch-line.test.sh b/tests/fm-backend-herdr-launch-line.test.sh new file mode 100755 index 00000000000..805af4b108a --- /dev/null +++ b/tests/fm-backend-herdr-launch-line.test.sh @@ -0,0 +1,129 @@ +#!/usr/bin/env bash +# tests/fm-backend-herdr-launch-line.test.sh - portable regression for the line +# bin/fm-spawn.sh types into a Herdr pane to start a staged launch file +# (bin/backends/herdr.sh fm_backend_herdr_launch_line). +# +# Herdr registers an agent only by probing the pane's foreground process group, +# so the agent must take the terminal foreground as its OWN process group when +# it starts (docs/herdr-backend.md "Agent registration at launch"). A pane shell +# that does not job-control sourced commands - fish, or any shell with monitor +# mode off - runs a sourced `. ''` agent inside the shell's own group, and +# Herdr never notices it. This suite runs the real typed line from exactly such +# a shell inside a real pseudo-terminal, with a stand-in agent that reports its +# own process group and the terminal's foreground group, so the guarantee is +# proven with real processes and no Herdr. The counterfactual `. ''` line +# runs from the same shell and must still share the shell's group, so the case +# can never pass vacuously. The real-Herdr, real-Pi half of the proof is +# tests/fm-herdr-pi-launch-registration-live-e2e.test.sh. +set -u + +# shellcheck source=tests/lib.sh +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +command -v python3 >/dev/null 2>&1 || { echo "skip: python3 not found (the pty driver needs it)"; exit 0; } +[ -x /bin/sh ] || { echo "skip: /bin/sh not found"; exit 0; } +command -v bash >/dev/null 2>&1 || { echo "skip: bash not found"; exit 0; } + +TMP_ROOT=$(fm_test_tmproot fm-herdr-launch-line) + +# shellcheck source=/dev/null +. "$ROOT/bin/backends/herdr.sh" + +# The stand-in agent: records " " and +# exits with the status its caller asked for, so status propagation is checked +# too. +AGENT="$TMP_ROOT/agent" +cat > "$AGENT" <<'PY' +#!/usr/bin/env python3 +import os, sys +fd = os.open("/dev/tty", os.O_RDONLY) +with open(os.environ["FM_TEST_AGENT_OUT"], "a") as out: + out.write("%d %d %d\n" % (os.getpid(), os.getpgrp(), os.tcgetpgrp(fd))) +sys.exit(int(os.environ.get("FM_TEST_AGENT_STATUS", "0"))) +PY +chmod +x "$AGENT" + +# The pty driver: the child becomes a session leader holding a fresh terminal +# as its foreground group, exactly like a Herdr pane's top shell, then runs +# . Output is drained until the child exits; its status is returned. +PTY="$TMP_ROOT/pty.py" +cat > "$PTY" <<'PY' +import os, sys +pid, master = os.forkpty() +if pid == 0: + os.execvp(sys.argv[1], sys.argv[1:]) +while True: + try: + if not os.read(master, 4096): + break + except OSError: + break +_, status = os.waitpid(pid, 0) +sys.exit(os.waitstatus_to_exitcode(status) if hasattr(os, "waitstatus_to_exitcode") else (status >> 8)) +PY + +# stage_launch : a staged launch file shaped like the ones +# fm-spawn writes (exports, then an env-prefixed agent command), in a directory +# whose name carries a space and a single quote so the line's quoting is real. +sq() { printf "'%s'" "$(printf '%s' "$1" | sed "s/'/'\\\\''/g")"; } +stage_launch() { # + local dir=$1 status=$2 file + mkdir -p "$dir" + file="$dir/launch.s1.sh" + printf 'export COMPACT_ADVISER_DISABLE=1; env -u CURSOR_AGENT FM_TEST_AGENT_STATUS=%s FM_TEST_AGENT_OUT=%s %s --tui-mode regular\n' \ + "$status" "$(sq "$dir/agent.out")" "$(sq "$AGENT")" > "$file" + printf '%s' "$file" +} + +# run_in_pane_shell : run the typed line from a bash that has +# job control OFF (the fish-like pane shell), inside a real pty, then record the +# shell's own group, the terminal foreground after the line returned, and the +# line's exit status. +run_in_pane_shell() { # + local dir=$1 line=$2 + # shellcheck disable=SC2016 # expanded by the pane-shell bash, not here + python3 "$PTY" bash --norc --noprofile -c ' + set +m + eval "$1" + rc=$? + python3 -c "import os; print(os.getpgrp(), os.tcgetpgrp(os.open(\"/dev/tty\", os.O_RDONLY)), $rc)" > "$2" + ' pane-shell "$line" "$dir/shell.out" +} + +test_launch_line_gives_the_agent_its_own_foreground_group() { + local dir file line agent_pid agent_pgid agent_fg shell_pgid shell_fg rc + dir="$TMP_ROOT/it's a launch dir" + file=$(stage_launch "$dir" 7) + line=$(fm_backend_herdr_launch_line "$file") + run_in_pane_shell "$dir" "$line" || fail "the pty driver failed for: $line" + [ -s "$dir/agent.out" ] || fail "the typed Herdr launch line never started the staged agent: $line" + read -r agent_pid agent_pgid agent_fg < "$dir/agent.out" + read -r shell_pgid shell_fg rc < "$dir/shell.out" + [ "$agent_pgid" = "$agent_pid" ] \ + || fail "the agent must lead its own process group (pid $agent_pid, pgid $agent_pgid)" + [ "$agent_pgid" != "$shell_pgid" ] \ + || fail "the agent still shares the pane shell's process group ($shell_pgid), so Herdr sees no group change" + [ "$agent_fg" = "$agent_pgid" ] \ + || fail "the agent's group ($agent_pgid) must hold the terminal foreground while it runs, got $agent_fg" + [ "$shell_fg" = "$shell_pgid" ] \ + || fail "the terminal must return to the pane shell's group ($shell_pgid) after the agent exits, got $shell_fg" + [ "$rc" = 7 ] || fail "the typed line must return the agent's exit status (7), got $rc" + pass "herdr launch line: from a shell without job control, the agent runs as its own foreground process group and the terminal returns to the shell" +} + +test_plain_source_line_shares_the_shell_group() { + local dir file agent_pid agent_pgid agent_fg shell_pgid shell_fg rc + dir="$TMP_ROOT/plain source" + file=$(stage_launch "$dir" 0) + run_in_pane_shell "$dir" ". $(sq "$file")" || fail "the pty driver failed for the plain source line" + [ -s "$dir/agent.out" ] || fail "the plain source line never started the staged agent" + read -r agent_pid agent_pgid agent_fg < "$dir/agent.out" + read -r shell_pgid shell_fg rc < "$dir/shell.out" + [ "$agent_pgid" = "$shell_pgid" ] \ + || fail "counterfactual drifted: a sourced agent under a shell without job control now gets its own group ($agent_pgid vs shell $shell_pgid), so the first case no longer proves anything" + [ "$agent_fg" = "$shell_pgid" ] || fail "counterfactual drifted: the foreground group moved to $agent_fg" + pass "herdr launch line counterfactual: a plain '. ' agent shares the pane shell's group, the shape Herdr never registers" +} + +test_launch_line_gives_the_agent_its_own_foreground_group +test_plain_source_line_shares_the_shell_group diff --git a/tests/fm-control-relaunch.test.sh b/tests/fm-control-relaunch.test.sh index 1242dc26bfe..126b07e928d 100755 --- a/tests/fm-control-relaunch.test.sh +++ b/tests/fm-control-relaunch.test.sh @@ -2004,14 +2004,17 @@ case "${1:-} ${2:-}" in 'pane send-text') # Mirrors the tmux fake's `becomes`: delivering the launch brief is what # makes an agent exist on this pane, so the control plane's alive-wait can - # observe the replacement come up. A launch arrives as a short line sourcing - # the staged launch file rather than the literal command, so read that file - # back before deciding what was delivered - exactly as the tmux fake above - # and tests/fixtures.sh do. + # observe the replacement come up. A launch arrives as a short line that + # sources the staged launch file (on Herdr inside a job-controlled /bin/sh, + # bin/backends/herdr.sh fm_backend_herdr_launch_line) rather than the + # literal command, so read that file back before deciding what was + # delivered - exactly as the tmux fake above and tests/fixtures.sh do. payload=${4:-} - case "$payload" in - ". '"*"'") staged=${payload#". '"}; staged=${staged%"'"}; [ ! -f "$staged" ] || payload=$(cat "$staged") ;; - esac + staged=$(printf '%s' "$payload" | sed -n "s/.*'\(\/[^']*\/launch\.[^']*\.sh\)'\$/\1/p") + [ -z "$staged" ] || [ ! -f "$staged" ] || { + printf '%s\n' "$payload" > "$D/launch-line" + payload=$(cat "$staged") + } case "$payload" in *'encode launch-brief'* | *'Firstmate operational input waiting: read'*) printf '%s\n' "$payload" > "$D/launched-command" @@ -2117,7 +2120,7 @@ herdr_case_or_skip() { # [session] [surviving-pane] } test_herdr_relaunch_resumes_only_the_registered_pi_session() { - local dir out rc=0 command registered + local dir out rc=0 command registered launch_line staged for registered in pi claude; do herdr_case_or_skip "resume-$registered" "resume-$registered" || { echo "skip - herdr relaunch needs jq (the herdr adapter parses JSON with it)" @@ -2133,6 +2136,10 @@ test_herdr_relaunch_resumes_only_the_registered_pi_session() { out=$(run_spawn "$dir" "resume-$registered" --relaunch --harness pi) || rc=$? expect_code 0 "$rc" "Herdr Pi relaunch should complete ($registered registration)"$'\n'"$out" command=$(cat "$dir/fake/launched-command") + launch_line=$(cat "$dir/fake/launch-line") + staged=$(printf '%s' "$launch_line" | sed -n "s/.*'\(\/[^']*\/launch\.[^']*\.sh\)'\$/\1/p") + assert_equals "$(bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_launch_line "$1"' "$ROOT" "$staged")" "$launch_line" \ + "a Herdr relaunch must start its staged launch through the Herdr launch line, so the agent takes its own foreground group" if [ "$registered" = pi ]; then assert_contains "$command" "--session '/tmp/pi-bound-session.jsonl'" \ "the replacement Pi must resume the session that owns Herdr status authority" diff --git a/tests/fm-herdr-pi-launch-registration-live-e2e.test.sh b/tests/fm-herdr-pi-launch-registration-live-e2e.test.sh new file mode 100755 index 00000000000..5f12186a7a5 --- /dev/null +++ b/tests/fm-herdr-pi-launch-registration-live-e2e.test.sh @@ -0,0 +1,150 @@ +#!/usr/bin/env bash +# Default-on live guard: a Pi started by Firstmate's Herdr launch line registers +# as a Herdr agent even when the pane shell does not job-control sourced +# commands, against the REAL Herdr and the REAL Pi. +# +# Herdr registers an agent only by probing the pane's foreground process group, +# and re-probes an agent-free pane only when that group changes or in a short +# window after its screen has been still. fish, or any shell with monitor mode +# off, runs a sourced `. ''` agent inside the shell's own group, so an +# agent that starts after a busy launch and keeps redrawing is never probed: +# the pane reads agent-free for as long as the agent works, and its lifecycle +# hook reports are held back (docs/herdr-backend.md "Agent registration at +# launch"). The launch line fm-spawn.sh types on Herdr, +# fm_backend_herdr_launch_line, gives the agent its own foreground group. +# +# This guard reproduces that shape in two panes of one isolated lab session: a +# bash with monitor mode off (the fish shape, available everywhere), a launch +# that keeps the screen busy for longer than Herdr's acquisition window, then a +# real Pi whose screen never goes still. The pane started through the Herdr +# launch line must register `pi`; the pane started through the plain source line +# is the control, and whether this Herdr release still misses it is reported +# rather than asserted, so a vendor fix does not fail the guard. It fails naming +# both versions when the launch line no longer registers Pi. +# +# Pi runs with an empty scratch agent directory and no prompt, so no model token +# is spent and the shared live gate runs it by default wherever the tools are +# installed. Every Herdr call goes through bin/fm-herdr-lab.sh on a named, +# throwaway lab session, never the default one. +set -u + +# shellcheck source=tests/lib.sh +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +fm_live_gate default-on FM_HERDR_PI_LAUNCH_REGISTRATION_LIVE_E2E herdr pi jq bash + +# shellcheck source=tests/herdr-test-safety.sh +. "$ROOT/tests/herdr-test-safety.sh" +herdr_forget_inherited_pane + +HERDR_VERSION=$(herdr --version 2>&1 | head -1) +HERDR_VERSION=${HERDR_VERSION#herdr } +PI_VERSION=$(pi --version 2>/dev/null | head -1 | tr -d '\r') +[ -n "$PI_VERSION" ] || PI_VERSION=unknown +version_fail() { # + fail "$1 [herdr $HERDR_VERSION, pi $PI_VERSION]" +} + +LAB_HELPER="$ROOT/bin/fm-herdr-lab.sh" +SESSION=$("$LAB_HELPER" name pi-launch-reg) || fail "could not generate an isolated Herdr lab session name" +TMP_ROOT=$(fm_test_tmproot fm-herdr-pi-launch-reg) +cleanup_all() { + local rc=$? + trap - EXIT + "$LAB_HELPER" teardown "$SESSION" || rc=1 + fm_test_cleanup + exit "$rc" +} +trap cleanup_all EXIT +"$LAB_HELPER" provision "$SESSION" || fail "could not provision the isolated Herdr lab session" +lab() { "$LAB_HELPER" run "$SESSION" "$@"; } + +# shellcheck source=/dev/null +. "$ROOT/bin/backends/herdr.sh" + +PI_BIN=$(command -v pi) +mkdir -p "$TMP_ROOT/cwd" "$TMP_ROOT/pi-agent" "$TMP_ROOT/staged" + +# Keeps Pi's screen changing every 150 ms, as a working agent's does, and grants +# session-only trust so no dialog is involved. +CHURN_EXT="$TMP_ROOT/churn-extension.ts" +cat > "$CHURN_EXT" <<'EOF' +export default function (pi: any) { + pi.on("project_trust", () => ({ trusted: "yes", remember: false })); + pi.on("session_start", (_event: any, ctx: any) => { + let tick = 0; + setInterval(() => ctx.ui.setStatus("churn", `churn ${tick++}`), 150); + }); +} +EOF + +sq() { printf "'%s'" "$(printf '%s' "$1" | sed "s/'/'\\\\''/g")"; } +# A staged launch shaped like fm-spawn's: exports, a preamble that keeps the +# screen busy for 10 s (longer than Herdr's 8 s acquisition window), then Pi. +LAUNCH_FILE="$TMP_ROOT/staged/launch.s1.sh" +printf '%s\n' "export COMPACT_ADVISER_DISABLE=1; sh -c 'i=0; while [ \$i -lt 50 ]; do printf .; sleep 0.2; i=\$((i+1)); done; echo'; env -u CURSOR_AGENT PI_CODING_AGENT_DIR=$(sq "$TMP_ROOT/pi-agent") FM_PI_HARNESS=pi $(sq "$PI_BIN") --no-session --no-context-files --offline -e $(sq "$CHURN_EXT")" \ + > "$LAUNCH_FILE" + +WS_OUT=$(lab workspace create --cwd "$TMP_ROOT/cwd" --label pi-launch-reg --no-focus) \ + || fail "could not create the lab workspace: $WS_OUT" +WS=$(printf '%s' "$WS_OUT" | jq -r '.result.workspace.workspace_id // empty') +[ -n "$WS" ] || fail "workspace create returned no workspace id" + +new_pane() { #