From 1283cf62e327eac86288719a11c99d92b67b1aa8 Mon Sep 17 00:00:00 2001 From: Freudator86 <94322668+Freudator86@users.noreply.github.com> Date: Tue, 28 Jul 2026 03:19:58 +0000 Subject: [PATCH 1/8] fix(axi): isolate installs per vessel home --- .gitignore | 1 + AGENTS.md | 1 + README.md | 9 +- bin/fm-axi-path-lib.sh | 31 ++++++ bin/fm-axi-suite.sh | 125 ++++++++++++++++++++---- bin/fm-backlog-handoff.sh | 3 + bin/fm-bootstrap.sh | 13 ++- bin/fm-decision-hold.sh | 3 + bin/fm-dispatch-select.sh | 7 ++ bin/fm-pr-merge.sh | 3 + bin/fm-session-start.sh | 3 + bin/fm-spawn.sh | 8 ++ bin/fm-teardown.sh | 3 + bin/fm-test-run.sh | 1 + docs/configuration.md | 16 ++- docs/scripts.md | 1 + tests/fm-axi-suite.test.sh | 125 ++++++++++++++++++++++-- tests/fm-bootstrap.test.sh | 53 ++++++++-- tests/fm-gotmp.test.sh | 2 + tests/fm-runtime-ignore.test.sh | 1 + tests/fm-spawn-dispatch-profile.test.sh | 9 ++ 21 files changed, 376 insertions(+), 42 deletions(-) create mode 100755 bin/fm-axi-path-lib.sh diff --git a/.gitignore b/.gitignore index 3ba90241a2..82f7a5355f 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ state/ data/ .no-mistakes/ .lavish/ +.local/axi/ .fm-secondmate-home .DS_Store __pycache__/ diff --git a/AGENTS.md b/AGENTS.md index 82bede5210..902da9e5fc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -104,6 +104,7 @@ data/ personal fleet records; LOCAL, gitignored as a whole /panel.meta model-panel record written by bin/fm-model-panel.sh: form, project, question pointer, stage, member task ids, and each role's resolved profile /question.md the one question text every member of that panel was given, written by bin/fm-model-panel.sh; survives teardown like the reports projects/ cloned repos; gitignored; READ-ONLY for you +.local/axi/ home-private npm prefix for this vessel's managed AXI CLI suite; gitignored graphify-out/ local Graphify knowledge graph artifacts; gitignored, expected to drift after hook or incremental updates state/ volatile runtime signals; gitignored .status appended by crewmates: ": " wake-event lines, not current-state truth diff --git a/README.md b/README.md index 70a995d81f..ff66425bd2 100644 --- a/README.md +++ b/README.md @@ -86,21 +86,24 @@ Then launch one of the co-primary harnesses; AGENTS.md takes over from there: **Claude Code** ```sh -claude +PATH="$PWD/.local/axi/bin:$PATH" claude ``` **Grok** ```sh -grok --trust +PATH="$PWD/.local/axi/bin:$PATH" grok --trust ``` **Pi** ```sh -pi +PATH="$PWD/.local/axi/bin:$PATH" pi ``` +The prefix directory may be absent on first launch; keeping it first from process start makes the vessel-owned AXI copies take precedence as soon as bootstrap installs them. +When `FM_HOME` differs from the checkout root, prepend `$FM_HOME/.local/axi/bin` instead. +Codex and OpenCode use the same PATH assignment before their `codex` or `opencode` launch command. For Grok, `--trust` is needed once per clone so project hooks and the turn-end guard load; `/hooks-trust` inside Grok works too. For Pi, approve the project trust prompt once per clone on first launch so the tracked `.pi/extensions/*.ts` files auto-load. Every Pi session starts with calm mode off; `/calm` is a session-local conversation-focused transcript toggle. diff --git a/bin/fm-axi-path-lib.sh b/bin/fm-axi-path-lib.sh new file mode 100755 index 0000000000..bcc47e1ebe --- /dev/null +++ b/bin/fm-axi-path-lib.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +# Resolve the npm prefix owned by one Firstmate operational home. +# +# Usage: +# . bin/fm-axi-path-lib.sh +# fm_axi_prefix [] # prints /.local/axi +# fm_axi_bin_dir [] # prints /.local/axi/bin +# fm_axi_prepend_path [] # exports that bin directory first on PATH +# +# The location is derived only from FM_HOME, so a fresh vessel needs no local +# configuration before bootstrap can install its AXI suite. + +fm_axi_prefix() { + local home=${1:-${FM_HOME:-}} + [ -n "$home" ] || return 1 + printf '%s/.local/axi\n' "${home%/}" +} + +fm_axi_bin_dir() { + printf '%s/bin\n' "$(fm_axi_prefix "${1:-${FM_HOME:-}}")" +} + +fm_axi_prepend_path() { + local bin + bin=$(fm_axi_bin_dir "${1:-${FM_HOME:-}}") || return 1 + case "${PATH:-}" in + "$bin"|"$bin":*) ;; + *) PATH="$bin${PATH:+:$PATH}" ;; + esac + export PATH +} diff --git a/bin/fm-axi-suite.sh b/bin/fm-axi-suite.sh index 88f333a772..ca3b53a874 100755 --- a/bin/fm-axi-suite.sh +++ b/bin/fm-axi-suite.sh @@ -8,12 +8,17 @@ # AXI_SUITE_STUCK: ... retry pending instead). # --force run the check now regardless of the cadence stamp. # -# The default cadence is once every 24 hours per FM_HOME. Patch and minor -# releases are installed into the same npm prefix as the command currently on -# PATH. Major releases and missing suite commands are reported for review and -# never installed. A failed registry lookup or update is recorded in +# The default cadence is once every 24 hours per FM_HOME. Every home owns the +# npm prefix $FM_HOME/.local/axi, whose bin directory resolves before inherited +# PATH entries. A normal check seeds an absent home copy from the installed +# version on PATH, or directly installs an eligible patch/minor release there. +# Existing external installs are fallback inputs only and are never modified or +# removed. Major releases and missing suite commands are reported for review +# and never installed. A failed registry lookup or update is recorded in # state/axi-suite-update.stuck and reported on every later invocation until a -# successful check clears it. +# successful check clears it. state/axi-suite-prefix-v1.ready records that every +# installed suite command has a vessel copy, so a pre-cutover cadence stamp can +# never postpone the first isolated install. # # FM_AXI_SUITE_CHECK_INTERVAL overrides the cadence in seconds. Set it to 0 to # check every time. FM_AXI_SUITE_DISABLE=1 disables the mechanism (tests and @@ -29,6 +34,11 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd "$SCRIPT_DIR/.." && pwd)}" FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}" +# shellcheck source=bin/fm-axi-path-lib.sh +. "$SCRIPT_DIR/fm-axi-path-lib.sh" +AXI_PREFIX=$(fm_axi_prefix "$FM_HOME") +AXI_BIN=$(fm_axi_bin_dir "$FM_HOME") +fm_axi_prepend_path "$FM_HOME" INTERVAL=${FM_AXI_SUITE_CHECK_INTERVAL:-86400} CHECK_ONLY=0 FORCE=0 @@ -104,13 +114,16 @@ is_hook_tool() { } install_hint() { - local tool=$1 spec=$2 hint - hint="npm install -g $spec" - is_hook_tool "$tool" && hint="$hint && $tool setup hooks" + local tool=$1 spec=$2 hint quoted_prefix quoted_tool + printf -v quoted_prefix '%q' "$AXI_PREFIX" + printf -v quoted_tool '%q' "$AXI_BIN/$tool" + hint="npm install -g --prefix $quoted_prefix $spec" + is_hook_tool "$tool" && hint="$hint && $quoted_tool setup hooks" printf '%s' "$hint" } STAMP="$STATE/axi-suite-update.checked" +PREFIX_READY="$STATE/axi-suite-prefix-v1.ready" DIAGNOSTICS="$STATE/axi-suite-update.diagnostics" STUCK="$STATE/axi-suite-update.stuck" @@ -120,7 +133,7 @@ emit_cached() { } now=$(date +%s 2>/dev/null || echo 0) -if [ "$FORCE" -ne 1 ] && [ -f "$STAMP" ]; then +if [ "$FORCE" -ne 1 ] && [ -f "$STAMP" ] && [ -f "$PREFIX_READY" ]; then checked=$(cat "$STAMP" 2>/dev/null || echo 0) case "$checked" in ''|*[!0-9]*) checked=0 ;; esac if [ "$now" -ge "$checked" ] && [ $((now - checked)) -lt "$INTERVAL" ]; then @@ -157,11 +170,38 @@ version_gt() { [ "$a3" -gt "$b3" ] 2>/dev/null } -npm_prefix_for() { - local command_path bin_dir - command_path=$(command -v "$1") || return 1 - bin_dir=$(dirname "$command_path") - dirname "$bin_dir" +home_tool_path() { + printf '%s/%s\n' "$AXI_BIN" "$1" +} + +install_home_tool() { + local tool=$1 spec=$2 expected installed + expected=$3 + if ! net_call npm install -g --prefix "$AXI_PREFIX" "$spec" >/dev/null 2>&1; then + return 1 + fi + installed=$(version_of "$(home_tool_path "$tool")") + [ "$installed" = "$expected" ] +} + +setup_home_hooks() { + net_call "$(home_tool_path "$1")" setup hooks >/dev/null 2>&1 +} + +seed_home_tool() { + local tool=$1 version=$2 + if ! install_home_tool "$tool" "$tool@$version" "$version"; then + printf 'AXI_SUITE_STUCK: %s vessel-prefix installation at %s failed\n' \ + "$tool" "$AXI_PREFIX" >> "$tmp_stuck" + return 1 + fi + if is_hook_tool "$tool" && ! setup_home_hooks "$tool"; then + printf 'AXI_SUITE_STUCK: %s installed at %s but hook setup failed\n' \ + "$tool" "$AXI_PREFIX" >> "$tmp_stuck" + return 1 + fi + printf 'AXI_SUITE_UPDATED: %s %s installed in vessel prefix\n' \ + "$tool" "$version" >> "$tmp_diag" } for tool in $SUITE; do @@ -183,33 +223,66 @@ for tool in $SUITE; do printf 'AXI_SUITE_STUCK: %s registry returned an invalid version\n' "$tool" >> "$tmp_stuck" continue fi + home_tool=$(home_tool_path "$tool") + home_installed=0 + [ -x "$home_tool" ] && home_installed=1 if [ "$installed" = "$latest" ]; then + if [ "$home_installed" -eq 0 ]; then + if [ "$CHECK_ONLY" -eq 1 ]; then + printf 'AXI_SUITE_UPDATE: %s %s needs vessel-prefix installation\n' \ + "$tool" "$installed" >> "$tmp_diag" + if is_hook_tool "$tool" && grep -q "^AXI_SUITE_STUCK: $tool " "$STUCK" 2>/dev/null; then + printf 'AXI_SUITE_STUCK: %s hook setup retry pending (already at %s)\n' \ + "$tool" "$latest" >> "$tmp_stuck" + fi + else + seed_home_tool "$tool" "$installed" || true + fi + continue + fi if is_hook_tool "$tool" && grep -q "^AXI_SUITE_STUCK: $tool " "$STUCK" 2>/dev/null; then if [ "$CHECK_ONLY" -eq 1 ]; then printf 'AXI_SUITE_STUCK: %s hook setup retry pending (already at %s)\n' "$tool" "$latest" >> "$tmp_stuck" - elif ! net_call "$tool" setup hooks >/dev/null 2>&1; then + elif ! setup_home_hooks "$tool"; then printf 'AXI_SUITE_STUCK: %s hook setup failed (already at %s)\n' "$tool" "$latest" >> "$tmp_stuck" fi fi continue fi - version_gt "$installed" "$latest" && continue + if version_gt "$installed" "$latest"; then + if [ "$home_installed" -eq 0 ]; then + if [ "$CHECK_ONLY" -eq 1 ]; then + printf 'AXI_SUITE_UPDATE: %s %s needs vessel-prefix installation\n' \ + "$tool" "$installed" >> "$tmp_diag" + else + seed_home_tool "$tool" "$installed" || true + fi + fi + continue + fi if [ "$(major_of "$installed")" != "$(major_of "$latest")" ]; then printf 'AXI_SUITE_REVIEW: %s major update %s -> %s (install: %s)\n' \ "$tool" "$installed" "$latest" "$(install_hint "$tool" "$tool@$latest")" >> "$tmp_diag" + if [ "$home_installed" -eq 0 ]; then + if [ "$CHECK_ONLY" -eq 1 ]; then + printf 'AXI_SUITE_UPDATE: %s %s needs vessel-prefix installation\n' \ + "$tool" "$installed" >> "$tmp_diag" + else + seed_home_tool "$tool" "$installed" || true + fi + fi continue fi [ "$CHECK_ONLY" -eq 0 ] || { printf 'AXI_SUITE_UPDATE: %s %s -> %s is eligible for automatic update\n' "$tool" "$installed" "$latest" >> "$tmp_diag" continue } - prefix=$(npm_prefix_for "$tool") - if ! net_call npm install -g --prefix "$prefix" "$tool@$latest" >/dev/null 2>&1; then + if ! install_home_tool "$tool" "$tool@$latest" "$latest"; then printf 'AXI_SUITE_STUCK: %s automatic update %s -> %s failed (npm prefix %s)\n' \ - "$tool" "$installed" "$latest" "$prefix" >> "$tmp_stuck" + "$tool" "$installed" "$latest" "$AXI_PREFIX" >> "$tmp_stuck" continue fi - if is_hook_tool "$tool" && ! net_call "$tool" setup hooks >/dev/null 2>&1; then + if is_hook_tool "$tool" && ! setup_home_hooks "$tool"; then printf 'AXI_SUITE_STUCK: %s updated to %s but hook setup failed\n' "$tool" "$latest" >> "$tmp_stuck" continue fi @@ -225,6 +298,18 @@ if [ "$CHECK_ONLY" -eq 0 ]; then fi if [ -s "$tmp_stuck" ]; then cp "$tmp_stuck" "$STUCK"; else rm -f "$STUCK"; fi printf '%s\n' "$now" > "$STAMP" + prefix_ready=1 + for tool in $SUITE; do + if command -v "$tool" >/dev/null 2>&1 && [ ! -x "$(home_tool_path "$tool")" ]; then + prefix_ready=0 + break + fi + done + if [ "$prefix_ready" -eq 1 ]; then + : > "$PREFIX_READY" + else + rm -f "$PREFIX_READY" + fi fi cat "$tmp_diag" 2>/dev/null cat "$tmp_stuck" 2>/dev/null diff --git a/bin/fm-backlog-handoff.sh b/bin/fm-backlog-handoff.sh index 66da67b8a8..3515c3d2cd 100755 --- a/bin/fm-backlog-handoff.sh +++ b/bin/fm-backlog-handoff.sh @@ -50,6 +50,9 @@ FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd "$SCRIPT_DIR/.." && pwd)}" FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" DATA="${FM_DATA_OVERRIDE:-$FM_HOME/data}" REG="$DATA/secondmates.md" +# shellcheck source=bin/fm-axi-path-lib.sh disable=SC1091 +. "$SCRIPT_DIR/fm-axi-path-lib.sh" +fm_axi_prepend_path "$FM_HOME" MAIN_BACKLOG="$DATA/backlog.md" # shellcheck source=bin/fm-tasks-axi-lib.sh disable=SC1091 . "$SCRIPT_DIR/fm-tasks-axi-lib.sh" diff --git a/bin/fm-bootstrap.sh b/bin/fm-bootstrap.sh index 1c33141f3b..f9237e85ac 100755 --- a/bin/fm-bootstrap.sh +++ b/bin/fm-bootstrap.sh @@ -102,6 +102,9 @@ PROJECTS="${FM_PROJECTS_OVERRIDE:-$FM_HOME/projects}" CONFIG="${FM_CONFIG_OVERRIDE:-$FM_HOME/config}" STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}" DATA="${FM_DATA_OVERRIDE:-$FM_HOME/data}" +# shellcheck source=bin/fm-axi-path-lib.sh disable=SC1091 +. "$SCRIPT_DIR/fm-axi-path-lib.sh" +fm_axi_prepend_path "$FM_HOME" # shellcheck source=bin/fm-tasks-axi-lib.sh disable=SC1091 . "$SCRIPT_DIR/fm-tasks-axi-lib.sh" # shellcheck source=bin/fm-tangle-lib.sh disable=SC1091 @@ -505,13 +508,18 @@ secondmate_liveness_sweep() { } install_cmd() { + local prefix tool_bin + prefix=$(fm_axi_prefix "$FM_HOME") case "$1" in tmux|node|git|gh|curl|jq|orca|zellij) echo "brew install $1 # or the platform's package manager" ;; cmux) echo "brew install --cask cmux # or see https://cmux.com" ;; treehouse) echo "curl -fsSL https://kunchenguid.github.io/treehouse/install.sh | sh" ;; no-mistakes) echo "curl -fsSL https://raw.githubusercontent.com/kunchenguid/no-mistakes/main/docs/install.sh | sh" ;; - gh-axi|chrome-devtools-axi|lavish-axi) echo "npm install -g $1 && $1 setup hooks" ;; - tasks-axi|quota-axi) echo "npm install -g $1" ;; + gh-axi|chrome-devtools-axi|lavish-axi) + tool_bin=$(fm_axi_bin_dir "$FM_HOME")/$1 + printf 'npm install -g --prefix %q %q && %q setup hooks\n' "$prefix" "$1" "$tool_bin" + ;; + tasks-axi|quota-axi) printf 'npm install -g --prefix %q %q\n' "$prefix" "$1" ;; *) return 1 ;; esac } @@ -928,6 +936,7 @@ if [ "${FM_BOOTSTRAP_VERBOSE_FACTS:-0}" = 1 ] \ fi if [ "${FM_BOOTSTRAP_DETECT_ONLY:-0}" != 1 ]; then "$SCRIPT_DIR/fm-axi-suite.sh" + hash -r secondmate_sync secondmate_liveness_sweep x_mode_setup diff --git a/bin/fm-decision-hold.sh b/bin/fm-decision-hold.sh index 495b7455a8..a3f7d316dd 100755 --- a/bin/fm-decision-hold.sh +++ b/bin/fm-decision-hold.sh @@ -64,6 +64,9 @@ DATA="${FM_DATA_OVERRIDE:-$FM_HOME/data}" # trusting append order, which is not trustworthy in exactly the hand-edited case # that produces it. ARCHIVE="$DATA/done-archive.md" +# shellcheck source=bin/fm-axi-path-lib.sh +. "$SCRIPT_DIR/fm-axi-path-lib.sh" +fm_axi_prepend_path "$FM_HOME" # shellcheck source=bin/fm-classify-lib.sh # shellcheck disable=SC1091 diff --git a/bin/fm-dispatch-select.sh b/bin/fm-dispatch-select.sh index 5caf59ffa5..d13afcec5e 100755 --- a/bin/fm-dispatch-select.sh +++ b/bin/fm-dispatch-select.sh @@ -36,6 +36,13 @@ # FM_DISPATCH_RANDOM_SOURCE overrides /dev/urandom for deterministic tests only. set -u +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd "$SCRIPT_DIR/.." && pwd)}" +FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" +# shellcheck source=bin/fm-axi-path-lib.sh +. "$SCRIPT_DIR/fm-axi-path-lib.sh" +fm_axi_prepend_path "$FM_HOME" + STALE_CLEAR_MARGIN=${FM_DISPATCH_STALE_CLEAR_MARGIN:-20} SELECT_OVERRIDE= QUOTA_JSON_FILE= diff --git a/bin/fm-pr-merge.sh b/bin/fm-pr-merge.sh index 8226798a67..22a2215d08 100755 --- a/bin/fm-pr-merge.sh +++ b/bin/fm-pr-merge.sh @@ -14,6 +14,9 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd "$SCRIPT_DIR/.." && pwd)}" FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}" +# shellcheck source=bin/fm-axi-path-lib.sh +. "$SCRIPT_DIR/fm-axi-path-lib.sh" +fm_axi_prepend_path "$FM_HOME" # shellcheck source=bin/fm-pr-lib.sh . "$SCRIPT_DIR/fm-pr-lib.sh" diff --git a/bin/fm-session-start.sh b/bin/fm-session-start.sh index b1721857f0..5b23958920 100755 --- a/bin/fm-session-start.sh +++ b/bin/fm-session-start.sh @@ -97,6 +97,9 @@ FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}" DATA="${FM_DATA_OVERRIDE:-$FM_HOME/data}" CONFIG="${FM_CONFIG_OVERRIDE:-$FM_HOME/config}" +# shellcheck source=bin/fm-axi-path-lib.sh +. "$SCRIPT_DIR/fm-axi-path-lib.sh" +fm_axi_prepend_path "$FM_HOME" PRIMARY_HARNESS=$("$SCRIPT_DIR/fm-harness.sh" 2>/dev/null || printf unknown) # shellcheck source=bin/fm-backend.sh diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index 7c5a7c422c..924645a37b 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -130,6 +130,9 @@ esac FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd "$SCRIPT_DIR/.." && pwd)}" FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" +# shellcheck source=bin/fm-axi-path-lib.sh +. "$SCRIPT_DIR/fm-axi-path-lib.sh" +fm_axi_prepend_path "$FM_HOME" STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}" DATA="${FM_DATA_OVERRIDE:-$FM_HOME/data}" PROJECTS="${FM_PROJECTS_OVERRIDE:-$FM_HOME/projects}" @@ -1326,6 +1329,9 @@ sq_piext=$(shell_quote "$STATE/$ID.pi-ext.ts") sq_piturnend=$(shell_quote "$PROJ_ABS/.pi/extensions/fm-primary-turnend-guard.ts") sq_piwatch=$(shell_quote "$PROJ_ABS/.pi/extensions/fm-primary-pi-watch.ts") sq_opinput=$(shell_quote "$FM_ROOT/bin/fm-operational-input.sh") +AXI_LAUNCH_HOME=$FM_HOME +[ "$KIND" != secondmate ] || AXI_LAUNCH_HOME=$PROJ_ABS +sq_axi_bin=$(shell_quote "$(fm_axi_bin_dir "$AXI_LAUNCH_HOME")") PIBRIEFENV= [ "$HARNESS" != pi ] || PIBRIEFENV="FM_FIRSTMATE_PI_LAUNCH_BRIEF=$sq_brief" LAUNCH=${LAUNCH//__MODELFLAG__/$MODELFLAG} @@ -1348,6 +1354,8 @@ fi # the env is set when the agent starts; the brief sleep lets the export land. spawn_send_text_line "$T" "export GOTMPDIR=$TASK_TMP/gotmp" sleep 0.3 +spawn_send_text_line "$T" "export PATH=$sq_axi_bin:\$PATH" +sleep 0.3 spawn_send_literal "$T" "$LAUNCH" sleep 0.3 if [ "${HERDR_PROJECTED:-0}" -eq 1 ]; then diff --git a/bin/fm-teardown.sh b/bin/fm-teardown.sh index 270e88c9b0..b8839f66d4 100755 --- a/bin/fm-teardown.sh +++ b/bin/fm-teardown.sh @@ -96,6 +96,9 @@ DATA="${FM_DATA_OVERRIDE:-$FM_HOME/data}" CONFIG="${FM_CONFIG_OVERRIDE:-$FM_HOME/config}" SECONDMATE_REG="$DATA/secondmates.md" SUB_HOME_MARKER=".fm-secondmate-home" +# shellcheck source=bin/fm-axi-path-lib.sh +. "$SCRIPT_DIR/fm-axi-path-lib.sh" +fm_axi_prepend_path "$FM_HOME" # shellcheck source=bin/fm-tasks-axi-lib.sh . "$SCRIPT_DIR/fm-tasks-axi-lib.sh" # shellcheck source=bin/fm-backend.sh diff --git a/bin/fm-test-run.sh b/bin/fm-test-run.sh index 26db1b4ab3..69254ab36f 100755 --- a/bin/fm-test-run.sh +++ b/bin/fm-test-run.sh @@ -689,6 +689,7 @@ families_for_changed_path() { bin/fm-brief.sh|bin/fm-ensure-agents-md.sh|bin/fm-crew-state.sh|bin/fm-model-panel.sh|\ bin/fm-decision-hold.sh|bin/fm-supervision*|bin/fm-transition-lib.sh|\ bin/fm-tmux-lib.sh|bin/fm-marker-lib.sh|bin/fm-operational-input.sh|bin/fm-tasks-axi-lib.sh|\ + bin/fm-axi-path-lib.sh|\ bin/fm-primary-scope-lib.sh|bin/fm-project-mode.sh|bin/fm-promote.sh|\ bin/fm-ff-lib.sh|bin/fm-gotmp*|bin/*pretool*) printf '%s\n' pure-contract-unit diff --git a/docs/configuration.md b/docs/configuration.md index 7639d20009..d8a7bac1d6 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -12,6 +12,7 @@ This section is the single owner of the top-level operational-home layout; produ The tracked code root contains the shared instruction, skill, documentation, workflow, and `bin/` surfaces, while each effective `FM_HOME` contains private operational directories. `data/` holds durable private fleet records such as the project and secondmate registries, captain preferences, optional shared captain preferences, learnings, backlog, briefs, and scout reports. `state/` holds volatile runtime records such as task metadata, append-only status events, endpoint signals, watcher and wake-queue coordination, away-mode state, generated X-mode artifacts, private secondmate config-reread generations with their retry and quarantine state, and parent-owned secondmate pending-reply records under `state/pending-replies/` (`bin/fm-pending-reply-lib.sh`). +`.local/axi/` is the home-private npm prefix for the managed AXI CLI suite. `config/` holds local gitignored operating choices, and `projects/` holds the local project clones that Firstmate reads but changes only through the guarded exceptions in `AGENTS.md`. A home's checkout also accumulates runtime artifacts that a supported harness or firstmate writes into the tracked tree itself: Claude Code's local permissions and settings file plus its scheduler, routine, worktree, checkpoint, mailbox, agent-registry, agent-memory, first-run, and daemon state, and firstmate's generated per-task hook overlay. @@ -397,14 +398,21 @@ Graphify is optional: Codex's PreToolUse hook fails open when `graphify` is abse When `config/crew-dispatch.json` exists, bootstrap also requires `jq` for dispatch profile validation. When X mode is opted in, bootstrap also requires `curl` and `jq` before arming the relay poll shim. `tasks-axi` and `quota-axi` are required bootstrap tools in every profile, the same class as `lavish-axi`. -An absent or incompatible `tasks-axi` reports `MISSING: tasks-axi (install: npm install -g tasks-axi)`; when `config/backlog-backend` is not `manual` and compatible `tasks-axi` is on `PATH`, bootstrap stays silent and firstmate uses its verbs for routine backlog mutations, otherwise it hand-edits `data/backlog.md` until installation is approved and completed. -An absent `quota-axi` reports `MISSING: quota-axi (install: npm install -g quota-axi)`; `bin/fm-dispatch-select.sh` still selects uniformly from the valid candidate array with an OS-backed random source when quota data is unavailable. +An absent or incompatible `tasks-axi` reports `MISSING: tasks-axi` with an install command targeting `$FM_HOME/.local/axi`; when `config/backlog-backend` is not `manual` and compatible `tasks-axi` is on `PATH`, bootstrap stays silent and firstmate uses its verbs for routine backlog mutations, otherwise it hand-edits `data/backlog.md` until installation is approved and completed. +An absent `quota-axi` reports `MISSING: quota-axi` with the same home-owned prefix; `bin/fm-dispatch-select.sh` still selects uniformly from the valid candidate array with an OS-backed random source when quota data is unavailable. ### AXI-suite self-update Locked bootstrap runs `bin/fm-axi-suite.sh` at most once per `FM_AXI_SUITE_CHECK_INTERVAL` for the configured AXI commands. -Patch and minor releases update automatically in the active npm prefix, while major releases and newly required commands emit `AXI_SUITE_REVIEW:` for captain approval. -Successful changes emit `AXI_SUITE_UPDATED:`, and bounded registry, permission, install, or hook failures emit `AXI_SUITE_STUCK:` and persist under `state/` until a successful check clears them. +Each vessel derives its npm prefix as `$FM_HOME/.local/axi` without configuration, so different operational homes never share the updater's write destination. +Firstmate entrypoints put `$FM_HOME/.local/axi/bin` first on `PATH`, and `bin/fm-spawn.sh` exports the owning vessel's bin first for every crewmate while a secondmate launch receives the secondmate home's bin first. +The recommended primary launch commands also prepend that directory before the harness starts, so a bare AXI command resolves the vessel copy whenever it exists and uses an inherited external installation only as the pre-cutover fallback. +On the first normal currency check, an existing external AXI installation is left untouched and its installed version is re-installed into the vessel prefix, or an eligible patch/minor release is installed there directly. +No existing installation is moved or removed. +Patch and minor releases update automatically in the vessel prefix, while major releases and newly required commands emit `AXI_SUITE_REVIEW:` for captain approval. +When a major release is pending and the vessel copy is absent, the updater seeds the currently installed major into the vessel prefix without accepting the major upgrade. +Successful changes emit `AXI_SUITE_UPDATED:`, and bounded registry, permission, install, verification, or hook failures emit `AXI_SUITE_STUCK:` and persist under `state/` until a successful check clears them. +The cadence stamp remains under that vessel's `state/`, but a stamp created before the isolated prefix is ready cannot postpone the one-time cutover; after readiness is recorded, cached invocations skip registry and install work exactly as before. `FM_AXI_SUITE_NETWORK_TIMEOUT` bounds the whole suite check, and `FM_AXI_SUITE_DISABLE` is reserved for tests or emergency diagnosis. ### Upstream firstmate and curated-fork checks diff --git a/docs/scripts.md b/docs/scripts.md index a5898d90b5..d2acf45c88 100644 --- a/docs/scripts.md +++ b/docs/scripts.md @@ -12,6 +12,7 @@ The shared no-mistakes gate refusal for fleet lifecycle entrypoints is summarize | `fm-operational-input.sh` | Construct and parse the canonical cross-language operational-input protocol | | `fm-bootstrap.sh` | Detect toolchain and fleet problems, run the locked session-start sweeps, and install approved tools | | `fm-axi-suite.sh` | Check and gate patch/minor self-updates of the npm-distributed AXI CLI suite | +| `fm-axi-path-lib.sh` | Resolve and prepend one vessel's home-private AXI npm prefix | | `fm-firstmate-update-check.sh` | Read-only check for relevant instruction-surface commits on the source this deployment updates from | | `fm-fork-sync-check.sh` | Detect unabsorbed real-upstream content in the curated fork and prefilter both sides of its commit review | | `fm-lint.sh` | Single owner of firstmate's shell-lint definition: file set, config, and pinned ShellCheck version | diff --git a/tests/fm-axi-suite.test.sh b/tests/fm-axi-suite.test.sh index 91be72e77f..4dd8b6ddf0 100755 --- a/tests/fm-axi-suite.test.sh +++ b/tests/fm-axi-suite.test.sh @@ -38,6 +38,30 @@ if [ "${1:-}" = view ]; then fi if [ "${1:-}" = install ]; then printf '%s\n' "$*" >> "$FM_TEST_INSTALL_LOG" + shift + prefix= + spec= + while [ "$#" -gt 0 ]; do + case "$1" in + --prefix) prefix=${2:-}; shift 2 ;; + -g) shift ;; + *) spec=$1; shift ;; + esac + done + [ -n "$prefix" ] && [ -n "$spec" ] || exit 1 + tool=${spec%@*} + version=${spec##*@} + sleep "${FM_TEST_INSTALL_SLEEP:-0}" + mkdir -p "$prefix/bin" + cat > "$prefix/bin/$tool" <> "\$FM_TEST_HOOK_LOG" + exit 0 +fi +TOOL + chmod +x "$prefix/bin/$tool" exit 0 fi exit 1 @@ -52,6 +76,7 @@ run_case() { PATH="$root/bin:$BASE_PATH" FM_HOME="$root/home" FM_STATE_OVERRIDE="$root/state" \ FM_AXI_SUITE_DISABLE=0 FM_AXI_SUITE_TOOLS="$tools" FM_AXI_SUITE_CHECK_INTERVAL=0 \ FM_TEST_VERSIONS="$root/versions" FM_TEST_INSTALL_LOG="$root/install.log" \ + FM_TEST_HOOK_LOG="$root/hook.log" \ "$ROOT/bin/fm-axi-suite.sh" --force } @@ -67,9 +92,10 @@ test_patch_and_minor_auto_update() { out=$(run_case "$w" "patch-axi minor-axi") assert_contains "$out" 'AXI_SUITE_UPDATED: patch-axi 1.2.3 -> 1.2.4' "patch update was not reported" assert_contains "$out" 'AXI_SUITE_UPDATED: minor-axi 1.2.3 -> 1.3.0' "minor update was not reported" - assert_grep 'patch-axi@1.2.4' "$w/install.log" "patch update was not installed" - assert_grep 'minor-axi@1.3.0' "$w/install.log" "minor update was not installed" - pass "patch and minor AXI-suite releases auto-update" + assert_grep "--prefix $w/home/.local/axi patch-axi@1.2.4" "$w/install.log" "patch update did not target the vessel prefix" + assert_grep "--prefix $w/home/.local/axi minor-axi@1.3.0" "$w/install.log" "minor update did not target the vessel prefix" + [ "$("$w/bin/patch-axi" --version)" = '1.2.3' ] || fail "the pre-existing patch-axi install was modified" + pass "patch and minor AXI-suite releases update only the vessel prefix" } test_major_and_missing_wait_for_review() { @@ -82,8 +108,9 @@ test_major_and_missing_wait_for_review() { out=$(run_case "$w" "major-axi new-axi") assert_contains "$out" 'AXI_SUITE_REVIEW: major-axi major update 1.9.9 -> 2.0.0' "major update was not held" assert_contains "$out" 'AXI_SUITE_REVIEW: new-axi is not installed' "new tool was not held" - [ ! -s "$w/install.log" ] || fail "review-only changes were installed" - pass "major releases and new suite tools wait for review" + assert_grep "--prefix $w/home/.local/axi major-axi@1.9.9" "$w/install.log" "the current major was not seeded into the vessel prefix" + assert_no_grep 'major-axi@2.0.0' "$w/install.log" "the held major release was installed" + pass "major releases wait for review while the current major seeds the vessel prefix" } test_failed_update_persists_stuck_signal() { @@ -152,8 +179,9 @@ exec /usr/bin/sort "$@" SH chmod +x "$w/bin/sort" run_case "$w" "ahead-axi" >/dev/null - [ ! -s "$w/install.log" ] || fail "a locally-ahead tool was downgraded when sort -V is unavailable" - pass "version comparison does not depend on GNU sort -V (locally-ahead tool is left alone)" + assert_grep "--prefix $w/home/.local/axi ahead-axi@2.1.0" "$w/install.log" "the locally-ahead version was not seeded into the vessel prefix" + assert_no_grep 'ahead-axi@2.0.5' "$w/install.log" "a locally-ahead tool was downgraded when sort -V is unavailable" + pass "version comparison keeps a locally-ahead version while seeding the vessel prefix" } test_bounded_kills_hung_call_without_timeout_binary() { @@ -212,8 +240,91 @@ SH pass "an unreachable registry cannot multiply the timeout across every tool in the suite" } +test_two_homes_update_distinct_prefixes_concurrently() { + local a b pid_a pid_b + a="$TMP_ROOT/concurrent-a" + b="$TMP_ROOT/concurrent-b" + for w in "$a" "$b"; do + mkdir -p "$w/bin" "$w/home/state" + make_npm "$w/bin" "$w/versions" "$w/install.log" + make_tool "$w/bin" race-axi 1.0.0 + printf '%s\n' 'race-axi=1.0.1' > "$w/versions" + done + PATH="$a/bin:$BASE_PATH" FM_HOME="$a/home" \ + FM_AXI_SUITE_DISABLE=0 FM_AXI_SUITE_TOOLS=race-axi FM_AXI_SUITE_CHECK_INTERVAL=0 \ + FM_TEST_VERSIONS="$a/versions" FM_TEST_INSTALL_LOG="$a/install.log" \ + FM_TEST_INSTALL_SLEEP=1 "$ROOT/bin/fm-axi-suite.sh" --force > "$a/out" & + pid_a=$! + PATH="$b/bin:$BASE_PATH" FM_HOME="$b/home" \ + FM_AXI_SUITE_DISABLE=0 FM_AXI_SUITE_TOOLS=race-axi FM_AXI_SUITE_CHECK_INTERVAL=0 \ + FM_TEST_VERSIONS="$b/versions" FM_TEST_INSTALL_LOG="$b/install.log" \ + FM_TEST_INSTALL_SLEEP=1 "$ROOT/bin/fm-axi-suite.sh" --force > "$b/out" & + pid_b=$! + wait "$pid_a" || fail "the first concurrent vessel update failed" + wait "$pid_b" || fail "the second concurrent vessel update failed" + assert_grep "--prefix $a/home/.local/axi race-axi@1.0.1" "$a/install.log" "the first vessel did not use its own prefix" + assert_grep "--prefix $b/home/.local/axi race-axi@1.0.1" "$b/install.log" "the second vessel did not use its own prefix" + assert_no_grep "$b/home/.local/axi" "$a/install.log" "the first vessel touched the second vessel prefix" + assert_no_grep "$a/home/.local/axi" "$b/install.log" "the second vessel touched the first vessel prefix" + pass "concurrent vessel updates have disjoint npm write destinations" +} + +test_vessel_prefix_wins_over_inherited_path() { + local w out + w="$TMP_ROOT/path-order" + mkdir -p "$w/bin" "$w/home/.local/axi/bin" "$w/home/state" + make_npm "$w/bin" "$w/versions" "$w/install.log" + make_tool "$w/bin" order-axi 9.9.9 + make_tool "$w/home/.local/axi/bin" order-axi 1.2.3 + printf '%s\n' 'order-axi=1.2.4' > "$w/versions" + out=$(PATH="$w/bin:$w/home/.local/axi/bin:$BASE_PATH" FM_HOME="$w/home" \ + FM_AXI_SUITE_DISABLE=0 FM_AXI_SUITE_TOOLS=order-axi FM_AXI_SUITE_CHECK_INTERVAL=0 \ + FM_TEST_VERSIONS="$w/versions" FM_TEST_INSTALL_LOG="$w/install.log" \ + "$ROOT/bin/fm-axi-suite.sh" --force) + assert_contains "$out" 'AXI_SUITE_UPDATED: order-axi 1.2.3 -> 1.2.4' \ + "the inherited PATH copy won over the vessel copy" + pass "the vessel AXI bin resolves first even when inherited PATH orders it later" +} + +test_currency_clock_survives_prefix_cutover() { + local w first second forced installs_after_first installs_after_second + w="$TMP_ROOT/currency-clock" + mkdir -p "$w/bin" "$w/home/state" + make_npm "$w/bin" "$w/versions" "$w/install.log" + make_tool "$w/bin" clock-axi 1.2.3 + printf '%s\n' 'clock-axi=1.2.4' > "$w/versions" + date +%s > "$w/home/state/axi-suite-update.checked" + first=$(PATH="$w/bin:$BASE_PATH" FM_HOME="$w/home" \ + FM_AXI_SUITE_DISABLE=0 FM_AXI_SUITE_TOOLS=clock-axi FM_AXI_SUITE_CHECK_INTERVAL=86400 \ + FM_TEST_VERSIONS="$w/versions" FM_TEST_INSTALL_LOG="$w/install.log" \ + "$ROOT/bin/fm-axi-suite.sh") + assert_contains "$first" 'AXI_SUITE_UPDATED: clock-axi 1.2.3 -> 1.2.4' \ + "the first currency check did not update the vessel copy" + assert_present "$w/home/state/axi-suite-update.checked" "the per-home currency stamp was not written" + assert_present "$w/home/state/axi-suite-prefix-v1.ready" "the vessel-prefix readiness marker was not written" + installs_after_first=$(wc -l < "$w/install.log" | tr -d ' ') + printf '%s\n' 'clock-axi=1.2.5' > "$w/versions" + second=$(PATH="$w/bin:$BASE_PATH" FM_HOME="$w/home" \ + FM_AXI_SUITE_DISABLE=0 FM_AXI_SUITE_TOOLS=clock-axi FM_AXI_SUITE_CHECK_INTERVAL=86400 \ + FM_TEST_VERSIONS="$w/versions" FM_TEST_INSTALL_LOG="$w/install.log" \ + "$ROOT/bin/fm-axi-suite.sh") + installs_after_second=$(wc -l < "$w/install.log" | tr -d ' ') + [ -z "$second" ] || fail "a cached currency check produced unexpected output: $second" + [ "$installs_after_second" = "$installs_after_first" ] || fail "the cached currency check installed again" + forced=$(PATH="$w/bin:$BASE_PATH" FM_HOME="$w/home" \ + FM_AXI_SUITE_DISABLE=0 FM_AXI_SUITE_TOOLS=clock-axi FM_AXI_SUITE_CHECK_INTERVAL=86400 \ + FM_TEST_VERSIONS="$w/versions" FM_TEST_INSTALL_LOG="$w/install.log" \ + "$ROOT/bin/fm-axi-suite.sh" --force) + assert_contains "$forced" 'AXI_SUITE_UPDATED: clock-axi 1.2.4 -> 1.2.5' \ + "the forced currency check did not read and update the vessel copy" + pass "the per-home currency clock still gates and resumes vessel-prefix updates" +} + test_patch_and_minor_auto_update test_major_and_missing_wait_for_review +test_two_homes_update_distinct_prefixes_concurrently +test_vessel_prefix_wins_over_inherited_path +test_currency_clock_survives_prefix_cutover test_failed_update_persists_stuck_signal test_check_only_never_runs_hook_setup test_hook_retry_self_clears_stuck_signal diff --git a/tests/fm-bootstrap.test.sh b/tests/fm-bootstrap.test.sh index fdce67e5cd..4c3cb716db 100755 --- a/tests/fm-bootstrap.test.sh +++ b/tests/fm-bootstrap.test.sh @@ -265,6 +265,7 @@ test_bootstrap_reporting() { # ambient checkout (CI runs on a feature branch) must not leak a TANGLE line in. out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$case_dir/home" FM_ROOT_OVERRIDE="$case_dir/home" \ FM_FAKE_TREEHOUSE_LEASE_HELP="$lease" "$ROOT/bin/fm-bootstrap.sh") + expect=${expect//__AXI_PREFIX__/$case_dir\/home\/.local\/axi} case "$mode" in empty) [ -z "$out" ] || fail "$label: expected silence, got: $out" ;; @@ -281,12 +282,12 @@ test_bootstrap_reporting() { treehouse --lease support is accepted silently^1^0.1.1^1^manual^empty^^ treehouse without --lease reports an upgrade, gh auth is fine^0^0.1.1^1^-^grep^MISSING: treehouse (install: curl -fsSL https://kunchenguid.github.io/treehouse/install.sh | sh)^NEEDS_GH_AUTH compatible tasks-axi is silent by default^1^0.1.1^1^-^empty^^ -missing tasks-axi is required by default^1^-^1^-^exact^MISSING: tasks-axi (install: npm install -g tasks-axi)^ -incompatible tasks-axi is required by default^1^0.1.0^1^-^exact^MISSING: tasks-axi (install: npm install -g tasks-axi)^ -tasks-axi without archive-body is required by default^1^0.1.2:noarchive^1^-^exact^MISSING: tasks-axi (install: npm install -g tasks-axi)^ -tasks-axi without multi-id mv is required by default^1^0.2.2:nomulti^1^-^exact^MISSING: tasks-axi (install: npm install -g tasks-axi)^ -missing quota-axi is required by default^1^0.1.1^0^manual^exact^MISSING: quota-axi (install: npm install -g quota-axi)^ -manual backlog backend still requires missing tasks-axi^1^-^1^manual^exact^MISSING: tasks-axi (install: npm install -g tasks-axi)^ +missing tasks-axi is required by default^1^-^1^-^exact^MISSING: tasks-axi (install: npm install -g --prefix __AXI_PREFIX__ tasks-axi)^ +incompatible tasks-axi is required by default^1^0.1.0^1^-^exact^MISSING: tasks-axi (install: npm install -g --prefix __AXI_PREFIX__ tasks-axi)^ +tasks-axi without archive-body is required by default^1^0.1.2:noarchive^1^-^exact^MISSING: tasks-axi (install: npm install -g --prefix __AXI_PREFIX__ tasks-axi)^ +tasks-axi without multi-id mv is required by default^1^0.2.2:nomulti^1^-^exact^MISSING: tasks-axi (install: npm install -g --prefix __AXI_PREFIX__ tasks-axi)^ +missing quota-axi is required by default^1^0.1.1^0^manual^exact^MISSING: quota-axi (install: npm install -g --prefix __AXI_PREFIX__ quota-axi)^ +manual backlog backend still requires missing tasks-axi^1^-^1^manual^exact^MISSING: tasks-axi (install: npm install -g --prefix __AXI_PREFIX__ tasks-axi)^ manual backlog backend suppresses tasks-axi availability^1^0.1.1^1^manual^empty^^ ROWS pass "bootstrap reports treehouse lease + tasks-axi/quota-axi bootstrap contracts" @@ -826,6 +827,45 @@ ROWS pass "bootstrap validates crew-dispatch.json and reports malformed or unverified configs" } +test_approved_axi_install_uses_vessel_prefix() { + local case_dir home fakebin out + case_dir="$TMP_ROOT/approved-axi-install" + home="$case_dir/home" + fakebin=$(fm_fakebin "$case_dir") + mkdir -p "$home" + cat > "$fakebin/npm" <<'SH' +#!/usr/bin/env bash +printf '%s\n' "$*" >> "$FM_FAKE_NPM_LOG" +prefix= +while [ "$#" -gt 0 ]; do + if [ "$1" = --prefix ]; then prefix=${2:-}; break; fi + shift +done +[ -n "$prefix" ] || exit 1 +mkdir -p "$prefix/bin" +cat > "$prefix/bin/gh-axi" <<'TOOL' +#!/usr/bin/env bash +if [ "${1:-}" = setup ] && [ "${2:-}" = hooks ]; then + printf '%s\n' setup-hooks >> "$FM_FAKE_HOOK_LOG" +fi +TOOL +chmod +x "$prefix/bin/gh-axi" +SH + chmod +x "$fakebin/npm" + : > "$case_dir/npm.log" + : > "$case_dir/hook.log" + out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" \ + FM_FAKE_NPM_LOG="$case_dir/npm.log" FM_FAKE_HOOK_LOG="$case_dir/hook.log" \ + "$ROOT/bin/fm-bootstrap.sh" install gh-axi) + assert_contains "$out" "npm install -g --prefix $home/.local/axi gh-axi" \ + "bootstrap did not disclose the vessel-owned install prefix" + assert_grep "install -g --prefix $home/.local/axi gh-axi" "$case_dir/npm.log" \ + "bootstrap npm execution did not use the vessel-owned prefix" + assert_grep setup-hooks "$case_dir/hook.log" \ + "bootstrap did not run hooks through the newly installed vessel binary" + pass "captain-approved AXI installs and hooks use the vessel prefix" +} + test_bootstrap_reporting test_no_mistakes_min_version test_git_is_required_with_supported_install_instruction @@ -848,3 +888,4 @@ test_bootstrap_info_is_no_load_and_actionable_lines_trigger test_crew_dispatch_active_rules_are_verbose_bootstrap_info test_crew_dispatch_validation test_currency_base_validation +test_approved_axi_install_uses_vessel_prefix diff --git a/tests/fm-gotmp.test.sh b/tests/fm-gotmp.test.sh index 03f0afafa7..b7808a057d 100755 --- a/tests/fm-gotmp.test.sh +++ b/tests/fm-gotmp.test.sh @@ -51,6 +51,7 @@ make_fake_root() { mkdir -p "$fake/bin/backends" "$fake/state" # Symlink the REAL teardown so the test exercises actual code, not a copy. ln -s "$TEARDOWN" "$fake/bin/fm-teardown.sh" + ln -s "$ROOT/bin/fm-axi-path-lib.sh" "$fake/bin/fm-axi-path-lib.sh" # fm-backend.sh + its tmux adapter: symlink the REAL files (teardown sources # fm-backend.sh unconditionally, and dispatches the kill call through the # tmux adapter; both are unchanged by this suite's fixture, just newly @@ -156,6 +157,7 @@ test_teardown_skips_gracefully_without_tasktmp() { local fake="$TMP_ROOT/$id-root" mkdir -p "$fake/bin/backends" "$fake/state" ln -s "$TEARDOWN" "$fake/bin/fm-teardown.sh" + ln -s "$ROOT/bin/fm-axi-path-lib.sh" "$fake/bin/fm-axi-path-lib.sh" ln -s "$ROOT/bin/fm-backend.sh" "$fake/bin/fm-backend.sh" ln -s "$ROOT/bin/backends/tmux.sh" "$fake/bin/backends/tmux.sh" ln -s "$ROOT/bin/fm-tmux-lib.sh" "$fake/bin/fm-tmux-lib.sh" diff --git a/tests/fm-runtime-ignore.test.sh b/tests/fm-runtime-ignore.test.sh index fad48d3c74..5d6431512f 100755 --- a/tests/fm-runtime-ignore.test.sh +++ b/tests/fm-runtime-ignore.test.sh @@ -10,6 +10,7 @@ fm_test_tmproot TMP_ROOT fm-runtime-ignore runtime_artifacts() { cat <<'PATHS' +.local/axi/bin/gh-axi .claude/settings.local.json .claude/scheduled_tasks.lock .claude/scheduled_tasks.json diff --git a/tests/fm-spawn-dispatch-profile.test.sh b/tests/fm-spawn-dispatch-profile.test.sh index 79b8b7a2c9..e0d707cd30 100755 --- a/tests/fm-spawn-dispatch-profile.test.sh +++ b/tests/fm-spawn-dispatch-profile.test.sh @@ -32,6 +32,9 @@ case "${1:-}" in if [ -n "${FM_FAKE_LAUNCH_LOG:-}" ]; then prev= for a in "$@"; do + case "$a" in + 'export PATH='*) printf '%s\n' "$a" >> "$FM_FAKE_LAUNCH_LOG.path" ;; + esac if [ "$prev" = "-l" ]; then printf '%s\n' "$a" >> "$FM_FAKE_LAUNCH_LOG" fi @@ -122,6 +125,8 @@ test_no_profile_keeps_claude_profile_defaults() { launch=$(cat "$LAUNCH_LOG") expected="CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION=false claude --dangerously-skip-permissions --settings '$WT_DIR/.claude/settings.fm-task.json' \"\$('${ROOT}/bin/fm-operational-input.sh' encode launch-brief < '$HOME_DIR/data/$id/brief.md')\"" [ "$launch" = "$expected" ] || fail "no-profile claude launch did not use the canonical launch kind"$'\n'"expected: $expected"$'\n'"actual: $launch" + assert_grep "export PATH='$HOME_DIR/.local/axi/bin':\$PATH" "$LAUNCH_LOG.path" \ + "ordinary crew did not receive the owning vessel's AXI bin first" pass "no --model/--effort records defaults and types the claude launch instructions" } @@ -176,6 +181,10 @@ test_secondmate_claude_launch_omits_the_task_overlay() { case "$launch" in *--settings*) fail "secondmate claude launch passes --settings for an overlay spawn never writes"$'\n'"actual: $launch" ;; esac + assert_grep "export PATH='$sm/.local/axi/bin':\$PATH" "$LAUNCH_LOG.path" \ + "secondmate launch did not receive its own home's AXI bin first" + assert_no_grep "$HOME_DIR/.local/axi/bin" "$LAUNCH_LOG.path" \ + "secondmate launch inherited the primary vessel's AXI bin as its first entry" pass "a secondmate claude launch omits --settings because no per-task overlay is written" } From 193fe3b4c7f168e4dde96893c5c739aaad8fb860 Mon Sep 17 00:00:00 2001 From: Freudator86 <94322668+Freudator86@users.noreply.github.com> Date: Tue, 28 Jul 2026 03:42:41 +0000 Subject: [PATCH 2/8] no-mistakes(review): harden vessel AXI prefix seeding, repair, and cadence --- .agents/skills/bootstrap-diagnostics/SKILL.md | 7 +- bin/fm-axi-path-lib.sh | 4 +- bin/fm-axi-suite.sh | 148 +++++++++++------- bin/fm-bootstrap.sh | 8 +- docs/configuration.md | 9 +- tests/fm-axi-suite.test.sh | 73 ++++++++- 6 files changed, 183 insertions(+), 66 deletions(-) diff --git a/.agents/skills/bootstrap-diagnostics/SKILL.md b/.agents/skills/bootstrap-diagnostics/SKILL.md index 8a195085a4..7815fc8fa0 100644 --- a/.agents/skills/bootstrap-diagnostics/SKILL.md +++ b/.agents/skills/bootstrap-diagnostics/SKILL.md @@ -49,9 +49,12 @@ When any diagnostic needs captain attention, report the plain consequence and re The original watcher remains the slow delivery backstop. - `FREQUENCY_MONITOR_UNIT: systemd --user is unavailable ...` - fast Bridge delivery cannot run on this host through the tracked unit, but the original watcher still provides slow durable delivery. Do not invent or auto-start an unapproved background-process fallback. -- `AXI_SUITE_UPDATED: -> ` - the vessel completed a gated patch or minor self-update; report it only when it materially affects current work. -- `AXI_SUITE_REVIEW: ` - a major release or newly required suite command was deliberately not installed; present the printed install command and purpose to the captain, then use `bin/fm-bootstrap.sh install ` only after consent. +- `AXI_SUITE_UPDATED: ` - the vessel completed a self-repair of its own npm prefix and needs nothing from you; report it only when it materially affects current work. + ` -> ` is a gated patch or minor self-update, ` installed in vessel prefix` is the home's own copy being seeded from the version already on `PATH`, and ` unreadable vessel copy removed from ` means a copy that could not report its version was dropped so the intact external copy stops being shadowed and a readable version can be reinstalled. +- `AXI_SUITE_REVIEW: ` - a major release, a newly required suite command, or a locally-ahead build the registry cannot supply was deliberately not installed; present the printed install command and purpose to the captain, then use `bin/fm-bootstrap.sh install ` only after consent. + For the locally-ahead case the external copy keeps working, so the decision is whether to publish that build or let the vessel accept the registry version. - `AXI_SUITE_STUCK: ` - the vessel could not check or apply an eligible update and persisted the condition in `state/axi-suite-update.stuck`; investigate the local install path first, and if the vessel cannot repair itself, relay the status through the existing Bridge workflow by dispatching a crewmate rather than calling project automation directly. + The condition is retried on the next currency window rather than on every session, so a line that keeps reappearing means the underlying cause is still present. - `FIRSTMATE_UPDATE_AVAILABLE: ` - the configured upstream firstmate has an upstream-only instruction-surface change; dispatch a crewmate to send an All-Ships update notice through the existing Bridge workflow, and never call Bridge project automation directly from firstmate or the check script. - `FIRSTMATE_UPDATE_STUCK: ` - the read-only upstream framework comparison failed and persisted the condition in `state/firstmate-update.stuck`; investigate the repository or network failure, and do not broadcast an update until the comparison succeeds. A `config/firstmate-update-base is unusable` detail is not a network failure: the check refused a configured comparison base, so handle it as `CURRENCY_BASE` below. diff --git a/bin/fm-axi-path-lib.sh b/bin/fm-axi-path-lib.sh index bcc47e1ebe..25bf515305 100755 --- a/bin/fm-axi-path-lib.sh +++ b/bin/fm-axi-path-lib.sh @@ -17,7 +17,9 @@ fm_axi_prefix() { } fm_axi_bin_dir() { - printf '%s/bin\n' "$(fm_axi_prefix "${1:-${FM_HOME:-}}")" + local prefix + prefix=$(fm_axi_prefix "${1:-${FM_HOME:-}}") || return 1 + printf '%s/bin\n' "$prefix" } fm_axi_prepend_path() { diff --git a/bin/fm-axi-suite.sh b/bin/fm-axi-suite.sh index ca3b53a874..9008e73871 100755 --- a/bin/fm-axi-suite.sh +++ b/bin/fm-axi-suite.sh @@ -16,9 +16,18 @@ # removed. Major releases and missing suite commands are reported for review # and never installed. A failed registry lookup or update is recorded in # state/axi-suite-update.stuck and reported on every later invocation until a -# successful check clears it. state/axi-suite-prefix-v1.ready records that every -# installed suite command has a vessel copy, so a pre-cutover cadence stamp can -# never postpone the first isolated install. +# successful check clears it. state/axi-suite-prefix-v1.cutover records that this +# home has already attempted the isolated seeding once, so a pre-cutover cadence +# stamp cannot postpone the cutover while a failed attempt still costs at most +# one sweep per cadence window instead of one per session. +# +# A vessel copy whose version cannot be read would otherwise shadow an intact +# external copy forever, so it is removed and reseeded instead of trusted. +# +# Hook setup for the hook-owning tools stays a shared user-global write that no +# prefix can isolate; it is invoked by name through the vessel bin directory so +# every home writes the same PATH-portable command (docs/configuration.md +# "AXI-suite self-update" owns that boundary). # # FM_AXI_SUITE_CHECK_INTERVAL overrides the cadence in seconds. Set it to 0 to # check every time. FM_AXI_SUITE_DISABLE=1 disables the mechanism (tests and @@ -114,16 +123,16 @@ is_hook_tool() { } install_hint() { - local tool=$1 spec=$2 hint quoted_prefix quoted_tool + local tool=$1 spec=$2 hint quoted_prefix quoted_bin printf -v quoted_prefix '%q' "$AXI_PREFIX" - printf -v quoted_tool '%q' "$AXI_BIN/$tool" + printf -v quoted_bin '%q' "$AXI_BIN" hint="npm install -g --prefix $quoted_prefix $spec" - is_hook_tool "$tool" && hint="$hint && $quoted_tool setup hooks" + is_hook_tool "$tool" && hint="$hint && PATH=$quoted_bin:\$PATH $tool setup hooks" printf '%s' "$hint" } STAMP="$STATE/axi-suite-update.checked" -PREFIX_READY="$STATE/axi-suite-prefix-v1.ready" +PREFIX_CUTOVER="$STATE/axi-suite-prefix-v1.cutover" DIAGNOSTICS="$STATE/axi-suite-update.diagnostics" STUCK="$STATE/axi-suite-update.stuck" @@ -133,7 +142,7 @@ emit_cached() { } now=$(date +%s 2>/dev/null || echo 0) -if [ "$FORCE" -ne 1 ] && [ -f "$STAMP" ] && [ -f "$PREFIX_READY" ]; then +if [ "$FORCE" -ne 1 ] && [ -f "$STAMP" ] && [ -f "$PREFIX_CUTOVER" ]; then checked=$(cat "$STAMP" 2>/dev/null || echo 0) case "$checked" in ''|*[!0-9]*) checked=0 ;; esac if [ "$now" -ge "$checked" ] && [ $((now - checked)) -lt "$INTERVAL" ]; then @@ -174,23 +183,51 @@ home_tool_path() { printf '%s/%s\n' "$AXI_BIN" "$1" } +home_tool_is_readable() { + [ -n "$(version_of "$(home_tool_path "$1")")" ] +} + +# The vessel bin directory resolves before every inherited PATH entry, so a +# vessel copy that cannot report its version shadows an intact external copy for +# every consumer. Dropping it restores that fallback and lets the normal seeding +# path reinstall from a version that can actually be read. +discard_broken_home_tool() { + local tool=$1 path + path=$(home_tool_path "$tool") + [ -e "$path" ] || return 0 + home_tool_is_readable "$tool" && return 0 + rm -f "$path" 2>/dev/null + hash -r 2>/dev/null || true + [ ! -e "$path" ] +} + install_home_tool() { - local tool=$1 spec=$2 expected installed + local tool=$1 spec=$2 expected installed status=0 expected=$3 if ! net_call npm install -g --prefix "$AXI_PREFIX" "$spec" >/dev/null 2>&1; then - return 1 + status=1 + else + installed=$(version_of "$(home_tool_path "$tool")") + [ "$installed" = "$expected" ] || status=1 fi - installed=$(version_of "$(home_tool_path "$tool")") - [ "$installed" = "$expected" ] + [ "$status" -eq 0 ] || discard_broken_home_tool "$tool" >/dev/null 2>&1 || true + return "$status" } setup_home_hooks() { - net_call "$(home_tool_path "$1")" setup hooks >/dev/null 2>&1 + local tool=$1 + net_call env PATH="$AXI_BIN:$PATH" "$tool" setup hooks >/dev/null 2>&1 } seed_home_tool() { - local tool=$1 version=$2 + local tool=$1 version=$2 registry_latest=${3:-} if ! install_home_tool "$tool" "$tool@$version" "$version"; then + if [ -n "$registry_latest" ]; then + printf 'AXI_SUITE_REVIEW: %s %s is ahead of registry latest %s and is not installable into %s; the external copy stays the fallback until it is published or %s is accepted (install: %s)\n' \ + "$tool" "$version" "$registry_latest" "$AXI_PREFIX" "$registry_latest" \ + "$(install_hint "$tool" "$tool@$registry_latest")" >> "$tmp_diag" + return 1 + fi printf 'AXI_SUITE_STUCK: %s vessel-prefix installation at %s failed\n' \ "$tool" "$AXI_PREFIX" >> "$tmp_stuck" return 1 @@ -204,7 +241,36 @@ seed_home_tool() { "$tool" "$version" >> "$tmp_diag" } +# Returns 0 when the vessel already owns a copy, 1 when this call reported or +# performed the seeding for an absent one. +ensure_home_copy() { + local tool=$1 version=$2 registry_latest=${3:-} + [ ! -x "$(home_tool_path "$tool")" ] || return 0 + if [ "$CHECK_ONLY" -eq 1 ]; then + printf 'AXI_SUITE_UPDATE: %s %s needs vessel-prefix installation\n' \ + "$tool" "$version" >> "$tmp_diag" + else + seed_home_tool "$tool" "$version" "$registry_latest" || true + fi + return 1 +} + for tool in $SUITE; do + if [ -x "$(home_tool_path "$tool")" ] && ! home_tool_is_readable "$tool"; then + if [ "$CHECK_ONLY" -eq 1 ]; then + printf 'AXI_SUITE_STUCK: %s vessel copy at %s is unreadable (removal pending)\n' \ + "$tool" "$AXI_PREFIX" >> "$tmp_stuck" + continue + fi + if discard_broken_home_tool "$tool"; then + printf 'AXI_SUITE_UPDATED: %s unreadable vessel copy removed from %s\n' \ + "$tool" "$AXI_PREFIX" >> "$tmp_diag" + else + printf 'AXI_SUITE_STUCK: %s unreadable vessel copy at %s could not be removed\n' \ + "$tool" "$AXI_PREFIX" >> "$tmp_stuck" + continue + fi + fi if ! command -v "$tool" >/dev/null 2>&1; then printf 'AXI_SUITE_REVIEW: %s is not installed (install: %s)\n' "$tool" "$(install_hint "$tool" "$tool")" >> "$tmp_diag" continue @@ -223,54 +289,26 @@ for tool in $SUITE; do printf 'AXI_SUITE_STUCK: %s registry returned an invalid version\n' "$tool" >> "$tmp_stuck" continue fi - home_tool=$(home_tool_path "$tool") - home_installed=0 - [ -x "$home_tool" ] && home_installed=1 if [ "$installed" = "$latest" ]; then - if [ "$home_installed" -eq 0 ]; then - if [ "$CHECK_ONLY" -eq 1 ]; then - printf 'AXI_SUITE_UPDATE: %s %s needs vessel-prefix installation\n' \ - "$tool" "$installed" >> "$tmp_diag" - if is_hook_tool "$tool" && grep -q "^AXI_SUITE_STUCK: $tool " "$STUCK" 2>/dev/null; then - printf 'AXI_SUITE_STUCK: %s hook setup retry pending (already at %s)\n' \ - "$tool" "$latest" >> "$tmp_stuck" - fi - else - seed_home_tool "$tool" "$installed" || true - fi - continue - fi + home_present=0 + ensure_home_copy "$tool" "$installed" && home_present=1 if is_hook_tool "$tool" && grep -q "^AXI_SUITE_STUCK: $tool " "$STUCK" 2>/dev/null; then if [ "$CHECK_ONLY" -eq 1 ]; then printf 'AXI_SUITE_STUCK: %s hook setup retry pending (already at %s)\n' "$tool" "$latest" >> "$tmp_stuck" - elif ! setup_home_hooks "$tool"; then + elif [ "$home_present" -eq 1 ] && ! setup_home_hooks "$tool"; then printf 'AXI_SUITE_STUCK: %s hook setup failed (already at %s)\n' "$tool" "$latest" >> "$tmp_stuck" fi fi continue fi if version_gt "$installed" "$latest"; then - if [ "$home_installed" -eq 0 ]; then - if [ "$CHECK_ONLY" -eq 1 ]; then - printf 'AXI_SUITE_UPDATE: %s %s needs vessel-prefix installation\n' \ - "$tool" "$installed" >> "$tmp_diag" - else - seed_home_tool "$tool" "$installed" || true - fi - fi + ensure_home_copy "$tool" "$installed" "$latest" || true continue fi if [ "$(major_of "$installed")" != "$(major_of "$latest")" ]; then printf 'AXI_SUITE_REVIEW: %s major update %s -> %s (install: %s)\n' \ "$tool" "$installed" "$latest" "$(install_hint "$tool" "$tool@$latest")" >> "$tmp_diag" - if [ "$home_installed" -eq 0 ]; then - if [ "$CHECK_ONLY" -eq 1 ]; then - printf 'AXI_SUITE_UPDATE: %s %s needs vessel-prefix installation\n' \ - "$tool" "$installed" >> "$tmp_diag" - else - seed_home_tool "$tool" "$installed" || true - fi - fi + ensure_home_copy "$tool" "$installed" || true continue fi [ "$CHECK_ONLY" -eq 0 ] || { @@ -298,18 +336,10 @@ if [ "$CHECK_ONLY" -eq 0 ]; then fi if [ -s "$tmp_stuck" ]; then cp "$tmp_stuck" "$STUCK"; else rm -f "$STUCK"; fi printf '%s\n' "$now" > "$STAMP" - prefix_ready=1 - for tool in $SUITE; do - if command -v "$tool" >/dev/null 2>&1 && [ ! -x "$(home_tool_path "$tool")" ]; then - prefix_ready=0 - break - fi - done - if [ "$prefix_ready" -eq 1 ]; then - : > "$PREFIX_READY" - else - rm -f "$PREFIX_READY" - fi + # Records the attempt, not its outcome: a home that could not seed every tool + # retries on the next cadence window instead of paying a full sweep, and a + # recurring alarm, on every single session. + : > "$PREFIX_CUTOVER" fi cat "$tmp_diag" 2>/dev/null cat "$tmp_stuck" 2>/dev/null diff --git a/bin/fm-bootstrap.sh b/bin/fm-bootstrap.sh index f9237e85ac..516af886b9 100755 --- a/bin/fm-bootstrap.sh +++ b/bin/fm-bootstrap.sh @@ -516,8 +516,12 @@ install_cmd() { treehouse) echo "curl -fsSL https://kunchenguid.github.io/treehouse/install.sh | sh" ;; no-mistakes) echo "curl -fsSL https://raw.githubusercontent.com/kunchenguid/no-mistakes/main/docs/install.sh | sh" ;; gh-axi|chrome-devtools-axi|lavish-axi) - tool_bin=$(fm_axi_bin_dir "$FM_HOME")/$1 - printf 'npm install -g --prefix %q %q && %q setup hooks\n' "$prefix" "$1" "$tool_bin" + # The tool is named, not absolute, so its hook installer records a + # PATH-portable command in the shared user-global harness config instead + # of this home's private path (docs/configuration.md "AXI-suite + # self-update"). + tool_bin=$(fm_axi_bin_dir "$FM_HOME") + printf 'npm install -g --prefix %q %q && PATH=%q:%s %q setup hooks\n' "$prefix" "$1" "$tool_bin" "\$PATH" "$1" ;; tasks-axi|quota-axi) printf 'npm install -g --prefix %q %q\n' "$prefix" "$1" ;; *) return 1 ;; diff --git a/docs/configuration.md b/docs/configuration.md index d8a7bac1d6..bb976f7459 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -411,8 +411,15 @@ On the first normal currency check, an existing external AXI installation is lef No existing installation is moved or removed. Patch and minor releases update automatically in the vessel prefix, while major releases and newly required commands emit `AXI_SUITE_REVIEW:` for captain approval. When a major release is pending and the vessel copy is absent, the updater seeds the currently installed major into the vessel prefix without accepting the major upgrade. +A vessel copy that cannot report its version would shadow the intact external copy for every consumer of that home's `PATH`, so the updater removes it and reseeds from a readable version instead of trusting it; the same removal happens when an install leaves a copy that cannot be read back. +A locally-ahead build that the registry cannot supply is reported as `AXI_SUITE_REVIEW:` rather than a failure, because the external copy remains the working fallback until that build is published or the vessel accepts the registry version. Successful changes emit `AXI_SUITE_UPDATED:`, and bounded registry, permission, install, verification, or hook failures emit `AXI_SUITE_STUCK:` and persist under `state/` until a successful check clears them. -The cadence stamp remains under that vessel's `state/`, but a stamp created before the isolated prefix is ready cannot postpone the one-time cutover; after readiness is recorded, cached invocations skip registry and install work exactly as before. +The cadence stamp remains under that vessel's `state/`, and `state/axi-suite-prefix-v1.cutover` records that this home already attempted the isolated seeding, so a stamp written before the cutover cannot postpone it. +The marker records the attempt and not its outcome: a home that could not seed every tool retries on the next cadence window instead of paying a full registry sweep, and a repeated alarm, on every session. + +Hook setup for `gh-axi`, `chrome-devtools-axi`, and `lavish-axi` is the one part of the suite that the prefix cannot isolate: ` setup hooks` writes the user-global harness surfaces (`~/.claude/settings.json`, `~/.codex/`, `~/.config/opencode/plugins/`) that every vessel on the host shares. +The updater and the printed install commands therefore invoke the tool by name with the vessel bin directory first on `PATH`, so the installer records the portable command name rather than one home's private path and every vessel converges on identical content. +Removing a vessel home cannot break another home's hook wiring as a result, but the wiring itself stays shared: the last vessel to run hook setup owns the version of that shared config on disk. `FM_AXI_SUITE_NETWORK_TIMEOUT` bounds the whole suite check, and `FM_AXI_SUITE_DISABLE` is reserved for tests or emergency diagnosis. ### Upstream firstmate and curated-fork checks diff --git a/tests/fm-axi-suite.test.sh b/tests/fm-axi-suite.test.sh index 4dd8b6ddf0..6597a3faa0 100755 --- a/tests/fm-axi-suite.test.sh +++ b/tests/fm-axi-suite.test.sh @@ -301,7 +301,7 @@ test_currency_clock_survives_prefix_cutover() { assert_contains "$first" 'AXI_SUITE_UPDATED: clock-axi 1.2.3 -> 1.2.4' \ "the first currency check did not update the vessel copy" assert_present "$w/home/state/axi-suite-update.checked" "the per-home currency stamp was not written" - assert_present "$w/home/state/axi-suite-prefix-v1.ready" "the vessel-prefix readiness marker was not written" + assert_present "$w/home/state/axi-suite-prefix-v1.cutover" "the vessel-prefix cutover marker was not written" installs_after_first=$(wc -l < "$w/install.log" | tr -d ' ') printf '%s\n' 'clock-axi=1.2.5' > "$w/versions" second=$(PATH="$w/bin:$BASE_PATH" FM_HOME="$w/home" \ @@ -320,11 +320,82 @@ test_currency_clock_survives_prefix_cutover() { pass "the per-home currency clock still gates and resumes vessel-prefix updates" } +test_failed_seed_still_honours_the_cadence() { + local w first second installs_after_first installs_after_second + w="$TMP_ROOT/failed-seed-cadence" + mkdir -p "$w/bin" "$w/home/state" + make_npm "$w/bin" "$w/versions" "$w/install.log" + make_tool "$w/bin" offline-axi 1.0.0 + printf '%s\n' 'offline-axi=1.0.0' > "$w/versions" + sed -i 's/if \[ "${1:-}" = install \]; then/if [ "${1:-}" = install ]; then printf "%s\\n" "$*" >> "$FM_TEST_INSTALL_LOG"; exit 1; fi\nif false; then/' "$w/bin/npm" + first=$(PATH="$w/bin:$BASE_PATH" FM_HOME="$w/home" \ + FM_AXI_SUITE_DISABLE=0 FM_AXI_SUITE_TOOLS=offline-axi FM_AXI_SUITE_CHECK_INTERVAL=86400 \ + FM_TEST_VERSIONS="$w/versions" FM_TEST_INSTALL_LOG="$w/install.log" \ + "$ROOT/bin/fm-axi-suite.sh") + assert_contains "$first" 'AXI_SUITE_STUCK: offline-axi vessel-prefix installation' \ + "the failed seed was not surfaced" + assert_present "$w/home/state/axi-suite-prefix-v1.cutover" \ + "a failed seed did not record the cutover attempt" + installs_after_first=$(wc -l < "$w/install.log" | tr -d ' ') + second=$(PATH="$w/bin:$BASE_PATH" FM_HOME="$w/home" \ + FM_AXI_SUITE_DISABLE=0 FM_AXI_SUITE_TOOLS=offline-axi FM_AXI_SUITE_CHECK_INTERVAL=86400 \ + FM_TEST_VERSIONS="$w/versions" FM_TEST_INSTALL_LOG="$w/install.log" \ + "$ROOT/bin/fm-axi-suite.sh") + installs_after_second=$(wc -l < "$w/install.log" | tr -d ' ') + [ "$installs_after_second" = "$installs_after_first" ] || fail "a failed seed repeated the whole sweep in the same cadence window" + assert_contains "$second" 'AXI_SUITE_STUCK: offline-axi vessel-prefix installation' \ + "the cached run dropped the persisted stuck signal" + pass "a failed vessel seed keeps the cadence and retries on the next window" +} + +test_unreadable_vessel_copy_is_replaced() { + local w out + w="$TMP_ROOT/broken-copy" + mkdir -p "$w/bin" "$w/home/.local/axi/bin" "$w/home/state" + make_npm "$w/bin" "$w/versions" "$w/install.log" + make_tool "$w/bin" repair-axi 1.2.3 + printf '#!/usr/bin/env bash\nexit 1\n' > "$w/home/.local/axi/bin/repair-axi" + chmod +x "$w/home/.local/axi/bin/repair-axi" + printf '%s\n' 'repair-axi=1.2.3' > "$w/versions" + out=$(run_case "$w" "repair-axi") + assert_contains "$out" 'AXI_SUITE_UPDATED: repair-axi unreadable vessel copy removed' \ + "the unreadable vessel copy was not removed" + assert_grep "--prefix $w/home/.local/axi repair-axi@1.2.3" "$w/install.log" \ + "the removed vessel copy was not reseeded from the intact external copy" + [ "$("$w/home/.local/axi/bin/repair-axi" --version)" = 'repair-axi 1.2.3' ] \ + || fail "the repaired vessel copy still cannot report its version" + assert_absent "$w/state/axi-suite-update.stuck" \ + "the broken vessel copy left a permanent stuck signal" + pass "an unreadable vessel copy is removed instead of shadowing the intact external copy" +} + +test_unpublished_ahead_version_is_not_a_recurring_alarm() { + local w out + w="$TMP_ROOT/ahead-unpublished" + mkdir -p "$w/bin" "$w/home/state" + make_npm "$w/bin" "$w/versions" "$w/install.log" + make_tool "$w/bin" dev-axi 3.1.0 + printf '%s\n' 'dev-axi=3.0.0' > "$w/versions" + sed -i 's/if \[ "${1:-}" = install \]; then/if [ "${1:-}" = install ]; then printf "%s\\n" "$*" >> "$FM_TEST_INSTALL_LOG"; exit 1; fi\nif false; then/' "$w/bin/npm" + out=$(run_case "$w" "dev-axi") + assert_contains "$out" 'AXI_SUITE_REVIEW: dev-axi 3.1.0 is ahead of registry latest 3.0.0' \ + "an unpublishable locally-ahead build was not reported for review" + assert_absent "$w/state/axi-suite-update.stuck" \ + "an unpublishable locally-ahead build raised a permanent stuck alarm" + assert_no_grep 'dev-axi@3.0.0' "$w/install.log" "the locally-ahead build was downgraded to the registry latest" + assert_present "$w/state/axi-suite-prefix-v1.cutover" \ + "an unpublishable locally-ahead build blocked the cadence marker" + pass "an unpublishable locally-ahead build reports for review instead of alarming forever" +} + test_patch_and_minor_auto_update test_major_and_missing_wait_for_review test_two_homes_update_distinct_prefixes_concurrently test_vessel_prefix_wins_over_inherited_path test_currency_clock_survives_prefix_cutover +test_failed_seed_still_honours_the_cadence +test_unreadable_vessel_copy_is_replaced +test_unpublished_ahead_version_is_not_a_recurring_alarm test_failed_update_persists_stuck_signal test_check_only_never_runs_hook_setup test_hook_retry_self_clears_stuck_signal From 14946318feabe63a9c97cc7826d656895150559d Mon Sep 17 00:00:00 2001 From: Freudator86 <94322668+Freudator86@users.noreply.github.com> Date: Tue, 28 Jul 2026 03:54:19 +0000 Subject: [PATCH 3/8] no-mistakes(review): bound vessel version probes and dedupe version reads --- bin/fm-axi-suite.sh | 154 +++++++++++++++++++++++++++---------- docs/configuration.md | 5 +- tests/fm-axi-suite.test.sh | 28 +++++++ 3 files changed, 143 insertions(+), 44 deletions(-) diff --git a/bin/fm-axi-suite.sh b/bin/fm-axi-suite.sh index 9008e73871..d636b19876 100755 --- a/bin/fm-axi-suite.sh +++ b/bin/fm-axi-suite.sh @@ -21,8 +21,10 @@ # stamp cannot postpone the cutover while a failed attempt still costs at most # one sweep per cadence window instead of one per session. # -# A vessel copy whose version cannot be read would otherwise shadow an intact -# external copy forever, so it is removed and reseeded instead of trusted. +# A vessel copy that answers without a version would otherwise shadow an intact +# external copy forever, so it is removed and reseeded instead of trusted. One +# that never answers at all is reported and left in place, because a bounded +# probe cannot tell a hung copy from a very slow working one. # # Hook setup for the hook-owning tools stays a shared user-global write that no # prefix can isolate; it is invoked by name through the vessel bin directory so @@ -34,7 +36,10 @@ # emergency diagnosis only). FM_AXI_SUITE_NETWORK_TIMEOUT bounds the whole # suite's cumulative registry lookup, install, and hook-setup time in seconds # (default 30), so an unreachable registry cannot multiply the stall across -# every tool in the suite. FM_AXI_SUITE_TOOLS overrides the space-separated +# every tool in the suite. FM_AXI_SUITE_PROBE_TIMEOUT bounds the suite's +# cumulative local `--version` probing in seconds (default 15) under a separate +# budget, so a hung binary can neither wedge session start nor consume the +# registry budget. FM_AXI_SUITE_TOOLS overrides the space-separated # suite tool list (default: quota-axi gh-axi tasks-axi gnhf lavish-axi # chrome-devtools-axi). set -u @@ -53,6 +58,7 @@ CHECK_ONLY=0 FORCE=0 SUITE="${FM_AXI_SUITE_TOOLS:-quota-axi gh-axi tasks-axi gnhf lavish-axi chrome-devtools-axi}" NET_TIMEOUT=${FM_AXI_SUITE_NETWORK_TIMEOUT:-30} +PROBE_TIMEOUT=${FM_AXI_SUITE_PROBE_TIMEOUT:-15} while [ $# -gt 0 ]; do case "$1" in @@ -66,6 +72,7 @@ done [ "${FM_AXI_SUITE_DISABLE:-0}" = 1 ] && exit 0 case "$INTERVAL" in ''|*[!0-9]*) INTERVAL=86400 ;; esac case "$NET_TIMEOUT" in ''|*[!0-9]*) NET_TIMEOUT=30 ;; esac +case "$PROBE_TIMEOUT" in ''|*[!0-9]*) PROBE_TIMEOUT=15 ;; esac HAVE_TIMEOUT=none if command -v timeout >/dev/null 2>&1; then HAVE_TIMEOUT=timeout @@ -83,7 +90,10 @@ bounded() { "$@" & cmd_pid=$! set +m - ( sleep "$call_timeout"; kill -TERM "-$cmd_pid" 2>/dev/null; sleep 1; kill -KILL "-$cmd_pid" 2>/dev/null ) & + # The watchdog must not inherit the caller's stdout: a command + # substitution stays open until every writer closes the pipe, so an + # attached watchdog would hold a fast call open for its whole budget. + ( sleep "$call_timeout"; kill -TERM "-$cmd_pid" 2>/dev/null; sleep 1; kill -KILL "-$cmd_pid" 2>/dev/null ) >/dev/null 2>&1 & local watchdog_pid=$! wait "$cmd_pid" 2>/dev/null status=$? @@ -94,13 +104,15 @@ bounded() { esac } -PHASE_DEADLINE=$(( $(date +%s 2>/dev/null || echo 0) + NET_TIMEOUT )) +STARTED=$(date +%s 2>/dev/null || echo 0) +PHASE_DEADLINE=$((STARTED + NET_TIMEOUT)) +PROBE_DEADLINE=$((STARTED + PROBE_TIMEOUT)) remaining_budget() { - local now rem + local deadline=$1 cap=$2 now rem now=$(date +%s 2>/dev/null || echo 0) - rem=$((PHASE_DEADLINE - now)) + rem=$((deadline - now)) [ "$rem" -gt 0 ] || rem=0 - [ "$rem" -le "$NET_TIMEOUT" ] || rem=$NET_TIMEOUT + [ "$rem" -le "$cap" ] || rem=$cap printf '%s' "$rem" } @@ -110,7 +122,18 @@ remaining_budget() { # spent, remaining calls fail fast without touching the network at all. net_call() { local budget - budget=$(remaining_budget) + budget=$(remaining_budget "$PHASE_DEADLINE" "$NET_TIMEOUT") + [ "$budget" -gt 0 ] || return 124 + bounded "$budget" "$@" +} + +# Running a local suite binary is its own stall risk: a half-installed shim can +# hang instead of exiting, and it must never wedge bootstrap or session start. +# Probes carry a cumulative budget of their own so a hung binary cannot spend +# the registry budget and an unreachable registry cannot starve the probes. +probe_call() { + local budget + budget=$(remaining_budget "$PROBE_DEADLINE" "$PROBE_TIMEOUT") [ "$budget" -gt 0 ] || return 124 bounded "$budget" "$@" } @@ -161,8 +184,25 @@ tmp_stuck=$(mktemp "${TMPDIR:-/tmp}/fm-axi-suite-stuck.XXXXXX") || { rm -f "$tmp tmp_diag_persist=$(mktemp "${TMPDIR:-/tmp}/fm-axi-suite-diag-persist.XXXXXX") || { rm -f "$tmp_diag" "$tmp_stuck"; exit 0; } trap 'rm -f "$tmp_diag" "$tmp_stuck" "$tmp_diag_persist"' EXIT -version_of() { - "$1" --version 2>/dev/null | grep -Eo '[0-9]+\.[0-9]+\.[0-9]+' | head -n 1 +# Reads one binary's version under the probe budget with stdin closed, and +# publishes both halves of the answer: VERSION_READ is the parsed version and +# VERSION_PROBE distinguishes a copy that answered without a version (empty, +# and therefore replaceable) from one that never answered at all (timeout, +# which is reported and retried instead of removed). Every caller reuses the +# published values rather than probing the same binary twice. +VERSION_READ= +VERSION_PROBE=empty +read_version() { + local out status=0 + VERSION_READ= + VERSION_PROBE=empty + out=$(probe_call "$1" --version /dev/null) || status=$? + VERSION_READ=$(printf '%s\n' "$out" | grep -Eo '[0-9]+\.[0-9]+\.[0-9]+' | head -n 1) + if [ -n "$VERSION_READ" ]; then + VERSION_PROBE=ok + else + case "$status" in 124|137|143) VERSION_PROBE=timeout ;; esac + fi } major_of() { @@ -183,34 +223,37 @@ home_tool_path() { printf '%s/%s\n' "$AXI_BIN" "$1" } -home_tool_is_readable() { - [ -n "$(version_of "$(home_tool_path "$1")")" ] -} - # The vessel bin directory resolves before every inherited PATH entry, so a # vessel copy that cannot report its version shadows an intact external copy for # every consumer. Dropping it restores that fallback and lets the normal seeding -# path reinstall from a version that can actually be read. -discard_broken_home_tool() { - local tool=$1 path - path=$(home_tool_path "$tool") +# path reinstall from a version that can actually be read. The caller owns the +# readability verdict; this only performs the removal. +discard_home_tool() { + local path + path=$(home_tool_path "$1") [ -e "$path" ] || return 0 - home_tool_is_readable "$tool" && return 0 rm -f "$path" 2>/dev/null hash -r 2>/dev/null || true [ ! -e "$path" ] } install_home_tool() { - local tool=$1 spec=$2 expected installed status=0 + local tool=$1 spec=$2 expected path status=0 expected=$3 - if ! net_call npm install -g --prefix "$AXI_PREFIX" "$spec" >/dev/null 2>&1; then - status=1 + path=$(home_tool_path "$tool") + if net_call npm install -g --prefix "$AXI_PREFIX" "$spec" >/dev/null 2>&1; then + read_version "$path" + [ "$VERSION_READ" = "$expected" ] || status=1 else - installed=$(version_of "$(home_tool_path "$tool")") - [ "$installed" = "$expected" ] || status=1 + status=1 + VERSION_READ= + VERSION_PROBE=empty + [ ! -x "$path" ] || read_version "$path" + fi + if [ "$status" -ne 0 ] && [ -e "$path" ] \ + && [ -z "$VERSION_READ" ] && [ "$VERSION_PROBE" != timeout ]; then + discard_home_tool "$tool" || true fi - [ "$status" -eq 0 ] || discard_broken_home_tool "$tool" >/dev/null 2>&1 || true return "$status" } @@ -241,28 +284,49 @@ seed_home_tool() { "$tool" "$version" >> "$tmp_diag" } -# Returns 0 when the vessel already owns a copy, 1 when this call reported or -# performed the seeding for an absent one. +# Returns 0 when the vessel owns a copy once this call is done, 1 when it does +# not. HOME_COPY_ACTION records what this call did, so a caller can tell an +# untouched pre-existing copy (present) from one whose seeding - hook setup +# included - already ran in this pass (attempted) or was only reported +# (reported). +HOME_COPY_ACTION=present ensure_home_copy() { local tool=$1 version=$2 registry_latest=${3:-} + HOME_COPY_ACTION=present [ ! -x "$(home_tool_path "$tool")" ] || return 0 if [ "$CHECK_ONLY" -eq 1 ]; then + HOME_COPY_ACTION=reported printf 'AXI_SUITE_UPDATE: %s %s needs vessel-prefix installation\n' \ "$tool" "$version" >> "$tmp_diag" - else - seed_home_tool "$tool" "$version" "$registry_latest" || true + return 1 fi - return 1 + HOME_COPY_ACTION=attempted + seed_home_tool "$tool" "$version" "$registry_latest" || true + [ -x "$(home_tool_path "$tool")" ] } for tool in $SUITE; do - if [ -x "$(home_tool_path "$tool")" ] && ! home_tool_is_readable "$tool"; then + home_version= + home_broken=0 + if [ -x "$(home_tool_path "$tool")" ]; then + read_version "$(home_tool_path "$tool")" + home_version=$VERSION_READ + if [ -z "$home_version" ]; then + if [ "$VERSION_PROBE" = timeout ]; then + printf 'AXI_SUITE_STUCK: %s vessel copy at %s did not answer --version within %ss\n' \ + "$tool" "$AXI_PREFIX" "$PROBE_TIMEOUT" >> "$tmp_stuck" + continue + fi + home_broken=1 + fi + fi + if [ "$home_broken" -eq 1 ]; then if [ "$CHECK_ONLY" -eq 1 ]; then printf 'AXI_SUITE_STUCK: %s vessel copy at %s is unreadable (removal pending)\n' \ "$tool" "$AXI_PREFIX" >> "$tmp_stuck" continue fi - if discard_broken_home_tool "$tool"; then + if discard_home_tool "$tool"; then printf 'AXI_SUITE_UPDATED: %s unreadable vessel copy removed from %s\n' \ "$tool" "$AXI_PREFIX" >> "$tmp_diag" else @@ -271,14 +335,19 @@ for tool in $SUITE; do continue fi fi - if ! command -v "$tool" >/dev/null 2>&1; then - printf 'AXI_SUITE_REVIEW: %s is not installed (install: %s)\n' "$tool" "$(install_hint "$tool" "$tool")" >> "$tmp_diag" - continue - fi - installed=$(version_of "$tool") - if [ -z "$installed" ]; then - printf 'AXI_SUITE_STUCK: %s installed version could not be read\n' "$tool" >> "$tmp_stuck" - continue + if [ -n "$home_version" ]; then + installed=$home_version + else + if ! command -v "$tool" >/dev/null 2>&1; then + printf 'AXI_SUITE_REVIEW: %s is not installed (install: %s)\n' "$tool" "$(install_hint "$tool" "$tool")" >> "$tmp_diag" + continue + fi + read_version "$tool" + installed=$VERSION_READ + if [ -z "$installed" ]; then + printf 'AXI_SUITE_STUCK: %s installed version could not be read\n' "$tool" >> "$tmp_stuck" + continue + fi fi if ! latest=$(net_call npm view "$tool" version 2>/dev/null); then printf 'AXI_SUITE_STUCK: %s latest version lookup failed\n' "$tool" >> "$tmp_stuck" @@ -292,7 +361,8 @@ for tool in $SUITE; do if [ "$installed" = "$latest" ]; then home_present=0 ensure_home_copy "$tool" "$installed" && home_present=1 - if is_hook_tool "$tool" && grep -q "^AXI_SUITE_STUCK: $tool " "$STUCK" 2>/dev/null; then + if is_hook_tool "$tool" && [ "$HOME_COPY_ACTION" != attempted ] \ + && grep -q "^AXI_SUITE_STUCK: $tool " "$STUCK" 2>/dev/null; then if [ "$CHECK_ONLY" -eq 1 ]; then printf 'AXI_SUITE_STUCK: %s hook setup retry pending (already at %s)\n' "$tool" "$latest" >> "$tmp_stuck" elif [ "$home_present" -eq 1 ] && ! setup_home_hooks "$tool"; then diff --git a/docs/configuration.md b/docs/configuration.md index bb976f7459..5bda436f40 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -411,7 +411,8 @@ On the first normal currency check, an existing external AXI installation is lef No existing installation is moved or removed. Patch and minor releases update automatically in the vessel prefix, while major releases and newly required commands emit `AXI_SUITE_REVIEW:` for captain approval. When a major release is pending and the vessel copy is absent, the updater seeds the currently installed major into the vessel prefix without accepting the major upgrade. -A vessel copy that cannot report its version would shadow the intact external copy for every consumer of that home's `PATH`, so the updater removes it and reseeds from a readable version instead of trusting it; the same removal happens when an install leaves a copy that cannot be read back. +A vessel copy that answers without reporting a version would shadow the intact external copy for every consumer of that home's `PATH`, so the updater removes it and reseeds from a readable version instead of trusting it; the same removal happens when an install leaves a copy that cannot be read back. +A copy that never answers is reported and left alone instead, because a bounded probe cannot distinguish a hung binary from a very slow working one. A locally-ahead build that the registry cannot supply is reported as `AXI_SUITE_REVIEW:` rather than a failure, because the external copy remains the working fallback until that build is published or the vessel accepts the registry version. Successful changes emit `AXI_SUITE_UPDATED:`, and bounded registry, permission, install, verification, or hook failures emit `AXI_SUITE_STUCK:` and persist under `state/` until a successful check clears them. The cadence stamp remains under that vessel's `state/`, and `state/axi-suite-prefix-v1.cutover` records that this home already attempted the isolated seeding, so a stamp written before the cutover cannot postpone it. @@ -420,7 +421,7 @@ The marker records the attempt and not its outcome: a home that could not seed e Hook setup for `gh-axi`, `chrome-devtools-axi`, and `lavish-axi` is the one part of the suite that the prefix cannot isolate: ` setup hooks` writes the user-global harness surfaces (`~/.claude/settings.json`, `~/.codex/`, `~/.config/opencode/plugins/`) that every vessel on the host shares. The updater and the printed install commands therefore invoke the tool by name with the vessel bin directory first on `PATH`, so the installer records the portable command name rather than one home's private path and every vessel converges on identical content. Removing a vessel home cannot break another home's hook wiring as a result, but the wiring itself stays shared: the last vessel to run hook setup owns the version of that shared config on disk. -`FM_AXI_SUITE_NETWORK_TIMEOUT` bounds the whole suite check, and `FM_AXI_SUITE_DISABLE` is reserved for tests or emergency diagnosis. +`FM_AXI_SUITE_NETWORK_TIMEOUT` bounds the whole suite's registry, install, and hook work, `FM_AXI_SUITE_PROBE_TIMEOUT` separately bounds its cumulative local version probing so a hung suite binary cannot wedge session start, and `FM_AXI_SUITE_DISABLE` is reserved for tests or emergency diagnosis. ### Upstream firstmate and curated-fork checks diff --git a/tests/fm-axi-suite.test.sh b/tests/fm-axi-suite.test.sh index 6597a3faa0..f5ac907021 100755 --- a/tests/fm-axi-suite.test.sh +++ b/tests/fm-axi-suite.test.sh @@ -369,6 +369,33 @@ test_unreadable_vessel_copy_is_replaced() { pass "an unreadable vessel copy is removed instead of shadowing the intact external copy" } +test_hung_vessel_copy_is_bounded_and_kept() { + local w out start end elapsed + w="$TMP_ROOT/hung-copy" + mkdir -p "$w/bin" "$w/home/.local/axi/bin" "$w/state" + make_npm "$w/bin" "$w/versions" "$w/install.log" + make_tool "$w/bin" hungcopy-axi 1.0.0 + printf '#!/usr/bin/env bash\nsleep 30\n' > "$w/home/.local/axi/bin/hungcopy-axi" + chmod +x "$w/home/.local/axi/bin/hungcopy-axi" + printf '%s\n' 'hungcopy-axi=1.0.0' > "$w/versions" + start=$(date +%s) + out=$(PATH="$w/bin:$BASE_PATH" FM_HOME="$w/home" FM_STATE_OVERRIDE="$w/state" \ + FM_AXI_SUITE_DISABLE=0 FM_AXI_SUITE_TOOLS=hungcopy-axi FM_AXI_SUITE_CHECK_INTERVAL=0 \ + FM_AXI_SUITE_PROBE_TIMEOUT=1 \ + FM_TEST_VERSIONS="$w/versions" FM_TEST_INSTALL_LOG="$w/install.log" \ + "$ROOT/bin/fm-axi-suite.sh" --force) + end=$(date +%s) + elapsed=$((end - start)) + [ "$elapsed" -lt 15 ] || fail "a hung vessel copy stalled the check for ${elapsed}s" + assert_contains "$out" 'AXI_SUITE_STUCK: hungcopy-axi vessel copy at' \ + "a hung vessel copy was not reported" + assert_present "$w/home/.local/axi/bin/hungcopy-axi" \ + "a vessel copy that only failed to answer in time was removed" + assert_present "$w/state/axi-suite-prefix-v1.cutover" \ + "a hung vessel copy blocked the cadence marker" + pass "a hung vessel copy is bounded, reported, and not removed" +} + test_unpublished_ahead_version_is_not_a_recurring_alarm() { local w out w="$TMP_ROOT/ahead-unpublished" @@ -395,6 +422,7 @@ test_vessel_prefix_wins_over_inherited_path test_currency_clock_survives_prefix_cutover test_failed_seed_still_honours_the_cadence test_unreadable_vessel_copy_is_replaced +test_hung_vessel_copy_is_bounded_and_kept test_unpublished_ahead_version_is_not_a_recurring_alarm test_failed_update_persists_stuck_signal test_check_only_never_runs_hook_setup From b4351eeb3db395f13e2b5df5acff0cb07749a42a Mon Sep 17 00:00:00 2001 From: Freudator86 <94322668+Freudator86@users.noreply.github.com> Date: Tue, 28 Jul 2026 04:04:09 +0000 Subject: [PATCH 4/8] no-mistakes(review): charge probe and network budgets separately --- bin/fm-axi-suite.sh | 122 +++++++++++++++++++++++++------------ docs/configuration.md | 1 + tests/fm-axi-suite.test.sh | 30 +++++++++ 3 files changed, 115 insertions(+), 38 deletions(-) diff --git a/bin/fm-axi-suite.sh b/bin/fm-axi-suite.sh index d636b19876..c5416c1bd6 100755 --- a/bin/fm-axi-suite.sh +++ b/bin/fm-axi-suite.sh @@ -24,7 +24,8 @@ # A vessel copy that answers without a version would otherwise shadow an intact # external copy forever, so it is removed and reseeded instead of trusted. One # that never answers at all is reported and left in place, because a bounded -# probe cannot tell a hung copy from a very slow working one. +# probe cannot tell a hung copy from a very slow working one, and so is one that +# was never run because the probe budget was already spent. # # Hook setup for the hook-owning tools stays a shared user-global write that no # prefix can isolate; it is invoked by name through the vessel bin directory so @@ -37,9 +38,11 @@ # suite's cumulative registry lookup, install, and hook-setup time in seconds # (default 30), so an unreachable registry cannot multiply the stall across # every tool in the suite. FM_AXI_SUITE_PROBE_TIMEOUT bounds the suite's -# cumulative local `--version` probing in seconds (default 15) under a separate -# budget, so a hung binary can neither wedge session start nor consume the -# registry budget. FM_AXI_SUITE_TOOLS overrides the space-separated +# cumulative local `--version` probing in seconds (default 15). Each budget is +# charged only for the time its own calls spend, so a hung binary can neither +# wedge session start nor consume the registry budget, and slow registry or +# install work cannot consume the probe budget. FM_AXI_SUITE_TOOLS overrides +# the space-separated # suite tool list (default: quota-axi gh-axi tasks-axi gnhf lavish-axi # chrome-devtools-axi). set -u @@ -104,38 +107,60 @@ bounded() { esac } -STARTED=$(date +%s 2>/dev/null || echo 0) -PHASE_DEADLINE=$((STARTED + NET_TIMEOUT)) -PROBE_DEADLINE=$((STARTED + PROBE_TIMEOUT)) -remaining_budget() { - local deadline=$1 cap=$2 now rem - now=$(date +%s 2>/dev/null || echo 0) - rem=$((deadline - now)) +# Each budget is an accumulator over the time its own calls actually spend, not +# a wall-clock deadline: a deadline would let registry and install time drain +# the probe budget (and probe time drain the registry budget) even though the +# two bound unrelated failure modes. Callers must therefore stay in this shell - +# running a budgeted call inside a command substitution would discard the charge +# it made. +NET_SPENT=0 +PROBE_SPENT=0 +NET_GRANTED=0 +PROBE_GRANTED=0 + +budget_grant() { + local rem + rem=$(($1 - $2)) [ "$rem" -gt 0 ] || rem=0 - [ "$rem" -le "$cap" ] || rem=$cap printf '%s' "$rem" } +seconds_since() { + local now spent + now=$(date +%s 2>/dev/null || echo 0) + spent=$((now - $1)) + [ "$spent" -ge 0 ] || spent=0 + printf '%s' "$spent" +} + # Bounds the whole suite's cumulative network exposure to NET_TIMEOUT instead # of NET_TIMEOUT per tool, so an unreachable registry cannot multiply the -# stall across every entry in FM_AXI_SUITE_TOOLS. Once the phase budget is -# spent, remaining calls fail fast without touching the network at all. +# stall across every entry in FM_AXI_SUITE_TOOLS. Once the budget is spent, +# remaining calls fail fast without touching the network at all. net_call() { - local budget - budget=$(remaining_budget "$PHASE_DEADLINE" "$NET_TIMEOUT") - [ "$budget" -gt 0 ] || return 124 - bounded "$budget" "$@" + local started status=0 + NET_GRANTED=$(budget_grant "$NET_TIMEOUT" "$NET_SPENT") + [ "$NET_GRANTED" -gt 0 ] || return 124 + started=$(date +%s 2>/dev/null || echo 0) + bounded "$NET_GRANTED" "$@" || status=$? + NET_SPENT=$((NET_SPENT + $(seconds_since "$started"))) + return "$status" } # Running a local suite binary is its own stall risk: a half-installed shim can # hang instead of exiting, and it must never wedge bootstrap or session start. # Probes carry a cumulative budget of their own so a hung binary cannot spend -# the registry budget and an unreachable registry cannot starve the probes. +# the registry budget and slow registry or install work cannot starve the +# probes. PROBE_GRANTED is 0 when the budget was already spent, which tells a +# caller that the binary was never run rather than that it failed to answer. probe_call() { - local budget - budget=$(remaining_budget "$PROBE_DEADLINE" "$PROBE_TIMEOUT") - [ "$budget" -gt 0 ] || return 124 - bounded "$budget" "$@" + local started status=0 + PROBE_GRANTED=$(budget_grant "$PROBE_TIMEOUT" "$PROBE_SPENT") + [ "$PROBE_GRANTED" -gt 0 ] || return 124 + started=$(date +%s 2>/dev/null || echo 0) + bounded "$PROBE_GRANTED" "$@" || status=$? + PROBE_SPENT=$((PROBE_SPENT + $(seconds_since "$started"))) + return "$status" } is_hook_tool() { @@ -182,24 +207,31 @@ mkdir -p "$STATE" 2>/dev/null || { tmp_diag=$(mktemp "${TMPDIR:-/tmp}/fm-axi-suite-diag.XXXXXX") || exit 0 tmp_stuck=$(mktemp "${TMPDIR:-/tmp}/fm-axi-suite-stuck.XXXXXX") || { rm -f "$tmp_diag"; exit 0; } tmp_diag_persist=$(mktemp "${TMPDIR:-/tmp}/fm-axi-suite-diag-persist.XXXXXX") || { rm -f "$tmp_diag" "$tmp_stuck"; exit 0; } -trap 'rm -f "$tmp_diag" "$tmp_stuck" "$tmp_diag_persist"' EXIT +tmp_read=$(mktemp "${TMPDIR:-/tmp}/fm-axi-suite-read.XXXXXX") || { rm -f "$tmp_diag" "$tmp_stuck" "$tmp_diag_persist"; exit 0; } +trap 'rm -f "$tmp_diag" "$tmp_stuck" "$tmp_diag_persist" "$tmp_read"' EXIT # Reads one binary's version under the probe budget with stdin closed, and # publishes both halves of the answer: VERSION_READ is the parsed version and -# VERSION_PROBE distinguishes a copy that answered without a version (empty, -# and therefore replaceable) from one that never answered at all (timeout, -# which is reported and retried instead of removed). Every caller reuses the -# published values rather than probing the same binary twice. +# VERSION_PROBE separates a copy that answered without a version (empty, and +# therefore replaceable) from one that ran but never answered (timeout) and one +# that was never run because the probe budget was already spent (skipped). +# Only `empty` is evidence about the binary itself, so only `empty` may lead to +# a removal. Every caller reuses the published values rather than probing the +# same binary twice. The probe writes to a file because a command substitution +# would run the budgeted call in a subshell and discard its charge. VERSION_READ= VERSION_PROBE=empty read_version() { - local out status=0 + local status=0 VERSION_READ= VERSION_PROBE=empty - out=$(probe_call "$1" --version /dev/null) || status=$? - VERSION_READ=$(printf '%s\n' "$out" | grep -Eo '[0-9]+\.[0-9]+\.[0-9]+' | head -n 1) + : > "$tmp_read" + probe_call "$1" --version "$tmp_read" 2>/dev/null || status=$? + VERSION_READ=$(grep -Eo '[0-9]+\.[0-9]+\.[0-9]+' "$tmp_read" 2>/dev/null | head -n 1) if [ -n "$VERSION_READ" ]; then VERSION_PROBE=ok + elif [ "$PROBE_GRANTED" -eq 0 ]; then + VERSION_PROBE=skipped else case "$status" in 124|137|143) VERSION_PROBE=timeout ;; esac fi @@ -243,15 +275,18 @@ install_home_tool() { path=$(home_tool_path "$tool") if net_call npm install -g --prefix "$AXI_PREFIX" "$spec" >/dev/null 2>&1; then read_version "$path" - [ "$VERSION_READ" = "$expected" ] || status=1 + if [ "$VERSION_PROBE" = skipped ]; then + status=0 + elif [ "$VERSION_READ" != "$expected" ]; then + status=1 + fi else status=1 VERSION_READ= - VERSION_PROBE=empty + VERSION_PROBE=skipped [ ! -x "$path" ] || read_version "$path" fi - if [ "$status" -ne 0 ] && [ -e "$path" ] \ - && [ -z "$VERSION_READ" ] && [ "$VERSION_PROBE" != timeout ]; then + if [ "$status" -ne 0 ] && [ -e "$path" ] && [ "$VERSION_PROBE" = empty ]; then discard_home_tool "$tool" || true fi return "$status" @@ -314,6 +349,11 @@ for tool in $SUITE; do if [ -z "$home_version" ]; then if [ "$VERSION_PROBE" = timeout ]; then printf 'AXI_SUITE_STUCK: %s vessel copy at %s did not answer --version within %ss\n' \ + "$tool" "$AXI_PREFIX" "$PROBE_GRANTED" >> "$tmp_stuck" + continue + fi + if [ "$VERSION_PROBE" = skipped ]; then + printf 'AXI_SUITE_STUCK: %s vessel copy at %s was not checked: the %ss probe budget is spent\n' \ "$tool" "$AXI_PREFIX" "$PROBE_TIMEOUT" >> "$tmp_stuck" continue fi @@ -345,15 +385,21 @@ for tool in $SUITE; do read_version "$tool" installed=$VERSION_READ if [ -z "$installed" ]; then - printf 'AXI_SUITE_STUCK: %s installed version could not be read\n' "$tool" >> "$tmp_stuck" + if [ "$VERSION_PROBE" = skipped ]; then + printf 'AXI_SUITE_STUCK: %s was not checked: the %ss probe budget is spent\n' \ + "$tool" "$PROBE_TIMEOUT" >> "$tmp_stuck" + else + printf 'AXI_SUITE_STUCK: %s installed version could not be read\n' "$tool" >> "$tmp_stuck" + fi continue fi fi - if ! latest=$(net_call npm view "$tool" version 2>/dev/null); then + : > "$tmp_read" + if ! net_call npm view "$tool" version >"$tmp_read" 2>/dev/null; then printf 'AXI_SUITE_STUCK: %s latest version lookup failed\n' "$tool" >> "$tmp_stuck" continue fi - latest=$(printf '%s\n' "$latest" | sed -nE 's/^([0-9]+\.[0-9]+\.[0-9]+)$/\1/p' | head -n 1) + latest=$(sed -nE 's/^([0-9]+\.[0-9]+\.[0-9]+)$/\1/p' "$tmp_read" 2>/dev/null | head -n 1) if [ -z "$latest" ]; then printf 'AXI_SUITE_STUCK: %s registry returned an invalid version\n' "$tool" >> "$tmp_stuck" continue diff --git a/docs/configuration.md b/docs/configuration.md index 5bda436f40..e5f7f76a76 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -422,6 +422,7 @@ Hook setup for `gh-axi`, `chrome-devtools-axi`, and `lavish-axi` is the one part The updater and the printed install commands therefore invoke the tool by name with the vessel bin directory first on `PATH`, so the installer records the portable command name rather than one home's private path and every vessel converges on identical content. Removing a vessel home cannot break another home's hook wiring as a result, but the wiring itself stays shared: the last vessel to run hook setup owns the version of that shared config on disk. `FM_AXI_SUITE_NETWORK_TIMEOUT` bounds the whole suite's registry, install, and hook work, `FM_AXI_SUITE_PROBE_TIMEOUT` separately bounds its cumulative local version probing so a hung suite binary cannot wedge session start, and `FM_AXI_SUITE_DISABLE` is reserved for tests or emergency diagnosis. +Each budget is charged only for the time its own calls spend, so a slow registry or a long first-cutover install cannot exhaust the probe budget and leave later tools unseeded, and a hung binary cannot exhaust the registry budget. ### Upstream firstmate and curated-fork checks diff --git a/tests/fm-axi-suite.test.sh b/tests/fm-axi-suite.test.sh index f5ac907021..1added4907 100755 --- a/tests/fm-axi-suite.test.sh +++ b/tests/fm-axi-suite.test.sh @@ -33,6 +33,7 @@ make_npm() { cat > "$bin/npm" <<'SH' #!/usr/bin/env bash if [ "${1:-}" = view ]; then + sleep "${FM_TEST_VIEW_SLEEP:-0}" sed -n "s/^${2}=//p" "$FM_TEST_VERSIONS" exit 0 fi @@ -396,6 +397,34 @@ test_hung_vessel_copy_is_bounded_and_kept() { pass "a hung vessel copy is bounded, reported, and not removed" } +test_registry_and_install_time_do_not_spend_the_probe_budget() { + local w out t + w="$TMP_ROOT/probe-budget" + mkdir -p "$w/bin" "$w/home" "$w/state" + make_npm "$w/bin" "$w/versions" "$w/install.log" + : > "$w/versions" + for t in slow-one-axi slow-two-axi slow-three-axi; do + make_tool "$w/bin" "$t" 1.0.0 + printf '%s=1.0.0\n' "$t" >> "$w/versions" + done + out=$(PATH="$w/bin:$BASE_PATH" FM_HOME="$w/home" FM_STATE_OVERRIDE="$w/state" \ + FM_AXI_SUITE_DISABLE=0 FM_AXI_SUITE_TOOLS="slow-one-axi slow-two-axi slow-three-axi" \ + FM_AXI_SUITE_CHECK_INTERVAL=0 FM_AXI_SUITE_NETWORK_TIMEOUT=60 FM_AXI_SUITE_PROBE_TIMEOUT=2 \ + FM_TEST_VERSIONS="$w/versions" FM_TEST_INSTALL_LOG="$w/install.log" \ + FM_TEST_VIEW_SLEEP=1 FM_TEST_INSTALL_SLEEP=1 \ + "$ROOT/bin/fm-axi-suite.sh" --force) + assert_no_grep 'AXI_SUITE_STUCK' <(printf '%s\n' "$out") \ + "slow registry and install work was charged to the probe budget" + for t in slow-one-axi slow-two-axi slow-three-axi; do + assert_contains "$out" "AXI_SUITE_UPDATED: $t 1.0.0 installed in vessel prefix" \ + "$t was not seeded after earlier tools spent wall-clock time" + assert_grep "--prefix $w/home/.local/axi $t@1.0.0" "$w/install.log" \ + "$t never reached the vessel prefix" + done + assert_absent "$w/state/axi-suite-update.stuck" "a successful cutover persisted a stuck signal" + pass "registry and install time never spends the local probe budget" +} + test_unpublished_ahead_version_is_not_a_recurring_alarm() { local w out w="$TMP_ROOT/ahead-unpublished" @@ -423,6 +452,7 @@ test_currency_clock_survives_prefix_cutover test_failed_seed_still_honours_the_cadence test_unreadable_vessel_copy_is_replaced test_hung_vessel_copy_is_bounded_and_kept +test_registry_and_install_time_do_not_spend_the_probe_budget test_unpublished_ahead_version_is_not_a_recurring_alarm test_failed_update_persists_stuck_signal test_check_only_never_runs_hook_setup From 388272915e1e80f6781b68eeb0a785298d74e0b5 Mon Sep 17 00:00:00 2001 From: Freudator86 <94322668+Freudator86@users.noreply.github.com> Date: Tue, 28 Jul 2026 04:17:03 +0000 Subject: [PATCH 5/8] no-mistakes(review): give first-cutover seeding its own bounded budget --- .agents/skills/bootstrap-diagnostics/SKILL.md | 2 + bin/fm-axi-suite.sh | 137 +++++++++++++----- docs/configuration.md | 7 +- tests/fm-axi-suite.test.sh | 63 ++++++++ 4 files changed, 174 insertions(+), 35 deletions(-) diff --git a/.agents/skills/bootstrap-diagnostics/SKILL.md b/.agents/skills/bootstrap-diagnostics/SKILL.md index 7815fc8fa0..bf5adfdff7 100644 --- a/.agents/skills/bootstrap-diagnostics/SKILL.md +++ b/.agents/skills/bootstrap-diagnostics/SKILL.md @@ -55,6 +55,8 @@ When any diagnostic needs captain attention, report the plain consequence and re For the locally-ahead case the external copy keeps working, so the decision is whether to publish that build or let the vessel accept the registry version. - `AXI_SUITE_STUCK: ` - the vessel could not check or apply an eligible update and persisted the condition in `state/axi-suite-update.stuck`; investigate the local install path first, and if the vessel cannot repair itself, relay the status through the existing Bridge workflow by dispatching a crewmate rather than calling project automation directly. The condition is retried on the next currency window rather than on every session, so a line that keeps reappearing means the underlying cause is still present. + A `vessel-prefix seeding ... was not attempted: the s seeding budget is spent` detail is not a broken install: the vessel ran out of its one-time seeding budget, the external copy is still the working fallback, and the next window seeds the rest, so report it only if it survives a second window. + While seeding runs, the script names each installing tool and the remaining seeding budget on standard error; that progress output is not a diagnostic and needs no handling. - `FIRSTMATE_UPDATE_AVAILABLE: ` - the configured upstream firstmate has an upstream-only instruction-surface change; dispatch a crewmate to send an All-Ships update notice through the existing Bridge workflow, and never call Bridge project automation directly from firstmate or the check script. - `FIRSTMATE_UPDATE_STUCK: ` - the read-only upstream framework comparison failed and persisted the condition in `state/firstmate-update.stuck`; investigate the repository or network failure, and do not broadcast an update until the comparison succeeds. A `config/firstmate-update-base is unusable` detail is not a network failure: the check refused a configured comparison base, so handle it as `CURRENCY_BASE` below. diff --git a/bin/fm-axi-suite.sh b/bin/fm-axi-suite.sh index c5416c1bd6..41da6fb37a 100755 --- a/bin/fm-axi-suite.sh +++ b/bin/fm-axi-suite.sh @@ -38,11 +38,16 @@ # suite's cumulative registry lookup, install, and hook-setup time in seconds # (default 30), so an unreachable registry cannot multiply the stall across # every tool in the suite. FM_AXI_SUITE_PROBE_TIMEOUT bounds the suite's -# cumulative local `--version` probing in seconds (default 15). Each budget is -# charged only for the time its own calls spend, so a hung binary can neither -# wedge session start nor consume the registry budget, and slow registry or -# install work cannot consume the probe budget. FM_AXI_SUITE_TOOLS overrides -# the space-separated +# cumulative local `--version` probing in seconds (default 15). +# FM_AXI_SUITE_SEED_TIMEOUT bounds the one-time installs that give a fresh +# vessel its own copies (default 120), because a first cutover installs the +# whole suite at once and would otherwise exhaust the steady-state network +# budget and report a healthy vessel as stuck. Each budget is charged only for +# the time its own calls spend, so no kind of work can consume another's. +# Seeding announces its progress on stderr while it runs, and a seed that +# genuinely stalls or outlives the seeding budget is still reported as +# AXI_SUITE_STUCK and retried on the next cadence window. +# FM_AXI_SUITE_TOOLS overrides the space-separated # suite tool list (default: quota-axi gh-axi tasks-axi gnhf lavish-axi # chrome-devtools-axi). set -u @@ -62,6 +67,7 @@ FORCE=0 SUITE="${FM_AXI_SUITE_TOOLS:-quota-axi gh-axi tasks-axi gnhf lavish-axi chrome-devtools-axi}" NET_TIMEOUT=${FM_AXI_SUITE_NETWORK_TIMEOUT:-30} PROBE_TIMEOUT=${FM_AXI_SUITE_PROBE_TIMEOUT:-15} +SEED_TIMEOUT=${FM_AXI_SUITE_SEED_TIMEOUT:-120} while [ $# -gt 0 ]; do case "$1" in @@ -76,6 +82,7 @@ done case "$INTERVAL" in ''|*[!0-9]*) INTERVAL=86400 ;; esac case "$NET_TIMEOUT" in ''|*[!0-9]*) NET_TIMEOUT=30 ;; esac case "$PROBE_TIMEOUT" in ''|*[!0-9]*) PROBE_TIMEOUT=15 ;; esac +case "$SEED_TIMEOUT" in ''|*[!0-9]*) SEED_TIMEOUT=120 ;; esac HAVE_TIMEOUT=none if command -v timeout >/dev/null 2>&1; then HAVE_TIMEOUT=timeout @@ -108,15 +115,24 @@ bounded() { } # Each budget is an accumulator over the time its own calls actually spend, not -# a wall-clock deadline: a deadline would let registry and install time drain -# the probe budget (and probe time drain the registry budget) even though the -# two bound unrelated failure modes. Callers must therefore stay in this shell - -# running a budgeted call inside a command substitution would discard the charge -# it made. +# a wall-clock deadline: a deadline would let one kind of work drain another's +# budget even though they bound unrelated failure modes. Callers must therefore +# stay in this shell - running a budgeted call inside a command substitution +# would discard the charge it made. +# +# The three budgets exist because their work has three different shapes: steady +# registry and update traffic (net), running a local suite binary (probe), and +# the one-time installs that give a fresh vessel its own copies (seed). Seeding +# is charged separately because a first cutover installs the whole suite at +# once, which would otherwise exhaust the tight steady-state network budget and +# report a healthy vessel as stuck. NET_SPENT=0 PROBE_SPENT=0 -NET_GRANTED=0 +SEED_SPENT=0 PROBE_GRANTED=0 +SEED_GRANTED=0 +BUDGET_GRANTED=0 +BUDGET_SPENT_DELTA=0 budget_grant() { local rem @@ -133,33 +149,52 @@ seconds_since() { printf '%s' "$spent" } -# Bounds the whole suite's cumulative network exposure to NET_TIMEOUT instead -# of NET_TIMEOUT per tool, so an unreachable registry cannot multiply the -# stall across every entry in FM_AXI_SUITE_TOOLS. Once the budget is spent, -# remaining calls fail fast without touching the network at all. -net_call() { - local started status=0 - NET_GRANTED=$(budget_grant "$NET_TIMEOUT" "$NET_SPENT") - [ "$NET_GRANTED" -gt 0 ] || return 124 +# Runs one bounded call against given , publishing the +# granted budget and the time the call actually consumed so each wrapper can +# charge its own accumulator. Returns 124 without running anything at all once +# that budget is spent. +run_budgeted() { + local cap=$1 spent=$2 started status=0 + shift 2 + BUDGET_SPENT_DELTA=0 + BUDGET_GRANTED=$(budget_grant "$cap" "$spent") + [ "$BUDGET_GRANTED" -gt 0 ] || return 124 started=$(date +%s 2>/dev/null || echo 0) - bounded "$NET_GRANTED" "$@" || status=$? - NET_SPENT=$((NET_SPENT + $(seconds_since "$started"))) + bounded "$BUDGET_GRANTED" "$@" || status=$? + BUDGET_SPENT_DELTA=$(seconds_since "$started") + return "$status" +} + +# Bounds the whole suite's cumulative registry and update exposure to +# NET_TIMEOUT instead of NET_TIMEOUT per tool, so an unreachable registry +# cannot multiply the stall across every entry in FM_AXI_SUITE_TOOLS. +net_call() { + local status=0 + run_budgeted "$NET_TIMEOUT" "$NET_SPENT" "$@" || status=$? + NET_SPENT=$((NET_SPENT + BUDGET_SPENT_DELTA)) return "$status" } # Running a local suite binary is its own stall risk: a half-installed shim can # hang instead of exiting, and it must never wedge bootstrap or session start. -# Probes carry a cumulative budget of their own so a hung binary cannot spend -# the registry budget and slow registry or install work cannot starve the -# probes. PROBE_GRANTED is 0 when the budget was already spent, which tells a -# caller that the binary was never run rather than that it failed to answer. +# PROBE_GRANTED is 0 when the budget was already spent, which tells a caller +# that the binary was never run rather than that it failed to answer. probe_call() { - local started status=0 - PROBE_GRANTED=$(budget_grant "$PROBE_TIMEOUT" "$PROBE_SPENT") - [ "$PROBE_GRANTED" -gt 0 ] || return 124 - started=$(date +%s 2>/dev/null || echo 0) - bounded "$PROBE_GRANTED" "$@" || status=$? - PROBE_SPENT=$((PROBE_SPENT + $(seconds_since "$started"))) + local status=0 + run_budgeted "$PROBE_TIMEOUT" "$PROBE_SPENT" "$@" || status=$? + PROBE_SPENT=$((PROBE_SPENT + BUDGET_SPENT_DELTA)) + PROBE_GRANTED=$BUDGET_GRANTED + return "$status" +} + +# The one-time installs that give this vessel its own copies. SEED_GRANTED is 0 +# when the budget was already spent, which separates "never attempted" from an +# install that ran and failed. +seed_call() { + local status=0 + run_budgeted "$SEED_TIMEOUT" "$SEED_SPENT" "$@" || status=$? + SEED_SPENT=$((SEED_SPENT + BUDGET_SPENT_DELTA)) + SEED_GRANTED=$BUDGET_GRANTED return "$status" } @@ -269,11 +304,42 @@ discard_home_tool() { [ ! -e "$path" ] } +# Seeding can take much longer than a steady-state check, and it runs inside a +# locked bootstrap where the operator sees only a pause. These lines go to +# stderr as the work happens - stdout is collected and emitted once at the end - +# so a fresh vessel shows which tool is installing and how much of the seeding +# budget is left, which is what separates normal installation work from a hang. +SEEDING_ANNOUNCED=0 +announce_seeding() { + local spec=$1 left + left=$(budget_grant "$SEED_TIMEOUT" "$SEED_SPENT") + if [ "$SEEDING_ANNOUNCED" -eq 0 ]; then + SEEDING_ANNOUNCED=1 + printf 'fm-axi-suite.sh: seeding this vessel AXI prefix (%s) for the first time; these installs run under a separate %ss seeding budget, so this check takes longer than a steady-state one\n' \ + "$AXI_PREFIX" "$SEED_TIMEOUT" >&2 + fi + printf 'fm-axi-suite.sh: installing %s into %s (%ss of the seeding budget left)\n' \ + "$spec" "$AXI_PREFIX" "$left" >&2 +} + +# An install that creates the vessel's copy is first-cutover seeding; one that +# replaces an existing vessel copy is an ordinary update. Deriving the budget +# from the copy itself keeps the two entry points - seed_home_tool and the +# patch/minor update path - from drifting apart. install_home_tool() { - local tool=$1 spec=$2 expected path status=0 + local tool=$1 spec=$2 expected path status=0 install_status=0 expected=$3 path=$(home_tool_path "$tool") - if net_call npm install -g --prefix "$AXI_PREFIX" "$spec" >/dev/null 2>&1; then + if [ -x "$path" ]; then + net_call npm install -g --prefix "$AXI_PREFIX" "$spec" >/dev/null 2>&1 || install_status=$? + else + SEED_GRANTED=0 + if [ "$(budget_grant "$SEED_TIMEOUT" "$SEED_SPENT")" -gt 0 ]; then + announce_seeding "$spec" + fi + seed_call npm install -g --prefix "$AXI_PREFIX" "$spec" >/dev/null 2>&1 || install_status=$? + fi + if [ "$install_status" -eq 0 ]; then read_version "$path" if [ "$VERSION_PROBE" = skipped ]; then status=0 @@ -300,6 +366,11 @@ setup_home_hooks() { seed_home_tool() { local tool=$1 version=$2 registry_latest=${3:-} if ! install_home_tool "$tool" "$tool@$version" "$version"; then + if [ "$SEED_GRANTED" -eq 0 ]; then + printf 'AXI_SUITE_STUCK: %s vessel-prefix seeding at %s was not attempted: the %ss seeding budget is spent; the external copy stays the fallback and the next check retries\n' \ + "$tool" "$AXI_PREFIX" "$SEED_TIMEOUT" >> "$tmp_stuck" + return 1 + fi if [ -n "$registry_latest" ]; then printf 'AXI_SUITE_REVIEW: %s %s is ahead of registry latest %s and is not installable into %s; the external copy stays the fallback until it is published or %s is accepted (install: %s)\n' \ "$tool" "$version" "$registry_latest" "$AXI_PREFIX" "$registry_latest" \ diff --git a/docs/configuration.md b/docs/configuration.md index e5f7f76a76..7a132be087 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -421,8 +421,11 @@ The marker records the attempt and not its outcome: a home that could not seed e Hook setup for `gh-axi`, `chrome-devtools-axi`, and `lavish-axi` is the one part of the suite that the prefix cannot isolate: ` setup hooks` writes the user-global harness surfaces (`~/.claude/settings.json`, `~/.codex/`, `~/.config/opencode/plugins/`) that every vessel on the host shares. The updater and the printed install commands therefore invoke the tool by name with the vessel bin directory first on `PATH`, so the installer records the portable command name rather than one home's private path and every vessel converges on identical content. Removing a vessel home cannot break another home's hook wiring as a result, but the wiring itself stays shared: the last vessel to run hook setup owns the version of that shared config on disk. -`FM_AXI_SUITE_NETWORK_TIMEOUT` bounds the whole suite's registry, install, and hook work, `FM_AXI_SUITE_PROBE_TIMEOUT` separately bounds its cumulative local version probing so a hung suite binary cannot wedge session start, and `FM_AXI_SUITE_DISABLE` is reserved for tests or emergency diagnosis. -Each budget is charged only for the time its own calls spend, so a slow registry or a long first-cutover install cannot exhaust the probe budget and leave later tools unseeded, and a hung binary cannot exhaust the registry budget. +`FM_AXI_SUITE_NETWORK_TIMEOUT` bounds the suite's steady-state registry, update, and hook work, `FM_AXI_SUITE_PROBE_TIMEOUT` separately bounds its cumulative local version probing so a hung suite binary cannot wedge session start, `FM_AXI_SUITE_SEED_TIMEOUT` separately bounds the one-time installs that give a fresh vessel its own copies, and `FM_AXI_SUITE_DISABLE` is reserved for tests or emergency diagnosis. +Each budget is charged only for the time its own calls spend, so no kind of work can exhaust another's: a first cutover installs the whole suite at once and would otherwise consume the tight steady-state network budget and report a healthy vessel as stuck. +An install that creates the vessel's copy is charged to the seeding budget and an install that replaces an existing vessel copy is an ordinary update, so the distinction never depends on which code path reached it. +Because seeding is much slower than a steady-state check and runs inside locked bootstrap, it announces on standard error which tool is installing and how much of the seeding budget remains, which is what lets an operator watching a fresh vessel tell normal installation work from a hang. +A seed that genuinely stalls, or that is never attempted because the seeding budget is already spent, is still reported as `AXI_SUITE_STUCK:` naming which of the two happened; the external copy remains the fallback and the next cadence window retries. ### Upstream firstmate and curated-fork checks diff --git a/tests/fm-axi-suite.test.sh b/tests/fm-axi-suite.test.sh index 1added4907..303625959e 100755 --- a/tests/fm-axi-suite.test.sh +++ b/tests/fm-axi-suite.test.sh @@ -397,6 +397,67 @@ test_hung_vessel_copy_is_bounded_and_kept() { pass "a hung vessel copy is bounded, reported, and not removed" } +test_first_cutover_seeds_whole_suite_without_alarming() { + local w out t tools + w="$TMP_ROOT/first-cutover" + tools="one-axi two-axi three-axi four-axi five-axi six-axi" + mkdir -p "$w/bin" "$w/home" "$w/state" + make_npm "$w/bin" "$w/versions" "$w/install.log" + : > "$w/versions" + for t in $tools; do + make_tool "$w/bin" "$t" 1.0.0 + printf '%s=1.0.0\n' "$t" >> "$w/versions" + done + out=$(PATH="$w/bin:$BASE_PATH" FM_HOME="$w/home" FM_STATE_OVERRIDE="$w/state" \ + FM_AXI_SUITE_DISABLE=0 FM_AXI_SUITE_TOOLS="$tools" FM_AXI_SUITE_CHECK_INTERVAL=0 \ + FM_AXI_SUITE_NETWORK_TIMEOUT=3 FM_AXI_SUITE_SEED_TIMEOUT=60 \ + FM_TEST_VERSIONS="$w/versions" FM_TEST_INSTALL_LOG="$w/install.log" \ + FM_TEST_INSTALL_SLEEP=1 \ + "$ROOT/bin/fm-axi-suite.sh" --force 2>"$w/progress.log") + for t in $tools; do + assert_contains "$out" "AXI_SUITE_UPDATED: $t 1.0.0 installed in vessel prefix" \ + "$t was not seeded during the first cutover" + assert_grep "--prefix $w/home/.local/axi $t@1.0.0" "$w/install.log" \ + "$t never reached the vessel prefix" + done + assert_absent "$w/state/axi-suite-update.stuck" \ + "a healthy six-tool first cutover raised a stuck alarm" + assert_grep 'seeding this vessel AXI prefix' "$w/progress.log" \ + "the first cutover never announced that seeding was in progress" + assert_grep 'seeding budget left' "$w/progress.log" \ + "the seeding progress output did not report the remaining budget" + pass "a healthy six-tool first cutover seeds every tool without alarming" +} + +test_stalled_first_cutover_alarms_and_stays_bounded() { + local w out start end elapsed + w="$TMP_ROOT/stalled-cutover" + mkdir -p "$w/bin" "$w/home" "$w/state" + make_npm "$w/bin" "$w/versions" "$w/install.log" + make_tool "$w/bin" stall-one-axi 1.0.0 + make_tool "$w/bin" stall-two-axi 1.0.0 + printf '%s\n' 'stall-one-axi=1.0.0' 'stall-two-axi=1.0.0' > "$w/versions" + start=$(date +%s) + out=$(PATH="$w/bin:$BASE_PATH" FM_HOME="$w/home" FM_STATE_OVERRIDE="$w/state" \ + FM_AXI_SUITE_DISABLE=0 FM_AXI_SUITE_TOOLS="stall-one-axi stall-two-axi" \ + FM_AXI_SUITE_CHECK_INTERVAL=0 FM_AXI_SUITE_SEED_TIMEOUT=1 \ + FM_TEST_VERSIONS="$w/versions" FM_TEST_INSTALL_LOG="$w/install.log" \ + FM_TEST_INSTALL_SLEEP=30 \ + "$ROOT/bin/fm-axi-suite.sh" --force 2>/dev/null) + end=$(date +%s) + elapsed=$((end - start)) + [ "$elapsed" -lt 20 ] || fail "a stalled first cutover ran for ${elapsed}s instead of staying inside its seeding budget" + assert_contains "$out" 'AXI_SUITE_STUCK: stall-one-axi vessel-prefix installation' \ + "a stalled seeding install was not reported" + assert_contains "$out" 'AXI_SUITE_STUCK: stall-two-axi vessel-prefix seeding at' \ + "the tool left unseeded by the spent budget was not reported" + assert_contains "$out" 'seeding budget is spent' \ + "the unattempted seed did not name the spent seeding budget" + assert_present "$w/state/axi-suite-prefix-v1.cutover" \ + "a stalled first cutover blocked the cadence marker" + pass "a stalled first cutover alarms clearly and stays inside its seeding budget" +} + test_registry_and_install_time_do_not_spend_the_probe_budget() { local w out t w="$TMP_ROOT/probe-budget" @@ -453,6 +514,8 @@ test_failed_seed_still_honours_the_cadence test_unreadable_vessel_copy_is_replaced test_hung_vessel_copy_is_bounded_and_kept test_registry_and_install_time_do_not_spend_the_probe_budget +test_first_cutover_seeds_whole_suite_without_alarming +test_stalled_first_cutover_alarms_and_stays_bounded test_unpublished_ahead_version_is_not_a_recurring_alarm test_failed_update_persists_stuck_signal test_check_only_never_runs_hook_setup From ab36dc9f9fba57642d8a5ff272c71a35591e7b81 Mon Sep 17 00:00:00 2001 From: Freudator86 <94322668+Freudator86@users.noreply.github.com> Date: Tue, 28 Jul 2026 04:40:28 +0000 Subject: [PATCH 6/8] no-mistakes(review): report spent seeding budget identically from both paths --- bin/fm-axi-suite.sh | 57 +++++++++++++++++++++++++++++--------- docs/configuration.md | 5 ++-- tests/fm-axi-suite.test.sh | 26 +++++++++++++++++ 3 files changed, 73 insertions(+), 15 deletions(-) diff --git a/bin/fm-axi-suite.sh b/bin/fm-axi-suite.sh index 41da6fb37a..b78a096800 100755 --- a/bin/fm-axi-suite.sh +++ b/bin/fm-axi-suite.sh @@ -44,9 +44,11 @@ # whole suite at once and would otherwise exhaust the steady-state network # budget and report a healthy vessel as stuck. Each budget is charged only for # the time its own calls spend, so no kind of work can consume another's. -# Seeding announces its progress on stderr while it runs, and a seed that -# genuinely stalls or outlives the seeding budget is still reported as -# AXI_SUITE_STUCK and retried on the next cadence window. +# Seeding reports its progress and a closing summary on stderr - live when +# bootstrap is run directly in a terminal, and captured with the rest of the +# bootstrap output under automated session start. A seed that genuinely stalls +# or outlives the seeding budget is still reported as AXI_SUITE_STUCK, names +# which of the two happened, and is retried on the next cadence window. # FM_AXI_SUITE_TOOLS overrides the space-separated # suite tool list (default: quota-axi gh-axi tasks-axi gnhf lavish-axi # chrome-devtools-axi). @@ -304,12 +306,20 @@ discard_home_tool() { [ ! -e "$path" ] } -# Seeding can take much longer than a steady-state check, and it runs inside a -# locked bootstrap where the operator sees only a pause. These lines go to -# stderr as the work happens - stdout is collected and emitted once at the end - -# so a fresh vessel shows which tool is installing and how much of the seeding -# budget is left, which is what separates normal installation work from a hang. +# Seeding can take much longer than a steady-state check, so it says which tool +# it is installing and how much of the seeding budget is left. These lines go to +# stderr, separate from the diagnostics stdout carries. +# +# How live that is depends on the caller: a captain running bin/fm-bootstrap.sh +# in a terminal sees each line as the work happens, but bin/fm-session-start.sh +# captures bootstrap with `2>&1` inside a command substitution, so under +# automated session start these lines are buffered with everything else and +# reach the digest only once bootstrap returns. The per-tool lines and the +# closing summary are therefore written to be readable after the fact as well: +# together they say how far the pass got and how much budget it consumed, which +# is what makes a later AXI_SUITE_STUCK budget line interpretable. SEEDING_ANNOUNCED=0 +SEEDED_COUNT=0 announce_seeding() { local spec=$1 left left=$(budget_grant "$SEED_TIMEOUT" "$SEED_SPENT") @@ -318,21 +328,31 @@ announce_seeding() { printf 'fm-axi-suite.sh: seeding this vessel AXI prefix (%s) for the first time; these installs run under a separate %ss seeding budget, so this check takes longer than a steady-state one\n' \ "$AXI_PREFIX" "$SEED_TIMEOUT" >&2 fi + SEEDED_COUNT=$((SEEDED_COUNT + 1)) printf 'fm-axi-suite.sh: installing %s into %s (%ss of the seeding budget left)\n' \ "$spec" "$AXI_PREFIX" "$left" >&2 } +summarize_seeding() { + [ "$SEEDING_ANNOUNCED" -eq 1 ] || return 0 + printf 'fm-axi-suite.sh: seeding pass finished: %s install(s) attempted into %s, %ss of the %ss seeding budget used\n' \ + "$SEEDED_COUNT" "$AXI_PREFIX" "$SEED_SPENT" "$SEED_TIMEOUT" >&2 +} + # An install that creates the vessel's copy is first-cutover seeding; one that # replaces an existing vessel copy is an ordinary update. Deriving the budget # from the copy itself keeps the two entry points - seed_home_tool and the # patch/minor update path - from drifting apart. +INSTALL_BUDGET=net install_home_tool() { local tool=$1 spec=$2 expected path status=0 install_status=0 expected=$3 path=$(home_tool_path "$tool") if [ -x "$path" ]; then + INSTALL_BUDGET=net net_call npm install -g --prefix "$AXI_PREFIX" "$spec" >/dev/null 2>&1 || install_status=$? else + INSTALL_BUDGET=seed SEED_GRANTED=0 if [ "$(budget_grant "$SEED_TIMEOUT" "$SEED_SPENT")" -gt 0 ]; then announce_seeding "$spec" @@ -363,12 +383,20 @@ setup_home_hooks() { net_call env PATH="$AXI_BIN:$PATH" "$tool" setup hooks >/dev/null 2>&1 } +# Both install call sites can be the one that seeds this vessel, so both must +# describe a spent seeding budget the same way: an install that was never +# allowed to start is not a broken install. Reporting it from the state +# install_home_tool published keeps the two descriptions from drifting apart. +report_unattempted_seed() { + [ "$INSTALL_BUDGET" = seed ] && [ "$SEED_GRANTED" -eq 0 ] || return 1 + printf 'AXI_SUITE_STUCK: %s vessel-prefix seeding at %s was not attempted: the %ss seeding budget is spent; the external copy stays the fallback and the next check retries\n' \ + "$1" "$AXI_PREFIX" "$SEED_TIMEOUT" >> "$tmp_stuck" +} + seed_home_tool() { local tool=$1 version=$2 registry_latest=${3:-} if ! install_home_tool "$tool" "$tool@$version" "$version"; then - if [ "$SEED_GRANTED" -eq 0 ]; then - printf 'AXI_SUITE_STUCK: %s vessel-prefix seeding at %s was not attempted: the %ss seeding budget is spent; the external copy stays the fallback and the next check retries\n' \ - "$tool" "$AXI_PREFIX" "$SEED_TIMEOUT" >> "$tmp_stuck" + if report_unattempted_seed "$tool"; then return 1 fi if [ -n "$registry_latest" ]; then @@ -503,8 +531,10 @@ for tool in $SUITE; do continue } if ! install_home_tool "$tool" "$tool@$latest" "$latest"; then - printf 'AXI_SUITE_STUCK: %s automatic update %s -> %s failed (npm prefix %s)\n' \ - "$tool" "$installed" "$latest" "$AXI_PREFIX" >> "$tmp_stuck" + if ! report_unattempted_seed "$tool"; then + printf 'AXI_SUITE_STUCK: %s automatic update %s -> %s failed (npm prefix %s)\n' \ + "$tool" "$installed" "$latest" "$AXI_PREFIX" >> "$tmp_stuck" + fi continue fi if is_hook_tool "$tool" && ! setup_home_hooks "$tool"; then @@ -522,6 +552,7 @@ if [ "$CHECK_ONLY" -eq 0 ]; then rm -f "$DIAGNOSTICS" fi if [ -s "$tmp_stuck" ]; then cp "$tmp_stuck" "$STUCK"; else rm -f "$STUCK"; fi + summarize_seeding printf '%s\n' "$now" > "$STAMP" # Records the attempt, not its outcome: a home that could not seed every tool # retries on the next cadence window instead of paying a full sweep, and a diff --git a/docs/configuration.md b/docs/configuration.md index 7a132be087..5e528cabe2 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -424,8 +424,9 @@ Removing a vessel home cannot break another home's hook wiring as a result, but `FM_AXI_SUITE_NETWORK_TIMEOUT` bounds the suite's steady-state registry, update, and hook work, `FM_AXI_SUITE_PROBE_TIMEOUT` separately bounds its cumulative local version probing so a hung suite binary cannot wedge session start, `FM_AXI_SUITE_SEED_TIMEOUT` separately bounds the one-time installs that give a fresh vessel its own copies, and `FM_AXI_SUITE_DISABLE` is reserved for tests or emergency diagnosis. Each budget is charged only for the time its own calls spend, so no kind of work can exhaust another's: a first cutover installs the whole suite at once and would otherwise consume the tight steady-state network budget and report a healthy vessel as stuck. An install that creates the vessel's copy is charged to the seeding budget and an install that replaces an existing vessel copy is an ordinary update, so the distinction never depends on which code path reached it. -Because seeding is much slower than a steady-state check and runs inside locked bootstrap, it announces on standard error which tool is installing and how much of the seeding budget remains, which is what lets an operator watching a fresh vessel tell normal installation work from a hang. -A seed that genuinely stalls, or that is never attempted because the seeding budget is already spent, is still reported as `AXI_SUITE_STUCK:` naming which of the two happened; the external copy remains the fallback and the next cadence window retries. +Because seeding is much slower than a steady-state check, it reports on standard error which tool is installing and how much of the seeding budget remains, then closes with how many installs it attempted and how much of the budget it used. +That output streams live when a captain runs `bin/fm-bootstrap.sh` in a terminal; `bin/fm-session-start.sh` captures bootstrap output for the digest, so under automated session start the same lines arrive together once bootstrap returns, and the summary is what makes the pass interpretable after the fact. +A seed that genuinely stalls, or that is never attempted because the seeding budget is already spent, is still reported as `AXI_SUITE_STUCK:` naming which of the two happened, identically from both install paths; the external copy remains the fallback and the next cadence window retries. ### Upstream firstmate and curated-fork checks diff --git a/tests/fm-axi-suite.test.sh b/tests/fm-axi-suite.test.sh index 303625959e..9620934ca5 100755 --- a/tests/fm-axi-suite.test.sh +++ b/tests/fm-axi-suite.test.sh @@ -458,6 +458,31 @@ test_stalled_first_cutover_alarms_and_stays_bounded() { pass "a stalled first cutover alarms clearly and stays inside its seeding budget" } +test_stalled_cutover_of_stale_installs_reports_the_same_way() { + local w out + w="$TMP_ROOT/stalled-stale-cutover" + mkdir -p "$w/bin" "$w/home" "$w/state" + make_npm "$w/bin" "$w/versions" "$w/install.log" + make_tool "$w/bin" stale-one-axi 1.0.0 + make_tool "$w/bin" stale-two-axi 1.0.0 + printf '%s\n' 'stale-one-axi=1.0.1' 'stale-two-axi=1.0.1' > "$w/versions" + out=$(PATH="$w/bin:$BASE_PATH" FM_HOME="$w/home" FM_STATE_OVERRIDE="$w/state" \ + FM_AXI_SUITE_DISABLE=0 FM_AXI_SUITE_TOOLS="stale-one-axi stale-two-axi" \ + FM_AXI_SUITE_CHECK_INTERVAL=0 FM_AXI_SUITE_SEED_TIMEOUT=1 \ + FM_TEST_VERSIONS="$w/versions" FM_TEST_INSTALL_LOG="$w/install.log" \ + FM_TEST_INSTALL_SLEEP=30 \ + "$ROOT/bin/fm-axi-suite.sh" --force 2>"$w/progress.log") + assert_contains "$out" 'AXI_SUITE_STUCK: stale-two-axi vessel-prefix seeding at' \ + "a first cutover through the update path blamed a broken install for a spent seeding budget" + assert_contains "$out" 'seeding budget is spent' \ + "the unattempted seed did not name the spent seeding budget" + assert_grep 'seeding this vessel AXI prefix' "$w/progress.log" \ + "seeding stale shared installs did not announce the cutover" + assert_grep 'seeding pass finished' "$w/progress.log" \ + "the seeding pass did not summarise what it attempted" + pass "a spent seeding budget reads the same through the update path as through seeding" +} + test_registry_and_install_time_do_not_spend_the_probe_budget() { local w out t w="$TMP_ROOT/probe-budget" @@ -516,6 +541,7 @@ test_hung_vessel_copy_is_bounded_and_kept test_registry_and_install_time_do_not_spend_the_probe_budget test_first_cutover_seeds_whole_suite_without_alarming test_stalled_first_cutover_alarms_and_stays_bounded +test_stalled_cutover_of_stale_installs_reports_the_same_way test_unpublished_ahead_version_is_not_a_recurring_alarm test_failed_update_persists_stuck_signal test_check_only_never_runs_hook_setup From 69ee559403e7e6c2b0c400a26afe8ad3ff35aebc Mon Sep 17 00:00:00 2001 From: Freudator86 <94322668+Freudator86@users.noreply.github.com> Date: Tue, 28 Jul 2026 05:08:13 +0000 Subject: [PATCH 7/8] no-mistakes(document): document per-vessel AXI prefix in stale doc surfaces --- AGENTS.md | 4 ++-- bin/backends/cmux.sh | 2 +- bin/backends/herdr.sh | 4 ++-- bin/backends/tmux.sh | 5 +++-- bin/backends/zellij.sh | 9 +++++---- bin/fm-bootstrap.sh | 3 +++ bin/fm-session-start.sh | 8 +++++--- docs/configuration.md | 4 ++-- docs/herdr-backend.md | 2 +- docs/orca-backend.md | 2 +- 10 files changed, 25 insertions(+), 18 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 902da9e5fc..ab7187e98a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -57,7 +57,7 @@ Never add an agent name as a commit co-author. ## 2. Layout and state `docs/configuration.md` is the single owner of the top-level operational-home layout and configuration schemas; each producing script's header and help own exact child fields and mutation mechanics. -`FM_HOME` selects an instance's private `data/`, `state/`, `config/`, and `projects/`, while scripts continue to come from their tracked code root. +`FM_HOME` selects an instance's private `data/`, `state/`, `config/`, `projects/`, and `.local/axi/`, while scripts continue to come from their tracked code root. Each secondmate has a persistent isolated `FM_HOME`, including its own state, backlog, projects, and session lock. `bin/fm-send.sh` fails closed unless `FM_HOME` is explicit, so a steer cannot silently resolve against another home. @@ -157,7 +157,7 @@ A lock-refused session must not spawn, steer, merge, drain the wake queue, repai 1. **Lock** - acquires the per-home session lock first, before anything mutates shared state. 2. **Bootstrap** - detect-only checks (tool/version problems, GitHub auth, the worktree-tangle check, harness override, dispatch-profile validation, backlog-backend status) always run, but routine confirmations stay silent by default. When the lock could not be acquired, the worktree-tangle check uses read-only advisory wording without a checkout repair command. - The five MUTATING sweeps - non-executing legacy PR-check migration, fleet sync, the local secondmate fast-forward sweep, the secondmate liveness sweep, and X-mode artifact writes - run only when this session actually holds the lock from step 1. + The six MUTATING sweeps - non-executing legacy PR-check migration, the AXI-suite currency check that installs into this home's own npm prefix, fleet sync, the local secondmate fast-forward sweep, the secondmate liveness sweep, and X-mode artifact writes - run only when this session actually holds the lock from step 1. The secondmate liveness sweep deterministically guarantees every registered secondmate is actually running: it probes each live secondmate's endpoint for a real agent process (not just pane presence), respawns only on a confident dead reading, and reports only skipped or failed guarantees as `SECONDMATE_LIVENESS:` lines (`bin/fm-bootstrap.sh`; `bin/fm-backend.sh`'s `fm_backend_agent_alive`). 3. **Wake queue** - when locked, drains the durable wake queue and prints the raw records prominently as this turn's first work queue; a bounded, clearly labeled historical status-event annotation may follow a valid `signal` record but never replaces it or current-state reconciliation, and a lapsed watcher chain still surfaces here via the same guard alarm. When the lock could not be acquired, the queue is left untouched because another session owns it, and the guard's tangle/watcher-liveness alarms still print in read-only advisory mode without drain, supervision repair, or checkout repair commands. diff --git a/bin/backends/cmux.sh b/bin/backends/cmux.sh index 69dc0b53bd..d1fa4ceb22 100644 --- a/bin/backends/cmux.sh +++ b/bin/backends/cmux.sh @@ -505,7 +505,7 @@ fm_backend_cmux_send_key() { # [expected-label] # no single-call atomic "run and submit" primitive (like herdr's `pane run`), # so this composes send (literal) + send-key enter, exactly like zellij's # equivalent - used for the fixed spawn-time commands (treehouse get, the -# GOTMPDIR export). +# GOTMPDIR export, the vessel AXI PATH export). fm_backend_cmux_send_text_line() { # [expected-label] fm_backend_cmux_send_literal "$1" "$2" "${3:-}" || return 1 fm_backend_cmux_send_key "$1" Enter "${3:-}" diff --git a/bin/backends/herdr.sh b/bin/backends/herdr.sh index cecb7e9c51..8d99c8029f 100644 --- a/bin/backends/herdr.sh +++ b/bin/backends/herdr.sh @@ -1301,8 +1301,8 @@ fm_backend_herdr_current_path() { # # fm_backend_herdr_send_text_line: send one line of TEXT then submit, # ATOMICALLY - mirrors tmux's `send-keys -t T text Enter`. Used for the fixed -# spawn-time commands (treehouse get, the GOTMPDIR export). `pane run` types -# the command and submits it in one call (verified). +# spawn-time commands (treehouse get, the GOTMPDIR export, the vessel AXI PATH +# export). `pane run` types the command and submits it in one call (verified). fm_backend_herdr_send_text_line() { # fm_backend_herdr_target_ready "$1" || return 1 fm_backend_herdr_cli "$FM_BACKEND_HERDR_SESSION" pane run "$FM_BACKEND_HERDR_PANE" "$2" >/dev/null 2>&1 diff --git a/bin/backends/tmux.sh b/bin/backends/tmux.sh index dee5813d64..de55dbd8af 100644 --- a/bin/backends/tmux.sh +++ b/bin/backends/tmux.sh @@ -103,8 +103,9 @@ fm_backend_tmux_current_path() { # # fm_backend_tmux_send_text_line: send one line of TEXT then Enter, with no # composer verification - used for the fixed spawn-time commands -# (`treehouse get`, the GOTMPDIR export) that already ran this exact sequence -# inline in fm-spawn.sh. Mirrors `tmux send-keys -t "$T" "" Enter`. +# (`treehouse get`, the GOTMPDIR export, the vessel AXI PATH export) that +# already ran this exact sequence inline in fm-spawn.sh. Mirrors +# `tmux send-keys -t "$T" "" Enter`. fm_backend_tmux_send_text_line() { # tmux send-keys -t "$1" "$2" Enter } diff --git a/bin/backends/zellij.sh b/bin/backends/zellij.sh index 162ea5477b..8f20bd3056 100644 --- a/bin/backends/zellij.sh +++ b/bin/backends/zellij.sh @@ -463,10 +463,11 @@ fm_backend_zellij_send_key() { # [expected-label] # fm_backend_zellij_send_text_line: send one line of TEXT then submit, # ATOMICALLY - mirrors tmux's `send-keys -t T text Enter` / herdr's `pane # run`. Used for the fixed spawn-time commands (treehouse get, the GOTMPDIR -# export). Zellij has no single-call atomic "run and submit" action, so this -# composes paste (literal) + send-keys Enter, exactly like send_literal + -# send_key are composed elsewhere - the two-step form is the ONLY form for -# this adapter, unlike tmux/herdr which have a genuinely atomic primitive. +# export, the vessel AXI PATH export). Zellij has no single-call atomic "run +# and submit" action, so this composes paste (literal) + send-keys Enter, +# exactly like send_literal + send_key are composed elsewhere - the two-step +# form is the ONLY form for this adapter, unlike tmux/herdr which have a +# genuinely atomic primitive. fm_backend_zellij_send_text_line() { # [expected-label] fm_backend_zellij_send_literal "$1" "$2" "${3:-}" || return 1 fm_backend_zellij_send_key "$1" Enter "${3:-}" diff --git a/bin/fm-bootstrap.sh b/bin/fm-bootstrap.sh index 516af886b9..83622dccc0 100755 --- a/bin/fm-bootstrap.sh +++ b/bin/fm-bootstrap.sh @@ -940,6 +940,9 @@ if [ "${FM_BOOTSTRAP_VERBOSE_FACTS:-0}" = 1 ] \ fi if [ "${FM_BOOTSTRAP_DETECT_ONLY:-0}" != 1 ]; then "$SCRIPT_DIR/fm-axi-suite.sh" + # The suite may have just seeded this home's own copies into $FM_HOME/.local/axi; + # drop the cached lookups so the sweeps below resolve the vessel copy, not the + # external one this shell already hashed. hash -r secondmate_sync secondmate_liveness_sweep diff --git a/bin/fm-session-start.sh b/bin/fm-session-start.sh index 5b23958920..633681d430 100755 --- a/bin/fm-session-start.sh +++ b/bin/fm-session-start.sh @@ -27,9 +27,11 @@ # # 1. lock - acquire the per-home session lock FIRST, before any # mutating step runs. -# 2. bootstrap - detect-only diagnostics always run. The five -# MUTATING sweeps (legacy PR-check migration, secondmate -# fast-forward, secondmate liveness, X-mode artifact writes, fleet sync) run only +# 2. bootstrap - detect-only diagnostics always run. The six +# MUTATING sweeps (legacy PR-check migration, the AXI-suite +# currency check that installs into this home's own npm prefix, +# secondmate fast-forward, secondmate liveness, X-mode artifact +# writes, fleet sync) run only # when this session actually holds the lock. # 3. wake-drain - mutates the durable wake queue, so it also only runs # when locked. diff --git a/docs/configuration.md b/docs/configuration.md index 5e528cabe2..5668130981 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -15,7 +15,7 @@ The tracked code root contains the shared instruction, skill, documentation, wor `.local/axi/` is the home-private npm prefix for the managed AXI CLI suite. `config/` holds local gitignored operating choices, and `projects/` holds the local project clones that Firstmate reads but changes only through the guarded exceptions in `AGENTS.md`. -A home's checkout also accumulates runtime artifacts that a supported harness or firstmate writes into the tracked tree itself: Claude Code's local permissions and settings file plus its scheduler, routine, worktree, checkpoint, mailbox, agent-registry, agent-memory, first-run, and daemon state, and firstmate's generated per-task hook overlay. +A home's checkout also accumulates runtime artifacts that a supported harness or firstmate writes into the tracked tree itself: Claude Code's local permissions and settings file plus its scheduler, routine, worktree, checkpoint, mailbox, agent-registry, agent-memory, first-run, and daemon state, firstmate's generated per-task hook overlay, and - whenever the home is the checkout root - the `.local/axi/` prefix above. The tracked root `.gitignore` owns the exact path list and is the only correct place for it: `dirty_status` in `bin/fm-ff-lib.sh` reads `git status --porcelain`, which reports untracked files too, so any of those artifacts would otherwise make a vessel dirty and silently drop it out of every guarded fast-forward, and a clone-private `.git/info/exclude` cannot carry the rule because a fresh clone does not inherit one. The patterns stay narrow so the tracked `.claude/settings.json` and the tracked `.claude/skills` symlink remain visible to `git add`, and `tests/fm-runtime-ignore.test.sh` proves in a fresh clone that every artifact form is ignored by the tracked `.gitignore` rather than a private or global exclude, that the tracked paths are not, and that `.claude/settings.local.json` is never tracked. @@ -210,7 +210,7 @@ A standalone-clone home cannot receive a primary-local commit through that no-fe ## FM_HOME `FM_HOME` selects the operational home for one firstmate instance. -When it is unset, most scripts use the repo root as the home; when it is set, scripts still run from this repo's `bin/`, but `state/`, `data/`, `config/`, and `projects/` come from `$FM_HOME`. +When it is unset, most scripts use the repo root as the home; when it is set, scripts still run from this repo's `bin/`, but `state/`, `data/`, `config/`, `projects/`, and the `.local/axi/` npm prefix come from `$FM_HOME`. `FM_ROOT_OVERRIDE` overrides the firstmate repo root used by scripts, including the primary checkout watched by the worktree-tangle guard. When `FM_HOME` is unset, it also behaves as the old whole-root override. `bin/fm-send.sh` is intentionally stricter than that general fallback: it requires `FM_HOME` to be set before resolving a target, so operator steers cannot silently resolve against the wrong home. diff --git a/docs/herdr-backend.md b/docs/herdr-backend.md index 9b41b97cd3..71417fc0fc 100644 --- a/docs/herdr-backend.md +++ b/docs/herdr-backend.md @@ -332,7 +332,7 @@ Herdr tasks additionally record: | Headless server start | `HERDR_SESSION= herdr server --session ` (backgrounded) | A bare socket call does NOT auto-start the server; the adapter always starts-then-polls before any workspace/tab/pane call. This fact is for start only, not cleanup, and the explicit `--session` flag is intentional because `HERDR_SESSION` alone is not safe session targeting. | | Duplicate task check | `herdr tab list --workspace `, match by `.label` | Herdr does NOT enforce tab-label uniqueness itself; two tabs can share a label. The adapter's own duplicate check is required. | | Send literal (unsubmitted) | `herdr pane send-text ` | Does NOT auto-submit, contrary to the original design addendum's guess. Verified directly: a unique marker sent this way sits unexecuted in the composer until a separate Enter. Behaves exactly like tmux's `send-keys -l`. | -| Send + submit atomically | `herdr pane run ` | Runs and submits a command in one call; used for the two fixed spawn-time commands (`treehouse get`, the `GOTMPDIR` export) exactly where tmux used one `send-keys ... Enter` call. | +| Send + submit atomically | `herdr pane run ` | Runs and submits a command in one call; used for the fixed spawn-time commands (`treehouse get`, the `GOTMPDIR` export, the owning vessel's AXI `PATH` export) exactly where tmux used one `send-keys ... Enter` call. | | Send key | `herdr pane send-keys ` | Verified names: `enter`, `escape` (alias `esc`), `ctrl+c` (aliases `C-c`, `c-c`). `ctrl+c` verified to interrupt a running foreground process immediately. | | Submit confirmation (idle baseline) | `herdr agent get ` -> `.result.agent.agent_status` after Enter | `fm_backend_herdr_send_text_submit` records the pre-Enter status and, when it is idle/done, confirms delivery by polling for `working`/`blocked` across the Enter attempt's confirmation budget. Composer-state reads remain the affirmative-empty pre-injection guard and the conservative fallback for preexisting submit-active or unreadable baselines; see "Native agent-state submit confirmation". | | Bounded capture | `herdr pane read --source recent --lines N` | See "Verified bug" below - N is never passed through directly. | diff --git a/docs/orca-backend.md b/docs/orca-backend.md index baaded3546..d5e8f7d1e9 100644 --- a/docs/orca-backend.md +++ b/docs/orca-backend.md @@ -71,7 +71,7 @@ Spawn: 2. Create an independent Orca worktree with `orca worktree create --repo id: --name fm- --no-parent --setup skip`. 3. Reuse the terminal returned by Orca worktree creation only when it appears in the verified `result.terminal.handle` shape, or create a titled terminal in that worktree when Orca returns only the worktree. 4. Install firstmate's per-harness turn-end hooks in the Orca worktree. -5. Write metadata, then send `GOTMPDIR` export and the selected harness launch through the recorded Orca terminal. +5. Write metadata, then send the `GOTMPDIR` export, the owning vessel's AXI `PATH` export, and the selected harness launch through the recorded Orca terminal. Operation routing: From 4a18d9c12314cb73af06367698090984b8f02599 Mon Sep 17 00:00:00 2001 From: Freudator86 <94322668+Freudator86@users.noreply.github.com> Date: Tue, 28 Jul 2026 14:09:19 +0000 Subject: [PATCH 8/8] no-mistakes: apply CI fixes --- tests/fm-backend.test.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/fm-backend.test.sh b/tests/fm-backend.test.sh index e50803ff80..f86dcc2b0b 100755 --- a/tests/fm-backend.test.sh +++ b/tests/fm-backend.test.sh @@ -112,7 +112,7 @@ BASE_REF=$(resolve_base_ref) \ # tmux-only conformance run the tmux adapter's behavior is what is under test, # and that is unchanged by any later (e.g. non-tmux backend) addition to # fm-backend.sh's own dispatch surface. -OLD_BIN_UNCHANGED_SIBLINGS="fm-gate-refuse-lib.sh fm-guard.sh fm-lock-lib.sh fm-tasks-axi-lib.sh fm-pr-lib.sh fm-tangle-lib.sh fm-tmux-lib.sh fm-composer-lib.sh fm-wake-lib.sh fm-classify-lib.sh fm-supervision-lib.sh fm-ff-lib.sh fm-config-inherit-lib.sh fm-project-mode.sh fm-harness.sh fm-crew-state.sh fm-decision-hold.sh fm-backend.sh fm-operational-input.sh" +OLD_BIN_UNCHANGED_SIBLINGS="fm-gate-refuse-lib.sh fm-guard.sh fm-lock-lib.sh fm-tasks-axi-lib.sh fm-pr-lib.sh fm-tangle-lib.sh fm-tmux-lib.sh fm-composer-lib.sh fm-wake-lib.sh fm-classify-lib.sh fm-supervision-lib.sh fm-ff-lib.sh fm-config-inherit-lib.sh fm-project-mode.sh fm-harness.sh fm-crew-state.sh fm-decision-hold.sh fm-axi-path-lib.sh fm-backend.sh fm-operational-input.sh" # A pull-request merge may add a new main-only dependency that the branch's older baseline does not have yet. OLD_BIN_OPTIONAL_SIBLINGS="fm-pending-reply-lib.sh fm-operational-input.sh" OLD_BIN_REFACTORED="fm-send.sh fm-peek.sh fm-watch.sh fm-spawn.sh fm-teardown.sh fm-marker-lib.sh"