diff --git a/docs/development-plan.md b/docs/development-plan.md
index 94cdcbf..c885085 100644
--- a/docs/development-plan.md
+++ b/docs/development-plan.md
@@ -473,3 +473,17 @@ PH0-PH5(45 个 Unit)全部完成、U-042 真实自举压测进行中期间ï¼
- **`v_goal_list`(对已有 `v_task_board`/`v_task_detail` 的扩展,非全新页面)**:搜索/筛选已有,新增排序(`task_query.list_tasks()` 加 `sort`/`order` 参数,白名单校验列名防注入);`v_task_detail` 新增"编辑目标"/"删除任务",仅在 `status='queued'` 时出现——一旦 Planner/Coder 已经基于这个 goal_text 产出过真实 artifact/work_graph_run,改写或删除就会让审计记录指向一个不存在的目标,这正是这个代码库一直在避免的不可追溯漂移,所以 `task_query.update_task_goal_text()`/`delete_task()` 都在非 `queued` 状态下拒绝(`TaskNotEditableError`,HTTP 409)。
- **真实验证时发现并修复一个真 bug**:`delete_task()` 第一版对一个真实创建的(走 `task_create.create_task()`,不是测试自己手写的 INSERT)`queued` 任务执行 `DELETE FROM task` 时,真实抛出 `sqlite3.IntegrityError`——`task_create.create_task()` 自己在同一个事务里总会写一条 `execution_event` 行(01-M 自己的外键,没有 `ON DELETE CASCADE`),哪怕这个任务从没被 Planner/Coder/Validator 碰过,也已经至少有一条自己创建时留下的事件。补上了同事务内先删这条任务自己的 `execution_event`(`queued` 态下不可能有真正的 artifact/bug_record/work_graph_run,所以这是唯一可能挡路的外键)。这次 bug 是测试自己的 `_insert_task` 辅助函数(手写 INSERT,从不写 `execution_event`)从没暴露过的——单测通过不代表真的能用,加了一个走真实 `task_create.create_task()` 路径的回归测试锁死。
- **验证方式**:`src/test_project_query.py`(12 个用例)、`src/test_task_query.py` 新增的排序/编辑/删除用例(含上面这条真实路径回归)、`src/test_backend_server.py` 新增两个真实 HTTP 路由测试类(`ProjectListAndCrudRouteTest`/`TaskCrudRouteTest`,真实起服务器+真实请求,不 mock)。浏览器扩展本次仍不可用,额外对着真实运行的服务器用 `curl` 走了一遍全部新增接口(含前面提到的真删除 bug 就是这一步抓到的)。474 个测试全绿。
+
+### 5.1 `v_task_detail`/`v_dashboard` 开始/暂停/停止(对 5 节功能扩展的追加)
+
+用户实际使用新建好的 `v_task_detail`/`v_dashboard` 页面时发现两处都没有"开始任务"的入口——`goal` 一旦创建,`task.status` 停在 `queued` 不再变化,没有任何按钮能让它真正跑起来。
+
+**追溯为什么 02-V/03-C 设计时漏掉了这个按钮**:不是 UI 遗漏,是 03-C 自己的 `c_task_create` 契约(`03-C-控制与接口.md` 第 42 行 `side_effects`)写的是"若 `max_concurrent_goals` 已达上限,任务落 `queued` 但不触发调度"——反过来读,没到上限时**默认就应该触发调度**。03-C/04-P 里反复出现"Org Graph 调度器将 `task.assigned_org_node` 指向的 Org Agent 唤起"这类描述,全文假设有一个常驻运行的调度器进程,任务一创建就自动被它捞走执行,因此从未设计过一个"人工点击开始"的按钮——03-C 自己都没设想过需要它。但 `src/scheduler.py`(U-032)只造了"从某个状态推进到底"的**函数**,从未被任何常驻进程持续调用(这一点在 U-032 当时的开发过程中也已经被记录过一次,这次追溯是同一个根因在控制台层面的直接后果)。三个真实自举闭环(`supervisor/self_hosting_e2e.py`)能跑通,靠的是测试代码显式调用 `scheduler.run_goal_to_completion`,不是这个"常驻调度器"真的存在。
+
+**设计取舍**(与用户确认后拍板,两处都选了推荐项):
+1. **软停止,不做真进程 kill**:`org_node_trigger.py`/`work_graph_execute.py` 都是阻塞的 `subprocess.run()` 调 `claude`,没有任何地方保存进程句柄给"停止"操作去杀。Pause/Stop 只拦截"下一阶段要不要触发",正在跑的那次真实调用会自然跑完。真正硬杀需要把这些调用点换成 `Popen` + 按 `task_id` 可查的 PID 表,这次不做。
+2. **不加新的 `task.status` 枚举值**:暂停/停止后的任务复用已有的 `status='blocked_pending_human'`,靠 `blocked_reason='user_paused'`/`'user_stopped'` 在审计轨迹里区分(功能上两者恢复方式完全一样),直接借用已经跑通的 `manual_resume` 机制(`org_loop_route._manual_resume`:从 `execution_event` 里最后一条 `task_status_changed` 事件读出暂停前的状态并还原),不改 01-M schema。
+
+**实现**:新增 `src/task_execution.py`——`run_or_resume_task_lifecycle()` 按 `task.status`(`queued`/`planning_brd_prd_sdd`/`coding`)跳过已经真实产出 artifact 的阶段,只重新触发还没跑的阶段;`start_task_execution_in_background()` 起一个独立 daemon 线程、开自己的 sqlite 连接(复用 `scheduler.run_goal_to_completion` 同样的"每线程一个连接"理由)跑到底,成功/失败都走 `scheduler.finish_task_lifecycle` 收尾,跟被调度器正常调度的任务落到同样的终态;`request_pause()` 只允许从 `planning_brd_prd_sdd`/`coding`(真的有阶段在飞)转 `blocked_pending_human`。HTTP 新增 `POST /api/tasks/:id/execute`、`/pause`、`/stop`,并把 `/route` 的 `manual_resume` 分支扩展成:如果暂停/停止前的 `blocked_reason` 是 `user_paused`/`user_stopped`,状态一旦被还原到 `queued`/`planning_brd_prd_sdd`/`coding`,就自动重新 spawn 后台执行线程,而不只是把状态字段翻回去——否则用户点"继续"以为任务会继续跑,实际上还要再点一次"开始",体验上等于没修。系统级阻塞(`loop_limit_reached`/`budget_exceeded`)的 `manual_resume` 行为不变,仍然只翻状态。`v_task_detail` 按 `task.status` 三选一显示"开始"/"暂停+停止"/"继续执行"按钮;`v_dashboard` 只加了一个轻量"开始"(不需要 Pause/Stop 的状态判断)。
+- **实现过程中发现并修复一个真 gap**:`backend_server.create_server()` 本身从未把 `db_path` 存到 `server` 对象上——之前所有请求处理器共用 `server.conn` 一条连接即可,这次新增的后台执行线程需要开自己独立的连接(不能跨线程共享 `server.conn`),发现这个字段根本不存在。补上 `server.db_path = resolved_db_path`。
+- **验证方式**:`src/test_task_execution.py`(14 个用例,覆盖 resume-from-每个可恢复状态、暂停信号的时序竞争、`request_pause` 的状态校验)、`src/test_backend_server.py` 新增 `TaskExecutionRouteTest`(8 个真实 HTTP 路由用例,`start_task_execution_in_background` 用 mock 隔离——它会真的开线程、真的对 `:memory:` 测试库开一条独立连接从而连到另一个空库,这类更底层的时序机制已经在 `test_task_execution.py` 里用真实临时文件库验证过,HTTP 层只验证接线:调用参数、状态码、`manual_resume` 是否按 `blocked_reason` 正确决定要不要自动续跑)。对着真实运行的服务器用 `curl` 走了一遍 `/execute`(非 `queued` 任务返回 409 `TASK_NOT_QUEUED`)、`/pause`(`queued` 任务返回 409 `TASK_NOT_PAUSABLE`;`coding` 任务成功转 `blocked_pending_human`/`user_paused`;重复 pause 返回 409),以及 `frontend/app.js` 实际下发的按钮 HTML。`src/` 全量 496 个测试全绿(新增 22 个:14 + 8)。
diff --git a/frontend/app.js b/frontend/app.js
index 4b1cc1f..01880f5 100644
--- a/frontend/app.js
+++ b/frontend/app.js
@@ -598,12 +598,30 @@ function mountDashboard() {
quotaBanner.style.display = "none";
return;
}
+ // v_dashboard Start affordance (2026-08-03): the full Pause/Stop set
+ // only makes sense on v_task_detail (they need the task's own status
+ // context to pick which one to show) -- the dashboard only ever needs
+ // "queued and nothing has started yet", so it gets just Start.
+ const startRowHtml = active.status !== "queued" ? "" : (
+ '
'
+ "
STATUS" + escapeHtml(task.status) + "
"
@@ -1126,6 +1165,31 @@ function mountTaskDetail(taskId) {
});
}
+ const startBtn = document.getElementById("btn_start_task");
+ if (startBtn) {
+ startBtn.addEventListener("click", async () => {
+ startBtn.disabled = true;
+ await fetch("/api/tasks/" + taskId + "/execute", { method: "POST" });
+ refresh();
+ });
+ }
+ const pauseBtn = document.getElementById("btn_pause_task");
+ if (pauseBtn) {
+ pauseBtn.addEventListener("click", async () => {
+ pauseBtn.disabled = true;
+ await fetch("/api/tasks/" + taskId + "/pause", { method: "POST" });
+ refresh();
+ });
+ }
+ const stopBtn = document.getElementById("btn_stop_task");
+ if (stopBtn) {
+ stopBtn.addEventListener("click", async () => {
+ stopBtn.disabled = true;
+ await fetch("/api/tasks/" + taskId + "/stop", { method: "POST" });
+ refresh();
+ });
+ }
+
const editBtn = document.getElementById("btn_edit_goal_text");
const deleteBtn = document.getElementById("btn_delete_goal");
if (editBtn) {
diff --git a/src/backend_server.py b/src/backend_server.py
index 341690e..dd9f948 100644
--- a/src/backend_server.py
+++ b/src/backend_server.py
@@ -39,7 +39,7 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from src import (
artifact_query, budget_guardrail, bug_record, deployment_query, execution_log,
org_loop_route, paths, project_query, project_scaffold, schema, settings_config,
- skill_query, skill_update, task_create, task_query, ws_subscribe,
+ skill_query, skill_update, task_create, task_execution, task_query, ws_subscribe,
)
_FRONTEND_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "frontend")
@@ -228,6 +228,15 @@ class _Handler(BaseHTTPRequestHandler):
elif self.path.startswith(_TASKS_PREFIX) and self.path.endswith("/route"):
task_id = self.path[len(_TASKS_PREFIX):-len("/route")]
self._handle_task_route(task_id)
+ elif self.path.startswith(_TASKS_PREFIX) and self.path.endswith("/execute"):
+ task_id = self.path[len(_TASKS_PREFIX):-len("/execute")]
+ self._handle_task_execute(task_id)
+ elif self.path.startswith(_TASKS_PREFIX) and self.path.endswith("/pause"):
+ task_id = self.path[len(_TASKS_PREFIX):-len("/pause")]
+ self._handle_task_pause(task_id, "user_paused")
+ elif self.path.startswith(_TASKS_PREFIX) and self.path.endswith("/stop"):
+ task_id = self.path[len(_TASKS_PREFIX):-len("/stop")]
+ self._handle_task_pause(task_id, "user_stopped")
elif self.path.startswith(_SKILLS_PREFIX) and self.path.endswith("/rollback"):
skill_id = self.path[len(_SKILLS_PREFIX):-len("/rollback")]
self._handle_skill_rollback(skill_id)
@@ -457,18 +466,80 @@ class _Handler(BaseHTTPRequestHandler):
def _handle_task_route(self, task_id):
"""c_org_loop_route (03-C §3.11) -- the underlying route_org_loop()
was built in U-016 with no HTTP route until now; v_task_detail's
- click_retry (manual_resume) is what actually needs it wired up."""
+ click_retry (manual_resume) is what actually needs it wired up.
+
+ **2026-08-03**: if the task was blocked via a user-initiated
+ pause/stop (task_execution.request_pause, blocked_reason in
+ ('user_paused', 'user_stopped')), a successful manual_resume also
+ spawns a background thread to actually continue real execution --
+ matching what the ORIGINAL blocked_pending_human/manual_resume
+ design already implies (04-P: "调度器继续执行队列中其他任务" --
+ resuming should let the scheduler keep going, not just flip a
+ status flag and stop). System-triggered blocks (loop_limit_reached,
+ budget_exceeded) keep their existing behavior unchanged -- only
+ restoring status, no auto-continuation -- that's a separate,
+ already-tested concern this change doesn't touch."""
payload, error = self._read_json_body()
if error:
self._send_json(400, error)
return
+ decided_route = payload.get("decided_route")
+ pre_resume_reason = None
+ if decided_route == "manual_resume":
+ row = self.server.conn.execute(
+ "SELECT blocked_reason FROM task WHERE task_id = ?", (task_id,)
+ ).fetchone()
+ pre_resume_reason = row[0] if row else None
try:
with self.server.conn_lock:
- result = org_loop_route.route_org_loop(self.server.conn, task_id, payload.get("decided_route"))
+ result = org_loop_route.route_org_loop(self.server.conn, task_id, decided_route)
except org_loop_route.LoopLimitReachedError as e:
self._send_json(409, {"error": "LOOP_LIMIT_REACHED", "detail": str(e)})
+ return
except (org_loop_route.TaskNotBlockedError, org_loop_route.TaskNotValidatingError, ValueError) as e:
self._send_json(400, {"error": "INVALID_PARAM", "detail": str(e)})
+ return
+ if pre_resume_reason in ("user_paused", "user_stopped") and result.get("new_status") in (
+ "queued", "planning_brd_prd_sdd", "coding",
+ ):
+ task_row = self.server.conn.execute(
+ "SELECT project_id, goal_text FROM task WHERE task_id = ?", (task_id,)
+ ).fetchone()
+ target_folder_path = self.server.conn.execute(
+ "SELECT target_folder_path FROM project WHERE project_id = ?", (task_row[0],)
+ ).fetchone()[0]
+ task_execution.start_task_execution_in_background(
+ self.server.db_path, task_id, target_folder_path, task_row[1],
+ )
+ self._send_json(200, result)
+
+ def _handle_task_execute(self, task_id):
+ """c_task_execute (2026-08-03): the manual "Start" trigger --
+ confirmed with the user this is genuinely missing (03-C's own
+ c_task_create contract already assumed task creation would
+ auto-trigger scheduling via an always-on scheduler that was never
+ actually wired up as a running process, see task_execution.py's
+ module docstring)."""
+ row = self.server.conn.execute("SELECT project_id, goal_text, status FROM task WHERE task_id = ?", (task_id,)).fetchone()
+ if row is None:
+ self._send_json(404, {"error": "NOT_FOUND"})
+ return
+ project_id, goal_text, status = row
+ if status != "queued":
+ self._send_json(409, {"error": "TASK_NOT_QUEUED", "detail": "task is status={}, not queued".format(status)})
+ return
+ target_folder_path = self.server.conn.execute(
+ "SELECT target_folder_path FROM project WHERE project_id = ?", (project_id,)
+ ).fetchone()[0]
+ task_execution.start_task_execution_in_background(self.server.db_path, task_id, target_folder_path, goal_text)
+ self._send_json(200, {"task_id": task_id, "started": True})
+
+ def _handle_task_pause(self, task_id, reason):
+ try:
+ with self.server.conn_lock:
+ result = task_execution.request_pause(self.server.conn, task_id, reason)
+ except task_execution.TaskExecutionError as e:
+ self._send_json(409, {"error": "TASK_NOT_PAUSABLE", "detail": str(e)})
else:
self._send_json(200, result)
@@ -818,10 +889,12 @@ def create_server(host="127.0.0.1", port=0, db_path=None, skill_repo_path=None):
write an actual commit into whatever repo this process happens to be
running from. Never exposed to HTTP clients -- only settable here, at
process/test-construction time."""
- conn = schema.connect(db_path if db_path is not None else paths.db_path(), check_same_thread=False)
+ resolved_db_path = db_path if db_path is not None else paths.db_path()
+ conn = schema.connect(resolved_db_path, check_same_thread=False)
_ensure_schema(conn)
server = _Server((host, port), _Handler)
server.conn = conn
+ server.db_path = resolved_db_path
server.conn_lock = threading.Lock()
server.skill_repo_path = skill_repo_path if skill_repo_path is not None else skill_query._REPO_ROOT
return server
diff --git a/src/task_execution.py b/src/task_execution.py
new file mode 100644
index 0000000..25ead76
--- /dev/null
+++ b/src/task_execution.py
@@ -0,0 +1,164 @@
+"""c_task_execute / c_task_pause (2026-08-03): manual Start/Pause/Stop
+controls for v_task_detail, confirmed with the user first:
+
+1. **Soft stop only.** `org_node_trigger.py`/`work_graph_execute.py` both
+ invoke the real `claude` CLI via blocking `subprocess.run()` -- no
+ process handle is stored anywhere a "stop" action could reach in and
+ kill. Pause/Stop let whatever real call is currently in flight finish
+ naturally; only the NEXT phase is withheld. A genuine hard-kill would
+ need switching those call sites to `Popen` + tracking PIDs somewhere
+ reachable by task_id, which is out of scope for this pass.
+2. **No new `task.status` enum value.** Paused/stopped tasks reuse
+ `status='blocked_pending_human'` with `blocked_reason='user_paused'` /
+ `'user_stopped'` (distinguished in the audit trail, not functionally --
+ both resume the same way) rather than a 01-M schema change. This reuses
+ the already-built, already-tested `manual_resume` mechanism
+ (`org_loop_route._manual_resume`) to restore the pre-paused status.
+
+**Why a manual Start button doesn't contradict the "lights-out" design**:
+`c_task_create`'s own contract (03-C) already assumed task creation would
+auto-trigger scheduling ("若 max_concurrent_goals 已达上限,任务落 queued
+但不触发调度" -- implying it DOES trigger scheduling otherwise) via an
+always-on Org Graph scheduler that was never actually wired up as a running
+process (`src/scheduler.py` builds the mechanism, nothing spins it up
+continuously). `start_task_execution_in_background()` below is that same
+auto-trigger, just invoked by an explicit user action instead of firing
+automatically the instant a task is created.
+"""
+
+import threading
+
+from src import e2e_pipeline, org_node_lifecycle, schema, scheduler
+
+_RESUMABLE_STATUSES = ("queued", "planning_brd_prd_sdd", "coding")
+_DOC_ARTIFACT_TYPES = ("brd", "prd", "sdd", "benchmark")
+_CODE_ARTIFACT_TYPES = ("code_diff", "test_report")
+
+
+class TaskExecutionError(Exception):
+ """The task isn't in a state this module knows how to (re)start from --
+ e.g. already running, already done, or blocked for a reason unrelated
+ to a user-initiated pause/stop."""
+
+
+class ExecutionPausedError(Exception):
+ """Internal control-flow signal, not a failure: a pause/stop was
+ requested while this background thread was between phases. Caught by
+ the background runner and treated as a clean stop -- the task is
+ ALREADY `blocked_pending_human` by construction (that's literally how
+ the pause was requested), nothing more to record."""
+
+
+def _check_not_paused(conn, task_id):
+ status = conn.execute("SELECT status FROM task WHERE task_id = ?", (task_id,)).fetchone()[0]
+ if status == "blocked_pending_human":
+ raise ExecutionPausedError(task_id)
+
+
+def _latest_artifact_ids(conn, task_id, artifact_types):
+ rows = conn.execute(
+ "SELECT artifact_type, artifact_id FROM artifact WHERE task_id = ? AND artifact_type IN ({}) "
+ "ORDER BY created_at".format(",".join("?" * len(artifact_types))),
+ (task_id, *artifact_types),
+ ).fetchall()
+ return {r[0]: r[1] for r in rows}
+
+
+def _artifact_content(conn, target_folder_path, task_id, artifact_type):
+ row = conn.execute(
+ "SELECT artifact_id FROM artifact WHERE task_id = ? AND artifact_type = ? ORDER BY created_at DESC LIMIT 1",
+ (task_id, artifact_type),
+ ).fetchone()
+ if row is None:
+ raise TaskExecutionError("task {} has no {} artifact to resume from".format(task_id, artifact_type))
+ return e2e_pipeline._read_artifact_content(conn, target_folder_path, row[0])
+
+
+def run_or_resume_task_lifecycle(conn, task_id, target_folder_path, goal_text):
+ """Same return shape as `e2e_pipeline.run_task_lifecycle` -- continues
+ from wherever `task.status` indicates real work last completed, instead
+ of re-running phases that already produced real artifacts. Pausing only
+ ever happens BETWEEN phases (checked right before each org_node
+ trigger), so the only statuses a paused task can be resumed from are
+ 'queued' (nothing ran yet), 'planning_brd_prd_sdd' (Planner done, Coder
+ not yet triggered), and 'coding' (Coder done, Validator not yet
+ triggered) -- anything else raises `TaskExecutionError` rather than
+ guessing at a mid-phase resume this module was never designed for."""
+ status = conn.execute("SELECT status FROM task WHERE task_id = ?", (task_id,)).fetchone()[0]
+ if status not in _RESUMABLE_STATUSES:
+ raise TaskExecutionError("task {} is status={}, not resumable from here".format(task_id, status))
+
+ if status == "queued":
+ return e2e_pipeline.run_task_lifecycle(conn, task_id, target_folder_path, goal_text)
+
+ doc_artifact_ids = _latest_artifact_ids(conn, task_id, _DOC_ARTIFACT_TYPES)
+ sdd_content = _artifact_content(conn, target_folder_path, task_id, "sdd")
+ benchmark_content = _artifact_content(conn, target_folder_path, task_id, "benchmark")
+
+ if status == "planning_brd_prd_sdd":
+ _check_not_paused(conn, task_id)
+ org_node_lifecycle.trigger_org_node_full(conn, "coder", task_id, target_folder_path, e2e_pipeline._READY_PROMPT)
+ code_artifact_ids = e2e_pipeline.implement_code(conn, task_id, sdd_content, goal_text, target_folder_path)
+ else: # status == "coding" -- Coder already ran
+ code_artifact_ids = _latest_artifact_ids(conn, task_id, _CODE_ARTIFACT_TYPES)
+
+ _check_not_paused(conn, task_id)
+ org_node_lifecycle.trigger_org_node_full(conn, "validator", task_id, target_folder_path, e2e_pipeline._READY_PROMPT)
+ verdict = e2e_pipeline.validate_implementation(task_id, benchmark_content, goal_text, target_folder_path)
+
+ return {
+ "task_id": task_id, "doc_artifact_ids": doc_artifact_ids,
+ "code_artifact_ids": code_artifact_ids, "verdict": verdict,
+ }
+
+
+def _mark_blocked(conn, task_id, exc):
+ reason = scheduler._blocked_reason_for(exc) if isinstance(exc, scheduler._ROUTABLE_EXCEPTIONS) else "execution_error"
+ scheduler._block_task(conn, task_id, reason)
+
+
+def start_task_execution_in_background(db_path, task_id, target_folder_path, goal_text):
+ """Spawns a daemon thread that runs (or resumes) this task's real
+ execution to completion. Own sqlite connection per thread -- same
+ reasoning as `scheduler.run_goal_to_completion`, a shared connection
+ across threads needs the same `conn_lock` discipline U-027 already
+ proved is easy to get wrong. On PASS/FAIL, applies the same
+ `scheduler.finish_task_lifecycle` closing step (`close_task_done` /
+ `org_level_loop.run_fix_loop`) a scheduled task gets, so a manually
+ started one reaches the same real terminal state. Returns the Thread
+ (already started) so callers/tests can `.join()` it if needed."""
+ def run():
+ conn = schema.connect(db_path)
+ try:
+ result = run_or_resume_task_lifecycle(conn, task_id, target_folder_path, goal_text)
+ scheduler.finish_task_lifecycle(conn, result, target_folder_path, goal_text)
+ except ExecutionPausedError:
+ pass
+ except Exception as e:
+ _mark_blocked(conn, task_id, e)
+ finally:
+ conn.close()
+
+ thread = threading.Thread(target=run, daemon=True)
+ thread.start()
+ return thread
+
+
+def request_pause(conn, task_id, reason):
+ """`reason`: 'user_paused' or 'user_stopped' -- see module docstring
+ for why these are functionally identical (both a soft stop reusing
+ blocked_pending_human), kept as distinct strings only so the audit
+ trail (execution_event / blocked_reason) preserves which button the
+ user actually clicked. Only valid while a background execution could
+ plausibly be in flight; raises TaskExecutionError otherwise (nothing
+ running to pause, or already terminal)."""
+ row = conn.execute("SELECT project_id, status FROM task WHERE task_id = ?", (task_id,)).fetchone()
+ if row is None:
+ raise TaskExecutionError("no such task: " + task_id)
+ project_id, status = row
+ if status not in ("planning_brd_prd_sdd", "coding"):
+ raise TaskExecutionError(
+ "task {} is status={}, nothing in-flight to pause/stop".format(task_id, status)
+ )
+ scheduler._block_task(conn, task_id, reason)
+ return {"task_id": task_id, "status": "blocked_pending_human", "blocked_reason": reason}
diff --git a/src/test_backend_server.py b/src/test_backend_server.py
index 3756b17..3a18c79 100644
--- a/src/test_backend_server.py
+++ b/src/test_backend_server.py
@@ -716,6 +716,121 @@ class TaskQueryAndRouteRouteTest(unittest.TestCase):
self.assertEqual(body[0]["current_usage"], 10)
+class TaskExecutionRouteTest(unittest.TestCase):
+ """v_task_detail Start/Pause/Stop (2026-08-03): c_task_execute,
+ c_task_pause -- task_execution.py's own logic (resume-from-status,
+ pause mid-flight) is already covered by test_task_execution.py, this
+ only exercises the HTTP wiring. `start_task_execution_in_background`
+ is mocked throughout: it spawns a real background thread that opens
+ its OWN sqlite connection via `schema.connect(db_path)`, which for a
+ ':memory:' test db would silently create a second, disconnected
+ database -- exactly the kind of lower-level mechanic the codebase's
+ own convention (see other route test classes) says belongs in
+ test_task_execution.py, not re-tested at the HTTP layer."""
+
+ def setUp(self):
+ self.server = backend_server.create_server(db_path=":memory:")
+ self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
+ self.thread.start()
+ self.server.conn.execute(
+ "INSERT INTO project (project_id, name, target_folder_path, has_git_repo, status) "
+ "VALUES ('proj-1', 'P1', '/tmp/proj-1', 1, 'active')"
+ )
+ self.server.conn.execute(
+ "INSERT INTO task (task_id, project_id, goal_text, task_type, status, assigned_org_node) "
+ "VALUES ('task-1', 'proj-1', 'the goal', 'goal', 'queued', 'planner')"
+ )
+ self.server.conn.commit()
+
+ def tearDown(self):
+ self.server.shutdown()
+ self.server.server_close()
+ self.thread.join(timeout=5)
+
+ def _post(self, path, payload=None):
+ host, port = self.server.server_address
+ conn = http.client.HTTPConnection(host, port, timeout=5)
+ body = json.dumps(payload).encode("utf-8") if payload is not None else b""
+ conn.request("POST", path, body=body)
+ resp = conn.getresponse()
+ raw = resp.read()
+ body = json.loads(raw) if raw else None
+ conn.close()
+ return resp.status, body
+
+ def test_execute_starts_queued_task(self):
+ with patch.object(backend_server.task_execution, "start_task_execution_in_background") as mock_start:
+ status, body = self._post("/api/tasks/task-1/execute")
+ self.assertEqual(status, 200)
+ self.assertEqual(body, {"task_id": "task-1", "started": True})
+ mock_start.assert_called_once_with(self.server.db_path, "task-1", "/tmp/proj-1", "the goal")
+
+ def test_execute_nonexistent_task_returns_404(self):
+ status, body = self._post("/api/tasks/no-such-task/execute")
+ self.assertEqual(status, 404)
+ self.assertEqual(body["error"], "NOT_FOUND")
+
+ def test_execute_non_queued_task_returns_409(self):
+ self.server.conn.execute("UPDATE task SET status = 'coding' WHERE task_id = 'task-1'")
+ self.server.conn.commit()
+ status, body = self._post("/api/tasks/task-1/execute")
+ self.assertEqual(status, 409)
+ self.assertEqual(body["error"], "TASK_NOT_QUEUED")
+
+ def test_pause_blocks_in_flight_task(self):
+ self.server.conn.execute("UPDATE task SET status = 'coding' WHERE task_id = 'task-1'")
+ self.server.conn.commit()
+ status, body = self._post("/api/tasks/task-1/pause")
+ self.assertEqual(status, 200)
+ self.assertEqual(body, {"task_id": "task-1", "status": "blocked_pending_human", "blocked_reason": "user_paused"})
+ row = self.server.conn.execute("SELECT status, blocked_reason FROM task WHERE task_id = 'task-1'").fetchone()
+ self.assertEqual(tuple(row), ("blocked_pending_human", "user_paused"))
+
+ def test_stop_blocks_in_flight_task_with_distinct_reason(self):
+ self.server.conn.execute("UPDATE task SET status = 'planning_brd_prd_sdd' WHERE task_id = 'task-1'")
+ self.server.conn.commit()
+ status, body = self._post("/api/tasks/task-1/stop")
+ self.assertEqual(status, 200)
+ self.assertEqual(body["blocked_reason"], "user_stopped")
+
+ def test_pause_on_queued_task_returns_409(self):
+ status, body = self._post("/api/tasks/task-1/pause")
+ self.assertEqual(status, 409)
+ self.assertEqual(body["error"], "TASK_NOT_PAUSABLE")
+
+ def test_manual_resume_after_user_pause_auto_continues(self):
+ self.server.conn.execute(
+ "UPDATE task SET status = 'blocked_pending_human', blocked_reason = 'user_paused' "
+ "WHERE task_id = 'task-1'"
+ )
+ self.server.conn.execute(
+ "INSERT INTO execution_event (event_id, project_id, task_id, event_type, payload) "
+ "VALUES ('evt-1', 'proj-1', 'task-1', 'task_status_changed', "
+ "'{\"from_status\": \"coding\", \"to_status\": \"blocked_pending_human\"}')"
+ )
+ self.server.conn.commit()
+ with patch.object(backend_server.task_execution, "start_task_execution_in_background") as mock_start:
+ status, body = self._post("/api/tasks/task-1/route", {"decided_route": "manual_resume"})
+ self.assertEqual(status, 200)
+ mock_start.assert_called_once_with(self.server.db_path, "task-1", "/tmp/proj-1", "the goal")
+
+ def test_manual_resume_after_system_block_does_not_auto_continue(self):
+ self.server.conn.execute(
+ "UPDATE task SET status = 'blocked_pending_human', blocked_reason = 'loop_limit_reached' "
+ "WHERE task_id = 'task-1'"
+ )
+ self.server.conn.execute(
+ "INSERT INTO execution_event (event_id, project_id, task_id, event_type, payload) "
+ "VALUES ('evt-1', 'proj-1', 'task-1', 'task_status_changed', "
+ "'{\"from_status\": \"queued\", \"to_status\": \"blocked_pending_human\"}')"
+ )
+ self.server.conn.commit()
+ with patch.object(backend_server.task_execution, "start_task_execution_in_background") as mock_start:
+ status, body = self._post("/api/tasks/task-1/route", {"decided_route": "manual_resume"})
+ self.assertEqual(status, 200)
+ mock_start.assert_not_called()
+
+
def _ws_handshake_request(path, key):
return (
"GET {} HTTP/1.1\r\n"
diff --git a/src/test_task_execution.py b/src/test_task_execution.py
new file mode 100644
index 0000000..c7d377c
--- /dev/null
+++ b/src/test_task_execution.py
@@ -0,0 +1,298 @@
+"""c_task_execute / c_task_pause tests (2026-08-03): mocked at the
+subprocess boundary (CLAUDE.md Testing constraint), same pattern as
+test_e2e_pipeline.py -- this file tests task_execution.py's own resume/
+pause wiring, not e2e_pipeline's phase mechanics again.
+
+Run: python3 -m unittest src.test_task_execution -v
+"""
+
+import os
+import subprocess
+import tempfile
+import unittest
+from unittest.mock import patch
+
+from src import e2e_pipeline, org_node_lifecycle, org_node_trigger, project_scaffold, schema, scheduler, task_execution, work_graph_execute
+
+
+def _fake_ready_trigger(org_node, prompt, target_folder_path, timeout=300):
+ assert prompt == e2e_pipeline._READY_PROMPT
+ return "Ready."
+
+
+class TaskExecutionTestBase(unittest.TestCase):
+ def setUp(self):
+ self.tmp = tempfile.TemporaryDirectory()
+ self.addCleanup(self.tmp.cleanup)
+ subprocess.run(["git", "init"], cwd=self.tmp.name, check=True, capture_output=True)
+ self.conn = schema.connect(":memory:")
+ schema.create_schema(self.conn)
+ project = project_scaffold.create_project(self.conn, self.tmp.name, init_git_if_missing=False)
+ self.project_id = project["project_id"]
+
+ def tearDown(self):
+ self.conn.close()
+
+ def _insert_task(self, task_id, status, assigned_org_node="planner", loop_round=0):
+ self.conn.execute(
+ "INSERT INTO task (task_id, project_id, goal_text, task_type, status, assigned_org_node, loop_round) "
+ "VALUES (?, ?, 'a goal', 'goal', ?, ?, ?)",
+ (task_id, self.project_id, status, assigned_org_node, loop_round),
+ )
+ self.conn.commit()
+
+ def _write_artifact(self, task_id, artifact_type, content):
+ run_id = "run-" + artifact_type
+ self.conn.execute(
+ "INSERT OR IGNORE INTO work_graph_run (run_id, task_id, org_node, graph_manifest, status) "
+ "VALUES (?, ?, 'planner', '{}', 'done')", (run_id, task_id),
+ )
+ rel_path = ".graphlemon/runs/{}/{}.md".format(task_id, artifact_type)
+ abs_path = os.path.join(self.tmp.name, rel_path)
+ os.makedirs(os.path.dirname(abs_path), exist_ok=True)
+ with open(abs_path, "w") as f:
+ f.write(content)
+ subprocess.run(["git", "add", "-A"], cwd=self.tmp.name, check=True, capture_output=True)
+ subprocess.run(
+ ["git", "-c", "user.name=t", "-c", "user.email=t@t", "commit", "-m", "x"],
+ cwd=self.tmp.name, check=True, capture_output=True,
+ )
+ sha = subprocess.run(
+ ["git", "rev-parse", "HEAD"], cwd=self.tmp.name, capture_output=True, text=True
+ ).stdout.strip()
+ self.conn.execute(
+ "INSERT INTO artifact (artifact_id, artifact_type, task_id, produced_by_run_id, file_path, "
+ "git_commit_sha, validation_status) VALUES (?, ?, ?, ?, ?, ?, 'passed')",
+ ("art-" + artifact_type, artifact_type, task_id, run_id, rel_path, sha),
+ )
+ self.conn.commit()
+
+
+class ResumeFromCodingTest(TaskExecutionTestBase):
+ """Coder already ran (real code_diff/test_report artifacts exist) --
+ resuming from 'coding' must skip straight to Validator, not redo
+ Planner or Coder."""
+
+ def test_skips_to_validator_only(self):
+ self._insert_task("task-1", status="coding", assigned_org_node="coder")
+ self._write_artifact("task-1", "sdd", "# SDD content")
+ self._write_artifact("task-1", "benchmark", "# Benchmark content")
+ self._write_artifact("task-1", "code_diff", "diff content")
+ self._write_artifact("task-1", "test_report", "report content")
+
+ with patch.object(org_node_lifecycle, "trigger_org_node", side_effect=_fake_ready_trigger), \
+ patch.object(org_node_trigger, "trigger_org_node", return_value="VERDICT: PASS") as mock_trigger:
+ result = task_execution.run_or_resume_task_lifecycle(self.conn, "task-1", self.tmp.name, "a goal")
+
+ self.assertEqual(result["verdict"]["verdict"], "PASS")
+ self.assertEqual(result["code_artifact_ids"]["code_diff"], "art-code_diff")
+ # only the validator's real call happened -- no coder call
+ mock_trigger.assert_called_once()
+ self.assertEqual(mock_trigger.call_args[0][0], "validator")
+ row = self.conn.execute("SELECT status FROM task WHERE task_id = 'task-1'").fetchone()
+ self.assertEqual(row[0], "validating")
+
+
+class ResumeFromPlanningTest(TaskExecutionTestBase):
+ """Planner already ran (real doc artifacts exist) -- resuming from
+ 'planning_brd_prd_sdd' must run Coder then Validator, not redo Planner."""
+
+ def test_runs_coder_then_validator(self):
+ self._insert_task("task-1", status="planning_brd_prd_sdd", assigned_org_node="planner")
+ # trigger_org_node_full("coder", ...) requires ALL FOUR doc artifacts
+ # validation_status='passed' (org_node_lifecycle._REQUIRED_ARTIFACTS),
+ # not just the two this module's own resume logic reads content from.
+ self._write_artifact("task-1", "brd", "# BRD content")
+ self._write_artifact("task-1", "prd", "# PRD content")
+ self._write_artifact("task-1", "sdd", "# SDD content")
+ self._write_artifact("task-1", "benchmark", "# Benchmark content")
+
+ with patch.object(org_node_lifecycle, "trigger_org_node", side_effect=_fake_ready_trigger), \
+ patch.object(work_graph_execute, "write_workflow_script"), \
+ patch.object(work_graph_execute, "execute_work_graph", return_value={"response": "done, tests pass"}), \
+ patch.object(org_node_trigger, "trigger_org_node", return_value="VERDICT: PASS") as mock_trigger:
+ result = task_execution.run_or_resume_task_lifecycle(self.conn, "task-1", self.tmp.name, "a goal")
+
+ self.assertEqual(result["verdict"]["verdict"], "PASS")
+ self.assertIn("code_diff", result["code_artifact_ids"])
+ mock_trigger.assert_called_once()
+ self.assertEqual(mock_trigger.call_args[0][0], "validator")
+
+
+class ResumeUnresumableStatusTest(TaskExecutionTestBase):
+ def test_done_task_raises(self):
+ self._insert_task("task-1", status="done")
+ with self.assertRaises(task_execution.TaskExecutionError):
+ task_execution.run_or_resume_task_lifecycle(self.conn, "task-1", self.tmp.name, "a goal")
+
+ def test_blocked_task_raises(self):
+ self._insert_task("task-1", status="blocked_pending_human")
+ with self.assertRaises(task_execution.TaskExecutionError):
+ task_execution.run_or_resume_task_lifecycle(self.conn, "task-1", self.tmp.name, "a goal")
+
+
+class PauseMidResumeTest(TaskExecutionTestBase):
+ """If task.status flips to blocked_pending_human WHILE this thread is
+ between phases (checked right before each org_node trigger),
+ ExecutionPausedError must stop cleanly without triggering the next
+ phase."""
+
+ def test_pause_before_validator_stops_cleanly(self):
+ self._insert_task("task-1", status="coding", assigned_org_node="coder")
+ self._write_artifact("task-1", "sdd", "# SDD content")
+ self._write_artifact("task-1", "benchmark", "# Benchmark content")
+ self._write_artifact("task-1", "code_diff", "diff content")
+ self._write_artifact("task-1", "test_report", "report content")
+
+ real_lookup = task_execution._latest_artifact_ids
+
+ def lookup_then_pause(conn, task_id, artifact_types):
+ # simulates a concurrent request_pause() landing right after
+ # this (real, unmocked) lookup runs -- the last step before
+ # _check_not_paused() is reached in the real code path.
+ result = real_lookup(conn, task_id, artifact_types)
+ conn.execute(
+ "UPDATE task SET status = 'blocked_pending_human', blocked_reason = 'user_paused' "
+ "WHERE task_id = 'task-1'"
+ )
+ conn.commit()
+ return result
+
+ with patch.object(task_execution, "_latest_artifact_ids", side_effect=lookup_then_pause), \
+ patch.object(org_node_trigger, "trigger_org_node") as mock_trigger:
+ with self.assertRaises(task_execution.ExecutionPausedError):
+ task_execution.run_or_resume_task_lifecycle(self.conn, "task-1", self.tmp.name, "a goal")
+ mock_trigger.assert_not_called()
+
+
+class CheckNotPausedTest(TaskExecutionTestBase):
+ def test_raises_when_blocked(self):
+ self._insert_task("task-1", status="blocked_pending_human")
+ with self.assertRaises(task_execution.ExecutionPausedError):
+ task_execution._check_not_paused(self.conn, "task-1")
+
+ def test_does_not_raise_when_not_blocked(self):
+ self._insert_task("task-1", status="coding")
+ task_execution._check_not_paused(self.conn, "task-1") # no raise
+
+
+class RequestPauseTest(TaskExecutionTestBase):
+ def test_pauses_a_coding_task(self):
+ self._insert_task("task-1", status="coding")
+ result = task_execution.request_pause(self.conn, "task-1", "user_paused")
+ self.assertEqual(result["status"], "blocked_pending_human")
+ row = self.conn.execute("SELECT status, blocked_reason FROM task WHERE task_id = 'task-1'").fetchone()
+ self.assertEqual(row, ("blocked_pending_human", "user_paused"))
+
+ def test_stops_a_planning_task(self):
+ self._insert_task("task-1", status="planning_brd_prd_sdd")
+ result = task_execution.request_pause(self.conn, "task-1", "user_stopped")
+ self.assertEqual(result["blocked_reason"], "user_stopped")
+
+ def test_cannot_pause_a_queued_task(self):
+ self._insert_task("task-1", status="queued")
+ with self.assertRaises(task_execution.TaskExecutionError):
+ task_execution.request_pause(self.conn, "task-1", "user_paused")
+
+ def test_cannot_pause_a_done_task(self):
+ self._insert_task("task-1", status="done")
+ with self.assertRaises(task_execution.TaskExecutionError):
+ task_execution.request_pause(self.conn, "task-1", "user_paused")
+
+ def test_nonexistent_task_raises(self):
+ with self.assertRaises(task_execution.TaskExecutionError):
+ task_execution.request_pause(self.conn, "no-such-task", "user_paused")
+
+
+class StartTaskExecutionInBackgroundTest(unittest.TestCase):
+ """Real threading (own connection per thread, same as scheduler.py) --
+ needs a real file db_path, not ':memory:' (a fresh ':memory:' connection
+ per thread would be a completely separate, empty database)."""
+
+ def setUp(self):
+ self.repo = tempfile.TemporaryDirectory()
+ self.addCleanup(self.repo.cleanup)
+ subprocess.run(["git", "init"], cwd=self.repo.name, check=True, capture_output=True)
+ self.tmp_db = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
+ self.tmp_db.close()
+ self.db_path = self.tmp_db.name
+ self.addCleanup(lambda: os.path.exists(self.db_path) and os.remove(self.db_path))
+
+ conn = schema.connect(self.db_path)
+ schema.create_schema(conn)
+ project = project_scaffold.create_project(conn, self.repo.name, init_git_if_missing=False)
+ self.project_id = project["project_id"]
+ conn.execute(
+ "INSERT INTO task (task_id, project_id, goal_text, task_type, status, assigned_org_node) "
+ "VALUES ('task-1', ?, 'a goal', 'goal', 'coding', 'coder')", (self.project_id,)
+ )
+ conn.commit()
+ conn.close()
+
+ def _write_artifact(self, task_id, artifact_type, content):
+ conn = schema.connect(self.db_path)
+ run_id = "run-" + artifact_type
+ conn.execute(
+ "INSERT OR IGNORE INTO work_graph_run (run_id, task_id, org_node, graph_manifest, status) "
+ "VALUES (?, ?, 'planner', '{}', 'done')", (run_id, task_id),
+ )
+ rel_path = ".graphlemon/runs/{}/{}.md".format(task_id, artifact_type)
+ abs_path = os.path.join(self.repo.name, rel_path)
+ os.makedirs(os.path.dirname(abs_path), exist_ok=True)
+ with open(abs_path, "w") as f:
+ f.write(content)
+ subprocess.run(["git", "add", "-A"], cwd=self.repo.name, check=True, capture_output=True)
+ subprocess.run(
+ ["git", "-c", "user.name=t", "-c", "user.email=t@t", "commit", "-m", "x"],
+ cwd=self.repo.name, check=True, capture_output=True,
+ )
+ sha = subprocess.run(
+ ["git", "rev-parse", "HEAD"], cwd=self.repo.name, capture_output=True, text=True
+ ).stdout.strip()
+ conn.execute(
+ "INSERT INTO artifact (artifact_id, artifact_type, task_id, produced_by_run_id, file_path, "
+ "git_commit_sha, validation_status) VALUES (?, ?, ?, ?, ?, ?, 'passed')",
+ ("art-" + artifact_type, artifact_type, task_id, run_id, rel_path, sha),
+ )
+ conn.commit()
+ conn.close()
+
+ def test_real_thread_completes_and_closes_task(self):
+ self._write_artifact("task-1", "sdd", "# SDD")
+ self._write_artifact("task-1", "benchmark", "# Benchmark")
+ self._write_artifact("task-1", "code_diff", "diff")
+ self._write_artifact("task-1", "test_report", "report")
+
+ with patch.object(org_node_lifecycle, "trigger_org_node", side_effect=_fake_ready_trigger), \
+ patch.object(org_node_trigger, "trigger_org_node", return_value="VERDICT: PASS"):
+ thread = task_execution.start_task_execution_in_background(
+ self.db_path, "task-1", self.repo.name, "a goal",
+ )
+ thread.join(timeout=10)
+
+ conn = schema.connect(self.db_path)
+ status = conn.execute("SELECT status FROM task WHERE task_id = 'task-1'").fetchone()[0]
+ conn.close()
+ self.assertEqual(status, "done")
+
+ def test_generic_failure_marks_blocked(self):
+ self._write_artifact("task-1", "sdd", "# SDD")
+ self._write_artifact("task-1", "benchmark", "# Benchmark")
+ self._write_artifact("task-1", "code_diff", "diff")
+ self._write_artifact("task-1", "test_report", "report")
+
+ with patch.object(org_node_lifecycle, "trigger_org_node", side_effect=_fake_ready_trigger), \
+ patch.object(org_node_trigger, "trigger_org_node", side_effect=RuntimeError("boom")):
+ thread = task_execution.start_task_execution_in_background(
+ self.db_path, "task-1", self.repo.name, "a goal",
+ )
+ thread.join(timeout=10)
+
+ conn = schema.connect(self.db_path)
+ row = conn.execute("SELECT status, blocked_reason FROM task WHERE task_id = 'task-1'").fetchone()
+ conn.close()
+ self.assertEqual(row, ("blocked_pending_human", "execution_error"))
+
+
+if __name__ == "__main__":
+ unittest.main()