Skip to content

Commit 893336b

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 e143db0 commit 893336b

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
@@ -41,10 +41,12 @@ import {
4141
import { quotaWaitSeconds, shouldAutoRetryQuota } from "./quota-retry.js"
4242
import {
4343
applyStallRecovery,
44+
repetitionRecoveryMessage,
4445
shouldAbortForStall,
4546
shouldNoticeStall,
4647
STALL_NOTICE_MESSAGE,
4748
STALL_NOTICE_MS,
49+
STALL_RECOVERY_MESSAGE,
4850
STALL_TIMEOUT_MS,
4951
} from "./stall-watchdog.js"
5052
import {
@@ -919,6 +921,19 @@ export function attachSessionBridge(
919921
return
920922
}
921923

924+
// Content-based, not time-based: a repeating line means the model is
925+
// stuck regardless of how fast it is producing it, so this is checked
926+
// before the silence clock rather than folded into it.
927+
if (bag.turn.status === "running" && bag.turn.repeating) {
928+
const repeatedTokens =
929+
bag.turn.streamTokenCount - (bag.turn.repeatingSinceTokenCount ?? 0)
930+
applyStallRecovery(
931+
{ abort: doInterrupt, notify: (message) => setStatusFlash(shell, message) },
932+
repetitionRecoveryMessage(repeatedTokens),
933+
)
934+
return
935+
}
936+
922937
const stallArgs = {
923938
status: bag.turn.status,
924939
awaitingResponse: bag.turn.awaitingResponse,
@@ -930,10 +945,10 @@ export function attachSessionBridge(
930945
}
931946

932947
if (shouldAbortForStall(stallArgs)) {
933-
applyStallRecovery({
934-
abort: doInterrupt,
935-
notify: (message) => setStatusFlash(shell, message),
936-
})
948+
applyStallRecovery(
949+
{ abort: doInterrupt, notify: (message) => setStatusFlash(shell, message) },
950+
STALL_RECOVERY_MESSAGE,
951+
)
937952
return
938953
}
939954

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(
@@ -184,3 +189,69 @@ describe("turn transitions", () => {
184189
expect(s.isProcessing).toBe(true)
185190
})
186191
})
192+
193+
describe("repetition tracking", () => {
194+
const line1 =
195+
"I'll verify callId emission and remaining edges, then write the ranked findings."
196+
const line2 =
197+
"Confirming callId emission, then writing the ranked findings."
198+
199+
const textDelta = (text: string) => ({
200+
type: "inference.text.delta",
201+
data: { token: text },
202+
})
203+
204+
test("varied streamed text is never flagged", () => {
205+
const s = fold([
206+
{ type: "inference.start" },
207+
textDelta("I'll check the callId path.\n"),
208+
textDelta("Running the search now.\n"),
209+
textDelta("Found the match.\n"),
210+
])
211+
expect(s.repeating).toBe(false)
212+
expect(s.repeatingSinceTokenCount).toBeNull()
213+
})
214+
215+
test("a line looping past the threshold flips repeating and latches the token count", () => {
216+
const deltas = Array(4)
217+
.fill([line1, line2])
218+
.flat()
219+
.map((line) => textDelta(`${line}\n`))
220+
const s = fold([{ type: "inference.start" }, ...deltas])
221+
expect(s.repeating).toBe(true)
222+
// Three text deltas land before the third `line1` repeat crosses the
223+
// occurrence threshold and latches the count.
224+
expect(s.repeatingSinceTokenCount).toBe(5)
225+
})
226+
227+
test("repetition tracked across a tool cycle survives connector.reply with tools outstanding", () => {
228+
const deltas = Array(4)
229+
.fill([line1, line2])
230+
.flat()
231+
.map((line) => textDelta(`${line}\n`))
232+
const withTool = turnStateFromEvent(
233+
fold([{ type: "inference.start" }, ...deltas]),
234+
{ type: "tool.start", data: { call: { id: "c1", name: "grep" } } },
235+
100,
236+
)
237+
const afterReply = turnStateFromEvent(
238+
withTool,
239+
{ type: "connector.reply" },
240+
101,
241+
)
242+
expect(afterReply.repeating).toBe(true)
243+
expect(afterReply.streamText.length).toBeGreaterThan(0)
244+
})
245+
246+
test("a fresh submit clears the repetition state", () => {
247+
const deltas = Array(4)
248+
.fill([line1, line2])
249+
.flat()
250+
.map((line) => textDelta(`${line}\n`))
251+
const looping = fold([{ type: "inference.start" }, ...deltas])
252+
const restarted = turnStateOnSubmit(looping, 200)
253+
expect(restarted.repeating).toBe(false)
254+
expect(restarted.repeatingSinceTokenCount).toBeNull()
255+
expect(restarted.streamText).toBe("")
256+
})
257+
})

0 commit comments

Comments
 (0)