背景

最近在 mbp 项目里要做一个 /blog skill:把工程会话里散落的 git diff、notepad、session transcript 自动整合成一篇中文复盘博客,预览确认后同步发布到两个站点 —— 一个是 Astro 静态博客(blog.lhq.homes),一个是自研的 Cloudflare Workers 博客 CFBlog-Plus(blog.nsu.dpdns.org)。

整个任务通过 OMC team 流水线启动,规划阶段产出了一份 4 Lane 的执行计划:

Lane内容
1SKILL.md 骨架 + 公共函数 common.sh + 复盘模板
2collect-context.sh 采集 git/notepad/memory
3apublish-astro.sh 推送到 blog_static 仓库
3bcfblog_post.py + publish-cfblog.sh 调用 CFBlog API
4verify-publish.sh + 集成测试 + run-all.sh

3 个 worker(worker-1/2/3)并行启动,按 Lane 拆分。worker-1 顺利完成 Lane 1(17 个 bats 用例通过)。

目标

  • /blog 在任意项目下可触发
  • 端到端可发布两站(Astro + CFBlog),任何一边失败不阻塞另一边
  • 必须有测试(用户硬性要求),脚本可独立单测
  • 不污染 ~/.claude/,按现有惯例把实际目录放 ~/.agents/skills/,再符号链接到 ~/.claude/skills/

非目标:

  • 不做 V2 的草稿/定时发布、版本管理、Codex/Gemini 适配
  • 不做封面图自动生成(img 字段留空)

遇到的问题

1. worker 失联与误报

我作为 team lead 在 worker-1 完成 Lane 1 后,看到 mission state 显示 worker-2/3 仍 pending,于是 SendMessage 推送任务。但 8 小时后再核查发现:自 worker-1 完成的时间起,目录没有任何新文件出现,三个 worker 全部 unreachable。

No agent named 'worker-2' is currently addressable. Spawn a new one or use the agent ID.

我之前给用户的”已推送 worker-2/3”是误报。实际上推送时它们已经断了,我没及时去 find 核查产物文件,光看 task state 误判。

2. set -e[ -f ] && cat 提前退出

接管后第一个写 collect-context.sh,函数里这样写:

collect_notepad() {
    local notepad="$PROJECT_DIR/.omc/notepad.md"
    [ -f "$notepad" ] && cat "$notepad"
}

跑 smoke test 时脚本走到 collect_notepad 之后直接退出,根本没走到后面的 collect_memory_files 和 JSON 组装。

3. publish-astro.sh title 解析不兼容

第一版用 awk 抽 title:

title="$(awk -F'"' '/^title:/ {print $2; exit}' "$md_file" 2>/dev/null || true)"

集成测试 fixture 写的是不带引号的 title: 集成测试文章,于是 commit message 退化成了 post: e2e-post(fallback 到 slug),不是预期的 post: 集成测试文章

4. CFBlog API 的 Content-Type 选择坑

CFBlog-Plus 后台对不同 Content-Type 的请求体走不同的解析路径,两条路径返回的结构并不一致 —— 用错 Content-Type 时,所有字段都会被读成 undefined,校验直接失败。这种实现细节文档里不会写,调通后封装到脚本里即可,不必每次都重新踩。

5. python3 -m pip install pytest 被 PEP 668 拦截

Homebrew Python 3.14 是 externally-managed-environment:

error: externally-managed-environment
× This environment is externally managed
hint: ... pass --break-system-packages.

不想破坏系统 Python,也不想给 skill 加 venv 步骤增加复杂度。

6. bats 不支持纯中文测试名

测试名里写了 @test "[E2E] CFBlog 发布仍可执行 even if astro push 提前失败",bats 解析出错:

bats: unknown test name `$'test_-5bE2E-5d_CFBlog_\xe5\x8f\x91\xe5\xb8\x83...

排查过程与踩坑

踩坑 1:worker 死活,要看文件不要看状态

发现状态显示 in_progress 不可信后,改成定期 find ~/.agents/skills/dev-blog-publisher -newer SKILL.md 看真实产物。一旦 8 小时没新文件就当 worker 已死,主动询问用户是否接管。

之后单人串行实现剩余 4 个 Lane,每个 Lane 写完立刻跑 bats 验证,发现错误立刻修,绝不堆积。

踩坑 2:函数尾返回非零会触发 set -e

[ -f ... ] && cat ... 当文件不存在时整条表达式返回 1,函数也就返回 1,外层 set -euo pipefail 就把脚本毙了。修复用显式 if 块,并给函数末尾加 return 0

collect_notepad() {
    local notepad="$PROJECT_DIR/.omc/notepad.md"
    if [ -f "$notepad" ]; then
        cat "$notepad"
    fi
    return 0
}

教训:任何在 set -e 下被 source 的 lib 函数,末尾都该显式 return 0 防御,特别是带条件分支的。

踩坑 3:YAML 解析交给 Python,不要 hack awk

把 awk 那一行换成 Python heredoc:

title="$(python3 - "$md_file" <<'PY' || true
import sys, re
try:
    text = open(sys.argv[1], encoding="utf-8").read()
    m = re.match(r"^---\s*\n(.*?)\n---", text, re.DOTALL)
    if m:
        for line in m.group(1).splitlines():
            mm = re.match(r'^\s*title\s*:\s*(.*?)\s*$', line)
            if mm:
                t = mm.group(1).strip()
                if (t.startswith('"') and t.endswith('"')) or (t.startswith("'") and t.endswith("'")):
                    t = t[1:-1]
                print(t)
                break
except Exception:
    pass
PY
)"

这样带引号、不带引号、单引号、双引号都兼容。

踩坑 4:CFBlog API 字段映射要按后台预期组装

最终把 frontmatter → CFBlog 请求体的映射做成了 cfblog_post.py 里的纯函数(pandoc 转 HTML、字段重命名、日期规范化),具体字段名和形状由调试时 attach 后台拿到的结构决定,封装好后上层一行调用就行。请求体格式和具体字段不在博客里展开,免得变成一份非官方 API 手册。

另一个隐藏的坑:响应的 rstJavaScript boolean true,不是数字 1

if data.get("rst") is True or data.get("rst") == 1:
    ...

还有 createDate 字段对 ISO 8601 的 T 分隔不友好,前置在 Python 端做一次 "YYYY-MM-DDTHH:MM" → "YYYY-MM-DD HH:MM" 的规范化,避免下游字段长度校验漏过空值。

踩坑 5:pytest 用 uv run 隔离

不污染系统 Python,又不想给 skill 加 venv 包袱。用 uv 的 uv run --with

uv run --with pytest --with pyyaml --python python3 python -m pytest tests/

每次跑测试 uv 临时拉环境,速度可接受(首次几秒,后续秒级)。run-all.sh 里做了 fallback:优先 uv,没 uv 就走系统 pytest,都没有就报清晰错误。

踩坑 6:bats 测试名只用 ASCII

bats 把测试名转成 shell 函数名时不支持非 ASCII 字符。改回全英文:

@test "[E2E] CFBlog publish still works when astro push fails" {
    ...
}

中文要留就放注释里,不放在 @test 引号里。

踩坑 7:验证状态优先级 failed > pending

集成测试发现 astro_status=pending(GH Actions 在跑)+ cfblog_status=failed(mock 返回 404)时,期望整体退出码 = pending (2),但实际 = failed (1)。

读代码确认 verify-publish.sh 的设计是 failed 优先于 pending(任何一站 failed 立刻判定失败),这是对的。改测试用例:不传 article_id 让 CFBlog 跳过,只看 Astro 的 pending 状态。

解决方案

最终架构:

~/.agents/skills/dev-blog-publisher/   ← 实际目录
├── SKILL.md                            ← 工作流编排(Bash/Read/Write/AskUserQuestion + session_search)
├── scripts/
│   ├── common.sh                       ← 日志 + env/dep 检查
│   ├── collect-context.sh              ← git/notepad/memory 采集 → JSON
│   ├── publish-astro.sh                ← git add/commit/push
│   ├── cfblog_post.py                  ← YAML 解析 + pandoc + JSON 数组 POST
│   ├── publish-cfblog.sh               ← Shell 入口(调用 Python)
│   └── verify-publish.sh               ← GH Actions + HTTP 200 双站验证
├── templates/retrospective.md          ← 6 章节复盘模板
└── tests/
    ├── test_common.bats                ← 17 用例
    ├── test_collect_context.bats       ← 12 用例
    ├── test_publish_astro.bats         ← 11 用例
    ├── test_publish_cfblog.bats        ← 9 用例
    ├── test_cfblog_post.py             ← 27 pytest 用例
    ├── test_verify_publish.bats        ← 9 用例
    ├── test_integration.bats           ← 6 用例
    └── run-all.sh                      ← 跑 bats + uv-pytest

~/.claude/skills/dev-blog-publisher → 符号链接到上面

为什么把实际目录放 ~/.agents/skills/:现有的 OMC skill 已经全是这种符号链接结构(opencli-adapter-author 等),保持一致。直接在 ~/.claude/skills/ 实体写会破坏惯例。

发布两站独立运行:

# Step 5a
bash scripts/publish-astro.sh "$BLOG_STATIC_REPO_PATH/src/content/blog/$SLUG.md"
# Step 5b
bash scripts/publish-cfblog.sh "$BLOG_STATIC_REPO_PATH/src/content/blog/$SLUG.md"

任何一站失败重试一次(间隔 5 秒),仍失败就只报告这一站,不阻塞另一站。

验证阶段:

bash scripts/verify-publish.sh "$SLUG" "$CFBLOG_ARTICLE_ID"

退出码:0 全成功 / 1 任何 failed / 2 任何 pending。

经验总结与工具推荐

关于多 agent 协作

  • 状态信号不如产物可信。worker 显示 in_progress 不代表它还活着。第一手验证永远是 find -newer + 实际看产物文件。
  • 失联超过 30 分钟主动切单人接管,不要无止境等待。
  • 给用户的进度报告一定要核查文件系统,宁可少说也不要误报。

关于 Shell + Python 混合

  • 关键数据转换交给 Python(YAML / JSON / HTTP),Shell 只做编排和子进程调用。
  • set -euo pipefail 是默认开。代价是所有函数尾要 return 0 防御,所有 && 短路要审视会不会让函数失败。
  • --var 环境变量(如 PUBLISH_ASTRO_GIT)暴露关键二进制路径,让 bats 测试可以 mock。

关于测试

  • bats-core 是 Shell 测试事实标准,brew install bats-core 一行装好。
  • 不用真实远端 git push 也能测:本地 git init --bare 当 push 目标。
  • uv run --with pkg 比 venv 简洁得多,CI 友好。

关于第三方 API

  • 看源码比看文档可靠。文档里通常不会写”换个 Content-Type 行为就完全不同”这类隐含约束,调通的最短路径常常是直接读 service code 的解析层。
  • JSON.stringify(true) === "true",但 Python data["rst"] == 1 不会匹配 True。跨语言比较要警惕类型。

推荐工具

工具用途安装
bats-coreShell 单元测试brew install bats-core
pandocMarkdown → HTMLbrew install pandoc
uvPython 临时依赖隔离brew install uv
ghGitHub Actions 状态查询brew install gh
jqJSON 处理(备用)brew install jq

最终成绩:64 个 bats + 27 个 pytest,91 个用例 100% 通过,覆盖 6 个脚本和端到端集成。skill 注册后 Claude Code 自动检测到,输入 /blog 即可触发。