From 4e551c9e7a932a4dd69074165652ad56f380d92a Mon Sep 17 00:00:00 2001 From: shuv Date: Fri, 6 Nov 2026 17:15:26 -0800 Subject: [PATCH 1/4] test(release): check every manifest's patched dependencies against the patch Run each workspace manifest's dependencies through the same pin-and-match check desktop and CLI staging use, so a spec that drifts from a patched version (as @cursor/sdk 1.2.0 did against the 1.3.1 patch) fails in the scripts tests instead of at release time. Claude Opus 5.5 via Cursor in T3 Code --- scripts/build-desktop-artifact.test.ts | 33 ++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/scripts/build-desktop-artifact.test.ts b/scripts/build-desktop-artifact.test.ts index c206b15f8548f..bf81d689158c0 100644 --- a/scripts/build-desktop-artifact.test.ts +++ b/scripts/build-desktop-artifact.test.ts @@ -24,6 +24,7 @@ import { ChildProcess, ChildProcessSpawner } from "effect/process"; import cursorPackageJson from "../packages/provider-cursor/package.json" with { type: "json" }; import serverPackageJson from "../apps/server/package.json" with { type: "json" }; +import { resolveCatalogDependencies } from "./lib/resolve-catalog.ts"; import { buildDesktopBundles, @@ -37,6 +38,7 @@ import { pinPatchedOverrideSpecs, pinPatchedStageDependencies, StagePatchedDependencyMismatchError, + stagePatchedDependenciesFrom, withPinnedFffNativeDependencies, DESKTOP_ELECTRON_LANGUAGES, DESKTOP_EXTRA_RESOURCES, @@ -741,6 +743,37 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { }), ); + it.effect("stages every workspace manifest's patched dependencies at a patched version", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const { patchedDependencies = {}, catalog = {} } = yield* readWorkspaceConfig(); + const repoRoot = path.join(import.meta.dirname, ".."); + const dirs = ["evals", "oxlint-plugin-t3code", "scripts"]; + for (const parent of ["apps", "infra", "packages"]) { + for (const child of yield* fs.readDirectory(path.join(repoRoot, parent))) { + dirs.push(path.join(parent, child)); + } + } + for (const dir of dirs) { + const manifestPath = path.join(repoRoot, dir, "package.json"); + if (!(yield* fs.exists(manifestPath))) continue; + const manifest = JSON.parse(yield* fs.readFileString(manifestPath)); + for (const field of ["dependencies", "optionalDependencies", "devDependencies"]) { + const dependencies = resolveCatalogDependencies(manifest[field] ?? {}, catalog, dir); + yield* stagePatchedDependenciesFrom( + patchedDependencies, + pinPatchedStageDependencies(dependencies, patchedDependencies), + ).pipe( + Effect.catch((cause) => + Effect.sync(() => assert.fail(`${dir}/package.json ${field}: ${cause.message}`)), + ), + ); + } + } + }), + ); + it("pins only a single caret, tilde, or >= anchor", () => { const patches = { "@ff-labs/fff-node@0.11.0": "patches/@ff-labs__fff-node@0.11.0.patch", From 78f3f1eaa129a0a3391fccd564d39870a856b852 Mon Sep 17 00:00:00 2001 From: shuv Date: Fri, 6 Nov 2026 17:17:36 -0800 Subject: [PATCH 2/4] fix(cursor): mark a broken Cursor thread as errored when its turn ends finalizeTurn always reported the provider thread as active, so after an unconfirmed checkpoint the session manager kept the loaded runtime and the next turn reused the uncertain agent. Report error for a broken disposition, as OpenCode does, so the next turn opens a fresh agent with a context handoff. Claude Opus 5.5 via Cursor in T3 Code Co-authored-by: Cursor --- ...estratorReplayRecovery.integration.test.ts | 107 ++++++++++++------ .../provider-cursor/src/server/adapter.ts | 4 +- 2 files changed, 75 insertions(+), 36 deletions(-) diff --git a/apps/server/src/orchestration-v2/testkit/OrchestratorReplayRecovery.integration.test.ts b/apps/server/src/orchestration-v2/testkit/OrchestratorReplayRecovery.integration.test.ts index c8d2bfe99f7a9..ff432bc15d719 100644 --- a/apps/server/src/orchestration-v2/testkit/OrchestratorReplayRecovery.integration.test.ts +++ b/apps/server/src/orchestration-v2/testkit/OrchestratorReplayRecovery.integration.test.ts @@ -251,6 +251,7 @@ const runCursorRecovery = Effect.fn("runCursorRecovery")(function* (input: { readonly transcript: CursorAgentSdkReplayTranscript; readonly runner: ReturnType; readonly checkpointFailure: boolean; + readonly sameRuntime?: boolean; }) { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -305,41 +306,59 @@ const runCursorRecovery = Effect.fn("runCursorRecovery")(function* (input: { }), }; - const before = yield* Effect.scoped( - runOrchestratorV2ProviderReplayScenario( - { - name: "provider_thread_resume/cursor:first-runtime", - transcript: input.transcript, - commands: phase1Commands, - steps: phase1Steps, - projectionThreadIds: materialized.projectionThreadIds, - runtimePolicyOverride: { cwd: tempDir }, - }, - harness, - options, - ), - ); - if (input.checkpointFailure) { - const first = projectionFor(before, input.transcript.scenario); - assert.equal(first.providerThreads[0]?.status, "error"); - assertAssistantTextIncludes(first, FIRST_FINAL); - } + const runAcrossRestart = Effect.gen(function* () { + const before = yield* Effect.scoped( + runOrchestratorV2ProviderReplayScenario( + { + name: "provider_thread_resume/cursor:first-runtime", + transcript: input.transcript, + commands: phase1Commands, + steps: phase1Steps, + projectionThreadIds: materialized.projectionThreadIds, + runtimePolicyOverride: { cwd: tempDir }, + }, + harness, + options, + ), + ); + if (input.checkpointFailure) { + const first = projectionFor(before, input.transcript.scenario); + assert.equal(first.providerThreads[0]?.status, "error"); + assertAssistantTextIncludes(first, FIRST_FINAL); + } - const result = yield* Effect.scoped( - runOrchestratorV2ProviderReplayScenario( - { - name: "provider_thread_resume/cursor:second-runtime", - transcript: input.transcript, - commands: phase2Commands, - steps: phase2Steps, - projectionThreadIds: materialized.projectionThreadIds, - runtimePolicyOverride: { cwd: tempDir }, - }, - harness, - // A real restart requeues effects, like checkpoint capture, the first runtime left running. - { ...options, recoverOnStartup: true }, - ), - ); + return yield* Effect.scoped( + runOrchestratorV2ProviderReplayScenario( + { + name: "provider_thread_resume/cursor:second-runtime", + transcript: input.transcript, + commands: phase2Commands, + steps: phase2Steps, + projectionThreadIds: materialized.projectionThreadIds, + runtimePolicyOverride: { cwd: tempDir }, + }, + harness, + // A real restart requeues effects, like checkpoint capture, the first runtime left running. + { ...options, recoverOnStartup: true }, + ), + ); + }); + const result = input.sameRuntime + ? yield* Effect.scoped( + runOrchestratorV2ProviderReplayScenario( + { + name: "provider_thread_resume/cursor:same-runtime", + transcript: input.transcript, + commands: [...phase1Commands, ...phase2Commands], + steps: materialized.steps, + projectionThreadIds: materialized.projectionThreadIds, + runtimePolicyOverride: { cwd: tempDir }, + }, + harness, + options, + ), + ) + : yield* runAcrossRestart; assertBaseProjection({ result, @@ -491,6 +510,26 @@ describe("orchestrator replay recovery", () => { ), ), ); + it.effect("hands the next Cursor turn to a fresh agent after an unconfirmed checkpoint", () => + Effect.scoped( + Effect.gen(function* () { + const transcript = yield* readCursorTranscript(true); + const runner = makeCursorAgentSdkReplayRunner(transcript); + yield* runCursorRecovery({ + transcript, + runner, + checkpointFailure: true, + sameRuntime: true, + }); + yield* runner.assertComplete; + }).pipe( + provideDeterministicTestRuntime, + Effect.provide( + Layer.merge(IdAllocator.layer, Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), + ), + ), + ), + ); it.effect("keeps a Claude subagent resumed after a server restart in its own thread", () => Effect.scoped( Effect.gen(function* () { diff --git a/packages/provider-cursor/src/server/adapter.ts b/packages/provider-cursor/src/server/adapter.ts index a4731dd2275a3..661c397fcdca6 100644 --- a/packages/provider-cursor/src/server/adapter.ts +++ b/packages/provider-cursor/src/server/adapter.ts @@ -2172,13 +2172,14 @@ export const makeCursorAdapterV2 = Effect.fn("makeCursorAdapterV2")(function* ( completedAt, }), }); + const threadDisposition = input.threadDisposition ?? "reusable"; yield* emitProviderEvent({ type: "provider_thread.updated", driver: CursorAgentSdk.CURSOR_PROVIDER, providerThread: { ...input.context.input.providerThread, providerSessionId: session.id, - status: "active", + status: threadDisposition === "broken" ? "error" : "active", firstRunOrdinal: input.context.input.providerThread.firstRunOrdinal ?? input.context.input.runOrdinal, @@ -2186,7 +2187,6 @@ export const makeCursorAdapterV2 = Effect.fn("makeCursorAdapterV2")(function* ( updatedAt: completedAt, }, }); - const threadDisposition = input.threadDisposition ?? "reusable"; yield* emitProviderEvent( input.status === "failed" ? { From 8d1b677f0b3e6c13210c8e26006c13a006174031 Mon Sep 17 00:00:00 2001 From: shuv Date: Fri, 6 Nov 2026 17:19:53 -0800 Subject: [PATCH 3/4] fix(cursor): keep permanent Cursor errors after the turn ends The 1.3.1 retry guard replaced every non-cancelled classifier decision after turnEnded with the checkpoint-uncertain error, so authentication and other permanent failures lost their kind and message. Only block retries; throw decisions now pass through with their original error and diagnostics. Claude Opus 5.5 via Cursor in T3 Code Co-authored-by: Cursor --- .../src/server/cursorRetry.test.ts | 25 +++++++++++++++---- patches/@cursor__sdk@1.3.1.patch | 7 +++--- pnpm-lock.yaml | 8 +++--- 3 files changed, 27 insertions(+), 13 deletions(-) diff --git a/packages/provider-cursor/src/server/cursorRetry.test.ts b/packages/provider-cursor/src/server/cursorRetry.test.ts index ef05e14743088..41652b6fa6884 100644 --- a/packages/provider-cursor/src/server/cursorRetry.test.ts +++ b/packages/provider-cursor/src/server/cursorRetry.test.ts @@ -116,7 +116,16 @@ describe.each(["cjs", "esm"] as const)("Cursor SDK %s retry boundary", (format) checkpoint: false, continuation: false, terminal: false, - cancelled: true, + thrown: { message: "Request cancelled", kind: "CancelledError" }, + expectedAttempts: 1, + failure: true, + }, + { + scenario: "authentication failure after an answer", + checkpoint: true, + continuation: false, + terminal: false, + thrown: { message: "Sign in to Cursor again", kind: "AuthenticationError" }, expectedAttempts: 1, failure: true, }, @@ -165,10 +174,12 @@ describe.each(["cjs", "esm"] as const)("Cursor SDK %s retry boundary", (format) cancellation: { toCancelledError: () => new Error("cancelled") }, hooks: { classify: (error: unknown, info: { attempt: number }) => - "cancelled" in fixture && fixture.cancelled + "thrown" in fixture ? { action: "throw", - error: Object.assign(new Error("Request cancelled"), { kind: "CancelledError" }), + error: Object.assign(new Error(fixture.thrown.message, { cause: error }), { + kind: fixture.thrown.kind, + }), } : info.attempt < 1 ? { @@ -208,7 +219,7 @@ describe.each(["cjs", "esm"] as const)("Cursor SDK %s retry boundary", (format) ); if (fixture.failure) await expect(result).rejects.toThrow( - "cancelled" in fixture ? "Request cancelled" : checkpointError, + "thrown" in fixture ? fixture.thrown.message : checkpointError, ); else await result; expect(actions).toHaveLength(fixture.expectedAttempts); @@ -219,7 +230,7 @@ describe.each(["cjs", "esm"] as const)("Cursor SDK %s retry boundary", (format) expect(diagnostics).toHaveLength(1); expect(diagnostics[0]?.originalRequestId).toBe("original-request"); expect(diagnostics[0]?.decision).toBe( - "cancelled" in fixture + "thrown" in fixture ? "throw" : fixture.failure ? "blocked_after_turn_ended" @@ -227,6 +238,10 @@ describe.each(["cjs", "esm"] as const)("Cursor SDK %s retry boundary", (format) ? "completed_from_checkpoint" : "retry", ); + if ("thrown" in fixture) { + await expect(result).rejects.toMatchObject({ kind: fixture.thrown.kind }); + expect(diagnostics[0]?.cause).toMatchObject({ message: "ECONNRESET after the answer" }); + } if ("writeFailure" in fixture) { expect(diagnostics[0]?.checkpoint).toBe("eligible"); expect(diagnostics[0]?.cause).toMatchObject({ code: "SQLITE_FULL" }); diff --git a/patches/@cursor__sdk@1.3.1.patch b/patches/@cursor__sdk@1.3.1.patch index 801667ffb9e67..7359f8b6034b7 100644 --- a/patches/@cursor__sdk@1.3.1.patch +++ b/patches/@cursor__sdk@1.3.1.patch @@ -22,10 +22,10 @@ index b56f18ebc41b57f91b4dbf3df510288c056a05b8..c3966b9e0755f2716649eaf524cce581 function global:__cursorUtf8FromLatin1 { diff --git a/dist/s2code-retry.cjs b/dist/s2code-retry.cjs new file mode 100644 -index 0000000000000000000000000000000000000000..8a9127a367027533179bc664aa3908b83295b1a2 +index 0000000000000000000000000000000000000000..9e6144112c9f72674320503278375c2620dcc7cc --- /dev/null +++ b/dist/s2code-retry.cjs -@@ -0,0 +1,56 @@ +@@ -0,0 +1,55 @@ +"use strict"; + +const diagnostics = require("node:diagnostics_channel").channel("s2code.cursor.retry"); @@ -60,8 +60,7 @@ index 0000000000000000000000000000000000000000..8a9127a367027533179bc664aa3908b8 + }, + classify(cause, info) { + let decision = input.hooks.classify(cause, info); -+ const cancelled = decision.action === "throw" && decision.error.kind === "CancelledError"; -+ const blocked = turnEnded && checkpoint !== "ineligible_terminal_turn" && !cancelled; ++ const blocked = turnEnded && decision.action === "retry" && checkpoint !== "ineligible_terminal_turn"; + if (blocked) { + decision = { + action: "throw", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 18dae625475ba..f8e7626a95fb8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -362,7 +362,7 @@ pnpmfileChecksum: sha256-HxQwAhyWNI/9fnrZxQ0MltAhBMQohaHvLg4GiiAL6JA= patchedDependencies: '@alchemy.run/test@2.0.0-beta.85': 29c0539ea71d9cc372f0b16558640599beb9bc609605feb2a493da268d72713e '@clerk/expo-native-components@0.4.0': 7a808b92da8460750b85818552e13be3fb73d111691c7db76965970351e124e4 - '@cursor/sdk@1.3.1': 9539e59be889271b1778056d5961d5d9ae4764ac06ae913ad4de58cbe60c5067 + '@cursor/sdk@1.3.1': 55ea990e7b024604849762eb478d2c2a83242230104d381419bbb88ccfcbe1c4 '@effect/vitest@4.1.2': 299510b71c36537a832a67e9d160a5f112a5f326a29e95c41cb45fc8234e47c7 '@expo/fingerprint@0.21.5': 8fba80d6bbce46c72de427fe8c7907462d262a0bfe50c6b2b7ee9c4e0c61e5e1 '@expo/metro-config@58.0.14': 90fa980e8b6480631fb2fbb04559834e3c612db40c550cd828f11be297d8d760 @@ -843,7 +843,7 @@ importers: version: 0.3.316(@anthropic-ai/sdk@0.93.0(zod@4.6.5))(@modelcontextprotocol/sdk@1.29.0(supports-color@8.1.1)(zod@4.6.5))(zod@4.6.5) '@cursor/sdk': specifier: 1.3.1 - version: 1.3.1(patch_hash=9539e59be889271b1778056d5961d5d9ae4764ac06ae913ad4de58cbe60c5067)(zod@4.6.5) + version: 1.3.1(patch_hash=55ea990e7b024604849762eb478d2c2a83242230104d381419bbb88ccfcbe1c4)(zod@4.6.5) '@effect/platform-node': specifier: 4.1.2 version: 4.1.2(bufferutil@4.1.0)(effect@4.1.2)(redis@6.2.1)(utf-8-validate@6.0.6) @@ -1752,7 +1752,7 @@ importers: dependencies: '@cursor/sdk': specifier: 1.3.1 - version: 1.3.1(patch_hash=9539e59be889271b1778056d5961d5d9ae4764ac06ae913ad4de58cbe60c5067)(zod@4.6.5) + version: 1.3.1(patch_hash=55ea990e7b024604849762eb478d2c2a83242230104d381419bbb88ccfcbe1c4)(zod@4.6.5) '@napi-rs/keyring': specifier: ^1.3.0 version: 1.3.0 @@ -13707,7 +13707,7 @@ snapshots: '@cursor/sdk-win32-x64@1.3.1': optional: true - '@cursor/sdk@1.3.1(patch_hash=9539e59be889271b1778056d5961d5d9ae4764ac06ae913ad4de58cbe60c5067)(zod@4.6.5)': + '@cursor/sdk@1.3.1(patch_hash=55ea990e7b024604849762eb478d2c2a83242230104d381419bbb88ccfcbe1c4)(zod@4.6.5)': dependencies: '@bufbuild/protobuf': 1.10.0 '@connectrpc/connect': 1.7.0(@bufbuild/protobuf@1.10.0) From 88f29f5dac8b8da11fc7952af174f7b73127d72b Mon Sep 17 00:00:00 2001 From: shuv Date: Fri, 6 Nov 2026 17:33:30 -0800 Subject: [PATCH 4/4] test(cursor): pin broken-thread status and retry-only guard Assert that finalizeTurn reports error for a broken disposition and active otherwise, and that after turnEnded the SDK retry guard rewrites only retry decisions; every throw keeps its original error and kind. Claude Opus 5.5 via Cursor in T3 Code Co-authored-by: Cursor --- .../src/server/adapter.test.ts | 4 +++ .../src/server/cursorRetry.test.ts | 34 +++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/packages/provider-cursor/src/server/adapter.test.ts b/packages/provider-cursor/src/server/adapter.test.ts index 6d311fba45156..e62986647a36c 100644 --- a/packages/provider-cursor/src/server/adapter.test.ts +++ b/packages/provider-cursor/src/server/adapter.test.ts @@ -535,6 +535,10 @@ describe("CursorAdapterV2", () => { assert.equal(terminal?.failure?.class, failure?.class); assert.equal(terminal?.failure?.message, failure?.message); assert.equal(terminal?.threadDisposition, threadDisposition ?? "reusable"); + assert.equal( + events.findLast((event) => event.type === "provider_thread.updated")?.providerThread.status, + threadDisposition === "broken" ? "error" : "active", + ); if (failure !== null) { assert.equal( terminal?.failure?.retryable, diff --git a/packages/provider-cursor/src/server/cursorRetry.test.ts b/packages/provider-cursor/src/server/cursorRetry.test.ts index 41652b6fa6884..b0eaf6be13d1b 100644 --- a/packages/provider-cursor/src/server/cursorRetry.test.ts +++ b/packages/provider-cursor/src/server/cursorRetry.test.ts @@ -59,6 +59,40 @@ function loadRetryLoop(format: "cjs" | "esm") { return NodeVM.runInNewContext(`${loop}; ${name}`, globals) as (input: object) => Promise; } +describe("Cursor SDK retry guard after the turn ended", () => { + it.each([ + { action: "retry", kind: undefined, blocked: true }, + { action: "throw", kind: "AuthenticationError", blocked: false }, + { action: "throw", kind: "CancelledError", blocked: false }, + { action: "throw", kind: "NonRetriableError", blocked: false }, + ])("turns only retries into checkpoint failures ($action $kind)", async (fixture) => { + const require = NodeModule.createRequire(import.meta.url); + const directory = NodePath.dirname(require.resolve("@cursor/sdk")); + const { guard } = require(NodePath.join(directory, "..", "s2code-retry.cjs")); + const cause = new Error("ECONNRESET after the answer"); + const decision = + fixture.action === "retry" + ? { action: "retry", immediate: true } + : { action: "throw", error: Object.assign(new Error("original"), { kind: fixture.kind }) }; + const guarded = guard({ + interaction: { sendUpdate: async () => {} }, + checkpointError: (error: unknown) => ({ kind: "NonRetriableError", cause: error }), + hooks: { classify: () => decision, runAttempt: async () => {} }, + }); + await guarded.interaction.sendUpdate({}, { message: { case: "turnEnded" } }); + const result = guarded.hooks.classify(cause, { + attemptRequestId: "attempt", + originalRequestId: "original-request", + attempt: 0, + }); + if (fixture.blocked) { + expect(result).toEqual({ action: "throw", error: { kind: "NonRetriableError", cause } }); + } else { + expect(result).toBe(decision); + } + }); +}); + describe.each(["cjs", "esm"] as const)("Cursor SDK %s retry boundary", (format) => { it.each([ {