feat(config): delete_with_cascade for agents (#7175) - #7837
Conversation
Adds the agent arm to delete_with_cascade (config-reference cascade), mirroring the model-provider arm, on top of the provider cascade. - delete_with_cascade(.., AliasKind::Agent, ..): refuses when an enabled heartbeat.agent names the target (the only HARD config ref), otherwise scrubs the SOFT references β clears heartbeat.agent (disabled) and acp.default_agent; drops the alias from every agent's delegates, workspace.access (map key), workspace.read_memory_from, and from each peer_group's members (retain, index-shift-safe) β removes the agents.<alias> entry, and verifies no dangling reference remains. DryRun mutates nothing. - scrub_agent_refs mirrors collect_agent_refs's trim split exactly: trims the sites validate() trims (heartbeat, acp.default_agent, delegates) and leaves the three AgentAlias-keyed sites raw (workspace.access, read_memory_from, peer_groups.agents). A whitespace-padded ref find() flags is scrubbed here too. Scope: config references only. The agent's owned non-config state (memory rows, workspace dir, cron/acp/session rows) is cascaded by the calling surface, which owns those infra stores; ImpactReport.owned_state stays empty here and the surface enforces owned-state HARD refs (e.g. live ACP sessions). Channels and TTS/transcription providers remain NotImplemented. 5 agent cascade tests (refuse-on-enabled-heartbeat, scrub-all-soft+remove, scrub-trim-split, dry-run, not-found). zeroclaw-config suite green; clippy --all-targets + fmt clean.
singlerider
left a comment
There was a problem hiding this comment.
I read this slice against current master: the new delete_agent / scrub_agent_refs arm, the collect_agent_refs it mirrors, plan_delete / find_all_references, and the agent-cascade tests. The cascade design is genuinely good and the trim-split discipline is exactly right. One blocker: a new doc comment misstates the HARD-ref invariant in a way that will mislead the scrub work in slices 4-8.
π΄ Blocking: the "only HARD agent ref is enabled heartbeat.agent" claim is wrong
The new module/function docs assert there is a single hard agent ref. From the diff:
delete_agent:// The only HARD agent ref is an enabled heartbeat.agent (see collect_agent_refs).- PR summary: "Refuses when the one HARD agent config ref would dangle (an enabled
heartbeat.agent...)."
collect_agent_refs recognizes a second HARD ref: channel ownership. When the target is the sole enabled owner of a channel, it pushes RefSite::hard("agents.<alias>.channels[i]", ScrubAction::Refuse, ...). plan_delete sets allowed = blockers.is_empty(), so that hard site refuses the delete too. Your own agent_delete_blocks_on_solely_owned_channel test pins exactly this.
The runtime behavior is correct: delete_agent calls plan_delete and returns Refused on any hard blocker, so a sole-owned-channel agent is refused before scrub_agent_refs runs, and nothing dangles. The problem is the comment, not the code. scrub_agent_refs is the mutating mirror of collect_agent_refs, and slices 4-8 extend that mirror; a maintainer reading "the only HARD agent ref is heartbeat" will reason that every non-heartbeat agent ref is soft and scrubbable, which is false for channel ownership. The comment documents the scrub/collect safety contract, so an inaccurate invariant here is a latent footgun. Reword to name both hard refs (enabled heartbeat.agent and sole-owned channels), or state that the hard set is whatever collect_agent_refs marks RefStrength::Hard and stop enumerating. Fix the same phrasing in the PR summary.
While here: there is no mutating-path test that the agent arm of delete_with_cascade refuses on a sole-owned channel. agent_delete_blocks_on_solely_owned_channel covers plan_delete only; cascade_agent_refuses_when_heartbeat_enabled covers the heartbeat blocker only. The channel-ownership refuse is transitively exercised through plan_delete, so this is a coverage gap rather than a defect, but a cascade_agent_refuses_when_solely_owned_channel case would lock the mutating arm's refuse path against future drift. Not required to land, but cheap and directly on point given the blocker above.
π’ What looks good: the trim-split mirror is precise and well-tested
scrub_agent_refsmatchescollect_agent_refs's trim discipline site for site: it trims exactly wherevalidate()trims (heartbeat, acp.default_agent, delegates) and matches raw on the threeAgentAlias-keyed sites (workspace.access key, read_memory_from, peer_groups.agents).cascade_agent_scrub_trim_split_mirrors_findpins that a paddedread_memory_fromref is left untouched while padded trimmed sites scrub, which is the subtle correctness property here.- The self-reference case is handled correctly and proven: the scrub loop iterates
cfg.agents.values_mut()including the to-be-deleted agent, so abot.delegates = ["bot"]self-ref is stripped beforecfg.agents.remove, and the post-conditionfind_all_referencesconfirms nothing dangles.cascade_agent_self_reference_is_scrubbedcovers it. - The post-condition re-walk after mutation is the right belt-and-suspenders: it converts any future scrub/collect drift into a loud
PostConditionerror instead of a silent dangling ref. - The fail-closed ordering is correct: refuse on hard before any mutation,
DryRunmutates nothing (cascade_agent_dry_run_mutates_nothing),NotFoundshort-circuits, andretainis index-shift-safe. - Scoping the slice to config refs only and deferring owned non-config state (memory/workspace/cron/acp/session) to the surface in slice 4 is the right boundary, and the updated docs make the handoff explicit.
β¦nnel refuse (zeroclaw-labs#7175) Review (zeroclaw-labs#7837, singlerider): the delete_agent gate comment claimed the only HARD agent ref is an enabled heartbeat.agent, but collect_agent_refs also marks a channel the agent solely owns as HARD (agent_delete_blocks_on_solely_owned_channel pins it). The runtime was already correct β plan_delete refuses on any hard blocker β but the comment would mislead the scrub work in later slices into treating channel ownership as soft. Reword to name both hard refs and cite collect_agent_refs as the source of truth. Also add cascade_agent_refuses_when_solely_owned_channel: the existing coverage exercised the channel-ownership refuse through plan_delete only; this locks the mutating delete_with_cascade(Agent) refuse path against future scrub/collect drift.
|
Thanks β good catch, fixed in c02c8aa. You're right the comment understated the hard set. Took the suggested test too: |
There was a problem hiding this comment.
Re-reviewed at c02c8aa against my CHANGES_REQUESTED on 72f3f0b. Both blocking items are resolved cleanly, and I confirmed the new test green locally (cargo test -p zeroclaw-config cascade_agent - 7/7, including the new case). Approving.
RESOLVED β - HARD-ref doc comment now names both hard refs
The delete_agent gate comment previously claimed the only HARD agent ref was an enabled heartbeat.agent, which understated the set: collect_agent_refs also marks sole-owned channel ownership RefStrength::Hard. The new comment fixes this the right way - it states the gate refuses on any HARD ref plan_delete found, names both current hard refs (enabled heartbeat.agent and a channel the agent solely owns), and grounds the invariant in collect_agent_refs/RefStrength::Hard rather than re-enumerating in a way that drifts. That framing keeps slices 4-8 honest: a maintainer extending the scrub/collect mirror reads the source of truth, not a stale hand-maintained list. PR summary corrected to match.
RESOLVED β - mutating arm now has its own sole-owned-channel refuse test
cascade_agent_refuses_when_solely_owned_channel locks the agent arm of delete_with_cascade against future drift: it asserts the refuse fires on agents.bot.channels[0] and that the agent plus its channel ownership survive intact (refuse-before-mutate). Previously this path was only transitively exercised through plan_delete; now the mutating arm pins it directly, alongside cascade_agent_refuses_when_heartbeat_enabled. Exactly the coverage I flagged as cheap and on point.
π’ Everything from the prior pass still holds
The trim-split mirror, self-reference scrub, fail-closed ordering, post-condition re-walk, and DryRun-mutates-nothing properties are unchanged by this commit - it is doc + test only, no behavior change to the cascade logic. Clean slice. Nice turnaround.
This slice:
crates/zeroclaw-config/src/alias_refs.rsonly β +265 / β12 (based directly on master; no cumulative inflation).Summary
delete_with_cascade(feat(config): typed delete-with-cascade for aliased entries (providers, agents, channels)Β #7175) β the config-reference cascade for deletingagents.<alias>β on top of the provider arm landed in feat(config): delete_with_cascade for model providers (#7175)Β #7830.heartbeat.agentnaming the target, or a channel the agent is the sole enabled owner of (whatevercollect_agent_refsmarksHard); otherwise scrubs the SOFT refs (clearsheartbeat.agentwhen disabled +acp.default_agent; drops the alias from every agent'sdelegates,workspace.accessmap key,read_memory_from, and each peer group's members), removes theagents.<alias>entry, and verifies no dangling ref remains.DryRunmutates nothing.NotImplemented.Notable correctness
scrub_agent_refsmirrorscollect_agent_refs's trim split exactly β trims the sitesvalidate()trims (heartbeat, acp.default_agent, delegates) and leaves the threeAgentAlias-keyed sites raw (workspace.access key, read_memory_from, peer_groups.agents). A self-referencing agent (its own delegates/read_memory_from naming itself) deletes cleanly β the scrub strips the self-ref before the entry is removed (testcascade_agent_self_reference_is_scrubbed).Validation
cargo test -p zeroclaw-configβ 907 passed; 0 failed (incl. agent-cascade tests) Β·cargo clippy --workspace --all-targetsclean Β·cargo fmt --all --checkclean. Full CI matrix runs on this PR.Security & Privacy β all No (pure in-memory config mutation; synthetic-alias tests).
Compatibility β backward compatible; no surface change.
Rollback β
git revert <sha>; no migration, no persisted-state effect.Related #7175.