Skip to content
Open
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
26 changes: 24 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,8 +123,30 @@ Run the optional SDK local-agent bridge in a local Bun or Node environment:
npm run sdk:opencode-bridge
```

The bridge process also accepts `CURSOR_SDK_BRIDGE_RUN_TIMEOUT_MS`; the default is
`180000`.
The bridge process also accepts optional runtime knobs; defaults shown:

```bash
CURSOR_SDK_BRIDGE_RUN_TIMEOUT_MS=180000 # per-request run timeout (packaged app overrides to 120000)
CURSOR_SDK_BRIDGE_MAX_RUN_RETRIES=3 # SDK-run retries per bridge call (4 total attempts)
CURSOR_SDK_BRIDGE_SHUTDOWN_TIMEOUT_MS=15000 # Node drain bound during shutdown
CURSOR_SDK_BRIDGE_PARENT_LOSS_POLL_INTERVAL_MS=1000 # parent-PID watchdog cadence (packaged app)
```

In the packaged macOS app the bridge runs as a child process owned by the app. The bridge
follows parent lifetime: it stops accepting new requests when the app begins shutdown, then
awaits in-flight SDK agent asynchronous disposal before exiting.

Two retry layers apply. Inside the bridge, `maxRunRetries` (default 3) controls SDK-run
retries: `attempt` starts at 0 and increments after each retryable SDK-run error that
occurs before any event is emitted, allowing up to 3 retries (4 total SDK-run attempts)
per bridge call. At the Swift app layer, `bridgeTransportAttempts = 2` means a retryable
pre-output transport failure, 503, or `cursor_sdk_unavailable` error causes the app to
recycle the owned bridge exactly once and issue one final bridge request. Auth failures,
arbitrary upstream errors, and any failure after output has begun are never retried at
either layer. Recycling is process-wide: because all local SDK sessions share the owned
bridge process, a recycle disposes every cached agent and may interrupt other concurrent
requests. This trade-off favors clearing poisoned local SDK state; affected callers can
retry through the unchanged public OpenAI-compatible response and error contract.

Release packages prefer a bundled Node runtime for the local SDK bridge and fall
back to Bun when Node is unavailable.
Expand Down
63 changes: 63 additions & 0 deletions docs/production.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,69 @@ API for Cursor ships as a signed macOS DMG and updates through Sparkle.
- The release workflow uploads the versioned DMG, latest DMG alias, and appcast to Cloudflare R2.
- The Worker serves `/download`, `/releases/...`, and `/appcast.xml` from Cloudflare.

## Local Bridge Lifecycle

In the packaged macOS app the SDK bridge runs as a managed child process.

**Ownership and shutdown sequence:**

- The macOS app launches the bridge child and holds its lifetime.
- The child polls the original parent PID at a configurable cadence (default 1000 ms;
`CURSOR_SDK_BRIDGE_PARENT_LOSS_POLL_INTERVAL_MS`). When the parent exits or the PID
changes, shutdown begins.
- During shutdown the bridge rejects new incoming requests immediately.
- In-flight request creation and any cached or pending SDK agents are drained with awaited
`Symbol.asyncDispose`; a synchronous `close()` call is used only when `asyncDispose` is
absent.
- Node is given a bounded drain window (default 15000 ms;
`CURSOR_SDK_BRIDGE_SHUTDOWN_TIMEOUT_MS`) before exiting.
- The run timeout is 180000 ms by default (`CURSOR_SDK_BRIDGE_RUN_TIMEOUT_MS`); the packaged
macOS app explicitly overrides this to 120000 ms.
- The Swift app waits 16000 ms (the Node drain bound of 15000 ms plus 1000 ms extra grace)
before sending SIGKILL.

**Recovery:**

Two retry layers are active.

Inside the bridge, SDK-run errors that occur before any event is emitted are retried up to
`maxRunRetries` times (default 3; configurable via `CURSOR_SDK_BRIDGE_MAX_RUN_RETRIES`). The
`attempt` counter starts at 0; retries continue while `attempt < maxRunRetries`, yielding at
most 4 SDK-run attempts per bridge call.

At the Swift app layer (`bridgeTransportAttempts = 2`), only retryable pre-output failures
recycle the owned bridge once: transport-level failures and known
`503`/`cursor_sdk_unavailable` stale-state errors that occur before any text or tool output
has been produced. The recycled bridge handles one final attempt. If that also fails, the
`CursorAPIError`/OpenAI-compatible error is returned unchanged.

The bridge is shared by all local SDK sessions. Recycling it is therefore process-wide:
shutdown disposes every cached agent and can interrupt unrelated concurrent requests.
This is an intentional recovery trade-off for the local macOS service so stale or
poisoned SDK process state is removed completely; interrupted callers receive the normal
error contract and can retry.

Auth failures, arbitrary upstream errors, and any failure after output has begun are never
retried at either layer.

**Verification:**

- Node lifecycle suite: `npx vitest run scripts/cursor-sdk-local-agent-bridge.test.mjs`
must pass.
- Swift build: `swift build -c release` from `macos/CursorAPI` (core and production-seam
typecheck green).
- Swift XCTest: requires a full Xcode installation with the XCTest framework; Command Line
Tools alone cannot execute `.xctest` bundles. Run via Xcode or `xcodebuild test`.
- No live credentialed model call was performed during lifecycle fix verification.

**Dependency decision recorded 2026-07-12:**

Lockfile remains at `@cursor/sdk 1.0.13`. Official `1.0.23` was evaluated: it retains a
non-awaited `close()` path while `asyncDispose` correctly awaits, and it raises the Node
engine requirement to `>=22.13`. The update was deferred; it does not fix the lifecycle root
cause alone and requires a runtime version bump. No package or lockfile change was made.
Re-evaluate this decision when changing the SDK lockfile or the bundled Node runtime.

## Required GitHub Secrets

- `MACOS_DEVELOPER_ID_CERTIFICATE_BASE64`: base64-encoded Developer ID Application `.p12`.
Expand Down
161 changes: 161 additions & 0 deletions docs/todos/cursor-api-lifecycle/plan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
# Cursor API Lifecycle Stability Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use subagent-driven-development (recommended) or executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Make the local Cursor SDK bridge self-cleaning and recoverable without changing the OpenAI-compatible API.

**Architecture:** The Node/Bun bridge owns SDK agents and must await their asynchronous disposal before replacement or exit. The bridge also watches its launching parent, while the Swift owner coordinates graceful termination and recycles the bridge once for retryable pre-output failures.

**Tech Stack:** Node.js ESM, `@cursor/sdk`, Vitest, Swift 6, Foundation `Process`, Swift Testing/XCTest.

**Commit Policy:** Commits are not authorized. The handoff must list all uncommitted task-owned changes.

---

### Task 1: Prove the cleanup failure locally

**Files:**
- Modify: `scripts/cursor-sdk-local-agent-bridge.test.mjs`
- Modify: `scripts/cursor-sdk-local-agent-bridge.mjs`

**Verifier:**
- Commands that must be fresh: `npm test -- --run scripts/cursor-sdk-local-agent-bridge.test.mjs`
- Expected evidence: the new fixture initially fails because shutdown returns before asynchronous SDK disposal completes.
- Notes: the same `rejection_key` twice stops and surfaces the task.

- [x] **Step 1: Export a test seam for bridge resource cleanup**

Expose a cleanup function that accepts explicit fake agent entries and an HTTP-server close callback without starting a real SDK agent.

- [x] **Step 2: Write the failing delayed-disposal fixture**

Use a fake agent with `Symbol.asyncDispose` that resolves only after the test releases it, then assert bridge cleanup remains pending until that release.

- [x] **Step 3: Run the focused test and record the expected failure**

Run: `npm test -- --run scripts/cursor-sdk-local-agent-bridge.test.mjs`

Expected: FAIL because the current implementation calls `close()` and does not await SDK disposal.

### Task 2: Implement deterministic bridge ownership

**Files:**
- Modify: `scripts/cursor-sdk-local-agent-bridge.mjs`
- Modify: `scripts/cursor-sdk-local-agent-bridge.test.mjs`

**Verifier:**
- Commands that must be fresh: `npm test -- --run scripts/cursor-sdk-local-agent-bridge.test.mjs`
- Expected evidence: delayed disposal, eviction ordering, and parent-loss fixtures pass.
- Notes: the same `rejection_key` twice stops and surfaces the task.

- [x] **Step 1: Await SDK disposal**

Prefer `await agent[Symbol.asyncDispose]()` and fall back to `agent.close()` only when the async protocol is unavailable.

- [x] **Step 2: Serialize eviction with cleanup**

Make retry and cache-eviction paths await disposal before creating a replacement SDK agent.

- [x] **Step 3: Add parent-loss detection**

Capture the initial parent PID and request one graceful shutdown if the parent changes; disable this check when the bridge starts as PID 1's child.

- [x] **Step 4: Coordinate process exit**

Stop accepting new HTTP work, dispose all cached agents, close the server, and retain a bounded force-exit fallback.

- [x] **Step 5: Run the focused verifier**

Run: `npm test -- --run scripts/cursor-sdk-local-agent-bridge.test.mjs`

Expected: PASS.

### Task 3: Recycle poisoned bridge state in the macOS harness

**Files:**
- Modify: `macos/CursorAPI/Sources/CursorAPICore/CursorSDKHarness.swift`
- Modify: `macos/CursorAPI/Sources/CursorAPICore/CursorSDKBridgeServer.swift`
- Modify: `macos/CursorAPI/Tests/CursorAPITests/ConnectivityCheckTests.swift`

**Verifier:**
- Commands that must be fresh: `cd macos/CursorAPI && swift test --filter ConnectivityCheckTests`
- Expected evidence: a retryable 503 or `cursor_sdk_unavailable` event is marked for one pre-output recycle, while auth and non-retryable upstream errors retain their existing mappings.
- Notes: the same `rejection_key` twice stops and surfaces the task.

- [x] **Step 1: Write retry-policy tests**

Cover retryable opaque SDK failures, explicit authentication failures, and non-retryable upstream failures.

- [x] **Step 2: Preserve the outward error contract**

Wrap retryable bridge failures internally, recycle the bridge before the second attempt, and unwrap the existing `CursorAPIError.upstream` if the retry also fails.

- [x] **Step 3: Extend graceful process shutdown**

Allow the bridge's bounded asynchronous cleanup window to finish before the existing force-kill fallback.

- [x] **Step 4: Run the focused Swift verifier**

Run: `cd macos/CursorAPI && swift test --filter ConnectivityCheckTests`

Expected: PASS.

### Task 4: Document and verify the integrated fix

**Files:**
- Modify: `README.md`
- Modify: `docs/production.md`
- Modify: `docs/todos/cursor-api-lifecycle/todo.md`

**Verifier:**
- Commands that must be fresh: `npm test`, `npm run typecheck`, `npm run build`, `cd macos/CursorAPI && swift test`
- Expected evidence: all repository-native checks pass and lifecycle documentation matches the implemented behavior.
- Notes: run the repository security gate if present; otherwise run required Semgrep. OSV is not required unless dependency files change. The same `rejection_key` twice stops and surfaces the task.

- [x] **Step 1: Document bridge ownership and recovery**

Explain that the bridge follows the app parent, awaits SDK disposal, and is recycled once for retryable pre-output failures.

- [x] **Step 2: Record the dependency intake decision**

Document that 1.0.23 keeps non-awaiting `close()` semantics, requires Node 22.13, and is deferred because it is not the root-cause fix.

- [x] **Step 3: Run focused and full verification**

Run the verifier commands plus the available security gates, then update the task matrix with exact evidence.

- [x] **Step 4: Prepare the parent handoff**

Report root cause, changed files, commands, residual uncertainty, and that a credentialed live proof still requires explicit approval.

### Task 5: Install the fixed local app

**Files and paths:**
- Build: `macos/CursorAPI/dist/API for Cursor.app`
- Replace: `/Applications/API for Cursor.app`

**Verifier:**
- Commands that must be fresh: `macos/CursorAPI/Scripts/verify-package.sh`, `codesign --verify --deep --strict`, bundle metadata checks, and installed bridge checksum comparison.
- Expected evidence: version `0.1.10` build `13`, fixed bridge payload matches the worktree, and no old helper process remains.

- [x] **Step 1: Stop the installed app and orphan helpers**
- [x] **Step 2: Build and verify the development package**
- [x] **Step 3: Replace and statically verify the installed app**

### Task 6: Integrate and install the stable bridge-port fix

**Files and paths:**
- Lifecycle base: commit `9d71542`
- Stable bridge port: commit `febf69b`
- Build: `macos/CursorAPI/dist/API for Cursor.app`
- Replace: `/Applications/API for Cursor.app`

**Verifier:**
- Commands that must be fresh: full JS tests, TypeScript typecheck, Swift target and package builds, package verification, installed signature and metadata checks, and two clean app start/quit cycles.
- Expected evidence: version `0.1.10` build `14`, the installed bridge uses the same internal listening port after a clean restart, and no app or bridge process remains after either shutdown.

- [x] **Step 1: Verify the combined lifecycle and bridge-port branch**
- [x] **Step 2: Build, package, and verify build 14**
- [x] **Step 3: Atomically replace and verify the installed app**
- [x] **Step 4: Prove bridge-port reuse across two clean restarts**
- [x] **Step 5: Run final security checks and prepare the remaining task-owned changes for commit**
Loading