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
- Check envelope.status == Delivered before calling the mark-read operation.
- Deliver the mail first (run the deliver flow) and then mark it read.
- If the mail was canceled, treat it as terminal and do not attempt read transitions.
- 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
- Always deliver before offering a 'mark read' action in the UI.
- Treat Read/Canceled as terminal states and disable further transitions.
- Serialize mail mutations through the mail_mutation lock to avoid racing delivery.
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
- Agent Mail can be canceled only while queued
- terminal lane transition requires a terminal status
- Cannot continue agent
- Cannot follow up agent
- Cannot resume agent : status is ( )
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)