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
124 changes: 107 additions & 17 deletions bin/fm-classify-lib.sh
Original file line number Diff line number Diff line change
Expand Up @@ -163,24 +163,30 @@ last_status_line() { # <status-file> [<previous-event-var>]
# A bare legacy free-text line counts as an event only when a captain token leads
# it, so continuation prose that merely mentions one cannot hide a declaration.
_fm_status_event_scan() {
local line last='' prev='' fallback='' verb legacy_re unstamped
local line last='' prev='' fallback='' legacy_re
legacy_re="^[[:space:]]*(${FM_CAPTAIN_RE:-$FM_CLASSIFY_CAPTAIN_RE_DEFAULT})"
while IFS= read -r line || [ -n "$line" ]; do
case "$line" in *[![:space:]]*) fallback=$line ;; *) continue ;; esac
case "$line" in *:*) status_line_verb "$line" verb ;; *) verb='' ;; esac
case "$verb" in
working|needs-decision|blocked|done|failed|note|\
"${FM_CLASSIFY_PAUSED_VERB:-$FM_CLASSIFY_PAUSED_VERB_DEFAULT}"|\
"${FM_CLASSIFY_RESOLVE_VERB:-$FM_CLASSIFY_RESOLVE_VERB_DEFAULT}"|\
"${FM_CLASSIFY_CAPTAIN_HELD_VERB:-$FM_CLASSIFY_CAPTAIN_HELD_VERB_DEFAULT}") prev=$last; last=$line ;;
*) _fm_status_unstamped "$line" unstamped
_fm_classify_matches "$unstamped" "$legacy_re" && { prev=$last; last=$line; } ;;
esac
_fm_status_line_is_event "$line" "$legacy_re" && { prev=$last; last=$line; }
done
printf '%s\n%s\n' "$prev" "${last:-$fallback}"
[ -n "$last" ]
}

# 0 when a nonblank <line> is a recognized status event for the scan above.
_fm_status_line_is_event() { # <line> <legacy-captain-re>
local verb unstamped
case "$1" in *:*) status_line_verb "$1" verb ;; *) verb='' ;; esac
case "$verb" in
working|needs-decision|blocked|done|failed|note|\
"${FM_CLASSIFY_PAUSED_VERB:-$FM_CLASSIFY_PAUSED_VERB_DEFAULT}"|\
"${FM_CLASSIFY_RESOLVE_VERB:-$FM_CLASSIFY_RESOLVE_VERB_DEFAULT}"|\
"${FM_CLASSIFY_CAPTAIN_HELD_VERB:-$FM_CLASSIFY_CAPTAIN_HELD_VERB_DEFAULT}") return 0 ;;
esac
_fm_status_unstamped "$1" unstamped
_fm_classify_matches "$unstamped" "$2"
}

# 0 when <line> matches the extended regex <pattern> case-insensitively, leaving
# the caller's nocasematch setting untouched.
_fm_classify_matches() { # <line> <pattern>
Expand Down Expand Up @@ -267,6 +273,66 @@ status_is_paused_or_captain_held() { # <status-line>
status_is_paused "$line" || status_is_captain_held "$line"
}

# The status line that holds a crew in a declared wait, or nothing when it is in
# none. Supervisors decide the wait from this line, never from the raw latest
# event: a resolved line is also how firstmate answers a decision (fm-send
# --resolve-key), and one that lands after a pause for a different phase key -
# including the stated default key a keyless decision shares - does not end the
# pause. Only a resolved line for the pause's own phase key (the keyed
# activity fold's key, where a keyless line is its own phase) retracts it, as
# does any other later event. A captain-held line counts only while it is the
# latest event. Bounded like last_status_line: only a tail window made wholly of
# resolved events widens the read to the whole file.
status_declared_wait_line() { # <status-file>
local f=$1 last verb resolve legacy_re
last=$(last_status_line "$f")
if status_is_paused_or_captain_held "$last"; then
printf '%s\n' "$last"
return 0
fi
resolve=${FM_CLASSIFY_RESOLVE_VERB:-$FM_CLASSIFY_RESOLVE_VERB_DEFAULT}
status_line_verb "$last" verb
[ "$verb" = "$resolve" ] || return 0
legacy_re="^[[:space:]]*(${FM_CAPTAIN_RE:-$FM_CLASSIFY_CAPTAIN_RE_DEFAULT})"
tail -n "$FM_CLASSIFY_EVENT_WINDOW_LINES" "$f" 2>/dev/null \
| _fm_status_declared_wait_scan "$resolve" "$legacy_re" \
|| _fm_status_declared_wait_scan "$resolve" "$legacy_re" < "$f" || :
}

# Walk the status lines on stdin back from the newest event past resolved lines
# to the first other event, and print it when it is a pause none of those
# resolved lines share a phase key with. Returns 1 when every event is a
# resolved line, so a caller reading a bounded window knows to widen it.
_fm_status_declared_wait_scan() { # <resolve-verb> <legacy-captain-re>
local resolve=$1 legacy_re=$2 line verb key keys=$'\n' i=0
local -a lines=()
while IFS= read -r line || [ -n "$line" ]; do
lines[i]=$line
i=$((i + 1))
done
while [ "$i" -gt 0 ]; do
i=$((i - 1))
line=${lines[i]}
case "$line" in *[![:space:]]*) ;; *) continue ;; esac
_fm_status_line_is_event "$line" "$legacy_re" || continue
status_line_verb "$line" verb
case "$verb" in
"$resolve") ;;
"${FM_CLASSIFY_PAUSED_VERB:-$FM_CLASSIFY_PAUSED_VERB_DEFAULT}") ;;
*) return 0 ;;
esac
key=$(_fm_decision_key "$line" "$_FM_CLASSIFY_KEYLESS_PHASE") || key=
if [ "$verb" = "$resolve" ]; then
keys="$keys$key"$'\n'
continue
fi
case "$keys" in *$'\n'"$key"$'\n'*) return 0 ;; esac
printf '%s\n' "$line"
return 0
done
return 1
}

# A condition-aware declared wait: a `paused:` line may say WHEN it expects to
# clear with `until <YYYY-MM-DDTHH:MM[:SS]Z>` anywhere in its text (UTC only, so
# no local-zone guess is ever recorded). Prints that time as epoch seconds so a
Expand Down Expand Up @@ -587,15 +653,15 @@ status_line_note() { # <status-line> -> text after the first colon, trimmed
fi
printf '%s' "$n"
}
_fm_decision_key() { # <status-line> -> key slug, or "default" when no token
_fm_decision_key() { # <status-line> [<keyless>] -> key slug, or <keyless> (default "default") when no token
local k unstamped
_fm_status_unstamped "$1" unstamped
if _fm_key_before_colon "$unstamped"; then
k=${unstamped%%:*}
k=${k#*\[key=}
k=${k%%\]*}
else
k=$(_fm_key_at_note_head "$unstamped") || { printf 'default'; return 0; }
k=$(_fm_key_at_note_head "$unstamped") || { printf '%s' "${2-default}"; return 0; }
fi
_fm_decision_slug_ok "$k" || return 1
printf '%s' "$k"
Expand Down Expand Up @@ -758,8 +824,8 @@ status_open_decisions() { # <status-file> [<kind>]

# Resolve the log's current declaration at one boundary for crew-state consumers.
# Any decision the fold still holds open wins over unrelated events, and the
# fold's most recently opened record supplies it; the latest recognized event
# stands when nothing is open.
# fold's most recently opened record supplies it; a standing declared wait, then
# the latest recognized event, stands when nothing is open.
# Actual run/pane evidence is still reconciled by fm-crew-state.sh.
status_current_line() { # <status-file> <kind>
local open key verb note current=''
Expand All @@ -769,6 +835,7 @@ status_current_line() { # <status-file> <kind>
done <<EOF
$open
EOF
[ -n "$current" ] || current=$(status_declared_wait_line "$1")
[ -n "$current" ] || current=$(last_status_line "$1")
printf '%s\n' "$current"
}
Expand Down Expand Up @@ -1862,10 +1929,33 @@ EOF
# A later done, failed, needs-decision, blocked, or resolved event carrying that
# key closes the phase, because it has moved to a terminal or separately tracked
# state.
# A bare legacy event uses the default key, preserving one-phase behavior.
# A bare legacy event prints as the default key, preserving one-phase behavior.
# That printed key is not the decision fold's shared default bucket: a line with
# no stated key is a different phase from an explicit "[key=default]" line, so a
# stated default-key retraction cannot cancel an unrelated keyless wait, while a
# keyless retraction still closes only the keyless phase.
# This fold is evidence about whether a parent event was explicitly superseded.
# It is never authoritative current crew state, and consumers must not let an open
# phase outrank a structured home snapshot or fm-crew-state result.
# Internal stand-in for a keyless phase. Outside the decision-key charset so it
# cannot collide with a stated slug, and rewritten to "default" only on output.
_FM_CLASSIFY_KEYLESS_PHASE=$'\036default'

# Rewrite the keyless stand-in back to the public "default" key. Only the key
# field is rewritten, so a note that happens to contain the stand-in stays put.
_fm_activity_publish_keys() { # <open-set>
local line key rest
while IFS= read -r line; do
[ -n "$line" ] || continue
key=${line%%$'\t'*}
rest=${line#*$'\t'}
[ "$key" = "$_FM_CLASSIFY_KEYLESS_PHASE" ] && key=default
printf '%s\t%s\n' "$key" "$rest"
done <<EOF
$1
EOF
}

_fm_status_open_activities_stream() {
local line verb key note resolve held open='' pause
resolve=${FM_CLASSIFY_RESOLVE_VERB:-$FM_CLASSIFY_RESOLVE_VERB_DEFAULT}
Expand All @@ -1878,7 +1968,7 @@ _fm_status_open_activities_stream() {
*) continue ;;
esac
verb=$(status_line_verb "$line")
key=$(_fm_decision_key "$line") || continue
key=$(_fm_decision_key "$line" "$_FM_CLASSIFY_KEYLESS_PHASE") || continue
case "$verb" in
working|"$pause")
note=$(status_line_note "$line")
Expand All @@ -1892,7 +1982,7 @@ _fm_status_open_activities_stream() {
;;
esac
done
printf '%s' "$open"
_fm_activity_publish_keys "$open"
}

status_open_activities() { # <status-file-or-dash>
Expand Down
2 changes: 1 addition & 1 deletion bin/fm-push-transition-lib.sh
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@ handle_push_transition() { # <backend> <session> <record>
# 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
if status_is_paused_or_captain_held "$(status_declared_wait_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
Expand Down
23 changes: 12 additions & 11 deletions bin/fm-supervise-daemon.sh
Original file line number Diff line number Diff line change
Expand Up @@ -406,7 +406,7 @@ classify_signal() { # <reason-after-colon> <state>
# first sight of a non-terminal stale it returns "self" and the caller records a
# timestamp marker; persistence is escalated by housekeeping's recheck, not here.
classify_stale() { # <window> <state> [<span-record> <span-status>]
local win=$1 state=$2 record=${3-} rc=${4-} task last event rest
local win=$1 state=$2 record=${3-} rc=${4-} task last declared event rest
task=$(window_to_task "$win" "$state")
if [ -z "$rc" ]; then
record=$(status_span_first_actionable_record "$state/$task.status" \
Expand All @@ -424,14 +424,15 @@ classify_stale() { # <window> <state> [<span-record> <span-status>]
printf 'escalate|stale + actionable status: %s' "$event"
return
fi
if [ -n "$last" ] && status_is_paused_or_captain_held "$last"; then
declared=$(status_declared_wait_line "$state/$task.status")
if [ -n "$declared" ] && status_is_paused_or_captain_held "$declared"; 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
# a status-file 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"
printf 'pause|paused (awaiting external), rechecked on a long cadence: %s' "$declared"
return
fi
if [ -n "$last" ] && status_is_captain_relevant "$last"; then
Expand Down Expand Up @@ -548,7 +549,7 @@ migrate_watcher_pause_markers() { # <state>
task=$(basename "$meta"); task=${task%.meta}
key=$(_stale_key "$task")
watcher_key=$(_stale_key "$win")
last=$(last_status_line "$state/$task.status")
last=$(status_declared_wait_line "$state/$task.status")
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
Expand All @@ -562,7 +563,7 @@ sync_pause_markers_from_signal() { # <state> <signal files>
for f in "${files[@]}"; do
case "$f" in *.status) ;; *) continue ;; esac
[ -e "$f" ] || continue
last=$(last_status_line "$f")
last=$(status_declared_wait_line "$f")
task=$(basename "$f"); task=${task%.status}
win=$(window_for_task "$task" "$state" 2>/dev/null || true)
[ -n "$win" ] || continue
Expand Down Expand Up @@ -1066,7 +1067,7 @@ housekeeping() { # <state>
rm -f "$marker"; continue
fi
task=$(window_to_task "$win" "$state")
last=$(last_status_line "$state/$task.status")
last=$(status_declared_wait_line "$state/$task.status")
if [ -n "$last" ] && status_is_paused_or_captain_held "$last"; then
reconcile_pause_tracking "$win" "$state" "$last"
continue
Expand Down Expand Up @@ -1107,7 +1108,7 @@ housekeeping() { # <state>
rm -f "$marker"; continue
fi
task=$(window_to_task "$win" "$state")
last=$(last_status_line "$state/$task.status")
last=$(status_declared_wait_line "$state/$task.status")
if [ -z "$last" ] || ! status_is_paused_or_captain_held "$last"; then
reconcile_pause_tracking "$win" "$state" "$last"
continue
Expand Down Expand Up @@ -1142,7 +1143,7 @@ housekeeping() { # <state>
case "$?" in
2) rm -f "$marker" ;;
*)
last=$(last_status_line "$state/$task.status")
last=$(status_declared_wait_line "$state/$task.status")
if [ -n "$last" ] && status_is_captain_held "$last"; then
if escalate_add "$state" "captain-held ${age}s (awaiting the captain, answer the held decision or release the hold): $win"; then
_now > "$marker"
Expand Down Expand Up @@ -1396,7 +1397,7 @@ handle_wake() { # <reason> <state>
pause) : ;;
*) case "$stale_detail" in
idle\ *s,\ possible\ wedge,\ escalation\ *)
last=$(last_status_line "$state/$task.status")
last=$(status_declared_wait_line "$state/$task.status")
status_is_paused_or_captain_held "$last" \
|| decision="escalate|${reason#stale: }"
;;
Expand All @@ -1411,7 +1412,7 @@ handle_wake() { # <reason> <state>
[ "$kind" = signal ] && sync_pause_markers_from_signal "$state" "$arg"
if [ "$kind" = stale ] && [ "$action" = escalate ]; then
task=$(window_to_task "$arg" "$state")
last=$(last_status_line "$state/$task.status")
last=$(status_declared_wait_line "$state/$task.status")
reconcile_pause_tracking "$arg" "$state" "$last"
fi
case "$action" in
Expand Down
20 changes: 10 additions & 10 deletions bin/fm-watch.sh
Original file line number Diff line number Diff line change
Expand Up @@ -1245,7 +1245,7 @@ wedge_wait_evidence() { # <task> -> one wait_record on stdout
local task=$1 last until statusf run
[ -n "$task" ] || return 1
statusf="$STATE/$task.status"
last=$(last_status_line "$statusf")
last=$(status_declared_wait_line "$statusf")
if status_is_captain_held "$last"; then
wait_record 'captain-held' 'awaiting the captain - verified hold transfer' \
captain 'answer the held decision or release the hold' "$statusf"
Expand Down Expand Up @@ -1543,7 +1543,7 @@ handle_paused_stale() { # <window> <task> <hash>
case "$mtime" in ''|*[!0-9]*) mtime=$(date +%s) ;; esac
now=$(date +%s)
age=$(( now - mtime ))
last=$(last_status_line "$statusf")
last=$(status_declared_wait_line "$statusf")
min_age=$PAUSE_RESURFACE_SECS
declaration="declared:$(fm_wake_signal_sig "$statusf" || true)"
if status_is_captain_held "$last"; then
Expand Down Expand Up @@ -1601,7 +1601,7 @@ handle_paused_stale() { # <window> <task> <hash>
busy_turn_bound_check() { # <window> <task> <hash> <since-file> <escalation-file>
local win=$1 task=$2 h=$3 since_file=$4 escalation_file=$5 key statusf declared
statusf="$STATE/$task.status"
if status_is_paused_or_captain_held "$(last_status_line "$statusf")"; then
if status_is_paused_or_captain_held "$(status_declared_wait_line "$statusf")"; then
if afk_present; then
# Away mode is daemon-owned, so this bound hands off the PLAIN wake identity
# and lets the daemon classify the declaration itself - the undecorated
Expand All @@ -1626,7 +1626,7 @@ busy_turn_bound_check() { # <window> <task> <hash> <since-file> <escalation-fil
rm -f "$since_file" "$escalation_file"
clear_write_tracking "$key"
declared="declared:$(fm_wake_signal_sig "$statusf" || true)"
if captain_held_silenced "$(last_status_line "$statusf")"; then
if captain_held_silenced "$(status_declared_wait_line "$statusf")"; then
printf '%s' "$declared" > "$STATE/.stale-$key"
triage_log "absorbed busy over-age pane (captain-held, never rechecked while the away-posture record exists): $win"
return 0
Expand Down Expand Up @@ -1675,7 +1675,7 @@ clear_pause_tracking() { # <window-key>
pause_state_class() { # <window> <task>
local win=$1 task=$2 key last recheck_file class agent_alive kind
key=$(window_key "$win")
last=$(last_status_line "$STATE/$task.status")
last=$(status_declared_wait_line "$STATE/$task.status")
recheck_file="$STATE/.paused-rechecked-$key"
if ! status_is_paused_or_captain_held "$last"; then
rm -f "$recheck_file"
Expand Down Expand Up @@ -1848,7 +1848,7 @@ surface_nonterminal_stale() { # <window> <hash>
local win=$1 h=$2 key task last declared=1 bounded=1 throttled=1 until now
key=$(window_key "$win")
task=$(window_to_task "$win" "$STATE")
last=$(last_status_line "$STATE/$task.status")
last=$(status_declared_wait_line "$STATE/$task.status")
STALE_WAIT_DECLARATION=
if status_is_paused "$last"; then
declared=0
Expand Down Expand Up @@ -2873,7 +2873,7 @@ EOF
# exemption below, because a mate's steers land in an inbox too.
[ -z "$task" ] || inbox_steer_check "$w" "$task"
key=$(window_key "$w")
last=$(last_status_line "$STATE/$task.status")
last=$(status_declared_wait_line "$STATE/$task.status")
if ! status_is_paused_or_captain_held "$last" && [ -e "$STATE/.paused-$key" ]; then
clear_pause_tracking "$key"
fi
Expand Down Expand Up @@ -3016,7 +3016,7 @@ EOF
esac
else
task=$(window_to_task "$w" "$STATE")
if [ -e "$pf" ] || status_is_paused_or_captain_held "$(last_status_line "$STATE/$task.status")"; then
if [ -e "$pf" ] || status_is_paused_or_captain_held "$(status_declared_wait_line "$STATE/$task.status")"; then
case "$(pause_state_class "$w" "$task")" in
paused) handle_paused_stale "$w" "$task" "$h" ;;
working) clear_pause_state "$key"
Expand Down Expand Up @@ -3046,7 +3046,7 @@ EOF
# 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.
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
if [ "$paused_bound" -ne 0 ] && [ -e "$pf" ] && { [ "$n" -ge 2 ] || ! status_is_paused_or_captain_held "$(status_declared_wait_line "$STATE/$(window_to_task "$w" "$STATE").status")"; }; then
clear_pause_tracking "$key"
fi
fi
Expand All @@ -3061,7 +3061,7 @@ EOF
clear_write_tracking "$key"
fi
task=$(window_to_task "$w" "$STATE")
if ! afk_present && status_is_paused_or_captain_held "$(last_status_line "$STATE/$task.status")" && [ "$busy_now" -ne 0 ]; then
if ! afk_present && status_is_paused_or_captain_held "$(status_declared_wait_line "$STATE/$task.status")" && [ "$busy_now" -ne 0 ]; then
case "$(pause_state_class "$w" "$task")" in
paused) handle_paused_stale "$w" "$task" "$h" ;;
# Inconclusive, but the declared wait itself still stands, so only the
Expand Down
Loading
Loading