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

matrix: room {recipient} is not in joined state

Error message

matrix: room {recipient} is not in joined state

What it means

resolve_joined_room is the pre-send gate: it resolves the recipient to a room id, looks the Room up in the SDK cache (a missing room fails earlier as 'matrix: bot is not in room'), then requires room.state() == RoomState::Joined. A room in Invited, Left, Knocked or Banned state aborts here, because Matrix only lets a client send events to rooms it has joined.

Source

Thrown at crates/zeroclaw-channels/src/matrix.rs:3760

    pub(super) async fn resolve_joined_room(
        client: &Client,
        cache: &Arc<TokioRwLock<HashMap<String, OwnedRoomId>>>,
        recipient: &str,
    ) -> Result<Room> {
        let id = client::resolve_room(client, cache, recipient).await?;
        let room = client.get_room(&id).ok_or_else(|| {
            ::zeroclaw_log::record!(
                ERROR,
                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
                    .with_outcome(::zeroclaw_log::EventOutcome::Failure)
                    .with_attrs(::serde_json::json!({"recipient": recipient})),
                "matrix: bot is not in room"
            );
            anyhow::Error::msg(format!("matrix: bot is not in room {recipient}"))
        })?;
        if room.state() != RoomState::Joined {
            bail!("matrix: room {recipient} is not in joined state");
        }
        Ok(room)
    }

    #[derive(Clone, Copy, Debug, PartialEq, Eq)]
    pub(super) enum AttachmentKind {
        Auto,
        Image,
        Audio,
        Video,
        File,
        Voice,
    }

    async fn upload_attachment(
        room: &Room,
        att: &MediaAttachment,
        kind: AttachmentKind,

View on GitHub (pinned to 88bb9c8533)

Solutions

  1. Ensure invites are accepted before dispatch: join invited rooms when the invite state appears, or have an admin re-invite the bot and let it join.
  2. If the bot was kicked or left, get it re-invited, re-join, then retry the send.
  3. After startup, wait for the first sync to complete before dispatching, or retry the send once after a short delay so room state hydrates.
  4. Confirm the recipient resolves to the room the bot actually occupies - parallel rooms can share a similar alias.

Example fix

// before: send immediately after startup
let room = resolve_joined_room(&client, &cache, recipient).await?;

// after: accept pending invites and let sync settle first
for invited in client.invited_rooms() {
    invited.join().await.ok();
}
tokio::time::sleep(std::time::Duration::from_secs(2)).await; // allow state sync
let room = resolve_joined_room(&client, &cache, recipient).await?;
Defensive patterns

Strategy: validation

Validate before calling

async fn ensure_joined(client: &Client, room_id: &str) -> anyhow::Result<()> {
    let id: OwnedRoomId = room_id.parse()?;
    let room = client
        .get_room(&id)
        .ok_or_else(|| anyhow::anyhow!("room {room_id} not in cache"))?;
    if room.state() != RoomState::Joined {
        room.join().await?; // accepts a pending invite or re-joins a left room
        anyhow::ensure!(room.state() == RoomState::Joined, "still not joined: {room_id}");
    }
    Ok(())
}

Prevention

When it happens

Trigger: Sending or reacting when the bot's cached state for the room is anything but Joined: an invite not yet accepted (Invited), the bot left or was kicked (Left/Banned), or the state cache has not hydrated from the initial sync yet at startup.

Common situations: Freshly invited bot whose auto-join has not run or is disabled; bot kicked mid-conversation while queued messages were pending; sends issued immediately after startup before the first sync completes; stale room cache after re-login.

Related errors


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