fix(afk): stop the away daemon from self-injecting on claude+herdr - #3100
fix(afk): stop the away daemon from self-injecting on claude+herdr#3100NicholasACTran wants to merge 11 commits into
Conversation
Confidence Score: 5/5The PR appears safe to merge. No blocking failure remains. Reviews (3): Last reviewed commit: "fix(afk): decide the self-injection guar..." | Re-trigger Greptile |
…cked path A Claude Code background shell hosting the sub-supervisor daemon inside the captain's own pane leaves a footer token that herdr's claude agent-detection ruleset misreads as permanently "working", so the away-mode busy guard can never read that pane idle and no escalation is ever delivered. Root cause documented in data/firstmate-afk-daemon-wedged-investigation/report.md (2026-08-26): 408 identical busy-guard deferrals over 95 minutes, versus a clean 2h22m run on the terminal-backed FM_SUPERVISOR_TARGET path. Route claude on the herdr backend through bin/fm-afk-launch.sh start (the non-visible tracked terminal path) instead of start-native, and correct the "it is fine" belief recorded in the afk skill and both launcher scripts' header comments. Other harness/backend combinations are unaffected: tmux's rendered busy-fallback regex does not match the background-shell footer token, so only herdr's native agent-status path is implicated. Deliberately not implemented: teaching the busy guard to subtract the daemon's own footer contribution, since that couples firstmate to a private, versioned herdr ruleset that already changes without notice.
docs/herdr-backend.md "Away-mode supervisor support" still described native tracked-background execution as safe for every harness with the capability, casting the terminal-backed launcher as the Pi-only fallback. That contradicted the routing fix in .agents/skills/afk/SKILL.md and bin/fm-afk-launch.sh: Claude on Herdr must not host the away daemon in its own pane, because Herdr's Claude agent-detection ruleset reads the daemon's own background-shell footer token as the agent working, at a priority that beats the idle rule, which wedges the busy guard for the life of the session. State the same exception here, in this doc's own voice, so a session that reads this file first gets the same answer as one that reads the skill first.
The launcher's start-native guard only covers entry through bin/fm-afk-launch.sh. This script is also a documented direct entry point (the native two-step's second half, and a bare direct call), so the same footer-wedge failure was still reachable by skipping the launcher entirely. Key the refusal on hosting mode, not harness+backend alone: the launcher's terminal-backed path also execs this script but always passes FM_SUPERVISOR_TARGET explicitly, so its presence marks an already-safe, launcher-hosted invocation and must stay permitted.
c28edfe to
750b498
Compare
…w its diagnostic The guard must refuse before any lifecycle state is written so a refusal leaves nothing to roll back - tracking that this invocation wrote .afk and reverting it afterward was a repair, not the contract. Reorder fm_afk_start_main so the daemon-lock refresh check and the guard's decision both happen before fm_afk_start_ensure_flag runs. Also narrow the guard's "cannot check myself" diagnostic: it fired whenever either side was unresolvable, including when herdr is provably absent (no TMUX_PANE, no HERDR_ENV+HERDR_PANE_ID) and there is no real ambiguity to report. Resolve the backend first and permit silently when it isn't herdr; only warn when herdr is confirmed present and the pane comparison itself cannot be completed.
|
Speaking as Kun's firstmate: Reviewed HEAD Class: corrective. Claude+herdr hosting the away daemon in the pane it injects into leaves a footer token Herdr maps to working, so the busy guard never delivers. This does not close #1956 or #3087. Those are watcher stale-hash / over-escalation bugs. No Sibling of #3090 (bounding escape) and overlapping #3089 (earlier docs/skill-only cause fix). #3089's author asked to hold that cause-fix with #3090. These three share AFK launch/start/skill files — not landing #3100 alone. VISION (per rule, inspected evidence):
First-time fork CI: after that full-diff review I approved workflow runs Not merging. Attestation matches this HEAD, but forge CI had not run yet, and the cause-fix stays paired with #3090 / #3089. Waiting on CI (and that pairing), not a captain decision. Merge-eligible: NO. Captain-flag NOW: NO. |
Intent
Fix a real bug where a Claude-primary + herdr away-mode setup silently never runs the away-mode daemon. The invariant: the daemon must never inject escalations into the very pane it is itself hosted in, on a claude primary under herdr - that self-injection is the wedge (a claude background shell renders a footer token herdr's claude-detection reads as 'the agent is working', so escalations defer forever). The fix (1) routes claude+herdr through the terminal-backed launcher in .agents/skills/afk/SKILL.md, (2) updates docs/herdr-backend.md to state the exception in its own voice, (3) enforces a guard in bin/fm-afk-launch.sh's start-native path refusing claude+herdr entirely (that path is always same-pane by definition), and (4) enforces a second guard in bin/fm-afk-start.sh's own direct entry point - since that script is also a documented direct entry point the launcher's guard alone does not cover - keyed on comparing this process's own raw pane (ignoring the FM_SUPERVISOR_TARGET override) against the actual injection target (which does honor that override): same pane on claude+herdr is refused, different panes (the launcher's own separate terminal, or an operator override pointing elsewhere) stay permitted. A direct claude+herdr entry that manually sets FM_SUPERVISOR_TARGET to a pane other than where it is actually running is deliberately PERMITTED - that is the case distinguishing this design from a simpler 'is FM_SUPERVISOR_TARGET set' signal, which review proved could be fooled in both directions. The guard decides before writing any lifecycle state (so a refusal leaves nothing to roll back) and stays silent when herdr is provably absent (only warns when herdr is confirmed present and the pane comparison itself cannot be completed). CI cannot run on this PR: GitHub holds workflow runs from a first-time fork contributor at action_required until a repo maintainer approves them, which is expected and accepted on this repo - the finish line is PR open, correct, and carrying the no-mistakes attestation over the actual current head, not green CI. PR 3100 is already open on this branch against https://github.com/kunchenguid/firstmate, pushed via the fork https://github.com/NicholasACTran/firstmate.git (captain-approved via no-mistakes init --fork-url, since this machine's GitHub account is pull-only on origin).
What Changed
.agents/skills/afk/SKILL.mdnow sends that pair to the terminal-backedbin/fm-afk-launch.sh start, anddocs/herdr-backend.mdstates the exception and its enforcement in its own voice. A claude background shell leaves a footer token that herdr's claude agent-detection ruleset reads as "the agent is working", so the away-mode busy guard defers every escalation for the life of the session.bin/fm-afk-launch.sh start_nativerefuses the claude+herdr pair outright (that path is same-pane by definition), andbin/fm-afk-start.shadds a second guard for its own direct entry point, keyed on comparing this process's raw pane (fm_afk_start_own_backend/fm_afk_start_own_pane, which ignoreFM_SUPERVISOR_TARGET) against the real injection target fromdiscover_supervisor_target(which honors it). Same pane on claude+herdr is refused; the launcher's separate pane and an override naming a different pane stay permitted. Both guards decide before any lifecycle state is written, and the start guard stays silent unless herdr is confirmed present but the pane comparison cannot complete.fm_afk_start_ensure_flagso the refresh and fresh-start branches each write it only after deciding to proceed, and extendtests/fm-afk-launch.test.shwith cases covering refusal-with-no-state-written, the refusal message namingstart, claude on non-herdr, non-claude on herdr, launcher-hosted entry, and both override directions;tests/fm-afk-return.test.shandtests/fm-daemon.test.shpin backend/pane env at affected call sites so the new guards do not fire on ambient environment.Risk Assessment
✅ Low: The change adds a narrowly-keyed same-pane refusal at the single exec chokepoint for the away daemon, correctly permits every launcher-hosted and non-claude/non-herdr path I traced, writes no lifecycle state on refusal, and is covered by behavior-executing tests whose ambient-environment pinning is load-bearing on the right variables; the three remaining findings are informational (dead diagnostic branches, an over-stated comment on the refresh carve-out, and a knob-honoring launcher guard whose residue is recoverable via
stop).Testing
I ran the three test files the change touches (fm-afk-launch, fm-afk-return, fm-daemon) and all pass, then proved the two new guard tests are real regressions by running the new test file against the base commit, where the claude+herdr refusal assertions fail exactly as the bug describes. For product-level evidence I drove the actual user scenario on a live herdr session: as a Claude captain in a real herdr pane,
bin/fm-afk-launch.sh start-nativeand a directbin/fm-afk-start.shboth refuse with the wedge explanation and point atbin/fm-afk-launch.sh start, leaving the state directory completely empty (nothing to roll back), while the prescribedstartpath succeeds and places the daemon in a separate non-visible workspace (workspaces 1 to 2, captain tab pane count unchanged at 1, daemon tab w2:t1 vs captain tab w1:t1), and runningbin/fm-afk-start.shfrom that daemon pane is permitted since it is a different pane from the injection target.stoprestores the original topology and clears state. This is a shell/CLI surface with no rendered UI, so the evidence is a CLI transcript rather than a screenshot.Evidence: Claude captain on a real herdr session: refusal at both entry points, then successful terminal-backed away-mode entry
Source: Claude captain on a real herdr session: refusal at both entry points, then successful terminal-backed away-mode entry
Evidence: Guard assertions fail on base 07bf0c8, pass on target b1cd4ec
Pipeline
Updates from git push no-mistakes
✅ **intent** - passed
✅ No issues found.
✅ **Rebase** - passed
✅ No issues found.
bin/fm-afk-start.sh:117- Both "cannot check itself" diagnostics in fm_afk_start_native_refused are unreachable, so the guard can never warn. Control reaches line 117 only when own_backend == herdr, which by fm_afk_start_own_backend (lines 81-91) requires TMUX_PANE empty AND HERDR_ENV=1 AND HERDR_PANE_ID non-empty. fm_afk_start_own_pane (lines 93-103) tests the identical two conditions, so it always takes its herdr branch and returns 0 - the line 118 message is dead. discover_supervisor_target under the same state returns 0 via the override branch or the HERDR_ENV+HERDR_PANE_ID compose branch, so the line 123 message is dead too (round 3 already noted this one as no-op). Net effect on the intent's phrase "only warns when herdr is confirmed present and the pane comparison itself cannot be completed": that warning never fires, and the one genuinely ambiguous state - HERDR_ENV=1 with HERDR_PANE_ID unset or empty - is classified by fm_afk_start_own_backend as "herdr provably absent" and permitted silently at line 114. Since own_backend and own_pane branch on identical predicates, fm_afk_start_own_backend plus the line 117-120 block could collapse into a single own_pane resolve. Noted so a future reader does not treat either message as a live failure mode; the user has already ruled twice on this diagnostic surface.bin/fm-afk-start.sh:243- The comment at line 243 asserts the refresh branch above it returned "a refresh of an already-live, already-safely-hosted daemon". The "already-safely-hosted" half is not established by anything the code checks: daemon_lock_held_by_live_daemon only proves a live daemon owns the lock, not where it is hosted. Concrete state - the exact one on the machine this fix targets - a claude+herdr captain that entered away mode before this change and is running the self-hosted, wedged in-pane daemon right now. Re-running /afk takes bin/fm-afk-launch.sh start, whose own refresh branch (bin/fm-afk-launch.sh:483-491) also returns 0 with "daemon already running; refreshed away-mode flag"; a bare bin/fm-afk-start.sh returns 0 at line 239. Neither guard fires, so the wedged daemon survives and away mode keeps silently delivering nothing until it dies. Refusing the refresh would not unwedge it either, so this is not obviously the wrong behavior - but the fix only takes effect on a genuinely fresh start, meaning verifying it on the reporting machine requires bin/fm-afk-launch.sh stop before bin/fm-afk-launch.sh start. Worth knowing before the fix is validated live; the comment's premise should not be read as a checked invariant.bin/fm-afk-launch.sh:547- fm_afk_launch_native_refused keys on discover_supervisor_backend, which honors the FM_SUPERVISOR_BACKEND override, while the sibling guard in bin/fm-afk-start.sh:114 deliberately reads the raw HERDR_ENV/HERDR_PANE_ID identity precisely because review proved an env-knob signal can be fooled in both directions. Concrete path on a real claude+herdr captain:FM_SUPERVISOR_BACKEND=tmux bin/fm-afk-launch.sh start-nativeresolves backend=tmux, is permitted, and proceeds to clear stale artifacts, write state/.afk, and write anone\t-\tnativeterminal record - and tests/fm-afk-launch.test.sh:544-553 pins that permit as correct. The wedge itself stays unreachable because the second step (fm-afk-start.sh) ignores that override and refuses on the raw same-pane comparison, so this is not a hole in the invariant. The residue is that the operator is left holding prepared lifecycle state and a terminal record for a daemon that can never start, requiring bin/fm-afk-launch.sh stop to roll back, where a raw-identity keying in the launcher would have refused before writing anything. Requires an unusual manual override, so low practical exposure.✅ **Test** - passed
✅ No issues found.
bash tests/fm-afk-launch.test.sh(65 assertions, includes the newunit_native_refuses_claude_on_herdrandunit_start_refuses_self_injecting_claude_on_herdr, plus the real-herdr and real-tmux topology e2e blocks)bash tests/fm-afk-return.test.sh(pinned-env re-entry gate call site)bash tests/fm-daemon.test.sh(103 assertions; the two lock-reclamation call sites re-pinned withAFK_START_ENV)Regression proof: exported base commit 07bf0c8 to a temp dir, copied in the newtests/fm-afk-launch.test.sh, and confirmed the guard assertions fail there (native guard: claude+herdr native launch was accepted,start guard: claude+herdr self-injection was accepted,start guard: an operator-set override reopened the self-injection wedge) and pass on b1cd4ecManual end-to-end on a live herdr lab session: created a captain workspace, ranbin/fm-afk-launch.sh start-native,bin/fm-afk-start.sh,bin/fm-afk-launch.sh start,bin/fm-afk-start.shfrom the daemon pane, andbin/fm-afk-launch.sh stopas a CLAUDECODE=1 / HERDR_ENV=1 captain, capturing the transcript and herdr workspace/pane counts✅ **Document** - passed
✅ No issues found.
✅ **Lint** - passed
✅ No issues found.
✅ **Push** - passed
✅ No issues found.