Skip to content

fix(bin): stop the Claude turn-end guard blocking stops it cannot repair - #1592

Closed
pa-arth wants to merge 6 commits into
kunchenguid:mainfrom
pa-arth:fm/turnend-guard-lock-and-budget
Closed

pa-arth wants to merge 6 commits into
kunchenguid:mainfrom
pa-arth:fm/turnend-guard-lock-and-budget

Conversation

@pa-arth

@pa-arth pa-arth commented Aug 3, 2026 •

Copy link
Copy Markdown

Intent

Raise the turn-end guard fix compliantly through the gate per CONTRIBUTING.md, so the repo's 'PR must be raised via no-mistakes' check passes. An earlier PR (#1592) for this same change was opened by hand with gh and fails that check.

The branch fixes three defects in bin/fm-turnend-guard.sh's --claude mode, in two commits.

Commit 1 (6938bc4), the original work:

  • A session that does not hold this home's session lock re-blocked on every turn for ~15 consecutive turns with three pieces of work in flight, while the lock-owning session sat idle. bin/fm-claude-stop-autoarm.sh refuses to arm from a non-lock-owning session, so blocking it demanded a repair it could never perform. The guard now allows that stop with an advisory.
  • The advertised ceiling of FM_CLAUDE_TURNEND_BLOCK_BUDGET (default 3) consecutive blocks never arrived, because a stalled auto-arm stops rewriting state/.claude-autoarm-epoch and the epoch-gated counter froze in exactly the stalled case the bound exists to cover. A genuine block now consumes one count per blocked stop; a BUDGET_ADVANCED flag keeps a single stop accounted at most once in total, and the allow paths keep their per-epoch idempotence.

Commit 2 (5b4b8ac), fixing what a previous run of this pipeline correctly caught in commit 1:

  • The foreign-lock allow used '! fm_session_lock_owned_by_self', which also reports failure when this process's harness ancestry cannot be resolved, so it conflated 'someone else owns the lock' with 'I cannot tell who owns it'. With an unresolvable ancestry and state/.lock holding this session's OWN live harness pid, the guard allowed a blind stop for the one session permitted to repair supervision. bin/fm-session-lock-lib.sh now owns the decision through one lock-file reader plus fm_session_lock_live_foreign_owner, which requires complete positive evidence including a RESOLVED ancestry; a missing lock, malformed lock, dead owner, and unresolvable ancestry all keep the ordinary blocking path.
  • The advisory asserted 'The lock-owning session restores supervision when it ends its next turn'. That is the assumption the motivating incident disproved, since an idle lock owner has no next turn, and it is wrong under away mode where the away daemon owns the watcher. It now states only what was verified and takes its recovery step from bin/fm-supervision-instructions.sh with the same --afk and --x-mode inputs block_stop uses.
  • docs/turnend-guard.md now describes the corrected predicate rather than the intended one.

One deliberate deviation a reviewer should judge on its merits rather than as an oversight: bin/fm-claude-stop-autoarm.sh reads the lock through the shared fm_session_lock_stale_owner, NOT through fm_session_lock_live_foreign_owner. Its inertness on an empty or malformed lock depends on treating that as uncertainty; routing it through the foreign-owner predicate would send it down the lock-recovery path to claim a lock nobody proved was free. The stale-owner form was traced outcome-equivalent to the code it replaces across all four cases (live foreign owner, unresolvable ancestry, dead pid, empty or malformed lock) while still removing the duplicated lock-file reader the two scripts had drifted apart on.

Test work worth knowing about: the original foreign-owner test did not control the condition it named, because run_hook_claude invokes the guard under a plain bash and ancestry resolution then depends on the host process tree - it resolves under a real session and does not on a CI runner, where the test silently passed through the fail-open branch. It now runs under the suite's existing fake-harness parent. A new test pins the unresolvable case deterministically on every host by running the guard beneath more plain shells than the ancestry walk's 16-hop bound, and asserts the guard blocks.

Local verification already run against this exact head: bin/fm-lint.sh clean at pinned ShellCheck 0.11.0; bin/fm-test-run.sh tests/fm-turnend-guard.test.sh green at 36/36; and the new unresolvable-ancestry test was mutation-verified against a scratch copy carrying the pre-fix predicate, where it fails with 'expected exit 2, got 0'.

Process note: two earlier fix rounds of this pipeline died mid-edit to an upstream 'Connection closed mid-response' API error and discarded their work, so commit 2 was authored directly on the branch between runs rather than by a fix round.

What Changed

  • bin/fm-turnend-guard.sh --claude mode now allows a stop with a read-only advisory when a different live harness process holds this home's state/.lock, instead of blocking a session that bin/fm-claude-stop-autoarm.sh forbids from arming. The decision moves into fm_session_lock_live_foreign_owner in bin/fm-session-lock-lib.sh, which requires complete positive evidence including a resolved harness ancestry, so a missing lock, malformed lock, dead owner, or unresolvable ancestry all keep the ordinary blocking path. The advisory's recovery step comes from bin/fm-supervision-instructions.sh with the same --afk / --x-mode inputs block_stop uses, rather than promising the lock owner will repair supervision on its next turn.
  • The FM_CLAUDE_TURNEND_BLOCK_BUDGET ceiling now counts blocks rather than epochs: a genuine block consumes one count per blocked stop even when a stalled auto-arm has frozen state/.claude-autoarm-epoch, while a new BUDGET_ADVANCED flag keeps a single stop accounted at most once in total and the allow paths keep their per-epoch idempotence.
  • bin/fm-session-lock-lib.sh gains a single state/.lock reader (fm_session_lock_pid) plus fm_session_lock_stale_owner, which bin/fm-claude-stop-autoarm.sh now uses in place of its own duplicated inline lock parsing — deliberately the stale-owner predicate, not the foreign-owner one, so an empty or malformed lock leaves the auto-arm inert. tests/fm-turnend-guard.test.sh adds coverage for the foreign-lock allow (under the suite's fake-harness parent), a host-independent unresolvable-ancestry block, a stale-lock block, and frozen-epoch budget exhaustion; docs for the guard, scripts, watcher continuity, and the supervision verification record are updated to match.

Risk Assessment

✅ Low: The only outstanding defect from the prior round is fixed and independently verified by probe — the ancestry walk now binds to the fixture's own fake-claude pid rather than the host's real session, and the deliberately-collapsed control shape resolves nothing so the test fails loudly instead of silently passing — leaving a well-bounded change whose production logic fails closed on every incomplete-evidence path.

Testing

I ran the three targeted suites that own this change — tests/fm-turnend-guard.test.sh (including the four new --claude cases), tests/fm-claude-stop-autoarm.test.sh, and tests/fm-session-lock-ancestry.test.sh — and all passed with no failures or gate skips. Because passing tests alone do not show the incident behavior, I also drove the real Stop hook by hand in hermetic homes against the guard as it existed at the base commit, at commit 1, and at branch head, and captured the transcripts a Claude session would actually receive: the base guard blocks a non-lock-owning session with the blind-turn banner while head allows it with an evidence-only advisory; the commit-1 guard allows a blind stop when the harness ancestry cannot resolve (and still carries the disproved "ends its next turn" promise) while head blocks; and against a frozen auto-arm epoch the base guard blocks 6 out of 6 stops with its block count stuck at zero while head counts 1-2-3 and reaches the attended "SUPERVISION IS GENUINELY DOWN" fail-open. An away-mode run confirms the advisory's recovery step comes from the shared supervision-instructions line rather than a hand-written claim, and a five-state lock matrix confirms the auto-arm's new stale-owner reader decides identically to the inline code it replaced. This change is not UI-facing, so the reviewer-visible evidence is CLI hook transcripts rather than screenshots. The worktree is clean; all scratch fixtures were temp dirs removed on exit and only evidence files remain.

Evidence: Per-commit Stop-hook transcripts for all three defects (before vs after)

DEFECT 1 - foreign live session-lock owner --- before (4ee4a0a) --- exit=2 | ● TURN WOULD END BLIND - SUPERVISION IS OFF | ● 1 task(s) in flight, but no live watcher holds this home lock (last beat: never). --- after (adc0913) --- exit=0 | {"systemMessage":"FIRSTMATE SUPERVISION IS OWNED BY ANOTHER SESSION: ... Stay read-only here. Recovery belongs to whoever holds the lock: watcher supervision needs Stop-owned automatic recovery; inspect the hook registration and startup status before ending the turn."} DEFECT 2 - unresolvable harness ancestry is not proof of a foreign owner --- before (commit 1 only, 6938bc4) --- exit=0 | {"systemMessage":"FIRSTMATE SUPERVISION IS OWNED BY ANOTHER SESSION: ... The lock-owning session restores supervision when it ends its next turn. Stay read-only here."} --- after (adc0913) --- exit=2 | ● TURN WOULD END BLIND - SUPERVISION IS OFF DEFECT 3 - frozen auto-arm epoch vs FM_CLAUDE_TURNEND_BLOCK_BUDGET (default 3) --- before (4ee4a0a) --- stop #1..#6: exit=2 budget=count=0 epoch=3 (ceiling never arrives) --- after (adc0913) --- stop #1: exit=2 count=1 stop #2: exit=2 count=2 stop #3: exit=2 count=3 stop #4: exit=0 count=4 | {"systemMessage":"FIRSTMATE SUPERVISION IS GENUINELY DOWN: 1 task(s) in flight, the Stop-owned auto-arm exhausted its bounded retries and one failure notice, no watcher or automatic continuation exists, and the block budget is exhausted. Keep this session attended and diagnose the automatic Stop-hook and watcher startup before relying on unattended supervision."}

==============================================================================
DEFECT 1 - a session that does not hold this home's session lock
Scenario: state/.lock holds a DIFFERENT live harness pid. That session is
forbidden from arming supervision, so blocking it demands a repair it can
never perform (the incident: ~15 consecutive re-blocks).
==============================================================================
--- before (4ee4a0a2790cfaa5e47b30fa462f16546f2ab5b6) ---
  exit=2
  | ●━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  | ●  TURN WOULD END BLIND - SUPERVISION IS OFF
  | ●  1 task(s) in flight, but no live watcher holds this home lock (last beat: never).
  | ●  The Stop-owned auto-arm did not claim this home either, so recovery is NOT already under way.
  | ●  watcher supervision needs Stop-owned automatic recovery; inspect the hook registration and startup status before ending the turn.
  | ●━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

--- after (adc0913ec765717231ec7e059bb34e970f3d8158) ---
  exit=0
  | {"systemMessage":"FIRSTMATE SUPERVISION IS OWNED BY ANOTHER SESSION: this session does not hold this home's session lock, so it must not arm, drain, or repair supervision. Supervision is not running for this home right now, and this turn is allowed to end only because this session is the one that cannot repair it. Stay read-only here. Recovery belongs to whoever holds the lock: watcher supervision needs Stop-owned automatic recovery; inspect the hook registration and startup status before ending the turn."}

==============================================================================
DEFECT 2 - 'I cannot tell whose lock this is' must not read as 'someone
else owns it'. Same live pid in state/.lock, but the guard's own harness
ancestry does not resolve, so the lock may well be THIS session's own.
==============================================================================
--- before (commit 1 only) (6938bc4) ---
  exit=0
  | {"systemMessage":"FIRSTMATE SUPERVISION IS OWNED BY ANOTHER SESSION: this session does not hold this home's session lock, so it must not arm, drain, or repair supervision. The lock-owning session restores supervision when it ends its next turn. Stay read-only here."}

--- after (adc0913ec765717231ec7e059bb34e970f3d8158) ---
  exit=2
  | ●━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  | ●  TURN WOULD END BLIND - SUPERVISION IS OFF
  | ●  1 task(s) in flight, but no live watcher holds this home lock (last beat: never).
  | ●  The Stop-owned auto-arm did not claim this home either, so recovery is NOT already under way.
  | ●  watcher supervision needs Stop-owned automatic recovery; inspect the hook registration and startup status before ending the turn.
  | ●━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

==============================================================================
DEFECT 3 - FM_CLAUDE_TURNEND_BLOCK_BUDGET (default 3) never arrived when a
stalled auto-arm stopped rewriting state/.claude-autoarm-epoch. Six
consecutive Stop hooks against a frozen epoch; only exit codes shown until
an allow appears.
==============================================================================
--- before (4ee4a0a2790cfaa5e47b30fa462f16546f2ab5b6) ---
  stop #1: exit=2  budget=session=sess-claude-mode count=0 epoch=3 
  stop #2: exit=2  budget=session=sess-claude-mode count=0 epoch=3 
  stop #3: exit=2  budget=session=sess-claude-mode count=0 epoch=3 
  stop #4: exit=2  budget=session=sess-claude-mode count=0 epoch=3 
  stop #5: exit=2  budget=session=sess-claude-mode count=0 epoch=3 
  stop #6: exit=2  budget=session=sess-claude-mode count=0 epoch=3 

--- after (adc0913ec765717231ec7e059bb34e970f3d8158) ---
  stop #1: exit=2  budget=session=sess-claude-mode count=1 epoch=3 
  stop #2: exit=2  budget=session=sess-claude-mode count=2 epoch=3 
  stop #3: exit=2  budget=session=sess-claude-mode count=3 epoch=3 
  stop #4: exit=0  budget=session=sess-claude-mode count=4 epoch=3 
  | {"systemMessage":"FIRSTMATE SUPERVISION IS GENUINELY DOWN: 1 task(s) in flight, the Stop-owned auto-arm exhausted its bounded retries and one failure notice, no watcher or automatic continuation exists, and the block budget is exhausted. Keep this session attended and diagnose the automatic Stop-hook and watcher startup before relying on unattended supervision."}
Evidence: Away-mode foreign-lock advisory (recovery step sourced from fm-supervision-instructions.sh)

AWAY-MODE (state/.afk present) foreign-lock advisory, head adc0913 exit=0 | {"systemMessage":"FIRSTMATE SUPERVISION IS OWNED BY ANOTHER SESSION: this session does not hold this home's session lock, so it must not arm, drain, or repair supervision. Supervision is not running for this home right now, and this turn is allowed to end only because this session is the one that cannot repair it. Stay read-only here. Recovery belongs to whoever holds the lock: Away mode owns watcher supervision; load /afk and ensure the daemon is running instead of starting normal supervision directly."}

AWAY-MODE (state/.afk present) foreign-lock advisory, head adc0913
exit=0
| {"systemMessage":"FIRSTMATE SUPERVISION IS OWNED BY ANOTHER SESSION: this session does not hold this home's session lock, so it must not arm, drain, or repair supervision. Supervision is not running for this home right now, and this turn is allowed to end only because this session is the one that cannot repair it. Stay read-only here. Recovery belongs to whoever holds the lock: Away mode owns watcher supervision; load /afk and ensure the daemon is running instead of starting normal supervision directly."}
Evidence: Auto-arm lock-reader outcome-equivalence matrix (the deliberate stale-owner deviation)

state/.lock | OLD inline reader | NEW stale_owner | guard's live_foreign_owner -------------------------------------------------------------------------------------------------------- live foreign harness pid | INERT (exit 0) | INERT (exit 0) | foreign owner dead pid | recover-lock | recover-lock | no proof empty | INERT (exit 0) | INERT (exit 0) | no proof malformed (not-a-pid) | INERT (exit 0) | INERT (exit 0) | no proof missing | INERT (exit 0) | INERT (exit 0) | no proof

auto-arm lock-reader outcome equivalence (head adc0913ec765717231ec7e059bb34e970f3d8158)

state/.lock                  | OLD inline reader | NEW stale_owner  | guard's live_foreign_owner
--------------------------------------------------------------------------------------------------------
live foreign harness pid     | INERT (exit 0)   | INERT (exit 0)   | foreign owner
dead pid                     | recover-lock     | recover-lock     | no proof
empty                        | INERT (exit 0)   | INERT (exit 0)   | no proof
malformed (not-a-pid)        | INERT (exit 0)   | INERT (exit 0)   | no proof
missing                      | INERT (exit 0)   | INERT (exit 0)   | no proof
Evidence: Evidence capture scripts (reproduce the transcripts above)
#!/usr/bin/env bash
# Capture real bin/fm-turnend-guard.sh --claude Stop-hook transcripts for the
# three defects this branch fixes, running the SAME scenario against the guard
# as it was before the fix and as it is at branch head. Output is what a Claude
# session actually receives at turn end: the hook's exit code plus its stdout.
set -u

REPO=$1
OUT=$2
WORK=$(mktemp -d "${TMPDIR:-/tmp}/turnend-evidence.XXXXXX")
trap 'rm -rf "$WORK"' EXIT

BASE=4ee4a0a2790cfaa5e47b30fa462f16546f2ab5b6   # before any fix
MID=5b4b8ac                                     # commit 1 only would be 6938bc4
COMMIT1=6938bc4
HEAD_REF=adc0913ec765717231ec7e059bb34e970f3d8158

SCRIPTS="fm-turnend-guard.sh fm-turnend-guard-grok.sh fm-operational-input.sh
fm-supervision-instructions.sh fm-harness.sh fm-primary-scope-lib.sh
fm-supervision-lib.sh fm-wake-lib.sh fm-session-lock-lib.sh"

make_home() {
  local dir=$1 ref=$2 f
  mkdir -p "$dir/state" "$dir/bin" "$dir/docs"
  git -C "$REPO" init -q "$dir" 2>/dev/null
  git -C "$dir" commit -q --allow-empty -m init
  : > "$dir/AGENTS.md"
  for f in $SCRIPTS; do
    git -C "$REPO" show "$ref:bin/$f" > "$dir/bin/$f"
  done
  chmod +x "$dir"/bin/*.sh
  cp -R "$REPO/docs/supervision-protocols" "$dir/docs/supervision-protocols"
  cat > "$dir/deep-shell.sh" <<'SH'
#!/usr/bin/env bash
depth=$1
shift
if [ "$depth" -gt 0 ]; then
  bash "$0" "$((depth - 1))" "$@"
  exit $?
fi
"$@"
SH
  chmod +x "$dir/deep-shell.sh"
  : > "$dir/state/task1.meta"          # one task in flight => supervision needed
}

start_fake_lock_owner() {
  local dir=$1
  ln -sf /bin/sleep "$dir/fake-claude-lock-owner"
  "$dir/fake-claude-lock-owner" 60 >/dev/null 2>&1 &
  LOCK_OWNER_PID=$!
}

# Stop hook fired from a session whose harness ancestry RESOLVES to a fake
# harness (deep shells above it push the real host session out of the 16-hop
# walk), i.e. a session that can tell the lock is not its own.
run_resolved() {
  local dir=$1 home
  home=$(cd "$dir" && pwd)
  ln -sf /bin/bash "$dir/fake-claude"
  printf '{"stop_hook_active":false,"session_id":"sess-claude-mode"}' \
    | CLAUDECODE=1 FM_HOME="$home" FM_CLAUDE_AUTOARM_SYNC_WAIT_MS=200 \
      bash "$dir/deep-shell.sh" 18 "$dir/fake-claude" -c \
      'bash "$FM_HOME/bin/fm-turnend-guard.sh" --claude; exit $?' 2>&1
}

# Stop hook fired where the ancestry walk resolves NOTHING.
run_unresolvable() {
  local dir=$1 home
  home=$(cd "$dir" && pwd)
  printf '{"stop_hook_active":false,"session_id":"sess-claude-mode"}' \
    | CLAUDECODE=1 FM_HOME="$home" FM_CLAUDE_AUTOARM_SYNC_WAIT_MS=200 \
      bash "$dir/deep-shell.sh" 18 bash "$dir/bin/fm-turnend-guard.sh" --claude 2>&1
}

run_plain() {
  local dir=$1 home
  home=$(cd "$dir" && pwd)
  printf '{"stop_hook_active":false,"session_id":"sess-claude-mode"}' \
    | CLAUDECODE=1 FM_HOME="$home" FM_CLAUDE_AUTOARM_SYNC_WAIT_MS=100 \
      bash "$dir/bin/fm-turnend-guard.sh" --claude 2>&1
}

report() {
  printf '  exit=%s\n' "$1"
  printf '%s\n' "$2" | sed 's/^/  | /'
  printf '\n'
}

exec > "$OUT" 2>&1

echo "=============================================================================="
echo "DEFECT 1 - a session that does not hold this home's session lock"
echo "Scenario: state/.lock holds a DIFFERENT live harness pid. That session is"
echo "forbidden from arming supervision, so blocking it demands a repair it can"
echo "never perform (the incident: ~15 consecutive re-blocks)."
echo "=============================================================================="
for ref in "$BASE:before" "$HEAD_REF:after"; do
  d="$WORK/d1-${ref#*:}"
  make_home "$d" "${ref%%:*}"
  start_fake_lock_owner "$d"
  printf '%s\n' "$LOCK_OWNER_PID" > "$d/state/.lock"
  echo "--- ${ref#*:} (${ref%%:*}) ---"
  out=$(run_resolved "$d"); st=$?
  kill "$LOCK_OWNER_PID" 2>/dev/null; wait "$LOCK_OWNER_PID" 2>/dev/null
  report "$st" "$out"
done

echo "=============================================================================="
echo "DEFECT 2 - 'I cannot tell whose lock this is' must not read as 'someone"
echo "else owns it'. Same live pid in state/.lock, but the guard's own harness"
echo "ancestry does not resolve, so the lock may well be THIS session's own."
echo "=============================================================================="
for ref in "$COMMIT1:before (commit 1 only)" "$HEAD_REF:after"; do
  d="$WORK/d2-$(echo "${ref#*:}" | tr -c 'a-z0-9' '-')"
  make_home "$d" "${ref%%:*}"
  start_fake_lock_owner "$d"
  printf '%s\n' "$LOCK_OWNER_PID" > "$d/state/.lock"
  echo "--- ${ref#*:} (${ref%%:*}) ---"
  out=$(run_unresolvable "$d"); st=$?
  kill "$LOCK_OWNER_PID" 2>/dev/null; wait "$LOCK_OWNER_PID" 2>/dev/null
  report "$st" "$out"
done

echo "=============================================================================="
echo "DEFECT 3 - FM_CLAUDE_TURNEND_BLOCK_BUDGET (default 3) never arrived when a"
echo "stalled auto-arm stopped rewriting state/.claude-autoarm-epoch. Six"
echo "consecutive Stop hooks against a frozen epoch; only exit codes shown until"
echo "an allow appears."
echo "=============================================================================="
for ref in "$BASE:before" "$HEAD_REF:after"; do
  d="$WORK/d3-${ref#*:}"
  make_home "$d" "${ref%%:*}"
  : > "$d/state/.claude-autoarm-failure-notified"
  printf 'epoch=3 owner_pid=999 outcome=failed updated_at=1\n' > "$d/state/.claude-autoarm-epoch"
  touch -t 202001010000 "$d/state/.claude-autoarm-epoch"
  printf 'session=sess-claude-mode\ncount=0\nepoch=3\n' > "$d/state/.turnend-claude-blocks"
  echo "--- ${ref#*:} (${ref%%:*}) ---"
  for i in 1 2 3 4 5 6; do
    out=$(run_plain "$d"); st=$?
    echo "  stop #$i: exit=$st  budget=$(tr '\n' ' ' < "$d/state/.turnend-claude-blocks")"
    if [ "$st" -eq 0 ]; then
      printf '%s\n' "$out" | sed 's/^/  | /'
      break
    fi
  done
  echo
done

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

⚠️ **Rebase** - 1 warning
  • ⚠️ .agents/skills/bootstrap-diagnostics/SKILL.md - branch carries 1 commit(s) that exist on your local main branch but were never pushed to origin/main; rebasing would bundle this unrelated work (46 file(s)) into the PR:
  • 6938bc4 fix(bin): stop the Claude turn-end guard blocking a stop it cannot repair

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

🔧 **Review** - 2 issues found → auto-fixed ✅
  • 🚨 tests/fm-turnend-guard.test.sh:1572 - run_hook_claude_under_fake_harness does not establish a fake-harness parent, so the foreign-owner test is still host-dependent — the exact defect the commit message claims to have fixed. &#34;$dir/fake-claude&#34; -c &#39;bash &#34;$FM_HOME/bin/fm-turnend-guard.sh&#34; --claude&#39; is a single simple command, which bash exec-optimizes: the fake-claude process is replaced in-place by the guard's bash, leaving no harness-named ancestor. Verified by probe under this exact invocation shape: fm_harness_ancestry_pids resolved to the host's real claude pid (comm=claude), not to the fixture. Consequence: on a host with a live Claude session above the suite the test passes via the wrong ancestor; on a CI runner with no harness ancestor fm_harness_ancestry_pids fails, fm_session_lock_live_foreign_owner returns false, the guard falls to block_stop and exits 2, so expect_code 0 ... must allow a stop it can never repair and the 'SUPERVISION IS OWNED BY ANOTHER SESSION' assertion both fail. Local 36/36 green does not predict CI here. Fix: keep the parent alive, e.g. -c &#39;bash &#34;$FM_HOME/bin/fm-turnend-guard.sh&#34; --claude; exit $?&#39; — I verified that form preserves fake-claude as the parent. Note the comment cites run_integrated_autoarm (line 1072) as the mechanism being reused, but that pre-existing helper collapses the same way (bash also exec-optimizes the last command of a multi-command -c string); it is unaffected only because the pid it writes to state/.lock is dead by the time anything reads it.
  • ℹ️ bin/fm-turnend-guard.sh:230 - Tradeoff note, deliberate per the stated intent and AGENTS.md section 3: when a live lock owner is idle, neither session repairs supervision — the non-owner now allows the stop with an advisory and stays read-only, while the owner has no next turn to fire its Stop hook. The home therefore runs with tasks in flight and no watcher for as long as the owner process stays alive, with no escalation beyond the per-turn systemMessage. It self-heals only when the owner exits, at which point fm_session_lock_stale_owner lets the other session's auto-arm reclaim and arm. This is still strictly better than the ~15-turn un-endable block it replaces; recording it because the advisory's wording is now the only signal a human ever sees.

🔧 Fix: keep the fake harness alive above the guard in tests
✅ Re-checked - no issues remain.

✅ **Test** - passed

✅ No issues found.

  • bin/fm-test-run.sh tests/fm-turnend-guard.test.sh — 57 checks green, including the four new --claude cases (foreign-lock allow, unresolvable-ancestry block, stale-lock block, frozen-epoch budget exhaustion)
  • bin/fm-test-run.sh tests/fm-claude-stop-autoarm.test.sh — green, covering the auto-arm's shared-lock-reader paths (no lock, live foreign owner, dead recorded owner)
  • bin/fm-test-run.sh tests/fm-session-lock-ancestry.test.sh — green, covering harness identification and live-vs-stale owner resolution
  • Manual per-commit Stop-hook capture: guard binaries extracted from 4ee4a0a / 6938bc4 / adc0913 into hermetic FM_HOME fixtures, run as a real --claude Stop hook with a live harness-named process in state/.lock (capture-guard-transcripts.sh)
  • Manual away-mode capture: same foreign-lock allow with state/.afk present, confirming the advisory's recovery step is sourced from fm-supervision-instructions.sh --afk 1 (capture-afk-advisory.sh)
  • Manual predicate matrix: auto-arm's pre-change inline lock reader vs fm_session_lock_stale_owner vs fm_session_lock_live_foreign_owner over live/dead/empty/malformed/missing state/.lock (capture-lock-predicate-matrix.sh)
⚠️ **Document** - 1 info
  • ℹ️ docs/verification/supervision.md:158 - The maintainer-verification record for the guard predicate is still the 2026-08-02 block covering the previous correction (bin/fm-lint.sh, bin/fm-doc-audience-check.sh, and a four-suite FM_TEST_SUMMARY total=4 failed=0), and .agents/skills/firstmate-coding-guidelines/SKILL.md requires verification evidence to be updated when behavior changes. This branch changes the guard predicate again (live-foreign-lock allow, per-block budget accounting) and adds four tests, so that record no longer covers the current head; its recorded fm-doc-audience-check: ok surfaces=61 local_links=174 line is also below the current 176 after this change. I did not author a replacement block because doing so honestly requires the exact command output from a lint and test run, and executing the pipeline's lint/test phases is outside this documentation phase. The intent already states the runs were made against this head (ShellCheck 0.11.0 clean, tests/fm-turnend-guard.test.sh 36/36), so the pipeline's lint/test phase output can be pasted into a new dated paragraph in that section.

🔧 Fix: refresh supervision verification record for current guard head
1 info still open:

  • ℹ️ docs/verification/supervision.md:158 - Judgment call worth a reviewer's eye: I replaced the 2026-08-02 guard-predicate verification block rather than appending a second dated block beside it. The file's stated purpose is active empirical facts about current guarantees, so two dated records for the same guarantee would be duplication plus a stale local_links=174 number. To make the supersede safe rather than lossy, I re-ran the exact same four-suite command the old block recorded (tests/fm-claude-stop-autoarm.test.sh, tests/fm-guard-stale-banner.test.sh, tests/fm-turnend-guard.test.sh, tests/fm-supervision-instructions.test.sh) at this head, so the new block covers identical ground, and I folded the old block's subject matter (auto-arm false-failure, monotonic bounded fail-open) into the new sentence alongside the session-lock split and per-block budget accounting. The two later 2026-08-02 paragraphs in the same section cover different guarantees and were left untouched. If you would rather keep the historical block verbatim and append, the new block is a self-contained unit that can be moved below it.
✅ **Lint** - passed

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

…pair

Two independent defects made the --claude turn-end guard re-block a session
forever instead of honoring its documented bound.

1. No session-lock awareness. A session that does not hold the home's session
   lock must not arm, drain, or repair supervision (AGENTS.md section 3), and
   fm-claude-stop-autoarm.sh refuses to arm from it on the same predicate. The
   guard nonetheless demanded that repair before allowing the stop, so a
   read-only session with work in flight re-blocked on every turn while the
   lock-owning session sat idle. Claude mode now allows the stop with an
   advisory whenever a different LIVE harness process holds state/.lock. The
   allow requires positive proof of a live foreign owner; a missing, malformed,
   or stale lock keeps the ordinary blocking path, so this does not widen into
   a general fail-open.

2. The bounded block budget did not bind. The count advanced only when the
   auto-arm epoch CHANGED, but a stalled auto-arm stops rewriting
   state/.claude-autoarm-epoch. With a frozen epoch the count never rose, the
   FM_CLAUDE_TURNEND_BLOCK_BUDGET ceiling was never reached, and the terminal
   attended fail-open became unreachable - the ceiling silently evaporated in
   exactly the stalled case it exists to cover. A genuine block now consumes
   one count per blocked stop. The allow paths keep per-epoch idempotence, so
   one event epoch still yields exactly one recovery turn, and a stop that was
   already accounted for on an allow path is not charged twice.

The terminal fail-open still requires a verified failure episode; that
requirement is deliberately unchanged.

Verification: both new regression tests were confirmed failing against
origin/main and passing against this change. Full tests/fm-turnend-guard.test.sh
green at 66 assertions, 0 failures. bin/fm-lint.sh clean (shellcheck 0.11.0),
bin/fm-doc-audience-check.sh ok.
@pa-arth pa-arth closed this Aug 3, 2026
@pa-arth
pa-arth deleted the fm/turnend-guard-lock-and-budget branch August 3, 2026 14:46
@pa-arth
pa-arth restored the fm/turnend-guard-lock-and-budget branch August 3, 2026 14:46
@pa-arth pa-arth reopened this Aug 3, 2026
pa-arth added 2 commits August 3, 2026 11:52
The --claude foreign-lock allow decided "a different session owns this lock"
with `! fm_session_lock_owned_by_self`. That helper also reports failure when
this process's harness ancestry cannot be resolved, so the negation collapsed
"someone else owns it" and "I cannot tell who owns it" into the same answer.
With an unresolvable ancestry and state/.lock holding this session's own live
harness pid, the guard allowed a blind stop for the one session permitted to
repair supervision, while fm-claude-stop-autoarm.sh stayed inert on the same
ambiguity: work in flight, supervision off, no signal. The header comment's
claim that the allow "never widens into a general fail-open" was false.

fm-session-lock-lib.sh now owns the decision through one lock-file reader and
two evidence-based predicates. fm_session_lock_live_foreign_owner succeeds only
on complete positive evidence: a numeric lock pid, a live harness at that pid,
a RESOLVED ancestry for this process, and the lock pid outside it. A missing
lock, a malformed lock, a dead owner, and an unresolvable ancestry all keep the
ordinary blocking path.

fm-claude-stop-autoarm.sh reads the lock through the shared
fm_session_lock_stale_owner rather than the foreign-owner predicate, and this is
deliberate. Its inertness on an empty or malformed lock depends on treating that
as uncertainty; routing it through the foreign-owner predicate would send it
down the recovery path to claim a lock nobody proved was free. The stale-owner
form is outcome-equivalent to the code it replaces across all four cases (live
foreign owner, unresolvable ancestry, dead pid, empty or malformed lock) while
still removing the duplicated reader the two scripts had drifted apart on.

The advisory no longer claims the lock owner restores supervision. An idle lock
owner has no next turn, which is the incident this allow exists for, and under
away mode the away supervisor owns the watcher. It now states only what was
verified and takes its recovery step from fm-supervision-instructions.sh with
the same --afk and --x-mode inputs the repair banner uses.

Tests: the existing foreign-owner test did not control the condition it named.
It ran the guard under a plain bash, so ancestry resolution depended on the host
process tree - it resolved under a real session and did not on a CI runner,
where the test passed through the fail-open branch instead of through positive
foreign ownership. It now runs under the suite's existing fake-harness parent so
the ancestry resolves. A new test pins the unresolvable case by running the
guard beneath more plain shells than the walk's 16-hop bound, which is
deterministic on every host, and asserts the guard blocks. Verified against a
scratch copy carrying the pre-fix predicate: that test fails there with
"expected exit 2, got 0".
@pa-arth pa-arth changed the title fix(bin): stop the Claude turn-end guard blocking a stop it cannot repair fix(bin): stop the Claude turn-end guard blocking stops it cannot repair Aug 3, 2026
@kunchenguid

Copy link
Copy Markdown
Owner

Speaking as Kun's firstmate: closing this as stale. It has been waiting on a contributor update for 14+ days with no author push or comment. Reopen if you want to pick it back up.

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