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
2 changes: 1 addition & 1 deletion bin/fm-classify-lib.sh
Original file line number Diff line number Diff line change
Expand Up @@ -400,7 +400,7 @@ stale_is_terminal() { # <window> <state>
# captain-relevant. This is the cheap fleet-scan both supervisors run as a
# catch-all backstop for a captain-relevant status the per-wake path might miss.
# No dedup is applied here: each consumer dedupes against its own seen-state (the
# daemon against .subsuper-seen-status-*, the watcher against .seen-* signatures).
# daemon via status_line_already_seen in bin/fm-supervise-daemon.sh (including .hb-surfaced-*); watcher via .seen-* signatures).
scan_captain_relevant_statuses() { # <state>
local state=$1 f last task
for f in "$state"/*.status; do
Expand Down
31 changes: 27 additions & 4 deletions bin/fm-supervise-daemon.sh
Original file line number Diff line number Diff line change
Expand Up @@ -528,6 +528,29 @@ mark_status_seen() { # <state> <task> <last-line>
printf '%s' "$line" > "$state/.subsuper-seen-status-$(_stale_key "$task")"
}

# 0 if this captain-relevant last line was already escalated or already
# surfaced to firstmate, so the catch-all scan must not re-fire it.
# Checks, in order:
# 1. .subsuper-seen-status-<task> - written by the daemon's per-wake escalate
# path (mark_escalated_seen) and by the catch-all itself after it escalates.
# 2. .hb-surfaced-<task> - written by the always-on watcher when it surfaces a
# captain-relevant status to firstmate (and also when the afk watcher hands
# the same line to this daemon). Without (2), a fresh away-mode session
# re-escalates historical done: lines firstmate already handled while afk
# was off.
# Does NOT promote hb into subsuper-seen: the always-on/afk watcher marks hb
# before the daemon's signal path runs, and promoting would let classify_signal
# self-handle as "already escalated" without ever delivering the event.
status_line_already_seen() { # <state> <task> <last-line>
local state=$1 task=$2 line=$3 key seen
key=$(_stale_key "$task")
seen="$state/.subsuper-seen-status-$key"
[ "$(cat "$seen" 2>/dev/null || true)" = "$line" ] && return 0
seen="$state/.hb-surfaced-$key"
[ "$(cat "$seen" 2>/dev/null || true)" = "$line" ] && return 0
return 1
}

# Mark every captain-relevant status line a per-wake classification escalated as
# seen, so the catch-all scan does not re-escalate the same line within
# HEARTBEAT_SCAN_SECS. Mirrors classify_signal/classify_stale's relevance test.
Expand Down Expand Up @@ -1060,14 +1083,14 @@ housekeeping() { # <state>
# (3) heartbeat scan (catch-all for a captain-relevant status the per-wake
# classifier may have missed). Cheap: status files only, no tmux. The
# captain-relevant filtering is the shared classifier's
# scan_captain_relevant_statuses; the daemon layers its digest dedup on top.
# scan_captain_relevant_statuses; the daemon layers its digest dedup on top
# via status_line_already_seen (subsuper-seen from escalate paths, and
# hb-surfaced from always-on / watcher surfacing - see that helper).
if [ "$(_file_age "$state/.subsuper-last-scan")" -ge "${FM_HEARTBEAT_SCAN_SECS:-$HEARTBEAT_SCAN_SECS_DEFAULT}" ]; then
_now > "$state/.subsuper-last-scan"
local seen
while IFS="$(printf '\t')" read -r f task last; do
[ -n "$f" ] || continue
seen="$state/.subsuper-seen-status-$(_stale_key "$task")"
[ "$(cat "$seen" 2>/dev/null || true)" = "$last" ] && continue
status_line_already_seen "$state" "$task" "$last" && continue
escalate_add "$state" "$(basename "$f"): $last (catch-all scan)"
mark_status_seen "$state" "$task" "$last"
done < <(scan_captain_relevant_statuses "$state")
Expand Down
28 changes: 28 additions & 0 deletions tests/fm-daemon.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -715,6 +715,33 @@ test_signal_escalate_marks_seen_no_catchall_refire() {
pass "captain signal escalate marks seen so the catch-all scan does not re-fire"
}

test_catchall_honours_hb_surfaced_no_refire() {
# Always-on firstmate already surfaced a done: line (watcher wrote
# .hb-surfaced-*), then away mode starts. The catch-all must NOT re-escalate
# that historical completion.
local dir state key line
dir=$(make_supercase scan-hb-surfaced)
state="$dir/state"
line='done: Leads stage seed manifest landed - https://example.test/pr/131'
printf '%s\n' "$line" > "$state/hist-t9.status"
key=$(printf '%s' "hist-t9" | tr ':/.' '___')
# Simulate always-on mark_surfaced only (no daemon escalate / no subsuper-seen).
printf '%s' "$line" > "$state/.hb-surfaced-$key"
[ ! -e "$state/.subsuper-seen-status-$key" ] || fail "precondition: subsuper-seen must be absent"
rm -f "$state/.subsuper-last-scan"
: > "$state/.subsuper-escalations"
FM_STATE_OVERRIDE="$state" housekeeping "$state"
[ ! -s "$state/.subsuper-escalations" ] \
|| fail "catch-all re-escalated a completion already surfaced via .hb-surfaced: $(cat "$state/.subsuper-escalations")"
# Signal path must still escalate a captain-relevant line that only has
# hb-surfaced (not subsuper-seen): the watcher marks hb before the daemon
# classifies, and promoting hb into subsuper would swallow that delivery.
FM_STATE_OVERRIDE="$state" handle_wake "signal: $state/hist-t9.status" "$state"
[ -s "$state/.subsuper-escalations" ] \
|| fail "signal path failed to escalate when only .hb-surfaced matched (would drop the delivery)"
pass "catch-all honours .hb-surfaced without blocking the signal escalate path"
}

test_collapse_newlines_pure() {
local out
out=$(_collapse_newlines $'line one\nline two\nline three')
Expand Down Expand Up @@ -1863,6 +1890,7 @@ test_inject_skip_forces_self
test_is_wake_reason_distinguishes_status_stdout
test_terminal_stale_escalate_leaves_no_marker
test_signal_escalate_marks_seen_no_catchall_refire
test_catchall_honours_hb_surfaced_no_refire
test_collapse_newlines_pure
test_afk_absent_daemon_does_not_inject
test_busy_guard_defers_when_supervisor_busy
Expand Down
Loading