diff --git a/.agents/skills/afk/SKILL.md b/.agents/skills/afk/SKILL.md index 517e8ba029c..e53bdd89e95 100644 --- a/.agents/skills/afk/SKILL.md +++ b/.agents/skills/afk/SKILL.md @@ -58,10 +58,12 @@ batched digest rather than per-wake injections. No `/back` is needed. The first genuine message is the return signal: - A message **without** the sentinel marker and **not** starting with `/afk` -> the captain is back. - Run `bin/fm-afk-launch.sh stop`: it stops the daemon in the correct order - it SIGTERMs the daemon so its shutdown flush runs **while `state/.afk` is still present** (clearing the flag first makes that flush a no-op via the daemon's presence gate, stranding undelivered escalations), then closes the daemon's own terminal by exact id, then clears `state/.afk` last. - Then flush one distilled "while you were out" catch-up (drain `state/.wake-queue`, summarize any pending escalations from `state/.subsuper-escalations` and any `state/.subsuper-inject-wedged` marker), and resume full per-wake responsiveness through the emitted primary-harness supervision protocol from session start. -- A message **with** the sentinel marker (`FM_INJECT_MARK`, ASCII 0x1f) -> it - is a daemon escalation; stay afk and process it. + Run `bin/fm-afk-return.sh` before acting on the message that brought the captain back. + That script owns correct-ordered daemon shutdown, durable wake draining, escalation and wedge evidence, and the return-catch-up gate. + If it reports a firstmate-actionable `blocked:` event, remediate it immediately through the normal lifecycle, or explicitly reclassify it with a durable reason and close its decision key with `resolved [key=...]`, then run `bin/fm-afk-return.sh check`. + Once the daemon stops, resume full per-wake responsiveness through the emitted primary-harness supervision protocol while blocker handling proceeds, so the gate never creates a blind wait. + Do not answer a Bearings request or perform any other ordinary captain work until the check exits successfully. +- A message **with** the sentinel marker (`FM_INJECT_MARK`, U+2063 INVISIBLE SEPARATOR) -> it is a daemon escalation; stay afk and process it. - Re-invoking `/afk` while already away -> stay afk (refresh the flag); this does **not** trigger an exit. @@ -77,12 +79,9 @@ explicit word - the daemon just batches the notification. ## Sentinel marker contract -The daemon prefixes every injection with `FM_INJECT_MARK` (ASCII unit -separator, 0x1f), invisible and untypable. This is how firstmate tells a -daemon escalation apart from a real message in the same pane. The marker -travels with the message text; it does not rely on harness-level -typed-vs-injected detection (which is not portable across claude, codex, -opencode, pi, and grok). +The daemon prefixes every injection with `FM_INJECT_MARK` (U+2063 INVISIBLE SEPARATOR), which has no normal keyboard keystroke and survives terminal transport as UTF-8 text. +This is how firstmate tells a daemon escalation apart from a real message in the same pane. +The marker travels with the message text; it does not rely on harness-level typed-vs-injected detection, which is not portable across claude, codex, opencode, pi, and grok. ## Busy-guard and composer guard @@ -103,6 +102,7 @@ In afk mode the composer guard is belt-and-suspenders (no human is typing), but **Max-defer escape (the daemon must never silently wedge).** If anything stays buffered past `FM_MAX_DEFER_SECS` (default 300), the daemon attempts one normal flush, which still requires an idle pane and an affirmatively empty composer. +The alarm is defense in depth rather than a substitute for keeping every genuinely idle supported composer injectable. If that submit cannot be confirmed, it raises a loud, rate-limited wedge alarm: an ERROR in the daemon log, a durable `state/.subsuper-inject-wedged` marker (surface it on the "while you were out" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bb5e0fc6c58..d95944d9c30 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -89,8 +89,8 @@ jobs: bearings_output=$(/bin/bash tests/fm-bearings-snapshot.test.sh) printf '%s\n' "$bearings_output" bearings_count=$(printf '%s\n' "$bearings_output" | grep -c '^ok - ') - [ "$bearings_count" -eq 29 ] || { - echo "::error::expected 29 Bearings tests, got $bearings_count" + [ "$bearings_count" -eq 30 ] || { + echo "::error::expected 30 Bearings tests, got $bearings_count" exit 1 } diff --git a/AGENTS.md b/AGENTS.md index 658b4cc2f8b..9ae8673472e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -630,11 +630,11 @@ Invoke the `/afk` skill when the captain says `/afk`, says they are going afk, ` The skill owns the full daemon procedure: classification policy, batching, injection hardening, max-defer, verified submit, marker stripping, portable lock, dedupe, target discovery, reliability properties, and `FM_INJECT_SKIP`. Inline facts that must survive without a loaded skill: -- Every daemon injection is prefixed with `FM_INJECT_MARK`, ASCII unit separator `0x1f`, so internal escalations are distinguishable from a captain message. +- Every daemon injection is prefixed with `FM_INJECT_MARK`, U+2063 INVISIBLE SEPARATOR, so internal escalations survive terminal transport and remain distinguishable from a captain message. - While `state/.afk` exists, the daemon owns the watcher; do not separately arm `fm-watch-arm.sh` or `fm-watch.sh`. - If firstmate receives a marked message while afk is active, it is an internal escalation: stay afk and process it. - If the message starts with `/afk`, stay afk and refresh the flag. -- Any other unmarked message means the captain is back: stop the daemon so its shutdown flush runs while `state/.afk` is still set and clear `state/.afk` last (the `/afk` skill owns this ordering, via `bin/fm-afk-launch.sh stop`; clearing the flag first would make the flush a no-op), flush catch-up from `state/.wake-queue`, `state/.subsuper-escalations`, and `state/.subsuper-inject-wedged`, then resume the emitted primary-harness supervision protocol. +- Any other unmarked message means the captain is back: load `/afk`, run `bin/fm-afk-return.sh`, and do not process that message as ordinary captain work until its durable catch-up gate clears; the script owns stop ordering, wake draining, and firstmate-actionable blocker precedence. - Afk never changes approval authority; PR merges, ask-user findings, destructive actions, irreversible actions, and security-sensitive choices still require the same approval they required before. - Bias ambiguous cases toward exit because a present captain beats token savings and a false exit is self-correcting. diff --git a/bin/backends/herdr.sh b/bin/backends/herdr.sh index 7874988b426..260d2b66630 100644 --- a/bin/backends/herdr.sh +++ b/bin/backends/herdr.sh @@ -668,9 +668,9 @@ fm_backend_herdr_strip_ansi() { # # fm_backend_herdr_composer_state: classify the composer's own row as # empty|pending|unknown, scanning a generous tail-window capture of . # herdr's CLI exposes no cursor-row primitive (unlike tmux's #{cursor_y}), so -# this locates the composer row structurally, recognizing TWO row shapes and -# keeping whichever match comes LAST (scanning forward), so a shape earlier in -# scrollback/a popup can never outrank the real (bottom-anchored) composer row: +# this locates the composer structurally, recognizing THREE shapes and keeping +# whichever match comes LAST (scanning forward), so a shape earlier in +# scrollback/a popup can never outrank the real (bottom-anchored) composer: # # bordered - a boxed composer (verified grok 0.2.82): the row's TRIMMED # content both STARTS and ENDS with the same border glyph (│, ┃, @@ -697,6 +697,15 @@ fm_backend_herdr_strip_ansi() { # # deliberately narrower than the bordered content classifier so a # no-agent shell fallback prompt (`>`, `$`, `%`, or `#`) falls # through to `unknown` instead of being misread as delivered. +# separated - Pi's composer is one or more content rows between two solid +# horizontal `─` separator rows, with no prompt glyph or side +# borders. This shape is accepted ONLY when Herdr's native +# `agent get` identifies the target as Pi and reports it idle, +# done, or blocked. A missing/stale/non-Pi agent identity, a +# working Pi, an over-tall candidate, or an incomplete separator +# pair remains unknown. This identity + structure conjunction is +# what makes a blank Pi row safe without weakening dead-shell or +# ambiguous-pane refusal. # # empty - blank, a bare prompt glyph, known ghost/placeholder text # ("Type a message...", verified grok 0.2.82's empty-composer @@ -731,9 +740,78 @@ FM_BACKEND_HERDR_IDLE_RE=${FM_BACKEND_HERDR_IDLE_RE:-'^Type a message\.\.\.$'} # (claude) and › (codex) only. Generic shell-style glyphs > $ % # are still # recognized after a bordered composer row has already been structurally found. FM_BACKEND_HERDR_BARE_PROMPT_RE=${FM_BACKEND_HERDR_BARE_PROMPT_RE:-'^[❯›]'} +# Pi allows a multi-line composer between its horizontal separators. Bound the +# structural candidate so two unrelated transcript rules with an arbitrarily +# large region between them can never be promoted into a composer. +FM_BACKEND_HERDR_PI_COMPOSER_MAX_LINES=${FM_BACKEND_HERDR_PI_COMPOSER_MAX_LINES:-8} + +fm_backend_herdr_pi_separator_row() { # + local row=$1 + row="${row#"${row%%[![:space:]]*}"}" + row="${row%"${row##*[![:space:]]}"}" + [ "${#row}" -ge 8 ] || return 1 + [ -z "${row//─/}" ] +} + +# Locate the content and closing-row position of the bottom-most complete pair +# of Pi separator rows. A separator closes the preceding candidate and +# immediately opens the next, so an earlier transcript rule can never outrank +# the live bottom composer pair. Globals let the caller compare this shape's +# screen position with generic bordered/bare candidates without losing empty +# composer content through command substitution. +fm_backend_herdr_pi_composer_find() { # + local cap=$1 line plain open=0 lines=0 candidate="" max row=0 open_row=0 + max=$FM_BACKEND_HERDR_PI_COMPOSER_MAX_LINES + case "$max" in ''|*[!0-9]*|0) max=8 ;; esac + FM_BACKEND_HERDR_PI_PAIR_FOUND=0 + FM_BACKEND_HERDR_PI_PAIR_VALID=0 + FM_BACKEND_HERDR_PI_PAIR_OPEN_LINE=0 + FM_BACKEND_HERDR_PI_PAIR_LINE=0 + FM_BACKEND_HERDR_PI_LAST_SEPARATOR_LINE=0 + FM_BACKEND_HERDR_PI_CONTENT="" + while IFS= read -r line; do + row=$((row + 1)) + plain=$(fm_backend_herdr_strip_ansi "$line") + if fm_backend_herdr_pi_separator_row "$plain"; then + FM_BACKEND_HERDR_PI_LAST_SEPARATOR_LINE=$row + if [ "$open" -eq 1 ]; then + FM_BACKEND_HERDR_PI_PAIR_FOUND=1 + FM_BACKEND_HERDR_PI_PAIR_OPEN_LINE=$open_row + FM_BACKEND_HERDR_PI_PAIR_LINE=$row + if [ "$lines" -le "$max" ]; then + FM_BACKEND_HERDR_PI_PAIR_VALID=1 + FM_BACKEND_HERDR_PI_CONTENT=$candidate + else + FM_BACKEND_HERDR_PI_PAIR_VALID=0 + FM_BACKEND_HERDR_PI_CONTENT="" + fi + fi + open=1 + open_row=$row + lines=0 + candidate="" + elif [ "$open" -eq 1 ]; then + [ -z "$candidate" ] || candidate="${candidate}"$'\n' + candidate="${candidate}${line}" + lines=$((lines + 1)) + fi + done < -> \t + local out + out=$(fm_backend_herdr_cli "$1" agent get "$2" 2>/dev/null) || return 1 + printf '%s' "$out" | jq -r '[.result.agent.agent // "", .result.agent.agent_status // ""] | @tsv' 2>/dev/null +} fm_backend_herdr_composer_state() { # -> empty|pending|unknown - local target=$1 cap line trimmed found=0 shape="" raw_match="" bordered=0 stripped + local target=$1 session pane cap line trimmed found=0 shape="" raw_match="" bordered=0 stripped + local identity agent agent_status row=0 generic_line=0 + fm_backend_herdr_parse_target "$target" || { printf 'unknown'; return 0; } + session=$FM_BACKEND_HERDR_SESSION + pane=$FM_BACKEND_HERDR_PANE cap=$(fm_backend_herdr_capture_ansi "$target" "$FM_BACKEND_HERDR_COMPOSER_LINES" 2>/dev/null \ || fm_backend_herdr_capture "$target" "$FM_BACKEND_HERDR_COMPOSER_LINES") || { printf 'unknown'; return 0; } # Structural scan: locate the bottom-most composer row and remember its RAW @@ -741,6 +819,7 @@ fm_backend_herdr_composer_state() { # -> empty|pending|unknown # keeps ghost text so the border/prompt glyph is still visible); the raw row is # kept for ANSI-aware content extraction after the scan. while IFS= read -r line; do + row=$((row + 1)) trimmed=$(fm_backend_herdr_strip_ansi "$line") trimmed="${trimmed#"${trimmed%%[![:space:]]*}"}" trimmed="${trimmed%"${trimmed##*[![:space:]]}"}" @@ -749,17 +828,54 @@ fm_backend_herdr_composer_state() { # -> empty|pending|unknown '│'*'│'|'┃'*'┃'|'|'*'|') shape=bordered raw_match=$line + generic_line=$row found=1 ;; *) if printf '%s' "$trimmed" | grep -qE "$FM_BACKEND_HERDR_BARE_PROMPT_RE"; then shape=bare raw_match=$line + generic_line=$row found=1 fi ;; esac done < <(printf '%s\n' "$cap") + # Pi has no prompt glyph or side border. Compare its bottom-most complete + # separator pair with the last generic match so an earlier bordered transcript + # row can never suppress the live Pi composer. Identity is consulted only when + # a lower separator pair could change the verdict. + fm_backend_herdr_pi_composer_find "$cap" + if [ "$FM_BACKEND_HERDR_PI_PAIR_FOUND" -eq 1 ] \ + && [ "$FM_BACKEND_HERDR_PI_PAIR_LINE" -gt "$generic_line" ] \ + && [ "$generic_line" -lt "$FM_BACKEND_HERDR_PI_PAIR_OPEN_LINE" ]; then + identity=$(fm_backend_herdr_agent_identity_raw "$session" "$pane" 2>/dev/null || true) + IFS=$'\t' read -r agent agent_status < -> empty|pending|unknown stripped=${stripped//|/} stripped="${stripped#"${stripped%%[![:space:]]*}"}" stripped="${stripped%"${stripped##*[![:space:]]}"}" + elif [ "$shape" = separated ]; then + # The native Pi identity plus the complete separator pair is the genuine + # composer container, equivalent to a bordered box for shared content + # classification. ANSI stripping keeps real text and drops only styling. + bordered=1 fi # Delegate the empty/pending/unknown decision to the shared owner. The bare # shape only ever starts with an AGENT glyph (FM_BACKEND_HERDR_BARE_PROMPT_RE diff --git a/bin/fm-afk-launch.sh b/bin/fm-afk-launch.sh index 61c6a28cb9c..a6ddcbcc6cd 100755 --- a/bin/fm-afk-launch.sh +++ b/bin/fm-afk-launch.sh @@ -437,6 +437,10 @@ fm_afk_launch_create_tmux() { # fm_afk_launch_start() { local captain_target captain_backend backup artifact had_afk=0 result + if [ -e "$FM_AFK_LAUNCH_STATE/.afk-return-catchup" ]; then + fm_afk_launch_log "return catch-up is still pending; run bin/fm-afk-return.sh check before re-entering away mode" + return 1 + fi # Capture the captain pane FIRST, before creating anything. captain_target=$(discover_supervisor_target) || { fm_afk_launch_log "could not resolve the captain supervisor pane (set FM_SUPERVISOR_TARGET)"; return 1; } @@ -503,6 +507,10 @@ fm_afk_launch_start() { fm_afk_launch_start_native() { local backup artifact had_afk=0 result=0 mkdir -p "$FM_AFK_LAUNCH_STATE" || return 1 + if [ -e "$FM_AFK_LAUNCH_STATE/.afk-return-catchup" ]; then + fm_afk_launch_log "return catch-up is still pending; run bin/fm-afk-return.sh check before re-entering away mode" + return 1 + fi if daemon_lock_held_by_live_daemon; then fm_afk_launch_record_validate_if_present || return 1 fm_afk_launch_flag_write || return 1 diff --git a/bin/fm-afk-return.sh b/bin/fm-afk-return.sh new file mode 100755 index 00000000000..23537e3b65e --- /dev/null +++ b/bin/fm-afk-return.sh @@ -0,0 +1,218 @@ +#!/usr/bin/env bash +# fm-afk-return.sh - deterministic away-mode return catch-up gate. +# +# Usage: +# fm-afk-return.sh Stop away mode, drain catch-up, and open/check gate. +# fm-afk-return.sh begin Same as the default command. +# fm-afk-return.sh check Re-drain and close the gate only after blockers resolve. +# fm-afk-return.sh guard Read-only refusal while away or catch-up is pending. +# +# `blocked:` is the crewmate protocol's firstmate-actionable verb. A live task's +# open blocked event must be remediated and closed with `resolved [key=...]`, or +# explicitly reclassified in the status stream with a durable reason, before an +# ordinary captain request may proceed. `needs-decision:` is captain-owned and +# is deliberately not part of this gate; normal reporting surfaces it. +# +# The durable state/.afk-return-catchup file is written BEFORE daemon shutdown, +# so a crash between stopping, draining, and blocker handling fails closed. It +# retains the drained wake, buffered-escalation, and wedge-marker evidence until +# every live open blocker is closed and `check` succeeds. Repeated begin/check +# calls are idempotent. `guard` never mutates state and is suitable for ordinary +# read entrypoints such as fm-bearings-snapshot.sh. +set -u + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd "$SCRIPT_DIR/.." && pwd)}" +FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" +STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}" +GATE="$STATE/.afk-return-catchup" +LOCK="$STATE/.afk-return-catchup.lock" + +usage() { + sed -n '2,7p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//' +} + +clean_field() { + LC_ALL=C tr '\t\r\n' ' ' +} + +append_evidence() { # + local kind=$1 text=$2 file=$3 clean record + [ -n "$text" ] || return 0 + while IFS= read -r line || [ -n "$line" ]; do + [ -n "$line" ] || continue + clean=$(printf '%s' "$line" | clean_field) + record=$(printf 'evidence\t%s\t%s' "$kind" "$clean") + grep -Fqx "$record" "$file" 2>/dev/null || printf '%s\n' "$record" >> "$file" + done < + local destination=$1 + [ -f "$GATE" ] || return 0 + grep '^evidence'"$(printf '\t')" "$GATE" >> "$destination" 2>/dev/null || true +} + +scan_open_blockers() { # -> tab-separated blocker rows + local meta id status key verb summary clean_summary + for meta in "$STATE"/*.meta; do + [ -f "$meta" ] || continue + id=$(basename "$meta") + id=${id%.meta} + status="$STATE/$id.status" + [ -f "$status" ] || continue + while IFS="$(printf '\t')" read -r key verb summary; do + [ "$verb" = blocked ] || continue + clean_summary=$(printf '%s' "$summary" | clean_field) + printf 'blocker\t%s\t%s\t%s\n' "$id" "$key" "$clean_summary" + done </dev/null || true) + [ -n "$started" ] || started=$(date +%s) + pending=$(mktemp "$STATE/.afk-return-catchup.pending.XXXXXX") || return 1 + { + printf 'schema\tfm-afk-return.v1\n' + printf 'started\t%s\n' "$started" + printf 'phase\tstopping-and-draining\n' + preserve_evidence /dev/stdout + } > "$pending" || { rm -f "$pending"; return 1; } + mv "$pending" "$GATE" +} + +write_gate() { # + local evidence=$1 blockers=$2 pending started + pending=$(mktemp "$STATE/.afk-return-catchup.pending.XXXXXX") || return 1 + started=$(awk -F '\t' '$1 == "started" { print $2; exit }' "$GATE" 2>/dev/null || true) + [ -n "$started" ] || started=$(date +%s) + { + printf 'schema\tfm-afk-return.v1\n' + printf 'started\t%s\n' "$started" + printf 'phase\tblocked\n' + cat "$evidence" 2>/dev/null || true + cat "$blockers" 2>/dev/null || true + } > "$pending" || { rm -f "$pending"; return 1; } + mv "$pending" "$GATE" +} + +print_evidence() { # + local file=$1 kind text + while IFS="$(printf '\t')" read -r tag kind text; do + [ "$tag" = evidence ] || continue + printf 'catch-up %s: %s\n' "$kind" "$text" + done < "$file" +} + +print_blockers() { # + local file=$1 tag id key summary + while IFS="$(printf '\t')" read -r tag id key summary; do + [ "$tag" = blocker ] || continue + printf 'firstmate-actionable blocker: %s [key=%s] %s\n' "$id" "$key" "$summary" + done < "$file" +} + +clear_delivery_artifacts() { + rm -f \ + "$STATE/.subsuper-escalations" \ + "$STATE/.subsuper-escalations.since" \ + "$STATE/.subsuper-inject-wedged" +} + +return_guard() { + if [ -e "$STATE/.afk" ]; then + printf 'fm-afk-return: away mode is still active; run bin/fm-afk-return.sh before ordinary captain work\n' >&2 + return 3 + fi + if [ -e "$GATE" ]; then + printf 'fm-afk-return: return catch-up is pending; remediate or durably reclassify every listed blocker, then run bin/fm-afk-return.sh check\n' >&2 + print_blockers "$GATE" >&2 + return 3 + fi + return 0 +} + +return_reconcile() { + local evidence blockers drained wedge escalations lifecycle_ok=1 + evidence=$(mktemp "$STATE/.afk-return-evidence.XXXXXX") || return 1 + blockers=$(mktemp "$STATE/.afk-return-blockers.XXXXXX") || { rm -f "$evidence"; return 1; } + preserve_evidence "$evidence" + + if [ -e "$STATE/.afk" ] || [ -e "$STATE/.afk-daemon-terminal" ]; then + if ! "$SCRIPT_DIR/fm-afk-launch.sh" stop; then + lifecycle_ok=0 + append_evidence lifecycle 'away-mode shutdown failed; lifecycle state preserved for retry' "$evidence" + fi + fi + + drained=$("$SCRIPT_DIR/fm-wake-drain.sh") || { + append_evidence lifecycle 'durable wake drain failed; retry catch-up before ordinary work' "$evidence" + lifecycle_ok=0 + drained="" + } + append_evidence wake "$drained" "$evidence" + + if [ -s "$STATE/.subsuper-inject-wedged" ]; then + wedge=$(head -1 "$STATE/.subsuper-inject-wedged" 2>/dev/null || true) + append_evidence wedge "$wedge" "$evidence" + fi + if [ -s "$STATE/.subsuper-escalations" ]; then + escalations=$(cat "$STATE/.subsuper-escalations" 2>/dev/null || true) + append_evidence escalation "$escalations" "$evidence" + fi + + scan_open_blockers > "$blockers" + if [ "$lifecycle_ok" -ne 1 ] || [ -s "$blockers" ]; then + write_gate "$evidence" "$blockers" || { rm -f "$evidence" "$blockers"; return 1; } + printf 'fm-afk-return: catch-up must finish before the captain request\n' >&2 + print_evidence "$GATE" >&2 + print_blockers "$GATE" >&2 + printf 'fm-afk-return: handle each blocker now, or close it with resolved [key=...] and append a durable reclassification reason, then run bin/fm-afk-return.sh check\n' >&2 + rm -f "$evidence" "$blockers" + return 3 + fi + + print_evidence "$evidence" + rm -f "$GATE" + clear_delivery_artifacts + rm -f "$evidence" "$blockers" + printf 'fm-afk-return: catch-up clear; ordinary captain work may proceed\n' + return 0 +} + +main() { + local mode=${1:-begin} rc + case "$mode" in + begin|check) ;; + guard) return_guard; return ;; + -h|--help|help) usage; return 0 ;; + *) usage >&2; return 2 ;; + esac + + # The mutating begin/check paths need locks and the keyed status fold. + # `guard` returned above without sourcing fm-wake-lib.sh, whose initialization + # creates the state directory, so the advertised read-only guard is literal. + # shellcheck source=bin/fm-wake-lib.sh + . "$SCRIPT_DIR/fm-wake-lib.sh" + # shellcheck source=bin/fm-classify-lib.sh + . "$SCRIPT_DIR/fm-classify-lib.sh" + + mkdir -p "$STATE" || return 1 + fm_lock_acquire_wait "$LOCK" + trap 'fm_lock_release "$LOCK"' EXIT + write_pending_seed || { fm_lock_release "$LOCK"; trap - EXIT; return 1; } + return_reconcile + rc=$? + fm_lock_release "$LOCK" + trap - EXIT + return "$rc" +} + +main "$@" diff --git a/bin/fm-bearings-snapshot.sh b/bin/fm-bearings-snapshot.sh index d39feef1ab3..897006b7f66 100755 --- a/bin/fm-bearings-snapshot.sh +++ b/bin/fm-bearings-snapshot.sh @@ -144,6 +144,11 @@ done command -v jq >/dev/null 2>&1 || { echo "fm-bearings-snapshot: jq not found" >&2; exit 1; } +# The deterministic return-catch-up owner must clear before this or any other +# ordinary captain request proceeds. Bearings does not reproduce that policy; +# it only consults the shared read-only gate. +"$SCRIPT_DIR/fm-afk-return.sh" guard || exit $? + NOW=${FM_BEARINGS_NOW:-$(date -u +%Y-%m-%dT%H:%M:%SZ)} if [ "$ALL_LANDED" = 1 ] || [ "$ALL_SECONDMATES" = 1 ]; then if [ "$ALL_LANDED" = 1 ]; then diff --git a/bin/fm-marker-lib.sh b/bin/fm-marker-lib.sh index 2303f3a5748..f7c7cb9cb7b 100644 --- a/bin/fm-marker-lib.sh +++ b/bin/fm-marker-lib.sh @@ -25,17 +25,14 @@ # travels with the live secondmate, and is summarized in AGENTS.md. # # Distinct from the afk daemon marker, on purpose. -# The away-mode daemon (bin/fm-supervise-daemon.sh) marks its daemon->firstmate -# escalations with a BARE leading unit separator (FM_INJECT_MARK, ASCII 0x1f). -# The from-firstmate marker instead uses U+2063 INVISIBLE SEPARATOR after its -# human-readable label. U+2063 has no normal keyboard keystroke but travels as -# UTF-8 text rather than a terminal control byte. The original ASCII 0x1f -# separator did not survive terminal input faithfully: on Herdr 0.7.3 feeding -# it to a real Pi composer removed the preceding label, so Pi received only the -# unmarked request (docs/herdr-backend.md records the incident and live proof). -# The afk contract keys on a LEADING 0x1f, while this marker begins with its -# label and contains no 0x1f, so the two cannot conflate. The visible label is -# what the secondmate's LLM reads; U+2063 remains invisible. +# Both terminal-safe markers use U+2063 INVISIBLE SEPARATOR because it has no +# normal keyboard keystroke but travels as UTF-8 text rather than a terminal +# control byte. The away-mode marker is a BARE leading U+2063; this marker begins +# with its human-readable label and places U+2063 after it, so the two cannot +# conflate. The original ASCII 0x1f separator did not survive terminal input +# faithfully: on Herdr 0.7.3 feeding it to a real Pi composer removed marker +# content, so Pi received an unmarked message. docs/herdr-backend.md records both +# incidents and their live proof. # # Sourced by bin/fm-send.sh, bin/fm-brief.sh, and the tests. No side effects on # source. set -u / set -e safe. diff --git a/bin/fm-supervise-daemon.sh b/bin/fm-supervise-daemon.sh index a2852bbc2a9..f9dc9c19295 100755 --- a/bin/fm-supervise-daemon.sh +++ b/bin/fm-supervise-daemon.sh @@ -21,12 +21,13 @@ # catch-up or when afk is re-entered. # # IN-BAND SENTINEL MARKER. Every daemon injection is prefixed with -# FM_INJECT_MARK (ASCII unit separator, 0x1f) — a byte a human would never type -# at the start of a message. Firstmate's contract: a message that starts with -# the marker is an internal escalation (stay afk); a message without it means -# the captain is back (exit afk, flush catch-up, resume per-wake responsiveness). -# The marker and the busy-guard solve the same problem — the daemon and the -# human share one input channel — so they live together under /afk. +# FM_INJECT_MARK (U+2063 INVISIBLE SEPARATOR), a character a human cannot type +# from a normal keyboard at the start of a message and Herdr transports as text. +# Firstmate's contract: a message that starts with the marker is an internal +# escalation (stay afk); a message without it means the captain is back (exit +# afk, flush catch-up, resume per-wake responsiveness). The marker and the +# busy-guard solve the same problem - the daemon and the human share one input +# channel - so they live together under /afk. # # Reliability model (see the /afk skill): # - Nothing is lost in away mode: while state/.afk exists, the watcher reverts @@ -205,13 +206,15 @@ LOG_MAX_BYTES_DEFAULT=1048576 LOG_KEEP_LINES_DEFAULT=2000 # --- presence-gating + sentinel marker -------------------------------------- -# The in-band sentinel: ASCII unit separator (0x1f). Invisible and untypable on -# a normal keyboard, so no real user message starts with it. Every daemon -# injection is prefixed with this byte; firstmate treats a leading marker as an -# internal escalation (stay afk) and its absence as "captain is back" (exit afk). -# Portable across harnesses: it travels with the message text, independent of -# any harness-level typed-vs-injected distinction. -FM_INJECT_MARK=$'\x1f' +# The in-band sentinel: U+2063 INVISIBLE SEPARATOR (UTF-8 e2 81 a3). It has no +# normal keyboard keystroke, so no real user message starts with it. Unlike the +# original ASCII unit separator, Herdr transports U+2063 through Pi's terminal +# editor as text instead of consuming it as a control action. Every daemon +# injection is prefixed with this character; firstmate treats a leading marker +# as an internal escalation (stay afk) and its absence as "captain is back" +# (exit afk). Portable across harnesses: it travels with the message text, +# independent of any harness-level typed-vs-injected distinction. +FM_INJECT_MARK=$'\xE2\x81\xA3' AFK_FLAG_NAME=".afk" # Resolve the effective state dir. FM_STATE_OVERRIDE wins (testing); otherwise diff --git a/docs/architecture.md b/docs/architecture.md index 625968b4662..8ce48d01d8e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -59,13 +59,14 @@ The guard covers the main primary and genuinely marked secondmate homes, exempts A presence-gated sub-supervisor (`bin/fm-supervise-daemon.sh`) extends this for walk-away supervision: the `/afk` skill starts it through the tracked foreground helper `bin/fm-afk-start.sh`, after which the watcher reverts to daemon-managed one-shot mode and the daemon self-handles routine wakes in bash. The watcher and daemon share `bin/fm-classify-lib.sh` for captain-relevant status verbs, declared-external-wait vocabulary, and status-scan primitives. The always-on watcher also uses that library's absorb classification on no-verb signals and first-sighting stale panes before status-log terminality is trusted, while the daemon maintains distinct wedge and declared-pause recheck cadences. -The daemon escalates captain-relevant events, plus a bounded recheck for a declared pause that remains idle, as one batched, single-line digest prefixed with an in-band sentinel marker so firstmate can tell daemon injections apart from real messages. +The daemon escalates captain-relevant events, plus a bounded recheck for a declared pause that remains idle, as one batched, single-line digest prefixed with a terminal-safe U+2063 sentinel marker so firstmate can tell daemon injections apart from real messages. Its supervisor injection path supports tmux and herdr panes, with `FM_SUPERVISOR_BACKEND` and `FM_SUPERVISOR_TARGET` resolved independently from the task-spawn backend. Pane existence, busy checks, composer checks, capture, and verified submit route through `bin/fm-backend.sh`: tmux keeps the same submit core used by the tmux send backend, while herdr uses native busy state, native agent-state submit confirmation on idle baselines, and its ANSI-aware structural composer classifier for pending-input guards and submit fallback. Composer-content classification has one shared owner, `bin/fm-composer-lib.sh`, used by tmux, herdr, Orca, and cmux after each adapter performs its own capture and composer-row recognition. The daemon injects only into an affirmatively `empty` composer, so both `pending` and `unknown` defer and a bare dead-shell prompt cannot receive an escalation; the complete policy is in [Composer-emptiness safety](herdr-backend.md#composer-emptiness-safety-2026-07-10-fleet-wide-across-all-four-backends). Unsupported supervisor backends refuse at daemon startup. Stalled escalation delivery writes `state/.subsuper-inject-wedged` and attempts a configured backend-independent active alert after `FM_MAX_DEFER_SECS` instead of silently deferring forever. +On an unmarked return, `bin/fm-afk-return.sh` owns ordered shutdown, durable catch-up evidence, and the fail-closed gate that keeps ordinary work behind every live firstmate-actionable blocker. `fm-send.sh` selects a pre-Enter popup-settle for slash commands and for codex `$...` skill invocations using metadata-routed target `harness=` values, then adds its own `FM_SEND_SETTLE` pause after successful text sends so immediate peeks catch the receiving turn starting; the sub-supervisor uses only the shared submit core and does not pay that post-submit pause. ## Runtime session backends diff --git a/docs/configuration.md b/docs/configuration.md index d4da2abb06a..8ddd11030db 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -326,6 +326,7 @@ HERDR_SESSION=default # herdr-only: named session for normal backend ops; not e FM_BACKEND_HERDR_COMPOSER_LINES=20 # herdr-only: tail lines scanned by composer-state guard/fallback paths; idle-baseline submit confirmation uses agent-state FM_BACKEND_HERDR_IDLE_RE='^Type a message\.\.\.$' # herdr-only: empty-composer placeholder regex after shared ghost extraction plus border and prompt stripping FM_BACKEND_HERDR_BARE_PROMPT_RE='^[❯›]' # herdr-only: verified agent glyphs recognized as an UNBORDERED (bare) composer row, e.g. claude's ❯ or codex's ›; shell glyphs remain unknown rather than empty, and de-emphasised ghost/placeholder text (dim or dark-truecolor) after an agent prompt reads empty via the shared fm_composer_strip_ghost (docs/herdr-backend.md "Incident (2026-07-08)", "Incident (2026-07-10)") +FM_BACKEND_HERDR_PI_COMPOSER_MAX_LINES=8 # herdr-only: maximum rows admitted between Pi's native-identity-corroborated separator pair; taller or ambiguous candidates stay unknown (docs/herdr-backend.md "Incident (2026-07-14)") FM_BACKEND_HERDR_SUBMIT_POLLS=6 # herdr-only: agent-state samples spread across each Enter attempt's budget when confirming a submit (docs/herdr-backend.md "Native agent-state submit confirmation") FM_BACKEND_HERDR_SUBMIT_MIN_SLEEP=0.6 # herdr-only: minimum per-Enter confirmation budget before polling agent-state after an idle baseline FM_BACKEND_ORCA_COMPOSER_LINES=200 # orca-only: terminal-read lines scanned to locate the composer row for submit verification diff --git a/docs/herdr-backend.md b/docs/herdr-backend.md index 0a0c15f7691..96df588661f 100644 --- a/docs/herdr-backend.md +++ b/docs/herdr-backend.md @@ -217,7 +217,7 @@ backend=herdr expected-label=fm-marker-pi-sm ``` -Pi's separator-only idle composer is outside the Herdr structural classifier's recognized bordered/bare shapes, so composer state was conservatively `unknown` both before and after the send. +At the time, Pi's separator-only idle composer was outside the Herdr structural classifier's recognized bordered/bare shapes, so composer state was conservatively `unknown` both before and after the send. The endpoint's native agent state was idle before submission, and the normal idle-to-working confirmation made `fm-send.sh` return successfully. A task-local Pi `before_agent_start` hook then captured the exact received prompt and UTF-8 bytes: @@ -259,6 +259,82 @@ evidence: direct-input received-hex=464d5f4d41524b45525f48455244525f444952454354 Unit coverage in `tests/fm-send-secondmate-marker.test.sh` pins exact-id and stable-label secondmates, exact-id and stable-label ordinary crewmates, explicit endpoints with and without local metadata, key-only sends, direct unmarked input, exact U+2063 bytes, and idempotence. Strict unresolved-selector behavior remains covered by `tests/fm-send-strict.test.sh`. +## Incident (2026-07-14): Pi-on-Herdr away escalation stayed non-injectable for 4555 seconds + +A guarded reproduction used Herdr 0.7.3, Pi 0.80.7, a generated non-`default` session from `bin/fm-herdr-lab.sh`, an isolated Firstmate home, a real Pi primary pane, the public `bin/fm-afk-launch.sh start` entrypoint, a live synthetic child pane, the real daemon and wake queue, `bin/fm-afk-launch.sh stop`, `bin/fm-wake-drain.sh`, and `bin/fm-bearings-snapshot.sh`. +Every production-adapter and explicit Herdr command was routed through the lab helper, and teardown verified that the running `default` session was unchanged. +The synthetic child appended `blocked [key=synthetic-dependency]: firstmate can refresh the synthetic token` while away mode was active. +The daemon classified the status as captain-relevant, retained it in `state/.subsuper-escalations`, and the watcher retained the matching `signal` in `state/.wake-queue`. +The oldest-escalation sidecar was backdated by 4555 seconds to reproduce the observed interval without waiting in wall-clock time. +The alarm then recorded `fm away-mode inject WEDGED: 4556s undelivered`, the active notifier fired exactly once, the buffer remained intact, and Pi captured zero injected prompts. + +The causal probes against the exact recorded supervisor target were: + +```text +target_exists=yes +busy_state=idle +composer_state=unknown +``` + +The plain Pi capture showed a blank content row between two horizontal separators: + +```text +───────────────────────────────────────────────────── + +───────────────────────────────────────────────────── + +``` + +The ANSI capture showed the same two blue separator rows with a reverse-video cursor in the blank content row. +Real pending text occupied that same middle row and native `agent get` remained idle, which proved native agent state alone could not distinguish an empty Pi composer from an unsubmitted Pi draft. +The exact target was correct, the agent was not busy, and submit verification was never reached because the affirmative-empty pre-injection guard rejected the unrecognized structure. +This rules out target resolution, busy detection, submit acknowledgement, and shutdown ordering as the 4555-second cause. +The root cause was solely that the Herdr structural classifier recognized bordered composers and bare `❯` or `›` prompt rows, while Pi renders a separator-only composer. + +`fm_backend_herdr_composer_state` remains the single backend owner of structural row recognition. +It now accepts content between the bottom-most complete pair of Pi separator rows only when Herdr's native identity says the target agent is exactly `pi` and its status is `idle`, `done`, or `blocked`. +A working Pi, a pending middle row, a missing or non-Pi identity, an incomplete pair, or an over-tall candidate remains `pending` or `unknown`, so dead shells and ambiguous panes are still non-injectable. +The extracted content still routes through the shared `bin/fm-composer-lib.sh` decision owner. + +Making the Pi composer injectable exposed the already-proven terminal-control hazard from the 2026-07-13 incident: Herdr consumed a leading ASCII `0x1f`, so Pi received `Supervisor escalate...` without the away marker. +`FM_INJECT_MARK` now uses a bare leading U+2063 INVISIBLE SEPARATOR, while the from-firstmate marker remains a visible label followed by U+2063, so their full prefixes stay distinct. +U+2063 has no normal keyboard keystroke and the real post-fix Pi prompt capture retained its `e281a3` prefix byte-exact. + +The return half of the same reproduction showed that separate `stop` and `wake-drain` calls left policy ownership to the operator and allowed an ordinary Bearings request to begin while the live blocker remained open. +Bearings' authoritative structured projection was already correct: + +```json +{"in_flight":[{"id":"synthetic-child","state":"blocked"}],"decisions_open":[{"id":"synthetic-child","verb":"blocked"}],"gates":[]} +``` + +The live blocker was never structured as queued work, so no return policy was duplicated into Bearings wording or its projection. +`bin/fm-afk-return.sh` now owns deterministic stop, drain, durable evidence, and the fail-closed return gate. +It refuses ordinary work until each live open `blocked:` key is resolved after immediate remediation or explicitly reclassified with a durable reason. +The `/afk` skill owns the situation-specific procedure, and the always-loaded `AGENTS.md` away stub contains only the safety-critical trigger to run that owner before processing the return message. +`bin/fm-bearings-snapshot.sh` consults the owner's read-only guard and contains no copy of the policy. + +The guarded post-fix command was: + +```sh +FM_AFK_PI_HERDR_E2E=1 HERDR_LAB_HELPER=bin/fm-herdr-lab.sh tests/fm-afk-pi-herdr-return-e2e.test.sh +``` + +The real result was: + +```text +ok - real Pi/Herdr pending composer refuses injection without forced submit and raises one observable fallback +ok - real idle Pi/Herdr accepts one marked escalation promptly, verifies submit, clears wedge state, and emits no duplicate alert +ok - real unmarked Pi return opens catch-up and blocks Bearings before the unresolved blocker can be deferred +ok - resolved return catch-up allows Bearings and a clean idempotent away re-entry +evidence: herdr=herdr 0.7.3 pi=0.80.7 inject-hex-prefix=e281a3 notifier-count=1 +``` + +Unit coverage in `tests/fm-backend-herdr.test.sh` pins the exact idle and pending Pi captures plus working, non-Pi, unreadable, and over-tall refusal. +`tests/fm-afk-return.test.sh` pins durable catch-up evidence, blocker ownership, Bearings precedence, explicit reclassification, re-entry, and tmux/Herdr parity. +`tests/fm-bearings-snapshot.test.sh` pins that live blocked work remains live structured state and never becomes a queued gate. +The existing tmux injection E2E remains the transport-parity proof for type-once, verified-submit behavior. +The wedge alarm remains defense in depth and is not the primary delivery path. + ## Verified bug: `pane read --lines N` returns empty for small N This was the most significant finding of this verification pass. @@ -313,17 +389,18 @@ The tmux backend was NOT affected by this incident: `fm_tmux_composer_state` rea Herdr's CLI exposes no cursor-row primitive, so the composer row is located by shape instead of position. For bordered composers, the row is the only line in a generous tail capture whose trimmed content both starts and ends with the same border glyph (`│`, `┃`, or a plain `|`) - the box's own top/bottom rows use rounded corners and never match, popup item rows and separator rows carry no border glyph at all, and the footer help line uses `│` only as an interior separator (never as the first/last character), so none of those can be mistaken for the composer. For unbordered live composers, added after the 2026-07-07 incident below, the row is a bottom-most trimmed line starting with a verified agent prompt glyph (`❯` for claude or `›` for codex); decorative bordered boxes above it lose to that bottom-most match. +For Pi, added after the 2026-07-14 incident above, the candidate is the content between the bottom-most complete separator pair, admitted only when native Herdr identity reports exactly `pi` with status `idle`, `done`, or `blocked` and the bounded structure is unambiguous. A popup-close-with-placeholder-fill still reads as real content on that row, so composer fallback correctly classifies it as pending; on the normal idle-baseline path, the same first Enter also fails to start a turn, so native agent-state confirmation likewise retries instead of stopping early. Known ghost/placeholder composer text (`Type a message...`, verified grok 0.2.82's empty-composer hint) is recognized and still reads as empty. When ANSI capture is available, the shared `fm_composer_strip_ghost` extractor removes de-emphasised ghost/placeholder runs before classification while retaining real typed input. The full dim/faint and dark-TRUECOLOR contract is recorded in the 2026-07-10 incident below. -`FM_BACKEND_HERDR_IDLE_RE` extends that placeholder match, `FM_BACKEND_HERDR_BARE_PROMPT_RE` controls the recognized unbordered prompt glyphs, and `FM_BACKEND_HERDR_COMPOSER_LINES` controls the tail-window scan depth; all three are documented in [`docs/configuration.md`](configuration.md). +`FM_BACKEND_HERDR_IDLE_RE` extends that placeholder match, `FM_BACKEND_HERDR_BARE_PROMPT_RE` controls the recognized unbordered prompt glyphs, `FM_BACKEND_HERDR_COMPOSER_LINES` controls the tail-window scan depth, and `FM_BACKEND_HERDR_PI_COMPOSER_MAX_LINES` bounds a Pi separator candidate; all four are documented in [`docs/configuration.md`](configuration.md). See `fm_backend_herdr_composer_state`, `fm_backend_herdr_wait_for_working`, and `fm_backend_herdr_send_text_submit` in `bin/backends/herdr.sh` for the implementation, and `tests/fm-backend-herdr.test.sh`'s composer-state, wait-for-working, and send-text-submit sections for the fake-harness coverage. ## Composer-state classifier: structural row read, not delta-based The herdr adapter no longer diffs raw pane content before/after Enter (see the incident above for why that was unsafe). -It keeps `fm_backend_herdr_composer_state` as a structural classifier for the composer's own row - located as the bottom-most bordered composer row or verified bare prompt row described above - and reports `empty`, `pending`, or `unknown`. +It keeps `fm_backend_herdr_composer_state` as a structural classifier for the composer's own content - located as the bottom-most bordered row, verified bare prompt row, or identity-corroborated Pi separator region described above - and reports `empty`, `pending`, or `unknown`. When ANSI capture is available, the classifier keeps the raw styled row long enough to route it through the shared `fm_composer_strip_ghost` extractor before classification. The 2026-07-10 incident below records the supported dim/faint and dark-TRUECOLOR ghost/placeholder styling. That classifier is still the away-mode daemon's affirmative-empty pre-injection guard and the conservative fallback when `fm_backend_herdr_send_text_submit` cannot use an idle/done native agent-state baseline. diff --git a/docs/scripts.md b/docs/scripts.md index a7d3030f90d..db420026ebd 100644 --- a/docs/scripts.md +++ b/docs/scripts.md @@ -46,6 +46,7 @@ The shared no-mistakes gate refusal used by `fm-spawn.sh`, `fm-send.sh`, and `fm | `fm-watch.sh` | Singleton-safe always-on watcher: absorb benign wakes, queue and exit on actionable ones | | `fm-afk-start.sh` | Run the common sourceable away-mode daemon entry in the foreground | | `fm-afk-launch.sh` | Own away-mode entry, exit, rollback, and any backend terminal lifecycle | +| `fm-afk-return.sh` | Own deterministic return shutdown, catch-up evidence, and the firstmate-actionable blocker gate | | `fm-supervisor-target-lib.sh` | Resolve the shared supervisor target and backend for the daemon and launcher | | `fm-supervise-daemon.sh` | Presence-gated away-mode sub-supervisor: self-handle routine wakes, escalate batched digests, alert on failed delivery | | `fm-crew-state.sh` | Print one deterministic current-state line for a crew | diff --git a/tests/fm-afk-inject-e2e.test.sh b/tests/fm-afk-inject-e2e.test.sh index 14060c99f3f..be029d6c1a3 100755 --- a/tests/fm-afk-inject-e2e.test.sh +++ b/tests/fm-afk-inject-e2e.test.sh @@ -83,7 +83,7 @@ SUPERVISOR_PANE=$("$REAL_TMUX" -L "$SOCKET" display-message -p -t supervisor '#{ LOOP_SCRIPT="$STATE_DIR/supervisor-loop.sh" cat > "$LOOP_SCRIPT" <<'LOOP' #!/usr/bin/env bash -MARK=$'\x1f' +MARK=$'\xE2\x81\xA3' LOG="$1" OLD_STTY=$(stty -g 2>/dev/null || true) [ -z "$OLD_STTY" ] || stty -echo -icanon min 1 time 0 2>/dev/null || true @@ -341,24 +341,19 @@ test_scenario_b() { # swallowed Enter, the retry path fires). sleep 8 - # Assert: exactly ONE digest in the log (no duplicate, no loss). - local digest_count - digest_count=$(grep -c 'Supervisor escalate' "$LOG_FILE" || true) - [ "$digest_count" -eq 1 ] \ - || fail "Scenario B: expected exactly 1 digest, got $digest_count (duplicate or lost)" - - # Assert: the digest is not concatenated with itself (two markers in one line). - if grep -q "$(printf '\x1f').*$(printf '\x1f')" "$LOG_FILE"; then - fail "Scenario B: digest concatenated with itself (two sentinel markers in one line)" - fi + # Assert: exactly ONE terminal-safe marker in the log (no duplicate, no loss). + local marker_count + marker_count=$(awk -F '\t' '{ hex=$1; count += gsub(/e281a3/, "", hex) } END { print count + 0 }' "$LOG_FILE") + [ "$marker_count" -eq 1 ] \ + || fail "Scenario B: expected exactly 1 U+2063 marker, got $marker_count (duplicate or lost)" # Assert: the digest line is classified as "injection" and starts with the - # sentinel marker (hex starts with 1f). + # terminal-safe sentinel marker (hex starts with e281a3). local digest_line digest_hex digest_line=$(grep 'Supervisor escalate' "$LOG_FILE" | head -1) digest_hex=$(printf '%s' "$digest_line" | cut -f1) case "$digest_hex" in - 1f*) ;; # correct: starts with the sentinel marker byte + e281a3*) ;; # correct: starts with the terminal-safe sentinel marker *) fail "Scenario B: digest does not start with sentinel marker (hex: $digest_hex)" ;; esac @@ -388,16 +383,11 @@ test_scenario_c() { echo "done: PR https://example.test/pr/300" > "$STATE_DIR/fake-c1.status" sleep 6 - # Exactly one digest line in the submitted log (no duplicate, no loss). - local digest_count - digest_count=$(grep -c 'Supervisor escalate' "$LOG_FILE" || true) - [ "$digest_count" -eq 1 ] \ - || fail "Scenario C: expected exactly 1 digest, got $digest_count" - - # Not concatenated with itself (two sentinel markers in one line). - if grep -q "$(printf '\x1f').*$(printf '\x1f')" "$LOG_FILE"; then - fail "Scenario C: digest concatenated with itself (two sentinel markers in one line)" - fi + # Exactly one terminal-safe marker in the submitted log (no duplicate, no loss). + local marker_count + marker_count=$(awk -F '\t' '{ hex=$1; count += gsub(/e281a3/, "", hex) } END { print count + 0 }' "$LOG_FILE") + [ "$marker_count" -eq 1 ] \ + || fail "Scenario C: expected exactly 1 U+2063 marker, got $marker_count" # The digest is classified as an injection and starts with the sentinel byte. local digest_line digest_hex @@ -408,7 +398,7 @@ test_scenario_c() { esac digest_hex=$(printf '%s' "$digest_line" | cut -f1) case "$digest_hex" in - 1f*) ;; + e281a3*) ;; *) fail "Scenario C: digest does not start with sentinel marker (hex: $digest_hex)" ;; esac diff --git a/tests/fm-afk-inject-herdr-e2e.test.sh b/tests/fm-afk-inject-herdr-e2e.test.sh index 5980e034e06..5e48ab43eb6 100755 --- a/tests/fm-afk-inject-herdr-e2e.test.sh +++ b/tests/fm-afk-inject-herdr-e2e.test.sh @@ -124,7 +124,7 @@ EOF LOOP_SCRIPT="$STATE_DIR/supervisor-loop.sh" cat > "$LOOP_SCRIPT" <<'LOOP' #!/usr/bin/env bash -MARK=$'\x1f' +MARK=$'\xE2\x81\xA3' LOG="$1" AGENT_SOURCE=fm-test-supervisor AGENT_LABEL=fm-test-supervisor @@ -378,21 +378,17 @@ test_scenario_b() { sleep 10 - local digest_count - digest_count=$(grep -c 'Supervisor escalate' "$LOG_FILE" || true) - [ "$digest_count" -eq 1 ] \ - || fail "Scenario B: expected exactly 1 digest, got $digest_count (duplicate or lost)" - - if grep -q "$(printf '\x1f').*$(printf '\x1f')" "$LOG_FILE"; then - fail "Scenario B: digest concatenated with itself (two sentinel markers in one line)" - fi + local marker_count + marker_count=$(awk -F '\t' '{ hex=$1; count += gsub(/e281a3/, "", hex) } END { print count + 0 }' "$LOG_FILE") + [ "$marker_count" -eq 1 ] \ + || fail "Scenario B: expected exactly 1 U+2063 marker, got $marker_count (duplicate or lost)" local digest_line digest_hex digest_line=$(grep 'Supervisor escalate' "$LOG_FILE" | head -1) digest_hex=$(printf '%s' "$digest_line" | cut -f1) case "$digest_hex" in - 1f*) ;; - *) fail "Scenario B: digest does not start with the sentinel marker (hex: $digest_hex)" ;; + e281a3*) ;; + *) fail "Scenario B: digest does not start with the terminal-safe sentinel marker (hex: $digest_hex)" ;; esac local user_count @@ -414,14 +410,10 @@ test_scenario_c() { echo "done: PR https://example.test/pr/300" > "$STATE_DIR/fake-c1.status" sleep 8 - local digest_count - digest_count=$(grep -c 'Supervisor escalate' "$LOG_FILE" || true) - [ "$digest_count" -eq 1 ] \ - || fail "Scenario C: expected exactly 1 digest, got $digest_count" - - if grep -q "$(printf '\x1f').*$(printf '\x1f')" "$LOG_FILE"; then - fail "Scenario C: digest concatenated with itself (two sentinel markers in one line)" - fi + local marker_count + marker_count=$(awk -F '\t' '{ hex=$1; count += gsub(/e281a3/, "", hex) } END { print count + 0 }' "$LOG_FILE") + [ "$marker_count" -eq 1 ] \ + || fail "Scenario C: expected exactly 1 U+2063 marker, got $marker_count" local digest_line digest_hex digest_line=$(grep 'Supervisor escalate' "$LOG_FILE" | head -1) @@ -431,8 +423,8 @@ test_scenario_c() { esac digest_hex=$(printf '%s' "$digest_line" | cut -f1) case "$digest_hex" in - 1f*) ;; - *) fail "Scenario C: digest does not start with the sentinel marker (hex: $digest_hex)" ;; + e281a3*) ;; + *) fail "Scenario C: digest does not start with the terminal-safe sentinel marker (hex: $digest_hex)" ;; esac local user_count diff --git a/tests/fm-afk-pi-herdr-return-e2e.test.sh b/tests/fm-afk-pi-herdr-return-e2e.test.sh new file mode 100755 index 00000000000..12b421351b8 --- /dev/null +++ b/tests/fm-afk-pi-herdr-return-e2e.test.sh @@ -0,0 +1,297 @@ +#!/usr/bin/env bash +# Real Pi/Herdr end-to-end regression for the 2026-07-14 two-owner incident. +# +# Opt-in because it launches a real interactive Pi primary, a real away daemon, +# and a real isolated Herdr lab session. Every explicit and production-adapter +# Herdr call is routed through fm-herdr-lab.sh. The scenario proves: +# - a live blocked status is classified and durably queued while away; +# - a pending Pi composer refuses injection and receives no forced Enter; +# - the existing wedge alarm remains observable and deduped; +# - clearing the draft makes the genuinely idle Pi composer injectable; +# - verified submit preserves the terminal-safe marker and clears delivery state; +# - an unmarked return request opens the catch-up gate before Bearings; +# - remediation/resolution clears the gate, and re-entry is idempotent. +set -u + +# shellcheck source=tests/lib.sh +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" +# shellcheck source=bin/fm-supervise-daemon.sh +. "$ROOT/bin/fm-supervise-daemon.sh" +# shellcheck source=bin/fm-backend.sh +. "$ROOT/bin/fm-backend.sh" + +if [ "${FM_AFK_PI_HERDR_E2E:-0}" != 1 ]; then + echo "skip: set FM_AFK_PI_HERDR_E2E=1 to run the real Pi/Herdr away-return regression" + exit 0 +fi + +for tool in herdr jq pi python3; do + command -v "$tool" >/dev/null 2>&1 || { echo "skip: $tool not found"; exit 0; } +done + +LAB_HELPER=${HERDR_LAB_HELPER:-$ROOT/bin/fm-herdr-lab.sh} +SESSION=$("$LAB_HELPER" name fm-afk-pi-return-e2e) +TMP_ROOT=$(fm_test_tmproot fm-afk-pi-return-e2e) +HOME_DIR="$TMP_ROOT/home" +STATE="$HOME_DIR/state" +PROJECT="$TMP_ROOT/project" +PI_DIR="$TMP_ROOT/pi-agent" +FAKEBIN="$TMP_ROOT/fakebin" +CAPTURE="$TMP_ROOT/pi-prompts.jsonl" +NOTIFY_LOG="$TMP_ROOT/wedge-notify.log" +ORIGINAL_PATH=$PATH +PRIMARY_PANE= +CHILD_PANE= +PRIMARY_TARGET= +DAEMON_STARTED=0 + +cleanup() { + local rc=$? + trap - EXIT + if [ "$DAEMON_STARTED" -eq 1 ]; then + PATH="$FAKEBIN:$ORIGINAL_PATH" HERDR_SESSION="$SESSION" FM_HOME="$HOME_DIR" FM_STATE_OVERRIDE="$STATE" \ + FM_SUPERVISOR_BACKEND=herdr FM_SUPERVISOR_TARGET="$PRIMARY_TARGET" \ + "$ROOT/bin/fm-afk-launch.sh" stop >/dev/null 2>&1 || true + fi + if ! "$LAB_HELPER" teardown "$SESSION"; then + rc=1 + fi + rm -rf "$TMP_ROOT" + exit "$rc" +} +trap cleanup EXIT +"$LAB_HELPER" provision "$SESSION" + +mkdir -p "$HOME_DIR"/{state,data,config,projects} "$PROJECT" "$PI_DIR" "$FAKEBIN" +printf '# Synthetic isolated Firstmate primary\n' > "$PROJECT/AGENTS.md" + +# A task-local extension grants session-only trust, captures exact prompt bytes, +# and aborts before provider work. No production supervision extension is loaded +# in this synthetic primary, so nothing except the test can mutate fleet state. +# Herdr still observes Pi's real idle->working transition, so production submit +# verification is exercised without making a model request. +CAPTURE_EXT="$TMP_ROOT/capture-extension.ts" +cat > "$CAPTURE_EXT" <<'EOF' +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; +import { appendFileSync } from "node:fs"; +const capturePath = process.env.FM_PI_CAPTURE_PATH!; +export default function (pi: ExtensionAPI) { + pi.on("project_trust", () => ({ trusted: "yes", remember: false })); + pi.on("before_agent_start", (event, ctx) => { + appendFileSync(capturePath, `${JSON.stringify({ prompt: event.prompt, hex: Buffer.from(event.prompt, "utf8").toString("hex") })}\n`); + ctx.abort(); + }); +} +EOF + +# Route production adapter invocations through the guarded helper too. The shim +# removes only the adapter's validated trailing pair, then the helper appends it. +cat > "$FAKEBIN/herdr" <&2; exit 97; } + args=("\${args[@]:0:\$((n-2))}") +else + [ "\${HERDR_SESSION:-}" = "\$session" ] || { echo 'wrapper requires isolated session' >&2; exit 98; } +fi +PATH="\$real_path" exec "\$helper" run "\$session" "\${args[@]}" +EOF +chmod +x "$FAKEBIN/herdr" + +cat > "$TMP_ROOT/wedge-recorder" <> '$NOTIFY_LOG' +EOF +chmod +x "$TMP_ROOT/wedge-recorder" + +cat > "$TMP_ROOT/daemon-entry" </dev/null + +wait_for_idle() { + local stable=0 status _ + for _ in $(seq 1 240); do + status=$("$LAB_HELPER" run "$SESSION" agent get "$PRIMARY_PANE" 2>/dev/null \ + | jq -r '.result.agent.agent_status // empty' 2>/dev/null || true) + case "$status" in + idle|done|blocked) stable=$((stable + 1)); [ "$stable" -ge 4 ] && return 0 ;; + *) stable=0 ;; + esac + sleep 0.25 + done + return 1 +} + +wait_for_prompt() { # + local predicate=$1 _ + for _ in $(seq 1 240); do + if [ -s "$CAPTURE" ] && jq -s -e "$predicate" "$CAPTURE" >/dev/null 2>&1; then + return 0 + fi + sleep 0.25 + done + return 1 +} + +wait_for_idle || fail "real Pi primary did not become stably idle" + +assert_blocker_open() { + local at=$1 open + open=$(status_open_decisions "$STATE/repair-task.status") + printf '%s' "$open" | grep -F $'synthetic-dependency\tblocked\t' >/dev/null \ + || fail "live blocked decision disappeared $at: status=$(cat "$STATE/repair-task.status" 2>/dev/null)" +} + +CHILD_OUT=$("$LAB_HELPER" run "$SESSION" tab create --workspace "$WORKSPACE" --cwd "$PROJECT" --label fm-repair-task --no-focus) +CHILD_PANE=$(printf '%s' "$CHILD_OUT" | jq -r '.result.root_pane.pane_id') +CHILD_TARGET="$SESSION:$CHILD_PANE" +cat > "$STATE/repair-task.meta" < "$HOME_DIR/data/backlog.md" <<'EOF' +## In flight +- [ ] repair-task - Repair the synthetic dependency (repo: synthetic-project, since 2026-07-14) + +## Queued + +## Done +EOF + +PATH="$FAKEBIN:$ORIGINAL_PATH" HERDR_SESSION="$SESSION" FM_HOME="$HOME_DIR" FM_STATE_OVERRIDE="$STATE" \ + FM_SUPERVISOR_BACKEND=herdr FM_SUPERVISOR_TARGET="$PRIMARY_TARGET" FM_AFK_LAUNCH_ENTRY="$TMP_ROOT/daemon-entry" \ + "$ROOT/bin/fm-afk-launch.sh" start >/dev/null +DAEMON_STARTED=1 +for _ in $(seq 1 100); do [ -s "$STATE/.supervise-daemon.pid" ] && break; sleep 0.1; done +[ -s "$STATE/.supervise-daemon.pid" ] || fail "away daemon did not start" + +# Pending input is never an injection target. Leave a real draft in Pi before +# the live child emits blocked:, then wait through max-defer. +"$LAB_HELPER" run "$SESSION" pane send-text "$PRIMARY_PANE" 'privacy safe human draft' >/dev/null +sleep 0.5 +composer=$(PATH="$FAKEBIN:$ORIGINAL_PATH" HERDR_SESSION="$SESSION" fm_backend_composer_state herdr "$PRIMARY_TARGET") +[ "$composer" = pending ] || fail "real Pi draft did not classify pending (got $composer)" +CHILD_CMD=$(printf "printf 'blocked [key=synthetic-dependency]: firstmate can refresh the synthetic token\\n' >> %q; exec sleep 120" "$STATE/repair-task.status") +"$LAB_HELPER" run "$SESSION" pane run "$CHILD_PANE" "$CHILD_CMD" >/dev/null +for _ in $(seq 1 160); do [ -s "$STATE/.subsuper-inject-wedged" ] && break; sleep 0.1; done +[ -s "$STATE/.subsuper-inject-wedged" ] || fail "persistently pending real Pi composer did not raise the defense-in-depth alarm" +[ -s "$STATE/.subsuper-escalations" ] || fail "pending real Pi composer lost the buffered blocker" +[ ! -s "$CAPTURE" ] || fail "daemon submitted into Pi while the real human draft was pending" +plain=$("$LAB_HELPER" run "$SESSION" pane read "$PRIMARY_PANE" --source recent --lines 200) +printf '%s' "$plain" | grep -F 'privacy safe human draft' >/dev/null || fail "pending Pi draft was modified or forcibly submitted" +for _ in $(seq 1 50); do [ -s "$NOTIFY_LOG" ] && break; sleep 0.1; done +[ -s "$NOTIFY_LOG" ] || fail "wedge alarm marker appeared but its active notifier did not finish" +[ "$(wc -l < "$NOTIFY_LOG" | tr -d ' ')" -eq 1 ] || fail "wedge alarm was not observed exactly once before recovery" +assert_blocker_open 'while the Pi composer was pending' +pass "real Pi/Herdr pending composer refuses injection without forced submit and raises one observable fallback" + +# Clear, never submit, the synthetic human draft. The same exact target now has +# native idle state plus a complete Pi separator composer and must accept quickly. +"$LAB_HELPER" run "$SESSION" pane send-keys "$PRIMARY_PANE" ctrl+c >/dev/null +wait_for_idle || fail "real Pi did not return idle after clearing the draft" +for _ in $(seq 1 80); do + composer=$(PATH="$FAKEBIN:$ORIGINAL_PATH" HERDR_SESSION="$SESSION" fm_backend_composer_state herdr "$PRIMARY_TARGET") + [ "$composer" = empty ] && break + sleep 0.1 +done +[ "$composer" = empty ] || fail "genuinely idle Pi separator composer did not classify empty (got $composer)" +wait_for_prompt 'any(.[]; .prompt | startswith("\u2063Supervisor escalate"))' \ + || fail "real Pi did not receive the buffered escalation after becoming safely idle" +INJECT_HEX=$(jq -r 'select(.prompt | startswith("\u2063Supervisor escalate")) | .hex' "$CAPTURE" | tail -1) +case "$INJECT_HEX" in e281a3*) ;; *) fail "real Pi escalation lost the terminal-safe marker: $INJECT_HEX" ;; esac +for _ in $(seq 1 80); do [ ! -s "$STATE/.subsuper-escalations" ] && break; sleep 0.1; done +[ ! -s "$STATE/.subsuper-escalations" ] || fail "confirmed real Pi delivery did not clear the escalation buffer" +[ ! -e "$STATE/.subsuper-inject-wedged" ] || fail "confirmed real Pi delivery did not clear the old wedge marker" +sleep 4 +[ "$(wc -l < "$NOTIFY_LOG" | tr -d ' ')" -eq 1 ] || fail "successful delivery emitted a duplicate wedge alert" +INJECT_PROMPT=$(jq -r 'select(.prompt | startswith("\u2063Supervisor escalate")) | .prompt' "$CAPTURE" | tail -1) +message_is_injection "$INJECT_PROMPT" || fail "terminal-delivered Pi escalation was not recognized as an internal marker" +assert_blocker_open 'after successful marked injection' +pass "real idle Pi/Herdr accepts one marked escalation promptly, verifies submit, clears wedge state, and emits no duplicate alert" + +# The captain returns with an ordinary unmarked Bearings request. The request is +# captured byte-exact, then the public return owner must gate it on the blocker. +wait_for_idle || fail "real Pi did not settle after the injected catch-up" +for _ in $(seq 1 80); do + composer=$(PATH="$FAKEBIN:$ORIGINAL_PATH" HERDR_SESSION="$SESSION" fm_backend_composer_state herdr "$PRIMARY_TARGET") + [ "$composer" = empty ] && break + sleep 0.1 +done +[ "$composer" = empty ] || fail "real Pi composer was not ready for the unmarked return request" +"$LAB_HELPER" run "$SESSION" pane send-text "$PRIMARY_PANE" 'Synthetic Bearings request' >/dev/null +"$LAB_HELPER" run "$SESSION" pane send-keys "$PRIMARY_PANE" enter >/dev/null +wait_for_prompt 'any(.[]; .prompt == "Synthetic Bearings request")' || fail "real Pi did not receive the unmarked return request" +RETURN_PROMPT=$(jq -r 'select(.prompt == "Synthetic Bearings request") | .prompt' "$CAPTURE" | tail -1) +should_exit_afk "$STATE" "$RETURN_PROMPT" || fail "unmarked Pi return request did not trigger the away exit contract" +assert_blocker_open 'before return catch-up' +[ -f "$STATE/repair-task.meta" ] || fail "live blocker metadata disappeared before return catch-up" + +set +e +RETURN_OUT=$(PATH="$FAKEBIN:$ORIGINAL_PATH" HERDR_SESSION="$SESSION" FM_ROOT_OVERRIDE="$PROJECT" FM_HOME="$HOME_DIR" FM_STATE_OVERRIDE="$STATE" \ + FM_SUPERVISOR_BACKEND=herdr FM_SUPERVISOR_TARGET="$PRIMARY_TARGET" "$ROOT/bin/fm-afk-return.sh" begin 2>&1) +RETURN_RC=$? +set -e +DAEMON_STARTED=0 +[ "$RETURN_RC" -eq 3 ] || fail "return catch-up did not gate the still-live blocker (rc=$RETURN_RC): $RETURN_OUT" +assert_contains "$RETURN_OUT" 'firstmate-actionable blocker: repair-task [key=synthetic-dependency]' "return gate did not assign remediation" +set +e +BEARINGS_OUT=$(PATH="$FAKEBIN:$ORIGINAL_PATH" HERDR_SESSION="$SESSION" FM_ROOT_OVERRIDE="$PROJECT" FM_HOME="$HOME_DIR" FM_STATE_OVERRIDE="$STATE" \ + "$ROOT/bin/fm-bearings-snapshot.sh" --json 2>&1) +BEARINGS_RC=$? +set -e +[ "$BEARINGS_RC" -eq 3 ] || fail "Bearings bypassed the return gate (rc=$BEARINGS_RC): $BEARINGS_OUT" +pass "real unmarked Pi return opens catch-up and blocks Bearings before the unresolved blocker can be deferred" + +printf 'resolved [key=synthetic-dependency]: refreshed the synthetic token and resumed the task\n' >> "$STATE/repair-task.status" +PATH="$FAKEBIN:$ORIGINAL_PATH" HERDR_SESSION="$SESSION" FM_ROOT_OVERRIDE="$PROJECT" FM_HOME="$HOME_DIR" FM_STATE_OVERRIDE="$STATE" \ + "$ROOT/bin/fm-afk-return.sh" check >/dev/null || fail "remediated blocker did not clear return catch-up" +PATH="$FAKEBIN:$ORIGINAL_PATH" HERDR_SESSION="$SESSION" FM_ROOT_OVERRIDE="$PROJECT" FM_HOME="$HOME_DIR" FM_STATE_OVERRIDE="$STATE" \ + "$ROOT/bin/fm-bearings-snapshot.sh" --json >/dev/null || fail "Bearings remained gated after blocker remediation" + +# A clean re-entry creates no stale delivery or alert, and an immediate return is +# idempotently clear because the keyed blocker is resolved. +PATH="$FAKEBIN:$ORIGINAL_PATH" HERDR_SESSION="$SESSION" FM_HOME="$HOME_DIR" FM_STATE_OVERRIDE="$STATE" \ + FM_SUPERVISOR_BACKEND=herdr FM_SUPERVISOR_TARGET="$PRIMARY_TARGET" FM_AFK_LAUNCH_ENTRY="$TMP_ROOT/daemon-entry" \ + "$ROOT/bin/fm-afk-launch.sh" start >/dev/null +DAEMON_STARTED=1 +PATH="$FAKEBIN:$ORIGINAL_PATH" HERDR_SESSION="$SESSION" FM_ROOT_OVERRIDE="$PROJECT" FM_HOME="$HOME_DIR" FM_STATE_OVERRIDE="$STATE" \ + FM_SUPERVISOR_BACKEND=herdr FM_SUPERVISOR_TARGET="$PRIMARY_TARGET" "$ROOT/bin/fm-afk-return.sh" begin >/dev/null \ + || fail "clean away re-entry/return was not idempotent" +DAEMON_STARTED=0 +[ "$(wc -l < "$NOTIFY_LOG" | tr -d ' ')" -eq 1 ] || fail "clean re-entry duplicated the historical wedge alert" +pass "resolved return catch-up allows Bearings and a clean idempotent away re-entry" + +printf 'evidence: herdr=%s pi=%s target=%s inject-hex-prefix=%s notifier-count=1\n' \ + "$(herdr --version)" "$(pi --version)" "$PRIMARY_TARGET" "${INJECT_HEX:0:6}" diff --git a/tests/fm-afk-return.test.sh b/tests/fm-afk-return.test.sh new file mode 100755 index 00000000000..a7e1a6888d8 --- /dev/null +++ b/tests/fm-afk-return.test.sh @@ -0,0 +1,211 @@ +#!/usr/bin/env bash +# Deterministic return-catch-up gate regression. +# +# Covers the second half of the 2026-07-14 incident: an away-mode blocked event +# survived in durable state, but the ordinary return request could proceed to +# Bearings before Firstmate owned remediation. The shared script now stops, +# drains, preserves evidence, and refuses ordinary work until every live open +# `blocked:` event is resolved or durably reclassified. +set -u + +# shellcheck source=tests/lib.sh +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +TMP_ROOT=$(fm_test_tmproot fm-afk-return-tests) + +install_runner() { # + local dir=$1 + mkdir -p "$dir/bin" "$dir/home/state" "$dir/home/data" "$dir/home/config" + cp "$ROOT/bin/fm-afk-return.sh" "$dir/bin/" + cp "$ROOT/bin/fm-wake-lib.sh" "$dir/bin/" + cp "$ROOT/bin/fm-classify-lib.sh" "$dir/bin/" + cat > "$dir/bin/fm-afk-launch.sh" <<'SH' +#!/usr/bin/env bash +[ "${1:-}" = stop ] || exit 2 +printf 'stop\n' >> "$FM_HOME/stop.log" +rm -f "$FM_HOME/state/.afk" +if [ -e "$FM_HOME/state/.fail-terminal-stop-once" ]; then + rm -f "$FM_HOME/state/.fail-terminal-stop-once" + exit 1 +fi +rm -f "$FM_HOME/state/.afk-daemon-terminal" +SH + cat > "$dir/bin/fm-wake-drain.sh" <<'SH' +#!/usr/bin/env bash +file="$FM_HOME/state/.fake-drain" +[ -f "$file" ] && cat "$file" +: > "$file" +SH + chmod +x "$dir/bin/"*.sh +} + +run_return() { # + local dir=$1 mode=$2 + FM_HOME="$dir/home" FM_STATE_OVERRIDE="$dir/home/state" "$dir/bin/fm-afk-return.sh" "$mode" 2>&1 +} + +seed_live_blocker() { # + local dir=$1 backend=$2 key=$3 target + case "$backend" in + tmux) target='synthetic:fm-repair-task' ;; + herdr) target='fm-lab-synthetic:w1:p2' ;; + esac + cat > "$dir/home/state/repair-task.meta" < "$dir/home/state/repair-task.status" +} + +test_return_gate_orders_catchup_before_bearings() { + local dir out rc gate wake_count + dir="$TMP_ROOT/ordering" + install_runner "$dir" + seed_live_blocker "$dir" herdr synthetic-dependency + date +%s > "$dir/home/state/.afk" + printf 'repair-task.status: blocked synthetic dependency\n' > "$dir/home/state/.subsuper-escalations" + printf 'fm away-mode inject WEDGED: 4555s undelivered\n' > "$dir/home/state/.subsuper-inject-wedged" + printf '1784074271\t2\tsignal\trepair-task.status\tsignal: synthetic status\n' > "$dir/home/state/.fake-drain" + + set +e + out=$(run_return "$dir" begin) + rc=$? + set -e + [ "$rc" -eq 3 ] || fail "return begin should gate on a live blocker (rc=$rc): $out" + gate="$dir/home/state/.afk-return-catchup" + [ -s "$gate" ] || fail "return begin did not persist its fail-closed catch-up gate" + assert_contains "$out" 'firstmate-actionable blocker: repair-task [key=synthetic-dependency]' "return output did not assign blocker remediation to Firstmate" + grep -F $'evidence\twake\t1784074271' "$gate" >/dev/null || fail "drained wake evidence was not retained in the durable gate" + grep -F $'evidence\twedge\tfm away-mode inject WEDGED: 4555s undelivered' "$gate" >/dev/null || fail "wedge evidence was not retained in the durable gate" + grep -F $'evidence\tescalation\trepair-task.status: blocked synthetic dependency' "$gate" >/dev/null || fail "buffered escalation evidence was not retained in the durable gate" + [ "$(wc -l < "$dir/home/stop.log" | tr -d ' ')" -eq 1 ] || fail "return begin did not stop away mode exactly once" + + # The exact incident regression: Bearings is an ordinary request and must + # refuse before reading/rendering while this shared gate remains open. + set +e + out=$(FM_HOME="$dir/home" FM_STATE_OVERRIDE="$dir/home/state" "$ROOT/bin/fm-bearings-snapshot.sh" --json 2>&1) + rc=$? + set -e + [ "$rc" -eq 3 ] || fail "Bearings should refuse behind the return gate (rc=$rc): $out" + assert_contains "$out" 'return catch-up is pending' "Bearings refusal did not point to the shared return owner" + + # Restart/re-entry is idempotent: no second stop, no duplicate catch-up line, + # and the same unresolved blocker remains authoritative. + set +e + out=$(run_return "$dir" begin) + rc=$? + set -e + [ "$rc" -eq 3 ] || fail "repeated begin should preserve the unresolved gate" + [ "$(wc -l < "$dir/home/stop.log" | tr -d ' ')" -eq 1 ] || fail "repeated begin stopped an already-stopped daemon twice" + wake_count=$(grep -c $'^evidence\twake\t1784074271' "$gate" || true) + [ "$wake_count" -eq 1 ] || fail "repeated begin duplicated retained wake evidence ($wake_count copies)" + [ "$(grep -c $'^evidence\twedge\t' "$gate" || true)" -eq 1 ] || fail "repeated begin duplicated retained wedge evidence" + [ "$(grep -c $'^evidence\tescalation\t' "$gate" || true)" -eq 1 ] || fail "repeated begin duplicated retained escalation evidence" + + printf 'resolved [key=synthetic-dependency]: refreshed the synthetic token and resumed the task\n' >> "$dir/home/state/repair-task.status" + out=$(run_return "$dir" check) || fail "resolved blocker did not clear return catch-up: $out" + assert_contains "$out" 'catch-up clear' "successful check did not announce that ordinary work may proceed" + [ ! -e "$gate" ] || fail "successful check left the return gate behind" + [ ! -e "$dir/home/state/.subsuper-escalations" ] || fail "successful check left delivered escalation state behind" + [ ! -e "$dir/home/state/.subsuper-inject-wedged" ] || fail "successful check left the wedge marker behind" + + out=$(run_return "$dir" check) || fail "an already-clear repeated check should be idempotent: $out" + [ ! -e "$gate" ] || fail "idempotent clear check recreated a gate" + pass "return catch-up precedes Bearings, owns live blocker remediation, preserves evidence once, and clears idempotently" +} + +test_explicit_reclassification_requires_durable_reason() { + local backend dir out rc + for backend in tmux herdr; do + dir="$TMP_ROOT/reclassify-$backend" + install_runner "$dir" + seed_live_blocker "$dir" "$backend" vendor-release + date +%s > "$dir/home/state/.afk" + : > "$dir/home/state/.fake-drain" + set +e + out=$(run_return "$dir" begin) + rc=$? + set -e + [ "$rc" -eq 3 ] || fail "$backend blocker did not open the return gate" + + # A pause alone cannot mask the keyed blocker. The old concern must be + # explicitly resolved with the durable reclassification reason first. + printf 'paused [key=vendor-release]: waiting for the synthetic vendor window\n' >> "$dir/home/state/repair-task.status" + set +e + out=$(run_return "$dir" check) + rc=$? + set -e + [ "$rc" -eq 3 ] || fail "$backend pause silently masked an unresolved blocked key" + + printf 'resolved [key=vendor-release]: reclassified as an external wait because the synthetic vendor owns the next event\n' >> "$dir/home/state/repair-task.status" + printf 'paused [key=vendor-release]: waiting for the synthetic vendor window\n' >> "$dir/home/state/repair-task.status" + out=$(run_return "$dir" check) || fail "$backend durable reclassification did not clear the return gate: $out" + [ ! -e "$dir/home/state/.afk-return-catchup" ] || fail "$backend reclassification left a gate behind" + done + pass "tmux and Herdr blockers require the same explicit durable reclassification before ordinary work" +} + +test_captain_decision_does_not_masquerade_as_firstmate_blocker() { + local dir out + dir="$TMP_ROOT/captain-decision" + install_runner "$dir" + cat > "$dir/home/state/decision-task.meta" <<'EOF' +window=synthetic:fm-decision-task +backend=tmux +kind=ship +EOF + printf 'needs-decision [key=api-shape]: captain must choose the synthetic API shape\n' > "$dir/home/state/decision-task.status" + date +%s > "$dir/home/state/.afk" + printf '1784074271\t1\tsignal\tdecision-task.status\tsignal: synthetic decision\n' > "$dir/home/state/.fake-drain" + out=$(run_return "$dir" begin) || fail "captain-owned decision should not be treated as a firstmate blocker: $out" + assert_contains "$out" 'catch-up wake:' "captain-owned decision wake was not surfaced in catch-up" + [ ! -e "$dir/home/state/.afk-return-catchup" ] || fail "captain-owned decision incorrectly opened a firstmate blocker gate" + pass "captain-owned needs-decision remains reportable without masquerading as a firstmate-actionable blocker" +} + +test_away_reentry_refuses_pending_return_gate() { + local dir out rc + dir="$TMP_ROOT/reentry" + mkdir -p "$dir/home/state" "$dir/home/data" "$dir/home/config" + printf 'schema\tfm-afk-return.v1\nphase\tblocked\n' > "$dir/home/state/.afk-return-catchup" + set +e + out=$(FM_HOME="$dir/home" FM_STATE_OVERRIDE="$dir/home/state" "$ROOT/bin/fm-afk-launch.sh" start-native 2>&1) + rc=$? + set -e + [ "$rc" -ne 0 ] || fail "away re-entry succeeded while return catch-up was pending" + assert_contains "$out" 'return catch-up is still pending' "away re-entry refusal did not explain the pending owner" + [ ! -e "$dir/home/state/.afk" ] || fail "away re-entry wrote .afk despite the pending return gate" + pass "away-mode re-entry fails closed while the prior return catch-up is pending" +} + +test_check_retries_recorded_terminal_teardown() { + local dir gate out rc + dir="$TMP_ROOT/terminal-teardown" + install_runner "$dir" + gate="$dir/home/state/.afk-return-catchup" + date +%s > "$dir/home/state/.afk" + printf 'herdr\tsynthetic:pane\tsynthetic-workspace\n' > "$dir/home/state/.afk-daemon-terminal" + touch "$dir/home/state/.fail-terminal-stop-once" + + set +e + out=$(run_return "$dir" begin) + rc=$? + set -e + [ "$rc" -eq 3 ] || fail "failed terminal teardown should keep return catch-up gated (rc=$rc): $out" + [ -e "$gate" ] || fail "failed terminal teardown cleared the return gate" + [ -e "$dir/home/state/.afk-daemon-terminal" ] || fail "failed terminal teardown discarded its durable record" + [ ! -e "$dir/home/state/.afk" ] || fail "failed terminal teardown did not preserve stop ordering" + + out=$(run_return "$dir" check) || fail "check did not retry recorded terminal teardown: $out" + [ ! -e "$dir/home/state/.afk-daemon-terminal" ] || fail "successful check left the terminal teardown record behind" + [ ! -e "$gate" ] || fail "successful terminal teardown retry left the return gate behind" + [ "$(wc -l < "$dir/home/stop.log" | tr -d ' ')" -eq 2 ] || fail "check did not retry terminal teardown exactly once" + pass "check retries recorded terminal teardown and keeps catch-up gated until success" +} + +test_return_gate_orders_catchup_before_bearings +test_explicit_reclassification_requires_durable_reason +test_captain_decision_does_not_masquerade_as_firstmate_blocker +test_away_reentry_refuses_pending_return_gate +test_check_retries_recorded_terminal_teardown diff --git a/tests/fm-backend-herdr.test.sh b/tests/fm-backend-herdr.test.sh index eab75fd382a..5186cca2ce6 100755 --- a/tests/fm-backend-herdr.test.sh +++ b/tests/fm-backend-herdr.test.sh @@ -893,6 +893,75 @@ test_composer_state_unknown_when_no_composer_row_found() { pass "fm_backend_herdr_composer_state: reports unknown for bare shell prompts with no composer row" } +# Real Pi 0.80.7 on Herdr 0.7.3 renders no prompt glyph and no side border. +# Its content is the row(s) between two blue horizontal separators; the idle row +# carries only a reverse-video cursor. This exact shape was `unknown` for 4555s +# during the 2026-07-14 incident, so the safe injector never attempted submit. +test_composer_state_pi_separator_idle_is_empty() { + local dir log resp fb out calls + dir="$TMP_ROOT/composer-pi-separated-idle"; mkdir -p "$dir/responses"; log="$dir/log"; resp="$dir/responses"; : > "$log" + printf '│ stale bordered transcript row │\n\x1b[0m\x1b[38;2;129;162;190m─────────────────────────────────────────────────────\x1b[0m\n\x1b[0m\x1b[7m \x1b[0m \n\x1b[0m\x1b[38;2;129;162;190m─────────────────────────────────────────────────────\x1b[0m\n\x1b[0m\x1b[38;2;102;102;102m~/synthetic-primary (main)\x1b[0m\n' > "$resp/1.out" + printf '{"result":{"agent":{"agent":"pi","agent_status":"idle"}}}\n' > "$resp/2.out" + fb=$(make_herdr_fakebin "$dir") + out=$( PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" \ + bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_composer_state lab:w1:p2' "$ROOT" ) + [ "$out" = empty ] || fail "an idle native Pi separator composer should read empty, got '$out'" + calls=$(grep -c $'\x1f''agent'$'\x1f''get' "$log") + [ "$calls" -eq 1 ] || fail "Pi separator recognition must corroborate identity exactly once, made $calls agent calls" + pass "fm_backend_herdr_composer_state: a native idle Pi separator composer reads empty" +} + +test_composer_state_pi_separator_real_text_is_pending() { + local dir log resp fb out + dir="$TMP_ROOT/composer-pi-separated-pending"; mkdir -p "$dir/responses"; log="$dir/log"; resp="$dir/responses"; : > "$log" + printf '\x1b[38;2;129;162;190m─────────────────────────────────────────────────────\x1b[0m\nprivacy safe human draft\x1b[7m \x1b[0m\n\x1b[38;2;129;162;190m─────────────────────────────────────────────────────\x1b[0m\n' > "$resp/1.out" + printf '{"result":{"agent":{"agent":"pi","agent_status":"done"}}}\n' > "$resp/2.out" + fb=$(make_herdr_fakebin "$dir") + out=$( PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" \ + bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_composer_state lab:w1:p2' "$ROOT" ) + [ "$out" = pending ] || fail "real text in a native Pi separator composer should read pending, got '$out'" + pass "fm_backend_herdr_composer_state: real Pi composer text remains pending" +} + +test_composer_state_pi_incomplete_separator_below_stale_generic_is_unknown() { + local dir log resp fb out + dir="$TMP_ROOT/composer-pi-separated-incomplete"; mkdir -p "$dir/responses"; log="$dir/log"; resp="$dir/responses"; : > "$log" + printf '│ │\n─────────────────────────────────────────────────────\n\n' > "$resp/1.out" + printf '{"result":{"agent":{"agent":"pi","agent_status":"idle"}}}\n' > "$resp/2.out" + fb=$(make_herdr_fakebin "$dir") + out=$( PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" \ + bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_composer_state lab:w1:p2' "$ROOT" ) + [ "$out" = unknown ] || fail "an incomplete Pi separator below a stale generic row should remain unknown, got '$out'" + pass "fm_backend_herdr_composer_state: an incomplete lower Pi separator cannot inherit a stale empty row" +} + +test_composer_state_pi_separator_requires_safe_native_identity() { + local dir log resp fb out status case_id idx=0 + for case_id in working non-pi unreadable over-tall; do + dir="$TMP_ROOT/composer-pi-separated-$case_id"; mkdir -p "$dir/responses"; log="$dir/log"; resp="$dir/responses"; : > "$log" + if [ "$case_id" = over-tall ]; then + { + printf '─────────────────────────────────────────────────────\n' + for idx in $(seq 1 9); do printf 'line %s\n' "$idx"; done + printf '─────────────────────────────────────────────────────\n' + } > "$resp/1.out" + else + printf '─────────────────────────────────────────────────────\n\n─────────────────────────────────────────────────────\n' > "$resp/1.out" + fi + case "$case_id" in + working) printf '{"result":{"agent":{"agent":"pi","agent_status":"working"}}}\n' > "$resp/2.out" ;; + non-pi) printf '{"result":{"agent":{"agent":"shell","agent_status":"idle"}}}\n' > "$resp/2.out" ;; + unreadable) printf '1\n' > "$resp/2.exit" ;; + over-tall) printf '{"result":{"agent":{"agent":"pi","agent_status":"idle"}}}\n' > "$resp/2.out" ;; + esac + fb=$(make_herdr_fakebin "$dir") + out=$( PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" \ + bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_composer_state lab:w1:p2' "$ROOT" ) + [ "$out" = unknown ] || fail "unsafe Pi separator case '$case_id' must remain unknown, got '$out'" + done + pass "fm_backend_herdr_composer_state: Pi separators never authorize working, non-Pi, unreadable, or over-tall targets" +} + # --- composer_state: unbordered (bare) composer rows ------------------------- # Regression coverage for the away-mode redelivery-loop incident # (docs/herdr-backend.md "Incident (2026-07-07)"): real claude and codex @@ -2020,6 +2089,10 @@ test_composer_state_real_text_is_pending test_composer_state_popup_placeholder_fill_is_pending test_composer_state_unknown_on_capture_failure test_composer_state_unknown_when_no_composer_row_found +test_composer_state_pi_separator_idle_is_empty +test_composer_state_pi_separator_real_text_is_pending +test_composer_state_pi_incomplete_separator_below_stale_generic_is_unknown +test_composer_state_pi_separator_requires_safe_native_identity test_composer_state_claude_unbordered_prompt_is_empty test_composer_state_claude_unbordered_prompt_is_pending test_composer_state_bare_prompt_below_stale_bordered_banner_wins diff --git a/tests/fm-bearings-snapshot.test.sh b/tests/fm-bearings-snapshot.test.sh index fc2b2a26479..4b7e6a9f83d 100755 --- a/tests/fm-bearings-snapshot.test.sh +++ b/tests/fm-bearings-snapshot.test.sh @@ -1142,6 +1142,25 @@ $(printf 'mate-landed-%02d' "$i")" pass "landed stays bounded with per-home + overall caps and omitted[] disclosure" } +# Bearings projects authoritative structured state rather than inventing return +# policy. A live blocked child remains a live in-flight record with state=blocked +# and an open blocker; it must never be converted into a queued `gates` record. +# The return-catch-up owner prevents this state from reaching ordinary rendering +# during an away return, while this test pins Bearings' own projection boundary. +test_live_blocker_is_not_charted_queue_work() { + local home fakebin json + home=$(make_home live-blocker); write_fixture "$home" + printf 'blocked [key=synthetic-dependency]: firstmate can refresh the synthetic token\n' > "$home/state/ship-task.status" + fakebin=$(make_fakebin "$home") + json=$(run "$home" "$fakebin" --json) + printf '%s' "$json" | jq -e ' + (.in_flight | any(.[]; .id == "ship-task" and .state == "blocked")) + and (.decisions_open | any(.[]; .id == "ship-task" and .verb == "blocked" and .key == "synthetic-dependency")) + and (.gates | any(.[]; .id == "ship-task") | not) + ' >/dev/null || fail "live blocked work was projected as queued/deferred work: $json" + pass "Bearings keeps a live blocker in structured live state and never converts it to Charted Next queue work" +} + # Captain's Call is populated only from the durable keyed open-decision set. The # anti-leak guard: action-free highlights - a working task, a completed scout, # queued/gated items, landed work - must never surface as an open decision, so they @@ -1212,6 +1231,7 @@ test_default_is_bounded_and_local_only test_toon_json_parity test_landed_includes_secondmate_home_merges test_landed_bounded_and_disclosed +test_live_blocker_is_not_charted_queue_work test_captains_call_anti_leak test_chat_contract_four_sections test_completed_scout_report_not_pending diff --git a/tests/fm-daemon.test.sh b/tests/fm-daemon.test.sh index 195a680b458..c6710a63d1a 100755 --- a/tests/fm-daemon.test.sh +++ b/tests/fm-daemon.test.sh @@ -693,6 +693,10 @@ test_busy_guard_defers_when_supervisor_busy() { } test_marker_detection() { + local marker_hex + marker_hex=$(printf '%s' "$FM_INJECT_MARK" | od -An -tx1 | tr -d ' \n') + [ "$marker_hex" = e281a3 ] \ + || fail "FM_INJECT_MARK must use terminal-safe U+2063 bytes, got $marker_hex" # message_is_injection: marker present -> injection; absent -> real message message_is_injection "${FM_INJECT_MARK}Supervisor escalate: done" \ || fail "marker-prefixed message not detected as injection" diff --git a/tests/fm-pi-watch-extension.test.sh b/tests/fm-pi-watch-extension.test.sh index 5aaba7fc1e6..6a8e536ac3b 100755 --- a/tests/fm-pi-watch-extension.test.sh +++ b/tests/fm-pi-watch-extension.test.sh @@ -7,6 +7,10 @@ set -u TMP_ROOT=$(fm_test_tmproot fm-pi-watch-extension) EXT="$ROOT/.pi/extensions/fm-primary-pi-watch.ts" +# Node 24 warns when these test-only dynamic imports load tracked ESM plugins +# from a clean checkout with no tracked .opencode/package.json. The warning is +# unrelated to plugin output, which the assertions intentionally require empty. +export NODE_NO_WARNINGS=1 install_pi_watch_extension_fixture() { local repo=$1