Webhook:任务完成回调
任务状态变化时,Hub 会主动 POST 到你在 Key 上配置的回调地址。 这是推荐的结果获取方式:任务是分钟级的,轮询 100 个 jobId 既慢又浪费。
1. 事件
| 事件 | 触发时机 | isFinal |
|---|---|---|
job.queued | 提交成功、任务入队 | false |
job.started | 节点领取开始执行 | false |
job.retrying | 失败后自动重新排队(还会再跑) | false |
job.completed | 执行成功 | true |
job.failed | 最终失败(不再重试) | true |
job.cancelled | 被取消 | true |
batch.completed | 整批任务全部到终态 | true |
⚠️ 只看 isFinal=true 判断终态。同一个 job 可能先收到 job.failed(第 1 次尝试失败)再收到 job.retrying,最后 job.completed(第 2 次成功)。中间失败不是终态。
2. 请求
http
POST https://team.cd-zq.com/api/zqagent/webhook
Content-Type: application/json
X-ZQ-Event: job.completed
X-ZQ-Signature: v1=3f9a...c21e
X-ZQ-Timestamp: 1762674000
X-ZQ-Delivery: dlv_7f2c9a
X-ZQ-Attempt: 1
{ ...载荷... }| 响应头/头 | 说明 |
|---|---|
X-ZQ-Event | 事件名 |
X-ZQ-Signature | v1= + HMAC-SHA256 十六进制 |
X-ZQ-Timestamp | Unix 秒 |
X-ZQ-Delivery | 本次投递 id(重试时保持不变) |
X-ZQ-Attempt | 第几次投递(从 1 开始) |
你的服务返回 2xx 即视为成功,响应体不解析。其它状态码或 10 秒超时都算失败。
3. 载荷
json
{
"event": "job.completed",
"deliveryId": "dlv_7f2c9a",
"jobId": "job_9f2c",
"externalId": "TASK-1024",
"batchId": "sprint-42",
"attempt": 2,
"maxAttempts": 4,
"isFinal": true,
"status": "done",
"kind": "ai",
"nodeId": "node_be",
"projectId": "proj_order",
"sessionId": "sess_x1",
"queuedAt": "2026-11-09T10:05:00Z",
"startedAt": "2026-11-09T10:05:12Z",
"finishedAt": "2026-11-09T10:12:03Z",
"durationMs": 411000,
"nextRetryAt": null,
"result": {
"summary": "已生成 reports/todo.md,共 23 项",
"producedFiles": ["reports/todo.md"],
"usage": { "inputTokens": 21000, "outputTokens": 3400 },
"iterations": 12,
"toolCalls": 18
},
"failure": null,
"occurredAt": "2026-11-09T10:12:03Z"
}字段
| 字段 | 说明 |
|---|---|
jobId / externalId | 服务端 id / 你自己的任务 id |
isFinal | 是否终态(见上节) |
attempt / maxAttempts | 第几次尝试 / 总共允许几次 |
result.summary | 最终回复(AI)或输出尾部(脚本),最多 4000 字符 |
result.producedFiles | 本轮改动的文件(工作区相对路径) |
result.usage | token 用量 |
failure.type | 失败分类(见 jobs.md) |
nextRetryAt | job.retrying 时为下次开始时间 |
batch.completed 载荷
json
{
"event": "batch.completed",
"deliveryId": "dlv_8a1b",
"jobId": null,
"batchId": "sprint-42",
"isFinal": true,
"batch": {
"total": 100,
"succeeded": 97,
"failed": 3,
"cancelled": 0,
"failedJobs": [
{ "jobId": "job_9f2c", "externalId": "TASK-17", "failureType": "llm_error" }
]
},
"occurredAt": "2026-11-09T11:30:00Z"
}这是"汇总"最省事的形态:监听一个事件即可,不用自己遍历 100 个 jobId。
4. 验签
4.1 算法
signature = hex(HMAC_SHA256(webhookSecret, timestamp + "." + rawBody))webhookSecret:创建 Key 时一并下发(whsec_前缀),只显示一次timestamp:X-ZQ-Timestamp的原始字符串rawBody:原始请求体字节,不要先 JSON.parse 再 stringify(键顺序/空格会变)
4.2 Node.js
js
import crypto from "node:crypto";
function verify(rawBody, headers, secret) {
const ts = headers["x-zq-timestamp"];
const got = headers["x-zq-signature"]; // "v1=..."
const expected =
"v1=" + crypto.createHmac("sha256", secret).update(`${ts}.${rawBody}`).digest("hex");
// 定长比较,避免时序侧信道
const a = Buffer.from(got);
const b = Buffer.from(expected);
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return false;
// 防重放:5 分钟之外的请求一律拒绝
return Math.abs(Date.now() / 1000 - Number(ts)) < 300;
}4.3 Python
python
import hmac, hashlib, time
def verify(raw_body: bytes, headers: dict, secret: str) -> bool:
ts = headers["X-ZQ-Timestamp"]
got = headers["X-ZQ-Signature"]
expected = "v1=" + hmac.new(
secret.encode(), f"{ts}.".encode() + raw_body, hashlib.sha256
).hexdigest()
if not hmac.compare_digest(got, expected):
return False
return abs(time.time() - int(ts)) < 3004.4 快速自测
bash
SECRET="whsec_xxx"
TS=$(date +%s)
BODY='{"event":"job.completed","jobId":"job_test"}'
SIG=$(printf '%s.%s' "$TS" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" -hex | sed 's/^.* //')
echo "X-ZQ-Signature: v1=$SIG"5. 重试策略
| 次 | 间隔 |
|---|---|
| 1 | 立即 |
| 2 | 10 秒 |
| 3 | 30 秒 |
| 4 | 2 分钟 |
| 5 | 10 分钟 |
| 6 | 1 小时 |
之后标记 failed。投递记录落盘:Hub 进程重启后会继续投未完成的。
⚠️ 注意与任务重试区分:任务重试是 1+3 次(30s/2min/10min), 因为那涉及真实执行成本;webhook 只是重发通知,所以更宽松。
6. 幂等与乱序
网络重试可能导致同一事件重复到达,也可能乱序(10 秒的重试早于 2 分钟的重试到达)。
建议:
- 用
X-ZQ-Delivery去重(同一次投递重发时不变) - 或按
(jobId, attempt)取"最大 attempt 的最终状态" - 处理顺序:先用
isFinal过滤,再用occurredAt比较新旧
js
const key = `${payload.jobId}:${payload.attempt}`;
if (seen.has(key)) return; // 已处理过
seen.add(key);7. 回调地址要求
| 要求 | 说明 |
|---|---|
| 协议 | 必须 HTTPS(载荷含任务结果,不能明文传输) |
| 主机 | 拒绝内网/回环/链路本地(127.*、10.*、192.168.*、172.16-31.*、::1) |
| 白名单 | 若 Hub 配置了允许站点,必须命中(默认配置为 https://team.cd-zq.com) |
| 跳转 | 不跟随 3xx(避免绕过白名单) |
配置位置:创建 Key 时填 callbackUrl;或在单个任务上覆盖。
7.1 投递记录与手动重发
回调没到时不靠猜。控制台「设置 → 集成」能看到最近的投递记录,接口也可以直接查:
http
GET /api/hub/agent-api/deliveries?limit=50json
{
"data": {
"items": [
{
"id": "dlv_a1b2",
"jobId": "job_9f2c",
"batchId": "sprint-42",
"event": "job.completed",
"attempt": 3,
"status": "pending",
"lastError": "HTTP 502",
"createdAt": "2026-11-09T10:12:03Z",
"deliveredAt": null
}
]
},
"requestId": "req_..."
}status:pending(等待/重试中)、delivered、failed(超过最大重试次数,保留待人工处理)。- 只能看到你自己的 Key 产生的投递,看不到别人的。
确认对方服务恢复后可以手动重发(不消耗自动重试配额):
http
POST /api/hub/agent-api/deliveries/dlv_a1b2/redeliver重发用的是当时那份载荷(不是重新生成),所以接收方按
deliveryId去重仍然有效。
8. 排障
收不到回调
先查投递记录(见 7.1):pending + lastError 基本就能定位是 DNS、证书还是对方 5xx。
- 确认 Key 或任务上有
callbackUrl - 确认地址返回 2xx(用
curl -v看) - 检查 Hub 日志:
[hub:webhook] <deliveryId> 投递失败(HTTP 500) - 手动重发(P2 提供管理接口;当前可重新
POST /v1/jobs/{id}/retry触发新一轮)
验签总是失败
- 用了
JSON.stringify(JSON.parse(body))而不是原始 body webhookSecret用了 API Key 而不是whsec_开头的密钥- 轮换过 Key:签名密钥也换了,需要更新配置
重复收到同一事件
这是重试的正常表现(Hub 没收到 2xx)。按第 6 节去重即可, 同时检查你的服务是否在 10 秒内没返回 2xx(例如在 handler 里做了重活)。
