Skip to content

Commit 47bb2db

Browse files
fix(provider): use a larger stall budget while a Cursor tool is in flight (#86)
* fix(provider): use a larger stall budget while a Cursor tool is in flight The stream watchdog re-armed only from push(), which fires for the ten mapped update types. A long shell command, build, or test suite streams nothing between tool-call-started and tool-call-completed, so a healthy run was cancelled at the 60s budget and the turn lost. Split the budget in two: an idle budget (OPENCODE_CURSOR_STALL_MS, raised 60000 -> 120000) and a tool-phase budget applied while at least one tool call is open (OPENCODE_CURSOR_TOOL_STALL_MS, default 600000, 0 disables just that bound). A tool-phase stall stays terminal and now names the tool still in flight. Also re-arm on any raw SDK update rather than only mapped ones, so progress/heartbeat types the plugin does not model still count as liveness. The re-arm runs after the switch so it observes post-mutation openTools state and always selects the correct budget. Reconcile openTools on turn-ended and on the forced resend: a dropped or differently-keyed completion would otherwise pin the turn to the 10-minute budget and name a tool that had already finished. Harden env parsing: Number("abc") is NaN and NaN <= 0 is false, so the old guard passed and setTimeout(fn, NaN) fired immediately, stalling every turn. Non-finite values now fall back to the default; an empty string still disables, preserving the existing escape hatch. * fix(provider): cap stall budgets at setTimeout's 32-bit ceiling A setTimeout delay is stored as a signed 32-bit int, so a value above 2147483647 overflows and Node silently clamps it to 1ms. envMs rejected NaN and Infinity but passed every other finite value straight through, so OPENCODE_CURSOR_TOOL_STALL_MS=999999999999 stalled every tool-bearing turn within milliseconds while reporting "no events for 999999999999ms". The tool-phase stall message tells operators to raise that same variable, so the trap was reachable by following the plugin's own advice. Both budgets are now capped at 2147483647. Verified against a live agent: with the over-large budget set, a 150s shell call died after 5.6s before this change and completes normally after it.
1 parent 2299bbf commit 47bb2db

4 files changed

Lines changed: 489 additions & 10 deletions

File tree

CHANGELOG.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,26 @@ All notable changes to this project will be documented in this file.
2020
own interceptor forwarding over the existing JSONL protocol) and re-emitted
2121
as structured opencode logs instead of raw terminal noise. Every other
2222
`console.log` call passes through unchanged.
23+
- **Fixed: the stream watchdog killed healthy runs during long tool execution.** The watchdog
24+
re-armed only on mapped event types, so a long shell command, build, or test suite that streamed
25+
nothing for 60s was cancelled and the turn lost. It now uses two budgets — an idle budget
26+
(`OPENCODE_CURSOR_STALL_MS`, default raised to `120000`) and a larger tool-phase budget
27+
(`OPENCODE_CURSOR_TOOL_STALL_MS`, default `600000`) applied while a tool call is in flight — and
28+
re-arms on **any** SDK update, including types the plugin doesn't model (progress/heartbeats). A
29+
tool-phase stall is terminal and names the in-flight tool. `OPENCODE_CURSOR_STALL_MS=0` still
30+
disables the whole watchdog; the tool-phase bound is independently disabled with
31+
`OPENCODE_CURSOR_TOOL_STALL_MS=0`. Open tool calls are reconciled on `turn-ended` and on a forced
32+
resend, so a dropped completion can't pin a turn to the 10-minute budget.
33+
- **Fixed: a non-numeric `OPENCODE_CURSOR_STALL_MS` stalled every turn immediately.**
34+
`Number("abc")` is `NaN`; `NaN <= 0` is `false`, so the guard passed and `setTimeout(fn, NaN)`
35+
fired at once. Env parsing now falls back to the default for non-finite values (an empty string
36+
still disables, preserving the historical escape hatch).
37+
- **Fixed: an over-large stall budget overflowed to a ~1 ms deadline.** A `setTimeout` delay is
38+
stored as a signed 32-bit int, so anything above `2147483647` is silently clamped to `1` — and the
39+
tool-phase stall message tells operators to *raise* `OPENCODE_CURSOR_TOOL_STALL_MS`, making the
40+
trap reachable by following the plugin's own advice. Setting it to e.g. `999999999999` stalled
41+
every tool-bearing turn within milliseconds while reporting `no events for 999999999999ms`. Both
42+
budgets are now capped at `2147483647`.
2343

2444
## [0.6.2] — 2026-07-28
2545

README.md

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -192,7 +192,8 @@ See [SECURITY.md](./SECURITY.md) for the full threat model.
192192
| `OPENCODE_CURSOR_MODEL_CACHE_TTL_MS` | `86400000` | Model-list cache lifetime (ms) |
193193
| `OPENCODE_CURSOR_DEBUG` || Set to `1` for trace logging on stderr |
194194
| `OPENCODE_CURSOR_TRANSPORT` || Force a transport: `http1` \| `http2-direct` \| `sidecar` — see [Transport](#transport) |
195-
| `OPENCODE_CURSOR_STALL_MS` | `60000` | Stream watchdog timeout (ms); `0` disables — see [Reliability](#reliability) |
195+
| `OPENCODE_CURSOR_STALL_MS` | `120000` | Idle stream-watchdog timeout in ms (no tool call open). `0` disables the whole watchdog; an empty string also disables — see [Reliability](#reliability) |
196+
| `OPENCODE_CURSOR_TOOL_STALL_MS` | `600000` | Stream-watchdog timeout in ms while a tool call is in flight (e.g. a long build or test suite). `0` disables the bound during tool execution only — see [Reliability](#reliability) |
196197
| `OPENCODE_CURSOR_SIDECAR` || Legacy: `1` maps to `sidecar`, `0` maps to `http2-direct` (superseded by `OPENCODE_CURSOR_TRANSPORT`) |
197198
| `OPENCODE_CURSOR_TOOL_INPUT_STREAM` | on | Set to `0` to disable live tool-input streaming (`tool-input-start`/`-delta`/`-end` parts) |
198199

@@ -378,10 +379,20 @@ The provider classifies Cursor SDK errors into typed kinds (`agent-not-found`, `
378379

379380
Sends carry an idempotency key so a retry is a server-side dedupe, not a duplicate turn.
380381

381-
A **stream watchdog** guards against a wedged run that streams nothing: if no event arrives within
382-
`OPENCODE_CURSOR_STALL_MS` (default `60000`), a pre-first-event stall cancels and force-resends
383-
once; a stall after partial output is surfaced as a terminal error rather than re-emitting the
384-
already-yielded prefix. Set `OPENCODE_CURSOR_STALL_MS=0` to disable.
382+
A **stream watchdog** guards against a wedged run that streams nothing. It uses two budgets:
383+
384+
- **Idle** (`OPENCODE_CURSOR_STALL_MS`, default `120000`): when no tool call is open. A
385+
pre-first-event stall cancels and force-resends once; a stall after partial output is surfaced
386+
as a terminal error rather than re-emitting the already-yielded prefix.
387+
- **Tool-phase** (`OPENCODE_CURSOR_TOOL_STALL_MS`, default `600000`): while at least one Cursor
388+
tool call is in flight. A long shell command, build, or test suite legitimately streams nothing
389+
for minutes; the larger budget stops a healthy run from being killed mid-tool. A tool-phase
390+
stall is terminal and names the in-flight tool. Set `0` to disable the bound during tool
391+
execution only.
392+
393+
The watchdog re-arms on **any** SDK update — including types the plugin doesn't model — so
394+
progress/heartbeat updates count as liveness. Set `OPENCODE_CURSOR_STALL_MS=0` to disable the whole
395+
watchdog (an empty string also disables, for backward compatibility).
385396

386397
## Troubleshooting
387398

src/provider/agent-events.ts

Lines changed: 72 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,32 @@ function toolDisplayName(toolCall: ({ type?: string } & Record<string, any>) | u
6565
return toolCall.type ?? "tool";
6666
}
6767

68+
/**
69+
* Node stores a timer delay in a signed 32-bit int; anything larger overflows
70+
* and is silently clamped to `1`. An operator following the tool-phase stall
71+
* message's own advice to "raise OPENCODE_CURSOR_TOOL_STALL_MS" could therefore
72+
* pick a number so large that every tool-bearing turn stalls within a
73+
* millisecond — the exact failure the budget is meant to prevent. Cap instead.
74+
*/
75+
const MAX_TIMEOUT_MS = 2_147_483_647;
76+
77+
/**
78+
* Parse a millisecond env var, falling back when unset. An empty string is
79+
* treated as `0` (preserving the historical "set to empty to disable" behavior
80+
* of `OPENCODE_CURSOR_STALL_MS`); any other non-finite value falls back to the
81+
* default so a typo can't arm `setTimeout(fn, NaN)` (which fires immediately).
82+
* Finite values are capped at {@link MAX_TIMEOUT_MS} so an over-large budget
83+
* degrades to "as long as a timer can express" rather than to ~instant.
84+
*/
85+
function envMs(name: string, fallback: number): number {
86+
const raw = process.env[name];
87+
if (raw === undefined) return fallback;
88+
if (raw === "") return 0;
89+
const n = Number(raw);
90+
if (!Number.isFinite(n)) return fallback;
91+
return Math.min(n, MAX_TIMEOUT_MS);
92+
}
93+
6894
/**
6995
* Stream a single turn on an already-acquired Cursor agent and yield normalized
7096
* events. The agent's lifecycle (create/resume/close) is owned by the caller
@@ -86,13 +112,23 @@ export async function* streamAgentTurn(
86112
const debug = process.env.OPENCODE_CURSOR_DEBUG === "1";
87113
const counts: Record<string, number> = {};
88114

89-
// Stall watchdog: if no event arrives within stallMs, cancel the wedged run
90-
// and force-resend once (pre-first-event only). `0` disables.
91-
const stallMs = Number(process.env.OPENCODE_CURSOR_STALL_MS ?? 60_000);
115+
// Stall watchdog. Two budgets:
116+
// - stallMs: idle budget (no tool call open). `0` disables the whole
117+
// watchdog, matching the historical single-knob behavior.
118+
// - toolStallMs: budget while at least one tool call is in flight. A long
119+
// shell command or test suite legitimately streams nothing for minutes;
120+
// killing it at the idle budget was a real-work-destroying false stall.
121+
// `0` disables the bound during tool execution only. Default 10 min.
122+
const stallMs = envMs("OPENCODE_CURSOR_STALL_MS", 120_000);
123+
const toolStallMs = envMs("OPENCODE_CURSOR_TOOL_STALL_MS", 600_000);
92124
let stallTimer: ReturnType<typeof setTimeout> | undefined;
93125
let forced = false;
94126
let anyEvent = false;
95127

128+
// Open tool calls: callId -> display name. Lets the stall message name the
129+
// culprit and lets armWatchdog pick the larger budget while a tool runs.
130+
const openTools = new Map<string, string>();
131+
96132
const push = (event: CursorEvent) => {
97133
anyEvent = true;
98134
queue.push(event);
@@ -103,10 +139,15 @@ export async function* streamAgentTurn(
103139

104140
const armWatchdog = () => {
105141
if (stallMs <= 0 || finished) return;
142+
const budget = openTools.size > 0 ? toolStallMs : stallMs;
106143
if (stallTimer) clearTimeout(stallTimer);
144+
if (budget <= 0) {
145+
stallTimer = undefined;
146+
return;
147+
}
107148
stallTimer = setTimeout(() => {
108149
void onStall();
109-
}, stallMs);
150+
}, budget);
110151
stallTimer.unref?.();
111152
};
112153

@@ -136,6 +177,9 @@ export async function* streamAgentTurn(
136177
});
137178
break;
138179
case "tool-call-started":
180+
// Track the open call BEFORE push() re-arms the watchdog with the
181+
// larger tool budget.
182+
openTools.set(String(update.callId), toolDisplayName(update.toolCall));
139183
push({
140184
type: "tool-call",
141185
id: String(update.callId),
@@ -144,6 +188,7 @@ export async function* streamAgentTurn(
144188
});
145189
break;
146190
case "tool-call-completed": {
191+
openTools.delete(String(update.callId));
147192
const tool = update.toolCall ?? {};
148193
const result = tool.result;
149194
// MCP failures often arrive as {status:"success", value:{isError:true}}
@@ -159,12 +204,22 @@ export async function* streamAgentTurn(
159204
break;
160205
}
161206
case "turn-ended":
207+
// Reconcile: a dropped or differently-keyed `tool-call-completed`
208+
// would otherwise leave an entry pinned here, holding the turn on the
209+
// 10-minute tool budget and naming a tool that already finished.
210+
openTools.clear();
162211
if (update.usage) {
163212
const summed = addUsage(options.usageBase, update.usage as CursorUsage);
164213
if (summed) push({ type: "usage", usage: summed });
165214
}
166215
break;
167216
}
217+
// Any SDK update proves the stream is alive — including types we don't map
218+
// (progress, heartbeats, future types), which never reach `push()`. Armed
219+
// AFTER the switch so it observes the post-mutation `openTools` state and
220+
// therefore always selects the correct budget (a `turn-ended` that cleared
221+
// the map must fall back to the idle budget immediately).
222+
armWatchdog();
168223
};
169224

170225
const runHolder: { run?: AgentRunLike } = {};
@@ -264,7 +319,16 @@ export async function* streamAgentTurn(
264319
// A stall AFTER partial output is terminal: force-resending would
265320
// re-emit the already-yielded prefix. Cancel the wedged run and surface
266321
// the stall instead.
267-
await failTerminal(`Cursor run stalled (no events for ${stallMs}ms)`);
322+
const budget = openTools.size > 0 ? toolStallMs : stallMs;
323+
const inFlight = [...openTools.values()];
324+
const toolHint =
325+
inFlight.length > 0
326+
? `; tool${inFlight.length > 1 ? "s" : ""} ${inFlight.map((n) => `"${n}"`).join(", ")} still in flight`
327+
: "";
328+
const knob = openTools.size > 0 ? "OPENCODE_CURSOR_TOOL_STALL_MS" : "OPENCODE_CURSOR_STALL_MS";
329+
await failTerminal(
330+
`Cursor run stalled (no events for ${budget}ms${toolHint}). Raise ${knob} (or set 0 to disable) if this legitimately runs longer.`,
331+
);
268332
return;
269333
}
270334
if (forced) {
@@ -278,6 +342,9 @@ export async function* streamAgentTurn(
278342
} catch {
279343
/* best effort */
280344
}
345+
// The abandoned run's tool calls will never complete; don't let them hold
346+
// the resend on the tool budget.
347+
openTools.clear();
281348
armWatchdog();
282349
startRun(true);
283350
};

0 commit comments

Comments
 (0)