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
21 changes: 11 additions & 10 deletions bin/fm-backend.sh
Original file line number Diff line number Diff line change
Expand Up @@ -622,34 +622,35 @@ fm_backend_source_readable() { # <path>
}

fm_backend_source() { # <name>
local name=$1 adapter rel path siblings
local name=$1 adapter rel sibling
fm_backend_validate "$name" || return 1
adapter="$FM_BACKEND_LIB_DIR/backends/$name.sh"
# The sibling list rides in the positional parameters: zsh does not
# word-split an unquoted expansion, so a space-separated string is one path.
case "$name" in
tmux)
siblings="fm-tmux-lib.sh fm-composer-lib.sh fm-cursor-lib.sh fm-session-lock-lib.sh fm-agent-process-lib.sh fm-gemini-lib.sh"
set -- fm-tmux-lib.sh fm-composer-lib.sh fm-cursor-lib.sh fm-session-lock-lib.sh fm-agent-process-lib.sh fm-gemini-lib.sh
;;
herdr)
siblings="fm-composer-lib.sh fm-transition-lib.sh fm-agent-process-lib.sh fm-session-lock-lib.sh fm-gemini-lib.sh"
set -- fm-composer-lib.sh fm-transition-lib.sh fm-agent-process-lib.sh fm-session-lock-lib.sh fm-gemini-lib.sh
;;
zellij)
siblings="fm-backend-hometag-lib.sh fm-composer-lib.sh"
set -- fm-backend-hometag-lib.sh fm-composer-lib.sh
;;
orca)
siblings="fm-composer-lib.sh"
set -- fm-composer-lib.sh
;;
cmux)
siblings="fm-backend-hometag-lib.sh fm-composer-lib.sh"
set -- fm-backend-hometag-lib.sh fm-composer-lib.sh
;;
*)
return 1
;;
esac
fm_backend_source_readable "$adapter" || return 1
# shellcheck disable=SC2086 # sibling names are a fixed space-separated list
for rel in $siblings; do
path="$FM_BACKEND_LIB_DIR/$rel"
fm_backend_source_readable "$path" || return 1
for rel in "$@"; do
sibling="$FM_BACKEND_LIB_DIR/$rel"
fm_backend_source_readable "$sibling" || return 1
done
case "$name" in
tmux)
Expand Down
38 changes: 34 additions & 4 deletions bin/fm-classify-lib.sh
Original file line number Diff line number Diff line change
Expand Up @@ -1057,6 +1057,28 @@ EOF
printf '%s' "$verb"
}

# The status file inside <state> that is this home's outbound parent channel
# rather than a self-home task status log, printed; empty when there is none.
# Only a remote mate home resolves one - its state/parent-replies.status is the
# parent channel (bin/fm-parent-channel-lib.sh owns that resolution, sourced
# lazily here because that library sources this one at its top level, so a
# top-level source would be circular). A main home, a local mate - whose
# channel lives in the parent home - or an unusable identity or binding keeps
# every file, so ordinary task logs fold and wake exactly as before. The home
# is the directory containing <state>, the <home>/state layout every caller of
# these fleet-wide scans shares; a state dir outside such a home excludes
# nothing. Callers compare the resolved path, never the file name, so a
# parent-replies.status in any other home shape stays an ordinary task log.
status_scan_parent_channel_exclude() { # <state>
local state=$1 exclude
if ! command -v fm_parent_channel_outbound_status >/dev/null 2>&1; then
# shellcheck source=bin/fm-parent-channel-lib.sh
. "$_FM_CLASSIFY_LIB_DIR/fm-parent-channel-lib.sh"
fi
exclude=$(fm_parent_channel_outbound_status "$(dirname "$state")" "$state") || return 0
printf '%s\n' "$exclude"
}

# Fleet-wide wrapper around status_open_decisions: scans every task's status
# log under <state> and prefixes each still-open decision with its owning task
# id, so a per-wake or per-session surface can print the consolidated open set
Expand All @@ -1065,9 +1087,11 @@ EOF
# one "<task>\t<key>\t<verb>\t<note>" line per open decision, in glob (task id)
# order; prints nothing when none are open.
scan_open_decisions() { # <state>
local state=$1 f task open line
local state=$1 f task open line exclude
exclude=$(status_scan_parent_channel_exclude "$state")
for f in "$state"/*.status; do
[ -e "$f" ] || continue
[ "$f" = "$exclude" ] && continue
task=$(basename "$f"); task="${task%.status}"
open=$(status_open_decisions "$f") || continue
[ -n "$open" ] || continue
Expand Down Expand Up @@ -1358,9 +1382,11 @@ status_open_decisions_incremental() { # <status-file> [<captured-end-offset>]
# the whole-file status_open_decisions, so a fleet-wide per-drain scan stays
# bounded by new appends rather than total lifetime log size across every task.
scan_open_decisions_incremental() { # <state>
local state=$1 f task open line
local state=$1 f task open line exclude
exclude=$(status_scan_parent_channel_exclude "$state")
for f in "$state"/*.status; do
[ -e "$f" ] || continue
[ "$f" = "$exclude" ] && continue
task=$(basename "$f"); task="${task%.status}"
open=$(status_open_decisions_incremental "$f") || continue
[ -n "$open" ] || continue
Expand All @@ -1375,9 +1401,11 @@ EOF
}

status_presentation_snapshot() { # <state>
local state=$1 f task size ident
local state=$1 f task size ident exclude
exclude=$(status_scan_parent_channel_exclude "$state")
for f in "$state"/*.status; do
[ -e "$f" ] || continue
[ "$f" = "$exclude" ] && continue
[ -f "$f" ] && [ -r "$f" ] && [ ! -L "$f" ] || continue
task=$(basename "$f"); task="${task%.status}"
size=$(_fm_status_file_size "$f") || return 1
Expand Down Expand Up @@ -1989,9 +2017,11 @@ status_line_is_unread_surface() { # <status-line>
# Prints nothing when none are unread. Directory scan rejects status symlinks
# the same way scan_open_decisions does.
scan_unread_surface_lines() { # <state>
local state=$1 f task lines line
local state=$1 f task lines line exclude
exclude=$(status_scan_parent_channel_exclude "$state")
for f in "$state"/*.status; do
[ -e "$f" ] || continue
[ "$f" = "$exclude" ] && continue
task=$(basename "$f"); task="${task%.status}"
lines=$(status_new_lines_since_cursor "$f") || return 1
[ -n "$lines" ] || continue
Expand Down
11 changes: 6 additions & 5 deletions bin/fm-contributions.sh
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
# fm-contributions.sh snapshot <input.json> [--all]
# fm-contributions.sh poll
# fm-contributions.sh pending
# fm-contributions.sh verdict <task> <url> <judged-head> <source-url> <actor> <summary>
# fm-contributions.sh verdict <task> <url> <judged-head> <source-url> <captain|fleet|maintainer|nobody> <summary>
# fm-contributions.sh ack <task> <url> <event-token>
# fm-contributions.sh arm [--if-owned]
#
Expand All @@ -23,9 +23,10 @@
# checks/reviews). Checks are normalized by name, id, started_at, status and
# conclusion; projection picks the newest attempt per distinct name. The last
# observation's lane names also disclose a lane absent from the next head.
# A verdict records the EXACT judged head, source URL, actor and summary. A
# comment's arrival time never supplies its judged head. Record a prose verdict
# only after its source identifies that head; otherwise leave it unbound and
# A verdict records the EXACT judged head, source URL, actor and summary. The
# actor is exactly one of captain, fleet, maintainer or nobody; any other value
# is refused. A comment's arrival time never supplies its judged head. Record a
# prose verdict only after its source identifies that head; otherwise leave it unbound and
# triage its signal. Formal reviews carry GitHub's own commit_id. Neither kind
# can grant merge authority. Captain-actor prose requires an existing live hold;
# an eligible merge remains a captain call, never an automatic forge action.
Expand Down Expand Up @@ -473,7 +474,7 @@ case "${1:-}" in
else
[ "$#" -eq 4 ] || fail 'verdict needs judged-head, source-url, actor and summary'
fm_pr_head_valid "$1" || fail 'an exact judged commit is required'
case "$3" in captain|fleet|maintainer|nobody) ;; *) fail 'invalid required actor' ;; esac
case "$3" in captain|fleet|maintainer|nobody) ;; *) fail "invalid required actor '$3'; expected one of: captain, fleet, maintainer, nobody" ;; esac
case "$2" in "$url"\#*) ;; *) fail 'verdict source must be a comment or review on this contribution' ;; esac
jq --arg head "$1" --arg source "$2" --arg actor "$3" --arg summary "$4" \
'.verdict={head:$head,source:$source,actor:$actor,summary:$summary}' "$TMP/row.json" > "$TMP/update.json"
Expand Down
8 changes: 5 additions & 3 deletions bin/fm-fleet-sync.sh
Original file line number Diff line number Diff line change
Expand Up @@ -319,10 +319,12 @@ sync_project() {
echo "$label: skipped: not a git repo"
return 0
fi
# Both sides are physical paths (git resolves --show-toplevel through symlinks),
# so a symlinked clone dir still compares equal to its own root.
# Compare filesystem identity, not spelling: the question is whether git's root
# and $PROJ are the same directory, and a string compare of the two paths also
# fails when they merely differ in case (case-insensitive volume) or in how a
# symlink is spelled.
proj_abs=$(cd "$PROJ" && pwd -P) || proj_abs=""
if [ "$proj_top" != "$proj_abs" ]; then
if [ -z "$proj_abs" ] || ! [ "$proj_top" -ef "$proj_abs" ]; then
echo "$label: skipped: not a clone root (git would act on $proj_top)"
return 0
fi
Expand Down
22 changes: 22 additions & 0 deletions bin/fm-parent-channel-lib.sh
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,28 @@ fm_parent_channel_destination() { # <home> <state>
esac
}

# The outbound parent-channel status path that lives INSIDE <state>, printed,
# when <home> is a remote mate; non-zero for a main home, a local mate, or an
# unusable identity or binding. Only the remote route resolves the channel into
# the mate's own state dir, so parent-replies.status there is the mate's parent
# channel rather than a self-home task status file: a home's own status scans
# and decision folds exclude exactly this resolved path (the same special case
# fm-pending-reply-lib.sh's wrong-home detection applies). A local mate's
# channel lives in the parent home's state/<id>.status, which the parent's
# scans must keep classifying, so only the remote route resolves here.
fm_parent_channel_outbound_status() { # <home> <state>
local home=$1 state=$2 destination rc=0
destination=$(fm_parent_channel_destination "$home" "$state") || rc=$?
[ "$rc" -eq 0 ] || return 1
# The substitution above ran the resolver in a subshell, so its route global
# died with it; resolve once more in this shell (stdout discarded, the same
# shape fm-pending-reply-lib.sh's wrong-home detection uses) so the route
# check reads the resolver's own verdict rather than re-deriving it.
fm_parent_channel_destination "$home" "$state" >/dev/null || return 1
[ "$FM_PARENT_CHANNEL_ROUTE" = remote ] || return 1
printf '%s\n' "$destination"
}

# Fold <text> onto one bounded line, so a note copied from a child ledger or a
# hold reason cannot break the channel's line framing.
fm_parent_channel_clean_note() { # <text>
Expand Down
19 changes: 13 additions & 6 deletions bin/fm-supervise-daemon.sh
Original file line number Diff line number Diff line change
Expand Up @@ -65,9 +65,10 @@
# undelivered past FM_MAX_DEFER_SECS, the daemon retries a normal flush and
# writes state/.subsuper-inject-wedged and attempts a configurable active
# alert if submit still cannot be confirmed.
# - Cheap heartbeat catch-all: every HEARTBEAT_SCAN_SECS the daemon greps all
# state/*.status for a captain-relevant line the per-wake classifier might
# have missed (e.g. a status verb outside CAPTAIN_RE) and escalates it.
# - Cheap heartbeat catch-all: every HEARTBEAT_SCAN_SECS the daemon greps the
# state dir's task status logs for a captain-relevant line the per-wake
# classifier might have missed (e.g. a status verb outside CAPTAIN_RE) and
# escalates it.
#
# The robustness shell from the prior always-inject version is preserved:
# single-instance lock (portable helper, no flock dependency), crash-loop
Expand Down Expand Up @@ -1184,8 +1185,8 @@ _oldest_line_age() { # <buf> -> seconds since the oldest buffered item first ar
# re-peek; gone -> clear; still declaring the wait, on an idle OR a busy pane
# -> escalate a recheck digest naming which human the wait is on, and reset
# the window (repeating bounded re-surface, never a wedge).
# 3) heartbeat scan: every HEARTBEAT_SCAN_SECS, grep state/*.status for a
# captain-relevant line the per-wake classifier missed and escalate it.
# 3) heartbeat scan: every HEARTBEAT_SCAN_SECS, run the catch-all status scan in
# the block below and escalate what it finds; that block owns its file set.
housekeeping() { # <state>
local state=$1 now due f key task win marker age last max_defer oldest pause_secs marker_epoch until bounded_until pause_reason
now=$(_now)
Expand Down Expand Up @@ -1339,11 +1340,17 @@ housekeeping() { # <state>
# because the event this backstop most needs to catch is precisely one a
# later routine append has already moved past; fm-classify-lib.sh's span
# read decides relevance, and the classified-through offset is the dedup.
# A remote mate's own parent channel is not a self-home task status log,
# so it is excluded here exactly as in the watcher's twin backstop
# (fm-watch.sh heartbeat_scan_finds_actionable); the home-shape-aware
# resolution lives in status_scan_parent_channel_exclude.
if [ "$(_file_age "$state/.subsuper-last-scan")" -ge "${FM_HEARTBEAT_SCAN_SECS:-$HEARTBEAT_SCAN_SECS_DEFAULT}" ]; then
_now > "$state/.subsuper-last-scan"
local event record rest endpoint ident rc
local event record rest endpoint ident rc exclude
exclude=$(status_scan_parent_channel_exclude "$state")
for f in "$state"/*.status; do
[ -e "$f" ] || [ -L "$f" ] || continue
[ "$f" = "$exclude" ] && continue
task=$(basename "$f"); task="${task%.status}"
record=$(status_span_first_actionable_record "$f" \
"$(status_seen_offset "$state" "$task")")
Expand Down
1 change: 1 addition & 0 deletions bin/fm-test-run.sh
Original file line number Diff line number Diff line change
Expand Up @@ -309,6 +309,7 @@ family_for_basename() {
;;
fm-daemon.test.sh|fm-guard-stale-banner.test.sh|fm-pi-watch-extension.test.sh|\
fm-session-lock-ancestry.test.sh|fm-cursor-primary.test.sh|\
fm-parent-channel-scan-exclusion.test.sh|\
fm-supervision-events.test.sh|fm-turnend-guard.test.sh|fm-wake-daemon-lifecycle-e2e.test.sh|\
fm-wake-drain-unread-status.test.sh|\
fm-tool-update-check.test.sh|\
Expand Down
13 changes: 11 additions & 2 deletions bin/fm-watch.sh
Original file line number Diff line number Diff line change
Expand Up @@ -1990,8 +1990,13 @@ age_of() { # seconds since file mtime; "due immediately" if missing
# The caller records reported state only after surfacing or intentional absorption,
# and commits a status classification position only after a successful span read.
scan_signals() {
local f sig sf
local f sig sf exclude
# A remote mate's own parent channel is not a self-home task status log; the
# home-shape-aware exclusion and its precedent live in
# status_scan_parent_channel_exclude (fm-classify-lib.sh).
exclude=$(status_scan_parent_channel_exclude "$STATE")
for f in "$STATE"/*.status "$STATE"/*.turn-ended; do
[ "$f" = "$exclude" ] && continue
if [ ! -e "$f" ]; then
case "$f" in *.status) [ -L "$f" ] || continue ;; *) continue ;; esac
fi
Expand Down Expand Up @@ -2255,10 +2260,14 @@ EOF
# is absorbed; it surfaces only an event the per-wake path absorbed by mistake -
# the fail-safe backstop.
heartbeat_scan_finds_actionable() {
local f task record rest endpoint ident rc found=1 sig marker
local f task record rest endpoint ident rc found=1 sig marker exclude
# Same self-home exclusion as scan_signals: a remote mate's parent channel
# must not come back through the heartbeat fail-safe backstop.
exclude=$(status_scan_parent_channel_exclude "$STATE")
FM_HEARTBEAT_SURFACE_ENDPOINTS=''
for f in "$STATE"/*.status; do
[ -e "$f" ] || [ -L "$f" ] || continue
[ "$f" = "$exclude" ] && continue
task=$(basename "$f"); task="${task%.status}"
record=$(status_span_first_actionable_record "$f" "$(hb_surfaced_offset "$task")")
rc=$?
Expand Down
3 changes: 3 additions & 0 deletions docs/secondmate-parent-channel.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ A duplicate line is harmless and a missed one is not, so the mate may still appe
For marked replies, the report helper accepts no caller-selected destination and uses the channel resolver for both local and remote homes; its script header owns the exact invocation contract.
The pending-reply guard may restate only the correlated line from a local mate's `state/<mate-id>.status` onto the parent channel, which repairs the common parent-home versus mate-home mixup without accepting arbitrary mate-home sightings as acknowledgement.
Other correlated mate-home status lines remain wrong-home evidence, while a remote home's routed `state/parent-replies.status` is already the parent channel and is not classified as wrong-home.
The mate home's own status scans treat that remote channel the same way: `status_scan_parent_channel_exclude` in `bin/fm-classify-lib.sh` resolves the outbound path through the same `bin/fm-parent-channel-lib.sh` binding, and the watcher's signal scan and heartbeat backstop, the away-mode daemon's catch-all scan, and the fleet-wide folds skip exactly that resolved path, never a file name.
The remote reply adapter already mirrors every channel line into the parent home, so folding the channel again here would only spin spurious wakes and a phantom `parent-replies` task, while a `parent-replies.status` in a main home or in a local mate is an ordinary task log that keeps folding and waking.
A missed-reply escalation includes the complete first sighting path and line number in readable shell-escaped form.

## What is deliberately not built
Expand All @@ -55,6 +57,7 @@ A missed-reply escalation includes the complete first sighting path and line num
`tests/fm-teardown.test.sh` covers teardown delivering a child's final line and refusing when the channel cannot be written.
`tests/fm-brief.test.sh` pins the charter's channel rule.
`tests/fm-pending-reply.test.sh` covers helper-selected local routing, remote-channel classification, same-basename restatement before false escalation, readable wrong-home diagnostics, and the rule that arbitrary mate-home sightings never acknowledge a reply.
`tests/fm-parent-channel-scan-exclusion.test.sh` covers the home-shape-aware scan exclusion against real remote, main-home, and local-mate fixtures: the watcher signal scan, both heartbeat backstops, the fleet-wide folds, and the real `fm-wake-drain.sh` end to end.

## Live verification

Expand Down
21 changes: 18 additions & 3 deletions tests/fm-backend.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -502,17 +502,32 @@ test_backend_validate_refuses_unknown() {
}

test_backend_source_shell_portable() {
local out status
local out status stub probe
# zsh does not word-split unquoted expansions; sourcing fm-backend.sh from
# an interactive zsh session must still recognize known backend names.
# The claim is name matching and the sibling precheck only: the adapters
# find their own siblings through BASH_SOURCE, so zsh is not a full load.
if command -v zsh >/dev/null 2>&1; then
zsh -c "cd '$ROOT' && source bin/fm-backend.sh && fm_backend_source herdr && whence -w fm_backend_herdr_capture >/dev/null" 2>/dev/null \
|| fail "zsh: fm_backend_source herdr should load the adapter when sourced"
zsh -c "cd '$ROOT' && source bin/fm-backend.sh && fm_backend_source herdr" >/dev/null 2>&1 \
|| fail "zsh: fm_backend_source herdr should accept the known backend name and find its sibling libraries"
out=$(zsh -c "cd '$ROOT' && source bin/fm-backend.sh && fm_backend_source bogus" 2>&1) \
&& fail "zsh: fm_backend_source bogus should fail"
assert_contains "$out" "unknown backend 'bogus'" \
"zsh: fm_backend_source did not reject bogus with the expected error"
pass "zsh: fm_backend_source recognizes known backends and rejects unknown ones"

# zsh ties the lowercase `path` array to PATH; a backend loaded while
# fm_backend_source clobbers PATH cannot resolve external commands.
stub="$TMP_ROOT/zsh-source-path"
probe="$stub/probe"
mkdir -p "$stub/backends"
printf 'command -v dirname > "%s"\n' "$probe" > "$stub/backends/orca.sh"
: > "$stub/fm-composer-lib.sh"
zsh -c "cd '$ROOT' && source bin/fm-backend.sh && FM_BACKEND_LIB_DIR='$stub' && fm_backend_source orca" >/dev/null 2>&1 \
|| fail "zsh: fm_backend_source orca should load a stub adapter"
[ -s "$probe" ] \
|| fail "zsh: fm_backend_source clobbered PATH while loading a backend adapter"
pass "zsh: fm_backend_source keeps PATH intact while loading a backend adapter"
else
pass "zsh: shell-portable backend matching skipped (zsh not found)"
fi
Expand Down
Loading
Loading