Skip to content

Commit 48e37b1

Browse files
committed
Clear the stall notice on paint, not only on the watchdog tick
The cadence timer is cancelled the moment the turn settles, so a tool.done then inference.done burst that lands before the next tick would otherwise leave the banner up forever.
1 parent 50606ee commit 48e37b1

4 files changed

Lines changed: 53 additions & 14 deletions

File tree

CHANGELOG.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,19 @@ matching `## [X.Y.Z]` section (plus install instructions). Do not maintain
1111
parallel copies under `docs/` or `scripts/notes/`. At cut time: rename
1212
`## [Unreleased]` to `## [X.Y.Z] - YYYY-MM-DD`, then run the release script.
1313

14+
## [Unreleased]
15+
16+
### TUI
17+
18+
- **In-flight tool rows show elapsed time.** Ordinary pending calls (MCP,
19+
search, shell) tick a live clock the same way Task rows already do, so a
20+
slow-but-alive call is distinguishable from a hung turn.
21+
22+
- **The stall notice comes down the moment activity resumes.** It is a live
23+
diagnosis, not a sticky banner: a tool finishing or the turn settling
24+
clears it on that paint, even if the monitor tick has already been
25+
cancelled.
26+
1427
## [0.2.102] - 2026-08-22
1528

1629
### Permissions

docs/TUI.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,9 @@ instant a tool batch resolves and `awaitingResponse` flips back to true).
140140
That wait has no signal to tell "still coming" from "never coming" apart, so
141141
it is never auto-aborted no matter how long it runs; it still surfaces via
142142
the notice, keeping the operator in control of whether to give up on it.
143+
The notice is a live diagnosis, not a sticky banner: it comes down on the
144+
same paint as the activity that ends the silence, including when the turn
145+
settles before the next monitor tick.
143146

144147
An idle session animates nothing at all: the monitor tick stops entirely
145148
rather than repainting an unchanging frame.
@@ -211,7 +214,10 @@ operator-preferred Amp/Codex-style lines:
211214

212215
`runtime-bridge` paints each `task` call as a stream row and rewrites it in
213216
place via `syncAgentProgress` / `agentProgress` (elapsed clock, current tool,
214-
stall marker). There is no standing FLEET board and no dual-rail agents chrome:
217+
stall marker). Ordinary in-flight tool rows get the same elapsed clock
218+
(`syncToolElapsed`) without the current-tool suffix, so a slow MCP or
219+
network call is distinguishable from a hung turn. There is no standing
220+
FLEET board and no dual-rail agents chrome:
215221
`formatChromeZones` always returns both zones null (`task` and `agents`), and
216222
geometry is stack-only (`layoutMode: "stack"`, `railWidth: 0`). Checklist and
217223
agents strips are parked pending rebuild; Alt+T / direct `setChromeZones` may

src/tui/runtime-bridge.ts

Lines changed: 11 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -927,6 +927,17 @@ export function attachSessionBridge(
927927

928928
const paintPhaseAt = (nowMs: number, isStalled: boolean): void => {
929929
const turn = bag.turn
930+
// The stall notice is a live diagnosis, not a sticky banner: it has to
931+
// set *and* clear on every paint — including handle() — because the
932+
// cadence timer is cancelled the moment the turn settles. If we only
933+
// touched it from tick(), a tool.done → inference.done burst that lands
934+
// before the next tick would leave the banner up forever.
935+
const level = stallLevel(stallArgsFor(nowMs))
936+
if (level === "notice") {
937+
setStatusFlash(shell, STALL_NOTICE_MESSAGE)
938+
} else if (shell.statusFlash === STALL_NOTICE_MESSAGE) {
939+
setStatusFlash(shell, null)
940+
}
930941
// The landing mark rides this same re-entry: it animates through the
931942
// draw/fill loop while a turn is live and holds its filled frame otherwise.
932943
paintLanding(shell, nowMs, turn.isProcessing)
@@ -1216,18 +1227,6 @@ export function attachSessionBridge(
12161227
return
12171228
}
12181229

1219-
// Notice only — the phase still paints below, because a ramp that stops
1220-
// moving is the very thing that reads as a hang.
1221-
const level = stallLevel(stallArgs)
1222-
if (level === "notice") {
1223-
setStatusFlash(shell, STALL_NOTICE_MESSAGE)
1224-
} else if (shell.statusFlash === STALL_NOTICE_MESSAGE) {
1225-
// Activity resumed after the notice was posted: it carries no ttl (it
1226-
// must stay up for as long as the silence lasts), so nothing else would
1227-
// ever take it down once the run starts producing again.
1228-
setStatusFlash(shell, null)
1229-
}
1230-
12311230
// Same "is this stalled at all" question `paintPhase` asks above — call
12321231
// the one definition (`isStalledForDisplay`) rather than re-deriving it
12331232
// from `stallLevel`'s result, so the two call sites can never disagree.

src/tui/turn-monitor.test.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -338,9 +338,30 @@ describe("stall watchdog", () => {
338338
expect(t.shell.statusFlash).toBe(STALL_NOTICE_MESSAGE)
339339

340340
// The model starts producing again — the notice must not linger past
341-
// the silence it was reporting.
341+
// the silence it was reporting. handle() itself has to take it down;
342+
// waiting for the next tick leaves a window where the turn can settle
343+
// and cancel the cadence, which would strand the banner forever.
342344
t.bridge.handle({ type: "inference.text.delta", data: { token: "ok" } })
345+
expect(t.shell.statusFlash).not.toBe(STALL_NOTICE_MESSAGE)
346+
} finally {
347+
t.bridge.dispose()
348+
}
349+
})
350+
})
351+
352+
test("clears the notice when the turn settles before the next tick", async () => {
353+
await withTestRenderer(async (h) => {
354+
const t: Harness = await setup(h)
355+
try {
356+
t.bridge.submit("build it", "immediate")
357+
t.port.clear()
358+
359+
t.advance(500)
343360
t.tick()
361+
expect(t.shell.statusFlash).toBe(STALL_NOTICE_MESSAGE)
362+
363+
t.bridge.handle({ type: "inference.done", data: {} })
364+
// Cadence is cancelled on settle. The notice has to already be gone.
344365
expect(t.shell.statusFlash).not.toBe(STALL_NOTICE_MESSAGE)
345366
} finally {
346367
t.bridge.dispose()

0 commit comments

Comments
 (0)