Uh oh!
There was an error while loading. Please reload this page.
A terminal marker rewrite records the effect it overwrites (#780 item 2) - #799
Conversation
The reconciler's reverse gap dropped an assetless failed/refused marker for a request key the config stops naming, reading "no installed_assets" as "this key never applied anything". That is only true for the half of an attach that copies files. An attach that reached `done`, wrote the client's settings and copied nothing (openclaw, routinely), then re-performed into failed/refused, produced a marker indistinguishable from one whose attach never touched the disk - and the drop stranded the settings write with nothing naming it. Record a `prior_done` bit on the rewrite and read it in the drop condition, so such a marker is handed to reverse() instead. Item 2 of #780, deferred from PR #630's review. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
PR #797 (`fix/absolute-form-third-front-door`) claimed 0247 for `llp/0247-absolute-form-third-front-door.decision.md` about a minute before this branch's commit, so both PRs introduce an LLP 0247 and both merge cleanly (different filenames). The result on master would be two documents claiming one number, an ambiguous `@ref LLP 0247#the-bit`, and a red `test/core/llp-ref-hygiene.test.js` ('no LLP number is claimed by two documents'). LLP 0156#renumber settles this: the later claimant moves above the highest number claimed anywhere. 0245-0249 are all claimed on unmerged branches, so this doc takes 0250. Mechanical rename plus the reference sweep in the same commit (the doc title, the two `Extended-by:` forward-refs on LLP 0138/0186, four `@ref`s and two prose mentions in `action_reconciler.js`, two in `types.d.ts`, and the test file's `@ref` and two test names). Nothing the document decided changes.
philcunliffe
commented
Aug 17, 2026
Neutral review round: PR #799 ( |
| claimant | commit adding the doc | PR created |
|---|---|---|
| #797 | 4120e77f 2026-08-17T20:45:18Z | 2026-08-17T20:45:42Z |
| #799 | 30d33e3c 2026-08-17T20:46:40Z | (this PR) |
The body's "No duplicate LLP numbers ... 0247, the next number free across every branch" was true when written and is no longer. The two files have different names, so git merges both without conflict and neither branch's CI catches it. On master the result is two documents claiming one number (making @ref LLP 0247#the-bit formally ambiguous) and a red test/core/llp-ref-hygiene.test.js:458 (no LLP number is claimed by two documents) - the exact invariant LLP 0156 unskipped that test to enforce.
Fixed per LLP 0156 #renumber ("the later claimant moves to a fresh number above the highest number claimed anywhere, including branches without an open PR"). 0245-0249 are all claimed on unmerged branches, so this document is now LLP 0250, with the reference sweep in the same commit:
git mvtollp/0250-marker-records-the-effect-it-overwrites.decision.mdplus its# LLP NNNN:title- the
Extended-by:forward-refs onllp/0138-...:9andllp/0186-...:8 - four
@refs and two prose mentions insrc/core/config/action_reconciler.js(235, 274, 335, 339, 542, 549) - two prose mentions in
src/core/config/types.d.ts(373, 374) - the
@refand both test names intest/core/action-reconciler.test.js(889, 891, 964)
Nothing the document decided changes; this is the mechanical renumbering CLAUDE.md explicitly permits on an Accepted doc. Verified on the pushed branch: git grep 0247 over src/, test/, llp/ returns nothing, all four @refs read LLP 0250, npm test 4219 tests / 4218 pass / 0 fail / 1 skipped, npm run typecheck clean, npm run smoke -- client_attach_on_join ok.
Residual race, for a human:#797 still claims 0247 for its own doc, and additionally double-claims 0246 with origin/integration/proxy-mode-default-attach, so it likely needs a sweep of its own. Whoever handles #797 should take 0251+ and leave 0250 here, or flip this one back and move #799 higher instead. Either resolution is fine; two 0247s is not.
2. medium - rearmRefusedActionMarker() still drops a marker carrying prior_done (recorded, deliberately not fixed)
src/core/config/action_reconciler.js:660-661. The re-arm decides by installed_assets alone, so an assetless refused marker carrying prior_done: true is deleted outright. A second reviewer reproduced the full chain end to end on this branch:
- org attach openclaw ->
done(settings written, no assets) isCurrent()drift ->refused+prior_done: true- user runs
hyp attach openclaw; it succeeds and the re-arm deletes the marker - next reconcile pass re-
perform()s and fails transiently ->failedwith noprior_done(itsexistingwasundefined) - org drops openclaw -> the reverse gap drops that marker without calling
reverse()(reverseCalls: [],marker left: null)
So the new invariant does not survive an explicit hyp attach, and LLP 0250's Consequences bullet ("rearmRefusedActionMarker() is unchanged ... No effect is stranded") rests on a claim the chain above falsifies: it assumes the next pass always writes a done marker back, and a failing pass does not.
I did not fix it, and I think the decision is defensible on a reason the document states only in passing. The re-arm runs only after await client.attach(...) resolves (clients.js:451, call at :484), so at step 3 the settings have just been rewritten by the user's own explicit attach. A hand-attached client records no marker at all by design (clients.js:500; central.js's "a client the user attached by hand stays"), and hyp detach openclaw reverses settings from the client's own file with no marker needed. That is exactly why the with-assets branch has to preserve the record and this one does not: nothing but the marker names org-installed asset paths, whereas the settings name themselves. Nothing is stranded in the "no longer named on disk" sense that #212 and LLP 0138 #marker-undo are about; what is lost is the reconciler's automatic undo, for a client the user just explicitly attached by hand.
And the obvious fix is not free: preserving the bit would make an org config-drop silently undo a user's explicit hyp attach, which contradicts the documented "a client the user attached by hand stays" rule. That is a product tradeoff, not a bug fix, so overriding a decision this PR settled one commit ago is not a neutral reviewer's call.
What I would ask the author for: replace the "No effect is stranded" bullet's reasoning with the disk-names-itself argument above, since the current wording is falsifiable as written. That is an editorial correction to an unmerged doc, not a change to what it decided.
3. low - markerRecordsPriorDone() does not know the applied marker status (recorded, not fixed)
src/core/config/action_reconciler.js:554 recognises only status === 'done', but ActionMarkerStatus is 'done' | 'failed' | 'refused' | 'applied' (types.d.ts:305), with applied documented at :301 as "current applied state of a reconciled/reversible handler". An applied -> failed rewrite would record no prior_done and the reverse gap would drop it, which is precisely the bug this PR fixes.
Not fixed, because the gap is wider than the accessor and papering over one cell would misrepresent it: nothing anywhere writes status: 'applied' to a marker (git grep "'applied'" over src/, bin/, hypaware-core/ finds only apply.js's unrelated ConfigStageResult.action), and the rest of the reconciler does not know the state either - the forward gap's short-circuit at :155 tests status === 'done', so an applied marker would re-perform() on every pass, and the reverse gap at :343 would send it to reverse() unconditionally. Either applied should be dropped from the union or the reconciler should learn it end to end; a one-token disjunct here would create a false impression that it is supported. Worth an issue.
4. info - PR body staleness after the renumber
The body's "this doc took 0247" and "No duplicate LLP numbers" no longer describe the branch; the document is LLP 0250. Not edited, per this round's rules.
What I scrutinised and cleared
prior_done is set on every path that rewrites a marker to a terminal state. The forward gap is the only writer of a terminal marker, and both terminal branches set it: :237 (refused) and :276 (failed). The bit is carried, not recomputed, so arbitrary chains hold: done -> failed reads existing.status === 'done'; failed -> failed and failed -> refused read existing.prior_done === true. done -> refused is a sink, because a refused marker short-circuits unconditionally at :151. The only other in-tree marker writers are clearClientActionMarker (a drop after a completed reversal) and rearmRefusedActionMarker, whose asset branch spreads ...marker and carries the bit through (:664). No other module writes the store.
A handler's detail cannot clobber it.marker.prior_done = true is a statement after the object literal in both branches, so it wins over ...(outcome.detail ?? {}). Note the converse is open, and deliberately so: ActionOutcome.detail is a JsonObject (types.d.ts:416), so a handler returning { detail: { prior_done: true } } on a first-pass failure would pin that key's marker permanently un-droppable. No handler does this and the failure direction is the conservative one, so a guard would cost more than the hazard.
The absent case is master's behaviour exactly.markerRecordsPriorDone returns false for undefined, for a missing field, and for any non-true value (a hand-edited "yes" included). In the reverse gap the new clause is &&-ed onto a condition already gated on status === 'failed' || 'refused', so on a pre-upgrade store the gate evaluates identically; the status === 'done' arm of the accessor is dead in that position and live only at the two write sites. Empirically confirmed: the 17 pre-existing tests, including the LLP 0186 reverse-gap control, pass unchanged, and the test diff is 131 insertions(+), 0 deletions(-) - the control was not weakened.
Nothing reverses that should not. The only new reversal is a terminal marker that really did reach done, and reverse() is idempotent by construction: action_attach.js:316 runs detach({descriptor, env}) off the client's own settings file and returns done even when there is nothing left to undo, so re-reversing an effect a human already removed by hand drops the marker cleanly. The one genuinely new liability is the retained-forever case for a client whose descriptor is gone or that declares no attachProbe (action_attach.js:322-331 fails deterministically): such a marker is now kept and error-logged every pass instead of dropped once. LLP 0250 accepts that trade explicitly, and it is the same trade LLP 0138 #marker-undo already accepted for the asset half. Worth knowing: hyp status renders a field whitelist (status.js:271-282 JSON, :510-525 text), so prior_done surfaces nowhere - which is what the doc claims and I verified, but it also means an operator seeing a permanently-retained openclaw failed marker gets no hint why it is no longer dropped.
Backfill is unaffected.action_backfill.js declares no reverse, so the reverse gap is skipped for that kind entirely; a prior_done written onto a backfill marker is inert.
The LLP 0138 / 0186 edits rewrite nothing. The full diff of both files is one line each, and both are Extended-by: forward-refs (0138's an append onto its existing LLP 0219 (...) entry, 0186 gaining the header line it lacked). Both docs are Status: Active and neither body byte changes.
The src/ diff is surgical: one deleted line in the whole tree, the old drop condition's readInstalledAssets(marker).length === 0).
Style. No em dash anywhere in the diff (checked U+2014, and U+2013/U+2018/U+2019/U+201C/U+201D/U+2192); no trailing semicolons in added JS; the new field is an interface member in types.d.ts with JSDoc, not a @typedef; no inline import('...') types introduced.
Suite baseline note. In a fresh npm install --prefer-offline of a clean worktree of this branch, npm test was fully green (4219 / 4218 pass / 0 fail / 1 skipped) - the ~20 pre-existing query-pushdown failures the body reports did not reproduce for me, though they did for the second reviewer running a different glob against the shared checkout. Either way nothing here is attributable to this PR.
The central.js divergence: safe, leave it
src/core/commands/central.js:497 keeps the sibling shortcut if (!marker || (marker.status === 'failed' && installedAssets.length === 0)), dropping a marker without reversing on the same unsound reading of the evidence (and omitting refused, which happens to fall the safe way). Leaving it is correct here:
- It is not a regression.
central.json this branch is byte-identical tomaster, sohyp leavebehaves exactly as it does today. This PR takes the number of unsound gates from two to one; it creates no new wrong outcome. - hyp leave's assetless-marker drop was never taught about refused markers (#627) #630 deletes it, with this exact rationale. I read that diff: PR hyp leave's assetless-marker drop was never taught about refused markers (#627) #630 (open,
MERGEABLE) removes the wholeifblock atcentral.js:497-508and replaces it with a comment naming the same defect ("a marker that reacheddone, wrote the client's settings and installed no files ... is indistinguishable from one that never applied anything"), routing every marker todetachClientViaCorewithquietNoop: true. Teaching that block aboutprior_donehere would collide line-for-line with that removal and send a mergeable, green, reviewed PR back through triage. - The two fixes do not interact. After both land, the reconciler still drops only markers that genuinely recorded nothing, which is what LLP 0186 wanted from that gate.
The cost of the window is worth recording: until #630 lands the fix is half-deployed, and hyp leave and a config-drop reconcile disagree about the same marker (leave drops it, the reconciler reverses it). Now that markerRecordsPriorDone() is exported it is a one-clause change if #630 stalls.
Items 1 (a reverse() returning refused) and 4 (hyp detach all narration) of #780 are correctly out of scope, for reasons I agree with: item 1 needs terminal-undo semantics settled and has no caller to test against, and item 4's mechanism (quietNoop) exists only on #630's branch.
Pushed to fix/issue-780: one commit, Review fix: renumber the new decision to LLP 0250, the number is free (c31eaaa7).
…premise LLP 0250 retired
philcunliffe
commented
Aug 17, 2026
Neutral review round 2 of 2: PR #799 ( |
philcunliffe
commented
Aug 18, 2026
Triage result for this PR at head No content of this PR changed after review round 2; the head moved only for a master merge refreshing stale CI. Triage does not merge; this PR remains for a maintainer's decision. |
Uh oh!
There was an error while loading. Please reload this page.
Issue #780 records four non-blocking follow-ups deferred during PR #630's review. This PR fixes item 2, the one behavioural defect among them. The other three are addressed below: one is already done, two are deliberately not actionable and are explained rather than touched.
Item 2 (fixed): a
donetofailed/refusedrewrite recorded assets but not the settings writeThe defect
src/core/config/action_reconciler.js's reverse gap drops a terminal marker for a request key the config stops naming, reading "noinstalled_assets" as "this key never applied anything". LLP 0138#marker-undostates that reading directly: "failednormally means nothing was applied, andinstalled_assetsis the evidence that something was."That is true for the half of an attach that copies files. It says nothing about the half that writes the client's settings, and nothing in the marker schema recorded that half at all. So for a client whose attach copies no files (openclaw is the routine case: it writes
models.providersinto~/.openclaw/openclaw.jsonand installs nothing):perform()returnsdone; the marker recordsstatus: 'done'and noinstalled_assets, because there are none.isCurrent()reports the marker stale), the forward gap re-perform()s, and this pass refuses or fails. The marker is rewritten in place torefused/failed, carrying an empty asset list forward.reverse().The settings entry from step 1 is still in the file. After step 3 nothing names it:
hyp statusshows no action, and the reconciler has no marker left to reverse. That is exactly the orphaning #212 and LLP 0138#marker-undorefuse to accept, reached by a route neither checked. The reverse gap's own comment already argues the opposite ("the settings half cannot [degrade to naming-and-releasing], because nothing else on disk would own the settings it left written") - the comment was right, the condition beneath it had nothing to read.The fix
New LLP 0247, extending LLP 0138
#marker-undoand LLP 0186#how-the-reconciler-distinguishes-it-from-done(both Active, neither edited beyond a mechanicalExtended-by:forward-ref). This is the design pass the maintainer asked for on #630 ("The marker-schema question ... needs its own design pass and review").ActionMarkergains one optional field,prior_done?: boolean. The reconciler'sfailedandrefusedrewrite branches set it when the marker they replace already recorded an applied effect. Adonemarker never carries it: itsstatusalready says the same thing, and two names for one fact are two chances to disagree.done->failed->failed), the same wayinstalled_assetssurvives one. Read through one exported accessor,markerRecordsPriorDone(), besidereadInstalledAssets()and for the reason LLP 0138 gave for that one.detailspread:detailis handler-reported,prior_doneis reconciler bookkeeping about an effect that is really on disk, and a handler that erased it would re-open this defect.doneis still dropped, which is the whole reason LLP 0186 putrefusedin that gate.master's behaviour exactly.hyp leave's sibling copy of the same unsound gate (src/core/commands/central.js) is deliberately not touched here: PR #630 removes that shortcut outright rather than teaching it a new field, so the two fixes are independent and this branch keepscentral.jsbyte-identical tomasterto avoid conflicting with it.Proof
Standalone reproduction against
origin/masterat04330abb(three reconcile passes over a fake handler:donewith no assets ->refusedafterisCurrent()drift -> key no longer desired):After the fix, the same script:
Two regression tests in
test/core/action-reconciler.test.js, both driven end to end throughreconcile()so nothing about the bit is hand-seeded:a settings-only attach rewritten from done to refused is reversed, not dropped (LLP 0247)- asserts thedonemarker carries neitherinstalled_assetsnorprior_done, that the rewrite torefusedrecordsprior_done: true, and that the reverse gap callsreverse()and reportsreversed.the prior-done bit survives repeated failed rewrites, and a key that never applied anything is still dropped (LLP 0247)- two keys under one handler, one that reachesdonethen fails twice and one that fails from the first pass. Asserts the bit is set on the first and absent on the second, survives the second failing rewrite, thatattemptsstill counts, and that only the first is reversed.Both fail on the current tree (
prior_doneisundefined, and with that assertion removedreverseCallsis[]) and pass after the fix. The existing LLP 0186 reverse-gap test - an assetlessrefusedmarker that never reacheddoneis dropped; one carryinginstalled_assetsis reversed - is unchanged and still passes; it is the control this must not break.Checks
npm run typecheck: clean.npm test: 4219 tests, 4198 pass, 20 fail, 1 skipped. All 20 failures are pre-existing onorigin/masterand unrelated (query pushdown / NULL semantics:whereToParquetFilter ...,pushed-down comparisons do not leak NULL rows, etc.). The failing set was captured on the pristine checkout and on this branch and diffed: byte-identical, 20 lines each. This branch moves the suite from 4217 tests / 4197 pass to 4219 / 4198, and changes no failure.0245and0246are already claimed onorigin/integration/proxy-mode-captureandorigin/integration/proxy-mode-default-attach, so this doc took0247, the next number free across every branch.Item 3 (already done, nothing in this PR)
PR #630's body was rewritten on 2026-08-15 to describe what actually merges, preserving the
neutral-triagemarker and theFixestrailer. Recorded on #780 already.Item 1 (not fixed, deliberately):
reverse()returningrefusedfalls into the generic failure branchUnchanged and out of scope, for the reason the issue itself gives. It is dead code today (
src/core/config/action_attach.jsholds the onlyreverse()in the tree and returns onlydoneorfailed), and fixing it means settling terminal-undo semantics between two settled LLPs that point in opposite directions (LLP 0138#refusal-is-not-failureversus#marker-undo/ #212). LLP 0186 §Explicitly out of scope already requires that areverse()genuinely needing to refuse brings its own branch and its own answer to "what happens to the marker", in its own request. Inventing that answer with no caller to test it against would be a design decision made by accident, which is what #780 exists to prevent. LLP 0247 restates it as still-open under Consequences so it stays visible.Item 4 (not fixed, deliberately):
hyp detach allnarrates every unattached clientMarked "minor, optional / preference only" in the issue, and the ready-made mechanism it names - the
quietNoopoption ondetachClientViaCore- exists only on PR #630's branch, not onmaster(verified:grep -rn quietNoopover this tree returns nothing). Implementing a second, independentquietNoophere would collide with #630 insrc/core/commands/clients.jsand send a currently mergeable, reviewed PR back through triage. It is a one-line gate once #630 lands.Fixes#780