zeroclaw-labs/zeroclaw · error · anyhow::Error

ACP request_permission timed out after {:?}

Error message

ACP request_permission timed out after {:?}

What it means

The operator never answered the approval prompt within AcpChannel's approval_timeout (fixed at construction). The timeout exists so a vanished client (crash, closed IDE, dropped connection) cannot park execute_tool_call forever and hold the session slot against max_sessions — the error frees the slot and surfaces the failure. The elapsed duration is included in the message.

Source

Thrown at crates/zeroclaw-channels/src/acp_channel.rs:497

            && let Some(args) = &request.raw_arguments
            && let Some(new_text) = args.get("new_string").or_else(|| args.get("content"))
            && let Some(s) = new_text.as_str()
        {
            tool_call["proposedEdit"] = json!(s);
        }
        let params = json!({
            "sessionId": self.session_id,
            "options": options,
            "toolCall": tool_call,
        });

        let call = self.rpc.request("session/request_permission", params);
        let response = match tokio::time::timeout(self.approval_timeout, call).await {
            Ok(Ok(value)) => value,
            Ok(Err(e)) => {
                anyhow::bail!("ACP request_permission failed: {} ({})", e.message, e.code)
            }
            Err(_) => anyhow::bail!(
                "ACP request_permission timed out after {:?}",
                self.approval_timeout
            ),
        };

        let outcome = response.get("outcome");
        let kind = outcome
            .and_then(|o| o.get("outcome"))
            .and_then(|s| s.as_str())
            .unwrap_or("");
        match kind {
            "selected" => {
                let option_id = outcome
                    .and_then(|o| o.get("optionId"))
                    .and_then(|s| s.as_str())
                    .unwrap_or("");
                // "selected" means the operator picked one of the options we
                // offered, so every arm here is a genuine operator decision.

View on GitHub (pinned to 88bb9c8533)

Solutions

  1. Treat this error as a denial (fail closed) — never as an implicit approve.
  2. Raise the approval_timeout passed to AcpChannel::new to fit realistic operator response times.
  3. Check client liveness before running approval-gated tools and skip the tool when the back channel is dead.
  4. Cancel in-flight approval requests on session/stop so teardown does not race the timeout.

Example fix

// construction: give operators realistic time
let ch = AcpChannel::new("acp", session_id, rpc, Duration::from_secs(600), caps);

// call site: timeout must deny, not approve
let decision = ch.request_approval(recipient, &req).await
    .unwrap_or(Some(ChannelApprovalResponse::Deny))
    .unwrap_or(ChannelApprovalResponse::Deny);
Defensive patterns

Strategy: fallback

Try / catch

let decision = match ch.request_approval(recipient, &req).await {
    Ok(d) => d.unwrap_or(ChannelApprovalResponse::Deny),
    Err(e) if e.to_string().contains("timed out") => {
        tracing::warn!(error = %e, "approval timed out; denying");
        ChannelApprovalResponse::Deny
    }
    Err(e) => return Err(e),
};

Prevention

When it happens

Trigger: A gated tool runs, session/request_permission is sent, and no response arrives before approval_timeout: the user ignored the dialog, the client exited without cancelling, or the transport died silently.

Common situations: approval_timeout tuned too low for real approval workflows; unattended or abandoned sessions; laptop sleep or network switches mid-approval.

Understand the failure class

Related errors


AI-assisted analysis of zeroclaw-labs/zeroclaw@88bb9c8533 (2026-08-23). Data as JSON: /api/errors/6c3a1f2c8f8f8f41. Report an issue: GitHub.