Skip to content

Unpicking an attached client is named, not silently left broken (#604) - #608

Merged
bgmcmullen merged 3 commits into
masterfrom
fix/issue-604
Aug 5, 2026
Merged

Unpicking an attached client is named, not silently left broken (#604)#608
bgmcmullen merged 3 commits into
masterfrom
fix/issue-604

Conversation

@philcunliffe

@philcunliffephilcunliffe commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Root cause

runPickerFinale (src/core/cli/walkthrough.js) only ever attaches picked
clients. Nothing detaches a client that a previous run attached and this run
left unchecked. The action reconciler's reverse lane
(src/core/config/action_reconciler.js) undoes only config-named action
keys, i.e. the org/central lane, so a solo machine's wizard-finale attach is
never reversed by it. Solo machines have been able to re-enter the picker via
Reconfigure since LLP 0129, so this is pre-existing on master.

Result, from the issue: unchecking Codex drops @hypaware/codex and its gateway
upstream while ~/.codex still routes through the gateway (capture stops,
requests can fail); re-picking only OpenTelemetry composes no
@hypaware/ai-gateway at all, so after the finale's daemon restart every
still-attached client points at a dead port.

What this implements

Option 2 (warn at finale time), plus option 3 (status diagnostic).

  • findAttachedNotConfiguredClients (src/core/cli/walkthrough.js): a client is
    stranded when its settings still carry a HypAware attach marker (read through
    the same descriptor-driven probe hyp status uses), it is not among this run's
    picks, and neither the config the finale just wrote nor the org's central layer
    enables its adapter. The finale prints the names, the consequence, and one
    hyp detach --client <name> line each, before the daemon restart. Dry runs
    carry the finale's usual (dry-run) tag.
  • FinaleSummary gains attachedNotConfigured?: string[] so the run's result
    carries what was printed.
  • hyp status gains client_attached_not_configured: the mirror of
    client_attach_missing, repairing with hyp detach --client <name>. Gated to
    hosts with no central layer, because on a joined host the same shape is a
    reconciler pass that has not run yet, not something an operator should undo by
    hand. Known gap noted in the LLP: a local-layer strand on a managed host is
    warned about by the finale but not by status.
  • LLP 0185 records the decision (new doc, number taken as instructed).

Explicitly not option 1 (finale-side detach)

Auto-detaching on a menu confirm is a destructive, surprising behaviour change:
unchecking a picker row is not an instruction to rewrite ~/.codex/config.toml,
and the picker has up to now only ever added. That is the maintainer's call to
make deliberately, not something a bug fix should decide on their behalf. The
warning is additive and reversible, and it makes the broken state visible without
committing to product behaviour. Options 1 and 3 stayed open; 3 is included here
because it was a small addition, 1 is not.

The warning surfaces the failure, it does not prevent it. A user who ignores
it is in exactly the state the issue describes. Option 1 is the complete fix if
you want it, and it is now cheap: the detection it needs is the function this PR
adds, and the undo it needs (detachClientFromDisk) already exists.

The org/central attach markers are untouched. A client whose adapter the
central layer names is never counted as stranded (the finale reads the central
layer read-only, for plugin names only), and the status diagnostic stays silent
on joined hosts. Nothing here writes to a settings file or a marker store.

Scope note: this PR does not touch src/core/cli/wizard/pick.js or
composePickerConfig, which #603 covers in parallel.

Ground truth

Pre-fix (node --test test/core/walkthrough-unpicked-attached-clients.test.js test/core/status-client-attached-not-configured.test.js, with src/ reverted to
master):

not ok 1 - a client attached with no plugin enabling it is a warning naming the detach
ok 2 - a configured attached client draws no stranded diagnostic
ok 3 - a managed host leaves the reverse lane to the reconciler and stays quiet
not ok 4 - the finale names a still-attached client the new config no longer collects
not ok 5 - a picked client that stays configured draws no stranded-attach warning
not ok 6 - an unattached client the picker skipped is not warned about
# tests 6
# pass 2
# fail 4

Post-fix, same command:

ok 1 - a client attached with no plugin enabling it is a warning naming the detach
ok 2 - a configured attached client draws no stranded diagnostic
ok 3 - a managed host leaves the reverse lane to the reconciler and stays quiet
ok 4 - the finale names a still-attached client the new config no longer collects
ok 5 - a picked client that stays configured draws no stranded-attach warning
ok 6 - an unattached client the picker skipped is not warned about
# tests 6
# pass 6
# fail 0

Full suite (npm test): # tests 3383 / # pass 3382 / # fail 0 / # skipped 1.
npm run typecheck: clean.

Smokes: status_diagnostics, walkthrough_picker_to_first_query and
walkthrough_backfill_client_history are green.
walkthrough_to_first_query, client_attach_idempotent and
claude_attach_detach fail in this environment, and fail identically on clean
master (verified by stashing the change), so they are pre-existing here.

One existing test needed a one-line fix: status-first-sync-hold.test.js
asserted diagnostics deep-equals [] while inheriting the runner's real
$HOME, so the new diagnostic fired from the developer's own attached Claude. It
now passes an isolated homeDir, which is what that assertion always meant.

Fixes#604

testand others added 2 commits August 4, 2026 21:13
Re-running `hyp init` and unchecking a previously attached client
regenerates a config without that client's adapter while the client's own
settings still route through the HypAware gateway. The finale only
attaches, and the action reconciler's reverse lane undoes only
config-named (org/central) keys, so a wizard attach on the local layer is
never reversed: capture stops and the client's requests can fail. Picking
only OpenTelemetry composes no gateway at all, and every still-attached
client points at a dead port after the finale's restart.
The finale now names the clients it left stranded (attach marker on disk,
not picked, enabled by neither the written config nor the central layer)
and prints the `hyp detach --client <name>` that clears each. It does not
detach: rewriting a client's settings file is not what unchecking a
picker row asked for, and that call is the maintainer's to make (LLP
0185). `hyp status` gains the matching `client_attached_not_configured`
diagnostic as the after-the-fact backstop, gated to solo hosts because on
a joined host that shape is a reconciler pass that has not run yet.
The org/central attach markers are untouched throughout.
Co-Authored-By: Claude <noreply@anthropic.com>
…604)
Three defects in the #604 fix, found in neutral review.
An unreadable local config emptied the active-plugin set for a reason that
has nothing to do with what the operator enabled, and the new
`client_attached_not_configured` branch read that as "nothing enables this
adapter": `hyp status` answered a parse failure with a `hyp detach` for
every attached client, stacked on top of the `config_unreadable` error that
is the actual repair. The branch now stands down when the local layer is
present but did not parse.
`findAttachedNotConfiguredClients` counted a plugin entry with
`enabled: false` as configured, while `hyp status` builds the same set from
`activePlugins`, which skips it. A switched-off adapter collects exactly as
little as an absent one, so the two surfaces disagreed about the same file.
LLP 0185 records both gates, and the forward-refs the corpus convention
wants land on the docs it extends: LLP 0180 (the finale's attach lane) and
LLP 0086 (the client drift diagnostic family). The status test's `@ref`
carried an empty gloss; it now says what it tests.
Tests: an unreadable local config draws no detach advice; a disabled plugin
entry strands its client; and the central-layer suppression LLP 0185 #scope
promises for the finale, which had no coverage on the walkthrough side.
Both new gates fail without the src change.
npm test: 3386 tests / 3385 pass / 0 fail / 1 skipped. npm run typecheck
clean. Smokes status_diagnostics and walkthrough_picker_to_first_query ok.
Co-Authored-By: Claude <noreply@anthropic.com>
@philcunliffe

Copy link
Copy Markdown
ContributorAuthor

Neutral review of PR #608 (head 2015d8a)

Verdict: approve with fixes applied. The fix does what #604's option 2 + option 3 ask for, the regression tests are real (4 of the 6 originals fail with src/ reverted to master, verified in a clean worktree), and the scope discipline around the org/central lane holds up: claude-desktop declares no attach_probe so it can never be named by either surface, and hyp detach resolves its descriptor from the bundled catalog (buildAttachPluginCatalog), so the repair the PR prints is runnable precisely when the client's plugin is not enabled, which is the whole scenario.

Five findings. All five are fixed in a70dc44, pushed to this branch.


1. hyp status answered an unreadable config with "detach everything" - medium

src/core/daemon/status.js:641 (pre-fix): the new branch is !configured && probe.attached && !hasCentral. configured is derived from activePlugins, which is empty whenever config === null (status.js:387-393). A local config file that is present but does not parse takes exactly that path (status.js:330-346 pushes config_unreadable, severity error), so on a solo host with a corrupt config.json the report carried, for every attached client:

codex settings still point at the HypAware gateway but '@hypaware/codex' is not enabled - ... run 'hyp detach --client codex' to unhook it

stacked on top of the config_unreadable error. The parse failure says nothing about what the operator enabled, and detaching is the one thing here that rewrites a user's own settings file. That is an accident being read as intent, and the advice actively works against the real repair (restore the config).

The config_missing case is left alone deliberately: no config at all plus a live attach marker genuinely is a stranded client, and the detach is genuinely right there.

Fixed:localConfigUnreadable (status.js:310) gates the branch (status.js:649). Regression test an unreadable local config is not read as an instruction to detach in test/core/status-client-attached-not-configured.test.js:94 - fails without the gate, passes with it.

2. The finale and hyp status disagreed about enabled: false - low

src/core/cli/walkthrough.js:1095 (pre-fix) built configured as new Set((config.plugins ?? []).map((entry) => entry.name)), counting a plugin entry as configured regardless of its enabled flag. hyp status builds the same set from activePlugins, which skips enabled === false (status.js:389). A switched-off adapter collects exactly as little as an absent one, so the two surfaces would have reported differently on the same config file.

Fixed: the filter now matches activePlugins (walkthrough.js:1101), with a new @ref LLP 0185#not-configured-means-not-active. Regression test a plugin entry left in the config with enabled false does not count as configured (test/core/walkthrough-unpicked-attached-clients.test.js:168), driving the exported findAttachedNotConfiguredClients directly - fails without the filter.

3. LLP 0185 §scope's central-layer claim had no walkthrough-side test - low

The status side had a managed host leaves the reverse lane to the reconciler and stays quiet, but readCentralPluginNames (walkthrough.js:1076) - the read that makes the LLP's "the org/central lane is untouched" true in the finale - had zero coverage. That is the half of the claim that matters most, since the finale is where the state gets created.

Fixed:a client the central layer names is not stranded by an unpicking run (test/core/walkthrough-unpicked-attached-clients.test.js:138) writes a central seed naming @hypaware/codex, picks only claude with codex attached, and asserts no warning.

4. Missing Extended-by: forward-refs - low (convention)

CLAUDE.md §LLP conventions requires a forward-ref on the applicable parts of a doc a new LLP extends, and the corpus practices it widely (LLP 0033, 0041, 0044, 0045, 0049, 0071, 0086, 0103, ...). LLP 0185 extends two accepted docs and neither was annotated: LLP 0180 (whose §decision is "the finale attaches picked clients and nothing else") and LLP 0086 (which owns the client attach-drift diagnostic family that client_attached_not_configured joins).

Fixed:Extended-by: lines added to llp/0180-finale-attaches-openclaw.decision.md:9 and llp/0086-attach-tracks-ephemeral-port.decision.md:9.

5. Empty @ref gloss - trivial (convention)

test/core/status-client-attached-not-configured.test.js:16 read // @ref LLP 0185#status-backstop [tests]: with nothing after the colon. The gloss is required (LLP 0000), and this is a reference that tells the reader nothing the filename doesn't.

Fixed: it now names what the file tests.

LLP 0185 §status-backstop and a new §not-configured-means-not-active record gates 1 and 2, so the @refs stay honest. The doc is introduced by this PR, so this is a revision under review, not an edit to a settled record.


Not fixed - maintainer's call

The warning is not carried into the closing run summary.FinaleSummary.attachedNotConfigured was added specifically so "the run's result carries what was printed", but writeWalkthroughRunSummary (walkthrough.js:567-599) never reads it. In a real hyp init the warning is printed, then the daemon restart runs, then ✓ Wrote <config> plus the attach/skills/agents lines, then the wizard's first-look queries. On a full-height terminal the one thing this PR exists to make visible is several screens above where the user's eye lands. One line in the run summary would fix it, and the data is already there. Left alone because it is a print-contract change to an Accepted LLP 0185 §decision ("It prints the clients, the consequence, and one hyp detach --client <name> line each"), which is yours to extend rather than a reviewer's.

Option 1 is still open, and the PR is honest about it. The warning surfaces the breakage without preventing it; a user who scrolls past is in exactly the state #604 describes. The PR's own framing is right that auto-rewriting ~/.codex/config.toml on a menu confirm is a product decision, not a bugfix decision. Worth noting that #604's ordering put finale-side detach first.

Checks reviewed and confirmed

  • No import cycle introduced by walkthrough.js -> daemon/status.js (traced the full static graph out of status.js; 51 modules, no path back to walkthrough.js).
  • client_attached_not_configured is not in degradingKinds (status.js:791), so it cannot flip overall, matching the LLP's non-degrading framing.
  • All @ref anchors resolve: LLP 0031#central-layer-is-sacrosanct (## Central layer is sacrosanct), LLP 0045#part-3-... (## Part 3. Reverse runs from disk: ...), LLP 0185#warn-do-not-detach / #status-backstop / #scope (explicit <a id>s).
  • The status-first-sync-hold.test.jshomeDir fix was necessary and sufficient: it is the only status test that deepEquals the whole diagnostics array; the rest use .some(d => d.kind === ...) and are unaffected by a developer's real $HOME.
  • No semicolons, no em dashes, no @typedef, no inline import('...') types in the diff.

Results

checkresult
npm test3386 tests / 3385 pass / 0 fail / 1 skipped
npm run typecheckclean
npm run smoke -- status_diagnosticsok
npm run smoke -- walkthrough_picker_to_first_queryok
PR's own tests, src/ reverted to master6 tests / 2 pass / 4 fail (regression coverage confirmed)
both new gates, src/ fix reverted9 tests / 7 pass / 2 fail (new coverage confirmed)

Pushed as a70dc44.

philcunliffe pushed a commit that referenced this pull request Aug 4, 2026
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.
…ad unfiltered (#604)
The status gate stands down on a local layer that is present but does not
parse, and deliberately does not stand down when there is no config at all:
nothing collects then, so a marker still on disk really is stranded. That
boundary (`errorKind !== 'config_missing'`, not `!ok`) had no test, so
widening the gate would have dropped the case silently. Adds one.
`readCentralPluginNames` is deliberately not filtered by `enabled`, unlike the
local set the caller builds: LLP 0185 #scope settles the org lane on the name
alone, and filtering would hand the operator a detach for an org-owned client.
Says so at the read, and scopes #not-configured-means-not-active to the local
layer so the two sections cannot be read against each other.
Co-Authored-By: Claude <noreply@anthropic.com>
@philcunliffe

Copy link
Copy Markdown
ContributorAuthor

Neutral review of PR #608, round 2 of 2 (head a70dc44)

Verdict: approve. Two small fixes applied in d6eaa7d; nothing blocking remains.

Round 1's five findings are all present and correct in a70dc44, and I re-derived
each of the claims they rest on rather than taking them on trust. The delta since
2015d8a is sound; what I found in round 2 is a coverage gap on the boundary of
the new gate and one doc ambiguity that could have led a later maintainer to undo
part of the fix.


Findings

1. The config_missing half of the new status gate had no test - low (coverage)

src/core/daemon/status.js:310 reads

constlocalConfigUnreadable=!localLoaded.ok&&localLoaded.errorKind!=='config_missing'

The !== 'config_missing' clause is load-bearing and deliberate: loadConfigFile
returns four failure kinds (config_missing, config_unreadable,
config_invalid_json, config_invalid_shape - src/core/config/schema.js:121,140,154),
and the gate is meant to catch the last three (a file that is there and does not
parse) while letting the first through, because with no config at all nothing
collects and the marker on disk really is stranded. Round 1's test covered only the
suppressed side. Verified the intended behaviour by hand against the real collector:

config_missing case -> config_missing, client_attached_not_configured

so the carve-out works, but nothing pinned it: simplifying the gate to
!localLoaded.ok was a green-suite refactor that would silently drop the case.

Fixed:a missing config still names the client its marker strands
(test/core/status-client-attached-not-configured.test.js:114). Mutation-checked:
with the gate rewritten to !localLoaded.ok, that file goes 4 pass / 1 fail on
exactly this test; restored, 5/5.

2. LLP 0185 §not-configured-means-not-active could be read against §scope - low (doc / honesty)

Round 1's fix #2 filtered enabled !== false out of the local configured set
(walkthrough.js:1101) and recorded it as "Both surfaces read a plugin entry with
enabled: false as inactive". But readCentralPluginNames (walkthrough.js:1063)
deliberately does not filter, and must not: §scope settles the org lane on the
plugin name alone, because a name is what makes the attach the reconciler's to
reverse. As written the two sections point opposite ways for a central entry
carrying enabled: false, and the "inconsistency" reads like a bug someone should
tidy up - the tidy-up being a one-word change that hands the operator a detach for
an org-owned client, i.e. the one thing the PR is most careful never to do.

Fixed: §not-configured-means-not-active is scoped to the local layer and
defers to §scope explicitly (llp/0185-...:93-102), and the precedence is stated
at the read it constrains, with a new @ref LLP 0185#scope [constrained-by]
(walkthrough.js:1059-1064). No behaviour change; the code was already right.


Round 1's deferred item, re-assessed as asked

FinaleSummary.attachedNotConfigured is never read by writeWalkthroughRunSummary.
Confirmed the ordering in a real attended hyp init: the warning prints in
runPickerFinale (walkthrough.js:1024-1037), then the daemon restart, then
writeWalkthroughRunSummary (wizard/index.js:199), then runWizardFirstLook,
which prints real query result tables, then the privacy narration. So the warning
is genuinely several screens above where a user's eye lands at the end of an
attended run.

My call: a preference, not a defect, and not worth spending this PR's last round on.
Three reasons:

  • The field is not dead. LLP 0185 §consequences asks that "the run's result carries
    what was printed", and it does: it is on the returned FinaleSummary, which is a
    public result surface (PickerWalkthroughResult.finale) and is what the new tests
    assert against. Nothing about that claim requires the summary printer to read it.
  • The fix is not mechanical. In runPickerWalkthrough (walkthrough.js:536) the run
    summary follows the warning almost immediately, so echoing there prints the same
    warning twice in one screen. Whether that duplication is worth the better placement
    in the wizard path is a print-contract judgement about two different callers, not a
    correction.
  • Nothing is lost or hidden meanwhile: the warning is printed, framed, with the
    detach commands, on every run that creates the state, and hyp status is the
    backstop for a run already closed. That is exactly the pair Unpicking a previously attached client in hyp init leaves it attached and can break it #604 asked for.

If the maintainer wants it in the summary, it is one line plus a sentence extending
LLP 0185 §warn-do-not-detach.

Option 1 (finale-side detach) is still open, and the PR remains honest that the
warning surfaces the breakage without preventing it. #604 listed detach first;
declining it in a bug fix is defensible and the PR argues it, not hides it.


Re-derived, not taken on trust

  • The gate cannot over-suppress the real repair. Every unreadable-config path
    (config_unreadable / config_invalid_json / config_invalid_shape) already
    emits its own diagnostic carrying the actual repair (status.js:330-346), and
    hasCentral already gated the branch off on joined hosts, so localConfigUnreadable
    only ever fires on the solo path it was written for. A legacy V1 config on disk
    loads as config_invalid_shape and is now correctly not answered with a detach.
  • No throw path from the finale's new read. LLP 0185 §consequences claims a
    failure degrades to "nothing stranded, never a wrong undo". It holds:
    buildWalkthroughClientDescriptorMap swallows discovery failure into an empty map
    (walkthrough.js:1381-1389), probeClientAttachFromDescriptor returns
    { attached: false, error } on every failure including a bad path
    (status.js:1081-1146), and readCentralPluginNames is wrapped. So the check
    cannot abort a finale between the config write and the daemon restart.
  • The two surfaces cannot disagree on a solo host.mergeConfigLayers returns
    effective = local verbatim when there is no central layer (merge.js:38-40), so
    status's activePlugins and the finale's configured set are built from the same
    entries; with a central layer, status's branch is off entirely.
  • Test strength, by mutation, not by green. Deleting the central-layer read from
    findAttachedNotConfiguredClients fails a client the central layer names is not stranded by an unpicking run (4 pass / 1 fail), so round 1's finding [codex] Add root tests and remove donor tree #3 test is
    load-bearing rather than vacuously green.
  • overall is untouched:client_attached_not_configured is a warning and is
    not in degradingKinds (status.js:802).
  • No other status test can be polluted by a developer's real $HOME. Only
    status-first-sync-hold.test.js deep-equals the whole diagnostics array, and it is
    the one round 1 isolated; the rest use .some(d => d.kind === ...). The
    status_diagnostics smoke already swaps in a fake HOME
    (hypaware-core/smoke/flows/status_diagnostics.js:69-76), so it is immune too.
  • README's diagnostics table is a curated subset, not an enumeration: it omits
    client_attach_stale, gateway_port_fallback, config_unreadable and others, so
    leaving client_attached_not_configured out of it is consistent, not an omission.
  • LLP 0185 is free and its refs resolve.origin/master tops out at LLP 0180;
    nothing else on the branch claims 0185. All eight @ref anchors in the changed
    files resolve to a real heading or explicit <a id>: 0031#central-layer-is-sacrosanct,
    0045#part-3-..., 0086#status-drift-diagnostic, 0139#repair-must-be-runnable,
    0185#warn-do-not-detach, #status-backstop, #scope, #not-configured-means-not-active.
    Both Extended-by: forward-refs (0086, 0180) point at real files.
  • Conventions: zero em dashes and zero trailing semicolons in the whole diff
    against the merge base; no @typedef, no inline import('...') types; the new
    FinaleSummary and StatusDiagnosticKind members are .d.ts interface members;
    the file-level @ref in the new status test matches existing practice
    (test/core/client-provenance.test.js:16).

Residual findings

None blocking. One item is left deliberately, classified as a preference:
FinaleSummary.attachedNotConfigured is not echoed in the closing run summary, so
in an attended hyp init the warning sits above the first-look query output rather
than beside the run's last words. Reasoning above; it is a print-contract choice
between two callers with different spacing, not a correctness gap, and the state is
still named at creation time and still caught afterwards by hyp status.

Results

checkresult
npm test (at a70dc44)3386 tests / 3385 pass / 0 fail / 1 skipped
npm test (at d6eaa7d)3387 tests / 3386 pass / 0 fail / 1 skipped
npm run typecheckclean, both commits
gate boundary, mutated to !localLoaded.ok4 pass / 1 fail (new test is load-bearing)
central read deleted from the finale4 pass / 1 fail (round 1's test is load-bearing)

Pushed as d6eaa7d.

@philcunliffe

Copy link
Copy Markdown
ContributorAuthor

Neutral triage (fix-loop bound hit at 2 rounds, head d6eaa7d284ad26a392879f4e73288787d280107d unreviewed): re-verified every finding from both review rounds against the committed tree rather than the review prose.

  • Round 1's 5 findings and round 2's 2 findings: all confirmed fixed. Ran the full suite fresh (npm test: 3387 tests / 3386 pass / 0 fail / 1 skipped, matching the PR's own numbers) and npm run typecheck (clean) at d6eaa7d, and spot-checked the localConfigUnreadable gate, the enabled !== false filter, the Extended-by: forward-refs, and that client_attached_not_configured stays out of degradingKinds.
  • LLP 0185 numbering: confirmed free relative to origin/master (no llp/0185-* or llp/0186-* file exists there).
  • Residual item (both rounds): FinaleSummary.attachedNotConfigured is never read by writeWalkthroughRunSummary, so in an attended hyp init the warning prints several screens above the run's last output (traced the actual call order: warning -> daemon restart -> run summary -> ~60 lines of first-look output -> privacy narration on the team path, with no pause between them). Independently re-assessed rather than accepting round 2's framing: classifying this as a preference, not a blocker. The message is complete and correct, prints before the daemon restart (the actual point of no return), survives in terminal scrollback, and is backstopped by hyp status on solo hosts; the managed-host gap is explicitly documented in LLP 0185 §status-backstop as a known, deliberate scope limit, not a hidden omission. No crash, data loss, security hole, or wrong repair command is involved, only prominence.

All residual findings are non-blocking. Opened #614 to track the deferred item so it doesn't get lost, since it does bear on the PR's stated purpose even though it isn't a merge blocker.

Not merging or readying this PR; that is outside triage's scope.

@philcunliffephilcunliffe added the neutral:approved neutral reviewed this and holds it for a maintainer merge (own or adopted PR; LLP 0025/0030) label Aug 4, 2026
@bgmcmullen
bgmcmullen merged commit 6ab0d99 into masterAug 5, 2026
9 checks passed
@bgmcmullen
bgmcmullen deleted the fix/issue-604 branch August 5, 2026 01:36
philcunliffe pushed a commit that referenced this pull request Aug 5, 2026
PR #608 was squash-merged as 6ab0d99, so this branch's copies of its
commits conflicted with the same content under new SHAs. Every conflicted
hunk that was #608's takes master; the branch keeps only its own work
(LLP 0188, writeAttachedNotConfiguredReminder, the wizard's firstLookShown
gated repeat, and its tests).
Co-Authored-By: Claude <noreply@anthropic.com>
bgmcmullen pushed a commit that referenced this pull request Aug 5, 2026
… every boot (LLP 0184/0186/0187) (#622)
* LLP 0184/0185: reconciler refused marker design, per issue #601
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.
* Renumber the refused-marker design 0185 -> 0186 (collision with #608)
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.
* LLP 0187: reconciler-refused-marker executable plan (8 tasks)
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.
* Widen action reconciler types for a terminal refused marker state
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
* T8: manual hyp attach re-arms a refused marker (LLP 0186/0187)
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
* The reconciler writes a terminal refused marker instead of retrying forever
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
* T7: hyp status surfaces terminal refused client actions (LLP 0186/0187)
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
* Add action_refusal.js: markActionRefused/isActionRefused convention
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
* action_attach.js: translate a marked refusal into a refused outcome
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
* T6: mark the Claude JSONC throw site as a permanent action refusal
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
* OpenClaw's ownership refusal is terminal, not a retried failure
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
* Scope the refused-marker re-arm so it stops eating done markers and dry 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>
* Re-arm a refused marker without eating the undo record it carries
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>
---------
Co-authored-by: test <test@test.com>
Co-authored-by: test <test@example.com>
Co-authored-by: Claude <noreply@anthropic.com>
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.

Unpicking a previously attached client in hyp init leaves it attached and can break it

2 participants

@philcunliffe@bgmcmullen