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
29 changes: 23 additions & 6 deletions bin/fm-classify-lib.sh
Original file line number Diff line number Diff line change
Expand Up @@ -81,13 +81,15 @@ FM_CLASSIFY_CAPTAIN_RE_DEFAULT='done:|needs-decision:|blocked:|failed:|PR ready|
# paused: <reason>
# to declare it is intentionally idling on a KNOWN external dependency - an
# upstream release, a vendor rate-limit reset, a scheduled window. Unlike
# `blocked:` (stuck, firstmate must help) an idle `paused:` pane is EXPECTED, so
# the stale path absorbs it instead of escalating a possible wedge. It is
# `blocked:` (stuck, firstmate must help) a `paused:` pane is EXPECTED to look
# unattended - idle, or busy in a polling or watch loop - so the wedge path
# absorbs it instead of escalating (status_pause_damps_wedge below). It is
# deliberately NOT in the captain-relevant set above: a pause is a "stop
# wedge-nagging this idle pane" signal, not work to keep surfacing. This constant
# is the ONE definition of the verb; both the watcher and the daemon read it here
# (status_is_paused) rather than hardcoding the literal, so the vocabulary cannot
# drift between the two consumers. FM_CLASSIFY_PAUSED_VERB overrides it.
# wedge-nagging this unattended pane" signal, not work to keep surfacing. This
# constant is the ONE definition of the verb; both the watcher and the daemon
# read it here (status_is_paused) rather than hardcoding the literal, so the
# vocabulary cannot 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 verified captain hold.
Expand Down Expand Up @@ -160,6 +162,21 @@ status_is_paused() { # <status-line>
[ "$verb" = "${FM_CLASSIFY_PAUSED_VERB:-$FM_CLASSIFY_PAUSED_VERB_DEFAULT}" ]
}

# 0 if <status-line> declares the current external wait that damps wedge aging.
# The always-on watcher applies this predicate before choosing the shorter wedge
# cadence, including when a reason already carries wedge decoration; the
# away-mode daemon applies the same precedence through its own declared-wait
# verdict (status_is_paused_or_captain_held below).
# Only the latest status line is passed, so any newer non-pause line restores
# ordinary wedge detection. Callers that already hold an authoritative run-step
# verdict keep its existing precedence over the status declaration. Deliberately
# narrower than status_is_paused_or_captain_held below: a pause predicts an
# unattended pane outright, while a captain hold earns the shared bounded cadence
# only through each caller's own liveness or idleness evidence.
status_pause_damps_wedge() { # <status-line>
status_is_paused "$1"
}

# 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
Expand Down
2 changes: 1 addition & 1 deletion bin/fm-supervise-daemon.sh
Original file line number Diff line number Diff line change
Expand Up @@ -417,7 +417,7 @@ classify_stale() { # <window> <state> [<span-record> <span-status>]
fi
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
# (fm-classify-lib.sh owns which declarations qualify): an unattended 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
Expand Down
78 changes: 49 additions & 29 deletions bin/fm-watch.sh
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
# since the previous poll. Every other no-verb wake surfaces, so a crew
# that finishes (or stops and waits) is never silently swallowed. A declared wait,
# either a paused: external wait or a verified captain-held transfer, is the
# separate idle absorb case and re-surfaces only on its long bounded cadence,
# separate absorb case and re-surfaces only on its long bounded cadence,
# although its initial no-verb status signal still surfaces in normal mode.
# While state/.afk exists, the daemon owns triage and this watcher queues and exits
# on every wake. Printed reason lines:
Expand Down Expand Up @@ -45,10 +45,10 @@
# (window_is_busy true) is exempt from the above, but
# only up to BUSY_TURN_MAX_SECS with no completed turn
# (state/<id>.turn-ended, or the spawn record before any
# turn completes). Past that bound, a declared external
# wait or verified captain-held transfer uses the long
# pause recheck cadence (under afk it is instead handed
# to the daemon as this plain reason, once per
# turn completes). Past that bound, a current declared
# external wait or verified captain-held transfer uses
# the long pause recheck cadence (under afk it is instead
# handed to the daemon as this plain reason, once per
# declaration; busy_turn_bound_check owns that handoff);
# every other pane goes through the same wedge timer and
# surfaces with the identical "stale: ..." reason,
Expand Down Expand Up @@ -217,15 +217,19 @@ BUSY_TURN_MAX_SECS=${FM_BUSY_TURN_MAX_SECS:-3600}
# A local secondmate's foreign queue is checked on every poll, but only after this
# bounded age can it produce a parent notification.
SECONDMATE_WAKE_STALL_SECS=${FM_SECONDMATE_WAKE_STALL_SECS:-60}
# A crew that declared a pause is idling on a known external wait, so its stale
# pane is absorbed rather than wedge-escalated.
# A captain-held or paused crew whose agent has confidently exited uses the same
# bounded cadence, while a live or ambiguously read agent surfaces on first sight
# and is then held to that same cadence; a secondmate earns the cadence on its
# declaration alone, because its endpoint liveness is deliberately never read
# (pause_state_class owns that split).
# A crew whose latest status declares a pause is waiting on a known external
# dependency, idle or busy in its own poll loop, so it takes this bounded cadence
# on that declaration alone rather than a wedge escalation; a newer non-pause
# status restores ordinary wedge detection.
# A captain-held crew earns the same cadence on the stale path only once its
# agent has confidently exited, while a live or ambiguously read agent surfaces
# on first sight and is then held to that same cadence; a secondmate's hold earns
# it on the declaration alone, because its endpoint liveness is deliberately
# never read (pause_state_class owns that split, and busy_turn_bound_check owns
# the busy-pane bound).
# These cases re-surface once for a recheck every PAUSE_RESURFACE_SECS - far
# longer than the wedge threshold, but finite so a forgotten hold cannot rot invisibly.
# longer than the wedge threshold, but finite so a forgotten pause or hold cannot
# rot invisibly.
PAUSE_RESURFACE_SECS=${FM_PAUSE_RESURFACE_SECS:-$FM_PAUSE_RESURFACE_SECS_DEFAULT}
# Consecutive event-path failures (fm_backend_wait_transition returning 2 -
# connect/subscribe failure) before the push fast-path is disabled for the rest
Expand Down Expand Up @@ -868,10 +872,10 @@ busy_turn_bound_check() { # <window> <task> <hash> <since-file> <escalation-fil
# Away mode is daemon-owned, so this bound hands off the PLAIN wake identity
# and lets the daemon classify the declaration itself - the undecorated
# identity the rest of this function's contract promises. Running the wedge
# timer here instead would decorate the wake as a possible wedge, and that
# decoration overrides the daemon's own pause verdict for the pane: the
# ladder then climbs on every re-arm, escalating a crew that declared the
# wait itself once per FM_STALE_ESCALATE_SECS for as long as the wait lasts.
# timer here instead would decorate the wake as a possible wedge and climb
# the ladder on every re-arm, waking the daemon once per
# FM_STALE_ESCALATE_SECS for as long as the wait lasts only for it to
# re-absorb the declaration it already tracks on the pause cadence.
# The one-shot is keyed on the DECLARATION (the status log's signature),
# never on the pane hash: a busy pane's harness footer ticks on every
# capture, so a hash-keyed one-shot would re-fire on every poll and the
Expand Down Expand Up @@ -902,9 +906,14 @@ busy_turn_bound_check() { # <window> <task> <hash> <since-file> <escalation-fil
return 1
}

# Keep .paused-resurfaced-<key> here: the re-surface throttle belongs to the
# declaration, not to one absorb, so a pane that flickers busy mid-wait (a short
# turn that appends no status line) re-enters the cadence without an off-schedule
# recheck. The main loop drops the throttle once the latest status stops
# declaring a wait (tests/fm-watch-triage.test.sh pins the flicker case).
clear_pause_state() { # <window-key>
local key=$1
rm -f "$STATE/.paused-$key" "$STATE/.paused-rechecked-$key" "$STATE/.paused-resurfaced-$key"
rm -f "$STATE/.paused-$key" "$STATE/.paused-rechecked-$key"
}

# The hash-scoped half of clear_pause_tracking: the stale suppressor, its wedge
Expand All @@ -924,9 +933,11 @@ clear_pause_tracking() { # <window-key>
}

# Reconcile a declared pause or captain-held status with authoritative crew state.
# After fm-crew-state has fallen back to stopped or unknown, paused classification is
# recovered only for a confidently dead ordinary crew, or for a secondmate, whose
# endpoint liveness this function deliberately never reads.
# A current declared pause owns the long cadence unless an authoritative run-step
# says work resumed. After fm-crew-state has fallen back to stopped or unknown, a
# captain-held classification is recovered only for a confidently dead ordinary
# crew, or for a secondmate, whose endpoint liveness this function deliberately
# never reads.
pause_state_class() { # <window> <task>
local win=$1 task=$2 key last recheck_file class agent_alive kind
key=$(window_key "$win")
Expand All @@ -942,6 +953,10 @@ pause_state_class() { # <window> <task>
# far more common no-declaration path above still costs none.
kind=$(window_kind "$win")
if [ -e "$STATE/.paused-$key" ] && [ "$(age_of "$recheck_file")" -lt "$STALE_ESCALATE_SECS" ]; then
if status_pause_damps_wedge "$last"; then
printf 'paused'
return
fi
if [ "$kind" != secondmate ]; then
agent_alive=$(fm_backend_agent_alive "$(window_backend "$win")" "$win" 2>/dev/null) || agent_alive=unknown
if [ "$agent_alive" != dead ]; then
Expand All @@ -959,7 +974,9 @@ pause_state_class() { # <window> <task>
printf 'working'
return
fi
if [ "$kind" != secondmate ]; then
if status_pause_damps_wedge "$last"; then
class=paused
elif [ "$kind" != secondmate ]; then
agent_alive=$(fm_backend_agent_alive "$(window_backend "$win")" "$win" 2>/dev/null) || agent_alive=unknown
if [ "$agent_alive" != dead ]; then
rm -f "$recheck_file"
Expand Down Expand Up @@ -1815,8 +1832,9 @@ EOF
[ -z "$task" ] || inbox_steer_check "$w" "$task"
key=$(window_key "$w")
last=$(last_status_line "$STATE/$task.status")
if ! status_is_paused_or_captain_held "$last" && [ -e "$STATE/.paused-$key" ]; then
clear_pause_tracking "$key"
if ! status_is_paused_or_captain_held "$last"; then
rm -f "$STATE/.paused-resurfaced-$key"
[ ! -e "$STATE/.paused-$key" ] || clear_pause_tracking "$key"
fi
# An idle secondmate endpoint is healthy by design, so a mate is admitted to
# the pane-stale path ONLY to serve a declared wait's bounded re-surface -
Expand Down Expand Up @@ -1913,9 +1931,11 @@ EOF
# - working: an actively-running pipeline legitimately sits on a static
# pane (e.g. waiting on CI), so absorb and start the wedge timer so a
# genuinely frozen run still escalates past STALE_ESCALATE_SECS;
# - paused: a declared wait pause_state_class admits (its header owns which
# liveness evidence each kind of crew must supply), so absorb on the long
# PAUSE_RESURFACE_SECS cadence instead of wedge-escalating;
# - paused: a declared wait pause_state_class admits - the latest status
# still declares an external wait, or a captain hold supplied the
# liveness evidence its header requires of that kind of crew - so
# absorb on the long PAUSE_RESURFACE_SECS cadence instead of
# wedge-escalating;
# - none: no running pipeline, no exact busy verdict, no admitted declared wait.
# Surface immediately so firstmate inspects the inconclusive state
# (it may be done via an interactive menu that wrote no done: status,
Expand Down Expand Up @@ -1967,8 +1987,8 @@ EOF
fi
# A busy pane normally means real work resumed, so stale pause bookkeeping
# is cleared - but not in the same poll the declared-pause cadence just
# recorded it, or the re-surface throttle it depends on would be erased and
# the pause would re-surface every poll instead of once per long cadence.
# recorded it, or a pane that flickers busy would flap the bookkeeping on
# every poll of a still-current pause.
if [ "$paused_bound" -ne 0 ] && [ -e "$pf" ] && { [ "$n" -ge 2 ] || ! status_is_paused_or_captain_held "$(last_status_line "$STATE/$(window_to_task "$w" "$STATE").status")"; }; then
clear_pause_tracking "$key"
fi
Expand Down
Loading