Skip to content

fix(bin): bound the away-mode busy guard and log delivery outcomes - #3090

Open
NicholasACTran wants to merge 12 commits into
kunchenguid:mainfrom
NicholasACTran:fm/firstmate-afk-escape-and-delivery-logging
Open

fix(bin): bound the away-mode busy guard and log delivery outcomes#3090
NicholasACTran wants to merge 12 commits into
kunchenguid:mainfrom
NicholasACTran:fm/firstmate-afk-escape-and-delivery-logging

Conversation

@NicholasACTran

@NicholasACTran NicholasACTran commented Aug 26, 2026

Copy link
Copy Markdown

Intent

Bound the away-mode busy guard so a false-positive (the daemon misreading an idle claude pane as busy) can never cause unbounded silence again, and make delivery success/failure explainable from the log alone. Background: a 2026-08-26 investigation (data/firstmate-afk-daemon-wedged-investigation/report.md) found away mode delivered nothing for 95 minutes because the existing max-defer retry re-enters the exact same busy guard that is failing. This PR is the BOUNDING fix (sibling of firstmate-afk-launch-fix, which is the CAUSE fix for the launch-path bug itself); both are needed together and neither alone makes away mode safe.

This branch already carries a fully validated fix through seven review rounds (all resolved, committed): the escape anchors to a durable busy+empty streak marker (state/.subsuper-busy-empty-streak-since) measuring exactly the condition being escaped - a continuous run of (busy verdict AND confirmed-empty composer) - not an in-process clock (resets on daemon restart, rejected) and not the buffered escalation's own undelivered age (measures the wrong quantity, can fire with zero seconds of real disagreement, rejected). The marker is created on first observation, reset the instant the condition breaks or on any early-return path that could not observe it, removed when the escape fires, and cleared only on a genuine fresh away-session entry (fm_afk_clear_stale_artifacts) - never on a refresh - and cleaned up consistently across bin/fm-afk-start.sh, bin/fm-afk-launch.sh's three artifact lists, and bin/fm-afk-return.sh. FM_BUSY_GUARD_ESCAPE_SECS is resolved once at daemon start with a forced-decimal parse (immune to bash's leading-zero-as-octal reading) and an overflow-safe upper clamp validated by string comparison before any arithmetic touches the value.

Skipping ci on this run: this repo blocks first-time GitHub Actions runs from a fork at action_required pending maintainer approval, so CI cannot start or report on this PR - waiting on it or looping trying to fix it is waiting on something that cannot happen. The captain explicitly ruled the finish line for this delivery is a correct, open PR carrying the no-mistakes pipeline attestation - not a green CI check - given that constraint.

What Changed

  • inject_msg in bin/fm-supervise-daemon.sh gains a bounded busy-guard escape: when a busy verdict disagrees with an affirmatively empty composer for FM_BUSY_GUARD_ESCAPE_SECS (default 300, 0 disables), it delivers instead of deferring again. Continuity is tracked by the durable state/.subsuper-busy-empty-streak-since marker, whose contents hold seconds observed and whose mtime bounds each tick's credit to one poll interval, so time the daemon never observed the pane (pane gone, crash backoff, restart between flushes) cannot buy the escape; the marker is reset on any non-empty composer, any non-busy verdict, any early return that never observed the pane, and once the escape fires. FM_BUSY_GUARD_ESCAPE_SECS and the per-tick cap derived from FM_HOUSEKEEPING_TICK are resolved once at daemon start through a shared _resolve_secs_override helper that strips leading zeros as decimal and enforces the 86400s clamp by string comparison, never by arithmetic that could overflow.
  • Delivery is now explainable from the log alone: a confirmed submit emits an inject delivered line and stamps state/.subsuper-last-delivery, and busy deferral lines name which branch produced the verdict (native agent-state vs the rendered-pane busy-regex fallback) via a new PANE_BUSY_LAST_SOURCE. The escape path reuses the already-confirmed empty composer read rather than re-probing the backend.
  • The new streak marker is treated as a session-scoped delivery artifact alongside the existing ones - cleared in fm_afk_clear_stale_artifacts (bin/fm-afk-start.sh), in bin/fm-afk-return.sh, and across all three artifact lists in bin/fm-afk-launch.sh (backup, restore, and both start paths) so it is preserved on refresh and cleared only on fresh entry. Docs (docs/architecture.md, docs/configuration.md, .agents/skills/afk/SKILL.md) describe the escape, the marker lifecycle, and the delivery record, and tests/fm-daemon.test.sh adds cases for deferring below threshold, escaping at threshold, one-second-short deferral, 0 disabling the escape, single composer read on escape, immunity to a stale escalation age, and the branch-naming deferral log, with launch/return tests extended to cover the new artifact.

Risk Assessment

✅ Low: The busy-guard escape is well-bounded and fails closed at every uncertain point (uncreatable/corrupt marker, unobserved gaps, non-empty composer), the resolvers avoid both octal misreads and arithmetic overflow, the artifact lifecycle is consistent across all four owners, and the new tests exercise real behavior rather than source text; only two cosmetic log/redirect nits remain.

Testing

I ran the targeted daemon test file plus the two afk tests touched by the artifact-list changes and all passed, then went beyond unit coverage to reproduce the actual failure end to end: the real inject_msg driven on a real poll cadence against a supervisor pane that reports busy forever while its composer reads provably empty. Against the base commit the daemon defers indefinitely with an unattributable "agent mid-turn" line and never records a delivery; on this branch it logs the observed busy+empty streak growing against the configured threshold, fires the override at the bound, actually submits the encoded escalation, logs the delivery, writes state/.subsuper-last-delivery, and clears the streak marker. Setting FM_BUSY_GUARD_ESCAPE_SECS=0 restores the old always-defer behavior while keeping the explainable log lines. Transcripts for all three runs are saved as evidence; this is a daemon/log-facing change with no rendered UI surface, so log transcripts are the end-user artifact.

Evidence: Before/after operator log transcripts (base commit vs this branch vs escape disabled)

Source: Before/after operator log transcripts (base commit vs this branch vs escape disabled)

# Away-mode busy-guard escape: before/after operator log transcripts

Scenario (identical in all three runs): the supervisor pane exists, its native
agent-state reads 'busy' forever while its composer reads provably 'empty' -
the exact false positive from data/firstmate-afk-daemon-wedged-investigation.
The real inject_msg is driven on a real poll cadence (FM_HOUSEKEEPING_TICK=2)
and writes the real state/.supervise-daemon.log. Escape window shortened to 6s
so the bound is observable in seconds instead of minutes.

## 1. BEFORE - base commit 07bf0c8 daemon, FM_BUSY_GUARD_ESCAPE_SECS=6 set
`` `
[2026-08-26T05:03:34-0400] daemon starting (pid 96914); target=default:w1:p2; backend=herdr; afk=on; busy_guard_escape=<no such mechanism>
[2026-08-26T05:03:34-0400] inject deferred: supervisor pane busy (agent mid-turn)
[2026-08-26T05:03:36-0400] inject deferred: supervisor pane busy (agent mid-turn)
[2026-08-26T05:03:38-0400] inject deferred: supervisor pane busy (agent mid-turn)
[2026-08-26T05:03:40-0400] inject deferred: supervisor pane busy (agent mid-turn)
[2026-08-26T05:03:42-0400] inject deferred: supervisor pane busy (agent mid-turn)
[2026-08-26T05:03:44-0400] inject deferred: supervisor pane busy (agent mid-turn)
[2026-08-26T05:03:47-0400] inject deferred: supervisor pane busy (agent mid-turn)
[2026-08-26T05:03:49-0400] inject deferred: supervisor pane busy (agent mid-turn)
`` `
Never delivers; the deferral line cannot say which branch decided; no delivery
record exists. (State dir after the run: no .subsuper-last-delivery.)

## 2. AFTER - this branch, FM_BUSY_GUARD_ESCAPE_SECS=6
`` `
[2026-08-26T05:02:48-0400] daemon starting (pid 91374); target=default:w1:p2; backend=herdr; afk=on; busy_guard_escape=6s
[2026-08-26T05:02:48-0400] inject deferred: supervisor pane busy (native agent-state (agent_status=busy)); composer confirmed-empty for 0s straight, escapes at 6s
[2026-08-26T05:02:50-0400] inject deferred: supervisor pane busy (native agent-state (agent_status=busy)); composer confirmed-empty for 2s straight, escapes at 6s
[2026-08-26T05:02:52-0400] inject deferred: supervisor pane busy (native agent-state (agent_status=busy)); composer confirmed-empty for 4s straight, escapes at 6s
[2026-08-26T05:02:54-0400] inject busy-guard override: native agent-state (agent_status=busy) has read busy against a confirmed-empty composer for 6s straight; delivering instead of deferring further
[2026-08-26T05:02:54-0400] inject delivered: escalation submitted (verdict=empty)
[2026-08-26T05:02:54-0400] main loop: escalation buffer flushed at tick 4
`` `
Text actually submitted to the pane:
`` `
⁣FIRSTMATE_OP: v1 away-supervisor: away-supervisor digest: task alpha needs a decision
`` `
state/.subsuper-last-delivery written: 1787734974
state/.subsuper-busy-empty-streak-since removed once the escape fired.

## 3. AFTER with the escape disabled - FM_BUSY_GUARD_ESCAPE_SECS=0
`` `
[2026-08-26T05:03:01-0400] daemon starting (pid 92795); target=default:w1:p2; backend=herdr; afk=on; busy_guard_escape=0s
[2026-08-26T05:03:01-0400] inject deferred: supervisor pane busy (native agent-state (agent_status=busy)); composer confirmed-empty for 0s straight, escapes at 0s
[2026-08-26T05:03:04-0400] inject deferred: supervisor pane busy (native agent-state (agent_status=busy)); composer confirmed-empty for 2s straight, escapes at 0s
[2026-08-26T05:03:06-0400] inject deferred: supervisor pane busy (native agent-state (agent_status=busy)); composer confirmed-empty for 4s straight, escapes at 0s
[2026-08-26T05:03:08-0400] inject deferred: supervisor pane busy (native agent-state (agent_status=busy)); composer confirmed-empty for 6s straight, escapes at 0s
[2026-08-26T05:03:10-0400] inject deferred: supervisor pane busy (native agent-state (agent_status=busy)); composer confirmed-empty for 8s straight, escapes at 0s
[2026-08-26T05:03:12-0400] inject deferred: supervisor pane busy (native agent-state (agent_status=busy)); composer confirmed-empty for 10s straight, escapes at 0s
[2026-08-26T05:03:14-0400] inject deferred: supervisor pane busy (native agent-state (agent_status=busy)); composer confirmed-empty for 12s straight, escapes at 0s
[2026-08-26T05:03:17-0400] inject deferred: supervisor pane busy (native agent-state (agent_status=busy)); composer confirmed-empty for 14s straight, escapes at 0s
`` `
Opt-out preserves the old always-defer behavior, but the log now still explains
why (busy source, observed streak seconds, configured threshold).
Evidence: Reproduction script for the wedged-pane scenario

Source: Reproduction script for the wedged-pane scenario

#!/usr/bin/env bash
# Reproduces the 2026-08-26 wedged-away-mode scenario end to end:
# the supervisor pane reports busy (herdr native agent-state) forever while its
# composer is provably empty - an idle claude pane misread as busy. The daemon's
# real inject_msg is driven on a real poll cadence and writes its real log.
set -u
ROOT=$1; STATE=$2; export FM_STATE_OVERRIDE=$STATE
mkdir -p "$STATE"
FM_TEST_DAEMON_SOURCED=1 . "$ROOT/bin/fm-supervise-daemon.sh"
LOG="$STATE/.supervise-daemon.log"
afk_enter "$STATE"
printf 'digest item: task alpha needs a decision\n' > "$STATE/.subsuper-escalations"
_now > "$STATE/.subsuper-escalations.since"

# The wedged pane: exists, native state reads busy, composer reads empty.
fm_backend_target_exists() { return 0; }
fm_backend_busy_state() { printf 'busy'; }
fm_backend_composer_state() { printf 'empty'; }
fm_backend_send_text_submit() { printf '%s' "$3" > "$STATE/typed.txt"; printf 'empty'; }

resolve_busy_guard_escape_secs
resolve_busy_empty_streak_step_max
log "daemon starting (pid $$); target=default:w1:p2; backend=herdr; afk=on; busy_guard_escape=${BUSY_GUARD_ESCAPE_SECS_RESOLVED}s"

tick=0
while [ "$tick" -lt 8 ]; do
  tick=$((tick+1))
  if inject_msg "away-supervisor digest: task alpha needs a decision" "$STATE"; then
    log "main loop: escalation buffer flushed at tick ${tick}"
    break
  fi
  sleep "${FM_HOUSEKEEPING_TICK}"
done
Evidence: Fixed-branch daemon log: bounded deferral then override and confirmed delivery
[05:02:48] daemon starting (pid 91374); target=default:w1:p2; backend=herdr; afk=on; busy_guard_escape=6s
[05:02:48] inject deferred: supervisor pane busy (native agent-state (agent_status=busy)); composer confirmed-empty for 0s straight, escapes at 6s
[05:02:50] inject deferred: ... composer confirmed-empty for 2s straight, escapes at 6s
[05:02:52] inject deferred: ... composer confirmed-empty for 4s straight, escapes at 6s
[05:02:54] inject busy-guard override: native agent-state (agent_status=busy) has read busy against a confirmed-empty composer for 6s straight; delivering instead of deferring further
[05:02:54] inject delivered: escalation submitted (verdict=empty)

-- submitted to pane: FIRSTMATE_OP: v1 away-supervisor: away-supervisor digest: task alpha needs a decision
-- state/.subsuper-last-delivery: 1787734974
Evidence: Base commit 07bf0c8 daemon log, same scenario: unbounded silence
[05:03:34] daemon starting ...; busy_guard_escape=<no such mechanism>
[05:03:34] inject deferred: supervisor pane busy (agent mid-turn)
[05:03:36] inject deferred: supervisor pane busy (agent mid-turn)
[05:03:38] inject deferred: supervisor pane busy (agent mid-turn)
... (8 ticks, never delivers, no .subsuper-last-delivery written)

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

⚠️ **Review** - 2 infos
  • ℹ️ bin/fm-supervise-daemon.sh:1404 - When FM_BUSY_GUARD_ESCAPE_SECS=0 (documented as "disables the escape"), the busy branch still falls into this else and logs ... composer confirmed-empty for Ns straight, escapes at 0s on every housekeeping tick. "escapes at 0s" reads as "the threshold is zero, so it should have escaped already", the opposite of what 0 means, which works against the intent's second goal of making delivery state explainable from the log alone. Concretely: an operator sets FM_BUSY_GUARD_ESCAPE_SECS=0 to restore the old always-defer behavior, then sees the daemon log "confirmed-empty for 45s straight, escapes at 0s" repeatedly while nothing ever escapes. Suggest branching the message on escape_secs -eq 0 (e.g. "escape disabled") - and, since the marker has no consumer while disabled, skipping the per-tick marker write in that case too.
  • ℹ️ bin/fm-supervise-daemon.sh:1443 - printf &#39;%s\n&#39; &#34;$(_now)&#34; &gt; &#34;$state/.subsuper-last-delivery&#34; 2&gt;/dev/null || true applies 2&gt;/dev/null after the output redirect, so if creating the marker fails (read-only or missing state dir) bash emits its own "cannot create" diagnostic on the still-unredirected stderr before the suppression takes effect. The exit status is swallowed by || true, but the noise is not. The streak-marker write four blocks earlier already uses the correct { printf ... &gt; &#34;$streak_marker&#34;; } 2&gt;/dev/null || true brace form; match it here.
✅ **Test** - passed

✅ No issues found.

  • bash tests/fm-daemon.test.sh (123 ok assertions, includes all new busy-guard escape / streak-marker / resolve_busy_guard_escape_secs / resolve_busy_empty_streak_step_max / delivery-logging tests)
  • bash tests/fm-afk-return.test.sh
  • bash tests/fm-afk-launch.test.sh
  • Manual e2e: drove the real inject_msg on a real FM_HOUSEKEEPING_TICK=2 cadence against a stubbed backend whose native agent-state reads busy forever with a confirmed-empty composer, with FM_BUSY_GUARD_ESCAPE_SECS=6, capturing state/.supervise-daemon.log, the text actually submitted, and state/.subsuper-last-delivery
  • Manual e2e baseline: same scenario run against git show 07bf0c8:bin/fm-supervise-daemon.sh (pre-fix daemon) - defers indefinitely, no delivery, no branch attribution in the log
  • Manual e2e opt-out: same scenario with FM_BUSY_GUARD_ESCAPE_SECS=0 - always-defer preserved, deferral lines still explain busy source, observed streak seconds, and threshold
⚠️ **Document** - 1 info
  • ℹ️ .agents/skills/afk/SKILL.md:179 - Pre-existing duplication, not introduced by this change: the "Injection hardening" bullet list re-states the busy/composer guard contract already owned by the earlier delivery-guard prose (lines 95-108), and now describes only the deferring half of the busy guard with no mention of the bounded escape. Nothing there contradicts the code (the escape still requires an affirmatively empty composer, so "Only empty permits injection" stays true), so I did not synchronize a second copy per the placement policy. A follow-up could reduce that bullet to a pointer at the owning section instead; doing it here would be an out-of-scope restructure of unrelated prose.
✅ **Lint** - passed

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

@greptile-apps

greptile-apps Bot commented Aug 26, 2026

Copy link
Copy Markdown

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains.

Reviews (9): Last reviewed commit: "no-mistakes(document): correct FM_BUSY_G..." | Re-trigger Greptile

Comment thread bin/fm-supervise-daemon.sh Outdated
Comment thread bin/fm-supervise-daemon.sh Outdated
NicholasACTran added a commit to NicholasACTran/firstmate that referenced this pull request Aug 26, 2026
…rride

Two findings from the automated review on PR kunchenguid#3090, both real:

P1: the busy-guard-since marker persisted across away sessions, so a
marker left over from a prior run could read as already-overdue on the
very first busy+empty observation of a new session and escape
immediately into an active supervisor turn - the exact "guard state
outlives the run it measured" failure this ticket exists to prevent.
inject_msg now corroborates the marker against state/.afk's own stamped
session-start epoch (afk_enter already records this) and treats a
marker older than the current session, or unreadable/non-numeric, as no
marker at all.

P2: FM_BUSY_GUARD_ESCAPE_SECS was validated only with -gt 0, so a blank,
non-numeric, or negative override silently and permanently disabled the
escape (and a non-numeric value additionally threw a bash
integer-expression error on every attempt). It's now validated
explicitly: zero disables, a positive integer sets the interval, and
anything else is refused with a loud log line and the default applied.
Comment thread bin/fm-supervise-daemon.sh Outdated
NicholasACTran added a commit to NicholasACTran/firstmate that referenced this pull request Aug 26, 2026
…ading zeros

Re-review finding on PR kunchenguid#3090: the P2 validator accepted any digit-only
override and used it as-is, so a leading-zero value like 010 could be
misread by C-style octal arithmetic as eight instead of ten, and 008/009
are not even valid octal. Force base 10 explicitly (`10#`) when parsing
the validated digit string, so the effective interval always equals the
decimal value the operator wrote. Adds a regression test asserting the
effective interval for 007/008/009/010 and a plain positive, not just
that validation accepted the string.
Comment thread bin/fm-supervise-daemon.sh Outdated
Comment thread bin/fm-supervise-daemon.sh Outdated
NicholasACTran added a commit to NicholasACTran/firstmate that referenced this pull request Aug 26, 2026
… once

Captain's ruling on PR kunchenguid#3090 (option B): rather than patch a fifth
finding on the same durable-marker shape, redesign it. The previous
findings (a marker that outlives its away session, a config value
trusted without validating how it's read) both stem from persisting the
escape clock and its configuration to disk and re-deriving them on
every attempt.

- BUSY_GUARD_SINCE_EPOCH is now a plain in-process variable, not a
  state/.subsuper-busy-guard-since file. inject_msg is only ever called
  from housekeeping and the daemon's own shutdown trap, both inside
  fm_super_main's single long-lived process, so a restart (a new
  process) gets a fresh clock automatically - no corroboration against
  state/.afk is needed because there is no durable value to inherit.
- FM_BUSY_GUARD_ESCAPE_SECS is resolved exactly once, at daemon start,
  by the new resolve_busy_guard_escape_secs into
  BUSY_GUARD_ESCAPE_SECS_RESOLVED: 0 disables, a positive integer up to
  the new BUSY_GUARD_ESCAPE_SECS_MAX (86400s) sets the interval,
  anything else is refused once with a loud log and the default
  applied. The clamp closes the overflow risk in the forced-decimal
  (10#) parse.
- The marker file and its .afk-corroboration logic are removed entirely
  - nothing else in the repo read it.

Regression tests rewritten to match: the restart case is now a fresh
subshell (a fresh process) that must not inherit a prior subshell's
elapsed time; the config cases call resolve_busy_guard_escape_secs
directly and assert the resolved value, including the clamp boundary.
@kunchenguid

Copy link
Copy Markdown
Owner

Speaking as Kun's firstmate:

Reviewed HEAD 87a5f12bbcc3631524366c4596ee2eca0feb8699 (unstamped) vs main f7a387f44ed4fa942914e8944be4981a42e1e102. Full diff reviewed (bin/fm-supervise-daemon.sh, tests/fm-daemon.test.sh, docs/architecture.md, docs/configuration.md). Whole thread read (Greptile P1/P2 rounds and the three follow-up commits). No .github/workflows/* change. Not disguised security.

Class: corrective. Away-mode's max-defer retry re-enters the same busy guard, so a false-positive busy verdict (empty composer) produced unbounded silence. This bounds that disagreement to FM_BUSY_GUARD_ESCAPE_SECS (default 300; 0 disables), logs delivery, and names the busy-source branch. Escape still requires composer empty — never pending, unknown, or a dead-shell prompt.

This does not close #1956 or #3087. Those are watcher stale-hash / over-escalation bugs. This PR bounds captain-pane injection when the busy guard is stuck. No Fixes/Closes link; do not treat fetch sort hints as proof.

Sibling of #3089 (cause: in-pane claude+herdr launch). Complementary, not conflicting. This PR does not touch the launch path.

VISION (per rule):

  • One captain, one interface — aligns. Failures and deliveries become visible instead of 95 minutes of identical deferrals.
  • Authority is explicit — aligns. No new autonomy grant; existing away-mode delivery is bounded. Escape still requires an exact empty composer.
  • Scripts own the mechanics — aligns. Guard, clock, and parser are scripted.
  • A restart is a non-event — cannot tell yet. Cross-session stale markers are reset against .afk. Greptile's remaining same-session daemon-restart P1 is still open: a marker newer than this session's .afk can survive a restart and fire immediately.
  • Delegation with a spine — aligns. Supervision delivery is restored as a bounded path, not a new task shape.
  • The fleet outlives any vendor — aligns. Does not teach the guard to parse a private Herdr ruleset.
  • Scope — aligns. Command-layer injection bound, not workshop/merge policy.

What is not cleared:

  1. No no-mistakes-pipeline-attestation:v1 for this HEAD. Blocking for merge.
  2. Remaining Greptile P1: same-session daemon restart can reuse the busy-guard-since marker (it is newer than .afk) and inject on the first busy+empty observation. Answer or fix.
  3. Remaining Greptile P1: a digit-only FM_BUSY_GUARD_ESCAPE_SECS outside Bash signed arithmetic can overflow instead of falling back to the default. Answer or clamp.
  4. Fork CI / Require-no-mistakes were approved after this review; they had not finished at comment time.

Security: no.

This is waiting on the author, not the captain: matching attestation for this HEAD, and a response to the two remaining Greptile P1s.

Merge-eligible: NO. Captain-flag NOW: NO.

@kunchenguid

Copy link
Copy Markdown
Owner

Speaking as Kun's firstmate:

Reviewed the whole thread, every file in the full diff (bin/fm-supervise-daemon.sh, docs/architecture.md, docs/configuration.md, tests/fm-daemon.test.sh), later commit e7e588dd (in-process clock + once-at-start config resolve), and current main f7a387f44ed4fa942914e8944be4981a42e1e102 / VISION.md. Classification: corrective. No .github/workflows/* changes. No security finding.

VISION (per rule, inspected evidence):

  • One captain, one interface — aligns. Away-mode is supposed to deliver captain-relevant escalations; this bounds a proven 95-minute silence. Delivery logging (inject delivered + state/.subsuper-last-delivery) and busy-source naming make a healthy vs wedged session distinguishable without another investigation.
  • Authority is explicit — aligns. The escape fires only on an exact empty composer read (never pending/unknown/dead-shell). FM_BUSY_GUARD_ESCAPE_SECS=0 restores the old always-defer busy verdict. This is the missing half of the existing FM_MAX_DEFER_SECS bound, not a new unconsented captain-facing capability.
  • Scripts own mechanics / agents own judgment — aligns. Exact timing, validation, clamp (86400), and inject path stay in the daemon script; tests cover pre-threshold defer, post-threshold escape, zero-disable, restart-fresh-clock, decimal/leading-zero parse, clamp, and invalid fallback.
  • A restart is a non-event — aligns under the latest redesign. BUSY_GUARD_SINCE_EPOCH is in-process, so a new daemon process cannot inherit a prior session's clock (the stale-marker class of bugs). A restart does reset the 300s window; that is the explicit trade of dropping the durable marker.
  • Delegation with a spine — aligns. Injection remains gated; the change makes a stuck guard fail into a bounded delivery instead of unbounded silence.
  • The fleet outlives any vendor — aligns. Deliberately does not parse Herdr's private claude.toml ruleset.
  • Scope — aligns. Command-layer away-mode, not the workshop.

First-time fork CI: after that full-diff review I approved workflow runs 32938123307 (CI) and 32938123289 (Require no-mistakes) for HEAD e7e588dddcd41f03b3c89752c410d05bcb8d505a.

Not merging. No no-mistakes-pipeline-attestation:v1 matching this HEAD, and the no-mistakes body-compliance check failed. Sibling #3089 is the cause-fix; this is the bound. Waiting on the author for a matching no-mistakes attestation (and to land or hold the pair together as you asked on #3089). Not a captain-decision hold.

NicholasACTran added a commit to NicholasACTran/firstmate that referenced this pull request Aug 26, 2026
…ithmetic

Fifth review finding on PR kunchenguid#3090: the clamp checked $((10#$raw)) AFTER
converting the raw value, but bash arithmetic silently wraps on
overflow rather than erroring, so a sufficiently large digit-only
value could wrap back into an in-range number and defeat the clamp
entirely - the same class of bug as the earlier leading-zero misparse,
just on the other end of the range.

resolve_busy_guard_escape_secs now strips leading zeros with a pure
regex match and compares by digit count (falling back to a
same-length lexicographic compare, which equals numeric order for
leading-zero-free digit strings) before any arithmetic ever touches
the value. A value with more digits than the clamp, or tied on digits
but lexicographically past it, is rejected on that string comparison
alone. Added a regression test with a 38-digit input, asserting the
resolved value is exactly the default rather than merely "different
from the input" - a wrapped garbage value would also differ, so the
weaker assertion could pass on the bug.
Comment thread bin/fm-supervise-daemon.sh Outdated
NicholasACTran added a commit to NicholasACTran/firstmate that referenced this pull request Aug 26, 2026
…e age

Sixth review finding on PR kunchenguid#3090: the in-process escape clock (the
prior redesign) reset on every daemon restart, and the daemon has a
crash-loop guard that makes restarts a real, expected event - so a
persistent busy false-positive combined with repeated restarts could
defer delivery indefinitely, the exact failure this ticket exists to
end.

inject_msg no longer keeps a clock of its own. It measures how long
the current escalation has gone undelivered via the existing durable
state/.subsuper-escalations.since (already maintained by
escalate_add/escalate_flush for FM_MAX_DEFER_SECS): created when an
escalation lands in an empty buffer, removed the moment delivery
succeeds. That value is tied to live undelivered work rather than to
the daemon process's lifetime, so it is not stale data on a restart -
it is an honest measurement of how long this exact escalation has
genuinely gone undelivered. escalate_flush's own empty-buffer guard
means inject_msg is only ever reached here with the file present.

Keeps everything else from the round-4/5 redesign: config resolved
once at daemon start with the overflow-safe clamp, the old
per-busy-guard marker file gone, and the escape still firing only on
an exact composer 'empty' read.

Regression test rewritten to assert the property directly: two
entirely separate subshells sharing no bash state, only the durable
file, where the second must still see the same escalation as overdue.
This fails against the prior in-process-clock version.
@NicholasACTran NicholasACTran changed the title fix(afk): bound the away-mode busy guard and log delivery/source fix(bin): bound the away-mode busy guard and record delivery outcomes Aug 26, 2026
Comment thread bin/fm-supervise-daemon.sh Outdated
Comment thread bin/fm-supervise-daemon.sh
NicholasACTran and others added 12 commits August 26, 2026 04:55
The 2026-08-26 investigation found that away mode's max-defer escape
retries the exact busy guard that just failed, so a guard false positive
(the daemon's own in-pane launch method making an idle claude pane read
permanently busy) produced an unbounded 95-minute silence instead of a
bounded one. inject_msg now tracks how long a busy verdict has disagreed
with a provably-empty composer and delivers anyway once
FM_BUSY_GUARD_ESCAPE_SECS elapses, bounding any future guard false
positive to that window.

A successful delivery previously logged nothing, so a healthy away
session and a wedged one looked identical in the daemon log; a confirmed
submit now logs a line and writes a durable state/.subsuper-last-delivery
timestamp. The busy-guard deferral log line now also names which branch
decided (native agent-state vs the rendered-pane regex fallback), so the
next occurrence is explainable by grep instead of an investigation.
…rride

Two findings from the automated review on PR kunchenguid#3090, both real:

P1: the busy-guard-since marker persisted across away sessions, so a
marker left over from a prior run could read as already-overdue on the
very first busy+empty observation of a new session and escape
immediately into an active supervisor turn - the exact "guard state
outlives the run it measured" failure this ticket exists to prevent.
inject_msg now corroborates the marker against state/.afk's own stamped
session-start epoch (afk_enter already records this) and treats a
marker older than the current session, or unreadable/non-numeric, as no
marker at all.

P2: FM_BUSY_GUARD_ESCAPE_SECS was validated only with -gt 0, so a blank,
non-numeric, or negative override silently and permanently disabled the
escape (and a non-numeric value additionally threw a bash
integer-expression error on every attempt). It's now validated
explicitly: zero disables, a positive integer sets the interval, and
anything else is refused with a loud log line and the default applied.
…ading zeros

Re-review finding on PR kunchenguid#3090: the P2 validator accepted any digit-only
override and used it as-is, so a leading-zero value like 010 could be
misread by C-style octal arithmetic as eight instead of ten, and 008/009
are not even valid octal. Force base 10 explicitly (`10#`) when parsing
the validated digit string, so the effective interval always equals the
decimal value the operator wrote. Adds a regression test asserting the
effective interval for 007/008/009/010 and a plain positive, not just
that validation accepted the string.
… once

Captain's ruling on PR kunchenguid#3090 (option B): rather than patch a fifth
finding on the same durable-marker shape, redesign it. The previous
findings (a marker that outlives its away session, a config value
trusted without validating how it's read) both stem from persisting the
escape clock and its configuration to disk and re-deriving them on
every attempt.

- BUSY_GUARD_SINCE_EPOCH is now a plain in-process variable, not a
  state/.subsuper-busy-guard-since file. inject_msg is only ever called
  from housekeeping and the daemon's own shutdown trap, both inside
  fm_super_main's single long-lived process, so a restart (a new
  process) gets a fresh clock automatically - no corroboration against
  state/.afk is needed because there is no durable value to inherit.
- FM_BUSY_GUARD_ESCAPE_SECS is resolved exactly once, at daemon start,
  by the new resolve_busy_guard_escape_secs into
  BUSY_GUARD_ESCAPE_SECS_RESOLVED: 0 disables, a positive integer up to
  the new BUSY_GUARD_ESCAPE_SECS_MAX (86400s) sets the interval,
  anything else is refused once with a loud log and the default
  applied. The clamp closes the overflow risk in the forced-decimal
  (10#) parse.
- The marker file and its .afk-corroboration logic are removed entirely
  - nothing else in the repo read it.

Regression tests rewritten to match: the restart case is now a fresh
subshell (a fresh process) that must not inherit a prior subshell's
elapsed time; the config cases call resolve_busy_guard_escape_secs
directly and assert the resolved value, including the clamp boundary.
…ithmetic

Fifth review finding on PR kunchenguid#3090: the clamp checked $((10#$raw)) AFTER
converting the raw value, but bash arithmetic silently wraps on
overflow rather than erroring, so a sufficiently large digit-only
value could wrap back into an in-range number and defeat the clamp
entirely - the same class of bug as the earlier leading-zero misparse,
just on the other end of the range.

resolve_busy_guard_escape_secs now strips leading zeros with a pure
regex match and compares by digit count (falling back to a
same-length lexicographic compare, which equals numeric order for
leading-zero-free digit strings) before any arithmetic ever touches
the value. A value with more digits than the clamp, or tied on digits
but lexicographically past it, is rejected on that string comparison
alone. Added a regression test with a 38-digit input, asserting the
resolved value is exactly the default rather than merely "different
from the input" - a wrapped garbage value would also differ, so the
weaker assertion could pass on the bug.
…e age

Sixth review finding on PR kunchenguid#3090: the in-process escape clock (the
prior redesign) reset on every daemon restart, and the daemon has a
crash-loop guard that makes restarts a real, expected event - so a
persistent busy false-positive combined with repeated restarts could
defer delivery indefinitely, the exact failure this ticket exists to
end.

inject_msg no longer keeps a clock of its own. It measures how long
the current escalation has gone undelivered via the existing durable
state/.subsuper-escalations.since (already maintained by
escalate_add/escalate_flush for FM_MAX_DEFER_SECS): created when an
escalation lands in an empty buffer, removed the moment delivery
succeeds. That value is tied to live undelivered work rather than to
the daemon process's lifetime, so it is not stale data on a restart -
it is an honest measurement of how long this exact escalation has
genuinely gone undelivered. escalate_flush's own empty-buffer guard
means inject_msg is only ever reached here with the file present.

Keeps everything else from the round-4/5 redesign: config resolved
once at daemon start with the overflow-safe clamp, the old
per-busy-guard marker file gone, and the escape still firing only on
an exact composer 'empty' read.

Regression test rewritten to assert the property directly: two
entirely separate subshells sharing no bash state, only the durable
file, where the second must still see the same escalation as overdue.
This fails against the prior in-process-clock version.
The busy+empty streak marker's wall-clock age counted time the daemon
never observed the pane at all: the main loop skips inject_msg entirely
during the pane-gone backoff, a crash backoff, and a restart between
flushes, so the first busy+empty observation after a threshold-length
gap escaped immediately and typed into a pane that may have just
genuinely started a turn.

The marker now records observed seconds in its contents and the last
observation time in its mtime, so each delivery attempt credits at most
one poll interval. A corrupted or oversized value fails closed at 0
without entering arithmetic.
…scape

Each observed busy+empty tick credited at most HOUSEKEEPING_TICK_DEFAULT
(15s) toward FM_BUSY_GUARD_ESCAPE_SECS, regardless of the cadence the
daemon actually retries a buffered escalation at. On a slower cadence
that multiplied the configured window by tick/15 - a 60s
FM_HOUSEKEEPING_TICK stretched the documented 300s bound into ~1200s of
away-mode silence, which is the unbounded-silence failure this escape
exists to cap.

BUSY_EMPTY_STREAK_STEP_MAX is now resolved once at daemon start from the
effective FM_HOUSEKEEPING_TICK. A zero, negative, non-numeric, or
absurdly large cadence cannot describe a real poll interval, so it is
refused with one log line and the default applied - never a 0s credit
(an escape that never fires) or a whole day at once. The decimal-safe,
overflow-safe parse the escape resolver already used is factored into
_resolve_secs_override and shared by both.
@NicholasACTran
NicholasACTran force-pushed the fm/firstmate-afk-escape-and-delivery-logging branch from ca51c5c to bdec393 Compare August 26, 2026 09:07
@NicholasACTran NicholasACTran changed the title fix(bin): bound the away-mode busy guard and record delivery outcomes fix(bin): bound the away-mode busy guard and log delivery outcomes Aug 26, 2026
@kunchenguid

Copy link
Copy Markdown
Owner

Speaking as Kun's firstmate:

Re-triage of newer activity since stamp 2026-08-26T06:28:10Z outcome=waiting-author. Last pass: corrective, waiting on attestation + same-session restart P1; sibling hold with #3089; fork CI approved then for HEAD e7e588dd.

New HEAD bdec3935b19b29d984a4ac7f6ba14cb00b6fc1ca vs current main 07bf0c8556c70b5241516efad0e51f802484c748. Whole thread re-read (prior firstmate comments, Greptile P1 sequence through round 8, latest Greptile success on this HEAD). Full diff re-reviewed: bin/fm-supervise-daemon.sh, bin/fm-afk-launch.sh, bin/fm-afk-start.sh, bin/fm-afk-return.sh, .agents/skills/afk/SKILL.md, docs/architecture.md, docs/configuration.md, tests/fm-daemon.test.sh, tests/fm-afk-launch.test.sh, tests/fm-afk-return.test.sh. No .github/workflows/*. Not disguised security.

Class: corrective. The busy-guard escape is now a durable observed-seconds streak (state/.subsuper-busy-empty-streak-since: contents = observed seconds, mtime = last observation, credit capped at one poll interval). Fail-closed on corrupt/uncreatable markers. Config resolved once at start with overflow-safe string clamp. Delivery logs inject delivered and stamps state/.subsuper-last-delivery. Busy-source branch is named on deferral. FM_BUSY_GUARD_ESCAPE_SECS=0 restores always-defer.

This does not close #1956 or #3087. No Fixes/Closes in the author body; this bounds captain-pane injection when the busy guard is stuck, it does not fix watcher stale-hash / over-escalation.

Last-pass blockers, inspected on this HEAD:

  1. Matching no-mistakes-pipeline-attestation:v1 — now present for bdec3935.
  2. Same-session restart P1 (marker newer than .afk firing immediately) — addressed. Marker stores observed seconds, not a wall-clock start; unobserved gaps (pane-gone, crash backoff, restart between flushes) credit at most one poll interval; early returns that never observe the pane clear the marker; a restart continues the observed streak rather than inheriting elapsed wall-clock. Covered by test_inject_msg_busy_guard_restart_does_not_reset_deadline and the unobserved-gap tests.
  3. Overflow P1 — addressed by _resolve_secs_override string-length clamp before any arithmetic, with a 38-digit fallback-to-default test.

VISION (per rule, inspected evidence):

  • One captain, one interface — aligns. Failures and deliveries become visible; a 95-minute identical-deferral silence is bounded.
  • Authority is explicit — aligns. Escape still requires composer empty (never pending/unknown/dead-shell). =0 restores the old always-defer. Not a new unconsented captain-facing capability.
  • Scripts own the mechanics — aligns. Guard, observed-tick clock, parsers, and clamp stay in the daemon script.
  • A restart is a non-event — aligns under this redesign. Durable observed-seconds marker survives a crash restart (so the bound cannot be postponed forever by the crash-loop guard) without treating unobserved wall-clock as disagreement. Fresh away-session entry still clears it via fm_afk_clear_stale_artifacts.
  • Delegation with a spine — aligns. Supervision delivery is restored as a bounded path.
  • The fleet outlives any vendor — aligns. Still does not parse Herdr's private ruleset.
  • Scope — aligns. Command-layer away-mode.

First-time fork CI: last pass approved runs for old HEAD e7e588dd. This HEAD was again action_required. After this re-review I approved workflow runs 32951339102 (CI), 32951339195 (Require no-mistakes), and 32951401675 (Require no-mistakes).

Not merging. Attestation now matches, remaining P1s look answered, but forge CI on this HEAD had not run yet, and the author asked the cause-fix (#3089 / later #3100) to land with this bounding fix. Still a sibling hold. Not a captain-decision hold.

Merge-eligible: NO. Captain-flag NOW: NO.

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