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?deep=1新增的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-scoped
GET /health?deep=1新增三个字段:
activeWork:只要任一受管工作区存在"已接受但未结算(accepted-but-unsettled)的 Prompt"、正在运行的后台 Agent、处于排队/等待接受/正在被父 continuation 处理的 Agent 终端通知、Session 管理的后台 shell 或 workflow 工作、子进程持有的会话轮次,即为true。聚合的session类别 hold 覆盖 goal 与 cron 处理、历史变更(history mutation)、排队或运行中的 Monitor continuation。activeWorkReporting:full/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.ts:
export 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 当作 unknown);notifyChanged()将变更合并到每个微任务一张快照,使得"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类别的子进程,可以按其旧谓词诚实地回答,却恰恰漏掉了该类别的工作,因此它的回答不能授权自动销毁。
缓存决定"何时值得问",从不授权销毁
一张新鲜的空快照只描述了它被构建的那一刻,工作完全可以在其后的间隙开始。因此自动清理走一条条件化 RPC:
qwen/control/session/close { sessionId, onlyIfUnheld: true } → { sessionId, closed: true } | { sessionId, closed: false, holds: [...] }其实现位于 packages/cli/src/acp-integration/acpAgent.ts,关闭流程如下:
- 进入关闭闸门(close gate),先做一次早期读取:如果发现已知 hold 立即拒绝(这是优化而非最终授权,因为闸门只阻止新轮次,已在运行的轮次仍可能结算出新的 hold);
- drain已激活的轮次——不做取消、不停止调度器,只等它结算;结算期间可能产生新 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 的 OR |
activeWorkReporting | full/partial/none——该布尔值有多大比例被担保 |
activeWorkStaleMs | 其所依据的最旧快照的年龄;无覆盖时为0 |
新鲜度由daemon分级,而不是控制器:报告节奏是按通道协商的(daemon 请求一个节奏和类别集合,子进程回显被钳制后的节奏与支持类别的交集),只有 daemon 能评判它。协议侧的核心协商与钳制函数在 bridgeTypes.ts:
ActiveWorkHeartbeatCapabilityV1携带intervalMs与categories;clampActiveWorkIntervalMs()把对端提议的节奏钳制在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 中的gradeActiveWorkCoverage):
export 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?deep=1端点的聚合逻辑(遍历受管工作区、逐运行时读取bridge.activeWork与bridge.activeWorkCoverage、OR 汇总并输出三个新字段)见 health.ts。注意该路由仅在deep=1查询参数下进入此聚合,默认探测仍保持廉价;且健康探测只读聚合数据,不 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.ts
- Session 侧 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),仅供参考