ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

qwen serve Daemon 文件日志器:从设计到落地的持久化诊断方案

qwen serve Daemon 文件日志器:从设计到落地的持久化诊断方案 qwen serve Daemon 文件日志器从设计到落地的持久化诊断方案【免费下载链接】qwen-codeAn open-source AI coding agent that lives in your terminal.项目地址: https://gitcode.com/GitHub_Trending/qw/qwen-codeqwen serve是 qwen-code 的常驻服务模式用于承载 SDK、桌面端与本地进程对 ACP 会话的访问。本文以仓库中的设计文档 docs/design/2026-05-26-daemon-logger-design.md 为主线结合其已落地的实现完整讲解 Daemon 级文件日志器的动机、架构、公开 API、日志格式、配置方式与容错机制。读完你将掌握daemon 日志文件写在哪里、latest软链如何辅助tail -f、QWEN_DAEMON_LOG_FILE等环境变量的精确语义以及实现相对设计文档新增的轮转、保留、并发写锁与降级模式等进阶能力。1. 问题背景为什么 daemon 级诊断需要落盘qwen serve把 daemon 级别的诊断信息生命周期事件、路由错误、ACP 子进程 stderr输出到process.stderr。这在 systemd / Docker 场景下没有问题但对 SDK、桌面端和本地 daemon 场景却很脆弱当客户端看到POST /session/:id/prompt返回 HTTP 500 时路由、会话与调用栈上下文已经随进程消失除非操作者事先手动重定向了 stderr现有的createDebugLogger位于 packages/core/src/utils/debugLogger.ts是会话作用域的它要求存在活跃的DebugLogSession并写入${runtimeBaseDir}/debug/sessionId.txt。而 serve daemon 在任何会话存在之前就已启动因此 daemon 级调用会静默失效同时复用它会破坏按会话维护的debug/latest语义。设计结论是新增一个 daemon 专属的文件 sink对现有 stderr 行为做纯增量扩展additive让 daemon 诊断在没有 shell 重定向的情况下也能留存。2. 设计范围与非目标In scope每个runQwenServe进程初始化一次的 logger文件路径${QWEN_RUNTIME_DIR 或 ~/.qwen}/debug/daemon/daemon-id.log追加模式汇聚以下四类输出runQwenServe.ts的生命周期 / 关闭 / 信号消息server.ts中sendBridgeError的路由错误bridge.ts的writeServeDebugLine当QWEN_SERVE_DEBUG开启时spawnChannel.ts的 ACP 子进程 stderr 转发通过QWEN_DAEMON_LOG_FILE0|false|off|no退出opt-outdaemon 目录下的latest软链便于tail -fserve CLI 文档补充说明。Out of scopeissue 明确的非目标不替代 OpenTelemetry也不新增 daemon 追踪不做结构化企业级日志导出由 issue #2014 另行负责不轮转、不删除既有的会话 debug 日志daemon 日志自身的轮转与大小上限在原始设计中明确延后后续 PR 实现见下文第 11 节。设计阶段仅约定若既有文件异常大启动时输出一条 stderr 警告不做自动处理。3. 架构与模块边界设计将改动收敛在几个文件内依赖图保持不变层新增/变更职责packages/cli/src/serve/daemonLogger.ts新增sink初始化、格式化、追加写文件、tee 到 stderr、flush、维护latest软链packages/cli/src/serve/runQwenServe.ts变更启动时初始化 logger用daemonLog.*替换生命周期writeStderrLine关闭时await flush()把onDiagnosticLine传入 bridgepackages/cli/src/serve/server.ts变更sendBridgeError(...)改为经daemonLog.error(...)路由packages/acp-bridge/src/types.tsBridgeOptions变更新增可选回调onDiagnosticLine?: (line, level?) voidpackages/acp-bridge/src/bridge.ts:writeServeDebugLine变更注入回调时同一行 tee 一份packages/acp-bridge/src/spawnChannel.ts变更子进程 stderr 转发器把每行前缀内容 tee 进onDiagnosticLine设计意图daemonLogger.ts单文件、cli 局部、无全局单例acp-bridge对 cli 保持无知只看到一个回调。在落地实现中模块边界与设计一致但文件命名变为daemon-logger.tskebab-case回调类型被正式命名为DiagnosticLineSink并定义在 packages/acp-bridge/src/bridgeOptions.tsexport type DiagnosticLineSink ( line: string, level?: info | warn | error, ) void;BridgeOptions.onDiagnosticLine字段位于 packages/acp-bridge/src/bridgeOptions.ts注释明确省略时是 no-op由 cli 的runQwenServe从 daemon logger 注入。3.1 无全局单例Logger 在runQwenServe内创建通过闭包传给需要它的内部 serve 模块或通过回调传给acp-bridge。理由有二与BridgeOptions既有的依赖注入方式保持一致避免debugLogger历史上遭遇的跨测试状态泄漏仓库为此保留了resetDebugLoggingState()。4. 日志路径与 Daemon ID设计约定的路径推导路径 Storage.getGlobalDebugDir() /daemon/daemon-id.log即${QWEN_RUNTIME_DIR 或 ~/.qwen}/debug/daemon/daemon-id.log复用运行时目录覆盖环境变量、上下文机制daemon-idserve-${pid}-${workspaceHash}workspaceHashcrypto.createHash(sha256).update(boundWorkspace).digest(hex).slice(0, 8)——固定长度、文件名安全、同一 workspace 路径下稳定pid用于区分同一 workspace 上的多个 daemon。latest软链~/.qwen/debug/daemon/latest→ 当前进程的日志文件初始化时用updateSymlink助手更新失败仅记录不致命文件模式aO_APPEND | O_CREAT重启后旧文件保留便于事后取证。需要指出实现与设计在此处有演进。落地的 packages/cli/src/serve/daemon-logger.ts 中daemon-id 简化为daemon:${pid}见computeDaemonId第 325-327 行稳定路径固定为daemon/daemon.logworkspace 信息仍保留在首行引导记录里workspaceboundWorkspace workspaceHash8位hex每次进程启动生成 32 位 hex 的runIdcrypto.randomBytes(16).toString(hex)写进日志行与状态查询作为运行身份标识。5. 公开 API设计文档给出了完整接口契约实现基本忠实还原daemon-logger.tsexport interface DaemonLogContext { route?: string; sessionId?: string; clientId?: string; childPid?: number; channelId?: string; [key: string]: unknown; } export interface DaemonLogger { info(message: string, ctx?: DaemonLogContext): void; warn(message: string, ctx?: DaemonLogContext): void; /** err.stack 以缩进续行追加在消息后err 与 ctx 相互独立、均可省略。 */ error(message: string, err?: Error | null, ctx?: DaemonLogContext): void; /** * 仅写文件的 tee调用方本身已在写 stderrACP 子进程 stderr 转发器、 * writeServeDebugLine时使用。行会以标准前缀追加到 daemon 日志 * 但不再回显到 stderr避免操作者输出翻倍。 */ raw(line: string, level?: info | warn | error): void; /** daemon 日志文件的绝对路径。 */ getLogPath(): string; getDaemonId(): string; /** 排空待写追加。由 runQwenServe 关闭处理器调用。 */ flush(): Promisevoid; } export interface InitDaemonLoggerOptions { boundWorkspace: string; pid?: number; // 默认 process.pid now?: () Date; // 默认 () new Date() stderr?: (line: string) void; // 默认 writeStderrLine baseDir?: string; // 默认 Storage.getGlobalDebugDir() } export function initDaemonLogger(opts: InitDaemonLoggerOptions): DaemonLogger;initDaemonLogger同步执行步骤设计约定计算daemonId与日志路径mkdirSync(parentDir, { recursive: true })——失败 → 返回 no-op logger 一条 stderr 警告启动继续appendFileSync(path, first line\n, { flag: a })同步写入daemon started pidpid workspaceboundWorkspace versioncli version。这同时充当可写性探测EACCES/ENOSPC 时降级为 no-op logger 一条 stderr 警告尽力更新latest软链吞掉错误返回 logger后续info/warn/error/raw调用入队异步fs.promises.appendFile。若QWEN_DAEMON_LOG_FILE为0|false|off|no之一initDaemonLogger在任何文件系统调用前短路为 no-op logger。实现变更说明落地的initDaemonLogger变成了异步函数返回PromiseDaemonLogger见 daemon-logger.ts因为稳定/回退家族分配与租约获取需要异步 I/O。但“失败可见于启动、不深埋于首次错误”的设计意图被保留并通过DaemonLoggerStatus显式暴露export interface DaemonLoggerStatus { runId: string; mode: stable | fallback | stderr-only; health: ok | degraded; issues: readonly DaemonLogIssue[]; droppedRecords: number; droppedBytes: number; }getStatus()实现新增被run-qwen-serve.ts用于状态查询接口见 run-qwen-serve.tshealth degraded时会输出相应告警。6. 日志行格式设计要求与debugLogger.buildLogLine视觉对齐2026-05-26T03:14:15.926Z [ERROR] [DAEMON] [trace_id... span_id...] routePOST /session/:id/prompt sessionIdabc clientIdxyz daemon failed to ... at fn (file.ts:42:7) at ...格式要点时间戳ISO 8601UTCtoISOString()级别INFO | WARN | ERROR。初始无 DEBUG——QWEN_SERVE_DEBUG的内容以INFO级别经raw()汇入标签字面量DAEMON追踪上下文trace.getActiveSpan()可用时输出逻辑与debugLogger.getActiveSpanTraceContext相同上下文字段keyvalue形式固定顺序route → sessionId → clientId → childPid → channelId随后附加键按字典序排列值含空白或时用JSON.stringify加引号错误栈以缩进续行追加在消息之后raw(line, level)在标准前缀timestamp [LEVEL] [DAEMON]之后原样写入不做额外加工。实现中有两个实现级细节值得注意均有测试覆盖文件行与 stderr 行略有差异写入文件的行会额外携带runIdrunId pidpidbuildDaemonFileLogLine第 185-200 行stderr 行则保持简洁。runId/pid是保留上下文键无法被调用方伪造测试reuses the stable path across restarts and keeps run identity immutable验证了这一点trace 键防伪造trace_id/span_id属于保留键会从上下文渲染中剔除避免调用方注入伪造的追踪信息见renderCtx与测试stays pure in an active span and filters reserved trace keys。Tee 语义重要info / warn / error同时写 daemon 日志文件和 stderr经注入的 stderr writer。替换原先writeStderrLine(...)的调用方直接使用它们即可无需再单独调 stderrraw仅写文件。用于 ACP 子进程 stderr 转发器与writeServeDebugLine——这两处调用方本就通过既有路径写 stderr若再回显会淹没操作者输出。测试 daemon-logger.test.ts 验证raw()写入带前缀的行、不触发 stderr tee、也不捕获环境 trace 上下文。7. 启动 / 关闭流程设计伪代码runQwenServe(opts): ... daemonLog initDaemonLogger({ boundWorkspace }) writeStderrLine(qwen serve: daemon log → ${daemonLog.getLogPath()}) // 启动横幅仅写 stderr避免“自指”成环 bridge createHttpAcpBridge({ ..., onDiagnosticLine: (line, level) daemonLog.raw(line, level), }) app createServeApp({ ..., daemonLog }) // 注入给 sendBridgeError shutdownHandler(signal): daemonLog.warn(shutdown signal${signal}) await drainBridge() await daemonLog.flush() process.exit(0)三个关键取舍启动横幅仅走 stderr关于路径的那行如果写进日志自身会形成自指循环initDaemonLogger同步任何失败在启动瞬间即可见而不是埋在第一次错误之后关闭时flush()是process.exit前最后一个 await 步骤SIGKILL 天然无法 flush设计明确接受这一点。实现中该流程完整保留run-qwen-serve.ts在启动阶段await initDaemonLogger({ boundWorkspace, baseDir: daemonLogBaseDir, ... })run-qwen-serve.ts随后输出qwen serve: daemon log → pathrun-qwen-serve.tsbridge 构造时注入onDiagnosticLine: (line, level) daemonLog.raw(line, level)run-qwen-serve.ts关闭路径上daemonLog.flush()先行run-qwen-serve.ts。8. 覆盖范围哪些输出进了 daemon 日志设计给出覆盖对照表核心原则是每一条既有 stderr 写入都被保留daemon 日志是纯增量来源现状改造后runQwenServe.ts生命周期 / 信号 / 配置警告writeStderrLine(...)daemonLog.info \| warn(...)stderr 仍发生由 daemonLog teerunQwenServe.tslistening on URLstdoutwriteStdoutLine(...)不变——操作脚本解析 stdoutserver.ts:sendBridgeErrorwriteStderrLine(...)带 route/sessionIddaemonLog.error(msg, err, { route, sessionId, ... })bridge.ts:writeServeDebugLineQWEN_SERVE_DEBUGwriteStderrLine(qwen serve debug: ...)tee 到onDiagnosticLine(line, info)spawnChannel.ts子进程 stderrprocess.stderr.write(prefix line \n)同时onDiagnosticLine(prefix line, warn)CLI 用法 / argparse 错误早期校验writeStderrLine(...)不变此时 logger 可能尚未创建实现印证server.ts中sendBridgeError被包装为注入daemonLog的版本server.tsspawnChannel.ts的 stderr 转发器把每一行含截断保护以warn级别回调onDiagnosticLinespawnChannel.tsbridge.ts中writeServeDebugLine在QWEN_SERVE_DEBUG生效时以info级别把qwen serve debug: msg汇入回调bridge.ts。9. 写入路径与 Flush设计约定的写路径内部维护一条Promisevoid链this.pending this.pending.then(() fs.promises.appendFile(...))每次info/warn/error/raw入队一次追加文件info/warn/error还同步调用注入的 stderr writerstderr 写入顺序保持同步先于入队文件追加按入队顺序最终一致写失败置内部degraded标志并一次性输出 stderr 警告后续调用仍尝试写但不再维护计数flush()返回当前尾部的 promise无缓冲层每次调用 一次appendFile。路由错误 生命周期量级很低微批处理属过早优化。实现保留了 promise 链与 degraded 标志并追加了背压与丢记录会计当待写字节超过maxPendingBytes默认 4 MiB时文件副本会被丢弃queue_overflowissue 一次性 stderr 提示丢弃计数进入getStatus()并在容量恢复后以一条 WARN 汇总记录daemon file log records dropped补记损失。10. 配置环境变量语义环境变量行为QWEN_DAEMON_LOG_FILE0\|false\|off\|noinitDaemonLogger返回 no-optee 为 no-opstderr 不变QWEN_DAEMON_LOG_FILE其他任意值或未设置启用默认QWEN_RUNTIME_DIRpath重定位~/.qwen根目录daemon 日志随之移动既有语义QWEN_SERVE_DEBUG1既有行为——激活writeServeDebugLine其行现在也 tee 进 daemon 日志QWEN_DAEMON_LOG_FILE有意与QWEN_DEBUG_LOG_FILE分离这样关闭按会话的 debug 日志不会连操作者的 daemon 日志一起关掉反之亦然。实现中isOptedOut()daemon-logger.ts对值做 trim 小写匹配因此False、OFF等变体同样生效opt-out 路径返回mode: stderr-only的 loggerdaemon-logger.test.ts 以参数化用例验证了全部取值。11. 实现落地设计之外的工程增强原始设计将“轮转 / 大小上限”列为非目标并延后。仓库中的后续设计 docs/design/2026-07-15-daemon-log-stable-rotation.md 与最终实现补齐了这块使 daemon 日志从“无限增长的取证文件”演进为有界、可并发、自愈的持久化存储。以下均可在 daemon-logger.ts 与 daemon-logger.test.ts 中找到实现与测试证据11.1 有界存储轮转 保留 记录截断默认策略DEFAULT_POLICY第 75-86 行{ maxBytes: 10 * 1024 * 1024, // 单个活跃日志上限 10 MiB maxArchives: 4, // 归档文件保留上限 4 个 maxRecordBytes: 256 * 1024, // 单条记录上限 256 KiB maxPendingBytes: 4 * 1024 * 1024, // 待写队列字节上限 // ... 租约与维护相关预算见 11.2 }轮转活跃文件超过maxBytes时先重命名为归档archive/daemon-12位代次-时间戳-8位随机hex.log再创建新的daemon.log保留归档超过maxArchives时删除最旧文件启动时若发现归档超限也会补做清理记录截断单条记录超过maxRecordBytes时在UTF-8 边界安全处截断并追加[truncated originalBytesN]标记绝不产生乱码\ufffd——测试truncates a file record on a UTF-8 boundary without truncating stderr用 200 个“你”字验证且 stderr 侧始终收到完整消息。11.2 多 daemon 并发租约lease与 stable / fallback 双模式同一 workspace 可能同时存在多个 daemon 进程。实现引入proper-lockfile租约机制首个 daemon 获得.stable-writer.lock1 秒获取预算成为stable写入者固定写daemon/daemon.log抢锁失败ELOCKED的后续 daemon 走fallback在daemon/runs/run-runId/daemon.log下独立写维护用.maintenance.lock家族所有权用.owner.lock并在runs/recent-fallback中记录最近回退目录latest软链始终指向 stable 文件回退家族在关闭时做保留清理只保留最新非活跃家族租约被onCompromised判定受损时文件写被“中毒”poisoned停用转为降级模式避免多写者互相覆盖。测试keeps latest on stable while a concurrent logger uses fallbackdaemon-logger.test.ts完整验证了这一并发场景。11.3 安全权限与状态可见性文件0o600、目录0o700FILE_MODE/DIRECTORY_MODE避免日志与锁文件被同机其他用户读取getStatus()让run-qwen-serve.ts能把runId / logMode / logHealth / logIssues / logDroppedRecords / logDroppedBytes暴露给状态接口run-qwen-serve.ts运维可程序化感知降级与丢记录。12. 错误处理矩阵场景行为initDaemonLoggermkdir/open 失败no-op logger 一条 stderr 警告daemon 启动继续文件无内容但 stderr 仍工作单次追加失败翻转 degraded 标志write_failed一次性 stderr 警告继续尝试flush()被拒关闭处理器捕获并记到 stderr不阻塞退出latest软链失败吞掉主写入不受影响轮转/保留失败rotation_failed/retention_failedissue 一次性警告按rotationRetryIntervalMs默认 60s重试队列溢出queue_overflowissue 一次性警告丢文件副本并会计恢复后补 WARN 汇总租约受损lease_compromisedissue文件写中毒停用降级 stderr-only13. 测试覆盖设计文档规划的测试在 packages/cli/src/serve/daemon-logger.test.ts1569 行中全面落地并随实现增强行格式INFO/WARN/ERROR、固定上下文顺序、附加键字典序、含空白/值的 JSON 引号、错误栈缩进续行、栈缺失时回退name: messageopt-out0/false/off/no含大小写、空白变体→ 不创建文件、stderr-only模式、仍支持 trace 上下文注入init路径与 daemon-id 推导、启动记录含runId/pid/workspace/workspaceHash、目录/文件/锁文件权限断言、mkdir 失败降级raw / tee 语义raw 仅文件、不 tee stderrinfo/warn/error 文件 stderr 双写trace 上下文采样且 recording 的 span 注入、未采样/无效/异常 span 省略、查找抛错不影响日志、trace_id/span_id防伪造flush50 条并发入队后flush()保证全部落盘latest 软链创建、更新、stable 并发下的归属有界存储重启复用稳定路径且 runId 不可伪造、UTF-8 边界截断、轮转与严格归档上限降级append 失败 → 中毒 write_failed 丢记录计数。此外runQwenServe/server/ acp-bridge 的测试分别验证了启动记录写入、关闭前 flush、路由错误经daemonLog.error携带正确route/sessionId以及onDiagnosticLine在QWEN_SERVE_DEBUG1与子进程 stderr 转发两条路径上的回调测试用捕获型 fake不触碰文件系统。14. 文档、回滚与验收标准文档设计约定在 serve CLI 文档中新增 “Daemon log file” 一节覆盖路径、daemon-id 格式、latest软链、QWEN_DAEMON_LOG_FILEopt-out以及与按会话debug/sessionId.txt的区别packages/cli/src/serve/下若有 README 则同步补充。回滚纯增量改动回滚即 revert 提交——删除daemon-logger.ts及其测试还原runQwenServe.ts生命周期 /sendBridgeError/ bridge /spawnChannel改动移除BridgeOptions.onDiagnosticLine。无磁盘状态需要清理既存 daemon 日志文件成为无害的孤儿文件。验收标准issue 原文逐条落实标准实现方式qwen serve无需 shell 重定向即创建/追加 daemon 日志initDaemonLogger启动即打开文件HTTP 500POST /session/:id/prompt可在 daemon 日志中关联sendBridgeError写入routesessionIdACP 子进程 stderr 行也进 daemon 日志spawnChannel经onDiagnosticLinetee首个会话之前、全部会话关闭之后日志仍工作非会话作用域随 daemon 生命周期存活既有 stderr 行为不变全部写入为增量无writeStderrLine调用被移除而不留等价物日志路径与 opt-out 有文档文档章节15. 开放问题与后续方向设计保留了两个非阻塞的后续项其中第一个已在实现中尘埃落定latest软链位置定于~/.qwen/debug/daemon/latest实现确认测试creates daemon/latest pointing to the current log验证JSON 行输出如QWEN_DAEMON_LOG_FORMATjson作为未来 flag 的可能性仍超出当前 PR 范围结构化导出由 issue #2014 负责。结语从设计文档到落地实现daemon 文件日志器始终围绕一条主线让qwen serve的 daemon 级诊断在无 shell 重定向、无会话上下文的场景下依然可留存、可关联、可取证。设计确立了路径、API、格式与纯增量原则实现则在此基础上补齐了轮转保留、并发租约、降级自愈与状态可见性最终通过 1569 行测试固化行为。对 SDK / 桌面端 / 本地 daemon 的运维者而言~/.qwen/debug/daemon/daemon.log加上tail -f ~/.qwen/debug/daemon/latest即是排查路由 500 与子进程异常的可靠起点。【免费下载链接】qwen-codeAn open-source AI coding agent that lives in your terminal.项目地址: https://gitcode.com/GitHub_Trending/qw/qwen-code创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进