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
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,7 @@ const runCursorRecovery = Effect.fn("runCursorRecovery")(function* (input: {
readonly transcript: CursorAgentSdkReplayTranscript;
readonly runner: ReturnType<typeof makeCursorAgentSdkReplayRunner>;
readonly checkpointFailure: boolean;
readonly sameRuntime?: boolean;
}) {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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* () {
Expand Down
4 changes: 4 additions & 0 deletions packages/provider-cursor/src/server/adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
4 changes: 2 additions & 2 deletions packages/provider-cursor/src/server/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2172,21 +2172,21 @@ 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,
lastRunOrdinal: input.context.input.runOrdinal,
updatedAt: completedAt,
},
});
const threadDisposition = input.threadDisposition ?? "reusable";
yield* emitProviderEvent(
input.status === "failed"
? {
Expand Down
59 changes: 54 additions & 5 deletions packages/provider-cursor/src/server/cursorRetry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,40 @@ function loadRetryLoop(format: "cjs" | "esm") {
return NodeVM.runInNewContext(`${loop}; ${name}`, globals) as (input: object) => Promise<void>;
}

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([
{
Expand Down Expand Up @@ -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,
},
Expand Down Expand Up @@ -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
? {
Expand Down Expand Up @@ -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);
Expand All @@ -219,14 +264,18 @@ 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"
: fixture.terminal
? "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" });
Expand Down
7 changes: 3 additions & 4 deletions patches/@cursor__sdk@1.3.1.patch
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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",
Expand Down
8 changes: 4 additions & 4 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

33 changes: 33 additions & 0 deletions scripts/build-desktop-artifact.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -37,6 +38,7 @@ import {
pinPatchedOverrideSpecs,
pinPatchedStageDependencies,
StagePatchedDependencyMismatchError,
stagePatchedDependenciesFrom,
withPinnedFffNativeDependencies,
DESKTOP_ELECTRON_LANGUAGES,
DESKTOP_EXTRA_RESOURCES,
Expand Down Expand Up @@ -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",
Expand Down
Loading