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 onwrite_v2()— fires). - But the live drain
replay_pending_steers(crates/alice/src/connector_bridge/stages/steer_routing.rs:317) is gatedif !read_v2() { return; }(line 322) — underdual_writethis 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).
- Idle + message → brief coalesce window (~1.5s) to batch rapid-fire parts into ONE thought → Thinking on the batch.
- Thinking + message → append to the queue (do not interrupt the turn).
- Turn finishes → queue non-empty? drain all (one batch) → Thinking on the batch → loop until empty. Empty → Idle.
- 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)
| Piece | Where | Status |
|---|---|---|
| Entry coalesce window (Idle→Bundling→batch, ~1.5s) | connector_bridge/stages/coalesce.rs → openalice-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-in | CoalesceRestart strategy → steer mailbox → drain_and_inject_steers per agentic-loop iteration (api-handlers/.../agentic_loop.rs) | ✅ done |
| Crash recovery | boot_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
PendingAfterQueuefor the conv inside thewrite_v2()branch. - `drain_pending` is take-all under a write lock →
replay_pending_steersand 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; confirmpending_queuedrains to empty + theb_queue: draining...log. - Prod deploy only on NAO's explicit go.