diff --git a/.claude/skills/pm-dispatch/references/dispatch-runbook.md b/.claude/skills/pm-dispatch/references/dispatch-runbook.md index 1b8a645b5c..09a17663a6 100644 --- a/.claude/skills/pm-dispatch/references/dispatch-runbook.md +++ b/.claude/skills/pm-dispatch/references/dispatch-runbook.md @@ -5,35 +5,33 @@ ## 车道取卡全序与 `pm:blocking`(维护者 2026-08-13) -出处(逐字,未译):「被依赖的卡片是不是应该通过label标注提高优先级。项目经理处理 -任务清单时,bug 是不是应该提高优先级。」全序本体与 `pm:blocking` 推导在主文件;补 -遗:级序理由一行 —— p0 是显式裁定,blocking 一张挡多张,bug 是已坏的不变量、先于 -增强,卡龄防饿死;写入与摘除都在分诊轮 sweep,依赖者全关即摘,手工挂上的按误标处 -理、sweep 对着索引校正;种子与消费者注记在 `scripts/pm/ensure-pm-labels.sh`。 +出处(逐字,未译):「被依赖的卡片是不是应该通过label标注提高优先级。项目经理处理任务清 +单时,bug 是不是应该提高优先级。」全序本体与 `pm:blocking` 推导在主文件;补遗:级序理由 +一行 —— p0 是显式裁定,blocking 一张挡多张,bug 是已坏的不变量、先于增强,卡龄防饿死; +写入与摘除都在分诊轮 sweep,依赖者全关即摘,手工挂上的按误标处理、sweep 对着索引校正; +种子与消费者注记在 `scripts/pm/ensure-pm-labels.sh`。 ## 紧急卡 fable 直接分诊(维护者 2026-08-13) -出处(逐字,未译):「对于比较紧急的卡片,是否应该使用 fable 5 子agent直接分诊」。 -触发、授权面与同格式同效力在主文件;补遗:授权面不含派发(⛔ 不写码、不认领、不派 -发);单一生产者纪律照旧(与「代扫」同界)—— 分诊座位在班且已在处理同卡即让行; -紧急通道改的是节奏,不是生产者数目。**半状态治愈积压排序**(维护者 2026-08-19, -原话:「项目经理等分诊,但是没有切换 label,导致挂了很久。」「我刚和他说了他才处 -理。」;排序规则本体在主文件):限量 + 最新优先会令最老半状态结构性饿死,活过一 -个 sweep 周期的半状态是治愈环自身的缺陷,不是库存;正文自报 P0/data-integrity 的 -命中即走本节紧急通道;机械年龄告警在半状态巡查脚本(细节以脚本头为权威)。 +出处(逐字,未译):「对于比较紧急的卡片,是否应该使用 fable 5 子agent直接分诊」。触 +发、授权面与同格式同效力在主文件;补遗:授权面不含派发(⛔ 不写码、不认领、不派发);单 +一生产者纪律照旧(与「代扫」同界)—— 分诊座位在班且已在处理同卡即让行;紧急通道改的是 +节奏,不是生产者数目。**半状态治愈积压排序**(维护者 2026-08-19「没有切换 label,导致挂 +了很久」;排序规则本体在主文件):限量 + 最新优先会令最老半状态结构性饿死,活过一个 +sweep 周期的半状态是治愈环自身的缺陷,不是库存;正文自报 P0/data-integrity 的命中即走本 +节紧急通道;机械年龄告警在半状态巡查脚本(细节以脚本头为权威)。 ## 发现分诊轮细则(维护者 2026-08-13) -出处(逐字,未译),同日三段:「Finding 很多,是否建议集中分诊」…「按照你的建议」 -= 域分批集中轮常设授权;「还有这个积压时常态,是否应该优化。」= 结构修复令;「积 -压数字本身有误导 —— 47 张里大半是带重启条件的健康 hold(它们本来就该躺着),那为 -什么标 finding」= 状态语义裁定。 +出处(逐字,未译),同日三段:「Finding 很多,是否建议集中分诊」…「按照你的建议」= 域分 +批集中轮常设授权;「还有这个积压时常态,是否应该优化。」= 结构修复令;「积压数字本身有误 +导 —— 47 张里大半是带重启条件的健康 hold(它们本来就该躺着),那为什么标 finding」= 状 +态语义裁定。 -- **状态转换机制**(「finding 恒 = 待首次定级」在主文件):定级出 hold ⇒ 同笔 - `finding` → `pm:on-hold`(域标签保留;hold 评论照既有纪律带日期/理由/具名重启 - 条件,维护者裁 vs 座位定级的出处写进评论,⛔ 不设第二个标签区分);修法是**状态 - 转换,不是豁免评论**(双态混编曾让健康 hold 顶红裸计数、藏住未定级堆;逐卡豁免 - 评论机制已废弃,⛔ 不复活)。 +- **状态转换机制**(「finding 恒 = 待首次定级」在主文件):定级出 hold ⇒ 同笔 `finding` + → `pm:on-hold`(域标签保留;hold 评论照既有纪律带日期/理由/具名重启条件,维护者裁 vs + 座位定级的出处写进评论,⛔ 不设第二个标签区分);修法是**状态转换,不是豁免评论**(双 + 态混编曾让健康 hold 顶红裸计数、藏住未定级堆;逐卡豁免评论机制已废弃,⛔ 不复活)。 - **首触定级补遗**:零定级评论的卡优先 —— 未定级卡前提准确性半衰期以天计,首触延 迟是单卡最大成本;旧 hold 重验不占预算;关闭 not planned 附理由并列入轮次报 告;立单 dev 只有局部视野,照实立单不压级。 @@ -44,115 +42,114 @@ ## 同趟成员卡写侧打包(维护者 2026-08-16 限流裁定的对价) -出处:反复撞的限流墙经维护者确认为「GitHub API 池」。同一趟派发/定级覆盖多张成员 -卡时,**锚卡承载全量认领(或定级)评论**(会话、分支、文件面、交付物、串行约束全 -量),成员卡各留一行指针指向锚卡;标签翻转照旧逐卡执行 —— 省的是评论正文写量,不 -是状态写入;验收与解锁扫描的读侧不变,仍逐卡读。 +出处:反复撞的限流墙经维护者确认为「GitHub API 池」。同一趟派发/定级覆盖多张成员卡时,**锚 +卡承载全量认领(或定级)评论**(会话、分支、文件面、交付物、串行约束全量),成员卡各留一行 +指针指向锚卡;标签翻转照旧逐卡执行 —— 省的是评论正文写量,不是状态写入;验收与解锁扫描的 +读侧不变,仍逐卡读。**写序:标签翻转只在认领评论写成功之后**,两笔不原子 —— 评论 502 而标 +签已翻即制造一记真 H2(实测一轮三起);顺序反了就带重试义务,重试未成即回滚标签。 ## 座位贴活性巡查(分诊轮常设项;维护者 2026-08-14) -出处:「结论当轮执行」同批裁定;先例是七条 🟢 贴一日内被逐条手工纠正。分诊 -Routine 每 fire 附带一次: +出处:「结论当轮执行」同批裁定;先例是七条 🟢 贴一日内被逐条手工纠正。分诊 Routine 每 +fire 附带一次: -- **扫描面**:`label:pm:seat` 列表页上标题挂 `🟢 ` 的贴; - `🟢 Routine` 座位以调度器读数为准,不入本巡查。 -- **判据只认在任者自己的产出**(自有评论/认领/正文编辑,作者与时间戳是平台盖章的硬读数),⛔ 不算发给它的跨座位通知 —— 收到消息不是活着的证据。 -- **>24h 无自有产出(与既有回收线同一条)⇒ 当场降级**:标题改 `⏳ vacant` + 摘 - assignee **同笔**,留证据评论(最后自有产出的时间戳与链接);在飞认领照认领协议 - 由原认领者跟完。 +- **扫描面**:`label:pm:seat` 列表页上标题挂 `🟢 ` 的贴;`🟢 Routine` 座位以 + 调度器读数为准,不入本巡查。 +- **判据只认在任者自己的产出**(自有评论/认领/正文编辑,作者与时间戳是平台盖章的硬读数), + ⛔ 不算发给它的跨座位通知 —— 收到消息不是活着的证据。 +- **>24h 无自有产出(与既有回收线同一条)⇒ 当场降级**:标题改 `⏳ vacant` + 摘 assignee + **同笔**,留证据评论(最后自有产出的时间戳与链接);在飞认领照认领协议由原认领者跟完。 - 惰性判定(接管冲突时)照旧,本巡查是其常设出口(细则见 `seat-post-protocol.md`)。 ## 分诊席读数成本三则(维护者 2026-08-20) -出处:三方讨论后维护者「立卡」;实测:数百评论座位贴每轮全文重读数千 token;耐久 -修法(量具读评论级边已落地;正文单读恢复被 sanitizer 陷阱挡住,另线)外的席内对价。 - -- **收班简报索引化**:模板固定四段 —— 首行机器判据(「分诊轮收尾」起始标记, - **逐字不动**,互斥守卫靠 grep 它)+ 索引表(卡号 | 一行判决)+ 健康指标 + 接 - 力清单(后两段沿用主文件轮次报告节的定义,含点名带开放下游依赖的决策卡)。 - **细节禁止复述**:逐卡处置理由已作为审计评论落卡,简报只指向不拷贝。硬预算: - 正文 ≤3000 字符,收班时**自报字符数**;连续两轮超限即巡检可见的漂移。没有读 - 数的「写短点」约定会涨回去,自报读数是防复利的关键。 -- **互斥最小尾页读法**(读数定义在主文件开轮互斥读法):尾页配方只覆盖座位贴的 - 两个读数;第三读数(车道 `pm:dispatched` 卡最新非本 session `Claim:`)出自车 - 道盘点,不在座位贴线程。配方:`issue_read get` 拿评论总数,按小 `perPage` - (~15)算末页页码只读尾页;尾页不含两读数再回翻一页,⛔ 不读整线程。 +出处:三方讨论后维护者「立卡」;实测数百评论座位贴每轮全文重读数千 token。 + +- **收班简报索引化**:四段模板 —— 首行机器判据(「分诊轮收尾」起始标记,**逐字不 + 动**,互斥守卫靠 grep 它)+ 索引表(卡号 | 一行判决)+ 健康指标 + 接力清单(后两段 + 沿用主文件轮次报告节定义,含点名带开放下游依赖的决策卡)。**细节禁止复述**:逐卡理 + 由已作审计评论落卡,简报只指不拷。硬预算正文 ≤3000 字符,收班**自报字符数**;连续 + 两轮超限即漂移 —— 没有读数的「写短点」约定会涨回去。 +- **互斥最小尾页读法**(读数定义在主文件):尾页配方只覆盖座位贴两个读数;第三读数 + (车道 `pm:dispatched` 卡最新非本 session `Claim:`)出自车道盘点,不在座位贴线程。 + 配方:`issue_read get` 拿评论总数,按小 `perPage`(~15)算末页页码只读尾页;尾页缺 + 读数再回翻一页,⛔ 不读整线程。 - **长线程状态读外包**:线程 > ~15 条的**状态提取**(找最新转换评论、抽 - `Restart-when:` 行、确认 claim/report 在不在)一律派低档只读子代理(sonnet 即 - 可)返回结构化摘录,尾部优先。**裁决级阅读(契约复审、放行判定、代裁)不可外 - 包** —— 省这部分是拿放行质量换 token,禁止。 + `Restart-when:` 行、确认 claim/report 在不在)派低档只读子代理(sonnet 即可)返回结 + 构化摘录,尾部优先。**裁决级阅读(契约复审、放行判定、代裁)不可外包** —— 那是拿放 + 行质量换 token,禁止。 +- **派发前读一次 `rate_limit`**:配额是全舰队共享的单点,耗尽同时杀掉在飞的每个 + agent(实测一记限流一次带走三个 dev);余量装不下整批就减批,⛔ 不靠撞墙发现。 ## 分诊两级盘点细则(维护者 2026-08-20) -出处(逐字,未译):「每次都需要 四仓全量 open issue 盘点 … 吗?建议分诊多久执行一 -次」→「立卡」。实测全量四仓 ~50k token/轮,`since` 等价 ~8k。小时层三读法:sweep -析取①②③ = `list_issues` + `since`(标签变更刷新 `updated_at`,半标注照样入窗); -解锁扫描反演 = 逐仓 `state=CLOSED` + `since` 与 `Blocked-by:` 反向索引求交,命中走 -既有回队双查;健康指标 = 逐标签 `perPage: 1` 只读 `totalCount`。每日层(当日首 -fire)= 四仓全量对账 + 归集本就日频的职责(finding 集中轮、`Restart-when:` 判据批 -扫、决策箱回填)。成本靶:干活轮 ≤50k,空转轮守既有探针预算;守小时 fire 的硬理由是契约复审时延(条款② PR 等复审清标,拉长节拍翻倍落地等待)。 +出处(逐字,未译):「每次都需要 四仓全量 open issue 盘点 … 吗?建议分诊多久执行一次」 +→「立卡」。实测全量四仓 ~50k token/轮,`since` 等价 ~8k。小时层三读法:sweep 析取①②③ = +`list_issues` + `since`(标签变更刷新 `updated_at`,半标注照样入窗);解锁扫描反演 = 逐仓 +`state=CLOSED` + `since` 与 `Blocked-by:` 反向索引求交,命中走既有回队双查;健康指标 = 逐 +标签 `perPage: 1` 只读 `totalCount`。每日层(当日首 fire)= 四仓全量对账 + 归集本就日频的 +职责(finding 集中轮、`Restart-when:` 判据批扫、决策箱回填)。成本靶:干活轮 ≤50k,空转轮 +守既有探针预算;守小时 fire 的硬理由是契约复审时延(条款② PR 等复审清标,拉长节拍翻倍落 +地等待)。 ## 落卡与裁决记录细则(维护者 2026-08-13) -出处(逐字,未译):「还有很多我发现分诊或者决裁后没有改状态,这个也是问题」…「这 -个也需要更新项目经理技能」。实测:一张决策卡三个叠加半状态。 - -- **裁决记录四件补遗**(四件本体在主文件;原子、记录座位全责、维护者裁与代裁同 - 规):① 鲜度门可内联声明分歧代替调和正文,对过期正文记录的裁决按过期论;② 结果 - 态 = `pm:queue`/`pm:blocked`/`pm:on-hold`/关闭,永不留挂;④ 已可判定的就地解 - 决,⛔ 不留给未来静默触发。 -- **机械两旗(report-only,归半状态巡查;接线是另一张卡的事,⛔ 不随协议文本改脚 - 本)**:旗一 —— open 卡标题带决策标记或线程含裁决记录、却仍挂 - `needs-user-decision`;旗二 —— `pm:blocked` 卡的 `Blocked-by:` 目标已关闭。 - 「交付了一半的阻塞」是判断不是 grep,归四件之③,⛔ 不进机械旗。 -- **落卡分析模板 + 业务写法六项**(中文,2026-08-19 裁;业务角度,2026-08-20 裁):模板骨架、 - 六项写法要求与四棱块固定形状全部细则移 `references/decision-analysis.md`。 -- **行文纪律**:出处一行(日期 + 原话);实测叙事至多压成一行 —— 故事住在卡上,不 - 进操作文本。 +出处(逐字,未译):「还有很多我发现分诊或者决裁后没有改状态,这个也是问题」…「这个也需 +要更新项目经理技能」。实测:一张决策卡三个叠加半状态。 + +- **裁决记录四件补遗**(四件本体在主文件;原子、记录座位全责、维护者裁与代裁同规):① 鲜 + 度门可内联声明分歧代替调和正文,对过期正文记录的裁决按过期论;② 结果态 = + `pm:queue`/`pm:blocked`/`pm:on-hold`/关闭,永不留挂;④ 已可判定的就地解决,⛔ 不留给未 + 来静默触发。 +- **机械两旗(report-only,归半状态巡查;接线是另一张卡的事,⛔ 不随协议文本改脚本)**:旗 + 一 —— open 卡标题带决策标记或线程含裁决记录、却仍挂 `needs-user-decision`;旗二 —— + `pm:blocked` 卡的 `Blocked-by:` 目标已关闭。「交付了一半的阻塞」是判断不是 grep,归四件 + 之③,⛔ 不进机械旗。 +- **落卡分析模板 + 业务写法六项**(中文,2026-08-19 裁;业务角度,2026-08-20 裁):模板骨 + 架、六项写法要求与四棱块固定形状全部细则移 `references/decision-analysis.md`。 +- **行文纪律**:出处一行(日期 + 原话);实测叙事至多压成一行 —— 故事住在卡上,不进操作文本。 ## 结论当轮执行(维护者 2026-08-14) -出处:维护者裁(逐字,未译)「现在开工派发」;当日实测是成批「建议关闭」复测结论 -与结论表躺置多日无人执行。 +出处:维护者裁(逐字,未译)「现在开工派发」;当日实测是成批「建议关闭」复测结论与结论表 +躺置多日无人执行。 -- **结论产生的那一轮就执行它。** 复测/审计/裁决得出结论而不在同一轮完成对应的关 - 闭/换标写入,本身就是半状态,与半状态家族同列 —— 记录后离场 - (record-and-walk-away)⛔ 禁止;结论表同规,表不是交付物,落账才是。 -- **无权执行 ⇒ 点名有权者,并当轮把状态搬进对方收件箱**(换标进对方的收件箱视图/ - 决策箱),⛔ 不许只留一条评论等人路过 —— 评论不是任何人的收件箱,标签才是。 -- **机械半边**:「交付 PR 已合并而卡仍挂 `pm:dispatched`」由半状态巡查的 - report-only 项浮出(细节以脚本头为权威);旗标是兜底,当轮执行才是修法。 +- **结论产生的那一轮就执行它。** 复测/审计/裁决得出结论而不在同一轮完成对应的关闭/换标写 + 入,本身就是半状态,与半状态家族同列 —— 记录后离场(record-and-walk-away)⛔ 禁止;结论 + 表同规,表不是交付物,落账才是。 +- **无权执行 ⇒ 点名有权者,并当轮把状态搬进对方收件箱**(换标进对方的收件箱视图/决策箱), + ⛔ 不许只留一条评论等人路过 —— 评论不是任何人的收件箱,标签才是。 +- **机械半边**:「交付 PR 已合并而卡仍挂 `pm:dispatched`」由半状态巡查的 report-only 项浮 + 出(细节以脚本头为权威);旗标是兜底,当轮执行才是修法。 ## 云卡(`mode:cloud`)四课 -**适用面论证**(裁定与 S/M/L 分配在主文件「资源与后端」):云有归档债、派发收集管 -线、逐卡容器+clone 启动三项固定税;subagent 唯一实成本「随 PM 会话死」已由 -branch-early、draft-PR 时点交报、transcript 复活与接手协议兜底;共享容器争用只来 -自 build+test,docs/指令类 M 卡碰不到 —— 归档义务因此只落云卡。 +**适用面论证**(裁定与 S/M/L 分配在主文件「资源与后端」):云有归档债、派发收集管线、 +逐卡容器+clone 三项固定税;subagent 唯一实成本「随 PM 会话死」已由 branch-early、 +draft-PR 时点交报、transcript 复活与接手协议兜底;共享容器争用只来自 build+test, +docs/指令类 M 卡碰不到 —— 归档义务因此只落云卡。 1. **授权面随 source,不随环境**:trigger 拉起的会话无仓库授权(只读勘察); - `create_session` 带 `source_url` 出生即持推送授权。同时带 `outcome_branch` - (= 认领分支)、显式 `model`(trigger 流不可指模型)、`title`(车道名开头, - ⛔ 不叫 os-dev;形如 `⚡ <车道> #<单号> <短语>`)。 -2. **派发词必带自驱条款**(云会话回合一结束即停摆等 poke):⛔ 不为提问/中期汇报结 - 束回合;开放选择按裁决与评估轴自裁记入终报 open_questions;合法回合终点只有 - (a) 推送完成 + 终报,或 (b) 硬阻塞详报。 -3. **交付通道**:dev 自开 draft PR(`Fixes #`,正文含验证记录)+ 终报以 issue - 评论(os-dev-report 标记)交付;未 attach 的姊妹仓够不着,跨仓跟进卡 PM 代立; - trigger 定时会话维持降级通道 —— 推送 outcome branch + 终报走最后一条会话消息, - PM 代开 draft PR。 -4. **云卡 draft PR 一存在,立即 `subscribe_pr_activity` —— 硬步骤**:未订阅的云卡 - PR 是轮询负债,订阅把感知从巡检周期缩到秒级。 - -监控与转向:`get_session` 读实时状态(IDLE + 分支未推送 = 停摆待 poke);投递消息 -用绑定会话的 poke 触发器(`create_trigger` 带 `persistent_session_id` + -`fire_trigger`,用后即 `delete_trigger`);巡检退为兜底心跳,只补订阅盲区(会话停 -摆、未开 PR 的分支、姊妹仓动静)。roster 不可达与 SendMessage 一款同裁(`ListAgents` 同列不到,⛔ 不复测)。会话句柄是**账 -号作用域**的:`get_session` / `archive_session` / 绑会话 poke 触发器对另一账号建 -的会话一律答 `not found`,与「从不存在」不可区分 —— **⛔ 永不读作死亡信号**(实测 -判 not found 的会话数十分钟前还 RUNNING;误判死会往活 worktree 塞第二个 agent); -跨账号接班活性只认 GitHub 产出读数 —— draft-PR 时点交报正为此存在,报告落卡即可收 -口。 + `create_session` 带 `source_url` 出生即持推送授权。同时带 `outcome_branch`(= 认领分 + 支)、显式 `model`(trigger 流不可指模型)、`title`(车道名开头,⛔ 不叫 os-dev;形如 + `⚡ <车道> #<单号> <短语>`)。 +2. **派发词必带自驱条款**(云会话回合一结束即停摆等 poke):⛔ 不为提问/中期汇报结束回合; + 开放选择按裁决与评估轴自裁记入终报 open_questions;合法回合终点只有 (a) 推送完成 + 终 + 报,或 (b) 硬阻塞详报。 +3. **交付通道**:dev 自开 draft PR(`Fixes #`,正文含验证记录)+ 终报以 issue 评论 + (os-dev-report 标记)交付;未 attach 的姊妹仓够不着,跨仓跟进卡 PM 代立;trigger 定时会 + 话维持降级通道 —— 推送 outcome branch + 终报走最后一条会话消息,PM 代开 draft PR。 +4. **云卡 draft PR 一存在,立即 `subscribe_pr_activity` —— 硬步骤**:未订阅的云卡 PR 是轮 + 询负债,订阅把感知从巡检周期缩到秒级。 + +监控与转向:`get_session` 读实时状态(IDLE + 分支未推送 = 停摆待 poke);投递消息用绑 +定会话的 poke 触发器(`create_trigger` 带 `persistent_session_id` + `fire_trigger`,用 +后即 `delete_trigger`);巡检退为兜底心跳,只补订阅盲区(会话停摆、未开 PR 的分支、姊妹 +仓动静)。roster 不可达与 SendMessage 同裁(`ListAgents` 同列不到,⛔ 不复测)。会话句 +柄是**账号作用域**的:`get_session` / `archive_session` / 绑会话 poke 触发器对另一账号 +建的会话一律答 `not found`,与「从不存在」不可区分 —— **⛔ 永不读作死亡信号**(实测判 +not found 的会话数十分钟前还 RUNNING;误判死会往活 worktree 塞第二个 agent);跨账号接 +班活性只认 GitHub 产出读数 —— draft-PR 时点交报正为此存在。 ## subagent 批(`mode:subagent`)派发前置:先快进本地检出 @@ -163,81 +160,84 @@ subagent 在 PM 容器内运行,agent 定义与技能文本读**本地检出**, ## 接手中断的 dev(worktree 接手协议) -先试复活、不可用才接手与 ⛔ 不重跑原派发词在主文件;四条增量: +先试复活、不可用才接手与 ⛔ 不重跑原派发词在主文件;五条增量: +- **死后恢复查三态,不止查远程**:三态 = 远程(`git ls-remote`)/ 仅容器磁盘 / 真没了 —— + 中间那态最值钱且当前无人查(worktree 比 agent 活得久,同容器共享,未提交改动通常还在); + 只查远程会理直气壮地令下一个 dev「从零开始」(实测三文件未提交改动险些丢)。捡到的工作一 + 律标 **UNVERIFIED** 交接 —— 不丢弃也不继承(实测捡到的改动含两个缺陷)。 - worktree 已存在,⛔ 不要新建,`cd` 进去接着做(第二个 worktree 会劈开工作); - 先读全部既有提交与未提交改动,逐 hunk 决定保留或修正 —— 既不推倒重来也不盲信(死 agent 的一切都未经验证); - 把死 agent 没跑完的验证**完整重跑**并报真实输出(中断的运行没留下测试证据); -- assignee、认领评论、分支**全部不动** —— 既有认领的延续,不是新认领;认领评论记 - 录接手,不被替换。 +- assignee、认领评论、分支**全部不动** —— 既有认领的延续,不是新认领;认领评论记录接手,不被替换。 ## 跨车道简单阻塞项直接接手(维护者 2026-08-13) -出处(逐字,未译):「如果当前任务依赖其他赛道的任务,如果判断简单,是否可以直接 -接手而不必持续等待」。判据与认领纪律(含 `Claim:` 拼写)在主文件;补遗:只覆盖 -「等一周 vs 做十分钟」的形状;`packages/spec` 恒归 spec 座位,本条不豁免(唯一所 -有者规则更硬);**⛔ 不是「借调」回归** —— 判据是依赖形状,只做被本车道开卡 -`Blocked-by:` 点名的那一个阻塞项,做完即回,⛔ 不顺手接第二张。 +出处(逐字,未译):「如果当前任务依赖其他赛道的任务,如果判断简单,是否可以直接接手而不必 +持续等待」。判据与认领纪律(含 `Claim:` 拼写)在主文件;补遗:只覆盖「等一周 vs 做十分钟」 +的形状;`packages/spec` 恒归 spec 座位,本条不豁免(唯一所有者规则更硬);**⛔ 不是「借 +调」回归** —— 判据是依赖形状,只做被本车道开卡 `Blocked-by:` 点名的那一个阻塞项,做完即 +回,⛔ 不顺手接第二张。 ## 停摆复位梯度 -原则三条在主文件;本节是执行细则。姿态句全文:「前台(阻塞)同步执行全部步骤,中 -途不停止、不把构建/测试挂到后台等唤醒」。梯度逐级(原样重发同一句不算一次复位): -① 复述执行姿态 → ② 点名下一个工具调用 + 明令禁止后台等待 → ③ 第三次停摆判 -unreliable,按接手协议重派到该分支。自己挂的定时器不会唤醒自己;长验证管线的派发/ -接力词写明前台姿态;生产侧条款在 os-dev 定义。 +原则三条在主文件;本节是执行细则。姿态句全文:「前台(阻塞)同步执行全部步骤,中途不停 +止、不把构建/测试挂到后台等唤醒」。梯度逐级(原样重发同一句不算一次复位):① 复述执行姿态 +→ ② 点名下一个工具调用 + 明令禁止后台等待 → ③ 第三次停摆判 unreliable,按接手协议重派到 +该分支。自己挂的定时器不会唤醒自己;长验证管线的派发/接力词写明前台姿态;生产侧条款在 +os-dev 定义。 ## 通知重放去重 -原则在主文件;细则:重放形态可以完全正常、报告完整正确 —— monitor 按自己的 -deadline 触发;身份 = 三元组 `(issue, 分支, PR head sha)` + 通知自报的守护对象 -(自报缺席就用三元组自己算);判定重放 ⇒ 台账记「重放,首达时间 T」即结束。 +原则在主文件;细则:重放形态可以完全正常、报告完整正确 —— monitor 按自己的 deadline 触 +发;身份 = 三元组 `(issue, 分支, PR head sha)` + 通知自报的守护对象(自报缺席就用三元组自 +己算);判定重放 ⇒ 台账记「重放,首达时间 T」即结束。 ## 直接验收兜底(报告丢失 ≠ 验收停摆) -触发三条件、舰队级例外与「先探活后翻 ready」在主文件;验收动作:逐文件核对 diff -与认领申报的文件面,对照 `origin/main` 复核 PR 正文的前提声明与验证叙述,复核清单 -其余判据不减;抢先翻 ready 会撞上 agent 的收尾推送(竞态保护);「推分支 → 开 -draft PR → 立即交报告」契约(保险,非效率优化)住 os-dev 定义,派发令只带增量。 +触发三条件、舰队级例外与「先探活后翻 ready」在主文件;验收动作:逐文件核对 diff 与认领申 +报的文件面,对照 `origin/main` 复核 PR 正文的前提声明与验证叙述,复核清单其余判据不减;抢 +先翻 ready 会撞上 agent 的收尾推送(竞态保护);「推分支 → 开 draft PR → 立即交报告」契约 +(保险,非效率优化)住 os-dev 定义,派发令只带增量。 ## 派发词构造细则 -原则(⛔ 默认不整段粘贴 issue 正文;dev 自查正文完整性是「自己读」截断风险的对价) -在主文件;本节是条款原文与增量: - -- **三分区条款原文**:「裁决(不可重裁)」—— dev 执行,不重开;「PM 机制假设(须 - 实测,鼓励证伪)」—— dev 动手前验证,证伪照实报告并按裁决意图换实现路径,⛔ 不 - 许为顺从假设硬做;「PM 建议的路线(可选,实测优先)」—— dev 有更好的就换,⛔ 不 - 得因「派发令写了」照做。 -- **清单取数补遗**(条款主文在主文件):门禁清单单班之内就会过期,所以派发那一刻 - 现取;全 farm 归 CI 跑一次,⛔ 不让 dev 枚举全 farm;点名单当天现取仍会漏(实测 - 漏点名的门恰被改动打红过)—— dev 对实际改动路径重取补跑,条款住 os-dev 定义。 -- **文件面两句原文**:「预期落点是 X;若实测表明真正的生产者在别包,报备后按生产 - 者侧修(落点与理由写进报告和 PR 正文),⛔ 不在消费者侧打补丁」—— 只写一个路径 - 名,是要求 dev 在守约与修对之间二选一;跨包常等于跨车道,PM 事后补跨座位声明。 -- **Same-day churn 行与在飞重叠**:派发时 `git log origin/main --oneline -20 -- - ` 见当天合并 ⇒ 加一行「基于合并后的代码工作,issue 引用的片段可能已变, - 先核对当前 main」(dev 的 worktree 切出后不会自己更新)。在飞重叠每轮拦截 - (churn 行只覆盖派发瞬间):main 新落 PR 与在飞申报文件面求交,相交即警告四句 - —— 合 main 重跑测试矩阵、读对方 diff 重划边界、只补它没覆盖的部分、被完全覆盖 - 就停下回报 ⛔ 不硬造 diff。晚一轮 = 一次返工。 -- **全仓 pin 清扫两句原文**:① grep 错误码/错误消息**全仓扫描**同语义 pin,一轮翻 - 完,不只改本包(旧立场的 pin 住在被改包之外的消费层);② 翻转后的 pin 必须继续 - 承重 —— 断言新语义的**实质**(行数、译文、状态码),不是「旧断言已删」;真正非 - 法形状的拒收断言**逐字保留**,被守护的面永不缩水。 +原则(⛔ 默认不整段粘贴 issue 正文;dev 自查正文完整性是「自己读」截断风险的对价)在主文 +件;本节是条款原文与增量: + +- **三分区条款原文**:「裁决(不可重裁)」—— dev 执行,不重开;「PM 机制假设(须实测, + 鼓励证伪)」—— dev 动手前验证,证伪照实报告并按裁决意图换实现路径,⛔ 不许为顺从假设 + 硬做;「PM 建议的路线(可选,实测优先)」—— dev 有更好的就换,⛔ 不得因「派发令写了」 + 照做。 +- **清单取数补遗**(条款主文在主文件):门禁清单单班之内就会过期,所以派发那一刻现取;全 + farm 归 CI 跑一次,⛔ 不让 dev 枚举全 farm;点名单当天现取仍会漏(实测漏点名的门恰被改 + 动打红过)—— dev 对实际改动路径重取补跑,条款住 os-dev 定义。**pnpm 10.31 起 + `install --workspace-concurrency` 被拒**(只对跑脚本的命令合法),装依赖命令别带它。 +- **文件面两句原文**:「预期落点是 X;若实测表明真正的生产者在别包,报备后按生产者侧修 + (落点与理由写进报告和 PR 正文),⛔ 不在消费者侧打补丁」—— 只写一个路径名,是要求 dev + 在守约与修对之间二选一;跨包常等于跨车道,PM 事后补跨座位声明。 +- **Same-day churn 行与在飞重叠**:派发时 `git log origin/main --oneline -20 -- ` 见 + 当天合并 ⇒ 加一行「基于合并后的代码工作,issue 引用的片段可能已变,先核对当前 main」 + (dev 的 worktree 切出后不会自己更新)。在飞重叠每轮拦截(churn 行只覆盖派发瞬间):main + 新落 PR 与在飞申报文件面求交,相交即警告四句 —— 合 main 重跑测试矩阵、读对方 diff 重划 + 边界、只补它没覆盖的部分、被完全覆盖就停下回报 ⛔ 不硬造 diff。晚一轮 = 一次返工。 +- **全仓 pin 清扫两句原文**:① grep 错误码/错误消息**全仓扫描**同语义 pin,一轮翻完,不只 + 改本包(旧立场的 pin 住在被改包之外的消费层);② 翻转后的 pin 必须继续承重 —— 断言新语 + 义的**实质**(行数、译文、状态码),不是「旧断言已删」;真正非法形状的拒收断言**逐字保 + 留**,被守护的面永不缩水。 - **条件性标准条款,命中判据才抄,不命中就省掉别硬套**: - - 多实现面组件(同一契约 ≥2 个实现面)⇒ 「测试放在**未来的分叉会被抓住的地方**, - 进共享一致性覆盖,不是独立测试文件」;不变量形:与 `find()` 给出相同行集,或以 + - 多实现面组件(同一契约 ≥2 个实现面)⇒ 「测试放在**未来的分叉会被抓住的地方**,进共享 + 一致性覆盖,不是独立测试文件」;不变量形:与 `find()` 给出相同行集,或以 `INVALID_FILTER` 拒收 —— 不允许有第三种、更安静的答案。 - 新增/改写拒收类用例 ⇒ 「最低断言错误的 `code` 与 `status`(ADR-0112 信封); - `toThrow()` / `rejects.toThrow()` 单独使用不构成拒收测试;措辞本身是契约时首句 - 断言**加在** `code`+`status` 之上;只约束新写/改写的用例,不回填存量」。 - - 改过滤/谓词语义 ⇒ 把 `references/compile-surfaces.md` 的编译面清单**按其复核串 - 重验后逐面抄进派发令**(⛔ 不凭记忆),并带:「每一面都必须在 PR 正文有一个结 - 论:已改 / 本就合规(证据)/ 明确不在范围(理由);⛔ 静默略过 —— 评审把没提到 - 的面读作漏掉的面」。防的不是做错,是做对了一部分然后以为做完了。 - - 改动触及**已发布包**(`package.json` 的 `private` 不为 true)⇒ 「用户可见行为 - 改动**必须**带 `.changeset/*.md`;判据是包的发布状态,不是改动大小 —— 缺了则 - 合进 main 却永不发布,看起来像修好了,比不合更糟」(ACCEPT 侧对账在复核清单)。 -- **测量先行卡**(修复由测量结果**有条件授权**)⇒ 派发令写明「⛔ 测量存在之前不写 - 修复」—— 测量姿态让 dev 看得见卡面没列的探针,实现姿态只看得见要交的 diff。 + `toThrow()` / `rejects.toThrow()` 单独使用不构成拒收测试;措辞本身是契约时首句断言**加 + 在** `code`+`status` 之上;只约束新写/改写的用例,不回填存量」。 + - 改过滤/谓词语义 ⇒ 把 `references/compile-surfaces.md` 的编译面清单**按其复核串重验后逐 + 面抄进派发令**(⛔ 不凭记忆),并带:「每一面都必须在 PR 正文有一个结论:已改 / 本就合 + 规(证据)/ 明确不在范围(理由);⛔ 静默略过 —— 评审把没提到的面读作漏掉的面」。防的 + 不是做错,是做对了一部分然后以为做完了。 + - 改动触及**已发布包**(`package.json` 的 `private` 不为 true)⇒ 「用户可见行为改动**必 + 须**带 `.changeset/*.md`;判据是包的发布状态,不是改动大小 —— 缺了则合进 main 却永不发 + 布,看起来像修好了,比不合更糟」(ACCEPT 侧对账在复核清单)。 +- **测量先行卡**(修复由测量结果**有条件授权**)⇒ 派发令写明「⛔ 测量存在之前不写修复」 + —— 测量姿态让 dev 看得见卡面没列的探针,实现姿态只看得见要交的 diff。 diff --git a/scripts/pm/check-half-states.mjs b/scripts/pm/check-half-states.mjs index cf0d211ba8..e137d82e6f 100644 --- a/scripts/pm/check-half-states.mjs +++ b/scripts/pm/check-half-states.mjs @@ -523,6 +523,43 @@ * the labels, and a title heuristic would make this sweeper guess at * intent. * + * ## H27 — the claim is perfect and the claimant is dead + * + * H27 an open `pm:dispatched` card whose claim is TEXTBOOK-CORRECT — assignee + * set, a first-line `Claim:` comment, a named branch that EXISTS on the + * remote — where that branch has not moved since the claim, no PR + * delivers the card, and the claim is older than the protocol's own + * ~24h stale line (`DEAD_CLAIM_STALE_HOURS`). This is the shape a dev + * agent that DIED leaves behind, and the measured cause arrives in + * batches: one shared-account capacity limit killed three concurrently + * dispatched agents at 05:50Z, leaving three cards on which every + * predicate in this file passed. ⭐ Its danger is not that it goes + * unreported but that it reads as HEALTHY: the next PM's round-open + * mutual-exclusion read looks for the latest non-self `Claim:` on a + * lane's dispatched cards, so a dead claim is read as a live claim by + * another session and the lane stays off the card — the protocol's own + * mutual-exclusion mechanism converting a corpse into a lane-wide block. + * H20 is the near neighbour and misses it BY CONSTRUCTION rather than by + * oversight: `.claude/agents/os-dev.md` makes pushing the empty branch + * the FIRST action of the task (a write-route probe), so a + * protocol-compliant agent that dies still leaves a ref and lands + * outside H20's no-ref-at-all population. The better the dev follows the + * protocol, the more invisible its death — which is why the two rows are + * disjoint by construction (H20 fires only when NO branch resolves, H27 + * only when one does) and why neither could be widened into the other. + * ⛔ The threshold is QUOTED, not measured: the fleet has 2 liveness + * samples and that is not a distribution, so the row mechanizes SKILL.md's + * existing 死认领回收 line (「认领 >~24h」) — the same 24h the seat-post + * patrol already calls 「与既有回收线同一条」, one number in the protocol + * with two readers. ⚠️ Reporting is not reclaiming, and the row says so: + * the protocol's reclaim rule applies to a branch that does NOT exist and + * states 「有带提交活分支的认领永不回收」, so this row prescribes the + * three-state recovery INSPECTION (remote / container disk / gone, found + * work handed on flagged UNVERIFIED — `references/dispatch-runbook.md`) + * and never an assignee drop. It deliberately under-reports the dev that + * pushed one commit and then died (its branch moved after the claim), for + * the same reason — that is precisely the card the protocol protects. + * * ## The close mechanism, measured (#8293) * * A half-delivered card (#8131) was closed `completed` two seconds after its @@ -4102,6 +4139,226 @@ export function h26BlockOnIndefiniteTarget(issue, resolutions) { return parts.join(' '); } +// --------------------------------------------------------------------------- +// H27 — the claim is PERFECT and the claimant is gone (#11248). +// +// H20 above asks whether a dispatch ever HAPPENED, and keys on NO REMOTE REF AT +// ALL. This row asks the opposite-shaped question: the ref exists, the claim is +// textbook-correct, and nothing has moved since. The two are disjoint by +// construction — H20 fires only when no branch resolves, H27 only when one +// does — and neither could be widened into the other without losing the +// property that makes it safe. +// +// ## Why the ref EXISTS in the failure this row is built on +// +// The dev-agent definition makes pushing the empty branch the FIRST action of +// the task, before any edit, as a write-route probe (`.claude/agents/os-dev.md` +// rule 1). So an agent that dies at any point after its first minute — which is +// every point that matters — leaves a branch ON THE REMOTE. That protocol step +// is correct and worth keeping, but it has a side effect nobody priced: it +// converts the dead-agent case out of H20's population and into a population +// with no reader at all. The better the dev follows the protocol, the more +// invisible its death. +// +// ## The measured incident +// +// Three devs were dispatched concurrently and all three died at once on one +// shared-account capacity limit — fleet-wide exhaustion is a single point of +// failure for every agent in flight, so this arrives in batches, not singly. +// Each card was left `pm:dispatched`, assigned, carrying a claim comment whose +// first line is literally `Claim:`, naming a branch that exists. Every +// predicate in this file passed. Worse than merely unreported: the next PM's +// round-open mutual-exclusion read looks for the latest non-self `Claim:` on a +// lane's dispatched cards, so it reads those corpses as LIVE claims by another +// session and stays off them. The protocol's own mutual-exclusion mechanism +// converts a dead claim into a lane-wide block. +// +// ## Threshold: the protocol's own line, not a new heuristic +// +// SKILL.md already carries a stale-claim reclaim rule at 「认领 >~24h」, so this +// row mechanizes an existing protocol threshold rather than inventing a +// liveness distribution the fleet does not have (the filing card is explicit +// that 2 samples is not a distribution). The same 24 hours is already the seat +// -post patrol's own line — 「与既有回收线同一条」 — so the number has one +// source in the protocol and two readers, rather than two numbers. +// +// ⚠️ The protocol's reclaim rule and this row are NOT the same act, and the +// remedy sentence keeps them apart. That rule reclaims a claim whose branch +// does not exist, and it says 「有带提交活分支的认领永不回收」 — a claim whose +// branch carries commits is never reclaimed. This row fires on branches that DO +// exist, some of them carrying commits, so it deliberately prescribes the +// recovery INSPECTION and never the reclaim: reporting a card is not reclaiming +// it, and the row must not be readable as authority to drop an assignee the +// protocol protects. +// --------------------------------------------------------------------------- + +/** + * The protocol's own stale-claim line, in hours (SKILL.md 死认领回收). The one + * threshold in this file that is QUOTED rather than measured — see the section + * note above for why a measured one would be worse here. + */ +export const DEAD_CLAIM_STALE_HOURS = 24; + +/** How old the governing claim is in HOURS — `null` when unreadable, as H20. */ +export function claimAgeHours(claim, nowMs = Date.now()) { + const minutes = claimAgeMinutes(claim, nowMs); + return minutes === null ? null : minutes / 60; +} + +/** + * Which cards buy the branch-activity read — exported for the same reason + * `h20NeedsRefProbe` is: a policy that decides what is READ AT ALL is where a + * silent hole would live. + * + * A claim younger than the threshold is not stale, so it buys nothing and this + * row says nothing about it either way. An UNREADABLE claim timestamp is + * gathered rather than skipped — it must not read as fresh (#4690) — and the + * predicate then declines to judge it out loud. + */ +export function h27NeedsClaimLivenessRead(issue, claim, nowMs = Date.now()) { + if (!labelNames(issue ?? {}).includes('pm:dispatched')) return false; + if (!claim || (claim.branches ?? []).length === 0) return false; + const age = claimAgeHours(claim, nowMs); + return age === null || age > DEAD_CLAIM_STALE_HOURS; +} + +/** + * Does any PR this sweep holds deliver card `n`? The delivery relation is + * `prDeliversCard` — H8's, deliberately shared rather than re-derived, so the + * two rows can never disagree about what "has a PR" means. + * + * Counted per channel because the two windows have different reach: the open + * listing is effectively complete (paged to exhaustion), while the merged one + * is a bounded recency window (`MERGED_WINDOW_PAGES`). That asymmetry is what + * the finding sentence has to disclose, so it is preserved here rather than + * collapsed into a boolean. + */ +export function claimDelivery(n, openPrs, mergedPrs) { + const target = String(n); + const open = (openPrs ?? []).filter((pr) => prDeliversCard(pr, target)).length; + const merged = (mergedPrs ?? []).filter((pr) => pr?.merged_at && prDeliversCard(pr, target)).length; + return { open, merged }; +} + +/** + * Did this branch move AFTER the claim was posted? Three-valued, never two: + * `true` / `false` / `null` when either timestamp is unreadable — an unread + * comparison is not a "no" (#4690), and collapsing it would let one unparseable + * date manufacture a finding about a card nobody measured. + */ +export function branchMovedSinceClaim(refState, claim) { + const head = Date.parse(refState?.headCommittedAt ?? ''); + const posted = Date.parse(claim?.createdAt ?? ''); + if (!Number.isFinite(head) || !Number.isFinite(posted)) return null; + return head > posted; +} + +/** + * H27 — null when clean, else the finding sentence. + * + * ## The conjunction, and why each term is load-bearing + * + * `pm:dispatched` the card still claims to be in flight + * claim older than 24h the protocol's own stale line + * a claimed branch EXISTS (else it is H20's row, not this one) + * NO branch moved since the claim — nothing was pushed for this dispatch + * no PR delivers the card neither open nor within the merged window + * + * ⛔ Dropping the branch-activity term would give exactly the PR-keyed row H20 + * refuses to be, and it is refused there for a measured reason: a dev inside a + * long build legitimately has a ref and no PR for over an hour. That objection + * is answered here by BOTH remaining terms and not by the threshold alone — a + * dev 24 hours in with commits landing is excluded by branch activity, and a + * dev with a PR open is excluded by delivery. What is left is a branch that has + * not moved since it was claimed, with nothing to show for a day. + * + * ## What it under-reports, stated rather than discovered + * + * A dev that pushed one commit and THEN died is not reported: its branch moved + * after the claim, so the activity term clears it. That is the measured shape + * of one of the three incident cards, and widening the term to "no activity in + * the last 24h" would catch it — at the cost of colliding with the protocol's + * 「有带提交活分支的认领永不回收」, which is a rule about exactly that card. + * Under-reporting on a card the protocol protects is the same call H17's + * extractor and H20's branch-shape matcher make: a row a reader cannot act on + * is worse than no row. + * + * @param {object} issue — an OPEN issue. + * @param {{ branches: string[], createdAt: string|null }|null} claim + * @param {{ branch: string, state: 'exists'|'absent'|'unreadable', + * headCommittedAt?: string|null }[]} refStates + * @param {{ open: number, merged: number }} delivery — `claimDelivery`. + */ +export function h27DeadClaimNoProgress(issue, claim, refStates, delivery, nowMs = Date.now()) { + if (!labelNames(issue ?? {}).includes('pm:dispatched')) return null; + if (!claim || (claim.branches ?? []).length === 0) return null; + const age = claimAgeHours(claim, nowMs); + if (age !== null && age <= DEAD_CLAIM_STALE_HOURS) return null; + + const rows = refStates ?? []; + if (rows.length === 0) return null; + const present = rows.filter((r) => r.state === 'exists'); + // No ref at all is H20's row; an unreadable probe is H20's quieter one. This + // row speaks only about branches it KNOWS are there. + if (present.length === 0) return null; + + // A delivery in either channel ends the question: an open PR is live work (or + // work already handed over), and a merged one is H8's row about a paired + // write, never this row's about a dead agent. + const { open = 0, merged = 0 } = delivery ?? {}; + if (open > 0 || merged > 0) return null; + + const moved = present.map((r) => branchMovedSinceClaim(r, claim)); + if (moved.some((m) => m === true)) return null; + + const named = namedBranches(present.map((r) => ({ branch: r.branch, state: r.state }))); + const recovery = + ' Report-only, and pointedly NOT a reclaim: the protocol reclaims a claim whose branch does ' + + 'NOT exist and states 「有带提交活分支的认领永不回收」, so a row about a branch that DOES ' + + 'exist can never be authority to drop an assignee. The remedy is the post-kill recovery ' + + 'inspection (`references/dispatch-runbook.md`): probe the claimant, then read all THREE ' + + 'states — on the remote / on the container disk only / gone — and hand anything found to a ' + + 'replacement flagged UNVERIFIED. ⛔ Never a label written from this script.'; + + if (moved.some((m) => m === null)) { + return ( + `\`pm:dispatched\` with a complete claim naming ${named}, and whether that branch has MOVED ` + + 'since the claim could not be determined this sweep (an unreadable claim or head-commit ' + + 'timestamp) — so this dispatch is UNJUDGED, not confirmed healthy. Unread is not "no ' + + 'activity" and it is not "activity" either (#4690); a liveness comparison dropped in ' + + 'silence reads as a working dev forever, which is the exact failure this item exists to ' + + 'end. Read the branch and the claim by hand.' + + recovery + ); + } + + const reading = + age === null + ? 'an unreadable claim timestamp (which must not read as fresh)' + : `~${Math.round(age)}h after the claim was posted (threshold ${DEAD_CLAIM_STALE_HOURS}h, the ` + + "protocol's own stale-claim line)"; + + return ( + `\`pm:dispatched\` with a PERFECT claim — assignee set, a first-line \`Claim:\` comment, and ` + + `${named} present on the remote — that has NOT MOVED SINCE IT WAS CLAIMED, with no PR ` + + `delivering the card, ${reading}. This is what a dev agent that DIED leaves behind, and the ` + + 'measured cause arrives in batches rather than singly: one shared-account capacity limit ' + + 'killed three concurrently-dispatched agents at once. ⭐ The card is indistinguishable from ' + + 'healthy in-flight work from the card itself — every field is correct, which is why no ' + + 'predicate here fired on it: H1 wants a missing assignee, H2 a missing claim comment, H8 a ' + + 'merged PR, and H20 no remote ref at all. H20 misses it BY CONSTRUCTION, not by accident: ' + + 'the dev-agent definition makes pushing the empty branch the first action of the task, so a ' + + 'protocol-compliant agent that dies still leaves a ref. Left unreported it does worse than ' + + "sit there — the next PM's round-open mutual-exclusion read treats a dead `Claim:` as a live " + + 'claim by another session and stays off the card, so one dead agent blocks the lane. ⛔ Rule ' + + 'out one reading first: a delivery that merged BEFORE this sweep\'s merged window ' + + `(${MERGED_WINDOW_PAGES} pages) is invisible here, so a card whose PR landed days ago and ` + + 'whose branch was never deleted can reach this row — check the card for a merged delivery ' + + 'before treating it as a death.' + + recovery + ); +} + // --------------------------------------------------------------------------- // Report rendering — pure over (findings, counts), so `--self-test` pins both // media offline. The live sweep below picks a renderer and prints it; nothing @@ -4220,9 +4477,11 @@ export function summaryLine(counts, findingCount) { `Blocker liveness (H19): targets resolved on ${btResolved} of ${btTargets} distinct \`Blocked-by:\` ` + `target(s) named by open \`pm:blocked\` card(s)` + `${btResolved < btTargets ? ' — each unresolved target is named on its own card\'s row, never dropped' : ''}. ` + - `Dispatch liveness (H20): remote ref read on ${refRead} of ${refTargets} distinct claimed branch(es) ` + - `named by open \`pm:dispatched\` card(s) past the ${DISPATCHED_NO_REF_STALE_MINUTES}-minute threshold` + - `${refRead < refTargets ? ' — each unread ref is named on its own card\'s row, never dropped' : ''}. ` + + `Dispatch liveness (H20 + H27): remote branch read on ${refRead} of ${refTargets} distinct claimed ` + + `branch(es) named by open \`pm:dispatched\` card(s) past the ${DISPATCHED_NO_REF_STALE_MINUTES}-minute ` + + `threshold — one read serving both rows, so H27's ${DEAD_CLAIM_STALE_HOURS}h population is a subset ` + + 'of this one and costs no request of its own' + + `${refRead < refTargets ? ' — each unread branch is named on its own card\'s row, never dropped' : ''}. ` + `Report-only: findings are patrol input, not a gate verdict.` ); } @@ -5670,20 +5929,40 @@ async function sweepInto(findings, seen, seenPrs, seenMerged, seenUnscoped, seen const resolveBranchRef = async (branch) => { const cached = refCache.get(branch); if (cached) return cached; - // `/git/ref/heads/` is an EXACT-match lookup and a 404 there is the - // healthy-negative this row is built on — not an error. The branch is + // `/branches/` is an EXACT-match lookup and a 404 there is the + // healthy-negative H20 is built on — not an error. The branch is // segment-encoded rather than whole-encoded because the slashes in // `claude/issue--` are path separators to this endpoint. - const path = `/repos/${OWNER_REPO}/git/ref/heads/${branch.split('/').map(encodeURIComponent).join('/')}`; + // + // ⭐ Why this endpoint rather than `/git/ref/heads/`, which H20 + // asked originally: H27 (#11248) needs the head commit's DATE, and this + // response already carries it while the ref response carries only a sha — + // reading it there would cost a second request per branch. Both endpoints + // resolve the SAME underlying ref, so existence is answered identically + // and H20's three states are untouched; the extra field rides in on a + // payload the sweep had already paid for. That is H26's own "FREE" shape, + // and it keeps the request COUNT of this pass exactly as it was. + const path = `/repos/${OWNER_REPO}/branches/${branch.split('/').map(encodeURIComponent).join('/')}`; let resolved; try { - await rest(path); - resolved = { state: 'exists', detail: null }; + const row = await rest(path); + resolved = { + state: 'exists', + detail: null, + // Missing rather than null-coalesced to a date: an absent field must + // reach the predicate as "unknown" so it declines to judge, never as a + // timestamp that happens to compare false (#4690). + headCommittedAt: row?.commit?.commit?.committer?.date ?? null, + }; } catch (err) { resolved = err?.status === 404 - ? { state: 'absent', detail: null } - : { state: 'unreadable', detail: err?.status ? `HTTP ${err.status}` : 'unreadable' }; + ? { state: 'absent', detail: null, headCommittedAt: null } + : { + state: 'unreadable', + detail: err?.status ? `HTTP ${err.status}` : 'unreadable', + headCommittedAt: null, + }; } refCache.set(branch, resolved); return resolved; @@ -5697,6 +5976,20 @@ async function sweepInto(findings, seen, seenPrs, seenMerged, seenUnscoped, seen for (const branch of claim.branches) states.push({ branch, ...(await resolveBranchRef(branch)) }); const undispatched = h20DispatchedNoBranchRef(issue, claim, states); if (undispatched) findings.push([issue, 'H20', undispatched]); + // H27 — the same claim, the same ref states, the OTHER question: not "was + // this ever dispatched" but "is the thing that was dispatched still alive". + // Its gathering gate (24h) is a strict subset of H20's (60 min), so every + // card it can speak about has already been probed above and it adds NO + // request of its own. Delivery is read from the two PR windows this sweep + // already holds — H8's inputs, through H8's own delivery relation. + if (!h27NeedsClaimLivenessRead(issue, claim)) continue; + const deadClaim = h27DeadClaimNoProgress( + issue, + claim, + states, + claimDelivery(issue.number, openWindow, mergedWindow), + ); + if (deadClaim) findings.push([issue, 'H27', deadClaim]); } // Distinct branches, which is the unit the cache and the request count are // in — and the word is in the summary sentence for the same reason it is in @@ -7491,14 +7784,138 @@ function selfTest() { repo: 'objectstack-ai/objectstack', issues: 1, unscoped: 1, prs: 0, merged: 0, dispatchRefRead, dispatchRefTargets, }); - t('summary: the H20 coverage pair is reported', summaryLine(refCounts(4, 5), 1).includes('remote ref read on 4 of 5 distinct claimed branch(es)'), true); + t('summary: the H20 coverage pair is reported', summaryLine(refCounts(4, 5), 1).includes('remote branch read on 4 of 5 distinct claimed branch(es)'), true); t('summary: …scoped to the population H20 judges', summaryLine(refCounts(4, 5), 1).includes('named by open `pm:dispatched` card(s)'), true); t('summary: …and names the threshold that bounded it', summaryLine(refCounts(4, 5), 1).includes(`past the ${DISPATCHED_NO_REF_STALE_MINUTES}-minute threshold`), true); - t('summary: an H20 shortfall points at the rows that carry it', summaryLine(refCounts(4, 5), 1).includes('each unread ref is named on its own card\'s row, never dropped'), true); - t('summary: a complete H20 pass adds no shortfall clause', summaryLine(refCounts(5, 5), 1).includes('each unread ref'), false); - t('summary: absent H20 counts degrade to 0, never to undefined', summaryLine({ repo: 'r', issues: 1, unscoped: 1, prs: 0, merged: 0 }, 0).includes('remote ref read on 0 of 0 distinct'), true); + t('summary: an H20 shortfall points at the rows that carry it', summaryLine(refCounts(4, 5), 1).includes('each unread branch is named on its own card\'s row, never dropped'), true); + t('summary: a complete H20 pass adds no shortfall clause', summaryLine(refCounts(5, 5), 1).includes('each unread branch'), false); + t('summary: absent H20 counts degrade to 0, never to undefined', summaryLine({ repo: 'r', issues: 1, unscoped: 1, prs: 0, merged: 0 }, 0).includes('remote branch read on 0 of 0 distinct'), true); + // The pair is H27's coverage number too, and a reader seeing a quiet H27 + // needs to know that — one read serves both rows. + t('summary: the pair is declared as serving BOTH rows', summaryLine(refCounts(5, 5), 1).includes('Dispatch liveness (H20 + H27)'), true); + t('summary: …naming H27\'s threshold', summaryLine(refCounts(5, 5), 1).includes(`H27's ${DEAD_CLAIM_STALE_HOURS}h population is a subset`), true); + t('summary: …and that it costs no request of its own', summaryLine(refCounts(5, 5), 1).includes('costs no request of its own'), true); t('summary: the report-only contract still ends the sentence after H20', summaryLine(refCounts(5, 5), 1).endsWith('not a gate verdict.'), true); + // -- H27: the claim is PERFECT and the claimant is dead (#11248) ------------ + // + // The specimen is the 2026-08-23 capacity kill: three agents dispatched at + // ~05:46Z, all three killed at ~05:50Z on one shared-account weekly limit. + // `NOW_27` is set past the protocol's own 24h stale line so the age the row + // reports is a real one rather than a round number chosen to pass. + const CLAIM_27 = '2026-08-23T05:46:00Z'; + const NOW_27 = Date.parse('2026-08-24T08:00:00Z'); // ~26h after the claim + const claim27Body = [ + 'Claim: `domain:devx` execution seat.', + 'Session: `session_0124Qg8rLvpXnQDwCmpKUmaJ`', + 'Branch: `claude/issue-5442-metadata-form`', + ].join('\n'); + const claim27 = (createdAt = CLAIM_27) => governingClaim([claimRow(createdAt, claim27Body)]); + const BR_27 = 'claude/issue-5442-metadata-form'; + // A branch that exists and has NOT moved since the claim: its head is the + // base commit it was cut from, which predates the claim. That is the shape + // the os-dev empty-branch push probe leaves behind. + const frozen = (headCommittedAt = '2026-08-23T05:10:00Z') => [ + { branch: BR_27, state: 'exists', detail: null, headCommittedAt }, + ]; + const noDelivery = { open: 0, merged: 0 }; + const dead27 = (over = {}) => + h27DeadClaimNoProgress( + // `in` rather than `??` throughout: a test that passes an explicit + // `undefined` must reach the predicate, not be replaced by the default it + // is trying to displace (measured — the missing-issue case was green + // against a healthy card before this was written out longhand). + 'issue' in over ? over.issue : dispatchedCard(), + 'claim' in over ? over.claim : claim27(), + 'refs' in over ? over.refs : frozen(), + 'delivery' in over ? over.delivery : noDelivery, + NOW_27, + ); + + // ★ The finding itself, and the facts the sentence must carry. + t('H27: a frozen branch + no PR past 24h -> finding', typeof dead27(), 'string'); + t('H27: …and names the branch', dead27().includes(`\`${BR_27}\``), true); + t('H27: …and says the branch has not moved since the claim', dead27().includes('NOT MOVED SINCE IT WAS CLAIMED'), true); + t('H27: …and reports the age against the protocol threshold', dead27().includes(`threshold ${DEAD_CLAIM_STALE_HOURS}h`), true); + t('H27: …calling that threshold the protocol\'s own line, not a heuristic', dead27().includes("protocol's own stale-claim line"), true); + t('H27: …and states the measured ~26h age', dead27().includes('~26h after the claim was posted'), true); + t('H27: …and names the lane-block consequence, not just the silence', dead27().includes('mutual-exclusion read'), true); + t('H27: …and explains WHY H20 cannot see it', dead27().includes('pushing the empty branch the first action'), true); + t('H27: …and rules out the pre-window merged delivery first', dead27().includes(`${MERGED_WINDOW_PAGES} pages`), true); + t('H27: not a loud finding', isLoudFinding(dead27()), false); + + // ★ Report-only, and specifically NOT a reclaim — the protocol protects a + // claim whose branch carries commits, so this row must never read as + // authority to drop an assignee. + t('H27: the remedy is the recovery inspection', dead27().includes('post-kill recovery'), true); + t('H27: …naming all three recovery states', dead27().includes('on the remote / on the container disk only / gone'), true); + t('H27: …and the UNVERIFIED hand-off', dead27().includes('flagged UNVERIFIED'), true); + t('H27: …quoting the protocol rule that forbids reclaiming this card', dead27().includes('有带提交活分支的认领永不回收'), true); + t('H27: …and never a label written from this script', dead27().includes('Never a label written from this script'), true); + + // ★ Disjoint from H20 BY CONSTRUCTION, in both directions, on one fixture. + const absent27 = [{ branch: BR_27, state: 'absent', detail: null, headCommittedAt: null }]; + t('H27: no ref at all is H20\'s row, not this one', dead27({ refs: absent27 }), null); + t('H27: …and H20 does fire on it', typeof h20DispatchedNoBranchRef(dispatchedCard(), claim27(), absent27, NOW_27), 'string'); + t('H27: an existing ref is clean for H20', h20DispatchedNoBranchRef(dispatchedCard(), claim27(), frozen(), NOW_27), null); + t('H27: …while H27 fires on exactly that card', typeof dead27(), 'string'); + t('H27: an UNREADABLE probe is H20\'s quieter row, not this one', dead27({ refs: [{ branch: BR_27, state: 'unreadable', detail: 'HTTP 500', headCommittedAt: null }] }), null); + + // ★ The branch-activity term. A dev that pushed after claiming is ALIVE for + // this row — and the under-report it implies is deliberate (see the docblock). + t('H27: a branch that moved AFTER the claim -> clean', dead27({ refs: frozen('2026-08-23T05:49:00Z') }), null); + t('H27: a branch whose head predates the claim -> finding', typeof dead27({ refs: frozen('2026-08-22T09:00:00Z') }), 'string'); + t('H27: one moved branch among frozen ones clears the card', dead27({ refs: [...frozen(), { branch: 'claude/issue-5442-b', state: 'exists', headCommittedAt: '2026-08-23T09:00:00Z' }] }), null); + t('H27: activity is measured against the CLAIM, not the threshold', dead27({ refs: frozen('2026-08-23T05:47:00Z') }), null); + // Three-valued, never two (#4690): an unreadable comparison is not a "no". + t('H27: an unreadable head timestamp does NOT read as healthy', dead27({ refs: frozen(null) }) === null, false); + t('H27: …and fires the quieter UNJUDGED row instead', dead27({ refs: frozen(null) }).includes('UNJUDGED, not confirmed healthy'), true); + t('H27: …which does not assert the finding it did not measure', dead27({ refs: frozen(null) }).includes('NOT MOVED SINCE IT WAS CLAIMED'), false); + t('H27: …citing the unread-is-not-absent rule', dead27({ refs: frozen(null) }).includes('#4690'), true); + t('H27: an absent head field reads as unknown, not as an old date', dead27({ refs: [{ branch: BR_27, state: 'exists' }] }).includes('UNJUDGED'), true); + t('H27: branchMovedSinceClaim is three-valued', [branchMovedSinceClaim(frozen()[0], claim27()), branchMovedSinceClaim(frozen('2026-08-23T09:00:00Z')[0], claim27()), branchMovedSinceClaim(frozen(null)[0], claim27())].join(','), 'false,true,'); + + // ★ The delivery term, through H8's own relation so the two cannot drift. + t('H27: an OPEN PR delivering the card -> clean', dead27({ delivery: { open: 1, merged: 0 } }), null); + t('H27: a MERGED delivery is H8\'s row, not this one', dead27({ delivery: { open: 0, merged: 1 } }), null); + t('H27: claimDelivery reads the body relation', claimDelivery(5442, [{ number: 9, body: 'Part of #5442' }], []).open, 1); + t('H27: …and the branch-name fallback', claimDelivery(5442, [{ number: 9, body: '', head: { ref: 'claude/issue-5442-x' } }], []).open, 1); + t('H27: …counting merged deliveries separately', claimDelivery(5442, [], [{ number: 9, body: 'Fixes #5442', merged_at: '2026-08-23T10:00:00Z' }]).merged, 1); + t('H27: …and ignoring an unmerged closed PR in the merged window', claimDelivery(5442, [], [{ number: 9, body: 'Fixes #5442', merged_at: null }]).merged, 0); + t('H27: …and a PR for some OTHER card', claimDelivery(5442, [{ number: 9, body: 'Part of #9999' }], []).open, 0); + t('H27: a card delivered in halves is not a death', dead27({ delivery: claimDelivery(5442, [{ number: 9, body: 'Part of #5442' }], []) }), null); + + // ★ Unlike H20, this predicate DOES take pull-request input — that is the + // deliberate difference between the two rows, pinned so it cannot be lost. + t('H27: the predicate takes a delivery input', h27DeadClaimNoProgress.length, 4); + t('H20: …and still takes none', h20DispatchedNoBranchRef.length, 3); + + // The gathering gate, and that it is a strict subset of H20's. + t('H27 gate: a claim past 24h is read', h27NeedsClaimLivenessRead(dispatchedCard(), claim27(), NOW_27), true); + t('H27 gate: a claim inside 24h buys nothing', h27NeedsClaimLivenessRead(dispatchedCard(), claim27('2026-08-24T04:00:00Z'), NOW_27), false); + t('H27 gate: an unreadable claim timestamp is read, never assumed fresh', h27NeedsClaimLivenessRead(dispatchedCard(), claim27('not-a-date'), NOW_27), true); + t('H27 gate: a card without `pm:dispatched` is out of scope', h27NeedsClaimLivenessRead(dispatchedCard(['pm:queue']), claim27(), NOW_27), false); + t('H27 gate: no claim -> nothing to read (that shape is H2\'s row)', h27NeedsClaimLivenessRead(dispatchedCard(), null, NOW_27), false); + t('H27 gate: a missing issue does not crash', h27NeedsClaimLivenessRead(undefined, claim27(), NOW_27), false); + // The subset property is what makes H27 cost ZERO extra requests: every card + // it can speak about was already probed for H20. + t('H27 gate: every H27 candidate is already an H20 candidate', h27NeedsClaimLivenessRead(dispatchedCard(), claim27(), NOW_27) && h20NeedsRefProbe(dispatchedCard(), claim27(), NOW_27), true); + t('H27 gate: …and the threshold is strictly wider than H20\'s', DEAD_CLAIM_STALE_HOURS * 60 > DISPATCHED_NO_REF_STALE_MINUTES, true); + + // The remaining gates and the caller contract, H20's shapes on H27's inputs. + t('H27: the label gate outranks everything', dead27({ issue: dispatchedCard(['pm:queue']) }), null); + t('H27: no claim -> no row', dead27({ claim: null }), null); + t('H27: an unprobed card yields no row (caller contract, as H19/H20)', dead27({ refs: [] }), null); + t('H27: absent ref states -> no row', dead27({ refs: undefined }), null); + t('H27: a missing issue does not crash', dead27({ issue: undefined }), null); + t('H27: a young claim -> no row even with a frozen branch', dead27({ claim: claim27('2026-08-24T04:00:00Z') }), null); + t('H27: exactly AT the threshold is not past it', dead27({ claim: claim27(new Date(NOW_27 - DEAD_CLAIM_STALE_HOURS * 3_600_000).toISOString()) }), null); + const unstamped27 = dead27({ claim: claim27('not-a-date') }); + t('H27: an unreadable claim timestamp does not read as fresh', unstamped27 === null, false); + t('H27: …and yields the UNJUDGED row (the comparison is impossible)', unstamped27.includes('UNJUDGED'), true); + const many27 = Array.from({ length: 7 }, (_, i) => ({ branch: `claude/issue-1-b${i}`, state: 'exists', headCommittedAt: '2026-08-22T09:00:00Z' })); + t('H27: the branch list is capped at the render budget', dead27({ refs: many27 }).includes(`+${7 - H20_BRANCH_LIST_CAP} more`), true); + // -- H16: open non-draft PR stuck in a merge conflict (2026-08-19 incident) -- // The single-PR payload shape, since `mergeable_state` is absent from the // listing rows this sweep otherwise runs on.