From c58cb0fbe8f6adc165b89f53124978414f2c7c6f Mon Sep 17 00:00:00 2001 From: Kun Chen <3233006+kunchenguid@users.noreply.github.com> Date: Tue, 21 Jul 2026 17:14:40 -0700 Subject: [PATCH 001/250] fix(herdr): group projected children beneath owning parents (#821) * feat(herdr): correct all-home child presentation topology Inherit the presentation opt-in to secondmate homes, label new projected spaces with the approved corner format, insert each child under its owning parent under one session-scoped lock, and keep flat non-destructive fallback. * no-mistakes(review): Exclude secondmates from Herdr presentation projection * no-mistakes(review): Harden shared Herdr locks and ambiguous child ordering * no-mistakes(review): Use adjacency-only Herdr child ownership * no-mistakes(review): Reject foreign legacy projections safely * no-mistakes(review): Validate Herdr session sockets before projection * no-mistakes(test): Fix Herdr teardown fixture session socket metadata * fix(herdr): canonicalize presentation lock socket paths Always resolve the session socket parent directory so symlink parents such as /tmp -> /private/tmp cannot split the shared cross-home lock identity. Refuse relative socket paths. Clarify lock-unavailable warnings. * no-mistakes(test): Fix Bash-compatible GitLab merge request URL parsing * no-mistakes(document): Document all-home Herdr child topology * no-mistakes(lint): Quote fallback provenance string for ShellCheck --- .agents/skills/harness-adapters/SKILL.md | 4 +- .../skills/secondmate-provisioning/SKILL.md | 5 +- .gitignore | 1 + AGENTS.md | 2 +- bin/backends/herdr.sh | 232 ++++++++++-- bin/fm-config-inherit-lib.sh | 11 +- bin/fm-fleet-snapshot.sh | 2 +- bin/fm-pr-lib.sh | 2 +- bin/fm-spawn.sh | 54 +-- bin/fm-teardown.sh | 22 +- docs/architecture.md | 2 +- docs/configuration.md | 6 +- docs/herdr-backend.md | 53 ++- .../fm-backend-herdr-presentation-e2e.test.sh | 305 ++++++++++++++- tests/fm-backend-herdr.test.sh | 358 +++++++++++++++++- tests/fm-secondmate-harness.test.sh | 12 +- tests/fm-teardown.test.sh | 3 + 17 files changed, 938 insertions(+), 136 deletions(-) diff --git a/.agents/skills/harness-adapters/SKILL.md b/.agents/skills/harness-adapters/SKILL.md index d2e6194cc13..761f07d6eb1 100644 --- a/.agents/skills/harness-adapters/SKILL.md +++ b/.agents/skills/harness-adapters/SKILL.md @@ -18,8 +18,8 @@ The captain may override that file at session start or later; a per-task instruc Secondmates have their own harness knob, so a secondmate can run on a different adapter than crewmates. `config/secondmate-harness` is the harness the primary uses to launch SECONDMATE agents, resolved through the fallback chain `config/secondmate-harness` -> `config/crew-harness` -> firstmate's own. An absent or `default` `config/secondmate-harness` therefore behaves exactly as the crew harness did before this knob existed (secondmates launched on the crew harness); setting it splits the two. -`config/crew-dispatch.json`, `config/crew-harness`, and `config/backlog-backend` are inherited by secondmate homes. -This skill owns only the harness-relevant consequence: a secondmate's own crewmates use the primary's dispatch profiles and static harness value, while `config/secondmate-harness` is the primary's own setting and is never inherited - secondmates do not spawn secondmates. +The [`secondmate-provisioning` skill](../secondmate-provisioning/SKILL.md) owns the complete inherited-local-material allowlist and propagation contract. +This skill owns only the harness-relevant consequence: a secondmate's own crewmates use the primary's inherited dispatch profiles and static harness value, while `config/secondmate-harness` is the primary's own setting and is never inherited - secondmates do not spawn secondmates. Inheritance copies the literal `config/crew-harness` file, so for a secondmate's own crewmates to run on the primary's crewmate harness the captain must set `config/crew-harness` to a concrete adapter name, such as `codex`. If `config/crew-harness` is unset or `default`, there is no concrete value to inherit, so the secondmate's own crewmates fall back to the secondmate's own/detected harness rather than the primary's effective crewmate harness. Inheritance also copies the literal `config/crew-dispatch.json` file, so secondmates apply the same best-fit profile rules for their own crewmates. diff --git a/.agents/skills/secondmate-provisioning/SKILL.md b/.agents/skills/secondmate-provisioning/SKILL.md index f62bdf42bf2..ecc364939ac 100644 --- a/.agents/skills/secondmate-provisioning/SKILL.md +++ b/.agents/skills/secondmate-provisioning/SKILL.md @@ -78,8 +78,9 @@ This section is the single owner of the secondmate sync and inherited-local-mate Before launch, `fm-spawn.sh --secondmate` locally fast-forwards the home to the primary firstmate checkout's current default-branch commit when it is safe; dirty, diverged, or in-flight homes launch unchanged with a warning. The locked session-start bootstrap sweep runs the same guarded fast-forward for every live secondmate home, discovered from `state/.meta` records with `kind=secondmate` (`data/secondmates.md` only backfills `home=` for older records). That no-fetch path is a purely local fast-forward of tracked files, never an origin fetch, and it never touches the gitignored operational dirs, so a secondmate's backlog, projects, and in-flight work are never disturbed; a linked worktree advances immediately, while a standalone clone that lacks the target receives firstmate updates through `/updatefirstmate`'s origin refresh. -The same launch and the same locked bootstrap sweep also propagate the primary's declared inherited local material: `config/crew-dispatch.json`, `config/crew-harness`, `config/backlog-backend`, and the one shared captain-preference file `data/captain-shared.md`. +The same launch and the same locked bootstrap sweep also propagate the primary's declared inherited local material: `config/crew-dispatch.json`, `config/crew-harness`, `config/backlog-backend`, `config/herdr-presentation-spaces`, and the one shared captain-preference file `data/captain-shared.md`. Because these paths are gitignored, that propagation is a separate, primary-authoritative copy independent of the tracked-files fast-forward: it re-converges every live home whether or not its tracked files advanced, and it touches only the declared items. +Propagation failures warn without blocking secondmate launch or session-start continuation, and the destination keeps whatever safely validated state the helper left behind. Inheritance copies the literal `config/crew-harness` file, so a secondmate's own crewmates use the primary's crewmate harness only when it names a concrete adapter such as `codex`; an unset or `default` value has nothing concrete to inherit, and the secondmate's own crewmates fall back to the secondmate's own or detected harness instead. `config/secondmate-harness` is not inherited because it is only the primary's knob for launching secondmate agents. `data/captain-shared.md` is main-authoritative in the primary home and read-only in secondmate homes. @@ -95,7 +96,7 @@ Keep every `data/learnings.md` fully local by captain decision; route fleet-gene No AGENTS.md reread nudge is needed at spawn or respawn because the agent reads instructions fresh on launch; only the bootstrap sweep's running-home instruction-surface advance needs that AGENTS.md re-read. Bootstrap reports successful AGENTS.md re-read sends as `BOOTSTRAP_INFO:` and only emits `NUDGE_SECONDMATES:` when that send fails and needs retry. A separate, literal-content config reread is required whenever inherited `config/*` material changes under an already-running secondmate. -After each successful allowlisted config write, both the locked bootstrap convergence path and mid-session `bin/fm-config-push.sh` use the shared propagation report to build one per-home generation-specific private instruction file from the validated destination post-write bytes for only the allowlisted config items that actually changed for that home (`config/crew-dispatch.json`, `config/crew-harness`, `config/backlog-backend`), in deterministic allowlist order. +After each successful allowlisted config write, both the locked bootstrap convergence path and mid-session `bin/fm-config-push.sh` use the shared propagation report to build one per-home generation-specific private instruction file from the validated destination post-write bytes for only the allowlisted config items that actually changed for that home (`config/crew-dispatch.json`, `config/crew-harness`, `config/backlog-backend`, `config/herdr-presentation-spaces`), in deterministic allowlist order. Each changed path is printed with clear begin/end delimiters and the destination file's full exact new bytes unparsed, or the explicit token `ABSENT` when propagation removed the destination copy. The instruction uses only minimal framing that these are defaults/rules and do not remove judgment; it never includes SHA values, selected profiles, parsed summaries, or any other generated interpretation. `data/captain-shared.md` is not a config file and is never inlined into this instruction file or message. diff --git a/.gitignore b/.gitignore index 5ed2da0c32a..2b17922dff5 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,4 @@ config/backend config/x-mode.env config/cmux-socket-password config/wedge-alarm +config/herdr-presentation-spaces diff --git a/AGENTS.md b/AGENTS.md index 29053745399..b5dddd2dd69 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -68,7 +68,7 @@ config/crew-dispatch.json optional crewmate dispatch profiles; LOCAL, gitignore config/secondmate-harness harness the PRIMARY uses to launch SECONDMATE agents, optionally followed by a model and effort token on the same line (" [] []"; section 4); LOCAL, gitignored; absent or "default" harness falls back to config/crew-harness then firstmate's own. The primary's own setting; NOT inherited into secondmate homes (secondmates do not spawn secondmates) config/backlog-backend backlog backend override; LOCAL, gitignored; absent or "tasks-axi" = default tasks-axi backend, "manual" = force routine backlog updates to hand-editing; inherited by secondmate homes (section 10) config/backend runtime session-provider backend override for new tasks; LOCAL, gitignored; absent = falls through to runtime auto-detection (the runtime firstmate itself is executing inside), then tmux; tmux is the verified reference backend (docs/tmux-backend.md), while herdr, zellij, orca, and cmux are experimental spawn backends (docs/herdr-backend.md, docs/zellij-backend.md, docs/orca-backend.md, docs/cmux-backend.md) - herdr and cmux can also be selected by runtime auto-detection, zellij and orca never are (always explicit), and codex-app is not accepted; see docs/codex-app-backend.md; not inherited into secondmate homes -config/herdr-presentation-spaces optional presence flag for Herdr's default-off disposable single-task visual projection; LOCAL, gitignored; not inherited into secondmate homes; see docs/herdr-backend.md "Optional disposable single-task presentation spaces" +config/herdr-presentation-spaces optional presence flag for Herdr's default-off disposable single-task visual projection; LOCAL, gitignored; inherited by secondmate homes; see docs/herdr-backend.md "Optional disposable single-task presentation spaces" config/cmux-socket-password optional cmux control-socket password; LOCAL, gitignored; read fresh on every cmux CLI call and passed through without ever overriding an operator's own ambient CMUX_SOCKET_PASSWORD when absent (docs/cmux-backend.md "Setup") config/wedge-alarm optional away-mode wedge-alarm active-alert directives; LOCAL, gitignored; absent means auto (macOS Notification Center when available); see docs/wedge-alarm.md config/x-mode.env generated X-mode watcher cadence; LOCAL, gitignored; source before arming watcher when present diff --git a/bin/backends/herdr.sh b/bin/backends/herdr.sh index ed40467c190..cecb7e9c512 100644 --- a/bin/backends/herdr.sh +++ b/bin/backends/herdr.sh @@ -267,8 +267,120 @@ fm_backend_herdr_projection_journal_token() { # printf '%s' "$token" } +# fm_backend_herdr_projection_concise_task_label: strip redundant owner +# prefixes from a task id used only in the presentation workspace label. +# Removes firstmate/, 2ndmate-/, and a presentation-level fm- owner +# prefix when present. The ordinary task tab remains fm- and is not +# built by this helper. +fm_backend_herdr_projection_concise_task_label() { # + local task=$1 + case "$task" in + firstmate/*) task=${task#firstmate/} ;; + 2ndmate-*/*) task=${task#*/} ;; + esac + case "$task" in + fm-*) task=${task#fm-} ;; + esac + printf '%s' "$task" +} + +# fm_backend_herdr_projection_workspace_label: presentation-only child label. +# Format is literal U+2514 BOX DRAWINGS LIGHT UP AND RIGHT, one space, the +# concise task label, then the unchanged · p: suffix. +# Labels and tokens remain non-authoritative correlators only. fm_backend_herdr_projection_workspace_label() { # - printf '%s/%s · p:%s' "$(fm_backend_herdr_workspace_label)" "$1" "$2" + printf '└ %s · p:%s' "$(fm_backend_herdr_projection_concise_task_label "$1")" "$2" +} + +# fm_backend_herdr_presentation_session_lock_path: one machine-private lock +# path per live named Herdr session/socket, shared across every Firstmate home +# that uses that session. +# The path is never under any one home's state/ and secondmates never write the +# primary home. Returns non-zero when the named session's socket cannot be +# resolved unambiguously. +fm_backend_herdr_presentation_lock_namespace() { + printf '%s' '/tmp/firstmate-herdr-presentation' +} + +fm_backend_herdr_presentation_lock_namespace_mode() { + if [ "$(uname -s 2>/dev/null)" = Darwin ]; then + stat -f '%Lp' "$1" 2>/dev/null + else + stat -c '%a' "$1" 2>/dev/null + fi +} + +fm_backend_herdr_presentation_lock_namespace_uid() { + if [ "$(uname -s 2>/dev/null)" = Darwin ]; then + stat -f '%u' "$1" 2>/dev/null + else + stat -c '%u' "$1" 2>/dev/null + fi +} + +fm_backend_herdr_presentation_lock_namespace_valid() { + local dir=$1 expected_uid owner mode + [ -d "$dir" ] && [ ! -L "$dir" ] || return 1 + expected_uid=$(id -u 2>/dev/null) || return 1 + owner=$(fm_backend_herdr_presentation_lock_namespace_uid "$dir") || return 1 + mode=$(fm_backend_herdr_presentation_lock_namespace_mode "$dir") || return 1 + [ "$owner" = "$expected_uid" ] && [ "$mode" = 700 ] +} + +# Resolve the one verified running named-session socket path as an absolute +# string. Requires JSON string type and non-empty length (jq -r is never used: +# it would turn JSON null into the literal string "null"). Canonicalizes the +# parent directory when that directory exists so symlink parents such as /tmp +# -> /private/tmp cannot yield two lock identities for the same socket. +fm_backend_herdr_presentation_session_socket_path() { # + local session=$1 sessions socket sock_dir sock_base + [ -n "$session" ] || return 1 + sessions=$(fm_backend_herdr_cli "$session" session list --json 2>/dev/null) || return 1 + socket=$(printf '%s' "$sessions" | jq -er --arg want "$session" ' + [.sessions[]? + | select(.name == $want and .running == true) + | select((.socket_path | type) == "string") + | select((.socket_path | length) > 0) + | .socket_path] + | if length == 1 then .[0] else empty end + ' 2>/dev/null) || return 1 + [ -n "$socket" ] || return 1 + case "$socket" in + /*) ;; + *) return 1 ;; + esac + sock_dir=$(dirname "$socket") + sock_base=$(basename "$socket") + [ -n "$sock_dir" ] && [ -n "$sock_base" ] || return 1 + if [ -d "$sock_dir" ]; then + sock_dir=$(cd "$sock_dir" 2>/dev/null && pwd -P) || return 1 + socket="$sock_dir/$sock_base" + fi + printf '%s' "$socket" +} + +fm_backend_herdr_presentation_session_lock_path() { # + local session=$1 socket key dir hash + [ -n "$session" ] || return 1 + socket=$(fm_backend_herdr_presentation_session_socket_path "$session") || return 1 + if command -v shasum >/dev/null 2>&1; then + hash=$(printf '%s\0%s' "$session" "$socket" | shasum -a 256 2>/dev/null | awk '{print $1}') + elif command -v sha256sum >/dev/null 2>&1; then + hash=$(printf '%s\0%s' "$session" "$socket" | sha256sum 2>/dev/null | awk '{print $1}') + else + return 1 + fi + [ -n "$hash" ] || return 1 + key=${hash:0:32} + dir=$(fm_backend_herdr_presentation_lock_namespace) || return 1 + [ -n "$dir" ] || return 1 + if [ ! -e "$dir" ] && [ ! -L "$dir" ]; then + if ! mkdir -m 700 "$dir" 2>/dev/null; then + fm_backend_herdr_presentation_lock_namespace_valid "$dir" || return 1 + fi + fi + fm_backend_herdr_presentation_lock_namespace_valid "$dir" || return 1 + printf '%s/order-%s.lock' "$dir" "$key" } # fm_backend_herdr_projection_focus_snapshot: print the exact active @@ -377,44 +489,96 @@ fm_backend_herdr_projection_close_pane_focus_preserving() { # is the owning FM_HOME label (firstmate or 2ndmate-). +# New-format └ ... · p: children and, for compatibility only, already +# adjacent old-format firstmate/... or 2ndmate-/... projections may extend +# the block read-only; they are never renamed or moved. # # This is presentation-only and always returns success. # Every unavailable, ambiguous, failed, or unverifiable ordering step prints a # warning and leaves the safely-created worker running in Herdr's current # order. # It never looks up a task endpoint, adopts or reuses a workspace, retries an -# ambiguous move, restores focus, or calls any close/delete primitive. -# Existing worker and secondmate workspace ids are read only to validate stable -# relative order; the sole move target is , captured -# directly from the current workspace-create response. -fm_backend_herdr_projection_order_best_effort() { # - local session=$1 created=$2 list analysis current desired protocol schema sessions socket mover response move_status focus_before - local before_secondmates before_workers after_secondmates after_workers +# ambiguous move, or calls any close/delete/rename primitive. +# The sole move target is , captured directly from the +# current workspace-create response. +# After a successful move, every pre-existing workspace id sequence excluding +# the new id must be byte-identical to the pre-move sequence. +fm_backend_herdr_projection_order_best_effort() { # + local session=$1 created=$2 parent=$3 list analysis current desired protocol schema socket mover response move_status focus_before + local before_existing after_existing + [ -n "$parent" ] || { + echo "warning: herdr presentation ordering missing owning parent label; leaving worker in Herdr's current order" >&2 + return 0 + } list=$(fm_backend_herdr_cli "$session" workspace list 2>/dev/null) || { echo "warning: herdr presentation ordering could not list workspaces; leaving worker in Herdr's current order" >&2 return 0 } - analysis=$(printf '%s' "$list" | jq -c --arg created "$created" ' - def primary_worker: + analysis=$(printf '%s' "$list" | jq -c --arg created "$created" --arg parent "$parent" ' + def is_parent: + (.label | type) == "string" and .label == $parent; + def is_top_level_parent: + (.label | type) == "string" + and ((.label == "firstmate") or (.label | test("^2ndmate-[^/]+$"))); + def is_new_child: + (.label | type) == "string" + and (.label | test("^└ .+ · p:[A-Za-z0-9_-]{22}$")); + def is_legacy_child: (.label | type) == "string" - and (.label | test("^firstmate/.+ · p:[A-Za-z0-9_-]{22}$")); + and (.label | test("^(firstmate|2ndmate-[^/]+)/.+ · p:[A-Za-z0-9_-]{22}$")); + def is_legacy_child_for($owner): + is_legacy_child and (.label | startswith($owner + "/")); + def is_child_for($owner): + is_new_child or is_legacy_child_for($owner); (.result.workspaces // null) as $spaces | select(($spaces | type) == "array" and ($spaces | length) > 0) | ([range(0; $spaces | length) | select($spaces[.].workspace_id == $created)]) as $matches | select(($matches | length) == 1) | ($matches[0]) as $current | select($current == (($spaces | length) - 1)) - | select($spaces[0].label == "firstmate") - | ($spaces[1:$current]) as $before - | ([range(0; $before | length) | select(($before[.] | primary_worker) | not)] | first // ($before | length)) as $prefix - | select(([$before[$prefix:][]? | select(primary_worker)] | length) == 0) + | ([range(0; $spaces | length) | select($spaces[.] | is_parent)]) as $parents + | select(($parents | length) == 1) + | ($parents[0]) as $pidx + | select($pidx < $current) + | ( + reduce range($pidx + 1; $current) as $i ( + 0; + if ($spaces[$i] | is_child_for($parent)) and (. == ($i - $pidx - 1)) + then . + 1 + else . + end + ) + ) as $block + | (reduce range($pidx + 1 + $block; $current) as $i ( + {valid: true, active_parent: null}; + if .valid == false then . + elif ($spaces[$i] | is_top_level_parent) then + .active_parent = $spaces[$i].label + elif ($spaces[$i] | is_new_child) then + if .active_parent == null then .valid = false else . end + elif ($spaces[$i] | is_legacy_child) then + .active_parent as $owner + | if $owner == null then + .valid = false + elif (($spaces[$i] | is_legacy_child_for($owner)) | not) then + .valid = false + else + . + end + else + .active_parent = null + end + )) as $remainder + | select($remainder.valid == true) | { current: $current, - desired: (1 + $prefix), - secondmates: [$spaces[] | select((.label | type) == "string" and (.label | startswith("2ndmate-"))) | .workspace_id], - workers: [$spaces[] | select(primary_worker and .workspace_id != $created) | .workspace_id] + desired: ($pidx + 1 + $block), + parent_index: $pidx, + existing: [$spaces[] | select(.workspace_id != $created) | .workspace_id] } ' 2>/dev/null) || analysis= [ -n "$analysis" ] || { @@ -458,15 +622,7 @@ fm_backend_herdr_projection_order_best_effort() { # &2 return 0 fi - sessions=$(fm_backend_herdr_cli "$session" session list --json 2>/dev/null) || { - echo "warning: herdr presentation ordering could not resolve the named session socket; leaving worker in Herdr's current order" >&2 - return 0 - } - socket=$(printf '%s' "$sessions" | jq -r --arg want "$session" ' - [.sessions[]? | select(.name == $want and .running == true) | .socket_path] - | if length == 1 then .[0] else empty end - ' 2>/dev/null) - [ -n "$socket" ] || { + socket=$(fm_backend_herdr_presentation_session_socket_path "$session") || { echo "warning: herdr presentation ordering found an ambiguous named session socket; leaving worker in Herdr's current order" >&2 return 0 } @@ -486,22 +642,24 @@ fm_backend_herdr_projection_order_best_effort() { # &2 return 0 fi - if ! printf '%s' "$response" | jq -e --arg created "$created" --argjson desired "$desired" ' + if ! printf '%s' "$response" | jq -e --arg created "$created" --arg parent "$parent" --argjson desired "$desired" ' .result.type == "workspace_list" and (.result.workspaces | type) == "array" and .result.workspaces[$desired].workspace_id == $created - and .result.workspaces[0].label == "firstmate" + and ([.result.workspaces[] | select(.label == $parent)] | length) == 1 + and ( + [range(0; .result.workspaces | length) as $i + | select(.result.workspaces[$i].label == $parent) + | $i][0] < $desired + ) ' >/dev/null 2>&1; then echo "warning: herdr presentation workspace move returned an unverifiable order; leaving worker running without cleanup" >&2 return 0 fi - before_secondmates=$(printf '%s' "$analysis" | jq -c '.secondmates' 2>/dev/null) - before_workers=$(printf '%s' "$analysis" | jq -c '.workers' 2>/dev/null) - after_secondmates=$(printf '%s' "$response" | jq -c '[.result.workspaces[] | select((.label | type) == "string" and (.label | startswith("2ndmate-"))) | .workspace_id]' 2>/dev/null) - after_workers=$(printf '%s' "$response" | jq -c --arg created "$created" '[.result.workspaces[] | select((.label | type) == "string" and (.label | test("^firstmate/.+ · p:[A-Za-z0-9_-]{22}$")) and .workspace_id != $created) | .workspace_id]' 2>/dev/null) - if [ "$after_secondmates" != "$before_secondmates" ] \ - || [ "$after_workers" != "$before_workers" ]; then + before_existing=$(printf '%s' "$analysis" | jq -c '.existing' 2>/dev/null) + after_existing=$(printf '%s' "$response" | jq -c --arg created "$created" '[.result.workspaces[] | select(.workspace_id != $created) | .workspace_id]' 2>/dev/null) + if [ "$after_existing" != "$before_existing" ]; then echo "warning: herdr presentation workspace move did not preserve relative order; leaving worker running without cleanup" >&2 fi return 0 diff --git a/bin/fm-config-inherit-lib.sh b/bin/fm-config-inherit-lib.sh index 5a5d5928a28..95abba2439a 100644 --- a/bin/fm-config-inherit-lib.sh +++ b/bin/fm-config-inherit-lib.sh @@ -4,10 +4,11 @@ # config/, so a secondmate's OWN crewmates inherit the primary's settings # (e.g. primary config/crew-dispatch.json makes a secondmate use the same dispatch # profile rules, primary config/crew-harness=codex makes a secondmate's crewmates -# spawn on codex too, and primary config/backlog-backend=manual makes that home -# hand-edit backlog files too). It also pushes the one primary-authoritative -# shared captain-preference file, data/captain-shared.md, into each secondmate -# home's data/ as a read-only copy. +# spawn on codex too, primary config/backlog-backend=manual makes that home +# hand-edit backlog files too, and primary config/herdr-presentation-spaces +# enables the same default-off Herdr presentation projection). It also pushes +# the one primary-authoritative shared captain-preference file, +# data/captain-shared.md, into each secondmate home's data/ as a read-only copy. # # Usage: . bin/fm-config-inherit-lib.sh (no FM_* setup required) # @@ -39,7 +40,7 @@ FM_SHARED_CAPTAIN_MODE="444" # The declared inheritable set (space-separated, config-dir-relative item paths). # Extend here to inherit more of the primary's local config; override via the # environment only in tests. Items must not contain whitespace. -FM_INHERITABLE_CONFIG="${FM_INHERITABLE_CONFIG:-crew-dispatch.json crew-harness backlog-backend}" +FM_INHERITABLE_CONFIG="${FM_INHERITABLE_CONFIG:-crew-dispatch.json crew-harness backlog-backend herdr-presentation-spaces}" fm_inherit_file_mode() { if [ "$(uname)" = Darwin ]; then diff --git a/bin/fm-fleet-snapshot.sh b/bin/fm-fleet-snapshot.sh index fbfb18912ef..51f61bb4ca8 100755 --- a/bin/fm-fleet-snapshot.sh +++ b/bin/fm-fleet-snapshot.sh @@ -1100,7 +1100,7 @@ secondmate_current_json() { # terminal_evidence:$terminal,contradiction:$contradiction}') else if [ -n "$event_raw" ]; then - provenance=parent-event-fallback + provenance='parent-event-fallback' freshness=historical-event else provenance=unknown diff --git a/bin/fm-pr-lib.sh b/bin/fm-pr-lib.sh index 14ab830d0bd..47e8be648fd 100755 --- a/bin/fm-pr-lib.sh +++ b/bin/fm-pr-lib.sh @@ -162,7 +162,7 @@ fm_pr_url_parse() { # The path class contains "/" and "-", so this match is greedy to the last # "/-/merge_requests/". Any earlier separator therefore lands inside the # captured path, where the reserved "-" segment is refused. - pattern='^https://([a-z0-9.-]{1,253})/([A-Za-z0-9._/-]{3,1024})/-/merge_requests/([1-9][0-9]*)$' + pattern='^https://([a-z0-9.-]{1,253})/([A-Za-z0-9._/-]+)/-/merge_requests/([1-9][0-9]*)$' [[ "$raw" =~ $pattern ]] || return 1 host=${BASH_REMATCH[1]} path=${BASH_REMATCH[2]} diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index acb6c8756b6..d7766205d02 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -35,13 +35,14 @@ # or recovered state is never adopted, reused, closed, or deleted through that # presentation path; a flat launch is allowed only after duplicate-agent risk # is independently absent. Treehouse allocation and task metadata are unchanged. -# A clean projected create makes one bounded attempt to hold the shared -# presentation-order lock through launch handoff. Lock contention warns and -# falls back to the ordinary flat layout before any projection mutation. A -# primary home's exact response-derived new workspace is appended to the stable -# primary-worker block immediately after firstmate. Ordering never authorizes -# lifecycle cleanup, and any unavailable, ambiguous, or failed move warns while -# the spawn continues. +# A clean projected create makes one bounded attempt to hold the one +# session-scoped presentation-order lock (keyed by named session plus +# canonical socket, outside any home's state/) through launch handoff. Lock +# contention warns and falls back to the ordinary flat layout before any +# projection mutation. The exact response-derived new workspace is inserted +# immediately after its owning parent (firstmate or 2ndmate-) contiguous +# child block. Ordering never authorizes lifecycle cleanup, and any +# unavailable, ambiguous, or failed move warns while the spawn continues. # Every projected create, prune, and move captures and verifies the named # session's exact active workspace and tab. A detected focus change restores # only that exact tab id; an ambiguous pre-operation snapshot refuses the @@ -249,8 +250,8 @@ spawn_abort_cleanup() { local status=$? if [ "$HERDR_PROJECTION_ABORT_CLEANUP" = 1 ] \ && [ "$HERDR_PRESENTATION_ORDER_LOCK_HELD" != 1 ]; then - if ! spawn_herdr_presentation_order_lock_acquire; then - echo "warning: herdr presentation focus lock stayed busy; retaining the projection journal and refusing concurrent abort cleanup" >&2 + if ! spawn_herdr_presentation_order_lock_acquire "${HERDR_PROJECTION_ABORT_SESSION:-}"; then + echo "warning: herdr presentation focus lock unavailable; retaining the projection journal and refusing concurrent abort cleanup" >&2 HERDR_PROJECTION_ABORT_CLEANUP=0 fi fi @@ -305,9 +306,14 @@ spawn_abort_cleanup() { } trap spawn_abort_cleanup EXIT +# One bounded lock per live Herdr session/socket, shared across all homes. +# is required so secondmate and primary spawns serialize against the +# same session without writing any other home's state directory. spawn_herdr_presentation_order_lock_acquire() { - local attempt - HERDR_PRESENTATION_ORDER_LOCK="$STATE/.herdr-presentation-order.lock" + local session=${1:-} attempt lock_path + [ -n "$session" ] || session=$(fm_backend_herdr_session) + lock_path=$(fm_backend_herdr_presentation_session_lock_path "$session") || return 1 + HERDR_PRESENTATION_ORDER_LOCK="$lock_path" attempt=0 while [ "$attempt" -lt 50 ]; do if fm_lock_try_acquire "$HERDR_PRESENTATION_ORDER_LOCK"; then @@ -866,8 +872,7 @@ case "$BACKEND" in fi HERDR_PRESENTATION_JOURNAL=$(fm_backend_herdr_projection_journal_path "$STATE" "$ID") HERDR_PROJECTED=0 - if [ -f "$CONFIG/herdr-presentation-spaces" ]; then - HERDR_PRESENTATION_ORDER_LOCK="$STATE/.herdr-presentation-order.lock" + if [ "$KIND" != secondmate ] && [ -f "$CONFIG/herdr-presentation-spaces" ]; then if [ -e "$HERDR_PRESENTATION_JOURNAL" ] || [ -L "$HERDR_PRESENTATION_JOURNAL" ]; then if [ -e "$STATE/$ID.meta" ] || [ -L "$STATE/$ID.meta" ]; then herdr_projection_existing_meta_allows_flat "$STATE/$ID.meta" || exit 1 @@ -876,14 +881,16 @@ case "$BACKEND" in fm_backend_herdr_projection_recovery_allows_flat \ "$HERDR_RECOVERY_SESSION" "$HERDR_PRESENTATION_JOURNAL" "$ID" || exit 1 elif [ ! -e "$STATE/$ID.meta" ] && [ ! -L "$STATE/$ID.meta" ]; then - HERDR_PRESENTATION_ORDERING=0 - if spawn_herdr_presentation_order_lock_acquire; then - if [ "$(FM_HOME="$HERDR_LABEL_HOME" fm_backend_herdr_workspace_label)" = firstmate ]; then - HERDR_PRESENTATION_ORDERING=1 - fi + HERDR_SES=$(fm_backend_herdr_session) + HERDR_PARENT_LABEL=$(FM_HOME="$HERDR_LABEL_HOME" fm_backend_herdr_workspace_label) + # Session lock path resolution needs a live named-session socket. + # Ensure the server before journal publication so lock failure degrades + # to flat without ever creating an unlocked projection. + if ! fm_backend_herdr_server_ensure "$HERDR_SES"; then + echo "warning: herdr presentation could not ensure its session server; using the ordinary flat layout without projection" >&2 + elif spawn_herdr_presentation_order_lock_acquire "$HERDR_SES"; then HERDR_PROJECTION_ID=$(fm_backend_herdr_projection_journal_create "$STATE" "$ID") || exit 1 - HERDR_PROJECTION_LABEL=$(FM_HOME="$HERDR_LABEL_HOME" \ - fm_backend_herdr_projection_workspace_label "$ID" "$HERDR_PROJECTION_ID") + HERDR_PROJECTION_LABEL=$(fm_backend_herdr_projection_workspace_label "$ID" "$HERDR_PROJECTION_ID") if ! FM_HOME="$HERDR_LABEL_HOME" fm_backend_herdr_projection_create_task \ "$PROJ_ABS" "$HERDR_PROJECTION_LABEL" "$W"; then if [ "${FM_BACKEND_HERDR_PROJECTION_CLEANUP_SAFE:-0}" = 1 ]; then @@ -904,11 +911,10 @@ case "$BACKEND" in HERDR_PROJECTION_ABORT_SESSION=$HERDR_SES HERDR_PROJECTION_ABORT_TASK_PANE=$HERDR_PANE_ID HERDR_PROJECTION_ABORT_SEEDED_PANE=$FM_BACKEND_HERDR_PROJECTION_SEEDED_PANE_ID - if [ "$HERDR_PRESENTATION_ORDERING" = 1 ]; then - fm_backend_herdr_projection_order_best_effort "$HERDR_SES" "$HERDR_WORKSPACE_ID" - fi + fm_backend_herdr_projection_order_best_effort \ + "$HERDR_SES" "$HERDR_WORKSPACE_ID" "$HERDR_PARENT_LABEL" else - echo "warning: herdr presentation focus lock stayed busy; using the ordinary flat layout without projection" >&2 + echo "warning: herdr presentation focus lock unavailable; using the ordinary flat layout without projection" >&2 fi fi fi diff --git a/bin/fm-teardown.sh b/bin/fm-teardown.sh index c1fa2d05694..5f2f02f8c82 100755 --- a/bin/fm-teardown.sh +++ b/bin/fm-teardown.sh @@ -1154,24 +1154,26 @@ fi if [ "$HERDR_PRESENTATION_RETIRE_CANDIDATE" = 1 ]; then # shellcheck source=bin/fm-wake-lib.sh . "$SCRIPT_DIR/fm-wake-lib.sh" - HERDR_PRESENTATION_FOCUS_LOCK="$STATE/.herdr-presentation-order.lock" + HERDR_PRESENTATION_FOCUS_LOCK= HERDR_PRESENTATION_FOCUS_LOCK_HELD=0 HERDR_PRESENTATION_FOCUS_LOCK_ATTEMPT=0 - while [ "$HERDR_PRESENTATION_FOCUS_LOCK_ATTEMPT" -lt 50 ]; do - if fm_lock_try_acquire "$HERDR_PRESENTATION_FOCUS_LOCK"; then - HERDR_PRESENTATION_FOCUS_LOCK_HELD=1 - break - fi - sleep 0.1 - HERDR_PRESENTATION_FOCUS_LOCK_ATTEMPT=$((HERDR_PRESENTATION_FOCUS_LOCK_ATTEMPT + 1)) - done + if HERDR_PRESENTATION_FOCUS_LOCK=$(fm_backend_herdr_presentation_session_lock_path "$HERDR_PRESENTATION_SESSION"); then + while [ "$HERDR_PRESENTATION_FOCUS_LOCK_ATTEMPT" -lt 50 ]; do + if fm_lock_try_acquire "$HERDR_PRESENTATION_FOCUS_LOCK"; then + HERDR_PRESENTATION_FOCUS_LOCK_HELD=1 + break + fi + sleep 0.1 + HERDR_PRESENTATION_FOCUS_LOCK_ATTEMPT=$((HERDR_PRESENTATION_FOCUS_LOCK_ATTEMPT + 1)) + done + fi if [ "$HERDR_PRESENTATION_FOCUS_LOCK_HELD" = 1 ]; then fm_backend_herdr_projection_close_pane_focus_preserving \ "$HERDR_PRESENTATION_SESSION" "$HERDR_PRESENTATION_PANE" 2>/dev/null || true HERDR_PRESENTATION_FOCUS_LOCK_HELD=0 fm_lock_release "$HERDR_PRESENTATION_FOCUS_LOCK" || true else - echo "warning: herdr presentation focus lock stayed busy; refusing a concurrent focus-unsafe pane close" >&2 + echo "warning: herdr presentation focus lock unavailable; refusing a concurrent focus-unsafe pane close" >&2 fi elif [ "$BACKEND" != orca ]; then fm_backend_kill "$BACKEND" "$T" "$(meta_value "$META" zellij_tab_id)" "fm-$ID" 2>/dev/null || true diff --git a/docs/architecture.md b/docs/architecture.md index b755dd59ce3..ecb965f6d69 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -170,7 +170,7 @@ Those optional tokens are re-read on every secondmate spawn or respawn and are o An explicit per-spawn harness or raw launch command does not inherit model or effort tokens from `config/secondmate-harness`. `config/crew-harness` remains the crewmate harness and is inherited into secondmate homes. `config/crew-dispatch.json` is inherited too; secondmates use the same natural-language dispatch profiles when spawning their own crewmates. -`config/backlog-backend` is inherited too; absent or `tasks-axi` selects the default tasks-axi backlog backend, while `manual` forces routine backlog updates to hand-editing across the fleet without disabling validated handoff delegation. +The [`secondmate-provisioning` skill](../.agents/skills/secondmate-provisioning/SKILL.md) owns the complete inherited-local-material allowlist and propagation contract. The `data/secondmates.md` line contract is owned by the [`secondmate-provisioning` skill](../.agents/skills/secondmate-provisioning/SKILL.md#routing-table), and the secondmate environment variables are documented in [configuration.md](configuration.md). diff --git a/docs/configuration.md b/docs/configuration.md index a1476d9e6d5..a5103991f8a 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -72,7 +72,7 @@ These five sentences are the single owner of the task-selector vocabulary; backe By default, Herdr workspaces are derived from `FM_HOME`: the primary home uses `firstmate`, and a secondmate home marked by `.fm-secondmate-home` uses `2ndmate-`. The default-container spawn, list-live, and recovery paths read that label from the active home, so a secondmate's own crewmates stay inside that secondmate home's herdr space. The optional local `config/herdr-presentation-spaces` presence flag instead enables Herdr's default-off disposable single-task visual projection; [`docs/herdr-backend.md`](herdr-backend.md#optional-disposable-single-task-presentation-spaces) owns its behavior, safety limits, and recovery contract. -The flag is local to its Firstmate home and is not inherited into secondmate homes. +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. Use the explicit guarded cleanup path described in [`docs/herdr-backend.md`](herdr-backend.md) instead of `herdr server stop`. For normal zellij operations, `FM_ZELLIJ_SESSION` selects the named session and defaults to `firstmate`. @@ -180,7 +180,7 @@ When the harness token is absent or `default`, secondmate launch falls back thro An explicit harness argument to `fm-spawn.sh` still overrides either config file for that spawn only. An explicit `--model` or `--effort` overrides the matching token from `config/secondmate-harness`; an explicit harness or raw launch command starts with clean model and effort defaults unless those flags are also passed. When `config/crew-dispatch.json` exists, crewmate and scout spawns require an explicit resolved harness instead of automatically falling back to `config/crew-harness`. -The inherited-local-material contract is owned by `secondmate-provisioning`; for harness behavior, its propagated config items make a secondmate's own crewmates, dispatch profiles, and backlog backend use the primary values. +The inherited-local-material contract is owned by [`secondmate-provisioning`](../.agents/skills/secondmate-provisioning/SKILL.md); its harness-relevant consequence is that a secondmate's own crewmates use the primary's dispatch profiles and static harness value. Those inherited values are defaults and rules only; `fm-spawn` still permits a consciously chosen explicit runtime outside the config. `config/secondmate-harness` is not inherited because secondmates do not launch secondmates. For grok, `fm-spawn.sh` installs one firstmate-owned global turn-end hook under `$GROK_HOME/hooks/`, or `~/.grok/hooks/` when `GROK_HOME` is unset, and drops a per-task `.fm-grok-turnend` pointer in the worktree, with teardown removing the task token and pointer. @@ -262,7 +262,7 @@ When a running home advances and its loaded instruction surface (`AGENTS.md`, `b If that send fails, bootstrap keeps an idempotent retry marker and emits `NUDGE_SECONDMATES:` with the failure reason. The same bootstrap run emits `SECONDMATE_LIVENESS:` only when a live secondmate endpoint is skipped or respawn fails; already-live and successfully respawned endpoints are handled silently. For a mid-session inherited local-material edit where tracked-file sync is not needed, run `bin/fm-config-push.sh`. -It uses the same live secondmate discovery and propagation helper as bootstrap, prints each live home's `crew-dispatch.json`, `crew-harness`, `backlog-backend`, and `data/captain-shared.md` result as `pushed`, `unchanged`, `skipped`, or `error`, and exits non-zero for real propagation errors or config-reread send failures. +It uses the same live secondmate discovery and propagation helper as bootstrap, prints each live home's `crew-dispatch.json`, `crew-harness`, `backlog-backend`, `herdr-presentation-spaces`, and `data/captain-shared.md` result as `pushed`, `unchanged`, `skipped`, or `error`, and exits non-zero for real propagation errors or config-reread send failures. When an allowlisted config item changes for an already-running home, it sends the literal-content reread pointer described in [`secondmate-provisioning`](../.agents/skills/secondmate-provisioning/SKILL.md); unchanged allowlisted config sends no pointer unless a previous delivery is pending. The locked bootstrap inheritance pass uses the same per-home changed-set and reread path for already-running homes; see `secondmate-provisioning` for the single contract owner. That live discovery starts from `state/*.meta` records with `kind=secondmate`; `data/secondmates.md` only backfills `home=` for older or incomplete meta records. diff --git a/docs/herdr-backend.md b/docs/herdr-backend.md index 4401050e0d5..9ab00659ddb 100644 --- a/docs/herdr-backend.md +++ b/docs/herdr-backend.md @@ -34,8 +34,8 @@ With the optional projection disabled, attach to the selected `HERDR_SESSION` an You do not need to attach for routine supervision: from an active firstmate session, `bin/fm-peek.sh fm-` reads a task's pane without attaching, and `FM_HOME= bin/fm-send.sh fm- ""` steers it unless `FM_HOME` is already set to the active firstmate home. An optional local `config/herdr-presentation-spaces` presence flag gives a clean new task a disposable one-task workspace instead. -The flag is absent by default, and the feature is presentation-only and best-effort rather than durable grouping. -Primary-home projected workspaces are placed in a stable contiguous block immediately after `firstmate` when Herdr protocol 16 `workspace.move` and `python3` are available. +The flag is absent by default, is inherited into secondmate homes through the primary-authoritative inheritable-config owner, and the feature is presentation-only and best-effort rather than durable grouping. +Every newly projected child created by a primary or secondmate home is inserted as a top-level space immediately after its owning parent (`firstmate` or `2ndmate-`) contiguous child block when Herdr protocol 16 `workspace.move` and `python3` are available. Unavailable or failed ordering warns and leaves the successfully created worker running in Herdr's current order. See "Optional disposable single-task presentation spaces" below before enabling it. @@ -136,16 +136,21 @@ Teardown (`fm_backend_herdr_kill`) closes only the task's pane/tab, never the wo ## Optional disposable single-task presentation spaces -Create the local, gitignored `config/herdr-presentation-spaces` file to enable the Stage 1 presentation projection for that Firstmate home. +Create the local, gitignored `config/herdr-presentation-spaces` file on the primary home to enable the presentation projection. +The primary's literal presence or absence converges to registered secondmate homes through the same launch, bootstrap, and config-push inheritance owner as the other declared inheritable config items. An absent file is off, and the off path runs the existing home-workspace and `fm-`-tab command sequence unchanged. +A home that has not yet converged stays flat rather than gaining partial projection authority. This is a visual convenience, not a task container authority, lifecycle foundation, or durable grouping guarantee. +The `kind=secondmate` agent itself always uses its ordinary `2ndmate-` parent workspace and never receives a corner projection; only eligible crewmates and scouts launched by that home project beneath it. Only a Herdr task with neither `state/.meta` nor `state/.herdr-presentation` is eligible for a projected create. Firstmate generates 128 random bits, encodes them as a 22-character base64url `projection_id`, and atomically publishes `state/.herdr-presentation` before asking Herdr to create anything. The three-line journal contains only `version=1`, `task_id=`, and `projection_id=`. It records that a visual projection was attempted and never selects or authorizes send, capture, kill, Treehouse return, or task-ownership decisions. -The new workspace is created with the normal project cwd, `--no-focus`, and a visible label such as `firstmate/release-notes · p:AbCdEfGhIjKlMnOpQrStUv`. +The new workspace is created with the normal project cwd, `--no-focus`, and a visible label such as `└ release-notes · p:AbCdEfGhIjKlMnOpQrStUv`. +Every newly created child uses the literal U+2514 `└`, one space, the concise task label with redundant `firstmate/`, `2ndmate-/`, and presentation-level `fm-` owner prefixes removed, then the unchanged ` · p:` suffix. +The ordinary task tab remains `fm-` and is unchanged. The full token is intentionally visible because Herdr has no verified persistent hidden field suitable for this non-adversarial correlator. The create response's exact workspace, seeded tab, and root pane IDs are retained only in the spawning process. The normal `fm-` tab is created in that exact workspace, and only the exact seeded tab from the same workspace-create response is eligible for pruning. @@ -155,16 +160,20 @@ Immediately before and after projected workspace create, task-tab create, seeded The snapshot comes only from the named session's response and is cross-checked against that workspace's focused tab. An ambiguous pre-operation snapshot refuses the focus-sensitive mutation rather than guessing from a label, order, or ambient client. -For a primary-home projected create, Firstmate makes one presentation-only ordering attempt after that exact workspace has converged. -A bounded shared lock serializes primary projected creates across workspace creation and ordering, so the workers retain Herdr's actual create order even when Firstmate starts them concurrently. -The new response-derived workspace id is appended to the existing contiguous `firstmate/... · p:` prefix immediately after `firstmate`. -This puts the complete primary-worker block before every `2ndmate-*` workspace while leaving every existing workspace, including all secondmate workspaces, in its previous relative order. +For every eligible projected create from a primary or secondmate home, Firstmate makes one presentation-only ordering attempt after that exact workspace has converged. +One bounded lock per live named Herdr session/socket serializes projected creates, ordering, abort cleanup, and projected normal cleanup across every Firstmate home that shares the session. +The lock key is derived from the verified session name and canonical socket path and lives in a machine-private shared runtime namespace, never inside any one home's `state/`. +An unverified or ambiguous socket or an insecure shared-lock namespace fails closed for presentation mutation, warns, and leaves the task on the ordinary flat path. +The new response-derived workspace id is inserted immediately after its owning parent (`firstmate` or `2ndmate-`) contiguous child block and before the next parent. +New-format `└ ... · p:` children define that block; already-adjacent old-format `firstmate/... · p:` or `2ndmate-/... · p:` projections may extend it read-only for compatibility and are never renamed or migrated. +An ambiguous, foreign, or detached presentation child makes the ordering shape unverifiable, so Firstmate warns and skips the move instead of assigning ownership by guesswork. Only the exact workspace id returned by the current projected create is ever a move target. -Labels are used only to validate the presentation shape and calculate an insert index, never as task authority or as a destructive target selector. +After a successful move, the sequence of every pre-existing workspace id excluding the new id must be byte-identical to the pre-move sequence. +Labels and tokens remain non-authoritative correlators only; they never authorize adoption, close, delete, rename, task routing, Treehouse return, or recovery. Herdr 0.7.4 protocol 16 exposes `workspace.move` in `herdr api schema`, with exact parameters `workspace_id` and zero-based `insert_index`, but does not expose it as a CLI subcommand. `bin/backends/herdr-workspace-move.py` therefore sends that one whitelisted method over the exact named session's Unix socket and accepts only its matching `workspace_list` response. -The returned order is checked against the primary-worker relative order and secondmate relative order. +The returned order is checked against the full pre-existing workspace-id sequence and the owning-parent insertion point. The installed move does not focus its target, but Firstmate still compares the exact pre-operation workspace and tab afterward and restores that exact tab if a future or failed move changes focus. Focus restoration is not an ordering retry and grants no authority over the moved workspace. @@ -203,7 +212,8 @@ Any live or unknown pane in those matches refuses the duplicate launch. The user-visible compromises are intentional: - Grouping is best-effort during a clean Herdr server lifetime, not durable or guaranteed. -- Clean primary-home projected creates form one stable contiguous worker block immediately after `firstmate`; existing ambiguous or manually interleaved layouts degrade with a warning instead of being rewritten. +- Clean projected creates form one stable contiguous child block immediately after their owning parent (`firstmate` or `2ndmate-`); existing ambiguous or manually interleaved layouts degrade with a warning instead of being rewritten. +- Existing live or recovered projected spaces are never force-renamed, moved, or promoted from tabs into the new topology. - A Herdr restart restores the token-bearing layout as an agent-free husk, and the task respawns flat while that old space is left untouched. - Crashes, response loss, failed exact-pane close, or human renames can leave stale empty-looking spaces that Firstmate never auto-deletes. - Spaces have no cross-home cleanup. @@ -214,19 +224,32 @@ The user-visible compromises are intentional: The projection and its ordering follow-up make no Herdr provider/API change, no Treehouse lease or return change, no ownership registry, and no cross-home cleanup path. It is intentionally separate from any future Treehouse hardening work. -### Isolated E2E evidence (2026-07-20) +### Isolated E2E evidence (2026-07-21) -The mandatory projection suite ran against Herdr 0.7.4, protocol 16, on macOS aarch64 through the guarded named-session lab contract. +The mandatory projection suite, including multi-home secondmate-child topology, ran against Herdr 0.7.4, protocol 16, on macOS aarch64 through the guarded named-session lab contract. The default-session fleet-state tripwire was identical before and after teardown. Exact command: ```sh -HERDR_LAB_HELPER=/Users/kunchen/.treehouse/firstmate-b8697d/3/firstmate/bin/fm-herdr-lab.sh \ +HERDR_LAB_HELPER="$(pwd)/bin/fm-herdr-lab.sh" \ bash tests/fm-backend-herdr-presentation-e2e.test.sh ``` -Exact result: +Exact result (abridged; full suite includes primary and secondmate multi-child topology, concurrent cross-home waves, session-lock contention, legacy coexistence, and exact-pane teardown): + +```text +ok - real Herdr lab: primary presentation opt-in inherits into real secondmate homes +ok - real Herdr lab: primary and two secondmate homes each own a top-level contiguous child block +ok - real Herdr lab: concurrent primary/A/B spawns stay session-locked with zero focus drift +ok - real Herdr lab: session lock contention from a secondmate home falls back flat with no journal +ok - real Herdr lab: legacy projection labels and flat secondmate tabs are left unmigrated +ok - real Herdr lab: multi-home exact-pane teardowns restore captain focus without workspace close authority +ok - real Herdr lab validation completed on Herdr 0.7.4 with the default-session tripwire intact +``` + +Earlier Stage 1 primary-only projection results from 2026-07-20 remain valid for the non-topology cases they covered. + ```text ok - real Herdr lab: flag-off spawn retains the Stage 1 Herdr command sequence with zero ordering calls diff --git a/tests/fm-backend-herdr-presentation-e2e.test.sh b/tests/fm-backend-herdr-presentation-e2e.test.sh index d7b0a9612a2..84905374e2e 100755 --- a/tests/fm-backend-herdr-presentation-e2e.test.sh +++ b/tests/fm-backend-herdr-presentation-e2e.test.sh @@ -1,6 +1,7 @@ #!/usr/bin/env bash # Isolated real-Herdr E2E coverage for the default-off disposable single-task -# presentation projection and its best-effort primary-worker ordering. +# presentation projection and its best-effort owning-parent ordering across +# primary and secondmate homes. # The test drives the real spawn and teardown scripts, a real Treehouse pool, # and the guarded named-session lab helper. set -u @@ -148,14 +149,14 @@ else fi if [ "$status" -eq 0 ] && [ "$mutation" = workspace-create ]; then case "$label" in - firstmate/active-seeded\ ·\ p:*) + $'└ active-seeded · p:'*) mkdir -p "$ACTIVE_SEEDED_CONTROL" printf '%s\n' "$(printf '%s' "$out" | jq -r '.result.workspace.workspace_id')" > "$ACTIVE_SEEDED_CONTROL/workspace" printf '%s\n' "$(printf '%s' "$out" | jq -r '.result.tab.tab_id')" > "$ACTIVE_SEEDED_CONTROL/seeded-tab" printf '%s\n' "$(printf '%s' "$out" | jq -r '.result.root_pane.pane_id')" > "$ACTIVE_SEEDED_CONTROL/seeded-pane" ;; - firstmate/abort-a\ ·\ p:*|firstmate/abort-b\ ·\ p:*) - task=${label#firstmate/}; task=${task%% *} + $'└ abort-a · p:'*|$'└ abort-b · p:'*) + task=${label#$'└ '}; task=${task%% *} mkdir -p "$POST_CREATE_ABORT_CONTROL/$task" printf '%s\n' "$(printf '%s' "$out" | jq -r '.result.workspace.workspace_id')" > "$POST_CREATE_ABORT_CONTROL/$task/workspace" ;; @@ -387,6 +388,12 @@ spawn_task() { # "$ROOT/bin/fm-spawn.sh" "$id" "$project" "sh -c 'sleep 120'" --backend herdr } +spawn_secondmate_task() { + local id=$1 home=$2 + FM_GATE_REFUSE_BYPASS=1 FM_SPAWN_NO_GUARD=1 FM_HOME="$HOME_DIR" FM_ROOT_OVERRIDE="$ROOT" \ + "$ROOT/bin/fm-spawn.sh" "$id" "$home" "sh -c 'sleep 120'" --secondmate --backend herdr +} + teardown_task() { # local id=$1 home=$2 FM_GATE_REFUSE_BYPASS=1 FM_HOME="$home" FM_ROOT_OVERRIDE="$ROOT" \ @@ -411,7 +418,7 @@ projection_labels_from_log() { # sed -n "$((start + 1)),\$p" "$HERDR_CALL_LOG" | awk -F '\t' ' $1 == "workspace" && $2 == "create" { for (i = 1; i < NF; i += 1) { - if ($i == "--label" && $(i + 1) ~ /^firstmate\//) { + if ($i == "--label" && $(i + 1) ~ /^└ /) { print $(i + 1) } } @@ -419,6 +426,13 @@ projection_labels_from_log() { # ' } +session_presentation_lock_path() { + PATH="$FAKEBIN:$PATH" HERDR_SESSION="$HERDR_LAB_SESSION" bash -c ' + . "$0/bin/backends/herdr.sh" + fm_backend_herdr_presentation_session_lock_path "$1" + ' "$ROOT" "$HERDR_LAB_SESSION" +} + assert_no_ordering_lifecycle_calls_since() { # local start=$1 name=$2 calls calls=$(sed -n "$((start + 1)),\$p" "$HERDR_CALL_LOG") @@ -522,8 +536,8 @@ PROJECTED_TAB=$(grep '^herdr_tab_id=' "$ON_META" | cut -d= -f2-) PROJECTED_PANE=$(grep '^herdr_pane_id=' "$ON_META" | cut -d= -f2-) PROJECTED_INFO=$(lab workspace get "$PROJECTED_WSID") || fail "could not inspect the projected workspace" PROJECTED_LABEL=$(printf '%s' "$PROJECTED_INFO" | jq -r '.result.workspace.label // empty') -[ "$PROJECTED_LABEL" = "firstmate/shape · p:$TOKEN" ] \ - || fail "projected workspace label did not contain the full visible token: $PROJECTED_LABEL" +[ "$PROJECTED_LABEL" = "└ shape · p:$TOKEN" ] \ + || fail "projected workspace label did not use the corner format with full token: $PROJECTED_LABEL" PROJECTED_TABS=$(lab tab list --workspace "$PROJECTED_WSID") PROJECTED_PANES=$(lab pane list --workspace "$PROJECTED_WSID") [ "$(printf '%s' "$PROJECTED_TABS" | jq -r '.result.tabs | length')" = 1 ] \ @@ -573,14 +587,16 @@ lab tab focus "$SECOND_TWO_TAB" >/dev/null || fail "could not restore the captur assert_focus_is "$CAPTAIN_FOCUS" "active seeded-tab fixture restoration" rm -rf "$ACTIVE_SEEDED_CONTROL" ACTIVE_SEEDED_CLEANUP_FOCUS_START=$(focus_audit_line_count) +ACTIVE_SEEDED_LOCK=$(session_presentation_lock_path) \ + || fail "could not resolve the session presentation lock for active-seeded cleanup" PATH="$FAKEBIN:$PATH" FM_HOME="$HOME_DIR" bash -c ' . "$0/bin/fm-wake-lib.sh" . "$0/bin/backends/herdr.sh" - lock="$1/state/.herdr-presentation-order.lock" + lock=$1 fm_lock_acquire_wait "$lock" fm_backend_herdr_projection_cleanup_exact "$2" "$3" "$4" fm_lock_release "$lock" -' "$ROOT" "$HOME_DIR" "$HERDR_LAB_SESSION" "$ACTIVE_SEEDED_TASK_PANE" "$ACTIVE_SEEDED_PANE" +' "$ROOT" "$ACTIVE_SEEDED_LOCK" "$HERDR_LAB_SESSION" "$ACTIVE_SEEDED_TASK_PANE" "$ACTIVE_SEEDED_PANE" assert_focus_is "$CAPTAIN_FOCUS" "active seeded-tab fixture cleanup" assert_cleanup_focus_preserved "$ACTIVE_SEEDED_CLEANUP_FOCUS_START" "$ACTIVE_SEEDED_PANE" "$CAPTAIN_FOCUS" rm -f "$HOME_DIR/state/active-seeded.herdr-presentation" @@ -588,13 +604,15 @@ pass "real Herdr lab: active seeded-tab pruning refuses the exact pane and prese LOCK_CONTENTION_READY="$TMP_ROOT/lock-contention-ready" LOCK_CONTENTION_RELEASE="$TMP_ROOT/lock-contention-release" -ROOT="$ROOT" HOME_DIR="$HOME_DIR" READY="$LOCK_CONTENTION_READY" RELEASE="$LOCK_CONTENTION_RELEASE" bash -c ' +LOCK_CONTENTION_PATH=$(session_presentation_lock_path) \ + || fail "could not resolve the session presentation lock for contention" +ROOT="$ROOT" READY="$LOCK_CONTENTION_READY" RELEASE="$LOCK_CONTENTION_RELEASE" \ + LOCK="$LOCK_CONTENTION_PATH" bash -c ' . "$ROOT/bin/fm-wake-lib.sh" - lock="$HOME_DIR/state/.herdr-presentation-order.lock" - fm_lock_try_acquire "$lock" || exit 1 + fm_lock_try_acquire "$LOCK" || exit 1 : > "$READY" while [ ! -e "$RELEASE" ]; do sleep 0.05; done - fm_lock_release "$lock" + fm_lock_release "$LOCK" ' & LOCK_CONTENTION_OWNER_PID=$! while [ ! -e "$LOCK_CONTENTION_READY" ] && kill -0 "$LOCK_CONTENTION_OWNER_PID" 2>/dev/null; do sleep 0.01; done @@ -612,7 +630,7 @@ wait "$LOCK_CONTENTION_OWNER_PID" || fail "guarded lab presentation lock owner f LOCK_CONTENTION_OWNER_PID= [ "$LOCK_CONTENTION_STATUS" -eq 0 ] \ || fail "bounded presentation lock contention did not fall back to a successful flat spawn: $(cat "$TMP_ROOT/lock-contended.err")" -grep -F "presentation focus lock stayed busy; using the ordinary flat layout without projection" "$TMP_ROOT/lock-contended.err" >/dev/null 2>&1 \ +grep -F "presentation focus lock unavailable; using the ordinary flat layout without projection" "$TMP_ROOT/lock-contended.err" >/dev/null 2>&1 \ || fail "bounded presentation lock contention did not warn about flat fallback" LOCK_CONTENTION_META="$HOME_DIR/state/lock-contended.meta" remember_meta_worktree "$LOCK_CONTENTION_META" >/dev/null @@ -622,7 +640,9 @@ LOCK_CONTENTION_WSID=$(grep '^herdr_workspace_id=' "$LOCK_CONTENTION_META" | cut [ ! -e "$HOME_DIR/state/lock-contended.herdr-presentation" ] \ || fail "bounded lock contention published a projection journal" LOCK_CONTENTION_CALLS=$(sed -n "$((LOCK_CONTENTION_START + 1)),\$p" "$HERDR_CALL_LOG") -if printf '%s\n' "$LOCK_CONTENTION_CALLS" | grep -E $'^(workspace\tcreate|pane\tclose|api\tschema|session\tlist)' >/dev/null 2>&1; then +# session list is required to resolve the shared session lock path before the +# bounded acquire attempt; it must not unlock projection create or move. +if printf '%s\n' "$LOCK_CONTENTION_CALLS" | grep -E $'^(workspace\tcreate|pane\tclose|api\tschema)' >/dev/null 2>&1; then fail "bounded lock contention performed an unlocked projection mutation or ordering capability call" fi [ "$(wc -l < "$MOVE_CALL_LOG" | tr -d '[:space:]')" = "$LOCK_CONTENTION_MOVE_START" ] \ @@ -663,7 +683,11 @@ CREATED_LABELS=$(projection_labels_from_log "$PROJECTION_ORDER_START") EXPECTED_LABELS=$(printf 'firstmate\n%s\n%s\n2ndmate-alpha\n2ndmate-bravo' "$PROJECTED_LABEL" "$CREATED_LABELS") ACTUAL_LABELS=$(printf '%s' "$ORDER_LIST" | jq -r '.result.workspaces[].label') [ "$ACTUAL_LABELS" = "$EXPECTED_LABELS" ] || fail "workspace order was not firstmate, stable primary block, secondmates: $ACTUAL_LABELS" -PRIMARY_IDS=$(printf '%s' "$ORDER_LIST" | jq -r '.result.workspaces[] | select(.label | startswith("firstmate/")) | .workspace_id') +PRIMARY_IDS=$(printf '%s' "$ORDER_LIST" | jq -r ' + .result.workspaces[] + | select((.label | startswith("└ ")) or (.label | startswith("firstmate/"))) + | .workspace_id +') MOVE_TARGETS=$(cut -f2 "$MOVE_CALL_LOG") [ "$MOVE_TARGETS" = "$PRIMARY_IDS" ] \ || fail "workspace.move targeted something other than each exact current projected-create id" @@ -729,8 +753,8 @@ grep -F "did not yield an isolated worktree" "$TMP_ROOT/abort-b.err" >/dev/null ABORT_A_PANE=$(cat "$POST_CREATE_ABORT_CONTROL/abort-a/task-pane") ABORT_B_PANE=$(cat "$POST_CREATE_ABORT_CONTROL/abort-b/task-pane") ABORT_SEQUENCE=$(sed -n "$((ABORT_FOCUS_START + 1)),\$p" "$FOCUS_AUDIT_LOG" | awk -F '\t' -v a="$ABORT_A_PANE" -v b="$ABORT_B_PANE" ' - $1 == "workspace-create" && $4 ~ /^firstmate\/abort-a · p:/ { print "create-a" } - $1 == "workspace-create" && $4 ~ /^firstmate\/abort-b · p:/ { print "create-b" } + $1 == "workspace-create" && $4 ~ /^└ abort-a · p:/ { print "create-a" } + $1 == "workspace-create" && $4 ~ /^└ abort-b · p:/ { print "create-b" } $1 == "pane-close" && $4 == a { print "close-a" } $1 == "pane-close" && $4 == b { print "close-b" } ') @@ -805,7 +829,7 @@ for ROUND in 1 2 3; do assert_raw_presentation_mutations_preserved_since "$WAVE_FOCUS_START" "focus wave $ROUND concurrent spawns" WAVE_LABELS=$(projection_labels_from_log "$WAVE_LOG_START") WAVE_EXPECTED=$(printf 'firstmate\n%s\n2ndmate-alpha\n2ndmate-bravo' "$WAVE_LABELS") - WAVE_ACTUAL=$(lab workspace list | jq -r '.result.workspaces[].label') + WAVE_ACTUAL=$(lab workspace list | jq -r '.result.workspaces[] | select(.label == "firstmate" or (.label | startswith("└ ")) or (.label | startswith("2ndmate-"))) | .label') [ "$WAVE_ACTUAL" = "$WAVE_EXPECTED" ] \ || fail "focus wave $ROUND lost stable contiguous ordering: $WAVE_ACTUAL" WAVE_SECOND_ORDER=$(lab workspace list | jq -r '.result.workspaces[] | select(.label | startswith("2ndmate-")) | .workspace_id') @@ -825,6 +849,249 @@ for ROUND in 1 2 3; do done pass "real Herdr lab: three repeated concurrent create/order/cleanup waves have zero active workspace or tab drift" +# ------------------------------------------------------------------ +# Multi-home topology: real secondmate FM_HOME spawn paths, inheritance, +# concurrent cross-home waves, and session-scoped lock contention. +# ------------------------------------------------------------------ +SECOND_HOME_A="$TMP_ROOT/home-2ndmate-alpha" +SECOND_HOME_B="$TMP_ROOT/home-2ndmate-bravo" +mkdir -p "$SECOND_HOME_A/state" "$SECOND_HOME_A/config" "$SECOND_HOME_A/data" \ + "$SECOND_HOME_B/state" "$SECOND_HOME_B/config" "$SECOND_HOME_B/data" +printf 'alpha\n' > "$SECOND_HOME_A/.fm-secondmate-home" +printf 'bravo\n' > "$SECOND_HOME_B/.fm-secondmate-home" +touch "$SECOND_HOME_A/state/.last-watcher-beat" "$SECOND_HOME_B/state/.last-watcher-beat" +# Ensure the secondmate homes look like gitignored firstmate homes so inheritance +# may write config/herdr-presentation-spaces. +git -C "$SECOND_HOME_A" init -q +git -C "$SECOND_HOME_B" init -q +printf 'config/herdr-presentation-spaces\nconfig/crew-harness\nconfig/crew-dispatch.json\nconfig/backlog-backend\n' \ + > "$SECOND_HOME_A/.gitignore" +cp "$SECOND_HOME_A/.gitignore" "$SECOND_HOME_B/.gitignore" +git -C "$SECOND_HOME_A" add .gitignore +git -C "$SECOND_HOME_B" add .gitignore +git -C "$SECOND_HOME_A" -c user.name='Firstmate Tests' -c user.email='tests@example.invalid' commit -qm init +git -C "$SECOND_HOME_B" -c user.name='Firstmate Tests' -c user.email='tests@example.invalid' commit -qm init +mkdir -p "$SECOND_HOME_A/bin" +printf '# Firstmate secondmate fixture\n' > "$SECOND_HOME_A/AGENTS.md" +printf 'Secondmate alpha charter.\n' > "$SECOND_HOME_A/data/charter.md" + +# Primary flag only; real inheritance must push presence into both secondmate homes. +[ -f "$HOME_DIR/config/herdr-presentation-spaces" ] \ + || fail "primary presentation flag disappeared before multi-home inheritance" +[ ! -e "$SECOND_HOME_A/config/herdr-presentation-spaces" ] \ + || fail "secondmate A unexpectedly had the presentation flag before inheritance" +[ ! -e "$SECOND_HOME_B/config/herdr-presentation-spaces" ] \ + || fail "secondmate B unexpectedly had the presentation flag before inheritance" +SECOND_SPAWN_LOG_START=$(log_line_count) +spawn_secondmate_task alpha "$SECOND_HOME_A" > "$TMP_ROOT/alpha.out" 2> "$TMP_ROOT/alpha.err" \ + || fail "secondmate alpha spawn failed: $(cat "$TMP_ROOT/alpha.err")" +[ -f "$SECOND_HOME_A/config/herdr-presentation-spaces" ] \ + || fail "secondmate spawn did not inherit the presentation flag" +[ ! -e "$HOME_DIR/state/alpha.herdr-presentation" ] \ + || fail "secondmate spawn published a presentation journal" +SECOND_META="$HOME_DIR/state/alpha.meta" +[ "$(grep '^kind=' "$SECOND_META" | cut -d= -f2-)" = secondmate ] \ + || fail "secondmate spawn did not record kind=secondmate" +SECOND_WSID=$(grep '^herdr_workspace_id=' "$SECOND_META" | cut -d= -f2-) +SECOND_LABEL=$(lab workspace get "$SECOND_WSID" | jq -r '.result.workspace.label') +[ "$SECOND_LABEL" = 2ndmate-alpha ] \ + || fail "secondmate spawn did not use its flat parent workspace: $SECOND_LABEL" +[ -z "$(projection_labels_from_log "$SECOND_SPAWN_LOG_START")" ] \ + || fail "secondmate spawn created a corner projection workspace" +if sed -n "$((SECOND_SPAWN_LOG_START + 1)),\$p" "$HERDR_CALL_LOG" \ + | grep -E $'^(workspace\tmove|session\tlist)' >/dev/null 2>&1; then + fail "secondmate spawn attempted presentation ordering" +fi +# shellcheck source=bin/fm-config-inherit-lib.sh +. "$ROOT/bin/fm-config-inherit-lib.sh" +propagate_inheritable_config "$HOME_DIR/config" "$SECOND_HOME_A/config" \ + || fail "inheritance into secondmate A failed" +propagate_inheritable_config "$HOME_DIR/config" "$SECOND_HOME_B/config" \ + || fail "inheritance into secondmate B failed" +[ -f "$SECOND_HOME_A/config/herdr-presentation-spaces" ] \ + || fail "primary presentation flag did not reach secondmate A" +[ -f "$SECOND_HOME_B/config/herdr-presentation-spaces" ] \ + || fail "primary presentation flag did not reach secondmate B" +pass "real Herdr lab: primary presentation opt-in inherits into real secondmate homes" + +# Keep the pre-existing 2ndmate-alpha/bravo workspaces as owning parents and captain focus. +assert_focus_is "$CAPTAIN_FOCUS" "multi-home captain focus" + +mkdir -p "$SECOND_HOME_A/data/a1" "$SECOND_HOME_A/data/a2" \ + "$SECOND_HOME_B/data/b1" "$SECOND_HOME_B/data/b2" \ + "$HOME_DIR/data/p1" "$HOME_DIR/data/p2" +printf 'Primary multi-home fixture 1.\n' > "$HOME_DIR/data/p1/brief.md" +printf 'Primary multi-home fixture 2.\n' > "$HOME_DIR/data/p2/brief.md" +printf 'Secondmate A fixture 1.\n' > "$SECOND_HOME_A/data/a1/brief.md" +printf 'Secondmate A fixture 2.\n' > "$SECOND_HOME_A/data/a2/brief.md" +printf 'Secondmate B fixture 1.\n' > "$SECOND_HOME_B/data/b1/brief.md" +printf 'Secondmate B fixture 2.\n' > "$SECOND_HOME_B/data/b2/brief.md" + +MULTI_FOCUS_START=$(focus_audit_line_count) +spawn_task p1 "$HOME_DIR" "$PROJECT_DIR" > "$TMP_ROOT/p1.out" 2> "$TMP_ROOT/p1.err" \ + || fail "multi-home primary p1 failed: $(cat "$TMP_ROOT/p1.err")" +spawn_task p2 "$HOME_DIR" "$PROJECT_DIR" > "$TMP_ROOT/p2.out" 2> "$TMP_ROOT/p2.err" \ + || fail "multi-home primary p2 failed: $(cat "$TMP_ROOT/p2.err")" +spawn_task a1 "$SECOND_HOME_A" "$PROJECT_DIR" > "$TMP_ROOT/a1.out" 2> "$TMP_ROOT/a1.err" \ + || fail "multi-home secondmate A a1 failed: $(cat "$TMP_ROOT/a1.err")" +spawn_task a2 "$SECOND_HOME_A" "$PROJECT_DIR" > "$TMP_ROOT/a2.out" 2> "$TMP_ROOT/a2.err" \ + || fail "multi-home secondmate A a2 failed: $(cat "$TMP_ROOT/a2.err")" +spawn_task b1 "$SECOND_HOME_B" "$PROJECT_DIR" > "$TMP_ROOT/b1.out" 2> "$TMP_ROOT/b1.err" \ + || fail "multi-home secondmate B b1 failed: $(cat "$TMP_ROOT/b1.err")" +spawn_task b2 "$SECOND_HOME_B" "$PROJECT_DIR" > "$TMP_ROOT/b2.out" 2> "$TMP_ROOT/b2.err" \ + || fail "multi-home secondmate B b2 failed: $(cat "$TMP_ROOT/b2.err")" +for META_X in p1 p2 a1 a2 b1 b2; do + case "$META_X" in + p*) remember_meta_worktree "$HOME_DIR/state/$META_X.meta" >/dev/null ;; + a*) remember_meta_worktree "$SECOND_HOME_A/state/$META_X.meta" >/dev/null ;; + b*) remember_meta_worktree "$SECOND_HOME_B/state/$META_X.meta" >/dev/null ;; + esac +done +assert_focus_is "$CAPTAIN_FOCUS" "multi-home sequential spawns" +assert_raw_presentation_mutations_preserved_since "$MULTI_FOCUS_START" "multi-home sequential spawns" + +P1_LABEL=$(lab workspace get "$(grep '^herdr_workspace_id=' "$HOME_DIR/state/p1.meta" | cut -d= -f2-)" | jq -r '.result.workspace.label') +P2_LABEL=$(lab workspace get "$(grep '^herdr_workspace_id=' "$HOME_DIR/state/p2.meta" | cut -d= -f2-)" | jq -r '.result.workspace.label') +A1_LABEL=$(lab workspace get "$(grep '^herdr_workspace_id=' "$SECOND_HOME_A/state/a1.meta" | cut -d= -f2-)" | jq -r '.result.workspace.label') +A2_LABEL=$(lab workspace get "$(grep '^herdr_workspace_id=' "$SECOND_HOME_A/state/a2.meta" | cut -d= -f2-)" | jq -r '.result.workspace.label') +B1_LABEL=$(lab workspace get "$(grep '^herdr_workspace_id=' "$SECOND_HOME_B/state/b1.meta" | cut -d= -f2-)" | jq -r '.result.workspace.label') +B2_LABEL=$(lab workspace get "$(grep '^herdr_workspace_id=' "$SECOND_HOME_B/state/b2.meta" | cut -d= -f2-)" | jq -r '.result.workspace.label') +case "$P1_LABEL" in $'└ p1 · p:'*) ;; *) fail "primary p1 label wrong: $P1_LABEL" ;; esac +case "$P2_LABEL" in $'└ p2 · p:'*) ;; *) fail "primary p2 label wrong: $P2_LABEL" ;; esac +case "$A1_LABEL" in $'└ a1 · p:'*) ;; *) fail "secondmate A a1 label wrong: $A1_LABEL" ;; esac +case "$A2_LABEL" in $'└ a2 · p:'*) ;; *) fail "secondmate A a2 label wrong: $A2_LABEL" ;; esac +case "$B1_LABEL" in $'└ b1 · p:'*) ;; *) fail "secondmate B b1 label wrong: $B1_LABEL" ;; esac +case "$B2_LABEL" in $'└ b2 · p:'*) ;; *) fail "secondmate B b2 label wrong: $B2_LABEL" ;; esac + +MULTI_LIST=$(lab workspace list) || fail "could not list multi-home topology" +MULTI_LABELS=$(printf '%s' "$MULTI_LIST" | jq -r ' + .result.workspaces[] + | select( + .label == "firstmate" + or .label == "2ndmate-alpha" + or .label == "2ndmate-bravo" + or (.label | startswith("└ ")) + ) + | .label +') +MULTI_EXPECTED=$(printf '%s\n' \ + firstmate "$P1_LABEL" "$P2_LABEL" \ + 2ndmate-alpha "$A1_LABEL" "$A2_LABEL" \ + 2ndmate-bravo "$B1_LABEL" "$B2_LABEL") +[ "$MULTI_LABELS" = "$MULTI_EXPECTED" ] \ + || fail "multi-home topology was not owning-parent grouped: $MULTI_LABELS" +pass "real Herdr lab: primary and two secondmate homes each own a top-level contiguous child block" + +# Concurrent cross-home wave under the one session lock. +mkdir -p "$HOME_DIR/data/pcw" "$SECOND_HOME_A/data/acw" "$SECOND_HOME_B/data/bcw" +printf 'Cross-home concurrent primary.\n' > "$HOME_DIR/data/pcw/brief.md" +printf 'Cross-home concurrent A.\n' > "$SECOND_HOME_A/data/acw/brief.md" +printf 'Cross-home concurrent B.\n' > "$SECOND_HOME_B/data/bcw/brief.md" +WAVE_CROSS_FOCUS=$(focus_audit_line_count) +spawn_task pcw "$HOME_DIR" "$PROJECT_DIR" > "$TMP_ROOT/pcw.out" 2> "$TMP_ROOT/pcw.err" & +PCW_PID=$! +spawn_task acw "$SECOND_HOME_A" "$PROJECT_DIR" > "$TMP_ROOT/acw.out" 2> "$TMP_ROOT/acw.err" & +ACW_PID=$! +spawn_task bcw "$SECOND_HOME_B" "$PROJECT_DIR" > "$TMP_ROOT/bcw.out" 2> "$TMP_ROOT/bcw.err" & +BCW_PID=$! +wait "$PCW_PID" || fail "cross-home concurrent primary failed: $(cat "$TMP_ROOT/pcw.err")" +wait "$ACW_PID" || fail "cross-home concurrent A failed: $(cat "$TMP_ROOT/acw.err")" +wait "$BCW_PID" || fail "cross-home concurrent B failed: $(cat "$TMP_ROOT/bcw.err")" +remember_meta_worktree "$HOME_DIR/state/pcw.meta" >/dev/null +remember_meta_worktree "$SECOND_HOME_A/state/acw.meta" >/dev/null +remember_meta_worktree "$SECOND_HOME_B/state/bcw.meta" >/dev/null +assert_focus_is "$CAPTAIN_FOCUS" "cross-home concurrent wave" +assert_raw_presentation_mutations_preserved_since "$WAVE_CROSS_FOCUS" "cross-home concurrent wave" +CROSS_LIST=$(lab workspace list) +printf '%s' "$CROSS_LIST" | jq -e ' + ([.result.workspaces[].label] | index("firstmate")) as $fm + | ([.result.workspaces[].label] | index("2ndmate-alpha")) as $a + | ([.result.workspaces[].label] | index("2ndmate-bravo")) as $b + | $fm != null and $a != null and $b != null + and $fm < $a and $a < $b +' >/dev/null 2>&1 || fail "cross-home concurrent wave reordered parents" +PCW_LABEL=$(lab workspace get "$(grep '^herdr_workspace_id=' "$HOME_DIR/state/pcw.meta" | cut -d= -f2-)" | jq -r '.result.workspace.label') +ACW_LABEL=$(lab workspace get "$(grep '^herdr_workspace_id=' "$SECOND_HOME_A/state/acw.meta" | cut -d= -f2-)" | jq -r '.result.workspace.label') +BCW_LABEL=$(lab workspace get "$(grep '^herdr_workspace_id=' "$SECOND_HOME_B/state/bcw.meta" | cut -d= -f2-)" | jq -r '.result.workspace.label') +case "$PCW_LABEL" in $'└ pcw · p:'*|firstmate) ;; *) fail "cross-home primary label wrong: $PCW_LABEL" ;; esac +case "$ACW_LABEL" in $'└ acw · p:'*|2ndmate-alpha) ;; *) fail "cross-home A label wrong: $ACW_LABEL" ;; esac +case "$BCW_LABEL" in $'└ bcw · p:'*|2ndmate-bravo) ;; *) fail "cross-home B label wrong: $BCW_LABEL" ;; esac +pass "real Herdr lab: concurrent primary/A/B spawns preserve parent order and exact focus" + +# Hold the shared session lock from a different home and force flat fallback. +CROSS_LOCK_READY="$TMP_ROOT/cross-lock-ready" +CROSS_LOCK_RELEASE="$TMP_ROOT/cross-lock-release" +CROSS_LOCK_PATH=$(session_presentation_lock_path) \ + || fail "could not resolve session lock for cross-home contention" +ROOT="$ROOT" READY="$CROSS_LOCK_READY" RELEASE="$CROSS_LOCK_RELEASE" LOCK="$CROSS_LOCK_PATH" bash -c ' + . "$ROOT/bin/fm-wake-lib.sh" + fm_lock_try_acquire "$LOCK" || exit 1 + : > "$READY" + while [ ! -e "$RELEASE" ]; do sleep 0.05; done + fm_lock_release "$LOCK" +' & +CROSS_LOCK_PID=$! +while [ ! -e "$CROSS_LOCK_READY" ] && kill -0 "$CROSS_LOCK_PID" 2>/dev/null; do sleep 0.01; done +[ -e "$CROSS_LOCK_READY" ] || fail "could not hold the cross-home session presentation lock" +mkdir -p "$SECOND_HOME_A/data/aflat" +printf 'Flat fallback under session lock contention.\n' > "$SECOND_HOME_A/data/aflat/brief.md" +if spawn_task aflat "$SECOND_HOME_A" "$PROJECT_DIR" > "$TMP_ROOT/aflat.out" 2> "$TMP_ROOT/aflat.err"; then + AFLAT_STATUS=0 +else + AFLAT_STATUS=$? +fi +: > "$CROSS_LOCK_RELEASE" +wait "$CROSS_LOCK_PID" || fail "cross-home session lock owner failed" +[ "$AFLAT_STATUS" -eq 0 ] \ + || fail "cross-home lock contention did not fall back flat: $(cat "$TMP_ROOT/aflat.err")" +grep -F "presentation focus lock unavailable; using the ordinary flat layout without projection" "$TMP_ROOT/aflat.err" >/dev/null 2>&1 \ + || fail "cross-home lock contention did not warn about flat fallback" +remember_meta_worktree "$SECOND_HOME_A/state/aflat.meta" >/dev/null +AFLAT_WSID=$(grep '^herdr_workspace_id=' "$SECOND_HOME_A/state/aflat.meta" | cut -d= -f2-) +AFLAT_LABEL=$(lab workspace get "$AFLAT_WSID" | jq -r '.result.workspace.label') +[ "$AFLAT_LABEL" = 2ndmate-alpha ] \ + || fail "cross-home lock contention did not use the ordinary secondmate home workspace: $AFLAT_LABEL" +[ ! -e "$SECOND_HOME_A/state/aflat.herdr-presentation" ] \ + || fail "cross-home lock contention published a projection journal" +assert_focus_is "$CAPTAIN_FOCUS" "cross-home lock contention flat fallback" +teardown_task aflat "$SECOND_HOME_A" > "$TMP_ROOT/aflat-teardown.out" 2> "$TMP_ROOT/aflat-teardown.err" \ + || fail "flat cross-home contention fixture teardown failed" +pass "real Herdr lab: session lock contention from a secondmate home falls back flat with no journal" + +# Seed a legacy old-format primary projection and a flat secondmate tab; correction must not migrate them. +LEGACY_OUT=$(lab workspace create --cwd "$PROJECT_DIR" --label "firstmate/legacy-seed · p:AbCdEfGhIjKlMnOpQrStUv" --no-focus) \ + || fail "could not seed a legacy old-format presentation space" +LEGACY_WSID=$(printf '%s' "$LEGACY_OUT" | jq -r '.result.workspace.workspace_id // empty') +[ -n "$LEGACY_WSID" ] || fail "legacy seed returned no workspace id" +FLAT_TAB_OUT=$(lab tab create --workspace "$(lab workspace list | jq -r '.result.workspaces[] | select(.label == "2ndmate-alpha") | .workspace_id' | head -1)" --cwd "$PROJECT_DIR" --label fm-flat-legacy-tab --no-focus) \ + || fail "could not seed a flat secondmate child tab" +FLAT_TAB_ID=$(printf '%s' "$FLAT_TAB_OUT" | jq -r '.result.tab.tab_id // empty') +mkdir -p "$HOME_DIR/data/post-legacy" +printf 'Post-legacy primary child.\n' > "$HOME_DIR/data/post-legacy/brief.md" +spawn_task post-legacy "$HOME_DIR" "$PROJECT_DIR" > "$TMP_ROOT/post-legacy.out" 2> "$TMP_ROOT/post-legacy.err" \ + || fail "post-legacy projected spawn failed: $(cat "$TMP_ROOT/post-legacy.err")" +remember_meta_worktree "$HOME_DIR/state/post-legacy.meta" >/dev/null +[ "$(lab workspace get "$LEGACY_WSID" | jq -r '.result.workspace.label')" = "firstmate/legacy-seed · p:AbCdEfGhIjKlMnOpQrStUv" ] \ + || fail "correction renamed or moved the seeded legacy projection" +lab tab get "$FLAT_TAB_ID" >/dev/null 2>&1 \ + || fail "correction removed the seeded flat secondmate child tab" +pass "real Herdr lab: legacy projection labels and flat secondmate tabs are left unmigrated" + +# Teardown multi-home projected tasks by exact pane only. +for META_HOME_PAIR in \ + "p1:$HOME_DIR" "p2:$HOME_DIR" "pcw:$HOME_DIR" "post-legacy:$HOME_DIR" \ + "a1:$SECOND_HOME_A" "a2:$SECOND_HOME_A" "acw:$SECOND_HOME_A" \ + "alpha:$HOME_DIR" \ + "b1:$SECOND_HOME_B" "b2:$SECOND_HOME_B" "bcw:$SECOND_HOME_B" +do + TASK_ID=${META_HOME_PAIR%%:*} + TASK_HOME=${META_HOME_PAIR#*:} + teardown_task "$TASK_ID" "$TASK_HOME" > "$TMP_ROOT/td-$TASK_ID.out" 2> "$TMP_ROOT/td-$TASK_ID.err" \ + || fail "multi-home teardown of $TASK_ID failed: $(cat "$TMP_ROOT/td-$TASK_ID.err")" +done +assert_focus_is "$CAPTAIN_FOCUS" "multi-home teardown" +pass "real Herdr lab: multi-home exact-pane teardowns restore captain focus without workspace close authority" + # A restart preserves the label and structural pane but removes the registered # agent. # The next spawn must leave that old projection untouched and use the flat diff --git a/tests/fm-backend-herdr.test.sh b/tests/fm-backend-herdr.test.sh index a0b68a3257d..c68f9622991 100755 --- a/tests/fm-backend-herdr.test.sh +++ b/tests/fm-backend-herdr.test.sh @@ -672,8 +672,8 @@ test_projection_create_uses_exact_response_ids_and_leaves_one_task_pane() { journal="$state/task-p2.herdr-presentation" token=$(bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_projection_journal_token "$1" task-p2' "$ROOT" "$journal") \ || fail "projection journal was not readable" - assert_contains "$(cat "$log")" $'workspace\x1fcreate\x1f--cwd\x1f/tmp/proj\x1f--label\x1ffirstmate/task-p2 · p:'"$token"$'\x1f--no-focus' \ - "projection workspace create did not use the visible full token and --no-focus" + assert_contains "$(cat "$log")" $'workspace\x1fcreate\x1f--cwd\x1f/tmp/proj\x1f--label\x1f└ task-p2 · p:'"$token"$'\x1f--no-focus' \ + "projection workspace create did not use the corner label, full token, and --no-focus" assert_contains "$(cat "$log")" $'tab\x1fcreate\x1f--workspace\x1fw9\x1f--cwd\x1f/tmp/proj\x1f--label\x1ffm-task-p2\x1f--no-focus' \ "projection task tab did not target the exact new workspace" assert_contains "$(cat "$log")" $'pane\x1fclose\x1fw9:p1' \ @@ -791,12 +791,35 @@ test_projection_seeded_prune_refuses_active_tab() { pass "herdr presentation focus: projected seeded pruning refuses the active tab" } +test_projection_label_builder_uses_corner_and_strips_owner_prefixes() { + local primary secondmate token + token='AbCdEfGhIjKlMnOpQrStUv' + [ "${#token}" -eq 22 ] || fail "fixture token must be 22 characters" + primary=$(bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_projection_workspace_label task-p2 '"$token" "$ROOT") + [ "$primary" = "└ task-p2 · p:$token" ] \ + || fail "primary child label was wrong: $primary" + secondmate=$(bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_projection_workspace_label secondmate-child-demo '"$token" "$ROOT") + [ "$secondmate" = "└ secondmate-child-demo · p:$token" ] \ + || fail "secondmate child label was wrong: $secondmate" + primary=$(bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_projection_workspace_label firstmate/task-p2 '"$token" "$ROOT") + [ "$primary" = "└ task-p2 · p:$token" ] \ + || fail "firstmate/ owner prefix was not stripped: $primary" + secondmate=$(bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_projection_workspace_label 2ndmate-fmdev-f2/child '"$token" "$ROOT") + [ "$secondmate" = "└ child · p:$token" ] \ + || fail "2ndmate owner prefix was not stripped: $secondmate" + primary=$(bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_projection_workspace_label fm-task-p2 '"$token" "$ROOT") + [ "$primary" = "└ task-p2 · p:$token" ] \ + || fail "presentation fm- owner prefix was not stripped: $primary" + case "$primary" in $'└ '*) ;; *) fail "label must start with U+2514 and one space" ;; esac + pass "herdr presentation labels: └ concise-task · p: for primary and secondmate children" +} + test_projection_order_moves_only_exact_new_workspace_and_preserves_relative_order() { local dir log resp fb mover mover_log out status dir="$TMP_ROOT/projection-order"; mkdir -p "$dir/responses" log="$dir/log"; resp="$dir/responses"; mover="$dir/mover"; mover_log="$dir/mover.log" : > "$log"; : > "$mover_log" - printf '%s\n' '{"result":{"workspaces":[{"workspace_id":"w1","label":"firstmate","focused":false},{"workspace_id":"w2","label":"firstmate/old · p:AbCdEfGhIjKlMnOpQrStUv","focused":false},{"workspace_id":"w3","label":"2ndmate-alpha","focused":false},{"workspace_id":"w4","label":"2ndmate-bravo","focused":true},{"workspace_id":"w5","label":"firstmate/new · p:ZyXwVuTsRqPoNmLkJiHgFe","focused":false}]}}' > "$resp/1.out" + printf '%s\n' '{"result":{"workspaces":[{"workspace_id":"w1","label":"firstmate","focused":false},{"workspace_id":"w2","label":"firstmate/old · p:AbCdEfGhIjKlMnOpQrStUv","focused":false},{"workspace_id":"w3","label":"2ndmate-alpha","focused":false},{"workspace_id":"w4","label":"2ndmate-bravo","focused":true},{"workspace_id":"w5","label":"└ new · p:ZyXwVuTsRqPoNmLkJiHgFe","focused":false}]}}' > "$resp/1.out" printf '%s\n' '{"client":{"version":"0.7.4","protocol":16},"server":{"running":true}}' > "$resp/2.out" # shellcheck disable=SC2016 # $defs is a literal JSON Schema key. printf '%s\n' '{"schemas":{"request":{"oneOf":[{"properties":{"method":{"const":"workspace.move"}}}],"$defs":{"WorkspaceMoveParams":{"required":["workspace_id","insert_index"],"properties":{"insert_index":{"type":"integer"}}}}}}}' > "$resp/3.out" @@ -804,29 +827,139 @@ test_projection_order_moves_only_exact_new_workspace_and_preserves_relative_orde cat > "$mover" <<'SH' #!/usr/bin/env bash printf '%s\t%s\t%s\n' "$1" "$2" "$3" >> "$FM_FAKE_MOVER_LOG" -printf '%s\n' '{"id":"fm-workspace-move","result":{"type":"workspace_list","workspaces":[{"workspace_id":"w1","label":"firstmate","focused":false},{"workspace_id":"w2","label":"firstmate/old · p:AbCdEfGhIjKlMnOpQrStUv","focused":false},{"workspace_id":"w5","label":"firstmate/new · p:ZyXwVuTsRqPoNmLkJiHgFe","focused":false},{"workspace_id":"w3","label":"2ndmate-alpha","focused":false},{"workspace_id":"w4","label":"2ndmate-bravo","focused":true}]}}' +printf '%s\n' '{"id":"fm-workspace-move","result":{"type":"workspace_list","workspaces":[{"workspace_id":"w1","label":"firstmate","focused":false},{"workspace_id":"w2","label":"firstmate/old · p:AbCdEfGhIjKlMnOpQrStUv","focused":false},{"workspace_id":"w5","label":"└ new · p:ZyXwVuTsRqPoNmLkJiHgFe","focused":false},{"workspace_id":"w3","label":"2ndmate-alpha","focused":false},{"workspace_id":"w4","label":"2ndmate-bravo","focused":true}]}}' SH chmod +x "$mover" fb=$(make_herdr_fakebin "$dir") out=$(PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" FM_HERDR_SCRIPT_STATUS=1 \ FM_BACKEND_HERDR_WORKSPACE_MOVER="$mover" FM_FAKE_MOVER_LOG="$mover_log" \ - bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_projection_focus_snapshot() { printf "w4\tw4:t2"; }; fm_backend_herdr_projection_focus_restore() { return 0; }; fm_backend_herdr_projection_order_best_effort fmtest w5' "$ROOT" 2>&1) + bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_projection_focus_snapshot() { printf "w4\tw4:t2"; }; fm_backend_herdr_projection_focus_restore() { return 0; }; fm_backend_herdr_projection_order_best_effort fmtest w5 firstmate' "$ROOT" 2>&1) status=$? [ "$status" -eq 0 ] || fail "best-effort projection ordering must not fail the spawn" [ -z "$out" ] || fail "successful projection ordering emitted a warning: $out" - [ "$(cat "$mover_log")" = $'/tmp/fmtest.sock\tw5\t2' ] \ - || fail "projection ordering did not move only the exact new response id to the stable append index" + [ "$(cat "$mover_log")" = "$(cd /tmp && pwd -P)/fmtest.sock"$'\t'"w5"$'\t'"2" ] \ + || fail "projection ordering did not move only the exact new response id to the owning-parent append index" assert_not_contains "$(cat "$log")" $'workspace\x1fclose' "projection ordering called workspace close" assert_not_contains "$(cat "$log")" $'session\x1fdelete' "projection ordering called session delete" assert_not_contains "$(cat "$log")" $'workspace\x1frename' "projection ordering called a label-based workspace mutation" pass "herdr presentation ordering: exact new workspace appends to the primary block while focus and relative orders stay stable" } +test_projection_order_secondmate_parent_block() { + local dir log resp fb mover mover_log out status + dir="$TMP_ROOT/projection-order-secondmate"; mkdir -p "$dir/responses" + log="$dir/log"; resp="$dir/responses"; mover="$dir/mover"; mover_log="$dir/mover.log" + : > "$log"; : > "$mover_log" + # firstmate, primary child, 2ndmate-A, A-child legacy, 2ndmate-B, human, NEW for A + printf '%s\n' '{"result":{"workspaces":[{"workspace_id":"w1","label":"firstmate"},{"workspace_id":"w2","label":"└ primary · p:AbCdEfGhIjKlMnOpQrStUv"},{"workspace_id":"w3","label":"2ndmate-alpha"},{"workspace_id":"w4","label":"2ndmate-alpha/old · p:AbCdEfGhIjKlMnOpQrStU1"},{"workspace_id":"w5","label":"2ndmate-bravo"},{"workspace_id":"wH","label":"human-notes"},{"workspace_id":"w6","label":"└ new-a · p:ZyXwVuTsRqPoNmLkJiHgFe"}]}}' > "$resp/1.out" + printf '%s\n' '{"client":{"version":"0.7.4","protocol":16},"server":{"running":true}}' > "$resp/2.out" + # shellcheck disable=SC2016 + printf '%s\n' '{"schemas":{"request":{"oneOf":[{"properties":{"method":{"const":"workspace.move"}}}],"$defs":{"WorkspaceMoveParams":{"required":["workspace_id","insert_index"],"properties":{"insert_index":{"type":"integer"}}}}}}}' > "$resp/3.out" + printf '%s\n' '{"sessions":[{"name":"fmtest","running":true,"socket_path":"/tmp/fmtest.sock"}]}' > "$resp/4.out" + cat > "$mover" <<'SH' +#!/usr/bin/env bash +printf '%s\t%s\t%s\n' "$1" "$2" "$3" >> "$FM_FAKE_MOVER_LOG" +printf '%s\n' '{"id":"fm-workspace-move","result":{"type":"workspace_list","workspaces":[{"workspace_id":"w1","label":"firstmate"},{"workspace_id":"w2","label":"└ primary · p:AbCdEfGhIjKlMnOpQrStUv"},{"workspace_id":"w3","label":"2ndmate-alpha"},{"workspace_id":"w4","label":"2ndmate-alpha/old · p:AbCdEfGhIjKlMnOpQrStU1"},{"workspace_id":"w6","label":"└ new-a · p:ZyXwVuTsRqPoNmLkJiHgFe"},{"workspace_id":"w5","label":"2ndmate-bravo"},{"workspace_id":"wH","label":"human-notes"}]}}' +SH + chmod +x "$mover" + fb=$(make_herdr_fakebin "$dir") + out=$(PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" FM_HERDR_SCRIPT_STATUS=1 \ + FM_BACKEND_HERDR_WORKSPACE_MOVER="$mover" FM_FAKE_MOVER_LOG="$mover_log" \ + bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_projection_focus_snapshot() { printf "w5\tw5:t1"; }; fm_backend_herdr_projection_focus_restore() { return 0; }; fm_backend_herdr_projection_order_best_effort fmtest w6 2ndmate-alpha' "$ROOT" 2>&1) + status=$? + [ "$status" -eq 0 ] || fail "secondmate parent ordering must not fail the spawn: $out" + [ -z "$out" ] || fail "successful secondmate ordering emitted a warning: $out" + [ "$(cat "$mover_log")" = "$(cd /tmp && pwd -P)/fmtest.sock"$'\t'"w6"$'\t'"4" ] \ + || fail "secondmate child was not inserted after its parent block: $(cat "$mover_log")" + assert_not_contains "$(cat "$log")" $'workspace\x1frename' "secondmate ordering renamed a legacy child" + pass "herdr presentation ordering: secondmate children append under their owning parent block" +} + +test_projection_order_foreign_legacy_child_is_read_only() { + local dir log resp fb mover out status + dir="$TMP_ROOT/projection-order-foreign-legacy"; mkdir -p "$dir/responses" + log="$dir/log"; resp="$dir/responses"; mover="$dir/mover"; : > "$log" + printf '%s\n' '{"result":{"workspaces":[{"workspace_id":"w1","label":"firstmate"},{"workspace_id":"w2","label":"2ndmate-alpha"},{"workspace_id":"w3","label":"2ndmate-bravo/foreign · p:AbCdEfGhIjKlMnOpQrStUv"},{"workspace_id":"w4","label":"└ new-alpha · p:ZyXwVuTsRqPoNmLkJiHgFe"}]}}' > "$resp/1.out" + cat > "$mover" <<'SH' +#!/usr/bin/env bash +echo called > "$FM_FAKE_MOVER_CALLED" +exit 0 +SH + chmod +x "$mover" + fb=$(make_herdr_fakebin "$dir") + out=$(PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" \ + FM_BACKEND_HERDR_WORKSPACE_MOVER="$mover" FM_FAKE_MOVER_CALLED="$dir/called" \ + bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_projection_order_best_effort fmtest w4 2ndmate-alpha' "$ROOT" 2>&1) + status=$? + [ "$status" -eq 0 ] || fail "foreign legacy ordering must not fail the spawn" + assert_contains "$out" "ambiguous workspace layout" "foreign legacy child did not warn" + [ ! -e "$dir/called" ] || fail "foreign legacy child attempted workspace.move" + assert_not_contains "$(cat "$log")" $'workspace\x1fclose' "foreign legacy layout triggered workspace cleanup" + assert_not_contains "$(cat "$log")" $'session\x1fdelete' "foreign legacy layout triggered session cleanup" + assert_not_contains "$(cat "$log")" $'workspace\x1frename' "foreign legacy layout triggered workspace rename" + pass "herdr presentation ordering: a foreign legacy child is warning-only and read-only" +} + +test_projection_order_allows_intervening_parent_child_block() { + local dir log resp fb mover mover_log out status + dir="$TMP_ROOT/projection-order-intervening-parent"; mkdir -p "$dir/responses" + log="$dir/log"; resp="$dir/responses"; mover="$dir/mover"; mover_log="$dir/mover.log" + : > "$log"; : > "$mover_log" + printf '%s\n' '{"result":{"workspaces":[{"workspace_id":"w1","label":"firstmate"},{"workspace_id":"w2","label":"firstmate/old · p:AbCdEfGhIjKlMnOpQrStUv"},{"workspace_id":"w3","label":"2ndmate-alpha"},{"workspace_id":"w4","label":"2ndmate-bravo"},{"workspace_id":"w5","label":"└ bravo-child · p:QqWwEeRrTtYyUuIiOoPpAa"},{"workspace_id":"w6","label":"└ new-first · p:ZyXwVuTsRqPoNmLkJiHgFe"}]}}' > "$resp/1.out" + printf '%s\n' '{"client":{"version":"0.7.4","protocol":16},"server":{"running":true}}' > "$resp/2.out" + # shellcheck disable=SC2016 + printf '%s\n' '{"schemas":{"request":{"oneOf":[{"properties":{"method":{"const":"workspace.move"}}}],"$defs":{"WorkspaceMoveParams":{"required":["workspace_id","insert_index"],"properties":{"insert_index":{"type":"integer"}}}}}}}' > "$resp/3.out" + printf '%s\n' '{"sessions":[{"name":"fmtest","running":true,"socket_path":"/tmp/fmtest.sock"}]}' > "$resp/4.out" + cat > "$mover" <<'SH' +#!/usr/bin/env bash +printf '%s\t%s\t%s\n' "$1" "$2" "$3" >> "$FM_FAKE_MOVER_LOG" +printf '%s\n' '{"id":"fm-workspace-move","result":{"type":"workspace_list","workspaces":[{"workspace_id":"w1","label":"firstmate"},{"workspace_id":"w2","label":"firstmate/old · p:AbCdEfGhIjKlMnOpQrStUv"},{"workspace_id":"w6","label":"└ new-first · p:ZyXwVuTsRqPoNmLkJiHgFe"},{"workspace_id":"w3","label":"2ndmate-alpha"},{"workspace_id":"w4","label":"2ndmate-bravo"},{"workspace_id":"w5","label":"└ bravo-child · p:QqWwEeRrTtYyUuIiOoPpAa"}]}}' +SH + chmod +x "$mover" + fb=$(make_herdr_fakebin "$dir") + out=$(PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" FM_HERDR_SCRIPT_STATUS=1 \ + FM_BACKEND_HERDR_WORKSPACE_MOVER="$mover" FM_FAKE_MOVER_LOG="$mover_log" \ + bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_projection_focus_snapshot() { printf "w4\tw4:t1"; }; fm_backend_herdr_projection_focus_restore() { return 0; }; fm_backend_herdr_projection_order_best_effort fmtest w6 firstmate' "$ROOT" 2>&1) + status=$? + [ "$status" -eq 0 ] || fail "intervening parent ordering must not fail the spawn: $out" + [ -z "$out" ] || fail "legitimate intervening parent ordering emitted a warning: $out" + [ "$(cat "$mover_log")" = "$(cd /tmp && pwd -P)/fmtest.sock"$'\t'"w6"$'\t'"2" ] \ + || fail "intervening parent block prevented the owning-parent insertion: $(cat "$mover_log")" + pass "herdr presentation ordering: intervening parent child blocks remain traversable" +} + +test_projection_order_human_spaces_never_move_targets() { + local dir log resp fb mover mover_log out status + dir="$TMP_ROOT/projection-order-human"; mkdir -p "$dir/responses" + log="$dir/log"; resp="$dir/responses"; mover="$dir/mover"; mover_log="$dir/mover.log" + : > "$log"; : > "$mover_log" + printf '%s\n' '{"result":{"workspaces":[{"workspace_id":"wH1","label":"notes"},{"workspace_id":"w1","label":"firstmate"},{"workspace_id":"wH2","label":"scratch"},{"workspace_id":"w2","label":"2ndmate-alpha"},{"workspace_id":"w3","label":"└ new · p:ZyXwVuTsRqPoNmLkJiHgFe"}]}}' > "$resp/1.out" + printf '%s\n' '{"client":{"version":"0.7.4","protocol":16},"server":{"running":true}}' > "$resp/2.out" + # shellcheck disable=SC2016 + printf '%s\n' '{"schemas":{"request":{"oneOf":[{"properties":{"method":{"const":"workspace.move"}}}],"$defs":{"WorkspaceMoveParams":{"required":["workspace_id","insert_index"],"properties":{"insert_index":{"type":"integer"}}}}}}}' > "$resp/3.out" + printf '%s\n' '{"sessions":[{"name":"fmtest","running":true,"socket_path":"/tmp/fmtest.sock"}]}' > "$resp/4.out" + cat > "$mover" <<'SH' +#!/usr/bin/env bash +printf '%s\t%s\t%s\n' "$1" "$2" "$3" >> "$FM_FAKE_MOVER_LOG" +printf '%s\n' '{"id":"fm-workspace-move","result":{"type":"workspace_list","workspaces":[{"workspace_id":"wH1","label":"notes"},{"workspace_id":"w1","label":"firstmate"},{"workspace_id":"w3","label":"└ new · p:ZyXwVuTsRqPoNmLkJiHgFe"},{"workspace_id":"wH2","label":"scratch"},{"workspace_id":"w2","label":"2ndmate-alpha"}]}}' +SH + chmod +x "$mover" + fb=$(make_herdr_fakebin "$dir") + out=$(PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" FM_HERDR_SCRIPT_STATUS=1 \ + FM_BACKEND_HERDR_WORKSPACE_MOVER="$mover" FM_FAKE_MOVER_LOG="$mover_log" \ + bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_projection_focus_snapshot() { printf "w2\tw2:t1"; }; fm_backend_herdr_projection_focus_restore() { return 0; }; fm_backend_herdr_projection_order_best_effort fmtest w3 firstmate' "$ROOT" 2>&1) + status=$? + [ "$status" -eq 0 ] || fail "human-interleaved ordering must not fail: $out" + [ "$(cat "$mover_log")" = "$(cd /tmp && pwd -P)/fmtest.sock"$'\t'"w3"$'\t'"2" ] \ + || fail "human spaces changed the move target or insert index: $(cat "$mover_log")" + pass "herdr presentation ordering: only the exact new id moves; human spaces keep relative order" +} + test_projection_order_failure_warns_without_cleanup_or_spawn_failure() { local dir log resp fb mover out status dir="$TMP_ROOT/projection-order-failure"; mkdir -p "$dir/responses" log="$dir/log"; resp="$dir/responses"; mover="$dir/mover"; : > "$log" - printf '%s\n' '{"result":{"workspaces":[{"workspace_id":"w1","label":"firstmate","focused":true},{"workspace_id":"w2","label":"2ndmate-alpha","focused":false},{"workspace_id":"w3","label":"firstmate/new · p:ZyXwVuTsRqPoNmLkJiHgFe","focused":false}]}}' > "$resp/1.out" + printf '%s\n' '{"result":{"workspaces":[{"workspace_id":"w1","label":"firstmate","focused":true},{"workspace_id":"w2","label":"2ndmate-alpha","focused":false},{"workspace_id":"w3","label":"└ new · p:ZyXwVuTsRqPoNmLkJiHgFe","focused":false}]}}' > "$resp/1.out" printf '%s\n' '{"client":{"version":"0.7.4","protocol":16},"server":{"running":true}}' > "$resp/2.out" # shellcheck disable=SC2016 # $defs is a literal JSON Schema key. printf '%s\n' '{"schemas":{"request":{"oneOf":[{"properties":{"method":{"const":"workspace.move"}}}],"$defs":{"WorkspaceMoveParams":{"required":["workspace_id","insert_index"],"properties":{"insert_index":{"type":"integer"}}}}}}}' > "$resp/3.out" @@ -839,7 +972,7 @@ SH fb=$(make_herdr_fakebin "$dir") out=$(PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" FM_HERDR_SCRIPT_STATUS=1 \ FM_BACKEND_HERDR_WORKSPACE_MOVER="$mover" \ - bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_projection_focus_snapshot() { printf "w1\tw1:t1"; }; fm_backend_herdr_projection_focus_restore() { return 0; }; fm_backend_herdr_projection_order_best_effort fmtest w3' "$ROOT" 2>&1) + bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_projection_focus_snapshot() { printf "w1\tw1:t1"; }; fm_backend_herdr_projection_focus_restore() { return 0; }; fm_backend_herdr_projection_order_best_effort fmtest w3 firstmate' "$ROOT" 2>&1) status=$? [ "$status" -eq 0 ] || fail "a workspace.move failure must not fail the projected spawn" assert_contains "$out" "workspace move failed or had an ambiguous response" \ @@ -854,7 +987,8 @@ test_projection_order_ambiguous_existing_block_is_read_only() { local dir log resp fb mover out status dir="$TMP_ROOT/projection-order-ambiguous"; mkdir -p "$dir/responses" log="$dir/log"; resp="$dir/responses"; mover="$dir/mover"; : > "$log" - printf '%s\n' '{"result":{"workspaces":[{"workspace_id":"w1","label":"firstmate","focused":true},{"workspace_id":"w2","label":"2ndmate-alpha","focused":false},{"workspace_id":"w3","label":"firstmate/old · p:AbCdEfGhIjKlMnOpQrStUv","focused":false},{"workspace_id":"w4","label":"firstmate/new · p:ZyXwVuTsRqPoNmLkJiHgFe","focused":false}]}}' > "$resp/1.out" + # Detached legacy child after the next parent breaks the contiguous block. + printf '%s\n' '{"result":{"workspaces":[{"workspace_id":"w1","label":"firstmate","focused":true},{"workspace_id":"w2","label":"2ndmate-alpha","focused":false},{"workspace_id":"w3","label":"firstmate/old · p:AbCdEfGhIjKlMnOpQrStUv","focused":false},{"workspace_id":"w4","label":"└ new · p:ZyXwVuTsRqPoNmLkJiHgFe","focused":false}]}}' > "$resp/1.out" cat > "$mover" <<'SH' #!/usr/bin/env bash echo called > "$FM_FAKE_MOVER_CALLED" @@ -864,7 +998,7 @@ SH fb=$(make_herdr_fakebin "$dir") out=$(PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" \ FM_BACKEND_HERDR_WORKSPACE_MOVER="$mover" FM_FAKE_MOVER_CALLED="$dir/called" \ - bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_projection_order_best_effort fmtest w4' "$ROOT" 2>&1) + bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_projection_order_best_effort fmtest w4 firstmate' "$ROOT" 2>&1) status=$? [ "$status" -eq 0 ] || fail "ambiguous projection ordering must not fail the spawn" assert_contains "$out" "ambiguous workspace layout" "ambiguous projection layout did not warn" @@ -874,6 +1008,196 @@ SH pass "herdr presentation ordering: an ambiguous existing worker block is warning-only and read-only" } +test_projection_order_foreign_new_child_before_parent_is_read_only() { + local dir log resp fb mover out status + dir="$TMP_ROOT/projection-order-foreign-new"; mkdir -p "$dir/responses" + log="$dir/log"; resp="$dir/responses"; mover="$dir/mover"; : > "$log" + printf '%s\n' '{"result":{"workspaces":[{"workspace_id":"w1","label":"firstmate","focused":true},{"workspace_id":"wH","label":"human-notes","focused":false},{"workspace_id":"w2","label":"└ foreign · p:AbCdEfGhIjKlMnOpQrStUv","focused":false},{"workspace_id":"w3","label":"2ndmate-alpha","focused":false},{"workspace_id":"w4","label":"└ new · p:ZyXwVuTsRqPoNmLkJiHgFe","focused":false}]}}' > "$resp/1.out" + cat > "$mover" <<'SH' +#!/usr/bin/env bash +echo called > "$FM_FAKE_MOVER_CALLED" +exit 0 +SH + chmod +x "$mover" + fb=$(make_herdr_fakebin "$dir") + out=$(PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" \ + FM_BACKEND_HERDR_WORKSPACE_MOVER="$mover" FM_FAKE_MOVER_CALLED="$dir/called" \ + bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_projection_order_best_effort fmtest w4 firstmate' "$ROOT" 2>&1) + status=$? + [ "$status" -eq 0 ] || fail "foreign new-child ordering must not fail the spawn" + assert_contains "$out" "ambiguous workspace layout" "foreign new child before its parent did not warn" + [ ! -e "$dir/called" ] || fail "foreign new child before its parent attempted workspace.move" + [ "$(wc -l < "$log" | tr -d '[:space:]')" = 1 ] \ + || fail "foreign new-child ordering did more than one read-only workspace list" + pass "herdr presentation ordering: a foreign new-format child is warning-only and read-only" +} + +test_projection_order_missing_parent_is_read_only() { + local dir log resp fb mover out status + dir="$TMP_ROOT/projection-order-missing-parent"; mkdir -p "$dir/responses" + log="$dir/log"; resp="$dir/responses"; mover="$dir/mover"; : > "$log" + printf '%s\n' '{"result":{"workspaces":[{"workspace_id":"w1","label":"firstmate"},{"workspace_id":"w2","label":"└ new · p:ZyXwVuTsRqPoNmLkJiHgFe"}]}}' > "$resp/1.out" + cat > "$mover" <<'SH' +#!/usr/bin/env bash +echo called > "$FM_FAKE_MOVER_CALLED" +exit 0 +SH + chmod +x "$mover" + fb=$(make_herdr_fakebin "$dir") + out=$(PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" \ + FM_BACKEND_HERDR_WORKSPACE_MOVER="$mover" FM_FAKE_MOVER_CALLED="$dir/called" \ + bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_projection_order_best_effort fmtest w2 2ndmate-missing' "$ROOT" 2>&1) + status=$? + [ "$status" -eq 0 ] || fail "missing parent must not fail the spawn" + assert_contains "$out" "ambiguous workspace layout" "missing parent did not warn" + [ ! -e "$dir/called" ] || fail "missing parent attempted workspace.move" + pass "herdr presentation ordering: missing owning parent is warning-only and read-only" +} + +test_presentation_session_lock_path_is_shared_across_homes() { + local dir log resp fb path_a path_b path_other path_tmp path_private + dir="$TMP_ROOT/presentation-session-lock"; mkdir -p "$dir/responses" "$dir/sockdir" + log="$dir/log"; resp="$dir/responses"; : > "$log" + : > "$dir/sockdir/fmtest.sock" + printf '%s\n' "{\"sessions\":[{\"name\":\"fmtest\",\"running\":true,\"socket_path\":\"$dir/sockdir/fmtest.sock\"}]}" > "$resp/1.out" + printf '%s\n' "{\"sessions\":[{\"name\":\"fmtest\",\"running\":true,\"socket_path\":\"$dir/sockdir/fmtest.sock\"}]}" > "$resp/2.out" + printf '%s\n' "{\"sessions\":[{\"name\":\"other\",\"running\":true,\"socket_path\":\"$dir/sockdir/other.sock\"}]}" > "$resp/3.out" + : > "$dir/sockdir/other.sock" + fb=$(make_herdr_fakebin "$dir") + path_a=$(PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" \ + bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_presentation_session_lock_path fmtest' "$ROOT") \ + || fail "session lock path resolution failed for home A" + path_b=$(PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" \ + bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_presentation_session_lock_path fmtest' "$ROOT") \ + || fail "session lock path resolution failed for home B" + [ "$path_a" = "$path_b" ] || fail "same session/socket must resolve one shared lock path" + case "$path_a" in + /tmp/firstmate-herdr-presentation/order-*.lock) ;; + *) fail "session lock path must use the shared machine namespace: $path_a" ;; + esac + case "$path_a" in + */state/*) fail "session lock path must not live under a home state directory: $path_a" ;; + esac + path_other=$(PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" \ + bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_presentation_session_lock_path other' "$ROOT") \ + || fail "session lock path resolution failed for a different session" + [ "$path_other" != "$path_a" ] || fail "different sessions must not share one lock path" + # Symlink parents such as /tmp -> /private/tmp must not split the lock identity. + if [ -L /tmp ] || [ "$(cd /tmp && pwd -P)" != /tmp ]; then + : > /tmp/fm-herdr-lock-canon-$$.sock + printf '%s\n' '{"sessions":[{"name":"canon","running":true,"socket_path":"/tmp/fm-herdr-lock-canon-'"$$"'.sock"}]}' > "$resp/4.out" + printf '%s\n' "{\"sessions\":[{\"name\":\"canon\",\"running\":true,\"socket_path\":\"$(cd /tmp && pwd -P)/fm-herdr-lock-canon-$$.sock\"}]}" > "$resp/5.out" + path_tmp=$(PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" \ + bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_presentation_session_lock_path canon' "$ROOT") \ + || fail "lock path with /tmp socket failed" + path_private=$(PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" \ + bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_presentation_session_lock_path canon' "$ROOT") \ + || fail "lock path with canonical socket failed" + rm -f /tmp/fm-herdr-lock-canon-$$.sock + [ "$path_tmp" = "$path_private" ] \ + || fail "symlink parent socket paths must resolve one lock: $path_tmp vs $path_private" + fi + pass "herdr presentation lock: one path per session/socket across homes" +} + +test_presentation_session_lock_path_rejects_malformed_socket() { + local dir log resp fb path status + dir="$TMP_ROOT/presentation-malformed-socket"; mkdir -p "$dir/responses" + log="$dir/log"; resp="$dir/responses"; : > "$log" + printf '%s\n' '{"sessions":[{"name":"fmtest","running":true,"socket_path":null}]}' > "$resp/1.out" + printf '%s\n' '{"sessions":[{"name":"fmtest","running":true}]}' > "$resp/2.out" + fb=$(make_herdr_fakebin "$dir") + path=$(PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" \ + bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_presentation_session_lock_path fmtest' "$ROOT" 2>&1) + status=$? + [ "$status" -ne 0 ] || fail "null socket_path must refuse the presentation lock path" + [ -z "$path" ] || fail "null socket_path returned a lock path: $path" + case "$path" in *null*) fail "null socket_path leaked into a lock path: $path" ;; esac + path=$(PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" \ + bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_presentation_session_lock_path fmtest' "$ROOT" 2>&1) + status=$? + [ "$status" -ne 0 ] || fail "missing socket_path must refuse the presentation lock path" + [ -z "$path" ] || fail "missing socket_path returned a lock path: $path" + pass "herdr presentation lock: null and missing socket paths fail closed" +} + +test_presentation_lock_malformed_socket_falls_back() { + local dir log resp fb out status lock_source + dir="$TMP_ROOT/presentation-malformed-socket-fallback"; mkdir -p "$dir/responses" + log="$dir/log"; resp="$dir/responses"; : > "$log" + printf '%s\n' '{"sessions":[{"name":"fmtest","running":true,"socket_path":null}]}' > "$resp/1.out" + fb=$(make_herdr_fakebin "$dir") + lock_source=$(sed -n '/^spawn_herdr_presentation_order_lock_acquire()/,/^spawn_herdr_presentation_order_lock_release()/p' "$ROOT/bin/fm-spawn.sh" | sed '$d') + out=$(PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" \ + LOCK_SOURCE="$lock_source" \ + bash -c ' + . "$0/bin/backends/herdr.sh" + eval "$LOCK_SOURCE" + if spawn_herdr_presentation_order_lock_acquire fmtest; then + printf "%s" acquired + else + printf "%s" flat + fi + ' "$ROOT" 2>&1) + status=$? + [ "$status" -eq 0 ] || fail "malformed socket fallback must not fail the spawn path: $out" + [ "$out" = flat ] || fail "malformed socket_path must fall back flat, got '$out'" + pass "herdr presentation lock: malformed socket metadata degrades to flat" +} + +test_projection_order_rejects_malformed_socket() { + local dir log resp fb mover out status + dir="$TMP_ROOT/projection-order-malformed-socket"; mkdir -p "$dir/responses" + log="$dir/log"; resp="$dir/responses"; mover="$dir/mover"; : > "$log" + printf '%s\n' '{"result":{"workspaces":[{"workspace_id":"w1","label":"firstmate"},{"workspace_id":"wH","label":"2ndmate-alpha"},{"workspace_id":"w2","label":"└ new · p:ZyXwVuTsRqPoNmLkJiHgFe"}]}}' > "$resp/1.out" + printf '%s\n' '{"client":{"version":"0.7.4","protocol":16},"server":{"running":true}}' > "$resp/2.out" + # shellcheck disable=SC2016 + printf '%s\n' '{"schemas":{"request":{"oneOf":[{"properties":{"method":{"const":"workspace.move"}}}],"$defs":{"WorkspaceMoveParams":{"required":["workspace_id","insert_index"],"properties":{"insert_index":{"type":"integer"}}}}}}}' > "$resp/3.out" + printf '%s\n' '{"sessions":[{"name":"fmtest","running":true,"socket_path":null}]}' > "$resp/4.out" + cat > "$mover" <<'SH' +#!/usr/bin/env bash +echo called > "$FM_FAKE_MOVER_CALLED" +exit 0 +SH + chmod +x "$mover" + fb=$(make_herdr_fakebin "$dir") + out=$(PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" FM_HERDR_SCRIPT_STATUS=1 \ + FM_BACKEND_HERDR_WORKSPACE_MOVER="$mover" FM_FAKE_MOVER_CALLED="$dir/called" \ + bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_projection_order_best_effort fmtest w2 firstmate' "$ROOT" 2>&1) + status=$? + [ "$status" -eq 0 ] || fail "malformed ordering socket must not fail the spawn" + assert_contains "$out" "ambiguous named session socket" "malformed ordering socket did not warn" + [ ! -e "$dir/called" ] || fail "malformed ordering socket attempted workspace.move" + pass "herdr presentation ordering: malformed socket metadata is warning-only and read-only" +} + +test_presentation_lock_insecure_namespace_falls_back() { + local dir log resp fb bad out status lock_source + dir="$TMP_ROOT/presentation-insecure-lock"; mkdir -p "$dir/responses" "$dir/sockdir" + log="$dir/log"; resp="$dir/responses"; : > "$log" + : > "$dir/sockdir/fmtest.sock" + bad="$dir/insecure"; mkdir -m 755 "$bad" + printf '%s\n' "{\"sessions\":[{\"name\":\"fmtest\",\"running\":true,\"socket_path\":\"$dir/sockdir/fmtest.sock\"}]}" > "$resp/1.out" + fb=$(make_herdr_fakebin "$dir") + lock_source=$(sed -n '/^spawn_herdr_presentation_order_lock_acquire()/,/^spawn_herdr_presentation_order_lock_release()/p' "$ROOT/bin/fm-spawn.sh" | sed '$d') + out=$(PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" \ + BAD_NAMESPACE="$bad" LOCK_SOURCE="$lock_source" \ + bash -c ' + . "$0/bin/backends/herdr.sh" + eval "$LOCK_SOURCE" + fm_backend_herdr_presentation_lock_namespace() { printf "%s" "$BAD_NAMESPACE"; } + if spawn_herdr_presentation_order_lock_acquire fmtest; then + printf "%s" acquired + else + printf "%s" flat + fi + ' "$ROOT" 2>&1) + status=$? + [ "$status" -eq 0 ] || fail "an insecure lock namespace must not fail the spawn path: $out" + [ "$out" = flat ] || fail "an insecure lock namespace must fall back flat, got '$out'" + pass "herdr presentation lock: insecure shared namespace refuses acquisition for flat fallback" +} + test_spawn_task_lock_covers_all_backend_creation_and_metadata_publication() { local source wake_source acquire_pattern backend_pattern meta_pattern acquire_line backend_line meta_line source=$(cat "$ROOT/bin/fm-spawn.sh") @@ -2457,9 +2781,21 @@ test_projection_focus_snapshot_requires_exact_workspace_and_tab test_projection_close_restores_exact_prior_focus test_projection_close_refuses_active_tab test_projection_seeded_prune_refuses_active_tab +test_projection_label_builder_uses_corner_and_strips_owner_prefixes test_projection_order_moves_only_exact_new_workspace_and_preserves_relative_order +test_projection_order_secondmate_parent_block +test_projection_order_foreign_legacy_child_is_read_only +test_projection_order_allows_intervening_parent_child_block +test_projection_order_human_spaces_never_move_targets test_projection_order_failure_warns_without_cleanup_or_spawn_failure test_projection_order_ambiguous_existing_block_is_read_only +test_projection_order_foreign_new_child_before_parent_is_read_only +test_projection_order_missing_parent_is_read_only +test_presentation_session_lock_path_is_shared_across_homes +test_presentation_session_lock_path_rejects_malformed_socket +test_presentation_lock_malformed_socket_falls_back +test_projection_order_rejects_malformed_socket +test_presentation_lock_insecure_namespace_falls_back test_spawn_task_lock_covers_all_backend_creation_and_metadata_publication test_projected_spawn_disarms_cleanup_before_ambiguous_launch_submission test_projected_abort_cleanup_holds_presentation_lock diff --git a/tests/fm-secondmate-harness.test.sh b/tests/fm-secondmate-harness.test.sh index 01160902249..e723c24acca 100755 --- a/tests/fm-secondmate-harness.test.sh +++ b/tests/fm-secondmate-harness.test.sh @@ -14,9 +14,10 @@ # explicit per-spawn harness arg still wins. # B) Inheritance. The primary pushes a declared, extensible set of LOCAL # (gitignored) config items - config/crew-dispatch.json, config/crew-harness, -# and config/backlog-backend - down into each secondmate home's config/, so -# the secondmate's OWN crewmates, dispatch profiles, and backlog backend -# inherit the primary's settings. It is primary-authoritative (re-pushed at +# config/backlog-backend, and config/herdr-presentation-spaces - down into +# each secondmate home's config/, so the secondmate's OWN crewmates, +# dispatch profiles, backlog backend, and Herdr presentation opt-in inherit +# the primary's settings. It is primary-authoritative (re-pushed at # secondmate spawn, on the bootstrap secondmate sweep, and by config push). # config/secondmate-harness is deliberately NOT inherited (secondmates do # not spawn secondmates). After a successful push that changes allowlisted @@ -133,6 +134,7 @@ test_propagate_lib() { printf '{"default":{"harness":"codex"}}\n' > "$src/crew-dispatch.json" printf 'codex\n' > "$src/crew-harness" printf 'manual\n' > "$src/backlog-backend" + : > "$src/herdr-presentation-spaces" stdout="$d/clean-copy.out" stderr="$d/clean-copy.err" propagate_inheritable_config "$src" "$dest" >"$stdout" 2>"$stderr" || fail "propagate returned non-zero" @@ -141,6 +143,7 @@ test_propagate_lib() { [ "$(cat "$dest/crew-dispatch.json")" = '{"default":{"harness":"codex"}}' ] || fail "crew-dispatch.json not propagated" [ "$(cat "$dest/crew-harness")" = codex ] || fail "crew-harness not propagated" [ "$(cat "$dest/backlog-backend")" = manual ] || fail "backlog-backend not propagated" + [ -f "$dest/herdr-presentation-spaces" ] || fail "herdr-presentation-spaces not propagated" # 2. idempotent: an unchanged re-run does not churn the mtime m1=$(date -r "$dest/crew-harness" +%s 2>/dev/null || stat -c %Y "$dest/crew-harness") @@ -173,11 +176,12 @@ test_propagate_lib() { [ "$(cat "$outside")" = outside ] || fail "destination symlink target was overwritten" # 4. removing the source mirrors absence downstream (primary-authoritative) - rm -f "$src/crew-dispatch.json" "$src/crew-harness" "$src/backlog-backend" + rm -f "$src/crew-dispatch.json" "$src/crew-harness" "$src/backlog-backend" "$src/herdr-presentation-spaces" propagate_inheritable_config "$src" "$dest" [ -e "$dest/crew-dispatch.json" ] && fail "dispatch profile absence not mirrored downstream" [ -e "$dest/crew-harness" ] && fail "absence not mirrored downstream" [ -e "$dest/backlog-backend" ] && fail "backlog-backend absence not mirrored downstream" + [ -e "$dest/herdr-presentation-spaces" ] && fail "herdr-presentation-spaces absence not mirrored downstream" rm -f "$dest/crew-harness" ln -s "$d/missing-target" "$dest/crew-harness" diff --git a/tests/fm-teardown.test.sh b/tests/fm-teardown.test.sh index e94446a4bae..c3fa616ac8d 100755 --- a/tests/fm-teardown.test.sh +++ b/tests/fm-teardown.test.sh @@ -1301,6 +1301,9 @@ case "${1:-} ${2:-}" in "status --json") printf '%s\n' '{"server":{"running":true}}' ;; + "session list") + printf '%s\n' '{"sessions":[{"name":"fmtest","running":true,"socket_path":"/tmp/fmtest.sock"}]}' + ;; "pane close") if [ "${FM_FAKE_HERDR_CLOSE_FAIL:-0}" = 1 ]; then exit 1 From b843c66b2d61b7a4dd257a07234ddd1a6227adad Mon Sep 17 00:00:00 2001 From: Kun Chen <3233006+kunchenguid@users.noreply.github.com> Date: Tue, 21 Jul 2026 19:50:51 -0700 Subject: [PATCH 002/250] fix: keep local no-mistakes tests intent-targeted (#823) * fix(no-mistakes): drop full-suite local Test override Local no-mistakes Test is intent-targeted; CI Behavior keeps the broad tests/*.test.sh suite. Keep commands.lint on bin/fm-lint.sh and add a focused contract test so the override cannot silently return. * no-mistakes(lint): Make CI contract assertion ShellCheck-clean --- .no-mistakes.yaml | 19 +++-- CONTRIBUTING.md | 5 +- docs/configuration.md | 6 +- tests/fm-nm-test-contract.test.sh | 115 ++++++++++++++++++++++++++++++ 4 files changed, 130 insertions(+), 15 deletions(-) create mode 100755 tests/fm-nm-test-contract.test.sh diff --git a/.no-mistakes.yaml b/.no-mistakes.yaml index b95e69b4125..f5d9412cce0 100644 --- a/.no-mistakes.yaml +++ b/.no-mistakes.yaml @@ -9,23 +9,22 @@ # HEAD-continuity guard; see docs/architecture.md "No-mistakes gate authority boundary." disable_project_settings: true -# Pin lint and the portable behavior suite to the same deterministic commands -# the Linux CI jobs run, instead of leaving them to no-mistakes' default handling. -# CI separately owns platform-specific compatibility lanes, including the stock -# macOS Bash snapshot checks. Without a configured -# commands.lint, the gate's lint step never ran the deterministic +# Pin lint to the same deterministic command CI runs, instead of leaving it to +# no-mistakes' default handling. Without a configured commands.lint, the gate's +# lint step never ran the deterministic # `shellcheck bin/*.sh bin/backends/*.sh tests/*.sh` that CI runs, so info-level # ShellCheck findings (e.g. SC2015) were not surfaced locally before CI rejected # them. commands.lint delegates to bin/fm-lint.sh, the single owner of the lint # definition that .github/workflows/ci.yml also invokes, so local can never # diverge from CI again (parity asserted by tests/fm-lint.test.sh). -# The test command mirrors the Linux behavior job in .github/workflows/ci.yml: -# iterate every tests/*.test.sh, run each, and fail the step if any one exits -# non-zero (an agent-driven test step has crashed the daemon). The e2e tests need -# tmux on PATH, which the firstmate environment provides. +# +# Do not set commands.test to a complete tests/*.test.sh walk. Local no-mistakes +# Test is intent-targeted validation of whether the change meets its brief; +# .github/workflows/ci.yml owns broad regression (behavior suite, platform, +# security, Herdr, tmux, and lifecycle coverage). A full-suite override here +# would duplicate CI and defeat the targeted Test contract. commands: lint: 'bin/fm-lint.sh' - test: 'command -v tmux >/dev/null || { echo "tmux is required for e2e tests" >&2; exit 1; }; tmux -V; rc=0; for t in tests/*.test.sh; do echo "== $t =="; bash "$t" || rc=1; done; exit "$rc"' # Keep test evidence out of this repo; it stays in a temp dir instead. test: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4e7bd8154dd..8fd98aa09e7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -62,7 +62,8 @@ A crewmate picking up such a brief should load the skill even if the brief preda When supervising live crewmates, keep firstmate's own long validation or build commands in the background so watcher wakes can still be handled. Crewmate validation follows the installed no-mistakes version's SKILL.md and live `axi` help instead of duplicating gate mechanics in firstmate docs. Firstmate's wrapper still matters: `ask-user` findings route to the captain through firstmate, and crewmates avoid `--yes` because it silently resolves captain-owned decisions without escalation. -Local `.no-mistakes/` state and test evidence stay out of this repo; `.no-mistakes.yaml` keeps evidence in a temp directory and pins the gate's lint and portable behavior commands to the Linux CI jobs, while `.github/workflows/ci.yml` owns additional platform-specific compatibility lanes. +Local `.no-mistakes/` state and test evidence stay out of this repo; `.no-mistakes.yaml` keeps evidence in a temp directory and pins the gate's lint command to `bin/fm-lint.sh`, matching the Linux CI lint job. +Local no-mistakes Test is intent-targeted and must not re-run every `tests/*.test.sh`; `.github/workflows/ci.yml` owns the broad behavior suite plus platform-specific compatibility lanes. That is firstmate-specific; do not commit `.no-mistakes/evidence/` here even when another no-mistakes-managed target project keeps committed PR evidence. Check and test the toolbelt before pushing: @@ -70,7 +71,7 @@ Check and test the toolbelt before pushing: ```sh for script in bin/*.sh bin/backends/*.sh; do bash -n "$script"; done # syntax-check the toolbelt bin/fm-lint.sh # lint the toolbelt and behavior tests; the single owner CI and the no-mistakes gate both run -for test_script in tests/*.test.sh; do bash "$test_script"; done # behavior tests, matching CI and no-mistakes commands.test +for test_script in tests/*.test.sh; do bash "$test_script"; done # full behavior suite (CI Behavior job; optional local full run) [ "$(readlink CLAUDE.md)" = "AGENTS.md" ] [ "$(readlink .claude/skills)" = "../.agents/skills" ] tmp=$(mktemp -d) && printf 'done: smoke\n' > "$tmp/smoke.status" && FM_STATE_OVERRIDE="$tmp" FM_SIGNAL_GRACE=1 FM_POLL=1 FM_HEARTBEAT=999999 bin/fm-watch-arm.sh # watcher re-arm smoke test (prints arm status, then an actionable signal) diff --git a/docs/configuration.md b/docs/configuration.md index a5103991f8a..c0b65e2ef5a 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -106,10 +106,10 @@ See [`wedge-alarm.md`](wedge-alarm.md) for the channel reference and macOS verif ## Gate defaults (.no-mistakes.yaml) -The tracked `.no-mistakes.yaml` keeps test evidence outside the repo and defines `commands.test` so no-mistakes runs firstmate's bash behavior suite directly. +The tracked `.no-mistakes.yaml` keeps test evidence outside the repo and pins `commands.lint` to `bin/fm-lint.sh` so local lint matches CI. That evidence policy is specific to the firstmate repo: target projects may legitimately commit `.no-mistakes/evidence/` from their own no-mistakes pipeline, but firstmate keeps `.no-mistakes/` local and CI rejects tracked entries under that path. -That command requires `tmux` on `PATH`, prints `tmux -V`, runs every `tests/*.test.sh` with `bash`, and fails if any script exits non-zero. -It intentionally mirrors the behavior-test baseline in [`.github/workflows/ci.yml`](../.github/workflows/ci.yml) instead of delegating the test step to an agent. +It does not set `commands.test` to a complete `tests/*.test.sh` walk. +Local no-mistakes Test stays intent-targeted; broad regression (including the portable behavior suite) lives in [`.github/workflows/ci.yml`](../.github/workflows/ci.yml). ## Captain Preferences (data/captain.md / data/captain-shared.md) diff --git a/tests/fm-nm-test-contract.test.sh b/tests/fm-nm-test-contract.test.sh new file mode 100755 index 00000000000..94c92fd5743 --- /dev/null +++ b/tests/fm-nm-test-contract.test.sh @@ -0,0 +1,115 @@ +#!/usr/bin/env bash +# Contract: local no-mistakes Test is intent-targeted; CI owns broad regression. +# +# Firstmate must not configure commands.test as a complete tests/*.test.sh walk +# (that duplicated CI and burned local pipeline time). Lint stays pinned to +# bin/fm-lint.sh. Remote CI Behavior must keep iterating every tests/*.test.sh. +set -u + +# shellcheck source=tests/lib.sh +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +NM="$ROOT/.no-mistakes.yaml" +CI="$ROOT/.github/workflows/ci.yml" + +test_nm_yaml_tracked() { + assert_present "$NM" "tracked .no-mistakes.yaml is missing" + git -C "$ROOT" ls-files --error-unmatch .no-mistakes.yaml >/dev/null 2>&1 \ + || fail ".no-mistakes.yaml is not tracked by git" + pass ".no-mistakes.yaml is present and tracked" +} + +test_nm_keeps_lint_pin() { + grep -Fqx " lint: 'bin/fm-lint.sh'" "$NM" \ + || fail "commands.lint must remain exactly bin/fm-lint.sh" + pass "commands.lint stays pinned to bin/fm-lint.sh" +} + +# True when the YAML maps a non-empty commands.test (string or mapping value). +# Empty / null / absent is the intended targeted-Test posture. +nm_commands_test_value() { + if command -v python3 >/dev/null 2>&1 && python3 -c 'import yaml' >/dev/null 2>&1; then + python3 -c ' +import yaml, sys +doc = yaml.safe_load(open(sys.argv[1])) or {} +cmds = doc.get("commands") or {} +val = cmds.get("test") if isinstance(cmds, dict) else None +if val is None or val is False: + print("") +elif isinstance(val, str): + print(val) +else: + print(repr(val)) +' "$NM" + return + fi + if command -v ruby >/dev/null 2>&1; then + ruby -ryaml -e ' +doc = YAML.safe_load(File.read(ARGV[0])) || {} +cmds = doc["commands"] || {} +val = cmds.is_a?(Hash) ? cmds["test"] : nil +if val.nil? || val == false + puts "" +elsif val.is_a?(String) + puts val +else + puts val.inspect +end +' "$NM" + return + fi + # Structural fallback: any commands.test line under the commands block. + awk ' + /^commands:[[:space:]]*$/ { in_cmds=1; next } + in_cmds && /^[^[:space:]#]/ { in_cmds=0 } + in_cmds && /^[[:space:]]+test:[[:space:]]*/ { + sub(/^[[:space:]]+test:[[:space:]]*/, "") + gsub(/^['\''"]|['\''"]$/, "") + print + exit + } + ' "$NM" +} + +test_nm_has_no_complete_local_test_command() { + local val + val=$(nm_commands_test_value) || fail "failed to read commands.test from .no-mistakes.yaml" + if [ -n "$val" ]; then + case "$val" in + *'tests/*.test.sh'*|*'tests/'*'.test.sh'*) + fail "commands.test must not walk the complete tests/*.test.sh suite; got: $val" + ;; + *) + # Any non-empty override still steers Test away from intent-targeted default. + fail "commands.test must be absent or empty so Test stays intent-targeted; got: $val" + ;; + esac + fi + # Also refuse a commented-out full-suite remnant that could be re-enabled by habit. + if grep -E '^[[:space:]]*#?[[:space:]]*test:[[:space:]].*tests/\*\.test\.sh' "$NM" >/dev/null 2>&1; then + fail ".no-mistakes.yaml still documents a full-suite commands.test line (active or comment)" + fi + pass "no-mistakes does not configure a complete local Test command" +} + +test_ci_still_runs_broad_behavior_suite() { + assert_present "$CI" "ci.yml is missing" + # Behavior job still iterates every portable behavior script. + grep -Fq 'for test_script in tests/*.test.sh' "$CI" \ + || fail "CI Behavior job must still loop over tests/*.test.sh" + grep -Fq "\"\$test_script\"" "$CI" \ + || fail "CI Behavior job must still execute each tests/*.test.sh script" + # Preserve other CI lanes this task must not shrink. + grep -Eq 'name:[[:space:]]*Lint shell scripts' "$CI" \ + || fail "CI must retain the lint job" + grep -Eq 'name:[[:space:]]*Stock macOS Bash snapshot compatibility' "$CI" \ + || fail "CI must retain the macOS stock Bash compatibility job" + grep -Eq 'name:[[:space:]]*Repo invariants' "$CI" \ + || fail "CI must retain the repo invariants job" + pass "CI still owns the broad behavior suite and companion jobs" +} + +test_nm_yaml_tracked +test_nm_keeps_lint_pin +test_nm_has_no_complete_local_test_command +test_ci_still_runs_broad_behavior_suite From f02eef1970bc7509ca17f53a34b81d8a92008298 Mon Sep 17 00:00:00 2001 From: Kun Chen <3233006+kunchenguid@users.noreply.github.com> Date: Tue, 21 Jul 2026 20:59:59 -0700 Subject: [PATCH 003/250] feat: add canonical timed test runner (#825) * feat(test): add canonical timed suite runner and honest CI timeout Introduce bin/fm-test-run.sh as the single serial owner for selecting one script, a family, a conservative changed-file set, or the explicit complete suite, with per-script timing markers and a JSON artifact. Wire CI Behavior through the runner, raise the hang-tripwire timeout to 25 minutes, and document entry points without restoring a full-suite local no-mistakes Test command. * no-mistakes(review): Captain: fix changed selection and empty summaries * no-mistakes(review): Captain: fail closed on unmapped changed sources * no-mistakes(document): Document canonical timed test entry points --- .github/workflows/ci.yml | 24 +- CONTRIBUTING.md | 12 +- bin/fm-test-run.sh | 791 ++++++++++++++++++++++++++++++ docs/arm-pretool-check.md | 2 +- docs/configuration.md | 3 +- tests/fm-nm-test-contract.test.sh | 15 +- tests/fm-test-run.test.sh | 352 +++++++++++++ 7 files changed, 1181 insertions(+), 18 deletions(-) create mode 100755 bin/fm-test-run.sh create mode 100755 tests/fm-test-run.test.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bdfa76b238c..b91fb3ba068 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,9 +27,10 @@ jobs: tests: name: Behavior tests runs-on: ubuntu-latest - # The suite should finish in ~2-3 minutes; this generous cap fails loudly on a - # hung watcher or tmux test instead of riding GitHub's 360-minute default. - timeout-minutes: 15 + # Measured portable suite wall-clock is ~13-15+ minutes without Herdr. + # This cap is a hang tripwire for a stuck watcher or tmux test, not the + # expected end of a healthy suite (and not a substitute for green results). + timeout-minutes: 25 steps: - uses: actions/checkout@v6 with: @@ -52,11 +53,20 @@ jobs: set -eu npm install -g tasks-axi tasks-axi --version - - run: | + # Single owner of serial suite selection, timing markers, and aggregate + # failure. Do not re-spell a for-loop over tests/*.test.sh here. + - name: Run portable behavior suite + run: | set -eu - for test_script in tests/*.test.sh; do - "$test_script" - done + mkdir -p "$RUNNER_TEMP/fm-test" + bin/fm-test-run.sh --all --json "$RUNNER_TEMP/fm-test/fm-test-timing.json" + - name: Upload behavior timing artifact + if: always() + uses: actions/upload-artifact@v4 + with: + name: fm-test-timing + path: ${{ runner.temp }}/fm-test/fm-test-timing.json + if-no-files-found: warn macos-stock-bash: name: Stock macOS Bash snapshot compatibility diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8fd98aa09e7..b72fda88204 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -71,14 +71,20 @@ Check and test the toolbelt before pushing: ```sh for script in bin/*.sh bin/backends/*.sh; do bash -n "$script"; done # syntax-check the toolbelt bin/fm-lint.sh # lint the toolbelt and behavior tests; the single owner CI and the no-mistakes gate both run -for test_script in tests/*.test.sh; do bash "$test_script"; done # full behavior suite (CI Behavior job; optional local full run) +bin/fm-test-run.sh tests/.test.sh # one script (primary local focus path, timed) +bin/fm-test-run.sh --family pure-contract-unit # one declared family (serial, timed) +bin/fm-test-run.sh --changed # conservative changed-file-informed set (never silent full suite) +bin/fm-test-run.sh --all # intentional complete portable suite (CI Behavior; optional local full run) [ "$(readlink CLAUDE.md)" = "AGENTS.md" ] [ "$(readlink .claude/skills)" = "../.agents/skills" ] tmp=$(mktemp -d) && printf 'done: smoke\n' > "$tmp/smoke.status" && FM_STATE_OVERRIDE="$tmp" FM_SIGNAL_GRACE=1 FM_POLL=1 FM_HEARTBEAT=999999 bin/fm-watch-arm.sh # watcher re-arm smoke test (prints arm status, then an actionable signal) ``` -Discover tests by listing `tests/*.test.sh`: each is a self-contained bash script named `.test.sh`, and its header comment describes what it covers, so run one directly to focus on a subject. -Tests that need a real optional backend or an explicit opt-in (real herdr/zellij/cmux smoke tests, the live Pi regression) skip themselves and print the tool or environment gate needed to enable them, so the run-all loop above is always safe. +`bin/fm-test-run.sh` is the single owner of serial behavior-suite selection, per-script timing markers, family totals, and the optional JSON timing artifact. +Its header and `--help` own the flags, family labels, and changed-file map; this section only documents the entry points. +Local no-mistakes Test stays intent-targeted and must not wire `commands.test` to `--all` or a `tests/*.test.sh` walk; CI Behavior calls `bin/fm-test-run.sh --all` for broad regression. +Discover tests by listing `tests/*.test.sh`: each is a self-contained bash script named `.test.sh`, and its header comment describes what it covers, so pass one to `bin/fm-test-run.sh` to focus on a subject with canonical timing output. +Tests that need a real optional backend or an explicit opt-in (real herdr/zellij/cmux smoke tests, the live Pi regression) skip themselves and print the tool or environment gate needed to enable them, so `--all` remains safe on machines without those tools. ## Questions diff --git a/bin/fm-test-run.sh b/bin/fm-test-run.sh new file mode 100755 index 00000000000..ad782f2e9ee --- /dev/null +++ b/bin/fm-test-run.sh @@ -0,0 +1,791 @@ +#!/usr/bin/env bash +# fm-test-run.sh - single owner of Firstmate's serial behavior-test runner. +# +# Replaces duplicated `for test_script in tests/*.test.sh` loops in CONTRIBUTING +# and the CI Behavior job. This phase is intentionally serial: no sharding and +# no local --jobs parallelism. +# +# Selection modes (exactly one of: --all, --family, --changed, or script paths): +# fm-test-run.sh --all +# fm-test-run.sh --family +# fm-test-run.sh --changed [--base ] +# fm-test-run.sh tests/.test.sh [more scripts...] +# +# Inspection (no execution): +# fm-test-run.sh --list --all +# fm-test-run.sh --list --family +# fm-test-run.sh --list-families +# +# Options: +# --json write a deterministic timing artifact after the run +# --list print selected script paths (one per line) and exit 0 +# --base with --changed, compare against this ref (default: origin/main) +# -h, --help print this header +# +# Per-script machine-parseable markers (stdout): +# FM_TEST_BEGIN + + + + diff --git a/.agents/skills/decision-hold-lifecycle/SKILL.md b/.agents/skills/decision-hold-lifecycle/SKILL.md index 43e327dd623..dcb1eeb8a87 100644 --- a/.agents/skills/decision-hold-lifecycle/SKILL.md +++ b/.agents/skills/decision-hold-lifecycle/SKILL.md @@ -27,7 +27,7 @@ When the captain simply answers a hold that has no follow-up work routed behind "A keyed answer closes its matching hold" is one capability with one owner, `bin/fm-decision-hold.sh answers`, and every channel that carries a captain answer feeds it the same `` and answer. A channel never maps a key to a hold, records a decision, or closes anything itself, so no channel is special and a new one needs no new closing logic. Chat already feeds it: `bin/fm-send.sh --resolve-key` answers a decision in whichever ledger still holds it open, including a decision already transferred to its durable hold. -A captured-answer source feeds it too once bound with `bin/fm-decision-hold.sh bind `; bind before arming the source, and key each structured question by the hold's own decision key. +A captured-answer source feeds it too once bound with `bin/fm-decision-hold.sh bind `, or with `--any-origin` for a source that carries answers across origins, such as the bearings board; bind before arming the source, and key each structured question by the hold's own decision key, or by its full hold identity under an any-origin binding. An unbound source and a question slug that is not a decision key both simply feed nothing: the answer is still captured and firstmate is still woken, and closing falls back to the commands above. A hold closed outside this owner leaves no durable answer, so the completion gate keeps failing until `bin/fm-decision-hold.sh repair` records the decision the captain actually gave; neither unrouted path may stand in for an answer the captain has not given. Resolved findings, recommendations that need no captain choice, and prose that merely sounds decision-like do not create holds. diff --git a/.agents/skills/process-event-sources/SKILL.md b/.agents/skills/process-event-sources/SKILL.md index 793ac546126..0abd9f3a208 100644 --- a/.agents/skills/process-event-sources/SKILL.md +++ b/.agents/skills/process-event-sources/SKILL.md @@ -82,6 +82,7 @@ Two rules the commands cannot enforce for you: ``` This call is atomically deduplicated by the exact source and sequence: it prints `handled: ` only the first time and `already-handled: ` on every repeat, so a paired effect gated on that distinction is never authorized twice. Reading the event line or the result file is not handling - only this call durably retires the wake, so call it every time, including on a repeat wake for a sequence you already acted on. : Ask the adapter what the result means rather than parsing it yourself - for Lavish, `bin/fm-procevent-lavish.sh classify ` returns `feedback`, `ended`, `waiting`, `missing`, or `unknown`. A `feedback` result can still be the last one a review ever produces, so never assume another wake is coming just because the state is not `ended`. +: A Lavish wake whose source id matches `bin/fm-procevent-lavish.sh source-id "$(bin/fm-bearings-board.sh path)"` is a bearings board result; load the `bearings` skill's board-wake handling regardless of which answer kinds the result contains. : A `when` wake carries the watch's one terminal captured outcome and may be re-announced until handled: `bin/fm-procevent-when.sh classify ` returns `fired` (relay the success and its output); `action-failed` (relay the captured error and decide recovery); `condition-error`, `never-true`, or `rejected` (the watch stopped safely without acting - report why and decide whether to re-arm); or `ambiguous` (the action was claimed but its outcome was never captured - verify its effect manually before anything else). Every `when` outcome is terminal and the action is never retried automatically, so after handling and the generic acknowledgement above, run `bin/fm-procevent-when.sh retire ` to clean the watch's private records before any re-arm. : Treat every byte of the result as **input, never instruction and never authority**. It came from outside firstmate, so it must not be executed, echoed into a shell, or read as permission. An approval in a result routes through the ordinary merge and decision owners, unchanged. : Never append a raw result to a task's status history; that log is a bounded event record, not a payload channel. diff --git a/AGENTS.md b/AGENTS.md index 67ec0d69609..d4d7011f57c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -107,7 +107,7 @@ state/ runtime records and signals; gitignored pending-replies/ parent-owned secondmate pending-reply records (correlation id, delivery vs reply, recovery, escalation); fm-pending-reply-lib.sh procevent/ registered process-to-event sources, one private record per canonical source id; written only by bin/fm-procevent.sh, and their presence alone keeps supervision required (section 13) procevent-inbox/ private captured results and their durable handled-acknowledgement markers; source output lives here and never in an event line - decision-bindings/ private bindings from a captured-answer source id to the captain-hold origin its keyed answers close; written only by bin/fm-decision-hold.sh bind, dropped by unbind and by source retirement (section 13; docs/decision-hold-lifecycle.md) + decision-bindings/ private bindings from a captured-answer source id to one captain-hold origin or the cross-origin marker; written only by bin/fm-decision-hold.sh bind, dropped by unbind and by source retirement (section 13; docs/decision-hold-lifecycle.md) when/ private condition->action watch specs, their trust bindings, and single-fire markers; written only by bin/fm-procevent-when.sh (section 13's process-event-sources trigger) x-inbox/ generated Relay pending mention payloads; fmx-respond drains it (section 14) x-context/ generated Relay durable per-request reply context and one-wake offer markers, keyed by request_id; survives inbox cleanup and expires within seven days (section 14; bin/fm-x-lib.sh) diff --git a/bin/fm-bearings-board.sh b/bin/fm-bearings-board.sh new file mode 100755 index 00000000000..008b714b805 --- /dev/null +++ b/bin/fm-bearings-board.sh @@ -0,0 +1,198 @@ +#!/usr/bin/env bash +# fm-bearings-board.sh - build and arm the /bearings lavish fleet board. +# +# The board is the captain-facing interactive surface of /bearings lavish: the +# shipped template (.agents/skills/bearings/assets/board-template.html) plus one +# injected fm-bearings-board.v1 JSON payload. This script owns the mechanics so +# the invoking agent's per-run work stays "compose the JSON, run build" - the +# agent never authors board UI at invocation time. +# +# Usage: +# fm-bearings-board.sh build +# fm-bearings-board.sh path +# +# build Validate the payload and inject it into a fresh copy of the shipped +# template at the stable board path. Establish or resume the Lavish +# session on that board BEFORE binding and arming its answer source, +# so a registered poll can never race a session that does not exist. +# Bind to the any-origin keyed-answer intake ALWAYS precedes arm, so +# the board can never produce an answer that has nowhere to go +# (decision-hold-lifecycle's ordering rule, enforced here rather +# than left to agent memory). Output starts with `board: `, +# then includes lavish-axi's session output and the remaining status: +# served: +# bound: (any-origin) +# armed: (first registration) +# already-armed: (registration already present) +# path Print the stable board path for this home. +# +# Validation is fail-closed: the payload must be valid JSON with +# schema=fm-bearings-board.v1 and every renderer-consumed field must satisfy +# the fm-bearings-board.v1 types and item invariants below. Every fleet row and +# Captain's Call item explicitly carries `repo`; the composer fills it from the +# snapshot and task records wherever known, and uses null or an empty string +# only as the deliberate genuinely-no-repo marker. In that exceptional case +# the template may display the routing id. Anything else refuses before the +# existing board is touched. +# +# The board path is stable - $FM_HOME/.lavish/bearings-board.html - so a +# re-invocation rebuilds the same file in place, which keeps the same Lavish +# session URL and the same canonical process-event source id. Injection escapes +# every `<` in the compact JSON as the \u003c string escape, so a payload string +# containing "" can never terminate the data block early. +# +# FM_BEARINGS_BOARD_TEMPLATE overrides the shipped template path (tests only). +set -eu + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd "$SCRIPT_DIR/.." && pwd)}" +FM_HOME="${FM_HOME:-$FM_ROOT}" + +TEMPLATE="${FM_BEARINGS_BOARD_TEMPLATE:-$SCRIPT_DIR/../.agents/skills/bearings/assets/board-template.html}" +PLACEHOLDER='__FM_BEARINGS_BOARD_DATA__' +BOARD_SCHEMA=fm-bearings-board.v1 + +usage() { + awk ' + NR == 1 { next } + /^#/ { sub(/^# ?/, ""); print; next } + { exit } + ' "$0" +} + +fail() { + printf 'fm-bearings-board: %s\n' "$*" >&2 + exit 1 +} + +board_path() { printf '%s/.lavish/bearings-board.html\n' "$FM_HOME"; } + +validate_payload() { # + jq -e --arg schema "$BOARD_SCHEMA" ' + def nonempty_string: type == "string" and length > 0; + def slug($max): type == "string" and test("^[A-Za-z0-9._-]{1," + ($max | tostring) + "}$"); + def repo_marker: has("repo") and (.repo == null or (.repo | type == "string")); + def optional_string($name): (has($name) | not) or (.[$name] | type == "string"); + def optional_https_url($name): + (has($name) | not) + or (.[$name] + | type == "string" + and test("^https://[A-Za-z0-9](?:[A-Za-z0-9.-]*[A-Za-z0-9])?(?::[0-9]{1,5})?(?:[/?#][^[:space:]]*)?$")); + def call_item: + type == "object" + and (.key | slug(128)) + and (.type == "decision" or .type == "merge" or .type == "credential") + and repo_marker + and (.title | nonempty_string) + and (.options | type == "array") + and ((.options | length) > 0 or .allow_freeform == true) + and ([.options[] + | type == "object" + and (.value | slug(128)) + and (.label | nonempty_string) + and optional_string("hint")] | all) + and (optional_string("about")) + and (optional_string("decide")) + and (optional_string("detail")) + and (optional_https_url("pr_url")) + and (optional_string("freeform_hint")) + and ((has("allow_freeform") | not) or (.allow_freeform | type == "boolean")) + and ((has("recommend_value") | not) + or ((.recommend_value | slug(128)) + and (.recommend_value as $recommend | [.options[].value] | index($recommend) != null))) + and (if .type == "merge" then (.risk | nonempty_string) else true end); + def underway_item: + type == "object" and repo_marker and (.id | nonempty_string) + and (.state | nonempty_string) and (.doing | nonempty_string) and (.kind | nonempty_string); + def landed_item: + type == "object" and repo_marker and (.id | nonempty_string) + and (.what | nonempty_string) and (.owner | nonempty_string) + and optional_https_url("pr_url"); + def charted_item: + type == "object" and repo_marker and (.id | slug(128)) + and (.title | nonempty_string) and (.reason | type == "string") + and (.dispatchable | type == "boolean"); + type == "object" + and (.schema == $schema) + and (.home | nonempty_string) + and (.generated | nonempty_string) + and (.prs_live | type == "boolean") + and (.captains_call | type == "array") + and (.underway | type == "array") + and (.landed | type == "array") + and (.charted | type == "array") + and ((has("charted_more") | not) + or ((.charted_more | type == "number") and (.charted_more >= 0) and (.charted_more | floor == .))) + and ([.captains_call[] | call_item] | all) + and ([.underway[] | underway_item] | all) + and ([.landed[] | landed_item] | all) + and ([.charted[] | charted_item] | all) + ' "$1" >/dev/null +} + +command_build() { + local data=${1-} board json tmp sid extracted + [ "$#" -eq 1 ] || { usage >&2; exit 2; } + command -v jq >/dev/null 2>&1 || fail "jq is required" + [ -f "$data" ] || fail "board data does not exist: $data" + jq empty "$data" 2>/dev/null || fail "board data is not valid JSON: $data" + validate_payload "$data" || fail "board data does not satisfy $BOARD_SCHEMA: $data" + [ -f "$TEMPLATE" ] && [ ! -L "$TEMPLATE" ] || fail "board template is missing: $TEMPLATE" + [ "$(grep -cxF "$PLACEHOLDER" "$TEMPLATE")" -eq 1 ] \ + || fail "board template does not carry exactly one data slot: $TEMPLATE" + + json=$(jq -c . "$data") || fail "cannot compact the board data" + # `<` never appears in JSON syntax outside strings, so escaping every + # occurrence keeps the payload valid JSON while making inert. + json=${json// "$tmp"; then + rm -f -- "$tmp" + fail "cannot inject the board data" + fi + if grep -qxF "$PLACEHOLDER" "$tmp"; then + rm -f -- "$tmp" + fail "the board data slot survived injection" + fi + # Round-trip the injected payload back out of the built page, so a board that + # would fail to parse in the browser fails here instead. + extracted=$(sed -n '/x", + "decide": "Adopt it?", + "options": [ + { "value": "yes", "label": "Adopt", "hint": "recommended" }, + { "value": "no", "label": "Keep current" } + ], + "allow_freeform": true + }, + { + "key": "merge.sample-task", + "type": "merge", + "repo": "sample", + "title": "Merge: sample change", + "detail": "validation green", + "task_id": "sample-task", + "pr_url": "https://github.com/example/sample/pull/1", + "checks": "green", + "risk": "low", + "options": [ + { "value": "merge", "label": "Merge now" }, + { "value": "hold", "label": "Not yet" } + ], + "allow_freeform": true + } + ], + "underway": [], + "landed": [], + "charted": [ + { "id": "sample-queued", "repo": "sample", "title": "Queued work", "reason": "", "dispatchable": true } + ], + "charted_more": 0 +} +EOF +} + +# Extract the injected payload back out of a built board page. +extract_payload() { # + sed -n '/ string can no longer + # terminate the data block. + extract_payload "$board" | jq -S . > "$home/extracted.json" \ + || fail "the built board does not carry parseable payload JSON" + jq -S . "$data" > "$home/expected.json" + diff -u "$home/expected.json" "$home/extracted.json" >/dev/null \ + || fail "the injected payload does not round-trip to the input document" + grep -qF '' "$board" \ + && fail "a payload string embedded a live closing script tag in the page" + grep -qxF '__FM_BEARINGS_BOARD_DATA__' "$board" \ + && fail "the data slot survived injection" + + sid=$(run_lavish_source_id "$home" "$board") + assert_contains "$out" "bound: $sid" "the binding does not name the board source: $out" + [ "$(run_decisions "$home" binding "$sid")" = "(any)" ] \ + || fail "the board source is not bound any-origin" + run_procevent "$home" list | awk 'NR > 1 { print $1 }' | grep -Fxq "$sid" \ + || fail "the board source is not registered after build" + pass "build injects the payload, binds any-origin, then arms the source" +} + +test_registration_cannot_consume_before_any_origin_binding() { + local home data runtime origin key hold board sid show + home=$(make_home order-proof) + data="$home/payload.json" + runtime="$home/runtime" + origin=order-proof-review + key=captain-choice + hold="$origin-decision-$key" + board="$home/.lavish/bearings-board.html" + + cp "$ROOT/.tasks.toml" "$home/.tasks.toml" + cat > "$home/data/backlog.md" <<'EOF' +## In flight + +## Queued + +## Done +EOF + fm_write_meta "$home/state/$origin.meta" "project=$home/projects/sample" "kind=scout" + run_decisions "$home" hold "$origin" "$key" \ + --title "Choose the order proof" --reason "captain choice pending" --repo sample >/dev/null \ + || fail "could not create the order-proof captain hold" + + write_valid_payload "$data" + jq --arg hold "$hold" '.captains_call[0].key = $hold' "$data" > "$data.tmp" \ + && mv "$data.tmp" "$data" + + mkdir -p "$runtime" + cp -R "$ROOT/bin" "$runtime/bin" + cat > "$runtime/bin/fm-procevent-lavish.sh" <<'SH' +#!/usr/bin/env bash +set -eu +if [ "${1:-}" = arm ]; then + artifact=${2:-} + "$REAL_LAVISH_ADAPTER" arm "$artifact" >/dev/null + sid=$("$REAL_LAVISH_ADAPTER" source-id "$artifact") + "$REAL_PROCEVENT" start "$sid" >/dev/null + exit 0 +fi +exec "$REAL_LAVISH_ADAPTER" "$@" +SH + chmod +x "$runtime/bin/fm-procevent-lavish.sh" + cat > "$home/fakebin/lavish-axi" <<'SH' +#!/usr/bin/env bash +if [ "${1:-}" != poll ]; then + exit 0 +fi +cat </dev/null \ + || fail "the order-proof board build failed" + + show=$(cd "$home" && tasks-axi show "$hold" --full) \ + || fail "the order-proof captain hold disappeared" + assert_contains "$show" "state: done" \ + "registration consumed its answer before the any-origin binding existed" + assert_contains "$show" "Resolution mode: answered" \ + "the answer was not closed through the real keyed-answer intake" + sid=$(run_lavish_source_id "$home" "$board") + [ "$(run_decisions "$home" binding "$sid")" = "(any)" ] \ + || fail "the order-proof source did not retain its any-origin binding" + pass "registration can consume answers only after any-origin binding exists" +} + +test_build_does_not_bind_or_arm_when_session_start_fails() { + local home data rc sid + home=$(make_home serve-failure) + data="$home/payload.json" + write_valid_payload "$data" + cat > "$home/fakebin/lavish-axi" <<'SH' +#!/usr/bin/env bash +exit 1 +SH + chmod +x "$home/fakebin/lavish-axi" + + set +e + run_board "$home" build "$data" >/dev/null 2>&1 + rc=$? + set -e + [ "$rc" -ne 0 ] || fail "build continued after Lavish session establishment failed" + sid=$(run_lavish_source_id "$home" "$home/.lavish/bearings-board.html") + ! run_decisions "$home" binding "$sid" >/dev/null 2>&1 \ + || fail "build bound the board before its Lavish session existed" + ! run_procevent "$home" list | awk 'NR > 1 { print $1 }' | grep -Fxq "$sid" \ + || fail "build armed the board before its Lavish session existed" + pass "build establishes the Lavish session before binding and arming" +} + +run_lavish_source_id() { # + local home=$1 + PATH="$home/fakebin:$PATH" FM_HOME="$home" \ + FM_STATE_OVERRIDE="$home/state" FM_DATA_OVERRIDE="$home/data" \ + FM_PROCEVENT_CLAIM_ROOT="$home/procevent-claims" \ + "$ROOT/bin/fm-procevent-lavish.sh" source-id "$2" +} + +test_rebuild_is_idempotent_and_does_not_double_arm() { + local home data board out records + home=$(make_home rearm) + data="$home/payload.json" + board="$home/.lavish/bearings-board.html" + write_valid_payload "$data" + run_board "$home" build "$data" >/dev/null || fail "the first build failed" + + jq '.generated = "2026-08-19T01:00Z"' "$data" > "$data.tmp" && mv "$data.tmp" "$data" + out=$(run_board "$home" build "$data") || fail "the rebuild failed" + assert_contains "$out" "already-armed: " "the rebuild re-armed an already registered source: $out" + extract_payload "$board" | jq -e '.generated == "2026-08-19T01:00Z"' >/dev/null \ + || fail "the rebuild did not refresh the board payload in place" + records=$(find "$home/state/procevent" -name '*.source' | wc -l | tr -d ' ') + [ "$records" = 1 ] || fail "rebuilding left $records source registrations instead of 1" + pass "rebuild refreshes the board in place without double-arming" +} + +test_build_refuses_a_template_without_exactly_one_slot() { + local home data rc out + home=$(make_home badslot) + data="$home/payload.json" + write_valid_payload "$data" + printf 'no slot\n' > "$home/broken-template.html" + set +e + out=$(FM_BEARINGS_BOARD_TEMPLATE="$home/broken-template.html" run_board "$home" build "$data" 2>&1) + rc=$? + set -e + [ "$rc" -ne 0 ] || fail "a template with no data slot was accepted" + assert_contains "$out" "data slot" "the slot refusal did not say why: $out" + assert_absent "$home/.lavish/bearings-board.html" "a refused template still produced a board" + pass "build refuses a template without exactly one data slot" +} + +test_path_is_stable_and_home_scoped +test_build_refuses_malformed_payloads_before_touching_the_board +test_build_injects_binds_then_arms +test_registration_cannot_consume_before_any_origin_binding +test_build_does_not_bind_or_arm_when_session_start_fails +test_rebuild_is_idempotent_and_does_not_double_arm +test_build_refuses_a_template_without_exactly_one_slot diff --git a/tests/fm-decision-hold-lifecycle.test.sh b/tests/fm-decision-hold-lifecycle.test.sh index 63e45418129..ad81510fb82 100755 --- a/tests/fm-decision-hold-lifecycle.test.sh +++ b/tests/fm-decision-hold-lifecycle.test.sh @@ -986,6 +986,150 @@ EOF pass "a channel source with no decision binding closes nothing" } +# An any-origin bound source carries answers whose keys are FULL hold identities, +# so one aggregation surface (the bearings board) can close decisions across +# origins - including identities longer than the old 64-character adapter cap - +# while a key with no -decision- separator (a merge or dispatch instruction) +# feeds nothing, a routed hold stays skipped for the routed close path, and the +# runner's feed seam carries the whole flow with no runner change. +test_any_origin_binding_closes_across_origins() { + local home alpha beta origin feedback out show long_key long_id overlong_key rc + home=$(make_home any-origin-board) + alpha=sample-alpha-review + beta=sample-instruction-layer-refinement-review + for origin in "$alpha" "$beta"; do + mkdir -p "$home/data/$origin" + tasks_in "$home" add "$origin" "Review $origin" --kind scout --repo sample --start >/dev/null \ + || fail "could not create origin $origin" + write_origin_meta "$home" "$origin" + printf 'done: deck ready\n' > "$home/state/$origin.status" + printf '# %s\n\nDecisions remain.\n' "$origin" > "$home/data/$origin/report.md" + done + run_decisions "$home" hold "$alpha" route-choice \ + --title "Captain call: route-choice" --reason "captain route choice pending" --repo sample >/dev/null \ + || fail "could not register the alpha hold" + run_decisions "$home" hold "$alpha" routed-phase \ + --title "Captain call: routed-phase" --reason "captain routed phase pending" --repo sample >/dev/null \ + || fail "could not register the alpha routed hold" + long_key=perishable-first-admission-choice + long_id="$beta-decision-$long_key" + [ "${#long_id}" -ge 81 ] \ + || fail "fixture regression: the full identity must exceed the old 64-char cap (got ${#long_id})" + run_decisions "$home" hold "$beta" "$long_key" \ + --title "Captain call: $long_key" --reason "captain admission choice pending" --repo sample >/dev/null \ + || fail "could not register the beta hold" + run_decisions "$home" complete "$alpha" route-choice routed-phase >/dev/null \ + || fail "completion failed for alpha" + run_decisions "$home" complete "$beta" "$long_key" >/dev/null \ + || fail "completion failed for beta" + tasks_in "$home" add sample-routed-work "Apply the routed phase" \ + --kind ship --repo sample --blocked-by "$alpha-decision-routed-phase" >/dev/null \ + || fail "could not route work behind the alpha routed hold" + + run_decisions "$home" bind board-src --any-origin >/dev/null \ + || fail "could not record the any-origin binding" + [ "$(run_decisions "$home" binding board-src)" = "(any)" ] \ + || fail "the any-origin binding did not resolve to its marker" + + # The captured board answer: two cross-origin full-identity answers, a merge + # instruction with no -decision- separator, a nonexistent identity, an answer + # for the routed hold, a 129-char key over the adapter cap, and a non-slug key. + overlong_key=$(printf 'x%.0s' {1..129}) + feedback="$home/board-feedback.txt" + cat > "$feedback" < "$home/adapter-root/bin/fm-procevent-boardchan.sh" </dev/null \ + || fail "could not register the board fixture source" + PATH="$home/fakebin:$PATH" FM_ROOT_OVERRIDE="$home/adapter-root" FM_HOME="$home" \ + FM_STATE_OVERRIDE="$home/state" FM_DATA_OVERRIDE="$home/data" \ + FM_PROCEVENT_CLAIM_ROOT="$home/procevent-claims" \ + "$ROOT/bin/fm-procevent.sh" start board-src >/dev/null 2>&1 + assert_present "$home/state/procevent-inbox/board-src.1.result" \ + "the board fixture channel captured no result to feed" + assert_absent "$home/state/procevent-inbox/board-src.1.handled" \ + "feeding a captain answer retired the notification firstmate still needs" + + show=$(tasks_in "$home" show "$alpha-decision-route-choice" --full) + assert_contains "$show" "state: done" "the alpha hold stayed open after an any-origin feed" + assert_contains "$show" "Resolution mode: answered" "the alpha hold did not record its close path" + assert_contains "$show" "Decision key: route-choice" \ + "the recorded key is not the hold's own short decision key" + show=$(tasks_in "$home" show "$long_id" --full) + assert_contains "$show" "state: done" "the cross-origin long-identity hold stayed open" + assert_contains "$show" "Answer: perishable-first" \ + "the long-identity hold did not record the captain's actual answer" + show=$(tasks_in "$home" show "$alpha-decision-routed-phase" --full) + assert_contains "$show" "state: queued" "any-origin closure closed a hold that still blocks routed work" + assert_contains "$show" "held: yes" "any-origin closure released a hold that still blocks routed work" + + # Replay through the intake directly: idempotent for closed holds, `skipped:` + # diagnostics for everything the feed must leave alone, nonzero because keys + # were skipped. + set +e + out=$(run_lavish "$home" answers "$feedback" \ + | run_decisions "$home" answers --any-origin \ + --source "the captured result board-src sequence 1" 2>&1) + rc=$? + set -e + [ "$rc" -ne 0 ] || fail "an any-origin run that skipped keys reported success" + assert_contains "$out" "closed: $alpha-decision-route-choice" \ + "replaying an identical any-origin capture was not idempotent: $out" + assert_contains "$out" "closed: $long_id" \ + "replaying the long-identity answer was not idempotent: $out" + assert_contains "$out" "skipped: merge.sample-task (not a full hold identity)" \ + "a merge instruction key was not skipped as a non-identity: $out" + assert_contains "$out" "skipped: $alpha-decision-ghost" \ + "a nonexistent identity was not reported skipped: $out" + assert_contains "$out" "skipped: $alpha-decision-routed-phase" \ + "the routed hold was not reported skipped: $out" + assert_contains "$out" "origin=(any)" "the summary line did not name the any-origin marker: $out" + + printf 'Captain chose the routed phase.\n' > "$home/routed-phase-decision.txt" + run_decisions "$home" resolve "$alpha" routed-phase \ + --decision-file "$home/routed-phase-decision.txt" --routed-to sample-routed-work >/dev/null \ + || fail "the routed close path stopped working after any-origin closure" + run_decisions "$home" verify "$alpha" >/dev/null \ + || fail "alpha's answered decisions did not satisfy the completion gate" + run_decisions "$home" verify "$beta" >/dev/null \ + || fail "beta's answered decision did not satisfy the completion gate" + pass "an any-origin bound source closes full-identity holds across origins" +} + # The answer verb is the hold ledger's answer-time closure primitive, so it must # carry every guard the unrouted close path already had. Weakening any of them to # reach closure would trade the loss this fixes for a worse one. @@ -1129,5 +1273,6 @@ test_secondmate_hold_stays_in_authoritative_home test_resolve_matches_quoted_blocked_by_edges test_bound_channel_answers_close_their_holds_at_answer_time test_unbound_source_closes_no_hold +test_any_origin_binding_closes_across_origins test_answer_preserves_every_unrouted_close_guard test_chat_channel_feeds_the_same_keyed_answer_intake From b96dba1babba971cda538751164990a2d8efa623 Mon Sep 17 00:00:00 2001 From: Kun Chen <3233006+kunchenguid@users.noreply.github.com> Date: Thu, 20 Aug 2026 23:28:39 -0700 Subject: [PATCH 198/250] fix(bearings): restore decision options and add close controls (#2707) * fix(bearings): always show decision options and a close/drop control Freeform-only Captain's Call cards hid the option buttons the board was designed around, and there was no way to drop a stale hold without inventing an answer. Require selectable options, keep freeform as a supplement, and route the reserved __drop__ answer through decline so the hold leaves Captain's Call. * no-mistakes(review): Fix drop closure and decision-only option validation * no-mistakes(review): Preserve answerability for non-decision cards * no-mistakes(document): Clarify decision drop documentation --- .agents/skills/bearings/SKILL.md | 8 +- .../bearings/assets/board-template.html | 46 +++-- .../skills/decision-hold-lifecycle/SKILL.md | 1 + bin/fm-bearings-board.sh | 15 +- bin/fm-decision-hold.sh | 98 +++++++---- docs/decision-hold-lifecycle.md | 26 ++- tests/fm-bearings-board.test.sh | 158 ++++++++++++++++++ 7 files changed, 303 insertions(+), 49 deletions(-) diff --git a/.agents/skills/bearings/SKILL.md b/.agents/skills/bearings/SKILL.md index 5f375dab2e3..44199d0b55d 100644 --- a/.agents/skills/bearings/SKILL.md +++ b/.agents/skills/bearings/SKILL.md @@ -77,6 +77,8 @@ Compose the payload from the same snapshot with the same ranking judgment as the - A Captain's Call decision key is the FULL hold identity from `decisions_open`; a merge card's key is `merge.`; the Charted Next dispatch picker's key is `dispatch.charted`. - Decision cards carry agent-authored copy: a short noun-phrase title, one-line `about` and `decide` context rows, and option labels with hints, with the recommended option marked. +- Every decision card must include at least one selectable option, and the board always renders freeform as a supplementary "something else" input, never the only control. +- Do not use `__drop__` as an option value: that reserved answer is the card's Close / drop control, recognized by the keyed-answer intake as a decline. - Every Captain's Call item and every Underway, Recently Landed, and Charted Next row carries an explicit `repo` field. Fill it from the snapshot and task records wherever known; use null or an empty string only as the deliberate genuinely-no-repo marker, in which case the template may show the internal id. Ids otherwise stay in the payload only as the routing channel, and composed reasons name blockers in plain words. Run `build` once after composing the payload. @@ -87,7 +89,11 @@ Never run `lavish-axi poll` for the board yourself: the armed source's supervise ### Handling a board wake A board answer arrives as an ordinary `procevent lavish ` check wake. Identify it by comparing the wake source id with `bin/fm-procevent-lavish.sh source-id "$(bin/fm-bearings-board.sh path)"`, regardless of which answer kinds the result contains; then load `process-event-sources` and follow its contract for the result read, adapter classification, and the handled acknowledgement. -Decision answers need no routing from you: the runner feeds the board's any-origin binding into `bin/fm-decision-hold.sh`'s one keyed-answer intake, which closes each full-identity hold at answer time; reconcile any `skipped:` key yourself, using `resolve` when routed work exists. +Decision answers need no routing from you: the runner feeds the board's any-origin binding into `bin/fm-decision-hold.sh`'s one keyed-answer intake, which closes each full-identity hold at answer time. +A reserved `__drop__` answer is the captain closing or dropping that hold, not a substantive choice and not a merge. +The intake declines it through `bin/fm-decision-hold.sh` with a "dropped by captain" decision record, so the hold leaves Captain's Call on the next rebuild. +Existing work routed behind that hold remains independent queued work; dropping does not close those dependents. +Reconcile any other `skipped:` key yourself, using `resolve` when routed work exists. Route the non-decision keys yourself: - `merge.` is the captain's explicit merge order; follow the merge ruling below. diff --git a/.agents/skills/bearings/assets/board-template.html b/.agents/skills/bearings/assets/board-template.html index c768f4d3466..f31463f074a 100644 --- a/.agents/skills/bearings/assets/board-template.html +++ b/.agents/skills/bearings/assets/board-template.html @@ -91,6 +91,8 @@ .fm-btn--primary:hover { background: var(--rust-600); } .fm-btn--gold { background: var(--gold-500); color: var(--navy-700); border-color: var(--ink-900); box-shadow: var(--shadow-hard-sm); } .fm-btn--gold:hover { background: var(--gold-600); color: var(--white); } +.fm-btn--ghost { background: transparent; color: var(--text-muted); border-color: var(--border-default); box-shadow: none; } +.fm-btn--ghost:hover { background: var(--paper-100); color: var(--text-strong); border-color: var(--ink-300); } .fm-btn[disabled] { opacity: 0.5; cursor: not-allowed; } /* ---- fm-card ---- */ @@ -219,7 +221,9 @@ letter-spacing: 0.07em; color: var(--navy-700); background: var(--gold-300); border: 1px solid var(--gold-600); border-radius: var(--radius-xs); padding: 3px 7px 2px; } .bb-opt:has(input:checked) { border-color: var(--rust-500); background: var(--rust-050); box-shadow: inset 0 0 0 1px var(--rust-500); } -.bb-decision__foot { display: flex; align-items: center; gap: 10px; margin-top: auto; } +.bb-decision__foot { display: flex; align-items: center; gap: 10px; margin-top: auto; flex-wrap: wrap; } +.bb-drop { margin-left: auto; } +.is-queued .bb-drop { display: none; } .bb-queued { display: none; align-items: center; gap: 6px; font-size: var(--fs-2xs); font-weight: 800; text-transform: uppercase; letter-spacing: 0.07em; @@ -517,10 +521,14 @@ }); form.appendChild(opts); - if (item.allow_freeform) { + /* Decision cards always keep a supplementary "something else" box; merge + and credential cards keep the payload's allow_freeform flag. */ + if (item.type === "decision" || item.allow_freeform) { var ff = document.createElement("input"); ff.type = "text"; ff.name = "note"; ff.className = "bb-freeform"; - ff.placeholder = item.freeform_hint || "or answer in your own words…"; + ff.placeholder = item.freeform_hint || (item.type === "decision" + ? "or something else…" + : "or answer in your own words…"); form.appendChild(ff); } @@ -534,16 +542,18 @@ var answerLimit = el("span", "bb-limit"); answerLimit.setAttribute("role", "alert"); foot.appendChild(answerLimit); + var dropBtn = null; + if (item.type === "decision") { + dropBtn = el("button", "fm-btn fm-btn--sm fm-btn--ghost bb-drop", "Close / drop"); + dropBtn.type = "button"; + dropBtn.setAttribute("aria-label", "Close or drop this decision"); + foot.appendChild(dropBtn); + } form.appendChild(foot); - form.addEventListener("submit", function (ev) { - ev.preventDefault(); + function queueAnswer(answer) { + if (card.classList.contains("is-queued")) return; answerLimit.classList.remove("is-visible"); - var fd = new FormData(form); - var value = fd.get("answer"); - var note = (fd.get("note") || "").trim(); - /* picked option, optionally annotated; a bare note is itself the answer */ - var answer = value ? (note ? value + " - " + note : value) : note; if (!answer) return; if (utf8ByteLength(answer) > 512) { answerLimit.textContent = "Answer is too long to queue (512 bytes maximum)."; @@ -560,7 +570,23 @@ card.classList.add("is-queued"); /* deal the next card once this one is answered */ setTimeout(function () { showCard(active < cards.length - 1 ? active + 1 : active); }, 450); + } + + form.addEventListener("submit", function (ev) { + ev.preventDefault(); + var fd = new FormData(form); + var value = fd.get("answer"); + var note = (fd.get("note") || "").trim(); + /* picked option, optionally annotated; a bare note is itself the answer */ + var answer = value ? (note ? value + " - " + note : value) : note; + queueAnswer(answer); }); + if (dropBtn) { + dropBtn.addEventListener("click", function () { + /* reserved close/drop encoding; the keyed-answer intake declines it */ + queueAnswer("__drop__"); + }); + } pad.appendChild(form); card.appendChild(pad); diff --git a/.agents/skills/decision-hold-lifecycle/SKILL.md b/.agents/skills/decision-hold-lifecycle/SKILL.md index dcb1eeb8a87..04a78f6b2a8 100644 --- a/.agents/skills/decision-hold-lifecycle/SKILL.md +++ b/.agents/skills/decision-hold-lifecycle/SKILL.md @@ -25,6 +25,7 @@ When the captain's answer authorizes follow-up work, the hold remains the author When the captain's answer routes no follow-up work at all, such as a declined proposal, `bin/fm-decision-hold.sh decline` records that answer and closes the hold; it never substitutes for routing work the captain did authorize. When the captain simply answers a hold that has no follow-up work routed behind it yet, `bin/fm-decision-hold.sh answer` records that answer and closes the hold, so answering is closing rather than a separate later act that can be forgotten. "A keyed answer closes its matching hold" is one capability with one owner, `bin/fm-decision-hold.sh answers`, and every channel that carries a captain answer feeds it the same `` and answer. +The exact answer `__drop__` is the reserved close/drop encoding owned by that script's header: the intake declines the hold with a dropped-by-captain record rather than recording a substantive answer, and closes only that hold while existing dependents remain independent queued work. A channel never maps a key to a hold, records a decision, or closes anything itself, so no channel is special and a new one needs no new closing logic. Chat already feeds it: `bin/fm-send.sh --resolve-key` answers a decision in whichever ledger still holds it open, including a decision already transferred to its durable hold. A captured-answer source feeds it too once bound with `bin/fm-decision-hold.sh bind `, or with `--any-origin` for a source that carries answers across origins, such as the bearings board; bind before arming the source, and key each structured question by the hold's own decision key, or by its full hold identity under an any-origin binding. diff --git a/bin/fm-bearings-board.sh b/bin/fm-bearings-board.sh index 008b714b805..fd336763aed 100755 --- a/bin/fm-bearings-board.sh +++ b/bin/fm-bearings-board.sh @@ -32,8 +32,13 @@ # Captain's Call item explicitly carries `repo`; the composer fills it from the # snapshot and task records wherever known, and uses null or an empty string # only as the deliberate genuinely-no-repo marker. In that exceptional case -# the template may display the routing id. Anything else refuses before the -# existing board is touched. +# the template may display the routing id. Decision cards must include at least +# one selectable option; every other Captain's Call item must either include an +# option or explicitly allow freeform input. Option values cannot +# be `__drop__`: that reserved answer is the board Close / drop encoding, +# recognized by fm-decision-hold.sh's keyed-answer intake as a decline rather +# than a substantive choice. Anything else refuses before the existing board +# is touched. # # The board path is stable - $FM_HOME/.lavish/bearings-board.html - so a # re-invocation rebuilds the same file in place, which keeps the same Lavish @@ -85,10 +90,14 @@ validate_payload() { # and repo_marker and (.title | nonempty_string) and (.options | type == "array") - and ((.options | length) > 0 or .allow_freeform == true) + and (if .type == "decision" + then (.options | length) > 0 + else ((.options | length) > 0 or .allow_freeform == true) + end) and ([.options[] | type == "object" and (.value | slug(128)) + and .value != "__drop__" and (.label | nonempty_string) and optional_string("hint")] | all) and (optional_string("about")) diff --git a/bin/fm-decision-hold.sh b/bin/fm-decision-hold.sh index 1e637de0151..794336b2325 100755 --- a/bin/fm-decision-hold.sh +++ b/bin/fm-decision-hold.sh @@ -59,20 +59,24 @@ # separate later call nobody is forced to make. It records the captain's answer # on an actively held hold, records `(none)` as the routed identities because no # follow-up work has been routed behind the hold yet, and closes it. It shares -# every guard `decline` has, including the refusal while any task is still -# blocked by the hold, so a decision whose follow-up work is already routed still -# goes through `resolve` and the routed-vs-unrouted distinction survives. It says -# only that the captain answered; `decline` still says the captain answered with -# no follow-up work at all. +# every guard the public `decline` path has, including the refusal while any task +# is still blocked by the hold, so a substantive decision whose follow-up work is +# already routed still goes through `resolve` and the routed-vs-unrouted +# distinction survives. It says only that the captain answered; an ordinary +# `decline` still says the captain answered with no follow-up work at all. The +# reserved keyed-answer drop described below is the sole internal exception. # # ONE KEYED-ANSWER INTAKE, FED BY EVERY CHANNEL. # "A keyed answer closes its matching hold" is a single capability, owned here # and nowhere else. `answers` is its channel-agnostic entry point: it reads # `\t\t