diff --git a/AGENTS.md b/AGENTS.md index eb98518..8a5ae3c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -85,7 +85,7 @@ state/ volatile runtime signals; gitignored .wake-queue durable queued wakes: epochseqkindkeypayload .afk durable away-mode flag; present = sub-supervisor may inject escalations (set by /afk, cleared on user return) .watch.lock .wake-queue.lock watcher singleton and queue serialization locks - .hash-* .count-* .stale-* .seen-* .last-* .heartbeat-streak watcher internals; never touch + .hash-* .count-* .stale-* .seen-* .babysit-* .escalated-* .last-* .heartbeat-streak watcher internals; never touch .last-watcher-beat watcher liveness beacon, touched every poll; fm-guard.sh reads it .subsuper-* .supervise-daemon.* sub-supervisor internals (stale markers, escalation buffer, seen-status dedup, log, lock, pid); never touch .no-mistakes/ local validation state and evidence; gitignored @@ -425,7 +425,7 @@ Use chat for yes/no decisions; use lavish-axi when there are multiple findings o For PR-based ship tasks, the ready signal depends on mode: `no-mistakes` reports `done: PR checks green` after CI is green, while `direct-PR` reports `done: PR ` after opening the PR. Run `bin/fm-pr-check.sh ` - it records `pr=` in the task's meta and arms the watcher's merge poll. Tell the captain: the PR's full URL (always the complete `https://...` link, never a bare `#number` - the captain's terminal makes a full URL clickable), a one-paragraph summary, and, for `no-mistakes`, the risk level it emitted. -(The check contract, for any custom `state/.check.sh` you write yourself: print one line only when firstmate should wake, print nothing otherwise, and finish before `FM_CHECK_TIMEOUT`.) +(The check contract, for any custom `state/.check.sh` you write yourself: **print the current state every run** (idempotent), e.g. `echo "merged"` while merged. The watcher dedups against `.seen-check-` and enqueues to the durable queue *before* advancing that marker, so a lost stdout or a crashed watcher can never swallow a wake - the same lossless guarantee signals enjoy. Edge-triggered checks that self-suppress via their own `.babysit-*.seen` are tolerated (empty stdout = no wake), but a swallowed transition is only recovered by the watcher's catch-all backstop, so prefer the stateless "always print current state" form. Finish before `FM_CHECK_TIMEOUT`.) If the captain says "merge it", run `gh-axi pr merge` yourself; that instruction is the explicit approval. If `yolo=on`, merge a green/approved PR yourself and post the required FYI. @@ -470,7 +470,7 @@ From there the task is an ordinary ship task through its mode-specific validatio The watcher is the backbone. Whenever at least one task is in flight, `bin/fm-watch.sh` must be running as a background task. It costs zero tokens while running and exits with one reason line when something needs you. -It also writes each detected wake to the durable queue at `state/.wake-queue` before advancing suppression markers such as `.seen-*`, `.stale-*`, `.last-check`, or `.last-heartbeat`. +It also writes each detected wake to the durable queue at `state/.wake-queue` before advancing suppression markers such as `.seen-*`, `.stale-*`, `.seen-check-*`, `.escalated-*`, `.last-check`, or `.last-heartbeat`. At the start of every wake-handling turn and every recovery turn, run `bin/fm-wake-drain.sh` before peeking panes, reading status files beyond the reason line, or starting new work. The printed one-shot reason line is still useful, but the drained queue is the lossless backlog. After handling drained wakes, re-arm `bin/fm-watch.sh` before you end the turn. diff --git a/README.md b/README.md index 3b1da8b..1a4e73a 100644 --- a/README.md +++ b/README.md @@ -115,6 +115,7 @@ firstmate works from any terminal - outside tmux, crewmates land in a detached ` - **Event-driven supervision** - a zero-token bash watcher (`bin/fm-watch.sh`) sleeps on the fleet and wakes the first mate only when a crewmate reports, stalls, a PR merges, or an internal heartbeat review is due. Detected wakes are also written to a durable local queue (`state/.wake-queue`) before detector state advances, so a missed one-shot process exit can be recovered by draining the queue. + Custom slow checks should print their current state idempotently; the watcher dedups repeated output and keeps a catch-all backstop for legacy self-suppressing `.babysit-*.seen` checks. Routine watcher polling, restarts, elapsed waiting time, and unchanged heartbeat reviews stay silent; an idle crew costs you nothing. A pull-based guard (`bin/fm-guard.sh`) warns through supervision tool output if tasks are in flight and that watcher stops running or queued wakes are waiting to be drained. A presence-gated sub-supervisor (`bin/fm-supervise-daemon.sh`) extends this for walk-away supervision: the `/afk` skill activates it, after which it self-handles routine wakes in bash and escalates only captain-relevant events as one batched, single-line digest (prefixed with an in-band sentinel marker so firstmate can tell daemon injections apart from real messages). diff --git a/bin/fm-pr-check.sh b/bin/fm-pr-check.sh index 928226e..71823fe 100755 --- a/bin/fm-pr-check.sh +++ b/bin/fm-pr-check.sh @@ -1,8 +1,9 @@ #!/usr/bin/env bash # Record a PR-ready task: appends pr= to state/.meta and arms the -# watcher's merge poll by writing state/.check.sh, which prints one line iff -# the PR is merged (the watcher's check contract: output = wake firstmate, -# silence = keep sleeping). +# watcher's merge poll by writing state/.check.sh. Once the PR is merged, +# the check prints the current terminal state every run; the watcher dedups the +# repeated output and enqueues the first delta before advancing suppression. +# Silence means "no current terminal state; keep sleeping." # Usage: fm-pr-check.sh set -eu diff --git a/bin/fm-teardown.sh b/bin/fm-teardown.sh index 69885f3..1ecd871 100755 --- a/bin/fm-teardown.sh +++ b/bin/fm-teardown.sh @@ -69,6 +69,23 @@ meta_value() { grep "^$key=" "$meta" | cut -d= -f2- || true } +sanitize_state_name() { printf '%s' "$1" | LC_ALL=C tr -c 'A-Za-z0-9._-' '_'; } + +cleanup_task_state() { + local state=$1 id=$2 check_name sidecar_name + check_name=$(sanitize_state_name "$id.check.sh") + sidecar_name=$(sanitize_state_name ".babysit-$id.seen") + rm -f \ + "$state/$id.status" \ + "$state/$id.turn-ended" \ + "$state/$id.check.sh" \ + "$state/$id.meta" \ + "$state/$id.pi-ext.ts" \ + "$state/.seen-check-$check_name" \ + "$state/.babysit-$id.seen" \ + "$state/.escalated-$sidecar_name" +} + registry_home_for_line() { sed -n 's/^[^(]*(home: \([^;)]*\);.*/\1/p' } @@ -346,7 +363,7 @@ cleanup_firstmate_home_children() { safe_rm_rf_child_worktree "$child_wt" "$child_proj" fi fi - rm -f "$sub_state/$child_id.status" "$sub_state/$child_id.turn-ended" "$sub_state/$child_id.check.sh" "$sub_state/$child_id.meta" "$sub_state/$child_id.pi-ext.ts" + cleanup_task_state "$sub_state" "$child_id" done } @@ -446,7 +463,7 @@ if [ "$KIND" = secondmate ]; then remove_firstmate_home "$HOME_PATH" "secondmate home" "$ID" remove_secondmate_registry_entry "$ID" fi -rm -f "$STATE/$ID.status" "$STATE/$ID.turn-ended" "$STATE/$ID.check.sh" "$STATE/$ID.meta" "$STATE/$ID.pi-ext.ts" +cleanup_task_state "$STATE" "$ID" if [ "$KIND" != scout ] && [ "$KIND" != secondmate ] && [ "$MODE" != local-only ]; then "$FM_ROOT/bin/fm-fleet-sync.sh" "$PROJ" || true fi diff --git a/bin/fm-watch.sh b/bin/fm-watch.sh index daa4356..345591a 100755 --- a/bin/fm-watch.sh +++ b/bin/fm-watch.sh @@ -4,7 +4,9 @@ # signal: ... a crewmate wrote a status line or a turn-end hook fired; signals # landing within FM_SIGNAL_GRACE of each other coalesce into one wake # stale: a crewmate pane stopped changing and shows no busy signature -# check: