When several detached background commands finish close together, each one spawns its own full streaming resume turn on the SAME session. Every turn opens and edits its own flow block in the same chat, so the blocks duplicate and overlap instead of one block updating in place.
Evidence
Five resume_session calls on one session inside 850ms, on four different threads:
16:07:52.088 ThreadId(03) resume_session a1a11b73... with full streaming pipeline
16:07:52.282 ThreadId(03) resume_session a1a11b73... with full streaming pipeline
16:07:52.437 ThreadId(05) resume_session a1a11b73... with full streaming pipeline
16:07:52.606 ThreadId(11) resume_session a1a11b73... with full streaming pipeline
16:07:52.764 ThreadId(11) resume_session a1a11b73... with full streaming pipeline
Fifteen more on the same session over the following two minutes.
Mechanism
build_enqueue_callback in src/channels/telegram/resume.rs does a bare tokio::spawn per finished task:
Arc::new(move |session_id, msg| {
tokio::spawn(async move {
...
resume_session(bot, chat_id, thread_id, session_id, msg.context_text, agent, state).await
});
})
There is no per-session gate. resume.rs never calls try_begin_turn, so the background path bypasses the concurrency gate that both user messages (handler.rs:3237) and reactions (handler.rs:4244) go through. Nothing serializes the resumes and nothing tells a second one that a turn is already streaming.
Fix
Claim the turn before resuming, and enqueue rather than fork when the session is busy:
try_begin_turn(session_id) returns a guard: run the resume holding it, so a concurrent completion sees the session as busy.
- It returns
None: push the result onto the existing per-session queue via enqueue_reaction. The tool loop drains that queue between rounds through reaction_queue_callback, which is registered on the AgentService at manager.rs:146 and therefore applies to any in-flight turn on the session, resume turns included.
The result lands inside the block that is already open instead of opening a second one, which is the requested behaviour: one block per session, edited in place.
Scope this to build_enqueue_callback rather than to resume_session itself, so startup crash-recovery resume keeps its current behaviour.
Impact
- Duplicate, overlapping flow blocks whenever more than one background task finishes at once
- Concurrent turns on a single session racing to edit the same chat
- Background results interleaved across blocks rather than reported in one place
When several detached background commands finish close together, each one spawns its own full streaming resume turn on the SAME session. Every turn opens and edits its own flow block in the same chat, so the blocks duplicate and overlap instead of one block updating in place.
Evidence
Five
resume_sessioncalls on one session inside 850ms, on four different threads:Fifteen more on the same session over the following two minutes.
Mechanism
build_enqueue_callbackinsrc/channels/telegram/resume.rsdoes a baretokio::spawnper finished task:There is no per-session gate.
resume.rsnever callstry_begin_turn, so the background path bypasses the concurrency gate that both user messages (handler.rs:3237) and reactions (handler.rs:4244) go through. Nothing serializes the resumes and nothing tells a second one that a turn is already streaming.Fix
Claim the turn before resuming, and enqueue rather than fork when the session is busy:
try_begin_turn(session_id)returns a guard: run the resume holding it, so a concurrent completion sees the session as busy.None: push the result onto the existing per-session queue viaenqueue_reaction. The tool loop drains that queue between rounds throughreaction_queue_callback, which is registered on the AgentService atmanager.rs:146and therefore applies to any in-flight turn on the session, resume turns included.The result lands inside the block that is already open instead of opening a second one, which is the requested behaviour: one block per session, edited in place.
Scope this to
build_enqueue_callbackrather than toresume_sessionitself, so startup crash-recovery resume keeps its current behaviour.Impact