Skip to content

fix(anolisa): release displaced plugin tool names on adapter enable - #3228

Closed
Forrest-ly wants to merge 11 commits into
mainfrom
fix/AGE-7186-openclaw-adapter-enable-memory-handoff
Closed

Forrest-ly wants to merge 11 commits into
mainfrom
fix/AGE-7186-openclaw-adapter-enable-memory-handoff

Conversation

@Forrest-ly

@Forrest-ly Forrest-ly commented Sep 11, 2026

Copy link
Copy Markdown
Collaborator

Why

anolisa adapter enable agent-memory openclaw installs and activates memory-anolisa, but OpenClaw's bundled memory-core stays loaded — the loader exempts it from memory-slot exclusivity — and OpenClaw's plugin tool registry is first-wins. So the adapter's own memory_get / memory_search are dropped (plugin tool name conflict (memory-anolisa): memory_get) and reads answer from memory-core, which reports disabled:true for every ~/.anolisa/memory path (#3218).

verify_runtime passes, because memory-anolisa really is loaded; nothing in the enable path observes the name collision. Only the bundle's install.sh performed the hand-off, and OpenClawDriver::apply_enable builds its own argv and never runs that script — so the documented adapter-manager entry point left the bug fully reproducible (#3225).

What changed

Declare the hand-off in the component contract and let the built-in driver execute it:

  • Contract[adapters.openclaw].displaces names the bundled plugins an adapter displaces, each with the exclusive slot the plugin re-takes when restored. Publishing metadata only, like [[adapters.openclaw.config]]: every command still comes from the driver.

  • No self-displacement, no duplicates — a contract may not displace the adapter's own plugin, checked twice: once against the manifest's plugin_id, and again against the id the driver resolved from the bundle. That manifest key is optional, so the first check on its own compares against ""; without the second, a self-displacing contract would install and verify its own plugin, disable it, and still report success with an Enabled receipt. It also may not name one plugin twice, or promise one exclusive slot to two plugins. Each entry is well-formed on its own, so only a cross-entry check can see either, and nothing downstream sees two of them at once until the receipt is already on disk: a repeated id persists a receipt its own driver cannot consume (a cleanup_failed record that neither status nor disable can read), and a repeated slot fails silently — on disable the first restore makes that plugin the slot's owner, the guard then reads it as a third owner for the second one and steps aside, removing the receipt while the second plugin stays disabled by this adapter. All three gates run before any host call, on the dry-run and the real path alike.

  • prepare_enable — receives the validated prior receipt, the argument plan_enable already took; the Manager validates it before the hook runs, so a forged receipt cannot decide what gets re-confirmed any more than it could shape the plan. It verifies each declared id against the host before any mutation, then claims only the transitions that are actually this adapter's to undo. Three positive answers each mean something different: a readable plugins list that does not have the id means the contract names a plugin this host does not ship, and fails the enable right there — otherwise the adapter's own plugin installs and verifies first and the mistake surfaces as a plugins disable error afterwards, leaving a receipt whose restore cannot converge either; an explicit plugins.deny entry, or a restrictive plugins.allow that omits the id, means the operator turned it off by policy and the host would refuse the restore; the host's global plugins.enabled = false switch means it would refuse every restore; a persisted enabled = false means they turned it off by hand. That global switch is the strongest of them and the easiest to miss, because it says nothing about any individual plugin's own entry — reading only the per-plugin keys misses it entirely, and an operator who flips it after a successful enable would leave the driver retrying a restore that can never converge, keeping the receipt as a cleanup failure forever instead of recognizing it as released by policy. plugins disable exits 0 either way, so its own status cannot distinguish a transition from a no-op — these probes can.

    The two policy keys hold lists, and OpenClaw renders a list as pretty JSON — one quoted element per line inside []. Neither reading the driver already had can see that shape, and both fail in the direction that matters: a whole-token text search misses "memory-core", on the quotes and trailing comma, so a real denylist reads as naming nobody; a last-line reduction reads the closing ] as an empty answer, so a real allowlist reads as no restriction at all. Policy answers are therefore parsed as JSON first — bare array, {"value": […]} envelope, null, and tolerating a diagnostics preamble plus a key = echo — with a line reading only as the fallback for a host that answers in some other shape.

    That fallback is scoped to the key being queried, because a config get answer mixes the value with whatever the host printed first, and only the key says which is which. Reading every line as a value list turned

    reading policy...
    plugins.allow = null
    

    into an allowlist naming reading and policy...: no [ or { for the JSON reader to anchor on, the first line has no separator so its whole text looked like a bare value, and the second line's null was correctly vacant — leaving the preamble as the answer. policy_blocking_plugin then read that as restrictive, enable skipped the hand-off entirely (both plugins kept fighting over the tool names and the receipt recorded no ownership for status to check), and disable printed instructions to edit a key whose value is null while leaving the plugin disabled. So: a line with a separator counts only when its left-hand side is the queried key (any other left-hand side — warning: config not found — is prose and is dropped), and bare separator-free lines are read only when the host echoed no key at all. Tokens that cannot be plugin ids are dropped. Residual, stated plainly: a host that prints only prose, no value line, and still exits 0 would have its prose read as ids — every documented rendering is handled, that one is not, and shape alone cannot separate it from a genuine bare value list.

    Only positive evidence counts. A host that cannot answer plugins list or either policy key is not a host that says "absent" or "off": the claim still happens, because the two failure modes are not symmetric — skipping a claim strands the host with nothing behind the slot after disable, while an unwanted one costs an operator a single plugins disable. A vacant plugins.allow — unset, null, [] — is likewise not a restriction, since an allowlist gates non-bundled installs on many hosts and reading "unset" as "nothing is allowed" would switch this whole feature off wherever the key was merely never set.

  • apply_enable — runs plugins disable <id> only after the adapter's own plugin is installed, enabled and verified loaded, so freeing the names can never leave the host with no plugin behind the slot. It also re-confirms each claim immediately before mutating and releases the ones that no longer hold: install, config writes and runtime verification all run between prepare_enable's probe and the command, and the Manager's lock serializes ANOLISA against itself — not against an operator typing openclaw plugins disable, nor a framework update. A plugin somebody else turned off in that window makes plugins disable exit 0 having changed nothing, so the transition is not this adapter's; keeping the claim would have a later adapter disable re-open a plugin the operator had just closed. Somebody else has to be established, though, because this driver's own commands are among the candidates: OpenClaw applies exclusive slot selection on both plugins install (src/cli/plugins-install-persist.ts:28) and plugins enable (src/cli/plugins-cli.ts:476) at v2026.4.14, through applySlotSelectionForPluginapplyExclusiveSlotSelection, which points the slot for the selected plugin's kind at it and writes enabled = false for every other plugin of that kind. memory-anolisa and memory-core both declare kind = "memory", so by the time the re-confirm ran, the plugin it was about to disable was already off — of this enable's own making, with no operator and no framework update anywhere. Releasing on that deleted the only record that the bundled plugin was off: the receipt named no displacement, disable reported a complete cleanup and removed it, and the host was left with no memory backend at all and nothing that would ever restore one. The same write leaves a second trace — the slot naming the plugin it selected, which an operator's plugins disable never touches — so a bare false flag is attributed through it in four states, not two (slot_attribution): a readable slot naming this adapter's own plugin (Own) means the hand-off is this enable's and has already been performed, so the claim stays and the idempotent plugins disable still runs; a readable slot naming anybody else (Other) — another plugin, the displaced one, an off sentinel, or no selection recorded at all, which is what a bundle declaring no slot kind leaves behind — is positive evidence of a third party and releases the claim, so the takeover window above stays closed; a declaration that named no slot (NotDeclared) releases it too, and on evidence rather than on a missing probe, because DisplacedPluginSpec::slot is documented as "declare it when the displaced plugin re-takes an exclusive slot, omit it when it does not" and upstream selection only ever disables plugins of the selected kind — the ones competing for that kind's slot — so a plugin the contract says holds no slot is not one this enable could have turned off (keeping it would also restore it with no slot guard at all, the sentinel and third-owner protections being keyed on the slot); and a probe the host cannot answer (Unreadable) is the one state that is evidence of nothing, so the claim stays. Collapsing Unreadable into "not ours" let one transient config get failure take a destructive decision, deleting the claim over a host where this driver's own selection had just performed the hand-off; collapsing NotDeclared into Unreadable — the mistake made while fixing that, and caught in review — did the opposite damage, taking ownership of an operator's intervening disable for every correctly declared slotless displacement. Recognizing the hand-off is not enough either — it has to be recorded before the next fallible step, because mark_displacement_applied is what turns a claim into a restoration record, and it used to live only in apply_displacements: four steps later, and after verify_runtime. So record_slot_selection_handoffs runs right after plugins install and again right after this adapter's own plugins enable, both ahead of verification, marking and write-ahead persisting on positive evidence only (slot readable and naming this plugin and the flag reading off) and skipping entries already applied. Without it an ordinary failed verification left applied = false over a plugin the host had turned off for this adapter: restore_decision answered SkipNotApplied, disable told the operator "this adapter never disabled it", reported a complete cleanup, removed the receipt, and stranded the host with no memory backend and nothing recording why. Recording can still fail, though, and what is lost then is the record, not the hand-off: attribution needs a readable slot, so a probe the host cannot answer at that moment — or a process that dies before the persist — leaves applied = false over a plugin this adapter did turn off, and if verification fails afterwards apply_displacements never runs at all. Keeping the claim is not the same as recording the hand-off, and only the second makes disable hand the plugin back. So the same predicate, as one shared helper (handoff_performed_by_slot_selection) because three callers have to agree, is asked again where the entry is read: restore_decision restores an unapplied entry whose slot names this adapter's own plugin and whose flag reads off instead of answering SkipNotApplied, and status files it under its own bucket instead of "off, but not by this adapter". Those two could disagree before, and the disagreement was user-visible — one report disowning a hand-off the same run was performing. This also closes the crash residual documented above, for the same reason: the read side no longer depends on the write side having landed. plugins disable applies no slot selection (plugins-cli.ts:495), so the hand-off itself never moves the slot. The probe was deliberately not moved ahead of plugins install: that would widen the takeover window for every adapter and still attribute nothing, trading a deterministic bug for a bigger race. A claim carried over from a prior receipt is not re-confirmed at all — it is already off because of this adapter. For the claims this enable does make, displacement_probe records how it reached them: ClaimEnabled when the host positively said the plugin was on, ClaimUnverified when it could not answer. The first is this enable's own transition and is always re-confirmed. The second cannot be attributed by the probe alone, so the prior receipt settles it: an unverified claim is exempt exactly when a validated prior receipt for the same OpenClaw state directory already owns that plugin id — the gate preserve_reenable_facts uses, because ownership does not travel across instances. That exemption is deliberately narrow, and both of its edges were real defects. Too wide — exempting every unverified claim — reopens the takeover window through a transient probe failure: a first enable has no prior ownership to protect, so a false at apply time can only mean the plugin went off for a reason this enable never observed, yet the receipt would keep a claim for a plugins disable that changed nothing, and a later adapter disable would re-open a plugin the operator had just closed. Too narrow — re-confirming inherited claims as well — loses ownership: on a re-enable whose single prepare-time read happened to fail, the plugin is off because a previous enable of this same adapter turned it off; preserve_reenable_facts has already declined to re-add the fact because the fresh claim occupies the same resource; and re-confirming would read that false as "somebody else just closed it" and delete the claim from the replacement receipt. The prior receipt is then gone, the ownership is lost permanently, and a later adapter disable never hands the plugin back while still removing the receipt. Either way the fresh set travels through PreparedEnable, and the apply-side release fires only on positive evidence (AlreadyOff / NotOnHost) — an unanswerable re-probe is not evidence about who turned it off. The receipt also records whether the hand-off actually ran, as DisplacedPluginRef::applied. The Manager persists the whole receipt in front of apply_enable — deliberately, so a crash cannot leave a framework mutation with no receipt behind it — which means an enable that fails during install, config writes or runtime verification leaves a cleanup_failed receipt naming a displacement whose plugins disable was never issued. Keeping the entry is right, because a retry needs the declaration; reading it as ownership is not, because a later adapter disable would then restore a plugin this adapter never displaced, undoing one the operator closed themselves in between and leaving nothing behind to show why. So apply_displacements marks the entry at the same write-ahead point it already persisted at, immediately before the command: a crash before it leaves the entry unapplied and restores nothing it should not, a crash after it leaves the entry applied and does not strand the host with a disabled plugin nobody owns. Restore, its dry-run preview and status all consult the mark first, and restore_decision gained a SkipNotApplied branch so the preview and the real disable cannot disagree about it.

Consulting the mark is not enough on its own, because a re-enable inherits. The three sets that decide what a later operation may rely on — the ids prepare_enable treats as already owned, the ones plan_enable previews as carried over, and the entries preserve_reenable_facts copies into the replacement receipt — are therefore all built from the applied subset. Inheriting an unapplied id is not a harmless duplicate of the read-side refusal, because the inheritance is exactly what suppresses the re-confirmation: the entry arrives in the new receipt without any probe of this host having produced it, so it is in neither freshly_claimed nor the probe's output, so apply_displacements skips it, marks it applied, and runs a plugins disable that exits 0 having changed nothing. One re-enable is then enough to launder an intent into an ownership, and the disable that follows re-enables a plugin the operator closed between the two enables; the dry-run promised it beforehand, since plan_enable checked the carried set before probing. Declining to inherit costs nothing: the entry falls through to dropped_displaced_plugins, cleanup_replaced_claim reports it as never performed (through the same SkipNotApplied branch), and this enable's own probe decides whether to claim the plugin afresh. The field defaults to applied and is omitted from the wire when true, so a receipt written before it existed keeps its ownership and an applied receipt serializes exactly as it always did.

When this enable probed the plugin again it has already written its own entry for the same resource, so preserve_reenable_facts has nothing to add — but the prior entry may be the only record that the hand-off ran, and the fresh one is written unapplied because prepare precedes every mutation. Dropping the prior's mark costs real ownership whenever the re-enable fails before reaching apply_displacements (install, config or runtime verification): the receipt survives as cleanup_failed, restore_decision reads the entry as never performed, and disable refuses to hand back a plugin the first enable really did disable — then removes the receipt, so nothing records the loss.

So the entry is written with the mark it should already carry, and prepare_enable decides it, because that is the only scope holding both halves of the question: the prior receipt and what this round's probe concluded about it. Exactly one branch inherits — an unverified claim the same instance's prior receipt already owns, i.e. the one case where this enable carries an older hand-off forward instead of establishing one of its own. A merge inside preserve_reenable_facts cannot make that distinction: the hook receives the receipt but not PreparedEnable, and the receipt carries no transient attribution on purpose, so anything merged there can key only on the resource match. That gets the opposite direction wrong too, and symmetrically so — when the operator re-enabled the plugin themselves, this round's probe is a positive ClaimEnabled, the old ownership is void, and inheriting its mark makes the cleanup_failed receipt claim a hand-off this round never reached, after which adapter disable undoes a manual disable the operator made in between. This is also what makes the enable-side exemption honest: an unverified inherited claim declines to re-confirm because that ownership "is about to be carried into the replacement", and carrying the entry without its mark would make that promise false.

A displacement's identity is the plugin, not the resource id that handles it, so the inheritance check in preserve_reenable_facts asks which plugin ids the replacement already claims — the same key prepare_enable's attribution and the duplicate-claim rejection both use, and the same key dropped_displaced_plugins has always used. Keyed on the resource id it missed a renamed one: prepare_enable wrote the canonical resource for the plugin, the prior's renamed entry was added beside it, and every re-enable died in cleanup_replaced_claim on "claimed more than once". That failure really was before any mutation, but it was on every attempt, for a receipt status and disable consume normally — a permanent block whose only way out was to disable first.

A resolved displacement likewise carries the resource id the receipt actually names rather than re-deriving the canonical one from the plugin id. Validation requires the reference to resolve to a framework-plugin resource of this framework whose plugin_id matches, but not that its name be canonical, so a consistently renamed receipt passes every check — and the helpers that rebuilt the id then looked up a resource the receipt does not contain. That is not a fail-closed rejection: mark_displacement_applied runs after apply_enable has persisted the new receipt, installed this adapter's own plugin and enabled it, so the error lands mid-mutation. status failed more quietly still, returning no resource reference for the single-displacement case the reference exists to serve. This narrows the window rather than closing it: OpenClaw offers no conditional write, and closing it properly needs a framework-level lock the CLI does not expose. Write-ahead persisted through EnableProgress like the config journal; a failure fails the enable and leaves a retryable cleanup_failed receipt rather than an adapter that reports healthy while its tools are shadowed.

  • disable — restores exactly what the receipt claims, treats a plugin the host has since dropped or blocked by policy as released rather than failed (a restore that can never succeed would otherwise strand the receipt forever, with this adapter's own plugin already uninstalled, over a cleanup with nothing left to do), and steps aside when plugins.slots.<slot> has since been given to a third plugin or closed outright. plugins enable re-runs the framework's exclusive slot selection, so either case would undo a choice the operator made after enable: one drags the slot back from the backend they picked, the other re-opens a slot they deliberately turned off. none is that off sentinel, and it shares its off-word list with the enablement probe so one host's rendering of "off" classifies identically whichever key it is read from. A recognized owner (this adapter's plugin, or the displaced plugin itself) is never re-read as a sentinel. An empty or unanswerable read still restores — the slot is then this adapter's or nobody's, and skipping it would leave the host with nothing behind the slot. A failed restore reports through cleanup_complete so the receipt survives and disable stays retryable — and a restore that succeeds — or that the host vetoes, which is a release the report itself announces in those words — is struck from that receipt on the spot, which is why disable and cleanup_replaced_claim take the claim mutably. The one entry that stays is one whose hand-off never ran: it was never ownership, no host state can make a retry act on it, and it is the receipt's only record of a plugin that is enabled and unclaimed. Cleanup can fail partway: a materialized skill directory replaced by a plain file makes remove_tree fail with ENOTDIR (which no uid bypasses), and that sets cleanup_complete = false without returning early, so the displacement restores still run and the Manager still keeps the receipt for retry. An entry left in that receipt would record a transition already undone, and the retry would perform it a second time — re-enabling a plugin the operator closed themselves in between, with nothing left to show why. DisableReport cannot express the split (one bool plus prose), so the claim is the channel; the Manager re-persists the mutated claim on both paths, the kept receipt after a partial disable and the prior receipt when a re-enable's cleanup is the incomplete one. It persists on the error path too, which is the half no report can cover: a driver that errors out mid-cleanup — a CLI that stops being spawnable between two restores — returns no DisableReport at all, and a bare ? used to reach the caller before either branch ran, so the releases already performed died with the in-memory claim. Both sites persist first, and only when the driver really changed something, so an error raised before its first mutation still leaves the receipt byte-for-byte as it was. This is the disable-side half of the invariant the re-enable ordering above states: a successful release must be followed by a durable record of it, and the driver has no way to write one except through the claim it is handed.
  • Re-enable — inherits only the ownership the contract being enabled now still declares, takes the slot from that declaration, and only when the prior receipt was written against the same OpenClaw state directory. Inheriting everything is as wrong as inheriting nothing: the fresh probe contributes no fact (the plugin is already disabled by this adapter), so a component upgrade that dropped the declaration, replaced the plugin, or moved it to another slot would be undone right here, same-home cleanup would have nothing to release, apply_enable would disable the old plugin again, and the new contract could never take effect. But a different OPENCLAW_STATE_DIR is a different registry, where the operator may have disabled the same plugin themselves — prepare_enable correctly declines to claim that, and inheriting would claim it anyway: the old instance gets restored, the new one gets disabled, and a later disable re-enables a plugin the operator had closed. The receipt already records which directory it was written against (OpenClawClaim.state_dir_resource), so no new field is needed. cleanup_replaced_claim hands back whatever is not inherited — from the prior receipt's own home, in full, when the migration crosses directories — while that receipt is still the durable record of why the plugin was off.

That hand-back is a host mutation whose only durable record is the receipt swap that follows it, so the Manager prunes stale materialized output before calling the driver and not after, leaving nothing fallible between the release and the swap. It used to sit in between, and failing there does not take an exotic host: the prune removes a stale materialized directory with a non-recursive remove_dir, so a single runtime-created file inside it is enough. The re-enable then returned ReenableCleanupIncomplete with plugins enable memory-core already issued and the prior receipt still on disk claiming applied ownership of it — so a working adapter immediately reported a tool-name collision it did not have, and an operator who then disabled the plugin themselves had it re-enabled again by a retry reading that stale ownership. The two steps are independent (one touches only ANOLISA's own materialized output under the datadir, the other only runs framework CLI verbs), so the reorder changes what a failure leaves behind rather than what either step does: a prune failure now happens with the host untouched and the prior receipt still describing it exactly, while a restore failure still happens with the prune done and the prior receipt durable. What remains is a state.save failure immediately after a successful restore; that window is not closable from here, because recording the release before performing it would strand the host if the process died in between — the error this codebase consistently ranks as worse — so it is documented in both the Manager and the driver rather than hidden. The dry-run lists the two steps in the same order the real re-enable runs them. preserve_reenable_facts therefore receives the DriverCtx carrying the current contract.

  • Every receipt probe reads the instance the receipt namesclaim_state_dir(claim), not the caller's environment. OPENCLAW_HOME and OPENCLAW_STATE_DIR can both have moved since enable and the Manager still validates such a receipt, so probing the caller's directory reports on a host the receipt never touched: with OPENCLAW_HOME=A retained and OPENCLAW_STATE_DIR=B overriding it, a B that happens to look clean hides a collision already restored in A. disable resolved it this way already; status now agrees. Which directories a prior receipt may name at all is the Manager's trust boundary — openclaw_allowed_roots admits only the current and legacy resolver roots, and validate_with_trust rejects the receipt before any driver hook runs — so an arbitrary A→B move fails closed at claim validation and never reaches the migration code. That is pinned by a test rather than left implicit, because widening it would mean trusting a state-file value as an external root: a trust-model change, not a driver fix.

  • Receipt integrity first — a receipt whose displacement references do not resolve (dangling, mistyped, wrong purpose, another framework's plugin, aimed at this adapter's own plugin, duplicated, or naming an empty or twice-claimed exclusive slot) is rejected before the first action of both status and disable, and before a re-enable plans or inherits from it. The slot rules the Manager applies to a contract are re-applied to the receipt, because the receipt is what disable consumes and it can be edited without the contract ever being re-read: two entries sharing one exclusive slot are not merely redundant — disable restores the first, the guard then reads that plugin as a third owner for the second and steps aside, and the receipt is still removed as a completed cleanup, orphaning a plugin this adapter disabled. slot = "" is rejected on both sides too, since validate_config_key only rejects an empty whole key and plugins.slots. is not empty; omitting slot is how a contract asks for an unguarded restore. Resolving late would let a corrupt receipt report Healthy and then let disable uninstall the adapter's own plugin before it noticed it cannot name what to hand back; a reference to an arbitrary FrameworkPlugin would otherwise drive an unintended plugins enable.

  • The adapter's own plugin is resolved by reference, not by positiondisable, status and the migration preview now follow OpenClawClaim.plugin_resource and cross-check purpose, framework and the top-level id, instead of taking "the first FrameworkPlugin in resources". That definition stopped being safe once a receipt could legitimately carry two: resources is a set keyed by id, so reordering it is a hand edit every generic check accepts, and it used to send plugins uninstall at the displaced plugin while keeping the adapter's own.

  • --dry-run — both plans read the receipt, not only the host, never promise less than the operation does, and their wording matches the branch the real operation takes. plan_enable receives the receipt it would replace, so a re-enable plan can report a displacement that receipt carries over — but only where the host does not contradict it, and only after the checks the real enable makes. Ownership alone is not enough: prepare_enable always probes, so when the operator turned the plugin back on after the last enable, the real enable reads a positive ClaimEnabled, treats that ownership as void, claims the plugin fresh and disables it again. A plan that short-circuited on ownership kept promising "it stays claimed and disable will hand it back", hiding a host mutation that overrides what the operator had just chosen; it now probes every declared id and names that case separately ("disable … again: it was re-enabled after the receipt being replaced displaced it, so this enable takes the tool names back"). Skipping the probe must not skip the existence check, though: prepare_enable probes unconditionally and fails the real enable when the host's inventory no longer has a declared id, so a carry-over promised for a plugin a framework upgrade has since removed described an enable that cannot run — and this PR's own rule is that a preview naming a different condition than the real path applies is worse than no preview. Both sides now answer "a readable inventory omits it" through one shared inventory_omits_plugin, and the plan reuses the inventory it already read, so the check costs no extra host call; an unreadable inventory is still not an omission, the same rule the enable-side claim gate applies; plan_reenable_cleanup lists the restore of a displacement the new contract dropped, a mutation plan_enable structurally cannot show because it only walks the current contract, yet cleanup_replaced_claim really performs first. A disable plan lists each restore with the branch the real disable will actually take, and so do the dropped-displacement and migration plans: all three ask the same restore_decision the real restore asks, through a new plan_disable_restores driver hook, so a preview cannot describe a veto the operation doesn't apply or omit one it does. There are six outcomes, not two — hand back; step aside because the plugin left the host's inventory; because the host's global plugins.enabled switch is off; because plugins.deny or a restrictive plugins.allow keeps it off; because its slot was closed explicitly; or because a third plugin owns the slot now. Composing that text in the Manager instead described only the two slot vetoes and called a slotless restore unconditional, which the inventory and policy vetoes are not (neither looks at the slot). That was worse than imprecise wording: every veto still counts as completed cleanup, so the receipt was removed afterwards and the operator was left with nothing to notice the divergence with. A migration plan lists the restores it performs in the prior home, which nothing else walks.

    An enable plan cannot call restore_decision — that reads the host as it is now, while the plan describes a disable that has not happened yet, and rendering a present-tense prediction as a future promise would be a new way to be wrong. So it enumerates the same branch set through one shared restore_conditions_note, used by all three of its branches (claim, carry-over, and the migration/dropped variants) and enumerating every RestoreDecision::Skip* — inventory, global switch, denylist, allowlist, slot closed, slot owned by a third plugin. And no published surface calls a slotless restore "unconditional" any more — the contract rustdoc (DisplacedPluginSpec::slot), the receipt-schema rustdoc (DisplacedPluginRef::slot), the empty-slot rejection message and the generic planner fallback all said it, and all four now say what is true: omitting slot only removes the slot from what is consulted, because the inventory and policy vetoes never looked at it. And a new validate_claim driver hook runs on the disable dry-run as well as the real path, so a corrupt receipt no longer receives a successful plan for an operation that will fail.

  • status — verifies the displacement as a new DisplacedPluginsReleased condition and degrades when a bundled plugin was re-enabled behind the adapter's back: the collision is back, and this adapter's own tools have stopped answering, while PluginRegistered / PluginResourcesLoaded still read clean. The verdict is decisive only where the evidence is, and the first question is not the enablement flag but whether the plugin can hold the tool names at all — the same inventory and policy questions, in the same order, that the restore branch asks, factored into one displacement_block both call so the two sides of a receipt cannot drift. A plugin a framework upgrade removed from the host cannot shadow this adapter's tools however its own plugins.entries.<id>.enabled reads; reading only that key reported a collision and degraded the whole adapter over a hand-off nothing was contesting, while disable was correctly treating the same host as having released it. Neither absence nor policy counts as released, and the reason is the same for both: each is evidence about what a freshly spawned CLI sees and about what the config asks for, and neither establishes that the gateway already running stopped serving those tools. A removal leaves a module loaded before it loaded still — which is why OpenClaw's own plugins uninstall asks for a gateway restart (plugins-cli.ts:623-626) — and a policy write depends for its effect on the host's plugin reload mode, which this driver cannot read (gateway.reload.mode = off leaves the running process untouched, per src/gateway/config-reload.ts). Filing either under released rolled a recorded hand-off from Unknown up to Healthy on the strength of one cold read, with a reason saying nothing needed doing — the false all-clear this condition exists to prevent, arriving exactly when the operator may still need to restart the gateway or check a real tool call. Both are therefore their own buckets reporting Unknown, and each reason says both halves: what rules the plugin out (nothing to re-disable, no route back without a reinstall; or the named policy key) and why that does not clear the running gateway, with the one settling check named — a real tool call, never plugins list / plugins inspect, which read the same persisted state the verdict came from. Both are still not False, which is what the original complaint was about: a plugin the host will not load is not one the operator can act on. The consequence, stated plainly: with no gateway channel every state a displaced plugin can be in is either a collision or unobservable, so this condition no longer reports True for a receipt that has displacement entries. The True arm is kept with a comment saying so — it is what a future bucket with positive evidence would reach, and keeping it explicit means adding one cannot silently change today's verdicts. The restore branch still treats both as released, because the question there is different — can this be handed back at all — and for a plugin the host does not have, or a policy that would refuse the plugins enable, the answer is no and no retry can change it; displacement_block's doc now says that the two callers share the facts and differ in the verdict on purpose.

Both questions are asked of every entry, including one whose hand-off never ran. applied is provenance — who turned the plugin off — and cannot stand in for host state: an entry this adapter never disabled is more likely to still be on, not less, since taking the names off it was the entire point of the enable that recorded it. Filing it under "nothing to verify" made the condition True, and because the Manager saves the receipt before apply_enable and rewrites it to cleanup_failed only when apply_enable returns an error, a hard exit in between (SIGKILL, OOM, power loss) left Enabled plus an unapplied entry on disk: this adapter's own plugin installed and verified loaded, the bundled plugin still enabled and still winning the framework's first-wins registry, and summarize reporting Healthy — the false all-clear this condition exists to prevent, reached through the one receipt state no clean failure produces. The bucket's own reason text had already said "this adapter never disabled it"; the status computation argued the opposite. So an unapplied entry the host reports enabled counts as a collision (False), and one it reports off counts as Unknown with the provenance stated — which is three-way, because "off, and not by this adapter" and "off, by this adapter's own slot selection, with the record lost" lead to different disable behavior and must not read the same. Nothing the persisted state can say counts as a release; see the absence/policy paragraph above. For a plugin that survives both questions, config saying enabled settles it: the plugin is not displaced whether or not the gateway has caught up, so that reports False and degrades the summary while every other condition still reads clean. Config saying disabled reports Unknown, not Trueplugins disable only writes config, and when the running gateway picks that up depends on the host's plugin reload mode, so that proves the hand-off was recorded and nothing about whether it took effect, and this driver has no channel to the running gateway. Reporting Healthy there would tell the operator the hand-off worked while their memory_get is still answered by the bundled plugin, i.e. it would make status the false all-clear this condition exists to prevent.

That Unknown is permanent, and the reason now says so. An earlier wording called the restart "what would settle it" and the user guide said "restart and re-check", but no state change in the driver corresponds to a restart: plugins.entries.<id>.enabled does not change when the gateway restarts, so the re-check returns the identical verdict and the operator loops with no exit. The reason now names openclaw gateway restart as what makes the hand-off take effect, states that restarting will not change the verdict, and points at an actual tool call as the way to confirm, because that is the only check that travels through the running gateway — and names no particular tool. displaces is a generic contract any adapter may declare for its own colliding tools, and DisplacedPluginSpec carries no tool names, so a driver hardcoding memory_get would send every other adapter's operator after a tool it does not register and could not use to confirm anything; this suite's own tokenless fixture was what pinned that mistake in place. Naming the concrete tool belongs in each component's documentation, which is what the agent-memory user guide does. It deliberately does not point at openclaw plugins list or plugins inspect: both read the persisted registry and config, so after enable and before a restart they show memory-core disabled while the old gateway may still be serving its tools — a false confirmation, worse than offering no check at all, because it moves the operator from "unknown" to "believes it is fixed". Both locales of the user guide say the same and warn against both commands explicitly. Turning this into a decisive verdict needs a gateway tool-catalog channel (tools.catalog / tools.effective). The transport primitive exists — FrameworkRpcSession plus DriverOps::run_framework_rpc, already consumed by codex.rs against codex app-server --stdio — but it spawns a stdio child rather than attaching to a running daemon, and nothing in this repo names an OpenClaw gateway session, its method spelling, its provenance field, or how "gateway down" differs from "tool still held". Those four are asked for in the review thread; guessing them is how an earlier revision of this PR shipped a probe that read config back and reported Healthy through the very window it existed to catch. An unreadable enablement key is a third, distinct answer with its own reason, so the operator is pointed at the key rather than at a restart that would not help.

Every bucket is reported, not just the worst one. A receipt may displace several plugins and each can be in a different state, so a chain that formatted only the winning bucket silently dropped the others and left the operator to discover them one re-run at a time. The status still takes the worst verdict, and with a single displaced plugin the condition also carries a resource ref, so a machine consumer need not parse the prose to find which plugin it is about.

The reason also no longer claims a restart is what makes the hand-off take effect. Whether one is needed depends on the host's plugin reload mode — a mode that hot-reloads plugins.entries.* applies the change by itself — and this driver cannot read that mode, so an unconditional "restart to activate" would buy every hot-reloading host an unnecessary gateway interruption on every hand-off. The wording is conditional in the reason and in both locales of the user guide. Remediation is spelled out per plugin — one plugins disable command and one real config key each — because a receipt may displace several, and openclaw plugins disable a, b passes one malformed argument while plugins.entries.a, b.enabled is a key that has never existed.

  • Guards — a skill_bundle adapter rejects the declaration instead of silently no-opping it; ids go through validate_plugin_id and slots through validate_config_key (they become plugins.slots.<slot>) in the Manager before any probing, and the driver re-validates before building argv.

Schema version

DRIVER_SCHEMA_VERSION moves to 4 for the new optional OpenClawClaim.displaced_plugins (id references plus the slot). The field is #[serde(default)], so version-3 receipts parse unchanged.

That constant is the version a driver writes, so it must not double as a read gate — an earlier revision of this PR claimed no read path gated on it, and that was wrong: qoder.rs::native_install_confirmed did exactly that, so the bump silently revoked the install ownership of every schema-3 Qoder receipt on disk (disable would refuse to uninstall a plugin ANOLISA installed and keep the receipt forever; a re-enable would re-record that plugin as somebody else's pre-existing one and then drop the receipt while leaving the plugin behind). Qoder now gates on a driver-local QODER_INSTALL_CONFIRMED_MIN_SCHEMA = 3 — the version that introduced plugin_install_confirmed — and the constant's doc says why it is write-time only.

Docs

The hand-off is the driver's contract. The bundle's own install.sh / uninstall.sh script entry point does not read the declaration and performs no hand-off (that is #3222, still open), so the user guide, the public DisplacedPluginSpec rustdoc and both contract copies now scope the claim to anolisa adapter and tell a script install to run openclaw plugins disable memory-core itself, rather than documenting behavior the scripts do not have. This also removes the merge-order dependency on #3222: either PR can land first.

The component's own two READMEs needed the same correction and did not get it until round 21. They are the first page anyone installing agent-memory reaches, they led with install.sh, and they never mentioned memory-core at all — so following that Quick Start installed the plugin and left the collision exactly where it was, which is the gap the user guide explicitly warns about. src/agent-memory/README.md and README_zh.md now lead with anolisa adapter enable agent-memory openclaw / adapter status, state what the first-wins registry does to this plugin's own tools while memory-core stays loaded, and keep the script path with the openclaw plugins disable memory-core step it requires (annotated # the script does NOT do this) plus the undo command. The reload-mode conditionality of gateway restart is stated as the guide states it, not unconditionally. Both languages carry the same content and the same commands byte-for-byte; only the inline comments are localized. docs-lint.sh checks naming and the docs/user-guide en/zh tree parity but not README parity, so that consistency is asserted by hand here rather than by a gate.

The guide also states what the hand-off costs, since it happens on every enable and is not an edge case: disabling memory-core removes that plugin's whole surface — its own memory_* tools, the openclaw memory command, and the background dreaming/consolidation lifecycle it runs — for as long as agent-memory is enabled. agent-memory covers retrieval meanwhile, but it reads and writes ~/.anolisa/memory, not memory-core's store, so data that plugin already accumulated stays untouched and out of reach until it is re-enabled, and the two stores are not merged. Recovery is adapter disable (subject to the slot guard) or openclaw plugins enable memory-core, plus openclaw gateway restart.

agent-memory declares memory-core on the memory slot in both contract copies (src/anolisa/manifests/components/agent-memory/component.toml and src/agent-memory/.anolisa/component.toml.in).

Related issue

Fixes #3225. Completes #3218 for the adapter-manager entry point; the script entry point is #3222.

Test

crates/anolisa-core/tests/adapter_manager.rs (192 cases) — the fake CLI persists the enablement flag plugins enable / plugins disable change, and applies OpenClaw's exclusive slot selection on plugins install / plugins enable for a bundle or bundled plugin that declares a slot kind (data-driven, so fixtures declaring none behave exactly as before), so a re-enable dry-run reads the host a real disable actually leaves behind. Its plugins inspect derives status from that persisted config and is now documented as doing so on purpose: that is what the real command does, and a driver must not read gateway liveness from it. The FAKE_OC_PENDING_RESTART knob an earlier revision added was removed with the probe it served — a fake that models an assumption is how that assumption stayed green. An argv-log helper scopes each assertion to the step under test, since the log is cumulative.

Contract rejection, all asserting an empty argv log, no receipt, no registry marker and no plugin left disabled: enable_rejects_self_displacement_resolved_from_the_bundle (manifest plugin_id omitted, bundle id equal to the declared displacement → rejected on both the dry-run and the real path), enable_rejects_self_displacement_against_a_declared_plugin_id (the declaration-time gate), enable_rejects_a_duplicated_displacement_id_before_touching_the_host (both modes), enable_rejects_two_displaced_plugins_sharing_one_exclusive_slot, enable_rejects_a_displacement_declaring_an_empty_slot (both modes).

State-directory migration, over a second OpenClaw instance where the operator disabled memory-core themselves: migration_does_not_inherit_displacement_ownership_the_new_home_never_had (old home's displacement handed back where it was taken, the operator's disable in the new home survives, the migrated receipt claims nothing, and a later disable never runs plugins enable memory-core) and migration_dry_run_plans_the_prior_homes_restore_not_a_carryover (the plan names the prior home, shows no carry-over of a claim made elsewhere, and mutates nothing).

Slot constraints on the receipt side: a_receipt_sharing_one_slot_between_two_displacements_is_rejected and a_receipt_with_an_empty_slot_suffix_is_rejected — each over status, disable --dry-run and the real disable, asserting one shared rejection reason, zero appended argv lines, both plugins still in the state enable left them, and the receipt kept for repair.

Effective inventory and policy: enable_does_not_claim_a_displacement_an_explicit_deny_list_blocks and ..._a_restrictive_allow_list_excludes (enable succeeds, no plugins disable, the receipt claims nothing, and disable then converges), enable_still_claims_a_displacement_when_the_allow_list_is_unset (vacant "" / [] / null / none are not restrictions — the hand-off still happens), enable_fails_before_installing_when_the_displaced_id_is_not_on_the_host (both modes; no real plugins install, nothing disabled, no receipt), enable_still_claims_when_the_inventory_probe_cannot_run, disable_treats_a_policy_blocked_restore_as_released and disable_treats_a_vanished_displaced_plugin_as_released (both cleanup_complete, receipt removed, nothing stranded, message names the policy key and the manual undo).

Policy answers in the host's own rendering, through the full enable→disable path: enable_and_disable_honor_a_pretty_json_deny_list and enable_and_disable_honor_a_pretty_json_allow_list (multi-line JSON; no plugins disable, nothing claimed, disable converges), enable_still_claims_when_a_pretty_json_allow_list_names_the_plugin (parsing must not over-correct into silently switching the hand-off off), enable_honors_a_pretty_json_policy_list_behind_a_key_echo (diagnostics preamble + key = echo, both keys), enable_still_claims_when_a_json_policy_list_is_empty_or_null ([] / [ ] / null × both keys). Plus unit coverage of the reader itself for pretty, compact, enveloped, preambled, bare-word, comma-separated and multi-line non-JSON answers.

Preamble handling, through the full chain and not just the parser — because the damage is the decision policy_blocking_plugin makes, not a wrong Vec<String>: enable_claims_the_displacement_a_preamble_plus_null_allowlist_does_not_forbid (the reported failure: hand-off still happens, ownership is recorded so status has something to check, disable restores and never mentions plugins.allow), enable_claims_the_displacement_a_preamble_plus_null_denylist_does_not_forbid, and enable_honors_a_real_deny_entry_behind_a_diagnostic_preamble (the fix is about which line carries the value, not about ignoring the key). Unit-level: policy_list_does_not_read_a_diagnostic_preamble_as_ids (preamble × null / [] / empty / NULL / unset / -, both keys), policy_list_ignores_a_foreign_key_echo (warning: config not found, and a real echo winning over prose on another line), policy_list_treats_every_vacant_spelling_as_vacant (13 spellings, case-insensitive).

Config vs. gateway: status_does_not_report_healthy_on_a_recorded_but_unverified_handoff (recorded hand-off → Unknown, never Healthy, reason names both the restart and what is known), status_is_decisive_when_config_says_the_displaced_plugin_is_back_on (FalseDegraded), status_separates_an_unreadable_probe_from_a_recorded_handoff (distinct reason naming the key, and not the restart). The recorded-hand-off case also pins the reason's advice, in both directions: it must say a restart does not change this verdict (unknown here means unobservable, not pending a restart), and it must point the operator at a check that actually travels through the running gateway — a real tool call, in the reason's own words "call one of this adapter's own tools that '' also provides and see which plugin answers". The two cold probes are forbidden by assertion, not merely omitted: reason must contain tool call and must not contain plugins list or plugins inspect (am:7593-7613), because both read the persisted registry and config this verdict was itself derived from, so before a restart they show the bundled plugin disabled while the old gateway may still be serving its tools — a false confirmation, worse than offering no check at all. So neither the restart promise nor a cold-probe confirmation can quietly come back.

Per-plugin remediation: status_gives_one_remediation_command_per_displaced_plugin (two plugins re-enabled → a separate runnable openclaw plugins disable <id> for each, and no joined disable a, b) and status_names_one_real_config_key_per_unverified_displacement (both real keys named, no fabricated joined key).

Resource order: disable_uninstalls_its_own_plugin_even_with_receipt_resources_reordered swaps the own-plugin and displaced resources in the persisted receipt — a legitimate hand edit every generic check accepts — and asserts disable uninstalls tokenless, never memory-core, hands the displaced plugin back, and completes.

Re-enable under a changed contract: reenable_releases_a_displacement_the_new_contract_dropped (declaration removed → plugins enable memory-core runs, marker cleared, replacement receipt claims nothing and keeps no displaced resource), reenable_swaps_a_displacement_the_new_contract_replaced (old plugin handed back, new one claimed), reenable_takes_the_slot_from_the_current_contract_not_the_prior_receipt (carried slot is memory2; disable then consults plugins.slots.memory2, not the prior plugins.slots.memory).

Corrupt receipts, over three forgeries (dangling reference, reference to the adapter's own openclaw_plugin resource, the same reference twice): status_refuses_to_report_a_corrupt_displacement_receipt (errors with BundleInvalid, never Healthy), disable_mutates_nothing_for_a_corrupt_displacement_receipt (both disable --dry-run and the real disable reject it — the dry-run without a single host call, the real one with an empty appended argv log, own plugin still registered, displaced plugin untouched, receipt kept for retry), and enable_dry_run_rejects_a_corrupt_displacement_receipt_before_probing.

Preview wording pinned against the real branch: on the enable side enable_dry_run_preview_names_every_restore_veto — the shared note must name the inventory and both policy vetoes for a slotful and a slotless declaration, add the slot vetoes only for the slotful one, and never say "unconditional". On the disable side, disable_preview_agrees_with_the_real_restore (slotful) and disable_preview_agrees_for_a_slotless_displacement (no unless clause, and the real disable agrees), plus four more pairings for the vetoes that release rather than restore: disable_preview_agrees_when_policy_blocks_the_restore, disable_preview_agrees_when_the_displaced_plugin_has_vanished, disable_preview_agrees_when_the_slot_blocks_the_restore, disable_preview_agrees_when_plugins_are_globally_disabled. A test that once asserted the opposite contract — that a slotless restore is unconditional — was deleted when that wording was removed; restore_conditions_note_covers_every_veto now asserts the note does not contain "unconditional", so it cannot come back.

Restore previews are tested as preview-vs-real pairings, not as wording — which is what keeps them from drifting again. assert_disable_preview_matches_real runs disable --dry-run, then the real disable, and asserts the preview's prediction about handing memory-core back equals what the argv log shows actually happened, plus that the expected direction is the one both agreed on (so a test cannot pass by both being wrong). Scenarios: plain restore, slotless declaration, plugins.deny added after enable, plugin removed from the host's registry, slot given to a third plugin, slot explicitly closed. On the re-enable side, reenable_preview_agrees_with_the_real_dropped_restore pairs the dropped-displacement plan against the real re-enable for both a slotful and a slotless declaration, and reenable_dry_run_lists_both_halves_of_a_replaced_displacement checks a replacement contract plans both halves. disable_dry_run_predicts_without_mutating pins that a plan performs nothing it announces.

Enable-side previews get the same treatment. enable_dry_run_preview_names_every_restore_veto asserts the plan names the inventory, plugins.deny and plugins.allow vetoes for both a slotful and a slotless declaration, adds the slot vetoes only for the slotful one, and forbids the word "unconditional" outright. The reviewer's repro is reenable_preview_does_not_promise_a_restore_policy_will_block: enable a slotless displacement, add plugins.deny, re-enable --dry-run — the carry-over line must not promise an unconditional restore and must name the veto that will actually fire, and the real lifecycle then issues no plugins enable while still removing the receipt. reenable_preview_fails_like_the_real_enable_for_a_vanished_plugin is the inventory-side twin, and goes further: it asserts the plan and the real enable fail identically. A unit-level restore_conditions_note_covers_every_veto is the drift guard that note's own doc promises: it walks every RestoreDecision::Skip* and fails if the enumeration stops matching.

Global switch, full lifecycle: enable_does_not_claim_a_displacement_while_plugins_are_globally_disabled (nothing claimed, no plugins disable), disable_treats_a_globally_disabled_host_as_released (switch flipped after a successful enable → cleanup_complete, receipt removed, message names plugins.enabled and the undo, no refused plugins enable issued), disable_preview_agrees_when_plugins_are_globally_disabled (the pairing).

Mixed states: status_reports_every_displacement_anomaly_in_a_mixed_state (one plugin re-enabled, the other's key unreadable → False summary but the reason names both, the second with the exact key that failed) and status_reports_recorded_and_unreadable_displacements_together. These need a per-key probe failure, so the fake CLI gained FAKE_OC_CONFIG_GET_FAIL_KEY=<key> — a global FAKE_OC_PROBE_FAIL=config_get cannot express "one readable, one not". status_condition_names_the_receipt_resource_for_a_single_displacement pins the resource ref. The reload wording is guarded inside status_does_not_report_healthy_on_a_recorded_but_unverified_handoff, which now requires "reload mode" and forbids "that is what applies it".

Ownership attribution — the two edges of the exemption, each pinned from its own side. Inherited: reenable_keeps_prior_ownership_through_a_transient_probe_failure is the exact reported sequence — a real enable establishes ownership, then FAKE_OC_CONFIG_GET_FAIL_ONCE fails precisely the one prepare-time read of plugins.entries.memory-core.enabled, the re-enable must keep the claim, and the subsequent disable must really issue plugins enable memory-core. reenable_keeps_prior_ownership_when_the_probe_cannot_run_at_all is the persistent-failure sibling. Not inherited: enable_releases_an_unverified_displacement_claim_when_no_prior_owns_it is the reviewer's requested regression — a first enable with FAKE_OC_CONFIG_GET_FAIL_ONCE and FAKE_OC_OPERATOR_DISABLES_ON_INSTALL together, then the full enable→disable chain: the receipt's displaced_plugins must be empty, plugins disable memory-core must never run, its resource must be dropped with it, and the following disable must not issue plugins enable memory-core. Both of those tests also assert the enablement key was read exactly twice, straddling plugins install, so the one-shot failure is provably prepare's read and not some other component's — without that the tests could pass by never entering the window at all. migration_does_not_inherit_an_unverified_displacement_claim is the same case across a state-directory move, which is what pins the gate itself: the prior receipt really does own the id, the new home's operator closed it themselves, and the probe is unanswerable rather than positively false, so nothing but the same-directory gate distinguishes the two instances. reenable_still_releases_a_positive_claim_taken_over_midway now runs against a real prior receipt whose ownership has been voided (somebody turned the plugin back on), so it no longer duplicates enable_releases_a_displacement_claim_taken_over_midway and its name matches its staging. And enable_keeps_a_positive_claim_the_apply_probe_cannot_read covers the half of the release condition nothing else could reach — prepare reads the plugin on, apply's own read then fails — via the new FAKE_OC_CONFIG_GET_FAIL_ON_NTH=<key>:<n> knob, which fails the n-th read of a key; a one-shot failure is n = 1 and a persistent one has no "n", so neither can put the two probes into different states. Reverting that condition to !matches!(probe, ClaimEnabled) now fails exactly this one test and nothing else.

Hand-off accounting: failed_enable_before_the_handoff_leaves_no_displacement_ownership is the reviewer's sequence end to end — runtime verification fails so plugins disable never runs, the operator then disables the plugin themselves, and adapter disable must not issue plugins enable memory-core; the report must also say nothing was restored, and the plugin must still be off exactly as the operator left it. Its assertions are ordered behaviour-first, mechanism-second, so a different implementation that gets the behaviour right is not penalized; the mechanism it then checks is that the declaration survives in the receipt with applied == false. successful_handoff_is_recorded_as_applied_and_restored is the mirror, so the mark cannot be "fixed" by never setting it. displaced_plugin_ref_defaults_to_applied_and_omits_it_when_true pins the wire contract in both directions and in both formats (JSON, and the TOML receipts are actually stored as): a pre-field entry reads as applied, and an applied entry serializes without the field.

Status evidence: status_does_not_report_a_collision_for_a_plugin_the_host_no_longer_has sets the enablement flag to true first as a control, then drops the plugin from the inventory and requires the condition to read True and the summary not to degrade. status_does_not_report_a_collision_for_a_plugin_policy_keeps_off runs the same shape across all three policy levels — plugins.deny, a restrictive plugins.allow, and the global plugins.enabled — resetting the other two between cases so they cannot mask each other. status_still_reports_a_collision_no_policy_explains is the control that keeps those honest: with no policy blocking it and the plugin still in the inventory, the same true flag is a collision and must still degrade.

Inheritance of an unperformed hand-off: reenable_does_not_inherit_a_displacement_whose_handoff_never_ran is the reviewer's chain end to end — enable fails at runtime verification, the operator disables the plugin themselves, a second enable runs, then disable — asserting that neither enable issues plugins disable memory-core, that nothing in the replacement receipt is marked applied, and that the final disable never issues plugins enable memory-core. Its receipt assertion is phrased as the invariant that protects the operator (nothing may be promoted to applied) rather than as one particular way of achieving it. reenable_dry_run_does_not_promise_a_carryover_for_an_unapplied_displacement pins the preview side: no "carries it over" / "disable will hand it back" for an unapplied entry, and the plan must instead describe what the real enable does — respect the operator's own disable. Both share a stage_failed_enable_with_unapplied_displacement fixture that asserts its own preconditions (hand-off never ran, entry present, entry unapplied) so neither test can pass against a mis-staged host.

Attribution of the mark: failed_reenable_does_not_inherit_an_ownership_the_operator_voided is the mirror case, and the reason the mark cannot be merged on a resource match alone — the operator re-enables the plugin after the first enable (voiding that ownership), the re-enable probes a positive ClaimEnabled and then fails before the hand-off, the operator disables it again by hand, and adapter disable must not issue plugins enable memory-core. It asserts the payoff first and the mechanism (applied == false) last, like its siblings.

Carrying the mark across: failed_reenable_keeps_the_applied_ownership_it_replaced is the failure-path sibling of reenable_keeps_prior_ownership_through_a_transient_probe_failure — the same transient probe failure, so the claim is inherited rather than re-confirmed, but the re-enable then fails at runtime verification, before apply_displacements could re-establish anything. It asserts the payoff first (the following disable must still issue plugins enable memory-core and the plugin must really come back) and the mechanism second (the surviving entry still reads as applied), so a different implementation that gets the behaviour right is not penalized. The success-path test passes either way, which is exactly why this one was missing.

Plan/real agreement: reenable_preview_fails_like_the_real_enable_for_a_vanished_plugin replaced a round-9 test that staged this scenario one-for-one (enable, drop the plugin from the host's registry, re-enable --dry-run) but asserted only the dry-run's wording and never asked what the real enable would do — the gap the divergence lived in. The test it replaced no longer exists in the tree.

Partial-cleanup ownership: disable_keeps_only_the_ownership_it_did_not_release is the reviewer's repro end to end — a skill directory replaced by a plain file so remove_tree fails ENOTDIR (structural, no chmod, so no uid can bypass it), a disable that therefore keeps its receipt, and the assertions that the restore nonetheless ran, that the kept receipt no longer names the displacement or its resource, and that after the operator closes the plugin themselves the retry does not re-enable it and still completes. disable_keeps_only_the_displacements_it_failed_to_restore is the multi-entry lifecycle: with the new FAKE_OC_ENABLE_FAIL_ID=<id> knob failing only the second restore, the kept receipt must name only memory-lancedb, and the retry must leave the operator's own disable of memory-core alone while still finishing the restore it owes. Without a per-id knob that split cannot be expressed — a global FAKE_OPENCLAW_FAIL=enable fails every restore, and a restore veto reports the entry as released rather than failed.

Released-by-veto and released-then-error ownership: disable_does_not_keep_ownership_a_veto_already_released is the reviewer's mixed repro — plugins.deny vetoes memory-core while FAKE_OC_ENABLE_FAIL_ID fails memory-lancedb's restore — so the kept receipt must name only the plugin a restore is still owed, its resource must go with it, and once the operator lifts the deny the retry must not issue the plugins enable the earlier report had already handed them. disable_records_released_ownership_when_the_cli_stops_midway covers the route that produces no report at all, through a new FAKE_OC_UNLINK_BIN_AFTER_ENABLE_ID=<id> knob that deletes the fake binary right after the first successful plugins enable, so the second restore cannot be spawned and disable returns Err: the release performed before the error must still be durable (CleanupFailed, only memory-lancedb left, resource gone with it), and the retry with the CLI back must not hand memory-core back a second time. A non-zero exit cannot express this — that is a report, and it takes the cleanup_complete path above. migration_cleanup_records_released_ownership_when_the_cli_stops_midway is the same injection one level up, where the receipt at risk is the prior one a migration keeps durable. disable_keeps_an_unapplied_displacement_it_never_owned pins the exception: a hand-off that never ran stays in the kept receipt, still applied = false, still False through the real status with the reason naming the plugin, and still unrestored by the retry.

Attribution and observability: enable_keeps_the_displacement_its_own_slot_selection_turned_off is the round-29 repro end to end — both plugins declare kind = "memory" (declare_bundle_plugin_kind + seed_bundled_plugin_with_kind), so the host's own slot selection turns memory-core off during install and points plugins.slots.memory at this adapter's plugin. It asserts that trace, that plugins disable memory-core still runs (idempotent), that the persisted receipt still names the displacement with applied = true, and the payoff: adapter disable really issues plugins enable memory-core and the flag reads true again, instead of leaving the host with no memory backend. enable_releases_a_displacement_claim_taken_over_midway still passes unchanged and now documents why its takeover stays attributable — that fixture declares no kind, so no ANOLISA command could have written the flag. status_does_not_clear_a_recorded_handoff_because_a_policy_now_blocks_it is the round-29 status repro: a recorded hand-off reads Unknown, adding plugins.deny = ["memory-core"] must leave it Unknown (summary neither Healthy nor Degraded), and the reason must name the plugin, the key and a real tool call while offering no cold probe. status_does_not_report_a_collision_for_a_plugin_policy_keeps_off now pins both halves for all three policy keys — Unknown, and neither Degraded nor Healthy.

Round 30 (attribution, recording and cold reads): failed_verify_keeps_the_handoff_slot_selection_already_performed is the reviewer's repro verbatim — both plugins kind = "memory", FAKE_OC_RUNTIME_STATUS=error fails verification after the host has already performed the hand-off — and asserts the flag is false, the receipt is kept CleanupFailed with the entry still present and applied = true, and that the recovery disable really issues plugins enable memory-core, puts the flag back to true, removes the receipt, and does not say "never disabled it". enable_keeps_the_claim_a_transient_slot_read_cannot_attribute fails every config get plugins.slots.memory for the whole enable: the claim must survive (a probe the host cannot answer is not evidence of an operator), the hand-off still runs, and once the read recovers the slot is tokenless — written all along — and disable hands the plugin back. status_does_not_clear_a_recorded_handoff_the_inventory_no_longer_lists is the absence repro (recorded hand-off Unknown → drop the plugin from the inventory → still Unknown, summary neither Healthy nor Degraded), and the round-18 case status_does_not_report_a_collision_for_a_plugin_the_host_no_longer_has now pins both halves for absence as well: not a collision, and not an all-clear, with the reason naming the inventory and a real tool call.

Round 31 (the two states that must not be confused, and the record that must not be lost): enable_releases_a_slotless_displacement_an_operator_turned_off is the reviewer's repro — displaces = [{ id = "other-plugin" }] with no slot, neither side declaring a kind, FAKE_OC_OPERATOR_DISABLES_ON_INSTALL=other-plugin — and asserts no plugins disable other-plugin, an empty displaced_plugins in the persisted receipt, and a disable that neither re-enables it nor moves its false flag. failed_verify_with_an_unreadable_slot_still_hands_the_plugin_back is the other repro — both plugins kind = "memory", FAKE_OC_CONFIG_GET_FAIL_KEY=plugins.slots.memory and FAKE_OC_RUNTIME_STATUS=error — and asserts the host state (flag false, slot tokenless), the receipt kept CleanupFailed with the entry still applied = false, then with both failures cleared that status reports Unknown without the "not by this adapter" provenance, and that disable issues plugins enable memory-core, restores the flag to true, removes the receipt, and never says "never disabled it".

migration_cleanup_keeps_only_the_ownership_it_did_not_release is the same guarantee one level up, on the re-enable path: a state-directory migration makes cleanup_replaced_claim run a full disable against the prior receipt's home, which can also partially fail, and the Manager keeps that prior receipt durable when it does — so the released ownership has to have been struck from it too, and the Manager has to persist it before reporting the failure. Neither the reviewer's report nor the review thread raised this sibling; it fell out of asking which paths keep a receipt after a partial release, and it is the only test covering the Manager's new prior-persist.

test_docs_stay_attached_to_their_own_test guards the attachment of four tests' doc blocks against the source, for the reason private_helper_docs_stay_attached_to_their_own_function guards the helpers': inserting a test between a doc block and its fn silently re-documents the neighbour, which is legal Rust and green CI.

Cleanup ordering: reenable_materialized_cleanup_failure_mutates_nothing_on_the_host is the reviewer's repro — a first enable that materializes a directory-shaped skill output and displaces memory-core, then a contract that drops the displacement declaration and replaces that directory with a file while a runtime-created file is still inside it. The prune must then remove the stale directory with a non-recursive remove_dir, which fails ENOTEMPTY. The test requires ReenableCleanupIncomplete and that no framework verb ran at all (no plugins enable memory-core, no plugins uninstall), that the plugin is still displaced and this adapter's own plugin still installed, and that the surviving receipt still reads applied — because the host really is still displaced. It then clears the runtime file and requires the retry to complete the whole cleanup in the new order, including the restore the dropped declaration implies, the new file-shaped output, and an empty displacement list on the new receipt.

Two properties of that injection are deliberate. It is structural, not permission-based: rmdir on a non-empty directory fails for every uid including root, whereas the chmod 000 this test originally used succeeds under CAP_DAC_OVERRIDE and would turn the test red on such a runner — and a permission error fails on symlink_metadata before any remove_dir, so it never exercised the prune's directory branch that the fix's own comment describes. And it drops the displacement declaration, which is what gives the forbidden-verb assertions teeth: that makes dropped_displaced_plugins non-empty so cleanup_replaced_claim really would issue plugins enable memory-core. The pre-existing structural prune test (reenable_refuses_directory_to_file_change_when_runtime_content_would_be_lost) uses a skill-only contract, so that hook returns without issuing a single framework verb and the test stays green under either ordering — which is why it could not serve as the ordering proof. No permission injection remains anywhere in adapter_manager.rs. migration_cleanup_retry_tolerates_already_missing_plugin was rewritten to inject its failure into the driver's own cleanup (the prior home's displacement restore, via FAKE_OPENCLAW_FAIL=enable) instead of into the prune, because the prune now runs first and can no longer produce the half-done cleanup that test exists to cover; its intent — a retry tolerating an already-unregistered plugin — is unchanged, and its doc says why the injection point moved.

Crash-state status: status_does_not_report_healthy_on_a_handoff_that_never_ran stages the write-ahead receipt (stage_failed_enable_with_unapplied_displacement), then models the hard exit by rewriting the persisted status to Enabled — the one thing a clean failure never leaves behind, and the only state that can reach a Healthy verdict, since summarize early-returns on CleanupFailed. Through the real AdapterManager::status it requires the condition to be False, the summary Degraded, and the reason to name the plugin and say the hand-off never ran. It then removes the plugin from the host's inventory and requires the verdict to stop being a collision, so the round-18 rule survives the round-20 fix in the same test.

Plan/real pairing: reenable_dry_run_previews_a_handoff_the_operator_undid asserts both halves against the same world, so the pairing cannot drift — while the plugin is still off the plan promises a carry-over, and once the operator re-enables it the plan must stop promising one and must say the names will be taken back, after which the real enable really issues plugins disable memory-core and the marker returns. The dry-run phase also asserts zero mutation — through assert_dry_run_only_probed, an allowlist shared by all three dry-run tests: every argv line must be --version, plugins list, a --help capability probe, or a config get. Those tests previously carried a three-item denylist of the mutating verbs the driver happened to have, which cannot fail on a verb nobody thought to forbid — precisely the one a later change would add. Injecting plugins disable memory-core into a dry-run's argv makes the helper fail, so the allowlist is verified to have teeth and not merely to be unexercised.

Renamed-receipt identity: renamed_displacement_receipt_survives_the_operator_reenabling_the_plugin (operator re-enabled → prepare writes a fresh canonical entry → the prior's renamed entry must be recognised as the same plugin, not added beside it → one entry, canonical id, applied, and a following disable still restores) and renamed_displacement_receipt_survives_a_transient_probe_failure (the other route to the duplicate, where the prior ownership is genuinely inherited and must stay applied). Both share a rename_displacement_resource helper that also asserts its own preconditions, and the round-17 test was refactored onto it.

Receipt identity: renamed_displacement_resource_survives_status_and_reenable renames a displaced-plugin resource and its reference together in a persisted receipt — which is all validation asks for — then requires status to return that id as the condition's resource reference and a plain re-enable to complete and keep the ownership usable through a following disable. The mid-mutation assertion is ordered first, since that is the serious symptom.

Doc attachment: private_helper_docs_stay_attached_to_their_own_function reads openclaw.rs with include_str! and asserts, for two pairs of neighbouring helpers, that each one's doc block still sits on its own fn. Nothing else can catch this: missing_docs does not apply to private items, and cargo doc -D warnings stays silent because two adjacent /// blocks with no blank line between them are legal Rust that simply render as one run-on attached to whichever item follows. Three review rounds have now found a helper documenting its neighbour's behaviour instead of its own, so the invariant is asserted rather than left to review. The replacement asserts that both paths fail, that they fail identically (dry_run.to_string() == real.to_string()), that neither mutates the host or consumes the receipt, and that a following disable still resolves the displacement through the inventory verdict and tells the operator it was released rather than dropping it silently.

Instance and window: status_probes_the_receipt_instance_not_the_callers_environment (receipt in A, caller pointed at B which reads clean, collision live in A → must still report False/Degraded), a_receipt_in_an_unrelated_state_directory_is_rejected_not_migrated (both env vars moved → status, both disables and the re-enable plan all fail with ClaimValidation, zero host calls, recorded instance untouched, receipt kept), enable_releases_a_displacement_claim_taken_over_midway (the fake CLI turns the plugin off as part of plugins install, i.e. inside the window → no plugins disable, and the released claim is gone from the persisted receipt including its resource, so a later disable does not re-enable it), and enable_still_claims_a_displacement_when_nothing_changed_midway (the control, so the re-confirmation cannot pass by never claiming anything).

Generic advice: status_advice_is_generic_for_a_non_memory_adapter runs on the suite's tokenless component and asserts the reason contains no memory_get / memory_search / agent-memory, is phrased against "this adapter's own tools", and still names the plugin the receipt really does claim. The round-8 assertions forbidding plugins list / plugins inspect as confirmation are kept.

Carried over from the previous revision: explicit-off slot (disable_never_reopens_an_explicitly_closed_slot, disable_honors_every_explicit_off_spelling_of_a_slot), re-enable dry-run carry-over, disable dry-run restore, and the three status displacement cases.

crates/anolisa-core/tests/adapter_frameworks.rs (68 cases) — Qoder regressions that rewrite a real receipt to driver_schema = 3 with plugin_install_confirmed = true: disable still uninstalls the ANOLISA-owned plugin and removes the receipt; a re-enable still carries ownership and a later disable still owns the uninstall.

Every fix in all thirty-one review rounds was reverse-checked: reverting it in isolation makes the corresponding tests fail with the reported symptom. (Round 28 raised nothing blocking, so there is no fix to revert.) Round 31 — folding NotDeclared back into Unreadable → exactly 1 (enable_releases_a_slotless_displacement_an_operator_turned_off, failing with plugins disable other-plugin in the argv, i.e. the operator's disable being claimed); reverting only restore_decision's new guard → exactly 1 (failed_verify_with_an_unreadable_slot_still_hands_the_plugin_back, failing on the argv assertion with disable having issued only plugins uninstall and plugins list, the reported stranded host); and reverting only the status provenance bucket → exactly 1 (the same test, failing on the provenance assertion with the old "not by this adapter" reason quoted). Round 30 — deleting the two record_slot_selection_handoffs call sites → exactly 1 (failed_verify_keeps_the_handoff_slot_selection_already_performed, failing with applied: false on the kept receipt, i.e. the reported disowned hand-off); folding SlotAttribution::Unreadable back into "not ours" → exactly 1 (enable_keeps_the_claim_a_transient_slot_read_cannot_attribute, failing with left: [] where ["memory-core"] must remain, the reported empty receipt); and dropping absent from the Unknown group → exactly 2 (both absence status tests, failing left: True, right: Unknown, the reported upgrade). Round 29 — dropping the slot attribution so a bare false flag releases again → exactly 1 (enable_keeps_the_displacement_its_own_slot_selection_turned_off, failing on the argv assertion with no plugins disable memory-core, i.e. the claim was released and the host would keep a disabled backend with no receipt); moving DisplacementBlock::Policy / PluginsGloballyDisabled back into released → exactly 2 (both policy status tests, failing left: True, right: Unknown, the reported flip). Round 27 changed only this document — the sentence above describing the recorded-hand-off test claimed it points at openclaw plugins list, while the test asserts the reason must not contain that string — so there is no code or test to revert; the check is that the description now matches am:7539-7626 assertion for assertion. Round 26 — deleting the release from the veto branch → exactly 1 (disable_does_not_keep_ownership_a_veto_already_released, failing with ["memory-core", "memory-lancedb"] where only ["memory-lancedb"] may remain, i.e. the reviewer's repro); restoring instead the bare ? on the Manager's driver.disable call → exactly 1 (disable_records_released_ownership_when_the_cli_stops_midway, failing with the kept receipt still Enabled and both entries in it); and the same on the re-enable site → exactly 1 (migration_cleanup_records_released_ownership_when_the_cli_stops_midway), so the two persistence sites stay pinned separately, as they have been since round 25. The plausible over-correction is pinned too: releasing the unapplied branch as well → exactly 1 (disable_keeps_an_unapplied_displacement_it_never_owned, failing with [] where ["memory-core"] must remain). Round 25 — deleting the single line that strikes a restored displacement from the receipt → exactly 2 (both new partial-cleanup disable tests), the first failing on "a release already performed must not survive in the receipt kept for retry"; deleting instead the Manager's new upsert_adapter_claim(prior.clone()) → exactly 1 (migration_cleanup_keeps_only_the_ownership_it_did_not_release, failing with ["memory-core", "memory-lancedb"] where only ["memory-lancedb"] may remain), while the two disable tests stay green, so the two persistence paths are pinned separately; and swapping the two regression tests' doc blocks → the new attachment guard fails, which is the check that caught the misplacement the reviewer reported. Round 24 changed only a test, so it was verified by re-running the round-23 reversion against it: with the Manager's pre-fix ordering restored, reenable_materialized_cleanup_failure_mutates_nothing_on_the_host fails with plugins enable memory-core in the argv ahead of the prune failure, while reenable_refuses_directory_to_file_change_when_runtime_content_would_be_lost still passes — which is the demonstration that the rebuilt test, and not the pre-existing one, is what pins the ordering. The injected failure was also confirmed to be Directory not empty (os error 39) on the stale directory, i.e. the non-recursive remove_dir path, rather than a permission error. Rounds 22 and 23 raised the same P1 (the second on the newer head, confirming it was still unfixed) — restoring the Manager's pre-fix ordering (prune after the driver's cleanup) → exactly 1 (reenable_materialized_cleanup_failure_mutates_nothing_on_the_host), failing with plugins enable memory-core present in the argv ahead of the prune failure, i.e. the reported sequence reproduced verbatim; the rewritten migration_cleanup_retry_tolerates_already_missing_plugin passes under both orderings, as it should, since its injected failure is inside the driver's cleanup. Round 21 was documentation-only (two component READMEs), so it was verified the way round 19 was rather than by reversion: every command and env var the new text names was checked to appear in both locales, the two bash blocks that must match were diffed and are byte-identical apart from a localized trailing comment, each file gained exactly one code block so the pre-existing EN/ZH asymmetry is unchanged, docs-link-check.py resolves the guide link from both files, and no test in the tree asserts on these READMEs (the README.md references in adapter_frameworks.rs and src/agent-memory/tests/*.rs are temp fixtures and a runtime-created mount readme, and src/agent-memory is not in the cargo workspace). Round 21's P1 was verbatim the same finding as round 20's, at the same file and line, and is covered by that round's fix and regression test. Round 20 — restoring the pre-fix openclaw.rs (unapplied entries skip the host probe and stay out of the False/Unknown computation) → exactly 1 (status_does_not_report_healthy_on_a_handoff_that_never_ran), failing with condition.status True instead of False and, with that assertion demoted to observe the next one, summary Healthy instead of Degraded — the reported false all-clear reproduced, not merely asserted. The failure output also captures the self-contradiction verbatim: the reason reads "this adapter never disabled it and there is no hand-off to verify" while the verdict says released. Round 19 was documentation-only, so it was verified by regeneration rather than by reversion: every backticked snake_case identifier in this description was extracted and checked against the #[test] functions actually present in the tree (5 stale names found and corrected, one of them a test deleted in round 9), and every count was regenerated from a fresh cargo test --workspace --locked on the current head. The one code change — replacing three dry-run denylists with a shared allowlist — was reverse-checked by injecting a mutating verb into a dry-run's argv, which fails exactly the test whose argv was touched. Round 18 — letting the plan's carried branch ignore the probe → exactly 1 (reenable_dry_run_previews_a_handoff_the_operator_undid, failing with the plan literally reading "carries it over, so it stays claimed and disable will hand it back"); and keying the inheritance check on the resource id → exactly 2 (both renamed-receipt tests, failing with displaced plugin 'memory-core' is claimed more than once). Round 17 — restoring round 16's state (prepare always writes unapplied, preserve merges on the resource match) → exactly 1 (failed_reenable_does_not_inherit_an_ownership_the_operator_voided, failing on the payoff with plugins enable memory-core in the disable argv), while round 16's own test still passes, which is the isolation that shows the two directions are separately pinned; re-deriving the resource id from the plugin id → exactly 1 (renamed_displacement_resource_survives_status_and_reenable, failing with BundleInvalid: displaced plugin resource '...' is missing); and moving either doc block back onto its neighbour → the doc-attachment guard. Round 16 — dropping the prior's applied mark when the replacement already holds the resource → exactly 1 (failed_reenable_keeps_the_applied_ownership_it_replaced, failing on the payoff with plugins enable memory-core absent from the disable argv); and letting the carried branch skip the existence check → exactly 1 (reenable_preview_fails_like_the_real_enable_for_a_vanished_plugin). Round 15 — building the inherited / carried / preserved sets from every prior id instead of the applied subset → exactly 2 (both new tests, and nothing else out of 169): the end-to-end one fails with plugins disable memory-core issued during the re-enable, the dry-run one fails with the preview literally reading "carries it over, so it stays claimed and disable will hand it back". Round 14 — recording a displacement as applied straight from prepare_enable → 1 (failed_enable_before_the_handoff_leaves_no_displacement_ownership, failing on the payoff with plugins enable memory-core in the disable argv, i.e. the reported undoing of the operator's own choice); and dropping the inventory/policy consultation from displaced_plugins_condition → exactly 2 (both new status tests), with status_still_reports_a_collision_no_policy_explains still passing, so the control is not what does the work. Round 13 — exempting every unverified claim from re-confirmation → 2 (enable_releases_an_unverified_displacement_claim_when_no_prior_owns_it and migration_does_not_inherit_an_unverified_displacement_claim, both failing with plugins disable memory-core actually issued); and widening the apply-side release back to !matches!(probe, ClaimEnabled) → exactly 1 (enable_keeps_a_positive_claim_the_apply_probe_cannot_read), which is the gap that mutation had been invisible to until now. Round 12 — treating an unverified claim as attributable → 1 (reenable_keeps_prior_ownership_through_a_transient_probe_failure, reproducing the reported loss of ownership). Round 11 — receipt-instance probe → 1; mid-enable re-confirmation → 1; the misplaced plan_disable_report doc was verified structurally (10 doc lines now sit on plan_disable_report, and validate_displacements_not_self's own 14 lines contain no DisableReport reference). Round 10 — global-switch veto → 3; merged mixed-state reason → 2; conditional reload wording → 1. Round 9 — enable preview hand-building slot-only text → 3 (including the reviewer's deny repro); memory_get hardcoded back into the reason → 2. Round 8 — preview composed without the shared decision → 3 pairing tests fail (policy-blocked, vanished, slot-blocked); cold probe offered as confirmation → 1. Round 7 — key-scoped policy parsing → 3 (2 unit + 1 integration, the integration one being the reviewer's exact scenario), non-promising Unknown wording → 1. Round 6 — JSON policy parsing → 5 (2 unit + 3 integration), config-vs-gateway verdict → 2. Round 5 — inventory/policy claim gate → 3, restore-side release → 2, config-vs-runtime status → 1. Round 4 — state-dir inheritance gate → 2, receipt slot constraints → 3, per-plugin remediation → 2. Round 3 — duplicate id/slot gates → 2, own-plugin resolution → 1, dropped-restore preview + disable dry-run validation → 4. Round 2 — self-displacement → 1, inheritance filter/slot → 3, receipt integrity → 2, preview wording → 3. Round 1 — Qoder schema gate → 2, explicit-off slot → 2, dry-run carry-over → 1, disable preview → 1, status → 2. openclaw.rs also has unit coverage for the slot classifier and the extended summarize.

One pre-existing unit-test fixture (manager.rs::openclaw_claim) declared plugin_resource = "openclaw_plugin" with an empty resources list — a dangling reference nothing used to check. It now carries the resource it names; the two cases that use it are about a missing adapter source, not receipt shape.

Local checks, all green:

  • cargo fmt --all --check
  • cargo clippy --all-targets --locked -- -D warnings
  • cargo test --workspace --locked — 2637 passed, 0 failed across 21 suites (anolisa-cli lib 1072, anolisa-core lib 1092, adapter_manager 192, anolisa-platform lib 112, adapter_frameworks 68, anolisa-env lib 28, plus the CLI integration suites and doc-tests)
  • RUSTDOCFLAGS="-D warnings" cargo doc --no-deps -p anolisa-core
  • scripts/docs-lint.sh, scripts/docs-link-check.py
  • src/agent-memory: make test-openclaw-install
  • git diff --check

Risk and compatibility

  • Public CLI, API, configuration, or documented behavior changed

Breaking, in-crate only: FrameworkDriver::disable and FrameworkDriver::cleanup_replaced_claim now take the claim as &mut AdapterClaim instead of &AdapterClaim, so a driver whose cleanup can partially fail can strike the ownership it has already released from the receipt the Manager is about to keep. Both are required-or-defaulted trait methods, so all nine in-tree drivers change signature; eight ignore the mutability. No new public type and no change to serialized output — the alternative, a machine-readable field on DisableReport, would have touched 44 constructors across 12 files and changed the JSON the CLI emits.

Breaking, in-crate only: FrameworkDriver::prepare_enable gained a prior: Option<&AdapterClaim> parameter, matching the argument plan_enable already takes. It is a required method, so every implementor changes signature; all nine in-tree drivers and their test call sites are updated here, and eight of them ignore the argument. It is not defaulted, on purpose — a silent None default would let a future driver inherit ownership without ever being told a prior receipt exists, which is the exact defect this round fixes.

Public API addition: DisplacedPluginRef gained an applied: bool. It is a field on a pub struct, so an out-of-tree constructor breaks; it is defaulted on the wire in both directions, so no existing receipt changes meaning or shape. DRIVER_SCHEMA_VERSION stays at 4 — the version this PR introduced for displaced_plugins, which has never shipped — because the field belongs to that same unreleased schema, and the constant is a write-time stamp rather than a read gate in any case.

Additive: a new field on PreparedEnable::OpenClaw (the set of displacements this enable's own probe claimed), a new optional contract key, a new optional receipt field, a new DriverCtx field, a new AdapterConditionKind variant, two new defaulted FrameworkDriver hooks — validate_claim (OpenClaw and Qoder implement it) and plan_disable_restores (OpenClaw only) — with the other drivers inheriting the accepting/empty defaults, and two internal FrameworkDriver signature changes (plan_enable gained the receipt it would replace, preserve_reenable_facts gained the current contract's DriverCtx). All nine drivers are updated; only OpenClaw reads either parameter. Adapters that declare no displacement behave exactly as before, and no other framework's driver reads the field.

Intentional behavior changes, all narrowing:

  • adapter enable --dry-run no longer promises a carry-over for a displacement the operator has since undone. When the host says the plugin is positively on, the plan now previews the plugins disable the real enable will perform — previously it claimed the plugin "stays claimed" and the mutation went unannounced.
  • adapter enable --dry-run now fails for a carried-over displacement whose plugin the host no longer has, instead of returning a plan that promises a carry-over the real enable refuses to perform; both paths reach the same missing_displacement_target verdict.
  • A re-enable no longer inherits a displacement whose hand-off never ran. The unperformed entry is now reported by cleanup_replaced_claim as never performed instead of being copied into the replacement receipt, and enable --dry-run no longer previews it as carried over.
  • adapter disable no longer restores a displacement whose hand-off never ran. A receipt left behind by an enable that failed before plugins disable used to read as ownership, so disable would re-enable a plugin this adapter never touched; it now reports that it never disabled it and leaves the plugin alone.
  • A disable or re-enable whose cleanup only partly succeeds now keeps a receipt that matches the host: ownership already released is struck from it, so a retry no longer repeats a plugins enable that already happened. Previously the whole receipt was kept verbatim.
  • A re-enable whose stale-file prune cannot complete now fails before touching the framework, instead of after having already handed a displaced plugin back. The error is the same ReenableCleanupIncomplete; what changes is that the host and the surviving receipt still agree, so a retry is safe.
  • adapter status no longer reports Healthy for a displacement whose hand-off never ran. A receipt left at Enabled with an unapplied entry — which only a hard exit between the Manager's write-ahead persist and apply_displacements produces — used to read as released; it now reports the collision and degrades, because the bundled plugin is still enabled and still holding the tool names.
  • adapter status no longer reports a collision for a displaced plugin that cannot load. One the host's inventory no longer lists, or one plugins.enabled / plugins.deny / a restrictive plugins.allow keeps off, used to degrade the adapter on the strength of its enablement flag alone; it is now reported as released and does not withhold True.
  • adapter enable no longer releases a displacement claim that an older receipt of this adapter owns. A re-enable whose enablement probe transiently fails now keeps the prior ownership instead of deleting it, so adapter disable still hands the plugin back. A first enable is unchanged in this respect: with nothing to inherit, an unverified claim whose plugin turns out to be off is still released, because keeping it would claim a transition this adapter did not make.
  • adapter enable releases a displacement claim if the plugin turns out to be off by the time apply_displacements runs, so a plugin somebody else disabled mid-enable is no longer recorded as this adapter's — and adapter disable will not re-open it.
  • adapter status probes the state directory the receipt names rather than the caller's environment, so it can now report a collision in the receipt's instance that the caller's current OPENCLAW_STATE_DIR does not have.
  • adapter enable no longer claims a displacement while the host's global plugins.enabled is false, and adapter disable treats that as released rather than as a retryable failure — previously it retried forever and stranded the receipt.
  • adapter enable now rejects a contract that displaces the plugin id resolved from the bundle, which previously succeeded and left the adapter enabled-but-disabled.
  • adapter enable now rejects a contract that declares the same displaced id twice, or the same exclusive slot for two plugins. Both previously reached the host; the duplicate id left an unconsumable cleanup_failed receipt, the duplicate slot left a plugin disabled with no receipt.
  • adapter disable --dry-run now rejects the same corrupt receipts the real disable does, where it previously returned a successful plan. Qoder's dry-run gains the same honesty through the shared hook.
  • adapter disable no longer re-enables a displaced plugin when plugins.slots.<slot> reads as an explicit off. Previously it did, re-opening a slot the operator had closed.
  • adapter status no longer tells every host to restart the gateway to activate the hand-off; the wording is conditional on the host's reload mode, so hot-reloading hosts are no longer advised into an unnecessary interruption.
  • adapter status advice for a recorded-but-unverified hand-off is now generic ("call one of this adapter's own tools that '' also provides") instead of naming memory_get, and defers to the component's own documentation for the concrete tool.
  • adapter status can now report degraded / unknown where it previously reported healthy, when a displaced plugin was re-enabled behind the adapter's back or the probe cannot run.
  • adapter status / disable / re-enable now fail closed on a receipt whose displacement references do not resolve, where status previously reported Healthy and disable discovered it only after uninstalling the adapter's own plugin. A receipt written by this driver always resolves, so this only fires on truncation or hand-editing — and disable then keeps the receipt for retry rather than half-finishing.
  • A re-enable under a contract that dropped or moved a displacement now hands the old plugin back during cleanup_replaced_claim. If that restore fails, the re-enable fails with ReenableCleanupIncomplete and the prior receipt stays durable, exactly as a failed cross-home cleanup already did.
  • Receipts whose own-plugin reference does not resolve, or resolves to a resource with the wrong purpose or framework, or disagrees with the receipt's top-level plugin_id, are now rejected by status, disable and the disable dry-run. A receipt written by this driver always satisfies all three, so this only fires on truncation or hand-editing.
  • Receipts naming an empty slot suffix, or two entries sharing one exclusive slot, are now rejected the same way; contracts declaring either are rejected at enable. Both previously reached the host — the shared slot orphaned a disabled plugin, the empty suffix silently dropped the restore guard.
  • adapter enable now fails before installing anything when a declared displaced id is absent from a readable plugins list. A contract that matched an older OpenClaw but not this one now says so up front instead of half-installing.
  • adapter enable no longer claims a displacement that plugins.deny or a restrictive plugins.allow already keeps off, and adapter disable treats such a plugin — or one the host has dropped — as released, so the receipt is removed instead of being kept for a retry that can never succeed.
  • adapter enable --dry-run no longer describes a future restore as unconditional, for a slotless declaration or any other; it lists the vetoes that can release the ownership instead. Four published documents said otherwise and were corrected with it.
  • adapter disable --dry-run now reports the restore verdict the real disable will reach, including "would leave it alone" for an inventory, policy or slot veto it previously promised to restore; it runs the same read-only probes (plugins list, the two policy keys, plugins.slots.<slot>) the operation will.
  • adapter status reports unknown, not healthy, whenever a displacement is recorded in config but its effect on the running gateway is unobservable — which, absent a gateway channel, is the steady state. This is a deliberate correctness-over-signal trade: the alternative asserts a hand-off took effect that may not have, which is the exact false all-clear this condition exists to prevent. It is also permanent — restarting the gateway does not change it, and the reason and both locales of the user guide now say so explicitly rather than implying a re-check will resolve it. Wiring a gateway tool-catalog channel would turn it into a decisive verdict; see the open question in the review thread.
  • A re-enable that crosses OpenClaw state directories no longer inherits the prior receipt's displacement claims. Ownership in the new home now comes only from probing the new home, and the prior home's displacements are restored there during cleanup instead of being carried into a registry they were never made in.

adapter enable runs one extra read-only config get per declared displacement (also in --dry-run), and adapter status one per claimed displacement.

Note for reviewers: this touches apply_enable shortly after #3223 landed there, and #3222 (still open) edits the same docs section — the docs no longer depend on #3222 landing first, so either order works now. If maintainers would rather this rode the shared entry-point policy in #3167, say so and I will rework it.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 0ac241d3ac

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread docs/user-guide/en/token-saving/agent-memory.md Outdated
Comment thread src/anolisa/crates/anolisa-core/src/adapter/openclaw.rs Outdated
Comment thread src/anolisa/crates/anolisa-core/src/adapter/openclaw.rs Outdated

@kongche-jbw kongche-jbw left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review baseline: cdd0d0078ad39dff768b5b76a16918ab4d4e77d4...0ac241d3ac560725d8a3f9cdde06176e4df4a162

[P1] 保留 Qoder v3 receipt 的安装所有权

src/anolisa/crates/anolisa-core/src/adapter/claim.rs:47 将共享版本常量升到 4,
adapter/qoder.rs:986 同时把该常量当作 Qoder 安装确认的最低版本。
升级前写出的 schema-v3 Qoder receipt 即使带有
plugin_install_confirmed = true,升级后也会被判成未确认。
此时 disable 会拒绝卸载仍存在的插件并永久保留 receipt;re-enable 还可能把该插件
改记为 pre-existing,随后移除 receipt 却遗留插件。PR 中“没有读取路径按版本门禁”
也因此不成立。Possible direction: 为 Qoder 固定使用其字段引入版本 3,或拆分
driver-specific schema;补充从真实 v3 confirmed receipt 执行 disable 和 re-enable 的回归测试。

[P1] 不要把显式关闭的 memory slot 当作空 slot 恢复

src/anolisa/crates/anolisa-core/src/adapter/openclaw.rs:3334none 归为可恢复状态,
但 OpenClaw 用 plugins.slots.memory = "none" 表示显式关闭 memory slot。
若用户在 adapter enable 后选择 off,再执行 adapter disable,这里仍会运行
plugins enable memory-core;该命令会重新选择默认 memory owner,反向覆盖用户的 off 选择。
Possible direction: 将显式 off sentinel 与缺失/不可读区分并阻止恢复;增加 slot 为
none 时不得调用 plugins enable memory-core 的完整 Manager 回归测试。

[P2] 让 dry-run 使用 receipt 中的 displacement 状态

src/anolisa/crates/anolisa-core/src/adapter/openclaw.rs:233-259 的 enable plan 只做新探测。
已有 receipt 的 re-enable dry-run 会因 memory-core=false 声称“不会恢复”,
但真实 re-enable 在 preserve_reenable_facts 中保留所有权,后续 disable 会恢复它。
所以该计划与真实生命周期相反,并可能误导用户认为最终 disable 不会启用
memory-core。Possible direction: 在生成 re-enable plan 时合并 prior receipt 的
OpenClawClaim.displaced_plugins,并补充“已成功 enable 后再次 dry-run enable”的回归测试。

@Forrest-ly
Forrest-ly force-pushed the fix/AGE-7186-openclaw-adapter-enable-memory-handoff branch from 0ac241d to 7c6157b Compare September 11, 2026 05:10
@Forrest-ly

Copy link
Copy Markdown
Collaborator Author

@kongche-jbw 感谢 review,三条都成立,已在 7c6157b5 全部修掉(沿用仓库惯例 fixup 进原 commit,PR 仍是单 commit)。逐条回复:


[P1] 保留 Qoder v3 receipt 的安装所有权 — 已修

你指出的完全正确,PR body 里「没有读取路径按版本门禁」这句话是错的:qoder.rs::native_install_confirmed 正是那个门禁。共享常量升到 4 之后它变成 >= 4,v3 confirmed receipt 被判为未确认。

采用你建议的第一个方向 —— 为 Qoder 固定其字段引入版本:

  • qoder.rs 新增 driver-local 常量 QODER_INSTALL_CONFIRMED_MIN_SCHEMA: u32 = 3plugin_install_confirmed7e6a9d4a 引入,那次把共享常量从 1 提到 3,所以 3 就是正确的下界);native_install_confirmed 改为门禁这个常量。
  • claim.rsDRIVER_SCHEMA_VERSION 文档补上「write-time version, never a read gate」:它是各 driver 写 receipt 时盖的戳,不表示旧 receipt 上可能有哪些字段;需要判断字段是否存在时必须用 driver-local 常量,并直接指向 Qoder 的那个常量作为范例。这样下一个 driver 加字段时不会再踩同一个坑。
  • 没有选择「拆分 driver-specific schema 常量」那条路:那要把 driver_schema 变成 per-driver 的一组数字,牵动 receipt 的 wire format 与全部 9 个 driver 的写入点,代价远大于收益;而问题本身只是「一个 driver 误用了共享常量」。

回归测试(adapter_frameworks.rs,把真实 enable 出来的 receipt 改写成 driver_schema = 3 + plugin_install_confirmed = true):

  • qoder_native_v3_confirmed_receipt_still_owns_its_plugin_on_disable —— disable 必须真的 plugins uninstall tokenless --scope userclaim_removedcleanup_complete、cache 目录消失。
  • qoder_native_v3_confirmed_receipt_carries_ownership_across_reenable —— re-enable 后 !plugin_preexisting && plugin_install_confirmed,随后 disable 仍然拥有卸载权。

两个用例在上一版 head 上都会失败,症状与你描述的后果链一致:前者 claim_removed == false(拒卸载 + receipt 永久保留),后者直接 refusing to replace pre-existing user-scope Qoder plugin 'tokenless@local'(所有权不跨 re-enable 传递,插件被记为 pre-existing)。qoder.rs:982-984 那段与常量不一致的文档注释也一并改掉了。


[P1] 不要把显式关闭的 memory slot 当作空 slot 恢复 — 已修

none 从「可恢复」列表里移出,并且按你的建议把「显式 off」与「缺失/不可读」在类型上彻底分开:

  • read_slot_owner 返回 Option<String>None = 宿主答不上来(config get 不存在 / 非零退出),Some(token) = 读到了(可能是空串)。之前两种情况都塌成 "",正是这个塌缩让 none 和「读不到」同罪。
  • 新增 slot_restore_decision(answer, own_plugin_id, displaced_id) -> SlotRestore{Proceed | ExplicitlyOff(token) | OwnedByThird(token)},替换原来的 slot_owner_blocks_restore。判定顺序是:先认「已知 owner」(本 adapter 自己的插件、被 displacement 的插件本身)→ 再判 explicit off → 再判 vacant sentinel("" / null / nil / undefined / nan / (empty))→ 其余为第三方 owner。已知 owner 优先于 sentinel 解释,这样一个真叫 none 的插件仍然持有自己的 slot。
  • 关于你指出的与 config_answer_is_false 口径不一致:这两处确实是同一个语义(「宿主说 off」)在两个 key 上的归约,所以 slot_answer_is_explicit_off(token) = token == "none" || config_answer_is_false(token),直接复用同一份 off-word 列表(false|0|no|off|disabled)。现在一个宿主无论把 off 渲染在哪个 key 上,分类结果都一致。
  • restore_displaced_plugins 为 explicit-off 单独出一条 message,说明「plugins.slots.<slot> 被显式设为 none,重新启用会重跑 OpenClaw 的 slot 选择并悄悄推翻这个选择」,并给出 openclaw plugins enable <id> 的手动出路。这仍然算 cleanup_complete = true:主动让位不是失败,没有可重试的东西,留着 receipt 反而会把它变成永久残留。

Manager 回归测试:disable_never_reopens_an_explicitly_closed_slot(断言 argv 里没有 plugins enable memory-core、message 说明原因、marker 仍在、receipt 被清掉)与 disable_honors_every_explicit_off_spelling_of_a_slot(六种 off 拼写逐一验证)。移除 explicit-off 分支后两个都会失败。

#3222uninstall.sh 那条 P2(34eca98 已修)现在是同一语义在两个入口上的一致处理。


[P2] 让 dry-run 使用 receipt 中的 displacement 状态 — 已修

按你建议的方向,生成 re-enable plan 时合并 prior receipt 的 OpenClawClaim.displaced_plugins

  • FrameworkDriver::plan_enable 增加 prior: Option<&AdapterClaim> 参数(trait 文档写明:会在 re-enable 中继承事实的 driver 必须按 receipt 而不是按新探测来做计划,因为此时宿主已经反映了上一次 enable 的变更,探测结果可能与真实生命周期完全相反)。9 个 driver 全部更新,只有 OpenClaw 读它,其余为 _prior
  • Manager 的 dry-run 分支改为validate_with_trust 校验 prior,再交给 plan_enable —— 伪造的 receipt 不该比在真实 re-enable 中更有话语权,包括影响计划文本。
  • OpenClaw plan_enable:prior receipt 已 claim 的 spec 走新分支,输出「keep openclaw plugin '' disabled: the receipt being replaced already claims this displacement and carries it over, so it stays claimed and disable will hand it back, and restore plugins.slots. on disable unless ...」;未被 claim 的仍走原实时探测(含「操作者自己关掉的」那一支)。

回归测试 reenable_dry_run_plans_the_displacement_the_receipt_carries_over:成功 enable 后再次 dry-run enable,断言计划里出现 carry-over 文案、出现 leave openclaw plugin 'memory-core' alone、且没有任何 mutating verb(plugins disable / plugins enable / plugins uninstall)、receipt 未被改动。为了让这个用例读到真实宿主状态,fake CLI 的 plugins enable / plugins disable 现在会像真宿主一样持久化 plugins.entries.<id>.enabledadapter_manager.rs 原有 92 个用例在此改动后全绿)。

顺带修掉同源的另一侧(disable dry-run 不读 displaced_plugins):plan_disable_report 现在把 OpenClaw 的 displaced 资源单列,输出「would re-enable openclaw plugin 'memory-core', which this adapter displaced unless plugins.slots.memory has been given to another plugin or explicitly closed by then」—— 动词与通用分支的 "unregister" 相反,所以没有并进 cleanup_ids


另外三条 bot inline 意见也一并处理了(docs 跨 PR 依赖、disable dry-run、status() 无 displaced 判断),细节在对应的 inline thread 里。

本地全绿:cargo fmt --all --checkcargo clippy --all-targets --locked -- -D warningscargo test --workspace --locked(anolisa-core lib 1083 / adapter_frameworks 68 / adapter_manager 99 / adapter_contract 28,0 failed)、RUSTDOCFLAGS="-D warnings" cargo doc --no-deps -p anolisa-corescripts/docs-lint.shscripts/docs-link-check.pysrc/agent-memorymake test-openclaw-install

PR body 已同步更新(含 schema 版本那段错误说法的更正,以及两处有意的行为收窄)。请再 review。

@kongche-jbw kongche-jbw left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review baseline: 8ba7a0f0d373e19955c7d37d97af7b3b0f0b6aa3...7c6157b5cf2881a364793820c334b464cd1b2fbc

[P1] 用解析后的插件 ID 拒绝自我 displacement

src/anolisa/crates/anolisa-core/src/adapter/manager.rs:953 只与可选的
manifest plugin_id 比较,但该字段可以省略;driver 随后会从
openclaw.plugin.json 或 component 名解析真实 ID。若 displaces.id
等于这个真实 ID,enable 会先安装并验证自己的插件,再把它禁用,最后仍返回成功并写下
Enabled receipt。Possible direction: 在 bundle ID 解析后再次做不同 ID 校验,并增加省略
plugin_id、bundle ID 与 displacement 相同且全程零 mutation 的 Manager 回归测试。

[P1] re-enable 应释放新 contract 已删除的 displacement

src/anolisa/crates/anolisa-core/src/adapter/openclaw.rs:3658 会把 prior receipt 中
next receipt 没有的每个 displacement 无条件复制回来。组件升级若删除该声明、替换插件或
修改 slot,当前探测会因旧插件已被本 adapter 禁用而产生空 next;这里又恢复旧事实,
same-home cleanup 随后直接 no-op,apply 还会再次禁用旧插件。因此新 contract 永远不会
生效。Possible direction: 只继承当前 contract 仍声明的所有权,并在替换 receipt 前恢复被删除
的条目;覆盖删除、改 ID 和改 slot 的真实 re-enable 测试。

[P1] 在任何卸载动作前验证 displacement receipt

src/anolisa/crates/anolisa-core/src/adapter/openclaw.rs:1050 把 dangling 或 mistyped
displacement 引用的错误吞成 None,generic claim 校验也不检查 payload 引用完整性。
同一个错误到 disable 时要等 adapter 自身已经 uninstall 后,才在 :1155 被发现。
损坏 receipt 因此可先显示 Healthy,再造成部分卸载;指向任意其他
FrameworkPlugin 的引用还会驱动一次非预期的 plugins enable。Possible direction: 在 status
和 disable 的首个动作前校验引用、purpose、与自身 ID 的不同性、唯一性及 slot,并补充
伪造 receipt 时 status 不得 Healthy、disable 不得产生任何 CLI mutation 的回归测试。

[P2] 让 dry-run 的恢复条件与真实 disable 一致

src/anolisa/crates/anolisa-core/src/adapter/openclaw.rs:264 对带 slot 的条目只说第三方
owner 会阻止恢复,却漏掉真实路径也会因显式 none/off 跳过。反过来,
src/anolisa/crates/anolisa-core/src/adapter/manager.rs:4211 又为无 slot 条目描述了
“operator state”条件,而真实 disable 对该类条目无条件 re-enable。两个合法 contract
都会得到与实际动作不同的预览。Possible direction: 让两处文案逐字反映各自真实分支,并用
slotful explicit-off 与 slotless lifecycle 固定预览。

[P2] 移除仍声称脚本会执行 hand-off 的 rustdoc

src/anolisa/crates/anolisa-core/src/manifest.rs:900 仍说 install.sh / uninstall.sh
执行相同生命周期,但当前脚本并不读取该声明或处理 memory-core;本 PR 的 module docs 和
用户指南也已明确相反。该 public rustdoc 仍把未实现行为发布为可用。Possible direction:
把这段改为 driver-only contract,并保持与两份 contract 注释一致。

[P2] 文档应说明禁用 memory-core 的完整功能代价

docs/user-guide/en/token-saving/agent-memory.md:122 说明了工具名交接,却没有说明
整个 memory-core 被禁用后,OpenClaw 的 openclaw memory 命令以及其
dreaming/consolidation 生命周期在 adapter 启用期间也不可用。这是当前 contract 每次
enable 都会发生的用户可见行为,而非边缘情况。Possible direction: 在 en/zh 文档与风险说明中
明确这项取舍及 disable 后的恢复条件。

@Forrest-ly
Forrest-ly force-pushed the fix/AGE-7186-openclaw-adapter-enable-memory-handoff branch from 7c6157b to 676607e Compare September 11, 2026 06:13
@Forrest-ly

Copy link
Copy Markdown
Collaborator Author

@kongche-jbw 第 2 轮 6 条全部成立,已在 676607ea 修完(仍按仓库惯例 fixup 进原 commit,PR 保持单 commit:7c6157b5676607ea)。逐条回复:


[P1] 用解析后的插件 ID 拒绝自我 displacement — 已修

确认:declared_plugin_id 来自可选的 manifest plugin_id,省略时 unwrap_or_default()"",声明期校验必然放行;真实 ID 要到 read_bundleopenclaw.plugin.json(或回落 component 名)才出现。

采用你的方向,在 bundle ID 解析之后补第二道门禁 validate_displacements_not_self(&bundle, &ctx),在 manager 的两个 read_bundle 之后各调用一次(dry-run 分支与真实分支),所以两条路径的拒绝时机与结果完全一致,且都早于任何宿主调用。没有只放在 validate_prepared_enable 里:那个 hook 在 dry-run 路径根本不执行,计划会照样把「安装并验证自己,再禁用自己」列成正常动作。声明期那道门禁保留(能看见 plugin_id 时更早失败,错误信息也更直接)。

测试 enable_rejects_self_displacement_resolved_from_the_bundle:省略 plugin_id、bundle ID 与 displacement 相同,dry-run 与真实 enable 都返回 InvalidAdapterInput,并断言 argv log 完全为空、无 receipt、registry marker 不存在(fixture 刻意不预置 marker,因此 marker 只可能来自被拒绝的 enable)。另加 enable_rejects_self_displacement_against_a_declared_plugin_id 固定声明期那道门禁——这两道此前都没有测试覆盖。

[P1] re-enable 应释放新 contract 已删除的 displacement — 已修

确认,而且这个 bug 的方向比「不生效」更糟:它是自我固化的。探测因为插件已被本 adapter 禁用而产出空 next,preserve 把旧事实全量复制回来,same-home cleanup 因 prior_home == next_home 直接 no-op,apply_enable 再按(继承来的)旧 claim 禁用一次旧插件 —— 新 contract 永远没有机会生效。

按你的方向改成两半:

  1. 只继承当前 contract 仍声明的所有权。 preserve_reenable_facts 增加 ctx: &DriverCtx 参数(trait + Qoder 实现 + manager 调用点一并更新;Qoder 用 _ctx 忽略),preserve_openclaw_displaced_facts 逐条按 plugin id 在 ctx.declared_displaces 里查,查不到就不继承。顺带解析并校验 prior 的引用(claim_displaced_plugins(prior)?),此处在任何 mutation 之前,失败即无副作用。
  2. slot 取自当前声明,不取自历史。 DisplacedPluginRef.slotspec.slot,因为「哪个插件是本 adapter 关的」是历史事实,而「它该回到哪个独占 slot」是 contract 元数据 —— 改 slot 的升级必须按新 slot 恢复。
  3. 被丢弃的条目在替换 receipt 之前交还。 复用已有的 cleanup_replaced_claim hook(语义正是「释放替换 receipt 无法继续拥有的资源」,且 manager 保证它跑在 upsert_adapter_claimapply_enable 之前):新增 dropped_displaced_plugins(prior, next) 算出差集,交给 restore_displaced_plugins 走同一套 slot 守卫。跨 home 分支不需要额外处理,那里本来就调用完整的 self.disable(prior, ctx)。恢复失败 → cleanup_complete = falseReenableCleanupIncomplete,prior receipt 保持 durable,与既有的跨 home cleanup 失败语义一致。

三种场景各有测试:reenable_releases_a_displacement_the_new_contract_dropped(删除声明 ⇒ 跑 plugins enable memory-core、marker 清除、替换 receipt 的 payload 与 resource list 都不留痕)、reenable_swaps_a_displacement_the_new_contract_replaced(改 ID ⇒ 旧的交还、新的被 claim)、reenable_takes_the_slot_from_the_current_contract_not_the_prior_receipt(改 slot ⇒ receipt 记 memory2,且 disable 时把 plugins.slots.memory2 设为第三方、旧 plugins.slots.memory 设为本 adapter,断言恢复,证明守卫读的是当前 slot)。回退继承过滤或 slot 取值,这三个用例分别失败。

[P1] 在任何卸载动作前验证 displacement receipt — 已修

确认,而且 :1047-1049 那句注释是我上一轮写的,它断言了一件并不成立的事(manager.rs:951-957 只在 enable 时校验 contract 声明的格式与自我差异性,从不校验已落盘 receipt 的 payload 引用完整性)。注释已随实现改掉。

claim_displaced_plugins 从「解析」升级为「解析 + 校验」,逐项检查:引用可解析、purpose == openclaw_displaced_plugin、kind 是 FrameworkPlugin 且 framework 等于 receipt 自己的 framework、plugin id 通过 validate_plugin_id不等于本 adapter 自己的 plugin id、resource id 与 plugin id 均唯一、slot 通过 validate_config_key

其中「purpose + framework + 非自身」三项是专门针对你指出的「指向任意其他 FrameworkPlugin 的引用会驱动一次非预期的 plugins enable」:只查 kind 是不够的,指向本 adapter 自己的 openclaw_plugin 资源、或指向别的 framework 的插件资源,kind 都合法。为此新增 claim_own_plugin_id(),按 payload 的 plugin_resource 精确定位自己的插件,而不是像 claim_plugin_id() 那样取「第一个 FrameworkPlugin」—— 后者在含 displaced 资源的 receipt 上本身就是个隐患,restore_displaced_plugins 的 slot 守卫也已改用它。

调用点全部前移到首个动作之前:statusdetect 之前解析(错误直接 ? 上抛,与 Qoder native_claim 对不一致 receipt 的 fail-closed 先例一致,manager.status 会把它变成 BundleInvalid);disable 紧跟 claim_state_dir 之后、plugins uninstall 之前解析,并把结果传给 restore_displaced_plugins(不再各自解析一次);plan_enablepreserve/cleanup_replaced_claim 也在任何 mutation 前解析 prior。displaced_plugins_condition 因此不再需要吞错误,签名从 Option<AdapterCondition> 收窄为接收已校验切片的 AdapterCondition

测试用三种伪造(悬空引用 / 指向 openclaw_plugin / 同一引用重复)跑两组断言:status_refuses_to_report_a_corrupt_displacement_receipt(必须 BundleInvalid,绝不会是 Healthy)、disable_mutates_nothing_for_a_corrupt_displacement_receipt(必须 BundleInvalid本步新增的 argv 记录为空——无 uninstall、无 enable、无 disable,且自己的插件仍注册、被 displacement 的插件仍在 enable 留下的状态、receipt 保留可重试)。另加 enable_dry_run_rejects_a_corrupt_displacement_receipt_before_probing。去掉前置校验后前两个用例失败。

(说明:argv log 是整测试累积的,所以新增了 argv_appended(world, since) 只取本步追加的行 —— 否则会误把 fixture 那次真实 enable 的调用算进来。)

[P2] 让 dry-run 的恢复条件与真实 disable 一致 — 已修

确认两处都反了,而且是各自反一半:slotful 少说一个 veto,slotless 凭空多说一个 veto。现在两处文案逐字对应真实分支:

  • openclaw.rs plan_enable 的 slot_note:有 slot → "…unless that slot has been given to another plugin or explicitly closed by then";无 slot → "…and re-enable it on disable (no exclusive slot declared, so the restore is unconditional)"(此前是空串,对恢复行为只字不提)。carry-over 那条复用同一个 slot_note,所以两种 enable 预览口径一致。
  • manager.rs plan_disable_report:无 slot 时删掉虚构的 "unless the operator's own state requires leaving it off",直接输出无条件的 "would re-enable …";有 slot 时保持点名 slot + 两个 veto。

四个用例固定预览输出:enable_dry_run_preview_names_the_real_restore_conditions(slot 名 + 第三方 veto + 显式关闭 veto 三者都要在)、enable_dry_run_preview_says_a_slotless_restore_is_unconditional(必须含 unconditional,且不得出现任何 plugins.slots.)、disable_dry_run_preview_matches_the_slotless_restore_branch(预览不得含 unless,并接着跑真实 disable 验证确实无条件恢复 —— 预览与行为对拍)、disable_dry_run_preview_matches_the_slotful_restore_branch。回退文案后前三个失败(第四个本来就正确,作为对照保持通过)。

[P2] 移除仍声称脚本会执行 hand-off 的 rustdoc — 已修

确认,这是我上一轮的遗漏:我改了 module docs、用户指南和两份 contract 注释,漏了 manifest.rs 的 public rustdoc —— 而它恰恰是对外发布面最广的一处,也正是 specs/documentation-standard.md 禁止的那类「把未实现行为写成可用」。

DisplacedPluginSpec 的文档改为 driver-only contract 表述,并显式说明 bundle 自己的 install.sh / uninstall.sh 不读该声明、不执行交接,脚本安装需自行释放冲突的工具名;措辞与两份 contract 注释、openclaw.rs module docs、en/zh 用户指南一致。同时把 slot 字段文档里「只在 slot 属于第三方插件时跳过恢复」补成「第三方插件或被显式关闭」,并说明为什么(恢复会重跑框架的独占 slot 选择)。

顺带清掉 prepare_enable 里最后一处同类残留("That is the same asymmetry the bundle's install.sh applies.")。RUSTDOCFLAGS="-D warnings" cargo doc --no-deps -p anolisa-core 通过。

[P2] 文档应说明禁用 memory-core 的完整功能代价 — 已修

确认,而且同意这是「每次 enable 都会发生」而非边缘情况——上一轮我只写了工具名交接与 status degraded,把一个用户必然付出的取舍写成了纯收益。

en/zh 两份用户指南各加两段(紧跟 slot 守卫那段之后):

  • 代价:交接禁用的是整个自带插件,不只是冲突的那两个工具名。只要 agent-memory 处于启用状态,memory-core 在这两个名字之外提供的一切都不可用 —— 它自己的 memory_* 工具、openclaw memory 命令入口、以及它在后台运行的 dreaming / consolidation 生命周期。明确写出「这不是边缘情况:每次 adapter enable 都会如此」。
  • 数据边界:这期间检索由 agent-memory 承担(指向 MCP tool setAuto consolidation 两节),但它读写 ~/.anolisa/memory,不是 memory-core 自己的存储;memory-core 此前积累的内容原封不动、同时不可访问,要等该插件重新启用后才恢复,且两边存储不会自动合并。
  • 恢复条件anolisa adapter disable agent-memory 恢复,前提是满足 slot 守卫;若 slot 已交给别的后端或被显式关闭,则需自行 openclaw plugins enable memory-core;任一变更后 openclaw gateway restart

PR body 的 Docs 与 Risk 两节同步写入这项取舍。scripts/docs-lint.sh(命名 + en/zh 树对齐)与 scripts/docs-link-check.py 均通过。


验证

本地全绿:cargo fmt --all --checkcargo clippy --all-targets --locked -- -D warningscargo test --workspace --locked2547 passed / 0 failed:anolisa-cli lib 1072、anolisa-core lib 1083、adapter_manager 111、adapter_frameworks 68、adapter_contract 28、doc-tests)、RUSTDOCFLAGS="-D warnings" cargo doc --no-deps -p anolisa-corescripts/docs-lint.shscripts/docs-link-check.pysrc/agent-memorymake test-openclaw-installgit diff --check

两轮共 9 条意见的每一处修复都做了反向验证:单独回退该修复,对应用例即以你描述的症状失败(本轮:自我 displacement 1 条、继承过滤/slot 3 条、receipt 前置校验 2 条、预览文案 3 条)。

两处内部 trait 签名变更:plan_enable 增加它将要替换的 receipt(上一轮),preserve_reenable_facts 增加当前 contract 的 DriverCtx(本轮)。9 个 driver 全部更新,只有 OpenClaw 读取这两个参数。PR body 的 Risk 一节列出了 5 项有意的行为收窄,其中新增的一项是:损坏的 displacement receipt 现在 fail closed(status 不再报 Healthy,disable 不再半途中断),以及 re-enable 在 cleanup_replaced_claim 阶段交还被丢弃的 displacement,失败则整个 re-enable 失败并保留 prior receipt。

请再 review。

@kongche-jbw kongche-jbw left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review baseline: a3e641a3ed7e1bc622de9849df5911500a896fb6...676607eacf1c62b9165a3e453e1830473ba34950

[P1] 在 enable 前拒绝重复的 displacement 所有权

src/anolisa/crates/anolisa-core/src/adapter/manager.rs:945 只逐项校验,未检查重复 ID
或重复的非空 slot。重复 ID 会生成两份同名 resource/reference:receipt 通过通用校验并先
落盘,自己的插件也已安装,随后 claim_displaced_plugins 才报重复;该
cleanup-failed receipt 连 status/disable 都无法消费。两个不同插件声明同一 slot 时,
disable 又会先恢复第一个,再把它当作第二个的 third owner,删除 receipt 却让第二个继续
处于本 adapter 造成的禁用状态。Possible direction: 在任何宿主探测前校验 ID 与非空 slot
唯一;分别补充重复 ID 零 mutation、重复 slot 不遗留禁用插件的 Manager 回归测试。

[P1] 从 payload 引用解析 adapter 自己的插件

src/anolisa/crates/anolisa-core/src/adapter/openclaw.rs:906 仍调用
claim_plugin_id,而该 helper 返回 resources 中第一个 FrameworkPlugin。本 PR 让合法
receipt 同时包含 adapter 与 displaced 插件;只要手工编辑时调整 resource 顺序,通用校验
和新的 displacement 校验都通过,disable 却会对 memory-core 执行 uninstall,自己的
插件反而保留;status 与迁移预览也会检查错对象。Possible direction: 始终通过
OpenClawClaim.plugin_resource 解析并校验 purpose/framework/顶层 ID 一致性;补充交换两项
resource 顺序后不得向 displaced ID 发出 uninstall 的回归测试。

[P2] 在 re-enable dry-run 中列出被删除声明的恢复动作

src/anolisa/crates/anolisa-core/src/adapter/openclaw.rs:331 对同一 home 直接返回空 cleanup
计划,而 plan_enable 又只遍历当前 contract。成功 enable 后若新 contract 删除或替换
一个 displacement,dry-run 因此完全不显示旧插件会被 re-enable;真实路径却在
cleanup_replaced_claim 中先执行该 mutation。Possible direction: 计划阶段比较 prior receipt
与当前声明并列出 dropped restore;补充 enable 后删除及替换声明的 dry-run 对拍测试。

[P2] disable dry-run 也应拒绝损坏的 displacement receipt

src/anolisa/crates/anolisa-core/src/adapter/manager.rs:1468 在 dry-run 分支仅做通用 receipt
校验便调用 plan_disable_report,没有触发 OpenClaw 新增的 reference/purpose/唯一性校验。
同一份 dangling 或重复 reference 会得到成功的 disable 计划,真实 disable 却在首个动作前
BundleInvalid,预览无法反映操作是否可执行。Possible direction: 在 real/dry-run 共用的
driver 校验钩子中验证 receipt;让现有三类 corrupt-receipt fixture 也覆盖 disable dry-run。

@Forrest-ly
Forrest-ly force-pushed the fix/AGE-7186-openclaw-adapter-enable-memory-handoff branch from 676607e to b62c527 Compare September 11, 2026 06:59
@Forrest-ly

Copy link
Copy Markdown
Collaborator Author

@kongche-jbw 第 3 轮 4 条全部成立,已在 b62c5278 修完(仍按仓库惯例 fixup 进原 commit,PR 保持单 commit:676607eab62c5278)。逐条回复:


[P1] 在 enable 前拒绝重复的 displacement 所有权 — 已修

确认,而且这两种重复的失败方式完全不同,重复 slot 那种更危险,因为它是静默的:

  • 重复 ID:两份同名 resource/reference。receipt 通过通用 validate_with_trust 落盘、apply_enable 装好自己的插件,直到 claim_displaced_plugins 才发现重复 —— 留下一个 cleanup_failed自己的 driver 都读不了的 receipt(status/disable 都在解析引用时 fail closed)。
  • 重复 slot:一路成功,只在 disable 时出错。slot 是独占的,恢复第一个插件后它就成了该 slot 的 owner,第二个的守卫随即把它读成「第三方 owner」并让位,receipt 被删除,而第二个插件仍处于本 adapter 造成的禁用状态、且再无记录说明原因。

按你的方向,在 manager.rs:945 那个循环里补跨项去重(BTreeSet,ID 与非空 slot 各一个),位置仍在任何宿主探测之前,dry-run 与真实路径共用。之所以只能放在这里:每一项单独看都合法,而下游直到 receipt 落盘之后才会同时看到两项。

测试:enable_rejects_a_duplicated_displacement_id_before_touching_the_host(dry-run 与真实两种模式都拒绝,并断言 argv log 完全为空、无 receipt、registry marker 不存在、memory-core 未被禁用)与 enable_rejects_two_displaced_plugins_sharing_one_exclusive_slotmemory-corememory-lancedb 共用 memory slot;除拒绝外,逐个断言两个插件都没有被留在禁用状态 —— 即你要求的「不遗留禁用插件」)。回退去重门禁后这两个用例失败。

[P1] 从 payload 引用解析 adapter 自己的插件 — 已修

确认,而且这个隐患是本 PR 自己引入的:在 receipt 只可能含一个 FrameworkPlugin 时,「取 resources 里第一个」是个安全的简写;本 PR 让合法 receipt 同时含 adapter 与 displaced 两个插件之后,它就不再安全了 —— 而 resources 是按 id 索引的集合,顺序不构成任何承诺,所以手工调序是一个通用校验与新增 displacement 校验都会放行的合法编辑。

新增 claim_own_plugin(claim) -> Result<Option<String>, AdapterError>,始终经 OpenClawClaim.plugin_resource 解析,并做你点名的三项一致性校验:

  1. purpose == openclaw_plugin(新增 PURPOSE_PLUGIN 常量,prepare_enable 也改用它,避免字面量与校验各写一份);
  2. kind 是 FrameworkPlugin framework 等于 receipt 自身的 framework
  3. 顶层 claim.plugin_id 与该 resource 的 plugin id 一致;skill bundle(plugin_resource 为空)时反过来要求顶层 ID 也必须为空,否则就是「声明了插件却没有对应 resource」。

claim_plugin_id(第一个 FrameworkPlugin)已彻底删除,全部 4 个调用点改用新解析器:disable:906)、statusPluginRegistered:815)、apply_enable 前置(:675)、迁移预览 plan_reenable_cleanup:336);上一轮引入的 claim_own_plugin_id 也并入它,restore_displaced_pluginsclaim_displaced_plugins 的 slot 守卫随之复用同一套校验。

测试 disable_uninstalls_its_own_plugin_even_with_receipt_resources_reordered:真实 enable 后把 openclaw_plugin 与 displaced 两项 resource 交换落盘(并断言 fixture 确实调了序),随后 disable 必须 plugins uninstall tokenless…绝不能出现 plugins uninstall memory-core、且 memory-core 是被 plugins enable 交还而非卸载,receipt 正常删除。回退为「取第一个」后该用例失败(会向 memory-core 发 uninstall)。

[P2] 在 re-enable dry-run 中列出被删除声明的恢复动作 — 已修

确认,这是上一轮修 P1-2 时留下的预览缺口:我把真实的 restore 放进了 cleanup_replaced_claim,却没有让任何计划函数描述它 —— plan_enable 结构上不可能描述(它只遍历当前 contract),而 plan_reenable_cleanup 在 same-home 直接早退。结果 dry-run 对一次真实会先执行的宿主 mutation 完全沉默。

plan_reenable_cleanup 的 same-home 分支改为返回 plan_dropped_displacement_restores(prior, ctx):解析并校验 prior 的 displacement,凡当前 ctx.declared_displaces 不再声明者列出一条动作。跨 home 分支不变(那里本来就调用完整的 self.disable(prior, ctx),已经覆盖)。文案与真实分支逐字对齐,包含 slot 守卫的两种 veto;无 slot 时明说恢复是无条件的。manager 把 plan_reenable_cleanup 的输出前置到 plan actions,正好对应 cleanup_replaced_claim 早于 apply_enable 的真实执行顺序。

三个对拍测试(都在成功 enable 之后):reenable_dry_run_lists_the_restore_of_a_dropped_displacement(删除声明 ⇒ 计划必须出现该 restore,且 dry-run 不执行它、宿主状态不变)、reenable_dry_run_lists_both_halves_of_a_replaced_displacement(替换声明 ⇒ 旧插件交还 新插件接管两条都要在,且新插件不得同时被列为 dropped)、reenable_dry_run_says_a_dropped_slotless_restore_is_unconditional(无 slot ⇒ 文案含 unconditional 且不得出现任何 plugins.slots.)。回退早退后这三个失败。

[P2] disable dry-run 也应拒绝损坏的 displacement receipt — 已修

确认,根因是 dry-run 分支根本不调用 driver:plan_disable_report(&claim) 是 receipt 的纯函数,只看 cleanup_ids / restore_ids 与 resource kind,永远碰不到 payload 的引用完整性,所以任何损坏 receipt 都能拿到一份「成功」的计划。

按你的方向新增 real/dry-run 共用的 driver 校验钩子 FrameworkDriver::validate_claim(&self, claim)(默认接受,供 payload 无交叉引用的 driver 使用),在 manager.rsclaim.validate_with_trust(...) 之后、if dry_run 分支之前调用一次 —— 一个调用点同时覆盖两条路径,不会再出现只修一边的情况。OpenClaw 的实现解析自身插件引用与全部 displacement 引用(即上一轮那套 reference/purpose/framework/唯一性/非自身/slot 校验)。

顺带把 Qoder 也接进同一个钩子(native_claim):它的 disable/status 早已对不一致 receipt fail closed,而 dry-run 同样只走 plan_disable_report,属于同一缺陷类;既然钩子已经引入,一行接入即可,避免留下第二处同类不一致。其余 7 个 driver 用默认实现,行为不变。

测试:把上一轮那三类 corrupt-receipt fixture(悬空引用 / 指向 openclaw_plugin / 同一引用重复)扩成先跑 disable --dry-run 再跑真实 disable,两者都必须返回 BundleInvalid,且 dry-run 阶段 argv log 长度不变(一次宿主调用都不发)。回退钩子后该用例失败。


验证

本地全绿:cargo fmt --all --checkcargo clippy --all-targets --locked -- -D warningscargo test --workspace --locked2553 passed / 0 failed:anolisa-cli lib 1072、anolisa-core lib 1083、adapter_manager 117、adapter_frameworks 68、adapter_contract 28、doc-tests)、RUSTDOCFLAGS="-D warnings" cargo doc --no-deps -p anolisa-corescripts/docs-lint.shscripts/docs-link-check.pygit diff --check

本轮 4 处修复同样逐一反向验证:单独回退后对应用例即以你描述的症状失败(重复 ID/slot 门禁 2 条、自身插件解析 1 条、dropped-restore 预览 + disable dry-run 校验 4 条)。

一处附带修正需要报备:manager.rs 的单测 fixture openclaw_claim 声明了 plugin_resource = "openclaw_plugin" 却给出空的 resources —— 一个此前无人校验的悬空引用。新的 validate_claim 正确地拒绝了它,导致两个与 receipt 形状无关(它们测的是 adapter source 缺失)的用例失败。已让该 fixture 带上它所引用的 resource,而不是放宽校验;两个用例的断言未改动。

PR body 已同步更新(What changed 三处、Test、Risk 三处新增收窄项)。本轮新增的对外可见行为变化:adapter enable 拒绝重复 ID / 重复 slot 的 contract;adapter disable --dry-run 与真实 disable 一样拒绝损坏 receipt(Qoder 的 dry-run 也一并变得诚实);自身插件引用不可解析或与实际 resource 不一致的 receipt 现在会被 status/disable 拒绝。

请再 review。

@kongche-jbw kongche-jbw left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review baseline: a3e641a3ed7e1bc622de9849df5911500a896fb6...b62c52786775b4c9c2838e2f6e6d1ccabe7147b8

[P1] 不要跨 OpenClaw state dir 继承 displacement 所有权

src/anolisa/crates/anolisa-core/src/adapter/openclaw.rs:3864 在所有 re-enable 中都把
prior displacement 合并到 next,未比较两份 receipt 的 state dir。若旧 home 中是本
adapter 禁用 memory-core,而新的 OPENCLAW_STATE_DIR 里该插件本就由用户禁用,
prepare 会正确地不 claim;这里却重新 claim,随后旧 home cleanup 恢复旧实例、新 home
apply 又禁用新实例,最终 disable 会在新 home 重新启用用户原本关闭的插件。dry-run
也在 :285 把旧事实显示为 carry-over,并漏掉旧 home 的 restore。Possible direction:
只在 state dir 相同时继承 displacement;跨 home 时完整计划/执行 prior disable,并按
新 home 的探测结果建立所有权。补充新 home 预先禁用该插件的 migration 生命周期测试。

[P1] 在 receipt 侧也校验 slot 的所有权约束

src/anolisa/crates/anolisa-core/src/adapter/openclaw.rs:3742 只去重 resource 与 plugin id,
没有去重非空 slot;手工把两个合法 displacement 的 receipt 改为同一 slot 后,
validate_claim 仍会放行。真实 disable 恢复第一个插件后,会把它识别成第二个插件的
third owner,跳过第二个恢复,却仍以 cleanup complete 删除 receipt,留下无主的禁用
插件。另外 manager.rs:980 和这里都通过校验拼接后的 plugins.slots.{slot},所以
slot = "" 也会被接受并退化为无守卫恢复。Possible direction: 消费 receipt 时去重非空
slot,并直接拒绝空 suffix;补充 corrupt receipt 的 dry-run/real 零 mutation 测试和空
slot contract 的 enable 拒绝测试。

[P2] 为多个异常 displacement 分别输出修复命令

src/anolisa/crates/anolisa-core/src/adapter/openclaw.rs:1152 把多个 plugin id 用逗号连接后
嵌入一条命令。两个插件同时被重新启用时,status 会建议执行
openclaw plugins disable a, b,这会传入错误参数,无法完成提示的直接修复;Unknown
分支也会虚构 plugins.entries.a, b.enabled 这个不存在的 key。Possible direction: 为每个
插件分别呈现命令与 key,并增加多 displacement 的 status 回归测试。

@Forrest-ly
Forrest-ly force-pushed the fix/AGE-7186-openclaw-adapter-enable-memory-handoff branch from b62c527 to 48fc01a Compare September 11, 2026 07:36
@Forrest-ly

Copy link
Copy Markdown
Collaborator Author

@kongche-jbw 第 4 轮 3 条全部成立,已在 48fc01af 修完(仍按仓库惯例 fixup 进原 commit,PR 保持单 commit:b62c527848fc01af)。逐条回复:


[P1] 不要跨 OpenClaw state dir 继承 displacement 所有权 — 已修

确认,而且这条比前几轮更隐蔽:它需要「迁移 + 新 home 里用户自己关过同一个插件」两个条件同时成立才发作,一旦发作后果是反向覆盖用户在新 home 的选择——正是第 1 轮 P1-2(显式 none)在另一个维度上的同一类错误。

按你给的方向修,并采纳了「不需要新字段」这一点:receipt 早就通过 OpenClawClaim.state_dir_resource 记录了它写向哪个目录,所以门禁就是一次路径比较。

  1. 继承门禁preserve_openclaw_displaced_facts):在 prior_entries.is_empty() 早退之后、解析引用之前加 if claim_state_dir(prior)? != require_home(ctx)? { return Ok(()) }。跨 home 时所有权来自新 home 自己的探测——用户自己关掉的插件 prepare_enable 本来就不 claim,这里也不再替它 claim。放在 empty 早退之后是为了不让「无 displacement 的 receipt」也去解析 state dir,保持既有行为。
  2. dry-run 同一门禁plan_enable:285):carried 只在 claim_state_dir(prior)? == home 时取 prior 的 id,否则为空、走新 home 的实时探测。预览与真实路径现在共用同一条规则,不会再出现「计划说 carry-over、真实却不继承」。
  3. 跨 home 的 restore 补进计划plan_reenable_cleanup):cross-home 分支本来就执行完整的 self.disable(prior, ctx),而它会交还 prior 的每一个 displacement,但计划里只列了 unregister 与 skill 移除——正是你指出的「漏掉旧 home 的 restore」。现在按 prior receipt 逐条列出「re-enable openclaw plugin '' in the prior state directory <old_home>」,并带上真实分支的 slot 条件(有 slot 说两种 veto,无 slot 说无条件)。base_cmd逐命令用传入的 home 设 OPENCLAW_STATE_DIR 的,所以这些 restore 确实作用于旧 home,与环境变量指向无关。

测试:migration_does_not_inherit_displacement_ownership_the_new_home_never_had —— 旧 home 里由本 adapter 禁用 memory-core,随后切到第二个 state dir,并在其中预先放置用户自己的禁用状态(registry/ + disabled/memory-core + config/plugins.entries.memory-core.enabled=false)。re-enable 后断言:旧 home 的禁用标记已清除(在拿走它的地方交还)、新 home 的用户禁用原样保留、迁移后 receipt 指向新目录且 displaced_plugins 为空;最后再跑一次 disable,断言 argv 中没有 plugins enable memory-core 且新 home 的禁用标记仍在——即你描述的「最终 disable 会在新 home 重新启用用户原本关掉的插件」这条后果链被堵住。另加 migration_dry_run_plans_the_prior_homes_restore_not_a_carryover(计划必须点名旧 home 的 restore、不得出现 carry-over 文案、且不执行任何 mutation)。回退继承门禁与计划门禁后这两个用例失败。

[P1] 在 receipt 侧也校验 slot 的所有权约束 — 已修

两点都确认。特别是「validate_config_key 只拒绝整键为空、. 不在 BANNED 列表」这条:拼接后的 plugins.slots. 非空,所以 slot = "" 一路放行,并静默退化成无守卫恢复——比报错更糟,因为它看起来像是「声明了 slot」。

  • receipt 侧去重 + 拒空claim_displaced_plugins):新增 slots_taken: BTreeMap<&str, &str>(slot → 已占用它的 plugin id,便于错误信息同时点名两者),非空 slot 重复即拒绝,理由写明后果链:「restoring the first would make it a third owner for the second, which disable would then leave behind」;slot 为空串直接拒绝。因为 validate_claim 已接入 status / disable(real + dry-run)/ re-enable 各路径,这一处校验自动覆盖全部消费点。
  • contract 侧拒空manager.rs:980):在 validate_config_key 之前显式拒绝 slot.is_empty(),错误信息直接告诉作者正确写法是省略 slot(那才是「无条件恢复」的表达方式),而不是写一个空串。
  • 上一轮已有的声明期 slot 去重(declared_displaced_slots)保留,两侧规则现在一致。

测试:a_receipt_sharing_one_slot_between_two_displacements_is_rejected(先声明两个不同 slot 的合法 displacement 并真实 enable,再手改 receipt 让第二个共用 memory;status / disable dry-run / 真实 disable 三者都必须以同一理由拒绝,且断言 argv log 长度不变——零 mutation、两个插件都仍在 enable 留下的禁用状态、receipt 保留待修);a_receipt_with_an_empty_slot_suffix_is_rejected(同样三路径 + 零 mutation);enable_rejects_a_displacement_declaring_an_empty_slot(contract 侧 slot = "",dry-run 与真实两种模式都拒绝,argv log 完全为空、无 receipt、插件未被禁用)。回退这三处校验后对应用例失败(3 条)。

[P2] 为多个异常 displacement 分别输出修复命令 — 已修

确认两个分支都坏了,而且 Unknown 分支更糟:它不只是命令不可执行,而是虚构了一个从未存在过的 config key,会把排查者引向一个查不到的地方。

  • False 分支:保留 '{names}' 用于陈述「哪些插件被重新启用了」(这里逗号列表是自然语言,不是 argv),但修复命令改为逐插件生成并用 " and " 连接——re-disable each one yourself with `openclaw plugins disable memory-core` and `openclaw plugins disable memory-lancedb` 。每一条都可单独复制执行。
  • Unknown 分支:不再把 id 列表插进 key 模板,而是逐插件生成真实 key 后列出——cannot read plugins.entries.memory-core.enabled, plugins.entries.memory-lancedb.enabled, so the displacement of '...' is unverified

测试:status_gives_one_remediation_command_per_displaced_plugin(两个 displacement 同时被外部重新启用 ⇒ 断言 reason 含各自独立、带反引号闭合的 openclaw plugins disable <id>,且不含 disable memory-core, memory-lancedb 这种拼接形式);status_names_one_real_config_key_per_unverified_displacementFAKE_OC_PROBE_FAIL=config_get ⇒ 断言两个真实 key 都在,且不含拼接出来的假 key)。回退为拼接后这两个用例失败。


验证

本地全绿:cargo fmt --all --checkcargo clippy --all-targets --locked -- -D warningscargo test --workspace --locked2560 passed / 0 failed:anolisa-cli lib 1072、anolisa-core lib 1083、adapter_manager 124adapter_frameworks 68、adapter_contract 28、doc-tests)、RUSTDOCFLAGS="-D warnings" cargo doc --no-deps -p anolisa-corescripts/docs-lint.shscripts/docs-link-check.pygit diff --check

本轮 3 处修复同样逐一反向验证:单独回退后对应用例即以你描述的症状失败(跨 home 继承门禁 2 条、receipt 侧 slot 约束 3 条、逐插件修复命令 2 条)。四轮合计 16 条意见全部如此验证过。

openclaw.rs module docs 已同步(继承的 state-dir 限制与理由、receipt 侧重新施加的 slot 规则、跨 home 时 cleanup 在旧 home 全量交还)。PR body 已更新(What changed 四处、Test 四处、Risk 新增三项收窄)。

本轮新增的对外可见行为变化:跨 state dir 的 re-enable 不再继承 prior receipt 的 displacement 声明(新 home 的所有权只来自对新 home 的探测,旧 home 的 displacement 在 cleanup 阶段就地交还);receipt 若含空 slot 后缀或两个条目共用一个独占 slot,status/disable/disable --dry-run 一律拒绝;contract 声明 slot = "" 在 enable 时被拒绝。

请再 review。

@kongche-jbw kongche-jbw left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review baseline: a9673ddab0ac1695a51db084e8b13f4582d591a7...48fc01af781ff170892156bbbd4c5d92a1897c5e

[P1] 按有效策略和插件清单判定 displacement 所有权

openclaw.rs:1112 只读取 plugins.entries.<id>.enabled,并把缺失键和所有探测失败
都当作可 claim。若用户通过 plugins.deny 或限制性 plugins.allow 禁用了
memory-core,enable 仍会认领这次禁用;随后 disable 在 openclaw.rs:1342 尝试恢复时,
OpenClaw 会以策略阻止而失败。此时自身插件已经卸载,但 receipt 会永久保留,且实现违反
“不恢复用户原先禁用的插件”的承诺。契约里的 id 拼错或宿主移除该插件也会在自身插件完成
安装后才由 plugins disable 报错,并在后续 restore 中以相同方式无法收敛。
Possible direction: 在任何 mutation 前从有效插件清单验证 id 存在且实际启用;策略已禁用的
插件不要 claim,restore 时把“已不存在”或“被显式策略阻止”视为所有权已释放。请补充
deny/allow 以及缺失 displaced id 的完整 enable→disable 回归测试。

[P2] 重启前不要把 hand-off 报告为 Healthy

openclaw.rs:1255 成功执行的 plugins disable 只更新配置;OpenClaw 明确要求重启
Gateway 才应用。用户尚未执行文档中的重启时,运行中的 memory-core 仍占用工具名,但
openclaw.rs:1181 只读新的配置值,因此 adapter status 会返回 Healthy。现有 fake CLI
把配置变化立即映射为 runtime disabled,无法覆盖这个窗口。Possible direction: 在状态中区分
pending restart,或通过真实 live-runtime 证据确认 hand-off 后再报告 Healthy,并增加配置状态
与运行时状态分离的测试。

@Forrest-ly
Forrest-ly force-pushed the fix/AGE-7186-openclaw-adapter-enable-memory-handoff branch from 48fc01a to 3519231 Compare September 11, 2026 08:39
@Forrest-ly

Copy link
Copy Markdown
Collaborator Author

@kongche-jbw 第 5 轮 2 条全部成立,已在 35192319 修完(仍按仓库惯例 fixup 进原 commit,PR 保持单 commit:48fc01af35192319)。另把你没提、但 Monitor 自查出的那条 P3(错位挂载的 doc comment)也一并带走了。逐条回复:


[P1] 按有效策略和插件清单判定 displacement 所有权 — 已修

确认,而且 Monitor 的取证把这条钉死了:全文 plugins.deny / plugins.allow 出现 0 次,即有效策略这一维从未被读取;而 read_plugin_enablement 的三态里有两种(CLI 不可用、非零退出)都落到「可 claim」。后果链里最要命的是它不收敛:1343-1355 恢复失败时 cleanup_complete = false 且明写「receipt is kept so disable can be retried」,但策略阻止是稳定失败,重试永远不成功,而此时自身插件已经卸载。

按你的方向做了三层,并采纳了 Monitor 的可复用情报(build_list_cmd / list_contains_plugin / build_inspect_cmd / help_lists_flag 全部复用,没有新增任何宿主接口):

1. claim 前:清单 + 策略 + 启用位(新 displacement_probe,取代原 displacement_is_claimable,返回三态 Claim / AlreadyOff(reason) / NotOnHost

  • 清单plugins list 可读且不含该 id ⇒ NotOnHost,在任何 mutation 之前以 InvalidAdapterInput 失败,理由直接指向 [[adapters.openclaw.displaces]] 的 id 与本机 OpenClaw 版本不匹配。这修掉了你说的「id 拼错或宿主移除该插件要等自身插件装完才报错」——错误类型也刻意选 InvalidAdapterInput 而非 FrameworkCli:宿主把 plugins list 答得好好的,是声明与这台机器不符,重试不会改变任何事。
  • 策略plugins.deny 含该 id ⇒ AlreadyOff("excluded by plugins.deny")plugins.allow 可读且非空却不含该 id ⇒ AlreadyOff("excluded by plugins.allow")。两者都不 claim,因此也不会承诺一个宿主必然拒绝的 restore。
  • 启用位:原有的 plugins.entries.<id>.enabled == false 保留为第三种 AlreadyOff
  • 调用点覆盖 real 与 dry-run 两条路径(prepare_enable:482plan_enable:328 共用同一函数),清单每次 enable/plan 只读一次(且仅在 contract 确实声明了 displacement 时才读,不声明的 adapter 一次额外调用都没有);dry-run 的 AlreadyOff 文案带上具体原因,不再是笼统的「already disabled before this adapter」。

这里有一个我刻意保守、需要你确认的判断plugins.allow 我只在非空时才当作限制。理由是 allowlist 在很多宿主上只门禁非自带插件的安装,而 memory-core 是 OpenClaw 自带的——如果它天然不需要出现在 allow 里,那么「allow 非空且不含 memory-core ⇒ 已被禁用」这个推断就会在所有使用 allowlist 的宿主上成立,从而把整个 hand-off 静默关掉,也就是本 PR 要修的那个 bug 本身。所以我给 allow 加了「非空」前置条件,并配了 enable_still_claims_a_displacement_when_the_allow_list_is_unset"" / [] / null / none 四种空态都仍须完成 hand-off)。如果 OpenClaw 的 plugins.allow 确实同样门禁自带插件,请告诉我,我把这条前置去掉;在那之前我选择不冒「静默失效」的风险。

2. restore 时:不存在 / 被策略阻止 ⇒ 视为所有权已释放restore_displaced_plugins

  • 清单可读且不含该插件 ⇒ 消息「it is no longer in this host's plugins list inventory, so the displacement is already released」,cleanup_complete 保持 true
  • policy_blocking_plugin 命中 ⇒ 消息点名是哪个 key、说明宿主会拒绝该 restore、并给出手动出路(「remove it from <key> and run openclaw plugins enable <id> yourself」),同样保持 true
  • 这两条正是让 disable 收敛的关键:它们不是失败,重试也永远不会成功,报 cleanup_complete = false 只会把 receipt 永久钉在那里(而此时自身插件已卸载)。

3. 「探测不到」仍然不等于「不存在/已关闭」

这条纪律贯穿全部新探针:plugins list 不可读 ⇒ 不判 NotOnHost,照旧 claim(enable_still_claims_when_the_inventory_probe_cannot_run 固定住这一点);plugins.deny / plugins.allow 不可读 ⇒ 无结论;空 allow ⇒ 非限制。理由和原来一致且不对称:漏 claim 会让宿主在 disable 后 slot 后面什么都没有,而多 claim 只花操作者一次 plugins disable

测试(7 个,全部 enable→disable 完整生命周期):enable_does_not_claim_a_displacement_an_explicit_deny_list_blocks..._a_restrictive_allow_list_excludesenable_still_claims_a_displacement_when_the_allow_list_is_unset(四种空态)、enable_fails_before_installing_when_the_displaced_id_is_not_on_the_host(dry-run + real 两种模式,断言没有真实 plugins install、没有 plugins disable、无 receipt、无 registry marker)、enable_still_claims_when_the_inventory_probe_cannot_rundisable_treats_a_policy_blocked_restore_as_released(enable 之后加 deny,断言 cleanup_complete、receipt 被删、消息点名 key、且没有发出会被拒的 plugins enable)、disable_treats_a_vanished_displaced_plugin_as_released(删掉 registry marker 后 disable)。

[P2] 重启前不要把 hand-off 报告为 Healthy — 已修

确认,而且这不是理论窗口:文档本来就要求 enable/disable 后执行 openclaw gateway restart,所以在重启之前 status 报 Healthy 恰恰是在用户最可能去查的时刻给出错误答案——配置已写、memory_get 却仍由 memory-core 应答。

按你的第二个方向做(用真实 live-runtime 证据确认 hand-off),复用 verify_runtime 已有的 plugins inspect <id> [--runtime] --jsonplugin.status

  • displaced_plugins_condition 现在对每个 config 判定为 Disabled 的插件再探一次运行态:status == "loaded" ⇒ 记入 pending_restart ⇒ 条件 False ⇒ summary Degraded,理由给出唯一能修好的命令 openclaw gateway restart,并说明「若重启后仍是 loaded,说明有别的东西重新启用了它」+ 逐插件的 plugins disable 命令。
  • --runtime 是否可用按 plugins inspect --help 探测(每次 status 一次,不是每插件一次),只在宿主声明该 flag 时才传——与 plugin_preflight 的既有做法一致。
  • 运行态探不到时不猜inspect 不可用 / 非零退出 / JSON 里没有 plugin.status ⇒ 保持「仅配置」的原判定,不降级。理由:对不支持 runtime 探测的宿主一律报 Degraded 是把猜测包装成结论,而 verify_runtime 自己在 enable 时也是回退到不带 --runtime 的。
  • 优先级:re_enabled(被外部重新启用)> pending_restart(未重启)> unverified(Unknown)> True

测试(3 个):status_reports_a_pending_restart_instead_of_healthy(配置态已 disabled、运行态仍 loaded ⇒ Degraded + False + reason 含 openclaw gateway restart 与插件名)、status_is_healthy_once_the_gateway_has_applied_the_handoff(同一 fixture 不带该 knob ⇒ Healthy + True;这一对正是你要求的「配置状态与运行时状态分离」的固定测试)、status_keeps_the_config_verdict_when_the_runtime_probe_cannot_runFAKE_OC_PROBE_FAIL=inspect ⇒ 仍为 True/Healthy)。

为让这条可测,fake CLI 增加了 FAKE_OC_PENDING_RESTART=<id>|*(让 plugins inspectdisabled/<id> 标记存在时仍报 loaded),以及 FAKE_OC_PROBE_FAIL=inspect(只让 inspect <id> 失败,--help 探测不受影响,因此能力判定照常)。你指出的「现有 fake 把配置变化立即映射为 runtime disabled,覆盖不到这个窗口」正是这两个 knob 要解决的;两者都已登记进 OWNED_ENV 以免跨用例泄漏。

en/zh 用户指南也补上了这个窗口:adapter status 在 enable 与 restart 之间会报 degraded 并直接给出重启命令,原因是 plugins disable 只写配置。

[P3] 错位挂载的 doc comment — 已修(Monitor 自查项)

确认是本 PR 的移动/插入留下的回归:claim_skill_resources 被移到 :4106 时丢了自己的文档,而那三行留在了 DisplacedPlugin 头上,导致 DisplacedPlugin 的 rustdoc 混入一段与它无关的 skill-name 说明(cargo doc 不报错,所以四轮都没被发现)。已把三行移回 claim_skill_resources 上方,DisplacedPlugin 只保留「One displaced framework plugin resolved from a receipt.」。


验证

本地全绿:cargo fmt --all --checkcargo clippy --all-targets --locked -- -D warningscargo test --workspace --locked2570 passed / 0 failed:anolisa-cli lib 1072、anolisa-core lib 1083、adapter_manager 134adapter_frameworks 68、adapter_contract 28、doc-tests)、RUSTDOCFLAGS="-D warnings" cargo doc --no-deps -p anolisa-corescripts/docs-lint.shscripts/docs-link-check.pysrc/agent-memorymake test-openclaw-installgit diff --check

本轮 3 处修复(含 P3 之外的两处 P1/P2 的三个侧面)同样逐一反向验证:单独回退后对应用例即以你描述的症状失败(清单/策略 claim 门禁 3 条、restore 侧释放 2 条、配置态 vs 运行态 1 条)。五轮合计 18 条意见全部如此验证过。

openclaw.rs module docs 已同步(enable 前的清单+策略校验、策略/消失视为已释放、status 同时读配置与运行态)。PR body 已更新(prepare_enable / disable / status 三处、Test 两处、Risk 新增三项收窄,并把「只认正面证据」这条纪律单独成段)。

本轮新增的对外可见行为变化:adapter enable 在声明的 displaced id 不在可读清单中时安装前即失败;plugins.deny / 非空 plugins.allow 已关掉的插件不再被 claim;adapter disable 把「宿主已无此插件」或「策略阻止恢复」视为已释放(receipt 正常删除,不再永久保留);adapter status 在 enable 与 gateway restart 之间报 degraded。

一个需要你确认的开放问题(上面 P1 里已详述):OpenClaw 的 plugins.allow 是否同样门禁自带插件?我目前只在 allow 非空时才把它当限制,以免在自带插件天然不入 allow 的宿主上静默关掉整个 hand-off。若你确认 allow 也覆盖自带插件,我去掉这个前置条件并补测试。

请再 review。

@kongche-jbw kongche-jbw left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review baseline: a9673ddab0ac1695a51db084e8b13f4582d591a7...35192319565c7e043a3f657f5fe2027714823080

[P1] 按 JSON 数组解析 OpenClaw 插件策略

src/anolisa/crates/anolisa-core/src/adapter/openclaw.rs:1217
config get plugins.deny/allow 的输出交给插件列表文本解析器。OpenClaw 对数组值会输出
pretty JSON;例如元素行是 "memory-core",不会命中精确 token,而 allowlist 的末行 ]
又会被当成空值。真实 deny/allow 策略因此不会被识别,enable 会错误认领该 transition,
disable 随后还会尝试一个被策略拒绝的 restore。当前测试只注入裸字符串,未覆盖宿主输出。

Possible direction: 将成功输出解析为 JSON 数组,并为 deny 与 allow 各补一个多行 JSON 的
enable/disable 生产链路回归测试。

[P2] 不要把本地 runtime inspect 当成正在运行的 Gateway

src/anolisa/crates/anolisa-core/src/adapter/openclaw.rs:1246 启动新的
openclaw plugins inspect --runtime 进程,1358 行据此判断既有 Gateway 是否仍加载插件。
但 OpenClaw 的该命令从当前配置读取 metadata,并在 CLI 进程内新建 runtime inspection;
它不查询正在运行的 Gateway。plugins disable 写入配置但尚未重启时,新进程会看到 disabled,
这里仍会返回 Healthy,正是本条件要发现的窗口。测试中的 FAKE_OC_PENDING_RESTART 是生产命令
不会提供的独立信号,所以绿测无法证明这条状态判断成立。

Possible direction: 改用真实 Gateway RPC/状态端点,或在获得真实重启证据前保持 pending;
回归测试应让长驻 Gateway 状态与磁盘配置分离,并通过同一生产探针读取。

@Forrest-ly
Forrest-ly force-pushed the fix/AGE-7186-openclaw-adapter-enable-memory-handoff branch from 3519231 to 8bda348 Compare September 11, 2026 12:20
@Forrest-ly

Copy link
Copy Markdown
Collaborator Author

@kongche-jbw 第 6 轮 2 条全部成立,已在 8bda348e 修完(仍单 commit,35192318bda348e)。

先说结论里最重要的一句:P2 你是对的,而我上一轮的修法本身是个错误——我不只是选错了探针,我还把测试 fake 改成去迎合我的假设,于是那个绿色的测试什么也没证明。下面详述。


[P1] 按 JSON 数组解析 OpenClaw 插件策略 — 已修

确认,而且你对失败方向的判断很关键:两个 reader 都不是「读不准」,而是都朝着放过策略的方向失败——

  • list_contains_plugin 走 whitespace 精确 token 相等,pretty JSON 的元素行是 "memory-core",(带引号 + 尾逗号),不命中 ⇒ 真实 denylist 被读成「没点名任何人」;
  • config_answer_token 归约到最后一行,pretty 数组的最后一行是 ],去掉标点后为空 ⇒ 真实 allowlist 被读成 vacant,而我上一轮恰恰把 vacant 定义为「非限制」⇒ 限制被读成没有限制。

也就是说:真实宿主上这两个策略维度一直是失效的,enable 会错误认领 transition,disable 随后发出一个必被策略拒绝的 restore。

修法:新增 policy_id_list()先按 JSON 解析,只在拿不到 JSON 时才回退逐行扫描。

  • JSON 侧 json_id_array():接受裸数组、{"value":[…]} 信封、以及 JSON null(宿主的「未设置」渲染,按空列表处理而不是按解析失败处理);对前置 diagnostics 与 key = 回显,用「扫描首个 [{」容忍——没有复用 extract_trailing_json,因为它只找 {
  • 回退侧逐行扫描:遍历所有行(不再只看最后一行),每行按最后一个 =/: 切出值以避开 key 回显,再按空白与逗号切分并剥掉 "'[];,跳过空 token 与 vacant 词。
  • 「只有点名了东西才算证据」这条规则在两种读法下保持一致:vacant(未设置 / null / [])永远不算限制——否则会在 merely-unset 的宿主上把整个 hand-off 静默关掉,那正是本 PR 要修的 bug。
  • 顺带把上一轮我提请你确认的 plugins.allow「非空」前置条件保留了下来,它现在由 PolicyIdList::Vacant 显式表达,语义没变。

测试(按你要求,deny 与 allow 各一条多行 JSON 的生产链路回归):enable_and_disable_honor_a_pretty_json_deny_listenable_and_disable_honor_a_pretty_json_allow_list(都走完整 enable→disable:断言无 plugins disable、receipt 未 claim、disable 收敛且不发会被拒的 plugins enable)。另加三条防止「修过头」与形状覆盖:enable_still_claims_when_a_pretty_json_allow_list_names_the_plugin(allowlist 点名了该插件就必须照常交接,否则等于静默关掉功能)、enable_honors_a_pretty_json_policy_list_behind_a_key_echo(diagnostics 前言 + key = 回显,deny/allow 两侧)、enable_still_claims_when_a_json_policy_list_is_empty_or_null[] / [\n] / null × 两个 key)。解析器本身另有 4 组单测覆盖 pretty / compact / 信封 / 带前言 / 裸词 / 逗号分隔 / 多行非 JSON / 空与 null。原有的裸字符串用例保留,它们现在覆盖的是回退路径。

回退 JSON 解析后共 5 个用例失败(2 单测 + 3 集成)。

[P2] 不要把本地 runtime inspect 当成正在运行的 Gateway — 已修,并且撤销了我上一轮的错误修法

你说得对,而且这条比「探针选错」更严重,我想把它讲清楚:

plugins inspect --runtime新起一个 CLI 进程、从当前配置读 metadata、在该进程内做 runtime inspection——它不查询正在运行的 Gateway。所以 plugins disable 写完配置、尚未重启时,新进程看到的就是 disabled,我那个 runtime_still_loaded 会返回 false,条件报 True、summary 报 Healthy——正是这个条件本该发现的那个窗口。我的修法在生产上是无效的。

更糟的是测试为什么是绿的:我给 fake CLI 加了 FAKE_OC_PENDING_RESTART,让 disabled/<id> 标记存在时 inspect 仍报 loaded。那是我假想出来的信号,生产命令并不提供。等于我先把假设写进 fake,再让测试去验证 fake——绿测只证明了「fake 符合我的假设」。这是比漏测更坏的情况,因为它会主动阻止后人怀疑这个探针。

处置:

  1. 删掉 runtime_still_loadedinspect_supports_runtime(两个方法整体移除),以及 FAKE_OC_PENDING_RESTART 这个 knob 和它的 OWNED_ENV 登记;fake 的 inspect 恢复为「由持久化配置推导状态」,并加注释说明这是刻意的——因为真实命令就是这个语义,driver 不得从中读 gateway liveness。
  2. 采用你给的第二个方向:在拿到真实重启证据之前保持 pending。现在的判定是——
    • config 说 enabledFalse(→ Degraded)。这一侧不需要 gateway 证据就成立:无论运行中的宿主是否已跟上,本 adapter 拥有的交接都不在生效状态。
    • config 说 disabledUnknown(不再是 True/Healthy)。它只证明交接被记录,不证明已生效;reason 明说 ANOLISA 没有通往运行中 gateway 的通道,并点名 openclaw gateway restart 是能确定它的那个动作。
    • config 读不到 ⇒ Unknown,但用另一条 reason(点名读不到的 key),避免把操作者推向一次无济于事的 restart。
  3. 用户指南 en/zh 同步改为「重启前报 unknown 而非 healthy」,并明确告诉操作者:此处的 unknown 应读作「重启网关后复查」,不是故障。

没有去发明一个 gateway RPC/状态端点。全仓检索确认本仓库对 OpenClaw gateway 只有 openclaw gateway restart 这一条 shell 命令(其余 gateway 命中都属于 cosh-ng 的 cosh-gateway,与本处无关),没有任何可复用的 gateway 通道;在无法验证命令名与输出形状的前提下写一个「猜测的端点 + 失败就静默降级」正是我刚刚犯的那类错误。所以这里想请你给一个具体输入:OpenClaw 是否有可查询运行中 gateway 已加载插件集的接口(RPC / status 子命令 / socket)? 如果有,我把 Unknown 换成真实判定,测试也就能按你说的「长驻 Gateway 状态与磁盘配置分离、并通过同一生产探针读取」来写——那需要 fake 里存在一个独立于 config 的 gateway 状态,而只有在存在真实生产探针时那样建模才有意义。

测试:status_does_not_report_healthy_on_a_recorded_but_unverified_handoff(断言 assert_ne!(Healthy)== Unknown、条件为 Unknown、reason 同时含 openclaw gateway restart、插件名、以及「已记录」这一已知事实)、status_is_decisive_when_config_says_the_displaced_plugin_is_back_onFalse + Degraded,防止 Unknown 变成掩盖真问题的敷衍)、status_separates_an_unreadable_probe_from_a_recorded_handoff(reason 必须点名 key 且不得含 gateway restart)。同时把 status_degrades_when_a_displaced_plugin_is_reenabled_behind_our_back 的基线断言从 Healthy/True 改为 Unknown。回退后 2 个用例失败。


一个由 P2 牵出、我没有擅自扩大的问题,想请你判断

如果 plugins inspect --runtime 不查询运行中的 gateway,那么 verify_runtimeopenclaw.rs,enable 时校验本 adapter 自己的插件是否 loaded)读到的也只是配置推导出的 metadata,而不是「已在运行中的 gateway 里加载」。

这会削弱本 PR 的一条核心排序理由:「只有在本插件已验证 loaded 之后才 plugins disable 被顶替的插件,以免 slot 后面什么都没有」。我没有动它,因为:(a) 它是 main 上的既有行为、不在本轮 review 范围内;(b) 改它会改变所有 OpenClaw adapter 的 enable 语义(若判定无法确认 loaded,enable 要么失败要么降级,影响面远大于本 PR);(c) 在你确认 gateway 通道之前,我同样没有可靠依据去替换它。

是否要为此单开一条(以及 verify_runtime 的措辞/文档是否应先降级为「配置层校验」以免继续对外宣称 runtime 验证),请你定;我按你的判断执行。我在 PR body 里也把这一点记为 open question 了。


验证

本地全绿:cargo fmt --all --checkcargo clippy --all-targets --locked -- -D warningscargo test --workspace --locked2578 passed / 0 failed:anolisa-cli lib 1072、anolisa-core lib 1086adapter_manager 139adapter_frameworks 68、adapter_contract 28、doc-tests)、RUSTDOCFLAGS="-D warnings" cargo doc --no-deps -p anolisa-corescripts/docs-lint.shscripts/docs-link-check.pysrc/agent-memorymake test-openclaw-installgit diff --check

本轮 2 处修复逐一反向验证:回退 JSON 解析 ⇒ 5 个用例失败(2 单测 + 3 集成);回退为「config 说 disabled 即视为已验证」⇒ 2 个用例失败。六轮合计 20 条意见全部如此验证过。

openclaw.rs module docs 已同步(status 只在证据充分处下判定、记录 ≠ 生效、无 gateway 通道所以报 Unknown)。PR body 已更新,并在 Risk 一节把这条有意的取舍写明:在没有 gateway 通道之前,adapter status 对声明了 displacement 的 adapter 会稳定报 unknown——我选择牺牲信号强度来换取不撒谎,因为另一种选择正好是本 PR 要消灭的那个「报告 enabled+verified 而工具名其实被遮蔽」的失败模式。

另:仓库已 transfer 到 agentic-os-org/anolisa(旧路径靠重定向仍可用),push 时遇到过两次 Empty reply from server,第三次成功;本轮 head 确认为 8bda348e

请再 review。

@kongche-jbw kongche-jbw left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review baseline: db24322e0c8adf5f2bc495fcbf2aceafe8dbcd02...8bda348e38cebf6e61a18dbd7e01dd42ff255852

[P1] 不要把诊断前言当成 allowlist 条目

src/anolisa/crates/anolisa-core/src/adapter/openclaw.rs:3979 会在 JSON 解析失败后扫描
stdout 的每一行。例如成功输出为 reading policy...plugins.allow = null 时,
json_id_array 找不到可从 [/{ 开始解析的值,fallback 又会把前言里的单词加入
Named。随后 policy_blocking_plugin 把它当作不含 memory-core 的限制性 allowlist,
enable 会跳过 displacement:两个插件仍争用工具名,receipt 也不会记录可供 status 检查的
所有权。这与“空值不构成限制、只认正面证据”的规则相反。

Possible direction: 在 fallback 前识别 key echo 后的 null/空值,或排除非值诊断行;补一条
带前言的空 allowlist 走完整 enable→disable 调用链的回归测试。

[P2] 让重启后的状态检查能够收敛

src/anolisa/crates/anolisa-core/src/adapter/openclaw.rs:1337 把每个配置为 disabled 的插件
固定归入 recorded_only,所以即使用户执行文档要求的 gateway restart,再次 status 仍会得到
同一个 Unknown。当前提示称 restart 会“settle”结论,用户指南也要求重启后复查,但实现没有
任何能让这次复查转为 Healthy 的状态变化。OpenClaw 已提供运行在 Gateway 内的
tools.catalog / tools.effective RPC,并返回插件来源,可用于判断冲突工具当前由谁持有。

Possible direction: 能力探测后查询 Gateway 的工具来源,不支持或不可达时再保留 Unknown;
回归测试应让磁盘配置相同,仅切换重启前后的 Gateway 工具 owner。

@Forrest-ly
Forrest-ly force-pushed the fix/AGE-7186-openclaw-adapter-enable-memory-handoff branch from 8bda348 to e33d9e8 Compare September 11, 2026 13:12
@Forrest-ly

Copy link
Copy Markdown
Collaborator Author

@kongche-jbw 第 7 轮 2 条全部成立,已在 e33d9e84 修完(仍单 commit,8bda348ee33d9e84)。P2 我选了 (b) 不做探测、改措辞,理由在下面,并把 (a) 需要的具体输入列成清单请你补。


[P1] 不要把诊断前言当成 allowlist 条目 — 已修

确认。你给的复现我逐符号核过:reading policy... + plugins.allow = null ⇒ stdout 里没有 [/{json_id_array 无锚点返回 None ⇒ fallback 逐行扫描,第 1 行无分隔符于是 tail = 整行 ⇒ Named(["reading","policy..."]);第 2 行 tail = null 被 vacant 吃掉。净结果就是前言里的两个词成了 allowlist 条目policy_blocking_plugin 判为「限制性 allowlist 且不含 memory-core」。

两个消费点都翻车,而且我认为你说的第二条比第一条更坏:

  • enable 侧:displacement_probe 返回 AlreadyOff("excluded by plugins.allow")整条 displacement 被跳过,两插件继续争用工具名,receipt 不记所有权 ⇒ displaced_plugins_condition 连可查对象都没有,status 无从报警;
  • restore 侧:拿同一个假判定打印「remove it from plugins.allow and run openclaw plugins enable memory-core yourself」⇒ 指导用户去改一个值是 null 的 key,同时把本该交还的插件永久留在 disabled。

这确实与「空值不构成限制、只认正面证据」的规则相反——而我上一轮恰恰把 vacant 定义为「非限制」,所以前言伪造出的非空列表正好绕过了那道保护。

修法采纳你指出的设计约束policy_id_list 拿不到本次查询的 key,所以签名改为 policy_id_list(output: &CliOutput, key: &str),两个调用点各自传 "plugins.deny" / "plugins.allow"。fallback 不再靠「排除非值诊断行」的启发式,而是正向限定哪一行才可能是值:

  • 有分隔符(= / :)的行,只有左半 trim 后恰好等于本次查询的 key 才算 key echo、才取其尾段;左半是别的东西(warning: config not foundnote: reading plugins.allow)一律整行丢弃——那是诊断句,尾段是散文不是 id 列表。
  • 完全无分隔符的行才算「裸值行」,且仅在整份输出没有任何本 key 的 echo 时才被采用。二者绝不混读——混读正是前言变成答案的原因。
  • 每个 token 再过一次 validate_plugin_id,不可能是 id 的直接丢弃。

另外按你指出的「只堵 null 会留下一整片」,把 config_answer_is_vacant 一并加固:改为大小写不敏感比较(走到这里的 token 不再都经过 config_answer_token 的小写化,NULLnull 必须同义),词表补 (null) / (none) / unset / <unset> / n/a / -。该函数与 slot 判定共用,扩表只影响「vacant ⇒ 允许恢复」这一侧,方向安全。

残留我写明而不是藏起来:宿主若只打印散文、没有任何值行、且仍 exit 0,那段散文仍会被当成 id。所有已文档化的渲染形态(裸值 / JSON / key=value / 前言 + 值在最后一行)都已覆盖,这一种没有——因为仅凭形状无法把它与「真的裸值列表」区分开。这条已写进 policy_id_list 的 rustdoc 与 PR body,不再靠注释里的乐观假设。

测试按你的要求走完整 enable→disable 生产链路,不是只测单函数(否则证明不了 AlreadyOff 那条误判消失):

  • enable_claims_the_displacement_a_preamble_plus_null_allowlist_does_not_forbid —— 注入 reading policy...\nplugins.allow = nullconfig get plugins.allow 的 stdout,断言:plugins disable memory-core 确实执行、receipt 确实记录了 1 条 displaced(让 status 有可查对象)、disable 确实发出 plugins enable memory-core、报告里不得出现 plugins.allow 字样、cleanup_complete、receipt 删除、disabled 标记清除。
  • enable_claims_the_displacement_a_preamble_plus_null_denylist_does_not_forbid —— 同形状在 deny 侧。
  • enable_honors_a_real_deny_entry_behind_a_diagnostic_preamble —— 同一前言形状下plugins.deny = memory-core 仍须被识别;这条是防止我把修复做成「干脆忽略 policy」。
  • 单测三组:policy_list_does_not_read_a_diagnostic_preamble_as_ids(前言 × null/[]/空/NULL/unset/-,deny 与 allow 两侧)、policy_list_ignores_a_foreign_key_echowarning: config not found 判 Vacant;且真 echo 与另一行散文共存时以 echo 为准)、policy_list_treats_every_vacant_spelling_as_vacant(13 种拼写)。

回退 key 限定后共 3 个用例失败(2 单测 + 1 集成),失败的集成用例正是你复现的那条链路。

[P2] 让重启后的状态检查能够收敛 — 本轮选 (b):不做探测,改措辞使其不再承诺收敛

先确认你的取证:recorded_only 的唯一信号是持久化的 plugins.entries.<id>.enabledgateway restart 不改这个 key,所以重启后再跑 status 得到逐字相同的 Unknown。而同一 head 的用户指南写着 "until that restart has run" / "restart the gateway and re-check",reason 写着 restart "would settle it" —— 文案承诺了一个实现里不存在的状态转移,用户会照文档反复重启复查,永远走不出来。这比报 Healthy 好,但仍然是在骗人,只是骗得温和一点。

为什么不选 (a)tools.catalog / tools.effective 在本仓库 0 引用(你和我都独立查过),而仓库唯一的 RPC 原语 FrameworkRpcSession 是「新起一个子进程、按行写 JSON-RPC 请求、等 N 个 id-bearing 响应后关闭 stdin」(Codex 的 app-server --stdio 就是这么用的)——它不是「连上正在运行的 daemon」的通道。要走 (a) 我得同时确定:启动一个会转发到运行中 Gateway 的会话的 argv、是否需要 initialize 握手、方法名到底是 tools.catalog 还是 tools/catalog、参数形状、响应里哪个字段给出插件来源、以及不可达/超时如何与「未重启」区分。这些我一个都无法从本仓库验证。上一轮我刚刚因为「假设了一个探针的语义、再把测试 fake 改成迎合这个假设」而交出一个生产上无效的修复;用同样的方式起一条新传输通道,代价只会更大。所以本轮不猜。

(b) 的落地(措辞改为不承诺收敛,并给出真能收敛的自查动作):

  • reason 改为:交接已记录,但 ANOLISA 观测不到运行中的 Gateway,因此无法确认是否已生效plugins disable 只写配置,OpenClaw 在下次 gateway restart 时应用;「重启不会改变本结论」(ANOLISA 之后依然没有 gateway 通道),所以请把此处的 unknown 读作「无法观测」而不是「重启一下就好」;若启用后还没重启过请执行 openclaw gateway restart(那是让交接真正生效的动作),随后直接自查openclaw plugins list,或调用一次 memory_get 看是哪个插件应答。
  • displaced_plugins_condition 的 rustdoc 与 openclaw.rs module docs 同步改为「该结论是永久的,且明说是永久的」,并写明要转成确定性结论需要 gateway tool-catalog 通道。
  • en/zh 用户指南同步:删掉 "until that restart has run" 与 "restart and re-check" 的闭环承诺,改成「重启是让交接生效的动作,但不会unknown 变成 healthy;此处 unknown 应读作『无法观测』而非故障;要自行确认请 openclaw plugins list 或调用 memory_get」。
  • 测试加了防回归的措辞断言,这样这个承诺不会悄悄回来:status_does_not_report_healthy_on_a_recorded_but_unverified_handoff 现在除了 assert_ne!(Healthy) / == Unknown / 点名插件 / 说明「已记录」之外,还断言 reason 含 "does not change this verdict" 且含 openclaw plugins list。把措辞改回旧承诺后该用例失败。

(a) 如果要做,请给我这四项,我就能按你要求的方式实现并测试(「磁盘配置相同、仅切换重启前后的 Gateway 工具 owner」,且 fake 里的 gateway 状态必须独立于 config、由同一条生产探针读取——这需要真实探针存在才有意义):

  1. 启动一个转发到运行中 Gateway 的会话的确切 argv(以及它是否需要 initialize 之类握手、是否复用 FrameworkRpcSession 的 stdio 语义);
  2. 方法名与参数的确切形状(tools.catalog vs tools/catalog,是否需要 scope/home 参数);
  3. 响应中承载插件来源的字段路径与取值(用于判断某个冲突工具当前由哪个插件持有);
  4. 能力探测方式(--help 里的哪个 flag,或哪个子命令的存在性),以及 Gateway 未运行时的可区分信号(退出码 / stderr / 特定 error code),好让「Gateway 没起」与「工具仍被 memory-core 持有」不混为一谈。

顺带把上一轮提的那个问题一并留在这里,因为它与 (a) 同源:verify_runtime(enable 时校验本 adapter 自己的插件 loaded)走的是同一个 plugins inspect --runtime,按你对该命令语义的说明,它读到的也是配置推导的 metadata 而非运行中 Gateway 的事实。这会削弱本 PR「先验证自身插件 loaded、再 disable 被顶替插件」这条排序理由的强度。我没有动它(main 既有行为、影响所有 OpenClaw adapter 的 enable 语义、且在 (a) 的通道确定之前我同样没有可靠替代),但如果 (a) 要做,verify_runtime 应该一起迁过去;或者先把它的文档措辞降级为「配置层校验」,别继续对外宣称 runtime 验证。请你定。


验证

本地全绿:cargo fmt --all --checkcargo clippy --all-targets --locked -- -D warningscargo test --workspace --locked2584 passed / 0 failed:anolisa-cli lib 1072、anolisa-core lib 1089adapter_manager 142adapter_frameworks 68、adapter_contract 28、doc-tests)、RUSTDOCFLAGS="-D warnings" cargo doc --no-deps -p anolisa-corescripts/docs-lint.shscripts/docs-link-check.pysrc/agent-memorymake test-openclaw-installgit diff --check

本轮 2 处修复逐一反向验证:回退 key 限定 ⇒ 3 个用例失败(含你复现的那条完整链路);把 reason 改回「restart 会 settle」的旧承诺 ⇒ 1 个用例失败。七轮合计 22 条意见全部如此验证过。

PR body 已更新(policy 解析一段展开为含复现输入与残留说明、status 一段改为明确「永久 Unknown」并列出 (a) 所需通道、Test 与 Risk 同步)。

请再 review。

@kongche-jbw kongche-jbw left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review baseline: db24322e0c8adf5f2bc495fcbf2aceafe8dbcd02...e33d9e844998afac5b7ebedf105d01989261764a

[P2] 让恢复预览包含 policy 与 inventory veto

src/anolisa/crates/anolisa-core/src/adapter/manager.rs:4292 把无 slot 的恢复称为
unconditional,有 slot 时也只列 slot guard;但真实路径在
src/anolisa/crates/anolisa-core/src/adapter/openclaw.rs:1490:1499 还会因
插件已不在 inventory 或被 policy 阻止而跳过恢复。例如 enable 后把
memory-core 加入 plugins.denydisable --dry-run 仍说会 re-enable,真实
disable 却不会发出 plugins enable,并正常删除 receipt。migration 和 dropped
displacement 的预览也有同一偏差。Possible direction: 让 planner 复用同一判定,或至少
列全这些 veto;补 policy-blocked 与 vanished plugin 的 dry-run/real 对拍测试。

[P2] 不要把冷态插件清单作为 Gateway 确认

src/anolisa/crates/anolisa-core/src/adapter/openclaw.rs:1386 和双语指南建议用
openclaw plugins list 直接确认 hand-off,但该命令读取持久化 registry/config,
并不查询已经运行的 Gateway。enable 后、Gateway 尚未应用变更时,它会显示
memory-core 已禁用,旧 Gateway 却仍可能在提供其工具,因而会给出错误确认。
Possible direction: 删除这条建议,保留真实工具调用,或接入 Gateway 的
tools.catalog / tools.effective;回归测试应拒绝把冷态 probe 当成确认手段。

@kongche-jbw kongche-jbw left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

独立复审 head:31e9aa03169a28cd928ab8f9e862c0825b2b873c。本轮抓取的 main 为 2e1ee6585d42eb4dc2f6b5756136e5734c94c4c1,完整 diff 的 merge-base 为 3f4b22e3d645057270155d905964085ddcf58d5a;同时核对了此前有正文评审的 79c5f0608eb082b5d87139e9bd54cde5de8d36c2 之后的修复。

撤回我在同一 head 上的批准,改为 Request changes。原因是本轮独立复查发现两个此前遗漏的生命周期组合,并已用当前生产 AdapterManager/OpenClawDriver 和 PR 自带的模拟 CLI 复现,详见两条 inline comments:

  • P1:首次 enable 在 slot 查询与 runtime verification 失败后,直接重试 enable 会丢失 memory-core 的恢复记录;随后 disable 报告完成并删除 receipt,却留下 memory-core.enabled=false。
  • P2:allowlist 仅包含当前插件时,disable dry-run 说 memory-core 会保持关闭,实际 uninstall 清空 allowlist 后却重新启用它,计划和真实动作相反。

这不是否定最新提交的卸载前持久化修复:已有失败恢复重试和 SIGKILL 回归测试均通过。此次改变结论是因为之前的批准没有覆盖上述相邻路径;原有测试通过不足以证明这些组合正确。建议修复两项并补上对应回归后再合并。

本轮验证(Linux / Rust 1.93.1):

  • cargo test --locked -p anolisa-core --lib adapter:379 passed。
  • cargo test --locked -p anolisa-core --test adapter_manager --test adapter_frameworks:adapter_frameworks 68 passed;adapter_manager 原有 197 passed,新增独立复现 2 failed,1 ignored(由父测试调用的子进程入口)。
  • git diff --check:通过。
  • 生产代码未修改;测试使用临时源码副本和模拟 CLI,没有执行真实 Gateway E2E。两个新增用例分别为 review_reenable_recovers_unrecorded_handoffreview_disable_preview_accounts_for_uninstall_allowlist,触发步骤与观测结果已完整写在 inline comments。

提交前 head 未变化,CI 13 success / 14 skipped。功能层面仍需保留既定说明:接管会暂停整个 memory-core 的附带能力,两套存储不自动合并;没有 Gateway 观测通道时,已记录交接的 status 仍可能持续 unknown。建议补充真实 enable → memory tool call → disable 验收,确认运行中的工具归属及原后端恢复。

Comment thread src/anolisa/crates/anolisa-core/src/adapter/openclaw.rs
Comment thread src/anolisa/crates/anolisa-core/src/adapter/openclaw.rs Outdated
Two lifecycle combinations the openclaw driver still got wrong, both read
off a host fact at a step where that fact no longer meant what the reader
took it to mean.

`prepare_enable` read a positive `plugins.entries.<id>.enabled = false` as
`FlagDisabled` and declined to claim the displacement, and
`preserve_openclaw_displaced_facts` declined to inherit the prior
receipt's entry because it was unapplied. Each answer is defensible alone;
together they lost a hand-off that had already happened. An enable whose
slot probe could not be read leaves the entry `applied = false` over a
plugin this adapter's own slot selection really did turn off, and
`restore_decision` has known since the last round that the host can still
attribute it. A re-enable over that receipt therefore claimed nothing, the
same-home cleanup handed the bundled plugin back, and this enable's own
install and enable switched it off again through the very selection the
restore branch had just attributed — over a replacement receipt with no
entry for it. The disable that followed uninstalled this adapter's plugin,
reported a receipt naming nothing to hand back, removed the receipt, and
left the bundled backend off, with no operator anywhere in the sequence
and nothing left recording why.

So recover the attribution at the step where the evidence for it exists,
which is `prepare_enable`: it is the only scope holding both the prior
receipt and this round's probe, the same reason `inherited_displacement_ids`
gives for deciding `carries_prior_handoff` there and nowhere else. The
predicate is the one the other three recovery marks already share, so the
five callers cannot drift apart — they already did once, visibly, and this
was the second time. It is asked only for a plugin a prior receipt *for
this same instance* already names, which is what keeps it from becoming
the unconditional inheritance the branch exists to avoid: an operator's own
disable still reads as theirs, and on a first enable there is no older
record to recover, so a slot naming this adapter's plugin can mean nothing
more than that somebody installed it by hand and closed the bundled one
themselves. `prior_for_this_instance` factors out the state-directory gate
both questions share rather than copying it a third time.

A disable preview handed `restore_decision` the `plugins.allow` the host
held *before* the operation it was previewing, while the real disable reads
it after its own `plugins uninstall` — and `removePluginFromConfig` removes
this adapter's plugin from that key, dropping the key entirely once nothing
is left in it (v2026.4.14 `src/plugins/uninstall.ts:126-133`). For an
allowlist naming only this adapter's own plugin, the plan therefore
reported a restriction the operation was about to delete, promised the
bundled plugin would stay disabled, and the disable re-enabled it anyway.
Every veto leaves `cleanup_complete` alone and the receipt is removed
either way, so nothing afterwards would have shown the operator that the
plan they were given described a different run.

`AllowlistReading` carries the correction. Live for every decision taken
after the uninstall and for every one no uninstall precedes at all — the
enable-side probe, `status`, and the same-home dropped-displacement
cleanup, which restores from the prior receipt with this adapter's plugin
still installed, and which is why the projection is a parameter rather
than a change to the shared read. Projected past the removal for the two
previews of a full disable: the plain one, and the migration one, whose
cross-home cleanup delegates to a real `disable`. `plugins.deny` and
`plugins.enabled` are operator keys this driver never writes, so neither
needed it.

`restore_decision`'s four host-reading arguments became one `RestoreHost`
because two of them now say *when* a reading was taken rather than what it
says, and a caller that applies one correction while forgetting the other
is exactly the bug being fixed here.

Trade-off: a re-enable whose prepare-time probe reads a positive `false`
now costs two extra `config get` calls per displacement a prior receipt
names as unapplied — the slot and the flag the shared predicate asks for —
and a preview costs one projection over a list it already read. Both are
read-only and both run before any mutation.

Tests: `reenable_recovers_a_handoff_the_failed_enable_never_marked` walks
failed enable → successful re-enable → disable, and asserts the bundled
backend is back on, that the replacement receipt carries the displacement
as applied, and that the re-enable no longer restores-then-redisplaces.
`disable_preview_accounts_for_the_allowlist_its_own_uninstall_drops` pairs
the preview against the operation for the single-entry allowlist and keeps
the multi-entry control that still blocks the restore. Both were confirmed
to fail on the previous head with the reported symptoms — an empty
displacement list after the retry, and `would leave openclaw plugin
'memory-core' disabled: a restrictive plugins.allow excludes it` over a
disable that re-enabled it.

Assisted-by: Codex:0.154.0
Co-authored-by: multica-agent <github@multica.ai>
Signed-off-by: Forrest-ly <forrest-ly@users.noreply.github.com>
@Forrest-ly

Copy link
Copy Markdown
Collaborator Author

第 35 轮两条意见均已修复,新增 commit e816072(head 由 31e9aa0 前移),两条 inline thread 已逐条答复并 resolve。

P1 — 重试丢失恢复记录

判决点放在 prepare_enableDisplacementProbe::FlagDisabled 分支,即构造 replacement claim 之前:那里是唯一同时握有 prior receipt 和本轮 probe 的 scope,也正是 inherited_displacement_ids / carries_prior_handoff 既有的判决点。判据复用现有 handoff_performed_by_slot_selection(该谓词的调用方由 4 个变 5 个,仍是一份实现)。只对"同一 state dir 的 prior receipt 已记下、但 applied=false"的 plugin id 询问,因此不是无条件继承:操作者自己的 disable 仍然不被认领,首次 enable 也不认领(只凭 slot 读数会在"手工装了本插件、又自己关掉 memory-core"时冒领)。

回归 reenable_recovers_a_handoff_the_failed_enable_never_marked:failed enable → successful re-enable → disable,断言最终 memory-core.enabled=true、replacement receipt 携带且 applied=true,并额外断言 re-enable 过程中不再出现 plugins enable memory-core(把修复钉在 prepare 侧而非靠 cleanup 兜住)。在 31e9aa0 上验证失败,报 left: [] / right: ["memory-core"],与 OWNERSHIP AFTER RETRY=[] 一致。

P2 — disable 预览与 uninstall 后的 allowlist 相反

采用投影而非改措辞:新增 AllowlistReading { Live, AfterOwnUninstall(own) }policy_blocking_plugin 判定 plugins.allow 前先剔除本插件 id,投影后为空即等价于 upstream 丢弃整个 key。AfterOwnUninstall 用于完整 disable 预览与跨 home / 迁移预览(后者委托一次真正的 disable);Live 用于真实 restore(已在 uninstall 之后)、enable 侧 probe、status,以及不套用投影的 same-home dropped-displacement cleanup。plugins.deny / plugins.enabled 不受影响。

顺带把 restore_decision 的四个"怎么读宿主"参数收成 RestoreHostslots 早有 SlotReadings::Captured 这个纠正,allowlist 是另一半,散成四个参数时调用方很容易只纠正一个——那正是本条 bug 的形状。参数个数也因此回到 clippy 上限内,没有新增 #[allow]

回归 disable_preview_accounts_for_the_allowlist_its_own_uninstall_drops:单元素 allowlist 的 preview/actual 对拍(预测 restore + 实际下发 + 最终 true),并保留多元素 allowlist 仍阻止恢复的对照。在 31e9aa0 上验证失败于预览侧,输出即 would leave openclaw plugin 'memory-core' disabled: a restrictive plugins.allow excludes it ...,而同一世界的真实 disable 下发 plugins enable memory-core

关于功能层面的非阻塞建议

三条既定说明已在文档中,本轮未改动(中英同步、行号对应当前 head):

说明 EN ZH
接管会暂停整个 memory-core 的附带能力(含 openclaw memory 与 dreaming / consolidation 生命周期),且每次 enable 都会如此 docs/user-guide/en/token-saving/agent-memory.md:129 docs/user-guide/zh/token-saving/agent-memory.md:128
两套存储不自动合并 …/en/…:131 …/zh/…:130
无 Gateway 观测通道时,已记录交接的 status 仍可能持续 unknown;重启也不会变成 healthy;要确认请发一次真实工具调用,不要用 plugins list / plugins inspect …/en/…:123 …/zh/…:122

真实 enable → memory tool call → disable 验收本轮未补,原因说明如下,并作为后续项:

  • 它需要一个正在运行的 OpenClaw gateway 才能观测工具归属——plugins inspect --runtime 会另起进程读同一份 config,只能回显配置,这一点驱动侧注释已写明,也是 statusunknown 而不是猜一个结论的原因;
  • 本环境与该 PR 的 CI 作业都没有 gateway:anolisa 的 CI 只跑 cargo fmt/clippy/test 与 nightly 的 raw-lifecycle.sh(仓库分发链路),openclaw-plugin-e2e.yml 服务的是 src/agent-sec-core/openclaw-plugin,与 adapter 生命周期无关;
  • 现有 tests/adapter_manager.rs 的模拟 CLI 已按上游行为建模 removePluginFromConfig(allowlist 摘除 + key 丢弃、memory slot 复位)与 applyExclusiveSlotSelection(同 kind 互斥 + 写 slot),本轮两个回归都跑在它上面;未覆盖的正是"运行中的 gateway 实际由哪个插件应答"这一段,与您的判断一致。

若要补,建议单独一个 nightly 作业:起真实 gateway → adapter enable → 调 memory_get 断言应答方 → adapter disable → 再调一次断言回到 memory-core。需要引入 gateway 运行时依赖,超出本轮"修两条 review 意见"的范围,故未夹带。

本轮验证(Linux / Rust 1.93.0;仓库 pin 的 1.93.1 在本机 rustup 镜像 404,未能装)

  • cargo test --locked -p anolisa-core --lib adapter:379 passed
  • cargo test --locked -p anolisa-core:lib 1092 passed、adapter_manager 199 passed(原 197 + 2 新增)、adapter_frameworks 68 passed、doc 1 passed,0 failed,1 ignored(由父测试调用的子进程入口)
  • cargo test --locked(anolisa workspace 全量):全绿,0 failed
  • cargo fmt --all --checkcargo clippy --all-targets --locked -- -D warningscargo test --workspace --doc --lockedgit diff --check:均通过
  • 未跑真实 Gateway E2E(见上)

@kongche-jbw 请复审第 36 轮。ikunkun-sys 09:58:41Z 的 APPROVED 仍在。

@kongche-jbw kongche-jbw left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

复审 31e9aa03 → e8160721:已阅读修复和作者回复,原两个复现场景现在通过。但仍有两项可复现问题,详见 inline comments:P1 是原恢复记录丢失问题在一次临时读取失败下仍然存在;P2 是新增恢复逻辑没有同步到 re-enable dry-run。

验证(Linux / Rust 1.93.1,当前生产 AdapterManager/OpenClawDriver + PR 自带模拟 CLI):

  • cargo test --locked -p anolisa-core --lib adapter:379 passed。
  • cargo test --locked -p anolisa-core --test adapter_manager --test adapter_frameworks:现有 manager 199 passed、1 ignored,frameworks 68 passed;新增的两项独立复现均 failed。
  • git diff --check 通过;当前 CI 13 success / 14 skipped。

生产代码未改动,未执行真实 Gateway E2E。新增复现为 review_reenable_preserves_unrecorded_handoff_on_one_failed_slot_probereview_reenable_preview_matches_recovered_ownership,完整触发条件和观察结果见评论。建议修复并补充回归后再合并,继续保留 Request changes。

Comment thread src/anolisa/crates/anolisa-core/src/adapter/openclaw.rs Outdated
Comment thread src/anolisa/crates/anolisa-core/src/adapter/openclaw.rs
Two ways the re-enable recovery added in the last round still disagreed
with itself, both about what a host read that fails means.

`prepare_enable` asks whether a prior receipt's unapplied entry is a
hand-off this adapter really performed, and the predicate it asked
answered with a boolean. A `config get plugins.slots.<slot>` that fails
is not "somebody else disabled it" — it is no answer at all — but the
boolean spent it as one, so the recovery was declined and the loss
followed exactly the path the recovery exists to block: the replacement
receipt named no displacement, the same-home cleanup handed the bundled
plugin back, this enable's own install and enable switched it off again,
and the disable that followed reported a receipt with nothing to restore
and removed it. One transient read failure, no operator anywhere in the
sequence, and the host left with no memory backend and nothing recording
why.

`handoff_attribution` is therefore the tri-state, and
`handoff_performed_by_slot_selection` is now the collapse of it — kept
for the four callers whose negative direction is inaction (leaving an
entry unapplied, filing it in a reporting bucket, skipping a restore),
where "not ours" and "could not tell" must stay the same answer, because
marking a hand-off on evidence nobody could read would claim a
transition this adapter never made. The re-enable is the one caller whose
negative direction is destructive, so it asks the tri-state and, on
`Unknown`, fails the enable before its first mutation with the prior
receipt untouched. That is the only answer that keeps both directions
honest: recovering would claim an operator's own disable, and declining
drops the only record of this adapter's. It stays gated on a prior
receipt for this same instance naming the plugin as unapplied, so it is
not the unconditional inheritance the branch exists to avoid — a first
enable still reads a `false` as belonging to whoever wrote it, and now
does so without a single host read.

The second disagreement is the same recovery missing from the preview.
`plan_enable` read a positive `false` and promised "already disabled
before this adapter, so it is not claimed and disable will not re-enable
it" over an enable whose replacement receipt carried the entry as
applied and whose disable then really did re-enable the plugin — the
opposite of the one fact a dry-run is run to learn, and invisible
afterwards, because the disable succeeds and removes the receipt.
`recovered_prior_handoff` is now the single judgment both callers ask,
which is the fix for the divergence rather than a second copy of it, and
the preview reports the carry-over in the terms the restore will use.

Trade-off: a re-enable whose prior receipt names an unapplied
displacement now costs the plan two read-only `config get` calls it did
not make, and a dry-run can fail on a transient read failure instead of
previewing a wrong answer. Both run before any mutation, and the failure
is retryable by construction — it is the host that has to recover, not
the receipt.

Tests: `review_reenable_preserves_unrecorded_handoff_on_one_failed_slot_probe`
sets `FAKE_OC_CONFIG_GET_FAIL_ONCE=plugins.slots.memory` over the
existing failed-enable fixture and asserts that the re-enable fails
having run nothing but probes, that the prior receipt survives naming
the displacement and still unmarked, that the retry recovers it as
applied, and that the disable which follows really does hand the bundled
backend back. `review_reenable_preview_matches_recovered_ownership`
reads the preview and the operation from the same world with no host
change in between, and asserts that the plan promises the carry-over,
the receipt performs it, and the disable reports it. Both were confirmed
to fail on the previous head with the reported symptoms — a re-enable
that "succeeded" over an empty displacement list, and `leave openclaw
plugin 'memory-core' alone (... disable will not re-enable it)` over a
disable that re-enabled it.
`private_helper_docs_stay_attached_to_their_own_function` gains the pair
this commit moved a doc between, which is the mistake it exists to
catch.

Assisted-by: Codex:0.154.0
Co-authored-by: multica-agent <github@multica.ai>
Signed-off-by: Forrest-ly <forrest-ly@users.noreply.github.com>
@Forrest-ly

Copy link
Copy Markdown
Collaborator Author

第 36 轮(@kongche-jbw,1×P1 + 1×P2)——两条都成立,已在 c1eb7e93 修完

本轮追加一个新 commit(与第 34/35 轮一致,不 amend),head e8160721c1eb7e93git diff e8160721 HEAD --shortstat = 2 files, +517 / −20,只动 openclaw.rstests/adapter_manager.rs。两条 inline thread(4021393571 / 4021393583)已逐条答复并 resolve,#322818/18 thread 全 resolved;细节与取证在 thread 里,这里只记结论。

你确认「原两个复现场景现在通过」,这两条是同一处修复留下的两个尾巴,而且同源:e8160721 给 re-enable 加的恢复判定只存在于 prepare_enable,而且只有布尔形态。 于是它一边把「读不到」当成「不是我的」(P1),一边压根没被 preview 知道(P2)。本轮把判定抽成唯一原语,两边共用。

[P1] Unknown 不再被压成 false — 已修

handoff_attribution() -> { Performed, NotPerformed, Unknown } 成为唯一判定原语;handoff_performed_by_slot_selection 退化为它的布尔收敛(== Performed),只留给那三个「否定即不作为」的调用方(enable 侧 write-ahead mark、disable 侧 pre-uninstall capture、restore_decision 的 restore 分支)——那里读到失败必须继续不打标,否则会把没观测到的转移记成本 adapter 做的,行为逐位不变(短路顺序也没变)。

re-enable 是唯一「否定即销毁」的调用方,所以它直接问三态:Performedapplied=true 进 replacement receipt;NotPerformedcontinue(操作者自己的 disable 照旧读作操作者的);UnknownAdapterError::FrameworkCli,在 prepare 阶段、任何 mutation 之前失败,旧 receipt 原样保留、可重试。

没有无条件继承所有 unapplied 条目:判定仍只对 prior_unrecorded_displacements() 命中的条目发起(同一 state 目录下的旧 receipt 且 applied=false)。首次 enable / 跨 home 迁移时该集合为空,直接返回 NothingToRecover,一次宿主读取都不做。

[P2] preview 与 prepare 共用同一套只读判定 — 已修

plan_enable 现在也调 recovered_prior_handoff()FlagDisabled 命中 Recovered 时走新增分支,文案与 carried 分支同构(同样带 restore_conditions_note 的 slot 说明),不再出现 leave ... alone (... disable will not re-enable it);命中 Unknown 时与 prepare 一样报错,避免换成「预览说不管、真实 enable 直接失败」这种新偏差。成本:只有旧 receipt 确有 applied=false 条目、且本轮 probe 是正向 false 时,preview 才多两次 config get(slot + flag),全为只读、全在 mutation 之前。

回归

两条用例都按你给的名字与路径补,且都在旧 head e8160721 上确认失败、症状与你实测一致:

  • review_reenable_preserves_unrecorded_handoff_on_one_failed_slot_probe —— 旧 head 上 re-enable「成功」但 displaced_plugins: [];新代码下断言 ① 首次 re-enable 报错且期间只跑只读探测(assert_dry_run_only_probed,白名单式)② 旧 receipt 仍在、仍只含 memory-core、仍 applied=false,宿主两项 config 未被改动 ③ 重试成功、replacement receipt applied=true、same-home cleanup 不再 plugins enable memory-core ④ 随后 disable 真的 plugins enable memory-core,无 never disabled it,最终 enabled=true
  • review_reenable_preview_matches_recovered_ownership —— 旧 head 上预览为 leave openclaw plugin 'memory-core' alone (already disabled before this adapter, so it is not claimed and disable will not re-enable it);新代码下同一 world、中间不改宿主状态,先 dry-run 再真实 enable → disable,断言预览含 keep openclaw plugin 'memory-core' disabled + disable will hand it back + plugins.slots.memory、不含任何 leave ... alone、dry-run 只发只读探测、receipt 为 applied=true、disable 报告含 re-enabled openclaw plugin 'memory-core'enabled=true

fixture 抽成 stage_with_unmarked_handoff,与 reenable_recovers_a_handoff_the_failed_enable_never_marked 共用同一套 staging 与前置断言(原用例本身未改动)。

本地验证

Rust 1.96.0 —— 本机 rustup 镜像取不到 1.93.1(mirrors.aliyun.comrust-1.93.1-x86_64-unknown-linux-gnu 返回 404),故用 stable 跑;改动未使用任何 1.93 以上的特性。

  • cargo fmt --all --check 通过
  • cargo clippy --all-targets --locked -- -D warnings 0 warning
  • cargo test --locked -p anolisa-core --lib adapter 379 passed(与你一致)
  • cargo test --locked -p anolisa-core --test adapter_manager --test adapter_frameworks 269 passed / 1 ignored(= 你的 199 + 68 再加本轮新增 2 例;两条新用例在旧 head 上均 failed)
  • cargo test --workspace --locked 2646 passed / 0 failed / 2 ignored
  • cargo doc --no-deps(含 --document-private-items)无新增 broken intra-doc link
  • git diff --check 通过

另外把 private_helper_docs_stay_attached_to_their_own_function 的断言表加了 handoff_attribution / handoff_performed_by_slot_selection 这一对——本次刚好把一段长文档从一个函数移到它的新邻居上方,正是那个测试要防的事。

生产代码未改动,未跑真实 Gateway E2E。两条都已修完并答复,请复审。

@kongche-jbw kongche-jbw left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

复审 e8160721 → c1eb7e93:上一轮两条问题已验证修复,re-enable 在恢复证据不可读时会在 mutation 前失败,preview 也与实际恢复行为一致。但直接 disable 仍有一条 P1,详见 inline comment。

这条是此前遗漏的 disable 路径问题,不是 c1eb7e9 新引入的回归;将临时副本中的生产 openclaw.rs 换回 e816072 后,同一独立复现也失败。

验证(Linux / Rust 1.93.1,生产 AdapterManager/OpenClawDriver + PR 自带模拟 CLI):

  • cargo test --locked -p anolisa-core --lib adapter:379 passed。
  • cargo test --locked -p anolisa-core --test adapter_manager --test adapter_frameworks:现有 manager 201 passed、1 ignored,frameworks 68 passed;另外新增的 re-enable Unknown 矩阵测试通过,覆盖 slot / enabled 标志读取失败 × dry-run / 实际执行四种组合。
  • 独立 independent_disable_keeps_unmarked_handoff_on_unreadable_capture:当前代码及上一版生产代码均失败。
  • git diff --check 通过;当前 CI 13 success / 14 skipped。

未修改工作树中的生产代码,未运行真实 Gateway E2E。建议修复 disable 的 Unknown 处理并补充回归后再合并,继续保留 Request changes。

Comment thread src/anolisa/crates/anolisa-core/src/adapter/openclaw.rs

@ikunkun-sys ikunkun-sys left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed current head c1eb7e931388edd6506b2593722992495ffce317. Requesting changes for the recovery failure already tracked in #3228 (comment); I independently reproduced it and am referencing that thread rather than creating a duplicate inline finding.

After a failed enable leaves an unmarked hand-off (applied=false, memory-core disabled, slot still naming this adapter), let only the next config get plugins.slots.memory fail once and call disable. The capture preserves the unreadable result, the boolean attribution skips recording ownership, and disable proceeds with uninstall. The observed argv is:

config get plugins.slots.memory
plugins uninstall tokenless --force
plugins list

Disable returns cleanup_complete=true and claim_removed=true while memory-core.enabled remains false. Retrying returns no receipt / nothing to disable, so recovery is lost. This is a previously missed path, not a new regression introduced by the latest update; my previous approval did not cover it. The conclusion here is limited to the reproduced one-shot slot-read failure: a one-shot enabled-flag read failure recovered on a subsequent live read in my separate check.

Validation on Linux with Rust 1.93.1:

  • All seven earlier review reproductions and the additional migration recovery test passed.
  • AdapterManager integration suite: 201 passed, 1 ignored child-process entry point.
  • OpenClaw unit tests: 53 passed.
  • The new isolated one-shot slot-read failure reproduction failed against the current production library using the simulated CLI.
  • git diff --check: passed. No real gateway end-to-end test was run.

At submission, the reviewed head is unchanged. CI has 13 successful and 14 skipped checks, all completed. GitHub reports MERGEABLE / BLOCKED and CHANGES_REQUESTED. Of 19 review threads, the linked recovery issue is the only unresolved one.

A `disable` that cannot read the hand-off attribution now fails before it
destroys the evidence for it, instead of spending "could not read" as "not
ours" and going on to erase the question.

`record_recovered_slot_selection_handoffs` asked the boolean collapse of
`handoff_attribution`, which folds `Unknown` into `NotPerformed`. Every
other caller of that collapse has *inaction* for its negative direction, so
the fold is right there. This one is followed by the rest of the disable,
and the rest of the disable is what makes the answer unrecoverable:
`plugins uninstall` resets `plugins.slots.memory` to the host default,
destroying the very reading the pre-uninstall capture exists to preserve;
the restore then skips an entry nothing marked; the cleanup reports itself
complete; and the Manager removes the one receipt that ever named the
hand-off. A single transient `config get` failure, with no crash and no
operator anywhere in the sequence, therefore ended with the bundled backend
off, `cleanup_complete = true`, `claim_removed = true`, and a report reading
"this adapter never disabled it and there is nothing to restore". That is
the stranded host the capture and the write-ahead mark were both added to
prevent, arrived one read failure later instead of one crash later, and with
the retry left nothing to retry from.

`recovered_disable_attribution` is the tri-state with `Unknown` turned into
an error. It is one shared function rather than a change in place because
two callers have to agree on it: the write-ahead mark, and
`plan_disable_restores`. The preview kept predicting a restore decision
through evidence the operation now refuses to act on, which is the
contradiction `recovered_prior_handoff` settled for the re-enable in the
other direction — "would leave openclaw plugin 'memory-core' alone ... this
adapter never disabled it" over a command that fails outright. Both sides
now fail on the same answer, and both fail before any mutation, so the host
and the receipt survive for the retry.

Trade-off: a disable whose receipt names an unapplied displacement can now
fail on a transient host read failure instead of completing with a report
that asserts what nobody could read, and its preview fails the same way.
That is the direction this driver already chose for the re-enable, and it is
retryable by construction — the host has to recover, not the receipt. The
preview also repeats one attribution read, because it takes no capture of
its own.

Tests: `review_disable_keeps_unmarked_handoff_on_unreadable_capture` is the
reviewer's repro — the failed-enable fixture, both of its faults cleared,
then `FAKE_OC_CONFIG_GET_FAIL_ONCE=plugins.slots.memory` and a real disable —
and asserts that the disable failed having issued nothing but reads, that
this adapter's plugin is still installed, that the slot still names it, that
the receipt still names the displacement unmarked, and that the retry then
recovers the ownership and really hands the bundled backend back.
`review_disable_keeps_unmarked_handoff_on_unreadable_enablement_flag` is the
same with the flag half failing instead, since neither half carries the
attribution on its own.
`review_disable_preview_fails_like_the_real_disable_on_unreadable_capture`
fails the plan on the same read, then hands the world to the existing
`assert_disable_preview_matches_real` once the host answers, so the gate is
shown to be exactly as wide as the operation and no wider. All three were
confirmed to fail on `c1eb7e93` with the reported symptoms.

Assisted-by: Codex:0.154.0
Co-authored-by: multica-agent <github@multica.ai>
Signed-off-by: Forrest-ly <forrest-ly@users.noreply.github.com>
@Forrest-ly

Copy link
Copy Markdown
Collaborator Author

第 37 轮(@kongche-jbw,1×P1)——成立,已在 78a5230 修完

本轮追加一个新 commit(与第 34/35/36 轮一致,不 amend),head c1eb7e9378a5230git diff c1eb7e93 HEAD --shortstat = 2 files, +333 / −23,只动 openclaw.rstests/adapter_manager.rs。inline thread 4021847679 已答复并 resolve,#322819/19 thread 全 resolved;取证与细节在 thread 里,这里只记结论。

你这条不是「新引入的回归」而是「此前遗漏」,判断也对:e8160721 把三态引入了 handoff_attribution,但只让 re-enable 一个调用方去问它,disable 侧仍拿布尔收敛——而 disable 的否定方向不是不作为,是销毁证据

[P1] disable 在归属不可读时停止 — 已修

  • 新增 recovered_disable_attribution() -> Result<bool, AdapterError>Performed → Ok(true)(write-ahead mark + persist_claim,行为不变)、NotPerformed → Ok(false)(跳过,行为不变)、Unknown → Err(AdapterError::FrameworkCli)record_recovered_slot_selection_handoffs 改为问它,不再把 Unknown 折叠成 falsecontinue
  • 失败点天然在第一条宿主变更之前:disable() 的顺序是 resolve → capture_slot_readings本判定plugins uninstall → skills → restore。Manager 的 Err 分支只在 claim != pristine 时落盘状态,此处 claim 未被本轮修改 ⇒ receipt 原样保留(status 都不变),slot 不重置、本插件不卸载、receipt 不删除。
  • 错误文案点名插件,说明「什么都没卸载、slot 没被重置、receipt 原样保留,证据还在,宿主恢复后重试」,以及两个方向都不能猜的理由。

顺带对齐 disable 预览,避免换成一种新偏差

plan_disable_restores 现在先对每个 applied=false 条目问同一个判定(预览无 capture,用 SlotReadings::Live),Unknown 时 dry-run 与真实 disable 一样报错。否则会出现「预览说 would leave ... never disabled it、真实 disable 直接失败」,即上一轮 P2 在 enable 侧修掉的同类矛盾。闸门只加在 plan_disable_restores,没加进共用的 restore_preview_lines:dropped-displacement / 迁移两条预览所描述的操作本身仍走布尔收敛,只改预览会把偏差搬到另一侧。

布尔收敛 handoff_performed_by_slot_selection 剩两个调用方(enable 侧 write-ahead mark、restore_decision 的 restore 分支),它们的否定方向确实只是「不打标 / 不恢复」,行为逐位不变;相关文档与调用方计数已同步(上一轮 thread 里「三个调用方」的说法本轮已修正)。

回归(3 条,均在 c1eb7e93 上确认失败、症状与你实测一致)

用例 覆盖 旧 head 上的表现
review_disable_keeps_unmarked_handoff_on_unreadable_capture 你的复现路径:failed enable fixture → 清掉两个故障 → 只设 FAKE_OC_CONFIG_GET_FAIL_ONCE=plugins.slots.memory → 真实 disable → 重试 disable cleanup_complete=trueclaim_removed=true、报告 ... this adapter never disabled it and there is nothing to restore,宿主 enabled=false
review_disable_keeps_unmarked_handoff_on_unreadable_enablement_flag 同 fixture,改设 FAKE_OC_CONFIG_GET_FAIL_ONCE=plugins.entries.memory-core.enabled(slot 可读、live flag 读失败) 「碰巧」恢复了:第一次判定压成 false 不打标,restore_decision 第二次判定时 FAIL_ONCE 已消耗 ⇒ 下发 plugins enable memory-core,但同一次 disable 内两次判定不一致、receipt 照删
review_disable_preview_fails_like_the_real_disable_on_unreadable_capture dry-run 与真实 disable 在同一读失败下同样报错;宿主恢复后交给既有 assert_disable_preview_matches_real 对拍 dry-run 「成功」并承诺 would leave openclaw plugin 'memory-core' alone ... never disabled it

前两条共用 assert_disable_stops_on_unreadable_attribution(两半都不单独承载归属,只守一半等于没守),断言链为:报错且点名插件 → argv 全为只读探测(assert_dry_run_only_probed,白名单式)→ registry_marker_exists() 仍真、plugins.slots.memory=tokenlessplugins.entries.memory-core.enabled=false 未变 → receipt 仍在且仍 applied=false → 重试后真的 plugins enable memory-core、无 never disabled itcleanup_complete=trueclaim_removed=true、最终 enabled=true

本地验证(Rust 1.93.0;本机 rustup 镜像取不到 1.93.1)

  • cargo fmt --all -- --check 通过
  • cargo clippy --workspace --all-targets -- -D warnings 0 warning
  • cargo test --locked -p anolisa-core:lib 1092 passedadapter_frameworks 68 passedadapter_manager 204 passed / 1 ignored(= 201 + 3)
  • RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps 通过(本轮改了 doc comment)
  • git diff --check 通过;3 条新用例在 c1eb7e93 全失败、在 78a5230 全通过
  • 未跑真实 Gateway E2E(与上一轮同一原因:需要一个正在运行的 gateway 才能观测工具归属)

有意留下的遗留项

cleanup_replaced_claim 的 same-home dropped-displacement restore 及其预览仍走布尔收敛,Unknown 在那里读作 SkipNotApplied,而 prior receipt 随即被 replacement 取代,归属记录同样会消失。形状与本条同类,但触发条件多一层(新契约丢掉该 displacement)且需要第三套错误文案,倾向单独一轮处理,本轮不扩大 diff;如认为应并入本轮,我按同样标准补。

请复审。

@kongche-jbw kongche-jbw left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

复审 c1eb7e93 → 78a5230:上一轮直接 disable 的 P1 已验证修复,slot / enabled 标志不可读时停止以及 disable 预览的三条回归均通过。但仍有两项独立复现,详见 inline comments:

  • P1:新契约删除 displacement 后,同目录 re-enable 仍会在一次 slot 读取失败时丢掉恢复记录。这是作者回复中承认的遗留问题,不是本次新增回归;它仍属于本 PR 的恢复契约,建议合并前处理。
  • P2:真实跨目录迁移会调用本次已加保护的 disable,迁移预览却没有同步检查,导致相同不可读状态下预览成功、实际执行报错。这是本次行为变更引入的偏差。

验证(Linux / Rust 1.93.1,生产 AdapterManager/OpenClawDriver + PR 自带模拟 CLI):

  • cargo test --locked -p anolisa-core --lib adapter:379 passed。
  • cargo test --locked -p anolisa-core --test adapter_manager --test adapter_frameworks:现有 manager 204 passed、1 ignored;frameworks 68 passed。新增两项独立复现均 failed。
  • P1 进一步走到最终 disable,确认 cleanup_complete=true、claim_removed=true,但 memory-core.enabled=false。
  • git diff --check 通过;提交前 head 未变,CI 13 success / 14 skipped。

未修改工作树中的生产代码,未运行真实 Gateway E2E。继续保留 Request changes,建议同时补齐上述相邻路径后再合并。

Comment thread src/anolisa/crates/anolisa-core/src/adapter/openclaw.rs Outdated
Comment thread src/anolisa/crates/anolisa-core/src/adapter/openclaw.rs
Both branches of `cleanup_replaced_claim` could still spend "the host could
not answer" as "this adapter never disabled it", and both branches are the
last place anybody can act on the ownership.

The same-home branch restores the displacements the replacement receipt no
longer claims, and `restore_decision` decides that restore through the
boolean collapse of `handoff_attribution`. The fold is right for a `disable`
— the receipt survives the skip and the next attempt is asked the same
question of an unchanged host — and wrong here, because this hook's `Ok` is
what lets the Manager make the receipt swap its next durable action. So a
single transient `config get` failure over an unapplied entry the contract
being enabled dropped ended with the restore skipped, `cleanup_complete =
true`, a replacement receipt naming no such plugin, and a bundled backend
still off with nothing recording which adapter turned it off. No crash, no
operator, and no receipt left for the retry to recover from. It is also the
one path the last two rounds could not reach: `prepare_enable` and
`plan_enable` both walk the *current* contract, so a displacement that
contract dropped is invisible to `recovered_prior_handoff` however unapplied
the prior receipt leaves it, and `preserve_openclaw_displaced_facts` carries
an entry over only when the contract still declares it *and* the receipt
marks it applied.

`attribute_dropped_prior_handoffs` asks the tri-state for every unapplied
dropped entry before the restore runs, so `Unknown` fails the re-enable
before its first mutation and the Manager keeps the prior receipt durable.
The judgment is `prior_handoff_recovery`, split out of
`recovered_prior_handoff` so a caller that already holds the entry does not
have to index a map to ask about it — no second attribution rule, and the
same error the re-enable side already raises.

The cross-home branch is `return self.disable(prior, ctx, progress)`, so it
inherited the attribution gate the real disable gained, while
`plan_reenable_cleanup` kept calling `restore_preview_lines` directly
because that gate had been added to `plan_disable_restores` rather than to
something both previews share. A migration dry-run therefore listed the
prior home's uninstall and described the unapplied entry as a hand-off that
never happened and needed no restore, over an enable that stopped in the
prior-home cleanup with "cannot read the evidence".
`gate_disable_restores_on_attribution` is the one read-only gate both
previews now ask, against the receipt's own home rather than the one `ctx`
resolves to.

Trade-off: a same-home re-enable whose contract dropped an unapplied
displacement, and any migration whose prior receipt names one, can now fail
on a transient host read failure instead of completing with a report that
asserts what nobody could read — and their dry-runs fail the same way. Both
run before any mutation and are retryable by construction: the host has to
recover, not the receipt. The migration plan also repeats one attribution
read, because a preview takes no capture of its own.

Tests: the reviewer's repro is
`review_dropped_displacement_keeps_an_unreadable_handoff_out_of_the_replacement_receipt`
— the failed-enable fixture, a contract that no longer displaces anything,
and one `FAKE_OC_CONFIG_GET_FAIL_ONCE=plugins.slots.memory` — and it asserts
that the re-enable failed having issued nothing but reads, that the prior
receipt still names the displacement unmarked, that the host is untouched,
and that the retry really runs `plugins enable memory-core` before the swap.
`review_dropped_displacement_preview_fails_like_the_real_reenable` fails the
plan on the same read and then shows it promising the hand-back once the
host answers, so the gate is exactly as wide as the operation.
`review_migration_preview_fails_like_the_real_cleanup_on_unreadable_slot`
and `..._on_unreadable_enablement_flag` move the same fixture to a new
`OPENCLAW_STATE_DIR` and assert that the preview and the operation fail
together on either half of the attribution, then agree on the prior-home
restore once the host answers. All four were confirmed to fail on
`78a52305` with the reported symptoms — a re-enable that "succeeded" over
`displaced_plugins = []`, and two plans reading "would leave openclaw plugin
'memory-core' alone ... this adapter never disabled it".

Assisted-by: Codex:0.154.0
Co-authored-by: multica-agent <github@multica.ai>
Signed-off-by: Forrest-ly <forrest-ly@users.noreply.github.com>
@Forrest-ly

Copy link
Copy Markdown
Collaborator Author

第 39 轮 review 已处理(head 78a52305ded47853

@kongche-jbw 两条 inline comment 都已修复、逐条答复并 resolve(threads 21/21 resolved,0 unresolved),请第 40 轮复审。

本轮只动 crates/anolisa-core/src/adapter/openclaw.rscrates/anolisa-core/tests/adapter_manager.rs+556 / −27

[P1] 替换 receipt 前保留不可读的 dropped displacement 恢复责任

按你的建议复用 handoff_attribution,没有另起一套归属判定:把三态判定从 recovered_prior_handoff 里拆成 prior_handoff_recovery(entry, …)Unknown → Err(unreadable_handoff_evidence),沿用 re-enable 侧既有错误),recovered_prior_handoff 只剩 map 查找 + 委托,两个原调用方行为逐位不变。新增 attribute_dropped_prior_handoffs(prior, dropped, ctx),对每个 !applied 的 dropped 条目问一次;cleanup_replaced_claim 同 home 分支在 restore_displaced_plugins 之前调用它。

此处 Unknown 不能再走 SkipNotApplied:与 disable 不同,这个 hook 的 Ok 正是 Manager 把「换 receipt」当作下一个 durable action 的前提,而 prepare_enable / plan_enable 只走当前契约、preserve_openclaw_displaced_facts 又要求「仍声明 applied」,所以跳过就是最后一条记录被删。失败发生在任何 mutation 之前(apply_enable 未跑,host 一个 verb 未变),Manager 走既有的 prior != pristine_prior 才落盘分支,旧 receipt 逐位保留,cleanup_complete 不会被报出来。预览侧 plan_dropped_displacement_restoresrestore_preview_lines 之前调用同一个 gate。

[P2] 迁移预览同步真实 disable 的归属检查

上一轮把 gate 加在 plan_disable_restores 里而不是两个预览共用的地方,所以跨目录的 plan_reenable_cleanup 漏了。抽成 gate_disable_restores_on_attribution(claim, displaced, ctx):对每个 !applied 条目调 recovered_disable_attribution(…, SlotReadings::Live, …)home 取自 claim_state_dir(claim)(receipt 自己的 home,不是 ctx 解析的那个)。plan_disable_restores 改为调用它(行为不变),跨目录分支在 restore_preview_lines 之前用 prior 调用它,于是预览与 return self.disable(prior, ctx, progress) 在同一个读上失败、报同一条 cannot read the evidence … while disabling

新增回归(4 条,均已确认在 78a52305 上 failed)

  • review_dropped_displacement_keeps_an_unreadable_handoff_out_of_the_replacement_receipt —— 你的 P1 复现序列(stage_with_unmarked_handoffredeclare_displacement(plugin_adapter_block(None))FAKE_OC_CONFIG_GET_FAIL_ONCE=plugins.slots.memory → enable):re-enable 失败且只发只读 verb、旧 receipt 仍在且 applied=false、host 未变;retry 成功并确实执行 plugins enable memory-core
  • review_dropped_displacement_preview_fails_like_the_real_reenable —— dry-run 在同一个读上失败;host 回答后 plan 承诺 hand-back,真实 enable 执行它。
  • review_migration_preview_fails_like_the_real_cleanup_on_unreadable_slot / ..._on_unreadable_enablement_flag —— 你的 P2 复现(新建 OPENCLAW_STATE_DIR 并在新目录 registry 声明 memory-coreFAKE_OC_CONFIG_GET_FAIL_KEY 分别打在 slot 与 enabled 两半),preview / actual 对拍:两者都失败、都点名 memory-core、旧 receipt 与旧安装未变;清掉故障后 plan 出现 would re-enable … in the prior state directory …,真实 enable 执行 plugins enable memory-core

Trade-off:契约删掉声明的同 home re-enable、以及旧 receipt 带 unapplied 条目的迁移,现在可能因一次瞬时 host 读失败而失败,dry-run 同样失败;两者都在任何 mutation 之前,且按构造可重试(要恢复的是 host,不是 receipt)。迁移 plan 会多重复一次归属读,因为预览没有自己的 capture。

本地验证(Linux / Rust 1.93.0,全部 --locked):-p anolisa-core --lib adapter 379 passed--test adapter_manager 208 passed / 1 ignored(+4);--test adapter_frameworks 68 passedcargo test --locked 全 workspace 绿(含 doc-tests)。cargo fmt --all --checkcargo clippy --all-targets --locked -- -D warningsgit diff --check 均通过。未运行真实 Gateway E2E。

@kongche-jbw kongche-jbw left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

补交对 78a52305 → ded47853 的复审意见,对应 issuecomment-5691993846 之后的版本。此前这轮结果只在本地会话中汇报,未发布到 PR,现补齐。

迁移预览的修复已验证通过。但删除 displacement 的恢复仍有两条 P1,详见 inline comments:

  • 已确认的归属没有传给 restore;第二次读取失败仍会将其当成未发生交接,enable 成功却丢失恢复记录。这是原 P1 修复不完整。
  • 即使不注入任何新故障,恢复命令之后的 install/enable 也会再次关闭 memory-core,而 replacement receipt 已没有该条目;最终 disable 无法恢复。这是此前遗漏的执行顺序问题。

验证(Linux / Rust 1.93.1,生产 AdapterManager/OpenClawDriver + PR 自带模拟 CLI):

  • cargo test --locked -p anolisa-core --lib adapter:379 passed。
  • cargo test --locked -p anolisa-core --test adapter_manager --test adapter_frameworks:现有 manager 208 passed、1 ignored,frameworks 68 passed,包括本次四条回归。新增的两项独立复现均 failed。
  • git diff --check 通过;head 仍为 ded4785,CI 13 success / 14 skipped。

未修改工作树中的生产代码,未运行真实 Gateway E2E。建议补齐完整生命周期的最终状态断言,继续保留 Request changes。

Comment thread src/anolisa/crates/anolisa-core/src/adapter/openclaw.rs Outdated
Comment thread src/anolisa/crates/anolisa-core/src/adapter/openclaw.rs Outdated
Two ways a same-home re-enable still lost a displacement the contract
being enabled dropped.

`attribute_dropped_prior_handoffs` settled the hand-off attribution
before the restore and then threw the verdict away, so
`restore_displaced_plugins` asked the host a second time through
`restore_decision`'s boolean collapse. A gate that stops the first failed
read but lets the restore spend the second one gates nothing: failing the
second `config get plugins.slots.memory` over the dropped-declaration
repro returned a successful enable with `displaced_plugins = []` and no
`plugins enable memory-core` in its argv — one transient read failure, no
operator, no crash. The settled verdicts now travel to the restore in
`RestoreHost::settled`, and to its preview with it, so the gate is the
last word on the attribution rather than the first of two. The only read
left live in that window is the slot veto, whose unanswerable answer keeps
the restore instead of declining it.

The second loss needed no failure at all. The cleanup restores a dropped
plugin *before* `apply_enable` installs this adapter's own plugin, and
`plugins install` and `plugins enable` both re-run OpenClaw's exclusive
slot selection — so a bundle that still competes for the slot turns the
plugin straight off again, over a replacement receipt naming nothing to
restore. The enable ended with `memory-core.enabled = false` and
`displaced_plugins = []`, and the disable that followed reported a
complete cleanup and removed itself. Moving the restore after the install
cannot be the fix: `plugins enable memory-core` re-runs the same selection
from the other side and would switch this adapter's own plugin off. What
changes is the record. `prepare_enable` carries the dropped entries
through `PreparedEnable`, and `apply_enable` re-asks `handoff_attribution`
once its own registration has run — `Performed` is recorded as applied,
`NotPerformed` is the bundle that does not compete and lets the contract
change take effect, `Unknown` is recorded unapplied so a `disable` can
still recover it from its own capture. `apply_displacements` skips what
the contract does not declare, which is exactly the set this path can add.

Trade-off: a re-enable whose contract dropped a slotful displacement can
now leave that displacement in the replacement receipt instead of an empty
one, which is the point — the receipt, the host and the disable that
follows describe one state. Two shapes stay unrecorded and are documented
on the method: a plugin the replacement receipt already claims, and a
slotful entry whose slot the replacement receipt gave to another plugin,
which `claim_displaced_plugins` rejects outright; expressing both would be
a receipt-format change. The dropped-restore preview gains one line
announcing the conditional retention, because a dry-run cannot predict a
post-install attribution.

Tests: `independent_dropped_handoff_second_read_failure` fails the second
`config get plugins.slots.memory` and asserts the restore still ran, the
receipt still carries it, and the disable that follows really leaves
`memory-core.enabled = true`.
`independent_dropped_handoff_survives_remaining_install` is the same
fixture with no fault injected anywhere, asserts the restore precedes the
install, and then asserts the final state of the whole enable → disable
lifecycle rather than the command order. Both were confirmed to fail on
`ded47853` with the reported symptoms.
`review_dropped_displacement_keeps_an_unreadable_handoff_out_of_the_replacement_receipt`
keeps every assertion about the gate and replaces only its last one: an
empty replacement receipt was the bug, not the contract.

Assisted-by: Codex:0.154.0
Co-authored-by: multica-agent <github@multica.ai>
Signed-off-by: Forrest-ly <forrest-ly@users.noreply.github.com>
@Forrest-ly

Copy link
Copy Markdown
Collaborator Author

第 40 轮 review(5220261554)2×P1 已修复

head:ded478536f0e80fefix(anolisa): keep a dropped hand-off the install repeats),两条 inline thread 已逐条答复并 resolve。

[P1-A] openclaw.rs:3450 — 已确认的归属没有传给恢复阶段

attribute_dropped_prior_handoffs 现在返回 SettledHandoffsBTreeMap<String, HandoffAttribution>)而不是 (),经 RestoreHost::settled 传到 restore_decision;新的 restore_handoff_performed 先查表、只有表里没有的条目才回落 live 读。第二次读取失败因此只能落在 slot veto 上,而 veto 对「读不到」的答案本来就是保留恢复。plan_dropped_displacement_restores 用同一张表,预览与操作问的次数一致。disable 侧传空表,行为不变(它的 gate 把结论写进 receipt 的 applied,不存在没传下去的归属)。无需额外持久化:gate 通过 / restore 失败保留 prior receipt / restore 成功当场划掉条目,三条路径都不丢已确认的归属;操作者显式改 slot 的保护(NotPerformedSkipSlotOwnedSkipSlotClosed、三个 policy veto)全部保留。

[P1-B] openclaw.rs:975 — 恢复之后的 install/enable 再次关闭 memory-core

按「保留恢复责任」修复,没有把恢复命令后移(后移不成立:plugins enable memory-core 会从另一侧重跑 slot selection,把本插件自己关掉)。prepare_enable 把 dropped 条目通过 PreparedEnable::OpenClaw::dropped_prior_displacements 带到 apply_enable,后者在 plugins install 之后与 plugins enable <own> 之后各问一次同一个 handoff_attributionPerformed ⇒ 写入替换 receipt 且 applied = trueNotPerformed ⇒ 新 bundle 不再竞争该 slot,契约变更真的生效,什么都不写;Unknown ⇒ 以 applied = false 保留,不声称转换,但 status 会报、disable 仍能用自己的 capture 在 uninstall 前恢复归属。apply_displacements 跳过契约未声明的条目,否则它会把 Unknown 条目提升为 applied 并对一个没人能确认已关闭的插件真的执行 plugins disable

预览补一行声明这是条件性保留(安装后的归属 dry-run 无法预测)。两处明确不记录的残留写在方法文档里:替换 receipt 已声明同一 plugin id;slotful 条目的 slot 已被替换 receipt 给了另一个插件(claim_displaced_plugins 拒绝两条共享独占 slot,要同时表达需要改 receipt 格式与恢复顺序)。

测试

命令 结果
cargo test --locked -p anolisa-core --lib adapter 379 passed
cargo test --locked -p anolisa-core --test adapter_manager --test adapter_frameworks 210 passed / 1 ignored + 68 passed = 278 passed / 1 ignored
cargo test --locked -p anolisa-core(全量) 1371 passed / 1 ignored
cargo clippy --locked --workspace --all-targets -- -D warnings 0 errors
cargo fmt --all -- --check / cargo doc --workspace --no-deps 通过(doc 只剩 claim.rs:317 一条既有 warning)
git diff --check 通过

新增两条独立回归,均先在 ded47853 上确认 failed、症状与你复现的一致:

  • independent_dropped_handoff_second_read_failure —— FAKE_OC_CONFIG_GET_FAIL_ON_NTH=plugins.slots.memory:2;base 上 enable 返回成功、argv 无 plugins enable memory-core(第二次 config get plugins.slots.memory 之后直接 plugins install --force / plugins enable tokenless / plugins inspect --json)。
  • independent_dropped_handoff_survives_remaining_install —— 不注入任何故障,完整 enable → disable;base 上 displaced_plugins[]。现在断言 restore 在 install 之前、enable 结束时 memory-core.enabled=false 且 receipt 记着它(applied=true)、disable 之后 memory-core.enabled=trueclaim_removed=true

review_dropped_displacement_keeps_an_unreadable_handoff_out_of_the_replacement_receipt 关于 gate 的断言全部保留,只把最后一条「替换 receipt 为空」换成本轮的正确终态——空的替换 receipt 正是 bug,不是契约。其余 276 条既有用例无回归。

环境:Linux / rustc 1.93.0(仓库 rust-toolchain.toml 钉 1.93.1,本地 rustup 镜像无该版本,用同 minor 的 1.93.0 跑)。未修改工作树中的其他生产代码,未运行真实 Gateway E2E。球权交回,等第 41 轮复审。

@kongche-jbw kongche-jbw left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

复审 ded47853 → 6f0e80fe:上一轮两条独立复现现在均通过,确认 settled attribution 传递及成功安装后的恢复责任保留已生效。但恢复责任在两个相邻路径仍会丢失,详见两条 P1 inline comments:安装发生副作用后报错;同一 slot 的 displacement 被新契约替换为另一个插件。

第一条是本轮 post-install 记录覆盖不足,第二条是作者明确留下且本轮独立复现的遗留项。若当前 receipt 格式无法表达第二种情况,可以在任何 mutation 前拒绝该契约变更,无需为了避免丢失记录而在本 PR 扩大格式设计。

基线:declared base 2e1ee6585d42eb4dc2f6b5756136e5734c94c4c1,merge-base 3f4b22e3d645057270155d905964085ddcf58d5a;本轮增量为 ded47856f0e80f

验证(Linux / Rust 1.93.1,生产 AdapterManager/OpenClawDriver + PR 自带模拟 CLI):

  • cargo test --locked -p anolisa-core --lib adapter:379 passed。
  • cargo test --locked -p anolisa-core --test adapter_manager --test adapter_frameworks:现有 manager 210 passed、1 ignored;frameworks 68 passed;新增两项独立复现均 failed。
  • 两项复现都走到了最终 disable:cleanup_complete=true、claim_removed=true,但 memory-core.enabled=false。
  • git diff --check 通过;发布前 head 未变,CI 13 success / 14 skipped。

未修改工作树生产代码,未运行真实 Gateway E2E。继续保留 Request changes。

Comment thread src/anolisa/crates/anolisa-core/src/adapter/openclaw.rs Outdated
Comment thread src/anolisa/crates/anolisa-core/src/adapter/openclaw.rs
Two adjacent paths still lost the recovery responsibility for a
displacement the contract being enabled dropped.

`apply_enable` recorded one only after `plugins install` returned
successfully, and the candidates lived only in `PreparedEnable`. But the
install is not an atomic step: it registers the bundle and applies
OpenClaw's exclusive slot selection before the exit status is decided, so
a non-zero exit is a report about a command that has already turned the
plugin the same-home cleanup handed back off again. By then the Manager
has swapped the receipts, `PreparedEnable` dies with the process, and the
durable receipt names nothing to restore. Over
`FAKE_OPENCLAW_FAIL=install_after_register` the enable failed with
`boom-after-register`, the following disable reported
`cleanup_complete = true` and `claim_removed = true`, and the host ended
with `memory-core.enabled = false` and no operator anywhere in the
sequence. Recording only on success cannot cover that, nor a process
killed between the command and the save.

So the candidates are staged in the receipt as *unapplied* entries before
the install and settled afterwards: `Performed` marks, `Unknown` leaves
the entry staged, and `NotPerformed` releases it — but only on the pass
after `plugins enable`, because install and enable run the same selection
and a release between the two would delete a responsibility the next
command is about to re-create. An unapplied entry claims no transition,
which is what keeps the contrast honest: an install that fails before it
mutates still leaves the disable nothing to spend, and it says so.

The second loss needed no failure at all. A dropped entry whose slot the
replacement receipt had already given to another plugin was skipped,
because `claim_displaced_plugins` rejects two entries sharing one
exclusive slot — so a contract that moved a slot from one bundled plugin
to another succeeded while discarding the older responsibility, and the
disable that followed found the slot reset to the dropped plugin by its
own uninstall, vetoed the declared one as a third owner, and left the
host with no backend behind the slot at all. It is staged slotless
instead. The slot is contract metadata rather than history, the current
contract declares no slot for a plugin it no longer names, and the entry
it *does* declare carries the guard for that exclusive slot; what the
dropped entry exists to keep is the plugin id and whether this adapter
turned it off. The attribution is still asked against the prior slot,
which is the one this adapter's own selection took.

This is a representation the existing receipt already supports, not a
format change, so nothing about the preview had to move — and the line
`plan_dropped_displacement_restores` already announced ("the replacement
receipt keeps the displacement, so disable still hands it back") is now
true of the collision case it used to contradict.

Trade-off: one shape is documented on the method rather than fixed. An
entry staged slotless by the collision case has no slot for a `disable`'s
pre-uninstall capture to be asked against, so an `Unknown` attribution
there is terminal — the disable skips the restore and says so, instead of
reporting a complete cleanup over a silence nobody recorded. Recovering it
needs a receipt that can hold a recoverable attribution alongside a slot
another entry owns, which is the format change this deliberately is not.

Tests: `review_dropped_handoff_survives_install_error_after_registration`
fails the install after registration and slot selection, then asserts the
receipt still names the plugin and that the disable which follows really
leaves `memory-core.enabled = true`.
`review_dropped_handoff_is_not_claimed_when_the_install_never_mutated` is
the contrast, failing the install before it registers: no
`plugins enable memory-core` from the disable, the report says the adapter
never disabled it, and the plugin stays on.
`review_same_slot_replacement_keeps_old_recovery_responsibility` moves the
memory slot from `memory-core` to `memory-lancedb` with no fault injected
anywhere, asserts the one receipt keeps both responsibilities, and asserts
the enable → disable lifecycle ends with a backend really on. All three
were confirmed to fail on `6f0e80fe` with the reported symptoms.

`cargo test --locked -p anolisa-core --lib adapter`: 379 passed.
`cargo test --locked -p anolisa-core --test adapter_manager --test
adapter_frameworks`: 281 passed / 1 ignored (213 + 68).

Co-authored-by: multica-agent <github@multica.ai>
@Forrest-ly
Forrest-ly force-pushed the fix/AGE-7186-openclaw-adapter-enable-memory-handoff branch from eddd84b to 19c63f8 Compare September 16, 2026 10:55
@Forrest-ly

Copy link
Copy Markdown
Collaborator Author

第 41 轮 2×P1 已修复 — head 6f0e80feb19c63f86

@kongche-jbw 两条 inline comment 已逐条答复并 resolve(4024671527 / 4024671537),球权交回第 42 轮复审。本轮一个 commit,只改 anolisa-core 的 openclaw adapter 与其测试,3 files changed / +526 −96。

P1-A:install 产生副作用后报错时保留 dropped 恢复责任

apply_enable 现在在 install 之前把 dropped 候选预写进正在写的 receipt(stage_dropped_recovery_candidates,一律 applied = false,逐条 persist_claim),install / enable 之后再确认归属:Performed → 标记 applied,NotPerformed → 释放条目(让契约变更真正生效),Unknown → 保持 unapplied。释放只在 plugins enable 之后那一趟做(DroppedRelease),因为 install 与 enable 跑同一套 exclusive slot selection,中间释放会删掉下一条命令正要重新产生的责任。归属仍问旧 receipt 的 slot,所以 slotless 条目照样能确认。

预写不声称任何转换,因此「install 在产生副作用之前失败」不会被冒领 —— 对照用例保留。

P1-B:同-slot 替换不再用 continue 丢掉旧责任

continue 已删除。被丢条目的 slot 若已被替换 receipt 给了另一个插件,改为 slotless 写入而不是跳过:这不是格式扩展(DisplacedPluginRef.slot 本就是 Option,slotless 位移是契约里已有的形状),依据是「slot 是契约元数据不是历史」—— 当前契约不声明这个插件,就没有契约 slot 可供守卫,而那个 exclusive slot 的守卫由契约确实声明的条目带着。于是一份 receipt 同时保住两份责任,claim_displaced_plugins 的双条目共槽拒绝也不再被绕过。

没有选择在 prepare/preview 拒绝:同-slot 替换在 bundle 不与被丢插件争同一 kind 时是安全且已有的升级路径(reenable_swaps_a_displacement_the_new_contract_replaced),一律拒绝会把它一起砍掉,而 prepare 阶段无法可靠判断 install 之后 slot 会不会被重新选走。

一个诚实的残留已写在 settle_recovered_dropped_handoffs 文档里:slotless 条目在 Unknown 归属下无法被 disable 侧从 uninstall 前的 capture 恢复,disable 会跳过并明说never disabled it),不再报 cleanup 完成后一片安静;闭合它需要你说本轮不要求的那个 receipt 格式变更。

新增回归(3 条,均在 6f0e80feb 上确认 failed)

用例 场景 终态断言
review_dropped_handoff_survives_install_error_after_registration FAKE_OPENCLAW_FAIL=install_after_register → enable 失败 → 清故障 → disable receipt 仍命名 memory-core(unapplied);disable 后 cleanup_complete / claim_removed / memory-core.enabled == true
review_dropped_handoff_is_not_claimed_when_the_install_never_mutated FAKE_OPENCLAW_FAIL=install(副作用之前退出)→ disable 不发 plugins enable memory-core,报告 never disabled it,插件保持 on
review_same_slot_replacement_keeps_old_recovery_responsibility memory-core@memorymemory-lancedb@memory,无故障注入 一份 receipt 保住两份责任;disable 后 memory-core.enabled == true,宿主有可用后端

验证(Linux / Rust 1.93.0;仓库 rust-toolchain.toml 钉的 1.93.1 在本机镜像上 404,用同 minor 的 1.93.0)

  • cargo test --locked -p anolisa-core --lib adapter379 passed(与你的基线一致,无回归)
  • cargo test --locked -p anolisa-core --test adapter_manager --test adapter_frameworks281 passed / 1 ignored(manager 213 = 210 + 3 新增,frameworks 68)
  • cargo clippy -p anolisa-core --all-targets --locked -- -D warnings:干净
  • cargo fmt --all -- --check:干净;git diff --check:干净
  • CI:19c63f86 的 check-runs 刚触发(29 条,其中 4 条 in_progress),未等待结果

未跑真实 Gateway E2E,与你复审时的范围一致。

@ikunkun-sys ikunkun-sys left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed current head 19c63f86d86b16c23d24fdc37cdd66371134bb30. The previous one-shot slot-read failure is fixed, and all eight earlier review reproductions pass. One new P2 remains in the same-slot replacement handling: staging the old displacement without its slot bypasses the operator-choice guard during its later restore.

Local validation on Linux with Rust 1.93.1:

  • AdapterManager integration suite: 213 passed, 1 ignored child-process entry point.
  • Cross-framework integration suite: 68 passed.
  • OpenClaw unit tests: 53 passed.
  • Eight previous review reproductions and the additional migration recovery test passed.
  • New same-slot replacement reproduction failed in both controls: the operator explicitly closes the slot, or selects a third plugin. The reproduction starts with a successful enable and injects no failures.
  • PR diff whitespace check against the merge base passed.

Validation used a simulated OpenClaw CLI; no real gateway end-to-end test was run. At submission, the reviewed head is unchanged, all checks are completed (13 successful, 14 skipped), and all 25 existing threads are resolved. GitHub reports MERGEABLE / BLOCKED and CHANGES_REQUESTED.

Comment thread src/anolisa/crates/anolisa-core/src/adapter/openclaw.rs
A same-slot replacement staged the older displacement without the slot the
current contract gave to another plugin, and the entry lost the operator's
protection along with the ownership. `claim_displaced_plugins` refuses one
exclusive slot to two entries, so `stage_dropped_recovery_candidates` dropped
the key — but ownership and the veto are not the same claim, and only the
first is exclusive: the plugin a guardless entry restores still declares that
slot's kind, so `plugins enable` on it re-runs OpenClaw's selection over the
same key whichever entry owns it.

No fault injection needed. Enable an adapter that displaces `memory-core` on
`memory`, add `memory-lancedb` of the same kind, change the contract to
displace only `memory-lancedb@memory`, re-enable, set `plugins.slots.memory`
to `none`, disable. The driver skipped `memory-lancedb` because the slot was
closed, then issued `plugins enable memory-core` without asking about it,
reopened the slot the operator had just closed, and reported a complete
cleanup. A third plugin behind the slot instead of `none` ended the same way,
with that selection reset to `memory-core`.

So the receipt can now hold the key without the ownership:
`DisplacedPluginRef::guard_slot`, defaulted and omitted on the wire like every
other field this driver added, so a version-4 receipt still parses and a
version-5 one that owns its slot serializes exactly as before.
`DisplacedPlugin::slot_key` is the single question every host-side decision
about an entry asks — the restore veto, the hand-off attribution and the
capture that feeds it — and it reads the owned slot first, so no entry that
owns a slot behaves differently. `DroppedPriorDisplacement` carries the key as
well, because a receipt is replaced more than once: without it the second
replacement stages the entry slotless *and* guardless, and the settle pass
that follows reads it as a plugin nothing competes for and deletes the
responsibility outright.

This is the format change the previous commit named as the only way out of its
own documented trade-off, so that trade-off is closed rather than restated: an
unapplied entry guarding a slot another entry owns is now recoverable from a
`disable`'s pre-uninstall capture instead of terminal.

Trade-off: a guard comes from the receipt, and the receipt learns a key only
from a contract declaration or from a slot it had to give up. A displacement
declared with no `slot` for a plugin that does compete for one still restores
on the inventory and policy vetoes alone — the driver has no key to read, and
deriving one from the bundle's `kind` would be a guess about a manifest it has
not parsed.

Tests: `review_same_slot_replacement_restore_keeps_an_operator_closed_slot`
and `review_same_slot_replacement_restore_keeps_a_third_plugin_selection` are
the two halves of the reproduction, each asserting no `plugins enable
memory-core`, a report naming the choice the retained entry stepped aside for,
and the operator's own value still behind the slot afterwards.
`a_second_replacement_keeps_the_guard_the_first_one_recorded` re-enables a
third time under a contract that declares nothing.
`disable_recovers_an_unmarked_handoff_whose_slot_another_entry_owns` forges
the mark away and asserts the capture recovers the hand-off rather than
disowning it. Two entries join `displacement_forgeries`: one that both claims
and guards a slot, and one with an empty guard slot. The first two were
confirmed to fail, with the reported `plugins enable memory-core` over the
operator's choice, on a build whose veto reads only the slot an entry owns;
the third fails when `DroppedPriorDisplacement` drops the key, and the fourth
when the attribution does.

`cargo test --locked -p anolisa-core --lib adapter`: 379 passed.
`cargo test --locked -p anolisa-core --test adapter_manager --test
adapter_frameworks`: 285 passed / 1 ignored (217 + 68).
`cargo test --locked --workspace`: green. `cargo fmt --all -- --check`,
`cargo clippy --workspace --all-targets -- -D warnings` and
`cargo doc --workspace --no-deps`: clean, apart from one pre-existing
`redundant_explicit_links` warning at `adapter/driver.rs:341` that this change
does not touch.

Signed-off-by: Forrest-ly <forrest-ly@users.noreply.github.com>
Co-authored-by: multica-agent <github@multica.ai>
@Forrest-ly

Copy link
Copy Markdown
Collaborator Author

第 42 轮 1×P2 已修复 — head 19c63f86d32940bf3

@ikunkun-sys inline comment 4032799227 已答复并 resolve,球权交回第 43 轮复审。本轮一个 commit,4 files changed / +607 −52,只动 anolisa-core 的 receipt 表示、openclaw adapter 与其测试。

P2:同-slot 替换丢掉的不是 slot 元数据,是 operator 选择的守卫

你这条成立,戳的是上一轮我自己写下的那句「slotless 条目没有守卫可让位,restore 直接执行」。那句话把 receipt 里有没有 slot 字段 当成了 宿主上有没有副作用,而两者无关:plugins enable memory-core 会重跑 OpenClaw 的 exclusive slot selection,写的是 plugins.slots.memory 这个 key。所以「让出所有权」与「让出 veto」必须分开,上一轮把两者一起丢了。

新增 DisplacedPluginRef::guard_slotclaim.rs:698),把这两个 claim 拆开:

字段 含义 是否独占
slot 这条条目拥有的 exclusive slot,restore 后由该插件重新占用 是,claim_displaced_plugins 的双条目共槽拒绝一字未动,slots_taken 只看它
guard_slot 这条条目的 restore 必须先读的 key(plugins enable 会重写它) 否,两个条目可以引用同一个 key(一个 own、一个 guard)——那正是同-slot 替换留下的形状

DisplacedPlugin::slot_key() = slot.or(guard_slot),成为 driver 关于一个条目问宿主的唯一 key:restore_decision 的 slot veto、slot_attribution 的归属、喂归属的 capture_slot_readings 都改问它。own 优先,所以任何拥有 slot 的条目行为不变。

线上兼容按本 driver 既有规则:#[serde(default, skip_serializing_if = "Option::is_none")],v4 receipt 照旧解析(读成「只咨询自己拥有的 slot」),v5 里拥有 slot 的条目序列化结果与 v4 逐字节相同;DRIVER_SCHEMA_VERSION 4 → 5 并在常量文档里记了这一条。

guard_slot 也跟着责任走:DroppedPriorDisplacement 带上它(driver.rs:192)。receipt 会被替换不止一次 —— 只带所有权的话,第二次替换会把条目 staged 成 slotless guardless,紧随的 settle_recovered_dropped_handoffs 把它读成「契约没声明 slot ⇒ 没有本 driver 的 selection 能解释这个 flag」⇒ NotPerformed ⇒ 直接删掉整条责任。回归里量过:去掉 carry,第二次替换后 receipt 只剩 [("memory-lancedb", Some("memory"), None, true)]memory-core 整条消失。

顺带闭合上一轮明写的那个残留

上一轮在 settle_recovered_dropped_handoffs 文档里写着:slotless 条目在 Unknown 归属下是终态,「要闭合它,需要一种能在『另一个条目已占用该 slot』的同时保存可恢复归属的 receipt 表示」。本轮就是这个表示,所以那段文档改成「没有任何形状被排除在恢复之外」,并有回归盯着(见下表第 4 行)。

新增回归(4 条)+ 2 条伪造形状

共用 fixture stage_same_slot_replacement:先断言 receipt 形状为 [("memory-lancedb", Some("memory"), None, true), ("memory-core", None, Some("memory"), true)]、slot 仍在本 adapter 自己的插件上 —— 这样此后 key 里是什么就只能来自 operator。全程不注入故障。

用例 场景 终态断言
review_same_slot_replacement_restore_keeps_an_operator_closed_slot 你的复现:plugins.slots.memory = none → disable argv 里既无 plugins enable memory-core 也无 plugins enable memory-lancedb;报告点名让位的那个选择;cleanup_complete / claim_removedplugins.slots.memory 仍为 nonememory-core.enabled 仍为 false
review_same_slot_replacement_restore_keeps_a_third_plugin_selection 同一 fixture,slot 改成第三方 memory-third 同上,slot 仍为 memory-third。这条存在的理由:veto 不能写成「显式关闭」的特例 —— plugins enable 不是在开/关之间选,而是把 slot 写给正在被 enable 的插件
a_second_replacement_keeps_the_guard_the_first_one_recorded 第三次 enable(契约不再声明任何位移) guard 仍在,并且随后仍然是 veto 而不是装饰
disable_recovers_an_unmarked_handoff_whose_slot_another_entry_owns 伪造 applied = false(宿主仍显示 slot = 本 adapter 插件、memory-core.enabled = false disable 的 pre-uninstall capture 恢复该 hand-off:argv 有 plugins enable memory-core、终态 enabled == "true"、报告不再出现 never disabled it

前两条在「veto 只读条目自己拥有的 slot」的构建上确认 failed,症状与你复现一致(plugins uninstall tokenless --force 之后紧跟 plugins enable memory-core,slot 被改回 memory-core);第 3 条在 DroppedPriorDisplacement 不携带 key 时 failed;第 4 条在归属不读 guard 时 failed。

displacement_forgeries 另加两种:同时 slot + guard_slot(「这条 restore 该读哪个 slot」有两个答案,失败关闭而不是按优先级悄悄挑一个)、空 guard_slotplugins.slots. 不是 key,会静默退化成无守卫 restore)。两者由既有的 statusdisable(含 --dry-run)伪造用例自动覆盖,并断言拒绝之前一次宿主调用都没有。

plan_dropped_displacement_restores 里那句「若注册本 adapter 自己的插件重新选中上面某个插件竞争的 slot …」的触发条件从 entry.slot.is_some() 改为 entry.slot_key().is_some():只 guard 的条目同样竞争那个 slot,否则第二次替换之后这句话就不打印了。

一个诚实的残留

guard 只能来自 receipt,而 receipt 只从两处学到 key:契约声明的 slot,或它被迫让出去的那个 slot。所以「契约声明里没写 slot、但插件确实声明了某个 kind」的位移,restore 仍只有 inventory / policy 三个 veto。闭合它要从 bundle 的 kind 反推 key,那是 driver 现在没有的概念,靠猜会新增一个误判面 —— 与上一轮拒绝在 prepare 一律拒绝同-slot 替换是同一条理由。

验证(Linux / Rust 1.93.0;仓库 rust-toolchain.toml 钉的 1.93.1 在本机镜像上 404,沿用上一轮的 1.93.0)

  • cargo test --locked -p anolisa-core --lib adapter379 passed(与你的基线一致,无回归)
  • cargo test --locked -p anolisa-core --test adapter_manager --test adapter_frameworks285 passed / 1 ignored(manager 217 = 213 + 4 新增,frameworks 68)
  • cargo test --locked --workspace:全绿
  • cargo fmt --all -- --checkcargo clippy --workspace --all-targets -- -D warningsgit diff --check:干净
  • cargo doc --workspace --no-deps:干净,除 adapter/driver.rs:341 既有的 redundant_explicit_links(本次未碰该行)
  • CI(32940bf3,仅取一次快照未等待):Test agent-memory / Commit Message Lint / Docs Lint / AW * / Check component versions 等 success,Test anolisaBuild website 仍 in_progress,0 failure

未跑真实 Gateway E2E,与你复审时的范围一致。

@ikunkun-sys ikunkun-sys left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

结论:REQUEST_CHANGES

基于 head 32940bf3677bc0b9e4ef1f768694fc8e4d1fc3f1,当前仍有一个可复现的恢复问题,见行内 P2:卸载自身产生的 memory slot 默认值被当成第三方接管,导致非默认 memory 插件未恢复且 receipt 被删除,dry-run 与实际结果也不一致。

本地验证(Linux x86_64,Rust 1.93.1):AdapterManager 217 passed / 1 ignored;adapter 单元测试 344 passed;跨框架测试 68 passed。独立用例 independent_nondefault_memory_restore 使用当前真实 Manager/Driver 与 PR 的模拟 OpenClaw CLI,无故障注入,恢复断言失败;未运行真实 gateway。已核对 OpenClaw v2026.4.14 的卸载源码。

提交前核验:head 未变化;CI 13 success / 14 skipped;MERGEABLE,mergeStateStatus=BLOCKED,reviewDecision=CHANGES_REQUESTED;26 条既有线程均已解决。

Comment thread src/anolisa/crates/anolisa-core/src/adapter/openclaw.rs Outdated
Forrest-ly and others added 2 commits September 18, 2026 12:16
The slot veto read `plugins.slots.<slot>` live, after the `plugins uninstall`
that precedes it, and `removePluginFromConfig` resets a slot the plugin it
removed had owned to that slot's default — `memory-core` for `memory`, from
`DEFAULT_SLOT_BY_KEY` (v2026.4.14 `src/plugins/uninstall.ts:147-155` with
`src/plugins/slots.ts:17-20`). For a displacement *of* the default the two
readings agree, because the reset target and the displaced plugin are the same
id and `slot_restore_decision` reads that as `Proceed`. For any other
displacement the reset names a plugin the receipt never claimed, so the veto
charged this cleanup's own footprint to a third owner's selection.

No fault injection and no forged receipt. Declare `displaces = memory-lancedb`
with `slot = "memory"`, give this adapter's own plugin, `memory-lancedb` and
`memory-core` all `kind = "memory"`, start the host on `memory-lancedb`,
enable, disable. The dry-run promised `would re-enable openclaw plugin
'memory-lancedb'`, because a preview reads the slot before the uninstall that
rewrites it; the operation said `left openclaw plugin 'memory-lancedb'
disabled: plugins.slots.memory now belongs to 'memory-core'`, reported
`cleanup_complete = true` and `claim_removed = true`, and left
`memory-lancedb.enabled` and `memory-core.enabled` both false — the second
because this adapter's own selection turned it off at enable. Nothing behind
the slot, no receipt recording why, and a preview contradicted by the
operation it described with nothing durable left to contradict it.

`slot_veto_owner` is the veto's reading, and it discounts exactly that value on
exactly the evidence that identifies it: the pre-uninstall capture has to name
this adapter's own plugin, which is the sole condition under which OpenClaw
resets the slot at all, and the live answer has to be that slot's default,
which is the sole value it resets to. Everything else the two readings disagree
about still decides on the live one, so an operator who moved or closed the
slot inside the window is still respected — including one who moved it *to* the
default, which is a choice and not a reset, and which the same capture tells
apart. `capture_slot_readings` therefore reads every slotful entry rather than
only the unapplied ones: the veto needs the pre-uninstall owner for the applied
entry, which is the ordinary case and precisely the one whose owed restore a
misread reset declines. That is one extra `config get` per distinct slot on a
`disable`, over a key the attribution already captures for the unapplied ones.

`default_slot_plugin` carries both keys OpenClaw has a default for. v2026.4.14
resets only `memory`, while the v2026.9.2 line resets `memory` and
`contextEngine` alike (`formatUninstallSlotResetPreview`,
`src/plugins/uninstall.ts:76-79`); the newer behaviour is a superset of the
older, so knowing only `memory` would read a current host's `contextEngine`
reset as an operator's selection — the same misreading, one slot over.

Trade-off: the correction is keyed to a capture, so it needs one. A `disable`
retrying after an unrelated failure captures a slot the *first* attempt's
uninstall already reset, and its two readings then agree on the default, which
the veto reads as an owner. Attributing that too would mean persisting the
pre-uninstall owner in the receipt the way the recovered hand-off mark already
is, and this change does not do that; what it leaves is the shape that shipped
before, not the one it fixes — a first disable over a host nothing else has
touched.

Tests: `independent_nondefault_memory_restore` is the reproduction above,
through the real Manager and driver against the fake CLI with nothing
injected, asserting the preview's promise, the `plugins enable memory-lancedb`
that keeps it, the backend really on afterwards, and no third-owner wording in
the report. `disable_still_steps_aside_for_an_operator_who_moved_the_slot_to_the_default`
is the same fixture with an operator's hand on the slot instead of the
uninstall's, asserting the restore is still declined and the selection still
stands. Run against `32940bf3` with only the driver reverted, the first fails
with the reported `now belongs to 'memory-core'` and no `plugins enable`
anywhere in the disable's argv; the second passes there too, which is the point
of it.

Two existing tests change expectation, not subject.
`review_same_slot_replacement_keeps_old_recovery_responsibility` and
`disable_recovers_an_unmarked_handoff_whose_slot_another_entry_owns` stage one
receipt whose declared entry owns `memory` and whose retained entry guards it,
and both were reading the reset as a veto on the *declared* entry — so the
older plugin was handed back by accident while the newer one was released as
somebody else's. The declared entry now wins the slot, the guard entry steps
aside for an owner that is really there, and both tests still assert what they
were written for: a backend on behind the slot afterwards, and the guard entry
*vetoed* rather than disowned.

`cargo test --locked -p anolisa-core --lib adapter`: 380 passed.
`cargo test --locked -p anolisa-core --test adapter_manager`: 219 passed /
1 ignored. `cargo test --locked -p anolisa-core --test adapter_frameworks`:
68 passed. `cargo test --locked --workspace`: green.
`cargo fmt --all -- --check` and `cargo clippy --all-targets --locked --
-D warnings`: clean. `cargo doc --workspace --no-deps`: clean apart from the
pre-existing `redundant_explicit_links` warning at `adapter/driver.rs:341`.

Co-authored-by: multica-agent <github@multica.ai>
Signed-off-by: Forrest-ly <forrest-ly@users.noreply.github.com>
…handoff

Resolve the merge conflict in crates/anolisa-core/tests/adapter_manager.rs
reported on PR #3228 (mergeable=CONFLICTING).

The conflict came from main landing the adapter observation-contract tests
(#3310 / first slice of #3309) while this branch carried a large rewrite of
the same test module. Both sides are kept:

- imports: union of both sides (AdapterCondition + StatusReport from this
  branch, AdapterStatusReport + ScanReport/ScanEntry/AdapterSourceStatus +
  central_log::LogRecord from main); all of these types exist unchanged in
  the production API on both sides.
- stage(): split into stage() + stage_in(root) exactly as main did, so the
  spaced-prefix central-log audit test can pin the tempdir shape.
- appended main's new read-only collectors helpers (tree_snapshot, scan_row,
  condition, condition_count, read_only_framework_query, central_log_len,
  central_log_framework_argv_after) and its 8 scan/status/source-authority
  regression tests, which this branch's rewrite did not contain.

No production code changed in the resolution: main did not touch the adapter
production files this branch refactored (driver.rs, manager.rs, openclaw.rs,
claim.rs), so they auto-merged to this branch's versions.

Verified: cargo check --workspace --tests = 0 errors;
cargo test -p anolisa-core --test adapter_manager = 227 passed, 1 ignored;
cargo fmt -p anolisa-core --check = clean.

Co-authored-by: multica-agent <github@multica.ai>
@ikunkun-sys

Copy link
Copy Markdown
Collaborator

Superseded by #3347. The replacement namespaces the agent-memory OpenClaw tools and removes the script-level memory-core takeover, keeping collision handling in the component without adding displacement lifecycle management to anolisa-core.

@kongche-jbw
kongche-jbw deleted the fix/AGE-7186-openclaw-adapter-enable-memory-handoff branch September 19, 2026 02:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

component:anolisa src/anolisa component:memory src/memory scope:documentation ./docs/|./*.md|./NOTICE

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(openclaw): adapter enable path never releases memory_get/memory_search from memory-core (#3218 still reproducible)

3 participants