Skip to content

Commit da2a401

Browse files
fix(session): retry resumed turns that fail against an expired Cursor agent (#52)
* fix(session): retry resumed turns that fail against an expired agent A pooled Cursor agent can pass resume() yet fail the subsequent send when Cursor's server has already expired it — surfacing as `Cursor run ended with status "error"` after a session sits idle. acquireAgent only wrapped resumeAgent() in its create-fallback, so a successful-resume-then-failed-send went uncaught and failed the turn (server retention is shorter than our local 7-day reuse window and is undocumented). agentRun now wraps the resumed-turn stream: on a resumed turn that throws before emitting any event (and is not aborted), it re-creates a fresh agent, replays the full transcript, and re-pools under the same session, overwriting the dead agentId. Guarded to a single attempt; never retries a fresh-create turn, an already-emitting stream, or a user abort. * chore(session): add debug traces and tidy retry path - Trace retry trigger under OPENCODE_CURSOR_DEBUG - Log original resume failure when it cannot ride along as cause - Document that retry fires on any error class (Cursor's status:"error" carries no machine-readable class); bounded to one attempt - Drop unused resetSessionPoolMemory import in tests
1 parent 0bca2ca commit da2a401

2 files changed

Lines changed: 442 additions & 7 deletions

File tree

src/provider/language-model.ts

Lines changed: 80 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -186,7 +186,10 @@ export class CursorLanguageModel implements LanguageModelV3 {
186186
}
187187
}
188188

189-
const acquired = await acquireAgent({
189+
// Shared acquire params. The retry path reuses this verbatim (minus
190+
// resumeAgentId) so a fresh agent can never drift from the first attempt's
191+
// config (sandbox, settingSources, MCP, etc.).
192+
const baseAcquire = {
190193
apiKey: this.requireApiKey(),
191194
modelSelection,
192195
mode,
@@ -200,9 +203,13 @@ export class CursorLanguageModel implements LanguageModelV3 {
200203
...(mcpServers ? { mcpServers } : {}),
201204
...(this.config.agents ? { agents: this.config.agents } : {}),
202205
...(poolKey ? { name: `opencode/${sessionID!.slice(-8)}` } : {}),
203-
...(resumeAgentId ? { resumeAgentId } : {}),
204206
...(poolKey ? { poolKey } : {}),
205207
...(record ? { record } : {}),
208+
};
209+
210+
const acquired = await acquireAgent({
211+
...baseAcquire,
212+
...(resumeAgentId ? { resumeAgentId } : {}),
206213
});
207214

208215
// A resumed agent already remembers the prior conversation, so send only the
@@ -212,13 +219,79 @@ export class CursorLanguageModel implements LanguageModelV3 {
212219
promptToCursorMessage(options.prompt))
213220
: promptToCursorMessage(options.prompt);
214221

222+
let yielded = false;
223+
let releasedOriginal = false;
215224
try {
216-
yield* streamAgentTurn(acquired.agent, message, {
217-
mode,
218-
abortSignal: options.abortSignal,
219-
});
225+
try {
226+
for await (const event of streamAgentTurn(acquired.agent, message, {
227+
mode,
228+
abortSignal: options.abortSignal,
229+
})) {
230+
yielded = true;
231+
yield event;
232+
}
233+
} catch (err) {
234+
// Resume-aware retry: a resumed agent can pass resume() yet fail the
235+
// actual send when Cursor's server has already expired the agent (its
236+
// server-side retention is shorter than our local 7-day reuse window,
237+
// and not documented). If nothing has been emitted downstream yet and
238+
// the user hasn't aborted, transparently re-create a fresh agent and
239+
// replay the full transcript — self-healing, no context loss. The
240+
// fresh agent re-pools under the same session (overwriting the dead
241+
// agentId) via acquireAgent's existing pooling path.
242+
//
243+
// Deliberate tradeoff: the retry fires on ANY error class (including
244+
// rate-limit or network failures) because Cursor's status:"error"
245+
// carries no machine-readable class to discriminate on. Bounded to a
246+
// single attempt, so the worst case is one extra create.
247+
if (
248+
acquired.resumed &&
249+
!yielded &&
250+
!options.abortSignal?.aborted
251+
) {
252+
if (process.env["OPENCODE_CURSOR_DEBUG"] === "1") {
253+
console.error(
254+
"[cursor:debug] resumed turn failed before emitting; retrying with a fresh agent",
255+
);
256+
}
257+
acquired.release();
258+
releasedOriginal = true;
259+
// A fresh create (no resumeAgentId) re-pools under the same
260+
// session, overwriting the dead agentId. If re-acquiring itself
261+
// fails (e.g. transient create error), surface that but keep the
262+
// original resume failure as the cause for diagnosability.
263+
let retry: Awaited<ReturnType<typeof acquireAgent>>;
264+
try {
265+
retry = await acquireAgent({ ...baseAcquire });
266+
} catch (retryErr) {
267+
if (retryErr instanceof Error && retryErr.cause === undefined) {
268+
retryErr.cause = err;
269+
} else if (process.env["OPENCODE_CURSOR_DEBUG"] === "1") {
270+
// Non-Error throw or pre-existing cause: the original resume
271+
// failure can't ride along as `cause`, so log it instead of
272+
// dropping it silently.
273+
console.error(
274+
"[cursor:debug] original resume failure (not attachable as cause):",
275+
err,
276+
);
277+
}
278+
throw retryErr;
279+
}
280+
try {
281+
const replay = promptToCursorMessage(options.prompt);
282+
yield* streamAgentTurn(retry.agent, replay, {
283+
mode,
284+
abortSignal: options.abortSignal,
285+
});
286+
} finally {
287+
retry.release();
288+
}
289+
} else {
290+
throw err;
291+
}
292+
}
220293
} finally {
221-
acquired.release();
294+
if (!releasedOriginal) acquired.release();
222295
}
223296
}
224297

0 commit comments

Comments
 (0)