feat/log-system:为三个 Eyrie 运行时进程统一接入结构化诊断日志

eyrie · main...feat/log-system · 2025 · 自包含,读完即弃

17 commits
105 文件
+5491 / −336
~40% 是测试代码

写法说明:本文按「逐跳走读」展开——每个机制都给真实代码片段(取自分支、经裁剪,青色斜体注释为解读所加,灰色斜体是源码原注释的保留或意译),每段代码标注所在文件。每条旅程结尾有一张「排查路标」:将来出问题时,症状对应去哪个文件看哪个函数。

1TL;DR

这个 PR 新建了 packages/log@eyrie/log)作为共用底座,为三个运行时进程——daemon(本地 AI 服务)、cli(命令行工具)、shell-main(Electron 主进程)——统一接入结构化诊断日志,并在 desktop renderer 端提供日志查看器 IPC 入口。

以前各端或依赖裸 pino 实例、或没有文件落盘、或把 Codex 子进程 stderr 原文(可能含 API key)纳入日志。现在每个进程启动时调 createRootLogger,写 JSON Lines 到 $EYRIE_HOME/log/<source>/<source>.log(单文件上限 10 MiB,保留 5 份滚动),进程退出前等待 flush 完成后才 process.exit。Desktop renderer 可通过 Electron IPC 读取历史日志(tail)或订阅实时流;renderer 侧全局 unhandled error 也通过 IPC 写入 shell-main 日志文件。

PR 还搭带了四项独立交付:任务卡右键菜单(archive/delete/priority)、任务子树级联归档/删除、P4 优先级级别、以及 CI 颜色 token 校验脚本。

2变更地图

apps/desktop
2427 行 · 42%
packages/log
1702 行 · 29%
apps/daemon
622 行 · 11%
apps/cli
323 行 · 6%
scripts + eslint
357 行 · 6%
packages/api+client
112 行 · 2%

测试行约占 40%(2332 / 5827 净增行)。apps/desktop 量最大但设计密度参差:日志 IPC 是核心,任务卡右键菜单等是搭带功能,互不依赖。

子系统设计重心(要细读)可放心略过
packages/log全部新建:root.tsfile-sink.tslive-hub.tsreader.tsserializer.tscontext.tspino-roll.d.ts(类型补丁),package.json
apps/desktopshell-log-bridge.tsquit.tserror-reporting.tsboard-remove-model.tsuse-deselect-removed-task.tsclient-provider.tsxcreateDaemonConnectionReportercontext-menu.tsx(Radix 薄包装),i18n JSON 文案,tailwind 颜色 token
apps/daemonindex.ts 初始化时序、app.tswithLogCtxcodex/index.ts stderr 策略、trpc/services.ts logs 服务其余文件大多是 pino 调用签名的机械对齐替换
apps/cliindex.ts 生命周期包装、helpers.ts 错误路径 async 化、output.ts logCliFailure依赖声明
packages/api/clientLogService 接口、logsRouterCreateTransportOptions 连接回调bun.lock

3架构一图流

以前 · 无结构化文件日志

daemon
pino → stderr only
无文件落盘
cli
consola/stderr
无文件落盘
shell-main
无结构化日志
无文件落盘
renderer
console.error
不上报 main

现在 · 统一 JSON Lines + IPC

daemon
@eyrie/log
daemon.log
cli
@eyrie/log
cli.log
shell-main
@eyrie/log
shell-main.log
renderer
IPC → shell-main logger
shell-main.log
desktop UI
IPC tail/subscribe
shell-main.log
desktop UI
tRPC logs.*
daemon.log

4数据与状态先行

读旅程之前先认识三个核心类型,它们是整个系统的词汇表。

LogEntry — 写入磁盘的最终行形状

packages/log/src/index.ts类型定义
export type LogEntry = {
  time: number        // Unix ms,来自发出方进程
  rootId: string      // 每次 createRootLogger 生成一个 UUID;跨重启可区分
  seq: number         // 该 rootId 下单调递增序号,与 rootId 构成消费方去重 id
  level: LogLevel     // 'info' | 'warn' | 'error' | 'fatal'
  source: LogSource   // 'daemon' | 'shell-main' | 'cli'
  component: string   // 调用方传入的模块名,如 'daemon.agent'
  msg: string
  code?: string       // error/fatal 无显式 code 时自动填 'NO_SOURCE_CODE'
  err?: SerializedError
  dropped?: number    // sink 降级时跳过的条数,恢复后附在下一条成功写入上
  extra?: Record<string, unknown>  // withLogCtx 上下文 + call-site extra 合并结果
}

LogReadRecord — 消费方(tRPC/IPC)收到的 DTO

packages/log/src/index.ts类型定义
export type LogReadRecord = {
  entry: LogEntry | ReaderSyntheticEntry  // ReaderSyntheticEntry = 文件损坏行的占位哨兵
  source: LogSource
  origin: 'live' | 'file'   // live = LiveLogHub 实时推送;file = 文件 tail 读取
  file?: string
  offset?: number            // 文件内字节偏移,用于排序
  id: string                 // 'daemon:${rootId}:${seq}'(正常行)或 'daemon:${file}:${offset}'(坏行)
}

IPC channel 常量 — desktop 三层共享的协议契约

apps/desktop/src/shared/logs.tschannel 常量(三层共用)
export const REPORT_RENDERER_ERROR_CHANNEL  = 'eyrie:renderer-error'     // renderer→main,单向
export const SHELL_LOGS_TAIL_CHANNEL        = 'eyrie:shell-logs-tail'     // invoke
export const SHELL_LOGS_SUBSCRIBE_CHANNEL   = 'eyrie:shell-logs-subscribe' // invoke
export const SHELL_LOGS_UNSUBSCRIBE_CHANNEL = 'eyrie:shell-logs-unsubscribe' // invoke
export const SHELL_LOGS_RECORD_CHANNEL      = 'eyrie:shell-logs-record'   // main→preload,广播

5底座:packages/log

@eyrie/log 是本次 PR 全量新建的共用包,提供两个导出入口:@eyrie/log(纯类型)和 @eyrie/log/node(运行时实现)。三个进程都依赖它,先把底座走通。

5.1写入路径:emitLog → sink → 磁盘

进程内只有一个全局 currentRootRootState),由 createRootLogger 安装。getLogger(component) 返回的 logger 不持有任何状态,每次调用时才惰性读取 currentRoot——createRootLogger 之前的写请求静默丢弃。

packages/log/src/node/root.tsemitLog
export function emitLog(component, level, msg, fields = {}) {
  const root = currentRoot         // 调用时才读,不是创建 logger 时
  if (!root || !shouldEmit(root.minLevel, level)) return
  const entry = buildEntry(root, component, level, msg, fields)
  for (const sink of root.sinks) {
    try { sink.write(entry) } catch { // sinks are allowed to degrade }
  }
  root.hub.publish(entry)           // 同步推送给 LiveLogHub
}

buildEntry 做三件事:序列化 err(含循环引用检测)、从 AsyncLocalStorage 读取 withLogCtx 注入的关联字段并与 call-site extra 合并、以及递增 root.seq

fileSink.write 是同步方法(接口要求),但底层流是异步初始化的,实际写入通过 void streamPromise.then(writeEntry) 排入微任务队列——fire-and-forget,流初始化失败时丢弃并计数。

packages/log/src/node/file-sink.tsfire-and-forget 写盘
write(entry) {
  void streamPromise.then(
    (stream) => writeEntry(stream, state, entry),
    (error) => { markDegraded(state, error); state.dropped += 1 },
  )
},

降级恢复时,withDropped 把累计丢弃数附加到成功写入的那条dropped 字段,然后清零。历史丢失内容不补写。

5.2读取与实时订阅

readLogTail 从文件末尾只读最后 10 MiB,截断处跳过不完整的第一行,逐行 JSON 解析,按 time + offset 升序返回 LogReadRecord[]

LiveLogHub 是进程内纯手工 AsyncIterable 多路广播,无回放缓冲。每个订阅者持有独立的 queue(已到达未取)和 waiting(悬挂 resolve):

packages/log/src/node/live-hub.tspushRecord
function pushRecord(subscriber, record) {
  if (subscriber.closed) return
  if (subscriber.waiting) {
    const resolve = subscriber.waiting
    subscriber.waiting = undefined
    resolve({ done: false, value: record })  // 消费方正在等:直接交付
    return
  }
  subscriber.queue.push(record)              // 消费方还没来 next():入队
}

6旅程 A:业务代码写一条日志到磁盘

以 daemon 里任意 logger.error(...) 为例,走完整链路。

全景 · 涉及 4 个文件
业务代码
daemon/src/agent/...
emitLog
packages/log/src/node/root.ts
fileSink.write
packages/log/src/node/file-sink.ts
pino-roll Writable
→ daemon.log

A.1根 Logger 初始化

daemon 的 main() 函数第一行就调 createRootLogger——早于文件系统初始化和加锁。这保证了加锁失败等启动错误也能落盘。

apps/daemon/src/index.ts启动序列(节选)
async function main(): Promise {
  createRootLogger({
    source: 'daemon',
    level: process.env.EYRIE_LOG_LEVEL ?? 'info',
    destinations: [diagnosticFileSink('daemon', DAEMON_LOG_DIR)],
  })                         // 第一行:此后任何错误都能落盘
  mkdirSync(paths.home, { recursive: true })
  // ... lockfile, DB open ...
}

diagnosticFileSink('daemon', dir)fileSink 的固化封装:文件名 daemon.log,单文件上限 10 MiB,保留 5 份滚动文件。CLI 和 shell-main 用相同调用模式,只换 source 和目录。

A.2emitLog 发射链

组件代码调 logger.error('Codex exited', { err, extra: { exitCode } }),进入 emitLogbuildEntry 先从 AsyncLocalStorage 读取 withLogCtx 注入的 requestId 等字段,与 call-site extra 合并(call-site 优先级更高),然后拼出完整 LogEntry(含自增 seq)。error/fatal 级别若无显式 code 字段,自动填 'NO_SOURCE_CODE'

A.3fileSink 写盘

sink.write(entry) 调用后立刻返回;void streamPromise.then(writeEntry) 在当前 tick 结束后执行。writeEntrysafe-stable-stringify(保证 key 顺序稳定,对循环引用不崩溃),写出一行 JSON + 换行。流由 pino-roll 管理,按 10 MiB 自动滚动,文件名规律为 daemon.log(当前)和 daemon.1.logdaemon.2.log...(历史)。

进程启动时 retainPriorRolledFiles 会先清理上次进程留下的超量旧文件(pino-roll 的保留逻辑只在当前进程的滚动事件触发)。

排查路标 · 旅程 A
症状从哪下手
写日志后文件没内容file-sink.ts:看 markDegraded,检查 console.error 输出的降级消息
日志文件超 5 个file-sink.tsretainPriorRolledFiles 只清理 baseName.N.ext 格式,检查文件名是否匹配
error 日志无 code 字段root.tsdefaultCode,确认 level 是 error/fatal
日志里没有 requestIdapp.ts:检查 withLogCtx 中间件是否包裹了该请求路径

7旅程 B:Desktop 读取 shell-main 日志

renderer 通过 Electron IPC 读取/订阅 shell-main.log,全程经过 preload 边界。重点在 main 侧的 shell-log-bridge.ts

全景 · 涉及 4 层
renderer
shellLogs.tail / subscribe
preload
apps/desktop/src/preload/index.ts
shell-log-bridge
apps/desktop/src/main/shell-log-bridge.ts
@eyrie/log/node
readLogTail / subscribeLiveLogs

B.1Tail 历史快照

renderer 调 window.eyrie.shellLogs.tail({ limit: 100 }),preload 直接 ipcRenderer.invoke(SHELL_LOGS_TAIL_CHANNEL, input),main 端的 handler 做两道校验后调底层函数。

apps/desktop/src/main/shell-log-bridge.tsreadShellLogTail
export async function readShellLogTail(logDir, input): Promise<LogReadRecord[]> {
  const request = parseTailInput(input)   // 校验 source 只能是 'shell-main'
  return readLogTail({
    source: SHELL_LOG_SOURCE,
    dir: logDir,
    limit: normalizeTailLimit(request.limit),  // 夹至 1–5000,默认 500
  })
}

parseTailInput 拒绝非 'shell-main' 的 source(该阶段仅暴露一个日志来源,注释标注"this phase"预留扩展)。limit 被 normalizeTailLimit 夹在 1–5000 之间,防止 renderer 传入恶意大值。

B.2实时订阅流

实时订阅比 tail 多一层 ID 机制:preload 维护一个模块级计数器,每次 subscribe 调用生成 "shell-log-N" 作为 subscriptionId。main 端把每条 live 记录广播给所有 preload 监听者,preload 靠 subscriptionId 过滤自己的记录——这避免了 main 需要持有 renderer 引用才能回调的循环依赖。

apps/desktop/src/preload/index.tssubscribe — 由 preload 生成 subscriptionId
subscribe: (input, onRecord) => {
  const subscriptionId = `shell-log-${nextShellLogSubscriptionId}`
  nextShellLogSubscriptionId += 1
  const listener = (_event, payload) => {
    if (payload.subscriptionId === subscriptionId) onRecord(payload.record)  // 过滤自己的记录
  }
  ipcRenderer.on(SHELL_LOGS_RECORD_CHANNEL, listener)
  void ipcRenderer.invoke(SHELL_LOGS_SUBSCRIBE_CHANNEL, { ...input, subscriptionId })
  return () => {                          // 返回 unsubscribe 函数
    ipcRenderer.off(SHELL_LOGS_RECORD_CHANNEL, listener)
    void ipcRenderer.invoke(SHELL_LOGS_UNSUBSCRIBE_CHANNEL, subscriptionId)
  }
}

main 端的 pump 循环在 pumpShellLogSubscription 里运行(不 await,后台跑)。pump 的 finally 块有一处关键保护:只有当 subscriptions.get(id)?.iterator === iterator(当前 iterator 仍是 map 里的那个)才清理 map。这处理了「同一 subscriptionId 打开第二个订阅」的替换场景——第一个 pump 的 finally 看到 map 里已是第二个 iterator,静默退出。

apps/desktop/src/main/shell-log-bridge.tspumpShellLogSubscription — finally 的 iterator 标识比对
finally {
  const current = subscriptions.get(subscriptionId)
  if (current?.iterator === iterator) {  // 只有「我」是当前持有者才清理
    subscriptions.delete(subscriptionId)
    sender.off('destroyed', current.cleanupSender)
    await iterator.return?.()
  }
}
排查路标 · 旅程 B
症状从哪下手
tail 返回空数组,文件有内容shell-log-bridge.tsparseTailInput source 校验,normalizeTailLimit limit 值
实时订阅收不到新日志shell-log-bridge.tspumpShellLogSubscription pump 是否还在跑;检查 sender 是否已 destroyed
同一窗口多个订阅互相干扰preload/index.tsnextShellLogSubscriptionId 计数器 + listener 的 subscriptionId 过滤

8旅程 C:Renderer 错误上报到 shell-main 日志

renderer 全局 window.errorunhandledrejection 不再 console.error,而是通过 IPC 写入 shell-main.log,供排查时与 shell-main 的其他日志关联。

全景 · 涉及 3 层
window error
error-reporting.ts
preload send
REPORT_RENDERER_ERROR_CHANNEL
shell-log-bridge
parseRendererErrorReport + logger.error
shell-main.log

renderer 侧的 installRendererErrorReporting 捕获 ErrorEventPromiseRejectionEvent,调 errorLike 把任意 thrown value 归一化为 { message, stack? },避免把 ErrorEvent 等宿主对象传过 preload 边界(preload 无法序列化)。

apps/desktop/src/renderer/lib/error-reporting.tserrorLike — 归一化 thrown value
function errorLike(value: unknown): { message: string; stack?: string } {
  if (value instanceof Error) {
    return value.stack ? { message: value.message, stack: value.stack } : { message: value.message }
  }
  return { message: nonErrorMessage(value) }  // 非 Error:string/symbol/primitive/null/undefined 各有分支
}

main 侧的 parseRendererErrorReport 做第二道过滤:只接受 isRecord(input) && typeof input.message === 'string' 且非空的输入,并对每个字符串字段调 truncate(message: 2000 字符,stack: 16000 字符,route/windowId: 200–2000 字符)。两道过滤的意图:renderer 侧防止序列化失败,main 侧防止恶意/意外大负载写入日志文件。

除全局 error 监听外,还有三个显式触发点走同一路径:optimistic-mutation.ts 的 overlapping write 警告、i18n missing key(仅开发环境)、以及 createDaemonConnectionReporter 的 WebSocket 非正常断连报告。

排查路标 · 旅程 C
症状从哪下手
renderer 抛错但 shell-main.log 无记录error-reporting.tsinstallRendererErrorReporting 是否已挂载;parseRendererErrorReport 的 message 非空校验
日志里 message 被截断shell-log-bridge.tstruncate 各字段上限(message 2000 / stack 16000 字符)
daemon 断线日志重复client-provider.tsxcreateDaemonConnectionReporteroutageReported flag,onConnectionOpen 会重置它

9进程退出前 flush

日志文件写入是异步的(pino-roll 底层用 SonicBoom)。每个进程都需要在 process.exit 前等待 closeLogs() 完成,否则最后几条日志会丢失。三个进程各自的做法:

daemonshutdown()、加锁失败路径、以及 main fatal 路径三处各自 await closeLogs()

clirunCli()try/finally 保证正常退出路径;exitWithError 内在 process.exit 前显式 await closeLogs()(这意味着两者可能双调,closeLogs 内部通过清空 currentRoot 保证幂等)。

shell-main:最复杂——Electron 的 will-quit 事件在 macOS 可多次触发(Dock 二次点击等)。createLogFlushWillQuitHandler 用两个 flag 实现幂等:

apps/desktop/src/main/quit.tswill-quit 双闸
export function createLogFlushWillQuitHandler(closeLogs, quit, reportError) {
  let closing = false   // flush 已启动
  let closed = false    // flush 已完成
  return (event) => {
    if (closed) return  // flush 完成后直接放行
    event.preventDefault()
    if (closing) return // 已在 flush:再次拦截,不重复启动
    closing = true
    void (async () => {
      try { await closeLogs() }
      catch (error) { reportError(error) }
      finally { closed = true; quit() }    // flush 完成后才真正退出
    })()
  }
}
新进程接入日志的标准步骤:① 在 main() 第一行调 createRootLogger({ source, destinations: [diagnosticFileSink(source, logDir)] });② 在所有退出路径(正常/异常/fatal)await closeLogs();③ 用 getLogger('module.name') 获取组件级 logger。

10Codex stderr 隐私保护

Codex 子进程的 stderr 可能含 API key(测试 fixture 里就有 OPENAI_API_KEY=...)。以前把最后一行 stderr 原文写入日志;现在改为只记录「有无 stderr 产生」这一布尔事实。

以前(main 分支)
stderrLines: string[] 有界环形缓冲(50 行)
子进程退出时:logger.error({ stderr: this.stderrLines.at(-1) }, 'Codex exited')
现在
stderrEmitted = false 布尔标志
child.stderr.on('data', () => { this.stderrEmitted = true })
child.stderr.resume()(消费流防背压堵死)
子进程退出时:logger.error('Codex exited', { extra: { ...(stderrEmitted ? { stderrPresent: true } : {}) } })

child.stderr.resume() 是关键:必须消费流,否则 pipe buffer 填满后子进程会阻塞。测试 tests/codex-runner.test.ts 的断言显式验证序列化后的日志 JSON 不包含 codex-test-api-key-secret

11心智模型补丁

可以假设 daemon 日志只在进程 stderr 里,不写文件。 daemon/cli/shell-main 三个进程均写结构化 JSON Lines 到 $EYRIE_HOME/log/<source>,单文件 10 MiB,最多保留 5 份。
可以假设 renderer 错误只在浏览器控制台可见。 renderer 全局 error/unhandledrejection 通过 IPC 写入 shell-main.log,可与 shell-main 的其他结构化日志关联排查。
可以假设 Electron 进程收到 will-quit 后立即退出。 shell-main 的 will-quit 被 preventDefault() 拦截,等待 closeLogs() flush 完成后才真正 quit(),期间重复触发 will-quit 幂等处理。
可以假设 Codex 子进程退出日志里包含 stderr 原文(可能含密钥)。 stderr 原文不进日志,只留 stderrPresent: true 布尔标记。resume() 消费流防背压。
可以用 getLogger 在模块顶层获取 logger 后立即写日志。 getLogger 返回的 logger 在 createRootLogger 之前调用会静默丢弃(不报错)。进程入口必须先初始化 root。
task priority 最高 P3,max(3) P4 级别加入,schema 改为 max(4)PriorityIndicator 由色块方块改为方向箭头图标。

12新词表

packages/log 核心概念
LogSource'daemon' | 'shell-main' | 'cli',标识日志条目来自哪个运行时进程,写入每条 LogEntry
rootId每次 createRootLogger 生成的 UUID,同一进程同一次启动共享一个,跨重启可区分
diagnosticFileSinkfileSink 的固化封装:文件名 ${source}.log,10 MiB 上限,保留 5 份滚动文件
closeLogsflush 所有注册 sink 的缓冲并关闭文件句柄;process.exit 前必须 await
LiveLogHub进程内无 replay 的 fan-out:emitLog 时同步推给所有 subscribeLiveLogs() 调用者
ReaderSyntheticEntryreader 在读到无法解析的文件行时构造的哨兵条目,带 rawPreview 和错误 code,让消费方知道有损坏行但不崩溃
withLogCtx基于 AsyncLocalStorage,在当前 async 调用链中注入关联字段(如 requestId),emitLog 时自动合并进 entry.extra
desktop IPC 层新概念
subscriptionIdpreload 本地生成的字符串("shell-log-1""shell-log-2"…),用于在广播 channel 上区分同 window 内的多个并发订阅
createLogFlushWillQuitHandler工厂函数,返回幂等的 Electron will-quit handler,内含 closing/closed 双 flag
createDaemonConnectionReporter工厂函数,把 WebSocket 断线报告去重为每次断连只写一条日志,reconnect 后复位
搭带交付(与日志无关)
dropTaskSubtreeFromBoard乐观删除/归档时在 board 快照上一并删掉所有后代,用固定点循环处理任意排列的 cards
hasPendingTaskRemoval替代旧 hasPendingTaskDelete,同时覆盖 delete 和 archive,且检查任务的整条祖先链而不只是单个 id
PriorityIndicator优先级符号组件,由色块方块改为 Lucide 方向箭头(P0=ChevronsUp…P4=ChevronsDown)

13测试与风险地图

有测试兜底的行为文件
fileSink 写入后可被 readLogTail 读回(真实 tmpdir 集成)packages/log/src/node/log.test.ts
sink 降级恢复后 dropped 附在下一条成功写入上packages/log/src/node/log.test.ts
LiveLogHub 推送 + 订阅者 closepackages/log/src/node/log.test.ts
跨进程文件保留逻辑(只删 baseName.N.ext 格式)packages/log/src/node/log.test.ts
will-quit 多次触发幂等,flush 失败仍能 quitapps/desktop/src/main/quit.test.ts
shell-log-bridge:tail/subscribe/unsubscribe/sender destroyed 全链路apps/desktop/tests/shell-log-bridge.test.ts
preload subscriptionId 生成与 payload 过滤apps/desktop/tests/preload.test.ts
renderer error 归一化(Error/非 Error/循环引用)apps/desktop/tests/error-reporting.test.ts
Codex stderr 内容不进日志文件apps/daemon/tests/codex-runner.test.ts
daemon logs.tail + logs.subscribe tRPC 过程apps/daemon/tests/diagnostic-log.test.ts
board-remove-model 固定点子树收集(深层乱序、环路保护)apps/desktop/src/renderer/entities/task/board-remove-model.test.ts
use-deselect-removed-task 的 seenPresent 边沿触发与 archive pending 延迟apps/desktop/src/renderer/features/tasks/use-deselect-removed-task.test.tsx
薄冰(重要逻辑无测试兜底)风险
closeLogs() 双调的幂等性🟡 CLI 正常退出和错误退出路径各调一次,root.tscloseLogs 里先清空 currentRoot,第二次调时 root 已是 undefined 直接 return——逻辑上安全,但无显式测试
daemon fatal 路径的 flush+exit 时序🟡 void closeLogs().finally(() => process.exit(1)),难以在 vitest 里断言 exit 时序
shell-main 日志文件滚动端到端🟠 10 MiB 上限和 5 份保留的实际滚动行为无 desktop 侧集成测试
logsRouter.tail 的 limit 默认值 500 和 callService 错误路径🟡 daemon 侧 diagnostic-log.test.ts 间接覆盖,router 层无直接断言
StorageUsageDto.diagnosticLogs 在目录不存在时静默返回 0⚪ 防御路径,功能影响小
合并前确认eslint.config.jsapps/** 全局加了 'no-console': 'error',所有现有 console.log/warn/error 调用必须在合并前清理或移入测试文件(测试文件有 'no-console': 'off' 豁免)。

14覆盖声明

全量精读范围:packages/log 所有新建文件、apps/daemon 所有变更文件、apps/cli 所有变更文件、packages/api/packages/client 所有变更文件、apps/desktop/src/main/shell-log-bridge.tsquit.tserror-reporting.tspreload/index.tsshared/logs.tsclient-provider.tsx(ConnectionReporter 部分)、board-remove-model.tseslint.config.js

desktop 搭带功能(TaskCardMenuuseArchiveTaskuse-session-task-deletion-cascade.tsPriorityPickeruse-deselect-removed-task.ts)的设计要点来自 subagent 精读,经本文作者对关键文件二次 Read 验证后引用,代码片段均取自亲读文件。