diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 5ac7ccf5d6378..245394cdc982c 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -4574,24 +4574,22 @@ def _run_prompt_submit(rid, sid: str, session: dict, text: Any) -> None: pass _clear_session_context(session_tokens) with session["history_lock"]: - session["running"] = False + if not goal_followup: + # Only release the turn guard when there is no followup + # to chain. When a goal continuation exists we keep + # running=True so no other thread (notification poller, + # concurrent prompt.submit) can steal the turn during the + # gap between this finally and the followup dispatch below. + session["running"] = False session["last_active"] = time.time() _clear_inflight_turn(session) _emit("session.info", sid, _session_info(agent, session)) - # Chain a goal-continuation turn if the judge said so. We do - # this AFTER the finally releases session["running"], so the - # nested _run_prompt_submit doesn't deadlock on the busy - # guard. A real user prompt that races us wins because - # prompt.submit sets running=True under the history_lock and - # we check that guard before re-firing. + # Chain a goal-continuation turn if the judge said so. Because the + # finally block above intentionally kept running=True when a followup + # exists, there is no TOCTOU window — no other thread can sneak in + # and start a concurrent turn on this session. if goal_followup: - with session["history_lock"]: - if session.get("running"): - # User already sent something — their turn wins, - # the judge will re-run on the next turn anyway. - return - session["running"] = True try: _emit("message.start", sid) _run_prompt_submit(rid, sid, session, goal_followup)