Thanks to visit codestin.com
Credit goes to github.com

Skip to content

fix(agent-ui): keep chat runs alive after New Task + show running indicator (#1580)#1656

Merged
kovtcharov-amd merged 1 commit into
mainfrom
worktree-fix-1580-running-status
Jun 15, 2026
Merged

fix(agent-ui): keep chat runs alive after New Task + show running indicator (#1580)#1656
kovtcharov-amd merged 1 commit into
mainfrom
worktree-fix-1580-running-status

Conversation

@kovtcharov-amd

Copy link
Copy Markdown
Collaborator

Why this matters

Follow-up to #1584. Before: clicking New Task (or switching sessions) while an agent was still generating killed the run — the SSE was torn down on unmount, the backend cancelled the turn, and the in-flight answer was discarded with no sign it had ever been running. @sdevinenamd reported the visible half ("losing the status bar… hard to tell if the original agent is still running") while waiting on the email-triage agent. After: the run keeps going in the background, finishes and persists on its own, the sidebar shows a spinner on any session with a live run, and re-opening that session re-attaches to its live stream so progress resumes.

A chat turn is now a first-class background run (RunManager) that outlives the SSE connection: the producer + persistence run in a detached task, and the SSE endpoint is a thin subscriber over a replay buffer with multi-subscriber fan-out. GET /api/chat/active is the backend-truth source for the sidebar indicator (survives refresh/revisit); GET /api/chat/attach reconnects to an in-flight run. Because a disconnect no longer cancels, Stop now calls the cancel endpoint explicitly, and deleting a session cancels its run so it can't persist to a dead session.

Test plan

  • pytest tests/unit/chat/ui/ — 720 passed (1 pre-existing Windows cp1252 flake in an unrelated agent.py-reading test, deselected). Includes new test_run_manager.py (run survives subscriber disconnect & persists, late-subscriber replay, active list, overlap-409, cancel) and test_chat_active_attach.py (active/attach endpoints, send-409-while-running, delete-cancels-run).
  • tsc --noEmit — clean on all app code; vite build — clean.
  • Live Agent UI (real backend + Lemonade/Gemma-4-E4B): started a long run → clicked New Task mid-stream → new session opened clean, old session kept its "Agent running" spinner → revisited it → live stream resumed → run completed in the background, answer persisted, session auto-titled, spinner cleared.
  • vitest run (added chatStore running-sessions cases) — runs in CI; not executed locally (vitest absent from the local env).

…icator (#1580)

Clicking New Task (or switching sessions) while an agent was still
generating used to abort the run: the SSE was torn down on the old view's
unmount, the backend cancelled the turn, and the in-flight answer was
discarded with no trace it had ever been running. The user lost both the
work and any way to tell the original agent was still going.

Now a chat turn is a first-class background run that outlives the SSE
connection. Navigating away leaves it running server-side; it finishes and
persists to the DB on its own. The sidebar shows a spinner on any session
with a live run (backend-truth, so it survives refresh and revisit), and
re-opening a running session re-attaches to its live stream so progress
resumes in the view.

- RunManager owns each run (producer thread + persistence) in a detached
  task; the SSE endpoint is now a thin subscriber over a replay buffer with
  multi-subscriber fan-out.
- GET /api/chat/active surfaces running session ids; GET /api/chat/attach
  reconnects to an in-flight run.
- Stop now calls the cancel endpoint explicitly (a client disconnect no
  longer cancels the run), and deleting a session cancels its run so it
  can't persist to a dead session.
@github-actions

Copy link
Copy Markdown
Contributor

Summary

Solid, well-tested fix for the #1580 follow-up: a chat turn becomes a first-class RunManager/Run that outlives the SSE connection, so navigating away no longer kills the agent, and the sidebar + re-attach give users a clear "still running" signal. The refactor is clean — extracting consumeSSEResponse so /send and /attach parse events identically is the right call, and the RunManager unit tests cover the genuinely tricky paths (survives subscriber disconnect, late-subscriber replay, overlap-409, cancel). The single thing worth confirming before merge is the delete-while-running race: delete_session removes the DB session before run_manager.cancel, and cancel is a best-effort flag the producer only observes at its next step boundary — so an in-flight run can still persist an assistant message to a session that no longer exists.

No critical or security issues. The threading-atomicity reasoning in run_manager.py (synchronous emit/subscribe/finish, no await between read and write of shared state) is sound for the single-threaded event loop.

Issues

🟡 Important

Delete-while-running can persist to a dead session (sessions.py:139-146, run_manager.py:1056)
delete_session deletes the session row first, then calls run_manager.cancel. Two gaps let a write slip through to the now-deleted session:

  1. cancel() only sets the flag if run.handler is not None. handler is assigned partway into _stream_chat_impl, so a run that's registered but hasn't reached that line yet returns cancel() → True while setting nothing — the run proceeds uncancelled.
  2. Even when the flag is set, the producer only observes it "at its next step boundary," so a run mid-final-generation can still db.add_message(session_id, "assistant", …) after the row is gone (orphan row, or FK error if enforced).

For the common path this is narrow, but data integrity on delete is worth hardening. Options: have the producer re-check session existence before its final persist, or gate add_message on the run not being cancelled. At minimum, confirm the orphaned-row behavior is harmless in your schema and note it. Not exploitable, just a correctness edge.

🟢 Minor

  • Inconsistent import placement for run_manager (sessions.py:144). chat.py:25 imports it at module level; here it's a function-local import. run_manager.py only pulls stdlib, so there's no circular-import reason for the local form — move it to the top for consistency.
  • Replay buffer is unbounded for the run's lifetime (run_manager.py:944). Bounded by run duration and dropped on completion, so fine in practice; just flagging that a pathologically long agent run holds every SSE string in memory until it finishes.
  • getActiveRuns/RunManager assume a single backend process. Consistent with the existing module-global _active_sse_handlers, and the Agent UI is a local single-process app — so this is correct here. Worth a one-line comment that the registry is process-local if multi-worker is ever on the table.
  • ChatView re-attach effect keys on [sessionId] only (ChatView.tsx). If a run starts on the session you're already viewing (e.g. triggered from another device), the global poll updates the spinner but this effect won't re-fire to auto-attach. Out of scope for the revisit flow this PR targets — noting for completeness.

Strengths

  • consumeSSEResponse extraction removes a ~90-line duplicated SSE parse loop and guarantees /send and /attach stay in lockstep — exactly the right refactor to land alongside the new endpoint.
  • Test coverage targets the hard parts, not just the happy path: test_run_survives_subscriber_disconnect, test_late_subscriber_gets_full_replay, test_subscribe_after_done_terminates_immediately, and the overlap/cancel cases all assert the registry's real contract. The endpoint tests also cover send-409-while-running and delete-cancels-run.
  • The 409 guard extension is the correct invariant (chat.py:100): session_lock.locked() or run_manager.is_running(sid) closes the window where the HTTP lock is released but the background run is still mutating the cached agent's conversation state. The comment explains why clearly.
  • Reference-stable setRunningSessions (chatStore.ts:150) avoids re-rendering the sidebar on every 2.6s poll tick — and is backed by an explicit test.

Verdict

Approve with suggestions. No blocking issues. Please confirm (or guard) the delete-while-running persistence race before merge — if the orphaned-row outcome is benign in your schema, a one-line note suffices; otherwise a re-check before the producer's final add_message would close it. The minor items are cleanup-at-leisure.

@kovtcharov-amd kovtcharov-amd added this pull request to the merge queue Jun 15, 2026
Merged via the queue into main with commit 41e67d3 Jun 15, 2026
32 checks passed
@kovtcharov-amd kovtcharov-amd deleted the worktree-fix-1580-running-status branch June 15, 2026 18:15
pull Bot pushed a commit to bhardwajRahul/gaia that referenced this pull request Jun 17, 2026
The Agent UI Vitest suite was silently broken on `main`: any PR touching
a `webui/` path (e.g. amd#1694, an installer fix) fails CI with `TypeError:
Cannot read properties of undefined (reading 'then')` in
`ChatView.test.tsx`, even when the PR has nothing to do with the UI.
This unblocks those PRs.

Root cause: amd#1656 added an `api.getActiveRuns().then(...)` call to
`ChatView`'s mount effect but never stubbed `getActiveRuns` in the
test's auto-mocked api module, so it returned `undefined`. `main`'s own
Vitest is path-filtered and hadn't re-run since the regression landed,
so it stayed latent until a webui-touching PR triggered it.

## Test plan
- [ ] `cd src/gaia/apps/webui && npx vitest run` — all 62 tests pass
- [ ] `npx vitest run src/components/__tests__/ChatView.test.tsx` — the
previously-failing test passes
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

electron Electron app changes tests Test changes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants