Fix probe-less client attach/reverse asymmetry that orphans settings#213
Conversation
A `contributes.client` descriptor with no `attach_probe` could be
auto-attached on join and marked `done`, but the disk-driven reverse
needs the probe to undo the on-disk settings. Reverse read the missing
probe as a clean no-op (`{ changed: false }`) and dropped the action
marker, leaving the client's settings orphaned with no record an attach
ever happened.
Root cause: attach-eligibility and reverse-capability diverged. perform()
needs only a live adapter (no probe), but reverse() replays the
descriptor's `attachProbe` to strip the managed settings (LLP 0045
Part 3). A probe-less client was therefore attachable but never
reversible.
Fix (both halves of the contract):
- `desired()` now skips a descriptor with no `attachProbe`, so a
probe-less client is never named as an attach target in the first
place — attach-eligibility requires reverse-capability.
- `reverse()` now treats a missing `attachProbe` for an applied marker
as a *failed* (retryable, visible) reverse rather than dropping the
marker, keeping any out-of-band marker (manual `hyp attach`, pre-fix)
recoverable instead of orphaning its settings.
Regression test (test/core/action-attach.test.js), failing on master,
passing here: a probe-less descriptor falls out of `desired()`, and
`reverse()` of one returns `failed` (not a marker-dropping `done`).
Both shipped adapters (claude/codex) declare a probe, so behavior is
unchanged for them; full suite green (1564 pass, 1 skipped).
Fixes #212
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Dual-agent review —
|
| Source | Finding (severity, evidence) | Intersects |
|---|---|---|
| Codex | Test Evidence Quality - no reconciler-level regression that the applied marker survives (minor, action_reconciler.js:211,222) |
Direct callers (reconciler keep-marker-on-failed path) |
| Claude | Added lines introduce U+2014 em dashes, banned by branch Code Style (minor, action_attach.js:219, test titles :222,:449) |
Targets (changed files) |
Codex review
Fix Validations
Probe-less descriptor auto-attach
- Status: correct
- Evidence: src/core/config/action_attach.js:91, src/core/config/action_attach.js:103, test/core/action-attach.test.js:222
- Assessment:
desired()now excludes descriptors withoutattachProbebefore naming an attach target, which closes the auto-attach half of the asymmetry.
Probe-less reverse dropping an applied marker
- Status: correct
- Evidence: src/core/config/action_attach.js:212, src/core/config/action_attach.js:217, src/core/config/action_reconciler.js:211, src/core/config/action_reconciler.js:222, test/core/action-attach.test.js:449
- Assessment: The new
failedoutcome is the right signal. The reconciler deletes markers only ondone; failed reverse paths are logged and left in place for retry.
Existing handler check
- Status: correct
- Evidence: src/core/config/client_detach_disk.js:85, src/core/config/client_detach_disk.js:87, src/core/config/action_attach.js:250
- Assessment: Existing disk detach could not have fixed this by itself: no probe returned
{ changed: false }, andreverse()treated all no-op detach results as successful marker drops.
Manual probe-less attach recovery
- Status: incomplete
- Evidence: src/core/cli/core_commands.js:3578, src/core/cli/core_commands.js:3585, src/core/config/client_detach_disk.js:85, src/core/config/client_detach_disk.js:87
- Assessment: The daemon action path is fixed, but manual
hyp attachstill dispatches to the live adapter without anattachProbegate, while manual detach still no-ops without a probe. If manual probe-less attach recovery is part of the intended claim, it is not covered by this diff.
Findings
9) Test Evidence Quality
- Severity: minor
- Confidence: medium
- Evidence: test/core/action-attach.test.js:459, test/core/action-attach.test.js:463, src/core/config/action_reconciler.js:211, src/core/config/action_reconciler.js:222
- Why it matters: The new regression verifies
reverse()returnsfailed, but the PR’s key safety claim is that the reconciler keeps the applied marker, and that marker deletion logic lives in a separate caller. - Suggested fix: Add a reconciler-level regression with a pre-existing
attachmarker for a probe-less descriptor, then assert reconcile reports reverse failure and the marker remains in the action store.
No Finding
-
- Behavioral Correctness
-
- Contract & Interface Fidelity
-
- Change Impact / Blast Radius
-
- Concurrency, Ordering & State Safety
-
- Error Handling & Resilience
-
- Security Surface
-
- Resource Lifecycle & Cleanup
-
- Release Safety
-
- Architectural Consistency
-
- Debuggability & Operability
Evidence Bundle
- Changed hot paths:
attachHandler.desired()probe gate,attachHandler.reverse()missing-probe failure, direct handler regression tests. - Impacted callers: daemon registers
attachHandlerviaDEFAULT_ACTION_HANDLERSat src/core/daemon/runtime.js:65; reconciler invokes handler reverse at src/core/config/action_reconciler.js:210; CLI detach sharesdetachClientFromDiskat src/core/cli/core_commands.js:3654. - Impacted tests: test/core/action-attach.test.js:222, test/core/action-attach.test.js:449, existing reverse coverage at test/core/action-attach.test.js:389 and test/core/action-attach.test.js:414.
- Unresolved uncertainty: I did not run the suite; this review used the provided diff plus targeted caller and contract traces.
Claude review
Claude review
Added lines introduce U+2014 em dashes (banned by repo Code Style)
- Severity: minor
- Confidence: 85
- Evidence: src/core/config/action_attach.js:219 (runtime
reasonstring); also :61,:72,:101,:194,:203; test/core/action-attach.test.js:222,:449 (test titles), :59,:226,:454 - Why it matters: The branch's own Code Style (CLAUDE.md / AGENTS.md line 38: "No em dashes (the U+2014 character) anywhere: code, comments, JSDoc, strings, or docs") forbids U+2014, and the repo lint (
scripts/check-syntax.js, not eslint) does not flag it, so green CI is no defense; these added lines each contain an em dash, most notably the user-visible returnedreasonat action_attach.js:219 and the two test titles. (Note: the file already uses em dashes pervasively pre-existing, so the PR follows local precedent.) - Suggested fix: Replace each added U+2014 with the punctuation the rule prescribes -
-in the runtimereasonstring and test titles, and a comma/colon/parenthetical or sentence split in comments and JSDoc.
Reports: /Users/phil/workspace/hypaware/.git/worktrees/dual-review-pr-213/dual-review/pr-213
🧭 Decision map — where to spend your attentionCompanion to the dual-review verdict. This casts no verdict — it points at the 2 forks where the author made a real choice, so you can skim the rest. Scanned: ~7 hunks across 2 files. Most is mechanical: expanded JSDoc / inline-comment prose and 1. Probe-less
|
…shes (#212) Finding 1 (codex): add a reconciler-level regression in test/core/action-reconciler.test.js that seeds a pre-existing applied attach marker for a probe-less descriptor, runs reconcile, and asserts the reverse is reported failed AND the marker survives in the store (never dropped, so settings are never orphaned). Mutation-checked: removing the reverse() guard makes exactly this test fail. Finding 2 (claude): replace the em dashes (U+2014) this PR added with the punctuation AGENTS.md prescribes - hyphens in the runtime reason string and test titles, colon/comma in JSDoc and comments, and the canonical colon separator (per ref-check) in the two @ref glosses. Pre-existing em dashes on untouched lines are left alone. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The minor-fix pass over-applied the no-em-dash style rule to the @ref annotation glosses, switching `[relation] — gloss` to `[relation]: gloss`. The em dash is structural @ref syntax (ref-check's documented format and 123:0 the dominant separator across src/), not the prose the rule targets, so those two refs were the only colon-separated refs in the corpus. Revert just the separators; the runtime reason string, test titles, and prose comments keep the hyphen/comma fixes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
🤖 neutral: reviewed, green, mergeable — held for human merge gateFixes #212 (probe-less
State: Out of scope (pre-existing, not this diff — noted for follow-up, not fixed here):
Merge is a human act — neutral never merges. Held for your call. |
… the guard Dual-review fixes on the #217 fix (PR #223). Finding 2 (major): detachClientViaCore cleared the attach marker unconditionally, even on `changed:false`. But `changed:false` is overloaded: probe-HAVING means "already clean" (safe to clear); probe-LESS means "cannot reverse, no probe to replay" (#212). The daemon reverse() KEEPS the marker for a probe-less descriptor; the CLI was dropping it, re-orphaning the settings the #212/#213 invariant protects. Gate the clearClientActionMarker call on `descriptor.attachProbe` so the CLI mirrors reverse(): clear for a probe-having client (changed:true or already-clean), keep for a probe-less one. Correct the comment (it claimed it behaved "exactly as reverse() does", which was wrong for the probe-less case). Finding 3 (minor): unit-test clearClientActionMarker's untested branches - missing file/bucket/key each return false and write no file; an emptied bucket is dropped while a sibling `backfill` bucket and its keys survive. Finding 4 (nit): cover the best-effort + changed:false semantics through the real CLI detach path - a probe-having client with already-clean settings still has its stale marker cleared; a probe-less client keeps its marker (the finding-2 guard, mirroring reverse()); a throwing retraction does not fail the detach. Mutation check: removing the `attachProbe` guard fails the probe-less test. Finding 5 (nit): replace a U+2014 em dash in test prose with a colon (AGENTS.md bans em dashes; @ref glosses excepted). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Root cause
A
contributes.clientdescriptor with noattach_probecould be auto-attached on join and markeddone, but the disk-driven reverse path needs the probe to undo the on-disk settings. The reverse read the missing probe as a clean no-op and dropped the action marker, leaving the client's settings orphaned with no record an attach ever happened.The two sides of the contract had diverged:
src/core/config/action_attach.jsdesired()named a client as an attach target on enabled plugin + on_join≠false + runtime registry reachable, without gating ondescriptor.attachProbe.perform()only needs a live adapter, so a probe-less client could attach and markdone.reverse()is disk-driven (LLP 0045 Part 3): it replays the descriptor'sattachProbeto strip the managed settings.detachClientFromDisk()opens withif (!probe) return { changed: false }— and{ changed: false }is overloaded: it means both "no probe (can't reverse)" and "already clean (nothing to do)". The oldreverse()always returneddone, so on a config-drop the marker dropped while the settings stayed written.Net: attach can succeed without a probe, but reverse needs it to undo → orphaned settings, invisible to a later detach.
Fix (both halves of the contract — issue's proposals 1 and 2)
desired()requiresattachProbe. A descriptor with no probe is skipped, so a probe-less client is never named as an attach target — attach-eligibility now requires reverse-capability.reverse()is honest about the gap. A missingattachProbefor an already-applied marker is treated as a failed (retryable, visible) reverse rather than a marker-droppingdone. This keeps any out-of-band marker (manualhyp attach, or a pre-fix marker) recoverable instead of orphaning its settings.Added
@ref LLP 0045#part-3…annotations ondesired()([constrained-by]) andreverse()explaining why the probe is required, since Part 3 already establishes reverse depends on theattachProbe.Regression test
test/core/action-attach.test.js, two new tests (a probe-lessPROBELESS_DESCRIPTORfixture):desired() excludes a probe-less descriptor …— failed on master (desired()namedprobeless), passes here ([]).reverse() of a probe-less descriptor fails …— failed on master ('done' !== 'failed'), passes here (failed, reason mentionsattach_probe, undo not consulted).Verified failing→passing by running the two tests against the unfixed
origin/mastersource (both✖) then the fixed source (both✔).Both shipped adapters (claude/codex) declare a probe, so their behavior is unchanged — this closes the latent gap #179's converged review flagged. Full suite green: 1564 pass, 1 skipped, 0 fail;
npm run lintclean.Fixes #212