Hmbown/CodeWhale · error

Agent Mail can be marked read only after delivery

Error message

Agent Mail can be marked read only after delivery

What it means

Agent Mail envelopes move through a lifecycle (Queued → Delivering → Delivered → Read, plus Canceled). The `mark read` operation only accepts envelopes already in the Delivered state; anything else (Queued, Delivering, Canceled) is rejected with this bail. The library throws it to prevent marking mail as read before the recipient could have seen it, keeping the status machine and `read_at` timestamp meaningful.

Solutions

  1. Check envelope.status == Delivered before calling the mark-read operation.
  2. Deliver the mail first (run the deliver flow) and then mark it read.
  3. If the mail was canceled, treat it as terminal and do not attempt read transitions.
  4. Guard against double-marking: a status of Read already means it was read; return the existing envelope instead.

Example fix

// before
mail.markRead(message_id)?; // assumes delivered
// after
let mail = store.load_agent_mail(message_id)?;
if mail.status != AgentMailStatus::Delivered {
    anyhow::bail!("refusing to mark read: status is {:?}", mail.status);
}
mail.markRead(message_id)?;
Defensive patterns

Strategy: validation

Validate before calling

let mail = store.load_agent_mail(message_id)?;
if mail.status != AgentMailStatus::Delivered {
    anyhow::bail!("cannot mark read: status is {:?}", mail.status);
}

Type guard

fn is_markable_as_read(status: &AgentMailStatus) -> bool { matches!(status, AgentMailStatus::Delivered) }

Try / catch

match runtime.mark_agent_mail_read(thread_id, message_id).await {
    Ok(env) => info!("marked read at {:?}", env.read_at),
    Err(e) if e.to_string().contains("only after delivery") => warn!("mail not in Delivered state; skipping"),
    Err(e) => return Err(e),
}

Prevention

When it happens

Trigger: Calling the mark-agent-mail-read flow (runtime_threads.rs mark_read handler) with a message_id whose envelope status is Queued, Delivering, or Canceled instead of Delivered.

Common situations: Tool/UI double-invocation where read is requested twice (second call sees status already Read? no — Read passes through `_` too), racing a delivery that has not completed, or attempting to mark canceled mail read after a cancel.

Understand the failure class

Background: "Invalid state transition" errors: "status must be X, actually Y", "already rejected/charging/uninstalled", "cannot ... while running" — what they mean when a library rejects your call — this error's family across 31 libraries.

Related errors


AI-assisted analysis of Hmbown/CodeWhale@73e0f67d83 (2026-09-22). Data as JSON: /api/errors/3a2e87ece72550a4. Report an issue: GitHub.

Appendix: source

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

        message_id: &AgentMailMessageId,
    ) -> Result<AgentMailEnvelope> {
        let thread = self.get_thread(thread_id).await?;
        let address = agent_mail_address(&self.store.owner_id, &thread)?;
        let envelope = {
            let _mail_mutation = self.store.mail_mutation.lock();
            let mut envelope = self.store.load_agent_mail(message_id)?;
            if envelope.destination != address {
                bail!("Agent Mail ownership denied: message does not belong to this destination");
            }
            match envelope.status {
                AgentMailStatus::Read => envelope,
                AgentMailStatus::Delivered => {
                    envelope.status = AgentMailStatus::Read;
                    envelope.read_at = Some(Utc::now());
                    self.store.save_agent_mail(&envelope)?;
                    envelope
                }
                _ => bail!("Agent Mail can be marked read only after delivery"),
            }
        };
        self.emit_agent_mail_event(AGENT_MAIL_EVENT_READ, &envelope)
            .await?;
        Ok(envelope)
    }

    /// Withdraw a queued envelope before it starts delivery (#6176). Only
    /// `Queued` mail can be canceled; anything that reached delivery keeps
    /// its receipt. Re-canceling an already-canceled envelope is an
    /// idempotent no-op returning the stored envelope.
    pub async fn cancel_agent_mail(
        &self,
        thread_id: &str,
        message_id: &AgentMailMessageId,
    ) -> Result<AgentMailEnvelope> {
        let thread = self.get_thread(thread_id).await?;
        let address = agent_mail_address(&self.store.owner_id, &thread)?;

View on GitHub (pinned to 73e0f67d83)