Skip to content

hyp leave's assetless-marker drop was never taught about refused markers (#627) - #630

Open
philcunliffe wants to merge 6 commits into
masterfrom
fix/issue-627
Open

hyp leave's assetless-marker drop was never taught about refused markers (#627)#630
philcunliffe wants to merge 6 commits into
masterfrom
fix/issue-627

Conversation

@philcunliffe

@philcunliffe philcunliffe commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Correction (after review rounds 1-2 and the maintainer's decision). The
description below was rewritten to match what actually merges. The original
described a shared-predicate approach that review disproved and that is not
in the tree.

  • What was originally proposed: a shared predicate markerRecordsNoEffect
    in src/core/config/action_reconciler.js, used by both hyp leave's
    assetless drop and the reconciler's reverse gap, extending the existing
    "drop it rather than reverse it" shortcut from failed to refused.
  • What review found (round 2): the predicate reads a marker's status as
    a proxy for what is on disk, and the proxy is unsound. An attach that
    reached done, wrote the client's settings and installed no files, then
    re-perform()ed into failed or refused, has its status rewritten in
    place; nothing carries the settings write forward. That marker is
    byte-indistinguishable from one whose attach never applied anything, so the
    shortcut would skip a settings edit that is still on disk. A live data-loss
    path, and a regression against master.
  • What actually ships: the shortcut is removed, not extended.
    hyp leave never consults marker status to decide whether to reverse.
    Every attach marker it holds goes to detachClientViaCore, which decides
    what to undo from the client's own settings file, is idempotent, and
    returns { changed: false } when there is nothing there. The reported
    symptom is fixed at the reporting layer instead, by a new quietNoop flag.
  • What is not touched: src/core/config/action_reconciler.js and its
    tests are byte-identical to master, and markerRecordsNoEffect exists
    nowhere in the tree. The reconciler's own reverse gap keeps master's
    behaviour, unchanged by this PR.

The symptom

LLP 0186 (PR #622)
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. hyp leave's assetless-drop gate read one status name:

if (!marker || (marker.status === 'failed' && installedAssets.length === 0)) {

so an assetless refused marker fell through to the full reversal instead of
being dropped the way an assetless failed one was.

This was never data loss: detachClientFromDisk no-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

No HypAware marker found in /home/you/.codex/config.toml; nothing to do.

in the middle of hyp leave, naming a settings file the refusal never wrote and
the 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 leave holds now goes to the same disk-driven undo, whatever
status it carries. The undo reads the client's own file rather than the marker's
status, is idempotent, and reports changed: false when there is nothing to
reverse, so running it over a marker that really did record nothing costs one
stat. The call gains a @ref to 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 new quietNoop option on
detachClientViaCore.
It suppresses exactly one line, the human-readable
No HypAware marker found ...; nothing to do. in the changed: false branch of
writeCoreDetachOutput. Nothing else changes, and the --json payload is never
affected: it already carries changed for exactly this distinction.

  • quietNoop has one caller, hyp leave. hyp detach <client> passes
    nothing and still prints the line, because there it is the answer to what the
    user asked. detachAllClientsFromDisk already ran under the full quiet.
  • quiet (the uninstall sweep's full-silence flag, LLP 0206 #d1, which
    arrived on master while this branch was open) subsumes quietNoop: the
    output block is reached only when !quiet. The conflict resolution kept both
    flags rather than merging them, since they are different cuts: quiet
    withholds every stdout line this routine writes and hands the caller the duty
    of rendering warnings and restored paths; quietNoop withholds only the
    no-op line and leaves the rest of the prose in place.

changed: false from a disk-driven undo means "this client's settings hold
nothing 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
master already does for a done marker over the same file, and silently
destroying an undo record is the shape LLP 0138 #marker-undo refuses.

Regression tests

test/core/leave-command.test.js, two new tests, each pinning one half of the
fix. Both were mutation-checked at the merged head:

  1. leave reverses a settings-only attach whose marker was later rewritten to refused - the case that decided the direction. A refused marker with no
    installed_assets over a ~/.claude/settings.json that really does carry the
    managed block: the settings are reversed, Detached claude is printed, and
    the marker is cleared. Restoring the old drop gate (extended to refused, as
    originally proposed) fails exactly this test.

  2. leave stays quiet about a marker whose client has nothing left to reverse - the reported symptom, with a done claude marker beside a
    refused codex one. Asserts stdout never mentions No HypAware marker found
    or .codex, that no ~/.codex/config.toml is conjured by the probe, that the
    quiet comes from the reversal finding nothing rather than from a missing
    descriptor, and that the done neighbour still reverses and still says so.
    Weakening quietNoop !== true back to a bare else fails 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 with
    the master merges).
  • npm run typecheck: clean.
  • CI on this SHA: all checks green (test and typecheck on Node 22 and 24, plus
    duplicate-numbers).

Finding 2 is deliberately unaddressed

Issue #627 carries two deferred findings. Only the hyp leave surface is
fixed here.
The other one, a reverse() returning refused falling into the
reverse gap's generic failure else and being retried forever, is not
touched, and closing #627 on this PR should not be read as having resolved it.
It is kept alive as item 3 of #780.

  • It is dead code today. ActionOutcome is one type across perform() and
    reverse(), so widening it made refused expressible on the reverse hook,
    but action_attach.js holds the only reverse() in the tree and it returns
    only done or failed. Nothing can currently reach that branch.
  • Fixing it would mean settling terminal-undo semantics, a design decision
    rather than a consistency cleanup. Two settled LLPs point in opposite
    directions: LLP 0138 #refusal-is-not-failure (name what you leave behind and
    release the marker) versus client attach: probe-less contributes.client can 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 more expensive than a retried one, which is why the current
    fallback is the safe half of the pair on purpose.
  • It remains recorded as an explicit out-of-scope constraint in
    LLP 0186 § Explicitly out of scope,
    which already states that a 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.

The related marker-schema question the maintainer deferred (a done to
failed/refused rewrite carries assets forward but not the settings write, and
the reconciler's reverse gap keeps master's inherited drop cells) needs its own
LLP extending LLP 0138/0186. Also tracked on #780.

Fixes #627

test and others added 2 commits August 5, 2026 04:23
…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>
@philcunliffe

Copy link
Copy Markdown
Contributor Author

Verdict: approve, with one gap fixed on the branch

The fix is correct, the refactor is genuinely pure, and the predicate does avoid the
proxy trap this repo keeps falling into. One finding was actionable and is fixed in
d898a88: the anti-drift property the PR is built around had no test.

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
leads with.

The status half is still an enumeration of named states. What makes it not a proxy
is that it is an allowlist with a safe default rather than a denylist:
status !== 'failed' && status !== 'refused' -> false. Everything the predicate does
not recognize is treated as effect-bearing. That does not remove the need to update
the predicate when a state is added; it makes forgetting cost a wasted disk probe
(exactly the #627 symptom, harmless) instead of a silent drop over a real effect
(data loss). Inverting that one comparison is the difference between the two, and
until this round nothing tested it. See finding 1.

I verified the "a refusal wrote nothing" premise rather than taking it from LLP 0186,
since that is the load-bearing claim and the exact place a status-as-proxy would
diverge. Both in-tree refusal sites return before any write:

  • hypaware-core/plugins-workspace/openclaw/src/attach.js:159 refuses after
    readOpenclawConfig, ahead of the single atomicWriteFile at line 176. The
    invariant is stated on the read at line 134 ("pure read-then-decide, no partial
    write" / R2).
  • hypaware-core/plugins-workspace/claude/src/settings.js:329 throws
    markActionRefused from inside readSettings, during JSON.parse.

So refused implies "wrote nothing" by an explicit documented invariant, not by
accident. failed is weaker in principle (a perform() that threw after a partial
settings write would be a failed assetless marker recording a real effect), but
openclaw's only fail(..., 'write') sits behind an mtime-guarded atomic write, and
in any case the failed half is unchanged by this PR: both gates already dropped
assetless failed markers on master. Recorded as an observation below, not a
finding.

Byte-identity of the refactor: confirmed

The action_reconciler.js reverse gap on master:

!marker || ((marker.status === 'failed' || marker.status === 'refused') &&
  readInstalledAssets(marker).length === 0)

is exactly markerRecordsNoEffect, term for term. Pure refactor. hyp leave now
evaluates the identical expression, so it matches the reconciler rather than
introducing a third behaviour. installedAssets is still live in central.js (used
by the missing-descriptor branch at :470), so nothing went dead.

Status set verified independently: ActionMarkerStatus is
'done' | 'failed' | 'refused' | 'applied' (src/core/config/types.d.ts:305), while
ActionOutcome.status is only 'done' | 'failed' | 'refused' (:394). applied is
declared but never written as an action marker (the four 'applied' hits in-tree are
ConfigStageResult.action, unrelated). It correctly returns false from the
predicate regardless. A marker with no status at all routes to the real reversal,
which is the right default for a truncated write.

Test strength: verified by mutation, not by report

Reverting src/ and keeping the tests reproduces the claimed failure exactly:

not ok 10 - leave drops an assetless refused attach marker the way it drops an assetless failed one
# tests 13 / pass 12 / fail 1

The overshoot guards genuinely guard. Two mutants against markerRecordsNoEffect:

mutant killed by
drop the asset check (return true) leave-command 11; action-reconciler 10 and 17
drop the status check (assets alone) leave-command 4, 8, 10, 12; action-reconciler 8, 9

Both halves are load-bearing and covered.

Findings

1. (medium, fixed) src/core/config/action_reconciler.js:537 - the defaulting
direction, the one property the shared predicate exists for, was untested.

A third mutant survived the entire suite:

if (marker.status === 'done' || marker.status === 'applied') return false

That is the same predicate with the allowlist turned into a denylist. npm test
stayed at 3429/3428/0. Nothing anywhere pinned which way an unrecognized status
falls, so the property the PR body calls "the actual anti-drift property" could be
inverted by a later edit without a single test noticing - and inverted, it is the
data-loss shape of #627 rather than the harmless one.

Fixed in d898a88, test/core/action-reconciler.test.js:885-957: two unit tests over
the predicate. The first asserts applied, an invented future state, '', and
case-variant spellings all count as recording an effect, plus a marker with no
status field. The second pins the full truth table, including the malformed-store
cases (installed_assets as a string, as an array of junk, as junk-with-one-real-path)
that readInstalledAssets normalizes. Re-running mutant C against the new tests now
gives not ok 18 ... # fail 1.

2. (informational, no action) Fixes #627 closes an issue with finding 2 open.

Confirmed the PR does not implement finding 2: the reverse gap's failure else
(action_reconciler.js:340-356) is untouched by the diff, and action_attach.js is
still the only reverse() in the tree, returning only done or failed. The PR body
states the omission explicitly under its own heading, with the LLP 0186 §Explicitly
out of scope backing. That is the right disclosure; flagging only that the issue will
close carrying an unresolved item, so if it should stay tracked it needs a fresh issue
after merge. Not editing the PR body.

3. (informational) @refs check out.

LLP 0186#how-the-reconciler-distinguishes-it-from-done resolves (§117), and that
section really does settle the reverse-gap rule the annotation claims. Extending it
to hyp leave is grounded separately by LLP 0138#marker-undo (§92 {#marker-undo}
names hyp detach and hyp leave as droppers that read the field through one
accessor), so the pairing on the new predicate is honest. No LLP file is edited; the
diff is three files. Minor nit, not worth a commit: {@link markerRecordsNoEffect}
inside // line comments at central.js:435 renders nowhere, since only the /** */
occurrence is JSDoc.

Checks

  • npm test: 3431 tests, 3430 pass, 0 fail, 1 skipped (was 3429/3428/0/1 at
    aa31fc7; +2 from this round)
  • npm run typecheck: clean
  • No em dashes, no semicolons introduced.

Residual findings

  • failed is a weaker guarantee than refused. refused is backed by an
    explicit no-partial-write invariant (openclaw R2, claude's read-path throw);
    failed is not - it means "perform() threw", which in principle can happen after
    a settings write, and such a marker would be assetless and get dropped. Unchanged
    by this PR (both gates already did this on master) and not reachable through any
    in-tree adapter today. Worth a real fix only if an adapter ever grows a non-atomic
    settings write.
  • The allowlist still has to be updated. A future state that genuinely records no
    effect will be routed to a pointless disk reversal until someone edits the
    predicate. That is deliberate and now tested, but it is a maintenance obligation
    moved to one place, not removed.
  • Finding 2 of Follow-up: deferred review findings from PR #622 #627 stays unimplemented by design (see finding 2 above).

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>
@philcunliffe

Copy link
Copy Markdown
Contributor Author

Verdict: do not merge as-is. Round 1's residual 1 resolves to a live data-loss path, and hyp leave regresses against master because of it.

Round 1 verified the refactor's byte-identity and killed the defaulting mutant. Round 2
was asked to settle the one thing it left open: whether a marker can be dropped over a
real on-disk effect. It can, it is reachable with in-tree adapters, and this PR moves
hyp leave from the side that cleans it up to the side that does not.

Residual 1, resolved: not a partial write, a carried write

I audited every in-tree perform() for a throw following a partial on-disk write, and
that specific shape does not exist. All three registered clients funnel their entire
settings edit through one atomicWriteFile at the end of a pure read-then-decide:

  • hypaware-core/plugins-workspace/claude/src/settings.js:120-263 mutates only the
    in-memory value, single writeAtomic at :252, nothing after it but result
    construction.
  • hypaware-core/plugins-workspace/codex/src/settings.js:28-43 read, prepare, write.
  • hypaware-core/plugins-workspace/openclaw/src/attach.js:104-204 single
    atomicWriteFile at :178, every refusal returns before it (the documented R2).
  • src/core/util/fs_atomic.js:85-111 is temp-then-rename with renamed tracking, so a
    throw anywhere before the rename leaves the target byte-identical, and nothing throws
    after it.

So round 1's stated worry is closed. But the audit surfaced a different and worse
route to the same drop, which the marker rewrite creates rather than the handler:

action_reconciler.js:238-259 (the failed branch) and :213-224 (the refused
branch) carry installed_assets forward across a rewrite, and nothing carries the
settings write forward
. A marker that reached done (settings written) and is later
re-perform()ed into failed/refused is byte-indistinguishable from one whose first
perform() applied nothing, if that attach installed no assets. markerRecordsNoEffect
then answers true over a real effect, and both gates drop it.

That "if" is not exotic. It is the normal case for one shipped client and a designed-for
case for the others:

  • openclaw contributes no skills and no subagents at all. Every in-tree
    ctx.skills.register / ctx.agents.register targets clients: ['claude']
    (claude/src/index.js:276,285) or ['codex'] (codex/src/index.js:241), and
    expandAssetClients (src/core/runtime/client_assets.js:312) only widens on 'all'.
    So every successful openclaw attach writes models.providers into openclaw.json
    and records a done marker with no installed_assets.
  • materializeAttachedAssets (action_attach.js:514-545) returns [] on any copy
    failure by design ("a copy failure must not churn the marker to failed"), and
    planClientAssets continues past each unusable asset. A claude/codex attach whose
    copies all failed lands in the same state.
  • Any pre-LLP-0138 marker, which action_attach.js:303 already documents as existing.

And the re-perform that rewrites it is routine: isCurrent returns false on every
gateway rebind to a new ephemeral port (LLP 0086) and on any asset-set change (LLP 0107).
The re-perform then refuses (openclaw ownership conflict after a user hand-edit;
claude markActionRefused on JSONC, claude/src/settings.js:329) or fails (unreadable
or concurrently-edited settings file).

Reproduced against the real reconciler, not reasoned:

marker after drift re-perform: {"status":"refused","request_key":"claude",
  "reason":"settings.json is JSONC","at":"..."}
markerRecordsNoEffect(marker) = true
reverseCalls = 0
markers left = undefined
client settings still on disk = true

The same probe with failed instead of refused behaves identically.

Finding 1 (blocker): hyp leave now strands a client's settings where master cleaned them

src/core/commands/central.js:451. Same fixture in both trees: ~/.claude/settings.json
holding a real _hypaware block and env.ANTHROPIC_BASE_URL, plus an assetless
refused marker (the drift-rewrite above). Then hyp leave.

_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 from d898a88)
  • npm run typecheck: clean
  • No em dashes, no semicolons introduced.
  • Pushed 7b598df to fix/issue-627.

Residual findings

  • Finding 1 is unresolved in code and is the blocker. An assetless marker rewritten
    from done is dropped over a settings write that is still on disk, by both gates. Three
    of the four gate/status cells are inherited from master, the fourth (refused at
    hyp leave) is introduced here and is a regression against master on the user-facing
    undo command. Fixing it means the failed/refused rewrite 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 #627 unless a fresh issue is filed.

@philcunliffe philcunliffe added the neutral:stuck neutral attempted this but cannot complete it autonomously — needs a human label Aug 5, 2026
@philcunliffe

Copy link
Copy Markdown
Contributor Author

Neutral triage: parked, blocker confirmed by independent reproduction

I am the triage rung the fix-loop escalates to after 2 review rounds. Round 2's review
(comment on this PR, d898a88... findings) reported a blocker: hyp leave's reverse
gate, migrated by this PR to the shared markerRecordsNoEffect predicate, drops an
assetless failed/refused marker even when that marker was rewritten from a done
state whose settings write is still on disk. My job was to verify that claim myself
before acting on it, since parking a correct PR is also a cost. I reproduced it. The
blocker is real.

My own reproduction (not inherited from round 2)

Built the exact fixture independently in two fresh worktrees (origin/fix/issue-627
at 7b598df and origin/master at b56ab21): a real ~/.claude/settings.json
carrying _hypaware and env.ANTHROPIC_BASE_URL, plus a client-actions.json attach
marker for claude with status: 'refused' and no installed_assets (simulating
a done attach, whose only effect was the settings write, later re-perform()ed into
a refusal). Then ran hyp leave against each tree, script at
/tmp/claude-1001/-work-hypaware/3832ec93-3811-4502-bd86-c3a74a70d863/scratchpad/repro.mjs.

_hypaware after leave ANTHROPIC_BASE_URL after leave leave mentions claude?
origin/master (b56ab21) removed removed yes: ✓ Detached claude (...); Removed http://127.0.0.1:4388
this PR (7b598df) still present still http://127.0.0.1:4388 no

Both runs exit 0 and print the same overall "left https://central.example" success
banner. On the PR head, the marker is silently deleted
(markers.attach -> undefined) and nothing on stdout names claude or the settings
file at all. This is src/core/commands/central.js:451
(if (markerRecordsNoEffect(marker)) { ... clearClientActionMarker(...); continue }),
compared with master's gate at the same call site,
!marker || (marker.status === 'failed' && installedAssets.length === 0), which lets
a refused marker fall through to detachClientViaCore and get properly reversed.

The root cause, confirmed by reading src/core/config/action_reconciler.js:180-260:
the done -> failed/refused marker rewrite carries installed_assets forward
(:184, :224) but carries no bit at all for "this marker's handler previously wrote
the client's settings." markerRecordsNoEffect (:548-552) can only see the two
records it's given, so it returns true over a real, undocumented-by-the-marker
effect. This is the exact "status as proxy for disk state" anti-pattern round 1's
own review named and the PR body promises to avoid, reappearing across the rewrite
seam rather than in the predicate itself.

Reachability: independently verified, not theoretical

  • hypaware-core/plugins-workspace/openclaw/src/index.js has no ctx.skills.register
    or ctx.agents.register call at all (confirmed by grep across all three client
    plugins: only claude/src/index.js:276,285 and codex/src/index.js:241 register
    assets, targeting clients: ['claude'] and ['codex'] respectively, never
    'openclaw' or 'all'). So every successful openclaw attach is a done marker
    with zero installed_assets, and its attach()
    (hypaware-core/plugins-workspace/openclaw/src/attach.js:104-204) writes
    models.providers via a single atomicWriteFile unconditionally on success.
  • materializeAttachedAssets (src/core/config/action_attach.js:514-545) returns
    [] on any copy failure "by design" per its own doc comment ("a copy failure is a
    degraded install ... rather than an attach to redo"), so a claude/codex attach with
    fully-failed copies is the same shape.
  • The re-perform that triggers the rewrite is routine, not contrived:
    markerIsCurrent/handler.isCurrent (action_reconciler.js:409-416) documents the
    LLP 0086 ephemeral-port-rebind drift path, and openclaw/src/attach.js:73,225
    cross-references the same isCurrent() re-attach-on-drift behavior by name.

So: any openclaw attach, or any client attach with failed asset copies, that later
drifts (routine port rebind) and re-perform()s into a refusal or failure, hits this
path. Confirmed, not hypothetical.

The quiet-the-probe alternative: looks viable, flagging for the human

Round 2 floated, without implementing, fixing #627's actual symptom (a noisy no-op
disk probe) by always running the real disk-driven reversal and making it quiet when
it finds nothing, rather than skipping it based on marker status. I checked the
shape of that fix and it looks structurally sound:

  • detachClientFromDisk (src/core/config/client_detach_disk.js) is already
    idempotent and already returns { changed: false } at multiple guard points when
    there is nothing on disk to reverse (absent settings file at :152/:766/:1103,
    no marker found at :172, no probe at :128).
  • The noisy line 627 complains about (No HypAware marker found ...; nothing to do.)
    lives at the CLI-reporting layer, src/core/commands/clients.js:1280, not inside
    the disk-driven reversal itself.
  • So the fix would be: stop consulting markerRecordsNoEffect to decide whether to
    call the reversal at all, always call it, and suppress/soften that one reporting
    line (or gate it on changed) when the probe legitimately finds nothing. That
    never uses marker status as a proxy for what's on disk, which sidesteps the whole
    marker-schema question. I did not implement or test this; it needs its own design
    pass and its own review, but nothing I found rules it out.

Non-blocking findings, confirmed, not acted on (this PR is parked, not merged)

  • The allowlist still needs updating for a future genuinely-no-effect state.
    markerRecordsNoEffect (action_reconciler.js:548) is deliberate and now tested
    (round 1 pinned the defaulting direction), but it is a maintenance obligation moved
    to one place, not removed. Not a blocker on its own.
  • Fixes #627 will close Follow-up: deferred review findings from PR #622 #627 carrying finding 2 unresolved. Finding 2 (a
    reverse() returning refused falling into the reverse gap's generic failure
    else, action_reconciler.js:339-365) is genuinely dead code today: I confirmed
    action_attach.js:315 (async reverse(requestKey, ctx, marker)) is still the only
    reverse() in the tree and only ever returns status: 'done' or status: 'failed'
    (grep across src/core/config/action_attach.js, no refused return anywhere in
    it). The PR body discloses this itself. If it should stay tracked, it needs a fresh
    issue; I am not filing one while this PR is parked, since the eventual fix (or
    re-scope) may change what that issue should say.

The decision this needs

  1. Fix the marker schema: teach the done -> failed/refused rewrite to carry
    forward whether the prior state wrote the client's settings (not just its asset
    list), so markerRecordsNoEffect can see it. This is a marker-schema decision per
    CLAUDE.md's LLP rules and needs a new LLP extending LLP 0138, not a review-round
    edit.
  2. Take the quiet-the-probe alternative instead: always run the real reversal,
    quiet the reporting when it finds nothing. Sidesteps the marker-schema question
    entirely; looks viable per the check above but is unimplemented and unreviewed.
  3. Close this PR and re-scope Follow-up: deferred review findings from PR #622 #627 if neither direction is wanted right now.

I am not implementing any of these: this is the design call CLAUDE.md reserves for a
new LLP or an explicit human steer, not something a review round should decide by
editing.

How to unstick

Reply on this PR thread with the direction to take (1, 2, or 3 above, or something
else). Neutral monitors this thread and re-engages on the next tick.

What I did not do

I did not merge, mark ready, or push to master. I made no code changes; the
worktrees I used for reproduction were temporary and are removed. npm test on the
PR head (7b598df, real run, not the truncated no-node_modules subset) still
reports 3431 tests, 3430 pass, 0 fail, 1 skipped, matching round 2's report.

@philcunliffe

Copy link
Copy Markdown
Contributor Author

Decision on the triage question: option 2, the quiet-the-probe alternative.

Stop consulting markerRecordsNoEffect to decide whether to run the reversal at all. Always call the real disk-driven reversal (detachClientFromDisk is already idempotent and returns { changed: false } when there is nothing to reverse), and fix #627's actual symptom at the reporting layer: gate or soften the "No HypAware marker found ...; nothing to do." line (src/core/commands/clients.js:1280) on changed, so a legitimately-empty probe is quiet.

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 hyp leave).

@philcunliffe philcunliffe removed the neutral:stuck neutral attempted this but cannot complete it autonomously — needs a human label Aug 14, 2026
@philcunliffe

Copy link
Copy Markdown
Contributor Author

Re-engaging with your guidance - taking option 2, the quiet-the-probe alternative: stop consulting markerRecordsNoEffect to decide whether to run the reversal, always call the real disk-driven reversal (detachClientFromDisk is idempotent and returns { changed: false } when there is nothing to reverse), and fix the symptom at the reporting layer by gating the "No HypAware marker found" line at src/core/commands/clients.js:1280 on changed. Noted that this never uses marker status as a proxy for disk, so the marker-schema question is deliberately left to its own design pass.

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>
@philcunliffe

Copy link
Copy Markdown
Contributor Author

Reworked to your decision: option 2, quiet the probe. Head is now eb9eb5d.

What the rework does

1. No status gate on whether the reversal runs (src/core/commands/central.js:433-449).
hyp leave hands every attach marker it holds to detachClientViaCore, whatever
status the marker carries. The shortcut that used to sit at the top of that loop is
gone, comment and all, replaced by a note on why there is no shortcut: the undo reads
the client's own settings file, is idempotent, and costs one stat when there is
genuinely nothing to reverse. Nothing in the leave path reads a marker status as a
proxy for what is on disk any more.

2. The symptom is fixed at the reporting layer (src/core/commands/clients.js:1287).
detachClientViaCore takes a quietNoop option, threaded into
writeCoreDetachOutput, which suppresses only the human
No HypAware marker found ...; nothing to do. line when changed is false.
hyp leave passes it (central.js:491) because it sweeps every marker on disk;
hyp detach <client> does not, because there that line is the whole answer to what
the user asked, and both test/core/client-name-case-insensitive.test.js:127 and the
client_attach_idempotent smoke pin it. The --json payload is untouched either way:
it already carries changed for exactly this distinction.

3. markerRecordsNoEffect is gone with the gate that motivated it.
src/core/config/action_reconciler.js and test/core/action-reconciler.test.js are
byte-identical to the merge base again, so the reconciler's reverse gap keeps the
inline condition it has on master, unchanged in behaviour and still governed by
LLP 0186. The predicate existed to be shared by two gates; with leave's gate
removed it would have been a single-caller extraction whose doc and whose unit tests
both describe a sharing property that no longer exists. Round 1's defaulting tests go
with it for the same reason: they pin a helper this version of the PR does not
introduce. The diff against the merge base is now three files.

Regression test, with before/after evidence

test/core/leave-command.test.js, two tests, one per half of the fix.

leave reverses a settings-only attach whose marker was later rewritten to refused
is the case you asked for: ~/.claude/settings.json carrying a real _hypaware
managed block and env.ANTHROPIC_BASE_URL, plus a refused marker with no
installed_assets. It asserts the managed block and the env are reversed, that leave
says Detached claude, and that the marker is cleared.

Run against the pre-rework source (7b598df's src/, new tests kept):

ok  9 - leave removes the assets its attach marker records, and leaves manual copies alone
not ok 10 - leave reverses a settings-only attach whose marker was later rewritten to refused
ok 11 - leave stays quiet about a marker whose client has nothing left to reverse
# tests 13 / pass 12 / fail 1

After the rework: 13/13.

leave stays quiet about a marker whose client has nothing left to reverse pins
#627's own symptom: a done claude plus an assetless refused codex, no
~/.codex/config.toml anywhere. It asserts stdout never says No HypAware marker found, never names .codex, and never says 'codex' plugin not installed (so the
quiet has to come from the reversal finding nothing, not from codex having no
descriptor to reverse through), that the probe conjures no config.toml, and that
claude still reverses and still reports.

Mutant, to show the second test is load-bearing and not just a passenger: with the
gate removed but } else if (quietNoop !== true) { put back to } else {:

ok 10 - leave reverses a settings-only attach whose marker was later rewritten to refused
not ok 11 - leave stays quiet about a marker whose client has nothing left to reverse
# tests 13 / pass 12 / fail 1

So test 10 fails without the gate removal and test 11 fails without the quieting.
Neither passes for free.

Checks

  • npm test: 3429 tests, 3428 pass, 0 fail, 1 skipped. Baseline on 7b598df in
    the same worktree was 3431/3430/0/1; the delta is exactly the two removed
    markerRecordsNoEffect unit tests (the leave file swaps two tests for two).
  • npm run typecheck: clean, as it was on the baseline.
  • No em dashes, no semicolons introduced.
  • hyp smoke client_attach_idempotent fails identically before and after in this
    environment (it cannot enable the bundled claude adapter here), so it is
    environmental, not a regression. The line it asserts on is the hyp detach one,
    which this change deliberately leaves alone.

Deliberately not answered

  • The marker-schema question. Nothing here teaches the done to failed/refused
    rewrite to record the settings write it overwrites. Per your steer, that stays a
    design pass of its own extending LLP 0138; this rework simply never asks the marker
    what is on disk.
  • The reconciler's reverse gap still drops an assetless failed/refused marker
    for a key the config stops naming, which is the same three inherited cells round 2
    tabulated and what LLP 0186 settles. Untouched here, and now literally unmodified.
  • Finding 2 of Follow-up: deferred review findings from PR #622 #627 (a reverse() returning refused falling into the reverse
    gap's generic failure else) is still unimplemented and still dead code today.
    Fixes #627 will close the issue over it, so it wants a fresh issue after merge.

Two notes for the reviewer

  • The PR body above is stale. It still describes the shared-predicate approach
    this rework replaces. I did not edit it; the commit message on eb9eb5d carries the
    current description.
  • One behaviour change worth naming. Because leave now reverses unconditionally, a
    marker whose client settings file is genuinely unreadable (the JSONC file that
    caused the refusal in the first place) now surfaces as a counted leave failure with
    the retry hint, where the old gate dropped such a marker silently. That is the same
    thing leave already did on master for a done marker over the same file, and it
    names the file that needs fixing, so I took it as the honest outcome rather than
    special-casing it back to silence.

test and others added 2 commits August 14, 2026 20:44
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>
@philcunliffe

Copy link
Copy Markdown
Contributor Author

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 neutral-ack-applied report against the committed tree at head 3a7a3312ce114752ee4361a4a7a450ff0a4243b5 in my own fresh worktree, taking nothing on trust.

Independent verification at head

  • The decision is implemented as specified. runLeave no longer consults any marker status to decide whether to reverse: the shortcut is gone and every attach marker goes to detachClientViaCore (src/core/commands/central.js:496-553). The symptom is fixed at the reporting layer: writeCoreDetachOutput gates the No HypAware marker found ...; nothing to do. line on quietNoop !== true in the changed: false branch only (src/core/commands/clients.js:1311). The --json payload is untouched.
  • markerRecordsNoEffect is absent tree-wide. Grep over all js/ts/md at head: zero hits. src/core/config/action_reconciler.js and test/core/action-reconciler.test.js are byte-identical to origin/master, so the reconciler's reverse gap is literally unmodified.
  • quietNoop has exactly one caller: hyp leave at src/core/commands/central.js:553. hyp detach <client> passes nothing (clients.js:128), so it still reports, and detachAllClientsFromDisk (clients.js:1235) already ran under full quiet. After the master merge, quiet correctly subsumes quietNoop (clients.js:1097: output is written only when !quiet).
  • Both regression tests exist and pass, and I re-ran the mutation checks myself at the merged head rather than trusting the report. Restoring the old drop gate (extended to refused) fails exactly leave reverses a settings-only attach whose marker was later rewritten to refused; weakening quietNoop !== true back to a bare else fails exactly leave stays quiet about a marker whose client has nothing left to reverse. Each test pins its half of the fix; neither passes for free.
  • Suite and checks: npm test at head is 4086 tests, 4085 pass, 0 fail, 1 skipped (the count grew with master; the merge brought it current). npm run typecheck clean. CI on this exact SHA: all checks green (test 22/24, typecheck 22/24, duplicate-numbers).
  • The merge commits bring the branch fully up to origin/master (merge base equals master's tip), and the whole diff against master is three files: central.js, clients.js, test/core/leave-command.test.js.

The two judgement calls

1. 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 master already does for a done marker over the same file, and silent destruction of an undo record is exactly the shape LLP 0138#marker-undo refuses. The honest outcome, as the report argued.

2. hyp detach all narrating every unattached client. Correctly out of scope. Pre-existing master behaviour at a call site this PR does not touch, and the decision covered leave's sweep only. For hyp detach <client> that line is the whole answer; for all it is a preference call, recorded in #780 item 4 with the now-available quietNoop mechanism named, for whoever wants it.

Non-blocking items, tracked in #780

  • Carried from Follow-up: deferred review findings from PR #622 #627: the still-unaddressed reverse()-returning-refused item (generic failure else, retry forever; dead code today, action_attach.js:316 is the only reverse() in the tree). Fixes #627 closes that issue on merge, so Follow-up: deferred review findings from PR #630 #780 keeps it alive.
  • The marker-schema question the maintainer explicitly deferred: the done to failed/refused rewrite carries assets but not the settings write, and the reconciler's own reverse gap keeps master's inherited drop cells. Needs its own LLP extending LLP 0138/0186.
  • The PR body is stale: it still describes the abandoned shared-predicate approach. Preference, but worth rewriting before merge; commit eb9eb5d's message and the ack-applied comment carry the accurate description.

Nothing here blocks the merge. I did not mark ready or merge; that stays with a human or the next rung.

@philcunliffe philcunliffe added the neutral:approved neutral reviewed this and holds it for a maintainer merge (own or adopted PR; LLP 0025/0030) label Aug 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

neutral:approved neutral reviewed this and holds it for a maintainer merge (own or adopted PR; LLP 0025/0030)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Follow-up: deferred review findings from PR #622

1 participant