Skip to content

Commit 797b86c

Browse files
committed
Fold gate blocked-ness into turn state so the stall watchdog sees it
The painter derived a local "blocked" turn from shell.overlayKind and the stall check read the shared bag.turn straight, so the exemption never reached the watchdog: an operator reading an approval for the duration of the stall timeout got the run aborted underneath them. Gates still queued behind another overlay were worse off, since overlayKind reflects whatever else is on screen. Turn state now carries a blocked-gate count, incremented the moment a gate is raised (queued or displayed alike) and decremented when it resolves, driven directly off the gate-wire lifecycle rather than the shell's overlay. The painter and the watchdog both read that one field instead of each re-deriving blocked-ness on their own.
1 parent 2b6b0ec commit 797b86c

6 files changed

Lines changed: 184 additions & 18 deletions

File tree

src/tui-opentui/gate-wire.ts

Lines changed: 44 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -227,13 +227,33 @@ function recordDecision(
227227
appendStreamRow(shell, { role: "system", text, meta: "permission" })
228228
}
229229

230+
/**
231+
* Blocked-ness is domain state, not a paint detail: the turn watchdog and the
232+
* painter both need to know a gate is outstanding, whether or not it has
233+
* reached the screen yet. This is the only place that sees a gate's full
234+
* lifecycle (raised, possibly queued, eventually resolved), so it is the one
235+
* that reports it — callers fold the pair into their own turn state.
236+
*/
237+
export type GateLifecycleHooks = {
238+
/** A gate was raised — queued or opened, whichever comes first. */
239+
readonly onGateOpened: () => void
240+
/** A previously raised gate resolved. */
241+
readonly onGateClosed: () => void
242+
}
243+
244+
const NOOP_GATE_HOOKS: GateLifecycleHooks = {
245+
onGateOpened: () => {},
246+
onGateClosed: () => {},
247+
}
248+
230249
/**
231250
* Subscribe the permission/operator gate events to the shell's overlays.
232251
* Returns a dispose function that removes exactly the listeners this call added.
233252
*/
234253
export function wireGates(
235254
emitter: EventEmitter,
236255
shell: AppShell,
256+
hooks: GateLifecycleHooks = NOOP_GATE_HOOKS,
237257
): () => void {
238258
// The shell has one overlay host, and opening onto a busy one is a no-op.
239259
// Gates cannot be dropped that way — a lost ask_operator blocks the run with
@@ -255,6 +275,15 @@ export function wireGates(
255275
})
256276

257277
function onPermission(ev: PermissionGateEvent): void {
278+
hooks.onGateOpened()
279+
let closed = false
280+
const resolve: PermissionGateEvent["resolve"] = (outcome) => {
281+
if (!closed) {
282+
closed = true
283+
hooks.onGateClosed()
284+
}
285+
ev.resolve(outcome)
286+
}
258287
const choices = permissionChoicesFromRequest(ev.request)
259288
const collapsedBody = permissionBodyFromRequest(ev.request, { hint: true })
260289
// Nothing was collapsed → no expand affordance, so the overlay leaves the
@@ -299,15 +328,15 @@ export function wireGates(
299328
...(sel.id !== undefined ? { id: sel.id } : {}),
300329
}
301330
recordDecision(shell, ev.request, choices, gateSelection)
302-
ev.resolve(approvalOutcomeFromSelection(choices, gateSelection))
331+
resolve(approvalOutcomeFromSelection(choices, gateSelection))
303332
},
304333
// Esc must settle the awaited promise (as a deny), not abandon it —
305334
// an unresolved gate hangs the run until the process is killed.
306335
onCancel: () => {
307336
if (settled) return
308337
settled = true
309338
clearTimers()
310-
ev.resolve(
339+
resolve(
311340
approvalOutcomeFromSelection(choices, {
312341
index: 0,
313342
id: PERMISSION_DENY_ID,
@@ -338,7 +367,7 @@ export function wireGates(
338367
const idx = pending.indexOf(open)
339368
if (idx >= 0) pending.splice(idx, 1)
340369
}
341-
ev.resolve({ allow: false, message })
370+
resolve({ allow: false, message })
342371
}
343372
function onAbort(): void {
344373
autoDeny("tool no longer running; permission request denied")
@@ -358,6 +387,15 @@ export function wireGates(
358387
}
359388

360389
function onOperator(ev: OperatorGateEvent): void {
390+
hooks.onGateOpened()
391+
let closed = false
392+
const resolve: OperatorGateEvent["resolve"] = (result) => {
393+
if (!closed) {
394+
closed = true
395+
hooks.onGateClosed()
396+
}
397+
ev.resolve(result)
398+
}
361399
const choices = operatorChoicesFromOptions(ev.options)
362400
// Guarded the same way as the permission gate: correctness must not rest
363401
// on callers of closeInsetOverlay remembering to null the cancel hook
@@ -371,7 +409,7 @@ export function wireGates(
371409
onAccept: (sel: OverlaySelection) => {
372410
if (settled) return
373411
settled = true
374-
ev.resolve(
412+
resolve(
375413
operatorResultFromSelection(ev.options, {
376414
index: sel.index,
377415
...(sel.id !== undefined ? { id: sel.id } : {}),
@@ -383,14 +421,14 @@ export function wireGates(
383421
onTextAnswer: (text: string) => {
384422
if (settled) return
385423
settled = true
386-
ev.resolve(operatorCustomResult(text))
424+
resolve(operatorCustomResult(text))
387425
},
388426
// Esc must settle the awaited promise (as a cancel), not abandon it —
389427
// an unresolved gate hangs the run until the process is killed.
390428
onCancel: () => {
391429
if (settled) return
392430
settled = true
393-
ev.resolve(operatorCancelResult())
431+
resolve(operatorCancelResult())
394432
},
395433
}))
396434
}

src/tui-opentui/product-host.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -486,7 +486,10 @@ export async function mountProductHost(
486486
// leave the terminal wedged with nobody able to restore it.
487487
let disposeGates: () => void
488488
try {
489-
disposeGates = wireGates(config.eventEmitter, shell)
489+
disposeGates = wireGates(config.eventEmitter, shell, {
490+
onGateOpened: () => bridge.gateOpened(),
491+
onGateClosed: () => bridge.gateClosed(),
492+
})
490493
} catch (err: unknown) {
491494
try {
492495
renderer.destroy()

src/tui-opentui/runtime-bridge.ts

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,8 @@ import {
5454
clearQuotaWait,
5555
initialTurnState,
5656
turnStateFromEvent,
57-
turnStateBlocked,
57+
turnStateGateClosed,
58+
turnStateGateOpened,
5859
turnStateOnInterrupt,
5960
turnStateOnSubmit,
6061
type TurnState,
@@ -161,6 +162,14 @@ export type SessionBridge = {
161162
attachments?: readonly PendingImageAttachment[],
162163
) => void
163164
interrupt: () => void
165+
/**
166+
* A permission or operator gate was raised — queued or already displayed.
167+
* Blocks the turn (and exempts it from the stall watchdog) until a matching
168+
* `gateClosed` call. Multiple outstanding gates nest correctly.
169+
*/
170+
gateOpened: () => void
171+
/** A previously raised gate resolved. */
172+
gateClosed: () => void
164173
dispose: () => void
165174
/** Current derived turn phase (progress label, stall clock, quota window). */
166175
readonly turn: TurnState
@@ -748,11 +757,7 @@ export function attachSessionBridge(
748757
}
749758

750759
const paintPhase = (): void => {
751-
// The gate overlay is the only "blocked" signal the shell sees; the gate
752-
// wiring resolves approvals itself and emits no bridge event.
753-
const gated =
754-
shell.overlayKind === "permissions" || shell.overlayKind === "operator"
755-
const turn = gated ? turnStateBlocked(bag.turn) : bag.turn
760+
const turn = bag.turn
756761
// The landing mark rides this same re-entry: it animates through the
757762
// draw/fill loop while a turn is live and holds its filled frame otherwise.
758763
paintLanding(shell, now(), turn.isProcessing)
@@ -893,6 +898,24 @@ export function attachSessionBridge(
893898
paintPhase()
894899
}
895900

901+
/**
902+
* A permission or operator gate was raised — queued or already on screen,
903+
* the turn does not distinguish. Called from the gate wiring itself, not
904+
* derived from `shell.overlayKind`, so a gate still waiting behind another
905+
* overlay exempts the turn from the stall watchdog just as an open one does.
906+
*/
907+
const gateOpened = (): void => {
908+
if (bag.disposed) return
909+
bag.turn = turnStateGateOpened(bag.turn)
910+
paintPhase()
911+
}
912+
913+
const gateClosed = (): void => {
914+
if (bag.disposed) return
915+
bag.turn = turnStateGateClosed(bag.turn, now())
916+
paintPhase()
917+
}
918+
896919
const tick = (): void => {
897920
if (bag.disposed) return
898921
const nowMs = now()
@@ -994,6 +1017,8 @@ export function attachSessionBridge(
9941017
},
9951018
submit,
9961019
interrupt: doInterrupt,
1020+
gateOpened,
1021+
gateClosed,
9971022
get turn() {
9981023
return bag.turn
9991024
},

src/tui-opentui/turn-monitor.test.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,7 @@ describe("turn progress label", () => {
227227
try {
228228
t.bridge.handle({ type: "inference.start", data: {} })
229229
t.shell.overlayKind = "permissions"
230+
t.bridge.gateOpened()
230231
t.tick()
231232
expect(t.shell.turnPhase).toEndWith("blocked")
232233

@@ -347,6 +348,48 @@ describe("stall watchdog", () => {
347348
})
348349
})
349350

351+
test("an open gate is exempt no matter how long the operator takes", async () => {
352+
await withTestRenderer(async (h) => {
353+
const t: Harness = await setup(h)
354+
try {
355+
t.bridge.submit("build it", "immediate")
356+
t.port.clear()
357+
t.bridge.gateOpened()
358+
359+
// Far past the stall timeout — an operator reading an approval must
360+
// never have the run torn down underneath them.
361+
t.advance(20 * 60_000)
362+
t.tick()
363+
expect(t.port.calls).toEqual([])
364+
expect(t.shell.statusFlash).not.toBe(STALL_NOTICE_MESSAGE)
365+
} finally {
366+
t.bridge.dispose()
367+
}
368+
})
369+
})
370+
371+
test("a gate queued but not yet displayed gets the same exemption", async () => {
372+
await withTestRenderer(async (h) => {
373+
const t: Harness = await setup(h)
374+
try {
375+
t.bridge.submit("build it", "immediate")
376+
t.port.clear()
377+
// The gate is raised but nothing else has changed `shell.overlayKind`
378+
// — this is the "queued behind another overlay" shape from
379+
// gate-wire.ts, where the gate is not nominally displayed yet.
380+
t.bridge.gateOpened()
381+
expect(t.shell.overlayKind).toBeNull()
382+
383+
t.advance(20 * 60_000)
384+
t.tick()
385+
expect(t.port.calls).toEqual([])
386+
expect(t.shell.statusFlash).not.toBe(STALL_NOTICE_MESSAGE)
387+
} finally {
388+
t.bridge.dispose()
389+
}
390+
})
391+
})
392+
350393
test("a live tool run is not treated as a stall", async () => {
351394
await withTestRenderer(async (h) => {
352395
const t: Harness = await setup(h)

src/tui-opentui/turn-state.test.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,9 @@ import { describe, expect, test } from "bun:test"
22

33
import {
44
initialTurnState,
5-
turnStateBlocked,
65
turnStateFromEvent,
6+
turnStateGateClosed,
7+
turnStateGateOpened,
78
turnStateOnInterrupt,
89
turnStateOnSubmit,
910
} from "./turn-state.js"
@@ -184,9 +185,26 @@ describe("turn transitions", () => {
184185
})
185186

186187
test("gate blocks without ending the turn", () => {
187-
const s = turnStateBlocked(turnStateOnSubmit(initialTurnState(0), 1))
188+
const s = turnStateGateOpened(turnStateOnSubmit(initialTurnState(0), 1))
188189
expect(s.status).toBe("blocked")
189190
expect(s.isProcessing).toBe(true)
191+
expect(s.blockedGateCount).toBe(1)
192+
})
193+
194+
test("a second queued gate keeps the turn blocked until both clear", () => {
195+
const running = turnStateOnSubmit(initialTurnState(0), 1)
196+
const bothOpen = turnStateGateOpened(turnStateGateOpened(running))
197+
expect(bothOpen.status).toBe("blocked")
198+
expect(bothOpen.blockedGateCount).toBe(2)
199+
200+
const oneClosed = turnStateGateClosed(bothOpen, 5)
201+
expect(oneClosed.status).toBe("blocked")
202+
expect(oneClosed.blockedGateCount).toBe(1)
203+
204+
const allClosed = turnStateGateClosed(oneClosed, 9)
205+
expect(allClosed.status).toBe("running")
206+
expect(allClosed.blockedGateCount).toBe(0)
207+
expect(allClosed.lastActivityAt).toBe(9)
190208
})
191209
})
192210

src/tui-opentui/turn-state.ts

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,16 @@ export type TurnState = {
135135
* gets long enough to trip `detectRepetition` on its own.
136136
*/
137137
readonly consecutiveMatchingCycles: number
138+
/**
139+
* Outstanding approval/operator gates, counted from the moment each is
140+
* raised — queued behind another overlay or already on screen, both count.
141+
* `status` reads "blocked" whenever this is above zero, which is what
142+
* exempts the turn from the stall watchdog: an operator reading a prompt
143+
* must be indistinguishable from a live tool call as far as the silence
144+
* clock is concerned. Painter and watchdog both read this one field rather
145+
* than each re-deriving blocked-ness from the shell's overlay.
146+
*/
147+
readonly blockedGateCount: number
138148
}
139149

140150
export function initialTurnState(nowMs: number): TurnState {
@@ -155,6 +165,7 @@ export function initialTurnState(nowMs: number): TurnState {
155165
repeatingSinceTokenCount: null,
156166
cycleFingerprint: null,
157167
consecutiveMatchingCycles: 0,
168+
blockedGateCount: 0,
158169
}
159170
}
160171

@@ -177,6 +188,7 @@ export function turnStateOnSubmit(state: TurnState, nowMs: number): TurnState {
177188
repeatingSinceTokenCount: null,
178189
cycleFingerprint: null,
179190
consecutiveMatchingCycles: 0,
191+
blockedGateCount: 0,
180192
}
181193
}
182194

@@ -185,9 +197,36 @@ export function turnStateOnInterrupt(_state: TurnState, nowMs: number): TurnStat
185197
return { ...initialTurnState(nowMs), status: "stopped" }
186198
}
187199

188-
/** A pending approval gate blocks the turn without ending it. */
189-
export function turnStateBlocked(state: TurnState): TurnState {
190-
return { ...state, status: "blocked", isProcessing: true }
200+
/**
201+
* A gate was raised — queued or opened, the turn does not distinguish.
202+
* The first outstanding gate blocks the turn without ending it; further
203+
* gates just add to the count so the turn stays blocked until all clear.
204+
*/
205+
export function turnStateGateOpened(state: TurnState): TurnState {
206+
const blockedGateCount = state.blockedGateCount + 1
207+
return {
208+
...state,
209+
status: "blocked",
210+
isProcessing: true,
211+
blockedGateCount,
212+
}
213+
}
214+
215+
/**
216+
* A gate resolved. Only the last outstanding gate clearing returns the turn
217+
* to "running" — earlier ones just decrement the count. `lastActivityAt`
218+
* moves to `nowMs` so the stall clock restarts from the moment the operator
219+
* actually answered, rather than crediting silence spent reading the prompt.
220+
*/
221+
export function turnStateGateClosed(state: TurnState, nowMs: number): TurnState {
222+
const blockedGateCount = Math.max(0, state.blockedGateCount - 1)
223+
if (blockedGateCount > 0) return { ...state, blockedGateCount }
224+
return {
225+
...state,
226+
status: state.status === "blocked" ? "running" : state.status,
227+
lastActivityAt: nowMs,
228+
blockedGateCount,
229+
}
191230
}
192231

193232
export function clearQuotaWait(state: TurnState): TurnState {

0 commit comments

Comments
 (0)