fix(chat): cancel orphaned generation on client disconnect

Closing the QuickChat browser tab mid-response left the backend agent
running with no listener. The `activeGenerations` slot persisted and the
next message's freshly-opened CLI SessionManager raced against the
lingering agent on the same session file, so the model produced no
output for the new prompt.

- POST /chat/sessions/:id/messages now calls cancelGeneration on
  req.on("close") if the response hasn't ended yet, so disconnects stop
  the agent promptly.
- beginGeneration only aborts the prior controller; it no longer
  disposes the prior agent. Disposing pre-emptively could yank the CLI
  process out from under the new generation's session opened on the
  same file.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-05-05 09:04:14 -07:00
parent 9239707f1f
commit e4e4e84baa
3 changed files with 23 additions and 7 deletions

View File

@@ -564,16 +564,18 @@ export class ChatManager {
* first so subscription and broadcast generationIds are tied together.
*/
beginGeneration(sessionId: string): { generationId: number; abortController: AbortController } {
// If a previous generation is still tracked (e.g. its browser disconnected
// mid-stream and its agent loop hasn't reached `finally` yet), abort its
// controller so it stops issuing further prompts/tool calls that would
// race against the new generation for the same CLI session file.
//
// We deliberately do NOT dispose its agent here — the previous generation
// owns its own dispose in its `finally`. Calling dispose pre-emptively can
// yank the underlying CLI process out from under the new generation's
// freshly-opened SessionManager pointing at the same session file.
const existing = this.activeGenerations.get(sessionId);
if (existing) {
existing.abortController.abort();
if (existing.agentResult) {
try {
existing.agentResult.session.dispose?.();
} catch (err) {
diagnostics.error(`Error disposing previous agent session during pre-emption:`, err);
}
}
}
this.generationCounter += 1;
const generationId = this.generationCounter;