Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

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

## [0.6.2] — 2026-07-28

Expand Down
21 changes: 16 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,8 @@ See [SECURITY.md](./SECURITY.md) for the full threat model.
| `OPENCODE_CURSOR_MODEL_CACHE_TTL_MS` | `86400000` | Model-list cache lifetime (ms) |
| `OPENCODE_CURSOR_DEBUG` | — | Set to `1` for trace logging on stderr |
| `OPENCODE_CURSOR_TRANSPORT` | — | Force a transport: `http1` \| `http2-direct` \| `sidecar` — see [Transport](#transport) |
| `OPENCODE_CURSOR_STALL_MS` | `60000` | Stream watchdog timeout (ms); `0` disables — see [Reliability](#reliability) |
| `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) |
| `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) |
| `OPENCODE_CURSOR_SIDECAR` | — | Legacy: `1` maps to `sidecar`, `0` maps to `http2-direct` (superseded by `OPENCODE_CURSOR_TRANSPORT`) |
| `OPENCODE_CURSOR_TOOL_INPUT_STREAM` | on | Set to `0` to disable live tool-input streaming (`tool-input-start`/`-delta`/`-end` parts) |

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

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

A **stream watchdog** guards against a wedged run that streams nothing: if no event arrives within
`OPENCODE_CURSOR_STALL_MS` (default `60000`), a pre-first-event stall cancels and force-resends
once; a stall after partial output is surfaced as a terminal error rather than re-emitting the
already-yielded prefix. Set `OPENCODE_CURSOR_STALL_MS=0` to disable.
A **stream watchdog** guards against a wedged run that streams nothing. It uses two budgets:

- **Idle** (`OPENCODE_CURSOR_STALL_MS`, default `120000`): when no tool call is open. A
pre-first-event stall cancels and force-resends once; a stall after partial output is surfaced
as a terminal error rather than re-emitting the already-yielded prefix.
- **Tool-phase** (`OPENCODE_CURSOR_TOOL_STALL_MS`, default `600000`): while at least one Cursor
tool call is in flight. A long shell command, build, or test suite legitimately streams nothing
for minutes; the larger budget stops a healthy run from being killed mid-tool. A tool-phase
stall is terminal and names the in-flight tool. Set `0` to disable the bound during tool
execution only.

The watchdog re-arms on **any** SDK update — including types the plugin doesn't model — so
progress/heartbeat updates count as liveness. Set `OPENCODE_CURSOR_STALL_MS=0` to disable the whole
watchdog (an empty string also disables, for backward compatibility).

## Troubleshooting

Expand Down
77 changes: 72 additions & 5 deletions src/provider/agent-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,32 @@ function toolDisplayName(toolCall: ({ type?: string } & Record<string, any>) | u
return toolCall.type ?? "tool";
}

/**
* Node stores a timer delay in a signed 32-bit int; anything larger overflows
* and is silently clamped to `1`. An operator following the tool-phase stall
* message's own advice to "raise OPENCODE_CURSOR_TOOL_STALL_MS" could therefore
* pick a number so large that every tool-bearing turn stalls within a
* millisecond — the exact failure the budget is meant to prevent. Cap instead.
*/
const MAX_TIMEOUT_MS = 2_147_483_647;

/**
* Parse a millisecond env var, falling back when unset. An empty string is
* treated as `0` (preserving the historical "set to empty to disable" behavior
* of `OPENCODE_CURSOR_STALL_MS`); any other non-finite value falls back to the
* default so a typo can't arm `setTimeout(fn, NaN)` (which fires immediately).
* Finite values are capped at {@link MAX_TIMEOUT_MS} so an over-large budget
* degrades to "as long as a timer can express" rather than to ~instant.
*/
function envMs(name: string, fallback: number): number {
const raw = process.env[name];
if (raw === undefined) return fallback;
if (raw === "") return 0;
const n = Number(raw);
if (!Number.isFinite(n)) return fallback;
return Math.min(n, MAX_TIMEOUT_MS);
}

/**
* Stream a single turn on an already-acquired Cursor agent and yield normalized
* events. The agent's lifecycle (create/resume/close) is owned by the caller
Expand All @@ -86,13 +112,23 @@ export async function* streamAgentTurn(
const debug = process.env.OPENCODE_CURSOR_DEBUG === "1";
const counts: Record<string, number> = {};

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

// Open tool calls: callId -> display name. Lets the stall message name the
// culprit and lets armWatchdog pick the larger budget while a tool runs.
const openTools = new Map<string, string>();

const push = (event: CursorEvent) => {
anyEvent = true;
queue.push(event);
Expand All @@ -103,10 +139,15 @@ export async function* streamAgentTurn(

const armWatchdog = () => {
if (stallMs <= 0 || finished) return;
const budget = openTools.size > 0 ? toolStallMs : stallMs;
if (stallTimer) clearTimeout(stallTimer);
if (budget <= 0) {
stallTimer = undefined;
return;
}
stallTimer = setTimeout(() => {
void onStall();
}, stallMs);
}, budget);
stallTimer.unref?.();
};

Expand Down Expand Up @@ -136,6 +177,9 @@ export async function* streamAgentTurn(
});
break;
case "tool-call-started":
// Track the open call BEFORE push() re-arms the watchdog with the
// larger tool budget.
openTools.set(String(update.callId), toolDisplayName(update.toolCall));
push({
type: "tool-call",
id: String(update.callId),
Expand All @@ -144,6 +188,7 @@ export async function* streamAgentTurn(
});
break;
case "tool-call-completed": {
openTools.delete(String(update.callId));
const tool = update.toolCall ?? {};
const result = tool.result;
// MCP failures often arrive as {status:"success", value:{isError:true}}
Expand All @@ -159,12 +204,22 @@ export async function* streamAgentTurn(
break;
}
case "turn-ended":
// Reconcile: a dropped or differently-keyed `tool-call-completed`
// would otherwise leave an entry pinned here, holding the turn on the
// 10-minute tool budget and naming a tool that already finished.
openTools.clear();
if (update.usage) {
const summed = addUsage(options.usageBase, update.usage as CursorUsage);
if (summed) push({ type: "usage", usage: summed });
}
break;
}
// Any SDK update proves the stream is alive — including types we don't map
// (progress, heartbeats, future types), which never reach `push()`. Armed
// AFTER the switch so it observes the post-mutation `openTools` state and
// therefore always selects the correct budget (a `turn-ended` that cleared
// the map must fall back to the idle budget immediately).
armWatchdog();
};

const runHolder: { run?: AgentRunLike } = {};
Expand Down Expand Up @@ -264,7 +319,16 @@ export async function* streamAgentTurn(
// A stall AFTER partial output is terminal: force-resending would
// re-emit the already-yielded prefix. Cancel the wedged run and surface
// the stall instead.
await failTerminal(`Cursor run stalled (no events for ${stallMs}ms)`);
const budget = openTools.size > 0 ? toolStallMs : stallMs;
const inFlight = [...openTools.values()];
const toolHint =
inFlight.length > 0
? `; tool${inFlight.length > 1 ? "s" : ""} ${inFlight.map((n) => `"${n}"`).join(", ")} still in flight`
: "";
const knob = openTools.size > 0 ? "OPENCODE_CURSOR_TOOL_STALL_MS" : "OPENCODE_CURSOR_STALL_MS";
await failTerminal(
`Cursor run stalled (no events for ${budget}ms${toolHint}). Raise ${knob} (or set 0 to disable) if this legitimately runs longer.`,
);
return;
}
if (forced) {
Expand All @@ -278,6 +342,9 @@ export async function* streamAgentTurn(
} catch {
/* best effort */
}
// The abandoned run's tool calls will never complete; don't let them hold
// the resend on the tool budget.
openTools.clear();
armWatchdog();
startRun(true);
};
Expand Down
Loading