From 7d0a59e6bf4e7f8b45169bf3d989bbbb9d3f4738 Mon Sep 17 00:00:00 2001 From: Israel Wilson Date: Thu, 3 Sep 2026 11:16:04 -0400 Subject: [PATCH 01/21] feat(bin): make a terminal done claim mean something a machine checked A `done:` claim was free prose. `bin/fm-dod-lib.sh` told every no-mistakes worker to append `done: PR {url} checks green`, `bin/fm-classify-lib.sh` matched the token `done`, and the fleet treated the claim as fact. A claim identified no commit, so nothing could compare what a pipeline validated with what a PR shipped, and the merge poll only ever watched for `merged` - so a "done" record for a PR later closed without merging aged quietly. Terminal claims now carry machine-checkable identity, and a verifier decides: - bin/fm-done-claim-lib.sh (new) is the one owner of the claim grammar, its parser, and the durable verdict record. bin/fm-dod-lib.sh and bin/fm-brief.sh render the worker instruction from that owner, so the shape a worker is told to write and the shape firstmate parses cannot drift. - bin/fm-verify-done.sh (new) establishes a claim from the forge, from git, and from the validation run's own record, never from the claim's prose. It answers verified / unverified / contradicted and binds its verdict to the exact claim it judged, so a later claim supersedes rather than inherits it. Comparing the validated commit with the shipped commit is the check that catches a fix landing after validation, or a force-push over a validated head. - Nothing treats an unverified claim as done: bin/fm-crew-state.sh reports `done-unverified` with the reason, bin/fm-teardown.sh refuses unverified and contradicted alongside its existing landed-work gates, and both the fleet snapshot and the session-start digest surface the verdict. - bin/fm-pr-poll.sh reports a PR closed WITHOUT merging as well as a merge, and bin/fm-merge-outcome-lib.sh publishes it as a contradiction. The notification marker gained a v2 line recording which outcome was delivered, so a close is reported once and a later reopen-and-merge still reports its own outcome. Only a merge retires the poll. - A status line matching no known verb is flagged in the drain rather than absorbed, bounded per drain with a count of any held back. Legacy claims degrade rather than explode: they carry no commit identity, so they read as unverified and are never silently upgraded. Claude-Session: https://claude.ai/code/session_01N6zvmegmTrHiWnrfXBxuPU --- AGENTS.md | 12 +- bin/fm-brief.sh | 6 +- bin/fm-classify-lib.sh | 39 ++- bin/fm-crew-state.sh | 38 ++- bin/fm-dod-lib.sh | 39 ++- bin/fm-done-claim-lib.sh | 273 +++++++++++++++ bin/fm-fleet-snapshot.sh | 16 +- bin/fm-inactive-reconcile.sh | 15 +- bin/fm-merge-outcome-lib.sh | 62 ++-- bin/fm-pr-lib.sh | 52 ++- bin/fm-pr-poll.sh | 20 +- bin/fm-session-start.sh | 19 +- bin/fm-teardown.sh | 47 +++ bin/fm-verify-done.sh | 318 +++++++++++++++++ bin/fm-wake-drain.sh | 42 ++- bin/fm-watch.sh | 15 +- docs/scripts.md | 10 +- docs/secondmate-parent-channel.md | 2 +- tests/fm-captain-hold-lifecycle.test.sh | 34 +- tests/fm-crew-state.test.sh | 44 ++- tests/fm-done-verified.test.sh | 435 ++++++++++++++++++++++++ tests/fm-fleet-snapshot-view.test.sh | 18 +- tests/fm-gotmp.test.sh | 2 + tests/fm-pr-check-security.test.sh | 63 +++- tests/fm-session-start.test.sh | 23 +- tests/fm-teardown.test.sh | 122 ++++++- 26 files changed, 1639 insertions(+), 127 deletions(-) create mode 100644 bin/fm-done-claim-lib.sh create mode 100755 bin/fm-verify-done.sh create mode 100755 tests/fm-done-verified.test.sh diff --git a/AGENTS.md b/AGENTS.md index 149894ad849..f07e48f201a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -108,7 +108,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-merge-notified canonical PR identity of the last merge outcome delivered for this task; bin/fm-pr-lib.sh owns the marker format and identity mechanics, while bin/fm-merge-outcome-lib.sh owns locked publication, duplicate suppression, and replacement + .pr-poll-merge-notified canonical PR identity and which terminal outcome was last delivered for this task; bin/fm-pr-lib.sh owns the marker format and identity mechanics, while bin/fm-merge-outcome-lib.sh owns locked publication, duplicate suppression, and replacement + .done-verdict what bin/fm-verify-done.sh established about this task's terminal claim, bound to the exact claim it judged; bin/fm-done-claim-lib.sh owns the format, and a new claim supersedes rather than inherits it branch-outcomes.jsonl .branch-outcomes-cursor .branch-outcomes-processed ..branch-outcome-index .branch-outcome-index-ready Pi supervision-branch durable outcome store, its read cursor, main's processed marker, bounded latest per-task status-coverage caches, and their recovery marker; bin/fm-branch-outcome.sh owns the formats branch-session/ .branch-session .branch-mirror-cursor the branch's per-main-session conversations, the pointer to the current one, and the dialog-mirror cursor; extension-owned (docs/pi-supervision-branch.md) .branch-eligible-rows .branch-eligible-owner .main-eligible-rows per-actor wake-row claims and branch-owner evidence; docs/watcher-continuity.md owns the acknowledgement contract @@ -371,15 +372,16 @@ The worker reports the PR when CI first becomes green rather than waiting for me ### PR ready, landing, and teardown -For PR-based ship tasks, the ready signal depends on mode: `no-mistakes` reports `done: PR checks green` after CI is green, while `direct-PR` reports `done: PR ` after opening the PR. -Run `bin/fm-pr-check.sh ` with the URL copied from that ready signal - it records `pr=` and the forge's `pr_head=` when available in the task's meta and arms the watcher's merge poll. -Tell the captain the PR's full `https://...` URL copied from the worker's ready line or the task's `pr=` metadata, a concise outcome summary, and the no-mistakes risk level when applicable. +A terminal `done:` claim names the exact commit, PR, branch, or report it is claiming, and only `bin/fm-verify-done.sh` decides whether that claim is true; `bin/fm-done-claim-lib.sh` owns the grammar and the durable verdict. +A claim that has not been established is never done: `bin/fm-crew-state.sh` reports it as `done-unverified`, and teardown refuses it. +Run `bin/fm-pr-check.sh ` with the URL copied from the terminal claim's `pr=` field or the task's `pr=` metadata, never assembled from memory - it records `pr=` and the forge's `pr_head=` when available in the task's meta and arms the watcher's poll, which reports a merge and a close without merge alike. +Tell the captain the PR's full URL, always the complete `https://...` link rather than a bare `#number`, copied verbatim from the claim's `pr=` field or the task's `pr=` metadata, a concise outcome summary, and the no-mistakes risk level when applicable. A captain instruction to merge is explicit authority; `yolo` is the only standing routine merge authority. For any custom `state/.check.sh` you write yourself, keep it an ordinary single-link mode-`0700` file, print one line only when firstmate should wake, print nothing otherwise, finish before `FM_CHECK_TIMEOUT`, then bind its current bytes with `bin/fm-check-register.sh ` before the watcher may execute it. Retire a custom check only through `bin/fm-check-unregister.sh ` (or `bin/fm-teardown.sh` for a spawned task); never hand-compose an `rm` with `$STATE`/`$ID`. Tear down a ship task only after landing is confirmed. -A teardown refusal for uncommitted or unlanded work is a stop-and-investigate result, never an obstacle to bypass. +A teardown refusal for uncommitted or unlanded work, or for a claim that is unverified or contradicted, is a stop-and-investigate result, never an obstacle to bypass. Never force teardown without explicit discard authority. After successful teardown, record completion, retain only the configured recent Done history, and re-evaluate queued work whose blockers and time gates have cleared. diff --git a/bin/fm-brief.sh b/bin/fm-brief.sh index 3a80a701678..8eba041c6be 100755 --- a/bin/fm-brief.sh +++ b/bin/fm-brief.sh @@ -390,7 +390,11 @@ Write your findings to \`$DATA/$ID/report.md\`. The report must stand alone: what you did, what you found, the evidence (commands run, output, file:line references), and what you recommend. If your deliverable is a visual artifact the captain will review and iterate on, you may host the Lavish review loop yourself (poll, revise, re-serve, staying alive) instead of handing it back to firstmate. Before reporting done, read and follow \`$FM_ROOT/.agents/skills/captain-hold-lifecycle/SKILL.md\` and pass its shared completion gate for the report and any visual review. -When the report is complete, append \`done: {one-line conclusion}\` to the status file and stop. +When the report is complete, append the terminal claim in exactly this shape, then stop: + + $(fm_done_claim_template scout "$ID") + +\`report=\` is the path you just wrote (\`$DATA/$ID/report.md\`); firstmate verifies that file exists before anything treats this task as done, so a claim naming a report that is missing or empty is reported as contradicted, not as done. If your findings reveal work that should ship (e.g. you reproduced a bug and the fix is clear), say so in the report; firstmate may promote this task in place, and you would then receive mode-specific ship instructions as a follow-up message. EOF echo "scaffolded: $BRIEF (scout; replace {TASK} and {FIRSTMATE_SPEC})" diff --git a/bin/fm-classify-lib.sh b/bin/fm-classify-lib.sh index 506f398cce9..4826e3b30f4 100755 --- a/bin/fm-classify-lib.sh +++ b/bin/fm-classify-lib.sh @@ -126,6 +126,37 @@ status_is_terminal_verb() { esac } +# The complete set of status-line verbs this fleet recognises. Three of them are +# overridable constants, so the set is assembled from those owners rather than +# re-listed as literals. `done-unverified` is deliberately absent: it is a +# current-STATE token bin/fm-crew-state.sh reports, never a verb anything writes +# into a status file. +status_line_verb_is_known() { # + local verb + verb=$(status_line_verb "${1:-}") + case "$verb" in + working|needs-decision|blocked|done|failed|note) return 0 ;; + esac + [ "$verb" = "${FM_CLASSIFY_PAUSED_VERB:-$FM_CLASSIFY_PAUSED_VERB_DEFAULT}" ] && return 0 + [ "$verb" = "${FM_CLASSIFY_RESOLVE_VERB:-$FM_CLASSIFY_RESOLVE_VERB_DEFAULT}" ] && return 0 + [ "$verb" = "${FM_CLASSIFY_CAPTAIN_HELD_VERB:-$FM_CLASSIFY_CAPTAIN_HELD_VERB_DEFAULT}" ] && return 0 + return 1 +} + +# 0 when a non-blank status line carries no recognised verb at all - a worker +# writing prose ("Migration syntax: OK", "- ruff check: all passed") into the +# status file. Such a line wakes firstmate and then classifies as nothing, so it +# used to be absorbed silently and its words were lost. It is surfaced instead: +# losing a worker's words is worse than showing an unclassifiable line, and this +# never rejects the line or asks the worker to rewrite it. +status_line_is_unrecognized() { # + case "${1:-}" in + *[![:space:]]*) ;; + *) return 1 ;; + esac + ! status_line_verb_is_known "$1" +} + # 0 if the given (last) status line matches a captain-relevant verb. # Verb-aware by default: terminal verbs always match; nonterminal progress verbs # (working, resolved, captain-held) and paused never match from free-text prose; @@ -1418,12 +1449,14 @@ status_new_lines_since_cursor() { # [] return "$rc" } -# 0 when a status line is an informational `note:` or a reserved-key -# pending-reply resolution. Those lines never fold into OPEN DECISIONS, so the -# drain's unread-status surface is their only guaranteed presentation. +# 0 when a status line is an informational `note:`, a reserved-key pending-reply +# resolution, or a line carrying no recognised verb at all. None of these fold +# into OPEN DECISIONS, so the drain's unread-status surface is their only +# guaranteed presentation. status_line_is_unread_surface() { # local line=$1 verb key note resolve held prefix [ -n "$line" ] || return 1 + status_line_is_unrecognized "$line" && return 0 verb=$(status_line_verb "$line") [ "$verb" = note ] && return 0 resolve=${FM_CLASSIFY_RESOLVE_VERB:-$FM_CLASSIFY_RESOLVE_VERB_DEFAULT} diff --git a/bin/fm-crew-state.sh b/bin/fm-crew-state.sh index 267b0902a93..6a1b8ca2b0c 100755 --- a/bin/fm-crew-state.sh +++ b/bin/fm-crew-state.sh @@ -66,6 +66,8 @@ STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}" . "$SCRIPT_DIR/fm-busy-lib.sh" # shellcheck source=bin/fm-nm-run-lib.sh . "$SCRIPT_DIR/fm-nm-run-lib.sh" +# shellcheck source=bin/fm-done-claim-lib.sh +. "$SCRIPT_DIR/fm-done-claim-lib.sh" ID=${1:-} [ -n "$ID" ] || { echo "usage: fm-crew-state.sh " >&2; exit 2; } @@ -83,8 +85,34 @@ case "$FM_CREW_STATE_RUNS_LIMIT" in ''|*[!0-9]*) FM_CREW_STATE_RUNS_LIMIT=200 ;; SEP=' ยท ' # Emit the one canonical line and exit 0. Detail is optional. +# +# One guard sits in front of every done path: a task that has APPENDED a +# terminal `done:` claim reports `done` only while a matching verified verdict +# stands for that exact claim (bin/fm-done-claim-lib.sh owns the claim grammar, +# the verdict record, and the match). Otherwise it reports `done-unverified` +# with the reason, because a worker asserting completion is not evidence of it. +# Two deliberate boundaries: +# - the guard fires only when a claim EXISTS. A run-step `passed` on a task +# that never claimed anything is machine-established already and stays +# `done`; there is no assertion to distrust. +# - `done-unverified` is a current-STATE token from this reader only. It is +# never a status-log verb, so nothing writes it into a status file and the +# classifier's vocabulary is untouched. +# Pure local file reads, so this stays the side-effect-free bounded read every +# caller relies on. emit() { # [detail] - local line="state: $1${SEP}source: $2" + local state=$1 line + if [ "$state" = "done" ]; then + fm_done_claim_status "$STATE" "$ID" + case "$FM_DONE_CLAIM_STATE" in + none|verified) ;; + *) + state="done-unverified" + set -- "$state" "$2" "${3:+$3$SEP}claim $FM_DONE_CLAIM_STATE: $FM_DONE_CLAIM_REASON" + ;; + esac + fi + line="state: $1${SEP}source: $2" [ -n "${3:-}" ] && line="$line${SEP}$3" printf '%s\n' "$line" exit 0 @@ -283,8 +311,16 @@ nm_gate_findings_count() { case "$rest" in ''|*[!0-9]*) return 0 ;; esac printf '%s' "$rest" } +# 0 when the status log's own terminal claim says the PR is ready. Accepts the +# current claim grammar (a `pr=` field, bin/fm-done-claim-lib.sh) and the legacy +# free-prose form, so a home mid-migration reads both. The claim's TRUTH is not +# decided here - emit()'s guard still downgrades an unverified claim - this only +# answers "did the worker report a PR", which is what the ci-step override needs. log_reports_ci_ready() { [ "$LOG_VERB" = "done" ] || return 1 + if fm_done_claim_parse "$LOG_LINE" && [ -n "$FM_DONE_CLAIM_PR" ]; then + return 0 + fi case "$(status_line_note "$LOG_LINE")" in *PR*"checks green"*|*"checks green"*PR*) return 0 ;; *) return 1 ;; diff --git a/bin/fm-dod-lib.sh b/bin/fm-dod-lib.sh index d7d0f3ccc6e..f68db358048 100755 --- a/bin/fm-dod-lib.sh +++ b/bin/fm-dod-lib.sh @@ -16,9 +16,36 @@ # scaffolds those two `# Task` subsections; bin/fm-spawn.sh and bin/fm-promote.sh # refuse leftover `{TASK}` / `{FIRSTMATE_SPEC}` placeholders through the helpers # below. Other mentions of `--intent` point here rather than restating the rule. +# The terminal-claim grammar every block below hands the worker is owned by +# bin/fm-done-claim-lib.sh, which also parses it and owns the durable verdict +# record; this file renders that owner's template rather than restating it, so +# the shape a worker is told to write and the shape firstmate verifies cannot +# drift apart. # Every heredoc here stays outside a command substitution: `VAR=$(cat < + cat <<'EOF' +Append the terminal claim in exactly this shape, then stop: + +EOF + printf ' %s\n\n' "$(fm_done_claim_template "$1" "$2")" + cat <<'EOF' +`head=` is the full 40-character commit you are claiming - read it from the thing you are claiming, never from memory of what you pushed. +Firstmate runs `bin/fm-verify-done.sh` on that claim and establishes it against the forge, git, and the validation run's own record before anything treats this task as done. +A claim it cannot establish stays unverified, and a claim it establishes as false is reported as contradicted; neither is a pass, and cleanup refuses both. +EOF +} + # Return 0 when a Task subsection still consists only of its scaffold # placeholder. A missing file and legacy briefs carry no such placeholders. fm_brief_task_placeholders_present() { # @@ -169,7 +196,9 @@ fm_dod_block() { # Delivery contract: mode=direct-PR This task ships **direct-PR**: you raise the PR yourself, without the no-mistakes pipeline. The task is complete only when committed on your branch. -When it is implemented and committed, push your branch and open a PR with \`gh-axi\`, then append \`done: PR {url}\` to the status file and stop. +When it is implemented and committed, push your branch and open a PR with \`gh-axi\`. +$(fm_dod_claim_rule direct-PR "$id") +Read the PR's own head with \`gh-axi pr view {url}\` so the commit you claim is the commit the PR carries. Do NOT run /no-mistakes. The configured merge authority decides whether to merge the PR; firstmate relays the outcome. EOF ;; @@ -180,7 +209,8 @@ Delivery contract: mode=local-only This task ships **local-only**: no remote, no PR, no pipeline. The task is complete only when committed on your branch \`fm/$id\`. Do NOT push, do NOT open a PR, do NOT merge. Keep your branch a clean fast-forward onto the current default branch - if \`main\` has advanced, rebase onto it so the eventual merge stays a fast-forward. -When it is implemented and committed, append \`done: ready in branch fm/$id\` to the status file and stop. +When it is implemented and committed, read your branch tip with \`git rev-parse HEAD\`. +$(fm_dod_claim_rule local-only "$id") The configured merge authority approves the ready branch, then firstmate merges it into local \`main\` through the guarded fast-forward path. EOF ;; @@ -207,7 +237,10 @@ Two firstmate-specific rules layer on top of that guidance: - NEVER pass \`--yes\` (or \`-y\`) to \`no-mistakes axi run\` or \`no-mistakes axi respond\`. It is banned fleet-wide. It auto-resolves every gate including ask-user findings with no escalation, and answering your own ask-user finding is a hard rule violation. -After /no-mistakes reports CI green (the CI-ready return point - do not wait for it to keep monitoring in the background until merge), append \`done: PR {url} checks green\` and stop. You are finished. +After /no-mistakes reports CI green (the CI-ready return point - do not wait for it to keep monitoring in the background until merge), you are finished. +$(fm_dod_claim_rule no-mistakes "$id") +Read \`head=\` from the PR itself with \`gh-axi pr view {url}\`, not from your last local commit: the pipeline pushes its own fix commits, so the commit you pushed and the commit the PR ships are routinely different. +Firstmate also compares that commit against the commit the pipeline actually validated. They diverge whenever anything lands after validation, and a claim that hides that divergence is contradicted, not done - so if they differ, say so and let firstmate decide rather than picking one. EOF ;; *) diff --git a/bin/fm-done-claim-lib.sh b/bin/fm-done-claim-lib.sh new file mode 100644 index 00000000000..07e1a5030e5 --- /dev/null +++ b/bin/fm-done-claim-lib.sh @@ -0,0 +1,273 @@ +#!/usr/bin/env bash +# Single owner of the terminal-claim grammar and its durable verdict record. +# +# Why this exists: a worker's `done:` line used to be free prose ("done: PR +# checks green"). It carried no commit identity, so nothing could check +# that the commit a pipeline validated was the commit the PR actually shipped, +# and nothing could notice when a PR that a task called done was later closed +# without merging. A claim is now required to name the exact object it is +# claiming, and a separate verifier (bin/fm-verify-done.sh) establishes that +# claim against the forge, git, and the validation run before anything in the +# fleet may treat the task as done. +# +# Grammar (this file is the one owner; bin/fm-dod-lib.sh and bin/fm-brief.sh +# render the worker-facing instruction from fm_done_claim_template): +# +# no-mistakes / direct-PR done: pr= head= - +# local-only done: branch=fm/ head= - +# scout done: report= - +# +# The parser is field-based and separator-agnostic: it reads whitespace- +# delimited `key=value` tokens out of the claim's note and ignores everything +# else, so prose around them, a plain or an em dash, and legacy free-prose +# claims all parse without special cases. A legacy claim simply yields no +# identity fields, which is exactly the "cannot establish" input the verdict +# rules below turn into `unverified`, never into a pass. +# +# Verdict record: state/.done-verdict, five lines, +# fm-done-verdict-v1 +# +# +# +# +# The claim hash binds a verdict to one exact claim, so appending a new `done:` +# line invalidates the old verdict instead of inheriting it. +# +# Read-only apart from fm_done_verdict_write. Sourced by bin/fm-verify-done.sh, +# bin/fm-crew-state.sh, bin/fm-teardown.sh, bin/fm-dod-lib.sh, bin/fm-brief.sh +# and tests; no side effects on source. + +# Public results every sourcing consumer reads. +FM_DONE_CLAIM_PR= +FM_DONE_CLAIM_HEAD= +FM_DONE_CLAIM_BRANCH= +FM_DONE_CLAIM_REPORT= +FM_DONE_CLAIM_LINE= +FM_DONE_CLAIM_STATE= +FM_DONE_CLAIM_REASON= +FM_DONE_VERDICT= +FM_DONE_VERDICT_CLAIM_HASH= +FM_DONE_VERDICT_EPOCH= +FM_DONE_VERDICT_REASON= + +FM_DONE_VERDICT_VERSION=fm-done-verdict-v1 + +# status_line_verb / status_line_note are the one owner of leading-verb and +# note extraction, so this file borrows them rather than re-deriving the shape. +# Guarded so a consumer that already sourced the classifier is not re-sourced, +# while one that only wants fm_done_claim_template still gets a working lib. +if ! declare -F status_line_verb >/dev/null 2>&1; then + # shellcheck source=bin/fm-classify-lib.sh + # shellcheck disable=SC1091 + . "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/fm-classify-lib.sh" +fi + +# The worker-facing claim shape for a delivery mode. Rendered verbatim into a +# brief, so the grammar the worker is told to use and the grammar the parser +# accepts cannot drift. +fm_done_claim_template() { # + case "$1" in + no-mistakes|direct-PR) printf 'done: pr={url} head={full-sha} - {one line}' ;; + local-only) printf 'done: branch=fm/%s head={full-sha} - {one line}' "$2" ;; + scout) printf 'done: report={path} - {one line}' ;; + *) return 1 ;; + esac +} + +# 0 when $1 is a full 40-character hex object name. The claim must carry the +# exact commit, not an abbreviation: an abbreviation cannot be compared against +# a forge head without resolving it somewhere, and "somewhere" is the ambiguity +# this whole contract exists to remove. +fm_done_claim_head_valid() { # + case "${1:-}" in + *[!0-9a-f]*|'') return 1 ;; + esac + [ "${#1}" -eq 40 ] +} + +# Parse a status line as a terminal claim. Returns 1 for any line that is not a +# `done:` line; sets the four identity fields (empty when absent) otherwise. +fm_done_claim_parse() { # + local line=${1:-} note token noglob=off + FM_DONE_CLAIM_PR= + FM_DONE_CLAIM_HEAD= + FM_DONE_CLAIM_BRANCH= + FM_DONE_CLAIM_REPORT= + [ -n "$line" ] || return 1 + [ "$(status_line_verb "$line")" = "done" ] || return 1 + note=$(status_line_note "$line") + # The note is arbitrary worker prose, so splitting it must not also glob it: + # an unquoted `*.md` in a summary would otherwise expand against whatever + # directory the caller happens to be in. The caller's own setting is restored. + case $- in *f*) noglob=on ;; esac + set -f + for token in $note; do + case "$token" in + pr=*) [ -n "$FM_DONE_CLAIM_PR" ] || FM_DONE_CLAIM_PR=${token#pr=} ;; + head=*) [ -n "$FM_DONE_CLAIM_HEAD" ] || FM_DONE_CLAIM_HEAD=${token#head=} ;; + branch=*) [ -n "$FM_DONE_CLAIM_BRANCH" ] || FM_DONE_CLAIM_BRANCH=${token#branch=} ;; + report=*) [ -n "$FM_DONE_CLAIM_REPORT" ] || FM_DONE_CLAIM_REPORT=${token#report=} ;; + esac + done + [ "$noglob" = on ] || set +f + return 0 +} + +# 0 when the parsed claim names something a machine can check: a full commit +# for ship work, or a report path for a scout. A legacy free-prose claim has +# neither and is the "no commit identity" case throughout. +fm_done_claim_has_identity() { + fm_done_claim_head_valid "$FM_DONE_CLAIM_HEAD" && return 0 + [ -n "$FM_DONE_CLAIM_REPORT" ] +} + +# The last `done:` line in a status log, or empty. A status log is an +# append-only event log, so the newest claim is the one under test; earlier +# claims were superseded by whatever the worker did next. +# +# This runs per task on every current-state read and every session-start digest, +# so the leading-verb test is only paid for by lines that could possibly carry +# it. `status_line_verb` costs a fork per call, and a line whose verb is `done` +# always begins with `done` after its leading whitespace - a correlation token +# or a `[key=...]` only ever follows the verb word - so the fork-free prefix +# test below is exact, not an approximation, and skips it for every other line. +fm_done_claim_last() { # + local f=${1:-} line trimmed last= + [ -f "$f" ] && [ -r "$f" ] && [ ! -L "$f" ] || return 0 + while IFS= read -r line || [ -n "$line" ]; do + trimmed=${line#"${line%%[![:space:]]*}"} + case "$trimmed" in done*) ;; *) continue ;; esac + [ "$(status_line_verb "$line")" = "done" ] || continue + last=$line + done < "$f" + printf '%s' "$last" +} + +fm_done_claim_hash() { # + local out + if command -v shasum >/dev/null 2>&1; then + out=$(printf '%s' "${1:-}" | shasum -a 256 2>/dev/null) || return 1 + elif command -v sha256sum >/dev/null 2>&1; then + out=$(printf '%s' "${1:-}" | sha256sum 2>/dev/null) || return 1 + else + return 1 + fi + out=${out%% *} + case "$out" in + *[!0-9a-f]*|'') return 1 ;; + esac + [ "${#out}" -eq 64 ] || return 1 + printf '%s' "$out" +} + +fm_done_verdict_path() { # + printf '%s/%s.done-verdict' "$1" "$2" +} + +# Collapse a reason to one printable line: the record is line-structured, so a +# newline or tab in a reason would silently reshape it. +fm_done_reason_clean() { # + printf '%s' "${1:-}" | LC_ALL=C tr '\n\t\r' ' ' | cut -c1-400 +} + +fm_done_verdict_write() { # + local state=$1 id=$2 verdict=$3 hash=$4 reason=$5 path tmp + case "$verdict" in verified|unverified|contradicted) ;; *) return 2 ;; esac + case "$hash" in *[!0-9a-f]*|'') return 2 ;; esac + [ "${#hash}" -eq 64 ] || return 2 + [ -d "$state" ] && [ ! -L "$state" ] || return 1 + path=$(fm_done_verdict_path "$state" "$id") + [ ! -L "$path" ] || return 1 + umask 077 + tmp=$(mktemp "$state/.fm-done-verdict.XXXXXX") || return 1 + { + printf '%s\n' "$FM_DONE_VERDICT_VERSION" + printf '%s\n' "$verdict" + printf '%s\n' "$hash" + printf '%s\n' "$(date +%s)" + printf '%s\n' "$(fm_done_reason_clean "$reason")" + } > "$tmp" || { rm -f -- "$tmp"; return 1; } + chmod 0600 "$tmp" 2>/dev/null || true + mv -f -- "$tmp" "$path" || { rm -f -- "$tmp"; return 1; } +} + +# Read a verdict record. Returns 1 when it is absent, unreadable, or malformed; +# a malformed record is never partially trusted. +fm_done_verdict_read() { # + local path version verdict hash epoch reason _extra + FM_DONE_VERDICT= + FM_DONE_VERDICT_CLAIM_HASH= + FM_DONE_VERDICT_EPOCH= + FM_DONE_VERDICT_REASON= + path=$(fm_done_verdict_path "$1" "$2") + [ -f "$path" ] && [ -r "$path" ] && [ ! -L "$path" ] || return 1 + exec 7< "$path" || return 1 + IFS= read -r version <&7 || { exec 7<&-; return 1; } + IFS= read -r verdict <&7 || { exec 7<&-; return 1; } + IFS= read -r hash <&7 || { exec 7<&-; return 1; } + IFS= read -r epoch <&7 || { exec 7<&-; return 1; } + IFS= read -r reason <&7 || reason= + if IFS= read -r _extra <&7; then + exec 7<&- + return 1 + fi + exec 7<&- + [ "$version" = "$FM_DONE_VERDICT_VERSION" ] || return 1 + case "$verdict" in verified|unverified|contradicted) ;; *) return 1 ;; esac + case "$hash" in *[!0-9a-f]*|'') return 1 ;; esac + [ "${#hash}" -eq 64 ] || return 1 + case "$epoch" in ''|*[!0-9]*) return 1 ;; esac + FM_DONE_VERDICT=$verdict + FM_DONE_VERDICT_CLAIM_HASH=$hash + # shellcheck disable=SC2034 # Public result consumed by sourcing callers. + FM_DONE_VERDICT_EPOCH=$epoch + FM_DONE_VERDICT_REASON=$reason +} + +# THE predicate every consumer asks: for this task's current terminal claim, +# what has actually been established? Sets FM_DONE_CLAIM_LINE (the claim under +# test, empty when the task has never claimed done), FM_DONE_CLAIM_STATE, and +# FM_DONE_CLAIM_REASON, plus the parsed claim fields. +# +# none the task has made no terminal claim; nothing to verify. +# verified a matching verdict record establishes the claim. +# unverified the claim cannot be established: no commit identity (every +# legacy claim), no verdict yet, a verdict for a superseded +# claim, or a verifier that could not reach its sources. +# contradicted the claim was established and is false. +# +# Pure read; returns 0 whatever the outcome, so a caller branches on the state +# rather than on an exit code. +fm_done_claim_status() { # + local state=$1 id=$2 hash + FM_DONE_CLAIM_LINE= + FM_DONE_CLAIM_STATE=none + FM_DONE_CLAIM_REASON= + FM_DONE_CLAIM_LINE=$(fm_done_claim_last "$state/$id.status") + [ -n "$FM_DONE_CLAIM_LINE" ] || return 0 + fm_done_claim_parse "$FM_DONE_CLAIM_LINE" || return 0 + if ! fm_done_claim_has_identity; then + FM_DONE_CLAIM_STATE=unverified + FM_DONE_CLAIM_REASON='legacy claim, no commit identity' + return 0 + fi + if ! hash=$(fm_done_claim_hash "$FM_DONE_CLAIM_LINE"); then + FM_DONE_CLAIM_STATE=unverified + FM_DONE_CLAIM_REASON='claim identity could not be computed (no sha256 tool)' + return 0 + fi + if ! fm_done_verdict_read "$state" "$id"; then + FM_DONE_CLAIM_STATE=unverified + FM_DONE_CLAIM_REASON="not verified yet; run bin/fm-verify-done.sh $id" + return 0 + fi + if [ "$FM_DONE_VERDICT_CLAIM_HASH" != "$hash" ]; then + FM_DONE_CLAIM_STATE=unverified + FM_DONE_CLAIM_REASON="recorded verdict judged an earlier claim; re-run bin/fm-verify-done.sh $id" + return 0 + fi + # shellcheck disable=SC2034 # Public results consumed by sourcing callers. + FM_DONE_CLAIM_STATE=$FM_DONE_VERDICT + # shellcheck disable=SC2034 + FM_DONE_CLAIM_REASON=$FM_DONE_VERDICT_REASON +} diff --git a/bin/fm-fleet-snapshot.sh b/bin/fm-fleet-snapshot.sh index 8ac60136d67..7d1ee6d377c 100755 --- a/bin/fm-fleet-snapshot.sh +++ b/bin/fm-fleet-snapshot.sh @@ -35,7 +35,10 @@ # prose-deferred rows out of default views. # tasks[]: one row per state/.meta, sorted by id. # Local current_state is parsed from bin/fm-crew-state.sh and preserves -# state, source, detail, and raw line separately. Remote secondmate rows use +# state, source, detail, and raw line separately. +# `done-unverified` is that reader's token for a task whose terminal claim +# has not been established (bin/fm-verify-done.sh); it counts as terminal +# wherever a terminal state matters, and never as done. Remote secondmate rows use # an explicit unknown value because their endpoint liveness belongs to # supervision rather than this snapshot path. # paths.status_log.last_event is historical wake-event data only, never @@ -524,6 +527,10 @@ task_json_lines() { # - a TERMINAL done/failed state on a single-owner task (scout or ship), whose # deliverable is its report or PR, so a COMPLETED scout surfaces only as a # report POINTER, never as a reopened pending decision. + # `done-unverified` clears alongside `done`: the crew TERMINATED either way, + # and whether its claim is true is a separate question this fold does not + # answer. Treating it as still-running would resurface every stale decision + # a finished task left behind. # Secondmates are excluded from lifecycle clearing: they are persistent and # multiplex many concerns onto one stream, so activity on one concern must # never clear another concern's keyed decision. A parked/blocked state, or a @@ -533,7 +540,8 @@ task_json_lines() { if [ "$kind" != secondmate ] && \ { { { [ "$current_source" = run-step ] || [ "$current_source" = pane ]; } \ && [ "$current_state" != parked ] && [ "$current_state" != blocked ]; } \ - || { [ "$current_state" = "done" ] || [ "$current_state" = "failed" ]; }; }; then + || { [ "$current_state" = "done" ] || [ "$current_state" = "done-unverified" ] \ + || [ "$current_state" = "failed" ]; }; }; then open_decisions_tsv="" fi open_decisions_json=$(printf '%s' "$open_decisions_tsv" | jq -R -s ' @@ -728,7 +736,9 @@ secondmate_home_summary_json() { # | ([ $owned_in_flight[] as $work | $tasks[] | select(.kind != "secondmate") - | select(.id == $work.id and (.current_state.state == "done" or .current_state.state == "failed")) + | select(.id == $work.id and (.current_state.state == "done" + or .current_state.state == "done-unverified" + or .current_state.state == "failed")) | {id,state:.current_state.state} ]) as $terminal_in_flight | ([if $backlog.present != true then {kind:"missing_backlog",ids:[],reason:"missing structured backlog"} diff --git a/bin/fm-inactive-reconcile.sh b/bin/fm-inactive-reconcile.sh index 52064545836..60847bf74eb 100755 --- a/bin/fm-inactive-reconcile.sh +++ b/bin/fm-inactive-reconcile.sh @@ -330,7 +330,15 @@ home_secondmate_id() { report_to_parent() { # local task=$1 state=$2 outcome_key=$3 fingerprint=$4 pr=$5 line - line="$state [key=$outcome_key]: inactive terminal child=$task fingerprint=$fingerprint" + # The parent channel is a status stream, so this line must lead with a verb the + # parent's classifier knows. A terminal state that is not one - an unverified + # claim - is a blocker for the parent rather than a completion, and names its + # real state in the note instead of being reported as done. + if status_line_verb_is_known "$state:"; then + line="$state [key=$outcome_key]: inactive terminal child=$task fingerprint=$fingerprint" + else + line="blocked [key=$outcome_key]: inactive terminal child=$task state=$state fingerprint=$fingerprint" + fi [ -z "$pr" ] || line="$line pr=$pr" fm_parent_channel_report "$FM_HOME" "$STATE" "$line" } @@ -491,8 +499,13 @@ reconcile_direct_child_locked() { # +# fm_merge_outcome_report [] # -# says who observed the merge, because that decides whether the +# says who observed the outcome, because that decides whether the # existing poll path also needs a local wake: # self - this home performed the merge. -# poll - this home's merge poll detected the merge, so the canonical outcome +# poll - this home's poll detected the outcome, so the canonical outcome # also wakes this home after any upward hop needed by a secondmate. # +# defaults to merged; closed-unmerged publishes the contradiction. +# # Returns 0 when the outcome is recorded (or already was), 2 on an invalid # request, 3 when this home's own role or parent binding cannot be read well # enough to say where the outcome belongs, and 1 on any other failure to # record. A caller that has already merged must report a non-zero return rather # than treat it as success: the merge landed and the record did not. -fm_merge_outcome_report() { # - local home=$1 state=$2 id=$3 url=$4 origin=$5 - local self_rc=0 destination='' line lock status=0 +fm_merge_outcome_report() { # [] + local home=$1 state=$2 id=$3 url=$4 origin=$5 outcome=${6:-merged} + local self_rc=0 destination='' line lock status=0 wake_note local provider host path number # shellcheck disable=SC2034 # Sourced wake helpers consume these scoped globals. local STATE FM_WAKE_QUEUE FM_WAKE_QUEUE_LOCK FM_MERGE_OUTCOME_ALREADY_RECORDED=false case "$origin" in self|poll) ;; *) return 2 ;; esac + fm_pr_poll_outcome_valid "$outcome" || return 2 fm_pr_task_id_valid "$id" || return 2 fm_pr_url_parse "$url" || return 2 provider=$FM_PR_PROVIDER @@ -64,8 +75,20 @@ fm_merge_outcome_report() { # number=$FM_PR_NUMBER [ -d "$state" ] && [ ! -L "$state" ] || return 1 + # A merge is a completion; a close without merge is a blocker, because the + # task still holds a done record the forge no longer supports. The verbs + # differ for exactly that reason and are both known status verbs. + if [ "$outcome" = merged ]; then + wake_note="check: merge landed: $id $FM_PR_URL" + else + wake_note="check: PR closed without merging, contradicting the done record: $id $FM_PR_URL" + fi if destination=$(fm_parent_channel_destination "$home" "$state"); then - line="done [key=merged-$id]: merged $id $FM_PR_URL" + if [ "$outcome" = merged ]; then + line="done [key=merged-$id]: merged $id $FM_PR_URL" + else + line="blocked [key=closed-unmerged-$id]: $id claims done but $FM_PR_URL was closed without merging" + fi else self_rc=$? [ "$self_rc" -eq 1 ] || return 3 @@ -78,7 +101,7 @@ fm_merge_outcome_report() { # lock="$state/$id.pr-poll-merge-notified.lock" fm_lock_acquire_wait "$lock" || return 1 if fm_pr_poll_merge_already_notified "$state" "$id" \ - "$provider" "$host" "$path" "$number"; then + "$provider" "$host" "$path" "$number" "$outcome"; then # shellcheck disable=SC2034 # Public result consumed by sourcing callers. FM_MERGE_OUTCOME_ALREADY_RECORDED=true fm_lock_release "$lock" @@ -89,12 +112,11 @@ fm_merge_outcome_report() { # fm_parent_channel_append_once "$destination" "$line" || status=1 fi if [ "$status" -eq 0 ] && { [ "$origin" = poll ] || [ -z "$destination" ]; }; then - fm_wake_append check "merged-$id-$FM_PR_URL" \ - "check: merge landed: $id $FM_PR_URL" || status=1 + fm_wake_append check "$outcome-$id-$FM_PR_URL" "$wake_note" || status=1 fi if [ "$status" -eq 0 ]; then fm_pr_poll_merge_mark_notified "$state" "$id" \ - "$provider" "$host" "$path" "$number" || status=1 + "$provider" "$host" "$path" "$number" "$outcome" || status=1 fi fm_lock_release "$lock" return "$status" diff --git a/bin/fm-pr-lib.sh b/bin/fm-pr-lib.sh index d9580dc9b4a..44ef8de5685 100755 --- a/bin/fm-pr-lib.sh +++ b/bin/fm-pr-lib.sh @@ -949,9 +949,16 @@ fm_pr_poll_retirement_recover_all() { # matching identity is a no-op; a different PR for the same task reaches its # role-routed supervision destination and replaces the marker when its first # outcome is published. -fm_pr_poll_merge_marker_matches() { # +# The marker records WHICH terminal outcome was already delivered for a PR +# identity, not merely that one was. A v1 marker predates the second outcome and +# means `merged`; v2 states the outcome on a sixth line. Recording it is what +# lets a PR closed without merging be reported once, and still let a later +# reopen-and-merge of that same PR report its own outcome instead of being +# suppressed as a duplicate. +fm_pr_poll_merge_marker_matches() { # [] local marker=$1 device=$2 expected_provider=$3 expected_host=$4 expected_path=$5 expected_number=$6 - local version provider host path number + local expected_outcome=${7:-merged} + local version provider host path number outcome fm_pr_private_file_valid "$marker" 600 "$device" || return 1 exec 8< "$marker" || return 1 IFS= read -r version <&8 || { exec 8<&-; return 1; } @@ -959,46 +966,67 @@ fm_pr_poll_merge_marker_matches() { # - local state=$1 id=$2 provider=$3 host=$4 path=$5 number=$6 marker state_device +# The terminal outcomes bin/fm-pr-poll.sh may report. `merged` retires the poll; +# `closed-unmerged` contradicts a done record and deliberately leaves the poll +# armed, because a closed PR can still be reopened and merged. +fm_pr_poll_outcome_valid() { # + case "${1:-}" in merged|closed-unmerged) return 0 ;; esac + return 1 +} + +fm_pr_poll_merge_already_notified() { # [] + local state=$1 id=$2 provider=$3 host=$4 path=$5 number=$6 outcome=${7:-merged} + local marker state_device fm_pr_task_id_valid "$id" || return 1 + fm_pr_poll_outcome_valid "$outcome" || return 1 [ -d "$state" ] && [ ! -L "$state" ] || return 1 state_device=$(fm_pr_file_device "$state") || return 1 marker="$state/$id.pr-poll-merge-notified" fm_pr_poll_merge_marker_matches "$marker" "$state_device" \ - "$provider" "$host" "$path" "$number" + "$provider" "$host" "$path" "$number" "$outcome" } -fm_pr_poll_merge_mark_notified() { # - local state=$1 id=$2 provider=$3 host=$4 path=$5 number=$6 marker tmp state_device +fm_pr_poll_merge_mark_notified() { # [] + local state=$1 id=$2 provider=$3 host=$4 path=$5 number=$6 outcome=${7:-merged} + local marker tmp state_device fm_pr_task_id_valid "$id" || return 1 + fm_pr_poll_outcome_valid "$outcome" || return 1 [ -d "$state" ] && [ ! -L "$state" ] || return 1 state_device=$(fm_pr_file_device "$state") || return 1 marker="$state/$id.pr-poll-merge-notified" fm_pr_regular_destination_on_device_or_absent "$marker" "$state_device" || return 1 umask 077 tmp=$(mktemp "$state/.fm-pr-poll-merge-notified.XXXXXX") || return 1 - if ! printf '%s\n%s\n%s\n%s\n%s\n' \ - fm-pr-poll-merge-notified-v1 "$provider" "$host" "$path" "$number" > "$tmp" \ + if ! printf '%s\n%s\n%s\n%s\n%s\n%s\n' \ + fm-pr-poll-merge-notified-v2 "$provider" "$host" "$path" "$number" "$outcome" > "$tmp" \ || ! chmod 0600 "$tmp" \ || ! fm_pr_poll_merge_marker_matches "$tmp" "$state_device" \ - "$provider" "$host" "$path" "$number" \ + "$provider" "$host" "$path" "$number" "$outcome" \ || ! fm_pr_regular_destination_on_device_or_absent "$marker" "$state_device" \ || ! mv -f -- "$tmp" "$marker" \ || ! fm_pr_poll_merge_marker_matches "$marker" "$state_device" \ - "$provider" "$host" "$path" "$number"; then + "$provider" "$host" "$path" "$number" "$outcome"; then rm -f -- "$tmp" return 1 fi diff --git a/bin/fm-pr-poll.sh b/bin/fm-pr-poll.sh index ed705ce7073..221fa5b89f4 100755 --- a/bin/fm-pr-poll.sh +++ b/bin/fm-pr-poll.sh @@ -1,9 +1,14 @@ #!/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 -# interpolated into this source: these bytes are identical for every task. +# It emits exactly one terminal line - `merged` for a merged PR or MR, or +# `closed-unmerged` for one closed WITHOUT merging - and stays silent otherwise, +# including on every error, so a failed lookup can never be read as either +# outcome. `closed-unmerged` exists because a task's `done:` record can stop +# being true after it is written: a PR that is closed rather than merged leaves +# a done record asserting an outcome the forge no longer holds, and watching +# only for `merged` let exactly that rot unnoticed. 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. set -u @@ -64,6 +69,7 @@ case "$provider" in [ "$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 + [ "$state" = CLOSED ] && printf '%s\n' closed-unmerged ;; gitlab) [ "${#host}" -ge 1 ] && [ "${#host}" -le 253 ] || exit 0 @@ -99,11 +105,13 @@ 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 "closed" wakes, so a changed + # format or an unreadable merge request stays silent instead of reporting + # either terminal outcome. 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 + [ "$state" = closed ] && printf '%s\n' closed-unmerged ;; *) exit 0 ;; esac diff --git a/bin/fm-session-start.sh b/bin/fm-session-start.sh index 8e38c464ccf..a6f75d30f85 100755 --- a/bin/fm-session-start.sh +++ b/bin/fm-session-start.sh @@ -44,9 +44,11 @@ # 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, each task's terminal-claim verdict +# from its durable record (bin/fm-done-claim-lib.sh; a +# local read, never a re-verification), 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, @@ -341,6 +343,8 @@ PRIMARY_HARNESS=$("$SCRIPT_DIR/fm-harness.sh" 2>/dev/null || printf unknown) . "$SCRIPT_DIR/fm-wake-lib.sh" # shellcheck source=bin/fm-line-cap-lib.sh . "$SCRIPT_DIR/fm-line-cap-lib.sh" +# shellcheck source=bin/fm-done-claim-lib.sh +. "$SCRIPT_DIR/fm-done-claim-lib.sh" # One tasks-axi compatibility verdict per session start. The probe costs three # tasks-axi subprocesses and this digest needs the same answer twice - here for @@ -831,6 +835,15 @@ for meta in "$STATE"/*.meta; do printf 'endpoint: unknown (no window recorded)\n' fi + # The task's own terminal claim, and whether anything has established it. + # A pure local read of the durable verdict record: this digest makes no + # network call, so it reports what bin/fm-verify-done.sh last established + # rather than re-establishing anything here. Silent for a task that has made + # no claim, which is every task still working. + fm_done_claim_status "$STATE" "$id" + [ "$FM_DONE_CLAIM_STATE" = none ] \ + || printf 'terminal claim: %s - %s\n' "$FM_DONE_CLAIM_STATE" "$FM_DONE_CLAIM_REASON" + status="$STATE/$id.status" if [ -f "$status" ]; then print_status_tail "$status" diff --git a/bin/fm-teardown.sh b/bin/fm-teardown.sh index e200bc80b0f..4988303e76a 100755 --- a/bin/fm-teardown.sh +++ b/bin/fm-teardown.sh @@ -50,6 +50,11 @@ # local-only projects additionally accept work merged into the local default # branch (firstmate performs that merge after configured approval) as a fallback # for the common case where there is no remote at all. +# REFUSES, alongside those gates, when the task's own terminal `done:` claim has +# not been ESTABLISHED. bin/fm-verify-done.sh reads the forge, git, and the +# validation run and answers verified / unverified / contradicted; cleanup is the +# point of no return for a claim, so it accepts only `verified`. A task that +# never claimed done has nothing to verify and is unaffected. # Scout tasks (kind=scout in meta) carve out of that check: their worktree is # declared scratch and the report at data//report.md is the work # product. Teardown proceeds only once the report exists and the shared @@ -185,6 +190,8 @@ SUB_HOME_PARENT_MARKER=".fm-secondmate-parent" . "$SCRIPT_DIR/fm-lock-lib.sh" # shellcheck source=bin/fm-classify-lib.sh . "$SCRIPT_DIR/fm-classify-lib.sh" +# shellcheck source=bin/fm-done-claim-lib.sh +. "$SCRIPT_DIR/fm-done-claim-lib.sh" # shellcheck source=bin/fm-gate-refuse-lib.sh . "$SCRIPT_DIR/fm-gate-refuse-lib.sh" # shellcheck source=bin/fm-pr-lib.sh @@ -2631,6 +2638,46 @@ if [ "$KIND" = secondmate ] && [ "$FORCE" = "--force" ]; then cleanup_firstmate_home_children "$HOME_PATH" || exit $? fi +# A terminal claim is the worker's assertion that the work landed; cleanup is +# the moment that assertion becomes irreversible, because it removes the local +# copy and the records that make the claim checkable at all. So a claim must be +# ESTABLISHED before it may be cleaned up: bin/fm-verify-done.sh reads the forge, +# git, and the validation run, and this gate refuses anything it could not +# establish (`unverified`) as well as anything it established as false +# (`contradicted`). The cheap local read comes first so a task that never claimed +# done has nothing to verify, passes, and never pays for a subprocess; the +# landed-work gates above are unchanged and still apply. +TEARDOWN_CLAIM= +if [ "$KIND" != secondmate ] && [ "$FORCE" != "--force" ]; then + TEARDOWN_CLAIM=$(fm_done_claim_last "$STATE/$ID.status") +fi +if [ -n "$TEARDOWN_CLAIM" ]; then + TEARDOWN_CLAIM_RC=0 + TEARDOWN_CLAIM_OUT=$(FM_HOME="$FM_HOME" FM_STATE_OVERRIDE="$STATE" FM_DATA_OVERRIDE="$DATA" \ + "$SCRIPT_DIR/fm-verify-done.sh" "$ID" 2>&1) || TEARDOWN_CLAIM_RC=$? + case "$TEARDOWN_CLAIM_RC" in + 0|2) ;; + 3) + echo "REFUSED: task $ID claims it is done, but that claim could not be established." >&2 + printf '%s\n' "$TEARDOWN_CLAIM_OUT" >&2 + echo "Re-run bin/fm-verify-done.sh $ID once the forge and the validation run can be read." >&2 + echo "A claim written before this contract carries no commit identity and can never be established: append a conforming claim naming the commit that shipped, then verify it." >&2 + exit 1 + ;; + 4) + echo "REFUSED: task $ID claims it is done and the claim is contradicted by the forge or the validation run." >&2 + printf '%s\n' "$TEARDOWN_CLAIM_OUT" >&2 + echo "This is a stop-and-investigate result: reconcile the claim with what actually shipped before cleanup." >&2 + exit 1 + ;; + *) + echo "REFUSED: the terminal claim for task $ID could not be checked (rc=$TEARDOWN_CLAIM_RC)." >&2 + printf '%s\n' "$TEARDOWN_CLAIM_OUT" >&2 + exit 1 + ;; + esac +fi + if [ "$KIND" = scout ] && [ "$FORCE" != "--force" ]; then REPORT="$DATA/$ID/report.md" if [ ! -f "$REPORT" ]; then diff --git a/bin/fm-verify-done.sh b/bin/fm-verify-done.sh new file mode 100755 index 00000000000..356d5a49f33 --- /dev/null +++ b/bin/fm-verify-done.sh @@ -0,0 +1,318 @@ +#!/usr/bin/env bash +# fm-verify-done.sh - decide whether a task's terminal `done:` claim is true. +# +# A worker asserts; this script establishes. It never reads the claim's prose +# for evidence: the claim supplies only the identity it is claiming (the +# grammar and the durable verdict record are owned by bin/fm-done-claim-lib.sh), +# and every fact used to judge it comes from the forge, from git, or from the +# validation run's own record. +# +# Usage: fm-verify-done.sh [--quiet] +# +# Checks, by delivery mode: +# no-mistakes, direct-PR +# - the claim names a parseable PR/MR URL +# - the forge reports that PR, and it is not closed-without-merging +# - the PR's current head commit equals the claimed head= +# - (no-mistakes only) the commit the validation run recorded as validated +# equals the claimed head=, so a fix pushed after validation, or a +# force-push over a validated head, cannot pass as validated work +# - the checks state is RECORDED as fact, never judged: a claim is not +# contradicted for having red or absent checks, because merge authority, +# not this script, owns that decision +# local-only +# - the claimed head= resolves in the task's local copy and is the tip of +# the claimed branch=, or - once that branch has been retired after the +# work merged - is contained in the local default branch +# scout +# - the claimed report= exists as a non-empty regular file. An absolute path +# is used as given (what bin/fm-brief.sh renders into a scout brief); a +# relative one is resolved against the home. +# +# Verdicts, written to state/.done-verdict and printed on stdout: +# verified exit 0 +# unverified exit 3 - a required fact could NOT be established (forge +# unreachable, no validation run recorded, legacy claim with no +# commit identity). Never a pass: not knowing is not proof. +# contradicted exit 4 - a required fact WAS established and the claim is false. +# exit 2 - the task has no terminal claim to verify, or the request is invalid. +# +# The verdict record binds to the exact claim line it judged, so appending a new +# `done:` line invalidates it rather than inheriting its verdict. +set -u + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd "$SCRIPT_DIR/.." && pwd)}" +FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" +STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}" + +# shellcheck source=bin/fm-classify-lib.sh +. "$SCRIPT_DIR/fm-classify-lib.sh" +# shellcheck source=bin/fm-done-claim-lib.sh +. "$SCRIPT_DIR/fm-done-claim-lib.sh" +# shellcheck source=bin/fm-pr-lib.sh +. "$SCRIPT_DIR/fm-pr-lib.sh" +# shellcheck source=bin/fm-nm-run-lib.sh +. "$SCRIPT_DIR/fm-nm-run-lib.sh" +# shellcheck source=bin/fm-tangle-lib.sh +. "$SCRIPT_DIR/fm-tangle-lib.sh" + +QUIET=0 +ID= +while [ "$#" -gt 0 ]; do + case "$1" in + --quiet) QUIET=1 ;; + -h|--help) sed -n '2,40p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; + -*) echo "fm-verify-done: unknown option $1" >&2; exit 2 ;; + *) [ -z "$ID" ] || { echo "fm-verify-done: one task id" >&2; exit 2; }; ID=$1 ;; + esac + shift +done +[ -n "$ID" ] || { echo "usage: fm-verify-done.sh [--quiet]" >&2; exit 2; } +fm_pr_task_id_valid "$ID" || { echo "fm-verify-done: invalid task id" >&2; exit 2; } + +META="$STATE/$ID.meta" +# The bounded no-mistakes read below; the same shape fm-crew-state.sh uses. +NM_TIMEOUT=${FM_VERIFY_DONE_NM_TIMEOUT:-20} +case "$NM_TIMEOUT" in ''|*[!0-9]*) NM_TIMEOUT=20 ;; esac +GH_TIMEOUT=${FM_VERIFY_DONE_GH_TIMEOUT:-30} +case "$GH_TIMEOUT" in ''|*[!0-9]*) GH_TIMEOUT=30 ;; esac + +meta_field() { # + [ -f "$META" ] || return 0 + grep "^$1=" "$META" 2>/dev/null | tail -1 | cut -d= -f2- || true +} + +VERDICT= +REASON= +verdict_is() { # + VERDICT=$1 + REASON=$2 +} + +# Record the outcome and exit. The record is written before the exit code is +# chosen so a caller that only reads the record and a caller that only reads +# the status agree. +finish() { + local hash rc=0 + case "$VERDICT" in + verified) rc=0 ;; + unverified) rc=3 ;; + contradicted) rc=4 ;; + *) rc=2 ;; + esac + if hash=$(fm_done_claim_hash "$FM_DONE_CLAIM_LINE"); then + fm_done_verdict_write "$STATE" "$ID" "$VERDICT" "$hash" "$REASON" \ + || echo "fm-verify-done: could not record the verdict for $ID" >&2 + else + echo "fm-verify-done: could not compute a claim identity for $ID" >&2 + rc=3 + fi + [ "$QUIET" -eq 1 ] || printf '%s: %s\n' "$VERDICT" "$REASON" + exit "$rc" +} + +CLAIM=$(fm_done_claim_last "$STATE/$ID.status") +if [ -z "$CLAIM" ]; then + echo "fm-verify-done: $ID has made no terminal claim to verify" >&2 + exit 2 +fi +FM_DONE_CLAIM_LINE=$CLAIM +fm_done_claim_parse "$CLAIM" || { echo "fm-verify-done: $ID has no terminal claim to verify" >&2; exit 2; } + +KIND=$(meta_field kind); [ -n "$KIND" ] || KIND=ship +MODE=$(meta_field mode) +WT=$(meta_field worktree) + +if ! fm_done_claim_has_identity; then + verdict_is unverified 'legacy claim, no commit identity' + finish +fi + +# --- scout: the deliverable is the report ------------------------------------ +if [ "$KIND" = scout ]; then + REPORT=$FM_DONE_CLAIM_REPORT + if [ -z "$REPORT" ]; then + verdict_is contradicted 'scout claim names no report' + finish + fi + case "$REPORT" in + /*) ;; + *) REPORT="$FM_HOME/$REPORT" ;; + esac + if [ -f "$REPORT" ] && [ ! -L "$REPORT" ] && [ -s "$REPORT" ]; then + verdict_is verified "report present at $FM_DONE_CLAIM_REPORT" + else + verdict_is contradicted "claimed report is missing or empty: $FM_DONE_CLAIM_REPORT" + fi + finish +fi + +HEAD_CLAIM=$FM_DONE_CLAIM_HEAD +if ! fm_done_claim_head_valid "$HEAD_CLAIM"; then + verdict_is unverified 'claim carries no full commit id' + finish +fi + +# --- local-only: git is the whole authority ---------------------------------- +if [ "$MODE" = local-only ]; then + BRANCH=$FM_DONE_CLAIM_BRANCH + if [ -z "$BRANCH" ]; then + verdict_is contradicted 'local-only claim names no branch' + finish + fi + if [ -z "$WT" ] || [ ! -d "$WT" ]; then + verdict_is unverified 'the local copy is gone, so the claimed commit cannot be established' + finish + fi + if ! git -C "$WT" rev-parse --verify --quiet "$HEAD_CLAIM^{commit}" >/dev/null 2>&1; then + verdict_is contradicted "claimed commit $HEAD_CLAIM does not exist in the local copy" + finish + fi + TIP=$(git -C "$WT" rev-parse --verify --quiet "refs/heads/$BRANCH" 2>/dev/null || true) + if [ -n "$TIP" ]; then + if [ "$TIP" != "$HEAD_CLAIM" ]; then + verdict_is contradicted "branch $BRANCH is at $TIP, not the claimed $HEAD_CLAIM" + finish + fi + verdict_is verified "branch $BRANCH is at the claimed $HEAD_CLAIM" + finish + fi + # The branch is gone. That is the normal end state once local-only work has + # been merged and its branch retired, so containment in the default branch is + # the same "it landed" fact the branch tip was standing in for. Only when + # there is no default branch to compare against is this genuinely unknown. + DEFAULT=$(fm_default_branch "$WT" 2>/dev/null || true) + if [ -z "$DEFAULT" ] \ + || ! git -C "$WT" rev-parse --verify --quiet "refs/heads/$DEFAULT^{commit}" >/dev/null 2>&1; then + verdict_is unverified "branch $BRANCH is gone and the local copy has no default branch to check the claimed $HEAD_CLAIM against" + finish + fi + if git -C "$WT" merge-base --is-ancestor "$HEAD_CLAIM" "refs/heads/$DEFAULT" 2>/dev/null; then + verdict_is verified "claimed $HEAD_CLAIM is on $DEFAULT; its branch $BRANCH has been retired" + finish + fi + verdict_is contradicted "branch $BRANCH is gone and the claimed $HEAD_CLAIM is not on $DEFAULT" + finish +fi + +# --- PR modes ---------------------------------------------------------------- +PR_CLAIM=$FM_DONE_CLAIM_PR +if [ -z "$PR_CLAIM" ]; then + verdict_is contradicted 'claim names no PR' + finish +fi +if ! fm_pr_url_parse "$PR_CLAIM"; then + verdict_is contradicted "claim names an unparseable PR reference: $PR_CLAIM" + finish +fi +PROVIDER=$FM_PR_PROVIDER +PR_URL=$FM_PR_URL + +# glab exposes a merge request's head commit only inside its JSON output, which +# would need a JSON processor firstmate does not require (the same reason +# bin/fm-pr-check.sh records no pr_head for GitLab). That is a standing provider +# limitation rather than a transient failure, so it is named as one: the claim +# stays unverified however many times it is re-run, and cleanup of such a task +# needs the captain's explicit discard authority. +if [ "$PROVIDER" != github ]; then + verdict_is unverified "the $PROVIDER forge does not expose a head commit through its CLI, so the shipped commit cannot be established for $PR_URL and re-running will not change that" + finish +fi +if ! command -v gh >/dev/null 2>&1; then + verdict_is unverified 'gh is not on PATH, so the forge could not be asked' + finish +fi + +# Addressed by its full validated URL, so gh resolves the repository from the +# URL and needs no checkout - the same way the merge poll asks (bin/fm-pr-poll.sh). +# The checks fallback is PER ENTRY, not per field across the whole list: a +# rollup mixes check runs (.conclusion, null while still running, then .status) +# with commit statuses (.state), and a list-wide fallback silently drops every +# entry of the other shape - reporting SUCCESS for a pull request that still has +# a pending or in-progress check. Deduplicated so the recorded fact stays short +# and stable rather than repeating one word per check. +VIEW=$(fm_run_timed "$GH_TIMEOUT" gh pr view "$PR_URL" \ + --json state,headRefOid,url,statusCheckRollup \ + -q '.state + "\t" + .headRefOid + "\t" + ([.statusCheckRollup[]? | (.conclusion // .status // .state // "UNKNOWN")] | unique | join(","))' \ + 2>/dev/null) || VIEW= +VIEW=$(printf '%s\n' "$VIEW" | head -1) +if [ -z "$VIEW" ]; then + verdict_is unverified "the forge could not be reached for $PR_URL, so the shipped commit is unknown" + finish +fi +PR_STATE=${VIEW%%$'\t'*} +REST=${VIEW#*$'\t'} +if [ "$PR_STATE" = "$VIEW" ]; then + verdict_is unverified "the forge answer for $PR_URL could not be read" + finish +fi +PR_HEAD=${REST%%$'\t'*} +CHECKS=${REST#*$'\t'} +[ "$PR_HEAD" != "$REST" ] || CHECKS= +[ -n "$CHECKS" ] || CHECKS='none reported' + +case "$PR_STATE" in + CLOSED|closed) + verdict_is contradicted "$PR_URL is closed without merging, so this task is not done" + finish + ;; + OPEN|open|MERGED|merged) ;; + *) + verdict_is unverified "the forge reports an unrecognised state '$PR_STATE' for $PR_URL" + finish + ;; +esac + +if [ -z "$PR_HEAD" ]; then + verdict_is unverified "the forge reported no head commit for $PR_URL" + finish +fi +if [ "$PR_HEAD" != "$HEAD_CLAIM" ]; then + verdict_is contradicted "$PR_URL is at $PR_HEAD, not the claimed $HEAD_CLAIM" + finish +fi + +if [ "$MODE" != no-mistakes ]; then + verdict_is verified "$PR_URL is $PR_STATE at the claimed $HEAD_CLAIM; checks: $CHECKS" + finish +fi + +# --- the check that catches a validated-but-not-shipped commit --------------- +# The claim is only true if the commit the pipeline validated is the commit the +# PR now carries. A docstring "no-op" pushed after validation, or a force-push +# over the validated head, both land here. +if [ -z "$WT" ] || [ ! -d "$WT" ]; then + verdict_is unverified 'the local copy is gone, so the validated commit cannot be established' + finish +fi +if ! command -v no-mistakes >/dev/null 2>&1; then + verdict_is unverified 'no-mistakes is not on PATH, so the validated commit could not be established' + finish +fi +RUN_OUT=$(fm_nm_run_checked "$WT" "$NM_TIMEOUT" axi status) || RUN_OUT= +if [ -z "$RUN_OUT" ]; then + verdict_is unverified 'no validation run is recorded for this work, so the validated commit is unknown' + finish +fi +RUN_BRANCH=$(fm_nm_strip_quotes "$(fm_nm_field "$RUN_OUT" branch)") +WT_BRANCH=$(git -C "$WT" symbolic-ref --quiet --short HEAD 2>/dev/null || true) +if [ -z "$RUN_BRANCH" ] || [ -z "$WT_BRANCH" ] || [ "$RUN_BRANCH" != "$WT_BRANCH" ]; then + verdict_is unverified 'no validation run is attributed to this work, so the validated commit is unknown' + finish +fi +RUN_HEAD=$(fm_nm_strip_quotes "$(fm_nm_field "$RUN_OUT" head)") +if [ -z "$RUN_HEAD" ]; then + verdict_is unverified 'the validation run records no commit, so the validated commit is unknown' + finish +fi +# The run may abbreviate; compare on the shorter of the two, which is exact for +# any abbreviation git itself would produce. +if [ "${HEAD_CLAIM#"$RUN_HEAD"}" = "$HEAD_CLAIM" ]; then + verdict_is contradicted "validation ran against $RUN_HEAD, but the claim ships $HEAD_CLAIM" + finish +fi +RUN_OUTCOME=$(fm_nm_strip_quotes "$(fm_nm_field "$RUN_OUT" outcome)") +RUN_STATUS=$(fm_nm_strip_quotes "$(fm_nm_field "$RUN_OUT" status)") +verdict_is verified "$PR_URL is $PR_STATE at the claimed $HEAD_CLAIM, validated at $RUN_HEAD (run ${RUN_OUTCOME:-$RUN_STATUS}); checks: $CHECKS" +finish diff --git a/bin/fm-wake-drain.sh b/bin/fm-wake-drain.sh index 88fc8edb5d8..446e042c530 100755 --- a/bin/fm-wake-drain.sh +++ b/bin/fm-wake-drain.sh @@ -345,14 +345,26 @@ EOF fi } -# Print still-unread informational status lines (note: answers and pending-reply -# resolutions) that the OPEN DECISIONS fold never carries. Uses the same -# cursor-backed unread span as the annotation path, and runs on every drain - -# including the empty-queue fast path - so a buried answer cannot be swallowed -# when the fold later advances the cursor. Prints nothing when nothing is -# unread, which is the common case. +# How many unrecognised lines one drain prints before it starts counting them +# instead. Deliberately bounded where `note:` lines are not: a note is a +# deliberate captain-facing answer, while unrecognised prose is a worker writing +# into the wrong channel, and one chatty worker must not be able to bury the +# rest of the drain. The count still says how many were held back, so nothing +# disappears silently. +UNRECOGNISED_STATUS_MAX=${FM_DRAIN_UNRECOGNISED_MAX:-10} +case "$UNRECOGNISED_STATUS_MAX" in ''|*[!0-9]*) UNRECOGNISED_STATUS_MAX=10 ;; esac + +# Print still-unread informational status lines (note: answers, pending-reply +# resolutions, and lines carrying no recognised verb) that the OPEN DECISIONS +# fold never carries. Uses the same cursor-backed unread span as the annotation +# path, and runs on every drain - including the empty-queue fast path - so a +# buried answer cannot be swallowed when the fold later advances the cursor. +# A line with no recognised verb is marked UNRECOGNISED rather than shown as an +# ordinary status line, because it woke firstmate and classified as nothing: the +# mark is what stops it being read as a state it never claimed. Prints nothing +# when nothing is unread, which is the common case. print_unread_status_section() { - local snapshot=${1:-} unread task line shown=0 + local snapshot=${1:-} unread task line shown=0 unrecognised=0 omitted=0 if [ -n "$snapshot" ]; then unread=$(scan_unread_surface_snapshot "$STATE" "$snapshot") || return 1 @@ -364,7 +376,17 @@ print_unread_status_section() { while IFS=$(printf '\t') read -r task line; do [ -n "$task" ] || continue [ -n "$line" ] || continue - line="$task $line" + if status_line_is_unrecognized "$line"; then + unrecognised=$((unrecognised + 1)) + if [ "$unrecognised" -gt "$UNRECOGNISED_STATUS_MAX" ]; then + omitted=$((omitted + 1)) + continue + fi + fm_cap_line_var "$line" 220 + line="$task UNRECOGNISED (matches no status verb): $FM_LINE_CAP_LINE" + else + line="$task $line" + fi if [ "$shown" -eq 0 ]; then printf 'UNREAD STATUS (new since last drain, not re-printed after this presentation):\n' || return 1 fi @@ -374,6 +396,10 @@ print_unread_status_section() { $unread EOF + if [ "$omitted" -gt 0 ]; then + printf 'UNREAD STATUS: %d more unrecognised line(s) omitted; read them in the task status log.\n' \ + "$omitted" || return 1 + fi [ "$shown" -gt 0 ] || return 0 } diff --git a/bin/fm-watch.sh b/bin/fm-watch.sh index 23042e9c4b7..b5a6bd710ac 100755 --- a/bin/fm-watch.sh +++ b/bin/fm-watch.sh @@ -1660,18 +1660,23 @@ while :; do fi if [ -n "$out" ]; then reason="check: $c: $out" - if [ "$is_pr_poll" -eq 1 ] && [ "$out" = merged ]; then + # Both PR-poll terminal outcomes publish through the one owner + # (bin/fm-merge-outcome-lib.sh), which dedupes on PR identity AND + # outcome. Only `merged` retires the poll: a PR closed without merging + # contradicts the task's done record but is not final, because it can + # still be reopened and merged, so its poll stays armed to report that. + if [ "$is_pr_poll" -eq 1 ] && { [ "$out" = merged ] || [ "$out" = closed-unmerged ]; }; then merge_outcome_rc=0 - fm_merge_outcome_report "$FM_HOME" "$STATE" "$id" "$url" poll \ + fm_merge_outcome_report "$FM_HOME" "$STATE" "$id" "$url" poll "$out" \ || merge_outcome_rc=$? if [ "$merge_outcome_rc" -ne 0 ]; then - triage_log "merge outcome for $id could not be recorded (rc=$merge_outcome_rc)" + triage_log "PR $out outcome for $id could not be recorded (rc=$merge_outcome_rc)" exit 1 fi - retire_merged_pr_poll "$id" + [ "$out" != merged ] || retire_merged_pr_poll "$id" touch "$STATE/.last-check" if [ "$FM_MERGE_OUTCOME_ALREADY_RECORDED" = true ]; then - triage_log "absorbed duplicate merged PR poll result for $id" + triage_log "absorbed duplicate $out PR poll result for $id" continue fi wake "$reason" diff --git a/docs/scripts.md b/docs/scripts.md index 9b33fced128..62b0dad2734 100644 --- a/docs/scripts.md +++ b/docs/scripts.md @@ -90,8 +90,10 @@ The shared no-mistakes gate refusal for fleet lifecycle entrypoints is summarize | `fm-afk-return.sh` | Own deterministic return shutdown, catch-up evidence, and the firstmate-actionable blocker gate | | `fm-supervisor-target-lib.sh` | Resolve the shared supervisor target and backend for the daemon and launcher | | `fm-supervise-daemon.sh` | Presence-gated away-mode sub-supervisor: self-handle routine wakes, guard injection by the detected primary harness, escalate batched digests, alert on failed delivery | -| `fm-crew-state.sh` | Print one deterministic current-state line for a crew | +| `fm-crew-state.sh` | Print one deterministic current-state line for a crew, reporting an unestablished terminal claim as `done-unverified` | | `fm-nm-run-lib.sh` | Single owner of shared no-mistakes run-attribution primitives and rules | +| `fm-done-claim-lib.sh` | Single owner of the terminal-claim grammar, its parser, and the durable verdict record | +| `fm-verify-done.sh` | Establish a task's terminal claim against the forge, git, and the validation run, and record the verdict | | `fm-tangle-lib.sh` | Shared default-branch resolution and primary-checkout tangle classification | | `fm-timeout-lib.sh` | Single owner of hard-bounded command execution and its fallback watchdog | | `fm-timing-lib.sh` | Single owner of the deferred network stage's per-step elapsed-time records, inert unless a run asks for them | @@ -124,13 +126,13 @@ The shared no-mistakes gate refusal for fleet lifecycle entrypoints is summarize | `fm-check-lib.sh` | Validate custom-check registrations and prepare private execution snapshots | | `fm-tool-update-check.sh` | Report watched tooling with an update available, and updates installed but left inert by PATH order | | `fm-pr-lib.sh` | Own canonical task and PR validation plus private atomic PR-poll publication, merge-notification identity, and retirement | -| `fm-pr-poll.sh` | Provide the byte-static watcher program for validated PR/MR-poll sidecars | +| `fm-pr-poll.sh` | Provide the byte-static watcher program for validated PR/MR-poll sidecars, reporting a merge or a close without merge | | `fm-pr-check.sh` | Record validated `pr=` and `pr_head=` values, then atomically arm a static merge poll | | `fm-pr-merge.sh` | Record PR metadata, merge a task's canonical full GitHub or GitLab URL, then refuse an outcome it cannot prove landed or queued | -| `fm-merge-outcome-lib.sh` | Publish a confirmed merge's durable, role-routed supervision outcome | +| `fm-merge-outcome-lib.sh` | Publish a PR's confirmed terminal outcome - merged, or closed without merging - as a durable, role-routed supervision outcome | | `fm-parent-channel-lib.sh` | Resolve a secondmate home's parent channel and append a captain-facing outcome line to it at most once | | `fm-promote.sh` | Promote a scout task in place to a protected ship task with an explicit delivery mode, and write the ship instructions carrying that mode's definition of done | -| `fm-teardown.sh` | Fail-closed teardown: return landed ship worktrees, require completed scout deliverables, retire secondmate homes | +| `fm-teardown.sh` | Fail-closed teardown: return landed ship worktrees, require an established terminal claim, require completed scout deliverables, retire secondmate homes | | `fm-harness.sh` | Detect the running harness and resolve crew or secondmate harness, model, and effort | | `fm-lock.sh` | Per-home firstmate session lock | | `fm-x-lib.sh` | Shared Relay config, relay, and reply-threading helpers | diff --git a/docs/secondmate-parent-channel.md b/docs/secondmate-parent-channel.md index 9b08d7dbcd4..dfafacdb80a 100644 --- a/docs/secondmate-parent-channel.md +++ b/docs/secondmate-parent-channel.md @@ -22,7 +22,7 @@ Every captain-facing outcome that leaves durable evidence in the mate home is pu | Outcome | Durable evidence in the mate home | Published by | |---|---|---| -| Ship child PR ready | the child's `done: PR ...` line; `pr=` in the child's record once registered | `bin/fm-inactive-reconcile.sh` on the next poll with the child's line; `bin/fm-pr-check.sh` at registration with the canonical URL | +| Ship child PR ready | the child's `done:` line, whatever shape its delivery mode's claim grammar gives it ([`bin/fm-done-claim-lib.sh`](../bin/fm-done-claim-lib.sh)); `pr=` in the child's record once registered | `bin/fm-inactive-reconcile.sh` on the next poll with the child's line; `bin/fm-pr-check.sh` at registration with the canonical URL | | Scout child findings | the child's `done:` line plus `data//report.md` | `bin/fm-inactive-reconcile.sh` on the next poll, with the report pointer | | Child failed | the child's `failed:` line | `bin/fm-inactive-reconcile.sh` on the next poll | | Child decision escalated to the captain | the task held for the captain in the mate backlog | `bin/fm-captain-hold.sh hold`, and its answer by `answer` | diff --git a/tests/fm-captain-hold-lifecycle.test.sh b/tests/fm-captain-hold-lifecycle.test.sh index ec5ef8c8b6b..636f13b9747 100755 --- a/tests/fm-captain-hold-lifecycle.test.sh +++ b/tests/fm-captain-hold-lifecycle.test.sh @@ -113,7 +113,7 @@ test_uninventoried_report_decision_refuses_completion() { ## Done EOF write_origin_meta "$home" "$id" - printf 'done: report and visual review complete\n' > "$home/state/$id.status" + printf 'done: report=data/%s/report.md - report and visual review complete\n' "$id" > "$home/state/$id.status" cat > "$home/data/$id/report.md" <<'EOF' # Sample route review @@ -238,7 +238,7 @@ test_answer_records_and_closes() { tasks_in "$home" add "$id" "Guard the answer path" --kind scout --repo sample --start >/dev/null \ || fail "could not create the answer-guard origin" write_origin_meta "$home" "$id" - printf 'done: report complete\n' > "$home/state/$id.status" + printf 'done: report=data/%s/report.md - report complete\n' "$id" > "$home/state/$id.status" printf '# Guard review\n\nOne captain choice remains.\n' > "$home/data/$id/report.md" run_captain "$home" hold sample-guard-call \ --title "Choose the guard option" --reason "captain guard choice pending" --repo sample >/dev/null \ @@ -435,7 +435,7 @@ test_out_of_band_close_is_recordable() { tasks_in "$home" add "$id" "Investigate the sample full run" --kind scout --repo sample --start >/dev/null \ || fail "could not create out-of-band origin" write_origin_meta "$home" "$id" - printf 'done: report complete\n' > "$home/state/$id.status" + printf 'done: report=data/%s/report.md - report complete\n' "$id" > "$home/state/$id.status" printf '# Sample full run review\n\nOne captain choice remains.\n' > "$home/data/$id/report.md" run_captain "$home" hold sample-submission-call --title "Choose the sample submission" \ --reason "captain submission choice pending" --repo sample --origin "$id" >/dev/null \ @@ -496,7 +496,7 @@ test_visual_review_uses_shared_completion_owner() { mkdir -p "$home/data/$id" tasks_in "$home" add "$id" "Review the sample board" --kind scout --repo sample --start >/dev/null write_origin_meta "$home" "$id" - printf 'done: investigation complete\n' > "$home/state/$id.status" + printf 'done: report=data/%s/report.md - investigation complete\n' "$id" > "$home/state/$id.status" printf '# Sample board investigation\n\nThe initial findings need no captain choice.\n' > "$home/data/$id/report.md" run_captain "$home" complete "$id" --none >/dev/null \ || fail "initial investigation could not pass the shared completion owner" @@ -551,8 +551,8 @@ test_terminal_single_owner_status_decision_does_not_block_empty_inventory() { mkdir -p "$home/data/$id" tasks_in "$home" add "$id" "Review a terminal sample finding" --kind scout --repo sample --start >/dev/null write_origin_meta "$home" "$id" - printf 'needs-decision [key=default]: choose route A or route B\ndone: report complete\n' \ - > "$home/state/$id.status" + printf 'needs-decision [key=default]: choose route A or route B\ndone: report=data/%s/report.md - report complete\n' \ + "$id" > "$home/state/$id.status" printf '# Terminal sample review\n\nNo unresolved captain choice remains.\n' > "$home/data/$id/report.md" open=$(bash -c '. "$1"; status_open_decisions "$2"' _ \ "$ROOT/bin/fm-classify-lib.sh" "$home/state/$id.status") @@ -600,7 +600,7 @@ EOF mkdir -p "$mate/data/$origin" tasks_in "$mate" add "$origin" "Investigate secondmate sample" --kind scout --repo sample --start >/dev/null write_origin_meta "$mate" "$origin" - printf 'done: report and visual review complete\n' > "$mate/state/$origin.status" + printf 'done: report=data/%s/report.md - report and visual review complete\n' "$origin" > "$mate/state/$origin.status" printf '# Sample secondmate review\n\nOne captain choice remains.\n' > "$mate/data/$origin/report.md" run_captain "$mate" hold sample-release-call --title "Choose the sample release" \ --reason "captain release choice pending" --repo sample --origin "$origin" >/dev/null \ @@ -616,7 +616,7 @@ EOF run_teardown "$mate" "$origin" >/dev/null 2> "$mate/teardown.err" \ || fail "secondmate investigation teardown failed: $(cat "$mate/teardown.err")" tasks_in "$mate" "done" "$origin" --report "data/$origin/report.md" --keep 0 >/dev/null - grep -Eq "^done \\[key=child-outcome-$origin-done-[0-9a-f]{8}\\]: child $origin done: report and visual review complete mode=scout report=data/$origin/report.md$" \ + grep -Eq "^done \\[key=child-outcome-$origin-done-[0-9a-f]{8}\\]: child $origin done: report=data/$origin/report.md - report and visual review complete mode=scout report=data/$origin/report.md$" \ "$parent/state/sample-mate.status" \ || fail "the scout's final line did not reach the parent at teardown" @@ -733,7 +733,7 @@ test_bound_channel_answers_close_at_answer_time() { tasks_in "$home" add "$id" "Propose sample eval changes" --kind scout --repo sample --start >/dev/null \ || fail "could not create the review origin" write_origin_meta "$home" "$id" - printf 'done: proposal deck ready for the captain\n' > "$home/state/$id.status" + printf 'done: report=data/%s/report.md - proposal deck ready for the captain\n' "$id" > "$home/state/$id.status" printf '# Sample eval proposal\n\nThree captain choices remain.\n' > "$home/data/$id/report.md" run_captain "$home" hold sample-membership-call --title "Captain call: membership" \ --reason "captain membership choice pending" --repo sample --origin "$id" >/dev/null @@ -866,7 +866,7 @@ test_unbound_source_closes_no_hold() { tasks_in "$home" add "$id" "Review sample without binding" --kind scout --repo sample --start >/dev/null \ || fail "could not create the unbound origin" write_origin_meta "$home" "$id" - printf 'done: deck ready\n' > "$home/state/$id.status" + printf 'done: report=data/%s/report.md - deck ready\n' "$id" > "$home/state/$id.status" printf '# Unbound review\n\nOne captain choice remains.\n' > "$home/data/$id/report.md" run_captain "$home" hold sample-only-call --title "Captain call: only choice" \ --reason "captain only choice pending" --repo sample --origin "$id" >/dev/null \ @@ -909,7 +909,7 @@ test_legacy_identities_keep_working() { mkdir -p "$home/data/$id" tasks_in "$home" add "$id" "Legacy-shaped review" --kind scout --repo sample --start >/dev/null write_origin_meta "$home" "$id" - printf 'done: report complete\n' > "$home/state/$id.status" + printf 'done: report=data/%s/report.md - report complete\n' "$id" > "$home/state/$id.status" printf '# Legacy review\n\nTwo captain choices remain.\n' > "$home/data/$id/report.md" hold=$(run_shim "$home" id "$id" pick-one) @@ -1283,7 +1283,7 @@ test_teardown_never_closes_a_captain_held_task() { tasks_in "$home" add "$id" "Investigate sample attachment evidence" --kind scout \ --repo sample --start >/dev/null || fail "could not create the investigation fixture" write_origin_meta "$home" "$id" - printf 'done: report complete\n' > "$home/state/$id.status" + printf 'done: report=data/%s/report.md - report complete\n' "$id" > "$home/state/$id.status" printf '# Sample attachment evidence\n\nThe captain must choose inline or by-reference attachments.\n' \ > "$home/data/$id/report.md" run_captain "$home" hold "$id" \ @@ -1318,7 +1318,7 @@ test_teardown_never_closes_a_captain_held_task() { tasks_in "$home" add "$plain" "Investigate the sample cache" --kind scout \ --repo sample --start >/dev/null || fail "could not create the ordinary fixture" write_origin_meta "$home" "$plain" - printf 'done: report complete\n' > "$home/state/$plain.status" + printf 'done: report=data/%s/report.md - report complete\n' "$plain" > "$home/state/$plain.status" printf '# Sample cache\n\nNothing waits on the captain.\n' > "$home/data/$plain/report.md" run_captain "$home" complete "$plain" --none >/dev/null \ || fail "completion gate failed for the ordinary investigation" @@ -1335,7 +1335,7 @@ test_teardown_never_closes_a_captain_held_task() { tasks_in "$home" add "$forced" "Investigate the sample forced path" --kind scout \ --repo sample --start >/dev/null || fail "could not create the forced fixture" write_origin_meta "$home" "$forced" - printf 'done: report complete\n' > "$home/state/$forced.status" + printf 'done: report=data/%s/report.md - report complete\n' "$forced" > "$home/state/$forced.status" printf '# Sample forced path\n\nOne captain choice remains.\n' > "$home/data/$forced/report.md" run_captain "$home" hold "$forced" --reason "captain must choose the sample forced path" >/dev/null \ || fail "could not hold the forced fixture for the captain" @@ -1377,7 +1377,7 @@ test_interrupted_cleanup_keeps_the_captain_call_recoverable() { fm_write_meta "$home/state/$id.meta" \ "window=firstmate:fm-$id" "worktree=$wt" "project=$home/projects/sample" \ "harness=codex" "kind=scout" "mode=scout" "spawn_gen=fixture-$id" - printf 'done: report complete\n' > "$home/state/$id.status" + printf 'done: report=data/%s/report.md - report complete\n' "$id" > "$home/state/$id.status" printf '# Failed cleanup\n\nThe captain call remains open.\n' > "$home/data/$id/report.md" run_captain "$home" hold "$id" --reason "captain must choose after cleanup retry" >/dev/null \ || fail "could not hold the cleanup-failure fixture" @@ -1447,7 +1447,9 @@ EOF --repo sample --start --file "$data/backlog.md" >/dev/null) \ || fail "could not create the relocated captain-hold fixture" write_origin_meta "$home" "$id" - printf 'done: report complete\n' > "$home/state/$id.status" + # This home's data root is relocated, so the claim names the report by its + # real path rather than the usual home-relative one. + printf 'done: report=%s/%s/report.md - report complete\n' "$data" "$id" > "$home/state/$id.status" printf '# Relocated hold\n\nThe captain call remains open.\n' > "$data/$id/report.md" PATH="$home/fakebin:$PATH" FM_HOME="$home" FM_STATE_OVERRIDE="$home/state" \ FM_DATA_OVERRIDE="$data" FM_CONFIG_OVERRIDE="$home/config" \ diff --git a/tests/fm-crew-state.test.sh b/tests/fm-crew-state.test.sh index a284cbe8eb6..4b73b84320f 100755 --- a/tests/fm-crew-state.test.sh +++ b/tests/fm-crew-state.test.sh @@ -31,6 +31,8 @@ set -u . "$(dirname "${BASH_SOURCE[0]}")/lib.sh" # shellcheck source=/dev/null . "$ROOT/bin/fm-classify-lib.sh" +# shellcheck source=/dev/null +. "$ROOT/bin/fm-done-claim-lib.sh" CREW_STATE="$ROOT/bin/fm-crew-state.sh" TMP_ROOT=$(fm_test_tmproot fm-crew-state) @@ -450,13 +452,38 @@ test_ci_ready_done_log_beats_monitoring_run() { printf 'done: PR https://github.com/o/r/pull/2 checks green\n' > "$d/state/feat-ci.status" FM_FAKE_AXI_STATUS="$(run_ci_monitoring fm/feat-ci)" local out; out=$(run_crew_state "$d" feat-ci) - assert_contains "$out" "state: done" "ci-ready status log -> done" + assert_contains "$out" "state: done-unverified" "a legacy ci-ready claim carries no commit identity, so it is not done" + assert_contains "$out" "legacy claim, no commit identity" "the downgrade names why the claim could not be established" assert_contains "$out" "source: status-log" "ci-ready state comes from the status log" assert_contains "$out" "checks green" "ci-ready detail preserves the report" assert_not_contains "$out" "state: working" "ci-ready is not hidden by monitoring run" pass "ci-ready status log beats monitoring run" } +# A terminal claim is reported as done ONLY while a verdict established for that +# exact claim stands. This is the other half of the downgrade above: without it +# the reader would report every claim as unverified and the state would be +# useless rather than merely strict. +test_established_claim_is_reported_as_done() { + reset_fakes + local d claim hash out; d=$(new_case claim-verified) + make_repo_on_branch "$d/wt" fm/feat-cv + make_fakebin "$d" >/dev/null + fm_write_meta "$d/state/feat-cv.meta" "window=fm:fm-feat-cv" "worktree=$d/wt" "kind=ship" + claim="done: pr=https://github.com/o/r/pull/2 head=00112233445566778899aabbccddeeff00112233 - shipped" + printf '%s\n' "$claim" > "$d/state/feat-cv.status" + hash=$(fm_done_claim_hash "$claim") || fail "could not compute the claim identity" + printf 'fm-done-verdict-v1\nverified\n%s\n%s\nPR at the claimed head\n' \ + "$hash" "$(date +%s)" > "$d/state/feat-cv.done-verdict" + FM_FAKE_AXI_STATUS="$(run_ci_monitoring fm/feat-cv)" + out=$(run_crew_state "$d" feat-cv) + # `done-unverified` contains `done`, so the negative assertion is what makes + # the positive one mean anything. + assert_not_contains "$out" "done-unverified" "an established claim must not be downgraded" + assert_contains "$out" "state: done" "an established claim must be reported as done" + pass "a claim with a matching verified verdict is reported as done" +} + # Regression for the PR #252 incident: the crew's own status log never got a # "done: ... checks green" line (log_reports_ci_ready above does not apply), # but the ci step's log tail shows CI is actually green and only waiting on @@ -476,7 +503,7 @@ all CI checks passed - still monitoring until merged or closed EOF ) local out; out=$(run_crew_state "$d" feat-cigreen) - assert_contains "$out" "state: done" "green ci-monitor run -> done" + assert_contains "$out" "state: done ยท " "green ci-monitor run -> done" assert_contains "$out" "source: run-step" "green ci-monitor -> run-step source" assert_contains "$out" "checks green" "green ci-monitor detail mentions checks green" assert_not_contains "$out" "state: working" "green ci-monitor must not read as still validating" @@ -492,7 +519,7 @@ test_top_level_ci_checks_green_surfaces_done() { FM_FAKE_AXI_STATUS="$(run_top_level_ci fm/feat-topcigreen)" FM_FAKE_CI_LOGS="all CI checks passed - still monitoring until merged or closed" local out; out=$(run_crew_state "$d" feat-topcigreen) - assert_contains "$out" "state: done" "top-level ci with green log -> done" + assert_contains "$out" "state: done ยท " "top-level ci with green log -> done" assert_contains "$out" "source: run-step" "top-level ci green -> run-step source" assert_contains "$out" "checks green" "top-level ci green detail mentions checks green" assert_not_contains "$out" "state: working" "top-level ci green must not stay working" @@ -508,7 +535,7 @@ test_ci_monitoring_no_checks_terminal_surfaces_done() { FM_FAKE_AXI_STATUS="$(run_ci_monitoring fm/feat-cinochecks)" FM_FAKE_CI_LOGS="no CI checks reported - still monitoring until merged or closed" local out; out=$(run_crew_state "$d" feat-cinochecks) - assert_contains "$out" "state: done" "terminal no-checks ci-monitor run -> done" + assert_contains "$out" "state: done ยท " "terminal no-checks ci-monitor run -> done" assert_contains "$out" "checks green" "terminal no-checks ci-monitor detail mentions checks green" pass "terminal no-checks ci-monitor marker surfaces done" } @@ -666,7 +693,7 @@ test_terminal_passed() { fm_write_meta "$d/state/feat-d.meta" "window=fm:fm-feat-d" "worktree=$d/wt" "kind=ship" FM_FAKE_AXI_STATUS="$(run_passed fm/feat-d)" local out; out=$(run_crew_state "$d" feat-d) - assert_contains "$out" "state: done" "passed run -> done" + assert_contains "$out" "state: done ยท " "passed run -> done" assert_contains "$out" "source: run-step" "passed -> run-step source" pass "terminal passed run is authoritative" } @@ -754,7 +781,7 @@ EOF )" FM_FAKE_CI_LOGS="CI checks running, waiting for results..." local out; out=$(run_crew_state "$d" feat-coarseready) - assert_contains "$out" "state: done" "coarse ready status -> done" + assert_contains "$out" "state: done-unverified" "a legacy coarse-ready claim is not done" assert_contains "$out" "source: status-log" "coarse ready status remains status-log sourced" assert_not_contains "$out" "state: working" "coarse ready status must not be suppressed by another branch log" pass "coarse run does not probe another branch's ci log" @@ -779,7 +806,7 @@ EOF local out; out=$(run_crew_state "$d" feat-g) assert_not_contains "$out" "source: run-step" "another branch's run not misattributed" assert_contains "$out" "source: status-log" "no own run -> falls back to status-log" - assert_contains "$out" "state: done" "falls back to the log verb" + assert_contains "$out" "state: done-unverified" "falls back to the log verb, downgraded because the claim is unverified" pass "another branch's run is ignored, falls back" } @@ -1061,7 +1088,7 @@ test_dead_window_still_reports_terminal_run_step() { FM_FAKE_AXI_STATUS="$(run_passed fm/feat-dead-done)" FM_FAKE_TMUX_MISSING=1 # the crew's window has closed local out; out=$(run_crew_state "$d" feat-dead-done) - assert_contains "$out" "state: done" "closed pane still reports terminal run-step done" + assert_contains "$out" "state: done-unverified" "closed pane still reports the terminal run-step, downgraded by its unverified claim" assert_contains "$out" "source: run-step" "closed pane does not mask the run-step" assert_not_contains "$out" "state: unknown" "closed pane with a run must never be unknown" pass "closed pane still reports a terminal run-step" @@ -1555,6 +1582,7 @@ test_genuine_parked_not_superseded test_scalar_gate_parked_not_superseded test_gate_block_parked_not_superseded test_ci_ready_done_log_beats_monitoring_run +test_established_claim_is_reported_as_done test_ci_monitoring_checks_green_surfaces_done test_top_level_ci_checks_green_surfaces_done test_ci_monitoring_no_checks_terminal_surfaces_done diff --git a/tests/fm-done-verified.test.sh b/tests/fm-done-verified.test.sh new file mode 100755 index 00000000000..8a27b97ef2b --- /dev/null +++ b/tests/fm-done-verified.test.sh @@ -0,0 +1,435 @@ +#!/usr/bin/env bash +# tests/fm-done-verified.test.sh - a terminal `done:` claim must mean something a +# machine checked, not something a worker asserted. +# +# The three incidents this pins, all the same shape: a worker reported a PR as +# validated and green while (1) the commit the pipeline validated was not the +# commit the PR shipped, (2) a force-push had replaced the validated head, and +# (3) the PR was later closed WITHOUT merging while the task record still read +# done, unnoticed for three weeks. Free prose ("done: PR checks green") +# carried no commit identity, so nothing could compare what was validated with +# what shipped, and the merge poll only ever watched for `merged`. +# +# Cases: +# (a) the claim grammar its owner hands a worker parses back into the exact +# identity fields the verifier reads - the two cannot drift +# (b) a claim naming a PR the forge cannot answer for -> unverified +# (c) a claim whose head is not the PR's head -> contradicted +# (d) a claim whose head is not the VALIDATED commit -> contradicted <- (1) +# (e) a claim on a PR closed without merging -> contradicted <- (3) +# (f) a legacy claim with no commit identity -> unverified, never a pass +# (g) an honest claim -> verified +# (h) a scout claim: report present -> verified, missing -> contradicted +# (i) a verdict binds to the exact claim it judged: a later claim is unverified +# (j) the merge poll reports closed-without-merge, and stays silent otherwise +# (k) the notification marker records WHICH outcome was delivered, so a close +# does not suppress a later merge of the same PR +# (l) the drain flags a status line carrying no recognised verb instead of +# absorbing it silently +set -u + +# shellcheck source=tests/wake-helpers.sh +. "$(dirname "${BASH_SOURCE[0]}")/wake-helpers.sh" +# shellcheck source=/dev/null +. "$ROOT/bin/fm-classify-lib.sh" +# shellcheck source=/dev/null +. "$ROOT/bin/fm-done-claim-lib.sh" +# shellcheck source=/dev/null +. "$ROOT/bin/fm-pr-lib.sh" + +VERIFY="$ROOT/bin/fm-verify-done.sh" +POLL="$ROOT/bin/fm-pr-poll.sh" +DRAIN="$ROOT/bin/fm-wake-drain.sh" +TMP_ROOT=$(fm_test_tmproot fm-done-verified) +fm_git_identity fmtest fmtest@example.invalid + +# A world with a task worktree holding two commits, so a "validated" commit and +# a later "shipped" commit are genuinely different objects. Prints the case dir; +# the caller reads the two commits back with git rather than through a global, +# because this runs inside a command substitution. +make_world() { # [kind] [mode] + local name=$1 kind=${2:-ship} mode=${3:-no-mistakes} dir + dir="$TMP_ROOT/$name" + mkdir -p "$dir/state" "$dir/data" "$dir/fakebin" "$dir/wt" + git -C "$dir/wt" init -q + git -C "$dir/wt" commit -q --allow-empty -m base + git -C "$dir/wt" checkout -q -b fm/task-v + git -C "$dir/wt" commit -q --allow-empty -m validated + git -C "$dir/wt" commit -q --allow-empty -m shipped + # Two modes. FAKE_GH_OUT returns a pre-formatted line, which keeps the verdict + # cases independent of jq. FAKE_GH_JSON returns a realistic API payload and + # applies the script's OWN -q expression with real jq, exactly as gh does, so + # that expression is exercised rather than mocked away. + cat > "$dir/fakebin/gh" <<'SH' +#!/usr/bin/env bash +set -u +[ "${1:-}" = pr ] && [ "${2:-}" = view ] || exit 1 +if [ -n "${FAKE_GH_JSON:-}" ]; then + q= + while [ "$#" -gt 0 ]; do + if [ "$1" = -q ]; then q=${2:-}; break; fi + shift + done + [ -n "$q" ] || exit 1 + printf '%s' "$FAKE_GH_JSON" | jq -r "$q" || exit 1 + exit 0 +fi +[ -n "${FAKE_GH_OUT:-}" ] || exit 1 +printf '%s\n' "$FAKE_GH_OUT" +SH + cat > "$dir/fakebin/no-mistakes" <<'SH' +#!/usr/bin/env bash +set -u +if [ "${1:-}" = axi ] && [ "${2:-}" = status ]; then printf '%s\n' "${FAKE_NM_STATUS:-}"; fi +exit 0 +SH + chmod +x "$dir/fakebin/gh" "$dir/fakebin/no-mistakes" + fm_write_meta "$dir/state/task-v.meta" \ + "window=fm:fm-task-v" "worktree=$dir/wt" "kind=$kind" "mode=$mode" + printf '%s\n' "$dir" +} + +# Run the verifier in and echo "\t". +verify() { # + local dir=$1 out rc=0 + out=$(PATH="$dir/fakebin:$PATH" FM_HOME="$dir" FM_STATE_OVERRIDE="$dir/state" \ + FM_DATA_OVERRIDE="$dir/data" "$VERIFY" task-v 2>&1) || rc=$? + printf '%s\t%s\n' "$rc" "$out" +} + +nm_status() { # + printf 'branch: %s\nhead: %s\nstatus: completed\noutcome: passed\n' "$1" "$2" +} + +# --- (a) the grammar its owner hands a worker is the grammar the parser reads -- + +test_claim_grammar_round_trips() { + local rendered + + rendered=$(fm_done_claim_template no-mistakes task-v) + rendered=${rendered//\{url\}/https://github.com/o/r/pull/7} + rendered=${rendered//\{full-sha\}/00112233445566778899aabbccddeeff00112233} + rendered=${rendered//\{one line\}/shipped it} + fm_done_claim_parse "$rendered" || fail "the rendered no-mistakes claim did not parse as a claim" + [ "$FM_DONE_CLAIM_PR" = "https://github.com/o/r/pull/7" ] \ + || fail "the rendered no-mistakes claim lost its PR: $rendered" + [ "$FM_DONE_CLAIM_HEAD" = "00112233445566778899aabbccddeeff00112233" ] \ + || fail "the rendered no-mistakes claim lost its commit: $rendered" + fm_done_claim_has_identity || fail "the rendered no-mistakes claim has no machine-checkable identity" + + rendered=$(fm_done_claim_template local-only task-v) + rendered=${rendered//\{full-sha\}/00112233445566778899aabbccddeeff00112233} + rendered=${rendered//\{one line\}/ready} + fm_done_claim_parse "$rendered" || fail "the rendered local-only claim did not parse as a claim" + [ "$FM_DONE_CLAIM_BRANCH" = "fm/task-v" ] \ + || fail "the rendered local-only claim lost its branch: $rendered" + [ "$FM_DONE_CLAIM_HEAD" = "00112233445566778899aabbccddeeff00112233" ] \ + || fail "the rendered local-only claim lost its commit: $rendered" + + rendered=$(fm_done_claim_template scout task-v) + rendered=${rendered//\{path\}//tmp/report.md} + rendered=${rendered//\{one line\}/found it} + fm_done_claim_parse "$rendered" || fail "the rendered scout claim did not parse as a claim" + [ "$FM_DONE_CLAIM_REPORT" = "/tmp/report.md" ] \ + || fail "the rendered scout claim lost its report: $rendered" + fm_done_claim_has_identity || fail "the rendered scout claim has no machine-checkable identity" + + fm_done_claim_parse "working: still going" \ + && fail "a non-terminal line was parsed as a terminal claim" + + # A summary is arbitrary worker prose. Splitting it into fields must not also + # glob it against whatever directory the reader happens to be in. + fm_done_claim_parse "done: pr=https://github.com/o/r/pull/7 head=00112233445566778899aabbccddeeff00112233 - fixed *.md and tests/*" \ + || fail "a claim whose summary contains glob characters did not parse" + [ "$FM_DONE_CLAIM_HEAD" = "00112233445566778899aabbccddeeff00112233" ] \ + || fail "glob characters in the summary corrupted the parsed commit" + case $- in *f*) fail "the parser left globbing disabled in its caller" ;; esac + pass "the claim grammar handed to a worker parses back into the identity the verifier reads" +} + +# --- (b)-(g) the verifier over PR modes -------------------------------------- + +test_unreachable_forge_is_unverified_never_a_pass() { + local dir result shipped + dir=$(make_world forge-unreachable) + shipped=$(git -C "$dir/wt" rev-parse HEAD) + printf 'done: pr=https://github.com/o/r/pull/999 head=%s - shipped\n' "$shipped" \ + > "$dir/state/task-v.status" + result=$(FAKE_GH_OUT='' verify "$dir") + [ "${result%%$'\t'*}" = 3 ] || fail "an unanswerable forge did not exit unverified: $result" + case "$result" in *unverified:*) ;; *) fail "an unanswerable forge was not reported unverified: $result" ;; esac + case "$result" in *"could not be reached"*) ;; *) fail "the unverified reason did not name the forge: $result" ;; esac + pass "a claim the forge cannot answer for is unverified, never a pass" +} + +test_head_not_the_pr_head_is_contradicted() { + local dir result shipped validated + dir=$(make_world head-mismatch) + shipped=$(git -C "$dir/wt" rev-parse HEAD) + validated=$(git -C "$dir/wt" rev-parse 'HEAD~1') + printf 'done: pr=https://github.com/o/r/pull/7 head=%s - shipped\n' "$shipped" \ + > "$dir/state/task-v.status" + result=$(FAKE_GH_OUT="OPEN $validated SUCCESS" verify "$dir") + [ "${result%%$'\t'*}" = 4 ] || fail "a head that is not the PR head did not exit contradicted: $result" + case "$result" in *contradicted:*) ;; *) fail "a head mismatch was not contradicted: $result" ;; esac + pass "a claim whose commit is not the PR's head is contradicted" +} + +test_head_not_the_validated_commit_is_contradicted() { + local dir result shipped validated + dir=$(make_world validated-mismatch) + shipped=$(git -C "$dir/wt" rev-parse HEAD) + validated=$(git -C "$dir/wt" rev-parse 'HEAD~1') + printf 'done: pr=https://github.com/o/r/pull/7 head=%s - shipped\n' "$shipped" \ + > "$dir/state/task-v.status" + result=$(FAKE_GH_OUT="OPEN $shipped SUCCESS" \ + FAKE_NM_STATUS="$(nm_status fm/task-v "$validated")" verify "$dir") + [ "${result%%$'\t'*}" = 4 ] \ + || fail "shipping a commit the pipeline never validated did not exit contradicted: $result" + case "$result" in *"validation ran against"*) ;; *) fail "the contradiction did not name the validated commit: $result" ;; esac + pass "a commit the PR ships but the pipeline never validated is contradicted" +} + +test_closed_unmerged_pr_is_contradicted() { + local dir result shipped + dir=$(make_world closed-unmerged) + shipped=$(git -C "$dir/wt" rev-parse HEAD) + printf 'done: pr=https://github.com/o/r/pull/7 head=%s - shipped\n' "$shipped" \ + > "$dir/state/task-v.status" + result=$(FAKE_GH_OUT="CLOSED $shipped SUCCESS" verify "$dir") + [ "${result%%$'\t'*}" = 4 ] || fail "a closed-unmerged PR did not exit contradicted: $result" + case "$result" in *"closed without merging"*) ;; *) fail "the contradiction did not name the close: $result" ;; esac + pass "a claim on a PR closed without merging is contradicted" +} + +test_legacy_claim_degrades_and_is_never_upgraded() { + local dir result + dir=$(make_world legacy-claim) + printf 'done: PR https://github.com/o/r/pull/7 checks green\n' > "$dir/state/task-v.status" + result=$(FAKE_GH_OUT="MERGED deadbeef SUCCESS" verify "$dir") + [ "${result%%$'\t'*}" = 3 ] || fail "a legacy claim did not exit unverified: $result" + case "$result" in *"legacy claim, no commit identity"*) ;; *) fail "the legacy claim was not named as such: $result" ;; esac + # The verdict token is the whole word after the exit code, so a check for + # "verified" must not be satisfied by the "verified" inside "unverified". + case "${result#*$'\t'}" in verified:*) fail "a legacy claim was silently upgraded to verified: $result" ;; esac + pass "a legacy claim degrades to unverified and is never upgraded" +} + +test_honest_claim_verifies_and_records_the_checks_state() { + local dir result shipped + dir=$(make_world honest-claim) + shipped=$(git -C "$dir/wt" rev-parse HEAD) + printf 'done: pr=https://github.com/o/r/pull/7 head=%s - shipped\n' "$shipped" \ + > "$dir/state/task-v.status" + result=$(FAKE_GH_OUT="OPEN $shipped SUCCESS" \ + FAKE_NM_STATUS="$(nm_status fm/task-v "$shipped")" verify "$dir") + [ "${result%%$'\t'*}" = 0 ] || fail "an honest claim was not verified: $result" + case "$result" in *"checks: SUCCESS"*) ;; *) fail "the checks state was not recorded as fact: $result" ;; esac + assert_present "$dir/state/task-v.done-verdict" "the verified claim left no durable verdict record" + assert_grep verified "$dir/state/task-v.done-verdict" "the durable record does not carry the verdict" + pass "an honest claim verifies and records the checks state as fact" +} + +test_red_checks_do_not_by_themselves_contradict_a_claim() { + local dir result shipped + dir=$(make_world red-checks) + shipped=$(git -C "$dir/wt" rev-parse HEAD) + printf 'done: pr=https://github.com/o/r/pull/7 head=%s - shipped\n' "$shipped" \ + > "$dir/state/task-v.status" + result=$(FAKE_GH_OUT="OPEN $shipped FAILURE" \ + FAKE_NM_STATUS="$(nm_status fm/task-v "$shipped")" verify "$dir") + [ "${result%%$'\t'*}" = 0 ] \ + || fail "the verifier judged the checks state instead of recording it: $result" + case "$result" in *"checks: FAILURE"*) ;; *) fail "the red checks state was not recorded: $result" ;; esac + pass "the checks state is recorded as fact, not judged" +} + +# The checks state is read out of a real rollup payload with the script's own +# query, because a rollup mixes check runs (.conclusion, null while a run is +# still going, then .status) with commit statuses (.state). A query that falls +# back across the whole list instead of per entry silently drops every entry of +# the other shape and records SUCCESS for a pull request that still has work in +# flight - a wrong fact in a verdict whose whole purpose is recording facts. +test_checks_state_is_read_from_a_real_rollup() { + local dir shipped result + command -v jq >/dev/null 2>&1 || { pass "skip: jq not found, real-rollup checks reading not exercised"; return 0; } + dir=$(make_world checks-rollup) + shipped=$(git -C "$dir/wt" rev-parse HEAD) + printf 'done: pr=https://github.com/o/r/pull/7 head=%s - shipped\n' "$shipped" \ + > "$dir/state/task-v.status" + + # A finished check run, an in-progress one, and a commit status: all three + # shapes must reach the record. + result=$(FAKE_GH_JSON="{\"state\":\"OPEN\",\"headRefOid\":\"$shipped\",\"url\":\"u\",\"statusCheckRollup\":[{\"conclusion\":\"SUCCESS\"},{\"conclusion\":null,\"status\":\"IN_PROGRESS\"},{\"state\":\"PENDING\"}]}" \ + FAKE_NM_STATUS="$(nm_status fm/task-v "$shipped")" verify "$dir") + [ "${result%%$'\t'*}" = 0 ] || fail "a real rollup payload did not verify: $result" + case "$result" in *IN_PROGRESS*) ;; *) fail "an in-progress check run was dropped from the recorded checks state: $result" ;; esac + case "$result" in *PENDING*) ;; *) fail "a commit status was dropped from the recorded checks state: $result" ;; esac + case "$result" in *SUCCESS*) ;; *) fail "a finished check run was dropped from the recorded checks state: $result" ;; esac + + # No checks at all is recorded as such, never as a pass. + result=$(FAKE_GH_JSON="{\"state\":\"MERGED\",\"headRefOid\":\"$shipped\",\"url\":\"u\",\"statusCheckRollup\":null}" \ + FAKE_NM_STATUS="$(nm_status fm/task-v "$shipped")" verify "$dir") + [ "${result%%$'\t'*}" = 0 ] || fail "a pull request with no checks did not verify: $result" + case "$result" in *"checks: none reported"*) ;; *) fail "an absent rollup was not recorded as none reported: $result" ;; esac + pass "the checks state is read out of a real rollup payload, per entry, with the script's own query" +} + +# --- (h) scout claims --------------------------------------------------------- + +test_scout_claim_checks_the_report_exists() { + local dir result + dir=$(make_world scout-report scout scout) + mkdir -p "$dir/data/task-v" + printf 'findings\n' > "$dir/data/task-v/report.md" + printf 'done: report=data/task-v/report.md - found it\n' > "$dir/state/task-v.status" + result=$(verify "$dir") + [ "${result%%$'\t'*}" = 0 ] || fail "a scout claim naming a real report was not verified: $result" + + dir=$(make_world scout-missing scout scout) + printf 'done: report=data/task-v/report.md - found it\n' > "$dir/state/task-v.status" + result=$(verify "$dir") + [ "${result%%$'\t'*}" = 4 ] || fail "a scout claim naming a missing report was not contradicted: $result" + pass "a scout claim is established against the report it names" +} + +# --- (i) a verdict judges one exact claim ------------------------------------ + +test_a_verdict_does_not_cover_a_later_claim() { + local dir shipped + dir=$(make_world verdict-binding) + shipped=$(git -C "$dir/wt" rev-parse HEAD) + printf 'done: pr=https://github.com/o/r/pull/7 head=%s - shipped\n' "$shipped" \ + > "$dir/state/task-v.status" + FAKE_GH_OUT="OPEN $shipped SUCCESS" FAKE_NM_STATUS="$(nm_status fm/task-v "$shipped")" \ + verify "$dir" >/dev/null + fm_done_claim_status "$dir/state" task-v + [ "$FM_DONE_CLAIM_STATE" = verified ] \ + || fail "setup error: the first claim did not verify ($FM_DONE_CLAIM_STATE)" + + printf 'done: pr=https://github.com/o/r/pull/7 head=%s - and one more thing\n' "$shipped" \ + >> "$dir/state/task-v.status" + fm_done_claim_status "$dir/state" task-v + [ "$FM_DONE_CLAIM_STATE" = unverified ] \ + || fail "a verdict for an earlier claim was inherited by a later one ($FM_DONE_CLAIM_STATE)" + case "$FM_DONE_CLAIM_REASON" in *"earlier claim"*) ;; *) fail "the reason did not name the superseded claim: $FM_DONE_CLAIM_REASON" ;; esac + pass "a verdict judges one exact claim and is not inherited by a later one" +} + +test_no_claim_is_not_a_verdict() { + local dir + dir=$(make_world no-claim) + printf 'working: still going\n' > "$dir/state/task-v.status" + fm_done_claim_status "$dir/state" task-v + [ "$FM_DONE_CLAIM_STATE" = none ] \ + || fail "a task that never claimed done was given a verdict ($FM_DONE_CLAIM_STATE)" + pass "a task that never claimed done has nothing to verify" +} + +# --- (j) the merge poll sees a close, not only a merge ------------------------ + +poll_says() { # -> the poll's one line + local state=$1 dir out + dir="$TMP_ROOT/poll-$state" + mkdir -p "$dir/fakebin" + cat > "$dir/fakebin/gh" </dev/null || true) + printf '%s' "$out" +} + +test_poll_reports_a_close_as_well_as_a_merge() { + [ "$(poll_says MERGED)" = merged ] || fail "the poll stopped reporting a merge" + [ "$(poll_says CLOSED)" = closed-unmerged ] \ + || fail "the poll did not report a PR closed without merging" + [ -z "$(poll_says OPEN)" ] || fail "the poll reported a terminal outcome for an open PR" + pass "the merge poll reports a close without merge as well as a merge" +} + +# --- (k) the marker records WHICH outcome was delivered ---------------------- + +test_marker_distinguishes_the_two_terminal_outcomes() { + local dir state + dir="$TMP_ROOT/marker-outcome" + state="$dir/state" + mkdir -p "$state" + + fm_pr_poll_merge_mark_notified "$state" task-v github github.com o/r 7 closed-unmerged \ + || fail "the close outcome could not be recorded" + fm_pr_poll_merge_already_notified "$state" task-v github github.com o/r 7 closed-unmerged \ + || fail "a recorded close was not recognised as already delivered" + if fm_pr_poll_merge_already_notified "$state" task-v github github.com o/r 7 merged; then + fail "a recorded close suppressed a later merge of the same PR" + fi + + # A marker written before the outcome was recorded means `merged`. + printf 'fm-pr-poll-merge-notified-v1\ngithub\ngithub.com\no/r\n7\n' > "$state/legacy.pr-poll-merge-notified" + chmod 0600 "$state/legacy.pr-poll-merge-notified" + fm_pr_poll_merge_already_notified "$state" legacy github github.com o/r 7 merged \ + || fail "a legacy marker stopped meaning that the merge was already delivered" + if fm_pr_poll_merge_already_notified "$state" legacy github github.com o/r 7 closed-unmerged; then + fail "a legacy merge marker was read as a delivered close" + fi + pass "the notification marker records which terminal outcome was delivered" +} + +# --- (l) a line matching no status verb is flagged, not absorbed -------------- + +test_drain_flags_a_line_matching_no_status_verb() { + local dir state status out + dir=$(make_case unrecognised-line) + state="$dir/state" + status="$state/task9.status" + out="$dir/drain.out" + printf 'note: bootstrap cursor line\n' > "$status" + FM_STATE_OVERRIDE="$state" "$DRAIN" >/dev/null 2>/dev/null \ + || fail "bootstrap drain failed while priming the cursor" + + printf 'Migration syntax: OK\n' >> "$status" + printf -- '- ruff check on all changed files: all passed\n' >> "$status" + FM_STATE_OVERRIDE="$state" "$DRAIN" > "$out" || fail "drain failed over unrecognised lines" + + grep -F 'UNRECOGNISED (matches no status verb): Migration syntax: OK' "$out" >/dev/null \ + || fail "a line matching no status verb was absorbed silently: $(cat "$out")" + grep -F 'UNRECOGNISED (matches no status verb): - ruff check on all changed files: all passed' "$out" >/dev/null \ + || fail "the second unrecognised line was dropped: $(cat "$out")" + pass "a status line matching no known verb is flagged in the drain, not absorbed" +} + +test_known_verbs_are_not_flagged_as_unrecognised() { + status_line_is_unrecognized "working: building" && fail "working: was called unrecognised" + status_line_is_unrecognized "done: pr=x head=y - z" && fail "done: was called unrecognised" + status_line_is_unrecognized "needs-decision: which" && fail "needs-decision: was called unrecognised" + status_line_is_unrecognized "blocked: stuck" && fail "blocked: was called unrecognised" + status_line_is_unrecognized "paused: upstream" && fail "paused: was called unrecognised" + status_line_is_unrecognized "failed: rc 2" && fail "failed: was called unrecognised" + status_line_is_unrecognized "note: fyi" && fail "note: was called unrecognised" + status_line_is_unrecognized "resolved: cleared" && fail "resolved: was called unrecognised" + status_line_is_unrecognized "captain-held: waiting" && fail "captain-held: was called unrecognised" + status_line_is_unrecognized " " && fail "a blank line was called unrecognised" + status_line_is_unrecognized "Migration syntax: OK" || fail "worker prose was not called unrecognised" + pass "every known status verb stays recognised while prose does not" +} + +test_claim_grammar_round_trips +test_unreachable_forge_is_unverified_never_a_pass +test_head_not_the_pr_head_is_contradicted +test_head_not_the_validated_commit_is_contradicted +test_closed_unmerged_pr_is_contradicted +test_legacy_claim_degrades_and_is_never_upgraded +test_honest_claim_verifies_and_records_the_checks_state +test_red_checks_do_not_by_themselves_contradict_a_claim +test_checks_state_is_read_from_a_real_rollup +test_scout_claim_checks_the_report_exists +test_a_verdict_does_not_cover_a_later_claim +test_no_claim_is_not_a_verdict +test_poll_reports_a_close_as_well_as_a_merge +test_marker_distinguishes_the_two_terminal_outcomes +test_drain_flags_a_line_matching_no_status_verb +test_known_verbs_are_not_flagged_as_unrecognised +echo "all fm-done-verified tests passed" diff --git a/tests/fm-fleet-snapshot-view.test.sh b/tests/fm-fleet-snapshot-view.test.sh index 3c89d80a17a..ef9068f9eac 100755 --- a/tests/fm-fleet-snapshot-view.test.sh +++ b/tests/fm-fleet-snapshot-view.test.sh @@ -111,7 +111,10 @@ EOF "kind=scout" \ "mode=scout" \ "yolo=off" - printf 'done: report ready\n' > "$home/state/scout-task.status" + printf 'done: report=data/scout-task/report.md - report ready\n' > "$home/state/scout-task.status" + FM_HOME="$home" FM_STATE_OVERRIDE="$home/state" FM_DATA_OVERRIDE="$home/data" \ + "$ROOT/bin/fm-verify-done.sh" scout-task >/dev/null \ + || fail "the scout-task claim fixture did not verify against its own report" fm_write_meta "$home/state/secondmate-task.meta" \ "window=firstmate:fm-secondmate-task" \ "worktree=$home/secondmate-home" \ @@ -475,7 +478,11 @@ EOF "kind=scout" \ "mode=scout" record_claude_idle "$home/state" bold-task - printf 'done: report ready\n' > "$home/state/bold-task.status" + printf 'done: report=%s/bold-task/report.md - report ready\n' "$data" \ + > "$home/state/bold-task.status" + FM_HOME="$home" FM_STATE_OVERRIDE="$home/state" FM_DATA_OVERRIDE="$data" \ + "$ROOT/bin/fm-verify-done.sh" bold-task >/dev/null \ + || fail "the bold-task claim fixture did not verify against its own report" fakebin=$(make_fakebin "$home") out=$(PATH="$fakebin:$PATH" FM_HOME="$home" FM_DATA_OVERRIDE="$data" FM_PROJECTS_OVERRIDE="$projects" \ FM_SNAPSHOT_NOW=2026-07-14T00:00:00Z "$SNAPSHOT" --json) @@ -757,9 +764,14 @@ test_completed_scout_report_is_pointer_not_pending() { record_claude_idle "$home/state" lavish-103 # Stale needs-decision, then the scout finished (done). No keyed resolution. printf 'needs-decision: adopt approach A or B for Lavish issue 103\n' > "$home/state/lavish-103.status" - printf 'done: report ready at data/lavish-103/report.md\n' >> "$home/state/lavish-103.status" + printf 'done: report=data/lavish-103/report.md - report ready\n' >> "$home/state/lavish-103.status" # Completed report whose PROSE reads like the decision. printf '# Lavish 103\nThe open question is whether to adopt approach A or B.\nThis needs a captain decision. Recommendation: A.\n' > "$home/data/lavish-103/report.md" + # The terminal claim is established, so the reader reports plain `done` here + # and this case keeps testing decisions rather than claim verification. + FM_HOME="$home" FM_STATE_OVERRIDE="$home/state" FM_DATA_OVERRIDE="$home/data" \ + "$ROOT/bin/fm-verify-done.sh" lavish-103 >/dev/null \ + || fail "the scout claim fixture did not verify against its own report" fakebin=$(make_fakebin "$home") out=$(PATH="$fakebin:$PATH" FM_HOME="$home" "$SNAPSHOT" --json) printf '%s' "$out" | jq -e ' diff --git a/tests/fm-gotmp.test.sh b/tests/fm-gotmp.test.sh index 43b15c6e818..da14ec05e68 100755 --- a/tests/fm-gotmp.test.sh +++ b/tests/fm-gotmp.test.sh @@ -68,6 +68,7 @@ SH # ownership are sourced by teardown. ln -s "$ROOT/bin/fm-control-lib.sh" "$fake/bin/fm-control-lib.sh" ln -s "$ROOT/bin/fm-classify-lib.sh" "$fake/bin/fm-classify-lib.sh" + ln -s "$ROOT/bin/fm-done-claim-lib.sh" "$fake/bin/fm-done-claim-lib.sh" # fm-timeout-lib.sh: the shared hard bound fm-classify-lib.sh sources for the # wedge detector's bounded worktree write probe. ln -s "$ROOT/bin/fm-timeout-lib.sh" "$fake/bin/fm-timeout-lib.sh" @@ -166,6 +167,7 @@ SH ln -s "$ROOT/bin/fm-lease-lib.sh" "$fake/bin/fm-lease-lib.sh" ln -s "$ROOT/bin/fm-control-lib.sh" "$fake/bin/fm-control-lib.sh" ln -s "$ROOT/bin/fm-classify-lib.sh" "$fake/bin/fm-classify-lib.sh" + ln -s "$ROOT/bin/fm-done-claim-lib.sh" "$fake/bin/fm-done-claim-lib.sh" # fm-timeout-lib.sh: the shared hard bound fm-classify-lib.sh sources for the # wedge detector's bounded worktree write probe. ln -s "$ROOT/bin/fm-timeout-lib.sh" "$fake/bin/fm-timeout-lib.sh" diff --git a/tests/fm-pr-check-security.test.sh b/tests/fm-pr-check-security.test.sh index e5e3eb09ede..c027f2fb9ec 100755 --- a/tests/fm-pr-check-security.test.sh +++ b/tests/fm-pr-check-security.test.sh @@ -687,15 +687,22 @@ test_static_poll_contract() { dir=$(make_case poll-contract) make_poll_fixture "$dir" - for state in OPEN CLOSED EMPTY MALFORMED; do + # The poll reports the two TERMINAL states and nothing else. A CLOSED pull + # request is reported because it contradicts a task record that already claims + # done; every unreadable or unrecognised answer stays silent, so neither + # outcome can be inferred from a failed lookup. + for state in OPEN EMPTY MALFORMED; do case "$state" in EMPTY) value= ;; MALFORMED) value='not-a-state' ;; *) value=$state ;; esac out=$(FM_TEST_GH_STATE="$value" run_poll "$dir") - [ -z "$out" ] || fail "static poll emitted for non-merged state" + [ -z "$out" ] || fail "static poll emitted for a non-terminal state" done + out=$(FM_TEST_GH_STATE=CLOSED run_poll "$dir") + [ "$out" = closed-unmerged ] \ + || fail "static poll did not report a pull request closed without merging" out=$(FM_TEST_GH_STATE=MERGED run_poll "$dir") [ "$out" = merged ] || fail "static poll did not emit exactly one merged line" out=$(FM_TEST_GH_FAIL=1 run_poll "$dir") @@ -1296,12 +1303,17 @@ gitlab.example group/subgroup/project 7" ] || fail "published GitLab sidecar bytes were not exact" - # Only an exact merged state wakes firstmate. Every other reading, including - # an unreadable merge request and a changed output format, stays silent. - for value in opened closed locked '' not-a-state MERGED merged-but-not; do + # Only the two exact terminal states wake firstmate. Every other reading, + # including an unreadable merge request and a changed output format, stays + # silent - "closed" must match exactly, so neither "locked" nor a case variant + # can be read as a merge request that was abandoned. + for value in opened locked '' not-a-state MERGED CLOSED closed-ish merged-but-not; do out=$(FM_TEST_GLAB_STATE="$value" run_poll "$dir") - [ -z "$out" ] || fail "GitLab poll emitted for a non-merged state" + [ -z "$out" ] || fail "GitLab poll emitted for a non-terminal state: $value" done + out=$(FM_TEST_GLAB_STATE=closed run_poll "$dir") + [ "$out" = closed-unmerged ] \ + || fail "GitLab poll did not report a merge request closed without merging" out=$(FM_TEST_GLAB_STATE=merged run_poll "$dir") [ "$out" = merged ] || fail "GitLab poll did not emit exactly one merged line" out=$(FM_TEST_GLAB_FAIL=1 run_poll "$dir") @@ -1920,16 +1932,13 @@ test_external_merge_transition_retires_only_terminal_poll() { add_stop_custom_check "$dir" before=$(poll_artifact_snapshot "$state" task-a) - for label in open-green open-red closed-unmerged forge-error malformed; do + for label in open-green open-red forge-error malformed; do rm -f "$state/.last-check" set +e case "$label" in open-green|open-red) FM_TEST_GH_STATE=OPEN run_watcher_bounded "$dir/home" "$dir/fakebin" > "$dir/$label.out" 2> "$dir/$label.err" ;; - closed-unmerged) - FM_TEST_GH_STATE=CLOSED run_watcher_bounded "$dir/home" "$dir/fakebin" > "$dir/$label.out" 2> "$dir/$label.err" - ;; forge-error) FM_TEST_GH_FAIL=1 run_watcher_bounded "$dir/home" "$dir/fakebin" > "$dir/$label.out" 2> "$dir/$label.err" ;; @@ -1945,6 +1954,38 @@ test_external_merge_transition_retires_only_terminal_poll() { ack_watcher_cycle "$state" || fail "$label control wake acknowledgement failed" done + # A pull request CLOSED without merging is not silence: it contradicts a task + # record that already claims done, so it wakes. It is also not terminal for + # the poll, because the pull request can still be reopened and merged, so the + # armed poll is left exactly as it was and the contradiction is reported once. + rm -f "$state/.last-check" + set +e + FM_TEST_GH_STATE=CLOSED run_watcher_bounded "$dir/home" "$dir/fakebin" \ + > "$dir/closed-unmerged.out" 2> "$dir/closed-unmerged.err" + rc=$? + set -e + [ "$rc" -eq 0 ] || fail "closed-unmerged watcher cycle failed: $(cat "$dir/closed-unmerged.err")" + case "$(cat "$dir/closed-unmerged.out")" in + check:*task-a.check.sh:*closed-unmerged) ;; + *) fail "a pull request closed without merging did not wake: $(cat "$dir/closed-unmerged.out")" ;; + esac + [ "$(poll_artifact_snapshot "$state" task-a)" = "$before" ] \ + || fail "a close without merge retired or altered the armed poll" + ack_watcher_cycle "$state" || fail "closed-unmerged wake acknowledgement failed" + + rm -f "$state/.last-check" + set +e + FM_TEST_GH_STATE=CLOSED run_watcher_bounded "$dir/home" "$dir/fakebin" \ + > "$dir/closed-again.out" 2> "$dir/closed-again.err" + rc=$? + set -e + [ "$rc" -eq 0 ] || fail "second closed-unmerged cycle failed: $(cat "$dir/closed-again.err")" + case "$(cat "$dir/closed-again.out")" in + check:*z-stop.check.sh:*stop-cycle) ;; + *) fail "the same close was reported twice: $(cat "$dir/closed-again.out")" ;; + esac + ack_watcher_cycle "$state" || fail "second closed-unmerged control wake acknowledgement failed" + rm -f "$state/z-stop.check.sh" "$state/z-stop.check-trust" "$state/.last-check" set +e FM_TEST_GH_STATE=MERGED run_watcher_bounded "$dir/home" "$dir/fakebin" > "$dir/merged.out" 2> "$dir/merged.err" @@ -1953,7 +1994,7 @@ test_external_merge_transition_retires_only_terminal_poll() { [ "$rc" -eq 0 ] || fail "external merged transition failed: $(cat "$dir/merged.err")" case "$(cat "$dir/merged.out")" in check:*task-a.check.sh:*merged) ;; *) fail "external merge did not preserve its notification" ;; esac assert_poll_absent "$state" task-a - pass "open/red, closed-unmerged, malformed, and forge errors remain armed until an exact merged transition" + pass "open/red, malformed, and forge errors stay silent, a close wakes once and stays armed, and only a merge retires the poll" } test_retirement_refuses_replacement_and_nonterminal_results() { diff --git a/tests/fm-session-start.test.sh b/tests/fm-session-start.test.sh index 75fb3ce00be..96da36e5626 100755 --- a/tests/fm-session-start.test.sh +++ b/tests/fm-session-start.test.sh @@ -1139,7 +1139,7 @@ EOF } test_orphan_status_logs_are_printed() { - local rec root home fakebin out matched_count orphan_count + local rec root home fakebin out matched_count orphan_count fleet_section rec=$(new_world orphan-status) IFS='|' read -r root home fakebin < record remote PR head # (q) no-mistakes + NO pr= recorded, PR discovered by branch -> ALLOW (yolo/no-CI merge) # +# Also covers the terminal-claim gate: cleanup is the point of no return for a +# `done:` claim, so a claim the verifier could not establish, or established as +# false, must refuse alongside the landed-work gates above. +# (z1) no terminal claim at all -> ALLOW (nothing asserted) +# (z2) legacy claim with no commit identity -> REFUSE (unverified) +# (z3) claim naming a commit that is not the branch -> REFUSE (contradicted) +# (z4) claim naming the branch tip -> ALLOW (verified) +# (z5) contradicted claim + --force -> ALLOW (escape hatch) +# # Also covers backlog teardown-lock-race: a git index.lock left in the worktree by a # killed crew process (bin/fm-teardown.sh's teardown_treehouse_return). # (r) provably-stale index.lock (old mtime, no live holder) -> lock removed, ALLOW @@ -1396,14 +1405,14 @@ test_secondmate_home_teardown_delivers_final_line_or_refuses() { wt_commit "$case_dir" "merged work" wt_head=$(git -C "$case_dir/wt" rev-parse HEAD) git -C "$case_dir/project" update-ref refs/heads/main "$wt_head" - printf 'working: shipping\ndone: PR https://github.com/example/repo/pull/9 checks green\n' \ + printf 'working: shipping\ndone: branch=fm/task-x1 head=%s - merged work\n' "$wt_head" \ > "$case_dir/state/task-x1.status" set +e FM_HOME="$case_dir/home" run_teardown "$case_dir" > "$case_dir/stdout" 2> "$case_dir/stderr" rc=$? set -e expect_code 0 "$rc" "mate-teardown-delivers: teardown should succeed: $(cat "$case_dir/stderr")" - grep -Eq '^done \[key=child-outcome-task-x1-done-[0-9a-f]{8}\]: child task-x1 done: PR https://github.com/example/repo/pull/9 checks green pr=https://github.com/example/repo/pull/9 mode=local-only$' "$channel" \ + grep -Eq "^done \[key=child-outcome-task-x1-done-[0-9a-f]{8}\]: child task-x1 done: branch=fm/task-x1 head=$wt_head - merged work mode=local-only\$" "$channel" \ || fail "mate-teardown-delivers: the final ledger line did not reach the parent: $(cat "$channel" 2>/dev/null)" [ ! -e "$case_dir/state/task-x1.meta" ] || fail "mate-teardown-delivers: teardown left the task record" @@ -1420,7 +1429,7 @@ test_secondmate_home_teardown_delivers_final_line_or_refuses() { wt_commit "$case_dir" "merged work" wt_head=$(git -C "$case_dir/wt" rev-parse HEAD) git -C "$case_dir/project" update-ref refs/heads/main "$wt_head" - printf 'done: PR https://github.com/example/repo/pull/9 checks green\n' > "$case_dir/state/task-x1.status" + printf 'done: branch=fm/task-x1 head=%s - merged work\n' "$wt_head" > "$case_dir/state/task-x1.status" set +e FM_HOME="$case_dir/home" run_teardown "$case_dir" > "$case_dir/stdout" 2> "$case_dir/stderr" rc=$? @@ -1446,7 +1455,7 @@ test_secondmate_home_teardown_delivers_final_line_or_refuses() { rc=$? set -e expect_code 0 "$rc" "mate-teardown-refuses: rerun after repair should succeed: $(cat "$case_dir/stderr2")" - grep -Eq '^done \[key=child-outcome-task-x1-done-[0-9a-f]{8}\]: child task-x1 done: PR https://github.com/example/repo/pull/9 checks green' "$channel" \ + grep -Eq "^done \[key=child-outcome-task-x1-done-[0-9a-f]{8}\]: child task-x1 done: branch=fm/task-x1 head=$wt_head - merged work" "$channel" \ || fail "mate-teardown-refuses: the rerun did not deliver the final line" [ ! -e "$case_dir/state/task-x1.meta" ] || fail "mate-teardown-refuses: rerun left the task record" pass "a secondmate home's teardown delivers the child's final line or refuses until it can" @@ -2738,6 +2747,111 @@ EOF pass "the run abort and the leaked-process reap both complete before the destructive worktree return" } +# --- terminal-claim gate ---------------------------------------------------- +# A claim is the worker's assertion; cleanup destroys the evidence that could +# ever check it. These pin that the gate refuses what it could not establish and +# what it established as false, and lets an established claim through. + +# Build a landed local-only case whose status log carries . Echoes the +# case dir. The caller reads the branch head back with `git rev-parse` rather +# than through a global: this runs inside a command substitution, so anything it +# sets in its own shell dies with that subshell (tests/lib.sh documents the same +# trap for fm_test_tmproot). +make_claim_case() { # + local name=$1 claim=$2 case_dir head + case_dir=$(make_case "$name") + write_meta "$case_dir" local-only ship + wt_commit "$case_dir" "claim work" + head=$(git -C "$case_dir/wt" rev-parse HEAD) + git -C "$case_dir/project" update-ref refs/heads/main "$head" + if [ -n "$claim" ]; then + printf '%s\n' "$claim" > "$case_dir/state/task-x1.status" + else + : > "$case_dir/state/task-x1.status" + fi + printf '%s\n' "$case_dir" +} + +test_no_terminal_claim_is_unaffected_by_the_gate() { + local case_dir rc=0 + case_dir=$(make_claim_case claim-none "") + set +e + run_teardown "$case_dir" > "$case_dir/stdout" 2> "$case_dir/stderr" + rc=$? + set -e + expect_code 0 "$rc" "a task that never claimed done must not be gated: $(cat "$case_dir/stderr")" + pass "a task with no terminal claim is unaffected by the claim gate" +} + +test_legacy_claim_without_commit_identity_refuses() { + local case_dir rc=0 + case_dir=$(make_claim_case claim-legacy "done: ready in branch fm/task-x1") + set +e + run_teardown "$case_dir" > "$case_dir/stdout" 2> "$case_dir/stderr" + rc=$? + set -e + [ "$rc" -ne 0 ] || fail "a legacy claim with no commit identity was torn down as done" + assert_grep "could not be established" "$case_dir/stderr" \ + "the refusal did not say the claim could not be established" + assert_grep "legacy claim, no commit identity" "$case_dir/stderr" \ + "the refusal did not name the missing commit identity" + [ -e "$case_dir/state/task-x1.meta" ] \ + || fail "the claim refusal erased the durable task record" + pass "a legacy claim with no commit identity refuses cleanup" +} + +test_claim_naming_the_wrong_commit_refuses() { + local case_dir rc=0 other + case_dir=$(make_claim_case claim-wrong "placeholder") + other=$(git -C "$case_dir/wt" rev-parse 'HEAD~1') + printf 'done: branch=fm/task-x1 head=%s - shipped\n' "$other" \ + > "$case_dir/state/task-x1.status" + set +e + run_teardown "$case_dir" > "$case_dir/stdout" 2> "$case_dir/stderr" + rc=$? + set -e + [ "$rc" -ne 0 ] || fail "a claim naming a commit that is not the branch tip was torn down as done" + assert_grep "contradicted" "$case_dir/stderr" \ + "the refusal did not report the claim as contradicted" + [ -e "$case_dir/state/task-x1.meta" ] \ + || fail "the contradiction refusal erased the durable task record" + pass "a claim naming the wrong commit refuses cleanup" +} + +test_established_claim_allows_teardown() { + local case_dir rc=0 head + case_dir=$(make_claim_case claim-good "placeholder") + head=$(git -C "$case_dir/wt" rev-parse HEAD) + printf 'done: branch=fm/task-x1 head=%s - shipped\n' "$head" \ + > "$case_dir/state/task-x1.status" + set +e + run_teardown "$case_dir" > "$case_dir/stdout" 2> "$case_dir/stderr" + rc=$? + set -e + expect_code 0 "$rc" "an established claim was refused: $(cat "$case_dir/stderr")" + pass "a claim naming the branch tip is established and allows cleanup" +} + +test_force_overrides_a_contradicted_claim() { + local case_dir rc=0 other + case_dir=$(make_claim_case claim-force "placeholder") + other=$(git -C "$case_dir/wt" rev-parse 'HEAD~1') + printf 'done: branch=fm/task-x1 head=%s - shipped\n' "$other" \ + > "$case_dir/state/task-x1.status" + set +e + run_teardown "$case_dir" --force > "$case_dir/stdout" 2> "$case_dir/stderr" + rc=$? + set -e + expect_code 0 "$rc" "--force did not override a contradicted claim: $(cat "$case_dir/stderr")" + pass "--force remains the explicit escape from a contradicted claim" +} + + +test_no_terminal_claim_is_unaffected_by_the_gate +test_legacy_claim_without_commit_identity_refuses +test_claim_naming_the_wrong_commit_refuses +test_established_claim_allows_teardown +test_force_overrides_a_contradicted_claim test_local_only_fork_remote_allows test_teardown_closes_the_backlog_item_itself test_teardown_manual_backend_leaves_the_backlog_to_the_operator From d0acebfb84c9d2741c901b67007b81225924f970 Mon Sep 17 00:00:00 2001 From: Israel Wilson Date: Thu, 3 Sep 2026 12:14:38 -0400 Subject: [PATCH 02/21] no-mistakes(review): fix(bin): close done-claim verification holes found in review --- bin/fm-classify-lib.sh | 18 +- bin/fm-done-claim-lib.sh | 24 ++ bin/fm-merge-outcome-lib.sh | 38 ++- bin/fm-verify-done.sh | 56 ++++- bin/fm-wake-drain.sh | 8 +- tests/fm-captain-hold-lifecycle.test.sh | 7 +- tests/fm-classify-corr-token.test.sh | 10 +- tests/fm-done-verified.test.sh | 314 +++++++++++++++++++++++- 8 files changed, 444 insertions(+), 31 deletions(-) diff --git a/bin/fm-classify-lib.sh b/bin/fm-classify-lib.sh index 4826e3b30f4..9c7fefce55d 100755 --- a/bin/fm-classify-lib.sh +++ b/bin/fm-classify-lib.sh @@ -126,11 +126,18 @@ status_is_terminal_verb() { esac } -# The complete set of status-line verbs this fleet recognises. Three of them are -# overridable constants, so the set is assembled from those owners rather than -# re-listed as literals. `done-unverified` is deliberately absent: it is a -# current-STATE token bin/fm-crew-state.sh reports, never a verb anything writes -# into a status file. +# 0 when a status line carries a state this fleet recognises. The built-in verb +# set comes first; three of its members are overridable constants, so it is +# assembled from those owners rather than re-listed as literals. `done-unverified` +# is deliberately absent: it is a current-STATE token bin/fm-crew-state.sh +# reports, never a verb anything writes into a status file. +# +# Beyond that set, whatever status_is_captain_relevant recognises is recognised +# here too, because that function is the one owner of the configurable +# vocabulary: FM_CAPTAIN_RE overrides the whole captain-relevant set, and its +# default also matches legacy free-text lines ("merged", "PR ready"). A home +# that configures its own verbs, or a legacy bare line the fleet already routes +# as a real state, must not then be labelled as matching no status verb. status_line_verb_is_known() { # local verb verb=$(status_line_verb "${1:-}") @@ -140,6 +147,7 @@ status_line_verb_is_known() { # [ "$verb" = "${FM_CLASSIFY_PAUSED_VERB:-$FM_CLASSIFY_PAUSED_VERB_DEFAULT}" ] && return 0 [ "$verb" = "${FM_CLASSIFY_RESOLVE_VERB:-$FM_CLASSIFY_RESOLVE_VERB_DEFAULT}" ] && return 0 [ "$verb" = "${FM_CLASSIFY_CAPTAIN_HELD_VERB:-$FM_CLASSIFY_CAPTAIN_HELD_VERB_DEFAULT}" ] && return 0 + status_is_captain_relevant "${1:-}" && return 0 return 1 } diff --git a/bin/fm-done-claim-lib.sh b/bin/fm-done-claim-lib.sh index 07e1a5030e5..6d82c473bf6 100644 --- a/bin/fm-done-claim-lib.sh +++ b/bin/fm-done-claim-lib.sh @@ -170,12 +170,36 @@ fm_done_reason_clean() { # printf '%s' "${1:-}" | LC_ALL=C tr '\n\t\r' ' ' | cut -c1-400 } +# 0 when a durable record already establishes THIS exact claim as verified. Run +# in a subshell so reading it does not clobber a caller's FM_DONE_VERDICT* view. +_fm_done_verdict_already_verified() { # + ( + fm_done_verdict_read "$1" "$2" || exit 1 + [ "$FM_DONE_VERDICT" = verified ] || exit 1 + [ "$FM_DONE_VERDICT_CLAIM_HASH" = "$3" ] || exit 1 + ) +} + +# Write the durable verdict record, with one refusal: an `unverified` verdict +# never overwrites a record that already establishes the SAME claim as +# `verified`. Unverified is only the absence of evidence - a forge that could +# not be reached, a tool missing from PATH - so letting it overwrite would let a +# transient outage un-establish work that was genuinely established. A +# `contradicted` verdict still overwrites, because that is positive evidence of +# falsity. The refusal protects only the DURABLE record: the run that observed +# the transient unverified result still prints and exits with it (see finish() +# in bin/fm-verify-done.sh), so nothing hides the outage from its caller. +# Returns 0 for that refusal, because the record already holds a stronger +# verdict for this claim and there is nothing left to record. fm_done_verdict_write() { # local state=$1 id=$2 verdict=$3 hash=$4 reason=$5 path tmp case "$verdict" in verified|unverified|contradicted) ;; *) return 2 ;; esac case "$hash" in *[!0-9a-f]*|'') return 2 ;; esac [ "${#hash}" -eq 64 ] || return 2 [ -d "$state" ] && [ ! -L "$state" ] || return 1 + if [ "$verdict" = unverified ] && _fm_done_verdict_already_verified "$state" "$id" "$hash"; then + return 0 + fi path=$(fm_done_verdict_path "$state" "$id") [ ! -L "$path" ] || return 1 umask 077 diff --git a/bin/fm-merge-outcome-lib.sh b/bin/fm-merge-outcome-lib.sh index 78183f6d083..915d674adf2 100755 --- a/bin/fm-merge-outcome-lib.sh +++ b/bin/fm-merge-outcome-lib.sh @@ -4,9 +4,13 @@ # Two outcomes travel this one path, because both change whether a task's own # `done:` record is still true: # merged the PR landed. -# closed-unmerged the PR was closed WITHOUT merging, which CONTRADICTS a done -# record written when it was still open. A done record that -# silently stops being true is the failure this carries. +# closed-unmerged the PR was closed WITHOUT merging. When the task holds a +# terminal `done:` record, that record has silently stopped +# being true, and reporting the contradiction is the failure +# this carries. The poll is armed at PR registration, well +# before any terminal claim, so this outcome is also reached +# with no claim on record; the publication then reports the +# close plainly rather than inventing a claim to contradict. # # Both a merge performed by this home and either outcome detected by its existing # poll use this operation, so neither depends on an agent remembering it. @@ -39,6 +43,8 @@ _FM_MERGE_OUTCOME_LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" . "$_FM_MERGE_OUTCOME_LIB_DIR/fm-pr-lib.sh" # shellcheck source=bin/fm-parent-channel-lib.sh . "$_FM_MERGE_OUTCOME_LIB_DIR/fm-parent-channel-lib.sh" +# shellcheck source=bin/fm-done-claim-lib.sh +. "$_FM_MERGE_OUTCOME_LIB_DIR/fm-done-claim-lib.sh" # shellcheck disable=SC2034 # Public result consumed by sourcing callers. FM_MERGE_OUTCOME_ALREADY_RECORDED=false @@ -60,8 +66,8 @@ FM_MERGE_OUTCOME_ALREADY_RECORDED=false # than treat it as success: the merge landed and the record did not. fm_merge_outcome_report() { # [] local home=$1 state=$2 id=$3 url=$4 origin=$5 outcome=${6:-merged} - local self_rc=0 destination='' line lock status=0 wake_note - local provider host path number + local self_rc=0 destination='' line lock status=0 wake_note claimed=0 + local provider host path number claim_state='' # shellcheck disable=SC2034 # Sourced wake helpers consume these scoped globals. local STATE FM_WAKE_QUEUE FM_WAKE_QUEUE_LOCK FM_MERGE_OUTCOME_ALREADY_RECORDED=false @@ -78,16 +84,34 @@ fm_merge_outcome_report() { # [/dev/null 2>&1 \ + && printf '%s' "$FM_DONE_CLAIM_STATE") + # An unreadable claim state is treated as no claim: the weaker wording is + # true either way, while the stronger one would not be. + case "$claim_state" in ''|none) ;; *) claimed=1 ;; esac + fi if [ "$outcome" = merged ]; then wake_note="check: merge landed: $id $FM_PR_URL" - else + elif [ "$claimed" -eq 1 ]; then wake_note="check: PR closed without merging, contradicting the done record: $id $FM_PR_URL" + else + wake_note="check: PR closed without merging: $id $FM_PR_URL" fi if destination=$(fm_parent_channel_destination "$home" "$state"); then if [ "$outcome" = merged ]; then line="done [key=merged-$id]: merged $id $FM_PR_URL" - else + elif [ "$claimed" -eq 1 ]; then line="blocked [key=closed-unmerged-$id]: $id claims done but $FM_PR_URL was closed without merging" + else + line="blocked [key=closed-unmerged-$id]: $id had its PR $FM_PR_URL closed without merging" fi else self_rc=$? diff --git a/bin/fm-verify-done.sh b/bin/fm-verify-done.sh index 356d5a49f33..1341cb86a56 100755 --- a/bin/fm-verify-done.sh +++ b/bin/fm-verify-done.sh @@ -21,13 +21,24 @@ # contradicted for having red or absent checks, because merge authority, # not this script, owns that decision # local-only +# - the claim names this task's own branch, exactly fm/ # - the claimed head= resolves in the task's local copy and is the tip of -# the claimed branch=, or - once that branch has been retired after the -# work merged - is contained in the local default branch +# that branch, or - once the branch has been retired after the work +# merged - is BOTH the local copy's own HEAD and contained in the local +# default branch. Bare containment is never enough: every commit already +# on the default branch is its own ancestor, so containment alone would +# pass any claim naming the default branch's tip. # scout # - the claimed report= exists as a non-empty regular file. An absolute path # is used as given (what bin/fm-brief.sh renders into a scout brief); a -# relative one is resolved against the home. +# relative one is resolved against the home's data root, which is where +# every scout report lives, so a relocated FM_DATA_OVERRIDE resolves the +# same claim as an unrelocated one. A leading `data/` is the home-relative +# spelling of that root and names it rather than a directory beneath it. +# +# A task whose meta records no mode= reads as no-mistakes, the same default +# bin/fm-teardown.sh applies, so a task spawned before mode= was recorded cannot +# skip the validated-commit check below. # # Verdicts, written to state/.done-verdict and printed on stdout: # verified exit 0 @@ -45,6 +56,7 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd "$SCRIPT_DIR/.." && pwd)}" FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}" +DATA="${FM_DATA_OVERRIDE:-$FM_HOME/data}" # shellcheck source=bin/fm-classify-lib.sh . "$SCRIPT_DIR/fm-classify-lib.sh" @@ -62,7 +74,7 @@ ID= while [ "$#" -gt 0 ]; do case "$1" in --quiet) QUIET=1 ;; - -h|--help) sed -n '2,40p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; + -h|--help) sed -n '2,51p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; -*) echo "fm-verify-done: unknown option $1" >&2; exit 2 ;; *) [ -z "$ID" ] || { echo "fm-verify-done: one task id" >&2; exit 2; }; ID=$1 ;; esac @@ -121,7 +133,10 @@ FM_DONE_CLAIM_LINE=$CLAIM fm_done_claim_parse "$CLAIM" || { echo "fm-verify-done: $ID has no terminal claim to verify" >&2; exit 2; } KIND=$(meta_field kind); [ -n "$KIND" ] || KIND=ship -MODE=$(meta_field mode) +# An absent mode= is the legacy ship population, which bin/fm-teardown.sh also +# reads as no-mistakes. Two owners of the same meta must not disagree about it, +# and reading it as "some other mode" would skip the validated-commit check. +MODE=$(meta_field mode); [ -n "$MODE" ] || MODE=no-mistakes WT=$(meta_field worktree) if ! fm_done_claim_has_identity; then @@ -138,7 +153,8 @@ if [ "$KIND" = scout ]; then fi case "$REPORT" in /*) ;; - *) REPORT="$FM_HOME/$REPORT" ;; + data/*) REPORT="$DATA/${REPORT#data/}" ;; + *) REPORT="$DATA/$REPORT" ;; esac if [ -f "$REPORT" ] && [ ! -L "$REPORT" ] && [ -s "$REPORT" ]; then verdict_is verified "report present at $FM_DONE_CLAIM_REPORT" @@ -161,6 +177,13 @@ if [ "$MODE" = local-only ]; then verdict_is contradicted 'local-only claim names no branch' finish fi + # A local-only task's branch is not a free field: bin/fm-brief.sh renders the + # claim template with this task's own fm/. A claim naming anything else is + # not this task's work, however true it may be of some other branch. + if [ "$BRANCH" != "fm/$ID" ]; then + verdict_is contradicted "claim names branch $BRANCH, not this task's fm/$ID" + finish + fi if [ -z "$WT" ] || [ ! -d "$WT" ]; then verdict_is unverified 'the local copy is gone, so the claimed commit cannot be established' finish @@ -179,9 +202,22 @@ if [ "$MODE" = local-only ]; then finish fi # The branch is gone. That is the normal end state once local-only work has - # been merged and its branch retired, so containment in the default branch is - # the same "it landed" fact the branch tip was standing in for. Only when - # there is no default branch to compare against is this genuinely unknown. + # been merged and its branch retired, but containment in the default branch + # cannot stand in for the branch tip on its own: a commit is its own ancestor, + # so every commit already on the default branch satisfies containment, and a + # worker that committed nothing could pass by naming the default branch's tip. + # The task's own local copy supplies the missing evidence. A worker that + # produced the claimed commit left its worktree sitting on it; one that + # produced nothing left its worktree at the spawn base. + WT_HEAD=$(git -C "$WT" rev-parse --verify --quiet HEAD 2>/dev/null || true) + if [ -z "$WT_HEAD" ]; then + verdict_is unverified "branch $BRANCH is gone and the local copy's own HEAD could not be read, so the claimed $HEAD_CLAIM cannot be established" + finish + fi + if [ "$WT_HEAD" != "$HEAD_CLAIM" ]; then + verdict_is unverified "branch $BRANCH is gone and the local copy's own HEAD is $WT_HEAD, not the claimed $HEAD_CLAIM, so nothing establishes that this task produced it" + finish + fi DEFAULT=$(fm_default_branch "$WT" 2>/dev/null || true) if [ -z "$DEFAULT" ] \ || ! git -C "$WT" rev-parse --verify --quiet "refs/heads/$DEFAULT^{commit}" >/dev/null 2>&1; then @@ -189,7 +225,7 @@ if [ "$MODE" = local-only ]; then finish fi if git -C "$WT" merge-base --is-ancestor "$HEAD_CLAIM" "refs/heads/$DEFAULT" 2>/dev/null; then - verdict_is verified "claimed $HEAD_CLAIM is on $DEFAULT; its branch $BRANCH has been retired" + verdict_is verified "the local copy is at the claimed $HEAD_CLAIM and it is on $DEFAULT; its branch $BRANCH has been retired" finish fi verdict_is contradicted "branch $BRANCH is gone and the claimed $HEAD_CLAIM is not on $DEFAULT" diff --git a/bin/fm-wake-drain.sh b/bin/fm-wake-drain.sh index 446e042c530..e49384c1d4f 100755 --- a/bin/fm-wake-drain.sh +++ b/bin/fm-wake-drain.sh @@ -365,6 +365,7 @@ case "$UNRECOGNISED_STATUS_MAX" in ''|*[!0-9]*) UNRECOGNISED_STATUS_MAX=10 ;; es # when nothing is unread, which is the common case. print_unread_status_section() { local snapshot=${1:-} unread task line shown=0 unrecognised=0 omitted=0 + local header='UNREAD STATUS (new since last drain, not re-printed after this presentation):' if [ -n "$snapshot" ]; then unread=$(scan_unread_surface_snapshot "$STATE" "$snapshot") || return 1 @@ -388,7 +389,7 @@ print_unread_status_section() { line="$task $line" fi if [ "$shown" -eq 0 ]; then - printf 'UNREAD STATUS (new since last drain, not re-printed after this presentation):\n' || return 1 + printf '%s\n' "$header" || return 1 fi printf '%s\n' "$line" || return 1 shown=$((shown + 1)) @@ -396,7 +397,12 @@ print_unread_status_section() { $unread EOF + # The count is part of the section, never a bare line on its own: with the cap + # set to 0 and only unrecognised lines unread, the loop above prints no header. if [ "$omitted" -gt 0 ]; then + if [ "$shown" -eq 0 ]; then + printf '%s\n' "$header" || return 1 + fi printf 'UNREAD STATUS: %d more unrecognised line(s) omitted; read them in the task status log.\n' \ "$omitted" || return 1 fi diff --git a/tests/fm-captain-hold-lifecycle.test.sh b/tests/fm-captain-hold-lifecycle.test.sh index 636f13b9747..7b24920402f 100755 --- a/tests/fm-captain-hold-lifecycle.test.sh +++ b/tests/fm-captain-hold-lifecycle.test.sh @@ -1447,9 +1447,10 @@ EOF --repo sample --start --file "$data/backlog.md" >/dev/null) \ || fail "could not create the relocated captain-hold fixture" write_origin_meta "$home" "$id" - # This home's data root is relocated, so the claim names the report by its - # real path rather than the usual home-relative one. - printf 'done: report=%s/%s/report.md - report complete\n' "$data" "$id" > "$home/state/$id.status" + # This home's data root is relocated, and the claim still uses the ordinary + # home-relative form: a relative report= resolves against the configured data + # root, so the same claim reads the same file wherever that root sits. + printf 'done: report=data/%s/report.md - report complete\n' "$id" > "$home/state/$id.status" printf '# Relocated hold\n\nThe captain call remains open.\n' > "$data/$id/report.md" PATH="$home/fakebin:$PATH" FM_HOME="$home" FM_STATE_OVERRIDE="$home/state" \ FM_DATA_OVERRIDE="$data" FM_CONFIG_OVERRIDE="$home/config" \ diff --git a/tests/fm-classify-corr-token.test.sh b/tests/fm-classify-corr-token.test.sh index 25a10c806a7..bc855efbfb9 100755 --- a/tests/fm-classify-corr-token.test.sh +++ b/tests/fm-classify-corr-token.test.sh @@ -40,7 +40,15 @@ drain_open() { # local state=$1 out=$2 FM_STATE_OVERRIDE="$state" "$DRAIN" > "$out" || fail "drain failed over $state" grep -F 'OPEN DECISIONS' "$out" >/dev/null || return 0 - cat "$out" + # Only the OPEN DECISIONS section. This suite is about the decision fold, and + # the drain's other sections legitimately echo the same raw impostor lines - + # the unread-status surface flags each one as UNRECOGNISED - so matching the + # whole drain would read a merely-quoted line as a decision the fold opened. + awk ' + /^OPEN DECISIONS/ { inside = 1; print; next } + inside && /^[A-Z][A-Z0-9]+([ ][A-Z0-9]+)*[ (:]/ { inside = 0 } + inside { print } + ' "$out" } # --- the fold, both directions ---------------------------------------------- diff --git a/tests/fm-done-verified.test.sh b/tests/fm-done-verified.test.sh index 8a27b97ef2b..55e3ebe51a8 100755 --- a/tests/fm-done-verified.test.sh +++ b/tests/fm-done-verified.test.sh @@ -26,6 +26,19 @@ # does not suppress a later merge of the same PR # (l) the drain flags a status line carrying no recognised verb instead of # absorbing it silently +# (m) a fabricated local-only claim naming the default branch's tip, on a task +# whose own branch never existed, is not verified: a commit is its own +# ancestor, so containment alone proves nothing +# (n) a local-only claim naming some other branch -> contradicted +# (o) a transient unverified never downgrades an established record, while the +# run that observed it still reports it +# (p) a contradicted verdict still overwrites a verified record, so (o) is a +# downgrade guard and not a freeze +# (q) a task whose meta records no mode= still gets the validated-commit check +# (r) a relative scout report= resolves against a relocated data root +# (s) a close with no done claim on record does not invent one +# (t) a home's configured verb vocabulary is not labelled UNRECOGNISED +# (u) the omitted-unrecognised count is never printed without its header set -u # shellcheck source=tests/wake-helpers.sh @@ -36,6 +49,8 @@ set -u . "$ROOT/bin/fm-done-claim-lib.sh" # shellcheck source=/dev/null . "$ROOT/bin/fm-pr-lib.sh" +# shellcheck source=/dev/null +. "$ROOT/bin/fm-merge-outcome-lib.sh" VERIFY="$ROOT/bin/fm-verify-done.sh" POLL="$ROOT/bin/fm-pr-poll.sh" @@ -89,14 +104,35 @@ SH printf '%s\n' "$dir" } -# Run the verifier in and echo "\t". -verify() { # - local dir=$1 out rc=0 +# Run the verifier in and echo "\t". defaults to +# the home's own data dir; a case that relocates it passes its own. +verify() { # [data-root] + local dir=$1 data=${2:-$dir/data} out rc=0 out=$(PATH="$dir/fakebin:$PATH" FM_HOME="$dir" FM_STATE_OVERRIDE="$dir/state" \ - FM_DATA_OVERRIDE="$dir/data" "$VERIFY" task-v 2>&1) || rc=$? + FM_DATA_OVERRIDE="$data" "$VERIFY" task-v 2>&1) || rc=$? printf '%s\t%s\n' "$rc" "$out" } +# A local-only world shaped like a real spawn: a repo whose default branch keeps +# advancing, plus a LINKED worktree left where the task was spawned. The task's +# own fm/task-v branch is never created, which is the state a worker that +# committed nothing leaves behind. Prints the case dir. +make_local_world() { # + local name=$1 dir default + dir="$TMP_ROOT/$name" + mkdir -p "$dir/state" "$dir/data" "$dir/fakebin" + git -C "$dir" init -q repo + git -C "$dir/repo" commit -q --allow-empty -m base + default=$(git -C "$dir/repo" symbolic-ref --quiet --short HEAD) + git -C "$dir/repo" worktree add -q --detach "$dir/wt" HEAD + # The default branch moves on after the spawn, so its tip is a commit the task + # never produced - exactly the commit a fabricated claim would reach for. + git -C "$dir/repo" commit -q --allow-empty -m later + fm_write_meta "$dir/state/task-v.meta" \ + "window=fm:fm-task-v" "worktree=$dir/wt" "kind=ship" "mode=local-only" + printf '%s\t%s\n' "$dir" "$default" +} + nm_status() { # printf 'branch: %s\nhead: %s\nstatus: completed\noutcome: passed\n' "$1" "$2" } @@ -416,6 +452,266 @@ test_known_verbs_are_not_flagged_as_unrecognised() { pass "every known status verb stays recognised while prose does not" } +# --- (m)-(n) local-only: containment alone is not evidence -------------------- + +test_a_fabricated_local_only_claim_is_not_verified() { + local out dir default tip result + out=$(make_local_world local-fabricated) || fail "the local-only fixture failed" + dir=${out%%$'\t'*} + default=${out#*$'\t'} + tip=$(git -C "$dir/repo" rev-parse "refs/heads/$default") + # The worker committed nothing and fm/task-v never existed. It claims the + # default branch's tip, which IS contained in the default branch, so bare + # ancestry would pass a claim holding none of this task's work. + printf 'done: branch=fm/task-v head=%s - shipped\n' "$tip" > "$dir/state/task-v.status" + result=$(verify "$dir") + case "${result#*$'\t'}" in + verified:*) fail "a fabricated local-only claim naming the default branch tip was verified: $result" ;; + esac + [ "${result%%$'\t'*}" = 3 ] \ + || fail "a fabricated local-only claim did not exit unverified: $result" + case "$result" in *"local copy's own HEAD"*) ;; *) fail "the unverified reason did not name what could not be established: $result" ;; esac + pass "a fabricated local-only claim naming the default branch's tip is not verified" +} + +test_a_local_only_claim_naming_another_branch_is_contradicted() { + local out dir tip result + out=$(make_local_world local-other-branch) || fail "the local-only fixture failed" + dir=${out%%$'\t'*} + tip=$(git -C "$dir/wt" rev-parse HEAD) + git -C "$dir/repo" branch -q fm/some-other-task "$tip" + printf 'done: branch=fm/some-other-task head=%s - shipped\n' "$tip" \ + > "$dir/state/task-v.status" + result=$(verify "$dir") + [ "${result%%$'\t'*}" = 4 ] \ + || fail "a claim naming another task's branch did not exit contradicted: $result" + case "$result" in *"not this task's fm/task-v"*) ;; *) fail "the contradiction did not name the expected branch: $result" ;; esac + pass "a local-only claim naming a branch other than this task's is contradicted" +} + +test_a_local_only_claim_on_a_retired_branch_still_verifies() { + local out dir default landed result + out=$(make_local_world local-retired) || fail "the local-only fixture failed" + dir=${out%%$'\t'*} + default=${out#*$'\t'} + # The work landed on the default branch, and the task's own copy sits on the + # commit it produced. The fm/task-v branch itself has been retired. + git -C "$dir/repo" commit -q --allow-empty -m landed + landed=$(git -C "$dir/repo" rev-parse "refs/heads/$default") + git -C "$dir/wt" checkout -q --detach "$landed" + printf 'done: branch=fm/task-v head=%s - shipped\n' "$landed" > "$dir/state/task-v.status" + result=$(verify "$dir") + [ "${result%%$'\t'*}" = 0 ] \ + || fail "genuinely landed local-only work with a retired branch was not verified: $result" + pass "local-only work whose branch was retired after landing still verifies" +} + +# --- (o)-(p) the durable record resists a downgrade but not a contradiction --- + +# Establish the claim, then re-run the verifier against the same claim under the +# given fake-forge state. Echoes the re-run's own "\t" and leaves the +# durable record for the caller to read through fm_done_claim_status. +establish_then_rerun() { # + local name=$1 rerun=$2 dir shipped result + dir=$(make_world "$name") + shipped=$(git -C "$dir/wt" rev-parse HEAD) + printf 'done: pr=https://github.com/o/r/pull/7 head=%s - shipped\n' "$shipped" \ + > "$dir/state/task-v.status" + result=$(FAKE_GH_OUT="OPEN $shipped SUCCESS" \ + FAKE_NM_STATUS="$(nm_status fm/task-v "$shipped")" verify "$dir") + [ "${result%%$'\t'*}" = 0 ] || fail "setup error: the claim did not verify: $result" + result=$(FAKE_GH_OUT="$rerun" FAKE_NM_STATUS="$(nm_status fm/task-v "$shipped")" verify "$dir") + printf '%s\t%s\n' "$dir" "$result" +} + +test_a_transient_unverified_does_not_downgrade_an_established_record() { + local out dir result + # The forge is unreachable on the re-run, which is absence of evidence, not + # evidence of falsity. + out=$(establish_then_rerun downgrade-guard '') || fail "the downgrade-guard fixture failed" + dir=${out%%$'\t'*} + result=${out#*$'\t'} + [ "${result%%$'\t'*}" = 3 ] \ + || fail "the re-run hid the transient failure it actually observed: $result" + case "$result" in *unverified:*) ;; *) fail "the re-run did not report the unverified it observed: $result" ;; esac + fm_done_claim_status "$dir/state" task-v + [ "$FM_DONE_CLAIM_STATE" = verified ] \ + || fail "a transient forge outage downgraded an established record to $FM_DONE_CLAIM_STATE" + pass "a transient unverified is reported but never downgrades an established record" +} + +test_a_contradiction_still_overwrites_an_established_record() { + local out dir result other + other=00112233445566778899aabbccddeeff00112233 + # The forge answers, and it answers with a different head: the claim is now + # established false, so the protection above must not freeze the record. + out=$(establish_then_rerun contradiction-overwrites "OPEN $other SUCCESS") \ + || fail "the contradiction-overwrite fixture failed" + dir=${out%%$'\t'*} + result=${out#*$'\t'} + [ "${result%%$'\t'*}" = 4 ] || fail "the re-run did not contradict the claim: $result" + fm_done_claim_status "$dir/state" task-v + [ "$FM_DONE_CLAIM_STATE" = contradicted ] \ + || fail "a contradiction did not overwrite the verified record ($FM_DONE_CLAIM_STATE)" + pass "a contradiction still overwrites an established record, so the guard is not a freeze" +} + +# --- (q) an absent mode= does not skip the validated-commit check ------------- + +test_an_absent_mode_still_checks_the_validated_commit() { + local dir result shipped validated + dir=$(make_world absent-mode) + shipped=$(git -C "$dir/wt" rev-parse HEAD) + validated=$(git -C "$dir/wt" rev-parse 'HEAD~1') + # A legacy ship task: spawned before mode= was recorded at all. + fm_write_meta "$dir/state/task-v.meta" \ + "window=fm:fm-task-v" "worktree=$dir/wt" "kind=ship" + printf 'done: pr=https://github.com/o/r/pull/7 head=%s - shipped\n' "$shipped" \ + > "$dir/state/task-v.status" + result=$(FAKE_GH_OUT="OPEN $shipped SUCCESS" \ + FAKE_NM_STATUS="$(nm_status fm/task-v "$validated")" verify "$dir") + [ "${result%%$'\t'*}" = 4 ] \ + || fail "a task with no recorded mode skipped the validated-commit check: $result" + case "$result" in *"validation ran against"*) ;; *) fail "the contradiction did not name the validated commit: $result" ;; esac + pass "a task whose meta records no mode still gets the validated-commit check" +} + +# --- (r) a relative scout report= follows the configured data root ------------ + +test_a_relative_scout_report_follows_a_relocated_data_root() { + local dir result + dir=$(make_world scout-relocated scout scout) + mkdir -p "$dir/elsewhere/task-v" + printf 'findings\n' > "$dir/elsewhere/task-v/report.md" + # Nothing at the home-relative location, so a read that ignored the configured + # data root would find no report at all. + assert_absent "$dir/data/task-v/report.md" "setup error: the report exists at the unrelocated path" + printf 'done: report=data/task-v/report.md - found it\n' > "$dir/state/task-v.status" + result=$(verify "$dir" "$dir/elsewhere") + [ "${result%%$'\t'*}" = 0 ] \ + || fail "a relative scout report was not resolved against the relocated data root: $result" + pass "a relative scout report= resolves against the configured data root, relocated or not" +} + +# --- (s) a close does not invent a done claim -------------------------------- + +# Publish a closed-unmerged outcome in a main home (no parent binding, so the +# outcome routes to this home's durable wake queue) and echo the queue's text. +close_publication() { # + local name=$1 status_line=$2 dir state + dir="$TMP_ROOT/$name" + state="$dir/state" + mkdir -p "$state" + printf '%s\n' "$status_line" > "$state/task-v.status" + fm_merge_outcome_report "$dir" "$state" task-v https://github.com/o/r/pull/7 \ + poll closed-unmerged || fail "the close outcome could not be published for $name" + cat "$state/.wake-queue" +} + +# The same publication from a secondmate home, which reports upward on its +# parent channel instead. Echoes the line the parent actually received. +close_publication_upward() { # + local name=$1 status_line=$2 dir state parent + dir="$TMP_ROOT/$name" + state="$dir/state" + parent="$TMP_ROOT/$name-parent" + mkdir -p "$state" "$parent/state" + printf 'mate-%s\n' "$name" > "$dir/.fm-secondmate-home" + printf 'schema=fm-secondmate-parent.v1\nroute=local\nparent_home=%s\n' "$parent" \ + > "$dir/.fm-secondmate-parent" + printf '%s\n' "$status_line" > "$state/task-v.status" + fm_merge_outcome_report "$dir" "$state" task-v https://github.com/o/r/pull/7 \ + poll closed-unmerged || fail "the upward close outcome could not be published for $name" + cat "$parent/state/mate-$name.status" +} + +test_a_close_does_not_invent_a_done_claim() { + local out claim + claim='done: pr=https://github.com/o/r/pull/7 head=00112233445566778899aabbccddeeff00112233 - shipped' + + # The poll is armed at PR registration, so a close can arrive while the worker + # is still working and has claimed nothing. + out=$(close_publication close-no-claim 'working: still going') \ + || fail "the no-claim close fixture failed" + case "$out" in + *"the done record"*) fail "a close invented a done record the task never wrote: $out" ;; + esac + case "$out" in + *"PR closed without merging"*) ;; + *) fail "a close with no claim on record was not published at all: $out" ;; + esac + + out=$(close_publication close-with-claim "$claim") || fail "the claimed close fixture failed" + case "$out" in + *"contradicting the done record"*) ;; + *) fail "a close over a real done claim did not report the contradiction: $out" ;; + esac + + # The durable parent line is the record the finding named, so it is checked on + # its own surface rather than inferred from the wake note. + out=$(close_publication_upward up-no-claim 'working: still going') \ + || fail "the upward no-claim close fixture failed" + case "$out" in + *"claims done"*) fail "the parent channel was told a task claims done when it never did: $out" ;; + esac + case "$out" in + *"closed-unmerged-task-v"*"closed without merging"*) ;; + *) fail "the upward close with no claim was not published at all: $out" ;; + esac + + out=$(close_publication_upward up-with-claim "$claim") \ + || fail "the upward claimed close fixture failed" + case "$out" in + *"claims done but"*) ;; + *) fail "the parent channel lost the contradiction for a real done claim: $out" ;; + esac + pass "a close reports a contradicted done record only when a claim is actually on record" +} + +# --- (t)-(u) the unread surface ---------------------------------------------- + +test_a_configured_verb_vocabulary_is_not_unrecognised() { + # A home may replace the whole captain-relevant verb set. Its own states must + # not then be labelled as matching no status verb. + FM_CAPTAIN_RE='escalate:|shipped:' + status_line_is_unrecognized "escalate: the migration needs a decision" \ + && fail "a verb this home configured as a real state was called unrecognised" + status_line_is_unrecognized "shipped: the fix is out" \ + && fail "a second configured verb was called unrecognised" + status_line_is_unrecognized "Migration syntax: OK" \ + || fail "worker prose stopped being unrecognised under a configured vocabulary" + unset FM_CAPTAIN_RE + + # The default set also routes legacy bare lines as real states, so they are + # not unrecognised either. + status_line_is_unrecognized "merged" && fail "a legacy bare captain line was called unrecognised" + status_line_is_unrecognized "PR ready" && fail "a legacy bare PR line was called unrecognised" + pass "a home's configured verb vocabulary and the legacy bare lines stay recognised" +} + +test_the_omitted_unrecognised_count_carries_its_header() { + local dir state status out + dir=$(make_case unrecognised-cap-zero) + state="$dir/state" + status="$state/task9.status" + out="$dir/drain.out" + printf 'note: bootstrap cursor line\n' > "$status" + FM_STATE_OVERRIDE="$state" "$DRAIN" >/dev/null 2>/dev/null \ + || fail "bootstrap drain failed while priming the cursor" + + printf 'Migration syntax: OK\n' >> "$status" + printf -- '- ruff check on all changed files: all passed\n' >> "$status" + # Opting out of the surface must not leave a bare counter with nothing to + # say what it is counting. + FM_DRAIN_UNRECOGNISED_MAX=0 FM_STATE_OVERRIDE="$state" "$DRAIN" > "$out" \ + || fail "drain failed with the unrecognised surface capped at zero" + assert_grep 'UNREAD STATUS: 2 more unrecognised line(s) omitted' "$out" \ + "the omitted unrecognised lines were not counted: $(cat "$out")" + assert_grep 'UNREAD STATUS (new since last drain' "$out" \ + "the omitted count was printed without its section header: $(cat "$out")" + pass "the omitted-unrecognised count is never printed as a bare orphan line" +} + test_claim_grammar_round_trips test_unreachable_forge_is_unverified_never_a_pass test_head_not_the_pr_head_is_contradicted @@ -432,4 +728,14 @@ test_poll_reports_a_close_as_well_as_a_merge test_marker_distinguishes_the_two_terminal_outcomes test_drain_flags_a_line_matching_no_status_verb test_known_verbs_are_not_flagged_as_unrecognised +test_a_fabricated_local_only_claim_is_not_verified +test_a_local_only_claim_naming_another_branch_is_contradicted +test_a_local_only_claim_on_a_retired_branch_still_verifies +test_a_transient_unverified_does_not_downgrade_an_established_record +test_a_contradiction_still_overwrites_an_established_record +test_an_absent_mode_still_checks_the_validated_commit +test_a_relative_scout_report_follows_a_relocated_data_root +test_a_close_does_not_invent_a_done_claim +test_a_configured_verb_vocabulary_is_not_unrecognised +test_the_omitted_unrecognised_count_carries_its_header echo "all fm-done-verified tests passed" From 5b73398564dafce263872b9ffd0cfb943f64cff3 Mon Sep 17 00:00:00 2001 From: Israel Wilson Date: Thu, 3 Sep 2026 12:26:54 -0400 Subject: [PATCH 03/21] fix(tests): keep the new claim-verification cases lint-clean Two findings in the cases added by the review fix round: `local dir=$1 data=${2:-$dir/data}` reads `dir` inside the same `local` (SC2318), and `FM_CAPTAIN_RE` is consumed by the sourced classifier rather than by this file (SC2034). Claude-Session: https://claude.ai/code/session_01N6zvmegmTrHiWnrfXBxuPU --- tests/fm-done-verified.test.sh | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/fm-done-verified.test.sh b/tests/fm-done-verified.test.sh index 55e3ebe51a8..b3ee5595460 100755 --- a/tests/fm-done-verified.test.sh +++ b/tests/fm-done-verified.test.sh @@ -107,7 +107,8 @@ SH # Run the verifier in and echo "\t". defaults to # the home's own data dir; a case that relocates it passes its own. verify() { # [data-root] - local dir=$1 data=${2:-$dir/data} out rc=0 + local dir=$1 + local data=${2:-$dir/data} out rc=0 out=$(PATH="$dir/fakebin:$PATH" FM_HOME="$dir" FM_STATE_OVERRIDE="$dir/state" \ FM_DATA_OVERRIDE="$data" "$VERIFY" task-v 2>&1) || rc=$? printf '%s\t%s\n' "$rc" "$out" @@ -673,6 +674,8 @@ test_a_close_does_not_invent_a_done_claim() { test_a_configured_verb_vocabulary_is_not_unrecognised() { # A home may replace the whole captain-relevant verb set. Its own states must # not then be labelled as matching no status verb. + # Read by the sourced classifier, not by this file. + # shellcheck disable=SC2034 FM_CAPTAIN_RE='escalate:|shipped:' status_line_is_unrecognized "escalate: the migration needs a decision" \ && fail "a verb this home configured as a real state was called unrecognised" From ad03c90d82771242bf23d20796fb47d38e23ed6c Mon Sep 17 00:00:00 2001 From: Israel Wilson Date: Thu, 3 Sep 2026 13:07:26 -0400 Subject: [PATCH 04/21] no-mistakes(review): route absent mode by claim shape, gate ledger done verb, arm verification --- AGENTS.md | 1 + bin/fm-inactive-reconcile.sh | 23 ++++++++- bin/fm-pr-check.sh | 18 +++++++ bin/fm-verify-done.sh | 39 +++++++++++--- docs/scripts.md | 2 +- tests/fm-done-verified.test.sh | 80 +++++++++++++++++++++++++++++ tests/fm-inactive-reconcile.test.sh | 52 ++++++++++++++++--- tests/fm-pr-check-security.test.sh | 58 +++++++++++++++++++++ 8 files changed, 255 insertions(+), 18 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index f07e48f201a..4bbe99260ba 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -375,6 +375,7 @@ The worker reports the PR when CI first becomes green rather than waiting for me A terminal `done:` claim names the exact commit, PR, branch, or report it is claiming, and only `bin/fm-verify-done.sh` decides whether that claim is true; `bin/fm-done-claim-lib.sh` owns the grammar and the durable verdict. A claim that has not been established is never done: `bin/fm-crew-state.sh` reports it as `done-unverified`, and teardown refuses it. Run `bin/fm-pr-check.sh ` with the URL copied from the terminal claim's `pr=` field or the task's `pr=` metadata, never assembled from memory - it records `pr=` and the forge's `pr_head=` when available in the task's meta and arms the watcher's poll, which reports a merge and a close without merge alike. +That registration also runs `bin/fm-verify-done.sh` for the task and prints its verdict, so a claim is established while the forge is already being consulted rather than only at cleanup; the verdict is advisory there and never changes what the registration records, arms, or exits with. Tell the captain the PR's full URL, always the complete `https://...` link rather than a bare `#number`, copied verbatim from the claim's `pr=` field or the task's `pr=` metadata, a concise outcome summary, and the no-mistakes risk level when applicable. A captain instruction to merge is explicit authority; `yolo` is the only standing routine merge authority. For any custom `state/.check.sh` you write yourself, keep it an ordinary single-link mode-`0700` file, print one line only when firstmate should wake, print nothing otherwise, finish before `FM_CHECK_TIMEOUT`, then bind its current bytes with `bin/fm-check-register.sh ` before the watcher may execute it. diff --git a/bin/fm-inactive-reconcile.sh b/bin/fm-inactive-reconcile.sh index 60847bf74eb..9374c2e3d11 100755 --- a/bin/fm-inactive-reconcile.sh +++ b/bin/fm-inactive-reconcile.sh @@ -96,6 +96,8 @@ CREW_STATE_BIN="${FM_INACTIVE_CREW_STATE_BIN:-$SCRIPT_DIR/fm-crew-state.sh}" . "$SCRIPT_DIR/fm-parent-channel-lib.sh" # shellcheck source=bin/fm-timeout-lib.sh . "$SCRIPT_DIR/fm-timeout-lib.sh" +# shellcheck source=bin/fm-done-claim-lib.sh +. "$SCRIPT_DIR/fm-done-claim-lib.sh" FM_INACTIVE_RECONCILE_SECS=${FM_INACTIVE_RECONCILE_SECS:-900} case "$FM_INACTIVE_RECONCILE_SECS" in @@ -402,7 +404,7 @@ claim_inactive_report_for_ledger() { # - local id=$1 meta=$2 status last previous state note pr mode yolo data incarnation fingerprint predecessor_head outcome_key line + local id=$1 meta=$2 status last previous state verb claim note pr mode yolo data incarnation fingerprint predecessor_head outcome_key line status="$STATE/$id.status" last=$(child_terminal_ledger_line "$status") || return 0 state=$(status_line_verb "$last") @@ -427,13 +429,30 @@ report_child_ledger_locked() { # mode=$(clean_field "$(meta_field "$meta" mode)") yolo=$(clean_field "$(meta_field "$meta" yolo)") data="${FM_DATA_OVERRIDE:-$FM_HOME/data}" - line="$state [key=$outcome_key]: child $id $state: $note" + # The same rule report_to_parent applies, at the other delivery path into the + # parent channel: a `done` the child only ASSERTED is not a completion the + # parent may record as one. It is published as a blocker naming what the claim + # actually is, so the parent's durable record cannot say done while the child's + # own home says done-unverified. A `failed` line, and a `done` whose verdict IS + # verified, are unchanged. The record and its key still carry the ledger's own + # state, so dedup is untouched; only the leading verb and one extra field move. + verb=$state + claim= + if [ "$state" = "done" ]; then + fm_done_claim_status "$STATE" "$id" + case "$FM_DONE_CLAIM_STATE" in + verified|none) ;; + *) verb=blocked; claim=$FM_DONE_CLAIM_STATE ;; + esac + fi + line="$verb [key=$outcome_key]: child $id $state: $note" [ -z "$pr" ] || line="$line pr=$pr" [ -z "$mode" ] || line="$line mode=$mode" [ -z "$yolo" ] || line="$line yolo=$yolo" if [ -f "$data/$id/report.md" ] && [ ! -L "$data/$id/report.md" ]; then line="$line report=data/$id/report.md" fi + [ -z "$claim" ] || line="$line claim=$claim" if fm_parent_channel_report "$FM_HOME" "$STATE" "$line"; then mark_reported "$RECORD_PENDING" || return 1 return 0 diff --git a/bin/fm-pr-check.sh b/bin/fm-pr-check.sh index 99b3e025db2..bc4b03ae2b3 100755 --- a/bin/fm-pr-check.sh +++ b/bin/fm-pr-check.sh @@ -3,6 +3,10 @@ # exact pr_head= when available, then atomically arm a static merge poll. # The watcher check source is byte-for-byte bin/fm-pr-poll.sh; task and PR data # live only in a private sidecar and are never interpolated into shell source. +# Registration is also where verification of the task's terminal claim is armed, +# because this is the one routine path that already consults the forge right +# after the worker claims done. It is strictly advisory and cannot change what +# this script registers, arms, or exits with. # A GitHub pull request URL and a GitLab merge request URL are both accepted, # including a merge request on a self-hosted GitLab instance. # Usage: fm-pr-check.sh @@ -152,4 +156,18 @@ case "$READY_RC" in 0|1) ;; *) printf 'actionable: PR %s is registered but its ready line did not reach the parent channel (rc=%s)\n' "$URL" "$READY_RC" >&2 ;; esac +# Establish the task's terminal claim now, while the forge is already being +# consulted, so `done-unverified` reads as something unusual rather than as +# every finished task's steady state between claim and cleanup. Advisory only: +# the PR is registered and the poll is armed above and stay so whatever the +# verifier does, including being absent, erroring, or timing out, and its +# outcome never reaches this script's exit status. A task that has not claimed +# done yet prints nothing and is not an error. +if [ -x "$SCRIPT_DIR/fm-verify-done.sh" ]; then + VERIFY_LINE=$(FM_HOME="$FM_HOME" \ + FM_STATE_OVERRIDE="$STATE" \ + FM_DATA_OVERRIDE="${FM_DATA_OVERRIDE:-}" \ + "$SCRIPT_DIR/fm-verify-done.sh" "$ID" 2>/dev/null | head -1) || VERIFY_LINE= + [ -z "$VERIFY_LINE" ] || printf 'claim: %s\n' "$VERIFY_LINE" +fi printf 'armed: state/%s.check.sh\n' "$ID" diff --git a/bin/fm-verify-done.sh b/bin/fm-verify-done.sh index 1341cb86a56..242b4e2768e 100755 --- a/bin/fm-verify-done.sh +++ b/bin/fm-verify-done.sh @@ -36,9 +36,15 @@ # same claim as an unrelocated one. A leading `data/` is the home-relative # spelling of that root and names it rather than a directory beneath it. # -# A task whose meta records no mode= reads as no-mistakes, the same default -# bin/fm-teardown.sh applies, so a task spawned before mode= was recorded cannot -# skip the validated-commit check below. +# A task whose meta records no mode= - the population spawned before mode= was +# recorded - is routed by the shape of its own claim, because an absent record is +# the absence of evidence and must never produce contradicted: a claim carrying +# branch= and no pr= takes the local-only arm, one carrying pr= takes the PR arm +# as no-mistakes so the validated-commit check still runs for it, and one +# carrying both or neither is unverified, naming the unrecorded mode as the thing +# that could not be established. bin/fm-teardown.sh keeps its own default; the +# point is that two owners of the same meta must not disagree in a way that +# reports a true claim as false. # # Verdicts, written to state/.done-verdict and printed on stdout: # verified exit 0 @@ -74,7 +80,9 @@ ID= while [ "$#" -gt 0 ]; do case "$1" in --quiet) QUIET=1 ;; - -h|--help) sed -n '2,51p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; + # The whole leading comment block, found rather than hard-coded, so editing + # the header cannot silently truncate the help it is. + -h|--help) awk 'NR > 1 { if ($0 !~ /^#/) exit; sub(/^# ?/, ""); print }' "$0"; exit 0 ;; -*) echo "fm-verify-done: unknown option $1" >&2; exit 2 ;; *) [ -z "$ID" ] || { echo "fm-verify-done: one task id" >&2; exit 2; }; ID=$1 ;; esac @@ -133,10 +141,7 @@ FM_DONE_CLAIM_LINE=$CLAIM fm_done_claim_parse "$CLAIM" || { echo "fm-verify-done: $ID has no terminal claim to verify" >&2; exit 2; } KIND=$(meta_field kind); [ -n "$KIND" ] || KIND=ship -# An absent mode= is the legacy ship population, which bin/fm-teardown.sh also -# reads as no-mistakes. Two owners of the same meta must not disagree about it, -# and reading it as "some other mode" would skip the validated-commit check. -MODE=$(meta_field mode); [ -n "$MODE" ] || MODE=no-mistakes +MODE=$(meta_field mode) WT=$(meta_field worktree) if ! fm_done_claim_has_identity; then @@ -164,6 +169,24 @@ if [ "$KIND" = scout ]; then finish fi +# A task spawned before mode= was recorded has none, and no record is the absence +# of evidence: it may never make a true claim come back contradicted. The claim's +# own shape decides instead, because it is unambiguous in both common cases - a +# local-only claim names its branch and no PR, a PR claim names its PR. A claim +# naming both or neither establishes nothing about the mode, and says so. +if [ -z "$MODE" ]; then + if [ -n "$FM_DONE_CLAIM_BRANCH" ] && [ -z "$FM_DONE_CLAIM_PR" ]; then + MODE=local-only + elif [ -n "$FM_DONE_CLAIM_PR" ] && [ -z "$FM_DONE_CLAIM_BRANCH" ]; then + # Read as no-mistakes so this legacy population still faces the + # validated-commit check rather than skipping it. + MODE=no-mistakes + else + verdict_is unverified 'this task records no delivery mode and the claim names neither a branch alone nor a PR alone, so the mode it should be judged against cannot be established' + finish + fi +fi + HEAD_CLAIM=$FM_DONE_CLAIM_HEAD if ! fm_done_claim_head_valid "$HEAD_CLAIM"; then verdict_is unverified 'claim carries no full commit id' diff --git a/docs/scripts.md b/docs/scripts.md index 62b0dad2734..f9320189cdc 100644 --- a/docs/scripts.md +++ b/docs/scripts.md @@ -127,7 +127,7 @@ The shared no-mistakes gate refusal for fleet lifecycle entrypoints is summarize | `fm-tool-update-check.sh` | Report watched tooling with an update available, and updates installed but left inert by PATH order | | `fm-pr-lib.sh` | Own canonical task and PR validation plus private atomic PR-poll publication, merge-notification identity, and retirement | | `fm-pr-poll.sh` | Provide the byte-static watcher program for validated PR/MR-poll sidecars, reporting a merge or a close without merge | -| `fm-pr-check.sh` | Record validated `pr=` and `pr_head=` values, then atomically arm a static merge poll | +| `fm-pr-check.sh` | Record validated `pr=` and `pr_head=` values, arm a static merge poll, and advisorily establish the task's terminal claim | | `fm-pr-merge.sh` | Record PR metadata, merge a task's canonical full GitHub or GitLab URL, then refuse an outcome it cannot prove landed or queued | | `fm-merge-outcome-lib.sh` | Publish a PR's confirmed terminal outcome - merged, or closed without merging - as a durable, role-routed supervision outcome | | `fm-parent-channel-lib.sh` | Resolve a secondmate home's parent channel and append a captain-facing outcome line to it at most once | diff --git a/tests/fm-done-verified.test.sh b/tests/fm-done-verified.test.sh index b3ee5595460..bb14b9bf26f 100755 --- a/tests/fm-done-verified.test.sh +++ b/tests/fm-done-verified.test.sh @@ -39,6 +39,11 @@ # (s) a close with no done claim on record does not invent one # (t) a home's configured verb vocabulary is not labelled UNRECOGNISED # (u) the omitted-unrecognised count is never printed without its header +# (v) an absent mode= with a local-only-shaped claim is judged as local-only, +# never reported as false for naming no PR +# (w) an absent mode= the claim's own shape cannot resolve is unverified, +# naming the unrecorded mode - absence of evidence is never contradicted +# (x) --help prints the whole header it documents itself with set -u # shellcheck source=tests/wake-helpers.sh @@ -577,6 +582,78 @@ test_an_absent_mode_still_checks_the_validated_commit() { pass "a task whose meta records no mode still gets the validated-commit check" } +# --- (v) an absent mode= with a local-only claim shape ------------------------ + +# A task spawned before mode= was recorded, whose worker then appended the +# conforming LOCAL-ONLY claim teardown asked it for. Reading the absent record +# as a PR mode would report that true claim as `contradicted: claim names no +# PR`, and teardown would print a stop-and-investigate falsehood. The claim's +# own shape - a branch and no PR - resolves it. +test_an_absent_mode_with_a_branch_claim_is_judged_as_local_only() { + local dir result shipped + dir=$(make_world absent-mode-branch) + shipped=$(git -C "$dir/wt" rev-parse HEAD) + fm_write_meta "$dir/state/task-v.meta" \ + "window=fm:fm-task-v" "worktree=$dir/wt" "kind=ship" + printf 'done: branch=fm/task-v head=%s - ready\n' "$shipped" > "$dir/state/task-v.status" + result=$(verify "$dir") + case "$result" in + *"claim names no PR"*) fail "a true local-only claim with no recorded mode was reported as false: $result" ;; + esac + [ "${result%%$'\t'*}" = 0 ] \ + || fail "a true local-only claim with no recorded mode did not verify: $result" + pass "an absent mode with a branch-shaped claim is judged as local-only" +} + +# --- (w) an absent mode= the claim shape cannot resolve ----------------------- + +# Absence of evidence may never manufacture evidence of falsity. When neither +# the meta nor the claim says which mode to judge against, the honest verdict is +# unverified naming exactly that, not contradicted. +test_an_unresolvable_absent_mode_is_unverified_never_contradicted() { + local dir result shipped + dir=$(make_world absent-mode-ambiguous) + shipped=$(git -C "$dir/wt" rev-parse HEAD) + fm_write_meta "$dir/state/task-v.meta" \ + "window=fm:fm-task-v" "worktree=$dir/wt" "kind=ship" + + printf 'done: head=%s - shipped\n' "$shipped" > "$dir/state/task-v.status" + result=$(verify "$dir") + [ "${result%%$'\t'*}" = 3 ] \ + || fail "a claim naming neither a branch nor a PR was not unverified: $result" + case "$result" in + *"no delivery mode"*) ;; + *) fail "the verdict did not name the unrecorded mode: $result" ;; + esac + + printf 'done: pr=https://github.com/o/r/pull/7 branch=fm/task-v head=%s - shipped\n' "$shipped" \ + > "$dir/state/task-v.status" + result=$(verify "$dir") + [ "${result%%$'\t'*}" = 3 ] \ + || fail "a claim naming both a branch and a PR was not unverified: $result" + pass "an absent mode the claim cannot resolve is unverified, never contradicted" +} + +# --- (x) the help is the whole header ---------------------------------------- + +# The help IS the header comment, so a hard-coded line range silently truncates +# it the moment the header grows. Asserted on the documented contract the last +# sentence states, which is the one a range that stops short would drop. +test_help_prints_the_whole_header() { + local out last + out=$("$VERIFY" --help) || fail "fm-verify-done.sh --help did not exit 0" + last=$(printf '%s\n' "$out" | grep -v '^[[:space:]]*$' | tail -1) + case "$last" in + *"invalidates it rather than inheriting its verdict.") ;; + *) fail "--help truncated its own header; it ends at: $last" ;; + esac + case "$out" in + *"Verdicts, written to state/"*) ;; + *) fail "--help dropped the verdict contract it documents" ;; + esac + pass "--help prints the whole header it documents itself with" +} + # --- (r) a relative scout report= follows the configured data root ------------ test_a_relative_scout_report_follows_a_relocated_data_root() { @@ -738,6 +815,9 @@ test_a_transient_unverified_does_not_downgrade_an_established_record test_a_contradiction_still_overwrites_an_established_record test_an_absent_mode_still_checks_the_validated_commit test_a_relative_scout_report_follows_a_relocated_data_root +test_an_absent_mode_with_a_branch_claim_is_judged_as_local_only +test_an_unresolvable_absent_mode_is_unverified_never_contradicted +test_help_prints_the_whole_header test_a_close_does_not_invent_a_done_claim test_a_configured_verb_vocabulary_is_not_unrecognised test_the_omitted_unrecognised_count_carries_its_header diff --git a/tests/fm-inactive-reconcile.test.sh b/tests/fm-inactive-reconcile.test.sh index 6a596dab169..7b8a49210a4 100755 --- a/tests/fm-inactive-reconcile.test.sh +++ b/tests/fm-inactive-reconcile.test.sh @@ -4,6 +4,8 @@ set -u # shellcheck source=tests/lib.sh . "$(dirname "${BASH_SOURCE[0]}")/lib.sh" +# shellcheck source=/dev/null +. "$ROOT/bin/fm-done-claim-lib.sh" RECON="$ROOT/bin/fm-inactive-reconcile.sh" DRAIN="$ROOT/bin/fm-wake-drain.sh" @@ -176,7 +178,7 @@ test_local_secondmate_delivers_terminal_ledger_line() { write_child "$MATE" child 'done: PR https://example.test/owner/repo/pull/1 checks green' FM_FAKE_CREW_STATE='unknown' run_reconcile "$MATE" key=$(reported_outcome_key "$MATE" child 'done') || fail "ledger receipt did not retain its collision-resistant key" - expected="done [key=$key]: child child done: PR https://example.test/owner/repo/pull/1 checks green pr=https://example.test/owner/repo/pull/1 mode=no-mistakes yolo=off" + expected="blocked [key=$key]: child child done: PR https://example.test/owner/repo/pull/1 checks green pr=https://example.test/owner/repo/pull/1 mode=no-mistakes yolo=off claim=unverified" grep -Fxq "$expected" "$MAIN/state/mate.status" \ || fail "secondmate did not deliver the child's ledger line on a plain poll: $(cat "$MAIN/state/mate.status" 2>/dev/null)" [ "$(outcome_count "$MATE" reported)" = 1 ] || fail "ledger delivery receipt was not durable" @@ -190,6 +192,41 @@ test_local_secondmate_delivers_terminal_ledger_line() { pass "secondmate delivers a child's terminal ledger line once, on the next poll, from the ledger alone" } +# The parent's durable supervision record must never say a child is done on the +# child's own assertion. The ledger path is the delivering path in a secondmate +# home, so it applies the same rule report_to_parent does: a terminal claim +# nothing has established reaches the parent as a blocker naming the claim +# state, and the same claim with a matching verified verdict reaches it as done. +test_ledger_delivery_downgrades_an_unestablished_claim() { + local claim hash key + claim='done: pr=https://example.test/owner/repo/pull/1 head=00112233445566778899aabbccddeeff00112233 - shipped' + + make_world ledger-unverified; bind_secondmate local + write_child "$MATE" child "$claim" + FM_FAKE_CREW_STATE='unknown' run_reconcile "$MATE" + key=$(reported_outcome_key "$MATE" child 'done') || fail "ledger receipt did not retain its key" + grep -q "^blocked \[key=$key\]: child child done: " "$MAIN/state/mate.status" \ + || fail "an unestablished claim did not reach the parent as a blocker: $(cat "$MAIN/state/mate.status" 2>/dev/null)" + grep -q "^done \[key=$key\]" "$MAIN/state/mate.status" \ + && fail "an unestablished claim reached the parent's record as done" + grep -Fq 'claim=unverified' "$MAIN/state/mate.status" \ + || fail "the downgraded line did not name the claim state" + + make_world ledger-verified; bind_secondmate local + write_child "$MATE" child "$claim" + hash=$(fm_done_claim_hash "$claim") || fail "could not compute the claim identity" + printf 'fm-done-verdict-v1\nverified\n%s\n%s\nPR at the claimed head\n' \ + "$hash" "$(date +%s)" > "$MATE/state/child.done-verdict" + FM_FAKE_CREW_STATE='unknown' run_reconcile "$MATE" + key=$(reported_outcome_key "$MATE" child 'done') || fail "established ledger receipt key missing" + grep -q "^done \[key=$key\]: child child done: " "$MAIN/state/mate.status" \ + || fail "an established claim was not delivered as done: $(cat "$MAIN/state/mate.status" 2>/dev/null)" + grep -Fq 'claim=' "$MAIN/state/mate.status" \ + && fail "an established claim was annotated as if it were not" + + pass "ledger delivery publishes an unestablished done claim as a blocker and an established one as done" +} + # A busy child cannot keep later ledger outcomes from being visited, and is # retried on the next poll after its lifecycle lock becomes available. test_busy_child_does_not_starve_later_ledger_outcomes() { @@ -240,16 +277,16 @@ test_secondmate_ledger_delivery_carries_report_and_failure() { scout_key=$(reported_outcome_key "$MATE" scout 'done') || fail "scout receipt key missing" boom_key=$(reported_outcome_key "$MATE" boom failed) || fail "failed receipt key missing" replaced_key=$(reported_outcome_key "$MATE" replaced-pr 'done') || fail "replacement PR receipt key missing" - grep -Fxq "done [key=$scout_key]: child scout done: report written pr=https://example.test/owner/repo/pull/1 mode=no-mistakes yolo=off report=data/scout/report.md" \ + grep -Fxq "blocked [key=$scout_key]: child scout done: report written pr=https://example.test/owner/repo/pull/1 mode=no-mistakes yolo=off report=data/scout/report.md claim=unverified" \ "$MAIN/state/mate.status" || fail "scout delivery lost its report pointer: $(cat "$MAIN/state/mate.status")" grep -Fxq "failed [key=$boom_key]: child boom failed: build broke pr=https://example.test/owner/repo/pull/1 mode=no-mistakes yolo=off" \ "$MAIN/state/mate.status" || fail "failed line was not delivered under the failed verb: $(cat "$MAIN/state/mate.status")" - grep -Fxq "done [key=$replaced_key]: child replaced-pr done: replacement PR https://example.test/owner/repo/pull/22 pr=https://example.test/owner/repo/pull/22 mode=no-mistakes yolo=off" \ + grep -Fxq "blocked [key=$replaced_key]: child replaced-pr done: replacement PR https://example.test/owner/repo/pull/22 pr=https://example.test/owner/repo/pull/22 mode=no-mistakes yolo=off claim=unverified" \ "$MAIN/state/mate.status" || fail "ledger fallback did not prefer the terminal line PR: $(cat "$MAIN/state/mate.status")" printf 'working: retrying\ndone: fixed on retry\n' >> "$MATE/state/boom.status" FM_FAKE_CREW_STATE='unknown' run_reconcile "$MATE" boom_key=$(reported_outcome_key "$MATE" boom 'done') || fail "recovered receipt key missing" - grep -Fq "done [key=$boom_key]: child boom done: fixed on retry" "$MAIN/state/mate.status" \ + grep -Fq "blocked [key=$boom_key]: child boom done: fixed on retry" "$MAIN/state/mate.status" \ || fail "a new terminal line after recovery was not delivered" [ "$(grep -c 'child-outcome-boom-' "$MAIN/state/mate.status")" = 2 ] \ || fail "recovery delivered the wrong number of lines: $(cat "$MAIN/state/mate.status")" @@ -350,7 +387,7 @@ test_secondmate_partial_ledger_line_waits_for_newline() { printf 'ten\n' >> "$MATE/state/child.status" FM_FAKE_CREW_STATE='unknown' run_reconcile "$MATE" key=$(reported_outcome_key "$MATE" child 'done') || fail "completed ledger receipt key missing" - grep -Fq "done [key=$key]: child child done: half written" "$MAIN/state/mate.status" \ + grep -Fq "blocked [key=$key]: child child done: half written" "$MAIN/state/mate.status" \ || fail "the completed line was not delivered once its newline landed" pass "a ledger line still being appended waits for its newline" } @@ -375,7 +412,7 @@ test_report_subcommand_delivers_and_refuses() { write_child "$MATE" child 'done: final word' run_report "$MATE" child || fail "report refused a deliverable ledger line" key=$(reported_outcome_key "$MATE" child 'done') || fail "report receipt key missing" - grep -Fq "done [key=$key]: child child done: final word" "$MAIN/state/mate.status" \ + grep -Fq "blocked [key=$key]: child child done: final word" "$MAIN/state/mate.status" \ || fail "report did not deliver the child's final line" run_report "$MATE" child || fail "report did not treat an already delivered line as owed nothing" write_child "$MATE" quiet 'working: nothing terminal' @@ -669,7 +706,7 @@ test_watcher_poll_delivers_child_ledger_line_to_parent() { done reap "$pid" key=$(reported_outcome_key "$MATE" child 'done') || fail "watcher ledger receipt key missing" - grep -Fxq "done [key=$key]: child child done: PR https://example.test/owner/repo/pull/1 checks green pr=https://example.test/owner/repo/pull/1 mode=no-mistakes yolo=off" \ + grep -Fxq "blocked [key=$key]: child child done: PR https://example.test/owner/repo/pull/1 checks green pr=https://example.test/owner/repo/pull/1 mode=no-mistakes yolo=off claim=unverified" \ "$MAIN/state/mate.status" \ || fail "the watcher poll did not deliver the child's ledger line to the parent: $(cat "$MAIN/state/mate.status" 2>/dev/null; cat "$WORLD/mate-watch.out")" [ ! -s "$WORLD/forge.log" ] || fail "ledger delivery invoked a forge command" @@ -788,6 +825,7 @@ test_reconciliation_never_calls_forge() { test_main_direct_terminal_presentation_receipt test_local_secondmate_delivers_terminal_ledger_line +test_ledger_delivery_downgrades_an_unestablished_claim test_busy_child_does_not_starve_later_ledger_outcomes test_secondmate_ledger_delivery_carries_report_and_failure test_terminal_line_during_state_read_yields_to_ledger_delivery diff --git a/tests/fm-pr-check-security.test.sh b/tests/fm-pr-check-security.test.sh index c027f2fb9ec..eba95c51018 100755 --- a/tests/fm-pr-check-security.test.sh +++ b/tests/fm-pr-check-security.test.sh @@ -146,6 +146,10 @@ case "${1:-} ${2:-}" in ;; esac case " $* " in + *"state,headRefOid,url,statusCheckRollup"*) + printf '%s\t%s\t%s\n' "${FM_TEST_GH_STATE:-OPEN}" \ + "${FM_TEST_GH_HEAD:-0123456789abcdef0123456789abcdef01234567}" SUCCESS + ;; *" headRefOid "*) printf '%s\n' "${FM_TEST_GH_HEAD:-0123456789abcdef0123456789abcdef01234567}" ;; *" state "*) [ "${FM_TEST_GH_FAIL:-0}" = 0 ] || exit 1 @@ -622,6 +626,59 @@ run_watcher_bounded() { FM_POLL=0.02 FM_HEARTBEAT=999999 FM_SIGNAL_GRACE=0 PATH="$fakebin:$BASE_PATH" "$WATCH" "$@" } +# Registration is the one routine path that already consults the forge right +# after a worker claims done, so it is where verification is armed. Without it +# `done-unverified` would be every finished task's steady state between claim +# and cleanup - a signal that is always on, which nobody reacts to. It is +# advisory: no verifier outcome, including there being nothing to verify, may +# change what this script registers, arms, or exits with. +test_registration_arms_claim_verification_without_blocking() { + local dir head url other + url=https://github.com/o/r/pull/9 + head=0123456789abcdef0123456789abcdef01234567 + other=89abcdef0123456789abcdef0123456789abcdef + + dir=$(make_case verify-armed) + fm_write_meta "$dir/home/state/task-a.meta" \ + "window=firstmate:fm-task-a" "worktree=$dir/wt" "kind=ship" "mode=direct-PR" + printf 'done: pr=%s head=%s - shipped\n' "$url" "$head" > "$dir/home/state/task-a.status" + FM_TEST_GH_HEAD=$head run_check_entry "$dir" task-a "$url" > "$dir/stdout" 2> "$dir/stderr" \ + || fail "registration failed for a task with a conforming claim: $(cat "$dir/stderr")" + fm_pr_poll_artifacts_valid "$dir/home/state" task-a "$POLL" \ + || fail "registration did not arm the poll for a task with a conforming claim" + assert_present "$dir/home/state/task-a.done-verdict" "registration left no durable verdict record" + assert_grep verified "$dir/home/state/task-a.done-verdict" "the durable record did not establish the claim" + assert_grep 'claim: verified' "$dir/stdout" "registration did not report the verdict it established" + assert_grep "armed: state/task-a.check.sh" "$dir/stdout" "registration stopped reporting what it armed" + + # A claim the forge establishes as FALSE must not stop the registration: the + # PR is still registered and the poll is still armed, and only the record and + # the printed line carry the contradiction. + dir=$(make_case verify-contradicted) + fm_write_meta "$dir/home/state/task-a.meta" \ + "window=firstmate:fm-task-a" "worktree=$dir/wt" "kind=ship" "mode=direct-PR" + printf 'done: pr=%s head=%s - shipped\n' "$url" "$other" > "$dir/home/state/task-a.status" + FM_TEST_GH_HEAD=$head run_check_entry "$dir" task-a "$url" > "$dir/stdout" 2> "$dir/stderr" \ + || fail "a contradicted claim changed the registration's exit status: $(cat "$dir/stderr")" + fm_pr_poll_artifacts_valid "$dir/home/state" task-a "$POLL" \ + || fail "a contradicted claim stopped the poll from being armed" + assert_grep contradicted "$dir/home/state/task-a.done-verdict" "the contradiction was not recorded durably" + assert_grep 'claim: contradicted' "$dir/stdout" "registration did not report the contradiction" + + # The ordinary case at registration time: the worker has not claimed done yet. + # Nothing to verify is not an error and prints nothing. + dir=$(make_case verify-no-claim) + write_task_meta "$dir" + FM_TEST_GH_HEAD=$head run_check_entry "$dir" task-a "$url" > "$dir/stdout" 2> "$dir/stderr" \ + || fail "a task with no terminal claim failed registration: $(cat "$dir/stderr")" + fm_pr_poll_artifacts_valid "$dir/home/state" task-a "$POLL" \ + || fail "a task with no terminal claim did not get its poll armed" + assert_absent "$dir/home/state/task-a.done-verdict" "a task with no claim was given a verdict record" + assert_no_grep 'claim:' "$dir/stdout" "a task with no claim printed a verdict line" + + pass "PR registration arms claim verification without ever blocking it" +} + test_rejected_metacharacter_bytes_are_inert() { local dir family rc before after dir=$(make_case rejected-metacharacters) @@ -2163,6 +2220,7 @@ test_gitlab_merged_poll_retires() { } test_parser_matrix +test_registration_arms_claim_verification_without_blocking test_gitlab_merge_watch test_merged_poll_retires_once test_merged_poll_reregistration_after_notification_is_absorbed From b1444bdb276ba27489d918f7d76d87487d68efd5 Mon Sep 17 00:00:00 2001 From: Israel Wilson Date: Thu, 3 Sep 2026 13:49:15 -0400 Subject: [PATCH 05/21] no-mistakes(review): reject local-only claims their branch never introduced; defer capped drain lines --- bin/fm-classify-lib.sh | 81 ++++++++++- bin/fm-pr-check.sh | 12 +- bin/fm-verify-done.sh | 65 +++++++-- bin/fm-wake-drain.sh | 62 ++++++++- docs/configuration.md | 4 + tests/fm-done-verified.test.sh | 210 ++++++++++++++++++++++++++--- tests/fm-pr-check-security.test.sh | 23 +++- 7 files changed, 415 insertions(+), 42 deletions(-) diff --git a/bin/fm-classify-lib.sh b/bin/fm-classify-lib.sh index 9c7fefce55d..a1473f4f170 100755 --- a/bin/fm-classify-lib.sh +++ b/bin/fm-classify-lib.sh @@ -1284,6 +1284,32 @@ $snapshot EOF } +# Pull an already-acknowledged span back to where a bounded section actually +# stopped presenting. is a "\t" list from +# a caller that showed only part of a task's unread span; that task then +# acknowledges exactly the bytes it showed, so the lines it held back stay +# unread and re-present on the next drain instead of being skipped past. Only +# ever moves an endpoint backwards, so it can never acknowledge more than the +# acknowledgement it is given. +status_hold_presented_spans() { # + local acknowledged=$1 held=${2:-} task endpoint ident held_task held_endpoint + while IFS=$(printf '\t') read -r task endpoint ident; do + [ -n "$task" ] || continue + while IFS=$(printf '\t') read -r held_task held_endpoint; do + [ "$held_task" = "$task" ] || continue + case "$held_endpoint" in ''|*[!0-9]*) continue ;; esac + case "$endpoint" in ''|*[!0-9]*) continue ;; esac + [ "$held_endpoint" -lt "$endpoint" ] || continue + endpoint=$held_endpoint + done < local state=$1 snapshot=$2 task endpoint ident f cur_ident size tmp backstop acknowledged_task acknowledged_endpoint tmp="$state/.status-presentation-cursor.tmp.$$" @@ -1490,17 +1516,24 @@ status_line_is_unread_surface() { # # still-unread `note:` or pending-reply resolution, in glob (task id) order. # Prints nothing when none are unread. Directory scan rejects status symlinks # the same way scan_open_decisions does. +# Each row carries the 1-based index of the line within that task's unread span, +# so a caller that presents only part of the span can name where it stopped and +# status_unread_span_endpoint_through can turn that back into a byte offset. The +# index counts every non-blank line in the span, not only the surfaced ones, +# because the cursor advances over bytes rather than over surfaced lines. scan_unread_surface_lines() { # - local state=$1 f task lines line + local state=$1 f task lines line index for f in "$state"/*.status; do [ -e "$f" ] || continue task=$(basename "$f"); task="${task%.status}" lines=$(status_new_lines_since_cursor "$f") || return 1 [ -n "$lines" ] || continue + index=0 while IFS= read -r line; do [ -n "$line" ] || continue + index=$((index + 1)) status_line_is_unread_surface "$line" || continue - printf '%s\t%s\n' "$task" "$line" + printf '%s\t%s\t%s\n' "$task" "$index" "$line" done < - local state=$1 snapshot=$2 task endpoint ident f lines line + local state=$1 snapshot=$2 task endpoint ident f lines line index while IFS=$(printf '\t') read -r task endpoint ident; do [ -n "$task" ] || continue f="$state/$task.status" lines=$(status_new_lines_since_cursor "$f" "$endpoint") || return 1 [ -n "$lines" ] || continue + index=0 while IFS= read -r line; do [ -n "$line" ] || continue + index=$((index + 1)) status_line_is_unread_surface "$line" || continue - printf '%s\t%s\n' "$task" "$line" + printf '%s\t%s\t%s\n' "$task" "$index" "$line" done <th non-blank line of a task's unread span, +# for a caller that presented only that much of it. The status log is +# append-only and the span is contiguous, so this is the SAME cursor the full +# advance uses, stopped earlier - not a second one. An of 0 is the cursor +# itself (nothing presented, nothing acknowledged); an at or past the span's +# last line is the captured endpoint. +status_unread_span_endpoint_through() { # + local f=$1 captured_end=$2 want=$3 offset span_file consumed=0 seen=0 line bytes + [ -f "$f" ] && [ -r "$f" ] && [ ! -L "$f" ] || return 1 + case "$captured_end" in ''|*[!0-9]*) return 1 ;; esac + case "$want" in ''|*[!0-9]*) return 1 ;; esac + offset=$(status_presentation_cursor_offset "$f") || return 1 + case "$offset" in ''|*[!0-9]*) return 1 ;; esac + if [ "$want" -eq 0 ] || [ "$offset" -ge "$captured_end" ]; then + printf '%s' "$offset" + return 0 + fi + span_file="$(_fm_open_decisions_cursor_path "$f").presented.$$" + _fm_status_read_span "$f" "$offset" "$((captured_end - offset))" > "$span_file" 2>/dev/null \ + || { rm -f "$span_file"; return 1; } + while IFS= read -r line || [ -n "$line" ]; do + bytes=$((${#line} + 1)) + consumed=$((consumed + bytes)) + case "$line" in + *[![:space:]]*) + seen=$((seen + 1)) + if [ "$seen" -ge "$want" ]; then break; fi + ;; + esac + done < "$span_file" + rm -f "$span_file" + if [ "$seen" -lt "$want" ] || [ "$((offset + consumed))" -ge "$captured_end" ]; then + printf '%s' "$captured_end" + return 0 + fi + printf '%s' "$((offset + consumed))" +} + # Fold material routed-work phases in the same keyed event stream. # A working or declared-pause event opens or replaces one phase for its key. # A later done, failed, needs-decision, blocked, or resolved event carrying that diff --git a/bin/fm-pr-check.sh b/bin/fm-pr-check.sh index bc4b03ae2b3..a9e78e23744 100755 --- a/bin/fm-pr-check.sh +++ b/bin/fm-pr-check.sh @@ -23,6 +23,8 @@ STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}" . "$SCRIPT_DIR/fm-wake-lib.sh" # shellcheck source=bin/fm-parent-channel-lib.sh . "$SCRIPT_DIR/fm-parent-channel-lib.sh" +# shellcheck source=bin/fm-timeout-lib.sh +. "$SCRIPT_DIR/fm-timeout-lib.sh" if [ "$#" -ne 2 ]; then echo "error: invalid PR check request" >&2 @@ -163,11 +165,19 @@ esac # verifier does, including being absent, erroring, or timing out, and its # outcome never reaches this script's exit status. A task that has not claimed # done yet prints nothing and is not an error. +# Advisory in latency as well as in outcome: the verifier consults the forge and +# the validation run behind its own bounds, and this is a human-facing +# entrypoint that used to return the moment the poll was armed, so the whole +# call sits under one deadline of this script's own. Exceeding it leaves the +# registration exactly as it already is and prints nothing. +VERIFY_TIMEOUT=${FM_PR_CHECK_VERIFY_TIMEOUT:-20} +case "$VERIFY_TIMEOUT" in ''|*[!0-9]*|0) VERIFY_TIMEOUT=20 ;; esac if [ -x "$SCRIPT_DIR/fm-verify-done.sh" ]; then VERIFY_LINE=$(FM_HOME="$FM_HOME" \ FM_STATE_OVERRIDE="$STATE" \ FM_DATA_OVERRIDE="${FM_DATA_OVERRIDE:-}" \ - "$SCRIPT_DIR/fm-verify-done.sh" "$ID" 2>/dev/null | head -1) || VERIFY_LINE= + fm_run_timed "$VERIFY_TIMEOUT" "$SCRIPT_DIR/fm-verify-done.sh" "$ID" 2>/dev/null \ + | head -1) || VERIFY_LINE= [ -z "$VERIFY_LINE" ] || printf 'claim: %s\n' "$VERIFY_LINE" fi printf 'armed: state/%s.check.sh\n' "$ID" diff --git a/bin/fm-verify-done.sh b/bin/fm-verify-done.sh index 242b4e2768e..c69ea94886b 100755 --- a/bin/fm-verify-done.sh +++ b/bin/fm-verify-done.sh @@ -23,11 +23,25 @@ # local-only # - the claim names this task's own branch, exactly fm/ # - the claimed head= resolves in the task's local copy and is the tip of -# that branch, or - once the branch has been retired after the work -# merged - is BOTH the local copy's own HEAD and contained in the local -# default branch. Bare containment is never enough: every commit already -# on the default branch is its own ancestor, so containment alone would -# pass any claim naming the default branch's tip. +# that branch, AND that branch has moved since git created it. +# bin/fm-brief.sh has every worker create fm/ before it does any +# work, so the branch exists at the spawn base from the outset: a branch +# still sitting exactly where it was created is positive evidence that it +# introduced nothing, whatever the claim says. The branch's own history is +# the authority for that, not its merge base with the default branch: +# local-only work is on the default branch by the time it may be torn +# down, so a merge base equal to the branch tip is the ordinary shape of +# LANDED work and would contradict every honest claim. A branch history +# that cannot be read is absence of evidence and reads unverified. +# - once the branch has been retired after the work merged, the claimed head +# must be BOTH the local copy's own HEAD and contained in the local default +# branch. Bare containment is never enough: every commit already on the +# default branch is its own ancestor, so containment alone would pass any +# claim naming the default branch's tip. The merge-base test has no +# counterpart here because the branch ref it needs is gone; what stands in +# its place is that git refuses to delete a branch a worktree has checked +# out, so a worker still sitting on an un-retired fm/ can never +# reach this arm. # scout # - the claimed report= exists as a non-empty regular file. An absolute path # is used as given (what bin/fm-brief.sh renders into a scout brief); a @@ -161,7 +175,15 @@ if [ "$KIND" = scout ]; then data/*) REPORT="$DATA/${REPORT#data/}" ;; *) REPORT="$DATA/$REPORT" ;; esac - if [ -f "$REPORT" ] && [ ! -L "$REPORT" ] && [ -s "$REPORT" ]; then + # A symlink is refused as evidence rather than followed, but refusing to read + # something is not proof that it is false: the report may be perfectly present + # behind the link. That is absence of evidence, so it names the link and reads + # unverified. + if [ -L "$REPORT" ]; then + verdict_is unverified "the claimed report is a symlink, which is not read as evidence: $FM_DONE_CLAIM_REPORT" + finish + fi + if [ -f "$REPORT" ] && [ -s "$REPORT" ]; then verdict_is verified "report present at $FM_DONE_CLAIM_REPORT" else verdict_is contradicted "claimed report is missing or empty: $FM_DONE_CLAIM_REPORT" @@ -215,13 +237,36 @@ if [ "$MODE" = local-only ]; then verdict_is contradicted "claimed commit $HEAD_CLAIM does not exist in the local copy" finish fi + DEFAULT=$(fm_default_branch "$WT" 2>/dev/null || true) + DEFAULT_REF= + if [ -n "$DEFAULT" ] \ + && git -C "$WT" rev-parse --verify --quiet "refs/heads/$DEFAULT^{commit}" >/dev/null 2>&1; then + DEFAULT_REF="refs/heads/$DEFAULT" + fi TIP=$(git -C "$WT" rev-parse --verify --quiet "refs/heads/$BRANCH" 2>/dev/null || true) if [ -n "$TIP" ]; then if [ "$TIP" != "$HEAD_CLAIM" ]; then verdict_is contradicted "branch $BRANCH is at $TIP, not the claimed $HEAD_CLAIM" finish fi - verdict_is verified "branch $BRANCH is at the claimed $HEAD_CLAIM" + # Being the branch tip is not the same as being this task's work. Every + # worker creates fm/ at the spawn base before it does anything, so a + # worker that commits nothing leaves the branch existing and pointing at + # that base. The commit the claim names must be one the branch introduced, + # which is what the branch's own history in git says: a branch still sitting + # exactly where it was created has introduced nothing, whatever is claimed + # of it. Its history being unreadable is absence of evidence, not falsity. + CREATED=$(git -C "$WT" reflog show --format=%H "refs/heads/$BRANCH" 2>/dev/null | tail -1 || true) + case "$CREATED" in *[!0-9a-f]*|'') CREATED= ;; esac + if [ -z "$CREATED" ]; then + verdict_is unverified "branch $BRANCH is at the claimed $HEAD_CLAIM, but its own history could not be read, so what $BRANCH introduced cannot be established" + finish + fi + if [ "$CREATED" = "$HEAD_CLAIM" ]; then + verdict_is contradicted "branch $BRANCH introduced nothing: it is still at $HEAD_CLAIM, the commit it was created at" + finish + fi + verdict_is verified "branch $BRANCH is at the claimed $HEAD_CLAIM, which it introduced after being created at $CREATED" finish fi # The branch is gone. That is the normal end state once local-only work has @@ -241,13 +286,11 @@ if [ "$MODE" = local-only ]; then verdict_is unverified "branch $BRANCH is gone and the local copy's own HEAD is $WT_HEAD, not the claimed $HEAD_CLAIM, so nothing establishes that this task produced it" finish fi - DEFAULT=$(fm_default_branch "$WT" 2>/dev/null || true) - if [ -z "$DEFAULT" ] \ - || ! git -C "$WT" rev-parse --verify --quiet "refs/heads/$DEFAULT^{commit}" >/dev/null 2>&1; then + if [ -z "$DEFAULT_REF" ]; then verdict_is unverified "branch $BRANCH is gone and the local copy has no default branch to check the claimed $HEAD_CLAIM against" finish fi - if git -C "$WT" merge-base --is-ancestor "$HEAD_CLAIM" "refs/heads/$DEFAULT" 2>/dev/null; then + if git -C "$WT" merge-base --is-ancestor "$HEAD_CLAIM" "$DEFAULT_REF" 2>/dev/null; then verdict_is verified "the local copy is at the claimed $HEAD_CLAIM and it is on $DEFAULT; its branch $BRANCH has been retired" finish fi diff --git a/bin/fm-wake-drain.sh b/bin/fm-wake-drain.sh index e49384c1d4f..1ed76700e89 100755 --- a/bin/fm-wake-drain.sh +++ b/bin/fm-wake-drain.sh @@ -349,8 +349,9 @@ EOF # instead. Deliberately bounded where `note:` lines are not: a note is a # deliberate captain-facing answer, while unrecognised prose is a worker writing # into the wrong channel, and one chatty worker must not be able to bury the -# rest of the drain. The count still says how many were held back, so nothing -# disappears silently. +# rest of the drain. The count still says how many were held back, and the +# presentation cursor is held at the last line this drain actually showed, so +# the rest re-present on the next drain rather than being skipped past. UNRECOGNISED_STATUS_MAX=${FM_DRAIN_UNRECOGNISED_MAX:-10} case "$UNRECOGNISED_STATUS_MAX" in ''|*[!0-9]*) UNRECOGNISED_STATUS_MAX=10 ;; esac @@ -363,10 +364,43 @@ case "$UNRECOGNISED_STATUS_MAX" in ''|*[!0-9]*) UNRECOGNISED_STATUS_MAX=10 ;; es # ordinary status line, because it woke firstmate and classified as nothing: the # mark is what stops it being read as a state it never claimed. Prints nothing # when nothing is unread, which is the common case. +# Byte endpoints for tasks whose unread span was only partly presented, in the +# "\t" form status_acknowledge_presented_snapshot takes. +UNREAD_STATUS_HELD_ENDPOINTS= + +# Record where this drain actually stopped presenting , so the lines the +# cap held back stay unread instead of being skipped past. Nothing is recorded +# for a task that was presented whole. +hold_unread_remainder() { # + local task=$1 through=$2 held_back=$3 snapshot=$4 f row_task endpoint ident captured= + [ -n "$task" ] || return 0 + [ "$held_back" -gt 0 ] || return 0 + f="$STATE/$task.status" + if [ -n "$snapshot" ]; then + while IFS=$(printf '\t') read -r row_task endpoint ident; do + [ "$row_task" = "$task" ] || continue + [ -n "$ident" ] || continue + captured=$endpoint + done </dev/null || true) + captured=${captured//[[:space:]]/} + fi + case "$captured" in ''|*[!0-9]*) return 0 ;; esac + endpoint=$(status_unread_span_endpoint_through "$f" "$captured" "$through") || return 0 + case "$endpoint" in ''|*[!0-9]*) return 0 ;; esac + UNREAD_STATUS_HELD_ENDPOINTS="${UNREAD_STATUS_HELD_ENDPOINTS}${task}$(printf '\t')${endpoint} +" +} + print_unread_status_section() { - local snapshot=${1:-} unread task line shown=0 unrecognised=0 omitted=0 + local snapshot=${1:-} unread task index line shown=0 unrecognised=0 omitted=0 + local current='' through=0 held_back=0 local header='UNREAD STATUS (new since last drain, not re-printed after this presentation):' + UNREAD_STATUS_HELD_ENDPOINTS= if [ -n "$snapshot" ]; then unread=$(scan_unread_surface_snapshot "$STATE" "$snapshot") || return 1 else @@ -374,13 +408,21 @@ print_unread_status_section() { fi [ -n "$unread" ] || return 0 - while IFS=$(printf '\t') read -r task line; do + while IFS=$(printf '\t') read -r task index line; do [ -n "$task" ] || continue + [ -n "$index" ] || continue [ -n "$line" ] || continue + if [ "$task" != "$current" ]; then + hold_unread_remainder "$current" "$through" "$held_back" "$snapshot" || return 1 + current=$task + through=0 + held_back=0 + fi if status_line_is_unrecognized "$line"; then unrecognised=$((unrecognised + 1)) if [ "$unrecognised" -gt "$UNRECOGNISED_STATUS_MAX" ]; then omitted=$((omitted + 1)) + held_back=$((held_back + 1)) continue fi fm_cap_line_var "$line" 220 @@ -392,10 +434,12 @@ print_unread_status_section() { printf '%s\n' "$header" || return 1 fi printf '%s\n' "$line" || return 1 + through=$index shown=$((shown + 1)) done < [kind] [mode] local name=$1 kind=${2:-ship} mode=${3:-no-mistakes} dir dir="$TMP_ROOT/$name" @@ -119,10 +134,13 @@ verify() { # [data-root] printf '%s\t%s\n' "$rc" "$out" } -# A local-only world shaped like a real spawn: a repo whose default branch keeps -# advancing, plus a LINKED worktree left where the task was spawned. The task's -# own fm/task-v branch is never created, which is the state a worker that -# committed nothing leaves behind. Prints the case dir. +# A local-only world shaped like a real spawn, per bin/fm-brief.sh's shared +# Setup section: a project repo whose default branch keeps advancing, a LINKED +# worktree added at detached HEAD on that default branch, and - as the brief +# makes every ship worker's FIRST action - `git checkout -b fm/task-v` in that +# worktree BEFORE any work exists. So a worker that commits nothing leaves +# fm/task-v EXISTING and pointing at the spawn base, which is the state the +# branch-exists arm has to judge. Prints "\t". make_local_world() { # local name=$1 dir default dir="$TMP_ROOT/$name" @@ -131,6 +149,7 @@ make_local_world() { # git -C "$dir/repo" commit -q --allow-empty -m base default=$(git -C "$dir/repo" symbolic-ref --quiet --short HEAD) git -C "$dir/repo" worktree add -q --detach "$dir/wt" HEAD + git -C "$dir/wt" checkout -q -b fm/task-v # The default branch moves on after the spawn, so its tip is a commit the task # never produced - exactly the commit a fabricated claim would reach for. git -C "$dir/repo" commit -q --allow-empty -m later @@ -139,6 +158,17 @@ make_local_world() { # printf '%s\t%s\n' "$dir" "$default" } +# Retire fm/task-v the way landing does: the work is merged into the default +# branch, the task's copy leaves the branch, and the branch itself is deleted. +# git refuses to delete a branch a worktree still has checked out, which is why +# a worker that never committed can never present the retired shape. +retire_local_branch() { # + local dir=$1 park=$2 + git -C "$dir/repo" merge -q --no-ff -m landed fm/task-v + git -C "$dir/wt" checkout -q --detach "$park" + git -C "$dir/repo" branch -q -D fm/task-v +} + nm_status() { # printf 'branch: %s\nhead: %s\nstatus: completed\noutcome: passed\n' "$1" "$2" } @@ -335,6 +365,27 @@ test_scout_claim_checks_the_report_exists() { pass "a scout claim is established against the report it names" } +# Refusing to read something is not proof that it is false. The symlink refusal +# stays - a link is not followed as evidence - but a report that is plainly +# present behind it must not be reported as missing, and teardown must not print +# stop-and-investigate wording for it. +test_a_symlinked_scout_report_is_unverified_not_contradicted() { + local dir result + dir=$(make_world scout-symlink scout scout) + mkdir -p "$dir/data/task-v" "$dir/elsewhere" + printf 'findings\n' > "$dir/elsewhere/report.md" + ln -s "$dir/elsewhere/report.md" "$dir/data/task-v/report.md" + printf 'done: report=data/task-v/report.md - found it\n' > "$dir/state/task-v.status" + result=$(verify "$dir") + [ "${result%%$'\t'*}" = 3 ] \ + || fail "a present-but-symlinked scout report was not unverified: $result" + case "$result" in + *"missing or empty"*) fail "the verdict called a present report missing: $result" ;; + esac + case "$result" in *symlink*) ;; *) fail "the verdict did not name the symlink it refused to read: $result" ;; esac + pass "a scout report behind a symlink is unverified, never contradicted" +} + # --- (i) a verdict judges one exact claim ------------------------------------ test_a_verdict_does_not_cover_a_later_claim() { @@ -466,20 +517,68 @@ test_a_fabricated_local_only_claim_is_not_verified() { dir=${out%%$'\t'*} default=${out#*$'\t'} tip=$(git -C "$dir/repo" rev-parse "refs/heads/$default") - # The worker committed nothing and fm/task-v never existed. It claims the - # default branch's tip, which IS contained in the default branch, so bare - # ancestry would pass a claim holding none of this task's work. + # The worker committed nothing, so fm/task-v still sits at the spawn base. It + # claims the default branch's tip, which IS contained in the default branch, + # so bare ancestry would pass a claim holding none of this task's work. printf 'done: branch=fm/task-v head=%s - shipped\n' "$tip" > "$dir/state/task-v.status" result=$(verify "$dir") case "${result#*$'\t'}" in verified:*) fail "a fabricated local-only claim naming the default branch tip was verified: $result" ;; esac - [ "${result%%$'\t'*}" = 3 ] \ - || fail "a fabricated local-only claim did not exit unverified: $result" - case "$result" in *"local copy's own HEAD"*) ;; *) fail "the unverified reason did not name what could not be established: $result" ;; esac + [ "${result%%$'\t'*}" = 4 ] \ + || fail "a claim naming a commit the task's branch is not at was not contradicted: $result" + case "$result" in *"not the claimed $tip"*) ;; *) fail "the contradiction did not name what the branch is actually at: $result" ;; esac pass "a fabricated local-only claim naming the default branch's tip is not verified" } +# The shape that actually occurs in this fleet, and the one bare tip-comparison +# passed: the branch exists because the brief had the worker create it, it still +# points at the spawn base because the worker committed nothing, and the claim +# names that base. Being the branch tip is not the same as being this task's +# work, and a branch that introduced nothing is positive evidence, not absence. +test_a_local_only_claim_naming_the_spawn_base_is_contradicted() { + local out dir base result + out=$(make_local_world local-spawn-base) || fail "the local-only fixture failed" + dir=${out%%$'\t'*} + base=$(git -C "$dir/wt" rev-parse HEAD) + [ "$(git -C "$dir/wt" rev-parse refs/heads/fm/task-v)" = "$base" ] \ + || fail "the fixture did not leave fm/task-v at the spawn base" + printf 'done: branch=fm/task-v head=%s - shipped\n' "$base" > "$dir/state/task-v.status" + result=$(verify "$dir") + case "${result#*$'\t'}" in + verified:*) fail "a claim naming a commit its branch never introduced was verified: $result" ;; + esac + [ "${result%%$'\t'*}" = 4 ] \ + || fail "a branch that introduced nothing was not contradicted: $result" + case "$result" in *"introduced nothing"*) ;; *) fail "the contradiction did not name what was established: $result" ;; esac + pass "a local-only claim naming the spawn base its branch never left is contradicted" +} + +# The honest local-only case, which must stay verified: the branch carries work +# the task actually produced, and the claim names its tip. +test_a_local_only_claim_on_introduced_work_verifies() { + local out dir default tip result + out=$(make_local_world local-real-work) || fail "the local-only fixture failed" + dir=${out%%$'\t'*} + default=${out#*$'\t'} + git -C "$dir/wt" commit -q --allow-empty -m "the task's own work" + tip=$(git -C "$dir/wt" rev-parse HEAD) + printf 'done: branch=fm/task-v head=%s - shipped\n' "$tip" > "$dir/state/task-v.status" + result=$(verify "$dir") + [ "${result%%$'\t'*}" = 0 ] \ + || fail "a claim naming work the task's own branch introduced was not verified: $result" + + # And it must STILL verify once that work is on the default branch with the + # branch left standing, which is the shape local-only work is in by the time + # bin/fm-teardown.sh consults this verdict. A test of the branch's merge base + # with the default branch would call this true claim false. + git -C "$dir/repo" update-ref "refs/heads/$default" "$tip" + result=$(verify "$dir") + [ "${result%%$'\t'*}" = 0 ] \ + || fail "landed local-only work whose branch still stands was not verified: $result" + pass "a local-only claim naming work its branch introduced verifies, landed or not" +} + test_a_local_only_claim_naming_another_branch_is_contradicted() { local out dir tip result out=$(make_local_world local-other-branch) || fail "the local-only fixture failed" @@ -496,22 +595,45 @@ test_a_local_only_claim_naming_another_branch_is_contradicted() { } test_a_local_only_claim_on_a_retired_branch_still_verifies() { - local out dir default landed result + local out dir work result out=$(make_local_world local-retired) || fail "the local-only fixture failed" dir=${out%%$'\t'*} - default=${out#*$'\t'} - # The work landed on the default branch, and the task's own copy sits on the - # commit it produced. The fm/task-v branch itself has been retired. - git -C "$dir/repo" commit -q --allow-empty -m landed - landed=$(git -C "$dir/repo" rev-parse "refs/heads/$default") - git -C "$dir/wt" checkout -q --detach "$landed" - printf 'done: branch=fm/task-v head=%s - shipped\n' "$landed" > "$dir/state/task-v.status" + # The task committed real work, that work landed on the default branch, its + # own copy still sits on the commit it produced, and fm/task-v is retired. + git -C "$dir/wt" commit -q --allow-empty -m "the task's own work" + work=$(git -C "$dir/wt" rev-parse HEAD) + retire_local_branch "$dir" "$work" + git -C "$dir/wt" rev-parse --verify --quiet refs/heads/fm/task-v >/dev/null 2>&1 \ + && fail "the fixture did not actually retire fm/task-v" + printf 'done: branch=fm/task-v head=%s - shipped\n' "$work" > "$dir/state/task-v.status" result=$(verify "$dir") [ "${result%%$'\t'*}" = 0 ] \ || fail "genuinely landed local-only work with a retired branch was not verified: $result" pass "local-only work whose branch was retired after landing still verifies" } +# The other half of the retired arm: the branch is gone, but the task's own copy +# is not sitting on the commit claimed, so nothing establishes that this task +# produced it. Absence of evidence, so unverified rather than contradicted. +test_a_retired_branch_claim_with_the_copy_elsewhere_is_not_verified() { + local out dir default work result + out=$(make_local_world local-retired-elsewhere) || fail "the local-only fixture failed" + dir=${out%%$'\t'*} + default=${out#*$'\t'} + git -C "$dir/wt" commit -q --allow-empty -m "the task's own work" + work=$(git -C "$dir/wt" rev-parse HEAD) + retire_local_branch "$dir" "refs/heads/$default" + printf 'done: branch=fm/task-v head=%s - shipped\n' "$work" > "$dir/state/task-v.status" + result=$(verify "$dir") + case "${result#*$'\t'}" in + verified:*) fail "a retired-branch claim the local copy does not stand behind was verified: $result" ;; + esac + [ "${result%%$'\t'*}" = 3 ] \ + || fail "a retired-branch claim with the copy elsewhere was not unverified: $result" + case "$result" in *"local copy's own HEAD"*) ;; *) fail "the unverified reason did not name what could not be established: $result" ;; esac + pass "a retired-branch claim the task's own copy does not stand behind is not verified" +} + # --- (o)-(p) the durable record resists a downgrade but not a contradiction --- # Establish the claim, then re-run the verifier against the same claim under the @@ -785,13 +907,56 @@ test_the_omitted_unrecognised_count_carries_its_header() { # say what it is counting. FM_DRAIN_UNRECOGNISED_MAX=0 FM_STATE_OVERRIDE="$state" "$DRAIN" > "$out" \ || fail "drain failed with the unrecognised surface capped at zero" - assert_grep 'UNREAD STATUS: 2 more unrecognised line(s) omitted' "$out" \ + assert_grep 'UNREAD STATUS: 2 more unrecognised line(s) held for the next drain' "$out" \ "the omitted unrecognised lines were not counted: $(cat "$out")" assert_grep 'UNREAD STATUS (new since last drain' "$out" \ "the omitted count was printed without its section header: $(cat "$out")" pass "the omitted-unrecognised count is never printed as a bare orphan line" } +# The cap bounds one drain; it must not cost the worker's words. What the cap +# held back stays unread and comes back on the next drain, while what was +# printed is not replayed - the brief is explicit that losing a worker's words +# is worse than showing them. +test_capped_unrecognised_lines_return_on_the_next_drain() { + local dir state status first second third + dir=$(make_case unrecognised-deferred) + state="$dir/state" + status="$state/task9.status" + printf 'note: bootstrap cursor line\n' > "$status" + FM_STATE_OVERRIDE="$state" "$DRAIN" >/dev/null 2>/dev/null \ + || fail "bootstrap drain failed while priming the cursor" + + { + printf 'first prose line from the worker\n' + printf 'second prose line from the worker\n' + printf 'third prose line from the worker\n' + } >> "$status" + + first="$dir/first.out"; second="$dir/second.out"; third="$dir/third.out" + FM_DRAIN_UNRECOGNISED_MAX=1 FM_STATE_OVERRIDE="$state" "$DRAIN" > "$first" \ + || fail "the capped drain failed" + assert_grep 'first prose line' "$first" "the capped drain printed nothing: $(cat "$first")" + assert_no_grep 'second prose line' "$first" "the cap did not hold the second line back" + assert_grep 'UNREAD STATUS: 2 more unrecognised line(s) held' "$first" \ + "the held-back lines were not counted: $(cat "$first")" + + FM_DRAIN_UNRECOGNISED_MAX=1 FM_STATE_OVERRIDE="$state" "$DRAIN" > "$second" \ + || fail "the second drain failed" + assert_grep 'second prose line' "$second" \ + "a line the cap held back never came back: $(cat "$second")" + assert_no_grep 'first prose line' "$second" \ + "a line that was already presented was replayed: $(cat "$second")" + + FM_DRAIN_UNRECOGNISED_MAX=1 FM_STATE_OVERRIDE="$state" "$DRAIN" > "$third" \ + || fail "the third drain failed" + assert_grep 'third prose line' "$third" \ + "the last held-back line never came back: $(cat "$third")" + assert_no_grep 'second prose line' "$third" \ + "an already-presented line was replayed on the third drain: $(cat "$third")" + pass "unrecognised lines the cap holds back re-present instead of being lost" +} + test_claim_grammar_round_trips test_unreachable_forge_is_unverified_never_a_pass test_head_not_the_pr_head_is_contradicted @@ -802,6 +967,7 @@ test_honest_claim_verifies_and_records_the_checks_state test_red_checks_do_not_by_themselves_contradict_a_claim test_checks_state_is_read_from_a_real_rollup test_scout_claim_checks_the_report_exists +test_a_symlinked_scout_report_is_unverified_not_contradicted test_a_verdict_does_not_cover_a_later_claim test_no_claim_is_not_a_verdict test_poll_reports_a_close_as_well_as_a_merge @@ -809,8 +975,11 @@ test_marker_distinguishes_the_two_terminal_outcomes test_drain_flags_a_line_matching_no_status_verb test_known_verbs_are_not_flagged_as_unrecognised test_a_fabricated_local_only_claim_is_not_verified +test_a_local_only_claim_naming_the_spawn_base_is_contradicted +test_a_local_only_claim_on_introduced_work_verifies test_a_local_only_claim_naming_another_branch_is_contradicted test_a_local_only_claim_on_a_retired_branch_still_verifies +test_a_retired_branch_claim_with_the_copy_elsewhere_is_not_verified test_a_transient_unverified_does_not_downgrade_an_established_record test_a_contradiction_still_overwrites_an_established_record test_an_absent_mode_still_checks_the_validated_commit @@ -821,4 +990,5 @@ test_help_prints_the_whole_header test_a_close_does_not_invent_a_done_claim test_a_configured_verb_vocabulary_is_not_unrecognised test_the_omitted_unrecognised_count_carries_its_header +test_capped_unrecognised_lines_return_on_the_next_drain echo "all fm-done-verified tests passed" diff --git a/tests/fm-pr-check-security.test.sh b/tests/fm-pr-check-security.test.sh index eba95c51018..ec4f38ea464 100755 --- a/tests/fm-pr-check-security.test.sh +++ b/tests/fm-pr-check-security.test.sh @@ -147,6 +147,7 @@ case "${1:-} ${2:-}" in esac case " $* " in *"state,headRefOid,url,statusCheckRollup"*) + [ "${FM_TEST_GH_SLEEP:-0}" = 0 ] || sleep "$FM_TEST_GH_SLEEP" printf '%s\t%s\t%s\n' "${FM_TEST_GH_STATE:-OPEN}" \ "${FM_TEST_GH_HEAD:-0123456789abcdef0123456789abcdef01234567}" SUCCESS ;; @@ -633,7 +634,7 @@ run_watcher_bounded() { # advisory: no verifier outcome, including there being nothing to verify, may # change what this script registers, arms, or exits with. test_registration_arms_claim_verification_without_blocking() { - local dir head url other + local dir head url other started elapsed url=https://github.com/o/r/pull/9 head=0123456789abcdef0123456789abcdef01234567 other=89abcdef0123456789abcdef0123456789abcdef @@ -676,6 +677,26 @@ test_registration_arms_claim_verification_without_blocking() { assert_absent "$dir/home/state/task-a.done-verdict" "a task with no claim was given a verdict record" assert_no_grep 'claim:' "$dir/stdout" "a task with no claim printed a verdict line" + # The arming is advisory in LATENCY too: this is a human-facing entrypoint + # that used to return the moment the poll was armed, and the verifier consults + # the forge behind its own bounds. A verifier that runs long is abandoned, and + # the registration it cannot affect is already complete. + dir=$(make_case verify-slow) + fm_write_meta "$dir/home/state/task-a.meta" \ + "window=firstmate:fm-task-a" "worktree=$dir/wt" "kind=ship" "mode=direct-PR" + printf 'done: pr=%s head=%s - shipped\n' "$url" "$head" > "$dir/home/state/task-a.status" + started=$(date +%s) + FM_TEST_GH_HEAD=$head FM_TEST_GH_SLEEP=20 FM_PR_CHECK_VERIFY_TIMEOUT=2 \ + run_check_entry "$dir" task-a "$url" > "$dir/stdout" 2> "$dir/stderr" \ + || fail "a slow verifier changed the registration's exit status: $(cat "$dir/stderr")" + elapsed=$(( $(date +%s) - started )) + [ "$elapsed" -lt 15 ] \ + || fail "registration waited ${elapsed}s on its advisory verification instead of bounding it" + fm_pr_poll_artifacts_valid "$dir/home/state" task-a "$POLL" \ + || fail "a slow verifier stopped the poll from being armed" + assert_grep "armed: state/task-a.check.sh" "$dir/stdout" \ + "a slow verifier stopped the registration reporting what it armed" + pass "PR registration arms claim verification without ever blocking it" } From b55b8e196382a38c468528cb4081d362a6193e89 Mon Sep 17 00:00:00 2001 From: Israel Wilson Date: Thu, 3 Sep 2026 14:28:52 -0400 Subject: [PATCH 06/21] no-mistakes(review): centralise contradiction rule; fix drain cursor, reflog, teardown gate --- bin/fm-done-claim-lib.sh | 37 ++++++++++++ bin/fm-pr-lib.sh | 9 +++ bin/fm-teardown.sh | 19 +++++- bin/fm-verify-done.sh | 93 ++++++++++++++++++++++------ bin/fm-wake-drain.sh | 10 ++- tests/fm-done-verified.test.sh | 107 +++++++++++++++++++++++++++++++++ tests/fm-teardown.test.sh | 80 ++++++++++++++++++++++++ 7 files changed, 334 insertions(+), 21 deletions(-) diff --git a/bin/fm-done-claim-lib.sh b/bin/fm-done-claim-lib.sh index 6d82c473bf6..362a7080b05 100644 --- a/bin/fm-done-claim-lib.sh +++ b/bin/fm-done-claim-lib.sh @@ -170,6 +170,43 @@ fm_done_reason_clean() { # printf '%s' "${1:-}" | LC_ALL=C tr '\n\t\r' ' ' | cut -c1-400 } +# Decide the verdict a caller may actually record, given what it observed. This +# is the one rule the whole done-claim contract rests on, held in the vocabulary's +# owner rather than in each judging site's head: +# +# `contradicted` means POSITIVE EVIDENCE of falsity. Absence of evidence is +# `unverified` - never contradicted, and never a pass. +# +# So a contradiction has to CARRY the observation it contradicts with: the branch +# tip actually read, the head the forge actually reported, the commit the +# validation run actually recorded. A caller asking for `contradicted` with +# nothing observed is recording falsity it did not establish, so this downgrades +# it to `unverified` whatever it asked for. Results land in +# FM_DONE_VERDICT_RESOLVED and FM_DONE_VERDICT_RESOLVED_REASON. +# +# The limit of the guard, named so no reader assumes it is total: it stops a site +# that observed NOTHING from recording falsity. It cannot stop a site that +# observed something real and drew the WRONG INFERENCE from it. The merge-base +# test bin/fm-verify-done.sh once used for local-only work was of that second +# kind - a merge base equal to the branch tip is a true observation that was +# simply the wrong invariant for landed work, and it would pass this guard +# untouched. Only reading an arm's reasoning catches that class. +FM_DONE_VERDICT_RESOLVED= +FM_DONE_VERDICT_RESOLVED_REASON= +fm_done_verdict_resolve() { # [] + local verdict=${1:-} reason=${2:-} observed=${3:-} + # shellcheck disable=SC2034 # Public results consumed by sourcing callers. + FM_DONE_VERDICT_RESOLVED=$verdict + # shellcheck disable=SC2034 + FM_DONE_VERDICT_RESOLVED_REASON=$reason + [ "$verdict" = contradicted ] || return 0 + [ -z "$observed" ] || return 0 + # shellcheck disable=SC2034 + FM_DONE_VERDICT_RESOLVED=unverified + # shellcheck disable=SC2034 + FM_DONE_VERDICT_RESOLVED_REASON="nothing was observed that contradicts the claim, so it is not established either way: $reason" +} + # 0 when a durable record already establishes THIS exact claim as verified. Run # in a subshell so reading it does not clobber a caller's FM_DONE_VERDICT* view. _fm_done_verdict_already_verified() { # diff --git a/bin/fm-pr-lib.sh b/bin/fm-pr-lib.sh index 44ef8de5685..16de882c162 100755 --- a/bin/fm-pr-lib.sh +++ b/bin/fm-pr-lib.sh @@ -990,6 +990,15 @@ fm_pr_poll_merge_marker_matches() { # case "${1:-}" in merged|closed-unmerged) return 0 ;; esac return 1 diff --git a/bin/fm-teardown.sh b/bin/fm-teardown.sh index 4988303e76a..6039404a87a 100755 --- a/bin/fm-teardown.sh +++ b/bin/fm-teardown.sh @@ -2644,13 +2644,28 @@ fi # ESTABLISHED before it may be cleaned up: bin/fm-verify-done.sh reads the forge, # git, and the validation run, and this gate refuses anything it could not # establish (`unverified`) as well as anything it established as false -# (`contradicted`). The cheap local read comes first so a task that never claimed -# done has nothing to verify, passes, and never pays for a subprocess; the +# (`contradicted`). The cheap local reads come first: a task that never claimed +# done has nothing to verify, and a claim a durable verdict record already +# establishes needs nothing re-established, so neither pays for a subprocess. The # landed-work gates above are unchanged and still apply. TEARDOWN_CLAIM= if [ "$KIND" != secondmate ] && [ "$FORCE" != "--force" ]; then TEARDOWN_CLAIM=$(fm_done_claim_last "$STATE/$ID.status") fi +if [ -n "$TEARDOWN_CLAIM" ] && fm_done_claim_status "$STATE" "$ID" \ + && [ "$FM_DONE_CLAIM_STATE" = verified ]; then + # A durable record already establishes THIS EXACT claim (fm_done_claim_status + # matches on the claim's hash, so a newer `done:` line does not inherit it). + # Re-running the verifier could only weaken that: `unverified` is the absence + # of evidence, and the sources it reads go away for ordinary reasons long + # after a claim was established - the forge unreachable, `gh` or `no-mistakes` + # off PATH, the validation run aged out of `axi status`. Deferring to the + # record here is what makes fm_done_verdict_write's anti-downgrade rule mean + # something at the only gate that blocks, and it leaves `--force` (explicit + # discard authority) for work that was never established, not for work that + # was. Every refusal below still stands whenever no such record does. + TEARDOWN_CLAIM= +fi if [ -n "$TEARDOWN_CLAIM" ]; then TEARDOWN_CLAIM_RC=0 TEARDOWN_CLAIM_OUT=$(FM_HOME="$FM_HOME" FM_STATE_OVERRIDE="$STATE" FM_DATA_OVERRIDE="$DATA" \ diff --git a/bin/fm-verify-done.sh b/bin/fm-verify-done.sh index c69ea94886b..577882f02f3 100755 --- a/bin/fm-verify-done.sh +++ b/bin/fm-verify-done.sh @@ -32,7 +32,10 @@ # local-only work is on the default branch by the time it may be torn # down, so a merge base equal to the branch tip is the ordinary shape of # LANDED work and would contradict every honest claim. A branch history -# that cannot be read is absence of evidence and reads unverified. +# that cannot be read is absence of evidence and reads unverified, and so +# is one whose oldest surviving reflog entry is no longer the creation +# (`git gc` prunes entries past gc.reflogExpire): the entry must say it is +# the creation before anything is concluded from it. # - once the branch has been retired after the work merged, the claimed head # must be BOTH the local copy's own HEAD and contained in the local default # branch. Bare containment is never enough: every commit already on the @@ -68,6 +71,11 @@ # contradicted exit 4 - a required fact WAS established and the claim is false. # exit 2 - the task has no terminal claim to verify, or the request is invalid. # +# The line between the last two is enforced by fm_done_verdict_resolve (in +# bin/fm-done-claim-lib.sh), which every arm here records through, rather than +# left to each arm's care: a `contradicted` verdict must carry the observation it +# contradicts with, and one that carries none is recorded as `unverified`. +# # The verdict record binds to the exact claim line it judged, so appending a new # `done:` line invalidates it rather than inheriting its verdict. set -u @@ -119,9 +127,16 @@ meta_field() { # VERDICT= REASON= -verdict_is() { # - VERDICT=$1 - REASON=$2 +# Every arm below records its verdict through here, and here defers to +# fm_done_verdict_resolve in bin/fm-done-claim-lib.sh - the owner of the verdict +# vocabulary - so the rule that `contradicted` must carry the observation it +# contradicts with is one implementation shared by every judging site, testable +# on its own, rather than a habit each arm has to remember. Read that helper for +# the rule and for the limit of what it can enforce. +verdict_is() { # [] + fm_done_verdict_resolve "$1" "$2" "${3:-}" + VERDICT=$FM_DONE_VERDICT_RESOLVED + REASON=$FM_DONE_VERDICT_RESOLVED_REASON } # Record the outcome and exit. The record is written before the exit code is @@ -167,7 +182,7 @@ fi if [ "$KIND" = scout ]; then REPORT=$FM_DONE_CLAIM_REPORT if [ -z "$REPORT" ]; then - verdict_is contradicted 'scout claim names no report' + verdict_is contradicted 'scout claim names no report' "$FM_DONE_CLAIM_LINE" finish fi case "$REPORT" in @@ -185,8 +200,15 @@ if [ "$KIND" = scout ]; then fi if [ -f "$REPORT" ] && [ -s "$REPORT" ]; then verdict_is verified "report present at $FM_DONE_CLAIM_REPORT" + elif [ ! -e "$REPORT" ]; then + verdict_is contradicted "claimed report is missing: $FM_DONE_CLAIM_REPORT" \ + "nothing exists at $REPORT" + elif [ ! -f "$REPORT" ]; then + verdict_is contradicted "claimed report is not a file: $FM_DONE_CLAIM_REPORT" \ + "$REPORT is not a regular file" else - verdict_is contradicted "claimed report is missing or empty: $FM_DONE_CLAIM_REPORT" + verdict_is contradicted "claimed report is empty: $FM_DONE_CLAIM_REPORT" \ + "$REPORT is a regular file of zero bytes" fi finish fi @@ -219,22 +241,31 @@ fi if [ "$MODE" = local-only ]; then BRANCH=$FM_DONE_CLAIM_BRANCH if [ -z "$BRANCH" ]; then - verdict_is contradicted 'local-only claim names no branch' + verdict_is contradicted 'local-only claim names no branch' "$FM_DONE_CLAIM_LINE" finish fi # A local-only task's branch is not a free field: bin/fm-brief.sh renders the # claim template with this task's own fm/. A claim naming anything else is # not this task's work, however true it may be of some other branch. if [ "$BRANCH" != "fm/$ID" ]; then - verdict_is contradicted "claim names branch $BRANCH, not this task's fm/$ID" + verdict_is contradicted "claim names branch $BRANCH, not this task's fm/$ID" "$BRANCH" finish fi if [ -z "$WT" ] || [ ! -d "$WT" ]; then verdict_is unverified 'the local copy is gone, so the claimed commit cannot be established' finish fi + # Asked before anything is concluded from a git answer: a directory that is + # not a readable repository answers "no such commit" to every question, and + # reading that as falsity would contradict every honest claim made from a + # copy whose .git has gone missing. + if ! git -C "$WT" rev-parse --git-dir >/dev/null 2>&1; then + verdict_is unverified "the local copy at $WT is not a readable git repository, so the claimed commit cannot be established" + finish + fi if ! git -C "$WT" rev-parse --verify --quiet "$HEAD_CLAIM^{commit}" >/dev/null 2>&1; then - verdict_is contradicted "claimed commit $HEAD_CLAIM does not exist in the local copy" + verdict_is contradicted "claimed commit $HEAD_CLAIM does not exist in the local copy" \ + "the readable object database at $WT holds no commit $HEAD_CLAIM" finish fi DEFAULT=$(fm_default_branch "$WT" 2>/dev/null || true) @@ -246,7 +277,7 @@ if [ "$MODE" = local-only ]; then TIP=$(git -C "$WT" rev-parse --verify --quiet "refs/heads/$BRANCH" 2>/dev/null || true) if [ -n "$TIP" ]; then if [ "$TIP" != "$HEAD_CLAIM" ]; then - verdict_is contradicted "branch $BRANCH is at $TIP, not the claimed $HEAD_CLAIM" + verdict_is contradicted "branch $BRANCH is at $TIP, not the claimed $HEAD_CLAIM" "$TIP" finish fi # Being the branch tip is not the same as being this task's work. Every @@ -256,14 +287,37 @@ if [ "$MODE" = local-only ]; then # which is what the branch's own history in git says: a branch still sitting # exactly where it was created has introduced nothing, whatever is claimed # of it. Its history being unreadable is absence of evidence, not falsity. - CREATED=$(git -C "$WT" reflog show --format=%H "refs/heads/$BRANCH" 2>/dev/null | tail -1 || true) + # + # The OLDEST SURVIVING reflog entry is only the creation point while the + # creation entry survives. `git gc` prunes entries past gc.reflogExpire (90 + # days by default), and on a long-lived branch that leaves the oldest + # surviving entry somewhere in the middle of the branch's life - possibly + # the tip itself, which would read a true claim as falsity. So the entry is + # required to SAY it is the creation ("branch: Created from ..."), which is + # what git writes for `git checkout -b fm/` (bin/fm-brief.sh's first + # instruction to every worker). Anything else is a history that no longer + # reaches back to the creation, which is absence of evidence. + CREATED_ENTRY=$(git -C "$WT" reflog show --format='%H %gs' "refs/heads/$BRANCH" 2>/dev/null | tail -1 || true) + CREATED=${CREATED_ENTRY%% *} + case "$CREATED_ENTRY" in + *' '*) CREATED_MSG=${CREATED_ENTRY#* } ;; + *) CREATED_MSG= ;; + esac case "$CREATED" in *[!0-9a-f]*|'') CREATED= ;; esac if [ -z "$CREATED" ]; then verdict_is unverified "branch $BRANCH is at the claimed $HEAD_CLAIM, but its own history could not be read, so what $BRANCH introduced cannot be established" finish fi + case "$CREATED_MSG" in + 'branch: Created from '*) ;; + *) + verdict_is unverified "branch $BRANCH is at the claimed $HEAD_CLAIM, but the oldest surviving reflog entry for it is not its creation, so what $BRANCH introduced cannot be established" + finish + ;; + esac if [ "$CREATED" = "$HEAD_CLAIM" ]; then - verdict_is contradicted "branch $BRANCH introduced nothing: it is still at $HEAD_CLAIM, the commit it was created at" + verdict_is contradicted "branch $BRANCH introduced nothing: it is still at $HEAD_CLAIM, the commit it was created at" \ + "$BRANCH was created at $CREATED and its tip is still $TIP" finish fi verdict_is verified "branch $BRANCH is at the claimed $HEAD_CLAIM, which it introduced after being created at $CREATED" @@ -294,18 +348,20 @@ if [ "$MODE" = local-only ]; then verdict_is verified "the local copy is at the claimed $HEAD_CLAIM and it is on $DEFAULT; its branch $BRANCH has been retired" finish fi - verdict_is contradicted "branch $BRANCH is gone and the claimed $HEAD_CLAIM is not on $DEFAULT" + DEFAULT_TIP=$(git -C "$WT" rev-parse --verify --quiet "$DEFAULT_REF" 2>/dev/null || true) + verdict_is contradicted "branch $BRANCH is gone and the claimed $HEAD_CLAIM is not on $DEFAULT" \ + "${DEFAULT_TIP:+$DEFAULT is at $DEFAULT_TIP and does not contain $HEAD_CLAIM}" finish fi # --- PR modes ---------------------------------------------------------------- PR_CLAIM=$FM_DONE_CLAIM_PR if [ -z "$PR_CLAIM" ]; then - verdict_is contradicted 'claim names no PR' + verdict_is contradicted 'claim names no PR' "$FM_DONE_CLAIM_LINE" finish fi if ! fm_pr_url_parse "$PR_CLAIM"; then - verdict_is contradicted "claim names an unparseable PR reference: $PR_CLAIM" + verdict_is contradicted "claim names an unparseable PR reference: $PR_CLAIM" "$PR_CLAIM" finish fi PROVIDER=$FM_PR_PROVIDER @@ -356,7 +412,8 @@ CHECKS=${REST#*$'\t'} case "$PR_STATE" in CLOSED|closed) - verdict_is contradicted "$PR_URL is closed without merging, so this task is not done" + verdict_is contradicted "$PR_URL is closed without merging, so this task is not done" \ + "the forge reports state $PR_STATE for $PR_URL" finish ;; OPEN|open|MERGED|merged) ;; @@ -371,7 +428,7 @@ if [ -z "$PR_HEAD" ]; then finish fi if [ "$PR_HEAD" != "$HEAD_CLAIM" ]; then - verdict_is contradicted "$PR_URL is at $PR_HEAD, not the claimed $HEAD_CLAIM" + verdict_is contradicted "$PR_URL is at $PR_HEAD, not the claimed $HEAD_CLAIM" "$PR_HEAD" finish fi @@ -411,7 +468,7 @@ fi # The run may abbreviate; compare on the shorter of the two, which is exact for # any abbreviation git itself would produce. if [ "${HEAD_CLAIM#"$RUN_HEAD"}" = "$HEAD_CLAIM" ]; then - verdict_is contradicted "validation ran against $RUN_HEAD, but the claim ships $HEAD_CLAIM" + verdict_is contradicted "validation ran against $RUN_HEAD, but the claim ships $HEAD_CLAIM" "$RUN_HEAD" finish fi RUN_OUTCOME=$(fm_nm_strip_quotes "$(fm_nm_field "$RUN_OUT" outcome)") diff --git a/bin/fm-wake-drain.sh b/bin/fm-wake-drain.sh index 1ed76700e89..b23236a0e3f 100755 --- a/bin/fm-wake-drain.sh +++ b/bin/fm-wake-drain.sh @@ -434,7 +434,15 @@ print_unread_status_section() { printf '%s\n' "$header" || return 1 fi printf '%s\n' "$line" || return 1 - through=$index + # The acknowledgement is ONE contiguous byte offset, so `through` can only + # ever mean "everything up to here was presented". Once this task has held a + # line back, no later index may be recorded: an endpoint past the omission + # would acknowledge the held-back lines as presented and they would never + # appear in any drain again, while this drain has just told the operator they + # were held. Freezing it means the lines printed after the first omission + # re-present on the next drain, which is the safe direction: showing a line + # twice costs the operator a second read, losing it costs a worker's words. + [ "$held_back" -gt 0 ] || through=$index shown=$((shown + 1)) done < "$dir/state/task-v.status" + result=$(verify "$dir") + [ "${result%%$'\t'*}" != 4 ] \ + || fail "a true claim was contradicted because the branch's creation entry had expired: $result" + [ "${result%%$'\t'*}" = 3 ] \ + || fail "a branch history that no longer reaches its creation was not unverified: $result" + case "$result" in + *"oldest surviving reflog entry"*) ;; + *) fail "the unverified reason did not name the unreadable history: $result" ;; + esac + pass "a branch whose creation entry has been pruned reads unverified, never contradicted" +} + +# The rule the whole contract rests on, exercised through its owner rather than +# left to each judging site to remember: `contradicted` is positive evidence of +# falsity, so it must carry the observation it contradicts with. A site that can +# name nothing it observed records `unverified` however it asked to be recorded. +test_a_contradiction_needs_the_observation_it_contradicts_with() { + fm_done_verdict_resolve contradicted 'the branch introduced nothing' '' + [ "$FM_DONE_VERDICT_RESOLVED" = unverified ] \ + || fail "a contradiction with nothing observed was recorded as $FM_DONE_VERDICT_RESOLVED" + case "$FM_DONE_VERDICT_RESOLVED_REASON" in + *"nothing was observed"*"the branch introduced nothing"*) ;; + *) fail "the downgraded reason lost either the rule or the caller's words: $FM_DONE_VERDICT_RESOLVED_REASON" ;; + esac + + fm_done_verdict_resolve contradicted 'the branch introduced nothing' 'fm/task-v is still at abc123' + [ "$FM_DONE_VERDICT_RESOLVED" = contradicted ] \ + || fail "an ordinary contradiction carrying its observation was downgraded" + [ "$FM_DONE_VERDICT_RESOLVED_REASON" = 'the branch introduced nothing' ] \ + || fail "an established contradiction had its reason rewritten: $FM_DONE_VERDICT_RESOLVED_REASON" + + # The other two verdicts are untouched by the rule; only falsity needs evidence. + fm_done_verdict_resolve unverified 'the forge could not be reached' '' + [ "$FM_DONE_VERDICT_RESOLVED" = unverified ] || fail "an unverified verdict was rewritten" + fm_done_verdict_resolve verified 'the PR is at the claimed head' '' + [ "$FM_DONE_VERDICT_RESOLVED" = verified ] || fail "a verified verdict was rewritten" + pass "a contradiction must name what it observed, and one that cannot reads unverified" +} + +# The mixed span, which is the shape that actually loses words: prose past the +# cap, then a line the drain DOES recognise. The acknowledgement is one +# contiguous byte offset, so a `note:` printed after an omission would carry the +# cursor past the held-back prose and retire it as presented - while this same +# drain has just told the operator it was held for the next one. +test_capped_lines_return_even_when_a_recognised_line_follows_them() { + local dir state status first second i + dir=$(make_case unrecognised-mixed-span) + state="$dir/state" + status="$state/task9.status" + printf 'note: bootstrap cursor line\n' > "$status" + FM_STATE_OVERRIDE="$state" "$DRAIN" >/dev/null 2>/dev/null \ + || fail "bootstrap drain failed while priming the cursor" + + for i in 1 2 3; do + printf 'prose line %s from the worker\n' "$i" >> "$status" + done + printf 'note: here is the answer you asked for\n' >> "$status" + + first="$dir/first.out"; second="$dir/second.out" + FM_DRAIN_UNRECOGNISED_MAX=1 FM_STATE_OVERRIDE="$state" "$DRAIN" > "$first" \ + || fail "the capped drain failed on a mixed span" + assert_grep 'prose line 1 from the worker' "$first" \ + "the capped drain printed no prose at all: $(cat "$first")" + assert_grep 'UNREAD STATUS: 2 more unrecognised line(s) held' "$first" \ + "the held-back prose was not counted: $(cat "$first")" + + FM_DRAIN_UNRECOGNISED_MAX=9 FM_STATE_OVERRIDE="$state" "$DRAIN" > "$second" \ + || fail "the second drain failed" + assert_grep 'prose line 2 from the worker' "$second" \ + "prose the cap held back was retired by a recognised line printed after it: $(cat "$second")" + assert_grep 'prose line 3 from the worker' "$second" \ + "the rest of the held-back prose never came back: $(cat "$second")" + assert_no_grep 'prose line 1 from the worker' "$second" \ + "a line that was already presented was replayed: $(cat "$second")" + pass "held-back prose survives a recognised line printed later in the same span" +} + test_the_omitted_unrecognised_count_carries_its_header() { local dir state status out dir=$(make_case unrecognised-cap-zero) @@ -989,6 +1093,9 @@ test_an_unresolvable_absent_mode_is_unverified_never_contradicted test_help_prints_the_whole_header test_a_close_does_not_invent_a_done_claim test_a_configured_verb_vocabulary_is_not_unrecognised +test_a_pruned_reflog_is_unverified_not_contradicted +test_a_contradiction_needs_the_observation_it_contradicts_with test_the_omitted_unrecognised_count_carries_its_header test_capped_unrecognised_lines_return_on_the_next_drain +test_capped_lines_return_even_when_a_recognised_line_follows_them echo "all fm-done-verified tests passed" diff --git a/tests/fm-teardown.test.sh b/tests/fm-teardown.test.sh index c4af47a8aca..1d42e34026f 100755 --- a/tests/fm-teardown.test.sh +++ b/tests/fm-teardown.test.sh @@ -47,6 +47,8 @@ # (z3) claim naming a commit that is not the branch -> REFUSE (contradicted) # (z4) claim naming the branch tip -> ALLOW (verified) # (z5) contradicted claim + --force -> ALLOW (escape hatch) +# (z6) established claim, verifier's sources then gone -> ALLOW (the record stands) +# (z7) same blinded world with NO record standing -> REFUSE (unverified) # # Also covers backlog teardown-lock-race: a git index.lock left in the worktree by a # killed crew process (bin/fm-teardown.sh's teardown_treehouse_return). @@ -2832,6 +2834,82 @@ test_established_claim_allows_teardown() { pass "a claim naming the branch tip is established and allows cleanup" } +# The record is what establishes a claim, not the verifier's exit code on the +# day cleanup happens. A claim established at PR registration must still clear +# the gate days later when the sources the verifier reads have gone away for +# ordinary reasons - the forge unreachable, `gh` or `no-mistakes` off PATH, the +# branch's reflog expired - because `unverified` is the absence of evidence and +# must not un-establish work that was genuinely established. Otherwise `--force`, +# which AGENTS.md ties to explicit discard authority, becomes the only way to +# clean up honest work. +run_verify_done() { # + FM_ROOT_OVERRIDE="$ROOT" \ + FM_HOME="$1" \ + FM_STATE_OVERRIDE="$1/state" \ + FM_DATA_OVERRIDE="$1/data" \ + "$ROOT/bin/fm-verify-done.sh" task-x1 +} + +# Take away the one thing the local-only arm needs to establish that fm/task-x1 +# introduced its claimed commit: the branch's own history. This is what an +# expired reflog leaves behind, and it is absence of evidence, not falsity. +blind_the_local_only_verifier() { # + local reflog + reflog="$(git -C "$1/wt" rev-parse --git-common-dir)/logs/refs/heads/fm/task-x1" + [ -f "$reflog" ] || fail "the fixture had no branch reflog to remove" + rm -f "$reflog" +} + +test_an_established_claim_survives_losing_the_verifier_sources() { + local case_dir rc=0 head + case_dir=$(make_claim_case claim-established "placeholder") + head=$(git -C "$case_dir/wt" rev-parse HEAD) + printf 'done: branch=fm/task-x1 head=%s - shipped\n' "$head" \ + > "$case_dir/state/task-x1.status" + run_verify_done "$case_dir" > "$case_dir/verify.out" 2>&1 \ + || fail "the honest claim was not established in the first place: $(cat "$case_dir/verify.out")" + [ -f "$case_dir/state/task-x1.done-verdict" ] \ + || fail "verifying the claim wrote no durable record" + + blind_the_local_only_verifier "$case_dir" + set +e + run_verify_done "$case_dir" > "$case_dir/verify2.out" 2>&1 + rc=$? + set -e + [ "$rc" -eq 3 ] \ + || fail "the fixture did not actually blind the verifier (rc=$rc): $(cat "$case_dir/verify2.out")" + + set +e + run_teardown "$case_dir" > "$case_dir/stdout" 2> "$case_dir/stderr" + rc=$? + set -e + expect_code 0 "$rc" \ + "a claim a durable record already established was refused once its sources went away: $(cat "$case_dir/stderr")" + pass "an established claim still clears the gate after the verifier's sources go away" +} + +# The other half of the same rule: deferring to the record is not deferring to +# nothing. With no record standing, the identical blinded world still refuses. +test_an_unestablished_claim_still_refuses_without_a_record() { + local case_dir rc=0 head + case_dir=$(make_claim_case claim-unestablished "placeholder") + head=$(git -C "$case_dir/wt" rev-parse HEAD) + printf 'done: branch=fm/task-x1 head=%s - shipped\n' "$head" \ + > "$case_dir/state/task-x1.status" + blind_the_local_only_verifier "$case_dir" + [ ! -e "$case_dir/state/task-x1.done-verdict" ] \ + || fail "the fixture left a verdict record behind" + set +e + run_teardown "$case_dir" > "$case_dir/stdout" 2> "$case_dir/stderr" + rc=$? + set -e + [ "$rc" -ne 0 ] \ + || fail "a claim nothing had ever established was torn down as done" + assert_grep "could not be established" "$case_dir/stderr" \ + "the refusal did not say the claim could not be established" + pass "a claim with no record standing still refuses when it cannot be established" +} + test_force_overrides_a_contradicted_claim() { local case_dir rc=0 other case_dir=$(make_claim_case claim-force "placeholder") @@ -2852,6 +2930,8 @@ test_legacy_claim_without_commit_identity_refuses test_claim_naming_the_wrong_commit_refuses test_established_claim_allows_teardown test_force_overrides_a_contradicted_claim +test_an_established_claim_survives_losing_the_verifier_sources +test_an_unestablished_claim_still_refuses_without_a_record test_local_only_fork_remote_allows test_teardown_closes_the_backlog_item_itself test_teardown_manual_backend_leaves_the_backlog_to_the_operator From fc9cd425c72d96e6945a381e7d44af38bd618ea7 Mon Sep 17 00:00:00 2001 From: Israel Wilson Date: Thu, 3 Sep 2026 14:54:54 -0400 Subject: [PATCH 07/21] no-mistakes(review): record stale and contradicted verdicts on PR outcomes --- AGENTS.md | 2 + bin/fm-classify-lib.sh | 8 ++ bin/fm-done-claim-lib.sh | 93 ++++++++++---- bin/fm-merge-outcome-lib.sh | 61 +++++++-- bin/fm-verify-done.sh | 11 ++ bin/fm-wake-drain.sh | 15 ++- tests/fm-done-verified.test.sh | 228 +++++++++++++++++++++++++++++++++ tests/fm-teardown.test.sh | 69 ++++++++++ 8 files changed, 450 insertions(+), 37 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 4bbe99260ba..5e3b1430bc5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -374,6 +374,8 @@ The worker reports the PR when CI first becomes green rather than waiting for me A terminal `done:` claim names the exact commit, PR, branch, or report it is claiming, and only `bin/fm-verify-done.sh` decides whether that claim is true; `bin/fm-done-claim-lib.sh` owns the grammar and the durable verdict. A claim that has not been established is never done: `bin/fm-crew-state.sh` reports it as `done-unverified`, and teardown refuses it. +Terminal evidence about a claim has three shapes, not two, and the verdict record names all three: `unverified` is absence of evidence and never downgrades what already stands, `contradicted` is positive evidence of falsity and overwrites anything, and `stale` is the world having changed under a verdict that was true when it was made, which `bin/fm-merge-outcome-lib.sh` records when a PR merges or closes under a standing claim. +A `stale` verdict is not established either: it reports as `done-unverified` and teardown re-runs `bin/fm-verify-done.sh` rather than passing on it. Run `bin/fm-pr-check.sh ` with the URL copied from the terminal claim's `pr=` field or the task's `pr=` metadata, never assembled from memory - it records `pr=` and the forge's `pr_head=` when available in the task's meta and arms the watcher's poll, which reports a merge and a close without merge alike. That registration also runs `bin/fm-verify-done.sh` for the task and prints its verdict, so a claim is established while the forge is already being consulted rather than only at cleanup; the verdict is advisory there and never changes what the registration records, arms, or exits with. Tell the captain the PR's full URL, always the complete `https://...` link rather than a bare `#number`, copied verbatim from the claim's `pr=` field or the task's `pr=` metadata, a concise outcome summary, and the no-mistakes risk level when applicable. diff --git a/bin/fm-classify-lib.sh b/bin/fm-classify-lib.sh index a1473f4f170..0301ca77996 100755 --- a/bin/fm-classify-lib.sh +++ b/bin/fm-classify-lib.sh @@ -1570,6 +1570,14 @@ EOF # last line is the captured endpoint. status_unread_span_endpoint_through() { # local f=$1 captured_end=$2 want=$3 offset span_file consumed=0 seen=0 line bytes + # The cursor is a BYTE offset, and ${#line} below counts CHARACTERS under any + # UTF-8 locale. A worker's prose is arbitrary text, so one multibyte character + # anywhere in a partly presented span would land the committed cursor mid-line + # and make the next drain read the tail of an already-presented line as a whole + # new status line - a fabricated line, in the one surface whose whole purpose + # is showing a worker's words faithfully. Counting in C makes the arithmetic + # bytes; bash restores the caller's locale when this function returns. + local LC_ALL=C [ -f "$f" ] && [ -r "$f" ] && [ ! -L "$f" ] || return 1 case "$captured_end" in ''|*[!0-9]*) return 1 ;; esac case "$want" in ''|*[!0-9]*) return 1 ;; esac diff --git a/bin/fm-done-claim-lib.sh b/bin/fm-done-claim-lib.sh index 362a7080b05..d43b1d98159 100644 --- a/bin/fm-done-claim-lib.sh +++ b/bin/fm-done-claim-lib.sh @@ -26,13 +26,38 @@ # # Verdict record: state/.done-verdict, five lines, # fm-done-verdict-v1 -# +# # # # # The claim hash binds a verdict to one exact claim, so appending a new `done:` # line invalidates the old verdict instead of inheriting it. # +# THE THREE SHAPES OF TERMINAL EVIDENCE. Evidence about a standing claim comes in +# three shapes, not two, and conflating the third with the first is what let a +# `done` record survive the close of its own PR: +# +# 1. Absence of evidence - we could not check. The forge was unreachable, a +# tool was off PATH, a reflog had been pruned, a validation run had aged +# out. The world has not changed; we simply failed to look. This must NEVER +# downgrade a standing verdict. It is `unverified`. +# 2. Positive evidence of falsity - we looked and the claim is false. The PR +# was closed without merging. This is `contradicted`, and it overwrites +# anything. +# 3. The world changed - the PR merged, possibly at a head nobody verified. +# The standing verdict is neither wrong nor still valid: it is a true +# statement about a world that no longer exists. This is `stale`, a +# distinct third state. The rule protecting a record from an outage was +# written for shape 1; it was never meant to preserve a verdict about a +# superseded world. +# +# `stale` is not established. Every consumer treats it the way it treats +# `unverified` - bin/fm-crew-state.sh reports `done-unverified`, and +# bin/fm-teardown.sh's record-first gate re-runs the verifier rather than +# passing on it, which is the point: the expensive check is deferred to the gate +# that actually needs the answer. The write precedence that keeps the three +# apart is stated once, on fm_done_verdict_write below. +# # Read-only apart from fm_done_verdict_write. Sourced by bin/fm-verify-done.sh, # bin/fm-crew-state.sh, bin/fm-teardown.sh, bin/fm-dod-lib.sh, bin/fm-brief.sh # and tests; no side effects on source. @@ -207,36 +232,53 @@ fm_done_verdict_resolve() { # [] FM_DONE_VERDICT_RESOLVED_REASON="nothing was observed that contradicts the claim, so it is not established either way: $reason" } -# 0 when a durable record already establishes THIS exact claim as verified. Run -# in a subshell so reading it does not clobber a caller's FM_DONE_VERDICT* view. -_fm_done_verdict_already_verified() { # +# The verdict a durable record currently holds for THIS exact claim, or empty +# when no record stands for it. Run in a subshell so reading it does not clobber +# a caller's FM_DONE_VERDICT* view. +_fm_done_verdict_standing() { # ( - fm_done_verdict_read "$1" "$2" || exit 1 - [ "$FM_DONE_VERDICT" = verified ] || exit 1 - [ "$FM_DONE_VERDICT_CLAIM_HASH" = "$3" ] || exit 1 + fm_done_verdict_read "$1" "$2" || exit 0 + [ "$FM_DONE_VERDICT_CLAIM_HASH" = "$3" ] || exit 0 + printf '%s' "$FM_DONE_VERDICT" ) } -# Write the durable verdict record, with one refusal: an `unverified` verdict -# never overwrites a record that already establishes the SAME claim as -# `verified`. Unverified is only the absence of evidence - a forge that could -# not be reached, a tool missing from PATH - so letting it overwrite would let a -# transient outage un-establish work that was genuinely established. A -# `contradicted` verdict still overwrites, because that is positive evidence of -# falsity. The refusal protects only the DURABLE record: the run that observed -# the transient unverified result still prints and exits with it (see finish() -# in bin/fm-verify-done.sh), so nothing hides the outage from its caller. -# Returns 0 for that refusal, because the record already holds a stronger -# verdict for this claim and there is nothing left to record. +# Write the durable verdict record, subject to the write precedence stated at +# the top of this file: an incoming verdict never replaces a standing one that +# says something stronger about the SAME claim. +# +# contradicted always written. Positive evidence of falsity outranks +# everything, including a `verified` established earlier. +# verified always written. It is the verifier's fresh look at the world. +# stale written unless the standing record is already `contradicted`, +# which is the stronger statement. Stale says the world moved +# past what was established, not that the claim was false. +# unverified written only when nothing stands for this claim, or what +# stands is itself `unverified`. Absence of evidence is not +# evidence: a forge that could not be reached or a tool missing +# from PATH must never un-establish, un-contradict, or un-stale +# a record, because the world did not change - we failed to look. +# +# The precedence protects only the DURABLE record: the run that observed the +# refused verdict still prints and exits with it (see finish() in +# bin/fm-verify-done.sh), so nothing hides an outage from its caller. A refusal +# returns 0, because the record already holds the stronger verdict for this +# claim and there is nothing left to record. fm_done_verdict_write() { # - local state=$1 id=$2 verdict=$3 hash=$4 reason=$5 path tmp - case "$verdict" in verified|unverified|contradicted) ;; *) return 2 ;; esac + local state=$1 id=$2 verdict=$3 hash=$4 reason=$5 path tmp standing + case "$verdict" in verified|unverified|contradicted|stale) ;; *) return 2 ;; esac case "$hash" in *[!0-9a-f]*|'') return 2 ;; esac [ "${#hash}" -eq 64 ] || return 2 [ -d "$state" ] && [ ! -L "$state" ] || return 1 - if [ "$verdict" = unverified ] && _fm_done_verdict_already_verified "$state" "$id" "$hash"; then - return 0 - fi + standing=$(_fm_done_verdict_standing "$state" "$id" "$hash") + case "$verdict" in + unverified) + case "$standing" in ''|unverified) ;; *) return 0 ;; esac + ;; + stale) + [ "$standing" != contradicted ] || return 0 + ;; + esac path=$(fm_done_verdict_path "$state" "$id") [ ! -L "$path" ] || return 1 umask 077 @@ -274,7 +316,7 @@ fm_done_verdict_read() { # fi exec 7<&- [ "$version" = "$FM_DONE_VERDICT_VERSION" ] || return 1 - case "$verdict" in verified|unverified|contradicted) ;; *) return 1 ;; esac + case "$verdict" in verified|unverified|contradicted|stale) ;; *) return 1 ;; esac case "$hash" in *[!0-9a-f]*|'') return 1 ;; esac [ "${#hash}" -eq 64 ] || return 1 case "$epoch" in ''|*[!0-9]*) return 1 ;; esac @@ -296,6 +338,9 @@ fm_done_verdict_read() { # # legacy claim), no verdict yet, a verdict for a superseded # claim, or a verifier that could not reach its sources. # contradicted the claim was established and is false. +# stale the claim was established, and its PR then reached a terminal +# state the establishment did not cover. Not established: the +# verdict is a true statement about a superseded world. # # Pure read; returns 0 whatever the outcome, so a caller branches on the state # rather than on an exit code. diff --git a/bin/fm-merge-outcome-lib.sh b/bin/fm-merge-outcome-lib.sh index 915d674adf2..82391551057 100755 --- a/bin/fm-merge-outcome-lib.sh +++ b/bin/fm-merge-outcome-lib.sh @@ -12,6 +12,14 @@ # with no claim on record; the publication then reports the # close plainly rather than inventing a claim to contradict. # +# Publishing is only half of it: this is also where the task's DURABLE verdict +# record is corrected, because this is the one site that observes the outcome +# and every later gate reads that record rather than re-observing. A close +# records `contradicted` for the standing claim; a merge marks an established +# claim `stale`, because the world moved past what was established. See +# bin/fm-done-claim-lib.sh for the three shapes of terminal evidence and the +# write precedence that keeps them apart. +# # Both a merge performed by this home and either outcome detected by its existing # poll use this operation, so neither depends on an agent remembering it. # This operation publishes the poll's local actionable row; the watcher @@ -67,7 +75,8 @@ FM_MERGE_OUTCOME_ALREADY_RECORDED=false fm_merge_outcome_report() { # [] local home=$1 state=$2 id=$3 url=$4 origin=$5 outcome=${6:-merged} local self_rc=0 destination='' line lock status=0 wake_note claimed=0 - local provider host path number claim_state='' + local provider host path number claim_state='' claim_probe='' claim_hash='' + local claim_verdict='' claim_reason='' # shellcheck disable=SC2034 # Sourced wake helpers consume these scoped globals. local STATE FM_WAKE_QUEUE FM_WAKE_QUEUE_LOCK FM_MERGE_OUTCOME_ALREADY_RECORDED=false @@ -91,12 +100,44 @@ fm_merge_outcome_report() { # [/dev/null 2>&1 \ - && printf '%s' "$FM_DONE_CLAIM_STATE") - # An unreadable claim state is treated as no claim: the weaker wording is - # true either way, while the stronger one would not be. - case "$claim_state" in ''|none) ;; *) claimed=1 ;; esac + claim_probe=$(fm_done_claim_status "$state" "$id" >/dev/null 2>&1 \ + && printf '%s\t%s' "$FM_DONE_CLAIM_STATE" \ + "$(fm_done_claim_hash "$FM_DONE_CLAIM_LINE" 2>/dev/null || true)") + claim_state=${claim_probe%%$'\t'*} + claim_hash=${claim_probe#*$'\t'} + [ "$claim_hash" != "$claim_probe" ] || claim_hash= + # An unreadable claim state is treated as no claim: the weaker wording is + # true either way, while the stronger one would not be. + case "$claim_state" in ''|none) ;; *) claimed=1 ;; esac + + # This is the only site in the fleet that OBSERVES a PR reach a terminal + # state, and the durable verdict record is what every later gate reads. Those + # two came apart once already: a claim established while the PR was open kept + # its `verified` record after the PR was closed unmerged, so cleanup passed on + # a record the forge had already falsified. Recording here, inside the funnel, + # is what stops observing and recording from separating again. + # + # Which verdict follows from which outcome is the three-shape distinction + # bin/fm-done-claim-lib.sh states: a close without merge is positive evidence + # of falsity and records `contradicted`; a merge is the world CHANGING under a + # verdict that was true when it was made, which records `stale` so the next + # gate re-verifies against the world that now exists. The verifier is + # deliberately not re-run here: that would couple every merge to forge and + # validation-run reads, and would only relocate the outage question one layer + # up. + # + # Both arms act only when a terminal claim is actually on record. The poll is + # armed at PR registration, long before any claim, so inventing a verdict for + # a task that has asserted nothing would make this publication itself the + # unfounded claim the whole verdict path exists to stop. + if [ "$claimed" -eq 1 ] && [ -n "$claim_hash" ]; then + if [ "$outcome" != merged ]; then + claim_verdict=contradicted + claim_reason="$FM_PR_URL was closed without merging, so this task is not done" + elif [ "$claim_state" = verified ]; then + claim_verdict=stale + claim_reason="$FM_PR_URL merged after this claim was established, so what was established no longer describes the world; re-run bin/fm-verify-done.sh $id" + fi fi if [ "$outcome" = merged ]; then wake_note="check: merge landed: $id $FM_PR_URL" @@ -132,7 +173,11 @@ fm_merge_outcome_report() { # [ [ ] + local name=$1 claim=$2 verdict=${3:-} reason=${4:-} dir state hash + dir="$TMP_ROOT/$name" + state="$dir/state" + mkdir -p "$state" + printf '%s\n' "$claim" > "$state/task-v.status" + if [ -n "$verdict" ]; then + hash=$(fm_done_claim_hash "$claim") || return 1 + fm_done_verdict_write "$state" task-v "$verdict" "$hash" "$reason" || return 1 + fi + printf '%s\n' "$dir" +} + +# A merge is not a contradiction and not an outage: it is the world moving past +# what was established. The verdict was true when it was made and no longer +# describes anything, so it must stop counting as established rather than +# survive untouched or be recorded as false. +test_a_merge_marks_an_established_claim_stale() { + local claim dir + claim="done: pr=$PR_A head=$HEAD_A - shipped" + dir=$(make_claim_world merge-stale "$claim" verified "the PR is open at the claimed head") \ + || fail "the established-claim fixture failed" + fm_merge_outcome_report "$dir" "$dir/state" task-v "$PR_A" poll merged \ + || fail "the merge outcome could not be published" + fm_done_claim_status "$dir/state" task-v + [ "$FM_DONE_CLAIM_STATE" = stale ] \ + || fail "a merge left an established claim reported as $FM_DONE_CLAIM_STATE" + case "$FM_DONE_CLAIM_REASON" in + *"re-run bin/fm-verify-done.sh"*) ;; + *) fail "the stale record did not say what to do about it: $FM_DONE_CLAIM_REASON" ;; + esac + pass "a merge marks an established claim stale rather than leaving it established" +} + +# The three-week rot, at the record rather than at the publication: a claim +# established while the PR was open must not survive that PR being closed. +test_a_close_contradicts_an_established_claim_in_the_record() { + local claim dir + claim="done: pr=$PR_A head=$HEAD_A - shipped" + dir=$(make_claim_world close-contradicts "$claim" verified "the PR is open at the claimed head") \ + || fail "the established-claim fixture failed" + fm_merge_outcome_report "$dir" "$dir/state" task-v "$PR_A" poll closed-unmerged \ + || fail "the close outcome could not be published" + fm_done_claim_status "$dir/state" task-v + [ "$FM_DONE_CLAIM_STATE" = contradicted ] \ + || fail "a close left an established claim reported as $FM_DONE_CLAIM_STATE" + case "$FM_DONE_CLAIM_REASON" in + *"closed without merging"*) ;; + *) fail "the contradiction did not name the close: $FM_DONE_CLAIM_REASON" ;; + esac + pass "a close records a contradiction over a claim established while the PR was open" +} + +# Neither arm may invent a verdict for a task that has asserted nothing. The +# poll is armed at PR registration, long before any claim. +test_a_terminal_outcome_invents_no_verdict_without_a_claim() { + local dir outcome + for outcome in merged closed-unmerged; do + dir=$(make_claim_world "no-claim-$outcome" 'working: still going') \ + || fail "the no-claim fixture failed" + fm_merge_outcome_report "$dir" "$dir/state" task-v "$PR_A" poll "$outcome" \ + || fail "the $outcome outcome could not be published without a claim" + assert_absent "$dir/state/task-v.done-verdict" \ + "a $outcome outcome invented a verdict for a task that never claimed done" + fm_done_claim_status "$dir/state" task-v + [ "$FM_DONE_CLAIM_STATE" = none ] \ + || fail "a $outcome outcome gave a claimless task the state $FM_DONE_CLAIM_STATE" + done + pass "a terminal PR outcome invents no verdict for a task that never claimed done" +} + +# The precedence itself, driven through the record's owner. Each incoming +# verdict is tried against a standing one and the record is read back. +test_verdict_write_precedence_keeps_the_stronger_statement() { + local dir state claim hash + claim="done: pr=$PR_A head=$HEAD_A - shipped" + dir="$TMP_ROOT/verdict-precedence" + state="$dir/state" + mkdir -p "$state" + printf '%s\n' "$claim" > "$state/task-v.status" + hash=$(fm_done_claim_hash "$claim") || fail "the claim could not be hashed" + + # Absence of evidence overwrites nothing: not an establishment, not a + # contradiction, not a superseded world. + fm_done_verdict_write "$state" task-v verified "$hash" established || fail "verified did not record" + fm_done_verdict_write "$state" task-v unverified "$hash" 'the forge could not be reached' \ + || fail "the refused unverified write reported failure" + fm_done_verdict_read "$state" task-v || fail "the record became unreadable" + [ "$FM_DONE_VERDICT" = verified ] \ + || fail "an outage un-established a verified record: $FM_DONE_VERDICT" + + fm_done_verdict_write "$state" task-v stale "$hash" 'the PR merged' || fail "stale did not record" + fm_done_verdict_read "$state" task-v || fail "the record became unreadable" + [ "$FM_DONE_VERDICT" = stale ] || fail "stale did not overwrite verified: $FM_DONE_VERDICT" + + fm_done_verdict_write "$state" task-v unverified "$hash" 'the forge could not be reached' \ + || fail "the refused unverified write reported failure" + fm_done_verdict_read "$state" task-v || fail "the record became unreadable" + [ "$FM_DONE_VERDICT" = stale ] || fail "an outage overwrote a stale record: $FM_DONE_VERDICT" + + fm_done_verdict_write "$state" task-v contradicted "$hash" 'the PR was closed unmerged' \ + || fail "contradicted did not record" + fm_done_verdict_read "$state" task-v || fail "the record became unreadable" + [ "$FM_DONE_VERDICT" = contradicted ] \ + || fail "contradicted did not overwrite stale: $FM_DONE_VERDICT" + + # Falsity is the strongest statement: neither absence nor a changed world + # may soften it. Only the verifier's own fresh look may. + fm_done_verdict_write "$state" task-v stale "$hash" 'the PR merged' \ + || fail "the refused stale write reported failure" + fm_done_verdict_read "$state" task-v || fail "the record became unreadable" + [ "$FM_DONE_VERDICT" = contradicted ] \ + || fail "stale overwrote a contradiction: $FM_DONE_VERDICT" + + fm_done_verdict_write "$state" task-v unverified "$hash" 'the forge could not be reached' \ + || fail "the refused unverified write reported failure" + fm_done_verdict_read "$state" task-v || fail "the record became unreadable" + [ "$FM_DONE_VERDICT" = contradicted ] \ + || fail "an outage overwrote a contradiction: $FM_DONE_VERDICT" + + fm_done_verdict_write "$state" task-v verified "$hash" 're-established' \ + || fail "a fresh establishment did not record" + fm_done_verdict_read "$state" task-v || fail "the record became unreadable" + [ "$FM_DONE_VERDICT" = verified ] \ + || fail "the verifier's fresh look could not overwrite a contradiction: $FM_DONE_VERDICT" + pass "verdict write precedence keeps the stronger statement about the same claim" +} + +# The presentation cursor is a BYTE offset. A worker's prose is arbitrary text, +# so a held-back span containing one multibyte character must not leave the +# cursor mid-line and turn the tail of an already-presented line into a whole +# new status line on the next drain. +test_a_held_back_span_with_multibyte_prose_presents_no_fragment() { + local dir state status first second line + dir=$(make_case unrecognised-multibyte) + state="$dir/state" + status="$state/task9.status" + printf 'note: bootstrap cursor line\n' > "$status" + FM_STATE_OVERRIDE="$state" "$DRAIN" >/dev/null 2>/dev/null \ + || fail "bootstrap drain failed while priming the cursor" + + { + printf 'prose \xe2\x9c\x93 line one from the worker\n' + printf 'prose \xe2\x9c\x93 line two from the worker\n' + printf 'prose \xe2\x9c\x93 line three from the worker\n' + } >> "$status" + + first="$dir/first.out"; second="$dir/second.out" + FM_DRAIN_UNRECOGNISED_MAX=1 FM_STATE_OVERRIDE="$state" "$DRAIN" > "$first" \ + || fail "the capped drain failed on a multibyte span" + assert_grep 'line one from the worker' "$first" \ + "the capped drain printed nothing: $(cat "$first")" + + FM_DRAIN_UNRECOGNISED_MAX=9 FM_STATE_OVERRIDE="$state" "$DRAIN" > "$second" \ + || fail "the second drain failed" + assert_grep 'line two from the worker' "$second" \ + "a held-back multibyte line never came back: $(cat "$second")" + assert_grep 'line three from the worker' "$second" \ + "the rest of the held-back multibyte prose never came back: $(cat "$second")" + # Every unrecognised line presented must be a whole line the worker wrote, not + # a tail sliced out of one that was already shown. + while IFS= read -r line; do + case "$line" in + *'UNRECOGNISED (matches no status verb): '*) ;; + *) continue ;; + esac + case "${line#*'UNRECOGNISED (matches no status verb): '}" in + 'prose '*' from the worker') ;; + *) fail "the drain presented a fragment of an already-presented line: $line" ;; + esac + done < "$second" + pass "a held-back span with multibyte prose presents whole lines, never a fragment" +} + +# The cap bounds one TASK, not the whole drain. A per-drain budget would let one +# chatty worker defer every alphabetically later task behind it, drain after +# drain, which is the starvation the cap exists to prevent. +test_the_unrecognised_cap_is_per_task_not_per_drain() { + local dir state out i + dir=$(make_case unrecognised-per-task) + state="$dir/state" + printf 'note: bootstrap cursor line\n' > "$state/task-a.status" + printf 'note: bootstrap cursor line\n' > "$state/task-z.status" + FM_STATE_OVERRIDE="$state" "$DRAIN" >/dev/null 2>/dev/null \ + || fail "bootstrap drain failed while priming the cursors" + + for i in 1 2 3; do + printf 'chatty prose %s from task-a\n' "$i" >> "$state/task-a.status" + done + printf 'the one line task-z had to say\n' >> "$state/task-z.status" + + out="$dir/drain.out" + FM_DRAIN_UNRECOGNISED_MAX=2 FM_STATE_OVERRIDE="$state" "$DRAIN" > "$out" \ + || fail "the capped drain failed across two tasks" + assert_grep 'chatty prose 1 from task-a' "$out" \ + "the chatty task printed nothing: $(cat "$out")" + assert_grep 'the one line task-z had to say' "$out" \ + "a quiet task was starved behind a chatty one's exhausted budget: $(cat "$out")" + pass "the unrecognised cap is per task, so one chatty worker cannot starve the rest" +} + test_the_omitted_unrecognised_count_carries_its_header() { local dir state status out dir=$(make_case unrecognised-cap-zero) @@ -1095,6 +1317,12 @@ test_a_close_does_not_invent_a_done_claim test_a_configured_verb_vocabulary_is_not_unrecognised test_a_pruned_reflog_is_unverified_not_contradicted test_a_contradiction_needs_the_observation_it_contradicts_with +test_a_merge_marks_an_established_claim_stale +test_a_close_contradicts_an_established_claim_in_the_record +test_a_terminal_outcome_invents_no_verdict_without_a_claim +test_verdict_write_precedence_keeps_the_stronger_statement +test_a_held_back_span_with_multibyte_prose_presents_no_fragment +test_the_unrecognised_cap_is_per_task_not_per_drain test_the_omitted_unrecognised_count_carries_its_header test_capped_unrecognised_lines_return_on_the_next_drain test_capped_lines_return_even_when_a_recognised_line_follows_them diff --git a/tests/fm-teardown.test.sh b/tests/fm-teardown.test.sh index 1d42e34026f..135390f3763 100755 --- a/tests/fm-teardown.test.sh +++ b/tests/fm-teardown.test.sh @@ -49,6 +49,8 @@ # (z5) contradicted claim + --force -> ALLOW (escape hatch) # (z6) established claim, verifier's sources then gone -> ALLOW (the record stands) # (z7) same blinded world with NO record standing -> REFUSE (unverified) +# (z8) a `stale` record, verifier blinded -> REFUSE (not established) +# (z9) verified record, then the PR closed unmerged -> REFUSE (contradicted) # # Also covers backlog teardown-lock-race: a git index.lock left in the worktree by a # killed crew process (bin/fm-teardown.sh's teardown_treehouse_return). @@ -2890,6 +2892,71 @@ test_an_established_claim_survives_losing_the_verifier_sources() { # The other half of the same rule: deferring to the record is not deferring to # nothing. With no record standing, the identical blinded world still refuses. +# A record-first gate is only safe while the record itself stays honest. These +# two pin the other half: a verdict that has stopped describing the world must +# not carry a task through cleanup on the strength of what it used to say. +seed_verdict_record() { # + local case_dir=$1 verdict=$2 reason=$3 claim hash + # shellcheck source=bin/fm-done-claim-lib.sh disable=SC1091 + . "$ROOT/bin/fm-done-claim-lib.sh" + claim=$(fm_done_claim_last "$case_dir/state/task-x1.status") + [ -n "$claim" ] || fail "the fixture wrote no terminal claim to bind a verdict to" + hash=$(fm_done_claim_hash "$claim") || fail "the claim could not be hashed" + fm_done_verdict_write "$case_dir/state" task-x1 "$verdict" "$hash" "$reason" \ + || fail "the $verdict record could not be seeded" +} + +# `stale` is not established. The record-first gate must fall through to the +# verifier for it, which is the whole point: the expensive check is deferred to +# the gate that actually needs the answer, and here that check cannot be made. +test_a_stale_record_does_not_carry_a_task_through_cleanup() { + local case_dir rc=0 head + case_dir=$(make_claim_case claim-stale "placeholder") + head=$(git -C "$case_dir/wt" rev-parse HEAD) + printf 'done: branch=fm/task-x1 head=%s - shipped\n' "$head" \ + > "$case_dir/state/task-x1.status" + seed_verdict_record "$case_dir" stale 'the PR merged after this claim was established' + blind_the_local_only_verifier "$case_dir" + set +e + run_teardown "$case_dir" > "$case_dir/stdout" 2> "$case_dir/stderr" + rc=$? + set -e + [ "$rc" -ne 0 ] \ + || fail "a stale record carried a task through cleanup without re-verification" + assert_grep "could not be established" "$case_dir/stderr" \ + "the refusal did not say the claim could not be established" + [ -e "$case_dir/state/task-x1.meta" ] \ + || fail "the stale refusal erased the durable task record" + pass "a stale record sends the gate back to the verifier instead of passing" +} + +# The three-week rot, end to end at the gate: a claim established while its PR +# was open, then closed unmerged. The close must have corrected the record, so +# cleanup refuses rather than passing on what the record used to say. +test_a_closed_pr_stops_its_own_done_record_carrying_cleanup() { + local case_dir rc=0 head + case_dir=$(make_claim_case claim-closed "placeholder") + head=$(git -C "$case_dir/wt" rev-parse HEAD) + printf 'done: branch=fm/task-x1 head=%s - shipped\n' "$head" \ + > "$case_dir/state/task-x1.status" + seed_verdict_record "$case_dir" verified 'the PR is open at the claimed head' + # shellcheck source=bin/fm-merge-outcome-lib.sh disable=SC1091 + . "$ROOT/bin/fm-merge-outcome-lib.sh" + FM_HOME="$case_dir" fm_merge_outcome_report "$case_dir" "$case_dir/state" task-x1 \ + https://github.com/o/r/pull/7 poll closed-unmerged \ + || fail "the close outcome could not be published" + blind_the_local_only_verifier "$case_dir" + set +e + run_teardown "$case_dir" > "$case_dir/stdout" 2> "$case_dir/stderr" + rc=$? + set -e + [ "$rc" -ne 0 ] \ + || fail "a claim whose PR was closed unmerged was torn down as done" + [ -e "$case_dir/state/task-x1.meta" ] \ + || fail "the refusal erased the durable task record" + pass "a PR closed without merging stops its own done record carrying cleanup" +} + test_an_unestablished_claim_still_refuses_without_a_record() { local case_dir rc=0 head case_dir=$(make_claim_case claim-unestablished "placeholder") @@ -2932,6 +2999,8 @@ test_established_claim_allows_teardown test_force_overrides_a_contradicted_claim test_an_established_claim_survives_losing_the_verifier_sources test_an_unestablished_claim_still_refuses_without_a_record +test_a_stale_record_does_not_carry_a_task_through_cleanup +test_a_closed_pr_stops_its_own_done_record_carrying_cleanup test_local_only_fork_remote_allows test_teardown_closes_the_backlog_item_itself test_teardown_manual_backend_leaves_the_backlog_to_the_operator From 7e88c81c271862ba6a28224a57e6aef167b3ab35 Mon Sep 17 00:00:00 2001 From: Israel Wilson Date: Thu, 3 Sep 2026 15:26:49 -0400 Subject: [PATCH 08/21] no-mistakes(review): bind done claims to the task that authored them --- bin/fm-merge-outcome-lib.sh | 20 ++- bin/fm-verify-done.sh | 158 +++++++++++++++++++----- docs/configuration.md | 2 +- tests/fm-done-verified.test.sh | 218 +++++++++++++++++++++++++++++++-- 4 files changed, 349 insertions(+), 49 deletions(-) diff --git a/bin/fm-merge-outcome-lib.sh b/bin/fm-merge-outcome-lib.sh index 82391551057..6e7c8643fdd 100755 --- a/bin/fm-merge-outcome-lib.sh +++ b/bin/fm-merge-outcome-lib.sh @@ -173,16 +173,26 @@ fm_merge_outcome_report() { # [. Without it the whole check is that the claim +# and the forge agree about a head, which any open PR whose head a worker +# states correctly would satisfy: consistency, not authorship. A +# no-mistakes task needs no counterpart because the validated-commit check +# already requires the run's branch to be this worktree's own. # - the checks state is RECORDED as fact, never judged: a claim is not # contradicted for having red or absent checks, because merge authority, # not this script, owns that decision # local-only # - the claim names this task's own branch, exactly fm/ # - the claimed head= resolves in the task's local copy and is the tip of -# that branch, AND that branch has moved since git created it. -# bin/fm-brief.sh has every worker create fm/ before it does any -# work, so the branch exists at the spawn base from the outset: a branch -# still sitting exactly where it was created is positive evidence that it -# introduced nothing, whatever the claim says. The branch's own history is -# the authority for that, not its merge base with the default branch: -# local-only work is on the default branch by the time it may be torn -# down, so a merge base equal to the branch tip is the ordinary shape of -# LANDED work and would contradict every honest claim. A branch history -# that cannot be read is absence of evidence and reads unverified, and so -# is one whose oldest surviving reflog entry is no longer the creation -# (`git gc` prunes entries past gc.reflogExpire): the entry must say it is -# the creation before anything is concluded from it. +# that branch, AND git's own log says the branch RECORDED a commit. +# That last one is the question this arm asks, and it took three tries to +# ask it. A merge base, then "the tip moved since creation", both tested +# whether something CHANGED rather than whether this branch AUTHORED +# anything - and bin/fm-brief.sh's local-only contract tells a worker to +# rebase onto the default branch when it advances, so a worker that +# commits nothing and obeys that instruction moves fm/ onto +# someone else's work and satisfied both. Only an entry git writes when a +# commit is recorded (`commit:`, `commit (amend):`, `commit (initial):`) +# says this branch made something; a rebase preserves it, so honest work +# that was later rebased still answers. # - once the branch has been retired after the work merged, the claimed head # must be BOTH the local copy's own HEAD and contained in the local default -# branch. Bare containment is never enough: every commit already on the -# default branch is its own ancestor, so containment alone would pass any -# claim naming the default branch's tip. The merge-base test has no -# counterpart here because the branch ref it needs is gone; what stands in -# its place is that git refuses to delete a branch a worktree has checked -# out, so a worker still sitting on an un-retired fm/ can never -# reach this arm. +# branch, and the local copy's own history must record a commit it made. +# Bare containment is never enough: every commit already on the default +# branch is its own ancestor, so containment alone would pass any claim +# naming the default branch's tip. Retiring the branch deletes the branch +# reflog with it, so the authorship question is asked of the worktree's +# HEAD log, which survives and records the same commits. +# - a history that cannot be read, or whose oldest surviving entry is no +# longer the creation (`git gc` prunes entries past gc.reflogExpire), or +# that reaches back but records no commit, is all absence of evidence and +# reads unverified. Only a branch demonstrably still sitting on the commit +# it was created at is contradicted. # scout -# - the claimed report= exists as a non-empty regular file. An absolute path -# is used as given (what bin/fm-brief.sh renders into a scout brief); a -# relative one is resolved against the home's data root, which is where -# every scout report lives, so a relocated FM_DATA_OVERRIDE resolves the -# same claim as an unrelocated one. A leading `data/` is the home-relative -# spelling of that root and names it rather than a directory beneath it. +# - the claimed report= exists as a non-empty regular file INSIDE this task's +# own directory under the home's data root. A file existing is not this +# task having produced it, so the path is bound to // the +# same way the local-only arm is bound to fm/. An absolute path is +# used as given (what bin/fm-brief.sh renders into a scout brief); a +# relative one is resolved against the data root, so a relocated +# FM_DATA_OVERRIDE resolves the same claim as an unrelocated one. A leading +# `data/` is the home-relative spelling of that root and names it rather +# than a directory beneath it. # # A task whose meta records no mode= - the population spawned before mode= was # recorded - is routed by the shape of its own claim, because an absent record is @@ -201,6 +212,18 @@ if [ "$KIND" = scout ]; then data/*) REPORT="$DATA/${REPORT#data/}" ;; *) REPORT="$DATA/$REPORT" ;; esac + # A file existing is not this task having produced it. Every scout report lives + # under the home's data root in the task's own directory, so a claim resolving + # anywhere else names someone else's deliverable however real that file is - + # the same binding the local-only arm makes by requiring branch = fm/. + case "$REPORT" in + "$DATA/$ID/"?*) ;; + *) + verdict_is contradicted "the claimed report is not this task's own: $FM_DONE_CLAIM_REPORT resolves to $REPORT, outside $DATA/$ID/" \ + "$REPORT" + finish + ;; + esac # A symlink is refused as evidence rather than followed, but refusing to read # something is not proof that it is false: the report may be perfectly present # behind the link. That is absence of evidence, so it names the link and reads @@ -248,6 +271,36 @@ if ! fm_done_claim_head_valid "$HEAD_CLAIM"; then finish fi +# The commit a named ref RECORDED, or empty when its log holds none. Movement is +# not authorship: bin/fm-brief.sh's local-only contract tells a worker to rebase +# onto the default branch when it advances, so a worker that commits nothing and +# follows that instruction fast-forwards fm/ onto someone else's work. The +# ref then differs from where it was created while having introduced nothing, +# which is why every earlier version of this test - a merge base, then "the tip +# moved" - passed a claim it should have refused. Only an entry git writes when a +# commit is RECORDED says this branch authored something, so that is what is +# read. A rebase preserves the original `commit:` entry, so honest work that was +# later rebased still answers here. +AUTHORED_AT= +ref_recorded_a_commit() { # + local wt=$1 ref=$2 entry sha msg + AUTHORED_AT= + while IFS= read -r entry; do + sha=${entry%% *} + case "$entry" in *' '*) msg=${entry#* } ;; *) continue ;; esac + case "$sha" in *[!0-9a-f]*|'') continue ;; esac + case "$msg" in + 'commit: '*|'commit (amend): '*|'commit (initial): '*) + AUTHORED_AT=$sha + return 0 + ;; + esac + done </dev/null || true) +EOF + return 1 +} + # --- local-only: git is the whole authority ---------------------------------- if [ "$MODE" = local-only ]; then BRANCH=$FM_DONE_CLAIM_BRANCH @@ -331,7 +384,16 @@ if [ "$MODE" = local-only ]; then "$BRANCH was created at $CREATED and its tip is still $TIP" finish fi - verdict_is verified "branch $BRANCH is at the claimed $HEAD_CLAIM, which it introduced after being created at $CREATED" + # The branch reflog reaches back to the creation entry, so it is this + # branch's whole history. A history with no commit in it is read as absence + # rather than falsity: this arm has been wrong three times by concluding too + # much from a true observation, and `unverified` refuses the claim just as + # firmly while never accusing a worker of something it cannot prove. + if ! ref_recorded_a_commit "$WT" "refs/heads/$BRANCH"; then + verdict_is unverified "branch $BRANCH is at the claimed $HEAD_CLAIM, but its history records no commit it made, so nothing establishes that this task authored that commit rather than inheriting it" + finish + fi + verdict_is verified "branch $BRANCH is at the claimed $HEAD_CLAIM, and its history records work it committed at $AUTHORED_AT" finish fi # The branch is gone. That is the normal end state once local-only work has @@ -356,7 +418,15 @@ if [ "$MODE" = local-only ]; then finish fi if git -C "$WT" merge-base --is-ancestor "$HEAD_CLAIM" "$DEFAULT_REF" 2>/dev/null; then - verdict_is verified "the local copy is at the claimed $HEAD_CLAIM and it is on $DEFAULT; its branch $BRANCH has been retired" + # Retiring the branch deletes its reflog with it, so the branch's own history + # is gone and the local copy's HEAD reflog is what survives. It records every + # commit this worktree made, which is the same authorship question the + # branch-exists arm asks, read off the only log still there to read. + if ! ref_recorded_a_commit "$WT" HEAD; then + verdict_is unverified "the local copy is at the claimed $HEAD_CLAIM and it is on $DEFAULT, but its own history records no commit it made, so nothing establishes that this task authored that commit rather than inheriting it" + finish + fi + verdict_is verified "the local copy is at the claimed $HEAD_CLAIM, which it committed at $AUTHORED_AT, and it is on $DEFAULT; its branch $BRANCH has been retired" finish fi DEFAULT_TIP=$(git -C "$WT" rev-parse --verify --quiet "$DEFAULT_REF" 2>/dev/null || true) @@ -402,8 +472,8 @@ fi # a pending or in-progress check. Deduplicated so the recorded fact stays short # and stable rather than repeating one word per check. VIEW=$(fm_run_timed "$GH_TIMEOUT" gh pr view "$PR_URL" \ - --json state,headRefOid,url,statusCheckRollup \ - -q '.state + "\t" + .headRefOid + "\t" + ([.statusCheckRollup[]? | (.conclusion // .status // .state // "UNKNOWN")] | unique | join(","))' \ + --json state,headRefOid,headRefName,url,statusCheckRollup \ + -q '.state + "\t" + .headRefOid + "\t" + (.headRefName // "") + "\t" + ([.statusCheckRollup[]? | (.conclusion // .status // .state // "UNKNOWN")] | unique | join(","))' \ 2>/dev/null) || VIEW= VIEW=$(printf '%s\n' "$VIEW" | head -1) if [ -z "$VIEW" ]; then @@ -417,8 +487,13 @@ if [ "$PR_STATE" = "$VIEW" ]; then finish fi PR_HEAD=${REST%%$'\t'*} -CHECKS=${REST#*$'\t'} -[ "$PR_HEAD" != "$REST" ] || CHECKS= +PR_BRANCH= +CHECKS= +if [ "$PR_HEAD" != "$REST" ]; then + REST=${REST#*$'\t'} + PR_BRANCH=${REST%%$'\t'*} + [ "$PR_BRANCH" = "$REST" ] || CHECKS=${REST#*$'\t'} +fi [ -n "$CHECKS" ] || CHECKS='none reported' case "$PR_STATE" in @@ -443,8 +518,25 @@ if [ "$PR_HEAD" != "$HEAD_CLAIM" ]; then finish fi +# A direct-PR task has no validation run to bind the PR to it, so without this +# the whole verification is "the claim and the forge agree about a head" - two +# facts a worker could satisfy by naming any open PR whose head it states +# correctly. That is consistency, not authorship. Every other arm binds its +# evidence to this task by name (local-only to fm/, scout to this task's own +# data directory) and this one binds the same way, through the branch the forge +# says the PR is built from. A no-mistakes task is already bound by the +# validated-commit check below, which requires the run's branch to be this +# worktree's own. if [ "$MODE" != no-mistakes ]; then - verdict_is verified "$PR_URL is $PR_STATE at the claimed $HEAD_CLAIM; checks: $CHECKS" + if [ -z "$PR_BRANCH" ]; then + verdict_is unverified "the forge reported no head branch for $PR_URL, so nothing establishes that this PR is this task's work" + finish + fi + if [ "$PR_BRANCH" != "fm/$ID" ]; then + verdict_is contradicted "$PR_URL is built from $PR_BRANCH, not this task's fm/$ID" "$PR_BRANCH" + finish + fi + verdict_is verified "$PR_URL is $PR_STATE at the claimed $HEAD_CLAIM from this task's $PR_BRANCH; checks: $CHECKS" finish fi diff --git a/docs/configuration.md b/docs/configuration.md index a9584b07c5d..76e937a72bb 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -842,7 +842,7 @@ FM_CREW_STATE_RUNS_LIMIT=200 # recent no-mistakes run rows scanned when axi sta FM_VERIFY_DONE_GH_TIMEOUT=30 # seconds allowed for the one forge read that establishes a terminal claim in bin/fm-verify-done.sh FM_VERIFY_DONE_NM_TIMEOUT=20 # seconds allowed for the one no-mistakes query that reads the validated commit in bin/fm-verify-done.sh FM_PR_CHECK_VERIFY_TIMEOUT=20 # seconds bin/fm-pr-check.sh allows its advisory claim verification; exceeding it prints nothing and changes nothing it registered or armed -FM_DRAIN_UNRECOGNISED_MAX=10 # unrecognised status lines printed per drain in UNREAD STATUS; the rest are counted and held back to the next drain rather than dropped, and 0 prints the count alone +FM_DRAIN_UNRECOGNISED_MAX=10 # unrecognised status lines printed per TASK in UNREAD STATUS, so one drain may print this many for each task with unread prose; the rest are counted and held back to the next drain rather than dropped, and 0 prints the count alone FM_CREW_STATE_BIN=bin/fm-crew-state.sh # test override for the current-state reader used by working/paused watcher triage FMX_PAIRING_TOKEN= # Relay pairing token; .env opt-in authorizes replies and eligible lifecycle actions FMX_RELAY_URL=https://myfirstmate.io # optional Relay endpoint override, mainly for local relay development diff --git a/tests/fm-done-verified.test.sh b/tests/fm-done-verified.test.sh index f464bd71239..3b8b6c56827 100755 --- a/tests/fm-done-verified.test.sh +++ b/tests/fm-done-verified.test.sh @@ -56,6 +56,14 @@ # everything, and a changed world does not soften a contradiction # (af) a held-back span containing multibyte prose presents whole lines only # (ag) the unrecognised cap is per task, so one chatty worker starves nobody +# (ah) a local-only branch that MOVED without committing anything - the rebase +# and merge the brief itself instructs - is not verified, at either arm +# (ai) a direct-PR claim is bound to this task's own branch, and a scout claim +# to this task's own report directory: a real PR or file is not authorship +# (aj) a whitespace-only line inside a partly held-back span retires nothing: +# the span index and the byte counter agree about what counts as a line +# (ak) a verdict write that fails never suppresses the outcome report, which +# on the poll path would be silence plus a dead watcher # (aa) a branch whose creation reflog entry has expired is unverified, not # contradicted: the oldest SURVIVING entry is not the creation point # (ab) a contradiction must carry the observation it contradicts with; one @@ -253,7 +261,7 @@ test_head_not_the_pr_head_is_contradicted() { validated=$(git -C "$dir/wt" rev-parse 'HEAD~1') printf 'done: pr=https://github.com/o/r/pull/7 head=%s - shipped\n' "$shipped" \ > "$dir/state/task-v.status" - result=$(FAKE_GH_OUT="OPEN $validated SUCCESS" verify "$dir") + result=$(FAKE_GH_OUT="OPEN $validated fm/task-v SUCCESS" verify "$dir") [ "${result%%$'\t'*}" = 4 ] || fail "a head that is not the PR head did not exit contradicted: $result" case "$result" in *contradicted:*) ;; *) fail "a head mismatch was not contradicted: $result" ;; esac pass "a claim whose commit is not the PR's head is contradicted" @@ -266,7 +274,7 @@ test_head_not_the_validated_commit_is_contradicted() { validated=$(git -C "$dir/wt" rev-parse 'HEAD~1') printf 'done: pr=https://github.com/o/r/pull/7 head=%s - shipped\n' "$shipped" \ > "$dir/state/task-v.status" - result=$(FAKE_GH_OUT="OPEN $shipped SUCCESS" \ + result=$(FAKE_GH_OUT="OPEN $shipped fm/task-v SUCCESS" \ FAKE_NM_STATUS="$(nm_status fm/task-v "$validated")" verify "$dir") [ "${result%%$'\t'*}" = 4 ] \ || fail "shipping a commit the pipeline never validated did not exit contradicted: $result" @@ -280,7 +288,7 @@ test_closed_unmerged_pr_is_contradicted() { shipped=$(git -C "$dir/wt" rev-parse HEAD) printf 'done: pr=https://github.com/o/r/pull/7 head=%s - shipped\n' "$shipped" \ > "$dir/state/task-v.status" - result=$(FAKE_GH_OUT="CLOSED $shipped SUCCESS" verify "$dir") + result=$(FAKE_GH_OUT="CLOSED $shipped fm/task-v SUCCESS" verify "$dir") [ "${result%%$'\t'*}" = 4 ] || fail "a closed-unmerged PR did not exit contradicted: $result" case "$result" in *"closed without merging"*) ;; *) fail "the contradiction did not name the close: $result" ;; esac pass "a claim on a PR closed without merging is contradicted" @@ -290,7 +298,7 @@ test_legacy_claim_degrades_and_is_never_upgraded() { local dir result dir=$(make_world legacy-claim) printf 'done: PR https://github.com/o/r/pull/7 checks green\n' > "$dir/state/task-v.status" - result=$(FAKE_GH_OUT="MERGED deadbeef SUCCESS" verify "$dir") + result=$(FAKE_GH_OUT="MERGED deadbeef fm/task-v SUCCESS" verify "$dir") [ "${result%%$'\t'*}" = 3 ] || fail "a legacy claim did not exit unverified: $result" case "$result" in *"legacy claim, no commit identity"*) ;; *) fail "the legacy claim was not named as such: $result" ;; esac # The verdict token is the whole word after the exit code, so a check for @@ -305,7 +313,7 @@ test_honest_claim_verifies_and_records_the_checks_state() { shipped=$(git -C "$dir/wt" rev-parse HEAD) printf 'done: pr=https://github.com/o/r/pull/7 head=%s - shipped\n' "$shipped" \ > "$dir/state/task-v.status" - result=$(FAKE_GH_OUT="OPEN $shipped SUCCESS" \ + result=$(FAKE_GH_OUT="OPEN $shipped fm/task-v SUCCESS" \ FAKE_NM_STATUS="$(nm_status fm/task-v "$shipped")" verify "$dir") [ "${result%%$'\t'*}" = 0 ] || fail "an honest claim was not verified: $result" case "$result" in *"checks: SUCCESS"*) ;; *) fail "the checks state was not recorded as fact: $result" ;; esac @@ -320,7 +328,7 @@ test_red_checks_do_not_by_themselves_contradict_a_claim() { shipped=$(git -C "$dir/wt" rev-parse HEAD) printf 'done: pr=https://github.com/o/r/pull/7 head=%s - shipped\n' "$shipped" \ > "$dir/state/task-v.status" - result=$(FAKE_GH_OUT="OPEN $shipped FAILURE" \ + result=$(FAKE_GH_OUT="OPEN $shipped fm/task-v FAILURE" \ FAKE_NM_STATUS="$(nm_status fm/task-v "$shipped")" verify "$dir") [ "${result%%$'\t'*}" = 0 ] \ || fail "the verifier judged the checks state instead of recording it: $result" @@ -406,7 +414,7 @@ test_a_verdict_does_not_cover_a_later_claim() { shipped=$(git -C "$dir/wt" rev-parse HEAD) printf 'done: pr=https://github.com/o/r/pull/7 head=%s - shipped\n' "$shipped" \ > "$dir/state/task-v.status" - FAKE_GH_OUT="OPEN $shipped SUCCESS" FAKE_NM_STATUS="$(nm_status fm/task-v "$shipped")" \ + FAKE_GH_OUT="OPEN $shipped fm/task-v SUCCESS" FAKE_NM_STATUS="$(nm_status fm/task-v "$shipped")" \ verify "$dir" >/dev/null fm_done_claim_status "$dir/state" task-v [ "$FM_DONE_CLAIM_STATE" = verified ] \ @@ -657,7 +665,7 @@ establish_then_rerun() { # shipped=$(git -C "$dir/wt" rev-parse HEAD) printf 'done: pr=https://github.com/o/r/pull/7 head=%s - shipped\n' "$shipped" \ > "$dir/state/task-v.status" - result=$(FAKE_GH_OUT="OPEN $shipped SUCCESS" \ + result=$(FAKE_GH_OUT="OPEN $shipped fm/task-v SUCCESS" \ FAKE_NM_STATUS="$(nm_status fm/task-v "$shipped")" verify "$dir") [ "${result%%$'\t'*}" = 0 ] || fail "setup error: the claim did not verify: $result" result=$(FAKE_GH_OUT="$rerun" FAKE_NM_STATUS="$(nm_status fm/task-v "$shipped")" verify "$dir") @@ -685,7 +693,7 @@ test_a_contradiction_still_overwrites_an_established_record() { other=00112233445566778899aabbccddeeff00112233 # The forge answers, and it answers with a different head: the claim is now # established false, so the protection above must not freeze the record. - out=$(establish_then_rerun contradiction-overwrites "OPEN $other SUCCESS") \ + out=$(establish_then_rerun contradiction-overwrites "OPEN $other fm/task-v SUCCESS") \ || fail "the contradiction-overwrite fixture failed" dir=${out%%$'\t'*} result=${out#*$'\t'} @@ -708,7 +716,7 @@ test_an_absent_mode_still_checks_the_validated_commit() { "window=fm:fm-task-v" "worktree=$dir/wt" "kind=ship" printf 'done: pr=https://github.com/o/r/pull/7 head=%s - shipped\n' "$shipped" \ > "$dir/state/task-v.status" - result=$(FAKE_GH_OUT="OPEN $shipped SUCCESS" \ + result=$(FAKE_GH_OUT="OPEN $shipped fm/task-v SUCCESS" \ FAKE_NM_STATUS="$(nm_status fm/task-v "$validated")" verify "$dir") [ "${result%%$'\t'*}" = 4 ] \ || fail "a task with no recorded mode skipped the validated-commit check: $result" @@ -1001,6 +1009,124 @@ test_capped_lines_return_even_when_a_recognised_line_follows_them() { pass "held-back prose survives a recognised line printed later in the same span" } +# --- authorship, not movement ------------------------------------------------ +# The shape that defeated the two previous versions of this invariant, planted +# exactly as this fleet produces it: a project repo whose default branch +# advances, a linked worktree on fm/ created at the older base, a +# worker that commits NOTHING, and then the rebase bin/fm-brief.sh's local-only +# contract instructs it to do when the default branch has moved. The branch tip +# then differs from where it was created while the branch has introduced +# nothing, which is what "did something change" tests could not tell apart from +# real work. +test_a_local_only_claim_on_work_the_branch_never_made_is_not_verified() { + local how out dir default tip result + for how in rebase merge; do + out=$(make_local_world "local-no-work-$how") || fail "the local-only fixture failed" + dir=${out%%$'\t'*} + default=${out#*$'\t'} + # The worker commits nothing at all and does exactly what the brief says to + # do when the default branch has advanced. + git -C "$dir/wt" "$how" -q "$default" >/dev/null 2>&1 \ + || fail "the fixture could not $how fm/task-v onto $default" + tip=$(git -C "$dir/wt" rev-parse HEAD) + [ "$tip" = "$(git -C "$dir/repo" rev-parse "refs/heads/$default")" ] \ + || fail "the $how fixture did not leave fm/task-v on the default branch's tip" + [ "$tip" != "$(git -C "$dir/wt" reflog show --format=%H refs/heads/fm/task-v | tail -1)" ] \ + || fail "the $how fixture did not move the branch off its creation point" + printf 'done: branch=fm/task-v head=%s - shipped\n' "$tip" > "$dir/state/task-v.status" + result=$(verify "$dir") + [ "${result%%$'\t'*}" != 0 ] \ + || fail "a claim on work its branch never made was verified after a $how: $result" + case "${result#*$'\t'}" in + *"records no commit it made"*) ;; + *) fail "the $how refusal did not name the missing authorship: $result" ;; + esac + done + pass "a local-only branch that moved without committing anything is not verified" +} + +# The same question at the other local-only arm. Retiring the branch deletes its +# reflog, so the authorship evidence has to come from the log that survives. +test_a_retired_branch_claim_on_work_never_made_is_not_verified() { + local out dir default tip result + out=$(make_local_world local-retired-no-work) || fail "the local-only fixture failed" + dir=${out%%$'\t'*} + default=${out#*$'\t'} + git -C "$dir/wt" merge -q "$default" >/dev/null 2>&1 \ + || fail "the fixture could not merge $default into fm/task-v" + tip=$(git -C "$dir/wt" rev-parse HEAD) + git -C "$dir/wt" checkout -q --detach HEAD \ + || fail "the fixture could not detach the local copy" + git -C "$dir/repo" branch -D fm/task-v >/dev/null 2>&1 \ + || fail "the fixture could not retire fm/task-v" + printf 'done: branch=fm/task-v head=%s - shipped\n' "$tip" > "$dir/state/task-v.status" + result=$(verify "$dir") + [ "${result%%$'\t'*}" != 0 ] \ + || fail "a retired-branch claim on work the copy never made was verified: $result" + case "${result#*$'\t'}" in + *"records no commit it made"*) ;; + *) fail "the retired-branch refusal did not name the missing authorship: $result" ;; + esac + pass "a retired local-only branch that committed nothing is not verified" +} + +# --- a PR must be this task's, not merely a real one ------------------------- +# A direct-PR task has no validation run binding the PR to it, so the claim and +# the forge agreeing about a head is consistency, not authorship: any open PR +# whose head a worker states correctly would otherwise pass. +test_a_direct_pr_claim_on_another_branch_is_not_verified() { + local dir result + dir=$(make_world direct-pr-foreign ship direct-PR) + printf 'done: pr=https://github.com/o/r/pull/9 head=%s - shipped\n' \ + "$(git -C "$dir/wt" rev-parse HEAD)" > "$dir/state/task-v.status" + result=$(FAKE_GH_OUT="OPEN $(git -C "$dir/wt" rev-parse HEAD) somebody-elses-branch SUCCESS" \ + verify "$dir") + [ "${result%%$'\t'*}" != 0 ] \ + || fail "a direct-PR claim naming another branch's PR was verified: $result" + [ "${result%%$'\t'*}" = 4 ] \ + || fail "a PR the forge says is built from another branch was not contradicted: $result" + case "${result#*$'\t'}" in + *"somebody-elses-branch"*"fm/task-v"*) ;; + *) fail "the contradiction did not name both branches: $result" ;; + esac + pass "a direct-PR claim whose PR is built from another branch is contradicted" +} + +# The honest direct-PR case, so the binding above is not vacuous, and the +# absent-head-branch case, which is absence of evidence rather than falsity. +test_a_direct_pr_claim_on_this_task_branch_verifies() { + local dir head result + dir=$(make_world direct-pr-own ship direct-PR) + head=$(git -C "$dir/wt" rev-parse HEAD) + printf 'done: pr=https://github.com/o/r/pull/9 head=%s - shipped\n' "$head" \ + > "$dir/state/task-v.status" + result=$(FAKE_GH_OUT="OPEN $head fm/task-v SUCCESS" verify "$dir") + [ "${result%%$'\t'*}" = 0 ] \ + || fail "an honest direct-PR claim on this task's own branch was not verified: $result" + + result=$(FAKE_GH_OUT="OPEN $head SUCCESS" verify "$dir") + [ "${result%%$'\t'*}" = 3 ] \ + || fail "a forge that reported no head branch was not unverified: $result" + pass "a direct-PR claim verifies on this task's own branch and never without one" +} + +# --- a report must be this task's own deliverable ---------------------------- +test_a_scout_claim_on_another_tasks_report_is_not_verified() { + local dir result + dir=$(make_world scout-foreign scout scout) + mkdir -p "$dir/data/task-other" + printf 'somebody elses findings\n' > "$dir/data/task-other/report.md" + printf 'done: report=data/task-other/report.md - shipped\n' > "$dir/state/task-v.status" + result=$(verify "$dir") + [ "${result%%$'\t'*}" != 0 ] \ + || fail "a scout claim naming another task's report was verified: $result" + case "${result#*$'\t'}" in + *"not this task's own"*) ;; + *) fail "the refusal did not say whose report it was: $result" ;; + esac + pass "a scout claim naming another task's report is not verified" +} + # --- the three shapes of terminal evidence ----------------------------------- # A verdict is a statement about a world. Absence of evidence must never # downgrade one; positive evidence of falsity outranks everything; and a world @@ -1087,6 +1213,30 @@ test_a_terminal_outcome_invents_no_verdict_without_a_claim() { pass "a terminal PR outcome invents no verdict for a task that never claimed done" } +# A secondary record must never have a veto over the primary report. The verdict +# write can fail for reasons that have nothing to do with the outcome, and a +# closed-unmerged observation that goes unpublished is the exact rot this whole +# path exists to catch - worse on the poll path, where a non-zero return kills +# the watcher outright. +test_a_failed_verdict_write_still_publishes_the_outcome() { + local claim dir out + claim="done: pr=$PR_A head=$HEAD_A - shipped" + dir=$(make_claim_world verdict-write-blocked "$claim" verified "the PR is open at the claimed head") \ + || fail "the established-claim fixture failed" + # A symlink at the verdict path is one real way fm_done_verdict_write refuses. + rm -f "$dir/state/task-v.done-verdict" + ln -s /dev/null "$dir/state/task-v.done-verdict" \ + || fail "the fixture could not block the verdict path" + fm_merge_outcome_report "$dir" "$dir/state" task-v "$PR_A" poll closed-unmerged \ + && fail "a blocked verdict write was reported as a clean outcome" + out=$(cat "$dir/state/.wake-queue" 2>/dev/null || true) + case "$out" in + *"PR closed without merging"*) ;; + *) fail "a blocked verdict write suppressed the close report entirely: $out" ;; + esac + pass "a failed verdict write never suppresses the outcome report it follows" +} + # The precedence itself, driven through the record's owner. Each incoming # verdict is tried against a standing one and the record is read back. test_verdict_write_precedence_keeps_the_stronger_statement() { @@ -1217,6 +1367,47 @@ test_the_unrecognised_cap_is_per_task_not_per_drain() { pass "the unrecognised cap is per task, so one chatty worker cannot starve the rest" } +# The span index the drain holds its cursor back with and the byte counter that +# converts it are one number reached two ways, and they must agree about which +# lines count. They do, because status_new_lines_since_cursor drops every +# whitespace-only line before either sees it - so this pins that agreement from +# the outside rather than trusting the reader to keep filtering. If it ever +# stopped, a blank line in a partly presented span would shift the committed +# endpoint a line further than the drain asked for, retiring a line the operator +# was just told was held. +test_a_blank_line_in_a_held_back_span_loses_nothing() { + local dir state status first second + dir=$(make_case unrecognised-blank-line) + state="$dir/state" + status="$state/task9.status" + printf 'note: bootstrap cursor line\n' > "$status" + FM_STATE_OVERRIDE="$state" "$DRAIN" >/dev/null 2>/dev/null \ + || fail "bootstrap drain failed while priming the cursor" + + { + printf ' \n' + printf 'prose alpha from the worker\n' + printf 'prose bravo from the worker\n' + printf 'prose charlie from the worker\n' + } >> "$status" + + first="$dir/first.out"; second="$dir/second.out" + FM_DRAIN_UNRECOGNISED_MAX=1 FM_STATE_OVERRIDE="$state" "$DRAIN" > "$first" \ + || fail "the capped drain failed on a span containing a blank line" + assert_grep 'prose alpha from the worker' "$first" \ + "the capped drain printed nothing: $(cat "$first")" + assert_no_grep 'prose bravo from the worker' "$first" \ + "the cap did not hold the second prose line back" + + FM_DRAIN_UNRECOGNISED_MAX=9 FM_STATE_OVERRIDE="$state" "$DRAIN" > "$second" \ + || fail "the second drain failed" + assert_grep 'prose bravo from the worker' "$second" \ + "a blank line in the span retired a held-back line: $(cat "$second")" + assert_grep 'prose charlie from the worker' "$second" \ + "the rest of the held-back prose never came back: $(cat "$second")" + pass "a whitespace-only line in a held-back span costs the worker no words" +} + test_the_omitted_unrecognised_count_carries_its_header() { local dir state status out dir=$(make_case unrecognised-cap-zero) @@ -1317,12 +1508,19 @@ test_a_close_does_not_invent_a_done_claim test_a_configured_verb_vocabulary_is_not_unrecognised test_a_pruned_reflog_is_unverified_not_contradicted test_a_contradiction_needs_the_observation_it_contradicts_with +test_a_local_only_claim_on_work_the_branch_never_made_is_not_verified +test_a_retired_branch_claim_on_work_never_made_is_not_verified +test_a_direct_pr_claim_on_another_branch_is_not_verified +test_a_direct_pr_claim_on_this_task_branch_verifies +test_a_scout_claim_on_another_tasks_report_is_not_verified test_a_merge_marks_an_established_claim_stale test_a_close_contradicts_an_established_claim_in_the_record test_a_terminal_outcome_invents_no_verdict_without_a_claim +test_a_failed_verdict_write_still_publishes_the_outcome test_verdict_write_precedence_keeps_the_stronger_statement test_a_held_back_span_with_multibyte_prose_presents_no_fragment test_the_unrecognised_cap_is_per_task_not_per_drain +test_a_blank_line_in_a_held_back_span_loses_nothing test_the_omitted_unrecognised_count_carries_its_header test_capped_unrecognised_lines_return_on_the_next_drain test_capped_lines_return_even_when_a_recognised_line_follows_them From fb61cb22b2fcee3a27e5a4b3c8b9919486289b30 Mon Sep 17 00:00:00 2001 From: Israel Wilson Date: Thu, 3 Sep 2026 16:03:02 -0400 Subject: [PATCH 09/21] no-mistakes(review): bind scout reports by location; let failed withdraw a claim --- AGENTS.md | 2 + bin/fm-done-claim-lib.sh | 49 +++++++--- bin/fm-merge-outcome-lib.sh | 16 +++- bin/fm-pr-lib.sh | 24 ++--- bin/fm-verify-done.sh | 61 ++++++++++-- bin/fm-watch.sh | 30 ++++-- docs/architecture.md | 5 +- tests/fm-done-verified.test.sh | 143 ++++++++++++++++++++++++++++- tests/fm-pr-check-security.test.sh | 24 ++++- tests/fm-teardown.test.sh | 54 +++++++++++ 10 files changed, 356 insertions(+), 52 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 5e3b1430bc5..5c4242b6952 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -376,6 +376,8 @@ A terminal `done:` claim names the exact commit, PR, branch, or report it is cla A claim that has not been established is never done: `bin/fm-crew-state.sh` reports it as `done-unverified`, and teardown refuses it. Terminal evidence about a claim has three shapes, not two, and the verdict record names all three: `unverified` is absence of evidence and never downgrades what already stands, `contradicted` is positive evidence of falsity and overwrites anything, and `stale` is the world having changed under a verdict that was true when it was made, which `bin/fm-merge-outcome-lib.sh` records when a PR merges or closes under a standing claim. A `stale` verdict is not established either: it reports as `done-unverified` and teardown re-runs `bin/fm-verify-done.sh` rather than passing on it. +The way out of a claim that turned out to be false is to withdraw it, not to override the gate: a `failed:` line appended after a `done:` line retracts the claim, so the task has none to verify and cleanup is no longer held to it. +Only `failed:` retracts; `blocked:` and `needs-decision:` say the work is ongoing and leave the claim standing, and teardown's landed-work gates apply either way. Run `bin/fm-pr-check.sh ` with the URL copied from the terminal claim's `pr=` field or the task's `pr=` metadata, never assembled from memory - it records `pr=` and the forge's `pr_head=` when available in the task's meta and arms the watcher's poll, which reports a merge and a close without merge alike. That registration also runs `bin/fm-verify-done.sh` for the task and prints its verdict, so a claim is established while the forge is already being consulted rather than only at cleanup; the verdict is advisory there and never changes what the registration records, arms, or exits with. Tell the captain the PR's full URL, always the complete `https://...` link rather than a bare `#number`, copied verbatim from the claim's `pr=` field or the task's `pr=` metadata, a concise outcome summary, and the no-mistakes risk level when applicable. diff --git a/bin/fm-done-claim-lib.sh b/bin/fm-done-claim-lib.sh index d43b1d98159..e925c3416f7 100644 --- a/bin/fm-done-claim-lib.sh +++ b/bin/fm-done-claim-lib.sh @@ -31,7 +31,8 @@ # # # The claim hash binds a verdict to one exact claim, so appending a new `done:` -# line invalidates the old verdict instead of inheriting it. +# line invalidates the old verdict instead of inheriting it, and a later +# `failed:` line withdraws the claim entirely (see fm_done_claim_last). # # THE THREE SHAPES OF TERMINAL EVIDENCE. Evidence about a standing claim comes in # three shapes, not two, and conflating the third with the first is what let a @@ -146,24 +147,49 @@ fm_done_claim_has_identity() { [ -n "$FM_DONE_CLAIM_REPORT" ] } -# The last `done:` line in a status log, or empty. A status log is an -# append-only event log, so the newest claim is the one under test; earlier +# The task's STANDING terminal claim, or empty when it has none. A status log is +# an append-only event log, so the newest `done:` is the one under test; earlier # claims were superseded by whatever the worker did next. # +# A later `failed:` line WITHDRAWS the claim, and the task then has none. That is +# the whole of the supersession rule and it is deliberately narrow: only +# `failed:` retracts. `blocked:` and `needs-decision:` must not, because they say +# the work is ongoing, not that the assertion is withdrawn, and a task sitting on +# a claim it still makes should stay refused while it is merely stuck. +# +# Why the rule exists at all, so it is not "simplified" back: without it a task +# whose PR was abandoned is held forever to an assertion it has already +# disowned - the claim gate refuses, and the only remaining instrument is +# `--force`, which AGENTS.md ties to explicit discard authority. Nothing is +# weakened by letting honesty out: a task that still CLAIMS done cannot be +# cleaned up on a false claim, and unlanded work stays protected by teardown's +# own landed-work gates, which are separate and untouched. The point of the shape +# is which exit it puts within reach. Make the way out of a false claim +# withdrawing it and people withdraw it; make it force and they learn to reach +# for force. +# # This runs per task on every current-state read and every session-start digest, # so the leading-verb test is only paid for by lines that could possibly carry -# it. `status_line_verb` costs a fork per call, and a line whose verb is `done` -# always begins with `done` after its leading whitespace - a correlation token -# or a `[key=...]` only ever follows the verb word - so the fork-free prefix -# test below is exact, not an approximation, and skips it for every other line. +# one of the two verbs. `status_line_verb` costs a fork per call, and a line +# whose verb is `done` or `failed` always begins with that word after its leading +# whitespace - a correlation token or a `[key=...]` only ever follows the verb +# word - so the fork-free prefix test below is exact, not an approximation, and +# skips it for every other line. fm_done_claim_last() { # local f=${1:-} line trimmed last= [ -f "$f" ] && [ -r "$f" ] && [ ! -L "$f" ] || return 0 while IFS= read -r line || [ -n "$line" ]; do trimmed=${line#"${line%%[![:space:]]*}"} - case "$trimmed" in done*) ;; *) continue ;; esac - [ "$(status_line_verb "$line")" = "done" ] || continue - last=$line + case "$trimmed" in + done*) + [ "$(status_line_verb "$line")" = "done" ] || continue + last=$line + ;; + failed*) + [ "$(status_line_verb "$line")" = "failed" ] || continue + last= + ;; + esac done < "$f" printf '%s' "$last" } @@ -332,7 +358,8 @@ fm_done_verdict_read() { # # test, empty when the task has never claimed done), FM_DONE_CLAIM_STATE, and # FM_DONE_CLAIM_REASON, plus the parsed claim fields. # -# none the task has made no terminal claim; nothing to verify. +# none the task has no standing terminal claim: it never made one, or +# it made one and a later `failed:` line withdrew it. # verified a matching verdict record establishes the claim. # unverified the claim cannot be established: no commit identity (every # legacy claim), no verdict yet, a verdict for a superseded diff --git a/bin/fm-merge-outcome-lib.sh b/bin/fm-merge-outcome-lib.sh index 6e7c8643fdd..8d633bc2f95 100755 --- a/bin/fm-merge-outcome-lib.sh +++ b/bin/fm-merge-outcome-lib.sh @@ -56,6 +56,12 @@ _FM_MERGE_OUTCOME_LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # shellcheck disable=SC2034 # Public result consumed by sourcing callers. FM_MERGE_OUTCOME_ALREADY_RECORDED=false +# true only when this call durably recorded a verdict for the task's standing +# claim. A caller that retires the poll on the strength of the outcome having +# been RECORDED reads this rather than the return code, which is also 0 when +# there was no claim to record anything about. +# shellcheck disable=SC2034 # Public result consumed by sourcing callers. +FM_MERGE_OUTCOME_VERDICT_RECORDED=false # fm_merge_outcome_report [] # @@ -80,6 +86,8 @@ fm_merge_outcome_report() { # [ [ "$tmp" \ + "$result" > "$tmp" \ || ! chmod 0600 "$tmp" \ || ! fm_pr_private_file_valid "$tmp" 600 "$state_device" \ || ! fm_pr_poll_retirement_parse "$tmp" \ @@ -987,18 +987,12 @@ fm_pr_poll_merge_marker_matches() { # case "${1:-}" in merged|closed-unmerged) return 0 ;; esac return 1 diff --git a/bin/fm-verify-done.sh b/bin/fm-verify-done.sh index 9898f2f5db0..42385462e70 100755 --- a/bin/fm-verify-done.sh +++ b/bin/fm-verify-done.sh @@ -57,12 +57,16 @@ # - the claimed report= exists as a non-empty regular file INSIDE this task's # own directory under the home's data root. A file existing is not this # task having produced it, so the path is bound to // the -# same way the local-only arm is bound to fm/. An absolute path is -# used as given (what bin/fm-brief.sh renders into a scout brief); a -# relative one is resolved against the data root, so a relocated -# FM_DATA_OVERRIDE resolves the same claim as an unrelocated one. A leading -# `data/` is the home-relative spelling of that root and names it rather -# than a directory beneath it. +# same way the local-only arm is bound to fm/. That binding is +# made on the real location, not on the spelling of one: a `report=` +# carrying a `..` component is refused outright, and the containing +# directory is then resolved physically before it is compared, so neither +# an upward walk nor a directory symlink can present another task's +# deliverable as this one's. An absolute path is used as given (what +# bin/fm-brief.sh renders into a scout brief); a relative one is resolved +# against the data root, so a relocated FM_DATA_OVERRIDE resolves the same +# claim as an unrelocated one. A leading `data/` is the home-relative +# spelling of that root and names it rather than a directory beneath it. # # A task whose meta records no mode= - the population spawned before mode= was # recorded - is routed by the shape of its own claim, because an absent record is @@ -99,7 +103,10 @@ # for the three shapes and the write precedence that keeps them apart. # # The verdict record binds to the exact claim line it judged, so appending a new -# `done:` line invalidates it rather than inheriting its verdict. +# `done:` line invalidates it rather than inheriting its verdict. A task whose +# claim a later `failed:` line has withdrawn has no claim to verify and exits 2: +# withdrawing a claim, not overriding the gate, is the way out of a false one +# (see fm_done_claim_last in bin/fm-done-claim-lib.sh). set -u SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" @@ -207,6 +214,17 @@ if [ "$KIND" = scout ]; then verdict_is contradicted 'scout claim names no report' "$FM_DONE_CLAIM_LINE" finish fi + # A `..` component makes the spelling of a path and its location two different + # things, and every containment test below is about the location. Refused + # outright rather than normalised away: a scout report path has no legitimate + # reason to walk upward, and the claim naming one is itself the observation. + case "/$FM_DONE_CLAIM_REPORT/" in + */../*) + verdict_is contradicted "the claimed report walks out of its own directory: $FM_DONE_CLAIM_REPORT" \ + "$FM_DONE_CLAIM_REPORT" + finish + ;; + esac case "$REPORT" in /*) ;; data/*) REPORT="$DATA/${REPORT#data/}" ;; @@ -216,6 +234,13 @@ if [ "$KIND" = scout ]; then # under the home's data root in the task's own directory, so a claim resolving # anywhere else names someone else's deliverable however real that file is - # the same binding the local-only arm makes by requiring branch = fm/. + # + # Tested twice, on the spelling and then on the real location, because a prefix + # match alone tests only how a path is written: this same binding was added to + # stop a substitution and shipped with `..` walking straight through it. The + # second test resolves both sides physically, so a directory symlink cannot + # stand in for the walk either. A path that will not resolve is left to the + # existence checks below, which report it as missing rather than as foreign. case "$REPORT" in "$DATA/$ID/"?*) ;; *) @@ -224,6 +249,26 @@ if [ "$KIND" = scout ]; then finish ;; esac + # The task's own directory has to BE a directory for containment in it to mean + # anything: a link there points containment wherever the link does. Refusing to + # read it is not proof the claim is false, so it names the link and reads + # unverified, exactly as the report symlink below does. + if [ -L "$DATA/$ID" ]; then + verdict_is unverified "this task's report directory is a symlink, which is not read as evidence: $DATA/$ID" + finish + fi + REPORT_DIR=$(cd "$(dirname "$REPORT")" 2>/dev/null && pwd -P) || REPORT_DIR= + OWN_DIR=$(cd "$DATA/$ID" 2>/dev/null && pwd -P) || OWN_DIR= + if [ -n "$REPORT_DIR" ] && [ -n "$OWN_DIR" ]; then + case "$REPORT_DIR/" in + "$OWN_DIR/"*) ;; + *) + verdict_is contradicted "the claimed report is not this task's own: $FM_DONE_CLAIM_REPORT is really in $REPORT_DIR, outside $OWN_DIR" \ + "$REPORT_DIR" + finish + ;; + esac + fi # A symlink is refused as evidence rather than followed, but refusing to read # something is not proof that it is false: the report may be perfectly present # behind the link. That is absence of evidence, so it names the link and reads @@ -426,7 +471,7 @@ if [ "$MODE" = local-only ]; then verdict_is unverified "the local copy is at the claimed $HEAD_CLAIM and it is on $DEFAULT, but its own history records no commit it made, so nothing establishes that this task authored that commit rather than inheriting it" finish fi - verdict_is verified "the local copy is at the claimed $HEAD_CLAIM, which it committed at $AUTHORED_AT, and it is on $DEFAULT; its branch $BRANCH has been retired" + verdict_is verified "the local copy is at the claimed $HEAD_CLAIM, its own history records a commit it made at $AUTHORED_AT, and the claim is on $DEFAULT; its branch $BRANCH has been retired" finish fi DEFAULT_TIP=$(git -C "$WT" rev-parse --verify --quiet "$DEFAULT_REF" 2>/dev/null || true) diff --git a/bin/fm-watch.sh b/bin/fm-watch.sh index b5a6bd710ac..fb4e9f10651 100755 --- a/bin/fm-watch.sh +++ b/bin/fm-watch.sh @@ -1519,13 +1519,13 @@ fi # Shared by both the first-notification and already-notified paths below so # the retirement sequence (bin/fm-pr-lib.sh) is stated once. -retire_merged_pr_poll() { # - local id=$1 - if fm_pr_poll_retirement_publish "$STATE" "$id" "$SCRIPT_DIR/fm-pr-poll.sh" merged; then +retire_terminal_pr_poll() { # + local id=$1 outcome=$2 + if fm_pr_poll_retirement_publish "$STATE" "$id" "$SCRIPT_DIR/fm-pr-poll.sh" "$outcome"; then fm_pr_poll_retirement_recover_one "$STATE" "$id" "$SCRIPT_DIR/fm-pr-poll.sh" \ - || triage_log "merged PR poll retirement remains recoverable for $id" + || triage_log "$outcome PR poll retirement remains recoverable for $id" else - triage_log "merged PR poll retirement deferred because its canonical snapshot changed for $id" + triage_log "$outcome PR poll retirement deferred because its canonical snapshot changed for $id" fi } @@ -1662,9 +1662,17 @@ while :; do reason="check: $c: $out" # Both PR-poll terminal outcomes publish through the one owner # (bin/fm-merge-outcome-lib.sh), which dedupes on PR identity AND - # outcome. Only `merged` retires the poll: a PR closed without merging - # contradicts the task's done record but is not final, because it can - # still be reopened and merged, so its poll stays armed to report that. + # outcome, and both retire the poll once their outcome is RECORDED. A + # poll exists to observe a terminal outcome; once that outcome is durably + # on record its job is finished and asking the forge again is pure waste. + # A close used to keep polling on the ground that it can be reopened and + # merged - the recorded contradiction now carries that instead, and a + # reopen that goes on to merge still reaches the merged path through the + # poll re-armed at re-registration. + # `merged` retires on the report alone, as it always has. A close retires + # only once the contradiction is durably recorded, because an unrecorded + # close is an outcome nothing has yet corrected: leaving its poll armed is + # what brings it back. if [ "$is_pr_poll" -eq 1 ] && { [ "$out" = merged ] || [ "$out" = closed-unmerged ]; }; then merge_outcome_rc=0 fm_merge_outcome_report "$FM_HOME" "$STATE" "$id" "$url" poll "$out" \ @@ -1673,7 +1681,11 @@ while :; do triage_log "PR $out outcome for $id could not be recorded (rc=$merge_outcome_rc)" exit 1 fi - [ "$out" != merged ] || retire_merged_pr_poll "$id" + if [ "$out" = merged ]; then + retire_terminal_pr_poll "$id" merged + elif [ "$FM_MERGE_OUTCOME_VERDICT_RECORDED" = true ]; then + retire_terminal_pr_poll "$id" "$out" + fi touch "$STATE/.last-check" if [ "$FM_MERGE_OUTCOME_ALREADY_RECORDED" = true ]; then triage_log "absorbed duplicate $out PR poll result for $id" diff --git a/docs/architecture.md b/docs/architecture.md index aa7284dc02b..309c4deffd4 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -25,11 +25,12 @@ Agent endpoint liveness and queue-consumption liveness are separate: on each pol Once that row reaches `FM_SECONDMATE_WAKE_STALL_SECS`, the primary appends one keyed `check` wake naming the mate, row sequence, and observed age; parent receipts and queued-key deduplication suppress repeats for the same row across watcher and handling crashes, while empty and younger queues remain silent. Endpointless registered mates remain outside this scan because startup secondmate-liveness owns dead or missing endpoint recovery, and remote homes retain their host-local supervision boundary. `tests/fm-wake-queue.test.sh` pins the notification, idempotence, quiet-queue, and byte-for-byte foreign-row preservation guarantees. -When a canonical validated PR poll returns exactly `merged`, the watcher routes it through the shared merge-outcome emitter before retiring the poll. +When a canonical validated PR poll returns a terminal outcome, the watcher routes it through the shared merge-outcome emitter before retiring the poll. +`merged` retires on successful publication; `closed-unmerged` retires only once the emitter has durably recorded its contradiction against the task's standing claim, so a close nothing has corrected keeps its poll armed. [`bin/fm-merge-outcome-lib.sh`](../bin/fm-merge-outcome-lib.sh)'s header owns role routing, PR-specific wake identity, marker-locked normal deduplication, and the at-least-once ordering that prefers a rare duplicate over silence. After successful outcome publication, the watcher immediately delivers the emitter's local actionable poll row and publishes a private retirement receipt bound to the poll's registration, bytes, file identities, metadata, provider, URL, and task ID. The retirement receipt makes poll cleanup safely retryable across restarts: fixed-path recovery revalidates the same evidence, removes the runnable check first, removes its registration and data sidecars, removes the receipt last, and preserves task metadata including `pr=` and `pr_head=`. -A concurrent replacement remains armed, every non-merged or invalid observation remains unchanged, and retirement never performs task or persistent-secondmate cleanup. +A concurrent replacement remains armed, every non-terminal or invalid observation remains unchanged, and retirement never performs task or persistent-secondmate cleanup. `bin/fm-pr-lib.sh` owns the notification-marker and retirement-receipt formats plus their strict identity mechanics, [`bin/fm-merge-outcome-lib.sh`](../bin/fm-merge-outcome-lib.sh) owns role-routed publication, the local durable row, and marker ordering, and `bin/fm-watch.sh` owns immediate poll-result delivery and retirement. No-verb wakes, such as `working:` notes and bare turn-ended signals, are benign only when every referenced task independently has positive evidence that its crew is still working: a currently attributed active no-mistakes step, or an exact busy verdict from the semantic busy-state contract, both read through `bin/fm-crew-state.sh`. A home that creates `config/turnend-churn-absorb` lets each eligible bare turn-ended task that lacks either authoritative proof use a third form: pane content that changed since the previous poll, compared against the same `state/.hash-*` marker the staleness backbone records, which claims no harness semantics and needs no adapter cooperation. diff --git a/tests/fm-done-verified.test.sh b/tests/fm-done-verified.test.sh index 3b8b6c56827..41bb8c33c92 100755 --- a/tests/fm-done-verified.test.sh +++ b/tests/fm-done-verified.test.sh @@ -60,6 +60,12 @@ # and merge the brief itself instructs - is not verified, at either arm # (ai) a direct-PR claim is bound to this task's own branch, and a scout claim # to this task's own report directory: a real PR or file is not authorship +# (al) the scout binding holds on the LOCATION, not the spelling: a `..` walk +# and a directory symlink both fail, an honest claim still verifies +# (am) a later `failed:` line withdraws a claim; `blocked:` and +# `needs-decision:` do not, and a later `done:` re-asserts one +# (an) a close reports whether it durably recorded its contradiction, which is +# what decides whether its poll retires or stays armed # (aj) a whitespace-only line inside a partly held-back span retires nothing: # the span index and the byte counter agree about what counts as a line # (ak) a verdict write that fails never suppresses the outcome report, which @@ -786,7 +792,7 @@ test_help_prints_the_whole_header() { out=$("$VERIFY" --help) || fail "fm-verify-done.sh --help did not exit 0" last=$(printf '%s\n' "$out" | grep -v '^[[:space:]]*$' | tail -1) case "$last" in - *"invalidates it rather than inheriting its verdict.") ;; + *"(see fm_done_claim_last in bin/fm-done-claim-lib.sh).") ;; *) fail "--help truncated its own header; it ends at: $last" ;; esac case "$out" in @@ -1124,7 +1130,100 @@ test_a_scout_claim_on_another_tasks_report_is_not_verified() { *"not this task's own"*) ;; *) fail "the refusal did not say whose report it was: $result" ;; esac - pass "a scout claim naming another task's report is not verified" + # The same substitution written as a walk out of this task's own directory. + # A prefix match tests the SPELLING of a location; this tests the location. + dir=$(make_world scout-traversal scout scout) + mkdir -p "$dir/data/task-other" "$dir/data/task-v" + printf 'somebody elses findings\n' > "$dir/data/task-other/report.md" + printf 'done: report=data/task-v/../task-other/report.md - shipped\n' \ + > "$dir/state/task-v.status" + result=$(verify "$dir") + [ "${result%%$'\t'*}" != 0 ] \ + || fail "a scout claim walking out of its own directory was verified: $result" + case "${result#*$'\t'}" in + *"walks out of its own directory"*) ;; + *) fail "the traversing spelling was not refused as one: $result" ;; + esac + + # A directory symlink is the other way to name a real file somewhere else. It + # is absence of evidence rather than falsity - the link is refused as evidence + # rather than followed - so it reads unverified, never verified. + dir=$(make_world scout-dir-symlink scout scout) + mkdir -p "$dir/data/task-other" + printf 'somebody elses findings\n' > "$dir/data/task-other/report.md" + ln -s "$dir/data/task-other" "$dir/data/task-v" \ + || fail "the fixture could not link the task directory elsewhere" + printf 'done: report=data/task-v/report.md - shipped\n' > "$dir/state/task-v.status" + result=$(verify "$dir") + [ "${result%%$'\t'*}" = 3 ] \ + || fail "a scout claim reaching another task's directory through a link was not unverified: $result" + + # And the honest case, so none of the above is vacuous. + dir=$(make_world scout-own scout scout) + mkdir -p "$dir/data/task-v" + printf 'my own findings\n' > "$dir/data/task-v/report.md" + printf 'done: report=data/task-v/report.md - shipped\n' > "$dir/state/task-v.status" + result=$(verify "$dir") + [ "${result%%$'\t'*}" = 0 ] \ + || fail "a scout claim naming this task's own report was not verified: $result" + pass "a scout claim is bound to this task's own report by location, not spelling" +} + +# --- withdrawing a claim, rather than overriding the guard -------------------- +# A claim the task has retracted is not a claim. Without this a task whose PR was +# abandoned stays held to an assertion it has already disowned, and the only exit +# left is the flag AGENTS.md ties to explicit discard authority. +test_a_later_failed_line_withdraws_the_claim() { + local dir state + dir="$TMP_ROOT/claim-withdrawn" + state="$dir/state" + mkdir -p "$state" + printf 'done: pr=%s head=%s - shipped\n' "$PR_A" "$HEAD_A" > "$state/task-v.status" + fm_done_claim_status "$state" task-v + [ "$FM_DONE_CLAIM_STATE" != none ] \ + || fail "setup error: the standing claim was not seen at all" + + printf 'failed: the PR was abandoned, no work landed\n' >> "$state/task-v.status" + fm_done_claim_status "$state" task-v + [ "$FM_DONE_CLAIM_STATE" = none ] \ + || fail "a claim withdrawn by a later failed line still stands as $FM_DONE_CLAIM_STATE" + [ -z "$FM_DONE_CLAIM_LINE" ] \ + || fail "a withdrawn claim was still returned as the line under test" + pass "a later failed line withdraws a terminal claim" +} + +# Deliberately narrow: only `failed:` retracts. A task that is merely stuck is +# still making its claim, and must stay held to it. +test_only_a_failed_line_withdraws_the_claim() { + local dir state verb + for verb in blocked needs-decision; do + dir="$TMP_ROOT/claim-standing-$verb" + state="$dir/state" + mkdir -p "$state" + printf 'done: pr=%s head=%s - shipped\n' "$PR_A" "$HEAD_A" > "$state/task-v.status" + printf '%s: waiting on something\n' "$verb" >> "$state/task-v.status" + fm_done_claim_status "$state" task-v + [ "$FM_DONE_CLAIM_STATE" != none ] \ + || fail "a $verb line withdrew a claim the task is still making" + done + + # A `done:` appended after a retraction is a fresh claim, not a revival. + dir="$TMP_ROOT/claim-reasserted" + state="$dir/state" + mkdir -p "$state" + { + printf 'done: pr=%s head=%s - first try\n' "$PR_A" "$HEAD_A" + printf 'failed: the PR was abandoned\n' + printf 'done: pr=%s head=%s - second try\n' "$PR_A" "$HEAD_A" + } > "$state/task-v.status" + fm_done_claim_status "$state" task-v + [ "$FM_DONE_CLAIM_STATE" != none ] \ + || fail "a claim re-asserted after a retraction did not stand" + case "$FM_DONE_CLAIM_LINE" in + *"second try"*) ;; + *) fail "the re-asserted claim was not the line under test: $FM_DONE_CLAIM_LINE" ;; + esac + pass "only a failed line withdraws a claim, and a later done re-asserts one" } # --- the three shapes of terminal evidence ----------------------------------- @@ -1237,6 +1336,43 @@ test_a_failed_verdict_write_still_publishes_the_outcome() { pass "a failed verdict write never suppresses the outcome report it follows" } +# A poll exists to observe a terminal outcome, so bin/fm-watch.sh retires a close +# once its contradiction is durably RECORDED - and leaves it armed when it is +# not, because an outcome nothing has corrected is exactly the one that must come +# back. This is the signal the watcher branches on. +test_a_close_reports_whether_it_recorded_its_contradiction() { + local claim dir + claim="done: pr=$PR_A head=$HEAD_A - shipped" + dir=$(make_claim_world close-recorded "$claim" verified "the PR is open at the claimed head") \ + || fail "the established-claim fixture failed" + fm_merge_outcome_report "$dir" "$dir/state" task-v "$PR_A" poll closed-unmerged \ + || fail "the close outcome could not be published" + [ "$FM_MERGE_OUTCOME_VERDICT_RECORDED" = true ] \ + || fail "a recorded contradiction did not report itself as recorded" + + # A blocked verdict path: the close is still published, but nothing has + # corrected the record, so the poll must stay armed. + dir=$(make_claim_world close-unrecorded "$claim" verified "the PR is open at the claimed head") \ + || fail "the established-claim fixture failed" + rm -f "$dir/state/task-v.done-verdict" + ln -s /dev/null "$dir/state/task-v.done-verdict" \ + || fail "the fixture could not block the verdict path" + fm_merge_outcome_report "$dir" "$dir/state" task-v "$PR_A" poll closed-unmerged \ + && fail "a blocked verdict write was reported as a clean outcome" + [ "$FM_MERGE_OUTCOME_VERDICT_RECORDED" = false ] \ + || fail "a contradiction that was never written reported itself as recorded" + + # No claim on record is nothing to contradict, so nothing is recorded and the + # poll stays armed for a reopen the worker has not yet claimed anything about. + dir=$(make_claim_world close-no-claim 'working: still going') \ + || fail "the no-claim fixture failed" + fm_merge_outcome_report "$dir" "$dir/state" task-v "$PR_A" poll closed-unmerged \ + || fail "the no-claim close could not be published" + [ "$FM_MERGE_OUTCOME_VERDICT_RECORDED" = false ] \ + || fail "a close with no claim on record reported a recorded contradiction" + pass "a close reports whether its contradiction was durably recorded" +} + # The precedence itself, driven through the record's owner. Each incoming # verdict is tried against a standing one and the record is read back. test_verdict_write_precedence_keeps_the_stronger_statement() { @@ -1513,10 +1649,13 @@ test_a_retired_branch_claim_on_work_never_made_is_not_verified test_a_direct_pr_claim_on_another_branch_is_not_verified test_a_direct_pr_claim_on_this_task_branch_verifies test_a_scout_claim_on_another_tasks_report_is_not_verified +test_a_later_failed_line_withdraws_the_claim +test_only_a_failed_line_withdraws_the_claim test_a_merge_marks_an_established_claim_stale test_a_close_contradicts_an_established_claim_in_the_record test_a_terminal_outcome_invents_no_verdict_without_a_claim test_a_failed_verdict_write_still_publishes_the_outcome +test_a_close_reports_whether_it_recorded_its_contradiction test_verdict_write_precedence_keeps_the_stronger_statement test_a_held_back_span_with_multibyte_prose_presents_no_fragment test_the_unrecognised_cap_is_per_task_not_per_drain diff --git a/tests/fm-pr-check-security.test.sh b/tests/fm-pr-check-security.test.sh index ec4f38ea464..c63d26b48dc 100755 --- a/tests/fm-pr-check-security.test.sh +++ b/tests/fm-pr-check-security.test.sh @@ -146,10 +146,11 @@ case "${1:-} ${2:-}" in ;; esac case " $* " in - *"state,headRefOid,url,statusCheckRollup"*) + *"state,headRefOid,headRefName,url,statusCheckRollup"*) [ "${FM_TEST_GH_SLEEP:-0}" = 0 ] || sleep "$FM_TEST_GH_SLEEP" - printf '%s\t%s\t%s\n' "${FM_TEST_GH_STATE:-OPEN}" \ - "${FM_TEST_GH_HEAD:-0123456789abcdef0123456789abcdef01234567}" SUCCESS + printf '%s\t%s\t%s\t%s\n' "${FM_TEST_GH_STATE:-OPEN}" \ + "${FM_TEST_GH_HEAD:-0123456789abcdef0123456789abcdef01234567}" \ + "${FM_TEST_GH_HEAD_BRANCH:-fm/task-a}" SUCCESS ;; *" headRefOid "*) printf '%s\n' "${FM_TEST_GH_HEAD:-0123456789abcdef0123456789abcdef01234567}" ;; *" state "*) @@ -2098,6 +2099,23 @@ test_retirement_refuses_replacement_and_nonterminal_results() { done [ "$(state_snapshot "$state")" = "$before" ] || fail "nonterminal result changed canonical artifacts" + # A close without merge is terminal too: once its contradiction is recorded the + # poll has nothing left to observe, so it retires on the same receipt the merge + # path uses, carrying the outcome that actually retired it. + dir=$(make_case retirement-closed-unmerged) + state="$dir/home/state" + write_poll_meta "$state" task-a https://github.com/o/r/pull/7 + seed_canonical_poll "$dir" task-a https://github.com/o/r/pull/7 + fm_pr_poll_snapshot_capture "$state" task-a "$POLL" || fail "could not snapshot close fixture" + fm_pr_poll_retirement_publish "$state" task-a "$POLL" closed-unmerged \ + || fail "a recorded close could not retire its own poll" + fm_pr_poll_retirement_parse "$state/task-a.pr-poll-retirement" \ + || fail "the close retirement receipt did not parse" + fm_pr_poll_retirement_recover_one "$state" task-a "$POLL" \ + || fail "the close retirement receipt was not recoverable" + [ ! -e "$state/task-a.check.sh" ] \ + || fail "the close retirement left the poll's runnable check armed" + printf '# tamper\n' >> "$state/task-a.check.sh" before=$(state_snapshot "$state") fm_pr_poll_retirement_publish "$state" task-a "$POLL" merged \ diff --git a/tests/fm-teardown.test.sh b/tests/fm-teardown.test.sh index 135390f3763..c5c58d73ef5 100755 --- a/tests/fm-teardown.test.sh +++ b/tests/fm-teardown.test.sh @@ -51,6 +51,8 @@ # (z7) same blinded world with NO record standing -> REFUSE (unverified) # (z8) a `stale` record, verifier blinded -> REFUSE (not established) # (z9) verified record, then the PR closed unmerged -> REFUSE (contradicted) +# (z10) false claim, then a `failed:` line withdrawing it -> ALLOW (no claim) +# (z11) false claim + `blocked:`/`needs-decision:` -> REFUSE (still claimed) # # Also covers backlog teardown-lock-race: a git index.lock left in the worktree by a # killed crew process (bin/fm-teardown.sh's teardown_treehouse_return). @@ -2909,6 +2911,56 @@ seed_verdict_record() { # # `stale` is not established. The record-first gate must fall through to the # verifier for it, which is the whole point: the expensive check is deferred to # the gate that actually needs the answer, and here that check cannot be made. +# The exit from a false claim is withdrawing it, not overriding the guard. A +# task whose PR was abandoned and which says so honestly has no claim left to +# verify, so the claim gate has nothing to hold it to - while its landed-work +# gates, which are separate, still apply. +test_a_withdrawn_claim_no_longer_holds_up_cleanup() { + local case_dir rc=0 head + case_dir=$(make_claim_case claim-withdrawn "placeholder") + head=$(git -C "$case_dir/wt" rev-parse 'HEAD~1') + # A claim that is false: it names a commit that is not the branch tip. + printf 'done: branch=fm/task-x1 head=%s - shipped\n' "$head" \ + > "$case_dir/state/task-x1.status" + set +e + run_teardown "$case_dir" > "$case_dir/stdout" 2> "$case_dir/stderr" + rc=$? + set -e + [ "$rc" -ne 0 ] \ + || fail "setup error: the false claim was not refused in the first place" + + printf 'failed: the PR was abandoned, nothing landed\n' >> "$case_dir/state/task-x1.status" + set +e + run_teardown "$case_dir" > "$case_dir/stdout2" 2> "$case_dir/stderr2" + rc=$? + set -e + expect_code 0 "$rc" \ + "a claim the task withdrew still held up cleanup: $(cat "$case_dir/stderr2")" + pass "withdrawing a false claim is a way out that does not need --force" +} + +# The other half, so the exit above is narrow rather than a hole: a task that is +# merely stuck is still making its claim and stays held to it. +test_a_blocked_task_is_still_held_to_its_standing_claim() { + local case_dir rc=0 head verb + for verb in blocked needs-decision; do + case_dir=$(make_claim_case "claim-standing-$verb" "placeholder") + head=$(git -C "$case_dir/wt" rev-parse 'HEAD~1') + printf 'done: branch=fm/task-x1 head=%s - shipped\n' "$head" \ + > "$case_dir/state/task-x1.status" + printf '%s: waiting on the captain\n' "$verb" >> "$case_dir/state/task-x1.status" + set +e + run_teardown "$case_dir" > "$case_dir/stdout" 2> "$case_dir/stderr" + rc=$? + set -e + [ "$rc" -ne 0 ] \ + || fail "a $verb line let a task out of a claim it is still making" + [ -e "$case_dir/state/task-x1.meta" ] \ + || fail "the $verb refusal erased the durable task record" + done + pass "a task that is merely stuck stays held to the claim it still makes" +} + test_a_stale_record_does_not_carry_a_task_through_cleanup() { local case_dir rc=0 head case_dir=$(make_claim_case claim-stale "placeholder") @@ -2999,6 +3051,8 @@ test_established_claim_allows_teardown test_force_overrides_a_contradicted_claim test_an_established_claim_survives_losing_the_verifier_sources test_an_unestablished_claim_still_refuses_without_a_record +test_a_withdrawn_claim_no_longer_holds_up_cleanup +test_a_blocked_task_is_still_held_to_its_standing_claim test_a_stale_record_does_not_carry_a_task_through_cleanup test_a_closed_pr_stops_its_own_done_record_carrying_cleanup test_local_only_fork_remote_allows From d40fa797e0466b3e2fae72d4bccbc039e77e8654 Mon Sep 17 00:00:00 2001 From: Israel Wilson Date: Thu, 3 Sep 2026 16:42:53 -0400 Subject: [PATCH 10/21] no-mistakes(review): retire closed polls on record; only tasks retract claims --- AGENTS.md | 4 +- bin/fm-done-claim-lib.sh | 29 ++++++-- bin/fm-merge-outcome-lib.sh | 16 +---- bin/fm-verify-done.sh | 10 ++- bin/fm-wake-drain.sh | 8 +++ bin/fm-watch.sh | 18 ++--- docs/configuration.md | 2 +- tests/fm-done-verified.test.sh | 112 ++++++++++++++++------------- tests/fm-pr-check-security.test.sh | 77 ++++++++++++++++---- 9 files changed, 180 insertions(+), 96 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 5c4242b6952..8e9fa9acd95 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -374,10 +374,12 @@ The worker reports the PR when CI first becomes green rather than waiting for me A terminal `done:` claim names the exact commit, PR, branch, or report it is claiming, and only `bin/fm-verify-done.sh` decides whether that claim is true; `bin/fm-done-claim-lib.sh` owns the grammar and the durable verdict. A claim that has not been established is never done: `bin/fm-crew-state.sh` reports it as `done-unverified`, and teardown refuses it. -Terminal evidence about a claim has three shapes, not two, and the verdict record names all three: `unverified` is absence of evidence and never downgrades what already stands, `contradicted` is positive evidence of falsity and overwrites anything, and `stale` is the world having changed under a verdict that was true when it was made, which `bin/fm-merge-outcome-lib.sh` records when a PR merges or closes under a standing claim. +Terminal evidence about a claim has three shapes, not two, and the verdict record names all three: `unverified` is absence of evidence and never downgrades what already stands, `contradicted` is positive evidence of falsity and overwrites anything, and `stale` is the world having changed under a verdict that was true when it was made, which `bin/fm-merge-outcome-lib.sh` records when a PR merges under a claim already established as verified. +A PR closed without merging is not stale but false, so the same owner records `contradicted` for it. A `stale` verdict is not established either: it reports as `done-unverified` and teardown re-runs `bin/fm-verify-done.sh` rather than passing on it. The way out of a claim that turned out to be false is to withdraw it, not to override the gate: a `failed:` line appended after a `done:` line retracts the claim, so the task has none to verify and cleanup is no longer held to it. Only `failed:` retracts; `blocked:` and `needs-decision:` say the work is ongoing and leave the claim standing, and teardown's landed-work gates apply either way. +Only the task speaking for itself may retract: the `failed:` line must carry no correlation token and no routed `[key=...]`, so a closed routed phase or a reported child outcome cannot withdraw the task's own terminal claim. Run `bin/fm-pr-check.sh ` with the URL copied from the terminal claim's `pr=` field or the task's `pr=` metadata, never assembled from memory - it records `pr=` and the forge's `pr_head=` when available in the task's meta and arms the watcher's poll, which reports a merge and a close without merge alike. That registration also runs `bin/fm-verify-done.sh` for the task and prints its verdict, so a claim is established while the forge is already being consulted rather than only at cleanup; the verdict is advisory there and never changes what the registration records, arms, or exits with. Tell the captain the PR's full URL, always the complete `https://...` link rather than a bare `#number`, copied verbatim from the claim's `pr=` field or the task's `pr=` metadata, a concise outcome summary, and the no-mistakes risk level when applicable. diff --git a/bin/fm-done-claim-lib.sh b/bin/fm-done-claim-lib.sh index e925c3416f7..e773b9a9063 100644 --- a/bin/fm-done-claim-lib.sh +++ b/bin/fm-done-claim-lib.sh @@ -151,11 +151,24 @@ fm_done_claim_has_identity() { # an append-only event log, so the newest `done:` is the one under test; earlier # claims were superseded by whatever the worker did next. # -# A later `failed:` line WITHDRAWS the claim, and the task then has none. That is -# the whole of the supersession rule and it is deliberately narrow: only -# `failed:` retracts. `blocked:` and `needs-decision:` must not, because they say -# the work is ongoing, not that the assertion is withdrawn, and a task sitting on -# a claim it still makes should stay refused while it is merely stuck. +# A later `failed:` line WITHDRAWS the claim, and the task then has none. Two +# things have to be true of that line, and the second is an AUTHORITY rule, not a +# formatting one. +# +# WHAT may retract: only `failed:`. `blocked:` and `needs-decision:` must not, +# because they say the work is ongoing, not that the assertion is withdrawn, and +# a task sitting on a claim it still makes should stay refused while it is merely +# stuck. +# +# WHO may retract: only the task speaking for itself, which means the line must +# carry no correlation token and no routed `[key=...]`. A decorated line is a +# SUB-EVENT - one routed phase, or one child's outcome - and a sub-event has no +# authority over the task's own terminal assertion. This is not hypothetical +# tidiness: bin/fm-brief.sh instructs workers to close routed phases with keyed +# lines, and report_child_ledger_locked publishes `failed [key=child-outcome-...]` +# into a PARENT home's status through bin/fm-parent-channel-lib.sh, so without +# this a child's failure would retract its mate task's own claim. Do not +# "simplify" this back to any-failed-line: the point is who is speaking. # # Why the rule exists at all, so it is not "simplified" back: without it a task # whose PR was abandoned is held forever to an assertion it has already @@ -186,7 +199,11 @@ fm_done_claim_last() { # last=$line ;; failed*) - [ "$(status_line_verb "$line")" = "failed" ] || continue + # Compared BEFORE any decoration is stripped, which is the authority test + # itself: status_line_verb reads through a `[key=...]` and through a + # correlation token, so a sub-event's verb is `failed` too. Only a line + # whose whole prefix is the bare verb is the task speaking for itself. + [ "${trimmed%%:*}" = "failed" ] || continue last= ;; esac diff --git a/bin/fm-merge-outcome-lib.sh b/bin/fm-merge-outcome-lib.sh index 8d633bc2f95..6e7c8643fdd 100755 --- a/bin/fm-merge-outcome-lib.sh +++ b/bin/fm-merge-outcome-lib.sh @@ -56,12 +56,6 @@ _FM_MERGE_OUTCOME_LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # shellcheck disable=SC2034 # Public result consumed by sourcing callers. FM_MERGE_OUTCOME_ALREADY_RECORDED=false -# true only when this call durably recorded a verdict for the task's standing -# claim. A caller that retires the poll on the strength of the outcome having -# been RECORDED reads this rather than the return code, which is also 0 when -# there was no claim to record anything about. -# shellcheck disable=SC2034 # Public result consumed by sourcing callers. -FM_MERGE_OUTCOME_VERDICT_RECORDED=false # fm_merge_outcome_report [] # @@ -86,8 +80,6 @@ fm_merge_outcome_report() { # [ [/sub/../report.md` names a report that really + # is this task's own - so it reads unverified and says only what was observed: + # the claim was refused for the walk it names, not for where it ends up. case "/$FM_DONE_CLAIM_REPORT/" in */../*) - verdict_is contradicted "the claimed report walks out of its own directory: $FM_DONE_CLAIM_REPORT" \ - "$FM_DONE_CLAIM_REPORT" + verdict_is unverified "the claimed report names an upward walk, which is not resolved as evidence: $FM_DONE_CLAIM_REPORT" finish ;; esac diff --git a/bin/fm-wake-drain.sh b/bin/fm-wake-drain.sh index 4def5fa936f..e1ce988132b 100755 --- a/bin/fm-wake-drain.sh +++ b/bin/fm-wake-drain.sh @@ -356,8 +356,16 @@ EOF # drain. The count still says how many were held back, and the # presentation cursor is held at the last line this drain actually showed, so # the rest re-present on the next drain rather than being skipped past. +# +# At least 1, always. The cap and the cursor are coupled: a held-back line freezes +# the presentation cursor at the line before it so the remainder re-presents, so a +# cap of 0 holds the FIRST line of every span and the cursor can never advance +# past it - a `note:` after one line of prose would then re-present on every drain +# forever while the prose itself was never shown. One line per task per drain is +# the smallest budget that still makes progress. UNRECOGNISED_STATUS_MAX=${FM_DRAIN_UNRECOGNISED_MAX:-10} case "$UNRECOGNISED_STATUS_MAX" in ''|*[!0-9]*) UNRECOGNISED_STATUS_MAX=10 ;; esac +[ "$UNRECOGNISED_STATUS_MAX" -ge 1 ] || UNRECOGNISED_STATUS_MAX=1 # Print still-unread informational status lines (note: answers, pending-reply # resolutions, and lines carrying no recognised verb) that the OPEN DECISIONS diff --git a/bin/fm-watch.sh b/bin/fm-watch.sh index fb4e9f10651..2c5614abe1b 100755 --- a/bin/fm-watch.sh +++ b/bin/fm-watch.sh @@ -1669,10 +1669,14 @@ while :; do # merged - the recorded contradiction now carries that instead, and a # reopen that goes on to merge still reaches the merged path through the # poll re-armed at re-registration. - # `merged` retires on the report alone, as it always has. A close retires - # only once the contradiction is durably recorded, because an unrecorded - # close is an outcome nothing has yet corrected: leaving its poll armed is - # what brings it back. + # Both retire on the same condition, which is the emitter returning 0: + # that IS the outcome being recorded, marker and publication together. + # An outcome that could not be recorded exits above without retiring, so + # its poll stays armed and brings it back. Nothing narrower belongs here - + # a close with no standing claim writes no verdict and is still fully + # recorded (the poll is armed at PR registration, long before any claim + # exists), and gating on the verdict would leave that ordinary case + # polling forever and would deny the close the retry the merge arm gets. if [ "$is_pr_poll" -eq 1 ] && { [ "$out" = merged ] || [ "$out" = closed-unmerged ]; }; then merge_outcome_rc=0 fm_merge_outcome_report "$FM_HOME" "$STATE" "$id" "$url" poll "$out" \ @@ -1681,11 +1685,7 @@ while :; do triage_log "PR $out outcome for $id could not be recorded (rc=$merge_outcome_rc)" exit 1 fi - if [ "$out" = merged ]; then - retire_terminal_pr_poll "$id" merged - elif [ "$FM_MERGE_OUTCOME_VERDICT_RECORDED" = true ]; then - retire_terminal_pr_poll "$id" "$out" - fi + retire_terminal_pr_poll "$id" "$out" touch "$STATE/.last-check" if [ "$FM_MERGE_OUTCOME_ALREADY_RECORDED" = true ]; then triage_log "absorbed duplicate $out PR poll result for $id" diff --git a/docs/configuration.md b/docs/configuration.md index 76e937a72bb..a7edd00dd6c 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -842,7 +842,7 @@ FM_CREW_STATE_RUNS_LIMIT=200 # recent no-mistakes run rows scanned when axi sta FM_VERIFY_DONE_GH_TIMEOUT=30 # seconds allowed for the one forge read that establishes a terminal claim in bin/fm-verify-done.sh FM_VERIFY_DONE_NM_TIMEOUT=20 # seconds allowed for the one no-mistakes query that reads the validated commit in bin/fm-verify-done.sh FM_PR_CHECK_VERIFY_TIMEOUT=20 # seconds bin/fm-pr-check.sh allows its advisory claim verification; exceeding it prints nothing and changes nothing it registered or armed -FM_DRAIN_UNRECOGNISED_MAX=10 # unrecognised status lines printed per TASK in UNREAD STATUS, so one drain may print this many for each task with unread prose; the rest are counted and held back to the next drain rather than dropped, and 0 prints the count alone +FM_DRAIN_UNRECOGNISED_MAX=10 # unrecognised status lines printed per TASK in UNREAD STATUS, so one drain may print this many for each task with unread prose; the rest are counted and held back to the next drain rather than dropped; clamped to a minimum of 1, because a cap of 0 would hold every span's first line and stall that task's cursor FM_CREW_STATE_BIN=bin/fm-crew-state.sh # test override for the current-state reader used by working/paused watcher triage FMX_PAIRING_TOKEN= # Relay pairing token; .env opt-in authorizes replies and eligible lifecycle actions FMX_RELAY_URL=https://myfirstmate.io # optional Relay endpoint override, mainly for local relay development diff --git a/tests/fm-done-verified.test.sh b/tests/fm-done-verified.test.sh index 41bb8c33c92..18465c57cf7 100755 --- a/tests/fm-done-verified.test.sh +++ b/tests/fm-done-verified.test.sh @@ -64,8 +64,8 @@ # and a directory symlink both fail, an honest claim still verifies # (am) a later `failed:` line withdraws a claim; `blocked:` and # `needs-decision:` do not, and a later `done:` re-asserts one -# (an) a close reports whether it durably recorded its contradiction, which is -# what decides whether its poll retires or stays armed +# (an) only the task itself withdraws its claim: a routed or child sub-event +# carrying a key or a correlation token has no authority to retract it # (aj) a whitespace-only line inside a partly held-back span retires nothing: # the span index and the byte counter agree about what counts as a line # (ak) a verdict write that fails never suppresses the outcome report, which @@ -1140,11 +1140,24 @@ test_a_scout_claim_on_another_tasks_report_is_not_verified() { result=$(verify "$dir") [ "${result%%$'\t'*}" != 0 ] \ || fail "a scout claim walking out of its own directory was verified: $result" + [ "${result%%$'\t'*}" = 3 ] \ + || fail "an unresolvable spelling was not unverified: $result" case "${result#*$'\t'}" in - *"walks out of its own directory"*) ;; - *) fail "the traversing spelling was not refused as one: $result" ;; + *"names an upward walk"*) ;; + *) fail "the refusal did not say what it actually observed: $result" ;; esac + # The same spelling over a report that really is this task's own: still + # refused, and still never called false, because refusing to resolve a path + # establishes nothing about where it ends up. + dir=$(make_world scout-inward-walk scout scout) + mkdir -p "$dir/data/task-v/sub" + printf 'my own findings\n' > "$dir/data/task-v/report.md" + printf 'done: report=data/task-v/sub/../report.md - shipped\n' > "$dir/state/task-v.status" + result=$(verify "$dir") + [ "${result%%$'\t'*}" = 3 ] \ + || fail "a walk that resolves inside this task's own directory was not unverified: $result" + # A directory symlink is the other way to name a real file somewhere else. It # is absence of evidence rather than falsity - the link is refused as evidence # rather than followed - so it reads unverified, never verified. @@ -1192,6 +1205,30 @@ test_a_later_failed_line_withdraws_the_claim() { pass "a later failed line withdraws a terminal claim" } +# A retraction is an act of authority, not a formatting accident. `status_line_verb` +# reads through a routed `[key=...]` and through a correlation token, so a +# SUB-EVENT's verb is `failed` too - and these are written by code, not only by +# prose: a secondmate home publishes a child's outcome as +# `failed [key=child-outcome--...]` into the PARENT's status. Nothing about +# one routed phase or one child may withdraw the task's own terminal assertion. +test_only_the_task_itself_may_withdraw_its_claim() { + local dir state line + for line in \ + 'failed [key=docs-pass]: that routed phase did not land' \ + 'failed [key=child-outcome-task-c-failed-abc123]: child task-c failed: no work landed' \ + 'failed corr=0123456789abcdef: the answered request did not land'; do + dir="$TMP_ROOT/claim-subevent-$RANDOM" + state="$dir/state" + mkdir -p "$state" + printf 'done: pr=%s head=%s - shipped\n' "$PR_A" "$HEAD_A" > "$state/task-v.status" + printf '%s\n' "$line" >> "$state/task-v.status" + fm_done_claim_status "$state" task-v + [ "$FM_DONE_CLAIM_STATE" != none ] \ + || fail "a sub-event withdrew the task's own terminal claim: $line" + done + pass "only the task speaking for itself withdraws its claim, never a sub-event" +} + # Deliberately narrow: only `failed:` retracts. A task that is merely stuck is # still making its claim, and must stay held to it. test_only_a_failed_line_withdraws_the_claim() { @@ -1336,43 +1373,6 @@ test_a_failed_verdict_write_still_publishes_the_outcome() { pass "a failed verdict write never suppresses the outcome report it follows" } -# A poll exists to observe a terminal outcome, so bin/fm-watch.sh retires a close -# once its contradiction is durably RECORDED - and leaves it armed when it is -# not, because an outcome nothing has corrected is exactly the one that must come -# back. This is the signal the watcher branches on. -test_a_close_reports_whether_it_recorded_its_contradiction() { - local claim dir - claim="done: pr=$PR_A head=$HEAD_A - shipped" - dir=$(make_claim_world close-recorded "$claim" verified "the PR is open at the claimed head") \ - || fail "the established-claim fixture failed" - fm_merge_outcome_report "$dir" "$dir/state" task-v "$PR_A" poll closed-unmerged \ - || fail "the close outcome could not be published" - [ "$FM_MERGE_OUTCOME_VERDICT_RECORDED" = true ] \ - || fail "a recorded contradiction did not report itself as recorded" - - # A blocked verdict path: the close is still published, but nothing has - # corrected the record, so the poll must stay armed. - dir=$(make_claim_world close-unrecorded "$claim" verified "the PR is open at the claimed head") \ - || fail "the established-claim fixture failed" - rm -f "$dir/state/task-v.done-verdict" - ln -s /dev/null "$dir/state/task-v.done-verdict" \ - || fail "the fixture could not block the verdict path" - fm_merge_outcome_report "$dir" "$dir/state" task-v "$PR_A" poll closed-unmerged \ - && fail "a blocked verdict write was reported as a clean outcome" - [ "$FM_MERGE_OUTCOME_VERDICT_RECORDED" = false ] \ - || fail "a contradiction that was never written reported itself as recorded" - - # No claim on record is nothing to contradict, so nothing is recorded and the - # poll stays armed for a reopen the worker has not yet claimed anything about. - dir=$(make_claim_world close-no-claim 'working: still going') \ - || fail "the no-claim fixture failed" - fm_merge_outcome_report "$dir" "$dir/state" task-v "$PR_A" poll closed-unmerged \ - || fail "the no-claim close could not be published" - [ "$FM_MERGE_OUTCOME_VERDICT_RECORDED" = false ] \ - || fail "a close with no claim on record reported a recorded contradiction" - pass "a close reports whether its contradiction was durably recorded" -} - # The precedence itself, driven through the record's owner. Each incoming # verdict is tried against a standing one and the record is read back. test_verdict_write_precedence_keeps_the_stronger_statement() { @@ -1554,17 +1554,33 @@ test_the_omitted_unrecognised_count_carries_its_header() { FM_STATE_OVERRIDE="$state" "$DRAIN" >/dev/null 2>/dev/null \ || fail "bootstrap drain failed while priming the cursor" - printf 'Migration syntax: OK\n' >> "$status" - printf -- '- ruff check on all changed files: all passed\n' >> "$status" - # Opting out of the surface must not leave a bare counter with nothing to - # say what it is counting. + { + printf 'Migration syntax: OK\n' + printf -- '- ruff check on all changed files: all passed\n' + printf 'note: the answer you asked for\n' + } >> "$status" + # A cap of 0 is clamped to 1, because the cap and the cursor are coupled: at 0 + # the first line of every span is held, the cursor freezes in front of it, and + # the span never advances. So one line is shown, the rest are counted under + # their own header, and the drain makes progress. FM_DRAIN_UNRECOGNISED_MAX=0 FM_STATE_OVERRIDE="$state" "$DRAIN" > "$out" \ || fail "drain failed with the unrecognised surface capped at zero" - assert_grep 'UNREAD STATUS: 2 more unrecognised line(s) held for the next drain' "$out" \ + assert_grep 'Migration syntax: OK' "$out" \ + "a cap of zero showed the worker nothing at all: $(cat "$out")" + assert_grep 'UNREAD STATUS: 1 more unrecognised line(s) held for the next drain' "$out" \ "the omitted unrecognised lines were not counted: $(cat "$out")" assert_grep 'UNREAD STATUS (new since last drain' "$out" \ "the omitted count was printed without its section header: $(cat "$out")" - pass "the omitted-unrecognised count is never printed as a bare orphan line" + + # The cursor moved, so the next drain shows what was held rather than + # re-presenting the same span forever. + FM_DRAIN_UNRECOGNISED_MAX=0 FM_STATE_OVERRIDE="$state" "$DRAIN" > "$dir/second.out" \ + || fail "the second capped drain failed" + assert_grep 'ruff check on all changed files' "$dir/second.out" \ + "a cap of zero wedged the cursor and never showed the held line: $(cat "$dir/second.out")" + assert_no_grep 'Migration syntax: OK' "$dir/second.out" \ + "a presented line was replayed after the clamped drain" + pass "a cap of zero is clamped so the drain shows something and keeps moving" } # The cap bounds one drain; it must not cost the worker's words. What the cap @@ -1651,11 +1667,11 @@ test_a_direct_pr_claim_on_this_task_branch_verifies test_a_scout_claim_on_another_tasks_report_is_not_verified test_a_later_failed_line_withdraws_the_claim test_only_a_failed_line_withdraws_the_claim +test_only_the_task_itself_may_withdraw_its_claim test_a_merge_marks_an_established_claim_stale test_a_close_contradicts_an_established_claim_in_the_record test_a_terminal_outcome_invents_no_verdict_without_a_claim test_a_failed_verdict_write_still_publishes_the_outcome -test_a_close_reports_whether_it_recorded_its_contradiction test_verdict_write_precedence_keeps_the_stronger_statement test_a_held_back_span_with_multibyte_prose_presents_no_fragment test_the_unrecognised_cap_is_per_task_not_per_drain diff --git a/tests/fm-pr-check-security.test.sh b/tests/fm-pr-check-security.test.sh index c63d26b48dc..c069c7dedb6 100755 --- a/tests/fm-pr-check-security.test.sh +++ b/tests/fm-pr-check-security.test.sh @@ -1562,6 +1562,56 @@ test_merged_poll_retires_once() { pass "validated merged polls notify once and retire before the next watcher cycle" } +# A poll exists to observe a terminal outcome, so a close without merge retires +# on exactly the same condition a merge does: the outcome having been recorded. +# The case that matters is a close with NO standing claim, which is ordinary - +# the poll is armed at PR registration, long before any worker claims anything - +# and which writes no verdict at all. Gating retirement on a verdict left that +# poll asking the forge once per watcher interval forever. +test_closed_unmerged_poll_retires_without_a_claim_to_contradict() { + local dir state rc first second + dir=$(make_case closed-unmerged-retirement) + state="$dir/home/state" + write_poll_meta "$state" task-a https://github.com/o/r/pull/1 + seed_canonical_poll "$dir" task-a https://github.com/o/r/pull/1 + add_stop_custom_check "$dir" + # The worker is still working: it has asserted nothing for the close to + # contradict, so no verdict is written for this outcome. + printf 'working: still going\n' > "$state/task-a.status" + + set +e + FM_TEST_GH_STATE=CLOSED run_watcher_bounded "$dir/home" "$dir/fakebin" \ + > "$dir/watch-1.out" 2> "$dir/watch-1.err" + rc=$? + set -e + [ "$rc" -eq 0 ] || fail "closed-unmerged retirement watcher failed: $(cat "$dir/watch-1.err")" + first=$(cat "$dir/watch-1.out") + case "$first" in + check:*task-a.check.sh:*closed-unmerged) ;; + *) fail "the close notification was not preserved: $first" ;; + esac + [ ! -e "$state/task-a.done-verdict" ] \ + || fail "a close with no claim on record invented a verdict" + ack_watcher_cycle "$state" || fail "close notification handling acknowledgement failed" + assert_poll_absent "$state" task-a + + rm -f "$state/.last-check" + set +e + FM_TEST_GH_STATE=CLOSED run_watcher_bounded "$dir/home" "$dir/fakebin" \ + > "$dir/watch-2.out" 2> "$dir/watch-2.err" + rc=$? + set -e + [ "$rc" -eq 0 ] || fail "second watcher cycle failed: $(cat "$dir/watch-2.err")" + second=$(cat "$dir/watch-2.out") + case "$second" in + check:*z-stop.check.sh:*stop-cycle) ;; + *) fail "second cycle did not reach the control check: $second" ;; + esac + ! grep -F 'task-a.check.sh: closed-unmerged' "$dir/watch-2.out" >/dev/null \ + || fail "a retired closed-unmerged poll asked the forge again" + pass "a closed-unmerged poll retires even when it had no claim to contradict" +} + # A poll's own retirement state is scoped to ONE registration, so it cannot by # itself catch a poll re-registered for a task whose merge was already # surfaced (e.g. bin/fm-pr-check.sh re-armed after the fact). The per-task @@ -2034,9 +2084,11 @@ test_external_merge_transition_retires_only_terminal_poll() { done # A pull request CLOSED without merging is not silence: it contradicts a task - # record that already claims done, so it wakes. It is also not terminal for - # the poll, because the pull request can still be reopened and merged, so the - # armed poll is left exactly as it was and the contradiction is reported once. + # record that already claims done, so it wakes. It is terminal for the poll + # too - the outcome is on the record, and a poll has nothing left to observe + # once that is true - so the poll retires exactly as a merge retires it. A + # reopen that goes on to merge is reported through a poll re-armed at + # re-registration, which the last leg of this case exercises. rm -f "$state/.last-check" set +e FM_TEST_GH_STATE=CLOSED run_watcher_bounded "$dir/home" "$dir/fakebin" \ @@ -2048,9 +2100,8 @@ test_external_merge_transition_retires_only_terminal_poll() { check:*task-a.check.sh:*closed-unmerged) ;; *) fail "a pull request closed without merging did not wake: $(cat "$dir/closed-unmerged.out")" ;; esac - [ "$(poll_artifact_snapshot "$state" task-a)" = "$before" ] \ - || fail "a close without merge retired or altered the armed poll" ack_watcher_cycle "$state" || fail "closed-unmerged wake acknowledgement failed" + assert_poll_absent "$state" task-a rm -f "$state/.last-check" set +e @@ -2065,15 +2116,12 @@ test_external_merge_transition_retires_only_terminal_poll() { esac ack_watcher_cycle "$state" || fail "second closed-unmerged control wake acknowledgement failed" - rm -f "$state/z-stop.check.sh" "$state/z-stop.check-trust" "$state/.last-check" - set +e - FM_TEST_GH_STATE=MERGED run_watcher_bounded "$dir/home" "$dir/fakebin" > "$dir/merged.out" 2> "$dir/merged.err" - rc=$? - set -e - [ "$rc" -eq 0 ] || fail "external merged transition failed: $(cat "$dir/merged.err")" - case "$(cat "$dir/merged.out")" in check:*task-a.check.sh:*merged) ;; *) fail "external merge did not preserve its notification" ;; esac - assert_poll_absent "$state" task-a - pass "open/red, malformed, and forge errors stay silent, a close wakes once and stays armed, and only a merge retires the poll" + # A later merge of the same PR is reported through a poll re-armed at + # re-registration, and the notification marker records WHICH outcome was + # delivered so the close cannot suppress it. That handover is pinned on the + # marker's own surface by test_marker_distinguishes_the_two_terminal_outcomes + # in tests/fm-done-verified.test.sh, which is where the marker contract lives. + pass "open/red, malformed, and forge errors stay silent, and a close wakes once and retires its poll" } test_retirement_refuses_replacement_and_nonterminal_results() { @@ -2262,6 +2310,7 @@ test_parser_matrix test_registration_arms_claim_verification_without_blocking test_gitlab_merge_watch test_merged_poll_retires_once +test_closed_unmerged_poll_retires_without_a_claim_to_contradict test_merged_poll_reregistration_after_notification_is_absorbed test_merged_poll_retries_a_failed_upward_report test_self_merge_and_poll_publish_one_outcome From 66cda4fce135fac4935c2cb41c9c281d8a9c902c Mon Sep 17 00:00:00 2001 From: Israel Wilson Date: Thu, 3 Sep 2026 17:10:39 -0400 Subject: [PATCH 11/21] no-mistakes(review): bind claim authority to the task's own voice both ways --- AGENTS.md | 5 +-- bin/fm-done-claim-lib.sh | 65 ++++++++++++++++++++-------------- bin/fm-merge-outcome-lib.sh | 37 +++++++++++++++---- bin/fm-pr-lib.sh | 11 +++--- docs/architecture.md | 3 +- tests/fm-done-verified.test.sh | 58 ++++++++++++++++++++++++++++++ 6 files changed, 138 insertions(+), 41 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 8e9fa9acd95..0e7f68ec136 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -379,10 +379,11 @@ A PR closed without merging is not stale but false, so the same owner records `c A `stale` verdict is not established either: it reports as `done-unverified` and teardown re-runs `bin/fm-verify-done.sh` rather than passing on it. The way out of a claim that turned out to be false is to withdraw it, not to override the gate: a `failed:` line appended after a `done:` line retracts the claim, so the task has none to verify and cleanup is no longer held to it. Only `failed:` retracts; `blocked:` and `needs-decision:` say the work is ongoing and leave the claim standing, and teardown's landed-work gates apply either way. -Only the task speaking for itself may retract: the `failed:` line must carry no correlation token and no routed `[key=...]`, so a closed routed phase or a reported child outcome cannot withdraw the task's own terminal claim. +Only the task speaking for itself may change what claim stands, in either direction: a `done:` or `failed:` line carrying a correlation token or a routed `[key=...]` is a sub-event, so a closed routed phase or a reported child outcome can neither assert nor withdraw the task's own terminal claim. Run `bin/fm-pr-check.sh ` with the URL copied from the terminal claim's `pr=` field or the task's `pr=` metadata, never assembled from memory - it records `pr=` and the forge's `pr_head=` when available in the task's meta and arms the watcher's poll, which reports a merge and a close without merge alike. That registration also runs `bin/fm-verify-done.sh` for the task and prints its verdict, so a claim is established while the forge is already being consulted rather than only at cleanup; the verdict is advisory there and never changes what the registration records, arms, or exits with. -Tell the captain the PR's full URL, always the complete `https://...` link rather than a bare `#number`, copied verbatim from the claim's `pr=` field or the task's `pr=` metadata, a concise outcome summary, and the no-mistakes risk level when applicable. +Tell the captain the PR's full `https://...` URL, never a bare `#number`, whenever the claim's `pr=` field or the task's `pr=` metadata holds one, copied verbatim from that record and never assembled from memory; when neither holds one yet, report only the identifier you actually have. +Tell them a concise outcome summary too, and the no-mistakes risk level when applicable. A captain instruction to merge is explicit authority; `yolo` is the only standing routine merge authority. For any custom `state/.check.sh` you write yourself, keep it an ordinary single-link mode-`0700` file, print one line only when firstmate should wake, print nothing otherwise, finish before `FM_CHECK_TIMEOUT`, then bind its current bytes with `bin/fm-check-register.sh ` before the watcher may execute it. Retire a custom check only through `bin/fm-check-unregister.sh ` (or `bin/fm-teardown.sh` for a spawned task); never hand-compose an `rm` with `$STATE`/`$ID`. diff --git a/bin/fm-done-claim-lib.sh b/bin/fm-done-claim-lib.sh index e773b9a9063..4c66af54658 100644 --- a/bin/fm-done-claim-lib.sh +++ b/bin/fm-done-claim-lib.sh @@ -147,28 +147,47 @@ fm_done_claim_has_identity() { [ -n "$FM_DONE_CLAIM_REPORT" ] } +# AUTHORITY over a task's terminal claim is a SINGLE SPEAKER TEST, and it governs +# BOTH directions equally: asserting a claim and retracting one are the same +# question, because both CHANGE what claim stands. Only the task speaking for +# itself may do either. +# +# A line carrying a correlation token or a routed `[key=...]` is a SUB-EVENT - +# one routed phase, or one child's outcome - speaking about part of the work, not +# the task speaking about itself. It may neither assert nor retract. +# +# This is an authority rule, not a formatting rule about verbs, so do not +# "simplify" it into one. `status_line_verb` deliberately reads THROUGH a +# `[key=...]` and through a correlation token, which is right for classification +# and wrong for authority, so the test is made on the undecorated prefix instead. +# It is written by code, not only by prose: bin/fm-brief.sh instructs workers to +# close routed phases with `done [key=]`, and +# report_child_ledger_locked publishes both `done [key=child-outcome-...]` and +# `failed [key=child-outcome-...]` into a PARENT home's status through +# bin/fm-parent-channel-lib.sh. Without the rule a child's outcome would become, +# or withdraw, its mate task's own terminal claim - and on a scout-kind task the +# child shape carries another task's `report=`, which the verifier would then +# record as `contradicted`. One predicate, called from both arms below, so a +# future author cannot close one direction and leave the other open. +fm_done_claim_own_voice() { # + local head=${1%%:*} + head=${head%"${head##*[![:space:]]}"} + [ "$head" = "${2:-}" ] +} + # The task's STANDING terminal claim, or empty when it has none. A status log is -# an append-only event log, so the newest `done:` is the one under test; earlier -# claims were superseded by whatever the worker did next. +# an append-only event log, so the newest `done:` the task itself spoke is the one +# under test; earlier claims were superseded by whatever the worker did next. # -# A later `failed:` line WITHDRAWS the claim, and the task then has none. Two -# things have to be true of that line, and the second is an AUTHORITY rule, not a -# formatting one. +# A later `failed:` line WITHDRAWS the claim, and the task then has none. # # WHAT may retract: only `failed:`. `blocked:` and `needs-decision:` must not, # because they say the work is ongoing, not that the assertion is withdrawn, and # a task sitting on a claim it still makes should stay refused while it is merely # stuck. # -# WHO may retract: only the task speaking for itself, which means the line must -# carry no correlation token and no routed `[key=...]`. A decorated line is a -# SUB-EVENT - one routed phase, or one child's outcome - and a sub-event has no -# authority over the task's own terminal assertion. This is not hypothetical -# tidiness: bin/fm-brief.sh instructs workers to close routed phases with keyed -# lines, and report_child_ledger_locked publishes `failed [key=child-outcome-...]` -# into a PARENT home's status through bin/fm-parent-channel-lib.sh, so without -# this a child's failure would retract its mate task's own claim. Do not -# "simplify" this back to any-failed-line: the point is who is speaking. +# WHO may assert or retract: fm_done_claim_own_voice above owns that, in both +# directions. # # Why the rule exists at all, so it is not "simplified" back: without it a task # whose PR was abandoned is held forever to an assertion it has already @@ -181,13 +200,11 @@ fm_done_claim_has_identity() { # withdrawing it and people withdraw it; make it force and they learn to reach # for force. # -# This runs per task on every current-state read and every session-start digest, -# so the leading-verb test is only paid for by lines that could possibly carry -# one of the two verbs. `status_line_verb` costs a fork per call, and a line +# This runs per task on every current-state read and every session-start digest. +# The speaker test is fork-free and exact rather than an approximation: a line # whose verb is `done` or `failed` always begins with that word after its leading -# whitespace - a correlation token or a `[key=...]` only ever follows the verb -# word - so the fork-free prefix test below is exact, not an approximation, and -# skips it for every other line. +# whitespace, and a correlation token or a `[key=...]` only ever follows the verb +# word, so an undecorated prefix is the task's own voice and nothing else is. fm_done_claim_last() { # local f=${1:-} line trimmed last= [ -f "$f" ] && [ -r "$f" ] && [ ! -L "$f" ] || return 0 @@ -195,15 +212,11 @@ fm_done_claim_last() { # trimmed=${line#"${line%%[![:space:]]*}"} case "$trimmed" in done*) - [ "$(status_line_verb "$line")" = "done" ] || continue + fm_done_claim_own_voice "$trimmed" 'done' || continue last=$line ;; failed*) - # Compared BEFORE any decoration is stripped, which is the authority test - # itself: status_line_verb reads through a `[key=...]` and through a - # correlation token, so a sub-event's verb is `failed` too. Only a line - # whose whole prefix is the bare verb is the task speaking for itself. - [ "${trimmed%%:*}" = "failed" ] || continue + fm_done_claim_own_voice "$trimmed" 'failed' || continue last= ;; esac diff --git a/bin/fm-merge-outcome-lib.sh b/bin/fm-merge-outcome-lib.sh index 6e7c8643fdd..b9108f4d32f 100755 --- a/bin/fm-merge-outcome-lib.sh +++ b/bin/fm-merge-outcome-lib.sh @@ -76,7 +76,7 @@ fm_merge_outcome_report() { # [ [/dev/null 2>&1 \ - && printf '%s\t%s' "$FM_DONE_CLAIM_STATE" \ - "$(fm_done_claim_hash "$FM_DONE_CLAIM_LINE" 2>/dev/null || true)") + && printf '%s\t%s\t%s' "$FM_DONE_CLAIM_STATE" \ + "$(fm_done_claim_hash "$FM_DONE_CLAIM_LINE" 2>/dev/null || true)" \ + "$(fm_pr_url_parse "$FM_DONE_CLAIM_PR" >/dev/null 2>&1 \ + && printf '%s %s %s %s' \ + "$FM_PR_PROVIDER" "$FM_PR_HOST" "$FM_PR_PATH" "$FM_PR_NUMBER")") claim_state=${claim_probe%%$'\t'*} - claim_hash=${claim_probe#*$'\t'} - [ "$claim_hash" != "$claim_probe" ] || claim_hash= + claim_rest=${claim_probe#*$'\t'} + [ "$claim_rest" != "$claim_probe" ] || claim_rest= + claim_hash=${claim_rest%%$'\t'*} + claim_pr=${claim_rest#*$'\t'} + [ "$claim_pr" != "$claim_rest" ] || claim_pr= # An unreadable claim state is treated as no claim: the weaker wording is # true either way, while the stronger one would not be. case "$claim_state" in ''|none) ;; *) claimed=1 ;; esac @@ -130,10 +141,22 @@ fm_merge_outcome_report() { # [ case "${1:-}" in merged|closed-unmerged) return 0 ;; esac return 1 diff --git a/docs/architecture.md b/docs/architecture.md index 309c4deffd4..1220219f0ef 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -26,7 +26,8 @@ Once that row reaches `FM_SECONDMATE_WAKE_STALL_SECS`, the primary appends one k Endpointless registered mates remain outside this scan because startup secondmate-liveness owns dead or missing endpoint recovery, and remote homes retain their host-local supervision boundary. `tests/fm-wake-queue.test.sh` pins the notification, idempotence, quiet-queue, and byte-for-byte foreign-row preservation guarantees. When a canonical validated PR poll returns a terminal outcome, the watcher routes it through the shared merge-outcome emitter before retiring the poll. -`merged` retires on successful publication; `closed-unmerged` retires only once the emitter has durably recorded its contradiction against the task's standing claim, so a close nothing has corrected keeps its poll armed. +Both outcomes retire on the same condition, the emitter recording the outcome, because a poll exists to observe a terminal outcome and has nothing left to do once that outcome is on record. +A close with no standing claim, or with a claim about a different PR, writes no verdict and is still fully recorded, while an outcome the emitter could not record keeps its poll armed and brings it back. [`bin/fm-merge-outcome-lib.sh`](../bin/fm-merge-outcome-lib.sh)'s header owns role routing, PR-specific wake identity, marker-locked normal deduplication, and the at-least-once ordering that prefers a rare duplicate over silence. After successful outcome publication, the watcher immediately delivers the emitter's local actionable poll row and publishes a private retirement receipt bound to the poll's registration, bytes, file identities, metadata, provider, URL, and task ID. The retirement receipt makes poll cleanup safely retryable across restarts: fixed-path recovery revalidates the same evidence, removes the runnable check first, removes its registration and data sidecars, removes the receipt last, and preserves task metadata including `pr=` and `pr_head=`. diff --git a/tests/fm-done-verified.test.sh b/tests/fm-done-verified.test.sh index 18465c57cf7..21ec588b5e1 100755 --- a/tests/fm-done-verified.test.sh +++ b/tests/fm-done-verified.test.sh @@ -1229,6 +1229,44 @@ test_only_the_task_itself_may_withdraw_its_claim() { pass "only the task speaking for itself withdraws its claim, never a sub-event" } +# The same authority rule in the ASSERTING direction, which is the half a rule +# written only about retraction leaves open. A sub-event may no more speak a +# task's terminal claim than withdraw one, and both shapes below are written by +# code: bin/fm-brief.sh instructs workers to close routed phases with +# `done [key=]`, and report_child_ledger_locked publishes a child's +# verified outcome as `done [key=child-outcome--...]` into the PARENT's +# status - carrying another task's `report=`, which the verifier would then +# record as contradicted against a parent that never claimed it. +test_only_the_task_itself_may_assert_its_claim() { + local dir state line + for line in \ + 'done [key=docs-pass]: that routed phase landed' \ + 'done [key=child-outcome-task-c-done-abc123]: child task-c done: closed out report=data/task-c/report.md' \ + 'done corr=0123456789abcdef: the answered request landed'; do + dir="$TMP_ROOT/claim-assert-subevent-$RANDOM" + state="$dir/state" + mkdir -p "$state" + printf 'done: pr=%s head=%s - shipped\n' "$PR_A" "$HEAD_A" > "$state/task-v.status" + printf '%s\n' "$line" >> "$state/task-v.status" + fm_done_claim_status "$state" task-v + case "$FM_DONE_CLAIM_LINE" in + *shipped*) ;; + *) fail "a sub-event became the task's own terminal claim: $FM_DONE_CLAIM_LINE" ;; + esac + done + + # A sub-event may not assert a claim where the task has made none either. + dir="$TMP_ROOT/claim-assert-subevent-only" + state="$dir/state" + mkdir -p "$state" + printf 'done [key=child-outcome-task-c-done-abc123]: child task-c done: closed out report=data/task-c/report.md\n' \ + > "$state/task-v.status" + fm_done_claim_status "$state" task-v + [ "$FM_DONE_CLAIM_STATE" = none ] \ + || fail "a sub-event alone gave a task the claim state $FM_DONE_CLAIM_STATE" + pass "only the task speaking for itself asserts its claim, never a sub-event" +} + # Deliberately narrow: only `failed:` retracts. A task that is merely stuck is # still making its claim, and must stay held to it. test_only_a_failed_line_withdraws_the_claim() { @@ -1331,6 +1369,24 @@ test_a_close_contradicts_an_established_claim_in_the_record() { pass "a close records a contradiction over a claim established while the PR was open" } +# `contradicted` is positive evidence of falsity and is unoverwritable short of a +# fresh `verified`, so it may only be recorded against a claim about the very PR +# whose close was observed. A task reused for a second PR still stands on its +# established claim about the first, and the forge falsified nothing about that. +test_a_close_contradicts_only_a_claim_about_the_pr_that_closed() { + local claim dir other + claim="done: pr=$PR_A head=$HEAD_A - shipped" + other=https://github.com/o/r/pull/8 + dir=$(make_claim_world close-other-pr "$claim" verified "the PR is open at the claimed head") \ + || fail "the established-claim fixture failed" + fm_merge_outcome_report "$dir" "$dir/state" task-v "$other" poll closed-unmerged \ + || fail "the close outcome could not be published" + fm_done_claim_status "$dir/state" task-v + [ "$FM_DONE_CLAIM_STATE" = verified ] \ + || fail "a close of another PR left the established claim reported as $FM_DONE_CLAIM_STATE" + pass "a close contradicts only a claim about the PR that actually closed" +} + # Neither arm may invent a verdict for a task that has asserted nothing. The # poll is armed at PR registration, long before any claim. test_a_terminal_outcome_invents_no_verdict_without_a_claim() { @@ -1668,8 +1724,10 @@ test_a_scout_claim_on_another_tasks_report_is_not_verified test_a_later_failed_line_withdraws_the_claim test_only_a_failed_line_withdraws_the_claim test_only_the_task_itself_may_withdraw_its_claim +test_only_the_task_itself_may_assert_its_claim test_a_merge_marks_an_established_claim_stale test_a_close_contradicts_an_established_claim_in_the_record +test_a_close_contradicts_only_a_claim_about_the_pr_that_closed test_a_terminal_outcome_invents_no_verdict_without_a_claim test_a_failed_verdict_write_still_publishes_the_outcome test_verdict_write_precedence_keeps_the_stronger_statement From bd2114bfe8f702d4800b6bc0c3378ad615245c19 Mon Sep 17 00:00:00 2001 From: Israel Wilson Date: Thu, 3 Sep 2026 17:31:09 -0400 Subject: [PATCH 12/21] no-mistakes(review): tie close narration and child reports to the verdict --- bin/fm-inactive-reconcile.sh | 44 ++++++++++++++---------- bin/fm-merge-outcome-lib.sh | 45 ++++++++++++++++--------- tests/fm-done-verified.test.sh | 52 +++++++++++++++++++++++++++++ tests/fm-inactive-reconcile.test.sh | 32 ++++++++++++++++++ 4 files changed, 140 insertions(+), 33 deletions(-) diff --git a/bin/fm-inactive-reconcile.sh b/bin/fm-inactive-reconcile.sh index 9374c2e3d11..e3219674032 100755 --- a/bin/fm-inactive-reconcile.sh +++ b/bin/fm-inactive-reconcile.sh @@ -410,7 +410,33 @@ report_child_ledger_locked() { # state=$(status_line_verb "$last") pr=$(pr_for_task "$meta" "$status" "$last") incarnation=$(meta_incarnation "$meta") - fingerprint=$(sha256_text "$incarnation|$id|$state|ledger|$last") + # The same rule report_to_parent applies, at the other delivery path into the + # parent channel: a `done` the child only ASSERTED is not a completion the + # parent may record as one. It is published as a blocker naming what the claim + # actually is, so the parent's durable record cannot say done while the child's + # own home says done-unverified. A `failed` line, and a `done` whose verdict IS + # verified, are unchanged. + # + # The verdict is therefore part of this event's IDENTITY, not only of its + # wording, and that is what the fingerprint below carries. A report about an + # unestablished claim is a report about an UNSETTLED state, and retiring it as + # though it were settled is how a pessimistic record rots: a child reported + # upward as `claim=unverified` while its PR was still unregistered would stay + # blocked on the parent's record forever once bin/fm-pr-check.sh established + # the claim, because the ledger line it was computed from never changed. With + # the verdict in the identity, a later establishment is a genuinely NEW event + # that publishes a corrected line, while a report whose verdict has not moved + # is still the same event and stays deduplicated exactly as before. + verb=$state + claim= + if [ "$state" = "done" ]; then + fm_done_claim_status "$STATE" "$id" + case "$FM_DONE_CLAIM_STATE" in + verified|none) ;; + *) verb=blocked; claim=$FM_DONE_CLAIM_STATE ;; + esac + fi + fingerprint=$(sha256_text "$incarnation|$id|$state|$claim|ledger|$last") previous=$(grep -v '^[[:space:]]*$' "$status" 2>/dev/null \ | tail -2 | awk 'NR == 1 { first = $0 } NR == 2 { print first }' || true) predecessor_head=$(sha256_text "$previous") @@ -429,22 +455,6 @@ report_child_ledger_locked() { # mode=$(clean_field "$(meta_field "$meta" mode)") yolo=$(clean_field "$(meta_field "$meta" yolo)") data="${FM_DATA_OVERRIDE:-$FM_HOME/data}" - # The same rule report_to_parent applies, at the other delivery path into the - # parent channel: a `done` the child only ASSERTED is not a completion the - # parent may record as one. It is published as a blocker naming what the claim - # actually is, so the parent's durable record cannot say done while the child's - # own home says done-unverified. A `failed` line, and a `done` whose verdict IS - # verified, are unchanged. The record and its key still carry the ledger's own - # state, so dedup is untouched; only the leading verb and one extra field move. - verb=$state - claim= - if [ "$state" = "done" ]; then - fm_done_claim_status "$STATE" "$id" - case "$FM_DONE_CLAIM_STATE" in - verified|none) ;; - *) verb=blocked; claim=$FM_DONE_CLAIM_STATE ;; - esac - fi line="$verb [key=$outcome_key]: child $id $state: $note" [ -z "$pr" ] || line="$line pr=$pr" [ -z "$mode" ] || line="$line mode=$mode" diff --git a/bin/fm-merge-outcome-lib.sh b/bin/fm-merge-outcome-lib.sh index b9108f4d32f..48fbf1acabe 100755 --- a/bin/fm-merge-outcome-lib.sh +++ b/bin/fm-merge-outcome-lib.sh @@ -5,12 +5,14 @@ # `done:` record is still true: # merged the PR landed. # closed-unmerged the PR was closed WITHOUT merging. When the task holds a -# terminal `done:` record, that record has silently stopped -# being true, and reporting the contradiction is the failure -# this carries. The poll is armed at PR registration, well -# before any terminal claim, so this outcome is also reached -# with no claim on record; the publication then reports the -# close plainly rather than inventing a claim to contradict. +# terminal `done:` record ABOUT THAT PR, that record has +# silently stopped being true, and reporting the contradiction +# is the failure this carries. The poll is armed at PR +# registration, well before any terminal claim, and a reused +# task may stand on a claim about an earlier PR, so this +# outcome is also reached with no claim to contradict; the +# publication then reports the close plainly rather than +# inventing a claim to contradict. # # Publishing is only half of it: this is also where the task's DURABLE verdict # record is corrected, because this is the one site that observes the outcome @@ -74,7 +76,7 @@ FM_MERGE_OUTCOME_ALREADY_RECORDED=false # than treat it as success: the merge landed and the record did not. fm_merge_outcome_report() { # [] local home=$1 state=$2 id=$3 url=$4 origin=$5 outcome=${6:-merged} - local self_rc=0 destination='' line lock status=0 wake_note claimed=0 + local self_rc=0 destination='' line lock status=0 wake_note claimed=0 claims_this_pr=0 local provider host path number claim_state='' claim_probe='' claim_hash='' local claim_rest='' claim_pr='' claim_verdict='' claim_reason='' # shellcheck disable=SC2034 # Sourced wake helpers consume these scoped globals. @@ -142,18 +144,29 @@ fm_merge_outcome_report() { # [ [ [/dev/null || true) + case "$queue" in + *"claims done but"*|*"contradicting the done record"*) + fail "a close of another PR was narrated as contradicting the task's claim: $queue" ;; + esac + case "$queue" in + *"PR closed without merging"*) ;; + *) fail "a close of another PR was not narrated at all: $queue" ;; + esac + + dir=$(make_claim_world close-narration-no-pr 'done: report=data/task-v/report.md - scouted') \ + || fail "the no-PR-claim fixture failed" + fm_merge_outcome_report "$dir" "$dir/state" task-v "$PR_A" poll closed-unmerged \ + || fail "the close outcome could not be published under a claim naming no PR" + assert_absent "$dir/state/task-v.done-verdict" \ + "a close contradicted a claim that names no PR at all" + queue=$(cat "$dir/state/.wake-queue" 2>/dev/null || true) + case "$queue" in + *"contradicting the done record"*) + fail "a claim naming no PR was narrated as contradicted: $queue" ;; + esac + + dir=$(make_claim_world close-narration-same "done: pr=$PR_A head=$HEAD_A - shipped" \ + verified "the PR is open at the claimed head") \ + || fail "the established-claim fixture failed" + fm_merge_outcome_report "$dir" "$dir/state" task-v "$PR_A" poll closed-unmerged \ + || fail "the close outcome could not be published" + fm_done_claim_status "$dir/state" task-v + [ "$FM_DONE_CLAIM_STATE" = contradicted ] \ + || fail "a close of the claimed PR left the claim reported as $FM_DONE_CLAIM_STATE" + queue=$(cat "$dir/state/.wake-queue" 2>/dev/null || true) + case "$queue" in + *"contradicting the done record"*) ;; + *) fail "a close of the claimed PR was not narrated as a contradiction: $queue" ;; + esac + pass "a close narrates a contradiction only where it records one" +} + # Neither arm may invent a verdict for a task that has asserted nothing. The # poll is armed at PR registration, long before any claim. test_a_terminal_outcome_invents_no_verdict_without_a_claim() { @@ -1728,6 +1779,7 @@ test_only_the_task_itself_may_assert_its_claim test_a_merge_marks_an_established_claim_stale test_a_close_contradicts_an_established_claim_in_the_record test_a_close_contradicts_only_a_claim_about_the_pr_that_closed +test_a_close_narrates_only_what_it_contradicts test_a_terminal_outcome_invents_no_verdict_without_a_claim test_a_failed_verdict_write_still_publishes_the_outcome test_verdict_write_precedence_keeps_the_stronger_statement diff --git a/tests/fm-inactive-reconcile.test.sh b/tests/fm-inactive-reconcile.test.sh index 7b8a49210a4..8192a32d85c 100755 --- a/tests/fm-inactive-reconcile.test.sh +++ b/tests/fm-inactive-reconcile.test.sh @@ -227,6 +227,37 @@ test_ledger_delivery_downgrades_an_unestablished_claim() { pass "ledger delivery publishes an unestablished done claim as a blocker and an established one as done" } +# A report about an UNSETTLED state must never be retired as though it were +# settled. A claim published upward as unverified is corrected when the verifier +# later establishes it, because the verdict is part of the event's identity and +# not only of its wording - while a verdict that has not moved is still the same +# event and stays deduplicated. +test_a_later_established_claim_corrects_the_parents_record() { + local claim hash + claim='done: pr=https://example.test/owner/repo/pull/1 head=00112233445566778899aabbccddeeff00112233 - shipped' + make_world ledger-corrects; bind_secondmate local + write_child "$MATE" child "$claim" + + FM_FAKE_CREW_STATE='unknown' run_reconcile "$MATE" + grep -q '^blocked \[key=child-outcome-child-done-' "$MAIN/state/mate.status" \ + || fail "an unestablished claim did not reach the parent as a blocker: $(cat "$MAIN/state/mate.status" 2>/dev/null)" + FM_FAKE_CREW_STATE='unknown' run_reconcile "$MATE" + [ "$(grep -c 'child-outcome-child-done-' "$MAIN/state/mate.status")" = 1 ] \ + || fail "an unchanged verdict was reported twice" + + hash=$(fm_done_claim_hash "$claim") || fail "could not compute the claim identity" + printf 'fm-done-verdict-v1\nverified\n%s\n%s\nPR at the claimed head\n' \ + "$hash" "$(date +%s)" > "$MATE/state/child.done-verdict" + FM_FAKE_CREW_STATE='unknown' run_reconcile "$MATE" + grep -q '^done \[key=child-outcome-child-done-' "$MAIN/state/mate.status" \ + || fail "an established claim never corrected the parent's blocked record: $(cat "$MAIN/state/mate.status" 2>/dev/null)" + + FM_FAKE_CREW_STATE='unknown' run_reconcile "$MATE" + [ "$(grep -c '^done \[key=child-outcome-child-done-' "$MAIN/state/mate.status")" = 1 ] \ + || fail "the corrected line was republished on a later poll" + pass "a claim established after it was reported unverified corrects the parent's record once" +} + # A busy child cannot keep later ledger outcomes from being visited, and is # retried on the next poll after its lifecycle lock becomes available. test_busy_child_does_not_starve_later_ledger_outcomes() { @@ -826,6 +857,7 @@ test_reconciliation_never_calls_forge() { test_main_direct_terminal_presentation_receipt test_local_secondmate_delivers_terminal_ledger_line test_ledger_delivery_downgrades_an_unestablished_claim +test_a_later_established_claim_corrects_the_parents_record test_busy_child_does_not_starve_later_ledger_outcomes test_secondmate_ledger_delivery_carries_report_and_failure test_terminal_line_during_state_read_yields_to_ledger_delivery From 438ed51db4960e483a85cb69cfcd03ceb955898c Mon Sep 17 00:00:00 2001 From: Israel Wilson Date: Thu, 3 Sep 2026 18:15:29 -0400 Subject: [PATCH 13/21] no-mistakes(review): give every terminal-outcome reader one own-voice predicate --- bin/fm-captain-hold.sh | 17 +++-- bin/fm-crew-state.sh | 17 ++++- bin/fm-done-claim-lib.sh | 34 ++++++++++ bin/fm-inactive-reconcile.sh | 16 ++--- bin/fm-wake-drain.sh | 9 ++- tests/fm-captain-hold-lifecycle.test.sh | 29 ++++++++ tests/fm-crew-state.test.sh | 89 +++++++++++++++++++++++++ tests/fm-inactive-reconcile.test.sh | 28 ++++++++ 8 files changed, 218 insertions(+), 21 deletions(-) diff --git a/bin/fm-captain-hold.sh b/bin/fm-captain-hold.sh index 57938ce45e8..f87d25f167e 100755 --- a/bin/fm-captain-hold.sh +++ b/bin/fm-captain-hold.sh @@ -160,6 +160,9 @@ DATA="${FM_DATA_OVERRIDE:-$FM_HOME/data}" # shellcheck source=bin/fm-classify-lib.sh # shellcheck disable=SC1091 . "$SCRIPT_DIR/fm-classify-lib.sh" +# shellcheck source=bin/fm-done-claim-lib.sh +# shellcheck disable=SC1091 +. "$SCRIPT_DIR/fm-done-claim-lib.sh" # shellcheck source=bin/fm-tasks-axi-lib.sh # shellcheck disable=SC1091 . "$SCRIPT_DIR/fm-tasks-axi-lib.sh" @@ -350,18 +353,22 @@ meta_value() { # } origin_open_decisions() { # - local origin=$1 meta="$STATE/$1.meta" status_file="$STATE/$1.status" open kind last verb + local origin=$1 meta="$STATE/$1.meta" status_file="$STATE/$1.status" open kind last open=$(status_open_decisions "$status_file") [ -n "$open" ] || return 0 [ -f "$meta" ] || { printf '%s' "$open"; return 0; } kind=$(meta_value "$meta" kind) [ -n "$kind" ] || kind=ship + # A task that has finished has no open decisions left to fold. Only a + # terminal line the task spoke in its own voice says it finished, which + # bin/fm-done-claim-lib.sh owns for every reader: a routed phase closing with + # `done [key=]` would otherwise silently drop the task's still-open + # decisions - including the very keyed decisions that phase belongs to. if [ "$kind" != secondmate ]; then last=$(last_status_line "$status_file") - verb=$(status_line_verb "$last") - case "$verb" in - done|failed) return 0 ;; - esac + if fm_done_claim_own_terminal_verb "$last" >/dev/null; then + return 0 + fi fi printf '%s' "$open" } diff --git a/bin/fm-crew-state.sh b/bin/fm-crew-state.sh index 6a1b8ca2b0c..bb8a0644996 100755 --- a/bin/fm-crew-state.sh +++ b/bin/fm-crew-state.sh @@ -152,16 +152,27 @@ log_last_line() { # reports `paused` distinctly, so a supervisor reading this sees a declared pause # and its reason rather than a wedge-suspect idle. map_log_state() { # + local terminal if status_is_paused "$1"; then echo paused return fi + # A TERMINAL verb is only this task's outcome when the task spoke it in its + # own voice; bin/fm-done-claim-lib.sh owns that question for every reader. + # A routed phase closing with `done [key=]` says one sub-event finished, + # not that the task did, and reading it as `done` would report a whole task + # complete on a line that never claimed as much. The non-terminal verbs below + # stay deliberately key-blind: a routed phase that is blocked or paused really + # is a supervision concern of the task it belongs to. + if terminal=$(fm_done_claim_own_terminal_verb "$1"); then + echo "$terminal" + return + fi case "$(status_line_verb "$1")" in working) echo working ;; needs-decision) echo parked ;; blocked) echo blocked ;; - done) echo "done" ;; - failed) echo failed ;; + done|failed) echo unknown ;; *) echo unknown ;; esac } @@ -317,7 +328,7 @@ nm_gate_findings_count() { # decided here - emit()'s guard still downgrades an unverified claim - this only # answers "did the worker report a PR", which is what the ci-step override needs. log_reports_ci_ready() { - [ "$LOG_VERB" = "done" ] || return 1 + [ "$(fm_done_claim_own_terminal_verb "$LOG_LINE" || true)" = "done" ] || return 1 if fm_done_claim_parse "$LOG_LINE" && [ -n "$FM_DONE_CLAIM_PR" ]; then return 0 fi diff --git a/bin/fm-done-claim-lib.sh b/bin/fm-done-claim-lib.sh index 4c66af54658..af1dc3807e5 100644 --- a/bin/fm-done-claim-lib.sh +++ b/bin/fm-done-claim-lib.sh @@ -175,6 +175,40 @@ fm_done_claim_own_voice() { # [ "$head" = "${2:-}" ] } +# The one owner of "is this status line the task speaking its own TERMINAL +# OUTCOME". Prints the bare verb (`done` or `failed`) and returns 0; returns 1 +# for every other line, including a line whose verb IS terminal but is spoken by +# a sub-event. Every reader that decides a task has finished - the current-state +# reader, both upward delivery paths in bin/fm-inactive-reconcile.sh, and the +# captain-hold decision fold - calls this rather than re-deriving the answer +# from `status_line_verb`, which reads through decoration by design. Five +# private copies of this question are how the readers came to disagree about one +# line; there is one implementation so they cannot again. +# +# SCOPE BOUNDARY, and it is narrow on purpose. This is for TERMINAL OUTCOMES +# only. The same key-blindness exists in readers of `paused`, `blocked` and +# `needs-decision`, and there it is load-bearing rather than defective: a routed +# phase that is blocked or paused genuinely IS a supervision concern of the task +# it belongs to, and the keyed-decision fold is built on keys meaning exactly +# that. Only a terminal outcome makes an untrue statement about the whole task +# when a sub-event speaks it. Do not "finish the job" by widening this to the +# other verbs; that would be a behaviour change resting on a false premise. +fm_done_claim_own_terminal_verb() { # + local line=${1:-} trimmed + trimmed=${line#"${line%%[![:space:]]*}"} + case "$trimmed" in + done*) + fm_done_claim_own_voice "$trimmed" 'done' || return 1 + printf 'done' + ;; + failed*) + fm_done_claim_own_voice "$trimmed" 'failed' || return 1 + printf 'failed' + ;; + *) return 1 ;; + esac +} + # The task's STANDING terminal claim, or empty when it has none. A status log is # an append-only event log, so the newest `done:` the task itself spoke is the one # under test; earlier claims were superseded by whatever the worker did next. diff --git a/bin/fm-inactive-reconcile.sh b/bin/fm-inactive-reconcile.sh index e3219674032..91f353b5acd 100755 --- a/bin/fm-inactive-reconcile.sh +++ b/bin/fm-inactive-reconcile.sh @@ -359,7 +359,9 @@ notice_parent_report_failed() { # # The whole terminal line a child's ledger ends in, or non-zero when the ledger # is absent, unusable, still being appended (no trailing newline yet), or does -# not end in a done or failed line. +# not end in a terminal line the child spoke in its own voice. A routed phase +# closing with `done [key=]` is a sub-event, not the child finishing, and +# bin/fm-done-claim-lib.sh owns that distinction for every reader. child_terminal_ledger_line() { # local status=$1 snapshot last marker='__FM_LEDGER_SNAPSHOT_END__' [ -f "$status" ] && [ ! -L "$status" ] && [ -s "$status" ] || return 1 @@ -367,10 +369,8 @@ child_terminal_ledger_line() { # case "$snapshot" in *$'\n'"$marker") ;; *) return 1 ;; esac snapshot=${snapshot%"$marker"} last=$(printf '%s' "$snapshot" | grep -v '^[[:space:]]*$' | tail -1) - case "$(status_line_verb "$last")" in - done|failed) printf '%s\n' "$last" ;; - *) return 1 ;; - esac + fm_done_claim_own_terminal_verb "$last" >/dev/null || return 1 + printf '%s\n' "$last" } # Claim one already-delivered inactive fallback as the delivery of this ledger @@ -407,7 +407,7 @@ report_child_ledger_locked() { # local id=$1 meta=$2 status last previous state verb claim note pr mode yolo data incarnation fingerprint predecessor_head outcome_key line status="$STATE/$id.status" last=$(child_terminal_ledger_line "$status") || return 0 - state=$(status_line_verb "$last") + state=$(fm_done_claim_own_terminal_verb "$last") || return 0 pr=$(pr_for_task "$meta" "$status" "$last") incarnation=$(meta_incarnation "$meta") # The same rule report_to_parent applies, at the other delivery path into the @@ -525,8 +525,8 @@ reconcile_direct_child_locked() { # /dev/null) || state_rc=$? [ "$state_rc" -ne 124 ] || return 3 last=$(last_status_line "$status") - if [ -n "$self" ]; then - case "$(status_line_verb "$last")" in done|failed) return 0 ;; esac + if [ -n "$self" ] && fm_done_claim_own_terminal_verb "$last" >/dev/null; then + return 0 fi # bin/fm-crew-state.sh reports an unverified terminal CLAIM as # `done-unverified`, and that is still a terminal outcome nobody has diff --git a/bin/fm-wake-drain.sh b/bin/fm-wake-drain.sh index e1ce988132b..3506b8b97f0 100755 --- a/bin/fm-wake-drain.sh +++ b/bin/fm-wake-drain.sh @@ -462,12 +462,11 @@ $unread EOF hold_unread_remainder "$current" "$through" "$held_back" "$snapshot" || return 1 - # The count is part of the section, never a bare line on its own: with the cap - # set to 0 and only unrecognised lines unread, the loop above prints no header. + # The count is part of a section the loop above has already opened: the cap is + # clamped to a minimum of one and the counter resets per task, so the first + # surfaced line of every task always prints and a non-zero omission always has + # a header in front of it. if [ "$omitted" -gt 0 ]; then - if [ "$shown" -eq 0 ]; then - printf '%s\n' "$header" || return 1 - fi printf 'UNREAD STATUS: %d more unrecognised line(s) held for the next drain; read them now in the task status log.\n' \ "$omitted" || return 1 fi diff --git a/tests/fm-captain-hold-lifecycle.test.sh b/tests/fm-captain-hold-lifecycle.test.sh index 7b24920402f..689eddaf860 100755 --- a/tests/fm-captain-hold-lifecycle.test.sh +++ b/tests/fm-captain-hold-lifecycle.test.sh @@ -1268,6 +1268,34 @@ EOF pass "a captain call with no routed work, a verified transfer, an open decision, and an answered call all stay silent" } +# A finished task has no open decisions left to fold, but only a terminal line +# the task spoke in its OWN voice says it finished. A routed phase closing with +# `done [key=]` closes one sub-event, and folding the task away on it would +# let `complete --none` attest that nothing is open while the captain's questions +# still stand. The unkeyed control proves the fold still closes on the real thing. +test_a_keyed_phase_close_does_not_fold_away_open_decisions() { + local home id + home=$(make_home keyed-close-decisions) + id=sample-systems-review + mkdir -p "$home/data/$id" + tasks_in "$home" add "$id" "Investigate sample systems" --kind scout --repo sample --start >/dev/null \ + || fail "could not create the investigation fixture" + write_origin_meta "$home" "$id" + cat > "$home/state/$id.status" <<'EOF' +working: report drafted +needs-decision [key=route]: choose route north or route south +done [key=docs]: that routed phase landed +EOF + if run_captain "$home" complete "$id" --none > "$home/keyed.out" 2> "$home/keyed.err"; then + fail "a closed routed phase folded the task's open captain call away and attested --none" + fi + + printf 'done: the whole task finished\n' >> "$home/state/$id.status" + run_captain "$home" complete "$id" --none > "$home/own.out" 2> "$home/own.err" \ + || fail "the task's own terminal line did not fold its open decisions away: $(cat "$home/own.err")" + pass "only the task's own terminal line folds its open captain decisions away" +} + # The originating work item is itself the captain call, which is what the policy # prefers ("hold the work item the question gates"). Cleanup of that finished # work must never be the act that closes the captain's own row: the deliverable @@ -1544,6 +1572,7 @@ test_chat_channel_feeds_the_same_keyed_answer_intake test_origin_slug_validation_precedes_path_construction test_status_resolution_over_an_open_hold_is_signalled test_legitimate_holds_produce_no_divergence_signal +test_a_keyed_phase_close_does_not_fold_away_open_decisions test_teardown_never_closes_a_captain_held_task test_interrupted_cleanup_keeps_the_captain_call_recoverable test_teardown_retains_captain_calls_in_a_relocated_backlog diff --git a/tests/fm-crew-state.test.sh b/tests/fm-crew-state.test.sh index 4b73b84320f..52ebbeb7ebb 100755 --- a/tests/fm-crew-state.test.sh +++ b/tests/fm-crew-state.test.sh @@ -950,6 +950,93 @@ test_no_run_herdr_idle_agent_status_and_idle_record_stays_idle() { pass "an idle record with idle agent_status stays not-busy (no regression for a human-blocked agent)" } +# A TERMINAL verb only reports the whole task terminal when the task spoke it in +# its own voice. bin/fm-brief.sh instructs workers to close a routed phase with +# `done [key=]`; that closes one sub-event, and reading it as the +# task's own completion reports a whole task finished on a line that never said +# so. The unkeyed positive control is what stops this passing vacuously. +test_a_keyed_done_is_not_the_tasks_own_terminal_state() { + reset_fakes + local d out + d=$(new_case keyed-terminal) + make_repo_on_branch "$d/wt" fm/feat-kt + make_fakebin "$d" >/dev/null + fm_write_meta "$d/state/feat-kt.meta" "window=fm:fm-feat-kt" "worktree=$d/wt" "kind=ship" "harness=claude" + printf 'working [key=docs]: starting\ndone [key=docs]: that routed phase landed\n' \ + > "$d/state/feat-kt.status" + FM_FAKE_AXI_STATUS="" + FM_FAKE_BUSY=0 + arm_idle_record "$d/state" feat-kt + out=$(run_crew_state "$d" feat-kt) + assert_not_contains "$out" "state: done" "a closed routed phase reported the whole task done" + assert_not_contains "$out" "state: failed" "a closed routed phase reported the whole task failed" + + reset_fakes + d=$(new_case unkeyed-terminal) + make_repo_on_branch "$d/wt" fm/feat-ut + make_fakebin "$d" >/dev/null + fm_write_meta "$d/state/feat-ut.meta" "window=fm:fm-feat-ut" "worktree=$d/wt" "kind=ship" "harness=claude" + printf 'failed: the work did not land\n' > "$d/state/feat-ut.status" + FM_FAKE_AXI_STATUS="" + FM_FAKE_BUSY=0 + arm_idle_record "$d/state" feat-ut + out=$(run_crew_state "$d" feat-ut) + assert_contains "$out" "state: failed" "the task's own terminal line must still be its state" + assert_contains "$out" "source: status-log" "the task's own terminal line comes from the status log" + pass "a keyed sub-event is not the task's terminal state, and its own line still is" +} + +# EXHAUSTIVENESS, and what it does and does not prove. "Is this line the task +# speaking its own terminal outcome" has ONE owner in bin/fm-done-claim-lib.sh +# because five private answers to it are how the fleet's readers came to +# disagree about a single line. This feeds one keyed `done [key=...]` into a +# fixture and asserts that the task-level answers AGREE across separate +# consumers - the current-state reader and the fleet snapshot's current_state - +# so a reader that answers privately shows up as a disagreement rather than as +# silence. +# +# What it proves: the covered readers agree. What it cannot prove: that a reader +# added later calls the shared predicate at all. That is why the predicate has +# one owner and every call site points at it. The other consumers of the same +# question are pinned by their own suites, each with the same keyed/unkeyed +# pair: the two upward delivery paths in tests/fm-inactive-reconcile.test.sh and +# the captain-decision fold in tests/fm-captain-hold-lifecycle.test.sh. +test_every_reader_agrees_about_a_keyed_terminal_line() { + local d out snapshot state + for state in keyed unkeyed; do + reset_fakes + d=$(new_case "agree-$state") + make_repo_on_branch "$d/wt" "fm/feat-ag-$state" + make_fakebin "$d" >/dev/null + fm_write_meta "$d/state/feat-ag.meta" "window=fm:fm-feat-ag" "worktree=$d/wt" "kind=ship" "harness=claude" + if [ "$state" = keyed ]; then + printf 'done [key=docs]: that routed phase landed\n' > "$d/state/feat-ag.status" + else + printf 'done: the whole task finished\n' > "$d/state/feat-ag.status" + fi + FM_FAKE_AXI_STATUS="" + FM_FAKE_BUSY=0 + arm_idle_record "$d/state" feat-ag + out=$(run_crew_state "$d" feat-ag) + snapshot=$(PATH="$d/fakebin:$PATH" FM_HOME="$d" FM_STATE_OVERRIDE="$d/state" \ + FM_DATA_OVERRIDE="$d/data" "$ROOT/bin/fm-fleet-snapshot.sh" --json 2>/dev/null \ + | jq -r '.. | objects | select(.id? == "feat-ag") | .current_state.state // ""' | head -1) + if [ "$state" = keyed ]; then + assert_not_contains "$out" "state: done" "the current-state reader read a closed routed phase as done" + case "$snapshot" in + done|done-unverified) fail "the fleet snapshot read a closed routed phase as $snapshot" ;; + esac + else + assert_contains "$out" "state: done" "the task's own terminal line stopped being its state" + case "$snapshot" in + done|done-unverified) ;; + *) fail "the fleet snapshot disagreed about the task's own terminal line: '$snapshot'" ;; + esac + fi + done + pass "the current-state reader and the fleet snapshot agree about a keyed terminal line" +} + # (g) no run + idle pane -> the status-log verb, as-is test_no_run_idle_pane_uses_log() { reset_fakes @@ -1596,6 +1683,8 @@ test_top_level_fixing_ci_running_after_green_stays_working test_top_level_fixing_done_log_stays_working test_terminal_passed test_terminal_failed +test_a_keyed_done_is_not_the_tasks_own_terminal_state +test_every_reader_agrees_about_a_keyed_terminal_line test_cross_branch_attribution_via_runs_list test_cross_branch_attribution_picks_most_recent_row test_coarse_run_does_not_probe_other_branch_ci_log_for_ready_status diff --git a/tests/fm-inactive-reconcile.test.sh b/tests/fm-inactive-reconcile.test.sh index 8192a32d85c..05ad6eafbc5 100755 --- a/tests/fm-inactive-reconcile.test.sh +++ b/tests/fm-inactive-reconcile.test.sh @@ -258,6 +258,33 @@ test_a_later_established_claim_corrects_the_parents_record() { pass "a claim established after it was reported unverified corrects the parent's record once" } +# Both upward delivery paths must agree with the rest of the fleet about what a +# terminal outcome is. A routed phase closing with `done [key=]` - the +# shape bin/fm-brief.sh instructs - is one sub-event finishing, so it must not be +# delivered upward as the child's completion by the ledger path, and it must not +# make the crew-state backstop stand down as though the outcome were already +# owned. The unkeyed control keeps both halves honest. +test_a_keyed_phase_close_is_not_the_childs_terminal_outcome() { + make_world keyed-ledger; bind_secondmate local + write_child "$MATE" child 'done [key=docs]: that routed phase landed' + FM_FAKE_CREW_STATE='unknown' run_reconcile "$MATE" + ! grep -q 'child-outcome-child-' "$MAIN/state/mate.status" 2>/dev/null \ + || fail "a closed routed phase was delivered upward as the child's outcome: $(cat "$MAIN/state/mate.status" 2>/dev/null)" + + # The backstop must still see this child, because the ledger path does not own + # an outcome the child never stated. + FM_FAKE_CREW_STATE='done' run_reconcile "$MATE" --startup + grep -q 'inactive-outcome-' "$MAIN/state/mate.status" \ + || fail "the crew-state backstop stood down for a child whose ledger states no outcome: $(cat "$MAIN/state/mate.status" 2>/dev/null)" + + make_world unkeyed-ledger; bind_secondmate local + write_child "$MATE" child 'failed: the work did not land' + FM_FAKE_CREW_STATE='unknown' run_reconcile "$MATE" + grep -q '^failed \[key=child-outcome-child-failed-' "$MAIN/state/mate.status" \ + || fail "the child's own terminal line was not delivered upward: $(cat "$MAIN/state/mate.status" 2>/dev/null)" + pass "a closed routed phase is neither delivered as the child's outcome nor allowed to silence the backstop" +} + # A busy child cannot keep later ledger outcomes from being visited, and is # retried on the next poll after its lifecycle lock becomes available. test_busy_child_does_not_starve_later_ledger_outcomes() { @@ -858,6 +885,7 @@ test_main_direct_terminal_presentation_receipt test_local_secondmate_delivers_terminal_ledger_line test_ledger_delivery_downgrades_an_unestablished_claim test_a_later_established_claim_corrects_the_parents_record +test_a_keyed_phase_close_is_not_the_childs_terminal_outcome test_busy_child_does_not_starve_later_ledger_outcomes test_secondmate_ledger_delivery_carries_report_and_failure test_terminal_line_during_state_read_yields_to_ledger_delivery From 835375715c557a132cdbfea5d75a36d181d37d32 Mon Sep 17 00:00:00 2001 From: Israel Wilson Date: Thu, 3 Sep 2026 18:27:34 -0400 Subject: [PATCH 14/21] no-mistakes(review): ask the classifier about both stated key positions --- bin/fm-classify-lib.sh | 14 ++++++++++++++ bin/fm-done-claim-lib.sh | 24 +++++++++++++++++------- tests/fm-done-verified.test.sh | 27 ++++++++++++++++++--------- 3 files changed, 49 insertions(+), 16 deletions(-) diff --git a/bin/fm-classify-lib.sh b/bin/fm-classify-lib.sh index 0301ca77996..7ddcafc4efb 100755 --- a/bin/fm-classify-lib.sh +++ b/bin/fm-classify-lib.sh @@ -387,6 +387,20 @@ status_line_note() { # -> text after the first colon, trimmed fi printf '%s' "$n" } +# 0 when the line STATES a routed key token, in either documented position: +# before the line's first colon, or at the head of the note. This is the one +# owner of that question, so nothing has to re-derive it from one spelling and +# then disagree with the fold about a single line - _fm_decision_key below reads +# the same pair of positions, and bin/fm-done-claim-lib.sh's authority rule asks +# here rather than testing a prefix itself. +# +# Slug VALIDITY is a separate question (_fm_decision_slug_ok): a malformed token +# is still a token the writer stated, and a reader deciding whether a line speaks +# for the whole task wants the statement, not its well-formedness. +status_line_states_key() { # + _fm_key_before_colon "$1" && return 0 + _fm_key_at_note_head "$1" >/dev/null +} _fm_decision_key() { # -> key slug, or "default" when no token local k if _fm_key_before_colon "$1"; then diff --git a/bin/fm-done-claim-lib.sh b/bin/fm-done-claim-lib.sh index af1dc3807e5..61af5f049c9 100644 --- a/bin/fm-done-claim-lib.sh +++ b/bin/fm-done-claim-lib.sh @@ -159,7 +159,14 @@ fm_done_claim_has_identity() { # This is an authority rule, not a formatting rule about verbs, so do not # "simplify" it into one. `status_line_verb` deliberately reads THROUGH a # `[key=...]` and through a correlation token, which is right for classification -# and wrong for authority, so the test is made on the undecorated prefix instead. +# and wrong for authority, so the undecorated prefix is tested here instead. +# +# A routed key has TWO stated positions, before the colon and at the head of the +# note, and the fleet's classifier treats them as equally valid. Whether a line +# states one is therefore asked of `status_line_states_key`, which owns both, +# rather than answered here from one spelling: an authority rule and the decision +# fold disagreeing about a single line is the same defect shape that once had +# five readers giving two answers. # It is written by code, not only by prose: bin/fm-brief.sh instructs workers to # close routed phases with `done [key=]`, and # report_child_ledger_locked publishes both `done [key=child-outcome-...]` and @@ -172,7 +179,8 @@ fm_done_claim_has_identity() { fm_done_claim_own_voice() { # local head=${1%%:*} head=${head%"${head##*[![:space:]]}"} - [ "$head" = "${2:-}" ] + [ "$head" = "${2:-}" ] || return 1 + ! status_line_states_key "$1" } # The one owner of "is this status line the task speaking its own TERMINAL @@ -234,11 +242,13 @@ fm_done_claim_own_terminal_verb() { # # withdrawing it and people withdraw it; make it force and they learn to reach # for force. # -# This runs per task on every current-state read and every session-start digest. -# The speaker test is fork-free and exact rather than an approximation: a line -# whose verb is `done` or `failed` always begins with that word after its leading -# whitespace, and a correlation token or a `[key=...]` only ever follows the verb -# word, so an undecorated prefix is the task's own voice and nothing else is. +# This runs per task on every current-state read and every session-start digest, +# so the speaker test is only paid for by lines that could carry one of the two +# verbs: such a line always begins with that word after its leading whitespace, +# which makes the case prefixes below exact rather than an approximation. The +# speaker test itself is not a prefix test - a routed `[key=...]` may be stated +# before the colon OR at the head of the note, and fm_done_claim_own_voice asks +# the classifier that owns both positions. fm_done_claim_last() { # local f=${1:-} line trimmed last= [ -f "$f" ] && [ -r "$f" ] && [ ! -L "$f" ] || return 0 diff --git a/tests/fm-done-verified.test.sh b/tests/fm-done-verified.test.sh index 764e4f5f691..114a2bfde9b 100755 --- a/tests/fm-done-verified.test.sh +++ b/tests/fm-done-verified.test.sh @@ -1211,11 +1211,15 @@ test_a_later_failed_line_withdraws_the_claim() { # prose: a secondmate home publishes a child's outcome as # `failed [key=child-outcome--...]` into the PARENT's status. Nothing about # one routed phase or one child may withdraw the task's own terminal assertion. +# A routed key has two stated positions the fleet's classifier treats alike - +# before the colon and at the head of the note - so both are exercised here: a +# rule that guards one spelling leaves the same hole open through the other. test_only_the_task_itself_may_withdraw_its_claim() { local dir state line for line in \ 'failed [key=docs-pass]: that routed phase did not land' \ 'failed [key=child-outcome-task-c-failed-abc123]: child task-c failed: no work landed' \ + 'failed: [key=docs-pass] that routed phase did not land' \ 'failed corr=0123456789abcdef: the answered request did not land'; do dir="$TMP_ROOT/claim-subevent-$RANDOM" state="$dir/state" @@ -1242,6 +1246,7 @@ test_only_the_task_itself_may_assert_its_claim() { for line in \ 'done [key=docs-pass]: that routed phase landed' \ 'done [key=child-outcome-task-c-done-abc123]: child task-c done: closed out report=data/task-c/report.md' \ + 'done: [key=docs-pass] that routed phase landed' \ 'done corr=0123456789abcdef: the answered request landed'; do dir="$TMP_ROOT/claim-assert-subevent-$RANDOM" state="$dir/state" @@ -1255,15 +1260,19 @@ test_only_the_task_itself_may_assert_its_claim() { esac done - # A sub-event may not assert a claim where the task has made none either. - dir="$TMP_ROOT/claim-assert-subevent-only" - state="$dir/state" - mkdir -p "$state" - printf 'done [key=child-outcome-task-c-done-abc123]: child task-c done: closed out report=data/task-c/report.md\n' \ - > "$state/task-v.status" - fm_done_claim_status "$state" task-v - [ "$FM_DONE_CLAIM_STATE" = none ] \ - || fail "a sub-event alone gave a task the claim state $FM_DONE_CLAIM_STATE" + # A sub-event may not assert a claim where the task has made none either, in + # either stated key position. + for line in \ + 'done [key=child-outcome-task-c-done-abc123]: child task-c done: closed out report=data/task-c/report.md' \ + 'done: [key=child-outcome-task-c-done-abc123] child task-c done: closed out report=data/task-c/report.md'; do + dir="$TMP_ROOT/claim-assert-subevent-only-$RANDOM" + state="$dir/state" + mkdir -p "$state" + printf '%s\n' "$line" > "$state/task-v.status" + fm_done_claim_status "$state" task-v + [ "$FM_DONE_CLAIM_STATE" = none ] \ + || fail "a sub-event alone gave a task the claim state $FM_DONE_CLAIM_STATE: $line" + done pass "only the task speaking for itself asserts its claim, never a sub-event" } From 76f312983eeaf72ce2e769480910727ab2680e95 Mon Sep 17 00:00:00 2001 From: Israel Wilson Date: Thu, 3 Sep 2026 18:55:08 -0400 Subject: [PATCH 15/21] no-mistakes(review): bind scout reports physically and split the ready handoff --- AGENTS.md | 1 + bin/fm-classify-lib.sh | 29 +++++++++++++- bin/fm-crew-state.sh | 1 + bin/fm-dod-lib.sh | 3 +- bin/fm-done-claim-lib.sh | 2 +- bin/fm-supervise-daemon.sh | 2 +- bin/fm-teardown.sh | 2 +- bin/fm-verify-done.sh | 65 ++++++++++++++++++------------- docs/configuration.md | 3 +- tests/fm-crew-state.test.sh | 41 +++++++++++++++++++- tests/fm-done-verified.test.sh | 70 +++++++++++++++++++++++++++++++++- tests/fm-teardown.test.sh | 10 +++-- 12 files changed, 191 insertions(+), 38 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 0e7f68ec136..bf4f55f489a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -373,6 +373,7 @@ The worker reports the PR when CI first becomes green rather than waiting for me ### PR ready, landing, and teardown A terminal `done:` claim names the exact commit, PR, branch, or report it is claiming, and only `bin/fm-verify-done.sh` decides whether that claim is true; `bin/fm-done-claim-lib.sh` owns the grammar and the durable verdict. +A no-mistakes worker whose implementation is committed but not yet validated appends `ready: ` instead, which is captain-relevant so firstmate sees the handoff, and is not terminal: it asserts nothing about a commit that has shipped, so it neither stands as a claim nor is verified as one. A claim that has not been established is never done: `bin/fm-crew-state.sh` reports it as `done-unverified`, and teardown refuses it. Terminal evidence about a claim has three shapes, not two, and the verdict record names all three: `unverified` is absence of evidence and never downgrades what already stands, `contradicted` is positive evidence of falsity and overwrites anything, and `stale` is the world having changed under a verdict that was true when it was made, which `bin/fm-merge-outcome-lib.sh` records when a PR merges under a claim already established as verified. A PR closed without merging is not stale but false, so the same owner records `contradicted` for it. diff --git a/bin/fm-classify-lib.sh b/bin/fm-classify-lib.sh index 7ddcafc4efb..693d4f360ad 100755 --- a/bin/fm-classify-lib.sh +++ b/bin/fm-classify-lib.sh @@ -74,8 +74,28 @@ unset _fm_classify_nounset # legacy lines that lack a standard terminal verb. status_is_captain_relevant is # verb-aware: a nonterminal working: or paused: line never becomes captain-relevant # merely because its prose contains one of those tokens (for example -# "working: rebased onto merged #76"). -FM_CLASSIFY_CAPTAIN_RE_DEFAULT='done:|needs-decision:|blocked:|failed:|PR ready|checks green|ready in branch|merged' +# "working: rebased onto merged #76"). The legacy tokens `PR ready` and +# `ready in branch` are prose, not the `ready:` verb below, and neither matches a +# bare `ready:` line - the verb earns its captain relevance in its own right. +FM_CLASSIFY_CAPTAIN_RE_DEFAULT='done:|ready:|needs-decision:|blocked:|failed:|PR ready|checks green|ready in branch|merged' + +# The PRE-VALIDATION HANDOFF verb. A no-mistakes worker appends +# ready: +# when the implementation is committed and it is waiting for firstmate to tell it +# to run /no-mistakes. It exists because `done:` was doing two jobs - "I have +# finished implementing, now validate" and "the task is complete" - and under the +# terminal-claim contract the second meaning is the only one `done:` may carry: +# a pre-validation `done:` names no commit identity, so it stood as a claim +# nothing could ever establish and downgraded the task from the moment the worker +# followed its own brief. Separating the words makes `done:` terminal by +# construction rather than by a reader's special case. +# +# It is CAPTAIN-RELEVANT, because a handoff waiting on firstmate to say "run +# /no-mistakes" is exactly the thing firstmate must see, and that is what `done:` +# used to buy here. It is deliberately NOT terminal: status_is_terminal_verb says +# no, and fm_done_claim_last does not read it as a claim, because nothing has +# been delivered yet. FM_CLASSIFY_READY_VERB overrides it. +FM_CLASSIFY_READY_VERB_DEFAULT='ready' # The deliberate-external-wait verb. A crew (or firstmate steering it) appends # paused: @@ -116,6 +136,9 @@ last_status_line() { # 0 if the given (last) status line's leading verb is a real terminal captain verb # (done, needs-decision, blocked, failed). Free-text tokens alone never count here; # callers that need legacy free-text matching use status_is_captain_relevant. +# The pre-validation handoff verb is captain-relevant but NOT terminal, so it is +# absent here on purpose: a worker waiting to be told to validate has delivered +# nothing yet. status_is_terminal_verb() { local line=$1 verb [ -n "$line" ] || return 1 @@ -144,6 +167,7 @@ status_line_verb_is_known() { # case "$verb" in working|needs-decision|blocked|done|failed|note) return 0 ;; esac + [ "$verb" = "${FM_CLASSIFY_READY_VERB:-$FM_CLASSIFY_READY_VERB_DEFAULT}" ] && return 0 [ "$verb" = "${FM_CLASSIFY_PAUSED_VERB:-$FM_CLASSIFY_PAUSED_VERB_DEFAULT}" ] && return 0 [ "$verb" = "${FM_CLASSIFY_RESOLVE_VERB:-$FM_CLASSIFY_RESOLVE_VERB_DEFAULT}" ] && return 0 [ "$verb" = "${FM_CLASSIFY_CAPTAIN_HELD_VERB:-$FM_CLASSIFY_CAPTAIN_HELD_VERB_DEFAULT}" ] && return 0 @@ -184,6 +208,7 @@ status_is_captain_relevant() { case "$verb" in done|needs-decision|blocked|failed) return 0 ;; esac + [ "$verb" = "${FM_CLASSIFY_READY_VERB:-$FM_CLASSIFY_READY_VERB_DEFAULT}" ] && return 0 fi printf '%s' "$line" | grep -qiE "${FM_CAPTAIN_RE:-$FM_CLASSIFY_CAPTAIN_RE_DEFAULT}" } diff --git a/bin/fm-crew-state.sh b/bin/fm-crew-state.sh index bb8a0644996..59b405b8190 100755 --- a/bin/fm-crew-state.sh +++ b/bin/fm-crew-state.sh @@ -172,6 +172,7 @@ map_log_state() { # working) echo working ;; needs-decision) echo parked ;; blocked) echo blocked ;; + "${FM_CLASSIFY_READY_VERB:-$FM_CLASSIFY_READY_VERB_DEFAULT}") echo ready ;; done|failed) echo unknown ;; *) echo unknown ;; esac diff --git a/bin/fm-dod-lib.sh b/bin/fm-dod-lib.sh index f68db358048..15bc49b0ced 100755 --- a/bin/fm-dod-lib.sh +++ b/bin/fm-dod-lib.sh @@ -219,7 +219,8 @@ EOF # Definition of done Delivery contract: mode=no-mistakes The task is complete only when committed on your branch. -When you believe it is complete, append \`done: {summary}\` to the status file and stop. +When you believe the implementation is complete and committed, append \`ready: {summary}\` to the status file and stop. +Use \`ready:\`, never \`done:\`: nothing has been validated or shipped yet, and a \`done:\` line is a terminal claim that must name what it claims. Firstmate will then instruct you to run /no-mistakes to validate and ship a PR. You drive no-mistakes by responding to its gates, not by implementing fixes. diff --git a/bin/fm-done-claim-lib.sh b/bin/fm-done-claim-lib.sh index 61af5f049c9..03a75d67fcc 100644 --- a/bin/fm-done-claim-lib.sh +++ b/bin/fm-done-claim-lib.sh @@ -455,7 +455,7 @@ fm_done_claim_status() { # fm_done_claim_parse "$FM_DONE_CLAIM_LINE" || return 0 if ! fm_done_claim_has_identity; then FM_DONE_CLAIM_STATE=unverified - FM_DONE_CLAIM_REASON='legacy claim, no commit identity' + FM_DONE_CLAIM_REASON='the claim names no commit identity' return 0 fi if ! hash=$(fm_done_claim_hash "$FM_DONE_CLAIM_LINE"); then diff --git a/bin/fm-supervise-daemon.sh b/bin/fm-supervise-daemon.sh index 91174bc5baf..ce33fff0e04 100755 --- a/bin/fm-supervise-daemon.sh +++ b/bin/fm-supervise-daemon.sh @@ -439,7 +439,7 @@ classify_stale() { # [ ] ;; esac fi - printf 'self|stale + terminal (already escalated by signal): %s' "$last" + printf 'self|stale + captain-relevant (already escalated by signal): %s' "$last" return fi # Non-terminal (or no status): defer to the persistence recheck. The caller diff --git a/bin/fm-teardown.sh b/bin/fm-teardown.sh index 6039404a87a..3829a633777 100755 --- a/bin/fm-teardown.sh +++ b/bin/fm-teardown.sh @@ -2676,7 +2676,7 @@ if [ -n "$TEARDOWN_CLAIM" ]; then echo "REFUSED: task $ID claims it is done, but that claim could not be established." >&2 printf '%s\n' "$TEARDOWN_CLAIM_OUT" >&2 echo "Re-run bin/fm-verify-done.sh $ID once the forge and the validation run can be read." >&2 - echo "A claim written before this contract carries no commit identity and can never be established: append a conforming claim naming the commit that shipped, then verify it." >&2 + echo "A claim that names no commit identity can never be established: append a conforming claim naming the commit that shipped, then verify it." >&2 exit 1 ;; 4) diff --git a/bin/fm-verify-done.sh b/bin/fm-verify-done.sh index dcc6b0f3143..38fd754a5e0 100755 --- a/bin/fm-verify-done.sh +++ b/bin/fm-verify-done.sh @@ -58,11 +58,12 @@ # own directory under the home's data root. A file existing is not this # task having produced it, so the path is bound to // the # same way the local-only arm is bound to fm/. That binding is -# made on the real location, not on the spelling of one: a `report=` -# carrying a `..` component is refused outright, and the containing -# directory is then resolved physically before it is compared, so neither -# an upward walk nor a directory symlink can present another task's -# deliverable as this one's. An absolute path is used as given (what +# made on the real location, and ONLY there: a `report=` carrying a `..` +# component is refused outright, and both the claimed directory and this +# task's own are then resolved physically before they are compared, so +# neither an upward walk nor a directory symlink can present another task's +# deliverable as this one's, and no mismatch of SPELLING alone can call a +# report foreign that really sits where the claim says. An absolute path is used as given (what # bin/fm-brief.sh renders into a scout brief); a relative one is resolved # against the data root, so a relocated FM_DATA_OVERRIDE resolves the same # claim as an unrelocated one. A leading `data/` is the home-relative @@ -203,7 +204,7 @@ MODE=$(meta_field mode) WT=$(meta_field worktree) if ! fm_done_claim_has_identity; then - verdict_is unverified 'legacy claim, no commit identity' + verdict_is unverified 'the claim names no commit identity' finish fi @@ -239,33 +240,38 @@ if [ "$KIND" = scout ]; then # anywhere else names someone else's deliverable however real that file is - # the same binding the local-only arm makes by requiring branch = fm/. # - # Tested twice, on the spelling and then on the real location, because a prefix - # match alone tests only how a path is written: this same binding was added to - # stop a substitution and shipped with `..` walking straight through it. The - # second test resolves both sides physically, so a directory symlink cannot - # stand in for the walk either. A path that will not resolve is left to the - # existence checks below, which report it as missing rather than as foreign. - case "$REPORT" in - "$DATA/$ID/"?*) ;; - *) - verdict_is contradicted "the claimed report is not this task's own: $FM_DONE_CLAIM_REPORT resolves to $REPORT, outside $DATA/$ID/" \ - "$REPORT" - finish - ;; - esac - # The task's own directory has to BE a directory for containment in it to mean - # anything: a link there points containment wherever the link does. Refusing to - # read it is not proof the claim is false, so it names the link and reads - # unverified, exactly as the report symlink below does. + # Tested on the REAL LOCATION and only there. A prefix match on the spelling + # tests how a path is written, not where it goes: this same binding was added + # to stop a substitution and shipped with `..` walking straight through it, and + # a spelling test that could contradict would also call a scout's own report + # foreign whenever the home sits under a symlinked component (`/tmp` on this + # platform), which is a false statement about a file sitting exactly where the + # claim says. So both sides are resolved physically and compared, and nothing + # short of that comparison may establish falsity. + # + # Containment that cannot be RESOLVED is not containment established, so it is + # never passed by default. What an unresolved own directory means depends on + # WHY it did not resolve, and the two answers are different evidence: a task + # that has no report directory AT ALL has produced no report, so a real file + # somewhere else is positively not its deliverable and is contradicted with + # that observation; a directory that exists but could not be read is a refusal + # to look, which is unverified. A claimed path that does not exist is reported + # as missing by the existence checks below either way. + # + # The task's own directory also has to BE a directory for containment in it to + # mean anything: a link there points containment wherever the link does. + # Refusing to read it is not proof the claim is false, so it names the link and + # reads unverified, exactly as the report symlink below does. if [ -L "$DATA/$ID" ]; then verdict_is unverified "this task's report directory is a symlink, which is not read as evidence: $DATA/$ID" finish fi REPORT_DIR=$(cd "$(dirname "$REPORT")" 2>/dev/null && pwd -P) || REPORT_DIR= OWN_DIR=$(cd "$DATA/$ID" 2>/dev/null && pwd -P) || OWN_DIR= + CONTAINED=0 if [ -n "$REPORT_DIR" ] && [ -n "$OWN_DIR" ]; then case "$REPORT_DIR/" in - "$OWN_DIR/"*) ;; + "$OWN_DIR/"*) CONTAINED=1 ;; *) verdict_is contradicted "the claimed report is not this task's own: $FM_DONE_CLAIM_REPORT is really in $REPORT_DIR, outside $OWN_DIR" \ "$REPORT_DIR" @@ -282,7 +288,14 @@ if [ "$KIND" = scout ]; then finish fi if [ -f "$REPORT" ] && [ -s "$REPORT" ]; then - verdict_is verified "report present at $FM_DONE_CLAIM_REPORT" + if [ "$CONTAINED" -eq 1 ]; then + verdict_is verified "report present at $FM_DONE_CLAIM_REPORT" + elif [ ! -e "$DATA/$ID" ]; then + verdict_is contradicted "the claimed report is not this task's own: $FM_DONE_CLAIM_REPORT is a real file at $REPORT while this task has no report directory at all" \ + "nothing exists at $DATA/$ID" + else + verdict_is unverified "the claimed report could not be bound to this task's own directory, which did not resolve: $DATA/$ID" + fi elif [ ! -e "$REPORT" ]; then verdict_is contradicted "claimed report is missing: $FM_DONE_CLAIM_REPORT" \ "nothing exists at $REPORT" diff --git a/docs/configuration.md b/docs/configuration.md index a7edd00dd6c..8bbee0734e8 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -873,8 +873,9 @@ FM_WATCH_CYCLE_LOG_KEEP_LINES=1000 # newest complete lifecycle rows considered FM_WATCHER_STALE_GRACE=300 # defaults to FM_GUARD_GRACE; seconds a live watcher lock may have a stale beacon before re-arm errors FM_SIGNAL_GRACE=30 # seconds to coalesce nearby status and turn-end signals into one wake FM_TURNEND_CHURN_ABSORB_SECS=900 # longest one endpoint's bare turn-ends may be deferred on pane-churn evidence alone; only consulted when config/turnend-churn-absorb is present -FM_CAPTAIN_RE='done:|needs-decision:|blocked:|failed:|PR ready|checks green|ready in branch|merged' # captain-relevant status regex; nonterminal progress verbs remain excluded even when their prose matches +FM_CAPTAIN_RE='done:|ready:|needs-decision:|blocked:|failed:|PR ready|checks green|ready in branch|merged' # captain-relevant status regex; nonterminal progress verbs remain excluded even when their prose matches FM_CLASSIFY_PAUSED_VERB=paused # leading status verb for a declared external wait; excluded from FM_CAPTAIN_RE and distinct from blocked +FM_CLASSIFY_READY_VERB=ready # leading status verb for a no-mistakes worker's pre-validation handoff; captain-relevant so firstmate sees it, but never terminal and never a claim FM_STALE_ESCALATE_SECS=240 # idle seconds before a provably-working stale pane escalates; stale panes whose crew is not provably working surface immediately unless admitted directly to the declared-wait cadence, while a live idle declared wait still surfaces once before that cadence bounds repeats FM_BUSY_TURN_MAX_SECS=3600 # maximum age of a busy pane's latest state/.turn-ended marker, or its state/.meta spawn record before any turn completes, before the same wedge escalation used for a provably-working non-busy stale takes over; inspection-only, never an automatic interrupt or restart; a declared external wait or verified captain-held transfer takes the FM_PAUSE_RESURFACE_SECS recheck below instead FM_PAUSE_RESURFACE_SECS=3600 # seconds between bounded rechecks of a declared external wait or verified captain-held transfer, including a live idle pane after its first inconclusive stale wake and a live busy pane past FM_BUSY_TURN_MAX_SECS; the away-mode daemon uses the same setting, ageing its window against the crew's own latest status line rather than pane busy state diff --git a/tests/fm-crew-state.test.sh b/tests/fm-crew-state.test.sh index 52ebbeb7ebb..4224ef846ce 100755 --- a/tests/fm-crew-state.test.sh +++ b/tests/fm-crew-state.test.sh @@ -453,7 +453,8 @@ test_ci_ready_done_log_beats_monitoring_run() { FM_FAKE_AXI_STATUS="$(run_ci_monitoring fm/feat-ci)" local out; out=$(run_crew_state "$d" feat-ci) assert_contains "$out" "state: done-unverified" "a legacy ci-ready claim carries no commit identity, so it is not done" - assert_contains "$out" "legacy claim, no commit identity" "the downgrade names why the claim could not be established" + assert_contains "$out" "the claim names no commit identity" "the downgrade names why the claim could not be established" + assert_not_contains "$out" "legacy" "the reason asserted when the claim was written, which nothing here observed" assert_contains "$out" "source: status-log" "ci-ready state comes from the status log" assert_contains "$out" "checks green" "ci-ready detail preserves the report" assert_not_contains "$out" "state: working" "ci-ready is not hidden by monitoring run" @@ -1037,6 +1038,43 @@ test_every_reader_agrees_about_a_keyed_terminal_line() { pass "the current-state reader and the fleet snapshot agree about a keyed terminal line" } +# The pre-validation handoff. A no-mistakes worker that has committed but not yet +# validated writes `ready:`, which is a state of its own - the task is neither +# done nor failed, it is waiting for firstmate to say "run /no-mistakes". The +# second half is the one that matters: because `ready:` is not a claim, a task +# that later passes its run-step reports plain `done` again. That exemption +# ("a run-step passed on a task that never claimed anything stays done") was +# unreachable while the brief told every worker to write a pre-validation +# `done:`, and this proves it is reachable now. +test_the_prevalidation_handoff_has_its_own_state_and_leaves_done_intact() { + reset_fakes + local d out + d=$(new_case ready-handoff) + make_repo_on_branch "$d/wt" fm/feat-rh + make_fakebin "$d" >/dev/null + fm_write_meta "$d/state/feat-rh.meta" "window=fm:fm-feat-rh" "worktree=$d/wt" "kind=ship" "harness=claude" + printf 'ready: implementation complete and committed\n' > "$d/state/feat-rh.status" + FM_FAKE_AXI_STATUS="" + FM_FAKE_BUSY=0 + arm_idle_record "$d/state" feat-rh + out=$(run_crew_state "$d" feat-rh) + assert_contains "$out" "state: ready" "the pre-validation handoff did not report its own state" + assert_not_contains "$out" "done-unverified" "the handoff was downgraded as if it were a claim" + + reset_fakes + d=$(new_case ready-then-passed) + make_repo_on_branch "$d/wt" fm/feat-rp + make_fakebin "$d" >/dev/null + fm_write_meta "$d/state/feat-rp.meta" "window=fm:fm-feat-rp" "worktree=$d/wt" "kind=ship" "harness=claude" + printf 'ready: implementation complete and committed\n' > "$d/state/feat-rp.status" + FM_FAKE_AXI_STATUS="$(run_passed fm/feat-rp)" + out=$(run_crew_state "$d" feat-rp) + assert_contains "$out" "state: done" "a passed run on a task that claimed nothing was not done" + assert_not_contains "$out" "done-unverified" "a machine-established pass was downgraded with no claim to distrust" + assert_contains "$out" "source: run-step" "the passed run is the authoritative source" + pass "the pre-validation handoff has its own state and leaves a machine-established pass intact" +} + # (g) no run + idle pane -> the status-log verb, as-is test_no_run_idle_pane_uses_log() { reset_fakes @@ -1685,6 +1723,7 @@ test_terminal_passed test_terminal_failed test_a_keyed_done_is_not_the_tasks_own_terminal_state test_every_reader_agrees_about_a_keyed_terminal_line +test_the_prevalidation_handoff_has_its_own_state_and_leaves_done_intact test_cross_branch_attribution_via_runs_list test_cross_branch_attribution_picks_most_recent_row test_coarse_run_does_not_probe_other_branch_ci_log_for_ready_status diff --git a/tests/fm-done-verified.test.sh b/tests/fm-done-verified.test.sh index 114a2bfde9b..6b706cf7845 100755 --- a/tests/fm-done-verified.test.sh +++ b/tests/fm-done-verified.test.sh @@ -306,7 +306,11 @@ test_legacy_claim_degrades_and_is_never_upgraded() { printf 'done: PR https://github.com/o/r/pull/7 checks green\n' > "$dir/state/task-v.status" result=$(FAKE_GH_OUT="MERGED deadbeef fm/task-v SUCCESS" verify "$dir") [ "${result%%$'\t'*}" = 3 ] || fail "a legacy claim did not exit unverified: $result" - case "$result" in *"legacy claim, no commit identity"*) ;; *) fail "the legacy claim was not named as such: $result" ;; esac + case "$result" in *"the claim names no commit identity"*) ;; *) fail "the claim was not named as identity-less: $result" ;; esac + # The reason must say what is CHECKABLE about the claim and nothing about when + # it was written: the current brief can produce this shape minutes before the + # reason is read, so "legacy" would be an assertion nothing here observed. + case "$result" in *legacy*) fail "the reason asserted when the claim was written: $result" ;; esac # The verdict token is the whole word after the exit code, so a check for # "verified" must not be satisfied by the "verified" inside "unverified". case "${result#*$'\t'}" in verified:*) fail "a legacy claim was silently upgraded to verified: $result" ;; esac @@ -917,6 +921,39 @@ test_a_configured_verb_vocabulary_is_not_unrecognised() { pass "a home's configured verb vocabulary and the legacy bare lines stay recognised" } +# The pre-validation handoff verb. `done:` was doing two jobs, and under this +# contract only one of them is legitimate: a no-mistakes worker whose brief told +# it to append `done: {summary}` before validating stood on a claim nothing could +# ever establish. `ready:` carries that handoff instead - captain-relevant, so +# firstmate still sees it, but asserting nothing about a shipped commit. +test_the_prevalidation_handoff_is_not_a_claim() { + local dir state + dir="$TMP_ROOT/ready-handoff" + state="$dir/state" + mkdir -p "$state" + printf 'working: implementing\nready: implementation complete and committed\n' > "$state/task-v.status" + fm_done_claim_status "$state" task-v + [ "$FM_DONE_CLAIM_STATE" = none ] \ + || fail "the pre-validation handoff stood as a terminal claim: $FM_DONE_CLAIM_STATE" + [ -z "$FM_DONE_CLAIM_LINE" ] \ + || fail "the pre-validation handoff was returned as a claim line: $FM_DONE_CLAIM_LINE" + + # It has to reach firstmate the way `done:` did, be recognised as a real state + # so the drain does not flag it as prose, and never be read as terminal. + status_is_captain_relevant 'ready: implementation complete and committed' \ + || fail "the handoff verb would not wake firstmate" + status_is_terminal_verb 'ready: implementation complete and committed' \ + && fail "the handoff verb was read as a terminal outcome" + status_line_is_unrecognized 'ready: implementation complete and committed' \ + && fail "the handoff verb was flagged as matching no status verb" + + # The legacy free-text tokens in the same default set are prose, not this verb, + # so the verb earns its captain relevance rather than borrowing theirs. + status_is_captain_relevant 'working: PR ready in branch fm/x' \ + && fail "a nonterminal progress line became captain-relevant from legacy prose" + pass "the pre-validation handoff is captain-relevant, recognised, and never a claim" +} + # The reflog is the branch's own history, but only while its creation entry # survives: `git gc` prunes entries past gc.reflogExpire, and on a long-lived # branch that can leave the oldest SURVIVING entry sitting on the tip. Reading @@ -1117,6 +1154,35 @@ test_a_direct_pr_claim_on_this_task_branch_verifies() { } # --- a report must be this task's own deliverable ---------------------------- +# The binding is on the real LOCATION, not on how the path is spelled. A scout +# that resolves its own deliverable (realpath, or `pwd -P` in its worktree) hands +# back a physical path, and on any platform whose home sits under a symlinked +# component that path is spelled differently from the logical one - so a spelling +# test would record the task's OWN report as established-false. Only the resolved +# comparison may establish falsity; the foreign case below proves it still does. +test_a_scout_claim_spelled_through_a_symlink_is_its_own_report() { + local dir link real result + dir=$(make_world scout-symlinked-home scout scout) + mkdir -p "$dir/data/task-v" + printf 'the findings\n' > "$dir/data/task-v/report.md" + # The home is REACHED through a symlinked component, exactly as a home under + # /tmp is on this platform, while the scout resolves and claims the real path. + link="$TMP_ROOT/scout-symlinked-link" + rm -f "$link" + ln -s "$dir" "$link" + real=$(cd "$link/data/task-v" && pwd -P) || fail "could not resolve the report directory" + [ "$real" != "$link/data/task-v" ] \ + || fail "setup error: the fixture link did not make the spellings differ" + printf 'done: report=%s/report.md - shipped\n' "$real" > "$dir/state/task-v.status" + result=$(verify "$link") + [ "${result%%$'\t'*}" = 0 ] \ + || fail "a scout claim naming its own report by its real path was not verified: $result" + case "${result#*$'\t'}" in + *contradicted*) fail "the task's own report was recorded established-false: $result" ;; + esac + pass "a report named by its resolved path is this task's own, however the home is spelled" +} + test_a_scout_claim_on_another_tasks_report_is_not_verified() { local dir result dir=$(make_world scout-foreign scout scout) @@ -1774,6 +1840,7 @@ test_an_unresolvable_absent_mode_is_unverified_never_contradicted test_help_prints_the_whole_header test_a_close_does_not_invent_a_done_claim test_a_configured_verb_vocabulary_is_not_unrecognised +test_the_prevalidation_handoff_is_not_a_claim test_a_pruned_reflog_is_unverified_not_contradicted test_a_contradiction_needs_the_observation_it_contradicts_with test_a_local_only_claim_on_work_the_branch_never_made_is_not_verified @@ -1781,6 +1848,7 @@ test_a_retired_branch_claim_on_work_never_made_is_not_verified test_a_direct_pr_claim_on_another_branch_is_not_verified test_a_direct_pr_claim_on_this_task_branch_verifies test_a_scout_claim_on_another_tasks_report_is_not_verified +test_a_scout_claim_spelled_through_a_symlink_is_its_own_report test_a_later_failed_line_withdraws_the_claim test_only_a_failed_line_withdraws_the_claim test_only_the_task_itself_may_withdraw_its_claim diff --git a/tests/fm-teardown.test.sh b/tests/fm-teardown.test.sh index c5c58d73ef5..1d156a4b4fb 100755 --- a/tests/fm-teardown.test.sh +++ b/tests/fm-teardown.test.sh @@ -2799,8 +2799,10 @@ test_legacy_claim_without_commit_identity_refuses() { [ "$rc" -ne 0 ] || fail "a legacy claim with no commit identity was torn down as done" assert_grep "could not be established" "$case_dir/stderr" \ "the refusal did not say the claim could not be established" - assert_grep "legacy claim, no commit identity" "$case_dir/stderr" \ + assert_grep "names no commit identity" "$case_dir/stderr" \ "the refusal did not name the missing commit identity" + assert_no_grep "written before this contract" "$case_dir/stderr" \ + "the refusal asserted when the claim was written, which nothing here observed" [ -e "$case_dir/state/task-x1.meta" ] \ || fail "the claim refusal erased the durable task record" pass "a legacy claim with no commit identity refuses cleanup" @@ -2984,12 +2986,14 @@ test_a_stale_record_does_not_carry_a_task_through_cleanup() { # The three-week rot, end to end at the gate: a claim established while its PR # was open, then closed unmerged. The close must have corrected the record, so -# cleanup refuses rather than passing on what the record used to say. +# cleanup refuses rather than passing on what the record used to say. The claim +# NAMES the PR that closed, which is what makes the close evidence about it - +# a close only falsifies a claim that is about the PR whose close was observed. test_a_closed_pr_stops_its_own_done_record_carrying_cleanup() { local case_dir rc=0 head case_dir=$(make_claim_case claim-closed "placeholder") head=$(git -C "$case_dir/wt" rev-parse HEAD) - printf 'done: branch=fm/task-x1 head=%s - shipped\n' "$head" \ + printf 'done: pr=https://github.com/o/r/pull/7 branch=fm/task-x1 head=%s - shipped\n' "$head" \ > "$case_dir/state/task-x1.status" seed_verdict_record "$case_dir" verified 'the PR is open at the claimed head' # shellcheck source=bin/fm-merge-outcome-lib.sh disable=SC1091 From 6741907266d3e7f829693cfb1e87b95c76b47d10 Mon Sep 17 00:00:00 2001 From: Israel Wilson Date: Thu, 3 Sep 2026 19:27:39 -0400 Subject: [PATCH 16/21] no-mistakes(review): anchor status verbs and bucket every live child --- bin/fm-classify-lib.sh | 33 ++++++++++++---- bin/fm-fleet-snapshot.sh | 21 +++++++++-- bin/fm-inactive-reconcile.sh | 7 ++++ docs/configuration.md | 2 +- docs/scripts.md | 2 +- tests/fm-done-verified.test.sh | 49 ++++++++++++++++++++++++ tests/fm-fleet-snapshot-view.test.sh | 56 ++++++++++++++++++++++++++++ tests/fm-inactive-reconcile.test.sh | 15 ++++++++ 8 files changed, 172 insertions(+), 13 deletions(-) diff --git a/bin/fm-classify-lib.sh b/bin/fm-classify-lib.sh index 693d4f360ad..2934a933ab9 100755 --- a/bin/fm-classify-lib.sh +++ b/bin/fm-classify-lib.sh @@ -70,14 +70,31 @@ unset _fm_classify_nounset # its away-mode classification. FM_CAPTAIN_RE overrides the whole set when a home # needs a custom verb vocabulary; absent, this default applies. # -# Free-text tokens (PR ready, checks green, ready in branch, merged) exist only for -# legacy lines that lack a standard terminal verb. status_is_captain_relevant is -# verb-aware: a nonterminal working: or paused: line never becomes captain-relevant -# merely because its prose contains one of those tokens (for example -# "working: rebased onto merged #76"). The legacy tokens `PR ready` and -# `ready in branch` are prose, not the `ready:` verb below, and neither matches a -# bare `ready:` line - the verb earns its captain relevance in its own right. -FM_CLASSIFY_CAPTAIN_RE_DEFAULT='done:|ready:|needs-decision:|blocked:|failed:|PR ready|checks green|ready in branch|merged' +# Two kinds of token live in this set and they are matched differently, because +# they earn their relevance differently. +# +# VERB tokens are ANCHORED to the start of the line (leading whitespace allowed, +# since a status line may be indented and fm_done_claim_last already tolerates +# that). A verb means something only in the leading position it is documented to +# occupy; matched anywhere, it turns worker prose into a state. That is not +# theoretical - "- integration suite ready: green" and "rebased, done: nothing +# left" both matched here unanchored, and because status_line_verb_is_known +# defers to this function as the owner of the configurable vocabulary, such a +# line counted as KNOWN, dropped out of the UNREAD STATUS surface, and woke +# firstmate while classifying as nothing. That silent absorption is exactly what +# the unrecognised-line surface exists to end, so no verb token is left +# unanchored: a twin one line away is how a rule comes to be enforced in one +# spelling and not its neighbour. +# +# FREE-TEXT tokens (PR ready, checks green, ready in branch, merged) stay +# unanchored, because they exist only for legacy lines that lack a standard +# leading verb - they describe prose and have no leading position to earn. +# +# status_is_captain_relevant is verb-aware on top of both: a nonterminal working: +# or paused: line never becomes captain-relevant merely because its prose +# contains one of those free-text tokens (for example "working: rebased onto +# merged #76"). +FM_CLASSIFY_CAPTAIN_RE_DEFAULT='^[[:space:]]*done:|^[[:space:]]*ready:|^[[:space:]]*needs-decision:|^[[:space:]]*blocked:|^[[:space:]]*failed:|PR ready|checks green|ready in branch|merged' # The PRE-VALIDATION HANDOFF verb. A no-mistakes worker appends # ready: diff --git a/bin/fm-fleet-snapshot.sh b/bin/fm-fleet-snapshot.sh index 7d1ee6d377c..6e078753e49 100755 --- a/bin/fm-fleet-snapshot.sh +++ b/bin/fm-fleet-snapshot.sh @@ -780,17 +780,32 @@ secondmate_home_summary_json() { # reason:((.hold_reason // .blocked_reason // "blocked") | trunc(120)),source:"backlog"} ] + [ $owned_in_flight[] as $work | $tasks[] - | select(.id == $work.id and (.current_state.state == "parked" or .current_state.state == "paused" or .current_state.state == "blocked")) + | select(.id == $work.id and (.current_state.state == "parked" or .current_state.state == "paused" or .current_state.state == "blocked" or .current_state.state == "ready")) | select(($work.hold_reason != null and $work.hold_kind != null) | not) | {id,title:((.backlog.title // .id) | trunc(90)),blocked_by:null, blocked_by_ids:[],unresolved_blocker_ids:[], reason:((.current_state.detail // .current_state.state) | trunc(120)),source:"child-state"} ]) as $holds_all + | (([ $active_all[].id ] + [ $holds_all[].id ] + [ $terminal_in_flight[].id ] + + [ $unknown_children[].id ] + [ $unowned_children[].id ] + + [ $owned_in_flight[] | select(.hold_reason != null and .hold_kind != null) | .id ]) + | unique) as $bucketed_ids + | ([ $tasks[] + | select(.kind != "secondmate") + | select(.id as $id | $bucketed_ids | index($id) | not) + | {id,state:.current_state.state} ]) as $unbucketed_current + | ($strict_invalidities + + (if ($unbucketed_current | length) > 0 then + [{kind:"unbucketed_current",ids:($unbucketed_current | map(.id)), + reason:("live child state belongs to no summary bucket: " + + ($unbucketed_current | map(.id + "=" + .state) | join(", ")))}] + else [] end)) as $strict_invalidities | ($backlog.present == true and ($unstructured_current | length) == 0 and ($unknown_children | length) == 0 and ($orphan_in_flight | length) == 0 and ($unowned_children | length) == 0 - and ($terminal_in_flight | length) == 0) as $valid + and ($terminal_in_flight | length) == 0 + and ($unbucketed_current | length) == 0) as $valid | (if ($strict_invalidities | length) > 0 then $strict_invalidities[0].reason elif ($unknown_children | length) > 0 then "child current state unavailable: " + ($unknown_children | map(.id) | join(", ")) @@ -800,7 +815,7 @@ secondmate_home_summary_json() { # else {kind:null,ids:[]} end) as $invalidity | (if ($valid | not) and (($unknown_children | length) > 0 - or (["orphan_in_flight","unowned_current","terminal_in_flight"] + or (["orphan_in_flight","unowned_current","terminal_in_flight","unbucketed_current"] | index($invalidity.kind) | not)) then "unknown" elif any($decisions_all[]; .verb == "needs-decision" or .verb == "captain-hold") then "captain_decision" diff --git a/bin/fm-inactive-reconcile.sh b/bin/fm-inactive-reconcile.sh index 91f353b5acd..e67547dee7a 100755 --- a/bin/fm-inactive-reconcile.sh +++ b/bin/fm-inactive-reconcile.sh @@ -532,10 +532,17 @@ reconcile_direct_child_locked() { # "$home/data/backlog.md" <<'EOF' +## In flight +- [ ] handoff-ship - Implementation handed off (repo: alpha) (kind: ship) (since 2026-07-11) + +## Queued + +## Done +EOF + fm_write_meta "$home/state/handoff-ship.meta" \ + "window=firstmate:fm-handoff-ship" \ + "worktree=$home/projects/handoff" \ + "project=alpha" \ + "harness=claude" \ + "kind=ship" \ + "mode=no-mistakes" + record_claude_idle "$home/state" handoff-ship + printf 'ready: implementation complete and committed\n' > "$home/state/handoff-ship.status" + fakebin=$(make_fakebin "$home") + out=$(PATH="$fakebin:$PATH" FM_HOME="$home" "$SNAPSHOT" --secondmate-home-summary) + printf '%s' "$out" | jq -e ' + .valid == true + and .invalidity == {kind:null,ids:[]} + and (.holds | any(.id == "handoff-ship")) + ' >/dev/null || fail "a child waiting for firstmate to act belonged to no bucket: $out" + + # A state no bucket claims must be loud. The fixture reaches one the only way + # a real deployment could - by running a build of the tree whose current-state + # reader emits it - so the guard is exercised end to end rather than mocked. + root="$home/test-root" + mkdir -p "$root" + cp -R "$ROOT/bin" "$root/bin" + sed -i.bak 's/^ \*) echo unknown ;;$/ *) echo novel-state ;;/' \ + "$root/bin/fm-crew-state.sh" + rm -f "$root/bin/fm-crew-state.sh.bak" + printf 'note: a line whose verb maps to no bucket\n' > "$home/state/handoff-ship.status" + out=$(PATH="$fakebin:$PATH" FM_HOME="$home" "$root/bin/fm-fleet-snapshot.sh" --secondmate-home-summary) + printf '%s' "$out" | jq -e ' + .valid == false + and .invalidity.kind == "unbucketed_current" + and (.invalidity.ids == ["handoff-ship"]) + and (.reason | contains("handoff-ship=novel-state")) + ' >/dev/null || fail "a state no bucket claims did not surface as an invalidity: $out" + pass "the home summary buckets every live child and names any state no bucket claims" +} + # Home-summary validity treats persistent secondmates as registered homes, not # in-flight children. They have no backlog rows, so they must not produce # unowned_current or terminal_in_flight. Ordinary crew/ship metas still do. @@ -911,6 +966,7 @@ EOF test_empty_fleet_json test_fixture_snapshot_json test_home_summary_excludes_secondmate_from_child_inventory +test_home_summary_buckets_every_live_child test_main_inventory_orphan_and_unstructured_disclosure test_normalized_roles_and_plural_blocker_readiness test_event_hints_follow_reconciled_current_state diff --git a/tests/fm-inactive-reconcile.test.sh b/tests/fm-inactive-reconcile.test.sh index 05ad6eafbc5..b88612dc72b 100755 --- a/tests/fm-inactive-reconcile.test.sh +++ b/tests/fm-inactive-reconcile.test.sh @@ -285,6 +285,20 @@ test_a_keyed_phase_close_is_not_the_childs_terminal_outcome() { pass "a closed routed phase is neither delivered as the child's outcome nor allowed to silence the backstop" } +# The pre-validation handoff has a backstop too. A stopped child that has handed +# off for validation is waiting on a supervisor action nobody has taken, which is +# what this backstop exists to catch; the same handoff reached it as a +# pre-validation `done:` before it had a verb of its own, and giving it one must +# not quietly remove the safety net behind it. +test_a_stopped_ready_child_reaches_the_backstop() { + make_world ready-backstop; bind_secondmate local + write_child "$MATE" child 'ready: implementation complete and committed' + FM_FAKE_CREW_STATE='ready' run_reconcile "$MATE" --startup + grep -q 'inactive-outcome-' "$MAIN/state/mate.status" \ + || fail "a stopped child waiting for validation was never reported upward: $(cat "$MAIN/state/mate.status" 2>/dev/null)" + pass "a stopped child that handed off for validation still reaches the backstop" +} + # A busy child cannot keep later ledger outcomes from being visited, and is # retried on the next poll after its lifecycle lock becomes available. test_busy_child_does_not_starve_later_ledger_outcomes() { @@ -886,6 +900,7 @@ test_local_secondmate_delivers_terminal_ledger_line test_ledger_delivery_downgrades_an_unestablished_claim test_a_later_established_claim_corrects_the_parents_record test_a_keyed_phase_close_is_not_the_childs_terminal_outcome +test_a_stopped_ready_child_reaches_the_backstop test_busy_child_does_not_starve_later_ledger_outcomes test_secondmate_ledger_delivery_carries_report_and_failure test_terminal_line_during_state_read_yields_to_ledger_delivery From 84ba0fdf7f7e043cf2b8e1d2fe6490e0c6563e37 Mon Sep 17 00:00:00 2001 From: Israel Wilson Date: Thu, 3 Sep 2026 19:46:45 -0400 Subject: [PATCH 17/21] no-mistakes(review): account for deliberate exclusions and own invalidity policy --- bin/fm-fleet-snapshot.sh | 70 ++++++++++++++--- tests/fm-fleet-snapshot-view.test.sh | 109 +++++++++++++++++++++++++++ 2 files changed, 169 insertions(+), 10 deletions(-) diff --git a/bin/fm-fleet-snapshot.sh b/bin/fm-fleet-snapshot.sh index 6e078753e49..5d70e8ef76c 100755 --- a/bin/fm-fleet-snapshot.sh +++ b/bin/fm-fleet-snapshot.sh @@ -120,6 +120,39 @@ FM_SNAPSHOT_SECONDMATE_MAX_BYTES=${FM_SNAPSHOT_SECONDMATE_MAX_BYTES:-262144} FM_SNAPSHOT_SECONDMATE_CHILDREN=${FM_SNAPSHOT_SECONDMATE_CHILDREN:-20} FM_SNAPSHOT_SECONDMATE_QUEUED=${FM_SNAPSHOT_SECONDMATE_QUEUED:-20} FM_SNAPSHOT_SECONDMATE_DECISIONS=${FM_SNAPSHOT_SECONDMATE_DECISIONS:-20} + +# THE ONE OWNER of what each home-summary invalidity reason MEANS to the two +# consumers that act on it. Both read this table rather than listing reasons +# themselves, because the two lists drifted apart the moment one of them gained +# an entry: a reason added to the state-`unknown` exemption and not to the +# parent sampler's tolerance made a parent discard a whole home's ledger over +# one flag. +# +# parent_tolerates - a parent sampling this home still SAMPLES it and +# keeps its ledger (active_children, decisions_open, +# holds, queued, landed, endpoints, counts). True for +# data-completeness observations about individual +# children, which say something is unaccounted for, +# not that the home's structure is unreadable. +# False for reasons that make the structure itself +# untrustworthy, where a retained ledger would be a +# record nothing supports. +# state_unknown_exempt - the summary's own `state` is still computed from +# the buckets rather than collapsing to `unknown`. +# +# A reason absent from this table is treated as neither tolerated nor exempt, +# which is the conservative direction; add it here when you add it to the +# emitter, and both consumers follow at once. +FM_SNAPSHOT_INVALIDITY_POLICY='{ + "missing_backlog": {"parent_tolerates": false, "state_unknown_exempt": false}, + "unstructured_current": {"parent_tolerates": false, "state_unknown_exempt": false}, + "child_current_unavailable": {"parent_tolerates": true, "state_unknown_exempt": false}, + "orphan_in_flight": {"parent_tolerates": true, "state_unknown_exempt": true}, + "unowned_current": {"parent_tolerates": true, "state_unknown_exempt": true}, + "terminal_in_flight": {"parent_tolerates": true, "state_unknown_exempt": true}, + "unbucketed_current": {"parent_tolerates": true, "state_unknown_exempt": true} +}' + FM_SNAPSHOT_TERMINAL_LINES=${FM_SNAPSHOT_TERMINAL_LINES:-8} FM_SNAPSHOT_TERMINAL_BYTES=${FM_SNAPSHOT_TERMINAL_BYTES:-4096} FM_SNAPSHOT_TERMINAL_TIMEOUT=${FM_SNAPSHOT_TERMINAL_TIMEOUT:-2} @@ -700,6 +733,7 @@ secondmate_home_summary_json() { # --argjson decisions_n "$FM_SNAPSHOT_SECONDMATE_DECISIONS" \ --argjson landed_n "$FM_SNAPSHOT_SECONDMATE_LANDED_PER_HOME" \ --argjson backlog "$1" \ + --argjson invalidity_policy "$FM_SNAPSHOT_INVALIDITY_POLICY" \ --argjson tasks "$2" ' def trunc($n): tostring | gsub("\\s+"; " ") @@ -785,18 +819,35 @@ secondmate_home_summary_json() { # | {id,title:((.backlog.title // .id) | trunc(90)),blocked_by:null, blocked_by_ids:[],unresolved_blocker_ids:[], reason:((.current_state.detail // .current_state.state) | trunc(120)),source:"child-state"} ]) as $holds_all + # Rows the buckets above leave out ON PURPOSE, each by a rule stated here. + # A deliberate exclusion is ACCOUNTED FOR - somebody classified the row and + # decided it has no bucket - which is a different thing from a row nobody + # classified, and the guard below can only tell them apart if every such + # rule declares itself. Adding an exclusion to a bucket without adding it + # here turns it into a false fall-through, which is exactly how a + # program-role child came to be reported as an inconsistency that did not + # exist. Do not answer a fall-through by inventing a bucket instead: that + # hides the exclusion rather than stating it. + # - a program-role in-flight row is outside $active_all because a program + # is not active child work; + # - an in-flight row whose hold the backlog already reports is outside the + # $holds_all child-state arm so one hold is not listed twice; the + # backlog arm reports it. + | (([ $owned_in_flight[] | select(.current_role == "program") | .id ] + + [ $owned_in_flight[] | select(.hold_reason != null and .hold_kind != null) | .id ]) + | unique) as $accounted_exclusions | (([ $active_all[].id ] + [ $holds_all[].id ] + [ $terminal_in_flight[].id ] + [ $unknown_children[].id ] + [ $unowned_children[].id ] - + [ $owned_in_flight[] | select(.hold_reason != null and .hold_kind != null) | .id ]) - | unique) as $bucketed_ids + + $accounted_exclusions) + | unique) as $accounted_ids | ([ $tasks[] | select(.kind != "secondmate") - | select(.id as $id | $bucketed_ids | index($id) | not) + | select(.id as $id | $accounted_ids | index($id) | not) | {id,state:.current_state.state} ]) as $unbucketed_current | ($strict_invalidities + (if ($unbucketed_current | length) > 0 then [{kind:"unbucketed_current",ids:($unbucketed_current | map(.id)), - reason:("live child state belongs to no summary bucket: " + + reason:("live child state is accounted for by nothing: " + ($unbucketed_current | map(.id + "=" + .state) | join(", ")))}] else [] end)) as $strict_invalidities | ($backlog.present == true @@ -815,8 +866,7 @@ secondmate_home_summary_json() { # else {kind:null,ids:[]} end) as $invalidity | (if ($valid | not) and (($unknown_children | length) > 0 - or (["orphan_in_flight","unowned_current","terminal_in_flight","unbucketed_current"] - | index($invalidity.kind) | not)) + or (($invalidity_policy[$invalidity.kind // ""].state_unknown_exempt // false) | not)) then "unknown" elif any($decisions_all[]; .verb == "needs-decision" or .verb == "captain-hold") then "captain_decision" elif ($active_all | length) > 0 then "active_child_work" @@ -1532,10 +1582,10 @@ secondmate_current_json() { # if [ "$summary_valid" != true ]; then summary_reason=$(printf '%s' "$summary" | jq -r '.reason // "unknown reason"') summary_invalidity=$(printf '%s' "$summary" | jq -r '.invalidity.kind // "unknown"') - case "$summary_invalidity" in - child_current_unavailable|orphan_in_flight|unowned_current|terminal_in_flight) : ;; - *) reason="structured home state invalid: $summary_reason" ;; - esac + if [ "$(printf '%s' "$FM_SNAPSHOT_INVALIDITY_POLICY" \ + | jq -r --arg k "$summary_invalidity" '.[$k].parent_tolerates // false')" != true ]; then + reason="structured home state invalid: $summary_reason" + fi fi fi diff --git a/tests/fm-fleet-snapshot-view.test.sh b/tests/fm-fleet-snapshot-view.test.sh index 030fcf6839f..a32bdc7e69d 100755 --- a/tests/fm-fleet-snapshot-view.test.sh +++ b/tests/fm-fleet-snapshot-view.test.sh @@ -866,6 +866,113 @@ EOF pass "the home summary buckets every live child and names any state no bucket claims" } +# A row the buckets leave out ON PURPOSE is accounted for, not missing. The +# exhaustive guard asks "did anyone classify this row", and a program-role +# in-flight row is deliberately outside $active_all because a program is not +# active child work - so it must not read as an inconsistency that does not +# exist. A row nobody classified must still surface, which is what stops this +# from being a way to silence the guard. +test_a_deliberate_exclusion_is_accounted_for_not_unbucketed() { + local home fakebin out + home=$(make_home summary-program-role) + mkdir -p "$home/projects/program" + cat > "$home/data/backlog.md" <<'EOF' +## In flight +- [ ] prog-task - Aggregate program (repo: alpha) (kind: program) (since 2026-07-11) + +## Queued + +## Done +EOF + fm_write_meta "$home/state/prog-task.meta" \ + "window=firstmate:fm-prog-task" \ + "worktree=$home/projects/program" \ + "project=alpha" \ + "harness=claude" \ + "kind=ship" \ + "mode=no-mistakes" + printf 'working: aggregating\n' > "$home/state/prog-task.status" + local gen + gen=$("$ROOT/bin/fm-busy-event.sh" arm "$home/state" prog-task) + "$ROOT/bin/fm-busy-event.sh" apply "$home/state" prog-task busy --gen "$gen" \ + --source claude-hook --event user-prompt-submit + fakebin=$(make_fakebin "$home") + out=$(PATH="$fakebin:$PATH" FM_HOME="$home" "$SNAPSHOT" --secondmate-home-summary) + printf '%s' "$out" | jq -e ' + .valid == true + and .invalidity == {kind:null,ids:[]} + and .reason == null + ' >/dev/null || fail "a deliberately excluded program-role child was reported as an inconsistency: $out" + pass "a deliberately excluded row is accounted for and raises no invalidity" +} + +# What each invalidity reason MEANS to its consumers has one owner, and this +# drives both consumers through the parent snapshot. A data-completeness +# observation about one child must not cost the parent the whole home's ledger: +# discarding active_children, decisions_open, holds, queued and landed over one +# unclassified row is disproportionate to the point of making the guard net +# harmful. A reason that makes the home's structure itself untrustworthy still +# discards it, which is what stops "tolerated" from meaning "ignored". +test_a_parent_tolerates_a_data_level_invalidity_but_not_a_structural_one() { + local home mate fakebin ledger out + home=$(make_home summary-parent-tolerance) + mate=$(make_home summary-parent-tolerance-mate) + printf 'mate\n' > "$mate/.fm-secondmate-home" + printf '# Fixture home\n' > "$mate/AGENTS.md" + mkdir -p "$mate/bin" + mkdir -p "$mate/state" "$mate/data" "$mate/config" "$mate/projects" + cat > "$mate/data/backlog.md" <<'EOF' +## In flight + +## Queued + +## Done +EOF + cat > "$home/data/backlog.md" <<'EOF' +## In flight + +## Queued + +## Done +EOF + printf -- '- mate - fixture domain (home: %s; scope: fixture work; projects: alpha; added 2026-08-28)\n' \ + "$mate" > "$home/data/secondmates.md" + fm_write_secondmate_meta "$home/state/mate.meta" "$mate" "fmtest:fm-mate" alpha claude + fakebin=$(make_fakebin "$home") + ledger=$(PATH="$fakebin:$PATH" FM_HOME="$mate" "$SNAPSHOT" --secondmate-home-summary) \ + || fail "could not build the mate's own ledger" + + printf '%s' "$ledger" | jq '.valid = false + | .reason = "live child state is accounted for by nothing: odd-task=novel-state" + | .invalidity = {kind:"unbucketed_current",ids:["odd-task"]} + | .holds = [{id:"held-task",title:"Held",blocked_by:null,blocked_by_ids:[], + unresolved_blocker_ids:[],reason:"waiting",source:"child-state"}] + | .counts.holds = 1' > "$mate/state/home-summary.json" + out=$(PATH="$fakebin:$PATH" FM_HOME="$home" "$SNAPSHOT" --json) \ + || fail "the parent snapshot failed over a tolerated invalidity" + printf '%s' "$out" | jq -e ' + .secondmate_current.records[0].provenance.selected == "structured-home" + and (.secondmate_current.records[0].holds | length) == 1 + and .secondmate_current.records[0].reconcile_inventory.kind == "unbucketed_current" + ' >/dev/null \ + || fail "a tolerated data-level invalidity cost the parent the home's ledger: $(printf '%s' "$out" | jq -c '.secondmate_current.records[0]')" + + printf '%s' "$ledger" | jq '.valid = false + | .reason = "missing structured backlog" + | .invalidity = {kind:"missing_backlog",ids:[]} + | .holds = [{id:"held-task",title:"Held",blocked_by:null,blocked_by_ids:[], + unresolved_blocker_ids:[],reason:"waiting",source:"child-state"}] + | .counts.holds = 1' > "$mate/state/home-summary.json" + out=$(PATH="$fakebin:$PATH" FM_HOME="$home" "$SNAPSHOT" --json) \ + || fail "the parent snapshot failed over a structural invalidity" + printf '%s' "$out" | jq -e ' + .secondmate_current.records[0].provenance.selected != "structured-home" + and (.secondmate_current.records[0].holds | length) == 0 + ' >/dev/null \ + || fail "a structural invalidity was sampled as though the home were readable: $(printf '%s' "$out" | jq -c '.secondmate_current.records[0]')" + pass "a parent keeps a home's ledger over a data-level invalidity and discards it over a structural one" +} + # Home-summary validity treats persistent secondmates as registered homes, not # in-flight children. They have no backlog rows, so they must not produce # unowned_current or terminal_in_flight. Ordinary crew/ship metas still do. @@ -967,6 +1074,8 @@ test_empty_fleet_json test_fixture_snapshot_json test_home_summary_excludes_secondmate_from_child_inventory test_home_summary_buckets_every_live_child +test_a_deliberate_exclusion_is_accounted_for_not_unbucketed +test_a_parent_tolerates_a_data_level_invalidity_but_not_a_structural_one test_main_inventory_orphan_and_unstructured_disclosure test_normalized_roles_and_plural_blocker_readiness test_event_hints_follow_reconciled_current_state From 59debad209168d7c5f243a16098d39c767a014ab Mon Sep 17 00:00:00 2001 From: Israel Wilson Date: Thu, 3 Sep 2026 20:43:38 -0400 Subject: [PATCH 18/21] no-mistakes(review): bring the ready handoff under the speaker test --- bin/fm-crew-state.sh | 27 ++++++++++++--- bin/fm-fleet-snapshot.sh | 13 +++++-- bin/fm-inactive-reconcile.sh | 28 ++++++++++++--- tests/fm-crew-state.test.sh | 18 ++++++++++ tests/fm-fleet-snapshot-view.test.sh | 22 +++++++++++- tests/fm-inactive-reconcile.test.sh | 52 ++++++++++++++++++++++++++++ 6 files changed, 147 insertions(+), 13 deletions(-) diff --git a/bin/fm-crew-state.sh b/bin/fm-crew-state.sh index 59b405b8190..3e0581e8f66 100755 --- a/bin/fm-crew-state.sh +++ b/bin/fm-crew-state.sh @@ -152,7 +152,7 @@ log_last_line() { # reports `paused` distinctly, so a supervisor reading this sees a declared pause # and its reason rather than a wedge-suspect idle. map_log_state() { # - local terminal + local terminal trimmed if status_is_paused "$1"; then echo paused return @@ -161,9 +161,18 @@ map_log_state() { # # own voice; bin/fm-done-claim-lib.sh owns that question for every reader. # A routed phase closing with `done [key=]` says one sub-event finished, # not that the task did, and reading it as `done` would report a whole task - # complete on a line that never claimed as much. The non-terminal verbs below - # stay deliberately key-blind: a routed phase that is blocked or paused really - # is a supervision concern of the task it belongs to. + # complete on a line that never claimed as much. + # + # The handoff verb is under the same speaker test, for the same reason rather + # than a new one: `ready` says THIS worker committed an implementation and is + # waiting to be told to validate it, which is a statement about the task + # itself. A secondmate publishing a child's handoff upward writes + # `ready [key=inactive-outcome-...]` into the parent's status, and read + # key-blind that would make the mate task claim a handoff it never made. + # + # `blocked`, `needs-decision` and the paused verb stay deliberately key-blind, + # unchanged: a routed phase that is blocked or paused really is a supervision + # concern of the task it belongs to. if terminal=$(fm_done_claim_own_terminal_verb "$1"); then echo "$terminal" return @@ -172,7 +181,15 @@ map_log_state() { # working) echo working ;; needs-decision) echo parked ;; blocked) echo blocked ;; - "${FM_CLASSIFY_READY_VERB:-$FM_CLASSIFY_READY_VERB_DEFAULT}") echo ready ;; + "${FM_CLASSIFY_READY_VERB:-$FM_CLASSIFY_READY_VERB_DEFAULT}") + trimmed=${1#"${1%%[![:space:]]*}"} + if fm_done_claim_own_voice "$trimmed" \ + "${FM_CLASSIFY_READY_VERB:-$FM_CLASSIFY_READY_VERB_DEFAULT}"; then + echo ready + else + echo unknown + fi + ;; done|failed) echo unknown ;; *) echo unknown ;; esac diff --git a/bin/fm-fleet-snapshot.sh b/bin/fm-fleet-snapshot.sh index 5d70e8ef76c..27c3157299d 100755 --- a/bin/fm-fleet-snapshot.sh +++ b/bin/fm-fleet-snapshot.sh @@ -828,12 +828,19 @@ secondmate_home_summary_json() { # # program-role child came to be reported as an inconsistency that did not # exist. Do not answer a fall-through by inventing a bucket instead: that # hides the exclusion rather than stating it. - # - a program-role in-flight row is outside $active_all because a program - # is not active child work; + # - a program-role in-flight row in the `working` state is outside + # $active_all because a program is not active child work. The exclusion + # is exactly as narrow as that reason: in any other state the row is + # bucketed like any child, so the guard is not permanently blind to it + # and the next state token added cannot be silently absorbed here; # - an in-flight row whose hold the backlog already reports is outside the # $holds_all child-state arm so one hold is not listed twice; the # backlog arm reports it. - | (([ $owned_in_flight[] | select(.current_role == "program") | .id ] + | (([ $owned_in_flight[] as $work + | select($work.current_role == "program") + | $tasks[] + | select(.id == $work.id and .current_state.state == "working") + | .id ] + [ $owned_in_flight[] | select(.hold_reason != null and .hold_kind != null) | .id ]) | unique) as $accounted_exclusions | (([ $active_all[].id ] + [ $holds_all[].id ] + [ $terminal_in_flight[].id ] diff --git a/bin/fm-inactive-reconcile.sh b/bin/fm-inactive-reconcile.sh index e67547dee7a..2a2f18a1c79 100755 --- a/bin/fm-inactive-reconcile.sh +++ b/bin/fm-inactive-reconcile.sh @@ -330,13 +330,24 @@ home_secondmate_id() { fm_parent_channel_home_id "$FM_HOME" } +# 0 when this inactive report is about the PRE-VALIDATION HANDOFF rather than a +# terminal outcome. `ready` is defined as deliberately not terminal +# (bin/fm-classify-lib.sh owns the verb, and status_is_terminal_verb says no to +# it), so every line this backstop writes about it has to say what was actually +# observed: a worker that stopped waiting on firstmate, not work that finished. +inactive_report_is_handoff() { # + [ "$1" = "${FM_CLASSIFY_READY_VERB:-$FM_CLASSIFY_READY_VERB_DEFAULT}" ] +} + report_to_parent() { # local task=$1 state=$2 outcome_key=$3 fingerprint=$4 pr=$5 line # The parent channel is a status stream, so this line must lead with a verb the # parent's classifier knows. A terminal state that is not one - an unverified # claim - is a blocker for the parent rather than a completion, and names its # real state in the note instead of being reported as done. - if status_line_verb_is_known "$state:"; then + if inactive_report_is_handoff "$state"; then + line="$state [key=$outcome_key]: inactive handoff waiting on firstmate child=$task fingerprint=$fingerprint" + elif status_line_verb_is_known "$state:"; then line="$state [key=$outcome_key]: inactive terminal child=$task fingerprint=$fingerprint" else line="blocked [key=$outcome_key]: inactive terminal child=$task state=$state fingerprint=$fingerprint" @@ -559,13 +570,22 @@ reconcile_direct_child_locked() { # /dev/null + fm_write_meta "$d/state/feat-rk.meta" "window=fm:fm-feat-rk" "worktree=$d/wt" "kind=ship" "harness=claude" + printf 'ready [key=inactive-outcome-mate-child-ready]: inactive handoff waiting on firstmate child=child\n' \ + > "$d/state/feat-rk.status" + FM_FAKE_AXI_STATUS="" + FM_FAKE_BUSY=0 + arm_idle_record "$d/state" feat-rk + out=$(run_crew_state "$d" feat-rk) + assert_not_contains "$out" "state: ready" "a child's handoff became the mate task's own state" + reset_fakes d=$(new_case ready-then-passed) make_repo_on_branch "$d/wt" fm/feat-rp diff --git a/tests/fm-fleet-snapshot-view.test.sh b/tests/fm-fleet-snapshot-view.test.sh index a32bdc7e69d..375c0a1e447 100755 --- a/tests/fm-fleet-snapshot-view.test.sh +++ b/tests/fm-fleet-snapshot-view.test.sh @@ -903,7 +903,27 @@ EOF and .invalidity == {kind:null,ids:[]} and .reason == null ' >/dev/null || fail "a deliberately excluded program-role child was reported as an inconsistency: $out" - pass "a deliberately excluded row is accounted for and raises no invalidity" + + # The exclusion is exactly as narrow as its reason. It covers `working`, which + # is the only state $active_all claims, so a program-role row in a state no + # bucket claims must still surface - otherwise the guard is permanently blind + # to program rows and the next state token added is absorbed here in silence. + local root + root="$home/test-root" + mkdir -p "$root" + cp -R "$ROOT/bin" "$root/bin" + sed -i.bak 's/^ \*) echo unknown ;;$/ *) echo novel-state ;;/' \ + "$root/bin/fm-crew-state.sh" + rm -f "$root/bin/fm-crew-state.sh.bak" + printf 'note: a line whose verb maps to no bucket\n' > "$home/state/prog-task.status" + record_claude_idle "$home/state" prog-task + out=$(PATH="$fakebin:$PATH" FM_HOME="$home" "$root/bin/fm-fleet-snapshot.sh" --secondmate-home-summary) + printf '%s' "$out" | jq -e ' + .valid == false + and .invalidity.kind == "unbucketed_current" + and (.invalidity.ids == ["prog-task"]) + ' >/dev/null || fail "the guard stayed blind to a program-role row in an unclassified state: $out" + pass "a deliberate exclusion is accounted for only in the state its reason covers" } # What each invalidity reason MEANS to its consumers has one owner, and this diff --git a/tests/fm-inactive-reconcile.test.sh b/tests/fm-inactive-reconcile.test.sh index b88612dc72b..26cf460c907 100755 --- a/tests/fm-inactive-reconcile.test.sh +++ b/tests/fm-inactive-reconcile.test.sh @@ -299,6 +299,56 @@ test_a_stopped_ready_child_reaches_the_backstop() { pass "a stopped child that handed off for validation still reaches the backstop" } +# What the backstop SAYS about a handoff has to be what it observed. `ready` is +# defined as pre-terminal, so a line calling it a terminal outcome would be the +# false narration this change has corrected repeatedly - and a genuinely terminal +# child must keep being described as one, so the wording is not merely softened +# everywhere. +test_a_handoff_report_is_not_narrated_as_terminal() { + local line + make_world handoff-narration; bind_secondmate local + write_child "$MATE" child 'working: still implementing' + FM_FAKE_CREW_STATE='ready' run_reconcile "$MATE" --startup + line=$(grep 'inactive-outcome-' "$MAIN/state/mate.status" 2>/dev/null | tail -1) + [ -n "$line" ] || fail "the handoff was never reported upward: $(cat "$MAIN/state/mate.status" 2>/dev/null)" + case "$line" in + *"waiting on firstmate"*) ;; + *) fail "the handoff report did not say what was observed: $line" ;; + esac + case "$line" in + *terminal*) fail "the handoff was narrated as a terminal outcome: $line" ;; + esac + + make_world terminal-narration; bind_secondmate local + write_child "$MATE" child 'working: still implementing' + FM_FAKE_CREW_STATE='failed' run_reconcile "$MATE" --startup + line=$(grep 'inactive-outcome-' "$MAIN/state/mate.status" 2>/dev/null | tail -1) + [ -n "$line" ] || fail "the terminal outcome was never reported upward: $(cat "$MAIN/state/mate.status" 2>/dev/null)" + case "$line" in + *terminal*) ;; + *) fail "a genuinely terminal child stopped being described as terminal: $line" ;; + esac + pass "a handoff report says what was observed while a terminal outcome still reads as terminal" +} + +# The captain presentation carries the same distinction, on the main-home path. +test_a_handoff_presentation_is_not_narrated_as_terminal() { + local payload + make_world handoff-presentation + write_child "$MAIN" child 'working: still implementing' + FM_FAKE_CREW_STATE='ready' run_reconcile "$MAIN" --startup + payload=$(grep -o 'inactive [^|]*' "$MAIN/state/.wake-queue" 2>/dev/null | tail -1) + [ -n "$payload" ] || fail "the handoff was never queued for presentation: $(cat "$MAIN/state/.wake-queue" 2>/dev/null)" + case "$payload" in + *"waiting on firstmate"*) ;; + *) fail "the queued handoff did not say what was observed: $payload" ;; + esac + case "$payload" in + *terminal*) fail "the queued handoff was narrated as a terminal outcome: $payload" ;; + esac + pass "a queued handoff presentation says what was observed rather than calling it terminal" +} + # A busy child cannot keep later ledger outcomes from being visited, and is # retried on the next poll after its lifecycle lock becomes available. test_busy_child_does_not_starve_later_ledger_outcomes() { @@ -901,6 +951,8 @@ test_ledger_delivery_downgrades_an_unestablished_claim test_a_later_established_claim_corrects_the_parents_record test_a_keyed_phase_close_is_not_the_childs_terminal_outcome test_a_stopped_ready_child_reaches_the_backstop +test_a_handoff_report_is_not_narrated_as_terminal +test_a_handoff_presentation_is_not_narrated_as_terminal test_busy_child_does_not_starve_later_ledger_outcomes test_secondmate_ledger_delivery_carries_report_and_failure test_terminal_line_during_state_read_yields_to_ledger_delivery From 393d4be4d67fc0791e824d2d4e0d58a9e9a450a3 Mon Sep 17 00:00:00 2001 From: Israel Wilson Date: Thu, 3 Sep 2026 22:06:57 -0400 Subject: [PATCH 19/21] no-mistakes(document): refresh docs stale under the terminal-claim gate --- .agents/skills/firstmate-codexapp/SKILL.md | 3 +- AGENTS.md | 3 +- bin/fm-inactive-reconcile.sh | 28 ++++++++++++------- docs/architecture.md | 3 +- docs/gitlab-merge-watch.md | 4 +-- docs/secondmate-parent-channel.md | 2 ++ .../verification/secondmate-parent-channel.md | 4 +++ 7 files changed, 32 insertions(+), 15 deletions(-) diff --git a/.agents/skills/firstmate-codexapp/SKILL.md b/.agents/skills/firstmate-codexapp/SKILL.md index 6428439639a..2452bb03e0a 100644 --- a/.agents/skills/firstmate-codexapp/SKILL.md +++ b/.agents/skills/firstmate-codexapp/SKILL.md @@ -61,8 +61,9 @@ For a Firstmate-managed task, include an explicit status instruction: ```text Append supervisor-visible status lines to /state/.status. -Use only these prefixes for status changes: working:, needs-decision:, blocked:, paused:, done:, failed:. +Use only these prefixes for status changes: working:, ready:, needs-decision:, blocked:, paused:, done:, failed:. Use paused: only for a deliberate known external wait that should be rechecked later, never for a blocker that needs firstmate to act. +Use ready: for an implementation that is committed but not yet validated, and done: only for the terminal claim in the exact shape your task brief gives you; it names what you are claiming and firstmate verifies it. Before doing substantive work, append "working: Codex Desktop thread started". ``` diff --git a/AGENTS.md b/AGENTS.md index bf4f55f489a..98633d714f4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -177,11 +177,12 @@ When that section reports its checks still in progress it names exactly what is Every locked drain also prints a bounded fleet-wide `OPEN DECISIONS` section when durable decision records remain open, including when the queue itself is empty; reconcile those entries before continuing. A main drain may also print a bounded, one-shot `STATUS OUTCOME BACKSTOP` when a task's newest captain-facing status event has no covering supervision-branch outcome; handle it as a recovered wake even when no queue row remains. The same drain prints every still-unread `note:` line and pending-reply resolution since the last presentation in an unbounded `UNREAD STATUS` section, so an answer buried under a later routine line is not dropped; those lines are not re-printed after that presentation. + That section also marks each still-unread line matching no status verb as `UNRECOGNISED` instead of absorbing it silently, bounded per task by `FM_DRAIN_UNRECOGNISED_MAX`; a trailing count names how many it held back, and those stay unread for the next drain rather than being lost, so read them in the task's status log when the count appears. It also prints a bounded `RECORD DIVERGENCE` section naming every captain call the status log reads as resolved while its backlog task is still held; nothing is closed for you, and `captain-hold-lifecycle` owns the reconciliation. 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`; each task's standing terminal claim and what was last established about it, read locally from its status log and `state/.done-verdict` and never re-verified here; 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-inactive-reconcile.sh b/bin/fm-inactive-reconcile.sh index 2a2f18a1c79..12dd42b7e71 100755 --- a/bin/fm-inactive-reconcile.sh +++ b/bin/fm-inactive-reconcile.sh @@ -10,13 +10,17 @@ # not a watcher, daemon, PR poll, or forge client of its own. # In a secondmate home every `scan` invocation, which is every watcher poll, # first runs the LEDGER-FIRST parent delivery: a direct child whose status -# ledger ends in a whole `done:` or `failed:` line has stated its own outcome, -# so that line is published on the parent channel at once through -# bin/fm-parent-channel-lib.sh as -# [key=child-outcome---]: child : [pr=] [mode=] [yolo=] [report=data//report.md] +# ledger ends in a whole `done:` or `failed:` line spoken in its own voice has +# stated its own outcome, so that line is published on the parent channel at +# once through bin/fm-parent-channel-lib.sh as +# [key=child-outcome---]: child : [pr=] [mode=] [yolo=] [report=data//report.md] [claim=] # carrying the child's recorded PR, delivery mode, merge posture, and scout # report pointer, without consulting fm-crew-state.sh and without waiting for -# the inactive cadence. A line still being appended (no trailing newline yet) +# the inactive cadence. The leading is the child's own state, except +# that a `done` whose terminal claim is not established is published as +# `blocked` with `claim=` naming what the claim actually is, so the +# parent's durable record cannot read done while the child's home reports +# done-unverified. A line still being appended (no trailing newline yet) # is left for the next poll. This is what keeps a mate's PR-ready, finding, # and failure outcomes from depending on the mate model appending them # (docs/secondmate-parent-channel.md). A main home has no parent channel and @@ -44,11 +48,15 @@ # It considers only a direct ordinary crewmate whose newest meta, status, or # turn-ended mtime is older than that interval and whose last status is not # captain-held. In a secondmate home a child whose ledger already ends in a -# terminal done or failed line belongs to the ledger-first path above and is -# skipped here, so one outcome is never reported twice. It then uses -# fm-crew-state.sh as the sole current-state source. -# Only a done or failed state is suspicious enough to create a durable terminal -# outcome record or wake the supervisor. +# terminal done or failed line spoken in its own voice belongs to the +# ledger-first path above and is skipped here, so one outcome is never reported +# twice. It then uses fm-crew-state.sh as the sole current-state source. +# Only a done, done-unverified, or failed state, or the pre-validation `ready` +# handoff, is suspicious enough to create a durable terminal outcome record or +# wake the supervisor: an unestablished claim is an outcome nobody delivered, +# and a stopped worker waiting on firstmate is an action nobody took, which is +# what this backstop exists to catch. `ready` is reported as the handoff it is +# rather than as a terminal outcome. # Working, paused, parked, blocked, unknown, persistent secondmates, and # captain-held work retain their existing supervision semantics. # diff --git a/docs/architecture.md b/docs/architecture.md index 1220219f0ef..2e85b97c001 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -52,7 +52,7 @@ Its initial normal-mode status signal still surfaces through the no-verb path, w Fresh stale panes use the same current-state read before trusting the status log, so an active run or a proven busy worker outranks an old captain-relevant status-log line left behind before validation. No-change heartbeats are also benign. Separately from heartbeat backoff and wedge handling, the watcher poll runs `bin/fm-inactive-reconcile.sh` on its own bounded cadence, while locked session start sends the same bounded local scan through `bin/fm-startup-network.sh`'s deferred worker so current-state reads never block the digest. -In each home the scan considers only that home's long-inactive direct ordinary crewmates, excludes captain-held work, and accepts only `done` or `failed` from `bin/fm-crew-state.sh`. +In each home the scan considers only that home's long-inactive direct ordinary crewmates, excludes captain-held work, and accepts only `done`, `done-unverified`, `failed`, or the pre-validation `ready` handoff from `bin/fm-crew-state.sh`; an unestablished claim is still an undelivered outcome, and a stopped worker waiting on firstmate is still nobody's action, so neither may fall out of the backstop, and the `ready` case is reported as the handoff it is rather than as a terminal outcome. A secondmate retains a durable receipt for its idempotent report through the established parent route, and main-home captain presentation retains a separate receipt; neither path performs a forge or PR check. A secondmate home's terminal child ledger lines, PR registrations, captain holds, and merges are published on that same parent route by the scripts that record them, so no captain-facing outcome depends on the mate model appending it ([secondmate-parent-channel.md](secondmate-parent-channel.md)). Absorbed wakes advance their suppression markers, log to `state/.watch-triage.log`, and keep the watcher blocking without a queue record or LLM turn. @@ -64,6 +64,7 @@ Because of that, a per-wake read of only the latest line can bury an earlier sti The drain coordinates that fold and its annotations through a locked fleet-wide snapshot whose `.status-presentation-cursor` manifest records each status file's identity plus independent annotation and outcome-backstop byte offsets. [`pi-supervision-branch.md`](pi-supervision-branch.md#lost-wake-outcome-backstop) owns the bounded lost-wake backstop that uses the latter offset. A queued signal annotation prints every status line still unread at that cursor, while the fleet-wide UNREAD STATUS section prints `note:` lines and reserved-key pending-reply resolutions once even on an empty-queue drain because those verbs never enter the OPEN DECISIONS fold. +That section also surfaces a line carrying no recognised verb, marked UNRECOGNISED so it is not read as a state it never claimed, under a per-task cap (`FM_DRAIN_UNRECOGNISED_MAX`) so one chatty worker cannot bury the rest of the drain; a held-back line freezes that task's presentation cursor at the last line actually shown, so the remainder re-presents on the next drain rather than being acknowledged unseen. A third bounded section, RECORD DIVERGENCE, prints on the same drains for the opposite failure: the status fold went quiet on a key that the durable captain-held task still shows as open, so the status side reads as complete while the two records contradict each other; `bin/fm-captain-hold.sh diverged` decides what counts and closes nothing, and `docs/captain-hold-lifecycle.md` owns the mechanism. A failed read, output, or concurrent-replacement check prevents the snapshot cursor from advancing across uncertain bytes, and teardown retires a task's manifest row before that task ID can be reused. The explicit resolution is written by the actor that answers, not the busy worker: `fm-send`'s `--resolve-key` appends the closing `resolved` line to this home's own copy of the ledger at answer time, which covers crewmates, local secondmates, and remote secondmates identically because a remote mate's escalations reach that local copy through the parent-replies ingest and only the answer message itself crosses the transport. diff --git a/docs/gitlab-merge-watch.md b/docs/gitlab-merge-watch.md index 0483b0e5557..9acc5291918 100644 --- a/docs/gitlab-merge-watch.md +++ b/docs/gitlab-merge-watch.md @@ -53,7 +53,7 @@ Two things about plain `glab` were established by running it, because assuming e First, plain `glab` has no field selector. `gh` reads one field with `--json state -q .state`; `glab mr view` offers only `-F, --output string Format output as: text, json`. Its JSON would need a JSON processor, and `jq` is not one of firstmate's common tools, so the state is read from glab's own field output instead. -Only an exact `merged` wakes firstmate, so a changed output format produces no wake rather than a false merge. +Only an exact `merged` or an exact `closed` wakes firstmate, as `merged` and `closed-unmerged`, so a changed output format produces no wake rather than a false terminal outcome. Second, `glab` cannot take a merge request URL the way `gh pr view` can. That form shells out to git for the current repository, and the watcher runs in no repository: @@ -207,7 +207,7 @@ Neither refusal armed a poll or recorded a `pr=`, so a missing tool leaves no ha `jq` is not one of firstmate's common tools, which is why the watch poll reads glab's field output instead. The merge path cannot do the same: `detailed_merge_status`, `has_conflicts`, `blocking_discussions_resolved`, and the head pipeline appear only in glab's JSON. -The poll's silence on a missing tool is safe because silence means "not merged yet"; a merge cannot be silent about it, so the requirement is reported rather than assumed. +The poll's silence on a missing tool is safe because silence means "no terminal outcome yet"; neither a merge nor a close can be silent about itself, so the requirement is reported rather than assumed. The merged half of the fixture is refused, and every failing condition is listed rather than just the first: diff --git a/docs/secondmate-parent-channel.md b/docs/secondmate-parent-channel.md index dfafacdb80a..be92fa7b111 100644 --- a/docs/secondmate-parent-channel.md +++ b/docs/secondmate-parent-channel.md @@ -33,6 +33,8 @@ Every captain-facing outcome that leaves durable evidence in the mate home is pu | An outcome that exists only in the mate's reasoning | none | the charter and the `AGENTS.md` carve-outs only | The ledger delivery reads files only: it calls no harness, no forge, and no current-state reader, so it is identical for every harness and runtime backend. +It publishes what the mate home actually holds, which for a `done:` line includes whether anything established it: a claim that is not verified is published as a `blocked` line carrying `claim=` and the child's own words, so the parent's durable record cannot read `done` while the child's home reports `done-unverified` ([`bin/fm-done-claim-lib.sh`](../bin/fm-done-claim-lib.sh) owns that verdict, read locally from the child's record). +The verdict is part of the event's identity, so a claim established later publishes a corrected line while an unchanged verdict stays deduplicated. Each delivery is keyed with the first eight hexadecimal characters of its receipt fingerprint and appended at most once by exact line, and the ledger path reuses the inactive scan's per-fingerprint receipts, so a replayed poll or restart cannot deliver an event twice while a genuinely new terminal event is delivered again. A duplicate line is harmless and a missed one is not, so the mate may still append its own judgement about a delivered outcome, and the parent reads the script's line as the fact and the mate's line as commentary. diff --git a/docs/verification/secondmate-parent-channel.md b/docs/verification/secondmate-parent-channel.md index 4cd541c8028..700e5fd4d4d 100644 --- a/docs/verification/secondmate-parent-channel.md +++ b/docs/verification/secondmate-parent-channel.md @@ -3,6 +3,10 @@ Maintainer-verification record for the guarantee in [`secondmate-parent-channel.md`](../secondmate-parent-channel.md): a captain-facing outcome recorded inside a secondmate home reaches the parent channel without the mate model writing it. Refresh it by rerunning the fixture below after changing any publisher named in `bin/fm-parent-channel-lib.sh`. +The 2026-09-03 transcript below predates the terminal-claim gate and has not been rerun since; the guarantee it proves is unchanged, but two of its recorded lines are no longer what the current scripts print. +The child's `done: PR ... checks green` line carries no commit identity, so `bin/fm-inactive-reconcile.sh` now publishes it as a `blocked` line with `claim=unverified` under a different fingerprint key ([`secondmate-parent-channel.md`](../secondmate-parent-channel.md) owns that rule), and `bin/fm-pr-check.sh` prints its advisory `claim:` verdict before `armed:`. +Rerun the fixture to replace the transcript rather than editing the recorded output. + ## What was run Date: 2026-09-03. From 422955aa5560f2bad0c944837145b6035212b929 Mon Sep 17 00:00:00 2001 From: Israel Wilson Date: Thu, 3 Sep 2026 22:35:28 -0400 Subject: [PATCH 20/21] docs: record the handoff for this branch before a context reset Written under a compaction warning. Holds the two hard rules, the two P1 findings with their sites, what is already changed and what remains, the identified CI failure, the A/B-proven pre-existing test failures, and a verbatim captain instruction about a file that must be left untouched. Claude-Session: https://claude.ai/code/session_01N6zvmegmTrHiWnrfXBxuPU --- HANDOFF-fm-done-means-verified-v1.md | 120 +++++++++++++++++++++++++++ 1 file changed, 120 insertions(+) create mode 100644 HANDOFF-fm-done-means-verified-v1.md diff --git a/HANDOFF-fm-done-means-verified-v1.md b/HANDOFF-fm-done-means-verified-v1.md new file mode 100644 index 00000000000..ae74fa8e8b2 --- /dev/null +++ b/HANDOFF-fm-done-means-verified-v1.md @@ -0,0 +1,120 @@ +# Handoff: fm-done-means-verified-v1 + +Written because context was about to compact. Re-read this before acting. +A claim you cannot re-derive is not a record. + +## Where things stand + +PR: https://github.com/kunchenguid/firstmate/pull/3683 +Branch `fm/fm-done-means-verified-v1`, pushed to the **ICGNU3 fork** (upstream +`kunchenguid` denies this machine's `ICGNU3` credential, so the pipeline's push +step fails; the fork is the sanctioned path and `AGENTS.md` recognises it). +The no-mistakes run is terminal (failed at `push`); custody is returned and the +branch is mine. `review`, `test`, `document`, `lint` all completed before it. + +Full PR body draft lives OUTSIDE the repo at: +`/pr-body.md` - it holds the proxy ledger, the twin table, +the three-state section, the follow-ups. If it is gone, the PR body on 3683 is +already the published copy; edit that. + +## The two hard rules (captain, stricter than the review findings) + +**RULE ONE.** A run may produce `verified` only when it reached a terminal, +successful, **accepted** outcome. Not running is not verified. Not +failed-but-explained is not verified. Terminal AND successful AND accepted, all +three, **gating the verdict** rather than appearing in its explanation. + +**RULE TWO.** A verdict must be **bound to the exact PR head SHA it evaluated**. +Any new commit on that PR automatically makes the verdict **stale**, and stale +**BLOCKS** cleanup until verification runs again. Not a warning, not a downgrade +to `unverified`. Bind the SHA into the verdict record so the binding cannot be +forgotten, and make the staleness test a comparison against the **live** head +rather than a cached fact. + +## The two P1 findings these come from + +1. `bin/fm-verify-done.sh`, end of the validated-commit arm: `RUN_OUTCOME` and + `RUN_STATUS` were read and used only in the explanation string before + `verdict_is verified`, so a still-running or failed run still verified. + Verdict and narration coming apart, in the top-level verifier. +2. `bin/fm-teardown.sh` claim gate (~line 2650): a standing `verified` verdict + was trusted without re-comparing the live head, so an open PR force-pushed or + given another commit after verification let cleanup delete local evidence + while the PR shipped an unclaimed, unvalidated commit. This is the THIRD + STATE (the world changed) built for merges and not applied to a moving PR. + +## Files touched this round, and what remains + +Touched (uncommitted at time of writing): +- `bin/fm-verify-done.sh` - RULE ONE gate added after the head comparison + (`fm_nm_run_is_active` -> unverified; `failed` -> contradicted; `cancelled` + and unrecognised -> unverified). **STILL TOO LOOSE for RULE ONE**: it accepts + `completed` as well as `passed`/`checks-passed`. Tighten to accepted outcomes + only, so a bare `completed` status with no accepted outcome is NOT verified. +- `bin/fm-teardown.sh` - gate now always runs the verifier and lets a standing + verified record rescue ONLY rc=3 (absence), never rc=4. +- `bin/fm-done-claim-lib.sh` - verdict record bumped to `fm-done-verdict-v2` + with a sixth line carrying the evaluated PR head; `fm_done_verdict_read` + tolerates v1 (empty binding) and exports `FM_DONE_VERDICT_EVALUATED_HEAD`. +- `tests/fm-done-verified.test.sh` - plants for the run-outcome gate (unfinished + -> not verified; failed -> contradicted; cancelled -> unverified; accepted -> + verified). Both proven to fail without the fix. +- `tests/fm-teardown.test.sh` - plant: a standing verdict does not survive the + head moving. Proven to fail without the fix. + +Remaining: +- Tighten RULE ONE to accepted outcomes only (`passed`, `checks-passed`). +- Have `bin/fm-verify-done.sh` PASS the evaluated PR head to + `fm_done_verdict_write` (sixth argument) so the binding is actually recorded. +- Implement RULE TWO's staleness properly: when the PR's live head differs from + the standing verdict's evaluated head, record/report **`stale`** (not + `contradicted`) and make the teardown gate BLOCK on it with its own message. + Distinguish: no standing verdict + head mismatch = `contradicted` (the + fabricated-claim case, must stay); standing verified verdict evaluated at the + claimed head + PR moved = `stale`. +- Plants required: a verified claim against an unfinished run must be refused; + a verdict whose PR head then moves must go stale and block. +- Re-run: `bin/fm-lint.sh`, `tests/fm-done-verified.test.sh`, + `tests/fm-teardown.test.sh`, `tests/fm-crew-state.test.sh`, + `tests/fm-captain-hold-lifecycle.test.sh`, `tests/fm-pr-check-security.test.sh`. +- Commit, push to `ICGNU3`, report as + `done: pr= head= - ` with `head=` READ BACK FROM THE + FORGE, never from what you believe you pushed. + +## The failed CI check, identified (not guessed) + +`gh pr checks 3683 --repo kunchenguid/firstmate` -> **Greptile Review: fail**. +It is the automated reviewer reporting the two P1 findings above; there is no +separate failing test job. Fixing the two findings is what clears it. + +## Known-pre-existing local test failures (A/B proven vs baseline 3d2a08b) + +`fm-composer-lib` (half-block rule), `fm-wake-queue` (subshell hold), +`fm-muse-harness` (process detection), `fm-teardown` (herdr-preflight-missing-adapter). +Same assertion and exit code on both sides of this change; all look macOS-local. +Plus one load artifact: `fm-pr-check-security` can fail with +`first merged watcher cycle failed` and EMPTY stderr under full-suite load, +because `run_watcher_bounded` wraps a real watcher in a perl `alarm 10` and +exits 124 on a TERM kill. It passes standalone at the same commit. + +## Captain instruction, verbatim - DO NOT ACT ON THE FILE + +> "Finish and verify the two verifier fixes first. Leave +> NVIDIA_BUILD_EVALUATION.md untouched. Once the verifier change is safely +> committed, report the file location, contents, apparent owner or origin, and +> whether it overlaps the planned Kimi K3 integration. Do not refresh, clean, +> delete, relocate, or overwrite the file. We will incorporate it deliberately +> when we resume the NVIDIA thread." + +File: `/Users/werhiz/firstmate/projects/rhizprotocol/docs/operational/research/NVIDIA_BUILD_EVALUATION.md` +Untracked, outside this worktree. **READ ONLY.** Do not run any refresh, sync, +clean, or checkout that could touch it. Sequence matters: verifier fixes +committed FIRST, then the report. + +## Convergence rule in force + +Fix only what review finds wrong in the code that already exists. No scope +extension, no volunteered safety constraints, no belt-and-braces - three of five +findings in one earlier round came from exactly that instinct. Anything real but +not required for this change to be correct becomes a named follow-up in the PR +body, not a commit. From 71c0e93b23559fee4845c240bdbf94f2af7fbe5c Mon Sep 17 00:00:00 2001 From: Israel Wilson Date: Thu, 3 Sep 2026 23:52:39 -0400 Subject: [PATCH 21/21] fix(bin): gate the done verdict on an accepted run and bind it to the head Two ways a terminal claim could still be believed on evidence that did not support it. The run's result gated nothing. fm-verify-done.sh read the validation run's outcome and status and then used them only inside the string it printed before recording `verified`, so a run still in flight, or one that ran and rejected the work, still produced an established claim: crew state reported done and cleanup was free to proceed. Matching heads only ever said validation LOOKED at this commit. The outcome now gates the verdict itself - terminal, successful, and accepted, all three - and a bare `completed` with no accepted outcome does not qualify. Below that bar the split is the one every other source in the script uses: a run that rejected the work is evidence of falsity, while a cancelled or unrecognised one established nothing either way. A verdict outlived the world it was about. A `verified` record was trusted at the teardown gate without re-reading the live head, so an open PR that was force-pushed or gained another commit afterwards left the record right about a commit the PR no longer carried - and cleanup deleted the local copy while the PR shipped a commit nothing claimed and nothing validated. The verdict now carries the exact head it evaluated, and a mismatch against the LIVE head reads as `stale` when a verdict already stands for this claim at the head the claim names. Staleness is not established, so it blocks: the gate always re-runs the verifier, and a standing record now rescues only absence (3), never falsity (4) and never staleness (5). With no verdict standing, the same mismatch stays `contradicted` - a claim naming a commit the PR never carried is falsity, not the world having moved. Both are planted and both were watched to fail. Removing the outcome gate verifies a claim whose run reports `running`, and removing only its accepted- outcome arm verifies one whose run reports `failed`, each reproducing the defect verbatim in the narration beside a `verified` verdict. Removing teardown's stale arm carries a moved-head claim through cleanup. Each keeps a positive control so it cannot pass vacuously. Claude-Session: https://claude.ai/code/session_01T2APQvC3ewFivzVZZFs8pm --- bin/fm-done-claim-lib.sh | 30 +++++-- bin/fm-teardown.sh | 50 ++++++++---- bin/fm-verify-done.sh | 87 ++++++++++++++++---- tests/fm-done-verified.test.sh | 103 +++++++++++++++++++++-- tests/fm-teardown.test.sh | 144 +++++++++++++++++++++++++++++++++ 5 files changed, 374 insertions(+), 40 deletions(-) diff --git a/bin/fm-done-claim-lib.sh b/bin/fm-done-claim-lib.sh index 03a75d67fcc..5af9ac98806 100644 --- a/bin/fm-done-claim-lib.sh +++ b/bin/fm-done-claim-lib.sh @@ -75,8 +75,10 @@ FM_DONE_VERDICT= FM_DONE_VERDICT_CLAIM_HASH= FM_DONE_VERDICT_EPOCH= FM_DONE_VERDICT_REASON= +FM_DONE_VERDICT_EVALUATED_HEAD= -FM_DONE_VERDICT_VERSION=fm-done-verdict-v1 +FM_DONE_VERDICT_VERSION=fm-done-verdict-v2 +FM_DONE_VERDICT_VERSION_V1=fm-done-verdict-v1 # status_line_verb / status_line_note are the one owner of leading-verb and # note extraction, so this file borrows them rather than re-deriving the shape. @@ -364,8 +366,12 @@ _fm_done_verdict_standing() { # # bin/fm-verify-done.sh), so nothing hides an outage from its caller. A refusal # returns 0, because the record already holds the stronger verdict for this # claim and there is nothing left to record. -fm_done_verdict_write() { # - local state=$1 id=$2 verdict=$3 hash=$4 reason=$5 path tmp standing +# binds the verdict to the exact PR head SHA it looked at, so a +# later commit on that PR can be detected as having moved the world out from +# under a verdict that was true when it was made. The binding lives in the +# record rather than in a caller's memory precisely so it cannot be forgotten. +fm_done_verdict_write() { # [] + local state=$1 id=$2 verdict=$3 hash=$4 reason=$5 evaluated=${6:-} path tmp standing case "$verdict" in verified|unverified|contradicted|stale) ;; *) return 2 ;; esac case "$hash" in *[!0-9a-f]*|'') return 2 ;; esac [ "${#hash}" -eq 64 ] || return 2 @@ -389,6 +395,7 @@ fm_done_verdict_write() { # printf '%s\n' "$hash" printf '%s\n' "$(date +%s)" printf '%s\n' "$(fm_done_reason_clean "$reason")" + printf '%s\n' "$evaluated" } > "$tmp" || { rm -f -- "$tmp"; return 1; } chmod 0600 "$tmp" 2>/dev/null || true mv -f -- "$tmp" "$path" || { rm -f -- "$tmp"; return 1; } @@ -397,11 +404,12 @@ fm_done_verdict_write() { # # Read a verdict record. Returns 1 when it is absent, unreadable, or malformed; # a malformed record is never partially trusted. fm_done_verdict_read() { # - local path version verdict hash epoch reason _extra + local path version verdict hash epoch reason evaluated _extra FM_DONE_VERDICT= FM_DONE_VERDICT_CLAIM_HASH= FM_DONE_VERDICT_EPOCH= FM_DONE_VERDICT_REASON= + FM_DONE_VERDICT_EVALUATED_HEAD= path=$(fm_done_verdict_path "$1" "$2") [ -f "$path" ] && [ -r "$path" ] && [ ! -L "$path" ] || return 1 exec 7< "$path" || return 1 @@ -410,12 +418,22 @@ fm_done_verdict_read() { # IFS= read -r hash <&7 || { exec 7<&-; return 1; } IFS= read -r epoch <&7 || { exec 7<&-; return 1; } IFS= read -r reason <&7 || reason= + evaluated= + # A v1 record predates the head binding and carries no sixth line. It is read + # rather than refused, and its empty binding is what makes the staleness test + # unable to establish anything for it - absence, not falsity. + if [ "$version" = "$FM_DONE_VERDICT_VERSION" ]; then + IFS= read -r evaluated <&7 || evaluated= + fi if IFS= read -r _extra <&7; then exec 7<&- return 1 fi exec 7<&- - [ "$version" = "$FM_DONE_VERDICT_VERSION" ] || return 1 + case "$version" in + "$FM_DONE_VERDICT_VERSION"|"$FM_DONE_VERDICT_VERSION_V1") ;; + *) return 1 ;; + esac case "$verdict" in verified|unverified|contradicted|stale) ;; *) return 1 ;; esac case "$hash" in *[!0-9a-f]*|'') return 1 ;; esac [ "${#hash}" -eq 64 ] || return 1 @@ -425,6 +443,8 @@ fm_done_verdict_read() { # # shellcheck disable=SC2034 # Public result consumed by sourcing callers. FM_DONE_VERDICT_EPOCH=$epoch FM_DONE_VERDICT_REASON=$reason + # shellcheck disable=SC2034 # Public result consumed by sourcing callers. + FM_DONE_VERDICT_EVALUATED_HEAD=$evaluated } # THE predicate every consumer asks: for this task's current terminal claim, diff --git a/bin/fm-teardown.sh b/bin/fm-teardown.sh index 3829a633777..dc8257b48c6 100755 --- a/bin/fm-teardown.sh +++ b/bin/fm-teardown.sh @@ -2644,32 +2644,45 @@ fi # ESTABLISHED before it may be cleaned up: bin/fm-verify-done.sh reads the forge, # git, and the validation run, and this gate refuses anything it could not # establish (`unverified`) as well as anything it established as false -# (`contradicted`). The cheap local reads come first: a task that never claimed -# done has nothing to verify, and a claim a durable verdict record already -# establishes needs nothing re-established, so neither pays for a subprocess. The -# landed-work gates above are unchanged and still apply. +# (`contradicted`). A task that never claimed done has nothing to verify and +# pays for no subprocess. A claim a durable record already establishes still +# gets checked, because the world moves under a standing verdict - an open PR +# can be force-pushed or gain a commit after its claim was verified - and that +# record only rescues the ABSENCE case. The landed-work gates above are +# unchanged and still apply. TEARDOWN_CLAIM= if [ "$KIND" != secondmate ] && [ "$FORCE" != "--force" ]; then TEARDOWN_CLAIM=$(fm_done_claim_last "$STATE/$ID.status") fi +# Does a durable record already establish THIS EXACT claim? fm_done_claim_status +# matches on the claim's hash, so a newer `done:` line never inherits an older +# verdict. This does NOT skip the check below: a verdict is a true statement +# about the world when it was made, and the world moves - an open PR can be +# force-pushed or gain another commit after its claim was verified, leaving the +# record right about a head the PR no longer carries. That is the same STALE +# case this change records on a merge, and trusting the record blind here would +# delete the local evidence while the PR ships an unclaimed commit. +# What the record does rescue is ABSENCE: `unverified` means the sources went +# away, and they go away for ordinary reasons long after a claim was established +# - the forge unreachable, `gh` or `no-mistakes` off PATH, the run aged out of +# `axi status`. So a standing verdict answers `unverified` and never answers +# `contradicted`, which is the three-state rule applied at the only gate that +# blocks. +TEARDOWN_CLAIM_ESTABLISHED=0 if [ -n "$TEARDOWN_CLAIM" ] && fm_done_claim_status "$STATE" "$ID" \ && [ "$FM_DONE_CLAIM_STATE" = verified ]; then - # A durable record already establishes THIS EXACT claim (fm_done_claim_status - # matches on the claim's hash, so a newer `done:` line does not inherit it). - # Re-running the verifier could only weaken that: `unverified` is the absence - # of evidence, and the sources it reads go away for ordinary reasons long - # after a claim was established - the forge unreachable, `gh` or `no-mistakes` - # off PATH, the validation run aged out of `axi status`. Deferring to the - # record here is what makes fm_done_verdict_write's anti-downgrade rule mean - # something at the only gate that blocks, and it leaves `--force` (explicit - # discard authority) for work that was never established, not for work that - # was. Every refusal below still stands whenever no such record does. - TEARDOWN_CLAIM= + TEARDOWN_CLAIM_ESTABLISHED=1 fi if [ -n "$TEARDOWN_CLAIM" ]; then TEARDOWN_CLAIM_RC=0 TEARDOWN_CLAIM_OUT=$(FM_HOME="$FM_HOME" FM_STATE_OVERRIDE="$STATE" FM_DATA_OVERRIDE="$DATA" \ "$SCRIPT_DIR/fm-verify-done.sh" "$ID" 2>&1) || TEARDOWN_CLAIM_RC=$? + # A standing verdict rescues only the ABSENCE result. It never rescues + # evidence of falsity (4), and it never rescues staleness (5) - staleness is + # the case where that very record is the thing that went out of date. + if [ "$TEARDOWN_CLAIM_RC" -eq 3 ] && [ "$TEARDOWN_CLAIM_ESTABLISHED" -eq 1 ]; then + TEARDOWN_CLAIM_RC=0 + fi case "$TEARDOWN_CLAIM_RC" in 0|2) ;; 3) @@ -2679,6 +2692,13 @@ if [ -n "$TEARDOWN_CLAIM" ]; then echo "A claim that names no commit identity can never be established: append a conforming claim naming the commit that shipped, then verify it." >&2 exit 1 ;; + 5) + echo "REFUSED: task $ID has a verified claim, but the PR has moved since it was established." >&2 + printf '%s\n' "$TEARDOWN_CLAIM_OUT" >&2 + echo "The verdict is about a head the PR no longer carries, so cleanup would delete the local copy while the PR ships a commit nothing claimed and nothing validated." >&2 + echo "Re-verify against the current head with bin/fm-verify-done.sh $ID, or have the worker claim the commit that actually shipped." >&2 + exit 1 + ;; 4) echo "REFUSED: task $ID claims it is done and the claim is contradicted by the forge or the validation run." >&2 printf '%s\n' "$TEARDOWN_CLAIM_OUT" >&2 diff --git a/bin/fm-verify-done.sh b/bin/fm-verify-done.sh index 38fd754a5e0..a958f3a3dd2 100755 --- a/bin/fm-verify-done.sh +++ b/bin/fm-verify-done.sh @@ -85,23 +85,30 @@ # unreachable, no validation run recorded, legacy claim with no # commit identity). Never a pass: not knowing is not proof. # contradicted exit 4 - a required fact WAS established and the claim is false. +# stale exit 5 - the claim was established and the world has since +# MOVED under it. Not established, and never a pass. # exit 2 - the task has no terminal claim to verify, or the request is invalid. # -# The line between the last two is enforced by fm_done_verdict_resolve (in -# bin/fm-done-claim-lib.sh), which every arm here records through, rather than -# left to each arm's care: a `contradicted` verdict must carry the observation it -# contradicts with, and one that carries none is recorded as `unverified`. +# The line between unverified and contradicted is enforced by +# fm_done_verdict_resolve (in bin/fm-done-claim-lib.sh), which every arm here +# records through, rather than left to each arm's care: a `contradicted` verdict +# must carry the observation it contradicts with, and one that carries none is +# recorded as `unverified`. # -# There is a fourth verdict this script never reaches for and must still expect -# to find in the record it is re-judging: `stale`. Terminal evidence about a -# claim has THREE shapes, not two - absence of evidence (`unverified`), positive -# evidence of falsity (`contradicted`), and the world having CHANGED under a -# verdict that was true when it was made (`stale`). Only the site that observes -# a PR reach a terminal state can see the third, so bin/fm-merge-outcome-lib.sh -# writes it when a merge lands under an established claim. What that record asks -# for is exactly this script: `stale` is not established, so every gate re-runs -# the verifier against the world that now exists. See bin/fm-done-claim-lib.sh -# for the three shapes and the write precedence that keeps them apart. +# Terminal evidence about a claim has THREE shapes, not two - absence of +# evidence (`unverified`), positive evidence of falsity (`contradicted`), and +# the world having CHANGED under a verdict that was true when it was made +# (`stale`). Two sites observe the third. bin/fm-merge-outcome-lib.sh writes it +# when a merge lands under an established claim, and this script writes it when +# an OPEN PR's head has moved off the head a standing verdict evaluated: a +# force-push or a later commit leaves the record right about a commit the PR no +# longer carries. Which of the two a head mismatch is turns entirely on whether +# a verdict already stands for this claim at the head the claim names - with +# none, the claim simply names a commit the PR never carried, and that is +# falsity. `stale` is not established either way, so every gate re-runs this +# script against the world that now exists rather than trusting the record. See +# bin/fm-done-claim-lib.sh for the three shapes and the write precedence that +# keeps them apart. # # The verdict record binds to the exact claim line it judged, so appending a new # `done:` line invalidates it rather than inheriting its verdict. A task whose @@ -172,16 +179,21 @@ verdict_is() { # [] # Record the outcome and exit. The record is written before the exit code is # chosen so a caller that only reads the record and a caller that only reads # the status agree. +# The PR head this run actually evaluated, recorded into the verdict so a later +# commit on that PR can be seen to have moved the world out from under it. Empty +# for arms that evaluate no PR. +EVALUATED_HEAD= finish() { local hash rc=0 case "$VERDICT" in verified) rc=0 ;; unverified) rc=3 ;; contradicted) rc=4 ;; + stale) rc=5 ;; *) rc=2 ;; esac if hash=$(fm_done_claim_hash "$FM_DONE_CLAIM_LINE"); then - fm_done_verdict_write "$STATE" "$ID" "$VERDICT" "$hash" "$REASON" \ + fm_done_verdict_write "$STATE" "$ID" "$VERDICT" "$hash" "$REASON" "$EVALUATED_HEAD" \ || echo "fm-verify-done: could not record the verdict for $ID" >&2 else echo "fm-verify-done: could not compute a claim identity for $ID" >&2 @@ -575,7 +587,21 @@ if [ -z "$PR_HEAD" ]; then verdict_is unverified "the forge reported no head commit for $PR_URL" finish fi +EVALUATED_HEAD=$PR_HEAD if [ "$PR_HEAD" != "$HEAD_CLAIM" ]; then + # Two different worlds produce this mismatch and they are not the same fact. + # If a verdict already stands for THIS claim and it was evaluated at the head + # the claim names, then the claim was true and the PR has since moved under + # it: the world changed, which is `stale`. With no such standing verdict, the + # claim simply names a commit this PR does not carry, which is falsity. The + # test is against the LIVE head read above, never against a cached fact. + if fm_done_verdict_read "$STATE" "$ID" \ + && [ "$FM_DONE_VERDICT" = verified ] \ + && [ -n "$FM_DONE_VERDICT_EVALUATED_HEAD" ] \ + && [ "$FM_DONE_VERDICT_EVALUATED_HEAD" = "$HEAD_CLAIM" ]; then + verdict_is stale "$PR_URL has moved to $PR_HEAD since this claim was established at $HEAD_CLAIM, so the verdict is about a world that no longer exists" "$PR_HEAD" + finish + fi verdict_is contradicted "$PR_URL is at $PR_HEAD, not the claimed $HEAD_CLAIM" "$PR_HEAD" finish fi @@ -638,5 +664,36 @@ if [ "${HEAD_CLAIM#"$RUN_HEAD"}" = "$HEAD_CLAIM" ]; then fi RUN_OUTCOME=$(fm_nm_strip_quotes "$(fm_nm_field "$RUN_OUT" outcome)") RUN_STATUS=$(fm_nm_strip_quotes "$(fm_nm_field "$RUN_OUT" status)") +# The run's result GATES the verdict; it does not decorate it. Matching heads +# only says validation looked at this commit, never that it accepted it, so a +# run still in flight or one that ended without accepting cannot produce +# `verified`. fm_nm_run_is_active in bin/fm-nm-run-lib.sh owns "is this run +# still going", so that question is asked of it rather than re-derived here. +if fm_nm_run_is_active "$RUN_OUT"; then + verdict_is unverified "validation is still running against $RUN_HEAD (${RUN_STATUS:-in flight}), so nothing has accepted this work yet" + finish +fi +# Terminal AND successful AND ACCEPTED, all three, gating the verdict rather +# than explaining it. A bare `completed` status with no accepted outcome says +# the run stopped, never that it accepted the work, so it does not qualify. +# Below that bar the split is the one every source in this script uses: a run +# that RAN and rejected the work is positive evidence the claim is false, while +# a cancelled run, or one reporting nothing this script recognises, established +# nothing either way. +case "$RUN_OUTCOME" in + passed|checks-passed) ;; + failed) + verdict_is contradicted "validation ran against $RUN_HEAD and failed, so this work was never accepted" "$RUN_OUTCOME" + finish + ;; + cancelled) + verdict_is unverified "validation against $RUN_HEAD was cancelled, so it neither accepted nor rejected this work" + finish + ;; + *) + verdict_is unverified "validation against $RUN_HEAD ended as '${RUN_OUTCOME:-${RUN_STATUS:-unknown}}', which does not establish that it accepted this work" + finish + ;; +esac verdict_is verified "$PR_URL is $PR_STATE at the claimed $HEAD_CLAIM, validated at $RUN_HEAD (run ${RUN_OUTCOME:-$RUN_STATUS}); checks: $CHECKS" finish diff --git a/tests/fm-done-verified.test.sh b/tests/fm-done-verified.test.sh index 0987104b0cc..372cf507b3b 100755 --- a/tests/fm-done-verified.test.sh +++ b/tests/fm-done-verified.test.sh @@ -377,6 +377,93 @@ test_checks_state_is_read_from_a_real_rollup() { pass "the checks state is read out of a real rollup payload, per entry, with the script's own query" } +# --- the run's result gates the verdict, it does not decorate it ------------- +# Matching heads only say validation LOOKED at this commit. A run still in +# flight, or one that ended without accepting the work, must never verify. +# RULE TWO: a verdict binds to the PR head it evaluated, and a later commit on +# that PR makes it STALE - the world moved under a verdict that was true - which +# is a different fact from a claim naming a head the PR never carried. +test_a_moved_pr_head_makes_a_standing_verdict_stale() { + local dir shipped moved result + dir=$(make_world head-moved) + shipped=$(git -C "$dir/wt" rev-parse HEAD) + printf 'done: pr=https://github.com/o/r/pull/7 head=%s - shipped\n' "$shipped" \ + > "$dir/state/task-v.status" + + # Establish it against the head the PR actually carries. + result=$(FAKE_GH_OUT="OPEN $shipped fm/task-v SUCCESS" \ + FAKE_NM_STATUS="$(nm_status fm/task-v "$shipped")" verify "$dir") + [ "${result%%$'\t'*}" = 0 ] || fail "setup: the honest claim did not verify: $result" + grep -Fq "$shipped" "$dir/state/task-v.done-verdict" \ + || fail "the verdict did not bind the PR head it evaluated" + + # The PR gains a commit. The claim was true; the verdict is now about a world + # that no longer exists. + moved=$(git -C "$dir/wt" rev-parse 'HEAD~1') + result=$(FAKE_GH_OUT="OPEN $moved fm/task-v SUCCESS" \ + FAKE_NM_STATUS="$(nm_status fm/task-v "$shipped")" verify "$dir") + [ "${result%%$'\t'*}" = 5 ] \ + || fail "a PR that moved under an established verdict was not reported stale: $result" + case "${result#*$'\t'}" in stale:*) ;; *) fail "the moved head was not recorded as stale: $result" ;; esac + + # With no standing verdict, the same mismatch is falsity, not staleness. + dir=$(make_world head-never-carried) + shipped=$(git -C "$dir/wt" rev-parse HEAD) + moved=$(git -C "$dir/wt" rev-parse 'HEAD~1') + printf 'done: pr=https://github.com/o/r/pull/7 head=%s - shipped\n' "$shipped" \ + > "$dir/state/task-v.status" + result=$(FAKE_GH_OUT="OPEN $moved fm/task-v SUCCESS" verify "$dir") + [ "${result%%$'\t'*}" = 4 ] \ + || fail "a claim naming a head the PR never carried was not contradicted: $result" + pass "a moved PR head makes an established verdict stale, while an unbacked mismatch stays contradicted" +} + +test_an_unfinished_run_never_verifies() { + local dir shipped result + dir=$(make_world run-unfinished) + shipped=$(git -C "$dir/wt" rev-parse HEAD) + printf 'done: pr=https://github.com/o/r/pull/7 head=%s - shipped\n' "$shipped" \ + > "$dir/state/task-v.status" + result=$(FAKE_GH_OUT="OPEN $shipped fm/task-v SUCCESS" \ + FAKE_NM_STATUS="branch: fm/task-v +head: $shipped +status: running" verify "$dir") + [ "${result%%$'\t'*}" != 0 ] \ + || fail "a claim whose validation run is still running was verified: $result" + case "${result#*$'\t'}" in unverified:*) ;; *) fail "an unfinished run was not unverified: $result" ;; esac + pass "a claim whose validation run is still running is never verified" +} + +test_a_failed_run_contradicts_and_a_cancelled_one_does_not() { + local dir shipped result + dir=$(make_world run-failed) + shipped=$(git -C "$dir/wt" rev-parse HEAD) + printf 'done: pr=https://github.com/o/r/pull/7 head=%s - shipped\n' "$shipped" \ + > "$dir/state/task-v.status" + + # A run that RAN and rejected the work is positive evidence of falsity. + result=$(FAKE_GH_OUT="OPEN $shipped fm/task-v SUCCESS" \ + FAKE_NM_STATUS="branch: fm/task-v +head: $shipped +status: failed +outcome: failed" verify "$dir") + [ "${result%%$'\t'*}" = 4 ] || fail "a failed validation run did not contradict the claim: $result" + + # A cancelled run established nothing either way, so it is absence. + result=$(FAKE_GH_OUT="OPEN $shipped fm/task-v SUCCESS" \ + FAKE_NM_STATUS="branch: fm/task-v +head: $shipped +status: cancelled +outcome: cancelled" verify "$dir") + [ "${result%%$'\t'*}" = 3 ] || fail "a cancelled validation run was not unverified: $result" + + # The positive control, so neither assertion above passes vacuously. + result=$(FAKE_GH_OUT="OPEN $shipped fm/task-v SUCCESS" \ + FAKE_NM_STATUS="$(nm_status fm/task-v "$shipped")" verify "$dir") + [ "${result%%$'\t'*}" = 0 ] || fail "an accepted validation run stopped verifying: $result" + pass "a failed run contradicts, a cancelled one is unverified, an accepted one verifies" +} + # --- (h) scout claims --------------------------------------------------------- test_scout_claim_checks_the_report_exists() { @@ -699,11 +786,14 @@ test_a_transient_unverified_does_not_downgrade_an_established_record() { } test_a_contradiction_still_overwrites_an_established_record() { - local out dir result other - other=00112233445566778899aabbccddeeff00112233 - # The forge answers, and it answers with a different head: the claim is now - # established false, so the protection above must not freeze the record. - out=$(establish_then_rerun contradiction-overwrites "OPEN $other fm/task-v SUCCESS") \ + local out dir result + # The forge answers, and it answers that the PR was CLOSED without merging: + # positive evidence of falsity, so the anti-downgrade protection must not + # freeze the record. A merely MOVED head is deliberately not used here - under + # a standing verdict that is staleness, not falsity, and its own case covers + # it - so this asserts the overwrite on evidence rather than on the world + # having moved. + out=$(establish_then_rerun contradiction-overwrites "CLOSED $(git -C "$TMP_ROOT/contradiction-overwrites/wt" rev-parse HEAD 2>/dev/null || printf x) fm/task-v SUCCESS") \ || fail "the contradiction-overwrite fixture failed" dir=${out%%$'\t'*} result=${out#*$'\t'} @@ -1865,6 +1955,9 @@ test_legacy_claim_degrades_and_is_never_upgraded test_honest_claim_verifies_and_records_the_checks_state test_red_checks_do_not_by_themselves_contradict_a_claim test_checks_state_is_read_from_a_real_rollup +test_an_unfinished_run_never_verifies +test_a_moved_pr_head_makes_a_standing_verdict_stale +test_a_failed_run_contradicts_and_a_cancelled_one_does_not test_scout_claim_checks_the_report_exists test_a_symlinked_scout_report_is_unverified_not_contradicted test_a_verdict_does_not_cover_a_later_claim diff --git a/tests/fm-teardown.test.sh b/tests/fm-teardown.test.sh index 1d156a4b4fb..2b8ad64eeee 100755 --- a/tests/fm-teardown.test.sh +++ b/tests/fm-teardown.test.sh @@ -53,6 +53,8 @@ # (z9) verified record, then the PR closed unmerged -> REFUSE (contradicted) # (z10) false claim, then a `failed:` line withdrawing it -> ALLOW (no claim) # (z11) false claim + `blocked:`/`needs-decision:` -> REFUSE (still claimed) +# (z12) verified record, then the PR gained a commit -> REFUSE (stale) +# (z13) the same fixture with the PR standing still -> ALLOW (control) # # Also covers backlog teardown-lock-race: a git index.lock left in the worktree by a # killed crew process (bin/fm-teardown.sh's teardown_treehouse_return). @@ -2778,6 +2780,146 @@ make_claim_case() { # printf '%s\n' "$case_dir" } +# The verifier reads the forge with its own `gh pr view --json ...`. This mock +# answers that one query from FM_FAKE_PR_VIEW, so a single fixture can be asked +# about the same PR twice: once while it still carries the claimed commit, and +# once after it has moved. +add_gh_pr_view_from_env() { # + local case_dir=$1 + cat > "$case_dir/fakebin/gh" <<'SH' +#!/usr/bin/env bash +case "${1:-} ${2:-}" in + "pr view") + case " $* " in + *headRefOid*) printf '%s\n' "${FM_FAKE_PR_VIEW:-}" ; exit 0 ;; + esac + ;; +esac +echo "error: pull request not found" >&2 +exit 1 +SH + chmod +x "$case_dir/fakebin/gh" +} + +# A direct-PR fixture whose work is landed on origin, so the landed-work gates +# pass and the claim gate is the only thing left to decide the teardown. The +# caller establishes the claim itself, because a setup failure inside a command +# substitution could not abort the test. +make_pr_claim_case() { # + local name=$1 case_dir head + case_dir=$(make_case "$name") + write_meta "$case_dir" direct-PR ship + wt_commit "$case_dir" "shippable work" + git -C "$case_dir/wt" push -q origin fm/task-x1 + git -C "$case_dir/project" fetch -q origin + add_gh_pr_view_from_env "$case_dir" + head=$(git -C "$case_dir/wt" rev-parse HEAD) + printf 'done: pr=https://github.com/o/r/pull/7 head=%s - shipped\n' "$head" \ + > "$case_dir/state/task-x1.status" + printf '%s\n' "$case_dir" +} + +# Establish a fixture's claim against the head the PR actually carries, exactly +# as a real registration would, so the verdict it leaves carries a real head +# binding rather than a seeded one. +establish_pr_claim() { # + local case_dir=$1 head=$2 rc=0 + set +e + FM_HOME="$case_dir" FM_STATE_OVERRIDE="$case_dir/state" \ + FM_DATA_OVERRIDE="$case_dir/data" \ + PATH="$case_dir/fakebin:${FM_TEARDOWN_TEST_PATH:-$PATH}" \ + FM_FAKE_PR_VIEW="OPEN $head fm/task-x1 SUCCESS" \ + "$ROOT/bin/fm-verify-done.sh" task-x1 >/dev/null 2>&1 + rc=$? + set -e + expect_code 0 "$rc" "setup: the honest PR claim did not verify" + assert_grep "$head" "$case_dir/state/task-x1.done-verdict" \ + "setup: the verdict did not bind the PR head it evaluated" +} + +# RULE TWO at the only gate that blocks. A verdict is a true statement about the +# world when it was made, and an open PR can gain a commit afterwards. The +# verdict then describes a head the PR no longer carries, so cleanup must STOP: +# passing on it would delete the local copy while the PR ships a commit nothing +# claimed and nothing validated. Staleness blocks; it is not a warning, and it +# is not a downgrade to absence. The local copy is deliberately left landed on +# origin so the landed-work gates all pass and only the claim gate can refuse - +# which is exactly the shape of the real failure, where everything local looks +# finished and only the forge knows the PR moved. +test_a_stale_verdict_blocks_cleanup() { + local case_dir head moved rc=0 + case_dir=$(make_pr_claim_case claim-stale-blocks) + head=$(git -C "$case_dir/wt" rev-parse HEAD) + moved=$(git -C "$case_dir/wt" rev-parse 'HEAD~1') + [ "$moved" != "$head" ] || fail "setup: the fixture has no second commit to move to" + establish_pr_claim "$case_dir" "$head" + + # The PR gains a commit the claim never named. Nothing local changed. + set +e + FM_FAKE_PR_VIEW="OPEN $moved fm/task-x1 SUCCESS" \ + run_teardown "$case_dir" > "$case_dir/stdout" 2> "$case_dir/stderr" + rc=$? + set -e + [ "$rc" -ne 0 ] \ + || fail "a verdict whose PR head moved under it still carried cleanup through" + assert_grep "has moved since it was established" "$case_dir/stderr" \ + "the refusal did not report the moved head as staleness" + [ -e "$case_dir/state/task-x1.meta" ] \ + || fail "the stale refusal erased the durable task record" + + # The positive control, so the refusal above is not vacuous: the same fixture + # with the PR standing still tears down cleanly. + case_dir=$(make_pr_claim_case claim-stale-control) + head=$(git -C "$case_dir/wt" rev-parse HEAD) + establish_pr_claim "$case_dir" "$head" + set +e + FM_FAKE_PR_VIEW="OPEN $head fm/task-x1 SUCCESS" \ + run_teardown "$case_dir" > "$case_dir/stdout" 2> "$case_dir/stderr" + rc=$? + set -e + expect_code 0 "$rc" "a PR that never moved was refused: $(cat "$case_dir/stderr")" + pass "a verdict whose PR head moved goes stale and blocks cleanup" +} + +# A verdict is true about the world when it was made, and the world moves. A +# standing `verified` record must rescue only the ABSENCE result, never the +# evidence one, or cleanup deletes the local copy while the branch carries a +# commit nobody claimed and nothing validated. +test_a_standing_verdict_does_not_survive_the_head_moving() { + local case_dir head moved claim hash rc=0 + case_dir=$(make_claim_case claim-moved "placeholder") + head=$(git -C "$case_dir/wt" rev-parse HEAD) + claim="done: branch=fm/task-x1 head=$head - shipped" + printf '%s\n' "$claim" > "$case_dir/state/task-x1.status" + + # Establish it, exactly as a real registration would. + set +e + FM_HOME="$case_dir" FM_STATE_OVERRIDE="$case_dir/state" FM_DATA_OVERRIDE="$case_dir/data" \ + "$ROOT/bin/fm-verify-done.sh" task-x1 >/dev/null 2>&1 + rc=$? + set -e + expect_code 0 "$rc" "setup: the honest claim did not verify" + assert_grep verified "$case_dir/state/task-x1.done-verdict" \ + "setup: no standing verified verdict was recorded" + + # Now the world moves under it: the branch gains a commit the claim never named. + wt_commit "$case_dir" "a commit the claim never named" + moved=$(git -C "$case_dir/wt" rev-parse HEAD) + [ "$moved" != "$head" ] || fail "setup: the branch did not move" + + set +e + run_teardown "$case_dir" > "$case_dir/stdout" 2> "$case_dir/stderr" + rc=$? + set -e + [ "$rc" -ne 0 ] \ + || fail "a standing verdict let cleanup proceed after the head moved under it" + assert_grep "contradicted" "$case_dir/stderr" \ + "the refusal did not report the moved head as contradicting the claim" + [ -e "$case_dir/state/task-x1.meta" ] \ + || fail "the refusal erased the durable task record" + pass "a standing verdict does not survive the head it was about moving" +} + test_no_terminal_claim_is_unaffected_by_the_gate() { local case_dir rc=0 case_dir=$(make_claim_case claim-none "") @@ -3049,6 +3191,8 @@ test_force_overrides_a_contradicted_claim() { test_no_terminal_claim_is_unaffected_by_the_gate +test_a_stale_verdict_blocks_cleanup +test_a_standing_verdict_does_not_survive_the_head_moving test_legacy_claim_without_commit_identity_refuses test_claim_naming_the_wrong_commit_refuses test_established_claim_allows_teardown