fix: block turn settle on accepted unfinished work and reconcile direct-report progress - #2303
Open
634634624 wants to merge 8 commits into
Open
Conversation
added 8 commits
August 13, 2026 00:42
…on-check ordering
…guration env block
…n continuation test
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Intent
Fix Firstmate so a Captain-facing reply is never treated as permission to stop accepted unfinished work. The always-loaded lifecycle must enforce, before a normal turn settles, that every accepted unfinished item is actively owned by a real progressing worker, durably queued with an exact dependency, resource delay, interactive-login, approval, or Captain-decision reason, or concretely completed or failed and reported; a reply itself is never completion. New Captain messages add work unless they explicitly cancel or replace it. Use the smallest deterministic turn-end, backlog, intake, or supervision owner rather than repetitive prose, preserve watcher, away-mode, Relay, approval, merge, destructive-action, lock, resource-health, and no-noise boundaries, dispatch only accepted eligible work, and distinguish running, queued, interaction/decision waits, completed, and failed work. Capacity clearing, dependency/resource/approval resolution, and completion must automatically re-evaluate and dispatch newly eligible items. The Captain must never poll for whether delegated work started, is progressing, stalled, or merely labeled active: Firstmate verifies actual ownership and progress, quietly reconciles or safely recovers stalls, and surfaces only outcomes, review-ready work, genuine decisions, credentials, or exhausted recovery failures. Implement periodic quiet read-only actual-progress reconciliation for every ordinary direct report in the existing single watcher loop, distinguishing liveness from meaningful progress, using bounded suspicion including longer validation phases and repeated same-theme rounds, persisting across restart, and requiring targeted read-only inspection before existing recovery. Add executable public-interface regressions for reply-with-unfinished-work, truthful blocked/queued work, completed work settling, additive unrelated messages, resource and approval gates, every-report periodic observation, healthy silence, genuine-progress reset, restart-safe suspicion, contradictory activity, and repeated correction rounds. Keep one owner and concise skill stubs, run repository lint, documentation-audience, focused and full tests, then create a no-mistakes PR without merging. Do not implement primary handoff; it is a separate dependency-blocked P1 because it changes session ownership.
What Changed
bin/fm-continuation-check.sh, a read-only turn-end check that reportstasks-axi readydispatchable accepted work plus ordinary In flight backlog items whose worker is missing (orphan) or not in canonicalworkingstate (inactive), and wired it intobin/fm-turnend-guard.shbehind afm-session-lock-lib.shownership test: the newcontinuation_gateemits an "ACCEPTED WORK STILL NEEDS RECONCILIATION" block and exits 2 only on paths where the supervision predicate has already resolved healthy/not-needed, and honorsstop_hook_activein--claudemode so it stays bounded to one forced follow-up and cannot preempt or suppress a blind-turn block.bin/fm-inactive-reconcile.shfrom terminal-outcome-only into per-scan progress observation for every ordinary direct report: it persists bounded baselines understate/progress-observations/(incarnation, worktree HEAD, current-state and status signatures, turn mtime, progress epoch, activity count), treats only state/HEAD/status change as meaningful progress, raises suspicion viaFM_PROGRESS_STALL_SECS, a longerFM_PROGRESS_LONG_PHASE_SECSfor whole-word test/build/CI/render/deploy phases,FM_PROGRESS_ACTIVITY_SCANSlive-activity-without-progress, orFM_PROGRESS_REPEAT_ROUNDSrepeated same-theme blocked/decision rounds, and queues at most onecheck: progress-suspicionwake per episode;bin/fm-watch.shnow runs this scan before the signal scan so a chatty sibling cannot starve it,bin/fm-session-start.shrelabels the startup pass as direct-report reconciliation, andbin/fm-teardown.shremoves the per-task record on both local and remote-secondmate paths.docs/architecture.md,docs/configuration.md,docs/scripts.md,docs/turnend-guard.md; addedtests/fm-continuation-check.test.sh(dispatchable work, held gates, completed work, additive Captain message, orphan and unverified-worker claims, lock-refused inertness, public-followup exclusion, and three turn-end guard ordering cases) and extended the inactive-reconcile, watch-triage, teardown, and turnend-guard suites, registering the new script and test inbin/fm-test-run.shfamilies.Risk Assessment
✅ Low: All four fix-round claims were independently verified against real tooling and live fixtures — the watch-loop ordering now satisfies both the signal-scan and slow-check starvation invariants, ready-ID anchoring and phase word-matching were confirmed correct in both directions against real tasks-axi output, the teardown helper precedes every caller, and the supervision predicate strictly outranks continuation — with the new tests being behavioral, discriminating, and free of the source-grep anti-pattern.
Testing
I exercised both halves of the change against their real public interfaces rather than only unit assertions. For the turn-end half I piped actual Stop-hook payloads into
bin/fm-turnend-guard.shand captured the literal Captain-facing banner across nine scenarios, confirming that a reply cannot settle dispatchable accepted work (exit 2), that all five truthful gate kinds stay silent, that clearing capacity alone re-arms dispatch with no new Captain message, that a new unrelated message adds rather than cancels, that orphaned and unverified-progress In flight claims are distinguished from genuinely working ones, that completion settles quietly, and that watcher recovery still outranks the continuation prompt. For the supervision half I drove real reconcile cycles: healthy workers are observed every cycle in total silence, a stall emits exactly one targeted read-only inspection wake and then stops repeating, the suspicion state is persisted on disk so it survives restart, genuine progress resets it automatically, and the word-boundary phase matcher correctly grants the long validation bound to tests/CI/build/deploy while denying it to prose like "decision" or "precision". I also proved the regression property by replaying the new tests against the base commit in a scratch worktree, where they fail with "eligible accepted work settled with status 0" — precisely the reported bug. All targeted suites pass (11, 24, 48, and 64 ok respectively) except one pre-existingherdr-preflight-missing-adapterfailure in the teardown suite, which I verified reproduces identically on the base commit and is untouched by this diff. No visual artifacts were captured because this change has no rendered UI surface — it is shell hooks and a watcher loop, so the CLI transcripts of the real hook output are the end-user surface. Note thatfm-watch-triage.test.shlegitimately takes about 13 minutes due to long polling waits in its procevent tests; it is slow, not hung. The working tree was left clean and all scratch worktrees and temp repro dirs were removed.Evidence: Captain-facing turn-end walkthrough (real Stop-hook output, 9 scenarios)
=== SCENARIO 1: Captain asks a question, worker task still accepted & unstarted === --- Firstmate replies to Captain, then the turn tries to settle --- ●━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ● ACCEPTED WORK STILL NEEDS RECONCILIATION ● continuation-required: ready=1 ids=ship-login-fix orphan=0 inactive=0 ● A reply is not completion. Dispatch eligible accepted work, or record the exact dependency, resource delay, interactive login, approval, or Captain decision before ending the turn. ●━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ [hook exit status: 2] === SCENARIO 2: work truthfully blocked (dependency / login / approval / capacity / date) === [hook exit status: 0] (silent + exit 0 = turn settles; no launch, no nagging) === SCENARIO 3: capacity clears -> the same held item re-evaluates and becomes dispatchable === --- while the load hold is present --- [hook exit status: 0] --- Captain frees a slot; the hold is released on the same item --- ● continuation-required: ready=1 ids=capacity orphan=0 inactive=0 [hook exit status: 2] (no new Captain message needed: freed capacity alone re-armed dispatch) === SCENARIO 4: a new, unrelated Captain message ADDS work, never cancels earlier work === ● continuation-required: ready=2 ids=earlier-accepted,new-request orphan=0 inactive=0 [hook exit status: 2] === SCENARIO 5: In flight item CLAIMS to be active but has no worker at all === ● continuation-required: ready=0 orphan=1 ids=claimed-active inactive=0 [hook exit status: 2] (orphan=1: a label is not ownership) === SCENARIO 8: supervision outranks continuation (watcher down + accepted work) === ● TURN WOULD END BLIND - SUPERVISION IS OFF [hook exit status: 2] (the watcher-repair instruction wins; the continuation prompt cannot preempt or suppress it) === SCENARIO 9: running / queued / waiting / completed / failed are distinguished === --- (a) owned-work's worker is NOT actually progressing --- continuation-required: ready=1 ids=queued-ready orphan=1 ids=claimed-active inactive=1 ids=owned-work [exit: 2] --- (b) owned-work's worker IS genuinely working --- continuation-required: ready=1 ids=queued-ready orphan=1 ids=claimed-active inactive=0 [exit: 2] --- (c) the orphan is reconciled and the queue is dispatched --- [exit: 0]Evidence: Periodic progress reconciliation walkthrough (healthy silence, stall, restart-safety, reset, word-boundary bounds)
=== SCENARIO 10: healthy worker -> watcher observes it every cycle and stays SILENT === poll 1 -> surfaced to Captain: '' (empty = nothing) poll 2 -> surfaced to Captain: '' (empty = nothing) poll 3 -> surfaced to Captain: '' (empty = nothing) persisted observation records: alpha.record wake queue (what Captain would be shown): '' => Firstmate verified real progress each cycle; Captain polled nothing and saw nothing. === SCENARIO 11: worker alive but making NO meaningful progress -> bounded suspicion === surfaced: actionable: targeted read-only progress inspection required: child=alpha reason=phase-without-progress:300s state=state: working · source: fake · implementation active head=none wake queued for Firstmate: 1786589947 1 check progress-suspicion:alpha:s1.1 targeted read-only progress inspection required... => a targeted READ-ONLY inspection is requested. No restart, no message, no steer. --- suspicion does not repeat itself every cycle (no-noise boundary) --- next poll surfaced: '' (empty = one prompt per unchanged episode) === SCENARIO 12: suspicion survives a restart (persisted, not in-memory) === incarnation=s1.1 suspicious=1 notice_emitted=1 === SCENARIO 13: genuine progress RESETS the episode === surfaced: '' (empty) suspicious=0 notice_emitted=0 => real advance cleared suspicion automatically; nothing reached the Captain. === SCENARIO 14: long validation phases get a longer leash - by WORD, not substring === Same 900s of no visible change. Ordinary bound = 120s, validation bound = 6h. genuine validation phases (should be patient): "running tests" -> patient (long validation bound) "CI is green-checking" -> patient (long validation bound) "building the release bundle" -> patient (long validation bound) "deploying to staging" -> patient (long validation bound) ordinary prose that merely CONTAINS those letters (must NOT buy patience): "waiting on a decision from the Captain" -> SUSPICIOUS (ordinary bound) "writing the specification" -> SUSPICIOUS (ordinary bound) "improving precision of the matcher" -> SUSPICIOUS (ordinary bound) "attesting the contest results" -> SUSPICIOUS (ordinary bound) => 'decision'/'specification'/'precision' no longer hide a real stall behind the 6h leash.Evidence: Regression proof: new tests fail before the fix, pass after
REGRESSION PROOF — the new tests fail BEFORE the fix and pass AFTER it ====== Method: a detached git worktree at the base commit (b91016f) received ONLY the new test files; all product scripts stayed pre-fix. --- tests/fm-continuation-check.test.sh against PRE-FIX code --- cp: bin/fm-continuation-check.sh: No such file or directory not ok - eligible accepted work settled with status 0: FM_TEST_SUMMARY total=1 failed=1 ^^ "settled with status 0" IS the reported bug: a reply ended the turn while accepted unfinished work was still dispatchable. --- tests/fm-inactive-reconcile.test.sh against PRE-FIX code --- not ok - watcher did not observe every ordinary direct report FM_TEST_SUMMARY total=1 failed=1 --- the same two suites against the FIXED code (ba3d693) --- fm-continuation-check.test.sh 11 ok, 0 failed (exit 0) fm-inactive-reconcile.test.sh 24 ok, 0 failed ("all inactive reconciliation tests passed")Evidence: Targeted suite results on the change (ba3d693)
Pipeline
Updates from git push no-mistakes
✅ **intent** - passed
✅ No issues found.
✅ **Rebase** - passed
✅ No issues found.
🔧 **Review** - 3 issues found → auto-fixed (4) ✅
bin/fm-turnend-guard.sh:151- The accepted-work continuation block is placed before the watcher-supervision predicate, so it preempts the blind-turn guard and reopens the documented 2026-07-21 blind window. Reproduced end-to-end: with a primary home whose state/.lock is owned by this ancestry, one state/task1.meta in flight, no watcher beacon, and payload {"stop_hook_active":true} under --claude, the base guard (b91016f) exits 2 printing 'TURN WOULD END BLIND - SUPERVISION IS OFF / 1 task(s) in flight, but no live watcher holds this home lock', while the new guard exits 0 with empty stderr. The cause is lines 151-153: when CONTINUATION_REQUIRED is non-empty and stop_hook_active is true, Claude mode returns exit 0 at line 152, which is above fm_supervision_status at line 183, so the watcher predicate and the cooperative auto-arm path never run. The same ordering also fires on the non-suppressed path: with stop_hook_active=false plus a dead watcher, only the ACCEPTED WORK banner is emitted and the watcher-repair instruction is never shown. This contradicts the doc line added in this change at docs/turnend-guard.md:68 ('That exception applies only to watcher recovery'), because the exception is taken before watcher recovery is evaluated. Fix by running the continuation check after the supervision predicate resolves, or by having the stop_hook_active suppression fall through to the supervision predicate instead of exiting 0. No new test covers continuation-required and supervision-off together.bin/fm-inactive-reconcile.sh:55- state/progress-observations/<id>.record is written per direct report but never removed. bin/fm-teardown.sh's two cleanup blocks (lines 395 and 2536) delete <id>.status, <id>.meta, <id>.turn-ended and related per-task files but not the new record, and grep shows no other writer or pruner references the directory. Records therefore accumulate for the lifetime of the home, one per task ever spawned. Correctness impact is limited because meta_incarnation uses a fresh spawn_gen per spawn, so a reused id resets the episode rather than falsely accusing a new worker; the residue is unbounded growth. Add the record to the teardown rm lists alongside the other per-task state files. Note the sibling terminal-outcomes/ has no retention either, so this follows existing precedent rather than introducing a new defect class.bin/fm-watch.sh:999- The new 'check: progress-suspicion' wake reason is produced by fm-watch.sh and its handling is specified in AGENTS.md:403 (targeted read-only inspection first, stuck-crewmate-recovery only when evidence confirms a stall), but no script consumes the reason string; routing is entirely model-directed via the always-loaded lifecycle. That matches the change's stated 'smallest deterministic owner' approach and the reason is distinguished from 'check: inactive-outcome' by substring match on the reconcile payload, which is a real emitted-output contract rather than a source grep. Recording it only as a deliberate tradeoff: the distinction between the two check reasons has no deterministic consumer, so a future wording change in the reconcile payload would silently downgrade suspicion wakes to generic inactive-outcome wakes.🔧 Fix: Order supervision predicate above accepted-work continuation
1 error still open:
bin/fm-teardown.sh:397-remove_progress_observationis called at line 397 insideremote_secondmate_teardown, but the function is not defined until line 744. The remote path is entered at line 422 (if remote_secondmate_teardown_locked; then), which executes before the shell has parsed the line-744 definition, so the name is unbound at call time. Verified against the real file, not inferred: extracting lines 326-400 and probingdeclare -F remove_progress_observationprints UNDEFINED, and a minimal repro confirms bash emitscommand not foundand returns 127 for a function defined after its caller runs. Impact is not cosmetic. The call sits AFTER the destructive section ofremote_secondmate_teardown: the secondmate registry line was already rewritten (mv -f -- "$tmp" "$SECONDMATE_REG") and$STATE/$ID.status,.meta,.turn-ended, and.open-decisions-cursorwere already removed at lines 395-396. Because the call is guarded by|| return 1, the unbound-name failure turns intoreturn 1, which propagates throughremote_secondmate_teardown_locked(line 417) toexit "$remote_teardown_rc"at line 428. A remote secondmate teardown therefore exits non-zero, skipping theteardown %s completeline, after it has already destroyed the local records - reporting failure for work that partly succeeded and inviting a retry against half-removed state. Every sibling helper used on this path is deliberately defined before line 326 (remote_teardown_locks_release:232,remote_recovery_paths_validate:247,remote_pending_replies_cleanup:302,remote_outbox_cleanup:316), and the two external ones come fromfm-secondmate-registry-lib.sh, so the change violates an established ordering invariant in this file rather than following it. shellcheck 0.11.0 does not report use-before-definition at the configured severity, so repository lint passes silently. The fix-round testtest_teardown_removes_progress_observation_recordonly exercises the local path (the line-2552 call site, where the function is already defined) andtests/fm-teardown.test.shcontains no remote-secondmate coverage at all, so this bug can pass the new test with the code wrong. Fix by moving theremove_progress_observationdefinition aboveremote_secondmate_teardown(before line 326), alongside the other remote helpers.🔧 Fix: Define progress-observation cleanup helper before remote caller
3 issues (2 warnings, 1 info) still open:
bin/fm-continuation-check.sh:47-table_idsscans the entiretasks-axi readyoutput instead of anchoring to theready[group header, so it also harvests rows printed under the separateready_public_followups[group.ready_countis taken only from thecount:line, which excludes those obligations, so the count and the ID list are drawn from different sets. Reproduced against real tasks-axi 0.2.5 by building a genuine delivery-ready obligation (public-followup add + bind-work + work-event ->ok: ... -> ready (revision 3)) and running the real script: with one ordinary queued item plus one ready obligation the script printscontinuation-required: ready=1 ids=q1,pf-1(count 1, two ids); with the obligation alone it exits 0 and prints nothing whiletasks-axi readyshowscount: 0andready_public_followups[1]{...}: pf-1,queued,public-followup,"-",summary,ready.tasks-axi ready --helpstates public-followup obligations "are never dispatchable and appear only in the separate ready_public_followups group", so listingpf-1under a banner instructing "Dispatch eligible accepted work" points Firstmate at an item the tool explicitly marks non-dispatchable. The repository's existing owner of this exact output,print_ready_queued_boundedin bin/fm-session-start.sh:446, anchors on/^ready\[/before consuming indented rows, so this is a deviation from the established parsing contract rather than a new constraint. Impact is bounded because the line is model-facing prose with no deterministic consumer (grep forcontinuation-requiredfinds only the script and its own test), and public-followup delivery is separately owned by bin/fm-public-followup.sh. Fix by gatingtable_idson theready[header the wayprint_ready_queued_boundeddoes. tests/fm-continuation-check.test.sh has no public-followup fixture, so this passes the new suite with the code wrong.bin/fm-inactive-reconcile.sh:394- The long-validation-phase branch matches the lowercased current-state line against bare substrings*ci*|*test*|*build*|*render*|*deploy*.ciis two characters and matches ordinary English inside the free-text detail field:decision,specification,precision,explicit,implicit,efficient,critical,service. That detail is worker-authored prose, not a controlled vocabulary: bin/fm-crew-state.sh:569 emitsemit "$LOG_STATE" status-log "$(status_line_note "$LOG_LINE")", piping the worker's own status-log note straight into the line this case statement scans. Verified by rendering the real format:state: working · source: status-log · awaiting a decision on the API shapeand... · implementing the specificationboth take the long branch. Effect is that such a worker gets FM_PROGRESS_LONG_PHASE_SECS (default 21600s / 6h) instead of FM_PROGRESS_STALL_SECS (default 7200s / 2h) before phase-without-progress suspicion, so a genuinely stalled worker whose last status happened to mention a decision or a specification goes unreconciled three times longer than intended. This weakens the intent requirement that suspicion use "longer validation phases" specifically for validation phases. Direction of failure is silence rather than noise, so it does not violate the no-noise boundary, which is why this is a warning rather than an error. Fix by anchoring the tokens to word boundaries (e.g. matchingci,ci/,ci:, or using a grep -Ew style test) instead of bare substring globs. tests/fm-inactive-reconcile.test.sh:test_long_validation_phase_uses_longer_bound only feedsci tests running, which matches on bothciandtest, so it cannot distinguish correct tokenization from the overmatch.bin/fm-inactive-reconcile.sh:416- The suspicion wake dedup key isprogress-suspicion:$id:$incarnation, which omits$reason. Within one incarnation, oncenotice_emitted=1is persisted, a later escalation to a materially different reason for the same worker (for example moving fromphase-without-progresstorepeated-same-theme-rounds:N) queues no new wake, because the emit is gated on[ "$emitted" -eq 0 ]. This is not a defect: genuine progress resetsemitted=0via the meaning_sig comparison, and re-alerting on every reason change for a worker already flagged for targeted inspection would violate the intent's explicit no-noise and "quietly reconciles" boundaries. Recording it only as a deliberate tradeoff, consistent with the bounded-suspicion requirement. No action needed.🔧 Fix: Anchor ready IDs and word-match validation phases
4 issues (1 error, 2 warnings, 1 info) still open:
bin/fm-watch.sh:996- The direct-report reconciliation block was moved from before the signal scan to after it, placing it in the exact starvation path this file explicitly documents. In the base (b91016f) the block ran at line 867, ahead ofpending=$(scan_signals)at line 948. At HEAD the signal scan is at line 935 and the reconcile block at line 996. Between them, line 975 callswake "$reason"and line 964 canexit 1.wake()(bin/fm-push-transition-lib.sh:88-109) ends inexit 0/exit "$output_status", so it terminates the whole cycle. Any cycle that finds an actionable signal therefore returns before reconciliation ever runs. This is not a hypothetical ordering nit: bin/fm-watch.sh:864-867 states the rule verbatim for the sibling slow-check block -- "Evaluated BEFORE the signal scan: wake() exits the cycle, so a check placed after the signal scan would be starved whenever a chatty sibling crewmate keeps producing signals - the slow poll (e.g. merge detection) would then never run until the fleet went quiet." The new block violates that invariant while the comment asserting it remains in the file. The failure does not self-correct: the watcher process exits on wake and is re-armed, and each new process re-enters the loop at line 826 and reaches the signal scan first again, so a fleet that produces an actionable signal on most cycles suppresses progress observation indefinitely rather than merely delaying it. The scan's own 900s rate limiter cannot help, because the script is never invoked to consult it. Impact lands directly on a required behavior: the intent mandates "periodic quiet read-only actual-progress reconciliation for every ordinary direct report in the existing single watcher loop". Under a chatty fleet -- precisely the case where a stalled worker hides among active siblings -- the periodic observation silently stops, so suspicion never accrues,progress_epochnever ages, and noprogress-suspicionwake is queued. bin/fm-session-start.sh:733 runs the same scan but only once per locked session start, so it is a one-shot backstop, not the periodic cadence the intent requires. The failure direction is silence, which is why no test catches it: tests/fm-inactive-reconcile.test.sh invokes fm-inactive-reconcile.sh directly and never exercises fm-watch.sh's cycle ordering, so every new progress test passes with the watcher wiring wrong. Fix by restoring the block ahead of the signal scan (its base position, next to the other cadence-owned slow checks), or by making the signal-scan wake path fall through to reconciliation before exiting. Flagged ask-user rather than auto-fix because the relocation is deliberate -- the new comment argues for running "after immediate signal triage and before slower pane-stale classification" -- so which ordering is correct is the author's call.bin/fm-continuation-check.sh:93- The per-taskfm-crew-state.shcall is bounded individually byfm_run_timed "$CREW_TIMEOUT"(default 3s, line 27), but the loop over In flight rows has no aggregate bound, and bin/fm-turnend-guard.sh:142-143 invokes the whole script with no timeout wrapper at all. Worst case latency is therefore 3s x (number of ordinary In flight tasks lacking a fast state answer), added synchronously to every primary turn end on the lock-owning session. Measured normal cost is small -- a real fm-crew-state.sh call on this machine returns in ~56ms -- so a typical fleet adds well under a second and this is not a merge blocker. It matters because the sibling owner of the same read, bin/fm-inactive-reconcile.sh, deliberately carries both a per-call timeout and an aggregateFM_INACTIVE_RECONCILE_BUDGET_SECSbound (default 10s, validated 1..30 at lines 80-89) plus a resume cursor, precisely because an unbounded sweep over many children is the known hazard; the new turn-end path adopts the per-call half of that pattern but not the aggregate half. A home with many stuck or timing-out workers is exactly the state this feature exists to detect, so the slow case correlates with the feature firing. Suggest wrapping the checker call in the guard withfm_run_timed(the guard already sources fm-timeout-lib.sh transitively) and treating timeout as a silent exit 0, consistent with the script's existing documented fail-open degrade for missing/incompatible tasks-axi.AGENTS.md:19- This change rewrites the delegation boundary in section 1 in a direction the stated intent does not ask for. Base text: "Outside hard rule 1's concrete captain-approved project operation exception, you do not do project-specific work yourself." New text weakens that to "you do not do substantive project-specific work yourself" and adds a new standing allowance: "Firstmate may directly perform only a genuinely quick, simple, low-risk accepted action when delegation costs more than execution and every other authority boundary still permits it." The intent governs turn-end continuation, supervision, and progress reconciliation; it directs the author to "Use the smallest deterministic turn-end, backlog, intake, or supervision owner rather than repetitive prose" and to "dispatch only accepted eligible work". It never authorizes broadening what Firstmate may execute without delegating. The qualifiers doing the load-bearing work here -- "substantive", "genuinely quick, simple, low-risk", "when delegation costs more than execution" -- are model-judged with no deterministic owner, unlike hard rule 1's existing exception, which requires the captain to "clearly and concretely approve, in the moment, for a specific project". Hard rule 1 itself is unchanged at lines 25-29, so the two now sit in tension: rule 1 still says firstmate reads projects and crewmates change them, while section 1 prose grants a self-assessed execution allowance. Introduced by the author's commit aa74d38, not by a fix round. Raising as ask-user rather than auto-fix because it is a deliberate authority-boundary edit and only the author can say whether it was meant to ride along with this change; if it was not, reverting the two sentences to the base wording keeps this change scoped to the continuation fix.bin/fm-inactive-reconcile.sh:501- Whenfm_run_timedreturns a non-124 failure,state_lineis left empty and every subsequent path still callsobserve_progresswith that empty value. An empty state line does not match any exclusion pattern in thecase "$held:$state_line"block (verified: it falls to the*)branch and is evaluated for suspicion), and it hashes intomeaning_sigas a distinct value, so a transient reader failure both resets the progress episode and makes the worker eligible for suspicion accounting. In practice this is close to unreachable and is recorded only as a tradeoff, not a defect: bin/fm-crew-state.sh routes every outcome throughemit(), which always prints astate: ...line andexit 0(lines 86-91), and its only non-zero exit is the usage error at line 71 for a missing id, which this call site cannot trigger since it always passes "$id". The 124 timeout case is already handled explicitly byreturn 3, which surrenders the scan budget. No action needed unless a future crew-state change introduces a silent non-zero exit path, at which point guarding with[ -n "$state_line" ] || return 0before observing would preserve the current semantics.🔧 Fix: Run direct-report reconciliation before signal scan
✅ Re-checked - no issues remain.
tests/fm-teardown.test.sh- Pre-existing, unrelated test failure in tests/fm-teardown.test.sh:not ok - herdr-preflight-missing-adapter: teardown continued without its required preflight. This is NOT caused by this change — it reproduces identically on the base commit b91016f (12 ok / 1 failed there, 14 ok / 1 failed here, the two additional passes being this change's new progress-observation teardown tests). The test file's herdr-preflight case is untouched by this diff (0 diff hunks match it), andherdrIS installed locally, so this is not an environment gate skip. Flagging so the author can decide whether to fix it separately or accept it as known-red; it does not block this change's intent.bash bin/fm-test-run.sh tests/fm-continuation-check.test.sh— 11 ok, 0 failed (all new public-interface continuation regressions)bash tests/fm-inactive-reconcile.test.sh— 24 ok, 0 failed, 'all inactive reconciliation tests passed' (incl. 8 new progress-observation regressions)bash tests/fm-watch-triage.test.sh— 48 ok, 0 failed, incl. newtest_reconcile_runs_before_actionable_signal_wakeandtest_reconcile_suspicion_wake_reason_is_routedbash tests/fm-turnend-guard.test.sh— 64 ok, 0 failedbash tests/fm-teardown.test.sh— 14 ok / 1 failed; both new tests (teardown retires this task's progress-observation record and spares other tasks',remote secondmate teardown retires the progress-observation record and still reports success) pass; the 1 failure is pre-existingherdr-preflight-missing-adapterREGRESSION PROOF:git worktree add --detach <tmp> b91016f2then copied ONLY the new test files into the pre-fix tree and ranbash bin/fm-test-run.sh tests/fm-continuation-check.test.sh→not ok - eligible accepted work settled with status 0(the reported bug reproduced) andtests/fm-inactive-reconcile.test.sh→not ok - watcher did not observe every ordinary direct reportBASELINE CONTROL:bash tests/fm-teardown.test.shon the pre-fix base worktree → identicalnot ok - herdr-preflight-missing-adapter, confirming that failure is pre-existing and unrelatedMANUAL E2E (turn-end, real Stop hook): piped{"stop_hook_active":false,...}intobin/fm-turnend-guard.shagainst a purpose-built Firstmate home across 9 Captain-facing scenarios — dispatchable accepted work (exit 2 + banner), five truthful gates (dependency/interactive-login/approval/capacity/date → silent exit 0), capacity-clearing auto re-dispatch, additive unrelated Captain message (ready=2), orphan In flight claim (orphan=1), unverified worker progress (inactive=1) vs verified working (inactive=0), completed work settling silently, and supervision-outranks-continuation precedenceMANUAL E2E (watcher reconciliation): drove realbin/fm-inactive-reconcile.sh scancycles — healthy worker observed silently 3x with a persistedprogress-observations/alpha.record; stalled worker emitted exactly onetargeted read-only progress inspection requiredwake and stayed quiet on the next poll;suspicious=1 notice_emitted=1verified persisted on disk (restart-safe); genuine branch-HEAD + status advance reset it tosuspicious=0 notice_emitted=0MANUAL E2E (word-boundary phase matching, commit 8eab836): held 900s of no-progress constant and compared bounds —running tests,CI is green-checking,building the release bundle,deploying to stagingstayed patient on the 6h validation bound, whilewaiting on a decision from the Captain,writing the specification,improving precision of the matcher,attesting the contest resultscorrectly fell back to the ordinary stall boundCLEANUP:git worktree remove --forceon the pre-fix worktree,git worktree prune, removed /tmp repro dirs, killed stray watcher processes;git status --porcelainreturns clean🔧 **Document** - 1 issue found → auto-fixed ✅
bin/fm-continuation-check.sh:27- bin/fm-continuation-check.sh:27 introduces FM_CONTINUATION_CREW_TIMEOUT (default 3s, self-clamping) which appears in neither the script's own header block nor the docs/configuration.md operator env block. Its two closest siblings, FM_CREW_STATE_NM_TIMEOUT and FM_TEARDOWN_NM_TIMEOUT, are both documented in that env block (docs/configuration.md:544-545), so the placement is inconsistent. I left it alone because this is a judgment call rather than staleness: the change made no pre-existing documented fact wrong, and the knob has no operator-facing purpose (it bounds one internal fm-crew-state.sh read inside a read-only turn-end hook). Resolve by either adding one env-block line matching the sibling convention, or adding one line to the script header if the intent is that internal-only knobs stay with the producer script.🔧 Fix: Document FM_CONTINUATION_CREW_TIMEOUT in configuration env block
✅ Re-checked - no issues remain.
🔧 **Lint** - 1 issue found → auto-fixed ✅
🔧 Fix: Replace SC2015 shift idiom with explicit if-else in continuation test
✅ Re-checked - no issues remain.
✅ **Push** - passed
✅ No issues found.