Skip to content

Commit 9223f68

Browse files
committed
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.
1 parent 5c65825 commit 9223f68

4 files changed

Lines changed: 401 additions & 10 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,21 @@ All notable changes to this project will be documented in this file.
44

55
## [Unreleased]
66

7+
- **Fixed: the stream watchdog killed healthy runs during long tool execution.** The watchdog
8+
re-armed only on mapped event types, so a long shell command, build, or test suite that streamed
9+
nothing for 60s was cancelled and the turn lost. It now uses two budgets — an idle budget
10+
(`OPENCODE_CURSOR_STALL_MS`, default raised to `120000`) and a larger tool-phase budget
11+
(`OPENCODE_CURSOR_TOOL_STALL_MS`, default `600000`) applied while a tool call is in flight — and
12+
re-arms on **any** SDK update, including types the plugin doesn't model (progress/heartbeats). A
13+
tool-phase stall is terminal and names the in-flight tool. `OPENCODE_CURSOR_STALL_MS=0` still
14+
disables the whole watchdog; the tool-phase bound is independently disabled with
15+
`OPENCODE_CURSOR_TOOL_STALL_MS=0`. Open tool calls are reconciled on `turn-ended` and on a forced
16+
resend, so a dropped completion can't pin a turn to the 10-minute budget.
17+
- **Fixed: a non-numeric `OPENCODE_CURSOR_STALL_MS` stalled every turn immediately.**
18+
`Number("abc")` is `NaN`; `NaN <= 0` is `false`, so the guard passed and `setTimeout(fn, NaN)`
19+
fired at once. Env parsing now falls back to the default for non-finite values (an empty string
20+
still disables, preserving the historical escape hatch).
21+
722
## [0.6.2] — 2026-07-28
823

924
Version-check UX cleanup from #79.

‎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: 60 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,20 @@ function toolDisplayName(toolCall: ({ type?: string } & Record<string, any>) | u
6464
return toolCall.type ?? "tool";
6565
}
6666

67+
/**
68+
* Parse a millisecond env var, falling back when unset. An empty string is
69+
* treated as `0` (preserving the historical "set to empty to disable" behavior
70+
* of `OPENCODE_CURSOR_STALL_MS`); any other non-finite value falls back to the
71+
* default so a typo can't arm `setTimeout(fn, NaN)` (which fires immediately).
72+
*/
73+
function envMs(name: string, fallback: number): number {
74+
const raw = process.env[name];
75+
if (raw === undefined) return fallback;
76+
if (raw === "") return 0;
77+
const n = Number(raw);
78+
return Number.isFinite(n) ? n : fallback;
79+
}
80+
6781
/**
6882
* Stream a single turn on an already-acquired Cursor agent and yield normalized
6983
* events. The agent's lifecycle (create/resume/close) is owned by the caller
@@ -85,13 +99,23 @@ export async function* streamAgentTurn(
8599
const debug = process.env.OPENCODE_CURSOR_DEBUG === "1";
86100
const counts: Record<string, number> = {};
87101

88-
// Stall watchdog: if no event arrives within stallMs, cancel the wedged run
89-
// and force-resend once (pre-first-event only). `0` disables.
90-
const stallMs = Number(process.env.OPENCODE_CURSOR_STALL_MS ?? 60_000);
102+
// Stall watchdog. Two budgets:
103+
// - stallMs: idle budget (no tool call open). `0` disables the whole
104+
// watchdog, matching the historical single-knob behavior.
105+
// - toolStallMs: budget while at least one tool call is in flight. A long
106+
// shell command or test suite legitimately streams nothing for minutes;
107+
// killing it at the idle budget was a real-work-destroying false stall.
108+
// `0` disables the bound during tool execution only. Default 10 min.
109+
const stallMs = envMs("OPENCODE_CURSOR_STALL_MS", 120_000);
110+
const toolStallMs = envMs("OPENCODE_CURSOR_TOOL_STALL_MS", 600_000);
91111
let stallTimer: ReturnType<typeof setTimeout> | undefined;
92112
let forced = false;
93113
let anyEvent = false;
94114

115+
// Open tool calls: callId -> display name. Lets the stall message name the
116+
// culprit and lets armWatchdog pick the larger budget while a tool runs.
117+
const openTools = new Map<string, string>();
118+
95119
const push = (event: CursorEvent) => {
96120
anyEvent = true;
97121
queue.push(event);
@@ -102,10 +126,15 @@ export async function* streamAgentTurn(
102126

103127
const armWatchdog = () => {
104128
if (stallMs <= 0 || finished) return;
129+
const budget = openTools.size > 0 ? toolStallMs : stallMs;
105130
if (stallTimer) clearTimeout(stallTimer);
131+
if (budget <= 0) {
132+
stallTimer = undefined;
133+
return;
134+
}
106135
stallTimer = setTimeout(() => {
107136
void onStall();
108-
}, stallMs);
137+
}, budget);
109138
stallTimer.unref?.();
110139
};
111140

@@ -135,6 +164,9 @@ export async function* streamAgentTurn(
135164
});
136165
break;
137166
case "tool-call-started":
167+
// Track the open call BEFORE push() re-arms the watchdog with the
168+
// larger tool budget.
169+
openTools.set(String(update.callId), toolDisplayName(update.toolCall));
138170
push({
139171
type: "tool-call",
140172
id: String(update.callId),
@@ -143,6 +175,7 @@ export async function* streamAgentTurn(
143175
});
144176
break;
145177
case "tool-call-completed": {
178+
openTools.delete(String(update.callId));
146179
const tool = update.toolCall ?? {};
147180
const result = tool.result;
148181
// MCP failures often arrive as {status:"success", value:{isError:true}}
@@ -158,12 +191,22 @@ export async function* streamAgentTurn(
158191
break;
159192
}
160193
case "turn-ended":
194+
// Reconcile: a dropped or differently-keyed `tool-call-completed`
195+
// would otherwise leave an entry pinned here, holding the turn on the
196+
// 10-minute tool budget and naming a tool that already finished.
197+
openTools.clear();
161198
if (update.usage) {
162199
const summed = addUsage(options.usageBase, update.usage as CursorUsage);
163200
if (summed) push({ type: "usage", usage: summed });
164201
}
165202
break;
166203
}
204+
// Any SDK update proves the stream is alive — including types we don't map
205+
// (progress, heartbeats, future types), which never reach `push()`. Armed
206+
// AFTER the switch so it observes the post-mutation `openTools` state and
207+
// therefore always selects the correct budget (a `turn-ended` that cleared
208+
// the map must fall back to the idle budget immediately).
209+
armWatchdog();
167210
};
168211

169212
const runHolder: { run?: AgentRunLike } = {};
@@ -257,7 +300,16 @@ export async function* streamAgentTurn(
257300
// A stall AFTER partial output is terminal: force-resending would
258301
// re-emit the already-yielded prefix. Cancel the wedged run and surface
259302
// the stall instead.
260-
await failTerminal(`Cursor run stalled (no events for ${stallMs}ms)`);
303+
const budget = openTools.size > 0 ? toolStallMs : stallMs;
304+
const inFlight = [...openTools.values()];
305+
const toolHint =
306+
inFlight.length > 0
307+
? `; tool${inFlight.length > 1 ? "s" : ""} ${inFlight.map((n) => `"${n}"`).join(", ")} still in flight`
308+
: "";
309+
const knob = openTools.size > 0 ? "OPENCODE_CURSOR_TOOL_STALL_MS" : "OPENCODE_CURSOR_STALL_MS";
310+
await failTerminal(
311+
`Cursor run stalled (no events for ${budget}ms${toolHint}). Raise ${knob} (or set 0 to disable) if this legitimately runs longer.`,
312+
);
261313
return;
262314
}
263315
if (forced) {
@@ -271,6 +323,9 @@ export async function* streamAgentTurn(
271323
} catch {
272324
/* best effort */
273325
}
326+
// The abandoned run's tool calls will never complete; don't let them hold
327+
// the resend on the tool budget.
328+
openTools.clear();
274329
armWatchdog();
275330
startRun(true);
276331
};

0 commit comments

Comments
 (0)