预计完成时间:15 分钟 | 难度:中级 | 类型:知识 + 技能
在本课结束时,你将能够:
success() / failure() / always() 条件函数优雅地处理成功和失败路径concurrency 并发控制:group 和 cancel-in-progress 的配合在第 1 课中,我们的工作流只是移除了触发标签。这在工作流 总是成功 的情况下没问题——但现实中不是这样。
假设你给 Issue 添加了 agent:implement,工作流开始运行,然后由于 API 配额耗尽而失败了。标签已经被移除了。你怎么知道它失败了?你怎么重试?
sandcastle 的答案:使用一组标签作为状态机。
| 标签 | 含义 | 类比 |
|---|---|---|
| agent:implement | 触发标签:用户说"请实现这个 Issue" | 命令/请求 |
| agent:in-progress | 运行中标签:工作流正在执行,充当互斥锁 | 锁/信号量 |
| agent:blocked | 阻塞标签:工作流失败了,需要人工干预 | 错误标志 |
| (无特殊标签) | 完成状态:工作流成功完成,一切清理干净 | 就绪/空闲 |
agent:in-progress 起着双重作用。它不仅显示工作流正在运行,它还阻止了同一个 Issue 被意外地重新触发——因为你一眼就能看到有东西正在运行。
这就是 sandcastle 和 course-video-manager 中每一个代理工作流遵循的精确状态机:
重试路径是关键:当工作流失败时,它添加 agent:blocked。用户调查问题,修复根本原因,然后重新添加 agent:implement 来重试。START 转换会移除 agent:blocked(如果存在),所以即使有遗留的阻塞标签,重试也能正常工作。
下面是第 1 课中简单工作流的升级版,实现了完整的状态机。在你的仓库中替换或更新 .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.workspace 和 RUNNER_TEMP:它们是两个完全不同的目录:
github.workspace → 源码目录,checkout 后仓库文件在这里,run 步骤默认在此执行RUNNER_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 运行器在每个步骤结束后自动记录结果。整个过程是:
exit 0(数字退出码)→ 运行器捕获 → 翻译成 success(字符串)exit 1(或任何非零值)→ 运行器捕获 → 翻译成 failure(字符串)if: 条件不满足被跳过 → skippedcancelledid:(如 id: work),之后就能用 steps.work.outcome 读取它。GitHub Actions 把几十种非零退出码统一成几个语义清晰的字符串——编排层关心的是状态语义,而不是具体退出码数字。
steps.<id>.conclusion:它受 continue-on-error 影响——如果步骤失败但设置了 continue-on-error: true,outcome 是 failure,但 conclusion 会是 success。
concurrency:两个参数配合控制并发行为:
group — 定义"谁和谁竞争"。同一个 group 值下同时只能有一个实例运行。课程中 group: agent-hello-issue-${{ github.event.issue.number }} 意味着"以 Issue 编号分组":Issue #5 不能同时跑两个 agent:hello 工作流,但 Issue #7 不受影响,可以并行跑。cancel-in-progress — 决定竞争发生时如何处理旧实例:true = 立刻杀死正在运行的旧实例,让新实例开始;false(课程用法)= 旧实例继续跑完,新实例排队等待。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,会发生什么问题?
2. 为什么清理步骤必须使用 if: always()?
3. concurrency 配置中 cancel-in-progress: false 的作用是什么?
4. 不写 if: 条件的步骤,默认行为等价于什么?
5. steps.work.outcome 的值 'failure' 是如何产生的?
.github/workflows/agent-hello.yml → 粘贴上面的完整代码 → Commit new fileagent:hello → 观察标签变化:hello 消失 → in-progress 出现 → in-progress 消失 → 收到成功评论.github/workflows/agent-hello.yml 文件 → 点编辑按钮 → 把 RANDOM_FAIL: "false" 改为 RANDOM_FAIL: "true" → Commit changes → 创建新 Issue 并添加 agent:hello → 观察 blocked 标签出现RANDOM_FAIL 改回 "false",Commit → 在同一个 Issue 上重新添加 agent:hello → 观察 blocked 被清除,任务成功完成agent:hello → 观察 Actions 页面中的排队行为steps.<id>.outcome 的官方定义