Skip to content

Reconciler refused marker: a handler refusal is terminal, not retried every boot (LLP 0184/0186/0187) - #622

Merged
bgmcmullen merged 21 commits into
masterfrom
integration/reconciler-refused-marker
Aug 5, 2026
Merged

Reconciler refused marker: a handler refusal is terminal, not retried every boot (LLP 0184/0186/0187)#622
bgmcmullen merged 21 commits into
masterfrom
integration/reconciler-refused-marker

Conversation

@philcunliffe

@philcunliffephilcunliffe commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Implements the terminal refused marker state, so the action reconciler stops retrying a client action that can only ever fail.

Routed through the pipeline at your instruction on #601: request LLP 0184 (your text, committed verbatim) -> design LLP 0186 -> executable plan LLP 0187 (8 tasks) -> this change set. LLP 0041 is untouched apart from a Extended-by: forward-ref, so what it settled stays settled.

The defect

failed was never terminal: every reconcile pass retried it forever. Correct for a dying hyp backfill subprocess, wrong for a refusal whose precondition only the user can change. The field report on #601 showed "attempts": 17, one per daemon boot, with hyp status giving no signal that waiting would not help.

Both seams are closed here: the marker could not express "permanent", and the handlers already computed the transient/permanent bit but dropped it before the marker was written.

What landed

  • refused marker state (src/core/config/action_reconciler.js, src/core/config/types.d.ts) that short-circuits like done but carries no attempts. The short-circuit is unconditional (not gated on isCurrent), per your "no new per-handler contract".
  • src/core/config/action_refusal.js (new): markActionRefused/isActionRefused carry the bit across the adapter seam on the thrown Error, so the Accepted kernel contract in hypaware-plugin-kernel-types.d.ts is not widened and unmigrated adapters keep working unchanged.
  • Migrations, scoped tightly: OpenClaw's ownership-conflict refusal and Claude's JSONC refusal become refused. Every other failure site (settings_path, endpoint, read, write, malformed JSON, backfill) stays failed.
  • hyp status gains a distinct refused state with the repair hint, and never flips overall.
  • Re-arm: a successful hyp attach <client> clears the marker, mirroring the existing detach-side call. The isCurrent-style input-hash re-arm is recorded in LLP 0186 as a named follow-up and deliberately not built.

Deliberately out of scope

  • Bounding attempts on genuinely transient failed markers (your own Open Question in LLP 0184 did not settle it).
  • Automatic input-hash re-arm for refused.

Verification

8 tasks, each implemented in isolation and merged --no-ff after its own verified gate. New coverage: action-refusal.test.js, attach-refused-rearm.test.js, plus extensions to the reconciler, attach, status, OpenClaw and Claude suites. Note the Claude JSONC refusal path had no prior test coverage anywhere in the repo, so that is new rather than extended.

CI on this PR is the authority for the suite; the per-task local runs were the precondition to merge, not the proof.

Fixes#601

Change-Set: reconciler-refused-marker

test added 19 commits August 4, 2026 22:09
Routes the reconciler-retries-forever fix through the pipeline per the
maintainer's guidance on #601: mints LLP 0184 (the
request document, transcribed from the maintainer's pasted analysis)
and LLP 0185 (the design), extending the Active LLP 0041 rather than
editing it.
LLP 0185 settles option 1 (a terminal `refused` marker state that
short-circuits like `done` and surfaces as attention-needed in `hyp
status`), widens ActionOutcome/ActionMarkerStatus/ClientActionState to
carry it, and specifies how the transient/permanent bit crosses the
kernel's throw-only attach() seam (markActionRefused/isActionRefused)
so OpenClaw's ownership-conflict refusal and Claude's JSONC refusal
both migrate without special-casing either adapter. Re-arm is the
explicit `hyp attach` re-run only, per the maintainer's instruction;
the isCurrent-style input-hash re-arm (LLP 0086 precedent) is recorded
as a named follow-up candidate, not built. attempts-bounding on
transient `failed` markers is left explicitly open.
LLP 0041's Extended-by: line gets a mechanical forward-ref to LLP
0185; nothing it settled is edited.
PR #608 (fix/issue-604) already carries llp/0185-unpicked-client-stays-attached.decision.md
and is further along, so this change set takes the next free number instead.
LLP 0041's Extended-by forward-ref updated to match.
Turns LLP 0186's design (option 1, the terminal refused marker state
per #601) into an eight-task graph: the widened
ActionMarkerStatus/ActionOutcome type seam, the action_refusal.js
marked-Error convention, the reconciler's unconditional refused
short-circuit, the action_attach.js catch translation, the OpenClaw
and Claude refusal-site migrations, the hyp status attention-needed
surface, and the explicit hyp attach re-arm.
Adds 'refused' to ActionMarkerStatus, ActionOutcome.status, and
ReconcileActionResult.outcome, and adds the new ActionRefusalError
interface (Error + required hypActionRefused: true) that lets a
marked throw cross the kernel's throw-only attach() seam. Revises
ActionMarker's doc comment to note a refused marker reuses `at` and
`reason`, carries no `attempts`, and preserves `installed_assets`
across rewrites the same way done/failed markers do.
This is the type seam every other task in the reconciler-refused-marker
change set (LLP 0186 / LLP 0187) builds on.
Task-Id: T1
A successful manual `hyp attach <client>` now clears whatever attach
marker sits at that request key, mirroring the existing detach-side
clearClientActionMarker call. This is the only re-arm a `refused`
marker gets in this pass: with no marker left, the next reconcile pass
treats the client as a fresh target and re-`perform()`s on its own. A
failed manual attach never reaches the clear, so a refused marker
survives a failed re-run unchanged.
Adds test/core/attach-refused-rearm.test.js, mirroring
test/core/detach-rejoin-recovery.test.js's pattern.
Task-Id: T8
…orever
A precondition refusal only the user can fix (a conflicting provider entry,
a JSONC settings file) came back as `failed`, so every reconcile pass
re-performed it and bumped `attempts` forever (LLP 0184). The forward-gap
loop now short-circuits a `refused` marker unconditionally, without
consulting `markerIsCurrent()`: the freshness hook answers "did the input
drift?", which says nothing about whether the refusal was resolved, and
re-arming is the explicit `hyp attach` re-run only in this pass.
A third outcome branch writes `{status, request_key, reason, at}` with no
`attempts` (nothing will ever increment one) and carries `installed_assets`
forward the way the `done` branch does, so a refusal on a re-perform never
orphans what an earlier successful attach copied. The reverse gap treats a
`refused` marker the way it treats a `failed` one: dropped when assetless,
routed to `reverse()` when it recorded an effect. `runOutcome()` accepts the
new shape, but only from a handler's return value: a bare throw stays
`failed`.
Task-Id: T3
Widens ClientActionState with a refused member and renders it distinctly
from failed in both hyp status --json (state/reason/at fall through
existing spreads) and prose (a repair hint pointing at hyp attach), so a
refused marker reads as attention-needed rather than a silent retry.
Task-Id: T7
New sibling module to action_attach.js / action_backfill.js in
src/core/config/. markActionRefused sets hypActionRefused: true on a
thrown Error so it survives the kernel's throw-only attach(): Promise<void>
seam; isActionRefused reads the marker back defensively, tolerant of any
non-Error or unmarked throw. Realizes the ActionRefusalError interface
LLP 0186/T1 already widened types.d.ts with.
Task-Id: T2
perform()'s catch now reads isActionRefused(err) to tell a permanent
precondition refusal (LLP 0186) apart from a transient environmental
failure, so the reconciler can short-circuit the former unconditionally
instead of retrying it forever.
Task-Id: T4
settings.js's JSONC detection throw site now wraps its ClaudeSettingsError
with markActionRefused so the marked Error survives index.js's attach()
rethrow, letting the reconciler treat it as a terminal refused outcome
instead of retrying an edit attach can never safely make. Other
ClaudeSettingsError throw sites are unchanged.
Adds the first test coverage for this refusal path plus a companion case
proving a plain malformed-JSON throw is not marked as refused.
Task-Id: T6
The `models.providers` ownership conflict (LLP 0167#attach-detach: a value
HypAware did not write sits at a key attach owns) is a property of the user's
config. No reconciler pass changes it, but the adapter reported it as
`{status:'failed'}`, so the marker was retried on every pass with `attempts`
climbing forever, which is LLP 0184's bug.
`OpenclawAttachOutcome` gains `{status:'refused', reason}` and that one call
site returns it. The other four `errorKind`s (`settings_path`, `endpoint`,
`read`, `write`) are environmental and may well clear on their own, so they
keep returning `{status:'failed'}` and keep retrying.
Mechanically, `fail()`'s side effects split out into `reportAttachFailure()`
and the refusal gets its own `refuse()` wrapper rather than a `status`
argument on the shared five-call-site helper: there is then no parameter a
future call site can pass wrong, and the four transient kinds cannot drift.
Both surfaces `reportAttachFailure()` writes are unchanged, so
`writeAttachOutput`'s `--json` payload and prose still say `status: 'failed'`
for a refusal: the split is about how the reconciler schedules a retry, not
about what the user's attach just did, and `--json` is a wire contract a
scripted caller already parses.
`index.js`'s wrapper throws `markActionRefused(new Error(reason))` for the
refused outcome, alongside the unchanged bare-`Error` throw for `failed`. The
kernel types the registered `attach()` as `Promise<void>`, so the mark is the
only way the terminal/transient bit survives that seam to `perform()`'s catch.
Tests: the three ownership-conflict assertions in openclaw-attach.test.js
become `'refused'`; the missing- and malformed-`openclaw.json` cases still
assert `'failed'`, proving the four environmental kinds did not migrate. At
the wrapper seam, the rethrown refusal is asserted marked and a hard failure
asserted unmarked. The `perform()`-level test stops asserting which non-`done`
status the catch produces: that classification is action_attach.js's, tested
there.
Task-Id: T5
…ry runs
Review round 1 of PR #622. The re-arm added in T8 cleared the attach marker
unconditionally after any successful manual `hyp attach`, which had two
effects nothing in LLP 0186 asked for:
- A `done` marker carrying `installed_assets` was deleted. That marker is the
only record naming the files an org-driven attach installed, and
`hyp detach` reads exactly it to know what to remove, so a manual re-attach
followed by a detach left those files stranded (LLP 0138#marker-undo, the
same invariant the reconciler's own carry-forward branches protect).
- `hyp attach --dry-run` wrote to the marker store. `hyp detach --dry-run`
returns before its own clear; the attach side did not.
Gate the clear on the marker's prior status being `refused`, and skip it under
--dry-run. That is exactly the re-arm the maintainer asked for on issue #601
and nothing more.
Also strengthen the one end-to-end assertion over the whole OpenClaw chain
back to the exact status (it had been weakened to `notEqual 'done'`, which
would stay green if any link regressed to `failed`), and cover the Claude half
of the seam at the registration level: the JSONC refusal only reaches the
reconciler if `index.js`'s wrapper and `withSpan` rethrow the marked Error
unchanged, which nothing tested.
LLP 0186's re-arm and test-strategy sections are updated in the same commit to
match.
Co-Authored-By: Claude <noreply@anthropic.com>
@philcunliffe

Copy link
Copy Markdown
ContributorAuthor

Neutral review, round 1 of 2

Verdict: approve after the fixes below, which are pushed. The design is faithful to what the maintainer settled on #601 (option 1 only, explicit hyp attach re-arm only, no isCurrent re-arm, no attempts bounding), the reconciler change is correct, the migration is precise, and the new tests are load-bearing. The one real problem was in T8's re-arm, which did more than re-arm.

Reviewed at head 2b84a4f; fixes are 553706c on integration/reconciler-refused-marker.


Findings

F1 (medium-high, fixed) - the re-arm deleted done markers, stranding org-installed assets.
src/core/commands/clients.js:435 (pre-fix): a successful manual hyp attach called clearClientActionMarker unconditionally, whatever the marker's prior status. A done attach marker is the only record naming the files an org-driven attach installed, and hyp detach reads exactly that marker (src/core/commands/clients.js:1157) to know what to remove. So hyp attach --client claude followed by hyp detach --client claude left those files on disk with nothing naming them, and no later reversal could remove them.

That is a straight regression against LLP 0138#marker-undo, the invariant this same PR restates twice in the reconciler's own carry-forward comments (action_reconciler.js:219, :308). It is also collateral: nothing about the refused-marker feature needs it. Confirmed empirically before fixing (seeded a done marker with installed_assets, ran the real attach() command path, marker came back undefined).

Fixed by gating the clear on existingMarker?.status === 'refused'.

F2 (medium, fixed) - hyp attach --dry-run wrote to the marker store.
Same call site. parsed.dryRun was forwarded to client.attach() but the clear below it ran regardless, so a dry run mutated on-disk state. The code comment claimed it was "mirroring the detach-side call below", but detachClientViaCore returns at clients.js:1068 under dryRun and never reaches its own clear. Confirmed empirically (dry-run attach against a seeded refused marker cleared it).

Fixed by skipping the clear entirely when parsed.dryRun === true.

F3 (low, fixed) - the only end-to-end assertion over the OpenClaw chain was weakened, not migrated.
test/plugins/openclaw-client-registration.test.js:313 went from assert.equal(refused.status, 'failed') to assert.notEqual(refused.status, 'done'). That test is the one place the whole chain runs for real (real adapter -> refuse() -> the wrapper's marked throw -> createAttachHandler().perform()'s classification), and notEqual('done') stays green if any single link regresses to failed. Restored to assert.equal(refused.status, 'refused'), which is strictly stronger than what was there on master.

F4 (low, fixed) - Claude's half of the adapter seam had no registration-level coverage.
claude-settings-attach.test.js proves settings.js marks the JSONC throw, but the mark only reaches perform() if claude/src/index.js's wrapper and withSpan rethrow the same object. Nothing tested that; a wrapper that rewrapped the error would silently downgrade the refusal to a retried failed with the whole suite still green. OpenClaw has this test; Claude did not. Added activate() attach() rethrows the JSONC refusal with the refusal mark intact.

LLP 0186's re-arm and test-strategy sections are updated in the same commit to match F1/F2 (the design's "the implementation does not need to branch on the marker's prior status before calling it" is what produced F1). Nothing LLP 0186 settled about the marker state itself was touched.


Verified clean

  • Scope discipline. Nothing deferred was built. markerIsCurrent()'s logic is unchanged (comment-only edit); no attempts cap anywhere; the isCurrent-style re-arm appears only as a named follow-up in the design. hypaware-plugin-kernel-types.d.ts is genuinely untouched (not in the diff at all) - the marked-Error convention is what avoided it. LLP 0041 is touched only on its Extended-by: line.
  • The reconciler change. The refused short-circuit is first in the forward-gap loop and unconditional (action_reconciler.js:150), ahead of the done + markerIsCurrent() gate. Reverse gap drops an assetless refused and routes an asset-bearing one to reverse() (:315). installed_assets carry-forward in the refused write branch mirrors the done branch's union (:220).
  • The adapter seam.isActionRefused is err instanceof Error && err.hypActionRefused === true, so an unmarked throw and a non-Error throw both read as failed. The mark survives OpenClaw's index.js rethrow, Claude's catch { throw err }, and withSpan, which rethrows the identical object for anything already an Error (span_helpers.js:41).
  • Migration precision. OpenClaw: only the conflictingProviderKeys site calls refuse() (attach.js:159); settings_path (:120), endpoint (:131), read (:149), write (:183) all still fail(). Claude: only JSONC is marked (settings.js:329); MALFORMED_JSON, NOT_AN_OBJECT, CONCURRENT_EDIT, INVALID_PORT, and the read/stat failures are untouched. Codex and the backfill handler have no refusal site and none was invented.
  • hyp status.[refused] renders as its own bracketed state with the run 'hyp attach <key>' after fixing the cause hint; --json forwards state/reason/at and no attempts; overall cannot flip, because collectHypAwareStatus's degradingKinds computation reads diagnostics only and never clientActions (daemon/status.js:773).
  • Re-arm. A successful attach clears; a failed attach leaves it in place (the clear sits below the throw) - and now, a dry run and a done marker are left alone too.
  • Test strength. Spot-checked by reverting each source hunk in isolation and confirming the matching test fails, restoring after each:
    • reconciler refused short-circuit removed -> action-reconciler.test.js fails 1/16
    • Claude markActionRefused unwrapped -> claude-settings-attach.test.js fails 1/21
    • OpenClaw refuse() reverted to fail(..., 'refused') -> 5 failures across the two OpenClaw files
    • clients.js clear removed -> attach-refused-rearm.test.js fails 1/2
    • and for my own fixes: refused-only guard removed -> new done-marker test fails; --dry-run guard removed -> new dry-run test fails; Claude wrapper made to rewrap -> new registration test fails.
  • @ref annotations. All seven LLP 0186 anchors resolve against its headings, as do LLP 0045#part-3-reverse-runs-from-disk-the-marker-is-a-self-describing-undo-record and LLP 0138#marker-undo. (I dropped the LLP 0045 ref at the re-arm site: with the clear now scoped to refused, it no longer mirrors detach's undo-record retraction, so the ref stopped being honest.)
  • Style. No em dashes, no semicolons, JSDoc types only, root-anchored .js type-import specifiers.

Residual findings

Both are low, neither is worth blocking on, neither is fixed.

  • R1 (low, cosmetic) - src/core/commands/status.js:477. The repair hint is inside the if (bits.length > 0) guard, so a refused entry with no reason renders bare [refused] with no next step - the "attention-needed" signal without the action. Unreachable today: the reconciler always writes reason ?? 'unknown'. It becomes reachable if a future handler or a hand-edited marker omits reason.
  • R2 (low, latent) - src/core/config/action_reconciler.js:340.ActionOutcome.status now admits 'refused' on reverse() too, but the reverse gap has no branch for it: a refused reverse falls into the else, is logged as action_reverse_failed, keeps its marker, and retries every pass - the exact forever-retry loop this change set removes from the forward gap. No handler returns it today, so this is only a shape the widened type makes expressible. If a detach ever needs to refuse (a settings file that turned into JSONC after attach is the obvious candidate), that branch has to exist first.

Numbers

before fixesafter fixes
npm test3390 tests, 3389 pass, 0 fail, 1 skipped3393 tests, 3392 pass, 0 fail, 1 skipped
npm run typecheckcleanclean

Pushed: yes, 553706c to integration/reconciler-refused-marker. Not merged, not marked ready - held for the maintainer as usual.

Review round 2 of PR #622. Round 1 scoped the manual-attach re-arm to
`refused` markers so a `done` one stops being collateral, but a `refused`
marker is an undo record too: the reconciler's own `refused` write branch
carries `installed_assets` forward across the rewrite precisely because a
refusal on a re-`perform()` does not un-install what an earlier successful
attach copied. Clearing such a marker is the same data loss round 1 fixed for
`done`, one status over: `hyp detach` reads exactly that marker to know what to
remove, so the org-installed files are stranded with nothing naming them.
Reproduced against the real `attach()` command path (seeded a `refused` marker
with `installed_assets`, ran attach, marker came back undefined).
Re-arm now goes through `rearmRefusedActionMarker`, beside the store it
rewrites. An assetless `refused` marker is still dropped outright, exactly as
before. One that records assets is rewritten to `failed` with the record
intact: `failed` is short-circuited by nothing, so it re-arms the forward gap
exactly as a cleared marker does, the reverse gap and `hyp detach` keep reading
the same undo record, and the next successful `perform()` unions the carried
paths onto the fresh `done` marker.
Also in this commit:
- `hyp status` prints the refused repair hint unconditionally instead of from
inside the reason guard, so a marker whose `reason` is empty or unreadable
renders the next step rather than a bare `[refused]` (round 1's residual R1).
- The reverse gap's failure arm now says why a `refused` reverse outcome
deliberately lands there: `ActionOutcome` is one type across both hooks, so
widening it made the status expressible on `reverse()`, but nothing produces
it and dropping a marker over an unreversed effect is the more expensive
wrong answer. Recorded as out of scope in LLP 0186 rather than left latent
(round 1's residual R2).
LLP 0186's re-arm, out-of-scope, and test-strategy sections are updated in the
same commit.
Co-Authored-By: Claude <noreply@anthropic.com>
@philcunliffe

Copy link
Copy Markdown
ContributorAuthor

Neutral review, round 2 of 2

Verdict: approve after the fix below, which is pushed (4fd8b70). The change set itself is sound and round 1's read of it holds. Round 1's own fixes are correct as far as they go, but F1 was fixed one status short: it stopped the re-arm eating a done marker's undo record and left it eating a refused marker's. Same mechanism, same data loss, narrower trigger. That is fixed here, along with both of round 1's residuals.

Reviewed at head 553706c; fixes are 4fd8b70 on integration/reconciler-refused-marker.


Findings

F5 (medium, fixed) - the re-arm still destroyed an undo record, now via refused instead of done.
src/core/commands/clients.js:446 (pre-fix). Round 1 gated the clear on existingMarker?.status === 'refused', on the reasoning that a done marker is the only record naming what an org-driven attach installed. That reasoning applies verbatim to a refused marker, because installed_assets means the same thing on both: the reconciler's refused write branch carries the field forward across the rewrite (action_reconciler.js:220-227), and its own comment says why - "a refusal on a re-perform() does not un-install what an earlier successful attach copied ... Dropping it here would orphan them past any later reversal." Then the re-arm dropped exactly that marker.

hyp detach reads the marker by request key and never looks at its status (clients.js:1171-1172), so the assets it removes are whatever the marker names, done or refused alike. Reachable path: org attach goes done with installed_assets -> daemon restarts onto a new ephemeral port, isCurrent() reports the marker stale (LLP 0086) -> re-perform() refuses (the user has since made settings.json JSONC, or an OpenClaw provider key conflicts) -> marker rewritten refused, assets carried -> user fixes the cause and runs hyp attach claude -> record gone. A hyp detach before the next reconcile pass then strands the files, and if the org's plugin set shrank in between they are stranded permanently, since the re-performed attach only reports what that pass copied.

Confirmed empirically before fixing: seeded a refused marker carrying installed_assets, ran the real attach() command path, marker came back undefined.

Fixed by routing the re-arm through a new rearmRefusedActionMarker() (src/core/config/action_reconciler.js:600), beside the store it rewrites:

  • assetless refused marker: dropped outright, exactly as before;
  • refused marker carrying installed_assets: rewritten to failed with the record intact. failed is short-circuited by nothing, so it re-arms the forward gap exactly as a cleared marker does; the reverse gap, hyp detach and hyp leave keep reading the same undo record; and the next successful perform() unions the carried paths onto the fresh done marker.
  • any other status, or no marker: no write at all (so round 1's done and --dry-run guarantees are preserved, now inside one function instead of at the call site).

Verified in the committed tree with git show HEAD:src/core/config/action_reconciler.js and git show HEAD:src/core/commands/clients.js, and by mutation: forcing the helper's installed_assets.length === 0 branch to true (an unconditional drop) fails the two new tests, 2/22; restored after.


Round 1's residuals, re-assessed

R1 (low, cosmetic) - now fixed.src/core/commands/status.js:477 (pre-fix). Round 1 called it unreachable because the reconciler always writes reason ?? 'unknown'. It is in fact reachable two ways: ?? passes an empty string through, so an adapter that refuses with new Error('') (action_attach.js sets reason from err.message) writes reason: '', and daemon/status.js:936 forwards a non-string reason as absent, so a hand-edited or foreign-version marker also renders bare. Cheap and worth closing rather than carrying to triage: the repair hint is now unconditional, since the hint is the whole point of the state and a bare [refused] is the attention signal without the action. Mutation-verified (restoring the bits.length > 0 guard fails the new test, 1/14).

R2 (low, latent) - determination: a documented non-issue, closed as a recorded constraint, not as code. Reasoning, since triage will want it:

  • It is not reachable today and not reachable by accident.reverse() exists on exactly one in-tree handler (action_attach.js), it returns only done/failed, and runOutcome() refuses to invent a refused from a throw (a bare throw is always failed). Reaching the bad state takes a new handler author deliberately writing { status: 'refused' } in a reverse hook.
  • The current fall-through is the safe half of the pair, not the dangerous half. A refused reverse is logged action_reverse_failed, keeps its marker, and retries. The alternative that "closes" it (drop the marker) destroys the only record naming settings and files that are still on disk, which is what client attach: probe-less contributes.client can attach but reverse() silently no-ops, orphaning settings #212 and LLP 0138#marker-undo both refuse to accept, and is the exact bug class F1 and F5 turned out to be. A retried undo is recoverable; a dropped one is not.
  • Closing it properly is a design decision this document deliberately did not make. What a terminal undo refusal means for the marker has two settled precedents pulling opposite ways in this repo: LLP 0138#refusal-is-not-failure says name-and-release for a containment-refused asset removal, client attach: probe-less contributes.client can attach but reverse() silently no-ops, orphaning settings #212 says keep-and-retry for a probe-less settings reversal. Picking one in a review round, with no producer to test against, is how the widened type gets a second wrong branch instead of none.
  • Narrowing the type instead would mean splitting ActionOutcome per hook, which touches the kernel-adjacent contract this change set deliberately left alone.

So: no behavior change. What was latent is now recorded, in the code at the branch itself (action_reconciler.js:340) and in LLP 0186 §Explicitly out of scope, naming the JSONC-after-attach case as the candidate that would need its own branch and its own answer for the marker.


Verified clean (round 2)

  • Round 1's refused-only gate misses no case it should clear.done must not be cleared (F1). failed needs nothing: it is short-circuited by nothing, so the next pass retries it regardless. applied is a reversible-handler state no attach handler writes. The only status that both blocks the forward gap and can be resolved by the user is refused, and after F5 the gate is on the status and on whether the marker records an effect, which is the real distinction.
  • The dry-run skip leaves the store consistent. Under --dry-run the adapter writes nothing, materializeAttachAssets is passed dryRun (clients.js:478), and the marker store is now never opened, so there is nothing to be consistent with. The re-arm remains ordered before asset materialization, as it was pre-fix: a materialization failure after a re-arm leaves a re-armed marker over settings that did land, which the next pass converges.
  • No other consumer of the widened outcome. The only reconcile() caller is daemon/runtime.js:367, which logs results.length and never branches on outcome, so a refused result cannot fall through a summary that only counts failed. The refusal's visibility comes from the reconciler's own client_action.refused error log plus hyp status.
  • hyp leave's parallel reverse gate was not migrated to refused (central.js:435 still reads marker.status === 'failed' && installedAssets.length === 0). Deliberately left alone: the un-migrated behavior is the conservative one. An assetless refused marker takes the full detachClientViaCore path instead of being dropped, which replays the real settings undo and then clears the marker. Migrating it would make leave drop markers it currently reverses, which is a strictly larger claim than this change set makes.
  • The adapter seam, re-checked. OpenClaw's refuse() is a separate function rather than a status parameter on fail(), so the four transient errorKinds cannot drift into it; the wrapper marks only the refused branch (openclaw/src/index.js:198-199); Claude marks only the JSONC throw. The CLI-facing --json payload still reports status: 'failed' for both, so no wire contract moved.
  • Style. No em dashes and no trailing semicolons in the added lines (checked against the diff), JSDoc types only, @ref anchors resolve (#re-arm-explicit-hyp-attach-re-run-only, #hyp-status-attention-needed-surface, #writing-it, LLP 0138#marker-undo).

Residual findings

  • None from this change set. R1 is fixed; R2 is settled as a recorded constraint with the reasoning above rather than left as an open item.
  • Pre-existing, not this PR (recorded so triage does not attribute them here): npm test has one failure in this sandbox, test/core/status-first-sync-hold.test.js "a corrupt marker fails open", which leaks the host's real systemd state into the diagnostics assertion. It fails identically with every change of mine stashed at 553706c. Likewise hyp smoke client_attach_on_join and hyp smoke client_attach_idempotent fail identically on origin/master (fb60a9f) in the same sandbox; hyp smoke core_boot_noop passes, so the harness itself is working.

Numbers

at 553706cat 4fd8b70
npm testnot re-run in full; round 1 recorded 3393 tests, 3392 pass, 0 fail, 1 skipped3396 tests, 3394 pass, 1 fail, 1 skipped
npm run typecheck-clean

The one failure is the environmental status-first-sync-hold case above, not a regression: with every change of mine stashed at 553706c that file fails identically (3 pass, 1 fail), so this sandbox differs from round 1's, which reported it green.

Three tests added: the rearmRefusedActionMarker unit contract (all five branches), the asset-bearing re-arm end to end through the real attach() command path plus the following reconcile() pass, and the reason-less refused status rendering.

Pushed: yes, 4fd8b70 to integration/reconciler-refused-marker. Not merged, not marked ready.

@philcunliffe

Copy link
Copy Markdown
ContributorAuthor

Neutral triage on this PR (head 4fd8b70).

Round 2's fix is complete. Checked rearmRefusedActionMarker (src/core/config/action_reconciler.js:600) directly, including a probe against the real path (seeded a refused marker with installed_assets, called the re-arm, then read the marker back the way hyp detach does): an asset-bearing refused marker rewrites to failed with installed_assets intact, and readInstalledAssets (used by both hyp detach and hyp leave) reads it back correctly in that window, regardless of status. test/core/attach-refused-rearm.test.js covers the same case end to end through a reconcile pass. Full suite: 3394/3396 pass, 1 skip, 1 fail (test/core/status-first-sync-hold.test.js, the documented systemd-state leak that also fails on master).

Also re-checked round 2's two determinations:

  • R1 (repair hint behind the reason guard): confirmed fixed - src/core/commands/status.js:477-488 renders the hint unconditionally now.
  • R2 (a reverse() returning refused retries forever): confirmed genuinely dead code today - action_attach.js's is the only reverse() in the tree and it only ever returns done/failed. Round 2's classification of this as a documented non-issue rather than a live bug stands.

No true blockers found. Two non-blocking, pre-existing deferrals from round 2 are recorded for tracking rather than left to rot: the reverse()-refused dead branch above, and hyp leave's reverse gate (central.js:435) never having been migrated to check refused the way the reconciler's own reverse gap and hyp detach now do (verified safe: it falls through to the real undo, which no-ops harmlessly and still clears the marker, just via a heavier path). Filed as #627.

@philcunliffe
philcunliffe marked this pull request as ready for review August 5, 2026 01:43
@philcunliffephilcunliffe added the neutral:approved neutral reviewed this and holds it for a maintainer merge (own or adopted PR; LLP 0025/0030) label Aug 5, 2026
@bgmcmullen
bgmcmullen merged commit b56ab21 into masterAug 5, 2026
9 checks passed
@bgmcmullen
bgmcmullen deleted the integration/reconciler-refused-marker branch August 5, 2026 03:56
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

neutral:approvedneutral 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.

Action reconciler retries permanently-failed client actions on every boot (LLP 0184)

2 participants

@philcunliffe@bgmcmullen