读完这篇你能:知道一条外部事件从进门到变成一个会话,中间经过哪三个角色;明白它为什么不是消息队列,以及”重复交付开出重复会话”意味着你要自己做什么。
前置:29-接入 MCP 服务。约 30 分钟。
示例代码:dsh-webhook-lab
DSH 版本:0.2.0-rc.2
29-接入 MCP 服务是”你问它答”:模型要用了才去调工具。这一篇讲反过来——外面发生了什么,让它自己触发一次分析。
PR 被打开了、告警响了、工单状态变了。这些事件不需要有人坐在那里问,它们应该自己变成一个会话。
目标
- 分清 webhook 这条缝的三个角色各拥有什么。
- 理解 fire-and-forget 分发意味着哪些能力不存在。
- 会写一条规则,并知道它要自己负责哪两件事。
- 知道一次会话创建按什么顺序发生、中途失败会怎样。
DSH 设计
webhook(网络钩子)是一种常见约定:外部系统出了事——代码仓库来了个新 issue、监控告警响了——它主动向你暴露的一个网址 POST 一份数据。DSH 把「收到这样一份数据」到「开出一个会话去处理它」之间的路切成三个角色。这里的会话(session)指一段有持久记录、由模型持续推进的对话。
先看角色怎么分。三个角色的划分首先是信任边界,其次才是功能分工。适配器(adapter)面对公网投递,拥有身份验证:先确认数据包没被中间人改过(验签),再解析内容。规则(rule)是受信任的程序化代码,跑在自己的进程里,拿到一份已验证的数据后决定「这条要不要管」,严格程度由写规则的人自己负责。运行时(ctx.webhookRuntime)拥有回调的生命周期与会话创建,是唯一被允许把一份投递变成会话的角色。这条边界的好处直接可见:新接一个事件来源只需要新写一个适配器,运行时和已有规则都不用动。
再看分发方式。它是 fire-and-forget(发了不等),这是刻意的取舍:这是一条投递通道,不是工作流引擎。运行时分发一份投递时,先快照当下匹配的规则,再各自独立调度,并在任何回调结束之前就返回。它没有队列、重试、去重、执行状态、崩溃重放,也没有完成结果——这些能力每一样都意味着持久化投递、定义重放语义、回答「处理完没有」,每一样都是另一个子系统的体量。代价直接写在账上:同一份投递重复到达,可能开出重复会话;想幂等、想重试、想知道办完没有,都得在规则里自己做。比如防重,最常见的写法是规则自己记一笔账:
const seen = new Set()
// 规则的 run 里:见过的交付直接说「不管」
if (seen.has(delivery.deliveryId)) return null
seen.add(delivery.deliveryId)
运行时唯一的活动账本是一份随进程消失的私有记账。进程一重启,「正在处理中」这回事就没有了。
最后看分发之后的事。会话创建完全复用普通会话语义,因为特殊化没有收益。规则说「要管」之后,运行时建出来的就是一个普通根会话:普通的工作目录、普通的持久化、普通的生命周期,和人在界面里开的会话同款。触发它的第一条消息也只是一条普通的用户角色消息,只是额外带着「来自 webhook」的来源信息。消息被会话收件箱接纳的那一刻就算提交,运行时不做特殊刷写、不等待对话轮次,之后的一切都走普通会话的持久化与生命周期。这条缝因此一行自己的会话管理代码都不用写。
把信任边界放在角色之间、把「投递之后的事」推回给普通会话——这条缝因此能保持只有「登记规则」和「分发投递」两个方法的体量。
三个角色
webhook 子系统做的一件事是:把已通过身份验证的外部交付,转换成可选的普通根会话。
“可选的”是关键——交付进来不代表一定要开会话,这中间有一道判断。
三个角色,边界划得很清楚:
| 角色 | 拥有什么 |
|---|---|
| 提供方适配器 | 身份验证、通用 JSON 接收 |
| 受信任的程序化规则 | 条件判断、外部调用 |
ctx.webhookRuntime |
回调生命周期、基于 Workspace 的会话创建 |
注意”受信任的程序化规则”这个措辞:规则里可以执行任意代码。这不是一个沙箱化的插件点,它是你自己写的、跑在你进程里的代码——所以条件判断的严格程度完全取决于你。
交付与规则
一份已验证的交付长这样:
interface VerifiedWebhookDelivery<K> {
readonly kind: K
readonly source: WebhookSourceId
readonly deliveryId: WebhookDeliveryId
readonly event: WebhookEventOf<K>
readonly receivedAt: number
}
WebhookRuleId、WebhookSourceId、WebhookDeliveryId 都是不透明字符串,各自有一个同名的品牌工厂函数(规则的 id 写 WebhookRuleId('...'),不能直接给裸字符串)。关于交付 id 有一句必须知道的定义:
交付 id 仅用于来源信息:runtime 既不存储也不对它去重。
也就是说这个 id 是给你做溯源用的,不是给你做幂等的。后面还会回到这一点。
WebhookEventMap 可以按提供方种类合并扩展,WebhookEventOf<K> 会挑出已知的提供方事件、否则接纳通用无损 JSON——这个设计有一个直接好处:树外的适配器不需要修改 runtime 包。你自己写一个内部系统的适配器,不用去动 DSH 的代码。
规则本身只有三个字段:
interface WebhookRule<K> {
readonly id: WebhookRuleId
readonly kind: K
run(
delivery: Readonly<VerifiedWebhookDelivery<K>>,
signal: AbortSignal,
): WebhookSessionRequest | null | Promise<WebhookSessionRequest | null>
}
run 返回 null 表示”这条交付我不管”;返回一个 WebhookSessionRequest 才是”请开个会话去处理它”;它也可以是 async 的。
有一句关于 signal 的话要划下来:异步工作如果应该在注册卸载时停止,规则就必须自己观察 signal。runtime 提供了这个信号,但不会替你检查——规则里那些 await 之后如果不看 signal.aborted,卸载之后它们还会继续跑。
分发:fire-and-forget
这是这条缝最容易被误读的地方。
dispatch() 做三件事:快照匹配的规则、彼此独立地调度每一个、在任何回调结算之前返回。
第一条”快照”意味着规则列表在分发那一刻就定下来了。第三条是字面意思:调用方拿到控制权时,回调还在跑。
然后是那份”没有”清单,值得逐条读:
runtime 没有队列、重试、去重、执行状态、崩溃重放、Agent 状态监听器或完成结果。
以及它的直接后果:
重复交付可能创建重复 Session。
这两句合起来定义了这个子系统的性格:它是一条投递通道,不是一套工作流引擎。想要”同一条交付只处理一次”,得自己在规则里做幂等(比如按业务 id 落一个标记);想要”处理失败后重试”,得自己在规则里重试;想要”知道处理完了没”,得自己找个地方记。
唯一的活动账本是私有的 teardown 记账,而且随进程消失——进程一重启,那些”正在处理中”的信息就没有了。
失败处理有一个细节值得记:抛出与拒绝按规则分别被包含。一条规则炸了,不会影响同一批里别的规则。这是对的——一批交付里某一条处理不了,不该牵连其他的。
注销这边做得很干净:注册的 disposer 会先移除规则,再中止并排空活动调用。顺序很重要——先摘掉,后续交付就不会再进到正在卸载的代码里;再排空,已经在跑的那几个能被收住。
会话创建
WebhookSessionRequest 要求的字段:绝对 workspacePath、标题、文本提示词、agent preset、permission preset。可选 model 用来指定明确的提供方/模型路由与输出 token 上限;省略时它会快照包含推理强度的完整当前部署选择,直到首个请求记下持久 header。
顺序上,非 null 的结果会在异步预检之前先生成快照,然后:
- 验证 permission preset 与 agent preset;
- 解析或创建规范 Workspace;
- 创建 Session(cwd 等于 Workspace 路径)的 Agent;
- 发布之前挂载所选 agent preset;
- 应用权限、标题与初始 follow-up 之前,持久附加 Session。
follow-up 是普通的持久 user-role 消息,只是带着 source.kind: "webhook" 和提供方/来源/交付/规则这些来源信息。它的 inbox 插入被接受时提交 webhook 操作。runtime 不执行特殊 flush,也不等待轮次——之后走的就是普通的会话持久化与 Agent 生命周期。
中途失败的处理分两段:
- 附加失败:在提示词出现之前释放新 Agent。
- 附加之后、提示词被接纳之前失败:尝试脱离 Workspace 并释放 Agent,而且不会取代原始错误。
还有一个容易被当成 bug 的行为:预检期间自动创建的 Workspace 会保留。理由是——另一个并发调用者可能已经在用它了。删掉一个可能正被别人使用的 Workspace,代价比留一个空的更大。
GitHub 适配器
@deepseek-ai/dsh-webhook-github 是一条现成的适配器,做法值得看一遍:
- 在注入的 WebServer 上注册精确路由;
- 每次请求解析凭据引用(不是启动时解析一次);
- 在解析之前验证 body 是未改动的
application/json——先确认东西没被中间人动过,再解析; - 在内存分发之后立即返回
202。
它的规范化事件保证是一个已签名的无损 JSON 对象。但注意下一句:规则负责验证自己消费的事件特定字段。
也就是说”已签名”回答的是”这确实是 GitHub 发来的、内容没被篡改”,不回答“这个 action 是 opened 还是 closed“、”issue.title 是不是字符串”。业务字段的校验在规则这一侧。
最后一条是个不错的工程习惯:官方评审指南把这条路由挂在一个隔离的第二个 WebServer 上,因此暴露 webhook 入口不会暴露浏览器 API。webhook 入口天生是”公网可达”的,浏览器 API 不是——两者混在同一个监听上,等于让前者替后者开门。
一条规则
demo dsh-webhook-lab 是一条”新开 issue 就去看一眼”的规则:
export function apply(ctx: Context): void {
// register 本身就是 effect:注销时会先移除规则,再中止并排空本规则的活动调用,
// 所以后续交付不会进到正在卸载的代码里。
ctx.effect(() => ctx.webhookRuntime.register({
id: RULE_ID,
kind: 'github',
run: (delivery, signal) => {
const issue = readIssueOpened(delivery)
// 返回 null 表示「这条交付我不管」,runtime 不会做任何事。
if (issue === undefined) return null
// 异步工作如果应该在注销时停下,就必须自己观察 signal。
if (signal.aborted) return null
// 非 null 的结果 = 请 runtime 开一个会话去处理它。
// agentPreset / permissionPreset 必须是这个 profile 里真实注册过的 id。
return {
workspacePath: process.cwd(),
title: `Issue #${issue.number}: ${issue.title}`,
prompt: `仓库里新开了一个 issue:${issue.title}。去看一眼相关代码,给出处理建议。`,
agentPreset: 'standard',
permissionPreset: 'read-only',
}
},
}), 'dsh-webhook-lab: rule')
}
真正的判断在 readIssueOpened 里——这就是”规则负责验证自己消费的事件特定字段”:
function readIssueOpened(delivery: { event: unknown }): { number: number, title: string } | undefined {
// GitHub 适配器把事件规范化成 { name, payload }:name 是 X-GitHub-Event 的名字,
// payload 是验签后的原始 JSON 对象。具体字段的校验是规则的活。
const { name, payload } = delivery.event as {
name?: unknown
payload?: { action?: unknown, issue?: { number?: unknown, title?: unknown } }
}
// 规则负责校验自己消费的事件特定字段——runtime 只保证它是无损 JSON。
if (name !== 'issues') return undefined
if (payload === undefined || payload.action !== 'opened') return undefined
if (typeof payload.issue?.number !== 'number' || typeof payload.issue.title !== 'string') return undefined
// 标题是人写的,掏空它是为了只留一句可读的提示词素材。
const title = payload.issue.title.trim()
if (title === '') return undefined
return { number: payload.issue.number, title }
}
这几层判断各拦一类东西:name 与 action 拦”不是我们要的事件”,类型检查拦”字段形状不对”,trim() 后判空拦”标题是空白”——最后一层看起来多余,但它是唯一能避免开出一个标题为空的会话的地方。
验证
cd code/dsh_plugin/dsh-webhook-lab && ./install.sh
三步,第三步专门验证”它不是队列”:
# 1. 配置对不对(三行:runtime、github 适配器、规则)
dsh --profile test --dump-config | grep -A3 'id: webhook-lab'
# 2. 起一次 web,向 webhook 路由投一份已签名的 issue opened 交付
# 适配器要求 x-hub-signature-256 / x-github-delivery / x-github-event 三个头,
# 密钥走凭据引用 DSH_GITHUB_WEBHOOK_SECRET。
export DSH_GITHUB_WEBHOOK_SECRET=lab-secret
dsh --profile test web --no-open &
BODY='{"action":"opened","issue":{"number":1,"title":"登录页在 Safari 下白屏"}}'
SIG="sha256=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$DSH_GITHUB_WEBHOOK_SECRET" | awk '{print $NF}')"
curl -i -X POST http://127.0.0.1:3080/github \
-H 'content-type: application/json' \
-H 'x-github-event: issues' \
-H 'x-github-delivery: lab-1' \
-H "x-hub-signature-256: $SIG" \
-d "$BODY"
你应当先收到 202(分发已经发生、回调还没结算),随后才看到新会话出现。
# 3. 同一个交付再 POST 一次
你会得到第二个会话。这不是 bug——runtime 不去重,它只负责把已验证的交付发给规则。要防重,就在规则里按 delivery.deliveryId(或更可靠的业务 id,比如 issue 编号)自己记一笔。
注意事项
- 它不是消息队列。 没有队列、重试、去重、执行状态、崩溃重放、完成结果。这几样要哪个都得自己实现。
- 重复交付会开出重复会话。 交付 id 只用于溯源,不参与去重。防重写在规则里。
- 规则是受信任的任意代码。 它跑在你的进程里,不是一个被沙箱隔开的插件点。条件判断的严格程度完全取决于你。
signal要自己看。 runtime 提供它,但不会替你在每个await之后检查。不检查的异步工作在卸载之后还会继续跑。dispatch()在任何回调结算前返回。 别把它当成”处理完了”的信号;成功路径上你会先拿到202,然后业务在后台发生。- 事件字段是规则的活。 适配器只保证”已签名且是无损 JSON”,不保证业务字段的形状与取值。
- 注销顺序是有意的。 先移除规则再排空活动调用——反过来的话,卸载过程中还会有新交付进来。
- 中途失败的处理分两段。 附加失败释放 Agent;附加之后、提示词接纳之前失败则尝试脱离 Workspace 并释放 Agent,且不取代原始错误。
- 自动创建的 Workspace 不会因为失败被删掉。 因为并发的另一个调用者可能已经在用。这是刻意的取舍。
- webhook 入口要和浏览器 API 分开监听。 前者天生公网可达,后者不是。挂在同一个监听上等于让前者替后者开门。