Skip to content

Commit a33aacb

Browse files
committed
Abort a model that loops on repeated output
The stall watchdog measured silence, so a model streaming the same line on repeat never tripped it: tokens kept arriving, lastActivityAt kept refreshing, and the run burned tokens until a human noticed and interrupted it. The watchdog now also folds streamed text into a bounded buffer and checks it for a line repeated past a threshold, aborting immediately on detection regardless of how fast the loop is producing output. The recovery message names it as the model repeating itself and reports the tokens spent on the looped span, so a retry reads as reasonable rather than papering over a hang.
1 parent cf3bb84 commit a33aacb

6 files changed

Lines changed: 324 additions & 21 deletions

File tree

src/tui-opentui/runtime-bridge.ts

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -40,10 +40,12 @@ import {
4040
import { quotaWaitSeconds, shouldAutoRetryQuota } from "./quota-retry.js"
4141
import {
4242
applyStallRecovery,
43+
repetitionRecoveryMessage,
4344
shouldAbortForStall,
4445
shouldNoticeStall,
4546
STALL_NOTICE_MESSAGE,
4647
STALL_NOTICE_MS,
48+
STALL_RECOVERY_MESSAGE,
4749
STALL_TIMEOUT_MS,
4850
} from "./stall-watchdog.js"
4951
import {
@@ -850,6 +852,19 @@ export function attachSessionBridge(
850852
return
851853
}
852854

855+
// Content-based, not time-based: a repeating line means the model is
856+
// stuck regardless of how fast it is producing it, so this is checked
857+
// before the silence clock rather than folded into it.
858+
if (bag.turn.status === "running" && bag.turn.repeating) {
859+
const repeatedTokens =
860+
bag.turn.streamTokenCount - (bag.turn.repeatingSinceTokenCount ?? 0)
861+
applyStallRecovery(
862+
{ abort: doInterrupt, notify: (message) => setStatusFlash(shell, message) },
863+
repetitionRecoveryMessage(repeatedTokens),
864+
)
865+
return
866+
}
867+
853868
const stallArgs = {
854869
status: bag.turn.status,
855870
awaitingResponse: bag.turn.awaitingResponse,
@@ -861,10 +876,10 @@ export function attachSessionBridge(
861876
}
862877

863878
if (shouldAbortForStall(stallArgs)) {
864-
applyStallRecovery({
865-
abort: doInterrupt,
866-
notify: (message) => setStatusFlash(shell, message),
867-
})
879+
applyStallRecovery(
880+
{ abort: doInterrupt, notify: (message) => setStatusFlash(shell, message) },
881+
STALL_RECOVERY_MESSAGE,
882+
)
868883
return
869884
}
870885

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

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import { describe, expect, test } from "bun:test"
22

33
import {
44
applyStallRecovery,
5+
detectRepetition,
6+
repetitionRecoveryMessage,
57
shouldAbortForStall,
68
shouldNoticeStall,
79
STALL_NOTICE_MS,
@@ -81,14 +83,65 @@ describe("shouldAbortForStall", () => {
8183
})
8284

8385
describe("applyStallRecovery", () => {
84-
test("aborts then notifies", () => {
86+
test("aborts then notifies with the default message", () => {
8587
const calls: string[] = []
8688
applyStallRecovery({
8789
abort: () => calls.push("abort"),
8890
notify: (m) => calls.push(m),
8991
})
9092
expect(calls).toEqual(["abort", STALL_RECOVERY_MESSAGE])
9193
})
94+
95+
test("aborts then notifies with a supplied message", () => {
96+
const calls: string[] = []
97+
applyStallRecovery(
98+
{ abort: () => calls.push("abort"), notify: (m) => calls.push(m) },
99+
"custom message",
100+
)
101+
expect(calls).toEqual(["abort", "custom message"])
102+
})
103+
})
104+
105+
describe("detectRepetition", () => {
106+
test("finds nothing in fresh, varied output", () => {
107+
const text = [
108+
"I'll check the callId emission path first.",
109+
"Running the search now.",
110+
"Found three matches across the module.",
111+
].join("\n")
112+
expect(detectRepetition(text).repeating).toBe(false)
113+
})
114+
115+
test("flags a line repeated past the occurrence threshold", () => {
116+
const line1 =
117+
"I'll verify callId emission and remaining edges, then write the ranked findings."
118+
const line2 = "Confirming callId emission, then writing the ranked findings."
119+
const text = Array(4).fill([line1, line2]).flat().join("\n")
120+
const check = detectRepetition(text)
121+
expect(check.repeating).toBe(true)
122+
expect(check.repeatedLine).toBe(line1)
123+
expect(check.occurrences).toBeGreaterThanOrEqual(3)
124+
})
125+
126+
test("ignores short recurring lines", () => {
127+
const text = Array(6).fill("Checking...").join("\n")
128+
expect(detectRepetition(text).repeating).toBe(false)
129+
})
130+
131+
test("does not flag two occurrences", () => {
132+
const line =
133+
"I'll verify callId emission and remaining edges, then write the ranked findings."
134+
const text = [line, "some other progress here.", line].join("\n")
135+
expect(detectRepetition(text).repeating).toBe(false)
136+
})
137+
})
138+
139+
describe("repetitionRecoveryMessage", () => {
140+
test("names degeneration and attributes the looped tokens", () => {
141+
const message = repetitionRecoveryMessage(42)
142+
expect(message).toContain("repeating itself")
143+
expect(message).toContain("42")
144+
})
92145
})
93146

94147
describe("shouldNoticeStall", () => {

src/tui-opentui/stall-watchdog.ts

Lines changed: 56 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,43 @@ export type ShouldAbortForStallArgs = {
2121
readonly streamingType: "text" | "thinking" | "tool" | null
2222
}
2323

24+
// A repeated line has to be long enough that short, legitimately-recurring
25+
// fragments ("Let me check.") do not trip the guard.
26+
const REPETITION_MIN_LINE_LENGTH = 20
27+
// How far back into the streamed text to look for repeats. Bounded so the
28+
// check stays cheap however long the turn runs.
29+
const REPETITION_LOOKBACK_LINES = 12
30+
// Three verbatim repeats of the same substantial line is not a coincidence
31+
// of phrasing — it is the model looping.
32+
const REPETITION_MIN_OCCURRENCES = 3
33+
34+
export type RepetitionCheck = {
35+
readonly repeating: boolean
36+
readonly repeatedLine: string | null
37+
readonly occurrences: number
38+
}
39+
40+
/**
41+
* Whether the tail of the streamed text is dominated by a line repeated
42+
* verbatim. Pure text-in, decision-out: the caller owns accumulating the
43+
* buffer across deltas and cycles within a turn.
44+
*/
45+
export function detectRepetition(text: string): RepetitionCheck {
46+
const lines = text
47+
.split("\n")
48+
.map((line) => line.trim())
49+
.filter((line) => line.length >= REPETITION_MIN_LINE_LENGTH)
50+
const tail = lines.slice(-REPETITION_LOOKBACK_LINES)
51+
const counts = new Map<string, number>()
52+
for (const line of tail) counts.set(line, (counts.get(line) ?? 0) + 1)
53+
for (const [line, occurrences] of counts) {
54+
if (occurrences >= REPETITION_MIN_OCCURRENCES) {
55+
return { repeating: true, repeatedLine: line, occurrences }
56+
}
57+
}
58+
return { repeating: false, repeatedLine: null, occurrences: 0 }
59+
}
60+
2461
/**
2562
* Whether silence of `thresholdMs` counts as stuck at all. Shared by the notice
2663
* and the abort so they never disagree about which runs are stalled — only
@@ -62,19 +99,35 @@ export function shouldNoticeStall(args: ShouldNoticeStallArgs): boolean {
6299
return silentPastThreshold(args, args.stallNoticeMs)
63100
}
64101

65-
/** Shown while the run is silent; names the state and the way out. */
102+
/**
103+
* Shown while nothing is arriving at all. Never fires while tokens are
104+
* flowing — a model looping on repeated content is still producing output,
105+
* so it is reported by `repetitionRecoveryMessage` instead, not this one.
106+
*/
66107
export const STALL_NOTICE_MESSAGE = "no response for a while — ctrl+c to interrupt"
67108

68109
export const STALL_RECOVERY_MESSAGE =
69110
"stopped after no response — send again to retry"
70111

112+
/**
113+
* Shown once a repeated line aborts the turn. Named as degeneration, not a
114+
* generic failure, so a retry reads as the reasonable next step rather than
115+
* papering over a suspected hang or network fault.
116+
*/
117+
export function repetitionRecoveryMessage(repeatedTokens: number): string {
118+
return `stopped after repeating itself — ~${repeatedTokens} tokens looped — send again to retry`
119+
}
120+
71121
export type ApplyStallRecoveryDeps = {
72122
/** Abort the in-flight run through the session port. */
73123
readonly abort: () => void
74124
readonly notify: (message: string) => void
75125
}
76126

77-
export function applyStallRecovery(deps: ApplyStallRecoveryDeps): void {
127+
export function applyStallRecovery(
128+
deps: ApplyStallRecoveryDeps,
129+
message: string = STALL_RECOVERY_MESSAGE,
130+
): void {
78131
deps.abort()
79-
deps.notify(STALL_RECOVERY_MESSAGE)
132+
deps.notify(message)
80133
}

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

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -369,6 +369,61 @@ describe("stall watchdog", () => {
369369
})
370370
})
371371

372+
describe("repetition guard", () => {
373+
test("aborts a looping model without waiting on the stall clock", async () => {
374+
await withTestRenderer(async (h) => {
375+
const t: Harness = await setup(h)
376+
try {
377+
t.bridge.submit("build it", "immediate")
378+
t.port.clear()
379+
380+
const line1 =
381+
"I'll verify callId emission and remaining edges, then write the ranked findings.\n"
382+
const line2 = "Confirming callId emission, then writing the ranked findings.\n"
383+
384+
// Tokens keep landing every tick — a real stall would never fire here.
385+
for (let i = 0; i < 6; i++) {
386+
t.bridge.handle({
387+
type: "inference.text.delta",
388+
data: { token: i % 2 === 0 ? line1 : line2 },
389+
})
390+
t.advance(10)
391+
t.tick()
392+
}
393+
394+
expect(t.port.calls).toEqual([{ op: "interrupt" }])
395+
expect(t.shell.statusFlash).toContain("repeating itself")
396+
expect(t.shell.statusFlash).not.toBe(STALL_NOTICE_MESSAGE)
397+
} finally {
398+
t.bridge.dispose()
399+
}
400+
})
401+
})
402+
403+
test("a slow but progressing turn is never killed", async () => {
404+
await withTestRenderer(async (h) => {
405+
const t: Harness = await setup(h)
406+
try {
407+
t.bridge.submit("build it", "immediate")
408+
t.port.clear()
409+
410+
for (let i = 0; i < 5; i++) {
411+
t.bridge.handle({
412+
type: "inference.text.delta",
413+
data: { token: `distinct progress update number ${i}\n` },
414+
})
415+
t.advance(500)
416+
t.tick()
417+
}
418+
419+
expect(t.port.calls).toEqual([])
420+
} finally {
421+
t.bridge.dispose()
422+
}
423+
})
424+
})
425+
})
426+
372427
describe("reasoning settles to a summary", () => {
373428
test("a closed thinking row carries its elapsed time", async () => {
374429
await withTestRenderer(async (h) => {

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

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,12 @@ import {
99
} from "./turn-state.js"
1010

1111
const fold = (
12-
events: readonly { type: string; data?: unknown; state?: string }[],
12+
events: readonly {
13+
type: string
14+
data?: unknown
15+
state?: string
16+
text?: string
17+
}[],
1318
startMs = 0,
1419
) =>
1520
events.reduce(
@@ -160,3 +165,69 @@ describe("turn transitions", () => {
160165
expect(s.isProcessing).toBe(true)
161166
})
162167
})
168+
169+
describe("repetition tracking", () => {
170+
const line1 =
171+
"I'll verify callId emission and remaining edges, then write the ranked findings."
172+
const line2 =
173+
"Confirming callId emission, then writing the ranked findings."
174+
175+
const textDelta = (text: string) => ({
176+
type: "inference.text.delta",
177+
data: { token: text },
178+
})
179+
180+
test("varied streamed text is never flagged", () => {
181+
const s = fold([
182+
{ type: "inference.start" },
183+
textDelta("I'll check the callId path.\n"),
184+
textDelta("Running the search now.\n"),
185+
textDelta("Found the match.\n"),
186+
])
187+
expect(s.repeating).toBe(false)
188+
expect(s.repeatingSinceTokenCount).toBeNull()
189+
})
190+
191+
test("a line looping past the threshold flips repeating and latches the token count", () => {
192+
const deltas = Array(4)
193+
.fill([line1, line2])
194+
.flat()
195+
.map((line) => textDelta(`${line}\n`))
196+
const s = fold([{ type: "inference.start" }, ...deltas])
197+
expect(s.repeating).toBe(true)
198+
// Three text deltas land before the third `line1` repeat crosses the
199+
// occurrence threshold and latches the count.
200+
expect(s.repeatingSinceTokenCount).toBe(5)
201+
})
202+
203+
test("repetition tracked across a tool cycle survives connector.reply with tools outstanding", () => {
204+
const deltas = Array(4)
205+
.fill([line1, line2])
206+
.flat()
207+
.map((line) => textDelta(`${line}\n`))
208+
const withTool = turnStateFromEvent(
209+
fold([{ type: "inference.start" }, ...deltas]),
210+
{ type: "tool.start", data: { call: { id: "c1", name: "grep" } } },
211+
100,
212+
)
213+
const afterReply = turnStateFromEvent(
214+
withTool,
215+
{ type: "connector.reply" },
216+
101,
217+
)
218+
expect(afterReply.repeating).toBe(true)
219+
expect(afterReply.streamText.length).toBeGreaterThan(0)
220+
})
221+
222+
test("a fresh submit clears the repetition state", () => {
223+
const deltas = Array(4)
224+
.fill([line1, line2])
225+
.flat()
226+
.map((line) => textDelta(`${line}\n`))
227+
const looping = fold([{ type: "inference.start" }, ...deltas])
228+
const restarted = turnStateOnSubmit(looping, 200)
229+
expect(restarted.repeating).toBe(false)
230+
expect(restarted.repeatingSinceTokenCount).toBeNull()
231+
expect(restarted.streamText).toBe("")
232+
})
233+
})

0 commit comments

Comments
 (0)