Skip to content

Commit cda09f3

Browse files
siddhartpaiclaude
andcommitted
fix(spawner): reclaim agent-container names squatted by a retired spawner identity
Container names are owner+slug scoped, but list() only sees containers labelled with the current spawner pubkey. A container created by a previous identity (wiped state dir, re-minted nsec) is therefore invisible to reconcile while still holding the name, and every create for that slug fails with Docker 409 forever. On a 409, inspect the squatter: if it carries the com.buzz.agent label it is spawner-managed (whichever identity created it) — remove it and retry the create once. Unlabelled containers are refused, so an operator's unrelated container of the same name is never touched. The workspace volume is a named mount and survives. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: sid <siddhartpai@gmail.com>
1 parent ec11488 commit cda09f3

1 file changed

Lines changed: 95 additions & 3 deletions

File tree

crates/buzz-spawner/src/container.rs

Lines changed: 95 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -278,17 +278,51 @@ impl DockerOps {
278278
..Default::default()
279279
};
280280

281-
let created = self
281+
let created = match self
282282
.docker
283283
.create_container(
284284
Some(CreateContainerOptions {
285285
name: Some(spec.name.clone()),
286286
..Default::default()
287287
}),
288-
body,
288+
body.clone(),
289289
)
290290
.await
291-
.with_context(|| format!("failed to create container {}", spec.name))?;
291+
{
292+
Ok(created) => created,
293+
// The name can be squatted by a container a previous spawner
294+
// identity created (names are owner+slug scoped, but list() only
295+
// sees the current identity's label) — self-heal by removing it,
296+
// but only after verifying the labels say it is an agent container.
297+
Err(e) if is_name_conflict(&e) => {
298+
self.remove_name_squatter(&spec.name)
299+
.await
300+
.with_context(|| {
301+
format!(
302+
"container name {} is taken and could not be reclaimed",
303+
spec.name
304+
)
305+
})?;
306+
self.docker
307+
.create_container(
308+
Some(CreateContainerOptions {
309+
name: Some(spec.name.clone()),
310+
..Default::default()
311+
}),
312+
body,
313+
)
314+
.await
315+
.with_context(|| {
316+
format!(
317+
"failed to create container {} after reclaiming its name",
318+
spec.name
319+
)
320+
})?
321+
}
322+
Err(e) => {
323+
return Err(e).with_context(|| format!("failed to create container {}", spec.name));
324+
}
325+
};
292326

293327
self.docker
294328
.start_container(&created.id, None::<StartContainerOptions>)
@@ -338,6 +372,39 @@ impl DockerOps {
338372
Ok(())
339373
}
340374

375+
/// Remove an agent container squatting on `name`.
376+
///
377+
/// Only containers carrying the `com.buzz.agent` label are removed — that
378+
/// label marks them as spawner-managed regardless of which spawner
379+
/// identity created them, so orphans left by a retired identity are fair
380+
/// game while an operator's unrelated container of the same name is not.
381+
/// The workspace volume is a named mount and survives the removal.
382+
async fn remove_name_squatter(&self, name: &str) -> Result<()> {
383+
use bollard::query_parameters::InspectContainerOptions;
384+
385+
let squatter = self
386+
.docker
387+
.inspect_container(name, None::<InspectContainerOptions>)
388+
.await
389+
.with_context(|| format!("failed to inspect conflicting container {name}"))?;
390+
let labels = squatter.config.as_ref().and_then(|c| c.labels.as_ref());
391+
if !labels.is_some_and(|labels| labels.contains_key(AGENT_LABEL)) {
392+
anyhow::bail!(
393+
"conflicting container {name} does not carry the {AGENT_LABEL} label; \
394+
refusing to remove a container the spawner does not manage"
395+
);
396+
}
397+
let spawner = labels
398+
.and_then(|labels| labels.get(SPAWNER_LABEL).cloned())
399+
.unwrap_or_default();
400+
tracing::info!(
401+
container = %name,
402+
previous_spawner = %spawner,
403+
"removing orphaned agent container squatting on required name"
404+
);
405+
self.remove_inner(name, None).await
406+
}
407+
341408
async fn logs_inner(&self, container_id: &str, lines: usize) -> Result<String> {
342409
use bollard::query_parameters::LogsOptionsBuilder;
343410
use futures_util::StreamExt;
@@ -362,6 +429,17 @@ impl DockerOps {
362429
}
363430
}
364431

432+
/// True when Docker rejected a create because the container name is taken.
433+
fn is_name_conflict(e: &bollard::errors::Error) -> bool {
434+
matches!(
435+
e,
436+
bollard::errors::Error::DockerResponseServerError {
437+
status_code: 409,
438+
..
439+
}
440+
)
441+
}
442+
365443
fn is_not_found(e: &bollard::errors::Error) -> bool {
366444
matches!(
367445
e,
@@ -395,4 +473,18 @@ mod tests {
395473
// Without this, two spawners on one host reap each other's containers.
396474
assert_eq!(labels.get(SPAWNER_LABEL), Some(&"s".repeat(64)));
397475
}
476+
477+
#[test]
478+
fn name_conflict_matches_409_only() {
479+
let conflict = bollard::errors::Error::DockerResponseServerError {
480+
status_code: 409,
481+
message: "Conflict. The container name is already in use".into(),
482+
};
483+
assert!(is_name_conflict(&conflict));
484+
let not_found = bollard::errors::Error::DockerResponseServerError {
485+
status_code: 404,
486+
message: "no such container".into(),
487+
};
488+
assert!(!is_name_conflict(&not_found));
489+
}
398490
}

0 commit comments

Comments
 (0)