diff --git a/.claude/skills/pm-dispatch/SKILL.md b/.claude/skills/pm-dispatch/SKILL.md index 6f2ea6d630..8fdd422981 100644 --- a/.claude/skills/pm-dispatch/SKILL.md +++ b/.claude/skills/pm-dispatch/SKILL.md @@ -183,6 +183,34 @@ flaky 连踢五个互不相关的 PR,核对失败签名一致后原样重投,五 - REST core(15000/时)在共享身份下**同样会打满** —— 本条的三份判据(rate_limit 读数、整点重置、独立计费)对它一体适用,别把「走 REST」读成「不限量」。 +**定点文本的写法纪律 —— 已删除的定时器仍会投递,且投递时文本可能已落后现实数轮。** +上面第一条让定点文本**完整**(带全待执行状态),这一条让它**过期时仍然安全**;两条 +是同一枪的两面,都成立才够用。2026-08-07 跨两个座位三次实测,两种形态、同一个后果: + +- `domain:spec-surface` 席**两枪已 `delete_trigger`**(回包确认 `deleted trigger …`) + 的定时器**照样投递**,文本都落后现实两轮。其中一枪写着「#5783 …… 判为不可靠、 + 交接、**重新派发一个 fresh os-dev**,worktree `objectstack-issue-5783` 已存在」; + 投递时 #5783 的 PR #6389 早已交付并通过复核 —— 照文本执行就是把一个重复 agent + 塞进一个活着且已完工的 worktree,正是认领协议要防的碰撞类,只不过这次是**从 + 自动化里**来的,而不是从抢跑的 PM 那里。 +- `domain:devx` 席的一枪**没被删,是被现实追上**:21:3x 挂、22:1x 投递,文本写的 + 「两个 dev 静默结束、未开 PR、未交报告 ⇒ 判定失效 ⇒ 重新派发一个新 dev」在投递 + 时前提已被推翻(两个 dev 都已回话正常推进,其中一个的 PR 已合并)。照做会向两个 + 活着且已有成果的任务各塞一个重复 dev。删与没删是两条路径,终点是同一个。 + +两条硬规则: + +- **每一枪定点文本必须以「幂等 —— 动手前先重读状态」开头**,⛔ 不得包含未经重读 + 即可执行的祈使句。三次都没出事的唯一原因就是这句在文本里、且重读**真的被执行**; + 定时器一旦投递,平台侧没有任何东西会替你复核它的前提 —— 把重读写进文本是**唯一** + 能让过期指令失效的机制。 +- 文本只许描述**判据**(「若 X 则 Y」),⛔ 不许描述**结论**(「现在去做 Y」)。 + 「⇒ 重新派发」「⇒ 判为不可靠」「⇒ 打回不 arm」这类祈使句正是要禁的形态:它们在 + 写下的那一刻可能是对的,投递时未必还是,而祈使句把「判据可能已变」这件事从文本里 + 抹掉了 —— 判据句自带复核,结论句把复核外包给了一个已经不在场的自己。 + +本条的落点在 step 6(巡检定点)与 step 7(flip 定点)各有一条同款约束,写法一致。 + **4. 核验 main 的事实用 `origin/main`,不用共享检出的工作树。** 共享检出的 HEAD 由 别的 agent 摆布,可能落后 origin/main 数十提交(今天 PM 与一名 dev 都在落后 63 提交的 树上 grep 出假阴性,据此差点判了错误的结论)。一律先 `git fetch origin main`,再: @@ -331,6 +359,31 @@ REST 取 `body`(原文 4321 / 5183 / 4181 字符)+ 取 `body_html`(渲染版)— 其它分类更硬:误判一次的代价是一条可入队缺陷躺一天,外加一条打给作者的假工单。 已发出的重贴指令若事后证伪,**要在同一处公开作废**(同 notes 7:诊断结论一旦 公开发出又被推翻,更正要发在同样公开的位置)。 +- **同一个 sanitizer 的第二种形状 —— 写侧的「就地删除」,上面两条的判据抓不到它, + 反引号也不保护。** 上面两条管的是**读侧**误判(把读取端截断当成 issue 端截断), + ⚠️ 一字不改、依旧成立;这一条是新增的**另一种失效形态**,不是对它的修正:短的 + `<…>` 片段在**写入时**被就地删掉,正文其余部分完好无损 —— 没有「断掉的位置」, + 所以「两者在同一处断掉」这个判据在它身上恒假,双读取会一致地告诉你「正文完整」, + 而它确实完整,只是少了几个片段。2026-08-07 `domain:spec-surface` 席在座位贴的 + 交接台账上写后回读实测三例,三例都在反引号里、三例都被吃掉: + + | 写入 | 存回 | + |---|---| + | `` | (整段变成空) | + | `expected to be 19` | `expected to be 19` | + | `git log -- ` | `git log -- ` | + + 第一例是有代价的:那个标记是一次座位交接中在飞 dev 报告的**全部收集路径** + (step 6 `mode:cloud` 的收集判据),台账因此让接班人去扫一个**已经从台账里被删掉 + 的**标记 —— 只有写后回读抓到了它。两条动作: + + - 正文里凡要保留字面尖括号,一律写 HTML 实体 `<` / `>`,⛔ 不靠反引号或 + 围栏 —— 实测它们不提供保护; + - 含 HTML 注释标记(如 ``)、`` / `` / `` + 一类占位符、泛型参数的正文,**写后回读逐个确认这些片段仍在**,这是动作不是提醒。 + label discipline 的「写后回读」是同一条纪律的上位(#5885 那两次「sanitizer 吞 + 内容」即本形态),本条给的是它的**具体形状与判据**:失效完全静默 —— API 返回 + 成功,渲染页看不出缺口,只有把存回的正文与你写的原文逐段对比才看得见。 **13. MCP 工具的两个参数语义陷阱 —— 过滤是 OR、labels 是整组替换。** spec 车道 一任内两次实测(#5925),都是 notes 6「命令没在回答你以为的问题」的 API 参数版: @@ -1549,13 +1602,51 @@ attached. 末尾的重挂会整个丢失,守夜链就此断裂 —— 2026-08-06 实测:一次漏挂让 四连灭批静默了 ~100 分钟而不是探活门槛设计的 ≤45 分钟。先挂后查,链条 对中断免疫;挂错了间隔可以在本轮末尾用 delete_trigger + 重挂修正,但 - 「没挂」无法被本轮以外的任何机制补救。 + 「没挂」无法被本轮以外的任何机制补救。重挂的那一枪按 **notes 3 的定点 + 文本写法纪律**写:以「幂等 —— 动手前先重读状态」开头、只写判据不写结论。 + 巡检定点是最容易写成祈使句的一类(「⇒ 判为不可靠 ⇒ 重新派发」),也是 + 投递时最可能已经过期的一类 —— 在飞的 dev 在两次唤醒之间会推分支、开 PR、 + 交报告,而已 `delete_trigger` 的定时器仍会投递(notes 3 实测两次)。 - **批量在飞期间,主巡检间隔不得长于 45 分钟**(同一授权)。探活门槛是 45 分钟,巡检间隔一旦超过它,门槛就成了写在纸上的数字 —— 最坏情形下 一个派发后即死的 agent 要等到下一轮巡检才被发现,静默窗口 = 巡检间隔, 而非门槛值。在飞清零的待命期可放宽到 60-70 分钟;有任何 dev 在飞即收紧 回 ≤45,灭批频发期(如宿主重启风暴)进一步压到 20-30 分钟。 +**45 分钟是发探针的门槛,⛔ 不是判死的门槛 —— 两者必须分开。** 上面五条回答 +「什么时候去问」;这一条回答 PM 真正面对的另一个问题:「多久之后我才可以下 +『它死了』的结论」。分开的理由是**后果不同** —— 探针的代价是一次 SendMessage, +判死的代价是**重新派发**,即往一个可能还活着的 worktree 里塞第二个 agent +(step 5「Handing off an interrupted dev」的碰撞面)。 + +- 判死的正当依据只有三类:**探针回包表明已死**(「no active task; resumed from + transcript」一类)、**宿主明确回报 stopped**、或**超过本车道基线且连续静默**。 + ⛔ 「探针门槛过了两次」不在其中 —— 它只说明它还在跑。 +- 「超过基线」里的基线是**你自己车道实测的完工耗时**,⛔ 不是本文里的任何常数。 + 没有基线就先建基线再判:同形态卡片各记一个「派发 → 推分支 / 开 PR」的端到端 + 耗时,三五单即可用。**在基线之内的沉默不是证据。** +- 两条实测基线**只是出处样例,⛔ 不是全车队常数** —— 卡片形态不同,区间没有理由 + 相同,driver 或 engine-core 的重活不适用下表: + + | 出处(车道 / 日期) | 卡片形态 | 实测端到端 | + |---|---|---| + | `domain:spec-surface` 席,2026-08-07(#6393) | 文本面卡:#5767 / #5622 / #5955 / #5783 | 93 / 96 / ~95 / ~110 分钟 | + | `domain:devx` 席,2026-08-07(#6393 认领评论) | 混合:#6251 / #6038 / #6405 / #6359 | ~67 / ~64 / ~160 / ~170 分钟(后两单含长 CI 等待) | + + 合两席九单:同一天、同一套工具下,端到端跨越 **~64 分钟到近 3 小时**。凡把单一 + 数字当判死线的读法,都会在这个跨度里翻车 —— 所以要建的是**你那一栏**的基线。 +- 两个座位当天各误判一次,都栽在这条线上:`domain:spec-surface` 席在 92 分钟处 + 写下「#5783 将判为不可靠」,而它在基线之内、几分钟后就推了分支;`domain:devx` + 席在派发 2 小时处判两个 dev「静默结束」并把「重新派发」写进了下一枪定点,而两个 + 都在做深度取证。后者靠**先 SendMessage 问状态、而不是直接重派**救回 —— 那正是 + 上面五条的第一条。 +- 与既有两个数字的关系,一句话讲清:**45 分钟 = 探活门槛**(去问);**`mode:cloud` + 的 ~2h 静默 = 本轮收集边界**(记 `blocked`、本轮不再等,下一轮从 GitHub 重收); + ⛔ 两者都不是判死。下面「报告丢失 ≠ 验收停摆」把 ≥2h 与**探活确认已死**并列成 + 两个条件而不是一个,正是这个区分的既有写法。 +- 这与 Operational notes 11(⛔ 不得据症状推断维护者中止)是同一个失效类换了个 + 变量:在你知道基线之前,「正常但慢」与「已死」的读数完全相同。 + **A stalled subagent is this half's most common failure, and it never self-heals.** When a dev stops mid-task reasoning that "a background watcher will wake me", **that watcher never fires** — a completion notification is itself the @@ -1819,6 +1910,10 @@ flip 定点**,到点核对门禁 job 结论(notes 10)、绿即转 ready + 挂 au 未绿再阶梯重挂。CI success webhook 不可靠是环境明示的前提 —— 一班 13 次转 ready 全部由定点驱动、零漏接(#5885);定点文本按 notes 3 的配额交接纪律携带完整待执行 状态(哪个 PR、什么判据),抗上下文丢失。⛔ 不要坐等 webhook,也不要忙轮询。 +notes 3 的**写法纪律**在这里同样是硬要求:文本以「幂等 —— 动手前先重读状态」开头、 +只写判据(「若 #N 的门禁 job 结论全绿 ⇒ 转 ready 并挂 auto-merge」),⛔ 不写结论 +(「转 ready」)。flip 定点尤其容易过期:那 6–9 分钟里 PR 可能已被队列管家处置、 +被踢出、转成 `dirty`、或被别人先入了队,而已删除的定时器仍会投递。 **落地窗口给关键 PR 挂事件订阅(`subscribe_pr_activity`,维护者 2026-08-07 拍板)。** 适用面:会话型座位、且 PR 已进入 PM 的落地窗口 —— ACCEPT 后,以及被 diff --git a/skills/objectstack-pm-dispatch/SKILL.md b/skills/objectstack-pm-dispatch/SKILL.md index 571abb29f9..c6a7fc2949 100644 --- a/skills/objectstack-pm-dispatch/SKILL.md +++ b/skills/objectstack-pm-dispatch/SKILL.md @@ -343,6 +343,27 @@ each selected issue, before dispatching, execute in order: name exists, you lost — touch nothing of theirs, reply that you are yielding, and pick another issue. **First comment wins.** +**Read the claim comment back — GitHub's body sanitizer deletes short `<…>` +spans in place, and backticks do not protect them.** The shape above is built +out of placeholders, and the branch name inside it is the key that both the race +check and the stale-claim reclaim below read. Measured on this loop, writing +then reading the stored body back: `` came back as *nothing at +all*, `expected to be 19` came back as `expected to be 19`, `git log -- +` came back as `git log -- `. All three were inside code spans. This is +**not** the truncation shape that step 0's *Repair first* handles: the rest of +the body survives intact, so there is no truncation point to find, the rendered +page looks correct, and the API returned success. The first of those spans cost +the most — it was a report-collection marker, so the instruction to sweep for it +had been deleted from the very text that carried it. + +- Write literal angle brackets as HTML entities (`<` / `>`), or put a + space after the `<`. A code span is not protection. +- After writing any body whose content is load-bearing — a claim comment, a + cloud-mode dispatch prompt carrying a report marker, a handover note — + **read it back and confirm each such span is still present**. The write side + needs this read-back for the same reason the read side needs two readings: + "the API returned 200" is not "the stored content is correct". + Developer agents push their branch early — a remote branch is the hardest evidence of work in flight, and it closes the gap between "claimed" and "a PR exists". @@ -425,6 +446,50 @@ has reported, or a dispatch has been silent for over ~2 h (count it as **Never treat the absence of a report as success.** +**Write every check-in as criteria, never as a conclusion — scheduled text +arrives in a future you cannot see.** A check-in armed now is read by a session +that has lost your context, against a world that has moved on. Measured three +times in one day on this loop: a scheduled message **still delivered after its +timer had been cancelled**, carrying text two rounds out of date. One of them +read "no branch and no report ⇒ judge the agent unreliable and dispatch a fresh +one"; by delivery time that agent had opened a PR which was already reviewed and +accepted, so executing the text verbatim would have pushed a duplicate agent +into a live, finished worktree — the exact collision the claim protocol exists to +prevent, arriving through the automation instead of through a racing PM. Two +rules make stale text harmless: + +- **Open every check-in with "idempotent — re-read the state before acting"**, + and include no imperative that can be executed without that re-read. Once a + timer fires nothing on the platform side re-checks its premise for you; + putting the re-read into the text is the only mechanism that can expire an + obsolete instruction. +- **State criteria ("if X then Y"), never conclusions ("now do Y").** A + criterion re-derives itself on arrival; a conclusion has already discarded the + reading it came from. "⇒ re-dispatch", "⇒ judge it unreliable" are the shape to + avoid — correct when written, not necessarily correct when delivered. This + holds for timers you believe you cancelled too: cancellation is not a guarantee + of non-delivery. + +**A silence threshold is a collection boundary, not a verdict of death.** The +~2 h above means "stop waiting this round"; it does not mean the agent is gone, +and the two must not be swapped, because their costs differ by an order of +magnitude — waiting one more round costs a round, while concluding death costs a +**duplicate dispatch into a worktree that may still be live**. Before concluding +that a dispatched agent is dead, require one of: a direct status query answered +in a way that shows it is dead, the host reporting the session stopped, or +**silence past a completion-time baseline you have actually measured**. + +Measure that baseline for your own project — dispatch to first pushed branch or +PR, over a handful of comparable cards — and treat it as local. Two same-day +samples from this repo's loop show why no single number can be inherited: four +text-only documentation cards landed at 93 / 96 / ~95 / ~110 min, while four +mixed cards from the same day spanned ~64 min to ~2 h 50 min (the two long ones +waiting on CI). Nine cards, one day, one toolchain, a spread from about an hour +to nearly three. **Silence inside the baseline is not evidence**, and a check-in +threshold having passed twice is evidence only that time passed. This is the +same failure as inferring an abort from symptoms: until you know the baseline, +"working normally, slowly" and "dead" produce identical readings. + ### 7. Review each report You are the reviewer of record. For each report, verify **against GitHub — not