You've already forked agentic-coding-workflow
docs(jobs): operator guide + acceptance checklist
This commit is contained in:
+141
@@ -0,0 +1,141 @@
|
||||
# 后台 Job Runner 操作指南
|
||||
|
||||
ACW 使用一个 DB-backed 的 job 队列 + 周期 runner 处理后台任务:workspace fetch、worktree
|
||||
prune、附件清理、job reaper、job purge,以及 webhook 投递。本文档面向运维:如何启用、
|
||||
如何排障、如何手动验收。
|
||||
|
||||
## 总览
|
||||
|
||||
- **队列存储**:表 `jobs`(payload JSONB),`SELECT FOR UPDATE SKIP LOCKED` 并发租约。
|
||||
- **Worker 池**:`jobs.workers` 个 goroutine 拉取并执行;handler 失败按指数退避重试,超过
|
||||
`max_attempts` 或 handler 返回 `Permanent` 错误进入 `dead` 状态。
|
||||
- **周期 runner**:每个 runner 由 `time.Ticker` 触发;panic 由 scheduler 捕获不影响其他
|
||||
runner。
|
||||
- **Webhook**:`WebhookNotifier` 注册到通知 dispatcher 之上,按 topic 白名单 enqueue
|
||||
`webhook.deliver` job;handler 用 HMAC-SHA256 签名后 POST。
|
||||
- **死信兜底**:webhook 进入 dead 状态时会写一条 audit、并向所有 admin 用户 fan-out 一条
|
||||
inApp 通知,避免外部投递失败被静默吞掉。
|
||||
|
||||
## 配置
|
||||
|
||||
配置在 `config.yaml` 的 `jobs:` 与 `notify.webhook:` 段,默认值见 `config.example.yaml`。
|
||||
|
||||
```yaml
|
||||
jobs:
|
||||
enabled: true # 整体开关;false 时不启动 scheduler/worker
|
||||
workers: 2 # worker 池大小
|
||||
shutdown_grace: 30s # 关停时等待 worker 排空的最长时间
|
||||
|
||||
workspace_fetch: # A:每个 idle workspace 跑 git fetch
|
||||
enabled: true
|
||||
interval: 5m
|
||||
|
||||
worktree_prune: # B:两阶段清理 idle worktree
|
||||
enabled: true
|
||||
interval: 24h
|
||||
idle_threshold: 720h # 30d:超过即可被 prune
|
||||
warning_lead: 72h # 3d:在 prune 前 72h 发警告通知
|
||||
|
||||
attachment_cleanup: # C:删除软删会话或孤儿态附件
|
||||
enabled: true
|
||||
interval: 24h
|
||||
retention: 720h # 30d
|
||||
|
||||
job_reaper: # D:将超过 lease_timeout 仍未完成的 running job 回退为 pending
|
||||
enabled: true
|
||||
interval: 1m
|
||||
lease_timeout: 5m
|
||||
|
||||
job_purge: # 物理清理 completed/dead 旧记录
|
||||
enabled: true
|
||||
interval: 24h
|
||||
completed_retention: 168h # 7d
|
||||
|
||||
notify:
|
||||
webhook:
|
||||
enabled: true
|
||||
url: https://hooks.example.com/incoming
|
||||
secret: ${WEBHOOK_SECRET}
|
||||
timeout: 10s
|
||||
topics: # 白名单:未列出的 topic 不会进 jobs 表
|
||||
- workspace.sync_failed
|
||||
- worktree.prune_pending
|
||||
```
|
||||
|
||||
`notify.webhook.enabled=false` 时 `WebhookNotifier.Send` 直接 short-circuit,不会写
|
||||
`jobs` 表。
|
||||
|
||||
## Webhook 协议
|
||||
|
||||
handler 对每个 webhook job POST 一次:
|
||||
|
||||
| Header | 说明 |
|
||||
| ------------------ | -------------------------------------------- |
|
||||
| `Content-Type` | `application/json` |
|
||||
| `X-ACW-Topic` | 通知 topic(同 body 中 `topic` 字段) |
|
||||
| `X-ACW-Event-Id` | job UUID,用于接收方去重 |
|
||||
| `X-ACW-Timestamp` | Unix 秒,签名 payload 的一部分 |
|
||||
| `X-ACW-Signature` | `sha256=<hex>`,HMAC-SHA256(`<ts>.` + body) |
|
||||
|
||||
**响应处理**:
|
||||
|
||||
- 2xx → 标记完成
|
||||
- 4xx → 永久失败(`Permanent`),直接 dead-letter
|
||||
- 5xx / 网络错误 → 退避重试,超过 `max_attempts` 后 dead-letter
|
||||
|
||||
### 接收方验签示例(Python)
|
||||
|
||||
```python
|
||||
import hmac, hashlib, time
|
||||
|
||||
def verify(secret: str, headers: dict, body: bytes) -> bool:
|
||||
ts = headers["X-ACW-Timestamp"]
|
||||
sig = headers["X-ACW-Signature"]
|
||||
if abs(time.time() - int(ts)) > 300:
|
||||
return False
|
||||
mac = hmac.new(secret.encode(), f"{ts}.".encode() + body, hashlib.sha256)
|
||||
expected = f"sha256={mac.hexdigest()}"
|
||||
return hmac.compare_digest(expected, sig)
|
||||
```
|
||||
|
||||
## 手动验收清单
|
||||
|
||||
每次较大改动后按以下步骤过一遍。前置:本地起一个 `nc -l` 或 `httpbin.org/status/500`
|
||||
作为 webhook 接收端。
|
||||
|
||||
- [ ] **关 webhook**(`notify.webhook.enabled=false`)→ 触发一次 fetch 失败 → 用户收到
|
||||
inApp 通知,但 `SELECT count(*) FROM jobs WHERE type='webhook.deliver'` 应为 0。
|
||||
- [ ] **开 webhook + 故意写错 URL**(指向 `localhost:1` 等不可达端口)→ 触发一条
|
||||
`workspace.sync_failed` → 看到 5 次重试日志(`job.fail_retry`)+ 1 条
|
||||
`status='dead'` 行 + 所有 admin 收到 `webhook.dead_letter` 兜底通知。
|
||||
- [ ] **prune 警告阶段**:手工 `UPDATE workspace_worktrees SET last_used_at = now() -
|
||||
INTERVAL '28 days' WHERE id = '<id>'` → 等下一轮 worktree_prune tick → 收到
|
||||
inApp 警告 + 该行 `prune_warning_at` 被写入。
|
||||
- [ ] **prune 复位**:接着对该 worktree acquire → release 一次 → `prune_warning_at`
|
||||
被清空、`last_used_at` 推到 now。
|
||||
- [ ] **prune 执行阶段**:`UPDATE workspace_worktrees SET last_used_at = now() -
|
||||
INTERVAL '31 days' WHERE id = '<id>'` → 等下一轮 tick → 真删 worktree(验证目录
|
||||
消失 + DB 行删)。
|
||||
- [ ] **reaper 复位**:`kill -9` 进程模拟崩溃,重启后等 `lease_timeout + interval` →
|
||||
`SELECT * FROM jobs WHERE status='running'` 应为空(被 D2 reaper 复位为
|
||||
`pending`)。
|
||||
- [ ] **purge**:手工把一行的 `completed_at` 改成 8 天前 → 等下一轮 job_purge tick →
|
||||
该行被物理删除。
|
||||
|
||||
## 常见排障
|
||||
|
||||
| 现象 | 排查思路 |
|
||||
| ------------------------------------ | ----------------------------------------------------------------------------- |
|
||||
| `jobs` 表只增不减 | 检查 `job_purge.enabled` 和 `completed_retention`;看 `job_purge` runner 日志 |
|
||||
| webhook 永远收不到 | 检查 `notify.webhook.enabled`、URL 可达性、topic 是否在白名单 |
|
||||
| 大量 `status='dead'` | 看 `last_error`;典型是接收端持续 5xx 或签名校验不过 |
|
||||
| Worker 卡住不出 job | 看 `lease_timeout`,可能被 reaper 复位中;或 pool 已满(`workers` 配置过小) |
|
||||
| 关停时 `jobs.module.shutdown_timeout` | 增大 `shutdown_grace`,或检查是否有 handler 不响应 ctx 取消 |
|
||||
|
||||
## 相关代码索引
|
||||
|
||||
- `internal/jobs/`:核心包(domain / repository / scheduler / worker / module / notifier)
|
||||
- `internal/jobs/handlers/webhook.go`:webhook 投递 handler
|
||||
- `internal/jobs/runners/`:5 个周期 runner
|
||||
- `internal/app/app.go`:runner 与 dispatcher 的 wiring + 关停顺序
|
||||
- `migrations/0005_jobs.up.sql`:表结构
|
||||
Reference in New Issue
Block a user