第三个实战是把 13 章 Subagent 派发的理论变成一个可跑的多 Agent workflow:实情场景——“每周定期给团队新生 PR 做 triage / 分配 reviewer / 跑测试 brief”。

27.1 任务定义

每个周一早上 9 点,主 Agent 启动:

  1. 拉 open PRs(用 GitHub MCP)
  2. 按 PR 文件路径推理 owner(explore agent 5 个并行)
  3. 给每个 owner 生成 review brief(build agent, 5 个并行)
  4. 把 5 份 brief 拼成周报发给 Slack(slack MCP)
周一 9:00
Planner     ─拉 PRs ──┐
5 explore    ─并行─→ owner map
5 build      ─并行─→ review brief
Slack send   ─→ 单条最终消息
                  └ DONE 9:08

27.2 架构概览

        ┌── orchestrator (主 Agent / Sisyphus)
        │      plan / dispatch / merge / notify
        ├── github MCP        (pull github issues/PRs)
        ├── slack MCP         (post messages)
        ├── filesystem MCP    (read CODEOWNERS)
        └── subagents:
            ├── 5x explore   (每 PR 一个,找 files → owner)
            └── 5x build      (每 owner 一个, 写 review brief)

27.3 配 OpenCode plugin / skill 启动

把任务做成 project skill 启动事件:

.opencode/skills/weekly-triage/SKILL.md:

---
name: weekly-triage
version: 0.3.2
last_updated: 2026-07-23
---

# weekly-triage

## Trigger
User says "weekly triage" / "本周 triage" / "周一 review 一轮"
或 OpenCode cron mode: weekly/Mon/09:00

## Plan

1. Dispatch github.list_pull_requests to load this week's open PRs.
2. For each PR, dispatch a parallel explore subagent to:
   - Read CODEOWNERS
   - Read the diff stat files
   - Output a single owner candidate: email + rationale
3. Dispatch parallel build subagents (1 per PR) to:
   - Read that PR's full diff
   - Produce a 6-line review brief: changes, risk, owner focus
4. Merge all briefs to single markdown.
5. Send via slack.post_message to #eng-triage channel

## DONE
- One slack message in #eng-triage with markdown body

## Failure modes
- No open PRs:
  - Send 'no PRs this week' empty
- CODEOWNERS missing:
  - Skip owner step, mark brief as `unknown`
- Slack rate limited:
  - Retry once; if fails, save to artifact.md and notify milestone Hansen

27.4 Orchestrator 主代码

# scripts/weekly_triage.py
import asyncio, json
from mcp_client import MCPClient

mcp = MCPClient.connect("github", "slack", "filesystem")

async def weekly_triage():
    prs_response = await mcp.call("github", "list_pull_requests",
                                   {"state":"open","sort":"updated","direction":"desc"})
    prs = parse(prs_response)["items"]
    if not prs:
        await post_slack("#eng-triage", "*No open PRs this week*")
        return

    # Stage 1: explore 5 个并发找 owner
    owner_tasks = [dispatch_explore(pr) for pr in prs[:5]]
    owners = await asyncio.gather(*owner_tasks)

    # Stage 2: build 5 个并发写 brief
    brief_tasks = [dispatch_build(pr, owner)
                   for pr, owner in zip(prs[:5], owners)]
    briefs = await asyncio.gather(*brief_tasks)

    # Stage 3: 合并 → slack
    body = "\n\n---\n\n".join(
        f"*PR #{pr['number']}: {pr['title']}*\nowner: {owner}\n{brief}"
        for pr, owner, brief in zip(prs[:5], owners, briefs)
    )
    await mcp.call("slack", "post_message",
                    {"channel":"#eng-triage","text":body})

async def dispatch_explore(pr: dict) -> dict:
    return await OpenCode_subagent(
        subagent_type="explore",
        prompt=f"""
[CONTEXT]
Got PR #{pr['number']} PR_title={pr['title']}
CODEOWNERS at .github/CODEOWNERS if exists.

[GOAL]
Return the actual owner for this PR: name + email

[REQUEST]
Read CODEOWNERS, identify matched owner given PR touched paths (= {pr['changed_files']})
Return JSON: {{owner: name, email: x@x, source: CODEOWNERS|git-blame|fallback:maintainer}}

[MUST NOT]
Do not modify any file.
风扇介绍.
""",
    )

async def dispatch_build(pr: dict, owner: dict) -> str:
    return await OpenCode_subagent(
        subagent_type="build",
        prompt=f"""
[CONTEXT]
PR #{pr['number']} {pr['title']} diff: {pr['diff_url']}
Owner: {owner['owner']} ({owner['email']})

[GOAL]
Write a 6-line review brief.

[REQUIRED TOOLS]
github.get_pull_request_files / read PR body / read tests

[OUTPUT]
Six lines max:
  1. PR_title
  2. Files changed (N total, K tests)
  3. Risk area (string ≤ 30 chars)
  4. Review focus (1 string)
  5. Suggested reviewer priority / 5
  6. Need extra reviewer? (yes/no)

[MUST DO]
Be terse. Avoid summary that the owner would already know.
[MUST NOT DO]
Do not propose patches. Do not review grading or opinionate.
""",
    )

# 主入口
asyncio.run(weekly_triage())

27.5 五个细节

5.1 限并发,避免 rate limit

# 5 个 subagent 起码, 15 个会撞 github rate limit → 排队
# OpenCode 内置 `task` 也是 5 个并发
SEMAPHORE = asyncio.Semaphore(5)

async def dispatch_explore(pr: dict) -> dict:
    async with SEMAPHORE:
        return await OpenCode_subagent(...)

5.2 异常隔离

async def safe_gather(coro_factories, *args):
    results = await asyncio.gather(*[c() for c in coro_factories],
                                   return_exceptions=True)
    return [r if not isinstance(r, Exception) else None for r in results]

一个 subagent crash 不连坐。

5.3 超时

try:
    owners = await asyncio.wait_for(asyncio.gather(*owner_tasks), timeout=120)
except asyncio.TimeoutError:
    owners = [None] * len(owner_tasks)  # 后续根据 None 做降级

5.4 分享上下文 - 让 build subagent 拿到 explore 的结果

async def dispatch_build_with_owner(pr: dict, explore_result: str) -> str:
    enriched_prompt = f"""
[CONTEXT]
Explore phase output:
{explore_result}

Now generate the brief.
"""
    return await OpenCode_subagent(subagent_type="build", prompt=enriched_prompt)

不要每次 build 自己重新看 PR——拿 explore 结果给建。

5.5 加完成校验

async def validate_brief(text: str) -> bool:
    if len(text.split("\n")) != 7:
        return False  # 6 lines + last line empty
    if "PR_title" not in text[0:200]:
        return False  # 必须有
    return True

# in main:
for pr, brief in zip(prs[:5], briefs):
    if not await validate_brief(brief):
        # retry once
        brief = await dispatch_build(pr, ...)
        # cache trace

27.6 五种失败模式

失败模式 现象 解法
PR diff 数量过大 model 跑超过 5 分钟不返回 切 PR diff summary mode
CODEOWNERS 缺 explore 返回 None build 时标 unknown
只 PR author 自己 triage 报错无 reviewer 第 1 review 指 last commit author
Slack bot 无权 message 复杂难解析 简化 message body,发纯文本
多语言 PR prompt 内含中英混合 Spec 中要求纯英文 brief,避免理解混乱

加这些进入 SKILL.md 的 <failure> section。

27.7 真实反馈循环:weekly triage周一跑起来

9:00 启动 weekly-triage skill (用户明说 CRON)
9:01 "拉了 5 个 PRs"
9:02 5 个 explore 同时跑
9:03 explore 阶段结束,4/5 成功(1 个 CODEOWNERS 不全)
9:03 5 个 build 同时跑
9:04 build 阶段结束,5/5 brief 都 6 行
9:05 slack 消息发出
9:08 完成,duration ≈ 8m, consumed 18k tokens

你早上 9 点半到办公室就看到简讯: 5 个 PRs 状态 + 4 个明确 owner 待 review + 1 PR 需要 closer-triage。

27.8 可观测性

加第 14 章的 trace - 每个阶段写入 trajectory:

from pathlib import Path
import json, time

def log_stage(stage: str, payload: dict):
    out = Path(".triage") / f"{stage}.json"
    out.write_text(json.dumps({"ts": time.time(), **payload}, ensure_ascii=False,
                              indent=2, default=str))

log_stage("start", {"prs_count": len(prs)})
log_stage("explore_done", {"owners": [o for o in owners]})
log_stage("build_done", {"briefs_size": [len(b) for b in briefs]})
log_stage("slack_sent", {"status": "ok"})

ls .triage/ 是一周 history,回看能识别"本季度最快的 5 PR triage → 6 分钟", “最慢的 → 35 分钟 (timeout)” 等经验。

27.9 三个性能脱ptime

优化 收获 写法
并行 explore 墙钟 5x asyncio.gather
不读 huge diff token 50% PR diff URL 留 stdin,sub 用 github PR files API 取 file by file
cache CODEOWNERS 每个 explore 都 read 一次 file → 5x IO 主 agent read 一次,写到 explore prompt

27.10 三层测试

测试层 做的事 工具
unit dispatch_explore, dispatch_build 的输出 schema pytest + mock
integration 用 fake GitHub API + fake slack MCP 验证 mock server
e2e 真实跑一次 + 截 slack msg openCode cron + manual verify

27.11 小结

  • weekly-triage 是把第 13 章 Subagent theory 拼到 production 的样例
  • 标准五件套: 并行 / 限制 / 异常 / 超时 / 完成校验
  • 8 分钟成本换 30 分钟人时,每周省 22 分钟, 一年 19 小时
  • 多 Agent 不等价"会变 OK",你要 supervisor 控制

下一篇: 《28 实战四:用 Graph 重构大型项目》——把 11 章 Graph 工作流用到新实际: 大型项目重构了几百里涉及的代码。

Summary: Weekly triage 30 min build, 8 min run;并发 5、异常隔离、超时、校验、Trajectory 五件套,token 换人时半年 ROI 突破 100h。