Manual hyp detach retracts its attach marker so rejoin re-attaches (#217)#223
Open
philcunliffe wants to merge 2 commits into
Open
Manual hyp detach retracts its attach marker so rejoin re-attaches (#217)#223philcunliffe wants to merge 2 commits into
hyp detach retracts its attach marker so rejoin re-attaches (#217)#223philcunliffe wants to merge 2 commits into
Conversation
…217) Manual `hyp detach` reversed a client's on-disk settings but left an orphaned `status: "done"` attach marker in `client-actions.json`. The action reconciler is level-triggered against that marker, so the next `hyp join`'s forward gap short-circuits on `done` and never re-attaches: detach-via-config-drop was rejoin-recoverable while detach-via-CLI was not, and the two disagreed side by side in `hyp status`. Fix: after a successful disk reversal, `detachClientViaCore` now retracts the client's `attach` marker via a new `clearClientActionMarker` helper on the reconciler module (which owns the marker store) — the same `delete markers[requestKey]` the reconciler's `reverse()` does once its disk undo succeeds. This keeps the single core undo's two call sites (CLI detach and reconciler reverse) from drifting, honoring LLP 0045 §Part 3 (the marker is a self-describing undo record that must not outlive its own effect being reversed). The retraction is best-effort and atomic (tmp+rename, mode 0600), and runs even on a no-op reversal so a stale marker over already-clean settings is still cleared. Regression test drives the full join -> detach -> rejoin cycle end to end: the real reconciler attaches (marker done + settings written), the real `hyp detach` command reverses settings and retracts the marker, and a second reconcile re-attaches while an un-detached sibling stays attached. It fails on master at the marker-retract assertion and passes with the fix. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Contributor
Author
Dual-agent review —
|
| Source | Finding (severity, evidence) | Intersects |
|---|---|---|
| Codex | major — concurrent RMW resurrects marker (action_reconciler.js:66,248,359,371) | Concurrency surface / Risks #1 |
| Claude | major — probe-less no-op clears marker, re-orphans settings (core_commands.js:3685) | Risks #2 |
| Claude | minor — missing unit coverage for no-op/emptied-bucket (action_reconciler.js:356-373) | Risks #3 |
| Claude | nit — no coverage for best-effort/changed:false paths (core_commands.js:3684-3697) | Risks #3 |
| Claude | nit — em dashes U+2014 added (test/core/detach-rejoin-recovery.test.js:26) | (style; out of high-risk surface) |
Codex review
Fix Validations
Manual detach leaves stale attach marker, blocking rejoin
- Status: correct
- Evidence:
src/core/cli/core_commands.js:3663,src/core/cli/core_commands.js:3685,src/core/config/action_reconciler.js:210,src/core/config/action_reconciler.js:212,test/core/detach-rejoin-recovery.test.js:194,test/core/detach-rejoin-recovery.test.js:211,test/core/detach-rejoin-recovery.test.js:223 - Assessment: The CLI now clears the same
attachmarker after successful disk detach that the reconciler reverse path already deletes after its own undo. The regression test covers join, manual detach, marker removal, and rejoin re-attach.
Findings
4) Concurrency, Ordering & State Safety
- Severity: major
- Confidence: medium
- Evidence:
src/core/config/action_reconciler.js:101,src/core/config/action_reconciler.js:248,src/core/config/action_reconciler.js:66,src/core/config/action_reconciler.js:70,src/core/config/action_reconciler.js:359,src/core/config/action_reconciler.js:371,src/core/config/action_reconciler.js:372 - Why it matters:
clearClientActionMarker()adds a second process that does read-modify-write over the whole marker file, so a daemon reconcile that read an older snapshot can later rewriteclient-actions.jsonand resurrect the staleattach.claudemarker this PR just cleared. - Suggested fix: Put all marker-store writers behind the same cross-process lock or retrying compare-and-merge path, and use it from both
writeStore()andclearClientActionMarker().
No Finding
- Behavioral Correctness
- Contract & Interface Fidelity
- Change Impact / Blast Radius
- Error Handling & Resilience
- Security Surface
- Resource Lifecycle & Cleanup
- Release Safety
- Test Evidence Quality
- Architectural Consistency
- Debuggability & Operability
Evidence Bundle
- Changed hot paths:
hyp detachviarunClientLifecycle()todetachClientViaCore();config-control/client-actions.jsonmarker read/write; action reconciler forward and reverse gaps. - Impacted callers:
src/core/cli/core_commands.js:3474,src/core/cli/core_commands.js:3493,src/core/cli/core_commands.js:3685;src/core/config/action_attach.js:107,src/core/config/action_attach.js:205. - Impacted tests:
test/core/detach-rejoin-recovery.test.js:161; existing reverse coverage attest/core/action-reconciler.test.js:330; status marker contract attest/core/status-client-actions.test.js:293. - Unresolved uncertainty: I did not run the suite; this review is based on the supplied diff plus targeted caller and contract tracing.
Claude review
Claude review
CLI detach clears the attach marker on a probe-less no-op, re-orphaning settings (#212 asymmetry)
- Severity: major
- Confidence: 84
- Evidence: src/core/cli/core_commands.js:3685
- Why it matters:
clearClientActionMarkerruns unconditionally afterdetachClientFromDisk, explicitly "even on changed:false". For a probe-less descriptordetachClientFromDiskreturns{changed:false}meaning "cannot reverse — no probe" (client_detach_disk.js:87), with the settingsattach()wrote still on disk; the CLI conflates that with "already clean" and drops the marker — the exact invariant client attach: probe-lesscontributes.clientcan attach but reverse() silently no-ops, orphaning settings #212/Fix probe-less client attach/reverse asymmetry that orphans settings #213 hardenedreverse()against (action_attach.js:212-221 returnsfailedand KEEPS the marker for a probe-less client). The PR comment claims it retracts "exactly as the reconciler's reverse() does," but reverse() deliberately does NOT retract in the probe-less case. Reachability is low today (no shipped client is probe-less; the reconciler never writes a probe-less marker post-client attach: probe-lesscontributes.clientcan attach but reverse() silently no-ops, orphaning settings #212, so it needs a stale/out-of-band marker), so this is a latent asymmetry rather than a live bug — but it is cheap to close and is precisely the invariant the team invested two prior issues in. - Suggested fix: Mirror the reverse() guard: gate the
clearClientActionMarkercall ondescriptor.attachProbebeing present (skip retraction for a probe-less descriptor so an out-of-band marker survives, exactly as reverse() keeps it), and correct the "exactly as reverse() does" comment to note the probe-less exception.
Missing unit coverage for clearClientActionMarker no-op and emptied-bucket branches
- Severity: minor
- Confidence: 88
- Evidence: src/core/config/action_reconciler.js:356-373
- Why it matters: The only test (detach-rejoin-recovery.test.js) exercises solely the happy branch (delete one key; the bucket still holds codex so it never empties). The no-op
return falseguard (line 361) and the emptied-bucket deletiondelete store[kind](line 367) are never hit, so a regression that (e.g.) creates/rewrites the file on a missing key, or fails to drop the last-key bucket, would still pass CI. - Suggested fix: Add a small unit test for
clearClientActionMarker: missing file/bucket/key each returnfalseand write no file; last-key removal drops the bucket while a sibling key is preserved; a truthy return rewrites atomically (mode 0600).
No coverage for the best-effort marker-retract failure and changed:false paths
- Severity: nit
- Confidence: 85
- Evidence: src/core/cli/core_commands.js:3684-3697
- Why it matters: The comment promises "a marker we cannot retract is a status blemish, not a detach failure" and "runs even on a no-op reversal (changed:false)", but nothing proves detach still returns 0 (and warns) when
clearClientActionMarkerthrows, nor that the marker is cleared on an already-clean (changed:false) reversal. - Suggested fix: Add a focused test where marker retraction throws (assert detach still succeeds with the warning emitted) and where settings are already clean (changed:false, assert the marker is still cleared).
New code adds em dashes (U+2014), banned by AGENTS.md
- Severity: nit
- Confidence: 82
- Evidence: test/core/detach-rejoin-recovery.test.js:26 (also the two diff-added
@refglosses at action_reconciler.js:354 and core_commands.js:3683) - Why it matters: AGENTS.md:38 states "No em dashes (the U+2014 character) anywhere: code, comments, JSDoc, strings, or docs"; the diff adds 3. The two
@refglosses follow the em-dash@refformat that CLAUDE.md itself prescribes and the codebase uses pervasively (so they are defensible), leaving the test-file narrative JSDoc em dash as the clean, unexempted violation. - Suggested fix: Replace the em dash(es) on the added test-prose line(s) with the punctuation the guidance prescribes (colon/comma/parentheses/sentence split); optionally reconcile the CLAUDE.md
@refformat vs the AGENTS.md ban separately.
Reports: /Users/phil/workspace/hypaware/.git/worktrees/tmp.UZCN0Zueou/dual-review/pr-223
… 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>
Contributor
Author
🤖 neutral: reviewed (2 rounds), green, mergeable — held for your mergeFixes #217 (manual
Merge is yours — neutral never merges. Held. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
After a manual
hyp detach <client>, a subsequenthyp joindoes not re-attach that client. It stays[configured, not attached]with no rejoin recovery, while a sibling client that was never detached remains attached — the divergence is visible side by side inhyp status.Root cause (confirms the issue's diagnosis): manual
hyp detachreverses the client's on-disk settings but leaves an orphanedstatus: "done"attach marker inclient-actions.json. The action reconciler is level-triggered against that marker, so the next join's forward gap short-circuits ondoneand never callsperform(). The asymmetry: the daemon reconciler'sreverse()deletes the marker (config-drop is rejoin-recoverable), but the manual CLI detach (detachClientViaCore→detachClientFromDisk) reversed disk only and never touched the marker store, so marker and disk drifted.Fix
Make manual
hyp detachretract the client'sattachmarker after a successful disk reversal, mirroring the daemonreverse()path so the two call sites of the single core undo cannot drift.clearClientActionMarker({ stateRoot, kind, requestKey })onaction_reconciler.js(the module that owns the marker store) — the write counterpart toreadClientActionStatus, atomic (tmp+rename, mode 0600), a no-op when the marker is absent.detachClientViaCorecalls it afterdetachClientFromDisksucceeds. It runs even on a no-op reversal (changed: false) so a stale marker over already-clean settings is still cleared, and is best-effort: a marker it cannot retract is a status blemish, not a detach failure (the settings undo already landed).This is consistent with LLP 0045 §Part 3 — the marker is a self-describing undo record for the one core undo shared by CLI detach and reconciler
reverse(); a manual detach that reverses settings must retract the marker so state stays honest. It composes with auto-attach-on-join (no reintroduction of the #212 orphaned-settings or #213 reverse-marker classes) — the reconciler's own reverse path is untouched.Test
test/core/detach-rejoin-recovery.test.jsdrives the full join → detach → rejoin cycle end to end: the real reconciler attaches claude + codex (markerdone, settings written), the realhyp detach claudecommand reverses the settings and retracts the marker, and a second reconcile re-attaches claude while the un-detached codex stays attached. It fails on master at the marker-retract assertion (manual detach must retract the orphaned claude attach marker) and passes with the fix.Gates
npm test: 1670 pass, 0 fail, 1 pre-existing skip (adds 1 test)npm run build:types(tsc): cleannpm run typecheck(includestest/): cleanFixes #217