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.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/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" ? { diff --git a/packages/provider-cursor/src/server/cursorRetry.test.ts b/packages/provider-cursor/src/server/cursorRetry.test.ts index ef05e14743088..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([ { @@ -116,7 +150,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 +208,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 +253,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 +264,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 +272,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) 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",