Skip to content

Commit 91495fa

Browse files
committed
Carry an outstanding gate's count across a turn boundary
Interrupting or settling a turn does not close the permission/operator overlay still on screen, so a gate raised in one turn can still be sitting open when the next turn starts, or after the current one has already settled. Resetting blockedGateCount to zero on every turn transition lost track of that gate: its eventual close would land against whatever turn happened to be live by then, either dropping that turn's own exemption early or silently reviving a settled turn's status to "running". Turn resets now carry the prior count forward instead of dropping it, and only mark a fresh turn "blocked" rather than "running" when a carried-over gate is still open. A terminal reset (done, stopped, failed) is left with its terminal status intact, since every one of those already satisfies the watchdog's exemption on its own.
1 parent 874a9a6 commit 91495fa

2 files changed

Lines changed: 80 additions & 11 deletions

File tree

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

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,44 @@ describe("turn transitions", () => {
206206
expect(allClosed.blockedGateCount).toBe(0)
207207
expect(allClosed.lastActivityAt).toBe(9)
208208
})
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)
246+
})
209247
})
210248

211249
describe("repetition tracking", () => {

src/tui-opentui/turn-state.ts

Lines changed: 42 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -169,11 +169,31 @@ export function initialTurnState(nowMs: number): TurnState {
169169
}
170170
}
171171

172+
/**
173+
* Reset to a fresh turn state, except an outstanding gate's count carries
174+
* across the boundary rather than being dropped. Interrupting or settling a
175+
* turn does not close the permission/operator overlay still on screen —
176+
* nothing else resolves it — so losing count of it here would let its
177+
* eventual `turnStateGateClosed` decrement an unrelated later turn's count
178+
* instead, either dropping that turn's own exemption early or masking a
179+
* stall it never earned. The status this resets to (`"stopped"`, `"done"`,
180+
* ...) is left as given: every one of those is already `!== "running"`,
181+
* which is all the watchdog requires, so there is no need to relabel it
182+
* `"blocked"` and risk `turnStateGateClosed` later reviving it to `"running"`.
183+
*/
184+
function carryBlockedGateCount(prior: TurnState, fresh: TurnState): TurnState {
185+
return prior.blockedGateCount === 0
186+
? fresh
187+
: { ...fresh, blockedGateCount: prior.blockedGateCount }
188+
}
189+
172190
/** Operator submitted a prompt: the run is live and awaiting first tokens. */
173191
export function turnStateOnSubmit(state: TurnState, nowMs: number): TurnState {
174192
return {
175193
...state,
176-
status: "running",
194+
// A gate left over from a prior turn still blocks the operator from
195+
// doing anything else, so the new turn inherits the exemption too.
196+
status: state.blockedGateCount > 0 ? "blocked" : "running",
177197
isProcessing: true,
178198
awaitingResponse: true,
179199
streamingType: null,
@@ -188,13 +208,15 @@ export function turnStateOnSubmit(state: TurnState, nowMs: number): TurnState {
188208
repeatingSinceTokenCount: null,
189209
cycleFingerprint: null,
190210
consecutiveMatchingCycles: 0,
191-
blockedGateCount: 0,
192211
}
193212
}
194213

195214
/** Ctrl+C / watchdog abort: nothing is in flight and no prompt may be replayed. */
196-
export function turnStateOnInterrupt(_state: TurnState, nowMs: number): TurnState {
197-
return { ...initialTurnState(nowMs), status: "stopped" }
215+
export function turnStateOnInterrupt(state: TurnState, nowMs: number): TurnState {
216+
return carryBlockedGateCount(state, {
217+
...initialTurnState(nowMs),
218+
status: "stopped",
219+
})
198220
}
199221

200222
/**
@@ -529,11 +551,11 @@ export function turnStateFromEvent(
529551
lastActivityAt: nowMs,
530552
}
531553
}
532-
return {
554+
return carryBlockedGateCount(state, {
533555
...initialTurnState(nowMs),
534556
status: "done",
535557
quota: state.quota,
536-
}
558+
})
537559

538560
/**
539561
* The other turn terminator: `agent.send()` resolves on connector.reply,
@@ -545,11 +567,11 @@ export function turnStateFromEvent(
545567
if (state.activeToolCalls.length > 0) {
546568
return { ...state, awaitingResponse: false, lastActivityAt: nowMs }
547569
}
548-
return {
570+
return carryBlockedGateCount(state, {
549571
...initialTurnState(nowMs),
550572
status: "done",
551573
quota: state.quota,
552-
}
574+
})
553575

554576
case "inference.error": {
555577
const quota = quotaFromInferenceError(event.data, nowMs)
@@ -561,15 +583,24 @@ export function turnStateFromEvent(
561583
}
562584

563585
case "reactor.done":
564-
return { ...initialTurnState(nowMs), quota: state.quota }
586+
return carryBlockedGateCount(state, {
587+
...initialTurnState(nowMs),
588+
quota: state.quota,
589+
})
565590

566591
case "reactor.error":
567-
return { ...initialTurnState(nowMs), status: "failed" }
592+
return carryBlockedGateCount(state, {
593+
...initialTurnState(nowMs),
594+
status: "failed",
595+
})
568596

569597
case "run":
570598
return event.state === "busy"
571599
? turnStateOnSubmit(state, nowMs)
572-
: { ...initialTurnState(nowMs), quota: state.quota }
600+
: carryBlockedGateCount(state, {
601+
...initialTurnState(nowMs),
602+
quota: state.quota,
603+
})
573604

574605
default:
575606
return state

0 commit comments

Comments
 (0)