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
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ state/ runtime records and signals; gitignored
.wake-queue durable queued wakes retained until post-handling acknowledgement: epoch<TAB>seq<TAB>kind<TAB>key<TAB>payload
.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)
.<id>.home-appends per-task ledger of byte ranges this home itself appended as bookkeeping closes, so a wake scan can tell its own growth from a foreign write instead of waking on it; presentation is unaffected, so both the signal annotation and UNREAD STATUS still print those lines; written only by fm-classify-lib.sh's status_home_appends_record; its sibling .<id>.home-appends.lock serializes that ledger's read-merge-write; both removed by teardown, safe to delete
.status-presentation-cursor .status-presentation-lock fleet-wide per-task status identity plus independent annotation and outcome-backstop byte offsets, with a serialization lock preventing already-presented lines from replaying while preserving delayed signal annotations; owned by fm-classify-lib.sh, with each task's row retired by teardown
.afk-contract the away-posture record: the captain's verbatim away words, expected return, reach profile, spend cap, and structured mandate clauses; written only by bin/fm-afk-contract.sh after the captain confirms the read-back, archived under afk-contracts/ at return; its presence IS the away posture in every harness; its sibling .afk-contract.lock serializes actions authorized by the live record (contract: bin/fm-afk-contract.sh)
afk-contracts/ archived away-posture records: one final record per away window keyed by entry time, plus any superseded mandates from that window
Expand Down
155 changes: 149 additions & 6 deletions bin/fm-classify-lib.sh
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
# A missing, malformed, identity-mismatched, or past-end classified position reads
# from byte 0, preferring a bounded duplicate over a lost event.
#
# There are three documented exceptions. The absorb classification
# There are four documented exceptions. The absorb classification
# (crew_absorb_class and its working/paused wrappers) is NOT a pure status-file
# read: it reuses bin/fm-crew-state.sh, which may make a bounded no-mistakes call,
# to decide whether a crew that just stopped its turn or went stale is working,
Expand All @@ -37,9 +37,12 @@
# open-decisions fold" below) also writes: it persists a per-status-file byte
# cursor and folded open-set as a side effect, so a per-drain fleet-wide scan
# stays bounded by new appends instead of re-reading each task's whole lifetime
# log every time. crew_worktree_written_since reads the task's meta file and walks
# a bounded slice of its worktree instead of a status file, so callers run it only
# at the moment they would otherwise escalate.
# log every time. status_home_appends_record writes the per-task home-owned
# append ledger (see "home-owned status-append ledger" below) so the wake scan
# can treat this home's own bookkeeping bytes as already owned.
# crew_worktree_written_since reads the task's meta file and walks a bounded slice
# of its worktree instead of a status file, so callers run it only at the moment
# they would otherwise escalate.

# Directory of this library, used to locate the sibling fm-crew-state.sh reader.
# Resolved at source time from BASH_SOURCE so it works whether sourced by a
Expand Down Expand Up @@ -1319,13 +1322,15 @@ status_presentation_marker_commit() {

status_retire_presentation_task() { # <state> <task-id>
local state=$1 task=$2 lock manifest tmp data row_task ident offset backstop extra rc=0 found=0
local signal_marker heartbeat_marker daemon_marker
local signal_marker heartbeat_marker daemon_marker home_appends home_appends_lock
lock="$state/.status-presentation-lock"
manifest="$state/.status-presentation-cursor"
tmp="$manifest.tmp.$$"
signal_marker=$(status_signal_seen_marker_path "$state" "$task")
heartbeat_marker=$(status_heartbeat_seen_marker_path "$state" "$task")
daemon_marker=$(status_daemon_seen_marker_path "$state" "$task")
home_appends="$state/.$task.home-appends"
home_appends_lock="$home_appends.lock"

# A remote-home teardown can legitimately retire an endpoint ID that has no
# status log in that home. Do not contend with that home's unrelated status
Expand All @@ -1335,6 +1340,8 @@ status_retire_presentation_task() { # <state> <task-id>
if [ ! -e "$state/$task.status" ] && [ ! -L "$state/$task.status" ] \
&& [ ! -e "$state/.$task.open-decisions-cursor" ] \
&& [ ! -L "$state/.$task.open-decisions-cursor" ] \
&& [ ! -e "$home_appends" ] && [ ! -L "$home_appends" ] \
&& [ ! -e "$home_appends_lock" ] && [ ! -L "$home_appends_lock" ] \
&& [ ! -e "$signal_marker" ] && [ ! -L "$signal_marker" ] \
&& [ ! -e "$heartbeat_marker" ] && [ ! -L "$heartbeat_marker" ] \
&& [ ! -e "$daemon_marker" ] && [ ! -L "$daemon_marker" ]; then
Expand Down Expand Up @@ -1384,7 +1391,8 @@ EOF
fi
if [ "$rc" -eq 0 ]; then
rm -f -- "$state/$task.status" "$state/.$task.open-decisions-cursor" \
"$signal_marker" "$heartbeat_marker" "$daemon_marker" || rc=1
"$home_appends" "$signal_marker" "$heartbeat_marker" "$daemon_marker" || rc=1
fm_lock_remove_path "$home_appends_lock" 2>/dev/null || true
fi
fm_lock_release "$lock" || rc=1
return "$rc"
Expand Down Expand Up @@ -1733,6 +1741,141 @@ window_to_task() {
t="${w##*:}"; t="${t#fm-}"; printf '%s' "$t"
}

# --- home-owned status-append ledger ----------------------------------------
#
# This home's bookkeeping closes (fm_wake_status_append_self_announced) record
# the exact byte range they appended so the wake scan can tell this home's own
# growth from a foreign write. That is the multi-answer path: two distinct
# --resolve-key closes must not each force a captain-facing wake solely because
# each one appended a status line, while a worker-authored line that is not in
# this ledger still signals.
# fm_wake_signal_seen_current (bin/fm-wake-lib.sh) is the ONLY consumer. The
# ledger decides whether growth wakes this home and nothing else: it never
# removes a line from presentation, so the drain's signal annotation and its
# UNREAD STATUS section both still print these bytes.
# The ledger does not use lag verbs to hide a worker `resolved` line; only
# bytes this home itself recorded as owned are ever treated as owned.
#
# Path: state/.<task>.home-appends
# Format:
# v1
# ident=<file-ident>
# <start><TAB><end>
# Ranges are half-open [start, end), merged when adjacent or overlapping.
# An identity mismatch (file rotated) discards the ledger. Teardown deletes it.
# Not a pure status-file read: status_home_appends_record writes this sidecar.

status_home_appends_path() { # <status-file>
local f=$1 dir base
dir=$(dirname "$f")
base=$(basename "$f")
printf '%s/.%s.home-appends' "$dir" "${base%.status}"
}

status_home_appends_ranges() { # <status-file> -> start<TAB>end lines
local f=$1 path ident data first rest line start end extra
path=$(status_home_appends_path "$f")
[ -f "$path" ] && [ -r "$path" ] && [ ! -L "$path" ] || return 0
ident=$(_fm_open_decisions_file_ident "$f") || return 0
data=$(LC_ALL=C command cat "$path" 2>/dev/null) || return 0
first=${data%%$'\n'*}
[ "$first" = v1 ] || return 0
rest=${data#*$'\n'}
[ "$rest" != "$data" ] || return 0
line=${rest%%$'\n'*}
case "$line" in ident=*) ;; *) return 0 ;; esac
[ "${line#ident=}" = "$ident" ] || return 0
case "$rest" in
*$'\n'*) rest=${rest#*$'\n'} ;;
*) return 0 ;;
esac
while IFS=$(printf '\t') read -r start end extra || [ -n "$start" ]; do
[ -n "$start" ] || continue
[ -z "$extra" ] || continue
case "$start:$end" in *[!0-9:]*) continue ;; esac
[ "$end" -gt "$start" ] || continue
printf '%s\t%s\n' "$start" "$end" || return 1
done <<EOF
$rest
EOF
}

status_home_appends_covers() { # <status-file> <start> <end>
local start=$2 end=$3 range_start range_end
case "$start:$end" in *[!0-9:]*) return 1 ;; esac
[ "$end" -ge "$start" ] || return 1
[ "$end" -eq "$start" ] && return 0
while IFS=$(printf '\t') read -r range_start range_end; do
[ -n "$range_start" ] || continue
case "$range_start:$range_end" in *[!0-9:]*) continue ;; esac
[ "$range_start" -le "$start" ] || continue
if [ "$range_end" -gt "$start" ]; then
start=$range_end
fi
if [ "$start" -ge "$end" ]; then
return 0
fi
done <<EOF
$(status_home_appends_ranges "$1")
EOF
[ "$start" -ge "$end" ]
}

status_home_appends_record() { # <status-file> <start> <end>
local f=$1 start=$2 end=$3 path lock rc=0
case "$start:$end" in *[!0-9:]*) return 1 ;; esac
[ "$end" -gt "$start" ] || return 1
path=$(status_home_appends_path "$f")
lock="$path.lock"
fm_lock_acquire_wait "$lock" || return 1
_fm_status_home_appends_merge_locked "$f" "$path" "$start" "$end" || rc=1
fm_lock_release "$lock" || rc=1
return "$rc"
}

_fm_status_home_appends_merge_locked() { # <status-file> <ledger-path> <start> <end>
local f=$1 path=$2 start=$3 end=$4 ident tmp merged
local LC_ALL=C
ident=$(_fm_open_decisions_file_ident "$f") || return 1
merged=$(printf '%s\n%s\t%s\n' "$(status_home_appends_ranges "$f")" "$start" "$end" | awk '
NF == 2 && $1 ~ /^[0-9]+$/ && $2 ~ /^[0-9]+$/ && $2+0 > $1+0 {
n++
s[n] = $1 + 0
e[n] = $2 + 0
}
END {
for (i = 1; i <= n; i++) {
for (j = i + 1; j <= n; j++) {
if (s[j] < s[i] || (s[j] == s[i] && e[j] < e[i])) {
t = s[i]; s[i] = s[j]; s[j] = t
t = e[i]; e[i] = e[j]; e[j] = t
}
}
}
m = 0
for (i = 1; i <= n; i++) {
if (m == 0 || s[i] > me[m]) {
m++
ms[m] = s[i]
me[m] = e[i]
} else if (e[i] > me[m]) {
me[m] = e[i]
}
}
for (i = 1; i <= m; i++) printf "%s\t%s\n", ms[i], me[i]
}
') || return 1
tmp="$path.tmp.$$"
{
printf 'v1\nident=%s\n' "$ident"
if [ -n "$merged" ]; then
printf '%s' "$merged"
case "$merged" in *$'\n') ;; *) printf '\n' ;; esac
fi
} > "$tmp" || { rm -f "$tmp"; return 1; }
mv -f "$tmp" "$path" || { rm -f "$tmp"; return 1; }
}

# Capture the bytes of an append-only status log at or after <start-offset> under
# one size-and-identity snapshot.
# The record form produces `<endpoint>\t<identity>\t<events>` and returns 0 when
Expand Down
11 changes: 6 additions & 5 deletions bin/fm-send.sh
Original file line number Diff line number Diff line change
Expand Up @@ -684,11 +684,12 @@ fi
# command; the decision then stays open and re-surfaces, never silently lost.
# All of one answer's closes are this home's own bookkeeping, written by the
# very turn that answered the decisions, so they go through ONE guarded
# self-announced append (bin/fm-wake-lib.sh) and do not wake this same session
# again, including when this home already folded those bytes through OPEN
# DECISIONS without a matching watcher seen marker; any concurrent foreign
# status bytes, or a worker line the fold read but never listed, leave the
# watcher's wake path untouched.
# self-announced append (bin/fm-wake-lib.sh). That records the appended byte
# range so separate --resolve-key answers do not each wake this same session,
# including when this home already folded those bytes through OPEN DECISIONS
# without a matching watcher seen marker; any concurrent foreign status bytes,
# or a worker line the fold read but never listed, leave the watcher's wake
# path untouched.
fm_send_close_resolved_keys() { # <answer-text>
local note=$1 k close_note append_rc still manual_close_cmd close_lines=() i=0
note=$(printf '%s' "$note" | tr '\n\r\t' ' ' | LC_ALL=C tr -d '\000-\037\177')
Expand Down
54 changes: 43 additions & 11 deletions bin/fm-wake-lib.sh
Original file line number Diff line number Diff line change
Expand Up @@ -2123,7 +2123,10 @@ fm_wake_signal_seen_size() { # <state> <file>
# that fact.
# A missing marker or unreadable signature is not a match, so uncertainty reads
# as an unreported state.
fm_wake_signal_seen_current() { # <state> <file>
# This predicate never consults the owned-append ledger, which is what makes it
# the safe gate for a captain-facing surface: a line must never be withheld from
# presentation merely because this home is the writer that appended it.
fm_wake_signal_reported_current() { # <state> <file>
local sig marker
sig=$(fm_wake_signal_sig "$2") || return 1
[ -n "$sig" ] || return 1
Expand All @@ -2137,6 +2140,28 @@ fm_wake_signal_seen_current() { # <state> <file>
esac
}

# 0 when the state was already reported, or when the file is a readable regular
# file that grew past the watcher's classified offset and every grown byte is in
# this home's owned-append ledger. Owned-only growth past the classified offset
# is this home's own bookkeeping and is not a new signal, so separate
# --resolve-key answers do not each force a wake. Any other signature change
# without owned growth is not a match, so uncertainty still reads as unreported.
# This is the wake-scan predicate and answers only "should this wake the home?".
# Presentation asks the different question and uses
# fm_wake_signal_reported_current.
fm_wake_signal_seen_current() { # <state> <file>
local classified size
fm_wake_signal_reported_current "$1" "$2" && return 0
case "$2" in *.status) ;; *) return 1 ;; esac
_fm_wake_require_classify || return 1
classified=$(fm_wake_signal_seen_size "$1" "$2")
size=$(_fm_status_file_size "$2") || return 1
size=${size//[[:space:]]/}
case "$classified:$size" in *[!0-9:]*) return 1 ;; esac
[ "$classified" -lt "$size" ] && [ -f "$2" ] && [ -r "$2" ] && [ ! -L "$2" ] || return 1
status_home_appends_covers "$2" "$classified" "$size"
}

fm_wake_status_reported_commit() { # <state> <status-file> <reported-signature>
_fm_wake_require_classify || return 1
status_presentation_marker_report "$(fm_wake_signal_seen_path "$1" "$2")" "$3"
Expand All @@ -2162,9 +2187,10 @@ fm_wake_status_mark_current() { # <state> <status-file>
# in the very turn or tick that writes them (answerer-closes resolved lines, a
# pending-reply escalation close, captain-held transfers). Such a close must
# not wake the session that wrote it, so this appends one command's lines
# together and then advances the watcher's seen marker across the appended
# bytes and no byte this home has not already read. The advance is
# provenance-gated and fails toward waking:
# together, records the exact appended byte range in the home-owned append
# ledger (bin/fm-classify-lib.sh), and then advances the watcher's seen marker
# across the appended bytes and no byte this home has not already read. The
# advance is provenance-gated and fails toward waking:
# - the marker advances only when this home already read every pre-append
# byte, the post-append size equals that size plus exactly the appended
# bytes (no foreign write interleaved), AND the watcher's own span
Expand All @@ -2181,14 +2207,18 @@ fm_wake_status_mark_current() { # <state> <status-file>
# side-band;
# - on ANY other condition - a missing file, pending foreign bytes, an
# interleaved writer, an unreadable size or identity - the lines are still
# appended but the marker is left alone, so the watcher surfaces the file
# normally.
# A later, different line from any other writer grows the size past the marker
# and wakes as before: task identity alone can never suppress new content.
# appended and the owned range is still recorded when growth is proven, but
# the marker is left alone, so the watcher surfaces the file normally.
# Later signal scans treat owned ranges as already owned even when the watcher
# has not caught up, so separate --resolve-key answers do not each force a
# captain-facing wake. A later, different line from any other writer grows the
# size past the owned ranges and wakes as before: task identity alone can never
# suppress new content.
# Returns 0 appended and self-announced, 1 appended but left for the watcher
# (the safe direction), 2 the append itself failed.
fm_wake_status_append_self_announced() { # <state> <status-file> <line>...
local state=$1 file=$2 line appended=0 pre_size='' pre_ident='' post_size post_ident classified folded lag span_rc=0
local state=$1 file=$2 line appended=0 pre_size='' pre_ident='' post_size post_ident
local classified folded lag span_rc=0
local LC_ALL=C
shift 2
_fm_wake_require_classify || return 1
Expand All @@ -2197,12 +2227,14 @@ fm_wake_status_append_self_announced() { # <state> <status-file> <line>...
pre_ident=$(_fm_open_decisions_file_ident "$file") || pre_ident=''
fi
printf '%s\n' "$@" >> "$file" || return 2
case "$pre_size" in ''|*[!0-9]*) return 1 ;; esac
post_size=$(_fm_status_file_size "$file") || return 1
post_ident=$(_fm_open_decisions_file_ident "$file") || return 1
case "$pre_size$post_size" in ''|*[!0-9]*) return 1 ;; esac
case "$post_size" in ''|*[!0-9]*) return 1 ;; esac
[ -n "$pre_ident" ] && [ "$post_ident" = "$pre_ident" ] || return 1
for line in "$@"; do appended=$((appended + ${#line} + 1)); done
[ "$post_size" -eq $((pre_size + appended)) ] || return 1
status_home_appends_record "$file" "$pre_size" "$post_size" || return 1
classified=$(fm_wake_signal_seen_size "$state" "$file")
if [ "$classified" != "$pre_size" ]; then
folded=$(status_open_decisions_cursor_offset "$file") || folded=0
Expand Down Expand Up @@ -2377,7 +2409,7 @@ fm_wake_print_annotations() { # <deduped-raw-rows> [<presentation-snapshot>]
# existing historical caveat. A direct status row is annotated for every
# still-unread line since the last drain presentation; already-presented
# bytes are not replayed.
if [ "$mode" = historical ] && fm_wake_signal_seen_current "$STATE" "$path"; then
if [ "$mode" = historical ] && fm_wake_signal_reported_current "$STATE" "$path"; then
continue
fi
offset=$(fm_wake_status_cursor_offset "$path") || return 1
Expand Down
Loading
Loading