Skip to content

Commit a7799d6

Browse files
committed
fix: harden unattended gateway execution
1 parent baf5982 commit a7799d6

34 files changed

Lines changed: 1299 additions & 447 deletions

.github/workflows/ci.yml

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches: ['**']
6+
pull_request:
7+
8+
permissions:
9+
contents: read
10+
11+
jobs:
12+
verify:
13+
runs-on: ubuntu-latest
14+
steps:
15+
- name: Checkout
16+
uses: actions/checkout@v6
17+
18+
- name: Setup Bun
19+
uses: oven-sh/setup-bun@v2
20+
21+
- name: Install dependencies
22+
run: bun install --frozen-lockfile
23+
24+
- name: Test
25+
run: bun test
26+
27+
- name: Typecheck
28+
run: bun run typecheck
29+
30+
- name: Build
31+
run: bun run build

AGENTS.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ SQLite(tasks / task_runs / task_templates)
1616
Gateway ─ Worker + Scheduler + Watchdog + Dashboard
1717
```
1818

19-
- 插件在 `plugin/supertask.ts` 注册 11`supertask_*` 工具:`add/next/start/done/fail/status/retry/list/get/schedule/upgrade`
19+
- 插件在 `plugin/supertask.ts` 注册 8`supertask_*` 工具:`add/next/status/retry/list/get/schedule/upgrade`;运行态与执行终态只允许 Gateway 写入,不得恢复外部 `start/done/fail`
2020
- Worker 通过参数数组直接执行 `opencode run --agent <task.agent> --format json <task.prompt>`;退出码决定成功或失败,Gateway 统一写任务状态和执行记录。
2121
- `agents/supertask-runner.md``plugin/task.ts` 是旧架构遗留,不是当前 npm 运行链路;不得重新接入嵌套 runner。
2222
- pm2 是可选守护层:仅显式运行 `supertask install` 时允许安装;插件加载不得静默安装全局依赖。前台运行使用 `supertask gateway`
@@ -52,6 +52,7 @@ bun run dev -- db check # 检查数据库完整性与业务统计
5252
- 数据库初始化时启用 WAL、创建 `gateway_lock` 并自动执行 `drizzle/` migrations。
5353
- Gateway 用 SQLite `BEGIN IMMEDIATE` + `gateway_lock` 保证单实例;Dashboard 默认只监听 `127.0.0.1:4680`
5454
- Gateway 仅在 Worker、Scheduler、Watchdog 和 Dashboard 启动完成后写入 `gateway_lock.ready_at`;PM2 `online` 不能单独作为就绪依据,进程 PID 必须匹配新鲜 ready 锁。
55+
- `/health` 必须同时反映组件活跃度和连续失败;`supertask doctor` 汇总 OpenCode、数据库、PM2 ready 锁、Dashboard 与日志轮转。显式 `supertask install` 配置有限保留的 PM2 日志轮转。
5556
- 数据库检查、备份、清空和恢复统一经过 `DatabaseMaintenanceService`;CLI 清空/恢复必须显式确认并拒绝运行中任务,且只可自动停启 PID 与当前数据库新鲜 ready 锁一致的 PM2 Gateway;前台或无法确认归属的进程必须拒绝误杀。清空/恢复前必须自动创建校验通过的安全备份,默认在操作失败时也恢复原 Gateway 状态,`--keep-stopped` 除外。
5657
- Dashboard 清空只能豁免当前 Gateway PID,仍必须服务端确认、拒绝运行中任务并在同一事务内先备份后删除;不得恢复为直接 `DELETE` 三张表的路由实现。
5758
- Dashboard 的浏览器写请求必须通过同源检查,数据库字符串进入 HTML 前必须调用 `esc`;API 的 ID、状态和配置不得直接断言类型。
@@ -65,8 +66,8 @@ bun run dev -- db check # 检查数据库完整性与业务统计
6566
## 核心业务约束
6667

6768
- 任务状态:`pending | running | done | failed | dead_letter | cancelled`;执行记录状态:`running | done | failed`
68-
- `cwd` 是任务的项目隔离键;插件默认使用提交任务时的 `process.cwd()`,查询和状态变更必须保持同一作用域。
69-
- 队列顺序保持 `urgency DESC → importance DESC → createdAt ASC → id ASC`同一 `batchId` 串行,不同批次可并行,依赖任务仅在 `dependsOn` 指向的任务完成后运行
69+
- `cwd` 是任务的项目隔离键;插件必须使用 OpenCode 工具上下文的 `directory`,不得信任模型传入的 `cwd`,查询和状态变更必须保持同一作用域。
70+
- 队列顺序保持 `urgency DESC → importance DESC → createdAt ASC → id ASC`全局并发和同一 `batchId` 串行必须依据数据库运行态,在 Gateway 重启后仍成立;不同批次可并行,依赖任务仅在同 cwd 的 `dependsOn` 完成后运行
7071
- `maxRetries` 表示首次执行之外允许的重试次数;失败任务按指数退避,耗尽后进入 `dead_letter`,手动重试会重置重试预算。
7172
- `retryBackoffMs``timeoutMs` 可按任务覆盖;调度模板克隆时必须保留 `cwd/batchId/maxRetries/retryBackoffMs/timeoutMs`
7273
- 运行中任务进入 `cancelled` 后,Worker 必须在轮询周期内终止对应进程树并关闭 run;Gateway 关闭时先按 `shutdownGracePeriodMs` drain,只有剩余任务才重置为 `pending`

README.md

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ Then add the plugin to `~/.config/opencode/opencode.json`:
2727
}
2828
```
2929

30-
Restart OpenCode to load 11 `supertask_*` tools. Then choose how to run the Gateway:
30+
Restart OpenCode to load 8 queue-management `supertask_*` tools. Execution state is owned only by the Gateway; the plugin no longer exposes manual `start/done/fail` transitions. Then choose how to run the Gateway:
3131

3232
```bash
3333
supertask install # recommended for long-running use: explicit pm2 setup
@@ -97,6 +97,7 @@ supertask uninstall # stop and remove from pm2
9797
supertask gateway # start Gateway in foreground
9898
supertask ui # open Web Dashboard in browser
9999
supertask config # show current config
100+
supertask doctor [--json] # end-to-end runtime diagnostics
100101

101102
# Task management
102103
supertask add -n "Task" -a "agent" -p "prompt" --importance 5 \
@@ -180,15 +181,15 @@ The complete configuration reference and restart semantics are documented in [Op
180181
```
181182

182183
Key mechanisms:
183-
- **Process supervision** — optional pm2 crash recovery; plugin load never installs pm2, but can reuse an existing installation
184+
- **Process supervision** — optional pm2 crash recovery with a 512 MB default memory restart threshold; plugin load never installs pm2, but can reuse an existing installation
184185
- **Version-aware restart** — if pm2 is already installed, plugin load starts or restarts the Gateway when the package version changes
185186
- **Process lock** — SQLite `BEGIN IMMEDIATE` ensures single instance
186-
- **Readiness check** — PM2 PID must match a fresh, ready Gateway lock; `/health` also checks component activity
187+
- **Readiness check** — PM2 PID must match a fresh, ready Gateway lock; `/health` also reports component activity, consecutive failures, and the latest error
187188
- **Heartbeat** — Worker updates every 30s; Watchdog kills stale processes
188189
- **Graceful shutdown** — stop claiming work, drain active tasks for 30s, then interrupt and requeue only unfinished tasks
189190
- **Exponential backoff** — configurable base × 2^n, capped at 30min
190191
- **Dead letter queue**`maxRetries` additional retries exhausted → `dead_letter`, manually recoverable
191-
- **Batch isolation** — Same `batchId` serial; different `batchId` parallel
192+
- **Batch isolation** — Same `batchId` remains serial across Gateway restarts; different `batchId` can run in parallel
192193
- **Priority**`urgency DESC → importance DESC → createdAt ASC → id ASC`
193194
- **Local Dashboard boundary** — loopback-only listener, same-origin write checks, escaped database output
194195
- **Guarded deletion** — active runs and prerequisites of executable dependent tasks cannot be deleted
@@ -197,7 +198,7 @@ Key mechanisms:
197198

198199
http://localhost:4680 — 4 pages:
199200

200-
Health endpoint: `GET http://localhost:4680/health` returns 200 only after Gateway startup completes and its internal loops remain active.
201+
Health endpoint: `GET http://localhost:4680/health` returns 200 only after Gateway startup completes and its internal loops remain active without an unrecovered loop failure. `supertask doctor` also checks OpenCode, SQLite, PM2 readiness, Dashboard health, and log rotation.
201202

202203
| Page | Features |
203204
|------|----------|
@@ -246,7 +247,7 @@ npm install -g opencode-supertask
246247
}
247248
```
248249

249-
重启 OpenCode 后会注入 11 个 `supertask_*` 工具。随后选择一种 Gateway 运行方式:
250+
重启 OpenCode 后会注入 8 个队列管理 `supertask_*` 工具。执行状态只允许 Gateway 写入,插件不再暴露手动 `start/done/fail` 状态迁移。随后选择一种 Gateway 运行方式:
250251

251252
```bash
252253
supertask install # 长期运行推荐:显式安装/配置 pm2 并启动 Gateway
@@ -288,12 +289,13 @@ supertask template add --type cron --cron "0 9 * * *" ...
288289

289290
- **任务队列** — 优先级调度、批次隔离、依赖管理
290291
- **安全停止** — 默认等待在途任务 30 秒;超时任务才会被中断并重新排队
291-
- **进程守护** — 可选 pm2 崩溃恢复;插件加载不会安装 pm2,但可复用机器上已有的 PM2
292+
- **进程守护** — 可选 pm2 崩溃恢复;显式 `supertask install` 同时安装/配置有限保留的日志轮转,插件加载不会安装全局依赖
292293
- **版本感知重启** — 已安装 pm2 时,插件加载会在包版本变化后启动或重启 Gateway
293294
- **定时任务** — cron / delayed / recurring,支持友好时间格式
294295
- **Web 控制台** — 任务监控、执行日志、在线配置、自动备份后事务性清空数据库
295296
- **Session 追踪** — 自动从 opencode run 输出中捕获 session ID
296297
- **安全删除** — 活跃执行必须先取消并收敛;仍被可执行任务依赖的前置任务也不会被误删
298+
- **一键诊断**`supertask doctor` 检查真实 OpenCode、SQLite、PM2 ready 锁、Dashboard 和日志轮转
297299

298300
### 数据库维护
299301

docs/architecture.md

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
# SuperTask 当前架构与决策
22

33
> 状态:当前有效
4-
> 最后核对:2026-07-15
5-
> 适用版本:源码 `main` 分支,当前开发基线 0.1.26
4+
> 最后核对:2026-07-16
5+
> 适用版本:源码 `main` 分支,当前开发基线 0.1.27
66
77
## 结论与适用边界
88

@@ -61,7 +61,7 @@ Gateway 启动时按以下顺序工作:
6161
TaskService.next()
6262
├─ 状态:pending,或已到 retryAfter 的 failed
6363
├─ 依赖:dependsOn 指向的任务必须 done
64-
├─ 批次:排除当前 Worker 已运行的非空 batchId
64+
├─ 批次:排除数据库中已有 running 任务的非空 batchId
6565
└─ 排序:urgency DESC → importance DESC → createdAt ASC → id ASC
6666
6767
TaskService.start() 条件更新,pending/failed → running
@@ -74,7 +74,9 @@ opencode run --agent <task.agent> --format json [-m <model>] <task.prompt>
7474

7575
Worker 直接执行目标 Agent,不再嵌套 `supertask-runner`。这减少了一层 LLM 决策、递归风险和 PID 追踪歧义;`task.agent=supertask-runner` 会被明确拒绝并进入死信。`agents/supertask-runner.md` 仅是历史备份。
7676

77-
`cwd` 是任务的项目隔离键,也是子进程工作目录。插件创建任务时强制记录当前 `process.cwd()`;插件侧查询和状态变更按同一 `cwd` 限定。
77+
`cwd` 是任务的项目隔离键,也是子进程工作目录。插件使用 OpenCode 工具上下文的 `directory`,不信任模型传入的 `cwd`;插件侧查询和状态变更按同一目录限定。
78+
79+
`running`、任务终态和 `task_runs` 执行终态只由 Gateway 写入。CLI 和插件不暴露 `start/done/fail`,避免外部调用制造没有 owner/PID 的运行记录或让任务与 run 状态分裂。
7880

7981
## 状态与重试语义
8082

@@ -97,11 +99,11 @@ pending / running / failed ──cancel──> cancelled
9799

98100
## 并发、超时与故障恢复
99101

100-
- 全局并发由 `worker.maxConcurrency` 控制,默认 2。
101-
- 同一非空 `batchId` 在单个 Gateway 内串行;不同批次和空 `batchId` 可以并行。
102-
- Worker 硬超时、运行中取消和宽限期后的关闭会终止子进程树;Watchdog 在使用数据库记录的旧 PID 前,还会校验进程命令是否匹配配置的 OpenCode 可执行文件。
103-
- Worker 定时更新 `task_runs.heartbeatAt`。Watchdog 发现心跳过期时终止记录的 `childPid`、关闭本次 run,并按同一重试预算恢复任务。
104-
- Gateway 获得单实例锁后、Worker 接单前,会把不存在 active run 的遗留 `running` 任务恢复为 `pending`,覆盖进程在创建 run 前后崩溃留下的孤儿状态
102+
- 全局并发由 `worker.maxConcurrency` 控制,默认 2;Worker 每次接单前同时统计 `running` 任务和仍未关闭的 running run,因此 Gateway 重启或取消收敛期间不会暂时突破额度
103+
- 同一非空 `batchId` 依据数据库中的任务/run 运行态全局串行;Gateway 重启后旧任务尚未收敛时,同批次新任务仍不会启动。不同批次和空 `batchId` 可以并行。
104+
- Worker 硬超时、运行中取消和宽限期后的关闭会终止子进程树;Watchdog 在使用数据库记录的旧 PID 前,会校验命令同时包含目标 OpenCode 可执行文件`run``--agent``--format json`。无法确认身份或无法确认退出时保持隔离,不重派任务
105+
- Worker 定时更新 `task_runs.heartbeatAt`。Watchdog 在 owner PID 已退出或心跳过期时立即检查子进程,确认其结束后在一个事务内关闭 run,并按同一重试预算恢复任务。
106+
- Gateway 获得单实例锁后、Worker 接单前,会把不存在 active run 的遗留 `running` 任务恢复为 `pending`;run 创建时立即记录 owner PID,覆盖进程在创建 run 或启动子进程前后崩溃留下的窗口
105107
- 输出只保留最后 64 KiB,避免单任务无限占用 Gateway 内存;发现 JSON 输出中的 `sessionID` 会写入执行记录。
106108

107109
该实现提供 at-least-once 倾向的本地恢复,不保证 exactly-once。任务执行外部副作用时,应自行设计幂等键或可重复执行策略。
@@ -111,7 +113,7 @@ pending / running / failed ──cancel──> cancelled
111113
模板支持 `cron``recurring` 和一次性 `delayed`
112114

113115
- Scheduler 只克隆普通任务,实际执行仍遵循队列优先级、批次、重试和超时规则。
114-
- `maxInstances` 统计同模板下 `pending``running` 数量;达到上限时本轮不克隆
116+
- `maxInstances` 统计同模板下 `pending``running` 和仍有自动重试预算的 `failed`;自动调度和 Dashboard 手动触发都服从该上限
115117
- `delayed` 成功克隆一次后自动禁用。
116118
- `cron``recurring` 的下一次时间基于本次成功克隆时的当前时间计算。
117119
- 当前没有可配置的 catch-up/backfill;Gateway 离线期间错过的多个周期不会逐个补跑。恢复后最多生成一个到期实例,再从当前时间计算下一次。
@@ -138,7 +140,7 @@ Dashboard 只绑定 `127.0.0.1`,浏览器写请求检查 `Sec-Fetch-Site` 和
138140

139141
以下是当前源码行为,不应由文档掩盖:
140142

141-
- `/health` 会检查 ready 锁与 Worker、SchedulerWatchdog 活跃时间,但仍没有指标导出、结构化日志轮转策略或告警集成
143+
- `/health` 检查 ready 锁、Worker/Scheduler/Watchdog 活跃时间和连续失败,并保留最近错误;`supertask doctor` 汇总 OpenCode、数据库、PM2、Dashboard 与日志轮转。显式 `supertask install` 会配置 `pm2-logrotate`,但仍没有指标导出和告警集成
142144
- 单实例锁、任务抢占和状态更新足以覆盖当前单机模型,但不提供分布式租约和 exactly-once 保证。
143145

144146
这些限制一旦成为真实故障来源,应先写复现测试,再调整实现与本文档。

0 commit comments

Comments
 (0)