diff --git a/.agents/skills/afk/SKILL.md b/.agents/skills/afk/SKILL.md index e884db150c6..bda927f0c87 100644 --- a/.agents/skills/afk/SKILL.md +++ b/.agents/skills/afk/SKILL.md @@ -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. diff --git a/AGENTS.md b/AGENTS.md index 06685cee0dd..3620b4ec82e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -102,6 +102,8 @@ state/ runtime records and signals; gitignored .pr-poll private validated data sidecar for the byte-static PR merge poll .pr-poll-registration private transactional provenance record binding the task, canonical metadata identity, sidecar, and static poll publication .pr-poll-retirement private identity-bound crash-recovery receipt for one exact validated merged result; removed after its poll artifacts retire + .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 + ..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 @@ -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/.meta`; a bounded tail of each task's `state/.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/.meta`, with that task's `state/..pause-poll-covered` note when one exists; a bounded tail of each task's `state/.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 ` 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. diff --git a/bin/fm-classify-lib.sh b/bin/fm-classify-lib.sh index 9915ece7d29..2838e59ad42 100755 --- a/bin/fm-classify-lib.sh +++ b/bin/fm-classify-lib.sh @@ -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, @@ -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 @@ -171,6 +175,120 @@ status_is_paused_or_captain_held() { # 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/..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 . +pause_poll_coverage_note_path() { # + printf '%s/.%s.pause-poll-covered' "$1" "$2" +} + +# Drop the coverage note for . 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() { # + 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 '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() { # + 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 diff --git a/bin/fm-pr-check.sh b/bin/fm-pr-check.sh index dea5e34e7b9..8e5a4450b37 100755 --- a/bin/fm-pr-check.sh +++ b/bin/fm-pr-check.sh @@ -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" diff --git a/bin/fm-pr-lib.sh b/bin/fm-pr-lib.sh index b8ea9eb8fd8..b02c74b0167 100755 --- a/bin/fm-pr-lib.sh +++ b/bin/fm-pr-lib.sh @@ -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-} @@ -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() { # + 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 holds a validated record that 's PR was positively +# observed to have reached a terminal non-merged state. +fm_pr_poll_terminal_observed() { # + 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 's PR was positively observed closed without merging. +# Idempotent, atomic, and private; refuses anything but a canonical PR URL. +fm_pr_poll_terminal_publish() { # + 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 '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() { # + 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= diff --git a/bin/fm-pr-poll.sh b/bin/fm-pr-poll.sh index ed705ce7073..4522dcee442 100755 --- a/bin/fm-pr-poll.sh +++ b/bin/fm-pr-poll.sh @@ -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. @@ -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 @@ -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 diff --git a/bin/fm-session-start.sh b/bin/fm-session-start.sh index a0383e46810..3d30d36fbd6 100755 --- a/bin/fm-session-start.sh +++ b/bin/fm-session-start.sh @@ -44,9 +44,9 @@ # 5. read-once contract - the do-not-re-read contract covering every source # represented by the two digests below. # 6. fleet digest - a compact data/backlog.md identity/metadata listing, -# every state/*.meta, a bounded state/*.status tail, -# state/.afk, and a cheap per-task endpoint-liveness read: -# read-only, always runs. +# every state/*.meta, any suppressed-pause-recheck note, +# a bounded state/*.status tail, state/.afk, and a cheap +# per-task endpoint-liveness read: read-only, always runs. # 7. network checks - the result of the deferred network stage started back at # step 1, harvested WITHOUT waiting for it. # 8. context digest - data/projects.md, data/secondmates.md, data/captain.md, @@ -809,6 +809,16 @@ for meta in "$STATE"/*.meta; do printf 'endpoint: unknown (no window recorded)\n' fi + # A declared wait whose bounded recheck is being suppressed because this task's + # own merge poll already covers it. Printed here so a quiet wait is visibly + # quiet on purpose rather than looking like a recheck that broke; the note is + # written and withdrawn by the supervisors themselves (fm-classify-lib.sh's + # pause_recheck_covered_by_merge_poll). + pause_note="$STATE/.$id.pause-poll-covered" + if [ -f "$pause_note" ]; then + printf 'pause recheck: %s\n' "$(cat "$pause_note")" + fi + status="$STATE/$id.status" if [ -f "$status" ]; then print_status_tail "$status" diff --git a/bin/fm-supervise-daemon.sh b/bin/fm-supervise-daemon.sh index 86bad52b44c..f3d62d5fea7 100755 --- a/bin/fm-supervise-daemon.sh +++ b/bin/fm-supervise-daemon.sh @@ -478,6 +478,9 @@ clear_pause_tracking() { # "$state/.paused-$watcher_key" "$state/.paused-rechecked-$watcher_key" "$state/.paused-resurfaced-$watcher_key" \ "$state/.stale-$watcher_key" "$state/.stale-since-$watcher_key" "$state/.wedge-escalations-$watcher_key" \ "$state/.writing-since-$watcher_key" "$state/.writing-resurfaced-$watcher_key" + # A crew that stopped declaring its pause no longer has a recheck suppressed, + # so its coverage note must not outlive the wait it described. + pause_poll_coverage_forget "$state" "$task" } reconcile_pause_tracking() { # @@ -1037,6 +1040,17 @@ housekeeping() { # # reset the marker so the window repeats. The digest names WHICH human the wait is # on, because the captain is the one reading it: an external dependency for a # paused: declaration, and the captain themself for a verified hold transfer. + # + # The one wait that needs no recheck is one this exact task already polls for: an + # armed, validated merge poll wakes on the merge by itself, so the shared + # classifier's pause_recheck_covered_by_merge_poll drops the due recheck exactly as + # the always-on watcher does. That drop is offered only to the declared + # external-wait verb, never to a captain hold, because a merge poll says nothing + # about whether the captain answered - the same narrowing the watcher applies, + # which is what keeps the two modes agreeing. The marker is deliberately left + # un-reset so the ordinary recheck escalates on the next tick if the coverage + # lapses - including when the covering PR has been positively observed closed + # without merging, which lapses it for good. pause_secs=${FM_PAUSE_RESURFACE_SECS:-$FM_PAUSE_RESURFACE_SECS_DEFAULT} for marker in "$state"/.subsuper-paused-*; do [ -e "$marker" ] || continue @@ -1053,6 +1067,13 @@ housekeeping() { # fi age=$(( now - $(cat "$marker" 2>/dev/null || echo "$now") )) [ "$age" -ge "$pause_secs" ] || continue + # Narrowed to the declared external-wait verb before the busy probe below, so a + # covered wait costs neither the probe nor the digest, while a captain hold - + # which a merge poll cannot answer - always falls through to its own recheck. + if status_is_paused "$last" && pause_recheck_covered_by_merge_poll "$state" "$task"; then + log "self-handle (paused, armed merge poll $FM_CLASSIFY_PAUSE_POLL_URL covers the wait): $win" + continue + fi stale_window_is_busy "$win" "$state" case "$?" in 0) rm -f "$marker" ;; @@ -1060,12 +1081,14 @@ housekeeping() { # *) last=$(last_status_line "$state/$task.status") if [ -n "$last" ] && status_is_captain_held "$last"; then + pause_poll_coverage_forget "$state" "$task" escalate_add "$state" "captain-held ${age}s (awaiting the captain, answer the held decision or release the hold): $win" _now > "$marker" elif [ -n "$last" ] && status_is_paused "$last"; then escalate_add "$state" "paused ${age}s (awaiting external, recheck whether the wait still holds): $win" _now > "$marker" else + pause_poll_coverage_forget "$state" "$task" rm -f "$marker" fi ;; diff --git a/bin/fm-teardown.sh b/bin/fm-teardown.sh index c84669d3632..75aebd8d6d7 100755 --- a/bin/fm-teardown.sh +++ b/bin/fm-teardown.sh @@ -745,7 +745,7 @@ remove_pr_poll_artifacts() { fm_pr_poll_retirement_recover_one "$state_dir" "$id" "$SCRIPT_DIR/fm-pr-poll.sh" || return 1 rm -f "$state_dir/$id.check.sh" "$state_dir/$id.pr-poll" \ "$state_dir/$id.pr-poll-registration" "$state_dir/$id.pr-poll-retirement" \ - "$state_dir/$id.check-trust" || return 1 + "$state_dir/$id.pr-poll-terminal" "$state_dir/$id.check-trust" || return 1 if fm_task_id_path_safe "$id"; then quarantine="$state_dir/.pr-check-quarantine" if [ -d "$quarantine" ] && [ ! -L "$quarantine" ]; then @@ -2559,7 +2559,8 @@ rm -f "$STATE/$ID.turn-ended" "$STATE/$ID.meta" \ "$STATE/$ID.kimi-turnend-token" "$STATE/$ID.muse-session" \ "$STATE/$ID.muse-session-current" "$STATE/$ID.cursor-session" \ "$STATE/$ID.control-relaunch" "$STATE/$ID.control-relaunch.meta-prior" \ - "$STATE/$ID.control-relaunch.brief-prior" "$STATE/$ID.control-relaunch.note" + "$STATE/$ID.control-relaunch.brief-prior" "$STATE/$ID.control-relaunch.note" \ + "$STATE/$ID.pr-poll-terminal" "$STATE/.$ID.pause-poll-covered" fm_lock_release "$META_LOCK" META_LOCK_HELD=0 if [ "$KIND" != scout ] && [ "$KIND" != secondmate ] && [ "$MODE" != local-only ]; then diff --git a/bin/fm-watch.sh b/bin/fm-watch.sh index d1d59d3ceb5..a8c311b772e 100755 --- a/bin/fm-watch.sh +++ b/bin/fm-watch.sh @@ -23,7 +23,10 @@ # external-wait pause or verified captain-held transfer is # absorbed instead with its own long re-surface cadence, # never as a wedge, and that recheck reason names which -# human the wait is on. Only when neither absorb class +# human the wait is on. A paused: wait that its own +# armed, validated merge poll already covers is dropped +# instead of re-surfaced (handle_paused_stale); a +# captain hold never is. Only when neither absorb class # applies does the log's last line decide: # terminal (captain-relevant) or non-terminal (no verb), # both surfaced at once. A provably-working stale past the @@ -52,7 +55,11 @@ # demand-deep-inspection marker, for human inspection # only - never an automatic interrupt, signal, or restart # of the worker or its tool process. -# check: