Skip to content
Source
Sign inSign up

[Bug]: Cursor Origin caches mergeability for a base commit the API did not evaluate #35901

Send to my agent
Opencoygeek(GitHub profile, opens in a new tab) opened this issue 0 comments

Summary

Cursor Origin can keep reporting a pull request as conflicting after its base returns to a commit where it merges cleanly. If the base moves between the branch-tip read and mergeability evaluation, the adapter stores that verdict under the earlier base instead of the base Origin actually evaluated. An isolated provider reproduction shows the incorrect verdict surviving a later refresh, while a stable-base control returns the correct result.

Before submitting

  • Related issues: no duplicate found in open and closed issues or pull requests for Cursor Origin mergeability and base-commit caching.
  • Removed credentials, tokens, private paths, and sensitive data.

Area

apps/server

Observed behavior

The fixture supplies a valid head H and reads base B, then simulates Origin evaluating the unchanged head against newer base C and returning a merge-conflict blocker with baseSha: C. After the base returns to B, the fixture advances the injected clock by 3 seconds so the pull-request, check and branch-tip caches expire. A second getChangeRequest still returns mergeability: conflicting without asking Origin for mergeability again, although the fixture would now return mergeable for H/B. The control with an unchanged base returns mergeable on both reads.

Reproduction

  1. Check out the commit in Version.
  2. Save the script below as repro.sh and run sh repro.sh /path/to/checkout.
  3. Compare the stable-base control with the race and reset case. Both use the real provider, schemas and caches; the OriginCli service supplies synthetic decoded responses.

The recorded run reproduces the failure deterministically. Prerequisites are Node.js with TypeScript stripping, npm, and registry access for the pinned Effect packages. The script isolates HOME/XDG/T3 state, installs dependencies and creates its fixture under mktemp -d, removes scratch on exit, and leaves the checkout unchanged. The controlled clock avoids real sleeps. It executes no Origin CLI, uses no credentials, and sends no forge requests. Exit 0 means the later read obtains the expected mergeable result, exit 1 means the incorrect cached conflict was reproduced, and exit 2 means setup or the stable-base control was inconclusive.

Reproduction script: repro.sh
#!/bin/sh
set -u
repo=$(cd "$1" && pwd) || { echo 'RESULT: inconclusive'; exit 2; }
scratch=$(mktemp -d) || { echo 'RESULT: inconclusive'; exit 2; }
trap 'rm -rf "$scratch"' EXIT HUP INT TERM
export HOME="$scratch/home" TMPDIR="$scratch/tmp"
export XDG_CONFIG_HOME="$scratch/config" XDG_DATA_HOME="$scratch/data" XDG_STATE_HOME="$scratch/state" T3CODE_HOME="$scratch/t3"
export GIT_CONFIG_NOSYSTEM=1 GIT_CONFIG_GLOBAL=/dev/null
mkdir -p "$HOME" "$TMPDIR" "$XDG_CONFIG_HOME" "$XDG_DATA_HOME" "$XDG_STATE_HOME" "$T3CODE_HOME"
node --version
npm --version
if ! npm install --prefix "$scratch" --ignore-scripts --no-audit --no-fund --no-package-lock --save-exact effect@4.1.3 @effect/platform-node@4.1.2 >"$scratch/install.log" 2>&1; then
  cat "$scratch/install.log"
  echo 'RESULT: inconclusive'
  exit 2
fi
node -e 'for (const n of ["effect", "@effect/platform-node"]) console.log(n + " " + require(process.argv[1] + "/node_modules/" + n + "/package.json").version)' "$scratch"
cat >"$scratch/probe.mjs" <<'JS'
import { registerHooks, createRequire } from 'node:module';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { pathToFileURL } from 'node:url';
const repo = resolve(process.argv[2]);
const runtime = import.meta.url;
registerHooks({ resolve(specifier, context, nextResolve) {
  if (specifier.startsWith('@t3tools/')) {
    const [name, ...rest] = specifier.slice('@t3tools/'.length).split('/');
    const folder = `${repo}/packages/${name}`;
    const manifest = JSON.parse(readFileSync(`${folder}/package.json`, 'utf8'));
    const key = rest.length ? `./${rest.join('/')}` : '.';
    const target = manifest.exports[key];
    return nextResolve(pathToFileURL(resolve(folder, typeof target === 'string' ? target : target.import)).href, context);
  }
  if (specifier === 'effect' || specifier.startsWith('effect/') || specifier.startsWith('@effect/')) return nextResolve(specifier, {...context, parentURL: runtime});
  return nextResolve(specifier, context);
}});
const Effect = await import('effect/Effect');
const Schema = await import('effect/Schema');
const { make } = await import(pathToFileURL(`${repo}/packages/source-control-cursor-origin/src/server/OriginPullRequestProvider.ts`).href);
const { OriginCli } = await import(pathToFileURL(`${repo}/packages/source-control-cursor-origin/src/server/OriginCli.ts`).href);
const Clock = await import('effect/Clock');
const head = 'a'.repeat(40), baseB = 'b'.repeat(40), baseC = 'c'.repeat(40), oldBase = 'd'.repeat(40);
async function scenario(racing) {
 let millis = 10000, phase = 0, calls = 0;
 const clock = {
  currentTimeMillisUnsafe:()=>millis, currentTimeMillis:Effect.sync(()=>millis),
  currentTimeNanosUnsafe:()=>BigInt(millis)*1000000n, currentTimeNanos:Effect.sync(()=>BigInt(millis)*1000000n),
  monotonicTimeNanosUnsafe:()=>BigInt(millis)*1000000n, monotonicTimeNanos:Effect.sync(()=>BigInt(millis)*1000000n),
  sleep:()=>Effect.never,
 };
 const cli = OriginCli.of({
  api:(input,schema)=>Effect.suspend(()=>{
   let data;
   if(input.operation==='getPullRequest') data={number:1,state:'open',title:'Fixture',webUrl:'https://cursor.com/codebase/fixture/repo/pull/1',head:{ref:'feature',sha:head},base:{ref:'main',sha:oldBase},version:{headSha:head,baseSha:oldBase,potentialMergeCommit:{state:'prepared',baseSha:oldBase}}};
   else if(input.operation==='getRepository') data={name:'repo'};
   else if(input.operation==='listChecks') data={checkRuns:[]};
   else if(input.operation==='getBaseTip') data={object:{sha:baseB}};
   else if(input.operation==='getMergeability') {
    calls++;
    const moved = racing && phase===0;
    data={verdict:moved?'blocked':'mergeable',headSha:head,baseRef:'main',baseSha:moved?baseC:baseB,blockers:moved?[{kind:'merge_conflict'}]:[]};
   } else return Effect.die(new Error(`unexpected operation ${input.operation}`));
   return Schema.decodeUnknownEffect(schema)(data);
  }),
  execute:()=>Effect.succeed('{}'),viewer:()=>Effect.succeed('fixture'),personalNamespace:()=>Effect.succeed('fixture'),resolveRepository:()=>Effect.succeed('fixture/repo'),
 });
 return Effect.runPromise(Effect.scoped(Effect.gen(function*(){
  const provider=yield* make;
  const input={cwd:process.cwd(),repository:'fixture/repo',number:1};
  const first=yield* provider.getChangeRequest(input);
  phase=1; millis+=3000;
  const second=yield* provider.getChangeRequest(input);
  return {first:first.mergeability,second:second.mergeability,calls};
 }).pipe(Effect.provideService(OriginCli,cli),Effect.provideService(Clock.Clock,clock))));
}
try {
 const control=await scenario(false), race=await scenario(true);
 console.log(`stable-base control: ${JSON.stringify(control)}`);
 console.log(`base changed B -> C during evaluation, then reset to B: ${JSON.stringify(race)}`);
 if(control.first!=='mergeable'||control.second!=='mergeable'||control.calls!==1) {console.log('RESULT: inconclusive');process.exitCode=2;}
 else if(race.second==='conflicting'&&race.calls===1) {console.log('RESULT: defect reproduced');process.exitCode=1;}
 else if(race.second==='mergeable') {console.log('RESULT: expected behavior');process.exitCode=0;}
 else {console.log('RESULT: inconclusive');process.exitCode=2;}
} catch(error) {console.error(error);console.log('RESULT: inconclusive');process.exitCode=2;}

JS
node "$scratch/probe.mjs" "$repo"
status=$?
if [ "$status" -ne 0 ] && [ "$status" -ne 1 ]; then
  echo 'RESULT: inconclusive'
  exit 2
fi
exit "$status"

Expected behavior

After the base returns to B, refreshing the pull request must not reuse a conflict verdict evaluated only for base C. It should obtain a verdict for the current head/base pair or report uncertainty until that pair is evaluated. Cursor's Origin OpenAPI document, PullRequestMergeability.baseSha, identifies the base tip used at evaluation and states that a later base push can change the verdict. The document's baseRef identifies the evaluated branch; expectedHeadSha fences only the head. The fetched OpenAPI 3.1.0 / v1alpha1 artifact has SHA-256 1e53a3e45bc1f52cb0b22ff3b2e57cc41849b4471c85725d75e59a68ef6aa610.

Impact and scope

Impact: P2 - A supported base-movement race produces incorrect conflict status across a subsequent refresh even after the short read caches expire.

The demonstrated condition is an unchanged PR head with a base that moves between the separate reads and later returns to the earlier base tip. The failure affects the shared provider detail consumed by clients. The source retains a definite mergeability result for up to one hour under that incorrect key; the reproduction advances only 3 seconds and does not claim a measured one-hour incident. NOT_ENOUGH_INFO: frequency in real repositories and a practical end-user workaround were not measured; real response captures or a live disposable repository would supply those facts. No actual merge was attempted, and the report makes no claim that Origin would merge conflicting commits.

Version

T3 Code commit 59e64eef881632a23f89c4138bcf37f23d92bf93.

Runtime and platform

Darwin arm64; Node.js v26.12.1; npm 11.20.0 in the isolated script; Effect 4.1.3; @effect/platform-node 4.1.2. No installed Origin CLI or account is required by this fixture.

Interface

Library: OriginPullRequestProvider.getChangeRequest, used by the shared pull-request detail service.

Earliest failure stage

State or storage

Relevant output or evidence

Actual output from the script above, exit 1:

v26.12.1
11.20.0
effect 4.1.3
@effect/platform-node 4.1.2
stable-base control: {"first":"mergeable","second":"mergeable","calls":1}
base changed B -> C during evaluation, then reset to B: {"first":"conflicting","second":"conflicting","calls":1}
RESULT: defect reproduced

The packages/source-control-cursor-origin/src/server/OriginPullRequestProvider.ts#L399-L436 source caches definite answers for one hour and checks the response head. The packages/source-control-cursor-origin/src/server/OriginPullRequestProvider.ts#L452-L478 source supplies the separately read tip and base ref in that cache key. The packages/source-control-cursor-origin/src/server/originPullRequests.ts#L274-L278 response schema does not retain the evaluated base SHA or ref. All repository names and SHA values in the reproduction are synthetic.

  1. tanstack-source added labelsbug

Sign in to join this conversation.