📖 第二课:标签状态机

预计完成时间:15 分钟  |  难度:中级  |  类型:知识 + 技能

📌 前置要求:本课假设你已经完成了 第 1 课,理解了基本的标签触发模式。

本课目标

在本课结束时,你将能够:


问题:只有一个标签不够

在第 1 课中,我们的工作流只是移除了触发标签。这在工作流 总是成功 的情况下没问题——但现实中不是这样。

假设你给 Issue 添加了 agent:implement,工作流开始运行,然后由于 API 配额耗尽而失败了。标签已经被移除了。你怎么知道它失败了?你怎么重试?

sandcastle 的答案:使用一组标签作为状态机


核心概念:四个标签,四个状态

标签 含义 类比
agent:implement 触发标签:用户说"请实现这个 Issue" 命令/请求
agent:in-progress 运行中标签:工作流正在执行,充当互斥锁 锁/信号量
agent:blocked 阻塞标签:工作流失败了,需要人工干预 错误标志
(无特殊标签) 完成状态:工作流成功完成,一切清理干净 就绪/空闲
💡 关键洞见:agent:in-progress 起着双重作用。它不仅显示工作流正在运行,它还阻止了同一个 Issue 被意外地重新触发——因为你一眼就能看到有东西正在运行。

状态转换图

这就是 sandcastle 和 course-video-manager 中每一个代理工作流遵循的精确状态机:

┌──────────────────────────┐ │ 用户添加 agent:implement │ └────────────┬─────────────┘ │ ┌────────────▼─────────────┐ │ 工作流检测到标签 │ │ if: label == │ │ 'agent:implement' │ └────────────┬─────────────┘ │ ┌────────────▼─────────────┐ │ START 转换: │ │ ─ remove agent:implement │ │ ─ remove agent:blocked │ │ + add agent:in-progress │ └────────────┬─────────────┘ │ ┌────────────▼─────────────┐ │ 执行任务 │ │ (AI 代理写代码...) │ └──────┬──────────┬────────┘ │ │ ✅ 成功 ❌ 失败 │ │ ┌────────────▼──┐ ┌────▼────────────────┐ │ DONE 转换: │ │ FAIL 转换: │ │ ─ remove │ │ + add agent:blocked │ │ in-progress │ │ (失败原因评论) │ └───────┬───────┘ └───────────┬──────────┘ │ │ ┌───────▼───────┐ ┌─────────▼──────────┐ │ │ │ 用户调查失败原因 │ │ 完成! ✅ │ │ 修复后重新添加 │ │ │ │ agent:implement │ └──────────────┘ └─────────┬──────────┘ │ 回到顶部 🔄

重试路径是关键:当工作流失败时,它添加 agent:blocked。用户调查问题,修复根本原因,然后重新添加 agent:implement 来重试。START 转换会移除 agent:blocked(如果存在),所以即使有遗留的阻塞标签,重试也能正常工作。


动手实践:完整的状态机工作流

下面是第 1 课中简单工作流的升级版,实现了完整的状态机。在你的仓库中替换或更新 .github/workflows/agent-hello.yml

📄 .github/workflows/agent-hello.yml(完整版)
name: "Agent: Hello (with state machine)"
on:
  issues:
    types: [labeled]

jobs:
  hello:
    if: github.event.label.name == 'agent:hello'
    runs-on: ubuntu-latest
    permissions:
      issues: write
    env:
      GH_REPO: ${{ github.repository }}  # gh CLI 需要,参见第 1 课
    concurrency:
      group: agent-hello-issue-${{ github.event.issue.number }}
      cancel-in-progress: false

    steps:
      # ═══ 阶段 1: START 转换 ═══
      - name: "Transition → in-progress"
        run: |
          gh issue edit "$N" --remove-label "agent:hello" || true
          gh issue edit "$N" --remove-label "agent:blocked" || true
          gh issue edit "$N" --add-label "agent:in-progress"
        env:
          N: ${{ github.event.issue.number }}
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

      # ═══ 阶段 2: 执行任务 ═══
      - name: "Do the work"
        id: work
        run: |
          echo "正在为 Issue #$N 执行任务..."
          sleep 3

          # 模拟:有时任务会失败
          if [ "$RANDOM_FAIL" = "true" ]; then
            echo "模拟失败!"
            exit 1
          fi

          # 发表成功评论(写入文件以避免 shell 转义问题)
          cat <<EOF > "$RUNNER_TEMP/comment.md"
          ✅ 任务成功完成!

          - Issue 标题:**$ISSUE_TITLE**
          - 完成时间:$(date -u '+%Y-%m-%d %H:%M:%S UTC')

          感谢使用 agent:hello 命令!
          EOF

          gh issue comment "$N" --body-file "$RUNNER_TEMP/comment.md"
        env:
          N: ${{ github.event.issue.number }}
          ISSUE_TITLE: ${{ github.event.issue.title }}
          RANDOM_FAIL: "false"  # 改为 "true" 测试失败路径
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

      # ═══ 阶段 3a: FAIL 转换 ═══
      - name: "Transition → blocked (on failure)"
        if: failure() && steps.work.outcome == 'failure'
        run: |
          gh issue edit "$N" --add-label "agent:blocked"

          cat <<EOF | gh issue comment "$N" --body-file -
          ❌ 任务执行失败。

          已添加 **agent:blocked** 标签。请调查失败原因(检查 Actions 日志),修复后重新添加 \`agent:hello\` 标签来重试。
          EOF
        env:
          N: ${{ github.event.issue.number }}
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

      # ═══ 阶段 3b: ALWAYS 清理 ═══
      - name: "Transition → done (always)"
        if: always()
        run: |
          gh issue edit "$N" --remove-label "agent:in-progress" || true
        env:
          N: ${{ github.event.issue.number }}
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

三个重要的运行环境概念

在分析新代码之前,先了解三个工作流中频繁出现的概念:

概念说明
${{ github.workspace }} GitHub Actions 自动提供的工作目录绝对路径。这是 actions/checkout 检出源码后仓库文件所在的位置,也是 run 步骤的默认工作目录。你的仓库代码在这里。
$RUNNER_TEMP GitHub Actions 自动提供的临时目录路径。工作流结束后自动清理。用于存放中间文件(如要发表的评论内容)。不用手动创建或删除。
$GITHUB_OUTPUT 一个特殊文件路径。用 echo "key=value" >> "$GITHUB_OUTPUT" 写入后,后续步骤可以用 steps.<id>.outputs.key 读取该值。(本课暂未使用,第 3 课会用到)
🔍 区分 github.workspaceRUNNER_TEMP它们是两个完全不同的目录: 课程中用 $RUNNER_TEMP/comment.md 写评论内容,正是因为那条评论只是中间产物,不需要保留在仓库里。

逐步解析:新增的部分

新元素作用
concurrency: group: ... 确保同一个 Issue 不会同时运行两个工作流实例。如果工作流正在运行,新的触发会被排队等待
--remove-label "agent:blocked" 清理上一次失败的阻塞标签,使得重试成为可能
if: success() 仅在前面的所有步骤都成功时运行。这是默认行为——不写 if: 等价于 if: success()
if: failure() GitHub Actions 的状态检查函数:仅在前面的步骤失败时运行
if: always() 无论前面步骤成功还是失败,都运行。用于必须执行的清理操作
if: cancelled() 仅在工作流被手动取消时运行。极少单独使用,但配合 always() 可以做取消后清理
steps.work.outcome == 'failure' 精确检查哪个步骤失败。当有多个步骤可能失败但你只想在某些失败时添加 blocked 标签时很有用
<<EOF ... EOF heredoc 语法:将多行文本通过管道传给 gh,避免 shell 变量转义的麻烦
if [ "$VAR" = "value" ]; then shell 的条件判断(不是 GitHub Actions 的 if:!)。[ ] 是 shell 的 test 命令,方括号内侧必须有空格
🔍 steps.work.outcome 从哪里来?不由你手动设置——GitHub Actions 运行器在每个步骤结束后自动记录结果。整个过程是: 你只需给步骤一个 id:(如 id: work),之后就能用 steps.work.outcome 读取它。GitHub Actions 把几十种非零退出码统一成几个语义清晰的字符串——编排层关心的是状态语义,而不是具体退出码数字。

还有一个相关字段 steps.<id>.conclusion:它受 continue-on-error 影响——如果步骤失败但设置了 continue-on-error: trueoutcomefailure,但 conclusion 会是 success
🔍 深入理解 concurrency两个参数配合控制并发行为: 课程选择 cancel-in-progress: false 是为了保护标签状态机的原子转换——如果旧实例刚把标签从 agent:hello 换成 agent:in-progress 就被杀死,标签状态会永远卡住。排队等待更安全。
⚠️ 重要:if: always() 不可或缺。如果没有它,当任务步骤失败时,GitHub Actions 默认会跳过所有后续步骤。这意味着 agent:in-progress 会永远留在 Issue 上,后续的标签触发也不会工作。使用 always() 确保清理步骤总是执行。

对比:两个仓库如何使用状态机

sandcastle 和 course-video-manager 中的每个代理工作流都遵循这个模式。以下是它们的标签使用对比:

工作流触发标签成功后动作
Explore agent:explore 移除 in-progress,Issue 回到空闲状态
Implement (Issue) agent:implement 移除 in-progress,创建 PR,给 PR 添加 agent:review
Review (PR) agent:review 移除 in-progress,发布审核结果,标记 PR 为 ready
Implement PR agent:implement 移除 in-progress,发布代码修改和评论
Update Branch agent:update-branch 移除 in-progress,推送合并结果

注意 Implement 工作流的特殊行为:它在成功后链式触发了下一个工作流(给新 PR 添加 agent:review),这需要用到下一课要讲的 PAT 技巧。


✏️ 诊断性测验

1. 如果 START 转换步骤没有移除 agent:blocked,会发生什么问题?

A. 工作流会立即失败,因为 detected duplicate label
B. 重试时 blocked 标签会一直留在 Issue 上,无法区分新旧失败
C. GitHub 会自动移除多余的标签
D. 不会有问题,blocked 标签会被自动覆盖

2. 为什么清理步骤必须使用 if: always()

A. 因为 GitHub Actions 要求所有步骤都使用条件
B. 因为如果任务失败,GitHub 默认跳过后续步骤,in-progress 标签会永远留在 Issue 上
C. 为了让工作流运行得更快
D. 因为 always() 是 GitHub Actions 的默认行为

3. concurrency 配置中 cancel-in-progress: false 的作用是什么?

A. 允许同时运行多个实例以提高速度
B. 禁止任何并发
C. 当新触发到来时,不取消正在运行的实例,而是排队等待
D. 自动取消运行超过 30 分钟的工作流

4. 不写 if: 条件的步骤,默认行为等价于什么?

A. if: success() — 只有前面步骤都成功才执行
B. if: always() — 无论前面步骤成功与否都执行
C. if: failure() — 只有前面步骤失败才执行
D. if: none — 该步骤无条件跳过

5. steps.work.outcome 的值 'failure' 是如何产生的?

A. 需要手动在工作流中设置:echo "outcome=failure" >> "$GITHUB_OUTPUT"
B. 运行器自动捕获 step 的非零退出码,将其翻译为字符串 'failure'
C. 由 gh CLI 在操作 Issue 标签后写入
D. 在 workflow_dispatch 触发时由用户传入


🧪 技能练习

🎯 任务:部署完整的状态机工作流,并故意触发失败路径来观察整个状态转换。
  1. 1 在 GitHub 网页上打开仓库 → Add fileCreate new file → 文件名填 .github/workflows/agent-hello.yml → 粘贴上面的完整代码 → Commit new file
  2. 2 创建标签:agent:helloagent:in-progressagent:blocked
  3. 3 测试成功路径:创建 Issue,添加 agent:hello → 观察标签变化:hello 消失 → in-progress 出现 → in-progress 消失 → 收到成功评论
  4. 4 测试失败路径:打开 .github/workflows/agent-hello.yml 文件 → 点编辑按钮 → 把 RANDOM_FAIL: "false" 改为 RANDOM_FAIL: "true"Commit changes → 创建新 Issue 并添加 agent:hello → 观察 blocked 标签出现
  5. 5 测试重试:再次编辑文件,把 RANDOM_FAIL 改回 "false",Commit → 在同一个 Issue 上重新添加 agent:hello → 观察 blocked 被清除,任务成功完成
  6. 6 测试并发保护:快速连续两次添加 agent:hello → 观察 Actions 页面中的排队行为
🎯 完成检查:当你看到 (a) 成功路径自动清理所有标签,(b) 失败路径正确添加 blocked 标签并保留失败评论,(c) 重试路径清除了 blocked 标签并重新开始——你就真正理解了标签状态机。

推荐进一步阅读


← 第 1 课:第一个标签触发工作流 第 2 课 / 共 3 课

💬 有问题?标签状态机是这两个仓库中最精妙的设计。如果你对任何部分感到困惑: 直接向你的 AI 导师提问!