Skip to content
Merged
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
9 changes: 5 additions & 4 deletions .agents/skills/afk/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,17 +136,18 @@ The daemon still clears its buffer only on the backend's `empty` success verdict

The daemon wraps `fm-watch.sh`, runs the watcher as a child, presents every durable wake after each actionable watcher close, classifies each presented record in bash, and acknowledges the presented generation only after routing completes.
It self-handles the routine majority without consuming a firstmate turn.
Captain-relevant events, plus a bounded recheck of a declared external wait that remains idle, escalate to firstmate's context as one pre-read, single-line, batched digest.
The classification predicates (the captain-relevant verb set, declared-pause vocabulary, signal/stale tests, and fleet-scan) live in the shared `bin/fm-classify-lib.sh`, the same library the always-on watcher uses for its own triage when afk is off, so the two modes apply one identical policy.
Captain-relevant events, plus a bounded recheck of a declared wait that remains idle, escalate to firstmate's context as one pre-read, single-line, batched digest.
The classification predicates (the captain-relevant verb set, declared-wait vocabulary, signal/stale tests, and fleet-scan) live in the shared `bin/fm-classify-lib.sh`, the same library the always-on watcher uses for its own triage when afk is off, so the two modes apply one identical policy.
While `state/.afk` exists the daemon owns the watcher, so the watcher reverts to one-shot and lets the daemon do the triage - the two never run their triage at the same time.

Classify each wake this way:

- `signal` with a terminal captain verb (`done:`, `needs-decision:`, `blocked:`, or `failed:`) -> escalate.
A nonterminal progress verb remains nonterminal even when its prose contains a legacy free-text token such as `PR ready`, `checks green`, `ready in branch`, or `merged`; only a bare legacy line with such a token escalates.
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.
- `signal` or `stale` for a declared wait, either a `paused:` external wait or a verified `captain-held` transfer -> 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 recheck and resets the pause window.
That recheck names which human the wait is on: the external dependency for `paused:`, and the captain themself for a `captain-held` transfer, who can answer the held decision or release the hold.
- `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
28 changes: 20 additions & 8 deletions bin/fm-classify-lib.sh
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ FM_CLASSIFY_CAPTAIN_RE_DEFAULT='done:|needs-decision:|blocked:|failed:|PR ready|
# drift between the two consumers. FM_CLASSIFY_PAUSED_VERB overrides it.
FM_CLASSIFY_PAUSED_VERB_DEFAULT='paused'

# Bounded re-surface cadence for a declared pause or a dead-agent captain hold.
# Bounded re-surface cadence for a declared pause or a verified 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;
Expand Down Expand Up @@ -146,19 +146,31 @@ status_is_paused() { # <status-line>
[ "$verb" = "${FM_CLASSIFY_PAUSED_VERB:-$FM_CLASSIFY_PAUSED_VERB_DEFAULT}" ]
}

# 0 if a status line declares either an external-wait pause or a verified
# captain-held transfer.
# Both declarations can intentionally leave an exited crew's endpoint idle, so
# the watcher applies its bounded pause cadence when agent death confirms that
# no live decision gate is being silenced.
status_is_paused_or_captain_held() { # <status-line>
# 0 if a status line's leading verb is the verified captain-held transfer verb.
# The same pure verb read as status_is_paused, and the discriminator a supervisor
# needs once a declared wait has already been recognized: the two declarations get
# the same bounded cadence, but they block on DIFFERENT humans, so a recheck that
# names an external dependency for a hold points the captain away from the fact
# that they are the one who can clear it.
status_is_captain_held() { # <status-line>
local line=$1 verb
status_is_paused "$line" && return 0
[ -n "$line" ] || return 1
verb=$(status_line_verb "$line")
[ "$verb" = "${FM_CLASSIFY_CAPTAIN_HELD_VERB:-$FM_CLASSIFY_CAPTAIN_HELD_VERB_DEFAULT}" ]
}

# 0 if a status line declares either an external-wait pause or a verified
# captain-held transfer.
# Both declarations can intentionally leave a crew's endpoint idle, so both
# supervisors give them one cadence: the away-mode daemon defers the wedge and
# ages a pause marker instead, and the watcher applies its bounded pause cadence
# once pause_state_class has admitted the wait (fm-watch.sh owns which liveness
# evidence each kind of crew must supply for that).
status_is_paused_or_captain_held() { # <status-line>
local line=$1
status_is_paused "$line" || status_is_captain_held "$line"
}

# --- durable keyed decisions ------------------------------------------------
#
# The status stream is an append-only EVENT log. Reading it last-event-wins
Expand Down
8 changes: 6 additions & 2 deletions bin/fm-push-transition-lib.sh
Original file line number Diff line number Diff line change
Expand Up @@ -130,8 +130,12 @@ handle_push_transition() { # <backend> <session> <record>
[ -n "$pane_id" ] || { sleep 1; return; }
window="$session:$pane_id"
task=$(window_to_task "$window" "$STATE")
if status_is_paused "$(last_status_line "$STATE/$task.status")"; then
triage_log "absorbed push $to (declared pause, awaiting external): $window"
# A declared wait already names the human this transition would report: an
# external dependency, or the captain a verified hold transferred the work to.
# Either way the wait is durably recorded, so absorb the immediate escalation
# and leave the bounded re-surface to the watcher's own pause cadence.
if status_is_paused_or_captain_held "$(last_status_line "$STATE/$task.status")"; then
triage_log "absorbed push $to (declared wait, awaiting external or captain): $window"
fm_backend_commit_transition "$backend" "$STATE" "$session" "$record" || exit 1
return
fi
Expand Down
84 changes: 48 additions & 36 deletions bin/fm-supervise-daemon.sh
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,11 @@
# durable wake after an actionable close, acknowledges only after routing, and
# either SELF-HANDLES the routine majority in bash (no firstmate turn) or
# ESCALATES a batched, distilled digest to the supervisor pane on
# captain-relevant events plus bounded declared-pause rechecks. This is the
# captain-relevant events plus bounded declared-wait rechecks. This is the
# token-efficient replacement for the prior always-inject daemon: routine
# signal/stale/heartbeat wakes cost zero firstmate context; only done/
# needs-decision/blocked/failed/persistent-wedge/check-output events and a
# declared-pause recheck reach the LLM, and even then as one pre-read digest per
# declared-wait recheck reach the LLM, and even then as one pre-read digest per
# batch window.
#
# PRESENCE-GATING (the /afk contract). The daemon is the away-mode engine: it
Expand Down Expand Up @@ -41,11 +41,13 @@
# drain and acknowledges it only after routing completes.
# - Fail-safe-to-escalate: any wake the classifier cannot confidently mark
# routine is escalated.
# - Bounded wedge latency: a stale pane without a declared external wait is
# escalated only after it has been idle for STALE_ESCALATE_SECS
# - Bounded wedge latency: a stale pane without a declared wait is escalated
# only after it has been idle for STALE_ESCALATE_SECS
# (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.
# within STALE_ESCALATE_SECS + a tick, never lost. A declared wait - either a
# paused: external wait or a verified captain-held transfer, per
# fm-classify-lib.sh's combined predicate - instead gets its own longer
# PAUSE_RESURFACE_SECS recheck, never a wedge escalation.
# 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,8 +91,9 @@
# kinds.
# 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)
# FM_PAUSE_RESURFACE_SECS idle seconds before a declared wait (external
# or captain-held) re-surfaces as a recheck
# (default 3600)
# 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 @@ -372,12 +375,13 @@ classify_stale() { # <window> <state>
local win=$1 state=$2 task last seen
task=$(window_to_task "$win" "$state")
last=$(last_status_line "$state/$task.status")
if [ -n "$last" ] && status_is_paused "$last"; then
# A DECLARED external-wait pause (fm-classify-lib.sh): an idle pane is EXPECTED,
# so this is not a wedge. The caller records a pause marker (long re-surface
# cadence in housekeeping) rather than a wedge stale marker. Cheap: reuses the
# status line already read, no fm-crew-state.sh call, mirroring the daemon's
# existing status-log classification.
if [ -n "$last" ] && status_is_paused_or_captain_held "$last"; then
# A DECLARED external-wait pause or a verified captain-held transfer
# (fm-classify-lib.sh owns which declarations qualify): an idle pane is
# EXPECTED, so this is not a wedge. The caller records a pause marker (long
# re-surface cadence in housekeeping) rather than a wedge stale marker. Cheap:
# reuses the status line already read, no fm-crew-state.sh call, mirroring the
# daemon's existing status-log classification.
printf 'pause|paused (awaiting external), rechecked on a long cadence: %s' "$last"
return
fi
Expand Down Expand Up @@ -446,10 +450,11 @@ stale_marker_remove() { # <window> <state>
rm -f "$state/.subsuper-stale-$key"
}

# 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
# Pause marker: state/.subsuper-paused-<key> holds the epoch a declared wait (a
# paused: external wait or a verified captain-held transfer) was first observed
# idle. Housekeeping ages it against PAUSE_RESURFACE_SECS (much longer than a
# wedge) and re-surfaces the wait 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
Expand Down Expand Up @@ -481,7 +486,7 @@ reconcile_pause_tracking() { # <window> <state> <last-status-line>
key=$(_stale_key "$task")
marker="$state/.subsuper-paused-$key"
watcher_key=$(_stale_key "$win")
if status_is_paused "$last"; then
if status_is_paused_or_captain_held "$last"; then
stale_marker_remove "$win" "$state"
pause_marker_record "$win" "$state"
elif [ -e "$marker" ] || [ -e "$state/.paused-$watcher_key" ]; then
Expand All @@ -499,7 +504,7 @@ migrate_watcher_pause_markers() { # <state>
key=$(_stale_key "$task")
watcher_key=$(_stale_key "$win")
last=$(last_status_line "$state/$task.status")
if status_is_paused "$last" || [ -e "$state/.subsuper-paused-$key" ] || [ -e "$state/.paused-$watcher_key" ]; then
if status_is_paused_or_captain_held "$last" || [ -e "$state/.subsuper-paused-$key" ] || [ -e "$state/.paused-$watcher_key" ]; then
reconcile_pause_tracking "$win" "$state" "$last"
fi
done
Expand Down Expand Up @@ -954,9 +959,10 @@ _oldest_line_age() { # <buf> -> seconds since the oldest buffered item first ar
# Never silently defer forever.
# 2) stale recheck: for each pending stale marker past STALE_ESCALATE_SECS,
# 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).
# 2b) pause re-surface: for each declared-wait marker past PAUSE_RESURFACE_SECS,
# re-peek; busy/gone -> clear; still idle + still declaring the wait -> escalate
# a recheck digest naming which human the wait is on, and reset the window
# (repeating bounded re-surface, never a wedge).
# 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 @@ -1007,7 +1013,7 @@ housekeeping() { # <state>
fi
task=$(window_to_task "$win" "$state")
last=$(last_status_line "$state/$task.status")
if [ -n "$last" ] && status_is_paused "$last"; then
if [ -n "$last" ] && status_is_paused_or_captain_held "$last"; then
reconcile_pause_tracking "$win" "$state" "$last"
continue
fi
Expand All @@ -1022,12 +1028,15 @@ housekeeping() { # <state>
esac
done

# (2b) pause re-surface recheck. A DECLARED external-wait pause idles by design,
# so it is rechecked on a much longer cadence than a wedge (PAUSE_RESURFACE_SECS)
# and never escalated as one - but it MUST re-surface, so a forgotten pause cannot
# 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.
# (2b) pause re-surface recheck. A declared wait idles by design (fm-classify-lib.sh's
# status_is_paused_or_captain_held owns which declarations qualify), so it is
# rechecked on a much longer cadence than a wedge (PAUSE_RESURFACE_SECS) and never
# escalated as one - but it MUST re-surface, so neither a forgotten pause nor a
# forgotten captain hold can rot invisibly. Past the window: busy (resumed) or gone
# -> drop; still idle and still declaring the wait -> escalate a recheck digest and
# reset the marker so the window repeats. The digest names WHICH human the wait is
# on, because the captain is the one reading it: an external dependency for a
# paused: declaration, and the captain themself for a verified hold transfer.
pause_secs=${FM_PAUSE_RESURFACE_SECS:-$FM_PAUSE_RESURFACE_SECS_DEFAULT}
for marker in "$state"/.subsuper-paused-*; do
[ -e "$marker" ] || continue
Expand All @@ -1038,7 +1047,7 @@ housekeeping() { # <state>
fi
task=$(window_to_task "$win" "$state")
last=$(last_status_line "$state/$task.status")
if [ -z "$last" ] || ! status_is_paused "$last"; then
if [ -z "$last" ] || ! status_is_paused_or_captain_held "$last"; then
reconcile_pause_tracking "$win" "$state" "$last"
continue
fi
Expand All @@ -1050,7 +1059,10 @@ housekeeping() { # <state>
2) rm -f "$marker" ;;
*)
last=$(last_status_line "$state/$task.status")
if [ -n "$last" ] && status_is_paused "$last"; then
if [ -n "$last" ] && status_is_captain_held "$last"; then
escalate_add "$state" "captain-held ${age}s (awaiting the captain, answer the held decision or release the hold): $win"
_now > "$marker"
elif [ -n "$last" ] && status_is_paused "$last"; then
escalate_add "$state" "paused ${age}s (awaiting external, recheck whether the wait still holds): $win"
_now > "$marker"
else
Expand Down Expand Up @@ -1237,10 +1249,10 @@ handle_wake() { # <reason> <state>
[ "${FM_ESCALATE_BATCH_SECS:-$ESCALATE_BATCH_SECS_DEFAULT}" -le 0 ] && { escalate_flush "$state" || true; }
;;
pause)
# Declared external-wait pause: record a pause marker (long re-surface
# cadence in housekeeping) and drop any wedge stale marker, so a pane that
# transitioned working->paused is not still wedge-aged. Only stale produces
# this action.
# Declared wait, an external-wait pause or a verified captain-held transfer:
# record a pause marker (long re-surface cadence in housekeeping) and drop any
# wedge stale marker, so a pane that transitioned working->declared-wait is not
# still wedge-aged. Only stale produces this action.
if [ "$kind" = "stale" ]; then
stale_marker_remove "$arg" "$state"
pause_marker_record "$arg" "$state"
Expand Down
Loading
Loading