fix(agents): reclaim the container a failed creation leaves behind (trinity-enterprise#313) - #1956
Conversation
…nt#313) `_rollback_failed_creation` rolled back DB/quota handles only, and its docstring justified leaving the container to "the cleanup watchdog". No watchdog covers a non-ephemeral agent — `cleanup_service._sweep_ephemeral_agents` is gated on the `trinity.ephemeral` label — so a failure after `containers.run` left a RUNNING container with no `agent_ownership` row. Two guards then deadlocked: nothing removed the container, and because it kept its workspace volume mounted, `_sweep_orphan_agent_volumes` could never advance the #1581 unattached-strike counter, so the volume was unreclaimable too. The phantom still rendered in the fleet listing, which is Docker-as-truth (Invariant #11). Observed live: 13+ hours, ~66 MB, an SSH port and a 2g limit held by an agent with zero rows anywhere; recovery needed manual docker rm -f + volume rm + a Redis DEL. The except-path now awaits `_reclaim_failed_creation_container`, which removes the container and clears the name-keyed Redis keyspace (#1560 — otherwise the next agent to reuse the name inherits stale breaker verdicts). Removing the container is also what unblocks the existing volume sweep, so AC #2 is met by the guard that already exists rather than a second destructive path. Two arrival shapes, because the reported failure has no handle: * handle in hand (a later step raised) — ownership is unambiguous * NO handle — the failure happened inside `containers.run` (the observed 60s Docker read timeout: the daemon created it, the client never got it). The container is re-derived BY NAME, which is a different security problem: on a shared Docker daemon (git worktrees, two stacks on one host) a name resolves to another install's live agent. Three fail-closed gates: not a 409 name conflict (a 409 means the daemon created nothing, so the incumbent is definitionally not ours), no `agent_ownership` row, and a `trinity.created` label at or after a floor stamped before the docker block. Anything unprovable refuses — a false negative costs one manual `docker rm -f`, a false positive deletes a running agent. The ownership gate applies to BOTH shapes on purpose: `_register_agent` writes the row before the last creation step, so a failure in `_materialize_agent_files` arrives holding the handle of a container the DB already considers an agent. Removing it would turn a half-created-but-present agent into a row with no container still holding its name — strictly worse than the leak. Deliberately NOT implemented: extending the Docker-as-truth sweep to any `trinity.platform=agent` container with no ownership row (the issue's option b). On a shared daemon a sibling stack's live agents have no rows in THIS install's DB, so that sweep would delete another install's running agents. It needs a per-install container label first, which by construction only helps containers created after it ships — i.e. it would not heal the existing leaks that motivate it. Left as a follow-up with that prerequisite stated. Two defects the full suite caught that the targeted tests could not: - `isinstance(exc, docker.errors.APIError)` raises TypeError wherever a sibling test stubs the docker module, propagating out of a function whose contract is "never raises" and REPLACING the creation error the caller reports. The 409 check is duck-typed on `.response.status_code` now. - #1560's guard bans `clear_agent_runtime_state` from crud.py outright, because the full sweep drops the slot ZSET and would strip an in-flight async execution off a LIVE container. This reclaim is the first place in the file where the container is provably GONE. Narrowed the guard to enforce that reason — the sweep may appear only inside the reclaim. 20 regression tests; `test_1484`'s case 15 asserted `remove.assert_not_called()` with the comment "left for the cleanup watchdog (PRESERVED)" — a characterization test pinning the defect, now inverted. Related to trinity-enterprise#313
1d02853 to
8104ebd
Compare
# Conflicts: # tests/registry.json
vybe
left a comment
There was a problem hiding this comment.
Validated via /validate-pr. Fixes abilityai/trinity-enterprise#313 (reference made explicit — cross-tracker, so status-in-dev needs setting by hand on ent#313; GitHub auto-close is same-repo only).
No secrets, no schema change, no new config var, no new top-level backend module. Architecture doc updated alongside.
Three things that make this the right fix rather than the obvious one:
- The issue's option (a) is correctly rejected as incomplete. "The orchestrator holds the handle" doesn't hold for a 60s Docker read timeout — the daemon created the container and the client never got the handle. Splitting into handle-in-hand vs re-derive-by-name, with the name path behind three fail-closed gates (not-a-409, no ownership row,
trinity.createdfloor), is the shape that actually covers the reported failure. - Option (b) deliberately not implemented, for a real reason — a Docker-as-truth sweep over
trinity.platform=agentwith no ownership row deletes a sibling stack's running agents on a shared daemon, and would need a per-install label that by construction can't heal the existing leaks motivating it. Deferring that with the prerequisite stated beats shipping a destructive sweep inside a leak fix. - The ownership gate applying to the handle-in-hand shape too.
_register_agentwrites the row before the last step, so a_materialize_agent_filesfailure arrives holding the handle of a container the DB already considers created — removing it would trade a leak for a row with no container still holding its name, which is strictly worse.
Also good: #1560's guard was narrowed to enforce its reason rather than deleted, and the isinstance(exc, docker.errors.APIError) → duck-typed .response.status_code fix removes a TypeError that was replacing the original creation error — both defects the 20 focused tests couldn't see and the full suite did.
Merged dev and resolved the tests/registry.json append conflict (union of entries preserved, JSON re-parsed, touched-file set unchanged).
# Conflicts: # tests/registry.json
Fixes abilityai/trinity-enterprise#313
Fixes the leak reported in
abilityai/trinity-enterprise#313. The issue is filed on the enterprise tracker but every affected file is OSS-core (crud.py,cleanup_service.py,agent_runtime_state.py), so the fix lands here.Problem
_rollback_failed_creationrolled back DB/quota handles only, and its docstring justified the omission: the container and volumes were "left for the cleanup watchdog". No such watchdog exists for a non-ephemeral agent — the only Docker-as-truth container sweep,cleanup_service._sweep_ephemeral_agents, is gated on thetrinity.ephemerallabel.So a failure after
containers.runleft a running container with noagent_ownershiprow, and two guards deadlocked:_sweep_orphan_agent_volumescould never advance itsORPHAN_VOLUME_UNATTACHED_STRIKEScounter (bug: Docker volumes are never deleted — volume_remove has zero callers; agent purge leaks workspace/public/shared volumes forever #1581) — so the volume was unreclaimable tooObserved live: 13+ hours, ~66 MB, an SSH port and a 2g memory limit held by a phantom that still rendered in the fleet listing (Docker-as-truth, Invariant #11). Recovery took a manual
docker rm -f,docker volume rm, and a RedisDEL.Fix
The except-path now awaits
_reclaim_failed_creation_container: remove the container, then clear the name-keyed Redis keyspace (#1560 — otherwise the next agent reusing that name inherits stale breaker verdicts and gets fast-failed as unhealthy without ever being contacted). Removing the container is also what unblocks the existing volume sweep, so AC #2 is met by the guard that already exists rather than a second destructive path.The issue's option (a) doesn't cover the failure that reported it
Option (a) argues "the orchestrator holds the handle, so ownership is unambiguous". Not for a 60s Docker read timeout — the daemon created the container and the client never received the handle. So there are two arrival shapes:
containers.run)Reclaiming by name is a different security problem: on a shared Docker daemon (git worktrees, two stacks on one host) a name resolves to another install's live agent. The gates:
agent_ownershiprowtrinity.createdat or after a floor stamped before the docker blockAnything unprovable refuses. A false negative costs one manual
docker rm -f; a false positive deletes a running agent.The ownership gate applies to both shapes — deliberately
_register_agentwrites the row before the last creation step, so a failure in_materialize_agent_filesarrives holding the handle of a container the DB already considers a created agent. Removing it would turn a half-created-but-present agent into a row with no container, still holding its name — strictly worse than the leak. (Caught in review of my own first cut, which removed it.)Option (b) deliberately not implemented
Extending the Docker-as-truth sweep to any
trinity.platform=agentcontainer with no ownership row is unsafe on a shared daemon — exactly the multi-stack setup the issue's own "Related" section describes. A sibling stack's live agents have no rows in this install's DB, so that sweep would delete another install's running agents.It needs a per-install container label first — which by construction only helps containers created after it ships, i.e. it would not heal the existing leaks that motivate (b). Worth its own issue with that prerequisite stated, rather than a destructive sweep shipped inside this fix.
Two defects the full suite caught that the targeted tests could not
Worth naming, since both were invisible to the 20 focused tests:
isinstance(exc, docker.errors.APIError)raisesTypeErrorwherever a sibling test stubs the docker module — propagating out of a function whose whole contract is never raises and replacing the original creation error. A clear "failed to persist per-agent GitHub PAT" became an unrelatedTypeError. The 409 check is duck-typed on.response.status_codenow.clear_agent_runtime_statefromcrud.pyoutright, because the full sweep drops the slot ZSET and would strip an in-flight async execution (refactor: fire-and-forget dispatch — a hung turn holds zero backend resource #1083) off a live container. This reclaim is the first place in that file where the container is provably gone. Rather than delete the guard I narrowed it to enforce its reason: the sweep may appear only inside the reclaim; every other path stays onclear_agent_breakers.Verification
test_1484's case 15 assertedcontainer_mock.remove.assert_not_called()with the comment "left for the cleanup watchdog (PRESERVED)" — a characterization test pinning the defect itself. Inverted, with the reason written at the assertion.AC coverage
containers.runfailure — removed inlineagent_runtime_stateRelated to trinity-enterprise#313
🤖 Generated with Claude Code