Two long-stale attach smokes assert the behaviour that replaced them (#655) - #656
Two long-stale attach smokes assert the behaviour that replaced them (#655)#656philcunliffe wants to merge 2 commits into
Conversation
…655) `claude_attach_detach` and `client_attach_on_join` have both failed on master since long before v1.19.0. Neither is a code defect: each asserts a contract that a later, documented decision deliberately replaced, and neither smoke was updated with it. Both are in neither the release battery nor CI, so nothing caught the drift. claude_attach_detach broke at 41ea01c (LLP 0106, #316). Attach used to install exactly one managed hook group per event; LLP 0106 added `classify-cwd` beside `session-context` on the two events where a fresh working directory appears (SessionStart, CwdChanged), so `hooks.SessionStart` now carries two groups and the smoke's `v.length === 1` fails. The unit test for the marker's managed hook list was updated at the time; the on-disk golden compare in the smoke was not. Assert both sides of the split instead: SessionStart and CwdChanged carry the pair, UserPromptSubmit and PostToolUse carry `session-context` alone, so dropping either kind or leaking `classify-cwd` onto the per-prompt and per-tool events fails here. client_attach_on_join broke at 1ce40da (LLP 0086, #277/#278). Its step 3 asserted the pre-0086 model, attach once and done forever: a relaunch on an unchanged revision must not re-attach. LLP 0086 D1 made a `done` marker at a moved endpoint a forward gap precisely so an ephemeral gateway port does not strand `ANTHROPIC_BASE_URL` on a port nothing bound, and rev-1 binds `127.0.0.1:0`, so the relaunch drifts by construction and the marker is refreshed. Cover both branches of that check rather than one: the relaunch on rev-1 now asserts the re-attach and that the base URL follows the new port, then rev-1b pins the gateway's `listen` so the endpoint stops moving and a further relaunch proves the short-circuit the step originally meant to prove. Co-Authored-By: Claude <noreply@anthropic.com>
client_attach_on_join reserved rev-1b's pinned port by binding port 0 and releasing it, then let the daemon bind it seconds later. Anything on the host could win that window, including the smoke's own stub central server, which made this the only flow in the suite that could collide with a co-resident process. Read the port back from the drifted daemon's own status.json instead (resolveLiveGatewayEndpointFromStatus, LLP 0086 D2) and pin rev-1b to that. The daemon then reclaims a port it already holds across the staged restart, so no reservation window opens at all. Pinning a port the daemon already bound means the relaunch short-circuits rather than re-attaching, so the separate pinned_attach boot is folded into the no_reattach one. The drift step still proves the re-attach branch and still asserts env.ANTHROPIC_BASE_URL === drifted.endpoint, and the short-circuit comparison still runs against a genuinely fresh marker timestamp: the drift step asserted it moved off the first attach's. The guard boot now also proves the gateway really reclaimed the pinned port, matched against this boot's status.json rather than the outgoing daemon's leftover snapshot (a pinned port makes both report the same endpoint). claude_attach_detach's LLP 0106 gloss claimed the doc scopes the classification hook to the fresh-cwd events. It does not: 0106 settles only that the hook is installed alongside session-context, and the event scoping is decided by MANAGED_HOOK_SPECS in the claude plugin's settings.js. Narrow the gloss to what 0106 says and point the scoping at the code that owns it. Co-Authored-By: Claude <noreply@anthropic.com>
Neutral review round 1 -
|
Neutral review round 2 -
|
| mutant | result |
|---|---|
| rev-1b serves the ephemeral port instead of the pin | FAIL at the new reclaim guard |
isCurrent forced false (short-circuit disabled) |
FAIL at no re-attach ... (done short-circuits) |
isCurrent forced true (drift detection disabled) |
FAIL at marker was not refreshed after the gateway rebound |
gatewaySourceDetails returns port + 1 |
FAIL at the drifted gateway reports its live bound endpoint |
The second is the one that matters: it proves the restructured short-circuit assertion is not vacuous under the pin. The first alone would not have shown that, since it fails at the guard rather than at the assertion.
Both freshness branches survive. What the restructure dropped was a second instance of the drift branch (drift caused by a config change rather than by ephemeral rebinding, same isCurrent path), not a distinct branch. Re-attach-on-drift is proven live by NC-C with ANTHROPIC_BASE_URL === drifted.endpoint; short-circuit-on-unmoved is proven live by NC-B. One incidental gain: the marker being short-circuited is now written by a different daemon boot, so the guard is proven across a restart boundary, where at 58ca0ff both boots were at the pinned port.
The startedAt guard is sound. It is computed per runDaemon call and never persisted, so two boots cannot collide at ms resolution; the new boot's first writeStatusFile carries the new startedAt with sources: [], so there is no window where the timestamp is fresh but the sources are stale (the resolver returns undefined and polling continues). The reviewer could not construct a wrong pass. Worth recording: the pid-liveness gate inside resolveLiveGatewayEndpointFromStatus is a no-op here because the daemon runs in-process, so all staleness protection comes from these explicit equality assertions.
reserveLocalPort is gone (repo-wide grep returns nothing) and no fixed port remains; the only literals are 127.0.0.1:0 and the stub's listen(0). 12 consecutive runs, all green. A narrow rebind window remains between the drift daemon releasing the port and the relaunch reclaiming it, but that is inherent to testing "a pinned port is reclaimed across a restart" and is far narrower than the old reserve-then-release window.
Corroboration for the round-1 ruling: the reviewer ran client_attach_on_join on origin/master and it FAILS at the pre-0086 timestamp assertion, so this PR is fixing a genuinely red smoke.
Finding 2 verified: LLP 0106's Decision says nothing about which events, and the narrowed gloss now claims exactly the alongside-session-context split and no more. The redirected attribution is accurate: MANAGED_HOOK_SPECS at settings.js:47 with the event-scoping rationale in the comment directly above it.
Scope is the two smoke files only. npm test 3574 pass / 0 fail / 1 skip, tsc clean, claude_attach_detach, gateway_claude_capture, cli_bundled_plugins_activated all ok. client_attach_idempotent fails identically on master, PR #654's territory.
A methodology note worth carrying: the reviewer's first negative control used a python3 heredoc, python3 is absent on this host, and the mutation silently never applied, so the smoke passed and looked like a vacuous assertion. It was caught and redone with node. A mutation that silently fails to apply reads exactly like a test that fails to catch it.
Both smokes named in #655 fail on
origin/master, and both are stale smokes, not code defects. Each asserts a contract that a later, documented decision deliberately replaced, and neither smoke was updated alongside it. Neither is in the CLAUDE.md release battery, which is why the drift went unnoticed for weeks.I reproduced both failures on
origin/masterbefore changing anything, and bisected each to an exact commit by confirming a pass at the parent and the identical failure at the commit itself.claude_attach_detachFailure on master
The predicate requires
hooks.SessionStart.length === 1. Actual value is two groups:claude-hook session-context --state-file .../session-context.jsonlandclaude-hook classify-cwd.When it started:
41ea01c(2026-07-14, "Enrollment privacy review", #316). Verified: passes at3766f50(the parent), fails identically at41ea01c.Stale, not a defect. LLP 0106 (Accepted) decided that a session-start classification hook is installed alongside the existing session-context hook.
settings.js'sMANAGED_HOOK_SPECSimplements it:session-contextrides all four managed events,classify-cwdrides only the two where a fresh working directory appears (SessionStart,CwdChanged). The unit testtest/plugins/claude-settings-attach.test.jswas updated for this at the time and asserts exactly this shape on the marker's managed hook list. The smoke's on-disk golden compare simply was not.Change: assert both sides of the split rather than just the surviving one.
SessionStartandCwdChangedeach carry the pair in install order;UserPromptSubmitandPostToolUsecarrysession-contextalone. Dropping either kind, or leakingclassify-cwdonto the per-prompt / per-tool events, now fails here. Added ahookCommands()helper that flattens ahooks.<event>block to its command strings.client_attach_on_joinFailure on master
Telemetry from the failing run shows two
client_action.attach_performevents at different endpoints (http://127.0.0.1:42635thenhttp://127.0.0.1:40765): the attach was re-performed on the relaunch.When it started:
1ce40da(2026-07-07, "Attach lifecycle: track the gateway's ephemeral port", #277 / #278). Verified: passes at1936122(the parent), fails identically at1ce40da.Stale, not a defect. The smoke was written in
96cd19d(LLP 0044/0045/0046) under the attach once, done forever model, and its step 3 encodes it: a relaunch on an unchanged revision must not re-attach. LLP 0086 (Active) D1#re-attach-on-driftreplaced that model, calling it out as the root cause of a real user report: the marker now records the endpoint it attached at, and adonemarker at a moved endpoint is a forward gap that re-performs. rev-1 in this smoke binds127.0.0.1:0, so every relaunch is at a new port and the re-attach is the documented, intended outcome. The smoke's assertion is the pre-0086 behaviour.Change: cover both branches of the freshness check rather than only the one that survived.
env.ANTHROPIC_BASE_URLfollows it to the newly bound port. This is the property LLP 0086 exists to protect, and nothing else covered it end to end.listento a port the smoke reserves, so the input the freshness check watches stops moving. The relaunch onto rev-1b re-attaches once at the pinned port, and a further relaunch at that same stable endpoint is the no-op branch: the marker timestamp and the client settings are byte-for-byte unchanged, which is what step 3 originally meant to prove and now proves honestly.The rev-2 drop / reverse-gap step is unchanged apart from taking the pinned
listen.Verification
claude_attach_detach: FAIL onorigin/masterbefore, ok after.client_attach_on_join: FAIL onorigin/masterbefore, ok after (run 3x, stable).node scripts/run-tests.js: 3574 pass, 0 fail, 1 skipped.npx tsc -p tsconfig.json --noEmit: clean.gateway_claude_captureok,cli_bundled_plugins_activatedok.client_attach_idempotentstill fails, but identically on pristineorigin/master(theadapter_not_enabledmessage split, Release-battery smoke client_attach_idempotent fails on master (passes at v1.19.0) #652), so it is untouched by this change.Note on the release battery
Neither smoke is in the CLAUDE.md battery. Both are now green and both cover contracts nothing else covers end to end (the on-disk hook block after attach; the LLP 0086 drift/no-drift split against a real daemon). Adding them is worth doing, but
client_attach_on_joinnow runs six daemon launches and takes roughly 15s, so it is a judgement call about battery runtime rather than an obvious edit. I have deliberately not touched CLAUDE.md here.Fixes #655