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 3 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 |
The inconsistency
LLP 0186 (PR #622) added
refusedas a terminal marker state that, likefailed, 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 assetless-drop was not, and still read one status name:So an assetless
refusedmarker fell through to the fulldetachClientViaCorereversal instead of being dropped the way the reverse gap drops it.This is not data loss:
detachClientFromDiskno-ops ({ changed: false }) because a refusal never wrote the client's settings, and the marker is cleared afterwards. It is not silent either, which is what the regression test pins: the pointless disk probe printsin the middle of
hyp leave, naming a settings file the refusal never wrote and the user never had.The fix
Both gates now share one predicate,
markerRecordsNoEffect, placed besidereadInstalledAssetsinsrc/core/config/action_reconciler.jsfor the reason that accessor gives for living there: the markers' droppers are not all handlers, and two gates deciding "nothing to undo" for themselves are two chances to disagree.The predicate deliberately expresses the property, not a status name. This codebase has repeatedly been bitten by keying on a named state as a proxy for a property (
config_missingfor "absent",firstLookRanfor "actually printed"), so the naive|| marker.status === 'refused'the issue sketches is exactly the shape to avoid. What both gates actually ask is "does this marker record any on-disk effect?", and that needs two independent records to say no:failed,refused), andinstalled_assets.Neither half is sufficient alone. "Carries no assets" cannot be the whole test, because a
doneattach that copied no files still owns the settings edit it wrote. And "status is failed/refused" cannot be the whole test, because a marker that wentdoneand was later rewritten carries the earlier attach's file list forward.An unrecognized status counts as recording an effect, so a fifth marker state added later routes to the real reversal rather than being silently dropped by a gate nobody remembered to update. That defaulting is the actual anti-drift property, and it is why this is a shared predicate rather than a second copy of a boolean.
The reconciler's own behaviour is unchanged: its inline condition is replaced by the identical shared call.
Regression test
test/core/leave-command.test.js, two tests:leave drops an assetless refused attach marker the way it drops an assetless failed one— a mixed store (claudedone,codexrefusedassetless,openclawfailedassetless). Asserts stdout never mentionsNo HypAware marker foundor.codex, that no~/.codex/config.tomlis conjured, thatdonestill reverses on disk andfailedis still dropped, and that all three markers are gone.Verified failing on
b56ab21with the source change reverted and the test kept:Passes after the fix. The other 12 tests in the file pass in both states, so the failure isolates the change.
leave still reverses a refused attach marker that carries installed assets— the overshoot guard. Arefusedmarker carryinginstalled_assets(adoneattach that later re-perform()ed into a refusal) must still take the real reversal, never be dropped, or the marker's file list is orphaned (LLP 0138 #marker-undo). Passes before and after, by design.Checks
npm test: 3429 tests, 3428 pass, 0 fail, 1 skippednpm run typecheck: cleanFinding 2 is deliberately unaddressed
Issue #627 carries two deferred findings. Only the
hyp leavegate is fixed here. The other one, areverse()returningrefusedfalling into the reverse gap's generic failureelseand being retried forever, is not touched, and closing #627 on this PR should not be read as having resolved it:ActionOutcomeis one type acrossperform()andreverse(), so widening it maderefusedexpressible on the reverse hook, butaction_attach.jsholds the onlyreverse()in the tree and it returns onlydoneorfailed. Nothing can currently reach that branch.contributes.clientcan attach but reverse() silently no-ops, orphaning settings #212 / LLP 0138 #marker-undo (never destroy the only record naming files that are still on disk). A wrong terminal decision about an undo is far more expensive than a retried one, which is why the current fallback is the safe half of the pair on purpose.reverse()genuinely needing to refuse requires its own branch, its own answer to "what happens to the marker", and its own request extending LLP 0186. That is where it should be settled, when a real reverse-refusal need appears.Fixes #627