diff --git a/docs/BACKLOG.md b/docs/BACKLOG.md index c2c1e4982..30e1dda0b 100644 --- a/docs/BACKLOG.md +++ b/docs/BACKLOG.md @@ -207,7 +207,7 @@ Ordered by value descending, then difficulty ascending (cheapest first at equal |--:|---|---|--:|--:|---|---|---|---| | 1 | **#1328** | no cell-to-item map exists, so an ASVS re-score cannot be handed to the seat that must flip the banner | 6 | 2 | _quick win_ | P1 | partly shipped | The check itself shipped -- scripts/asvs/rescore_handoff_check.py implements the date comparison at :260-275, and tests/test_asvs_rescore_handoff.py passes 21 of 21 under the project venv -- so that limb is met, but the item's own stated remainder is not. Driving the tool against the vault scorecard from both this worktree and the primary checkout exited 3 with "REFUSING: the ledger history is TRUNCATED at a shallow graft boundary", because the guard at rescore_handoff_check.py:214 fired when a ledger walk began at a graft and ca8a7488, the oldest visible revision of docs/BACKLOG.md, is listed in .git/shallow; no non-shallow engine checkout exists beside this one. **CLEARED 2026-09-03 without deepening any clone, and the fix was NOT the one this paragraph proposed.** The graft-predates-the-ledger case it names was already handled by construction: `git log` over a path lists only revisions where that path CHANGED, so a graft older than the ledger's creation is never the oldest line, and the oldest line is a graft exactly when the path already existed at the boundary -- which is the real state here, so that distinction alone would have gone on refusing. **The truncation test was necessary but not sufficient, and the missing half is a date.** A floored last-touch is always later than or equal to the truth, so the floor can only suppress a hit dated AT OR BEFORE the graft boundary; a re-score strictly after it is decided identically on the floored date and on the true one. The verdict is therefore per PAIR, and the whole run is refused only when NOTHING is decidable. Value 6 because a ledger-integrity screen that refuses in the only environment it ships into leaves the step-one-done step-two-missing disagreement undetected exactly as filed; difficulty 2 for a small additive change on an existing tested seam plus the record edit. | | 2 | **#1334** | The dispatch gate green-lights demand-gate and owner-ruling verdicts, the two that mean do not just build it | 6 | 2 | _quick win_ | P1 | partly shipped | Partly shipped -- the two advise limbs landed and the retirement limb is written but not on main. GATED_VERDICTS at scripts/coord/dispatch_gate.py:108 and the branch at :175 advise on demand-gate and owner-ruling, pinned by tests/test_coord_dispatch_gate.py:237 and :320. The retirement limb is absent from HEAD: judge() at scripts/coord/dispatch_gate.py:134 takes only the item and reads only item.fields at :152, and the word retire appears nowhere in that file. I ran the limb's own needles over the ledger at HEAD -- three open rows are retired in place (#1309, #1311, #1332) and the gate grades #1332 ok, the row whose body says it is a duplicate of #1086 and should not be built, which is what holds value at 6 against the awkward workaround of reading each row by eye. Landing it is cheaper than a rebase: eaf6d0940 on claude/builder-2-1334-retirement-limb is 363 lines across the gate and its tests, git merge-base --is-ancestor returns false against main, but git merge-tree main eaf6d0940 reports no conflict and main has touched neither file since the merge base, so the cost is a clean merge plus ruff, mypy and pytest. | -| 3 | **#1374** | fleet.ps1 originMainAgeMinutes reports fetch recency but measures when origin/main last MOVED, and on the packed-refs path it is null so the stop condition cannot fire | 6 | 2 | _quick win_ | P1 | not started | Not started -- nothing outside docs/BACKLOG.md cites this item, and both halves reproduce in this checkout. scripts/coord/fleet.ps1:178 stats the ref file refs/remotes/origin/main while naming the variable $fetchHead; measured here that loose ref is dated 2026-09-02 19:41 against a .git/FETCH_HEAD of 2026-09-03 12:27, so the stop at scripts/coord/fleet.ps1:275 would print DO NOT TREAT THE ROSTER BELOW AS COMPLETE (scripts/coord/fleet.ps1:491) about a fetch made sixteen hours later. The packed-refs half is live rather than theoretical: .git/packed-refs line 634 already carries a stale refs/remotes/origin/main at 4b3b2f96, so the loose ref is the only thing keeping the field non-null, and without it Test-Path fails, the value stays null, and the null guard at scripts/coord/fleet.ps1:275 makes a blind instrument render exactly like a healthy one. Landing it is a small change on one existing seam -- read a real fetch clock, make the unmeasurable case a stop rather than silence, and pin both directions in a Windows-gated test beside tests/test_coord_handoff_pointer.py. | +| 3 | **#1374** | fleet.ps1 originMainAgeMinutes reports fetch recency but measures when origin/main last MOVED, and on the packed-refs path it is null so the stop condition cannot fire | 6 | 2 | _quick win_ | P1 | built, PR open | Built 2026-09-03, not merged -- see the item's own note for what landed and what remains. At scoring time: nothing outside docs/BACKLOG.md cited this item, and both halves reproduce in this checkout. scripts/coord/fleet.ps1:178 stats the ref file refs/remotes/origin/main while naming the variable $fetchHead; measured here that loose ref is dated 2026-09-02 19:41 against a .git/FETCH_HEAD of 2026-09-03 12:27, so the stop at scripts/coord/fleet.ps1:275 would print DO NOT TREAT THE ROSTER BELOW AS COMPLETE (scripts/coord/fleet.ps1:491) about a fetch made sixteen hours later. The packed-refs half is live rather than theoretical: .git/packed-refs line 634 already carries a stale refs/remotes/origin/main at 4b3b2f96, so the loose ref is the only thing keeping the field non-null, and without it Test-Path fails, the value stays null, and the null guard at scripts/coord/fleet.ps1:275 makes a blind instrument render exactly like a healthy one. Landing it is a small change on one existing seam -- read a real fetch clock, make the unmeasurable case a stop rather than silence, and pin both directions in a Windows-gated test beside tests/test_coord_handoff_pointer.py. | | 4 | **#1375** | install-gate.ps1 REPLACES the machine-wide gate allowlist on every run rather than merging, with no backup and no warning, and a dropped root fails open silently | 6 | 2 | _quick win_ | P1 | built, unlanded | Not started, and every mechanism claim holds at HEAD: scripts/worktree/install-gate.ps1:495 writes the allowlist with a bare Set-Content, the install branch defaults to exactly one root at :424, the only Get-Content of that file sits in the -Status branch at :300 which returns first, and the param block at :48-70 offers no -Add or -Merge. No backup exists, and two places assert one does -- install-gate.ps1:441 says the allowlist writer has done this since #1375 and tests/test_install_gate_records_the_install.py:16 repeats it, while the only Copy-Item backups cover settings.json at :164 and the gate script at :449. The precondition is live: the machine allowlist at ~/.claude/hooks/worktree-gate.repos.txt carries both MessageFoundry and MessageFoundry-vault, so a bare run from either drops the other. Correcting one anchor, since the silence has a different cause than filed -- a bare run writes ONE root, so the zero-root kill switch at scripts/hooks/worktree_gate.ps1:1259 never fires; the dropped root is simply absent from the roots list, matches no rule, and the gate exits 0 at :2447 and :2784 with no line printed. Left to build: read-and-merge, a .bak beside the allowlist, a warning naming any root the run is about to drop, and a deliberate way to still narrow scope; the workaround is awkward because -Repo is [string[]] at :50 so every governed root must be re-named on every run and docs/WORKTREE-GATE.md:157-158 reads additive, and the cost is a small additive change to one script plus a test on the region-extraction seam tests/test_install_gate_records_the_install.py already establishes. The Why text above is the 2026-09-03 scoring pass verbatim and is left as filed. UPDATE 2026-09-03: its "not started" is FALSE and the Build state cell moved because of it -- this item was built on 2026-08-29 in commits 7af7bb9b6 and 34f29c4f8 on the local branch b1-1375-install-gate-merge, neither an ancestor of origin/main and neither ever proposed in a PR. Read the item's in-progress banner before starting any work on it. | | 5 | **#1421** | Record what the #1277 grant-trail default costs: unbounded audit_log growth, a standalone commit per authenticated read, and a decision record outside the ADR | 6 | 2 | _quick win_ | P1 | partly shipped | **Corrected 2026-09-03, after this row was scored:** costs 3 and 4 shipped -- the `_audit_all_authz` docstring and the ADR 0118 section 5 amendment -- so the last two sentences of the scoring measurement below no longer hold. Costs 1 and 2 stay open pending the owner ruling; see the item's Update note. The scoring measurement as taken, unchanged: All four costs stand at HEAD and none has been closed. Nothing bounds audit_log: [retention].audit_days defaults 0 and is documented reserved and unenforced at messagefoundry/config/settings.py:1675-1678, messagefoundry/store/base.py declares eight purge entry points between :1202 and :1849 with none for audit, and a grep of messagefoundry/ finds no reader of audit_days outside those two files, so an operator who sets a window gets no purge and no warning -- the accepted-but-inert knob is the gap, and the only workaround is flipping the #1277 default back off, which lowers the rate without bounding the table. The three records still disagree about why: messagefoundry/config/retention_classification.py:17-19 rests the rationale on the retention requirement "not on chain-breakage" while messagefoundry/config/settings.py:1675-1677 and docs/PHI.md:118 give both reasons, and that question decides whether in-place deletion is open at all. Cost 3's docstring is unchanged at messagefoundry/api/security.py:177-179, and ADR 0118's amendment at docs/adr/0118-secure-by-default-security-configuration-section.md:165 still records the outcome and the delegation without the two questions or the eight options. What this row ships is an owner ruling plus reconciled prose across five artifacts, so the cost is doc work held consistent rather than engine change. | | 6 | **#1341** | block-blanket-git-stage splits without quote state, so quoted prose reaches program position and denies | 6 | 3 | _quick win_ | P2 | partly shipped | The over-deny limb landed and I drove it: all six pinned prose and read-only payloads from this item now ALLOW against the live hook, and 21 real blanket stages still DENY (scripts/hooks/block-blanket-git-stage.ps1:130, tests/test_blanket_stage_guard.py:248-289). The #1229 limb this item made mandatory -- measure the fail-open axis, not only the false denies -- is unmet, and the repair bought the exact loss it warned about: driving the committed hook against its own parent at 786ac8b49^, six payloads flipped DENY to ALLOW, among them "echo it's fine && git add -A" and "echo isn't ready ; git add .", because Hide-QuotedSpans opens a quote span on any bare apostrophe and on a backslash-escaped quote, then blanks the real stage that follows (scripts/hooks/block-blanket-git-stage.ps1:74-88). The MUST_STILL_DENY corpus that exists to catch precisely this carries no unbalanced-quote or escaped-quote row, so the test arm reports success while missing the class (tests/test_blanket_stage_guard.py:392-424). Two smaller limbs are also open: the guard grew a third local scanner instead of consuming #1086's helper, by its own admission and with a stated deletion plan (scripts/hooks/block-blanket-git-stage.ps1:26-30), and the ledger row still reads "not started" (docs/BACKLOG.md:16424). Landing the remainder means making Hide-QuotedSpans honour backslash escapes and fall back to the unblanked view when the quote state is unbalanced at end of input, then re-driving both corpora so the prose fix does not regress -- a small additive change on one existing seam plus test rows, pwsh-gated. | @@ -18418,6 +18418,31 @@ site_prefixes 1 2 50.0% <- would FAIL a flat 80% rule > **Scored 2026-09-03 -> P1.** Value **6/10** · Difficulty **2/10** · _quick win_. Not started -- nothing outside docs/BACKLOG.md cites this item, and both halves reproduce in this checkout. scripts/coord/fleet.ps1:178 stats the ref file refs/remotes/origin/main while naming the variable $fetchHead; measured here that loose ref is dated 2026-09-02 19:41 against a .git/FETCH_HEAD of 2026-09-03 12:27, so the stop at scripts/coord/fleet.ps1:275 would print DO NOT TREAT THE ROSTER BELOW AS COMPLETE (scripts/coord/fleet.ps1:491) about a fetch made sixteen hours later. The packed-refs half is live rather than theoretical: .git/packed-refs line 634 already carries a stale refs/remotes/origin/main at 4b3b2f96, so the loose ref is the only thing keeping the field non-null, and without it Test-Path fails, the value stays null, and the null guard at scripts/coord/fleet.ps1:275 makes a blind instrument render exactly like a healthy one. Landing it is a small change on one existing seam -- read a real fetch clock, make the unmeasurable case a stop rather than silence, and pin both directions in a Windows-gated test beside tests/test_coord_handoff_pointer.py. > Verdict: build > Closing-act: code +> +> **BUILT 2026-09-03 on branch `worktree-agent-a1ba06ee275587442`. NOT merged, so the banner flip +> stays the Lander's.** Both halves landed in `scripts/coord/fleet.ps1`. The clock now reads a +> `FETCH_HEAD` mtime -- the NEWEST across `` and every `/worktrees/*` git dir, +> because a fetch inside a linked worktree writes its OWN `FETCH_HEAD` and leaves the common one +> untouched. That third clock was not in the item and it is the one every seat here sits behind: +> measured in this checkout at one moment, the loose ref read 34 minutes, `/FETCH_HEAD` 17, +> and the newest worktree clock 3. The field is renamed `lastFetchAgeMinutes` and joined by a +> never-null `lastFetchClock` naming the file that answered. Nothing outside `docs/BACKLOG.md` read +> the old key -- checked over this repo and the vault's `origin/main`, with `fleet.ps1` itself as +> the positive control that the search could see -- so it was renamed rather than doubled. The +> unmeasurable case is now a STOP of its own, and it is not an edge: `git clone` packs the remote +> ref and writes no `FETCH_HEAD` at all, so a fresh clone was silently blind. A generic `(null)` +> sentinel in the text receipt closes the same silence on `originMainSha`, the only other nullable +> receipt field. Pinned by `tests/test_coord_fleet_fetch_clock.py` -- nine arms, Windows-gated, +> positive control first -- and every arm was shown to redden under a mutation of the seam it +> covers. +> +> **WHAT REMAINS, and the first one can still read fresh while the ref is stale.** `FETCH_HEAD` +> means "a fetch happened", not "origin/main was refreshed": `git fetch origin refs/pull/N/head`, +> or a fetch of another remote, bumps the clock and leaves the ref alone. The file's own first line +> names the ref and remote fetched, so closing it is possible; it is stated as a limit in the +> script and not attempted here. `git ls-remote origin main` would answer the real question +> directly in about 0.7 seconds and was DECLINED -- this script is a pure reader a stranded session +> runs, so a network round-trip per render adds the failure mode the instrument exists to survive. **Cluster:** Fleet coordination. **Priority:** P3. **Verdict:** build (small). **Severity:** no engine effect, no PHI axis, no deployment axis (sec. 0). It degrades a roster seats read to decide whether the board in front of them is complete. diff --git a/scripts/coord/fleet.ps1 b/scripts/coord/fleet.ps1 index d23e2578d..85cd63ba1 100644 --- a/scripts/coord/fleet.ps1 +++ b/scripts/coord/fleet.ps1 @@ -169,15 +169,86 @@ if (Test-Path -LiteralPath $errFile) { $writerErrors = @(Get-Content -LiteralPath $errFile -EA SilentlyContinue).Count } -# origin/main's own age. Every landed verdict is computed against this ref, and the ref moves ONLY on -# fetch. A landed verdict against a stale ref is the dangerous direction: this repo carries reverts, -# and against a stale cached main a reverted change reads as "already landed" -- i.e. deliberately -# reverted work would be recorded as done. +# HOW FRESH IS THE origin/main EVERY LANDED VERDICT IS JUDGED AGAINST? A landed verdict against a +# stale ref is the dangerous direction: this repo carries reverts, and against a stale cached main a +# reverted change reads as "already landed" -- i.e. deliberately reverted work recorded as done. A +# remote-tracking ref is only ever refreshed by a fetch, so the age of the ref IS the age of the last +# fetch, and the fetch is the thing to time. +# +# READ THE FETCH CLOCK, NOT THE REF FILE (BACKLOG #1374). This stated fetch recency and stat'ed +# `refs/remotes/origin/main`, whose mtime moves when the REF MOVES and not when a fetch happened. +# They are two clocks. Measured on this repo 2026-08-28: `.git/FETCH_HEAD` at 18:02:16.769 against +# the loose ref at 18:01:24.059 -- a fetch landed 52 seconds AFTER the ref last moved and left the +# ref untouched. Reproduced from an empty sandbox 2026-09-03: a fetch against an unmoved remote +# writes FETCH_HEAD and creates no loose ref at all, so a fleet that fetched seconds ago fired the +# stop and printed DO NOT TREAT THE ROSTER BELOW AS COMPLETE about a fetch that was fresh. +# +# AND THE LOOSE REF WAS NOT EVEN THE COMMON CASE, WHICH IS THE HALF THAT RENDERED HEALTHY. Measured +# 2026-09-03: `git clone` packs `refs/remotes/origin/main` into `packed-refs` and writes NO loose +# ref, and `git pack-refs --all` puts any clone in that state. With nothing to stat the value stayed +# null, the old `-ne $null` guard meant the stop could not fire, and an absent warning renders +# identically to a healthy one. So the unmeasurable case is now a STOP in its own right: this +# instrument must never be silent about being blind. +# +# FETCH_HEAD IS PER-WORKTREE AND THE REMOTE-TRACKING REF IS SHARED, so read the whole clone. +# Measured 2026-09-03: a fetch run inside a linked worktree writes `/FETCH_HEAD` and leaves +# `/FETCH_HEAD` untouched, while `refs/remotes/origin/main` is common to the clone. Every +# seat here works in a linked worktree, so reading only the common dir would report the PRIMARY's +# last fetch. The question is "when was this clone's shared origin/main last refreshed", and any +# worktree's fetch refreshes it -- so the answer is the NEWEST clock in the clone. +# +# THE SAME CONCLUSION WAS REACHED INDEPENDENTLY IN THIS REPO, and the reasoning lives there rather +# than being restated here: `_remote_knowledge()` in scripts/asvs/scorecard.py reads FETCH_HEAD, +# probes more than one git dir, takes the newest, and treats "no clock" as a loud NEVER-FETCHED. It +# probes only the CURRENT worktree's git dir plus the common one, which is right for a tool judging +# ONE tree; this renders a roster for the whole clone, so it sweeps every worktree. +# +# DO NOT "FIX" THIS BACK TO THE REFLOG. It is the obvious-looking alternative -- it survives +# `pack-refs` and is not per-worktree -- and it is the SAME WRONG CLOCK, because a reflog records ref +# MOVEMENTS. Measured on this repo 2026-09-03: the newest `origin/main` reflog entry and +# `.git/FETCH_HEAD` differ by 1000 seconds, the fetch being the newer. `git for-each-ref` is out for +# the same reason: it exposes the upstream COMMIT's date, which here preceded the ref update by 11 +# minutes. No git plumbing reports a fetch time, so the file mtime is the only clock on offer. +# +# STATED LIMITS, all three in the same direction a reader needs to know about: +# 1. `git ls-remote origin main` would answer the real question directly ("is my cached ref +# current?") in about 0.7 seconds, measured. DECLINED: this script is a PURE READER a stranded +# session runs to reconstitute a fleet, so it must work offline and unauthenticated. A network +# round-trip per render buys accuracy by adding the failure mode the instrument exists to +# survive. The clock is therefore a PROXY, deliberately. +# 2. FETCH_HEAD says "a fetch happened", not "origin/main was refreshed". `git fetch origin +# refs/pull/N/head`, or a fetch of a different remote, bumps this clock while leaving +# origin/main untouched -- so this can read FRESH while the ref is stale, which is the +# dangerous direction. The file's own first line names the ref and remote fetched, so closing +# it is possible and is not attempted here. +# 3. `git fetch --no-write-fetch-head` refreshes the ref and writes no clock at all, so it reads +# as no fetch ever. That one fails LOUD, via the unmeasurable stop, which is the safe direction. $originMainSha = Invoke-Git -Dir $repo -GitArgs @('rev-parse', 'origin/main') -$originMainAgeMinutes = $null -$fetchHead = Join-Path $common 'refs\remotes\origin\main' -if (Test-Path -LiteralPath $fetchHead) { - $originMainAgeMinutes = [int]((Get-Date).ToUniversalTime() - (Get-Item -LiteralPath $fetchHead).LastWriteTimeUtc).TotalMinutes + +# The clone's worktrees, enumerated a SECOND way and on purpose: `$repoWorktrees` above lists WORKING +# TREE paths from `git worktree list`, and getting a git dir out of one costs a git child process +# each. The admin directories under `/worktrees` are the git dirs, already. +$fetchClockPaths = @(Join-Path $common 'FETCH_HEAD') +$worktreeGitDirs = Join-Path $common 'worktrees' +if (Test-Path -LiteralPath $worktreeGitDirs) { + foreach ($d in @(Get-ChildItem -LiteralPath $worktreeGitDirs -Directory -EA SilentlyContinue)) { + $fetchClockPaths += (Join-Path $d.FullName 'FETCH_HEAD') + } +} + +$lastFetchAgeMinutes = $null +# NEVER NULL, because this is the line a reader scans past. A blank value beside a null age is how +# the blind case passed for a healthy one; a sentence saying it could not be measured cannot. +$lastFetchClock = 'UNMEASURABLE -- no FETCH_HEAD in this clone (see stop conditions)' +# -LiteralPath, not a glob: a checkout path may contain [ or ], and -Path would treat it as a +# wildcard and silently match nothing. Missing and unreadable both fall out as no item. +$newestFetch = @($fetchClockPaths | + ForEach-Object { Get-Item -LiteralPath $_ -EA SilentlyContinue } | + Sort-Object LastWriteTimeUtc -Descending | + Select-Object -First 1) +if ($newestFetch.Count -eq 1) { + $lastFetchAgeMinutes = [int]((Get-Date).ToUniversalTime() - $newestFetch[0].LastWriteTimeUtc).TotalMinutes + $lastFetchClock = $newestFetch[0].FullName } # --------------------------------------------------------------------------------------------- @@ -272,7 +343,14 @@ $stops = @() if (-not $fenceAvailable) { $stops += 'fenceAvailable=false -- no config root with a sessions/ directory was found; every state below would be a guess' } if ($liveWithoutRecord.Count -gt 0) { $stops += "liveSessionsWithoutRecord=$($liveWithoutRecord.Count) -- the writer is not running in every live seat, so this roster is INCOMPLETE by that many" } if ($records.Count -eq 0 -and $heartbeats.Count -eq 0) { $stops += 'recordsExamined=0 AND writerHeartbeatIn=0 -- indistinguishable from a writer that was never installed' } -if ($null -ne $originMainAgeMinutes -and $originMainAgeMinutes -gt 60) { $stops += "originMainAgeMinutes=$originMainAgeMinutes -- origin/main has not been fetched recently; landed verdicts would be computed against a stale ref" } +# BOTH DIRECTIONS FIRE, and the null one is the reason this rung exists (BACKLOG #1374). The old +# guard was `-ne $null -and -gt 60`, so the one state where the instrument knows nothing was the one +# state it said nothing about. +if ($null -eq $lastFetchAgeMinutes) { + $stops += 'lastFetchAgeMinutes=UNMEASURABLE -- no FETCH_HEAD exists anywhere in this clone, so this instrument CANNOT tell a fetch made seconds ago from one never made, and every landed verdict below is computed against an origin/main of UNKNOWN age. A fresh clone reads this way on purpose: `git clone` writes no FETCH_HEAD. Run `git fetch origin` -- it both refreshes the ref and makes this field measurable' +} elseif ($lastFetchAgeMinutes -gt 60) { + $stops += "lastFetchAgeMinutes=$lastFetchAgeMinutes -- the last fetch anywhere in this clone was $lastFetchAgeMinutes minutes ago; landed verdicts would be computed against a stale origin/main" +} # POINTER CENSUS, resolved here rather than trusted from the records. Printed as a RATIO because the # numerator alone cannot be read: "1 dangling" is a crisis at 2 pointers and noise at 200. Measured @@ -338,7 +416,14 @@ $receipt = [ordered]@{ writerErrorLines = $writerErrors repoWorktrees = $repoWorktrees.Count originMainSha = $originMainSha - originMainAgeMinutes = $originMainAgeMinutes + # NAMED FOR THE CLOCK IT READS. The predecessor key `originMainAgeMinutes` claimed fetch recency + # and timed the ref file instead; nothing outside docs/BACKLOG.md ever read it by name (checked + # against this repo and the vault's origin/main, with `fleet.ps1` itself as the positive + # control), so it was renamed rather than doubled. A consumer pinned to the old key now finds no + # key at all, which is the honest failure -- the alternative was a familiar name that keeps + # answering the wrong question. + lastFetchAgeMinutes = $lastFetchAgeMinutes + lastFetchClock = $lastFetchClock handoffPointers = $ptrTotal handoffPointerSeats = $ptrBoxes.Count handoffPointersDangling = $ptrDangling @@ -484,7 +569,14 @@ exit $code "RECEIPT -- what was EXAMINED, not merely what was found:" foreach ($k in $receipt.Keys) { if ($k -eq 'stopConditions') { continue } - " {0,-26} {1}" -f $k, $receipt[$k] + # A NULL MUST NOT RENDER AS WHITESPACE. `-f` formats $null as the empty string, so a field this + # instrument could not measure printed as a blank column and read exactly like a quiet, healthy + # one -- the same failure BACKLOG #1374 is about, on whichever field happens to be null next. + # `originMainSha` is null in any checkout with no `origin` remote and was rendering that way. + # This is the generic backstop; a field whose null needs a REMEDY still carries its own sentence + # (`lastFetchClock`), because "(null)" cannot tell anyone to run `git fetch origin`. + $v = if ($null -eq $receipt[$k]) { '(null)' } else { $receipt[$k] } + " {0,-26} {1}" -f $k, $v } "" if ($stops.Count -gt 0) { diff --git a/tests/test_coord_fleet_fetch_clock.py b/tests/test_coord_fleet_fetch_clock.py new file mode 100644 index 000000000..571cbf23b --- /dev/null +++ b/tests/test_coord_fleet_fetch_clock.py @@ -0,0 +1,335 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +# Copyright (C) 2026 MessageFoundry Organization and contributors +"""``fleet.ps1`` must time the FETCH, and must never go quiet when it cannot time anything. + +``fleet.ps1`` computes every landed verdict against the cached ``origin/main``, so the receipt has +to say how old that ref is. It reported ``originMainAgeMinutes`` and stat'ed the ref file +``refs/remotes/origin/main``, whose mtime moves when the REF MOVES rather than when a fetch +happened. Two clocks, two failures (BACKLOG #1374). + +**THE WRONG CLOCK.** Measured on this repo 2026-08-28: ``.git/FETCH_HEAD`` at ``18:02:16.769`` +against the loose ref at ``18:01:24.059`` -- a fetch landed 52 seconds AFTER the ref last moved and +left the ref untouched. So a fleet that fetched seconds ago, against a quiet ``main``, fired the +stop and printed DO NOT TREAT THE ROSTER BELOW AS COMPLETE about a fetch that was fresh. + +**THE UNMEASURABLE CASE RENDERED HEALTHY, AND THAT IS THE WORSE HALF.** With the ref packed there is +no loose file to stat, the value stayed null, and the guard read ``-ne $null -and -gt 60`` -- so the +one state where the instrument knows nothing was the one state it said nothing about. An absent +warning renders identically to a healthy one. + +**THAT STATE IS THE DEFAULT, NOT AN EDGE.** Measured from an empty sandbox 2026-09-03 and pinned by +``test_a_fresh_clone_has_no_loose_ref_and_no_fetch_clock`` below: ``git clone`` packs +``refs/remotes/origin/main`` and writes NO ``FETCH_HEAD``. A brand-new checkout was blind, silently. + +**AND THE CLOCK IS PER-WORKTREE WHILE THE REF IS SHARED.** Measured 2026-09-03: a fetch inside a +linked worktree writes ``/FETCH_HEAD`` and leaves ``/FETCH_HEAD`` untouched, while +``refs/remotes/origin/main`` is common to the clone. Every seat here works in a linked worktree, so +a common-dir-only read reports the PRIMARY's last fetch. Live in the engine checkout the same day, +three clocks disagreeing at once: loose ref 34 minutes, ``/FETCH_HEAD`` 17 minutes, newest +worktree ``FETCH_HEAD`` 3 minutes. + +THE POSITIVE CONTROL RUNS FIRST. Every other arm here asserts a stop condition or a null, and a +suite that only ever asserts failure states would pass against a script hard-coded to fire. +""" + +from __future__ import annotations + +import json +import os +import shutil +import subprocess +import time +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +COORD = ROOT / "scripts" / "coord" +TIMEOUT = 180 + +pytestmark = pytest.mark.skipif( + shutil.which("pwsh") is None or os.name != "nt", + reason="fleet.ps1 needs pwsh on Windows", +) + + +def git(cwd: Path, *args: str) -> str: + proc = subprocess.run( + ["git", "-C", str(cwd), *args], capture_output=True, text=True, timeout=TIMEOUT, check=True + ) + return proc.stdout.strip() + + +def backdate(path: Path, minutes: float) -> None: + """Move one file's mtime into the past. The only way to age a clock inside a test.""" + when = time.time() - minutes * 60 + os.utime(path, (when, when)) + + +def common_dir(clone: Path) -> Path: + return Path(git(clone, "rev-parse", "--path-format=absolute", "--git-common-dir")) + + +def fetch_heads(clone: Path) -> list[Path]: + """Every FETCH_HEAD this clone owns: the common dir's, plus one per linked worktree. + + Deliberately a second, independent implementation of the sweep fleet.ps1 does, so an error in + the SCRIPT cannot hide behind the same error in the test. The premise the two share -- that + these are the directories that count -- is pinned instead by + ``test_a_fetch_from_a_linked_worktree_is_the_clock_that_counts``, which makes real git put a + real clock in a real linked worktree. + """ + common = common_dir(clone) + found = [common / "FETCH_HEAD"] + found += sorted((common / "worktrees").glob("*/FETCH_HEAD")) + return [p for p in found if p.exists()] + + +@pytest.fixture(scope="session") +def upstream(tmp_path_factory: pytest.TempPathFactory) -> Path: + """A remote to clone from, so the clone under test has a real ``origin/main``. + + SESSION-SCOPED because no arm mutates it -- they clone it and fetch from it, and every mutation + in this file lands in the per-test ``clone``. `git` costs roughly 280 ms per process on Windows, + so a function-scoped fixture spent five spawns per test on a repo nobody writes to. + """ + up = tmp_path_factory.mktemp("up") + subprocess.run(["git", "init", "-q", "-b", "main", str(up)], check=True, capture_output=True) + git(up, "config", "user.email", "t@example.invalid") + git(up, "config", "user.name", "t") + (up / "a.txt").write_text("a", encoding="utf-8") + git(up, "add", "a.txt") + git(up, "commit", "-qm", "base") + return up + + +@pytest.fixture +def clone(tmp_path: Path, upstream: Path) -> Path: + """A throwaway CLONE carrying its OWN copy of the script under test. + + A clone rather than a bare ``git init``, because the packed-refs state this item is about is + something ``git clone`` produces and ``git init`` cannot. fleet.ps1 anchors on where it LIVES, + so copying it in is what keeps this sandbox out of the real registry. + """ + c = tmp_path / "clone" + subprocess.run( + ["git", "clone", "-q", str(upstream), str(c)], + check=True, + capture_output=True, + timeout=TIMEOUT, + ) + git(c, "config", "user.email", "t@example.invalid") + git(c, "config", "user.name", "t") + (c / "scripts" / "coord").mkdir(parents=True) + for name in ("fleet.ps1", "mail-key.ps1", "session-registry.ps1"): + shutil.copy2(COORD / name, c / "scripts" / "coord" / name) + return c + + +def fleet(clone: Path, render: str) -> str: + proc = subprocess.run( + [ + "pwsh", + "-NoProfile", + "-NonInteractive", + "-File", + str(clone / "scripts" / "coord" / "fleet.ps1"), + render, + ], + cwd=str(clone), + capture_output=True, + text=True, + timeout=TIMEOUT, + check=False, + ) + # Exit 2 means fenceAvailable=false, which is expected wherever no config root is present. + assert proc.returncode in (0, 2), f"rc={proc.returncode} stderr={proc.stderr}" + return proc.stdout + + +def fleet_json(clone: Path) -> dict: + return json.loads(fleet(clone, "-Json")) + + +def fleet_text(clone: Path) -> str: + return fleet(clone, "-Text") + + +def fetch_stops(receipt: dict) -> list[str]: + """Only this rung's stops. Other rungs fire in a sandbox and are not what these arms measure.""" + return [s for s in receipt["stopConditions"] if "lastFetch" in s] + + +class TestTheClockIsTheFetchAndNotTheRef: + def test_a_fresh_fetch_reports_a_small_age_and_raises_no_stop(self, clone: Path) -> None: + """POSITIVE CONTROL, AND IT RUNS FIRST. + + If a measurable, recent fetch cannot produce a clean receipt in this sandbox, then every + assertion below is satisfied by a script that fires unconditionally. + """ + git(clone, "fetch", "origin") + receipt = fleet_json(clone)["receipt"] + assert receipt["lastFetchAgeMinutes"] is not None, receipt + assert receipt["lastFetchAgeMinutes"] < 60 + assert receipt["lastFetchClock"].endswith("FETCH_HEAD"), receipt["lastFetchClock"] + assert fetch_stops(receipt) == [] + + def test_a_stale_ref_beside_a_fresh_fetch_raises_no_stop(self, clone: Path) -> None: + """THE WRONG-CLOCK ARM. The ref has not moved for a day; the fetch was seconds ago. + + This is the 52-second reading at its real magnitude. The predecessor stat'ed the ref file + and would report a day of staleness here, firing DO NOT TREAT THE ROSTER BELOW AS COMPLETE + about a fetch that is fresh. + """ + git(clone, "fetch", "origin") + # A loose ref, backdated a day. It is written directly because `git update-ref` to the value + # the ref ALREADY has is a no-op and leaves the pack alone -- measured here, and the arm + # failed on exactly that until the write became explicit. A single sha line is the shape git + # writes, and the rev-parse below is the check that the ref still resolves through it. + sha = git(clone, "rev-parse", "origin/main") + loose = common_dir(clone) / "refs" / "remotes" / "origin" / "main" + loose.parent.mkdir(parents=True, exist_ok=True) + loose.write_text(sha + "\n", encoding="utf-8") + assert git(clone, "rev-parse", "origin/main") == sha + backdate(loose, minutes=24 * 60) + + receipt = fleet_json(clone)["receipt"] + assert receipt["lastFetchAgeMinutes"] < 60, "the FETCH is fresh, whatever the ref says" + assert fetch_stops(receipt) == [], "a fresh fetch must not be reported as a stale ref" + + def test_a_fetch_older_than_an_hour_still_fires_the_stop(self, clone: Path) -> None: + """The stop must survive the clock swap. A gate that never fires reports nothing.""" + git(clone, "fetch", "origin") + for head in fetch_heads(clone): + backdate(head, minutes=180) + + receipt = fleet_json(clone)["receipt"] + assert receipt["lastFetchAgeMinutes"] >= 170, receipt["lastFetchAgeMinutes"] + stops = fetch_stops(receipt) + assert len(stops) == 1, stops + assert "lastFetchAgeMinutes=" in stops[0] + assert "stale origin/main" in stops[0], stops[0] + + +class TestBlindMustNotRenderAsHealthy: + def test_a_fresh_clone_has_no_loose_ref_and_no_fetch_clock(self, clone: Path) -> None: + """THE CONSTRUCTED ROW, asserted as a precondition before anything is judged on it. + + A checkout with a loose ref cannot exercise the null case at all, so the case is BUILT here + rather than hoped for. This also pins the measurement the fix rests on: the packed-refs + state is what `git clone` hands you, not an exotic configuration someone opted into. + """ + common = common_dir(clone) + assert not (common / "refs" / "remotes" / "origin" / "main").exists(), ( + "a fresh clone must have NO loose remote ref -- nothing here would be measuring the " + "packed-refs path otherwise" + ) + packed = (common / "packed-refs").read_text(encoding="utf-8") + assert "refs/remotes/origin/main" in packed, packed + assert git(clone, "rev-parse", "origin/main"), "and the ref still resolves through the pack" + assert fetch_heads(clone) == [], "and `git clone` writes no fetch clock at all" + + def test_no_fetch_clock_anywhere_fires_the_stop_rather_than_going_quiet( + self, clone: Path + ) -> None: + """THE HALF THAT RENDERED HEALTHY. Null is now loud, and it is loud in the receipt too.""" + for head in fetch_heads(clone): + head.unlink() # defensive: the arm is "no clock exists", not "this git wrote none" + assert fetch_heads(clone) == [] + + receipt = fleet_json(clone)["receipt"] + assert receipt["lastFetchAgeMinutes"] is None, receipt["lastFetchAgeMinutes"] + stops = fetch_stops(receipt) + assert len(stops) == 1, stops + assert "UNMEASURABLE" in stops[0], stops[0] + # The neighbouring receipt field must not be blank either. A null age printed as an empty + # column is the same silence in a different place. + assert receipt["lastFetchClock"], "the clock field must never render empty" + assert "UNMEASURABLE" in receipt["lastFetchClock"], receipt["lastFetchClock"] + + def test_the_text_render_refuses_to_call_a_blind_roster_complete(self, clone: Path) -> None: + """The reader of this output is the person least equipped to notice a missing warning. + + ``-Text`` is what a seat actually reads, so the blind case is asserted where they read it. + + THIS ARM ASSERTED THE WRONG FEATURE FIRST AND SURVIVED A MUTATION IT SHOULD HAVE CAUGHT. + ``STOP CONDITIONS FIRED`` and a bare ``UNMEASURABLE`` are both satisfied without this rung: + an empty sandbox already fires ``recordsExamined=0``, and ``lastFetchClock`` prints the word + in the receipt block whatever the guard does. Re-put the null guard back and the arm stayed + green. It now reads the STOP BLOCK ITSELF and looks for this rung's own line. + """ + for head in fetch_heads(clone): + head.unlink() + out = fleet_text(clone) + assert "NO STOP CONDITIONS" not in out, out[:2000] + marker = "STOP CONDITIONS FIRED" + assert marker in out, out[:2000] + block = out.split(marker, 1)[1].split("\n\n", 1)[0] + assert "lastFetchAgeMinutes=UNMEASURABLE" in block, block + + def test_a_null_receipt_field_prints_a_sentinel_instead_of_whitespace( + self, clone: Path + ) -> None: + """The generic backstop, on the field that exposed the need for it. + + PowerShell's ``-f`` formats ``$null`` as the empty string, so any receipt field this + instrument could not measure printed as a blank column and read exactly like a quiet healthy + one. ``originMainSha`` is null in any checkout with no ``origin`` remote, and was rendering + that way -- the same failure as the fetch clock, one field along. + + The remote is REMOVED here rather than hoped absent, because a clone always has one. + """ + git(clone, "remote", "remove", "origin") + out = fleet_text(clone) + # `.strip()` is the point: a blank value strips down to the key alone, so `endswith` is what + # tells a rendered sentinel apart from an empty column. + lines = { + ln.split()[0]: ln.strip() + for ln in out.splitlines() + if ln.startswith(" ") and ln.strip() + } + assert lines["originMainSha"].endswith("(null)"), lines["originMainSha"] + assert lines["lastFetchAgeMinutes"].endswith("(null)"), lines["lastFetchAgeMinutes"] + + +class TestTheWholeCloneIsRead: + def test_a_fetch_from_a_linked_worktree_is_the_clock_that_counts(self, clone: Path) -> None: + """FETCH_HEAD is per-worktree; the remote-tracking ref is shared. + + Every seat here works in a linked worktree, so a common-dir-only read answers "when did the + PRIMARY last fetch" while the receipt's label asks "how fresh is origin/main". Any + worktree's fetch refreshes the shared ref, so the newest clock in the clone is the answer. + """ + git(clone, "fetch", "origin") + common = common_dir(clone) + wt = clone.parent / "wt" + git(clone, "worktree", "add", "-q", "-b", "side", str(wt)) + git(wt, "fetch", "origin") + + wt_head = Path(git(wt, "rev-parse", "--path-format=absolute", "--git-dir")) / "FETCH_HEAD" + assert wt_head.exists(), "the arm needs the per-worktree clock it is about" + # The primary's clock is a day old. Only the worktree's is fresh. + backdate(common / "FETCH_HEAD", minutes=24 * 60) + + receipt = fleet_json(clone)["receipt"] + assert receipt["lastFetchAgeMinutes"] < 60, receipt["lastFetchAgeMinutes"] + assert "worktrees" in receipt["lastFetchClock"], receipt["lastFetchClock"] + assert fetch_stops(receipt) == [] + + +class TestTheFieldNamesTheClockItReads: + def test_the_misnamed_predecessor_key_is_gone(self, clone: Path) -> None: + """A key that claims fetch recency and times a ref file must not survive the fix. + + Checked before renaming rather than after: nothing outside ``docs/BACKLOG.md`` reads this + key by name, in this repo or in the vault's ``origin/main``, with ``fleet.ps1`` itself as + the positive control that the search could see. So it was renamed, not doubled -- a + consumer pinned to the old key now finds no key, which is the honest failure. + """ + git(clone, "fetch", "origin") + receipt = fleet_json(clone)["receipt"] + assert "originMainAgeMinutes" not in receipt, sorted(receipt) + assert "lastFetchAgeMinutes" in receipt, sorted(receipt) + assert "lastFetchClock" in receipt, sorted(receipt) + # The sha is a different question and stays. + assert receipt["originMainSha"], receipt diff --git a/tests/tooling_manifest.txt b/tests/tooling_manifest.txt index 8abdb2d71..bbcaab22f 100644 --- a/tests/tooling_manifest.txt +++ b/tests/tooling_manifest.txt @@ -53,6 +53,7 @@ tests/test_citation_line_check.py tests/test_claim_check.py tests/test_claim_shared_registry.py tests/test_coord_alloc_strand_sweep.py +tests/test_coord_fleet_fetch_clock.py tests/test_durability_hook_provenance.py tests/test_gate_ci_mirror_parity.py tests/test_hook_prose_folding.py