Linear、GitHub Issues 等工具对 AI Agent 的支持程度差异巨大:GitHub 是扁平的开关状态,Linear 是严格的状态机模型,Agent 写入时必须理解每个平台的隐含规则。
Agents 现在会向工作追踪器写入数据,而这些追踪器已开始将它们建模为一等公民。Linear 的 GraphQL schema 中有 AgentSession implements Node 和 AgentActivity implements Node —— 不是在旁边强行挂接的 webhook,而是与 Issue 和 User 并列的实体。
写入才是有趣的部分,因为追踪器不是带有 status 列的数据库。它是一个有自己主张的状态机,而且每个供应商的主张都不一样。
以下三个打破我认知的例子,按我错得有多离谱排序。
这个最简单。GitHub issue 是扁平的、二元的 —— open 或 closed。没有 epic,没有 parent,没有 workflow。如果你的流程中有"这个 story 属于那个 epic",GitHub 没有任何东西可以映射它。
你可以用 labels 来模拟。这可行。重要的是你这样做之后会发生什么:你发明了一个在提供商那边根本不存在的约定,任何不知道这个约定的读取者看到的只是一个带有奇怪 label 的扁平列表。
这是一个不错的权衡。但悄无声息地这样做就不对了。
Linear 有 WorkflowState 带 type 字段,你合理地预期 type 就是词汇表。下面是 schema 自己对实体的描述:
团队 workflow 中的一个状态,代表 issue 的状态,如 Triage、Backlog、Todo、In Review、Done 或 Canceled。[…] Workflow states 有一个 type 来分类它们(triage、backlog、unstarted、started、completed、canceled)。
把这两个列表对照着看。"In Review" 出现在第一个列表中 —— 这是团队真正有的状态 —— 但在第二个列表中完全没有。Review 不是一个 type;它是一个恰好被 typed 为 started 的状态,和"In Progress"一模一样。所以你无法把一个抽象的"in review"映射到一个状态 type。你必须按 ID 映射到特定的状态,且每个团队各不相同。
(schema 自己在这方面有矛盾,顺便说一句。实体描述包含 triage;但 type 字段本身的描述只列了 backlog、unstarted、started、completed、canceled。不管哪个是对的,这不是你想 hardcode 的词汇表。)
WorkflowState.type 是 String!,不是 enum。它没有版本控制,提供商可以扩展它。今天你 switch 的任何东西都是对未来的猜测。
WorkflowState.team 是 Team! —— 非空。Workflow 属于团队,所以同一 workspace 中的两个团队对同一个概念步骤可能有不同的状态,单个扁平映射无法表示这种情况。
而且 triage 在大多数抽象模型中根本没有等价物。当一个 issue 在 triage 中时,你的 agent 应该做什么?
这个打破了我的设计。
我假设一个工作项有一个 status,向它写入意味着设置一个字段。在 Jira 中不是这样。你必须先调用:
GET /rest/api/3/issue/{issueIdOrKey}/transitions
这会返回当前允许的 transitions —— 取决于项目的 workflow、issue 类型和项的当前状态。然后你 post transition id。而这个 transition 可能附加了一个 screen,这种情况下它会要求必填字段才会通过。
所以"移动到 in progress"不是一次写入。它是:发现可能的选项、将你的意图与之匹配、提供 transition screen 要求的任何内容、然后执行。一个不这样做的 agent 不会干净地失败 —— 它在半完成后才失败。
还有第二个陷阱。Jira 的 statusCategory 看起来像是规范词汇表,它恰好有三个值:TODO、IN_PROGRESS、DONE。如果你的模型有"ready"或"in review"或"blocked",它们都无法从中派生。你需要每个项目的 status 映射,或者放弃这些概念。
常见的 workaround 是把供应商自己的状态名放到 prompt 中。下面是 OpenAI 的 Symphony —— 一个针对追踪器运行编码 agent 的编排器 —— 在其参考 workflow 中:
update_issue(..., state: "In Progress")
它的规范明确说明这是设计决策。§11.5,"Tracker Writes and Agent Tools":
Symphony 不需要在编排器中有一等 tracker 写入 API。Ticket 变更(状态转换、评论、附件、PR 元数据)通常由编码 agent 通过所选适配器的提供商原生工具处理。
它的两个必需适配器函数都是读取。写入留给 agent。
对于编排器来说这是一个合理的边界。但它把问题推到了 prompt 层,而包含"In Progress"的 prompt 在有人重命名列时、在第二个项目使用不同 workflow 时、或者在你迁移追踪器时就会坏掉。这也是一个 agent 很可能 hallucinate 一个似是而非变体的字符串。
三个部分,第二个是人们会跳过的。
用角色说话,不用状态。一个小的抽象词汇表 —— backlog → ready → in_progress → in_review → done,加 blocked —— prompts 和 workflows 都基于这个词汇表。这没什么新鲜的;每个集成层都有某个版本的这个东西。
Resolve 映射一次,与人一起确认,然后提交。这才是重要的部分。我见过的每个运行时方法都是让模型在每次调用时发现有效 transitions 并选一个,这意味着答案可能因为没人能看到的原因在两次运行之间不同。相反:在设置时自省提供商的真实状态,提出一个映射,让人类确认它,然后把结果作为配置提交到 repo。现在映射是可审查的、可 diff 的、每次运行都相同的。错误的映射变成一条 PR 评论而不是一个谜。
声明差距并大声协商。对于每个提供商缺少的能力,明确选择一种行为:大声失败、用某种方式模拟(通过 labels 实现 GitHub 层级)、或者带警告降级。我对自己的规则是:既没有报错也没有记录的差距就是一个 bug。这就是防止抽象说谎的方式 —— 你被告知什么时候离开了铺好的路,而不是三周后从一张支持工单中发现它。
我有六个 workflow 角色,blocked 是其中之一。代码上方的文档说 blocked(正交)—— 我写的时候知道,blocked 不是工作阶段而是附加在某个阶段上的条件。代码不同意这个注释。把一个项转换到 blocked 会摧毁它拥有的任何角色,所以"blocked while in review"是无法表示的。
Linear 在我刚读过的同一个 schema 中公开说了这一点:IssueRelationType 是 blocks、duplicate、related。Blocking 是一个关系,躺在 workflow states 旁边而不是里面。Jira 和 GitLab 也同意。这三个都会打破我的设计,而修复 —— 把它变成一个关系而不是状态 —— 在零用户时几乎免费,在一百个用户时就很贵了。
如果有什么一般性教训:一个针对两个提供商设计的抽象不是抽象,是平均。我针对 Azure DevOps 和 GitHub 设计了我的,它们彼此确实不同,但它仍然在接触第三个提供商时没有存活下来。
这篇文章来自构建 Baron 的过程,Baron 是一个开源层,实现上述功能 —— 从编码 agent 到工作追踪器的标准化写入,角色映射提交到你的 repo。它还很早期,我更想要 bug 报告而不是 stars。