From f35e20bec6827b09d7f1c9fa6684195ffe37eb49 Mon Sep 17 00:00:00 2001 From: ecloin Date: Thu, 6 Aug 2026 17:20:03 -0400 Subject: [PATCH] feat(herdr): name each spawned agent after its task Herdr's agents sidebar lists agents by the agent record's own name field, which is separate from the fm- tab label, so every firstmate worker read as the same anonymous entry there. After the launch key lands, a herdr spawn now issues a bounded best-effort `agent rename fm-` (2ndmate- for a secondmate, matching the home workspace label form). The name is presentation only: it never gates the spawn, retries are bounded and silent, and no code path reads it back for identity, lookup, or targeting - Herdr would accept a unique agent name as a selector, so the adapter keeps addressing recorded pane ids. --- bin/backends/herdr.sh | 64 ++++++++ bin/fm-spawn.sh | 9 ++ docs/herdr-backend.md | 3 + docs/verification/runtime-backends.md | 1 + tests/fm-backend-herdr.test.sh | 201 ++++++++++++++++++++++++++ 5 files changed, 278 insertions(+) diff --git a/bin/backends/herdr.sh b/bin/backends/herdr.sh index 7d9afa4641..8ff7b7e051 100644 --- a/bin/backends/herdr.sh +++ b/bin/backends/herdr.sh @@ -1947,6 +1947,70 @@ fm_backend_herdr_agent_alive() { # esac } +# --- agent naming: sidebar DECORATION ONLY ----------------------------------- +# +# Herdr's agents sidebar lists each registered agent by the agent record's own +# `name` field, which is separate from the fm- TAB label, so without a name +# every firstmate worker in the fleet reads as the same anonymous entry there. +# `herdr agent rename ` sets that field and `--clear` removes it +# (verified on herdr 0.8.x: the name comes back on `agent list`/`agent get` and +# the sidebar renders it). +# +# Like the tab label and the presentation token (see this file's header), the +# name never authorizes lookup, adoption, reuse, closure, task ownership, or +# endpoint selection, and no code path reads it back for control flow. Recovery +# and orphan discovery keep matching fm- TAB labels through +# fm_backend_herdr_list_live. +# +# That contract is ours to keep rather than Herdr's to enforce: `herdr agent +# --help` states that a target may be "a unique agent name", so Herdr itself +# would happily resolve one of these names as a selector, and two homes running +# the same task id would make that selector ambiguous. Every adapter call +# therefore keeps addressing the exact recorded pane id, never a name. +# +# FM_BACKEND_HERDR_NAME_ATTEMPTS / _INTERVAL bound the retry window below; +# tests override them to keep the fail-open case fast. +FM_BACKEND_HERDR_NAME_ATTEMPTS=${FM_BACKEND_HERDR_NAME_ATTEMPTS:-5} +FM_BACKEND_HERDR_NAME_INTERVAL=${FM_BACKEND_HERDR_NAME_INTERVAL:-0.4} + +# fm_backend_herdr_agent_name: the sidebar name for 's . A +# secondmate gets the same 2ndmate- form its home's own workspace already +# carries (fm_backend_herdr_workspace_label); every other kind gets the fm- +# form its task tab is already labeled with. +fm_backend_herdr_agent_name() { # + case "$1" in + secondmate) printf '2ndmate-%s' "$2" ;; + *) printf 'fm-%s' "$2" ;; + esac +} + +# fm_backend_herdr_name_agent_best_effort: give 's agent the sidebar +# name . +# +# The agent record exists only once the harness registers itself, which real +# claude and codex were measured doing 90-490ms after the launch key lands +# (docs/herdr-backend.md "Native agent-state submit confirmation"), so a rename +# issued the instant firstmate presses Enter can legitimately lose that race. +# Retry a BOUNDED number of times and then give up silently. A successful +# rename is itself proof the agent had registered, so no separate presence +# probe is needed. +# +# Always returns 0 and prints nothing: the name is cosmetic, and a spawn must +# never fail, or stall past this bounded window, over it. +fm_backend_herdr_name_agent_best_effort() { # + local name=$2 attempts=$FM_BACKEND_HERDR_NAME_ATTEMPTS interval=$FM_BACKEND_HERDR_NAME_INTERVAL i + [ -n "$name" ] || return 0 + fm_backend_herdr_parse_target "$1" || return 0 + case "$attempts" in ''|*[!0-9]*|0) attempts=1 ;; esac + case "$interval" in ''|*[!0-9.]*) interval=0 ;; esac + for ((i = 0; i < attempts; i++)); do + [ "$i" -eq 0 ] || sleep "$interval" + fm_backend_herdr_cli "$FM_BACKEND_HERDR_SESSION" agent rename \ + "$FM_BACKEND_HERDR_PANE" "$name" >/dev/null 2>&1 && return 0 + done + return 0 +} + # fm_backend_herdr_create_task: create the task's tab (one pane) in # ("session:workspace_id"). Herdr does NOT enforce label # uniqueness itself (verified: two tabs can share a label), so the duplicate diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index 44d1184b09..94ab4f633e 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -2333,6 +2333,15 @@ if [ "$KIND" = secondmate ] && [ "${FM_SKIP_SECONDMATE_INHERIT:-0}" != 1 ]; then fi fi +# Name this worker in herdr's agents sidebar, so the captain sees one entry per +# task there instead of an indistinguishable row per worker. Runs last, after +# the launch key and every delivery confirmation, because it is pure +# presentation: it never gates the spawn, its retry window is bounded, and +# nothing ever reads the name back (bin/backends/herdr.sh "agent naming"). +if [ "$BACKEND" = herdr ]; then + fm_backend_herdr_name_agent_best_effort "$T" "$(fm_backend_herdr_agent_name "$KIND" "$ID")" +fi + SPAWN_DELIVERY= [ -z "$MODE" ] || SPAWN_DELIVERY=" mode=$MODE yolo=$YOLO" echo "spawned $ID harness=$HARNESS kind=$KIND$SPAWN_DELIVERY window=$META_WINDOW worktree=$WT" diff --git a/docs/herdr-backend.md b/docs/herdr-backend.md index fc92fd2fb1..f7aa13de8e 100644 --- a/docs/herdr-backend.md +++ b/docs/herdr-backend.md @@ -63,6 +63,9 @@ Avoid naming a personal workspace `firstmate` or `2ndmate-` for that reason, An older secondmate workspace using `firstmate-` is not migrated automatically; rename it manually before expecting new tasks or recovery to use it. Recovery and list-live still scan the first workspace matching the home label, because they address panes they already recorded rather than choosing where new work goes. +After the launch key lands, a spawn also gives the worker's Herdr agent record the sidebar name `fm-`, or `2ndmate-` for a secondmate, so the agents sidebar distinguishes workers instead of listing them all identically. +That name is decoration on a bounded best-effort call: it never fails a spawn, and nothing reads it back for identity, lookup, or targeting. + Existing task operations use recorded endpoint ids and do not move a live task when labels change. The per-home workspace is reused while it has task tabs. Closing its last tab can remove the workspace, and the next spawn recreates it. diff --git a/docs/verification/runtime-backends.md b/docs/verification/runtime-backends.md index 4af5877693..9ea8a5fdbc 100644 --- a/docs/verification/runtime-backends.md +++ b/docs/verification/runtime-backends.md @@ -214,6 +214,7 @@ The CLI matrix was checked directly: | Native state | `herdr agent get ` | Working and done transitions were visible; native `busy` remains positive activity evidence, while native `idle` cannot close a turn and the adapter's semantic lifecycle decides worker state. | | Restart | guarded named-session stop then start | Workspace, tab, pane, and labels persisted; the agent process and registration did not. | | Close | `herdr pane close --session ` | The exact one-pane task tab closed; closing a final tab could remove the workspace. | +| Agent sidebar name | `herdr agent rename ` | Available from 0.7.4 (`herdr agent --help` lists `herdr agent rename |--clear`); a 0.8.x rename set the record's `name`, which then appeared in `herdr agent list` and in the sidebar. The same help text notes a target may be a unique agent name, so the adapter keeps addressing pane ids only. | All destructive verification used `bin/fm-herdr-lab.sh` with a non-default `fm-lab-` name and a byte-identical default-session tripwire. No ambient `herdr server stop` command is a supported test operation. diff --git a/tests/fm-backend-herdr.test.sh b/tests/fm-backend-herdr.test.sh index b76393da41..ddd5699333 100755 --- a/tests/fm-backend-herdr.test.sh +++ b/tests/fm-backend-herdr.test.sh @@ -4198,6 +4198,199 @@ test_wait_transition_clean_timeout_returns_1() { pass "fm_backend_herdr_wait_transition: stock macOS Bash clean timeout closes fd 9 and returns 1" } +# --- agent naming: per-task sidebar decoration, never identity --------------- + +# rename_calls -> the `agent rename` invocations the adapter made, one +# unit-separated line each. +rename_calls() { # + grep -F $'\x1f''agent'$'\x1f''rename' "$1" || true +} + +test_agent_name_uses_the_task_tab_form_for_workers() { + local name + name=$(bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_agent_name crewmate herdr-agent-names' "$ROOT") + [ "$name" = "fm-herdr-agent-names" ] || fail "a crewmate's sidebar name should be fm-, got '$name'" + name=$(bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_agent_name scout probe-1' "$ROOT") + [ "$name" = "fm-probe-1" ] || fail "a scout's sidebar name should be fm-, got '$name'" + pass "fm_backend_herdr_agent_name: crewmates and scouts are named after their fm- task tab" +} + +test_agent_name_uses_the_home_form_for_a_secondmate() { + local name + name=$(bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_agent_name secondmate design' "$ROOT") + [ "$name" = "2ndmate-design" ] || fail "a secondmate's sidebar name should match its home workspace label form, got '$name'" + pass "fm_backend_herdr_agent_name: a secondmate carries the same 2ndmate- form as its home workspace" +} + +test_name_agent_renames_the_exact_pane_once_when_the_agent_is_registered() { + local dir log resp fb calls + dir="$TMP_ROOT/name-agent-ok"; mkdir -p "$dir/responses" + log="$dir/log"; resp="$dir/responses"; : > "$log" + fb=$(make_herdr_fakebin "$dir") + PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" \ + FM_BACKEND_HERDR_NAME_INTERVAL=0 \ + bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_name_agent_best_effort sess:w1:p2 fm-task-a' "$ROOT" + expect_code 0 $? "naming an agent must always report success" + calls=$(rename_calls "$log") + [ "$(printf '%s\n' "$calls" | grep -c .)" = 1 ] \ + || fail "a rename that lands first try must not be repeated, got: $calls" + assert_contains "$calls" $'\x1f''agent'$'\x1f''rename'$'\x1f''w1:p2'$'\x1f''fm-task-a' \ + "the rename did not target the task's exact pane with its task name" + assert_contains "$calls" 'HERDR_SESSION=sess' "the rename was not scoped to the task's own herdr session" + pass "fm_backend_herdr_name_agent_best_effort: one session-scoped rename of the task's exact pane" +} + +test_name_agent_retries_until_the_harness_registers_its_agent() { + local dir log resp fb calls + dir="$TMP_ROOT/name-agent-race"; mkdir -p "$dir/responses" + log="$dir/log"; resp="$dir/responses"; : > "$log" + fb=$(make_herdr_fakebin "$dir") + # The agent record does not exist yet for the first two attempts, exactly as + # it does not for the first few hundred ms after the launch key lands. + printf '1\n' > "$resp/1.exit" + printf '1\n' > "$resp/2.exit" + PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" \ + FM_BACKEND_HERDR_NAME_INTERVAL=0 FM_BACKEND_HERDR_NAME_ATTEMPTS=5 \ + bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_name_agent_best_effort sess:w1:p2 fm-task-b' "$ROOT" + expect_code 0 $? "naming an agent must always report success" + calls=$(rename_calls "$log") + [ "$(printf '%s\n' "$calls" | grep -c .)" = 3 ] \ + || fail "the rename should retry past a not-yet-registered agent and stop on the first success, got: $calls" + pass "fm_backend_herdr_name_agent_best_effort: retries past the agent-registration race, then stops" +} + +test_name_agent_gives_up_silently_within_a_bounded_window() { + local dir log resp fb out calls i + dir="$TMP_ROOT/name-agent-fail"; mkdir -p "$dir/responses" + log="$dir/log"; resp="$dir/responses"; : > "$log" + fb=$(make_herdr_fakebin "$dir") + for i in 1 2 3 4 5 6; do printf '1\n' > "$resp/$i.exit"; done + out=$(PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" \ + FM_BACKEND_HERDR_NAME_INTERVAL=0 FM_BACKEND_HERDR_NAME_ATTEMPTS=3 \ + bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_name_agent_best_effort sess:w1:p2 fm-task-c' "$ROOT" 2>&1) + expect_code 0 $? "a rename that never lands must still report success" + [ -z "$out" ] || fail "a failed rename must stay silent, got: $out" + calls=$(rename_calls "$log") + [ "$(printf '%s\n' "$calls" | grep -c .)" = 3 ] \ + || fail "a never-landing rename must stop at its configured attempt budget, got: $calls" + pass "fm_backend_herdr_name_agent_best_effort: a rename that never lands gives up silently inside its bounded budget" +} + +test_name_agent_refuses_an_unparseable_target_or_empty_name() { + local dir log resp fb + dir="$TMP_ROOT/name-agent-guards"; mkdir -p "$dir/responses" + log="$dir/log"; resp="$dir/responses"; : > "$log" + fb=$(make_herdr_fakebin "$dir") + PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" FM_BACKEND_HERDR_NAME_INTERVAL=0 \ + bash -c '. "$0/bin/backends/herdr.sh" + fm_backend_herdr_name_agent_best_effort nocolon fm-task-d || exit 1 + fm_backend_herdr_name_agent_best_effort sess:w1:p2 "" || exit 1' "$ROOT" + expect_code 0 $? "the naming guards must still report success" + [ -z "$(rename_calls "$log")" ] || fail "an unparseable target or an empty name must issue no rename at all" + pass "fm_backend_herdr_name_agent_best_effort: no rename for an unparseable target or an empty name" +} + +# make_herdr_spawnfake: a `herdr` stub answering exactly the calls one flat +# (unprojected) crewmate spawn makes, so the REAL bin/fm-spawn.sh can be driven +# end to end. The home workspace is pre-seeded (label from FM_FAKE_WS_LABEL) so +# the run adopts it instead of creating one, `pane get` reports +# FM_FAKE_PANE_PATH as the settled foreground cwd for the worktree-detection +# poll, and `agent rename` exits FM_FAKE_RENAME_EXIT so a spawn can be replayed +# against a rename that never lands. +make_herdr_spawnfake() { # -> echoes fakebin dir + local dir=$1 fb="$1/fakebin" + mkdir -p "$fb" + cat > "$fb/herdr" <<'SH' +#!/usr/bin/env bash +set -u +LOG="${FM_HERDR_LOG:?}" +{ + printf 'HERDR_SESSION=%s' "${HERDR_SESSION:-}" + for a in "$@"; do printf '\x1f%s' "$a"; done + printf '\n' +} >> "$LOG" +case "${1:-} ${2:-}" in + "status --json") + printf '{"client":{"version":"0.7.1","protocol":14},"server":{"running":true}}\n' ;; + "workspace list") + printf '{"result":{"workspaces":[{"workspace_id":"w1","label":"%s"}]}}\n' "${FM_FAKE_WS_LABEL:?}" ;; + "tab list") + printf '{"result":{"tabs":[]}}\n' ;; + "tab create") + printf '{"result":{"tab":{"tab_id":"w1:t2"},"root_pane":{"pane_id":"w1:p2"}}}\n' ;; + "pane get") + printf '{"result":{"pane":{"pane_id":"w1:p2","pid":4242,"cwd":"%s","foreground_cwd":"%s"}}}\n' \ + "${FM_FAKE_PANE_PATH:-}" "${FM_FAKE_PANE_PATH:-}" ;; + "agent get") + printf '{"error":{"code":"agent_not_found","message":"no agent"}}\n' ;; + "agent rename") + exit "${FM_FAKE_RENAME_EXIT:-0}" ;; +esac +exit 0 +SH + chmod +x "$fb/herdr" + fm_fake_exit0 "$fb" treehouse gh gh-axi + printf '%s\n' "$fb" +} + +# run_herdr_spawn -> echoes "||"; +# drives the real bin/fm-spawn.sh for one crewmate on the herdr backend. +run_herdr_spawn() { # [] + local name=$1 rename_exit=${2:-0} dir home proj wt fb log id out rc + dir="$TMP_ROOT/$name"; home="$dir/home"; proj="$dir/project"; wt="$dir/wt" + id="spawnname-$name" + mkdir -p "$home/data/$id" "$home/projects" "$home/state" "$home/config" + printf 'brief\n' > "$home/data/$id/brief.md" + # Force the flat per-home workspace path so this case never depends on the + # projection's own version floor or focus handling. + printf 'off\n' > "$home/config/herdr-presentation-spaces" + touch "$home/state/.last-watcher-beat" + fm_git_worktree "$proj" "$wt" "fm/$id" + fb=$(make_herdr_spawnfake "$dir/fake") + log="$dir/log"; : > "$log" + out=$( FM_ROOT_OVERRIDE='' FM_HOME="$home" \ + FM_STATE_OVERRIDE="$home/state" FM_DATA_OVERRIDE="$home/data" \ + FM_PROJECTS_OVERRIDE="$home/projects" FM_CONFIG_OVERRIDE="$home/config" \ + FM_SPAWN_NO_GUARD=1 PATH="$fb:$PATH" \ + FM_HERDR_LOG="$log" FM_FAKE_WS_LABEL=firstmate FM_FAKE_PANE_PATH="$wt" \ + FM_FAKE_RENAME_EXIT="$rename_exit" \ + FM_BACKEND_HERDR_NAME_INTERVAL=0 FM_BACKEND_HERDR_NAME_ATTEMPTS=2 \ + HERDR_SESSION=fmtest \ + "$ROOT/bin/fm-spawn.sh" "$id" "$proj" "sh -c 'echo spawned'" \ + --mode no-mistakes --yolo off --backend herdr 2>&1 ) + rc=$? + printf '%s|%s|%s\n' "$log" "${out//$'\n'/ }" "$rc" +} + +test_spawn_names_the_herdr_agent_after_its_task() { + local rec log out rc calls + rec=$(run_herdr_spawn named) + IFS='|' read -r log out rc < on the pane it just launched" +} + +test_spawn_survives_a_rename_that_never_lands() { + local rec log out rc calls + rec=$(run_herdr_spawn quiet-fail 1) + IFS='|' read -r log out rc <