fix(volumes): rename-safe volume identity — sweep, purge, create and recovery (#1664, #1665, #1667)#1666
Conversation
…'s live home volume (#1664) Rename keeps the agent's Docker data volumes — Docker can rename neither a volume nor its immutable `trinity.agent-name` label — so after a rename both name and label permanently describe an agent that no longer exists. The #1581 orphan sweep read that self-description as ownership truth, found no agent by that name, and force-removed what is actually the LIVE agent's `/home/developer`. Only Docker's in-use 409 stood in the way; during any container recreate (guardrails / resources / shared-folder change, operator rebuild) the volume is briefly unattached, and a sweep landing in that window silently and unrecoverably destroyed the agent's #1169 `data_paths` data. Ownership is now a DB question, not a volume-label question: - `agent_ownership.volume_base_name` (NULL = `agent_name`, so no backfill) pins the volume identity, written in the same statement as the rename and frozen across re-renames. `db.is_volume_base_reserved` replaces `is_agent_name_reserved` as the sweep's orphan predicate; soft-deleted rows still match, so the recovery window holds for renamed agents too. - The purge path resolves the base BEFORE deleting the row that holds it, so a renamed agent's real volumes are the ones reclaimed (they leaked before). - Agents renamed before the column existed are healed at boot from Docker's own mount table (`_heal_renamed_volume_bases`, one-shot, idempotent, ahead of the startup sweep) — the pin can never overwrite an existing one. Two further fail-safe gates, since this sweep force-removes durable user data (#1638: cleanup must fail safe): - A volume mounted by ANY container is skipped. Fail-closed: if the mount table can't be read, the whole cycle is skipped rather than reclaiming blind. - A candidate must be seen unattached for 3 consecutive cycles (~15 min) — the recreate gap is seconds, so one unattached sighting is not evidence of an orphan. Any attached sighting resets the streak. Verified against a real Docker daemon (four scenarios): the pre-#1664 ownership answer destroys the volume; with the pin it survives 8 unattached cycles; a mounted-but-unowned volume survives; a genuine orphan is still reclaimed, but only after the streak. Dual-track migration per invariant #3 (SQLite `agent_ownership_volume_base_name` + Alembic 0024). 23 new tests; full unit suite green. Related to #1664 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ger facade `_sweep_orphan_agent_volumes`, the purge path and the boot heal all call `db.is_volume_base_reserved` / `get_volume_base_name` / `set_volume_base_name`, but the facade had no pass-throughs — every one would have raised AttributeError at runtime, and the sweep swallows exceptions per-cycle, so the reclaim would have silently degraded to a no-op forever. Caught by the `test_database_facade_delegation` guard (the unit tests mock `db`, so they cannot see a facade gap). Related to #1664 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… the pin (#1664) Self-review catch: my own predicate had the same data-loss shape it was meant to close, one layer over. `get_public_volume_name` / `get_shared_volume_name` name off the LIVE agent name, so a renamed agent creates `agent-{new}-public` while its workspace keeps `agent-{old}-workspace` — it legitimately owns volumes under two bases. The predicate matched only the pin, so `is_volume_base_reserved("new-name")` was False for a live agent, and the public volume — which is unmounted whenever file-sharing is toggled off, defeating the attached-check — was reclaimed after the 3-cycle streak. Verified against a real daemon: the live agent's public volume was deleted. Ownership is now the UNION of both identities (agent_name OR volume_base_name), which is also strictly safer than the pre-#1664 predicate it replaces (`is_agent_name_reserved` is exactly the first branch), so it can only protect more, never less. A purged row matches neither, so genuine orphans are still reclaimed — re-verified end-to-end. The purge path had the mirror gap: removing only the pinned base leaked `agent-{new}-public` forever. It now removes both (deduped, so an un-renamed agent is one call as before). Related to #1664 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…g to another (#1664) Rename frees the NAME while the agent keeps its volumes under the old base (Docker can rename neither a volume nor its immutable label). `crud.py`'s volume block is get-then-create — an existing volume is REUSED, not rejected — so a new agent created under the freed name silently booted on the renamed agent's `/home/developer`: its `.env`, its `.credentials.enc`, its workspace, with both containers writing the same disk. The two owners need not be the same person, so this is a cross-tenant credential disclosure, not just corruption. Verified against a real daemon before the fix: a second agent created under the freed name read the first agent's ANTHROPIC_API_KEY out of its `.env`. The create gate now also consults `db.is_volume_base_reserved` (the #1664 primitive) and 409s before any container or volume work. Partial: this closes the rename-driven case, where a live row still claims the base. A volume orphaned by a failed/pending reclaim has no row at all, so the predicate says "free" and the get-then-create reuse still applies — the create path needs to refuse-or-adopt an existing volume explicitly. Filed separately. Related to #1664 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…1664) Second self-review catch, and a regression from this branch's own both-bases purge. `volume_base_name` carries no unique constraint, and installs predating the create-time gate can hold a collision the gate now prevents: agent `new` pinned to base `old`, PLUS a live agent literally named `old` — both on the same volumes (that shared-volume state is exactly the bug the create gate closes, so it exists in the field wherever a freed name was reused). Purging `new` resolved base `old` and removed `agent-old-*` — the LIVE `old` agent's home volume. Before the both-bases change, purge touched only `agent-new-*`, so this branch widened the blast radius rather than inheriting it. The purged row is already gone by then, so a still-True `is_volume_base_reserved` means a DIFFERENT row claims the base: its data is live and not ours to drop. Skip it (WARNING) and let the orphan sweep reclaim it if it ever goes unowned. Verified against a real DB + daemon: the purge completes, removes 0 volumes, and the live agent's volume survives. Also drops the now-dead `and_` import the union-predicate edit left behind. Related to #1664 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Resolve by running |
…fork tests CI regression-diff caught the create gate 409ing all 9 test_fork_to_own tests: that module mocks the whole `database` module, and an unstubbed MagicMock attribute is truthy — so the new `db.is_volume_base_reserved` call read as "base taken" and refused every create in the module. Test-harness gap, not a production one: fork destinations, deploy pre-populate (#950) and versioned redeploy all use fresh names with no owning row. Stubbed explicitly, with the trap noted for the next person to add a db-gated check. Also exempts ephemeral agents from the gate. Ghosts are volume-less by construction (the volume block is `if not config.ephemeral`), so there is nothing for them to collide with, and gating them put a DB read on the burst-spawn path for no reason. Related to #1664 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
| # | Found by | Finding | Status |
|---|---|---|---|
| C1 | self-review | Ownership predicate ignored a renamed agent's second volume base — get_public_volume_name/get_shared_volume_name name off the live name, so agent-{new}-public exists alongside the old-based workspace. Matching only the pin marked a live agent's public volume an orphan, and that volume is unmounted whenever file-sharing is off, defeating the attached-check. Verified on a real daemon: it deleted a live agent's public volume — the same data-loss shape this PR fixes, one layer up. |
fixed ee75479b — predicate is now the union of both identities, a strict superset of the pre-#1664 is_agent_name_reserved (that predicate is exactly the union's first branch), so it can only protect more, never less |
| C2 | review of surrounding code | Cross-tenant credential disclosure (pre-existing on dev): rename frees the name while the volume lives on, and crud.py provisions get-then-create — an existing volume is silently reused. An agent created under the freed name booted on the renamed agent's /home/developer. Verified on a real daemon: the new agent read agent A's ANTHROPIC_API_KEY out of its .env, and the two owners need not be the same person. |
gated c4728b3a (maintainer's call to fix here) — create now consults is_volume_base_reserved and 409s before any container/volume work. Remainder (orphaned volume, no row → predicate says "free" → still adopted) filed as #1667 |
| C3 | re-review | The both-bases purge from C1 could delete a different live agent's volumes. volume_base_name has no unique constraint, and installs predating C2's gate can hold the collision that gate now prevents (agent new pinned to base old plus a live agent named old, sharing volumes). Purging new removed agent-old-*. Before the both-bases change purge touched only agent-new-*, so this branch widened the blast radius rather than inheriting it. |
fixed b6f7d562 — the purged row is gone by then, so a still-True is_volume_base_reserved means another row claims the base: skip + WARNING. Verified on a real DB+daemon: purge completes, removed 0 data volumes, live volume survives |
| C4 | CI regression-diff | The C2 gate 409'd all 9 test_fork_to_own tests: that module mocks the whole database module, and an unstubbed MagicMock attribute is truthy, so the new call read as "base taken" and refused every create. Test-harness gap, not production (fork destinations, deploy.py pre-populate #950 and versioned redeploy all use fresh names with no owning row). |
fixed — stub added; audited all modules with the same wholesale-db mock and every caller of create_agent_internal, all green. Ghosts also exempted from the gate (volume-less by construction) |
Informational (not blocking)
- N+1: one
is_volume_base_reservedper volume per cycle (~3/agent/5 min). Same shape as the code it replaces — not a regression; anINquery would fix it if fleets grow. - Strike map is per-process: with
--workers 2each worker needs its own 3-cycle streak. Independently safe (both must agree), just slower to reclaim. - Both
docker_utilslist calls are deliberately unfiltered — an unrecognised container's mount still counts as in-use. Slightly heavier on busy hosts; the fail-safe is the point.
Clean
SQL injection (all SQLAlchemy expression-level) · auth boundaries (no new endpoints) · credential exposure in logs (names only) · bare excepts (none; broad catches documented per-sweep) · enum completeness (none added) · docs (architecture.md updated for schema + predicate; learnings ledger entry added).
The durable lesson (now in docs/memory/learnings.md)
A resource's self-description is not its ownership, and renames are what prove it. The pre-existing "double-guard" (volume name AND label must agree) could never catch this class: after a rename both halves are equally, consistently stale — a self-consistent lie. Ownership has to live in a row we control.
The corollary this PR learned the hard way, three times: mocking db makes every new db.* call invisible. It hid a facade gap that would have AttributeError'd in production and — because the sweep swallows per-cycle exceptions — silently degraded the reclaim to a permanent no-op; it hid C4; and mock-based sweep tests were happy through C1. Only the real-DB/real-Docker runs and CI's base-vs-head diff caught them. A -k subset is a guess about blast radius: C4 slipped through locally because my filter didn't match the filename.
Test state (honest accounting)
CI green: 19/19 checks, and the regression-diff (base vs head, 3 seeds each) reports no new failures introduced by HEAD — that job is what caught C4 and the earlier facade gap, so it is load-bearing here, not decoration.
The local full unit suite is 4135 passed, 7 failed. None are attributable to this branch, and rather than assert that, I checked each: 5 reproduce on a clean origin/dev worktree (test_admin_email_login ×3, Europe/Kiev tzdata missing locally, test_1474_read_boundary_z::test_schedules_summary_last_run_at_normalized); the other 2 (test_1081_physical_meter, sqlite) are order-dependent — they pass in isolation on both branches, and pairing them directly with this PR's new test file passes, so the new real-DB harness usage is not the polluter. CI's seeded base-vs-head runs agree.
Recommendation
Merge-ready pending human review. What's left is judgment rather than mechanics, and worth a reviewer's opinion: (1) is a 3-cycle (~15 min) unattached streak the right conservatism for a backstop sweep? (2) should the create gate 409, or offer an explicit adopt_existing_volume? (#1667 has the options written up.)
…ume (#1665) Rename keeps the agent's volumes under the pre-rename base (Docker can rename neither a volume nor its immutable label), so for a renamed agent the CURRENT name names no volume. `recreate_missing_container` (#1559 soft-delete recovery) f-stringed that name — and `containers.run` CREATES a missing named volume rather than failing, so recovery silently rebuilt the agent on an empty `/home/developer` while its real data (incl. #1169 `data_paths`) sat unreferenced under the old base. Silent, not loud: the agent comes back looking healthy, just empty. Verified against a real DB + daemon, before: recovery mounts `agent-{new}-workspace` -> "<volume does not exist -> docker CREATES it empty>". After: mounts `agent-{old}-workspace` -> the agent's real data. Three sites, one rule — resolve through the ownership row, never the name: - `recreate_missing_container`: the data-loss one, now via the new `_workspace_volume_name` (fail-safe: a DB error falls back to the agent name, the pre-#1665 behavior and correct for every un-renamed agent). - `_read_template_yaml_from_volume`: same trap one level down — it read nothing for a renamed agent, so recovery rebuilt on DEFAULT agent-type/runtime instead of the committed ones. Now recovers `researcher`/`codex` correctly. - `recreate_container_with_updated_config` carried a DEAD `agent_volume_name = f"agent-{agent_name}-workspace"` assignment that read as though it were the mount in use (it isn't — mounts are carried forward from the old container, which is what keeps a renamed agent on its volume across a recreate). Removed and replaced with a note, since a live-looking wrong name is how this bug class spreads. `deploy.py`'s `agent-{version_name}-workspace` is correct by construction (a fresh name, no row, no rename in its past) and is annotated as audited rather than changed. Also stubs `get_volume_base_name` in the #1559 recreate test: it mocks the whole database module, and an unstubbed MagicMock attribute is truthy — it would be f-stringed straight into the volume name (same trap that broke test_fork_to_own in #1666). Related to #1665 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…not a fallthrough (#1667) The create path provisioned the home volume with get-then-create and no branch: an existing `agent-{name}-workspace` was silently mounted as the new agent's `/home/developer`. Whatever the previous holder of that name left behind — its `.env`, its `.credentials.enc`, its workspace — resurfaced inside a different agent, possibly a different owner's. Verified against a real daemon in #1666: the new agent read the old one's ANTHROPIC_API_KEY. #1664's gate covers a volume some ROW still claims (the rename case). This covers the volume NOTHING claims: a purge whose removal hit an in-use 409 and was skipped, a crash between `volume_create` and the ownership INSERT (creation writes the volume first — the reason the orphan sweep carries a 1h creation grace), or a restored backup. Emptiness cannot be the discriminator — the fact that reshaped this fix: deploy-local (#950), the ONE legitimate adopter, PRE-POPULATES the volume with the template before calling create, so a valid adopt is non-empty. (Docker also auto-populates a named volume from the image on first mount, so "empty" would not even identify a crashed create.) So the adopter declares itself: `adopt_existing_workspace` — a function kwarg, mirroring `skip_name_sanitization`, deliberately NOT a field on AgentConfig, where a caller could set it and re-open the disclosure. deploy passes True; everyone else gets a 409 naming the volume and how to clear it. An adopt is also logged now, so it is never silent again. Raised BEFORE the docker try-block: that block catches Exception, rolls back and re-raises as a generic 500, so a 409 inside it would lose both its status and its message (the same reason fork_to_own raises its destination 409 before the block). Nothing is built at that point, so there is nothing to roll back either. Fail-open on a probe error, and ghosts skip it (volume-less by construction). Unaffected by design: `system_agent_service` builds its recycled-name container directly rather than through `create_agent_internal`; the Cornelius seeder is first-run-only behind a durable flag. Also fixes the fork test harness, which stubbed `volume_get` as a bare AsyncMock — i.e. "yes, that volume exists" to every probe, the opposite of reality for the fresh names those tests create. NotFound is the truthful default (raised as the harness's own `_NotFound`, since it rebinds `docker.errors.NotFound`). Related to #1667 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
obasilakis
left a comment
There was a problem hiding this comment.
Reviewed the full diff against the merge-base (68699cf9); all 48 tests pass locally (test_1664_* + test_1581_*). No blocking findings — the identity model is right, the sweep's three fail-closed conditions are well-chosen, the dual-track migration is complete (SQLite + Alembic 0024 + schema.py + tables.py + facade), and the learnings entry is a genuinely good one.
Two non-blocking notes, one filed separately:
1. Rename is the one producer of volume_base_name left ungated → filed as #1671 (P1), not a merge blocker.
is_volume_base_reserved has exactly two enforcement call-sites: crud.py:298 (create) and the purge/sweep. Rename checks only get_agent_container(sanitized_name) and agent_name == new_name, so rename → a freed base is still permitted, and the collision your purge-path comment anticipates ("installs predating the create-time gate … can hold a collision") can be minted fresh post-fix by an ordinary swap: rename prod-agent → prod-agent-old, then staging-agent → prod-agent. Since get_public_volume_name names off the live name (deliberately, per your is_volume_base_reserved docstring), the new prod-agent then get-then-creates onto the old agent's agent-prod-agent-public — the #1667 class through the ungated path. The purge handling stays correctly fail-safe (skips both bases rather than destroying), so this is a prevention gap, not a safety one — hence the follow-up rather than a change request here. Full repro + suggested fix in #1671.
2. Docstring points at a function that isn't there — db/migrations.py:2895:
those are healed at boot by
heal_renamed_volume_bases(db/agents.py)
It's _heal_renamed_volume_bases in services/cleanup_service.py:1758; db/agents.py has no such function. Worth correcting on your next push — the migration docstring is exactly where someone goes to understand why the backfill is a no-op.
Nice work on this one. The is_reclaimable_agent_volume docstring change in particular — explicitly demoting it from "safety" to "the caller aimed correctly, not evidence the data is dead" — is the sort of thing that stops the next person trusting a guard for something it never proved.
… at both producers (#1671) Review catch (@obasilakis on #1666): #1664 established that one ownership row claims one volume base and gated CREATE on it, but rename is the other producer of `volume_base_name` and was left ungated. An ordinary ops swap mints the collision the create gate refuses: rename `prod` -> `prod-old` (row keeps pin `prod`) rename `staging` -> `prod` (name free, base NOT free) <- was allowed Two rows then claim base `prod`. `get_public_volume_name` names off the LIVE name (deliberate, per the is_volume_base_reserved docstring), so the new `prod` get-then-creates onto the old agent's `agent-prod-public` — the #1667 silent-adopt disclosure through the ungated path. It also strands both bases forever: with two claimants the purge guard skips them and the orphan sweep never reclaims them, so this incompleteness weakened THIS PR's own guard, which assumes a single claimant. Gated at both layers, per the reviewer's stronger suggestion and the #1445 pattern: `db.rename_agent` re-checks inside its transaction (the chokepoint — closes the check-then-write gap and covers future callers), and the router gates before the container is stopped/renamed so a refusal leaves nothing half-done and returns an actionable 409 rather than that path's generic 500. `is_volume_base_reserved` grows `exclude_agent`: "does anyone ELSE claim this base?". The reviewer's suggested one-liner would have refused a LEGITIMATE rename-back — an agent renamed `B`->`A` keeps pin `B`, so renaming it to `B` must be allowed (it owns that base; the result has one claimant), and the naive gate told the owner their own volumes belonged to someone else. Verified against a real DB: the swap is refused, exactly one row claims the base afterwards, and `B`->`A`->`B` still works. Also fixes the migration docstring the review flagged: the boot heal is `CleanupService._heal_renamed_volume_bases` (services/cleanup_service.py), not `heal_renamed_volume_bases` (db/agents.py) — that docstring is exactly where someone goes to understand why the backfill is a no-op. Closes #1671 Related to #1664 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Both notes addressed, and I took the #1671 gap into this PR rather than leaving it as a follow-up. Thank you for the review — the rename analysis in particular. 1. #1671 — rename gated (
|
obasilakis
left a comment
There was a problem hiding this comment.
Approved. Both review notes verified in 74f4a9a7, not just claimed:
Docstring — db/migrations.py:2892 now points at CleanupService._heal_renamed_volume_bases (services/cleanup_service.py). Correct.
#1671 rename gate — taken into this PR and enforced at both producers: the chokepoint inside db.rename_agent's transaction (closes the check-then-write gap, covers future callers) and a router gate at agent_rename.py:118, which fires before container_stop at 139 — a refusal leaves nothing half-done and returns an actionable 409 instead of the generic 500: Failed to update database.
Your correction to my suggested fix is right and I'm glad you pushed back. My one-liner would have refused a legitimate rename-back (B->A->B): the agent's own pin makes is_volume_base_reserved true, so the gate would refuse the owner their own volumes and tell them the base belongs to someone else. exclude_agent — "does anyone else claim this base?" — is the right predicate, and the chokepoint expresses it directly in SQL (volume_base_name == new_name AND agent_name != old_name).
And your reason for treating it as a blocker is sharper than my reason for filing it as a follow-up. I read it as prevention-vs-safety; you spotted that two claimants on one base leave is_volume_base_reserved true after either row is purged, so the guard skips both bases and the sweep never reclaims them — unreachable, undeletable volumes. This PR's own guard assumed a single claimant, so enforcing the invariant at only one of its two producers would have rested it on something the code didn't guarantee. Right call.
Agreed on checked-not-structural: the claim is agent_name OR volume_base_name, which no single-column partial index expresses, and materialising the base into one column would trade away the zero-backfill property #1664 was built around. Worth revisiting only if a third producer appears.
The nightly bot's conflict warning is stale — confirmed dev (68699cf9) is an ancestor and GitHub reports MERGEABLE. Flagging it rather than silently 'fixing' a phantom was the right instinct.
19/19 checks green on 74f4a9a7. Merging.
…ead (ent#157) The rebase surfaced a collision the SQLite track absorbed silently and the PostgreSQL track could not: #1666 landed `0024_agent_ownership_volume_base_name` on dev while this branch carried `0024_agent_ownership_a2a_exposed`, both chained to `0023`. Two revisions, one parent — a branched history, so `alembic upgrade head` has multiple heads and the pg-migrations job fails at the real PG boot path. The two tracks fail differently, which is worth remembering: `db/migrations.py` is a LIST, so its conflict was a union and both entries just run. Alembic is a LINKED LIST, so the same "both sides added a migration" merges cleanly at the text level and breaks at runtime. A green SQLite parity check says nothing about the chain. Renamed to `0025_agent_ownership_a2a_exposed` with `down_revision = 0024_agent_ownership_volume_base_name`. Verified by walking the graph: 26 revisions, exactly one head, no parent with more than one child, no dangling down_revision, and an unbroken chain head→`0001_baseline`. Related to trinity-enterprise#157
…ead (ent#157) The rebase surfaced a collision the SQLite track absorbed silently and the PostgreSQL track could not: #1666 landed `0024_agent_ownership_volume_base_name` on dev while this branch carried `0024_agent_ownership_a2a_exposed`, both chained to `0023`. Two revisions, one parent — a branched history, so `alembic upgrade head` has multiple heads and the pg-migrations job fails at the real PG boot path. The two tracks fail differently, which is worth remembering: `db/migrations.py` is a LIST, so its conflict was a union and both entries just run. Alembic is a LINKED LIST, so the same "both sides added a migration" merges cleanly at the text level and breaks at runtime. A green SQLite parity check says nothing about the chain. Renamed to `0025_agent_ownership_a2a_exposed` with `down_revision = 0024_agent_ownership_volume_base_name`. Verified by walking the graph: 26 revisions, exactly one head, no parent with more than one child, no dangling down_revision, and an unbroken chain head→`0001_baseline`. Related to trinity-enterprise#157
…#1676) * feat(agents): editable display label with an immutable slug (ent#181) An agent had exactly one name — its slug — so "rename" meant the heavyweight identity change: `PUT /rename` stops the container, rewrites ~20 tables, clears every per-agent Redis keyspace, renames avatar files, and STILL leaves the agent's volumes under the old base, because Docker can rename neither a volume nor its immutable `trinity.agent-name` label. That path is the root of #1664/#1665/#1667/#1669/#1671. Yet "call it Marketing Bot" is the common case, and it should not touch any of that. Adds a display label that is rendered, never resolved. The slug stays the identity everything machine-facing keys on; the label is presentation. - `agent_ownership.display_label TEXT`, nullable — NULL means "render the slug", so no backfill and every existing agent is unchanged until someone sets one; clearing reverts to the slug rather than blanking a name. Dual-track migration (Invariant #3): SQLite `agent_ownership_display_label` + Alembic 0025, plus schema.py / tables.py. - `DisplayLabelMixin` (Invariant #2 — new setting, new mixin), with a batched reader for the fleet list so the hottest endpoint stays one query, and the four facade pass-throughs (the #1666 lesson: mocked tests can't see a facade gap). - `GET`/`PUT /api/agents/{name}/label`, owner-only. Read never coerces `label` to the slug — the UI must tell "no label" from "label equals slug". WS `agent_label_changed` broadcast. - Frontend: one resolution helper (`utils/agentName.js`) — no per-site `label || name`, which would show one agent under two names (§1.3.1 FR-3). The header pencil edits the label and shows the slug as secondary text (FR-4); list/tile surfaces render the label with the slug in the tooltip. The store goes through the shared axios client (Invariant #7). - The slug rename is demoted, not removed (FR-5): a separate "Rename the id instead…" affordance with copy stating what it does (restart, re-key, volumes stay under the old id) — owners who need it keep it; it stops being the default gesture. Requirements §1.3.1 written before the code (rule #1). Verified end-to-end on the live stack: label set → slug untouched (container intact, `/api/agents/{slug}` still 200, nothing restarted), blank and null both clear back to the slug. 28 tests. Decision (maintainer): OSS-core; demote the slug rename; label everywhere. Closes trinity-enterprise#181 * fix(agents): carry the label on the detail endpoint + make the WS broadcast live (ent#181) Self-review (/review) caught two consistency gaps I'd introduced. 1. `GET /api/agents/{name}` — the endpoint AgentHeader loads on page open — enriched the agent dict with owner/is_owner/can_share but NOT display_label. So on a fresh load or refresh the header showed the SLUG; the label only appeared after an edit round-tripped through the store. Reproduced live (detail returned display_label=None for a labelled agent), fixed by adding the one read, re-verified live. §1.3.1 FR-3 — the surfaces must agree. 2. The `agent_label_changed` broadcast was dead: it set only `type`, but the frontend WS client switches on `event` (both are the convention, per agent_started et al.), and no handler existed — so a label change on one client never reached others. Added `event` + the `data` shape the other agent_* events use, and a handler that updates the cached agent (the slug never moves, so it's a pure re-render). Endpoint test updated to assert the new broadcast shape.
Fixes the P0 in #1664: the #1581 orphan-volume sweep can silently, unrecoverably destroy a live renamed agent's
/home/developer— including #1169data_pathsruntime data.Related to #1664, #1665, #1667
Root cause
Rename keeps the agent's Docker data volumes — Docker can rename neither a volume nor its immutable
trinity.agent-namelabel — so after a rename both the volume name and its label permanently describe an agent that no longer exists. The sweep read that self-description as ownership truth: label →old-name→ no ownership row → orphan →volume rm -f.The old
is_reclaimable_agent_volumedouble-guard could not catch it: it only verifies the volume's name and label agree with each other, and both carry the same stale identity. Nothing asked whether the data was alive. Only Docker's in-use 409 stood in the way — andrecreate_container_with_updated_config(guardrails / resources / shared-folder change, operator rebuild) removes the old container before creating the new one, so a sweep landing in that window succeeds.The fix: ownership is a DB question, not a volume-label question
agent_ownership.volume_base_name(NULL ⇒agent_name, so no backfill) pins the volume identity. Written in the same statement as the rename (atomic — a crash cannot leave a renamed row unpinned) and frozen across re-renames (COALESCE, not SET — the volumes never moved).db.is_volume_base_reservedreplacesis_agent_name_reservedas the sweep's orphan predicate. Soft-deleted rows still match, so the Soft-delete all entities + configurable data retention policy #834 recovery window holds for renamed agents too.remove_agent_volumes(new_name)targeted volumes that never existed and the real ones leaked)._heal_renamed_volume_basesreconstructs the pin from Docker's own mount table (containeragent-{current}mountingagent-{other}-workspaceis a completed rename). One-shot, idempotent, runs ahead of the startup sweep, and can never overwrite an existing pin.Defence in depth (#1638: cleanup must fail safe)
This sweep force-removes durable user data, so two more gates stand in front of it — each independently sufficient:
list_attached_volume_namesreads mounts across all containers (unfiltered — a volume someone mounts is somebody's live data whatever it calls itself). Fail-closed: if the mount table can't be read the whole cycle is skipped rather than reclaiming blind.ORPHAN_VOLUME_UNATTACHED_STRIKES= 3 consecutive cycles (~15 min). The recreate gap is seconds, so one unattached sighting is not evidence of an orphan; any attached sighting resets the streak. This is a backstop sweep with no urgency (the purge path is the primary reclaim).is_reclaimable_agent_volumestays as the last line of defence, with its docstring corrected — its safety claim ("a live agent's data is unreachable by a bug") was exactly what rename falsified.Verification
Real Docker daemon, real sweep code, synthetic renamed-agent volume in the recreate window (
gracebypassed — orthogonal, covered by the #1581 suite):Full lifecycle, zero mocks — real
DatabaseManager(real schema + migrations), real Docker daemon, real sweep:Also exercised both new Docker helpers against the live daemon (9/9 agent containers mapped correctly;
Mountsconfirmed present on the list payload — one API call, no per-container inspect).Tests: 23 new (
tests/unit/test_1664_renamed_agent_volume_safety.py) covering the helpers, all three sweep gates, the purge base-resolution, the heal, and the DB predicate/pin on the real schema (SQLite + PG viadb_harness);test_1581_agent_volume_reclaim.pyupdated to the new contract. Full unit suite green — 4129 passed; the 4 failures in the run (test_admin_email_login,Europe/Kievtzdata) reproduce identically onorigin/devand are unrelated to this change.A second commit worth reviewing
6c100a91adds the threeDatabaseManagerpass-throughs. The unit tests mockdb, so they could not see that the facade had nois_volume_base_reserved/get_volume_base_name/set_volume_base_name— every call would have raisedAttributeError, and since the sweep swallows per-cycle exceptions, the reclaim would have silently degraded to a permanent no-op. Thetest_database_facade_delegationguard caught it; the mock-free run above is what proves the wiring.Found by
/review(self-review of this diff)Two things the review turned up, both now fixed in-branch:
A regression this PR introduced (
ee75479b).get_public_volume_name/get_shared_volume_namename off the live agent name, so a renamed agent owns volumes under two bases: workspace under the old,agent-{new}-publicunder the current. My predicate matched only the pin, sois_volume_base_reserved("new-name")was False for a live agent — and the public volume is unmounted whenever file-sharing is toggled off, which defeats the attached-check. Verified against a real daemon: it deleted a live agent's public volume. Same data-loss shape I was fixing, one layer up. Ownership is now the union of both identities, which is also a strict superset of the pre-bug: #1581 orphan-volume sweep can destroy a live renamed agent's home volume (rename keeps old volume name+label) #1664is_agent_name_reserved(that predicate is exactly the union's first branch), so it can only protect more, never less. The purge had the mirror gap (leakedagent-{new}-public) and now removes both bases, deduped.A cross-tenant P0 in the surrounding code (
4a0a41c9, per maintainer decision to gate it here). Rename frees the name while the volume lives on, andcrud.pyprovisions with get-then-create — an existing volume is silently reused. So an agent created under the freed name booted on the renamed agent's/home/developer. Verified on a real daemon: the new agent read agent A'sANTHROPIC_API_KEYfrom its.env, and the two owners need not be the same person. The create gate now consultsis_volume_base_reservedand 409s before any container/volume work. This closes the rename-driven case; the orphaned-volume case (no row claims it, so the predicate says "free" and get-then-create still adopts it) needs an explicit refuse-or-adopt decision and is filed as bug: agent creation silently reuses a pre-existing Docker volume of the same name (get-then-create, no adopt/refuse decision) #1667.#1665 — recovery mounted a renamed agent's empty workspace (folded in)
The same root cause pointed at recovery.
recreate_missing_container(#1559) f-stringed the agent's current name, which for a renamed agent names no volume — andcontainers.runcreates a missing named volume rather than failing, so recovery rebuilt the agent on a fresh empty/home/developerand started it. Healthy-looking, just empty, with its real data (incl. #1169data_paths) stranded under the old base.One rule — resolve through the ownership row, never the name (
_workspace_volume_name; NULL pin ⇒ the name itself, so un-renamed agents are byte-identical to today; a DB error falls back to the name rather than blocking a rebuild):recreate_missing_containeragent-{current}-workspace→ empty workspace_read_template_yaml_from_volume{}→ recovery rebuilt on default agent-type/runtimeresearcher/codexrecreate_container_with_updated_configagent_volume_name = f"agent-{agent_name}-workspace"that read as if it were the mount in usedeploy.py's version-name volume is correct by construction (fresh name, no row, no rename in its past) — annotated as audited, not changed. Verified mock-free (real DB + real daemon); 3 new tests. Filed #1669 for the fourth site the audit found (public/shared volumes name off the live name) — deliberately not bundled: agents renamed in the field already hold data underagent-{new}-public, so resolving to the pinned base would orphan that instead. It needs an adopt/migrate decision.#1667 — silent volume adoption at create (folded in)
crud.pyprovisioned the home volume with get-then-create and no branch: an existingagent-{name}-workspacewas silently mounted as the new agent's/home/developer, so the previous holder of that name had its.env,.credentials.encand workspace resurface inside a different agent — possibly a different owner's. #1664's gate covers a volume some row still claims (rename); this covers the volume nothing claims: a purge whose removal hit an in-use 409, a crash betweenvolume_createand the ownership INSERT, or a restored backup.The fact that reshaped the fix (and killed the option I favoured when filing #1667): emptiness cannot discriminate, because deploy-local (#950) pre-populates the volume with the template before calling create — a legitimate adopt is non-empty. (Docker also auto-populates a named volume from the image on first mount, so "empty" wouldn't even identify a crashed create.)
So the adopter declares itself:
adopt_existing_workspace— a function kwarg, mirroringskip_name_sanitization, deliberately not a field onAgentConfigwhere an API caller could set it and re-open the disclosure.deploypassesTrue; everyone else gets a 409 naming the volume and how to clear it. Adoption is now logged, so it is never silent again.Raised before the docker try-block — that block catches
Exception, rolls back, and re-raises a generic 500, so a 409 inside it would lose both its status and its message (the same reasonfork_to_ownraises its destination 409 before the block). Nothing is built yet there, so nothing needs rolling back. Fail-open on a probe error; ghosts skip it (volume-less by construction).Verified mock-free against a real daemon:
Unaffected by design, checked rather than assumed:
system_agent_servicebuilds its recycled-name container directly instead of viacreate_agent_internal; the Cornelius seeder is first-run-only behind a durable flag.Notes for review
agent_ownership_volume_base_name+ Alembic0024_agent_ownership_volume_base_name, plusschema.py/tables.py. Verified the SQLite migration applies to a pre-existing table and is idempotent on re-run.agent-{current_name}-workspace(soft-delete recoveryrecreate_missing_container,_read_template_yaml_from_volume, deploy pre-populate). For a renamed agent, recovery mounts a fresh empty volume — a real bug, but not data destruction, and it wants the primitive this PR introduces (db.get_volume_base_name). Kept out to keep the P0 fix tight.🤖 Generated with Claude Code