Notice when a Bot stops talking, instead of spinning forever - #19
Merged
davidmckayv merged 3 commits intoAug 20, 2026
Merged
Conversation
A Bot is any AG-UI endpoint, so it is infrastructure this deployment does not run: it gets redeployed mid-answer, its own upstream times out, and it will happily hold a connection open and write nothing more. Nothing here noticed. The channel stayed busy, the composer stayed locked, and the only way out was to reload the page or restart the server, which is a poor thing to ask of somebody who is looking at a Bot that appears to be thinking and never was. The watch is on activity, not on duration. A turn may legitimately run for an hour while events keep arriving; a turn whose stream has produced nothing at all for the configured timeout is wedged. A duration limit would cap how much work a Bot is allowed to do, which nobody asked for. This caps how long a person is asked to watch a spinner, which is the actual complaint. It sits on the Bot's own response body rather than on the reply to the browser, because in Intelligence mode that reply is a JSON envelope with a fixed length and the AG-UI events reach the browser over a WebSocket to the gateway. The Bot's response is the stream that stalls and the only place a stall can be seen. The wrapper is a pass-through transform that counts chunks and does nothing else: it must not buffer, must not delay a chunk and must not read the bytes, because a watchdog that can misread a working run into a broken one is worse than the failure it was added for. On a stall it writes one RUN_ERROR into the same stream and closes it. Both surfaces already understand that event, so nobody downstream had to learn a new one; the sentence it carries names the Bot and says what happened. The channel now draws that sentence at the end of the transcript rather than above the composer, which is where the missing answer was going to be and where the person is already looking. No exemption exists for a frontend tool call, and none is needed. The run ends before the browser executes one: the Bot emits its tool calls and RUN_FINISHED, its stream closes, the browser runs the tool and a second run carries the result back. A browser tool that takes ten minutes holds no stream open. AGENT_STALL_TIMEOUT_MS configures it, and zero or an unset variable leaves every stream alone. A turn that is ended is a turn somebody loses, so an existing deployment does not acquire that behaviour without asking for it; .env.example ships two minutes, which is already what a Bot in this repository treats as the outer bound of a quiet connection. Each stall writes an agent.stream_stalled row, because one hung turn reads as a bad afternoon and the same Bot hanging twice a day for a month is a fact about an endpoint that only becomes visible when somebody can count it.
The clock was kept in the wrong place. `watchdog.record` was called from the relay's transform callback, and a TransformStream runs its transform only once the readable side is being pulled, so what was being timed was the moment the CONSUMER took a chunk rather than the moment the Bot produced one. A reader that paused for longer than the timeout therefore looked exactly like a Bot that had gone silent: the run was ended, the audit row said the endpoint had sent nothing, and the person was told their Bot had stopped responding while it was streaming the whole time. In this deployment that reader is the Intelligence runner publishing every event on to the gateway over the network, which is precisely the sort of thing that pauses. The pump now times the resolution of each read from the Bot, and stops the clock across the handover to the relay, so the only quiet ever counted is quiet on the wire. The recovery no longer waits on the database. The audit row was written before the stream was touched, on the argument that a record must not depend on a promise a broken stream owes us; but the writes it was protecting are queued and unawaited anyway, while the insert it moved in front of them is a bare statement against the pool every other write shares, with no deadline of its own. A saturated pool or an unreachable Postgres is exactly the condition a Bot is most likely to hang in, and in that condition the watchdog fired and then parked, leaving the spinner and the locked composer it exists to end. The socket is now released, the sentence queued and the stream closed first, and the row written after. The direct Bot chat says something now. The channel drew the sentence at the end of its transcript; the other surface drew nothing at all, because the banner it was assumed to have belongs to a provider this app does not mount and is suppressed even there unless the dev console is on. It now watches the same runs the chat starts and draws a line under the page header. Not where the missing answer was going to be, which is where the channel puts it, but the packaged chat owns and virtualises its message list and reaching into it means taking on its scrolling. Both surfaces fall back to the same sentence from the same place, so a person who uses both is not told two different things about the same silence. A stream is now judged by the rule the client actually applies. The guard would only write into `text/event-stream`, which is stricter than @ag-ui/client, and being stricter was not the safe direction it was argued to be: a Bot serving events under any other content type had its stream closed with nothing in it, so the run ended and nobody was told anything on either surface. The client treats everything except the protobuf media type as server-sent events, and so does this now. The shipped timeout drops to a minute, because two minutes was in a race it could lose. Every Bot in this repository serves on Bun with `idleTimeout: 120`, and Bun tears a wedged streaming response down about a second past that; a watchdog set to the same two minutes lands within a second of the socket dying, and when it lost the person got a transport error instead of the designed sentence and the trail got no row. Half the Bot's own idle timeout is far enough clear to be deterministic, and is still far longer than any real silence inside a run. Three smaller things on the trail and around it. A stalled turn takes the same colour as an action that did not happen, rather than the muted one that reads as "Allowed", and it joins the "Did not happen" filter. The two numbers the row exists to carry are drawn: how long the stream was silent, and whether the Bot had managed to say anything first, which is the difference between an endpoint that dies mid-answer and one that never begins. And the test asserting an unwatched Bot's fetch was left alone was asserting nothing, since @ag-ui/client fills that field in either way; it now tells the two apart with a sentinel.
davidmckayv
previously approved these changes
Aug 19, 2026
davidmckayv
left a comment
Contributor
There was a problem hiding this comment.
Reviewed against the code and run locally. Full suite passes on a clean database (bun run test:ci), format, lint and typecheck clean.
No security concerns: this touches no auth boundary and nothing it adds can be reached without going through the gateway.
jerelvelarde
marked this pull request as ready for review
August 19, 2026 23:48
jerelvelarde
requested review from
MikeRyanDev,
guidovizoso and
tylerslaton
as code owners
August 19, 2026 23:48
Main grew a queue in the channel composer and a repeat count on the boundary while this branch was open, and both of them landed in the files the watchdog threads its `stopped` prop through. The three channel components now carry both. `ChatTranscript` draws the red line where `Thinking` was and the parked messages under it; `ConversationView` takes `stopped` alongside `queueWhileBusy` and `stoppable` and passes each to the half that wants it; `ChannelChat` hands down `runError` as well as the two counters main added. A turn the watchdog ends is a turn that ended, so the queue drains on it exactly as it does on Stop. Nothing was needed to make that true: the watchdog writes one RUN_ERROR into the stream and closes it, so `runAgent` settles, both counters come back down in their `finally` blocks, and the drain sees the same falling edge it sees for every other way a turn can finish. The one path with a branch of its own is a disabled conversation, which is main's and is untouched. The audit page keeps both new filters and both new labels, and `config.test.ts` keeps both sides' cases.
davidmckayv
approved these changes
Aug 20, 2026
davidmckayv
left a comment
Contributor
There was a problem hiding this comment.
Re-approving after the rebase. 686 pass / 0 fail locally.
The conflict with #16 that I stopped short of resolving is gone, and the merge kept both sides: the stopped-turn notice and the queued-message rendering coexist rather than one displacing the other.
Adds no migration, so it is unaffected by the 0001 numbering across #15, #18 and #20.
davidmckayv
added a commit
to jerelvelarde/openbot
that referenced
this pull request
Aug 20, 2026
Both branches added to the same audit row renderer. CopilotKit#19 colours a stalled turn as not-carried-out and prints how long the silence was; this branch prints the rule a question was asked under. Neither displaces the other.
This was referenced Aug 20, 2026
davidmckayv
added a commit
that referenced
this pull request
Aug 20, 2026
The approval registry from #15 kept its pending questions in a Map in the process, and the repetition detector from #17 kept its counts the same way. Both are correct on a laptop and both stop being correct the moment a deployment runs a second server process, which is the deployment every part of this is aimed at: several processes behind a load balancer, serving a company. The failure mode is the reason to take them back rather than leave them and fix them later. Neither one breaks loudly. A question raised on one process and answered on another is reported as no longer open, which reads exactly like an expiry. Counts split across processes mean a rule written as `repeat.count >= 10` never fires, which reads exactly like a Bot behaving itself. A boundary that silently stops enforcing is worse than one that was never advertised, because the deployment is relying on it. The ask lists, the approval surface, the repetition context and the two audit event kinds go with them. What stays is everything that was already right: the stall watchdog, which tracks streams in the process that holds them and belongs there, and the client-side work from #16 and #19. The rule is now on the pull request template, stated before the work rather than at review. Our own gateway snapshot cache has the same problem and is next.
davidmckayv
added a commit
that referenced
this pull request
Aug 21, 2026
Audited every markdown file against everything that landed today, including the work that was not mine. `docs/coworkers.md` still told people to point `MANAGED_AGENT_AG_UI_URL` at `4200`. #33 made `agent-langgraph` on `4201` the default precisely because the proof-of-concept hand-writes the protocol and leaves the tool loop to whatever is watching, so following that page produced the shape the change moved away from. Three environment variables the server reads were in `.env.example` and nowhere in the configuration reference: `AGENT_STALL_TIMEOUT_MS` from #19, which is the only thing that notices a Bot's stream going silent; `AGENT_TOOL_TOKEN` from #34, without which no framework Bot may call a granted tool back; and `APP_DIST_DIR`, which the container sets so one process serves both halves. Both documentation indexes had fallen behind their own directory and listed neither `deployment.md` nor `releasing.md`. `docs/development.md` gains the migration workflow the checks in #64 now enforce: never hand-edit a generated migration, write a data step with `--custom`, and what to do when `drizzle-kit migrate` hangs and exits non-zero with nothing printed, which is the journal naming a file a rebase renamed. `drizzle-kit check` calls that state fine, because it compares schemas rather than asking whether the journal and the directory agree. The README keeps its shape: what this is, how to run it, how to deploy it, and where to read the rest.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The problem
A Bot is any AG-UI endpoint, so it is infrastructure this deployment does not run: it gets redeployed mid-answer, its upstream times out, or it accepts a connection, writes a RUN_STARTED and never writes again. Nothing noticed. The stream stayed open, the channel stayed busy, the composer stayed locked, and the only way out was a reload or a restart.
Nor could anyone count it. One hung turn reads as a bad afternoon; the same Bot hanging twice a day for a month is a fact about an endpoint. The trail had no row for it: every event in
audit.tsrecords something that happened, and this is the absence of anything happening.The approach
Activity, not duration. A turn may run for an hour while events keep arriving; one whose stream has produced nothing at all for
stallMsis wedged. The ceiling is on how long a person watches a spinner, not on how much work a Bot may do.On the Bot's stream, not the reply to the browser. In Intelligence mode, OpenBot's only mode,
POST /api/copilotkit/agent/:id/rundoes not stream: it answers fixed-length JSON in about a second, and events reach the browser over a WebSocket. The guard sits on thefetchanHttpAgentis built with;server/src/app.tsis untouched.The clock is kept by the pump, not the relay. A
TransformStreamtransforms only once the readable side is pulled, so timing there records when the consumer took a chunk, not when the Bot produced one — and that consumer is the Intelligence runner republishing over the network, which pauses. The relay is an identity transform, present only to give this process a writable end it can close from outside; the pump times each read off the Bot's body and stops the clock across the handover. A reader that stops for good is therefore never reported, which errs the safe way: believing a healthy Bot is broken ends somebody's turn. The relay may not buffer, await, or read the bytes.What a stall does. Cancel the Bot's side, releasing the socket; queue one RUN_ERROR and close the stream; write the audit row last, since that store is a bare insert against the shared pool and a saturated pool is the condition a Bot is likeliest to hang in. A lost row beats a watchdog that fires and then parks. The sentence goes into every framing but the AG-UI protobuf media type, the rule
@ag-ui/client0.0.57 uses to pick its parser; being stricter would close a stream with nothing in it. Tool calls need no exemption: the run ends before the browser executes one.Where the person reads it. Neither surface drew anything for a failed run, so both now draw the sentence from one source. The channel takes the shimmer's slot at the end of the transcript, and not as a message — no id, gone next turn — because the conversation goes back to the model. The direct Bot chat draws it under the page header, since the packaged chat virtualises its message list; the cost is that it is not beside the gap.
Configuration and the trail.
AGENT_STALL_TIMEOUT_MS, read throughconfig.ts, which refuses to boot on anything but a whole number of milliseconds; zero or unset switches the watch off, so no deployment acquires it unasked..env.exampleships 60000 because the deadline is in a race: Bots here serve on Bun withidleTimeout: 120, and Bun tears a wedged response down a second past that, handing the person a transport error and the trail no row. Each stall writesagent.stream_stalledwith the Bot, thread, run, the length of the silence and how many chunks came first, which separates an endpoint that dies mid-answer from one that never answers. It joins the "Did not happen" filter and carries no actor.What is not covered
BuiltInAgentreaches its provider through the AI SDK, so there is no response body to wrap.fetchhas not resolved, so no body exists yet; that needs a connect-level timeout.data:line and the parser rejects both. Forbidden to read bytes, the guard cannot see this, and the same frame fails identically with the watch off.Merge notes
Single-hunk additions to
server/src/audit.ts, the admin audit page,server/src/config.ts,server/src/index.tsand.env.examplewill conflict textually withpolicy-ask-a-person,repeated-action-detectorandroutines-and-webhook-triggers, and all resolve by keeping both entries.queue-and-steer-a-busy-botneeds more care: it edits the same three channel components and adds its own props and rendering in the same hunk as the shimmer. Both sets of props must survive, and so must the rule added here, that the stopped line replaces the shimmer rather than sitting beside it. Separately,mountCopilotRuntimegained a requiredstallGuardbefore its defaultedbasePath.Verification
formatandformat:checkover 295 files, no fixes applied;lintexit 0 with 24 warnings, all pre-existing and elsewhere;typecheckexit 0 for app, server and worker;buildexit 0.bun run testgives 635 pass, 5 skip, 0 fail across 73 files, 41 more than main's 594; the skips are the pre-existing smoke tests gated onOPENBOT_SMOKE.server/tests/turn-watchdog.test.tspins the activity rule on an injected clock, including that a paused handover is not charged to the Bot.server/tests/stall-guard.test.tsruns real streams through the wrapper: the sentence and row a silent Bot produces, bytes unchanged when the reader waits five times the timeout before its first read, and recovery completing while the audit insert never settles.app/tests/stopped-turn.test.tsandapp/tests/audit-silence.test.tscover the wording of the two lines a person reads. Both defect fixes carry a test confirmed failing beforehand.The whole path was also driven against a live server with a silent stub Bot, which produced the RUN_ERROR in the thread's events, a cancelled upstream stream, and the audit row.