ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

qwen-code Active-work health signal:基于持有(Hold)语义的会话工作活性健康信号与条件化回收机制

qwen-code Active-work health signal:基于持有(Hold)语义的会话工作活性健康信号与条件化回收机制 qwen-code Active-work health signal基于持有Hold语义的会话工作活性健康信号与条件化回收机制【免费下载链接】qwen-codeAn open-source AI coding agent that lives in your terminal.项目地址: https://gitcode.com/GitHub_Trending/qw/qwen-code导读本文深入剖析 qwen-code开源 AI 编码 Agent运行于终端中守护进程daemon与 ACP 子进程之间的一套核心活性协议active-work health signal。它解决的是一个真实而棘手的问题——当会话Session派发出去的 Prompt 返回后后台 Agent 仍在运行、终端通知仍在排队此时若把零活动 Prompt误判为空闲并重启守护进程会直接打断后台任务并丢失其通知。读完本文你将掌握GET /health?deep1新增的activeWork/activeWorkReporting/activeWorkStaleMs三个字段的语义与组合规则、子进程端派生式 Hold快照报告的设计原理以及 daemon 与子进程之间条件化关闭conditional close这一原子化回收流程的完整调用链。问题背景activePrompts 为零并不等于空闲在守护进程的既有健康模型中activePrompts统计的是当前已派发给 ACP 子进程的 Prompt 数量。这个数字存在一个明显的盲区一个 Prompt 可以在启动后台 Agent 之后结束。此时activePrompts回到 0但会话归属的工作后台 Agent、待投递的终端通知仍在运行。如果重启控制器只依据零活动 Prompt就判定空闲并重启 daemon那么这些后台 Agent 会在完成前被杀死它们的终端通知也无法到达父会话。这正是 2026-08-06-active-work-health.md 这篇设计文档要解决的问题为健康检查补充一个会话级工作活性信号。范围与核心语义Session-scoped而非 Channel-scopedGET /health?deep1新增三个字段activeWork只要任一受管工作区存在已接受但未结算accepted-but-unsettled的 Prompt、正在运行的后台 Agent、处于排队/等待接受/正在被父 continuation 处理的 Agent 终端通知、Session 管理的后台 shell 或 workflow 工作、子进程持有的会话轮次即为true。聚合的session类别 hold 覆盖 goal 与 cron 处理、历史变更history mutation、排队或运行中的 Monitor continuation。activeWorkReportingfull/partial/none——该布尔值有多少比例是被担保vouched for的。activeWorkStaleMs其所依据的最旧快照的年龄无覆盖时为0。一个关键的设计决定是该字段是 Session 作用域而非 Channel 作用域。尚未挂接 Session 的通道级工作——正在进行的 spawn、挂起的 restore、MCP 发现或鉴权——不计入。因此activeWork可能读作false而 daemon 自身的hasNoChannelWork却同时拒绝回收该通道。文档明确指出这两个问题回答的是不同的问题允许彼此不一致需要该 daemon 是否可回收判断的控制器必须把下文的三项组合规则与优雅关闭握手结合使用而不能奢望这一个字段承载更多含义。重启策略仍由外部控制器掌握daemon 只发布事实不发布restartSafe。为什么是Hold派生而非维护每个 Session 向 daemon 报告一组命名的 holds每个 hold 携带类别agent、notification、shell、session或workflow。源码中该类型定义于 packages/acp-bridge/src/bridgeTypes.tsexport type ActiveWorkHoldCategory | agent | notification | shell | session | workflow;设计的第一个要点是Holds 是派生出来的绝不是被维护出来的。Session.collectActiveWorkHolds()在每次调用时直接读取工作的真正持有者——后台任务注册表的未终结集合、后台 shell 注册表的运行条目、通知队列以及进行中的接受/continuation 状态见 packages/cli/src/acp-integration/session/Session.ts。它的接口定义在 active-work-reporter.ts 中export interface ActiveWorkSource { readonly sessionId: string; collectActiveWorkHolds(): ActiveWorkHoldV1[]; }为什么不用获取/释放账本acquire/release ledger因为账本可能漏掉一次释放而一个泄漏的 hold 会把它的 Session 永久钉住同时每个快照都会忠实地重复发布这个泄漏。派生式读取则保证 hold 不可能比它所命名的工作活得更久daemon 侧缓存会自动收敛到持有者真实的状态。几个重要的派生细节agent 类别用的是BackgroundTaskRegistry.hasUnfinalizedTasks()的谓词而不是hasRunningTasks()。被取消的 Agent 仍欠着一条终端任务通知cancel()会翻转状态并发出状态变更但通知稍后才由finalizeCancelled()或 5 秒宽限定时器送达。如果以running为键Session 会在这个窗口内显得空闲被分离detached的会话会在通知仍然欠着的情况下被关闭。shell 无论运行多少个只用一个聚合 hold{ category: shell, id: background-shells }。任务注册表和/tasks表面仍然保留详细的名单active-work 只需要有界保留这一事实。聚合还防止无限增长的 shell 名单突破协议中每个 Session 的 hold 上限1,024 个。非前台 Prompt 的子进程轮次用一个聚合 hold{ category: session, id: session:active-turn }。这使条件化关闭的谓词与 drain 等待的工作保持对齐同时不暴露功能特定的协议类别。workflow 类别对已保留但未注册的运行脚本加载、日志回放也要持有workflowRegistry.listStartingRunIds?.()覆盖那些还没有list()条目的保留运行否则 daemon 发起的条件化关闭可能读不到任何 hold从而销毁一个正在被客户端启动的会话Session.ts。暂停paused的运行不会 pin 会话因为没有后备机制会释放它。为什么是全量快照Channel 作用域、固定节奏、无需重传报告是通道作用域的完整快照而不是每个 Session 的增量转换。每个 ACP 通道一条消息携带该子进程拥有的所有 Session 及其持有的所有 holds{ v: 1, seq: 12, sessions: [ { sessionId: …, holds: [{ category: agent, id: a1b2 }] } ] }对应的类型定义在 bridgeTypes.ts而通道级发布器ActiveWorkReporter实现在 active-work-reporter.ts。实现要点每条消息都是带单调递增seq的完整快照seq仅用于丢弃乱序消息从不用于检测缺口——一个被丢弃的报告最多损失一个周期的陈旧度不需要重传、ack 或上次报告状态来做 diff下一张快照就是全部真相。通道作用域让常开节奏always-on cadence成本可控无论 Session 数量多少每个周期只有一条小消息。由于报告是完整的Session 从新快照中缺席 子进程侧不再持有任何东西。缺席与报告了但无 hold是同一个事实走同一条路径——最终都是去询问子进程而不是臆断。报告在构造时如果发生异常会放弃整张快照而不是发送部分数据Session 缺席意味着子进程已释放它Session 报告无 hold 意味着可以安全关闭——发布收集到一半的数据等于主动邀请 daemon 销毁活跃工作。不发任何内容只是让 daemon 侧的副本变旧其新鲜度分级本就会将其视为不可信并保留active-work-reporter.ts。Prompt 刻意不出现在子进程的报告中。daemon 自己负责接受、排队、派发和结算 Prompt因此它自己的pendingPromptCount既权威又严格更宽——它覆盖仍在 FIFO 中等待的 Prompt而子进程看不到这些。如果两侧都报告 Prompt就会对一个事实产生两个真相来源且无从调和。子进程侧还做了两个工程化细节构造时立即发布一次快照让 daemon 尽早离开已协商但从未报告的状态而不是等第一个周期过去后仍把每个 Session 当作 unknownnotifyChanged()将变更合并到每个微任务一张快照使得Agent 完成 其终端通知入队这种同一 tick 内的爆发只产生一条已反映结算后状态的消息active-work-reporter.ts。排序保证快照必须先于 Prompt 响应上线由于报告走同一通道一个关键的排序约定随之而来快照要在同一条流上的 Prompt 响应之前 flush。因为 daemon 在 Prompt 响应落地的瞬间就会把 pending-prompt 计数清零如果该 Prompt 留下的 hold它启动的后台 Agent 或 shell还没有上线daemon 会短暂地两个事实都看不到从而可能回收该 Session。源码中的flush()正是为此设计它立即发布并把发布完成挂到#tail上等待且永不 reject——报告问题绝不能变成用户的 Prompt 失败active-work-reporter.ts。报告状态机与原子化关闭daemon 对每个 Session 维护四种状态之一状态含义行为unsupported通道从未协商过该能力不贡献任何东西沿用既有清理行为把它当unknown会让每个遗留 Session 永久不可回收incomplete通道已协商但不报告 daemon 当前要求的全部类别健康度降级为partial该 Session 的常规自动清理被禁用。与新鲜度未知不同再多一个来回也无法让旧子进程理解它未协商的类别unknown已协商但最近没有听到足够新的报告健康表面读作忙碌但这不是 daemon 停驻的状态——它会去询问known已应用一张新鲜快照正常参与判定从未报告和失声已久刻意是同一个状态。一张比分级窗口intervalMs × 3更旧的快照不是在报告Session 空闲而是报告缺席——后台 Agent 可能在此期间的任何时刻启动——因此它不再作为证据。unknown 是去问的理由不是跳过的理由两个消费方对 unknown 的解读不同而且必须不同健康表面把 unknown 报告为忙碌——控制器绝不能把没人告诉我误解为什么都没在跑自动清理把它当作候选者继续进行下面的条件化关闭。只有known的工作——daemon 自有的或一张新鲜报告中的被持有工作——才会直接阻止尝试。在 unknown 上跳过看起来安全实际是更糟的失败没有任何东西会去解决它一个在静默通道上的 Session 会被永久保留且没有出路。询问只花一次有界的往返任何非应答仍会保留而且无论子进程的快照是否在送达它都能在自己的关闭闸门下权威地回答。不完整覆盖incomplete则不同会跳过一个协商过但省略shell或session类别的子进程可以按其旧谓词诚实地回答却恰恰漏掉了该类别的工作因此它的回答不能授权自动销毁。缓存决定何时值得问从不授权销毁一张新鲜的空快照只描述了它被构建的那一刻工作完全可以在其后的间隙开始。因此自动清理走一条条件化 RPCqwen/control/session/close { sessionId, onlyIfUnheld: true } → { sessionId, closed: true } | { sessionId, closed: false, holds: [...] }其实现位于 packages/cli/src/acp-integration/acpAgent.ts关闭流程如下进入关闭闸门close gate先做一次早期读取如果发现已知 hold 立即拒绝这是优化而非最终授权因为闸门只阻止新轮次已在运行的轮次仍可能结算出新的 holddrain已激活的轮次——不做取消、不停止调度器只等它结算结算期间可能产生新 hold拒绝必须把保留的 Session 原样留下在关闭闸门 历史变更闸门同时压住破坏性竞态的情况下再读一次未过滤的collectActiveWorkHolds()仍无 hold 才继续 finalize/flush/close 并删除存储条目闸门始终未释放因此在最终读取之后、拆除之前子进程侧不可能出现新的 hold。若任一次读取发现 hold释放闸门并把 hold 交还。daemon 只有在返回的 hold 集保持在与其他快照相同的每 Session 1,024 个上限内时才采用它超大的拒绝仍会保留 Session但不会替换最后一份有效缓存。daemon 侧需要自己的掩护因为这次往返是一次最长可达十秒的 await。有未决条件化关闭的 Session 会被标记为 in-flight所有准入路径——attach、prompt、rewind——都像拒绝正在关闭的 Session 一样拒绝它。否则在往返期间被接受的 prompt 会在它竞速的拆除完成时丢失原来的同步 guard-then-teardown 序列免费获得了这个保证拆开它正是需要显式说明的原因。超时处理daemon 无法判断子进程是否已关闭。它不会原地重试也不臆断把 Session 留在原地让下一张快照来结算。缺席不等于同意销毁——它只是让 Session 成为候选者候选者仍须通过所有常规守卫无 SSE 订阅者、无注册客户端、无 daemon 自有工作在进行之后 daemon 才会再次询问子进程。一个已关闭 Session 的子进程会对一个它已不拥有的 Session 回答closed这正是丢失的关闭响应被恢复的方式——永远不需要猜测。显式 close、kill、shutdown 和通道退出保持强制语义不走这条路径。一个守卫模型覆盖所有触发源有六类事件族会决定该看看某个 Session 了最后一个客户端分离、Prompt 结算、终端通知结算、attach 注册回滚、完整的子进程快照报告该 Session 空闲或省略它、空闲收割器idle reaper的 TTL。每个都有自己的策略但没有任何一个可以削弱共享部分守卫为什么共享未处于 closing 或 close-in-flight两条路径竞速同一拆除会重复往返并互相竞态守卫无 SSE 订阅者有人在看这个 Session 的流无 daemon 自有工作在进行daemon 正在推送的排队/已派发 prompt 与通知绝不依赖子进程报告任何东西协商的报告覆盖全部类别更新的类别中存在工作时旧谓词不得授权拆除无新鲜子进程报告的被持有工作只有 known 的工作会阻止unknown 是继续去询问的候选者子进程在自己的关闭闸门下确认缓存说的是过去曾为真只有子进程能说现在为真收割器reaper刻意忽略已注册的客户端 ID——它存在的意义就是处理 detach 从未到达的崩溃路径——但这是唯一的差别它在销毁任何东西之前仍然必须询问子进程。刻意不做的事三个关注点、三个机制本文档明确划清了边界没有心跳看门狗也没有由工作状态驱动的通道级杀死。从一个 Session 停止报告推断这个通道死了会杀死该进程上的每一个 Session而一次挂起、一次长时间的事件循环停顿、或一条丢失的通知看起来都与停滞的子进程一模一样。三个独立的关注点对应三个独立的机制关注点机制传输/进程存活通道 ping-pong另立变更进程响应但 Agent 逻辑停滞基于进度的看门狗另立变更会话工作保留本文档杀死整个多路复用通道仅在通道确实死亡时才是合理的——此时其上的每个 Session 反正都不可达。把它当作从单个 Session 报告推导出的结论则不合理。健康表面三个字段的精确语义与组合规则字段含义activeWork各运行时runtime上 daemon 自有工作与报告 holds 的 ORactiveWorkReportingfull/partial/none——该布尔值有多大比例被担保activeWorkStaleMs其所依据的最旧快照的年龄无覆盖时为0新鲜度由daemon分级而不是控制器报告节奏是按通道协商的daemon 请求一个节奏和类别集合子进程回显被钳制后的节奏与支持类别的交集只有 daemon 能评判它。协议侧的核心协商与钳制函数在 bridgeTypes.tsActiveWorkHeartbeatCapabilityV1携带intervalMs与categoriesclampActiveWorkIntervalMs()把对端提议的节奏钳制在ACTIVE_WORK_HEARTBEAT_MIN/MAX_INTERVAL_MS之间——1ms 的提议会淹没传输数小时的提议会让新鲜度分级失去意义任何不可用的值回退到默认节奏而不是禁用报告类别缺省回退不带categories的 v1 请求意味着遗留的agent/notification基线ACTIVE_WORK_LEGACY_HOLD_CATEGORIES这允许新子进程对旧 daemon 保持线缆报告可读同时其本地收集器在条件化关闭时仍能看到 shell 工作。过期快照或省略类别的子进程会把级别降为partial而不是悄悄收窄布尔值覆盖的范围。activeWorkStaleMs是诊断性的且只度量被覆盖的 Session——未覆盖的 Session 已体现在级别中再让它拖低年龄会造成双重计数出现级别说无覆盖、旁边却显示正数陈旧度的矛盾。实现上无覆盖时返回0而非null空闲且无 Session 的 daemon 不得在应用自身新鲜度下限的控制器眼里读作无限陈旧packages/cli/src/serve/routes/health.ts。分级必须在整个 daemon 上一次性计算级别是在整个 daemon 上计算一次而不是按运行时分别计算再合并因为级别不可组合一个没有 Session 的运行时对它拥有的一切是空洞地担保vacuouslyfull把这种空担保当作证据会让一个空工作区为另一个工作区的未报告 Session 背书。因此每个运行时只暴露覆盖计数路由先求和再分级health.ts 与 bridgeTypes.ts 中的gradeActiveWorkCoverageexport function gradeActiveWorkCoverage(totals: { total: number; covered: number; onNegotiatedChannel: number; }): full | partial | none { if (totals.total 0 || totals.covered totals.total) return full; return totals.onNegotiatedChannel 0 ? none : partial; }none被保留给没有任何一个 Session 坐在协商过报告的通道上——此时依据activeWork行动不安全而不只是降级。/health?deep1端点的聚合逻辑遍历受管工作区、逐运行时读取bridge.activeWork与bridge.activeWorkCoverage、OR 汇总并输出三个新字段见 health.ts。注意该路由仅在deep1查询参数下进入此聚合默认探测仍保持廉价且健康探测只读聚合数据不 ping 子进程或通道并非真正的存活探测health.ts。控制器侧的忙碌判定控制器应把 daemon 视为忙碌当且仅当const busy health.activePrompts 0 || health.activeWork || health.activeWorkReporting ! full;activePrompts保留其精确的旧含义作为独立的兼容性信号。边界观察缓存而非重启租约最后是设计者明确写下的边界这是一个观察缓存不是一个重启租约。即使一张新鲜的、空的、完全分级的快照也只描述它被拍摄的那一刻——新工作完全可以立刻开始。上述规则大幅降低了错误重启的风险但并未消除它。严格的安全需要一道 prepare-restart 栅栏停止新工作准入、确认 drain 完成、然后才关闭——那就是优雅关闭graceful shutdown超出本文档的范围。相关源码索引设计文档docs/design/2026-08-06-active-work-health.md通道级快照发布器packages/cli/src/acp-integration/active-work-reporter.tsSession 侧 hold 派生packages/cli/src/acp-integration/session/Session.ts条件化关闭 RPC 实现packages/cli/src/acp-integration/acpAgent.ts协议类型、协商与分级函数packages/acp-bridge/src/bridgeTypes.ts健康检查路由packages/cli/src/serve/routes/health.ts相关测试packages/cli/src/acp-integration/active-work-reporter.test.ts、packages/cli/src/acp-integration/session/Session.test.ts、packages/cli/src/acp-integration/acpAgent.test.ts、packages/cli/src/serve/server.test.ts【免费下载链接】qwen-codeAn open-source AI coding agent that lives in your terminal.项目地址: https://gitcode.com/GitHub_Trending/qw/qwen-code创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表