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
7 changes: 6 additions & 1 deletion .agents/skills/bootstrap-diagnostics/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
name: bootstrap-diagnostics
description: >-
Agent-only handling playbook for session-start bootstrap diagnostics.
Use whenever the session-start digest's bootstrap section prints an actionable diagnostic line - MISSING, MISSING_MANUAL, BACKEND_INVALID, NEEDS_GH_AUTH, TANGLE, CREW_DISPATCH invalid, FLEET_SYNC, PR_CHECK_MIGRATION, SECONDMATE_SYNC, SECONDMATE_LIVENESS, NUDGE_SECONDMATES, or FMX - or when a standalone bin/fm-bootstrap.sh run prints one of those lines.
Use whenever the session-start digest's bootstrap section prints an actionable diagnostic line - MISSING, MISSING_MANUAL, BACKEND_INVALID, NEEDS_GH_AUTH, TANGLE, CREW_DISPATCH invalid, FLEET_SYNC, PR_CHECK_MIGRATION, SECONDMATE_SYNC, SECONDMATE_LIVENESS, NUDGE_SECONDMATES, UPSTREAM, or FMX - or when a standalone bin/fm-bootstrap.sh run prints one of those lines.
A silent bootstrap section, or a BOOTSTRAP_INFO fact, means no skill load.
user-invocable: false
metadata:
Expand Down Expand Up @@ -48,5 +48,10 @@ When any diagnostic needs captain attention, report the plain consequence and re
Investigate the reason because that secondmate is not guaranteed live.
- `NUDGE_SECONDMATES: secondmate <id>: send failed: <reason>` - the secondmate sweep fast-forwarded a running secondmate home and its loaded instruction surface (`AGENTS.md`, `bin/`, or `.agents/skills/`) changed, but the deterministic `fm-send.sh fm-<id>` re-read nudge failed.
Inspect the reason, keep the pending marker under `state/.secondmate-nudge-pending/` intact, and rerun session start after the endpoint or metadata issue is fixed so bootstrap can retry the exact same marked send.
- `UPSTREAM: <N> commits behind <remote>/<branch> (<url>) - <subjects>` - this home is a fork whose configured upstream has commits the local checkout lacks.
Tell the captain the commit count, the upstream URL, and the listed subjects so they can judge urgency.
Do not merge, rebase, or fast-forward from upstream yourself; `/updatefirstmate` and `bin/fm-update.sh` only advance from origin and cannot deliver upstream work on a fork.
Wait for an explicit captain decision on whether and how to take the upstream commits.
Absence of this line is normal for a non-fork home, an offline probe, or a current tip - never invent drift.
- `FMX: X mode on ...` / `FMX: X mode off ...` - bootstrap confirmed or removed the local X-mode poll artifacts (`docs/configuration.md` "X mode (.env)").
Only when a running watcher needs the cadence transition applied immediately, restart the home-scoped watcher through the emitted harness supervision protocol; bootstrap deliberately never restarts the watcher itself.
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -452,7 +452,7 @@ It performs guarded fast-forward updates of firstmate and registered secondmate

These skills are not captain-invocable; load them only at their precise triggers.

- `bootstrap-diagnostics` - load whenever the session-start digest's bootstrap section prints an actionable diagnostic line (`MISSING:`, `MISSING_MANUAL:`, `BACKEND_INVALID:`, `NEEDS_GH_AUTH`, `TANGLE:`, `CREW_DISPATCH: invalid`, `FLEET_SYNC:`, `PR_CHECK_MIGRATION:`, `SECONDMATE_SYNC:`, `SECONDMATE_LIVENESS:`, `NUDGE_SECONDMATES:`, or `FMX:`); silence and `BOOTSTRAP_INFO:` need no load.
- `bootstrap-diagnostics` - load whenever the session-start digest's bootstrap section prints an actionable diagnostic line (`MISSING:`, `MISSING_MANUAL:`, `BACKEND_INVALID:`, `NEEDS_GH_AUTH`, `TANGLE:`, `CREW_DISPATCH: invalid`, `FLEET_SYNC:`, `PR_CHECK_MIGRATION:`, `SECONDMATE_SYNC:`, `SECONDMATE_LIVENESS:`, `NUDGE_SECONDMATES:`, `UPSTREAM:`, or `FMX:`); silence and `BOOTSTRAP_INFO:` need no load.
- `diagnostic-reasoning` - load before scoping a reported bug and before acting on a diagnostic report.
- `harness-adapters` - load before spawning or recovering a crewmate or secondmate, handling a trust dialog, sending a harness-specific skill invocation, interrupting or exiting an agent, resuming an exited agent, or verifying a new harness adapter.
- `firstmate-orca` - load before switching to Orca, spawning or supervising Orca-backed work, smoke-testing Orca backend behavior, debugging Orca task state, or reconciling Orca-backed task metadata.
Expand Down
12 changes: 12 additions & 0 deletions bin/fm-bootstrap.sh
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,14 @@
# "NUDGE_SECONDMATES: secondmate <id>: send failed: <reason>",
# "BOOTSTRAP_INFO: nudged fm-<id> with '<message>'",
# "SECONDMATE_LIVENESS: secondmate <id>: skipped: <reason>|respawn failed: <reason>",
# "UPSTREAM: <N> commits behind <remote>/<branch> (<url>) - <subjects>",
# "FMX: X mode on ..." or "FMX: X mode off ...".
# UPSTREAM is detect-only and silent when there is no upstream remote,
# origin and upstream share a URL (not a fork), the home is a secondmate,
# the network is unavailable, or HEAD already contains the upstream tip.
# It never merges and never touches projects/; see bin/fm-upstream-lib.sh.
# Surrounding tooling (no-mistakes, treehouse) is out of scope here - those
# already surface their own version gaps through MISSING / their CLIs.
# When a RUNNING secondmate worktree is fast-forwarded to firstmate's
# own current default-branch commit (a purely LOCAL fast-forward, never
# an origin fetch) AND its loaded instruction surface (AGENTS.md, bin/,
Expand Down Expand Up @@ -100,6 +107,8 @@ DATA="${FM_DATA_OVERRIDE:-$FM_HOME/data}"
. "$SCRIPT_DIR/fm-x-lib.sh"
# shellcheck source=bin/fm-backend.sh disable=SC1091
. "$SCRIPT_DIR/fm-backend.sh"
# shellcheck source=bin/fm-upstream-lib.sh disable=SC1091
. "$SCRIPT_DIR/fm-upstream-lib.sh"

fleet_sync_origin_backed_project_count() {
local count proj
Expand Down Expand Up @@ -800,6 +809,9 @@ if [ "${FM_BOOTSTRAP_VERBOSE_FACTS:-0}" = 1 ] \
&& ! fm_backlog_backend_manual "$CONFIG" && fm_tasks_axi_compatible; then
echo "BOOTSTRAP_INFO: tasks-axi available"
fi
# Read-only fork drift check: surfaces commits on the configured upstream remote
# that this home lacks. Silent when not a fork / offline / current. Never merges.
fm_upstream_check "$FM_ROOT" "$FM_HOME"
if [ "${FM_BOOTSTRAP_DETECT_ONLY:-0}" != 1 ]; then
secondmate_sync
secondmate_liveness_sweep
Expand Down
210 changes: 210 additions & 0 deletions bin/fm-upstream-lib.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
# shellcheck shell=bash
# Read-only upstream-drift detection for a forked firstmate home.
# Usage: . bin/fm-upstream-lib.sh
#
# fm_upstream_check prints at most one actionable bootstrap line when the
# configured upstream remote has commits this home lacks:
# UPSTREAM: <N> commits behind <remote>/<branch> (<url>) - <subject>; ...
# Silent (no output) when there is no upstream remote, origin and upstream are
# the same URL (not a fork), the home is a secondmate, the network is down, the
# tip is already reachable and current, or any probe fails.
#
# Detection never merges, never force-updates local branches, and never touches
# projects/. The only git write is a bounded fetch that updates
# refs/remotes/<remote>/<branch> so commit subjects can be listed.
# Mechanics and defaults below are owned by this file; bin/fm-bootstrap.sh only
# sources and calls it.

FM_UPSTREAM_REMOTE_DEFAULT="${FM_UPSTREAM_REMOTE_DEFAULT:-upstream}"
FM_UPSTREAM_LS_TIMEOUT_DEFAULT="${FM_UPSTREAM_LS_TIMEOUT_DEFAULT:-3}"
FM_UPSTREAM_FETCH_TIMEOUT_DEFAULT="${FM_UPSTREAM_FETCH_TIMEOUT_DEFAULT:-5}"
FM_UPSTREAM_SUBJECT_LIMIT_DEFAULT="${FM_UPSTREAM_SUBJECT_LIMIT_DEFAULT:-8}"

# Run <cmd...> under a portable wall-clock timeout of <secs> seconds.
# Exit status is the command's, or 124 on timeout (GNU timeout convention).
fm_upstream_run_timeout() {
local secs=$1
shift
if command -v timeout >/dev/null 2>&1; then
timeout "$secs" "$@"
return $?
fi
if command -v gtimeout >/dev/null 2>&1; then
gtimeout "$secs" "$@"
return $?
fi
perl -e '
my $seconds = shift;
my $pid = fork;
die "fork failed\n" unless defined $pid;
if (!$pid) {
setpgrp(0, 0);
exec @ARGV;
die "exec failed: $!\n";
}
local $SIG{ALRM} = sub {
kill "TERM", -$pid;
select undef, undef, undef, 0.2;
kill "KILL", -$pid;
exit 124;
};
alarm $seconds;
waitpid $pid, 0;
exit($? >> 8);
' "$secs" "$@"
}

# Normalize a remote URL for equality checks: strip trailing .git and slash,
# and peel a file:// scheme so path-form and file-form remotes compare equal.
fm_upstream_normalize_url() {
local u=$1
u=${u%.git}
u=${u%/}
case "$u" in
file://*) u=${u#file://} ;;
esac
printf '%s\n' "$u"
}

# Resolve the branch name to compare against on <remote> in <dir>.
# Prefers refs/remotes/<remote>/HEAD, then origin-default, then main/master.
fm_upstream_branch() {
local dir=$1 remote=$2 ref branch
ref=$(git -C "$dir" symbolic-ref --quiet --short "refs/remotes/$remote/HEAD" 2>/dev/null || true)
if [ -n "$ref" ]; then
printf '%s\n' "${ref#"$remote"/}"
return 0
fi
if command -v fm_default_branch >/dev/null 2>&1; then
branch=$(fm_default_branch "$dir" 2>/dev/null || true)
if [ -n "$branch" ]; then
printf '%s\n' "$branch"
return 0
fi
fi
for branch in main master; do
if git -C "$dir" show-ref --verify --quiet "refs/remotes/$remote/$branch" \
|| git -C "$dir" show-ref --verify --quiet "refs/heads/$branch"; then
printf '%s\n' "$branch"
return 0
fi
done
return 1
}

# Truncate <text> to <max> characters, appending "..." when clipped.
fm_upstream_clip() {
local text=$1 max=$2
if [ "${#text}" -le "$max" ]; then
printf '%s\n' "$text"
return 0
fi
printf '%s...\n' "${text:0:$((max - 3))}"
}

# Print subjects for commits in <tip> not reachable from <base>, bounded.
fm_upstream_subjects() {
local dir=$1 base=$2 tip=$3 limit=$4
local line clipped out="" n=0
while IFS= read -r line; do
[ -n "$line" ] || continue
clipped=$(fm_upstream_clip "$line" 72)
if [ -z "$out" ]; then
out=$clipped
else
out="$out; $clipped"
fi
n=$((n + 1))
[ "$n" -lt "$limit" ] || break
done < <(git -C "$dir" log --format=%s --no-decorate -n "$limit" "$base..$tip" 2>/dev/null || true)
printf '%s\n' "$out"
}

# Detect upstream drift for the firstmate repo at <dir>.
# Optional <home> enables the secondmate-home silence rule when that home carries
# a .fm-secondmate-home marker. Always exits 0; prints one line or nothing.
fm_upstream_check() {
local dir=$1 home=${2:-} remote branch origin_url upstream_url tip head
local track_ref count subjects ls_timeout fetch_timeout subject_limit url_disp
local tip_ok=0

remote=${FM_UPSTREAM_REMOTE:-$FM_UPSTREAM_REMOTE_DEFAULT}
ls_timeout=${FM_UPSTREAM_LS_TIMEOUT:-$FM_UPSTREAM_LS_TIMEOUT_DEFAULT}
fetch_timeout=${FM_UPSTREAM_FETCH_TIMEOUT:-$FM_UPSTREAM_FETCH_TIMEOUT_DEFAULT}
subject_limit=${FM_UPSTREAM_SUBJECT_LIMIT:-$FM_UPSTREAM_SUBJECT_LIMIT_DEFAULT}

case "$ls_timeout" in *[!0-9]* | '') ls_timeout=$FM_UPSTREAM_LS_TIMEOUT_DEFAULT ;; esac
case "$fetch_timeout" in *[!0-9]* | '') fetch_timeout=$FM_UPSTREAM_FETCH_TIMEOUT_DEFAULT ;; esac
case "$subject_limit" in *[!0-9]* | '' | 0) subject_limit=$FM_UPSTREAM_SUBJECT_LIMIT_DEFAULT ;; esac

[ -n "$dir" ] || return 0
git -C "$dir" rev-parse --is-inside-work-tree >/dev/null 2>&1 || return 0

if [ -n "$home" ] && [ -f "$home/.fm-secondmate-home" ]; then
return 0
fi

git -C "$dir" remote get-url "$remote" >/dev/null 2>&1 || return 0

origin_url=$(git -C "$dir" remote get-url origin 2>/dev/null || true)
upstream_url=$(git -C "$dir" remote get-url "$remote" 2>/dev/null || true)
[ -n "$upstream_url" ] || return 0
if [ -n "$origin_url" ] \
&& [ "$(fm_upstream_normalize_url "$origin_url")" = "$(fm_upstream_normalize_url "$upstream_url")" ]; then
return 0
fi

branch=$(fm_upstream_branch "$dir" "$remote" 2>/dev/null || true)
[ -n "$branch" ] || branch=main
track_ref="refs/remotes/$remote/$branch"

tip=$(
GIT_TERMINAL_PROMPT=0 \
fm_upstream_run_timeout "$ls_timeout" \
git -C "$dir" ls-remote --refs "$remote" "refs/heads/$branch" 2>/dev/null \
| awk 'NR==1 { print $1; exit }'
) || true
[ -n "$tip" ] || return 0

head=$(git -C "$dir" rev-parse HEAD 2>/dev/null || true)
[ -n "$head" ] || return 0
[ "$tip" != "$head" ] || return 0

if git -C "$dir" cat-file -e "$tip^{commit}" 2>/dev/null; then
tip_ok=1
else
# Bounded fetch into the remote-tracking ref only - never merges, never
# touches local branches or projects/.
if GIT_TERMINAL_PROMPT=0 \
fm_upstream_run_timeout "$fetch_timeout" \
git -C "$dir" fetch --no-tags --quiet "$remote" \
"+refs/heads/$branch:$track_ref" >/dev/null 2>&1; then
if git -C "$dir" cat-file -e "$tip^{commit}" 2>/dev/null; then
tip_ok=1
fi
fi
fi
[ "$tip_ok" -eq 1 ] || return 0

# Upstream tip already contained in HEAD means we are ahead or equal, not behind.
if git -C "$dir" merge-base --is-ancestor "$tip" "$head" 2>/dev/null; then
return 0
fi

count=$(git -C "$dir" rev-list --count "$head..$tip" 2>/dev/null || true)
case "$count" in
'' | *[!0-9]*) return 0 ;;
0) return 0 ;;
esac

subjects=$(fm_upstream_subjects "$dir" "$head" "$tip" "$subject_limit")
url_disp=$(fm_upstream_normalize_url "$upstream_url")
if [ -n "$subjects" ]; then
printf 'UPSTREAM: %s commits behind %s/%s (%s) - %s\n' \
"$count" "$remote" "$branch" "$url_disp" "$subjects"
else
printf 'UPSTREAM: %s commits behind %s/%s (%s)\n' \
"$count" "$remote" "$branch" "$url_disp"
fi
return 0
}
4 changes: 4 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -423,6 +423,10 @@ FM_WEDGE_DEMAND_INSPECT_COUNT=3 # consecutive provably-working stale escalati
FM_WATCH_TRIAGE_LOG_MAX_BYTES=262144 # size cap for the watcher's absorbed-wake debug log
FM_FLEET_SYNC_BOOTSTRAP_TIMEOUT= # optional seconds allowed for bootstrap's best-effort clone refresh; unset/blank defaults to max(20, 5 + 3 * origin-backed-project-count)
FM_FLEET_PRUNE=1 # set to 0 to skip pruning local branches whose upstream is gone
FM_UPSTREAM_REMOTE=upstream # remote probed by bootstrap's read-only fork-drift check (`UPSTREAM:` line; bin/fm-upstream-lib.sh)
FM_UPSTREAM_LS_TIMEOUT=3 # seconds allowed for the fork-drift ls-remote probe before it silently gives up
FM_UPSTREAM_FETCH_TIMEOUT=5 # seconds allowed for the bounded tracking-ref-only fetch that lists commit subjects
FM_UPSTREAM_SUBJECT_LIMIT=8 # max upstream commit subjects listed on the UPSTREAM: line
FM_STALE_WORKTREE_LOCK_AGE_SECS=30 # min mtime age before fm-teardown.sh treats a leftover worktree git index.lock as provably stale
FM_TREEHOUSE_RETURN_LOCK_RETRIES=3 # retries after a treehouse return fails on the transient git index.lock signature
FM_TREEHOUSE_RETURN_LOCK_RETRY_WAIT_SECS=1 # seconds fm-teardown.sh waits before each retry after that signature
Expand Down
1 change: 1 addition & 0 deletions docs/scripts.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ The shared no-mistakes gate refusal for fleet lifecycle entrypoints is summarize
| `fm-task-outcome.sh` | Resolve a worker outcome from an explicit value, structured backlog title, or safe fallback |
| `fm-visible-status.sh` | Project authoritative worker details onto Herdr presentation metadata |
| `fm-tangle-lib.sh` | Shared default-branch resolution and primary-checkout tangle classification |
| `fm-upstream-lib.sh` | Read-only fork upstream-drift detection for session-start bootstrap (`UPSTREAM:`) |
| `fm-supervision-lib.sh` | Shared in-flight-work-without-fresh-watcher-beacon predicate |
| `fm-ff-lib.sh` | Shared guarded fast-forward helper for origin pulls and local secondmate syncs |
| `fm-lock-lib.sh` | Shared "is this git lock provably abandoned?" proof used by teardown and fleet-sync |
Expand Down
Loading