Skip to content
Open
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: 2 additions & 0 deletions .agents/skills/afk/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,8 @@ Classify each wake this way:
- `signal` or `stale` for a declared wait, either a `paused:` external wait or a verified `captain-held` transfer -> self-handle and track the pause rather than a wedge.
If it remains declared and idle past `FM_PAUSE_RESURFACE_SECS` (default 3600s), housekeeping sends one recheck and resets the pause window.
That recheck names which human the wait is on: the external dependency for `paused:`, and the captain themself for a `captain-held` transfer, who can answer the held decision or release the hold.
A `paused:` external wait whose own armed, validated merge poll already covers it takes no recheck at all: the shared classifier drops it and deliberately leaves the pause window unreset, exactly as the always-on watcher does ([`docs/architecture.md`](../../../docs/architecture.md) owns that rule).
A `captain-held` transfer is never dropped that way, because a merge poll says nothing about whether the captain answered.
- `check` -> always escalate. Check scripts print only when firstmate should wake.
- `stale` with a terminal status or bare legacy captain-relevant line -> escalate.
Nonterminal progress remains transient even when its prose contains a legacy free-text token or its seen-status marker already matches, so record a marker and self-handle.
Expand Down
4 changes: 3 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,8 @@ state/ runtime records and signals; gitignored
<id>.pr-poll private validated data sidecar for the byte-static PR merge poll
<id>.pr-poll-registration private transactional provenance record binding the task, canonical metadata identity, sidecar, and static poll publication
<id>.pr-poll-retirement private identity-bound crash-recovery receipt for one exact validated merged result; removed after its poll artifacts retire
<id>.pr-poll-terminal private identity-bound record that this exact PR was positively observed closed without merging, so the merge poll can no longer stand in for a declared wait; only an unambiguous forge answer writes it
.<id>.pause-poll-covered private note that this task's declared wait needs no bounded recheck because its own armed merge poll already wakes on the merge; written and withdrawn by both supervisors through fm-classify-lib.sh, printed under that task in the session-start fleet digest
.pr-check-quarantine/ private non-runnable storage for checks neutralized by the non-executing migration
.pr-check-migration.log private per-task outcomes distinguishing rebuilt or canonically registered replacement polls, quarantined unarmed polls, and incomplete migrations
.pr-check-migration-scan-v1 private marker proving the non-executing scan disabled every unsafe legacy check; .pr-check-migration-v1 separately records completed private repairs
Expand Down Expand Up @@ -170,7 +172,7 @@ When that section reports its checks still in progress it names exactly what is
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.
The script itself never starts supervision; the emitted harness protocol owns the exact wait or wake mechanism.
5. **Fleet-state digest** - after that read-once contract and ahead of the context digest, the compact backlog listing owned by `bin/fm-session-start.sh`; every `state/<id>.meta`; a bounded tail of each task's `state/<id>.status` (labeled as wake-EVENT history, not current state, with the full log path printed for a deeper read); the `state/.afk` flag; and one cheap alive/dead read of each task's recorded backend endpoint.
5. **Fleet-state digest** - after that read-once contract and ahead of the context digest, the compact backlog listing owned by `bin/fm-session-start.sh`; every `state/<id>.meta`, with that task's `state/.<id>.pause-poll-covered` note when one exists; a bounded tail of each task's `state/<id>.status` (labeled as wake-EVENT history, not current state, with the full log path printed for a deeper read); the `state/.afk` flag; and one cheap alive/dead read of each task's recorded backend endpoint.
That liveness line is a fast presence check only, not a full state read - when you need a crew's actual current state (a run-step, not just "is the pane there"), read it with `bin/fm-crew-state.sh <id>` as before; the digest deliberately skips that deeper, slower read for every task so it stays fast and bounded.
6. **Network checks** - after the fleet-state digest, the deferred stage's result, or an explicit statement of what it has not confirmed yet.
A read-only session runs no network checks at all and says so.
Expand Down
122 changes: 120 additions & 2 deletions bin/fm-classify-lib.sh
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
# daemon keeps its escalation-digest seen-markers; the watcher keeps its .seen-*
# signatures).
#
# 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 @@ -25,7 +25,11 @@
# 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.
# at the moment they would otherwise escalate. pause_recheck_covered_by_merge_poll
# (see "declared pauses a merge poll already covers" below) also writes: it
# delegates the trust decision to bin/fm-pr-lib.sh and records or drops the
# observable coverage note that keeps a suppressed recheck from looking like a
# broken one.

# 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 @@ -171,6 +175,120 @@ status_is_paused_or_captain_held() { # <status-line>
status_is_paused "$line" || status_is_captain_held "$line"
}

# --- declared pauses a merge poll already covers ----------------------------
#
# The bounded re-surface above exists because firstmate has no other way to learn
# that a declared wait cleared. That is not true of a task whose PR merge is being
# polled: bin/fm-pr-check.sh arms a static validated poll that prints exactly one
# line the moment the PR merges and stays silent on everything inconclusive,
# errors included, so the poll - not an hourly model turn - is what learns that
# this exact wait cleared. Re-surfacing such a pause only re-asks a question
# already answered by a check that cannot miss.
#
# This predicate answers about a poll, not about a verb: WHICH declarations may be
# dropped is the caller's narrowing, and both supervisors offer it only to the
# declared external-wait verb (bin/fm-watch.sh's handle_paused_stale owns that
# rule). Never hand it a captain hold - a merge poll cannot report that.
#
# bin/fm-pr-lib.sh owns what makes a poll trustworthy (fm_pr_poll_artifacts_valid:
# byte-identical poll source, private 0600 artifacts on the state device, and a
# registration binding the task, the PR identity, and both file identities to the
# task's own metadata). This predicate only asks that owner, so a check.sh that is
# some other custom check, a poll whose trust binding no longer matches, or an
# unreadable library all answer no. It is positive-evidence-only by design: only a
# fully validated poll for THIS task suppresses the recheck, because a redundant
# wake costs one turn while a lost one costs a stalled task nobody notices.
#
# Suppression is deliberately observable rather than silent. On a yes this records
# state/.<task>.pause-poll-covered, a one-line human-readable note the session-start
# fleet digest prints under that task; on a no it removes that note, so coverage
# that lapses cannot leave a stale claim behind. That side effect is the fourth
# documented exception to this library's otherwise pure reads.
#
# Coverage also LAPSES on a positively observed terminal non-merged outcome. The
# poll only ever speaks on a merge, so a PR closed without merging would leave the
# wait quiet forever; the poll durably records that observation when it sees it
# (fm_pr_poll_terminal_observed) and this predicate reads that record on the way
# past. Nothing inconclusive counts: silence, a forge error, an expired
# credential, an absent CLI, and an unparseable answer leave no record, so this
# path never asks the forge anything and never mistakes a lookup failure for a
# rejection.
#
# The trust template is resolved from this library's own directory and is NOT
# environment-overridable: it is the byte-comparison anchor that separates the
# validated poll from any other check.sh, so an ambient value redirecting it would
# let an arbitrary check suppress a real recheck.
# FM_CLASSIFY_PAUSE_POLL_URL carries the covering PR URL back to callers that want
# it for their own logging.
FM_CLASSIFY_PAUSE_POLL_URL=

# Path of the observable coverage note for <state> <task>.
pause_poll_coverage_note_path() { # <state> <task-id>
printf '%s/.%s.pause-poll-covered' "$1" "$2"
}

# Drop the coverage note for <state> <task>. Called by both supervisors when a
# crew stops declaring its pause, and by teardown, so the note never outlives the
# wait it describes.
pause_poll_coverage_forget() { # <state> <task-id>
local state=$1 task=$2
[ -n "$state" ] && [ -n "$task" ] || return 0
rm -f -- "$(pause_poll_coverage_note_path "$state" "$task")" 2>/dev/null || true
return 0
}

# 0 if <task>'s declared pause is already covered by an armed, validated merge
# poll for that same task, so its bounded recheck is pure cost. Records the
# observable coverage note on 0 and removes it on 1.
pause_recheck_covered_by_merge_poll() { # <state-dir> <task-id>
local state=$1 task=$2 note stamp template url
FM_CLASSIFY_PAUSE_POLL_URL=
[ -n "$state" ] && [ -n "$task" ] || return 1
# Cheap short-circuit so the common no-poll pause never pays for loading or
# running the validation owner below; the owner still decides every yes.
if [ ! -f "$state/$task.check.sh" ] || [ -L "$state/$task.check.sh" ]; then
pause_poll_coverage_forget "$state" "$task"
return 1
fi
if ! declare -F fm_pr_poll_artifacts_valid >/dev/null 2>&1; then
if [ ! -f "$_FM_CLASSIFY_LIB_DIR/fm-pr-lib.sh" ]; then
pause_poll_coverage_forget "$state" "$task"
return 1
fi
# shellcheck source=bin/fm-pr-lib.sh
. "$_FM_CLASSIFY_LIB_DIR/fm-pr-lib.sh" || { pause_poll_coverage_forget "$state" "$task"; return 1; }
fi
template="$_FM_CLASSIFY_LIB_DIR/fm-pr-poll.sh"
if [ ! -f "$template" ] \
|| ! fm_pr_poll_artifacts_valid "$state" "$task" "$template"; then
pause_poll_coverage_forget "$state" "$task"
return 1
fi
url=$FM_PR_DATA_URL
# The covering PR has been seen to end without merging, so the poll that was
# standing in for the recheck will never speak again: coverage lapses here and
# the ordinary bounded recheck resumes. A library too old to answer that
# question cannot establish coverage either, so it lapses the same way.
if ! declare -F fm_pr_poll_terminal_observed >/dev/null 2>&1 \
|| fm_pr_poll_terminal_observed "$state" "$task" "$url"; then
pause_poll_coverage_forget "$state" "$task"
return 1
fi
FM_CLASSIFY_PAUSE_POLL_URL=$url
note=$(pause_poll_coverage_note_path "$state" "$task")
# Rewrite only when the covered PR changed, so the note keeps the timestamp of
# the moment this wait first went quiet instead of churning every poll.
if [ -f "$note" ] && grep -qF -- " $FM_CLASSIFY_PAUSE_POLL_URL " "$note" 2>/dev/null; then
return 0
fi
stamp=$(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date -u)
if ( umask 077; printf 'declared-wait recheck suppressed since %s: the armed merge poll for %s covers this wait and wakes firstmate when that PR merges\n' \
"$stamp" "$FM_CLASSIFY_PAUSE_POLL_URL" > "$note" ) 2>/dev/null; then
chmod 0600 "$note" 2>/dev/null || true
fi
return 0
}

# --- durable keyed decisions ------------------------------------------------
#
# The status stream is an append-only EVENT log. Reading it last-event-wins
Expand Down
6 changes: 6 additions & 0 deletions bin/fm-pr-check.sh
Original file line number Diff line number Diff line change
Expand Up @@ -136,4 +136,10 @@ fm_pr_poll_publish_prepared || {
echo "error: could not publish PR poll" >&2
exit 1
}
# A freshly armed watch has observed nothing yet, so any terminal non-merged
# record left by the poll this one replaces must not answer for it.
fm_pr_poll_terminal_forget "$STATE" "$ID" || {
echo "error: stale terminal PR observation could not be cleared" >&2
exit 1
}
printf 'armed: state/%s.check.sh\n' "$ID"
91 changes: 91 additions & 0 deletions bin/fm-pr-lib.sh
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,8 @@ FM_PR_RETIRE_REG_IDENTITY=
FM_PR_RETIRE_RECEIPT_HASH=
FM_PR_RETIRE_RECEIPT_IDENTITY=
FM_PR_POLL_RETIREMENT_REJECTED=
FM_PR_TERMINAL_ID=
FM_PR_TERMINAL_URL=

fm_task_id_path_safe() {
local id=${1-}
Expand Down Expand Up @@ -927,6 +929,95 @@ fm_pr_poll_retirement_recover_one() {
&& [ ! -e "$receipt" ] && [ ! -L "$receipt" ]
}

# --- positively observed terminal non-merged outcomes -----------------------
#
# The poll speaks on a merge and stays silent on every other reading, errors
# included, so silence alone can never separate "still open" from "closed
# without merging". A poll run that positively observes the forge's own terminal
# non-merged state records it here, once, so a later reader can establish that
# this exact PR will never merge without asking the forge again. Nothing short of
# that unambiguous observation is ever written: silence, a forge error, an
# expired credential, an absent CLI, and an unparseable answer all leave no
# record, which keeps every reader on the fail-toward-the-recheck side.
#
# The record is identity-bound to one PR URL rather than to the task alone, so it
# can only answer for the PR that was actually observed; a poll rearmed at a
# different PR is not covered by it. The observation is deliberately sticky: a
# closed PR that is later reopened leaves the record standing, which costs a
# redundant recheck rather than a lost one.
# Layout: version tag, task id, canonical PR URL, result.
fm_pr_poll_terminal_parse() { # <file>
local file=$1 version id url result _extra
FM_PR_TERMINAL_ID=
FM_PR_TERMINAL_URL=
[ -f "$file" ] && [ ! -L "$file" ] || return 1
exec 8< "$file" || return 1
IFS= read -r version <&8 || { exec 8<&-; return 1; }
IFS= read -r id <&8 || { exec 8<&-; return 1; }
IFS= read -r url <&8 || { exec 8<&-; return 1; }
IFS= read -r result <&8 || { exec 8<&-; return 1; }
if IFS= read -r _extra <&8; then
exec 8<&-
return 1
fi
exec 8<&-
[ "$version" = fm-pr-poll-terminal-v1 ] || return 1
fm_pr_task_id_valid "$id" || return 1
fm_pr_url_parse "$url" || return 1
[ "$result" = closed-unmerged ] || return 1
FM_PR_TERMINAL_ID=$id
FM_PR_TERMINAL_URL=$FM_PR_URL
}

# 0 when <state> holds a validated record that <id>'s PR <url> was positively
# observed to have reached a terminal non-merged state.
fm_pr_poll_terminal_observed() { # <state> <id> <url>
local state=$1 id=$2 url=$3 record state_device
fm_pr_task_id_valid "$id" || return 1
[ -n "$url" ] || return 1
[ -d "$state" ] && [ ! -L "$state" ] || return 1
record="$state/$id.pr-poll-terminal"
[ -e "$record" ] || [ -L "$record" ] || return 1
state_device=$(fm_pr_file_device "$state") || return 1
fm_pr_private_file_valid "$record" 600 "$state_device" || return 1
fm_pr_poll_terminal_parse "$record" || return 1
[ "$FM_PR_TERMINAL_ID" = "$id" ] || return 1
[ "$FM_PR_TERMINAL_URL" = "$url" ]
}

# Record that <id>'s PR <url> was positively observed closed without merging.
# Idempotent, atomic, and private; refuses anything but a canonical PR URL.
fm_pr_poll_terminal_publish() { # <state> <id> <url>
local state=$1 id=$2 url=$3 record state_device tmp
fm_pr_task_id_valid "$id" || return 1
fm_pr_url_parse "$url" || return 1
[ "$FM_PR_URL" = "$url" ] || return 1
[ -d "$state" ] && [ ! -L "$state" ] || return 1
fm_pr_poll_terminal_observed "$state" "$id" "$url" && return 0
state_device=$(fm_pr_file_device "$state") || return 1
record="$state/$id.pr-poll-terminal"
fm_pr_regular_destination_on_device_or_absent "$record" "$state_device" || return 1
tmp=$(mktemp "$state/.fm-pr-poll-terminal.XXXXXX") || return 1
if ! printf '%s\n%s\n%s\n%s\n' fm-pr-poll-terminal-v1 "$id" "$url" closed-unmerged > "$tmp" \
|| ! chmod 0600 "$tmp" \
|| ! fm_pr_private_file_valid "$tmp" 600 "$state_device" \
|| ! fm_pr_regular_destination_on_device_or_absent "$record" "$state_device" \
|| ! mv -f -- "$tmp" "$record"; then
rm -f -- "$tmp"
return 1
fi
fm_pr_poll_terminal_observed "$state" "$id" "$url"
}

# Drop <id>'s terminal record. Called wherever a poll is rearmed or torn down, so
# an observation never outlives the watch it described.
fm_pr_poll_terminal_forget() { # <state> <id>
local state=$1 id=$2
fm_pr_task_id_valid "$id" || return 1
rm -f -- "$state/$id.pr-poll-terminal" || return 1
[ ! -e "$state/$id.pr-poll-terminal" ] && [ ! -L "$state/$id.pr-poll-terminal" ]
}

fm_pr_poll_retirement_recover_all() {
local state=$1 template=$2 receipt id
FM_PR_POLL_RETIREMENT_REJECTED=
Expand Down
24 changes: 17 additions & 7 deletions bin/fm-pr-poll.sh
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
#!/usr/bin/env bash
# Static watcher program for a validated PR/MR poll sidecar.
# It emits exactly one merged line for a merged PR or MR and stays silent
# otherwise, including on every error, so a failed lookup can never be read as
# a merge. The provider-tagged identity is data in the sidecar and is never
# It emits exactly one line for each of the two outcomes that end a merge watch:
# "merged" for a merged PR or MR, and "closed-unmerged" for one the forge itself
# reports as closed without merging. Every other reading is silence, errors
# included, so a failed lookup can never be read as either terminal outcome and
# an unreachable forge can never be read as a rejection.
# The provider-tagged identity is data in the sidecar and is never
# interpolated into this source: these bytes are identical for every task.
# Each provider is read through its own standard CLI, gh for GitHub and glab
# for GitLab, so an upstream checkout needs no extra tooling to follow either.
Expand Down Expand Up @@ -63,7 +66,10 @@ case "$provider" in
esac
[ "$url" = "https://github.com/$owner/$repo/pull/$number" ] || exit 0
state=$(gh pr view "$url" --json state -q .state 2>/dev/null) || exit 0
[ "$state" = MERGED ] && printf '%s\n' merged
case "$state" in
MERGED) printf '%s\n' merged ;;
CLOSED) printf '%s\n' closed-unmerged ;;
esac
;;
gitlab)
[ "${#host}" -ge 1 ] && [ "${#host}" -le 253 ] || exit 0
Expand Down Expand Up @@ -99,11 +105,15 @@ case "$provider" in
# to git for the current repository, and the watcher runs in no repository.
# The state is read from glab's own field output rather than its JSON,
# because plain glab has no field selector and firstmate does not require a
# JSON processor; only an exact "merged" wakes, so a changed format or an
# unreadable merge request stays silent instead of reporting a merge.
# JSON processor; only an exact "merged" or an exact "closed" is reported, so
# a changed format or an unreadable merge request stays silent instead of
# reporting either a merge or a rejection.
raw=$(glab mr view "$number" -R "https://$host/$path" 2>/dev/null) || exit 0
state=$(printf '%s\n' "$raw" | sed -n 's/^state:[[:space:]]*//p' | head -1) || exit 0
[ "$state" = merged ] && printf '%s\n' merged
case "$state" in
merged) printf '%s\n' merged ;;
closed) printf '%s\n' closed-unmerged ;;
esac
;;
*) exit 0 ;;
esac
Expand Down
Loading