📖 第三课:高级模式 — 链式触发、预检、形状检测与 PRD 链式推进

预计完成时间:25 分钟  |  难度:高级  |  类型:知识

📌 前置要求:本课假设你已理解 第 1 课 的基本触发模式和 第 2 课 的状态机。

本课目标

在本课结束时,你将理解 sandcastle 和 course-video-manager 中四个关键高级模式:


工具链概念速览(第 3 课新出现的工具)

本课会接触到两个参考仓库中实际使用的命令行工具和 git 操作。在深入模式之前先简单认识一下:

工具是什么在本课中怎么用
npx tsx npx 是 Node.js 自带的包运行器,可以运行 npm 包而无需全局安装。
tsx 是一个工具,能直接运行 TypeScript 文件(不需要先编译成 JS)。
合起来:npx tsx some-script.ts = "直接执行这个 TS 文件"
sandcastle 用 npx tsx .sandcastle/implement/implement.ts
来运行 AI 代理的入口脚本
gh api ... --jq gh api 是 GitHub CLI 中调用 REST/GraphQL API 的命令。
--jq 参数让你用 jq 语法从返回的 JSON 中提取字段。
(jq 是一个 JSON 处理工具,预装在 GitHub Actions 的 Ubuntu 虚拟机上)
gh api repos/.../issues --jq 'length'
获取 JSON 数组的长度
git push --force-with-lease git push 的一个更安全的变体。普通 --force无条件覆盖远程分支;
--force-with-lease 只在远程分支没有被其他人修改过时才允许覆盖。
AI 代理推送代码时使用,防止覆盖
人类在此期间手动提交的内容
💡 别担心:你不需要在自己的工作流中立即使用这些工具。本课是知识课,重点是理解模式的设计思路。当你需要在自己的仓库中接入 AI 代理时,再回来参考这些具体用法。

模式 1:PAT 技巧 — 让工作流链式触发

问题

假设 Implement 工作流成功后创建了一个 PR。你希望自动触发 Review 工作流来审核这个 PR。自然的做法是:

Implement 完成 → gh pr edit --add-label "agent:review" → Review 工作流自动启动

但这里有一个关键陷阱:GitHub 会抑制GITHUB_TOKEN 触发的事件来防止递归工作流。如果你用 secrets.GITHUB_TOKEN 来添加标签,那个标签不会触发下游工作流!

解决方案:使用 Personal Access Token (PAT)

用 PAT 添加的标签触发下游工作流。sandcastle 和 course-video-manager 都使用了这个技巧:

# 在 Implement 工作流的最后一步中:
- name: "Request review on new PR"
  run: |
    if [ -n "$AGENT_PAT" ]; then
      # PAT 可用 → 下游 Review 工作流会被自动触发
      GH_TOKEN="$AGENT_PAT" gh pr edit "$PR_NUMBER" --add-label "agent:review"
    else
      # 回退到 GITHUB_TOKEN → 下游不被触发,需要手动帮助
      gh pr edit "$PR_NUMBER" --add-label "agent:review"
      echo "::warning::没有 AGENT_PAT。请手动给 PR 添加 agent:review 标签"
    fi
  env:
    AGENT_PAT: ${{ secrets.AGENT_PAT }}
    GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
✅ 优雅降级模式:即使 PAT 不可用,工作流也能正常运行——只是链式触发需要人类手动帮忙。这是防御性设计的最佳实践。注意代码中的 if [ -n "$AGENT_PAT" ]; thenshell 条件判断,不是 GitHub Actions 的 if:(详见第 2 课逐步解析)。逐词拆解:if = "如果",[ -n "..." ] = "字符串非空则为真"(方括号内必须有空格,写成 [-n 就报错),; then = "那么执行以下代码"。对应词 -z = "字符串为空则为真"。
🔑 为什么需要 PAT?GitHub 的递归抑制是有意设计的。如果没有它,两个互相触发的工作流会无限循环,消耗所有 Action 配额。PAT 绕过了这个保护,所以你必须确保你的工作流不会形成无限循环。另外别忘了 concurrency——它防止同一个 Issue/PR 同时跑多个实例,是链式触发场景下的另一道保险(详见第 2 课 concurrency 深入解释)。
🪪 Personal Access Token 到底是什么?

PAT 是你个人 GitHub 账号的"替身钥匙"——一串随机字符串,长得像 github_pat_11ABC...。它有三重含义:

① 身份:代表"你这个人",不是"工作流机器"
② 权限:创建 PAT 时你选择它有哪些权限(读 Issue?写 PR?提交代码?)
③ 有效期:你设定它什么时候过期(7 天、30 天、永久……)

所以:GITHUB_TOKEN = 机器人身份,受递归抑制;PAT = 你的真人身份,不受抑制。这就是为什么 sandcastle 能用 PAT 实现 Implement → Review 的链式自动触发。

如何创建:GitHub Settings → Developer settings → Personal access tokens → Fine-grained tokens → 选择仓库和权限 → 生成 → 复制到仓库的 Settings → Secrets and variables → Actions 中存为 AGENT_PAT
🤔 用 PAT 不会也造成循环调用吗?

这是理解 PAT 技巧最关键的问题。答案是:会,如果你设计不当。但 sandcastle 有三道防线防止死循环:

防线机制位置
1. 标签状态机 每一步的标签不同agent:implementagent:reviewagent:review-done。链条走到头就停了——每个标签只触发一个特定工作流,不会往回指。 第 2 课
2. concurrency 同一个 Issue/PR 同时只允许跑一个工作流实例。即使有人手滑加了不该加的标签,也不会出现两个实现同时跑。 第 2 课
3. 单向设计 链条是单向的:implement → review → 结束。没有箭头往回指。每一个后续标签都不会触发前一个工作流。 架构设计

换句话说:GITHUB_TOKEN自动挡——安全但跑不起来(链式触发被抑制);PAT手动挡——能跑但需要你自己会踩刹车。sandcastle 用标签状态机 + concurrency + 单向设计三把刹车确保安全。

工作流内部通信:outputsneeds

在进入模式 2 之前,需要理解同一个工作流中不同 job 之间如何传递数据。GitHub Actions 的 job 默认是独立运行的(可能在不同虚拟机上),它们通过两个机制通信:

机制作用
jobs.<job>.outputs job 的输出声明。值来自该 job 中某个 step 通过 echo "key=value" >> "$GITHUB_OUTPUT" 写入的内容($GITHUB_OUTPUT 的概念见第 2 课运行环境概念)。
jobs.<job>.needs 声明"我需要等某个 job 完成才能运行"。同时也让当前 job 能通过 needs.<job>.outputs.<key> 读取那个 job 的输出。

简单说:预检 job 通过 $GITHUB_OUTPUT 写入结论 → 执行 job 通过 needs.preflight.outputs.proceed 读取 → 决定是否继续。这样就实现了"先检查,再执行"。

📎 补充:Job 的执行顺序

GitHub Actions 的 job 默认并行执行,不保证谁先完成。只有加了 needs 才会建立依赖关系:

写法行为
没写 needs所有 job 同时启动,互不等待
needs: preflight等 preflight 完成才启动
needs: [lint, test]等 lint test 都完成才启动

本课的预检模式利用的就是这个机制:implement job 的 needs: preflight 不仅保证顺序,还让它可以读取 needs.preflight.outputs.proceed。而且当 preflight 输出 proceed=false 时,implement job 根本不会启动——不在 if 判断快,而在虚拟机上连环境都没创建就被跳过了。

模式 2:预检守卫 — 行动前三思

问题

Implement 工作流需要数十分钟的 AI 代理时间和 API 费用。如果 Issue 根本不适合自动化实现(例如,它是一个讨论帖,或者已经有人在处理了),在运行前拒绝可以节省大量资源。

解决方案:在运行昂贵任务前验证条件

sandcastle 的 agent-implement.yml 在执行 AI 代理之前运行了三个预检步骤:

jobs:
  preflight:
    if: github.event.label.name == 'agent:implement'
    runs-on: ubuntu-latest
    outputs:
      proceed: ${{ steps.check_pr.outputs.proceed }}
      reason: ${{ steps.check_pr.outputs.reason }}
    steps:
      # 预检 1: 检查是否存在已有的 PR
      - name: "Check for existing PR"
        id: check_pr
        run: |
          PRS=$(gh pr list --repo "$REPO" --state open \
            --search "Closes #$ISSUE_NUMBER" --json number -q length)
          if [ "$PRS" -gt 0 ]; then
            echo "proceed=false" >> "$GITHUB_OUTPUT"
            echo "reason=已有开放 PR 引用了该 Issue" >> "$GITHUB_OUTPUT"
          else
            echo "proceed=true" >> "$GITHUB_OUTPUT"
          fi

  # 只有预检通过后,这个 job 才运行
  implement:
    needs: preflight
    if: needs.preflight.outputs.proceed == 'true'
    # ... 昂贵的 AI 代理任务 ...
💡 设计原则:将验证逻辑与执行逻辑分为两个独立的 job。使用 outputsneeds 在 job 之间传递决策。这样执行 job 可以简单地检查 proceed == 'true' 而不需要重复验证逻辑。

sandcastle 中的三个预检守卫:

预检检查什么拒绝原因
1. 检测 Issue 形状 Issue 是否有子 Issue?是否是子 Issue? 子 Issue 必须通过父 Issue 处理
2. 拒绝子 Issue 如果 Issue 是子 Issue "请在父 Issue #N 上添加标签"
3. 拒绝 PRD 形状 如果 Issue 有子 Issue(是 PRD) "独立 Issue 实现不支持 PRD"

补充:checkout 步骤的两个常用参数

在 sandcastle 的工作流中,你经常看到这样的 checkout 步骤:

- uses: actions/checkout@v4
  with:
    ref: ${{ github.event.pull_request.head.sha }}
    fetch-depth: 0
参数含义
ref: head.sha 指定检出哪个 commit。默认检出的是 merge commit(PR 合并到基础分支后的结果),但 AI 代理需要工作在 PR 的原始分支上。用 head.sha 直接检出 PR 分支的最新 commit。
fetch-depth: 0 获取完整的 git 历史(默认只获取最新一个 commit)。AI 代理需要完整历史来理解代码上下文和生成有意义的 commit。
💡 fetch-depth: 0 会拉太多吗?

fetch-depth 控制的是拉多少条 commit 记录,不是拉多少 MB 文件——两者没有直接关系:100 个 commit 每个改 1 行的仓库可能只有几 KB 的 git 数据;而 1 个 commit 塞了 500MB 视频的仓库,fetch-depth: 1 也照样拉 500MB。

sandcastle 敢用 fetch-depth: 0 是因为它历史短、纯文本。对于你自己的 AI 代理工作流,fetch-depth: 50 通常足够——既给了 AI 上下文理解代码风格,又不会拉无用历史。

什么时候该用 fetch-depth: 0:需要 git blamegit bisect、生成 changelog,或者仓库本身很小(<100 MB)。
什么时候不该用:大仓库(几百 MB+)、CI 只跑 lint/test/build、部署工作流只需当前代码。

模式 3:形状检测 — Issue 是哪一种?

问题

agent:implement 标签可以加在任何 Issue 上。但不同类型的 Issue 需要不同的处理方式:

如何在运行时区分它们?

解决方案:REST + GraphQL 双重检测

GitHub REST API 可以告诉你一个 Issue 是否有子 Issue,但不能告诉你它是否子 Issue。为此需要 GraphQL:

# 检测 1: Issue 是否有子 Issue?(REST API)
- name: "Detect sub-issues"
  id: detect
  run: |
    SUB_COUNT=$(gh api "repos/$REPO/issues/$N/sub_issues" \
      --jq 'length')
    echo "has_sub_issues=$([ "$SUB_COUNT" -gt 0 ] \
      && echo true || echo false)" >> "$GITHUB_OUTPUT"

# 检测 2: Issue 是否是子 Issue?(GraphQL — REST 不支持)
    PARENT=$(gh api graphql -f query='
      query($owner:String!, $repo:String!, $number:Int!) {
        repository(owner:$owner, name:$repo) {
          issue(number:$number) {
            parent { number title }
          }
        }
      }
    ' -f owner="$OWNER" -f repo="$REPO" -f number="$N" \
      --jq '.data.repository.issue.parent.number')

    echo "is_sub_issue=$([ -n "$PARENT" ] \
      && echo true || echo false)" >> "$GITHUB_OUTPUT"
⚠️ 为什么需要 GraphQL?GitHub REST API 的 Issue 端点不返回父 Issue 信息。只有 GraphQL 的 issue.parent 字段可以查询。这是 sandcastle/course-video-manager 中唯一使用 GraphQL 的地方。

有了这两个检测结果,工作流可以决定走哪个分支:

has_sub_issuesis_sub_issue形状动作
falsefalse独立 Issue✅ 运行 implement(单 Issue 模式)
truefalsePRD✅ 运行 implement-prd(逐个实现子 Issue)
true子 Issue❌ 拒绝并引导用户到父 Issue

完整架构图(单 Issue 路径)

将三个模式组合在一起,就是一个完整的工作流架构:

┌─────────────────────────────────────────────────────┐ │ 用户添加 agent:implement 标签到 Issue │ └──────────────────────┬──────────────────────────────┘ │ ┌────────────▼────────────┐ │ 阶段 1: 预检 + 形状检测 │ │ ─ 检查子 Issue / 父 Issue│ │ ─ 检查已有 PR │ │ ─ 拒绝不合格的 Issue │ └─────┬──────────┬────────┘ │ │ proceed blocked │ │ ┌──────────▼──────────┐ │ 阶段 2: 状态转换 │ │ ─ remove agent: │ │ implement │ │ ─ remove blocked │ │ + add in-progress │ └──────────┬──────────┘ │ ┌──────────▼──────────┐ │ 阶段 3: AI 代理执行 │ │ ─ 实现代码 │ │ ─ 创建分支 │ │ ─ 提交更改 │ └──────┬──────┬───────┘ │ │ ✅ 成功 ❌ 失败 │ │ ┌────────▼──┐ ┌─▼───────────┐ │ 创建 PR │ │ + blocked │ │ 用 PAT 给 │ │ + 失败评论 │ │ PR 添加 │ │ ─ in-progress│ │ agent: │ └─────────────┘ │ review │ └────┬───────┘ │ ┌────▼──────────┐ │ Review 工作流 │ │ 自动启动! │ └───────────────┘

模式 4:PRD 链式推进 — 逐个实现子 Issue

问题

一个 PRD(有子 Issue 的 Issue)被加上 agent:implement 标签时,不是只实现一个 Issue——它需要逐个实现所有子 Issue,所有子 Issue 的提交汇入同一个分支,最终产出一个 PR。怎么做到?

核心思路:不是循环,是"完成一个→触发下一个"

最容易误解的地方:它不是一个工作流跑一次循环实现所有子 Issue,而是自触发接力

第 1 次运行:实现子 Issue #43 → 关闭它 → 重新加 agent:implement 标签 → 触发第 2 次运行 第 2 次运行:发现 #43 已关闭 → 实现子 Issue #44 → 关闭它 → 重新加标签 → 触发第 3 次运行 ... 最后一次运行:发现所有子 Issue 都关闭了 → 给 PR 加 agent:review → 链结束

每次都是全新的工作流运行实例,依靠 GitHub 的标签事件驱动下一棒。

逐步跟踪一个完整链条

假设 PRD Issue #42 有 4 个子 Issue(#43~#46),你给它加了 agent:implement 标签:

┌─ 运行 #1 ─────────────────────────────────────────────┐ │ │ │ ① 形状检测: Issue #42 has_sub_issues=true │ │ → 走 Implement PRD 工作流(不是单 Issue 工作流) │ │ │ │ ② 标签转换: -agent:implement +agent:in-progress │ │ │ │ ③ 找第一个 open 子 Issue: #43 │ │ (gh api ".../issues/42/sub_issues" → 按 API │ │ 返回顺序取第一个 state=open 的) │ │ │ │ ④ 创建/复用分支: agent/prd-42-<slug> │ │ 首次运行 → 从 main 创建 │ │ 后续运行 → checkout 已有分支继续在上面提交 │ │ │ │ ⑤ AI 代理实现子 Issue #43 │ │ 提交信息: "feat: ... Part of #42" │ │ (没有 "Closes #43" — 关闭是工作流的事) │ │ │ │ ⑥ git push (普通 push,不用 --force) │ │ ← 注意:这里不 force!分支上的历史提交要保留 │ │ │ │ ⑦ 关闭子 Issue #43 │ │ gh issue close 43 -c "Done in <commit-sha>" │ │ │ │ ⑧ 检查是否已有 PR → 首次: 没有 → 创建草稿 PR │ │ 标题覆盖整个 PRD,正文含 Closes #42 │ │ ← 这个 PR 只创建一次,后续运行复用 │ │ │ │ ⑨ 检查剩余 open 子 Issue │ │ 结果: #44, #45, #46 还开着 → 还有 3 个! │ │ │ │ ⑩ 重新加标签 (用 PAT!) │ │ GH_TOKEN="$AGENT_PAT" gh issue edit 42 │ │ --add-label "agent:implement" │ │ ← 因为用 PAT,标签会触发新的工作流运行! │ │ │ │ ⑪ 收尾: -agent:in-progress │ │ │ └────────────────────────────────────────────────────────┘ │ ▼ (GitHub 触发新运行) ┌─ 运行 #2 ─────────────────────────────────────────────┐ │ ③ 找第一个 open 子 Issue: #43→已关闭, #44→找到了! │ │ ④ 分支已存在 → checkout 复用 │ │ ⑤ AI 实现 #44,提交到同一分支 │ │ ⑦ 关闭 #44 │ │ ⑨ 检查剩余: #45, #46 还开着 → 还有! │ │ ⑩ 再加 agent:implement → 触发运行 #3 │ └────────────────────────────────────────────────────────┘ │ ▼ ... 运行 #3, #4 ... │ ▼ ┌─ 运行 #4 (最后一个子 Issue #46) ───────────────────────┐ │ ⑦ 关闭 #46 │ │ ⑨ 检查剩余 open 子 Issue: 0 个! │ │ ⑩ 不走 agent:implement 了! │ │ 改加 agent:review 到 PR → 触发 Review 工作流 │ └────────────────────────────────────────────────────────┘

四个关键设计决策

决策怎么做为什么
自触发而非内循环 每次完成一个子 Issue 后重新加标签触发下一次运行 每个子 Issue 独立运行,一个失败不影响架构;可以随时中断(不加标签就停了)
共用分支累积提交 所有子 Issue 的 commit 都在同一分支上 一个 PRD = 一个 PR,便于人类审查整个功能
普通 push,不 force PRD 工作流用 git push(不像单 Issue 用 --force 每次运行追加 commit,绝不能覆盖之前子 Issue 的提交
关闭子 Issue 是工作流的事 AI 代理的 commit 信息只写 Part of #42,不写 Closes #43 分离职责:AI 只管写代码,工作流管理系统状态

和单 Issue 模式的关键区别

单 IssuePRD
分支agent/issue-<n>-<slug>agent/prd-<n>-<slug>
push 方式--force(每次全新)普通 push(累积)
PR 创建每次新建首次创建,后续复用
链式触发agent:review(1 跳)agent:implement(N-1 次自循环)→ agent:review
关闭 IssuePR 的 Closes #N 自动关工作流手动 gh issue close 逐个关子 Issue
🔗 一句话:PRD 链式推进 = "每次跑步只吃一个子 Issue,吃完后按一下起跑按钮(重新加标签),直到跑道清空"。不是一口气跑完全程,而是用标签事件接力
⚠️ 注意:sandcastle 当前只拒绝 PRD 形状的 Issue(提示用户走 PRD 工作流),完整的 PRD 链式推进实现在 course-video-manager 的 AFK Agent Platform Spec 中。但理解这个模式对你设计自己的工作流至关重要——当你需要处理多子 Issue 的需求时,这种自触发接力模式是最健壮的方案。

✏️ 诊断性测验

1. 为什么用 GITHUB_TOKEN 添加标签不会触发下游工作流?

A. GITHUB_TOKEN 没有操作标签的权限
B. 标签事件只对人类操作有效
C. GitHub 会抑制 GITHUB_TOKEN 产生的事件以防止递归工作流
D. GitHub Actions 不支持工作流间的链式触发

2. sandcastle 在 Implement 工作流中为什么需要 GraphQL?

A. REST API 已废弃,GraphQL 是唯一选择
B. REST API 不返回父 Issue 信息,只有 GraphQL 可以查询 issue.parent
C. GraphQL 比 REST 更快
D. 为了在移动端也能查询 Issue 信息

3. 预检步骤为什么应该放在独立的 job 中(而非任务的第一个 step)?

A. GitHub Actions 要求每个 step 都是独立 job
B. 为了让代码看起来更专业
C. 预检失败时,执行 job 根本不会启动,节省 Action 配额和等待时间
D. 为了让多个预检 job 并行运行


🎯 总结:你学到了什么

三个课程覆盖了 sandcastle 和 course-video-manager 中所有关键工作流模式:

  1. 第 1 课on: issues: types: [labeled] + if: 门控 = 标签触发的基本模式
  2. 第 2 课:trigger → in-progress → done | blocked = 标签状态机
  3. 第 3 课(本课):PAT 链式触发 + 预检守卫 + 形状检测 + PRD 链式推进 = 生产级健壮性

本课涵盖的四个高级模式:

模式解决什么问题核心机制
1. PAT 技巧 工作流之间如何链式触发 PAT 代表真人身份,绕过 GitHub 递归抑制
2. 预检守卫 如何在运行昂贵任务前验证条件 独立 preflight job + outputs/needs 传递决策
3. 形状检测 区分独立 Issue / 子 Issue / PRD REST (查子 Issue) + GraphQL (查父 Issue) 双重检测
4. PRD 链式推进 多个子 Issue 如何逐个自动实现 自触发标签接力:完成一个 → 重新加标签 → 触发下一个

现在你已经具备了在自己的仓库中实现类似系统的所有知识。从简单开始(第 1 课的工作流),逐步添加状态机(第 2 课),最后根据需要引入高级模式(第 3 课)。

🚀 下一步:你不需要一次实现所有模式。sandcastle 本身也是迭代构建的。建议的路线图:
  1. 部署基本标签触发 + 状态机(第 1-2 课)
  2. 添加必要的预检验证
  3. 当需要工作流链时,引入 PAT 技巧
  4. 当 Issue 类型多样化时,引入形状检测
  5. 当需要处理多子 Issue 的 PRD 时,引入链式推进模式

推荐进一步阅读


← 第 2 课:标签状态机 第 3 课 / 共 3 课

💬 有问题?高级模式是建立在前两课基础之上的。如果你对 PAT 技巧、预检守卫、形状检测或 PRD 链式推进有任何疑问,直接向你的 AI 导师提问。