Skip to content

fix(bin): contain test lane execution, add sync-axi and agy adapters, and harden crew supervision - #2639

Open
BohnBawerick wants to merge 67 commits into
kunchenguid:mainfrom
BohnBawerick:fm/fm-racy-watcher-lock-test
Open

fix(bin): contain test lane execution, add sync-axi and agy adapters, and harden crew supervision#2639
BohnBawerick wants to merge 67 commits into
kunchenguid:mainfrom
BohnBawerick:fm/fm-racy-watcher-lock-test

Conversation

@BohnBawerick

Copy link
Copy Markdown

Intent

Make the Firstmate test lane safe: stop it hanging forever, stop it leaking processes, and fix the test that goes red at random. This is the piece that makes our pipeline trustworthy, so nothing else about it can be taken on faith.

Three defects, one family.

  1. The random red check. tests/fm-watcher-lock.test.sh::test_pid_identity_is_locale_invariant is racy by construction: it launches "sleep 300 &", captures the pid, then samples /proc//cmdline twice and compares. The first sample can land inside the fork window, before execve has replaced the image, so it reads the forking shell's own command line, while the second, milliseconds later, reads the exec'd image. It passes locally and fails on a cold runner that widens the fork window. Fix: do not sample the child until execve has completed - wait for the exec'd image to settle before taking the first sample, with a bounded timeout that fails loudly rather than sampling early. Do not paper over it by comparing only the locale-formatted prefix; the point of the test is the pid identity string. Prove it by artificially widening the window (sampling immediately after the fork with no settle wait), showing red, then showing green with the settle wait, repeated enough times to mean something.

  2. The leaked process, reproduced from two different test files, so it is a family problem needing a shared fix. A lane runner (bin/fm-test-run.sh --lane portable-serial-3of4) was still alive after 7h23m with its output frozen, holding a test-started watcher that never exits. The lane's last output before it froze was a diagnostic from bin/fm-watch.sh: "trap: line 2: unexpected EOF while looking for matching )". That trap diagnostic is the load-bearing clue and must be explained, not worked around. Separately, tests/fm-watch-triage.test.sh left an orphan reparented to init with its scratch state directory already deleted, so its cleanup had run and only the process outlived it; treat that as possibly a second, different cause and diagnose it on its own evidence rather than assuming one root cause covers both.

  3. The wedge mechanism, the worst of the three. The orphan's file descriptor 2 was still the lane's stdout pipe, the same pipe the lane's tee read from, confirmed by identical pipe inodes. So tee never saw end of file, the lane runner blocked on tee forever, and the suite made no further progress with no diagnostic at all. One orphan is enough to hang a whole lane silently and indefinitely.

Required, and none of these substitutes for another:

  1. Do not leak the process. Every watcher a test starts is reaped by that test before it exits, verified by pid.
  2. Do not let a leaked process hold the lane. A test's children must not inherit the lane's stdout/stderr pipe, or the lane must not block on a descendant it did not intend to wait for. This makes the failure survivable even when requirement 1 regresses, which is why it is not optional.
  3. A per-script timeout in the lane that fails loudly naming the script, rather than hanging. A lane that can sit for seven hours with no output is its own defect regardless of what hung it.
  4. The fork/execve fix in requirement 1 above.

Scope. This change owns bin/fm-test-run.sh's lane safety (reaping, pipe inheritance, per-script timeout) and the two watcher test files. A sibling task owns the other half of the suite problem: triaging the scripts that fail on this machine under load, and making individual slow scripts faster. Findings that clearly belong to that sibling are written into the PR body rather than fixed here.

Constraints. Do not weaken an assertion to make a test green; a test that no longer proves its contract is worse than a slow one. Colocate tests with the existing pattern in tests/, name them .test.sh, and extend an existing script rather than inventing a new runner. Tests must exercise behaviour through an executable interface, never assert implementation-source bytes. Never pattern-kill: no pkill -f, no killall, because other lanes share this machine's process table and a pattern kill has already reached into a sibling lane; reap by pid. bin/fm-lint.sh must pass. One full sentence per line in tracked Markdown, plain dash and never an em dash, and no agent co-author on commits.

What Changed

  • Contained test runner execution in bin/fm-test-run.sh with process group isolation, private file output redirection, per-script timeouts, and automatic process tracking and reaping in tests/lib.sh (fm_test_track_pid, fm_test_wait_exec_settled).
  • Added Antigravity (agy) harness adapter support with turn-end hook integration in bin/fm-agy-turnend-hook.sh and introduced the /sync-axi synchronization tool and skill (bin/fm-sync-axi.sh, .agents/skills/sync-axi/SKILL.md).
  • Hardened daemon supervision and turn-end guard against false alarms during away mode, prevented task ID reuse in bin/fm-spawn.sh, corrected crew validation state reporting in bin/fm-crew-state.sh, and updated local branch landing workflows in bin/fm-merge-local.sh.

Risk Assessment

✅ Low: The test lane containment, per-script timeout, process group reaping, and execve settlement changes are well-bounded, thoroughly tested, and all prior review findings have been resolved.

Testing

Executed the full set of targeted test suites (tests/fm-test-run.test.sh, tests/fm-watcher-lock.test.sh, tests/fm-watch-triage.test.sh) and generated empirical verification artifacts covering fork/exec settling invariance, process containment without pipe leaks, per-script timeouts, and test-helper cleanup, with all tests passing cleanly.

Evidence: Fork/Exec Settling Comparison

Source: Fork/Exec Settling Comparison

=== Demonstration: Fork-to-exec settling window === Widening fork-to-exec window with bash -c 'sleep 0.25; exec sleep 300' Running 20 iterations with IMMEDIATE sampling (no settle wait): iter 1: sample1 != sample2 (diverged mid-exec) iter 2: sample1 != sample2 (diverged mid-exec) iter 3: sample1 != sample2 (diverged mid-exec) Immediate sampling divergence: 20 / 20 iterations (FAILED / RED) Running 20 iterations with fm_test_wait_exec_settled (with settle wait): Settled sampling divergence: 0 / 20 iterations (PASSED / GREEN)

=== Demonstration: Fork-to-exec settling window ===
Widening fork-to-exec window with bash -c 'sleep 0.25; exec sleep 300'

Running 20 iterations with IMMEDIATE sampling (no settle wait):
  iter 1: sample1 != sample2 (diverged mid-exec)
  iter 2: sample1 != sample2 (diverged mid-exec)
  iter 3: sample1 != sample2 (diverged mid-exec)
Immediate sampling divergence: 20 / 20 iterations (FAILED / RED)

Running 20 iterations with fm_test_wait_exec_settled (with settle wait):
Settled sampling divergence: 0 / 20 iterations (PASSED / GREEN)
Evidence: Lane Leak Containment & Output Follower Evidence

Source: Lane Leak Containment & Output Follower Evidence

=== Demonstration: Lane containment of leaked processes === Running leaky.test.sh (leaves background sleep 600 holding stdout) followed by after.test.sh FM_TEST_BEGIN 2026-08-19T18:03:07Z /tmp/demo-lane-leak.bZoP4b/leaky.test.sh family=unclassified expected_gate_skip=none ok - leaky test started ok - leaky test finished its assertions fm-test-run: reaping processes /tmp/demo-lane-leak.bZoP4b/leaky.test.sh left behind (group 49791) FM_TEST_LEAK /tmp/demo-lane-leak.bZoP4b/leaky.test.sh pgid=49791 FM_TEST_END 2026-08-19T18:03:13Z /tmp/demo-lane-leak.bZoP4b/leaky.test.sh exit=0 duration_ms=5476 gate_skip=false FM_TEST_BEGIN 2026-08-19T18:03:13Z /tmp/demo-lane-leak.bZoP4b/after.test.sh family=unclassified expected_gate_skip=none ok - after script executed successfully FM_TEST_END 2026-08-19T18:03:13Z /tmp/demo-lane-leak.bZoP4b/after.test.sh exit=0 duration_ms=160 gate_skip=false FM_TEST_SUMMARY total=2 failed=0 skipped_gate=0 duration_ms=5813

=== Demonstration: Lane containment of leaked processes ===
Running leaky.test.sh (leaves background sleep 600 holding stdout) followed by after.test.sh

FM_TEST_BEGIN 2026-08-19T18:03:07Z /tmp/demo-lane-leak.bZoP4b/leaky.test.sh family=unclassified expected_gate_skip=none
ok - leaky test started
ok - leaky test finished its assertions
fm-test-run: reaping processes /tmp/demo-lane-leak.bZoP4b/leaky.test.sh left behind (group 49791)
FM_TEST_LEAK /tmp/demo-lane-leak.bZoP4b/leaky.test.sh pgid=49791
FM_TEST_END 2026-08-19T18:03:13Z /tmp/demo-lane-leak.bZoP4b/leaky.test.sh exit=0 duration_ms=5476 gate_skip=false
FM_TEST_BEGIN 2026-08-19T18:03:13Z /tmp/demo-lane-leak.bZoP4b/after.test.sh family=unclassified expected_gate_skip=none
ok - after script executed successfully
FM_TEST_END 2026-08-19T18:03:13Z /tmp/demo-lane-leak.bZoP4b/after.test.sh exit=0 duration_ms=160 gate_skip=false
FM_TEST_SUMMARY total=2 failed=0 skipped_gate=0 duration_ms=5813
FM_TEST_SUMMARY_FAMILY family=unclassified count=2 duration_ms=5636 failed=0
FM_TEST_SLOWEST rank=1 script=/tmp/demo-lane-leak.bZoP4b/leaky.test.sh duration_ms=5476
FM_TEST_SLOWEST rank=2 script=/tmp/demo-lane-leak.bZoP4b/after.test.sh duration_ms=160
Evidence: Lane Per-Script Timeout Enforcement Evidence

Source: Lane Per-Script Timeout Enforcement Evidence

=== Demonstration: Lane per-script timeout enforcement === Running hang.test.sh under --script-timeout 3 followed by after.test.sh FM_TEST_BEGIN 2026-08-19T18:03:19Z /tmp/demo-lane-timeout.vyjOLM/hang.test.sh family=unclassified expected_gate_skip=none ok - script started and is now hanging... fm-test-run: per-script budget of 3s exceeded, terminating: /tmp/demo-lane-timeout.vyjOLM/hang.test.sh not ok - /tmp/demo-lane-timeout.vyjOLM/hang.test.sh exceeded the per-script budget of 3s and was terminated fm-test-run: reaping processes /tmp/demo-lane-timeout.vyjOLM/hang.test.sh left behind (group 54590) FM_TEST_LEAK /tmp/demo-lane-timeout.vyjOLM/hang.test.sh pgid=54590 FM_TEST_END 2026-08-19T18:03:22Z /tmp/demo-lane-timeout.vyjOLM/hang.test.sh exit=124 duration_ms=2917 gate_skip=false FM_TEST_BEGIN 2026-08-19T18:03:22Z /tmp/demo-lane-timeout.vyjOLM/after.test.sh family=unclassified expected_gate_skip=none ok - lane successfully proceeded to subsequent script FM_TEST_END 2026-08-19T18:03:22Z /tmp/demo-lane-timeout.vyjOLM/after.test.sh exit=0 duration_ms=167 gate_skip=false FM_TEST_SUMMARY total=2 failed=1 skipped_gate=0 duration_ms=3305

=== Demonstration: Lane per-script timeout enforcement ===
Running hang.test.sh under --script-timeout 3 followed by after.test.sh

FM_TEST_BEGIN 2026-08-19T18:03:19Z /tmp/demo-lane-timeout.vyjOLM/hang.test.sh family=unclassified expected_gate_skip=none
ok - script started and is now hanging...
fm-test-run: per-script budget of 3s exceeded, terminating: /tmp/demo-lane-timeout.vyjOLM/hang.test.sh
not ok - /tmp/demo-lane-timeout.vyjOLM/hang.test.sh exceeded the per-script budget of 3s and was terminated
fm-test-run: reaping processes /tmp/demo-lane-timeout.vyjOLM/hang.test.sh left behind (group 54590)
FM_TEST_LEAK /tmp/demo-lane-timeout.vyjOLM/hang.test.sh pgid=54590
FM_TEST_END 2026-08-19T18:03:22Z /tmp/demo-lane-timeout.vyjOLM/hang.test.sh exit=124 duration_ms=2917 gate_skip=false
FM_TEST_BEGIN 2026-08-19T18:03:22Z /tmp/demo-lane-timeout.vyjOLM/after.test.sh family=unclassified expected_gate_skip=none
ok - lane successfully proceeded to subsequent script
FM_TEST_END 2026-08-19T18:03:22Z /tmp/demo-lane-timeout.vyjOLM/after.test.sh exit=0 duration_ms=167 gate_skip=false
FM_TEST_SUMMARY total=2 failed=1 skipped_gate=0 duration_ms=3305
FM_TEST_SUMMARY_FAMILY family=unclassified count=2 duration_ms=3084 failed=1
FM_TEST_SLOWEST rank=1 script=/tmp/demo-lane-timeout.vyjOLM/hang.test.sh duration_ms=2917
FM_TEST_SLOWEST rank=2 script=/tmp/demo-lane-timeout.vyjOLM/after.test.sh duration_ms=167
Evidence: Test Helper Automatic Reaping on Failure Evidence

Source: Test Helper Automatic Reaping on Failure Evidence

=== Demonstration: Test helper automatic reaping on early failure === Executing test script that spawns background child and fails early... Spawning background child that ignores SIGTERM... Failing assertion deliberately before explicit reap... not ok - assertion failed as part of demonstration Checking if tracked child PID 57530 is still running: SUCCESS: Child process 57530 was cleanly reaped by tests/lib.sh EXIT trap.

=== Demonstration: Test helper automatic reaping on early failure ===
Executing test script that spawns background child and fails early...

Spawning background child that ignores SIGTERM...
Failing assertion deliberately before explicit reap...
not ok - assertion failed as part of demonstration
/home/paiva/.no-mistakes/worktrees/3437026af8a8/01M0DDW88W3ET2PSY02BFQ9JPA/tests/lib.sh: line 218: 57530 Killed                  bash -c 'trap "" TERM HUP; exec sleep 600'

Checking if tracked child PID 57530 is still running:
SUCCESS: Child process 57530 was cleanly reaped by tests/lib.sh EXIT trap.
Evidence: Targeted Test Suite Run Log

Source: Targeted Test Suite Run Log

=== Running Targeted Test Suite ===
FM_TEST_BEGIN 2026-08-19T18:03:35Z tests/fm-test-run.test.sh family=pure-contract-unit expected_gate_skip=none
ok - exact suite coverage: --all lists every tests/*.test.sh once
ok - family selection returns a proper subset of the suite
ok - single-script selection lists exactly that path
ok - changed-file selection stays conservative (never silent full suite)
ok - changed selection covers dependents and fails closed for unmapped source
ok - empty changed selection emits deterministic text and JSON summaries
ok - timing markers and JSON artifact are valid
ok - aggregate exit reflects any script failure
ok - gate-skip accounting is honest and non-failing
ok - fail-on-gate-skip converts herdr-not-found into a hard failure
ok - exclude-family drops the named primary family after selection
ok - portable shard union, disjointness, and coverage guard hold
ok - portable serial shards are a deterministic disjoint cover of the serial lane
ok - portable serial shard lanes refuse mismatched, out-of-range, and countless names
ok - --jobs refuses non-proven / stateful selections
ok - jobs scheduler runs proven scripts; failure propagates; non-proven refused
ok - Herdr CI family-run step times out at 20 min under a 75 min job backstop
ok - aggregate-json merges lane timing artifacts
ok - a leaked descendant is reaped by pid and the lane keeps running
ok - a script that stops making progress is terminated, named, and the lane continues
ok - a script's full stdout and stderr reach the lane in order
ok - a registered process is reaped even when the test fails before reaping it
ok - a registered process that is not one of the test shell's jobs is still reaped
ok - an interrupted lane reaps its follower and its script, and frees its stdout
FM_TEST_END 2026-08-19T18:04:30Z tests/fm-test-run.test.sh exit=0 duration_ms=54433 gate_skip=false
FM_TEST_BEGIN 2026-08-19T18:04:30Z tests/fm-watcher-lock.test.sh family=watcher-wake-lock expected_gate_skip=none
ok - simultaneous watcher starts leave exactly one live process
ok - fm_pid_identity real ps fallback is locale-invariant
ok - fm_pid_identity is locale-invariant across LC_ALL/LC_TIME
ok - pid identity sampling waits for execve and is stable once it has
ok - /proc process identity ignores simulated btime changes
ok - /proc process identity detects pid reuse
ok - MSYS /proc process identity regression skipped on non-Windows host
ok - killed watcher stale lock is reclaimed
tests/fm-watcher-lock.test.sh: line 1131: 10241 Killed                  FM_STATE_OVERRIDE="$state" bash -c '
    . "$1"
    fm_lock_remove_path() {
      if [ "$1" = "$STATE/.watch.lock" ]; then
        kill -KILL "${BASHPID:-$$}"
      fi
      return 1
    }
    fm_lock_try_acquire "$2"
  ' _ "$LIB" "$lockdir" > /dev/null 2>&1
ok - stale watcher reclaim publishes durable recovery evidence before clear
ok - live watcher lock with stale heartbeat is actionable
ok - guard banner leads when down with pending wakes (repair-after-drain) and stays silent when live and fresh
ok - concurrent fm_lock_try_acquire yields exactly one winner
ok - dead-pid stale lock is reclaimed by a single acquirer
ok - concurrent stale-lock steal yields exactly one winner
ok - live steal mutex is not reclaimed
ok - live-held lock is not stolen
ok - empty mid-acquire lock keeps a minimum grace
ok - late original claimant cannot claim a recreated lock
ok - paused mid-acquire claimant backs off to active stealer
ok - watch restart preserves recovery without signaling a reused pid
ok - watch restart attaches to a verified healthy peer and later surfaces a successor gap
ok - watcher self-evicts when the lock pid no longer names it
ok - arm turns clean self-eviction without a successor into a typed failure
ok - arm attaches to a live fresh watcher and fails loudly when that cycle has no successor
ok - attached arm signals record a classified lifecycle entry
ok - arm starts cleanly and resurfaces recovery after a dead-pid lock
ok - arm cleans child watcher and temp output on HUP
WAKE_ACK_REQUIRED: after handling completes run bin/fm-wake-drain.sh --ack-through 1 --recovery-generation 38279.1787162709.alZT2D
ok - arm propagates an immediate watcher wake before confirmation
ok - arm attaches to a peer watcher after child stands down and surfaces a missing successor
watcher: lock held by live pid 42183 but heartbeat is stale for 840506713s (>300s); inspect or stop that watcher before re-arming.
ok - arm reports FAILED and exits non-zero when no fresh watcher can be confirmed
ok - cycle-exit ledger links a verified successor and remains size-capped
ok - SIGSTOP distinguishes live PID from stale beacon and termination records the exit class
FM_TEST_END 2026-08-19T18:05:33Z tests/fm-watcher-lock.test.sh exit=0 duration_ms=63714 gate_skip=false
FM_TEST_BEGIN 2026-08-19T18:05:33Z tests/fm-watch-triage.test.sh family=watcher-wake-lock expected_gate_skip=none
ok - signal_reason_is_actionable: benign absorbed, captain verbs and coalesced batches surfaced
ok - stale_is_terminal: terminal status surfaces, non-terminal and no-status are benign
ok - scan_captain_relevant_statuses lists only captain-relevant statuses
ok - classifier primitives: keyed decisions and activity phases, captain relevance, window-to-task, and overrides
ok - crew_is_provably_working: only working+run-step/pane is provable; idle/finished/parked/failed/unknown surface
ok - status_is_paused: only the leading paused verb matches, and paused is not captain-relevant
ok - crew_absorb_class: working/paused/none from one read; crew_is_paused and crew_is_provably_working agree
ok - signal_crew_provably_working: benign only when every referenced crew is provably working
ok - a secondmate's status signal is never absorbed as provably working; crewmates are unaffected
ok - a no-verb signal whose crew is provably working is absorbed (no exit, no queue, suppressor advanced, beacon present)
ok - a bare turn-end whose crew is provably working (busy pane) is absorbed
ok - a bare turn-end whose crew is not provably working is surfaced (the swallowed-finish fix)
ok - a no-verb working: note whose crew is idle with no running pipeline is surfaced
ok - a secondmate's status note surfaces even while its own agent is busy
ok - a self-announced close never wakes its own home, and the next real note still does
ok - captain-relevant signal is surfaced (queue + exit) and marked surfaced
ok - a stale pane sitting on a terminal status is surfaced (queue + exit)
ok - a stale terminal-looking status is overridden and absorbed while a run is actively working, and wedge-escalated when not working
ok - provably-working non-terminal stale is absorbed and suppressed while working, then wedge-escalated when not working
ok - a stale pane whose harness lacks a semantic busy source (unknown) still wedge-escalates past the threshold
ok - consecutive wedge escalations on the same pane accumulate and demand deep inspection at the threshold
ok - a pane becoming active again resets the consecutive wedge-escalation counter
ok - a busy worker below the turn-age bound remains working with no escalation
ok - a busy worker with a stable pane hash still escalates once its completed-turn age reaches the bound
ok - a busy worker whose pane hash changes every poll still escalates once its completed-turn age reaches the bound
ok - touching a busy worker's completed-turn marker resets the age and prevents an old-age escalation
ok - repeated busy turn-age escalations reuse the existing escalation counter and demand deep inspection at the threshold
ok - the production default busy-turn-age bound is 3600s (5min under does not wedge, 66min over does)
ok - a not-provably-working non-terminal stale is surfaced immediately (never left to wait out the timer)
ok - a declared pause is absorbed on first sight, then re-surfaced as a recheck past the threshold, never wedge-escalated
ok - exited declared-pause and captain-held panes use bounded pause cadence while a live decision gate still surfaces once
ok - a declared paused secondmate re-surfaces on the bounded normal-mode cadence
ok - a non-paused secondmate retains normal stale suppression
ok - a resumed secondmate clears pause and stale tracking before stale exemption
ok - unchanged stale hashes reclassify when a crew enters or leaves pause
ok - a declared pause is periodically rechecked against authoritative active-run state
ok - a paused status overridden by authoritative working preserves its wedge timer and escalates when not working
ok - matching non-terminal stale suppressors repair missing or corrupt stale-since timers
ok - triage log capping handles wc byte counts with leading spaces
ok - a captured process-event result wakes a healthy watcher proactively, with no manual drain
ok - an unacknowledged process-event result re-drains until handling is acknowledged
ok - complete process-event queue keys map to distinct seen markers
WAKE_ACK_REQUIRED: after handling completes run bin/fm-wake-drain.sh --ack-through 1 --recovery-generation 37390.1787162933.3TFwRM
ok - queue revalidation, proactive output, and marker commit serialize with drain
/home/paiva/.no-mistakes/worktrees/3437026af8a8/01M0DDW88W3ET2PSY02BFQ9JPA/bin/fm-push-transition-lib.sh: line 96: echo: write error: Broken pipe
tests/wake-helpers.sh: line 277: 41324 Killed                  PATH="$dir/fakebin:$PATH" FM_HOME="$dir" FM_PROCEVENT_CLAIM_ROOT="$dir/claims" FM_CREW_STATE_BIN="$dir/fakebin/fm-crew-state.sh" FM_POLL=0.2 FM_SIGNAL_GRACE=1 FM_CHECK_INTERVAL=999999 FM_HEARTBEAT=999999 "$WATCH" > "$out"
tests/wake-helpers.sh: line 277: 43649 Killed                  PATH="$dir/fakebin:$PATH" FM_HOME="$dir" FM_PROCEVENT_CLAIM_ROOT="$dir/claims" FM_CREW_STATE_BIN="$dir/fakebin/fm-crew-state.sh" FM_POLL=0.2 FM_SIGNAL_GRACE=1 FM_CHECK_INTERVAL=999999 FM_HEARTBEAT=999999 "$WATCH" > "$out"
ok - surfacing failures replay until post-handling acknowledgement
ok - marker failure exits through the shared wake owner, releases its lock, and replays later
ok - a heartbeat with no captain-relevant change is absorbed and backs off the cadence
ok - heartbeat backstop fail-safe surfaces a captain-relevant status the per-wake path missed
ok - the liveness beacon stays fresh while the watcher absorbs benign wakes (fm-guard never false-alarms)
ok - with .afk present the watcher reverts to one-shot so the daemon owns triage (no double-triage)
ok - AFK changed paused panes hand off plain stale identities for daemon-owned pause triage
FM_TEST_END 2026-08-19T18:09:35Z tests/fm-watch-triage.test.sh exit=0 duration_ms=241593 gate_skip=false
FM_TEST_SUMMARY total=3 failed=0 skipped_gate=0 duration_ms=360017
FM_TEST_SUMMARY_FAMILY family=pure-contract-unit count=1 duration_ms=54433 failed=0
FM_TEST_SUMMARY_FAMILY family=watcher-wake-lock count=2 duration_ms=305307 failed=0
FM_TEST_SLOWEST rank=1 script=tests/fm-watch-triage.test.sh duration_ms=241593
FM_TEST_SLOWEST rank=2 script=tests/fm-watcher-lock.test.sh duration_ms=63714
FM_TEST_SLOWEST rank=3 script=tests/fm-test-run.test.sh duration_ms=54433

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

⏭️ **Rebase** - skipped
  • ⚠️ .agents/skills/afk/SKILL.md - branch carries 59 commit(s) that exist on your local main branch but were never pushed to origin/main; rebasing would bundle this unrelated work (93 file(s)) into the PR:
  • 55baba2 local: land fm/fm-recover-lost-pane-fix
  • 8d1ab17 local: land fm/fm-task-id-reuse
  • 2090ffd local: land fm/fm-validation-self-start
  • 04fcf59 local: land fm/fm-fork-main-stale
  • 8173043 fix: start no-mistakes validation from the worker after commit
  • 5e3079c fix(bin): distinguish unreadable away-mode panes from gone ones
  • 725e4c7 fix(bin): refuse task id reuse when data directory exists
  • 024a405 fix(delivery): allow firstmate tasks to land into authoritative local main
  • 7e09a25 local: land fm/fm-crewstate-false-failed
  • ec6f160 local: land fm/fm-brief-no-mistakes-cli
  • 85cbede no-mistakes(document): Update documentation for ternary code identity attribution rules
  • d35217b fix(crew-state): stop reporting a live validation run as failed
  • 2e87803 no-mistakes(review): Scope unstartable validation reporting rule in brief generator
  • 67e309e Merge remote-tracking branch 'origin/main'
  • e329d46 local: land fm/fm-turnend-guard-afk-false-blind
  • 2890197 local: land fm/fm-wedge-aging-ignores-busy
  • 416c0d3 fix(supervision): stop the turn-end guard alarming under away mode
  • e85be9e fix(supervision): suppress possible-wedge escalation while busy source affirmatively reports working
  • a5bc06b fix(brief): name the no-mistakes CLI, not a skill a crewmate cannot load
  • 84ef5d5 fix(sync-axi): check branch ancestry for unlanded work and expand --help
  • bf64cbb feat(sync-axi): add captain-invocable /sync-axi command and mechanics
  • 1b245e2 Merge remote-tracking branch 'origin/main'
  • f68fadb local: record fm/hz-agy-adapter as incorporated
  • 2c2b7b2 no-mistakes(document): propagate agy adapter facts into stale doc owners
  • 48952de no-mistakes(review): fix agy trust re-poll, raw-launch registry dir, derive teardown pointers
  • 8b88847 fix(ci): fail hung Herdr behavior runs in 20 minutes (ci: bound Herdr behavior shard with a 20-minute step timeout #2413)
  • 5af8c87 chore: ignore scratchpad/ at the repo root (fix: ignore home-root scratchpad directory #2359)
  • 11e34d1 no-mistakes(document): propagate agy adapter facts into stale doc owners
  • aa5ec4b local: pin agy to gemini-3.7-flash-high
  • cee7660 local: adopt agy crew harness ahead of upstream
  • 732f461 no-mistakes(review): fix agy trust re-poll, raw-launch registry dir, derive teardown pointers
  • 8c73e74 no-mistakes(review): document agy hook scanning every workspace path
  • e52ffb9 no-mistakes(review): scan all agy workspace paths, share binary resolver
  • ad03306 no-mistakes(review): use portable home path in agy binary doc row
  • fea8efb no-mistakes(review): correct agy composer doc and raw-launch test comment
  • e6e1520 no-mistakes(review): bind agy launch binary in its own case, refresh test prose
  • ed40abf fix(composer): refuse glyph shortcut when identity is unavailable
  • c2112b2 no-mistakes(review): refuse glyph shortcut until identity probe clears pi
  • d6c1818 no-mistakes(review): let pi identity beat glyph shortcut, unblock agy remove
  • b56bf98 no-mistakes(review): order agy hook install, add path guards and doc rows
  • 9235f2e no-mistakes(review): retire agy turn-end wiring and bound its composer shortcut
  • 8cd970d feat: add agy crew harness pinned to gemini-3.1-pro-high
  • e474f5a no-mistakes(review): document agy hook scanning every workspace path
  • d38ebbd no-mistakes(review): scan all agy workspace paths, share binary resolver
  • 0ed8943 no-mistakes(review): use portable home path in agy binary doc row
  • ce89d07 no-mistakes(review): correct agy composer doc and raw-launch test comment
  • d8282a7 no-mistakes(review): bind agy launch binary in its own case, refresh test prose
  • 2b4be08 fix(composer): refuse glyph shortcut when identity is unavailable
  • d972a64 no-mistakes(review): refuse glyph shortcut until identity probe clears pi
  • d29cbd3 no-mistakes(review): let pi identity beat glyph shortcut, unblock agy remove
  • 207358e no-mistakes(review): order agy hook install, add path guards and doc rows
  • 2f1b6d9 no-mistakes(review): retire agy turn-end wiring and bound its composer shortcut
  • f208755 feat: add agy crew harness pinned to gemini-3.1-pro-high
  • 2ec7064 local: apply Kimi 0.36.0 workspace-trust fix ahead of upstream
  • 73dc318 docs(kimi): record that 0.36.0 has no folder-trust skip
  • e3d6d6d no-mistakes(document): document kimi trust dialog and herdr Up key
  • 827de72 no-mistakes(review): retry transient kimi trust-key sends and name the real key
  • fae1be4 no-mistakes(review): propagate kimi trust-key failures and pin dialog specificity
  • 3525ecd fix(spawn): accept Kimi 0.36.0 folder-trust dialog during readiness

Push main to origin, or rebase your branch onto origin/main, before gating.

🔧 **Review** - 3 issues found → auto-fixed (2) ✅
  • ⚠️ bin/fm-test-run.sh:1757 - The serial path starts stream_growing_file "$out" "$stop" & as a lane-owned background job that inherits the lane's stdout, and it exits only when the stop flag file appears under $RUN_TMP. The lane installs no INT/TERM trap; its only trap is trap 'rm -rf "$RUN_TMP"' EXIT (bin/fm-test-run.sh:1662). Failing sequence: run bin/fm-test-run.sh --lane portable-serial-3of4 | tee lane.log and send SIGINT to the foreground group (Ctrl-C, or a supervisor). Job control is off in the lane's main shell at that point (probe_group_reaping restores set +m, and run_script_contained's set -m is confined to a command-substitution subshell), so per POSIX bash sets SIGINT to SIG_IGN for the asynchronous follower: the follower survives. The lane exits, its EXIT trap deletes $RUN_TMP, so $stop can never be created and [ -e "$stop" ] is false forever. The follower loops on sleep 0.05 indefinitely while still holding the lane's stdout pipe, so the outer tee never sees EOF - the exact silent indefinite hang this change exists to remove, with the lane's own child in place of a test's orphan. Separately, because set -m puts the running test script in its own process group, the same SIGINT does not reach it either, so it is orphaned too. Fix: extend the trap to INT and TERM, kill the follower by the PID the lane already holds and reap the in-flight script's process group before removing $RUN_TMP, and make stream_growing_file return when its input file no longer exists.
  • ⚠️ tests/fm-watcher-lock.test.sh:1079 - test_pid_identity_sampling_waits_for_execve widens the fork-to-exec window with a wall-clock timer (bash -c 'sleep 0.3; exec sleep 300' &, line 1059) and then demands [ "$diverged" -eq 5 ] - every one of 5 iterations must differ. The early sample is not free: it forks a fresh bash, sources bin/fm-wake-lib.sh (which itself forks uname at line 15 and runs mkdir -p "$STATE" at line 16), then forks od to hex the cmdline. Failing sequence: on a cold or heavily loaded runner - the same machine class the intent describes, where a lane sat for 7h23m - one iteration's early sample takes longer than 0.3s, lands after execve, so early equals settled, diverged becomes 4, and the case fails with "the widened exec window stopped diverging". That is a new random red in the change whose stated purpose is to remove random reds. A deterministic widening keeps the 5/5 strictness: have the child block on a gate file (bash -c 'while [ ! -e "$1" ]; do sleep 0.05; done; exec sleep 300' _ "$gate" &) and have the test create that gate only after taking the early sample, so the pre-exec window is closed by the test rather than by a timer.
  • ℹ️ tests/lib.sh:265 - fm_test_reap_tracked_pids only reaps a registered PID that is also in this shell's job table (jobs -rp / jobs -sp written to $live, then grep -qx). The comment presents that solely as a PID-recycling guard, but it also discards every legitimately registered PID that is not one of the calling shell's own jobs - for example a watcher started by bin/fm-watch-arm.sh, which is a grandchild of the test shell and is exactly the process type in the reported leak (see the arm-started watcher_pid at tests/fm-watcher-lock.test.sh:955). Today every fm_test_track_pid caller in the two changed test files passes a direct $! job, so nothing regresses now, and the grandchild case happens to be covered by fm_test_reap_pid's descendant snapshot while the arm is still alive. The risk is the contract: CONTRIBUTING.md now instructs contributors to register PIDs with fm_test_track_pid, and a contributor who registers a grandchild PID gets a silent no-op with no diagnostic. Either document the job-table restriction at fm_test_track_pid, or keep the recycling guard while also accepting a PID that is still a descendant of this shell in the live ps parent/child graph (fm_test_descendant_pids already computes that), which carries no recycling risk.

🔧 Fix: reap the lane's follower and script on interrupt
5 issues (2 warnings, 3 infos) still open:

  • ⚠️ bin/fm-test-run.sh:312 - inflight="$LANE_INFLIGHT_DIR/$BASHPID" reads BASHPID unguarded while the script runs under set -eu (bin/fm-test-run.sh:80). BASHPID does not exist before Bash 4.0. Every other reference in this repo guards it - bin/fm-watch.sh:790, bin/fm-watch-arm.sh:86, bin/fm-spawn.sh:2793, bin/fm-remote-job-worker.sh:73 and ~15 more all write ${BASHPID:-$$} - and .github/workflows/ci.yml:350 maintains an explicit macos-stock-bash job pinned to Bash 3.2.57, so 3.2 is a live compatibility target rather than a hypothetical. Failing sequence: on a macOS box where env bash resolves to /bin/bash 3.2, bin/fm-test-run.sh tests/anything.test.sh reaches run_script_contained, expands $BASHPID, and dies with "BASHPID: unbound variable" before the first script's status is written - the runner is unusable, not degraded. Nothing else in bin/fm-test-run.sh requires Bash 4 (no declare -A, mapfile, ${var,,}), so this single expansion is what drops support. Fix by matching the repo convention, but not naively: $$ is identical in every parallel worker subshell, so ${BASHPID:-$$} alone would make all --jobs>1 workers write the same inflight file and lane_abort would reap only the last one. Key the file on the started script's PID (available as $pid immediately after the &), which is unique per worker on every Bash version.

  • ⚠️ tests/fm-watcher-lock.test.sh:711 - The user intent marks as REQUIRED: "Do not leak the process. Every watcher a test starts is reaped by that test before it exits, verified by pid." Three cases in tests/fm-watcher-lock.test.sh - one of the two files the intent puts in scope - start a watcher through bin/fm-watch-arm.sh and neither register it with fm_test_track_pid nor verify it is gone. This is precisely the process shape in the reported leak: docs/verification/test-lane-safety.md records the frozen tree as lane -> test script -> fm-watch-arm.sh -> fm-watch.sh -> sleep.

  • Line 711 kill "$armpid" "$lock_pid" 2>/dev/null || true in test_arm_starts_and_self_heals sends a single unescalated TERM to $lock_pid, the watcher whose liveness the test just asserted at line 710, then waits only on $armpid. The same doc's central finding is that a watcher's TERM can be swallowed when its trap action fails to re-parse at delivery, which is why every other reap in this change escalates to KILL.

  • Line 966 kill -TERM "$watcher_pid" in test_stopped_watcher_is_live_but_stale_then_exit_is_classified has the same shape.

  • test_arm_self_eviction_is_loud_without_successor captures watcher_pid at line 563 and never signals or verifies it at all, relying entirely on the watcher self-evicting.

Why the registry does not cover them: $lock_pid and $watcher_pid are grandchildren of the test shell and are never passed to fm_test_track_pid, so fm_test_reap_tracked_pids never iterates them. They would only be caught as a snapshot descendant inside fm_test_reap_pid("$armpid"), and in all three cases the arm has already exited by then, so the snapshot is empty and a surviving watcher is an orphan of init that nothing reaps.

This is survivable, not a hang: the lane's process-group reap in run_script_contained catches it and prints FM_TEST_LEAK. But the intent states the four requirements explicitly do not substitute for each other, so requirement 2 covering requirement 1 is not acceptance. Raising this rather than fixing it because deciding whether these three cases should register the lock pid, assert it exited, or are deliberately left to the lane backstop is the author's scope call.

  • ℹ️ tests/fm-test-run.test.sh:762 - test_lane_reaps_a_leaked_child_and_keeps_going asserts grep -Fq 'exit=0' "$out" with the message "the leaking script's own result was not preserved", but $out holds FM_TEST_END markers for both fixtures and the second one, $tmp/after.test.sh, always exits 0. Failing sequence: change run_script_contained so a reported leak overwrites the script's own status (for example rc=1 alongside the FM_TEST_LEAK line), and the leaky script's marker becomes exit=1 while after.test.sh still emits exit=0 - the grep still matches and the case still passes, so it cannot detect the regression it names. This is the one behavior in that test not pinned by another assertion; the FM_TEST_LEAK grep on line 760 is already correctly scoped to the script path. Anchor this one the same way, e.g. grep -Eq "FM_TEST_END .* ${tmp}/leaky\\.test\\.sh exit=0 ".
  • ℹ️ bin/fm-test-run.sh:323 - kill_pid_hard (bin/fm-test-run.sh:283) sends SIGKILL as its last statement and returns without confirming the process died, and the budget path at line 323 then does an unbounded wait "$pid". The comment on REAP_GRACE_TICKS at line 111 says the grace applies "between TERM and KILL, and again after KILL", but only reap_process_group implements the second wait; kill_pid_hard does not. The sibling helper in this same change, fm_test_signal_pid_hard (tests/lib.sh:210), does implement the post-KILL wait and returns whether the process is actually gone - so the two reapers in one change disagree on the same contract. Concretely: a script wedged in uninterruptible sleep (D state on a stalled mount) survives SIGKILL until the I/O returns, and the lane blocks in wait with no bound and no diagnostic - the failure mode the whole change exists to eliminate, now inside the timeout handler itself. This is the least reachable of the four findings and the budget already fires first, so it is hardening rather than a live defect: give kill_pid_hard the post-KILL confirmation loop it documents, and have run_script_contained report "could not reap" and move on instead of waiting when it comes back false.
  • ℹ️ tests/lib.sh:165 - fm_test_reap_tracked_pids only ever runs from fm_test_cleanup, which is reached through the trap fm_test_cleanup EXIT armed at tests/lib.sh:294. Roughly 40 test files replace that trap with their own EXIT handler and never call fm_test_cleanup - tests/fm-control.test.sh, tests/fm-kimi-harness.test.sh, tests/fm-agy-harness.test.sh, tests/fm-cursor-harness.test.sh and tests/fm-procevent.test.sh among them. In any of those, fm_test_track_pid appends to the registry and nothing ever reads it: no reap, no warning, no diagnostic. Nothing regresses today, because only the two watcher files call fm_test_track_pid and neither overrides the trap (I checked). The new exposure is the contract: CONTRIBUTING.md:100 now tells every contributor to register PIDs with fm_test_track_pid, and a contributor who adds a background process to one of those ~40 files gets a silent no-op and reintroduces exactly the orphan this change removed. The library comment at tests/lib.sh:57 already names the rule ("define its own EXIT trap and call fm_test_cleanup from inside it") but fm_test_track_pid's own doc block does not repeat it and the CONTRIBUTING sentence does not mention it at all. Cheapest close: state the dependency at fm_test_track_pid and in the CONTRIBUTING sentence that introduces it.

🔧 Fix: drop BASHPID, bound the post-KILL wait, reap arm watchers
✅ Re-checked - no issues remain.

✅ **Test** - passed

✅ No issues found.

  • bin/fm-test-run.sh tests/fm-test-run.test.sh
  • bin/fm-test-run.sh tests/fm-watcher-lock.test.sh
  • bin/fm-test-run.sh tests/fm-watch-triage.test.sh
  • bin/fm-test-run.sh tests/fm-test-run.test.sh tests/fm-watcher-lock.test.sh tests/fm-watch-triage.test.sh
  • Empirical fork-to-exec settling demonstration comparing immediate sampling (20/20 failed) against fm_test_wait_exec_settled (20/20 passed)
  • Empirical lane leak containment demonstration verifying process-group reaping, follower decoupling, FM_TEST_LEAK reporting, and unblocked lane progression
  • Empirical per-script timeout demonstration verifying termination of hung scripts, explicit naming, exit=124 reporting, and continued suite progress
  • Empirical test helper reap verification ensuring tests/lib.sh PID tracking unconditionally cleans up background processes on early assertion failures
✅ **Document** - passed

✅ No issues found.

✅ **Lint** - passed

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

Kimi 0.36.0 prompts Trust this folder? on every untrusted worktree path,
with Don't trust preselected. The readiness gate waited only for Welcome
or an empty composer, so spawn aborted and the pane died.

Detect that exact dialog, send Up then Enter, and keep the existing
readiness and delivery checks. Do not write ~/.kimi-code/workspace-trust/.
Checked kimi-cli and kimi-code CLI/config docs plus kimi --help.
--auto and --yolo do not skip Trust this folder?, so spawn still
accepts the dialog with Up then Enter.
Applied locally at the captain's explicit instruction so kimi is dispatchable
in this home today. The same change is open upstream as PR kunchenguid#2328, held by
GitHub pending first-time-contributor workflow approval.

This diverges the primary from origin/main until that PR lands. Reconcile by
dropping this merge once upstream carries the change.
Verify Antigravity CLI as a crewmate/scout adapter. Pin the launch to
gemini-3.1-pro-high, omit --effort (it conflicts with *-high model ids),
accept the workspace trust dialog, and install a gated global Stop hook.
Secondmate, primary turn-end, and watcher-arm remain unverified.
A separated greater-than pair is unknown on zellij, cmux, and orca
because agy is not verified there. Identity-capable backends keep
the existing probe-then-shortcut path.
Verify Antigravity CLI as a crewmate/scout adapter. Pin the launch to
gemini-3.1-pro-high, omit --effort (it conflicts with *-high model ids),
accept the workspace trust dialog, and install a gated global Stop hook.
Secondmate, primary turn-end, and watcher-arm remain unverified.
A separated greater-than pair is unknown on zellij, cmux, and orca
because agy is not verified there. Identity-capable backends keep
the existing probe-then-shortcut path.
Applies the same pattern as the Kimi 0.36.0 workspace-trust fix: the adapter
works and the runtime is installed and funded, so the running copy gets it now
rather than waiting on the PR to land. Reconcile by dropping this merge once
the upstream PR merges.

Adopted at fm/hz-agy-adapter e474f5a; the review fix round still in flight is
not included and will arrive with the PR.

# Conflicts:
#	bin/fm-spawn.sh
#	docs/configuration.md
BohnBawerick and others added 25 commits August 16, 2026 17:22
- The mode=no-mistakes ship scaffold told the worker to invoke the
  /no-mistakes skill three times. A crewmate runs in a project worktree,
  not the firstmate home, so that skill is unreachable and the worker
  stalls exactly where it has just finished implementing. Overnight
  2026-08-17/18 three workers each burned a supervisor round trip there
  and reported done: for a commit with no PR.
- The scaffold now names the interface the worker actually has: the
  no-mistakes CLI on PATH, with the concrete run and respond commands,
  and it notes that firstmate's trigger may still be worded as a skill
  invocation so either wording lands on the same command.
- Harden the failure mode the defect lands on: completion for this mode
  is now stated as a green PR, the implementation handoff line says in
  its own text that nothing has shipped yet, and a run that cannot start
  is routed to blocked: rather than done:.
- direct-PR refused "/no-mistakes"; it now refuses the pipeline by name.
  local-only, scout and the secondmate charter carried no such premise.
- Extend tests/fm-brief.test.sh: every variant is generated and checked
  for skill-invocation instructions, and the no-mistakes definition of
  done is pinned to the CLI commands and the PR-bound done: gate.
- Add an away supervision model to bin/fm-wake-lib.sh: while state/.afk
  exists the away daemon owns supervision for every primary harness and
  runs the watcher one cycle at a time, so an unheld watcher lock is the
  healthy state and the daemon itself is what must be tested.
- fm_turnend_supervision_healthy keeps the PID-strict watcher check
  everywhere except away mode, where fm_away_daemon_healthy requires a
  live identity-matched daemon plus a turning loop; a dead, recycled-pid
  or wedged daemon still blocks the turn end.
- Bound daemon-tick freshness with FM_AWAY_TICK_GRACE (180s), derived
  from the daemon's housekeeping cadence and crash backoff rather than
  guessed, and read the freshest of its housekeeping tick, its watcher
  child's beat, and its startup stamp.
- Give the pull guard the same answer, and name the daemon in both
  banners instead of telling the session to arm a watcher.
- Let bin/fm-afk-start.sh delegate its already-running check to the same
  predicate so entering away mode and guarding it cannot disagree.
- Away mode outranks a pinned FM_SUPERVISION_MODEL harness model, which
  bin/fm-spawn.sh bakes into every secondmate launch.
Suppress the possible-wedge escalation when the busy source affirmatively
reports a pane as working. Away-mode housekeeping was aging a stale marker
into a wedge alarm without ever asking whether the worker was busy, which
produced repeated false alarms on panes that were provably mid-task.

Landed locally on the captain's word so the fleet gets the fix now; upstream
PR 2554 stays open on its own at kunchenguid/firstmate.
Stop the turn-end guard raising TURN WOULD END BLIND on a healthy fleet under
away mode. The guard demanded a live long-lived watcher process, which away
mode deliberately does not run - the away daemon owns supervision and runs the
watcher one-shot. The supervision-model vocabulary in fm-wake-lib.sh now covers
that fourth shape, so a healthy away fleet stops quietly while a dead or
stalled daemon still blocks. Measured 3/200 false blocks before, 0/200 after.

Landed locally on the captain's word so the fleet gets the fix now; upstream
PR 2557 stays open on its own at kunchenguid/firstmate.
A run's recorded head is the head the PIPELINE has advanced to as it applies
its own fix commits, and those commits are pushed to the configured target
rather than fetched into the crew's worktree - so during a run the head does
not resolve in that repository at all. The attribution rule treated an
unresolvable head as a mismatch, rejected the branch's own live row, and
matched the newest stale row underneath it: a failed run recorded at the very
head the worktree still held. A healthy validation therefore read as `failed`,
which routes firstmate into recovery and can restart a run that is working.

- Code identity is now ternary: match, mismatch, and unverified for a real run
  head this copy cannot resolve. The strict predicate is unchanged, so
  teardown's abort authority still requires a verified binding.
- An unverified run is bound by its submitted head - the head it was launched
  against - read from `axi sync --check`, the one read-only surface that
  reports it on the installed v1.48.0.
- When nothing binds the run it may still report work under way, but never a
  terminal verdict: `failed` and `done` become `unknown`, which is not itself
  an instruction to act.
- The coarse runs list stops at the branch's newest row, so a superseded run
  can no longer answer for the current one.
Generated ship briefs told the worker to invoke the /no-mistakes skill, which a
worker cannot reach: the skill lives in the firstmate home and loads for a
firstmate session, while a worker runs in an isolated project copy. Every brief
variant now names the CLI path instead.

Also narrows the accompanying "never report done: for work that has no PR" line
to the case where a validation run cannot start, resolving the contradiction
with the section 7 handoff. The captain approved that scoping (option a) and
separately approved removing the intermediate handoff altogether, filed as
fm-validation-self-start.

Landed locally on the captain's word; upstream PR 2566 stays open at
kunchenguid/firstmate with all 13 checks green.
fm-crew-state.sh reported a live, healthy validation run as failed. It matched
runs to a task by comparing the run's head against the worktree head, but a
running pipeline commits its fix rounds to the gate repository, so that head is
absent from the worktree's object store. The live row was rejected and an older
failed row at the unchanged worktree head answered instead - a confident wrong
verdict that routes firstmate into recovery and risks restarting healthy work.

Now uses ternary code identity bound to the run's submitted head, withholds a
terminal verdict when identity cannot be proven, and takes the newest matching
row. 7 new regression assertions.

Landed locally on the captain's word. Upstream PR 2569 stays open at
kunchenguid/firstmate; its checks are held pending the repository owner's
approval of outside-contributor workflow runs, which never arrived. Our own
pipeline completed review, test, document and lint with no findings.
… main

Firstmate's delivery path on a fork repository left tasks unable to land
where the running fleet actually executes. For ordinary projects,
no-mistakes tasks merge remotely via PR and sync down via fleet-sync. For
Firstmate's own repository, local main is authoritative for the fleet while
upstream PRs remain open outward contributions. Because fm-merge-local.sh
refused mode!=local-only tasks and fm-fleet-sync.sh diverged on upstream
origin/main, changes were stranded.

- Teach bin/fm-merge-local.sh to fast-forward local main for Firstmate's
  own repository tasks (where project is FM_ROOT/FM_HOME) in any mode.
- Teach bin/fm-pr-merge.sh to fast-forward local main when merging a
  Firstmate repository task remotely.
- Teach bin/fm-fleet-sync.sh to skip FM_ROOT/FM_HOME gracefully rather
  than falsely alarming STUCK on upstream origin/main.
- Teach bin/fm-teardown.sh to recognize work landed in the local default
  branch.
- Add docs/verification/fork-reconciliation.md with the empirical evidence
  and reconciliation plan for fork/main.
- Add tests/fm-merge-local.test.sh and expand fleet-sync, pr-merge, and
  teardown test suites with full RED-GREEN coverage.
Away-mode housekeeping treated every failed capture as a gone pane and
dropped the marker with no escalation. A redraw, timeout, or backend
hiccup then silently stopped watching a worker that was still there,
which is the failure this path exists to prevent.

Both the stale-wedge and pause-resurface sites now share
stale_window_recheck: retry the capture twice (0.4s apart) before
verdict, then ask fm_backend_agent_state. Only an authoritatively
missing endpoint is gone. A present dead shell is ordinary idle. Every
other state, including an unreadable or unverified probe, escalates and
keeps the marker on the same cadence because the watcher cannot
recapture an unreadable pane. target_exists is not used as a gone proof:
tmux can fall back to the active window, and Orca's check is itself a
capture.

Tests cover gone, unreadable-present (alive/unreadable/unverified),
retry-then-ordinary, and dead-is-not-gone at both call sites.
Remove the intermediate pre-PR done: handoff. The implementation worker
starts its own no-mistakes CLI run immediately after the commit, reports
working: when that run starts, and reports done: only with a PR.
Firstmate's own tasks could not land where the running fleet executes. On this
fork, local main is authoritative and upstream PRs are an outward courtesy, but
the local merge path refused any task that was not local-only and fleet sync
falsely alarmed on upstream origin/main, so finished work was stranded.

Teaches the local merge path, the PR merge path, fleet sync, and teardown to
recognize firstmate's own repository and its authoritative local main. Adds
docs/verification/fork-reconciliation.md plus a new merge-local test suite and
expanded fleet-sync, pr-merge and teardown coverage.

Landed locally on the captain's explicit word. Our own pipeline completed
review, test, document and lint with no findings. The upstream PR 2597 check
failure is the known fm-watcher-lock fork/execve flake, tracked separately as
fm-racy-watcher-lock-test.
A worker that finished writing code stopped and waited for firstmate to tell it
to start validation. That handoff is a message that can fail to land: one worker
sat finished and idle for eighty minutes today because the start message was
never submitted, and every task paid the round trip even when it worked.

The worker now starts its own no-mistakes run immediately after the
implementation commit, announces the start with a nonterminal working line, and
must report failed or blocked if the run dies mid-pipeline, so firstmate still
learns start and failure without a handoff. It may no longer claim done before a
PR exists. Review, tests, gates, ask-user escalation and merge authority are
untouched: the pipeline's own separate agent still does the reviewing, the author
still cannot answer its own ask-user finding, and --yes remains banned.

Landed locally on the captain's explicit word.
Task ids become free again after cleanup, but the previous task's data directory
was left on disk. Reusing the id then handed the new worker the old task's brief,
silently, and it would build the wrong thing.

Scaffolding now refuses a reused id while that directory still exists, rather
than writing over or reading through it.

Landed locally on the captain's explicit word.
While the captain is away, the background supervisor treated every failed screen
capture as proof the worker was gone, dropped its marker and silently stopped
watching it. A redraw, timeout or backend hiccup was enough. That is precisely
the failure the away-mode supervisor exists to prevent.

Both the stale-wedge and pause-resurface sites now retry the capture twice
before deciding, then ask the backend whether the endpoint actually exists. Only
an authoritatively missing endpoint counts as gone; a present dead shell is
ordinary idle; every other state, including unreadable or unverified, escalates
and keeps the marker. Endpoint presence alone is not accepted as proof, because
tmux can fall back to the active window and Orca's own check is a capture.

Landed locally on the captain's explicit word.
Session start printed data/captain.md and data/learnings.md whole, so the
startup memory surface grew with no read path that could refuse it: 35,526
estimated tokens against a 7,500-token budget on the reference home.

- bin/fm-memory-compile.sh compiles the bundle session start injects: a
  standing core, a catalog of every note, and the notes whose triggers match
  live fleet work, capped against config/startup-memory-budget. Core is never
  dropped, the catalog outranks every note, and a note that does not fit is
  skipped rather than ending selection.
- bin/fm-memory-migrate.sh splits a home's data/learnings.md into one atomic
  cited note per heading, publishes the catalog, and freezes plus archives the
  original before removing it.
- fm-session-start.sh injects the compiled bundle when data/memory/ exists and
  keeps the whole-file print when it does not, or when the compile fails.
- Two session-start fixtures forced a MISSING diagnostic by removing node from
  the fake bin, which proves nothing on a host that also ships /usr/bin/node.
  They now shadow gh-axi, which cannot exist outside the fake bin.

On the reference home the surface goes from 35,526 tokens to 7,490, and to
7,480 with four hot notes once the core is trimmed to its target size.
- Each compiler mode now accepts only its own flags, so `catalog --context`
  or `compile --dry-run` is a usage error instead of a silently ignored
  option that reads as a compile which simply matched nothing.
- Session start says so when it cannot create the temporary file for the
  compiled bundle, rather than falling back to the whole-file print with no
  word about why.
Trigger derivation stemmed a trailing `s` off every proper noun, so a heading
about `Windows` produced the trigger `window` - which then matched nothing,
because trigger matching is whole-token. The stopword test now tries the
singular while the trigger keeps its original spelling.

The possessive-stripping `\b` is a GNU sed extension that does nothing on BSD
sed. It was also redundant: the following substitution already splits
`Firstmate's` into `Firstmate` and a one-character `s` the length filter drops.
The network-partition fixture removed node from the fake bin to produce a
local-half diagnostic, which proves nothing on a host that also ships
/usr/bin/node: the base PATH still satisfies `command -v`, no MISSING line is
emitted, and the partition assertion fails for a reason that has nothing to do
with the partition. gh-axi cannot exist outside the fake bin, so the assertion
now means the same thing on every host.

Same change as the two session-start fixtures in this branch's first commit.
@BohnBawerick
BohnBawerick force-pushed the fm/fm-racy-watcher-lock-test branch from cf6266e to d2e889d Compare August 19, 2026 20:43
Three defects in the same family made the behavior suite untrustworthy: a lane
could hang indefinitely with no diagnostic, tests leaked the processes they
started, and one check went red at random.

- bin/fm-test-run.sh runs every script contained: its own process group, a
  private output file instead of a share of the lane's own stdout pipe, a
  per-script wall-clock budget (--script-timeout, default 1800s), and a reap of
  whatever the script leaves behind. A leak is reported as FM_TEST_LEAK and the
  lane keeps going; a script that stops making progress is terminated and named
  with exit=124. Reaping is always by PID or by the group id the lane itself
  created, never by matching command lines.
- tests/lib.sh gains a spawned-process registry: fm_test_track_pid makes the
  reap unconditional, so a process survives neither a passing test nor one that
  fails an assertion before its own reap runs. Every reap escalates TERM to KILL
  after a bounded grace, because a shell whose trap action fails to parse at
  delivery swallows the signal and keeps running.
- tests/lib.sh gains fm_test_wait_exec_settled, and the watcher-lock test waits
  for a backgrounded child to finish becoming itself before sampling its pid
  identity. Sampling inside the fork-to-exec window reads the forking shell's
  own command line, which is why that check passed locally and failed on a cold
  runner.

Adds four lane-safety regressions to tests/fm-test-run.test.sh, a non-vacuous
execve-settling regression to tests/fm-watcher-lock.test.sh, and the evidence
record in docs/verification/test-lane-safety.md.
@BohnBawerick
BohnBawerick force-pushed the fm/fm-racy-watcher-lock-test branch from d2e889d to 1f7e2b5 Compare August 19, 2026 21:00
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.

2 participants