Hmbown/CodeWhale · error

Dynamic tool call '{}' is already pending

Error message

Dynamic tool call '{}' is already pending

What it means

register_pending_dynamic_tool found params.call_id already present in the pending registry. Call ids are the identity used to route results, timeouts, and turn-termination settlement, so a duplicate id while the first is still pending is rejected rather than allowed to alias two live calls.

Source

Thrown at crates/tui/src/runtime_threads.rs:3078

                .then_with(|| left.id.cmp(&right.id))
        });
        (approvals, user_inputs)
    }

    fn register_pending_dynamic_tool(
        &self,
        params: DynamicToolCallParams,
    ) -> Result<oneshot::Receiver<DynamicToolCallResult>> {
        let (tx, rx) = oneshot::channel();
        let (settlement_tx, _settlement_rx) = watch::channel(0);
        let mut pending = self.pending_dynamic_tools.lock();
        if pending.len() >= MAX_PENDING_DYNAMIC_TOOL_CALLS {
            bail!(
                "Runtime has reached the pending dynamic tool call limit ({MAX_PENDING_DYNAMIC_TOOL_CALLS})"
            );
        }
        if pending.contains_key(&params.call_id) {
            bail!("Dynamic tool call '{}' is already pending", params.call_id);
        }
        pending.insert(
            params.call_id.clone(),
            PendingDynamicToolEntry {
                params,
                sender: Some(tx),
                settlement_tx,
                indeterminate: false,
            },
        );
        Ok(rx)
    }

    /// Atomically select the single terminal owner for a dynamic tool call.
    ///
    /// The registry entry intentionally remains present with an empty sender
    /// while the winner commits its receipt. `get_thread_detail` therefore
    /// cannot publish a cursor that has neither the pending request nor the

View on GitHub (pinned to 0c42157ee5)

Solutions

  1. Generate call ids with a collision-proof source (UUID v4 / ULID) instead of counters or deterministic derivation
  2. If reusing an id intentionally, first wait for the prior call to settle (submit its result or let the turn end) so the registry slot is released
  3. On catching this error, regenerate a fresh call_id and retry the registration once
  4. Audit retry logic that replays the exact same DynamicToolCallParams

Example fix

// before
let call_id = format!("call_{}", attempt); // collides on retry

// after
let call_id = uuid::Uuid::new_v4().to_string(); // unique per registration
Defensive patterns

Strategy: validation

Validate before calling

// Generate ids from a collision-proof source; never reuse across retries.
fn fresh_call_id() -> String {
    uuid::Uuid::new_v4().to_string()
}

Try / catch

// Duplicate id while pending: regenerate and retry once.
match manager.register_dynamic_tool_call(params).await {
    Ok(rx) => Ok(rx),
    Err(e) if e.to_string().contains("already pending") => {
        let mut params = params;
        params.call_id = fresh_call_id();
        manager.register_dynamic_tool_call(params).await
    }
    Err(e) => Err(e),
}

Prevention

When it happens

Trigger: Calling register with a reused call_id (counter reset, deterministic id generation like "call_1" across retries, or a copy-pasted id) while the first call has not reached a terminal receipt. Checked at runtime_threads.rs:3077 before insert.

Common situations: Retrying a registration after a transient error with the same locally-generated id; parallel branches generating ids from the same seed; UUID v4 not used where the caller assumed uniqueness.

Related errors


AI-assisted analysis of Hmbown/CodeWhale@0c42157ee5 (2026-08-20). Data as JSON: /api/errors/3219ffff2fdb5de4. Report an issue: GitHub.