Skip to content

feat(gateway): agent owned-state rename cascade + rename wiring (#7468) - #7841

Merged
Nillth merged 2 commits into
zeroclaw-labs:masterfrom
NNet-Dev:feat/7468-rename-owned-state
Jun 18, 2026
Merged

feat(gateway): agent owned-state rename cascade + rename wiring (#7468)#7841
Nillth merged 2 commits into
zeroclaw-labs:masterfrom
NNet-Dev:feat/7468-rename-owned-state

Conversation

@Nillth

@Nillth Nillth commented Jun 17, 2026

Copy link
Copy Markdown
Collaborator

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

Typed delete-with-cascade (#7175) + alias rename (#7468), sliced for review. Opened as draft for CI; merge strictly in order. While the PRs ahead are unmerged this diff is cumulative β€” it includes the un-merged slices below and collapses to its own delta once they land.

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

This slice (own delta): 12 files, +620 / βˆ’18 (gateway + infra + runtime + memory). Cumulative shown until #3–#6 merge: 14 files, +2610 / βˆ’92.

Summary

The surface half of rename (#7468): wires slice 6's rename_with_cascade into the gateway and re-points the agent's owned non-config state. Today POST /api/config/rename-map-key for an agent just swaps the config key β€” leaving every reference dangling and the agent's persisted state stranded under the old alias.

  • handle_rename_map_key routes by section: agents β†’ rename_agent_cascade (config rewrite + workspace move + owned-DB re-point + persist); providers/channels β†’ rename_config_cascade (config rewrite + persist); non-aliased sections β†’ the generic key-swap (unchanged). A missing source alias now returns 404 for aliased paths.
  • Persistence correctness (load-bearing): save_dirty writes only marked-dirty paths, and the config-layer cascade never marks dirty β€” so the handler marks every path in RenameReport.dirty_paths (the renamed entry's old+new key + each rewritten referrer, at entry granularity) before persisting. /verify confirmed all 9 referrer sites rewritten in the on-disk file.
  • In-place, no archive; no live-session refusal β€” a live ACP session simply follows the rename (contrast delete, which refuses).
  • Owned-state rename methods (best-effort + reported): Memory::rename_agent (sqlite/postgres update the single agents.alias row β€” memory rides the UUID; lucid delegates; qdrant rewrites the agent_id payload), cron::rename_jobs_by_agent, AcpSessionStore::rename_sessions_by_agent, SessionBackend::rename_agent_attribution; a cascade_rename_agent coordinator runs them from data_dir.

Review: 2 blockers found + fixed

  1. rename_agent UNIQUE(alias) collision silently orphaned memory. Delete purges memories but leaves the agents row (the UNIQUE alias slot), so renaming onto a previously-used-then-deleted alias hit the constraint β†’ the Err was swallowed as a best-effort warning while the config rename committed β†’ split-brain returned as a clean 200. Fixed: rename_agent is now collision-safe β€” under the connection lock (a transaction for postgres) it refuses if the target alias still owns memory rows, else drops the orphan row and proceeds. + test (orphan-reclaim + live-collision-refusal).
  2. Owned-state warnings were dropped from the API response. A partial owned-state failure returned {renamed:true} with the warning only in a server log. Fixed: RenameMapKeyResponse.warnings now carries owned.warnings (omitted when empty).

Validation

cargo clippy --workspace --all-targets + cargo fmt --all --check clean at the stack tip (incl. postgres path). End-to-end /verify (real binary + live gateway): rename victim→survivor → 200, re-read the config FILE → all 9 referrer sites now survivor, grep victim → no matches; probes: rename non-existent → 404, no-op → 400. Full CI matrix runs on this draft.

Security & Privacy

New fs op: the workspace dir move (operator-controlled data_dir). DB writes re-point agent_alias/alias columns. No new network/permissions/secrets. No PII in tests.

Compatibility β€” backward compatible; agent rename now also rewrites refs + re-points owned state (a fix). New response field warnings is additive (omitted when empty).

Rollback (risk: medium) β€” git revert; no migration; rename is idempotent/re-runnable.

Deferred

  • RPC config/map-key-rename still does the bare key-swap (a pre-existing gap); routing it through rename_with_cascade is a follow-up.
  • Markdown/none memory keep the trait default (memory lives in the moved workspace) β€” a rename_agent bail surfaces as a warning.

Related #7468.

@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 reviewed slice 7 on its own delta (f8978da), the surface half of rename (#7468). I read rename_agent_cascade / rename_config_cascade / handle_rename_map_key, the cascade_rename_agent coordinator, and the owned-state rename store methods, cross-checked against the slice-4 delete_agent_cascade it mirrors, and confirmed the memory/infra rename tests green locally. The slice-4 workspace-ordering lesson is correctly applied here, and both self-found blockers are fixed well. One blocker remains: a failed workspace move is the one partial failure this slice does not surface, which contradicts the surfaced-not-masked fix you made for the DB stores in the same PR.

πŸ”΄ Blocking: a failed workspace move is silently swallowed, the same split-brain the warnings fix set out to kill

In rename_agent_cascade the workspace move logs a WARN and sets workspace_moved = false on failure, but that outcome never reaches the caller:

match tokio::fs::rename(&old_ws, &new_ws).await {
    Ok(()) => workspace_moved = true,
    Err(err) => { /* WARN log only */ }
}
…
axum::Json(RenameMapKeyResponse { …, renamed: true, warnings: owned.warnings })

owned.warnings comes from cascade_rename_agent, which covers memory / cron / acp / session but not the workspace move (that happens in the handler). So if the config rename + DB re-point succeed but the workspace rename fails (cross-device EXDEV, a permission error, a stale lock), the response is {renamed: true, warnings: []} while the config now points at to and the workspace still sits at the from path. That is exactly the split-brain-returned-as-clean-200 that blocker 2 fixed for the owned DB state, just on the filesystem half. The agent's IDENTITY.md edits and operator notes are now stranded under the old alias dir with nothing telling the operator.

Fix: feed the workspace-move failure into the same surfaced channel, e.g. push format!("workspace move {old_ws:?} -> {new_ws:?}: {err}") onto a warnings vec that the response carries alongside owned.warnings. A handler-level test that forces the move to fail (or at least asserts a simulated owned-state warning round-trips into RenameMapKeyResponse.warnings) would lock it. There is currently no gateway-level test exercising rename_agent_cascade, which is why this and the response-shape contract ride only on /verify - the same coverage blind spot slice 4 had.

🟒 The slice-4 workspace-ordering lesson is applied correctly

old_ws is captured before rename_with_cascade runs (while the entry still lives under from, so a custom workspace.path resolves), and new_ws after. The old_ws == new_ws skip is right: a custom workspace path is alias-independent so there is nothing to move, and only the default <install>/agents/<alias>/workspace location actually relocates. This is exactly the ordering bug I flagged on #7838 (slice 4), and it is not repeated here. Good.

🟒 Both self-found blockers are fixed correctly

  • Collision-safe rename_agent: holding the connection lock across the COUNT-check + orphan-drop + UPDATE (a transaction on postgres) closes the TOCTOU, refuses when the target alias still owns memory rows (a genuine conflict, not silently merged), and drops the orphan agents row left behind by a prior delete otherwise. Memory rides the stable agent_id UUID so only the alias column moves. sqlite_rename_agent_reclaims_orphan_and_refuses_live_collision pins both arms. This is the right fix for a real split-brain (delete purges memories but leaves the UNIQUE alias slot).
  • Owned-state warnings surfaced: RenameMapKeyResponse.warnings now carries owned.warnings (additive, omitted when empty). Correct - and it is precisely this mechanism the workspace-move failure above should also use.

🟒 Owned-state rename methods and routing are sound

  • Memory::rename_agent (sqlite/postgres single agents.alias update, lucid delegates, qdrant rewrites the agent_id payload), cron::rename_jobs_by_agent, AcpSessionStore::rename_sessions_by_agent (live + killed), SessionBackend::rename_agent_attribution - all best-effort + reported, all tested at the store level.
  • handle_rename_map_key routes by section (agents β†’ full cascade, providers/channels β†’ config-only rename_config_cascade, non-aliased β†’ generic swap), returns 404 for a missing source on aliased paths, and marks every RenameReport.dirty_paths entry dirty before persist - the same persistence contract slice 6 hands it, consumed correctly.
  • No-archive / no-live-session-refusal is the right contract for rename (a live ACP session follows the rename rather than blocking it), in deliberate contrast to delete.

The deferred RPC config/map-key-rename bare-swap gap is a reasonable follow-up to call out. Fix the workspace-move surfacing and this lands.

from: from.clone(),
to: to.clone(),
renamed: true,
warnings: owned.warnings,

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.

This response is the only place a workspace-move failure could reach the caller, but it carries owned.warnings only - and cascade_rename_agent covers memory/cron/acp/session, not the workspace move (which happens above, in this handler). So if the tokio::fs::rename(&old_ws, &new_ws) above fails (cross-device EXDEV, permissions, a stale lock) the response is {renamed: true, warnings: []} while config points at to and the workspace still sits at the from path. That is the same split-brain blocker 2 fixed for the DB stores, on the filesystem half.

Feed the move failure into the surfaced channel, e.g.:

let mut warnings = owned.warnings;
// in the Err(err) arm of the workspace rename above:
warnings.push(format!("workspace move {old_ws:?} -> {new_ws:?}: {err}"));
…
warnings,

A handler-level test that forces the move to fail (or asserts an owned-state warning round-trips into RenameMapKeyResponse.warnings) would lock both this and the response-shape contract, which currently ride only on /verify.

@singlerider singlerider added this to the v0.8.1 milestone Jun 17, 2026
@Nillth
Nillth force-pushed the feat/7468-rename-owned-state branch from f8978da to c0114ff Compare June 17, 2026 10:00
Nillth added a commit to NNet-Dev/zeroclaw that referenced this pull request Jun 17, 2026
…roclaw-labs#7468)

Review (zeroclaw-labs#7841, singlerider): rename_agent_cascade WARN-logged a failed workspace
move but never surfaced it β€” so config + owned DB state re-point to `to` while
the workspace stays stranded at `from`, returned as a clean
{renamed: true, warnings: []}. That is the same split-brain-as-clean-200 the
owned-store warnings fix already closed, on the filesystem half.

Extract move_renamed_workspace -> Option<String> (the warning on failure) and
merge it with owned.warnings into the response (and the INFO log). + a test
that forces the move to fail and asserts the surfaced warning.
@Nillth

Nillth commented Jun 17, 2026

Copy link
Copy Markdown
Collaborator Author

Thanks β€” fixed in 780a7a1.

You're right, the workspace move was the one partial failure that didn't surface. Extracted move_renamed_workspace, which returns the warning on a failed move, and merged it with owned.warnings into RenameMapKeyResponse.warnings (and the INFO log) β€” so a config/DB rename to to with the workspace stranded at from is now reported, not a clean 200. Added a test that forces the move to fail and asserts the surfaced warning.

@Nillth
Nillth requested a review from singlerider June 17, 2026 10:36
@Audacity88 Audacity88 added enhancement New feature or request risk: high labels Jun 17, 2026
@singlerider
singlerider marked this pull request as ready for review June 17, 2026 22:21
@singlerider
singlerider requested a review from Audacity88 as a code owner June 17, 2026 22:21
@singlerider
singlerider dismissed their stale review June 17, 2026 22:25

Addressed

@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-REVIEW at head 780a7a1. I re-checked my dismissed CHANGES_REQUESTED blocker against the current source. It's resolved. CI 17/17 green, MERGEABLE. This is slice 7 of the rename/delete cascade stack (sibling to #7838-#7842, all on v0.8.1); verdict is on the own delta β€” the surface half of rename (#7468).

βœ… RESOLVED β€” a failed workspace move during rename is now surfaced, not swallowed

My blocker is fixed. The workspace move is extracted into move_renamed_workspace(old_ws, new_ws) -> Option<String>, which returns the warning on failure (and still WARN-logs). rename_agent_cascade now merges that into the same surfaced channel as the DB stores:

let move_warning = move_renamed_workspace(&old_ws, &new_ws).await;
let mut warnings: Vec<String> = Vec::new();
warnings.extend(move_warning);
…
warnings.extend(owned.warnings);
…
axum::Json(RenameMapKeyResponse { …, renamed: true, warnings })

So a config + owned-DB rename to to with the workspace stranded at from (cross-device EXDEV, permission error, stale lock) now returns the warning instead of a clean {renamed: true, warnings: []}. This closes the split-brain-as-clean-200 on the filesystem half, consistent with the owned-store warnings fix it now matches. The new renamed_workspace_move_failure_is_surfaced test forces the move to fail (new_ws parent is a file) and asserts the warning surfaces, plus that the nothing-to-move paths (old_ws == new_ws, missing source) return None with no spurious warning β€” that locks both arms. The fix commit is scoped to api_config.rs alone, no creep.

🟒 The rest of the slice still reads clean on re-check

The findings I approved last round all hold on this head:

  • Workspace ordering β€” old_ws is captured before rename_with_cascade (while the entry still lives under from, so a custom workspace.path resolves), new_ws after; the old_ws == new_ws skip is correct since a custom path is alias-independent. The #7838 slice-4 ordering bug is not repeated.
  • Collision-safe rename_agent β€” the COUNT-check + orphan-drop + UPDATE under the connection lock (a transaction on postgres) closes the TOCTOU; refuses on a live target collision, reclaims a prior-delete orphan row otherwise; memory rides the stable agent_id UUID. Pinned by sqlite_rename_agent_reclaims_orphan_and_refuses_live_collision.
  • Routing + persistence β€” handle_rename_map_key routes agents β†’ full cascade, providers/channels β†’ config-only rename_config_cascade, non-aliased β†’ generic swap; 404 on missing source for aliased paths; every RenameReport.dirty_paths entry marked dirty before persist_and_swap. No-archive / no-live-session-refusal is the right contract for rename (a live ACP session follows the rename).

Blocker cleared, no new findings. Approving on the own delta; the stack's in-order merge discipline governs when it lands.

@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 re-reviewed slice 7 at head 780a7a1, the surface half of rename (#7468). My prior review (dismissed) carried one πŸ”΄ blocker: a failed workspace move was the single partial failure this slice swallowed, contradicting the surfaced-not-masked fix made for the owned DB stores in the same PR. That is now fixed, verified at head with the regression test green locally.

βœ… Resolved: the workspace-move failure is now surfaced, closing the filesystem-half split-brain

move_renamed_workspace (api_config.rs) returns Option<String>: None on success / no-op / missing source, and on a tokio::fs::rename error it WARN-logs and returns the failure as a warning string. rename_agent_cascade seeds its warnings vec with that move warning before extending it with owned.warnings, so the response carries every partial failure, not just the owned-DB ones:

let move_warning = move_renamed_workspace(&old_ws, &new_ws).await;
let mut warnings: Vec<String> = Vec::new();
warnings.extend(move_warning);
…
warnings.extend(owned.warnings);

A config + owned-DB re-point that succeeds while the workspace move fails (cross-device EXDEV, permissions, a stale lock) now returns {renamed: true, warnings: ["workspace move … failed: …"]} instead of a clean {renamed: true, warnings: []}, so the operator can remediate the stranded workspace rather than discovering it later. This is exactly the surfaced channel I asked for, applied to the filesystem half the same way blocker 2 applied it to the DB stores.

renamed_workspace_move_failure_is_surfaced pins it at the unit boundary: it forces the move to fail (new_ws parent is a file so create_dir_all cannot succeed), asserts a "workspace move" warning surfaces, asserts the source dir stays put, and asserts both no-op (old == new) and missing-source paths return None. That is the handler-level coverage this slice's rename_agent_cascade was previously missing. Green locally:

cargo test -p zeroclaw-gateway renamed_workspace_move_failure_is_surfaced
test api_config::tests::renamed_workspace_move_failure_is_surfaced ... ok

🟒 Everything I praised on the prior pass still holds

The slice-4 workspace-ordering lesson is applied correctly (old_ws captured before rename_with_cascade, new_ws after, the old_ws == new_ws skip right for alias-independent custom paths). Both self-found blockers (collision-safe rename_agent under the connection lock; owned-state warnings surfaced in the response) are fixed correctly. Owned-state rename methods and section routing are sound, the dirty-path persistence contract from slice 6 is consumed correctly, and the no-archive / no-live-session-refusal contract is the right deliberate contrast to delete.

The deferred RPC config/map-key-rename bare-swap remains a reasonable follow-up to call out.

Approving. This is a stacked draft-for-CI slice (7 of 8); the merge-strictly-in-order constraint behind #7837-#7840 stands as a merge-time gate, independent of this verdict. Milestone v0.8.1 is already set and matches the series.

Nillth added 2 commits June 18, 2026 10:39
…claw-labs#7468)

The surface half of rename (zeroclaw-labs#7468): wires rename_with_cascade (PR6, config
refs) into the gateway and re-points the agent's owned non-config state, so a
rename is complete end-to-end instead of a bare key swap that leaves
references dangling and persisted state stranded under the old alias.

handle_rename_map_key now routes by section:
- agents  -> rename_agent_cascade: rename_with_cascade (config-ref rewrite) +
  move the workspace dir (<install>/agents/<from>/workspace -> <to>/...; custom
  paths are alias-independent and skipped) + re-point owned DB state
  (cascade_rename_agent) + persist. In-place, no archive; unlike delete there
  is no live-session refusal (a live ACP session follows the rename).
- providers / channels -> rename_config_cascade: config-ref rewrite + persist.
- non-aliased sections -> the generic key-swap rename, unchanged.
- a missing source alias now returns 404 (NotFound) for the aliased paths;
  InvalidName/Reserved -> 400, PostCondition -> 500.

Persistence correctness: save_dirty only writes marked-dirty paths, and the
config-layer cascade never marks dirty. So the handler marks EVERY path
RenameReport.dirty_paths reports (the renamed entry's old+new key plus each
rewritten referrer at entry granularity) before persisting β€” otherwise a
cross-entry rewrite would be applied in memory but left stale on disk.

Owned-state rename methods (best-effort + reported, mirroring the delete
cascade; warnings surfaced, never masked):
- Memory::rename_agent (trait default bails) + impls: sqlite/postgres update
  the single agents.alias row (memory rows ride the UUID); lucid delegates to
  the local sqlite mirror; qdrant rewrites the agent_id payload on every
  matching point via set-payload-by-filter.
- cron::rename_jobs_by_agent, AcpSessionStore::rename_sessions_by_agent,
  SessionBackend::rename_agent_attribution (+ sqlite impl) β€” direct UPDATE of
  the agent_alias TEXT column.
- cascade_rename_agent coordinator (zeroclaw-gateway) runs them all from
  data_dir + returns a RenameStateReport with per-store counts + warnings.

Crates: zeroclaw-api + zeroclaw-memory (rename_agent x4 backends) Β·
zeroclaw-runtime (cron rename) Β· zeroclaw-infra (acp + session rename) Β·
zeroclaw-gateway (cascade_rename_agent + handler routing).

Tests: sqlite memory rename (rows re-point under the new alias, ride the
UUID), acp rename (live + killed sessions follow), session-metadata rename.
Full workspace builds; clippy --all-targets --all-features + fmt clean.

Review (adversarial, 4 lenses) β€” persistence/gateway/error = ship; 2 owned-state
blockers found + fixed:
- rename_agent (sqlite/postgres) is now COLLISION-SAFE. Delete purges memories
  but leaves the agents row (the UNIQUE alias slot), so renaming onto a
  previously-used-then-deleted alias hit the constraint and was swallowed as a
  warning while the config rename committed β†’ split-brain. Now: under the
  connection lock (a transaction for postgres), refuse if the target alias still
  owns memory rows, else drop the orphan row and proceed. + a test covering
  orphan-reclaim and live-collision-refusal.
- Owned-state warnings are now surfaced in RenameMapKeyResponse.warnings (and the
  INFO log), not just a server-side WARN β€” a partial owned-state failure is
  visible to the caller instead of a clean 200.
…roclaw-labs#7468)

Review (zeroclaw-labs#7841, singlerider): rename_agent_cascade WARN-logged a failed workspace
move but never surfaced it β€” so config + owned DB state re-point to `to` while
the workspace stays stranded at `from`, returned as a clean
{renamed: true, warnings: []}. That is the same split-brain-as-clean-200 the
owned-store warnings fix already closed, on the filesystem half.

Extract move_renamed_workspace -> Option<String> (the warning on failure) and
merge it with owned.warnings into the response (and the INFO log). + a test
that forces the move to fail and asserts the surfaced warning.
@Nillth
Nillth force-pushed the feat/7468-rename-owned-state branch from 780a7a1 to 29fb1de Compare June 18, 2026 00:45
@github-actions github-actions Bot removed the config Auto scope: src/config/** changed. label Jun 18, 2026
@Nillth
Nillth merged commit 2d37d54 into zeroclaw-labs:master Jun 18, 2026
18 checks passed

@Audacity88 Audacity88 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.

Reviewed current head 29fb1de after the rebase onto merged #7840. I checked the live PR state, prior review/comment history, the current api_config.rs / owned-state diff, and the store-level rename paths for memory, cron, ACP sessions, and session attribution. I did not run local Cargo for this review-only pass; current CI was still in progress during source review.

βœ… Resolved β€” Workspace move failures now surface to the caller

The prior blocker around swallowed workspace-move failures is fixed. move_renamed_workspace() now returns a warning when the filesystem move fails, and rename_agent_cascade() merges that warning with the owned-state warnings before building RenameMapKeyResponse. That closes the clean-200 case where config and DB state moved to the new alias while the workspace stayed behind with no caller-visible signal.

🟒 What looks good β€” The owned-state rename coverage is the right shape

The store-level rename additions are pointed at the right canonical state: SQLite/Postgres move the agents.alias row while memory rides the stable agent UUID, Qdrant rewrites the agent_id payload, cron/ACP/session metadata update their owning alias columns, and the Lucid backend delegates to the local SQLite mirror. The collision-safe SQL path that refuses to merge live memory under an existing target alias is also the right safety boundary.

πŸ”΄ Blocking β€” Agent rename moves owned state before config persistence can still split state

rename_agent_cascade() currently performs the irreversible/best-effort side effects before persist_and_swap():

let move_warning = move_renamed_workspace(&old_ws, &new_ws).await;
let owned = crate::agent_owned_state::cascade_rename_agent(...).await;
warnings.extend(owned.warnings);

if let Err(e) = persist_and_swap(state, working).await {
    return error_response(e);
}

That leaves the inverse split-brain still possible. If the workspace move and owned-store re-point succeed but persist_and_swap() fails, persist_and_swap() restores the previous config file snapshot and does not swap the in-memory config, while the workspace, memory alias row, cron jobs, ACP sessions, and session attribution may already be under to. The API returns an error response, not a RenameMapKeyResponse with warnings, so the caller is left with config still naming from and owned state already moved to to.

This matters because the PR's load-bearing contract is that rename either persists the config rewrite and reports partial owned-state failures, or refuses before mutating external state. Right now config durability is last, so a save failure after side effects breaks that contract.

Please persist the config rename before running the workspace move and owned-state cascade, then return warnings for any post-persist side-effect failures. If config persistence must remain last for some reason, the handler needs an equivalent rollback/compensation story for every side effect before returning an error, but persisting first matches the warning-based contract more directly.

Nillth added a commit that referenced this pull request Jun 19, 2026
Expose the alias CRUD that shipped server-side (#7840/#7841/#7842) in the
web config UI for agents/providers/channels.

- Rename: pencil action on alias rows -> inline edit -> renameMapKey
  (POST /api/config/rename-map-key rewrites every reference). Agent-rename
  warnings[] (owned stores that didn't follow) surface to the operator;
  also busts the command-palette search cache.
- Delete cascade preview: trash now fetches a dry-run before committing,
  showing HARD references that block the delete, SOFT references that get
  scrubbed, the owned-state note, and live ACP session count. Confirm only
  when allowed.
- New endpoint GET /api/config/delete-plan (read-only, require_auth-gated,
  returns no secret values) reuses alias_refs::plan_delete -- the same walk
  the real delete refuses on -- so the preview can't disagree with the delete.

Related #7175, #7468
Nillth added a commit to NNet-Dev/zeroclaw that referenced this pull request Jun 20, 2026
…w-labs#7907)

rename_agent_cascade (added by zeroclaw-labs#7841) moved external owned state β€” the
agent workspace dir and the memory/cron/acp/session stores β€” before the
config rename was durably persisted. A persist failure after those
side-effects left config still naming `from` while the workspace and
owned stores had moved to `to`: the inverse split-brain zeroclaw-labs#7841 fixed.

Reorder to persist the config first. On persist failure nothing has
moved, so config/workspace/owned-state stay consistently on `from` (a
clean abort). Only after a durable persist run the workspace move and
owned-state cascade, reading the committed config via a short-lived
clone (never a lock guard across the .await; the cascade needs only
data_dir, unchanged by the rename). A post-persist side-effect failure
keeps renamed=true and surfaces via the existing `warnings` β€” each step
is idempotently re-runnable from the corrected config, so re-issuing the
rename converges; no compensation saga. The completion log escalates
INFO->WARN when warnings are present so a degraded outcome is visible.

Adds two regression tests (cron-DB probe): one forces a persist failure
and asserts owned state is not moved (verified to fail under the pre-fix
ordering), one asserts the move happens after a successful persist.

Scope: the rename path only. The mirror sibling delete_agent_cascade has
the same defect and is filed separately.

Closes zeroclaw-labs#7907
wangmiao0668000666 added a commit to wangmiao0668000666/zeroclaw that referenced this pull request Jun 21, 2026
…w-labs#7907)

rename_agent_cascade (added by zeroclaw-labs#7841) moved external owned state β€” the
agent workspace dir and the memory/cron/acp/session stores β€” before the
config rename was durably persisted. A persist failure after those
side-effects left config still naming `from` while the workspace and
owned stores had moved to `to`: the inverse split-brain zeroclaw-labs#7841 fixed.

Reorder to persist the config first. On persist failure nothing has
moved, so config/workspace/owned-state stay consistently on `from` (a
clean abort). Only after a durable persist run the workspace move and
owned-state cascade, reading the committed config via a short-lived
clone (never a lock guard across the .await; the cascade needs only
data_dir, unchanged by the rename). A post-persist side-effect failure
keeps renamed=true and surfaces via the existing `warnings` β€” each step
is idempotently re-runnable from the corrected config, so re-issuing the
rename converges; no compensation saga. The completion log escalates
INFO->WARN when warnings are present so a degraded outcome is visible.

Adds two regression tests (cron-DB probe): one forces a persist failure
and asserts owned state is not moved (verified to fail under the pre-fix
ordering), one asserts the move happens after a successful persist.

Scope: the rename path only. The mirror sibling delete_agent_cascade has
the same defect and is filed separately.

Closes zeroclaw-labs#7907
Nillth added a commit that referenced this pull request Jun 21, 2026
* fix(gateway): persist agent rename before moving owned state (#7907)

rename_agent_cascade (added by #7841) moved external owned state β€” the
agent workspace dir and the memory/cron/acp/session stores β€” before the
config rename was durably persisted. A persist failure after those
side-effects left config still naming `from` while the workspace and
owned stores had moved to `to`: the inverse split-brain #7841 fixed.

Reorder to persist the config first. On persist failure nothing has
moved, so config/workspace/owned-state stay consistently on `from` (a
clean abort). Only after a durable persist run the workspace move and
owned-state cascade, reading the committed config via a short-lived
clone (never a lock guard across the .await; the cascade needs only
data_dir, unchanged by the rename). A post-persist side-effect failure
keeps renamed=true and surfaces via the existing `warnings` β€” each step
is idempotently re-runnable from the corrected config, so re-issuing the
rename converges; no compensation saga. The completion log escalates
INFO->WARN when warnings are present so a degraded outcome is visible.

Adds two regression tests (cron-DB probe): one forces a persist failure
and asserts owned state is not moved (verified to fail under the pre-fix
ordering), one asserts the move happens after a successful persist.

Scope: the rename path only. The mirror sibling delete_agent_cascade has
the same defect and is filed separately.

Closes #7907

* fix(gateway): make agent-rename recovery converge on re-issue

#7940 review (Audacity88, WareWolf-MoonWall, singlerider): the persist-first
reorder fixed the clean-abort case, but the documented "re-issue the rename to
converge" recovery could not actually run. Once persist commits config to `to`,
a re-issued `from -> to` reached rename_with_cascade and was rejected (the `to`
key already exists -> InvalidName collision; or `from` absent -> NotFound), so
in the post-persist partial-failure window the operator had no working recovery
command and the WARN guidance silently failed at the API layer.

Make rename_agent_cascade resume-aware: when the committed config already names
`to` and no longer has `from`, skip the already-done config rewrite + persist and
re-run only the idempotent side-effects (the workspace move early-returns once
the source is gone; the owned-store cascade re-points by WHERE agent_alias=from).
Re-issuing the same rename now converges instead of failing β€” the recovery
contract the handler documents.

Add agent_rename_resume_converges_when_config_already_to: seeds the post-persist
lag state (config = `to`; cron row + workspace still at `from`), re-issues the
rename, and asserts the side-effects re-run (no 404) and converge onto `to`.

* fix(gateway): request-correlate agent-rename resume to lagging residue

The #7940 resume guard treated any committed-`to` config shape
(`agent(from).is_none() && agent(to).is_some()`) as a partial-failure
resume. An unrelated `X -> to` where `to` already exists and `X` is
absent from config matched the same shape and was silently swallowed as
a resume - returning 2xx and running no-op side-effects instead of
surfacing the operator's error.

Gate the resume on actual lagging side-effect residue under `from` via a
new read-only `rename_residue_exists`, the exact fingerprint a genuine
partial failure leaves. It probes every store the side-effects re-point
(the read-only twin of each mutation in move_renamed_workspace +
cascade_rename_agent): the default per-alias workspace dir, cron jobs,
ACP sessions (live OR killed - rename re-points both), memory, and
session-metadata attribution. With committed-`to` but no residue the
normal branch runs and returns NotFound/collision, surfacing the error.

Add two minimal read-only APIs the probe needs, mirroring exactly what
the corresponding rename moves (not a memory/live-only count, which would
be a false negative that breaks a real resume):
- Memory::count_agent (SQL: the agents row; qdrant: payload points;
  lucid delegates; markdown/none default 0)
- SessionBackend::count_agent_attribution (sqlite override; else 0)

Test: keep agent_rename_resume_converges_when_config_already_to (genuine
resume with residue still converges); add
agent_rename_unrelated_collision_is_not_treated_as_resume (committed-`to`
with no residue surfaces an error, not a silent success).
ZOOWH pushed a commit to ZOOWH/zeroclaw that referenced this pull request Jun 22, 2026
…w-labs#7940)

* fix(gateway): persist agent rename before moving owned state (zeroclaw-labs#7907)

rename_agent_cascade (added by zeroclaw-labs#7841) moved external owned state β€” the
agent workspace dir and the memory/cron/acp/session stores β€” before the
config rename was durably persisted. A persist failure after those
side-effects left config still naming `from` while the workspace and
owned stores had moved to `to`: the inverse split-brain zeroclaw-labs#7841 fixed.

Reorder to persist the config first. On persist failure nothing has
moved, so config/workspace/owned-state stay consistently on `from` (a
clean abort). Only after a durable persist run the workspace move and
owned-state cascade, reading the committed config via a short-lived
clone (never a lock guard across the .await; the cascade needs only
data_dir, unchanged by the rename). A post-persist side-effect failure
keeps renamed=true and surfaces via the existing `warnings` β€” each step
is idempotently re-runnable from the corrected config, so re-issuing the
rename converges; no compensation saga. The completion log escalates
INFO->WARN when warnings are present so a degraded outcome is visible.

Adds two regression tests (cron-DB probe): one forces a persist failure
and asserts owned state is not moved (verified to fail under the pre-fix
ordering), one asserts the move happens after a successful persist.

Scope: the rename path only. The mirror sibling delete_agent_cascade has
the same defect and is filed separately.

Closes zeroclaw-labs#7907

* fix(gateway): make agent-rename recovery converge on re-issue

zeroclaw-labs#7940 review (Audacity88, WareWolf-MoonWall, singlerider): the persist-first
reorder fixed the clean-abort case, but the documented "re-issue the rename to
converge" recovery could not actually run. Once persist commits config to `to`,
a re-issued `from -> to` reached rename_with_cascade and was rejected (the `to`
key already exists -> InvalidName collision; or `from` absent -> NotFound), so
in the post-persist partial-failure window the operator had no working recovery
command and the WARN guidance silently failed at the API layer.

Make rename_agent_cascade resume-aware: when the committed config already names
`to` and no longer has `from`, skip the already-done config rewrite + persist and
re-run only the idempotent side-effects (the workspace move early-returns once
the source is gone; the owned-store cascade re-points by WHERE agent_alias=from).
Re-issuing the same rename now converges instead of failing β€” the recovery
contract the handler documents.

Add agent_rename_resume_converges_when_config_already_to: seeds the post-persist
lag state (config = `to`; cron row + workspace still at `from`), re-issues the
rename, and asserts the side-effects re-run (no 404) and converge onto `to`.

* fix(gateway): request-correlate agent-rename resume to lagging residue

The zeroclaw-labs#7940 resume guard treated any committed-`to` config shape
(`agent(from).is_none() && agent(to).is_some()`) as a partial-failure
resume. An unrelated `X -> to` where `to` already exists and `X` is
absent from config matched the same shape and was silently swallowed as
a resume - returning 2xx and running no-op side-effects instead of
surfacing the operator's error.

Gate the resume on actual lagging side-effect residue under `from` via a
new read-only `rename_residue_exists`, the exact fingerprint a genuine
partial failure leaves. It probes every store the side-effects re-point
(the read-only twin of each mutation in move_renamed_workspace +
cascade_rename_agent): the default per-alias workspace dir, cron jobs,
ACP sessions (live OR killed - rename re-points both), memory, and
session-metadata attribution. With committed-`to` but no residue the
normal branch runs and returns NotFound/collision, surfacing the error.

Add two minimal read-only APIs the probe needs, mirroring exactly what
the corresponding rename moves (not a memory/live-only count, which would
be a false negative that breaks a real resume):
- Memory::count_agent (SQL: the agents row; qdrant: payload points;
  lucid delegates; markdown/none default 0)
- SessionBackend::count_agent_attribution (sqlite override; else 0)

Test: keep agent_rename_resume_converges_when_config_already_to (genuine
resume with residue still converges); add
agent_rename_unrelated_collision_is_not_treated_as_resume (committed-`to`
with no residue surfaces an error, not a silent success).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

cron Auto scope: src/cron/** changed. enhancement New feature or request gateway Auto scope: src/gateway/** changed. memory Auto scope: src/memory/** changed. runtime Auto scope: src/runtime/** changed.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants