Cursor Debug Mode 实战:用日志定位搜索结果回退

先看哪一次请求写入了当前结果,再用相同完成顺序检验修复。

你能带走什么
  • 用明确完成顺序稳定复现旧响应覆盖
  • 用 run_id、请求编号和处理决定核对本轮证据
  • 同一搜索词的两次请求仍需区分身份
  • 成功与失败都通过最新代次检查后再更新状态
  • 保留固定预期,在清理诊断日志后继续回归

1. 搜索词停在 cat,结果为什么又回到 ca

连续输入 ca、cat,屏幕上的搜索词已经是 cat,列表却在稍后回到了 ca 的结果。代码里没有报错,每个请求也都成功了。让模型直接猜一个补丁,很容易得到看似合理的去抖、缓存或重试方案;真正要查的是:哪一次请求,在什么时刻获得了写入当前结果的资格。

本文准备了一个完整的离线 Node 实验。你可以把它放进 Cursor,用 Debug Mode 练习提出假设、观察运行记录、定位写入点和复验。我们先保留一个有错的实现,再检验“只比较搜索词”的修补为什么仍会失效,最后使用每个实例内递增的请求编号隔离旧结果。全文给出四份文件的源码,不依赖另外下载代码。

实测范围:2026-10-13,Linux x86_64、Node v24.19.0,19 项测试通过。Promise 由程序显式完成,不使用网络、计时器、浏览器、React 或模型。本文没有运行真实 Cursor 会话,也没有验证它的日志收集服务、界面操作或修复效果;下面的日志是这个实验自己产生的 JSON。

发起顺序 r1、r2,完成顺序 r2、r1;旧实现回退,代次修复丢弃 r1
图 1:原创时序示意,来源是本文实际执行的两个场景。

2. 先约定搜索框应该怎样变化

本例把 UI 压缩成四个字段:query 是当前输入,value 是当前结果,error 是当前错误,status 取 idle、loading、success 或 error。每次发起搜索都清空 value 和 error,并进入 loading。只有这个实例最后发起的请求可以把状态改成 success 或 error。旧请求完成时,即使最新请求仍在等待,也不能暂时填入旧结果。

这是一项明确的产品约定。实际项目也可以保留上一页结果、显示缓存或允许并行面板,但要先写清楚各自的可见状态。本例的 ca@r1、cat@r2 是合成返回值,用 @r1 这样的标记显示响应来源;它们不表示网络地址,也不假设真实接口一定返回请求编号。

输入与完成顺序本例要求的最终状态用途
ca(r1)、cat(r2);r2→r1query=cat,value=cat@r2复现旧响应覆盖
cat(r1)、car(r2)、cat(r3);r2→r3→r1query=cat,value=cat@r3识别同词的不同请求
ca(r1)、cat(r2);r1→r2query=cat,value=cat@r2顺序对照,单独通过不足以检出故障

3. 在 Cursor 中先提交一份可检验的调查任务

截至本文核对日,Cursor 的 Debug Mode 文档把工作过程描述为:提出假设、加入日志、由使用者复现、分析运行记录、定点修复,再复验并清理插桩;适用问题包括执行顺序和异步竞态。可以在 Agent 的模式选择器中选择 Debug Mode。下面是准备好本文文件后可使用的任务说明,属于建议输入,没有声称这段提示词已经由 Cursor 执行。

请调查本文 search-lab.mjs 的搜索结果回退。
先使用 broken 模式,暂不改 eligible,也不要改测试预期。
预期:最后发起的请求拥有结果和错误状态的写入权。
复现:ca(r1)、cat(r2),让 r2 先完成,再让 r1 完成。
请列出可以由本轮日志区分的假设,并说明需要观察哪个字段。
读完指定 run_id 的完整记录后,引用 seq 和 request_id 解释根因。
日志缺失、来自别轮或缺少处理决定时,请报告未知并补采集。
确认根因后再提出最小修改,保留同词请求与失败场景的测试。

如果 Cursor 为项目加入了自己的采集代码,先核对本轮实际生成的复现步骤和日志位置。本文的字段和事件名由示例定义,不是 Cursor 的原生 trace 格式;也不需要向某个假定端口发送请求。原始搜索词、响应正文和错误内容可能包含用户资料,迁移到真实项目时应改用合成值或只记录必要标识。

4. 让假设对应到具体的一行证据

阅读代码前,可以先列出下面几种解释。假设的意义在于规定下一步看什么;只有出现相应记录,才能缩小原因。本文的受控返回值排除了接口生成错误内容这条路径,但真实服务仍需要自己的请求与响应核对。

假设需要看到的证据本例的判定
输入状态被旧回调改回 ca旧请求完成后 state.query 变成 ca故障日志仍是 query=cat,未发生这种变化
服务把 cat 请求答成了 car2 的 resolved.value 就是 ca 的结果本例明确给 r2 返回 cat@r2
旧请求不受限制地写入当前结果latest_id=2 时,r1 仍产生 committed故障运行 seq=6 直接显示这一点
日志不是本轮完整记录run_id 或 instance_id 不同,seq 缺失,缺少终结决定只能标为 UNKNOWN,先补采集

这些判断只适用于给出的输入、代码和记录。不要把“没有看到某事件”自动理解为“事件没有发生”。如果最后一行丢了,恰好可能丢掉的就是旧请求的写入。

5. 文件一 search-lab.mjs:可控请求、原始故障和插桩

新建一个空目录,把下面内容保存为 search-lab.mjs。controlledTransport 保存 Promise 的 resolve 和 reject,由测试指定哪一个请求先完成。createSearch 的 broken 分支无条件接收完成结果,这就是本例的原始故障。query 分支保留待证伪的修补;generation 分支是随后验证的修复。

run_id 标记一轮场景,instance_id 区分搜索实例,seq 表示该实例内记录顺序。request_id 在发起时分配,latest_id 记录写日志时的最新编号。日志分别覆盖发起、完成和是否提交三个关键位置。requireRun 只检查这个有限实验的采集链是否完整;它不会代替状态断言,也不适合直接作为生产日志校验器。

// Offline teaching fixture. Events are ours, not a Cursor log schema.
export function controlledTransport() {
  const pending = new Map();
  return {
    request(query, id) {
      if (pending.has(id)) throw new Error(`duplicate request ${id}`);
      let resolve, reject;
      const promise = new Promise((ok, fail) => { resolve = ok; reject = fail; });
      pending.set(id, { resolve, reject });
      return promise;
    },
    settle(id, outcome) {
      const slot = pending.get(id);
      if (!slot) throw new Error(`no pending request ${id}`);
      if (Object.hasOwn(outcome, 'error')) slot.reject(new Error(outcome.error));
      else if (Object.hasOwn(outcome, 'value')) slot.resolve(outcome.value);
      else throw new Error('outcome needs value or error');
      pending.delete(id);
    }
  };
}

export function createSearch({ request, mode = 'generation',
  runId, instanceId = 'search', events = [] }) {
  if (!['broken', 'query', 'generation'].includes(mode)) throw new Error('bad mode');
  if (!runId) throw new Error('runId required');
  let latest = 0, seq = 0;
  const state = { query: '', value: null, error: null, status: 'idle' };
  const read = () => ({ ...state });
  const emit = (event, id, query, extra = {}) => events.push({
    run_id: runId, instance_id: instanceId, seq: ++seq,
    event, request_id: id, latest_id: latest, query, ...extra
  });
  function eligible(id, query) {
    if (mode === 'broken') return true; // Original bug: accept every completion.
    if (mode === 'query') return query === state.query; // Deliberately weak fix.
    return id === latest; // Fixed: identity of the latest request in this instance.
  }
  async function search(query) {
    const id = ++latest;
    Object.assign(state, { query, value: null, error: null, status: 'loading' });
    emit('issued', id, query);
    let outcome;
    try {
      const value = await request(query, id);
      outcome = { value, error: null, status: 'success' };
      emit('resolved', id, query, { value });
    } catch (error) {
      const message = error instanceof Error ? error.message : String(error);
      outcome = { value: null, error: message, status: 'error' };
      emit('rejected', id, query, { error: message });
    }
    if (eligible(id, query)) {
      Object.assign(state, outcome);
      emit('committed', id, query, { state: read() });
    } else emit('discarded', id, query);
    return read();
  }
  return { search, read, events };
}

export async function scenario(mode, queries, schedule, runId) {
  const transport = controlledTransport();
  const app = createSearch({ request: transport.request, mode, runId });
  const jobs = queries.map(q => app.search(q));
  const snapshots = [];
  for (const step of schedule) {
    transport.settle(step.id, step);
    await jobs[step.id - 1]; // Wait for this completion's state update; no sleep.
    snapshots.push(app.read());
  }
  if (schedule.length !== queries.length) throw new Error('incomplete schedule');
  await Promise.all(jobs);
  return { state: app.read(), snapshots, events: app.events };
}

// A bounded capture check for this fixture, not a general log validator.
export function requireRun(events, runId, instanceId, count) {
  const unknown = reason => { throw new Error(`UNKNOWN: ${reason}`); };
  if (!events.length) unknown('no events');
  const issued = new Set(), arrived = new Set(), finished = new Set();
  for (const [index, e] of events.entries()) {
    if (e.run_id !== runId || e.instance_id !== instanceId) unknown('mixed identity');
    if (e.seq !== index + 1) unknown('missing or reordered sequence');
    if (!Number.isInteger(e.request_id) || e.request_id < 1 || e.request_id > count)
      unknown('unexpected request');
    const id = e.request_id;
    if (e.event === 'issued') {
      if (issued.has(id)) unknown('duplicate issue');
      issued.add(id);
    } else if (['resolved', 'rejected'].includes(e.event)) {
      if (!issued.has(id) || arrived.has(id)) unknown('unexpected completion');
      arrived.add(id);
    } else if (['committed', 'discarded'].includes(e.event)) {
      if (!arrived.has(id) || finished.has(id)) unknown('unexpected decision');
      finished.add(id);
    } else unknown('unknown event');
  }
  if (issued.size !== count || arrived.size !== count || finished.size !== count)
    unknown('incomplete capture');
  return events; // Passing does not prove correctness; inspect decisions next.
}

6. 文件二 demo.mjs:每次完成哪个请求都写在输入里

保存为 demo.mjs,与上一份文件放在同一目录。这里没有等待 100 毫秒之类的时间猜测:settle(2, …) 明确完成 r2,await 对应任务后才执行下一步。运行标签由命令行传入;重新采集时换一个标签,并保留各自的文件,避免把两次运行都叫 trial-01。

import { scenario, requireRun } from './search-lab.mjs';
const label = process.argv.find(arg => arg.startsWith('--run='))?.slice(6);
if (!label || !/^[a-zA-Z0-9_-]+$/.test(label))
  throw new Error('use --run=<new-label>, for example --run=trial-01');
const race = [{ id: 2, value: 'cat@r2' }, { id: 1, value: 'ca@r1' }];
const aba = [{ id: 2, value: 'car@r2' }, { id: 3, value: 'cat@r3' },
  { id: 1, value: 'cat@r1' }];
const ordered = [{ id: 1, value: 'ca@r1' }, { id: 2, value: 'cat@r2' }];
const inputs = [
  ['race-broken', 'broken', ['ca', 'cat'], race],
  ['race-fixed', 'generation', ['ca', 'cat'], race],
  ['aba-query', 'query', ['cat', 'car', 'cat'], aba],
  ['aba-fixed', 'generation', ['cat', 'car', 'cat'], aba],
  ['ordered-broken', 'broken', ['ca', 'cat'], ordered],
  ['ordered-fixed', 'generation', ['ca', 'cat'], ordered]
];
const results = [];
for (const [name, mode, queries, schedule] of inputs) {
  const runId = `${label}/${name}`;
  const result = await scenario(mode, queries, schedule, runId);
  requireRun(result.events, runId, 'search', queries.length);
  results.push({ runId, mode, queries, schedule, ...result });
}
if (process.argv.includes('--json')) {
  console.log(JSON.stringify({ node: process.version, results }, null, 2));
} else {
  for (const r of results) console.log(`${r.runId}: query=${r.state.query} value=${r.state.value} status=${r.state.status}`);
  for (const r of results.slice(0, 2)) {
    console.log(`\n${r.runId} events:`);
    for (const e of r.events) console.log(JSON.stringify(e));
  }
}
node --version
node demo.mjs --run=trial-01
node demo.mjs --run=trial-02 --json > trial-02.json

第二条命令在终端显示六个场景的结果和前两个场景的完整事件。第三条使用新标签 trial-02 再执行相同的确定性输入,生成包含全部输入、快照和日志的 JSON。调查真实问题时,也要一次执行使用一个新标签,切勿把相同标签的输出追加到同一份证据里。未提供 --run 时脚本会报错并以非零状态退出。

7. 实际输出:有错的实现也能通过顺序场景

Node v24.19.0 下,node demo.mjs --run=trial-01 的完整标准输出如下。前六行中,ordered-broken 与 ordered-fixed 一样,所以只跑顺序完成场景会漏掉回退。aba-query 表明搜索词重新变回 cat 后,旧的 cat 响应依然能越过字符串检查。

trial-01/race-broken: query=cat value=ca@r1 status=success
trial-01/race-fixed: query=cat value=cat@r2 status=success
trial-01/aba-query: query=cat value=cat@r1 status=success
trial-01/aba-fixed: query=cat value=cat@r3 status=success
trial-01/ordered-broken: query=cat value=cat@r2 status=success
trial-01/ordered-fixed: query=cat value=cat@r2 status=success

trial-01/race-broken events:
{"run_id":"trial-01/race-broken","instance_id":"search","seq":1,"event":"issued","request_id":1,"latest_id":1,"query":"ca"}
{"run_id":"trial-01/race-broken","instance_id":"search","seq":2,"event":"issued","request_id":2,"latest_id":2,"query":"cat"}
{"run_id":"trial-01/race-broken","instance_id":"search","seq":3,"event":"resolved","request_id":2,"latest_id":2,"query":"cat","value":"cat@r2"}
{"run_id":"trial-01/race-broken","instance_id":"search","seq":4,"event":"committed","request_id":2,"latest_id":2,"query":"cat","state":{"query":"cat","value":"cat@r2","error":null,"status":"success"}}
{"run_id":"trial-01/race-broken","instance_id":"search","seq":5,"event":"resolved","request_id":1,"latest_id":2,"query":"ca","value":"ca@r1"}
{"run_id":"trial-01/race-broken","instance_id":"search","seq":6,"event":"committed","request_id":1,"latest_id":2,"query":"ca","state":{"query":"cat","value":"ca@r1","error":null,"status":"success"}}

trial-01/race-fixed events:
{"run_id":"trial-01/race-fixed","instance_id":"search","seq":1,"event":"issued","request_id":1,"latest_id":1,"query":"ca"}
{"run_id":"trial-01/race-fixed","instance_id":"search","seq":2,"event":"issued","request_id":2,"latest_id":2,"query":"cat"}
{"run_id":"trial-01/race-fixed","instance_id":"search","seq":3,"event":"resolved","request_id":2,"latest_id":2,"query":"cat","value":"cat@r2"}
{"run_id":"trial-01/race-fixed","instance_id":"search","seq":4,"event":"committed","request_id":2,"latest_id":2,"query":"cat","state":{"query":"cat","value":"cat@r2","error":null,"status":"success"}}
{"run_id":"trial-01/race-fixed","instance_id":"search","seq":5,"event":"resolved","request_id":1,"latest_id":2,"query":"ca","value":"ca@r1"}
{"run_id":"trial-01/race-fixed","instance_id":"search","seq":6,"event":"discarded","request_id":1,"latest_id":2,"query":"ca"}

关注 race-broken 的 seq=4 到 seq=6:r2 已经把 cat@r2 写入,随后 r1 在 latest_id=2 的情况下又提交了 ca@r1。当前 query 没有改变,变化的是 value。race-fixed 的相同调度仍然收到 r1,但最后记录 discarded,状态保留 cat@r2。这比只看最终打印“成功”更能解释修复实际拦住了哪一步。

8. 为什么 query 相等还不够

有人会把接受条件改成 query === state.query。对 ca→cat 的回退,它确实能够挡住旧 ca。换成 cat→car→cat 时,第一个和第三个请求的字符串相同,身份却不同。我们让 r2 先完成,r3 再完成,最后才完成 r1;此时字符串比较会让 r1 把 cat@r3 覆盖成 cat@r1。

cat、car、cat 三次请求的比较;字符串条件错误放行 r1,代次条件只接收 r3
图 2:同词负例保留响应来源,避免把相同搜索词当成相同操作。

本例按每个控制器实例里的递增编号判断 id === latest。编号不需要跟随响应传回:它已经由当前 search 调用保存在闭包中。只要所有可见结果和错误写入都经过这道检查,同一个实例内已经过期的请求就不能更新这些状态。这个条件不取消请求,也不减少流量;它处理的是本地状态归属。

9. 文件三 clean-search.mjs:把成功和失败放在同一道检查后

保存以下清理版。它移除了诊断事件和两个错误模式,保留同样的状态约定。成功和失败都先生成 outcome,再判断本次 id 是否仍为 latest,避免旧请求的 catch 或 finally 单独修改当前错误、结果或 loading。最新请求失败时,本例应显示该失败,不能用随后完成的旧成功响应掩盖它。

// Same state contract after removing all diagnostic logging and bad modes.
export function createCleanSearch(request) {
  let latest = 0;
  const state = { query: '', value: null, error: null, status: 'idle' };
  const read = () => ({ ...state });
  async function search(query) {
    const id = ++latest;
    Object.assign(state, { query, value: null, error: null, status: 'loading' });
    let outcome;
    try {
      outcome = { value: await request(query, id), error: null, status: 'success' };
    } catch (error) {
      outcome = { value: null,
        error: error instanceof Error ? error.message : String(error), status: 'error' };
    }
    if (id === latest) Object.assign(state, outcome);
    return read();
  }
  return { search, read };
}

递增变量位于工厂函数内部,两个搜索框分别创建控制器即可拥有独立代次。不要放成一个所有组件共享的全局计数器。真实界面还需要根据框架处理卸载、缓存、取消和重试等生命周期;这里没有实现这些功能。React 官方 useEffect 示例也指出响应可能乱序,并通过清理时忽略失效结果来处理相应场景;这为失效结果隔离提供原理参考,本文没有测试 React 实现。

10. 文件四 search.test.mjs:把结果写死,再逐次检查状态

预期值写在测试文件中,没有拿 generation 的输出来生成答案。六个基础用例包括两个已知错误输出:断言 broken 最后得到 ca@r1、query 最后得到 cat@r1,是为了证明反例确实存在,不代表这两个实现符合搜索框约定。后续测试再断言修复版本必须满足固定的正确状态。

除了最终值,测试还比较每次完成后的完整快照。旧请求先失败时,最新请求仍应保持 loading;旧请求后失败时,最新成功不能被抹掉;最新请求失败后,旧成功也不能让页面重新显示成功。清理版重复执行同词回退和这些失败场景。

import test from 'node:test';
import assert from 'node:assert/strict';
import { controlledTransport, createSearch, scenario, requireRun } from './search-lab.mjs';
import { createCleanSearch } from './clean-search.mjs';
const race = [{ id: 2, value: 'cat@r2' }, { id: 1, value: 'ca@r1' }];
const aba = [{ id: 2, value: 'car@r2' }, { id: 3, value: 'cat@r3' },
  { id: 1, value: 'cat@r1' }];
const ordered = [{ id: 1, value: 'ca@r1' }, { id: 2, value: 'cat@r2' }];
const success = (query, value) => ({ query, value, error: null, status: 'success' });
const loading = query => ({ query, value: null, error: null, status: 'loading' });
const failure = (query, error) => ({ query, value: null, error, status: 'error' });
const cases = [
  ['race broken', 'broken', ['ca', 'cat'], race, success('cat', 'ca@r1')],
  ['race fixed', 'generation', ['ca', 'cat'], race, success('cat', 'cat@r2')],
  ['same query weak fix', 'query', ['cat', 'car', 'cat'], aba, success('cat', 'cat@r1')],
  ['same query fixed', 'generation', ['cat', 'car', 'cat'], aba, success('cat', 'cat@r3')],
  ['ordered broken', 'broken', ['ca', 'cat'], ordered, success('cat', 'cat@r2')],
  ['ordered fixed', 'generation', ['ca', 'cat'], ordered, success('cat', 'cat@r2')]
];
for (const [name, mode, queries, schedule, expected] of cases) {
  test(name, async () => {
    const result = await scenario(mode, queries, schedule, name);
    assert.deepEqual(result.state, expected); // Literal contract, not another implementation.
  });
}
for (const [mode, expected] of [
  ['broken', ['issued:1', 'issued:2', 'resolved:2', 'committed:2', 'resolved:1', 'committed:1']],
  ['generation', ['issued:1', 'issued:2', 'resolved:2', 'committed:2', 'resolved:1', 'discarded:1']]
]) test(`${mode} exact event order`, async () => {
  const r = await scenario(mode, ['ca', 'cat'], race, mode);
  assert.deepEqual(r.events.map(e => `${e.event}:${e.request_id}`), expected);
});
test('stale success cannot replace latest success at any settlement', async () => {
  const r = await scenario('generation', ['cat', 'car', 'cat'], aba, 'snapshots');
  assert.deepEqual(r.snapshots, [loading('cat'), success('cat', 'cat@r3'), success('cat', 'cat@r3')]);
});
const errorCases = [
  ['old failure arrives first', [{ id: 1, error: 'old failed' }, { id: 2, value: 'cat@r2' }],
    [loading('cat'), success('cat', 'cat@r2')]],
  ['old failure arrives last', [{ id: 2, value: 'cat@r2' }, { id: 1, error: 'old failed' }],
    [success('cat', 'cat@r2'), success('cat', 'cat@r2')]],
  ['latest failure survives old success', [{ id: 2, error: 'latest failed' }, { id: 1, value: 'ca@r1' }],
    [failure('cat', 'latest failed'), failure('cat', 'latest failed')]]
];
for (const [name, schedule, expected] of errorCases) test(name, async () => {
  const r = await scenario('generation', ['ca', 'cat'], schedule, name);
  assert.deepEqual(r.snapshots, expected);
});
test('two instances have independent generations', async () => {
  const ta = controlledTransport(), tb = controlledTransport();
  const a = createSearch({ request: ta.request, runId: 'two', instanceId: 'a' });
  const b = createSearch({ request: tb.request, runId: 'two', instanceId: 'b' });
  const a1 = a.search('ca'), a2 = a.search('cat'), b1 = b.search('dog');
  tb.settle(1, { value: 'dog@b1' }); await b1;
  ta.settle(2, { value: 'cat@a2' }); await a2;
  ta.settle(1, { value: 'ca@a1' }); await a1;
  assert.deepEqual(a.read(), success('cat', 'cat@a2'));
  assert.deepEqual(b.read(), success('dog', 'dog@b1'));
});
test('synchronous request throw becomes latest error', async () => {
  const app = createSearch({ request: () => { throw new Error('sync failed'); }, runId: 'sync' });
  await app.search('cat');
  assert.deepEqual(app.read(), failure('cat', 'sync failed'));
});
test('evidence capture accepts only the complete expected run', async () => {
  const r = await scenario('generation', ['ca', 'cat'], race, 'capture');
  assert.equal(requireRun(r.events, 'capture', 'search', 2), r.events);
  assert.throws(() => requireRun([], 'capture', 'search', 2), /UNKNOWN: no events/);
  assert.throws(() => requireRun(r.events.slice(0, -1), 'capture', 'search', 2), /UNKNOWN: incomplete capture/);
  assert.throws(() => requireRun(r.events.filter(e => e.seq !== 3), 'capture', 'search', 2), /UNKNOWN: missing/);
  const mixed = r.events.map(e => ({ ...e })); mixed[2].run_id = 'another-run';
  assert.throws(() => requireRun(mixed, 'capture', 'search', 2), /UNKNOWN: mixed identity/);
  const otherInstance = r.events.map(e => ({ ...e })); otherInstance[2].instance_id = 'other';
  assert.throws(() => requireRun(otherInstance, 'capture', 'search', 2), /UNKNOWN: mixed identity/);
});
for (const [name, queries, schedule, expected] of [
  ['clean same query', ['cat', 'car', 'cat'], aba,
    [loading('cat'), success('cat', 'cat@r3'), success('cat', 'cat@r3')]],
  ...errorCases.map(([name, schedule, expected]) => [`clean ${name}`, ['ca', 'cat'], schedule, expected])
]) test(name, async () => {
  const t = controlledTransport(), app = createCleanSearch(t.request);
  const jobs = queries.map(q => app.search(q)), snapshots = [];
  for (const step of schedule) {
    t.settle(step.id, step); await jobs[step.id - 1]; snapshots.push(app.read());
  }
  await Promise.all(jobs);
  assert.deepEqual(snapshots, expected);
});
node --test --test-reporter=spec search.test.mjs

本次实际运行结果为 tests 19、pass 19、fail 0、cancelled 0、skipped 0、todo 0。测试包括 6 个基础场景、2 个固定事件序列、1 个逐次状态检查、3 个异步失败场景、1 个多实例场景、1 个同步抛错场景、1 个采集完整性测试和 4 个清理后回归场景。测试框架同时显示每项耗时,耗时不作为验收指标。

如果断言失败,先核对四个文件是否完整复制、命令是否在同一目录执行,以及 Node 版本是否与实测环境一致。事件顺序不匹配时检查 schedule 中的 id 和每步 await,不要加 sleep 让测试碰运气,也不要直接把 expected 改成当前错误输出。本文验证的是列出的状态契约和场景,19 项通过并不等于实际应用的全部行为都已验收。

11. 日志缺失时,结论先停在未知

2026-01-30 的一条社区反馈提到,根因尚未确定时就给出修复,会让使用者不得不回滚后继续调查。2026-05-15 的另一条日志讨论描述了文件存在但搜索未找到的情况。2026-08-04 的后续讨论回复也澄清了日志已经写入,按实际文件提供给 Agent 后可读取。这些都是带日期的历史案例,不能据此断言今天的版本仍有相同故障,也不能把帖子标题中的“没有写入”当成已核实结论。

遇到类似情况,先确认当前这次复现生成了什么文件或终端输出,再按确切位置读取;核对 run_id、实例和请求数是否对应刚才的操作。若文件确实没有生成,保留复现步骤和采集错误继续排查。若已经生成但未读到,可以将本轮必要记录明确提供给 Agent。不要把别轮日志拼起来,也不必为了这个例子改整个仓库的忽略规则。

空日志、缺行和混入其他运行均标记 UNKNOWN;完整后再检查旧请求处理与固定状态预期
图 3:采集检查只决定证据是否够用,正确性仍由运行记录和独立断言确认。

本文的 requireRun 对空数组、截断末尾、漏掉中间 seq、混入另一个 run_id 都会抛出 UNKNOWN 错误。这四种情况已在测试中实际构造。即便它返回了完整日志,仍要核对 committed 或 discarded 对应的请求,以及每次完成后的状态;采集完整本身不证明修复正确。

12. 带回真实项目时,把验收问题问到写入点

完成离线练习后,可以让 Cursor 回到项目里找到实际结果、错误和加载状态的写入位置,沿用已经写清楚的产品约定。真实网络无法像本例一样只调用 settle 排定顺序时,可以使用项目已有的可控测试替身复现;再另做真实服务和界面的验收。若想引入取消请求或数据缓存,也应分别说明它们要解决什么,并保留失效结果不能提交的检查。

请基于本轮完整日志说明:
1. 哪个请求是最后发起的,哪一个旧请求试图写入?给出对应 seq。
2. 本次补丁在哪里统一保护成功、错误和加载状态?
3. ca→cat 的乱序、cat→car→cat 的同词回退,以及新旧请求失败各得到什么?
4. 哪些断言的预期在修复前已经确定?移除诊断代码后是否仍通过?
5. 哪些环节还没在真实应用验证?如果证据缺失,请列出下一步采集内容。

最后保留复现输入、失败用例和修复后的固定断言,清理临时诊断代码。对这个搜索框,值得留下来的证据很具体:旧请求在最新代次已经变化后完成,写入被拒绝,当前结果和错误状态仍属于最后一次操作。

这篇文章实测了 Cursor Debug Mode 自动修复吗?

没有。实测的是 Node v24.19.0 的离线搜索控制器和 19 项测试;Cursor 的工作流程依据官方文档,真实客户端、模型和日志收集器没有运行。

为什么比较搜索词相等仍会出现回退?

cat→car→cat 会产生两个搜索词相同但身份不同的请求。旧 cat 最后返回时,字符串检查会放行它;本例用每个实例内递增的请求编号区分。

旧请求失败也需要检查代次吗?

需要。本例的结果、错误和状态统一受代次检查保护。旧失败不能覆盖最新成功,旧成功也不能掩盖最新失败。

按顺序完成的测试通过,能说明修复好了么?

不能。本文的原始错误实现也能在顺序完成时得到正确最终值,必须保留乱序和同词负例,以及每次完成后的状态断言。

run_id 和 discarded 是 Cursor 规定的字段吗?

不是。这些字段和事件由本文示例定义,用来说明本轮运行和状态写入的关系。真实 Cursor 的采集方式应以当前会话和官方文档为准。