Skip to content

Commit baf5982

Browse files
committed
收紧命令校验并保护运行任务删除
1 parent 6128109 commit baf5982

14 files changed

Lines changed: 346 additions & 39 deletions

AGENTS.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ bun run dev -- db check # 检查数据库完整性与业务统计
7070
- `maxRetries` 表示首次执行之外允许的重试次数;失败任务按指数退避,耗尽后进入 `dead_letter`,手动重试会重置重试预算。
7171
- `retryBackoffMs``timeoutMs` 可按任务覆盖;调度模板克隆时必须保留 `cwd/batchId/maxRetries/retryBackoffMs/timeoutMs`
7272
- 运行中任务进入 `cancelled` 后,Worker 必须在轮询周期内终止对应进程树并关闭 run;Gateway 关闭时先按 `shutdownGracePeriodMs` drain,只有剩余任务才重置为 `pending`
73+
- 删除任务必须拒绝 `running` 状态、仍有 `running` 执行记录,或仍被 `pending/running/failed/dead_letter` 任务依赖的前置任务;手动删除与过期清理都必须防止子进程失联和依赖悬空。
7374
- Watchdog 处理数据库中的旧 PID 前必须校验进程命令与配置的 OpenCode 可执行文件匹配;Unix 上仅对确认的独立进程组发送信号。
7475
- Watchdog 的 `checkIntervalMs` 是心跳检查间隔,`cleanupIntervalMs` 是数据清理间隔,两者不可混用;配置经 `validateConfig` 校验后才允许运行或保存。
7576
- 调度模板支持 `cron | delayed | recurring`,Scheduler 将模板克隆为普通任务并受 `maxInstances` 限制;`delayed` 成功生成一次后必须自动禁用。
@@ -92,6 +93,7 @@ bun run dev -- db check # 检查数据库完整性与业务统计
9293
- Service 层测试直接调用静态方法,不经过 CLI
9394
- CLI 集成测试通过 `execSync` 子进程执行,并用临时 `SUPERTASK_DB_PATH`,不得读写用户真实数据库
9495
- `db check/backup/clear/restore` 的交互式 stdout 必须人类可读;非 TTY 或显式 `--json` 必须保持可解析 JSON,成功与错误都要覆盖这三种模式
96+
- `db check` 报告 `ok=false` 时必须返回非零退出码;CLI 数字参数必须完整匹配整数,不得用 `parseInt` 接受尾随字符或截断小数
9597
- Gateway 构建产物 E2E 必须使用隔离数据库和假 OpenCode 可执行文件覆盖普通任务、失败重试、`delayed``recurring``cron`,不得为测试调用真实模型
9698

9799
## 发布流程

README.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,7 @@ supertask get --id 1
106106
supertask status
107107
supertask cancel --id 1
108108
supertask retry --id 1
109+
supertask delete --id 1 # running tasks must be cancelled and fully stopped first
109110

110111
# Scheduled templates (friendly duration format)
111112
supertask template add --name "Daily" --agent "gen" \
@@ -134,6 +135,8 @@ supertask db check --json
134135
supertask db check | jq '.counts'
135136
```
136137

138+
`db check` exits non-zero when integrity, foreign keys, or required tables fail, while still printing the complete report. CLI IDs and integer options are parsed strictly: values such as `12abc` or `3.5` are rejected instead of being truncated.
139+
137140
### Duration Format
138141

139142
Schedule supports friendly duration strings:
@@ -188,6 +191,7 @@ Key mechanisms:
188191
- **Batch isolation** — Same `batchId` serial; different `batchId` parallel
189192
- **Priority**`urgency DESC → importance DESC → createdAt ASC → id ASC`
190193
- **Local Dashboard boundary** — loopback-only listener, same-origin write checks, escaped database output
194+
- **Guarded deletion** — active runs and prerequisites of executable dependent tasks cannot be deleted
191195

192196
## Web Dashboard
193197

@@ -197,7 +201,7 @@ Health endpoint: `GET http://localhost:4680/health` returns 200 only after Gatew
197201

198202
| Page | Features |
199203
|------|----------|
200-
| Task Queue | Filter by status, retry, delete |
204+
| Task Queue | Filter by status, retry, cancel, guarded delete |
201205
| Scheduled Tasks | Template CRUD, enable/disable, manual trigger |
202206
| Execution Logs | task_runs history with session tracking |
203207
| System Status | Config editor, concurrency monitor, backup-first transactional database clear |
@@ -289,6 +293,7 @@ supertask template add --type cron --cron "0 9 * * *" ...
289293
- **定时任务** — cron / delayed / recurring,支持友好时间格式
290294
- **Web 控制台** — 任务监控、执行日志、在线配置、自动备份后事务性清空数据库
291295
- **Session 追踪** — 自动从 opencode run 输出中捕获 session ID
296+
- **安全删除** — 活跃执行必须先取消并收敛;仍被可执行任务依赖的前置任务也不会被误删
292297

293298
### 数据库维护
294299

@@ -308,6 +313,8 @@ supertask db check --json
308313
supertask db check | jq '.counts'
309314
```
310315

316+
`db check` 发现完整性、外键或必需表异常时会返回非零退出码,同时保留完整报告。CLI 的 ID 与整数参数采用严格解析,`12abc``3.5` 等输入会直接报错,不再截断成另一个合法值。
317+
311318
### 数据位置
312319

313320
- 数据库:`~/.local/share/opencode/tasks.db`

docs/architecture.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ pending / running / failed ──cancel──> cancelled
9393
- 普通失败保存为 `failed`;退避到期后 Worker 可以直接重新抢占。Watchdog 恢复则保存为带 `retryAfter``pending`
9494
- 超出预算进入 `dead_letter`,不会自动执行;手动 retry 将状态改回 `pending`,并把 `retryCount` 清零。
9595
- 单次任务可覆盖 `maxRetries``retryBackoffMs``timeoutMs`;模板克隆时会复制这些字段。
96+
- 删除不是运行态迁移:`running` 任务或仍存在 `running` 执行记录的任务必须先取消并等待 Worker 关闭 run;仍被 `pending/running/failed/dead_letter` 任务依赖的前置任务也拒绝删除。手动删除和 Watchdog 过期清理都遵守依赖保护,避免制造悬空任务。
9697

9798
## 并发、超时与故障恢复
9899

docs/operations.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,8 @@ supertask db check # 完整性、外键、业务表和运行状态检查
5151

5252
数据库连接启用 WAL 和 5 秒 `busy_timeout`。每次新连接初始化会自动执行 migrations、启用外键并检查孤立记录;检查失败会拒绝继续运行,不会自动删除用户数据。
5353

54+
CLI 的任务/模板 ID、优先级、重试次数和列表数量均按完整十进制整数解析;带尾随字符、小数、越界值和未知任务状态会返回非零退出码,不会再由 `parseInt` 静默截断。
55+
5456
## 完整配置
5557

5658
未写出的字段使用下表默认值。Dashboard 保存配置时会进行分组浅合并和完整校验,写入采用临时文件后原子重命名,权限为 `0600`
@@ -161,6 +163,10 @@ Watchdog 的恢复时间不是任务超时时间。Worker 的 `taskTimeoutMs`
161163

162164
运行中 `cancel` 会在下一个 Worker 轮询周期被发现,随后终止对应进程树、保留任务 `cancelled` 状态,并把本次 run 关闭为失败。
163165

166+
运行中任务不能直接删除。执行 `supertask cancel --id <id>` 或在 Dashboard 点击“取消”后,还要等待对应 `running` 执行记录关闭;在此之前 CLI 和 Dashboard 删除接口都会返回冲突。这个保护避免任务记录先消失、Worker 无法再定位并终止仍在运行的子进程。
167+
168+
仍被 `pending/running/failed/dead_letter` 任务引用为 `dependsOn` 的前置任务也不能删除;Watchdog 的过期清理遵循同一保护。先处理或删除下游任务,避免制造永远无法满足的悬空依赖。已终态且不可重试的 `done/cancelled` 下游不会阻塞前置任务清理。
169+
164170
### 配置无法加载
165171

166172
`supertask config` 会输出具体字段错误。修复 `~/.config/opencode/supertask.json` 后重启 Gateway。不要靠删除字段反复试错;先对照上面的范围和两个跨字段约束。
@@ -191,7 +197,7 @@ supertask db backup
191197
supertask db backup --output ~/supertask-backup/tasks.db
192198
```
193199

194-
- `db check` 运行 `PRAGMA integrity_check`、外键检查、必需表检查,并返回三张业务表和运行中记录的数量。
200+
- `db check` 运行 `PRAGMA integrity_check`、外键检查、必需表检查,并返回三张业务表和运行中记录的数量;检查不通过时仍输出完整报告,但进程退出码为非零,便于监控和 CI 正确判定失败
195201
- `db backup` 使用当前连接生成一致性快照,将其转换为不依赖 `-wal`/`-shm` 的独立 SQLite 文件,再用只读连接复检;目标文件已存在时拒绝覆盖。在线备份可以在 Gateway 运行时执行。
196202
- 自动备份默认与数据库放在同一目录,名称包含用途、UTC 时间和随机后缀,文件权限为 `0600`
197203
- `check/backup/clear/restore` 在交互式终端输出人类可读摘要;stdout 非 TTY 时保持 JSON,终端脚本可传 `--json` 强制 JSON。成功和错误使用同一判断,便于 `supertask db check | jq` 等既有调用继续工作。

src/cli/index.ts

Lines changed: 31 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { TaskTemplateService } from '@core/services/task-template.service';
44
import { DatabaseMaintenanceService } from '@core/services/database-maintenance.service';
55
import { closeDb } from '@core/db';
66
import { parseDuration } from '@core/duration';
7-
import type { TaskStatus, ScheduleType } from '@core/db/schema';
7+
import type { ScheduleType } from '@core/db/schema';
88
import {
99
getPackageVersion,
1010
restartGatewayAfterMaintenance,
@@ -15,6 +15,11 @@ import {
1515
renderDatabaseResult,
1616
type GatewayMaintenanceReport,
1717
} from './database-output';
18+
import {
19+
parseBoundedInteger,
20+
parsePositiveInteger,
21+
parseTaskStatus,
22+
} from './validation';
1823

1924
async function withDb<T>(
2025
fn: () => Promise<T>,
@@ -113,12 +118,12 @@ program
113118
prompt: options.prompt,
114119
model: options.model,
115120
category: options.category,
116-
importance: parseInt(options.importance),
117-
urgency: parseInt(options.urgency),
121+
importance: parseBoundedInteger(options.importance, 'importance', 1, 5),
122+
urgency: parseBoundedInteger(options.urgency, 'urgency', 1, 5),
118123
batchId: options.batch,
119-
dependsOn: options.depends ? parseInt(options.depends) : undefined,
124+
dependsOn: options.depends ? parsePositiveInteger(options.depends, 'depends') : undefined,
120125
cwd: submitCwd,
121-
maxRetries: parseInt(options.maxRetries),
126+
maxRetries: parseBoundedInteger(options.maxRetries, 'max-retries', 0, 1000),
122127
retryBackoffMs,
123128
timeoutMs,
124129
});
@@ -152,7 +157,7 @@ program
152157
.description('开始执行任务(标记为 running)')
153158
.requiredOption('--id <id>', '任务 ID')
154159
.action(async (options) => withDb(async () => {
155-
const task = await TaskService.start(parseInt(options.id), { cwd: process.cwd() });
160+
const task = await TaskService.start(parsePositiveInteger(options.id, 'id'), { cwd: process.cwd() });
156161
if (task) {
157162
console.log(JSON.stringify({ id: task.id, status: task.status }));
158163
} else {
@@ -167,7 +172,7 @@ program
167172
.requiredOption('--id <id>', '任务 ID')
168173
.option('-l, --log <log>', '结果日志')
169174
.action(async (options) => withDb(async () => {
170-
const task = await TaskService.done(parseInt(options.id), options.log, { cwd: process.cwd() });
175+
const task = await TaskService.done(parsePositiveInteger(options.id, 'id'), options.log, { cwd: process.cwd() });
171176
if (task) {
172177
console.log(JSON.stringify({ id: task.id, status: task.status }));
173178
} else {
@@ -182,7 +187,7 @@ program
182187
.requiredOption('--id <id>', '任务 ID')
183188
.option('-l, --log <log>', '错误日志')
184189
.action(async (options) => withDb(async () => {
185-
const task = await TaskService.fail(parseInt(options.id), options.log, { cwd: process.cwd() });
190+
const task = await TaskService.fail(parsePositiveInteger(options.id, 'id'), options.log, { cwd: process.cwd() });
186191
if (task) {
187192
console.log(JSON.stringify({
188193
id: task.id,
@@ -200,7 +205,7 @@ program
200205
.description('取消任务')
201206
.requiredOption('--id <id>', '任务 ID')
202207
.action(async (options) => withDb(async () => {
203-
const task = await TaskService.cancel(parseInt(options.id), { cwd: process.cwd() });
208+
const task = await TaskService.cancel(parsePositiveInteger(options.id, 'id'), { cwd: process.cwd() });
204209
if (task) {
205210
console.log(JSON.stringify({ id: task.id, status: task.status }));
206211
} else {
@@ -216,7 +221,7 @@ program
216221
.option('-b, --batch <batchId>', '批次 ID(批量重试)')
217222
.action(async (options) => withDb(async () => {
218223
if (options.id) {
219-
const task = await TaskService.retry(parseInt(options.id), { cwd: process.cwd() });
224+
const task = await TaskService.retry(parsePositiveInteger(options.id, 'id'), { cwd: process.cwd() });
220225
if (task) {
221226
console.log(JSON.stringify({ id: task.id, status: task.status }));
222227
} else {
@@ -250,11 +255,11 @@ program
250255
.option('-l, --limit <number>', '限制数量', '20')
251256
.action(async (options) => withDb(async () => {
252257
const tasks = await TaskService.list({
253-
status: options.status as TaskStatus,
258+
status: parseTaskStatus(options.status),
254259
batchId: options.batch,
255260
category: options.category,
256261
cwd: process.cwd(),
257-
limit: parseInt(options.limit),
262+
limit: parsePositiveInteger(options.limit, 'limit'),
258263
});
259264
console.log(JSON.stringify(tasks, null, 2));
260265
}));
@@ -264,7 +269,7 @@ program
264269
.description('获取单个任务详情')
265270
.requiredOption('--id <id>', '任务 ID')
266271
.action(async (options) => withDb(async () => {
267-
const task = await TaskService.getById(parseInt(options.id), { cwd: process.cwd() });
272+
const task = await TaskService.getById(parsePositiveInteger(options.id, 'id'), { cwd: process.cwd() });
268273
if (task) {
269274
console.log(JSON.stringify(task, null, 2));
270275
} else {
@@ -278,8 +283,9 @@ program
278283
.description('删除任务')
279284
.requiredOption('--id <id>', '任务 ID')
280285
.action(async (options) => withDb(async () => {
281-
const deleted = await TaskService.delete(parseInt(options.id), { cwd: process.cwd() });
282-
console.log(JSON.stringify({ deleted, id: parseInt(options.id) }));
286+
const id = parsePositiveInteger(options.id, 'id');
287+
const deleted = await TaskService.delete(id, { cwd: process.cwd() });
288+
console.log(JSON.stringify({ deleted, id }));
283289
}));
284290

285291
program
@@ -336,16 +342,16 @@ program
336342
prompt: options.prompt,
337343
model: options.model,
338344
category: options.category,
339-
importance: parseInt(options.importance),
340-
urgency: parseInt(options.urgency),
345+
importance: parseBoundedInteger(options.importance, 'importance', 1, 5),
346+
urgency: parseBoundedInteger(options.urgency, 'urgency', 1, 5),
341347
cwd: process.cwd(),
342348
batchId: options.batch,
343349
scheduleType: options.type as ScheduleType,
344350
cronExpr: options.cron,
345351
intervalMs,
346352
runAt,
347-
maxInstances: parseInt(options.maxInstances),
348-
maxRetries: parseInt(options.maxRetries),
353+
maxInstances: parseBoundedInteger(options.maxInstances, 'max-instances', 1, 1000),
354+
maxRetries: parseBoundedInteger(options.maxRetries, 'max-retries', 0, 1000),
349355
retryBackoffMs,
350356
timeoutMs,
351357
});
@@ -365,7 +371,7 @@ program
365371
.description('启用模板')
366372
.requiredOption('--id <id>', '模板 ID')
367373
.action(async (options) => withDb(async () => {
368-
const tmpl = await TaskTemplateService.enable(parseInt(options.id));
374+
const tmpl = await TaskTemplateService.enable(parsePositiveInteger(options.id, 'id'));
369375
if (tmpl) {
370376
console.log(JSON.stringify({ id: tmpl.id, enabled: true }));
371377
} else {
@@ -379,7 +385,7 @@ program
379385
.description('禁用模板')
380386
.requiredOption('--id <id>', '模板 ID')
381387
.action(async (options) => withDb(async () => {
382-
const tmpl = await TaskTemplateService.disable(parseInt(options.id));
388+
const tmpl = await TaskTemplateService.disable(parsePositiveInteger(options.id, 'id'));
383389
if (tmpl) {
384390
console.log(JSON.stringify({ id: tmpl.id, enabled: false }));
385391
} else {
@@ -393,8 +399,9 @@ program
393399
.description('删除模板')
394400
.requiredOption('--id <id>', '模板 ID')
395401
.action(async (options) => withDb(async () => {
396-
const deleted = await TaskTemplateService.delete(parseInt(options.id));
397-
console.log(JSON.stringify({ deleted, id: parseInt(options.id) }));
402+
const id = parsePositiveInteger(options.id, 'id');
403+
const deleted = await TaskTemplateService.delete(id);
404+
console.log(JSON.stringify({ deleted, id }));
398405
})),
399406
);
400407

@@ -408,6 +415,7 @@ databaseCommand
408415
.action(async (options: { json?: boolean }) => withDb(async () => {
409416
const result = DatabaseMaintenanceService.check();
410417
console.log(renderDatabaseResult('check', result, { forceJson: options.json }));
418+
if (!result.ok) process.exitCode = 1;
411419
}, (error) => renderDatabaseError(error, { forceJson: options.json })));
412420

413421
databaseCommand

src/cli/validation.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import type { TaskStatus } from '@core/db/schema';
2+
3+
const TASK_STATUSES = new Set<TaskStatus>([
4+
'pending',
5+
'running',
6+
'done',
7+
'failed',
8+
'dead_letter',
9+
'cancelled',
10+
]);
11+
12+
export function parsePositiveInteger(value: string, name: string): number {
13+
if (!/^\d+$/.test(value)) throw new Error(`${name} 必须是正整数`);
14+
const parsed = Number(value);
15+
if (!Number.isSafeInteger(parsed) || parsed <= 0) {
16+
throw new Error(`${name} 必须是正整数`);
17+
}
18+
return parsed;
19+
}
20+
21+
export function parseBoundedInteger(
22+
value: string,
23+
name: string,
24+
min: number,
25+
max: number,
26+
): number {
27+
if (!/^\d+$/.test(value)) {
28+
throw new Error(`${name} 必须是 ${min}${max} 之间的整数`);
29+
}
30+
const parsed = Number(value);
31+
if (!Number.isSafeInteger(parsed) || parsed < min || parsed > max) {
32+
throw new Error(`${name} 必须是 ${min}${max} 之间的整数`);
33+
}
34+
return parsed;
35+
}
36+
37+
export function parseTaskStatus(value: string | undefined): TaskStatus | undefined {
38+
if (value === undefined) return undefined;
39+
if (!TASK_STATUSES.has(value as TaskStatus)) {
40+
throw new Error(`status 无效:${value}`);
41+
}
42+
return value as TaskStatus;
43+
}

0 commit comments

Comments
 (0)