Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions bin/backends/herdr.sh
Original file line number Diff line number Diff line change
Expand Up @@ -1947,6 +1947,70 @@ fm_backend_herdr_agent_alive() { # <target>
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-<id> TAB label, so without a name
# every firstmate worker in the fleet reads as the same anonymous entry there.
# `herdr agent rename <pane> <name>` 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-<id> 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 <kind>'s <task-id>. A
# secondmate gets the same 2ndmate-<id> form its home's own workspace already
# carries (fm_backend_herdr_workspace_label); every other kind gets the fm-<id>
# form its task tab is already labeled with.
fm_backend_herdr_agent_name() { # <kind> <task-id>
case "$1" in
secondmate) printf '2ndmate-%s' "$2" ;;
*) printf 'fm-%s' "$2" ;;
esac
}

# fm_backend_herdr_name_agent_best_effort: give <target>'s agent the sidebar
# name <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() { # <target> <name>
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
# <container> ("session:workspace_id"). Herdr does NOT enforce label
# uniqueness itself (verified: two tabs can share a label), so the duplicate
Expand Down
9 changes: 9 additions & 0 deletions bin/fm-spawn.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
3 changes: 3 additions & 0 deletions docs/herdr-backend.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,9 @@ Avoid naming a personal workspace `firstmate` or `2ndmate-<id>` for that reason,
An older secondmate workspace using `firstmate-<id>` 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-<task-id>`, or `2ndmate-<secondmate-id>` 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.
Expand Down
1 change: 1 addition & 0 deletions docs/verification/runtime-backends.md
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,7 @@ The CLI matrix was checked directly:
| Native state | `herdr agent get <pane>` | 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 <pane> --session <name>` | The exact one-pane task tab closed; closing a final tab could remove the workspace. |
| Agent sidebar name | `herdr agent rename <target> <name>` | Available from 0.7.4 (`herdr agent --help` lists `herdr agent rename <target> <name>|--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.
Expand Down
201 changes: 201 additions & 0 deletions tests/fm-backend-herdr.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 <log> -> the `agent rename` invocations the adapter made, one
# unit-separated line each.
rename_calls() { # <log>
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-<id>, 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-<id>, got '$name'"
pass "fm_backend_herdr_agent_name: crewmates and scouts are named after their fm-<id> 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-<id> 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() { # <dir> -> 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 <case-name> -> echoes "<log>|<spawn stdout+stderr>|<rc>";
# drives the real bin/fm-spawn.sh for one crewmate on the herdr backend.
run_herdr_spawn() { # <case-name> [<rename-exit>]
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 <<EOF
$rec
EOF
expect_code 0 "$rc" "the herdr crewmate spawn should succeed: $out"
assert_contains "$out" "spawned spawnname-named" "the spawn did not report success"
calls=$(rename_calls "$log")
assert_contains "$calls" $'\x1f''agent'$'\x1f''rename'$'\x1f''w1:p2'$'\x1f''fm-spawnname-named' \
"the spawn did not name its worker's agent after the task"
pass "fm-spawn.sh: a herdr crewmate spawn names its agent fm-<task-id> 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 <<EOF
$rec
EOF
expect_code 0 "$rc" "a failed sidebar rename must not fail the spawn: $out"
assert_contains "$out" "spawned spawnname-quiet-fail" "a failed sidebar rename must not stop the spawn reporting success"
assert_not_contains "$out" "rename" "a failed sidebar rename must not add noise to the spawn's own report"
calls=$(rename_calls "$log")
[ "$(printf '%s\n' "$calls" | grep -c .)" = 2 ] \
|| fail "a failed spawn-time rename must stop at its configured attempt budget, got: $calls"
pass "fm-spawn.sh: a herdr agent rename that never lands is bounded and never fails the spawn"
}

# shellcheck source=bin/fm-backend.sh
. "$ROOT/bin/fm-backend.sh"

Expand Down Expand Up @@ -4372,3 +4565,11 @@ test_wait_transition_stream_absorb_clears_then_timeout
test_wait_transition_reader_failure_returns_2
test_wait_transition_bad_ack_returns_2_and_cleans_up
test_wait_transition_clean_timeout_returns_1
test_agent_name_uses_the_task_tab_form_for_workers
test_agent_name_uses_the_home_form_for_a_secondmate
test_name_agent_renames_the_exact_pane_once_when_the_agent_is_registered
test_name_agent_retries_until_the_harness_registers_its_agent
test_name_agent_gives_up_silently_within_a_bounded_window
test_name_agent_refuses_an_unparseable_target_or_empty_name
test_spawn_names_the_herdr_agent_after_its_task
test_spawn_survives_a_rename_that_never_lands
Loading