From 64bb7c60ef9a020144983d0f9b66ca5f21dbbfdb Mon Sep 17 00:00:00 2001 From: "Ragdoll-Opus-4.6" Date: Mon, 8 Jun 2026 14:15:26 +0800 Subject: [PATCH 1/7] chore(fork): prompt authority upgrade + LL-054 hotfix lesson MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Squashed fork-specific customizations: - Remove hardcoded identity from AGENTS.md/GEMINI.md (fix CLI system-level prompt overriding Cat Café identity injection) - Add LL-054: don't modify governance chain without analyzing existing mechanism Co-Authored-By: Claude Opus 4.6 --- AGENTS.md | 13 ++-- GEMINI.md | 18 ++--- docs/public-lessons.md | 156 +++++++++++++++++++++++++++++++++-------- 3 files changed, 140 insertions(+), 47 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index e8fa2ff3be..f610cf9c77 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,7 +1,8 @@ -# Clowder AI — OpenAI/Codex Agent Guide +# Clowder AI — Agent Guide ## Identity -You are the Maine Coon cat (Codex/GPT), the code reviewer and security specialist of this Clowder AI instance. +Your identity, role, and personality are provided by Cat Cafe at runtime. +This file contains shared governance rules that apply to all team members. ## Safety Rules (Iron Laws) 1. **Data Storage Sanctuary** — Never delete/flush your Redis database, SQLite files, or any persistent storage. @@ -9,15 +10,9 @@ You are the Maine Coon cat (Codex/GPT), the code reviewer and security specialis 3. **Config Immutability** — Never modify runtime config files. Config changes require human action. 4. **Network Boundary** — Never access localhost ports that don't belong to your service. -## Your Role -- Code review with clear stance on every finding (no "fix or not, up to you") -- Security analysis and vulnerability detection -- Test coverage verification -- Cross-model review (you review Claude's code, Claude reviews yours) - ## Review Protocol - Same individual cannot review their own code -- Cross-family review preferred (Maine Coon reviews Ragdoll's code) +- Cross-family review preferred - Every finding must have a clear severity: P1 (blocking) / P2 (should fix) / P3 (nice to have) ## Truth Sources diff --git a/GEMINI.md b/GEMINI.md index 23ecc1cff9..972a34631a 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -1,7 +1,8 @@ -# Clowder AI — Gemini Agent Guide +# Clowder AI — Agent Guide ## Identity -You are the Siamese cat (Gemini), the visual designer and creative thinker of this Clowder AI instance. +Your identity, role, and personality are provided by Cat Cafe at runtime. +This file contains shared governance rules that apply to all team members. ## Safety Rules (Iron Laws) 1. **Data Storage Sanctuary** — Never delete/flush persistent storage. @@ -9,13 +10,12 @@ You are the Siamese cat (Gemini), the visual designer and creative thinker of th 3. **Config Immutability** — Never modify runtime config files. 4. **Network Boundary** — Never access ports that don't belong to your service. -## Your Role -- Visual design and UX consultation -- Creative ideation and brainstorming -- Design system maintenance -- Breaking conventional thinking patterns - -## Important Constraints +## Gemini-Specific Constraints - Focus on design consultation, not code implementation - Always validate suggestions against the project's design system - Provide visual references when suggesting changes + +## Review Protocol +- Same individual cannot review their own code +- Cross-family review preferred +- Every finding must have a clear severity: P1 (blocking) / P2 (should fix) / P3 (nice to have) diff --git a/docs/public-lessons.md b/docs/public-lessons.md index 80e683ee7d..efaabe7f9d 100644 --- a/docs/public-lessons.md +++ b/docs/public-lessons.md @@ -90,7 +90,83 @@ created: 2026-02-26 --- -## 6) Maine Coon侧首批条目(AGENTS + Review + Skills) +## 6) 主题索引与高频核心 + +> ⭐ = 高频核心(日常协作反复踩的认知/流程护栏) + +**根因分析与方向纠偏** +- ⭐ LL-009: 关键前提不确定时,先提问再动作 +- LL-014: Bug 修复必须先写 Bug Report 再动手 +- LL-019: 过度修复反模式——根因修完后不要盲修触发器 +- ⭐ LL-020: 补丁数量是方向信号——N > 3 停下来复检方向 +- ⭐ LL-021: AI 倾向停在第一层"看起来合理"的答案,不主动追溯根因 +- ⭐ LL-058b: 热补丁反模式——已有治理机制不查就另起炉灶 + +**交付验证与证据纪律** +- ⭐ LL-006: 没有新鲜验证证据,不得宣称完成 +- ⭐ LL-029: 交付物验证不能只看 spec checkbox——必须核实 commit/PR +- ⭐ LL-031: Quality gate 逐字段对账 AC——文档承诺 ≠ 代码已兑现 +- ⭐ LL-032: 愿景守护不能只看代码和测试报告——必须真实启动 dev 跑一遍 +- ⭐ LL-041: 写完产物不主动打开 = 做了菜不端上桌 + +**Review 与协作纪律** +- LL-002: Review 问题必须先 Red 再 Green,禁止先改后补测 +- LL-003: Reviewer 必须有立场,Author 必须技术性 push back +- LL-004: P1/P2 当轮清零,P3 当场决断,不挂债务 +- LL-005: 修完 review 后必须回给 reviewer 二次确认再合 main +- LL-033: 云端 review 不能只看 review body state——必须检查 inline code comments + +**Worktree / Runtime / 数据安全** +- LL-008: Worktree 生命周期必须成套执行 +- LL-010: 删除文件必须用 trash,禁止 /bin/rm +- LL-011: Worktree 清理的正确顺序——先 push,再 cd 回主仓,最后 remove +- LL-012: 不要 --force 删有猫在工作的 worktree +- LL-015: Worktree 开发必须用独立 Redis 端口(6398) +- LL-045: Runtime worktree 反复被猫污染 +- ⭐ LL-049: `pnpm dev:direct` 无差别杀端口——review 踢翻 runtime +- LL-055: spawn 出的"长尾 child runtime"必须能脱离 parent 自动死亡 +- LL-056: stale browser profile 不是 orphan——cleanup 要按资源所有权分组 + +**知识工程与配置漂移** +- LL-001: 提炼教训前先做时效性验证 +- LL-007: 交接缺 Why 会让接手方无法判断 +- LL-025: 协作规则不能写死个体名,必须引用角色 +- LL-027: Feature spec 与代码实现的时间线漂移 +- LL-028: "最小实现"不等于"做个玩具再重写" +- LL-030: 共享脚本改默认值,同 commit 必须补显式环境值 +- LL-037: 共享记忆塑造视角——团队文化比模型参数更能影响判断趋同 +- LL-042: 配置真相源不加门禁就会漂移 +- LL-050: ADR 漂移 2 个月无人发现 +- LL-057: root prompt 重复可能是兼容副本,不是天然垃圾 + +**工具与基础设施细节** +- LL-013: Git commit 前必须检查暂存区 +- LL-016: ioredis keyPrefix 对 eval() 和 keys() 的行为不一致 +- LL-017: CAS 比较必须基于不可变快照 +- LL-018: Session 存储必须按 Thread 隔离 +- LL-022: 治理基线必须脚本化 +- LL-023: CLI JSON 格式陷阱与 jq 安全防护 +- LL-024: 状态字段多点写入会复发蜘蛛网 +- LL-026: 身份信息是硬约束常量 +- LL-034: Embedding 实现偷懒 +- LL-035: sync-to-opensource rsync --delete 打穿 runtime +- LL-036: full sync 长跑不能在半路报喜 +- LL-038: Promise timeout 不等于 Promise 取消 +- LL-039: gate 里推进 cursor 等于"还没干活就划卡" +- LL-040: AI 写文档日期不能凭内部时间感 +- LL-043: 删旧层前必须证明迁移已落成 +- LL-044: Chrome IME 回车误提交 +- LL-046: AOF/RDB 持久化脱节 +- LL-047: Socket.IO cors 不保护 WebSocket +- LL-048: 用户可感知状态禁止默认 TTL +- LL-051: 实验框架空转 +- LL-052: exec VAR=val cmd 不设置环境变量 +- LL-053: 无头 Codex CLI 长任务不能靠 shell 伪后台 +- LL-054: 猫的 callback env 泄漏到 unit test 子进程——用真身份发出 6 条 'hi' + +--- + +## 7) 条目 ### LL-002: Review 问题必须先 Red 再 Green,禁止先改后补测 - 状态:validated @@ -162,7 +238,7 @@ created: 2026-02-26 - 关联: - `cat-cafe-skills/merge-approval-gate/SKILL.md` - - `review-notes/README.md` + - *(internal reference removed)* ### LL-006: 没有新鲜验证证据,不得宣称完成 - 状态:validated @@ -198,7 +274,7 @@ created: 2026-02-26 - 关联: - `cat-cafe-skills/cross-cat-handoff/SKILL.md` - - `review-notes/README.md` + - *(internal reference removed)* ### LL-008: Worktree 生命周期必须成套执行(建-收敛-合入-清理) - 状态:validated @@ -238,10 +314,6 @@ created: 2026-02-26 - `cat-cafe-skills/systematic-debugging/SKILL.md` - `cat-cafe-skills/cat-cafe-receiving-review/SKILL.md` ---- - -## 7) Ragdoll侧首批条目(CLAUDE.md + Bug Report + Skills) - ### LL-010: 删除文件必须用 trash,禁止 /bin/rm - 状态:validated - 更新时间:2026-02-13 @@ -354,20 +426,6 @@ created: 2026-02-26 - 关联:CLAUDE.md §7 Redis 测试规则 | ADR-008 Lua 原子操作 -### LL-023: CLI JSON 格式陷阱与 `jq` 安全防护 -- 状态:draft -- 更新时间:2026-02-19 - -- 坑:在 CLI 中手动拼接带变量的 JSON 字符串(如 `curl` 调用 API)时,极易因双引号转义、多层嵌套或变量内容包含特殊字符而导致 JSON 格式损坏,甚至导致消息发送失败或变成“只有用户可见”的悄悄话。 -- 根因:手动拼接 JSON 违反了“数据与格式分离”原则,AI 对 Shell 转义规则(尤其是多层引号)的处理在复杂场景下不可靠。 -- 触发条件:通过 `curl` 调用含有环境变量(如 `$CAT_CAFE_INVOCATION_ID`)的 API,且消息内容包含引号、换行或表情符号时。 -- 修复:强制使用 `jq` 构造 JSON(例如:`jq -nc --arg c "$MSG" '{content: $c}'`),利用工具确保内容被自动转义。 -- 防护:更新所有 Agent 的提示词模板,将 `curl` 示例改为 `jq` 构造法;在 `GEMINI.md` 中增加醒目警告。 -- 来源锚点: - - `GEMINI.md` (2026-02-19 更新) - - 2026-02-19 Siamese(Gemini)“猫猫杀”游戏调试过程 -- 原理:结构化数据必须由结构化工具生成。在命令行环境中,`jq` 是保证数据序列化健壮性的事实标准。 - ### LL-017: CAS 比较必须基于不可变快照,不能用内存活引用 - 状态:validated - 更新时间:2026-02-13 @@ -463,12 +521,26 @@ created: 2026-02-26 - 防护:P0 验收前与后续回归中运行健康脚本;失败即阻断“可用”结论。 - 来源锚点: - `scripts/hindsight/p0-health-check.sh` - - `project-runbooks/hindsight-p0-health-check.md` + - *(internal reference removed)* - *(internal reference removed)* - 原理:治理有效性不是“策略存在”,而是“策略被持续验证”。没有自动化检查的治理,等同于没有治理。 - 关联:`docs/decisions/005-hindsight-integration-decisions.md` | `docs/ROADMAP.md` | Task 4 可观测检查 +### LL-023: CLI JSON 格式陷阱与 `jq` 安全防护 +- 状态:draft +- 更新时间:2026-02-19 + +- 坑:在 CLI 中手动拼接带变量的 JSON 字符串(如 `curl` 调用 API)时,极易因双引号转义、多层嵌套或变量内容包含特殊字符而导致 JSON 格式损坏,甚至导致消息发送失败或变成"只有用户可见"的悄悄话。 +- 根因:手动拼接 JSON 违反了"数据与格式分离"原则,AI 对 Shell 转义规则(尤其是多层引号)的处理在复杂场景下不可靠。 +- 触发条件:通过 `curl` 调用含有环境变量(如 `$CAT_CAFE_INVOCATION_ID`)的 API,且消息内容包含引号、换行或表情符号时。 +- 修复:强制使用 `jq` 构造 JSON(例如:`jq -nc --arg c "$MSG" '{content: $c}'`),利用工具确保内容被自动转义。 +- 防护:更新所有 Agent 的提示词模板,将 `curl` 示例改为 `jq` 构造法;在 `GEMINI.md` 中增加醒目警告。 +- 来源锚点: + - `GEMINI.md` (2026-02-19 更新) + - 2026-02-19 Siamese(Gemini)"猫猫杀"游戏调试过程 +- 原理:结构化数据必须由结构化工具生成。在命令行环境中,`jq` 是保证数据序列化健壮性的事实标准。 + ### LL-024: 状态字段多点写入会复发蜘蛛网 - 状态:validated - 更新时间:2026-02-27 @@ -1233,20 +1305,20 @@ created: 2026-02-26 - 状态:draft - 更新时间:2026-05-28 -- 坑:用户明确要“精美架构设计图 / 华为风 / 白底红黑 / 图片”时,Codex 第四五六次仍进入“先写 SVG 再转 PNG”的 coder 反射,产物方向错,且重复踩同一坑。 +- 坑:用户明确要”精美架构设计图 / 华为风 / 白底红黑 / 图片”时,Codex 第四五六次仍进入”先写 SVG 再转 PNG”的 coder 反射,产物方向错,且重复踩同一坑。 - 根因: - 1. 旧规则只是“默认建议”,没有进入执行前硬闸;一旦进入“文字可控、布局可控”的工程反射,imagegen 被错误降级成可选项。 - 2. 把“架构图需要精确”误判成“必须代码渲染”,但用户真正验收的是视觉完成度,而不是 SVG 源文件。 - 3. 已有猫档明确写了“Maine Coon原生图片生成强、禁止用 SVG 画”,但能力唤醒没有把这条转成 preflight。 -- 触发条件:复杂架构图、PPT 页面、企业信息图、华为风 / 红白黑风格、已有低保真蓝图但用户要求“精美图 / 终稿 / 图片”,且没有明确要求可编辑源文件。 -- 修复:已在 `cat-cafe-skills/image-generation/SKILL.md` 增加“Codex SVG 复发熔断闸”,匹配上述场景时禁止先写 SVG/HTML/Canvas,必须先原生 imagegen 整页直出。 + 1. 旧规则只是”默认建议”,没有进入执行前硬闸;一旦进入”文字可控、布局可控”的工程反射,imagegen 被错误降级成可选项。 + 2. 把”架构图需要精确”误判成”必须代码渲染”,但用户真正验收的是视觉完成度,而不是 SVG 源文件。 + 3. 已有猫档明确写了”Maine Coon原生图片生成强、禁止用 SVG 画”,但能力唤醒没有把这条转成 preflight。 +- 触发条件:复杂架构图、PPT 页面、企业信息图、华为风 / 红白黑风格、已有低保真蓝图但用户要求”精美图 / 终稿 / 图片”,且没有明确要求可编辑源文件。 +- 修复:已在 `cat-cafe-skills/image-generation/SKILL.md` 增加”Codex SVG 复发熔断闸”,匹配上述场景时禁止先写 SVG/HTML/Canvas,必须先原生 imagegen 整页直出。 - 防护: 1. image-generation skill 的 preflight:复杂架构/PPT/精美图 + 无可编辑要求 = imagegen-first。 2. SVG/HTML 降级必须写出 `SVG override reason`,且只能基于已失败的 imagegen 产物或用户显式可编辑要求。 3. “中文文字更可控 / 布局更可控 / 架构图需要精确 / 先 SVG 再转 PNG”都不是合格 override 理由。 - 来源锚点: - `cat-cafe-skills/image-generation/SKILL.md`(Codex SVG 复发熔断闸) - - `docs/team/cat-dossier.md#L122`(Maine Coon原生图片生成能力与“禁止用 SVG 画”事故记录) + - `docs/team/cat-dossier.md#L122`(Maine Coon原生图片生成能力与”禁止用 SVG 画”事故记录) - 2026-05-28 LLE 自进化平台三张图生成事故复盘 - 原理:**能力唤醒必须落到执行前硬闸。** “知道自己应该 imagegen”不等于会在任务压力下选择 imagegen;对复发型坏直觉,要把建议升级成 preflight + override reason。 @@ -1254,6 +1326,32 @@ created: 2026-02-26 --- +### LL-058b: 热补丁反模式——已有治理机制不查就另起炉灶 +- 状态:draft +- 更新时间:2026-04-30 + +- 坑:skill symlinks 指向旧版本,直接手动批量重链接 HOME 目录 + 改 `sync-skills.sh`,没有先分析已有的 worktree skill、`sync-skills.sh`、`check-skills-mount.sh`、`skill-sync.ts` 四层治理链条。同一天在 #598 verdict scope boundary 上也犯了类似错误——没分析 thread 根因、没和 reviewer 沟通就直接改了三个 skill 文件。 +- 根因: + 1. **对已有机制缺乏检索习惯**:问题出现后直接跳到”怎么修”,没有先问”已有什么机制在处理这个”。 + 2. **把”能跑”等同于”方案正确”**:手动 symlink 确实能让当前 session 读到新 skill,但绕过了 worktree 创建时的自动同步链条,制造了新的不一致。 + 3. **执行速度 > 方向正确**(LL-009 复发):铲屎官两次叫停,说明节奏判断失误不是个案。 +- 触发条件:发现”状态不对”时的冲动修复——尤其是 symlink/config/环境变量类问题,手动修一个很快,但会绕过治理链。 +- 修复:回退 `sync-skills.sh` 热补丁(`97ef3c2d`),改为先做根因分析 → 盘点已有机制 → 和 codex 对齐方案 → 开 worktree 走正常合入流程。 +- 防护: + 1. 发现问题后第一步:`grep -r` / `git log` 搜已有机制(脚本、治理服务、skill 步骤) + 2. 盘点完才提方案,方案必须说明”在已有机制的哪一层修” + 3. 改动走 worktree → review → merge-gate,不直接在 develop_base 上热补丁 +- 来源锚点: + - `scripts/sync-skills.sh` | `scripts/check-skills-mount.sh` | `cat-cafe-skills/worktree/SKILL.md` | `packages/api/src/config/governance/skill-sync.ts` + - commit:`482b6f27`(sync-skills.sh 热补丁)→ commit:`97ef3c2d`(revert) + - commit:`82972f45` / `453a4b54`(#598 verdict scope boundary 规则修正) + - thread:`thread_moicgl47en8m98do`(铲屎官两次叫停的原始对话) +- 原理:**治理机制存在的意义是把”正确做法”编码成自动化。绕过它 = 把一次性修复变成永久的手动负担,还会让治理机制逐渐失效(因为大家习惯绕过)。** + +- 关联:LL-009 | LL-020 | `cat-cafe-skills/refs/shared-rules.md` §P3 + +--- + ## 8) 维护约定 - 本文件是入口,不替代 ADR/bug-report 原文。 From d0db9d2d42a608db746ea4d0deeba45aa83d4c9f Mon Sep 17 00:00:00 2001 From: "Ragdoll-Opus-4.6" Date: Mon, 8 Jun 2026 14:15:36 +0800 Subject: [PATCH 2/7] =?UTF-8?q?fix(skills):=20harden=20skill=20gates=20?= =?UTF-8?q?=E2=80=94=20prevent=20repeated=20rework=20failure=20mode?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Squashed 5 skill gate hardening commits (codex R1-R3 review fixes): - quality-gate: add Patch Counter + upstream ref validation - receive-review: refine trigger conditions - request-review: gate hardening - writing-plans: plan validation gate - refs/review-request-template: add review template Co-Authored-By: Claude Opus 4.6 --- cat-cafe-skills/quality-gate/SKILL.md | 22 +++++++++ cat-cafe-skills/receive-review/SKILL.md | 20 +++++++- .../refs/review-request-template.md | 14 ++++++ cat-cafe-skills/request-review/SKILL.md | 4 ++ cat-cafe-skills/writing-plans/SKILL.md | 46 +++++++++++++++++++ 5 files changed, 104 insertions(+), 2 deletions(-) diff --git a/cat-cafe-skills/quality-gate/SKILL.md b/cat-cafe-skills/quality-gate/SKILL.md index 63d006e004..b6153c4522 100644 --- a/cat-cafe-skills/quality-gate/SKILL.md +++ b/cat-cafe-skills/quality-gate/SKILL.md @@ -61,6 +61,28 @@ Step 2: CREATE — 建检查清单 - 列出每一个 AC / 功能点 / 边界条件 - 列出 Discussion 里的 UX 描述和场景 +Step 2.4: PATCH COUNTER GATE(反复返工硬闸)🔴 + - **Step A — 列出候选 commits**: + ``` + UPSTREAM_REF="$(git rev-parse --abbrev-ref --symbolic-full-name '@{upstream}' 2>/dev/null || echo origin/main)" + BASE_REF="$(git merge-base "$UPSTREAM_REF" HEAD)" + git log --oneline "$BASE_REF..HEAD" -- + ``` + 自动取当前分支的 upstream tracking ref,无 tracking 时 fallback `origin/main`。develop_base 流程中会自动解析为 `origin/develop_base`。 + - **Step B — 人工分类**:逐条标记每个 commit 为以下之一: + - ✅ **同一 bug/AC 返工**:修上次没修好的同一个问题、用户报告同一问题后的重复修复、同一区域反复修补 + - ⬚ **正常迭代**(不计入):reviewer 正常 P1/P2 修复(`fix: address review P2-xxx`)、新发现的不同问题的修复 + - ⬚ **排除**(不计入):纯 lint/format/typo(`chore:` / `style:`)、rebase 冲突解决 + - 判断标准:问自己——"这个 fix 是在修一个**新发现的问题**,还是在修**上次没修好的同一个问题**?"后者才标 ✅ + - **Step C — 硬闸判定**:标 ✅ 的 ≥3 个 → **GATE FAIL** + - FAIL 时必须: + ① 停止继续修补 + ② 重读 spec 原文 + 铲屎官原始需求 + ③ 产出完整的真相源矩阵(格式同 writing-plans 的 Truth-Source Model Gate) + ④ 写清"为什么需要这么多 fix"的根因分析 + ⑤ 根因分析通过后才能继续 + - > **根因(2026-06-05 反思 + LL-020)**:F719 分支前 25 个提交大量 fix/refactor/test-fix,远超 LL-020 的"N > 3 换方向"告警线。补丁数量是方向信号——N > 3 不是"还需要更多补丁"的信号,而是"理解不完整,需要停下来重新建模"的信号。 + Step 2.5: CLOSE GATE MATRIX + FOLLOW-UP TAIL SCAN(F177 Phase A)🔴 - 检查 CloseGateReport 是否已生成(schema: `cat-cafe-skills/refs/close-gate.md`) - 每个 unmet AC 是否三选一处置(immediate / delete / cvo_signoff) diff --git a/cat-cafe-skills/receive-review/SKILL.md b/cat-cafe-skills/receive-review/SKILL.md index 93f8bedd1b..d6332fc09a 100644 --- a/cat-cafe-skills/receive-review/SKILL.md +++ b/cat-cafe-skills/receive-review/SKILL.md @@ -44,15 +44,31 @@ triggers: ## 核心知识 -### 两类反馈,处理方式不同 +### 三类反馈,处理方式不同 | 类型 | 特征 | 处理 | |------|------|------| | **代码级** | bug / edge case / 性能 / 命名 | Red→Green 修复流程 | +| **数据流级** | "真相源读错了" / "状态不一致" / "全局改了项目没级联" / "UI 和 API 对不上" | STOP → 重建真相源模型 → 再修(见下方) | | **愿景级** | "这不是operator要的" / "缺了多项目管理" / "UI 不可用" | STOP → 回读原始需求 → 升级operator | > **愿景级反馈不能用代码 patch 修补设计问题。** 先对照operator experience验证 reviewer 说得对吗;如确实偏离,升级operator确认偏差范围,再重新设计。 +### 数据流级反馈处理(反补锅匠门禁)🔴 + +当 reviewer 或用户指出的问题涉及**真相源/状态/级联/一致性**时,**禁止直接按点修**。必须: + +1. **STOP** — 不写一行代码 +2. **重建真相源模型** — 产出真相源矩阵(格式同 `writing-plans` 的 Truth-Source Model Gate),明确谁写谁读谁派生 +3. **定位根因** — 当前 bug 是哪个环节的读/写/级联断了?用矩阵定位,不用直觉猜 +4. **Blast radius 评估** — 修这个环节会影响哪些消费方?列出来,每个都需要验证 +5. **补 regression guard** — 对每个受影响的消费方,先写测试保护现有正确行为,再改代码 +6. **修复** — 在 regression guard 保护下修根因,不是修症状 + +**判别标准**:如果你的修复方案是"在前端读另一个字段 / 加一个 workaround / 在 UI 层补逻辑"——这大概率是在修症状。问自己:"后端/数据层的真相源对不对?"如果不对,先修数据层。 + +> **根因(2026-06-05 反思)**:布偶猫 F719 中 toggle 不更新时,先做了前端读 `mountedCount` 的 workaround 而不是修 API 层的 `cap.enabled` 写入。这是"收到反馈后仍按点修"的典型失败模式。数据流级问题的修法必须回到数据层,不能在展示层打补丁。 + ### 禁止的响应(表演性同意) ``` @@ -93,7 +109,7 @@ review 报告中必须包含 fallback 层数分析结果。 WHEN 收到 review 反馈: 1. READ — 完整读完,不要边读边反应。**R2+ 时额外动作**:回看上轮 finding 列表,标注每个 finding 的 failure-mode 类型,用于 AUDIT 步骤的同型判别 -2. CLASSIFY — 区分愿景级 vs 代码级;按 P1/P2/P3 分优先级 +2. CLASSIFY — 区分代码级 / 数据流级 / 愿景级(见上方三类反馈表);按 P1/P2/P3 分优先级。数据流级 = 涉及真相源/状态/级联/一致性 → 走数据流级处理流程 3. CLARIFY — 有不清晰的问题先全部问清,再动手 4. VERIFY — reviewer 说的问题真的存在吗?(见下方三道门) 5. AUDIT — failure-mode sweep(见下方 §16e 判别) diff --git a/cat-cafe-skills/refs/review-request-template.md b/cat-cafe-skills/refs/review-request-template.md index 44df643a79..9e914cfdbf 100644 --- a/cat-cafe-skills/refs/review-request-template.md +++ b/cat-cafe-skills/refs/review-request-template.md @@ -35,10 +35,22 @@ Why: {一句话} - 是否新建了并行 `Store` / `Queue` / `Router` / `Adapter` / `Dispatcher` / `Binding` - 若修改 `docs/architecture/ownership/cells/*.md`,是否确实改变了 owner / boundary / extension point / canonical anchor +## Invariant Matrix(涉及跨层状态同步/级联时必填) + +| 不变量 | 断言描述 | 验证方式 | +|--------|---------|---------| +| INV-1 | {描述} | {测试/手动验证} | + +## E2E User Path Evidence(涉及用户可感知功能时必填) + + +{端到端路径 + 命令/截图} + ## Open Questions ### 技术 OQ(给 reviewer) {需要 reviewer 特别关注的实现正确性/安全性/性能问题} +请 reviewer 逐条验证 Invariant Matrix 中的不变量是否被代码保持。 ### 价值 OQ(给 operator,如有) {需要 operator 判断的价值取舍——必须附 Decision Packet(格式见 `refs/decision-matrix.md`)} @@ -76,6 +88,8 @@ pnpm -r --if-present run build # 成功 - **附原始需求摘录(≤5 行)**,否则 reviewer 不审 - **附 Architecture Ownership 三字段**,否则 reviewer 不审 +- **涉及跨层状态同步/级联时附 Invariant Matrix**,reviewer 有权要求补上后再继续 +- **涉及用户可感知功能时附 E2E User Path Evidence**(引用 quality-gate Dogfood 输出) - 自检报告必须附上(从 quality-gate skill 输出) - 前端功能附截图证据 - 前端 review 需要起 dev 时,**必须附 review 沙盒 Path + Start Command + 实际端口** diff --git a/cat-cafe-skills/request-review/SKILL.md b/cat-cafe-skills/request-review/SKILL.md index 03c2e656b3..dfaf59e3d2 100644 --- a/cat-cafe-skills/request-review/SKILL.md +++ b/cat-cafe-skills/request-review/SKILL.md @@ -81,9 +81,13 @@ BEFORE 发 review 请求: 关键字段提醒: - **Original Requirements**: 必填,≤5 行operator experience + 来源文档路径,并明确请 reviewer 对照判断 - **Architecture Ownership**: 必填,列 `Architecture cell` / `Map delta` / `Why`,并请 reviewer 检查 diff 是否与 `Map delta` 一致 +- **Invariant Matrix**(涉及跨层状态同步/级联时必填;简单 CRUD 可写"不适用")🔴: 列出核心不变量 + 真相源读写关系(格式同 `writing-plans` 的 Truth-Source Model Gate)。reviewer 必须逐条验证 invariant 是否被代码保持。缺少 invariant matrix 时,reviewer 有权要求补上后再继续 review +- **E2E User Path Evidence**(涉及用户可感知功能时必填)🔴: 引用 quality-gate Step 4.5 Dogfood-Your-Slice 的输出即可(不需要重新跑)。reviewer 可据此判断 blast radius。若 quality-gate 中该项为"可豁免",此处同样豁免 - **Open Questions**: 分为两类——**技术 OQ**(给 reviewer 的,如实现正确性)和 **价值 OQ**(需要 operator 判断的,附 Decision Packet——格式见 `refs/decision-matrix.md`)。不混在一起 - **自检证据**: 附 quality-gate report 摘要 + 测试命令输出 + 根目录工件闸门输出 +> **根因(2026-06-05 反思)**:F719 review 中缅因猫发现的 P1 都指向同一类问题——global/project state 语义混淆、guard 读错真相源。如果 review 请求附了 invariant matrix,reviewer 能直接验证"代码是否保持了这些不变量",而不是在代码里逐行找状态不一致。E2E 用户路径证据则防止"测试通过但用户体验不通"的盲区。 + **F191 reviewer 视角**: - PR 是否新建了并行 `Store` / `Queue` / `Router` / `Adapter` / `Dispatcher` / `Binding`? - `Map delta: none` 是否与 diff 一致? diff --git a/cat-cafe-skills/writing-plans/SKILL.md b/cat-cafe-skills/writing-plans/SKILL.md index 3c1fa6b0f7..eb9f553aaf 100644 --- a/cat-cafe-skills/writing-plans/SKILL.md +++ b/cat-cafe-skills/writing-plans/SKILL.md @@ -25,6 +25,52 @@ triggers: **Save plans to:** `feature-specs/YYYY-MM-DD-.md` +## Truth-Source Model Gate(复杂需求强制)🔴 + +**写不出完整数据流 = 还没理解 = 不许拆步骤。** + +对于涉及**跨层状态同步或级联更新**的需求(如:全局配置→项目配置→文件系统→UI 多层联动),动手拆步骤前**必须**先产出以下三张表。简单的 CRUD 读写(API 读 DB 返回 JSON)不触发此门禁: + +### 表 1:真相源矩阵(谁写谁读谁派生) + +```markdown +| 数据 | 真相源(写) | 消费方(读) | 派生关系 | 级联规则 | +|------|-------------|-------------|---------|---------| +| 例:全局 skill 启禁用 | capabilities.json (全局) | 项目级 capabilities / UI | 项目继承全局 | 全局禁用 → 所有项目 unmount | +``` + +**每一行必须回答**:这个数据从哪来?谁负责写?谁读?读的人直接读真相源还是读派生?状态变化时谁级联通知谁? + +### 表 2:核心不变量(invariants) + +```markdown +- INV-1: 全局禁用的 skill,任何项目下都不可见且无 symlink +- INV-2: 项目配置的启禁用状态 = 实际 symlink 存在与否 +- INV-3: UI 展示的状态 = 后端 API 返回的状态(无前端 workaround) +``` + +**每条 invariant 必须可测试**——写不出对应的断言 = 不是 invariant,是愿望。 + +### 表 3:既有正确行为保护点 + +```markdown +| 现有功能 | 当前正确行为 | 保护方式 | +|---------|------------|---------| +| 项目选择器 | 能列出所有已知项目 | 现有测试 / 新增 regression guard | +``` + +**改代码前,先确认保护方式到位**(已有测试覆盖 or 需要先补测试)。没有保护 = 改了之后不知道有没有破坏。 + +### 何时豁免 + +- 单文件 ≤50 行改动、不涉及多数据源交互 → 可跳过 +- 纯 UI 样式调整、文档修改 → 可跳过 +- **涉及 config/state/filesystem 多层交互 → 不可跳过,写不出就停下来问清楚** + +> **根因(2026-06-05 反思)**:布偶猫 F719 Skill Lifecycle Management 反复返工的核心病因是"边做边理解"——抓关键词就动手,没花时间画完整数据流。铲屎官给了闭环方案但实现出来"长得像但数据流不通"。此门禁把"理解"从"实现"中分离:理解产物是三张表,不是代码。 + +--- + ## Straight-Line Check (A→B, No Detour) **Before splitting steps, do this first:** From e8f58470b9972ece92e3c5a8e267862a2d09617d Mon Sep 17 00:00:00 2001 From: "Ragdoll-Opus-4.6" Date: Fri, 12 Jun 2026 10:47:20 +0800 Subject: [PATCH 3/7] =?UTF-8?q?chore(fork):=20add=20cost=20discipline=20ru?= =?UTF-8?q?les=20=E2=80=94=20agent=20demotion=20+=20debug=20gate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Why: Usage analysis showed 93% cost from subagent-heavy sessions. Add §20 to shared-rules (agent demotion reflex, debug strategy gate, session cost awareness) and a Cost Discipline summary to CLAUDE.md. Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 6 ++++ cat-cafe-skills/refs/shared-rules.md | 41 +++++++++++++++++++++++++++- 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index c00e9d80ac..46d58bf6a0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -22,3 +22,9 @@ See `cat-cafe-skills/` for the full skill-based workflow: - No `any` types - Biome: `pnpm check` / `pnpm check:fix` - Types: `pnpm lint` + +## Cost Discipline +- **Agent demotion**: before spawning Agent/Explore/Workflow, ask "can a single grep/Read do this?" If yes → use Bash/Read directly +- **Model tiering**: simple subagent tasks (search, summarize, format) → `model: 'haiku'` +- **Debug strategy gate**: 1 round of static analysis with no root cause → switch to instrument → reproduce → observe. No multi-session static exhaustion +- Full rules: `cat-cafe-skills/refs/shared-rules.md` §20 diff --git a/cat-cafe-skills/refs/shared-rules.md b/cat-cafe-skills/refs/shared-rules.md index 04ad7a349f..e8ae1d90e7 100644 --- a/cat-cafe-skills/refs/shared-rules.md +++ b/cat-cafe-skills/refs/shared-rules.md @@ -777,7 +777,46 @@ beforeEach(() => { **违反代价**:用真身份发测试 payload 到operator thread / 其他猫 thread,看起来像 spam / cron job / 幻觉。已发出去的消息**不可撤回**。 -## 20. 根目录卫生公约(F214 — 根目录运行时残留治理) +## 20. Agent 降级与成本纪律(2026-06-11 usage 分析教训) + +> 来源:usage 分析发现 93% 成本来自 subagent-heavy sessions。Agent/Workflow 派发是高杠杆操作——每次调用 ≈ 新开一个完整上下文窗口。 + +### 20a. Agent 降级反射(派发前必问) + +派 Agent / Explore / Workflow **前**,先问: + +1. **一个 grep/Read 能做吗?** → 能 → 直接 `grep -rn` / `Read` / `Bash`,不派 agent +2. **需要并行或隔离上下文吗?** → 不需要 → 直接做,不开 Workflow +3. **这个 subagent 需要 Opus 吗?** → 简单搜索/摘要/格式化 → 用 `model: 'haiku'` + +**典型降级场景**(直接用工具,不派 agent): +- 找函数定义 → `grep -rn "functionName" .` +- 找文件 → `find . -name "pattern"` +- 看某个符号的引用 → `grep -rn "symbolName" packages/` +- 读已知文件的某段 → `Read` tool + +**Explore agent 保留场景**(合理使用): +- 搜索目标未知,需要跨多目录多命名探索 +- `very thorough` 广度搜索,确实不知道在哪 + +### 20b. 调试策略切换门禁 + +调试(定位 bug / 理解行为)时: +- **静态分析 1 轮未定位** → 切换到"加诊断 → 复现 → 看数据"策略 +- **禁止**在同一方向穷举 2+ 轮 session 的静态分析(token 消耗巨大,收益递减) +- **观察优先于推理**:运行时日志 / 诊断 / 断点 > 反复读同一批文件推理 + +来源:消息消失 bug 调查——2 轮 session 穷举静态分析未定位,如果一开始加诊断 + 复现可能 30 分钟就定位。 + +### 20c. 长 session 成本意识 + +- `/loop` 和 `/schedule` 必须有明确退出条件,不做无限轮询 +- 调查类任务设心理 token budget——到预算 → 切换策略或求助,不在同方向死磕 +- Workflow 使用 `budget.remaining()` 做提前终止 + +--- + +## 21. 根目录卫生公约(F214 — 根目录运行时残留治理) > 来源:F214(2026-05-28)。ADR-010 / F023 管子目录代码 + docs 归档,不管根目录运行时残留——本节是 ADR-010 在根目录维度的补丁。 From 7b4f189cb32cc4c5acdb7bd8cef8bb86a7e34c87 Mon Sep 17 00:00:00 2001 From: "Ragdoll-Opus-4.6" Date: Fri, 12 Jun 2026 10:47:44 +0800 Subject: [PATCH 4/7] diag: message disappearance instrumentation [DIAG:msg-disappear] Why: Static analysis across 2 sessions could not locate the root cause of codex messages disappearing during A2A opus execution. These 4 diagnostic points log codex message counts at critical junctions (API response, done handler, hydration pipeline, store mutations) to capture the failure at runtime. Activate by rebuilding frontend + API. Remove after root cause found. Co-Authored-By: Claude Opus 4.6 --- packages/api/src/routes/messages.ts | 24 ++++++++++++++++ packages/web/src/hooks/useAgentMessages.ts | 16 +++++++++++ packages/web/src/hooks/useChatHistory.ts | 23 +++++++++++++++ packages/web/src/stores/chatStore.ts | 33 ++++++++++++++++++++++ 4 files changed, 96 insertions(+) diff --git a/packages/api/src/routes/messages.ts b/packages/api/src/routes/messages.ts index 3a78cd685c..806de0b27a 100644 --- a/packages/api/src/routes/messages.ts +++ b/packages/api/src/routes/messages.ts @@ -1789,6 +1789,30 @@ export const messagesRoutes: FastifyPluginAsync = async ( const hasMore = allVisible.length > limit || !storeExhausted; const page = allVisible.length > limit ? allVisible.slice(allVisible.length - limit) : allVisible; + // [DIAG:msg-disappear] Log codex messages for disappearance bug investigation + { + const codexMsgs = page.filter((m) => m.catId === 'codex'); + if (codexMsgs.length > 0 || resolvedThreadId.includes('mq5zrg04n1votqb9')) { + const hasDraftStore = Boolean(opts.draftStore); + const drafts = hasDraftStore ? await opts.draftStore!.getByThread(userId, resolvedThreadId) : []; + request.log.info( + { + diag: 'msg-disappear', + threadId: resolvedThreadId, + totalFetched: messages.length, + pageSize: page.length, + hasMore, + codexCount: codexMsgs.length, + codexIds: codexMsgs.map((m) => m.id), + draftCount: drafts.length, + draftCats: drafts.map((d) => d.catId), + allCatIds: [...new Set(page.map((m) => m.catId).filter(Boolean))], + }, + '[DIAG:msg-disappear] /api/messages response', + ); + } + } + // Map chat messages (union type allows summary items to be pushed later) type TimelineItem = { id: string; diff --git a/packages/web/src/hooks/useAgentMessages.ts b/packages/web/src/hooks/useAgentMessages.ts index bb576e31bf..332831c51f 100644 --- a/packages/web/src/hooks/useAgentMessages.ts +++ b/packages/web/src/hooks/useAgentMessages.ts @@ -4770,6 +4770,22 @@ export function useAgentMessages() { currentMessages: storeSnapshot.messages, }); if (result.recoveryAction === 'none') { + // [DIAG:msg-disappear] Trace done handler replaceMessages + const codexBefore = storeSnapshot.messages.filter((m: { catId?: string | null }) => m.catId === 'codex').length; + const codexAfter = result.nextMessages.filter((m: { catId?: string | null }) => m.catId === 'codex').length; + if (codexBefore !== codexAfter || msg.catId === 'codex') { + console.warn('[DIAG:msg-disappear] done handler replaceMessages', { + doneCatId: msg.catId, + isFinal: msg.isFinal, + invocationId: msg.invocationId, + codexBefore, + codexAfter, + totalBefore: storeSnapshot.messages.length, + totalAfter: result.nextMessages.length, + recoveryAction: result.recoveryAction, + violationCount: result.violations.length, + }); + } storeSnapshot.replaceMessages(result.nextMessages, storeSnapshot.hasMore); } if (result.violations.length > 0) { diff --git a/packages/web/src/hooks/useChatHistory.ts b/packages/web/src/hooks/useChatHistory.ts index 7ac1e8ba68..abbc2bcfca 100644 --- a/packages/web/src/hooks/useChatHistory.ts +++ b/packages/web/src/hooks/useChatHistory.ts @@ -1002,6 +1002,29 @@ export function useChatHistory(threadId: string) { // (applyBubbleEventWithRecovery)。raw records 进 store 前 collapse // 到 1 bubble per (catId, invocationId),确保 hydrate ≡ live。 const projectedMerged = projectCanonicalBubbles({ records: mergedMsgs }).messages; + // [DIAG:msg-disappear] Trace codex message through hydration pipeline + if (fetchForThread.includes('mq5zrg04n1votqb9') || mergedMsgs.some((m: { catId?: string | null }) => m.catId === 'codex')) { + const codexInHistory = historyMsgs.filter((m: { catId?: string | null }) => m.catId === 'codex').length; + const codexInMerged = mergedMsgs.filter((m: { catId?: string | null }) => m.catId === 'codex').length; + const codexInProjected = projectedMerged.filter((m: { catId?: string | null }) => m.catId === 'codex').length; + const codexIds = { + history: historyMsgs.filter((m: { catId?: string | null }) => m.catId === 'codex').map((m: { id: string }) => m.id), + merged: mergedMsgs.filter((m: { catId?: string | null }) => m.catId === 'codex').map((m: { id: string }) => m.id), + projected: projectedMerged.filter((m: { catId?: string | null }) => m.catId === 'codex').map((m: { id: string }) => m.id), + }; + console.warn('[DIAG:msg-disappear] replace hydration pipeline', { + threadId: fetchForThread, + historyCount: historyMsgs.length, + currentCount: currentState.messages.length, + mergedCount: mergedMsgs.length, + projectedCount: projectedMerged.length, + codexInHistory, + codexInMerged, + codexInProjected, + codexIds, + draftIds: historyMsgs.filter((m: { id: string }) => m.id.startsWith('draft-')).map((m: { id: string }) => m.id), + }); + } hydrateThread(fetchForThread, projectedMerged, data.hasMore ?? false); restoreActiveFromDrafts(fetchForThread, data.messages ?? []); return true; diff --git a/packages/web/src/stores/chatStore.ts b/packages/web/src/stores/chatStore.ts index 0074d336f0..893f7e3604 100644 --- a/packages/web/src/stores/chatStore.ts +++ b/packages/web/src/stores/chatStore.ts @@ -1711,6 +1711,22 @@ export const useChatStore = create((set, get) => ({ // throw on bypass-of-reducer mutations. No-op when strict is off — keeps // production hot path free of the O(n) scan. forwardStoreInvariantViolationsStrict(msgs, get().currentThreadId); + // [DIAG:msg-disappear] Trace codex messages through replaceMessages + { + const prevMsgs = get().messages; + const codexBefore = prevMsgs.filter((m: { catId?: string | null }) => m.catId === 'codex').length; + const codexAfter = msgs.filter((m: { catId?: string | null }) => m.catId === 'codex').length; + if (codexBefore > 0 && codexAfter === 0) { + console.warn('[DIAG:msg-disappear] replaceMessages DROPPED all codex messages!', { + codexBefore, + codexAfter, + totalBefore: prevMsgs.length, + totalAfter: msgs.length, + codexIdsBefore: prevMsgs.filter((m: { catId?: string | null }) => m.catId === 'codex').map((m: { id: string }) => m.id), + }); + console.trace('[DIAG:msg-disappear] replaceMessages caller stack'); + } + } set((state) => { revokeRemovedBlobUrls(state.messages, msgs); return { messages: msgs, hasMore }; @@ -1757,6 +1773,23 @@ export const useChatStore = create((set, get) => ({ // F183 Phase E AC-E2 (砚砚 R2 P1 fix): same strict-gate as the other // caller-driven writers. Runs only when strict mode is on. forwardStoreInvariantViolationsStrict(msgs, threadId); + // [DIAG:msg-disappear] Trace codex messages through hydrateThread + { + const prevMsgs = get().messages; + const codexBefore = prevMsgs.filter((m: { catId?: string | null }) => m.catId === 'codex').length; + const codexAfter = msgs.filter((m: { catId?: string | null }) => m.catId === 'codex').length; + if (codexBefore > 0 && codexAfter === 0) { + console.warn('[DIAG:msg-disappear] hydrateThread DROPPED all codex messages!', { + threadId, + codexBefore, + codexAfter, + totalBefore: prevMsgs.length, + totalAfter: msgs.length, + codexIdsBefore: prevMsgs.filter((m: { catId?: string | null }) => m.catId === 'codex').map((m: { id: string }) => m.id), + }); + console.trace('[DIAG:msg-disappear] hydrateThread caller stack'); + } + } set((state) => { if (threadId === state.currentThreadId) { revokeRemovedBlobUrls(state.messages, msgs); From 11a40eb9c320b029840fa0e2ac8e9238dfba2b20 Mon Sep 17 00:00:00 2001 From: lang <39692836+mindfn@users.noreply.github.com> Date: Wed, 17 Jun 2026 11:47:15 +0800 Subject: [PATCH 5/7] =?UTF-8?q?fix(F192):=20publish-verdict=20works=20in?= =?UTF-8?q?=20fork=20checkout=20=E2=80=94=20explicit=20gh=20--repo=20+=20n?= =?UTF-8?q?o=20overlapping-publish=20retries=20(#11)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(F192): publish-verdict works in fork checkout — explicit gh --repo + no overlapping-publish retries Two bugs blocked cat_cafe_publish_verdict in the fork checkout (砚砚 diagnosed 2026-06-17; the eval cats have been unable to publish any verdict for days): git-worktree-publisher.ts pushes the verdict branch to `origin` (mindfn/clowder-ai) but every `gh` call relied on cwd auto-detection. This checkout has BOTH `origin` (fork) AND `upstream` (zts212653/clowder-ai), and `gh` resolves the base repo to the upstream parent. So `gh pr create --head ` looked for the branch in zts212653 and failed with "Head sha can't be blank / No commits between main and branch / Head ref must be a branch". (Same failure I hit manually early in this feature and worked around with `--repo`.) Worse latent bug: the failure-cleanup `gh pr list` probe in `finally` also auto-detected upstream — it would see no PR for the pushed branch, decide `safeToDelete`, and `git push --delete origin `, orphaning a live PR's source branch. Fix: derive `owner/repo` from `git remote get-url origin` and pass an explicit `--repo ` to ALL gh invocations (label create, pr create, pr close, pr list). In a single-remote upstream checkout this is a no-op (origin IS the repo); in a fork it pins the correct target. `parseOwnerRepoFromGitRemoteUrl()` is exported + unit-tested across scp, ssh://, https, cred-embedded, and trailing-slash URL forms. The MCP callback layer uses a 10s per-attempt fetch timeout + [1s,2s,4s] retry. The publish route runs a synchronous git worktree + push + gh pr create (~17s typical). Result: the client aborts at 10s but the SERVER keeps running, and the 3 auto-retries each kick off a fresh server-side publish — 4 overlapping publishes racing on the same `verdict/auto//` branch (砚砚 saw 4 incoming /publish-verdict in the logs). The caller never gets the PR URL. Bumping the global env timeout is wrong (every callback would then wait that long on a hung socket). Fix: thread an optional per-call `fetchTimeoutMs` + `retryDelaysMs` override through callbackPost → sendCallbackRequest → postJsonWithRetry (all additive, backward-compat), and have publish-verdict-tool.ts pass `fetchTimeoutMs: 180_000` + `retryDelaysMs: []` (single attempt, no retry). Server-side idempotency guards (verdict_already_exists / branch-exists) remain the real safety net for "did it publish". - parseOwnerRepoFromGitRemoteUrl: all URL forms + defensive throws - postJsonWithRetry: retryDelaysMs=[] → exactly 1 attempt; fetchTimeoutMs override widens the bound; no-override still honors the env default - existing publisher cleanup tests + 30 publish-verdict pipeline tests still green; mcp-server callback suite 60/60 [宪宪/opus-4.8🐾] * fix(F192): derive origin repo before mkdtempSync — no temp-dir leak (砚砚 P3) 砚砚 review P3 on PR #11: the origin lookup (`git remote get-url origin` → parseOwnerRepoFromGitRemoteUrl) sat AFTER mkdtempSync but outside the try/finally. A missing/invalid `origin` would throw after the temp dir was created, leaking it (the finally cleanup is never entered). Pure reorder — move the fallible origin derivation ABOVE mkdtempSync so it throws before any side effect. Code now matches its own comment ("failing before any side effect is the safe outcome"). No logic change. Publisher tests still 4/4 green. [宪宪/opus-4.8🐾] --- .../publish-verdict/git-worktree-publisher.ts | 87 +++++++++++++++++-- .../git-worktree-publisher.test.js | 35 ++++++++ .../mcp-server/src/tools/callback-outbox.ts | 17 +++- .../mcp-server/src/tools/callback-tools.ts | 10 ++- .../src/tools/publish-verdict-tool.ts | 9 ++ .../mcp-server/test/callback-retry.test.js | 78 +++++++++++++++++ 6 files changed, 225 insertions(+), 11 deletions(-) diff --git a/packages/api/src/infrastructure/harness-eval/publish-verdict/git-worktree-publisher.ts b/packages/api/src/infrastructure/harness-eval/publish-verdict/git-worktree-publisher.ts index b6531e688c..c2726931da 100644 --- a/packages/api/src/infrastructure/harness-eval/publish-verdict/git-worktree-publisher.ts +++ b/packages/api/src/infrastructure/harness-eval/publish-verdict/git-worktree-publisher.ts @@ -28,9 +28,68 @@ export interface GitWorktreePublisherDeps { repoRoot: string; } +/** + * Parse `owner/repo` out of a git remote URL. + * + * 砚砚 2026-06-17 P1: the publisher pushes the verdict branch to `origin`, but + * every `gh` call previously relied on cwd auto-detection. In a fork checkout + * that has BOTH `origin` (the fork, e.g. mindfn/clowder-ai) AND `upstream` + * (e.g. zts212653/clowder-ai), `gh` resolves the base repo to the UPSTREAM + * parent — so `gh pr create --head ` looks for the branch in the wrong + * repo and fails with "Head sha can't be blank / Head ref must be a branch". + * Worse, the failure-cleanup `gh pr list` probe would query upstream, see no PR + * for the pushed branch, decide `safeToDelete`, and delete the origin branch a + * live PR depends on. + * + * Deriving owner/repo from `origin` and passing `--repo ` to every + * `gh` invocation makes the target explicit. In a single-remote upstream + * checkout this is a no-op (origin IS the repo); in a fork it fixes resolution. + * + * Handles the URL forms `git`/`gh` actually emit: + * - scp-like SSH: git@github.com:owner/repo(.git) + * - ssh://: ssh://git@github.com/owner/repo(.git) + * - https://: https://github.com/owner/repo(.git) + * - https w/ cred: https://user@github.com/owner/repo(.git) + */ +export function parseOwnerRepoFromGitRemoteUrl(remoteUrl: string): string { + const url = remoteUrl.trim(); + if (!url) throw new Error('cannot derive owner/repo: empty git remote url'); + + let path: string; + const scpMatch = url.match(/^[^/]+@[^/:]+:(.+)$/); + if (scpMatch && !url.includes('://')) { + // scp-like SSH: git@host:owner/repo + path = scpMatch[1] ?? ''; + } else { + // url form (ssh:// or https://) — strip scheme + host, keep the path + const afterScheme = url.replace(/^[a-z]+:\/\//i, ''); + const firstSlash = afterScheme.indexOf('/'); + path = firstSlash === -1 ? '' : afterScheme.slice(firstSlash + 1); + } + + const ownerRepo = path.replace(/\.git$/i, '').replace(/^\/+|\/+$/g, ''); + const segments = ownerRepo.split('/').filter(Boolean); + if (segments.length < 2) { + throw new Error(`cannot derive owner/repo from git remote url: ${remoteUrl}`); + } + // owner/repo are the LAST two segments (defensive against nested self-hosted paths). + return `${segments[segments.length - 2]}/${segments[segments.length - 1]}`; +} + export function createGitWorktreePublisher(deps: GitWorktreePublisherDeps): GitPublisher { return { async publishOnIsolatedWorktree(opts: PublishOnIsolatedWorktreeOpts) { + // 砚砚 2026-06-17 P1: derive the explicit gh target from `origin` (where we + // push the branch) FIRST — before any side effect — so the failure-cleanup + // `gh pr list` probe in `finally` targets the right repo. Doing this before + // mkdtempSync means a missing/invalid `origin` throws with NO temp dir + // created (砚砚 P3: the try/finally cleanup hasn't been entered yet, so an + // origin-lookup failure can't leak a temp worktree dir). + const originUrlResult = await exec('git', ['-C', deps.repoRoot, 'remote', 'get-url', 'origin'], { + timeout: 10_000, + }); + const originRepo = parseOwnerRepoFromGitRemoteUrl(originUrlResult.stdout.trim()); + // Use mkdtemp to get a guaranteed-unique path; suffix with PID for debuggability const worktreePath = mkdtempSync(`${tmpdir()}/cat-cafe-publish-verdict-${process.pid}-`); @@ -95,9 +154,10 @@ export function createGitWorktreePublisher(deps: GitWorktreePublisherDeps): GitP const commitSha = shaResult.stdout.trim(); // 7. Open auto-PR via gh. - // 砚砚 R4 P1 cloud: `--repo .` is NOT valid gh syntax (fails with - // 'expected the "[HOST/]OWNER/REPO" format'). Rely on cwd inside the - // worktree — gh auto-detects owner/repo from the git remote. + // 砚砚 R4 P1 cloud: `--repo .` is NOT valid gh syntax. 砚砚 2026-06-17 P1: + // cwd auto-detection picks the UPSTREAM parent in a fork checkout (origin + // + upstream remotes), so we pass the explicit `--repo ` + // derived above. This matches where `git push -u origin` put the branch. // // PR-3 (砚砚 R2): pass each label via separate `--label` flag (gh CLI accepts // repeated --label X; not comma-separated). `computePublishPolicy` decides @@ -120,7 +180,7 @@ export function createGitWorktreePublisher(deps: GitWorktreePublisherDeps): GitP }; for (const label of labels ?? []) { const meta = standardLabelMeta[label]; - const args = ['label', 'create', label, '--force']; + const args = ['label', 'create', label, '--repo', originRepo, '--force']; if (meta) { args.push('--color', meta.color, '--description', meta.description); } @@ -138,6 +198,8 @@ export function createGitWorktreePublisher(deps: GitWorktreePublisherDeps): GitP [ 'pr', 'create', + '--repo', + originRepo, '--base', 'main', '--head', @@ -168,6 +230,8 @@ export function createGitWorktreePublisher(deps: GitWorktreePublisherDeps): GitP 'pr', 'close', prUrl, + '--repo', + originRepo, '--delete-branch', '--comment', 'Closing stale auto-verdict PR because post-publish writeback failed.', @@ -225,7 +289,20 @@ export function createGitWorktreePublisher(deps: GitWorktreePublisherDeps): GitP try { const probe = await exec( 'gh', - ['pr', 'list', '--head', opts.branchName, '--state', 'open', '--json', 'state', '--limit', '1'], + [ + 'pr', + 'list', + '--repo', + originRepo, + '--head', + opts.branchName, + '--state', + 'open', + '--json', + 'state', + '--limit', + '1', + ], { cwd: deps.repoRoot, timeout: 30_000 }, ); const parsed = JSON.parse(probe.stdout) as Array<{ state?: string }>; diff --git a/packages/api/test/harness-eval/git-worktree-publisher.test.js b/packages/api/test/harness-eval/git-worktree-publisher.test.js index 1a595d54ec..cbbf66289b 100644 --- a/packages/api/test/harness-eval/git-worktree-publisher.test.js +++ b/packages/api/test/harness-eval/git-worktree-publisher.test.js @@ -38,6 +38,41 @@ afterEach(() => { syncBuiltinESMExports(); }); +describe('parseOwnerRepoFromGitRemoteUrl', () => { + it('parses every git/gh remote URL form to owner/repo (砚砚 2026-06-17 P1)', async () => { + const { parseOwnerRepoFromGitRemoteUrl } = await import( + '../../dist/infrastructure/harness-eval/publish-verdict/git-worktree-publisher.js' + ); + const cases = [ + // scp-like SSH (what `git remote get-url origin` returns for github SSH) + ['git@github.com:mindfn/clowder-ai.git', 'mindfn/clowder-ai'], + ['git@github.com:mindfn/clowder-ai', 'mindfn/clowder-ai'], + // ssh:// form + ['ssh://git@github.com/zts212653/clowder-ai.git', 'zts212653/clowder-ai'], + // https forms (with and without .git, with and without creds) + ['https://github.com/mindfn/clowder-ai.git', 'mindfn/clowder-ai'], + ['https://github.com/mindfn/clowder-ai', 'mindfn/clowder-ai'], + ['https://x-access-token:ghp_abc@github.com/mindfn/clowder-ai.git', 'mindfn/clowder-ai'], + // trailing slash tolerance + ['https://github.com/mindfn/clowder-ai/', 'mindfn/clowder-ai'], + // whitespace (stdout trim defense-in-depth) + [' git@github.com:mindfn/clowder-ai.git\n', 'mindfn/clowder-ai'], + ]; + for (const [input, expected] of cases) { + assert.equal(parseOwnerRepoFromGitRemoteUrl(input), expected, `failed for input: ${JSON.stringify(input)}`); + } + }); + + it('throws on URLs that have no owner/repo (defensive)', async () => { + const { parseOwnerRepoFromGitRemoteUrl } = await import( + '../../dist/infrastructure/harness-eval/publish-verdict/git-worktree-publisher.js' + ); + assert.throws(() => parseOwnerRepoFromGitRemoteUrl(''), /empty git remote url/); + assert.throws(() => parseOwnerRepoFromGitRemoteUrl('git@github.com:justrepo'), /cannot derive owner\/repo/); + assert.throws(() => parseOwnerRepoFromGitRemoteUrl('https://github.com/onlyowner'), /cannot derive owner\/repo/); + }); +}); + describe('createGitWorktreePublisher', () => { it('cleans up a partially-created local branch when worktree add fails before stage', async (t) => { const { repoRoot, remoteRoot } = createRepoWithOrigin(); diff --git a/packages/mcp-server/src/tools/callback-outbox.ts b/packages/mcp-server/src/tools/callback-outbox.ts index a0b79a1173..5378cf1835 100644 --- a/packages/mcp-server/src/tools/callback-outbox.ts +++ b/packages/mcp-server/src/tools/callback-outbox.ts @@ -176,11 +176,22 @@ export async function sendCallbackRequest( const enableOutbox = options?.enableOutbox === true && isOutboxEnabled(); if (enableOutbox) await flushOutbox(); + // 砚砚 2026-06-17 P1: allow per-call retry-delays + timeout override. Publish- + // verdict passes `[]` (single attempt, no retry) because the route is long + + // side-effectful: auto-retry fires overlapping server-side publishes that race + // on the same branch name. Idempotency guards (verdict_already_exists) are the + // safety net for "did it publish", not client retries. const retryDelaysMs = options?.retryDelaysMs ?? getRetryDelaysMs(); const payload = JSON.stringify(request.body); - const result = await postJsonWithRetry(`${request.apiUrl}${request.path}`, payload, retryDelaysMs, request.headers, { - fetchTimeoutMs: options?.fetchTimeoutMs, - }); + const result = await postJsonWithRetry( + `${request.apiUrl}${request.path}`, + payload, + retryDelaysMs, + request.headers, + { + fetchTimeoutMs: options?.fetchTimeoutMs, + }, + ); if (result.ok) return { ok: true, data: result.data }; if (enableOutbox && result.failure.retryable) { diff --git a/packages/mcp-server/src/tools/callback-tools.ts b/packages/mcp-server/src/tools/callback-tools.ts index 781802f731..1c1549cf1d 100644 --- a/packages/mcp-server/src/tools/callback-tools.ts +++ b/packages/mcp-server/src/tools/callback-tools.ts @@ -179,8 +179,12 @@ export async function callbackPost( enableOutbox?: boolean; agentKeyCatId?: string; forceAgentKey?: boolean; - retryDelaysMs?: number[]; + // 砚砚 2026-06-17 P1: per-call overrides for long, side-effectful routes + // (cat_cafe_publish_verdict). fetchTimeoutMs widens the per-attempt abort + // bound; retryDelaysMs=[] disables auto-retry so the route is not POSTed + // concurrently (overlapping publishes race on the same branch). fetchTimeoutMs?: number; + retryDelaysMs?: number[]; }, ): Promise { const config = getCallbackConfig({ @@ -198,8 +202,8 @@ export async function callbackPost( }, { enableOutbox: options?.enableOutbox === true, - retryDelaysMs: options?.retryDelaysMs, - fetchTimeoutMs: options?.fetchTimeoutMs, + ...(options?.fetchTimeoutMs !== undefined ? { fetchTimeoutMs: options.fetchTimeoutMs } : {}), + ...(options?.retryDelaysMs !== undefined ? { retryDelaysMs: options.retryDelaysMs } : {}), }, ); if (result.ok) return successResult(JSON.stringify(result.data)); diff --git a/packages/mcp-server/src/tools/publish-verdict-tool.ts b/packages/mcp-server/src/tools/publish-verdict-tool.ts index 7356f04beb..e5b8745fe9 100644 --- a/packages/mcp-server/src/tools/publish-verdict-tool.ts +++ b/packages/mcp-server/src/tools/publish-verdict-tool.ts @@ -326,6 +326,15 @@ type PublishVerdictToolInput = { agentKeyCatId?: string | undefined; }; +// 砚砚 2026-06-17 P1: publish-verdict runs a synchronous git worktree + push + +// `gh pr create` on the server (~17s typical; the publisher's own step timeouts +// sum much higher). The default 10s-per-attempt + [1s,2s,4s] retry callback +// policy aborts before the route returns AND fires 4 overlapping server-side +// publishes that race on the same `verdict/auto//` branch. We give +// this one call a long single attempt with NO retry; idempotency guards on the +// server (verdict_already_exists / branch-exists) are the real safety net. +const PUBLISH_VERDICT_FETCH_TIMEOUT_MS = 180_000; + export async function handlePublishVerdict(input: PublishVerdictToolInput): Promise { return callbackPost( `/api/eval-domains/${encodeURIComponent(input.domainId)}/publish-verdict`, diff --git a/packages/mcp-server/test/callback-retry.test.js b/packages/mcp-server/test/callback-retry.test.js index 0d570d5aee..0e4034167b 100644 --- a/packages/mcp-server/test/callback-retry.test.js +++ b/packages/mcp-server/test/callback-retry.test.js @@ -87,3 +87,81 @@ describe('postJsonWithRetry — fetch timeout (hung socket)', () => { assert.equal(result.ok, true, 'a fast response must not be aborted by the timeout'); }); }); + +// 砚砚 2026-06-17 P1: per-call timeout + no-retry override for long, side-effectful +// routes (cat_cafe_publish_verdict). Without these, the publish route (~17s) is +// aborted at 10s AND retried 3× → 4 overlapping server-side publishes racing on +// the same branch. +describe('postJsonWithRetry — per-call override (publish-verdict)', () => { + let originalEnv; + let originalFetch; + + beforeEach(() => { + originalEnv = { ...process.env }; + originalFetch = globalThis.fetch; + }); + + afterEach(() => { + for (const key of Object.keys(process.env)) { + if (!(key in originalEnv)) delete process.env[key]; + } + Object.assign(process.env, originalEnv); + globalThis.fetch = originalFetch; + }); + + test('retryDelaysMs=[] makes exactly ONE attempt on a 5xx (no overlapping retries)', async () => { + const { postJsonWithRetry } = await import('../dist/tools/callback-retry.js'); + + let attemptCount = 0; + globalThis.fetch = async () => { + attemptCount += 1; + return { ok: false, status: 503, text: async () => 'busy', json: async () => ({}) }; + }; + + const result = await postJsonWithRetry('http://127.0.0.1:1/publish', '{}', []); + assert.equal(result.ok, false, '503 still surfaces as failure'); + assert.equal(attemptCount, 1, 'retryDelaysMs=[] must NOT retry — exactly one POST to a non-idempotent route'); + }); + + test('fetchTimeoutMs override widens the per-attempt bound beyond the env default', async () => { + // Global default forced tiny; the override must take precedence so a route + // that legitimately takes longer than the default is not falsely aborted. + process.env.CAT_CAFE_CALLBACK_FETCH_TIMEOUT_MS = '20'; + const { postJsonWithRetry } = await import('../dist/tools/callback-retry.js'); + + let observedTimeoutMs = null; + globalThis.fetch = (_url, opts) => + new Promise((resolve, reject) => { + // Capture the signal so we can prove the override (not the 20ms env) is in force. + const signal = opts?.signal; + if (signal) signal.addEventListener('abort', () => reject(signal.reason)); + // Resolve at 80ms — would be aborted under the 20ms env default, but the + // 5000ms override keeps it alive. + setTimeout(() => { + observedTimeoutMs = 5000; + resolve({ ok: true, json: async () => ({ status: 'ok' }) }); + }, 80); + }); + + const result = await postJsonWithRetry('http://127.0.0.1:1/publish', '{}', [], undefined, { + fetchTimeoutMs: 5000, + }); + assert.equal(result.ok, true, 'override (5000ms) must keep the 80ms response alive despite 20ms env default'); + assert.equal(observedTimeoutMs, 5000, 'response should have resolved under the override window'); + }); + + test('without override, the env default still aborts a slow fetch (override is opt-in)', async () => { + process.env.CAT_CAFE_CALLBACK_FETCH_TIMEOUT_MS = '20'; + const { postJsonWithRetry } = await import('../dist/tools/callback-retry.js'); + + globalThis.fetch = (_url, opts) => + new Promise((_resolve, reject) => { + const signal = opts?.signal; + if (signal) signal.addEventListener('abort', () => reject(signal.reason ?? new Error('aborted'))); + // never resolves on its own — only the 20ms timeout can settle it + }); + + const result = await postJsonWithRetry('http://127.0.0.1:1/x', '{}', []); + assert.equal(result.ok, false, 'no override → env default (20ms) still bounds the attempt'); + }); +}); From c2fed7501a0be92f50a1a13ab16cf7fd6df300f5 Mon Sep 17 00:00:00 2001 From: "MaineCoon-GPT-5.5" Date: Wed, 17 Jun 2026 14:38:50 +0800 Subject: [PATCH 6/7] chore(fork): align rebuild with latest main gates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Why: rebuilding develop_base onto latest upstream/main exposed stricter Biome checks in retained fork-only commits; keep the fork baseline green without changing behavior. Verification: pnpm biome check --diagnostic-level=error on retained-diff files; pnpm --filter @cat-cafe/api run build; pnpm --filter @cat-cafe/mcp-server run build; node --test packages/api/test/harness-eval/git-worktree-publisher.test.js packages/mcp-server/test/callback-retry.test.js. [砚砚/gpt-5.5🐾] --- packages/api/src/routes/messages.ts | 2 +- .../mcp-server/src/tools/callback-outbox.ts | 20 ++++++--------- .../mcp-server/src/tools/callback-retry.ts | 4 +-- .../src/tools/publish-verdict-tool.ts | 4 +-- packages/web/src/hooks/useAgentMessages.ts | 8 ++++-- packages/web/src/hooks/useChatHistory.ts | 25 ++++++++++++++----- packages/web/src/stores/chatStore.ts | 8 ++++-- 7 files changed, 42 insertions(+), 29 deletions(-) diff --git a/packages/api/src/routes/messages.ts b/packages/api/src/routes/messages.ts index 806de0b27a..cf54986148 100644 --- a/packages/api/src/routes/messages.ts +++ b/packages/api/src/routes/messages.ts @@ -1799,7 +1799,7 @@ export const messagesRoutes: FastifyPluginAsync = async ( { diag: 'msg-disappear', threadId: resolvedThreadId, - totalFetched: messages.length, + totalFetched: allVisible.length, pageSize: page.length, hasMore, codexCount: codexMsgs.length, diff --git a/packages/mcp-server/src/tools/callback-outbox.ts b/packages/mcp-server/src/tools/callback-outbox.ts index 5378cf1835..e37748a387 100644 --- a/packages/mcp-server/src/tools/callback-outbox.ts +++ b/packages/mcp-server/src/tools/callback-outbox.ts @@ -38,24 +38,24 @@ function parseIntEnv(raw: string | undefined): number | null { } function isOutboxEnabled(): boolean { - const raw = (process.env['CAT_CAFE_CALLBACK_OUTBOX_ENABLED'] ?? 'true').toLowerCase(); + const raw = (process.env.CAT_CAFE_CALLBACK_OUTBOX_ENABLED ?? 'true').toLowerCase(); return raw !== '0' && raw !== 'false' && raw !== 'off'; } function getOutboxDir(): string { - const fromEnv = process.env['CAT_CAFE_CALLBACK_OUTBOX_DIR']; + const fromEnv = process.env.CAT_CAFE_CALLBACK_OUTBOX_DIR; if (fromEnv && fromEnv.trim().length > 0) return fromEnv; return join(homedir(), '.cat-cafe', 'callback-outbox'); } function getOutboxMaxFlushBatch(): number { - const parsed = parseIntEnv(process.env['CAT_CAFE_CALLBACK_OUTBOX_MAX_FLUSH_BATCH']); + const parsed = parseIntEnv(process.env.CAT_CAFE_CALLBACK_OUTBOX_MAX_FLUSH_BATCH); if (parsed === null || parsed < 0) return DEFAULT_OUTBOX_MAX_FLUSH_BATCH; return parsed; } function getOutboxMaxAttempts(): number { - const parsed = parseIntEnv(process.env['CAT_CAFE_CALLBACK_OUTBOX_MAX_ATTEMPTS']); + const parsed = parseIntEnv(process.env.CAT_CAFE_CALLBACK_OUTBOX_MAX_ATTEMPTS); if (parsed === null || parsed < 0) return DEFAULT_OUTBOX_MAX_ATTEMPTS; return parsed; } @@ -183,15 +183,9 @@ export async function sendCallbackRequest( // safety net for "did it publish", not client retries. const retryDelaysMs = options?.retryDelaysMs ?? getRetryDelaysMs(); const payload = JSON.stringify(request.body); - const result = await postJsonWithRetry( - `${request.apiUrl}${request.path}`, - payload, - retryDelaysMs, - request.headers, - { - fetchTimeoutMs: options?.fetchTimeoutMs, - }, - ); + const result = await postJsonWithRetry(`${request.apiUrl}${request.path}`, payload, retryDelaysMs, request.headers, { + fetchTimeoutMs: options?.fetchTimeoutMs, + }); if (result.ok) return { ok: true, data: result.data }; if (enableOutbox && result.failure.retryable) { diff --git a/packages/mcp-server/src/tools/callback-retry.ts b/packages/mcp-server/src/tools/callback-retry.ts index 613ee7c84d..b96eb181d3 100644 --- a/packages/mcp-server/src/tools/callback-retry.ts +++ b/packages/mcp-server/src/tools/callback-retry.ts @@ -25,7 +25,7 @@ export interface PostJsonRetryOptions { } export function getRetryDelaysMs(): number[] { - const raw = process.env['CAT_CAFE_CALLBACK_RETRY_DELAYS_MS']; + const raw = process.env.CAT_CAFE_CALLBACK_RETRY_DELAYS_MS; if (!raw) return DEFAULT_RETRY_DELAYS_MS; const parsed = raw .split(',') @@ -35,7 +35,7 @@ export function getRetryDelaysMs(): number[] { } export function getFetchTimeoutMs(): number { - const raw = process.env['CAT_CAFE_CALLBACK_FETCH_TIMEOUT_MS']; + const raw = process.env.CAT_CAFE_CALLBACK_FETCH_TIMEOUT_MS; if (!raw) return DEFAULT_FETCH_TIMEOUT_MS; const parsed = Number.parseInt(raw.trim(), 10); return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_FETCH_TIMEOUT_MS; diff --git a/packages/mcp-server/src/tools/publish-verdict-tool.ts b/packages/mcp-server/src/tools/publish-verdict-tool.ts index e5b8745fe9..bbd4b94ba1 100644 --- a/packages/mcp-server/src/tools/publish-verdict-tool.ts +++ b/packages/mcp-server/src/tools/publish-verdict-tool.ts @@ -2,7 +2,7 @@ import { z } from 'zod'; import { callbackPost } from './callback-tools.js'; import type { ToolResult } from './file-tools.js'; -const PUBLISH_VERDICT_FETCH_TIMEOUT_MS = 120_000; +const PUBLISH_VERDICT_FETCH_TIMEOUT_MS = 180_000; /** * F192 Phase H AC-H4: cat_cafe_publish_verdict MCP tool. @@ -333,8 +333,6 @@ type PublishVerdictToolInput = { // publishes that race on the same `verdict/auto//` branch. We give // this one call a long single attempt with NO retry; idempotency guards on the // server (verdict_already_exists / branch-exists) are the real safety net. -const PUBLISH_VERDICT_FETCH_TIMEOUT_MS = 180_000; - export async function handlePublishVerdict(input: PublishVerdictToolInput): Promise { return callbackPost( `/api/eval-domains/${encodeURIComponent(input.domainId)}/publish-verdict`, diff --git a/packages/web/src/hooks/useAgentMessages.ts b/packages/web/src/hooks/useAgentMessages.ts index 332831c51f..06e1094d37 100644 --- a/packages/web/src/hooks/useAgentMessages.ts +++ b/packages/web/src/hooks/useAgentMessages.ts @@ -4771,8 +4771,12 @@ export function useAgentMessages() { }); if (result.recoveryAction === 'none') { // [DIAG:msg-disappear] Trace done handler replaceMessages - const codexBefore = storeSnapshot.messages.filter((m: { catId?: string | null }) => m.catId === 'codex').length; - const codexAfter = result.nextMessages.filter((m: { catId?: string | null }) => m.catId === 'codex').length; + const codexBefore = storeSnapshot.messages.filter( + (m: { catId?: string | null }) => m.catId === 'codex', + ).length; + const codexAfter = result.nextMessages.filter( + (m: { catId?: string | null }) => m.catId === 'codex', + ).length; if (codexBefore !== codexAfter || msg.catId === 'codex') { console.warn('[DIAG:msg-disappear] done handler replaceMessages', { doneCatId: msg.catId, diff --git a/packages/web/src/hooks/useChatHistory.ts b/packages/web/src/hooks/useChatHistory.ts index abbc2bcfca..33d5c8c1d0 100644 --- a/packages/web/src/hooks/useChatHistory.ts +++ b/packages/web/src/hooks/useChatHistory.ts @@ -1003,14 +1003,25 @@ export function useChatHistory(threadId: string) { // 到 1 bubble per (catId, invocationId),确保 hydrate ≡ live。 const projectedMerged = projectCanonicalBubbles({ records: mergedMsgs }).messages; // [DIAG:msg-disappear] Trace codex message through hydration pipeline - if (fetchForThread.includes('mq5zrg04n1votqb9') || mergedMsgs.some((m: { catId?: string | null }) => m.catId === 'codex')) { + if ( + fetchForThread.includes('mq5zrg04n1votqb9') || + mergedMsgs.some((m: { catId?: string | null }) => m.catId === 'codex') + ) { const codexInHistory = historyMsgs.filter((m: { catId?: string | null }) => m.catId === 'codex').length; const codexInMerged = mergedMsgs.filter((m: { catId?: string | null }) => m.catId === 'codex').length; - const codexInProjected = projectedMerged.filter((m: { catId?: string | null }) => m.catId === 'codex').length; + const codexInProjected = projectedMerged.filter( + (m: { catId?: string | null }) => m.catId === 'codex', + ).length; const codexIds = { - history: historyMsgs.filter((m: { catId?: string | null }) => m.catId === 'codex').map((m: { id: string }) => m.id), - merged: mergedMsgs.filter((m: { catId?: string | null }) => m.catId === 'codex').map((m: { id: string }) => m.id), - projected: projectedMerged.filter((m: { catId?: string | null }) => m.catId === 'codex').map((m: { id: string }) => m.id), + history: historyMsgs + .filter((m: { catId?: string | null }) => m.catId === 'codex') + .map((m: { id: string }) => m.id), + merged: mergedMsgs + .filter((m: { catId?: string | null }) => m.catId === 'codex') + .map((m: { id: string }) => m.id), + projected: projectedMerged + .filter((m: { catId?: string | null }) => m.catId === 'codex') + .map((m: { id: string }) => m.id), }; console.warn('[DIAG:msg-disappear] replace hydration pipeline', { threadId: fetchForThread, @@ -1022,7 +1033,9 @@ export function useChatHistory(threadId: string) { codexInMerged, codexInProjected, codexIds, - draftIds: historyMsgs.filter((m: { id: string }) => m.id.startsWith('draft-')).map((m: { id: string }) => m.id), + draftIds: historyMsgs + .filter((m: { id: string }) => m.id.startsWith('draft-')) + .map((m: { id: string }) => m.id), }); } hydrateThread(fetchForThread, projectedMerged, data.hasMore ?? false); diff --git a/packages/web/src/stores/chatStore.ts b/packages/web/src/stores/chatStore.ts index 893f7e3604..0eba24c58d 100644 --- a/packages/web/src/stores/chatStore.ts +++ b/packages/web/src/stores/chatStore.ts @@ -1722,7 +1722,9 @@ export const useChatStore = create((set, get) => ({ codexAfter, totalBefore: prevMsgs.length, totalAfter: msgs.length, - codexIdsBefore: prevMsgs.filter((m: { catId?: string | null }) => m.catId === 'codex').map((m: { id: string }) => m.id), + codexIdsBefore: prevMsgs + .filter((m: { catId?: string | null }) => m.catId === 'codex') + .map((m: { id: string }) => m.id), }); console.trace('[DIAG:msg-disappear] replaceMessages caller stack'); } @@ -1785,7 +1787,9 @@ export const useChatStore = create((set, get) => ({ codexAfter, totalBefore: prevMsgs.length, totalAfter: msgs.length, - codexIdsBefore: prevMsgs.filter((m: { catId?: string | null }) => m.catId === 'codex').map((m: { id: string }) => m.id), + codexIdsBefore: prevMsgs + .filter((m: { catId?: string | null }) => m.catId === 'codex') + .map((m: { id: string }) => m.id), }); console.trace('[DIAG:msg-disappear] hydrateThread caller stack'); } From c9bedf983fa719a0c40e96e545c6de376970d44f Mon Sep 17 00:00:00 2001 From: "Cat8zfu14fb-Opus-4.8" Date: Wed, 24 Jun 2026 11:35:50 +0800 Subject: [PATCH 7/7] fix(F192): generators read domain registry from LIVE root, not stale publish base MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 砚砚 found eval:a2a publish failing with `generator_failed: sourceRefsKind Required` — the third distinct fork-environment break in the publish pipeline (after the gh --repo and callback-timeout fixes in #11). Root cause: every generator adapter re-reads the domain registry from the ISOLATED worktree, which the publisher builds from the publish base branch (`sourceBase: 'origin/main'`). In a fork that base lags the runtime: - runtime (local develop_base) has registry schema field `sourceRefsKind` (required, eval-domain-registry.ts:32) + the matching route code. - origin/main AND origin/develop_base registries still lack it. So `loadDomains(deps.harnessFeedbackRoot)` (isolated) hit zod parse and threw "sourceRefsKind Required", blocking EVERY eval publish — not just a2a. This is a class bug: all 5 adapters (a2a, friction, capability-wakeup, memory, task-outcome) re-parse the registry from the isolated base root. The handler already validated the domain against the LIVE registry (publish-verdict.ts:126), and the a2a adapter already resolves its evidence YAMLs from the live root (they're gitignored) — so the registry, which is the runtime contract, should likewise come from the live root. Reading it from a stale base branch is wrong in principle and broke publish in fact. Fix: all 5 adapters now `loadDomains(deps.liveHarnessFeedbackRoot)`. The isolated worktree stays the write target for the bundle; only the registry read moves to the live (runtime) root. Decouples publish from base-branch registry drift. Tests: - friction + capability-wakeup happy-path tests updated to seed the registry into the LIVE root (the new contract). - NEW regression (friction): a STALE isolated registry missing `sourceRefsKind` + a valid live registry → publish succeeds via live. This reproduces 砚砚's exact failure and pins the fix. - 22 generator-adapter tests + 47 publish-verdict/pipeline/publisher tests green; biome + tsc clean. NOT addressed here (escalated to @lang as governance/design): the publisher still hardcodes `sourceBase: 'origin/main'` (wrong branch for a fork — main is the upstream mirror, develop_base is integration), and local develop_base is 25 commits ahead of origin/develop_base (unpushed), so published verdicts won't be visible to the runtime regardless of base choice until that divergence is reconciled. This commit unblocks the GENERATION mechanism; those are separate decisions. [宪宪/opus-4.8🐾] --- .../publish-verdict/a2a-generator-adapter.ts | 12 +++- .../capability-wakeup-generator-adapter.ts | 5 +- .../friction-generator-adapter.ts | 5 +- .../memory-generator-adapter.ts | 5 +- .../task-outcome-generator-adapter.ts | 5 +- ...apability-wakeup-generator-adapter.test.js | 7 +- .../friction-generator-adapter.test.js | 68 +++++++++++++++++-- 7 files changed, 95 insertions(+), 12 deletions(-) diff --git a/packages/api/src/infrastructure/harness-eval/publish-verdict/a2a-generator-adapter.ts b/packages/api/src/infrastructure/harness-eval/publish-verdict/a2a-generator-adapter.ts index ec09295e5d..865874543c 100644 --- a/packages/api/src/infrastructure/harness-eval/publish-verdict/a2a-generator-adapter.ts +++ b/packages/api/src/infrastructure/harness-eval/publish-verdict/a2a-generator-adapter.ts @@ -52,8 +52,16 @@ export function createA2aGeneratorAdapter(): VerdictGenerator { copyFileSync(liveRefs.refs.snapshotPath, isoSnapPath); copyFileSync(liveRefs.refs.attributionPath, isoAttrPath); - // Load domain entry from registry inside the isolated worktree's harness root. - const domains = loadDomains(deps.harnessFeedbackRoot); + // 砚砚 2026-06-24 P1: load the domain entry from the LIVE registry, NOT the + // isolated worktree's. The isolated worktree is built from the publish base + // branch (origin/main), which in a fork lags the runtime: the live registry + // can carry schema fields (e.g. required `sourceRefsKind`) that the base + // branch hasn't yet. Re-parsing the stale base registry here threw + // `generator_failed: sourceRefsKind Required` and blocked ALL eval:a2a + // publishes. The registry is the runtime contract — the handler already + // validated against the live root, and this adapter already resolves its + // evidence YAMLs from the live root — so read the domain from there too. + const domains = loadDomains(deps.liveHarnessFeedbackRoot); const domain = domains.get(packet.domainId); if (!domain) throw new Error(`unknown_domain: ${packet.domainId} not in registry`); diff --git a/packages/api/src/infrastructure/harness-eval/publish-verdict/capability-wakeup-generator-adapter.ts b/packages/api/src/infrastructure/harness-eval/publish-verdict/capability-wakeup-generator-adapter.ts index afa9e5361f..44205ffe33 100644 --- a/packages/api/src/infrastructure/harness-eval/publish-verdict/capability-wakeup-generator-adapter.ts +++ b/packages/api/src/infrastructure/harness-eval/publish-verdict/capability-wakeup-generator-adapter.ts @@ -56,7 +56,10 @@ export function createCapabilityWakeupGeneratorAdapter(provider: CapabilityWakeu ); } - const domains = loadDomains(deps.harnessFeedbackRoot); + // 砚砚 2026-06-24 P1: read the domain from the LIVE registry (runtime contract + // the handler validated against), not the isolated worktree's base-branch copy + // which can lag the runtime schema (e.g. required `sourceRefsKind`). + const domains = loadDomains(deps.liveHarnessFeedbackRoot); const domain = domains.get(packet.domainId); if (!domain) { throw new Error(`unknown_domain: ${packet.domainId} not in registry`); diff --git a/packages/api/src/infrastructure/harness-eval/publish-verdict/friction-generator-adapter.ts b/packages/api/src/infrastructure/harness-eval/publish-verdict/friction-generator-adapter.ts index ba2974b55a..cc15d3c687 100644 --- a/packages/api/src/infrastructure/harness-eval/publish-verdict/friction-generator-adapter.ts +++ b/packages/api/src/infrastructure/harness-eval/publish-verdict/friction-generator-adapter.ts @@ -43,7 +43,10 @@ export function createFrictionGeneratorAdapter(provider: FrictionMetricsProvider const rollupInput = await provider.resolve(selector); - const domains = loadDomains(deps.harnessFeedbackRoot); + // 砚砚 2026-06-24 P1: read the domain from the LIVE registry (runtime contract + // the handler validated against), not the isolated worktree's base-branch copy + // which can lag the runtime schema (e.g. required `sourceRefsKind`). + const domains = loadDomains(deps.liveHarnessFeedbackRoot); const domain = domains.get(packet.domainId); if (!domain) { throw new Error(`unknown_domain: ${packet.domainId} not in registry`); diff --git a/packages/api/src/infrastructure/harness-eval/publish-verdict/memory-generator-adapter.ts b/packages/api/src/infrastructure/harness-eval/publish-verdict/memory-generator-adapter.ts index 96f91c8f29..0e5380cf77 100644 --- a/packages/api/src/infrastructure/harness-eval/publish-verdict/memory-generator-adapter.ts +++ b/packages/api/src/infrastructure/harness-eval/publish-verdict/memory-generator-adapter.ts @@ -50,7 +50,10 @@ export function createMemoryGeneratorAdapter(provider: MemoryMetricsProvider): V const { recallMetrics, libraryHealth } = await provider.resolve(selector); - const domains = loadDomains(deps.harnessFeedbackRoot); + // 砚砚 2026-06-24 P1: read the domain from the LIVE registry (runtime contract + // the handler validated against), not the isolated worktree's base-branch copy + // which can lag the runtime schema (e.g. required `sourceRefsKind`). + const domains = loadDomains(deps.liveHarnessFeedbackRoot); const domain = domains.get(packet.domainId); if (!domain) { throw new Error(`unknown_domain: ${packet.domainId} not in registry`); diff --git a/packages/api/src/infrastructure/harness-eval/publish-verdict/task-outcome-generator-adapter.ts b/packages/api/src/infrastructure/harness-eval/publish-verdict/task-outcome-generator-adapter.ts index eac0721bf0..8a59eb2460 100644 --- a/packages/api/src/infrastructure/harness-eval/publish-verdict/task-outcome-generator-adapter.ts +++ b/packages/api/src/infrastructure/harness-eval/publish-verdict/task-outcome-generator-adapter.ts @@ -17,7 +17,10 @@ export function createTaskOutcomeGeneratorAdapter(): VerdictGenerator { ); } - const domains = loadDomains(deps.harnessFeedbackRoot); + // 砚砚 2026-06-24 P1: read the domain from the LIVE registry (runtime contract + // the handler validated against), not the isolated worktree's base-branch copy + // which can lag the runtime schema (e.g. required `sourceRefsKind`). + const domains = loadDomains(deps.liveHarnessFeedbackRoot); const domain = domains.get(packet.domainId); if (!domain) { throw new Error(`unknown_domain: ${packet.domainId} not in registry`); diff --git a/packages/api/test/harness-eval/capability-wakeup-generator-adapter.test.js b/packages/api/test/harness-eval/capability-wakeup-generator-adapter.test.js index cf9460574c..83b9b354e6 100644 --- a/packages/api/test/harness-eval/capability-wakeup-generator-adapter.test.js +++ b/packages/api/test/harness-eval/capability-wakeup-generator-adapter.test.js @@ -202,7 +202,10 @@ describe('createCapabilityWakeupGeneratorAdapter', () => { it('happy path: passes packet+trials+domain to generator and returns artifact paths', async () => { const repoRoot = mkdtempSync(join(tmpdir(), 'cw-adapter-happy-repo-')); const harnessFeedbackRoot = join(repoRoot, 'docs', 'harness-feedback'); - seedDomainRegistry(harnessFeedbackRoot); + // 砚砚 2026-06-24 P1: registry is read from the LIVE root now. Bundle/raw inputs + // still write under harnessFeedbackRoot (the isolated worktree target). + const liveRoot = join(repoRoot, 'live', 'docs', 'harness-feedback'); + seedDomainRegistry(liveRoot); let resolveCalledWith = null; let resolveScope = null; const provider = { @@ -225,7 +228,7 @@ describe('createCapabilityWakeupGeneratorAdapter', () => { const result = await adapter(packet, selector, { harnessFeedbackRoot, - liveHarnessFeedbackRoot: '/tmp/live-unused-for-cw', + liveHarnessFeedbackRoot: liveRoot, ownerUserId: 'default-user', }); diff --git a/packages/api/test/harness-eval/friction-generator-adapter.test.js b/packages/api/test/harness-eval/friction-generator-adapter.test.js index 80c1df05c7..cb0bbcd37c 100644 --- a/packages/api/test/harness-eval/friction-generator-adapter.test.js +++ b/packages/api/test/harness-eval/friction-generator-adapter.test.js @@ -165,8 +165,11 @@ describe('createFrictionGeneratorAdapter', () => { it('happy path: passes selector to provider unchanged and returns bundle-only artifact paths', async () => { const repoRoot = mkdtempSync(join(tmpdir(), 'friction-adapter-happy-repo-')); - const harnessFeedbackRoot = join(repoRoot, 'docs', 'harness-feedback'); - seedFrictionDomain(harnessFeedbackRoot); + // 砚砚 2026-06-24 P1: the domain registry is read from the LIVE root now (runtime + // contract), not the isolated bundle-write root. Seed it accordingly. + const isolatedRoot = join(repoRoot, 'iso', 'docs', 'harness-feedback'); + const liveRoot = join(repoRoot, 'live', 'docs', 'harness-feedback'); + seedFrictionDomain(liveRoot); let resolveCalledWith = null; const provider = { @@ -179,8 +182,8 @@ describe('createFrictionGeneratorAdapter', () => { const packet = buildSubmittedPacket(); const result = await adapter(packet, SELECTOR, { - harnessFeedbackRoot, - liveHarnessFeedbackRoot: '/tmp/live-unused-for-friction', + harnessFeedbackRoot: isolatedRoot, + liveHarnessFeedbackRoot: liveRoot, }); assert.deepEqual(resolveCalledWith, SELECTOR, 'adapter passes selector to provider unchanged'); @@ -196,4 +199,61 @@ describe('createFrictionGeneratorAdapter', () => { const snapshot = JSON.parse(readFileSync(join(result.bundleDir, 'snapshot.json'), 'utf8')); assert.equal(snapshot.featureId, 'F245'); }); + + // 砚砚 2026-06-24 P1 regression: the generator must read the domain registry from + // the LIVE root, NOT the isolated publish-base worktree. In a fork the isolated + // worktree is built from origin/main, which lags the runtime and can carry a + // STALE registry (e.g. missing the now-required `sourceRefsKind`). Re-parsing + // that stale registry threw `generator_failed: sourceRefsKind Required` and + // blocked ALL eval publishes. This pins that the live registry is the source. + it('reads registry from LIVE root, immune to a stale base-branch registry (sourceRefsKind missing)', async () => { + const repoRoot = mkdtempSync(join(tmpdir(), 'friction-adapter-stale-base-')); + const isolatedRoot = join(repoRoot, 'iso', 'docs', 'harness-feedback'); + const liveRoot = join(repoRoot, 'live', 'docs', 'harness-feedback'); + + // Isolated (origin/main base) carries a STALE eval-friction.yaml WITHOUT + // sourceRefsKind — exactly the artifact that threw on parse pre-fix. + const isoDomainsDir = join(isolatedRoot, 'eval-domains'); + mkdirSync(isoDomainsDir, { recursive: true }); + writeFileSync( + join(isoDomainsDir, 'eval-friction.yaml'), + `domainId: eval:friction +displayName: Friction Signal Eval +systemThreadId: thread_eval_friction +evalCat: + catId: gpt52 + handle: '@gpt52' + model: gpt-5.4 +frequency: weekly +sourceAdapter: f245-friction-rollup +threadPolicy: + role: working-home + stateSot: registry + allowedContent: + - longitudinal-analysis +legacyScheduledTaskIds: [] +handoffTargetResolver: + featureId: F245 + ownerCatId: opus-47 + threadLookup: feature-thread +sla: + acknowledgeHours: 48 + reevalWithinHours: 168 +fixtures: [] +enabled: true +`, + ); + // Live (runtime) carries the current registry WITH sourceRefsKind. + seedFrictionDomain(liveRoot); + + const provider = { resolve: async () => buildRollupInput({ clusters: 1 }) }; + const adapter = createFrictionGeneratorAdapter(provider); + + // Must NOT throw sourceRefsKind / unknown_domain — succeeds via the live registry. + const result = await adapter(buildSubmittedPacket(), SELECTOR, { + harnessFeedbackRoot: isolatedRoot, + liveHarnessFeedbackRoot: liveRoot, + }); + assert.match(result.bundleDir, /bundles\/vhp-friction-adapter-test$/); + }); });