# Watcher continuity This document explains how Firstmate keeps the watcher re-armed after a wake, how wakes are ordered and acknowledged, and which tests and live evidence cover that contract. Read it when debugging a supervision gap or changing a harness's re-arm path. The watcher remains intentionally one-shot: one actionable reason closes one watcher cycle. Must-work continuity now lives above that process boundary instead of depending on the model remembering a re-arm step. In this document, an arm is one run of `bin/fm-watch-arm.sh`, which starts a watcher cycle or attaches to one and returns the cycle's reason. | Topic | Section | | --- | --- | | Which component re-arms the watcher on each harness | [Ownership](#ownership) | | What happens between an actionable close and the wake reaching the model | [Actionable wake ordering](#actionable-wake-ordering) | | How a watcher-downtime episode is announced and retired | [Recovery episode acknowledgement](#recovery-episode-acknowledgement) | | How each actor consumes the wake queue | [Per-actor acknowledgement](#per-actor-acknowledgement) | | What `bin/fm-watch-arm.sh` guarantees about each cycle | [Arm-layer cycle contract](#arm-layer-cycle-contract) | | Which test suites pin these contracts | [Regression coverage](#regression-coverage) | | What is not guaranteed, and where live evidence lives | [Active limits and verification](#active-limits-and-verification) | ## Ownership On Pi, omp, OpenCode, Cursor, and Claude primaries, one component owns re-arming the watcher. Codex and Grok keep their own protocols; see [Manual recovery and other harnesses](#manual-recovery-and-other-harnesses). | Harness | Re-arm owner | | --- | --- | | Pi | `.pi/extensions/fm-primary-pi-watch.ts` | | omp | `.omp/extensions/fm-primary-omp-watch.ts` | | OpenCode | `.opencode/plugins/fm-primary-watch-arm.js` | | Cursor | `.cursor/hooks.json` `stop` hook (`bin/fm-turnend-guard-cursor.sh`) | | Claude | `.claude/settings.json` Stop/StopFailure `asyncRewake` hook (`bin/fm-claude-stop-autoarm.sh`) | On a non-Pi primary, a home that runs the supervision host also changes what the owner runs; see [Supervision host](#supervision-host). ### Pi, omp, and OpenCode adapters Pi's `.pi/extensions/fm-primary-pi-watch.ts`, omp's `.omp/extensions/fm-primary-omp-watch.ts`, and OpenCode's `.opencode/plugins/fm-primary-watch-arm.js` own continuous re-arm after an actionable child close. Each adapter: - Starts the next arm before delivering the wake prompt. - Checks current session-lock ownership at launch. - Preserves one child or scheduled retry at a time. - Applies bounded exponential retry after an unexpected or failed close. Pi treats an arm child whose process is already gone as an empty slot even while its close event is still pending, so a repair call or a scheduled retry starts a fresh arm instead of answering unchanged. The actionable restoration path applies the same live-child check after readiness, so output retained by a dead child's descendant cannot satisfy successor verification. Pi also settles an arm child on its process exit event, not only on stdio close, so a child that dies while a descendant still holds its pipes takes the same bounded retry. If a complete actionable line arrives while those pipes drain after exit, it replaces the provisional failure retry and takes the ordinary actionable restoration path. A failed follow-up never cancels continuity restoration. ### Pi session replacement Pi same-process session replacement follows the generation-owner contract in `.pi/extensions/fm-primary-pi-watch.ts`: 1. `session_shutdown` changes the current generation's durable extension marker from `active` to `handoff`, but keeps its established arm child alive. 2. The owning `session_start` publishes a distinct active generation. 3. That `session_start` commits its tracked replacement arm. 4. Only after that commit does the replacement arm retire the predecessor. A state-scoped replacement handoff carries every actionable close whose delivery overlapped `session_shutdown`, including: - A main follow-up Pi accepted but had not yet consumed. - Branch handling. - A retiring child that reports after the successor claim. A handoff marker never satisfies the extension-ownership tolerance. So a running Pi process whose replacement did not load this extension is reported as missing, rather than borrowing stale load evidence from its predecessor. A main follow-up counts as delivered once Pi accepts it, never once the model reads it. The reason is that a follow-up queued while main is streaming joins the running run without a `before_agent_start`. The extension header owns how consumption is observed and why it only decides what a replacement replays. ### omp session replacement omp's replacement follows its own generation-owner contract in `.omp/extensions/fm-primary-omp-watch.ts`, whose header owns its differences from Pi: - It retires the predecessor arm at replacement shutdown instead of retaining it across the handoff. - It reports no shutdown reason, so every shutdown with a pending actionable close persists the handoff for the next owning `session_start` to replay. ### Cursor stop hook Cursor's `.cursor/hooks.json` `stop` hook (`bin/fm-turnend-guard-cursor.sh`) owns routine tokenless re-arm for a Cursor primary. It re-arms by parking that awaited hook on `bin/fm-watch-arm.sh` and returning an actionable close as one follow-up. [`turnend-guard.md`](turnend-guard.md#harness-integrations) owns its Pi-host stand-down, loop bounds, and supersession baton. ### Claude Stop hook Claude's `.claude/settings.json` Stop/StopFailure `asyncRewake` hook (`bin/fm-claude-stop-autoarm.sh`) owns routine tokenless re-arm. Do not run the hook as a manual arm from a tool turn: a short-lived tool process cannot own its park; its header and help own the invocation contract. The hook fires on every Stop and StopFailure, covering API-error turn ends that skip Stop entirely. On either event, an eligible primary with supervision need admits one home-scoped owner, which foregrounds `bin/fm-watch-arm.sh` inside the hook-owned process tree. If a session exits mid-cycle and leaves an orphaned auto-arm hook, the replacement session's first eligible Stop or StopFailure supersedes its claim, attaches to the running watcher through `bin/fm-watch-arm.sh`, and delivers the cycle's close. [`turnend-guard.md`](turnend-guard.md#auto-arm-generation-claim) owns the open-claim definition. While supervision is still needed and away mode remains inactive, an actionable close wakes the idle session through exit 2. StopFailure-driven continuations are paced rather than dropped during persistent API errors; after waiting, the hook stands down silently if away mode appeared or supervision is no longer needed. The script header owns the storm-bound mechanics, [configuration](configuration.md#environment-variables) lists the cooldown override, and [StopFailure verification](verification/supervision.md#stopfailure-auto-arm) records the Herdr-hosted evidence and deterministic regression entry point. ### Claude session-lock ownership The hook handles the session lock as follows: - A well-formed session-lock owner is a numeric PID, or a tagged `win:` value on a Cygwin-family Git Bash host. The shared `fm_session_lock_pid_blocks_reclaim` verdict in `bin/fm-session-lock-lib.sh` is the one decision every recovery caller applies to that identity: an all-zero, proven-dead, reused non-harness, or live-but-unsignalable record stays reclaimable, while a readable verified harness and a live owner whose identity cannot be read keep recovery inert. That library also owns the process-info fallback used when the `ps` binary cannot execute. - An absent lock keeps the competing hook inert: with no recorded owner there is nothing stale to recover. - A malformed, empty, or otherwise unusable record is not an identity, so it never reaches that verdict: the competing hook stays inert, and a later `bin/fm-lock.sh` acquisition replaces the record. Whether the session owns that lock is the shared `fm_session_lock_owned_by_self` verdict in `bin/fm-session-lock-lib.sh`. That verdict accepts either of two cases: - A recorded pid inside the current harness ancestry. - A live lock recorded under this same trusted Claude session id. With that verdict, a background session keeps arming after its transient helper chain is recycled. [`turnend-guard.md`](turnend-guard.md#guard-predicates) owns the Claude guard's behavior when that live owner is genuinely another session. The stale-owner claim occurs only after the existing AFK and supervision-need gates pass. ### Claude arm failures After each non-actionable arm close, the hook rechecks the identity-matched watcher lock and fresh beacon before retrying a bounded number of times. The beacon is `state/.last-watcher-beat`, which only the watcher process touches. - A cycle-end failure is benign when that live-watcher predicate is true. In that case the hook suppresses the arm output and continues silently. [`turnend-guard.md`](turnend-guard.md#failure-progression-and-block-budget) owns the failure-notice commit contract, monotonic failure progression, attended-alarm writers, suppression reasons, positive recovery reset, and session-bound interruption isolation. ### Supervision host On a non-Pi primary, a home that runs the supervision host runs `bin/fm-supervision-host.sh` in place of the arm its re-arm owner would start. The host owns successive watcher cycles through the same arm. [supervision-host.md](supervision-host.md#failure-direction) owns the default hand-back's downtime restoration, including when the successor already exited. Its [acknowledged hand-back](supervision-host.md#acknowledged-hand-back) section owns the opt-in Claude transfer that preserves supervision and retries delivery until main acknowledges the specific queued check. The arm's recovery and acknowledgement contracts below still apply. ## Actionable wake ordering This section covers what each re-arm owner does between an actionable close and the wake reaching the model. ### Pi, omp, and OpenCode successor start After an actionable Pi, omp, or OpenCode child close, the adapter: 1. Waits for the predecessor process to close. 2. Starts and verifies one singleton successor. 3. Confirms the handling handoff before scheduling the follow-up: Pi confirms against the restoration's own recovery token, while omp and OpenCode confirm against the current successor. 4. Delivers the original wake. A complete Pi reason line can be observed while the predecessor is still finishing durable cleanup. That line is retained for replacement handoff, but the adapter never treats that already-ready predecessor as its own successor. The successor start and the wake delivery are separate steps on Pi. A successor start never waits behind a wake delivery that is still in flight, so a close that arrives while an earlier wake is still being delivered to the supervision branch re-arms immediately instead of leaving the fleet unwatched for the length of that delivery. Delivery to the branch stays serialized: one wake is delivered at a time, each wake only after its own close's successor start has settled, and the deliveries keep their order. The handling handoff is confirmed inside the successor start, while that successor is still the live arm, and the delivery reads the confirmed outcome. A successor that a later close already replaced is therefore never confirmed as a failed handoff, and a healthy newer successor is never reported to main as a restoration failure. A verified successor that fails while a successor start or a wake delivery is in flight keeps its bounded retry for the end of that operation, and the operation that held the guard settles it. If the handoff confirmation fails, the adapter retries it once: Pi against that same token, omp and OpenCode against the current generation and successor. A failed confirmation is a restoration failure: the adapter classifies the error and surfaces exactly one typed message. Pi retires the current successor only when the failed token names its exact watcher pid and generation and that pid is no longer alive, while omp and OpenCode retire the current successor whenever the restoration's watcher pid is no longer alive. On Pi a generation mismatch means a newer pipeline superseded this delivery mid-restore, so the wake routes like a confirmed delivery, with no failure appendix, and nothing is retired. An already-acknowledged episode confirms as a no-op when the confirmation names its generation, because the drain acknowledged it after the successor started but before the confirmation ran. The Pi extension diagnostic log is opt-in and off by default: only a positive FM_WATCH_EXTENSION_LOG_KEEP_LINES value appends restore attempts, readiness timeouts, and confirmation targets and results to state/.watch-extension.log, a bounded record that never changes supervision behavior. docs/configuration.md owns the knob's default and accepted values. A failed confirmation is never swallowed. ### Readiness timeout and retry The adapter waits at most one readiness timeout per attempt. If the successor is not ready in that time, the adapter sends TERM and waits a bounded retirement confirmation before the next lock-verified exponential retry. If the unready arm does not retire within that bound, the adapter keeps ownership, starts no overlapping retry, and delivers the typed fallback immediately. When that retained arm later closes, its actual close is classified as a new supervised event without replaying the earlier fallback. After the configured retry bound is exhausted, the adapter delivers the original wake with a typed continuity-restoration failure, even if every successor arm hung without reporting readiness. This is deliberate Option B ordering. Whenever restoration succeeds, the fleet is protected before the model handles the wake. When restoration does not succeed, the model is never left blind. ### Claude handling successor With the supervision host disabled, Claude's [auto-arm hook](#claude-stop-hook) also starts one handling successor before notification. After an actionable foreground close, including an attached peer cycle that ended, the hook: 1. Launches `bin/fm-watch-arm.sh` with the closed arm's pid as `FM_WATCH_PREDECESSOR_ARM_PID`. 2. Waits for that arm's one status line. 3. Only then exits 2 with the wake. A child of the hook cannot outlive its exit-2 rewake. So that successor is the one deliberate detached launch in the continuity path: - It runs under nohup. - Its stdio is away from the hook's pipes. - It has its own process group. This is the shape `bin/fm-startup-network.sh` uses, and [`verification/supervision.md`](verification/supervision.md#detached-session-open-workers-survive-the-hook) verified that it survives the hook. The next eligible auto-arm firing's foreground arm attaches to that live cycle. A successor that confirms no live watcher adds one line to the rewake banner and never withholds the wake. The next eligible firing then re-arms as before. ### Durable queue and turn-end backstop The durable wake queue preserves actionable events between a watcher close and the next drain. The bounded turn-end guard enforces recovery at Stop when no watcher is live and no open generation claim is still deciding. So a finished, hung, or identity-mismatched claim cannot suppress that recovery ([`turnend-guard.md`](turnend-guard.md#harness-integrations) owns that boundary). The recovery-episode contract below owns once-per-generation announcement. A handling successor does not re-announce the predecessor-delivered wake while its handling handoff remains open. It enters its poll loop immediately and keeps scanning signals, stale panes, and checks. An external durable append still republishes that episode as pending downtime, so the successor surfaces it whether the prior handling turn is still running or has acknowledged its rows. The append numbers past every queued row, so a lost or malformed sequence counter cannot let an outstanding acknowledgement consume it. Once those rows are acknowledged, a later append that repeats one byte for byte still opens a new recovery generation, which the successor recovers. Notification never consumes rows; sequence-bound, per-actor acknowledgement remains the owner of consumption. After the supervision host finishes a handled turn, it settles the successor handoff under the durable-queue and recovery-marker locks. Any row left for main then becomes a recoverable downtime episode, including one appended after the predecessor closed but before the successor initialized or the host confirmed its handoff. The successor closes once with the recovery wake only while that row is still queued; this is how a captain inbox note reaches main during handling-successor startup or while the host is parked on the handled wake's successor. When the host instead hands the accepted close back to main and leaves that successor running, it republishes the episode as announced downtime, because the close it hands back is that episode's delivery; the successor therefore does not announce it again, while a later durable append still opens a pending episode it recovers. ### Manual recovery and other harnesses - The model no longer re-arms after ordinary wakes. - No PreToolUse hook denies fleet commands based on watcher status. - A genuine auto-arm failure describes the automatic mechanism as broken and never directs a routine manual background arm. - Terminal arm-output classification (`started`, `attached`, or `FAILED`) remains defense in depth for the manual recovery path. - Codex retains its bounded foreground checkpoint protocol unless the home opts in to its [native Stop park](supervision-protocols/codex.md). - Grok retains its tracked background-task notification protocol. No adapter starts a replacement with a fire-and-forget shell `&` from a model command. The Claude hook's detached handling successor is launched by the hook itself, which waits for the successor's status line before it exits. The turn-end guard remains the final backstop rather than the normal continuity mechanism. In its `--claude` mode it cooperates with the auto-arm. ## Recovery episode acknowledgement A recovery episode is one generation of the `state/.watcher-down` marker. It is retired only by the generation-bound acknowledgement the drain prints as `WAKE_ACK_REQUIRED`. The away return brief treats a still-open handling episode as a wake in progress, not watcher downtime; an open downtime episode remains a gap. ### Announcement An unacknowledged downtime generation is announced at most once. The first recovery marks that generation announced, and later empty-queue arms leave it announced until durable work or interrupted handling makes recovery pending again. A non-successor watcher start checks the durable queue and recovery marker under their locks. If an announced-but-unacknowledged episode has an empty queue, the arm leaves that generation announced, making repeated empty-queue arms idempotent while a long-poll source is merely alive. If a durable row arrived after the announcement, the arm opens a fresh pending downtime generation so buried work still resurfaces once. A Codex foreground checkpoint skips the announced-episode reopen transition, so an already-announced generation stays announced while the checkpoint continues its bounded supervision. Its ordinary arm check still announces a never-announced generation, including one made pending by a durable queue append during the checkpoint. ### Generation reuse An ordinary watcher close attempts to publish downtime, and every durable queue append publishes it. A watcher closing to resurface the recovery it found at its start preserves the existing marker instead, while a handling successor resurfacing a later row publishes downtime like any close. If EXIT cleanup cannot acquire the downtime-marker lock within its bound, it retains the stale singleton for the next arm to publish the missing downtime before clearing that lock (see [Grace, beacon, and stop signals](#grace-beacon-and-stop-signals)). A downtime republication of a pending episode reuses its generation. A watcher close leaves an announced downtime episode announced, while a successful durable append opens a fresh pending generation so a live watcher can recover the new work. An announced handling episode becomes pending downtime on the same generation because its handling turn may have been interrupted. That handling republication gives a successor exactly one recovery presentation without orphaning the acknowledgement already printed for that generation. A watcher stopped so an arm can take its cycle over (`bin/fm-watch-arm.sh --take-over`) publishes downtime like any close, but the taking arm restores an acknowledged episode that stop reopened only when the taken-over arm's cycle-ledger row for that exact arm and watcher records the watcher ending by the take-over's TERM and no wake was appended in between. The taking arm waits within a short bound for that row; a missing row or any other signal leaves downtime for the fresh cycle's ordinary recovery wake, while take-over still proceeds. Any other episode is left for the next cycle's arm check. ### What an acknowledgement retires An acknowledgement carries two separable facts: - Queue-row consumption is bound to the monotonic `--ack-through` sequence (further scoped per actor - see "Per-actor acknowledgement" below). - Only retiring the episode is bound to `--recovery-generation`. A generation mismatch therefore does not block consumption of rows through that sequence. It is a non-fatal result that names its own remedy: re-drain, then acknowledge the newer episode. The acknowledgement retires the marker only when no rows remain after sequence-bound consumption. A concurrently appended wake has a higher sequence, remains queued, and keeps the episode pending for presentation. Consequently, a watcher close during handling republishes the same generation as pending and forces one recovery turn even when no queue row remains, while the outstanding generation-bound acknowledgement stays valid. An acknowledged episode does not freeze the generation, because the next downtime after it opens an episode of its own. ## Per-actor acknowledgement `bin/fm-wake-drain.sh` consumes the queue per actor, not per whole-queue cutoff. It uses the `fm_lease_actor` identity owned by `bin/fm-lease-lib.sh`. The Pi branch extension injects its branch actor into its own bash tool calls. ### Claiming rows Every presented row is claimed to exactly one actor under the durable queue lock. - Main records its presented set in `state/.main-eligible-rows`. - A branch grant is published through `bin/fm-wake-grant.sh` under that same lock in `state/.branch-eligible-rows`. The grant is bound to the live branch process and extension generation recorded in `state/.branch-eligible-owner`. Publication is refused if main already claimed any requested row. Activation, publication, release, and deactivation exit nonzero if queue-lock acquisition fails, preserving both grant records and leaving another owner's lock intact. - A main drain validates that owner evidence under the queue lock and reclaims the grant when its process is gone or its identity no longer matches. A branch drain never reclaims: it drains the grant just published for its turn, and its sandbox may be unable to inspect the host pid. - A main drain claims every currently unclaimed row and excludes an active branch grant from both presentation and acknowledgement. ### Lock deadlines during presentation An ordinary presentation drain bounds both its initial queue-lock acquire and its later status-presentation-lock acquire at the deadline owned by the script header. | Lock with a live holder | Drain result | | --- | --- | | Initial queue lock | One PID-naming advisory, and the whole drain is skipped before any claim or mutation. | | Status-presentation lock | One such advisory after raw wake presentation, and status annotations, sections, and cursors are left retriable on the next drain. | Acknowledgement invocations and every other mutation-critical queue-lock acquire retain blocking semantics, so acknowledgement atomicity is unchanged. ### Guard counts for branch-held rows Because the main drain's exclusion makes branch-granted rows invisible to main, `bin/fm-guard.sh`'s queued-wake warning counts only the rows the calling actor can itself present or retire. So an actor is never sent to a drain that provably has nothing for it. `bin/fm-wake-lib.sh` owns that per-actor count (`fm_wake_actor_pending_count`) alongside the grant row-list and owner-record reads that the drain and `bin/fm-wake-grant.sh` share. A row a live grant reserves is therefore never counted as drainable for main. Rather than going silent about a visibly non-empty queue, the guard prints a distinct advisory. That advisory names the live supervision branch as the holder and says not to drain those rows from here. The branch actor's queued-wake output stays suppressed in every case. A main drain with nothing of its own left, and a live grant still holding the queue, says so in one bounded line instead of exiting silently. ### Structurally unusable rows A row that lost the five appended fields or its numeric sequence can never be claimed, presented, or named by an `--ack-through` cutoff. A main drain retires such a row under the queue lock. It reports how many it removed, together with those rows verbatim, bounded to the first 20 and a count of the rest, because the queue was their only durable record. A branch drain never retires them, because a grant can only name sequences that were structurally valid when it was published. A retirement that cannot be read or written is reported and never fails the drain. The rows that remain usable are still presented with their acknowledgement command, and the unusable ones stay queued for a later drain to retire. Failing the whole drain would strand the usable rows too. ### Acknowledgement cutoffs | Acknowledgement | What it deletes | | --- | --- | | Main `--ack-through ` | Only claimed main rows at or below the cutoff. | | Branch | Only claimed branch rows at or below its cutoff. | A main acknowledgement first claims every unreserved row at or below its cutoff, so none is stranded. It leaves a row above the cutoff that arrived after presentation unowned, so an away-session grant can still take it rather than handing every later wake back to main. Every settled branch prompt releases any residual grant. So an omitted or failed acknowledgement leaves the durable row available to a later main drain. A successful acknowledgement has already removed it. An acknowledgement can remove none of the actor's rows while a presented row above the cutoff still waits. Such an acknowledgement is reported as having acknowledged nothing, together with the exact `--ack-through` and `--recovery-generation` command for that presented row. The presented set is read before any re-claim, so a row that arrived after presentation is never named for unseen acknowledgement. If a branch offer loses the claim race to main, it rejects its settlement so the watcher retains the actionable close until Pi accepts its main follow-up. ### Branch eligibility and check rows [`pi-supervision-branch.md`](pi-supervision-branch.md#components-and-their-owners) owns branch eligibility, mixed-queue dispatch, the pre-drain recheck, and heartbeat's all-or-nothing rule. While attended, a check-kind row is main-owned, including a heartbeat review. So it is never part of a branch claim and never defers one. Main is woken for it on that check's own triggering close. Under the away-posture record the exclusion lifts and a check row is offered to and claimed by the branch like every other actionable row. `fm-wake-drain.sh` never reclassifies a row itself. It filters the queue to the current actor's opaque claim before same-key deduplication, then presents and acknowledges only that actor-local view. A missing or empty branch snapshot is refused loudly rather than read as "nothing eligible". With a live branch owner, a missing snapshot means the branch's own acknowledgement already consumed the grant, and the refusal says so; otherwise reaching the drain without the non-empty handoff promised by the extension is a wiring bug. A branch acknowledgement retires the check-row receipts - inactive-outcome, inactive-reconcile notice, and secondmate stall - of exactly the granted sequences it consumes, so a branch-consumed check is never re-queued by its producer. Attended, a grant names no check row and each scan finds nothing. ### Per-actor regression tests `tests/fm-wake-queue.test.sh`'s mixed-queue actor, stale-acknowledgement remedy, and presentation-deadline tests drive the real scripts and check that: - Branch acknowledgement cannot swallow a main row. - A concurrent main turn cannot present or acknowledge an active branch grant. - A no-op stale acknowledgement names the current presented wake's exact command. - Live-holder presentation contention stays bounded and retriable. - Acknowledgement locking remains blocking. The same suite pins the counted-equals-presentable invariant against `bin/fm-guard.sh` and `bin/fm-wake-drain.sh` together: - A branch-held row raises the held advisory rather than the ordinary queued-wake warning for main. - That row is presented with its acknowledgement command - with the ordinary warning restored - as soon as the grant clears. - Structurally unusable rows are retired by main alone while every remaining row stays presentable and acknowledgeable. Branch acknowledgement retiring the check-row receipts of exactly its granted sequences is pinned by `tests/fm-wake-queue.test.sh` for the secondmate stall receipt and by `tests/fm-inactive-reconcile.test.sh` for the inactive-outcome receipt. `tests/fm-pi-branch-extension.test.sh` pins extension-side classification, claim publication and release, and the pre-drain recheck. ## Arm-layer cycle contract `bin/fm-watch-arm.sh` never returns a clean empty success. ### How an arm resolves a close | Child return | What the arm does | | --- | --- | | Actionable output | Returns that reason normally. | | Zero/empty | Rechecks the home lock and beacon, attaches to a verified healthy successor when one exists, or resolves the close against the watcher's bounded terminal-delivery ledger. | An attached arm follows verified identity-matched successors and resolves the same way when that chain ends without one. It does this because it holds no handle on the watcher's stdout and cannot read the reason line itself. ### Terminal-delivery ledger Before releasing its singleton lock after printing an actionable reason, the watcher records that reason with its PID and process identity in `state/.watch-deliveries.log`. A matching PID and identity lets an attached arm report the delivered reason and exit zero, even after its durable wake was handled and acknowledged. An unrelated queue producer or a recycled PID cannot satisfy the match. Only a cycle with no matching delivery record emits `watcher: FAILED - cycle ended without an actionable reason` and exits nonzero. ### Cycle exit log The arm layer appends one tab-separated record per observed cycle to `state/.watch-cycle-exits.log`. Each record includes: - Arm and watcher PIDs. - Start and end timestamps. - Exit code and signal. - Classified reason. - Beacon age. - Lock identity before and after close. - Successor disposition. The file is size-capped through `FM_WATCH_CYCLE_LOG_MAX_BYTES` and `FM_WATCH_CYCLE_LOG_KEEP_LINES`. `state/.watch-triage.log` remains only the watcher's bounded absorbed-wake debug log and carries no lifecycle semantics. ### Grace, beacon, and stop signals The default 300-second grace is unchanged. Only the watcher process touches `state/.last-watcher-beat`. No helper process can make a wedged watcher appear healthy. An arm whose own script path sits under a disposable no-mistakes validation checkout (`.no-mistakes/worktrees/`) refuses with the typed failure line before touching any state, because a watcher started there outlives the validation step and keeps writing the real home's state from a checkout about to be deleted. Once per poll the watcher checks that its home, its state directory, and its own code root still exist, and exits with a logged reason when one is gone, scoped to itself alone, so a torn-down temporary home or a discarded checkout never leaves an orphan watcher behind. The arm applies the same rule to its own ground: when its script, its state directory, or an `FM_HOME` that existed at start disappears, it fails with `watcher: FAILED - arm ground removed: no longer exists` instead of polling on; the `bin/fm-watch-arm.sh` header owns the timing bound. The watcher uses bash's native fatal handling for HUP and TERM, including during a blocked check, a blocked `fm_backend_capture` pane read, or a backend event wait, so both run its EXIT cleanup and stop that read or wait. `watcher_stop_signals` in `bin/fm-watch.sh` owns the signal-handling rationale. The EXIT cleanup bounds its wait for `state/.watcher-down.lock` while persisting recovery state with `FM_WATCHER_CLEANUP_LOCK_BOUND` (default 2 seconds). Only positive decimal integers are accepted, including leading-zero forms such as `08`; empty, non-numeric, and zero values (including `00`) fall back to 2 seconds. A live foreign holder therefore cannot strand a TERM'd watcher in this marker-lock wait: on timeout the recovery transition fails without releasing the singleton, leaving dead-pid stale evidence for the next arm to republish and clear. The bounded wait allows itself one extra lock-freshness window past the bound when the only remaining blocker is a lock whose holder never published its pid: `fm_lock_mid_acquire_is_fresh` defers to that residue for a whole freshness window before it can be stolen, so a creator killed inside its claim write would otherwise consume the entire bound without ever becoming reclaimable. A published numeric pid, live or dead, grants no extension to the original wait bound. ### Startup interruption window The watcher installs its EXIT cleanup trap immediately after claiming the singleton lock, before any recovery-marker or queue lock is touched, and publishes its home-bound lock identity (`fm-home`, `watcher-path`, `pid-identity`) right after. The claim also removes the predecessor's `state/.last-watcher-beat` before that identity exists, so a home-bound identity never pairs with an inherited beacon: health, and with it the arm's confirmation, requires the new watcher's own first poll. If the watcher cannot obtain a nonempty process identity or write any required identity record, it exits through that trap instead of entering an unconfirmable poll loop. Everything past that point - marker transitions, queue reads, delivery records - runs inside the trap's coverage. Cleanup joins any in-flight owner-directory creation or lock publication before releasing its own micro-locks and discarding its scoped unpublished staging directories; foreign staging and ownership are preserved. It then attempts the same bounded downtime publication an ordinary exit performs. The only window outside cleanup coverage is the singleton claim itself, so the arm never stops a child there. The arm treats that published identity as the startup safe point: it forwards a signal or its confirmation-deadline TERM only once the child's identity matches, waiting past the deadline if it must, and otherwise waits for the child to exit on its own; only a removed state directory, which leaves no lock to strand, skips the wait. An interrupted start leaves no self-owned singleton, steal mutex, micro-lock, or staging directory when downtime publication succeeds. The foreign marker-lock contention exception described above still retains singleton evidence deliberately. `FM_WATCHER_STARTUP_LOCK_BOUND` bounds the downtime publish inside a `.watch.lock` steal and, after identity publication, the startup recovery reopen and arm-check transitions. A live foreign holder that outlasts the bound during singleton-steal publication or a recovery transition ends the child with `watcher: FAILED - startup lock held by live pid `; the arm relays the child's final output even when startup exits during the safe-point wait. A handling successor's resurface decision reads the queue inside the same arm-check lock hold that announces the marker, so no separate queue wait can skip a wake the transition already announced. `FM_ARM_CONFIRM_TIMEOUT` is a liveness backstop rather than a fork budget, while successor hand-off confirmation uses the separate `FM_ARM_SUCCESSOR_CONFIRM_TIMEOUT`. Defaults and overrides, including Claude Stop's outer successor-status wait, are owned by [configuration](configuration.md#environment-variables). ## Regression coverage ### Pi and OpenCode watch extension `tests/fm-pi-watch-extension.test.sh` checks Pi's first-cycle-or-explicit-repair tool metadata and ownership-based redundant-call no-ops. It then simulates actionable and empty child closes against the actual Pi and OpenCode close handlers, and: - Blocks prompt delivery to prove the successor launches first. - Holds one branch delivery open to prove successive actionable closes still restore their own successors, and that the queued wakes then arrive in order. - Proves a close whose delivery waits behind that held delivery keeps the handoff its own successor confirmed instead of reporting a healthy newer successor to main as a restoration failure. - Verifies single-flight behavior for the restoration pump and for delivery separately. - Changes the session lock before close to prove ownership is rechecked. - Hangs each successor arm to prove bounded fallback delivery includes the typed restoration failure. - Lets a Pi successor's descendant print readiness after the arm exits to prove restoration retries until a live successor exists before delivery. - Lets a verified Pi successor exit after delivery while a descendant holds its pipes to prove the exit alone schedules the bounded retry. - Delays a Pi arm's actionable line behind a descendant-held pipe until after its exit to prove that line still becomes the wake instead of a retry. The same suite covers ordinary same-process session replacement for `/new`, `/resume`, `/fork`, and reload, plus: - Same-instance shutdown-plus-start. - The predecessor remaining live under a handoff generation until its replacement commits. - Bounded retry after that replacement kills the predecessor but fails before readiness. - Automatic re-arm before any model turn. - A fresh extension-module rebind carrying all in-flight actionable closes exactly once. - Stale prior-generation callbacks. - Repeated transitions with exactly one live cycle. - Disappearance of the shutting-down refusal after a valid replacement activates. - Terminal quit still refusing late rearm. - A mid-restore marker advance that delivers the wake with no rejection appendix, offers it to an accepting supervision branch like a confirmed delivery, and records the attempt and the confirm result in the bounded extension log when opted in. - A failed confirmation for a stale successor that spares a newer arm started by a repair. - A repair, a scheduled retry, and a deferred close over a dead-but-unclosed arm child that each start a fresh arm instead of stalling. The guard and session-start suites prove that active generation evidence tolerates a fresh-beacon handoff. They also prove that a legacy or handoff-phase watcher marker from an absent replacement extension still raises the outage diagnostic. ### Arm, recovery, triage, and lock suites `tests/fm-watch-arm.test.sh` covers: - Durable queue replay. - Real remote parent-replies ingestion into the authoritative status log. - Decision-only OPEN DECISIONS recovery. - Interrupted handling replay. - Generation-bound acknowledgement. - A persistent live successor after recovery. - An idle live Lavish source that stays quiet until its real result wakes promptly. - An append that reopens an announced empty recovery. - An external append beside a handling successor's inherited rows that surfaces both before and after the prior handling turn acknowledges its rows, including after a lost or malformed sequence counter and when the new row repeats an acknowledged inherited row byte for byte. - A watcher close inside the handling window that must leave the printed acknowledgement valid. - A re-arm whose recovery cycle is slowed after confirmation and must still surface rather than read as a watcher that stayed live. - The self-healing moved-generation acknowledgement that consumes its handled rows and names its remedy. - The already-acknowledged confirmation no-op for a matching generation, with its mismatched-generation, dead-pid, and lock-mismatch rejections preserved. - The manual-restart generation churn that makes a confirmation for the churned generation report a mismatch, which an arm check without a reopen leaves in place. - A take-over that stays quiet after a confirmed TERM, including one that lands inside a herdr event wait, still surfaces queued work and self-exit downtime, and attaches without stopping a cycle the named arm does not own. - The disposable-checkout arm refusal. - The home-gone and state-gone watcher exits. - The test reaper that stops a watcher armed for a temporary home. `tests/fm-watch-recovery-loop.test.sh` covers: - The once-per-generation announcement bound with the real Pi extension against a refused handling handshake. - A handling successor that must surface a real crew event instead of going blind. - A handling successor that resurfaces a row appended between its predecessor's close and its own startup handoff. `tests/fm-watch-triage.test.sh` proves TERM stops a watcher blocked inside a poll's pane capture and still releases its lock and records an acknowledgeable stop. It also exercises a single TERM with a live foreign downtime-marker lock holder, retained stale singleton and subsequent arm-style recovery, including decimal `08` and zero `00` cleanup bounds. It checks that a newly appended keyed decision is classified without rereading earlier status bytes, so signal handling can return to the watcher's beacon refresh even when the status history is long. `tests/fm-wake-queue.test.sh` proves TERM likewise stops a watcher blocked in the drain-ring idle check's pane capture. `tests/fm-watcher-lock.test.sh` covers: - Verified-successor attach. - Bounded lock-wait extension for an unpublished pid, while a published live holder retains the original bound. - Recovery publication before stale-lock removal. - The typed self-eviction failure. - Bounded and successor-linked lifecycle rows. - Startup-stop resilience: slow post-claim confirmation, typed startup-lock and identity-publication failures, safe-point deadline and signal forwarding, and cleanup of published locks and in-flight staging while preserving foreign owners. - A SIGSTOP counterfactual that distinguishes a live PID from a stale beacon before classifying termination. ### Claude auto-arm and turn-end guard `tests/fm-subagent-pretool-check.test.sh` proves Claude retains only the non-status Bash seatbelts. `tests/fm-claude-stop-autoarm.test.sh` covers: - The auto-arm's scope. - Stale and live session owners. - Unchanged AFK and need boundaries. - Single-flight. - Supersession of a dead session's orphaned claim by the replacement session's first Stop, including a resumed session that carries the dead one's conversation id, and a claim with no readable session lock. - A recycled background bridge keeping its live claim on the trusted session id pinned to the lock holder's recorded session-lock identity. - The Windows/Git Bash session-lock readers for the supervision-host key, claim read-back, and mid-turn health, plus untagged-value parity with the numeric helpers, in `tests/fm-session-lock-windows-consumers.test.sh`. - The ps-free identity route and its fail-closed refusals when the `ps` binary cannot execute, in `tests/fm-session-lock-ps-denied.test.sh`. - Bounded failure retries. - Benign live-watcher cycle ends. - One-notice failure episodes. - Exit-2 translation. - The handling successor an ended attached cycle starts with the closed arm as its predecessor and that outlives the rewake. - An unconfirmed successor reported in the banner without withholding the wake. - The [session-bound HUP/TERM/INT failure handoff](turnend-guard.md#failure-progression-and-block-budget) and replacement-session notice isolation. It also covers generation-claim single-flight, stuck-claim and earlier-session supersession, superseded-owner silence, notice-marker refusal and retry, ownership-atomic episode reset, and the legacy upgrade shim. [`turnend-guard.md`](turnend-guard.md) owns those behavior contracts. `FM_CLAUDE_LIVE_E2E=1 tests/fm-claude-stop-autoarm-live-e2e.test.sh`: 1. Starts with the reproduced stale-lock state. 2. Receives session start through the tracked SessionStart hook. 3. Completes two tokenless cycles. 4. Checks the competing-live-owner negative control. `tests/fm-turnend-guard.test.sh` covers the cooperative `--claude` guard, including: - Monotonic failed-epoch progression. - The integrated bounded fail-open. - Post-alarm continuation suppression. - Positive recovery reset. [`turnend-guard.md`](turnend-guard.md#regression-coverage) lists that suite's full generation and legacy claim coverage. ## Active limits and verification The goal is continuity without a Pi, omp, or OpenCode model-memory re-arm step. No zero-latency guarantee is claimed, because lock verification, watcher startup, and bounded retry delays remain deliberate safety work. OpenCode support targets persistent TUI sessions rather than headless `opencode run`. The other harnesses rely on these mechanisms: - Claude normally depends on the [auto-arm hook's rewake](#claude-stop-hook). The host's opt-in [acknowledged hand-back](supervision-host.md#acknowledged-hand-back) owns guarded retries when that continuation is lost. - Cursor depends on its awaited stop-hook park. - Grok retains native background-completion notifications. - Codex retains bounded foreground checkpoints unless the home opts in to its [native Stop park](supervision-protocols/codex.md). [`verification/supervision.md`](verification/supervision.md#watcher-continuity) records the current cross-harness live evidence, the dated Stop-owned Claude auto-arm results, and exact opt-in commands.