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 AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,7 @@ A lock-refused session must not spawn, steer, merge, drain the wake queue, repai

The digest itself makes no external-network call and never waits for one.
Every network check a session start owes - GitHub auth, dead-secondmate relaunch, secondmate convergence, pending handoff delivery, and project clone refresh - runs off the digest's blocking path in a bounded worker owned by `bin/fm-startup-network.sh` and is reported in the digest's own `NETWORK CHECKS` section.
The locked startup inactive-outcome scan joins that worker so a slow local current-state read cannot block the digest; its findings use the ordinary durable wake queue.
The locked startup inactive-outcome scan and best-effort home-summary publication join that worker so a slow local current-state read cannot block the digest; the scan's findings use the ordinary durable wake queue.
When that section reports its checks still in progress it names exactly what is unconfirmed; treat none of those as passed until `bin/fm-startup-network.sh report` returns the finished result, while a failed or otherwise actionable result also arrives as a `check: startup-network` wake.

1. **Lock** - acquires the per-home session lock first, before anything mutates shared state, then starts the deferred startup stage above.
Expand Down
13 changes: 11 additions & 2 deletions bin/fm-fleet-snapshot.sh
Original file line number Diff line number Diff line change
Expand Up @@ -1991,8 +1991,17 @@ contribution_tasks_json() {
if [ "$OUTPUT_MODE" = contribution-input ]; then
# Reuse the canonical backlog parser, without observing workers or other homes.
contribution_tasks=$(contribution_tasks_json) || { echo "fm-fleet-snapshot: contribution task read failed" >&2; exit 1; }
jq -n --argjson backlog "$BACKLOG_JSON" --argjson tasks "$contribution_tasks" '{backlog:$backlog,tasks:$tasks}'
exit 0
JSON_TRANSPORT_DIR=$(mktemp -d "${TMPDIR:-/tmp}/fm-fleet-snapshot.XXXXXX") \
|| { echo "fm-fleet-snapshot: temporary transport directory creation failed" >&2; exit 1; }
printf '%s\n' "$BACKLOG_JSON" > "$JSON_TRANSPORT_DIR/backlog.json" \
|| { echo "fm-fleet-snapshot: temporary backlog file write failed" >&2; exit 1; }
printf '%s\n' "$contribution_tasks" > "$JSON_TRANSPORT_DIR/contribution-tasks.json" \
|| { echo "fm-fleet-snapshot: temporary contribution task file write failed" >&2; exit 1; }
jq -n --slurpfile backlog "$JSON_TRANSPORT_DIR/backlog.json" \
--slurpfile tasks "$JSON_TRANSPORT_DIR/contribution-tasks.json" \
'{backlog:$backlog[0],tasks:$tasks[0]}'
jq_rc=$?
exit "$jq_rc"
fi
prefetch_task_current_states || { echo "fm-fleet-snapshot: task observation failed" >&2; exit 1; }
TASKS_JSON=$(task_json_lines) || { echo "fm-fleet-snapshot: task snapshot failed" >&2; exit 1; }
Expand Down
132 changes: 108 additions & 24 deletions bin/fm-herdr-session-cleanup.sh
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@
# The script never closes a workspace. It removes only the matching journal,
# and only after the exact pane is confirmed gone. Every error warns and returns
# success so session startup continues conservatively.
# Discovery validates each home journal once; locked mutation checks are uncached.
# FM_HERDR_SESSION_CLEANUP_TIMEOUT bounds the complete pass (default 30 seconds);
# after expiry the parent reclaims only recorded locks it can safely acquire.
# Unfinished candidates are preserved and coverage is explicitly unconfirmed.
set -u

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
Expand All @@ -35,6 +39,12 @@ STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}"
fm_backend_source herdr
# shellcheck source=bin/fm-pr-lib.sh
. "$SCRIPT_DIR/fm-pr-lib.sh"
# shellcheck source=bin/fm-timeout-lib.sh
. "$SCRIPT_DIR/fm-timeout-lib.sh"

FM_HERDR_CLEANUP_INDEX=
FM_HERDR_CLEANUP_INDEX_READY=0
FM_HERDR_CLEANUP_LOCK_RECORD=${FM_HERDR_CLEANUP_LOCK_RECORD:-}

fm_herdr_cleanup_warn() {
printf 'warning: herdr session-start projection cleanup: %s\n' "$*" >&2
Expand Down Expand Up @@ -63,7 +73,14 @@ fm_herdr_cleanup_home_identity() {
}

fm_herdr_cleanup_journal_matches() { # <title> <session> <home-real>
local title=$1 session=$2 home_real=$3 journal id expected journal_home
local title=$1 session=$2 home_real=$3 index
index=$(fm_herdr_cleanup_index "$session" "$home_real") || return 1
fm_herdr_cleanup_index_matches "$title" "$index"
}

# The index is only a discovery accelerator, never mutation authority.
fm_herdr_cleanup_index() { # <session> <home-real>
local session=$1 home_real=$2 journal id journal_home expected
[ -d "$STATE" ] && [ ! -L "$STATE" ] || return 1
for journal in "$STATE"/*"$FM_BACKEND_HERDR_PRESENTATION_JOURNAL_SUFFIX"; do
[ -f "$journal" ] && [ ! -L "$journal" ] || continue
Expand All @@ -78,11 +95,20 @@ fm_herdr_cleanup_journal_matches() { # <title> <session> <home-real>
fi
expected=$(fm_backend_herdr_projection_workspace_label \
"$id" "$FM_BACKEND_HERDR_JOURNAL_PROJECTION_ID")
[ "$expected" = "$title" ] || continue
printf '%s\t%s\t%s\n' "$journal" "$id" "$FM_BACKEND_HERDR_JOURNAL_PROJECTION_ID"
printf '%s\t%s\t%s\t%s\n' "$expected" "$journal" "$id" \
"$FM_BACKEND_HERDR_JOURNAL_PROJECTION_ID"
done
}

fm_herdr_cleanup_index_matches() { # <title> [index]
local title=$1 index=${2-$FM_HERDR_CLEANUP_INDEX} label record
while IFS= read -r record; do
label=${record%%$'\t'*}
[ "$label" = "$title" ] || continue
printf '%s\n' "${record#*$'\t'}"
done <<< "$index"
}

fm_herdr_cleanup_unique_match() { # <title> <session> <home-real>
local title=$1 session=$2 home_real=$3 matches count record
FM_HERDR_CLEANUP_JOURNAL=
Expand All @@ -92,7 +118,11 @@ fm_herdr_cleanup_unique_match() { # <title> <session> <home-real>
FM_HERDR_CLEANUP_BOUND_WORKSPACE=
FM_HERDR_CLEANUP_BOUND_TAB=
FM_HERDR_CLEANUP_BOUND_PANE=
matches=$(fm_herdr_cleanup_journal_matches "$title" "$session" "$home_real") || return 1
if [ "${4:-fresh}" = discovery ] && [ "$FM_HERDR_CLEANUP_INDEX_READY" = 1 ]; then
matches=$(fm_herdr_cleanup_index_matches "$title") || return 1
else
matches=$(fm_herdr_cleanup_journal_matches "$title" "$session" "$home_real") || return 1
fi
count=$(printf '%s\n' "$matches" | awk 'NF { n++ } END { print n+0 }')
[ "$count" -eq 1 ] || return 1
record=$(printf '%s\n' "$matches" | awk 'NF { print; exit }')
Expand Down Expand Up @@ -199,12 +229,47 @@ fm_herdr_cleanup_revalidate() { # <session> <workspace> <tab> <pane> <title> <to
[ "${focus#*$'\t'}" != "$tab" ]
}

fm_herdr_cleanup_one() { # <session> <workspace> <title> <home-real>
fm_herdr_cleanup_record_lock_paths() { # <id> <task-lock> <presentation-lock>
local id=$1 task_lock=$2 presentation_lock=$3 record=$FM_HERDR_CLEANUP_LOCK_RECORD
[ -n "$record" ] || return 0
[ -f "$record" ] && [ ! -L "$record" ] || return 1
printf '%s\t%s\t%s\n' "$id" "$task_lock" "$presentation_lock" > "$record"
}

fm_herdr_cleanup_recover_interrupted_candidate() { # <lock-record>
local record=$1 id task_lock presentation_lock
[ -f "$record" ] && [ ! -L "$record" ] || return 0
IFS=$'\t' read -r id task_lock presentation_lock < "$record" || return 0
fm_task_id_creation_valid "$id" || return 0
[ "$task_lock" = "$STATE/.spawn-$id.lock" ] || return 0
case "$presentation_lock" in
/tmp/firstmate-herdr-presentation/order-????????????????????????????????.lock) ;;
*) return 0 ;;
esac
if fm_lock_try_acquire "$task_lock"; then

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Reclaim locks owned by the timed-out cleanup worker

When the deadline terminates cleanup after both locks are acquired, the owning shell can remain as a zombie under a container PID 1; fm_lock_try_acquire treats that PID as alive because kill -0 succeeds, so this recovery attempt does nothing. The added executable-deadline test reproduces this and leaves .spawn-task.lock behind; because the presentation lock is attempted only after reacquiring that task lock, it remains stranded too, permanently blocking later Herdr lifecycle operations in that session. Recovery needs to reclaim the recorded timed-worker ownership without relying on ordinary dead-owner acquisition.

Useful? React with 👍 / 👎.

if fm_lock_try_acquire "$presentation_lock"; then
fm_lock_release "$presentation_lock" || true
fi
fm_lock_release "$task_lock" || true
fi
return 0
}

fm_herdr_cleanup_one() ( # <session> <workspace> <title> <home-real>
local session=$1 workspace=$2 title=$3 home_real=$4 token journal id task_lock
local version bound_workspace bound_tab bound_pane presentation_lock snapshot
local tab pane state close_status=0
task_lock='' presentation_lock=''
# A deadline may interrupt lock acquisition or a backend read.
# fm_lock_release verifies this process owns each path, so unconditional cleanup
# closes the signal window before the held flags could be set.
trap '[ -z "$presentation_lock" ] || fm_lock_release "$presentation_lock" || true
[ -z "$task_lock" ] || fm_lock_release "$task_lock" || true' EXIT
trap 'exit 143' TERM
trap 'exit 130' INT
trap 'exit 129' HUP
token=$(fm_herdr_cleanup_title_token "$title") || return 0
if ! fm_herdr_cleanup_unique_match "$title" "$session" "$home_real"; then
if ! fm_herdr_cleanup_unique_match "$title" "$session" "$home_real" discovery; then
return 0
fi
journal=$FM_HERDR_CLEANUP_JOURNAL
Expand All @@ -215,24 +280,24 @@ fm_herdr_cleanup_one() { # <session> <workspace> <title> <home-real>
bound_pane=$FM_HERDR_CLEANUP_BOUND_PANE
[ "$FM_HERDR_CLEANUP_TOKEN" = "$token" ] || return 0
task_lock="$STATE/.spawn-$id.lock"
if ! fm_lock_try_acquire "$task_lock"; then
fm_herdr_cleanup_warn "$id skipped because its task lock is busy"
return 0
fi
presentation_lock=$(fm_backend_herdr_presentation_session_lock_path "$session" 2>/dev/null) || {
fm_lock_release "$task_lock" || true
fm_herdr_cleanup_warn "$id skipped because the shared presentation lock is unavailable"
return 0
}
fm_herdr_cleanup_record_lock_paths "$id" "$task_lock" "$presentation_lock" || {
fm_herdr_cleanup_warn "$id skipped because deadline lock recovery could not be recorded"
return 0
}
if ! fm_lock_try_acquire "$task_lock"; then
fm_herdr_cleanup_warn "$id skipped because its task lock is busy"
return 0
fi
if ! fm_lock_try_acquire "$presentation_lock"; then
fm_lock_release "$task_lock" || true
fm_herdr_cleanup_warn "$id skipped because the shared presentation lock is busy"
return 0
fi

if [ -e "$STATE/$id.meta" ] || [ -L "$STATE/$id.meta" ]; then
fm_lock_release "$presentation_lock" || true
fm_lock_release "$task_lock" || true
return 0
fi
snapshot=$(fm_backend_herdr_cli "$session" api snapshot 2>/dev/null) || snapshot=
Expand All @@ -241,25 +306,19 @@ fm_herdr_cleanup_one() { # <session> <workspace> <title> <home-real>
"$snapshot" "$workspace" "$title" "$token" \
"$bound_workspace" "$bound_tab" "$bound_pane"; then
fm_herdr_cleanup_warn "$id preserved because its locked candidate snapshot was ambiguous"
fm_lock_release "$presentation_lock" || true
fm_lock_release "$task_lock" || true
return 0
fi
tab=$FM_HERDR_CLEANUP_TAB
pane=$FM_HERDR_CLEANUP_PANE
if [ "$(fm_backend_herdr_pane_agent_state "$session" "$pane")" != no-agent ] \
|| ! fm_backend_herdr_pane_idle_shell_pid "$session" "$pane" >/dev/null; then
fm_herdr_cleanup_warn "$id preserved because its pane is not a provably idle childless shell"
fm_lock_release "$presentation_lock" || true
fm_lock_release "$task_lock" || true
return 0
fi
if ! fm_herdr_cleanup_revalidate \
"$session" "$workspace" "$tab" "$pane" "$title" "$token" "$home_real" \
"$journal" "$id" "$version" "$bound_workspace" "$bound_tab" "$bound_pane"; then
fm_herdr_cleanup_warn "$id preserved because immediate revalidation changed or was unreadable"
fm_lock_release "$presentation_lock" || true
fm_lock_release "$task_lock" || true
return 0
fi

Expand Down Expand Up @@ -287,10 +346,8 @@ fm_herdr_cleanup_one() { # <session> <workspace> <title> <home-real>
else
fm_herdr_cleanup_warn "$id preserved because exact pane closure could not be confirmed"
fi
fm_lock_release "$presentation_lock" || true
fm_lock_release "$task_lock" || true
return 0
}
)

fm_herdr_session_cleanup() {
local session home_real list candidates workspace title journal found=0
Expand Down Expand Up @@ -324,6 +381,11 @@ fm_herdr_session_cleanup() {
fm_herdr_cleanup_warn "session '$session' workspace discovery was unreadable; preserving every candidate"
return 0
}
FM_HERDR_CLEANUP_INDEX=$(fm_herdr_cleanup_index "$session" "$home_real") || {
fm_herdr_cleanup_warn 'journal discovery failed; preserving every candidate'
return 0
}
FM_HERDR_CLEANUP_INDEX_READY=1
while IFS=$'\t' read -r workspace title; do
[ -n "$workspace" ] && [ -n "$title" ] || continue
fm_herdr_cleanup_one "$session" "$workspace" "$title" "$home_real"
Expand All @@ -332,6 +394,28 @@ fm_herdr_session_cleanup() {
}

if [ "${FM_HERDR_SESSION_CLEANUP_SOURCE_ONLY:-0}" != 1 ]; then
fm_herdr_session_cleanup
if [ "${1:-}" = --_worker ]; then
fm_herdr_session_cleanup
else
[ -d "$STATE" ] && [ ! -L "$STATE" ] || exit 0
budget=${FM_HERDR_SESSION_CLEANUP_TIMEOUT:-30}
case "$budget" in ''|*[!0-9]*|0) budget=30 ;; esac
cleanup_lock_record=$(umask 077; mktemp "$STATE/.herdr-cleanup-locks.XXXXXX") || {
fm_herdr_cleanup_warn 'deadline lock recovery could not be prepared; preserving every candidate'
exit 0
}
cleanup_rc=0
FM_HERDR_CLEANUP_LOCK_RECORD="$cleanup_lock_record" \
fm_run_timed "$budget" "$SCRIPT_DIR/fm-herdr-session-cleanup.sh" --_worker || cleanup_rc=$?
if [ "$cleanup_rc" -ne 0 ]; then
fm_herdr_cleanup_recover_interrupted_candidate "$cleanup_lock_record"
fi
if [ "$cleanup_rc" -eq 124 ]; then
fm_herdr_cleanup_warn "${budget}s deadline reached; unfinished candidates preserved; cleanup coverage is unconfirmed"
elif [ "$cleanup_rc" -ne 0 ]; then
fm_herdr_cleanup_warn "cleanup exited $cleanup_rc; unfinished candidates preserved; cleanup coverage is unconfirmed"
fi
rm -f -- "$cleanup_lock_record" 2>/dev/null || true
fi
exit 0
fi
10 changes: 2 additions & 8 deletions bin/fm-session-start.sh
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@
# convergence, pending remote handoff delivery, and the fleet-sync fetch - are
# started as one detached bounded worker right after the lock (step 1) and
# harvested at step 7 without ever blocking on it. The bounded inactive-outcome
# startup scan joins that worker because its local current-state reads can also
# startup scan and home-summary publication join that worker because their local current-state reads can also
# be slow. bin/fm-startup-network.sh owns that stage and its safety argument;
# bin/fm-bootstrap.sh and bin/fm-inactive-reconcile.sh remain the owners of the
# work itself and still run it.
Expand Down Expand Up @@ -656,13 +656,7 @@ if [ "$READ_ONLY" -eq 0 ]; then
rm -f "$COMPLETION_FILE" 2>/dev/null || true
fi
fm_trace_context_session_start "$CONFIG" "$STATE/.trace-context-effective"
# A full locked start publishes this home's current structured summary.
# Publication is side-band and best-effort, so it can never change the
# session-start result. A context re-emit is not another session start.
if [ "$REEMIT" -eq 0 ]; then
"$SCRIPT_DIR/fm-home-summary-refresh.sh" --best-effort || true
fi
# Every network call and the potentially slow inactive-outcome startup scan
# Every network call, summary refresh, and potentially slow inactive-outcome startup scan
# are launched HERE, detached and bounded, so they run concurrently with the
# whole digest below instead of in front of it. Step 7 harvests whatever has
# finished, without ever waiting.
Expand Down
38 changes: 28 additions & 10 deletions bin/fm-startup-network.sh
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,13 @@
# composed from bounded local reads while these checks run concurrently in a
# detached worker, and their result is reported back inline when it finishes in
# time, or as a durable wake when it does not. The locked startup's bounded
# inactive-outcome scan also runs here because its local current-state reads can
# be just as slow; that scan publishes its own findings to the durable wake queue.
# inactive-outcome scan and best-effort home-summary refresh also run here because
# their local current-state reads can be just as slow; the scan publishes its own
# findings to the durable wake queue. Summary publication runs concurrently with
# the checks under its own single-flight lock and a deadline capped at the stage
# budget. The network result is published before the summary child is reaped, and
# that child is still reaped before the deferred stage finishes. It never inherits
# the digest stdout.
#
# WHAT IS PRESERVED. Nothing is dropped. bin/fm-bootstrap.sh remains the single
# owner of every network sweep and still runs all of them, unchanged, via its
Expand Down Expand Up @@ -104,7 +109,10 @@
# and the wake decision.
#
# The whole stage is bounded by FM_STARTUP_NETWORK_TIMEOUT (default 120s), one
# aggregate deadline covering both the inactive-outcome scan and network sweeps.
# aggregate deadline covering the inactive-outcome scan and network sweeps.
# Concurrent summary publication retains its own FM_HOME_SUMMARY_TIMEOUT, capped
# at that stage budget, and is reaped before the deferred stage exits. Its cleanup
# cannot delay publication of the network result.
# Hitting the bound is reported as an actionable NETWORK_CHECKS: line, never as
# silence. bin/fm-timeout-lib.sh remains the single owner of bounded execution.
set -u
Expand Down Expand Up @@ -197,7 +205,7 @@ worker_alive() {
phase_label() { # <phases>
case "$1" in
probe) printf 'GitHub authentication' ;;
probe,sweeps) printf 'GitHub authentication, dead-secondmate relaunch, secondmate convergence, pending handoff delivery, project clone refresh with its drift reporting, and inactive terminal-outcome reconciliation' ;;
probe,sweeps) printf 'GitHub authentication, dead-secondmate relaunch, secondmate convergence, pending handoff delivery, project clone refresh with its drift reporting, inactive terminal-outcome reconciliation, and home-summary publication' ;;
*) printf 'the deferred network checks' ;;
esac
}
Expand Down Expand Up @@ -420,7 +428,7 @@ EOF
}

cmd_run() { # <locked> <lock-pid> <generation>
local locked=$1 lock_pid=$2 generation=$3 phases started budget out rc sweep_locked=0 downgraded=0 internal=0 lease_held=0 timings stage_started
local locked=$1 lock_pid=$2 generation=$3 phases started budget out rc sweep_locked=0 downgraded=0 internal=0 lease_held=0 timings stage_started summary_pid='' summary_budget
mkdir -p "$STATE" 2>/dev/null || return 1
started=$(now)
budget=$(stage_budget)
Expand Down Expand Up @@ -485,12 +493,21 @@ EOF
downgraded=1
fi
fi
# One aggregate deadline covers both deferred operations. The inactive scan
# retains its own tighter per-scan bound inside this outer bound. Findings
# need no report translation: the scan writes its ordinary durable
# inactive-outcome wakes directly. A child shell composes the two executable
# owners only so fm_run_timed can govern them as one process group.
# The inactive scan retains its own tighter per-scan bound inside this outer
# bound. Findings need no report translation: the scan writes its ordinary
# durable inactive-outcome wakes directly. The best-effort summary is
# single-flight and runs alongside the checks, not in front of them. Keep its
# bounded wrapper outside the network process group: killing that wrapper at
# the network deadline could strand the separate process group it owns. Both
# budgets start together; the network result is published before reaping the
# summary child.
if [ "$sweep_locked" -eq 1 ]; then
summary_budget=${FM_HOME_SUMMARY_TIMEOUT:-60}
case "$summary_budget" in ''|*[!0-9]*|0) summary_budget=60 ;; esac
[ "$summary_budget" -le "$budget" ] || summary_budget=$budget
FM_HOME_SUMMARY_TIMEOUT="$summary_budget" FM_HOME_SUMMARY_IF_IDLE=1 \
"$SCRIPT_DIR/fm-home-summary-refresh.sh" --best-effort </dev/null >/dev/null 2>&1 &
summary_pid=$!
# shellcheck disable=SC2016 # Child-shell variables expand inside the bound.
fm_run_timed "$budget" env FM_HOME="$FM_HOME" FM_STATE_OVERRIDE="$STATE" \
FM_BOOTSTRAP_NETWORK=only FM_BOOTSTRAP_NETWORK_LOCK_PID="$lock_pid" \
Expand Down Expand Up @@ -524,6 +541,7 @@ EOF
publish "$generation" failed "$phases" "$sweep_locked" "$started" "$rc" "$out" "$timings"
;;
esac
[ -z "$summary_pid" ] || wait "$summary_pid" || true
rm -f "$out" 2>/dev/null || true
[ -z "$timings" ] || rm -f "$timings" 2>/dev/null || true
return 0
Expand Down
Loading
Loading