Repository Wiki
deepseek-ai/deepseek-harness

Webhook 触发与外部会话创建

本页说明 Webhook 子系统如何接收已认证的外部交付、匹配并异步执行受信任规则,以及如何把规则产生的请求转换为 Workspace-backed Session。实现采用 fire-and-forget 语义:Webhook runtime 不保存交付、不去重,也不等待规则或 Session 后续轮次完成。

Purpose and Scope

本页覆盖 WebhookRuntime 的规则注册、交付快照、按 provider kind 分发、生命周期卸载,以及规则返回 WebhookSessionRequest 后的外部会话创建边界;同时说明 GitHub webhook 适配器在整个链路中的职责边界。

适配器的 HTTP 路由、凭据解析、原始 JSON body 校验和 GitHub 事件规范化属于 provider-specific 层;本页只在需要解释数据如何进入 runtime 时提及它们。更完整的 GitHub 路由使用和评审指南应参见相邻的 GitHub webhook / review 文档。Session 持久化、Agent 生命周期和普通 Web API 的细节也不在本页展开。

Overview

Webhook runtime 位于“已认证 provider 交付”和“普通 Session 创建”之间:

  1. provider 适配器验证请求并生成 VerifiedWebhookDelivery。
  2. runtime 验证共享字段,复制为无损 JSON 快照并深度冻结,避免任意规则修改其他规则看到的数据。
  3. runtime 快照当前注册表,只启动 kind 相同且尚未卸载的规则;每个规则拥有独立的 active invocation 集合。
  4. 规则以 WebhookSessionRequest 或 null 结束。null 表示该规则消费了交付但不创建 Session。
  5. 非空请求交给 createWebhookSession,由其完成 preset、Workspace、Agent、权限、标题和初始 follow-up 的常规 Session 创建流程。

这种设计把“认证和通用接收”留在 provider adapter,把“业务条件和外部调用”留给受信任规则,把“跨 provider 的会话生命周期”集中到 runtime。代价是 runtime 明确不提供队列、重试、幂等去重、崩溃重放或完成状态;重复交付可能产生重复 Session。

Architecture

Loading diagram...

Source: index.ts

图中的关键边界均由源代码或 subsystem 文档确认:WebhookRuntime 维护注册表并调用 createWebhookSession;它注入 agents、preset 集合、sessionTitle 和 workspaceRegistry,因此 Session 创建不是独立的 HTTP 结果,而是依托现有应用上下文完成。

核心实现

规则注册与类型擦除

register<K extends string>(rule: WebhookRule<K>) 首先检查 runtime 没有进入关闭状态,然后验证规则 id、provider kind 和 run 回调。公开泛型保留 provider-specific 的编写时类型;注册表内部把规则擦除为 AnyWebhookRule,只保留 runtime 所需的共享形状。这样 runtime 无需理解每一种 provider 的事件字段。

实际注册通过 Cordis effect 完成:effect 初始化时检查重复 id,创建 AbortController 和 active: Set<Promise<void>>,再放入 rules。返回的 disposer 是可等待函数,最终调用 disposeRegistration。因此注册本身和卸载都能纳入 Cordis 生命周期。

typescript
1register<K extends string>(rule: WebhookRule<K>): () => Promise<void> { 2 if (this.closing) throw new Error('webhook runtime is closing') 3 if (typeof rule.id !== 'string' || rule.id.trim() === '') { 4 throw new TypeError('webhook rule id must be a non-empty string') 5 } 6 if (typeof rule.kind !== 'string' || rule.kind.trim() === '') { 7 throw new TypeError(`webhook rule \"${String(rule.id)}\" kind must be a non-empty string`) 8 } 9 if (typeof rule.run !== 'function') { 10 throw new TypeError(`webhook rule \"${String(rule.id)}\" requires run()`) 11 } 12 13 const erased = rule as AnyWebhookRule 14 let registration!: RuleRegistration 15 const disposeEffect = this.ctx.effect(() => { 16 if (this.closing) throw new Error('webhook runtime is closing') 17 if (this.rules.has(rule.id)) throw new Error(`webhook rule \"${rule.id}\" is already registered`) 18 registration = { 19 rule: erased, 20 controller: new AbortController(), 21 active: new Set(), 22 closing: false, 23 } 24 this.rules.set(rule.id, registration) 25 return () => this.disposeRegistration(registration) 26 }, `webhookRuntime.register(${rule.id})`) 27 return async () => { await disposeEffect() } 28}

Source: index.ts

交付验证、快照与匹配

dispatch 是同步入口,但它只启动异步调用,不等待任何规则完成。进入时先拒绝 closing runtime,再由 snapshotDelivery 检查 kind、source、deliveryId 都是非空字符串,receivedAt 是非负安全整数,并要求整个交付可以表示为无损 JSON。随后调用 deepFreeze,使同一份安全快照可共享给多个规则。

分发时遍历 [...]this.rules.values(),即先取得当前注册项的数组快照。匹配条件是注册项未 closing 且 registration.rule.kind === snapshot.kind。因此分发开始后新注册的规则不会追溯接收这次交付,卸载中的规则也不会继续接受新的 invocation。

typescript
1dispatch<K extends string>(delivery: VerifiedWebhookDelivery<K>): void { 2 if (this.closing) throw new Error('webhook runtime is closing') 3 const snapshot = snapshotDelivery(delivery) 4 for (const registration of [...this.rules.values()]) { 5 if (registration.closing || registration.rule.kind !== snapshot.kind) continue 6 this.startInvocation(registration, snapshot) 7 } 8}

Source: index.ts

Fire-and-forget invocation

startInvocation 用 Promise.resolve().then(...) 把规则执行放入异步链。开始和 createWebhookSession 前后都检查 AbortSignal,防止卸载后继续推进。规则异常不会传播回 dispatch;捕获逻辑按“正常失败”记录 warning,按“已因 disposal 中止”记录 debug。无论成功还是失败,finally 都会从 active 集合移除该 promise。

因此调用方只能知道交付已被 runtime 接受并开始调度,不能从 dispatch() 返回值获知规则是否命中、Session 是否创建成功或后续 Agent 是否完成。

Core Flow

Loading diagram...

Sources:

Session 请求与 Workspace 语义

规则返回非空值后,runtime 将交付和规则 id 传给 createWebhookSession。文档定义 WebhookSessionRequest 必须包含绝对 workspacePath、标题、文本提示词、agent preset 和 permission preset;model 可选,省略时使用当前部署选择的快照。创建过程会验证 preset,解析或创建规范 Workspace,使新 Agent 的 cwd 等于 Workspace 路径,在发布前挂载 agent preset,并先持久化附加 Session,再提交普通 user-role follow-up。

Webhook follow-up 使用 source.kind: "webhook",附带 provider、source、delivery 和 rule 信息。它不触发特殊 flush,也不等待 Agent 轮次;之后由普通 Session persistence 和 Agent 生命周期接管。这种复用避免为 webhook 另建一套 Session 状态机,同时保留来源可追踪性。

数据与生命周期模型

Loading diagram...

Sources:

deliveryId 只用于来源信息,runtime 不存储它,也不据此去重。活动操作表仅存在于每个 RuleRegistration.active 中;进程退出后不会保留可恢复的执行记录。

Usage Examples

通过 runtime 注册和分发规则

以下代码片段是公开 API 的实际签名和 runtime 调度逻辑,可作为适配器或受信任业务规则接入点的最小参考。规则回调是否观察 signal 由规则自身负责;若希望卸载时停止异步工作,就必须使用该 signal。

typescript
1export interface WebhookRule<K extends string = string> { 2 readonly id: WebhookRuleId 3 readonly kind: K 4 readonly run: ( 5 delivery: Readonly<VerifiedWebhookDelivery<K>>, 6 signal: AbortSignal, 7 ) => WebhookSessionRequest | null | Promise<WebhookSessionRequest | null> 8} 9 10register<K extends string>(rule: WebhookRule<K>): () => Promise<void> 11dispatch<K extends string>(delivery: VerifiedWebhookDelivery<K>): void

Source: webhook.zh.md

GitHub webhook E2E 中的隔离配置入口

真实 E2E 测试把 webhook listener 使用的 Cordis 配置 overlay 固定为 fixture 路径,并把 secret、delivery id 和测试标题集中定义。这说明 provider 入口的验证应通过隔离 listener 与真实 CLI 进程测试,而不应把 Webhook endpoint 默认暴露到浏览器 API。

typescript
1const OVERLAY = fileURLToPath(new URL( 2 './fixtures/github-webhook/cordis.yml', 3 import.meta.url, 4)) 5const SECRET = 'github-webhook-real-e2e-secret' 6const DELIVERY = 'github-webhook-real-e2e-delivery' 7const MARKER = 'DSH_GITHUB_WEBHOOK_REAL_E2E_OK' 8const TITLE = 'GitHub webhook real e2e'

Source: github-webhook-real.e2e.ts

Configuration and接入边界

源代码证据表明 runtime 通过 Cordis service 注入以下依赖;这些不是本页可以推断出的环境变量,而是构造时的服务依赖:

依赖用途
agents创建 webhook 触发的 Agent
agentDefaultModel未明确指定 model 时提供默认模型路由
agentPresets校验并挂载请求选择的 agent preset
permissionPresets校验并应用 permission preset
sessionTitle提供 Session 标题默认/应用上下文
workspaceRegistry解析或创建规范 Workspace

GitHub 适配器的实现边界在 subsystem 文档中明确为:在注入的 WebServer 上注册精确路由;解析每次请求的凭据引用;在解析前验证未改动的 application/json body;规范化为已签名的无损 JSON 对象;内存分发后立即返回 HTTP 202。事件特定字段不由适配器替规则验证,而由实际消费它的规则验证。

API Reference

WebhookRuntime.register<K extends string>(rule: WebhookRule<K>): () => Promise<void>

注册一个受信任的程序化规则。

  • 参数 rule:包含唯一 id、provider kind 和 run(delivery, signal) 回调。
  • 返回值:可等待的 disposer。disposer 会把规则从注册表移除,中止其 AbortController,并等待 active callbacks 排空。
  • 同步错误:runtime 正在关闭时抛出 Error;id、kind 为空或 run 不是函数时抛出 TypeError;Cordis effect 初始化时发现重复 id 也会抛出 Error。

WebhookRuntime.dispatch<K extends string>(delivery: VerifiedWebhookDelivery<K>): void

将一个已认证交付发送给当前匹配的规则,并在规则 callback settle 前返回。

  • 参数 delivery:包含 provider kind、配置来源、delivery id、规范化事件和 received timestamp 的交付。
  • 返回值:void;它不代表规则成功,也不代表 Session 已创建。
  • 同步错误:runtime 正在关闭时抛出 Error;kind、source 或 deliveryId 不是非空字符串,receivedAt 不是非负安全整数,或值不是无损 JSON 时抛出 TypeError。
  • 匹配规则:仅执行 kind 相同且 registration 尚未进入 closing 的规则。
  • 异步错误:规则异常和 Session 创建异常被 invocation 自己捕获并记录,不回抛给 dispatch 调用者。

Failure Modes、边界与并发

输入和 provider 边界

  • delivery 的共享元数据在规则运行前统一验证;无效交付不会部分进入规则。
  • snapshotJsonValue 失败时,runtime 拒绝不能无损表示的值;随后不会调用任何规则。
  • provider 事件的业务字段不是 runtime 的通用校验范围,规则必须验证自己需要的字段。

卸载竞态

卸载顺序是“隐藏、abort、drain”:disposeRegistration 先将 closing 设为 true,再从 rules 删除,调用 controller.abort,最后循环等待 active 集合清空。disposal ??= 使并发调用 disposer 共享同一个 Promise,不会重复执行清理。由于 dispatch 使用 registration 数组快照,已经被快照到的项仍会进入 startInvocation,但 invocation 在执行前通过 signal.throwIfAborted(),从而在 teardown 过程中停止。

typescript
1private disposeRegistration(registration: RuleRegistration): Promise<void> { 2 registration.disposal ??= (async () => { 3 registration.closing = true 4 this.rules.delete(registration.rule.id) 5 registration.controller.abort(new Error(`webhook rule \"${registration.rule.id}\" was disposed`)) 6 while (registration.active.size > 0) { 7 await Promise.allSettled([...registration.active]) 8 } 9 })() 10 return registration.disposal 11}

Source: index.ts

Session 创建失败

Session 创建阶段的清理策略由 subsystem 文档明确:附加失败会在提示词出现前释放新 Agent;附加之后、提示词接纳之前失败时,会尝试脱离 Workspace 并释放 Agent,但不会覆盖原始错误。预检期间自动创建的 Workspace 会保留,因为另一个并发调用者可能已经使用它。

可靠性与运维限制

runtime 没有持久队列、重试、去重、执行状态、崩溃重放或 Agent 状态监听器。运维上应把 provider delivery 的重试和幂等策略视为外部责任;尤其不能把一次 dispatch() 返回误解为业务处理完成。由于 active 集合是内存结构,进程重启不会恢复未完成 callback。

性能与扩展点

每次 dispatch 会对当前规则表做一次数组快照,并为每个匹配规则启动一个独立 invocation;不同规则之间不会串行等待。因此规则数量和规则 callback 自身的异步工作量决定了瞬时并发,而不是某个 runtime 内置队列。runtime 不做限流或背压,这也是 provider 侧必须考虑请求速率和重复交付的原因。

扩展的主要入口是 WebhookRule<K>:新增 provider 不需要修改 runtime,只需生成与 provider kind 对应的 VerifiedWebhookDelivery,并注册消费该 kind 的规则。规则应保持 callback 可取消,使用传入的 AbortSignal,并在需要幂等时自行依据业务字段处理;runtime 不会替它对 delivery id 去重。

Tests 与验证证据

仓库包含 GitHub webhook 的真实 E2E 测试入口。测试通过启动 CLI、交换认证 cookie、使用 HTTP Remote RPC 和 WebSocket stream 观察公开状态,并设置 90 秒启动超时、10 秒流首项超时;这些测试验证 provider 入口和实际 Session 观察路径,而不是只验证纯函数。

typescript
1async function remoteRpc<T>(baseUrl: string, endpoint: string, args: object): Promise<T> { 2 const authenticated = await authenticatedWeb(baseUrl) 3 const response = await fetch(`${authenticated.origin}/api/${endpoint}`, { 4 method: 'POST', 5 headers: { 'content-type': 'application/json', cookie: authenticated.cookie }, 6 body: JSON.stringify({ 7 type: 'client-request', 8 rpcId: `github-webhook-real-${endpoint}-${randomUUID()}`, 9 method: endpoint, 10 payload: { args }, 11 }), 12 }) 13 if (!response.ok) { 14 throw new Error(`${endpoint} returned HTTP ${String(response.status)}: ${await response.text()}`) 15 } 16 const envelope = await response.json() as { 17 result: { ok: true; value: T } | { ok: false; error: { code: string; message: string } } 18 } 19 if (!envelope.result.ok) { 20 throw new Error(`${endpoint} failed: ${envelope.result.error.code}: ${envelope.result.error.message}`) 21 } 22 return envelope.result.value 23}

Source: github-webhook-real.e2e.ts

Sources

(3 files)
docs/subsystems
packages/webhook/webhook/src