a turn this tab did not start is still a turn in progress

"Turn finished · no closing message" kept appearing over an agent that was
plainly still working, with output resuming a moment later.

isGenerating was set in exactly three places: sendPrompt, and the two reconnect
handshakes. So it answered "am I waiting for a reply I asked for", never "is
this conversation working". Every other way a turn begins left it false — a
commander waking on its own schedule, a queued prompt the server delivered,
another tab driving the same session, or a turn that started before this tab
was looking. The events still render, but buildRows reads isGenerating to
decide whether the last turn is finished, so a live turn was `complete` and the
moment it sat on a completed tool call with nothing said after it, the banner
fired. Same gap left the composer enabled mid-turn, and a message typed then
was not marked injected — which splits the live turn and prints the banner a
second way, the exact failure injected exists to prevent.

session:claude now sets it: the harness's own turn-start signal, and the only
one that arrives before any work does. tool:start sets it too, because
session:claude is not durable and a socket connecting mid-turn never receives
one, while a tool cannot start outside a turn.

Two candidates were measured rather than argued. A genuinely silent turn ending
earns the banner honestly, but 667 of 673 recorded turns end with
assistant:text immediately before result, so that is ~1% and cannot be it. And
a stale sync:live is real but rare: correlating every attach in the officer log
against the agent's own later output, 15 of 549 "not generating" attaches were
contradicted and one provably so. That residue is left alone — the tool:start
fallback now corrects it on the next event — and noted on the refresh card,
which is the same seam.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: f7a62308-0977-4cf4-aa29-c020853fd420
This commit is contained in:
2026-08-25 15:27:49 +00:00
co-authored by Claude Opus 5
parent f56b9de0d6
commit 6b8cf848ff
4 changed files with 122 additions and 39 deletions
+3 -3
View File
@@ -21,10 +21,10 @@ the owner's OS user and can never be granted. Indirection there really is accide
## Themes
**97 open, 30 closed**, across 32 themes.
**97 open, 31 closed**, across 32 themes.
| Theme | Open | Closed |
| ------------------------------------------------------------------------------------------ | ---- | ------ |
| --- | --- | --- |
| [Affordances](todo/affordances/) | 2 | 0 |
| [Agents coordinating agents](todo/agent-coordination/) | 3 | 0 |
| [Speaking into a session from the backend](todo/agent-delivery/) | 1 | 1 |
@@ -37,7 +37,7 @@ the owner's OS user and can never be granted. Indirection there really is accide
| [Fleet — one command across every server](todo/fleet/) | 5 | 2 |
| [Headscale](todo/headscale/) | 0 | 5 |
| [Infra (alpha)](todo/infra/) | 7 | 0 |
| [Known bugs](todo/known-bugs/) | 5 | 3 |
| [Known bugs](todo/known-bugs/) | 5 | 4 |
| [Memory — one store instead of per-harness files](todo/memory/) | 1 | 0 |
| [Mobile apps](todo/mobile-apps/) | 4 | 0 |
| [Multi-user](todo/multi-user/) | 17 | 5 |
+23 -4
View File
@@ -409,10 +409,26 @@ export function useChat(initialSessionId?: string, initialModel?: string | null,
break;
case 'session:claude':
// The turn has only just begun and the conversation is already addressable. Everything that makes
// a refresh survivable hangs off this: the URL is what the reattach handshake sends, so writing it
// here rather than at `result` is the difference between "refresh mid-turn and lose the turn" and
// "refresh mid-turn and watch it carry on".
/*
A turn is starting, and THIS TAB MAY NOT BE THE ONE THAT STARTED IT.
`isGenerating` used to be set in exactly three places — `sendPrompt`, and the two reconnect
handshakes — so it answered "am I waiting for a reply I asked for", not "is this conversation
working". Every other way a turn begins left it false: a commander waking on its own schedule, a
queued prompt the server delivered, another tab or another device driving the same session.
The tab still rendered the work, because the events arrive either way. But `buildRows` reads
`isGenerating` to decide whether the last turn is finished, so with it false a live turn is
`complete`, and the moment it sat on a completed tool call with nothing said after it the reader
got "Turn finished · no closing message" — over an agent that carried on a second later. It also
left the composer enabled mid-turn, and a message typed then was not marked `injected`, which
splits the live turn: the precise failure `injected` exists to prevent (see types.ts).
Marked here rather than on the first tool call because this is the harness's own turn-start
signal, and it is the only one that arrives before any work does.
*/
setIsGenerating(true);
setHasStarted(true);
claudeSessionIdRef.current = msg.claudeSessionId;
setClaudeSessionId(msg.claudeSessionId);
if (replaceUrl) writePermalink(msg.claudeSessionId);
@@ -449,6 +465,9 @@ export function useChat(initialSessionId?: string, initialModel?: string | null,
}
case 'tool:start': {
// Belt and braces for a turn whose `session:claude` this tab missed — an event that is NOT durable,
// so a socket that connects mid-turn never receives one. A tool cannot start outside a turn.
setIsGenerating(true);
toolCallsInTurnRef.current = true;
const call: ChatMessage = {
role: 'tool',
@@ -0,0 +1,50 @@
---
id: known-bugs/a-turn-this-tab-did-not-start-reads-as-finished
title: 'A turn this tab did not start reads as finished — "no closing message" over a working agent'
theme: known-bugs
status: done
created: 2026-08-25
date_source: stated
order: 6
---
**"Turn finished · no closing message" appears while the agent is still working, and output resumes a
moment later.** Reported by the owner 2026-08-25 with a screenshot: the session list shows Platform Harry
`generating`, and Platform Harry's own panel shows the banner under two completed Bash calls. He reports it
happens often.
**Not the same bug as the two below it, and not a race.** `isGenerating` was set in exactly three places:
`sendPrompt`, and the two reconnect handshakes (`sync:live`, `sync:messages`). So it answered _"am I
waiting for a reply I asked for"_, never _"is this conversation working"_. Every other way a turn begins
left it **false** — a commander waking on its own schedule, a queued prompt the server delivered, another
tab or another device driving the same session, or simply a turn that started before this tab was looking.
The tab still renders the work, because the events arrive regardless. But `buildRows` reads `isGenerating`
to decide whether the last turn is finished (`generatingNow = isLast && isGenerating`), so with it false a
live turn is `complete` — and the moment that turn sits on a completed tool call with nothing said after
it, the banner fires. Then the next event arrives and the conversation carries on, which is exactly what
"still outputting in a bit" is.
Two further consequences of the same gap, both visible in the screenshot: the composer stays **enabled**
during someone else's turn, and a message typed then is not marked `injected` — which splits the live turn
and prints the banner a second way, the precise failure `injected` was added to prevent (`types.ts`).
**Ruled out on the way, with evidence rather than reading.** A silent turn ending would earn the banner
honestly, so that was measured first: across every durable event ever recorded, **667 of 673 turns end with
`assistant:text` immediately before `result`**. Genuine silent endings are ~1% and cannot account for "a lot
of times". A stale `sync:live` was the other candidate and is real but rare — correlating every attach in
the officer log against the agent's own subsequent output, 15 of 549 "not generating" attaches were
contradicted, and only **one** provably so (the first event after it was a `tool:result`, so a tool was
already in flight when officer answered).
---
**Fixed 2026-08-25.** `session:claude` — the harness's own turn-start signal, and the only one that arrives
before any work does — now sets `isGenerating`. `tool:start` sets it too, as a fallback: `session:claude` is
not durable, so a socket that connects mid-turn never receives one, and a tool cannot start outside a turn.
Residual, and left alone deliberately: `sync:live` can still report `isGenerating: false` for a turn that is
running (the one proven case above, `sidecar.isClaudeGenerating` answering for an adopted session). The
`tool:start` fallback now corrects that on the next event rather than leaving the turn wrongly settled, so
the symptom is covered even where the answer is not. Fixing the answer itself belongs with
[[known-bugs/refresh-mid-tool-call-renders-the-turn-folded]], which is the same seam.
@@ -43,3 +43,17 @@ Possibly related, same neighbourhood (refresh + a live turn + folding), not conf
cause: `known-bugs/refreshing-a-finished-chat-turn-loses-output` — a different symptom (a turn that DID
finish loses its output entirely and misreports `0 turns · 0 tok`) but the same refresh-time seam
between what the transcript says and what the live socket confirms.
**2026-08-25 — partly overtaken, and the measurement is worth having.** A separate cause was found and
fixed for the same family of symptom: `isGenerating` was never set by anything except `sendPrompt` and the
two handshakes, so a turn this tab did not start rendered as finished from the first event onwards. See
[[known-bugs/a-turn-this-tab-did-not-start-reads-as-finished]]. `session:claude` and `tool:start` now set
it, which means the last turn stops folding as soon as any work arrives — so the window this card is about
is now bounded by the next event rather than open-ended.
What is left here is the question this card actually asks, and it is now measured rather than suspected:
correlating every attach in the officer log against the agent's own subsequent durable output, **15 of 549
attaches that told a client "not generating" were contradicted, one of them provably** — the first event
after it was a `tool:result`, so a tool was already in flight when `sidecar.isClaudeGenerating` answered.
So `sync:live` does sometimes carry a wrong value, it is rare, and it is a wrong ANSWER rather than the
timing gap this card assumed. `historicalCount` was not implicated.