From 2d7daa9e365e91d740524a1892a7525f62c6bbdd Mon Sep 17 00:00:00 2001 From: Tiago Date: Mon, 5 Oct 2026 23:14:21 -0300 Subject: [PATCH 01/12] fix: refuse a confirming Enter on the Claude background-task exit picker (#6666) * fix: refuse a confirming Enter on the Claude exit picker The background-task picker still classifies as pending, so a retried Enter confirms "Exit and stop tasks". Stop after the Enter that opened it, and raise the existing stale wake with the dialog name. A non-paused secondmate still skips that wake, so a secondmate parked on the picker still looks idle. Model-downgrade confirmation, MCP approval, and any other Claude exit confirmation are not covered, because there is no recorded screen for them. * no-mistakes(review): fix: anchor exit picker match and wake second mates * fix: refuse a typed submit while the Claude exit picker is open A pane that already shows the picker must not receive the message or a confirming Enter. The match requires the recorded heading and footer lines, and it is skipped when no dialog name is being recorded. * no-mistakes(review): restore watcher to main behaviour, drop dialog wake * no-mistakes(test): test: align Herdr picker fixtures with the preflight read * no-mistakes(document): document exit refusal on a recognised dialog * fix: remove the dialog file when exit runs in a subshell do_exit is invoked from a command substitution, so the parent cleanup never saw the path it is supposed to delete. * no-mistakes(review): fix: set the dialog file path after the control lock * no-mistakes(document): document why the dialog file path follows the lock * no-mistakes(ci): The exit cleanup in bin/fm-control.sh now deletes the dialog file first and releases the control lock second. The changes are in the worktree and are not committed. Invariant: only the process that holds the control lock for a task may write or delete that task's dialog file ($STATE/.composer-dialog, the file that carries the picker name from the composer read to the Enter checks). The old cleanup released the lock and then deleted the file, so the delete ran outside the lock. Places where this invariant must hold: control_cleanup is the only place that deletes the file, and the single assignment after CONTROL_LOCK_HELD=1 is the only place that names it. do_exit only truncates the file, and it runs while the parent holds the lock. A process that loses the lock never sets the path, so it deletes nothing (earlier fix, unchanged). No sibling site needed a change. Fix: in control_cleanup, the rm of the dialog file moved above the fm_lock_release call, with a two-line comment that gives the reason. No dialog recognition or supervision code changed. Test: added test_exit_removes_the_dialog_file_before_releasing_the_lock in tests/fm-control-relaunch.test.sh. It runs one `fm-control exit` with a recording rm on PATH. The lock release removes paths at or under the control lock with rm, so the recording rm writes whether the dialog file exists at that moment. The test asserts the last record is "absent". It starts no second command. Verification, all run locally: - Before the fix, the new test failed with "the dialog file must be gone when the control lock is released, got: present". - After the fix, tests/fm-control-relaunch.test.sh exits 0 with 75 ok lines and no "not ok" line, including the new test and the existing test that the file is gone after exit and after relaunch. - tests/fm-control.test.sh exits 0 with 44 ok lines. - shellcheck -x on both changed files exits 0 with no output. One thing I noticed and did not change: tests/fm-control-relaunch.test.sh prints 615 "rm: cannot remove ... Permission denied" lines during its temp-directory teardown. The count is the same with my change reverted, so this change does not cause it, and the suite still exits 0. I could not read the Greptile check log (the check run was not found), so I worked from the finding text and the user instructions --- bin/backends/herdr.sh | 9 ++ bin/fm-backend.sh | 39 +++++-- bin/fm-composer-lib.sh | 76 +++++++++++++ bin/fm-control.sh | 44 +++++++- bin/fm-tmux-lib.sh | 5 + docs/agent-control.md | 3 + docs/verification/runtime-backends.md | 18 +++ tests/fm-backend-herdr.test.sh | 71 ++++++++++++ tests/fm-composer-lib.test.sh | 155 ++++++++++++++++++++++++++ tests/fm-control-relaunch.test.sh | 54 +++++++++ tests/fm-control.test.sh | 86 ++++++++++++++ tests/fm-tmux-submit-busy.test.sh | 70 +++++++++++- 12 files changed, 621 insertions(+), 9 deletions(-) diff --git a/bin/backends/herdr.sh b/bin/backends/herdr.sh index c0cdd0540db..15ae10a5800 100644 --- a/bin/backends/herdr.sh +++ b/bin/backends/herdr.sh @@ -3487,7 +3487,12 @@ fm_backend_herdr_send_text_submit() { # esac # Native stayed idle. Composer empty is positive delivery (a landed # Claude turn that never flipped agent_status). Proven pending retries. + # A picker that classifies pending must not receive that retry. verdict=$(fm_backend_herdr_composer_state "$target") + if fm_composer_blocking_dialog_noted >/dev/null; then + printf 'unknown' + return 0 + fi case "$verdict" in empty) printf 'empty'; return 0 ;; pending|pending-unproven) ;; @@ -3496,6 +3501,10 @@ fm_backend_herdr_send_text_submit() { # else sleep "$sleep_s" verdict=$(fm_backend_herdr_composer_state "$target") + if fm_composer_blocking_dialog_noted >/dev/null; then + printf 'unknown' + return 0 + fi if [ "$verdict" = pending ] && [ "$raw_status" != working ] \ && [ "$footer_baseline" = idle ] \ && [ "$(fm_backend_herdr_rendered_busy_state "$target")" = busy ]; then diff --git a/bin/fm-backend.sh b/bin/fm-backend.sh index 798bb5c599a..916e3d2e353 100644 --- a/bin/fm-backend.sh +++ b/bin/fm-backend.sh @@ -811,18 +811,43 @@ fm_backend_send_key() { # [expected-label] # fm_backend_send_text_submit: type text once, then submit and verify, # retrying only the submission (never retyping). Echoes the backend's # proof-carrying verdict; callers require exact empty for confirmed delivery. +# A pane that already shows the recognised dialog is refused before any +# adapter types, so that submit neither types the text nor sends Enter. fm_backend_send_text_submit() { # [expected-label] - local backend=$1 + local backend=$1 rc=0 target label dialog shift + target=$1 + label=${6:-} fm_backend_source "$backend" || return 1 + # Every Enter loop below reads the dialog sink, so it must exist before + # any adapter types: a sink that fails here leaves the composer untouched. + fm_composer_dialog_sink_prepare || { + echo "error: the dialog check for a $backend submit could not be recorded" >&2 + return 1 + } + # One composer read after the sink exists and before the adapter types. + # The classify writes the sink; a named dialog means the next Enter would + # answer it. + if [ -n "$label" ]; then + fm_backend_composer_state "$backend" "$target" "$label" >/dev/null || true + else + fm_backend_composer_state "$backend" "$target" >/dev/null || true + fi + if dialog=$(fm_composer_blocking_dialog_noted); then + fm_composer_dialog_sink_release + echo "error: blocked on a prompt: $dialog" >&2 + return 1 + fi case "$backend" in - tmux) fm_backend_tmux_send_text_submit "$@" ;; - herdr) fm_backend_herdr_send_text_submit "$@" ;; - zellij) fm_backend_zellij_send_text_submit "$@" ;; - orca) fm_backend_orca_send_text_submit "$@" ;; - cmux) fm_backend_cmux_send_text_submit "$@" ;; - *) echo "error: no send-text implementation for backend '$backend'" >&2; return 1 ;; + tmux) fm_backend_tmux_send_text_submit "$@" || rc=$? ;; + herdr) fm_backend_herdr_send_text_submit "$@" || rc=$? ;; + zellij) fm_backend_zellij_send_text_submit "$@" || rc=$? ;; + orca) fm_backend_orca_send_text_submit "$@" || rc=$? ;; + cmux) fm_backend_cmux_send_text_submit "$@" || rc=$? ;; + *) echo "error: no send-text implementation for backend '$backend'" >&2; rc=1 ;; esac + fm_composer_dialog_sink_release + return "$rc" } # fm_backend_kill: remove the task's session endpoint. An already-gone target diff --git a/bin/fm-composer-lib.sh b/bin/fm-composer-lib.sh index 38285587980..1e2cb3b8371 100644 --- a/bin/fm-composer-lib.sh +++ b/bin/fm-composer-lib.sh @@ -1671,9 +1671,80 @@ EOF printf '%s\n' "$joined" | LC_ALL=C awk '{$1=$1; printf "%s", $0}' } +# fm_composer_blocking_dialog: name a screen whose next Enter would answer it. +# Prints the name and returns 0 only for the recorded structure of one dialog: +# the heading on its own line, then its selected row alone on a row, with the +# recorded footer as the last non-blank row. A heading buried in a sentence, +# or a last line that only starts with the same words, is not that dialog. +# The strings alone are not enough, because a diff, a note, or a test fixture +# on the pane can quote all of them above a normal composer. A miss returns 1 +# and prints nothing. +# Recorded 2026-10-05 on Claude Code 2.1.289: /exit while a background shell +# is still running opens this picker, and its selected row is Exit and stop tasks. +fm_composer_blocking_dialog() { # -> dialog name + local screen=${1-} + [ -n "$screen" ] || return 1 + if printf '%s\n' "$screen" | fm_composer_strip_ansi | LC_ALL=C awk ' + /^[ \t]*Background work is running[ \t\r]*$/ { heading = 1 } + heading && /^[ \t]*❯ 1\. Exit and stop tasks[ \t\r]*$/ { selected = 1 } + /[^ \t\r]/ { last = $0 } + END { exit !(selected && last ~ /^[ \t]*Enter to confirm · Esc to cancel[ \t\r]*$/) } + '; then + printf '%s' 'Claude background-task exit picker' + return 0 + fi + return 1 +} + +# A command substitution drops a shell variable, and every composer read runs +# inside one. The name is therefore written to FM_COMPOSER_DIALOG_SINK when +# that path is set. The classifier verdict is unchanged. When the sink is +# unset the name would be discarded, so the match is skipped. +fm_composer_note_blocking_dialog() { # + local name= + [ -n "${FM_COMPOSER_DIALOG_SINK:-}" ] || return 1 + if name=$(fm_composer_blocking_dialog "$1"); then + printf '%s' "$name" > "$FM_COMPOSER_DIALOG_SINK" || return 1 + return 0 + fi + : > "$FM_COMPOSER_DIALOG_SINK" || return 1 + return 1 +} + +# fm_composer_blocking_dialog_noted: print the name the latest classify wrote +# to the sink. Returns 1 when the sink is unset or empty. +fm_composer_blocking_dialog_noted() { + [ -n "${FM_COMPOSER_DIALOG_SINK:-}" ] || return 1 + [ -s "$FM_COMPOSER_DIALOG_SINK" ] || return 1 + cat "$FM_COMPOSER_DIALOG_SINK" +} + +# Empty the sink, creating it when the caller has not. Sets +# FM_COMPOSER_DIALOG_OWNED=1 only for a sink this call created, so a caller +# that shares the path can still read the name after the release. +fm_composer_dialog_sink_prepare() { + FM_COMPOSER_DIALOG_OWNED=0 + if [ -z "${FM_COMPOSER_DIALOG_SINK:-}" ]; then + FM_COMPOSER_DIALOG_SINK=$(mktemp "${TMPDIR:-/tmp}/fm-composer-dialog.XXXXXX") || return 1 + FM_COMPOSER_DIALOG_OWNED=1 + return 0 + fi + : > "$FM_COMPOSER_DIALOG_SINK" +} + +fm_composer_dialog_sink_release() { + if [ "${FM_COMPOSER_DIALOG_OWNED:-}" = 1 ]; then + rm -f "$FM_COMPOSER_DIALOG_SINK" + FM_COMPOSER_DIALOG_SINK= + FM_COMPOSER_DIALOG_OWNED=0 + fi +} + fm_composer_classify_screen() { # [cursor_row] [identity] local caps=$1 screen=$2 cy=${3:-} identity=${4:-} local styled=0 cursor=0 has_identity=0 kv plain + # Note the dialog before any early return so a pending picker is still named. + fm_composer_note_blocking_dialog "$screen" || true while IFS= read -r kv; do case "$kv" in styled=1) styled=1 ;; @@ -1795,6 +1866,11 @@ fm_composer_submit_retry_core() { # "$send_key_fn" "$target" Enter "$expected_label" || true sleep "$sleep_s" state=$("$state_fn" "$target" "$expected_label") + # The first Enter can open a picker. A later Enter would confirm it. + if fm_composer_blocking_dialog_noted >/dev/null; then + printf 'unknown' + return 0 + fi case "$state" in pending|pending-unproven) ;; *) printf '%s' "$state"; return 0 ;; diff --git a/bin/fm-control.sh b/bin/fm-control.sh index aa4c9af2004..f61876bd0b9 100755 --- a/bin/fm-control.sh +++ b/bin/fm-control.sh @@ -200,6 +200,11 @@ control_cleanup() { && declare -F relaunch_rollback >/dev/null 2>&1; then relaunch_rollback || true fi + # Remove the dialog file while the lock is still held: once it is released, + # the next lifecycle command for this task writes the same path. + if [ -n "${FM_COMPOSER_DIALOG_SINK:-}" ]; then + rm -f "$FM_COMPOSER_DIALOG_SINK" + fi if [ "$CONTROL_LOCK_HELD" = 1 ]; then CONTROL_LOCK_HELD=0 fm_lock_release "$CONTROL_LOCK" || true @@ -313,6 +318,11 @@ trap control_cleanup EXIT fm_lock_try_acquire "$CONTROL_LOCK" \ || die "another lifecycle action is already running for task $ID" CONTROL_LOCK_HELD=1 +# do_exit runs in a command substitution. That subshell does not run this +# EXIT trap, so the parent has to hold the path the trap removes. Set it +# only once the lock is held: a process that loses the lock runs the same +# trap, and would remove the file the lock holder is reading. +FM_COMPOSER_DIALOG_SINK=$STATE/$ID.composer-dialog META="$STATE/$ID.meta" if [ ! -f "$META" ]; then case "$RAW_ID" in @@ -390,6 +400,13 @@ require_state_verified_backend() { # die "task $ID runs on the $BACKEND backend, which has no recovery-grade agent-state classifier, so '$1' cannot prove the agent actually stopped; refusing rather than reporting an unproven transition as done" } +# refuse_blocking_prompt: the screen is a dialog a confirming Enter would +# answer. Name it and stop. Do not type Escape or an option: both dismiss +# or choose. +refuse_blocking_prompt() { # + die "task $ID is blocked on a prompt: $1. Refusing to type Enter into it." +} + # rendered_matches : whether any row of the visible viewport matches. # An unreadable viewport is a no, so every caller treats it as missing proof. rendered_matches() { # @@ -557,7 +574,7 @@ retire_busy_incarnation() { # do_exit: stop the running agent, preserving endpoint and worktree. Prints # `already-stopped`, `endpoint-gone`, or `stopped`. do_exit() { - local state cmd hazard verdict composer_state cancel absence interrupt_result=not-needed + local state cmd hazard verdict composer_state cancel absence interrupt_result=not-needed dialog require_state_verified_backend exit state=$(agent_state) case "$state" in @@ -624,8 +641,16 @@ do_exit() { if [ -n "$hazard" ] && rendered_matches "$hazard"; then die "task $ID shows the $HARNESS revert picker, where typed text becomes a search and Enter reverts file changes; refusing to type the $cmd exit command. Close it with $(fm_control_interrupt_key "$HARNESS"), never Enter, then retry '$VERB'" fi + : > "$FM_COMPOSER_DIALOG_SINK" \ + || die "task $ID's dialog check could not be recorded" composer_state=$(fm_backend_composer_state "$BACKEND" "$T" "$LABEL" 2>/dev/null) \ || composer_state=unknown + # The classify that filled the sink ran in a subshell, so read the file + # rather than a function that subshell sourced. + if [ -s "${FM_COMPOSER_DIALOG_SINK:-}" ]; then + dialog=$(cat "$FM_COMPOSER_DIALOG_SINK") + refuse_blocking_prompt "$dialog" + fi case "$composer_state" in empty) ;; pending) @@ -645,7 +670,24 @@ do_exit() { || die "the exit command could not be sent to task $ID on $BACKEND" [ "$verdict" != send-failed ] \ || die "the exit command could not be sent to task $ID on $BACKEND" + # The submitting Enter can open the picker. The agent is still alive, and + # another Enter would confirm the selected row. A dead agent may leave the + # same text behind; that is not a prompt still waiting. + if [ -s "${FM_COMPOSER_DIALOG_SINK:-}" ]; then + dialog=$(cat "$FM_COMPOSER_DIALOG_SINK") + if [ "$(agent_state)" != dead ]; then + refuse_blocking_prompt "$dialog" + fi + fi state=$(wait_agent_state "$EXIT_WAIT" dead) || { + # A submit can return before any read sees the picker: a native busy + # verdict needs no composer read, and a cleared composer can be read + # before the picker renders. Read the screen once more here. + : > "$FM_COMPOSER_DIALOG_SINK" || true + fm_backend_composer_state "$BACKEND" "$T" "$LABEL" >/dev/null 2>&1 || true + if [ -s "$FM_COMPOSER_DIALOG_SINK" ]; then + refuse_blocking_prompt "$(cat "$FM_COMPOSER_DIALOG_SINK")" + fi die "exit-delivered $ID interrupt=$interrupt_result exit-command=delivered agent-state=$state exit=unconfirmed; the agent did not stop within ${EXIT_WAIT}s" } # The incarnation is over: retire its busy wiring so no stale record or diff --git a/bin/fm-tmux-lib.sh b/bin/fm-tmux-lib.sh index f031e65870b..f7cb21f2c84 100755 --- a/bin/fm-tmux-lib.sh +++ b/bin/fm-tmux-lib.sh @@ -250,6 +250,11 @@ fm_tmux_submit_enter_core() { # [baseline-idle tmux send-keys -t "$target" Enter 2>/dev/null || true sleep "$sleep_s" state=$(fm_tmux_composer_state "$target") + # The first Enter can open a picker. A later Enter would confirm it. + if fm_composer_blocking_dialog_noted >/dev/null; then + printf 'unknown' + return 0 + fi case "$state" in pending|pending-unproven) ;; unknown) diff --git a/docs/agent-control.md b/docs/agent-control.md index c949a13ba96..a543e881938 100644 --- a/docs/agent-control.md +++ b/docs/agent-control.md @@ -50,6 +50,9 @@ 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. +`exit` also refuses, naming the dialog as `blocked on a prompt`, when the screen shows a recognised dialog that a further Enter would answer, whether the dialog was open before the exit command was typed or the submitting Enter opened it; it sends no Escape and chooses no option, so closing the dialog is left to the operator. +A stopped agent whose pane still shows the dialog text is not refused. +[`fm_composer_blocking_dialog`](../bin/fm-composer-lib.sh) owns the recognised set, which today is only Claude's background-task exit picker; [its verification record](verification/runtime-backends.md#claude-background-task-exit-picker) lists the dialogs that are not covered. **Teardown and discard are not verbs and will not become verbs.** `exit` stops an agent and preserves everything else. diff --git a/docs/verification/runtime-backends.md b/docs/verification/runtime-backends.md index f45033ef120..ce429421552 100644 --- a/docs/verification/runtime-backends.md +++ b/docs/verification/runtime-backends.md @@ -1264,6 +1264,24 @@ FM_HERDR_SUBMIT_CONFIRM_LIVE=1 tests/fm-herdr-submit-confirm-live-e2e.test.sh ok - live Herdr submit confirm: Claude Code (2.1.283 (Claude Code)) on herdr 0.9.0 proves and submits a typed /exit behind its command popup ``` +### Claude background-task exit picker + +Measured 2026-10-05 against Claude Code 2.1.289 in an isolated tmux session. +The Herdr lab was not running, so the Herdr path is covered by the existing fakes. +Typing `/exit` while a background shell is still running opens a picker whose selected row is "Exit and stop tasks" and whose footer is "Enter to confirm · Esc to cancel". +That screen still classifies as pending, the same verdict as unsubmitted composer text. +A second Enter would confirm the selected row. +The picker is recognised by its recorded structure only: the heading on its own line, then the selected row alone on its row, with `Enter to confirm · Esc to cancel` as the last non-blank row. +The same strings quoted above a normal composer, as a diff, this note, or a test fixture shows them, are not a picker. +Submit retries now stop after the Enter that opened the picker and report unknown. +A typed submit to a pane that already shows the picker types nothing and sends no Enter. +Exit reports that the worker is blocked on the Claude background-task exit picker and does not type another Enter. +A submit can return before any read sees the picker, so exit reads the screen once more when its wait for the agent to stop times out, and names the picker there too. +Exit does not report a stopped agent whose pane still shows the picker text as blocked on a prompt. +The watcher does not read the picker: a pane parked on it keeps the ordinary stale triage. +No recorded screen was available for a model-downgrade confirmation, an MCP approval, or a Claude exit confirmation other than this picker, so those dialogs are not covered. +Refusing an Enter that would confirm a dialog restores an existing safety path, so it is not gated behind a flag. + ### Prune and respawn The real label-collision reproduction is owned by: diff --git a/tests/fm-backend-herdr.test.sh b/tests/fm-backend-herdr.test.sh index 0911e981bcc..01d0a7015f8 100755 --- a/tests/fm-backend-herdr.test.sh +++ b/tests/fm-backend-herdr.test.sh @@ -126,6 +126,14 @@ herdr_submit_claude_prefix() { # printf ' \xe2\x9d\xaf %s\n' "$text" > "$resp/4.out" } +# herdr_submit_preflight_prefix: fm_backend_send_text_submit reads the composer +# once before the adapter types. That read is call 1 and shows an empty +# composer, so every adapter call moves one slot later. +herdr_submit_preflight_prefix() { # + herdr_submit_shift "$1" 1 + printf ' \xe2\x9d\xaf\n' > "$1/1.out" +} + # make_herdr_server_env_fakebin: a stateful server stub that records only the # long-lived server launch environment, then reports the server as running. make_herdr_server_env_fakebin() { # -> echoes fakebin dir @@ -4431,6 +4439,67 @@ test_send_text_submit_popup_autocomplete_requires_second_enter() { pass "fm_backend_herdr_send_text_submit: a slash-command popup's placeholder fill on Enter #1 never flips agent_status to working, so it does not short-circuit as submitted; Enter #2 is retried and lands it" } +test_send_text_submit_refuses_confirming_enter_on_exit_picker() { + local dir log resp fb out enter_count + dir="$TMP_ROOT/submit-exit-picker"; mkdir -p "$dir/responses" "$dir/tmp"; log="$dir/log"; resp="$dir/responses"; : > "$log" + herdr_submit_claude_prefix "$resp" "/exit" + printf '{"result":{"agent":{"agent":"claude","agent_status":"idle"}}}\n' > "$resp/5.out" + printf '{"result":{"agent":{"agent":"claude","agent_status":"idle"}}}\n' > "$resp/7.out" + printf '%s\n' \ + 'Background work is running' \ + '❯ 1. Exit and stop tasks' \ + 'The following will stop when you exit:' \ + 'shell · sleep 300' \ + ' 2. Move to background and exit' \ + ' 3. Stay' \ + 'Enter to confirm · Esc to cancel' > "$resp/8.out" + herdr_submit_preflight_prefix "$resp" + fb=$(make_herdr_fakebin "$dir") + out=$( PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" FM_BACKEND_HERDR_SUBMIT_POLLS=1 \ + TMPDIR="$dir/tmp" \ + bash -c '. "$0/bin/fm-backend.sh"; fm_backend_send_text_submit herdr default:w1:p2 "/exit" 3 0.01 0.01' "$ROOT" ) + enter_count=$(grep -c $'\x1f''pane'$'\x1f''send-keys'$'\x1f''w1:p2'$'\x1f''enter' "$log" || true) + [ "$out" = unknown ] || fail "the exit picker should stop the retry as unknown, got '$out'; log: $(cat "$log")" + [ "$enter_count" -eq 1 ] || fail "the exit picker should get one Enter, got $enter_count; log: $(cat "$log")" + [ -z "$(ls -A "$dir/tmp")" ] || fail "the submit left its dialog record behind: $(ls -A "$dir/tmp")" + pass "fm_backend_herdr_send_text_submit: the Claude background-task exit picker gets no confirming Enter" +} + +# Herdr can report `blocked` for a picker the submitting Enter opened. The +# submit then reports delivery with no composer read, so the picker is named +# only by the caller's next composer read, the one fm-control exit takes when +# its wait for the agent to stop times out. +test_blocked_submit_leaves_the_exit_picker_to_the_next_composer_read() { + local dir log resp fb out enter_count + dir="$TMP_ROOT/submit-blocked-picker"; mkdir -p "$dir/responses"; log="$dir/log"; resp="$dir/responses"; : > "$log" + herdr_submit_claude_prefix "$resp" "/exit" + printf '{"result":{"agent":{"agent":"claude","agent_status":"idle"}}}\n' > "$resp/5.out" + printf '{"result":{"agent":{"agent":"claude","agent_status":"blocked"}}}\n' > "$resp/7.out" + printf '%s\n' \ + 'Background work is running' \ + '❯ 1. Exit and stop tasks' \ + 'The following will stop when you exit:' \ + 'shell · sleep 300' \ + ' 2. Move to background and exit' \ + ' 3. Stay' \ + 'Enter to confirm · Esc to cancel' > "$resp/8.out" + herdr_submit_preflight_prefix "$resp" + fb=$(make_herdr_fakebin "$dir") + out=$( PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" FM_BACKEND_HERDR_SUBMIT_POLLS=1 \ + FM_COMPOSER_DIALOG_SINK="$dir/sink" \ + bash -c '. "$0/bin/fm-backend.sh" + verdict=$(fm_backend_send_text_submit herdr default:w1:p2 "/exit" 3 0.01 0.01) + printf "%s|%s|" "$verdict" "$(cat "$FM_COMPOSER_DIALOG_SINK")" + fm_backend_composer_state herdr default:w1:p2 >/dev/null + cat "$FM_COMPOSER_DIALOG_SINK"' "$ROOT" ) + enter_count=$(grep -c $'\x1f''pane'$'\x1f''send-keys'$'\x1f''w1:p2'$'\x1f''enter' "$log" || true) + [ "$out" = 'empty||Claude background-task exit picker' ] \ + || fail "a blocked submit should report delivery unnamed and the next composer read should name the picker, got '$out'; log: $(cat "$log")" + [ "$enter_count" -eq 1 ] || fail "a blocked submit should send one Enter, got $enter_count; log: $(cat "$log")" + [ -f "$dir/sink" ] || fail "a submit must not remove a dialog record its caller owns" + pass "fm_backend_send_text_submit (herdr): a picker behind a blocked verdict is named by the caller's next composer read" +} + test_send_text_submit_confirms_blocked_after_enter() { local dir log resp fb out enter_count dir="$TMP_ROOT/submit-blocked"; mkdir -p "$dir/responses"; log="$dir/log"; resp="$dir/responses"; : > "$log" @@ -5954,6 +6023,8 @@ test_send_text_submit_detects_landed_send test_send_text_submit_detects_swallowed_enter test_send_text_submit_replays_literal_send_stderr test_send_text_submit_popup_autocomplete_requires_second_enter +test_send_text_submit_refuses_confirming_enter_on_exit_picker +test_blocked_submit_leaves_the_exit_picker_to_the_next_composer_read test_send_text_submit_confirms_blocked_after_enter test_send_text_submit_preexisting_working_pending_is_queued_enter test_send_text_submit_preexisting_working_does_not_confirm_failed_enter diff --git a/tests/fm-composer-lib.test.sh b/tests/fm-composer-lib.test.sh index 4dbddf14b82..83ce4599472 100755 --- a/tests/fm-composer-lib.test.sh +++ b/tests/fm-composer-lib.test.sh @@ -1080,3 +1080,158 @@ test_queued_enter_verdict_does_not_convert_other_states() { test_queued_enter_verdict_busy_pending_is_empty test_queued_enter_verdict_idle_pending_stays_pending test_queued_enter_verdict_does_not_convert_other_states + +# The selected row sits on cursor row 1 so a tmux read whose cursor is that +# row, and a cursorless read, both still see unsubmitted text. +exit_picker_screen() { + printf '%s\n' \ + 'Background work is running' \ + '❯ 1. Exit and stop tasks' \ + 'The following will stop when you exit:' \ + 'shell · sleep 300' \ + ' 2. Move to background and exit' \ + ' 3. Stay' \ + 'Enter to confirm · Esc to cancel' +} + +fm_test_picker_send() { + printf 'Enter\n' >> "$FM_TEST_PICKER_ENTERS" +} + +fm_test_picker_state() { + fm_composer_classify_screen 'styled=1' "$FM_TEST_PICKER_SCREEN" 1 +} + +test_background_exit_picker_stays_pending_and_blocks_retry() { + local screen out rc sink enters + screen=$(exit_picker_screen) + out=$(fm_composer_blocking_dialog "$screen"); rc=$? + [ "$rc" -eq 0 ] || fail "the recorded picker should match" + [ "$out" = 'Claude background-task exit picker' ] || fail "dialog name was '$out'" + out=$(fm_composer_blocking_dialog 'Background work is running'); rc=$? + [ "$rc" -eq 1 ] || fail "a heading alone must not match" + [ -z "$out" ] || fail "a miss must print nothing, got '$out'" + out=$(fm_composer_blocking_dialog "$(printf '%s\n' 'Background work is running' 'Exit and stop tasks')"); rc=$? + [ "$rc" -eq 1 ] || fail "two of the three strings must not match" + out=$(fm_composer_blocking_dialog "$(printf '%s\n' "$screen" '' '')"); rc=$? + [ "$rc" -eq 0 ] || fail "blank rows below the footer should still match" + sink=$(mktemp) + FM_COMPOSER_DIALOG_SINK=$sink + out=$(fm_composer_classify_screen 'styled=1' "$screen" 1) + [ "$out" = pending ] || fail "cursor on the selected row should stay pending, got '$out'" + [ "$(cat "$sink")" = 'Claude background-task exit picker' ] || fail "classify should note the dialog, got '$(cat "$sink")'" + out=$(fm_composer_classify_screen 'styled=1' "$screen") + [ "$out" = pending ] || fail "a styled cursorless picker should stay pending, got '$out'" + unset FM_COMPOSER_DIALOG_SINK + rm -f "$sink" + FM_TEST_PICKER_SCREEN=$screen + FM_TEST_PICKER_ENTERS=$(mktemp) + : > "$FM_TEST_PICKER_ENTERS" + fm_composer_dialog_sink_prepare || fail "the dialog sink could not be prepared" + sink=$FM_COMPOSER_DIALOG_SINK + out=$(fm_composer_submit_retry_core fm_test_picker_send fm_test_picker_state win 3 0) + fm_composer_dialog_sink_release + [ ! -e "$sink" ] || fail "the release should remove a sink that prepare created" + [ -z "${FM_COMPOSER_DIALOG_SINK:-}" ] || fail "the release should unset a sink that prepare created" + enters=$(grep -c '^Enter$' "$FM_TEST_PICKER_ENTERS" || true) + [ "$out" = unknown ] || fail "a picker must stop the retry as unknown, got '$out'" + [ "$enters" -eq 1 ] || fail "a picker must receive one Enter, got $enters" + rm -f "$FM_TEST_PICKER_ENTERS" + unset FM_TEST_PICKER_SCREEN FM_TEST_PICKER_ENTERS + pass "the Claude background-task exit picker stays pending and receives no confirming Enter" +} + +# The picker's own text, shown the way a worker pane shows it when it prints +# this repository's diff, verification note, or a test fixture: quoted above a +# normal composer. No picker is open, so the next Enter confirms nothing. +quoted_exit_picker_screen() { + printf '%s\n' \ + '● Here is the fixture the test uses:' \ + "+ 'Background work is running' \\" \ + "+ '❯ 1. Exit and stop tasks' \\" \ + "+ 'Enter to confirm · Esc to cancel'" \ + ' The selected row is "❯ 1. Exit and stop tasks" and the footer is "Enter to confirm · Esc to cancel".' \ + 'Background work is running' \ + '❯ 1. Exit and stop tasks' \ + 'Enter to confirm · Esc to cancel' \ + '' \ + '╭──────────────╮' \ + '│ > next steer │' \ + '╰──────────────╯' +} + +test_dialog_heading_and_footer_must_be_the_recorded_lines() { + local screen out rc + screen=$(printf '%s\n' \ + 'The fixture mentions Background work is running in a sentence' \ + '❯ 1. Exit and stop tasks' \ + 'Enter to confirm · Esc to cancel') + out=$(fm_composer_blocking_dialog "$screen"); rc=$? + [ "$rc" -eq 1 ] || fail "a heading buried in a sentence must not match" + [ -z "$out" ] || fail "a miss must print nothing, got '$out'" + screen=$(printf '%s\n' \ + 'Background work is running' \ + '❯ 1. Exit and stop tasks' \ + 'Enter to confirm the deployment') + out=$(fm_composer_blocking_dialog "$screen"); rc=$? + [ "$rc" -eq 1 ] || fail "a last line that only starts with the confirm words must not match" + [ -z "$out" ] || fail "a miss must print nothing, got '$out'" + pass "a buried heading or a different last line is not the exit picker" +} + +test_dialog_note_skips_the_match_when_no_sink_is_set() { + local screen out rc before after + screen=$(exit_picker_screen) + unset FM_COMPOSER_DIALOG_SINK + out=$(fm_composer_note_blocking_dialog "$screen"); rc=$? + [ "$rc" -eq 1 ] || fail "a note without a sink should return 1, got $rc" + [ -z "$out" ] || fail "a note without a sink should print nothing, got '$out'" + [ -z "${FM_COMPOSER_DIALOG_SINK:-}" ] || fail "a note without a sink must not create one" + out=$(fm_composer_classify_screen 'styled=1' "$screen" 1) + [ "$out" = pending ] || fail "classify without a sink should stay pending, got '$out'" + trap 'true' RETURN + before=$(trap -p RETURN) + fm_composer_dialog_sink_prepare || fail "the dialog sink could not be prepared" + fm_composer_dialog_sink_release + after=$(trap -p RETURN) + trap - RETURN + [ "$before" = "$after" ] || fail "release replaced the caller RETURN trap: $after" + pass "a dialog note without a sink skips the match, and release leaves a caller RETURN trap" +} + +test_quoted_exit_picker_text_is_not_a_dialog() { + local screen out rc sink enters + screen=$(quoted_exit_picker_screen) + out=$(fm_composer_blocking_dialog "$screen"); rc=$? + [ "$rc" -eq 1 ] || fail "picker text quoted above a normal composer must not match" + [ -z "$out" ] || fail "a miss must print nothing, got '$out'" + out=$(fm_composer_blocking_dialog "$(printf '%s\n' \ + 'Background work is running' \ + "+ '❯ 1. Exit and stop tasks' \\" \ + 'Enter to confirm · Esc to cancel')"); rc=$? + [ "$rc" -eq 1 ] || fail "a selected row that is not alone on its row must not match" + out=$(fm_composer_blocking_dialog "$(printf '%s\n' \ + '❯ 1. Exit and stop tasks' \ + 'Background work is running' \ + 'Enter to confirm · Esc to cancel')"); rc=$? + [ "$rc" -eq 1 ] || fail "a selected row above the heading must not match" + FM_TEST_PICKER_SCREEN=$screen + FM_TEST_PICKER_ENTERS=$(mktemp) + : > "$FM_TEST_PICKER_ENTERS" + fm_composer_dialog_sink_prepare || fail "the dialog sink could not be prepared" + sink=$FM_COMPOSER_DIALOG_SINK + out=$(fm_composer_submit_retry_core fm_test_picker_send fm_test_picker_state win 3 0) + [ ! -s "$sink" ] || fail "quoted picker text must not be noted as a dialog, got '$(cat "$sink")'" + fm_composer_dialog_sink_release + enters=$(grep -c '^Enter$' "$FM_TEST_PICKER_ENTERS" || true) + [ "$out" = pending ] || fail "quoted picker text must keep the ordinary pending verdict, got '$out'" + [ "$enters" -eq 3 ] || fail "quoted picker text must keep the ordinary Enter retries, got $enters" + rm -f "$FM_TEST_PICKER_ENTERS" + unset FM_TEST_PICKER_SCREEN FM_TEST_PICKER_ENTERS + pass "picker text quoted above a normal composer is not read as a live picker" +} + +test_background_exit_picker_stays_pending_and_blocks_retry +test_dialog_heading_and_footer_must_be_the_recorded_lines +test_dialog_note_skips_the_match_when_no_sink_is_set +test_quoted_exit_picker_text_is_not_a_dialog diff --git a/tests/fm-control-relaunch.test.sh b/tests/fm-control-relaunch.test.sh index 1242dc26bfe..a214f057b7e 100755 --- a/tests/fm-control-relaunch.test.sh +++ b/tests/fm-control-relaunch.test.sh @@ -1627,6 +1627,7 @@ test_concurrent_relaunch_is_refused() { i=$((i + 1)) done [ -e "$lock" ] || { kill "$holder" 2>/dev/null; fail "could not stage a held control lock"; } + printf 'held\n' > "$dir/home/state/rl19.composer-dialog" out=$(run_control "$dir" rl19 relaunch --note "concurrent"); rc=$? kill "$holder" 2>/dev/null || true wait "$holder" 2>/dev/null || true @@ -1635,6 +1636,8 @@ test_concurrent_relaunch_is_refused() { "the refusal should name the concurrent action" [ "$(cat "$dir/fake/command")" = claude ] \ || fail "a refused concurrent relaunch must not stop the agent" + [ "$(cat "$dir/home/state/rl19.composer-dialog" 2>/dev/null)" = held ] \ + || fail "a refused concurrent relaunch must not remove the lock holder's dialog file" pass "fm-control relaunch: two control actions on one task serialize instead of interleaving" } @@ -2386,6 +2389,57 @@ test_relaunch_moves_a_drifted_item_back_in_flight() { pass "relaunch heals an item that drifted out of In flight while the task stayed live" } +test_exit_and_relaunch_remove_the_dialog_file() { + local dir out rc + dir=$(new_case dialog-file-exit rl70) + add_ship_task "$dir" rl70 claude + out=$(run_control "$dir" rl70 exit); rc=$? + expect_code 0 "$rc" "exit should stop the agent"$'\n'"$out" + [ ! -e "$dir/home/state/rl70.composer-dialog" ] \ + || fail "exit should remove the dialog file" + + dir=$(new_case dialog-file-relaunch rl71) + add_ship_task "$dir" rl71 claude + out=$(run_control "$dir" rl71 relaunch --note "replace the agent"); rc=$? + expect_code 0 "$rc" "relaunch should replace the agent"$'\n'"$out" + [ ! -e "$dir/home/state/rl71.composer-dialog" ] \ + || fail "relaunch should remove the dialog file" + pass "fm-control removes the dialog file after exit and after relaunch" +} + +# The lock release removes paths at or under the control lock with rm, so a +# recording rm sees the state directory at the moment of release without a +# second overlapping command. +test_exit_removes_the_dialog_file_before_releasing_the_lock() { + local dir out rc lock sink trace + dir=$(new_case dialog-file-order rl72) + add_ship_task "$dir" rl72 claude + lock="$dir/home/state/.control-rl72.lock" + sink="$dir/home/state/rl72.composer-dialog" + trace="$dir/fake/rm-trace" + cat > "$dir/fakebin/rm" <> "$trace" + break + ;; + esac +done +exec "$(command -v rm)" "\$@" +SH + chmod +x "$dir/fakebin/rm" + out=$(run_control "$dir" rl72 exit); rc=$? + expect_code 0 "$rc" "exit should stop the agent"$'\n'"$out" + [ ! -e "$lock" ] || fail "exit should release the control lock" + [ "$(tail -n 1 "$trace" 2>/dev/null)" = absent ] \ + || fail "the dialog file must be gone when the control lock is released, got: $(cat "$trace" 2>/dev/null)" + pass "fm-control exit removes the dialog file before it releases the control lock" +} + +test_exit_and_relaunch_remove_the_dialog_file +test_exit_removes_the_dialog_file_before_releasing_the_lock test_same_harness_relaunch_keeps_identity_and_reuses_the_endpoint test_relaunch_refuses_before_exit_when_the_composer_holds_pending_text test_relaunch_refuses_before_exit_when_the_composer_state_is_unproven diff --git a/tests/fm-control.test.sh b/tests/fm-control.test.sh index 832c3fd7a49..f34fb8acb91 100755 --- a/tests/fm-control.test.sh +++ b/tests/fm-control.test.sh @@ -175,6 +175,18 @@ case "${1:-}" in done printf 'fakepane\n'; exit 0 ;; capture-pane) + if [ -f "$D/after-enter" ] && [ -f "$D/keys" ] && grep -qx Enter "$D/keys"; then + # after-enter-late holds how many captures after Enter still show the + # ordinary pane, for a screen that renders after the submit has read it. + late=0 + [ ! -f "$D/after-enter-late" ] || late=$(cat "$D/after-enter-late") + if [ "$late" -gt 0 ]; then + printf '%s' "$((late - 1))" > "$D/after-enter-late" + else + cat "$D/after-enter" + exit 0 + fi + fi if [ -f "$D/devin" ]; then devin_screen "$(cat "$D/devin")"; elif [ -f "$D/pane" ]; then cat "$D/pane"; else printf '╭────╮\n│ │\n╰────╯\n'; fi exit 0 ;; list-windows) @@ -838,6 +850,77 @@ test_busy_agent_is_interrupted_before_the_exit_command() { pass "fm-control exit: a busy agent receives interrupt delivery before the exit command" } +exit_picker_screen() { + printf '%s\n' \ + 'Background work is running' \ + '❯ 1. Exit and stop tasks' \ + 'The following will stop when you exit:' \ + 'shell · sleep 300' \ + ' 2. Move to background and exit' \ + ' 3. Stay' \ + 'Enter to confirm · Esc to cancel' +} + +test_exit_refuses_an_open_background_picker() { + local dir out rc + dir=$(new_case open-picker) + add_task "$dir" t1 claude + alive_as "$dir" claude + exit_picker_screen > "$dir/fake/pane" + out=$(run_control "$dir" t1 exit); rc=$? + expect_code 1 "$rc" "an open exit picker should refuse"$'\n'"$out" + assert_contains "$out" "blocked on a prompt: Claude background-task exit picker" \ + "the refusal should name the dialog" + assert_not_contains "$out" "Esc" "the refusal must not name a dismissal key" + [ ! -s "$dir/fake/literal" ] || fail "an open picker must not be typed into" + [ ! -s "$dir/fake/keys" ] || fail "an open picker must receive no keys" + pass "fm-control exit: an already-open background-task picker is not typed into" +} + +test_exit_refuses_the_confirming_enter() { + local dir out rc enters + dir=$(new_case confirm-picker) + add_task "$dir" t1 claude + alive_as "$dir" claude + exit_picker_screen > "$dir/fake/after-enter" + out=$(env FM_FAKE_NEVER_DIES=1 PATH="$dir/fakebin:$PATH" FM_HOME="$dir/home" \ + FM_FAKE_DIR="$dir/fake" FM_CONTROL_POLL=0.01 FM_CONTROL_EXIT_WAIT=0.05 \ + "$CONTROL" t1 exit 2>&1); rc=$? + expect_code 1 "$rc" "the confirming Enter should refuse"$'\n'"$out" + assert_contains "$out" "blocked on a prompt: Claude background-task exit picker" \ + "the refusal should name the dialog" + assert_not_contains "$out" "Esc" "the refusal must not name a dismissal key" + [ "$(literals "$dir")" = /exit ] || fail "the exit command should still be typed, got '$(literals "$dir")'" + enters=$(grep -c '^Enter$' "$dir/fake/keys" || true) + [ "$enters" -eq 1 ] || fail "only the submitting Enter should be sent, got $enters" + pass "fm-control exit: the Enter that opens the background-task picker is not followed by a confirming Enter" +} + +# The submit reads a cleared composer before the picker renders, so it reports +# delivery and no read inside it sees the picker. Exit's own read after the +# stop wait times out must still name the dialog. +test_exit_names_a_picker_that_renders_after_the_submit() { + local dir out rc enters + dir=$(new_case late-picker) + add_task "$dir" t1 claude + alive_as "$dir" claude + exit_picker_screen > "$dir/fake/after-enter" + printf '1' > "$dir/fake/after-enter-late" + out=$(env FM_FAKE_NEVER_DIES=1 PATH="$dir/fakebin:$PATH" FM_HOME="$dir/home" \ + FM_FAKE_DIR="$dir/fake" FM_CONTROL_POLL=0.01 FM_CONTROL_EXIT_WAIT=0.05 \ + "$CONTROL" t1 exit 2>&1); rc=$? + expect_code 1 "$rc" "a picker that renders after the submit should refuse"$'\n'"$out" + [ "$(cat "$dir/fake/after-enter-late")" = 0 ] \ + || fail "the submit should have read the ordinary pane once after Enter" + assert_contains "$out" "blocked on a prompt: Claude background-task exit picker" \ + "the refusal should name the dialog" + assert_not_contains "$out" "did not stop within" \ + "a recognised picker must not fall back to the generic timeout message" + enters=$(grep -c '^Enter$' "$dir/fake/keys" || true) + [ "$enters" -eq 1 ] || fail "only the submitting Enter should be sent, got $enters" + pass "fm-control exit: a picker that renders after the submit returned is named when the stop wait times out" +} + test_idle_agent_is_not_interrupted() { local dir out rc gen dir=$(new_case idle) @@ -1100,6 +1183,9 @@ test_interrupt_refuses_when_no_agent_runs test_ambiguous_endpoint_refuses test_busy_agent_is_interrupted_before_the_exit_command test_idle_agent_is_not_interrupted +test_exit_refuses_an_open_background_picker +test_exit_refuses_the_confirming_enter +test_exit_names_a_picker_that_renders_after_the_submit test_interrupt_without_acknowledgement_preserves_busy_state test_muse_interrupt_confirms_adapter_acknowledgement test_interrupt_revalidates_agent_after_acknowledgement_wait diff --git a/tests/fm-tmux-submit-busy.test.sh b/tests/fm-tmux-submit-busy.test.sh index 5290aaaea97..058c9f7ebb5 100755 --- a/tests/fm-tmux-submit-busy.test.sh +++ b/tests/fm-tmux-submit-busy.test.sh @@ -44,7 +44,14 @@ case "${1:-}" in send-keys) shift; is_enter=0 while [ "$#" -gt 0 ]; do - case "$1" in -t) shift ;; -l) ;; Enter) is_enter=1 ;; esac; shift + case "$1" in + -t) shift ;; + -l) ;; + Enter) is_enter=1 ;; + -*) ;; + *) [ -z "${FM_FAKE_SENT:-}" ] || printf 'typed %s\n' "$1" >> "$FM_FAKE_SENT" ;; + esac + shift done if [ "$is_enter" = 1 ]; then [ -z "${FM_FAKE_SENT:-}" ] || printf 'Enter\n' >> "$FM_FAKE_SENT" @@ -344,6 +351,67 @@ test_claude_busy_signature_uses_real_capture_shapes() { test_busy_pane_pending_returns_empty test_idle_pane_pending_returns_pending + +test_exit_picker_refuses_confirming_enter() { + local dir fakebin composer sent vfile enters + dir="$TMP_ROOT/exit-picker" + fakebin=$(make_submit_mock "$dir") + composer="$dir/composer" + sent="$dir/sent.log" + vfile="$dir/verdict" + printf '%s\n' \ + 'Background work is running' \ + '❯ 1. Exit and stop tasks' \ + 'The following will stop when you exit:' \ + 'shell · sleep 300' \ + ' 2. Move to background and exit' \ + ' 3. Stay' \ + 'Enter to confirm · Esc to cancel' > "$composer" + : > "$sent" + touch "$dir/.swallow" + fm_composer_dialog_sink_prepare || fail "the dialog sink could not be prepared" + PATH="$fakebin:$PATH" FM_FAKE_COMPOSER="$composer" FM_FAKE_SENT="$sent" \ + FM_FAKE_SWALLOW="$dir/.swallow" FM_FAKE_PERSIST_SWALLOW=1 FM_FAKE_PANE_BUSY=0 \ + fm_tmux_submit_enter_core "win" 3 0 > "$vfile" 2>/dev/null + fm_composer_dialog_sink_release + [ "$(cat "$vfile")" = unknown ] || fail "exit picker should return unknown, got '$(cat "$vfile")'" + enters=$(grep -c '^Enter$' "$sent" || true) + [ "$enters" -eq 1 ] || fail "exit picker should get one Enter, got $enters" + pass "fm_tmux_submit_enter_core: the Claude background-task exit picker gets no confirming Enter" +} + +test_exit_picker_refuses_confirming_enter + +test_typed_submit_on_open_exit_picker_types_nothing() { + local dir fakebin composer sent before rc=0 err + # shellcheck source=/dev/null + . "$ROOT/bin/fm-backend.sh" + dir="$TMP_ROOT/typed-on-picker" + fakebin=$(make_submit_mock "$dir") + composer="$dir/composer" + sent="$dir/sent.log" + err="$dir/err" + printf '%s\n' \ + 'Background work is running' \ + '❯ 1. Exit and stop tasks' \ + 'The following will stop when you exit:' \ + 'shell · sleep 300' \ + ' 2. Move to background and exit' \ + ' 3. Stay' \ + 'Enter to confirm · Esc to cancel' > "$composer" + before=$(cat "$composer") + : > "$sent" + PATH="$fakebin:$PATH" FM_FAKE_COMPOSER="$composer" FM_FAKE_SENT="$sent" \ + fm_backend_send_text_submit tmux win 'please continue' 3 0 0 >"$dir/out" 2>"$err" || rc=$? + [ "$rc" -ne 0 ] || fail "a typed submit onto an open picker should refuse" + [ ! -s "$sent" ] || fail "a typed submit onto an open picker sent input: $(cat "$sent")" + [ "$(cat "$composer")" = "$before" ] || fail "a typed submit onto an open picker changed the pane" + grep -F 'blocked on a prompt: Claude background-task exit picker' "$err" >/dev/null \ + || fail "the refusal should name the picker, got '$(cat "$err")'" + pass "fm_backend_send_text_submit: a typed message to the exit picker types nothing and sends no Enter" +} + +test_typed_submit_on_open_exit_picker_types_nothing test_wrapped_continuation_retries_swallowed_enter test_placeholder_like_bare_input_retries_swallowed_enter test_busy_pane_composer_clears_first_try From 5838f105008cb24bc999a819d3c5b50c2fd5675d Mon Sep 17 00:00:00 2001 From: M00NLIG7 <57321738+M00NLIG7@users.noreply.github.com> Date: Mon, 5 Oct 2026 20:29:16 -0600 Subject: [PATCH 02/12] fix: restore timeout watchdog compatibility with macOS Bash 3.2 (#6028) * fix: support stock macOS Bash in timeout watchdog * no-mistakes(ci): Fixed ci-1 in tests/fm-timeout-lib.test.sh: both startup-owner failure paths now kill the bounded command group before killing the watchdog. Fault injection reproduced both leaks before the fix and confirmed cleanup afterward. Bash 3.2 suite, syntax check, ShellCheck, and diff checks passed; GNU timeout coverage skipped because the binary is unavailable. Production code unchanged --- bin/fm-timeout-lib.sh | 8 ++-- tests/fm-timeout-lib.test.sh | 77 ++++++++++++++++++++++++++++++++++-- 2 files changed, 78 insertions(+), 7 deletions(-) diff --git a/bin/fm-timeout-lib.sh b/bin/fm-timeout-lib.sh index a785ad8b793..18a793a87ac 100644 --- a/bin/fm-timeout-lib.sh +++ b/bin/fm-timeout-lib.sh @@ -221,11 +221,13 @@ fm_exec_timed() { # exit 125 fi owner=${FM_EXEC_TIMED_OWNER_PID:-$$} - [ "$owner" != "$BASHPID" ] || owner=$PPID unset FM_EXEC_TIMED_OWNER_PID if command -v perl >/dev/null 2>&1; then exec perl -MPOSIX=WNOHANG,setpgid -MTime::HiRes=time -e ' - my ($bound, $grace, $owner) = (shift, shift, shift); + my ($bound, $grace, $owner, $shell_parent) = (shift, shift, shift, shift); + # exec preserves the shell PID, including in Bash 3.2 subshells where + # BASHPID is unavailable. Keep the pre-exec parent for startup races. + $owner = $shell_parent if $owner == $$; my $parent = getppid(); my ($pid, $pending, $kill_at, $timed_out) = (0, "", 0, 0); for my $sig (qw(TERM INT HUP)) { @@ -272,7 +274,7 @@ fm_exec_timed() { # } select undef, undef, undef, 0.05; } - ' -- "$seconds" "$grace" "$owner" "$@" + ' -- "$seconds" "$grace" "$owner" "$PPID" "$@" elif command -v timeout >/dev/null 2>&1; then exec timeout -k "$grace" "$seconds" "$@" elif command -v gtimeout >/dev/null 2>&1; then diff --git a/tests/fm-timeout-lib.test.sh b/tests/fm-timeout-lib.test.sh index 56bcc6dfc5a..621367e558b 100755 --- a/tests/fm-timeout-lib.test.sh +++ b/tests/fm-timeout-lib.test.sh @@ -64,6 +64,35 @@ test_passes_the_command_status_and_output_through() { pass "fm_exec_timed passes a command's status and output through unchanged" } +# Exercise the installed system Bash explicitly, including stock macOS 3.2: +# neither a top-level call nor a subshell may abort before starting its command. +# Unset BASHPID so newer system Bash also covers the missing-variable case. +test_system_bash_preserves_completion_and_signal_statuses() { + local mode status command out rc + for mode in top-level subshell; do + for status in 0 7 137 143; do + case "$status" in + 137) command='echo ran; kill -KILL $$' ;; + 143) command='echo ran; kill -TERM $$' ;; + *) command="echo ran; exit $status" ;; + esac + rc=0 + out=$(PATH=$PERL_ONLY /bin/bash -c ' + . "$1/bin/fm-timeout-lib.sh" + unset BASHPID + if [ "$2" = subshell ]; then + ( fm_exec_timed 5 1 bash -c "$3" ) + else + fm_exec_timed 5 1 bash -c "$3" + fi + ' _ "$ROOT" "$mode" "$command" 2>&1) || rc=$? + [ "$rc" -eq "$status" ] || fail "system Bash $mode lost command status $status (rc=$rc: $out)" + [ "$out" = ran ] || fail "system Bash $mode did not run the command cleanly: $out" + done + done + pass "system Bash top-level and subshell calls preserve success, failure, and signal status" +} + # A command that honors TERM ends at the bound, long before the grace would # have forced it, and is gone afterwards. test_term_ends_a_cooperative_command_at_the_bound() { @@ -109,7 +138,7 @@ test_the_bound_replaces_the_calling_shell() { rm -f "$dir/caller" "$dir/parent" ( . "$ROOT/bin/fm-timeout-lib.sh" - printf '%s\n' "$BASHPID" > "$dir/caller" + perl -e 'print getppid(), "\n"' > "$dir/caller" PATH=$path fm_exec_timed 5 1 bash -c 'echo "$PPID" > "$1"' _ "$dir/parent" ) || fail "the bounded probe failed under PATH=$path" caller=$(cat "$dir/caller") @@ -204,16 +233,16 @@ test_a_named_owner_that_is_gone_ends_the_command() { # fm_exec_timed - the watchdog then starts already reparented - is still # detected instead of leaving the command running to its bound. test_an_owner_that_dies_during_startup_ends_the_command() { - local dir watchdog started + local dir watchdog started pid dir="$TMP_ROOT/startup-owner" mkdir -p "$dir" # shellcheck disable=SC2016 PATH=$PERL_ONLY bash -c ' . "$1/bin/fm-timeout-lib.sh" ( - echo "$BASHPID" > "$2/watchdog" + perl -e "print getppid(), qq(\\n)" > "$2/watchdog" while kill -0 "$$" 2>/dev/null; do sleep 0.05; done - fm_exec_timed 60 1 bash -c "exec sleep 300" + fm_exec_timed 60 1 bash -c "echo \$\$ > \"\$1\"; exec sleep 300" _ "$2/pid" ) >/dev/null 2>&1 & exit 0 ' _ "$ROOT" "$dir" @@ -222,6 +251,10 @@ test_an_owner_that_dies_during_startup_ends_the_command() { started=$SECONDS while kill -0 "$watchdog" 2>/dev/null; do if [ "$((SECONDS - started))" -ge 15 ]; then + if [ -s "$dir/pid" ]; then + pid=$(cat "$dir/pid") + kill -KILL -- "-$pid" 2>/dev/null || true + fi kill -KILL "$watchdog" 2>/dev/null || true fail "a watchdog whose owner died during startup ran on toward its bound" fi @@ -230,6 +263,40 @@ test_an_owner_that_dies_during_startup_ends_the_command() { pass "fm_exec_timed ends the command when its owner dies during watchdog startup" } +# A top-level calling shell has its own PID in $$, unlike a Bash subshell. +# Capture its parent before exec: that parent can exit while the top-level +# shell is still on its way into the watchdog. +test_a_top_level_parent_that_dies_during_startup_ends_the_command() { + local dir watchdog started pid + dir="$TMP_ROOT/top-level-parent" + mkdir -p "$dir" + PATH=$PERL_ONLY bash -c ' + bash -c '\'' + . "$1/bin/fm-timeout-lib.sh" + echo "$$" > "$2/watchdog" + while kill -0 "$PPID" 2>/dev/null; do sleep 0.05; done + fm_exec_timed 60 1 bash -c "echo \$\$ > \"\$1\"; exec sleep 300" _ "$2/pid" + '\'' _ "$1" "$2" >/dev/null 2>&1 & + while [ ! -s "$2/watchdog" ]; do sleep 0.02; done + exit 0 + ' _ "$ROOT" "$dir" + wait_for_file "$dir/watchdog" + watchdog=$(cat "$dir/watchdog") + started=$SECONDS + while kill -0 "$watchdog" 2>/dev/null; do + if [ "$((SECONDS - started))" -ge 15 ]; then + if [ -s "$dir/pid" ]; then + pid=$(cat "$dir/pid") + kill -KILL -- "-$pid" 2>/dev/null || true + fi + kill -KILL "$watchdog" 2>/dev/null || true + fail "top-level watchdog lost its pre-exec parent and ran toward its bound" + fi + sleep 0.02 + done + pass "fm_exec_timed preserves a top-level shell's parent across exec startup" +} + # perl is preferred whenever it exists, because only its watchdog can reap a # leftover descendant after replacing the caller. test_perl_is_preferred_over_timeout() { @@ -328,6 +395,7 @@ test_run_timed_passes_a_natural_exit_through_a_fired_bound() { } test_passes_the_command_status_and_output_through +test_system_bash_preserves_completion_and_signal_statuses test_run_timed_reports_the_bound_when_the_wrapper_records_a_signal_death test_run_timed_passes_a_natural_exit_through_a_fired_bound test_term_ends_a_cooperative_command_at_the_bound @@ -337,6 +405,7 @@ test_a_descendant_holding_the_output_cannot_outlast_the_bound test_a_signal_to_the_bounding_process_reaches_the_command test_a_named_owner_that_is_gone_ends_the_command test_an_owner_that_dies_during_startup_ends_the_command +test_a_top_level_parent_that_dies_during_startup_ends_the_command test_perl_is_preferred_over_timeout test_refuses_rather_than_running_unbounded test_rejects_malformed_bounds_before_running_anything From e06a46fec726071618da0460f0de552d4d078ef1 Mon Sep 17 00:00:00 2001 From: Symphony Date: Tue, 6 Oct 2026 21:56:41 +0700 Subject: [PATCH 03/12] fix(project-management): use subshell form for Initialize command (#6699) Wrap the cd command in a subshell to comply with the cd-guard policy that blocks persistent top-level directory changes in the primary firstmate checkout. The subshell form (cd projects/ && ...) is accepted by the policy as documented in issue #6502. Fixes #6502 --- .agents/skills/project-management/SKILL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.agents/skills/project-management/SKILL.md b/.agents/skills/project-management/SKILL.md index d68d5195330..6c39cc871ed 100644 --- a/.agents/skills/project-management/SKILL.md +++ b/.agents/skills/project-management/SKILL.md @@ -84,7 +84,7 @@ The captain's request to create that local project authorizes this local initial Run no-mistakes initialization only for `no-mistakes` and `no-mistakes-prod-only` projects: ```sh -cd projects/ && no-mistakes init && no-mistakes doctor +(cd projects/ && no-mistakes init && no-mistakes doctor) ``` Initialization configures the local gate and does not vendor a no-mistakes skill into the project. From 99da16d954ccee7e0dbf594d88707e6871fd0929 Mon Sep 17 00:00:00 2001 From: Freudator86 <94322668+Freudator86@users.noreply.github.com> Date: Wed, 7 Oct 2026 00:36:09 +0200 Subject: [PATCH 04/12] feat(bin): add armable daily startup growth check (#6725) * Add daily startup growth check * no-mistakes(review): watch printed startup memory, retain growth baselines, drop knobs * no-mistakes(review): delegate budget verdict, report before publish, pin shim home * no-mistakes(review): baseline first sightings silently, drop mtime, spare secondmates * no-mistakes(review): cap the wake line, validate budget verdict fields * no-mistakes(review): guard record schema, check appends, tighten assertions * no-mistakes(review): drop arbitrary tracked library, make re-arm idempotent * no-mistakes(review): correct tracked-set wording, restore shim guards, register artifacts * no-mistakes(review): exit on signal instead of publishing partial record * no-mistakes(document): correct startup-growth record removal cost in state registry * no-mistakes(test): sweep orphaned empty startup-growth temp records on due evaluation * no-mistakes(document): note watcher need for armed startup growth check * no-mistakes(ci): Diagnosed "Behavior portable serial 5": the shard failed on tests/fm-contributions.test.sh in test_arm_plumbs_a_configured_budget_into_the_check_shim (inherited mode) with "generated check did not attempt a read" and a missing forge/calls file. Root cause: bin/fm-contributions.sh:379-382 computes DEADLINE=$(date +%s)+BUDGET and then gates each read on DEADLINE-$(date +%s) >= OBSERVATION_RESERVE (= min(BUDGET,15)). At the one-second budget this test uses, that gate demands zero elapsed whole seconds, so a wall-clock second boundary crossing between the two date calls makes the poll loop break before any forge read. The test calls wrap_forge (which installs a fake date reading $FORGE/clock only when that file exists) but never seeded forge/clock, so it ran against the real clock. The repo already documents this exact hazard at tests/fm-contributions.test.sh:675-677 and freezes the clock in every other budget-constrained test: test_budget_exhaustion_keeps_prior_record (both modes), test_genuine_failure_near_deadline_is_unavailable, test_reservation_defers_later_url_when_fifteen_seconds_do_not_remain, test_slow_read_deadline_kill_is_budget_refusal, test_budget_is_cut_down_to_the_watcher_check_bound. test_arm_plumbs was the only omission; its single loop body covers both the configured and inherited modes. The remaining wrap_forge users run the default 20-second budget (5 seconds of slack above the reserve) and are not reachable by this race. Fix (smallest, matching the sibling idiom): added the clock freeze `/bin/date +%s > "$home/forge/clock"` plus a two-line comment inside that test's loop, before the poll. Three added lines in tests/fm-contributions.test.sh; no production code and no other file touched. Verification: reproduced the exact CI failure message by running a scratch copy whose fake date advances one second per read (worst case of the unfrozen clock) - missing forge/calls, "not ok - generated check did not attempt a read". With the fix, the single test passes 10/10 standalone and the full tests/fm-contributions.test.sh passes all 46 assertions with exit 0. tests/fm-startup-growth-check.test.sh still exits 0. bash -n and shellcheck -S warning are clean on the edited file; scratch repro files were removed, leaving only the intended three-line change. Note for the author: this flake is not caused by this branch - tests/fm-contributions.test.sh is untouched by the PR and the race is pre-existing, surfacing only on a loaded runner (the shard's neighbouring assertions show 80+ second gaps). It was fixed because it is genuine nondeterminism with an established in-repo remedy and the check cannot otherwise go green. No work was done on the unselected greptile findings (ci-2..ci-5) or the cancelled "Behavior portable serial 2" check (ci-6) * no-mistakes(ci): Diagnosed "Behavior portable serial 6": the omitted log section (fetched with gh api) shows the shard failed on tests/fm-calm-pi-extension.test.sh in test_hidden_block_geometry_e2e with "Pi Calm hidden-block geometry E2E did not complete the /reload viewport transition" (exit=1, duration_ms=23437; the family line pure-contract-unit count=3 duration_ms=51193 failed=1 matches devin-harness 3203 + calm-pi 23437 + task-delivery 24553). Root cause: tests/fm-calm-pi-extension.test.sh:2831 wait_for_geometry_transition detected the /reload by SAMPLING a single intermediate frame - it polled tmux capture-pane for Pi's transient "Reloading keybindings, extensions, skills, prompts, themes, and context files..." box and only accepted the rebuilt transcript afterwards (elif gated on saw_transient). Pi shows that box only while the reload runs and replaces it via dismissReloadBox when done, so on a loaded runner the box can live entirely between two polls; saw_transient then stays 0 forever and the 600-attempt wait times out although the reload fully succeeded. Reproduced locally under CPU oversubscription with CI's Pi 1.0.4: 1 failure in 8 runs, diagnostics proving the mechanism ("DIAG: transition timeout saw_transient=0 attempt=600") while the captured viewport already showed the completed reload status row and the intact collapsed transcript. Not a Pi-version regression: 1.0.4's handleReloadCommand and both banner strings are identical to the installed 0.85.1, and shard 6 of the previous pipeline run (job 112490351105) passed this same script on Pi 1.0.4. Invariant violated: a TUI E2E assertion must key off a durable state the UI retains, never off one intermediate frame polling may miss. Sibling enumeration: the helper was defined once and called once; grep for transient/saw_/two-stage waits across tests/, bin/ and .pi/ found no other one-shot-frame wait - every other wait in the file keys off durable text presence/absence or off continuously repeating animation frames (the working-ship motion checks) where sampling eventually connects; no doc or other test referenced the helper. Fix (smallest, one helper): renamed it wait_for_geometry_reload and made it wait for the durable status row Pi appends once the reload completed and the chat was rebuilt ("Reloaded keybindings, extensions, skills, prompts, themes, and context files", a persistent chat child via showStatus, present in both 0.85.1 and 1.0.4) together with CALM_GEOMETRY_FINAL. That marker is absent before the reload and never appears if the reload throws, so the check is strictly stronger than before (a failed reload now fails the test instead of passing on transient-then-final). One file changed: tests/fm-calm-pi-extension.test.sh, 2 hunks, no production code. Verification: deterministic before/after - with polls spaced 0.4s apart (the loaded-runner worst case) the pre-fix helper fails with exactly the CI message and the post-fix helper passes; 12/12 consecutive passes of the isolated test under load with Pi 1.0.4; full file 15/15 ok exit 0 under LANG=C.utf8 (matching the runner locale); bash -n, shellcheck -S warning and bin/fm-lint.sh clean; scratch repro files and the temp Pi 1.0.4 install removed, git status shows only the one intended modification. Notes: (1) the flake is not caused by this branch - that test file is untouched by the PR and the race is pre-existing; fixed because it is genuine nondeterminism and the check cannot otherwise go green. (2) Under far harsher load than CI applies a separate unrelated flake appeared in the same test: Pi's "Warning: tmux extended-keys is off..." row occasionally lands between the collapsed [skill] ahoy row and the final response, making assert_geometry_gap see 5 rows instead of 2. Different cause, never seen in CI, and its plausible remedies would change key delivery for the other TUI tests in the file, so it was left alone rather than growing this fix --------- Co-authored-by: Quartermaster --- .../skills/operational-home-layout/SKILL.md | 1 + bin/fm-startup-growth-check.sh | 417 ++++++++++++ docs/configuration.md | 22 + docs/scripts.md | 1 + tests/fm-calm-pi-extension.test.sh | 18 +- tests/fm-contributions.test.sh | 3 + tests/fm-startup-growth-check.test.sh | 629 ++++++++++++++++++ 7 files changed, 1084 insertions(+), 7 deletions(-) create mode 100755 bin/fm-startup-growth-check.sh create mode 100755 tests/fm-startup-growth-check.test.sh diff --git a/.agents/skills/operational-home-layout/SKILL.md b/.agents/skills/operational-home-layout/SKILL.md index 82fa94fc061..2786265f63b 100644 --- a/.agents/skills/operational-home-layout/SKILL.md +++ b/.agents/skills/operational-home-layout/SKILL.md @@ -91,6 +91,7 @@ state/ runtime records and signals; gitignored tool-updates.check.sh generated watched-tool update poll shim and its .check-trust binding; present only after bin/fm-tool-update-check.sh arm; its report record .tool-updates is what keeps one pending update from being reported on every poll mail.check.sh generated received-mail poll shim and its .check-trust binding; present only after bin/fm-mail-check.sh arm; report record .mail-check (mail schema: docs/configuration.md "Mail plane") .mail-seen .mail-woken .mail-retry .mail-retry-pos .mail-turn .mail-seen.lock mail-plane poll cursor, emission journal, transient-fetch retry set, retry-scan position, contended-slot turn flag, and overlapping-poll lock; written only by bin/fm-mail.sh (mail schema: docs/configuration.md "Mail plane") + startup-growth.check.sh generated daily startup-growth poll shim and its .check-trust binding; present only after bin/fm-startup-growth-check.sh arm; its record .startup-growth-check holds the daily gate, the per-file growth baselines, and the last reported finding set, so removing it re-baselines growth silently and repeats a standing finding such as a budget overrun once (docs/configuration.md "Daily startup growth check") pending-replies/ parent-owned secondmate pending-reply records (correlation id, delivery vs reply, recovery, escalation); fm-pending-reply-lib.sh procevent/ registered process-to-event sources, one private record per canonical source id; written only by bin/fm-procevent.sh, and their presence alone keeps supervision required (`process-event-sources` skill) procevent-inbox/ private captured results and their durable handled-acknowledgement markers; source output lives here and never in an event line diff --git a/bin/fm-startup-growth-check.sh b/bin/fm-startup-growth-check.sh new file mode 100755 index 00000000000..3543666d727 --- /dev/null +++ b/bin/fm-startup-growth-check.sh @@ -0,0 +1,417 @@ +#!/usr/bin/env bash +# fm-startup-growth-check.sh - daily cheap growth check for startup memory and instruction surfaces. +# +# Usage: +# fm-startup-growth-check.sh [check] +# fm-startup-growth-check.sh arm +# fm-startup-growth-check.sh disarm +# fm-startup-growth-check.sh --help +# +# `check` evaluates at most once every 86400 seconds, one daily evaluation. +# Polls inside that interval only read this check's small state record and stay +# silent. +# +# A due evaluation uses metadata only: regular-file safety checks plus stat(1) +# byte sizes. It does not run the startup digest, bootstrap, network checks, +# model calls, repository refreshes, /stow, or full preference/learning +# rereads. The budget total, its verdict, and its secondmate exception come +# from `bin/fm-startup-memory-budget.sh report`, the single owner of +# config/startup-memory-budget, and are never re-derived here. data/projects.md +# and data/secondmates.md are printed in full by every session start too, so +# they are watched for prompt growth without entering that budget total. +# The tracked set is the startup entrypoints session start executes directly +# plus the agent instruction files, not every script and library the startup +# path reaches; those bytes are code/instruction size, not LLM prompt cost. +# +# A secondmate home is never notified about the primary-owned +# data/captain-shared.md it cannot edit: the owner suppresses the budget overrun +# it causes alone, and this check suppresses its per-file growth there while +# still recording the observation. +# +# Growth is measured against a retained per-file baseline rather than only +# against the previous evaluation, so accumulation that stays under one day's +# threshold is still caught. A surface seen for the first time is baselined +# silently, including the first content of an optional file that was absent when +# the check started; an established baseline survives the file disappearing and +# coming back. Reporting a file rebases its baseline to the reported size, so +# accepted growth then stays silent. The thresholds are fixed: +# 2048 bytes for tracked startup/instruction files +# 250 estimated tokens, ceil(bytes / 3), for printed startup memory files +# Budget overrun is always meaningful. +# +# A due evaluation also removes the empty temporary records a killed +# evaluation can leave in state/: only files matching its own mint pattern +# that are empty and untouched for an hour, never a record with bytes in it. +# +# `arm` writes state/startup-growth.check.sh and binds its bytes with +# fm-check-register.sh so the existing watcher slow-check cadence invokes the +# daily gate. `disarm` removes the shim, trust binding, and report record. +set -u +export LC_ALL=C + +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}" +CONFIG_DIR="${FM_CONFIG_OVERRIDE:-$FM_HOME/config}" +DATA_DIR="${FM_DATA_OVERRIDE:-$FM_HOME/data}" +CHECK_ID=startup-growth +CHECK_SHIM="$STATE/$CHECK_ID.check.sh" +CHECK_TRUST="$STATE/$CHECK_ID.check-trust" +RECORD="$STATE/.startup-growth-check" +RECORD_SCHEMA_LINE=$'schema\tfm-startup-growth-check-v1' +REGISTER_BIN="$SCRIPT_DIR/fm-check-register.sh" +BUDGET_BIN="$SCRIPT_DIR/fm-startup-memory-budget.sh" + +# shellcheck source=bin/fm-pr-lib.sh +. "$SCRIPT_DIR/fm-pr-lib.sh" +# shellcheck source=bin/fm-startup-memory-budget-lib.sh +. "$SCRIPT_DIR/fm-startup-memory-budget-lib.sh" +# shellcheck source=bin/fm-line-cap-lib.sh +. "$SCRIPT_DIR/fm-line-cap-lib.sh" +# shellcheck source=bin/fm-check-lib.sh +. "$SCRIPT_DIR/fm-check-lib.sh" + +usage() { + sed -n '2,48{s/^# \{0,1\}//;p;}' "$0" +} + +fail() { + printf 'fm-startup-growth-check: %s\n' "$1" >&2 + exit 1 +} + +now_epoch() { + case "${FM_STARTUP_GROWTH_NOW:-}" in + ''|*[!0-9]*) date +%s ;; + *) printf '%s\n' "$FM_STARTUP_GROWTH_NOW" ;; + esac +} + +INTERVAL=86400 +BYTE_THRESHOLD=2048 +TOKEN_THRESHOLD=250 +MAX_LINE=1000 +ORPHAN_GRACE=3600 +ORPHAN_SWEEP_LIMIT=64 +PRIMARY_OWNED_MEMORY= +if [ -e "$FM_HOME/.fm-secondmate-home" ] || [ -L "$FM_HOME/.fm-secondmate-home" ]; then + PRIMARY_OWNED_MEMORY=data/captain-shared.md +fi + +file_size() { + if [ "$(uname)" = Darwin ]; then + /usr/bin/stat -f %z "$1" 2>/dev/null + else + stat -c %s "$1" 2>/dev/null + fi +} + +file_mtime() { + if [ "$(uname)" = Darwin ]; then + /usr/bin/stat -f %m "$1" 2>/dev/null + else + stat -c %Y "$1" 2>/dev/null + fi +} + +# A kill landing between mktemp(1) and the traps that own the temporary record +# leaves an empty scratch file nothing else would ever remove. A due +# evaluation sweeps those, bounded on every axis: only the mint pattern, only +# empty regular files, only ones untouched for ORPHAN_GRACE seconds, and at +# most ORPHAN_SWEEP_LIMIT per evaluation. A concurrent evaluation's live +# scratch is minutes younger than that grace, and a scratch carrying any +# record bytes is never a candidate, so neither published baselines nor work in +# flight can be removed here. +sweep_orphan_records() { # + local now=$1 scratch mtime swept=0 + for scratch in "$STATE"/.startup-growth-check.??????; do + [ "$swept" -lt "$ORPHAN_SWEEP_LIMIT" ] || break + [ -f "$scratch" ] && [ ! -L "$scratch" ] && [ ! -s "$scratch" ] || continue + mtime=$(file_mtime "$scratch") || continue + case "$mtime" in ''|*[!0-9]*) continue ;; esac + [ $((now - mtime)) -ge "$ORPHAN_GRACE" ] || continue + rm -f -- "$scratch" || true + swept=$((swept + 1)) + done +} + +append_finding() { + if [ -z "$FINDINGS" ]; then + FINDINGS=$1 + else + FINDINGS="$FINDINGS; $1" + fi +} + +stat_surface() { # + local kind=$1 display=$2 path=$3 absence_ok=$4 bytes tokens prev_baseline baseline delta presence=present + if [ ! -e "$path" ] && [ ! -L "$path" ]; then + bytes=0 + presence=absent + [ "$absence_ok" = yes ] || append_finding "missing $kind $display" + elif [ -L "$path" ] || [ ! -f "$path" ]; then + bytes=0 + presence=unsafe + append_finding "unsafe $kind $display" + else + bytes=$(file_size "$path") || true + case "$bytes" in + ''|*[!0-9]*) + bytes=0 + presence=unreadable + append_finding "unreadable $kind $display" + ;; + esac + fi + + prev_baseline=$(awk -F '\t' -v p="$display" '$1 == p { print $5; found=1; exit } END { if (!found) print "" }' "$OLD_RECORD" 2>/dev/null || true) + case "$prev_baseline" in + ''|*[!0-9]*) prev_baseline= ;; + esac + + if [ "$presence" != present ]; then + baseline=${prev_baseline:--} + elif [ -z "$prev_baseline" ] || [ "$bytes" -le "$prev_baseline" ]; then + baseline=$bytes + else + baseline=$prev_baseline + delta=$((bytes - baseline)) + case "$kind" in + memory|printed-memory) + tokens=$(fm_startup_memory_estimated_tokens_for_bytes "$delta") || tokens=0 + if [ "$tokens" -ge "$TOKEN_THRESHOLD" ]; then + baseline=$bytes + [ "$display" = "$PRIMARY_OWNED_MEMORY" ] \ + || append_finding "$kind growth $display +${tokens} estimated_tokens (+${delta} bytes, total ${bytes} bytes)" + fi + ;; + tracked) + if [ "$delta" -ge "$BYTE_THRESHOLD" ]; then + append_finding "tracked startup surface growth $display +${delta} bytes (total ${bytes} bytes)" + baseline=$bytes + fi + ;; + esac + fi + + printf '%s\t%s\t%s\t%s\t%s\n' "$display" "$kind" "$presence" "$bytes" "$baseline" >> "$NEW_RECORD" || exit 1 +} + +write_record_atomically() { + local tmp=$1 dest=$2 state_device + [ -d "$STATE" ] && [ ! -L "$STATE" ] || return 1 + state_device=$(fm_pr_file_device "$STATE") || return 1 + fm_pr_regular_destination_on_device_or_absent "$dest" "$state_device" || return 1 + mv -f -- "$tmp" "$dest" +} + +record_usable() { + local line + [ -f "$RECORD" ] && [ ! -L "$RECORD" ] || return 1 + IFS= read -r line < "$RECORD" || return 1 + [ "$line" = "$RECORD_SCHEMA_LINE" ] +} + +read_last_eval() { + record_usable || return 0 + awk -F '\t' '$1 == "last_eval" { print $2; exit }' "$RECORD" 2>/dev/null || true +} + +check_due() { + local now last age + now=$(now_epoch) + last=$(read_last_eval) + case "$last" in + ''|*[!0-9]*) printf '%s\n' "$now"; return 0 ;; + esac + age=$((now - last)) + if [ "$age" -lt 0 ] || [ "$age" -ge "$INTERVAL" ]; then + printf '%s\n' "$now" + return 0 + fi + return 1 +} + +evaluate_budget() { + local report line reason valid=yes budget='' total='' status='' exception='' + if ! report=$(FM_HOME="$FM_HOME" FM_CONFIG_OVERRIDE="$CONFIG_DIR" FM_DATA_OVERRIDE="$DATA_DIR" \ + "$BUDGET_BIN" report 2>&1); then + reason=${report##*startup-memory-budget: } + append_finding "startup memory budget unavailable owner=bin/fm-startup-memory-budget.sh reason=${reason//$'\n'/ }" + return 0 + fi + while IFS= read -r line; do + case "$line" in + effective_budget_tokens=*) budget=${line#*=} ;; + total_estimated_tokens=*) total=${line#*=} ;; + budget_status=*) status=${line#*=} ;; + exception=*) exception=${line#*=} ;; + esac + done < <(printf '%s\n' "$report") + case "$budget:$total" in + *[!0-9:]*|:*|*:) valid=no ;; + esac + case "$status" in + within-budget|over-budget) ;; + *) valid=no ;; + esac + case "$exception" in + ''|primary-owned-shared-file-alone-exceeds-budget) ;; + *) valid=no ;; + esac + if [ "$valid" = no ]; then + append_finding "startup memory budget unavailable owner=bin/fm-startup-memory-budget.sh reason=unparseable report" + return 0 + fi + printf '%s\t%s\t%s\t%s\t%s\n' memory_budget "$budget" "$total" "$status" "$exception" >> "$NEW_RECORD" || exit 1 + [ "$status" = over-budget ] && [ -z "$exception" ] || return 0 + append_finding "startup memory budget overrun total_estimated_tokens=$total budget=$budget owner=bin/fm-startup-memory-budget.sh" +} + +run_check() { + local now reported_previous + if ! now=$(check_due); then + return 0 + fi + [ -d "$STATE" ] && [ ! -L "$STATE" ] || fail "state directory is unavailable" + sweep_orphan_records "$now" + OLD_RECORD=$RECORD + record_usable || OLD_RECORD=/dev/null + reported_previous=$(awk -F '\t' '$1 == "reported" { print substr($0, index($0, "\t") + 1); exit }' "$OLD_RECORD" 2>/dev/null || true) + NEW_RECORD=$(mktemp "$STATE/.startup-growth-check.XXXXXX") || exit 1 + trap 'rm -f -- "${NEW_RECORD:-}"' EXIT + trap 'rm -f -- "${NEW_RECORD:-}"; exit 1' HUP INT TERM + FINDINGS= + printf '%s\n' "$RECORD_SCHEMA_LINE" > "$NEW_RECORD" || exit 1 + printf '%s\t%s\n' last_eval "$now" >> "$NEW_RECORD" || exit 1 + + stat_surface tracked AGENTS.md "$FM_ROOT/AGENTS.md" no + stat_surface tracked CLAUDE.md "$FM_ROOT/CLAUDE.md" yes + stat_surface tracked bin/fm-session-start.sh "$FM_ROOT/bin/fm-session-start.sh" no + stat_surface tracked bin/fm-bootstrap.sh "$FM_ROOT/bin/fm-bootstrap.sh" no + stat_surface tracked bin/fm-supervision-instructions.sh "$FM_ROOT/bin/fm-supervision-instructions.sh" no + stat_surface printed-memory data/projects.md "$DATA_DIR/projects.md" yes + stat_surface printed-memory data/secondmates.md "$DATA_DIR/secondmates.md" yes + stat_surface memory data/captain.md "$DATA_DIR/captain.md" yes + stat_surface memory data/captain-shared.md "$DATA_DIR/captain-shared.md" yes + stat_surface memory data/learnings.md "$DATA_DIR/learnings.md" yes + + evaluate_budget + + if [ -n "$FINDINGS" ]; then + if [ "$FINDINGS" != "$reported_previous" ]; then + fm_cap_line "startup-growth: $FINDINGS" "$MAX_LINE" + fi + printf '%s\t%s\n' reported "$FINDINGS" >> "$NEW_RECORD" || exit 1 + fi + write_record_atomically "$NEW_RECORD" "$RECORD" || fail "could not publish report record" + NEW_RECORD= +} + +SHIM_TMP= +ARM_BACKUP= + +shim_write() { # + local want=$1 device=$2 + fm_pr_regular_destination_on_device_or_absent "$CHECK_SHIM" "$device" || return 1 + if [ -e "$CHECK_SHIM" ] && [ "$(fm_pr_file_mode "$CHECK_SHIM")" = 700 ] \ + && [ "$(cat "$CHECK_SHIM" 2>/dev/null)" = "$want" ]; then + return 0 + fi + SHIM_TMP=$(umask 077; mktemp "$STATE/.startup-growth-check-shim.XXXXXX" 2>/dev/null) || return 1 + if ! printf '%s\n' "$want" > "$SHIM_TMP" \ + || ! chmod 0700 "$SHIM_TMP" \ + || ! fm_pr_private_file_valid "$SHIM_TMP" 700 "$device" \ + || ! fm_pr_regular_destination_on_device_or_absent "$CHECK_SHIM" "$device" \ + || ! mv -f -- "$SHIM_TMP" "$CHECK_SHIM"; then + rm -f -- "$SHIM_TMP" + SHIM_TMP= + return 1 + fi + SHIM_TMP= + fm_pr_private_file_valid "$CHECK_SHIM" 700 "$device" +} + +shim_backup() { # + local device=$1 tmp + tmp=$(umask 077; mktemp "$STATE/.startup-growth-check-shim.XXXXXX" 2>/dev/null) || return 1 + if ! cat "$CHECK_SHIM" > "$tmp" 2>/dev/null \ + || ! chmod 0700 "$tmp" \ + || ! fm_pr_private_file_valid "$tmp" 700 "$device"; then + rm -f -- "$tmp" + return 1 + fi + printf '%s\n' "$tmp" +} + +arm_rollback() { + [ -z "$SHIM_TMP" ] || rm -f -- "$SHIM_TMP" + SHIM_TMP= + if [ -n "$ARM_BACKUP" ]; then + mv -f -- "$ARM_BACKUP" "$CHECK_SHIM" 2>/dev/null || rm -f -- "$ARM_BACKUP" + ARM_BACKUP= + if fm_custom_check_registered "$STATE" "$CHECK_ID"; then + return 0 + fi + fi + rm -f -- "$CHECK_SHIM" "$CHECK_TRUST" +} + +arm_failed() { # + trap - HUP INT TERM + arm_rollback + fail "$1" +} + +arm() { + local state_device home want + [ -d "$STATE" ] && [ ! -L "$STATE" ] || fail "state directory is unavailable" + case "$FM_HOME" in + /*) home=$FM_HOME ;; + *) home=$(CDPATH='' cd -- "$FM_HOME" 2>/dev/null && pwd -P) || fail "cannot resolve FM_HOME $FM_HOME" ;; + esac + state_device=$(fm_pr_file_device "$STATE") || fail "state directory is unavailable" + want=$(printf '%s\n' \ + '#!/usr/bin/env bash' \ + "export FM_HOME=$(printf '%q' "$home")" \ + "exec $(printf '%q' "$SCRIPT_DIR/fm-startup-growth-check.sh") check") + ARM_BACKUP= + if [ -f "$CHECK_SHIM" ] && [ ! -L "$CHECK_SHIM" ]; then + ARM_BACKUP=$(shim_backup "$state_device") || fail "could not save the existing check shim" + fi + trap 'arm_failed "arming was interrupted"' HUP INT TERM + shim_write "$want" "$state_device" || arm_failed "check shim path is unavailable" + FM_HOME="$home" "$REGISTER_BIN" "$CHECK_ID" >/dev/null || arm_failed "could not register the check shim" + trap - HUP INT TERM + [ -z "$ARM_BACKUP" ] || rm -f -- "$ARM_BACKUP" + ARM_BACKUP= + printf 'armed: state/%s.check.sh\n' "$CHECK_ID" +} + +disarm() { + rm -f -- "$CHECK_SHIM" "$CHECK_TRUST" "$RECORD" + printf 'disarmed: state/%s.check.sh\n' "$CHECK_ID" +} + +case "${1:-check}" in + check) + [ "$#" -le 1 ] || { usage >&2; exit 2; } + run_check + ;; + arm) + [ "$#" -eq 1 ] || { usage >&2; exit 2; } + arm + ;; + disarm) + [ "$#" -eq 1 ] || { usage >&2; exit 2; } + disarm + ;; + -h|--help|help) + usage + ;; + *) + usage >&2 + exit 2 + ;; +esac diff --git a/docs/configuration.md b/docs/configuration.md index b37cc82c2aa..63695826c71 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -667,6 +667,28 @@ The internal [`/stow` skill](../.agents/skills/stow/SKILL.md) owns curation and The helper's header owns exact parsing, publication, and report output mechanics. +### Daily startup growth check + +A home can arm a lightweight daily growth monitor with `bin/fm-startup-growth-check.sh arm`. +It writes `state/startup-growth.check.sh` and binds it through the existing authenticated watcher-check mechanism, so no extra daemon or scheduler is installed. +Registering it is a reason to watch on the same terms as the [watched-tool check](#watched-tool-updates-configwatched-toolsjson), so an armed home keeps needing a watcher after its last task is torn down. +Use `bin/fm-startup-growth-check.sh disarm` to remove the check and its local report record. + +The check evaluates at most once per day and stays silent when nothing meaningful changed. +A due evaluation uses file metadata and byte sizes before any content inspection: it asks `bin/fm-startup-memory-budget.sh report` for the budget verdict over `data/captain.md`, `data/captain-shared.md`, and `data/learnings.md`, watches the `data/projects.md` and `data/secondmates.md` that session start also prints in full for growth without entering that budget total, and separately watches the tracked startup/instruction owner files described by the script header. +`bin/fm-startup-memory-budget.sh` remains the sole owner of the budget total and its verdict, so the check never re-derives either: when that owner annotates an overrun caused by the primary-owned `data/captain-shared.md` alone, a secondmate home is not woken about an overrun it cannot act on. +A secondmate home is likewise not notified about per-file growth of that same primary-owned `data/captain-shared.md`, which it receives read-only; the growth is still observed and recorded, and a primary home reports it normally. +Those tracked bytes are code and instruction-surface size, not prompt-memory cost. +The check does not run session-start, bootstrap, network checks, model calls, repository refreshes, `/stow`, or full preference/learnings rereads. + +Growth is measured against a per-file baseline retained in the check's own state record, so accumulation that stays under one day's threshold is still caught once it adds up; reporting a file rebases its baseline to the reported size, so accepted growth then stays silent. +A surface observed for the first time is baselined silently, including the first content of an optional file that did not exist yet when the check was armed, and an established baseline survives that file disappearing and coming back. +The fixed growth thresholds are inspectable in the script header: 2048 bytes for tracked startup/instruction files and 250 estimated tokens for the printed startup-memory files. +Budget overrun, unsafe or unreadable inputs, missing required tracked owner files, or material growth are reported once and deduplicated until the finding changes or clears; the report line is delivered before the check advances its own record, so a state-publication failure can repeat a finding but never swallow one. +That one line goes out through the shared per-line digest cut, so an over-long finding set carries the repo's `[truncated]` marker instead of ending mid-finding, while deduplication keeps comparing the full uncapped set. +Older bulk learning files remain reference-only; this monitor neither loads nor merges them. +A reported review need is only a recommendation, not cleanup authority. + ## Stow pass horizon (config/stow-pass-horizon) `config/stow-pass-horizon` is an optional local, gitignored presence flag that opts this home in to the pass-count decay horizon in the internal [`/stow` skill](../.agents/skills/stow/SKILL.md). diff --git a/docs/scripts.md b/docs/scripts.md index 3e2f475c830..e6e8da4bb6d 100644 --- a/docs/scripts.md +++ b/docs/scripts.md @@ -135,6 +135,7 @@ The shared no-mistakes gate lifecycle boundary is summarized in [architecture.md | `fm-check-unregister.sh` | Retire a custom watcher check and its trust binding by validated task id | | `fm-check-lib.sh` | Validate custom-check registrations and prepare private execution snapshots | | `fm-tool-update-check.sh` | Report watched tooling with an update available, and updates installed but left inert by PATH order | +| `fm-startup-growth-check.sh` | Daily metadata-only growth check for startup memory and tracked startup/instruction surfaces | | `fm-pr-lib.sh` | Own canonical task and PR validation plus private atomic PR-poll publication, merge-notification identity, and retirement | | `fm-pr-poll.sh` | Provide the byte-static watcher program for validated pull-request, merge-request, and Gerrit-change poll sidecars | | `fm-contributions.sh` | Observe owned publications, retain exact-head judgments, measure required actors, and wake on maintainer signals | diff --git a/tests/fm-calm-pi-extension.test.sh b/tests/fm-calm-pi-extension.test.sh index f4baded12f4..f1f917f650a 100755 --- a/tests/fm-calm-pi-extension.test.sh +++ b/tests/fm-calm-pi-extension.test.sh @@ -2833,13 +2833,17 @@ TS return 1 } - wait_for_geometry_transition() { - local file=$1 transient_text=$2 final_text=$3 attempt=0 saw_transient=0 + # Pi's "Reloading..." box is a single intermediate frame, so no polling + # interval can be guaranteed to sample it on a loaded machine. Wait instead + # for the durable status row Pi appends to the transcript once the reload has + # completed and the chat has been rebuilt: it is absent before the reload and + # never appears when the reload fails. + wait_for_geometry_reload() { + local file=$1 reloaded_text=$2 final_text=$3 attempt=0 while [ "$attempt" -lt 600 ]; do capture_geometry_viewport "$file" || true - if grep -Fq "$transient_text" "$file" 2>/dev/null; then - saw_transient=1 - elif [ "$saw_transient" -eq 1 ] && grep -Fq "$final_text" "$file" 2>/dev/null; then + if grep -Fq "$reloaded_text" "$file" 2>/dev/null && + grep -Fq "$final_text" "$file" 2>/dev/null; then return 0 fi sleep 0.01 @@ -2894,9 +2898,9 @@ TS tmux -L "$TMUX_SOCKET" send-keys -t "$TMUX_SESSION" -l '/reload' tmux -L "$TMUX_SOCKET" send-keys -t "$TMUX_SESSION" Enter - wait_for_geometry_transition \ + wait_for_geometry_reload \ "$snapshot" \ - "Reloading keybindings, extensions, skills, prompts, themes, and context files..." \ + "Reloaded keybindings, extensions, skills, prompts, themes, and context files" \ "CALM_GEOMETRY_FINAL" \ || fail "Pi Calm hidden-block geometry E2E did not complete the /reload viewport transition" assert_geometry_gap "$snapshot" "reloaded native Calm transcript" diff --git a/tests/fm-contributions.test.sh b/tests/fm-contributions.test.sh index 9af32fc2950..47ccb09854b 100755 --- a/tests/fm-contributions.test.sh +++ b/tests/fm-contributions.test.sh @@ -1020,6 +1020,9 @@ test_arm_plumbs_a_configured_budget_into_the_check_shim() { wrap_forge "$home" mutate_record "$home" delivery '.records[0].checked_at="2026-09-15T08:00:00Z"' cp "$home/data/delivery/contributions.json" "$home/prior.json" + # Freeze the clock: an unfrozen one can tick past the one-second budget + # before the first forge call, so nothing is ever observed. + /bin/date +%s > "$home/forge/clock" printf 'hang\n' > "$home/forge/fault" if [ "$mode" = configured ]; then with_home "$home" env FM_CONTRIBUTIONS_BUDGET=1 "$ROOT/bin/fm-contributions.sh" arm >/dev/null \ diff --git a/tests/fm-startup-growth-check.test.sh b/tests/fm-startup-growth-check.test.sh new file mode 100755 index 00000000000..6fbec8b66e1 --- /dev/null +++ b/tests/fm-startup-growth-check.test.sh @@ -0,0 +1,629 @@ +#!/usr/bin/env bash +# Behavioral coverage for the daily startup growth check. +set -u + +# shellcheck source=tests/lib.sh +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" +# shellcheck source=bin/fm-pr-lib.sh +. "$ROOT/bin/fm-pr-lib.sh" + +TMP_ROOT=$(fm_test_tmproot fm-startup-growth-check) +CHECK="$ROOT/bin/fm-startup-growth-check.sh" + +make_world() { + local name=$1 root home + root="$TMP_ROOT/$name/root" + home="$TMP_ROOT/$name/home" + mkdir -p "$root/bin" "$home/config" "$home/data" "$home/state" + printf '# Firstmate\n' > "$root/AGENTS.md" + printf 'See AGENTS.md\n' > "$root/CLAUDE.md" + printf '#!/usr/bin/env bash\nexit 0\n' > "$root/bin/fm-session-start.sh" + printf '#!/usr/bin/env bash\nexit 0\n' > "$root/bin/fm-bootstrap.sh" + printf '#!/usr/bin/env bash\nexit 0\n' > "$root/bin/fm-supervision-instructions.sh" + printf '7500\n' > "$home/config/startup-memory-budget" + printf 'projects\n' > "$home/data/projects.md" + printf 'secondmates\n' > "$home/data/secondmates.md" + printf 'captain\n' > "$home/data/captain.md" + printf 'shared\n' > "$home/data/captain-shared.md" + printf 'learnings\n' > "$home/data/learnings.md" + printf '%s|%s\n' "$root" "$home" +} + +run_check() { + local root=$1 home=$2 now=$3 out status=0 + out=$(FM_ROOT_OVERRIDE="$root" FM_HOME="$home" FM_STARTUP_GROWTH_NOW="$now" "$CHECK" check 2>&1) || status=$? + printf '%s\t%s\n' "$status" "$out" +} + +output_part() { printf '%s' "$1" | cut -f2-; } +status_part() { printf '%s' "$1" | cut -f1; } + +add_bytes() { + local path=$1 count=$2 + dd if=/dev/zero bs=1 count="$count" 2>/dev/null | tr '\000' x >> "$path" +} + +budget_total() { # + awk -F '\t' '$1 == "memory_budget" { print $3; exit }' "$1/state/.startup-growth-check" +} + +test_initial_baseline_is_silent_and_records_metadata() { + local rec root home result out + rec=$(make_world baseline) + root=${rec%%|*} + home=${rec#*|} + result=$(run_check "$root" "$home" 1000) + [ "$(status_part "$result")" = 0 ] || fail "baseline check failed: $(output_part "$result")" + out=$(output_part "$result") + [ -z "$out" ] || fail "baseline without findings should stay silent: $out" + assert_grep $'last_eval\t1000' "$home/state/.startup-growth-check" "baseline did not record the evaluation time" + assert_grep $'AGENTS.md\ttracked\tpresent' "$home/state/.startup-growth-check" "baseline did not record tracked startup metadata" + assert_grep $'data/learnings.md\tmemory\tpresent' "$home/state/.startup-growth-check" "baseline did not record memory metadata" + assert_grep $'data/projects.md\tprinted-memory\tpresent' "$home/state/.startup-growth-check" "baseline did not record printed projects metadata" + assert_grep $'data/secondmates.md\tprinted-memory\tpresent' "$home/state/.startup-growth-check" "baseline did not record printed secondmates metadata" +} + +test_same_day_poll_does_not_touch_surfaces() { + local rec root home result out + rec=$(make_world same-day) + root=${rec%%|*} + home=${rec#*|} + run_check "$root" "$home" 1000 >/dev/null + rm -f "$root/AGENTS.md" + ln -s /no/such/place "$root/AGENTS.md" + result=$(run_check "$root" "$home" 1200) + [ "$(status_part "$result")" = 0 ] || fail "same-day poll failed: $(output_part "$result")" + out=$(output_part "$result") + [ -z "$out" ] || fail "same-day poll inspected surfaces instead of staying gated: $out" + assert_grep $'last_eval\t1000' "$home/state/.startup-growth-check" "same-day poll rewrote the daily record" +} + +test_due_growth_reports_once_and_dedupes() { + local rec root home result out + rec=$(make_world growth) + root=${rec%%|*} + home=${rec#*|} + run_check "$root" "$home" 1000 >/dev/null + add_bytes "$root/AGENTS.md" 2500 + add_bytes "$home/data/learnings.md" 900 + result=$(run_check "$root" "$home" 87401) + [ "$(status_part "$result")" = 0 ] || fail "growth check failed: $(output_part "$result")" + out=$(output_part "$result") + assert_contains "$out" 'tracked startup surface growth AGENTS.md +2500 bytes' "tracked growth was not reported" + assert_contains "$out" 'memory growth data/learnings.md +300 estimated_tokens (+900 bytes' "memory growth was not reported as estimated prompt cost" + result=$(run_check "$root" "$home" 173802) + [ "$(status_part "$result")" = 0 ] || fail "dedupe check failed: $(output_part "$result")" + out=$(output_part "$result") + [ -z "$out" ] || fail "unchanged persistent finding was repeated: $out" +} + +test_gradual_growth_below_daily_threshold_is_reported_cumulatively() { + local rec root home result out now day + rec=$(make_world cumulative) + root=${rec%%|*} + home=${rec#*|} + now=1000 + result=$(run_check "$root" "$home" "$now") + [ "$(status_part "$result")" = 0 ] || fail "cumulative baseline failed: $(output_part "$result")" + for day in 1 2 3; do + add_bytes "$root/AGENTS.md" 700 + add_bytes "$home/data/learnings.md" 300 + now=$((now + 86401)) + result=$(run_check "$root" "$home" "$now") + [ "$(status_part "$result")" = 0 ] || fail "cumulative day $day failed: $(output_part "$result")" + out=$(output_part "$result") + if [ "$day" -lt 3 ]; then + [ -z "$out" ] || fail "sub-threshold day $day should stay silent: $out" + fi + done + assert_contains "$out" 'tracked startup surface growth AGENTS.md +2100 bytes' "cumulative tracked growth was not reported once it added up" + assert_contains "$out" 'memory growth data/learnings.md +300 estimated_tokens (+900 bytes' "cumulative memory growth was not reported once it added up" + + now=$((now + 86401)) + result=$(run_check "$root" "$home" "$now") + [ "$(status_part "$result")" = 0 ] || fail "post-report check failed: $(output_part "$result")" + out=$(output_part "$result") + [ -z "$out" ] || fail "reported growth was repeated after the baseline was rebased: $out" +} + +test_printed_memory_growth_is_reported_without_entering_the_budget_total() { + local rec root home result out before after + rec=$(make_world printed) + root=${rec%%|*} + home=${rec#*|} + run_check "$root" "$home" 1000 >/dev/null + before=$(budget_total "$home") + add_bytes "$home/data/projects.md" 900 + result=$(run_check "$root" "$home" 87401) + [ "$(status_part "$result")" = 0 ] || fail "printed-memory check failed: $(output_part "$result")" + out=$(output_part "$result") + assert_contains "$out" 'printed-memory growth data/projects.md +300 estimated_tokens (+900 bytes' "printed startup memory growth was not reported" + after=$(budget_total "$home") + assert_equals "$before" "$after" "printed startup memory changed the budget total it must not own" +} + +test_first_content_of_an_optional_file_is_baselined_silently() { + local rec root home result out + rec=$(make_world first-content) + root=${rec%%|*} + home=${rec#*|} + rm -f "$home/data/secondmates.md" + result=$(run_check "$root" "$home" 1000) + [ "$(status_part "$result")" = 0 ] || fail "absent-surface baseline failed: $(output_part "$result")" + out=$(output_part "$result") + [ -z "$out" ] || fail "an absent optional surface should stay silent: $out" + + add_bytes "$home/data/secondmates.md" 1000 + result=$(run_check "$root" "$home" 87401) + [ "$(status_part "$result")" = 0 ] || fail "first-content check failed: $(output_part "$result")" + out=$(output_part "$result") + [ -z "$out" ] || fail "first content of an optional file was reported as growth: $out" + + add_bytes "$home/data/secondmates.md" 900 + result=$(run_check "$root" "$home" 173802) + [ "$(status_part "$result")" = 0 ] || fail "post-baseline growth check failed: $(output_part "$result")" + out=$(output_part "$result") + assert_contains "$out" 'printed-memory growth data/secondmates.md +300 estimated_tokens (+900 bytes' "growth above the silently established baseline was not reported" +} + +test_established_baseline_survives_disappearance_and_restoration() { + local rec root home result out + rec=$(make_world restored) + root=${rec%%|*} + home=${rec#*|} + add_bytes "$home/data/projects.md" 3000 + run_check "$root" "$home" 1000 >/dev/null + + rm -f "$home/data/projects.md" + result=$(run_check "$root" "$home" 87401) + [ "$(status_part "$result")" = 0 ] || fail "disappearance check failed: $(output_part "$result")" + out=$(output_part "$result") + [ -z "$out" ] || fail "an absent optional surface should stay silent: $out" + + printf 'projects\n' > "$home/data/projects.md" + add_bytes "$home/data/projects.md" 3000 + result=$(run_check "$root" "$home" 173802) + [ "$(status_part "$result")" = 0 ] || fail "restoration check failed: $(output_part "$result")" + out=$(output_part "$result") + [ -z "$out" ] || fail "restoring a file at its established size was reported as growth: $out" + + add_bytes "$home/data/projects.md" 900 + result=$(run_check "$root" "$home" 260203) + [ "$(status_part "$result")" = 0 ] || fail "post-restoration growth check failed: $(output_part "$result")" + out=$(output_part "$result") + assert_contains "$out" 'printed-memory growth data/projects.md +300 estimated_tokens (+900 bytes' "growth above the preserved baseline was not reported after restoration" +} + +test_secondmate_is_not_notified_about_primary_owned_shared_growth() { + local rec root home result out + rec=$(make_world shared-growth) + root=${rec%%|*} + home=${rec#*|} + : > "$home/.fm-secondmate-home" + run_check "$root" "$home" 1000 >/dev/null + add_bytes "$home/data/captain-shared.md" 900 + result=$(run_check "$root" "$home" 87401) + [ "$(status_part "$result")" = 0 ] || fail "secondmate shared-growth check failed: $(output_part "$result")" + out=$(output_part "$result") + [ -z "$out" ] || fail "a secondmate was notified about growth of the read-only primary-owned shared file: $out" + + rec=$(make_world shared-growth-primary) + root=${rec%%|*} + home=${rec#*|} + run_check "$root" "$home" 1000 >/dev/null + add_bytes "$home/data/captain-shared.md" 900 + result=$(run_check "$root" "$home" 87401) + [ "$(status_part "$result")" = 0 ] || fail "primary shared-growth check failed: $(output_part "$result")" + out=$(output_part "$result") + assert_contains "$out" 'memory growth data/captain-shared.md +300 estimated_tokens (+900 bytes' "a primary home did not report growth of its own shared file" +} + +test_budget_overrun_reports_and_separates_prompt_cost() { + local rec root home result out + rec=$(make_world overrun) + root=${rec%%|*} + home=${rec#*|} + printf '10\n' > "$home/config/startup-memory-budget" + add_bytes "$home/data/captain.md" 90 + add_bytes "$root/bin/fm-bootstrap.sh" 3000 + result=$(run_check "$root" "$home" 1000) + [ "$(status_part "$result")" = 0 ] || fail "overrun check failed: $(output_part "$result")" + out=$(output_part "$result") + assert_contains "$out" 'startup memory budget overrun total_estimated_tokens=' "budget overrun was not reported" + assert_not_contains "$out" 'tracked startup surface growth' "initial tracked growth should not be inferred without a baseline" + assert_not_contains "$out" 'bin/fm-bootstrap.sh' "initial tracked bytes were incorrectly counted as prompt-memory overrun evidence" + result=$(run_check "$root" "$home" 87401) + [ "$(status_part "$result")" = 0 ] || fail "repeated overrun check failed: $(output_part "$result")" + out=$(output_part "$result") + [ -z "$out" ] || fail "standing budget overrun was reported again instead of deduplicated: $out" +} + +test_secondmate_is_not_woken_about_the_primary_owned_shared_overrun() { + local rec root home result out + rec=$(make_world secondmate) + root=${rec%%|*} + home=${rec#*|} + printf '10\n' > "$home/config/startup-memory-budget" + add_bytes "$home/data/captain-shared.md" 900 + : > "$home/.fm-secondmate-home" + result=$(run_check "$root" "$home" 1000) + [ "$(status_part "$result")" = 0 ] || fail "secondmate overrun check failed: $(output_part "$result")" + out=$(output_part "$result") + assert_not_contains "$out" 'startup memory budget overrun' "a secondmate was woken about the primary-owned shared overrun it cannot act on" + + rm -f "$home/.fm-secondmate-home" + result=$(run_check "$root" "$home" 87401) + [ "$(status_part "$result")" = 0 ] || fail "primary overrun check failed: $(output_part "$result")" + out=$(output_part "$result") + assert_contains "$out" 'startup memory budget overrun total_estimated_tokens=' "the same overrun was not reported in a primary home" +} + +test_metadata_read_failure_keeps_the_retained_baseline() { + local rec root home result out fakebin real_stat + if [ "$(uname)" = Darwin ]; then + printf 'note - metadata read failure is exercised through the stat -c branch; skipping on Darwin\n' + return 0 + fi + rec=$(make_world unreadable) + root=${rec%%|*} + home=${rec#*|} + add_bytes "$root/AGENTS.md" 5000 + result=$(run_check "$root" "$home" 1000) + [ "$(status_part "$result")" = 0 ] || fail "unreadable baseline failed: $(output_part "$result")" + + real_stat=$(command -v stat) || fail "no stat(1) on PATH" + fakebin="$TMP_ROOT/unreadable/fakebin" + mkdir -p "$fakebin" + cat > "$fakebin/stat" <&1) \ + || fail "unreadable due run failed: $out" + assert_contains "$out" 'unreadable tracked AGENTS.md' "a failed size read was not reported as unreadable" + + result=$(run_check "$root" "$home" 173802) + [ "$(status_part "$result")" = 0 ] || fail "post-failure check failed: $(output_part "$result")" + out=$(output_part "$result") + [ -z "$out" ] || fail "a failed metadata read destroyed the retained baseline and fabricated growth: $out" +} + +test_due_unsafe_inputs_are_reported_but_absent_optional_memory_is_not() { + local rec root home result out + rec=$(make_world unsafe) + root=${rec%%|*} + home=${rec#*|} + run_check "$root" "$home" 1000 >/dev/null + rm -f "$home/data/captain-shared.md" "$home/data/learnings.md" + ln -s "$home/data/captain.md" "$home/data/learnings.md" + result=$(run_check "$root" "$home" 87401) + [ "$(status_part "$result")" = 0 ] || fail "unsafe check failed: $(output_part "$result")" + out=$(output_part "$result") + assert_contains "$out" 'unsafe memory data/learnings.md' "unsafe symlinked memory was not reported" + assert_not_contains "$out" 'missing memory data/captain-shared.md' "absent optional shared memory should not be reported" + result=$(run_check "$root" "$home" 173802) + [ "$(status_part "$result")" = 0 ] || fail "repeated unsafe check failed: $(output_part "$result")" + out=$(output_part "$result") + [ -z "$out" ] || fail "standing unsafe finding was reported again instead of deduplicated: $out" +} + +test_over_long_finding_set_is_capped_with_the_shared_marker() { + local rec root home deep seg out reported + rec=$(make_world capped) + root=${rec%%|*} + home=${rec#*|} + seg= + while [ "${#seg}" -lt 100 ]; do + seg="${seg}memory" + done + deep="$home/data" + while [ "${#deep}" -lt 1200 ]; do + deep="$deep/$seg" + done + mkdir -p "$deep" + ln -s "$home/data/captain.md" "$deep/learnings.md" + out=$(FM_ROOT_OVERRIDE="$root" FM_HOME="$home" FM_DATA_OVERRIDE="$deep" FM_STARTUP_GROWTH_NOW=1000 \ + "$CHECK" check 2>/dev/null) || fail "capped check failed" + assert_contains "$out" 'unsafe memory data/learnings.md' "the leading finding was lost" + [ "${#out}" -le 1000 ] || fail "the wake line was emitted uncapped at ${#out} characters" + assert_contains "$out" ' [truncated]' "the capped wake line carries no truncation marker" + reported=$(awk -F '\t' '$1 == "reported" { print substr($0, index($0, "\t") + 1); exit }' \ + "$home/state/.startup-growth-check") + [ "${#reported}" -gt "${#out}" ] \ + || fail "the dedupe record stored the capped line instead of the full finding set" +} + +test_unknown_budget_verdict_fields_are_reported_as_unparseable() { + local rec root home fixbin out + fixbin=$(make_isolated_bin verdict 0) + + cat > "$fixbin/fm-startup-memory-budget.sh" <<'STUB' +#!/usr/bin/env bash +printf 'role=primary +' +printf 'effective_budget_tokens=7500 +' +printf 'total_estimated_tokens=10 +' +printf 'budget_status=sideways +' +STUB + chmod 0755 "$fixbin/fm-startup-memory-budget.sh" + rec=$(make_world verdict-status) + root=${rec%%|*} + home=${rec#*|} + out=$(FM_ROOT_OVERRIDE="$root" FM_HOME="$home" FM_STARTUP_GROWTH_NOW=1000 \ + "$fixbin/fm-startup-growth-check.sh" check 2>&1) || fail "unknown-status check failed: $out" + assert_contains "$out" 'reason=unparseable report' "an unrecognized budget_status was accepted as within-budget" + + cat > "$fixbin/fm-startup-memory-budget.sh" <<'STUB' +#!/usr/bin/env bash +printf 'role=primary +' +printf 'effective_budget_tokens=10 +' +printf 'total_estimated_tokens=7500 +' +printf 'budget_status=over-budget +' +printf 'exception=some-unrelated-annotation +' +STUB + chmod 0755 "$fixbin/fm-startup-memory-budget.sh" + rec=$(make_world verdict-exception) + root=${rec%%|*} + home=${rec#*|} + out=$(FM_ROOT_OVERRIDE="$root" FM_HOME="$home" FM_STARTUP_GROWTH_NOW=1000 \ + "$fixbin/fm-startup-growth-check.sh" check 2>&1) || fail "unknown-exception check failed: $out" + assert_contains "$out" 'reason=unparseable report' "an unrecognized exception annotation silently suppressed the overrun" +} + +test_record_with_a_foreign_schema_marker_is_not_trusted() { + local rec root home record out first + rec=$(make_world foreign-schema) + root=${rec%%|*} + home=${rec#*|} + record="$home/state/.startup-growth-check" + { + printf 'schema\tfm-startup-growth-check-v2\n' + printf 'last_eval\t1000\n' + printf 'AGENTS.md\tpresent\t1\t1\t1\n' + } > "$record" + out=$(FM_ROOT_OVERRIDE="$root" FM_HOME="$home" FM_STARTUP_GROWTH_NOW=1200 "$CHECK" check 2>&1) \ + || fail "foreign-schema check failed: $out" + [ -z "$out" ] || fail "a record from another schema was reinterpreted instead of re-baselined: $out" + IFS= read -r first < "$record" + assert_equals "$(printf 'schema\tfm-startup-growth-check-v1')" "$first" "the foreign record was kept instead of replaced" + assert_grep $'last_eval\t1200' "$record" "the foreign record's last_eval gated the evaluation instead of being ignored" +} + +test_findings_are_delivered_even_when_the_record_cannot_be_published() { + local rec root home out status=0 leftover + rec=$(make_world unpublishable) + root=${rec%%|*} + home=${rec#*|} + printf '5\n' > "$home/config/startup-memory-budget" + ln -s /no/such/place "$home/state/.startup-growth-check" + out=$(FM_ROOT_OVERRIDE="$root" FM_HOME="$home" FM_STARTUP_GROWTH_NOW=1000 "$CHECK" check 2>/dev/null) || status=$? + [ "$status" != 0 ] || fail "an unpublishable report record should surface as a failure" + assert_contains "$out" 'startup memory budget overrun' "the finding was dropped because the record could not be published" + leftover=$(cd "$home/state" && ls -1 .startup-growth-check.?????? 2>/dev/null || true) + [ -z "$leftover" ] || fail "a failed evaluation leaked its temporary record: $leftover" +} + +# Copies the check and the libraries it sources into an isolated bin, so the +# helpers it execs can be replaced: the budget owner with a stub report, or the +# register with a failing one. +make_isolated_bin() { # + local name=$1 code=$2 bin + bin="$TMP_ROOT/$name/bin" + mkdir -p "$bin" + cp "$ROOT/bin/fm-startup-growth-check.sh" "$ROOT/bin/fm-pr-lib.sh" \ + "$ROOT/bin/fm-startup-memory-budget-lib.sh" "$ROOT/bin/fm-line-cap-lib.sh" \ + "$ROOT/bin/fm-check-lib.sh" "$ROOT/bin/fm-startup-memory-budget.sh" "$bin/" + if [ "$code" = 0 ]; then + cp "$ROOT/bin/fm-check-register.sh" "$bin/fm-check-register.sh" + else + printf '#!/usr/bin/env bash\nexit %s\n' "$code" > "$bin/fm-check-register.sh" + chmod 0755 "$bin/fm-check-register.sh" + fi + printf '%s\n' "$bin" +} + +test_rearming_an_unchanged_binding_does_not_replace_the_shim() { + local rec root home bin before after out + rec=$(make_world rearm-noop) + root=${rec%%|*} + home=${rec#*|} + bin=$(make_isolated_bin rearm-noop 0) + FM_ROOT_OVERRIDE="$root" FM_HOME="$home" "$bin/fm-startup-growth-check.sh" arm >/dev/null \ + || fail "first arm failed" + before=$(fm_pr_file_inode "$home/state/startup-growth.check.sh") + out=$(FM_ROOT_OVERRIDE="$root" FM_HOME="$home" "$bin/fm-startup-growth-check.sh" arm 2>&1) \ + || fail "re-arm of an unchanged binding failed: $out" + after=$(fm_pr_file_inode "$home/state/startup-growth.check.sh") + assert_equals "$before" "$after" "re-arming replaced a shim whose bytes were already correct" + out=$(env -u FM_HOME FM_ROOT_OVERRIDE="$root" FM_STARTUP_GROWTH_NOW=1000 \ + "$home/state/startup-growth.check.sh" 2>&1) || fail "the re-armed shim no longer runs: $out" + assert_present "$home/state/.startup-growth-check" "the re-armed shim did not run the daily check" +} + +test_failed_first_arm_leaves_the_home_plainly_unarmed() { + local rec root home bin status=0 out leftover + rec=$(make_world arm-fail) + root=${rec%%|*} + home=${rec#*|} + bin=$(make_isolated_bin arm-fail 1) + out=$(FM_ROOT_OVERRIDE="$root" FM_HOME="$home" "$bin/fm-startup-growth-check.sh" arm 2>&1) || status=$? + [ "$status" != 0 ] || fail "a failed registration reported a successful arm" + assert_absent "$home/state/startup-growth.check.sh" "a failed first arm left an unregistered shim behind" + assert_absent "$home/state/startup-growth.check-trust" "a failed first arm left a trust binding behind" + leftover=$(cd "$home/state" && ls -1 .startup-growth-check-shim.?????? 2>/dev/null || true) + [ -z "$leftover" ] || fail "a failed arm leaked its staged shim: $leftover" +} + +test_failed_rearm_keeps_the_previously_armed_shim_and_trust() { + local rec root home bin good bad shim trust status=0 out + rec=$(make_world rearm-fail) + root=${rec%%|*} + home=${rec#*|} + good=$(make_isolated_bin rearm-fail 0) + FM_ROOT_OVERRIDE="$root" FM_HOME="$home" "$good/fm-startup-growth-check.sh" arm >/dev/null \ + || fail "first arm failed" + shim=$(cat "$home/state/startup-growth.check.sh") + trust=$(cat "$home/state/startup-growth.check-trust") + + bad=$(make_isolated_bin rearm-fail-bad 1) + out=$(FM_ROOT_OVERRIDE="$root" FM_HOME="$home" "$bad/fm-startup-growth-check.sh" arm 2>&1) || status=$? + [ "$status" != 0 ] || fail "a failed re-registration reported a successful arm" + assert_present "$home/state/startup-growth.check.sh" "a failed re-arm disarmed a working home" + assert_present "$home/state/startup-growth.check-trust" "a failed re-arm removed the trust binding of a working home" + assert_equals "$shim" "$(cat "$home/state/startup-growth.check.sh")" "a failed re-arm changed the armed shim" + assert_equals "$trust" "$(cat "$home/state/startup-growth.check-trust")" "a failed re-arm changed the trust binding" + out=$(env -u FM_HOME FM_ROOT_OVERRIDE="$root" FM_STARTUP_GROWTH_NOW=1000 \ + "$home/state/startup-growth.check.sh" 2>&1) || fail "the preserved shim no longer runs: $out" + assert_present "$home/state/.startup-growth-check" "the preserved shim did not run the daily check" +} + +test_interrupted_evaluation_abandons_its_partial_record() { + local rec root home bin ready pid status=0 waited=0 first leftover result out + rec=$(make_world interrupted) + root=${rec%%|*} + home=${rec#*|} + run_check "$root" "$home" 1000 >/dev/null + add_bytes "$root/AGENTS.md" 1500 + + bin=$(make_isolated_bin interrupted 0) + ready="$TMP_ROOT/interrupted/budget-entered" + cat > "$bin/fm-startup-memory-budget.sh" < "$ready" +while [ -f "$ready" ]; do + sleep 0.05 +done +printf 'role=primary\n' +printf 'effective_budget_tokens=7500\n' +printf 'total_estimated_tokens=10\n' +printf 'budget_status=within-budget\n' +STUB + chmod 0755 "$bin/fm-startup-memory-budget.sh" + + FM_ROOT_OVERRIDE="$root" FM_HOME="$home" FM_STARTUP_GROWTH_NOW=87401 \ + "$bin/fm-startup-growth-check.sh" check >/dev/null 2>&1 & + pid=$! + while [ ! -f "$ready" ]; do + [ "$waited" -lt 200 ] || fail "the check never reached its budget call" + waited=$((waited + 1)) + sleep 0.05 + done + kill -TERM "$pid" || fail "could not signal the running check" + rm -f "$ready" + wait "$pid" || status=$? + [ "$status" != 0 ] || fail "an interrupted evaluation finished as if it had published a record" + + IFS= read -r first < "$home/state/.startup-growth-check" + assert_equals "$(printf 'schema\tfm-startup-growth-check-v1')" "$first" "an interrupted evaluation published a partial record" + assert_grep $'last_eval\t1000' "$home/state/.startup-growth-check" "an interrupted evaluation advanced the daily gate" + leftover=$(cd "$home/state" && ls -1 .startup-growth-check.?????? 2>/dev/null || true) + [ -z "$leftover" ] || fail "an interrupted evaluation left its temporary record behind: $leftover" + + add_bytes "$root/AGENTS.md" 1000 + result=$(run_check "$root" "$home" 173802) + [ "$(status_part "$result")" = 0 ] || fail "post-interrupt check failed: $(output_part "$result")" + out=$(output_part "$result") + assert_contains "$out" 'tracked startup surface growth AGENTS.md +2500 bytes' "the retained baselines did not survive an interrupted evaluation" +} + +test_aged_empty_orphan_records_are_swept_without_touching_live_work() { + local rec root home state now result out + rec=$(make_world orphan-sweep) + root=${rec%%|*} + home=${rec#*|} + state="$home/state" + now=$(date +%s) + run_check "$root" "$home" "$((now - 90000))" >/dev/null + add_bytes "$root/AGENTS.md" 2500 + + : > "$state/.startup-growth-check.aaaaaa" + printf 'schema\tfm-startup-growth-check-v1\n' > "$state/.startup-growth-check.bbbbbb" + touch -t 202001010000 "$state/.startup-growth-check.aaaaaa" "$state/.startup-growth-check.bbbbbb" + : > "$state/.startup-growth-check.cccccc" + + result=$(run_check "$root" "$home" "$now") + [ "$(status_part "$result")" = 0 ] || fail "sweeping evaluation failed: $(output_part "$result")" + out=$(output_part "$result") + assert_absent "$state/.startup-growth-check.aaaaaa" "an interrupted evaluation's empty temporary record was never swept" + assert_present "$state/.startup-growth-check.bbbbbb" "the sweep removed an orphan that still held record bytes" + assert_present "$state/.startup-growth-check.cccccc" "the sweep removed a concurrent evaluation's live temporary record" + assert_contains "$out" 'tracked startup surface growth AGENTS.md +2500 bytes' "the sweep cost the evaluation its retained baselines" + assert_grep $'last_eval\t'"$now" "$state/.startup-growth-check" "the sweeping evaluation did not publish its own record" +} + +test_orphan_sweep_stays_inside_the_daily_cadence() { + local rec root home state now result + rec=$(make_world orphan-sweep-gated) + root=${rec%%|*} + home=${rec#*|} + state="$home/state" + now=$(date +%s) + run_check "$root" "$home" "$now" >/dev/null + : > "$state/.startup-growth-check.aaaaaa" + touch -t 202001010000 "$state/.startup-growth-check.aaaaaa" + result=$(run_check "$root" "$home" "$((now + 200))") + [ "$(status_part "$result")" = 0 ] || fail "same-day poll failed: $(output_part "$result")" + assert_present "$state/.startup-growth-check.aaaaaa" "a same-day poll did work instead of staying gated" +} + +test_arm_and_disarm_use_authenticated_custom_check() { + local rec root home out + rec=$(make_world arm) + root=${rec%%|*} + home=${rec#*|} + out=$(FM_ROOT_OVERRIDE="$root" FM_HOME="$home" "$CHECK" arm) + assert_contains "$out" 'armed: state/startup-growth.check.sh' "arm did not announce the check shim" + assert_present "$home/state/startup-growth.check.sh" "arm did not write the check shim" + assert_present "$home/state/startup-growth.check-trust" "arm did not register trust for the check shim" + + out=$(env -u FM_HOME FM_ROOT_OVERRIDE="$root" FM_STARTUP_GROWTH_NOW=1000 "$home/state/startup-growth.check.sh" 2>&1) \ + || fail "registered check shim failed: $out" + [ -z "$out" ] || fail "registered shim baseline run should stay silent: $out" + assert_present "$home/state/.startup-growth-check" "registered shim did not run the daily check in the pinned home" + assert_absent "$root/state/.startup-growth-check" "registered shim resolved the home from the environment instead of its pinned value" + add_bytes "$root/AGENTS.md" 2500 + out=$(env -u FM_HOME FM_ROOT_OVERRIDE="$root" FM_STARTUP_GROWTH_NOW=87401 "$home/state/startup-growth.check.sh" 2>&1) \ + || fail "registered check shim failed on growth: $out" + assert_contains "$out" 'startup-growth: tracked startup surface growth AGENTS.md +2500 bytes' "registered shim did not report growth to the watcher" + + FM_ROOT_OVERRIDE="$root" FM_HOME="$home" "$CHECK" disarm >/dev/null + assert_absent "$home/state/startup-growth.check.sh" "disarm left the check shim" + assert_absent "$home/state/startup-growth.check-trust" "disarm left the trust binding" +} + +test_initial_baseline_is_silent_and_records_metadata +test_same_day_poll_does_not_touch_surfaces +test_due_growth_reports_once_and_dedupes +test_gradual_growth_below_daily_threshold_is_reported_cumulatively +test_printed_memory_growth_is_reported_without_entering_the_budget_total +test_first_content_of_an_optional_file_is_baselined_silently +test_established_baseline_survives_disappearance_and_restoration +test_secondmate_is_not_notified_about_primary_owned_shared_growth +test_budget_overrun_reports_and_separates_prompt_cost +test_secondmate_is_not_woken_about_the_primary_owned_shared_overrun +test_metadata_read_failure_keeps_the_retained_baseline +test_due_unsafe_inputs_are_reported_but_absent_optional_memory_is_not +test_findings_are_delivered_even_when_the_record_cannot_be_published +test_record_with_a_foreign_schema_marker_is_not_trusted +test_over_long_finding_set_is_capped_with_the_shared_marker +test_unknown_budget_verdict_fields_are_reported_as_unparseable +test_interrupted_evaluation_abandons_its_partial_record +test_aged_empty_orphan_records_are_swept_without_touching_live_work +test_orphan_sweep_stays_inside_the_daily_cadence +test_arm_and_disarm_use_authenticated_custom_check +test_rearming_an_unchanged_binding_does_not_replace_the_shim +test_failed_first_arm_leaves_the_home_plainly_unarmed +test_failed_rearm_keeps_the_previously_armed_shim_and_trust +pass "fm-startup-growth-check" From 2e8cd9e9179c1d852f59d3b90fb19be98f4d7bf3 Mon Sep 17 00:00:00 2001 From: Tiago Date: Tue, 6 Oct 2026 23:24:48 -0300 Subject: [PATCH 05/12] fix(bin): reopen the remote-reply continuity decision on a later break (#6708) * fix: reopen a remote-reply continuity break after repair A later break for the same route and reason was swallowed after the operator resolved the first one, because the status line matched for the life of the log. The continuity ingest now appends again when the cursor has moved or retirement has reset that episode, and an unchanged re-read still appends nothing. status_event_recorded is unchanged. Its other callers are the pending-reply escalation, which already decides its own episode, the parent-channel note append, and the remote document transfer note. * no-mistakes(review): seed continuity episode for already recorded break line * no-mistakes(document): document when a remote-reply continuity break reopens * no-mistakes(ci): The adapter now stores the continuity episode record before it appends the `blocked` line, so a failed store appends nothing. The changes are uncommitted in the worktree, in `bin/fm-procevent-remote-reply.sh` and `tests/fm-remote-reply.test.sh`. **Invariant:** a continuity `blocked` line is on the parent status log only when the episode record for that break is already stored. Only the `continuity-broken` branch of `cmd_ingest` appends that line, so the fix is at that one place. **What changed in `cmd_ingest`:** - It decides first whether the break needs a line, then stores the episode record, then appends. - If storing the record fails, it stops with "cannot record continuity episode" and appends nothing. - If the line is already on the log and no episode record exists, it stores the record and does not append. **One addition you did not ask for:** if the append fails after the record is stored, the adapter puts the earlier record back (or deletes the new one when none existed). Without that, the retry would read as the unchanged repeat and append nothing, which is the issue 6701 failure again. **Deviation from your test instruction:** the test does not use `chmod` on the cursor directory. `write_continuity_episode` runs `chmod 700` on that directory before every write, so a read-only directory is made writable again. The test instead makes `mktemp` fail for the episode's temporary file in that directory, the same way the existing receipt-failure test does. **Tests added to `tests/fm-remote-reply.test.sh`:** - Store failure: the second break exits 1, appends no `blocked` line and opens no decision. One retry after storage recovers exits 3 and appends a single line. - Append failure: with the status log read-only, the third break exits 1 and appends nothing. One retry after the log is writable exits 3 and appends a single line. **Verification:** `bash tests/fm-remote-reply.test.sh` ends with "ALL TESTS PASSED" with the fix. Against the script at commit b750c828 the same test file fails at "a continuity break appended its line before its episode was stored". `shellcheck -S warning` reports only an unused loop variable at line 667 of the test file, which this change does not touch. I ran no other test files. The Greptile Review check log could not be retrieved, so I worked from the finding text alone * fix: record a continuity break's reader position on its status line A later break at another cursor is then a different line, so the existing duplicate check appends it and reopens the decision. An unchanged re-read builds the same line and appends nothing. * fix: reopen a continuity break after an identical restore A retirement that puts the same bytes back used to rebuild the recorded line, so the later break stayed closed. The retirement count on that line makes the later break distinct. * no-mistakes(review): remove continuity match for full-prefix line without retirement count * no-mistakes(document): clarify what a continuity break status line records * fix: remove the reply cursor before recording retirement A stop between those steps must leave the count unchanged, so an unchanged continuity break still builds the same line. --- bin/fm-procevent-remote-reply.sh | 58 +++++++- docs/remote-secondmates.md | 6 + tests/fm-remote-reply.test.sh | 230 +++++++++++++++++++++++++++++++ 3 files changed, 291 insertions(+), 3 deletions(-) diff --git a/bin/fm-procevent-remote-reply.sh b/bin/fm-procevent-remote-reply.sh index 7d545c0508b..a7d82904aa7 100755 --- a/bin/fm-procevent-remote-reply.sh +++ b/bin/fm-procevent-remote-reply.sh @@ -128,6 +128,7 @@ source_id() { cursor_path() { printf '%s/%s.cursor\n' "$CURSOR_DIR" "$1"; } ingest_receipt_path() { printf '%s/%s.%s.ingested\n' "$CURSOR_DIR" "$1" "$2"; } +retirement_count_path() { printf '%s/%s.retirements\n' "$CURSOR_DIR" "$1"; } mirrored_source_path() { printf '%s/.remote-reply-mirrored-%s\n' "$STATE" "$1"; } read_cursor() { # ; sets CURSOR_OFFSET and CURSOR_HASH @@ -164,6 +165,43 @@ write_cursor() { # mv -f -- "$tmp" "$path" } +# A missing file is zero and is not created. Ingest only reads this. +# Retirement is the one writer, so a crash during ingest cannot change it. +read_retirement_count() { # ; sets RETIREMENT_COUNT + local path count lines + path=$(retirement_count_path "$1") + RETIREMENT_COUNT=0 + [ -e "$path" ] || [ -L "$path" ] || return 0 + [ -f "$path" ] && [ ! -L "$path" ] || die "reply retirement count is unsafe: $path" + lines=$(grep -c '^count=' "$path" 2>/dev/null || true) + [ "$lines" = 1 ] || die "reply retirement count is invalid: $path" + count=$(sed -n 's/^count=//p' "$path") + case "$count" in ''|*[!0-9]*) die "reply retirement count is invalid: $path" ;; esac + RETIREMENT_COUNT=$count +} + +write_retirement_count() { # + local id=$1 count=$2 path tmp + case "$count" in ''|*[!0-9]*) return 1 ;; esac + mkdir -p "$CURSOR_DIR" || return 1 + chmod 700 "$CURSOR_DIR" 2>/dev/null || true + path=$(retirement_count_path "$id") + [ ! -L "$path" ] || return 1 + tmp=$(umask 077; mktemp "$CURSOR_DIR/.retirements.XXXXXX") || return 1 + printf 'count=%s\n' "$count" > "$tmp" || { rm -f -- "$tmp"; return 1; } + chmod 600 "$tmp" || { rm -f -- "$tmp"; return 1; } + if ! mv -f -- "$tmp" "$path"; then + rm -f -- "$tmp" + return 1 + fi +} + +# Twelve characters distinguish breaks in the status line. The cursor keeps +# the full digest the reader uses. +continuity_prefix() { + printf '%.12s' "$CURSOR_HASH" +} + ingest_receipt_matches() { # local path stored actual count path=$(ingest_receipt_path "$1" "$2") @@ -544,7 +582,12 @@ cmd_ingest() { die "result does not continue the current cursor for $id" fi if [ "$class" = continuity-broken ]; then - line="blocked [key=remote-reply-continuity-$id]: remote reply continuity broke for $id ($reason)" + # The same offset, prefix, and retirement count build the same line, so a + # retry appends nothing. Retirement removes the cursor before it records + # the next count, so a later break is a new line even when the restored + # bytes match, and a stop between those steps leaves the count unchanged. + read_retirement_count "$id" + line="blocked [key=remote-reply-continuity-$id]: remote reply continuity broke for $id ($reason) at offset ${CURSOR_OFFSET} prefix $(continuity_prefix) retirements ${RETIREMENT_COUNT}" append_rc=0 if status_event_recorded "$status_file" "$line"; then append_rc=1 @@ -726,7 +769,7 @@ cmd_retire_quiesce_locked() { } cmd_retire_finalize_locked() { - local id=${1:-} force=${2:-} sid path + local id=${1:-} force=${2:-} sid path cursor validate_id "$id" [ -z "$force" ] || [ "$force" = --force ] || die "invalid retirement option: $force" sid=$(source_id "$id") @@ -742,7 +785,16 @@ cmd_retire_finalize_locked() { done fi fi - rm -f -- "$(cursor_path "$id")" + # Remove the cursor first. A stop before the count write leaves that count + # unchanged, so the same break still builds the same line. + cursor=$(cursor_path "$id") + rm -f -- "$cursor" || die "cannot remove remote reply cursor" + if [ -e "$cursor" ] || [ -L "$cursor" ]; then + die "cannot remove remote reply cursor" + fi + read_retirement_count "$id" + write_retirement_count "$id" "$((RETIREMENT_COUNT + 1))" \ + || die "cannot record remote reply retirement" rm -f -- "$CURSOR_DIR/$id".*.ingested rm -f -- "$(fm_pending_reply_remote_channel_watermark_path "$STATE" "$id")" } diff --git a/docs/remote-secondmates.md b/docs/remote-secondmates.md index cb725a7b65c..980fbceaf8c 100644 --- a/docs/remote-secondmates.md +++ b/docs/remote-secondmates.md @@ -588,6 +588,12 @@ The [process-to-event operating contract](configuration.md#process-to-event-sour The source log is never truncated or consumed. A shortened or changed prefix stops the relay and surfaces a continuity failure instead of silently resetting the cursor. +The failure appends one `blocked` line to the parent status stream, which opens a decision. +The line records the reason, the reader position (the cursor offset and the first 12 characters of the prefix hash), and the retirement count (how many times the route has been retired). +Reading the same break again, with the cursor where it was and the count unchanged, appends nothing. +A later break at a different reader position, or after another retirement, appends a new `blocked` line and opens the decision again. +A line written before that position was recorded does not match, so the next break appends the new line once. + ### SSH exit 255 and unavailable homes An SSH exit status of 255 always means transport failure or unknown remote completion. diff --git a/tests/fm-remote-reply.test.sh b/tests/fm-remote-reply.test.sh index f5e8aef0c5d..92f6d9e73d0 100755 --- a/tests/fm-remote-reply.test.sh +++ b/tests/fm-remote-reply.test.sh @@ -1024,6 +1024,15 @@ assert_absent "$PARENT/state/procevent/$SID.source" "continuity break was re-arm remote_env "$ADAPTER" ingest ios "$RESULT_TWELVE" >/dev/null 2>&1 || true [ "$(grep -cF 'blocked [key=remote-reply-continuity-ios]' "$PARENT/state/ios.status")" -eq 1 ] \ || fail "continuity replay duplicated the escalation" +first_offset=$(sed -n 's/^offset=//p' "$PARENT/state/remote-replies/ios.cursor") +first_hash=$(sed -n 's/^prefix_sha256=//p' "$PARENT/state/remote-replies/ios.cursor" | tr 'A-F' 'a-f') +first_prefix=$(printf '%.12s' "$first_hash") +assert_grep "at offset ${first_offset} prefix ${first_prefix} retirements 0" "$PARENT/state/ios.status" \ + "continuity break did not record the reader position" +assert_no_grep "prefix ${first_hash}" "$PARENT/state/ios.status" \ + "continuity break recorded the full prefix hash" +assert_absent "$PARENT/state/remote-replies/ios.retirements" \ + "a route that has never been retired gained a retirement count" status_line_at_epoch "$(grep -F 'blocked [key=remote-reply-continuity-ios]' "$PARENT/state/ios.status")" >/dev/null \ || fail "new continuity escalation has unknown emission time" if [ "${FM_TEST_EVIDENCE:-0}" = 1 ]; then @@ -1064,8 +1073,229 @@ remote_env "$ADAPTER" handle ios "$GEN" "$RESULT_TWELVE" >/dev/null 2>&1 || [ "$ || fail "pending continuity result could not be acknowledged after retirement refusal" remote_env "$ADAPTER" retire ios >/dev/null assert_absent "$PARENT/state/remote-replies/ios.cursor" "adapter retirement left its cursor" +recorded_retirements=$(cat "$PARENT/state/remote-replies/ios.retirements" 2>/dev/null || true) +[ "$recorded_retirements" = count=1 ] \ + || fail "adapter retirement did not record its count (got: ${recorded_retirements:-absent})" assert_absent "$PARENT/state/remote-replies/ios.caught-up" \ "adapter retirement left a caught-up watermark a later route could inherit" pass "remote reply retirement quiesces and refuses unhandled captured results" +# Empty the remote log under the committed cursor and handle the break the +# next blocking source reports. Sets RESULT_BREAK. +break_repaired_route() { #