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
111 changes: 105 additions & 6 deletions bin/backends/herdr.sh
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,17 @@ FM_BACKEND_HERDR_MIN_WORKSPACE_MOVE_PROTOCOL=16
# both fixes reaches 19, and the pre-fix builds top out at 17.
FM_BACKEND_HERDR_MIN_PRESENTATION_PROTOCOL=19
FM_BACKEND_HERDR_MIN_PRESENTATION_VERSION=0.8.0
# The version floor for the fleet role metadata token (docs/herdr-backend.md
# "Fleet role token"). Custom pane metadata tokens and `herdr pane
# report-metadata --token` first shipped in Herdr 0.7.4. That release shares
# protocol 16 with 0.7.3, so the release core of the version string decides at
# protocol 16, and protocol 17 (Herdr 0.7.5) is the first protocol that implies
# the feature on its own.
FM_BACKEND_HERDR_MIN_ROLE_TOKEN_PROTOCOL=17
FM_BACKEND_HERDR_MIN_ROLE_TOKEN_VERSION=0.7.4
# The metadata source id Firstmate reports under, so its tokens never collide
# with an integration hook's own display metadata.
FM_BACKEND_HERDR_METADATA_SOURCE=firstmate
# One-warning-per-release dedupe marker prefix, under the state dir. The
# projection decision is remade on every spawn, so an undeduplicated
# below-floor warning would repeat on every crewmate; the key is the detected
Expand Down Expand Up @@ -194,25 +205,28 @@ fm_backend_herdr_version_at_least() { # <candidate> <floor>
return 0
}

# fm_backend_herdr_release_floor_verdict <protocol> <version>: the pure
# classifier for the presentation version floor. Return codes: 0 at or above the
# floor, 1 provably below it, 2 indeterminate.
# fm_backend_herdr_release_floor_verdict <protocol> <version> [<min-protocol>
# <min-version>]: the pure classifier for a release floor, by default the
# presentation version floor. Return codes: 0 at or above the floor, 1 provably
# below it, 2 indeterminate.
# Two independent signals are read so no single field is load-bearing, and
# either one can carry a positive verdict: the protocol number, which is the
# structural signal this adapter already uses for every other capability gate,
# and the release core of the version string. A signal that is unreadable or
# unparseable simply cannot carry a verdict; a readable protocol below the floor
# is decisive on its own, and only losing BOTH signals reports indeterminate.
fm_backend_herdr_release_floor_verdict() { # <protocol> <version>
fm_backend_herdr_release_floor_verdict() { # <protocol> <version> [<min-protocol> <min-version>]
local protocol=${1:-} version=${2:-} protocol_known=0 version_status=0
local min_protocol=${3:-$FM_BACKEND_HERDR_MIN_PRESENTATION_PROTOCOL}
local min_version=${4:-$FM_BACKEND_HERDR_MIN_PRESENTATION_VERSION}
case "$protocol" in
''|*[!0-9]*) ;;
*)
protocol_known=1
[ "$protocol" -ge "$FM_BACKEND_HERDR_MIN_PRESENTATION_PROTOCOL" ] && return 0
[ "$protocol" -ge "$min_protocol" ] && return 0
;;
esac
fm_backend_herdr_version_at_least "$version" "$FM_BACKEND_HERDR_MIN_PRESENTATION_VERSION" \
fm_backend_herdr_version_at_least "$version" "$min_version" \
|| version_status=$?
[ "$version_status" -eq 0 ] && return 0
{ [ "$protocol_known" -eq 1 ] || [ "$version_status" -eq 1 ]; } && return 1
Expand Down Expand Up @@ -337,6 +351,91 @@ fm_backend_herdr_presentation_enabled() { # <config-dir> [<state-dir>]
fm_backend_herdr_presentation_default_supported "$state_dir"
}

# fm_backend_herdr_role_token_supported [<session>]: whether the selected
# session can store the fleet role metadata token. The client parses --token
# and a running server stores it, so both must pass the floor; with no running
# server only the client applies. Same return codes as
# fm_backend_herdr_release_floor_verdict.
fm_backend_herdr_role_token_supported() { # [<session>]
local session=${1:-} status protocol version running client_verdict=0 server_verdict=0
command -v herdr >/dev/null 2>&1 || return 2
command -v jq >/dev/null 2>&1 || return 2
[ -n "$session" ] || session=$(fm_backend_herdr_session)
status=$(fm_backend_herdr_cli "$session" status --json 2>/dev/null) || return 2
protocol=$(printf '%s' "$status" | jq -r '.client.protocol // empty' 2>/dev/null) || return 2
version=$(printf '%s' "$status" | jq -r '.client.version // empty' 2>/dev/null) || return 2
fm_backend_herdr_release_floor_verdict "$protocol" "$version" \
"$FM_BACKEND_HERDR_MIN_ROLE_TOKEN_PROTOCOL" "$FM_BACKEND_HERDR_MIN_ROLE_TOKEN_VERSION" \
|| client_verdict=$?
running=$(printf '%s' "$status" | jq -r '.server.running // empty' 2>/dev/null) || return 2
[ "$running" = true ] || return "$client_verdict"
protocol=$(printf '%s' "$status" | jq -r '.server.protocol // empty' 2>/dev/null) || return 2
version=$(printf '%s' "$status" | jq -r '.server.version // empty' 2>/dev/null) || return 2
fm_backend_herdr_release_floor_verdict "$protocol" "$version" \
"$FM_BACKEND_HERDR_MIN_ROLE_TOKEN_PROTOCOL" "$FM_BACKEND_HERDR_MIN_ROLE_TOKEN_VERSION" \
|| server_verdict=$?
{ [ "$client_verdict" -eq 1 ] || [ "$server_verdict" -eq 1 ]; } && return 1
{ [ "$client_verdict" -eq 0 ] && [ "$server_verdict" -eq 0 ]; } && return 0
return 2
}

# fm_backend_herdr_report_role <session> <pane> <role>: report the fleet role
# token for one exact pane (docs/herdr-backend.md "Fleet role token" owns the
# contract). The token is display-only: every caller treats a non-zero return
# as a warning, never as a failed operation. A release below the floor has no
# metadata token surface, so it is skipped silently and returns 0.
fm_backend_herdr_report_role() { # <session> <pane> <role>
local session=${1:-} pane=${2:-} role=${3:-} verdict=0
case "$role" in
firstmate|secondmate|crewmate|scout) ;;
*)
echo "warning: herdr role token not reported: unknown fleet role '$role'" >&2
return 1
;;
esac
if [ -z "$session" ] || [ -z "$pane" ]; then
echo "warning: herdr role token '$role' not reported: no exact herdr session and pane" >&2
return 1
fi
fm_backend_herdr_role_token_supported "$session" || verdict=$?
case "$verdict" in
0) ;;
1) return 0 ;;
*)
echo "warning: herdr role token '$role' not reported for pane '$pane': the herdr release for session '$session' could not be read" >&2
return 1
;;
esac
if ! fm_backend_herdr_cli "$session" pane report-metadata "$pane" \
--source "$FM_BACKEND_HERDR_METADATA_SOURCE" --token "role=$role" >/dev/null 2>&1; then
echo "warning: herdr role token '$role' not reported for pane '$pane' in session '$session'; the sidebar cannot show this pane's fleet role" >&2
return 1
fi
return 0
}

# fm_backend_herdr_report_own_role <home>: report the fleet role token for the
# herdr pane this process runs in. A home carrying the secondmate marker is a
# secondmate, and every other home is the primary firstmate. A process outside
# herdr has no pane and returns 0 with no herdr call. Herdr pane ids restart at
# the same low numbers in every session, so the pane is reported only after its
# injected socket identity proves it belongs to the selected session.
fm_backend_herdr_report_own_role() { # <home>
local home=${1:-} pane=${HERDR_PANE_ID:-} role=firstmate session claimed_socket="" session_socket=""
[ "${HERDR_ENV:-}" = 1 ] && [ -n "$pane" ] || return 0
if [ -n "$home" ] && { [ -e "$home/$FM_BACKEND_HERDR_SECONDMATE_MARKER" ] || [ -L "$home/$FM_BACKEND_HERDR_SECONDMATE_MARKER" ]; }; then
role=secondmate
fi
session=$(fm_backend_herdr_session)
claimed_socket=$(fm_backend_herdr_canonical_socket_path "${HERDR_SOCKET_PATH:-}") || claimed_socket=""
session_socket=$(fm_backend_herdr_presentation_session_socket_path "$session") || session_socket=""
if [ -z "$claimed_socket" ] || [ "$claimed_socket" != "$session_socket" ]; then
echo "warning: herdr role token '$role' not reported: pane '$pane' could not be proved to belong to herdr session '$session'" >&2
return 1
fi
fm_backend_herdr_report_role "$session" "$pane" "$role"
}

# fm_backend_herdr_workspace_label: the per-firstmate-HOME herdr workspace
# label (docs/herdr-backend.md "Default task container shape"). The PRIMARY home (no
# secondmate marker) resolves to the constant "firstmate", byte-identical to
Expand Down
11 changes: 9 additions & 2 deletions bin/fm-session-start.sh
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,9 @@
#
# 1. lock - acquire the per-home session lock FIRST, before any
# mutating step runs.
# 2. bootstrap - home-local stale Herdr projection cleanup runs only
# when this session actually holds the lock. Detect-only
# 2. bootstrap - home-local stale Herdr projection cleanup and the
# report of this session's own Herdr fleet role token
# run only when this session actually holds the lock. Detect-only
# diagnostics always run. Bootstrap's six MUTATING sweeps
# (same-home backlog reconciliation,
# secondmate convergence, secondmate liveness, pending remote
Expand Down Expand Up @@ -689,6 +690,12 @@ elif [ "$REEMIT" -eq 1 ]; then
else
BOOT_OUT=$(
"$SCRIPT_DIR/fm-herdr-session-cleanup.sh" 2>&1 || true
# Report this session's own fleet role token when it runs in a Herdr pane.
# It is display-only, so a failure prints one warning and never blocks
# startup (docs/herdr-backend.md "Fleet role token").
if [ "${HERDR_ENV:-}" = 1 ] && [ -n "${HERDR_PANE_ID:-}" ] && fm_backend_source herdr 2>/dev/null; then
fm_backend_herdr_report_own_role "$FM_HOME" 2>&1 || true
fi
FM_BOOTSTRAP_NETWORK=skip FM_TASKS_AXI_COMPATIBLE="$TASKS_AXI_COMPATIBLE" \
"$SCRIPT_DIR/fm-bootstrap.sh" 2>&1
)
Expand Down
14 changes: 14 additions & 0 deletions bin/fm-spawn.sh
Original file line number Diff line number Diff line change
Expand Up @@ -3213,6 +3213,20 @@ if [ -n "$SPAWN_DEFERRED_SIGNAL" ]; then
exit "$SPAWN_DEFERRED_SIGNAL_STATUS"
fi

# Herdr sidebar rules key on a fleet role token. The token is display-only, so a
# failed report warns and never fails the spawn or relaunch
# (docs/herdr-backend.md "Fleet role token"). The exact pane comes from the task
# record this spawn just published, so a relaunch reports its replacement pane.
if [ "$BACKEND" = herdr ]; then
case "$KIND" in
secondmate) SPAWN_HERDR_ROLE=secondmate ;;
scout) SPAWN_HERDR_ROLE=scout ;;
*) SPAWN_HERDR_ROLE=crewmate ;;
esac
fm_backend_herdr_report_role "$(fm_meta_get "$STATE/$ID.meta" herdr_session)" \
"$(fm_meta_get "$STATE/$ID.meta" herdr_pane_id)" "$SPAWN_HERDR_ROLE" || true
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"
8 changes: 8 additions & 0 deletions docs/herdr-backend.md
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,14 @@ Operational compromises:
`tests/fm-herdr-session-cleanup-e2e.test.sh` covers the restored-shell cleanup in a guarded non-default named lab.
`tests/fm-backend-herdr-focus-flash-e2e.test.sh` reproduces the raw explicit-close focus steal on the installed release and proves the focus-safe emptying-close plan removes a doomed workspace with no wrong-focus interval; [`verification/runtime-backends.md`](verification/runtime-backends.md#workspace-removal-focus-safety) owns the active versioned evidence.

## Fleet role token

Firstmate reports a display-only `role` pane metadata token, so Herdr sidebar rules can tell fleet roles apart with `$role`.
The values are `firstmate` for the primary, `secondmate` for a secondmate agent, `crewmate` for a ship worker, and `scout` for a scout.
Every spawn and relaunch reports it for the exact pane in the published task record.
A locked session start reports it for its own pane, and only after that pane's injected socket identity matches the named session.
Reports use metadata source `firstmate` and need Herdr 0.7.4 or newer; an older release is skipped silently, and a failed report warns but never fails the spawn or the session start.

## Default-tab prune safety

`herdr workspace create` seeds one default tab.
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 @@ -298,6 +298,7 @@ The CLI matrix was checked directly:
| Native state | `herdr agent get <pane>` | Working and done transitions were visible on some harnesses; live Claude Code 2.1.236 on Herdr 0.8.0 kept `agent_status=idle` for an entire landed turn, including a multi-second tool call, so submit confirmation falls through to the shared composer verdict. 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. |
| Fleet role token | `herdr pane report-metadata <pane> --source firstmate --token role=<role> --session <name>` | On 2026-09-14 against Herdr 0.9.0 protocol 22 in a named lab session, `pane get` returned `"tokens":{"role":"crewmate"}`, a second report replaced the value, a `$role` key failed with `invalid_metadata_token`, and an unknown pane failed with `pane_not_found`. |

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
23 changes: 23 additions & 0 deletions tests/fm-backend-herdr-launcher-workspace-e2e.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,10 @@ workspace_of_pane() { # <pane_id>
lab pane get "$1" 2>/dev/null | jq -r '.result.pane.workspace_id // empty' 2>/dev/null
}

role_of_pane() { # <pane_id>
lab pane get "$1" 2>/dev/null | jq -r '.result.pane.tokens.role // empty' 2>/dev/null
}

label_of_workspace() { # <workspace_id>
lab workspace list 2>/dev/null \
| jq -r --arg id "$1" '.result.workspaces[]? | select(.workspace_id == $id) | .label' 2>/dev/null
Expand Down Expand Up @@ -231,6 +235,21 @@ WS_PRIMARY=$(workspace_of_pane "$UNIQA_PANE")
[ "$(label_of_workspace "$WS_PRIMARY")" = firstmate ] || fail "uniqA did not land in a 'firstmate' workspace"
[ "$(focused_workspace)" = "$WS_OTHER" ] || fail "the spawn stole focus from the captain's workspace"
pass "real herdr E2E: with one 'firstmate' workspace and no herdr parent, a crewmate still lands in this home's own workspace without stealing focus"
[ "$(role_of_pane "$UNIQA_PANE")" = crewmate ] || fail "a crewmate spawn did not report role=crewmate for its exact pane"
pass "real herdr E2E: a crewmate spawn reports the crewmate fleet role token for its exact pane"

# --- 1b. a scout reports the scout fleet role token --------------------------

mkdir -p "$PRIMARY_HOME/data/scoutR"
write_ship_brief "$PRIMARY_HOME/data/scoutR/brief.md" scoutR
spawn_from_launcher "" "$PRIMARY_HOME" scoutR "$PROJ" --scout
[ "$SPAWN_RC" -eq 0 ] || fail "a primary scout spawn failed"$'\n'"$(cat "$SPAWN_ERR")"
SCOUTR_META="$PRIMARY_HOME/state/scoutR.meta"
record_worktree "$SCOUTR_META"
SCOUTR_PANE=$(grep '^herdr_pane_id=' "$SCOUTR_META" | cut -d= -f2-)
[ -n "$SCOUTR_PANE" ] || fail "scoutR meta is missing herdr_pane_id"
[ "$(role_of_pane "$SCOUTR_PANE")" = scout ] || fail "a scout spawn did not report role=scout for its exact pane"
pass "real herdr E2E: a scout spawn reports the scout fleet role token for its exact pane"

# --- 2. unique label, WITH a launcher pane: same workspace, now by identity --

Expand Down Expand Up @@ -409,6 +428,8 @@ SME_WS=$(workspace_of_pane "$SME_PANE")
[ "$(tab_labels_of_workspace "$WS_SM_DECOY")" = "$WS_SM_DECOY_TABS_BEFORE" ] \
|| fail "the duplicate secondmate-labeled workspace was mutated"
pass "real herdr E2E: a secondmate launching its own worker gets the same exact-workspace guarantee, and its same-labeled sibling is untouched"
[ "$(role_of_pane "$SME_PANE")" = crewmate ] || fail "a secondmate's own crewmate did not report role=crewmate for its exact pane"
pass "real herdr E2E: a secondmate's own crewmate reports the crewmate fleet role token"

# --- 7. a --secondmate launch is NOT collapsed into the launcher's workspace -

Expand All @@ -422,6 +443,8 @@ SM2_WS=$(workspace_of_pane "$SM2_PANE")
[ "$(label_of_workspace "$SM2_WS")" = "2ndmate-$SM2_ID" ] \
|| fail "a --secondmate launch should land in '2ndmate-$SM2_ID', got '$(label_of_workspace "$SM2_WS")'"
pass "real herdr E2E: a --secondmate launch still stands up that secondmate's own workspace instead of inheriting the launcher's"
[ "$(role_of_pane "$SM2_PANE")" = secondmate ] || fail "a --secondmate launch did not report role=secondmate for its exact pane"
pass "real herdr E2E: a --secondmate launch reports the secondmate fleet role token for its exact pane"

# --- 8. teardown closes only the worker's own pane --------------------------

Expand Down
Loading
Loading