fix(tui): autonomous background process completion notifications (#26071) (#26327)

* feat(process-registry): add format_process_notification shared helper

* feat(process-registry): add drain_notifications method

* refactor(cli): use shared drain_notifications and format_process_notification

* feat(tui): add background notification poller for completion_queue

* feat(tui): wire notification poller into session init/finalize

* refactor(tui): add post-turn drain using shared helper as safety net
This commit is contained in:
Siddharth Balyan 2026-05-15 19:31:00 +05:30 committed by GitHub
parent db84a78e61
commit d5416284f1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 486 additions and 55 deletions

View file

@ -287,6 +287,9 @@ def _finalize_session(session: dict | None, end_reason: str = "tui_close") -> No
if not session or session.get("_finalized"):
return
session["_finalized"] = True
stop_event = session.get("_notif_stop")
if stop_event is not None:
stop_event.set()
agent = session.get("agent")
lock = session.get("history_lock")
@ -579,6 +582,7 @@ def _start_agent_build(sid: str, session: dict) -> None:
pass
_wire_callbacks(sid)
_sessions[sid]["_notif_stop"] = _start_notification_poller(sid, _sessions[sid])
_notify_session_boundary("on_session_reset", key)
info = _session_info(agent)
@ -1955,6 +1959,7 @@ def _init_session(sid: str, key: str, agent, history: list, cols: int = 80):
# session startup resilient).
pass
_wire_callbacks(sid)
_sessions[sid]["_notif_stop"] = _start_notification_poller(sid, _sessions[sid])
_notify_session_boundary("on_session_reset", key)
_emit("session.info", sid, _session_info(agent))
@ -3027,6 +3032,105 @@ def _(rid, params: dict) -> dict:
return _ok(rid, {"status": "streaming"})
def _notification_poller_loop(
stop_event: threading.Event, sid: str, session: dict
) -> None:
"""Poll completion_queue and dispatch notifications autonomously.
Runs in a daemon thread started by _init_session(). Emits a
status.update (kind=process) for user visibility, then chains an
agent turn via _run_prompt_submit if the session is idle.
NOTE: The completion_queue is global (one per process). If multiple
TUI sessions coexist, whichever poller wakes first grabs the event,
even if the process was started by a different session. This matches
CLI/gateway behavior (single session per process).
"""
from tools.process_registry import process_registry, format_process_notification
while not stop_event.is_set() and not session.get("_finalized"):
try:
evt = process_registry.completion_queue.get(timeout=0.5)
except Exception:
continue
_evt_sid = evt.get("session_id", "")
if evt.get("type") == "completion" and process_registry.is_completion_consumed(_evt_sid):
continue
text = format_process_notification(evt)
if not text:
continue
_emit("status.update", sid, {"kind": "process", "text": text})
with session["history_lock"]:
if session.get("running"):
process_registry.completion_queue.put(evt)
continue
session["running"] = True
rid = f"__notif__{int(time.time() * 1000)}"
try:
_emit("message.start", sid)
_run_prompt_submit(rid, sid, session, text)
except Exception as exc:
print(
f"[tui_gateway] notification poller dispatch failed: "
f"{type(exc).__name__}: {exc}",
file=sys.stderr,
)
with session["history_lock"]:
session["running"] = False
# Drain any remaining events after stop signal (process all pending
# before exiting so nothing is lost on shutdown).
while not process_registry.completion_queue.empty():
try:
evt = process_registry.completion_queue.get_nowait()
except Exception:
break
_evt_sid = evt.get("session_id", "")
if evt.get("type") == "completion" and process_registry.is_completion_consumed(_evt_sid):
continue
text = format_process_notification(evt)
if not text:
continue
_emit("status.update", sid, {"kind": "process", "text": text})
with session["history_lock"]:
if session.get("running"):
process_registry.completion_queue.put(evt)
break
session["running"] = True
rid = f"__notif__{int(time.time() * 1000)}"
try:
_emit("message.start", sid)
_run_prompt_submit(rid, sid, session, text)
except Exception as exc:
print(
f"[tui_gateway] notification poller dispatch failed: "
f"{type(exc).__name__}: {exc}",
file=sys.stderr,
)
with session["history_lock"]:
session["running"] = False
def _start_notification_poller(sid: str, session: dict) -> threading.Event:
"""Start the background notification poller for a TUI session."""
stop = threading.Event()
t = threading.Thread(
target=_notification_poller_loop,
args=(stop, sid, session),
daemon=True,
)
t.start()
return stop
def _run_prompt_submit(rid, sid: str, session: dict, text: Any) -> None:
with session["history_lock"]:
history = list(session["history"])
@ -3385,6 +3489,36 @@ def _run_prompt_submit(rid, sid: str, session: dict, text: Any) -> None:
with session["history_lock"]:
session["running"] = False
# Drain completion notifications that arrived during this turn.
# The background poller handles between-turn delivery; this is
# the safety net for events that arrived mid-turn.
try:
from tools.process_registry import process_registry
for _evt, synth in process_registry.drain_notifications():
with session["history_lock"]:
if session.get("running"):
process_registry.completion_queue.put(_evt)
break
session["running"] = True
try:
_emit("message.start", sid)
_run_prompt_submit(rid, sid, session, synth)
except Exception as _n_exc:
print(
f"[tui_gateway] completion notification dispatch failed: "
f"{type(_n_exc).__name__}: {_n_exc}",
file=sys.stderr,
)
with session["history_lock"]:
session["running"] = False
except Exception as _drain_exc:
print(
f"[tui_gateway] completion queue drain failed: "
f"{type(_drain_exc).__name__}: {_drain_exc}",
file=sys.stderr,
)
threading.Thread(target=run, daemon=True).start()