Skip to content

fix(coord): three signals that could not tell an answer from a failure - #140

Open
wshallwshall wants to merge 7 commits into
mainfrom
claude/announce-hook-intersession-d11524
Open

fix(coord): three signals that could not tell an answer from a failure#140
wshallwshall wants to merge 7 commits into
mainfrom
claude/announce-hook-intersession-d11524

Conversation

@wshallwshall

Copy link
Copy Markdown
Collaborator

Picks up the three items filed-not-built in the ADR 0158 handoff (HANDOFF-announce-hook.md §6), plus one defect found while verifying them.

The handoff's headline open item (§3 — the collision-gate fix not being in force until the primary checkout advanced) is already closed: #133 merged as 3389aa2b, the primary is at 093db339, and grep -c MatchedDirty there returns 2.

What is here

overlap.ps1 A -Json query answered "nobody else" by printing nothing, and attributed a session to the first matching worktree prefix
collision_gate.ps1 Every fail-open path exited 0 with empty stdout — indistinguishable from an all-clear
claim.ps1 -Take -Note on a key you hold accepted the note, reported success, and discarded it

All three are the same shape: a signal with one representation for two different states.

The one that was not filed

overlap.ps1 -File X -Json printed nothing when nobody else was touching the file. @() | ConvertTo-Json -AsArray sends zero objects down the pipeline, so ConvertTo-Json never runs — -AsArray only shapes output that already exists. My first version of the gate fix treated empty output as "the script never answered", and it fired on an ordinary edit to an untouched file, which is most edits.

Found by running the real gate against the real overlap script. The existing test stubs emitted a shape the real script never produced, so nothing in the suite could have caught it. The producer is fixed first; the gate's detection is only meaningful afterwards.

Fail-open is unchanged

The gate still allows on every error path. The notice is hookSpecificOutput.additionalContext with no permissionDecision — a bare line would risk a misparse on every Edit/Write, since this hook's stdout is the decision. Rate-limited per reason (30 min); if the rate-limit stamp cannot be written the notice is emitted anyway, because the failure mode of a noise-suppressor must be noise.

Not taken

  • claim -List staleness-vs-liveness — already open as fix(coord): claim -List measured age and called it staleness #106.
  • Hunk-range disjointness for the gate — evidence-gated and deliberately not built. All three reported false denials were the committed-and-clean case f55d6c67 already fixed, and a wrong disjointness check under-blocks, trading a loud failure for a silent one.

Verification

ruff check + ruff format --check + mypy --strict clean; 140 coordination tests pass.

Every new assertion was checked against the unfixed script first, not just against the fix:

  • attribution — the primary comes back Live: true, Branch: main, wearing the nested session's id
  • array contract — a no-hit query returns ''
  • the claimed-stamp round-trip test caught a real defect during development (ConvertFrom-Json coerces ISO-8601 to [datetime], so a naive rewrite silently downgraded the stamp and still parsed)

Note for whoever merges

The collision gate is not an installed copy. ~/.claude/settings.json wires a shim that resolves the script live, preferring the primary checkout. So merging this does not put it in force — the primary has to be advanced to a commit containing it. That is the owner's call, since the primary is shared with every live session.

grep -c Write-JsonArray <primary>/scripts/coord/overlap.ps1

Non-zero means in force. It tests the property, not the provenance.

🤖 Generated with Claude Code

Two defects in one script, and they are the same defect: a signal that
cannot distinguish the state it reports from a different state.

1. A -Json query answered "nobody else is in this file" by printing
   NOTHING. `@() | ConvertTo-Json -AsArray` sends zero objects down the
   pipeline, so ConvertTo-Json never runs -- -AsArray only shapes output
   that already exists. On stdout an all-clear was therefore byte-for-byte
   identical to the script dying before it answered, and no consumer could
   tell them apart. Every -Json exit now goes through one emitter that
   always produces an array. (-InputObject is not the fix: with -AsArray it
   double-wraps to [[]].)

   Found by running the real script against the real collision gate rather
   than the test stubs, which had been written to a shape the real script
   never produced.

2. A live session was attributed to a worktree by FIRST prefix hit. Linked
   worktrees live under the primary checkout, so every linked path is also
   a prefix match for the primary's row: the primary was handed whichever
   nested session the hash table enumerated first, and reported LIVE on
   main, "building" a peer's task list. Hash order is not stable, so it was
   a different wrong answer each run -- which is why it read as noise
   rather than as a bug. Longest prefix wins is the only rule that survives
   nesting, and it is resolved once against every worktree instead of per
   row.

   docs/WORKTREES.md already named this exact trap for the announce hook's
   id rule, where the cure was "never match by prefix". Here a prefix match
   is genuinely required -- a session may sit in any subdirectory -- so the
   cure has to be longest-prefix instead.

Both are pinned against a real nested-worktree git fixture; a sibling
layout would pass under the old rule and prove nothing. Each new assertion
was checked against the unfixed script first: the attribution test reports
the primary as Live/main/<peer session id>, and the array test sees ''.
…ked nothing

Every fail-open path in this hook -- overlap script missing, throwing, or
printing garbage -- exited 0 with EMPTY STDOUT. On a PreToolUse hook whose
stdout is parsed as a decision, empty stdout means "allow", which is
byte-for-byte what "checked, nobody else is in this file" looks like. So a
gate that had consulted nothing was indistinguishable from a gate reporting
all-clear, and its own failure reached the session as reassurance.

That is the silent-control class this repo has now hit five times, and it
is the same shape as the wired-but-inert announce shim: the surface that
was supposed to report sat downstream of the failure it existed to detect.

The posture does not change -- every one of these paths still ALLOWS. Only
the silence does. It now emits a hookSpecificOutput.additionalContext
notice naming which reason (overlap-missing / overlap-failed /
overlap-empty / overlap-unparseable / payload-unreadable). It must be that
JSON shape and never a bare line: this hook's stdout is a decision, so a
stray line risks a misparse on every Edit and Write -- a diagnostic that
would be a worse fault than the one it reports. There is deliberately no
permissionDecision key: a notice that blocked would invert the fail-open
posture that is the whole point of this gate.

Rate-limited per reason (30 min, -NoticeCooldownMinutes) so a persistently
broken overlap cannot narrate itself into every edit -- this gate's own
docstring records where a gate that cries wolf ends up. The stamp lives
under -StateDir, defaulting to the repo's coordination dir and resolved
ONLY when about to report, so nothing new runs on the hot path. If the
stamp cannot be read or written the notice is emitted anyway: the failure
mode of a noise-suppressor must be noise, never quiet, or an unwritable
directory silently restores exactly the behaviour this removes.

Distinguishing overlap-empty from a resolved "nobody" required fixing the
producer first (previous commit) -- you cannot detect a difference the
producer never encoded. Verified against the real overlap script, not only
the stubs: an ordinary edit to an untouched file is silent.

Tests: -StateDir isolates the throttle per test, or the first notice would
silence the next test's and the suite would pass on run order.
…arded it

-Take documented itself as idempotent -- "re-taking your own claim just
refreshes the note" -- and did not refresh anything. A new -Note was taken,
acknowledged and dropped.

That is worse than an outright failure, because of what the note is for.
It is the only field written deliberately to say what a session is doing,
and announce-session.ps1 broadcasts it to every session joining the repo
while telling them to prefer it over the worktree name. So the one field
elevated to authoritative was the one field that could not be corrected.
Measured 2026-08-02: a claim note was still announcing "NO PR OPENED --
honouring the #119 merge freeze" to every joining session hours after both
that PR and the one it gated had merged.

The workaround people reached for -- -Release then -Take -- drops the claim
in between, re-opening the race the claim exists to close.

Re-taking a key you hold now rewrites the file in place: note, branch (a
worktree can have switched branches, and a claim naming a branch nobody is
on is another confidently-wrong coordination fact) and a new `refreshed`
stamp, leaving `claimed` untouched -- which is what proves the claim was
never let go. Write-then-rename, not a truncating write: claim_check.py
swallows a JSON parse error into "not claimed", so a torn file is a
silently disabled gate, and a crash mid-refresh must leave the old note.
Mutual exclusion is unchanged and pinned: a peer's key is still refused.

One trap found by the test rather than by reading. ConvertFrom-Json
silently coerces an ISO-8601 string to [datetime], so [string]$c.claimed
returns the local short form -- sub-second precision and UTC offset gone.
Writing that back would have downgraded the stamp on every refresh, and it
would still have parsed, so nothing would ever have complained. Stamps now
round-trip through "o", and the test asserts byte equality rather than
"still parses". The same coercion is handled where announce reads it, with
an invariant-culture parse for the string case.

announce-session.ps1 now prints each claim note's AGE (from `refreshed`
else `claimed`, "age unknown" when it cannot be determined -- an unknown
age must not render as a fresh one). Elevating a note to authoritative
makes a stale one strictly more dangerous than none, and age is the cheap
signal that lets a reader discount it.

Not taken here: claim -List's staleness-vs-liveness rendering, which is
already open as its own change.
…ired

SESSION-DRIFT-CONTROLS.md: a fifth instance of the silent-control class,
in the collision gate itself, added to the callout that names the class.
It carries the part worth reusing -- the fix was not "check harder", it was
giving two states different bytes, and the first attempt failed because the
PRODUCER had never encoded the difference. Status-table rows for the three
controls, and the claim-refresh behaviour beside claim.ps1's entry.

WORKTREES.md: the announce id rule already warned that a prefix match
resolves a peer in the primary to an arbitrary worktree session, because
every worktree cwd extends the primary's. overlap.ps1 had that same trap
live at the same time. Noted there, with the distinction that matters:
overlap genuinely needs a prefix match, so the cure is longest-prefix
rather than exact-match.

And a correction. The broadcast-constraints list said of last week's merge
freeze that "#119 never merged (it died on an unrelated CI timeout)". It
merged the following day, 2026-08-02 01:45Z. Verified against the API
rather than restated. The lesson is unchanged and in fact sharper: the
recipients could not evaluate the predicate, so the freeze outlived its own
condition in both directions -- five sessions held while it had not
arrived, and a claim note was still announcing it hours after it had.
Found while checking a peer session's report, not by looking for it. That
session announced itself by hand on 2026-08-02 and gave the reason as "the
hook is on an unmerged branch". It had merged (#133, 3389aa2) hours
earlier, so the observation was right and the diagnosis was not, and
nothing would have corrected it.

Measured across all five config roots:

  - no `mefor-announce` UserPromptSubmit entry anywhere
  - the one UserPromptSubmit entry installed is `# mefor-web-announce`,
    which resolves scripts/hooks/announce.ps1 -- a different script in a
    different repo, and one the installer's own comment already warns is
    easy to confuse with this marker
  - <git-common-dir>/mefor-coord/announce/ does not exist, so there is not
    a single receipt: it has never executed

install-coordination.ps1 was last run before the announce row existed, and
merging a hook does not install one. Its two other entries -- the
SessionStart banner and the collision gate -- were wired then and are
present, which is precisely why nothing looked wrong.

The part worth carrying: the missing-script notice was built so this class
could not hide, and it CANNOT FIRE when the hook is not wired at all,
because it lives inside the shim. Same shape as the defect this document
already records one level down -- the detector sat downstream of the
failure it existed to detect. So the status table now distinguishes rule
4's inert-BY-DESIGN from this one's inert-BY-ACCIDENT, and the confirmation
step is a receipt on disk rather than a reading of the settings file.

Not installed here: that writes ~/.claude/settings.json, which is shared
with every session on this machine. Owner's call, from a plain terminal.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant