diff --git a/.agents/skills/secondmate-provisioning/SKILL.md b/.agents/skills/secondmate-provisioning/SKILL.md index 81393e8b5f8..f62bdf42bf2 100644 --- a/.agents/skills/secondmate-provisioning/SKILL.md +++ b/.agents/skills/secondmate-provisioning/SKILL.md @@ -92,10 +92,23 @@ Never copy any secondmate `data/captain-shared.md` back into the primary. Keep each home's `data/captain.md` domain-local. After first propagation to an existing home, trim that home's local `data/captain.md` by hand to domain-specific content plus pointers to `data/captain-shared.md`; do not automate or silently delete private content. Keep every `data/learnings.md` fully local by captain decision; route fleet-general machinery facts into tracked documentation through the normal firstmate repo path rather than inventing shared learnings propagation. -No reread nudge is needed at spawn or respawn because the agent reads `AGENTS.md` fresh on launch; only the bootstrap sweep's running-home instruction-surface advance needs one. -Bootstrap reports successful sends as `BOOTSTRAP_INFO:` and only emits `NUDGE_SECONDMATES:` when that send fails and needs retry. -For already-live secondmates, use `bin/fm-config-push.sh` to push a mid-session inherited local-material change without running the tracked-file fast-forward or nudging the agents. -It uses the same live-home discovery and propagation helper as bootstrap and reports each item as `pushed`, `unchanged`, `skipped`, or `error`. +No AGENTS.md reread nudge is needed at spawn or respawn because the agent reads instructions fresh on launch; only the bootstrap sweep's running-home instruction-surface advance needs that AGENTS.md re-read. +Bootstrap reports successful AGENTS.md re-read sends as `BOOTSTRAP_INFO:` and only emits `NUDGE_SECONDMATES:` when that send fails and needs retry. +A separate, literal-content config reread is required whenever inherited `config/*` material changes under an already-running secondmate. +After each successful allowlisted config write, both the locked bootstrap convergence path and mid-session `bin/fm-config-push.sh` use the shared propagation report to build one per-home generation-specific private instruction file from the validated destination post-write bytes for only the allowlisted config items that actually changed for that home (`config/crew-dispatch.json`, `config/crew-harness`, `config/backlog-backend`), in deterministic allowlist order. +Each changed path is printed with clear begin/end delimiters and the destination file's full exact new bytes unparsed, or the explicit token `ABSENT` when propagation removed the destination copy. +The instruction uses only minimal framing that these are defaults/rules and do not remove judgment; it never includes SHA values, selected profiles, parsed summaries, or any other generated interpretation. +`data/captain-shared.md` is not a config file and is never inlined into this instruction file or message. +Homes whose allowlisted config files were all unchanged receive no config-reread message when no retry is pending. +Different homes may receive different changed-file sets based on their pre-push destination bytes. +Delivery uses the existing routed secondmate path (`fm-send`) with only a single-line `CONFIG_REREAD: ` pointer; a failed instruction publication retains the generated exact bytes in a bounded private retry queue when possible, legacy retry reports remain recoverable, a failed publication or retry-marker write retains the exact generation until it can be delivered, a failed send records a per-generation durable retry marker when possible, and all failures surface a concrete `CONFIG_REREAD:` diagnostic without claiming the live agent already re-read the values. +The propagation, generation publication, and pointer-delivery sequence holds one per-home inheritance lock, so concurrent mid-session pushes cannot deliver an older generation after a newer one. +A newly launched or relaunched secondmate already reads its files at launch, so its pending config-reread generations are discarded or quarantined after cleanup failure and it needs no redundant live-agent config nudge unless propagation changes files after launch. +Quarantined pre-relaunch generations are retained in bounded private history, and cleanup skips creating an empty quarantine generation. +Successfully delivered generations are retained only within a bounded per-home state history, while pending generations remain until delivery succeeds or a launch supersedes them. +These config values remain defaults and rules only; they must not harden `fm-spawn` to reject a deliberate runtime choice that differs from the configured defaults. +For already-live secondmates, use `bin/fm-config-push.sh` to push a mid-session inherited local-material change without running the tracked-file fast-forward. +It uses the same live-home discovery and propagation helper as bootstrap, reports each item as `pushed`, `unchanged`, `skipped`, or `error`, and follows the config-reread contract above for changed or pending generations. `bin/fm-home-seed.sh` refuses to copy a missing or placeholder charter. Direct seed without a preexisting brief requires `FM_SECONDMATE_CHARTER`. diff --git a/bin/fm-bootstrap.sh b/bin/fm-bootstrap.sh index 2bd11baa432..96ad03536ab 100755 --- a/bin/fm-bootstrap.sh +++ b/bin/fm-bootstrap.sh @@ -185,6 +185,8 @@ fleet_sync() { } secondmate_sync() { + # shellcheck source=bin/fm-wake-lib.sh disable=SC1091 + . "$SCRIPT_DIR/fm-wake-lib.sh" # Local-HEAD secondmate sync: fast-forward every LIVE secondmate home # to the primary checkout's current default-branch commit. Purely LOCAL - no # fetch, no origin dependency: a linked-worktree home already holds the primary's @@ -335,8 +337,13 @@ secondmate_sync() { # surface into every VALIDATED live secondmate home swept above. # FF_SEEN_HOMES is exactly that set, and fm-config-inherit-lib.sh owns the # declared config items plus data/captain-shared.md. - local id home home_real propagated_homes + # After a successful push that changes allowlisted config/* for an already- + # running home, send its literal-content reread instruction pointer so the + # live agent does not keep applying stale defaults. Spawn/respawn already + # re-reads at launch and needs no redundant nudge unless files changed after launch. + local id home home_real home_lock propagated_homes report reread_out reread_skip_pending propagated_homes="" + SECONDMATE_RESPAWNED_IDS=${SECONDMATE_RESPAWNED_IDS:-} while IFS='|' read -r id home _window _meta; do validate_secondmate_home "$id" "$home" || continue home_real="$VALIDATED_HOME" @@ -348,9 +355,56 @@ secondmate_sync() { *" $home_real "*) continue ;; esac propagated_homes="$propagated_homes $home_real" - if ! propagate_secondmate_inheritance "$FM_HOME" "$home_real" "$CONFIG" "$DATA"; then + mkdir -p "$home_real/state" || { + echo "CONFIG_REREAD: secondmate $id: send failed: could not create state directory" + continue + } + home_lock=$(fm_config_inherit_lock_path "$home_real") || { + echo "CONFIG_REREAD: secondmate $id: send failed: could not resolve per-home lock" + continue + } + fm_lock_acquire_wait "$home_lock" || { + echo "CONFIG_REREAD: secondmate $id: send failed: could not acquire per-home lock" + continue + } + reread_skip_pending=0 + case " $SECONDMATE_RESPAWNED_IDS " in + *" $id "*) reread_skip_pending=1 ;; + esac + if [ "$reread_skip_pending" -eq 0 ] \ + && fm_config_reread_retry_queue_is_full "$FM_HOME" "$id"; then + fm_config_reread_retry_pending "$id" "$home_real" || true + if fm_config_reread_retry_queue_is_full "$FM_HOME" "$id"; then + echo "CONFIG_REREAD: secondmate $id: send failed: retry instruction queue is full" + fm_lock_release "$home_lock" || true + continue + fi + fi + report=$(mktemp "${TMPDIR:-/tmp}/fm-bootstrap-inherit.XXXXXX" 2>/dev/null) || { + echo "SECONDMATE_SYNC: secondmate $id: skipped: inheritance failed" + fm_lock_release "$home_lock" || true + continue + } + if FM_CONFIG_INHERIT_REPORT="$report" \ + propagate_secondmate_inheritance "$FM_HOME" "$home_real" "$CONFIG" "$DATA"; then + : + else echo "SECONDMATE_SYNC: secondmate $id: skipped: inheritance failed" fi + if ! reread_out=$(FM_HOME="$FM_HOME" FM_ROOT_OVERRIDE="$FM_ROOT" \ + FM_STATE_OVERRIDE="$STATE" \ + FM_CONFIG_REREAD_SKIP_PENDING="$reread_skip_pending" \ + fm_config_send_reread_nudge "$id" "$home_real" "$report" 2>&1); then + if [ -n "$reread_out" ]; then + printf '%s\n' "$reread_out" + else + echo "CONFIG_REREAD: secondmate $id: send failed: unknown error" + fi + elif [ -n "$reread_out" ]; then + printf '%s\n' "$reread_out" + fi + rm -f "$report" + fm_lock_release "$home_lock" || true done < <(live_secondmate_meta_records "$STATE" "$DATA/secondmates.md") return 0 } @@ -388,6 +442,7 @@ secondmate_liveness_sweep() { # explicitly out of scope here. [ -d "$STATE" ] || return 0 local meta id window harness backend target verdict out + SECONDMATE_RESPAWNED_IDS="" for meta in "$STATE"/*.meta; do [ -f "$meta" ] || continue grep -q '^kind=secondmate$' "$meta" 2>/dev/null || continue @@ -409,6 +464,7 @@ secondmate_liveness_sweep() { dead) fm_backend_kill "$backend" "$target" 2>/dev/null || true if out=$(FM_SPAWN_NO_GUARD=1 "$FM_ROOT/bin/fm-spawn.sh" "$id" --secondmate 2>&1); then + SECONDMATE_RESPAWNED_IDS="$SECONDMATE_RESPAWNED_IDS $id" : else echo "SECONDMATE_LIVENESS: secondmate $id: respawn failed: $(first_line "$out")" @@ -800,8 +856,8 @@ if [ "${FM_BOOTSTRAP_VERBOSE_FACTS:-0}" = 1 ] \ echo "BOOTSTRAP_INFO: tasks-axi available" fi if [ "${FM_BOOTSTRAP_DETECT_ONLY:-0}" != 1 ]; then - secondmate_sync secondmate_liveness_sweep + secondmate_sync x_mode_setup fleet_sync fi diff --git a/bin/fm-config-inherit-lib.sh b/bin/fm-config-inherit-lib.sh index 61bd63b7829..5a5d5928a28 100644 --- a/bin/fm-config-inherit-lib.sh +++ b/bin/fm-config-inherit-lib.sh @@ -19,6 +19,9 @@ # (bin/fm-config-push.sh). It is PRIMARY-AUTHORITATIVE: the primary's value wins # and is re-pushed on every convergence, so the fleet stays converged on the # primary; an item the primary does not set is mirrored as absence downstream. +# After successful config/* changes under an already-running secondmate, callers +# invoke fm_config_send_reread_nudge so the live agent re-reads exact post-write +# bytes (spawn/respawn already re-reads at launch and needs no redundant nudge). # # Extensible by design: FM_INHERITABLE_CONFIG is the single declared list of # config-dir-relative items the primary propagates. Add an item there and every @@ -436,3 +439,667 @@ propagate_inheritable_config() { done return "$rc" } + +# Relative prefix of per-home instruction files written after a successful +# config push so the live secondmate can re-read exact post-write bytes. +# Kept under state/ (gitignored operational dir) so it never dirties the home. +FM_CONFIG_REREAD_INSTRUCTION_PREFIX_REL="state/.fm-inherited-config-reread" +FM_CONFIG_REREAD_MAX_SENT=16 +FM_CONFIG_REREAD_RETRY_ROOT_REL="state/.fm-inherited-config-reread-retry" +FM_CONFIG_REREAD_MAX_PENDING=16 +FM_CONFIG_REREAD_MAX_QUARANTINE=16 +FM_CONFIG_INHERIT_LOCK_REL="state/.fm-inherited-config.lock" + +# Framing lines for the config-reread instruction. Defaults/rules only - never +# an enforcement claim, and never a parsed summary of file contents. +FM_CONFIG_REREAD_FRAMING='These inherited config files changed. Re-read and apply their exact contents at every future intake. They are defaults/rules and do not remove your judgment to choose differently when warranted.' + +# fm_config_reread_is_allowlisted_item +# True only for the declared inheritable config allowlist (bare item name as +# recorded in FM_CONFIG_INHERIT_REPORT). data/captain-shared.md is never +# allowlisted here and must never be inlined into a reread instruction. +fm_config_reread_is_allowlisted_item() { + local item=$1 candidate + for candidate in $FM_INHERITABLE_CONFIG; do + [ "$candidate" = "$item" ] && return 0 + done + return 1 +} + +# fm_config_reread_changed_items +# Print bare allowlisted config item names whose report status is "pushed", +# in FM_INHERITABLE_CONFIG order (deterministic path order). Empty when none. +fm_config_reread_changed_items() { + local report=$1 item status + [ -n "$report" ] && [ -f "$report" ] || return 0 + for item in $FM_INHERITABLE_CONFIG; do + status=$(awk -F '\t' -v item="$item" '$1 == item { print $2; exit }' "$report" 2>/dev/null) || status="" + [ "$status" = pushed ] || continue + printf '%s\n' "$item" + done +} + +fm_config_inherit_lock_path() { + local dest_home=$1 + [ -n "$dest_home" ] || return 1 + printf '%s/%s\n' "$dest_home" "$FM_CONFIG_INHERIT_LOCK_REL" +} + +fm_config_reread_retry_dir() { + local source_home=$1 id=$2 token + [ -n "$source_home" ] && [ -n "$id" ] || return 1 + token=${id//[^a-zA-Z0-9_.-]/_} + [ -n "$token" ] || token=unknown + printf '%s/%s/%s\n' "$source_home" "$FM_CONFIG_REREAD_RETRY_ROOT_REL" "$token" +} + +fm_config_reread_pending_stages() { + local source_home=$1 id=$2 retry_dir stage + retry_dir=$(fm_config_reread_retry_dir "$source_home" "$id") || return 1 + for stage in "$retry_dir"/.fm-inherited-config-reread.*; do + case "$stage" in + *.report) continue ;; + esac + [ -f "$stage" ] && [ ! -L "$stage" ] || continue + [ -s "$stage" ] || continue + printf '%s\n' "$stage" + done | LC_ALL=C sort +} + +fm_config_reread_pending_reports() { + local source_home=$1 id=$2 retry_dir report + retry_dir=$(fm_config_reread_retry_dir "$source_home" "$id") || return 1 + for report in "$retry_dir"/.fm-inherited-config-reread.*.report; do + [ -f "$report" ] && [ ! -L "$report" ] || continue + printf '%s\n' "$report" + done | LC_ALL=C sort +} + +fm_config_reread_has_staged() { + local source_home=$1 id=$2 stage + while IFS= read -r stage; do + [ -n "$stage" ] && return 0 + done < <(fm_config_reread_pending_stages "$source_home" "$id") + while IFS= read -r stage; do + [ -n "$stage" ] && return 0 + done < <(fm_config_reread_pending_reports "$source_home" "$id") + return 1 +} + +fm_config_reread_retry_queue_is_full() { + local source_home=$1 id=$2 count report_count + count=$(fm_config_reread_pending_stages "$source_home" "$id" | wc -l | tr -d ' ') + report_count=$(fm_config_reread_pending_reports "$source_home" "$id" | wc -l | tr -d ' ') + count=$((count + report_count)) + [ "$count" -ge "$FM_CONFIG_REREAD_MAX_PENDING" ] +} + +fm_config_reread_retry_pending() { + local id=$1 dest_home=$2 report retry_out rc + report=$(mktemp "${TMPDIR:-/tmp}/fm-config-reread-retry.XXXXXX" 2>/dev/null) || { + printf 'CONFIG_REREAD: secondmate %s: send failed: could not create retry report\n' "$id" + return 1 + } + retry_out=$(fm_config_send_reread_nudge "$id" "$dest_home" "$report" 2>&1) + rc=$? + rm -f "$report" + [ -z "$retry_out" ] || printf '%s\n' "$retry_out" + return "$rc" +} + +fm_config_reread_new_retry_stage_path() { + local source_home=$1 id=$2 retry_dir sequence sequence_file sequence_tmp generation stage + retry_dir=$(fm_config_reread_retry_dir "$source_home" "$id") || return 1 + mkdir -p "$retry_dir" 2>/dev/null || return 1 + chmod 0700 "$retry_dir" 2>/dev/null || return 1 + sequence=$(cat "$retry_dir/.sequence" 2>/dev/null || true) + case "$sequence" in + ''|*[!0-9]*) sequence=0 ;; + esac + sequence=$((sequence + 1)) + sequence_file="$retry_dir/.sequence" + sequence_tmp=$(umask 077; mktemp "$retry_dir/.sequence.XXXXXX" 2>/dev/null) || return 1 + if ! printf '%s\n' "$sequence" > "$sequence_tmp" || ! chmod 0600 "$sequence_tmp" 2>/dev/null || ! mv -f "$sequence_tmp" "$sequence_file" 2>/dev/null; then + rm -f "$sequence_tmp" + return 1 + fi + generation=$(date -u +%Y%m%dT%H%M%S 2>/dev/null) || return 1 + generation="$generation.$(printf '%08d' "$sequence")" + stage=$(umask 077; mktemp "$retry_dir/.fm-inherited-config-reread.$generation.XXXXXX" 2>/dev/null) || return 1 + printf '%s\n' "$stage" +} + +fm_config_reread_save_retry_report() { + local report=$1 stage_path=$2 report_path tmp parent + parent=${stage_path%/*} + report_path="$stage_path.report" + tmp=$(umask 077; mktemp "$parent/.fm-config-reread-report.XXXXXX" 2>/dev/null) || return 1 + if ! cat "$report" > "$tmp" || ! chmod 0600 "$tmp" 2>/dev/null || ! mv -f "$tmp" "$report_path" 2>/dev/null; then + rm -f "$tmp" + return 1 + fi + printf '%s\n' "$report_path" +} + +# fm_config_write_reread_instruction +# After successful propagation, write one instruction from the validated +# destination state. Includes only changed allowlisted config files, each with +# relative path, begin/end delimiters, and either the destination file's full +# exact post-write bytes (streamed unparsed) or the literal token ABSENT when +# the destination copy was removed. Returns 1 when no allowlisted config item +# changed (or on write failure). Never inlines data/captain-shared.md, SHA +# values, selected profiles, or any generated interpretation. +fm_config_write_reread_instruction() { + local dest_home=$1 report=$2 instruction_path=$3 item rel dest parent tmp first=1 + FM_CONFIG_REREAD_FAILED_TEMP="" + [ -n "$dest_home" ] || return 1 + [ -n "$report" ] && [ -f "$report" ] || return 1 + [ -n "$instruction_path" ] || return 1 + parent=${instruction_path%/*} + [ -n "$parent" ] && [ "$parent" != "$instruction_path" ] || return 1 + mkdir -p "$parent" 2>/dev/null || return 1 + tmp=$(umask 077; mktemp "$instruction_path.tmp.XXXXXX" 2>/dev/null) || return 1 + chmod 0600 "$tmp" 2>/dev/null || { rm -f "$tmp"; return 1; } + while IFS= read -r item; do + [ -n "$item" ] || continue + fm_config_reread_is_allowlisted_item "$item" || continue + rel="config/$item" + dest="$dest_home/config/$item" + if [ "$first" = 1 ]; then + printf '%s\n' "$FM_CONFIG_REREAD_FRAMING" >> "$tmp" || { rm -f "$tmp"; return 1; } + first=0 + fi + { + printf '\n' + printf '%s\n' "$rel" + printf '%s\n' "-----BEGIN $rel-----" + } >> "$tmp" || { rm -f "$tmp"; return 1; } + if [ -f "$dest" ] && [ ! -L "$dest" ]; then + # Stream destination post-write bytes only - never re-read the primary. + cat "$dest" >> "$tmp" || { rm -f "$tmp"; return 1; } + else + printf '%s\n' "ABSENT" >> "$tmp" || { rm -f "$tmp"; return 1; } + fi + printf '%s\n' "-----END $rel-----" >> "$tmp" || { rm -f "$tmp"; return 1; } + done < <(fm_config_reread_changed_items "$report") + if [ "$first" = 1 ]; then + rm -f "$tmp" + return 1 + fi + if ! mv -f "$tmp" "$instruction_path" 2>/dev/null; then + FM_CONFIG_REREAD_FAILED_TEMP="$tmp" + return 1 + fi + return 0 +} + +fm_config_reread_adopt_exact_temp() { + local exact_tmp=$1 stage_path=$2 + [ -f "$exact_tmp" ] && [ ! -L "$exact_tmp" ] || return 1 + [ ! -L "$stage_path" ] || return 1 + if mv -f "$exact_tmp" "$stage_path" 2>/dev/null; then + return 0 + fi + if cp "$exact_tmp" "$stage_path" 2>/dev/null \ + && chmod 0600 "$stage_path" 2>/dev/null \ + && cmp -s "$exact_tmp" "$stage_path"; then + rm -f "$exact_tmp" 2>/dev/null || true + return 0 + fi + rm -f "$stage_path" 2>/dev/null || true + return 1 +} + +fm_config_reread_pending_instructions() { + local state=$1 pending instruction + for pending in "$state"/.fm-inherited-config-reread.*.pending; do + [ -f "$pending" ] && [ ! -L "$pending" ] || continue + instruction=${pending%.pending} + printf '%s\n' "$instruction" + done | LC_ALL=C sort +} + +fm_config_reread_has_pending() { + local dest_home=$1 state pending + state="$dest_home/${FM_CONFIG_REREAD_INSTRUCTION_PREFIX_REL%/*}" + for pending in "$state"/.fm-inherited-config-reread.*.pending; do + [ -f "$pending" ] && [ ! -L "$pending" ] || continue + return 0 + done + return 1 +} + +fm_config_reread_cleanup_sent() { + local dest_home=$1 state path paths sorted total remove + state="$dest_home/${FM_CONFIG_REREAD_INSTRUCTION_PREFIX_REL%/*}" + [ -d "$state" ] || return 0 + paths="" + for path in "$state"/.fm-inherited-config-reread.*; do + case "$path" in + *.pending) continue ;; + esac + [ -f "$path" ] && [ ! -L "$path" ] || continue + [ -e "$path.pending" ] || [ -L "$path.pending" ] && continue + if [ -n "$paths" ]; then + paths+=$'\n' + fi + paths+="$path" + done + [ -n "$paths" ] || return 0 + sorted=$(printf '%s\n' "$paths" | LC_ALL=C sort) + total=$(printf '%s\n' "$sorted" | wc -l | tr -d ' ') + remove=$((total - FM_CONFIG_REREAD_MAX_SENT)) + [ "$remove" -gt 0 ] || return 0 + while IFS= read -r path; do + [ "$remove" -gt 0 ] || break + [ -n "$path" ] || continue + [ -e "$path.pending" ] || [ -L "$path.pending" ] && continue + rm -f "$path" 2>/dev/null || continue + remove=$((remove - 1)) + done </dev/null || return 1 + tmp=$(umask 077; mktemp "$parent/.fm-config-reread-pending.XXXXXX" 2>/dev/null) || return 1 + if ! printf '%s\n' "$instruction_path" > "$tmp"; then + rm -f "$tmp" + return 1 + fi + if ! chmod 0600 "$tmp" 2>/dev/null; then + rm -f "$tmp" + return 1 + fi + if ! mv -f "$tmp" "$pending_path" 2>/dev/null; then + rm -f "$tmp" + return 1 + fi + return 0 +} + +fm_config_reread_publish_stage() { + local dest_home=$1 stage=$2 state final pending_pointer tmp + [ -f "$stage" ] && [ ! -L "$stage" ] || return 1 + state="$dest_home/${FM_CONFIG_REREAD_INSTRUCTION_PREFIX_REL%/*}" + mkdir -p "$state" 2>/dev/null || return 1 + final="$state/${stage##*/}" + if [ -f "$final.pending" ] && [ ! -L "$final.pending" ]; then + pending_pointer=$(cat "$final.pending" 2>/dev/null || true) + [ "$pending_pointer" = "$final" ] || return 1 + [ -f "$final" ] && [ ! -L "$final" ] || return 1 + printf '%s\n' "$final" + return 0 + fi + tmp=$(umask 077; mktemp "$state/.fm-config-reread-publish.XXXXXX" 2>/dev/null) || return 1 + if ! cat "$stage" > "$tmp" || ! chmod 0600 "$tmp" 2>/dev/null || ! mv -f "$tmp" "$final" 2>/dev/null; then + rm -f "$tmp" + return 1 + fi + if ! fm_config_reread_mark_pending "$final" "$final.pending"; then + rm -f "$final" + return 1 + fi + printf '%s\n' "$final" +} + +fm_config_reread_send_failure() { + local id=$1 instruction_path=$2 pending_path=$3 detail=$4 + if ! fm_config_reread_mark_pending "$instruction_path" "$pending_path"; then + detail="$detail; could not record retry marker" + fi + printf 'CONFIG_REREAD: secondmate %s: send failed: %s\n' "$id" "$detail" + return 1 +} + +# fm_config_reread_send_pointer +fm_config_reread_send_pointer() { + local id=$1 instruction_path=$2 pending_path selector out rc send_bin message pending_pointer + pending_path="$instruction_path.pending" + if [ ! -f "$instruction_path" ] || [ -L "$instruction_path" ]; then + printf 'CONFIG_REREAD: secondmate %s: send failed: pending instruction file is missing\n' "$id" + return 1 + fi + pending_pointer=$(cat "$pending_path" 2>/dev/null || true) + if [ "$pending_pointer" != "$instruction_path" ]; then + printf 'CONFIG_REREAD: secondmate %s: send failed: pending instruction file is mismatched\n' "$id" + return 1 + fi + selector="fm-$id" + send_bin="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/fm-send.sh" + if [ ! -x "$send_bin" ]; then + fm_config_reread_send_failure "$id" "$instruction_path" "$pending_path" "fm-send.sh not executable at $send_bin" + return 1 + fi + if [ -z "${FM_HOME:-}" ]; then + fm_config_reread_send_failure "$id" "$instruction_path" "$pending_path" "FM_HOME is not set" + return 1 + fi + message="CONFIG_REREAD: $instruction_path" + out=$(FM_HOME="$FM_HOME" \ + FM_ROOT_OVERRIDE="${FM_ROOT_OVERRIDE:-}" \ + FM_STATE_OVERRIDE="${FM_STATE_OVERRIDE:-}" \ + FM_SEND_SETTLE="${FM_SEND_SETTLE:-0}" \ + "$send_bin" "$selector" "$message" 2>&1) && rc=0 || rc=$? + if [ "$rc" -eq 0 ]; then + rm -f "$pending_path" + return 0 + fi + out=${out%%$'\n'*} + [ -n "$out" ] || out="fm-send exited $rc" + fm_config_reread_send_failure "$id" "$instruction_path" "$pending_path" "$out" + return 1 +} + +# fm_config_reread_discard_pending +fm_config_reread_discard_pending() { + local dest_home=$1 id=${2:-} source_home=${3:-} state pending instruction retry_dir retry_stage rc=0 + state="$dest_home/${FM_CONFIG_REREAD_INSTRUCTION_PREFIX_REL%/*}" + for pending in "$state"/.fm-inherited-config-reread.*.pending; do + [ -f "$pending" ] && [ ! -L "$pending" ] || continue + instruction=${pending%.pending} + rm -f "$pending" 2>/dev/null || rc=1 + rm -f "$instruction" 2>/dev/null || rc=1 + done + if [ -n "$id" ] && [ -n "$source_home" ]; then + retry_dir=$(fm_config_reread_retry_dir "$source_home" "$id") || rc=1 + if [ -d "$retry_dir" ]; then + for retry_stage in "$retry_dir"/.fm-inherited-config-reread.*; do + [ -f "$retry_stage" ] && [ ! -L "$retry_stage" ] || continue + rm -f "$retry_stage" 2>/dev/null || rc=1 + done + rm -f "$retry_dir/.sequence" 2>/dev/null || true + rmdir "$retry_dir" 2>/dev/null || true + fi + fi + return "$rc" +} + +fm_config_reread_quarantine_prune() { + local root=$1 keep=${2:-$FM_CONFIG_REREAD_MAX_QUARANTINE} dirs dir oldest path remove + case "$keep" in + ''|*[!0-9]*) keep=$FM_CONFIG_REREAD_MAX_QUARANTINE ;; + esac + [ -d "$root" ] || return 0 + dirs="" + for dir in "$root"/generation.*; do + [ -d "$dir" ] && [ ! -L "$dir" ] || continue + [ -n "$dirs" ] && dirs+=$'\n' + dirs+="$dir" + done + dirs=$(printf '%s\n' "$dirs" | LC_ALL=C sort) + [ -n "$dirs" ] || return 0 + remove=$(printf '%s\n' "$dirs" | wc -l | tr -d ' ') + remove=$((remove - keep)) + while [ "$remove" -gt 0 ]; do + oldest=${dirs%%$'\n'*} + if [ "$oldest" = "$dirs" ]; then + dirs="" + else + dirs=${dirs#*$'\n'} + fi + for path in "$oldest"/.[!.]* "$oldest"/..?* "$oldest"/*; do + [ -e "$path" ] || [ -L "$path" ] || continue + if [ -d "$path" ] && [ ! -L "$path" ]; then + rmdir "$path" 2>/dev/null || return 1 + else + rm -f "$path" 2>/dev/null || return 1 + fi + done + rmdir "$oldest" 2>/dev/null || return 1 + remove=$((remove - 1)) + done +} + +fm_config_reread_quarantine_dir() { + local home=$1 state root quarantine + state="$home/${FM_CONFIG_REREAD_INSTRUCTION_PREFIX_REL%/*}" + root="$state/.fm-inherited-config-reread-quarantine" + mkdir -p "$root" 2>/dev/null || return 1 + chmod 0700 "$root" 2>/dev/null || return 1 + fm_config_reread_quarantine_prune "$root" $((FM_CONFIG_REREAD_MAX_QUARANTINE - 1)) || return 1 + quarantine=$(umask 077; mktemp -d "$root/generation.XXXXXX" 2>/dev/null) || return 1 + chmod 0700 "$quarantine" 2>/dev/null || return 1 + printf '%s\n' "$quarantine" +} + +fm_config_reread_quarantine_pending() { + local dest_home=$1 id=${2:-} source_home=${3:-} + local state pending instruction retry_dir retry_stage dest_quarantine source_quarantine + local dest_has_artifacts source_has_artifacts rc=0 + state="$dest_home/${FM_CONFIG_REREAD_INSTRUCTION_PREFIX_REL%/*}" + dest_has_artifacts=0 + for pending in "$state"/.fm-inherited-config-reread.*.pending; do + [ -f "$pending" ] && [ ! -L "$pending" ] || continue + dest_has_artifacts=1 + break + done + dest_quarantine="" + if [ "$dest_has_artifacts" -eq 1 ]; then + dest_quarantine=$(fm_config_reread_quarantine_dir "$dest_home" 2>/dev/null || true) + fi + for pending in "$state"/.fm-inherited-config-reread.*.pending; do + [ -f "$pending" ] && [ ! -L "$pending" ] || continue + instruction=${pending%.pending} + if [ -n "$dest_quarantine" ] && mv -f "$pending" "$dest_quarantine/${pending##*/}" 2>/dev/null; then + if [ -e "$instruction" ] || [ -L "$instruction" ]; then + mv -f "$instruction" "$dest_quarantine/${instruction##*/}" 2>/dev/null || { + rm -f "$instruction" 2>/dev/null || true + rc=1 + } + fi + else + rm -f "$pending" 2>/dev/null || rc=1 + rm -f "$instruction" 2>/dev/null || rc=1 + rc=1 + fi + done + if [ -n "$id" ] && [ -n "$source_home" ]; then + retry_dir=$(fm_config_reread_retry_dir "$source_home" "$id") || retry_dir= + source_has_artifacts=0 + if [ -d "$retry_dir" ]; then + for retry_stage in "$retry_dir"/.fm-inherited-config-reread.*; do + [ -f "$retry_stage" ] && [ ! -L "$retry_stage" ] || continue + source_has_artifacts=1 + break + done + fi + source_quarantine="" + if [ "$source_has_artifacts" -eq 1 ]; then + source_quarantine=$(fm_config_reread_quarantine_dir "$source_home" 2>/dev/null || true) + fi + if [ -d "$retry_dir" ]; then + for retry_stage in "$retry_dir"/.fm-inherited-config-reread.*; do + [ -f "$retry_stage" ] && [ ! -L "$retry_stage" ] || continue + if [ -n "$source_quarantine" ] && mv -f "$retry_stage" "$source_quarantine/${retry_stage##*/}" 2>/dev/null; then + : + else + rm -f "$retry_stage" 2>/dev/null || rc=1 + rc=1 + fi + done + if [ -f "$retry_dir/.sequence" ] && [ ! -L "$retry_dir/.sequence" ]; then + if [ -n "$source_quarantine" ] && mv -f "$retry_dir/.sequence" "$source_quarantine/.sequence" 2>/dev/null; then + : + else + rm -f "$retry_dir/.sequence" 2>/dev/null || rc=1 + rc=1 + fi + fi + rmdir "$retry_dir" 2>/dev/null || true + fi + fi + return "$rc" +} + +# fm_config_send_reread_nudge +# After successful propagation, if any allowlisted config item changed for this +# home, write the exact-byte instruction under the destination home and send a +# single-line pointers to those files through the routed secondmate path +# (fm-send). The files contain only changed config paths, clear delimiters, and +# the destination's full exact post-write bytes (or ABSENT) - never summaries, +# SHA values, selected profiles, or data/captain-shared.md. No-op (return 0) when +# nothing changed and no pending delivery exists. On publication or send +# failure, print a concrete CONFIG_REREAD retry diagnostic to stdout and return +# non-zero - never claim the live agent reread the values. +fm_config_send_reread_nudge() { + local id=$1 dest_home=$2 report=$3 + local dest_home_abs state source_home_abs changed_items pending_paths stage_paths delivery_paths + local stage_path instruction_path current_stage_path exact_tmp + local send_failures retry_report_paths retry_report_path retry_stage_path retry_record_path + [ -n "$id" ] || return 1 + [ -n "$dest_home" ] || return 1 + [ -n "$report" ] && [ -f "$report" ] || return 1 + dest_home_abs=$(cd "$dest_home" 2>/dev/null && pwd -P) || { + printf 'CONFIG_REREAD: secondmate %s: send failed: destination home is not readable\n' "$id" + return 1 + } + state="$dest_home_abs/${FM_CONFIG_REREAD_INSTRUCTION_PREFIX_REL%/*}" + changed_items=$(fm_config_reread_changed_items "$report") + pending_paths="" + stage_paths="" + retry_report_paths="" + if [ "${FM_CONFIG_REREAD_SKIP_PENDING:-0}" != 1 ]; then + pending_paths=$(fm_config_reread_pending_instructions "$state") + source_home_abs=$(cd "${FM_HOME:-}" 2>/dev/null && pwd -P || true) + if [ -n "$source_home_abs" ]; then + stage_paths=$(fm_config_reread_pending_stages "$source_home_abs" "$id") + retry_report_paths=$(fm_config_reread_pending_reports "$source_home_abs" "$id") + fi + fi + send_failures=0 + while IFS= read -r retry_report_path; do + [ -n "$retry_report_path" ] || continue + retry_stage_path=${retry_report_path%.report} + exact_tmp="" + for stage_path in "$retry_stage_path".tmp.*; do + [ -f "$stage_path" ] && [ ! -L "$stage_path" ] || continue + exact_tmp="$stage_path" + break + done + if [ -n "$exact_tmp" ]; then + rm -f "$retry_report_path" 2>/dev/null || send_failures=1 + continue + fi + if fm_config_write_reread_instruction "$dest_home_abs" "$retry_report_path" "$retry_stage_path"; then + rm -f "$retry_report_path" 2>/dev/null || send_failures=1 + if [ -n "$stage_paths" ]; then + stage_paths+=$'\n' + fi + stage_paths+="$retry_stage_path" + else + exact_tmp=${FM_CONFIG_REREAD_FAILED_TEMP:-} + if [ -n "$exact_tmp" ] \ + && fm_config_reread_adopt_exact_temp "$exact_tmp" "$retry_stage_path"; then + rm -f "$retry_report_path" 2>/dev/null || send_failures=1 + if [ -n "$stage_paths" ]; then + stage_paths+=$'\n' + fi + stage_paths+="$retry_stage_path" + elif [ -n "$exact_tmp" ] && [ -f "$exact_tmp" ]; then + printf 'CONFIG_REREAD: secondmate %s: send failed: retained exact retry temporary %s\n' "$id" "$exact_tmp" + send_failures=1 + break + else + printf 'CONFIG_REREAD: secondmate %s: send failed: could not rebuild retry instruction\n' "$id" + send_failures=1 + break + fi + fi + done </dev/null && pwd -P || true) + if [ -z "$source_home_abs" ]; then + printf 'CONFIG_REREAD: secondmate %s: send failed: could not reserve retry instruction\n' "$id" + return 1 + fi + if fm_config_reread_retry_queue_is_full "$source_home_abs" "$id"; then + printf 'CONFIG_REREAD: secondmate %s: send failed: retry instruction queue is full\n' "$id" + return 1 + fi + current_stage_path=$(fm_config_reread_new_retry_stage_path "$source_home_abs" "$id") || { + printf 'CONFIG_REREAD: secondmate %s: send failed: could not reserve retry instruction\n' "$id" + return 1 + } + if ! fm_config_write_reread_instruction "$dest_home_abs" "$report" "$current_stage_path"; then + exact_tmp=${FM_CONFIG_REREAD_FAILED_TEMP:-} + if [ -n "$exact_tmp" ] \ + && fm_config_reread_adopt_exact_temp "$exact_tmp" "$current_stage_path"; then + printf 'CONFIG_REREAD: secondmate %s: send failed: could not publish retry instruction; retained exact retry generation %s\n' "$id" "$current_stage_path" + elif [ -n "$exact_tmp" ] && [ -f "$exact_tmp" ]; then + rm -f "$current_stage_path" 2>/dev/null || true + printf 'CONFIG_REREAD: secondmate %s: send failed: retained exact retry temporary %s\n' "$id" "$exact_tmp" + elif retry_record_path=$(fm_config_reread_save_retry_report "$report" "$current_stage_path"); then + rm -f "$current_stage_path" 2>/dev/null || true + printf 'CONFIG_REREAD: secondmate %s: send failed: could not write retry instruction; retained retry report %s\n' "$id" "$retry_record_path" + else + rm -f "$current_stage_path" 2>/dev/null || true + printf 'CONFIG_REREAD: secondmate %s: send failed: could not write retry instruction or retain retry report\n' "$id" + fi + return 1 + fi + if [ -n "$stage_paths" ]; then + stage_paths+=$'\n' + fi + stage_paths+="$current_stage_path" + fi + delivery_paths="$pending_paths" + while IFS= read -r stage_path; do + [ -n "$stage_path" ] || continue + if instruction_path=$(fm_config_reread_publish_stage "$dest_home_abs" "$stage_path"); then + if [ -n "$delivery_paths" ]; then + case $'\n'"$delivery_paths"$'\n' in + *$'\n'"$instruction_path"$'\n'*) ;; + *) delivery_paths+=$'\n'; delivery_paths+="$instruction_path" ;; + esac + else + delivery_paths="$instruction_path" + fi + else + printf 'CONFIG_REREAD: secondmate %s: send failed: could not publish retry instruction\n' "$id" + send_failures=1 + break + fi + done </dev/null || true + done </dev/null) || { echo " home: error - could not create report file" errors=1 + fm_lock_release "$home_lock" || true continue } reports="$reports $report" if FM_CONFIG_INHERIT_REPORT="$report" propagate_secondmate_inheritance "$FM_HOME" "$home_real" "$CONFIG" "$DATA"; then - print_item_report "$report" + : + else + errors=1 + fi + print_item_report "$report" + reread_pending=0 + if fm_config_reread_has_pending "$home_real" || fm_config_reread_has_staged "$FM_HOME" "$id"; then + reread_pending=1 + fi + if reread_out=$(FM_HOME="$FM_HOME" FM_ROOT_OVERRIDE="$FM_ROOT" \ + FM_STATE_OVERRIDE="$STATE" \ + fm_config_send_reread_nudge "$id" "$home_real" "$report" 2>&1); then + if [ -n "$(fm_config_reread_changed_items "$report")" ] || [ "$reread_pending" -eq 1 ]; then + printf ' config-reread: sent\n' + fi + [ -z "$reread_out" ] || printf '%s\n' "$reread_out" else errors=1 - print_item_report "$report" + if [ -n "$reread_out" ]; then + printf '%s\n' "$reread_out" + else + printf ' config-reread: send failed\n' + fi fi + fm_lock_release "$home_lock" || true done < "$records" [ "$errors" -eq 0 ] || exit 1 diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index 65adfa20be8..acb6c8756b6 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -71,7 +71,8 @@ # A --secondmate spawn also propagates the primary's declared inherited local # material, so the secondmate's OWN crewmates inherit primary config and the # secondmate receives the primary's read-only shared captain-preference file -# (fm-config-inherit-lib.sh). +# (fm-config-inherit-lib.sh). A successful launch clears pending inherited +# config reread generations because the new agent reads the converged files. # --scout records kind=scout in the task's meta (report deliverable, scratch worktree; # see AGENTS.md task lifecycle); --secondmate records kind=secondmate and launches in a # provisioned firstmate home; the default is kind=ship. @@ -224,6 +225,8 @@ HERDR_PRESENTATION_ORDER_LOCK= HERDR_PRESENTATION_ORDER_LOCK_HELD=0 SPAWN_TASK_LOCK= SPAWN_TASK_LOCK_HELD=0 +CONFIG_INHERIT_LOCK= +CONFIG_INHERIT_LOCK_HELD=0 parse_orca_worktree_result() { local raw=$1 rest @@ -294,6 +297,10 @@ spawn_abort_cleanup() { SPAWN_TASK_LOCK_HELD=0 fm_lock_release "$SPAWN_TASK_LOCK" || true fi + if [ "$CONFIG_INHERIT_LOCK_HELD" = 1 ]; then + CONFIG_INHERIT_LOCK_HELD=0 + fm_lock_release "$CONFIG_INHERIT_LOCK" || true + fi return "$status" } trap spawn_abort_cleanup EXIT @@ -709,6 +716,19 @@ if [ "$KIND" = secondmate ]; then else echo "warning: secondmate $ID sync skipped before launch: primary default-branch commit cannot be resolved" >&2 fi + mkdir -p "$PROJ_ABS/state" || { + echo "error: could not create secondmate state directory for $PROJ_ABS" >&2 + exit 1 + } + CONFIG_INHERIT_LOCK=$(fm_config_inherit_lock_path "$PROJ_ABS") || { + echo "error: could not resolve secondmate inheritance lock for $PROJ_ABS" >&2 + exit 1 + } + if ! fm_lock_acquire_wait "$CONFIG_INHERIT_LOCK"; then + echo "error: could not acquire secondmate inheritance lock for $PROJ_ABS" >&2 + exit 1 + fi + CONFIG_INHERIT_LOCK_HELD=1 # Inheritance propagation: push the primary-authoritative local inheritance # surface into this secondmate home (fm-config-inherit-lib.sh). propagate_secondmate_inheritance "$FM_HOME" "$PROJ_ABS" "$CONFIG" "$DATA" \ @@ -1256,5 +1276,14 @@ if [ "${HERDR_PROJECTED:-0}" -eq 1 ]; then spawn_herdr_presentation_order_lock_release fi spawn_send_key "$T" Enter +if [ "$KIND" = secondmate ]; then + if ! fm_config_reread_discard_pending "$PROJ_ABS" "$ID" "$FM_HOME"; then + if fm_config_reread_quarantine_pending "$PROJ_ABS" "$ID" "$FM_HOME"; then + echo "CONFIG_REREAD: secondmate $ID: quarantined pre-relaunch generations after cleanup failure (destination=$PROJ_ABS/state/.fm-inherited-config-reread-quarantine source=$FM_HOME/state/.fm-inherited-config-reread-quarantine)" >&2 + else + echo "CONFIG_REREAD: secondmate $ID: cleanup failed; pre-relaunch generations were force-cleared where possible (destination=$PROJ_ABS source=$FM_HOME)" >&2 + fi + fi +fi echo "spawned $ID harness=$HARNESS kind=$KIND mode=$MODE yolo=$YOLO window=$META_WINDOW worktree=$WT" diff --git a/docs/configuration.md b/docs/configuration.md index 61838643f0a..a1476d9e6d5 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -11,7 +11,7 @@ The shared orchestrator behavior lives in [`AGENTS.md`](../AGENTS.md) - edit it This section is the single owner of the top-level operational-home layout; producer script headers and their help own exact child-file fields and mutation contracts. 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, and generated X-mode artifacts. +`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, and private secondmate config-reread generations with their retry and quarantine state. `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`. `bin/fm-spawn.sh` owns the base task-metadata fields it emits, while the runtime-backend section below owns backend-specific fields and selector interpretation. @@ -181,6 +181,7 @@ An explicit harness argument to `fm-spawn.sh` still overrides either config file An explicit `--model` or `--effort` overrides the matching token from `config/secondmate-harness`; an explicit harness or raw launch command starts with clean model and effort defaults unless those flags are also passed. When `config/crew-dispatch.json` exists, crewmate and scout spawns require an explicit resolved harness instead of automatically falling back to `config/crew-harness`. The inherited-local-material contract is owned by `secondmate-provisioning`; for harness behavior, its propagated config items make a secondmate's own crewmates, dispatch profiles, and backlog backend use the primary values. +Those inherited values are defaults and rules only; `fm-spawn` still permits a consciously chosen explicit runtime outside the config. `config/secondmate-harness` is not inherited because secondmates do not launch secondmates. For grok, `fm-spawn.sh` installs one firstmate-owned global turn-end hook under `$GROK_HOME/hooks/`, or `~/.grok/hooks/` when `GROK_HOME` is unset, and drops a per-task `.fm-grok-turnend` pointer in the worktree, with teardown removing the task token and pointer. For Pi secondmate launches, `fm-spawn.sh` starts Pi with `-e` pointed at the secondmate home's own tracked `.pi/extensions/fm-primary-pi-watch.ts` and `.pi/extensions/fm-primary-turnend-guard.ts`, both already present from the secondmate home's git worktree. @@ -260,8 +261,10 @@ It emits `SECONDMATE_SYNC:` only when a home was skipped for an actionable sync When a running home advances and its loaded instruction surface (`AGENTS.md`, `bin/`, or `.agents/skills/`) changed, bootstrap sends the re-read nudge itself through the stable `fm-` selector and reports the exact completed send as `BOOTSTRAP_INFO:`. If that send fails, bootstrap keeps an idempotent retry marker and emits `NUDGE_SECONDMATES:` with the failure reason. The same bootstrap run emits `SECONDMATE_LIVENESS:` only when a live secondmate endpoint is skipped or respawn fails; already-live and successfully respawned endpoints are handled silently. -For a mid-session inherited local-material edit where tracked-file sync and reread nudges are not needed, run `bin/fm-config-push.sh`. -It uses the same live secondmate discovery and propagation helper as bootstrap, prints each live home's `crew-dispatch.json`, `crew-harness`, `backlog-backend`, and `data/captain-shared.md` result as `pushed`, `unchanged`, `skipped`, or `error`, and exits non-zero only for real propagation errors. +For a mid-session inherited local-material edit where tracked-file sync is not needed, run `bin/fm-config-push.sh`. +It uses the same live secondmate discovery and propagation helper as bootstrap, prints each live home's `crew-dispatch.json`, `crew-harness`, `backlog-backend`, and `data/captain-shared.md` result as `pushed`, `unchanged`, `skipped`, or `error`, and exits non-zero for real propagation errors or config-reread send failures. +When an allowlisted config item changes for an already-running home, it sends the literal-content reread pointer described in [`secondmate-provisioning`](../.agents/skills/secondmate-provisioning/SKILL.md); unchanged allowlisted config sends no pointer unless a previous delivery is pending. +The locked bootstrap inheritance pass uses the same per-home changed-set and reread path for already-running homes; see `secondmate-provisioning` for the single contract owner. That live discovery starts from `state/*.meta` records with `kind=secondmate`; `data/secondmates.md` only backfills `home=` for older or incomplete meta records. Skipped items, such as a destination checkout that does not yet gitignore the item, are visible warnings but not hard failures. diff --git a/docs/scripts.md b/docs/scripts.md index 5a4cc0f0e67..77ae37e2799 100644 --- a/docs/scripts.md +++ b/docs/scripts.md @@ -40,7 +40,7 @@ The shared no-mistakes gate refusal for fleet lifecycle entrypoints is summarize | `backends/zellij.sh` | Experimental zellij session-provider adapter | | `backends/orca.sh` | Experimental Orca backend adapter owning both worktree and terminal | | `backends/cmux.sh` | Experimental cmux session-provider adapter | -| `fm-config-push.sh` | Push declared inherited local material to live secondmate homes mid-session | +| `fm-config-push.sh` | Push declared inherited local material to live secondmates mid-session and send a pointer to the literal-content config reread when config changed | | `fm-project-mode.sh` | Resolve a project's delivery mode and `+yolo` flag from `data/projects.md` | | `fm-merge-local.sh` | Fast-forward a `local-only` project's local default branch after approval | | `fm-review-diff.sh` | Review a crewmate branch or resolved PR head against the authoritative base | @@ -59,7 +59,7 @@ The shared no-mistakes gate refusal for fleet lifecycle entrypoints is summarize | `fm-supervision-lib.sh` | Shared in-flight-work-without-fresh-watcher-beacon predicate | | `fm-ff-lib.sh` | Shared guarded fast-forward helper for origin pulls and local secondmate syncs | | `fm-lock-lib.sh` | Shared "is this git lock provably abandoned?" proof used by teardown and fleet-sync | -| `fm-config-inherit-lib.sh` | Shared primary-to-secondmate inherited local-material propagation | +| `fm-config-inherit-lib.sh` | Shared primary-to-secondmate inherited local-material propagation and config-reread delivery | | `fm-tasks-axi-lib.sh` | Shared backlog-backend selector and `tasks-axi` compatibility probe | | `fm-wake-drain.sh` | Atomically drain queued watcher wakes, emit bounded best-effort status-event annotations, then assert watcher liveness | | `fm-wake-lib.sh` | Shared durable wake queue, portable locks, and watcher identity/health helpers | diff --git a/tests/fm-secondmate-harness.test.sh b/tests/fm-secondmate-harness.test.sh index e4a430d3e9d..01160902249 100755 --- a/tests/fm-secondmate-harness.test.sh +++ b/tests/fm-secondmate-harness.test.sh @@ -19,7 +19,12 @@ # inherit the primary's settings. It is primary-authoritative (re-pushed at # secondmate spawn, on the bootstrap secondmate sweep, and by config push). # config/secondmate-harness is deliberately NOT inherited (secondmates do -# not spawn secondmates). +# not spawn secondmates). After a successful push that changes allowlisted +# config under an already-running home, a literal-content reread instruction +# is written to the secondmate home and only its pointer is sent via the +# routed secondmate path (exact destination bytes, no summaries); unchanged +# config sends nothing unless a previous send failure is pending. + # C) Model/effort pin. config/secondmate-harness may carry optional model and # effort tokens after the harness (" [] []"), read by # fm-harness.sh secondmate-model / secondmate-effort. A bare harness-only @@ -721,7 +726,31 @@ make_fake_toolchain() { local dir=$1 fakebin fakebin="$dir/fakebin" mkdir -p "$fakebin" - fm_fake_exit0 "$fakebin" tmux node gh-axi chrome-devtools-axi lavish-axi + fm_fake_exit0 "$fakebin" node gh-axi chrome-devtools-axi lavish-axi + # tmux fake supports fm-send's composer-verified submit path and optional + # FM_FAKE_TMUX_LOG / FM_FAKE_TMUX_FAIL_LITERAL for reread-nudge assertions. + cat > "$fakebin/tmux" <<'SH' +#!/usr/bin/env bash +if [ -n "${FM_FAKE_TMUX_LOG:-}" ]; then + printf '%s\n' "$*" >> "$FM_FAKE_TMUX_LOG" +fi +case "$*" in + *display-message*'#{pane_current_command}'*) printf '%s\n' codex; exit 0 ;; + *display-message*'#{pane_id}'*) printf '%s\n' '%1'; exit 0 ;; + *display-message*'#{cursor_y}'*) printf '%s\n' 0; exit 0 ;; + *capture-pane*) printf '\n'; exit 0 ;; + *'send-keys'*' -l '*) + [ "${FM_FAKE_TMUX_FAIL_LITERAL:-0}" = 1 ] && exit 1 + exit 0 + ;; + *send-keys*) + [ "${FM_FAKE_TMUX_FAIL_LITERAL:-0}" = 1 ] && exit 1 + exit 0 + ;; +esac +exit 0 +SH + chmod +x "$fakebin/tmux" cat > "$fakebin/gh" <<'SH' #!/usr/bin/env bash exit 0 @@ -748,16 +777,108 @@ SH } run_bootstrap() { - local w=$1 fakebin + local w=$1 fakebin log=${2:-} fakebin=$(make_fake_toolchain "$w") - PATH="$fakebin:$BASE_PATH" FM_HOME="$w/home" FM_ROOT_OVERRIDE="$w/main" \ - "$ROOT/bin/fm-bootstrap.sh" 2>/dev/null + if [ -n "$log" ]; then + PATH="$fakebin:$BASE_PATH" FM_HOME="$w/home" FM_ROOT_OVERRIDE="$w/main" \ + FM_SEND_SETTLE=0 FM_FAKE_TMUX_LOG="$log" \ + "$ROOT/bin/fm-bootstrap.sh" 2>/dev/null + else + PATH="$fakebin:$BASE_PATH" FM_HOME="$w/home" FM_ROOT_OVERRIDE="$w/main" \ + FM_SEND_SETTLE=0 "$ROOT/bin/fm-bootstrap.sh" 2>/dev/null + fi } run_config_push() { - local w=$1 - PATH="$BASE_PATH" FM_HOME="$w/home" FM_ROOT_OVERRIDE="$w/main" \ - "$ROOT/bin/fm-config-push.sh" + local w=$1 fakebin log=${2:-} + fakebin=$(make_fake_toolchain "$w") + if [ -n "$log" ]; then + PATH="$fakebin:$BASE_PATH" FM_HOME="$w/home" FM_ROOT_OVERRIDE="$w/main" \ + FM_SEND_SETTLE=0 FM_FAKE_TMUX_LOG="$log" \ + "$ROOT/bin/fm-config-push.sh" + else + PATH="$fakebin:$BASE_PATH" FM_HOME="$w/home" FM_ROOT_OVERRIDE="$w/main" \ + FM_SEND_SETTLE=0 \ + "$ROOT/bin/fm-config-push.sh" + fi +} + +reread_instruction_path() { + local home=$1 state path latest= + state="$(cd "$home/state" && pwd -P)" + for path in "$state"/.fm-inherited-config-reread.*; do + case "$path" in + *.pending) continue ;; + esac + [ -f "$path" ] && [ ! -L "$path" ] || continue + latest="$path" + done + [ -n "$latest" ] || return 1 + printf '%s\n' "$latest" +} + +reread_pending_path() { + printf '%s.pending\n' "$(reread_instruction_path "$1")" +} + +reread_retry_stage_path() { + local home=$1 id=$2 retry_dir path latest= + retry_dir="$home/state/.fm-inherited-config-reread-retry/$id" + for path in "$retry_dir"/.fm-inherited-config-reread.*; do + [ -f "$path" ] && [ ! -L "$path" ] || continue + latest="$path" + done + [ -n "$latest" ] || return 1 + printf '%s\n' "$latest" +} + +reread_retry_report_path() { + local home=$1 id=$2 retry_dir path latest= + retry_dir="$home/state/.fm-inherited-config-reread-retry/$id" + for path in "$retry_dir"/.fm-inherited-config-reread.*.report; do + [ -f "$path" ] && [ ! -L "$path" ] || continue + latest="$path" + done + [ -n "$latest" ] || return 1 + printf '%s\n' "$latest" +} + +reread_mode() { + if [ "$(uname)" = Darwin ]; then + stat -f %Lp "$1" + else + stat -c %a "$1" + fi +} + +assert_no_reread_instructions() { + local home=$1 state path + state="$home/state" + for path in "$state"/.fm-inherited-config-reread.*; do + case "$path" in + *.pending) continue ;; + esac + [ -f "$path" ] || [ -L "$path" ] || continue + fail "unexpected config reread instruction: $path" + done +} + +assert_no_reread_pending() { + local home=$1 state path + state="$home/state" + for path in "$state"/.fm-inherited-config-reread.*.pending; do + [ -e "$path" ] || [ -L "$path" ] || continue + fail "unexpected pending config reread marker: $path" + done +} + +assert_no_reread_retry_stages() { + local home=$1 id=$2 retry_dir path + retry_dir="$home/state/.fm-inherited-config-reread-retry/$id" + for path in "$retry_dir"/.fm-inherited-config-reread.*; do + [ -e "$path" ] || [ -L "$path" ] || continue + fail "unexpected staged config reread retry: $path" + done } # The sweep pushes the primary's declared inherited config into a live home, @@ -898,8 +1019,30 @@ test_bootstrap_sweep_surfaces_config_propagation_failure() { pass "B11 bootstrap sweep surfaces config propagation failures" } +test_bootstrap_rereads_after_partial_propagation() { + local w head log out instruction pointer + w=$(new_world boot-prop-partial) + head=$(git -C "$w/main" rev-parse HEAD) + add_sm_worktree "$w" sm "$head" + printf '{"default":{"harness":"codex"}}\n' > "$w/home/config/crew-dispatch.json" + printf 'invalid shared header\n' > "$w/home/data/captain-shared.md" + log="$w/boot-prop-partial.tmux.log" + + out=$(run_bootstrap "$w" "$log") + assert_contains "$out" "SECONDMATE_SYNC: secondmate sm: skipped: inheritance failed" \ + "partial bootstrap propagation did not remain diagnostic" + [ "$(cat "$w/sm/config/crew-dispatch.json")" = '{"default":{"harness":"codex"}}' ] \ + || fail "partial bootstrap propagation did not retain the completed config write" + instruction=$(reread_instruction_path "$w/sm") || fail "partial bootstrap reread instruction missing" + assert_present "$instruction" "partial bootstrap propagation did not write a reread instruction" + pointer="CONFIG_REREAD: $(reread_instruction_path "$w/sm")" + assert_contains "$(cat "$log")" "$pointer" \ + "partial bootstrap propagation did not route the instruction pointer" + pass "B11 bootstrap rereads completed config writes after partial propagation" +} + test_config_push_propagates_reports_without_ff_or_nudge() { - local w c1 sm_real old_head out err status out2 tmp + local w c1 sm_real old_head out err status out2 tmp log w=$(new_world config-push-basic) c1=$(git -C "$w/main" rev-parse HEAD) add_sm_worktree "$w" sm "$c1" @@ -918,7 +1061,8 @@ test_config_push_propagates_reports_without_ff_or_nudge() { printf 'codex\n' > "$w/home/config/crew-harness" printf 'manual\n' > "$w/home/config/backlog-backend" err="$w/config-push-basic.err" - out=$(run_config_push "$w" 2>"$err"); status=$? + log="$w/config-push-basic.tmux.log" + out=$(run_config_push "$w" "$log" 2>"$err"); status=$? expect_code 0 "$status" "config push should succeed" assert_contains "$out" "config-push: $w/home -> live secondmate homes" \ @@ -931,13 +1075,18 @@ test_config_push_propagates_reports_without_ff_or_nudge() { "config push did not report crew-harness as pushed" assert_contains "$out" "backlog-backend: pushed" \ "config push did not report backlog-backend as pushed" + assert_contains "$out" "config-reread: sent" \ + "config push with changed config must send a literal reread instruction" assert_not_contains "$out" "NUDGE_SECONDMATES" \ - "config push must not nudge secondmates" + "config push must not use the AGENTS.md instruction-surface nudge channel" [ "$(git -C "$w/sm" rev-parse HEAD)" = "$old_head" ] \ || fail "config push fast-forwarded tracked files" [ ! -s "$err" ] || fail "clean config push wrote unexpected stderr: $(cat "$err")" + assert_contains "$(cat "$log")" "[fm-from-firstmate]" \ + "config reread must use the marked routed secondmate path" - out2=$(run_config_push "$w" 2>"$err"); status=$? + : > "$log" + out2=$(run_config_push "$w" "$log" 2>"$err"); status=$? expect_code 0 "$status" "idempotent config push should succeed" assert_contains "$out2" "crew-dispatch.json: unchanged" \ "idempotent config push did not report crew-dispatch as unchanged" @@ -945,7 +1094,10 @@ test_config_push_propagates_reports_without_ff_or_nudge() { "idempotent config push did not report crew-harness as unchanged" assert_contains "$out2" "backlog-backend: unchanged" \ "idempotent config push did not report backlog-backend as unchanged" - pass "B12 config-push propagates via shared live discovery, reports items, and does not fast-forward or nudge" + assert_not_contains "$out2" "config-reread: sent" \ + "unchanged config must not send a reread message" + [ ! -s "$log" ] || fail "unchanged config push still invoked tmux send: $(cat "$log")" + pass "B12 config-push propagates via shared live discovery, reports items, rereads on change only, and does not fast-forward" } test_config_push_reports_skips_dirty_and_invalid_home() { @@ -1016,6 +1168,882 @@ test_config_push_exits_nonzero_on_copy_error() { pass "B14 config-push exits nonzero on real propagation errors" } +test_config_push_rereads_after_partial_propagation() { + local w head log out err status instruction pointer + w=$(new_world config-push-partial) + head=$(git -C "$w/main" rev-parse HEAD) + add_sm_worktree "$w" sm "$head" + printf '{"default":{"harness":"codex"}}\n' > "$w/home/config/crew-dispatch.json" + printf 'invalid shared header\n' > "$w/home/data/captain-shared.md" + log="$w/config-push-partial.tmux.log" + err="$w/config-push-partial.err" + + out=$(run_config_push "$w" "$log" 2>"$err"); status=$? + expect_code 1 "$status" "partial propagation should remain non-zero" + assert_contains "$out" "crew-dispatch.json: pushed" \ + "partial propagation did not report the completed config item" + assert_contains "$out" "data/captain-shared.md: error" \ + "partial propagation did not report the failed shared item" + assert_contains "$out" "config-reread: sent" \ + "partial propagation lost the completed config reread" + [ "$(cat "$w/sm/config/crew-dispatch.json")" = '{"default":{"harness":"codex"}}' ] \ + || fail "partial propagation did not retain the completed config write" + instruction=$(reread_instruction_path "$w/sm") || fail "partial propagation reread instruction missing" + assert_present "$instruction" "partial propagation did not write a reread instruction" + pointer="CONFIG_REREAD: $(reread_instruction_path "$w/sm")" + assert_contains "$(cat "$log")" "$pointer" \ + "partial propagation did not route the instruction pointer" + pass "B14 config-push rereads completed config writes after partial propagation" +} + +# --------------------------------------------------------------------------- +# Literal-content config reread nudge (post-propagation live-agent wake) +# --------------------------------------------------------------------------- + +shared_captain_header_for_tests() { + cat <<'EOF' +# Shared captain preferences + +This file is main-authoritative in the main firstmate home. +In secondmate homes it is read-only in secondmate homes and must not be edited there. +Route new captain-preference discoveries to the main firstmate through marked status or a document pointer. +EOF +} + +# End-user-aligned reproduction of the pre-fix gap, then the fixed behavior: +# two live homes start with different stale config subsets; after push each is +# updated and each live agent receives only its own changed-content instruction. +test_config_reread_per_home_changed_sets_and_exact_bytes() { + local w head log out err status instr_a instr_b multiline_json pointer + w=$(new_world config-reread-per-home) + head=$(git -C "$w/main" rev-parse HEAD) + add_sm_worktree "$w" alpha "$head" + add_sm_worktree "$w" beta "$head" + mkdir -p "$w/alpha/config" "$w/beta/config" "$w/alpha/state" "$w/beta/state" + + # alpha is stale on harness + backlog; beta is stale on multiline dispatch only. + printf 'pi\n' > "$w/alpha/config/crew-harness" + printf 'tasks-axi\n' > "$w/alpha/config/backlog-backend" + printf '{"default":{"harness":"old"}}\n' > "$w/beta/config/crew-dispatch.json" + + multiline_json=$(printf '{\n "default": {\n "harness": "grok",\n "model": "grok-4.5"\n },\n "rules": [\n {"when": "news", "use": {"harness": "grok"}}\n ]\n}\n') + printf '%s' "$multiline_json" > "$w/home/config/crew-dispatch.json" + printf 'codex\n' > "$w/home/config/crew-harness" + printf 'manual\n' > "$w/home/config/backlog-backend" + { + shared_captain_header_for_tests + printf '%s\n' "shared secret preference body that must never appear in a config reread" + } > "$w/home/data/captain-shared.md" + + log="$w/config-reread-per-home.tmux.log" + err="$w/config-reread-per-home.err" + out=$(run_config_push "$w" "$log" 2>"$err"); status=$? + expect_code 0 "$status" "per-home reread config push should succeed" + [ ! -s "$err" ] || fail "unexpected stderr: $(cat "$err")" + + # Destination bytes converged per home. + cmp -s "$w/home/config/crew-dispatch.json" "$w/alpha/config/crew-dispatch.json" \ + || fail "alpha did not receive multiline dispatch" + cmp -s "$w/home/config/crew-dispatch.json" "$w/beta/config/crew-dispatch.json" \ + || fail "beta did not receive multiline dispatch" + [ "$(cat "$w/alpha/config/crew-harness")" = codex ] || fail "alpha harness not updated" + [ "$(cat "$w/alpha/config/backlog-backend")" = manual ] || fail "alpha backlog-backend not updated" + + instr_a=$(reread_instruction_path "$w/alpha") || fail "alpha instruction missing after config push" + instr_b=$(reread_instruction_path "$w/beta") || fail "beta instruction missing after config push" + assert_present "$instr_a" "alpha should receive a config-reread instruction file" + assert_present "$instr_b" "beta should receive a config-reread instruction file" + [ "$(reread_mode "$instr_a")" = 600 ] || fail "alpha instruction is not private" + [ "$(reread_mode "$instr_b")" = 600 ] || fail "beta instruction is not private" + + # Deterministic allowlist path order and exact destination bytes for alpha + # (all three config items were missing/stale and therefore pushed). + assert_grep "These inherited config files changed" "$instr_a" "alpha framing missing" + assert_grep "defaults/rules" "$instr_a" "alpha must preserve agent judgment framing" + assert_contains "$(cat "$instr_a")" "config/crew-dispatch.json" "alpha missing dispatch path" + assert_contains "$(cat "$instr_a")" "config/crew-harness" "alpha missing harness path" + assert_contains "$(cat "$instr_a")" "config/backlog-backend" "alpha missing backlog path" + # Path order follows FM_INHERITABLE_CONFIG. + awk ' + /config\/crew-dispatch\.json/ { d=NR } + /config\/crew-harness/ { h=NR } + /config\/backlog-backend/ { b=NR } + END { + if (!(d && h && b && d < h && h < b)) exit 1 + } + ' "$instr_a" || fail "alpha instruction path order is not deterministic allowlist order" + + # Exact multiline JSON appears byte-for-byte between delimiters. + assert_contains "$(cat "$instr_a")" "$multiline_json" \ + "alpha instruction must include exact multiline dispatch bytes" + assert_contains "$(cat "$instr_a")" $'-----BEGIN config/crew-harness-----\ncodex\n-----END config/crew-harness-----' \ + "alpha instruction must include exact harness scalar bytes" + assert_contains "$(cat "$instr_a")" $'-----BEGIN config/backlog-backend-----\nmanual\n-----END config/backlog-backend-----' \ + "alpha instruction must include exact backlog-backend scalar bytes" + + # No parsed/effective summary, no SHA, no captain-shared dump. + assert_not_contains "$(cat "$instr_a")" "Default worker" "must not emit parsed worker summary" + assert_not_contains "$(cat "$instr_a")" "sha" "must not emit sha tokens" + assert_not_contains "$(cat "$instr_a")" "SHA" "must not emit SHA tokens" + assert_not_contains "$(cat "$instr_a")" "captain-shared" "captain-shared path must not appear" + assert_not_contains "$(cat "$instr_a")" "shared secret preference body" \ + "captain-shared content must never be inlined" + assert_not_contains "$(cat "$instr_b")" "shared secret preference body" \ + "beta must not inline captain-shared either" + + # Beta started with only dispatch stale; harness/backlog were absent on both + # sides for beta... wait: primary has harness+backlog, beta lacked them, so + # they are also pushed. Seed beta with matching harness/backlog so only + # dispatch changes for beta - re-run a focused unit of the write helper below. + # For this push, beta was missing harness and backlog too, so all three push. + # Prove isolation by comparing that neither instruction references the other's + # pre-push stale unique value. + assert_not_contains "$(cat "$instr_a")" '"harness":"old"' \ + "alpha must not receive beta's pre-push stale dispatch" + assert_not_contains "$(cat "$instr_b")" $'pi\n' \ + "beta instruction must not leak alpha-only stale harness bytes as a standalone scalar block incorrectly" + + # Routed send used the from-firstmate marker and carried only the pointer. + pointer="CONFIG_REREAD: $(reread_instruction_path "$w/alpha")" + assert_contains "$(cat "$log")" "[fm-from-firstmate]" "reread send must be marked" + assert_contains "$(cat "$log")" "$pointer" "reread send must point to the durable instruction file" + assert_not_contains "$(cat "$log")" '"harness": "grok"' "sent message must not inline multiline JSON" + assert_not_contains "$(cat "$log")" $'\n "default"' "sent message must not contain embedded newlines" + assert_not_contains "$(cat "$log")" "Default worker" "sent message must not summarize" + pass "B15 config reread is per-home, exact-byte, ordered, and pointer-only" +} + +test_config_reread_isolation_and_absent_and_send_failure() { + local w head log out out2 err status status2 instr_a instr_b report retry_log retry_out retry_status retry_pointer + local first_instr first_copy second_instr second_pointer + w=$(new_world config-reread-absent) + head=$(git -C "$w/main" rev-parse HEAD) + add_sm_worktree "$w" alpha "$head" + add_sm_worktree "$w" beta "$head" + mkdir -p "$w/alpha/config" "$w/beta/config" "$w/alpha/state" "$w/beta/state" + + # alpha: only harness will change (dispatch+backlog already match primary absence). + # beta: only dispatch will change. + printf 'old-harness\n' > "$w/alpha/config/crew-harness" + printf '{"stale":true}\n' > "$w/beta/config/crew-dispatch.json" + # Primary has only crew-harness set; dispatch and backlog absent. + printf 'codex\n' > "$w/home/config/crew-harness" + rm -f "$w/home/config/crew-dispatch.json" "$w/home/config/backlog-backend" + + log="$w/config-reread-absent.tmux.log" + err="$w/config-reread-absent.err" + out=$(run_config_push "$w" "$log" 2>"$err"); status=$? + expect_code 0 "$status" "absent-mirror reread push should succeed" + + instr_a=$(reread_instruction_path "$w/alpha") || fail "alpha instruction missing after config push" + instr_b=$(reread_instruction_path "$w/beta") || fail "beta instruction missing after config push" + assert_present "$instr_a" "alpha instruction missing after harness change" + assert_present "$instr_b" "beta instruction missing after dispatch removal" + + # alpha changed harness only. + assert_contains "$(cat "$instr_a")" "config/crew-harness" "alpha should mention harness" + assert_contains "$(cat "$instr_a")" $'-----BEGIN config/crew-harness-----\ncodex\n-----END config/crew-harness-----' \ + "alpha harness block exact" + assert_not_contains "$(cat "$instr_a")" "config/crew-dispatch.json" \ + "alpha must not list unchanged/absent-both dispatch" + assert_not_contains "$(cat "$instr_a")" "config/backlog-backend" \ + "alpha must not list unchanged/absent-both backlog" + assert_not_contains "$(cat "$instr_a")" '{"stale":true}' \ + "alpha must not receive beta's changed dispatch content" + + # beta: dispatch mirrored to ABSENT (and harness is also newly pushed from primary). + assert_contains "$(cat "$instr_b")" "config/crew-dispatch.json" "beta should mention dispatch" + assert_contains "$(cat "$instr_b")" $'-----BEGIN config/crew-dispatch.json-----\nABSENT\n-----END config/crew-dispatch.json-----' \ + "beta must represent removal as ABSENT" + assert_not_contains "$(cat "$instr_b")" "old-harness" \ + "beta must not receive alpha's pre-push stale harness content" + # Pure ABSENT + unchanged isolation via the write helper (no second inheritance path). + report="$w/absent-only.report" + { + printf '%s\n' $'crew-dispatch.json\tpushed\tmirrored primary absence' + printf '%s\n' $'crew-harness\tunchanged\t' + printf '%s\n' $'backlog-backend\tunchanged\t' + printf '%s\n' $'data/captain-shared.md\tpushed\t' + } > "$report" + rm -f "$w/beta/config/crew-dispatch.json" + fm_config_write_reread_instruction "$w/beta" "$report" "$w/beta/state/.fm-inherited-config-reread-absent" \ + || fail "ABSENT instruction write failed" + assert_contains "$(cat "$w/beta/state/.fm-inherited-config-reread-absent")" \ + $'-----BEGIN config/crew-dispatch.json-----\nABSENT\n-----END config/crew-dispatch.json-----' \ + "helper ABSENT representation" + assert_not_contains "$(cat "$w/beta/state/.fm-inherited-config-reread-absent")" "captain-shared" \ + "helper must ignore captain-shared even when report says pushed" + assert_not_contains "$(cat "$w/beta/state/.fm-inherited-config-reread-absent")" "config/crew-harness" \ + "helper must omit unchanged items" + + # Send failure becomes a retryable diagnostic and non-zero exit. + printf 'claude\n' > "$w/home/config/crew-harness" + err="$w/config-reread-send-fail.err" + out=$(PATH="$(make_fake_toolchain "$w"):$BASE_PATH" \ + FM_HOME="$w/home" FM_ROOT_OVERRIDE="$w/main" FM_SEND_SETTLE=0 \ + FM_FAKE_TMUX_FAIL_LITERAL=1 \ + "$ROOT/bin/fm-config-push.sh" 2>"$err"); status=$? + expect_code 1 "$status" "send failure should make config-push exit non-zero" + assert_contains "$out" "CONFIG_REREAD: secondmate" "send failure diagnostic missing" + assert_contains "$out" "send failed" "send failure must say send failed" + assert_not_contains "$out" "config-reread: sent" \ + "must not claim reread landed when send failed" + first_instr=$(reread_instruction_path "$w/alpha") || fail "alpha failed-send instruction missing" + first_copy="$w/alpha/first-reread-generation.copy" + cp "$first_instr" "$first_copy" + assert_present "$(reread_pending_path "$w/alpha")" \ + "alpha send failure did not record a retry marker" + assert_present "$(reread_pending_path "$w/beta")" \ + "beta send failure did not record a retry marker" + + # A later changed push publishes a distinct generation without overwriting + # the failed generation, then an unchanged push retries both pointers. + printf 'pi\n' > "$w/home/config/crew-harness" + err="$w/config-reread-send-fail-second.err" + out2=$(PATH="$(make_fake_toolchain "$w"):$BASE_PATH" \ + FM_HOME="$w/home" FM_ROOT_OVERRIDE="$w/main" FM_SEND_SETTLE=0 \ + FM_FAKE_TMUX_FAIL_LITERAL=1 \ + "$ROOT/bin/fm-config-push.sh" 2>"$err"); status2=$? + expect_code 1 "$status2" "second send failure should make config-push exit non-zero" + assert_not_contains "$out2" "config-reread: sent" \ + "second send failure must not claim reread delivery" + second_instr=$(reread_instruction_path "$w/alpha") || fail "alpha second generation missing" + [ "$first_instr" != "$second_instr" ] || fail "successive pushes reused the same generation path" + cmp -s "$first_copy" "$first_instr" || fail "later push overwrote the earlier generation bytes" + second_pointer="CONFIG_REREAD: $second_instr" + assert_present "$(reread_pending_path "$w/alpha")" \ + "alpha second generation did not remain pending" + + # A normal later push retries the durable pointers even though propagation is + # unchanged, then clears every marker after delivery succeeds. + retry_log="$w/config-reread-send-retry.tmux.log" + retry_out=$(run_config_push "$w" "$retry_log" 2>"$err"); retry_status=$? + expect_code 0 "$retry_status" "send failure should be retryable" + assert_contains "$retry_out" "config-reread: sent" \ + "retry should report the reread as sent" + retry_pointer="CONFIG_REREAD: $(reread_instruction_path "$w/beta")" + assert_contains "$(cat "$retry_log")" "$retry_pointer" \ + "retry did not resend the durable pointer" + assert_contains "$(cat "$retry_log")" "CONFIG_REREAD: $first_instr" \ + "retry did not resend the first pending generation" + assert_contains "$(cat "$retry_log")" "$second_pointer" \ + "retry did not resend the second pending generation" + assert_no_reread_pending "$w/alpha" + assert_no_reread_pending "$w/beta" + pass "B16 config reread isolation, ABSENT, generation safety, send failure, and retry" +} + +test_config_reread_publication_failure_retries_exact_generation() { + local w head fakebin real_mv alpha_state out status stage log instr retry_out retry_status + w=$(new_world config-reread-publication-retry) + head=$(git -C "$w/main" rev-parse HEAD) + add_sm_worktree "$w" alpha "$head" + mkdir -p "$w/alpha/config" "$w/alpha/state" + printf 'old\n' > "$w/alpha/config/crew-harness" + printf 'codex\n' > "$w/home/config/crew-harness" + + fakebin=$(make_fake_toolchain "$w") + real_mv=$(command -v mv) + alpha_state=$(cd "$w/alpha/state" && pwd -P) + cat > "$fakebin/mv" <&1); status=$? + expect_code 1 "$status" "publication failure should remain diagnostic" + assert_contains "$out" "CONFIG_REREAD: secondmate" "publication failure diagnostic missing" + assert_not_contains "$out" "config-reread: sent" \ + "publication failure must not claim reread delivery" + [ "$(cat "$w/alpha/config/crew-harness")" = codex ] \ + || fail "publication failure did not retain the completed config write" + stage=$(reread_retry_stage_path "$w/home" alpha) \ + || fail "publication failure did not retain an exact retry generation" + assert_contains "$(cat "$stage")" \ + $'-----BEGIN config/crew-harness-----\ncodex\n-----END config/crew-harness-----' \ + "retry generation did not retain exact destination bytes" + assert_no_reread_instructions "$w/alpha" + + rm -f "$fakebin/mv" + log="$w/config-reread-publication-retry.tmux.log" + retry_out=$(run_config_push "$w" "$log" 2>/dev/null); retry_status=$? + expect_code 0 "$retry_status" "publication failure should retry on an unchanged push" + assert_contains "$retry_out" "config-reread: sent" \ + "successful publication retry should report delivery" + instr=$(reread_instruction_path "$w/alpha") \ + || fail "publication retry did not publish an instruction" + assert_contains "$(cat "$log")" "CONFIG_REREAD: $instr" \ + "publication retry did not send the durable pointer" + assert_no_reread_retry_stages "$w/home" alpha + pass "B20 config reread publication failures retain exact generations for retry" +} + +test_config_reread_write_failure_retains_exact_retry_generation() { + local w head fakebin real_mv retry_dir out status stage_path log retry_out retry_status instr + local old_instr new_instr + w=$(new_world config-reread-write-retry) + head=$(git -C "$w/main" rev-parse HEAD) + add_sm_worktree "$w" sm "$head" + mkdir -p "$w/sm/config" "$w/sm/state" + printf 'old\n' > "$w/sm/config/crew-harness" + printf 'codex\n' > "$w/home/config/crew-harness" + fakebin=$(make_fake_toolchain "$w") + real_mv=$(command -v mv) + mkdir -p "$w/home/state/.fm-inherited-config-reread-retry/sm" + retry_dir=$(cd "$w/home/state/.fm-inherited-config-reread-retry/sm" && pwd -P) + cat > "$fakebin/mv" <&1); status=$? + expect_code 1 "$status" "instruction-write failure should remain diagnostic" + assert_contains "$out" "retained exact retry generation" \ + "instruction-write failure did not retain exact retry bytes" + stage_path=$(reread_retry_stage_path "$w/home" sm) \ + || fail "instruction-write failure did not leave a durable exact generation" + assert_contains "$(cat "$stage_path")" \ + $'-----BEGIN config/crew-harness-----\ncodex\n-----END config/crew-harness-----' \ + "instruction-write failure did not retain the original exact bytes" + printf 'changed-before-retry\n' > "$w/home/config/crew-harness" + rm -f "$fakebin/mv" + log="$w/config-reread-write-retry.tmux.log" + retry_out=$(run_config_push "$w" "$log" 2>/dev/null); retry_status=$? + expect_code 0 "$retry_status" "a later changed push should retry an instruction-write failure" + assert_contains "$retry_out" "config-reread: sent" \ + "later changed push did not deliver the retained exact generation" + old_instr=$(grep 'CONFIG_REREAD:' "$log" | head -n 1 | sed 's/.*CONFIG_REREAD: //') + new_instr=$(grep 'CONFIG_REREAD:' "$log" | tail -n 1 | sed 's/.*CONFIG_REREAD: //') + [ -n "$old_instr" ] && [ -n "$new_instr" ] && [ "$old_instr" != "$new_instr" ] \ + || fail "later changed push did not deliver both generations" + instr="$old_instr" + assert_contains "$(cat "$instr")" \ + $'-----BEGIN config/crew-harness-----\ncodex\n-----END config/crew-harness-----' \ + "exact retry delivery did not preserve the original destination bytes" + assert_contains "$(cat "$new_instr")" "changed-before-retry" \ + "later changed push did not deliver its new destination bytes" + assert_no_reread_retry_stages "$w/home" sm + pass "B21 config reread instruction-write failures retain exact retry generations" +} + +test_config_reread_exact_temp_survives_adoption_failure() { + local w head fakebin real_mv real_cp retry_dir out status stage_path log retry_out retry_status + local old_instr new_instr + w=$(new_world config-reread-exact-temp-fallback) + head=$(git -C "$w/main" rev-parse HEAD) + add_sm_worktree "$w" sm "$head" + mkdir -p "$w/sm/config" "$w/sm/state" + printf 'old\n' > "$w/sm/config/crew-harness" + printf 'codex\n' > "$w/home/config/crew-harness" + fakebin=$(make_fake_toolchain "$w") + real_mv=$(command -v mv) + real_cp=$(command -v cp) + mkdir -p "$w/home/state/.fm-inherited-config-reread-retry/sm" + retry_dir=$(cd "$w/home/state/.fm-inherited-config-reread-retry/sm" && pwd -P) + cat > "$fakebin/mv" < "$fakebin/cp" <&1); status=$? + expect_code 1 "$status" "exact temporary fallback failure should remain diagnostic" + assert_contains "$out" "retained exact retry temporary" \ + "exact temporary fallback failure did not retain the immutable bytes" + stage_path=$(reread_retry_stage_path "$w/home" sm) \ + || fail "exact temporary fallback failure lost its retry artifact" + case "$stage_path" in + *.tmp.*) : ;; + *) fail "exact temporary fallback retained an unexpected artifact: $stage_path" ;; + esac + [ ! -e "$stage_path.report" ] \ + || fail "exact temporary fallback created a lossy retry report" + assert_contains "$(cat "$stage_path")" \ + $'-----BEGIN config/crew-harness-----\ncodex\n-----END config/crew-harness-----' \ + "exact temporary fallback did not preserve the original bytes" + printf 'changed-before-retry\n' > "$w/home/config/crew-harness" + rm -f "$fakebin/mv" "$fakebin/cp" + log="$w/config-reread-exact-temp-fallback.tmux.log" + retry_out=$(run_config_push "$w" "$log" 2>/dev/null); retry_status=$? + expect_code 0 "$retry_status" "later push should deliver retained exact temporary bytes" + old_instr=$(grep 'CONFIG_REREAD:' "$log" | head -n 1 | sed 's/.*CONFIG_REREAD: //') + new_instr=$(grep 'CONFIG_REREAD:' "$log" | tail -n 1 | sed 's/.*CONFIG_REREAD: //') + [ -n "$old_instr" ] && [ -n "$new_instr" ] && [ "$old_instr" != "$new_instr" ] \ + || fail "later push did not deliver both exact generations" + assert_contains "$(cat "$old_instr")" \ + $'-----BEGIN config/crew-harness-----\ncodex\n-----END config/crew-harness-----' \ + "later push rebuilt the retained temporary from newer bytes" + assert_contains "$(cat "$new_instr")" "changed-before-retry" \ + "later push did not deliver the new destination bytes" + assert_no_reread_retry_stages "$w/home" sm + pass "B21 config reread preserves exact bytes when temporary adoption also fails" +} + +test_config_reread_serializes_concurrent_pushes() { + local w head fakebin marker entered log first_out second_out first_pid first_status second_status + local first_instr second_instr first_line second_line + w=$(new_world config-reread-serialized-pushes) + head=$(git -C "$w/main" rev-parse HEAD) + add_sm_worktree "$w" sm "$head" + mkdir -p "$w/sm/config" "$w/sm/state" + printf 'old\n' > "$w/sm/config/crew-harness" + printf 'one\n' > "$w/home/config/crew-harness" + + fakebin=$(make_fake_toolchain "$w") + mv "$fakebin/tmux" "$fakebin/tmux.real" + marker="$w/first-send.marker" + entered="$w/first-send.entered" + log="$w/config-reread-serialized.tmux.log" + cat > "$fakebin/tmux" < "$marker") 2>/dev/null; then + : > "$entered" + sleep 1 + fi + ;; +esac +exec "$fakebin/tmux.real" "\$@" +SH + chmod +x "$fakebin/tmux" + + first_out="$w/first-push.out" + ( + PATH="$fakebin:$BASE_PATH" FM_HOME="$w/home" FM_ROOT_OVERRIDE="$w/main" \ + FM_SEND_SETTLE=0 FM_FAKE_TMUX_LOG="$log" \ + "$ROOT/bin/fm-config-push.sh" > "$first_out" 2>&1 + ) & + first_pid=$! + for _ in $(seq 1 100); do + [ -e "$entered" ] && break + sleep 0.02 + done + [ -e "$entered" ] || fail "first config push did not reach pointer delivery" + first_instr=$(reread_instruction_path "$w/sm") \ + || fail "first concurrent push did not publish its generation" + printf 'two\n' > "$w/home/config/crew-harness" + second_out="$w/second-push.out" + PATH="$fakebin:$BASE_PATH" FM_HOME="$w/home" FM_ROOT_OVERRIDE="$w/main" \ + FM_SEND_SETTLE=0 FM_FAKE_TMUX_LOG="$log" \ + "$ROOT/bin/fm-config-push.sh" > "$second_out" 2>&1 + second_status=$? + wait "$first_pid"; first_status=$? + expect_code 0 "$first_status" "first serialized config push failed" + expect_code 0 "$second_status" "second serialized config push failed" + second_instr=$(reread_instruction_path "$w/sm") \ + || fail "second concurrent push did not publish its generation" + [ "$first_instr" != "$second_instr" ] || fail "concurrent pushes reused a generation" + [ "$(cat "$w/sm/config/crew-harness")" = two ] \ + || fail "concurrent pushes did not converge the latest config bytes" + first_line=$(grep -n -F "CONFIG_REREAD: $first_instr" "$log" | head -n 1 | cut -d: -f1) + second_line=$(grep -n -F "CONFIG_REREAD: $second_instr" "$log" | head -n 1 | cut -d: -f1) + [ -n "$first_line" ] && [ -n "$second_line" ] && [ "$first_line" -lt "$second_line" ] \ + || fail "concurrent pushes delivered generations out of order" + pass "B21 config reread serializes concurrent propagation and delivery" +} + +test_config_reread_full_retry_queue_drains_before_new_push() { + local w head retry_dir path n fakebin log out status pointer_count + w=$(new_world config-reread-full-queue) + head=$(git -C "$w/main" rev-parse HEAD) + add_sm_worktree "$w" sm "$head" + mkdir -p "$w/sm/config" "$w/sm/state" + printf 'old\n' > "$w/sm/config/crew-harness" + printf 'new\n' > "$w/home/config/crew-harness" + retry_dir="$w/home/state/.fm-inherited-config-reread-retry/sm" + mkdir -p "$retry_dir" + for n in $(seq -w 1 16); do + path="$retry_dir/.fm-inherited-config-reread.20260721T000000.$n" + printf 'generation-%s\n' "$n" > "$path" + chmod 0600 "$path" + done + fakebin=$(make_fake_toolchain "$w") + log="$w/config-reread-full-queue.tmux.log" + out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$w/home" FM_ROOT_OVERRIDE="$w/main" \ + FM_SEND_SETTLE=0 FM_FAKE_TMUX_LOG="$log" \ + "$ROOT/bin/fm-config-push.sh" 2>&1); status=$? + expect_code 0 "$status" "a full retry queue should drain before a new push" + assert_contains "$out" "config-reread: sent" \ + "a new config generation was not delivered after retry draining" + [ "$(cat "$w/sm/config/crew-harness")" = new ] \ + || fail "the new config generation did not propagate after retry draining" + assert_no_reread_retry_stages "$w/home" sm + pointer_count=$(grep -c 'CONFIG_REREAD:' "$log" 2>/dev/null || true) + [ "$pointer_count" -ge 17 ] \ + || fail "full retry queue did not deliver all pending generations before the new one" + pass "B22 full config reread retry queues drain before new publication" +} + +test_config_reread_cleanup_runs_after_mixed_delivery_failure() { + local w head fakebin state_real fail_path path n report out status count + w=$(new_world config-reread-mixed-delivery) + head=$(git -C "$w/main" rev-parse HEAD) + add_sm_worktree "$w" sm "$head" + mkdir -p "$w/sm/state" + state_real=$(cd "$w/sm/state" && pwd -P) + fail_path="$state_real/.fm-inherited-config-reread.9999-fail" + for n in $(seq -w 1 18); do + path="$state_real/.fm-inherited-config-reread.00$n" + printf 'generation-%s\n' "$n" > "$path" + chmod 0600 "$path" + done + printf 'failed-generation\n' > "$fail_path" + chmod 0600 "$fail_path" + for path in "$state_real"/.fm-inherited-config-reread.*; do + fm_config_reread_mark_pending "$path" "$path.pending" \ + || fail "could not mark mixed-delivery generation pending" + done + fakebin=$(make_fake_toolchain "$w") + mv "$fakebin/tmux" "$fakebin/tmux.real" + cat > "$fakebin/tmux" < "$report" + out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$w/home" FM_ROOT_OVERRIDE="$w/main" \ + FM_SEND_SETTLE=0 fm_config_send_reread_nudge sm "$w/sm" "$report" 2>&1); status=$? + expect_code 1 "$status" "mixed delivery failure should remain diagnostic" + assert_contains "$out" "CONFIG_REREAD: secondmate sm: send failed" \ + "mixed delivery failure diagnostic missing" + count=0 + for path in "$state_real"/.fm-inherited-config-reread.*; do + case "$path" in + *.pending) continue ;; + esac + [ -f "$path" ] && [ ! -L "$path" ] || continue + [ ! -e "$path.pending" ] || continue + count=$((count + 1)) + done + [ "$count" = 16 ] || fail "mixed delivery failure skipped bounded sent-history cleanup (count=$count)" + assert_present "$fail_path.pending" "failed generation lost its retry marker" + pass "B23 mixed config reread delivery failures still bound sent history" +} + +test_config_reread_stops_after_failed_generation() { + local w fakebin state_real old new report log out status + w=$(new_world config-reread-order) + mkdir -p "$w/sm/state" + state_real=$(cd "$w/sm/state" && pwd -P) + old="$state_real/.fm-inherited-config-reread.0000-fail" + new="$state_real/.fm-inherited-config-reread.0001-new" + printf 'old-generation\n' > "$old" + printf 'new-generation\n' > "$new" + chmod 0600 "$old" "$new" + fm_config_reread_mark_pending "$old" "$old.pending" \ + || fail "could not mark older generation pending" + fm_config_reread_mark_pending "$new" "$new.pending" \ + || fail "could not mark newer generation pending" + fakebin=$(make_fake_toolchain "$w") + mv "$fakebin/tmux" "$fakebin/tmux.real" + cat > "$fakebin/tmux" < "$report" + log="$w/config-reread-order.tmux.log" + out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$w/home" FM_ROOT_OVERRIDE="$ROOT" \ + FM_SEND_SETTLE=0 FM_FAKE_TMUX_LOG="$log" \ + fm_config_send_reread_nudge sm "$w/sm" "$report" 2>&1); status=$? + expect_code 1 "$status" "an older failed generation should remain diagnostic" + assert_contains "$out" "CONFIG_REREAD: secondmate sm: send failed" \ + "older generation failure diagnostic missing" + assert_not_contains "$(cat "$log" 2>/dev/null || true)" ".0001-new" \ + "newer generation was delivered after an older failure" + assert_present "$old.pending" "older failed generation lost its retry marker" + assert_present "$new.pending" "newer generation was sent after an older failure" + pass "B26 config reread delivery stops after the oldest failed generation" +} + +test_bootstrap_detect_only_does_not_create_state() { + local w fakebin detect_state out status + w=$(new_world bootstrap-detect-only) + detect_state="$w/detect-state" + fakebin=$(make_fake_toolchain "$w") + out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$w/home" FM_ROOT_OVERRIDE="$w/main" \ + FM_STATE_OVERRIDE="$detect_state" FM_BOOTSTRAP_DETECT_ONLY=1 \ + "$ROOT/bin/fm-bootstrap.sh" 2>&1); status=$? + expect_code 0 "$status" "detect-only bootstrap should succeed" + [ ! -e "$detect_state" ] || fail "detect-only bootstrap created its state directory" + pass "B24 bootstrap detect-only mode remains filesystem read-only" +} + +test_config_reread_skips_when_unchanged_and_reads_after_push() { + local w head log out err status report instr n path pending_instruction count + w=$(new_world config-reread-after-push) + head=$(git -C "$w/main" rev-parse HEAD) + add_sm_worktree "$w" sm "$head" + mkdir -p "$w/sm/config" "$w/sm/state" + + printf 'codex\n' > "$w/home/config/crew-harness" + printf 'codex\n' > "$w/sm/config/crew-harness" + log="$w/config-reread-unchanged.tmux.log" + out=$(run_config_push "$w" "$log" 2>/dev/null); status=$? + expect_code 0 "$status" "unchanged push should succeed" + assert_not_contains "$out" "config-reread: sent" "no reread when nothing changed" + [ ! -s "$log" ] || fail "unchanged push still sent text: $(cat "$log")" + assert_no_reread_instructions "$w/sm" + + # Prove instruction bytes are taken from destination after write: if we only + # read the primary source, a post-copy destination mutation would not matter. + # Here we call the write helper after planting distinct dest bytes and a + # pushed report line. + printf '%s' 'destination-post-write' > "$w/sm/config/crew-harness" + printf 'primary-source-only\n' > "$w/home/config/crew-harness" + report="$w/after-push.report" + printf '%s\n' $'crew-harness\tpushed\t' > "$report" + instr="$w/sm/state/.fm-inherited-config-reread-dest" + fm_config_write_reread_instruction "$w/sm" "$report" "$instr" \ + || fail "destination-byte instruction write failed" + assert_contains "$(cat "$instr")" "destination-post-write" \ + "instruction must use destination post-write bytes" + assert_contains "$(cat "$instr")" $'destination-post-write-----END config/crew-harness-----' \ + "instruction must not append a byte to a non-newline-terminated destination" + assert_not_contains "$(cat "$instr")" "primary-source-only" \ + "instruction must not fall back to primary source bytes" + : > "$w/sm/config/crew-harness" + fm_config_write_reread_instruction "$w/sm" "$report" "$instr" \ + || fail "empty destination instruction write failed" + assert_contains "$(cat "$instr")" $'-----BEGIN config/crew-harness----- +-----END config/crew-harness-----' \ + "instruction must represent an empty destination without a synthetic byte" + pending_instruction="$w/sm/state/.fm-inherited-config-reread.20260721T000000.01" + printf '%s\n' generation > "$pending_instruction" + fm_config_reread_mark_pending "$pending_instruction" "$pending_instruction.pending" \ + || fail "could not create bounded-lifecycle pending marker" + for n in $(seq -w 2 18); do + path="$w/sm/state/.fm-inherited-config-reread.20260721T000000.$n" + printf '%s\n' generation > "$path" + chmod 0600 "$path" + done + fm_config_reread_cleanup_sent "$w/sm" + count=0 + for path in "$w/sm/state"/.fm-inherited-config-reread.*; do + case "$path" in + *.pending) continue ;; + esac + [ -f "$path" ] && [ ! -L "$path" ] || continue + [ ! -e "$path.pending" ] || continue + count=$((count + 1)) + done + [ "$count" = 16 ] || fail "sent reread generations were not bounded" + assert_present "$pending_instruction" "cleanup deleted a pending reread generation" + assert_present "$pending_instruction.pending" "cleanup deleted a pending reread marker" + pass "B17 config reread skips unchanged homes and reads destination post-write bytes" +} + +test_config_reread_bootstrap_path_and_spawn_flexibility() { + local w head log out fakebin sm launchlog launch instr report stale + w=$(new_world config-reread-bootstrap) + head=$(git -C "$w/main" rev-parse HEAD) + add_sm_worktree "$w" sm "$head" + mkdir -p "$w/sm/config" "$w/sm/state" + printf 'old\n' > "$w/sm/config/crew-harness" + printf 'codex\n' > "$w/home/config/crew-harness" + + fakebin=$(make_fake_toolchain "$w") + log="$w/bootstrap-reread.tmux.log" + out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$w/home" FM_ROOT_OVERRIDE="$w/main" \ + FM_SEND_SETTLE=0 FM_FAKE_TMUX_LOG="$log" \ + "$ROOT/bin/fm-bootstrap.sh" 2>/dev/null) + [ "$(cat "$w/sm/config/crew-harness")" = codex ] || fail "bootstrap did not push harness" + instr=$(reread_instruction_path "$w/sm") || fail "bootstrap reread instruction missing" + assert_present "$instr" "bootstrap must write a config reread instruction when config changed" + assert_contains "$(cat "$log")" "[fm-from-firstmate]" \ + "bootstrap config reread must use routed secondmate send" + assert_contains "$(cat "$instr")" \ + $'-----BEGIN config/crew-harness-----\ncodex\n-----END config/crew-harness-----' \ + "bootstrap instruction must carry exact post-write harness bytes" + + # fm-spawn still permits a conscious explicit runtime outside the config + # (defaults/rules only - never harden spawn against deliberate choice). + w=$(new_world config-reread-spawn-flex) + printf 'codex\n' > "$w/home/config/crew-harness" + printf 'codex\n' > "$w/home/config/secondmate-harness" + sm="$w/sm-flex" + make_seeded_home "$sm" sm-flex + mkdir -p "$sm/state" + report="$sm/state/stale-reread.report" + printf '%s\n' $'crew-harness\tpushed\t' > "$report" + stale="$sm/state/.fm-inherited-config-reread.spawn-stale" + fm_config_write_reread_instruction "$sm" "$report" "$stale" \ + || fail "could not create spawn stale reread generation" + fm_config_reread_mark_pending "$stale" "$stale.pending" \ + || fail "could not create spawn stale reread marker" + launchlog="$w/spawn-flex.launch.log" + spawn_secondmate_capture "$w" sm-flex "$sm" "$launchlog" --harness pi >/dev/null 2>&1 + assert_no_reread_pending "$sm" + assert_no_reread_instructions "$sm" + launch=$(cat "$launchlog") + assert_contains "$launch" "pi" \ + "explicit --harness pi must still win over configured codex defaults" + pass "B18 bootstrap config reread path works; spawn flexibility remains defaults-only" +} + +test_bootstrap_respawns_before_config_reread() { + local w head fakebin log report stale + w=$(new_world config-reread-respawn-order) + head=$(git -C "$w/main" rev-parse HEAD) + add_sm_worktree "$w" sm "$head" + mkdir -p "$w/sm/config" "$w/sm/state" + printf 'harness=codex\n' >> "$w/home/state/sm.meta" + printf '%s' old > "$w/sm/config/crew-harness" + printf '%s' codex > "$w/home/config/crew-harness" + report="$w/sm/state/stale-reread.report" + printf '%s\n' $'crew-harness\tpushed\t' > "$report" + stale="$w/sm/state/.fm-inherited-config-reread.stale-generation" + fm_config_write_reread_instruction "$w/sm" "$report" "$stale" \ + || fail "could not create stale reread generation" + fm_config_reread_mark_pending "$stale" "$stale.pending" \ + || fail "could not create stale reread marker" + log="$w/config-reread-respawn-order.log" + +cat > "$w/main/bin/fm-spawn.sh" <> '$log' +printf '%s' codex > '$w/sm/config/crew-harness' +SH + chmod +x "$w/main/bin/fm-spawn.sh" + fakebin=$(make_fake_toolchain "$w") + cat > "$fakebin/tmux" <> '$log' ;; +esac +SH + chmod +x "$fakebin/tmux" + PATH="$fakebin:$BASE_PATH" FM_HOME="$w/home" FM_ROOT_OVERRIDE="$w/main" \ + FM_SEND_SETTLE=0 FM_FAKE_TMUX_LOG="$log" \ + "$ROOT/bin/fm-bootstrap.sh" >/dev/null 2>&1 + assert_contains "$(cat "$log")" "spawn" \ + "bootstrap did not respawn the dead secondmate" + assert_not_contains "$(cat "$log")" "send-keys" \ + "bootstrap nudged a secondmate before its respawn completed" + assert_present "$stale" "bootstrap removed the stale generation before relaunch handling" + assert_present "$stale.pending" "bootstrap removed the stale marker before relaunch handling" + fm_config_reread_discard_pending "$w/sm" || fail "could not clean respawn test generation" + assert_no_reread_pending "$w/sm" + assert_no_reread_instructions "$w/sm" + pass "B19 bootstrap respawns before inherited-config reread" +} + +test_spawn_quarantines_pending_rereads_on_cleanup_failure() { + local w sm report stale fakebin real_rm out status launchlog quarantine_root quarantined_count + local quarantine_dirs before_quarantine_dirs after_quarantine_dirs n dir + w=$(new_world config-reread-spawn-quarantine) + sm="$w/sm" + mkdir -p "$w/home/config" + printf 'codex\n' > "$w/home/config/crew-harness" + make_seeded_home "$sm" sm + mkdir -p "$sm/state" + report="$sm/state/stale-reread.report" + printf '%s\n' $'crew-harness\tpushed\t' > "$report" + stale="$sm/state/.fm-inherited-config-reread.spawn-stale" + fm_config_write_reread_instruction "$sm" "$report" "$stale" \ + || fail "could not create pending spawn reread generation" + fm_config_reread_mark_pending "$stale" "$stale.pending" \ + || fail "could not mark pending spawn reread generation" + quarantine_root="$sm/state/.fm-inherited-config-reread-quarantine" + mkdir -p "$quarantine_root" + for n in $(seq -w 1 16); do + dir="$quarantine_root/generation.old$n" + mkdir -p "$dir" + printf 'old-quarantine-%s\n' "$n" > "$dir/snapshot" + printf 'hidden-quarantine-%s\n' "$n" > "$dir/.hidden-snapshot" + done + fakebin=$(make_launch_capturing_tmux "$w/tmux-spawn-quarantine") + real_rm=$(command -v rm) + cat > "$fakebin/rm" <&1); status=$? + expect_code 0 "$status" "spawn should remain available after reread cleanup failure" + assert_contains "$out" "CONFIG_REREAD: secondmate sm: quarantined pre-relaunch generations" \ + "spawn cleanup failure did not emit a CONFIG_REREAD quarantine diagnostic" + assert_no_reread_pending "$sm" + assert_no_reread_instructions "$sm" + assert_present "$quarantine_root" "spawn cleanup failure did not create a quarantine directory" + quarantined_count=$(find "$quarantine_root" -type f | wc -l | tr -d ' ') + [ "$quarantined_count" -ge 2 ] \ + || fail "spawn cleanup failure did not quarantine both generation artifacts" + quarantine_dirs=0 + for dir in "$quarantine_root"/generation.*; do + [ -d "$dir" ] && [ ! -L "$dir" ] || continue + quarantine_dirs=$((quarantine_dirs + 1)) + done + [ "$quarantine_dirs" -le 16 ] \ + || fail "spawn cleanup failure exceeded bounded quarantine history ($quarantine_dirs)" + before_quarantine_dirs=$quarantine_dirs + fm_config_reread_quarantine_pending "$sm" sm "$w/home" || true + after_quarantine_dirs=0 + for dir in "$quarantine_root"/generation.*; do + [ -d "$dir" ] && [ ! -L "$dir" ] || continue + after_quarantine_dirs=$((after_quarantine_dirs + 1)) + done + [ "$after_quarantine_dirs" = "$before_quarantine_dirs" ] \ + || fail "empty quarantine cleanup created an extra generation directory" + assert_not_contains "$(cat "$launchlog")" "CONFIG_REREAD:" \ + "spawn cleanup failure left a stale reread pointer eligible for delivery" + pass "B25 spawn quarantines stale rereads without blocking relaunch" +} + test_harness_resolution test_secondmate_model_effort_tokens test_propagate_lib @@ -1037,8 +2065,24 @@ test_bootstrap_sweep_propagates_when_tracked_current test_bootstrap_sweep_defers_dispatch_on_stale_unignored_home test_bootstrap_sweep_no_inheritance_is_noop test_bootstrap_sweep_surfaces_config_propagation_failure +test_bootstrap_rereads_after_partial_propagation test_config_push_propagates_reports_without_ff_or_nudge test_config_push_reports_skips_dirty_and_invalid_home test_config_push_exits_nonzero_on_copy_error +test_config_push_rereads_after_partial_propagation +test_config_reread_per_home_changed_sets_and_exact_bytes +test_config_reread_isolation_and_absent_and_send_failure +test_config_reread_publication_failure_retries_exact_generation +test_config_reread_write_failure_retains_exact_retry_generation +test_config_reread_exact_temp_survives_adoption_failure +test_config_reread_serializes_concurrent_pushes +test_config_reread_full_retry_queue_drains_before_new_push +test_config_reread_cleanup_runs_after_mixed_delivery_failure +test_config_reread_stops_after_failed_generation +test_config_reread_skips_when_unchanged_and_reads_after_push +test_config_reread_bootstrap_path_and_spawn_flexibility +test_bootstrap_respawns_before_config_reread +test_spawn_quarantines_pending_rereads_on_cleanup_failure +test_bootstrap_detect_only_does_not_create_state echo "# all fm-secondmate-harness tests passed"