Skip to content

feat(bin): let a checks-green ship task declare a merge this fleet cannot perform - #2799

Open
Inthuson wants to merge 17 commits into
kunchenguid:mainfrom
Inthuson:fm/ship-done-merge-wait-declaration-d7
Open

Inthuson wants to merge 17 commits into
kunchenguid:mainfrom
Inthuson:fm/ship-done-merge-wait-declaration-d7

Conversation

@Inthuson

Copy link
Copy Markdown
Contributor

Intent

Give a checks-green ship task a way to declare, durably, that it is deliberately waiting on a merge nobody in this fleet can perform, so possible-wedge aging stops without closing the task, exiting the worker, or removing the merge poll.

Why: this is live, not hypothetical. Measured on a real task on 2026-08-20, the watcher alarmed every 90 to 150 seconds on an idle green pane, each alarm costing firstmate a handling turn, with no way to suppress it. As of 2026-08-22 there are four firstmate PRs in exactly this state at once, all green or unrunnable and all waiting on a maintainer this fleet has no control over, plus a fifth on GitLab. This is the steady state of the whole firstmate lane and the single largest source of pointless supervision turns.

Both obvious escapes were verified wrong before choosing this design. (1) A 'paused:' status line does not help: crew_absorb_class reuses bin/fm-crew-state.sh, whose authoritative run-step read wins, so a declared wait loses to the still-open run. The captain explicitly ruled out 'fixing' this by re-declaring a pause harder or adding a new pause verb. (2) Teardown is forbidden because the work is committed and not landed; data/learnings.md records that exiting or tearing down over an open run once cost a twelve-hour-late push and a false delivery record, and teardown removes the merge poll that would have surfaced the merge. So the missing capability is a declared, durable acknowledgement at the classifier, not a new pause verb and not a lifecycle shortcut.

Requirements the captain stated, all of which must remain true and are deliberate rather than oversights:

  • A task NOT at a terminal checks-green outcome must still age into a possible wedge exactly as it does today.
  • A task whose declaration is present but whose recorded PR is not actually green, or does not exist, must not be silenced by the declaration.
  • The merge poll must keep running. The captain's words: 'A declaration that silences the merge poll as well would be worse than the current noise.' Hence bin/fm-merge-wait.sh declare REFUSES when no merge poll is armed, and merge_wait_declared de-admits the moment the poll is gone. This gating is the feature's bound, not an incidental check.
  • Do NOT blanket-classify every checks-green task as non-actionable. On 2026-08-21 three merge watches were found pointing at heads that no longer existed and one merge had already happened unnoticed, so 'green' is not a safe reason to stop looking on its own. Only an explicit declaration is, and only while it holds.

Scope was deliberately constrained by the captain: 'This is a classifier change. Do not restructure bin/fm-crew-state.sh's state model, do not unify the several places outcome truth is derived, and do not generalise this into a framework for arbitrary declared waits.' An adjacent task (brief-required-external-wait-w7) covers the required-external-wait brief work and was explicitly not to be absorbed. Structural problems found along the way were to be recorded as a note for firstmate to file, not fixed: one such note was filed about bin/fm-supervise-daemon.sh classifying stale panes through its own classify_stale, which never calls crew_absorb_class, so a declared merge wait is still escalated while away mode owns supervision. That second-consumer change is deliberately out of scope here.

Design decisions I made while implementing:

  • A separate durable record state/.merge-wait rather than a new field in state/.meta, to avoid entangling bin/fm-pr-check.sh's atomic metadata identity contract.
  • The declaration is admitted ONLY from source: run-step plus the terminal checks-green detail. Status-log-sourced and coarse 'done' verdicts are deliberately excluded because they rest on the worker's own prose claim rather than authoritative run state. A task whose run is no longer attributed therefore resumes ordinary aging, which is the fail-safe direction.
  • The checks-green detail string became a shared constant FM_CLASSIFY_CHECKS_GREEN_DETAIL in fm-classify-lib.sh because it is the byte-for-byte coupling between the reader in fm-crew-state.sh that writes it and the classifier that prefix-matches it; a literal copied into both is how writer and reader drift apart.
  • The new merge-wait token comes out of crew_absorb_class's SAME single crew-state read, adding no extra state call to the per-wake triage.
  • Dedicated watcher markers .merge-wait-, .merge-wait-rechecked- and .merge-wait-resurfaced- rather than reusing .paused-: a merge-waiting task's last status line is 'done: ... checks green', not 'paused:', so the stale loop's pause reconciliation would wipe a reused marker on every poll and the wait would re-surface every poll instead of once per long cadence.
  • The absorb is a bounded hourly re-surface, never permanent silence, and its age is anchored on the declaration record's own mtime so a churny idle pane cannot keep resetting the cadence.
  • merge_wait_repeat_poll re-reads authoritative crew state at most once per wedge window, so a PR that goes red after admission returns to the ordinary wedge timer even though its declaration is still on disk.
  • I found and fixed a hole in my own first version: withdrawing a declaration originally dropped only the cadence markers, leaving the absorbed pane hash suppressed in .stale-, so a window would have stayed quiet on a declaration that no longer existed until its pane happened to change. Hence the clear_merge_wait_state / clear_merge_wait_tracking split, mirroring the existing clear_pause_state / clear_pause_tracking pair.

Testing approach the captain asked for: extend the existing colocated classifier tests in tests/fm-watch-triage.test.sh rather than inventing a new harness (a new test file would also require shard/family/timing registration in bin/fm-test-run.sh). Six tests cover a declared wait on a genuinely green recorded PR being absorbed and re-surfaced, the same declaration not absorbing when the run is not at a terminal outcome, the declaration not absorbing when the recorded PR is missing or changed, a task with no declaration aging exactly as it does today, a withdrawn declaration releasing its absorbed pane, and the merge poll surviving in every case. The captain required the new assertions be proved non-vacuous: each was, by five targeted breakages of the implementation, with the matching test failing every time and passing again on restore.

What Changed

  • Adds bin/fm-merge-wait.sh (declare / show / clear), which writes a durable state/<id>.merge-wait record naming the pull request being waited on. declare refuses unless the task records a pr= and that pull request's own registered merge poll is still armed (distinguishing a custom check in the slot, with its own message), bin/fm-teardown.sh now removes the record with the rest of a task's state, and AGENTS.md, docs/architecture.md, docs/configuration.md, and docs/scripts.md document the record, the declaration workflow, and the reminder cadence.
  • bin/fm-classify-lib.sh gains merge_wait_declared and merge_wait_poll_armed and a new merge-wait token from crew_absorb_class (now taking an optional state dir), admitted only when the authoritative run step reports the terminal checks-green detail — extracted as the shared FM_CLASSIFY_CHECKS_GREEN_DETAIL constant that bin/fm-crew-state.sh now emits, so writer and reader cannot drift.
  • bin/fm-watch.sh routes every suppressible stale wake through a new emit_stale_wake that requires an explicit alarm class, and merge_wait_absorbs_alarm absorbs only idle-stale alarms — never the busy-turn bound, never in away mode, never over a crew's own paused:/captain-held line — re-reading the declaration and a fresh crew-state verdict per alarm, passing one reminder through per FM_PAUSE_RESURFACE_SECS anchored on the record's mtime, leaving the merge poll armed and deferring .wedge-escalations-<key> writes to surfaced alarms only; clear_merge_wait_markers retires the throttle when a declaration stops holding. Covered by 26 new tests (24 in tests/fm-watch-triage.test.sh, one each in the crew-state and teardown suites).

Risk Assessment

✅ Low: The final round's two behavioral changes are narrow and verified correct against source (the escalation count is now written only on the surfaced path before the exiting wake, and the poll gate checks the same registration evidence the poll's own validator uses, shared with declare so the two cannot disagree), every intent criterion is satisfied, the single-emit-point, alarm-class, and away-mode invariants all still hold, and the only surviving findings are comment-completeness and test-fixture fidelity with no behavioral consequence.

Testing

Baseline for this round was the two commits the previous rounds landed on the test file, so I re-ran the change's own targeted coverage rather than the suite: the 24 declared-merge-wait cases in tests/fm-watch-triage.test.sh, the teardown retirement case, and the 6 timing-sensitive cases those rounds repaired, the last of which I ran 7 times in a row on a host at load average 70 to confirm the write-chain race and the reap hang are actually gone (42 assertions, no failures, no wedge). I independently re-proved the two load-bearing fixes non-vacuous by deleting the implementation line each covers, watching the matching test fail, and restoring. I then found and closed one real coverage gap in the change's own coupling: outcome: checks-passed had no fixture anywhere in the suite, so I added tests/fm-crew-state.test.sh:test_checks_passed_outcome_admits_a_declared_merge_wait, which drives a real checks-passed run through the real reader, the real merge poll, the real declaration writer and the real classifier and asserts merge-wait with the declaration and none without it; a drifted literal on that branch reds it while the existing triage cases stay green, which is precisely the blindness it removes. For reviewer-visible product evidence I built and ran an end-to-end demo through the real CLIs and captured its transcript, which quantifies the intent: 3 alarms across 3 pane hashes undeclared versus 0 declared, the refusals when no merge poll is armed and when a custom check occupies that slot, one bounded reminder per cadence with its own wording, the alarm returning when the run goes red or the recorded pull request changes, withdrawal releasing the pane and retiring the throttle, and the still-armed merge poll reporting merged while the declaration is live. There is no UI surface in this change, so the reviewer-visible artifact is a CLI transcript plus the watcher's own triage log and durable wake queue rather than a screenshot. Everything passed; the only working-tree change I leave behind is the added test, and all scratch runners were removed.

Evidence: End-to-end CLI transcript: declare, absorb, bounded reminder, refusals, withdrawal, and the merge landing

=== STEP 0 the task, as firstmate left it: green PR, worker alive, merge poll armed $ bin/fm-pr-check.sh shipdemo https://github.com/example/repo/pull/4242 (arm the merge poll that will report the merge) armed: state/shipdemo.check.sh $ bin/fm-crew-state.sh shipdemo (the AUTHORITATIVE run-step read the classifier trusts) state: done · source: run-step · checks green: PR ready for review === STEP 1 TODAY, with no declaration: the idle green pane alarms (the measured noise) watcher woke firstmate and exited. reason it delivered: > stale: fm:fm-shipdemo $ bin/fm-wake-drain.sh 1787415857 1 stale fm:fm-shipdemo stale: fm:fm-shipdemo merge poll still armed: state/shipdemo.check.sh, state/shipdemo.pr-poll, state/shipdemo.pr-poll-registration === STEP 2 the declaration REFUSES to be made without an armed merge poll $ bin/fm-merge-wait.sh declare nopoll error: nopoll has no armed merge poll (run bin/fm-pr-check.sh first) exit status: 1 (record written? no) ... and refuses when the check slot holds some OTHER check, not this merge poll: $ bin/fm-merge-wait.sh declare custom error: custom has a check armed, but it is not this task's merge poll for https://github.com/example/repo/pull/4242 (re-arm with bin/fm-pr-check.sh) record written? no === STEP 3 firstmate declares the wait on the real task $ bin/fm-merge-wait.sh declare shipdemo "upstream maintainer owns this merge" declared: state/shipdemo.merge-wait (https://github.com/example/repo/pull/4242)&#10;$ bin/fm-merge-wait.sh show shipdemo pr=https://github.com/example/repo/pull/4242&#10;declared=1787415860&#10;note=upstream maintainer owns this merge === STEP 4 the same pane now goes quiet: no wake, nothing queued, merge poll untouched watcher still running after 4 polls (it did NOT wake firstmate) watcher stdout (what firstmate would have been told): <empty> durable wake queue: <empty> state/.watch-triage.log: [2026-08-22T16:24:28+0000] absorbed stale (declared merge wait, age 8s): fm:fm-shipdemo the pane text now CHANGES (a clock tick) - the case measured on 2026-08-20, where each new pane hash cost one alarm. It stays absorbed: still running after the new hash. queue: <empty> [2026-08-22T16:24:39+0000] absorbed stale (declared merge wait, age 19s): fm:fm-shipdemo merge poll still armed: state/shipdemo.check.sh, state/shipdemo.pr-poll, state/shipdemo.pr-poll-registration === STEP 5 suppression is not silence: one reminder per FM_PAUSE_RESURFACE_SECS the reminder firstmate receives: > stale: fm:fm-shipdemo (merge wait 504s, checks green and awaiting a merge this fleet cannot perform, rechecked on a long cadence not a wedge; confirm the merge is still someone else's to make) and inside the same window it goes quiet again: no second reminder. watcher stdout: <empty> === STEP 6 the declaration does NOT silence a pull request that is not green $ bin/fm-crew-state.sh shipdemo state: failed · source: run-step · run failed the declaration was ignored and firstmate was woken: > stale: fm:fm-shipdemo === STEP 6b green again, but the watch was re-armed on a DIFFERENT pull request declaration on disk still says: pr=https://github.com/example/repo/pull/4242&#10;task metadata now records: pr=https://github.com/example/repo/pull/4300&#10;and the authoritative verdict is green again, so ONLY the mismatch is at play: state: done · source: run-step · checks green: PR ready for review the superseded declaration was ignored and firstmate was woken: > stale: fm:fm-shipdemo === STEP 7 withdrawing the declaration returns the pane to ordinary aging $ bin/fm-merge-wait.sh clear shipdemo cleared: state/shipdemo.merge-wait firstmate is woken again, exactly as before the declaration existed: > stale: fm:fm-shipdemo merge poll still armed: state/shipdemo.check.sh, state/shipdemo.pr-poll, state/shipdemo.pr-poll-registration reminder throttle retired? yes === STEP 8 the measured cost, counted: one alarm per new pane hash, before and after (a) with NO declaration: hash 1 -> WOKE firstmate: stale: fm:fm-shipdemo hash 2 -> WOKE firstmate: stale: fm:fm-shipdemo hash 3 -> WOKE firstmate: stale: fm:fm-shipdemo undeclared: 3 alarm(s) across 3 distinct pane hashes (b) with the declaration in place: hash 1 -> no wake hash 2 -> no wake hash 3 -> no wake declared : 0 alarm(s) across 3 distinct pane hashes === STEP 9 the merge finally lands: the poll the declaration never silenced reports it declaration still live? yes firstmate is woken by the merge poll, not by a wedge alarm: > check: .../state/shipdemo.check.sh: merged === END


=== STEP 0  the task, as firstmate left it: green PR, worker alive, merge poll armed
$ cat /tmp/no-mistakes-evidence/01M0M20RC149A8DXDPT5JZ6RNV/e2e-workdir/state/shipdemo.meta
  window=fm:fm-shipdemo
  kind=ship
  worktree=/tmp/no-mistakes-evidence/01M0M20RC149A8DXDPT5JZ6RNV/e2e-workdir/wt
$ cat /tmp/no-mistakes-evidence/01M0M20RC149A8DXDPT5JZ6RNV/e2e-workdir/state/shipdemo.status
  done: PR https://github.com/example/repo/pull/4242 checks green
$ bin/fm-pr-check.sh shipdemo https://github.com/example/repo/pull/4242   (arm the merge poll that will report the merge)
  armed: state/shipdemo.check.sh
$ bin/fm-crew-state.sh shipdemo   (the AUTHORITATIVE run-step read the classifier trusts)
  state: done · source: run-step · checks green: PR ready for review

=== STEP 1  TODAY, with no declaration: the idle green pane alarms (the measured noise)
  watcher woke firstmate and exited. reason it delivered:
    > stale: fm:fm-shipdemo
$ bin/fm-wake-drain.sh
  1787415857	1	stale	fm:fm-shipdemo	stale: fm:fm-shipdemo
  merge poll still armed: state/shipdemo.check.sh, state/shipdemo.pr-poll, state/shipdemo.pr-poll-registration

=== STEP 2  the declaration REFUSES to be made without an armed merge poll
$ bin/fm-merge-wait.sh declare nopoll
  error: nopoll has no armed merge poll (run bin/fm-pr-check.sh first)
  exit status: 1   (record written? no)

  ... and refuses when the check slot holds some OTHER check, not this merge poll:
$ bin/fm-merge-wait.sh declare custom
  error: custom has a check armed, but it is not this task's merge poll for https://github.com/example/repo/pull/4242 (re-arm with bin/fm-pr-check.sh)
  record written? no

=== STEP 3  firstmate declares the wait on the real task
$ bin/fm-merge-wait.sh declare shipdemo "upstream maintainer owns this merge"
  declared: state/shipdemo.merge-wait (https://github.com/example/repo/pull/4242)
$ bin/fm-merge-wait.sh show shipdemo
  pr=https://github.com/example/repo/pull/4242
  declared=1787415860
  note=upstream maintainer owns this merge

=== STEP 4  the same pane now goes quiet: no wake, nothing queued, merge poll untouched
  watcher still running after 4 polls (it did NOT wake firstmate)
  watcher stdout (what firstmate would have been told): <empty>
  durable wake queue: <empty>
  state/.watch-triage.log:
    [2026-08-22T16:24:28+0000] absorbed stale (declared merge wait, age 8s): fm:fm-shipdemo

  the pane text now CHANGES (a clock tick) - the case measured on 2026-08-20,
  where each new pane hash cost one alarm. It stays absorbed:
  still running after the new hash. queue: <empty>
  state/.watch-triage.log:
    [2026-08-22T16:24:28+0000] absorbed stale (declared merge wait, age 8s): fm:fm-shipdemo
    [2026-08-22T16:24:39+0000] absorbed stale (declared merge wait, age 19s): fm:fm-shipdemo
  merge poll still armed: state/shipdemo.check.sh, state/shipdemo.pr-poll, state/shipdemo.pr-poll-registration

=== STEP 5  suppression is not silence: one reminder per FM_PAUSE_RESURFACE_SECS
  (aging the declaration record past the 240s cadence)
  the reminder firstmate receives:
    > stale: fm:fm-shipdemo (merge wait 504s, checks green and awaiting a merge this fleet cannot perform, rechecked on a long cadence not a wedge; confirm the merge is still someone else's to make)
$ bin/fm-wake-drain.sh
  1787415889	2	stale	fm:fm-shipdemo	stale: fm:fm-shipdemo (merge wait 504s, checks green and awaiting a merge this fleet cannot perform, rechecked on a long cadence not a wedge; confirm the merge is still someone else's to make)
  and inside the same window it goes quiet again:
    no second reminder. watcher stdout: <empty>

=== STEP 6  the declaration does NOT silence a pull request that is not green
  (same declaration on disk; the authoritative run now reports a failure)
$ bin/fm-crew-state.sh shipdemo
  state: failed · source: run-step · run failed
  the declaration was ignored and firstmate was woken:
    > stale: fm:fm-shipdemo
$ bin/fm-wake-drain.sh
  1787415908	3	stale	fm:fm-shipdemo	stale: fm:fm-shipdemo

=== STEP 6b  green again, but the watch was re-armed on a DIFFERENT pull request
  (the declaration still names #4242; the task now records #4300, so the
   declaration is not inherited by the new pull request)
$ bin/fm-pr-check.sh shipdemo https://github.com/example/repo/pull/4300
  armed: state/shipdemo.check.sh
  declaration on disk still says: pr=https://github.com/example/repo/pull/4242
  task metadata now records:      pr=https://github.com/example/repo/pull/4300
  and the authoritative verdict is green again, so ONLY the mismatch is at play:
    state: done · source: run-step · checks green: PR ready for review
  the superseded declaration was ignored and firstmate was woken:
    > stale: fm:fm-shipdemo
$ bin/fm-wake-drain.sh
  1787415918	4	stale	fm:fm-shipdemo	stale: fm:fm-shipdemo

=== STEP 7  withdrawing the declaration returns the pane to ordinary aging
$ bin/fm-merge-wait.sh clear shipdemo
  cleared: state/shipdemo.merge-wait
  firstmate is woken again, exactly as before the declaration existed:
    > stale: fm:fm-shipdemo
$ bin/fm-wake-drain.sh
  1787415927	5	stale	fm:fm-shipdemo	stale: fm:fm-shipdemo
  merge poll still armed: state/shipdemo.check.sh, state/shipdemo.pr-poll, state/shipdemo.pr-poll-registration
  reminder throttle retired? yes

=== STEP 8  the measured cost, counted: one alarm per new pane hash, before and after
  (a) with NO declaration:
    hash 1 -> WOKE firstmate: stale: fm:fm-shipdemo
    hash 2 -> WOKE firstmate: stale: fm:fm-shipdemo
    hash 3 -> WOKE firstmate: stale: fm:fm-shipdemo
  undeclared: 3 alarm(s) across 3 distinct pane hashes

  (b) with the declaration in place:
    declared: state/shipdemo.merge-wait (https://github.com/example/repo/pull/4242)
    hash 1 -> no wake
    hash 2 -> no wake
    hash 3 -> no wake
  declared  : 0 alarm(s) across 3 distinct pane hashes
  state/.watch-triage.log:
    [2026-08-22T16:24:56+0000] absorbed stale (declared merge wait, age 511s): fm:fm-shipdemo
    [2026-08-22T16:26:12+0000] absorbed stale (declared merge wait, age 8s): fm:fm-shipdemo
    [2026-08-22T16:26:32+0000] absorbed stale (declared merge wait, age 28s): fm:fm-shipdemo
    [2026-08-22T16:26:50+0000] absorbed stale (declared merge wait, age 46s): fm:fm-shipdemo
  merge poll still armed: state/shipdemo.check.sh, state/shipdemo.pr-poll, state/shipdemo.pr-poll-registration

=== STEP 9  the merge finally lands: the poll the declaration never silenced reports it
  (the maintainer merges it; the forge now answers MERGED)
  declaration still live? yes
  firstmate is woken by the merge poll, not by a wedge alarm:
    > check: /tmp/no-mistakes-evidence/01M0M20RC149A8DXDPT5JZ6RNV/e2e-workdir/state/shipdemo.check.sh: merged
$ bin/fm-wake-drain.sh
  1787416023	9	check	/tmp/no-mistakes-evidence/01M0M20RC149A8DXDPT5JZ6RNV/e2e-workdir/state/shipdemo.check.sh	check: /tmp/no-mistakes-evidence/01M0M20RC149A8DXDPT5JZ6RNV/e2e-workdir/state/shipdemo.check.sh: merged

=== END
Evidence: The demo script that produced the transcript (real firstmate CLIs, only tmux / no-mistakes / gh stubbed)
#!/usr/bin/env bash
# End-to-end demonstration of the declared merge wait, driven the way firstmate
# actually drives it: the real bin/fm-merge-wait.sh, the real bin/fm-pr-check.sh
# merge poll, the real bin/fm-crew-state.sh authoritative reader, the real
# bin/fm-classify-lib.sh classifier, the real bin/fm-watch.sh watcher, and the
# real bin/fm-wake-drain.sh queue reader.
#
# Only two things are stubbed, and both are external programs rather than
# firstmate code: `tmux` (serves a canned pane capture, so no terminal is needed)
# and the `no-mistakes` CLI (serves one canned `axi status` run object, so no
# validation pipeline is needed). Every firstmate decision below is the shipped
# code's own.
set -u

ROOT=${ROOT:?ROOT must point at the firstmate checkout}
EV=${EV:?EV must point at the evidence dir}
WORK="$EV/e2e-workdir"
STATE="$WORK/state"
WT="$WORK/wt"
FB="$WORK/fakebin"
PANE="$WORK/pane.txt"
TASK=shipdemo
WINDOW=fm:fm-shipdemo
KEY=$(printf '%s' "$WINDOW" | tr ':/.' '___')
PR='https://github.com/example/repo/pull/4242'
QUIET_ROOT=$(mktemp -d)   # keeps bin/fm-guard.sh's tangle banner out of the transcript

rm -rf "$WORK"
mkdir -p "$STATE" "$WT" "$FB"

say() { printf '\n=== %s\n' "$*"; }
run() { printf '$ %s\n' "$*"; "$@" 2>&1 | sed 's/^/  /'; }

# --- external stubs ---------------------------------------------------------
cat > "$FB/no-mistakes" <<'SH'
#!/usr/bin/env bash
set -u
case "${1:-}" in
  axi) shift
    case "${1:-}" in
      status) shift
        if [ "${1:-}" = --run ]; then printf '%s\n' "${FM_FAKE_AXI_STATUS_RUN:-}"
        else printf '%s\n' "${FM_FAKE_AXI_STATUS:-}"; fi ;;
      logs) printf '%s\n' "${FM_FAKE_CI_LOGS:-}" ;;
    esac ;;
  runs) printf '%s\n' "${FM_FAKE_RUNS_LIST:-}" ;;
esac
exit 0
SH
cat > "$FB/tmux" <<'SH'
#!/usr/bin/env bash
set -u
if [ "${1:-}" = "list-windows" ]; then
  [ -n "${FM_FAKE_TMUX_WINDOW:-}" ] && printf '%s\n' "${FM_FAKE_TMUX_WINDOW#*:}"
  exit 0
fi
if [ "${1:-}" = "capture-pane" ]; then
  [ -n "${FM_FAKE_TMUX_CAPTURE:-}" ] && cat "$FM_FAKE_TMUX_CAPTURE"
  exit 0
fi
if [ "${1:-}" = "display-message" ]; then
  case "$*" in *pane_current_command*) printf '%s\n' "${FM_FAKE_TMUX_CURRENT_COMMAND:-zsh}"; exit 0 ;; esac
fi
exit 1
SH
# The merge poll armed below is REAL, so a watcher whose check cadence came due runs
# it and asks the forge about the merge. The forge is therefore stubbed too: gh stays
# silent (as it does for an unmerged pull request) until FM_FAKE_GH_STATE is set,
# which is how STEP 9 lands the merge. No network call is possible from this demo.
cat > "$FB/gh" <<'SH'
#!/usr/bin/env bash
set -u
[ -n "${FM_FAKE_GH_STATE:-}" ] || exit 1
printf '%s\n' "$FM_FAKE_GH_STATE"
exit 0
SH
printf '#!/usr/bin/env bash\nexit 1\n' > "$FB/glab"
chmod +x "$FB/gh" "$FB/glab"
chmod +x "$FB/no-mistakes" "$FB/tmux"

# --- the crew: a real worktree on its own branch, a ship task, a green run ----
git -C "$WT" init -q
git -C "$WT" -c user.email=demo@example.test -c user.name=demo commit -q --allow-empty -m init
git -C "$WT" checkout -q -b fm/ship-demo
HEAD_SHA=$(git -C "$WT" rev-parse HEAD)

printf 'window=%s\nkind=ship\nworktree=%s\n' "$WINDOW" "$WT" > "$STATE/$TASK.meta"
# The crew's own last status line, exactly as a ship task reports a green PR.
printf 'done: PR %s checks green\n' "$PR" > "$STATE/$TASK.status"
# An idle green pane: byte-identical text on consecutive polls is what the
# watcher calls stale.
printf '%s' 'firstmate ship task - PR #4242 is green, waiting on the maintainer' > "$PANE"

# The authoritative run: a real no-mistakes run object at the terminal
# checks-passed outcome, attributed to this branch and this head.
export FM_FAKE_AXI_STATUS="run:
  id: \"01RUNDEMO\"
  branch: fm/ship-demo
  status: completed
  head: \"$HEAD_SHA\"
  pr: \"$PR\"
  findings: none
outcome: checks-passed"

# The status line was already surfaced once, through the production signature
# owner, so the per-poll signal scan does not re-fire on it. Every phase below is
# about pane staleness, which is where the measured noise came from.
FM_STATE_OVERRIDE="$STATE" bash -c '
  . "$1"
  sig=$(fm_wake_signal_sig "$3") || exit 1
  printf "%s" "$sig" > "$(fm_wake_signal_seen_path "$2" "$3")"
' _ "$ROOT/bin/fm-wake-lib.sh" "$STATE" "$STATE/$TASK.status"

fm() {  # run a firstmate script against this demo's state
  env PATH="$FB:$PATH" FM_ROOT_OVERRIDE="$QUIET_ROOT" FM_STATE_OVERRIDE="$STATE" "$ROOT/bin/$1" "${@:2}"
}

# One watcher round. Real bin/fm-watch.sh, real bin/fm-crew-state.sh (no
# FM_CREW_STATE_BIN stub), tight poll so a demo does not take minutes.
watch_round() {  # <out> [extra env...]
  local out=$1; shift
  env PATH="$FB:$PATH" FM_ROOT_OVERRIDE="$QUIET_ROOT" FM_STATE_OVERRIDE="$STATE" \
    FM_FAKE_TMUX_WINDOW="$WINDOW" FM_FAKE_TMUX_CAPTURE="$PANE" FM_FAKE_TMUX_CURRENT_COMMAND=zsh \
    FM_POLL=1 FM_SIGNAL_GRACE=1 FM_CHECK_INTERVAL=999999 FM_HEARTBEAT=999999 \
    "$@" "$ROOT/bin/fm-watch.sh" > "$out" 2>&1 &
  printf '%s' "$!"
}

wait_exit() {  # <pid> [ticks]
  local pid=$1 limit=${2:-150} i=0
  while [ "$i" -lt "$limit" ]; do
    kill -0 "$pid" 2>/dev/null || { wait "$pid" 2>/dev/null; return 0; }
    sleep 0.1; i=$((i + 1))
  done
  return 1
}

stop() {  # <pid> - bounded stop for a watcher that is deliberately still running
  local pid=$1 i=0
  kill "$pid" 2>/dev/null || true
  while [ "$i" -lt 100 ]; do
    kill -0 "$pid" 2>/dev/null || { wait "$pid" 2>/dev/null; return 0; }
    sleep 0.1; i=$((i + 1))
  done
  kill -9 "$pid" 2>/dev/null || true
  wait "$pid" 2>/dev/null || true
}

# Let the watcher complete <n> whole polls (the liveness beacon is touched at the
# top of every poll).
polls() {  # <pid> <n>
  local pid=$1 want=$2 beat="$STATE/.last-watcher-beat" seen=0 last="" now i=0
  rm -f "$beat"
  while [ "$i" -lt 400 ] && [ "$seen" -lt "$want" ]; do
    kill -0 "$pid" 2>/dev/null || return 1
    now=$(stat -c %Y "$beat" 2>/dev/null || true)
    if [ -n "$now" ] && [ "$now" != "$last" ]; then last=$now; seen=$((seen + 1)); fi
    sleep 0.1; i=$((i + 1))
  done
  [ "$seen" -ge "$want" ]
}

# What firstmate would actually receive: the durable wake queue, read and
# acknowledged through the real drain. Acknowledging is not cosmetic here: a
# watcher that stopped leaves a recovery episode, and the next watcher spends its
# first wake re-announcing it ("check: rearm-resurface") - real behavior, but
# noise for this demo, so every round below is acknowledged before the next.
drain_and_ack() {  # [quiet]
  local err="$WORK/drain.err" seq gen
  if [ "${1-}" = quiet ]; then
    fm fm-wake-drain.sh > /dev/null 2> "$err" || true
  else
    printf '$ bin/fm-wake-drain.sh\n'
    fm fm-wake-drain.sh 2> "$err" | sed 's/^/  /'
  fi
  seq=$(sed -n 's/^WAKE_ACK_REQUIRED:.*--ack-through \([0-9][0-9]*\) --recovery-generation [A-Za-z0-9._-][A-Za-z0-9._-]*$/\1/p' "$err")
  gen=$(sed -n 's/^WAKE_ACK_REQUIRED:.*--ack-through [0-9][0-9]* --recovery-generation \([A-Za-z0-9._-][A-Za-z0-9._-]*\)$/\1/p' "$err")
  [ -n "$seq" ] || return 0
  fm fm-wake-drain.sh --ack-through "$seq" --recovery-generation "$gen" >/dev/null 2>&1 || true
}

# Launch a round, and if it exits only to re-announce a recovery episode, retire
# that and launch again. Echoes the pid of the round that matters.
fresh_round() {  # <out> [extra env...]
  local out=$1 pid tries=0
  shift
  while [ "$tries" -lt 3 ]; do
    pid=$(watch_round "$out" "$@")
    if wait_exit "$pid" 30 && grep -qx 'check: rearm-resurface' "$out" 2>/dev/null; then
      drain_and_ack quiet
      : > "$out"
      tries=$((tries + 1))
      continue
    fi
    printf '%s' "$pid"
    return 0
  done
  printf '%s' "$pid"
}

poll_survives() {
  local missing=
  for f in "$TASK.check.sh" "$TASK.pr-poll" "$TASK.pr-poll-registration"; do
    [ -s "$STATE/$f" ] || missing="$missing $f"
  done
  if [ -n "$missing" ]; then
    printf '  MERGE POLL GONE:%s\n' "$missing"
  else
    printf '  merge poll still armed: state/%s.check.sh, state/%s.pr-poll, state/%s.pr-poll-registration\n' \
      "$TASK" "$TASK" "$TASK"
  fi
}

triage_tail() { printf '  state/.watch-triage.log:\n'; tail -4 "$STATE/.watch-triage.log" 2>/dev/null | sed 's/^/    /'; }

############################################

... [3859 bytes truncated] ...

$WORK/4.out" ] && cat "$WORK/4.out" || echo '<empty>')"
printf '  durable wake queue: %s\n' \
  "$([ -s "$STATE/.wake-queue" ] && cat "$STATE/.wake-queue" || echo '<empty>')"
triage_tail
printf '\n  the pane text now CHANGES (a clock tick) - the case measured on 2026-08-20,\n'
printf '  where each new pane hash cost one alarm. It stays absorbed:\n'
printf '%s' 'firstmate ship task - PR #4242 is green, waiting on the maintainer (00:02)' > "$PANE"
if polls "$pid" 4; then
  printf '  still running after the new hash. queue: %s\n' \
    "$([ -s "$STATE/.wake-queue" ] && cat "$STATE/.wake-queue" || echo '<empty>')"
else
  printf '  UNEXPECTED: a changed hash woke firstmate: %s\n' "$(cat "$WORK/4.out")"
fi
triage_tail
stop "$pid"
drain_and_ack quiet
poll_survives

###############################################################################
say 'STEP 5  suppression is not silence: one reminder per FM_PAUSE_RESURFACE_SECS'
###############################################################################
printf '  (aging the declaration record past the 240s cadence)\n'
touch -d "@$(( $(date +%s) - 500 ))" "$STATE/$TASK.merge-wait"
rm -f "$STATE/.stale-$KEY"
pid=$(fresh_round "$WORK/5.out" FM_PAUSE_RESURFACE_SECS=240 FM_STALE_ESCALATE_SECS=999)
if wait_exit "$pid"; then
  printf '  the reminder firstmate receives:\n'
  sed 's/^/    > /' "$WORK/5.out"
else
  stop "$pid"; printf '  UNEXPECTED: the aged declaration passed no reminder through\n'
fi
drain_and_ack
printf '  and inside the same window it goes quiet again:\n'
rm -f "$STATE/.stale-$KEY"
pid=$(fresh_round "$WORK/5b.out" FM_PAUSE_RESURFACE_SECS=240 FM_STALE_ESCALATE_SECS=999)
if polls "$pid" 3; then
  printf '    no second reminder. watcher stdout: %s\n' \
    "$([ -s "$WORK/5b.out" ] && cat "$WORK/5b.out" || echo '<empty>')"
else
  printf '    UNEXPECTED: the reminder repeated inside its throttle window: %s\n' "$(cat "$WORK/5b.out")"
fi
stop "$pid"
drain_and_ack quiet

###############################################################################
say 'STEP 6  the declaration does NOT silence a pull request that is not green'
###############################################################################
printf '  (same declaration on disk; the authoritative run now reports a failure)\n'
export FM_FAKE_AXI_STATUS="run:
  id: \"01RUNDEMO\"
  branch: fm/ship-demo
  status: completed
  head: \"$HEAD_SHA\"
  pr: \"$PR\"
  findings: none
outcome: failed"
printf '$ bin/fm-crew-state.sh %s\n' "$TASK"
fm fm-crew-state.sh "$TASK" 2>&1 | sed 's/^/  /'
rm -f "$STATE/.stale-$KEY" "$STATE/.merge-wait-resurfaced-$KEY"
pid=$(fresh_round "$WORK/6.out" FM_PAUSE_RESURFACE_SECS=999 FM_STALE_ESCALATE_SECS=999)
if wait_exit "$pid"; then
  printf '  the declaration was ignored and firstmate was woken:\n'
  sed 's/^/    > /' "$WORK/6.out"
else
  stop "$pid"; printf '  UNEXPECTED: a non-green PR stayed silenced by its declaration\n'
fi
drain_and_ack
export FM_FAKE_AXI_STATUS="run:
  id: \"01RUNDEMO\"
  branch: fm/ship-demo
  status: completed
  head: \"$HEAD_SHA\"
  pr: \"$PR\"
  findings: none
outcome: checks-passed"

###############################################################################
say 'STEP 6b  green again, but the watch was re-armed on a DIFFERENT pull request'
###############################################################################
printf '  (the declaration still names #4242; the task now records #4300, so the\n'
printf '   declaration is not inherited by the new pull request)\n'
NEWPR='https://github.com/example/repo/pull/4300'
printf '$ bin/fm-pr-check.sh %s %s\n' "$TASK" "$NEWPR"
fm fm-pr-check.sh "$TASK" "$NEWPR" 2>&1 | grep -v 'fm-guard.sh: No such file' | sed 's/^/  /'
touch "$STATE/.last-check"
printf '  declaration on disk still says: %s\n' "$(grep '^pr=' "$STATE/$TASK.merge-wait")"
printf '  task metadata now records:      %s\n' "$(grep '^pr=' "$STATE/$TASK.meta" | tail -1)"
printf '  and the authoritative verdict is green again, so ONLY the mismatch is at play:\n'
printf '    %s\n' "$(fm fm-crew-state.sh "$TASK" 2>&1)"
rm -f "$STATE/.stale-$KEY" "$STATE/.merge-wait-resurfaced-$KEY"
pid=$(fresh_round "$WORK/6b.out" FM_PAUSE_RESURFACE_SECS=999 FM_STALE_ESCALATE_SECS=999)
if wait_exit "$pid"; then
  printf '  the superseded declaration was ignored and firstmate was woken:\n'
  sed 's/^/    > /' "$WORK/6b.out"
else
  stop "$pid"; printf '  UNEXPECTED: a superseded declaration kept its pane quiet\n'
fi
drain_and_ack
# Back to the declared pull request for the withdrawal step below.
fm fm-pr-check.sh "$TASK" "$PR" >/dev/null 2>&1
touch "$STATE/.last-check"

###############################################################################
say 'STEP 7  withdrawing the declaration returns the pane to ordinary aging'
###############################################################################
printf '$ bin/fm-merge-wait.sh clear %s\n' "$TASK"
fm fm-merge-wait.sh clear "$TASK" 2>&1 | sed 's/^/  /'
rm -f "$STATE/.stale-$KEY"
pid=$(fresh_round "$WORK/7.out" FM_PAUSE_RESURFACE_SECS=999 FM_STALE_ESCALATE_SECS=999)
if wait_exit "$pid"; then
  printf '  firstmate is woken again, exactly as before the declaration existed:\n'
  sed 's/^/    > /' "$WORK/7.out"
else
  stop "$pid"; printf '  UNEXPECTED: a withdrawn declaration kept its pane quiet\n'
fi
drain_and_ack
poll_survives
printf '  reminder throttle retired? %s\n' \
  "$([ -e "$STATE/.merge-wait-resurfaced-$KEY" ] && echo 'no - marker still present' || echo yes)"

###############################################################################
say 'STEP 8  the measured cost, counted: one alarm per new pane hash, before and after'
###############################################################################
# A green ship task's own last status line is captain-relevant ("done: PR ... checks
# green"), so its idle pane reaches the terminal branch and is surfaced ONCE PER NEW
# HASH. A pane with a clock, a token counter, or any other churn therefore alarms
# every time it settles - the 90-to-150-second cadence measured on 2026-08-20, each
# alarm costing firstmate a handling turn. Counted here both ways.
count_alarms() {  # <label> <tag>
  local label=$1 tag=$2 i alarms=0 pid
  for i in 1 2 3; do
    printf '%s' "firstmate ship task - PR #4242 is green, waiting on the maintainer ($tag-0$i)" > "$PANE"
    rm -f "$STATE/.hash-$KEY" "$STATE/.count-$KEY"
    pid=$(fresh_round "$WORK/8-$tag-$i.out" FM_PAUSE_RESURFACE_SECS=999 FM_STALE_ESCALATE_SECS=999)
    if wait_exit "$pid" 120; then
      alarms=$((alarms + 1))
      printf '    hash %s -> WOKE firstmate: %s\n' "$i" "$(cat "$WORK/8-$tag-$i.out")"
    else
      stop "$pid"
      printf '    hash %s -> no wake\n' "$i"
    fi
    drain_and_ack quiet
  done
  printf '  %s: %s alarm(s) across 3 distinct pane hashes\n' "$label" "$alarms"
}
printf '  (a) with NO declaration:\n'
count_alarms 'undeclared' u
printf '\n  (b) with the declaration in place:\n'
fm fm-merge-wait.sh declare "$TASK" 'upstream maintainer owns this merge' 2>&1 | sed 's/^/    /'
count_alarms 'declared  ' d
triage_tail
poll_survives

###############################################################################
say 'STEP 9  the merge finally lands: the poll the declaration never silenced reports it'
###############################################################################
# The single cover this feature relies on. The declaration is live and absorbing
# wedge alarms, and the merge poll is still armed - so when the maintainer merges,
# the poll is what wakes firstmate.
printf '  (the maintainer merges it; the forge now answers MERGED)\n'
export FM_FAKE_GH_STATE=MERGED
rm -f "$STATE/.last-check"    # the check cadence comes due
printf '  declaration still live? %s\n' \
  "$([ -e "$STATE/$TASK.merge-wait" ] && echo yes || echo no)"
pid=$(fresh_round "$WORK/9.out" FM_PAUSE_RESURFACE_SECS=999 FM_STALE_ESCALATE_SECS=999 FM_CHECK_INTERVAL=1)
if wait_exit "$pid" 300; then
  printf '  firstmate is woken by the merge poll, not by a wedge alarm:\n'
  sed 's/^/    > /' "$WORK/9.out"
else
  stop "$pid"; printf '  UNEXPECTED: the merge was never reported while the declaration held\n'
fi
drain_and_ack
unset FM_FAKE_GH_STATE

say 'END'
Evidence: Round 3 evidence summary, mapping each intent requirement to the step that shows it
Round 3 test phase - what was exercised and what it showed
==========================================================
Host condition for every measurement below: 32 cores at load average ~68-70
(65 orphaned load spinners left by an earlier round, already reported).

1. Targeted automated tests
---------------------------
tests/fm-watch-triage.test.sh, the 24 declared-merge-wait and absorbed-alarm
cases, run as a subset of the real file (definitions verbatim, only the
invocation list narrowed):
  24 ok, 0 not ok, 5m20s.  -> merge-wait-tests.log

tests/fm-watch-triage.test.sh, the 6 cases whose timing the previous two rounds
changed (the 30 -> 100 tick budget alignment, the wait_absent write-chain
synchronization, and the bounded reap):
  7 consecutive runs (1 + 6), 42 ok, 0 not ok, no hang.
  -> timing-fix-tests-round1.log, timing-rep-1..6.log
  Before the wait_absent fix the same file failed 2 runs in 8 on this host.

tests/fm-teardown.test.sh, test_teardown_retires_a_declared_merge_wait:
  1 ok.

2. Non-vacuity re-checks (independent of the previous rounds' own)
-----------------------------------------------------------------
Deleted from bin/fm-watch.sh:
    if [ -e "$STATE/.merge-wait-resurfaced-$key" ]; then
      merge_wait_declared "$task" "$STATE" || clear_merge_wait_markers "$key"
    fi
  -> not ok - the withdrawn declaration kept its reminder throttle
     (test_merge_wait_withdrawal_retires_the_reminder_throttle)

Deleted from wedge_timer_check's repair branch:
    clear_write_tracking "$(window_key "$win")"
  -> not ok - an idle-window timer repair kept a finished write-deferral chain
     (test_timer_repair_drops_a_finished_write_deferral_chain; confirms the new
     wait_absent synchronization did not make that assertion unfailable)

bin/fm-watch.sh restored to HEAD after each; git status clean.

3. End-to-end product demonstration
-----------------------------------
merge-wait-e2e-demo.sh / merge-wait-e2e-transcript.txt

Driven through the real bin/fm-merge-wait.sh, bin/fm-pr-check.sh,
bin/fm-crew-state.sh, bin/fm-classify-lib.sh, bin/fm-watch.sh and
bin/fm-wake-drain.sh. The only stubs are two external programs: `tmux` (canned
pane capture) and the `no-mistakes` CLI (one canned `axi status` run object with
outcome: checks-passed), plus `gh`, which stays silent until STEP 9 lands the
merge. Notably the authoritative crew-state verdict is NOT stubbed here, unlike
in the unit tests, so the real reader is the one that emits
"checks green: PR ready for review" and the real classifier is the one that
prefix-matches it - the FM_CLASSIFY_CHECKS_GREEN_DETAIL coupling proved
writer-to-reader rather than assumed.

Mapped onto the intent's stated requirements:
  aging stops without closing the task, exiting the worker, or removing the
  merge poll ............................................. STEP 4, 8b, 9
  not at a terminal checks-green outcome -> still ages .... STEP 6
  declaration present but PR not green or not the recorded
  one -> not silenced .................................... STEP 6, 6b
  merge poll must keep running, declare refuses without an
  armed one ............................................. STEP 2, and STEP 9,
                                                          where the still-armed
                                                          poll reports the merge
                                                          while the declaration
                                                          is live
  no blanket classification of every checks-green task ... STEP 1, 8a (three
                                                          alarms in three hashes
                                                          with no declaration)
  suppression bounded, never permanent silence ........... STEP 5
  withdrawal releases the pane ........................... STEP 7

4. One test added this round
----------------------------
tests/fm-crew-state.test.sh: test_checks_passed_outcome_admits_a_declared_merge_wait
Gap it closes: `outcome: checks-passed` had no fixture anywhere in the suite, so
the one outcome a declaration may be admitted under was never driven through the
real reader. Every merge-wait case in tests/fm-watch-triage.test.sh builds its
canned verdict line from FM_CLASSIFY_CHECKS_GREEN_DETAIL, so they pin the reader
of that constant while assuming its writer.
Non-vacuity, both directions, proved by hardcoding a drifted literal on that
branch of bin/fm-crew-state.sh ("checks green: PR ready to review"):
  new test                                        -> not ok
  test_terminal_stale_merge_wait_absorbs_each_new_hash -> still ok
which is exactly the blindness the new test removes.
Whole file after the addition: 54 ok, 0 not ok, 34s.
bin/fm-crew-state.sh restored; git status shows only the test file changed.
- Evidence: Persisted state after the demo (declaration record, merge poll artifacts, watcher triage log) (local file: /tmp/no-mistakes-evidence/01M0M20RC149A8DXDPT5JZ6RNV/e2e-workdir/state)
Evidence: The 24 declared-merge-wait cases
ok - merge_wait_declared: admitted only with a record naming the currently recorded pull request and that pull request's own armed merge poll
ok - crew_absorb_class: merge-wait only from the run step's green outcome plus a live declaration; every other outcome still ages
ok - a declared merge wait absorbs the stale wake on its first hash and on every later hash, so a churning green pane stops alarming
ok - an absorbed merge wait passes exactly one reminder through per PAUSE_RESURFACE_SECS, naming the merge as someone else's to make
ok - a checks-green task with no declaration is surfaced exactly as before, so green alone never silences a pane
ok - a merge-wait refusal lasts exactly one alarm, so a transient unreadable verdict cannot permanently void a live declaration
ok - a declaration whose authoritative verdict is freshly not green alarms on that very attempt, so no cached verdict can buy silence
ok - a declaration naming a superseded pull request, or whose merge poll is gone, still alarms and costs no read beyond classification
ok - a declaration is admitted only while THIS task's merge poll is armed, so a custom check in that slot alarms like no declaration at all
ok - suppressed alarms do not inflate the wedge-escalation count, so the first genuine escalation starts from 1
ok - a merge wait declared after its pane was already surfaced goes quiet on the next poll, without waiting for the pane to change
ok - a withdrawn declaration is honored at the next alarm attempt, returning its pane to ordinary aging
ok - withdrawing a declaration retires its reminder throttle, so a later declaration on the same window keeps its own first reminder
ok - a crew's own declared pause outranks a live merge-wait record, keeping its exemption from wedge aging and its own external-wait wording
ok - a declared pause's one immediate surface is never absorbed by the same task's merge-wait declaration
ok - a verified captain-held transfer keeps its own awaiting-the-captain recheck even when the task carries an admissible merge-wait record
ok - a declared merge wait never absorbs the busy-pane completed-turn bound, which reports a possible hung call rather than a pending merge
ok - the away-mode gate refuses an otherwise admissible alarm before any status, record or crew-state read
ok - away mode absorbs no wake and runs no crew-state read even with a live admissible declaration
ok - the idle-pane wedge window is still absorbed by a live declaration, so carving out the busy class did not narrow the feature
ok - the bounded reminder also passes through from the non-terminal wedge window, not only from a first sighting
ok - an absorbed idle-stale alarm leaves a busy-turn escalation count intact, so a hung call still reaches demand-deep-inspection
ok - an absorbed idle-stale wedge alarm records no count at all, so a later genuine escalation starts from 1
ok - an absorbed idle-stale wedge alarm writes nothing, so a busy-turn count survives and its hung-call chain still reaches demand-deep-inspection
Evidence: Timing-sensitive cases, 7 consecutive runs at load average 70

run 1 exit=0 run 2 exit=0 run 3 exit=0 run 4 exit=0 run 5 exit=0 run 6 exit=0 === aggregate === 6 ok - provably-working non-terminal stale is absorbed on first sight, then wedge-escalated past the threshold 6 ok - matching non-terminal stale suppressors repair missing or corrupt stale-since timers 6 ok - both first-sight paths through a captain-relevant status drop a finished write-deferral chain with the idle window 6 ok - an idle-window timer repair drops a finished write-deferral chain, so the next deferral gets a fresh re-surface window 6 ok - a write deferral re-surfaces once on the bounded pause cadence, so a churning worktree cannot stay invisible 6 ok - a quiet pane writing its own worktree is deferred, while one writing nothing still wedge-escalates on the unchanged schedule

ok - provably-working non-terminal stale is absorbed on first sight, then wedge-escalated past the threshold
ok - matching non-terminal stale suppressors repair missing or corrupt stale-since timers
ok - a quiet pane writing its own worktree is deferred, while one writing nothing still wedge-escalates on the unchanged schedule
ok - a write deferral re-surfaces once on the bounded pause cadence, so a churning worktree cannot stay invisible
ok - an idle-window timer repair drops a finished write-deferral chain, so the next deferral gets a fresh re-surface window
ok - both first-sight paths through a captain-relevant status drop a finished write-deferral chain with the idle window
Evidence: tests/fm-crew-state.test.sh after the added case

Source: tests/fm-crew-state.test.sh after the added case (local file: /tmp/no-mistakes-evidence/01M0M20RC149A8DXDPT5JZ6RNV/crew-state-file.log)

ok - a real checks-passed run plus a real declaration classifies merge-wait, and the run alone classifies none
...
all fm-crew-state tests passed (54 ok, 0 not ok, 34s)
- Outcome: 🔧 3 issues found → auto-fixed (2) ✅ across 3 runs (2h58m33s)

Pipeline

Updates from git push no-mistakes

... (14 earlier update rounds omitted to keep the PR body within GitHub's 65536-char limit; full history is in the run log.)

🔧 **Test** - 3 issues found → auto-fixed (2) ✅

🔧 Fix: test: synchronize write-chain assertion on the file it asserts
✅ Re-checked - no issues remain.

  • bash tests/ztmp-mergewait.test.sh (a subset runner over tests/fm-watch-triage.test.sh definitions with only the invocation list narrowed to the 24 declared-merge-wait and absorbed-alarm cases): 24 ok, 0 not ok, 5m20s
  • The 6 timing-sensitive cases the previous rounds changed (test_nonterminal_stale_provably_working_absorbed_then_escalated, test_nonterminal_stale_repairs_missing_or_corrupt_timer, test_wedge_escalation_deferred_while_worktree_is_written, test_write_deferral_resurfaces_on_the_bounded_cadence, test_timer_repair_drops_a_finished_write_deferral_chain, test_terminal_first_sight_drops_a_finished_write_deferral_chain) run 7 times consecutively at load average ~70: 42 ok, 0 not ok, no reap hang
  • tests/fm-teardown.test.sh -> test_teardown_retires_a_declared_merge_wait: 1 ok
  • bash tests/fm-crew-state.test.sh (whole file, after adding the new case): 54 ok, 0 not ok, 34s
  • Non-vacuity: deleted merge_wait_declared &#34;$task&#34; &#34;$STATE&#34; || clear_merge_wait_markers &#34;$key&#34; from bin/fm-watch.sh -> not ok - the withdrawn declaration kept its reminder throttle (test_merge_wait_withdrawal_retires_the_reminder_throttle), then restored
  • Non-vacuity: deleted clear_write_tracking &#34;$(window_key &#34;$win&#34;)&#34; from wedge_timer_check's repair branch -> not ok - an idle-window timer repair kept a finished write-deferral chain, then restored, confirming the new wait_absent wait did not make that assertion unfailable
  • Non-vacuity of the added test: hardcoded a drifted literal on bin/fm-crew-state.sh's checks-passed branch -> new test not ok while test_terminal_stale_merge_wait_absorbs_each_new_hash stayed ok, then restored
  • End-to-end manual verification: EV=... ROOT=$PWD bash /tmp/no-mistakes-evidence/01M0M20RC149A8DXDPT5JZ6RNV/merge-wait-e2e-demo.sh driving the real bin/fm-pr-check.sh, bin/fm-crew-state.sh, bin/fm-merge-wait.sh (declare/show/clear), bin/fm-watch.sh and bin/fm-wake-drain.sh over a real git worktree and a real outcome: checks-passed run, with only tmux, the no-mistakes CLI and gh stubbed
🔧 **Document** - 2 issues found → auto-fixed (3) ✅
  • ℹ️ docs/architecture.md:35 - docs/architecture.md lines 33-37 carry a second full copy of the accepted-limit contract (reminder scope, the merge poll as the single cover, why the heartbeat review is not a second cover, the poll-died exposure, and the terminal-outcome immutability rationale) that merge_wait_absorbs_alarm's header in bin/fm-watch.sh explicitly claims to own: 'THE ACCEPTED LIMIT, stated once and in full, because every other place that touches it points here rather than restating it'. Both copies are currently accurate and one is test-pinned (tests/fm-watch-triage.test.sh:2140 asserts the architecture.md reminder claim), so I left the prose intact rather than deleting accepted maintainer-architecture safety rationale. Judgment call for the author: reduce those lines to a short statement plus a pointer to the implementation header, or move the uniqueness claim out of the code comment. Out of scope to decide here.
  • ℹ️ tests/fm-watch-triage.test.sh:2015 - Observed while verifying my own comment edit, not caused by it and not a documentation gap: bin/fm-lint.sh exits 1 on this branch because of SC2016 (info) at tests/fm-watch-triage.test.sh:2015, in the single-quoted bash -c body of merge_wait_suppressor_verdict. The file is unmodified in the working tree, so the finding is in committed test code. shellcheck is clean on both files I touched. Reported only so it is not lost; the lint phase owns it and tests are outside this phase's edit remit.

🔧 Fix: point architecture.md at the accepted-limit owner
3 infos still open:

  • ℹ️ tests/fm-watch-triage.test.sh:2140 - Part 2's premise did not hold, reported rather than assumed. tests/fm-watch-triage.test.sh:2140 is a comment header, not an assertion: it is the rationale block above test_merge_wait_reminder_passes_through_from_the_wedge_window, and no test anywhere in tests/ reads the bytes of docs/architecture.md or of any bin/fm-watch.sh comment (verified by grepping the whole suite for reads of those paths). So there was no pin to re-aim, and part 3's 'delete the claim, watch the assertion fail' could not be performed. I ran the inverse demonstration instead: with the entire accepted-limit paragraph including the exclusivity claim deleted from the header, the full file passed 84/84 exit 0, identical to the baseline run, and the wedge-window reminder test passed in both. On which claim it cited: the wording 'the bounded reminder wherever an alarm attempt recurs' appears at BOTH docs/architecture.md:12 and the now-removed line 33, so it was ambiguous between them; I re-aimed the citation at merge_wait_absorbs_alarm's header, which is now the single owner of that reminder's reach. Remaining gap for you to decide: if you want the header claim genuinely pinned, that means NEW test code (a case asserting bin/fm-watch.sh still contains the claim sentence), which is executable test authoring and outside this phase's edit remit. Proposed as follow-up for the test phase rather than done here.
  • ℹ️ docs/configuration.md:681 - The uniqueness audit's one unresolved touchpoint, left alone under your explicit instruction. docs/configuration.md:681 still enumerates the reminder's reach in the FM_PAUSE_RESURFACE_SECS entry ('wherever an alarm attempt recurs: a pane whose hash keeps changing, or the non-terminal wedge window, and not a byte-static pane on the terminal branch, which attempts no alarm at all once its hash is recorded'), which restates the first face of the accepted limit without pointing at the header. You directed me not to disturb line 681, and trimming the enumeration to a pointer would edit it, so I did not. My judgment on why the header's claim survives this: line 681 states only the setting's own scope, not the limit, since it says nothing about the merge poll as sole cover, the heartbeat non-cover, the residual exposure, or the terminal-outcome immutability, and the guidelines allow one deliberate reinforcement at a genuine risk point (an operator reading this setting would otherwise take the reminder as unconditional). If you want the header's sole-ownership claim absolute, the edit is to keep 'wherever an alarm attempt recurs' and replace the trailing enumeration with a pointer to merge_wait_absorbs_alarm, which preserves the qualification the previous round established. That is your call, not mine, because it touches the line you protected.
  • ℹ️ bin/fm-classify-lib.sh:1236 - Audited and judged compliant, recorded so the uniqueness check is auditable rather than implied. bin/fm-classify-lib.sh:1234-1236, in merge_wait_poll_armed's header, says 'On a byte-static terminal pane that is silence with no cover at all, since the armed poll is the only cover the suppressor claims (see merge_wait_absorbs_alarm in bin/fm-watch.sh).' That touches two faces of the limit but in a single clause, as the reason this predicate must not be weakened back to a bare -f check.sh test, and it names the owner in the same sentence. That is the one-line reinforcement at a genuine risk point the knowledge-placement policy permits, not a restatement, so I left it. No other place in bin/ or docs/ touches the accepted limit: bin/fm-watch.sh's later reconciliation comment already points at the header and says so explicitly, bin/fm-merge-wait.sh's header states only its own refusal rationale, and AGENTS.md states only the admission gates.

🔧 Fix: designate the accepted-limit owner instead of asserting exclusivity
2 infos still open:

  • ℹ️ docs/architecture.md:33 - Part 3 answered, no edit made: I read docs/architecture.md:33-34 and bin/fm-watch.sh:1364-1367 the same way you do, so both stand as they are. architecture.md:33 says the limit 'has exactly one owner: the merge_wait_absorbs_alarm header in bin/fm-watch.sh states in full ...', which is a designation of ownership plus a claim about the pointed-at text; line 34's 'Read it there rather than here' is an instruction about this document only. fm-watch.sh:1364-1367 says the header 'states in full: ... Read it there rather than restating any of it here', which likewise constrains only its own site. Neither asserts anything about files a future editor could change without notice, so neither can silently become false the way the two deleted clauses could. Recorded so the reading is auditable rather than implied.
  • ℹ️ bin/fm-watch.sh:426 - One judgment call the new imperative creates, for you to settle rather than me: it reads 'DO NOT restate any of it elsewhere, in code or in docs', with no carve-out, while bin/fm-classify-lib.sh:1234-1236 carries the single clause you authorized as it stands ('since the armed poll is the only cover the suppressor claims (see merge_wait_absorbs_alarm in bin/fm-watch.sh)'). My reading is that the clause obeys the rule, because it names the owner in the same sentence and gives one reason this predicate must not weaken back to a bare -f check.sh test rather than stating the contract, so it is a pointer with local context and not a restatement. But a future reader applying the imperative literally could read it as licence to delete that clause, which would cost the reason the gate exists. I did not add a carve-out because your instruction was to write the designation and the imperative 'and nothing more', and widening the rule myself is exactly the drift you are guarding against. If you want the tension closed, the edit is one clause in the imperative permitting a single-sentence reinforcement that names this owner.

🔧 Fix: rescope accepted-limit imperative from restating to adding
✅ Re-checked - no issues remain.

🔧 **Lint** - 1 issue found → auto-fixed ✅
  • ⚠️ linter found issues (exit code 1)

🔧 Fix: narrowly suppress false-positive SC2016 in merge-wait suppressor helper
✅ Re-checked - no issues remain.

✅ **Push** - passed

✅ No issues found.

…perform

A ship task whose checks are green keeps its worker, its branch and its merge
poll by design: the work is committed and not landed, so teardown is refused and
would remove the very poll that will notice the merge. Its idle pane therefore
re-wedged on every new pane hash, measured at one alarm every 90 to 150 seconds
on 2026-08-20, each costing a supervision turn. With four such pull requests
open at once this was the single largest source of pointless wakes.

Neither obvious escape works. A `paused:` status line loses to the still-open
run, because the absorb classification reads authoritative crew state and the
run step outranks the status log. Tearing the task down is forbidden and would
disarm the poll.

So this adds a declared, durable acknowledgement at the classifier:

- bin/fm-merge-wait.sh writes state/<id>.merge-wait, refusing unless the task
  records a pull request AND its merge poll is armed, and re-verifying that the
  classifier actually admits what it wrote.
- fm-classify-lib.sh's merge_wait_declared is a pure read of that record; the
  new merge-wait token comes out of crew_absorb_class's SAME single crew-state
  read, admitted only from the run step's own terminal checks-green outcome.
- fm-watch.sh absorbs that stale on the long re-surface cadence, anchored on the
  declaration's own mtime, and re-reads authoritative state at most once per
  wedge window so a pull request that stops being green returns to ordinary
  aging with its declaration still on disk.
- Withdrawing the declaration releases the absorbed pane hash with it, so a
  window never stays quiet on a declaration that no longer holds.
- Teardown removes the record with the rest of the task's state.

Deliberately narrow. Green alone never silences a pane: on 2026-08-21 three
merge watches were found pointing at heads that no longer existed and one merge
had already landed unnoticed. The merge poll is never touched, and gating the
declaration on it means a declaration can never silence more than that poll
already covers.

The checks-green detail string becomes a shared constant, because it is the
byte-for-byte coupling between the reader that writes it and the classifier that
matches it.

Tests extend the colocated classifier suite: the declared wait absorbed and
re-surfaced, not absorbed when the run is not at that outcome, not absorbed when
the recorded pull request is missing or changed, an undeclared green task aging
exactly as before, a withdrawn declaration releasing its pane, and the merge
poll surviving every one. Each assertion was proved non-vacuous by breaking the
corresponding code and watching it fail.
@greptile-apps

greptile-apps Bot commented Aug 22, 2026 •

Copy link
Copy Markdown

Confidence Score: 5/5

The PR appears safe to merge because no blocking failure remains within the eligible follow-up scope.

No blocking failure remains.

Reviews (2): Last reviewed commit: "no-mistakes(lint): narrowly suppress fal..." | Re-trigger Greptile

@Inthuson
Inthuson force-pushed the fm/ship-done-merge-wait-declaration-d7 branch from 4c1f480 to 874580b Compare August 22, 2026 18:54
dnth added a commit to dnth/firstmate that referenced this pull request Sep 11, 2026
#133)

* fix(bin): accept converged synchronized runs in validation binding

A correctly attributed no-mistakes run that passed its required checks
stays active only to monitor its open PR. In that state `axi status --run`
still reports status running with no outcome and no branch_sync block,
while `axi sync --check` reports the branch converged: state synchronized,
safety already_synchronized, relation equal, and the pipeline block naming
the same run, its submitted head, and its pushed-back current head. Both
completion paths required pipeline_owned, so the bound run could never
bind or seal its own advance (observed on fm-omp-orchestrate-opt-in run
01M25YRZ7NP7HVZ33K1XC94AYN, PR #131: submitted 5d52705,
pushed c096921, checks green, still monitoring).

Add fm_nm_run_branch_ownership to bin/fm-nm-run-lib.sh as the one owner of
active-run branch evidence, used by both --bind-run and --complete. It
keeps pipeline_owned acceptance unchanged and additionally accepts a
synchronized state only on the full sync readout: same run id,
submitted_head resolving to the validated head, current_head and the
reported local head resolving to the run's observed head, relation equal,
and safety already_synchronized. Checks-green readiness stays a separate
requirement, so a merely synchronized-but-not-passed run still refuses.

Upstream kunchenguid/firstmate has no fix to port: fm-receipt-check.sh is
fork-local and upstream's diverged fm-nm-run-lib.sh has no synchronized
handling. Adjacent upstream repairs (kunchenguid#2881, kunchenguid#3681, kunchenguid#3704, kunchenguid#3846) cover
other read paths; open upstream PR kunchenguid#2799 confirms the checks-green
merge-wait state but targets the supervision classifier, not completion.

Extend tests/fm-receipt-check.test.sh with the converged bind+complete
path and negative controls for foreign run ids, mismatched submitted
heads, incomplete convergence fields, stale local heads, and
synchronized-with-pending-CI completion.

* no-mistakes(document): Correct run-owned branch wording in verification evidence
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant