Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .agents/skills/afk/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,8 @@ Classify each wake this way:
Other signals with no captain-relevant status -> self-handle.
- `signal` or `stale` for a declared `paused:` external wait -> self-handle and track the pause rather than a wedge.
If it remains declared and idle past `FM_PAUSE_RESURFACE_SECS` (default 3600s), housekeeping sends one awaiting-external recheck and resets the pause window.
The recheck exists to re-ask a wait that can change without the captain, so it is skipped for a wait the backlog records as captain-gated (`hold_kind: captain`): that one clears only when the captain acts, and the captain acting is already the away-mode exit, which runs the full return catch-up.
Suppression is a cadence decision only - the wait is still tracked, still reset each window, and still as visible as before in the backlog digest, the fleet view, and the return catch-up - and any kind that cannot be established is rechecked normally rather than dropped.
- `check` -> always escalate. Check scripts print only when firstmate should wake.
- `stale` with a terminal status or bare legacy captain-relevant line -> escalate.
Nonterminal progress remains transient even when its prose contains a legacy free-text token or its seen-status marker already matches, so record a marker and self-handle.
Expand Down
69 changes: 62 additions & 7 deletions bin/fm-classify-lib.sh
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
#!/usr/bin/env bash
# Shared wake classifier: the common source of truth for captain-relevant status
# tests, declared-external-wait vocabulary, and the working/paused absorb
# classification that makes no-verb signal and stale-pane wakes safe to absorb.
# tests, declared-external-wait vocabulary, the captain-gated-versus-external pause
# kind, and the working/paused absorb classification that makes no-verb signal and
# stale-pane wakes safe to absorb.
# Sourced by BOTH the always-on watcher
# (bin/fm-watch.sh) and the away-mode daemon (bin/fm-supervise-daemon.sh) so the
# overlapping triage policy lives in one place instead of two copies that can
Expand All @@ -13,7 +14,9 @@
# daemon keeps its escalation-digest seen-markers; the watcher keeps its .seen-*
# signatures).
#
# The one exception is the absorb classification (crew_absorb_class and its
# Two functions are exceptions. task_hold_kind reads the backlog through its own
# tool; see its own comment for the callers' cost contract. The other is the absorb
# classification (crew_absorb_class and its
# working/paused wrappers). It is NOT a pure status-file read: it reuses
# bin/fm-crew-state.sh, which may make a bounded no-mistakes call, to decide
# whether a crew that just stopped its turn or went stale is working, deliberately
Expand Down Expand Up @@ -59,10 +62,10 @@ FM_CLASSIFY_PAUSED_VERB_DEFAULT='paused'

# Bounded re-surface cadence for a declared pause or a dead-agent captain hold.
# Far longer than the wedge threshold (FM_STALE_ESCALATE_SECS, default 240s), it
# avoids nagging a deliberate wait while ensuring a forgotten hold cannot rot
# invisibly - it re-surfaces once for a recheck every window. One hour by default;
# both consumers read FM_PAUSE_RESURFACE_SECS with this default so the cadence has
# one owner.
# avoids nagging a deliberate wait while ensuring a forgotten external wait cannot
# rot invisibly. One hour by default; both consumers read FM_PAUSE_RESURFACE_SECS
# with this default so the cadence has one owner, while away mode suppresses only
# captain-gated rechecks as owned by the /afk skill.
# shellcheck disable=SC2034 # Read by the watcher and daemon (fm-watch.sh, fm-supervise-daemon.sh), not this lib.
FM_PAUSE_RESURFACE_SECS_DEFAULT=3600

Expand Down Expand Up @@ -140,6 +143,58 @@ status_is_paused_or_captain_held() { # <status-line>
[ "$verb" = "${FM_CLASSIFY_CAPTAIN_HELD_VERB:-$FM_CLASSIFY_CAPTAIN_HELD_VERB_DEFAULT}" ]
}

# --- pause kind: can this wait change without the captain? -------------------
#
# status_is_paused answers "does this pane idle by design". It does NOT answer the
# question a re-surface cadence actually asks: can the thing being waited on change
# without the captain acting? An external wait can, so rechecking it is real work.
# A captain-gated wait cannot - it clears only when the captain acts, and the
# captain acting is already the away-mode exit signal, which runs the full return
# catch-up, so a timed recheck can never surface anything that exit does not.
#
# The backlog already records exactly this distinction per work item as hold_kind
# (captain|external|load|parked|future), so these read that existing vocabulary
# instead of parsing pause prose or inventing a parallel one.
#
# NOT a pure read: task_hold_kind shells out to the backlog reader in FM_HOME, the
# same tool and field bin/fm-decision-hold.sh verifies a captain hold with. Callers
# run it only once a pause has already aged past its window, never on every wake.
FM_CLASSIFY_CAPTAIN_HOLD_KIND_DEFAULT='captain'

_fm_show_field() { # <tasks-axi-show-output> <field> -> value, or empty
printf '%s\n' "$1" | sed -n "s/^[[:space:]]*$2: //p" | head -1
}

# Print the backlog hold kind recorded for <task-id>, or `unknown` when it cannot
# be established: no reader, an unreadable or absent item, or an item that carries
# no active hold at all. `unknown` is deliberately never a kind a caller may treat
# as captain-gated, so an indeterminate wait keeps its ordinary handling.
task_hold_kind() { # <task-id> [home]
local id=$1 home=${2:-${FM_HOME:-}} out kind
case "$id" in ''|*[!A-Za-z0-9._-]*) printf 'unknown'; return ;; esac
command -v tasks-axi >/dev/null 2>&1 || { printf 'unknown'; return; }
if [ -n "$home" ] && [ -d "$home" ]; then
out=$(cd "$home" && tasks-axi show "$id" --full 2>/dev/null) || out=''
else
out=$(tasks-axi show "$id" --full 2>/dev/null) || out=''
fi
[ -n "$out" ] || { printf 'unknown'; return; }
[ "$(_fm_show_field "$out" held)" = yes ] || { printf 'unknown'; return; }
kind=$(_fm_show_field "$out" hold_kind)
case "$kind" in
''|*[!A-Za-z0-9._-]*) printf 'unknown' ;;
*) printf '%s' "$kind" ;;
esac
}

# 0 if <task-id>'s declared wait is gated on the captain rather than on something
# that can clear by itself. Consumers use it to decide CADENCE only: a captain-gated
# wait stays exactly as visible as before in the backlog digest, the fleet view, and
# the away-mode return catch-up - it simply stops being re-asked on a timer.
pause_is_captain_gated() { # <task-id> [home]
[ "$(task_hold_kind "$@")" = "${FM_CLASSIFY_CAPTAIN_HOLD_KIND:-$FM_CLASSIFY_CAPTAIN_HOLD_KIND_DEFAULT}" ]
}

# --- durable keyed decisions ------------------------------------------------
#
# The status stream is an append-only EVENT log. Reading it last-event-wins
Expand Down
33 changes: 27 additions & 6 deletions bin/fm-supervise-daemon.sh
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@
# (configurable), rechecked once. A wedged crewmate is therefore detected
# within STALE_ESCALATE_SECS + a tick, never lost. A declared pause instead
# gets its own longer PAUSE_RESURFACE_SECS recheck, never a wedge escalation.
# That recheck is skipped for a wait the backlog records as captain-gated,
# because only the captain can clear it and the captain's return already
# surfaces it.
# Crewmates are autonomous, so a delayed stale response does not stall a
# healthy crewmate's own progress.
# Buffered escalation delivery also has a max-defer alarm: if a digest stays
Expand Down Expand Up @@ -89,7 +92,8 @@
# FM_STALE_ESCALATE_SECS idle seconds before a stale pane escalates
# as a possible wedge (default 240)
# FM_PAUSE_RESURFACE_SECS idle seconds before a declared external wait
# re-surfaces as a recheck (default 3600)
# re-surfaces as a recheck (default 3600); a
# captain-gated wait is never re-surfaced on it
# FM_ESCALATE_BATCH_SECS buffer window for batched escalation
# digests; 0 = flush immediately (default 90)
# FM_HEARTBEAT_SCAN_SECS cadence for the catch-all status scan
Expand Down Expand Up @@ -448,9 +452,10 @@ stale_marker_remove() { # <window> <state>

# Pause marker: state/.subsuper-paused-<key> holds the epoch a declared pause was
# first observed idle. Housekeeping ages it against PAUSE_RESURFACE_SECS (much
# longer than a wedge) and re-surfaces the pause once per window. Recording is
# create-if-absent so the timestamp is stable across a churny idle pane (many
# distinct stale hashes map to one marker), keeping the cadence hash-immune.
# longer than a wedge) and applies the pause-kind cadence decision once per window.
# Recording is create-if-absent so the timestamp is stable across a churny idle
# pane (many distinct stale hashes map to one marker), keeping the cadence
# hash-immune.
pause_marker_record() { # <window> <state> - create if absent
local win=$1 state=$2 key marker
key=$(_stale_key "$(window_to_task "$win" "$state")")
Expand Down Expand Up @@ -953,7 +958,8 @@ _oldest_line_age() { # <buf> -> seconds since the oldest buffered item first ar
# re-peek the pane; still idle -> escalate (wedge); resumed -> clear marker.
# 2b) pause re-surface: for each declared-pause marker past PAUSE_RESURFACE_SECS,
# re-peek; busy/gone -> clear; still idle + still paused -> escalate a recheck
# digest and reset the window (repeating bounded re-surface, never a wedge).
# digest and reset the window (repeating bounded re-surface, never a wedge),
# except for a captain-gated wait, which resets its window without escalating.
# 3) heartbeat scan: every HEARTBEAT_SCAN_SECS, grep state/*.status for a
# captain-relevant line the per-wake classifier missed and escalate it.
housekeeping() { # <state>
Expand Down Expand Up @@ -1025,6 +1031,17 @@ housekeeping() { # <state>
# rot invisibly. Past the window: busy (resumed) or gone -> drop; still idle and
# still declaring the pause -> escalate a recheck digest and reset the marker so
# the window repeats.
#
# A CAPTAIN-GATED wait is the one exception, and it is a cadence exception only.
# It cannot clear without the captain, and the captain acting is already the
# away-mode exit signal, which runs the full return catch-up - so the recheck can
# never surface anything the exit does not, and only ever costs a turn to answer
# "still waiting". Its marker is still kept and its window still reset, so the
# wait stays tracked here and stays exactly as visible as before in the backlog
# digest, the fleet view, and the return catch-up; it also resumes ordinary
# rechecking within one window if its recorded kind stops being captain-gated.
# An indeterminate kind is NOT a captain-gated kind and keeps being rechecked
# (pause_is_captain_gated in bin/fm-classify-lib.sh).
pause_secs=${FM_PAUSE_RESURFACE_SECS:-$FM_PAUSE_RESURFACE_SECS_DEFAULT}
for marker in "$state"/.subsuper-paused-*; do
[ -e "$marker" ] || continue
Expand All @@ -1048,7 +1065,11 @@ housekeeping() { # <state>
*)
last=$(last_status_line "$state/$task.status")
if [ -n "$last" ] && status_is_paused "$last"; then
escalate_add "$state" "paused ${age}s (awaiting external, recheck whether the wait still holds): $win"
if pause_is_captain_gated "$task"; then
log "pause recheck suppressed for $win: captain-gated wait, only the captain can clear it"
else
escalate_add "$state" "paused ${age}s (awaiting external, recheck whether the wait still holds): $win"
fi
_now > "$marker"
else
rm -f "$marker"
Expand Down
1 change: 1 addition & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ firstmate's always-loaded operating contract and routing index for conditional p

A zero-token bash watcher (`bin/fm-watch.sh`) sleeps on the fleet, classifies detected wakes in bash, and wakes the first mate only when something is actionable.
Actionable wakes include captain-relevant status signals, no-verb signals whose crew is not provably working, authenticated check output such as PR merge polling or an X-mode mention, stale panes whose crew is not provably working whether their status log looks terminal or non-terminal, provably-working stale panes that persist past `FM_STALE_ESCALATE_SECS`, declared external waits that remain paused past `FM_PAUSE_RESURFACE_SECS`, and heartbeat backstop hits.
In away mode that pause recheck is limited to waits that can change without the captain; the `/afk` skill owns that rule.
Repeated provably-working stale escalations on the same unchanged pane add an escalation count to the wake reason and, at `FM_WEDGE_DEMAND_INSPECT_COUNT`, a `demand-deep-inspection` marker.
A busy pane is otherwise exempt from staleness, but only until its latest `state/<id>.turn-ended` marker reaches `FM_BUSY_TURN_MAX_SECS`, or its `state/<id>.meta` spawn record reaches that age before any turn completes; past that bound it is routed through the same wedge escalation, with the identical reason, escalation count, and `demand-deep-inspection` marker, for inspection only - never an automatic interrupt, signal, or restart.
Those actionable wakes are written to a durable local queue (`state/.wake-queue`) before detector state advances, so a missed process exit can be recovered by draining the queue.
Expand Down
2 changes: 1 addition & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -542,7 +542,7 @@ FM_CAPTAIN_RE='done:|needs-decision:|blocked:|failed:|PR ready|checks green|read
FM_CLASSIFY_PAUSED_VERB=paused # leading status verb for a declared external wait; excluded from FM_CAPTAIN_RE and distinct from blocked
FM_STALE_ESCALATE_SECS=240 # idle seconds before a provably-working stale pane escalates; stale panes whose crew is not provably working surface immediately unless they declare the pause verb
FM_BUSY_TURN_MAX_SECS=3600 # maximum age of a busy pane's latest state/<id>.turn-ended marker, or its state/<id>.meta spawn record before any turn completes, before the same wedge escalation used for a provably-working non-busy stale takes over; inspection-only, never an automatic interrupt or restart
FM_PAUSE_RESURFACE_SECS=3600 # seconds before an idle declared external wait re-surfaces for a recheck in the watcher or away-mode daemon
FM_PAUSE_RESURFACE_SECS=3600 # seconds before an idle declared external wait re-surfaces for a recheck in the watcher or away-mode daemon; the away-mode daemon skips this recheck for a wait the backlog records as captain-gated, because only the captain can clear it
FM_WEDGE_DEMAND_INSPECT_COUNT=3 # consecutive provably-working stale escalations on the same unchanged pane before demand-deep-inspection is added
FM_WATCH_TRIAGE_LOG_MAX_BYTES=262144 # size cap for the watcher's absorbed-wake debug log
FM_FLEET_SYNC_BOOTSTRAP_TIMEOUT= # optional seconds allowed for bootstrap's best-effort clone refresh; unset/blank defaults to max(20, 5 + 3 * origin-backed-project-count)
Expand Down
Loading
Loading