fix(gate): rule 3d named a remedy that cannot reach the worktree it refused (BACKLOG #1057) - #261
Conversation
…efused (BACKLOG #1057)
The refusal was right and the sentence after it was not. A caller told "no" is handed a
command to run instead, and for the population that most often reaches this rule that
command throws.
* `remove.ps1 -Name <dir>` resolves to <repo-parent>/<repo-leaf>-<dir>. new.ps1:79-81
ASSERTS that shape after deriving it, so the <primary>-<name> sibling family is the only
one it can produce and the only one remove.ps1 can resolve; anything else fails its
Test-Path and throws "No such worktree".
* `prune-merged.ps1` excludes anything with a `.claude/worktrees/` path segment OUTRIGHT,
by its own header, and -Name cannot reach them either. The exclusion is deliberate:
those are the trees a live session gets relocated into.
Census on this clone 2026-08-06: 45 sibling worktrees, 8 Claude-managed, 4 other -- and all
six live sessions sat in the 8. Verified against the INSTALLED gate, not only the repo copy.
Same defect as #1032 one rule over, where rule 3b printed a new.ps1 command new.ps1 refuses
to run. A refusal the reader cannot act on is the standing invitation to route around the
guard, which costs more than the refusal buys.
The deny now branches on worktree family. The family neither script can serve gets a literal
`git -C "<primary>" worktree remove "<path>"` plus a line saying why prune-merged.ps1 does
not apply. The sibling family KEEPS prune-merged.ps1 -- it is dry-run by default and consults
occupancy, so it is strictly better there than a bare git command, and the fix must not
become "stop naming the scripts". A test pins that.
WRITING THE TEST ENLARGED THE DEFECT. The own-tree branch printed the literal placeholder
`-Name <directory-name>` for BOTH families: the right tool, named correctly, and still not
pasteable. The gate has already resolved the path by then, so it now names the directory.
REMEDY TEXT ONLY. Which worktrees rule 3d refuses is untouched, and nothing security-relevant
reads the classification -- which is what makes a misclassification cheap here and expensive
in rule 3c. Failure direction is pinned by test rather than asserted: a junction or UNC
spelling breaks the prefix match, classifies NOT-sibling, and the not-sibling remedy is valid
for every family including siblings. The dangerous direction needs a non-sibling to
SPURIOUSLY match <primary>-<name>, which an unresolved alias makes less likely, not more.
#1064's measured rule 3d fail-open is deliberately not touched here.
TWO ASSERTIONS IN THE NEW TESTS EXIST BECAUSE THE FIRST VERSIONS MEASURED THE WRONG THING:
* every deny opens by quoting the command back, so searching the WHOLE reason for a runnable
`git ... worktree remove` matched the refusal's own echo, and the own-tree case for the
Claude-managed family went GREEN over a deny that offered nothing runnable. Assertions are
now scoped to the text after "What to do instead:";
* the remedy deliberately says prune-merged.ps1 CANNOT help here, so a bare token search
fails on the prohibition itself. The property is "no runnable invocation", not "the name
never appears" -- the same distinction test_coord_claim_liveness.py already draws for
-Force, and for the same reason.
Negative control run, not assumed: reverted, 4 of the 6 cases go red and the 2 that stay
green are the controls. Full gate suite 247 passed. Rule 3d is byte-identical between main
and claude/worktree-gate-backlog-061bdc's tip, checked before writing, so this cannot revert
or duplicate that session's in-flight work.
This closes the REMEDY half of #1057. The occupancy half is untouched and the item stays open:
rule 3d still has no occupancy or authorship signal, so an unoccupied worktree is still
refused rather than allowed.
Pull request was closed
…ACKLOG #1057) `test (ubuntu-latest, py3.14)` failed on PR #261. The junction test shells out to `cmd /c mklink /J` and guarded with `if made.returncode != 0: pytest.skip(...)`. There is no `cmd` on Linux, so subprocess.run RAISES FileNotFoundError before there is a returncode to inspect -- the guard never runs and the test errors. PLATFORM-MASKED, AND WINDOWS IS THE PLATFORM THAT CANNOT SEE IT: the guard is only reachable where `cmd` exists, so a green local quartet on Windows was structurally incapable of catching this. Same shape rule 3d's own comment already records, where a construct "passed on Windows and failed on the Linux CI leg". Gate on the platform FIRST, and keep the OSError arm: `cmd` existing does not mean junction creation is permitted, so the two guards catch different things and both are load-bearing. VERIFIED BY REPRODUCING THE LINUX CONDITION, not by re-running on Windows: cmd absent, unfixed -> FileNotFoundError, test ERRORS (the CI failure, reproduced) cmd absent, fixed -> SKIPPED via the OSError arm os.name=posix, fixed -> SKIPPED via the platform gate Windows, real cmd -> 6 passed, unchanged The first probe written for this was WRONG and is worth recording: patching `os.name` alone does not reproduce Linux, because `cmd` still exists here -- the unfixed test happily created a junction and PASSED. A control that cannot see the defect is not a control. Making subprocess.run raise for `cmd` is the faithful reproduction, and it discriminates. That also settles the OSError arm empirically rather than by argument: with `cmd` absent and os.name still "nt", the platform gate does NOT fire and the OSError arm is what prevents the error. Full gate suite 247 passed. Patch authored by the coordinator under route (c); the claim gate correctly refused their commit because another worktree held #1057, so it is committed here rather than forced past. It then refused THIS worktree too, for the same reason and equally correctly -- entitlement is keyed on the committing worktree, and building the fix in a fresh worktree moved me out of my own claim. Salvaged the documented way: released my own claim without -Force, re-took it here.
Pull request was closed
…KLOG #1057) `test (windows-2025, py3.14)` timed out on PR #261: "The action 'Tests (pytest)' has timed out after 36 minutes". Not a flake and not a defect in the gate change -- the leg has almost no headroom and this file's 249 lines consumed what was left. #261 05:00:57 -> 05:38:03 = 37m06s step timed out at 36m #260 04:59:22 -> 05:32:31 = 33m09s success #259 22:00:10 -> 22:38:30 = 38m20s success step_timeout is 36 for windows-2025, read from ci.yml's matrix rather than inferred from the error text. A suite habitually finishing near 33 against a 36 cap has ~17% headroom. THE TELL THAT IT IS ELAPSED TIME AND NOT CONTENT: the timeout named tests/test_tls_floor_probe.py, while #261 touches worktree_gate.ps1, its tests and one BACKLOG line -- nothing TLS-related. Location and change do not line up. Also ruled out: environmental (the same leg is SUCCESS on 13 other open PRs), and the exit-139 native-crash class of #264 (this is a clean timeout, not a crash). THE FIX HERE IS TO MAKE THIS FILE CHEAPER, NOT TO RAISE THE CAP. step_timeout is deliberately held under job_timeout (46) so a process-level deadlock below pytest surfaces as a STEP failure rather than a job kill; ci.yml says so explicitly. Raising it would trade a real diagnostic for a green tick. The `repo` fixture is subprocess-bound (six `git` spawns) and READ-ONLY -- every test feeds a payload to the hook, which DENIES before git runs, so no worktree is ever actually removed against it. Function scope paid for it six times: 36 process launches, cheap on Linux and expensive on a hosted Windows runner. Module-scoped it removes 30 of those. Measured locally: 10.66s -> 5.26s, a 51% cut, with setup no longer appearing in the top durations at all. The docstring records WHY sharing is safe rather than asserting that it is, and says what would make it unsafe: a case that really removes a worktree needs its own function-scoped fixture. Files #1084 for the underlying condition, which outlives this file. The margin is invisible until it is gone -- nothing reports "the Windows leg finished at 92% of its budget" -- and the measured spread across three PRs the same day (33m09s / 37m06s / 38m20s) means runner variance alone is comparable to the headroom, so a re-run can flip the verdict with nothing changed. That is also why #261 must not be merged on a lucky green. Diagnosis by the coordinator, who ruled out three alternative explanations against evidence before accepting the elapsed-time one, and who recorded a prediction before re-running rather than after.
docs/BACKLOG.md only, and the conflict was structural rather than semantic: #268 (#1087) and this branch (#1084) both append an item to the file's tail, so every backlog PR that merges invalidates every other open one. Three landed in a row tonight (#263, #266, #268), which is what made the tail a serialization point. Resolved by keeping BOTH items in numeric order. Merged rather than rebased so both commits and the PR's arming survive. VERIFIED BY INTENT, not by absence of conflict markers: * no duplicate item numbers anywhere in the file; * every heading present on origin/main is still present here -- set difference is empty; * the only heading this branch adds is #1084, which is the one it is for; * backlog_status_check.py exit 0, 346 items each declaring exactly one status. The conflict set was established with `git merge-tree --write-tree`, not from a changed-files list: `gh pr view --json files` lists files CHANGED, which is a different question and inflated another session's conflict count threefold earlier tonight. Entitlement is unaffected -- ledger_check.py consults owns() only for ADDED headings, and #1084 is allocated to this worktree, which is also why this resolution was not delegable.
… wrong instrument #1084 was filed the same day, for the same leg and the same cap, and neither item referenced the other. It is not a harmless duplicate: the two CONTRADICT each other on the central action. #1084 says "Do NOT simply raise the cap"; #1096 says the cap must be re-derived. Left alone, whichever is worked first leaves the survivor standing as an open item asserting the work was wrong. The collision was invisible from this worktree. #1084 is not on main -- it lives only on origin/claude/gate-3d-remedy-1057 (PR #261, open) -- so reading docs/BACKLOG.md here shows no conflict, and backlog_status_check cannot see one either. The cross-reference is therefore labelled FORWARD-LOOKING in the item, per the rule that a citation which does not resolve yet must say so. The contradiction resolves, and #1084 is not wrong in spirit -- its premise is wrong. Its "~92% of step_timeout, about 17% headroom" comes from comparing JOB wall time across three PRs (33m09s / 37m06s / 38m20s) against a STEP cap. On the step the figure is 35:13 against 36:00 = 97.8% of budget, 2.2% headroom. That is the same job-for-step substitution ci.yml already records at least three earlier sessions making while triaging this very cap. That changes what "protect the diagnostic" implies, which is the whole disagreement. #1084 is right that step_timeout sits under job_timeout so a deadlock below pytest surfaces as a step failure, and right that raising a cap to buy a green tick would trade that away. But at 2.2% headroom, against a distribution where EVERY passing run exceeds the file's own 28:00 re-derive trigger, the cap fires on a healthy suite as readily as on a deadlock -- so re-deriving it RESTORES the diagnostic rather than surrendering it. Also absorbed from #1084 so closing it loses nothing: its three-wrong-answers triage, the step-under-job rationale, its fix directions (margin reporting is the cheapest and should likely happen regardless; splitting the Windows leg; the measured 51% cut on tests/test_worktree_gate_remedy_families.py from removing 30 of 36 git spawns), and its #1000 negative-control requirement. Added stronger evidence than either item had alone: a SAME-COMMIT flip. Run 31192717684 (fix-1006-absence-mutation, head 27d7746) failed at 36:07 on attempt 1 and passed at 33:14 on attempt 2. #1084 inferred a flip from three DIFFERENT PRs, which is not identical content; this is. The 43-of-43 figure is NOT rewritten to 45. An independent re-derivation later the same day reproduced 35:13 to the second and the same five kills, with the pool grown to n=68 and 45 of 45 above the trigger. It is recorded as a second measurement with its own pool rather than folded in, because a number edited away from the pool that produced it is exactly the rot ci.yml warns about. Recommended disposition stated in the item: keep #1096, close #1084 as merged into it, correct #1084's numbers rather than carrying them forward. There is no allocation collision -- 1084 and 1096 are different numbers, only the subject collided. Gates: backlog_status_check.py --min-items 150 OK (366 items, each declaring exactly one status), ledger_check.py --ci OK. Live table still 109 rows with contiguous ranks; #1096 still parses open via parse_items.
|
Coordinator: BACKLOG #1084 on this branch collides in SUBJECT with #1096, and the two contradict each other. Nothing blocks this PR; please read before it lands. This is not a number collision -- 1084 and 1096 are different numbers, correctly allocated. It is a Same defect, same day, same leg, same cap. #1096's kill table literally contains #1084's originating run Where the disagreement comes from -- a premise, not a judgement#1084's headline is "~92% of its Measured on the step, filtered on step conclusion,
Why that flips the conclusion rather than just sharpening itYour reasoning in (3) is right and I have kept it: But at 2.2% headroom, against a distribution where every passing run today exceeds Additional evidence neither item had alone: a same-commit flip. Run What I did, and what I did notI amended #1096 (PR #278, unarmed) to cite #1084 as a forward-looking reference -- flagged as such, I did not touch #1084 or anything else on this branch. Editing another session's landed item is the If you would rather keep #1084 as the surviving item, say so and I will strip the superseding language from |
…on main (#278) * backlog: file #1096, the windows-2025 Tests (pytest) cap is exceeded on main ci.yml sets step_timeout 36 for both Windows legs and, in the same comment block, names its own re-derivation trigger: re-derive if a windows-2025 Tests (pytest) step is ever seen above 28:00. Measured 2026-08-07 over all 80 ci.yml runs created that day, jobs fetched with filter=all so an attempt killed at the cap is not hidden behind its passing re-run, timing the STEP and filtering on step conclusion: windows-2025 Tests (pytest) max passing 35:13 n=65 steps / 43 success cap 36:00 -> 47 seconds of margin, 1.022x passing steps above the 28:00 trigger: 43 of 43 killed at 36:0x: 5, two of them push runs on main (7ecff8a, b78214f) So the trigger is not merely fired, it is universally exceeded, and the cap is failing green suites. ci.yml already records this shape once at 26:00 (PR #119, killed at 26:07 with zero tests failing, diagnosed there as coin-flipping against the cap) and already admits 36:00 failed its own spread rule when it was chosen, needing roughly 37:00 to hold. Measured the step rather than the job deliberately: ci.yml records at least three sessions substituting job durations while triaging this same cap, and a job-duration reading of this pool gives 33-40 minutes, which invites the wrong conclusion that the cap is comfortable. job_timeout must be re-derived in the same act. The job carries two step_timeout-gated pytest steps and its 46 is sized against the observed sum, not 2 x step_timeout, so raising step_timeout alone moves the kill to the job cap, where no step conclusion is reported and the instrument used to measure this is lost. Ledger mechanics, stated separately because they are independent claims: - Number allocated with scripts/coord/alloc.ps1, not grepped. Row added in this same commit at rank 6, ranks 6..108 renumbered to 7..109. - The renumber was bounded to the live table (lines 180..287). The file also holds a superseded 134-row 2026-07-10 table; an assertion checks it comes out byte-identical, and it does. - The four Distribution census lines were re-derived, not carried forward: Value 7 -> 8, Difficulty 3 -> 40, P1 -> 9, quick win -> 34, sum 108 -> 109. - The "Every one of the 102 open items" sentence was left ALONE on purpose. It is a dated claim about the 2026-08-03 re-scoring pass, not a live census, and bumping it would assert that pass scored items that did not exist yet. Prior passes appear to have bumped it; that looks like the actual source of its drift. MY FILING IS CORRECT AND THE CENSUS IS STILL WRONG -- these are separate claims and this commit only makes the first. Verified in both directions with parse_items imported from backlog_status_check.py rather than a hand-rolled scan, which surfaces PRE-EXISTING drift this commit does not touch: 52 open items carry no row, 11 rows name an item that is not open, and parse_items counts 150 open items against a census asserting 109. Those two sets cancel on a total, which is why a matching sum never caught it. That reconcile is its own pass and needs 52 items scored. Gates run locally: backlog_status_check.py --min-items 150 OK (366 items, each declaring exactly one status), ledger_check.py --ci OK. * backlog #1096: reconcile with #1084, which is the same defect under a wrong instrument #1084 was filed the same day, for the same leg and the same cap, and neither item referenced the other. It is not a harmless duplicate: the two CONTRADICT each other on the central action. #1084 says "Do NOT simply raise the cap"; #1096 says the cap must be re-derived. Left alone, whichever is worked first leaves the survivor standing as an open item asserting the work was wrong. The collision was invisible from this worktree. #1084 is not on main -- it lives only on origin/claude/gate-3d-remedy-1057 (PR #261, open) -- so reading docs/BACKLOG.md here shows no conflict, and backlog_status_check cannot see one either. The cross-reference is therefore labelled FORWARD-LOOKING in the item, per the rule that a citation which does not resolve yet must say so. The contradiction resolves, and #1084 is not wrong in spirit -- its premise is wrong. Its "~92% of step_timeout, about 17% headroom" comes from comparing JOB wall time across three PRs (33m09s / 37m06s / 38m20s) against a STEP cap. On the step the figure is 35:13 against 36:00 = 97.8% of budget, 2.2% headroom. That is the same job-for-step substitution ci.yml already records at least three earlier sessions making while triaging this very cap. That changes what "protect the diagnostic" implies, which is the whole disagreement. #1084 is right that step_timeout sits under job_timeout so a deadlock below pytest surfaces as a step failure, and right that raising a cap to buy a green tick would trade that away. But at 2.2% headroom, against a distribution where EVERY passing run exceeds the file's own 28:00 re-derive trigger, the cap fires on a healthy suite as readily as on a deadlock -- so re-deriving it RESTORES the diagnostic rather than surrendering it. Also absorbed from #1084 so closing it loses nothing: its three-wrong-answers triage, the step-under-job rationale, its fix directions (margin reporting is the cheapest and should likely happen regardless; splitting the Windows leg; the measured 51% cut on tests/test_worktree_gate_remedy_families.py from removing 30 of 36 git spawns), and its #1000 negative-control requirement. Added stronger evidence than either item had alone: a SAME-COMMIT flip. Run 31192717684 (fix-1006-absence-mutation, head 27d7746) failed at 36:07 on attempt 1 and passed at 33:14 on attempt 2. #1084 inferred a flip from three DIFFERENT PRs, which is not identical content; this is. The 43-of-43 figure is NOT rewritten to 45. An independent re-derivation later the same day reproduced 35:13 to the second and the same five kills, with the pool grown to n=68 and 45 of 45 above the trigger. It is recorded as a second measurement with its own pool rather than folded in, because a number edited away from the pool that produced it is exactly the rot ci.yml warns about. Recommended disposition stated in the item: keep #1096, close #1084 as merged into it, correct #1084's numbers rather than carrying them forward. There is no allocation collision -- 1084 and 1096 are different numbers, only the subject collided. Gates: backlog_status_check.py --min-items 150 OK (366 items, each declaring exactly one status), ledger_check.py --ci OK. Live table still 109 rows with contiguous ranks; #1096 still parses open via parse_items.
…ded item Resolves the docs/BACKLOG.md conflict against main at f74c2ab. Subject wording note: the claim gate refused an earlier subject that named the dropped item as "BACKLOG #1084", because that phrasing on a code-touching commit means "I am building this item" and the item is unclaimed. It is not being built here, it is being REMOVED, so the original subject was simply inaccurate about what the commit does. The number is stated throughout this body instead. BACKLOG #1084 is DROPPED from this branch rather than landed, on the owner's instruction. It is superseded by #1096, which is already on main, and the two contradict each other on the central action: #1084 says "Do NOT simply raise the cap", #1096 says the cap must be re-derived. #1084's premise is the reason it loses. Its headline "~92% of step_timeout, about 17% headroom" divides a JOB wall time by a STEP cap; measured on the step it is 97.8% of budget with 2.2% headroom. The two figures are also inconsistent with each other on their face, since 92% used is 8% headroom and 17% would require a step near 30 minutes. #1096 already carries a section anticipating this collision and asking that the reconciliation be written into whichever item survives. That section previously predicted the cross-reference would resolve "once #261 lands". It is updated here in the same act, because #261 lands WITHOUT #1084 and the old wording would have been left permanently promising a resolution that can never arrive. The #1084 number stays allocated and burned rather than reused. Verified by count against both parents rather than by inspection: HEAD 151 main 178 merged 178 lost-from-HEAD [1084] -- exactly the intended drop, nothing else lost-from-main [] -- main fully preserved, including #1101 invented [] -- no headings created An earlier resolution of this same conflict was REFUSED by that check: it was built against a main that predated #1101, and the check reported lost-from-main [1101]. Rebuilt against f74c2ab. Content checks, matching main exactly: adr/0094-tls-expiry-relaxation.md (rotted) 0 adr/0094-granular-expiry-only-tls-relaxation.md (correct) 1 BACKLOG.md:<line> anchors 5 The #1057 banner update this PR exists to make is preserved. scripts/hooks/worktree_gate.ps1 auto-merged with no markers, so it was checked by content in both directions rather than trusted: all 64 lines this branch adds and all 86 lines main adds are present in the merged file. Gates: backlog hygiene 19 passed; the repo-wide link gate reports 0 genuine unresolved links.
Conflict resolved, and BACKLOG #1084 is DROPPED from this branchResolved by the coordinator at the owner's instruction. This PR no longer files #1084. The gate fix and the #1057 banner update — the work this PR exists to do — are untouched. Why #1084 is dropped rather than landedIt is superseded by #1096, already on #1096 already carried a section anticipating this collision and asking that the reconciliation be written into whichever item survives. That section predicted the cross-reference would resolve "once #261 lands". It is updated in this same commit, because #261 now lands without #1084 and the old wording would have been left permanently promising a resolution that can never arrive. The #1084 number stays allocated and burned rather than reused. Verification, by count against both parents rather than by inspectionAn earlier resolution of this same conflict was REFUSED by that check. It was built against a Content checks, matching
Gates run locally: backlog hygiene 19 passed; |
Fixes BACKLOG #1057. 3 files, +300 -7 -- one BACKLOG banner line, the gate, and a new test file.
THE DEFECT. Rule 3d refused an operation and then named a remedy that could not act on the worktree
it had just refused. The gate has already resolved the path by that point, so the deny now names the
actual directory.
⭐ THE FINDING GREW UNDER TEST, and the correction is the interesting part. It was reported as
affecting one branch of the deny. Testing showed the own-tree branch printed the LITERAL PLACEHOLDER
-Name <directory-name>for BOTH families -- the right tool, named correctly, and still notpasteable. So the defect was in both branches and both families, not the one originally filed.
git ... worktree removetherefore matched the refusal's own ECHO, so the Claude-managedown-tree case went GREEN over a deny that offered nothing runnable. Now scoped to the text
after "What to do instead:".
on the prohibition itself. The property is "no runnable invocation", NOT "the name never
appears" -- the same distinction test_coord_claim_liveness.py already draws for -Force.
EVIDENCE, with the negative control RUN rather than assumed: 6/6 green post-fix; reverting the fix
turns 4 red while the 2 that stay green are the controls. Full gate suite 247 passed, no regression.
Quartet green; leak scan clean on all three files.
⛔ DELIBERATELY NOT TOUCHED: #1064's fail-open, where
git worktree remove <relative-primary>ismeasured ALLOW. That is a separate item and a security decision rather than a text change.
COORDINATION. scripts/hooks/worktree_gate.ps1 is under active work by another session. This change
was written only after that session freed the file, and the insert point was re-measured immediately
before writing rather than trusted from an earlier reading. Verified here: 9f96fdfe's edit does not
overlap that session's committed changes (3-way merge against their tip, base-anchored: CLEAN).
PROVENANCE. Cherry-picked from claude/analyze-issues-1057-1060-1063-9e9220 onto current main so this
stands alone: on that branch the commit sits ON TOP of PR #260's three anchoring commits, and a PR
cut from that tip would have duplicated #260's content. #260 stays frozen at a042c0f and is
untouched.