kb://architecture/LANE_QUEUE_IDEAL_B_2026-06-15active2026-06-17

Lane Queue — the Claude Code ideal model (path B), 2026-06-15

openalicedesignalice-core

Lane Queue — the "Claude Code" ideal model (path B), 2026-06-15

Author: session 2026-06-15 (NAO-directed). Status: design approved (model); implementation on a branch + lab before any prod deploy (prod-Alice hold active).

The bug (live, reproduced by NAO)

In a Telegram chat, when a message is split into several quick parts (a coalesced batch in one lane), Alice answers only the first; the rest stay stuck — no reply, no typing indicator — until the process restarts, at which point boot_recovery_v2 drains + answers them.

Root cause (verified in code)

Prod runs OPENALICE_LANE_V2=dual_write (write_v2()=true, read_v2()=false).

  • QueueAfter writes late-arriving messages into the v2 lane pending_queue (mirror_push_pending, gated on write_v2() — fires).
  • But the live drain replay_pending_steers (crates/alice/src/connector_bridge/stages/steer_routing.rs:317) is gated if !read_v2() { return; } (line 322) — under dual_write this is a no-op.

→ The queue fills but never drains live; only boot_recovery_v2 drains it at restart. Write gate (`write_v2`) and drain gate (`read_v2`) are mismatched.

The ideal model (NAO-approved) — Claude-Code-style, per lane = (chat, speaker)

States: Idle / Thinking + ONE FIFO pending_queue (single source of truth).

  1. Idle + message → brief coalesce window (~1.5s) to batch rapid-fire parts into ONE thought → Thinking on the batch.
  2. Thinking + message → append to the queue (do not interrupt the turn).
  3. Turn finishes → queue non-empty? drain all (one batch) → Thinking on the batch → loop until empty. Empty → Idle.
  4. Hybrid "mid-think fold-in" (timing-decided): while still Thinking, at safe checkpoints (between tool/reasoning steps, before finalizing the reply) Alice peeks the queue; a same-request follow-up is folded into the current turn → ONE combined answer. If the turn is already committed/sending → it rides the next batch → a second answer.

Guarantees: every message answered (critical in Worker mode), in order, batched; nothing stuck until restart. Timing decides one-answer vs two — exactly the two cases NAO described.

What already exists (the ideal is mostly built)

PieceWhereStatus
Entry coalesce window (Idle→Bundling→batch, ~1.5s)connector_bridge/stages/coalesce.rsopenalice-coalesce::coalesce()✅ done
Single v2 pending_queue + Processing-park (M4d)coalesce/src/lane_v2_types.rs add_inbound✅ done
drain_pending (take-all FIFO)coalesce/src/lane_registry_v2.rs✅ done
Mid-think fold-inCoalesceRestart strategy → steer mailbox → drain_and_inject_steers per agentic-loop iteration (api-handlers/.../agentic_loop.rs)✅ done
Crash recoveryboot_recovery_v2 moves frozen bundle → pending_queue✅ done

The change set (minimal, safe, reversible) — 3 changes

Change 1 — fix the drain gate (1 line). steer_routing.rs:322: read_v2()write_v2(). Now the "ride-next-inbound" drain fires under dual_write. (Reversible: OPENALICE_LANE_V2=off disables both the v2 write-mirror and the drain.)

Change 2 — post-turn drain LOOP (modules/mod_events.rs, after the turn task's handle_message_rich_*.await). Under write_v2(): discard the parallel v1 PendingAfterQueue for this conv (so it can't replay as duplicates), then loop { drain_pending(lane_key); if empty break; combine \n\n; re-enter pipeline } until the queue is empty. Else-branch keeps the existing v1 drain for off. This is what makes "answer first, then the rest batched, looping until drained" real.

Change 3 — regression test. Clone drain_pending_re_injects_on_next_inbound with dual_write + new integration tests (lane_v2_post_turn_drain_queue_b.rs): split→all-answered-batched, rapid→folded-into-one, during-think→next-batch, drain-loop-until-empty.

Mode decision

Stay `dual_write` + fix the gate. Do NOT flip to v2_read/v2_only yet — that changes the live read path (bigger blast radius). The gate fix + drain loop make v2 the live drain source while v1 stays as the off-mode fallback. Promote to v2_read later as a separate env-only step once lab-validated.

Risks + mitigations

  • Double-drain (v1 + v2 same items under dual_write): mitigated by discarding the v1 PendingAfterQueue for the conv inside the write_v2() branch.
  • `drain_pending` is take-all under a write lockreplay_pending_steers and the post-turn loop cannot double-drain (second caller gets an empty Vec).
  • Personality non-regression: the \n\n-join + full-pipeline re-entry is byte-identical to today's v1 drain; persona/soul rebuilt fresh per re-entry.
  • Worker mode: the loop runs until empty → no message left unanswered.

Validation

  • cargo test -p openalice-alice --lib (steer_routing gate test) · cargo test -p openalice-coalesce --lib · cargo test -p openalice-integration-tests -j4 --test-threads=4 (the 4 new lane tests).
  • Lab: OPENALICE_LANE_V2=dual_write, deploy to lab, send split messages (msg2 while Thinking on msg1) → expect a combined or batched-second reply, never stuck; confirm pending_queue drains to empty + the b_queue: draining... log.
  • Prod deploy only on NAO's explicit go.