research: teardown per-topic container on publish + delete (P1)
ci / gates (push) Successful in 7s
ci / rust (push) Failing after 10s
ci / frontend (push) Successful in 26s
ci / e2e (push) Has been skipped
ci / publish (push) Has been skipped

Path B spawned a per-topic team container on start_topic (research
runtime) but nothing ever stopped it. Containers accumulated on gw-04
across the lifetime of every topic — the earlier cleanup pass reclaimed
6.72 GB of these. Now the container is stopped + removed automatically
at the two terminal transitions:

- delete_topic — the topic row is gone, the container is meaningless.
- approve_publish (reviewing → publishing) — the research work is done.
  Artifact writing (queued as R1) reads from the durable run_events
  log, so it doesn't need a live runtime.

Wiring is a fire-and-forget teardown() helper in research_container.rs:
- Connects Docker via the same socket-proxy shim as spawn().
- Calls the existing stop() which is idempotent (404 = already gone,
  304 = already stopped, both treated as ok).
- Errors log with eprintln! and don't block the API response — a topic
  is done whether Docker is reachable or not. Dev machines without
  Docker running just log a debug line and return.

Path A (repo-only mount + shared clawmates_core network) is untouched
so there's no Traefik dynamic-file cleanup needed. If we ever add
per-topic Traefik routing, extend teardown() with the corresponding
file remove.

Follow-up: gw-04 will still have any legacy containers from before this
commit. One-shot `docker ps -a --filter "name=research-" -q | xargs -r
docker rm -f` on gw-04 is the manual sweep.
This commit is contained in:
Omar Sobh
2026-07-09 14:02:50 -07:00
parent 763b95a253
commit f60df36717
2 changed files with 32 additions and 0 deletions
+24
View File
@@ -242,3 +242,27 @@ pub async fn stop(docker: &Docker, name: &str) -> Result<(), String> {
Err(e) => Err(format!("remove {name}: {e}")), Err(e) => Err(format!("remove {name}: {e}")),
} }
} }
/// Fire-and-forget teardown for a topic's runtime — called from
/// `approve_publish` (topic reaches terminal `publishing` state) and
/// `delete_topic`. Non-fatal: Docker unreachable or container already
/// gone both log a debug line and return so the API response stays
/// clean. Callers should NOT `await?` on this — the API contract is
/// "the topic is done" whether Docker is reachable or not.
pub async fn teardown(topic_id: Uuid) {
let name = container_name_for(topic_id);
let docker = match connect() {
Ok(d) => d,
Err(e) => {
// Dev-machine no-docker path — silently no-op. Prod always
// has the socket-proxy sidecar, so this branch is a signal
// rather than a warning.
eprintln!("research_container::teardown({topic_id}): docker connect failed: {e}");
return;
}
};
match stop(&docker, &name).await {
Ok(_) => eprintln!("research_container::teardown({topic_id}): removed {name}"),
Err(e) => eprintln!("research_container::teardown({topic_id}): stop {name} failed: {e}"),
}
}
+8
View File
@@ -543,6 +543,10 @@ pub async fn delete_topic(
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
) -> Result<StatusCode, ApiError> { ) -> Result<StatusCode, ApiError> {
cm_db::repo::research_topics::delete(&state.pool, id, user.workspace_id.as_uuid()).await?; cm_db::repo::research_topics::delete(&state.pool, id, user.workspace_id.as_uuid()).await?;
// Teardown the per-topic team container (Path B). Fire-and-forget:
// the topic row is already gone, so any Docker failure is a log-line
// problem, not an API-response problem.
crate::research_container::teardown(id).await;
Ok(StatusCode::NO_CONTENT) Ok(StatusCode::NO_CONTENT)
} }
@@ -885,6 +889,10 @@ async fn decide_publish(
"publishing", "publishing",
) )
.await?; .await?;
// The research work is done — tear down the per-topic team
// container. Artifact-writing (R1) doesn't need a live runtime;
// it reads run_events from the durable log.
crate::research_container::teardown(approval.topic_id).await;
} }
Ok(StatusCode::NO_CONTENT) Ok(StatusCode::NO_CONTENT)
} }