Hmbown/CodeWhale · error · anyhow::Error

Agent Mail can be marked read only after delivery

Error message

Agent Mail can be marked read only after delivery

What it means

mark_agent_mail_read only permits the Delivered -> Read transition (Read -> Read is an idempotent no-op). The envelope's status was Queued, Delivering, or Failed, so it has not been delivered yet and cannot be marked read.

Source

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

        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)
    }

    /// Claim and project one envelope into the existing destination turn
    /// queue. A busy thread keeps queued mail untouched; retryable failures are
    /// claimed again only below the bounded attempt ceiling.
    pub async fn deliver_agent_mail(
        &self,
        thread_id: &str,
        message_id: &AgentMailMessageId,
    ) -> Result<(AgentMailEnvelope, Option<TurnRecord>)> {
        let thread = self.get_thread(thread_id).await?;
        let address = agent_mail_address(&self.store.owner_id, &thread)?;
        {

View on GitHub (pinned to 0c42157ee5)

Solutions

  1. Only call mark_read for envelopes whose status is Delivered (or Read, for idempotency) - check status via list/load first
  2. If delivery is pending, drive deliver_agent_mail to completion, then mark read
  3. For Failed envelopes, inspect envelope.failure: retryable failures need re-delivery, not read-marking
  4. Sequence the client as deliver -> present -> mark_read, never mark_read on store visibility alone

Example fix

// before
manager.mark_agent_mail_read(thread_id, &msg.id).await?; // may run while status == Queued

// after
let envelope = manager.load_agent_mail(&msg.id).await?;
if matches!(envelope.status, AgentMailStatus::Delivered | AgentMailStatus::Read) {
    manager.mark_agent_mail_read(thread_id, &msg.id).await?;
}
Defensive patterns

Strategy: validation

Validate before calling

// Gate on status before marking read.
let envelope = manager.load_agent_mail(message_id).await?;
match envelope.status {
    AgentMailStatus::Delivered | AgentMailStatus::Read => {
        manager.mark_agent_mail_read(thread_id, message_id).await?;
    }
    _ => {/* still queued/delivering/failed: not readable yet */}
}

Type guard

fn is_readable(status: AgentMailStatus) -> bool {
    matches!(status, AgentMailStatus::Delivered | AgentMailStatus::Read)
}

Prevention

When it happens

Trigger: Calling mark_read on a just-queued envelope before deliver_agent_mail projected it into the destination turn; racing the delivery state machine; or marking read a permanently Failed envelope. Match at runtime_threads.rs:3708-3717 (AgentMailStatus in protocol/src/agent_mail.rs:220-226).

Common situations: A mail client optimistically marking messages read on render while delivery is still in flight; retry storms that read before deliver; polling loops that treat 'visible in store' as 'delivered'.

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@0c42157ee5 (2026-08-20). Data as JSON: /api/errors/3a2e87ece72550a4. Report an issue: GitHub.