diff --git a/.agents/skills/captain-hold-lifecycle/SKILL.md b/.agents/skills/captain-hold-lifecycle/SKILL.md index f2d2aa83b12..02e7d333524 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 c63eb1d5926..4efd674cf79 100644 --- a/.agents/skills/harness-adapters/references/harness/pi.md +++ b/.agents/skills/harness-adapters/references/harness/pi.md @@ -18,7 +18,6 @@ Verified on 2026-07-27 with Pi and Pi-signed 0.82.0 unless a fact gives another Native Codex sessions may request `ultra` through the native extension flag described by `../../../bin/fm-spawn.sh`; it is separate from Pi's thinking levels. Pi has no permission system, so workers are always autonomous. -Pi's installed `packages/coding-agent/docs/settings.md` UI and display section documents `regular` as the `tuiMode` default and `fullscreen` as experimental. Fullscreen can bury steering messages by rewriting scrollback, so Firstmate avoids it when the installed CLI supports the override. `../../../bin/fm-spawn.sh --help` owns the executable-pinning and version-safe launch mechanics. @@ -31,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 cf908fa11e8..c0cdd0540db 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() { # } fm_backend_source() { # - local name=$1 adapter rel path siblings + local name=$1 adapter rel sibling fm_backend_validate "$name" || return 1 adapter="$FM_BACKEND_LIB_DIR/backends/$name.sh" + # The sibling list rides in the positional parameters: zsh does not + # word-split an unquoted expansion, so a space-separated string is one path. case "$name" in tmux) - siblings="fm-tmux-lib.sh fm-composer-lib.sh fm-cursor-lib.sh fm-session-lock-lib.sh fm-agent-process-lib.sh fm-gemini-lib.sh" + set -- fm-tmux-lib.sh fm-composer-lib.sh fm-cursor-lib.sh fm-session-lock-lib.sh fm-agent-process-lib.sh fm-gemini-lib.sh ;; herdr) - siblings="fm-composer-lib.sh fm-transition-lib.sh fm-agent-process-lib.sh fm-session-lock-lib.sh fm-gemini-lib.sh" + set -- fm-composer-lib.sh fm-transition-lib.sh fm-agent-process-lib.sh fm-session-lock-lib.sh fm-gemini-lib.sh ;; zellij) - siblings="fm-backend-hometag-lib.sh fm-composer-lib.sh" + set -- fm-backend-hometag-lib.sh fm-composer-lib.sh ;; orca) - siblings="fm-composer-lib.sh" + set -- fm-composer-lib.sh ;; cmux) - siblings="fm-backend-hometag-lib.sh fm-composer-lib.sh" + set -- fm-backend-hometag-lib.sh fm-composer-lib.sh ;; *) return 1 ;; esac fm_backend_source_readable "$adapter" || return 1 - # shellcheck disable=SC2086 # sibling names are a fixed space-separated list - for rel in $siblings; do - path="$FM_BACKEND_LIB_DIR/$rel" - fm_backend_source_readable "$path" || return 1 + for rel in "$@"; do + sibling="$FM_BACKEND_LIB_DIR/$rel" + fm_backend_source_readable "$sibling" || return 1 done case "$name" in tmux) diff --git a/bin/fm-backlog-transition-lib.sh b/bin/fm-backlog-transition-lib.sh index d7dc67bee53..7d73826034f 100644 --- a/bin/fm-backlog-transition-lib.sh +++ b/bin/fm-backlog-transition-lib.sh @@ -78,6 +78,13 @@ FM_BACKLOG_CLOSE_REPLAY_RESULT= # library does not source fm-tasks-axi-lib.sh does not apply. # shellcheck source=bin/fm-timeout-lib.sh disable=SC1091 . "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/fm-timeout-lib.sh" +# fm-pr-lib.sh owns which URL is a Gerrit change. It is functions and empty +# globals only, so it is sourced once rather than re-initialising a caller's +# parsed identity. +if ! declare -F fm_pr_url_parse >/dev/null 2>&1; then + # shellcheck source=bin/fm-pr-lib.sh disable=SC1091 + . "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/fm-pr-lib.sh" +fi # Latched when a row read hits its bound. fm_backlog_row_show runs inside a # command substitution, so the subshell can READ this latch but cannot set it; @@ -509,16 +516,34 @@ fm_backlog_start() { # fm_backlog_mutate "$1" start "$2" } +# tasks-axi takes a --pr link only as a canonical GitHub or Forgejo pull request +# and refuses anything else, so a Gerrit change URL is recorded on the row as a +# note instead. The subshell keeps the parse from overwriting a caller's +# FM_PR_* identity. +fm_backlog_pr_is_gerrit_change() { # + ( fm_pr_url_parse "$1" && [ "$FM_PR_PROVIDER" = gerrit ] ) +} + fm_backlog_done() { # [flag...] - local data=$1 id=$2 + local data=$1 id=$2 arg previous_arg='' + local -a done_args=() shift 2 - fm_backlog_mutate "$data" "done" "$id" "$@" + for arg in "$@"; do + if [ "$previous_arg" = --pr ] && fm_backlog_pr_is_gerrit_change "$arg"; then + done_args[${#done_args[@]}-1]=--note + done_args+=("Gerrit change $arg") + else + done_args+=("$arg") + fi + previous_arg=$arg + done + fm_backlog_mutate "$data" "done" "$id" "${done_args[@]+"${done_args[@]}"}" } fm_backlog_row_artifact_supported() { local id=$1 flag=${2:-} value=${3:-} case "$flag" in - --pr) return 0 ;; + --pr) ! fm_backlog_pr_is_gerrit_change "$value" ;; --report) [ "$value" = "data/$id/report.md" ] ;; *) return 1 ;; esac @@ -550,8 +575,12 @@ fm_backlog_retain() { # [flag...] fi ;; --pr) - deliverable="${deliverable:+$deliverable; }PR $arg" - row_args=(--pr "$arg") + if fm_backlog_row_artifact_supported "$id" --pr "$arg"; then + deliverable="${deliverable:+$deliverable; }PR $arg" + row_args=(--pr "$arg") + else + deliverable="${deliverable:+$deliverable; }Gerrit change $arg" + fi ;; --note) deliverable="${deliverable:+$deliverable; }$arg" ;; esac diff --git a/bin/fm-captain-hold.sh b/bin/fm-captain-hold.sh index 880926494c2..c80b9979c34 100755 --- a/bin/fm-captain-hold.sh +++ b/bin/fm-captain-hold.sh @@ -49,6 +49,10 @@ # a UTC `Captain hold set:` timestamp in the task body: repeating an active # hold preserves the existing timestamp, while re-holding released work starts # a new lifecycle. A task already closed is refused rather than reopened. +# `--origin` also records the origin on a `Captain hold origin:` body line, which +# `complete` and `verify` check. The reason may hold parentheses and line breaks: +# tasks-axi refuses them, so `hold` escapes them where it writes the reason and +# readers decode them (bin/fm-hold-reason-lib.sh owns the encoding). # `--until` records the captain's own deferral date through `tasks-axi hold # --until`, so a "revisit later" answer is stored as a date instead of a live # card. @@ -134,7 +138,10 @@ # `--none` is an explicit semantic attestation that the just-reviewed surface # has no unresolved captain call, and is refused while the origin still has an # open keyed status decision. With a non-empty inventory, every listed task is -# verified durable (actively captain-held, or closed with a recorded answer), +# verified durable (captain-held, or carrying a recorded resolution), +# is never the origin itself, and, when `hold --origin` recorded one, was held +# for this origin; a hold with no recorded origin is accepted on durability +# alone and named in the output, # the inventory is unioned idempotently into the metadata, and every still-open # keyed status decision is transferred to its durable owner with a # `captain-held [key=...]` status close naming the inventory. Later review @@ -142,7 +149,8 @@ # surviving report and tasks without recreating task state. # `verify` is read-only and is called by scout teardown, so teardown cannot # erase a source before this gate has succeeded: every recorded inventory -# entry must still be durable and no keyed status decision may be open. +# entry must still satisfy the same durability and origin checks as `complete`, +# and no keyed status decision may be open. # Metadata compatibility: the attestation keeps the historical # `decisions_reviewed=1` and `decision_keys=` keys, and an inventory entry that # names no existing task resolves through the legacy `-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 @@ -948,6 +1052,7 @@ report_retained_artifact_failure() { # apply_pending_retained_artifact() { # local id=$1 marker local -a args=() + RETAINED_CLOSE_ARGS=() marker=$(fm_backlog_close_marker_path "$STATE" "$id") || return 1 [ -e "$marker" ] || [ -L "$marker" ] || return 0 fm_backlog_close_marker_validate "$marker" "$DATA" "$id" "$STATE" \ @@ -956,6 +1061,10 @@ apply_pending_retained_artifact() { # args=("${FM_BACKLOG_CLOSE_VALIDATED_ARGS[@]+"${FM_BACKLOG_CLOSE_VALIDATED_ARGS[@]}"}") case "${args[0]-}" in --pr|--report) + if [ "${args[0]}" = --pr ] && fm_backlog_pr_is_gerrit_change "${args[1]-}"; then + RETAINED_CLOSE_ARGS=(--note "Gerrit change ${args[1]}") + return 0 + fi fm_backlog_row_artifact_supported "$id" "${args[@]}" || return 0 fm_backlog_mutate "$DATA" update "$id" "${args[@]}" \ || { report_retained_artifact_failure "$id" "$marker"; return 1; } @@ -968,7 +1077,7 @@ close_answered() { # tasks_axi unhold "$1" >/dev/null else apply_pending_retained_artifact "$1" || return 1 - tasks_axi "done" "$1" >/dev/null + tasks_axi "done" "$1" "${RETAINED_CLOSE_ARGS[@]+"${RETAINED_CLOSE_ARGS[@]}"}" >/dev/null fi } @@ -1622,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 @@ -1654,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 @@ -1697,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-classify-lib.sh b/bin/fm-classify-lib.sh index 11cc7f24cfb..7482e5a9df6 100755 --- a/bin/fm-classify-lib.sh +++ b/bin/fm-classify-lib.sh @@ -1057,6 +1057,28 @@ EOF printf '%s' "$verb" } +# The status file inside that is this home's outbound parent channel +# rather than a self-home task status log, printed; empty when there is none. +# Only a remote mate home resolves one - its state/parent-replies.status is the +# parent channel (bin/fm-parent-channel-lib.sh owns that resolution, sourced +# lazily here because that library sources this one at its top level, so a +# top-level source would be circular). A main home, a local mate - whose +# channel lives in the parent home - or an unusable identity or binding keeps +# every file, so ordinary task logs fold and wake exactly as before. The home +# is the directory containing , the /state layout every caller of +# these fleet-wide scans shares; a state dir outside such a home excludes +# nothing. Callers compare the resolved path, never the file name, so a +# parent-replies.status in any other home shape stays an ordinary task log. +status_scan_parent_channel_exclude() { # + local state=$1 exclude + if ! command -v fm_parent_channel_outbound_status >/dev/null 2>&1; then + # shellcheck source=bin/fm-parent-channel-lib.sh + . "$_FM_CLASSIFY_LIB_DIR/fm-parent-channel-lib.sh" + fi + exclude=$(fm_parent_channel_outbound_status "$(dirname "$state")" "$state") || return 0 + printf '%s\n' "$exclude" +} + # Fleet-wide wrapper around status_open_decisions: scans every task's status # log under and prefixes each still-open decision with its owning task # id, so a per-wake or per-session surface can print the consolidated open set @@ -1065,9 +1087,11 @@ EOF # one "\t\t\t" line per open decision, in glob (task id) # order; prints nothing when none are open. scan_open_decisions() { # - local state=$1 f task open line + local state=$1 f task open line exclude + exclude=$(status_scan_parent_channel_exclude "$state") for f in "$state"/*.status; do [ -e "$f" ] || continue + [ "$f" = "$exclude" ] && continue task=$(basename "$f"); task="${task%.status}" open=$(status_open_decisions "$f") || continue [ -n "$open" ] || continue @@ -1358,9 +1382,11 @@ status_open_decisions_incremental() { # [] # the whole-file status_open_decisions, so a fleet-wide per-drain scan stays # bounded by new appends rather than total lifetime log size across every task. scan_open_decisions_incremental() { # - local state=$1 f task open line + local state=$1 f task open line exclude + exclude=$(status_scan_parent_channel_exclude "$state") for f in "$state"/*.status; do [ -e "$f" ] || continue + [ "$f" = "$exclude" ] && continue task=$(basename "$f"); task="${task%.status}" open=$(status_open_decisions_incremental "$f") || continue [ -n "$open" ] || continue @@ -1375,9 +1401,11 @@ EOF } status_presentation_snapshot() { # - local state=$1 f task size ident + local state=$1 f task size ident exclude + exclude=$(status_scan_parent_channel_exclude "$state") for f in "$state"/*.status; do [ -e "$f" ] || continue + [ "$f" = "$exclude" ] && continue [ -f "$f" ] && [ -r "$f" ] && [ ! -L "$f" ] || continue task=$(basename "$f"); task="${task%.status}" size=$(_fm_status_file_size "$f") || return 1 @@ -1989,9 +2017,11 @@ status_line_is_unread_surface() { # # Prints nothing when none are unread. Directory scan rejects status symlinks # the same way scan_open_decisions does. scan_unread_surface_lines() { # - local state=$1 f task lines line + local state=$1 f task lines line exclude + exclude=$(status_scan_parent_channel_exclude "$state") for f in "$state"/*.status; do [ -e "$f" ] || continue + [ "$f" = "$exclude" ] && continue task=$(basename "$f"); task="${task%.status}" lines=$(status_new_lines_since_cursor "$f") || return 1 [ -n "$lines" ] || continue 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 30f39851203..d5a2f902a74 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] @@ -1431,6 +1462,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= @@ -1486,8 +1539,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 601c4cf447c..5b32a455ea7 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-parent-channel-lib.sh b/bin/fm-parent-channel-lib.sh index 24718258317..160d580ac02 100644 --- a/bin/fm-parent-channel-lib.sh +++ b/bin/fm-parent-channel-lib.sh @@ -123,6 +123,28 @@ fm_parent_channel_destination() { # esac } +# The outbound parent-channel status path that lives INSIDE , printed, +# when is a remote mate; non-zero for a main home, a local mate, or an +# unusable identity or binding. Only the remote route resolves the channel into +# the mate's own state dir, so parent-replies.status there is the mate's parent +# channel rather than a self-home task status file: a home's own status scans +# and decision folds exclude exactly this resolved path (the same special case +# fm-pending-reply-lib.sh's wrong-home detection applies). A local mate's +# channel lives in the parent home's state/.status, which the parent's +# scans must keep classifying, so only the remote route resolves here. +fm_parent_channel_outbound_status() { # + local home=$1 state=$2 destination rc=0 + destination=$(fm_parent_channel_destination "$home" "$state") || rc=$? + [ "$rc" -eq 0 ] || return 1 + # The substitution above ran the resolver in a subshell, so its route global + # died with it; resolve once more in this shell (stdout discarded, the same + # shape fm-pending-reply-lib.sh's wrong-home detection uses) so the route + # check reads the resolver's own verdict rather than re-deriving it. + fm_parent_channel_destination "$home" "$state" >/dev/null || return 1 + [ "$FM_PARENT_CHANNEL_ROUTE" = remote ] || return 1 + printf '%s\n' "$destination" +} + # Fold onto one bounded line, so a note copied from a child ledger or a # hold reason cannot break the channel's line framing. fm_parent_channel_clean_note() { # diff --git a/bin/fm-remote-delta-read.sh b/bin/fm-remote-delta-read.sh index d4c26bd6697..84aef13d051 100755 --- a/bin/fm-remote-delta-read.sh +++ b/bin/fm-remote-delta-read.sh @@ -10,6 +10,16 @@ # the source. A shortened or changed prefix returns a structured continuity-break # result instead of silently rebasing the cursor. # +# 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 # same way after cleanup. The remote job worker preempts this read-only poll to # unblock any queued command other than another reply long-poll, then publishes @@ -19,7 +29,7 @@ set -eu FM_HOME=${FM_HOME:?FM_HOME is required} MAX_BYTES=${FM_REMOTE_DELTA_MAX_BYTES:-65536} -POLL_SECONDS=${FM_REMOTE_DELTA_POLL_SECONDS:-0.2} +POLL_SECONDS=${FM_REMOTE_DELTA_POLL_SECONDS:-0.5} die() { printf 'error: %s\n' "$1" >&2; exit 1; } usage() { sed -n '2,11p' "$0" | sed 's/^# \{0,1\}//'; exit 2; } @@ -79,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 @@ -129,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 68d3b62c064..ce6e4090737 100755 --- a/bin/fm-remote-job-lib.sh +++ b/bin/fm-remote-job-lib.sh @@ -55,6 +55,18 @@ # Abandoned .stage.* staging litter older than # FM_REMOTE_JOB_STAGE_REAP_SECONDS is reaped by the worker's stale sweep. # +# Result consumers and active-command monitors sample every 0.25 seconds by +# default; the dispatcher's post-activity burst still samples every 0.05 seconds. +# FM_REMOTE_JOB_ACTIVE_POLL_SECONDS overrides the active/result interval; an +# explicitly supplied FM_REMOTE_JOB_POLL_SECONDS remains the legacy fallback +# for both intervals. Resolve the active default before filling the dispatcher +# default, and retain it when the library is sourced again. +# Once-per-second cancellation, preemption, and disconnect checks can overshoot +# their due time by one sampling interval plus work/scheduling time, as can the +# active command's timeout check. Completion and result collection can each add +# one interval. Sleeps stay ordinary child processes: existing signal handlers +# and the separate cancellation/preemption TERM-to-KILL grace are unchanged. +# # The worker accepts only a tracked, non-symlink executable named fm-*.sh below # its configured FM_ROOT/bin. Every child receives env -i with the composed # PATH, HOME, FM_HOME, FM_ROOT_OVERRIDE, and FM_REMOTE_JOB_ACTIVE=1. The PATH @@ -88,6 +100,7 @@ FM_REMOTE_JOB_MAX_BYTES=${FM_REMOTE_JOB_MAX_BYTES:-1048576} FM_REMOTE_JOB_QUEUE_TIMEOUT=${FM_REMOTE_JOB_QUEUE_TIMEOUT:-360} FM_REMOTE_JOB_TIMEOUT=${FM_REMOTE_JOB_TIMEOUT:-360} FM_REMOTE_JOB_WAIT_GRACE=${FM_REMOTE_JOB_WAIT_GRACE:-30} +FM_REMOTE_JOB_ACTIVE_POLL_SECONDS=${FM_REMOTE_JOB_ACTIVE_POLL_SECONDS:-${FM_REMOTE_JOB_POLL_SECONDS:-0.25}} FM_REMOTE_JOB_POLL_SECONDS=${FM_REMOTE_JOB_POLL_SECONDS:-0.05} FM_REMOTE_JOB_REAP_SECONDS=${FM_REMOTE_JOB_REAP_SECONDS:-3600} FM_REMOTE_JOB_STAGE_REAP_SECONDS=${FM_REMOTE_JOB_STAGE_REAP_SECONDS:-600} @@ -486,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 @@ -677,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" @@ -696,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" || @@ -720,20 +765,19 @@ 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" return 1 fi fi - sleep "$FM_REMOTE_JOB_POLL_SECONDS" + sleep "$FM_REMOTE_JOB_ACTIVE_POLL_SECONDS" done } diff --git a/bin/fm-remote-job-worker.sh b/bin/fm-remote-job-worker.sh index 8973f5d6dae..118d75c3493 100755 --- a/bin/fm-remote-job-worker.sh +++ b/bin/fm-remote-job-worker.sh @@ -23,11 +23,12 @@ # worker's orphan recovery. # # The serving loop does not busy-poll an idle queue. After a lane starts or is -# reaped it rescans every FM_REMOTE_JOB_POLL_SECONDS for 20 passes, so a home +# reaped it rescans every FM_REMOTE_JOB_POLL_SECONDS for four passes, so a home # whose lane just finished starts its next job promptly; otherwise it sleeps -# one second between passes. That bound is how long newly staged or cancelled -# work, a lane that died, an orphaned claim, or an expired queue deadline can -# wait for the next pass, and it refreshes the readiness heartbeat about once +# one second between passes. Work arriving after the four-pass burst may wait +# for that quiet scan. Newly staged or cancelled work, a lane that died, an +# orphaned claim, or an expired queue deadline can wait that interval plus +# scan work and scheduling time. It refreshes the readiness heartbeat about once # per second, far inside the probe's 10-second freshness bound. The stale # sweep, whose state preparation also re-applies the queue directories' 0700 # modes, runs at startup and then at most every 60 seconds, never more rarely @@ -61,7 +62,7 @@ FM_REMOTE_JOB_ORPHAN_GRACE_SECONDS=$(worker_bounded_setting "${FM_REMOTE_JOB_ORP FM_REMOTE_JOB_SUPERVISOR_MAX_RESTARTS=$(worker_bounded_setting "${FM_REMOTE_JOB_SUPERVISOR_MAX_RESTARTS:-}" 20) FM_REMOTE_JOB_SUPERVISOR_MAX_BACKOFF_SECONDS=$(worker_bounded_setting "${FM_REMOTE_JOB_SUPERVISOR_MAX_BACKOFF_SECONDS:-}" 5) FM_REMOTE_JOB_SUPERVISOR_HEALTHY_SECONDS=$(worker_bounded_setting "${FM_REMOTE_JOB_SUPERVISOR_HEALTHY_SECONDS:-}" 10) -WORKER_FAST_PASSES=20 +WORKER_FAST_PASSES=4 WORKER_IDLE_WAIT_SECONDS=1 WORKER_SWEEP_SECONDS=60 @@ -739,7 +740,7 @@ worker_run_with_timeout() { # [args...] fi next_check=$((SECONDS + 1)) fi - sleep "$FM_REMOTE_JOB_POLL_SECONDS" + sleep "$FM_REMOTE_JOB_ACTIVE_POLL_SECONDS" done wait "$group_pid" 2>/dev/null rc=$? @@ -750,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 2a16b95bc97..fcee5a152b3 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 @@ -340,6 +346,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 @@ -1878,6 +1887,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 @@ -2030,7 +2050,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 @@ -2296,6 +2316,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-supervise-daemon.sh b/bin/fm-supervise-daemon.sh index 7a7191807df..a2a7664f4fb 100755 --- a/bin/fm-supervise-daemon.sh +++ b/bin/fm-supervise-daemon.sh @@ -65,9 +65,10 @@ # undelivered past FM_MAX_DEFER_SECS, the daemon retries a normal flush and # writes state/.subsuper-inject-wedged and attempts a configurable active # alert if submit still cannot be confirmed. -# - Cheap heartbeat catch-all: every HEARTBEAT_SCAN_SECS the daemon greps all -# state/*.status for a captain-relevant line the per-wake classifier might -# have missed (e.g. a status verb outside CAPTAIN_RE) and escalates it. +# - Cheap heartbeat catch-all: every HEARTBEAT_SCAN_SECS the daemon greps the +# state dir's task status logs for a captain-relevant line the per-wake +# classifier might have missed (e.g. a status verb outside CAPTAIN_RE) and +# escalates it. # # The robustness shell from the prior always-inject version is preserved: # single-instance lock (portable helper, no flock dependency), crash-loop @@ -1184,8 +1185,8 @@ _oldest_line_age() { # -> seconds since the oldest buffered item first ar # re-peek; gone -> clear; still declaring the wait, on an idle OR a busy pane # -> escalate a recheck digest naming which human the wait is on, and reset # the window (repeating bounded re-surface, never a wedge). -# 3) heartbeat scan: every HEARTBEAT_SCAN_SECS, grep state/*.status for a -# captain-relevant line the per-wake classifier missed and escalate it. +# 3) heartbeat scan: every HEARTBEAT_SCAN_SECS, run the catch-all status scan in +# the block below and escalate what it finds; that block owns its file set. housekeeping() { # local state=$1 now due f key task win marker age last max_defer oldest pause_secs marker_epoch until bounded_until pause_reason now=$(_now) @@ -1339,11 +1340,17 @@ housekeeping() { # # because the event this backstop most needs to catch is precisely one a # later routine append has already moved past; fm-classify-lib.sh's span # read decides relevance, and the classified-through offset is the dedup. + # A remote mate's own parent channel is not a self-home task status log, + # so it is excluded here exactly as in the watcher's twin backstop + # (fm-watch.sh heartbeat_scan_finds_actionable); the home-shape-aware + # resolution lives in status_scan_parent_channel_exclude. if [ "$(_file_age "$state/.subsuper-last-scan")" -ge "${FM_HEARTBEAT_SCAN_SECS:-$HEARTBEAT_SCAN_SECS_DEFAULT}" ]; then _now > "$state/.subsuper-last-scan" - local event record rest endpoint ident rc + local event record rest endpoint ident rc exclude + exclude=$(status_scan_parent_channel_exclude "$state") for f in "$state"/*.status; do [ -e "$f" ] || [ -L "$f" ] || continue + [ "$f" = "$exclude" ] && continue task=$(basename "$f"); task="${task%.status}" record=$(status_span_first_actionable_record "$f" \ "$(status_seen_offset "$state" "$task")") diff --git a/bin/fm-supervision-host.sh b/bin/fm-supervision-host.sh index 2de90d18929..631b179bc87 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 @@ -155,10 +164,15 @@ # a new engine conversation after this many turns; every main session start # also opens a new one), FM_SUPERVISION_HOST_READY_TIMEOUT (25: how long a # successor cycle may take to verify), FM_SUPERVISION_HOST_POLL (1). +# Park duration uses Bash's process-relative SECONDS counter (including Bash +# 3.2), while durable timestamps still use epoch time. This is not a portable +# monotonic-clock guarantee. Arm exit probes use ordinary 0.5-second child +# sleeps within the unchanged POLL-cadence maintenance and boundary checks; +# close observation and a shell-only caught signal may wait that interval plus +# work/scheduling time. No stop-signal disposition or cleanup bound changes. # FM_TEST_SUPERVISION_HOST_CLOCK names a file holding the park's elapsed -# seconds, which the park and turn boundary checks read in place of the wall -# clock only when FM_TEST_SEAM=1; tests/lib.sh arms the marker for isolated -# suites. +# seconds, which the park and turn boundary checks read in place of SECONDS +# only when FM_TEST_SEAM=1; tests/lib.sh arms the marker for isolated suites. set -u SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" @@ -226,8 +240,10 @@ 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 HOST_STARTED=$(date +%s) GEN="host-$HOST_PID-$HOST_STARTED" TURN_SEQ=0 @@ -245,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= @@ -356,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 } @@ -442,22 +475,24 @@ start_arm() { # [--restart]; sets the started pi STARTED_ARM_OUT=$out } -park_elapsed() { +park_elapsed() { # Sets PARK_ELAPSED without a production clock/helper fork. if [ "${FM_TEST_SEAM:-}" = 1 ] && [ -n "${FM_TEST_SUPERVISION_HOST_CLOCK:-}" ]; then - numeric_or "$(cat "$FM_TEST_SUPERVISION_HOST_CLOCK" 2>/dev/null)" 0 + PARK_ELAPSED=$(numeric_or "$(cat "$FM_TEST_SUPERVISION_HOST_CLOCK" 2>/dev/null)" 0) return fi - printf '%s\n' $(( $(date +%s) - HOST_STARTED )) + PARK_ELAPSED=$((SECONDS - HOST_STARTED_SECONDS)) } boundary_reached() { - [ "$(park_elapsed)" -ge "$PARK_SECONDS" ] + park_elapsed + [ "$PARK_ELAPSED" -ge "$PARK_SECONDS" ] } # True when an engine turn started now could still be running at the turn # limit (the boundary unless the owner set a later one). turn_crosses_boundary() { - [ $(( $(park_elapsed) + TURN_TIMEOUT + ENGINE_GRACE )) -ge "$PARK_LIMIT" ] + park_elapsed + [ $((PARK_ELAPSED + TURN_TIMEOUT + ENGINE_GRACE)) -ge "$PARK_LIMIT" ] } # End the park at the boundary: stop the current and successor arms and this @@ -471,7 +506,8 @@ boundary_exit() { SUCCESSOR_PID= SUCCESSOR_OUT= "$SCRIPT_DIR/fm-watch-arm.sh" --stop >/dev/null 2>&1 || true - log_line "boundary after $(park_elapsed)s" + park_elapsed + log_line "boundary after ${PARK_ELAPSED}s" emit 'supervision-host: cycle boundary - the host ended its park at its bound; drain, acknowledge, and end the turn, and the next park starts on its own' exit 0 } @@ -496,12 +532,11 @@ await_close() { refresh_process "$ARM_PID" [ "$READY_PENDING" -eq 0 ] || stream_ready_line boundary_reached && return 1 - # The arm's exit is probed at a tenth of a second between POLL-cadence - # checks: the close is read as soon as the arm dies instead of up to POLL - # seconds late, while refresh keeps its per-second cadence. - i=$((POLL * 10)) + # Probe the arm's exit twice a second between POLL-cadence checks, without + # changing the outer identity refresh, readiness, or boundary cadence. + i=$((POLL * 2)) while [ "$i" -gt 0 ] && fm_pid_alive "$ARM_PID"; do - sleep 0.1 + sleep 0.5 i=$((i - 1)) done done @@ -545,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. @@ -629,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= @@ -1000,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 dfff544fbdf..2b7044dc182 100755 --- a/bin/fm-test-run.sh +++ b/bin/fm-test-run.sh @@ -309,6 +309,7 @@ family_for_basename() { ;; fm-daemon.test.sh|fm-guard-stale-banner.test.sh|fm-pi-watch-extension.test.sh|\ fm-session-lock-ancestry.test.sh|fm-cursor-primary.test.sh|\ + fm-parent-channel-scan-exclusion.test.sh|\ fm-supervision-events.test.sh|fm-turnend-guard.test.sh|fm-wake-daemon-lifecycle-e2e.test.sh|\ fm-wake-drain-unread-status.test.sh|\ fm-tool-update-check.test.sh|\ @@ -363,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|\ @@ -792,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 @@ -1649,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/bin/fm-watch.sh b/bin/fm-watch.sh index 35c5a9f1b75..6e51f76770a 100755 --- a/bin/fm-watch.sh +++ b/bin/fm-watch.sh @@ -1990,8 +1990,13 @@ age_of() { # seconds since file mtime; "due immediately" if missing # The caller records reported state only after surfacing or intentional absorption, # and commits a status classification position only after a successful span read. scan_signals() { - local f sig sf + local f sig sf exclude + # A remote mate's own parent channel is not a self-home task status log; the + # home-shape-aware exclusion and its precedent live in + # status_scan_parent_channel_exclude (fm-classify-lib.sh). + exclude=$(status_scan_parent_channel_exclude "$STATE") for f in "$STATE"/*.status "$STATE"/*.turn-ended; do + [ "$f" = "$exclude" ] && continue if [ ! -e "$f" ]; then case "$f" in *.status) [ -L "$f" ] || continue ;; *) continue ;; esac fi @@ -2255,10 +2260,14 @@ EOF # is absorbed; it surfaces only an event the per-wake path absorbed by mistake - # the fail-safe backstop. heartbeat_scan_finds_actionable() { - local f task record rest endpoint ident rc found=1 sig marker + local f task record rest endpoint ident rc found=1 sig marker exclude + # Same self-home exclusion as scan_signals: a remote mate's parent channel + # must not come back through the heartbeat fail-safe backstop. + exclude=$(status_scan_parent_channel_exclude "$STATE") FM_HEARTBEAT_SURFACE_ENDPOINTS='' for f in "$STATE"/*.status; do [ -e "$f" ] || [ -L "$f" ] || continue + [ "$f" = "$exclude" ] && continue task=$(basename "$f"); task="${task%.status}" record=$(status_span_first_actionable_record "$f" "$(hb_surfaced_offset "$task")") rc=$? diff --git a/docs/agent-control.md b/docs/agent-control.md index c949a13ba96..2c9a78a34cc 100644 --- a/docs/agent-control.md +++ b/docs/agent-control.md @@ -50,6 +50,7 @@ muse is the one verified adapter that restores the cancelled prompt back into it The clear is refused before anything is sent when the recorded backend cannot deliver it. `exit` reads the composer's state before typing the exit command and requires the exact `empty` verdict; a `pending` verdict refuses by naming the pending text, and any other verdict (`unknown`, `pending-unproven`, or an unreadable read) refuses as not proven empty, matching the fail-safe contract every other consumer that can overwrite composer input follows. +An idle Claude session with a titled composer top rule can pass that same empty-composer check for send, exit, and relaunch; a draft in the composer still blocks input that would overwrite it. **Teardown and discard are not verbs and will not become verbs.** `exit` stops an agent and preserves everything else. diff --git a/docs/captain-hold-lifecycle.md b/docs/captain-hold-lifecycle.md index 1a3e1cc7cdf..482ca3572a5 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, percent signs, 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. @@ -140,6 +149,7 @@ After cleanup, and still under the task's own lock, teardown does three things: - It records one `Deliverable of the finished work: ...` line at the end of the task body. - It copies a supported pull request or canonical `data//report.md` into the row's structured artifact fields. + A Gerrit change URL is not a pull request tasks-axi accepts, so it appears only in the deliverable line. - It runs `tasks-axi reopen`. The row returns to Queued with its hold intact. @@ -152,6 +162,7 @@ That record carries the retention intent as a `mode=retain` line. An interrupted cleanup therefore replays the retention at the next session start through the same record, validator, and lock as an ordinary close, and never closes the row. If the captain answers before replay, `answer` validates that record and copies any supported retained pull request or report into the row before closing it. +A retained Gerrit change URL is instead recorded as a `Gerrit change ` note on that close. Replay then retires the record. ### Known retained-delivery gaps @@ -468,7 +479,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. @@ -517,7 +528,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 abffe75a137..b54c6081f45 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -839,6 +839,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/remote-secondmates.md b/docs/remote-secondmates.md index eb7537cf84b..e13eebacf9f 100644 --- a/docs/remote-secondmates.md +++ b/docs/remote-secondmates.md @@ -82,7 +82,8 @@ On macOS the worker is `dev.firstmate.remote-job`, an Aqua-scoped LaunchAgent at After that bootstrap, every non-doctor `fm-on.sh` target runs through that worker in the remote account's GUI session. It never runs in the SSH process or a Herdr pane. Linux uses the same queue and worker protocol without the Aqua-session requirement. -When idle, the worker checks for newly staged work about once per second; after a lane starts or finishes it checks more frequently for a short period. +The [`fm-remote-job-worker.sh` header](../bin/fm-remote-job-worker.sh) owns dispatch cadence and the quiet-scan latency for work arriving after its post-activity burst. +Active-command and result waits use a separate sampling interval; the [`fm-remote-job-lib.sh` header](../bin/fm-remote-job-lib.sh) owns its defaults, overrides, and completion, cancellation, and timeout latency contract. ### Job lanes and preemption @@ -512,6 +513,7 @@ A process-event source takes these steps: - It does not carry blank separators. The listener holds its claim across an empty wait and across a delta it re-arms, so a line appended during either is collected without waiting for the next supervision cycle. +The [`fm-remote-delta-read.sh` header](../bin/fm-remote-delta-read.sh) owns snapshot sampling and its line-visibility and wait-window latency contract. It stops when that registration is retired, the registered command changes, or the home's owner lease lapses. `bin/fm-procevent.sh` owns the generic relisten rule, and `bin/fm-procevent-remote-reply.sh` owns this adapter's answer. diff --git a/docs/secondmate-parent-channel.md b/docs/secondmate-parent-channel.md index a15a163107c..614a26fca83 100644 --- a/docs/secondmate-parent-channel.md +++ b/docs/secondmate-parent-channel.md @@ -38,6 +38,8 @@ A duplicate line is harmless and a missed one is not, so the mate may still appe For marked replies, the report helper accepts no caller-selected destination and uses the channel resolver for both local and remote homes; its script header owns the exact invocation contract. The pending-reply guard may restate only the correlated line from a local mate's `state/.status` onto the parent channel, which repairs the common parent-home versus mate-home mixup without accepting arbitrary mate-home sightings as acknowledgement. Other correlated mate-home status lines remain wrong-home evidence, while a remote home's routed `state/parent-replies.status` is already the parent channel and is not classified as wrong-home. +The mate home's own status scans treat that remote channel the same way: `status_scan_parent_channel_exclude` in `bin/fm-classify-lib.sh` resolves the outbound path through the same `bin/fm-parent-channel-lib.sh` binding, and the watcher's signal scan and heartbeat backstop, the away-mode daemon's catch-all scan, and the fleet-wide folds skip exactly that resolved path, never a file name. +The remote reply adapter already mirrors every channel line into the parent home, so folding the channel again here would only spin spurious wakes and a phantom `parent-replies` task, while a `parent-replies.status` in a main home or in a local mate is an ordinary task log that keeps folding and waking. A missed-reply escalation includes the complete first sighting path and line number in readable shell-escaped form. ## What is deliberately not built @@ -55,6 +57,7 @@ A missed-reply escalation includes the complete first sighting path and line num `tests/fm-teardown.test.sh` covers teardown delivering a child's final line and refusing when the channel cannot be written. `tests/fm-brief.test.sh` pins the charter's channel rule. `tests/fm-pending-reply.test.sh` covers helper-selected local routing, remote-channel classification, same-basename restatement before false escalation, readable wrong-home diagnostics, and the rule that arbitrary mate-home sightings never acknowledge a reply. +`tests/fm-parent-channel-scan-exclusion.test.sh` covers the home-shape-aware scan exclusion against real remote, main-home, and local-mate fixtures: the watcher signal scan, both heartbeat backstops, the fleet-wide folds, and the real `fm-wake-drain.sh` end to end. ## Live verification diff --git a/docs/supervision-host.md b/docs/supervision-host.md index 7b832029171..ccc2ac95bfd 100644 --- a/docs/supervision-host.md +++ b/docs/supervision-host.md @@ -46,7 +46,7 @@ Until they land, their current behavior stays as described in their own owners. | Component | Owner | Role | |---|---|---| -| The loop | `bin/fm-supervision-host.sh` | Its header owns the per-close order, the park boundary, ownership checks, predecessor cleanup, state files, and tunables. | +| The loop | `bin/fm-supervision-host.sh` | Its header owns the per-close order, the park boundary and elapsed clock, arm-exit sampling and signal-observation latency, ownership checks, predecessor cleanup, state files, and tunables. | | The arm owners | Each primary's existing arm owner | Runs the host for a home that runs it and delivers a handed-back wake to main; see [Arm owners](#arm-owners). | | The engine | `bin/fm-supervision-engine-lib.sh` | Owns the home gate, including the default on Claude and the opt-out, the verified-engine list, and one bounded engine turn, including the reap of engine tool processes that outlive it. | | Row eligibility and the offer rule | `bin/fm-branch-dispatch.mjs` | The command entry to `.pi/extensions/lib/fm-branch-dispatch.ts`, so the host and the Pi extension compute branch-claimable rows, their task scope, and whether the branch may take a close (`branchOfferForWake`) from one owner; it also renders the wake message with the same away-posture tail, or the dialog mirror at its head. | @@ -110,7 +110,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. @@ -226,7 +228,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 2de0652a158..f48bf9b535e 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. @@ -736,7 +757,7 @@ Cursor is deliberately outside this cursor-anchored empty-composer matrix becaus ### 2026-09-20 claude 2.1.236 statusLine footer through Herdr Verified on 2026-09-20 on macOS arm64 (Darwin 25.6.0) against Claude Code 2.1.236 running as Firstmate workers in Herdr 0.8.0 panes, read through Herdr's ANSI capture with its exact capability descriptor (`styled=1`, `cursor=0`, `identity=1`, `rows=20`). -Claude 2.x draws its composer as a bare `❯` + U+00A0 row between two solid `─` rules, and this home's configured statusLine plus Claude's permission-mode hint render on the two rows directly below the closing rule. +In these observed panes, Claude drew its composer as a bare `❯` + U+00A0 row between two solid `─` rules, and this home's configured statusLine plus Claude's permission-mode hint rendered on the two rows directly below the closing rule. The statusLine's first glyph is `→` (U+2192), which is Cursor's own prompt glyph, so the cursorless "bottom-most shape wins" rule selected the statusLine as a bare composer at `kind=bare first=18 last=19` within the 20-row tail, read the statusLine and the hint row as wrapped typed input, and answered `pending` on a composer holding nothing. `fm_task_inbox_ring` (`bin/fm-task-inbox-lib.sh`) defers on exactly that verdict, and `bin/fm-watch.sh`'s re-ring calls the same function, so both the first doorbell and every retry were skipped and the worker never saw the steer. 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-backend.test.sh b/tests/fm-backend.test.sh index 96d00b10303..a9030018d8c 100755 --- a/tests/fm-backend.test.sh +++ b/tests/fm-backend.test.sh @@ -502,17 +502,32 @@ test_backend_validate_refuses_unknown() { } test_backend_source_shell_portable() { - local out status + local out status stub probe # zsh does not word-split unquoted expansions; sourcing fm-backend.sh from # an interactive zsh session must still recognize known backend names. + # The claim is name matching and the sibling precheck only: the adapters + # find their own siblings through BASH_SOURCE, so zsh is not a full load. if command -v zsh >/dev/null 2>&1; then - zsh -c "cd '$ROOT' && source bin/fm-backend.sh && fm_backend_source herdr && whence -w fm_backend_herdr_capture >/dev/null" 2>/dev/null \ - || fail "zsh: fm_backend_source herdr should load the adapter when sourced" + zsh -c "cd '$ROOT' && source bin/fm-backend.sh && fm_backend_source herdr" >/dev/null 2>&1 \ + || fail "zsh: fm_backend_source herdr should accept the known backend name and find its sibling libraries" out=$(zsh -c "cd '$ROOT' && source bin/fm-backend.sh && fm_backend_source bogus" 2>&1) \ && fail "zsh: fm_backend_source bogus should fail" assert_contains "$out" "unknown backend 'bogus'" \ "zsh: fm_backend_source did not reject bogus with the expected error" pass "zsh: fm_backend_source recognizes known backends and rejects unknown ones" + + # zsh ties the lowercase `path` array to PATH; a backend loaded while + # fm_backend_source clobbers PATH cannot resolve external commands. + stub="$TMP_ROOT/zsh-source-path" + probe="$stub/probe" + mkdir -p "$stub/backends" + printf 'command -v dirname > "%s"\n' "$probe" > "$stub/backends/orca.sh" + : > "$stub/fm-composer-lib.sh" + zsh -c "cd '$ROOT' && source bin/fm-backend.sh && FM_BACKEND_LIB_DIR='$stub' && fm_backend_source orca" >/dev/null 2>&1 \ + || fail "zsh: fm_backend_source orca should load a stub adapter" + [ -s "$probe" ] \ + || fail "zsh: fm_backend_source clobbered PATH while loading a backend adapter" + pass "zsh: fm_backend_source keeps PATH intact while loading a backend adapter" else pass "zsh: shell-portable backend matching skipped (zsh not found)" fi diff --git a/tests/fm-backlog-atomicity.test.sh b/tests/fm-backlog-atomicity.test.sh index 1bb88a45538..55d62044bed 100755 --- a/tests/fm-backlog-atomicity.test.sh +++ b/tests/fm-backlog-atomicity.test.sh @@ -2030,6 +2030,45 @@ test_recovery_replays_a_close_an_interrupted_cleanup_left_open() { pass "session start finishes a close an interrupted cleanup recorded but never landed" } +test_recovery_replays_a_gerrit_close_with_its_change_url_as_a_note() { + local case_dir id out real_tasks_axi gerrit_url=https://gerrit.example.com/c/project/+/12345 + id=atomic-heal-gerrit-b9 + case_dir=$(make_home heal-pending-gerrit-close) + add_item "$case_dir" "$id" + start_item "$case_dir" "$id" + # The record a pre-fix teardown left: the Gerrit change URL as a --pr link. + printf 'id=%s\ndata=%s\nspawn_gen=spawn-heal-gerrit\narg=--pr\narg=%s\n' \ + "$id" "$(home_of "$case_dir")/data" "$gerrit_url" \ + > "$(home_of "$case_dir")/state/$id.backlog-close" + # Pin the refusal tasks-axi applies to a --pr link that is not a canonical + # GitHub pull request, so this case keeps reproducing whatever the installed + # release accepts. + real_tasks_axi=$(command -v tasks-axi) + cat > "$case_dir/fakebin/tasks-axi" </dev/null \ + || fail "the replayed Gerrit close did not record its change URL as a note" + assert_absent "$(home_of "$case_dir")/state/$id.backlog-close" \ + "a replayed Gerrit close left its record behind" + pass "session start replays a recorded Gerrit close with its change URL as a note" +} + test_recovery_backfills_a_recorded_link_on_an_already_done_item() { local case_dir id marker out id=atomic-heal-done-backfill-b9 @@ -3056,6 +3095,7 @@ test_recovery_marks_an_owned_record_in_flight test_recovery_rejects_an_internal_worker_record_symlink test_recovery_ignores_a_symlinked_worker_record test_recovery_replays_a_close_an_interrupted_cleanup_left_open +test_recovery_replays_a_gerrit_close_with_its_change_url_as_a_note test_recovery_backfills_a_recorded_link_on_an_already_done_item test_recovery_preserves_a_close_when_the_backlog_cannot_be_read test_recovery_retry_preserves_incomplete_cleanup_warning diff --git a/tests/fm-calm-claude-mod.test.sh b/tests/fm-calm-claude-mod.test.sh index 69ab66558e0..ce5dcf8a869 100644 --- a/tests/fm-calm-claude-mod.test.sh +++ b/tests/fm-calm-claude-mod.test.sh @@ -33,6 +33,13 @@ run_node() { # node --input-type=module <"$1" } +# js_string : a JavaScript string literal for a shell value, for the +# generated scripts below. ${value@Q} would need Bash 4.4 and yields shell +# quoting; stock macOS Bash 3.2 reports a bad substitution. +js_string() { # + node -e 'process.stdout.write(JSON.stringify(process.argv[1]))' -- "$1" +} + test_plugin_shape() { local link resolved autoload link="$ROOT/.agents/skills/firstmate-calm" @@ -49,7 +56,7 @@ test_plugin_shape() { [ ! -e "$MOD/SKILL.md" ] || fail "the mod carries a SKILL.md and would load as a skill on every harness" cat >"$TMP_ROOT/shape.mjs" <"$TMP_ROOT/sprite.mjs" <"$TMP_ROOT/raster.mjs" < { if (!condition) throw new Error(message); }; for (let length = 0; length <= 80; length += 1) { const bytes = new Uint8Array(randomBytes(length)); @@ -235,8 +242,8 @@ test_presentation_policy() { local out cat >"$TMP_ROOT/policy.mjs" < { if (!condition) throw new Error(message); }; const plugin = "/repo/.claude/mods/firstmate-calm"; check(policy.calmPreferencePath({}, plugin) === "/repo/config/calm", "plugin-root fallback"); @@ -473,8 +480,8 @@ test_classifier_parity_with_shell_owner() { cat >"$TMP_ROOT/classify.mjs" <"$TMP_ROOT/doorbells.mjs" <&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() { @@ -4190,7 +4201,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" @@ -4863,7 +4874,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 5ecd51fe9ba..2864a3e30eb 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 @@ -3017,6 +3026,63 @@ SH pass "an answer before cleanup replay preserves the retained report" } +test_answer_before_cleanup_replay_notes_a_retained_gerrit_change() { + local home id repo wt rc show real_tasks_axi gerrit_url=https://gerrit.example.com/c/project/+/12345 + home=$(make_home answer-before-replay-gerrit) + id=sample-answer-before-replay-gerrit + repo="$home/projects/sample" + wt="$home/projects/$id" + fm_git_worktree "$repo" "$wt" fm/answer-before-replay-gerrit + tasks_in "$home" add "$id" "Ship the held Gerrit change" --kind ship \ + --repo sample --start >/dev/null || fail "could not create the held Gerrit answer fixture" + fm_write_meta "$home/state/$id.meta" \ + "window=firstmate:fm-$id" "endpoint_task_id=$id" "worktree=$wt" \ + "project=$repo" "harness=codex" "kind=ship" "mode=no-mistakes" \ + "pr=$gerrit_url" "spawn_gen=fixture-$id" + printf 'done: change landed\n' > "$home/state/$id.status" + run_captain "$home" hold "$id" --reason "captain must choose the follow-up" >/dev/null \ + || fail "could not hold the landed Gerrit task for the captain" + real_tasks_axi=$(command -v tasks-axi) + cat > "$home/fakebin/tasks-axi" < "$home/fakebin/treehouse" <<'SH' +#!/usr/bin/env bash +exit 1 +SH + chmod +x "$home/fakebin/treehouse" + + set +e + PATH="$home/fakebin:$PATH" FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$home" \ + FM_STATE_OVERRIDE="$home/state" FM_DATA_OVERRIDE="$home/data" \ + FM_CONFIG_OVERRIDE="$home/config" "$TEARDOWN" "$id" --force \ + > "$home/teardown.out" 2> "$home/teardown.err" + rc=$? + set -e + [ "$rc" -ne 0 ] || fail "cleanup succeeded despite the failed worktree return" + assert_present "$home/state/$id.backlog-close" \ + "the interrupted cleanup lost its retained-artifact record" + + printf 'Proceed with the landed change.\n' > "$home/answer.txt" + run_captain "$home" answer "$id" --decision-file "$home/answer.txt" >/dev/null \ + || fail "the captain could not answer a Gerrit task before cleanup replay" + show=$(tasks_in "$home" show "$id" --full) || fail "the answered Gerrit row is gone" + assert_contains "$show" "state: done" "the answer did not close the Gerrit row" + assert_contains "$show" "Gerrit change $gerrit_url" \ + "the answer dropped the retained Gerrit change URL" + pass "an answer before cleanup replay notes the retained Gerrit change" +} + test_unusable_pending_close_record_names_its_reason() { local home id wt rc err marker home=$(make_home unusable-pending-close-reason) @@ -3033,7 +3099,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 @@ -3097,7 +3163,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 @@ -3174,7 +3245,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" \ @@ -3195,6 +3271,52 @@ EOF pass "cleanup retains captain calls in the configured backlog" } +test_teardown_retains_a_gerrit_captain_call_with_its_change_url() { + local home id repo wt show real_tasks_axi gerrit_url=https://gerrit.example.com/c/project/+/12345 + home=$(make_home teardown-held-gerrit) + id=sample-held-gerrit + repo="$home/projects/sample" + wt="$home/projects/$id" + fm_git_worktree "$repo" "$wt" fm/held-gerrit + tasks_in "$home" add "$id" "Ship the held Gerrit change" --kind ship \ + --repo sample --start >/dev/null || fail "could not create the held Gerrit fixture" + fm_write_meta "$home/state/$id.meta" \ + "window=firstmate:fm-$id" "endpoint_task_id=$id" "worktree=$wt" \ + "project=$repo" "harness=codex" "kind=ship" "mode=no-mistakes" \ + "pr=$gerrit_url" "spawn_gen=fixture-$id" + printf 'done: change landed\n' > "$home/state/$id.status" + run_captain "$home" hold "$id" --reason "captain must choose the follow-up" >/dev/null \ + || fail "could not hold the landed Gerrit task for the captain" + # Pin the refusal tasks-axi applies to a --pr link that is not a canonical + # GitHub pull request, so this case keeps reproducing whatever the installed + # release accepts. + real_tasks_axi=$(command -v tasks-axi) + cat > "$home/fakebin/tasks-axi" < "$home/teardown.out" 2> "$home/teardown.err" \ + || fail "cleanup of a captain-held Gerrit task failed: $(cat "$home/teardown.err")" + show=$(tasks_in "$home" show "$id" --full) || fail "the captain-held Gerrit row is gone after cleanup" + assert_contains "$show" "state: queued" "the held Gerrit row still reads as worked on" + assert_contains "$show" "hold_kind: captain" "cleanup dropped the captain hold" + assert_contains "$show" "Deliverable of the finished work: Gerrit change $gerrit_url" \ + "the Gerrit change URL was not recorded on the still-open row" + assert_absent "$home/state/$id.backlog-close" \ + "successful cleanup left its pending transition record behind" + pass "cleanup keeps a captain-held Gerrit task open and records its change URL" +} + test_merge_approval_releases_before_zero_done_retention() { local home id archive repo wt pr show home=$(make_home zero-done-retention) @@ -3958,7 +4080,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" \ @@ -3996,7 +4118,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")" @@ -4034,6 +4156,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 @@ -4066,9 +4667,11 @@ test_teardown_never_closes_a_captain_held_task test_retained_row_artifacts_survive_captain_answers test_interrupted_cleanup_keeps_the_captain_call_recoverable test_answer_before_cleanup_replay_preserves_the_retained_report +test_answer_before_cleanup_replay_notes_a_retained_gerrit_change test_unusable_pending_close_record_names_its_reason test_relocated_report_does_not_wedge_an_answer_before_replay test_teardown_retains_captain_calls_in_a_relocated_backlog +test_teardown_retains_a_gerrit_captain_call_with_its_change_url test_merge_approval_releases_before_zero_done_retention test_pr_merge_entrypoint_refuses_a_captain_held_task test_local_merge_entrypoint_refuses_a_captain_held_task 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 eaefb3f1d36..c796438ad07 100755 --- a/tests/fm-composer-lib.test.sh +++ b/tests/fm-composer-lib.test.sh @@ -747,6 +747,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, @@ -999,6 +1052,7 @@ test_matrix_pi_dollar_status_footer_is_empty test_matrix_opencode_leftbar_signals test_grok_approval_mode_title 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 a771832ce33..48fa59d1f8b 100755 --- a/tests/fm-contributions.test.sh +++ b/tests/fm-contributions.test.sh @@ -362,6 +362,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) @@ -669,17 +695,24 @@ set -eu printf '%s\n' "$*" >> "$FORGE/calls" fault=$(cat "$FORGE/fault" 2>/dev/null || true) case "$fault" in latency) sleep "${FORGE_LATENCY:-2}" ;; esac +# Concurrent forge callers each advance one shared clock. Truncating it in +# place races with the other callers and the fake date: an interleaved write +# can publish a half-written value (or the 6 an emptied read computes), and a +# caller then evaluates DEADLINE against torn arithmetic. Publish every new +# value by rename so each reader always sees one complete old-or-new clock. +clock_bump() { + local tmp + tmp=$(mktemp "$FORGE/clock.XXXXXX") + printf '%s\n' "$(( $(cat "$FORGE/clock") + $1 ))" > "$tmp" + mv -f "$tmp" "$FORGE/clock" +} case "$fault:$*" in # Advance once before the parallel read wave; its readers share this clock. - reserve:'api repos/o/r/issues/9') - printf '%s\n' "$(( $(cat "$FORGE/clock") + 6 ))" > "$FORGE/clock" ;; + reserve:'api repos/o/r/issues/9') clock_bump 6 ;; slow-wave:'api repos/o/r/pulls/8') sleep 3 ;; slow-wave:'api repos/o/r/pulls/8/reviews?'*) sleep 6 ;; - exhaust:'api repos/o/r/issues/8/comments?'*) - printf '%s\n' "$(( $(cat "$FORGE/clock") + 100 ))" > "$FORGE/clock" ;; - fail-late:'api repos/o/r/pulls/8/reviews?'*) - printf '%s\n' "$(( $(cat "$FORGE/clock") + 100 ))" > "$FORGE/clock" - printf 'HTTP 502\n' >&2; exit 1 ;; + exhaust:'api repos/o/r/issues/8/comments?'*) clock_bump 100 ;; + fail-late:'api repos/o/r/pulls/8/reviews?'*) clock_bump 100; printf 'HTTP 502\n' >&2; exit 1 ;; fail:'api repos/o/r/pulls/8/reviews?'*) printf 'HTTP 502\n' >&2; exit 1 ;; down:*) printf 'HTTP 502\n' >&2; exit 1 ;; hang:'api repos/o/r/pulls/8') sleep 4 ;; @@ -1129,7 +1162,7 @@ test_late_owner_keeps_failure_episode_suppressed() { } failures=0 -for test_name in test_retired_task_keeps_distinct_actionable_contributions test_merge_call_reaches_board 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_retired_task_keeps_distinct_actionable_contributions test_merge_call_reaches_board 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-parent-channel-scan-exclusion.test.sh b/tests/fm-parent-channel-scan-exclusion.test.sh new file mode 100755 index 00000000000..7d8a580a4c6 --- /dev/null +++ b/tests/fm-parent-channel-scan-exclusion.test.sh @@ -0,0 +1,414 @@ +#!/usr/bin/env bash +# tests/fm-parent-channel-scan-exclusion.test.sh - a remote mate home's own +# outbound parent channel (state/parent-replies.status, resolved through +# bin/fm-parent-channel-lib.sh) must not be enumerated by the home's own status +# scans: every parent-channel append is mirrored into the parent home by the +# remote reply adapter, so folding or waking on it here spins spurious signal +# wakes and phantom "parent-replies" open decisions. The exclusion must be +# home-shape-aware: a parent-replies.status in a main home, in a local mate, or +# in any other home shape is an ordinary task log and keeps waking and folding. +# +# Covers the watcher scan (scan_signals, the heartbeat fail-safe backstop), the +# away-mode daemon's twin catch-all scan (fm-supervise-daemon.sh housekeeping), +# and fm-classify-lib.sh's fleet-wide folds (whole-file, incremental, +# presentation snapshot, unread surface), each against a real remote mate +# fixture plus the main-home and local-mate negative cases, and the real +# fm-wake-drain.sh end to end. +set -u + +# shellcheck source=tests/lib.sh +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +TMP_ROOT=$(fm_test_tmproot fm-parent-channel-scan-exclusion) +mkdir -p "$TMP_ROOT" +TMP_ROOT=$(cd "$TMP_ROOT" && pwd -P) + +# The real drain asserts watcher liveness through fm-guard.sh, whose tangle +# check warns when FM_ROOT sits on a feature branch; point it at a fresh +# non-git dir so the banner stays inert in this disposable worktree (the same +# trick tests/wake-helpers.sh installs for the drain suites). +FM_ROOT_OVERRIDE="$(fm_test_tmproot fm-parent-channel-scan-exclusion-root)" +export FM_ROOT_OVERRIDE +mkdir -p "$FM_ROOT_OVERRIDE" + +cleanup() { rm -rf -- "$TMP_ROOT"; } +trap cleanup EXIT + +# seed_remote_mate : build a remote mate home whose state dir carries one +# genuine task log and the outbound parent channel, with one captain-facing +# decision, one reserved-key resolution, and one informational note on the +# channel - exactly the line shapes a mate home publishes mechanically. +seed_remote_mate() { # + local dir=$1 + mkdir -p "$dir/state" + printf '%s\n' mate > "$dir/.fm-secondmate-home" + printf 'schema=fm-secondmate-parent.v1\nroute=remote\nparent_host=remote.example\n' \ + > "$dir/.fm-secondmate-parent" + printf 'needs-decision [key=captain-hold-pr-7-1]: captain hold pr-7: merge the green PR?\n' \ + > "$dir/state/parent-replies.status" + printf 'resolved [key=captain-hold-pr-5-2]: captain chose the staged rollout\n' \ + >> "$dir/state/parent-replies.status" + printf 'note: the release branch is cut\n' >> "$dir/state/parent-replies.status" + printf 'needs-decision [key=api-shape]: pick REST or RPC\n' > "$dir/state/real-task.status" + printf 'note: benchmark results are in\n' >> "$dir/state/real-task.status" +} + +# seed_plain_home : a main home (no secondmate identity marker) whose +# state dir carries a parent-replies.status that merely shares the name. +seed_plain_home() { # + local dir=$1 + mkdir -p "$dir/state" + printf 'needs-decision [key=name-only]: an ordinary task file that shares the name\n' \ + > "$dir/state/parent-replies.status" + printf 'needs-decision [key=other-task]: a genuine sibling task decision\n' \ + > "$dir/state/other-task.status" +} + +# seed_local_mate : a LOCAL mate home - its parent channel +# lives in the parent home's state/.status, so a parent-replies.status in +# its own state dir is an ordinary self-home file. +seed_local_mate() { # + local dir=$1 parent_home=$2 + mkdir -p "$dir/state" + printf '%s\n' mate > "$dir/.fm-secondmate-home" + printf 'schema=fm-secondmate-parent.v1\nroute=local\nparent_home=%s\n' "$parent_home" \ + > "$dir/.fm-secondmate-parent" + printf 'needs-decision [key=local-shape]: still an ordinary self-home file\n' \ + > "$dir/state/parent-replies.status" +} + +REMOTE="$TMP_ROOT/remote-mate" +PLAIN="$TMP_ROOT/main-home" +LOCAL_MATE="$TMP_ROOT/local-mate" +seed_remote_mate "$REMOTE" +seed_plain_home "$PLAIN" +seed_local_mate "$LOCAL_MATE" "$PLAIN" +REMOTE_STATE="$REMOTE/state" +PLAIN_STATE="$PLAIN/state" +LOCAL_STATE="$LOCAL_MATE/state" + +# --- unit: the exclusion predicates ----------------------------------------- + +test_predicate_resolves_only_the_remote_channel() { + local out rc + out=$(FM_TEST_LIB_SOURCED=1 bash -c ' + # shellcheck source=bin/fm-classify-lib.sh + . "$1/bin/fm-classify-lib.sh" + status_scan_parent_channel_exclude "$2" + ' _ "$ROOT" "$REMOTE_STATE") \ + || fail "the remote mate's channel must resolve for exclusion, got rc=$?" + [ "$out" = "$REMOTE_STATE/parent-replies.status" ] \ + || fail "the exclusion must be the resolved channel path, got: $out" + out=$(FM_TEST_LIB_SOURCED=1 bash -c ' + # shellcheck source=bin/fm-classify-lib.sh + . "$1/bin/fm-classify-lib.sh" + status_scan_parent_channel_exclude "$2" + ' _ "$ROOT" "$PLAIN_STATE") + [ -z "$out" ] || fail "a main home must exclude nothing, got: $out" + out=$(FM_TEST_LIB_SOURCED=1 bash -c ' + # shellcheck source=bin/fm-classify-lib.sh + . "$1/bin/fm-classify-lib.sh" + status_scan_parent_channel_exclude "$2" + ' _ "$ROOT" "$LOCAL_STATE") + [ -z "$out" ] || fail "a local mate must exclude nothing, got: $out" + pass "only a remote mate home resolves its own parent channel for exclusion" +} + +test_resolver_predicates_on_home_shape_not_name() { + local out + out=$(FM_TEST_LIB_SOURCED=1 bash -c ' + # shellcheck source=bin/fm-parent-channel-lib.sh + . "$1/bin/fm-parent-channel-lib.sh" + fm_parent_channel_outbound_status "$2" "$3" + ' _ "$ROOT" "$REMOTE" "$REMOTE_STATE") \ + || fail "the remote mate's outbound status must resolve" + [ "$out" = "$REMOTE_STATE/parent-replies.status" ] \ + || fail "the remote route must resolve into the mate's own state dir, got: $out" + FM_TEST_LIB_SOURCED=1 bash -c ' + # shellcheck source=bin/fm-parent-channel-lib.sh + . "$1/bin/fm-parent-channel-lib.sh" + fm_parent_channel_outbound_status "$2" "$3" + ' _ "$ROOT" "$PLAIN" "$PLAIN_STATE" \ + && fail "a main home has no outbound parent-channel status" + FM_TEST_LIB_SOURCED=1 bash -c ' + # shellcheck source=bin/fm-parent-channel-lib.sh + . "$1/bin/fm-parent-channel-lib.sh" + fm_parent_channel_outbound_status "$2" "$3" + ' _ "$ROOT" "$LOCAL_MATE" "$LOCAL_STATE" \ + && fail "a local mate's channel lives in the parent home, not its own state dir" + pass "fm_parent_channel_outbound_status resolves only the remote route" +} + +# --- unit: the fleet-wide folds omit the channel and keep genuine tasks ----- + +test_remote_folds_omit_channel_and_keep_genuine_task() { + local dir out + dir="$TMP_ROOT/folds" + seed_remote_mate "$dir/home" + out=$(FM_TEST_LIB_SOURCED=1 bash -c ' + # shellcheck source=bin/fm-classify-lib.sh + . "$1/bin/fm-classify-lib.sh" + echo "WHOLE:"; scan_open_decisions "$2" + echo "SNAPSHOT:"; status_presentation_snapshot "$2" + echo "UNREAD:"; scan_unread_surface_lines "$2" + ' _ "$ROOT" "$dir/home/state") || fail "the remote-mate fold pass failed" + case "$out" in *parent-replies*) + fail "the channel leaked into the remote mate's folds: $out" ;; + esac + printf '%s\n' "$out" | sed -n '/^WHOLE:/,/^SNAPSHOT:/p' | grep -F 'api-shape' >/dev/null \ + || fail "the genuine task's open decision must still fold: $out" + printf '%s\n' "$out" | sed -n '/^SNAPSHOT:/,/^UNREAD:/p' | grep -F 'real-task' >/dev/null \ + || fail "the genuine task must stay in the presentation snapshot: $out" + printf '%s\n' "$out" | sed -n '/^UNREAD:/,$p' | grep -F 'benchmark results' >/dev/null \ + || fail "the genuine task's note must stay on the unread surface: $out" + pass "a remote mate's folds omit its channel and keep a genuine task" +} + +test_incremental_fold_omits_channel_and_keeps_genuine_task() { + local dir out + dir="$TMP_ROOT/folds-incremental" + seed_remote_mate "$dir/home" + out=$(FM_TEST_LIB_SOURCED=1 bash -c ' + # shellcheck source=bin/fm-classify-lib.sh + . "$1/bin/fm-classify-lib.sh" + scan_open_decisions_incremental "$2" + ' _ "$ROOT" "$dir/home/state") || fail "the incremental fold failed" + case "$out" in *parent-replies*) + fail "the channel leaked into the incremental fold: $out" ;; + esac + printf '%s\n' "$out" | grep -F 'api-shape' >/dev/null \ + || fail "the genuine task's decision must still fold incrementally: $out" + pass "the cursor-backed incremental fold omits a remote mate's channel" +} + +test_channel_lines_never_reach_the_remote_unread_surface() { + local dir out + dir="$TMP_ROOT/unread" + seed_remote_mate "$dir/home" + out=$(FM_TEST_LIB_SOURCED=1 bash -c ' + # shellcheck source=bin/fm-classify-lib.sh + . "$1/bin/fm-classify-lib.sh" + scan_unread_surface_lines "$2" + ' _ "$ROOT" "$dir/home/state") || fail "the unread-surface scan failed" + case "$out" in *parent-replies*|*captain-hold*|*release\ branch*) + fail "channel decision, resolution, or note surfaced as self-home unread status: $out" ;; + esac + pass "the channel's resolution and note lines stay off the remote unread surface" +} + +test_name_shared_file_folds_in_a_main_home() { + local out + out=$(FM_TEST_LIB_SOURCED=1 bash -c ' + # shellcheck source=bin/fm-classify-lib.sh + . "$1/bin/fm-classify-lib.sh" + scan_open_decisions "$2" + ' _ "$ROOT" "$PLAIN_STATE") || fail "the main-home fold failed" + printf '%s\n' "$out" | grep -F 'name-only' >/dev/null \ + || fail "a main home's parent-replies.status must keep folding as an ordinary task: $out" + printf '%s\n' "$out" | grep -F 'other-task' >/dev/null \ + || fail "the sibling task decision must keep folding: $out" + pass "a parent-replies.status in a main home still folds" +} + +test_name_shared_file_folds_in_a_local_mate() { + local out + out=$(FM_TEST_LIB_SOURCED=1 bash -c ' + # shellcheck source=bin/fm-classify-lib.sh + . "$1/bin/fm-classify-lib.sh" + scan_open_decisions "$2" + ' _ "$ROOT" "$LOCAL_STATE") || fail "the local-mate fold failed" + printf '%s\n' "$out" | grep -F 'local-shape' >/dev/null \ + || fail "a local mate's parent-replies.status must keep folding: $out" + pass "a parent-replies.status in a local mate still folds" +} + +# --- unit: the watcher's signal scan and heartbeat backstop ----------------- + +# Source the watcher once with an isolated state/home; its source guard returns +# before the lock/loop, so only the functions load. scan_signals and +# heartbeat_scan_finds_actionable read STATE at call time. FM_ROOT_OVERRIDE +# stays at the inert dir set above; the unit-called functions read STATE, not +# the repo root. +WATCH_STATE="$REMOTE_STATE" +export FM_STATE_OVERRIDE="$WATCH_STATE" +export FM_HOME="$REMOTE" +# Production modules are independently linted canonical roots. Keep this test's +# ShellCheck context local while preserving its unchanged runtime source path. +# shellcheck source=/dev/null +. "$ROOT/bin/fm-watch.sh" + +test_watcher_scan_skips_channel_and_keeps_task_in_remote_mate() { + local out rc + STATE="$REMOTE_STATE" + out=$(scan_signals) || fail "scan_signals failed over the remote mate state" + printf '%s\n' "$out" | cut -f3 | grep -F 'parent-replies.status' >/dev/null \ + && fail "the channel must not produce a signal wake: $out" + printf '%s\n' "$out" | cut -f3 | grep -F 'real-task.status' >/dev/null \ + || fail "the genuine task's status must still wake: $out" + pass "scan_signals skips a remote mate's channel and still reports its tasks" +} + +test_heartbeat_backstop_skips_channel_in_remote_mate() { + local dir rc + dir="$TMP_ROOT/heartbeat" + seed_remote_mate "$dir/home" + # A quiet task log keeps the first pass channel-only: the note: line is + # informational, so only the excluded channel could make the scan actionable. + printf 'note: benchmark results are in\n' > "$dir/home/state/real-task.status" + STATE="$dir/home/state" + heartbeat_scan_finds_actionable; rc=$? + [ "$rc" -eq 1 ] || fail "the channel must not surface through the heartbeat backstop (rc=$rc): $FM_HEARTBEAT_SURFACE_ENDPOINTS" + case "$FM_HEARTBEAT_SURFACE_ENDPOINTS" in + *parent-replies*) fail "the channel leaked into the heartbeat backstop: $FM_HEARTBEAT_SURFACE_ENDPOINTS" ;; + esac + # A genuine task's captain-relevant line must keep reaching the backstop. + printf 'blocked [key=wedge]: the crew is stuck\n' >> "$dir/home/state/real-task.status" + heartbeat_scan_finds_actionable; rc=$? + [ "$rc" -eq 0 ] || fail "a genuine task's decision must surface through the heartbeat backstop" + case "$FM_HEARTBEAT_SURFACE_ENDPOINTS" in + *real-task.status*) ;; + *) fail "the heartbeat backstop must name the genuine task: $FM_HEARTBEAT_SURFACE_ENDPOINTS" ;; + esac + case "$FM_HEARTBEAT_SURFACE_ENDPOINTS" in + *parent-replies*) fail "the channel leaked into the heartbeat backstop: $FM_HEARTBEAT_SURFACE_ENDPOINTS" ;; + esac + pass "the heartbeat backstop skips a remote mate's channel and keeps its tasks" +} + +test_watcher_scan_keeps_name_shared_files_outside_remote_mates() { + local out + STATE="$PLAIN_STATE" + out=$(scan_signals) || fail "scan_signals failed over the main-home state" + printf '%s\n' "$out" | cut -f3 | grep -F 'parent-replies.status' >/dev/null \ + || fail "a main home's parent-replies.status must keep waking: $out" + # shellcheck disable=SC2034 # read by the sourced watcher's scans at call time + STATE="$LOCAL_STATE" + out=$(scan_signals) || fail "scan_signals failed over the local-mate state" + printf '%s\n' "$out" | cut -f3 | grep -F 'parent-replies.status' >/dev/null \ + || fail "a local mate's parent-replies.status must keep waking: $out" + pass "scan_signals keeps parent-replies.status outside remote mate homes" +} + +# --- unit: the away-mode daemon's heartbeat catch-all backstop -------------- + +# The daemon runs the watcher's twin catch-all scan while a home is away, so it +# needs the same exclusion. Source it in a subshell - its BASH_SOURCE guard +# skips the main loop, and the isolation keeps its function table from +# colliding with the watcher already sourced above. +daemon_heartbeat_scan() { # + local home=$1 + rm -f "$home/state/.subsuper-last-scan" + FM_TEST_LIB_SOURCED=1 FM_HOME="$home" FM_STATE_OVERRIDE="$home/state" \ + bash -c ' + # shellcheck source=/dev/null + . "$1/bin/fm-supervise-daemon.sh" + housekeeping "$2" + ' _ "$ROOT" "$home/state" >/dev/null 2>&1 +} + +test_daemon_heartbeat_backstop_skips_channel_in_remote_mate() { + local dir buffer + dir="$TMP_ROOT/daemon-heartbeat" + seed_remote_mate "$dir/home" + # A quiet task log keeps the first pass channel-only, so only the excluded + # channel could put anything in the escalation buffer. + printf 'note: benchmark results are in\n' > "$dir/home/state/real-task.status" + daemon_heartbeat_scan "$dir/home" + buffer=$(cat "$dir/home/state/.subsuper-escalations" 2>/dev/null || true) + case "$buffer" in *parent-replies*|*captain-hold*|*release\ branch*) + fail "the channel leaked into the daemon's catch-all scan: $buffer" ;; + esac + [ -z "$(cat "$dir/home/state/.subsuper-seen-status-parent-replies" 2>/dev/null || true)" ] \ + || fail "the daemon tracked the channel as a phantom parent-replies task" + + # A genuine task's captain-relevant line must keep reaching the backstop. + printf 'blocked [key=wedge]: the crew is stuck\n' >> "$dir/home/state/real-task.status" + daemon_heartbeat_scan "$dir/home" + buffer=$(cat "$dir/home/state/.subsuper-escalations" 2>/dev/null || true) + printf '%s\n' "$buffer" | grep -F 'real-task.status' >/dev/null \ + || fail "a genuine task's decision must still surface through the daemon backstop: $buffer" + case "$buffer" in *parent-replies*) + fail "the channel leaked into the daemon's catch-all scan: $buffer" ;; + esac + pass "the daemon's catch-all scan skips a remote mate's channel and keeps its tasks" +} + +test_daemon_heartbeat_backstop_keeps_name_shared_file_in_a_main_home() { + local dir buffer + dir="$TMP_ROOT/daemon-heartbeat-main" + seed_plain_home "$dir/home" + printf 'blocked [key=name-only]: an ordinary task file that shares the name\n' \ + > "$dir/home/state/parent-replies.status" + daemon_heartbeat_scan "$dir/home" + buffer=$(cat "$dir/home/state/.subsuper-escalations" 2>/dev/null || true) + printf '%s\n' "$buffer" | grep -F 'parent-replies.status' >/dev/null \ + || fail "a main home's parent-replies.status must keep reaching the daemon backstop: $buffer" + pass "the daemon's catch-all scan keeps parent-replies.status outside remote mate homes" +} + +# --- end to end: the real drain over a remote mate home --------------------- + +test_drain_presents_no_channel_content_in_remote_mate() { + local dir out manifest + dir="$TMP_ROOT/drain" + seed_remote_mate "$dir/home" + mkdir -p "$dir/home/data" + FM_STATE_OVERRIDE="$dir/home/state" FM_HOME="$dir/home" \ + "$ROOT/bin/fm-wake-drain.sh" > "$dir/drain.out" \ + || fail "the drain failed over a remote mate home" + out=$(cat "$dir/drain.out") + case "$out" in *parent-replies*) + fail "the drain presented the remote mate's channel: $out" ;; + esac + printf '%s\n' "$out" | grep -F 'api-shape' >/dev/null \ + || fail "the genuine task's open decision must still surface in OPEN DECISIONS: $out" + printf '%s\n' "$out" | grep -F 'benchmark results' >/dev/null \ + || fail "the genuine task's note must still surface under UNREAD STATUS: $out" + # The presentation manifest is rebuilt from the excluded snapshot, so a + # channel row an older watcher recorded must not survive the drain. + manifest=$(cat "$dir/home/state/.status-presentation-cursor" 2>/dev/null || true) + case "$manifest" in *parent-replies*) + fail "the presentation manifest still tracks the channel: $manifest" ;; + esac + pass "the real drain presents no channel content from a remote mate home" +} + +test_drain_ignores_stale_channel_records_from_an_older_watcher() { + local dir out manifest + dir="$TMP_ROOT/drain-stale" + seed_remote_mate "$dir/home" + mkdir -p "$dir/home/data" + # An older watcher folded the channel and tracked it as a task; the fixed + # drain must drop both rather than present or choke on them. + printf 'needs-decision [key=old-phantom]: folded by the unfixed watcher\n' \ + > "$dir/home/state/.parent-replies.open-decisions-cursor" + printf 'parent-replies\tstrong:1:2:3\t99\t0\n' \ + > "$dir/home/state/.status-presentation-cursor" + FM_STATE_OVERRIDE="$dir/home/state" FM_HOME="$dir/home" \ + "$ROOT/bin/fm-wake-drain.sh" > "$dir/drain.out" \ + || fail "the drain failed over stale channel records" + out=$(cat "$dir/drain.out") + case "$out" in *parent-replies*|*old-phantom*) + fail "a stale channel fold resurfaced through the drain: $out" ;; + esac + manifest=$(cat "$dir/home/state/.status-presentation-cursor" 2>/dev/null || true) + case "$manifest" in *parent-replies*) + fail "the stale manifest row survived the drain: $manifest" ;; + esac + pass "stale channel records from an older watcher are dropped, not presented" +} + +test_predicate_resolves_only_the_remote_channel +test_resolver_predicates_on_home_shape_not_name +test_remote_folds_omit_channel_and_keep_genuine_task +test_incremental_fold_omits_channel_and_keeps_genuine_task +test_channel_lines_never_reach_the_remote_unread_surface +test_name_shared_file_folds_in_a_main_home +test_name_shared_file_folds_in_a_local_mate +test_watcher_scan_skips_channel_and_keeps_task_in_remote_mate +test_heartbeat_backstop_skips_channel_in_remote_mate +test_watcher_scan_keeps_name_shared_files_outside_remote_mates +test_daemon_heartbeat_backstop_skips_channel_in_remote_mate +test_daemon_heartbeat_backstop_keeps_name_shared_file_in_a_main_home +test_drain_presents_no_channel_content_in_remote_mate +test_drain_ignores_stale_channel_records_from_an_older_watcher diff --git a/tests/fm-pi-seeded-home-trust-live-e2e.test.sh b/tests/fm-pi-seeded-home-trust-live-e2e.test.sh new file mode 100755 index 00000000000..93714ce2d0b --- /dev/null +++ b/tests/fm-pi-seeded-home-trust-live-e2e.test.sh @@ -0,0 +1,139 @@ +#!/usr/bin/env bash +# Live guard for fm-spawn's Pi seeded-secondmate --approve preflight. +# +# Reproduces the Pi "Trust project folder?" stall on a freshly seeded +# secondmate-shaped home (tracked .pi/extensions + .fm-secondmate-home) under a +# disposable PI_CODING_AGENT_DIR, then proves the spawn-side --approve flag +# clears that stall without rewriting the disposable trust store. An unseeded +# path without --approve still prompts. +# +# Token-free: never submits a prompt and never answers the dialog with Enter. +# Uses Escape / kill-server only. Never touches ~/.pi. +# +# Policy: default-on wherever pi and tmux are installed (fm_live_gate). +set -u + +# shellcheck source=tests/lib.sh +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +REAL_TMUX=$(command -v tmux 2>/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 b7e9061bc5b..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 @@ -107,6 +109,85 @@ git -C "$REMOTE_ROOT" config user.name Test git -C "$REMOTE_ROOT" add AGENTS.md bin git -C "$REMOTE_ROOT" commit -qm 'remote job fixture' +# Observe the actual sleep executable boundary for the result consumer, a +# top-level command lane, and the dispatcher. Re-source the public library as +# callers may do; its own dispatcher default must not become a legacy override. +poll_cadence_case() ( + local label=$1 legacy=$2 active=$3 expected=$4 dispatch=$5 poll_dir pid='' i + poll_dir="$TMP_ROOT/poll-$label" + mkdir -p "$poll_dir/bin" + cat > "$poll_dir/bin/sleep" <<'SH' +#!/bin/bash +printf '%s\n' "$1" >> "$FM_POLL_SLEEP_LOG" +exec /bin/sleep "$@" +SH + chmod +x "$poll_dir/bin/sleep" + trap '[ -z "$pid" ] || { kill -TERM "$pid" 2>/dev/null || true; wait "$pid" 2>/dev/null || true; }' EXIT + unset FM_REMOTE_JOB_POLL_SECONDS FM_REMOTE_JOB_ACTIVE_POLL_SECONDS + # shellcheck disable=SC2030 # The legacy override is local to this cadence fixture. + [ -z "$legacy" ] || export FM_REMOTE_JOB_POLL_SECONDS="$legacy" + # shellcheck disable=SC2030 # The active override is local to this cadence fixture. + [ -z "$active" ] || export FM_REMOTE_JOB_ACTIVE_POLL_SECONDS="$active" + export FM_REMOTE_JOB_STATE_ROOT="$poll_dir/state" FM_ROOT_OVERRIDE="$REMOTE_ROOT" + # shellcheck disable=SC2030 # Each cadence fixture owns its subshell's bounds. + export FM_REMOTE_JOB_QUEUE_TIMEOUT=60 FM_REMOTE_JOB_TIMEOUT=30 + # shellcheck disable=SC2030 # The recording executable is local to this fixture. + export PATH="$poll_dir/bin:$PATH" FM_POLL_SLEEP_LOG="$poll_dir/sleeps" + # shellcheck source=bin/fm-remote-job-lib.sh + . "$ROOT/bin/fm-remote-job-lib.sh" + # shellcheck source=bin/fm-remote-job-lib.sh + . "$ROOT/bin/fm-remote-job-lib.sh" + fm_remote_job_stage "$ACCOUNT_HOME" "$REMOTE_ROOT" "$REMOTE_HOME" \ + fm-delay-job.sh 0.8 "$poll_dir/ran" /dev/null || fail "$FM_REMOTE_JOB_ERROR" + # Publish a real bounded result after the caller has entered its wait, without + # a lane's own samples contaminating this consumer-only executable log. + ( + /bin/sleep 0.8 + : > "$FM_REMOTE_JOB_JOBS/$FM_REMOTE_JOB_ID/stdout" + : > "$FM_REMOTE_JOB_JOBS/$FM_REMOTE_JOB_ID/stderr" + printf '0\n' > "$FM_REMOTE_JOB_JOBS/$FM_REMOTE_JOB_ID/exit" + fm_remote_job_write_state "$FM_REMOTE_JOB_JOBS/$FM_REMOTE_JOB_ID" 'done' + ) & + pid=$! + fm_remote_job_wait "$ACCOUNT_HOME" "$FM_REMOTE_JOB_ID" || fail "$FM_REMOTE_JOB_ERROR" + wait "$pid" || fail "$label result producer failed" + pid='' + [ "$FM_REMOTE_JOB_EXIT" -eq 0 ] || fail "$label result consumer lost the exit status" + grep -qx "$expected" "$FM_POLL_SLEEP_LOG" || fail "$label consumer never sampled at $expected seconds" + [ "$(sort -u "$FM_POLL_SLEEP_LOG")" = "$expected" ] || fail "$label consumer used another cadence" + + : > "$FM_POLL_SLEEP_LOG" + fm_remote_job_stage "$ACCOUNT_HOME" "$REMOTE_ROOT" "$REMOTE_HOME" \ + fm-delay-job.sh 0.8 "$poll_dir/ran" /dev/null || fail "$FM_REMOTE_JOB_ERROR" + HOME="$ACCOUNT_HOME" "$BASH" "$REMOTE_ROOT/bin/fm-remote-job-worker.sh" --lane "$FM_REMOTE_JOB_ID" & + pid=$! + wait "$pid" || fail "$label command lane failed" + pid='' + [ -e "$poll_dir/ran" ] || fail "$label lane did not execute its command" + [ "$(fm_remote_job_read_state "$FM_REMOTE_JOB_JOBS/$FM_REMOTE_JOB_ID")" = 'done' ] || fail "$label lane did not publish completion" + grep -qx "$expected" "$FM_POLL_SLEEP_LOG" || fail "$label lane never sampled at $expected seconds" + if [ "$expected" != 0.05 ]; then + ! grep -qx 0.05 "$FM_POLL_SLEEP_LOG" || fail "$label lane still sampled at the dispatcher default" + fi + + : > "$FM_POLL_SLEEP_LOG" + HOME="$ACCOUNT_HOME" "$BASH" "$REMOTE_ROOT/bin/fm-remote-job-worker.sh" > "$poll_dir/worker.log" 2>&1 & + pid=$! + for ((i = 0; i < 200; i++)); do + grep -qx 1 "$FM_POLL_SLEEP_LOG" && break + /bin/sleep 0.05 + done + grep -qx 1 "$FM_POLL_SLEEP_LOG" || fail "$label dispatcher never reached its one-second quiet wait" + [ "$(grep -cx "$dispatch" "$FM_POLL_SLEEP_LOG")" -eq 4 ] || fail "$label dispatcher did not limit its fast burst to four $dispatch-second waits" + kill -TERM "$pid" || fail "$label dispatcher stopped unexpectedly" + wait "$pid" 2>/dev/null || true + pid='' + pass "$label: result and command samples use $expected seconds; dispatcher uses four $dispatch-second waits then one second" +) +poll_cadence_case default '' '' 0.25 0.05 || exit 1 +poll_cadence_case legacy 0.07 '' 0.07 0.07 || exit 1 +poll_cadence_case active 0.07 0.12 0.12 0.07 || exit 1 + DEFAULT_STATE="$TMP_ROOT/default-timeout-jobs" DEFAULT_BOUNDS=$( unset FM_REMOTE_JOB_QUEUE_TIMEOUT @@ -957,6 +1038,7 @@ fi exec '$(command -v sleep)' "\$@" SH chmod +x "$STALL_BIN/sleep" +# shellcheck disable=SC2031 # Cadence fixture PATH changes stayed in their subshells. HOME="$STALL_HOME" FM_ROOT_OVERRIDE="$REMOTE_ROOT" FM_REMOTE_JOB_STATE_ROOT="$STALL_STATE" \ FM_REMOTE_JOB_PLATFORM_OVERRIDE=Linux PATH="$STALL_BIN:$PATH" \ "$REMOTE_ROOT/bin/fm-remote-job-worker.sh" --serve \ @@ -1175,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() { #