Skip to content

Commit 7eb560d

Browse files
Merge pull request #381 from corbitsdev/cl-5642-gate-blocked-turns-are-exempted-from-the-stall-watchdog-only
Fold gate blocked-ness into turn state so the stall watchdog sees it
2 parents 036e6f5 + b3f818c commit 7eb560d

7 files changed

Lines changed: 310 additions & 28 deletions

File tree

src/tui-opentui/gate-wire.ts

Lines changed: 48 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -242,13 +242,51 @@ function recordOperatorDecision(
242242
appendStreamRow(shell, { role: "system", text, meta: "operator" })
243243
}
244244

245+
/**
246+
* Blocked-ness is domain state, not a paint detail: the turn watchdog and the
247+
* painter both need to know a gate is outstanding, whether or not it has
248+
* reached the screen yet. This is the only place that sees a gate's full
249+
* lifecycle (raised, possibly queued, eventually resolved), so it is the one
250+
* that reports it — callers fold the pair into their own turn state.
251+
*/
252+
export type GateLifecycleHooks = {
253+
/** A gate was raised — queued or opened, whichever comes first. */
254+
readonly onGateOpened: () => void
255+
/** A previously raised gate resolved. */
256+
readonly onGateClosed: () => void
257+
}
258+
259+
const NOOP_GATE_HOOKS: GateLifecycleHooks = {
260+
onGateOpened: () => {},
261+
onGateClosed: () => {},
262+
}
263+
264+
/**
265+
* Wrap a gate's `resolve` so `onGateClosed` fires exactly once no matter
266+
* which of accept / cancel / auto-deny settles it first.
267+
*/
268+
function onceClosed<T>(
269+
onGateClosed: () => void,
270+
resolve: (value: T) => void,
271+
): (value: T) => void {
272+
let closed = false
273+
return (value) => {
274+
if (!closed) {
275+
closed = true
276+
onGateClosed()
277+
}
278+
resolve(value)
279+
}
280+
}
281+
245282
/**
246283
* Subscribe the permission/operator gate events to the shell's overlays.
247284
* Returns a dispose function that removes exactly the listeners this call added.
248285
*/
249286
export function wireGates(
250287
emitter: EventEmitter,
251288
shell: AppShell,
289+
hooks: GateLifecycleHooks = NOOP_GATE_HOOKS,
252290
): () => void {
253291
// The shell has one overlay host, and opening onto a busy one is a no-op.
254292
// Gates cannot be dropped that way — a lost ask_operator blocks the run with
@@ -270,6 +308,8 @@ export function wireGates(
270308
})
271309

272310
function onPermission(ev: PermissionGateEvent): void {
311+
hooks.onGateOpened()
312+
const resolve = onceClosed(hooks.onGateClosed, ev.resolve)
273313
const choices = permissionChoicesFromRequest(ev.request)
274314
const collapsedBody = permissionBodyFromRequest(ev.request, { hint: true })
275315
// Nothing was collapsed → no expand affordance, so the overlay leaves the
@@ -318,7 +358,7 @@ export function wireGates(
318358
...(sel.id !== undefined ? { id: sel.id } : {}),
319359
}
320360
recordDecision(shell, ev.request, choices, gateSelection)
321-
ev.resolve(approvalOutcomeFromSelection(choices, gateSelection))
361+
resolve(approvalOutcomeFromSelection(choices, gateSelection))
322362
},
323363
// Esc must settle the awaited promise (as a deny), not abandon it —
324364
// an unresolved gate hangs the run until the process is killed.
@@ -328,7 +368,7 @@ export function wireGates(
328368
clearTimers()
329369
const gateSelection = { index: 0, id: PERMISSION_DENY_ID }
330370
recordDecision(shell, ev.request, choices, gateSelection)
331-
ev.resolve(approvalOutcomeFromSelection(choices, gateSelection))
371+
resolve(approvalOutcomeFromSelection(choices, gateSelection))
332372
},
333373
})
334374
}
@@ -358,7 +398,7 @@ export function wireGates(
358398
const idx = pending.indexOf(open)
359399
if (idx >= 0) pending.splice(idx, 1)
360400
}
361-
ev.resolve({ allow: false, message })
401+
resolve({ allow: false, message })
362402
}
363403
function onAbort(): void {
364404
autoDeny("tool no longer running; permission request denied")
@@ -378,6 +418,8 @@ export function wireGates(
378418
}
379419

380420
function onOperator(ev: OperatorGateEvent): void {
421+
hooks.onGateOpened()
422+
const resolve = onceClosed(hooks.onGateClosed, ev.resolve)
381423
const choices = operatorChoicesFromOptions(ev.options)
382424
// Guarded the same way as the permission gate: correctness must not rest
383425
// on callers of closeInsetOverlay remembering to null the cancel hook
@@ -396,7 +438,7 @@ export function wireGates(
396438
if (settled) return
397439
settled = true
398440
recordOperatorDecision(shell, ev.question, sel.label)
399-
ev.resolve(
441+
resolve(
400442
operatorResultFromSelection(ev.options, {
401443
index: sel.index,
402444
...(sel.id !== undefined ? { id: sel.id } : {}),
@@ -409,15 +451,15 @@ export function wireGates(
409451
if (settled) return
410452
settled = true
411453
recordOperatorDecision(shell, ev.question, text)
412-
ev.resolve(operatorCustomResult(text))
454+
resolve(operatorCustomResult(text))
413455
},
414456
// Esc must settle the awaited promise (as a cancel), not abandon it —
415457
// an unresolved gate hangs the run until the process is killed.
416458
onCancel: () => {
417459
if (settled) return
418460
settled = true
419461
recordOperatorDecision(shell, ev.question, "Cancelled")
420-
ev.resolve(operatorCancelResult())
462+
resolve(operatorCancelResult())
421463
},
422464
}))
423465
}

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()
@@ -995,6 +1018,8 @@ export function attachSessionBridge(
9951018
},
9961019
submit,
9971020
interrupt: doInterrupt,
1021+
gateOpened,
1022+
gateClosed,
9981023
get turn() {
9991024
return bag.turn
10001025
},

src/tui-opentui/stall-watchdog.test.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,19 @@ describe("shouldAbortForStall", () => {
5050
expect(shouldAbortForStall({ ...base, status: "stopping" })).toBe(false)
5151
})
5252

53+
// Two independent exemptions (a gate open on the operator, a sibling tool
54+
// call still outstanding) must both keep exempting when combined — neither
55+
// one's guard may accidentally require the other's condition to also hold.
56+
test("a gate open and a sibling tool call each exempt alone, and together", () => {
57+
const gateOnly = { ...base, status: "blocked" as const }
58+
const toolCallOnly = { ...base, activeToolCalls: ["call-2"] }
59+
const both = { ...base, status: "blocked" as const, activeToolCalls: ["call-2"] }
60+
61+
expect(shouldAbortForStall(gateOnly)).toBe(false)
62+
expect(shouldAbortForStall(toolCallOnly)).toBe(false)
63+
expect(shouldAbortForStall(both)).toBe(false)
64+
})
65+
5366
test("a settled turn with nothing in flight is not a stall", () => {
5467
expect(
5568
shouldAbortForStall({

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

Lines changed: 73 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)
@@ -367,6 +410,36 @@ describe("stall watchdog", () => {
367410
}
368411
})
369412
})
413+
414+
// The gate exemption (this fix) and the parallel-tool-call exemption
415+
// (CL-5641) are independent guards feeding the same stall check — a run
416+
// with both outstanding must stay exempt, and closing the gate while the
417+
// tool call is still out must not re-expose it to the clock.
418+
test("a gate open alongside a live sibling tool call stays exempt", async () => {
419+
await withTestRenderer(async (h) => {
420+
const t: Harness = await setup(h)
421+
try {
422+
t.bridge.submit("build it", "immediate")
423+
t.bridge.handle({
424+
type: "inference.tool_call.end",
425+
data: { name: "task", callId: "c1" },
426+
})
427+
t.bridge.gateOpened()
428+
t.port.clear()
429+
430+
t.advance(20 * 60_000)
431+
t.tick()
432+
expect(t.port.calls).toEqual([])
433+
434+
t.bridge.gateClosed()
435+
t.advance(20 * 60_000)
436+
t.tick()
437+
expect(t.port.calls).toEqual([])
438+
} finally {
439+
t.bridge.dispose()
440+
}
441+
})
442+
})
370443
})
371444

372445
describe("repetition guard", () => {

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

Lines changed: 58 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,64 @@ 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)
208+
})
209+
210+
test("a gate still open at interrupt keeps its count into the next turn", () => {
211+
// The overlay is not closed by an interrupt — nothing else resolves it —
212+
// so a turn that ends while a gate is still outstanding must not lose
213+
// count of it: the eventual close belongs to this gate, not to whatever
214+
// turn happens to be live when the operator finally answers.
215+
const interrupted = turnStateOnInterrupt(
216+
turnStateGateOpened(turnStateOnSubmit(initialTurnState(0), 1)),
217+
2,
218+
)
219+
expect(interrupted.status).toBe("stopped")
220+
expect(interrupted.blockedGateCount).toBe(1)
221+
222+
const nextTurn = turnStateOnSubmit(interrupted, 3)
223+
expect(nextTurn.status).toBe("blocked")
224+
expect(nextTurn.blockedGateCount).toBe(1)
225+
226+
// The stale gate from before the interrupt finally resolves — it must
227+
// settle the count the new turn inherited, not resurrect a status the
228+
// new turn never asked for.
229+
const resolved = turnStateGateClosed(nextTurn, 9)
230+
expect(resolved.status).toBe("running")
231+
expect(resolved.blockedGateCount).toBe(0)
232+
})
233+
234+
test("closing a stale gate after the turn settled does not resurrect it", () => {
235+
const done = turnStateFromEvent(
236+
turnStateGateOpened(turnStateOnSubmit(initialTurnState(0), 1)),
237+
{ type: "inference.done", data: {} },
238+
2,
239+
)
240+
expect(done.status).toBe("done")
241+
expect(done.blockedGateCount).toBe(1)
242+
243+
const resolved = turnStateGateClosed(done, 9)
244+
expect(resolved.status).toBe("done")
245+
expect(resolved.blockedGateCount).toBe(0)
190246
})
191247
})
192248

0 commit comments

Comments
 (0)