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
- Only call mark_read for envelopes whose status is Delivered (or Read, for idempotency) - check status via list/load first
- If delivery is pending, drive deliver_agent_mail to completion, then mark read
- For Failed envelopes, inspect envelope.failure: retryable failures need re-delivery, not read-marking
- 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
- Model the status machine: Queued -> Delivering -> Delivered -> Read; Failed is terminal until retried
- Never mark read based on store visibility alone - wait for the delivered event
- For Failed envelopes, inspect envelope.failure.retryable and re-drive delivery instead
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
- Agent Mail ownership denied: message does not belong to this
- terminal lane transition requires a terminal status
- Trigger '{trigger_id}' cannot be canceled (status: {:?})
- Agent Mail accepts a bounded handoff summary, not a raw tran
- Agent Mail source and destination threads must differ
AI-assisted analysis of Hmbown/CodeWhale@0c42157ee5 (2026-08-20).
Data as JSON: /api/errors/3a2e87ece72550a4.
Report an issue: GitHub.