跳转到内容

LSP / 诊断 / 上下文增强

旧版 eec0843c 源码提交 eec0843ce422
状态已完成
难度较难
预计阅读45 分钟
  • packages/opencode/src/lsp/lsp.ts
  • packages/opencode/src/lsp/client.ts
  • packages/opencode/src/lsp/diagnostic.ts
  • packages/opencode/src/tool/edit.ts
  • packages/opencode/src/tool/write.ts

源码基线:eec0843ce422。本章追踪的是“编辑一个 TypeScript 文件后”的典型源码路径,不是实际启动 language server 的运行记录;具体可用 server 取决于本机配置与依赖。

学完本章,你应该能:

  • 画出文件工具、LSP.Service、LSP client、language server 和 Agent loop 的关系。
  • 解释为什么 LSP 是反馈层,而不是 Agent 核心循环或编译器替代品。
  • 追踪一次 edit 后的 touchFile -> getClients -> open/change -> wait -> diagnostics -> report
  • 区分 push 与 pull diagnostics,以及 documentfull 等待模式。
  • 说明 client 复用、in-flight 去重、broken 标记解决了什么问题。
  • 识别 LSP 被禁用、server 不匹配、启动失败、等待超时和陈旧诊断等失败边界。

LSP 是 OpenCode 的“IDE 反馈层”:文件落盘后,它让匹配的 language server 重新看到该文档,等待一小段时间收集诊断,再把有限的 error 文本放回工具结果,让 Agent 有机会继续修。

本章的中心问题是:

文件写成功只证明 I/O 成功;OpenCode 如何快速知道这次修改是否引入了语言级错误,又如何避免 LSP 故障反过来阻断所有编辑?

2. 先看全图:LSP 在哪里,不在哪里

Section titled “2. 先看全图:LSP 在哪里,不在哪里”
EditTool / WriteTool
|
| 文件已经落盘
v
LSP.touchFile(file, mode)
|
v
getClients(file)
- 扩展名匹配
- 计算项目 root
- 复用或启动 client
|
v
client.notify.open
didOpen 或 didChange
|
v
push notification <---- Language Server ---- pull request
| |
+---------------+----------------------+
v
client diagnostics cache
|
v
LSP.diagnostics + report
|
v
tool output -> 下一轮模型

边界先说清:

  • 通用机制:变更后通知分析器、等待新结果、聚合、限流、反馈。
  • OpenCode 产品层:按 instance 缓存 client、Effect service、Bus event、push/pull 兼容、tool output 格式。
  • LSP 不负责:决定改什么、批准能否改、运行单元测试、证明业务正确。

LSP.Interface 还暴露 hover、definition、references、symbols 和调用层级查询,说明它也能提供语义上下文;本章的具体旅程只跟踪编辑后的 diagnostics,因为这是文件工具自动进入的路径。接口见 packages/opencode/src/lsp/lsp.ts:123-138

packages/opencode/src/lsp/lsp.ts packages/opencode/src/lsp/lsp.ts:123-138
123export interface Interface {定义数据结构约束。124  readonly init: () => Effect.Effect<void>Effect 异步工作流。125  readonly status: () => Effect.Effect<Status[]>Effect 异步工作流。126  readonly hasClients: (file: string) => Effect.Effect<boolean>Effect 异步工作流。127  readonly touchFile: (input: string, diagnostics?: "document" | "full") => Effect.Effect<void>处理语言服务诊断。128  readonly diagnostics: () => Effect.Effect<Record<string, LSPClient.Diagnostic[]>>处理语言服务诊断。129  readonly hover: (input: LocInput) => Effect.Effect<any>Effect 异步工作流。130  readonly definition: (input: LocInput) => Effect.Effect<any[]>Effect 异步工作流。131  readonly references: (input: LocInput) => Effect.Effect<any[]>Effect 异步工作流。132  readonly implementation: (input: LocInput) => Effect.Effect<any[]>Effect 异步工作流。133  readonly documentSymbol: (uri: string) => Effect.Effect<(DocumentSymbol | Symbol)[]>Effect 异步工作流。134  readonly workspaceSymbol: (query: string) => Effect.Effect<Symbol[]>Effect 异步工作流。135  readonly prepareCallHierarchy: (input: LocInput) => Effect.Effect<any[]>Effect 异步工作流。136  readonly incomingCalls: (input: LocInput) => Effect.Effect<any[]>Effect 异步工作流。137  readonly outgoingCalls: (input: LocInput) => Effect.Effect<any[]>Effect 异步工作流。138}

3. 最小机制:把编辑后的文件重新交给分析器

Section titled “3. 最小机制:把编辑后的文件重新交给分析器”

忽略 JSON-RPC 和多语言细节,最小闭环是:

1async function diagnoseAfterEdit(file) {定义一段可复用逻辑。2  const clients = await clientsFor(file)3  for (const client of clients) {遍历集合。4    const version = await client.openOrChange(file)5    await client.waitForDiagnostics(file, version)处理语言服务诊断。6  }7  return onlyErrorsAndLimit(merge(clients.map(c => c.diagnostics)))处理语言服务诊断。8}

为什么不能只读一次 client.diagnostics?因为文件刚改完时,缓存可能仍是旧版本。touchFile 先发文档通知,再按 mode 等待 push 或 pull 结果,正是在处理这个时间差。

Java 开发者可以把它类比为一个长期运行的 LanguageIntelligenceService,但不要类比成每次调用 javac:LSP client 与 server 会跨多次编辑复用,并通过 JSON-RPC 增量保持文档状态。

4. 三层对象:server 配置、client 连接、service 路由

Section titled “4. 三层对象:server 配置、client 连接、service 路由”
真实标识职责生命周期
server 描述LSPServer.Info支持哪些扩展、怎样找 root、怎样 spawn配置/内置注册
clientLSPClient.InfoJSON-RPC 连接、文档版本、诊断缓存按 server + root 复用
serviceLSP.Service根据文件选 client,聚合诊断和语义查询instance 级

LSP.state 在配置允许时加载内置 server,再应用禁用、覆盖或自定义 command/env/initialization。若 cfg.lsp 为假值,则所有 LSP 都禁用。见 packages/opencode/src/lsp/lsp.ts:148-199

packages/opencode/src/lsp/lsp.ts packages/opencode/src/lsp/lsp.ts:148-199
148    const state = yield* InstanceState.make<State>(等待 Effect 结果。149      Effect.fn("LSP.state")(function* (ctx) {处理语言服务诊断。150        const cfg = yield* config.get()读取运行配置。151152        const servers: Record<string, LSPServer.Info> = {}处理语言服务诊断。153154        if (!cfg.lsp) {按条件进入分支。155          log.info("all LSPs are disabled")处理语言服务诊断。156        } else {157          for (const server of Object.values(LSPServer)) {处理语言服务诊断。158            servers[server.id] = server159          }160161          filterExperimentalServers(servers, flags)162163          if (cfg.lsp !== true) {按条件进入分支。164            for (const [name, item] of Object.entries(cfg.lsp)) {遍历集合。165              const existing = servers[name]166              if (item.disabled) {按条件进入分支。167                log.info(`LSP server ${name} is disabled`)处理语言服务诊断。168                delete servers[name]169                continue170              }171              servers[name] = {172                ...existing,173                id: name,174                root: existing?.root ?? (async (_file, ctx) => ctx.directory),175                extensions: item.extensions ?? existing?.extensions ?? [],176                spawn: async (root) => ({177                  process: lspspawn(item.command[0], item.command.slice(1), {处理命令执行。178                    cwd: root,179                    env: { ...process.env, ...item.env },180                  }),181                  initialization: item.initialization,182                }),183              }184            }185          }186187          log.info("enabled LSP servers", {处理语言服务诊断。188            serverIds: Object.values(servers)189              .map((server) => server.id)190              .join(", "),191          })192        }193194        const s: State = {195          clients: [],196          servers,197          broken: new Set(),198          spawning: new Map(),199        }

5. 一条具体源码旅程:edit 后诊断 src/config.ts

Section titled “5. 一条具体源码旅程:edit 后诊断 src/config.ts”

假设 EditTool 已经成功修改 /workspace/project/src/config.ts。这是教学输入,不代表本章真的改过该文件。

EditTool 在写入、格式化、发布事件并更新 metadata 之后才调用:

1yield* lsp.touchFile(filePath, "document")等待 Effect 结果。2const diagnostics = yield* lsp.diagnostics()处理语言服务诊断。3const block = LSP.Diagnostic.report(filePath, diagnostics[normalizedFilePath] ?? [])处理语言服务诊断。

路径:packages/opencode/src/tool/edit.ts:151-197

packages/opencode/src/tool/edit.ts packages/opencode/src/tool/edit.ts:151-197
151              yield* afs.writeWithDirs(filePath, Bom.join(contentNew, desiredBom))准备修改文件内容。152              if (yield* format.file(filePath)) {等待 Effect 结果。153                contentNew = yield* Bom.syncFile(afs, filePath, desiredBom)等待 Effect 结果。154              }155              yield* bus.publish(File.Event.Edited, { file: filePath })广播状态变化。156              yield* bus.publish(FileWatcher.Event.Updated, {广播状态变化。157                file: filePath,158                event: "change",159              })160              diff = trimDiff(161                createTwoFilesPatch(准备修改文件内容。162                  filePath,163                  filePath,164                  normalizeLineEndings(contentOld),165                  normalizeLineEndings(contentNew),166                ),167              )168            }).pipe(Effect.orDie),Effect 异步工作流。169          )170171          let additions = 0172          let deletions = 0173          for (const change of diffLines(contentOld, contentNew)) {遍历集合。174            if (change.added) additions += change.count || 0按条件进入分支。175            if (change.removed) deletions += change.count || 0按条件进入分支。176          }177          const filediff: Snapshot.FileDiff = {178            file: filePath,179            patch: diff,准备修改文件内容。180            additions,181            deletions,182          }183184          yield* ctx.metadata({等待 Effect 结果。185            metadata: {186              diff,187              filediff,188              diagnostics: {},处理语言服务诊断。189            },190          })191192          let output = "Edit applied successfully."193          yield* lsp.touchFile(filePath, "document")等待 Effect 结果。194          const diagnostics = yield* lsp.diagnostics()处理语言服务诊断。195          const normalizedFilePath = AppFileSystem.normalizePath(filePath)读写本地文件。196          const block = LSP.Diagnostic.report(filePath, diagnostics[normalizedFilePath] ?? [])处理语言服务诊断。197          if (block) output += `\n\nLSP errors detected in this file, please fix:\n${block}`处理语言服务诊断。

这建立了明确的因果顺序:language server 读取的是最终落盘且可能已格式化的文件,而不是模型最初提交的 newString

5.2 getClients 先判断“谁能理解这个文件”

Section titled “5.2 getClients 先判断“谁能理解这个文件””

getClients(file) 首先拒绝 instance 外文件,然后取扩展名,遍历 server:

  1. server 有 extensions 且不包含 .ts:跳过。
  2. server.root(file, ctx) 找不到 root:跳过。
  3. (root + server.id) 已在 broken:跳过。
  4. 已有相同 root/server client:复用。
  5. 正在 spawn:等待同一个 in-flight Promise。
  6. 否则 schedule 一次 spawn + initialize。

路径:packages/opencode/src/lsp/lsp.ts:211-299

packages/opencode/src/lsp/lsp.ts packages/opencode/src/lsp/lsp.ts:211-299
211    const getClients = Effect.fnUntraced(function* (file: string) {Effect 异步工作流。212      const ctx = yield* InstanceState.context等待 Effect 结果。213      if (!containsPath(file, ctx)) return [] as LSPClient.Info[]处理语言服务诊断。214      const s = yield* InstanceState.get(state)等待 Effect 结果。215      return yield* Effect.promise(async () => {Effect 异步工作流。216        const extension = path.parse(file).ext || file开始解析命令参数。217        const result: LSPClient.Info[] = []处理语言服务诊断。218219        async function schedule(server: LSPServer.Info, root: string, key: string) {处理语言服务诊断。220          const handle = await server221            .spawn(root, ctx, flags)222            .then((value) => {223              if (!value) s.broken.add(key)按条件进入分支。224              return value返回给上一层。225            })226            .catch((err) => {227              s.broken.add(key)228              log.error(`Failed to spawn LSP server ${server.id}`, { error: err })处理语言服务诊断。229              return undefined返回给上一层。230            })231232          if (!handle) return undefined按条件进入分支。233          log.info("spawned lsp server", { serverID: server.id, root })234235          const client = await LSPClient.create({处理语言服务诊断。236            serverID: server.id,237            server: handle,238            root,239            directory: ctx.directory,240            instance: ctx,241          }).catch(async (err) => {242            s.broken.add(key)243            await Process.stop(handle.process)244            log.error(`Failed to initialize LSP client ${server.id}`, { error: err })处理语言服务诊断。245            return undefined返回给上一层。246          })247248          if (!client) return undefined按条件进入分支。249250          const existing = s.clients.find((x) => x.root === root && x.serverID === server.id)251          if (existing) {按条件进入分支。252            await Process.stop(handle.process)253            return existing返回给上一层。254          }255256          s.clients.push(client)257          return client返回给上一层。258        }259260        for (const server of Object.values(s.servers)) {遍历集合。261          if (server.extensions.length && !server.extensions.includes(extension)) continue按条件进入分支。262263          const root = await server.root(file, ctx)264          if (!root) continue按条件进入分支。265          if (s.broken.has(root + server.id)) continue按条件进入分支。266267          const match = s.clients.find((x) => x.root === root && x.serverID === server.id)268          if (match) {按条件进入分支。269            result.push(match)270            continue271          }272273          const inflight = s.spawning.get(root + server.id)274          if (inflight) {按条件进入分支。275            const client = await inflight276            if (!client) continue按条件进入分支。277            result.push(client)278            continue279          }280281          const task = schedule(server, root, root + server.id)282          s.spawning.set(root + server.id, task)283284          task.finally(() => {285            if (s.spawning.get(root + server.id) === task) {按条件进入分支。286              s.spawning.delete(root + server.id)287            }288          })289290          const client = await task291          if (!client) continue按条件进入分支。292293          result.push(client)294          await Bus.publish(ctx, Event.Updated, {})广播状态变化。295        }296297        return result返回给上一层。298      })299    })
getClients:按文件懒启动并复用 LSP client packages/opencode/src/lsp/lsp.ts:211-299

重点观察 clients、spawning、broken 三个状态如何避免重复启动和重复失败。

211    const getClients = Effect.fnUntraced(function* (file: string) {Effect 异步工作流。212      const ctx = yield* InstanceState.context等待 Effect 结果。213      if (!containsPath(file, ctx)) return [] as LSPClient.Info[]处理语言服务诊断。214      const s = yield* InstanceState.get(state)等待 Effect 结果。215      return yield* Effect.promise(async () => {Effect 异步工作流。216        const extension = path.parse(file).ext || file开始解析命令参数。217        const result: LSPClient.Info[] = []处理语言服务诊断。218219        async function schedule(server: LSPServer.Info, root: string, key: string) {处理语言服务诊断。220          const handle = await server221            .spawn(root, ctx, flags)222            .then((value) => {223              if (!value) s.broken.add(key)按条件进入分支。224              return value返回给上一层。225            })226            .catch((err) => {227              s.broken.add(key)228              log.error(`Failed to spawn LSP server ${server.id}`, { error: err })处理语言服务诊断。229              return undefined返回给上一层。230            })231232          if (!handle) return undefined按条件进入分支。233          log.info("spawned lsp server", { serverID: server.id, root })234235          const client = await LSPClient.create({处理语言服务诊断。236            serverID: server.id,237            server: handle,238            root,239            directory: ctx.directory,240            instance: ctx,241          }).catch(async (err) => {242            s.broken.add(key)243            await Process.stop(handle.process)244            log.error(`Failed to initialize LSP client ${server.id}`, { error: err })处理语言服务诊断。245            return undefined返回给上一层。246          })247248          if (!client) return undefined按条件进入分支。249250          const existing = s.clients.find((x) => x.root === root && x.serverID === server.id)251          if (existing) {按条件进入分支。252            await Process.stop(handle.process)253            return existing返回给上一层。254          }255256          s.clients.push(client)257          return client返回给上一层。258        }259260        for (const server of Object.values(s.servers)) {遍历集合。261          if (server.extensions.length && !server.extensions.includes(extension)) continue按条件进入分支。262263          const root = await server.root(file, ctx)264          if (!root) continue按条件进入分支。265          if (s.broken.has(root + server.id)) continue按条件进入分支。266267          const match = s.clients.find((x) => x.root === root && x.serverID === server.id)268          if (match) {按条件进入分支。269            result.push(match)270            continue271          }272273          const inflight = s.spawning.get(root + server.id)274          if (inflight) {按条件进入分支。275            const client = await inflight276            if (!client) continue按条件进入分支。277            result.push(client)278            continue279          }280281          const task = schedule(server, root, root + server.id)282          s.spawning.set(root + server.id, task)283284          task.finally(() => {285            if (s.spawning.get(root + server.id) === task) {按条件进入分支。286              s.spawning.delete(root + server.id)287            }288          })289290          const client = await task291          if (!client) continue按条件进入分支。292293          result.push(client)294          await Bus.publish(ctx, Event.Updated, {})广播状态变化。295        }296297        return result返回给上一层。298      })299    })

spawning 解决并发触发下的重复启动;broken 让当前 instance 不再反复尝试同一 root/server 组合。后者提高稳定性,但也意味着环境在运行中被修好后,不一定会自动重试。

5.3 schedule 把进程变成 JSON-RPC client

Section titled “5.3 schedule 把进程变成 JSON-RPC client”

server spawn 失败或返回空值会标记 broken。进程启动后,LSPClient.create 若初始化失败,也会停止进程并标记 broken。见 packages/opencode/src/lsp/lsp.ts:219-257

packages/opencode/src/lsp/lsp.ts packages/opencode/src/lsp/lsp.ts:219-257
219        async function schedule(server: LSPServer.Info, root: string, key: string) {处理语言服务诊断。220          const handle = await server221            .spawn(root, ctx, flags)222            .then((value) => {223              if (!value) s.broken.add(key)按条件进入分支。224              return value返回给上一层。225            })226            .catch((err) => {227              s.broken.add(key)228              log.error(`Failed to spawn LSP server ${server.id}`, { error: err })处理语言服务诊断。229              return undefined返回给上一层。230            })231232          if (!handle) return undefined按条件进入分支。233          log.info("spawned lsp server", { serverID: server.id, root })234235          const client = await LSPClient.create({处理语言服务诊断。236            serverID: server.id,237            server: handle,238            root,239            directory: ctx.directory,240            instance: ctx,241          }).catch(async (err) => {242            s.broken.add(key)243            await Process.stop(handle.process)244            log.error(`Failed to initialize LSP client ${server.id}`, { error: err })处理语言服务诊断。245            return undefined返回给上一层。246          })247248          if (!client) return undefined按条件进入分支。249250          const existing = s.clients.find((x) => x.root === root && x.serverID === server.id)251          if (existing) {按条件进入分支。252            await Process.stop(handle.process)253            return existing返回给上一层。254          }255256          s.clients.push(client)257          return client返回给上一层。

client 用 server stdout/stdin 建立 JSON-RPC message connection:

1const connection = createMessageConnection(2  new StreamMessageReader(input.server.process.stdout as any),3  new StreamMessageWriter(input.server.process.stdin as any),4)

路径:packages/opencode/src/lsp/client.ts:141-155

packages/opencode/src/lsp/client.ts packages/opencode/src/lsp/client.ts:141-155
141export async function create(input: {对外暴露模块成员。142  serverID: string143  server: LSPServer.Handle处理语言服务诊断。144  root: string145  directory: string146  instance: InstanceContext147}) {148  const logger = log.clone().tag("serverID", input.serverID)149  logger.info("starting client")150  const instance = input.instance151152  const connection = createMessageConnection(153    new StreamMessageReader(input.server.process.stdout as any),154    new StreamMessageWriter(input.server.process.stdin as any),155  )

initialize 有 45 秒 timeout,并声明文档同步、动态注册诊断和 related document 等 capability。失败被包装成 InitializeError。见 packages/opencode/src/lsp/client.ts:18-23packages/opencode/src/lsp/client.ts:246-305

packages/opencode/src/lsp/client.ts packages/opencode/src/lsp/client.ts:18-23
18const DIAGNOSTICS_DEBOUNCE_MS = 15019const DIAGNOSTICS_DOCUMENT_WAIT_TIMEOUT_MS = 5_00020const DIAGNOSTICS_FULL_WAIT_TIMEOUT_MS = 10_00021const DIAGNOSTICS_REQUEST_TIMEOUT_MS = 3_0002223const INITIALIZE_TIMEOUT_MS = 45_000
packages/opencode/src/lsp/client.ts packages/opencode/src/lsp/client.ts:246-305
246  // --- Initialize handshake ---247248  logger.info("sending initialize")249  const initialized = await withTimeout(250    connection.sendRequest<{ capabilities?: ServerCapabilities }>("initialize", {251      rootUri: pathToFileURL(input.root).href,252      processId: input.server.process.pid,253      workspaceFolders: [254        {255          name: "workspace",256          uri: pathToFileURL(input.root).href,257        },258      ],259      initializationOptions: {260        ...input.server.initialization,261      },262      capabilities: {263        window: {264          workDoneProgress: true,265        },266        workspace: {267          configuration: true,268          didChangeWatchedFiles: {269            dynamicRegistration: true,270          },271          diagnostics: {处理语言服务诊断。272            refreshSupport: false,273          },274        },275        textDocument: {276          synchronization: {277            didOpen: true,278            didChange: true,279          },280          diagnostic: {处理语言服务诊断。281            dynamicRegistration: true,282            relatedDocumentSupport: true,283          },284          publishDiagnostics: {处理语言服务诊断。285            versionSupport: false,286          },287        },288      },289    }),290    INITIALIZE_TIMEOUT_MS,291  ).catch((err) => {292    logger.error("initialize error", { error: err })293    throw new InitializeError({ serverID: input.serverID, cause: err })失败时抛出错误。294  })295296  const syncKind = getSyncKind(initialized.capabilities)297  const hasStaticPullDiagnostics = Boolean(initialized.capabilities?.diagnosticProvider)选择模型或 provider。298299  await connection.sendNotification("initialized", {})300301  if (input.server.initialization) {按条件进入分支。302    await connection.sendNotification("workspace/didChangeConfiguration", {303      settings: input.server.initialization,304    })305  }

5.4 touchFile 选择 didOpen 还是 didChange

Section titled “5.4 touchFile 选择 didOpen 还是 didChange”

touchFile 对每个 client 调 notify.open。这个名字容易误导:它既可能 open,也可能 change。

首次见到文件时:

  • 读取磁盘文本;
  • 发送 workspace/didChangeWatchedFiles,类型为 created;
  • 清空该文件的 push/pull cache;
  • 发送 textDocument/didOpen,version = 0;
  • 把文本和版本记入 files

再次触碰时:

  • 发送 watched-files changed;
  • version + 1;
  • 发送 textDocument/didChange
  • 若 server 要求 incremental sync,仍以“覆盖旧全文范围”的单个 change 发送新全文。

路径:packages/opencode/src/lsp/client.ts:594-669

packages/opencode/src/lsp/client.ts packages/opencode/src/lsp/client.ts:594-669
594    notify: {595      async open(request: { path: string }) {596        request.path = Filesystem.normalizePath(597          path.isAbsolute(request.path) ? request.path : path.resolve(input.directory, request.path),598        )599        const text = await Filesystem.readText(request.path)600        const extension = path.extname(request.path)601        const languageId = LANGUAGE_EXTENSIONS[extension] ?? "plaintext"602603        const document = files[request.path]604        if (document !== undefined) {按条件进入分支。605          // Do not wipe diagnostics on didChange. Some servers (e.g. clangd) only606          // re-emit diagnostics when the content actually changes, so clearing607          // here would lose errors for no-op touchFile calls. Let the server's608          // next push/pull overwrite naturally.609          logger.info("workspace/didChangeWatchedFiles", request)610          await connection.sendNotification("workspace/didChangeWatchedFiles", {611            changes: [612              {613                uri: pathToFileURL(request.path).href,614                type: FILE_CHANGE_CHANGED,615              },616            ],617          })618619          const next = document.version + 1620          files[request.path] = { version: next, text }621          logger.info("textDocument/didChange", {622            path: request.path,623            version: next,624          })625          await connection.sendNotification("textDocument/didChange", {626            textDocument: {627              uri: pathToFileURL(request.path).href,628              version: next,629            },630            contentChanges:631              syncKind === TEXT_DOCUMENT_SYNC_INCREMENTAL632                ? [633                    {634                      range: {635                        start: { line: 0, character: 0 },636                        end: endPosition(document.text),637                      },638                      text,639                    },640                  ]641                : [{ text }],642          })643          return next返回给上一层。644        }645646        logger.info("workspace/didChangeWatchedFiles", request)647        await connection.sendNotification("workspace/didChangeWatchedFiles", {648          changes: [649            {650              uri: pathToFileURL(request.path).href,651              type: FILE_CHANGE_CREATED,652            },653          ],654        })655656        logger.info("textDocument/didOpen", request)657        pushDiagnostics.delete(request.path)处理语言服务诊断。658        pullDiagnostics.delete(request.path)处理语言服务诊断。659        await connection.sendNotification("textDocument/didOpen", {660          textDocument: {661            uri: pathToFileURL(request.path).href,662            languageId,663            version: 0,664            text,665          },666        })667        files[request.path] = { version: 0, text }668        return 0返回给上一层。669      },

第二次 touch 不会预先清空 diagnostics,因为 clangd 等 server 对无内容变化的触碰可能不重新发布;贸然清空会把仍有效的错误丢掉。这个取舍由注释和控制流直接说明,见 packages/opencode/src/lsp/client.ts:603-608

packages/opencode/src/lsp/client.ts packages/opencode/src/lsp/client.ts:603-608
603        const document = files[request.path]604        if (document !== undefined) {按条件进入分支。605          // Do not wipe diagnostics on didChange. Some servers (e.g. clangd) only606          // re-emit diagnostics when the content actually changes, so clearing607          // here would lose errors for no-op touchFile calls. Let the server's608          // next push/pull overwrite naturally.

5.5 document 模式同时兼容 push 与 pull

Section titled “5.5 document 模式同时兼容 push 与 pull”

touchFile(file, "document") 记录 after 时间和文档 version,再调用 waitForDiagnostics。document 等待上限为 5 秒,full 为 10 秒,单次 pull request 为 3 秒。见 packages/opencode/src/lsp/client.ts:18-23packages/opencode/src/lsp/lsp.ts:346-366

packages/opencode/src/lsp/client.ts packages/opencode/src/lsp/client.ts:18-23
18const DIAGNOSTICS_DEBOUNCE_MS = 15019const DIAGNOSTICS_DOCUMENT_WAIT_TIMEOUT_MS = 5_00020const DIAGNOSTICS_FULL_WAIT_TIMEOUT_MS = 10_00021const DIAGNOSTICS_REQUEST_TIMEOUT_MS = 3_0002223const INITIALIZE_TIMEOUT_MS = 45_000
packages/opencode/src/lsp/lsp.ts packages/opencode/src/lsp/lsp.ts:346-366
346    const touchFile = Effect.fn("LSP.touchFile")(function* (input: string, diagnostics?: "document" | "full") {处理语言服务诊断。347      log.info("touching file", { file: input })348      const clients = yield* getClients(input)等待 Effect 结果。349      yield* Effect.promise(() =>Effect 异步工作流。350        Promise.all(并行等待多个任务。351          clients.map(async (client) => {352            const after = Date.now()353            const version = await client.notify.open({ path: input })354            if (!diagnostics) return处理语言服务诊断。355            return client.waitForDiagnostics({处理语言服务诊断。356              path: input,357              version,358              mode: diagnostics,处理语言服务诊断。359              after,360            })361          }),362        ).catch((err) => {363          log.error("failed to touch file", { err, file: input })364        }),365      )366    })

push 路径:server 发 textDocument/publishDiagnostics,client 记录时间/version并更新 cache。见 packages/opencode/src/lsp/client.ts:191-208

packages/opencode/src/lsp/client.ts packages/opencode/src/lsp/client.ts:191-208
191  connection.onNotification("textDocument/publishDiagnostics", (params) => {处理语言服务诊断。192    const filePath = getFilePath(params.uri)193    if (!filePath) return按条件进入分支。194    logger.info("textDocument/publishDiagnostics", {处理语言服务诊断。195      path: filePath,196      count: params.diagnostics.length,处理语言服务诊断。197      version: params.version,198    })199    published.set(filePath, {200      at: Date.now(),201      version: typeof params.version === "number" ? params.version : undefined,202    })203    if (shouldSeedDiagnosticsOnFirstPush(input.serverID) && !pushDiagnostics.has(filePath)) {处理语言服务诊断。204      pushDiagnostics.set(filePath, params.diagnostics)处理语言服务诊断。205      return返回给上一层。206    }207    updatePushDiagnostics(filePath, params.diagnostics)处理语言服务诊断。208  })

pull 路径:client 主动请求 textDocument/diagnostic;若 server 动态注册多个 identifier,会并行发出请求,一旦已有一批产生当前文件诊断即可解除等待,较慢结果继续在后台合并。见 packages/opencode/src/lsp/client.ts:332-366packages/opencode/src/lsp/client.ts:451-466

packages/opencode/src/lsp/client.ts packages/opencode/src/lsp/client.ts:332-366
332  async function requestDiagnosticReport(filePath: string, identifier?: string): Promise<DiagnosticRequestResult> {定义一段可复用逻辑。333    const report = await withTimeout(334      connection.sendRequest<DocumentDiagnosticReport | null>("textDocument/diagnostic", {处理语言服务诊断。335        ...(identifier ? { identifier } : {}),336        textDocument: {337          uri: pathToFileURL(filePath).href,338        },339      }),340      DIAGNOSTICS_REQUEST_TIMEOUT_MS,341    ).catch(() => null)342    if (!report) return { handled: false, matched: false, byFile: new Map<string, Diagnostic[]>() }按条件进入分支。343344    const byFile = new Map<string, Diagnostic[]>()345    const push = (target: string, items: Diagnostic[]) => {346      const existing = byFile.get(target) ?? []347      byFile.set(target, existing.concat(items))348    }349350    let handled = false351    let matched = false352    if (Array.isArray(report.items)) {按条件进入分支。353      push(filePath, report.items)354      handled = true355      matched = true356    }357    for (const [uri, related] of Object.entries(report.relatedDocuments ?? {})) {遍历集合。358      const relatedPath = getFilePath(uri)359      if (!relatedPath || !Array.isArray(related.items)) continue按条件进入分支。360      push(relatedPath, related.items)361      handled = true362      matched = matched || relatedPath === filePath363    }364365    return { handled, matched, byFile }返回给上一层。366  }
packages/opencode/src/lsp/client.ts packages/opencode/src/lsp/client.ts:451-466
451  // LATENCY-CRITICAL: dispatch identifier pulls in parallel and unblock once one452  // batch already produced diagnostics for the current file. Let slower pulls keep453  // merging in the background; do not sequence identifier-by-identifier, and do454  // not add a post-match settle/debounce delay. See PR #23771.455  async function requestDocumentDiagnostics(filePath: string) {处理语言服务诊断。456    const state = documentPullState()457    if (!state.supported) return { handled: false, matched: false }按条件进入分支。458    return requestDiagnostics(处理语言服务诊断。459      filePath,460      [461        requestDiagnosticReport(filePath),462        ...state.documentIdentifiers.map((identifier) => requestDiagnosticReport(filePath, identifier)),463      ],464      (results) => hasCurrentFileDiagnostics(filePath, results),处理语言服务诊断。465    )466  }

waitForDocumentDiagnostics 在 pull 未匹配时,会与 fresh push、capability registration 变化竞争,直到结果或超时。见 packages/opencode/src/lsp/client.ts:503-560

packages/opencode/src/lsp/client.ts packages/opencode/src/lsp/client.ts:503-560
503  function waitForFreshPush(request: { path: string; version: number; after: number; timeout: number }) {定义一段可复用逻辑。504    if (request.timeout <= 0) return Promise.resolve(false)按条件进入分支。505    return new Promise<boolean>((resolve) => {返回给上一层。506      let finished = false507      let debounceTimer: ReturnType<typeof setTimeout> | undefined508      let timeoutTimer: ReturnType<typeof setTimeout> | undefined509      let unsub: (() => void) | undefined510      const finish = (result: boolean) => {511        if (finished) return按条件进入分支。512        finished = true513        if (debounceTimer) clearTimeout(debounceTimer)按条件进入分支。514        if (timeoutTimer) clearTimeout(timeoutTimer)按条件进入分支。515        unsub?.()516        resolve(result)517      }518      const schedule = () => {519        const hit = published.get(request.path)520        if (!hit) return按条件进入分支。521        if (typeof hit.version === "number" && hit.version !== request.version) return按条件进入分支。522        if (hit.at < request.after && hit.version !== request.version) return按条件进入分支。523        if (debounceTimer) clearTimeout(debounceTimer)按条件进入分支。524        debounceTimer = setTimeout(() => finish(true), Math.max(0, DIAGNOSTICS_DEBOUNCE_MS - (Date.now() - hit.at)))525      }526527      timeoutTimer = setTimeout(() => finish(false), request.timeout)528      unsub = busRuntime.runSync((svc) =>529        svc530          .subscribeCallback(Event.Diagnostics, (event) => {处理语言服务诊断。531            if (event.properties.path !== request.path || event.properties.serverID !== input.serverID) return按条件进入分支。532            schedule()533          })534          .pipe(Effect.provideService(InstanceRef, instance)),Effect 异步工作流。535      )536      schedule()537    })538  }539540  async function waitForDocumentDiagnostics(request: { path: string; version: number; after?: number }) {处理语言服务诊断。541    const startedAt = request.after ?? Date.now()542    const pushWait = waitForFreshPush({543      path: request.path,544      version: request.version,545      after: startedAt,546      timeout: DIAGNOSTICS_DOCUMENT_WAIT_TIMEOUT_MS,547    })548549    while (Date.now() - startedAt < DIAGNOSTICS_DOCUMENT_WAIT_TIMEOUT_MS) {550      const result = await requestDocumentDiagnostics(request.path)处理语言服务诊断。551      if (result.matched) return按条件进入分支。552      const remaining = DIAGNOSTICS_DOCUMENT_WAIT_TIMEOUT_MS - (Date.now() - startedAt)553      if (remaining <= 0) return按条件进入分支。554      const next = await Promise.race([555        pushWait.then((ready) => (ready ? "push" : ("timeout" as const))),556        waitForRegistrationChange(remaining).then((changed) => (changed ? "registration" : ("timeout" as const))),557      ])558      if (next !== "registration") return按条件进入分支。559    }560  }

5.6 聚合后只把有限 error 交给模型

Section titled “5.6 聚合后只把有限 error 交给模型”

每个 client 内部先合并、去重 push 与 pull diagnostics。service 再把所有已启动 client 的 map 按文件合并。见 packages/opencode/src/lsp/client.ts:167-184packages/opencode/src/lsp/client.ts:671-676packages/opencode/src/lsp/lsp.ts:368-379

packages/opencode/src/lsp/client.ts packages/opencode/src/lsp/client.ts:167-184
167  const pushDiagnostics = new Map<string, Diagnostic[]>()处理语言服务诊断。168  const pullDiagnostics = new Map<string, Diagnostic[]>()处理语言服务诊断。169  const published = new Map<string, { at: number; version?: number }>()170  const diagnosticRegistrations = new Map<string, CapabilityRegistration>()处理语言服务诊断。171  const registrationListeners = new Set<() => void>()172  const mergedDiagnostics = (filePath: string) =>处理语言服务诊断。173    dedupeDiagnostics([...(pushDiagnostics.get(filePath) ?? []), ...(pullDiagnostics.get(filePath) ?? [])])处理语言服务诊断。174  const updatePushDiagnostics = (filePath: string, next: Diagnostic[]) => {处理语言服务诊断。175    pushDiagnostics.set(filePath, next)处理语言服务诊断。176    void busRuntime.runPromise((svc) =>177      svc178        .publish(Event.Diagnostics, { path: filePath, serverID: input.serverID })广播状态变化。179        .pipe(Effect.provideService(InstanceRef, instance)),Effect 异步工作流。180    )181  }182  const updatePullDiagnostics = (filePath: string, next: Diagnostic[]) => {处理语言服务诊断。183    pullDiagnostics.set(filePath, next)处理语言服务诊断。184  }
packages/opencode/src/lsp/client.ts packages/opencode/src/lsp/client.ts:671-676
671    get diagnostics() {处理语言服务诊断。672      const result = new Map<string, Diagnostic[]>()673      for (const key of new Set([...pushDiagnostics.keys(), ...pullDiagnostics.keys()])) {处理语言服务诊断。674        result.set(key, mergedDiagnostics(key))处理语言服务诊断。675      }676      return result返回给上一层。
packages/opencode/src/lsp/lsp.ts packages/opencode/src/lsp/lsp.ts:368-379
368    const diagnostics = Effect.fn("LSP.diagnostics")(function* () {处理语言服务诊断。369      const results: Record<string, LSPClient.Diagnostic[]> = {}处理语言服务诊断。370      const all = yield* runAll(async (client) => client.diagnostics)处理语言服务诊断。371      for (const result of all) {遍历集合。372        for (const [p, diags] of result.entries()) {遍历集合。373          const arr = results[p] || []374          arr.push(...diags)375          results[p] = arr376        }377      }378      return results返回给上一层。379    })

Diagnostic.report 只保留 severity = 1 的 error,每个文件最多输出 20 条,并把 0-based line/character 转成 1-based 文本位置。见 packages/opencode/src/lsp/diagnostic.ts:3-26

packages/opencode/src/lsp/diagnostic.ts packages/opencode/src/lsp/diagnostic.ts:3-26
3const MAX_PER_FILE = 2045export function pretty(diagnostic: LSPClient.Diagnostic) {处理语言服务诊断。6  const severityMap = {7    1: "ERROR",8    2: "WARN",9    3: "INFO",10    4: "HINT",11  }1213  const severity = severityMap[diagnostic.severity || 1]处理语言服务诊断。14  const line = diagnostic.range.start.line + 1处理语言服务诊断。15  const col = diagnostic.range.start.character + 1处理语言服务诊断。1617  return `${severity} [${line}:${col}] ${diagnostic.message}`处理语言服务诊断。18}1920export function report(file: string, issues: LSPClient.Diagnostic[]) {处理语言服务诊断。21  const errors = issues.filter((item) => item.severity === 1)22  if (errors.length === 0) return ""按条件进入分支。23  const limited = errors.slice(0, MAX_PER_FILE)24  const more = errors.length - MAX_PER_FILE25  const suffix = more > 0 ? `\n... and ${more} more` : ""26  return `<diagnostics file="${file}">\n${limited.map(pretty).join("\n")}${suffix}\n</diagnostics>`处理语言服务诊断。

因此:

  • warning/info/hint 仍可能存在于 metadata 的 diagnostics map;
  • EditTool 拼给模型的文本 block 只包含 error;
  • “没有 block”既可能是没有 error,也可能是没有可用 client、等待未得到新结果或 LSP 已被禁用。

6. WriteTool 为什么会看到“其他文件”的错误

Section titled “6. WriteTool 为什么会看到“其他文件”的错误”

WriteTool 同样调用 touchFile(filepath, "document"),然后遍历整个聚合 diagnostics map:当前文件优先报告,其他有 error 的文件最多报告五个。见 packages/opencode/src/tool/write.ts:18-18packages/opencode/src/tool/write.ts:74-90

packages/opencode/src/tool/write.ts packages/opencode/src/tool/write.ts:18-18
18const MAX_PROJECT_DIAGNOSTICS_FILES = 5
packages/opencode/src/tool/write.ts packages/opencode/src/tool/write.ts:74-90
74          let output = "Wrote file successfully."75          yield* lsp.touchFile(filepath, "document")等待 Effect 结果。76          const diagnostics = yield* lsp.diagnostics()处理语言服务诊断。77          const normalizedFilepath = AppFileSystem.normalizePath(filepath)读写本地文件。78          let projectDiagnosticsCount = 0处理语言服务诊断。79          for (const [file, issues] of Object.entries(diagnostics)) {处理语言服务诊断。80            const current = file === normalizedFilepath81            if (!current && projectDiagnosticsCount >= MAX_PROJECT_DIAGNOSTICS_FILES) continue处理语言服务诊断。82            const block = LSP.Diagnostic.report(current ? filepath : file, issues)处理语言服务诊断。83            if (!block) continue按条件进入分支。84            if (current) {按条件进入分支。85              output += `\n\nLSP errors detected in this file, please fix:\n${block}`处理语言服务诊断。86              continue87            }88            projectDiagnosticsCount++处理语言服务诊断。89            output += `\n\nLSP errors detected in other files:\n${block}`处理语言服务诊断。90          }

这对创建公共类型或配置文件很有用:一个文件的变化可能让引用方报错。但它不是完整项目诊断保证:

  • document mode 重点等待当前文件,不等同于完整 workspace 检查;
  • 只有已经启动的 clients 会被 runAll 聚合;
  • 文本输出还限制了其他文件数量。

若需要构建级确信,仍应运行测试、编译或专门的检查命令。

7. 语义上下文:同一 service 还能回答什么

Section titled “7. 语义上下文:同一 service 还能回答什么”

LSP.Service 把位置查询路由给适用 clients:

  • hovertextDocument/hover
  • definitiontextDocument/definition
  • referencestextDocument/references
  • implementationtextDocument/implementation
  • documentSymbol / workspaceSymbol
  • incoming / outgoing call hierarchy

路径:packages/opencode/src/lsp/lsp.ts:381-482

packages/opencode/src/lsp/lsp.ts packages/opencode/src/lsp/lsp.ts:381-482
381    const hover = Effect.fn("LSP.hover")(function* (input: LocInput) {处理语言服务诊断。382      return yield* run(input.file, (client) =>等待 Effect 结果。383        client.connection384          .sendRequest("textDocument/hover", {385            textDocument: { uri: pathToFileURL(input.file).href },386            position: { line: input.line, character: input.character },387          })388          .catch(() => null),389      )390    })391392    const definition = Effect.fn("LSP.definition")(function* (input: LocInput) {处理语言服务诊断。393      const results = yield* run(input.file, (client) =>等待 Effect 结果。394        client.connection395          .sendRequest("textDocument/definition", {396            textDocument: { uri: pathToFileURL(input.file).href },397            position: { line: input.line, character: input.character },398          })399          .catch(() => null),400      )401      return results.flat().filter(Boolean)返回给上一层。402    })403404    const references = Effect.fn("LSP.references")(function* (input: LocInput) {处理语言服务诊断。405      const results = yield* run(input.file, (client) =>等待 Effect 结果。406        client.connection407          .sendRequest("textDocument/references", {408            textDocument: { uri: pathToFileURL(input.file).href },409            position: { line: input.line, character: input.character },410            context: { includeDeclaration: true },411          })412          .catch(() => []),413      )414      return results.flat().filter(Boolean)返回给上一层。415    })416417    const implementation = Effect.fn("LSP.implementation")(function* (input: LocInput) {处理语言服务诊断。418      const results = yield* run(input.file, (client) =>等待 Effect 结果。419        client.connection420          .sendRequest("textDocument/implementation", {421            textDocument: { uri: pathToFileURL(input.file).href },422            position: { line: input.line, character: input.character },423          })424          .catch(() => null),425      )426      return results.flat().filter(Boolean)返回给上一层。427    })428429    const documentSymbol = Effect.fn("LSP.documentSymbol")(function* (uri: string) {处理语言服务诊断。430      const file = fileURLToPath(uri)431      const results = yield* run(file, (client) =>等待 Effect 结果。432        client.connection.sendRequest("textDocument/documentSymbol", { textDocument: { uri } }).catch(() => []),433      )434      return (results.flat() as (DocumentSymbol | Symbol)[]).filter(Boolean)返回给上一层。435    })436437    const workspaceSymbol = Effect.fn("LSP.workspaceSymbol")(function* (query: string) {处理语言服务诊断。438      const results = yield* runAll((client) =>等待 Effect 结果。439        client.connection440          .sendRequest<Symbol[]>("workspace/symbol", { query })441          .then((result) => result.filter((x) => kinds.includes(x.kind)).slice(0, 10))442          .catch(() => [] as Symbol[]),443      )444      return results.flat()返回给上一层。445    })446447    const prepareCallHierarchy = Effect.fn("LSP.prepareCallHierarchy")(function* (input: LocInput) {处理语言服务诊断。448      const results = yield* run(input.file, (client) =>等待 Effect 结果。449        client.connection450          .sendRequest("textDocument/prepareCallHierarchy", {451            textDocument: { uri: pathToFileURL(input.file).href },452            position: { line: input.line, character: input.character },453          })454          .catch(() => []),455      )456      return results.flat().filter(Boolean)返回给上一层。457    })458459    const callHierarchyRequest = Effect.fnUntraced(function* (Effect 异步工作流。460      input: LocInput,461      direction: "callHierarchy/incomingCalls" | "callHierarchy/outgoingCalls",462    ) {463      const results = yield* run(input.file, async (client) => {等待 Effect 结果。464        const items = await client.connection465          .sendRequest<unknown[] | null>("textDocument/prepareCallHierarchy", {466            textDocument: { uri: pathToFileURL(input.file).href },467            position: { line: input.line, character: input.character },468          })469          .catch(() => [] as unknown[])470        if (!items?.length) return []按条件进入分支。471        return client.connection.sendRequest(direction, { item: items[0] }).catch(() => [])返回给上一层。472      })473      return results.flat().filter(Boolean)返回给上一层。474    })475476    const incomingCalls = Effect.fn("LSP.incomingCalls")(function* (input: LocInput) {处理语言服务诊断。477      return yield* callHierarchyRequest(input, "callHierarchy/incomingCalls")等待 Effect 结果。478    })479480    const outgoingCalls = Effect.fn("LSP.outgoingCalls")(function* (input: LocInput) {处理语言服务诊断。481      return yield* callHierarchyRequest(input, "callHierarchy/outgoingCalls")等待 Effect 结果。482    })

多数查询失败会被降级为 null 或空数组,再把多个 client 结果拍平。这体现同一设计:LSP 是增强信息,不应轻易让整个 Agent 工作流崩溃。

证据边界:本章列出的 sourceFiles 证明 service 能力与 JSON-RPC 请求;模型怎样把这些能力作为具体 tool 使用,不在本章这条自动 diagnostics 旅程内。

8. 失败路径:best-effort 到底意味着什么

Section titled “8. 失败路径:best-effort 到底意味着什么”
情况源码行为对文件编辑的影响
LSP 全局禁用server registry 为空编辑成功,无诊断
扩展名或 root 不匹配getClients 返回空编辑成功,无诊断
文件位于 instance 外getClients 返回空LSP 不处理该文件
server spawn 返回空/抛错标记 broken,记录日志编辑成功,本 instance 通常不再重试该组合
client initialize 失败/超时停止进程,标记 broken编辑成功,无该 client 诊断
touch/open/wait 任一步抛错touchFile catch 并记录日志编辑成功,诊断可能缺失或仍是缓存值
push/pull 等待超时wait 返回编辑继续,不能保证拿到本次最新结果
server 返回 warning/infometadata 可保留Diagnostic.report 不把它拼入错误 block
error 超过 20 条截断并显示剩余数量Agent 先看到前 20 条
touchFile 与 diagnostics 聚合 packages/opencode/src/lsp/lsp.ts:346-379

touchFile 捕获异常,因此 LSP 故障通常不会回滚已经完成的文件修改。

346    const touchFile = Effect.fn("LSP.touchFile")(function* (input: string, diagnostics?: "document" | "full") {处理语言服务诊断。347      log.info("touching file", { file: input })348      const clients = yield* getClients(input)等待 Effect 结果。349      yield* Effect.promise(() =>Effect 异步工作流。350        Promise.all(并行等待多个任务。351          clients.map(async (client) => {352            const after = Date.now()353            const version = await client.notify.open({ path: input })354            if (!diagnostics) return处理语言服务诊断。355            return client.waitForDiagnostics({处理语言服务诊断。356              path: input,357              version,358              mode: diagnostics,处理语言服务诊断。359              after,360            })361          }),362        ).catch((err) => {363          log.error("failed to touch file", { err, file: input })364        }),365      )366    })367368    const diagnostics = Effect.fn("LSP.diagnostics")(function* () {处理语言服务诊断。369      const results: Record<string, LSPClient.Diagnostic[]> = {}处理语言服务诊断。370      const all = yield* runAll(async (client) => client.diagnostics)处理语言服务诊断。371      for (const result of all) {遍历集合。372        for (const [p, diags] of result.entries()) {遍历集合。373          const arr = results[p] || []374          arr.push(...diags)375          results[p] = arr376        }377      }378      return results返回给上一层。379    })

“best-effort”不是坏事:它保持文件工具可用。但调用者必须把“诊断为空”和“验证通过”分开。

9. OpenCode 的选择:延迟、完整性与韧性

Section titled “9. OpenCode 的选择:延迟、完整性与韧性”

不用在启动 OpenCode 时拉起所有语言服务,节省资源;代价是第一次触碰某类文件会承担 spawn 与 initialize 延迟。

兼容不同 server 和动态 capability;代价是缓存、时间/version 判定、去重和等待逻辑明显更复杂。

选择三:document 模式给编辑反馈,full 模式留给更广检查

Section titled “选择三:document 模式给编辑反馈,full 模式留给更广检查”

局部修改优先低延迟;代价是相关文件诊断可能不完整。WriteTool 读取聚合缓存能补充一些项目错误,但不能替代全量构建。

选择四:错误降级而不是阻断落盘

Section titled “选择四:错误降级而不是阻断落盘”

语言服务只是增强层,崩溃时仍允许 Agent 工作;代价是成功结果必须携带“验证可能缺失”的认知。

方法一:按 (能力实现, 项目根) 复用重服务

Section titled “方法一:按 (能力实现, 项目根) 复用重服务”

语言服务、索引器、编译 daemon 都适合这样缓存。验证问题:并发首次请求是否会重复启动同一实例?失败后如何退避或恢复?

方法二:等待异步结果时携带版本与时间边界

Section titled “方法二:等待异步结果时携带版本与时间边界”

只等“任意一个结果”容易拿到旧缓存。验证问题:如何证明反馈与本次输入版本相关?

方法三:增强层失败时明确降低置信度

Section titled “方法三:增强层失败时明确降低置信度”

可以继续主流程,但结果语义要区分“无错误”和“未完成检查”。验证问题:调用者能否知道验证器未运行或已超时?

不看源码,用 90 秒回答:

  1. clients / spawning / broken 三个集合分别解决什么问题?
  2. notify.open 为什么第二次会发 didChange
  3. push 与 pull diagnostics 怎样汇合?
  4. 为什么空 diagnostics 不能直接解释为代码正确?

练习梯子:

  1. 定位题:从 EditTooltouchFile 追到 client.notify.open
  2. 时序题:画出“文件 version 1 → didChange → push/pull → report”的序列。
  3. 故障题:假设 typescript language server 初始化超时,说明文件状态和 Agent 可见结果。
  4. 实现题:写一个只支持单 client 的 diagnostics cache,键必须包含文件路径和版本。
  5. 迁移题:把同样的 lazy + inflight + broken 模式用于项目索引器。

12. 最后复盘:反馈有了,谁决定能不能行动?

Section titled “12. 最后复盘:反馈有了,谁决定能不能行动?”

LSP 的闭环是:

文件落盘 -> 选择/启动语言服务 -> 同步文档版本 -> 等待诊断 -> 聚合限流 -> 回填 Agent

它让 Agent 像有了一双 IDE 的眼睛,但眼睛只负责看,不负责授权。文件修改前为什么能暂停等待用户?默认规则为什么有的 allow、有的 ask、有的 deny?“总是允许”又怎样影响后续请求?下一章进入真正的 runtime 闸门:权限、审批与安全边界。