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 149894ad849..98633d714f4 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 @@ -176,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. @@ -371,15 +373,25 @@ 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 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. +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 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 `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`. 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/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. 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-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-classify-lib.sh b/bin/fm-classify-lib.sh index 506f398cce9..2934a933ab9 100755 --- a/bin/fm-classify-lib.sh +++ b/bin/fm-classify-lib.sh @@ -70,12 +70,49 @@ 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"). -FM_CLASSIFY_CAPTAIN_RE_DEFAULT='done:|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: +# 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 +153,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 @@ -126,6 +166,46 @@ status_is_terminal_verb() { esac } +# 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:-}") + 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 + status_is_captain_relevant "${1:-}" && 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; @@ -145,6 +225,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}" } @@ -348,6 +429,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 @@ -1245,6 +1340,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.$$" @@ -1418,12 +1539,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} @@ -1449,17 +1572,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 + # 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 + 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-crew-state.sh b/bin/fm-crew-state.sh index 267b0902a93..3e0581e8f66 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 @@ -124,16 +152,45 @@ 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 trimmed 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 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 + fi case "$(status_line_verb "$1")" in working) echo working ;; needs-decision) echo parked ;; blocked) echo blocked ;; - done) echo "done" ;; - failed) echo failed ;; + "${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 } @@ -283,8 +340,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 + [ "$(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 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..15bc49b0ced 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 ;; @@ -189,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. @@ -207,7 +238,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..5af9ac98806 --- /dev/null +++ b/bin/fm-done-claim-lib.sh @@ -0,0 +1,500 @@ +#!/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, 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 +# `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. + +# 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_EVALUATED_HEAD= + +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. +# 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" ] +} + +# 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 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 +# `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:-}" ] || return 1 + ! status_line_states_key "$1" +} + +# 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. +# +# 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 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 +# 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 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 + while IFS= read -r line || [ -n "$line" ]; do + trimmed=${line#"${line%%[![:space:]]*}"} + case "$trimmed" in + done*) + fm_done_claim_own_voice "$trimmed" 'done' || continue + last=$line + ;; + failed*) + fm_done_claim_own_voice "$trimmed" 'failed' || continue + last= + ;; + esac + 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 +} + +# 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" +} + +# 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 0 + [ "$FM_DONE_VERDICT_CLAIM_HASH" = "$3" ] || exit 0 + printf '%s' "$FM_DONE_VERDICT" + ) +} + +# 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. +# 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 + [ -d "$state" ] && [ ! -L "$state" ] || return 1 + 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 + 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")" + 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; } +} + +# 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 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 + 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= + 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<&- + 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 + 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 + # 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, +# 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 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 +# 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. +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='the claim names 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..27c3157299d 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 @@ -117,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} @@ -524,6 +560,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 +573,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 ' @@ -692,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+"; " ") @@ -728,7 +770,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"} @@ -770,17 +814,56 @@ 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 + # 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 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[] 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 ] + + [ $unknown_children[].id ] + [ $unowned_children[].id ] + + $accounted_exclusions) + | unique) as $accounted_ids + | ([ $tasks[] + | select(.kind != "secondmate") + | 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 is accounted for by nothing: " + + ($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(", ")) @@ -790,8 +873,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"] - | 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" @@ -1507,10 +1589,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/bin/fm-inactive-reconcile.sh b/bin/fm-inactive-reconcile.sh index 52064545836..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. # @@ -96,6 +104,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 @@ -328,9 +338,28 @@ 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 - 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 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" + fi [ -z "$pr" ] || line="$line pr=$pr" fm_parent_channel_report "$FM_HOME" "$STATE" "$line" } @@ -349,7 +378,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 @@ -357,10 +388,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 @@ -394,13 +423,39 @@ 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") + state=$(fm_done_claim_own_terminal_verb "$last") || return 0 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") @@ -419,13 +474,14 @@ 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" + 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 @@ -488,12 +544,24 @@ 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 + # delivered - the whole reason this backstop exists. Matching only `done` + # would silently drop exactly the outcomes that most need a supervisor. + # + # `ready` is here for the same reason. It is not terminal, but a stopped + # worker that has handed off for validation is waiting on a supervisor action + # nobody has taken, which is the state this backstop exists to catch; the same + # handoff reached this path as a pre-validation `done:` before it had a verb of + # its own, and giving it one must not quietly remove its backstop. case "$state_line" in 'state: done '*) state='done' ;; + 'state: done-unverified '*) state='done-unverified' ;; 'state: failed '*) state='failed' ;; + 'state: ready '*) state='ready' ;; *) return 0 ;; esac pr=$(pr_for_task "$meta" "$status") @@ -510,13 +578,22 @@ 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 - local provider host path number +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 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. 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 +92,104 @@ 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. + # + # Whether the task holds such a record is a fact to read, not to assume: the + # poll is armed at PR registration (bin/fm-pr-check.sh), long before any + # terminal claim, so a PR closed while the worker is still working has no done + # record to contradict. Asserting one would make this publication itself the + # unfounded claim the whole verdict path exists to stop. Read in a subshell so + # the claim globals a caller may be holding are left alone. + # + # The same probe also reports WHICH PR the standing claim is about, normalised + # to the same identity this observation carries, because a task may be reused + # for a second PR and re-registered while an established claim about the first + # still stands. + claim_probe=$(fm_done_claim_status "$state" "$id" >/dev/null 2>&1 \ + && 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_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 + + # 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. + # + # "This task's standing claim is about the PR that just closed" is ONE fact, so + # it is decided once here and every consumer below reads this variable. The + # verdict and the narration are two statements of that same fact - a durable + # `contradicted` record and the words the captain and the parent channel are + # told - and they came apart once already, inside the very commit that added + # `claim_pr` to hold them together: the record learned the narrower test while + # the wording was left on "a claim exists". Neither may be changed without the + # other, which is why neither re-derives the test. + # + # Why the close arm needs the test and the merge arm does not. `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 standing on an + # established claim about the first would otherwise have that claim marked + # false, and be announced as claiming done about a PR it never named. `stale` + # needs no such test: it only asks the next gate to re-verify against the world + # that now exists, which is harmless whichever claim is standing. + if [ "$claimed" -eq 1 ] && [ "$claim_pr" = "$provider $host $path $number" ]; then + claims_this_pr=1 + fi + if [ "$claimed" -eq 1 ] && [ -n "$claim_hash" ]; then + if [ "$outcome" != merged ]; then + if [ "$claims_this_pr" -eq 1 ]; then + claim_verdict=contradicted + claim_reason="$FM_PR_URL was closed without merging, so this task is not done" + fi + 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" + elif [ "$claims_this_pr" -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 - line="done [key=merged-$id]: merged $id $FM_PR_URL" + if [ "$outcome" = merged ]; then + line="done [key=merged-$id]: merged $id $FM_PR_URL" + elif [ "$claims_this_pr" -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=$? [ "$self_rc" -eq 1 ] || return 3 @@ -78,7 +202,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 +213,25 @@ 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 + # After the report, never in front of it. This write can fail for reasons that + # have nothing to do with the outcome - a symlink planted at the verdict path, + # a full or read-only state directory - and gating the publication on it would + # turn a closed-unmerged observation, the exact rot this whole path exists to + # catch, into no parent line, no captain wake, and (on the poll path, through + # bin/fm-watch.sh's fatal non-zero handling) a dead watcher. Publishing first + # is the same order the marker already uses, for the same reason: both + # publications are key-deduplicated, so an at-least-once retry cannot + # duplicate. A failure here still sets status, so the retry happens; it simply + # no longer has a veto over the report. + if [ -n "$claim_verdict" ]; then + fm_done_verdict_write "$state" "$id" "$claim_verdict" "$claim_hash" "$claim_reason" \ + || 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-check.sh b/bin/fm-pr-check.sh index 99b3e025db2..a9e78e23744 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 @@ -19,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 @@ -152,4 +158,26 @@ 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. +# 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:-}" \ + 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-pr-lib.sh b/bin/fm-pr-lib.sh index d9580dc9b4a..0759aa0b35a 100755 --- a/bin/fm-pr-lib.sh +++ b/bin/fm-pr-lib.sh @@ -703,7 +703,7 @@ fm_pr_poll_retirement_parse() { [[ "$check_identity" =~ ^[0-9]+:[0-9]+$ ]] || return 1 [[ "$reg_hash" =~ ^[0-9a-f]{64}$ ]] || return 1 [[ "$reg_identity" =~ ^[0-9]+:[0-9]+$ ]] || return 1 - [ "$result" = merged ] || return 1 + fm_pr_poll_outcome_valid "$result" || return 1 FM_PR_RETIRE_ID=$id FM_PR_RETIRE_PROVIDER=$provider FM_PR_RETIRE_URL=$url @@ -850,7 +850,7 @@ fm_pr_poll_retirement_discard_obsolete() { fm_pr_poll_retirement_publish() { local state=$1 id=$2 template=$3 result=$4 receipt state_device tmp - [ "$result" = merged ] || return 1 + fm_pr_poll_outcome_valid "$result" || return 1 fm_pr_poll_snapshot_matches "$state" "$id" "$template" || return 1 state_device=$(fm_pr_file_device "$state") || return 1 receipt="$state/$id.pr-poll-retirement" @@ -872,7 +872,7 @@ fm_pr_poll_retirement_publish() { "$FM_PR_POLL_SNAPSHOT_CHECK_IDENTITY" \ "$FM_PR_POLL_SNAPSHOT_REG_HASH" \ "$FM_PR_POLL_SNAPSHOT_REG_IDENTITY" \ - merged > "$tmp" \ + "$result" > "$tmp" \ || ! chmod 0600 "$tmp" \ || ! fm_pr_private_file_valid "$tmp" 600 "$state_device" \ || ! fm_pr_poll_retirement_parse "$tmp" \ @@ -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,71 @@ 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, and the outcomes a +# retirement receipt may record. Both retire the poll on the same condition, +# because a poll exists to OBSERVE a terminal outcome and has nothing left to do +# once that outcome is on record: the emitter reporting it. A close with no +# standing claim, or with a claim about a different PR, writes no verdict and is +# still fully recorded; an outcome that could not be recorded keeps its poll +# armed, which is what brings it back. +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-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 e200bc80b0f..dc8257b48c6 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,81 @@ 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`). 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 + 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) + 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 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 + 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..a958f3a3dd2 --- /dev/null +++ b/bin/fm-verify-done.sh @@ -0,0 +1,699 @@ +#!/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 +# - (direct-PR only) the branch the forge says the PR is built from is this +# task's own fm/. 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 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, 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 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/. That binding is +# 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 +# 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 +# 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 +# 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. +# 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 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`. +# +# 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 +# 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)" +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" +# 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 ;; + # 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 + 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= +# 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 +# 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" "$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 + 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 'the claim names 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' "$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. + # + # Refusing to resolve a path is a refusal to READ, though, not a finding that + # the claim is false - `data//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 unverified "the claimed report names an upward walk, which is not resolved as evidence: $FM_DONE_CLAIM_REPORT" + finish + ;; + esac + case "$REPORT" in + /*) ;; + 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/. + # + # 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/"*) 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" + 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 + # 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 + 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" + 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 empty: $FM_DONE_CLAIM_REPORT" \ + "$REPORT is a regular file of zero bytes" + fi + 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' + 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 + if [ -z "$BRANCH" ]; then + 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" "$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" \ + "the readable object database at $WT holds no commit $HEAD_CLAIM" + 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" "$TIP" + finish + fi + # 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. + # + # 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" \ + "$BRANCH was created at $CREATED and its tip is still $TIP" + finish + fi + # 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 + # 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 + 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" "$DEFAULT_REF" 2>/dev/null; then + # 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, 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) + 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' "$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" "$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,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 + 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'*} +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 + CLOSED|closed) + 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) ;; + *) + 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 +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 + +# 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 + 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 + +# --- 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" "$RUN_HEAD" + 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)") +# 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/bin/fm-wake-drain.sh b/bin/fm-wake-drain.sh index 88fc8edb5d8..3506b8b97f0 100755 --- a/bin/fm-wake-drain.sh +++ b/bin/fm-wake-drain.sh @@ -345,15 +345,74 @@ 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 TASK's unread span prints before the drain +# starts counting them instead. The budget is per task, reset at each task +# transition: a single per-drain budget would let one chatty worker consume it +# and defer every alphabetically later task's prose behind itself, drain after +# drain, which is the starvation this cap exists to prevent rather than cause. +# 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, 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 +# 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. +# 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 + 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 @@ -361,19 +420,56 @@ 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 - line="$task $line" + if [ "$task" != "$current" ]; then + hold_unread_remainder "$current" "$through" "$held_back" "$snapshot" || return 1 + current=$task + through=0 + held_back=0 + unrecognised=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 + 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 + printf '%s\n' "$header" || return 1 fi printf '%s\n' "$line" || return 1 + # 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 < - 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 } @@ -1660,18 +1660,35 @@ 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, 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. + # 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 \ + 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" + retire_terminal_pr_poll "$id" "$out" 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/architecture.md b/docs/architecture.md index aa7284dc02b..2e85b97c001 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -25,11 +25,13 @@ 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. +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=`. -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. @@ -50,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. @@ -62,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/configuration.md b/docs/configuration.md index b6a61b472a1..5167c25647c 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -839,6 +839,10 @@ FM_CODEX_WATCH_CHECKPOINT=180 # seconds per foreground watcher checkpoint in C FM_CREW_STATE_NM_TIMEOUT=10 # seconds allowed per no-mistakes query inside fm-crew-state.sh FM_TEARDOWN_NM_TIMEOUT=10 # seconds allowed per no-mistakes query or abort inside fm-teardown.sh FM_CREW_STATE_RUNS_LIMIT=200 # recent no-mistakes run rows scanned when axi status cannot be attributed directly +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; 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 @@ -869,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='^[[:space:]]*done:|^[[:space:]]*ready:|^[[:space:]]*needs-decision:|^[[:space:]]*blocked:|^[[:space:]]*failed:|PR ready|checks green|ready in branch|merged' # captain-relevant status regex; verb tokens are anchored to the leading position they are documented to occupy so worker prose containing one is not absorbed as a state, while the legacy free-text tokens stay unanchored; 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/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/scripts.md b/docs/scripts.md index 9b33fced128..8934d00f63e 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` and a pre-validation handoff as `ready` | | `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-check.sh` | Record validated `pr=` and `pr_head=` values, then atomically arm a static merge poll | +| `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, 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 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..be92fa7b111 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` | @@ -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. diff --git a/tests/fm-captain-hold-lifecycle.test.sh b/tests/fm-captain-hold-lifecycle.test.sh index ec5ef8c8b6b..689eddaf860 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) @@ -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 @@ -1283,7 +1311,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 +1346,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 +1363,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 +1405,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 +1475,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" - printf 'done: report complete\n' > "$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" \ @@ -1541,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-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-crew-state.test.sh b/tests/fm-crew-state.test.sh index a284cbe8eb6..71128b22fad 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,39 @@ 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" "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" 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 +504,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 +520,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 +536,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 +694,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 +782,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 +807,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" } @@ -923,6 +951,148 @@ 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" +} + +# 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" + + # `ready` says THIS worker committed and awaits the go-ahead to validate, so it + # is under the same speaker test the terminal verbs are. A secondmate publishes + # a child's handoff upward as `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. + reset_fakes + d=$(new_case ready-keyed) + make_repo_on_branch "$d/wt" fm/feat-rk + make_fakebin "$d" >/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 + 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 @@ -1061,7 +1231,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 +1725,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 @@ -1568,6 +1739,9 @@ 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_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 new file mode 100755 index 00000000000..372cf507b3b --- /dev/null +++ b/tests/fm-done-verified.test.sh @@ -0,0 +1,2011 @@ +#!/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 +# (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, and +# what the cap holds back re-presents on the next drain rather than +# being skipped past forever +# (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 +# (y) a local-only branch that introduced nothing cannot satisfy a claim, in +# the shape this fleet actually produces: the branch EXISTS at the spawn +# base because the brief has the worker create it before any work +# (z) a scout report present behind a symlink is unverified, not contradicted +# (ad) a merge marks an established claim stale; a close contradicts it; and +# neither invents a verdict for a task that claimed nothing +# (ae) verdict write precedence: absence never downgrades, falsity outranks +# 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 +# (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) 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 +# 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 +# that can name nothing observed is recorded unverified instead +# (ac) prose the cap held back survives a recognised line printed after it in +# the same span - the shape that silently lost a worker's words +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" +# 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" +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. +# +# This is a standalone repository, NOT the linked worktree of a project repo a +# real spawn produces (make_local_world below is the fixture shaped like a real +# spawn). That is sufficient here and only here: the PR arm judges the claim +# against the forge and the validation run and never consults a default branch +# or a merge base, so the worktree's relationship to a parent repo cannot change +# any verdict it reaches. The same applies to the kind=scout cases, which are +# left on a branch with commits while a real scout sits at detached HEAD in a +# scratch worktree: the scout arm reads only the claimed report path. +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". defaults to +# the home's own data dir; a case that relocates it passes its own. +verify() { # [data-root] + 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" +} + +# 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" + 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 + 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 + 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" +} + +# 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" +} + +# --- (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 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" +} + +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 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" + 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 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" +} + +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 fm/task-v SUCCESS" verify "$dir") + [ "${result%%$'\t'*}" = 3 ] || fail "a legacy claim did not exit unverified: $result" + 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 + 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 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 + 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 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" + 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" +} + +# --- 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() { + 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" +} + +# 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() { + 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 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 ] \ + || 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" +} + +# --- (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, 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'*}" = 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" + 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 work result + out=$(make_local_world local-retired) || fail "the local-only fixture failed" + dir=${out%%$'\t'*} + # 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 +# 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 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") + 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 + # 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'} + [ "${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 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" + 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" +} + +# --- (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 + *"(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 + *"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() { + 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. + # 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" + 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" +} + +# 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" +} + +# A VERB earns captain relevance from the leading position it is documented to +# occupy, never from appearing anywhere in a line. Unanchored, worker prose that +# happens to contain a verb token counted as a real state: it became "known", so +# it dropped out of the unread surface and woke firstmate while classifying as +# nothing - the exact silent absorption the surface exists to end. The legacy +# free-text tokens are prose and keep matching wherever they appear. +test_a_verb_earns_relevance_only_from_the_leading_position() { + local line + for line in \ + '- integration suite ready: green' \ + 'rebased, done: nothing left to do' \ + '- ruff check on all changed files: failed: 0 errors'; do + status_is_captain_relevant "$line" \ + && fail "worker prose became captain-relevant from a verb token mid-line: $line" + status_line_verb_is_known "$line" \ + && fail "worker prose counted as a known state from a verb token mid-line: $line" + status_line_is_unrecognized "$line" \ + || fail "worker prose carrying a verb token mid-line was not unrecognised: $line" + status_line_is_unread_surface "$line" \ + || fail "worker prose carrying a verb token mid-line never reached the unread surface: $line" + done + + # A real leading line still earns it, indented or not. + for line in \ + 'done: pr=https://github.com/o/r/pull/1 head=00112233445566778899aabbccddeeff00112233 - shipped' \ + ' done: pr=https://github.com/o/r/pull/1 head=00112233445566778899aabbccddeeff00112233 - shipped' \ + 'ready: implementation complete and committed' \ + ' ready: implementation complete and committed' \ + 'failed: the work did not land' \ + 'needs-decision: which database?' \ + 'blocked: waiting on credentials'; do + status_is_captain_relevant "$line" \ + || fail "a real leading status line stopped being captain-relevant: $line" + status_line_verb_is_known "$line" \ + || fail "a real leading status line stopped being a known state: $line" + status_line_is_unrecognized "$line" \ + && fail "a real leading status line was flagged as matching no status verb: $line" + done + + # The legacy free-text tokens describe prose and have no leading position to + # earn, so they still match wherever they appear. + status_is_captain_relevant 'merged' || fail "a legacy bare merged line stopped matching" + status_is_captain_relevant 'PR ready for review' || fail "a legacy PR ready line stopped matching" + status_is_captain_relevant 'the branch is ready in branch fm/x' \ + || fail "a legacy ready-in-branch line stopped matching" + pass "a verb earns captain relevance from its leading position, while legacy prose tokens match anywhere" +} + +# 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 +# that as "created here, introduced nothing" would report a true claim as false. +# A history that no longer reaches the creation is absence of evidence. +test_a_pruned_reflog_is_unverified_not_contradicted() { + local out dir tip result + out=$(make_local_world local-pruned-reflog) || fail "the local-only fixture failed" + dir=${out%%$'\t'*} + git -C "$dir/wt" commit -q --allow-empty -m "work the task itself produced" + tip=$(git -C "$dir/wt" rev-parse HEAD) + git -C "$dir/wt" reflog show refs/heads/fm/task-v --format='%gs' \ + | tail -1 | grep -q '^branch: Created from ' \ + || fail "the fixture did not leave a branch-creation entry to expire" + # Exactly what gc.reflogExpire leaves behind on a long-lived branch: the + # creation entry gone, the branch's own tip now the oldest entry git can see. + git -C "$dir/wt" reflog delete 'refs/heads/fm/task-v@{1}' \ + || fail "the fixture could not expire the branch-creation entry" + [ "$(git -C "$dir/wt" reflog show --format=%H refs/heads/fm/task-v | tail -1)" = "$tip" ] \ + || fail "expiring the creation entry did not leave the tip as the oldest entry" + printf 'done: branch=fm/task-v head=%s - shipped\n' "$tip" > "$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" +} + +# --- 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 ---------------------------- +# 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) + 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 + # 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" + [ "${result%%$'\t'*}" = 3 ] \ + || fail "an unresolvable spelling was not unverified: $result" + case "${result#*$'\t'}" in + *"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. + 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" +} + +# 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. +# 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" + 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" +} + +# 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: [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" + 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, 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" +} + +# 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 ----------------------------------- +# A verdict is a statement about a world. Absence of evidence must never +# downgrade one; positive evidence of falsity outranks everything; and a world +# that has CHANGED under a verdict leaves it neither wrong nor still valid, +# which is the third shape the record now names. The site that observes a PR +# reach a terminal state is the only one that can see the third, so these drive +# it through fm_merge_outcome_report rather than through the verifier. + +HEAD_A=00112233445566778899aabbccddeeff00112233 +PR_A=https://github.com/o/r/pull/7 + +# A world holding one terminal claim, optionally with a verdict already +# standing for it. Echoes the state dir; the caller reads the record back +# through the library rather than through a global, because this runs inside a +# command substitution. +make_claim_world() { # [ ] + 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" +} + +# `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" +} + +# The verdict and the words are two statements of one fact, so they must not be +# able to disagree. A close under a claim about a different PR, or under a claim +# that names no PR at all, writes no contradiction and must not tell the captain +# the task claims done about the PR that closed either. +test_a_close_narrates_only_what_it_contradicts() { + local dir other queue + other=https://github.com/o/r/pull/8 + + dir=$(make_claim_world close-narration-other "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 "$other" poll closed-unmerged \ + || fail "the close outcome could not be published" + queue=$(cat "$dir/state/.wake-queue" 2>/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() { + 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" +} + +# 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() { + 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" +} + +# 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) + 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' + 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 '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")" + + # 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 +# 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 +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_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 +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 +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 +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_prevalidation_handoff_is_not_a_claim +test_a_verb_earns_relevance_only_from_the_leading_position +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_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 +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 +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 +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..375c0a1e447 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 ' @@ -799,6 +811,188 @@ test_parked_scout_decision_stays_pending() { pass "a scout still parked at a decision stays pending (terminal clear does not over-fire)" } +# Every live child must land in a bucket the summary actually reports, and the +# guard for that is exhaustive BY CONSTRUCTION rather than by a list somebody +# remembered to extend: a task belonging to no bucket is a named invalidity, so a +# state added later without touching the buckets fails loudly instead of +# vanishing while the summary still says valid. `ready` is the state that +# exposed this - it means firstmate must act, and it belonged nowhere. +test_home_summary_buckets_every_live_child() { + local home fakebin out root + home=$(make_home summary-exhaustive) + mkdir -p "$home/projects/handoff" + cat > "$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" +} + +# 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" + + # 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 +# 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. @@ -899,6 +1093,9 @@ 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_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 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-inactive-reconcile.test.sh b/tests/fm-inactive-reconcile.test.sh index 6a596dab169..26cf460c907 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,163 @@ 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 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" +} + +# 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" +} + +# 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" +} + +# 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() { @@ -240,16 +399,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 +509,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 +534,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 +828,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 +947,12 @@ 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_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 diff --git a/tests/fm-pr-check-security.test.sh b/tests/fm-pr-check-security.test.sh index e5e3eb09ede..c069c7dedb6 100755 --- a/tests/fm-pr-check-security.test.sh +++ b/tests/fm-pr-check-security.test.sh @@ -146,6 +146,12 @@ case "${1:-} ${2:-}" in ;; esac case " $* " in + *"state,headRefOid,headRefName,url,statusCheckRollup"*) + [ "${FM_TEST_GH_SLEEP:-0}" = 0 ] || sleep "$FM_TEST_GH_SLEEP" + 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 "*) [ "${FM_TEST_GH_FAIL:-0}" = 0 ] || exit 1 @@ -622,6 +628,79 @@ 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 started elapsed + 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" + + # 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" +} + test_rejected_metacharacter_bytes_are_inert() { local dir family rc before after dir=$(make_case rejected-metacharacters) @@ -687,15 +766,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 +1382,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") @@ -1471,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 @@ -1920,16 +2061,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,15 +2083,45 @@ test_external_merge_transition_retires_only_terminal_poll() { ack_watcher_cycle "$state" || fail "$label control wake acknowledgement failed" done - rm -f "$state/z-stop.check.sh" "$state/z-stop.check-trust" "$state/.last-check" + # A pull request CLOSED without merging is not silence: it contradicts a task + # 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=MERGED run_watcher_bounded "$dir/home" "$dir/fakebin" > "$dir/merged.out" 2> "$dir/merged.err" + 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 "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 + [ "$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 + ack_watcher_cycle "$state" || fail "closed-unmerged wake acknowledgement failed" assert_poll_absent "$state" task-a - pass "open/red, closed-unmerged, malformed, and forge errors remain armed until an exact merged transition" + + 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" + + # 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() { @@ -1979,6 +2147,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 \ @@ -2122,8 +2307,10 @@ 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_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 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) +# (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) +# (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). # (r) provably-stale index.lock (old mtime, no live holder) -> lock removed, ALLOW @@ -1396,14 +1413,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 +1437,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 +1463,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 +2755,454 @@ 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" +} + +# 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 "") + 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 "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" +} + +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" +} + +# 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. +# 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. +# 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") + 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. 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: 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 + . "$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") + 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") + 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_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 +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 test_teardown_closes_the_backlog_item_itself test_teardown_manual_backend_leaves_the_backlog_to_the_operator