feat(gateway): agent owned-state rename cascade + rename wiring (#7468) - #7841
Conversation
c43db4b to
f8978da
Compare
singlerider
left a comment
There was a problem hiding this comment.
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 orphanagentsrow left behind by a prior delete otherwise. Memory rides the stableagent_idUUID so only thealiascolumn moves.sqlite_rename_agent_reclaims_orphan_and_refuses_live_collisionpins 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.warningsnow carriesowned.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 singleagents.aliasupdate, lucid delegates, qdrant rewrites theagent_idpayload),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_keyroutes by section (agents β full cascade, providers/channels β config-onlyrename_config_cascade, non-aliased β generic swap), returns 404 for a missing source on aliased paths, and marks everyRenameReport.dirty_pathsentry 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, |
There was a problem hiding this comment.
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.
f8978da to
c0114ff
Compare
β¦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.
|
Thanks β fixed in 780a7a1. You're right, the workspace move was the one partial failure that didn't surface. Extracted |
singlerider
left a comment
There was a problem hiding this comment.
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_wsis captured beforerename_with_cascade(while the entry still lives underfrom, so a customworkspace.pathresolves),new_wsafter; theold_ws == new_wsskip 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 stableagent_idUUID. Pinned bysqlite_rename_agent_reclaims_orphan_and_refuses_live_collision. - Routing + persistence β
handle_rename_map_keyroutes agents β full cascade, providers/channels β config-onlyrename_config_cascade, non-aliased β generic swap; 404 on missing source for aliased paths; everyRenameReport.dirty_pathsentry marked dirty beforepersist_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
left a comment
There was a problem hiding this comment.
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.
β¦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.
780a7a1 to
29fb1de
Compare
Audacity88
left a comment
There was a problem hiding this comment.
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.
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
β¦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
β¦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
* 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).
β¦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).
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_cascadeinto the gateway and re-points the agent's owned non-config state. TodayPOST /api/config/rename-map-keyfor 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_keyroutes 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.save_dirtywrites only marked-dirty paths, and the config-layer cascade never marks dirty β so the handler marks every path inRenameReport.dirty_paths(the renamed entry's old+new key + each rewritten referrer, at entry granularity) before persisting./verifyconfirmed all 9 referrer sites rewritten in the on-disk file.Memory::rename_agent(sqlite/postgres update the singleagents.aliasrow β memory rides the UUID; lucid delegates; qdrant rewrites theagent_idpayload),cron::rename_jobs_by_agent,AcpSessionStore::rename_sessions_by_agent,SessionBackend::rename_agent_attribution; acascade_rename_agentcoordinator runs them fromdata_dir.Review: 2 blockers found + fixed
rename_agentUNIQUE(alias) collision silently orphaned memory. Delete purges memories but leaves theagentsrow (the UNIQUE alias slot), so renaming onto a previously-used-then-deleted alias hit the constraint β theErrwas swallowed as a best-effort warning while the config rename committed β split-brain returned as a clean 200. Fixed:rename_agentis 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).{renamed:true}with the warning only in a server log. Fixed:RenameMapKeyResponse.warningsnow carriesowned.warnings(omitted when empty).Validation
cargo clippy --workspace --all-targets+cargo fmt --all --checkclean 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 nowsurvivor,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-pointagent_alias/aliascolumns. 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
warningsis additive (omitted when empty).Rollback (risk: medium) β
git revert; no migration; rename is idempotent/re-runnable.Deferred
config/map-key-renamestill does the bare key-swap (a pre-existing gap); routing it throughrename_with_cascadeis a follow-up.rename_agentbail surfaces as a warning.Related #7468.