diff --git a/.agents/skills/captain-hold-lifecycle/SKILL.md b/.agents/skills/captain-hold-lifecycle/SKILL.md index eaea0c27411..438f6353b2a 100644 --- a/.agents/skills/captain-hold-lifecycle/SKILL.md +++ b/.agents/skills/captain-hold-lifecycle/SKILL.md @@ -19,6 +19,7 @@ The agent performs the semantic inventory because scripts must not infer captain Every unresolved question that belongs to the captain and is discovered while producing, reading, presenting, or ending an investigation or visual review must be carried by a captain-held task in the authoritative backlog of the home that owns the originating work before that work or review may be treated as complete. For a Lavish board-backed handoff, pass the reply through `bin/fm-procevent-lavish.sh arm --agent-reply-file` before appending the status; the adapter owns version-specific acceptance ordering. Prefer holding the work item the question gates over minting a new row; create a new task only when no work item exists to hold. +The originating investigation or review is never its own inventory entry, so hold a separate task for the call and pass `--origin ` so `complete` can check it. Put the question and its options in the hold reason, and keep one held task per genuine gate: a multi-question review is one held task pointing at its report, not a row per question. Represent that task with exactly one board card that consolidates its questions and options; never fan one task id into duplicate same-key cards. Register or re-hold through `bin/fm-captain-hold.sh hold`, which is idempotent per task id. After inventorying the whole report and review surface, run `bin/fm-captain-hold.sh complete` with every captain-held task id, or with `--none` only when the reviewed surface leaves nothing waiting on the captain. diff --git a/.agents/skills/harness-adapters/references/harness/pi.md b/.agents/skills/harness-adapters/references/harness/pi.md index b6afb51fad3..4efd674cf79 100644 --- a/.agents/skills/harness-adapters/references/harness/pi.md +++ b/.agents/skills/harness-adapters/references/harness/pi.md @@ -30,9 +30,10 @@ The router's Detection section owns how launch markers and ancestry select betwe Keep the instructions as one positional argument. Multiple positional arguments become separate queued messages; the spawn template already preserves the one-argument shape. -A project trust dialog can appear on the first Pi run in any not-yet-trusted directory, including a clean worktree. +A project trust dialog can appear on the first Pi run in any not-yet-trusted directory that holds a trust-requiring resource such as `.pi/extensions/`, including a clean worktree and a freshly seeded secondmate home. Accept it with Enter and verify the instructions begin processing. The decision persists per path in `~/.pi/agent/trust.json`, or in the pinned root's `trust.json` under a worker account pin, so later spawns in the same pooled slot under that root skip it. +For unattended seeded-secondmate launches, `../../../bin/fm-spawn.sh --help` owns the capability-gated project-trust approval mechanics; [runtime verification](../../../../../docs/verification/runtime-backends.md#pi-seeded-secondmate-project-trust) owns the regression evidence. ## Worker turn-end extension diff --git a/bin/backends/herdr.sh b/bin/backends/herdr.sh index 1308805971f..35b5dacae78 100644 --- a/bin/backends/herdr.sh +++ b/bin/backends/herdr.sh @@ -3363,6 +3363,11 @@ fm_backend_herdr_proof_lines() { # # viewport is the one bound that always contains the composer. # Styled capture is preferred. An empty or failed styled read falls through to # the plain capture so a missing ANSI format does not look like an empty draft. +# This read serves only the Claude payload proof, so the grok-tuned +# dark-truecolor ghost strip is off (FM_COMPOSER_GHOST_LUMA_MAX=0): Claude +# 2.1.283 draws a typed slash command in muted grey 38;2;112;112;112 (verified +# live), which that strip dropped, judging a typed /exit unsent. Claude's own +# ghost suggestion is SGR-2 dim and is still stripped. fm_backend_herdr_composer_content() { # local target=$1 cap caps if cap=$(fm_backend_herdr_visible_capture_ansi "$target" 2>/dev/null) && [ -n "$cap" ]; then @@ -3372,7 +3377,7 @@ fm_backend_herdr_composer_content() { # else return 1 fi - fm_composer_extract_selected_content "$caps" "$cap" + FM_COMPOSER_GHOST_LUMA_MAX=0 fm_composer_extract_selected_content "$caps" "$cap" } # fm_backend_herdr_composer_payload_shown: 0 when , read from a diff --git a/bin/fm-afk-return.sh b/bin/fm-afk-return.sh index 99e6bc86ac7..b162e6bba40 100755 --- a/bin/fm-afk-return.sh +++ b/bin/fm-afk-return.sh @@ -71,6 +71,9 @@ RETURN_GRACE=${FM_GUARD_GRACE:-300} # shellcheck source=bin/fm-afk-contract.sh . "$SCRIPT_DIR/fm-afk-contract.sh" CONTRACT="$SCRIPT_DIR/fm-afk-contract.sh" +# Functions only: decodes the stored hold reasons the catch-up listing shows. +# shellcheck source=bin/fm-hold-reason-lib.sh +. "$SCRIPT_DIR/fm-hold-reason-lib.sh" usage() { sed -n '2,11p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//' @@ -591,7 +594,7 @@ render_return_brief() { # -decision-` @@ -223,6 +231,9 @@ DATA="${FM_DATA_OVERRIDE:-$FM_HOME/data}" # shellcheck source=bin/fm-wake-lib.sh # shellcheck disable=SC1091 . "$SCRIPT_DIR/fm-wake-lib.sh" +# shellcheck source=bin/fm-hold-reason-lib.sh +# shellcheck disable=SC1091 +. "$SCRIPT_DIR/fm-hold-reason-lib.sh" # shellcheck source=bin/fm-parent-channel-lib.sh # shellcheck disable=SC1091 . "$SCRIPT_DIR/fm-parent-channel-lib.sh" @@ -792,27 +803,106 @@ write_hold_set_stamp() { # + printf '%s\n' "$1" | sed -n 's/^Captain hold origin: \(.*\)$/\1/p' | head -1 +} + +task_identity() { + local id=$1 + if task_show "$id"; then + id=$(show_field_value "$TASK_SHOW_OUTPUT" id) + validate_slug backend-task-id "$id" + elif ! printf '%s\n' "$TASK_SHOW_OUTPUT" | grep -q '^code: NOT_FOUND$'; then + fail "could not resolve the backend identity of $id" + fi + printf '%s' "$id" +} + +write_hold_origin() { # + local id=$1 body=$2 origin=$3 stamp rest new_body tmp + body=$(decode_shown_value "$body") \ + || fail "could not decode the existing body for $id" + stamp=$(printf '%s\n' "$body" | sed -n 1p) + [ -n "$(body_hold_set_timestamp "$body")" ] \ + || fail "task $id lost its hold-set stamp before its origin was recorded" + rest=$(printf '%s\n' "$body" | sed 1d | awk '!/^Captain hold origin: /' \ + | awk 'NF || started { started = 1; print }') + new_body=$stamp + if [ -n "$origin" ]; then + new_body=$(printf '%s\nCaptain hold origin: %s' "$stamp" "$origin") + fi + if [ -n "$rest" ]; then + new_body=$(printf '%s\n\n%s' "$new_body" "$rest") + fi + tmp=$(umask 077; mktemp "${TMPDIR:-/tmp}/fm-captain-hold-origin.XXXXXX") \ + || fail "cannot stage the hold origin" + if ! printf '%s\n' "$new_body" > "$tmp"; then + rm -f -- "$tmp" + fail "cannot stage the hold origin for $id" + fi + if ! tasks_axi update "$id" --body-file "$tmp" >/dev/null; then + rm -f -- "$tmp" + fail "could not record the hold origin on $id" + fi + rm -f -- "$tmp" +} + +refuse_self_inventory() { + local origin=$1 entry=$2 meta="$STATE/$1.meta" + if list_has_key "$(meta_value "$meta" decision_keys)" "$entry"; then + fail "origin $origin cannot be its own captain-call inventory entry; historical decision_keys in $meta still contains $entry; hold a separate captain task with --origin $origin, replace only $entry in the final decision_keys= line with that task id while preserving all other entries, then re-run complete $origin " + fi + fail "origin $origin cannot be its own captain-call inventory entry; hold a separate captain task for the call and list that task" +} + # Resolve one entry and verify the row it names is durably captain-held. A # resolution failure that is not the read bound keeps resolve_entry's own # status - its stderr already named the entry; 124 means the backend never # answered, which is not the same as an unknown entry and must not be spent -# as absence. On success prints " " so the caller can keep the -# attestation evidence. -verify_entry_durable() { # ; prints " " - local origin=$1 entry=$2 resolved resolve_status=0 +# as absence. The result carries the attestation evidence and whether an +# origin was recorded, so completion can disclose the legacy fallback. +verify_entry_durable() { # ; prints " " + local origin=$1 entry=$2 resolved resolve_status=0 id how stored origin_state=unrecorded origin_id stored_id + # The origin task is never its own captain-call inventory: it is the work the + # calls were found in, so accepting it would let a refused hold look recorded. + if [ -n "$origin" ] && [ "$origin" != "$BINDING_ANY" ] && [ "$entry" = "$origin" ]; then + refuse_self_inventory "$origin" "$entry" + fi resolved=$(resolve_entry "$origin" "$entry") || resolve_status=$? if [ "$resolve_status" -ne 0 ]; then [ "$resolve_status" -ne 124 ] \ || fail "the backlog backend exceeded its read bound resolving $entry" exit "$resolve_status" fi - printf '%s\n' "$resolved" - verify_hold_durable "${resolved%% *}" + id=${resolved%% *} + how=${resolved##* } + verify_hold_durable "$id" + id=$(show_field_value "$TASK_SHOW_OUTPUT" id) + validate_slug backend-task-id "$id" + stored=$(body_hold_origin "$(decode_shown_value "$(show_field "$TASK_SHOW_OUTPUT" body)")") + origin_id=$origin + if [ -n "$origin" ] && [ "$origin" != "$BINDING_ANY" ]; then + origin_id=$(task_identity "$origin") || exit $? + [ "$id" != "$origin_id" ] || refuse_self_inventory "$origin" "$entry" + fi + if [ -n "$stored" ]; then + if [ -n "$origin" ] && [ "$origin" != "$BINDING_ANY" ]; then + stored_id=$(task_identity "$stored") || exit $? + if [ "$stored_id" != "$origin_id" ]; then + fail "captain-held task $id was held for origin $stored, not $origin; hold a task for $origin or list the right one" + fi + fi + origin_state=recorded + fi + printf '%s %s %s\n' "$id" "$how" "$origin_state" } command_hold() { local id=${1:-} title='' reason='' repo='' origin='' until='' show state existing_title body='' hold_kind hold_set occurrence - local existing_hold_kind='' existing_held='' preserve_hold_set=0 + local existing_hold_kind='' existing_held='' preserve_hold_set=0 stored_reason previous_origin='' hold_status=0 [ "$#" -ge 1 ] || { usage >&2; exit 2; } shift while [ "$#" -gt 0 ]; do @@ -827,8 +917,9 @@ command_hold() { shift done validate_slug task-id "$id" - validate_one_line reason "$reason" - case "$reason" in *'('*|*')'*) fail "reason must not contain parentheses (tasks-axi hold contract)" ;; esac + [ -n "$reason" ] || fail "reason must not be empty" + # bin/fm-hold-reason-lib.sh owns the storage constraint and reversible encoding. + stored_reason=$(fm_hold_reason_encode "$reason") || fail "could not encode the hold reason" if [ -n "$origin" ]; then validate_slug origin-id "$origin" fi @@ -889,12 +980,25 @@ command_hold() { task_show_or_fail "$id" "task $id disappeared while recording its hold-set stamp" [ -n "$(body_hold_set_timestamp "$(show_field_value "$show" body)")" ] \ || fail "task $id did not retain its hold-set stamp" + if [ -n "$origin" ]; then + origin=$(task_identity "$origin") || exit $? + previous_origin=$(body_hold_origin "$(show_field_value "$show" body)") + write_hold_origin "$id" "$(show_field "$show" body)" "$origin" || exit $? + fi if [ -n "$until" ]; then - tasks_axi hold "$id" --reason "$reason" --kind captain --until "$until" >/dev/null \ - || fail "could not hold task $id for the captain" + tasks_axi hold "$id" --reason "$stored_reason" --kind captain --until "$until" >/dev/null \ + || hold_status=$? else - tasks_axi hold "$id" --reason "$reason" --kind captain >/dev/null \ - || fail "could not hold task $id for the captain" + tasks_axi hold "$id" --reason "$stored_reason" --kind captain >/dev/null \ + || hold_status=$? + fi + if [ "$hold_status" -ne 0 ]; then + # A refused re-hold must not associate the previous hold or answer with a + # new origin. Restore the old line verbatim, without resolving it again. + if [ -n "$origin" ]; then + write_hold_origin "$id" "$(show_field "$show" body)" "$previous_origin" || exit $? + fi + fail "could not hold task $id for the captain" fi task_show "$id" || fail "task $id disappeared while holding it" show=$TASK_SHOW_OUTPUT @@ -1627,7 +1731,7 @@ reconcile_note() { command_complete() { local origin=${1:-} meta previous='' supplied='' keys='' entry key status_file open has_meta=0 transfer_rc transfers=() resolved - local resolved_how attested_by_prefix='' + local resolved_how attested_by_prefix='' origin_state unrecorded_origin='' [ "$#" -ge 2 ] || { usage >&2; exit 2; } validate_slug origin-id "$origin" shift @@ -1659,8 +1763,13 @@ command_complete() { while IFS= read -r entry; do [ -n "$entry" ] || continue resolved=$(verify_entry_durable "$origin" "$entry") || exit $? + origin_state=${resolved##* } + resolved=${resolved% *} resolved_how=${resolved##* } resolved=${resolved%% *} + if [ "$origin_state" = unrecorded ]; then + unrecorded_origin="${unrecorded_origin}${unrecorded_origin:+ }$resolved" + fi if [ "$resolved_how" = migrated-prefix ]; then attested_by_prefix="${attested_by_prefix}${attested_by_prefix:+ }$entry=$resolved" fi @@ -1702,8 +1811,9 @@ EOF fi fi fi - printf 'complete: %s captain-call inventory reviewed%s%s\n' "$origin" "${keys:+ ($keys)}" \ - "${attested_by_prefix:+ [attested through the configured prefix: $attested_by_prefix]}" + printf 'complete: %s captain-call inventory reviewed%s%s%s\n' "$origin" "${keys:+ ($keys)}" \ + "${attested_by_prefix:+ [attested through the configured prefix: $attested_by_prefix]}" \ + "${unrecorded_origin:+ [no recorded origin on: $unrecorded_origin; not checked against $origin]}" } command_verify() { diff --git a/bin/fm-claude-stop-autoarm.sh b/bin/fm-claude-stop-autoarm.sh index 69785abfc30..f42e0846cb6 100755 --- a/bin/fm-claude-stop-autoarm.sh +++ b/bin/fm-claude-stop-autoarm.sh @@ -537,6 +537,22 @@ if [ "$ACTIONABLE" -eq 1 ]; then [ -z "$OUT" ] || rm -f "$OUT" 2>/dev/null || true exit 2 fi + if [ "$HOST_MODE" -eq 1 ] && fm_autoarm_still_owner "$STATE" "$MY_GEN" \ + && fm_recovery_marker_snapshot "$STATE/.watcher-down" \ + && [[ "$FM_RECOVERY_MARKER_TOKEN" == pending:handling:* || "$FM_RECOVERY_MARKER_TOKEN" == announced:handling:* ]] \ + && ! fm_watcher_healthy "$STATE" "$SCRIPT_DIR/fm-watch.sh" "$GRACE" "$FM_HOME"; then + LOST_HANDBACK_COMMITTED=0 + if [ ! -e "$FAILURE_NOTICE" ]; then + printf 'firstmate watcher auto-arm FAILED - the supervision host returned an actionable wake, but its rewake could not be committed.\n' >&2 + autoarm_commit failed "$FAILURE_NOTICE" && LOST_HANDBACK_COMMITTED=1 + else + autoarm_commit failed-suppressed && LOST_HANDBACK_COMMITTED=1 + fi + if [ "$LOST_HANDBACK_COMMITTED" -eq 1 ]; then + [ -z "$OUT" ] || rm -f "$OUT" 2>/dev/null || true + exit 2 + fi + fi [ -z "$OUT" ] || rm -f "$OUT" 2>/dev/null || true exit 0 fi diff --git a/bin/fm-composer-lib.sh b/bin/fm-composer-lib.sh index 49f1b7c9429..38285587980 100644 --- a/bin/fm-composer-lib.sh +++ b/bin/fm-composer-lib.sh @@ -762,6 +762,37 @@ _fm_composer_pi_separator_row() { # return 1 } +# _fm_composer_titled_rule_row: 0 when a trimmed row is a composer rule with a +# session title burned into it (Claude Code draws a named session's title into +# its composer's TOP rule: `──────── ─`, issues #5601 and #5558), proven +# by collapsing to exactly the column width of , the partner +# closing rule already mapped to spaces. +# +# This is deliberately NOT a relaxation of _fm_composer_pi_separator_row, and +# the two must not be merged: that predicate also feeds the pi identity +# conjunction, so it stays strictly dashes-only. This one has the single +# consumer _fm_composer_bare_rule_sandwich. +# +# The row must OPEN with the same 8-column dash run the strict separator +# requires. Width is proven by comparing canonical space strings, never by +# `${#row}`, which counts characters under UTF-8 and bytes under LC_ALL=C +# (issue #1988). Title text is ASCII-printable only, the same boundary +# _fm_composer_titled_bottom_ok holds; any other glyph leaves residue, and the +# verdict stays `unknown`, the safe direction. +_fm_composer_titled_rule_row() { # + local row=$1 expected=$2 spaces + case "$row" in + ────────*) ;; + *) return 1 ;; + esac + spaces=${row//─/ } + spaces=$(printf '%s' "$spaces" | LC_ALL=C sed 's/[!-~]/ /g') + case "$spaces" in + *[![:space:]]*) return 1 ;; + esac + [ "$spaces" = "$expected" ] +} + # Row-scan results are returned through FM_COMPOSER_SCAN_* globals (bash 3.2 # has no nameref); they are internal to this owner. _fm_composer_scan_screen() { # [extract-wrap] @@ -1428,6 +1459,28 @@ _fm_composer_locate_footer_zone() { # && [ "$FM_COMPOSER_SCAN_BARE_ROW" -le "$FM_COMPOSER_FOOTER_LAST" ] } +# _fm_composer_bare_rule_sandwich: 0 when bare agent-glyph sits in its +# own titled composer: a titled rule directly above it and the screen's only +# unmatched separator directly below it, which is that composer's closing rule. +# +# The cursorless staleness rule reads an unmatched separator BELOW a candidate +# as proof the candidate is scrollback. A titled top rule never opens the +# separator pair, so the composer's own closing rule becomes that unmatched +# separator and a genuinely idle composer read `unknown`. Adjacency on BOTH +# edges keeps the staleness rule intact everywhere else: a glyph stranded in +# scrollback has transcript rows, not its own rules, around it. +_fm_composer_bare_rule_sandwich() { # + local plain=$1 row=$2 above below + [ "$row" -ge 1 ] || return 1 + [ "$FM_COMPOSER_SCAN_PI_LAST_SEPARATOR" -eq "$((row + 1))" ] || return 1 + below=$(_fm_composer_screen_row "$((row + 1))" "$plain") + fm_composer_normalize_trim_var below + _fm_composer_pi_separator_row "$below" || return 1 + above=$(_fm_composer_screen_row "$((row - 1))" "$plain") + fm_composer_normalize_trim_var above + _fm_composer_titled_rule_row "$above" "${below//─/ }" +} + _fm_composer_select_cursorless() { local plain=$1 generic=-1 next boundary raw trimmed glyph bare footer=0 FM_COMPOSER_SELECTED_KIND= @@ -1483,8 +1536,14 @@ _fm_composer_select_cursorless() { fi if [ "$FM_COMPOSER_SCAN_PI_PAIR_FOUND" = 0 ] \ && [ "$FM_COMPOSER_SCAN_PI_LAST_SEPARATOR" -gt "$generic" ]; then - FM_COMPOSER_SELECTED_KIND= - return 1 + # Spare only a bare glyph inside its own titled composer rules; see + # _fm_composer_bare_rule_sandwich for why that shape is not scrollback. + if ! { [ "$FM_COMPOSER_SELECTED_KIND" = bare ] \ + && [ "$generic" = "$FM_COMPOSER_SCAN_BARE_ROW" ] \ + && _fm_composer_bare_rule_sandwich "$plain" "$FM_COMPOSER_SCAN_BARE_ROW"; }; then + FM_COMPOSER_SELECTED_KIND= + return 1 + fi fi if [ "$FM_COMPOSER_SCAN_SHELL_ROW" -gt "$generic" ]; then FM_COMPOSER_SELECTED_KIND= diff --git a/bin/fm-contributions.sh b/bin/fm-contributions.sh index 4e27e33e8a1..12bfc5fffcf 100755 --- a/bin/fm-contributions.sh +++ b/bin/fm-contributions.sh @@ -5,7 +5,7 @@ # fm-contributions.sh snapshot [--all] # fm-contributions.sh poll # fm-contributions.sh pending -# fm-contributions.sh verdict +# fm-contributions.sh verdict # fm-contributions.sh ack # fm-contributions.sh arm [--if-owned] # @@ -23,9 +23,10 @@ # checks/reviews). Checks are normalized by name, id, started_at, status and # conclusion; projection picks the newest attempt per distinct name. The last # observation's lane names also disclose a lane absent from the next head. -# A verdict records the EXACT judged head, source URL, actor and summary. A -# comment's arrival time never supplies its judged head. Record a prose verdict -# only after its source identifies that head; otherwise leave it unbound and +# A verdict records the EXACT judged head, source URL, actor and summary. The +# actor is exactly one of captain, fleet, maintainer or nobody; any other value +# is refused. A comment's arrival time never supplies its judged head. Record a +# prose verdict only after its source identifies that head; otherwise leave it unbound and # triage its signal. Formal reviews carry GitHub's own commit_id. Neither kind # can grant merge authority. Captain-actor prose requires an existing live hold; # an eligible merge remains a captain call, never an automatic forge action. @@ -473,7 +474,7 @@ case "${1:-}" in else [ "$#" -eq 4 ] || fail 'verdict needs judged-head, source-url, actor and summary' fm_pr_head_valid "$1" || fail 'an exact judged commit is required' - case "$3" in captain|fleet|maintainer|nobody) ;; *) fail 'invalid required actor' ;; esac + case "$3" in captain|fleet|maintainer|nobody) ;; *) fail "invalid required actor '$3'; expected one of: captain, fleet, maintainer, nobody" ;; esac case "$2" in "$url"\#*) ;; *) fail 'verdict source must be a comment or review on this contribution' ;; esac jq --arg head "$1" --arg source "$2" --arg actor "$3" --arg summary "$4" \ '.verdict={head:$head,source:$source,actor:$actor,summary:$summary}' "$TMP/row.json" > "$TMP/update.json" diff --git a/bin/fm-fleet-snapshot.sh b/bin/fm-fleet-snapshot.sh index 666d03b8d6c..7f830bff272 100755 --- a/bin/fm-fleet-snapshot.sh +++ b/bin/fm-fleet-snapshot.sh @@ -229,6 +229,8 @@ esac . "$SCRIPT_DIR/fm-landed-lib.sh" # FM_LANDED_JQ_DEFS: the shared landed selector # shellcheck source=bin/fm-merge-authority-lib.sh . "$SCRIPT_DIR/fm-merge-authority-lib.sh" +# shellcheck source=bin/fm-hold-reason-lib.sh +. "$SCRIPT_DIR/fm-hold-reason-lib.sh" usage() { cat <<'EOF' @@ -381,13 +383,14 @@ first_pr_url_in_file() { # grep -Eo 'https?://[^[:space:])"]+/pull/[0-9]+' "$1" 2>/dev/null | head -1 } -backlog_json() { # [] - defaults to this home's $BACKLOG +backlog_json() ( # [] - defaults to this home's $BACKLOG local backlog=${1:-$BACKLOG} if [ ! -f "$backlog" ]; then jq -n --arg path "$backlog" '{path:$path,present:false,records:[]}' return 0 fi + set -o pipefail # shellcheck disable=SC2094 jq -Rn --arg path "$backlog" --arg today "$SNAPSHOT_TODAY" --arg now "$SNAPSHOT_NOW" \ --argjson age_days "$FM_SNAPSHOT_UNDATED_HOLD_AGE_DAYS" ' @@ -570,8 +573,8 @@ backlog_json() { # [] - defaults to this home's $BACKLOG | .captain_actionable = (.hold_bucket == "live") else . end) | del(.section,.order) - ' < "$backlog" -} + ' < "$backlog" | fm_hold_reason_decode_stream json +) SNAPSHOT_TASK_DIR= SNAPSHOT_TASK_METAS=() diff --git a/bin/fm-fleet-sync.sh b/bin/fm-fleet-sync.sh index f8cc3054591..91b76f78555 100755 --- a/bin/fm-fleet-sync.sh +++ b/bin/fm-fleet-sync.sh @@ -319,10 +319,12 @@ sync_project() { echo "$label: skipped: not a git repo" return 0 fi - # Both sides are physical paths (git resolves --show-toplevel through symlinks), - # so a symlinked clone dir still compares equal to its own root. + # Compare filesystem identity, not spelling: the question is whether git's root + # and $PROJ are the same directory, and a string compare of the two paths also + # fails when they merely differ in case (case-insensitive volume) or in how a + # symlink is spelled. proj_abs=$(cd "$PROJ" && pwd -P) || proj_abs="" - if [ "$proj_top" != "$proj_abs" ]; then + if [ -z "$proj_abs" ] || ! [ "$proj_top" -ef "$proj_abs" ]; then echo "$label: skipped: not a clone root (git would act on $proj_top)" return 0 fi diff --git a/bin/fm-hold-reason-lib.sh b/bin/fm-hold-reason-lib.sh new file mode 100644 index 00000000000..6b6b6a6d9ff --- /dev/null +++ b/bin/fm-hold-reason-lib.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash +# fm-hold-reason-lib.sh - the one reversible encoding of a captain-hold reason. +# +# tasks-axi stores a hold reason as one markdown line inside a parenthesised tag, +# so its own `hold` refuses parentheses and line breaks. A decision reason is +# ordinary prose, so bin/fm-captain-hold.sh encodes the reason where it writes +# it and every reader that shows it decodes it again, instead of banning the +# characters. Stored reasons use the reserved fm-hold-v1: prefix followed by +# base64-encoded UTF-8 text. Unmarked reasons are plain text. Readers decode only +# the hold-reason field, once, and keep line breaks in quoted output strings. +# +# Source this file; it defines functions only. + +# fm_hold_reason_encode : print the storable form, no trailing newline. +fm_hold_reason_encode() { + printf '%s' "$1" | perl -MMIME::Base64=encode_base64 -0777 -ne \ + 'print "fm-hold-v1:", encode_base64($_, "")' +} + +# fm_hold_reason_decode_stream [toon|markdown|json]: decode marked reason fields. +fm_hold_reason_decode_stream() { + perl -MJSON::PP -MMIME::Base64=encode_base64,decode_base64 -MEncode=decode,FB_CROAK -e ' + use strict; + use warnings; + binmode STDIN, ":encoding(UTF-8)"; + binmode STDOUT, ":encoding(UTF-8)"; + my $format = shift; + my $json = JSON::PP->new->allow_nonref; + sub decode_reason { + my ($value) = @_; + return $value unless defined($value) && $value =~ /^fm-hold-v1:(.*)\z/s; + my $payload = $1; + my $bytes = decode_base64($payload); + return $value unless encode_base64($bytes, "") eq $payload; + # Historical literals with valid base64 and UTF-8 remain indistinguishable + # from encoded reasons; malformed payloads retain their stored text. + my $decoded = eval { decode("UTF-8", $bytes, FB_CROAK) }; + return $@ ? $value : $decoded; + } + sub decode_field { + my ($raw) = @_; + my $value = $raw =~ /^"/ ? $json->decode($raw) : $raw; + my $decoded = decode_reason($value); + return $decoded eq $value ? $raw : $json->encode($decoded); + } + if ($format eq "json") { + local $/; + my $snapshot = $json->decode(); + for my $record (@{$snapshot->{records}}) { + $record->{hold_reason} = decode_reason($record->{hold_reason}) + if exists $record->{hold_reason}; + } + print $json->encode($snapshot), "\n"; + exit; + } + my ($column, $task); + while (my $line = ) { + if ($format eq "markdown") { + $line =~ s{^([-*] .*\(hold:\s*)(fm-hold-v1:[A-Za-z0-9+/]*={0,2})(\).*)$} + {$1 . decode_field($2) . $3}e; + } elsif ($line =~ /^tasks\[\d+\]\{([^}]*)\}:\n?$/) { + my @names = split /,/, $1; + ($column) = grep { $names[$_] eq "hold_reason" } 0 .. $#names; + $task = 0; + } elsif (defined($column) && $line =~ /^ (.*)\n?$/) { + my @fields = $1 =~ /("(?:[^"\\]|\\.)*"|[^,]+)/g; + $fields[$column] = decode_field($fields[$column]); + $line = " " . join(",", @fields) . "\n"; + } elsif ($task && $line =~ /^ hold_reason: (.*)\n?$/) { + $line = " hold_reason: " . decode_field($1) . "\n"; + } elsif ($line !~ /^ /) { + $column = undef; + $task = $line eq "task:\n"; + } + print $line; + } + ' "${1:-toon}" +} diff --git a/bin/fm-lint.sh b/bin/fm-lint.sh index 59a20e4f480..b5517045ea4 100755 --- a/bin/fm-lint.sh +++ b/bin/fm-lint.sh @@ -7,13 +7,13 @@ # both use this owner without duplicating lint configuration. # The explicit --fast mode is local-only and disables ShellCheck's extended # dataflow analysis while preserving ordinary shell lint checks and source -# following. CI, main, and merge-base-less runs keep --norc --external-sources -# with full dataflow over the whole canonical set. An ordinary local branch -# (changed-file mode, including the no-mistakes lint step) drops +# following. CI, main, and merge-base-less runs attempt --norc +# --external-sources with full dataflow for each canonical root. An ordinary +# local branch (changed-file mode, including the no-mistakes lint step) drops # --external-sources, keeps dataflow, and excludes SC1091, SC2034, SC2153, -# and SC2329, the codes that need library context. Those codes still run in -# CI over the whole set. Explicit paths keep --external-sources with the -# selected dataflow mode. +# and SC2329, the codes that need library context. CI checks those codes +# on source-following attempts (see the memory fallback below). Explicit +# paths attempt --external-sources with the selected dataflow mode. # Tests stop source analysis at imported production modules because CI analyzes # every production shell separately as a canonical, source-aware root. # The default (no explicit-path) path also runs bin/fm-lint-workflows.sh so a @@ -25,8 +25,8 @@ # - In CI (GITHUB_ACTIONS=true or CI=true), on the main branch, or when no # merge-base against origin/main (or local main) can be found, it lints # the full canonical set: bin/*.sh bin/backends/*.sh tests/*.sh, with -# --external-sources and full dataflow. This is what CI always runs, so -# CI coverage never depends on a local diff. +# --external-sources and full dataflow first. CI coverage never depends +# on a local diff; memory failures may take the narrower retry below. # - Otherwise (an ordinary local branch with a real merge-base) it lints # only the canonical-set files changed since that merge-base, including # uncommitted local edits, via plain local `git diff` (no network, no @@ -50,9 +50,10 @@ # two CI runners, each with those same concurrency-limited workers. # Partitions are complete, disjoint, and byte-weight balanced; --list-files # exposes their actual roots. -# Partition mode is always full source-aware analysis, never changed-only or -# --fast, and does not accept explicit paths. Each partition also runs workflow -# lint and backend-purity checks, keeping either invocation independently useful. +# Partition mode starts with full source-aware analysis, never changed-only +# or --fast, and does not accept explicit paths. Each partition also runs +# workflow lint and backend-purity checks, keeping either invocation +# independently useful. # # With FM_LINT_REQUIRE_BOUNDS=1, which CI sets, every per-root ShellCheck # process runs under an enforced envelope: a wall deadline @@ -71,29 +72,42 @@ # cannot apply the address-space limit at all) each root still runs in its # own ShellCheck process with identical diagnostics, just unbounded. # +# If a source-following root exits with a memory failure, it is retried once +# without --external-sources under the same memory limit and only the time +# left in that root's original deadline; with under a second left, the +# memory failure stands without a retry. A clean retry passes +# with an explicit memory-fallback reason and warning; only the same +# cross-file-dependent codes omitted in local no-source lint are excluded. +# Other findings and failed retries still fail lint. The retry's diagnostics +# replace the failed attempt's output; peak RSS is the maximum of both attempts. +# # Per-root evidence is incremental: workers append begin/end records (root, -# mode, shard, start, end, duration, exit status, reason, and peak RSS when -# measured) to a roots log as each root completes, so a mid-run kill still -# leaves the completed record and names the root in flight as -# begun-but-unfinished. With --telemetry the log is retained at +# mode, shard, start, end, duration, final exit status, reason, peak RSS when +# measured, and whether the final attempt followed sources) to a roots log +# as each root completes, so a mid-run kill still leaves the completed record +# and names the root in flight as begun-but-unfinished. With --telemetry the +# log is retained at # .roots.tsv (or .roots.tsv if there is no # .tsv suffix); otherwise it lives only in the -# run's scratch dir. Reason values are ok, findings, timeout, memory, -# signal:, limit-unavailable, or error:. Memory requires process-level -# evidence (a GHC exhaustion status or runtime error on stderr), not an echoed -# source excerpt or an OOM phrase in a filename. In partition mode begin/end +# run's scratch dir. Reason values are ok, findings, memory-fallback, +# timeout, memory, signal:, limit-unavailable, or error:. +# Memory requires process-level evidence (a GHC exhaustion status or runtime +# error on stderr), not an echoed source excerpt or an OOM phrase in a +# filename. In partition mode begin/end # lines also stream to stderr, and an abnormal root end is always reported # there. # # Optional quiet telemetry writes one bounded TSV snapshot of content and source # graph identity, wall/CPU/RSS, shard load, and competing ShellCheck processes. +# source_followed_directives counts directives only for roots whose final +# attempt followed sources, not roots that passed or failed a no-source retry. # # Usage: # fm-lint.sh lint the context-selected file set (see above) # fm-lint.sh --fast [path]... local lint with extended analysis disabled # fm-lint.sh ... lint explicit roots with the same config # fm-lint.sh --jobs <1|2> [path]... override concurrent worker count -# fm-lint.sh --partition <1of2|2of2> lint one full-rigor canonical CI partition +# fm-lint.sh --partition <1of2|2of2> lint one canonical CI partition (see fallback above) # fm-lint.sh --telemetry ... write a quiet metrics snapshot # fm-lint.sh --required-version print the ShellCheck pin # fm-lint.sh --list-files print the file set that would be linted @@ -101,8 +115,8 @@ set -u REQUIRED_SHELLCHECK=0.11.0 -# Cross-file codes that need --external-sources. Local changed-file mode -# cannot judge them, so they stay CI-only. +# Cross-file codes that need --external-sources. No-source checks (local +# changed-file mode and memory fallback) cannot judge them. LOCAL_NOX_EXCLUDE=SC1091,SC2034,SC2153,SC2329 SELF_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)" SELF="$SELF_DIR/fm-lint.sh" @@ -160,6 +174,17 @@ fm_lint_root_rss() { # printf '%s\n' "${kib:-unavailable}" } +fm_lint_max_root_rss() { # + local first=$1 second=$2 + case "$first" in ''|unavailable|*[!0-9]*) printf '%s\n' "$second"; return ;; esac + case "$second" in ''|unavailable|*[!0-9]*) printf '%s\n' "$first"; return ;; esac + if [ "$first" -gt "$second" ]; then + printf '%s\n' "$first" + else + printf '%s\n' "$second" + fi +} + # Map a root's exit status onto the reported reason vocabulary without # pretending every signal or nonzero exit is a memory kill: only process-level # memory-failure evidence earns the memory reason - GHC's heap-exhaustion @@ -209,24 +234,11 @@ fm_lint_classify_root() { # esac } -# Run one selected root in its own ShellCheck process, record its lifecycle -# in the roots log, and append its diagnostics to the shard output. -fm_lint_run_root() { # - local index=$1 path=$2 output_dir=$3 shard_index=$4 - local root_out="$output_dir/root.$shard_index.$index.out" - local root_err="$output_dir/root.$shard_index.$index.err" - local rss_file="$output_dir/root.$shard_index.$index.rss" - local start_ms end_ms duration_ms invocation_rc=0 reason rss_kib - start_ms=$(fm_lint_now_ms) - if [ -n "${FM_LINT_INTERNAL_ROOTS_LOG:-}" ]; then - printf 'begin\t%s\t%s\t%s\t%s\t%s\n' \ - "$index" "$path" "$shard_index" "${FM_LINT_INTERNAL_MODE:-}" "$start_ms" \ - >> "$FM_LINT_INTERNAL_ROOTS_LOG" - fi - if [ "${FM_LINT_INTERNAL_PROGRESS:-0}" = 1 ]; then - printf 'fm-lint: begin %s (shard %s, %s mode)\n' \ - "$path" "$shard_index" "${FM_LINT_INTERNAL_MODE:-unknown}" >&2 - fi +# Run one ShellCheck invocation under the given deadline and the per-root +# address-space limit, returning its exit status in FM_LINT_LAST_RC. +fm_lint_exec_root() { # + local path=$1 root_out=$2 root_err=$3 rss_file=$4 seconds=$5 invocation_rc=0 + shift 5 if [ "${FM_LINT_INTERNAL_BOUNDED:-none}" != none ]; then # The watchdog runs in a process group of its own (the same setpgrp hop the # workers use), so the owner's TERM-then-KILL group sweep cannot kill it @@ -237,33 +249,105 @@ fm_lint_run_root() { # # the watchdog is still starting is detected too. ( FM_EXEC_TIMED_OWNER_PID=$$ exec "${FM_LINT_PERL_BIN:-perl}" -e 'setpgrp(0, 0) or die "setpgrp: $!"; exec @ARGV or die "exec: $!"' \ "${BASH:-bash}" "$SELF" --internal-timed \ - "$FM_LINT_INTERNAL_ROOT_SECS" "$FM_LINT_INTERNAL_GRACE" \ + "$seconds" "$FM_LINT_INTERNAL_GRACE" \ "${BASH:-bash}" "$SELF" --internal-root "$rss_file" "$FM_LINT_INTERNAL_MEMORY_KIB" \ - "$FM_LINT_SHELLCHECK" "${FM_LINT_WORKER_ARGS[@]}" -- "$path" ) > "$root_out" 2> "$root_err" & + "$FM_LINT_SHELLCHECK" "$@" -- "$path" ) > "$root_out" 2> "$root_err" & FM_LINT_WORKER_RUN_PID=$! wait "$FM_LINT_WORKER_RUN_PID" || invocation_rc=$? FM_LINT_WORKER_RUN_PID= else - "$FM_LINT_SHELLCHECK" "${FM_LINT_WORKER_ARGS[@]}" -- "$path" > "$root_out" 2> "$root_err" & + "$FM_LINT_SHELLCHECK" "$@" -- "$path" > "$root_out" 2> "$root_err" & FM_LINT_WORKER_RUN_PID=$! wait "$FM_LINT_WORKER_RUN_PID" || invocation_rc=$? FM_LINT_WORKER_RUN_PID= fi + FM_LINT_LAST_RC=$invocation_rc +} + +# Run one selected root, retry memory failures without source following, record +# its lifecycle in the roots log, and append the final diagnostics. +fm_lint_run_root() { # + local index=$1 path=$2 output_dir=$3 shard_index=$4 + local root_out="$output_dir/root.$shard_index.$index.out" + local root_err="$output_dir/root.$shard_index.$index.err" + local rss_file="$output_dir/root.$shard_index.$index.rss" + local fallback_out="$output_dir/root.$shard_index.$index.fallback.out" + local fallback_err="$output_dir/root.$shard_index.$index.fallback.err" + local fallback_rss="$output_dir/root.$shard_index.$index.fallback.rss" + local start_ms end_ms duration_ms invocation_rc=0 reason rss_kib initial_rc initial_reason + local fallback_secs + local final_follow_sources=${FM_LINT_INTERNAL_FOLLOW_SOURCES:-1} + local -a fallback_args + start_ms=$(fm_lint_now_ms) + if [ -n "${FM_LINT_INTERNAL_ROOTS_LOG:-}" ]; then + printf 'begin\t%s\t%s\t%s\t%s\t%s\n' \ + "$index" "$path" "$shard_index" "${FM_LINT_INTERNAL_MODE:-}" "$start_ms" \ + >> "$FM_LINT_INTERNAL_ROOTS_LOG" + fi + if [ "${FM_LINT_INTERNAL_PROGRESS:-0}" = 1 ]; then + printf 'fm-lint: begin %s (shard %s, %s mode)\n' \ + "$path" "$shard_index" "${FM_LINT_INTERNAL_MODE:-unknown}" >&2 + fi + fm_lint_exec_root "$path" "$root_out" "$root_err" "$rss_file" \ + "$FM_LINT_INTERNAL_ROOT_SECS" "${FM_LINT_WORKER_ARGS[@]}" + invocation_rc=$FM_LINT_LAST_RC + reason=$(fm_lint_classify_root "$invocation_rc" "$root_err") + initial_rc=$invocation_rc + initial_reason=$reason + # The retry spends what is left of this root's one deadline rather than a + # fresh one, so both attempts together still fit the budget CI sized its job + # timeout around. + fallback_secs=$(( (start_ms + FM_LINT_INTERNAL_ROOT_SECS * 1000 - $(fm_lint_now_ms)) / 1000 )) + if [ "$reason" = memory ] \ + && [ "${FM_LINT_INTERNAL_FOLLOW_SOURCES:-1}" -eq 1 ] \ + && [ "${FM_LINT_INTERNAL_BOUNDED:-none}" != none ] \ + && [ "$fallback_secs" -lt 1 ]; then + printf 'fm-lint: %s hit the memory ceiling with --external-sources (reason=%s rc=%s); no time left in its %ss deadline to retry without it\n' \ + "$path" "$initial_reason" "$initial_rc" "$FM_LINT_INTERNAL_ROOT_SECS" >> "$output_dir/shard.$shard_index.out" + rss_kib=$(fm_lint_root_rss "$rss_file") + cat "$root_out" "$root_err" >> "$output_dir/shard.$shard_index.out" + elif [ "$reason" = memory ] \ + && [ "${FM_LINT_INTERNAL_FOLLOW_SOURCES:-1}" -eq 1 ]; then + fallback_args=() + for arg in "${FM_LINT_WORKER_ARGS[@]}"; do + [ "$arg" = --external-sources ] || fallback_args+=("$arg") + done + [ -z "$LOCAL_NOX_EXCLUDE" ] || fallback_args+=("--exclude=$LOCAL_NOX_EXCLUDE") + fm_lint_exec_root "$path" "$fallback_out" "$fallback_err" "$fallback_rss" \ + "$fallback_secs" "${fallback_args[@]}" + final_follow_sources=0 + invocation_rc=$FM_LINT_LAST_RC + reason=$(fm_lint_classify_root "$invocation_rc" "$fallback_err") + rss_kib=$(fm_lint_max_root_rss \ + "$(fm_lint_root_rss "$rss_file")" "$(fm_lint_root_rss "$fallback_rss")") + printf 'fm-lint: %s hit the memory ceiling with --external-sources (reason=%s rc=%s); retried without it' \ + "$path" "$initial_reason" "$initial_rc" >> "$output_dir/shard.$shard_index.out" + if [ "$reason" = ok ]; then + reason=memory-fallback + invocation_rc=0 + printf '; fallback passed with cross-file codes excluded (%s)\n' "$LOCAL_NOX_EXCLUDE" \ + >> "$output_dir/shard.$shard_index.out" + else + printf '; fallback reason=%s rc=%s\n' "$reason" "$invocation_rc" \ + >> "$output_dir/shard.$shard_index.out" + fi + cat "$fallback_out" "$fallback_err" >> "$output_dir/shard.$shard_index.out" + else + rss_kib=$(fm_lint_root_rss "$rss_file") + cat "$root_out" "$root_err" >> "$output_dir/shard.$shard_index.out" + fi end_ms=$(fm_lint_now_ms) duration_ms=$((end_ms - start_ms)) - rss_kib=$(fm_lint_root_rss "$rss_file") - reason=$(fm_lint_classify_root "$invocation_rc" "$root_err") if [ -n "${FM_LINT_INTERNAL_ROOTS_LOG:-}" ]; then - printf 'end\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \ + printf 'end\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \ "$index" "$path" "$shard_index" "${FM_LINT_INTERNAL_MODE:-}" \ - "$start_ms" "$end_ms" "$duration_ms" "$invocation_rc" "$reason" "$rss_kib" \ + "$start_ms" "$end_ms" "$duration_ms" "$invocation_rc" "$reason" "$rss_kib" "$final_follow_sources" \ >> "$FM_LINT_INTERNAL_ROOTS_LOG" fi - if [ "${FM_LINT_INTERNAL_PROGRESS:-0}" = 1 ] || { [ "$reason" != ok ] && [ "$reason" != findings ]; }; then + if [ "${FM_LINT_INTERNAL_PROGRESS:-0}" = 1 ] || { [ "$reason" != ok ] && [ "$reason" != findings ] && [ "$reason" != memory-fallback ]; }; then printf 'fm-lint: end %s reason=%s rc=%s duration_ms=%s rss_kib=%s\n' \ "$path" "$reason" "$invocation_rc" "$duration_ms" "$rss_kib" >&2 fi - cat "$root_out" "$root_err" >> "$output_dir/shard.$shard_index.out" return "$invocation_rc" } @@ -1207,7 +1291,11 @@ if [ -n "$TELEMETRY" ]; then : > "$TMP_ROOT/source-targets" source_directives=0 source_boundaries=0 + source_followed=0 + awk -F '\t' '$1 == "end" && $12 == 0 { print $2 }' "$ROOTS_LOG" > "$TMP_ROOT/no-source-indices" + root_index=0 for path in "${ROOTS[@]}"; do + root_index=$((root_index + 1)) if [ -f "$path" ]; then bytes=$(wc -c < "$path" 2>/dev/null | tr -d '[:space:]') case "$bytes" in ''|*[!0-9]*) bytes=0 ;; esac @@ -1220,17 +1308,18 @@ if [ -n "$TELEMETRY" ]; then sub(/[[:space:]].*$/, "", target) print target } - ' "$path" >> "$TMP_ROOT/source-targets" + ' "$path" > "$TMP_ROOT/root-source-targets" + cat "$TMP_ROOT/root-source-targets" >> "$TMP_ROOT/source-targets" + if [ "$FOLLOW_SOURCES" -eq 1 ] \ + && ! grep -qx "$root_index" "$TMP_ROOT/no-source-indices"; then + followed_here=$(grep -cv '^/dev/null$' "$TMP_ROOT/root-source-targets" || true) + source_followed=$((source_followed + followed_here)) + fi fi done source_directives=$(wc -l < "$TMP_ROOT/source-targets" | tr -d '[:space:]') source_boundaries=$(grep -c '^/dev/null$' "$TMP_ROOT/source-targets" 2>/dev/null || true) case "$source_boundaries" in ''|*[!0-9]*) source_boundaries=0 ;; esac - if [ "$FOLLOW_SOURCES" -eq 1 ]; then - source_followed=$((source_directives - source_boundaries)) - else - source_followed=0 - fi source_targets=$(LC_ALL=C sort -u "$TMP_ROOT/source-targets" | wc -l | tr -d '[:space:]') content_cksum=$(cksum "$TMP_ROOT/content-cksums" | awk '{print $1 "-" $2}') git_head=$(git rev-parse HEAD 2>/dev/null || printf 'unavailable') diff --git a/bin/fm-remote-delta-read.sh b/bin/fm-remote-delta-read.sh index 8450628d568..84aef13d051 100755 --- a/bin/fm-remote-delta-read.sh +++ b/bin/fm-remote-delta-read.sh @@ -10,9 +10,14 @@ # the source. A shortened or changed prefix returns a structured continuity-break # result instead of silently rebasing the cursor. # -# An unchanged snapshot is retried after FM_REMOTE_DELTA_POLL_SECONDS (default -# 0.5 seconds). A complete line is visible on the next sample, and the window -# deadline can overshoot by that interval plus snapshot and scheduling work. +# The log is sampled every FM_REMOTE_DELTA_POLL_SECONDS (default 0.5 seconds). +# A complete line is visible on the next sample, and the window deadline can +# overshoot by that interval plus snapshot and scheduling work. +# Each sample of an existing log stats it once. The first sample always runs +# the bounded capture and hashing; later samples skip that work only when the +# size, subsecond mtime and ctime, inode, and device key is unchanged. If either +# timestamp lacks a nonzero subsecond fraction, every sample captures the log +# rather than trusting a coarse key that could hide a same-second rewrite. # The wait remains an ordinary child sleep; signal handling is unchanged. # # Exit 75 means the wait window closed with no complete line. SIGTERM exits the @@ -84,6 +89,30 @@ snapshot_log() { # ) } +delta_subsecond() { # : digits, one dot, and a nonzero fraction + case "$1" in *[!0-9.]* | *.*.*) return 1 ;; esac + case "$1" in [0-9]*.*[1-9]*) ;; *) return 1 ;; esac +} + +# The file identity a snapshot was taken against: GNU and BSD stat spell the +# fields differently, so the poll selects the syntax once by capability. The +# mtime and ctime keep their subsecond fraction; a key without one (a stat or +# filesystem with whole-second timestamps) is discarded, because it cannot tell +# a same-second same-size rewrite apart, and that poll takes a full snapshot. +delta_log_key() { # : sets KEY to "size:mtime:ctime:inode:device" or empty + local rest mtime ctime + if [ "$DELTA_KEY_GNU_STAT" = 1 ]; then + KEY=$(stat -c '%s:%.9Y:%.9Z:%i:%d' "$1" 2>/dev/null) || KEY= + else + KEY=$(stat -f '%z:%Fm:%Fc:%i:%d' "$1" 2>/dev/null) || KEY= + fi + rest=${KEY#*:} + mtime=${rest%%:*} + rest=${rest#*:} + ctime=${rest%%:*} + delta_subsecond "$mtime" && delta_subsecond "$ctime" || KEY= +} + resolve_log() { # local rel=$1 home_real parent_real parent base path case "$rel" in ''|/*|*'//'*) die "log must be a nonempty relative path" ;; esac @@ -134,61 +163,69 @@ trap 'rm -rf -- "$TMP"' EXIT trap 'exit 75' TERM : > "$TMP/empty" EMPTY_HASH=$(sha256_file "$TMP/empty") -START=$(date +%s) +if stat -c '%s' / >/dev/null 2>&1; then DELTA_KEY_GNU_STAT=1; else DELTA_KEY_GNU_STAT=0; fi +START=$SECONDS +LAST_KEY= while :; do if [ -e "$LOG" ] || [ -L "$LOG" ]; then [ -f "$LOG" ] && [ ! -L "$LOG" ] || die "log changed into an unsafe file: $REL" - snapshot_log "$LOG" "$TMP/source" "$TMP/size" \ - || die "log could not be captured safely: $REL" - SIZE=$(tr -d ' ' < "$TMP/size") - if [ "$SIZE" -lt "$OFFSET" ]; then - copy_prefix "$TMP/source" "$SIZE" "$TMP/prefix" - ACTUAL=$(sha256_file "$TMP/prefix") - emit_break truncated "$SIZE" "$ACTUAL" - exit 0 - fi - copy_prefix "$TMP/source" "$OFFSET" "$TMP/prefix" - ACTUAL=$(sha256_file "$TMP/prefix") - if [ "$ACTUAL" != "$PREFIX" ]; then - emit_break prefix-changed "$SIZE" "$ACTUAL" - exit 0 - fi - if [ "$SIZE" -gt "$OFFSET" ]; then - tail -c "+$((OFFSET + 1))" "$TMP/source" | head -c "$MAX_BYTES" > "$TMP/chunk" || true - COMPLETE_BYTES=$(LC_ALL=C od -An -v -tu1 "$TMP/chunk" | awk ' - { for (i = 1; i <= NF; i++) { bytes++; if ($i == 10) complete=bytes } } - END { print complete + 0 } - ') - if [ "$COMPLETE_BYTES" -eq 0 ]; then : > "$TMP/payload"; else head -c "$COMPLETE_BYTES" "$TMP/chunk" > "$TMP/payload"; fi - BYTES=$(LC_ALL=C wc -c < "$TMP/payload" | tr -d ' ') - if [ "$BYTES" -gt 0 ]; then - TO=$((OFFSET + BYTES)) - copy_prefix "$TMP/source" "$TO" "$TMP/to-prefix" - TO_HASH=$(sha256_file "$TMP/to-prefix") - PAYLOAD_HASH=$(sha256_file "$TMP/payload") - printf 'schema=fm-remote-delta.v1\n' - printf 'status=delta\n' - printf 'path=%s\n' "$REL" - printf 'from_offset=%s\n' "$OFFSET" - printf 'to_offset=%s\n' "$TO" - printf 'from_prefix_sha256=%s\n' "$PREFIX" - printf 'to_prefix_sha256=%s\n' "$TO_HASH" - printf 'payload_sha256=%s\n' "$PAYLOAD_HASH" - printf 'payload_bytes=%s\n' "$BYTES" - printf 'reason=\n\n' - cat "$TMP/payload" + delta_log_key "$LOG" + if [ -z "$KEY" ] || [ "$KEY" != "$LAST_KEY" ]; then + snapshot_log "$LOG" "$TMP/source" "$TMP/size" \ + || die "log could not be captured safely: $REL" + # The gate stat precedes the capture, so the snapshot is at least as new + # as its key: a log that moved in between changes the key and is + # captured again on the next poll, never mistaken for stable. + LAST_KEY=$KEY + IFS= read -r SIZE < "$TMP/size" + if [ "$SIZE" -lt "$OFFSET" ]; then + copy_prefix "$TMP/source" "$SIZE" "$TMP/prefix" + ACTUAL=$(sha256_file "$TMP/prefix") + emit_break truncated "$SIZE" "$ACTUAL" exit 0 fi - if [ $((SIZE - OFFSET)) -ge "$MAX_BYTES" ]; then - emit_break line-exceeds-bound "$SIZE" "$ACTUAL" + copy_prefix "$TMP/source" "$OFFSET" "$TMP/prefix" + ACTUAL=$(sha256_file "$TMP/prefix") + if [ "$ACTUAL" != "$PREFIX" ]; then + emit_break prefix-changed "$SIZE" "$ACTUAL" exit 0 fi + if [ "$SIZE" -gt "$OFFSET" ]; then + tail -c "+$((OFFSET + 1))" "$TMP/source" | head -c "$MAX_BYTES" > "$TMP/chunk" || true + COMPLETE_BYTES=$(LC_ALL=C od -An -v -tu1 "$TMP/chunk" | awk ' + { for (i = 1; i <= NF; i++) { bytes++; if ($i == 10) complete=bytes } } + END { print complete + 0 } + ') + if [ "$COMPLETE_BYTES" -eq 0 ]; then : > "$TMP/payload"; else head -c "$COMPLETE_BYTES" "$TMP/chunk" > "$TMP/payload"; fi + BYTES=$(LC_ALL=C wc -c < "$TMP/payload" | tr -d ' ') + if [ "$BYTES" -gt 0 ]; then + TO=$((OFFSET + BYTES)) + copy_prefix "$TMP/source" "$TO" "$TMP/to-prefix" + TO_HASH=$(sha256_file "$TMP/to-prefix") + PAYLOAD_HASH=$(sha256_file "$TMP/payload") + printf 'schema=fm-remote-delta.v1\n' + printf 'status=delta\n' + printf 'path=%s\n' "$REL" + printf 'from_offset=%s\n' "$OFFSET" + printf 'to_offset=%s\n' "$TO" + printf 'from_prefix_sha256=%s\n' "$PREFIX" + printf 'to_prefix_sha256=%s\n' "$TO_HASH" + printf 'payload_sha256=%s\n' "$PAYLOAD_HASH" + printf 'payload_bytes=%s\n' "$BYTES" + printf 'reason=\n\n' + cat "$TMP/payload" + exit 0 + fi + if [ $((SIZE - OFFSET)) -ge "$MAX_BYTES" ]; then + emit_break line-exceeds-bound "$SIZE" "$ACTUAL" + exit 0 + fi + fi fi elif [ "$OFFSET" -ne 0 ] || [ "$PREFIX" != "$EMPTY_HASH" ]; then emit_break missing 0 "$EMPTY_HASH" exit 0 fi - NOW=$(date +%s) - [ $((NOW - START)) -lt "$WAIT" ] || exit 75 + [ $((SECONDS - START)) -lt "$WAIT" ] || exit 75 sleep "$POLL_SECONDS" done diff --git a/bin/fm-remote-job-lib.sh b/bin/fm-remote-job-lib.sh index a60daf41d01..ce6e4090737 100755 --- a/bin/fm-remote-job-lib.sh +++ b/bin/fm-remote-job-lib.sh @@ -499,15 +499,43 @@ fm_remote_job_write_state() { # queued|running|done mv -f -- "$tmp" "$job/state" } -fm_remote_job_read_state() { # - local job=$1 value extra - fm_remote_job_regular_bounded "$job/state" 64 || return 1 - IFS= read -r value < "$job/state" || return 1 - if IFS= read -r extra < <(tail -n +2 "$job/state"); then - : "$extra" - return 1 +# Reads a one-line record bounded to bytes with builtins only, matching +# fm_remote_job_regular_bounded plus the former read/tail checks: a regular +# non-symlink file of at most bytes, one newline-terminated line, a +# tolerated unterminated tail, no carriage returns, and a non-empty value. +# The -d '' -n read treats NUL as the delimiter, so an ordinary +# record (no NULs) is pulled whole at once: the read fails at end of file, +# and success means either bytes landed (the file busts the +# bound) or a NUL stopped it early (already malformed). -N cannot do this: +# the stock /bin/bash on macOS is 3.2, which has -n but no -N. The local +# LC_ALL=C makes -n count bytes rather than multibyte characters, so the byte +# bound holds in a UTF-8 locale. +fm_remote_job_read_line() { # + local file=$1 max=$2 result_var=$3 content + local LC_ALL=C + [ -f "$file" ] && [ ! -L "$file" ] || return 1 + ! IFS= read -r -d '' -n "$((max + 1))" content < "$file" 2>/dev/null || return 1 + case "$content" in *$'\r'* | *$'\n'*$'\n'*) return 1 ;; esac + case "$content" in *$'\n'*) ;; *) return 1 ;; esac + content=${content%%$'\n'*} + [ -n "$content" ] || return 1 + printf -v "$result_var" '%s' "$content" +} + +# Reads the one-word state record with builtins only: the result consumers and +# the lane preemption scan call this once per sample, so it cannot afford the +# bounded-size subshell or a tail process substitution. Passing a result +# variable name avoids the command substitution fork; without one the value is +# printed as before. +fm_remote_job_read_state() { # [result-variable] + local job=$1 result_var=${2:-} read_value + fm_remote_job_read_line "$job/state" 64 read_value || return 1 + case "$read_value" in queued|running|'done') ;; *) return 1 ;; esac + if [ -n "$result_var" ]; then + printf -v "$result_var" '%s' "$read_value" + else + printf '%s\n' "$read_value" fi - case "$value" in queued|running|'done') printf '%s\n' "$value" ;; *) return 1 ;; esac } fm_remote_job_read_number() { # queue_deadline|timeout|deadline|seq @@ -690,7 +718,7 @@ fm_remote_job_stage() { # [args...]; stdi fm_remote_job_wait() { # ; honors FM_REMOTE_JOB_DISCONNECT_PROBE local account_home=$1 id=$2 job state queue_deadline execution_timeout wait_deadline exit_value - local now next_probe=0 + local deadline_ticks next_probe=0 fm_remote_job_prepare_state "$account_home" || return 1 job=$(fm_remote_job_job_dir "$id") || { FM_REMOTE_JOB_ERROR="remote job record disappeared or became unsafe" @@ -709,8 +737,12 @@ fm_remote_job_wait() { # ; honors FM_REMOTE_JOB_DISCONNECT_PR return 1 } wait_deadline=$((queue_deadline + execution_timeout + FM_REMOTE_JOB_WAIT_GRACE)) + # SECONDS is the loop's clock so no time child runs per sample: one date + # read here converts the epoch deadline into the shell's own tick counter + # with the same whole-second granularity. + deadline_ticks=$((SECONDS + wait_deadline - $(date +%s))) while :; do - state=$(fm_remote_job_read_state "$job" 2>/dev/null || true) + fm_remote_job_read_state "$job" state 2>/dev/null || state= case "$state" in 'done') if ! fm_remote_job_regular_bounded "$job/stdout" "$FM_REMOTE_JOB_MAX_BYTES" || @@ -733,13 +765,12 @@ fm_remote_job_wait() { # ; honors FM_REMOTE_JOB_DISCONNECT_PR queued|running) ;; *) FM_REMOTE_JOB_ERROR="remote job state is invalid"; return 1 ;; esac - now=$(date +%s) - if [ "$now" -ge "$wait_deadline" ]; then + if [ "$SECONDS" -ge "$deadline_ticks" ]; then FM_REMOTE_JOB_ERROR="remote job did not complete within its bounded wait" return 1 fi - if [ -n "${FM_REMOTE_JOB_DISCONNECT_PROBE:-}" ] && [ "$now" -ge "$next_probe" ]; then - next_probe=$((now + 1)) + if [ -n "${FM_REMOTE_JOB_DISCONNECT_PROBE:-}" ] && [ "$SECONDS" -ge "$next_probe" ]; then + next_probe=$((SECONDS + 1)) if ! "$FM_REMOTE_JOB_DISCONNECT_PROBE"; then fm_remote_job_cancel "$account_home" "$id" 2>/dev/null || true FM_REMOTE_JOB_ERROR="remote job caller disconnected; the job was cancelled" diff --git a/bin/fm-remote-job-worker.sh b/bin/fm-remote-job-worker.sh index 73191029ff0..118d75c3493 100755 --- a/bin/fm-remote-job-worker.sh +++ b/bin/fm-remote-job-worker.sh @@ -751,25 +751,49 @@ worker_run_with_timeout() { # [args...] return "$rc" } -worker_job_command() { # ; the first argv element of a staged record - local job=$1 first= - fm_remote_job_regular_bounded "$job/argv" "$FM_REMOTE_JOB_MAX_BYTES" || return 1 - IFS= read -r -d '' first < "$job/argv" || [ -n "$first" ] || return 1 - printf '%s\n' "$first" -} - worker_preempting_waiter_exists() { # - local lane_home=$1 job state command job_home + local lane_home=$1 job state command job_home field_terminated remaining chunk + # The argv byte bound counts with read -n and ${#...}, which count bytes only + # in the C locale. + local LC_ALL=C for job in "$FM_REMOTE_JOB_JOBS"/job-*; do [ -d "$job" ] && [ ! -L "$job" ] || continue - state=$(fm_remote_job_read_state "$job" 2>/dev/null || true) + fm_remote_job_read_state "$job" state 2>/dev/null || continue [ "$state" = queued ] || continue fm_remote_job_cancelled "$job" && continue # Lanes are per home, so only a waiter for this lane's own home may - # preempt; another home's queue drains through its own lane. - job_home=$(worker_read_text "$job" home 8192 2>/dev/null || true) + # preempt; another home's queue drains through its own lane. The record + # fields are read with builtins only: this scan runs once a second in + # every lane that executes a preemptible long poll, so no field read may + # spawn a child process. + fm_remote_job_read_line "$job/home" 8192 job_home 2>/dev/null || job_home= [ "$job_home" = "$lane_home" ] || continue - command=$(worker_job_command "$job" 2>/dev/null || true) + # The staged argv record must fit within FM_REMOTE_JOB_MAX_BYTES: bound + # the first NUL-delimited field, then walk the remaining NUL-terminated + # fields and any unterminated tail, still with builtins only. -d '' -n + # is the bounded read on the macOS stock bash (3.2 has -n but no -N); + # never pass -n 0, whose behavior diverges across bash versions. + command= + if [ -f "$job/argv" ] && [ ! -L "$job/argv" ]; then + { field_terminated= + IFS= read -r -d '' -n "$((FM_REMOTE_JOB_MAX_BYTES + 1))" command && field_terminated=1 + if [ -n "$field_terminated" ]; then + if [ "${#command}" -gt "$FM_REMOTE_JOB_MAX_BYTES" ]; then + false + else + remaining=$((FM_REMOTE_JOB_MAX_BYTES - ${#command} - 1)) + chunk= + while [ "$remaining" -ge 0 ] && IFS= read -r -d '' -n "$((remaining + 1))" chunk; do + [ "${#chunk}" -le "$remaining" ] || break + remaining=$((remaining - ${#chunk} - 1)) + done + remaining=$((remaining - ${#chunk})) + [ "$remaining" -ge 0 ] + fi + else + [ -n "$command" ] + fi; } < "$job/argv" 2>/dev/null || command= + fi fm_remote_job_command_preemptible "$command" || return 0 done return 1 diff --git a/bin/fm-session-start.sh b/bin/fm-session-start.sh index 9ddaadc88ba..67f25704265 100755 --- a/bin/fm-session-start.sh +++ b/bin/fm-session-start.sh @@ -371,6 +371,8 @@ PRIMARY_HARNESS=$("$SCRIPT_DIR/fm-harness.sh" 2>/dev/null || printf unknown) . "$SCRIPT_DIR/fm-wake-lib.sh" # shellcheck source=bin/fm-line-cap-lib.sh . "$SCRIPT_DIR/fm-line-cap-lib.sh" +# shellcheck source=bin/fm-hold-reason-lib.sh +. "$SCRIPT_DIR/fm-hold-reason-lib.sh" # One tasks-axi compatibility verdict per session start. The probe costs three # tasks-axi subprocesses and this digest needs the same answer twice - here for @@ -470,7 +472,7 @@ print_backlog_manual_compact() { } } } - ' "$path" + ' "$path" | fm_hold_reason_decode_stream markdown } # tasks-axi closes every listing with its own help block. This section composes @@ -522,11 +524,11 @@ print_backlog_tasks_axi_compact() { printf 'compact backlog listing (tasks-axi; done rows omitted; every in-flight, held, and blocked row shown in full; ready queued bounded to %s; task bodies omitted)\n' \ "$QUEUED_LIMIT" printf '\nin flight:\n' - printf '%s\n' "$in_flight" | strip_axi_help + printf '%s\n' "$in_flight" | fm_hold_reason_decode_stream | strip_axi_help printf '\nheld (captain- or time-gated; an in-flight item that is also held appears in both groups):\n' - printf '%s\n' "$held" | strip_axi_help + printf '%s\n' "$held" | fm_hold_reason_decode_stream | strip_axi_help printf '\nblocked queued:\n' - printf '%s\n' "$blocked" | strip_axi_help + printf '%s\n' "$blocked" | fm_hold_reason_decode_stream | strip_axi_help printf '\nready queued (dispatchable now):\n' print_ready_queued_bounded "$ready" return 0 diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index c4bfef15555..aa123a39fb9 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -176,6 +176,12 @@ # name from PATH once, probes that concrete path with --help, and launches the # same path. It adds --tui-mode regular only when that help advertises the flag; # a failed or inconclusive probe omits it so older Pi versions remain launchable. +# A --secondmate launch of a Firstmate-seeded home (the existing +# .fm-secondmate-home marker validate_firstmate_home_for_spawn already requires) +# also adds --approve when that help advertises it, so the first unattended +# launch does not stall on Pi's "Trust project folder?" dialog for that home +# path; --approve is session-scoped to the launch cwd and does not rewrite the +# operator's trust.json. Ordinary Pi worker launches never receive --approve. # A missing selected executable refuses before endpoint creation, and pi-signed # never falls back to pi. # Devin is worker-only: --permission-mode dangerous and @@ -354,6 +360,9 @@ # supplies its own trailing space, empty never used) # __PIBIN__ quoted concrete Pi-family executable path resolved from PATH # __PITUIMODE__ optional --tui-mode regular when that executable advertises it +# __PIAPPROVE__ optional --approve on a seeded Pi/pi-signed secondmate when +# that executable advertises the flag (empty otherwise; session +# trust for the launch cwd only, never a trust.json rewrite) # __PIRESUME__ optional relaunch-only `--session ` that keeps a # Pi replacement on the session the endpoint's runtime already # reports (relaunch_resume_args below owns it; it supplies its @@ -1892,6 +1901,17 @@ pi_supports_tui_mode() { printf '%s\n' "$help" | grep -Eq -- '(^|[[:space:]])--tui-mode([[:space:]=]|$)' } +# Same help-probe shape as pi_supports_tui_mode for the session-scoped project +# trust flag. A seeded secondmate home carries tracked .pi/extensions that gate +# Pi behind "Trust project folder?" on first launch; --approve trusts that +# launch cwd for the run without rewriting ~/.pi/agent/trust.json. +pi_supports_approve() { + local executable=$1 help + help=$("$executable" --help 2>&1) || return 1 + # Pi prints "--approve, -a"; allow comma (and any non-token char) after the name. + printf '%s\n' "$help" | grep -Eq -- '(^|[[:space:]])--approve([^[:alnum:]_-]|$)' +} + # omp pre-launch model validation. `omp models --json` (omp 18.1.11) prints # {"models":[{"provider","id","selector":"/",...}]} for built-in and # auto-discovered providers only; it never lists a provider an extension @@ -2044,7 +2064,7 @@ launch_template() { ;; opencode) printf '%s' 'OPENCODE_CONFIG_CONTENT='\''{"permission":{"*":"allow"}__EFFORTFLAG__}'\'' opencode __MODELFLAG__--prompt "$(__OPINPUT__ encode launch-brief < __BRIEF__)"' ;; pi | pi-signed) - printf '%s' '__PIBIN____PITUIMODE____PIRESUME__' + printf '%s' '__PIBIN____PITUIMODE____PIAPPROVE____PIRESUME__' if [ "$kind" = secondmate ]; then printf '%s' ' __MODELFLAG____EFFORTFLAG__-e __PITURNEND__ -e __PIWATCH__ "$(__OPINPUT__ encode launch-brief < __BRIEF__)"' else @@ -2310,6 +2330,15 @@ pi | pi-signed) PI_TUI_MODE=' --tui-mode regular' fi LAUNCH=${LAUNCH//__PITUIMODE__/$PI_TUI_MODE} + # Seeded-home signal is .fm-secondmate-home (required by + # validate_firstmate_home_for_spawn before any secondmate launch reaches + # the pane). Session-only --approve; never expand to a parent path or + # rewrite the operator trust store. + PI_APPROVE= + if [ "$KIND" = secondmate ] && pi_supports_approve "$PI_BIN"; then + PI_APPROVE=' --approve' + fi + LAUNCH=${LAUNCH//__PIAPPROVE__/$PI_APPROVE} LAUNCH="FM_PI_HARNESS=$HARNESS $LAUNCH" ;; cursor) diff --git a/bin/fm-supervision-host.sh b/bin/fm-supervision-host.sh index 90fd54836e9..26555b69633 100755 --- a/bin/fm-supervision-host.sh +++ b/bin/fm-supervision-host.sh @@ -54,8 +54,11 @@ # before the close is printed, so supervision continues when the session # drops the handoff. It confirms no handling handoff, so the recovery # marker still reads downtime and the re-arm owner delivers the close to -# main. The watcher singleton lock makes the session's next arm attach to -# that cycle instead of starting a second one; +# main. The host records that successor's arm before relinquishing it +# (detach_successor owns the persistence check and failure path). The +# session's next park without --restart requests a take-over of its cycle +# rather than an ordinary attach; bin/fm-watch-arm.sh's --take-over header owns the +# conditions under which that restores a single owner and the fallback; # - away (an away record exists): every close goes to the engine. # Every turn that starts attended meets that rule again at its start, so a # close accepted away whose turn starts attended (the captain returned in @@ -132,7 +135,12 @@ # left running (recorded with identities, never by name), including the # engine descendants its turn recorded, removes that turn's files, and # releases the branch actor's leases; it releases them again after every -# engine turn. +# engine turn. It also reads the record of a successor a pass-through left for +# main: while that arm still runs under its recorded identity, the first cycle +# without --restart requests a take-over rather than an ordinary attach. +# Activation removes the +# record only once that identity is no longer alive, so a later host retries a +# take-over that left it running. # # STATE (all under state/, owned here): .supervision-host (this host's pid and # the processes it runs), .supervision-host-engine (the engine conversation: @@ -141,7 +149,8 @@ # report scope and the reports it recorded), .supervision-host-prompt and # .supervision-host-wake (the prompt and wake text of the current turn), # .supervision-host-mirror (the dialog-mirror feed while an attended wake is -# rendered), +# rendered), .supervision-host-left (the pid and identity of the successor arm a +# pass-through left running for main, until that arm is gone), # .supervision-host-health (the latch: errors, cooldown, and probe time, keyed # to the main session, engine, and model), and .supervision-host.log (a bounded # ledger of where every close went, with each engine turn's usage and @@ -231,6 +240,7 @@ HOST_LOG="$STATE/.supervision-host.log" ENGINE_PID_FILE="$STATE/.supervision-host.engine-pid" HEALTH_FILE="$STATE/.supervision-host-health" MIRROR_FEED="$STATE/.supervision-host-mirror" +LEFT_RECORD="$STATE/.supervision-host-left" HOST_PID=$$ HOST_STARTED_SECONDS=$SECONDS @@ -251,7 +261,12 @@ HANDLE_RC=0 ENGINE_SUBSHELL= SUCCESSOR_PID= SUCCESSOR_OUT= +SUCCESSOR_WATCHER= +SUCCESSOR_GENERATION= ENGINE_RUNNING=0 +# The successor arm a predecessor's pass-through left for main, which the +# first cycle takes over. +LEFT_ARM= # The running turn's result and diagnostics files, removed by the cleanup when # the host is stopped mid-turn. TURN_RESULT= @@ -362,6 +377,18 @@ activate() { done rm -f "$STATE"/.supervision-host-arm.* "$STATE"/.supervision-host-descendants.* "$STATE"/.supervision-host-result.* \ "$STATE"/.supervision-host-errors.* "$STATE"/.supervision-host-readback.* "$TURN_FILE" "$MIRROR_FEED" 2>/dev/null || true + # The successor a pass-through left for main: the first cycle takes it over + # while it still answers to its recorded identity, and its record goes only + # once it does not. + if [ -f "$LEFT_RECORD" ]; then + pid='' identity='' + IFS="$(printf '\t')" read -r pid identity < "$LEFT_RECORD" || true + if fm_pid_alive "$pid" && [ -n "$identity" ] && [ "$(identity_of "$pid")" = "$identity" ]; then + LEFT_ARM=$pid + else + rm -f "$LEFT_RECORD" + fi + fi printf 'host\t%s\t%s\n' "$HOST_PID" "$(identity_of "$HOST_PID")" > "$HOST_RECORD" || return 1 release_branch_leases } @@ -553,10 +580,17 @@ retire_successor() { # Hand the close to main: stop the successor cycle, print the close, why, and # any further "supervision-host:" lines, and exit. exit_to_main() { # [further lines] + local lines=${2:-} rc=0 retire_successor + if [ -n "$SUCCESSOR_GENERATION" ] \ + && ! fm_recovery_marker_publish "$STATE/.watcher-down" downtime >/dev/null 2>&1; then + log_line "to-main downtime-unrestored $1" + lines=${lines:+$lines$'\n'}"supervision-host: watcher downtime could not be restored for the main hand-back" + rc=1 + fi log_line "to-main $1" - emit "supervision-host: $1" "${2:-}" - exit 0 + emit "supervision-host: $1" "$lines" + exit "$rc" } # The outcome store (bin/fm-branch-outcome.sh) owns and validates these rows. @@ -637,13 +671,27 @@ start_successor() { # done } -# Drop the successor from this host's cleanup without stopping it. The shell -# signals background jobs when it exits, and this arm's handler would then -# stop the watcher, so disown it first. The capture file stays tracked so the -# EXIT trap unlinks it; the arm already holds that descriptor and keeps -# waiting on the watcher. +# Record the successor for the next host to take over, then drop it from this +# host's cleanup without stopping it. A successor whose record does not read +# back as a regular file holding exactly its pid and identity stays tracked, +# so the cleanup stops it and main's next turn end arms a fresh cycle; that +# returns 1. The shell signals background jobs when it exits, and this arm's +# handler would then stop the watcher, so disown it first. The capture file +# stays tracked so the EXIT trap unlinks it; the arm already holds that +# descriptor and keeps waiting on the watcher. detach_successor() { + local identity tmp= [ -n "${SUCCESSOR_PID:-}" ] || return 0 + identity=$(identity_of "$SUCCESSOR_PID") + if [ -z "$identity" ] || ! tmp=$(mktemp "$LEFT_RECORD.tmp.XXXXXX" 2>/dev/null) \ + || ! printf '%s\t%s\n' "$SUCCESSOR_PID" "$identity" > "$tmp" 2>/dev/null \ + || ! mv -f "$tmp" "$LEFT_RECORD" 2>/dev/null \ + || [ -L "$LEFT_RECORD" ] || [ ! -f "$LEFT_RECORD" ] \ + || [ "$(cat "$LEFT_RECORD" 2>/dev/null)" != "$SUCCESSOR_PID"$'\t'"$identity" ]; then + [ -z "$tmp" ] || rm -f "$tmp" "$LEFT_RECORD/${tmp##*/}" 2>/dev/null || true + log_line "pass-through successor-unrecorded $(printf '%s\n' "$REASON" | head -n 1)" + return 1 + fi disown "$SUCCESSOR_PID" 2>/dev/null || true forget_process "$SUCCESSOR_PID" SUCCESSOR_PID= @@ -1008,6 +1056,9 @@ log_line "start gen=$GEN primary=$PRIMARY" # The first cycle. if [ "$FIRST_ARM_RESTART" -eq 1 ]; then start_arm "$OWNER_PREDECESSOR" --restart +elif [ -n "$LEFT_ARM" ]; then + log_line "take-over arm=$LEFT_ARM" + start_arm "$OWNER_PREDECESSOR" --take-over "$LEFT_ARM" else start_arm "$OWNER_PREDECESSOR" fi || { echo "watcher: FAILED - the supervision host could not start a watcher cycle"; exit 1; } diff --git a/bin/fm-tasks-axi.sh b/bin/fm-tasks-axi.sh index b773014a115..7f0dc1822c9 100755 --- a/bin/fm-tasks-axi.sh +++ b/bin/fm-tasks-axi.sh @@ -14,6 +14,10 @@ # stores it verbatim as a link, which lifecycle transitions record relative to # that same root. # +# `show` (including `view`) and `list` decode stored captain-hold reasons +# through bin/fm-hold-reason-lib.sh, which owns the field-only decoding contract. +# Decoded reasons use quoted strings so embedded line breaks remain intact. +# # Why it exists: a bare `tasks-axi` resolves the tracked `.tasks.toml` paths # against its working directory, so from the code root it forks the queue # whenever the home lives elsewhere; docs/configuration.md ("Backlog backend") @@ -46,7 +50,8 @@ # - a markdown `/backlog.md` that is itself a symlink, because the # first write would replace the link with a private copy, exactly the fork # this command exists to prevent. Lifecycle transitions refuse the same file. -# Otherwise the exit status is tasks-axi's own. +# Otherwise the exit status is tasks-axi's own, unless decoding a read fails; +# in that case the decoder's nonzero status is returned. set -u SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" @@ -57,6 +62,8 @@ DATA="${FM_DATA_OVERRIDE:-$FM_HOME/data}" . "$SCRIPT_DIR/fm-tasks-axi-lib.sh" # shellcheck source=bin/fm-backlog-transition-lib.sh disable=SC1091 . "$SCRIPT_DIR/fm-backlog-transition-lib.sh" +# shellcheck source=bin/fm-hold-reason-lib.sh disable=SC1091 +. "$SCRIPT_DIR/fm-hold-reason-lib.sh" usage() { awk ' @@ -137,4 +144,11 @@ else fi cd "$FM_BACKLOG_AXI_ROOT" || fail "cannot enter the backlog root $FM_BACKLOG_AXI_ROOT" +case "${1:-}" in + show|view|list) + set -o pipefail + tasks-axi ${ARGS[@]+"${ARGS[@]}"} | fm_hold_reason_decode_stream + exit $? + ;; +esac exec tasks-axi ${ARGS[@]+"${ARGS[@]}"} diff --git a/bin/fm-test-run.sh b/bin/fm-test-run.sh index 22b6a6796cc..8c71d510efb 100755 --- a/bin/fm-test-run.sh +++ b/bin/fm-test-run.sh @@ -364,6 +364,7 @@ family_for_basename() { fm-harness-liveness-drift-live-e2e.test.sh|\ fm-devin-signals-live-e2e.test.sh|fm-muse-signals-live-e2e.test.sh|fm-rovo-signals-live-e2e.test.sh|fm-agy-signals-live-e2e.test.sh|\ fm-launch-prompt-signals-live-e2e.test.sh|\ + fm-pi-seeded-home-trust-live-e2e.test.sh|\ fm-herdr-version-floor-live-e2e.test.sh|\ fm-herdr-pi-stale-registration-live-e2e.test.sh|\ fm-worker-account-live-e2e.test.sh|\ @@ -793,6 +794,7 @@ tests/fm-pi-branch-live-e2e.test.sh 48 tests/fm-pi-branch-responsiveness-live-e2e.test.sh 12834 tests/fm-pi-codex-native.test.sh 75 tests/fm-pi-primary-live-e2e.test.sh 72 +tests/fm-pi-seeded-home-trust-live-e2e.test.sh 45 tests/fm-pi-watch-extension.test.sh 56515 tests/fm-pi-windows-shell-invocation.test.sh 5121 tests/fm-pr-check-security.test.sh 300675 @@ -1650,7 +1652,7 @@ families_for_changed_path() { bin/fm-lint.sh|bin/fm-lint-workflows.sh|bin/fm-install-shellcheck.sh|\ bin/fm-install-actionlint.sh|\ bin/fm-brief.sh|bin/fm-ensure-agents-md.sh|bin/fm-crew-state.sh|\ - bin/fm-captain-hold.sh|bin/fm-decision-hold.sh|bin/fm-supervision*|bin/fm-transition-lib.sh|\ + bin/fm-captain-hold.sh|bin/fm-hold-reason-lib.sh|bin/fm-decision-hold.sh|bin/fm-supervision*|bin/fm-transition-lib.sh|\ bin/fm-tmux-lib.sh|bin/fm-marker-lib.sh|bin/fm-operational-input.sh|bin/fm-tasks-axi-lib.sh|\ bin/fm-vendor-auth-probe.sh|\ bin/fm-primary-scope-lib.sh|bin/fm-project-mode.sh|bin/fm-forge-detect.sh|bin/fm-promote.sh|\ diff --git a/bin/fm-wake-lib.sh b/bin/fm-wake-lib.sh index 60a9d289090..dfa387079b0 100755 --- a/bin/fm-wake-lib.sh +++ b/bin/fm-wake-lib.sh @@ -971,9 +971,64 @@ _fm_recovery_marker_reopen_announced() { fm_lock_release "$FM_WAKE_QUEUE_LOCK" } +# The handover rule for a watcher stopped by bin/fm-watch-arm.sh --take-over +# (docs/watcher-continuity.md "Generation reuse" owns it). The snapshot reads +# the marker token and the queue's append sequence under both locks before the +# stop; handover-restore puts an acknowledged token back only while that +# sequence is unchanged and the marker reads the fresh pending downtime the +# stopped watcher's own close published. +FM_RECOVERY_HANDOVER_TOKEN= +FM_RECOVERY_HANDOVER_SEQ= +fm_recovery_marker_handover_snapshot() { # + local marker=$1 lock + FM_RECOVERY_HANDOVER_TOKEN= + FM_RECOVERY_HANDOVER_SEQ= + lock="${marker}.lock" + fm_lock_acquire_wait "$FM_WAKE_QUEUE_LOCK" || return 1 + if ! fm_lock_acquire_wait "$lock"; then + fm_lock_release "$FM_WAKE_QUEUE_LOCK" + return 1 + fi + if fm_recovery_marker_read "$marker"; then + # shellcheck disable=SC2034 # Read by callers after this function returns. + FM_RECOVERY_HANDOVER_TOKEN=$FM_RECOVERY_MARKER_TOKEN + fi + # shellcheck disable=SC2034 # Read by callers after this function returns. + FM_RECOVERY_HANDOVER_SEQ=$(cat "$STATE/.wake-queue.seq" 2>/dev/null || true) + fm_lock_release "$lock" + fm_lock_release "$FM_WAKE_QUEUE_LOCK" +} + +_fm_recovery_marker_handover_restore() { + local marker=$1 token=$2 seq=$3 lock status=0 + case "$token" in acked:*) ;; *) return 0 ;; esac + lock="${marker}.lock" + fm_lock_acquire_wait "$FM_WAKE_QUEUE_LOCK" || return 1 + if ! fm_lock_acquire_wait "$lock"; then + fm_lock_release "$FM_WAKE_QUEUE_LOCK" + return 1 + fi + if [ "$(cat "$STATE/.wake-queue.seq" 2>/dev/null || true)" = "$seq" ] \ + && fm_recovery_marker_read "$marker"; then + case "$FM_RECOVERY_MARKER_TOKEN" in + pending:downtime:*) + if [ "${FM_RECOVERY_MARKER_TOKEN##*:}" != "${token##*:}" ]; then + _fm_recovery_marker_restore_token_locked "$marker" "$token" || status=1 + fi + ;; + esac + fi + fm_lock_release "$lock" + fm_lock_release "$FM_WAKE_QUEUE_LOCK" + return "$status" +} + fm_recovery_transition() { local marker=$1 action=$2 target=${3:-} value=${4:-} bound=${5:-} case "$action" in + handover-restore) + _fm_recovery_marker_handover_restore "$marker" "$target" "$value" + ;; publish) _fm_recovery_marker_publish "$marker" "${target:-downtime}" "$bound" ;; @@ -1035,6 +1090,10 @@ fm_recovery_marker_reopen_announced() { fm_recovery_transition "$1" reopen-announced } +fm_recovery_marker_handover_restore() { # + fm_recovery_transition "$1" handover-restore "$2" "$3" +} + # fm_lock_reap_dead_link # Remove a link lock whose owner is dead without a nested mutex. Renaming the # dead owner directory to this process's tombstone elects exactly one reaper, diff --git a/bin/fm-watch-arm.sh b/bin/fm-watch-arm.sh index 1932c0b9414..3a996a0320e 100755 --- a/bin/fm-watch-arm.sh +++ b/bin/fm-watch-arm.sh @@ -68,6 +68,17 @@ # bin/fm-watch.sh`: that pattern matches every firstmate home's watcher # (secondmate homes run the same script) and would kill siblings. # +# --take-over : own the cycle that arm owns, for an owner +# that left a successor cycle running through main's turn and now parks again +# (bin/fm-supervision-host.sh). Only when this home's healthy watcher is that +# arm's own child, it stops that watcher by its locked identity: a cycle that +# delivered a reason before the stop landed reports it exactly as an attached +# arm would, and otherwise this arm owns a fresh cycle as a plain arm does. +# Recovery restoration follows docs/watcher-continuity.md "Generation reuse"; +# an unconfirmed stop leaves downtime for the fresh cycle's recovery check. +# Any other watcher, or one that outlives the stop, +# is attached to exactly as a plain arm attaches. +# # --stop: the same home-scoped stop without re-arming, for an owner that ends # its own supervision cycle on purpose (the supervision host's park boundary, # bin/fm-supervision-host.sh). The stopped watcher publishes downtime exactly @@ -137,9 +148,10 @@ ARM_PID=${BASHPID:-$$} case "$CYCLE_LOG_MAX_BYTES" in ''|*[!0-9]*|0) CYCLE_LOG_MAX_BYTES=262144 ;; esac case "$CYCLE_LOG_KEEP_LINES" in ''|*[!0-9]*|0) CYCLE_LOG_KEEP_LINES=1000 ;; esac -# The lifecycle ledger is diagnostic evidence, not a supervision dependency. -# Writes are bounded and best-effort so an observability failure cannot stall an -# otherwise healthy watcher cycle. +# Lifecycle writes are bounded and best-effort so an observability failure +# cannot stall an otherwise healthy watcher cycle. Take-over also uses the +# owner's row as stop evidence; missing evidence takes the safe recovery path +# (docs/watcher-continuity.md "Generation reuse"). cycle_clean_field() { printf '%s' "$1" | tr '\t\r\n' ' ' | cut -c1-512 } @@ -237,9 +249,10 @@ cycle_log_append() { # A persistent adapter passes the arm pid that just closed. Once this new arm # verifies its watcher, update that predecessor's final record in place so the # one-record-per-cycle ledger captures the actual successor outcome without an -# extra synthetic lifecycle row. +# extra synthetic lifecycle row. A taking-over arm names itself instead, so its +# record of the cycle it took over names the cycle it started. cycle_mark_predecessor_successor() { - local successor=$1 predecessor=${FM_WATCH_PREDECESSOR_ARM_PID:-} i tmp + local successor=$1 predecessor=${2:-${FM_WATCH_PREDECESSOR_ARM_PID:-}} i tmp case "$predecessor" in ''|*[!0-9]*) return 0 ;; esac @@ -324,31 +337,35 @@ fail_unexplained_cycle() { return 1 } -# Close a cycle whose reason line this arm could not read against the bounded -# terminal-delivery ledger the watcher publishes before releasing its lock. -close_unobserved_cycle() { - local i reason clean_identity record_pid record_identity record_reason +# Read the reason the current cycle's watcher recorded in the bounded +# terminal-delivery ledger it publishes before releasing its lock. Sets +# DELIVERED_REASON; fails when no record matches the cycle's pid and identity. +DELIVERED_REASON= +cycle_delivered_reason() { + local i clean_identity record_pid record_identity record_reason + DELIVERED_REASON= clean_identity=$(printf '%s' "$cycle_watcher_identity" | tr '\t\r\n' ' ') i=0 while ! fm_lock_try_acquire "$WATCH_DELIVERY_LOCK"; do - [ "$i" -lt 20 ] || { - fail_unexplained_cycle - return 1 - } + [ "$i" -lt 20 ] || return 1 sleep 0.02 i=$((i + 1)) done - reason= if [ -f "$WATCH_DELIVERY_LOG" ]; then while IFS=$'\t' read -r record_pid record_identity record_reason; do if [ "$record_pid" = "$cycle_watcher_pid" ] && [ "$record_identity" = "$clean_identity" ]; then - reason=$record_reason + DELIVERED_REASON=$record_reason fi done < "$WATCH_DELIVERY_LOG" fi fm_lock_release "$WATCH_DELIVERY_LOCK" - if [ -n "$reason" ]; then - printf '%s\n' "$reason" + [ -n "$DELIVERED_REASON" ] +} + +# Close a cycle whose reason line this arm could not read against that ledger. +close_unobserved_cycle() { + if cycle_delivered_reason; then + printf '%s\n' "$DELIVERED_REASON" return 0 fi fail_unexplained_cycle @@ -461,10 +478,17 @@ handling_successor_generation() { mode=arm handling_generation= handling_watcher_pid= +take_over_arm_pid= case "${1:-}" in ''|arm|--arm) mode=arm ;; --restart) mode=restart ;; --stop) mode=stop ;; + --take-over) + mode=take-over + take_over_arm_pid=${2:-} + case "$take_over_arm_pid" in ''|*[!0-9]*) echo "watcher: invalid take-over arm pid" >&2; exit 2 ;; esac + [ "$#" -eq 2 ] || { echo "watcher: unexpected take-over arguments" >&2; exit 2; } + ;; --handling-delivered) mode=handling-delivered handling_generation=${2:-} @@ -474,7 +498,7 @@ case "${1:-}" in case "$handling_watcher_pid" in ''|*[!0-9]*) echo "watcher: invalid successor watcher pid" >&2; exit 2 ;; esac [ "$#" -eq 4 ] || { echo "watcher: unexpected handling delivery arguments" >&2; exit 2; } ;; - *) echo "usage: $(basename "$0") [--restart | --stop | --handling-delivered GENERATION --watcher-pid PID]" >&2; exit 2 ;; + *) echo "usage: $(basename "$0") [--restart | --stop | --take-over ARM_PID | --handling-delivered GENERATION --watcher-pid PID]" >&2; exit 2 ;; esac if [ "$mode" = handling-delivered ]; then @@ -524,6 +548,67 @@ if [ "$mode" = stop ]; then exit 0 fi +# Stop the watcher the named arm owns, by its locked identity, and wait for it +# to exit (header, --take-over). Returns 3 after printing the reason that cycle +# delivered before the stop landed, 0 once it stopped without delivering, and +# 1 when it was not stopped (its handover state was unreadable, or it outlived +# the stop), which leaves it to the plain attach below. +take_over_cycle() { # + local pid=$1 i owner_signal + cycle_begin "$pid" attached "$2" + fm_recovery_marker_handover_snapshot "$STATE/.watcher-down" || return 1 + if attached_holder_live "$pid"; then + kill -TERM "$pid" 2>/dev/null || true + fi + i=0 + while [ "$i" -lt 50 ] && fm_pid_alive "$pid"; do + sleep 0.1 + i=$((i + 1)) + done + if fm_pid_alive "$pid"; then + return 1 + fi + if cycle_delivered_reason; then + cycle_log_append unknown unknown taken-over-delivered-wake none + printf '%s\n' "$DELIVERED_REASON" + return 3 + fi + # Only the owner can wait on this watcher and distinguish our TERM from a + # self-exit that raced the stop. Give its post-wait ledger append a short bound. + i=0 + owner_signal= + while [ "$i" -lt 50 ]; do + owner_signal=$(awk -F '\t' -v arm="$take_over_arm_pid" -v watcher="$pid" ' + $1 == "arm_pid=" arm && $2 == "watcher_pid=" watcher { signal = $7 } + END { sub(/^signal=/, "", signal); print signal } + ' "$CYCLE_LOG" 2>/dev/null || true) + [ -z "$owner_signal" ] || break + sleep 0.02 + i=$((i + 1)) + done + if [ "$owner_signal" = TERM ]; then + fm_recovery_marker_handover_restore "$STATE/.watcher-down" \ + "$FM_RECOVERY_HANDOVER_TOKEN" "$FM_RECOVERY_HANDOVER_SEQ" || true + cycle_log_append unknown unknown taken-over none + else + cycle_log_append unknown unknown taken-over-unconfirmed-stop none + fi + return 0 +} + +TAKEN_OVER=0 +if [ "$mode" = take-over ]; then + mode=arm + if healthy_watcher \ + && [ "$(ps -o ppid= -p "$HEALTHY_PID" 2>/dev/null | tr -d ' ')" = "$take_over_arm_pid" ]; then + take_over_cycle "$HEALTHY_PID" "$HEALTHY_IDENTITY" + case $? in + 0) TAKEN_OVER=1 ;; + 3) exit 0 ;; + esac + fi +fi + # If a genuinely live+fresh watcher already holds the lock, do not start a second # one - attach to that cycle and wait until it ends so the harness notify fires # then, not as an immediate empty wake. (--restart skips this: it just stopped @@ -672,6 +757,7 @@ while :; do exit 1 fi cycle_mark_predecessor_successor "started:$child" + [ "$TAKEN_OVER" -eq 0 ] || cycle_mark_predecessor_successor "started:$child" "$ARM_PID" if [ -n "$handling_generation" ]; then echo "watcher: started pid=$child (beacon fresh) recovery-generation=$handling_generation" else diff --git a/docs/captain-hold-lifecycle.md b/docs/captain-hold-lifecycle.md index 360e0925c4a..ef04ee6a9e3 100644 --- a/docs/captain-hold-lifecycle.md +++ b/docs/captain-hold-lifecycle.md @@ -51,8 +51,9 @@ It works in this order: 1. It uses an existing task, or creates one when nothing exists to hold. 2. It records the task's UTC hold-set timestamp as the leading line of the task body. -3. It invokes the underlying tasks-axi hold operation. -4. It verifies both records. +3. When `--origin` is supplied, it records the origin on the task, replacing any previous association. +4. It invokes the underlying tasks-axi hold operation. +5. It verifies the hold and timestamp. Publishing the stamp first ensures a snapshot cannot observe a newly captain-held task without the timestamp that defines its age. @@ -62,6 +63,10 @@ Repeat and edge cases: - Re-holding released work starts a new timestamped lifecycle. - A closed task is refused rather than reopened. - `--until` stores the captain's own deferral date through tasks-axi's date gate. +- Before the backend hold runs, `--origin` records the origin the call is held for on its own `Captain hold origin:` body line, which `complete` and `verify` check using backend identities rather than alias spellings. + If that write fails, the backend hold is not attempted. +- The reason may contain parentheses, semicolons, quotes, and line breaks. + [`bin/fm-hold-reason-lib.sh`](../bin/fm-hold-reason-lib.sh) owns the storage encoding and compatibility rules; [`bin/fm-tasks-axi.sh --help`](../bin/fm-tasks-axi.sh) owns the public read commands and output contract. ### Answering a call (`answer`) @@ -103,6 +108,10 @@ A post-teardown visual review can complete against the surviving report and dura `complete` accepts `--none` as an explicit semantic inventory result. `--none` is refused while the origin still has a lifecycle-open keyed status decision. Before recording completion, `complete` verifies every listed task against tasks-axi. +The origin is never its own inventory entry, so a hold that failed cannot be vouched for by the origin row. +For a historical inventory that names its own origin, hold a separate captain task with `--origin`, replace only the invalid entry in the final `decision_keys=` line of the origin metadata with that task id while preserving all other entries, and re-run `complete`. +An entry whose recorded origin differs from the one being completed is refused. +An entry with no recorded origin, such as a hold made before origins were recorded or without `--origin`, is accepted on the durability check alone and named in the output. With a non-empty inventory, `complete` appends a `captain-held [key=]` transfer event for every still-open keyed status decision. The event names the reviewed inventory. @@ -114,7 +123,7 @@ Scout teardown calls the read-only `verify` subcommand after checking for the re `verify` checks three things: - The recorded attestation exists. -- Every recorded inventory entry is still durable: actively captain-held, or carrying a recorded answer. +- Every recorded inventory entry still passes the [completion inventory checks](#recording-a-reviewed-inventory-complete). - No keyed status decision opened after the last `complete`. A keyed status decision opened after the last `complete` makes `verify` fail, and re-running `complete` is the repair. @@ -479,7 +488,7 @@ It then finishes any still-recorded dependency-edge cleanup without rewriting th ## Verification record -The focused end-to-end regression suite is `tests/fm-captain-hold-lifecycle.test.sh`, using only synthetic `sample` identities and decision text. +The focused end-to-end regression suite is `tests/fm-captain-hold-lifecycle.test.sh`, using only synthetic identities and decision text. It proves the behaviors below. The suite does not test the accepted merge-to-cleanup re-hold window or asynchronous queued-forge landing because those events occur after the locally serialized merge command has returned. @@ -528,7 +537,8 @@ The suite does not test the accepted merge-to-cleanup re-hold window or asynchro ### Legacy paths -- Every legacy path works: composed identities through the shim, pre-collapse `decision_keys=` metadata, routed-resolution replay, and a concrete-origin binding. +- Composed identities through the shim, valid pre-collapse `decision_keys=` inventories, routed-resolution replay, and a concrete-origin binding remain supported. + Historical self-inventories require the [documented repair](#recording-a-reviewed-inventory-complete). ### Task-body read-back cases diff --git a/docs/configuration.md b/docs/configuration.md index d2c6d9fee22..c816aec0f74 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -806,6 +806,7 @@ The Kimi installer requires an existing regular non-symlink `~/.kimi-code/config Its `remove` action excises only the marker-delimited Firstmate region and removes Firstmate's hook files. For Pi and pi-signed secondmate launches, `fm-spawn.sh` starts the selected executable 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. +Pi-family secondmates can start unattended in Firstmate-seeded homes without accepting project trust manually; [`fm-spawn.sh --help`](../bin/fm-spawn.sh) owns the capability requirement, session-only approval scope, and older-version fallback, with [regression evidence](verification/runtime-backends.md#pi-seeded-secondmate-project-trust). For omp secondmate launches, `fm-spawn.sh` passes no `-e` at all: omp auto-discovers the home's tracked `.omp/extensions/` with no trust gate, and naming a discovered file with `-e` as well loads it twice; every omp launch instead carries the tracked `.omp/fm-worker-overlay.yml` posture overlay through `--config`, which [`fm-spawn.sh --help`](../bin/fm-spawn.sh) owns. diff --git a/docs/fm-test-portable-shards.md b/docs/fm-test-portable-shards.md index 1e152cda2e9..63b670792f8 100644 --- a/docs/fm-test-portable-shards.md +++ b/docs/fm-test-portable-shards.md @@ -105,11 +105,11 @@ Portable shards, each portable serial shard, and the Herdr lane upload runner-ge ## Lint partitions and end-to-end latency -`bin/fm-lint.sh` owns two canonical CI partitions, each running full source-aware ShellCheck analysis, workflow validation, and backend-purity checks. -CI requires its per-root bounds, so an unenforceable deadline or address-space limit refuses lint rather than running uncapped; the script header owns the envelope and per-root execution contract. -Its `--list-files` interface exposes partition membership; `tests/fm-lint.test.sh` verifies complete/disjoint executed roots and unchanged analysis flags. +`bin/fm-lint.sh` owns two canonical CI partitions, each attempting full source-aware ShellCheck analysis and running workflow validation and backend-purity checks. +CI requires its per-root bounds, so an unenforceable deadline or address-space limit refuses lint rather than running uncapped; the script header owns the envelope, per-root execution contract, and memory fallback. +Its `--list-files` interface exposes partition membership; `tests/fm-lint.test.sh` verifies complete/disjoint executed roots, initial analysis flags, and fallback reporting. The workflow uploads each partition's quiet telemetry plus its per-root lifecycle sidecar to distinguish analysis cost, memory use, and host contention. -No fast mode, path skips, reduced checks, or paid runner provisioning is part of this layout. +No fast mode, path skips, or paid runner provisioning is part of this layout. The longer-term performance objective remains a complete green run under fifteen minutes including start delay, but the current watch-triage floor alone exceeds that objective. The immediate packing target is the runner's modeled script budget, not a claim that more shards alone can make an indivisible script faster. diff --git a/docs/supervision-host.md b/docs/supervision-host.md index 6faad8fb2b1..1f693b69935 100644 --- a/docs/supervision-host.md +++ b/docs/supervision-host.md @@ -109,7 +109,9 @@ The host asks the Pi branch's offer rule (`branchOfferForWake`, through `bin/fm- So a close reaches main off Pi exactly when it would on Pi: a check trigger, a decision-owned signal or stale trigger, and a scan that is unsafe or holds nothing for the branch stay main's. On that main-only pass-through the host starts the successor watcher cycle and leaves it running, then prints the close unchanged. It leaves the watcher's recovery marker reading downtime, confirming no handling handoff, because the re-arm owner delivers a close to main only while that marker reads downtime. -The watcher's singleton lock makes the session's next arm attach to that cycle instead of starting a second one. +The session's next park without `--restart` requests a take-over to restore a single host-owned arm; the [host header](../bin/fm-supervision-host.sh) owns successor persistence and cleanup, and the [arm header](../bin/fm-watch-arm.sh) owns take-over eligibility and fallback. +OpenCode and omp still launch the host with `--restart`, which takes precedence over recorded take-over and lacks its acknowledgement-preserving handover; changing that first-cycle path remains a follow-up. +The host-off Claude Stop hook's detached handling successor is also unchanged; see [Claude handling successor](watcher-continuity.md#claude-handling-successor). It also passes the close through unchanged, with no added line, when any of these holds (`fm_supervision_host_attended_ready` in `bin/fm-supervision-engine-lib.sh` owns the list): - The home names no usable engine. @@ -225,7 +227,10 @@ The captain row is still durable, and the next drain presents it until it is ack ## Failure direction Every path that cannot finish a wake the engine took hands that wake to main, with one `supervision-host: ` line after the close. -Before handing it back, the host stops its successor cycle. +Before handing it back, the host stops its successor cycle, and whenever a successor generation was recorded (confirmed or not), it explicitly republishes downtime for that generation. +That publication is required even when the successor already exited, because no watcher cleanup remains to make the close deliverable to the arm owner. +If that publication fails, the hand-back adds a `supervision-host: watcher downtime could not be restored` line and the host exits nonzero. +On Claude, a Stop hook whose rewake is refused while the recovery marker is still `pending:handling` and no watcher is live commits the auto-arm failure notice once per failure episode (`failed-suppressed` after that) and still exits 2, so the hand-back reaches main; every other refused rewake stays silent as before. So the owner's next arm starts from the same state as without the host, and the wake stays durable in the queue. ### Paths that hand the wake back diff --git a/docs/verification/runtime-backends.md b/docs/verification/runtime-backends.md index 1cb48c25e14..7bc3776d49f 100644 --- a/docs/verification/runtime-backends.md +++ b/docs/verification/runtime-backends.md @@ -508,6 +508,27 @@ The lab home was deleted and the test entry was removed from the store and verif That automated spawn case runs against a fake claude, so it asserts the store entry and the launch command and nothing more; the live arms above are what establish that the entry actually suppresses the dialog. The composer-classification record below observes the same gate from the other side, where an untrusted worktree left Claude, Grok, and Muse unverified because the guard reads a first-launch trust dialog as an unreadable composer. +## Pi seeded-secondmate project trust + +[`fm-spawn.sh --help`](../../bin/fm-spawn.sh) owns the seeded-secondmate project-trust approval contract and compatibility fallback. +The live guard below isolates Pi's trust-gate behavior in secondmate-shaped homes; portable launch-command coverage separately verifies that spawn selects the flag for the intended launches. + +Verified 2026-10-02 on pi 0.82.0 through the default-on live guard (disposable `PI_CODING_AGENT_DIR` / `HOME` only; never `~/.pi`): + +```sh +bash tests/fm-pi-seeded-home-trust-live-e2e.test.sh +``` + +``` +# live pi version: 0.82.0 +ok - fresh seeded Pi secondmate-shaped home stalls on Trust project folder? without --approve +ok - seeded home with --approve starts past the trust dialog without rewriting trust.json +ok - unseeded path without --approve still prompts on Trust project folder? +# all fm-pi-seeded-home-trust-live-e2e checks passed (3) +``` + +Portable launch-command coverage lives in `tests/fm-spawn-dispatch-profile.test.sh` (`test_pi_seeded_secondmate_preapproves_project_trust`, `test_pi_worker_launch_omits_seeded_home_approve`, `test_pi_approve_probe_omits_unsupported_flag`). + ## Launch-prompt backstop signatures `bin/fm-busy-lib.sh`'s launch-prompt backstop (`fm_busy_launch_prompt_parked`) reclassifies a launch whose busy record is still pinned at the fm-spawn seed as `unknown launch-prompt`, rather than `busy fm-spawn`, when the captured pane matches that harness's own recognized trust, sign-in, or first-run dialog. diff --git a/docs/watcher-continuity.md b/docs/watcher-continuity.md index c2641b368ba..9d9e19dadd5 100644 --- a/docs/watcher-continuity.md +++ b/docs/watcher-continuity.md @@ -121,7 +121,7 @@ The Claude turn-end guard owns that notice commit contract, the monotonic failur On a non-Pi primary, a home that runs the supervision host runs `bin/fm-supervision-host.sh` in place of the arm its re-arm owner would start. The host owns successive watcher cycles through the same arm. -The host's successor and pass-through lifecycle is owned by [supervision-host.md](supervision-host.md#postures); the arm's recovery and acknowledgement contracts below still apply. +[supervision-host.md](supervision-host.md#failure-direction) owns the hand-back's downtime restoration, including when the successor already exited; the arm's recovery and acknowledgement contracts below still apply. ## Actionable wake ordering @@ -226,6 +226,9 @@ A downtime republication of a pending episode reuses its generation. A watcher close leaves an announced downtime episode announced, while a successful durable append opens a fresh pending generation so a live watcher can recover the new work. An announced handling episode becomes pending downtime on the same generation because its handling turn may have been interrupted. That handling republication gives a successor exactly one recovery presentation without orphaning the acknowledgement already printed for that generation. +A watcher stopped so an arm can take its cycle over (`bin/fm-watch-arm.sh --take-over`) publishes downtime like any close, but the taking arm restores an acknowledged episode that stop reopened only when the taken-over arm's cycle-ledger row for that exact arm and watcher records the watcher ending by the take-over's TERM and no wake was appended in between. +The taking arm waits within a short bound for that row; a missing row or any other signal leaves downtime for the fresh cycle's ordinary recovery wake, while take-over still proceeds. +Any other episode is left for the next cycle's arm check. ### What an acknowledgement retires @@ -441,6 +444,7 @@ They also prove that a legacy or handoff-phase watcher marker from an absent rep - A watcher close inside the handling window that must leave the printed acknowledgement valid. - A re-arm whose recovery cycle is slowed after confirmation and must still surface rather than read as a watcher that stayed live. - The self-healing moved-generation acknowledgement that consumes its handled rows and names its remedy. +- A take-over that stays quiet after a confirmed TERM, still surfaces queued work and self-exit downtime, and attaches without stopping a cycle the named arm does not own. - The disposable-checkout arm refusal. - The home-gone and state-gone watcher exits. - The test reaper that stops a watcher armed for a temporary home. diff --git a/tests/fm-afk-return.test.sh b/tests/fm-afk-return.test.sh index b66ead8c0da..fd589be2965 100755 --- a/tests/fm-afk-return.test.sh +++ b/tests/fm-afk-return.test.sh @@ -35,6 +35,7 @@ install_runner() { # cp "$ROOT/bin/fm-afk-contract.sh" "$dir/bin/" cp "$ROOT/bin/fm-branch-outcome.sh" "$dir/bin/" cp "$ROOT/bin/fm-tasks-axi-lib.sh" "$dir/bin/" + cp "$ROOT/bin/fm-hold-reason-lib.sh" "$dir/bin/" cp "$ROOT/bin/fm-backlog-transition-lib.sh" "$dir/bin/" # The merge-notification marker reader behind the brief's landed section. cp "$ROOT/bin/fm-pr-lib.sh" "$dir/bin/" diff --git a/tests/fm-backend-herdr.test.sh b/tests/fm-backend-herdr.test.sh index bdfaabc2375..0911e981bcc 100755 --- a/tests/fm-backend-herdr.test.sh +++ b/tests/fm-backend-herdr.test.sh @@ -314,7 +314,13 @@ test_version_check_refuses_old_protocol() { test_version_check_refuses_missing_herdr() { local dir out status dir="$TMP_ROOT/version-missing"; mkdir -p "$dir/empty-fakebin" - out=$( PATH="$dir/empty-fakebin:/usr/bin:/bin" \ + # Hermetic PATH: the fakebin carries only bash (so the inner `bash -c` + # still resolves) and no system dir, so a real herdr installed under + # /usr/bin (or /bin -> usr/bin) cannot leak into this "not installed" + # simulation. fm_backend_herdr_tool_check needs no external tool on this + # path: `command -v` is a builtin and it short-circuits on herdr first. + ln -sf "$(command -v bash)" "$dir/empty-fakebin/bash" + out=$( PATH="$dir/empty-fakebin" \ bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_version_check' "$ROOT" 2>&1 ) status=$? [ "$status" -ne 0 ] || fail "version_check should refuse when herdr is not installed" @@ -5041,6 +5047,37 @@ test_send_text_submit_claude_slash_popup_composer_is_still_proven_and_submitted( pass "fm_backend_herdr_send_text_submit: a typed slash command hidden behind its popup is still proven and submitted" } +# Live Claude Code 2.1.283 draws a recognized typed slash command in muted +# truecolor grey (38;2;112;112;112, luminance 112), below the grok-tuned +# dark-foreground ghost threshold. Claude's own ghost suggestion is SGR-2 dim, +# so the Claude payload proof must not strip the grey command and judge the +# typed /exit unsent (the fm-control exit breakage, reproduced live). +test_send_text_submit_claude_grey_slash_command_is_proven_and_submitted() { + local dir log resp fb out enter_count text rule head + dir="$TMP_ROOT/submit-claude-grey-slash"; mkdir -p "$dir/responses"; log="$dir/log"; resp="$dir/responses"; : > "$log" + text='/exit' + herdr_submit_claude_prefix "$resp" "$text" + rule=$(printf '%0.s\xe2\x94\x80' $(seq 1 60)) + head=$(printf '%0.s\xe2\x94\x80' $(seq 1 19)) + { + printf ' \x1b[0m\x1b[38;2;112;112;112m/\x1b[0m\x1b[1m\x1b[38;2;112;112;112mexit\x1b[0m\x1b[38;2;112;112;112m Exit the CLI\x1b[0m\n' + printf '\x1b[0m\x1b[38;2;121;129;134m%s Firstmate operational input 1790546042 \xe2\x94\x80\x1b[0m\n' "$head" + printf '\xe2\x9d\xaf\xc2\xa0\x1b[0m\x1b[38;2;112;112;112m/exit\x1b[0m\n' + printf '\x1b[0m\x1b[38;2;121;129;134m%s\x1b[0m\n' "$rule" + printf ' \x1b[0m\x1b[38;2;86;93;96m\xe2\x8f\xb5\xe2\x8f\xb5 bypass permissions on\x1b[0m\n' + } > "$resp/4.out" + printf '{"result":{"agent":{"agent":"claude","agent_status":"idle"}}}\n' > "$resp/5.out" + printf '{"result":{"agent":{"agent_status":"working"}}}\n' > "$resp/7.out" + fb=$(make_herdr_fakebin "$dir") + out=$( PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" FM_BACKEND_HERDR_SUBMIT_POLLS=1 \ + bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_send_text_submit default:w1:p2 "$1" 3 0.01 0.01' "$ROOT" "$text" ) + [ "$out" = empty ] || fail "a typed /exit drawn in Claude's grey slash-command colour must be proven and submitted, got '$out'" + enter_count=$(grep -c $'\x1f''pane'$'\x1f''send-keys'$'\x1f''w1:p2'$'\x1f''enter' "$log") + [ "$enter_count" -eq 1 ] || fail "the proven grey slash command should be submitted once, sent $enter_count Enter(s)" + [ "$(herdr_ctrl_u_count "$log")" -eq 0 ] || fail "a proven grey slash command must not be cleared" + pass "fm_backend_herdr_send_text_submit: a typed slash command Claude draws in muted truecolor grey is proven and submitted" +} + test_send_text_submit_lone_paste_placeholder_submits_the_long_payload() { local dir log resp fb out enter_count text dir="$TMP_ROOT/submit-paste-placeholder"; mkdir -p "$dir/responses"; log="$dir/log"; resp="$dir/responses"; : > "$log" @@ -5945,6 +5982,7 @@ test_send_text_submit_accepts_marked_payloads_whose_read_back_drops_u2063 test_send_text_submit_refuses_marked_digest_missing_its_head test_composer_state_claude_slash_popup_pushes_composer_above_tail_window test_send_text_submit_claude_slash_popup_composer_is_still_proven_and_submitted +test_send_text_submit_claude_grey_slash_command_is_proven_and_submitted test_send_text_submit_lone_paste_placeholder_submits_the_long_payload test_send_text_submit_multiline_paste_placeholder_submits_the_long_payload test_send_text_submit_refuses_placeholder_followed_by_a_literal_remainder diff --git a/tests/fm-calm-pi-extension.test.sh b/tests/fm-calm-pi-extension.test.sh index 9288562ee27..940f7bfb6aa 100755 --- a/tests/fm-calm-pi-extension.test.sh +++ b/tests/fm-calm-pi-extension.test.sh @@ -53,6 +53,17 @@ wait_for_text() { return 1 } +# Pi 1.0.0 defaults its TUI to a fullscreen alternate-screen mode whose scrollable +# transcript is application-owned: rows that leave the viewport stay reachable +# through Pi's own scroll keys but never enter terminal scrollback, so +# tmux capture-pane -S can no longer see them. Transcript assertions below need +# real terminal scrollback, so each launch pins the regular TUI mode wherever the +# flag exists; versions without the flag retain their existing launch arguments. +PI_TUI_MODE_ARGS= +if pi --help 2>&1 | grep -q -- '--tui-mode'; then + PI_TUI_MODE_ARGS='--tui-mode regular' +fi + find_chrome() { local candidate if [ -n "${FM_CHROME_BIN:-}" ] && [ -x "$FM_CHROME_BIN" ]; then @@ -2289,7 +2300,7 @@ TS fi tmux -L "$TMUX_SOCKET" new-session -d -s "$TMUX_SESSION" -x 160 -y 36 \ - "cd '$project' && env FM_HOME='$home' PI_CODING_AGENT_DIR='$config' FM_OPERATIONAL_INPUT_SCRIPT='$OPERATIONAL_INPUT' PI_OFFLINE=1 pi --approve --no-context-files --no-skills --no-prompt-templates --no-extensions $extensions $session_arg; rc=\$?; printf '\nPI_EXIT=%s\n' \"\$rc\"; sleep 20" + "cd '$project' && env FM_HOME='$home' PI_CODING_AGENT_DIR='$config' FM_OPERATIONAL_INPUT_SCRIPT='$OPERATIONAL_INPUT' PI_OFFLINE=1 pi $PI_TUI_MODE_ARGS --approve --no-context-files --no-skills --no-prompt-templates --no-extensions $extensions $session_arg; rc=\$?; printf '\nPI_EXIT=%s\n' \"\$rc\"; sleep 20" i=0 while [ "$i" -lt 120 ]; do pane=$(tmux -L "$TMUX_SOCKET" capture-pane -p -t "$TMUX_SESSION" -S - 2>/dev/null || true) @@ -2428,7 +2439,7 @@ JS tmux -L "$TMUX_SOCKET" kill-session -t "$TMUX_SESSION" 2>/dev/null || true printf '%s\n' on >"$home/config/calm" tmux -L "$TMUX_SOCKET" new-session -d -s "$TMUX_SESSION" -x 160 -y 36 \ - "cd '$project' && env FM_HOME='$home' PI_CODING_AGENT_DIR='$config' FM_OPERATIONAL_INPUT_SCRIPT='$OPERATIONAL_INPUT' PI_OFFLINE=1 pi --approve --no-context-files --no-skills --no-prompt-templates --no-extensions -e ./.pi/extensions/fm-calm.ts -e ./followup-e2e.ts --session '$exact_session'; rc=\$?; printf '\nPI_EXIT=%s\n' \"\$rc\"; sleep 20" + "cd '$project' && env FM_HOME='$home' PI_CODING_AGENT_DIR='$config' FM_OPERATIONAL_INPUT_SCRIPT='$OPERATIONAL_INPUT' PI_OFFLINE=1 pi $PI_TUI_MODE_ARGS --approve --no-context-files --no-skills --no-prompt-templates --no-extensions -e ./.pi/extensions/fm-calm.ts -e ./followup-e2e.ts --session '$exact_session'; rc=\$?; printf '\nPI_EXIT=%s\n' \"\$rc\"; sleep 20" i=0 while [ "$i" -lt 120 ]; do pane=$(tmux -L "$TMUX_SOCKET" capture-pane -p -t "$TMUX_SESSION" -S - 2>/dev/null || true) @@ -2599,7 +2610,7 @@ TS printf '%s\n' "$calm_state" >"$home/config/calm" mkdir -p "$sessions/$label" tmux -L "$TMUX_SOCKET" new-session -d -s "$TMUX_SESSION" -x 160 -y 36 \ - "cd '$project' && env FM_HOME='$home' PI_CODING_AGENT_DIR='$config' FM_OPERATIONAL_INPUT_SCRIPT='$OPERATIONAL_INPUT' QUEUED_ESCAPE_HELD='$held' QUEUED_ESCAPE_STATUS_LOG='$sessions/$label/status.log' PI_OFFLINE=1 pi --approve --no-context-files --no-skills --no-prompt-templates --no-extensions -e ./.pi/extensions/fm-calm.ts -e ./queued-escape-e2e.ts --session-dir '$sessions/$label'; rc=\$?; printf '\nPI_EXIT=%s\n' \"\$rc\"; sleep 20" + "cd '$project' && env FM_HOME='$home' PI_CODING_AGENT_DIR='$config' FM_OPERATIONAL_INPUT_SCRIPT='$OPERATIONAL_INPUT' QUEUED_ESCAPE_HELD='$held' QUEUED_ESCAPE_STATUS_LOG='$sessions/$label/status.log' PI_OFFLINE=1 pi $PI_TUI_MODE_ARGS --approve --no-context-files --no-skills --no-prompt-templates --no-extensions -e ./.pi/extensions/fm-calm.ts -e ./queued-escape-e2e.ts --session-dir '$sessions/$label'; rc=\$?; printf '\nPI_EXIT=%s\n' \"\$rc\"; sleep 20" wait_for_text "$TMP_ROOT/queued-escape-pane" 'queued-escape-e2e.ts' \ || fail "Pi queued-row $label case did not reach the ready composer" tmux -L "$TMUX_SOCKET" send-keys -t "$TMUX_SESSION" -l "/queued-escape-e2e $label" @@ -2798,7 +2809,7 @@ TS local session_arg=$1 tmux -L "$TMUX_SOCKET" kill-session -t "$TMUX_SESSION" 2>/dev/null || true tmux -L "$TMUX_SOCKET" new-session -d -s "$TMUX_SESSION" -x 100 -y 44 \ - "cd '$project' && env FM_HOME='$home' PI_CODING_AGENT_DIR='$config' PI_OFFLINE=1 pi --approve --no-context-files --no-prompt-templates --no-extensions -e ./.pi/extensions/fm-calm.ts -e ./geometry-provider.ts $session_arg; rc=\$?; printf '\nPI_EXIT=%s\n' \"\$rc\"; sleep 20" + "cd '$project' && env FM_HOME='$home' PI_CODING_AGENT_DIR='$config' PI_OFFLINE=1 pi $PI_TUI_MODE_ARGS --approve --no-context-files --no-prompt-templates --no-extensions -e ./.pi/extensions/fm-calm.ts -e ./geometry-provider.ts $session_arg; rc=\$?; printf '\nPI_EXIT=%s\n' \"\$rc\"; sleep 20" } capture_geometry_viewport() { @@ -4193,7 +4204,7 @@ TS JSON tmux -L "$TMUX_SOCKET" new-session -d -s "$TMUX_SESSION" -x 180 -y 44 \ - "cd '$project' && env FM_HOME='$home' PI_CODING_AGENT_DIR='$config' FM_OPERATIONAL_INPUT_SCRIPT='$OPERATIONAL_INPUT' PI_OFFLINE=1 pi --approve --no-skills --no-prompt-templates --no-context-files --session '$session_file'; rc=\$?; printf '\nPI_EXIT=%s\n' \"\$rc\"; sleep 30" + "cd '$project' && env FM_HOME='$home' PI_CODING_AGENT_DIR='$config' FM_OPERATIONAL_INPUT_SCRIPT='$OPERATIONAL_INPUT' PI_OFFLINE=1 pi $PI_TUI_MODE_ARGS --approve --no-skills --no-prompt-templates --no-context-files --session '$session_file'; rc=\$?; printf '\nPI_EXIT=%s\n' \"\$rc\"; sleep 30" wait_for_text "$default_snapshot" "The deterministic tool example is complete." \ || fail "Pi calm E2E did not reach the restored session transcript" assert_contains "$(cat "$default_snapshot")" "CALM_E2E_OUTPUT" "calm mode was not off by default" @@ -4866,7 +4877,7 @@ JS tmux -L "$TMUX_SOCKET" kill-session -t "$TMUX_SESSION" 2>/dev/null || true tmux -L "$TMUX_SOCKET" new-session -d -s "$TMUX_SESSION" -x 180 -y 44 \ - "cd '$project' && env FM_HOME='$home' PI_CODING_AGENT_DIR='$config' FM_OPERATIONAL_INPUT_SCRIPT='$OPERATIONAL_INPUT' PI_OFFLINE=1 pi --approve --no-skills --no-prompt-templates --no-context-files --session '$session_file'; rc=\$?; printf '\nPI_EXIT=%s\n' \"\$rc\"; sleep 30" + "cd '$project' && env FM_HOME='$home' PI_CODING_AGENT_DIR='$config' FM_OPERATIONAL_INPUT_SCRIPT='$OPERATIONAL_INPUT' PI_OFFLINE=1 pi $PI_TUI_MODE_ARGS --approve --no-skills --no-prompt-templates --no-context-files --session '$session_file'; rc=\$?; printf '\nPI_EXIT=%s\n' \"\$rc\"; sleep 30" wait_for_text "$restarted_snapshot" "CALM_WORKING_E2E_RESPONSE" \ || fail "Pi did not restore the persisted session after restart" assert_not_contains "$(cat "$restarted_snapshot")" "CALM_E2E_OUTPUT" "restart/resume reset Calm and restored a tool row" diff --git a/tests/fm-captain-hold-lifecycle.test.sh b/tests/fm-captain-hold-lifecycle.test.sh index 4343725f200..301878b3dd4 100755 --- a/tests/fm-captain-hold-lifecycle.test.sh +++ b/tests/fm-captain-hold-lifecycle.test.sh @@ -88,6 +88,15 @@ run_captain() { # FM_CONFIG_OVERRIDE="$home/config" "$ROOT/bin/fm-captain-hold.sh" "$@" } +# Completes 's captain-call inventory through a separate held task, because +# the origin task is never accepted as its own inventory entry. +complete_through_sibling() { # + local home=$1 id=$2 + run_captain "$home" hold "$id-call" --title "Sibling captain call for $id" \ + --reason "captain must decide the sibling call" --repo sample --origin "$id" >/dev/null \ + && run_captain "$home" complete "$id" "$id-call" +} + request_reconciles() { # ... local home=$1 source_id=$2 id shift 2 @@ -357,7 +366,7 @@ case "${1:-}" in show) case "${2:-}" in @KNOWN@) ;; - *) printf 'error: no task %s in this backlog\n' "${2:-}" >&2; exit 1 ;; + *) printf 'error: no task %s in this backlog\ncode: NOT_FOUND\n' "${2:-}" >&2; exit 1 ;; esac printf '%s\n' 'task:' printf ' id: %s\n' "$2" @@ -2575,7 +2584,7 @@ test_teardown_never_closes_a_captain_held_task() { run_captain "$home" hold "$id" \ --reason "captain must choose inline or by-reference attachments" >/dev/null \ || fail "could not hold the originating work item for the captain" - run_captain "$home" complete "$id" "$id" >/dev/null \ + complete_through_sibling "$home" "$id" >/dev/null \ || fail "completion gate failed with the origin as its own captain call" run_teardown "$home" "$id" > "$home/teardown.out" 2> "$home/teardown.err" \ @@ -2666,7 +2675,7 @@ test_retained_row_artifacts_survive_captain_answers() { > "$home/data/$retained_id/report.md" run_captain "$home" hold "$retained_id" --reason "captain must choose the report follow-up" \ >/dev/null || fail "could not hold the retained report" - run_captain "$home" complete "$retained_id" "$retained_id" >/dev/null \ + complete_through_sibling "$home" "$retained_id" >/dev/null \ || fail "completion gate failed for the retained report" run_teardown "$home" "$retained_id" > "$home/retained-teardown.out" \ 2> "$home/report-teardown.err" \ @@ -2687,7 +2696,7 @@ test_retained_row_artifacts_survive_captain_answers() { run_captain "$home" hold "$precedence_id" \ --reason "captain must choose the report follow-up" >/dev/null \ || fail "could not hold the report precedence fixture" - run_captain "$home" complete "$precedence_id" "$precedence_id" >/dev/null \ + complete_through_sibling "$home" "$precedence_id" >/dev/null \ || fail "completion gate failed for the report precedence fixture" run_teardown "$home" "$precedence_id" > "$home/precedence-teardown.out" \ 2> "$home/precedence-teardown.err" \ @@ -2815,7 +2824,7 @@ test_retained_row_artifacts_survive_captain_answers() { printf '# Released report\n' > "$home/data/$released_id/report.md" run_captain "$home" hold "$released_id" --reason "captain report release pending" \ >/dev/null || fail "could not hold the released report" - run_captain "$home" complete "$released_id" "$released_id" >/dev/null \ + complete_through_sibling "$home" "$released_id" >/dev/null \ || fail "completion gate failed for the released report" printf 'Release the completed report.\n' > "$home/released-answer.txt" run_captain "$home" answer "$released_id" --release \ @@ -2920,7 +2929,7 @@ test_interrupted_cleanup_keeps_the_captain_call_recoverable() { printf '# Failed cleanup\n\nThe captain call remains open.\n' > "$home/data/$id/report.md" run_captain "$home" hold "$id" --reason "captain must choose after cleanup retry" >/dev/null \ || fail "could not hold the cleanup-failure fixture" - run_captain "$home" complete "$id" "$id" >/dev/null \ + complete_through_sibling "$home" "$id" >/dev/null \ || fail "completion gate failed for the cleanup-failure fixture" cat > "$home/fakebin/treehouse" <<'SH' #!/usr/bin/env bash @@ -2979,7 +2988,7 @@ test_answer_before_cleanup_replay_preserves_the_retained_report() { printf '# Interrupted cleanup\n\nThe captain call remains open.\n' > "$home/data/$id/report.md" run_captain "$home" hold "$id" --reason "captain must choose after interrupted cleanup" \ >/dev/null || fail "could not hold the answer-before-replay fixture" - run_captain "$home" complete "$id" "$id" >/dev/null \ + complete_through_sibling "$home" "$id" >/dev/null \ || fail "completion gate failed for the answer-before-replay fixture" cat > "$home/fakebin/treehouse" <<'SH' #!/usr/bin/env bash @@ -3100,7 +3109,7 @@ test_unusable_pending_close_record_names_its_reason() { printf '# Unusable pending close\n\nThe captain call remains open.\n' > "$home/data/$id/report.md" run_captain "$home" hold "$id" --reason "captain must choose after interrupted cleanup" \ >/dev/null || fail "could not hold the unusable pending-close fixture" - run_captain "$home" complete "$id" "$id" >/dev/null \ + complete_through_sibling "$home" "$id" >/dev/null \ || fail "completion gate failed for the unusable pending-close fixture" cat > "$home/fakebin/treehouse" <<'SH' #!/usr/bin/env bash @@ -3164,7 +3173,12 @@ EOF || fail "could not hold the relocated answer-before-replay fixture" PATH="$home/fakebin:$PATH" FM_HOME="$home" FM_STATE_OVERRIDE="$home/state" \ FM_DATA_OVERRIDE="$data" FM_CONFIG_OVERRIDE="$home/config" \ - "$ROOT/bin/fm-captain-hold.sh" complete "$id" "$id" >/dev/null \ + "$ROOT/bin/fm-captain-hold.sh" hold "$id-call" --title "Sibling captain call" \ + --reason "captain must decide the sibling call" --repo sample --origin "$id" >/dev/null \ + || fail "could not hold the sibling captain call" + PATH="$home/fakebin:$PATH" FM_HOME="$home" FM_STATE_OVERRIDE="$home/state" \ + FM_DATA_OVERRIDE="$data" FM_CONFIG_OVERRIDE="$home/config" \ + "$ROOT/bin/fm-captain-hold.sh" complete "$id" "$id-call" >/dev/null \ || fail "completion gate failed for the relocated answer-before-replay fixture" cat > "$home/fakebin/treehouse" <<'SH' #!/usr/bin/env bash @@ -3241,7 +3255,12 @@ EOF || fail "could not hold the relocated work item" PATH="$home/fakebin:$PATH" FM_HOME="$home" FM_STATE_OVERRIDE="$home/state" \ FM_DATA_OVERRIDE="$data" FM_CONFIG_OVERRIDE="$home/config" \ - "$ROOT/bin/fm-captain-hold.sh" complete "$id" "$id" >/dev/null \ + "$ROOT/bin/fm-captain-hold.sh" hold "$id-call" --title "Sibling captain call" \ + --reason "captain must decide the sibling call" --repo sample --origin "$id" >/dev/null \ + || fail "could not hold the sibling captain call" + PATH="$home/fakebin:$PATH" FM_HOME="$home" FM_STATE_OVERRIDE="$home/state" \ + FM_DATA_OVERRIDE="$data" FM_CONFIG_OVERRIDE="$home/config" \ + "$ROOT/bin/fm-captain-hold.sh" complete "$id" "$id-call" >/dev/null \ || fail "completion gate failed for the relocated captain hold" PATH="$home/fakebin:$PATH" FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$home" \ @@ -4071,7 +4090,7 @@ PM > "$home/data/$scout/report.md" run_captain "$home" hold "$scout" --reason "captain must choose" >/dev/null \ || fail "could not hold the investigation for the captain" - run_captain "$home" complete "$scout" "$scout" >/dev/null \ + complete_through_sibling "$home" "$scout" >/dev/null \ || fail "the completion gate failed with the origin as its own captain call" PERL5LIB="$shim" PERL5OPT=-MFmNoNonrefDefault \ run_teardown "$home" "$scout" > "$home/nonref.out" 2> "$home/nonref.err" \ @@ -4109,7 +4128,7 @@ retain_row_with_body() { # || fail "could not give $id a body carrying non-ASCII characters" run_captain "$home" hold "$id" --reason "captain must choose" >/dev/null \ || fail "could not hold $id for the captain" - run_captain "$home" complete "$id" "$id" >/dev/null \ + complete_through_sibling "$home" "$id" >/dev/null \ || fail "the completion gate failed for $id" run_teardown "$home" "$id" > "$home/$id.out" 2> "$home/$id.err" \ || fail "cleanup of captain-held $id failed: $(cat "$home/$id.err")" @@ -4147,6 +4166,485 @@ test_retained_body_keeps_its_utf8_bytes() { pass "cleanup preserves every byte of a retained body's non-ASCII characters" } +# A refused hold must never read as a recorded one. The gate used to accept the +# origin as its own inventory whenever the origin row looked durable, so a hold +# that failed just before `complete ` left a satisfied gate +# with no captain call recorded. +test_origin_is_never_its_own_inventory_entry() { + local home id + home=$(make_home origin-self-inventory) + id=sample-self-review + mkdir -p "$home/data/$id" + tasks_in "$home" add "$id" "Investigate sample self review" --kind scout --repo sample --start >/dev/null \ + || fail "could not create the investigation fixture" + write_origin_meta "$home" "$id" + printf 'done: report complete\n' > "$home/state/$id.status" + if run_captain "$home" hold "$id" --reason "" >/dev/null 2> "$home/hold.err"; then + fail "hold accepted an empty reason" + fi + if run_captain "$home" complete "$id" "$id" > "$home/self.out" 2> "$home/self.err"; then + fail "complete accepted the origin as its own inventory after a failed hold" + fi + assert_grep "cannot be its own captain-call inventory entry" "$home/self.err" \ + "the refusal does not say why the origin was rejected" + assert_no_grep "decisions_reviewed=1" "$home/state/$id.meta" \ + "the refused completion recorded an inventory attestation" + + # Holding the origin row itself must not let it vouch for itself either. + run_captain "$home" hold "$id" --reason "captain must choose" >/dev/null \ + || fail "could not hold the origin row" + if run_captain "$home" complete "$id" "$id" > "$home/held.out" 2> "$home/held.err"; then + fail "complete accepted a held origin row as its own inventory" + fi + pass "complete refuses the origin as its own captain-call inventory" +} + +# `hold --origin` records which origin a call was held for, and `complete` +# refuses a task held for a different origin. A hold recorded before that +# record existed, or without --origin, still verifies and is flagged. +test_complete_refuses_an_entry_held_for_another_origin() { + local home id other o out + home=$(make_home origin-mismatch) + id=sample-first-review + other=sample-second-review + for o in "$id" "$other"; do + mkdir -p "$home/data/$o" + tasks_in "$home" add "$o" "Investigate $o" --kind scout --repo sample --start >/dev/null \ + || fail "could not create the $o fixture" + write_origin_meta "$home" "$o" + printf 'done: report complete\n' > "$home/state/$o.status" + done + run_captain "$home" hold sample-other-call --title "Call for the second review" \ + --reason "captain must decide" --repo sample --origin "$other" >/dev/null \ + || fail "could not hold the call recorded for the second review" + if run_captain "$home" complete "$id" sample-other-call > "$home/mismatch.out" 2> "$home/mismatch.err"; then + fail "complete accepted an entry held for a different origin" + fi + assert_grep "was held for origin $other, not $id" "$home/mismatch.err" \ + "the refusal does not name both origins" + assert_no_grep "decisions_reviewed=1" "$home/state/$id.meta" \ + "the refused completion recorded an inventory attestation" + + run_captain "$home" hold sample-own-call --title "Call for the first review" \ + --reason "captain must decide" --repo sample --origin "$id" >/dev/null \ + || fail "could not hold the call recorded for the first review" + out=$(run_captain "$home" complete "$id" sample-own-call) \ + || fail "complete refused an entry held for its own origin" + assert_not_contains "$out" "no recorded origin" \ + "an entry with a recorded origin was flagged as unrecorded" + + tasks_in "$home" add sample-old-call "Call held before origins were recorded" --kind captain --repo sample >/dev/null \ + || fail "could not create the older call" + tasks_in "$home" hold sample-old-call --reason "captain must decide" --kind captain >/dev/null \ + || fail "could not hold the older call" + out=$(run_captain "$home" complete "$other" sample-old-call) \ + || fail "complete refused an older hold with no recorded origin" + assert_contains "$out" "no recorded origin on: sample-old-call" \ + "an older hold with no recorded origin was not flagged" + pass "complete refuses an entry held for another origin and flags one with none recorded" +} + +test_hold_origins_precede_backend_holds() { + local home phase timing failure id shown origin until_args=() + for phase in new active released; do + for timing in plain dated; do + home=$(make_home "origin-failure-$phase-$timing") + id=sample-call + for origin in origin-a origin-b; do + tasks_in "$home" add "$origin" "Review $origin" --kind scout --repo sample >/dev/null \ + || fail "could not create $origin" + write_origin_meta "$home" "$origin" + done + if [ "$phase" != new ]; then + run_captain "$home" hold "$id" --title "Separate call" --reason "Choose for A" \ + --origin origin-a >/dev/null || fail "could not establish the original association" + fi + if [ "$phase" = released ]; then + printf 'Release this work.\n' > "$home/answer.txt" + run_captain "$home" answer "$id" --release --decision-file "$home/answer.txt" >/dev/null \ + || fail "could not release the original hold" + fi + cat > "$home/fakebin/tasks-axi" <<'SH' +#!/usr/bin/env bash +if [ "${1:-}" = show ] && [ "${2:-}" = origin-b ] && [ -f "$FM_HOME/fail-lookup" ]; then + : > "$FM_HOME/lookup-refused" + printf 'error: origin read failed\ncode: READ_FAILED\n' >&2 + exit 2 +fi +if [ "${1:-}" = update ] && [ -f "$FM_HOME/fail-write" ]; then + previous='' + for arg in "$@"; do + if [ "$previous" = --body-file ] && grep -qx 'Captain hold origin: origin-b' "$arg"; then + : > "$FM_HOME/write-refused" + exit 9 + fi + previous=$arg + done +fi +if [ "${1:-}" = hold ] && [ "${2:-}" != --help ]; then + "$REAL_TASKS_AXI" show "$2" --full > "$FM_HOME/before-backend-hold" || exit $? + if [ -f "$FM_HOME/fail-hold" ]; then + : > "$FM_HOME/hold-refused" + exit 9 + fi +fi +exec "$REAL_TASKS_AXI" "$@" +SH + chmod +x "$home/fakebin/tasks-axi" + until_args=() + [ "$timing" != dated ] || until_args=(--until 2099-01-01) + for failure in lookup write hold; do + : > "$home/fail-$failure" + if run_captain "$home" hold "$id" --title "Separate call" --reason "Choose for B" \ + --origin origin-b ${until_args[@]+"${until_args[@]}"} > "$home/hold.out" 2> "$home/hold.err"; then + fail "$phase $timing hold succeeded despite an origin $failure failure" + fi + assert_present "$home/$failure-refused" "the failure did not reach the origin $failure" + if [ "$failure" = hold ]; then + assert_present "$home/before-backend-hold" "$phase $timing failure never reached the backend hold" + assert_grep 'Captain hold origin: origin-b' "$home/before-backend-hold" \ + "the failed backend hold did not see the new association" + rm "$home/before-backend-hold" + else + assert_absent "$home/before-backend-hold" "$phase $timing origin $failure failure reached the backend hold" + fi + shown=$(tasks_in "$home" show "$id" --full) + assert_not_contains "$shown" 'Captain hold origin: origin-b' \ + "$phase $timing origin $failure failure published the new association" + if [ "$phase" = active ]; then + assert_contains "$shown" 'held: yes' "an origin $failure failure lifted an existing hold" + else + assert_contains "$shown" 'held: no' "$phase $timing origin $failure failure left the task held" + fi + if [ "$phase" != new ]; then + assert_contains "$shown" 'Captain hold origin: origin-a' \ + "$phase $timing origin $failure failure lost the original association" + fi + rm "$home/fail-$failure" + if [ "$phase" = new ] && run_captain "$home" complete origin-a "$id" \ + > "$home/unrelated.out" 2> "$home/unrelated.err"; then + fail "$timing origin $failure failure satisfied an unrelated inventory" + fi + if run_captain "$home" complete origin-b "$id" > "$home/complete.out" 2> "$home/complete.err"; then + fail "$phase $timing origin $failure failure satisfied completion for B" + fi + printf 'decisions_reviewed=1\ndecision_keys=%s\n' "$id" >> "$home/state/origin-b.meta" + if run_captain "$home" verify origin-b > "$home/verify.out" 2> "$home/verify.err"; then + fail "$phase $timing origin $failure failure verified an inventory for B" + fi + if [ "$phase" != new ]; then + run_captain "$home" complete origin-a "$id" >/dev/null \ + || fail "$phase $timing origin $failure failure invalidated completion for A" + run_captain "$home" verify origin-a >/dev/null \ + || fail "$phase $timing origin $failure failure invalidated verification for A" + fi + done + run_captain "$home" hold "$id" --reason "Choose for B" --origin origin-b \ + ${until_args[@]+"${until_args[@]}"} >/dev/null || fail "$phase $timing successful retry failed" + assert_present "$home/before-backend-hold" "the successful retry did not reach the backend hold" + shown=$(cat "$home/before-backend-hold") + assert_contains "$shown" 'Captain hold origin: origin-b' "the backend hold ran before the new origin was recorded" + assert_not_contains "$shown" 'Captain hold origin: origin-a' "the backend hold ran with the old association" + shown=$(tasks_in "$home" show "$id" --full) + assert_contains "$shown" 'held: yes' "the successful retry did not hold the task" + assert_contains "$shown" 'Captain hold origin: origin-b' "a successful hold lost its association" + assert_not_contains "$shown" 'Captain hold origin: origin-a' "a successful hold retained the old association" + run_captain "$home" complete origin-b "$id" >/dev/null \ + || fail "a successful hold could not complete B" + run_captain "$home" verify origin-b >/dev/null || fail "a successful hold could not verify B" + if run_captain "$home" complete origin-a "$id" >/dev/null 2> "$home/old-origin.err"; then + fail "a successful reassociation still certified A" + fi + done + done + pass "new, active, and released holds require the origin first with and without deferral" +} + +test_historical_self_inventory_has_workable_repair() { + local home origin=sample-review keep=retained-call replacement=repair-call meta before out + home=$(make_home historical-self-inventory) + run_captain "$home" hold "$origin" --title "Old review call" --reason "Choose" >/dev/null \ + || fail "could not create the historical origin" + write_origin_meta "$home" "$origin" + for out in "$keep" "$replacement"; do + run_captain "$home" hold "$out" --title "Call $out" --reason "Choose" --origin "$origin" >/dev/null \ + || fail "could not create $out" + done + meta="$home/state/$origin.meta" + printf 'decisions_reviewed=1\ndecision_keys=%s,%s\n' "$origin" "$keep" >> "$meta" + before=$(cat "$meta") + for out in "$replacement" --none; do + if run_captain "$home" complete "$origin" "$out" > "$home/complete.out" 2> "$home/complete.err"; then + fail "complete accepted the historical self-inventory" + fi + assert_grep "historical decision_keys in $meta still contains $origin" "$home/complete.err" \ + "the historical refusal did not identify the persisted entry" + assert_grep 'replace only' "$home/complete.err" "the refusal omitted the repair instruction" + done + if run_captain "$home" verify "$origin" > "$home/verify.out" 2> "$home/verify.err"; then + fail "verify accepted the historical self-inventory" + fi + assert_grep "historical decision_keys in $meta still contains $origin" "$home/verify.err" \ + "verify omitted the historical repair instruction" + assert_equals "$before" "$(cat "$meta")" "refusing a historical inventory changed it" + sed "s/^decision_keys=$origin,$keep$/decision_keys=$replacement,$keep/" "$meta" > "$meta.repaired" + mv "$meta.repaired" "$meta" + run_captain "$home" complete "$origin" "$replacement" >/dev/null \ + || fail "the documented historical repair did not allow completion" + run_captain "$home" verify "$origin" >/dev/null || fail "the repaired inventory did not verify" + assert_equals "decision_keys=$replacement,$keep" "$(grep '^decision_keys=' "$meta" | tail -1)" \ + "repair lost a sibling inventory entry" + if run_captain "$home" complete "$origin" "$origin" >/dev/null 2> "$home/self.err"; then + fail "repair allowed a new self-inventory" + fi + pass "historical self-inventories name a workable repair that preserves sibling entries" +} + +test_inventory_compares_backend_identities() { + local home origin entry shown before + home=$(make_home backend-identities) + run_captain "$home" hold fm-o --title "Origin" --reason "Choose" >/dev/null \ + || fail "could not create the canonical origin" + tasks_in "$home" add fm-other "Other origin" --kind scout --repo sample >/dev/null \ + || fail "could not create the other origin" + cat > "$home/fakebin/tasks-axi" <<'SH' +#!/usr/bin/env bash +if [ "${1:-}" = show ] && [ "${2:-}" = o ] && [ -f "$FM_HOME/fail-identity" ]; then + printf 'error: origin read failed\ncode: READ_FAILED\n' >&2 + exit 2 +fi +if [ "$#" -ge 2 ]; then + case "$2" in + o|call|other) set -- "$1" "fm-$2" "${@:3}" ;; + esac +fi +exec "$REAL_TASKS_AXI" "$@" +SH + chmod +x "$home/fakebin/tasks-axi" + for origin in fm-o o; do + for entry in fm-o o; do + write_origin_meta "$home" "$origin" + if run_captain "$home" complete "$origin" "$entry" > "$home/self.out" 2> "$home/self.err"; then + fail "complete accepted aliased self-inventory $origin/$entry" + fi + assert_grep 'cannot be its own captain-call inventory entry' "$home/self.err" \ + "the alias refusal did not identify self-inventory" + printf 'decisions_reviewed=1\ndecision_keys=%s\n' "$entry" >> "$home/state/$origin.meta" + if run_captain "$home" verify "$origin" > "$home/verify.out" 2> "$home/verify.err"; then + fail "verify accepted aliased self-inventory $origin/$entry" + fi + assert_grep 'historical decision_keys' "$home/verify.err" "the alias repair diagnostic was missing" + done + write_origin_meta "$home" "$origin" + done + run_captain "$home" hold fm-call --title "Separate call" --reason "Choose" --origin o >/dev/null \ + || fail "could not hold a call using the origin alias" + shown=$(tasks_in "$home" show fm-call --full) + assert_contains "$shown" 'Captain hold origin: fm-o' "hold did not store the backend origin identity" + printf '%s\n' "$shown" | sed -n 's/^ body: //p' | jq -r . \ + | sed 's/^Captain hold origin: fm-o$/Captain hold origin: o/' > "$home/legacy-origin.txt" + tasks_in "$home" update fm-call --body-file "$home/legacy-origin.txt" >/dev/null \ + || fail "could not create a legacy stored alias" + for origin in fm-o o; do + for entry in fm-call call; do + run_captain "$home" complete "$origin" "$entry" >/dev/null \ + || fail "complete refused equivalent origin spellings for $origin/$entry" + run_captain "$home" verify "$origin" >/dev/null \ + || fail "verify refused equivalent origin spellings for $origin/$entry" + done + done + for origin in fm-other other; do + write_origin_meta "$home" "$origin" + if run_captain "$home" complete "$origin" call > "$home/other.out" 2> "$home/other.err"; then + fail "complete accepted another origin through $origin" + fi + assert_grep "was held for origin o, not $origin" "$home/other.err" "the alias mismatch was not identified" + printf 'decisions_reviewed=1\ndecision_keys=call\n' >> "$home/state/$origin.meta" + if run_captain "$home" verify "$origin" >/dev/null 2> "$home/other-verify.err"; then + fail "verify accepted another origin through $origin" + fi + done + : > "$home/fail-identity" + before=$(cat "$home/state/o.meta") + if run_captain "$home" complete o fm-call >/dev/null 2> "$home/read.err"; then + fail "an unreadable backend identity was treated as an absent origin" + fi + assert_grep 'could not resolve the backend identity of o' "$home/read.err" "the identity read failure was hidden" + assert_equals "$before" "$(cat "$home/state/o.meta")" "a failed identity read changed the inventory" + rm "$home/fail-identity" + write_origin_meta "$home" report-only + run_captain "$home" hold report-call --title "Report call" --reason "Choose" --origin report-only >/dev/null \ + || fail "an origin with metadata but no backlog row could not record a call" + run_captain "$home" complete report-only report-call >/dev/null \ + || fail "an origin with metadata but no backlog row could not complete" + run_captain "$home" verify report-only >/dev/null \ + || fail "an origin with metadata but no backlog row could not verify" + pass "completion and verification compare backend identities for entries and current or stored origins" +} + +# tasks-axi refuses parentheses and line breaks in a hold reason and stores the +# rest on one markdown line. The reason is encoded where it is written and +# decoded wherever it is shown, so prose with every awkward character survives. +test_hold_reason_round_trips_awkward_characters() { + local home id reason stored json shown start verb fields out raw rc raw_rc mode + local title legacy body quoted_reason quoted_title quoted_legacy expected_reason until_args=() + local malformed index=0 malformed_reasons=( + 'fm-hold-v1:/w==' 'fm-hold-v1:bm9ydGg=$' 'fm-hold-v1:bm9ydGg' 'fm-hold-v1:Zh==' + ) + home=$(make_home reason-round-trip) + title='Investigate literal %28, "fm-hold-v1:bm9ydGg="' + legacy='Visit https://example.test/%28literal%29 and %0A; fm-hold-v1:bm9ydGg=' + body=$'fm-hold-v1:bm9ydGg=\n hold_reason: "%28"\n' + quoted_title=$(jq -cn --arg value "$title" '$value') + quoted_legacy=$(jq -cn --arg value "$legacy" '$value') + tasks_in "$home" add sample-legacy-call "$title" --kind captain --repo sample >/dev/null \ + || fail "could not create the legacy call" + tasks_in "$home" hold sample-legacy-call --reason "$legacy" --kind captain >/dev/null \ + || fail "could not hold the legacy call" + printf '%s' "$body" > "$home/legacy-body.txt" + tasks_in "$home" update sample-legacy-call --body-file "$home/legacy-body.txt" >/dev/null \ + || fail "could not write the legacy body" + + # Historical literal reasons are persisted input, not encoder output. + for malformed in "${malformed_reasons[@]}"; do + id="sample-malformed-$index" + index=$((index + 1)) + tasks_in "$home" add "$id" "Historical reason $index" --kind captain --repo sample >/dev/null \ + || fail "could not create $id" + tasks_in "$home" hold "$id" --reason "$malformed" --kind captain >/dev/null \ + || fail "could not store the historical literal reason" + for verb in show view; do + out=$(FM_HOME="$home" "$ROOT/bin/fm-tasks-axi.sh" "$verb" "$id" --full) \ + || fail "public $verb failed on historical literal $malformed" + raw=$(tasks_in "$home" "$verb" "$id" --full) + assert_equals "$raw" "$out" "public $verb changed historical literal $malformed" + done + done + + for id in sample-reason-call sample-dated-call; do + until_args=() + reason=$' Pick route (north); say "yes" or \'no\' - 100% sure %28x%29, café\t\\slash\r\nSecond line\n\n' + if [ "$id" = sample-dated-call ]; then + until_args=(--until 2099-01-01) + reason='fm-hold-v1:bm9ydGg=' + fi + quoted_reason=$(jq -cn --arg value "$reason" '$value') + run_captain "$home" hold "$id" --title "$title" --reason "$reason" \ + --repo sample ${until_args[@]+"${until_args[@]}"} >/dev/null \ + || fail "hold refused the reason for $id" + stored=$(grep "^- \[ \] $id " "$home/data/backlog.md") \ + || fail "the held row is not on one backlog line" + assert_contains "$stored" "(hold: fm-hold-v1:" "the persisted reason has no encoding marker" + assert_contains "$stored" "(hold-kind: captain)" "the reason broke the hold-kind tag" + + for verb in show view; do + out=$(FM_HOME="$home" "$ROOT/bin/fm-tasks-axi.sh" "$verb" "$id") \ + || fail "public $verb failed for $id" + shown=$(printf '%s\n' "$out" | sed -n 's/^ hold_reason: //p') + printf '%s\n' "$shown" | jq -e --arg reason "$reason" '. == $reason' >/dev/null \ + || fail "public $verb changed the reason for $id" + assert_contains "$out" " title: $quoted_title" "public $verb changed the title" + done + for fields in hold_reason,body body,hold_reason,hold_until; do + out=$(FM_HOME="$home" "$ROOT/bin/fm-tasks-axi.sh" list --fields "$fields") \ + || fail "public list failed with $fields" + assert_contains "$out" "$quoted_reason" "public list changed the reason with $fields" + assert_contains "$out" "$quoted_title" "public list changed the title with $fields" + raw=$(tasks_in "$home" list --fields "$fields" | grep '^ sample-legacy-call,') + shown=$(printf '%s\n' "$out" | grep '^ sample-legacy-call,') + assert_equals "$raw" "$shown" "public list changed legacy or unrelated fields" + for malformed in "${malformed_reasons[@]}"; do + assert_contains "$out" "$malformed" "public list changed historical literal $malformed" + done + done + json=$(PATH="$home/fakebin:$PATH" FM_HOME="$home" FM_STATE_OVERRIDE="$home/state" \ + FM_DATA_OVERRIDE="$home/data" FM_CONFIG_OVERRIDE="$home/config" \ + "$ROOT/bin/fm-fleet-snapshot.sh" --json) || fail "fleet snapshot failed" + printf '%s' "$json" | jq -e --arg id "$id" --arg reason "$reason" --arg title "$title" \ + '.backlog.records[] | select(.id == $id) | .hold_reason == $reason and .title == $title' >/dev/null \ + || fail "fleet changed the reason or title for $id" + printf '%s' "$json" | jq -e --arg reason "$legacy" --arg title "$title" \ + '.backlog.records[] | select(.id == "sample-legacy-call") | + .hold_reason == $reason and .title == $title and .body_lines[0] == "fm-hold-v1:bm9ydGg="' >/dev/null \ + || fail "fleet changed legacy or unrelated fields" + for malformed in "${malformed_reasons[@]}"; do + printf '%s' "$json" | jq -e --arg reason "$malformed" \ + 'any(.backlog.records[]; .hold_reason == $reason)' >/dev/null \ + || fail "fleet changed historical literal $malformed" + done + done + + out=$(FM_HOME="$home" "$ROOT/bin/fm-tasks-axi.sh" show sample-legacy-call --full) + raw=$(tasks_in "$home" show sample-legacy-call --full) + assert_equals "$raw" "$out" "public show changed legacy or unrelated fields" + out=$(FM_HOME="$home" "$ROOT/bin/fm-tasks-axi.sh" list) + raw=$(tasks_in "$home" list) + assert_equals "$raw" "$out" "public list changed output with no reason column" + for verb in show list; do + out=$(FM_HOME="$home" "$ROOT/bin/fm-tasks-axi.sh" "$verb" --help) + raw=$(tasks_in "$home" "$verb" --help) + assert_equals "$raw" "$out" "public $verb changed help output" + done + out=$(FM_HOME="$home" "$ROOT/bin/fm-tasks-axi.sh" show nonexistent-call 2>&1) + rc=$? + raw=$(tasks_in "$home" show nonexistent-call 2>&1) + raw_rc=$? + [ "$raw_rc" -ne 0 ] || fail "the missing-task fixture unexpectedly exists" + expect_code "$raw_rc" "$rc" "public show missing task" + assert_equals "$raw" "$out" "public show changed a read error" + + expected_reason=$' Pick route (north); say "yes" or \'no\' - 100% sure %28x%29, café\t\\slash\r\nSecond line\n\n' + quoted_reason=$(jq -cn --arg value "$expected_reason" '$value') + for mode in tool manual fallback; do + case "$mode" in + manual) printf 'manual\n' > "$home/config/backlog-backend" ;; + fallback) + rm "$home/config/backlog-backend" + cat > "$home/fakebin/tasks-axi" <<'SH' +#!/usr/bin/env bash +if [ "${1:-}" = list ]; then + printf 'read failed: literal %%28 and fm-hold-v1:bm9ydGg=\n' >&2 + exit 1 +fi +exec "$REAL_TASKS_AXI" "$@" +SH + chmod +x "$home/fakebin/tasks-axi" + ;; + esac + start=$(PATH="$home/fakebin:$PATH" REAL_TASKS_AXI="$TASKS_AXI_BIN" FM_HOME="$home" \ + FM_STATE_OVERRIDE="$home/state" FM_DATA_OVERRIDE="$home/data" FM_CONFIG_OVERRIDE="$home/config" \ + FM_BOOTSTRAP_NETWORK=skip "$ROOT/bin/fm-session-start.sh" 2>&1 || true) + assert_contains "$start" "$quoted_reason" "startup $mode changed the encoded reason" + assert_contains "$start" 'Investigate literal %28' "startup $mode changed the title" + assert_contains "$start" "$legacy" "startup $mode changed the legacy reason" + assert_contains "$start" '"fm-hold-v1:bm9ydGg="' "startup $mode decoded a reason twice" + for malformed in "${malformed_reasons[@]}"; do + assert_contains "$start" "$malformed" "startup $mode changed historical literal $malformed" + done + if [ "$mode" = fallback ]; then + assert_contains "$start" 'read failed: literal %28 and fm-hold-v1:bm9ydGg=' \ + "startup changed unrelated error text" + fi + done + rm "$home/fakebin/tasks-axi" + out=$(PATH="$home/fakebin:$PATH" FM_HOME="$home" FM_STATE_OVERRIDE="$home/state" \ + FM_DATA_OVERRIDE="$home/data" FM_CONFIG_OVERRIDE="$home/config" \ + "$ROOT/bin/fm-afk-return.sh" check 2>&1 || true) + assert_contains "$out" "$quoted_reason" "return brief changed the encoded reason" + assert_contains "$out" "$quoted_title" "return brief changed the title" + assert_contains "$out" "$quoted_legacy" "return brief changed the legacy reason" + for malformed in "${malformed_reasons[@]}"; do + assert_contains "$out" "$malformed" "return brief changed historical literal $malformed" + done + pass "marked hold reasons round-trip through public reads, fleet, startup, and return without changing other fields" +} + +test_hold_reason_round_trips_awkward_characters +test_hold_origins_precede_backend_holds +test_historical_self_inventory_has_workable_repair +test_inventory_compares_backend_identities +test_origin_is_never_its_own_inventory_entry +test_complete_refuses_an_entry_held_for_another_origin test_uninventoried_report_decision_refuses_completion test_hold_decodes_a_bare_scalar_body_without_the_nonref_default test_retained_body_keeps_its_utf8_bytes diff --git a/tests/fm-claude-stop-autoarm.test.sh b/tests/fm-claude-stop-autoarm.test.sh index 7b47c25e854..0cae21c30e9 100755 --- a/tests/fm-claude-stop-autoarm.test.sh +++ b/tests/fm-claude-stop-autoarm.test.sh @@ -1411,6 +1411,24 @@ write_host_fixture() { stood-down) printf "printf 'supervision-host stood down: this session no longer owns supervision\\n'\n" ;; + lost-handback|lost-announced-handback) + local marker=pending + [ "$kind" = lost-handback ] || marker=announced + printf "printf '%s:handling:fixture-generation\\\\n' > \"\$FM_HOME/state/.watcher-down\"\\n" "$marker" + cat <<'SH' +printf 'signal: fixture.status\n' +printf 'supervision-host: branch-outcome: fixture\n' +printf 'supervision-host: watcher downtime could not be restored for the main hand-back\n' +exit 1 +SH + ;; + benign-refusal) + cat <<'SH' +printf 'acked:downtime:fixture-generation\n' > "$FM_HOME/state/.watcher-down" +printf 'signal: fixture.status\n' +printf 'supervision-host: branch-outcome: fixture\n' +SH + ;; handed-back-many) cat <<'SH' printf 'pending:downtime:fixture-generation\n' > "$FM_HOME/state/.watcher-down" @@ -1570,6 +1588,56 @@ test_host_stand_down_is_silent() { pass "auto-arm: a host that stood down closes silently without a retry" } +# Main already drained and acknowledged the wake, so the rewake is refused on a +# marker that is no longer downtime: that refusal stays silent and opens no +# failure episode. +test_host_benign_rewake_refusal_opens_no_failure_episode() { + local dir out status + dir=$(make_primary_dir "$TMP_ROOT/host-benign-refusal") + mkdir -p "$dir/config" + rm -f "$dir/config/supervision-host-off" + : > "$dir/state/task.meta" + write_host_fixture "$dir" benign-refusal + out=$(run_autoarm "$dir" 2>/dev/null); status=$? + expect_code 0 "$status" "a refused rewake on an acknowledged marker must stay silent" + assert_not_contains "$out" "auto-arm FAILED" "a benign refusal must not deliver a failure notice" + assert_absent "$dir/state/.claude-autoarm-failure-notified" "a benign refusal opened a failure episode" + [ "$(epoch_outcome "$dir")" != failed ] || fail "a benign refusal must not record outcome=failed" + pass "auto-arm: a host rewake refused on an acknowledged marker opens no failure episode" +} + +# The host handed a wake back but left the marker in handling (pending or +# announced) with no live successor, so no rewake can commit: the hook delivers +# the failure notice once per episode and keeps exiting 2 without repeating it. +assert_host_lost_handback_notifies_once_per_episode() { + local kind=$1 dir out status + dir=$(make_primary_dir "$TMP_ROOT/host-$kind") + mkdir -p "$dir/config" + rm -f "$dir/config/supervision-host-off" + : > "$dir/state/task.meta" + write_host_fixture "$dir" "$kind" + out=$(run_autoarm "$dir" 2>/dev/null); status=$? + expect_code 2 "$status" "a lost hand-back must reach main" + assert_contains "$out" "auto-arm FAILED - the supervision host returned an actionable wake" "a lost hand-back must deliver the failure notice" + assert_present "$dir/state/.claude-autoarm-failure-notified" "a lost hand-back did not record its failure episode" + [ "$(epoch_outcome "$dir")" = failed ] || fail "a lost hand-back must record outcome=failed, got: $(epoch_outcome "$dir")" + out=$(run_autoarm "$dir" 2>/dev/null); status=$? + expect_code 2 "$status" "a repeated lost hand-back must still reach main" + assert_not_contains "$out" "auto-arm FAILED" "a repeated lost hand-back must not repeat the failure notice" + [ "$(epoch_outcome "$dir")" = failed-suppressed ] \ + || fail "a repeated lost hand-back must record outcome=failed-suppressed, got: $(epoch_outcome "$dir")" +} + +test_host_lost_handback_notifies_once_per_episode() { + assert_host_lost_handback_notifies_once_per_episode lost-handback + pass "auto-arm: a lost host hand-back notifies once per failure episode" +} + +test_host_lost_announced_handback_notifies_once_per_episode() { + assert_host_lost_handback_notifies_once_per_episode lost-announced-handback + pass "auto-arm: a lost host hand-back on an announced marker notifies once per failure episode" +} + test_host_crash_is_retried_then_reported() { local dir out status dir=$(make_primary_dir "$TMP_ROOT/host-crash") @@ -1692,6 +1760,9 @@ test_host_handback_beside_a_quiet_record_carries_no_away_note test_plain_arm_banner_keeps_its_wake_line_cap test_host_handback_carries_every_host_line test_host_stand_down_is_silent +test_host_benign_rewake_refusal_opens_no_failure_episode +test_host_lost_handback_notifies_once_per_episode +test_host_lost_announced_handback_notifies_once_per_episode test_host_crash_is_retried_then_reported test_arguments_never_arm test_fm_lock_status_still_works_with_shared_lib diff --git a/tests/fm-composer-lib.test.sh b/tests/fm-composer-lib.test.sh index a9abd9e8ef6..4dbddf14b82 100755 --- a/tests/fm-composer-lib.test.sh +++ b/tests/fm-composer-lib.test.sh @@ -731,6 +731,59 @@ test_matrix_grok_titled_bottom_border() { pass "matrix: grok's real oversized titled bottom is empty while typed and unproved panes stay safe" } +test_matrix_claude_titled_top_rule() { + # A named Claude Code session draws its title into the composer's TOP rule + # (issues #5601 and #5558; observed on herdr as + # `─── Firstmate operational input 1790546042 ─`). The strict separator + # predicate rejects that row, so the pair never opened, the closing rule + # read as a lower unmatched separator, and a visibly empty composer read + # `unknown` on every cursorless backend, refusing steers, exit, and relaunch. + local rule title top bottom footer screen ansi typed claude_idle + local scrollback short nonascii flush blank + claude_idle=$(printf 'claude\tidle') + rule='────────────────────────────────────────────────────────────' + title=' Firstmate operational input 1790546042 ' + top="${rule}───${title}─" + bottom="${rule}────────────────────────────────────────────" + footer=' ⏵⏵ bypass permissions on (shift+tab to cycle)' + screen="recap: earlier work"$'\n'"$top"$'\n❯'"$NBSP"$'\n'"$bottom"$'\n'"$footer" + ansi="${ESC}[38;2;128;130;131mrecap: earlier work${ESC}[0m"$'\n' + ansi+="${ESC}[0m${ESC}[38;2;121;129;134m${rule}─── ${ESC}[38;2;177;185;249m${title# }${ESC}[38;2;121;129;134m─${ESC}[0m"$'\n' + ansi+="${ESC}[0m${ESC}[38;2;128;130;131m❯${NBSP}${ESC}[0m"$'\n' + ansi+="${ESC}[0m${ESC}[38;2;121;129;134m${bottom}${ESC}[0m"$'\n'"$footer" + assert_screen "titled claude idle on herdr" empty "$CAPS_STYLED" "$screen" '' "$claude_idle" + assert_screen "titled claude idle on herdr (ansi)" empty "$CAPS_STYLED" "$ansi" '' "$claude_idle" + assert_screen "titled claude idle on zellij (ansi)" empty "$CAPS_STYLED_NOID" "$ansi" + assert_screen "titled claude idle on cmux/orca" empty "$CAPS_PLAIN" "$screen" + assert_screen "titled claude idle on tmux" empty "$CAPS_TMUX" "$ansi" 2 probe-absent + typed="$top"$'\n❯ fix the login bug\n'"$bottom"$'\n'"$footer" + assert_screen "titled claude typed on herdr" pending "$CAPS_STYLED" "$typed" '' "$claude_idle" + assert_screen "titled claude typed on zellij" pending "$CAPS_STYLED_NOID" "$typed" + assert_screen "titled claude typed on tmux" pending "$CAPS_TMUX" "$typed" 1 probe-absent + assert_screen "titled claude typed on plain backends" unknown "$CAPS_PLAIN" "$typed" + # The staleness rule still holds: a titled sandwich stranded in scrollback, + # with transcript rows between it and a lower unmatched rule, stays unknown. + scrollback="$top"$'\n❯'"$NBSP"$'\n'"$bottom"$'\nlater transcript output\n'"$bottom"$'\nmore output' + assert_screen "titled sandwich in scrollback" unknown "$CAPS_STYLED_NOID" "$scrollback" + # Width is proven, not assumed: a titled rule narrower than its closing rule + # is not that composer's top edge. + short="${rule}${title}─"$'\n❯'"$NBSP"$'\n'"$bottom" + assert_screen "mismatched titled rule width" unknown "$CAPS_STYLED_NOID" "$short" + # A non-ASCII title leaves residue and refuses rather than guessing width. + nonascii="${rule}─── ✳ Firstmate operational input 179054604 ─"$'\n❯'"$NBSP"$'\n'"$bottom" + assert_screen "non-ASCII titled rule" unknown "$CAPS_STYLED_NOID" "$nonascii" + # The rule must open with the strict separator's dash run. + flush=" Firstmate operational input 1790546042 ${rule}────"$'\n❯'"$NBSP"$'\n'"$bottom" + assert_screen "title flush at the rule's start" unknown "$CAPS_STYLED_NOID" "$flush" + # The strict blank-row posture is untouched: no glyph row, no proof. + blank="$top"$'\n\n'"$bottom" + assert_screen "titled rule over a blank row" unknown "$CAPS_STYLED_NOID" "$blank" + # The untitled pair keeps its verdict alongside the new shape. + assert_screen "untitled claude idle on herdr" empty "$CAPS_STYLED" \ + "$bottom"$'\n❯'"$NBSP"$'\n'"$bottom"$'\n'"$footer" '' "$claude_idle" + pass "matrix: claude's titled top rule proves an idle composer empty and a draft pending (#5601, #5558)" +} + test_matrix_kimi_bordered_shell_glyph_box() { # Kimi's bordered `│ > │` composer - the shape fm-spawn.sh's retired # spawn-local regex used to own. Now the shared owner proves it everywhere, @@ -982,6 +1035,7 @@ test_matrix_pi_separated_needs_identity test_matrix_pi_dollar_status_footer_is_empty test_matrix_opencode_leftbar_signals test_matrix_grok_titled_bottom_border +test_matrix_claude_titled_top_rule test_matrix_kimi_bordered_shell_glyph_box test_matrix_claude_inside_zellij_ansi_dump test_strict_blank_row_divergence diff --git a/tests/fm-contributions.test.sh b/tests/fm-contributions.test.sh index a1582c4e2e3..ce87baefafb 100755 --- a/tests/fm-contributions.test.sh +++ b/tests/fm-contributions.test.sh @@ -299,6 +299,32 @@ test_verdict_retains_judged_head() { pass 'recorded judgment keeps its exact head and is stale immediately on a published replacement' } +test_verdict_actor_values_are_discoverable() { + local home help out actor + home=$(new_home verdict-actors) + forge_home "$home" + with_home "$home" "$ROOT/bin/fm-pr-check.sh" delivery https://github.com/o/r/pull/8 >/dev/null \ + || fail 'could not register delivery before judging its head' + help=$("$ROOT/bin/fm-contributions.sh" --help) || fail 'verdict help did not print' + out=$(with_home "$home" "$ROOT/bin/fm-contributions.sh" verdict delivery https://github.com/o/r/pull/8 "$HEAD_A" \ + https://github.com/o/r/pull/8#issuecomment-99 bogus 'no such actor' 2>&1) \ + && fail 'an unknown actor was accepted' + [ "$(printf '%s\n' "$help" | sed -n '/^ fm-contributions.sh verdict /p')" = \ + ' fm-contributions.sh verdict ' ] \ + || fail "help usage does not name exactly the accepted actors: $help" + [ "$(printf '%s\n' "$help" | sed -n '/^actor is exactly one of /p')" = \ + 'actor is exactly one of captain, fleet, maintainer or nobody; any other value' ] \ + || fail "help explanation does not name exactly the accepted actors: $help" + [ "$out" = "fm-contributions: invalid required actor 'bogus'; expected one of: captain, fleet, maintainer, nobody" ] \ + || fail "refusal does not name exactly the accepted actors: $out" + for actor in captain fleet maintainer nobody; do + with_home "$home" "$ROOT/bin/fm-contributions.sh" verdict delivery https://github.com/o/r/pull/8 "$HEAD_A" \ + https://github.com/o/r/pull/8#issuecomment-99 "$actor" 'documented actor' >/dev/null \ + || fail "documented actor $actor was refused" + done + pass 'verdict help and refusal name exactly the actors the command accepts' +} + test_observed_replacement_refreshes_verdict() { local home home=$(new_home observed-replacement) @@ -1073,7 +1099,7 @@ test_late_owner_keeps_failure_episode_suppressed() { } failures=0 -for test_name in test_actor_coverage test_stale_verdict test_unchecked_is_not_silence test_newest_check_has_no_verdict test_comment_wake test_review_wake test_inline_wake test_ready_issue_wake test_fresh_issue_requires_maintainer test_missing_lane_remains_missing test_partial_freshness_keeps_measured_rows test_malformed_record_cannot_prove_silence test_issue_timeline_and_exact_ack test_verdict_retains_judged_head test_observed_replacement_refreshes_verdict test_unobserved_head_leaves_verdict_unknown test_away_yolo_is_fleet_work test_away_yolo_cross_home_is_fleet_work test_retired_and_unsupported_coverage test_unsupported_forge_is_not_fleet_work test_held_unsupported_forge_is_not_captain_work test_shared_contribution_signal_wakes_once test_watcher_keeps_diagnostics_separate_from_contribution_wakes test_expired_child_unsupported_forge_stays_unmeasured test_watcher_surfaces_new_contribution_once test_home_summary_coverage test_unreadable_pending_is_not_empty test_record_task_identity_matches_dirname_basename test_read_only_views_create_no_state test_budget_refusal_between_calls test_budget_bounded_call_timeout test_genuine_failure_near_deadline_is_unavailable test_shared_url_observed_once test_terminal_contribution_settles test_late_owner_inherits_terminal_observation test_interrupted_multi_owner_poll_settles_every_owner test_done_task_open_pr_still_observed test_reservation_defers_later_url_when_fifteen_seconds_do_not_remain test_three_second_pr_reads_complete_fresh_in_one_cycle test_slow_read_deadline_kill_is_budget_refusal test_unmeasured_url_does_not_starve_the_tail test_budget_is_cut_down_to_the_watcher_check_bound test_arm_plumbs_a_configured_budget_into_the_check_shim test_unavailable_forge_records_error_and_wakes_once_per_episode test_late_owner_keeps_failure_episode_suppressed; do +for test_name in test_actor_coverage test_stale_verdict test_unchecked_is_not_silence test_newest_check_has_no_verdict test_comment_wake test_review_wake test_inline_wake test_ready_issue_wake test_fresh_issue_requires_maintainer test_missing_lane_remains_missing test_partial_freshness_keeps_measured_rows test_malformed_record_cannot_prove_silence test_issue_timeline_and_exact_ack test_verdict_retains_judged_head test_verdict_actor_values_are_discoverable test_observed_replacement_refreshes_verdict test_unobserved_head_leaves_verdict_unknown test_away_yolo_is_fleet_work test_away_yolo_cross_home_is_fleet_work test_retired_and_unsupported_coverage test_unsupported_forge_is_not_fleet_work test_held_unsupported_forge_is_not_captain_work test_shared_contribution_signal_wakes_once test_watcher_keeps_diagnostics_separate_from_contribution_wakes test_expired_child_unsupported_forge_stays_unmeasured test_watcher_surfaces_new_contribution_once test_home_summary_coverage test_unreadable_pending_is_not_empty test_record_task_identity_matches_dirname_basename test_read_only_views_create_no_state test_budget_refusal_between_calls test_budget_bounded_call_timeout test_genuine_failure_near_deadline_is_unavailable test_shared_url_observed_once test_terminal_contribution_settles test_late_owner_inherits_terminal_observation test_interrupted_multi_owner_poll_settles_every_owner test_done_task_open_pr_still_observed test_reservation_defers_later_url_when_fifteen_seconds_do_not_remain test_three_second_pr_reads_complete_fresh_in_one_cycle test_slow_read_deadline_kill_is_budget_refusal test_unmeasured_url_does_not_starve_the_tail test_budget_is_cut_down_to_the_watcher_check_bound test_arm_plumbs_a_configured_budget_into_the_check_shim test_unavailable_forge_records_error_and_wakes_once_per_episode test_late_owner_keeps_failure_episode_suppressed; do ( "$test_name" ) || failures=$((failures + 1)) done [ "$failures" -eq 0 ] || fail "$failures contribution regressions" diff --git a/tests/fm-extension-binding.test.sh b/tests/fm-extension-binding.test.sh index 22e23f1a645..ab7530a49df 100644 --- a/tests/fm-extension-binding.test.sh +++ b/tests/fm-extension-binding.test.sh @@ -108,6 +108,8 @@ extension_test_cleanup() { ( # worker.pid names the serving child; the copied remote helper stops its # known isolated supervisor tree so it cannot respawn during teardown. + # Production libraries are linted independently by fm-lint.sh. + # shellcheck source=/dev/null . "$REMOTE_ROOT/bin/fm-remote-job-lib.sh" fm_remote_job_stop_worker_tree "$(cat "$TMP_ROOT/remote-jobs/worker.pid")" ) 2>/dev/null || true diff --git a/tests/fm-fleet-sync.test.sh b/tests/fm-fleet-sync.test.sh index 68c32f50d30..0bee1668c3d 100755 --- a/tests/fm-fleet-sync.test.sh +++ b/tests/fm-fleet-sync.test.sh @@ -683,8 +683,8 @@ test_symlinked_clone_still_syncs() { home=$(new_home) clone=$(build_pair "$home" sigma) advance_origin "$home" sigma C1 - # A symlinked clone dir is a real clone root; the guard compares resolved paths, - # so it must not be mistaken for a directory nested in someone else's repo. + # A symlinked clone dir is a real clone root and must not be mistaken for a + # directory nested in someone else's repo. mv "$clone" "$home/real-sigma" ln -s "$home/real-sigma" "$clone" @@ -694,6 +694,38 @@ test_symlinked_clone_still_syncs() { pass "the clone-root guard accepts a symlinked clone directory" } +test_clone_root_named_by_another_spelling_still_syncs() { + local home clone fakebin alias out + home=$(new_home) + clone=$(build_pair "$home" tau) + advance_origin "$home" tau C1 + fakebin="$home/fb-rootalias"; rm -rf "$fakebin"; mkdir -p "$fakebin" + # git reports the clone's own root through an alias that is the same directory + # but a different string, as it does on a case-insensitive volume when the home + # was recorded with other casing. A symlink stands in for the case difference so + # the test also holds on a case-sensitive filesystem. + alias="$home/root-alias" + ln -s "$clone" "$alias" + cat > "$fakebin/git" <<'SH' +#!/usr/bin/env bash +real=${REAL_GIT_FOR_TEST:?} +case " $* " in + *" rev-parse --show-toplevel "*) printf '%s\n' "${ROOT_ALIAS_FOR_TEST:?}"; exit 0 ;; +esac +exec "$real" "$@" +SH + chmod +x "$fakebin/git" + out="$home/out"; err="$home/err" + + ROOT_ALIAS_FOR_TEST="$alias" run_sync_guarded "$home" "$fakebin" "$out" "$err" tau || true + + assert_contains "$(cat "$out")" "tau: synced" \ + "a clone root that git names with another spelling must still fast-forward" + assert_not_contains "$(cat "$out")" "not a clone root" \ + "the guard must compare the directory itself, not the spelling of its path" + pass "the clone-root guard accepts a root named by a different spelling of the same directory" +} + test_non_signature_fetch_failure_is_not_retried() { local home fakebin clone out err home=$(new_home) @@ -741,3 +773,4 @@ test_non_signature_fetch_failure_is_not_retried test_non_clone_dir_never_syncs_the_enclosing_repo test_non_clone_dir_named_directly_never_syncs_the_enclosing_repo test_symlinked_clone_still_syncs +test_clone_root_named_by_another_spelling_still_syncs diff --git a/tests/fm-lint.test.sh b/tests/fm-lint.test.sh index 46f73d99566..327f9b2d4e8 100755 --- a/tests/fm-lint.test.sh +++ b/tests/fm-lint.test.sh @@ -3,10 +3,9 @@ # # bin/fm-lint.sh is the single owner invoked by CI # (.github/workflows/ci.yml) and by the pre-push gate (.no-mistakes.yaml -# commands.lint). CI runs its two full-rigor canonical partitions; the local -# gate uses its context-selected default. Their selection differs deliberately, -# while this owner keeps analysis flags, configuration, and tool versions from -# drifting. +# commands.lint). CI and the local gate deliberately select different roots; +# bin/fm-lint.sh owns their analysis modes, memory fallback, configuration, +# and tool versions. # Regression origin: with no commands.lint configured, the local no-mistakes # lint step never ran the deterministic shell lint, so PRs passed local # validation yet failed CI on info/warning findings such as SC2015, SC1007, and @@ -1532,6 +1531,172 @@ test_root_memory_limit_reports_a_named_death() { pass "a root refused by its enforced memory limit fails by name with a memory reason" } +test_memory_failure_retries_without_external_sources() { + local tmp fakebin fixture out rc log rss_kib require_bounds=0 mode + local -a modes=(0) + if fm_lint_bounds_supported; then + require_bounds=1 + modes=(1 0) + fi + tmp=$(fm_test_tmproot fm-lint-memory-fallback) + fakebin=$(fm_fakebin "$tmp") + fixture="$tmp/teardown.sh" + log="$tmp/flags.log" + printf '#!/usr/bin/env bash\n# shellcheck source=lib.sh\nexit 0\n' > "$fixture" + cat > "$fakebin/shellcheck" <<'SH' +#!/usr/bin/env bash +if [ "${1:-}" = --version ]; then + printf 'ShellCheck - shell script analysis tool\nversion: 0.11.0\n' + exit 0 +fi +follow=no +exclude=none +while [ "$#" -gt 0 ] && [ "$1" != -- ]; do + case "$1" in + --external-sources) follow=yes ;; + --exclude=*) exclude=${1#--exclude=} ;; + esac + shift +done +shift +printf '%s\t%s\n' "$follow" "$exclude" >> "$FM_TEST_FALLBACK_LOG" +if [ "$follow" = yes ]; then + printf 'shellcheck: Heap exhausted;\n' >&2 + exit 251 +fi +exit 0 +SH + chmod +x "$fakebin/shellcheck" + + for mode in "${modes[@]}"; do + : > "$log" + rc=0 + out=$(PATH="$fakebin:$PATH" FM_LINT_JOBS=1 FM_LINT_REQUIRE_BOUNDS="$mode" \ + FM_TEST_FALLBACK_LOG="$log" "$LINT" --telemetry "$tmp/pass.$mode.tsv" "$fixture" 2>&1) || rc=$? + [ "$rc" -eq 0 ] || fail "a clean no-source fallback did not pass (bounded=$mode)"$'\n'"$out" + assert_grep $'source_directives\t1' "$tmp/pass.$mode.tsv" "telemetry lost the root's source directive" + assert_grep $'source_followed_directives\t0' "$tmp/pass.$mode.tsv" \ + "telemetry counted a source directive that the passing fallback did not follow" + [ "$(cat "$log")" = "$(printf 'yes\tnone\nno\tSC1091,SC2034,SC2153,SC2329')" ] \ + || fail "the memory failure did not retry without external sources and exclude only cross-file codes"$'\n'"$(cat "$log")" + assert_contains "$out" "hit the memory ceiling with --external-sources (reason=memory rc=251)" \ + "the fallback was not identified in the output" + assert_contains "$out" "fallback passed with cross-file codes excluded (SC1091,SC2034,SC2153,SC2329)" \ + "the narrower fallback result was not disclosed" + awk -F '\t' '$1 == "end" && $3 ~ /teardown\.sh$/ && $9 == 0 && $10 == "memory-fallback" { found=1 } END { exit !found }' \ + "$tmp/pass.$mode.roots.tsv" || fail "the clean fallback was not recorded distinctly" + rss_kib=$(awk -F '\t' '$1 == "end" && $3 ~ /teardown\.sh$/ { print $11 }' "$tmp/pass.$mode.roots.tsv") + if [ "$mode" -eq 1 ]; then + assert_grep $'meta\tbounds_enforced\t1' "$tmp/pass.$mode.roots.tsv" \ + "the bounded fallback did not enforce bounds" + case "$rss_kib" in ''|*[!0-9]*) fail "the fallback attempts lost per-root RSS reporting: $rss_kib" ;; esac + else + assert_grep $'meta\tbounds_enforced\t0' "$tmp/pass.$mode.roots.tsv" \ + "the unbounded fallback unexpectedly enforced bounds" + [ "$rss_kib" = unavailable ] || fail "the unbounded fallback unexpectedly reported RSS: $rss_kib" + fi + done + + if ! pinned_ready; then + pass "SKIP (ShellCheck $REQUIRED not resolved): real fallback finding check" + return + fi + local real_shellcheck + real_shellcheck=$(command -v shellcheck) + cat > "$fakebin/shellcheck" <<'SH' +#!/usr/bin/env bash +if [ "${1:-}" = --version ]; then + exec "$FM_REAL_SHELLCHECK" "$@" +fi +for arg in "$@"; do + if [ "$arg" = --external-sources ]; then + printf 'shellcheck: Heap exhausted;\n' >&2 + exit 251 + fi +done +exec "$FM_REAL_SHELLCHECK" "$@" +SH + chmod +x "$fakebin/shellcheck" + # shellcheck disable=SC2016 # The fixture intentionally contains an unexpanded parameter. + printf '#!/usr/bin/env bash\nx=$1\nprintf "%%s\\n" $x\n' > "$fixture" + rc=0 + out=$(PATH="$fakebin:$PATH" FM_REAL_SHELLCHECK="$real_shellcheck" \ + FM_LINT_JOBS=1 FM_LINT_REQUIRE_BOUNDS="$require_bounds" \ + "$LINT" --telemetry "$tmp/finding.tsv" "$fixture" 2>&1) || rc=$? + [ "$rc" -eq 1 ] || fail "a real ShellCheck finding in the fallback did not fail lint (exit $rc)"$'\n'"$out" + assert_contains "$out" "fallback reason=findings rc=1" \ + "the fallback finding was not identified" + assert_contains "$out" "SC2086" "the real fallback finding was not reported" + awk -F '\t' '$1 == "end" && $3 ~ /teardown\.sh$/ && $9 == 1 && $10 == "findings" { found=1 } END { exit !found }' \ + "$tmp/finding.roots.tsv" || fail "the fallback finding was not recorded as a failure" + pass "memory failures retry without source following, exclude cross-file codes, and preserve a real fallback finding" +} + +test_memory_fallback_spends_only_the_remaining_root_deadline() { + if ! fm_lint_bounds_supported; then + pass "SKIP (host cannot enforce the bounded envelope): fallback deadline check" + return + fi + local tmp fakebin fixture log out rc duration_ms + tmp=$(fm_test_tmproot fm-lint-fallback-deadline) + fakebin=$(fm_fakebin "$tmp") + fixture="$tmp/teardown.sh" + log="$tmp/attempts.log" + printf '#!/usr/bin/env bash\nexit 0\n' > "$fixture" + cat > "$fakebin/shellcheck" <<'SH' +#!/usr/bin/env bash +if [ "${1:-}" = --version ]; then + printf 'ShellCheck - shell script analysis tool\nversion: 0.11.0\n' + exit 0 +fi +for arg in "$@"; do + if [ "$arg" = --external-sources ]; then + printf 'follow\n' >> "$FM_TEST_ATTEMPT_LOG" + sleep "$FM_TEST_FIRST_SECS" + printf 'shellcheck: Heap exhausted;\n' >&2 + exit 251 + fi +done +printf 'fallback\n' >> "$FM_TEST_ATTEMPT_LOG" +sleep 60 +exit 0 +SH + chmod +x "$fakebin/shellcheck" + + # A 3s first attempt leaves about 3s of the 6s deadline, so the retry is + # killed there; a fresh deadline would let the root run for about 9s. + : > "$log" + rc=0 + out=$(PATH="$fakebin:$PATH" FM_LINT_JOBS=1 FM_LINT_REQUIRE_BOUNDS=1 \ + FM_LINT_ROOT_SECONDS=6 FM_LINT_ROOT_GRACE=1 \ + FM_TEST_ATTEMPT_LOG="$log" FM_TEST_FIRST_SECS=3 \ + "$LINT" --telemetry "$tmp/partial.tsv" "$fixture" 2>&1) || rc=$? + [ "$rc" -ne 0 ] || fail "a fallback cut off by the root deadline unexpectedly passed" + [ "$(cat "$log")" = "$(printf 'follow\nfallback')" ] \ + || fail "the root did not retry once with its remaining time"$'\n'"$(cat "$log")" + assert_contains "$out" "fallback reason=timeout" \ + "the fallback was not stopped by the root's remaining deadline"$'\n'"$out" + duration_ms=$(awk -F '\t' '$1 == "end" && $3 ~ /teardown\.sh$/ { print $8 }' "$tmp/partial.roots.tsv") + [ "$duration_ms" -lt 7000 ] \ + || fail "the first attempt and fallback together exceeded the root deadline plus grace: ${duration_ms}ms" + + # With under a second of the deadline left, no retry starts. + : > "$log" + rc=0 + out=$(PATH="$fakebin:$PATH" FM_LINT_JOBS=1 FM_LINT_REQUIRE_BOUNDS=1 \ + FM_LINT_ROOT_SECONDS=6 FM_LINT_ROOT_GRACE=1 \ + FM_TEST_ATTEMPT_LOG="$log" FM_TEST_FIRST_SECS=5.2 \ + "$LINT" --telemetry "$tmp/spent.tsv" "$fixture" 2>&1) || rc=$? + [ "$rc" -ne 0 ] || fail "a memory failure with no deadline left unexpectedly passed" + [ "$(cat "$log")" = follow ] \ + || fail "a fallback started with no time left in the root deadline"$'\n'"$(cat "$log")" + assert_contains "$out" "no time left in its 6s deadline to retry without it" \ + "the skipped fallback was not explained"$'\n'"$out" + awk -F '\t' '$1 == "end" && $3 ~ /teardown\.sh$/ && $10 == "memory" && $12 == 1 { found=1 } END { exit !found }' \ + "$tmp/spent.roots.tsv" || fail "the unretried memory failure was not recorded as a source-following memory failure" + pass "a memory fallback runs only within the time left in its root's original deadline" +} + test_memory_evidence_outranks_findings_and_signal_reasons() { local tmp fakebin roots_log out rc name reason bounded local -a roots modes @@ -1800,13 +1965,8 @@ test_seeded_module_boundary_parity() { pass "SKIP (ShellCheck $REQUIRED not resolved): seeded source-boundary parity check" return fi - local tmp rel adapter dispatcher dep owner test_root out rc - tmp=$(mktemp -d "$ROOT/.fm-lint-parity.XXXXXX") - if [ "${#FM_TEST_CLEANUP_DIRS[@]}" -eq 0 ]; then - trap fm_test_cleanup EXIT - fi - FM_TEST_CLEANUP_DIRS+=("$tmp") - rel=${tmp#"$ROOT/"} + local tmp adapter dispatcher dep owner test_root out rc + tmp=$(fm_test_tmproot fm-lint-parity) adapter="$tmp/adapter.sh" dispatcher="$tmp/dispatcher.sh" dep="$tmp/owner-dep.sh" @@ -1834,7 +1994,7 @@ owner_dependency_value=ok SH cat > "$owner" </dev/null || true) +SOCKET="fm-pi-seeded-trust-$$" +LAB= +CHECKED=0 + +note() { printf '# %s\n' "$1"; } +pass() { printf 'ok - %s\n' "$1"; } + +cleanup() { + [ -z "${REAL_TMUX:-}" ] || "$REAL_TMUX" -L "$SOCKET" kill-server >/dev/null 2>&1 || true + [ -z "${LAB:-}" ] || rm -rf -- "$LAB" +} +trap cleanup EXIT + +fail() { printf 'not ok - %s\n' "$1" >&2; exit 1; } + +fm_live_gate default-on FM_PI_SEEDED_HOME_TRUST_LIVE pi tmux + +PI_BIN=$(command -v pi) || fail "pi missing after live gate" +VERSION_OUT=$("$PI_BIN" --version 2>&1) || fail "pi --version failed: $VERSION_OUT" +note "live pi version: $VERSION_OUT" + +if ! "$PI_BIN" --help 2>&1 | grep -Eq -- '(^|[[:space:]])--approve([^[:alnum:]_-]|$)'; then + note "installed pi does not advertise --approve; spawn omits the flag and this guard has nothing to prove" + echo "# fm-pi-seeded-home-trust-live-e2e: skipped (no --approve on installed pi)" + exit 0 +fi + +LAB=$(mktemp -d "${TMPDIR:-/tmp}/fm-pi-seeded-trust.XXXXXX") || fail "could not create disposable lab" +PI_DIR="$LAB/pi-agent" +mkdir -p "$PI_DIR" +printf '{}\n' > "$PI_DIR/trust.json" +TRUST_BEFORE=$(cat "$PI_DIR/trust.json") + +seed_home() { # + local dir=$1 id=$2 + mkdir -p "$dir/.pi/extensions" "$dir/data" "$dir/state" "$dir/config" + printf '%s\n' "$id" > "$dir/.fm-secondmate-home" + printf 'export default function () {}\n' > "$dir/.pi/extensions/fm-primary-turnend-guard.ts" + printf 'export default function () {}\n' > "$dir/.pi/extensions/fm-primary-pi-watch.ts" + printf '# test charter\n' > "$dir/data/charter.md" +} + +capture_until() { # + local session=$1 expect=$2 seconds=$3 out=$4 + local target="$session:w" tail='' i limit + limit=$((seconds * 5)) + for ((i = 0; i < limit; i++)); do + tail=$("$REAL_TMUX" -L "$SOCKET" capture-pane -p -t "$target" -S -80 2>/dev/null) || true + if printf '%s' "$tail" | grep -qiE "$expect"; then + printf '%s' "$tail" > "$out" + return 0 + fi + sleep 0.2 + done + printf '%s' "$tail" > "$out" + return 1 +} + +# --- 1. Fresh seeded home WITHOUT --approve stalls on the trust dialog ------ +SEED="$LAB/seeded-stall" +seed_home "$SEED" lab-sm-stall +"$REAL_TMUX" -L "$SOCKET" new-session -d -s stall -n w -c "$SEED" -- \ + env HOME="$LAB/home-stall" PI_CODING_AGENT_DIR="$PI_DIR" PI_OFFLINE=1 \ + "$PI_BIN" --no-session --no-skills --no-prompt-templates \ + || fail "could not launch pi without --approve" +if ! capture_until stall 'Trust project folder' 15 "$LAB/pane-stall.txt"; then + fail "seeded home without --approve never showed Trust project folder? within 15s: +$(cat "$LAB/pane-stall.txt")" +fi +"$REAL_TMUX" -L "$SOCKET" send-keys -t stall:w Escape >/dev/null 2>&1 || true +"$REAL_TMUX" -L "$SOCKET" kill-session -t stall >/dev/null 2>&1 || true +CHECKED=$((CHECKED + 1)) +pass "fresh seeded Pi secondmate-shaped home stalls on Trust project folder? without --approve" + +# --- 2. Same shape WITH --approve starts past the dialog; trust.json intact - +SEED2="$LAB/seeded-approve" +seed_home "$SEED2" lab-sm-approve +printf '{}\n' > "$PI_DIR/trust.json" +"$REAL_TMUX" -L "$SOCKET" new-session -d -s approve -n w -c "$SEED2" -- \ + env HOME="$LAB/home-approve" PI_CODING_AGENT_DIR="$PI_DIR" PI_OFFLINE=1 \ + "$PI_BIN" --approve --no-session --no-skills --no-prompt-templates \ + || fail "could not launch pi with --approve" +if ! capture_until approve 'fm-primary-turnend-guard|fm-primary-pi-watch|No models available|escape interrupt' 15 \ + "$LAB/pane-approve.txt"; then + fail "seeded home with --approve never reached a post-trust TUI within 15s: +$(cat "$LAB/pane-approve.txt")" +fi +if printf '%s' "$(cat "$LAB/pane-approve.txt")" | grep -qiE 'Trust project folder'; then + fail "seeded home with --approve still showed Trust project folder?: +$(cat "$LAB/pane-approve.txt")" +fi +"$REAL_TMUX" -L "$SOCKET" send-keys -t approve:w Escape >/dev/null 2>&1 || true +"$REAL_TMUX" -L "$SOCKET" kill-session -t approve >/dev/null 2>&1 || true +TRUST_AFTER=$(cat "$PI_DIR/trust.json") +[ "$TRUST_AFTER" = "$TRUST_BEFORE" ] || [ "$TRUST_AFTER" = '{}' ] \ + || fail " --approve rewrote the disposable trust store: before=$TRUST_BEFORE after=$TRUST_AFTER" +CHECKED=$((CHECKED + 1)) +pass "seeded home with --approve starts past the trust dialog without rewriting trust.json" + +# --- 3. Unseeded path without --approve still prompts ----------------------- +UNSEEDED="$LAB/unseeded" +mkdir -p "$UNSEEDED/.pi/extensions" +printf 'export default function () {}\n' > "$UNSEEDED/.pi/extensions/dummy.ts" +printf '{}\n' > "$PI_DIR/trust.json" +"$REAL_TMUX" -L "$SOCKET" new-session -d -s unseeded -n w -c "$UNSEEDED" -- \ + env HOME="$LAB/home-unseeded" PI_CODING_AGENT_DIR="$PI_DIR" PI_OFFLINE=1 \ + "$PI_BIN" --no-session --no-skills --no-prompt-templates \ + || fail "could not launch pi on an unseeded path" +if ! capture_until unseeded 'Trust project folder' 15 "$LAB/pane-unseeded.txt"; then + fail "unseeded path without --approve never showed Trust project folder? within 15s: +$(cat "$LAB/pane-unseeded.txt")" +fi +"$REAL_TMUX" -L "$SOCKET" send-keys -t unseeded:w Escape >/dev/null 2>&1 || true +"$REAL_TMUX" -L "$SOCKET" kill-session -t unseeded >/dev/null 2>&1 || true +CHECKED=$((CHECKED + 1)) +pass "unseeded path without --approve still prompts on Trust project folder?" + +[ "$CHECKED" -ge 3 ] || fail "guard checked nothing useful (checked=$CHECKED)" +echo "# all fm-pi-seeded-home-trust-live-e2e checks passed ($CHECKED)" diff --git a/tests/fm-remote-delta-read.test.sh b/tests/fm-remote-delta-read.test.sh new file mode 100755 index 00000000000..49c37fe920a --- /dev/null +++ b/tests/fm-remote-delta-read.test.sh @@ -0,0 +1,220 @@ +#!/usr/bin/env bash +# Behavior tests for bin/fm-remote-delta-read.sh, the append-only reply-log +# reader a remote lane runs as its preemptible long poll. +# +# Pins, through the executable interface: +# * the delta schema: offsets, prefix and payload hashes, and payload bytes +# * every continuity-break reason: truncated, prefix-changed, missing, and +# line-exceeds-bound, plus an unsafe symlink or traversal target +# * an incomplete tail line is withheld until a newline completes it +# * exit 75 when the wait window closes with nothing appended +# * the per-poll executable boundary: an unchanged log costs one stat per +# sample, and the bounded capture/hashing path runs only when the file's +# stat identity changed - a same-size in-place rewrite still breaks the +# continuity hash, so statting cheaper never hides a change. +set -u + +# shellcheck source=tests/lib.sh +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P) +TMP_ROOT=$(fm_test_tmproot fm-remote-delta-read) +mkdir -p "$TMP_ROOT" +TMP_ROOT=$(cd "$TMP_ROOT" && pwd -P) +DELTA_HOME="$TMP_ROOT/home" +DELTA_LOG_REL=state/replies.status +mkdir -p "$DELTA_HOME/state" +READER="$ROOT/bin/fm-remote-delta-read.sh" + +EMPTY_SHA=$(: | shasum -a 256 | awk '{print $1}') +sha() { printf '%b' "$1" | shasum -a 256 | awk '{print $1}'; } + +run_reader() { # [rel] + FM_HOME="$DELTA_HOME" FM_REMOTE_DELTA_POLL_SECONDS=0.05 \ + "$READER" "${4:-$DELTA_LOG_REL}" "$1" "$2" "$3" +} + +# A growing log returns the complete appended lines with exact boundaries. +: > "$DELTA_HOME/$DELTA_LOG_REL" +run_reader 0 "$EMPTY_SHA" 4 > "$TMP_ROOT/growth.out" & +READER_PID=$! +sleep 0.3 +printf 'first line\n' >> "$DELTA_HOME/$DELTA_LOG_REL" +wait "$READER_PID" || fail "a delta on growth did not exit 0" +OUT=$(<"$TMP_ROOT/growth.out") +assert_contains "$OUT" 'status=delta' 'the grown log did not produce a delta' +assert_contains "$OUT" 'from_offset=0' 'the delta did not start at the caller cursor' +assert_contains "$OUT" 'to_offset=11' 'the delta did not stop at the complete line' +assert_contains "$OUT" "from_prefix_sha256=$EMPTY_SHA" 'the delta did not echo the caller prefix hash' +assert_contains "$OUT" 'payload_sha256='"$(sha 'first line\n')" 'the payload hash is not the appended bytes' +assert_contains "$OUT" 'payload_bytes=11' 'the payload byte count is wrong' +[ "$(tail -n 1 "$TMP_ROOT/growth.out")" = 'first line' ] || fail 'the delta did not carry the appended line' +pass 'an appended line produces a delta with exact offsets, hashes, and payload' + +# An unchanged log closes the window with 75 and never runs the snapshot path: +# one stat per sample is the whole per-poll cost. +DELTA_SHIM="$TMP_ROOT/delta-shim" +EXEC_LOG="$TMP_ROOT/delta-execs" +mkdir -p "$DELTA_SHIM" +for TOOL in perl shasum sha256sum od tail head wc tr date stat dirname basename; do + REAL=$(PATH=/usr/bin:/bin command -v "$TOOL" 2>/dev/null || true) + [ -n "$REAL" ] || continue + cat > "$DELTA_SHIM/$TOOL" <> "\$FM_TEST_EXEC_LOG" +exec $REAL "\$@" +SH + chmod +x "$DELTA_SHIM/$TOOL" +done +: > "$EXEC_LOG" +: > "$DELTA_HOME/$DELTA_LOG_REL" +FM_TEST_EXEC_LOG="$EXEC_LOG" PATH="$DELTA_SHIM:/usr/bin:/bin" run_reader 0 "$EMPTY_SHA" 2 > /dev/null && \ + fail "an unchanged log did not exit 75" || RC=$? +[ "${RC:-0}" -eq 75 ] || fail "an unchanged log closed its window with $RC instead of 75" +perl_execs=$(grep -cx perl "$EXEC_LOG" || true) +stat_execs=$(grep -cx stat "$EXEC_LOG" || true) +# The first poll always takes one snapshot: it must validate the caller's +# cursor prefix before waiting. The gate only suppresses the repeats. +[ "$perl_execs" -eq 1 ] || fail "an unchanged log ran the bounded capture $perl_execs times" +for TOOL in od tail head wc date; do + hits=$(grep -cx "$TOOL" "$EXEC_LOG" || true) + [ "$hits" -eq 0 ] || fail "an unchanged log ran $TOOL $hits times in the poll loop" +done +[ "$stat_execs" -ge 5 ] || fail "the unchanged window did not keep polling stat ($stat_execs)" +pass 'an unchanged log costs one stat per poll and exits 75 at the window' + +# Growth still pays the capture and hashing tools exactly when bytes appear. +: > "$EXEC_LOG" +FM_TEST_EXEC_LOG="$EXEC_LOG" PATH="$DELTA_SHIM:/usr/bin:/bin" run_reader 0 "$EMPTY_SHA" 4 > "$TMP_ROOT/growth2.out" & +READER_PID=$! +sleep 0.3 +printf 'counted change\n' >> "$DELTA_HOME/$DELTA_LOG_REL" +wait "$READER_PID" || fail 'the shimmed growth run did not exit 0' +assert_contains "$(<"$TMP_ROOT/growth2.out")" 'status=delta' 'the shimmed run lost the delta' +[ "$(grep -cx perl "$EXEC_LOG" || true)" -ge 1 ] || fail 'growth did not run the bounded capture' +[ "$(grep -cx shasum "$EXEC_LOG" || true)" -ge 2 ] || fail 'growth did not hash prefix and payload' +pass 'the capture and hashing path runs exactly once a real change lands' + +# A shrunk file reports the truncation with the hash of what actually remains. +printf 'alpha\nbeta\n' > "$DELTA_HOME/$DELTA_LOG_REL" +PREFIX_SHA=$(sha 'alpha\nbeta\n') +run_reader 11 "$PREFIX_SHA" 4 > "$TMP_ROOT/truncated.out" & +READER_PID=$! +sleep 0.3 +printf 'a\n' > "$DELTA_HOME/$DELTA_LOG_REL" +wait "$READER_PID" || fail 'the truncated read did not exit 0' +OUT=$(<"$TMP_ROOT/truncated.out") +assert_contains "$OUT" 'status=continuity-broken' 'truncation did not produce a break' +assert_contains "$OUT" 'reason=truncated' 'truncation was not named' +assert_contains "$OUT" 'to_offset=2' 'the break did not report the shrunk size' +assert_contains "$OUT" "to_prefix_sha256=$(sha 'a\n')" 'the break did not hash the remaining prefix' +pass 'a shrunk log breaks continuity as truncated with the remaining hash' + +# A same-size in-place rewrite changes only mtime/ctime: the stat gate must +# still take the snapshot, where the prefix hash catches the changed bytes. +# This rewrite lands in a later epoch second. +printf 'alpha\nbeta\n' > "$DELTA_HOME/$DELTA_LOG_REL" +run_reader 11 "$PREFIX_SHA" 4 > "$TMP_ROOT/rewrite.out" & +READER_PID=$! +sleep 1.1 +printf 'OMEGA\nbeta\n' > "$DELTA_HOME/$DELTA_LOG_REL" +wait "$READER_PID" || fail 'the rewritten read did not exit 0' +OUT=$(<"$TMP_ROOT/rewrite.out") +assert_contains "$OUT" 'status=continuity-broken' 'a same-size rewrite did not produce a break' +assert_contains "$OUT" 'reason=prefix-changed' 'the same-size rewrite was not named prefix-changed' +assert_contains "$OUT" 'to_offset=11' 'the break did not report the current size' +pass 'a same-size in-place rewrite breaks continuity as prefix-changed' + +# Model a same-second same-size rewrite at the stat executable boundary: +# size, inode, device, and whole-second timestamps stay fixed; only the +# fractions change. Rewrite the real log after the initial snapshot's prefix +# has been hashed, so scheduler load cannot move the test across a second. +SUBSECOND_SHIM="$TMP_ROOT/subsecond-shim" +mkdir -p "$SUBSECOND_SHIM" +cat > "$SUBSECOND_SHIM/stat" <<'SH' +#!/bin/sh +fraction=111111111 +[ ! -e "$FM_TEST_REWRITE_DONE" ] || fraction=222222222 +printf '11:100.%s:100.%s:123:456\n' "$fraction" "$fraction" +SH +REAL_SHASUM=$(PATH=/usr/bin:/bin command -v shasum) +cat > "$SUBSECOND_SHIM/shasum" < "\$FM_TEST_REWRITE_LOG" + fi + : > "\$FM_TEST_REWRITE_DONE" + fi + ;; +esac +SH +chmod +x "$SUBSECOND_SHIM/stat" "$SUBSECOND_SHIM/shasum" +printf 'alpha\nbeta\n' > "$DELTA_HOME/$DELTA_LOG_REL" +FM_TEST_REWRITE_LOG="$DELTA_HOME/$DELTA_LOG_REL" \ + FM_TEST_REWRITE_DONE="$TMP_ROOT/rewrite-done" \ + PATH="$SUBSECOND_SHIM:/usr/bin:/bin" \ + run_reader 11 "$PREFIX_SHA" 10 > "$TMP_ROOT/same-second.out" \ + || fail 'the same-second rewrite read did not exit 0' +[ -e "$TMP_ROOT/rewrite-done" ] || fail 'the initial prefix hash did not trigger the rewrite' +OUT=$(<"$TMP_ROOT/same-second.out") +assert_contains "$OUT" 'status=continuity-broken' 'the subsecond change did not break continuity' +assert_contains "$OUT" 'reason=prefix-changed' 'a same-second same-size rewrite was not detected' +pass 'a same-second same-size rewrite of the same inode breaks continuity' + +# A log that disappears mid-wait breaks as missing only for a nonzero cursor. +printf 'alpha\nbeta\n' > "$DELTA_HOME/$DELTA_LOG_REL" +FM_TEST_REWRITE_LOG="$DELTA_HOME/$DELTA_LOG_REL" \ + FM_TEST_REWRITE_DONE="$TMP_ROOT/remove-done" FM_TEST_REMOVE_LOG=1 \ + PATH="$SUBSECOND_SHIM:/usr/bin:/bin" \ + run_reader 11 "$PREFIX_SHA" 10 > "$TMP_ROOT/missing.out" \ + || fail 'the missing-file read did not exit 0' +OUT=$(<"$TMP_ROOT/missing.out") +assert_contains "$OUT" 'status=continuity-broken' 'a removed log did not produce a break' +assert_contains "$OUT" 'reason=missing' 'the removed log was not named missing' +pass 'a removed log breaks continuity as missing' + +# A removed log is not a break for a cursor at the origin: it keeps waiting, +# which is what a first poll against a not-yet-created log relies on. +run_reader 0 "$EMPTY_SHA" 1 > /dev/null && fail 'a missing log at offset 0 did not wait' || RC=$? +[ "${RC:-0}" -eq 75 ] || fail "a missing log at offset 0 exited $RC instead of 75" +pass 'a missing log at the origin cursor keeps waiting until the window closes' + +# An incomplete tail line is withheld until its newline lands, then delivered +# whole rather than as a fragment. +printf 'whole\n' > "$DELTA_HOME/$DELTA_LOG_REL" +run_reader 6 "$(sha 'whole\n')" 4 > "$TMP_ROOT/partial.out" & +READER_PID=$! +sleep 0.3 +printf 'frag' >> "$DELTA_HOME/$DELTA_LOG_REL" +sleep 0.4 +printf -- '-ment\n' >> "$DELTA_HOME/$DELTA_LOG_REL" +wait "$READER_PID" || fail 'the completed line did not exit 0' +OUT=$(<"$TMP_ROOT/partial.out") +assert_contains "$OUT" 'status=delta' 'the completed line did not produce a delta' +assert_contains "$OUT" 'to_offset=16' 'the delta did not stop at the completed line' +assert_contains "$OUT" 'payload_bytes=10' 'the payload did not carry the whole line' +[ "$(tail -n 1 "$TMP_ROOT/partial.out")" = 'frag-ment' ] || fail 'the payload did not join the fragment' +pass 'an unterminated tail is withheld until the newline completes it' + +# The same continuity rules apply to the schema's other break and refusal +# surfaces, with the wait window never entered. +printf 'past-bound tail' > "$DELTA_HOME/$DELTA_LOG_REL" +FM_HOME="$DELTA_HOME" FM_REMOTE_DELTA_MAX_BYTES=8 \ + run_reader 0 "$EMPTY_SHA" 1 > "$TMP_ROOT/bound.out" || fail 'the bound break did not exit 0' +assert_contains "$(<"$TMP_ROOT/bound.out")" 'reason=line-exceeds-bound' \ + 'a tail line longer than the payload bound did not break' +run_reader 0 "$EMPTY_SHA" 1 '../outside' > /dev/null 2>&1 && \ + fail 'a traversing path was accepted' || true +printf 'real\n' > "$DELTA_HOME/state/real.status" +ln -sfn real.status "$DELTA_HOME/state/link.status" +run_reader 0 "$EMPTY_SHA" 1 'state/link.status' > /dev/null 2>&1 && \ + fail 'a symlinked log was accepted' || true +pass 'the reader refuses traversal, symlinks, and oversized tail lines' + +printf 'delta-read contract tests complete\n' diff --git a/tests/fm-remote-job-orphan-reap.test.sh b/tests/fm-remote-job-orphan-reap.test.sh index 667be925aa3..9e0a43a91bc 100755 --- a/tests/fm-remote-job-orphan-reap.test.sh +++ b/tests/fm-remote-job-orphan-reap.test.sh @@ -114,7 +114,8 @@ start_worker() { export FM_REMOTE_JOB_STATE_ROOT="$state_root" export FM_REMOTE_JOB_PLATFORM_OVERRIDE=Linux export FM_REMOTE_JOB_ORPHAN_GRACE_SECONDS=1 - # shellcheck source=bin/fm-remote-job-lib.sh + # Production libraries are linted independently by fm-lint.sh. + # shellcheck source=/dev/null . "$ROOT/bin/fm-remote-job-lib.sh" fm_remote_job_start_linux_worker "$root" "$account_home" >&2 || exit 1 deadline=$(( $(date +%s) + 10 )) diff --git a/tests/fm-remote-job.test.sh b/tests/fm-remote-job.test.sh index a7a5b96873a..b546876d126 100755 --- a/tests/fm-remote-job.test.sh +++ b/tests/fm-remote-job.test.sh @@ -26,6 +26,7 @@ STALL_WORKER_PID= STALL_REPLACEMENT_PID= STALL_JOB_GROUP= QUIET_WORKER_PID= +SCAN_LANE_PID= mkdir -p "$REMOTE_ROOT/bin" "$REMOTE_HOME" "$ACCOUNT_HOME" "$RUNTIME_BIN" # worker.pid records the serving child, not its restart supervisor, so stopping # that pid alone leaves the supervisor to respawn - the leak @@ -38,6 +39,7 @@ cleanup_remote_job_fixture() { [ -z "$LOST_TERM_PID" ] || kill -KILL "$LOST_TERM_PID" 2>/dev/null || true [ -z "$REPLACEMENT_OWNER_PID" ] || kill -KILL "$REPLACEMENT_OWNER_PID" 2>/dev/null || true [ -z "$QUIET_WORKER_PID" ] || kill -KILL "$QUIET_WORKER_PID" 2>/dev/null || true + [ -z "$SCAN_LANE_PID" ] || kill -KILL "$SCAN_LANE_PID" 2>/dev/null || true local stall_pid for stall_pid in "$STALL_WORKER_PID" "$STALL_REPLACEMENT_PID"; do [ -n "$stall_pid" ] || continue @@ -1255,6 +1257,209 @@ quiet_stop "$QUIET_WORKER_PID" QUIET_WORKER_PID= pass "an idle worker still repairs queue permissions and stops promptly on TERM" +# fm_remote_job_read_state is the per-sample read of the result consumers and +# the lane preemption scan, so it is built from builtins and must keep the +# published contract: a regular non-symlink file of at most 64 bytes, one +# newline-terminated line, and a value in the published set. An unterminated +# trailing fragment inside the size bound is still tolerated, matching the +# former tail -n +2 check. +STATE_CORPUS="$TMP_ROOT/state-corpus" +mkdir -p "$STATE_CORPUS/job-x" +state_accepts() { #