hyp leave's assetless-marker drop was never taught about refused markers (#627) - #630
hyp leave's assetless-marker drop was never taught about refused markers (#627)#630philcunliffe wants to merge 6 commits into
Conversation
…ers (#627) LLP 0186 added `refused` as a terminal marker state that, like `failed`, records no effect of its own: an attach refusal stops before it touches the client's settings. The reconciler's reverse gap was taught to treat the two alike. `hyp leave`'s parallel gate was not, and still asked `marker.status === 'failed'`, so an assetless `refused` marker fell through to the full `detachClientViaCore` reversal instead of being dropped. The fallthrough is not data loss, but it is not silent either: the disk probe finds nothing to reverse and prints "No HypAware marker found in <path>; nothing to do." during `hyp leave`, naming a settings file the refusal never wrote. Both gates now share one predicate, `markerRecordsNoEffect`, beside `readInstalledAssets` in action_reconciler.js. It asks the property the gates actually care about (this marker records no on-disk effect), which needs both a status that never wrote one and an empty `installed_assets`: a `done` attach that copied no files still owns the settings edit it wrote, so "carries no assets" alone would be the wrong test. An unrecognized status counts as recording an effect, so a marker state added later routes to the real reversal instead of being dropped by a gate nobody updated. The reconciler's own behaviour is unchanged; its inline condition is replaced by the identical shared call. Co-Authored-By: Claude <noreply@anthropic.com>
…ared predicate exists for
The predicate's two halves are both already covered: the reverse-gap and
`hyp leave` tests fail if the asset check goes away, and the `done` cases
fail if the status check goes away. Its third property was not covered at
all: mutating the status test from an allowlist ("failed or refused, else
an effect") to a denylist ("done or applied, else no effect") left the
whole suite green.
That defaulting is the anti-drift property the shared predicate was
introduced for. Inverted, a fifth marker state added later is silently
dropped by both gates instead of routed to the real reversal, which is the
data-loss shape of the #627 bug rather than its harmless one. Pin it, plus
the truth table for a marker whose `installed_assets` is malformed rather
than absent.
Co-Authored-By: Claude <noreply@anthropic.com>
Verdict: approve, with one gap fixed on the branchThe fix is correct, the refactor is genuinely pure, and the predicate does avoid the Does the predicate actually avoid the proxy trap?Yes, and for a reason worth naming precisely, because it is not the one the PR body The status half is still an enumeration of named states. What makes it not a proxy I verified the "a refusal wrote nothing" premise rather than taking it from LLP 0186,
So Byte-identity of the refactor: confirmedThe !marker || ((marker.status === 'failed' || marker.status === 'refused') &&
readInstalledAssets(marker).length === 0)is exactly Status set verified independently: Test strength: verified by mutation, not by reportReverting The overshoot guards genuinely guard. Two mutants against
Both halves are load-bearing and covered. Findings1. (medium, fixed) A third mutant survived the entire suite: if (marker.status === 'done' || marker.status === 'applied') return falseThat is the same predicate with the allowlist turned into a denylist. Fixed in 2. (informational, no action) Confirmed the PR does not implement finding 2: the reverse gap's failure 3. (informational)
Checks
Residual findings
|
A marker rewritten from `done` to `failed` or `refused` carries the earlier attach's asset list forward but nothing carries its settings write forward, so an attach that landed a settings write while installing no assets reads as "recorded nothing" and is dropped over settings still on disk. The predicate's doc claimed the two records were sufficient. Say what they miss instead, and where the fix belongs. Co-Authored-By: Claude <noreply@anthropic.com>
Verdict: do not merge as-is. Round 1's residual 1 resolves to a live data-loss path, and
|
_hypaware after leave |
ANTHROPIC_BASE_URL after leave |
leave said anything? | |
|---|---|---|---|
master (b56ab21) |
removed | removed | yes |
| this PR (d898a88) | still there | still http://127.0.0.1:4388 |
no |
master's gate was marker.status === 'failed' && assets.length === 0, so an assetless
refused marker fell through to detachClientViaCore, whose disk-driven undo reads the
settings file's own marker and cleans it. markerRecordsNoEffect drops it instead. The
user runs the one command whose whole job is to undo everything, is told it succeeded,
and is left with Claude Code pointed at a port that no longer binds, with no marker left
naming it and no line of output mentioning it.
Scope of the underlying defect vs. what this PR adds, stated precisely:
| gate | failed assetless |
refused assetless |
|---|---|---|
| reconciler reverse gap | drops on master (pre-existing) |
drops on master (pre-existing, #622) |
hyp leave |
drops on master (pre-existing) |
reversed on master, drops after this PR |
Three of the four cells are inherited. The fourth is this PR, and it is the one on the
user-facing command. "Now consistent with the reconciler" is true, and the consistency
is consistently wrong.
This also contradicts the PR body's central claim. The body says the predicate expresses
"the property, not a status name", and that two independent records both have to say
nothing happened. They do not: the asset half covers the asset effect, and the settings
effect of a prior done is covered by nothing. The predicate is a proxy for the
property after all, in exactly the way the body sets out to avoid, and being shared now
makes every future gate inherit the wrong answer instead of one of them.
What I did and did not change
I did not change behaviour. The fix is a marker-schema question (the rewrite has to
record the effect it overwrites, e.g. carrying a prior-done bit forward through the
failed/refused branches, which markerRecordsNoEffect then reads), and CLAUDE.md
reserves settled marker semantics (LLP 0138 #marker-undo, LLP 0186) for a new LLP rather
than a reviewer's edit in the last round.
What I did land, in 7b598df, is honesty about it: the predicate's JSDoc asserted the
two records were sufficient, and the next caller would have believed it. It now names the
uncovered case and where the fix belongs (src/core/config/action_reconciler.js:529-538,
verified via git show HEAD:src/core/config/action_reconciler.js). Also folded in round
1's declined nit: the {@link markerRecordsNoEffect} inside // line comments at
central.js:436 renders nowhere, now plain backticks.
An alternative worth putting in front of triage: #627's actual symptom is a noisy
no-op probe, not a harmful one. Making the idempotent disk-driven reversal quiet when it
finds nothing would fix #627 without ever using marker status as a proxy for what is on
disk. That is a design pivot, not a review edit, so I am not making it.
Finding 2 (informational): round 1's mutants re-verified, plus three of mine
Round 1's fix holds, and both call sites are pinned.
| mutant | result |
|---|---|
A (round 1's): allowlist to denylist, status === 'done' || status === 'applied' |
dies - not ok 66, 3431/3429/1 |
B: if (!marker) return false (invert the missing-marker default) |
dies - not ok 67 only. Nothing but round 1's new unit test covers a missing marker |
C: invert the asset half, .length !== 0 |
dies - 5 tests, not ok 58/65/67/998/999 |
D: reverse-gap call site weakened to !marker |
dies - not ok 58/65 |
E: hyp leave call site weakened to !marker |
dies - not ok 998 |
No surviving mutant. Round 1's tests pin what they claim.
Finding 3 (informational): finding 2 of #627 is still unimplemented, and still disclosed
Confirmed unchanged: the reverse gap's failure else at
src/core/config/action_reconciler.js:339-365 is not in the diff, and action_attach.js:315
is still the only reverse() in the tree, returning only done or failed. The PR body
still carries its "Finding 2 is deliberately unaddressed" section. Fixes #627 will close
the issue over it, so it needs a fresh issue after merge or it leaves tracking. Not
editing the PR body.
Checks
npm test: 3431 tests, 3430 pass, 0 fail, 1 skipped (unchanged fromd898a88)npm run typecheck: clean- No em dashes, no semicolons introduced.
- Pushed
7b598dftofix/issue-627.
Residual findings
- Finding 1 is unresolved in code and is the blocker. An assetless marker rewritten
fromdoneis dropped over a settings write that is still on disk, by both gates. Three
of the four gate/status cells are inherited frommaster, the fourth (refusedat
hyp leave) is introduced here and is a regression againstmasteron the user-facing
undo command. Fixing it means thefailed/refusedrewrite recording the effect it
overwrites, which is a marker-schema decision for a new LLP extending LLP 0138. - The allowlist still has to be updated for a future state that genuinely records no
effect. Deliberate, tested since round 1, centralised rather than removed. - Finding 2 of Follow-up: deferred review findings from PR #622 #627 stays unimplemented by design, and will be closed unresolved by
Fixes #627unless a fresh issue is filed.
Neutral triage: parked, blocker confirmed by independent reproductionI am the triage rung the fix-loop escalates to after 2 review rounds. Round 2's review My own reproduction (not inherited from round 2)Built the exact fixture independently in two fresh worktrees (
Both runs exit 0 and print the same overall "left https://central.example" success The root cause, confirmed by reading Reachability: independently verified, not theoretical
So: any openclaw attach, or any client attach with failed asset copies, that later The quiet-the-probe alternative: looks viable, flagging for the humanRound 2 floated, without implementing, fixing #627's actual symptom (a noisy no-op
Non-blocking findings, confirmed, not acted on (this PR is parked, not merged)
The decision this needs
I am not implementing any of these: this is the design call CLAUDE.md reserves for a How to unstickReply on this PR thread with the direction to take (1, 2, or 3 above, or something What I did not doI did not merge, mark ready, or push to |
|
Decision on the triage question: option 2, the quiet-the-probe alternative. Stop consulting This never uses marker status as a proxy for what's on disk, so the marker-schema question (the missing "wrote settings" bit) does not need answering here. It needs its own design pass and review as the triage note says - please rework the PR in that direction, including a regression test for the reproduced case (settings-only attach rewritten to refused must still be reversed by |
|
Re-engaging with your guidance - taking option 2, the quiet-the-probe alternative: stop consulting The stuck label is removed. Neutral will rework the PR in that direction, including a regression test for the reproduced case. |
…s quiet (#627) Rework on the maintainer's decision: option 2, the quiet-the-probe alternative. The earlier rounds fixed #627 by teaching leave's assetless-drop gate about `refused`, and review round 2 showed that gate is the bug: a marker that reached `done`, wrote the client's settings and installed no files, then re-`perform()`ed into `failed` or `refused`, is byte-indistinguishable from one that never applied anything, so the gate skipped a settings edit still on disk. So no gate. `hyp leave` hands every attach marker it holds to the one core disk-driven undo, which reads the client's own settings file rather than the marker's status, is idempotent, and answers `changed: false` when there is nothing to reverse. #627's actual complaint, a pointless probe narrating a settings file the user never had, is fixed where it lives: `quietNoop` suppresses the "No HypAware marker found ...; nothing to do." line for callers sweeping a whole set of markers. `hyp detach <client>` still prints it, because there it is the answer to what the user asked. Nothing here reads a marker status as a proxy for what is on disk, so the marker-schema question round 2 opened (nothing carries a prior `done`'s settings write across the rewrite) is untouched and still needs its own design pass. The shared `markerRecordsNoEffect` predicate went with the gate that motivated it: the reconciler's reverse gap is back to the inline condition it has on master, unchanged in behaviour and still governed by LLP 0186. Co-Authored-By: Claude <noreply@anthropic.com>
|
Reworked to your decision: option 2, quiet the probe. Head is now What the rework does1. No status gate on whether the reversal runs ( 2. The symptom is fixed at the reporting layer ( 3. Regression test, with before/after evidence
Run against the pre-rework source ( After the rework: 13/13.
Mutant, to show the second test is load-bearing and not just a passenger: with the So test 10 fails without the gate removal and test 11 fails without the quieting. Checks
Deliberately not answered
Two notes for the reviewer
|
Master gained `quiet` on detachClientViaCore (LLP 0206 #d1: uninstalling the daemon sweeps every client and renders the undo's warning and restoredPaths itself), which collided with this branch's `quietNoop`. The two are orthogonal cuts of the same routine's prose, so both survive: - `quiet` withholds all of the routine's stdout and hands the result back for the caller to render. - `quietNoop` withholds only the "No HypAware marker found ...; nothing to do." line, so `hyp leave`'s sweep does not narrate a settings file the user may never have had (#627), while `hyp detach <client>` keeps it. The call site is now `if (!quiet) writeCoreDetachOutput({ ctx, name, json, quietNoop, result })`: quiet subsumes quietNoop, which is the honest relation between them, and the doc comment says so. central.js merged clean and keeps this PR's rework intact: the status-reading shortcut stays removed, detachClientViaCore runs for every marker leave holds, and leave alone passes `quietNoop: true`. Co-Authored-By: Claude <noreply@anthropic.com>
Neutral triage: ship. Rework verified against the decision, all remaining items non-blocking, tracked in #780.I am the triage rung re-engaging after the maintainer's decision (option 2, quiet the probe) was reported applied. I verified every claim in the Independent verification at head
The two judgement calls1. Unconditional reversal surfaces an unreadable settings file as a counted leave failure. Acceptable, not a blocker. A marker over a genuinely unreadable file used to be dropped silently by the gate; now the failure is counted, the retry hint names the file, and the marker survives as the undo record. That matches what 2. Non-blocking items, tracked in #780
Nothing here blocks the merge. I did not mark ready or merge; that stays with a human or the next rung. |
The symptom
LLP 0186 (PR #622)
added
refusedas a terminal marker state that, likefailed, records noeffect of its own: an attach refusal stops before it touches the client's
settings.
hyp leave's assetless-drop gate read one status name:so an assetless
refusedmarker fell through to the full reversal instead ofbeing dropped the way an assetless
failedone was.This was never data loss:
detachClientFromDiskno-ops ({ changed: false })because a refusal never wrote the client's settings. It was not silent either,
which is the part users see. The pointless disk probe prints
in the middle of
hyp leave, naming a settings file the refusal never wrote andthe user never had.
What ships
Two changes, in the two source files the diff touches.
src/core/commands/central.js(runLeave): the status shortcut is gone.Every marker
hyp leaveholds now goes to the same disk-driven undo, whateverstatus it carries. The undo reads the client's own file rather than the marker's
status, is idempotent, and reports
changed: falsewhen there is nothing toreverse, so running it over a marker that really did record nothing costs one
stat. The call gains a
@refto LLP 0045#part-3-reverse-runs-from-disk-the-marker-is-a-self-describing-undo-record,which is the rule this restores: the marker is an undo record, not a description
of the disk.
src/core/commands/clients.js: a newquietNoopoption ondetachClientViaCore. It suppresses exactly one line, the human-readableNo HypAware marker found ...; nothing to do.in thechanged: falsebranch ofwriteCoreDetachOutput. Nothing else changes, and the--jsonpayload is neveraffected: it already carries
changedfor exactly this distinction.quietNoophas one caller,hyp leave.hyp detach <client>passesnothing and still prints the line, because there it is the answer to what the
user asked.
detachAllClientsFromDiskalready ran under the fullquiet.quiet(the uninstall sweep's full-silence flag, LLP 0206#d1, whicharrived on
masterwhile this branch was open) subsumesquietNoop: theoutput block is reached only when
!quiet. The conflict resolution kept bothflags rather than merging them, since they are different cuts:
quietwithholds every stdout line this routine writes and hands the caller the duty
of rendering warnings and restored paths;
quietNoopwithholds only theno-op line and leaves the rest of the prose in place.
changed: falsefrom a disk-driven undo means "this client's settings holdnothing of ours". That is an answer when the user named the client, and noise
when a sweep is walking every marker it can find.
Known consequence of reversing unconditionally
A marker over a genuinely unreadable settings file used to be dropped silently
by the gate; it now surfaces as a counted leave failure, with the retry hint
naming the file and the marker surviving as the undo record. That is what
masteralready does for adonemarker over the same file, and silentlydestroying an undo record is the shape LLP 0138
#marker-undorefuses.Regression tests
test/core/leave-command.test.js, two new tests, each pinning one half of thefix. Both were mutation-checked at the merged head:
leave reverses a settings-only attach whose marker was later rewritten to refused- the case that decided the direction. Arefusedmarker with noinstalled_assetsover a~/.claude/settings.jsonthat really does carry themanaged block: the settings are reversed,
Detached claudeis printed, andthe marker is cleared. Restoring the old drop gate (extended to
refused, asoriginally proposed) fails exactly this test.
leave stays quiet about a marker whose client has nothing left to reverse- the reported symptom, with adoneclaude marker beside arefusedcodex one. Asserts stdout never mentionsNo HypAware marker foundor
.codex, that no~/.codex/config.tomlis conjured by the probe, that thequiet comes from the reversal finding nothing rather than from a missing
descriptor, and that the
doneneighbour still reverses and still says so.Weakening
quietNoop !== trueback to a bareelsefails exactly this test.One existing test's comments were updated where they described the removed
shortcut ("a failed marker never applied an effect: leave just drops it").
Checks
Verified in a fresh worktree at head
3a7a331:npm test: 4086 tests, 4085 pass, 0 fail, 1 skipped (the count grew withthe master merges).
npm run typecheck: clean.duplicate-numbers).
Finding 2 is deliberately unaddressed
Issue #627 carries two deferred findings. Only the
hyp leavesurface isfixed here. The other one, a
reverse()returningrefusedfalling into thereverse gap's generic failure
elseand being retried forever, is nottouched, and closing #627 on this PR should not be read as having resolved it.
It is kept alive as item 3 of #780.
ActionOutcomeis one type acrossperform()andreverse(), so widening it maderefusedexpressible on the reverse hook,but
action_attach.jsholds the onlyreverse()in the tree and it returnsonly
doneorfailed. Nothing can currently reach that branch.rather than a consistency cleanup. Two settled LLPs point in opposite
directions: LLP 0138
#refusal-is-not-failure(name what you leave behind andrelease the marker) versus client attach: probe-less
contributes.clientcan attach but reverse() silently no-ops, orphaning settings #212 / LLP 0138#marker-undo(never destroy theonly record naming files that are still on disk). A wrong terminal decision
about an undo is more expensive than a retried one, which is why the current
fallback is the safe half of the pair on purpose.
LLP 0186 § Explicitly out of scope,
which already states that a
reverse()genuinely needing to refuse requiresits own branch, its own answer to "what happens to the marker", and its own
request extending LLP 0186.
The related marker-schema question the maintainer deferred (a
donetofailed/refusedrewrite carries assets forward but not the settings write, andthe reconciler's reverse gap keeps master's inherited drop cells) needs its own
LLP extending LLP 0138/0186. Also tracked on #780.
Fixes #627