From 18cf163a7702ecd22c878480e5898addb9c92b44 Mon Sep 17 00:00:00 2001 From: Vincent-new-macbook Date: Tue, 3 Mar 2026 08:35:24 +0800 Subject: [PATCH 1/4] docs(user-guide): update Supervisor and systemd configuration instructions for CLI tool paths --- docs/USER_GUIDE.md | 40 ++++++++++++++++++++++++++++++++++++---- 1 file changed, 36 insertions(+), 4 deletions(-) diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index c5dafcf..e28061d 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -328,18 +328,41 @@ python run.py run MY_PROJECT my-tasks --delay 111-229 --daemon nohup python run.py run MY_PROJECT my-tasks --delay 111-229 > task.log 2>&1 & ``` +**Supervisor / systemd 配置关键说明:** + +Supervisor 和 systemd 使用极简环境启动进程,**不会加载你的 `.bashrc` / `.zshrc`**, +因此 `kimi`、`agent`、`copilot`、`claude` 等 CLI 工具的路径不在默认 `PATH` 中, +执行时会报 `Tool Not Found` 或 `command not found`。 + +你需要先查出每个工具的完整路径,然后在配置中通过 `environment` 传入: + +```bash +# 查询各 CLI 工具的安装路径 +which kimi # 例如 /usr/local/bin/kimi +which agent # 例如 /home/deploy/.local/bin/agent +which copilot # 例如 /www/server/nodejs/v22.17.1/bin/copilot +which claude # 例如 /home/deploy/.local/bin/claude + +# 查询 Python 虚拟环境路径 +which python # 确保是 venv 内的 python,例如 /path/to/auto-run-task/.task_env/bin/python +``` + +将上面得到的目录汇总到 `PATH` 中(取 `dirname` 部分),写入配置的 `environment` 字段。 + **Supervisor 配置示例:** ```ini [program:auto-task-runner] -command=/path/to/venv/bin/python /path/to/run.py run MY_PROJECT my-tasks --delay 111-229 +command=/path/to/auto-run-task/.task_env/bin/python /path/to/auto-run-task/run.py run MY_PROJECT my-tasks --delay 111-229 directory=/path/to/auto-run-task autostart=true autorestart=false stdout_logfile=/var/log/auto-task-runner.log stderr_logfile=/var/log/auto-task-runner-err.log -environment=PYTHONUNBUFFERED=1 user=deploy +; ⬇️ 关键:把 CLI 工具所在目录加入 PATH,否则会 Tool Not Found +environment=PYTHONUNBUFFERED=1,HOME="/home/deploy",PATH="/www/server/nodejs/v22.17.1/bin:/home/deploy/.local/bin:/usr/local/bin:/usr/bin:/bin" +; 如果工具需要代理访问,追加:HTTP_PROXY="http://127.0.0.1:7890",HTTPS_PROXY="http://127.0.0.1:7890" ``` **systemd 配置示例:** @@ -353,16 +376,25 @@ After=network.target Type=simple User=deploy WorkingDirectory=/path/to/auto-run-task -ExecStart=/path/to/venv/bin/python run.py run MY_PROJECT my-tasks --delay 111-229 +ExecStart=/path/to/auto-run-task/.task_env/bin/python run.py run MY_PROJECT my-tasks --delay 111-229 Restart=no -Environment=PYTHONUNBUFFERED=1 StandardOutput=journal StandardError=journal +; ⬇️ 关键:把 CLI 工具所在目录加入 PATH +Environment=PYTHONUNBUFFERED=1 +Environment=HOME=/home/deploy +Environment=PATH=/www/server/nodejs/v22.17.1/bin:/home/deploy/.local/bin:/usr/local/bin:/usr/bin:/bin +; 如果工具需要代理访问: +; Environment=HTTP_PROXY=http://127.0.0.1:7890 +; Environment=HTTPS_PROXY=http://127.0.0.1:7890 [Install] WantedBy=multi-user.target ``` +> ⚠️ **常见问题:** 如果 `supervisorctl start` 后日志报 `Tool Not Found: copilot`, +> 说明 `environment` 中的 `PATH` 缺少该工具所在目录。用 `which copilot` 查路径后补上即可。 + **Daemon 模式下的行为变化:** | 功能 | 正常模式 | Daemon 模式 | From ba8bfbcdab0ca4af6a4010342fc529e93e3c4022 Mon Sep 17 00:00:00 2001 From: Vincent-new-macbook Date: Thu, 5 Mar 2026 07:49:33 +0800 Subject: [PATCH 2/4] feat(task): add fallback to task_code for task_no and update validation messages --- task_runner/notify.py | 21 ++++----------------- task_runner/task_set.py | 5 ++++- task_runner/validators.py | 4 ++-- tests/test_notify.py | 1 - tests/test_task_set.py | 22 ++++++++++++++++++++++ 5 files changed, 32 insertions(+), 21 deletions(-) create mode 100644 tests/test_task_set.py diff --git a/task_runner/notify.py b/task_runner/notify.py index 149e0f4..6d2053c 100644 --- a/task_runner/notify.py +++ b/task_runner/notify.py @@ -221,9 +221,6 @@ def build_batch_complete_message( lines.append("") lines.append("> 执行被用户中断 (Ctrl+C)") - lines.append("") - lines.append("> 播报来源:轻易云自动机器人") - return "\n".join(lines) @@ -256,10 +253,7 @@ def build_task_failure_message( lines.append(f"**退出码:** {return_code}") lines.append(f"**失败原因:** {failure_reason}") lines.append(f"**耗时:** {elapsed}") - if log_file: - lines.append(f"**日志文件:** {log_file}") - - suffix_lines = ["", "> 播报来源:轻易云自动机器人"] + suffix_lines = [""] compact_output = _fit_output_by_budget(lines, output_tail, suffix_lines) if compact_output: lines.append("") @@ -289,8 +283,6 @@ def build_interrupt_message( lines.append(f"**中断时间:** {now_str}") lines.append(f"**当前任务:** {current_task_no} {current_task_name}") lines.append(f"**已完成:** {completed}/{total}") - lines.append("") - lines.append("> 播报来源:轻易云自动机器人") return "\n".join(lines) @@ -330,13 +322,11 @@ def build_task_complete_message( if progress_done is not None and progress_total: pct = (progress_done / progress_total) * 100 lines.append(f"**当前进度:** {progress_done}/{progress_total} ({pct:.1f}%)") - if log_file: - lines.append(f"**日志文件:** {log_file}") - suffix_lines: list[str] = [""] - if next_task_no and next_task_name: + if next_task_no: + next_title = f"{next_task_no} {next_task_name}" if next_task_name else str(next_task_no) suffix_lines.append("### 下一任务预告") - suffix_lines.append(f"- {next_task_no} {next_task_name}") + suffix_lines.append(f"- {next_title}") if next_tool: suffix_lines.append(f"- 工具: {next_tool}") if next_model: @@ -345,9 +335,6 @@ def build_task_complete_message( suffix_lines.append("### 下一任务预告") suffix_lines.append("- 当前任务集已无待执行任务") - suffix_lines.append("") - suffix_lines.append("> 播报来源:轻易云自动机器人") - compact_output = _fit_output_by_budget(lines, output_tail, suffix_lines) if compact_output: lines.append("") diff --git a/task_runner/task_set.py b/task_runner/task_set.py index cad8dc6..9762b90 100644 --- a/task_runner/task_set.py +++ b/task_runner/task_set.py @@ -77,8 +77,11 @@ def to_dict(self) -> dict: @classmethod def from_dict(cls, d: dict) -> "Task": + # Backward compatibility: some task sets use `task_code` as the real + # identifier and keep `task_no` empty. + task_no = d.get("task_no") or d.get("task_code") or "" return cls( - task_no=d.get("task_no", ""), + task_no=task_no, task_name=d.get("task_name", ""), batch=d.get("batch", 1), description=d.get("description", ""), diff --git a/task_runner/validators.py b/task_runner/validators.py index 637ab00..286ced1 100644 --- a/task_runner/validators.py +++ b/task_runner/validators.py @@ -149,9 +149,9 @@ def validate_task_set_file(data: dict, project_dir: Path) -> ValidationResult: result.add_error(f"Task [{i}] is not a JSON object") continue - task_no = task.get("task_no") + task_no = task.get("task_no") or task.get("task_code") if not task_no: - result.add_error(f"Task [{i}] missing 'task_no'") + result.add_error(f"Task [{i}] missing 'task_no' (or fallback 'task_code')") elif task_no in seen_nos: result.add_error(f"Duplicate task_no: '{task_no}'") else: diff --git a/tests/test_notify.py b/tests/test_notify.py index f21602e..f1ef6f0 100644 --- a/tests/test_notify.py +++ b/tests/test_notify.py @@ -385,7 +385,6 @@ def test_rich_details(self): assert "claude-opus-4.6" in msg assert "退出码" in msg assert "最终结果输出" in msg - assert "M18.log" in msg class TestBuildInterruptMessage: diff --git a/tests/test_task_set.py b/tests/test_task_set.py new file mode 100644 index 0000000..c3f141b --- /dev/null +++ b/tests/test_task_set.py @@ -0,0 +1,22 @@ +"""Tests for task_runner.task_set compatibility behavior.""" + +from task_runner.task_set import Task +from task_runner.validators import validate_task_set_file + + +def test_task_from_dict_falls_back_to_task_code(): + task = Task.from_dict({"task_code": "JSONEDITOR-001", "task_name": "demo"}) + assert task.task_no == "JSONEDITOR-001" + + +def test_validate_task_set_accepts_task_code_fallback(tmp_path): + project_dir = tmp_path + data = { + "tasks": [ + {"task_code": "T-001", "task_name": "a", "status": "not-started"}, + {"task_code": "T-002", "task_name": "b", "status": "not-started"}, + ] + } + + result = validate_task_set_file(data, project_dir) + assert result.ok From 97012de2954f8a463aa5f34a1599b7923b1759b3 Mon Sep 17 00:00:00 2001 From: Vincent-new-macbook Date: Sun, 8 Mar 2026 17:37:14 +0800 Subject: [PATCH 3/4] feat: add support for executing multiple task sets sequentially - Updated USER_GUIDE.md to include examples for running multiple task sets and using the --all flag. - Modified cli.py to accept multiple task set names and added --all and --stop-on-error options. - Refactored run_cmd.py to handle multi-task set execution, including logic for task set ordering and error handling. - Enhanced display functions to show headers and summaries for multi-task set executions. - Added task_set_order field to ProjectConfig for defining execution order. - Created comprehensive tests for multi-task set functionality, including argument parsing and execution flow. --- README.md | 12 +- TODO.md | 386 ------------------------- docs/USER_GUIDE.md | 55 +++- task_runner/cli.py | 46 ++- task_runner/commands/dryrun_cmd.py | 4 +- task_runner/commands/run_cmd.py | 190 ++++++++++++- task_runner/display/__init__.py | 12 +- task_runner/display/summary.py | 96 +++++++ task_runner/project.py | 7 +- tests/test_run_cmd.py | 442 +++++++++++++++++++++++++++++ 10 files changed, 837 insertions(+), 413 deletions(-) delete mode 100644 TODO.md create mode 100644 tests/test_run_cmd.py diff --git a/README.md b/README.md index cff33d1..a431850 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,8 @@ - 🎨 **丰富终端** — Rich 面板、进度条、心跳动画、项目仪表板 - 🌐 **代理自动控制** — kimi 免代理,其他工具自动启用代理 - 🔄 **断点续跑** — 状态实时持久化,中断后从上次位置继续 -- 🛡️ **健壮可靠** — PTY 色彩保留、原子写入、优雅信号处理、git 安全标签 +- � **多任务集顺序执行** — `--all` 或显式指定多个任务集,支持 `task_set_order` 自定义顺序 +- �🛡️ **健壮可靠** — PTY 色彩保留、原子写入、优雅信号处理、git 安全标签 - ⏱️ **防误标** — AI CLI 执行低于 10s 自动标记失败(防止空跑) - 🕐 **防封号** — 任务间随机延时(默认 60-120s),降低被检测为机器人的风险 - � **进程守护** — 支持 supervisor / systemd / nohup,自动检测非 TTY 环境或 `--daemon` 显式启用 @@ -49,7 +50,9 @@ python run.py project create MY_PROJECT --workspace /path/to/repo # 4. 执行 python run.py dry-run MY_PROJECT my-tasks # 预览 -python run.py run MY_PROJECT my-tasks # 执行 +python run.py run MY_PROJECT my-tasks # 执行单个任务集 +python run.py run MY_PROJECT --all # 执行所有任务集 +python run.py run MY_PROJECT ts1 ts2 ts3 # 顺序执行多个任务集 ``` **完整操作指引、命令示例与典型工作流** → [用户操作指南](docs/USER_GUIDE.md) @@ -65,8 +68,8 @@ python run.py run MY_PROJECT my-tasks # 执行 | `project info` | 查看项目详情 | | `project validate` | 校验项目结构 | | `project archive` | 归档项目 | -| `run` | 执行任务 | -| `dry-run` | 预览模式(只生成 prompt 不执行) | +| `run` | 执行任务(支持单个、多个或 `--all` 全部任务集) | +| `dry-run` | 预览模式(支持单个、多个或 `--all`) | | `reset` | 重置任务状态(用于重跑) | | `list` | 列出任务集/任务 | | `status` | 项目状态仪表板 | @@ -106,6 +109,7 @@ auto-run-task/ - **`{{key}}`** — 模板占位符,替换为 `task[key]` 的值 - **`#item`** — 替换为整个任务对象的 JSON +- **`task_set_order`** — 在 `__init__.json` 中定义 `--all` 时的任务集执行顺序 - **任务字段** — `task_no`, `task_name`, `batch`, `priority`, `status`, `depends_on`, `cli.tool`, `cli.model` 等 详见 [用户操作指南 - 数据结构](docs/USER_GUIDE.md#数据结构详解)。 diff --git a/TODO.md b/TODO.md deleted file mode 100644 index 891c5c0..0000000 --- a/TODO.md +++ /dev/null @@ -1,386 +0,0 @@ -# Auto Task Runner — 用户反馈优化 TODO 指南 - -> 基于用户反馈整理的改进清单,按优先级和独立性排序,便于逐项安排实施。 - ---- - -## 📋 Issue #1:Recent 完成列表增加完成时间 - -**现状:** -- `display/tracker.py` 的 `_render()` 在 Recent 区域只显示 `✅ M10 任务名 4m 18s` -- `record_result()` 只记录了 `task_no`, `task_name`, `status`, `elapsed`, `success` -- 用户无法知道上一个任务是什么时候完成的,时间间隔感知缺失 - -**改动范围:** -- [task_runner/display/tracker.py](task_runner/display/tracker.py) - -**方案:** -1. 在 `record_result()` 中增加 `finished_at` 字段(`datetime.now().strftime("%H:%M:%S")`) -2. 在 `_render()` 的 Recent 区域渲染时追加完成时间戳 - -**改动前:** -``` -✅ M10 会计要素(AccountElement)建模补齐 4m 18s -``` - -**改动后:** -``` -✅ M10 会计要素(AccountElement)建模补齐 4m 18s [12:34:56] -``` - -**难度:** ⭐ 简单 -**预计工时:** 10 分钟 -**依赖:** 无 - ---- - -## 📋 Issue #2:Running 状态的动画、开始时间、Elapsed 计时修复 - -### 问题分析 - -**2a — Spinner 动画不动:** -- `_render()` 中 spinner 基于 `tick = int(elapsed * 2)` 计算帧索引 -- `elapsed = time.time() - self._current_start` 在每次 render 时实时计算 -- Rich `Live` 设置了 `refresh_per_second=2`,理论上每 0.5s 刷新一次 -- **但 Rich `Live` 的 auto-refresh 是基于上次 `update()` 后的 renderable 重新 render,如果 renderable 是 Panel 对象而非 callable,它不会重新调用 `_render()`** -- 根本原因:`Live` 的 auto-refresh 会重新渲染已有的 renderable,但 Panel 是静态对象。需要将 `_render` 作为 `get_renderable` 回调,或者用一个定时线程来调用 `_refresh()` - -**可能修复方式(推荐):** -- 方案 A:将 `Live` 的 renderable 改为一个实现 `__rich__()` 的对象,这样 auto-refresh 会每次调用 `__rich__()` 重新生成 Panel -- 方案 B:启动一个后台线程定时调用 `self._refresh()`(每 0.5s) - -**推荐方案 A**,更简洁,不引入额外线程: -```python -class _TrackerRenderable: - def __init__(self, tracker): - self._tracker = tracker - def __rich_console__(self, console, options): - yield self._tracker._render() -``` - -然后 `Live(renderable=_TrackerRenderable(self), ...)` 即可让 auto-refresh 每次重新调用 `_render()`。 - -**2b — 显示开始时间:** -- 在 Running 行增加任务开始时间的显示 -- `set_current_task()` 已经记录了 `_current_start = time.time()`,需要同时记录人类可读 `_current_start_str` - -**改动后效果:** -``` -⠋ Running │ M18 — 物料(Material)建模补齐 (started 14:30:05) - Elapsed │ 3m 25s -``` - -**2c — Elapsed 不计时:** -- 这是 2a 的连带问题,spinner 不动说明 panel 没有重新渲染,elapsed 自然也不更新 -- 修复 2a 后 2c 自动解决 - -**改动范围:** -- [task_runner/display/tracker.py](task_runner/display/tracker.py) - -**难度:** ⭐⭐ 中等 -**预计工时:** 30 分钟 -**依赖:** 无 - ---- - -## 📋 Issue #3:默认工具改为 kimi - -**现状:** -- `ProjectConfig` 的 `default_tool = "copilot"`,`default_model = "claude-opus-4.6"` -- `from_dict()` 反序列化时 fallback 也是 `copilot` - -**改动范围:** -- [task_runner/project.py](task_runner/project.py) — `ProjectConfig` dataclass 默认值 + `from_dict()` fallback -- 建议同时检查 `commands/project_cmd.py` 创建项目时的逻辑是否硬编码了值 - -**方案:** -1. `default_tool` 改为 `"kimi"` -2. `default_model` 改为 `""` 或 `None`(kimi 不支持 model 选择) -3. `from_dict()` 中 fallback 同步改为 `"kimi"` - -**注意事项:** -- 已有项目的 `__init__.json` 不受影响(已持久化) -- 仅影响新建项目和缺省字段的项目 - -**难度:** ⭐ 简单 -**预计工时:** 10 分钟 -**依赖:** 无 - ---- - -## 📋 Issue #4:单任务超时 40 分钟自动标记失败 - -**现状:** -- 没有任何上限超时机制 -- 只有下限检查(< 10 秒标记失败) -- 如果 AI CLI 卡住,整个 pipeline 会永远阻塞 - -**改动范围:** -- [task_runner/executor.py](task_runner/executor.py) — 任务执行核心 `execute_task()` / PTY 读取循环 - -**方案:** -1. 新增配置常量 `MAX_EXECUTION_SECONDS = 2400`(40 分钟) -2. 支持通过 `--timeout` CLI 参数覆盖(或在 `__init__.json` 项目级配置) -3. 在 PTY 读取循环中检查已用时间,超时时: - - 向子进程发送 `SIGTERM` - - 等待 5 秒,若未退出发送 `SIGKILL` - - 标记任务为 `failed`,`failure_reason = "timeout"` - - 记录日志 - - 继续执行下一个任务 -4. 在 Pipe fallback 模式中也加入相同超时逻辑 - -**实现要点:** -- PTY 模式:在 `select.select()` 循环中加时间判断 -- PIPE 模式:使用 `subprocess.Popen.wait(timeout=...)` + 循环检查 -- 或统一用监控线程在超时时 kill 子进程 - -**难度:** ⭐⭐⭐ 中等偏高 -**预计工时:** 45-60 分钟 -**依赖:** 无(但建议在 Issue #5 之前完成,提升稳定性) - ---- - -## 📋 Issue #5:提升运行稳定性 - -这是一个综合性优化,包含多个子项: - -### 5a — argparse 隐式导入修复 -- `parse_delay_range()` 引用了 `argparse.ArgumentTypeError`,但 `argparse` 未在文件顶部导入 -- 当前只是因为该函数从 argparse context 被调用才没出错 -- **修复:** 在 `executor.py` 顶部增加 `import argparse` - -### 5b — 子进程清理增强 -- 当前 SIGTERM 后有 5s 等待 + SIGKILL,但在某些边缘情况下(如 PTY EOF 但进程未退出),清理逻辑分散 -- **建议:** 在 `execute_task()` 的 finally 块中增加统一的子进程清理确认 - -### 5c — JSON 原子写入审计 -- 当前使用 tmp + rename 模式(好!),确认所有写入路径都遵循此模式 -- 检查是否有遗漏的直接 `open(f, 'w')` 写法 - -### 5d — 信号处理改进 -- `_ctrl_c_count` 无锁递增,虽然 Python GIL 保证了原子性,但在信号处理器中建议使用更安全的模式 -- 考虑用 `signal.pthread_sigmask` 或更安全的方式 - -### 5e — PTY/PIPE 降级日志 -- 当 PTY 不可用降级到 PIPE 时,应有明确日志告知用户 - -**改动范围:** -- [task_runner/executor.py](task_runner/executor.py) -- 可能涉及 [task_runner/task_set.py](task_runner/task_set.py)、[task_runner/project.py](task_runner/project.py) - -**难度:** ⭐⭐ 各子项简单,但需逐一排查 -**预计工时:** 60 分钟(全部子项) -**依赖:** 无 - ---- - -## 📋 Issue #6:企业微信机器人通知集成 - -这是最大的功能新增,建议拆分为以下子任务: - -### 6a — Webhook 通知基础架构 -**新增文件:** `task_runner/notify.py` - -**核心设计:** -```python -# 抽象基类,面向未来扩展(钉钉、飞书等) -class Notifier(ABC): - @abstractmethod - def send(self, message: dict) -> bool: ... - -class WeComNotifier(Notifier): - """企业微信群机器人通知""" - def __init__(self, webhook_url: str): ... - def send(self, message: dict) -> bool: ... -``` - -**环境变量配置:** -```bash -# 企业微信 Webhook -export TASK_RUNNER_WECOM_WEBHOOK="https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx" - -# 未来扩展 -# export TASK_RUNNER_DINGTALK_WEBHOOK="..." -# export TASK_RUNNER_FEISHU_WEBHOOK="..." - -# 总开关 -export TASK_RUNNER_NOTIFY_ENABLED="true" -``` - -也支持项目级 `__init__.json` 配置: -```json -{ - "notify": { - "wecom_webhook": "https://...", - "enabled": true - } -} -``` - -优先级:环境变量 > 项目配置 > 关闭 - -### 6b — 消息模板设计 - -**任务完成通知(单任务级 — 可选,默认不发):** -``` -✅ 任务完成:M10 会计要素(AccountElement)建模补齐 -项目:MASTER_DATA / master-data-v2 -耗时:4m 18s -状态:成功 -``` - -**批次/全部完成通知(必发):** -```markdown -📊 任务批次执行完成 - -项目:MASTER_DATA -任务集:master-data-v2 -执行时间:14:30:05 ~ 16:45:30(2h 15m) - -📈 执行结果: - ✅ 成功:12 - ❌ 失败:2 - ⏭️ 跳过:1 - -❌ 失败任务: - - M18 物料(Material)建模补齐 — timeout (40m) - - M22 客户(Customer)建模补齐 — exit code 1 - -下一步:python run.py run MASTER_DATA master-data-v2 --retry-failed -``` - -**中断通知:** -``` -⚡ 任务执行中断 - -项目:MASTER_DATA / master-data-v2 -中断时间:16:45:30 -当前任务:M18 物料(Material)建模补齐 -已完成:12/15 -恢复命令:python run.py run MASTER_DATA master-data-v2 -``` - -**错误通知:** -``` -❌ 任务执行错误 - -项目:MASTER_DATA / master-data-v2 -任务:M18 物料(Material)建模补齐 -错误原因:超时 (40m) / 异常退出 / ... -耗时:40m 00s -``` - -### 6c — 企业微信 API 对接 - -根据文档 https://developer.work.weixin.qq.com/document/path/99110 : - -```python -import urllib.request -import json - -def send_wecom_message(webhook_url: str, content: str, msg_type: str = "markdown") -> bool: - """发送企业微信机器人消息""" - payload = { - "msgtype": msg_type, - "markdown": {"content": content} - } - data = json.dumps(payload).encode("utf-8") - req = urllib.request.Request( - webhook_url, - data=data, - headers={"Content-Type": "application/json"}, - method="POST", - ) - try: - with urllib.request.urlopen(req, timeout=10) as resp: - result = json.loads(resp.read()) - return result.get("errcode") == 0 - except Exception: - return False # 通知失败不应阻断任务执行 -``` - -**关键原则:** -- 使用标准库 `urllib.request`,**不增加任何外部依赖** -- 通知失败不影响任务执行(catch all exceptions) -- 超时设为 10s,防止网络问题阻塞 -- 通知发送使用独立线程,不阻塞主流程 - -### 6d — 接入执行引擎 - -**触发时机:** - -| 事件 | 通知内容 | 默认开启 | -|------|---------|---------| -| 全部/批次完成 | 汇总统计 | ✅ 是 | -| 任务失败 | 失败详情 | ✅ 是 | -| 执行中断 (Ctrl+C) | 中断进度 | ✅ 是 | -| 单任务成功 | 任务完成信息 | ❌ 否(可通过 `--notify-each` 开启)| - -**改动点:** -- `executor.py` — 在 `_run_v3()` 中注入 notifier -- `commands/run_cmd.py` — 新增 `--notify` / `--no-notify` / `--notify-each` CLI 参数 -- `project.py` — ProjectConfig 新增 `notify` 配置段 - -### 6e — CLI 参数 - -```bash -# 使用环境变量中配置的 webhook(默认行为) -python run.py run MY_PROJECT tasks - -# 显式关闭通知 -python run.py run MY_PROJECT tasks --no-notify - -# 每个任务完成都通知 -python run.py run MY_PROJECT tasks --notify-each - -# 命令行指定 webhook(覆盖环境变量) -python run.py run MY_PROJECT tasks --wecom-webhook "https://..." -``` - -**改动范围:** -- 新增 [task_runner/notify.py](task_runner/notify.py) -- 修改 [task_runner/executor.py](task_runner/executor.py) -- 修改 [task_runner/commands/run_cmd.py](task_runner/commands/run_cmd.py) -- 修改 [task_runner/project.py](task_runner/project.py) - -**难度:** ⭐⭐⭐⭐ 较复杂(但可拆分实施) -**预计工时:** 2-3 小时(全部子任务) -**依赖:** 建议在 Issue #4 完成后再做(这样 timeout 错误也能推送通知) - ---- - -## 🗓️ 建议实施顺序 - -``` -优先级排序(由易到难、由核心到外围): - -第 1 轮 — 核心体验修复(30 min) - ├── Issue #1 显示完成时间 [10 min] ⭐ - ├── Issue #2 修复 spinner/elapsed/开始时间 [20 min] ⭐⭐ - └── Issue #3 默认工具改 kimi [10 min] ⭐ - -第 2 轮 — 稳定性增强(1.5 h) - ├── Issue #4 40 分钟超时自动失败 [45 min] ⭐⭐⭐ - └── Issue #5 稳定性子项 [45 min] ⭐⭐ - -第 3 轮 — 通知能力(2-3 h) - ├── Issue #6a 通知基础架构 [30 min] - ├── Issue #6b 消息模板 [30 min] - ├── Issue #6c 企业微信 API [30 min] - ├── Issue #6d 接入执行引擎 [45 min] - └── Issue #6e CLI 参数 [15 min] -``` - ---- - -## 📁 受影响文件一览 - -| 文件 | Issue | 改动类型 | -|------|-------|---------| -| `task_runner/display/tracker.py` | #1, #2 | 修改 | -| `task_runner/project.py` | #3, #6d | 修改 | -| `task_runner/executor.py` | #4, #5, #6d | 修改 | -| `task_runner/commands/run_cmd.py` | #4(?), #6e | 修改 | -| `task_runner/notify.py` | #6 | **新增** | -| `task_runner/config.py` | #4(常量) | 修改 | -| `README.md` | #6 | 更新文档 | diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index e28061d..0193d30 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -101,6 +101,12 @@ python run.py dry-run MY_PROJECT fix-bugs # 确认无误后执行 python run.py run MY_PROJECT fix-bugs + +# 一次性顺序执行多个任务集 +python run.py run MY_PROJECT fix-bugs migration refactor + +# 执行项目内所有任务集 +python run.py run MY_PROJECT --all ``` --- @@ -132,6 +138,15 @@ python run.py project archive FIX_CODE # 基本执行(使用项目默认 tool/model) python run.py run FIX_CODE code-quality-fix +# 一次性顺序执行多个任务集 +python run.py run FIX_CODE code-quality-fix migration refactor + +# 执行项目内所有任务集(按 task_set_order 或字母序) +python run.py run FIX_CODE --all + +# 多任务集 + 遇错停止(默认遇错继续执行下一个任务集) +python run.py run FIX_CODE --all --stop-on-error + # 指定工具和模型 python run.py run FIX_CODE code-quality-fix --tool agent --model opus-4.6 python run.py run FIX_CODE code-quality-fix --tool kimi @@ -315,6 +330,31 @@ python run.py run MY_PROJECT my-tasks # → 自动从上次中断的位置继续 ``` +### 场景 6:多任务集顺序执行 + +当项目中有多个任务集需要按顺序执行(如先修复、再迁移、最后重构): + +```bash +# 方式 1:显式指定执行顺序 +python run.py run MY_PROJECT fix-bugs migration refactor + +# 方式 2:使用 --all 执行所有任务集 +python run.py run MY_PROJECT --all + +# 方式 3:在 __init__.json 中配置 task_set_order 后使用 --all +# __init__.json 中添加:"task_set_order": ["fix-bugs", "migration", "refactor"] +python run.py run MY_PROJECT --all + +# 遇到错误立即停止(默认会继续执行下一个任务集) +python run.py run MY_PROJECT --all --stop-on-error + +# 先预览所有任务集 +python run.py dry-run MY_PROJECT --all +``` + +> 💡 **提示:** 多任务集执行时,每个任务集之间会显示进度分隔线和汇总面板。 +> 按 Ctrl+C 中断后,当前任务集的已完成任务状态会被保存,后续任务集不会执行。 + ### 场景 5:进程守护 / 后台长时间运行 当需要在 supervisor、systemd 或 nohup 下运行时,使用 `--daemon` 模式: @@ -323,9 +363,12 @@ python run.py run MY_PROJECT my-tasks # 显式指定 daemon 模式 python run.py run MY_PROJECT my-tasks --delay 111-229 --daemon +# 多任务集 + daemon 模式 +python run.py run MY_PROJECT --all --delay 111-229 --daemon + # 自动检测:当 stdout 不是 TTY 时自动启用 daemon 模式 # 所以在 supervisor / systemd / nohup 下不加 --daemon 也能正常工作 -nohup python run.py run MY_PROJECT my-tasks --delay 111-229 > task.log 2>&1 & +nohup python run.py run MY_PROJECT --all --delay 111-229 > task.log 2>&1 & ``` **Supervisor / systemd 配置关键说明:** @@ -353,7 +396,7 @@ which python # 确保是 venv 内的 python,例如 /path/to/auto-run-task/ ```ini [program:auto-task-runner] -command=/path/to/auto-run-task/.task_env/bin/python /path/to/auto-run-task/run.py run MY_PROJECT my-tasks --delay 111-229 +command=/path/to/auto-run-task/.task_env/bin/python /path/to/auto-run-task/run.py run MY_PROJECT --all --delay 111-229 directory=/path/to/auto-run-task autostart=true autorestart=false @@ -376,7 +419,7 @@ After=network.target Type=simple User=deploy WorkingDirectory=/path/to/auto-run-task -ExecStart=/path/to/auto-run-task/.task_env/bin/python run.py run MY_PROJECT my-tasks --delay 111-229 +ExecStart=/path/to/auto-run-task/.task_env/bin/python run.py run MY_PROJECT --all --delay 111-229 Restart=no StandardOutput=journal StandardError=journal @@ -427,6 +470,7 @@ WantedBy=multi-user.target "default_tool": "kimi", "default_model": "", "tags": ["code-quality"], + "task_set_order": ["code-quality-fix", "migration", "refactor"], "run_record": [ { "run_at": "2024-06-01_10-00-00", @@ -442,6 +486,11 @@ WantedBy=multi-user.target } ``` +| 字段 | 说明 | +| --- | --- | +| `task_set_order` | 可选。定义 `--all` 时的任务集执行顺序。未列出的任务集会按字母序追加到末尾。不配置则默认按字母序执行所有任务集。 | +``` + ### `.tasks.json` — 任务集 ```json diff --git a/task_runner/cli.py b/task_runner/cli.py index 4e60b21..f72bedf 100644 --- a/task_runner/cli.py +++ b/task_runner/cli.py @@ -104,11 +104,40 @@ def _add_run_subparser(subparsers): """Add the 'run' subcommand.""" run_parser = subparsers.add_parser( "run", - help="Execute tasks from a project's task set", + help="Execute tasks from a project's task set(s)", formatter_class=argparse.RawDescriptionHelpFormatter, + epilog="""examples: + # Run a single task set + %(prog)s MY_PROJECT fix-bugs + + # Run multiple task sets sequentially + %(prog)s MY_PROJECT setup migration cleanup + + # Run all task sets in the project + %(prog)s MY_PROJECT --all + + # Run all task sets with project-defined order (task_set_order in __init__.json) + %(prog)s MY_PROJECT --all +""", ) run_parser.add_argument("project_name", help="Project name") - run_parser.add_argument("task_set_name", help="Task set name (without .tasks.json)") + run_parser.add_argument( + "task_set_names", + nargs="*", + metavar="TASK_SET", + help="Task set name(s) (without .tasks.json). Specify one or more, or use --all.", + ) + run_parser.add_argument( + "--all", + dest="run_all", + action="store_true", + help="Run all task sets in the project (order: task_set_order in __init__.json, then alphabetical)", + ) + run_parser.add_argument( + "--stop-on-error", + action="store_true", + help="Stop execution if any task set fails (default: continue to next task set)", + ) _add_execution_options(run_parser) @@ -121,7 +150,18 @@ def _add_dryrun_subparser(subparsers): formatter_class=argparse.RawDescriptionHelpFormatter, ) dryrun_parser.add_argument("project_name", help="Project name") - dryrun_parser.add_argument("task_set_name", help="Task set name (without .tasks.json)") + dryrun_parser.add_argument( + "task_set_names", + nargs="*", + metavar="TASK_SET", + help="Task set name(s) (without .tasks.json). Specify one or more, or use --all.", + ) + dryrun_parser.add_argument( + "--all", + dest="run_all", + action="store_true", + help="Dry-run all task sets in the project", + ) _add_execution_options(dryrun_parser) diff --git a/task_runner/commands/dryrun_cmd.py b/task_runner/commands/dryrun_cmd.py index d73c923..ceeb22b 100644 --- a/task_runner/commands/dryrun_cmd.py +++ b/task_runner/commands/dryrun_cmd.py @@ -2,9 +2,9 @@ Dry-run command: generate prompts without executing. """ -from .run_cmd import _execute +from .run_cmd import _dispatch def handle_dryrun(args) -> int: """Dry-run: generate prompts only, no execution.""" - return _execute(args, dry_run=True) + return _dispatch(args, dry_run=True) diff --git a/task_runner/commands/run_cmd.py b/task_runner/commands/run_cmd.py index 2f5288e..4eea20f 100644 --- a/task_runner/commands/run_cmd.py +++ b/task_runner/commands/run_cmd.py @@ -1,11 +1,21 @@ """ Run command: orchestrate the full execution flow. + +Supports executing one or more task sets sequentially within a project. """ +import signal +import time from datetime import datetime from ..config import get_tool_config -from ..display import show_error, show_info +from ..display import ( + show_error, + show_info, + show_multi_task_set_header, + show_multi_task_set_summary, + show_task_set_divider, +) from ..project import ( RunRecord, add_run_record, @@ -22,18 +32,171 @@ update_latest_symlink, ) from ..scheduler import schedule_tasks -from ..task_set import load_task_set +from ..task_set import discover_task_sets, load_task_set def handle_run(args) -> int: - """Execute tasks in a project.""" - return _execute(args, dry_run=False) + """Execute tasks in a project (one or more task sets).""" + return _dispatch(args, dry_run=False) + + +def _resolve_task_set_names(args, project_name: str) -> list[str] | None: + """Resolve the list of task set names from CLI args. + + Returns a list of task set names, or None on error. + """ + run_all = getattr(args, "run_all", False) + names = getattr(args, "task_set_names", []) or [] + + if not names and not run_all: + show_error( + "Please specify task set name(s) or use --all.\n" + " Example: python run.py run PROJECT task1 task2\n" + " Example: python run.py run PROJECT --all" + ) + return None + + if run_all: + # Load project to check task_set_order + try: + config = load_project(project_name) + except FileNotFoundError: + show_error(f"Project '{project_name}' not found!") + return None + + project_dir = get_project_dir(project_name) + all_sets = discover_task_sets(project_dir) + + if not all_sets: + show_error(f"No task sets found in project '{project_name}'!") + return None + + # Use task_set_order if defined, otherwise alphabetical + if config.task_set_order: + ordered = [] + for name in config.task_set_order: + if name in all_sets: + ordered.append(name) + else: + show_error( + f"Task set '{name}' in task_set_order not found in project '{project_name}'!" + ) + return None + # Append any discovered sets not listed in order + for name in all_sets: + if name not in ordered: + ordered.append(name) + return ordered + + return all_sets + + # Validate specified names exist + project_dir = get_project_dir(project_name) + all_sets = discover_task_sets(project_dir) + for name in names: + if name not in all_sets: + show_error( + f"Task set '{name}' not found in project '{project_name}'!\n" + f" Available: {', '.join(all_sets)}" + ) + return None + + return names + + +def _dispatch(args, dry_run: bool = False) -> int: + """Resolve task sets and dispatch to single or multi execution.""" + project_name = args.project_name + task_set_names = _resolve_task_set_names(args, project_name) + if task_set_names is None: + return 1 + if len(task_set_names) == 1: + return _execute_single(args, task_set_names[0], dry_run=dry_run) -def _execute(args, dry_run: bool = False) -> int: - """Shared execution logic for run and dry-run.""" + return _execute_multi(args, task_set_names, dry_run=dry_run) + + +def _execute_multi(args, task_set_names: list[str], dry_run: bool = False) -> int: + """Execute multiple task sets sequentially.""" + project_name = args.project_name + stop_on_error = getattr(args, "stop_on_error", False) + + # ── Daemon mode detection (must run before any display calls) ── + import sys as _sys + + from ..display import auto_detect_daemon_mode, enable_daemon_mode + + daemon = getattr(args, "daemon", False) + if daemon: + enable_daemon_mode() + elif not _sys.stdout.isatty(): + auto_detect_daemon_mode() + + # Show multi-task-set header + show_multi_task_set_header(task_set_names, project_name) + + results: list[dict] = [] + overall_start = time.time() + interrupted = False + + for idx, ts_name in enumerate(task_set_names): + show_task_set_divider(idx + 1, len(task_set_names), ts_name) + + ts_start = time.time() + interrupt_flag: list[bool] = [] + + try: + code = _execute_single( + args, ts_name, dry_run=dry_run, _interrupt_flag=interrupt_flag + ) + except KeyboardInterrupt: + interrupted = True + code = 130 + results.append({ + "task_set_name": ts_name, + "code": code, + "elapsed": time.time() - ts_start, + }) + break + + ts_elapsed = time.time() - ts_start + results.append({ + "task_set_name": ts_name, + "code": code, + "elapsed": ts_elapsed, + }) + + # Restore default signal handling between task sets so CTRL+C + # raises KeyboardInterrupt and SIGTERM terminates cleanly + # (executor installs its own handlers for both signals) + signal.signal(signal.SIGINT, signal.default_int_handler) + signal.signal(signal.SIGTERM, signal.SIG_DFL) + + # Stop if interrupted inside executor + if interrupt_flag: + interrupted = True + break + + # Stop on error if requested + if stop_on_error and code != 0: + show_info(f"Stopping: task set '{ts_name}' failed (--stop-on-error)") + break + + overall_elapsed = time.time() - overall_start + show_multi_task_set_summary(results, overall_elapsed, interrupted=interrupted) + + return 0 if all(r["code"] == 0 for r in results) else 1 + + +def _execute_single( + args, + task_set_name: str, + dry_run: bool = False, + _interrupt_flag: list[bool] | None = None, +) -> int: + """Execute a single task set. Core execution logic.""" project_name = args.project_name - task_set_name = args.task_set_name # ── Load project ── try: @@ -102,7 +265,7 @@ def _execute(args, dry_run: bool = False) -> int: ) if not scheduled: - show_info("No tasks to execute after filtering.") + show_info(f"No tasks to execute in '{task_set_name}' after filtering.") return 0 # ── Resolve proxy ── @@ -131,8 +294,6 @@ def _execute(args, dry_run: bool = False) -> int: daemon = getattr(args, "daemon", False) # ── Daemon / supervisor mode ── - # Explicit --daemon flag OR auto-detect when stdout is not a TTY - # (e.g. supervisord, systemd, nohup, piped output). import sys as _sys from ..display import auto_detect_daemon_mode, enable_daemon_mode, is_daemon_mode @@ -143,7 +304,7 @@ def _execute(args, dry_run: bool = False) -> int: auto_detect_daemon_mode() if is_daemon_mode(): - no_color = True # force no-color in daemon mode + no_color = True if no_color: from ..display import console as _console @@ -151,7 +312,7 @@ def _execute(args, dry_run: bool = False) -> int: _console.no_color = True if verbose: - heartbeat = min(heartbeat, 15) # More frequent heartbeats in verbose mode + heartbeat = min(heartbeat, 15) # ── Create run context ── filters = { @@ -198,7 +359,6 @@ def _execute(args, dry_run: bool = False) -> int: delay_range = parse_delay_range(getattr(args, "delay", None)) - # Resolve per-task timeout from ..config import MAX_EXECUTION_SECONDS cli_timeout = getattr(args, "timeout", None) @@ -236,6 +396,10 @@ def _execute(args, dry_run: bool = False) -> int: result_code = executor.run() + # Propagate interrupt flag to caller + if _interrupt_flag is not None and executor.interrupted: + _interrupt_flag.append(True) + # ── Save run summary ── results = executor.get_results() task_results = executor.get_task_results() diff --git a/task_runner/display/__init__.py b/task_runner/display/__init__.py index 8d55107..9e6ecb9 100644 --- a/task_runner/display/__init__.py +++ b/task_runner/display/__init__.py @@ -51,7 +51,14 @@ ) # ─── Summary / progress ────────────────────────────────────────── -from .summary import show_all_done, show_progress_bar, show_summary +from .summary import ( + show_all_done, + show_multi_task_set_header, + show_multi_task_set_summary, + show_progress_bar, + show_summary, + show_task_set_divider, +) # ─── Task display ──────────────────────────────────────────────── from .tasks import ( @@ -107,6 +114,8 @@ "show_run_history", "show_schedule_plan", # Summary + "show_multi_task_set_header", + "show_multi_task_set_summary", "show_summary", "show_task_cmd", # Tasks @@ -116,6 +125,7 @@ "show_task_prompt_info", "show_task_result", "show_task_running", + "show_task_set_divider", "show_task_set_list", "show_task_skip", "show_task_start", diff --git a/task_runner/display/summary.py b/task_runner/display/summary.py index 7ae6cd5..5617ee7 100644 --- a/task_runner/display/summary.py +++ b/task_runner/display/summary.py @@ -9,6 +9,102 @@ from .core import STATUS_ICONS, _format_elapsed, console +def show_multi_task_set_header(task_set_names: list[str], project: str): + """Display header for multi-task-set sequential execution.""" + names_str = ", ".join(f"[magenta]{n}[/magenta]" for n in task_set_names) + panel = Panel( + f"[bold]Project:[/bold] [cyan]{project}[/cyan]\n" + f"[bold]Task Sets ({len(task_set_names)}):[/bold] {names_str}\n" + f"[bold]Mode:[/bold] Sequential execution", + title="[bold] 📋 Multi Task Set Execution [/bold]", + border_style="bright_blue", + box=box.DOUBLE_EDGE, + padding=(1, 2), + ) + console.print(panel) + console.print() + + +def show_task_set_divider(current: int, total: int, name: str): + """Display a separator between task sets in multi-mode.""" + console.print() + console.rule( + f"[bold cyan]📦 Task Set [{current}/{total}]: {name}[/bold cyan]", + style="bright_blue", + ) + console.print() + + +def show_multi_task_set_summary( + results: list[dict], + total_elapsed: float, + interrupted: bool = False, +): + """Display combined summary for multi-task-set execution.""" + all_succeeded = all(r["code"] == 0 for r in results) + + if interrupted: + title = "⚠️ [bold yellow]Multi Task Set Execution Interrupted[/bold yellow]" + border = "yellow" + elif all_succeeded: + title = "🎉 [bold green]All Task Sets Completed Successfully![/bold green]" + border = "green" + else: + title = "📊 [bold]Multi Task Set Execution Summary[/bold]" + border = "red" + + # Per task set table + table = Table( + box=box.SIMPLE_HEAVY, + show_header=True, + header_style="bold", + padding=(0, 1), + ) + table.add_column("#", width=4, justify="center") + table.add_column("Task Set", style="bold", min_width=15) + table.add_column("Status", width=10, justify="center") + table.add_column("Duration", width=12, justify="right", style="cyan") + + sets_ok = 0 + sets_fail = 0 + for i, r in enumerate(results, 1): + ok = r["code"] == 0 + icon = "✅" if ok else "❌" + if ok: + sets_ok += 1 + else: + sets_fail += 1 + table.add_row( + str(i), + r["task_set_name"], + icon, + _format_elapsed(r["elapsed"]), + ) + + console.print() + console.rule("[bold]Multi Task Set Summary[/bold]", style="bright_blue") + console.print() + console.print(table) + + # Overall stats + lines = [ + f"[green]✅ Succeeded[/green] │ {sets_ok} task set(s)", + f"[red]❌ Failed[/red] │ {sets_fail} task set(s)", + f"[cyan]⏱ Duration[/cyan] │ {_format_elapsed(total_elapsed)}", + ] + + panel = Panel( + "\n".join(lines), + title=title, + border_style=border, + box=box.DOUBLE_EDGE, + padding=(1, 2), + ) + console.print() + console.print(panel) + console.print() + + def show_summary( succeeded: int, failed: int, diff --git a/task_runner/project.py b/task_runner/project.py index 1fab573..c3e0b1f 100644 --- a/task_runner/project.py +++ b/task_runner/project.py @@ -88,13 +88,14 @@ class ProjectConfig: default_tool: str = "kimi" default_model: str = "" tags: list[str] = field(default_factory=list) + task_set_order: list[str] = field(default_factory=list) run_record: list[RunRecord] = field(default_factory=list) # Internal: path to the project directory _project_dir: Path | None = field(default=None, repr=False) def to_dict(self) -> dict: - return { + d = { "project": self.project, "description": self.description, "workspace": self.workspace, @@ -105,6 +106,9 @@ def to_dict(self) -> dict: "tags": self.tags, "run_record": [r.to_dict() for r in self.run_record], } + if self.task_set_order: + d["task_set_order"] = self.task_set_order + return d @classmethod def from_dict(cls, d: dict, project_dir: Path | None = None) -> "ProjectConfig": @@ -118,6 +122,7 @@ def from_dict(cls, d: dict, project_dir: Path | None = None) -> "ProjectConfig": default_tool=d.get("default_tool", "kimi"), default_model=d.get("default_model", ""), tags=d.get("tags", []), + task_set_order=d.get("task_set_order", []), run_record=records, _project_dir=project_dir, ) diff --git a/tests/test_run_cmd.py b/tests/test_run_cmd.py new file mode 100644 index 0000000..53d1092 --- /dev/null +++ b/tests/test_run_cmd.py @@ -0,0 +1,442 @@ +"""Tests for task_runner.commands.run_cmd — multi-task-set support.""" + +import argparse +import json +import shutil +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from task_runner.commands.run_cmd import _dispatch, _resolve_task_set_names +from task_runner.project import ProjectConfig + + +# ─── Helpers ─────────────────────────────────────────────────────── + + +def _make_args(**kwargs) -> argparse.Namespace: + """Build a minimal argparse.Namespace matching CLI expectations.""" + defaults = { + "project_name": "TEST_PROJECT", + "task_set_names": [], + "run_all": False, + "stop_on_error": False, + "tool": None, + "model": None, + "template": None, + "proxy_mode": None, + "batch": None, + "min_priority": None, + "start": None, + "retry_failed": False, + "work_dir": None, + "heartbeat": 60, + "delay": None, + "timeout": None, + "git_safety": False, + "verbose": False, + "quiet": True, # suppress banner in tests + "no_color": True, + "daemon": False, + "notify_enabled": False, + "notify_each": False, + "wecom_webhook": None, + } + defaults.update(kwargs) + return argparse.Namespace(**defaults) + + +@pytest.fixture +def project_dir(tmp_path): + """Create a temporary project structure for testing.""" + proj = tmp_path / "projects" / "TEST_PROJECT" + proj.mkdir(parents=True) + + # __init__.json + config = { + "project": "TEST_PROJECT", + "workspace": str(tmp_path), + "status": "planned", + "created_at": "2026-01-01_00-00-00", + "default_tool": "kimi", + "default_model": "", + "tags": [], + "run_record": [], + } + (proj / "__init__.json").write_text(json.dumps(config), encoding="utf-8") + + # templates + tpl_dir = proj / "templates" + tpl_dir.mkdir() + (tpl_dir / "__init__.md").write_text("{{task_name}}\n#item\n", encoding="utf-8") + + # runtime + (proj / "runtime" / "runs").mkdir(parents=True) + (proj / "runtime" / "backups").mkdir(parents=True) + + # Task sets + ts_alpha = {"template": "templates/__init__.md", "tasks": [ + {"task_no": "A-1", "task_name": "Alpha task 1", "batch": 1, "priority": 10, "status": "not-started"}, + ]} + (proj / "alpha.tasks.json").write_text(json.dumps(ts_alpha), encoding="utf-8") + + ts_beta = {"template": "templates/__init__.md", "tasks": [ + {"task_no": "B-1", "task_name": "Beta task 1", "batch": 1, "priority": 10, "status": "not-started"}, + ]} + (proj / "beta.tasks.json").write_text(json.dumps(ts_beta), encoding="utf-8") + + ts_gamma = {"template": "templates/__init__.md", "tasks": [ + {"task_no": "G-1", "task_name": "Gamma task 1", "batch": 1, "priority": 10, "status": "not-started"}, + ]} + (proj / "gamma.tasks.json").write_text(json.dumps(ts_gamma), encoding="utf-8") + + return proj + + +# ─── _resolve_task_set_names ────────────────────────────────────── + + +class TestResolveTaskSetNames: + """Test the task set name resolution logic.""" + + def test_no_names_and_no_all_returns_none(self): + args = _make_args() + result = _resolve_task_set_names(args, "NONEXISTENT") + assert result is None + + def test_explicit_names_returned_in_order(self, project_dir): + args = _make_args(task_set_names=["beta", "alpha"]) + with patch("task_runner.commands.run_cmd.get_project_dir", return_value=project_dir): + result = _resolve_task_set_names(args, "TEST_PROJECT") + assert result == ["beta", "alpha"] + + def test_nonexistent_name_returns_none(self, project_dir): + args = _make_args(task_set_names=["alpha", "missing"]) + with patch("task_runner.commands.run_cmd.get_project_dir", return_value=project_dir): + result = _resolve_task_set_names(args, "TEST_PROJECT") + assert result is None + + def test_all_returns_alphabetical(self, project_dir): + args = _make_args(run_all=True) + config = ProjectConfig( + project="TEST_PROJECT", workspace=str(project_dir.parent.parent), + task_set_order=[], + ) + with ( + patch("task_runner.commands.run_cmd.load_project", return_value=config), + patch("task_runner.commands.run_cmd.get_project_dir", return_value=project_dir), + ): + result = _resolve_task_set_names(args, "TEST_PROJECT") + assert result == ["alpha", "beta", "gamma"] + + def test_all_with_order_respects_order(self, project_dir): + args = _make_args(run_all=True) + config = ProjectConfig( + project="TEST_PROJECT", workspace=str(project_dir.parent.parent), + task_set_order=["gamma", "alpha"], + ) + with ( + patch("task_runner.commands.run_cmd.load_project", return_value=config), + patch("task_runner.commands.run_cmd.get_project_dir", return_value=project_dir), + ): + result = _resolve_task_set_names(args, "TEST_PROJECT") + # gamma, alpha from order, then beta appended (not in order list) + assert result == ["gamma", "alpha", "beta"] + + def test_all_with_order_invalid_name_returns_none(self, project_dir): + args = _make_args(run_all=True) + config = ProjectConfig( + project="TEST_PROJECT", workspace=str(project_dir.parent.parent), + task_set_order=["gamma", "nonexistent"], + ) + with ( + patch("task_runner.commands.run_cmd.load_project", return_value=config), + patch("task_runner.commands.run_cmd.get_project_dir", return_value=project_dir), + ): + result = _resolve_task_set_names(args, "TEST_PROJECT") + assert result is None + + def test_all_empty_project_returns_none(self, tmp_path): + empty_proj = tmp_path / "projects" / "EMPTY" + empty_proj.mkdir(parents=True) + config_data = { + "project": "EMPTY", "workspace": str(tmp_path), + "status": "planned", "created_at": "2026-01-01_00-00-00", + "default_tool": "kimi", "default_model": "", "tags": [], "run_record": [], + } + (empty_proj / "__init__.json").write_text(json.dumps(config_data), encoding="utf-8") + + args = _make_args(run_all=True, project_name="EMPTY") + config = ProjectConfig(project="EMPTY", workspace=str(tmp_path), task_set_order=[]) + with ( + patch("task_runner.commands.run_cmd.load_project", return_value=config), + patch("task_runner.commands.run_cmd.get_project_dir", return_value=empty_proj), + ): + result = _resolve_task_set_names(args, "EMPTY") + assert result is None + + def test_single_name_returned_as_list(self, project_dir): + args = _make_args(task_set_names=["alpha"]) + with patch("task_runner.commands.run_cmd.get_project_dir", return_value=project_dir): + result = _resolve_task_set_names(args, "TEST_PROJECT") + assert result == ["alpha"] + + +# ─── ProjectConfig.task_set_order ───────────────────────────────── + + +class TestProjectConfigTaskSetOrder: + """Test task_set_order field in ProjectConfig.""" + + def test_from_dict_reads_order(self): + data = { + "project": "X", "workspace": "/tmp", "task_set_order": ["c", "a", "b"], + } + config = ProjectConfig.from_dict(data) + assert config.task_set_order == ["c", "a", "b"] + + def test_from_dict_default_empty(self): + data = {"project": "X", "workspace": "/tmp"} + config = ProjectConfig.from_dict(data) + assert config.task_set_order == [] + + def test_to_dict_includes_order_when_set(self): + config = ProjectConfig(project="X", workspace="/tmp", task_set_order=["a", "b"]) + d = config.to_dict() + assert d["task_set_order"] == ["a", "b"] + + def test_to_dict_omits_order_when_empty(self): + config = ProjectConfig(project="X", workspace="/tmp", task_set_order=[]) + d = config.to_dict() + assert "task_set_order" not in d + + +# ─── CLI Argument Parsing ───────────────────────────────────────── + + +class TestCLIParsing: + """Test that the CLI parser handles multi-task-set args correctly.""" + + def test_single_task_set(self): + from task_runner.cli import parse_args + args = parse_args(["run", "MY_PROJECT", "fix-bugs"]) + assert args.project_name == "MY_PROJECT" + assert args.task_set_names == ["fix-bugs"] + assert args.run_all is False + + def test_multiple_task_sets(self): + from task_runner.cli import parse_args + args = parse_args(["run", "MY_PROJECT", "setup", "migration", "cleanup"]) + assert args.task_set_names == ["setup", "migration", "cleanup"] + + def test_all_flag(self): + from task_runner.cli import parse_args + args = parse_args(["run", "MY_PROJECT", "--all"]) + assert args.run_all is True + assert args.task_set_names == [] + + def test_stop_on_error_flag(self): + from task_runner.cli import parse_args + args = parse_args(["run", "MY_PROJECT", "--all", "--stop-on-error"]) + assert args.stop_on_error is True + + def test_dryrun_multiple_task_sets(self): + from task_runner.cli import parse_args + args = parse_args(["dry-run", "MY_PROJECT", "a", "b"]) + assert args.task_set_names == ["a", "b"] + + def test_dryrun_all_flag(self): + from task_runner.cli import parse_args + args = parse_args(["dry-run", "MY_PROJECT", "--all"]) + assert args.run_all is True + + def test_no_task_set_no_all_parsed_ok(self): + """Parser should accept no task sets (validation is in the handler).""" + from task_runner.cli import parse_args + args = parse_args(["run", "MY_PROJECT"]) + assert args.task_set_names == [] + assert args.run_all is False + + +# ─── _dispatch routing ──────────────────────────────────────────── + + +class TestDispatchRouting: + """Test that _dispatch routes correctly to single vs multi execution.""" + + def test_single_calls_execute_single(self, project_dir): + args = _make_args(task_set_names=["alpha"]) + with ( + patch("task_runner.commands.run_cmd._resolve_task_set_names", return_value=["alpha"]), + patch("task_runner.commands.run_cmd._execute_single", return_value=0) as mock_single, + ): + code = _dispatch(args, dry_run=False) + assert code == 0 + mock_single.assert_called_once_with(args, "alpha", dry_run=False) + + def test_multi_calls_execute_multi(self, project_dir): + args = _make_args(task_set_names=["alpha", "beta"]) + with ( + patch("task_runner.commands.run_cmd._resolve_task_set_names", return_value=["alpha", "beta"]), + patch("task_runner.commands.run_cmd._execute_multi", return_value=0) as mock_multi, + ): + code = _dispatch(args, dry_run=False) + assert code == 0 + mock_multi.assert_called_once_with(args, ["alpha", "beta"], dry_run=False) + + def test_resolve_failure_returns_1(self): + args = _make_args() + with patch("task_runner.commands.run_cmd._resolve_task_set_names", return_value=None): + code = _dispatch(args, dry_run=False) + assert code == 1 + + +# ─── Multi execution integration ───────────────────────────────── + + +class TestExecuteMulti: + """Integration-level tests for multi-task-set execution.""" + + def test_stop_on_error_skips_remaining(self): + from task_runner.commands.run_cmd import _execute_multi + + args = _make_args( + task_set_names=["alpha", "beta", "gamma"], + stop_on_error=True, + ) + + call_count = {"n": 0} + + def mock_execute_single(a, name, dry_run=False, _interrupt_flag=None): + call_count["n"] += 1 + if name == "alpha": + return 1 # fail + return 0 + + with patch("task_runner.commands.run_cmd._execute_single", side_effect=mock_execute_single): + code = _execute_multi(args, ["alpha", "beta", "gamma"], dry_run=False) + + assert code == 1 + assert call_count["n"] == 1 # only first set executed + + def test_continue_on_error_by_default(self): + from task_runner.commands.run_cmd import _execute_multi + + args = _make_args( + task_set_names=["alpha", "beta", "gamma"], + stop_on_error=False, + ) + + call_count = {"n": 0} + + def mock_execute_single(a, name, dry_run=False, _interrupt_flag=None): + call_count["n"] += 1 + if name == "beta": + return 1 # fail + return 0 + + with patch("task_runner.commands.run_cmd._execute_single", side_effect=mock_execute_single): + code = _execute_multi(args, ["alpha", "beta", "gamma"], dry_run=False) + + assert code == 1 # overall fails + assert call_count["n"] == 3 # all sets executed + + def test_all_succeed_returns_0(self): + from task_runner.commands.run_cmd import _execute_multi + + args = _make_args(stop_on_error=False) + + def mock_execute_single(a, name, dry_run=False, _interrupt_flag=None): + return 0 + + with patch("task_runner.commands.run_cmd._execute_single", side_effect=mock_execute_single): + code = _execute_multi(args, ["alpha", "beta"], dry_run=False) + + assert code == 0 + + def test_interrupt_flag_stops_execution(self): + from task_runner.commands.run_cmd import _execute_multi + + args = _make_args(stop_on_error=False) + call_count = {"n": 0} + + def mock_execute_single(a, name, dry_run=False, _interrupt_flag=None): + call_count["n"] += 1 + if name == "alpha" and _interrupt_flag is not None: + _interrupt_flag.append(True) + return 1 + return 0 + + with patch("task_runner.commands.run_cmd._execute_single", side_effect=mock_execute_single): + code = _execute_multi(args, ["alpha", "beta"], dry_run=False) + + assert code == 1 + assert call_count["n"] == 1 # stopped after interrupt + + def test_dry_run_passed_through(self): + from task_runner.commands.run_cmd import _execute_multi + + args = _make_args(stop_on_error=False) + dry_run_values = [] + + def mock_execute_single(a, name, dry_run=False, _interrupt_flag=None): + dry_run_values.append(dry_run) + return 0 + + with patch("task_runner.commands.run_cmd._execute_single", side_effect=mock_execute_single): + _execute_multi(args, ["alpha", "beta"], dry_run=True) + + assert dry_run_values == [True, True] + + def test_daemon_mode_enabled_before_display(self): + """Daemon flag should trigger enable_daemon_mode before header display.""" + from task_runner.commands.run_cmd import _execute_multi + + args = _make_args(daemon=True, stop_on_error=False) + daemon_enabled_calls = [] + + original_enable = None + + def track_enable(): + daemon_enabled_calls.append(True) + + def mock_execute_single(a, name, dry_run=False, _interrupt_flag=None): + return 0 + + with ( + patch("task_runner.display.enable_daemon_mode", side_effect=track_enable), + patch("task_runner.display.core.enable_daemon_mode", side_effect=track_enable), + patch("task_runner.commands.run_cmd._execute_single", side_effect=mock_execute_single), + ): + _execute_multi(args, ["alpha"], dry_run=False) + + assert len(daemon_enabled_calls) >= 1 + + def test_sigterm_restored_between_task_sets(self): + """SIGTERM handler should be restored to SIG_DFL after each task set.""" + import signal + + from task_runner.commands.run_cmd import _execute_multi + + args = _make_args(stop_on_error=False) + sigterm_handlers = [] + + def mock_execute_single(a, name, dry_run=False, _interrupt_flag=None): + return 0 + + original_signal = signal.signal + + def tracking_signal(sig, handler): + if sig == signal.SIGTERM: + sigterm_handlers.append(handler) + return original_signal(sig, handler) + + with ( + patch("task_runner.commands.run_cmd._execute_single", side_effect=mock_execute_single), + patch("task_runner.commands.run_cmd.signal.signal", side_effect=tracking_signal), + ): + _execute_multi(args, ["alpha", "beta"], dry_run=False) + + # After each task set, SIGTERM should be restored to SIG_DFL + assert signal.SIG_DFL in sigterm_handlers From 764bbcaa6f4bbf7252b6c64868a058ac16897706 Mon Sep 17 00:00:00 2001 From: Vincent-new-macbook Date: Thu, 12 Mar 2026 11:03:59 +0800 Subject: [PATCH 4/4] feat: add OpenCode CLI support with model configuration and update documentation --- README.md | 3 ++- docs/USER_GUIDE.md | 7 +++++-- task_runner/config.py | 9 +++++++++ tests/test_config.py | 12 ++++++++++-- 4 files changed, 26 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index a431850..54206db 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ - 📁 **项目化架构** — 以项目为中心,支持多任务集、运行历史、模板管理 - 🔧 **多工具支持** — kimi / agent (Claude Code) / copilot / claude,一键切换 -- 🤖 **多模型选择** — 项目级、任务集级、任务级可独立配置 tool/model +- 🤖 **多模型选择** — 项目级、任务集级、任务级可独立配置 tool/model(opencode 使用 provider/model 格式) - 📋 **结构化任务集** — `.tasks.json` 定义任务,`{{key}}` + `#item` 模板渲染 - 🗂️ **运行时管理** — 每次运行自动创建运行目录、备份任务集、记录历史 - 🎯 **智能调度** — batch + priority 排序,依赖验证,支持过滤和重试 @@ -84,6 +84,7 @@ python run.py run MY_PROJECT ts1 ts2 ts3 # 顺序执行多个任务集 | `agent` | `opus-4.6` | ✓ | Claude Code Agent CLI | | `copilot` | `claude-opus-4.6` | ✓ | GitHub Copilot CLI | | `claude` | 固定 | ✓ | Claude CLI(单模型) | +| `opencode`| `minimax-cn-coding-plan/MiniMax-M2.5-highspeed` | ✗ | OpenCode CLI(多 provider) | --- diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 0193d30..72e8b44 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -151,6 +151,7 @@ python run.py run FIX_CODE --all --stop-on-error python run.py run FIX_CODE code-quality-fix --tool agent --model opus-4.6 python run.py run FIX_CODE code-quality-fix --tool kimi python run.py run FIX_CODE code-quality-fix --tool copilot --model claude-opus-4.6 +python run.py run FIX_CODE code-quality-fix --tool opencode --model minimax/MiniMax-M2.5-highspeed # 只运行指定批次 python run.py run FIX_CODE code-quality-fix --batch 1 @@ -316,7 +317,8 @@ python run.py run MY_PROJECT migration --batch 3 "tasks": [ { "task_no": "T-1", "cli": { "tool": "kimi" }, "..." : "..." }, { "task_no": "T-2", "cli": { "tool": "agent", "model": "opus-4.6" }, "..." : "..." }, - { "task_no": "T-3", "cli": { "tool": "copilot", "model": "claude-opus-4.6" }, "..." : "..." } + { "task_no": "T-3", "cli": { "tool": "copilot", "model": "claude-opus-4.6" }, "..." : "..." }, + { "task_no": "T-4", "cli": { "tool": "opencode", "model": "minimax/MiniMax-M2.5-highspeed" }, "..." : "..." } ] } ``` @@ -374,7 +376,7 @@ nohup python run.py run MY_PROJECT --all --delay 111-229 > task.log 2>&1 & **Supervisor / systemd 配置关键说明:** Supervisor 和 systemd 使用极简环境启动进程,**不会加载你的 `.bashrc` / `.zshrc`**, -因此 `kimi`、`agent`、`copilot`、`claude` 等 CLI 工具的路径不在默认 `PATH` 中, +因此 `kimi`、`agent`、`copilot`、`claude`、`opencode` 等 CLI 工具的路径不在默认 `PATH` 中, 执行时会报 `Tool Not Found` 或 `command not found`。 你需要先查出每个工具的完整路径,然后在配置中通过 `environment` 传入: @@ -385,6 +387,7 @@ which kimi # 例如 /usr/local/bin/kimi which agent # 例如 /home/deploy/.local/bin/agent which copilot # 例如 /www/server/nodejs/v22.17.1/bin/copilot which claude # 例如 /home/deploy/.local/bin/claude +which opencode # 例如 /usr/local/bin/opencode # 查询 Python 虚拟环境路径 which python # 确保是 venv 内的 python,例如 /path/to/auto-run-task/.task_env/bin/python diff --git a/task_runner/config.py b/task_runner/config.py index c447573..3a43616 100644 --- a/task_runner/config.py +++ b/task_runner/config.py @@ -91,6 +91,15 @@ class ToolConfig: supports_model=False, description="Claude CLI (claude-opus-4-6 only) — 需要代理", ), + "opencode": ToolConfig( + name="opencode", + cmd_template='opencode run --model {model} "$(cat {task_file})"', + needs_proxy=False, + supports_model=True, + default_model="minimax-cn-coding-plan/MiniMax-M2.5-highspeed", + models=[], # opencode supports many providers — run `opencode models` to list + description="OpenCode CLI — 无需代理,支持多 provider/model(格式: provider/model)", + ), } diff --git a/tests/test_config.py b/tests/test_config.py index c1e82db..3596589 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -12,7 +12,7 @@ class TestToolConfig: def test_all_expected_tools_present(self): - assert set(TOOL_CONFIGS.keys()) == {"kimi", "agent", "copilot", "claude"} + assert set(TOOL_CONFIGS.keys()) == {"kimi", "agent", "copilot", "claude", "opencode"} def test_kimi_no_proxy(self): cfg = TOOL_CONFIGS["kimi"] @@ -38,6 +38,13 @@ def test_claude_no_model_support(self): assert cfg.needs_proxy is True assert cfg.supports_model is False + def test_opencode_no_proxy_with_model(self): + cfg = TOOL_CONFIGS["opencode"] + assert cfg.needs_proxy is False + assert cfg.supports_model is True + assert cfg.default_model is not None + assert cfg.models == [] # accepts any provider/model + def test_cmd_template_contains_task_file(self): """Every tool's command template must reference the {task_file} placeholder.""" for name, cfg in TOOL_CONFIGS.items(): @@ -56,7 +63,7 @@ def test_raises_for_unknown_tool(self): with pytest.raises(KeyError, match="Unknown tool"): get_tool_config("nonexistent") - @pytest.mark.parametrize("tool", ["kimi", "agent", "copilot", "claude"]) + @pytest.mark.parametrize("tool", ["kimi", "agent", "copilot", "claude", "opencode"]) def test_all_tools_retrievable(self, tool): cfg = get_tool_config(tool) assert cfg.name == tool @@ -73,3 +80,4 @@ def test_contains_all_tools(self): assert "agent" in names assert "copilot" in names assert "claude" in names + assert "opencode" in names