Skip to content

feat(config): delete_with_cascade for agents (#7175) - #7837

Merged
singlerider merged 2 commits into
zeroclaw-labs:masterfrom
NNet-Dev:feat/7175-agent-cascade
Jun 17, 2026
Merged

feat(config): delete_with_cascade for agents (#7175)#7837
singlerider merged 2 commits into
zeroclaw-labs:masterfrom
NNet-Dev:feat/7175-agent-cascade

Conversation

@Nillth

@Nillth Nillth commented Jun 17, 2026

Copy link
Copy Markdown
Collaborator

πŸ“‹ Stacked series β€” 3 of 8

Typed delete-with-cascade (#7175) + alias rename (#7468), sliced for review. This is the active slice (3 of 8) β€” ready for review; slices 4–8 trail as drafts for CI and merge strictly in order. Based directly on master, so this diff is just its own slice (no cumulative inflation).

Sequence: 1 #7785 βœ… Β· 2 #7830 βœ… Β· 3 #7837 ← this PR Β· 4 #7838 Β· 5 #7839 Β· 6 #7840 Β· 7 #7841 Β· 8 #7842

This slice: crates/zeroclaw-config/src/alias_refs.rs only β€” +265 / βˆ’12 (based directly on master; no cumulative inflation).

Summary

  • Adds the agent arm of delete_with_cascade (feat(config): typed delete-with-cascade for aliased entries (providers, agents, channels)Β #7175) β€” the config-reference cascade for deleting agents.<alias> β€” on top of the provider arm landed in feat(config): delete_with_cascade for model providers (#7175)Β #7830.
  • Refuses when a HARD agent config ref would dangle β€” an enabled heartbeat.agent naming the target, or a channel the agent is the sole enabled owner of (whatever collect_agent_refs marks Hard); otherwise scrubs the SOFT refs (clears heartbeat.agent when disabled + acp.default_agent; drops the alias from every agent's delegates, workspace.access map key, read_memory_from, and each peer group's members), removes the agents.<alias> entry, and verifies no dangling ref remains. DryRun mutates nothing.
  • Scope: config references only. The agent's owned non-config state (memory/workspace/cron/acp/session) is cascaded by the surface in 4 of 8. Channels + TTS/transcription remain NotImplemented.
  • New arm on an unused-by-callers function β†’ zero runtime change.

Notable correctness

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 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 (test cascade_agent_self_reference_is_scrubbed).

Validation

cargo test -p zeroclaw-config β†’ 907 passed; 0 failed (incl. agent-cascade tests) Β· cargo clippy --workspace --all-targets clean Β· cargo fmt --all --check clean. 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.

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 singlerider left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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_refs matches collect_agent_refs's trim discipline site for site: it trims exactly where validate() trims (heartbeat, acp.default_agent, delegates) and matches raw on the three AgentAlias-keyed sites (workspace.access key, read_memory_from, peer_groups.agents). cascade_agent_scrub_trim_split_mirrors_find pins that a padded read_memory_from ref 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 a bot.delegates = ["bot"] self-ref is stripped before cfg.agents.remove, and the post-condition find_all_references confirms nothing dangles. cascade_agent_self_reference_is_scrubbed covers it.
  • The post-condition re-walk after mutation is the right belt-and-suspenders: it converts any future scrub/collect drift into a loud PostCondition error instead of a silent dangling ref.
  • The fail-closed ordering is correct: refuse on hard before any mutation, DryRun mutates nothing (cascade_agent_dry_run_mutates_nothing), NotFound short-circuits, and retain is 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.
@Nillth

Nillth commented Jun 17, 2026

Copy link
Copy Markdown
Collaborator Author

Thanks β€” good catch, fixed in c02c8aa.

You're right the comment understated the hard set. delete_agent now names both HARD agent refs β€” an enabled heartbeat.agent and a channel the agent solely owns β€” and cites collect_agent_refs's RefStrength::Hard as the source of truth instead of enumerating, so the scrub mirror in slices 4–8 won't read channel ownership as soft. Fixed the same phrasing in the PR summary.

Took the suggested test too: cascade_agent_refuses_when_solely_owned_channel locks the mutating delete_with_cascade(Agent) refuse path on a sole-owned channel (prior coverage hit it through plan_delete only). Full zeroclaw-config suite green.

@Nillth
Nillth requested a review from singlerider June 17, 2026 08:25

@singlerider singlerider left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.

@singlerider
singlerider merged commit 5099292 into zeroclaw-labs:master Jun 17, 2026
18 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

config Auto scope: src/config/** changed.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants