Hmbown/CodeWhale · error · anyhow::Error
Task id already exists: {}
Error message
Task id already exists: {} What it means
Duplicate-id guard in the staged create transaction: the id is rejected if it is already in the in-memory task map, or if tasks/<id>.json exists, or if a staged .<id>.json.pending file exists. The staged extension is deliberately not .json so startup replay ignores interrupted creates; this triple check prevents a retry from overwriting a durable record or resurrecting a half-finished one.
Source
Thrown at crates/tui/src/task_manager.rs:1299
tool_calls: Vec::new(),
timeline: vec![TaskTimelineEntry {
timestamp: Utc::now(),
kind: "queued".to_string(),
summary: "Task queued".to_string(),
detail_path: None,
}],
};
{
let mut state = self.state.lock().await;
let task_path = self.tasks_dir.join(format!("{}.json", task.id));
// The staged extension is intentionally not `.json`, so startup
// replay ignores an interrupted create until the queue write has
// succeeded and this file is atomically promoted.
let staged_task_path = self.tasks_dir.join(format!(".{}.json.pending", task.id));
if state.tasks.contains_key(&task.id) || task_path.exists() || staged_task_path.exists()
{
bail!("Task id already exists: {}", task.id);
}
let mut next_queue = state.queue.clone();
next_queue.push_back(task.id.clone());
// Stage the owner record, then persist its queue membership, then
// atomically promote it. A crash before promotion leaves either an
// ignored staged file or a queue entry with no task (which replay
// drops); a crash after promotion leaves the complete runnable
// pair. In-memory scheduling is published only after all three.
write_json_atomic(&staged_task_path, &task)?;
if let Err(err) = self.persist_queue_locked(&next_queue) {
if let Err(cleanup_err) = fs::remove_file(&staged_task_path) {
tracing::warn!(
task_id = %task.id,
error = %cleanup_err,
"failed to remove ignored staged task after queue write failure"
);
}View on GitHub (pinned to 0c42157ee5)
Solutions
- Allocate a fresh id (new UUIDv4-derived task_<16hex>) for every create attempt instead of reusing the preallocated one after a failure.
- If a previous attempt definitely failed and left debris, remove tasks/.<id>.json.pending manually, then create with a new id.
- Never catch this error and retry unchanged; the id space collision is deterministic until state changes.
Example fix
// before
loop {
match tm.add_task_with_id(req.clone(), id.clone()).await {
Ok(t) => break t, // retries the SAME id -> 'Task id already exists'
Err(_) => continue,
}
}
// after
loop {
let id = format!("task_{}", &Uuid::new_v4().simple().to_string()[..16]);
match tm.add_task_with_id(req.clone(), id).await {
Ok(t) => break t,
Err(e) if e.to_string().contains("already exists") => continue,
Err(e) => return Err(e),
}
} Defensive patterns
Strategy: validation
Validate before calling
// Before retrying a create, confirm the id is truly free:
fn task_id_free(tasks_dir: &Path, tasks: &HashMap<String, TaskRecord>, id: &str) -> bool {
!tasks.contains_key(id)
&& !tasks_dir.join(format!("{id}.json")).exists()
&& !tasks_dir.join(format!(".{id}.json.pending")).exists()
} Try / catch
match tm.add_task_with_id(req, id).await {
Err(e) if e.to_string().contains("Task id already exists") => {
let id = new_task_id(); // regenerate and retry once
tm.add_task_with_id(req, id).await
}
other => other,
} Prevention
- Mint a fresh id per create attempt; never reuse a preallocated id after any failure.
- After crashes, inspect tasks_dir for .pending debris before re-registering ids.
- Keep one writer per tasks_dir to avoid cross-process duplicates.
When it happens
Trigger: Calling add_task_with_id twice with the same preallocated id; retrying the register step after a partial failure that left a .pending file; a genuine id collision from a weak external generator.
Common situations: Agent flows that preallocate an id, fail downstream, and retry the whole transaction with the same id; crash-recovery scenarios where the staged file was not cleaned; ids reused across process restarts.
Related errors
- Trigger '{trigger_id}' cannot be canceled (status: {:?})
- DSH is already connected; use `{CLI_COMMAND} update` to rewr
- MCP server '{name}' already exists in {}. Use `codewhale mcp
- Task prompt cannot be empty
- Invalid preallocated task id: expected task_<16hex>
AI-assisted analysis of Hmbown/CodeWhale@0c42157ee5 (2026-08-20).
Data as JSON: /api/errors/d12dc282fc032b43.
Report an issue: GitHub.