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
4 changes: 3 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ config/trace-context optional presence flag enabling default-off native W3C tra
config/cmux-socket-password optional cmux control-socket password; LOCAL, gitignored; read fresh on every cmux CLI call and passed through without ever overriding an operator's own ambient CMUX_SOCKET_PASSWORD when absent (docs/cmux-backend.md "Setup")
config/wedge-alarm optional away-mode wedge-alarm active-alert directives; LOCAL, gitignored; absent means auto (macOS Notification Center when available); see docs/wedge-alarm.md
config/watched-tools.json optional list of the tools this home depends on, read by the update check armed with bin/fm-tool-update-check.sh; LOCAL, gitignored, firstmate-maintained but human-editable, and NOT inherited by secondmate homes; see docs/configuration.md "Watched tool updates"
config/attended-routine-status-absorb optional "off" opt-out from default-on attended absorption of routine supervision wakes; LOCAL, gitignored; see docs/configuration.md "Attended routine status absorption"
config/x-mode.env generated Relay watcher cadence; LOCAL, gitignored; source before arming watcher when present
data/ personal fleet records; LOCAL, gitignored as a whole
backlog.md task queue, dependencies, history
Expand Down Expand Up @@ -121,6 +122,7 @@ state/ runtime records and signals; gitignored
.watcher-down private generation-bound recovery state coupling watcher downtime, durable wake presentation, and post-handling acknowledgement; never touch
.<id>.open-decisions-cursor per-task byte cursor and folded open-decision set bounding the OPEN DECISIONS scan's cost to new status-log appends; written only by fm-classify-lib.sh's status_open_decisions_incremental, removed by teardown, safe to delete (forces one full re-fold)
.status-presentation-cursor .status-presentation-lock fleet-wide per-task status identity/byte-offset manifest and serialization lock preventing already-presented status lines from being replayed as new; owned by fm-classify-lib.sh, with each task's row retired by teardown
.status-absorbed-<id> per-task receipt binding the status-file identity and byte endpoint the attended watcher absorbed instead of waking on; owned by fm-classify-lib.sh, retired once the drain presents those bytes, removed by teardown; never touch
.afk durable away-mode flag; present = sub-supervisor may inject escalations (set by /afk, cleared on user return)
.watch.lock .wake-queue.lock watcher singleton and queue serialization locks
.claude-autoarm.lock .claude-autoarm-epoch .claude-autoarm-failure-notified .claude-autoarm-failure-alarmed .turnend-claude-blocks .turnend-claude-blocks.lock Claude Stop auto-arm single-flight, epoch, failure-episode, attended-alarm, guard-budget, and budget-lock records; never touch
Expand Down Expand Up @@ -163,7 +165,7 @@ When that section reports its checks still in progress it names exactly what is
3. **Wake queue** - when locked, presents the durable wake queue and prints the raw records prominently as this turn's first work queue; a clearly labeled status-event annotation may follow a valid `signal` record and includes every status line still unread at the presentation cursor, but never replaces the raw record or current-state reconciliation, and a lapsed watcher chain still surfaces here via the same guard alarm.
Presented records remain durable until the handling turn runs the generation-bound acknowledgement printed by the drain.
Every locked drain also prints a bounded fleet-wide `OPEN DECISIONS` section when durable decision records remain open, including when the queue itself is empty; reconcile those entries before continuing.
The same drain prints every still-unread `note:` line and pending-reply resolution since the last presentation in an unbounded `UNREAD STATUS` section, so an answer buried under a later routine line is not dropped; those lines are not re-printed after that presentation.
The same drain prints every still-unread `note:` line, pending-reply resolution, and routine status line the attended watcher absorbed instead of waking on, since the last presentation, in an unbounded `UNREAD STATUS` section, so an answer buried under a later routine line is not dropped; those lines are not re-printed after that presentation.
It also prints a bounded `RECORD DIVERGENCE` section naming every captain call the status log reads as resolved while its backlog task is still held; nothing is closed for you, and `captain-hold-lifecycle` owns the reconciliation.
When the lock could not be acquired and verified, the queue is left untouched because no session mutation is authorized, and the guard's tangle/watcher-liveness alarms still print in read-only advisory mode without drain, supervision repair, or checkout repair commands.
4. **Supervision operating instructions** - after the wake queue and before both digests, the digest emits exactly one operating block for the detected primary harness, followed by the read-once contract that governs them.
Expand Down
216 changes: 209 additions & 7 deletions bin/fm-classify-lib.sh
Original file line number Diff line number Diff line change
Expand Up @@ -769,6 +769,84 @@ EOF
printf '%s' "$offset"
}

_fm_status_absorbed_receipt_path() { # <status-file>
local f=$1 state task
state=${f%/*}
task=${f##*/}; task=${task%.status}
printf '%s/.status-absorbed-%s' "$state" "$task"
}

# Persist the current identity and byte endpoint for each absorbed status file.
# The presentation lock serializes this receipt with drain snapshots. Callers
# write the receipt before advancing .seen-* so a crash can replay rather than
# lose an absorbed line. Bare turn-end signals need no receipt because they carry
# no status bytes to present.
status_record_absorbed_signal() { # <state> <file> ...
local state=$1 lock f dir task ident size receipt tmp rc=0
shift
lock="$state/.status-presentation-lock"
fm_lock_acquire_wait "$lock" || return 1
for f in "$@"; do
case "$f" in *.status) ;; *) continue ;; esac
dir=${f%/*}
[ "$dir" = "$state" ] || { rc=1; break; }
[ -f "$f" ] && [ -r "$f" ] && [ ! -L "$f" ] || { rc=1; break; }
task=${f##*/}; task=${task%.status}
[ -n "$task" ] || { rc=1; break; }
ident=$(_fm_open_decisions_file_ident "$f") || { rc=1; break; }
size=$(_fm_status_file_size "$f") || { rc=1; break; }
size=${size//[[:space:]]/}
case "$size" in ''|*[!0-9]*) rc=1; break ;; esac
[ -n "$ident" ] || { rc=1; break; }
receipt=$(_fm_status_absorbed_receipt_path "$f")
tmp="$receipt.tmp.$$"
if ! printf '%s\t%s\n' "$ident" "$size" > "$tmp" || ! mv -f "$tmp" "$receipt"; then
rm -f "$tmp"
rc=1
break
fi
done
fm_lock_release "$lock" || rc=1
return "$rc"
}

# Print the pending absorbed byte endpoint for a status file, or an empty string
# when no receipt applies. A receipt that can no longer describe the CURRENT file
# - stale identity, an endpoint past the end, malformed content, or a symlinked
# or otherwise unreadable receipt - is dropped and reported as absent: those
# absorbed bytes went away with the file they described, and one unverifiable
# receipt must never disable the drain's no-loss sections. Dropping is also the
# conservative direction, because it can only hold the presentation cursor back.
# Only a status file whose own identity or size cannot be read fails.
status_absorbed_receipt_endpoint() { # <status-file>
local f=$1 receipt data ident endpoint extra cur_ident size
receipt=$(_fm_status_absorbed_receipt_path "$f")
if [ ! -e "$receipt" ] && [ ! -L "$receipt" ]; then return 0; fi
cur_ident=$(_fm_open_decisions_file_ident "$f") || return 1
size=$(_fm_status_file_size "$f") || return 1
size=${size//[[:space:]]/}
case "$size" in ''|*[!0-9]*) return 1 ;; esac
if [ -f "$receipt" ] && [ -r "$receipt" ] && [ ! -L "$receipt" ] \
&& data=$(LC_ALL=C command cat "$receipt" 2>/dev/null); then
IFS=$(printf '\t') read -r ident endpoint extra <<EOF
$data
EOF
if [ -n "$ident" ] && [ -z "$extra" ] && [ "$ident" = "$cur_ident" ]; then
case "$endpoint" in
''|*[!0-9]*) ;;
*)
if [ "$endpoint" -le "$size" ]; then
printf '%s' "$endpoint"
return 0
fi
;;
esac
fi
fi
rm -f -- "$receipt" || return 1
return 0
}

status_retire_presentation_task() { # <state> <task-id>
local state=$1 task=$2 lock manifest tmp data row_task ident offset extra rc=0 found=0
lock="$state/.status-presentation-lock"
Expand All @@ -782,7 +860,9 @@ status_retire_presentation_task() { # <state> <task-id>
# durable proof that there is nothing to retire.
if [ ! -e "$state/$task.status" ] && [ ! -L "$state/$task.status" ] \
&& [ ! -e "$state/.$task.open-decisions-cursor" ] \
&& [ ! -L "$state/.$task.open-decisions-cursor" ]; then
&& [ ! -L "$state/.$task.open-decisions-cursor" ] \
&& [ ! -e "$state/.status-absorbed-$task" ] \
&& [ ! -L "$state/.status-absorbed-$task" ]; then
if [ ! -e "$manifest" ] && [ ! -L "$manifest" ]; then
return 0
fi
Expand Down Expand Up @@ -826,14 +906,15 @@ EOF
fi
fi
if [ "$rc" -eq 0 ]; then
rm -f -- "$state/$task.status" "$state/.$task.open-decisions-cursor" || rc=1
rm -f -- "$state/$task.status" "$state/.$task.open-decisions-cursor" \
"$state/.status-absorbed-$task" || rc=1
fi
fm_lock_release "$lock" || rc=1
return "$rc"
}

status_acknowledge_presented_snapshot() { # <state> <snapshot> [<fully-presented-task-ids>]
local state=$1 snapshot=$2 fully_presented=${3:-} task endpoint ident f offset lines line safe
local state=$1 snapshot=$2 fully_presented=${3:-} task endpoint ident f offset lines line safe absorbed
while IFS=$(printf '\t') read -r task endpoint ident; do
[ -n "$task" ] || continue
safe=false
Expand All @@ -843,6 +924,10 @@ $fully_presented
if [ "$safe" = false ]; then
f="$state/$task.status"
offset=$(status_presentation_cursor_offset "$f") || return 1
absorbed=$(status_absorbed_receipt_endpoint "$f") || return 1
[ -n "$absorbed" ] || absorbed=$offset
[ "$absorbed" -le "$endpoint" ] || absorbed=$endpoint
[ "$absorbed" -ge "$offset" ] || absorbed=$offset
lines=$(status_new_lines_since_cursor "$f" "$endpoint") || return 1
# Once any informational line in this span is presented fleet-wide, the
# contiguous cursor may advance through the captured endpoint. Routine
Expand All @@ -858,7 +943,7 @@ $fully_presented
done <<EOF
$lines
EOF
if [ "$safe" = false ]; then endpoint=$offset; fi
if [ "$safe" = false ]; then endpoint=$absorbed; fi
fi
printf '%s\t%s\t%s\n' "$task" "$endpoint" "$ident" || return 1
done <<EOF
Expand All @@ -867,7 +952,7 @@ EOF
}

status_commit_presentation_snapshot() { # <state> <snapshot>
local state=$1 snapshot=$2 task endpoint ident f cur_ident size tmp
local state=$1 snapshot=$2 task endpoint ident f cur_ident size tmp absorbed receipt
tmp="$state/.status-presentation-cursor.tmp.$$"
: > "$tmp" || return 1
while IFS=$(printf '\t') read -r task endpoint ident; do
Expand All @@ -888,6 +973,17 @@ status_commit_presentation_snapshot() { # <state> <snapshot>
$snapshot
EOF
mv -f "$tmp" "$state/.status-presentation-cursor" || { rm -f "$tmp"; return 1; }
while IFS=$(printf '\t') read -r task endpoint ident; do
[ -n "$task" ] || continue
f="$state/$task.status"
absorbed=$(status_absorbed_receipt_endpoint "$f") || return 1
[ -n "$absorbed" ] || continue
[ "$endpoint" -ge "$absorbed" ] || continue
receipt=$(_fm_status_absorbed_receipt_path "$f")
rm -f -- "$receipt" || return 1
done <<EOF
$snapshot
EOF
}

scan_open_decisions_snapshot() { # <state> <task-and-endpoint-snapshot>
Expand Down Expand Up @@ -1031,6 +1127,32 @@ status_new_lines_since_cursor() { # <status-file> [<captured-end-offset>]
return "$rc"
}

# Print non-blank lines from one explicitly captured byte span. Both endpoints
# must belong to the current regular status file. This is the offset-aware
# primitive delayed absorbed-status presentation needs so it can surface every
# absorbed byte without treating later unrelated routine appends as presented.
status_lines_between_offsets() { # <status-file> <start-offset> <end-offset>
local f=$1 start=$2 end=$3 size chunk_file line rc=0
[ -f "$f" ] && [ -r "$f" ] && [ ! -L "$f" ] || return 1
case "$start:$end" in *[!0-9:]*) return 1 ;; esac
[ "$start" -le "$end" ] || return 1
size=$(_fm_status_file_size "$f") || return 1
size=${size//[[:space:]]/}
case "$size" in ''|*[!0-9]*) return 1 ;; esac
[ "$end" -le "$size" ] || return 1
[ "$start" -lt "$end" ] || return 0
chunk_file="$(_fm_open_decisions_cursor_path "$f").span.$$"
_fm_status_read_span "$f" "$start" "$((end - start))" > "$chunk_file" 2>/dev/null \
|| { rm -f "$chunk_file"; return 1; }
while IFS= read -r line || [ -n "$line" ]; do
case "$line" in
*[![:space:]]*) printf '%s\n' "$line" || { rc=1; break; } ;;
esac
done < "$chunk_file"
rm -f "$chunk_file"
return "$rc"
}

# 0 when a status line is an informational `note:` or a reserved-key
# pending-reply resolution. Those lines never fold into OPEN DECISIONS, so the
# drain's unread-status surface is their only guaranteed presentation.
Expand Down Expand Up @@ -1081,11 +1203,29 @@ EOF
}

scan_unread_surface_snapshot() { # <state> <task-and-endpoint-snapshot>
local state=$1 snapshot=$2 task endpoint ident f lines line
local state=$1 snapshot=$2 task endpoint ident f lines line offset absorbed
while IFS=$(printf '\t') read -r task endpoint ident; do
[ -n "$task" ] || continue
f="$state/$task.status"
lines=$(status_new_lines_since_cursor "$f" "$endpoint") || return 1
# A status file that vanished or was replaced by a symlink between the
# snapshot and this scan has nothing left to present; skip it rather than
# failing the whole presentation, matching status_new_lines_since_cursor.
[ -f "$f" ] && [ -r "$f" ] && [ ! -L "$f" ] || continue
offset=$(status_presentation_cursor_offset "$f") || return 1
absorbed=$(status_absorbed_receipt_endpoint "$f") || return 1
[ -n "$absorbed" ] || absorbed=$offset
[ "$absorbed" -le "$endpoint" ] || absorbed=$endpoint
[ "$absorbed" -ge "$offset" ] || absorbed=$offset
if [ "$absorbed" -gt "$offset" ]; then
lines=$(status_lines_between_offsets "$f" "$offset" "$absorbed") || return 1
while IFS= read -r line; do
[ -n "$line" ] || continue
printf '%s\t%s\n' "$task" "$line"
done <<EOF
$lines
EOF
fi
lines=$(status_lines_between_offsets "$f" "$absorbed" "$endpoint") || return 1
[ -n "$lines" ] || continue
while IFS= read -r line; do
[ -n "$line" ] || continue
Expand Down Expand Up @@ -1181,6 +1321,68 @@ signal_reason_is_actionable() { # <file> ...
return 1
}

# 0 only when every referenced signal path has a recognized routine shape.
# Status files must end in a recognized nonterminal lifecycle verb (the progress
# and declared-wait verbs, plus the informational `note:` verb, whose bytes are
# still surfaced exactly once through the delayed presentation receipt); turn-end
# markers may accompany them or stand alone. Missing files, unknown path kinds,
# blank status logs, and every other verb are not routine, so both attended and
# away supervisors fail toward waking on syntax they do not positively understand.
signal_reason_is_routine_nonterminal() { # <file> ...
local f last verb seen=0
for f in "$@"; do
case "$f" in
*.status)
[ -f "$f" ] && [ -r "$f" ] && [ ! -L "$f" ] || return 1
last=$(last_status_line "$f")
[ -n "$last" ] || return 1
verb=$(status_line_verb "$last")
case "$verb" in
working|resolved|captain-held|note|"${FM_CLASSIFY_PAUSED_VERB:-$FM_CLASSIFY_PAUSED_VERB_DEFAULT}") ;;
*) return 1 ;;
esac
seen=1
;;
*.turn-ended)
[ -f "$f" ] && [ -r "$f" ] && [ ! -L "$f" ] || return 1
seen=1
;;
*) return 1 ;;
esac
done
[ "$seen" -eq 1 ]
}

# Attended routine status absorption is safely default-on for the narrow shapes
# above. FM_ATTENDED_ROUTINE_STATUS_ABSORB overrides the home-local
# config/attended-routine-status-absorb value. Only exact `on` or `off` is
# accepted; invalid input disables absorption so configuration drift wakes the
# model instead of hiding work.
attended_routine_status_absorb_enabled() {
local value=${FM_ATTENDED_ROUTINE_STATUS_ABSORB:-} home config
if [ -z "$value" ]; then
home=${FM_HOME:-${FM_ROOT_OVERRIDE:-$_FM_CLASSIFY_LIB_DIR/..}}
config="$home/config/attended-routine-status-absorb"
if [ -e "$config" ] || [ -L "$config" ]; then
[ -f "$config" ] && [ -r "$config" ] && [ ! -L "$config" ] || return 1
value=$(LC_ALL=C command cat "$config" 2>/dev/null) || return 1
else
value=on
fi
fi
[ "$value" = on ]
}

# The one attended signal-absorption policy. A caller may absorb only when the
# feature is enabled, every signal has a recognized routine shape, no status is
# captain-relevant, and every referenced crew has positive current-work proof.
attended_signal_is_absorbable() { # <file> ...
attended_routine_status_absorb_enabled || return 1
signal_reason_is_routine_nonterminal "$@" || return 1
signal_reason_is_actionable "$@" && return 1
signal_crew_provably_working "$@"
}

# Classify WHY an idle/stale crew MIGHT be safely absorbed instead of surfaced,
# from bin/fm-crew-state.sh's one authoritative current-state line
# ("state: <s> · source: <src> · <detail>"). Prints exactly one token:
Expand Down
21 changes: 20 additions & 1 deletion bin/fm-supervise-daemon.sh
Original file line number Diff line number Diff line change
Expand Up @@ -339,9 +339,28 @@ _collapse_newlines() { # <text>
# summary firstmate would otherwise have to re-read.

classify_signal() { # <reason-after-colon> <state>
local reason=$1 state=$2 f last distilled="" rel="" all_seen=1 task seen
local reason=$1 state=$2 f last distilled="" rel="" all_seen=1 task seen live=""
# A path that no longer exists was retired by teardown, not left unclassified,
# and carries no content to act on, so it self-handles exactly as before rather
# than adding a contentless digest entry. Whatever remains must have a
# recognized shape: the same positive policy attended mode uses is the away
# classifier's syntax boundary too. Unknown verbs, unknown path kinds, and
# blank logs are escalated instead of becoming a second, broader dialect.
# shellcheck disable=SC2086 # reason is a space-separated signal path list
for f in $reason; do
[ -e "$f" ] || continue
live="${live}${live:+ }$f"
done
if [ -n "$live" ]; then
# shellcheck disable=SC2086 # live is a space-separated signal path list
if ! signal_reason_is_routine_nonterminal $live \
&& ! signal_reason_is_actionable $live; then
printf 'escalate|unrecognized signal shape: %s' "$reason"
return
fi
fi
# shellcheck disable=SC2086 # live is a space-separated signal path list
for f in $live; do
last=$(last_status_line "$f")
[ -n "$last" ] || continue
distilled="${distilled}$(basename "$f"): ${last} | "
Expand Down
4 changes: 2 additions & 2 deletions bin/fm-wake-drain.sh
Original file line number Diff line number Diff line change
Expand Up @@ -347,7 +347,7 @@ if [ ! -s "$FM_WAKE_QUEUE" ]; then
esac
fm_lock_release "$FM_WAKE_QUEUE_LOCK"
DRAIN_LOCK_HELD=false
(print_status_presentation) || true
(print_status_presentation) || echo "wake drain: status presentation failed; UNREAD STATUS, OPEN DECISIONS and RECORD DIVERGENCE may be incomplete" >&2
if [ "$RECOVERY_ACK_REQUIRED" = true ]; then
printf 'WAKE_ACK_REQUIRED: after handling completes run bin/fm-wake-drain.sh --ack-through 0 --recovery-generation %s\n' "${RECOVERY_MARKER_TOKEN##*:}" >&2
fi
Expand Down Expand Up @@ -399,6 +399,6 @@ DRAIN_LOCK_HELD=false
printf 'WAKE_ACK_REQUIRED: after handling completes run bin/fm-wake-drain.sh --ack-through %s --recovery-generation %s\n' \
"$ACK_THROUGH" "${RECOVERY_MARKER_TOKEN##*:}" >&2

(print_status_presentation "$RAW_ROWS") || true
(print_status_presentation "$RAW_ROWS") || echo "wake drain: status presentation failed; UNREAD STATUS, OPEN DECISIONS and RECORD DIVERGENCE may be incomplete" >&2
assert_watcher_liveness
exit 0
Loading
Loading