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
56 changes: 41 additions & 15 deletions bin/backends/herdr.sh
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,8 @@
# never overrides a real invocation. It exists only so this file's own unit
# tests, which source it directly without that preamble, resolve to a sane
# default (the firstmate repo root - never a secondmate home, so
# fm_backend_herdr_workspace_label falls through to "firstmate" exactly like
# pre-P3 behavior when a test does not care about home-specific labeling).
# fm_backend_herdr_workspace_label falls through to "1M-FIRSTMATE" when a
# test does not care about home-specific labeling).
FM_BACKEND_HERDR_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
FM_ROOT="${FM_ROOT_OVERRIDE:-${FM_ROOT:-$FM_BACKEND_HERDR_ROOT}}"
FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}"
Expand Down Expand Up @@ -110,11 +110,29 @@ FM_BACKEND_HERDR_SECONDMATE_MARKER=".fm-secondmate-home"
# No send, capture, Treehouse, or general task-ownership path reads it.
FM_BACKEND_HERDR_PRESENTATION_JOURNAL_SUFFIX=".herdr-presentation"

# fm_backend_herdr_mate_scope: derive the naming-convention "scope" tag from a
# secondmate id (docs/herdr-backend.md "Mate naming convention"). Pure and
# independently unit-testable: uppercases, then replaces every character
# outside A-Z0-9 with "-", squeezes repeats, and trims leading/trailing "-".
# An id that sanitizes to nothing (empty input, or no surviving alphanumeric)
# falls back to the literal "UNKNOWN" so a malformed or missing marker never
# produces an empty workspace label. That fallback only guarantees non-empty,
# not non-colliding: every malformed marker shares the same "UNKNOWN" scope,
# so two malformed homes can still collide on "2M-UNKNOWN" - fix the marker
# rather than relying on this fallback to stay unique.
fm_backend_herdr_mate_scope() {
local id=$1 scope
scope=$(printf '%s' "$id" | tr '[:lower:]' '[:upper:]' | LC_ALL=C tr -c 'A-Z0-9' '-' | tr -s '-')
scope=${scope#-}
scope=${scope%-}
printf '%s' "${scope:-UNKNOWN}"
}

# 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
# every pre-existing task's recorded label - no forced migration. A SECONDMATE
# home resolves to "2ndmate-<secondmate-id>", so its tasks land in their own
# label (docs/herdr-backend.md "Mate naming convention"), always uppercase
# "<materank>-<scope>". The PRIMARY home (no secondmate marker) resolves to
# the constant "1M-FIRSTMATE". A SECONDMATE home resolves to
# "2M-<fm_backend_herdr_mate_scope-of-its-id>", so its tasks land in their own
# workspace, obviously distinguishable from the primary's (and from every
# other secondmate's) in herdr's spaces sidebar. Read fresh from FM_HOME on
# every call rather than cached at source time: FM_HOME is the home's own
Expand All @@ -126,13 +144,16 @@ FM_BACKEND_HERDR_PRESENTATION_JOURNAL_SUFFIX=".herdr-presentation"
fm_backend_herdr_workspace_label() {
local marker="$FM_HOME/$FM_BACKEND_HERDR_SECONDMATE_MARKER" id
if [ -f "$marker" ]; then
id=$(tr -d '[:space:]' < "$marker" 2>/dev/null)
if [ -n "$id" ]; then
printf '2ndmate-%s' "$id"
return 0
fi
id=$(cat "$marker" 2>/dev/null)
# Trim only outer whitespace here; fm_backend_herdr_mate_scope is what
# normalizes any embedded separator (space, newline, ...) to "-", so
# stripping it here first would silently merge distinct ids together.
id="${id#"${id%%[![:space:]]*}"}"
id="${id%"${id##*[![:space:]]}"}"
printf '2M-%s' "$(fm_backend_herdr_mate_scope "$id")"
Comment thread
coderabbitai[bot] marked this conversation as resolved.
return 0
fi
printf 'firstmate'
printf '1M-FIRSTMATE'
}

# fm_backend_herdr_cli: run `herdr <args...>` scoped to <session>, setting
Expand Down Expand Up @@ -641,7 +662,10 @@ fm_backend_herdr_projection_close_pane_focus_preserving() { # <session> <pane-i
# returned by THIS projected create immediately after its owning parent's
# contiguous child block and before the next parent.
#
# <parent-label> is the owning FM_HOME label (firstmate or 2ndmate-<id>).
# <parent-label> is the owning FM_HOME label (docs/herdr-backend.md "Mate
# naming convention": 1M-FIRSTMATE or 2M-<scope>, or - read-only, for
# already-adjacent pre-existing workspaces - the legacy firstmate/2ndmate-<id>
# form).
# Optional <parent-workspace-id> is that parent's EXACT id, which the caller
# already resolved from the launching agent's own herdr identity. When given it
# anchors the owning parent by id, so two workspaces sharing the home label no
Expand Down Expand Up @@ -681,7 +705,9 @@ fm_backend_herdr_projection_order_best_effort() { # <session> <created-workspac
end;
def is_top_level_parent:
(.label | type) == "string"
and ((.label == "firstmate") or (.label | test("^2ndmate-[^/]+$")));
and ((.label == "firstmate")
or (.label | test("^2ndmate-[^/]+$"))
or (.label | test("^[0-9]+M-[A-Z0-9-]+$")));
def is_new_child:
(.label | type) == "string"
and (.label | test("^└ .+ · p:[A-Za-z0-9_-]{22}$"));
Expand Down Expand Up @@ -867,7 +893,7 @@ fm_backend_herdr_workspace_find_all() { # <session>
# NOTE: the jq variable is $want, NOT $label - `label` is a jq reserved
# keyword (label/break), so declaring a jq variable named "label" is a
# compile error that `2>/dev/null` would silently swallow, making this find
# ALWAYS return empty and every spawn mint a fresh "firstmate" workspace
# ALWAYS return empty and every spawn mint a fresh "1M-FIRSTMATE" workspace
# (the workspace leak).
printf '%s' "$list" | jq -r --arg want "$label" \
'.result.workspaces[]? | select(.label == $want) | .workspace_id' 2>/dev/null
Expand Down
14 changes: 11 additions & 3 deletions bin/fm-spawn.sh
Original file line number Diff line number Diff line change
Expand Up @@ -1030,7 +1030,7 @@ case "$BACKEND" in
# secondmate's home), so FM_HOME here still names the primary. Shadow it
# to PROJ_ABS for just these two calls (bash restores it automatically
# after each prefixed simple-command call) so the secondmate's tab lands
# in the secondmate's own workspace, not the primary's "firstmate" one.
# in the secondmate's own workspace, not the primary's "1M-FIRSTMATE" one.
#
# Placement, separately from labeling: a crewmate/scout belongs in the
# EXACT herdr workspace this launching process is itself running in, which
Expand All @@ -1040,9 +1040,17 @@ case "$BACKEND" in
# the per-home container instead of inheriting this launcher's.
HERDR_LABEL_HOME=$FM_HOME
HERDR_LAUNCHER_RELATIONSHIP=launcher-home
# HERDR_TASK_LABEL is the herdr-specific tab label passed to
# fm_backend_herdr_create_task below: ordinarily the shared $W (fm-<id>,
# identical to every other backend), except a --secondmate spawn's own tab
# IS that mate's live agent, so it gets the mate naming convention's
# uppercase "<materank>-<scope>" label (docs/herdr-backend.md "Mate naming
# convention") instead of a lowercase task-style name.
HERDR_TASK_LABEL=$W
if [ "$KIND" = secondmate ]; then
HERDR_LABEL_HOME=$PROJ_ABS
HERDR_LAUNCHER_RELATIONSHIP=other-home
HERDR_TASK_LABEL=$(FM_HOME="$HERDR_LABEL_HOME" fm_backend_herdr_workspace_label)
fi
HERDR_PRESENTATION_JOURNAL=$(fm_backend_herdr_projection_journal_path "$STATE" "$ID")
HERDR_PROJECTED=0
Expand Down Expand Up @@ -1172,13 +1180,13 @@ case "$BACKEND" in
HERDR_SEEDED_DEFAULT_TAB_ID=${HERDR_CONTAINER_RAW#*$'\t'}
HERDR_SES=${CONTAINER%%:*}
HERDR_WORKSPACE_ID=${CONTAINER#*:}
HERDR_TASK_IDS=$(FM_HOME="$HERDR_LABEL_HOME" fm_backend_herdr_create_task "$CONTAINER" "$W" "$PROJ_ABS" "$HERDR_SEEDED_DEFAULT_TAB_ID") || exit 1
HERDR_TASK_IDS=$(FM_HOME="$HERDR_LABEL_HOME" fm_backend_herdr_create_task "$CONTAINER" "$HERDR_TASK_LABEL" "$PROJ_ABS" "$HERDR_SEEDED_DEFAULT_TAB_ID") || exit 1
read -r HERDR_TAB_ID HERDR_PANE_ID <<EOF
$HERDR_TASK_IDS
EOF
fi
if [ -z "$HERDR_TAB_ID" ] || [ -z "$HERDR_PANE_ID" ]; then
echo "error: herdr did not return a tab/pane id for $W" >&2
echo "error: herdr did not return a tab/pane id for $HERDR_TASK_LABEL" >&2
exit 1
fi
T="$HERDR_SES:$HERDR_PANE_ID"
Expand Down
4 changes: 2 additions & 2 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,8 +100,8 @@ These five sentences are the single owner of the task-selector vocabulary; backe
`fm-teardown.sh <id>` takes a task id directly and validates the complete metadata-only endpoint identity before any runtime dispatch or cleanup mutation.
Missing, empty, duplicate, malformed, backend-inconsistent, or task-mismatched endpoint records are preserved and refused.
Legacy tmux metadata remains cleanup-compatible when its exact window name is `fm-<id>`; opaque non-tmux endpoints require their recorded `endpoint_task_id=` binding, except legacy Herdr metadata lacking that binding, which self-repairs by appending it once the live pane is confirmed to still belong to the task, and otherwise refuses without mutation (see [herdr-backend.md](herdr-backend.md#endpoint-metadata)).
`FM_HOME` determines Herdr's home label: the primary home uses `firstmate`, and a secondmate home marked by `.fm-secondmate-home` uses `2ndmate-<secondmate-id>`.
[`herdr-backend.md`](herdr-backend.md#watching-and-task-containers) owns launcher-bound workspace placement, the label-only fallback, collision handling, and recovery behavior.
`FM_HOME` determines Herdr's home label: the primary home uses `1M-FIRSTMATE`, and a secondmate home marked by `.fm-secondmate-home` uses `2M-<SCOPE>`, uppercase and derived from its marker id.
[`herdr-backend.md`](herdr-backend.md#watching-and-task-containers) owns launcher-bound workspace placement, the label-only fallback, collision handling, and recovery behavior, and [Mate naming convention](herdr-backend.md#mate-naming-convention) owns the exact rank/scope derivation.
The optional local `config/herdr-presentation-spaces` presence flag instead enables Herdr's default-off disposable single-task visual projection; [Optional presentation spaces](herdr-backend.md#optional-presentation-spaces) owns its behavior, safety limits, recovery contract, and narrow locked session-start cleanup of exact restored idle-shell children.
The flag is default-off and inherited into secondmate homes under the primary-authoritative contract owned by [`secondmate-provisioning`](../.agents/skills/secondmate-provisioning/SKILL.md).
For normal herdr operations, `HERDR_SESSION` selects the named session, but destructive test cleanup must not rely on `HERDR_SESSION` alone.
Expand Down
19 changes: 14 additions & 5 deletions docs/herdr-backend.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,9 @@ Real harness credential tests remain opt-in rather than part of default CI.

The ordinary topology puts one task tab per endpoint in the exact workspace of the Firstmate or secondmate that launches it.
When the launcher has no Herdr workspace to inherit, the adapter maintains one durable home-labeled workspace instead.
The primary home label is `firstmate`.
A secondmate home label is `2ndmate-<secondmate-id>`, derived from its validated `.fm-secondmate-home` marker.
A secondmate launched by the primary receives a narrowly scoped home override during container creation.
The primary home label is `1M-FIRSTMATE`.
A secondmate home label is `2M-<SCOPE>`, uppercase and derived from its validated `.fm-secondmate-home` marker id; see "Mate naming convention" below for the full contract.
A secondmate launched by the primary receives a narrowly scoped home override during container creation, and its own live-agent tab (not only its workspace) carries the same uppercase mate label.

Attach to the selected named Herdr session and switch to the relevant home workspace to watch its task tabs.
Routine supervision uses `bin/fm-peek.sh <id>` and `FM_HOME=<home> bin/fm-send.sh <id> '<text>'` without attaching.
Expand All @@ -57,14 +57,23 @@ That covers a missing or unusable socket identity, a closed or unreadable launch

Firstmate running outside Herdr entirely has no launcher workspace to inherit, so its workers use this home's own labeled workspace, created on first use.
That path needs the home label to identify exactly one workspace: two workspaces sharing it are an unresolvable placement and refuse rather than adopting either.
Avoid naming a personal workspace `firstmate` or `2ndmate-<id>` for that reason, and because the adapter cannot distinguish that label collision from its own container.
An older secondmate workspace using `firstmate-<id>` is not migrated automatically; rename it manually before expecting new tasks or recovery to use it.
Avoid naming a personal workspace `1M-FIRSTMATE` or `2M-<SCOPE>` for that reason, and because the adapter cannot distinguish that label collision from its own container.
An older secondmate workspace using `2ndmate-<id>` is not migrated automatically; rename it manually before expecting new tasks or recovery to use it.
An installation upgrading from the pre-mate-naming-convention `firstmate`/`2ndmate-<id>` labels is not migrated either: the first spawn into an already-running home mints a fresh `1M-FIRSTMATE`/`2M-<SCOPE>` workspace rather than adopting the old one, leaving the old workspace behind to close or merge manually.
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.

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.

## Mate naming convention

A mate's workspace and its own live-agent tab are always labeled uppercase `<materank>-<scope>`, so the captain can tell a supervisor from a worker in Herdr's sidebar at a glance.
`materank` is `1M` for the main firstmate, `2M` for a secondmate, `3M` for a third mate if one ever exists.
`scope` is the mate's own registered id, uppercased: the primary defaults to the literal scope `FIRSTMATE` (giving `1M-FIRSTMATE`), and a secondmate's scope comes from its `.fm-secondmate-home` marker id (`bin/backends/herdr.sh`'s `fm_backend_herdr_mate_scope`), for example `2M-BEADME`.
That helper sanitizes the id (uppercase, non-alphanumeric characters collapsed to a single `-`, leading/trailing `-` trimmed) and falls back to the literal scope `UNKNOWN` when the id is empty or sanitizes to nothing, so a malformed or missing marker never produces an empty label; every malformed marker shares that same `UNKNOWN` scope, so two malformed homes can still collide on `2M-UNKNOWN`.
A crewmate spawned by any mate inherits that mate's placement but is never uppercase: its tab keeps the ordinary lowercase `fm-<id>` task label, so a mate and its subordinate crewmates are always visually distinct and never collide.

## Optional presentation spaces

Create local gitignored `config/herdr-presentation-spaces` to request a disposable one-task workspace for each new crewmate or scout.
Expand Down
3 changes: 3 additions & 0 deletions docs/verification/runtime-backends.md
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,9 @@ ok - real herdr E2E: a --secondmate launch still stands up that secondmate's own
ok - real herdr E2E: teardown closes only the worker's own pane and leaves the launcher, its workspace, and the same-labeled sibling intact
```

This capture predates the mate naming convention (see `docs/herdr-backend.md`'s "Mate naming convention" section): it still shows the old `firstmate` workspace label rather than the current `1M-FIRSTMATE`.
The output above is preserved verbatim as the historical record; capture fresh evidence with the new label on the next real Herdr E2E run.

That suite's headline case runs `bin/fm-spawn.sh` inside a real Herdr pane, so the parent identity comes from Herdr's own injection rather than a composed environment.
Cross-session and contradictory bindings are covered deterministically in `tests/fm-backend-herdr.test.sh`, which can script a second server's socket without provisioning one.

Expand Down
Loading
Loading