Commit add263a97b46e6aa6f384dd3eda5bae2914af7ff

Authored by bboonnee
1 parent 7c720ab8

v_task_detail/v_dashboard: Start/Pause/Stop task execution controls

Users had no way to actually start a queued goal from the console --
task.status just sits at 'queued' forever. Traced the root cause through
03-C: c_task_create's own contract ("若 max_concurrent_goals 已达上限,
任务落 queued 但不触发调度") implies task creation auto-triggers
scheduling otherwise, and 03-C/04-P repeatedly assume an always-on Org
Graph scheduler that watches the queue -- src/scheduler.py builds the
mechanism but nothing ever spins it up as a running process, so no
"start" button was ever designed because none was supposed to be needed.

Design (confirmed with user, both recommended options chosen):
- Pause/Stop are a soft stop, not a real subprocess kill -- the current
  in-flight `claude` call finishes naturally, only the next phase is
  withheld. Hard-killing would need Popen + PID tracking, out of scope.
- No new task.status enum value -- reuses blocked_pending_human with
  blocked_reason='user_paused'/'user_stopped', riding the existing
  manual_resume mechanism instead of a schema change.

New src/task_execution.py: run_or_resume_task_lifecycle() resumes from
whichever phase actually hasn't run yet (queued/planning_brd_prd_sdd/
coding), start_task_execution_in_background() runs it to completion on
its own daemon thread + sqlite connection, request_pause() blocks an
in-flight task. Extended /route's manual_resume handling so resuming
from a user-initiated pause/stop actually re-continues execution in the
background, not just flips the status field back.

Real gap found while building this: backend_server.create_server() never
stored db_path on the server object -- needed for the new background
thread to open its own connection to the same db.

22 new tests (14 src/test_task_execution.py + 8 TaskExecutionRouteTest in
test_backend_server.py, HTTP wiring mocked around the already-unit-tested
background thread mechanics). Verified against a real running server via
curl: /execute 409s a non-queued task, /pause 409s a queued task and a
double-pause, succeeds on a coding task. src/ suite: 496 tests, all green.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
docs/development-plan.md
... ... @@ -473,3 +473,17 @@ PH0-PH545 荳ェ Unit牙驛ィ螳梧縲ゞ-042 逵溷ョ櫁荳セ蜴区オ玖ソ幄。御クュ譛滄龍
473 473 - **`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`粂TTP 409峨
474 474 - **逵溷ョ樣ェ瑚ッ∵慮蜿醍鴫蟷カ菫ョ螟堺ク荳ェ逵 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()` 霍ッ蠕噪蝗槫ス呈オ玖ッ暮煤豁サ縲
475 475 - **鬪瑚ッ∵婿蠑**啻src/test_project_query.py`12 荳ェ逕ィ萓具シ峨〜src/test_task_query.py` 譁ー蠅樒噪謗貞コ/郛冶セ/蛻髯、逕ィ萓具シ亥性荳企擇霑呎擅逵溷ョ櫁キッ蠕屓蠖抵シ峨〜src/test_backend_server.py` 譁ー蠅樔ク、荳ェ逵溷ョ HTTP 霍ッ逕ア豬玖ッ慕アサProjectListAndCrudRouteTest`/`TaskCrudRouteTest`檎悄螳櫁オキ譛榊苅蝎ィ+逵溷ョ櫁ッキ豎ゑシ御ク mock峨よオ剰ァ亥勣謇ゥ螻墓悽谺。莉堺ク榊庄逕ィ碁「晏、門ッケ逹逵溷ョ櫁ソ占。檎噪譛榊苅蝎ィ逕ィ `curl` 襍ー莠ク驕榊驛ィ譁ー蠅樊磁蜿」亥性蜑埼擇謠仙芦逧悄蛻髯、 bug 蟆ア譏ッ霑吩ク豁・謚灘芦逧シ峨474 荳ェ豬玖ッ募扈ソ縲
  476 +
  477 +### 5.1 `v_task_detail`/`v_dashboard` 蠑蟋/證ょ●/蛛懈ュ「亥ッケ 5 闃ょ粥閭ス謇ゥ螻慕噪霑ス蜉
  478 +
  479 +逕ィ謌キ螳樣刔菴ソ逕ィ譁ー蟒コ螂ス逧 `v_task_detail`/`v_dashboard` 鬘オ髱「譌カ蜿醍鴫荳、螟豐。譛"蠑蟋倶ササ蜉。"逧蜿」窶披覗goal` 荳譌ヲ蛻帛サコ形task.status` 蛛懷惠 `queued` 荳榊蜿伜喧梧イ。譛我ササ菴墓潔髓ョ閭ス隶ゥ螳悄豁」霍題オキ譚・縲
  480 +
  481 +**霑ス貅ッ荳コ莉荵 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`-032牙宵騾莠"莉取汾荳ェ迥カ諤∵耳霑帛芦蠎"逧**蜃ス謨ー**御サ取悴陲ォ莉サ菴募クク鬩サ霑帷ィ区戟扈ュ隹畑郁ソ吩ク轤ケ蝨ィ U-032 蠖捺慮逧シ蜿題ソィ倶クュ荵溷キイ扈剰「ォ隶ー蠖戊ソク谺。瑚ソ呎ャ。霑ス貅ッ譏ッ蜷御ク荳ェ譬ケ蝗蝨ィ謗ァ蛻カ蜿ー螻る擇逧峩謗・蜷取棡峨ゆク我クェ逵溷ョ櫁荳セ髣ュ邇ッsupervisor/self_hosting_e2e.py`芽霍鷹夲シ碁擒逧弍豬玖ッ穂サ」遐∵仞蠑剰ー畑 `scheduler.run_goal_to_completion`御ク肴弍霑吩クェ"蟶ク鬩サ隹コヲ蝎ィ"逵溽噪蟄伜惠縲
  482 +
  483 +**隶セ隶。蜿冶**井ク守畑謌キ遑ョ隶、蜷取牛譚ソ御ク、螟騾我コ耳闕宣。ケ会シ
  484 +1. **霓ッ蛛懈ュ「御ク榊★逵溯ソ帷ィ kill**啻org_node_trigger.py`/`work_graph_execute.py` 驛ス譏ッ髦サ蝪樒噪 `subprocess.run()` 隹 `claude`梧イ。譛我ササ菴募慍譁ケ菫晏ュ倩ソ帷ィ句唱譟サ"蛛懈ュ「"謫堺ス懷悉譚縲1ause/Stop 蜿ェ諡ヲ謌ェ"荳倶ク髦カ谿オ隕∽ク崎ヲ∬ァヲ蜿"梧ュ」蝨ィ霍醍噪驍」谺。逵溷ョ櫁ー畑莨夊辟カ霍大ョ後ら悄豁」遑ャ譚髴隕∵滑霑吩コ幄ー畑轤ケ謐「謌 `Popen` + 謖 `task_id` 蜿ッ譟・逧 PID 陦ィ瑚ソ呎ャ。荳榊★縲
  485 +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縲
  486 +
  487 +**螳樒鴫**壽眠蠅 `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`縲TTP 譁ー蠅 `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` 陦御クコ荳榊序御サ咲┯蜿ェ鄙サ迥カ諤√Av_task_detail` 謖 `task.status` 荳蛾我ク譏セ遉コ"蠑蟋"/"證ょ●+蛛懈ュ「"/"扈ァ扈ュ謇ァ陦"謖蛾聴嫣v_dashboard` 蜿ェ蜉莠ク荳ェ霓サ驥"蠑蟋"井ク埼怙隕 Pause/Stop 逧憾諤∝愛譁ュ峨
  488 +- **螳樒鴫霑ィ倶クュ蜿醍鴫蟷カ菫ョ螟堺ク荳ェ逵 gap**啻backend_server.create_server()` 譛ャ霄ォ莉取悴謚 `db_path` 蟄伜芦 `server` 蟇ケ雎。荳岩披比ケ句燕謇譛芽ッキ豎ょ、炊蝎ィ蜈ア逕ィ `server.conn` 荳譚。霑樊磁蜊ウ蜿ッ瑚ソ呎ャ。譁ー蠅樒噪蜷主床謇ァ陦檎コソ遞矩怙隕∝シ閾ェ蟾ア迢ャ遶狗噪霑樊磁井ク崎霍ィ郤ソ遞句莠ォ `server.conn`会シ悟書邇ー霑吩クェ蟄玲ョオ譬ケ譛ャ荳榊ュ伜惠縲り。・荳 `server.db_path = resolved_db_path`縲
  489 +- **鬪瑚ッ∵婿蠑**啻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` 驥檎畑逵溷ョ樔クエ譌カ譁サカ蠎馴ェ瑚ッ∬ソシ粂TTP 螻ょ宵鬪瑚ッ∵磁郤ソ夊ー畑蜿よ焚縲∫憾諤∫√〜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縲Asrc/` 蜈ィ驥 496 荳ェ豬玖ッ募扈ソ域眠蠅 22 荳ェ14 + 8峨
... ...
frontend/app.js
... ... @@ -598,12 +598,30 @@ function mountDashboard() {
598 598 quotaBanner.style.display = "none";
599 599 return;
600 600 }
  601 + // v_dashboard Start affordance (2026-08-03): the full Pause/Stop set
  602 + // only makes sense on v_task_detail (they need the task's own status
  603 + // context to pick which one to show) -- the dashboard only ever needs
  604 + // "queued and nothing has started yet", so it gets just Start.
  605 + const startRowHtml = active.status !== "queued" ? "" : (
  606 + '<div class="row"><span></span><span>'
  607 + + '<button class="btn btn-primary" id="db_start_task" type="button">开始(execute)</button>'
  608 + + "</span></div>"
  609 + );
601 610 taskSummary.innerHTML = (
602 611 '<div class="row"><span>Goal</span><span>' + escapeHtml(active.goal_text) + "</span></div>"
603 612 + '<div class="row"><span>Loop Round</span><span>' + active.loop_round + "</span></div>"
604 613 + '<div class="row"><span>Status</span><span><a href="#/tasks/' + active.task_id + '">'
605 614 + escapeHtml(active.status) + "</a></span></div>"
  615 + + startRowHtml
606 616 );
  617 + const dbStartBtn = document.getElementById("db_start_task");
  618 + if (dbStartBtn) {
  619 + dbStartBtn.addEventListener("click", async () => {
  620 + dbStartBtn.disabled = true;
  621 + await fetch("/api/tasks/" + active.task_id + "/execute", { method: "POST" });
  622 + refreshTasksAndOrgGraph();
  623 + });
  624 + }
607 625  
608 626 const detailResp = await fetch("/api/tasks/" + active.task_id);
609 627 const detail = await detailResp.json();
... ... @@ -1031,7 +1049,27 @@ function mountTaskDetail(taskId) {
1031 1049 return '<div class="org-node' + (activeSet.has(node) ? " active" : "") + '">' + node + "</div>" + arrow;
1032 1050 }).join("");
1033 1051  
1034   - const alertHtml = task.status !== "blocked_pending_human" ? "" : (
  1052 + // v_task_detail Start/Pause/Stop (2026-08-03): a task only ever has one
  1053 + // real thing "running" against it at a time -- exactly one of these
  1054 + // three blocks is shown, keyed off task.status, matching the states
  1055 + // task_execution.py itself understands (see its module docstring for
  1056 + // why Pause/Stop are a soft stop reusing blocked_pending_human rather
  1057 + // than a genuine subprocess kill / new status enum value).
  1058 + const isUserPauseStop = task.status === "blocked_pending_human"
  1059 + && (task.blocked_reason === "user_paused" || task.blocked_reason === "user_stopped");
  1060 + const execControlsHtml = task.status === "queued" ? (
  1061 + '<div class="btn-row" style="margin:0 0 16px;">'
  1062 + + '<button class="btn btn-primary" id="btn_start_task" type="button">开始(execute)</button></div>'
  1063 + ) : (task.status === "planning_brd_prd_sdd" || task.status === "coding") ? (
  1064 + '<div class="btn-row" style="margin:0 0 16px;">'
  1065 + + '<button class="btn" id="btn_pause_task" type="button">暂停(pause)</button>'
  1066 + + '<button class="btn btn-danger" id="btn_stop_task" type="button">停止(stop)</button></div>'
  1067 + ) : "";
  1068 +
  1069 + const alertHtml = task.status !== "blocked_pending_human" ? "" : isUserPauseStop ? (
  1070 + '<div class="alert-banner">' + (task.blocked_reason === "user_paused" ? "⏸ 已暂停" : "⏹ 已停止")
  1071 + + '<button class="btn btn-primary" id="btn_manual_resume" type="button">继续执行</button></div>'
  1072 + ) : (
1035 1073 '<div class="alert-banner">⚠ 已阻塞(' + escapeHtml(task.blocked_reason || "") + '),需要人工干预'
1036 1074 + '<button class="btn btn-danger" id="btn_manual_resume" type="button">人工干预(manual_resume)</button></div>'
1037 1075 );
... ... @@ -1083,6 +1121,7 @@ function mountTaskDetail(taskId) {
1083 1121 content.innerHTML = (
1084 1122 "<h1>" + escapeHtml(task.goal_text) + "</h1>"
1085 1123 + goalCrudHtml
  1124 + + execControlsHtml
1086 1125 + alertHtml
1087 1126 + '<div class="meta-row">'
1088 1127 + "<div><b>STATUS</b>" + escapeHtml(task.status) + "</div>"
... ... @@ -1126,6 +1165,31 @@ function mountTaskDetail(taskId) {
1126 1165 });
1127 1166 }
1128 1167  
  1168 + const startBtn = document.getElementById("btn_start_task");
  1169 + if (startBtn) {
  1170 + startBtn.addEventListener("click", async () => {
  1171 + startBtn.disabled = true;
  1172 + await fetch("/api/tasks/" + taskId + "/execute", { method: "POST" });
  1173 + refresh();
  1174 + });
  1175 + }
  1176 + const pauseBtn = document.getElementById("btn_pause_task");
  1177 + if (pauseBtn) {
  1178 + pauseBtn.addEventListener("click", async () => {
  1179 + pauseBtn.disabled = true;
  1180 + await fetch("/api/tasks/" + taskId + "/pause", { method: "POST" });
  1181 + refresh();
  1182 + });
  1183 + }
  1184 + const stopBtn = document.getElementById("btn_stop_task");
  1185 + if (stopBtn) {
  1186 + stopBtn.addEventListener("click", async () => {
  1187 + stopBtn.disabled = true;
  1188 + await fetch("/api/tasks/" + taskId + "/stop", { method: "POST" });
  1189 + refresh();
  1190 + });
  1191 + }
  1192 +
1129 1193 const editBtn = document.getElementById("btn_edit_goal_text");
1130 1194 const deleteBtn = document.getElementById("btn_delete_goal");
1131 1195 if (editBtn) {
... ...
src/backend_server.py
... ... @@ -39,7 +39,7 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
39 39 from src import (
40 40 artifact_query, budget_guardrail, bug_record, deployment_query, execution_log,
41 41 org_loop_route, paths, project_query, project_scaffold, schema, settings_config,
42   - skill_query, skill_update, task_create, task_query, ws_subscribe,
  42 + skill_query, skill_update, task_create, task_execution, task_query, ws_subscribe,
43 43 )
44 44  
45 45 _FRONTEND_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "frontend")
... ... @@ -228,6 +228,15 @@ class _Handler(BaseHTTPRequestHandler):
228 228 elif self.path.startswith(_TASKS_PREFIX) and self.path.endswith("/route"):
229 229 task_id = self.path[len(_TASKS_PREFIX):-len("/route")]
230 230 self._handle_task_route(task_id)
  231 + elif self.path.startswith(_TASKS_PREFIX) and self.path.endswith("/execute"):
  232 + task_id = self.path[len(_TASKS_PREFIX):-len("/execute")]
  233 + self._handle_task_execute(task_id)
  234 + elif self.path.startswith(_TASKS_PREFIX) and self.path.endswith("/pause"):
  235 + task_id = self.path[len(_TASKS_PREFIX):-len("/pause")]
  236 + self._handle_task_pause(task_id, "user_paused")
  237 + elif self.path.startswith(_TASKS_PREFIX) and self.path.endswith("/stop"):
  238 + task_id = self.path[len(_TASKS_PREFIX):-len("/stop")]
  239 + self._handle_task_pause(task_id, "user_stopped")
231 240 elif self.path.startswith(_SKILLS_PREFIX) and self.path.endswith("/rollback"):
232 241 skill_id = self.path[len(_SKILLS_PREFIX):-len("/rollback")]
233 242 self._handle_skill_rollback(skill_id)
... ... @@ -457,18 +466,80 @@ class _Handler(BaseHTTPRequestHandler):
457 466 def _handle_task_route(self, task_id):
458 467 """c_org_loop_route (03-C §3.11) -- the underlying route_org_loop()
459 468 was built in U-016 with no HTTP route until now; v_task_detail's
460   - click_retry (manual_resume) is what actually needs it wired up."""
  469 + click_retry (manual_resume) is what actually needs it wired up.
  470 +
  471 + **2026-08-03**: if the task was blocked via a user-initiated
  472 + pause/stop (task_execution.request_pause, blocked_reason in
  473 + ('user_paused', 'user_stopped')), a successful manual_resume also
  474 + spawns a background thread to actually continue real execution --
  475 + matching what the ORIGINAL blocked_pending_human/manual_resume
  476 + design already implies (04-P: "调度器继续执行队列中其他任务" --
  477 + resuming should let the scheduler keep going, not just flip a
  478 + status flag and stop). System-triggered blocks (loop_limit_reached,
  479 + budget_exceeded) keep their existing behavior unchanged -- only
  480 + restoring status, no auto-continuation -- that's a separate,
  481 + already-tested concern this change doesn't touch."""
461 482 payload, error = self._read_json_body()
462 483 if error:
463 484 self._send_json(400, error)
464 485 return
  486 + decided_route = payload.get("decided_route")
  487 + pre_resume_reason = None
  488 + if decided_route == "manual_resume":
  489 + row = self.server.conn.execute(
  490 + "SELECT blocked_reason FROM task WHERE task_id = ?", (task_id,)
  491 + ).fetchone()
  492 + pre_resume_reason = row[0] if row else None
465 493 try:
466 494 with self.server.conn_lock:
467   - result = org_loop_route.route_org_loop(self.server.conn, task_id, payload.get("decided_route"))
  495 + result = org_loop_route.route_org_loop(self.server.conn, task_id, decided_route)
468 496 except org_loop_route.LoopLimitReachedError as e:
469 497 self._send_json(409, {"error": "LOOP_LIMIT_REACHED", "detail": str(e)})
  498 + return
470 499 except (org_loop_route.TaskNotBlockedError, org_loop_route.TaskNotValidatingError, ValueError) as e:
471 500 self._send_json(400, {"error": "INVALID_PARAM", "detail": str(e)})
  501 + return
  502 + if pre_resume_reason in ("user_paused", "user_stopped") and result.get("new_status") in (
  503 + "queued", "planning_brd_prd_sdd", "coding",
  504 + ):
  505 + task_row = self.server.conn.execute(
  506 + "SELECT project_id, goal_text FROM task WHERE task_id = ?", (task_id,)
  507 + ).fetchone()
  508 + target_folder_path = self.server.conn.execute(
  509 + "SELECT target_folder_path FROM project WHERE project_id = ?", (task_row[0],)
  510 + ).fetchone()[0]
  511 + task_execution.start_task_execution_in_background(
  512 + self.server.db_path, task_id, target_folder_path, task_row[1],
  513 + )
  514 + self._send_json(200, result)
  515 +
  516 + def _handle_task_execute(self, task_id):
  517 + """c_task_execute (2026-08-03): the manual "Start" trigger --
  518 + confirmed with the user this is genuinely missing (03-C's own
  519 + c_task_create contract already assumed task creation would
  520 + auto-trigger scheduling via an always-on scheduler that was never
  521 + actually wired up as a running process, see task_execution.py's
  522 + module docstring)."""
  523 + row = self.server.conn.execute("SELECT project_id, goal_text, status FROM task WHERE task_id = ?", (task_id,)).fetchone()
  524 + if row is None:
  525 + self._send_json(404, {"error": "NOT_FOUND"})
  526 + return
  527 + project_id, goal_text, status = row
  528 + if status != "queued":
  529 + self._send_json(409, {"error": "TASK_NOT_QUEUED", "detail": "task is status={}, not queued".format(status)})
  530 + return
  531 + target_folder_path = self.server.conn.execute(
  532 + "SELECT target_folder_path FROM project WHERE project_id = ?", (project_id,)
  533 + ).fetchone()[0]
  534 + task_execution.start_task_execution_in_background(self.server.db_path, task_id, target_folder_path, goal_text)
  535 + self._send_json(200, {"task_id": task_id, "started": True})
  536 +
  537 + def _handle_task_pause(self, task_id, reason):
  538 + try:
  539 + with self.server.conn_lock:
  540 + result = task_execution.request_pause(self.server.conn, task_id, reason)
  541 + except task_execution.TaskExecutionError as e:
  542 + self._send_json(409, {"error": "TASK_NOT_PAUSABLE", "detail": str(e)})
472 543 else:
473 544 self._send_json(200, result)
474 545  
... ... @@ -818,10 +889,12 @@ def create_server(host=&quot;127.0.0.1&quot;, port=0, db_path=None, skill_repo_path=None):
818 889 write an actual commit into whatever repo this process happens to be
819 890 running from. Never exposed to HTTP clients -- only settable here, at
820 891 process/test-construction time."""
821   - conn = schema.connect(db_path if db_path is not None else paths.db_path(), check_same_thread=False)
  892 + resolved_db_path = db_path if db_path is not None else paths.db_path()
  893 + conn = schema.connect(resolved_db_path, check_same_thread=False)
822 894 _ensure_schema(conn)
823 895 server = _Server((host, port), _Handler)
824 896 server.conn = conn
  897 + server.db_path = resolved_db_path
825 898 server.conn_lock = threading.Lock()
826 899 server.skill_repo_path = skill_repo_path if skill_repo_path is not None else skill_query._REPO_ROOT
827 900 return server
... ...
src/task_execution.py 0 → 100644
  1 +"""c_task_execute / c_task_pause (2026-08-03): manual Start/Pause/Stop
  2 +controls for v_task_detail, confirmed with the user first:
  3 +
  4 +1. **Soft stop only.** `org_node_trigger.py`/`work_graph_execute.py` both
  5 + invoke the real `claude` CLI via blocking `subprocess.run()` -- no
  6 + process handle is stored anywhere a "stop" action could reach in and
  7 + kill. Pause/Stop let whatever real call is currently in flight finish
  8 + naturally; only the NEXT phase is withheld. A genuine hard-kill would
  9 + need switching those call sites to `Popen` + tracking PIDs somewhere
  10 + reachable by task_id, which is out of scope for this pass.
  11 +2. **No new `task.status` enum value.** Paused/stopped tasks reuse
  12 + `status='blocked_pending_human'` with `blocked_reason='user_paused'` /
  13 + `'user_stopped'` (distinguished in the audit trail, not functionally --
  14 + both resume the same way) rather than a 01-M schema change. This reuses
  15 + the already-built, already-tested `manual_resume` mechanism
  16 + (`org_loop_route._manual_resume`) to restore the pre-paused status.
  17 +
  18 +**Why a manual Start button doesn't contradict the "lights-out" design**:
  19 +`c_task_create`'s own contract (03-C) already assumed task creation would
  20 +auto-trigger scheduling ("若 max_concurrent_goals 已达上限,任务落 queued
  21 +但不触发调度" -- implying it DOES trigger scheduling otherwise) via an
  22 +always-on Org Graph scheduler that was never actually wired up as a running
  23 +process (`src/scheduler.py` builds the mechanism, nothing spins it up
  24 +continuously). `start_task_execution_in_background()` below is that same
  25 +auto-trigger, just invoked by an explicit user action instead of firing
  26 +automatically the instant a task is created.
  27 +"""
  28 +
  29 +import threading
  30 +
  31 +from src import e2e_pipeline, org_node_lifecycle, schema, scheduler
  32 +
  33 +_RESUMABLE_STATUSES = ("queued", "planning_brd_prd_sdd", "coding")
  34 +_DOC_ARTIFACT_TYPES = ("brd", "prd", "sdd", "benchmark")
  35 +_CODE_ARTIFACT_TYPES = ("code_diff", "test_report")
  36 +
  37 +
  38 +class TaskExecutionError(Exception):
  39 + """The task isn't in a state this module knows how to (re)start from --
  40 + e.g. already running, already done, or blocked for a reason unrelated
  41 + to a user-initiated pause/stop."""
  42 +
  43 +
  44 +class ExecutionPausedError(Exception):
  45 + """Internal control-flow signal, not a failure: a pause/stop was
  46 + requested while this background thread was between phases. Caught by
  47 + the background runner and treated as a clean stop -- the task is
  48 + ALREADY `blocked_pending_human` by construction (that's literally how
  49 + the pause was requested), nothing more to record."""
  50 +
  51 +
  52 +def _check_not_paused(conn, task_id):
  53 + status = conn.execute("SELECT status FROM task WHERE task_id = ?", (task_id,)).fetchone()[0]
  54 + if status == "blocked_pending_human":
  55 + raise ExecutionPausedError(task_id)
  56 +
  57 +
  58 +def _latest_artifact_ids(conn, task_id, artifact_types):
  59 + rows = conn.execute(
  60 + "SELECT artifact_type, artifact_id FROM artifact WHERE task_id = ? AND artifact_type IN ({}) "
  61 + "ORDER BY created_at".format(",".join("?" * len(artifact_types))),
  62 + (task_id, *artifact_types),
  63 + ).fetchall()
  64 + return {r[0]: r[1] for r in rows}
  65 +
  66 +
  67 +def _artifact_content(conn, target_folder_path, task_id, artifact_type):
  68 + row = conn.execute(
  69 + "SELECT artifact_id FROM artifact WHERE task_id = ? AND artifact_type = ? ORDER BY created_at DESC LIMIT 1",
  70 + (task_id, artifact_type),
  71 + ).fetchone()
  72 + if row is None:
  73 + raise TaskExecutionError("task {} has no {} artifact to resume from".format(task_id, artifact_type))
  74 + return e2e_pipeline._read_artifact_content(conn, target_folder_path, row[0])
  75 +
  76 +
  77 +def run_or_resume_task_lifecycle(conn, task_id, target_folder_path, goal_text):
  78 + """Same return shape as `e2e_pipeline.run_task_lifecycle` -- continues
  79 + from wherever `task.status` indicates real work last completed, instead
  80 + of re-running phases that already produced real artifacts. Pausing only
  81 + ever happens BETWEEN phases (checked right before each org_node
  82 + trigger), so the only statuses a paused task can be resumed from are
  83 + 'queued' (nothing ran yet), 'planning_brd_prd_sdd' (Planner done, Coder
  84 + not yet triggered), and 'coding' (Coder done, Validator not yet
  85 + triggered) -- anything else raises `TaskExecutionError` rather than
  86 + guessing at a mid-phase resume this module was never designed for."""
  87 + status = conn.execute("SELECT status FROM task WHERE task_id = ?", (task_id,)).fetchone()[0]
  88 + if status not in _RESUMABLE_STATUSES:
  89 + raise TaskExecutionError("task {} is status={}, not resumable from here".format(task_id, status))
  90 +
  91 + if status == "queued":
  92 + return e2e_pipeline.run_task_lifecycle(conn, task_id, target_folder_path, goal_text)
  93 +
  94 + doc_artifact_ids = _latest_artifact_ids(conn, task_id, _DOC_ARTIFACT_TYPES)
  95 + sdd_content = _artifact_content(conn, target_folder_path, task_id, "sdd")
  96 + benchmark_content = _artifact_content(conn, target_folder_path, task_id, "benchmark")
  97 +
  98 + if status == "planning_brd_prd_sdd":
  99 + _check_not_paused(conn, task_id)
  100 + org_node_lifecycle.trigger_org_node_full(conn, "coder", task_id, target_folder_path, e2e_pipeline._READY_PROMPT)
  101 + code_artifact_ids = e2e_pipeline.implement_code(conn, task_id, sdd_content, goal_text, target_folder_path)
  102 + else: # status == "coding" -- Coder already ran
  103 + code_artifact_ids = _latest_artifact_ids(conn, task_id, _CODE_ARTIFACT_TYPES)
  104 +
  105 + _check_not_paused(conn, task_id)
  106 + org_node_lifecycle.trigger_org_node_full(conn, "validator", task_id, target_folder_path, e2e_pipeline._READY_PROMPT)
  107 + verdict = e2e_pipeline.validate_implementation(task_id, benchmark_content, goal_text, target_folder_path)
  108 +
  109 + return {
  110 + "task_id": task_id, "doc_artifact_ids": doc_artifact_ids,
  111 + "code_artifact_ids": code_artifact_ids, "verdict": verdict,
  112 + }
  113 +
  114 +
  115 +def _mark_blocked(conn, task_id, exc):
  116 + reason = scheduler._blocked_reason_for(exc) if isinstance(exc, scheduler._ROUTABLE_EXCEPTIONS) else "execution_error"
  117 + scheduler._block_task(conn, task_id, reason)
  118 +
  119 +
  120 +def start_task_execution_in_background(db_path, task_id, target_folder_path, goal_text):
  121 + """Spawns a daemon thread that runs (or resumes) this task's real
  122 + execution to completion. Own sqlite connection per thread -- same
  123 + reasoning as `scheduler.run_goal_to_completion`, a shared connection
  124 + across threads needs the same `conn_lock` discipline U-027 already
  125 + proved is easy to get wrong. On PASS/FAIL, applies the same
  126 + `scheduler.finish_task_lifecycle` closing step (`close_task_done` /
  127 + `org_level_loop.run_fix_loop`) a scheduled task gets, so a manually
  128 + started one reaches the same real terminal state. Returns the Thread
  129 + (already started) so callers/tests can `.join()` it if needed."""
  130 + def run():
  131 + conn = schema.connect(db_path)
  132 + try:
  133 + result = run_or_resume_task_lifecycle(conn, task_id, target_folder_path, goal_text)
  134 + scheduler.finish_task_lifecycle(conn, result, target_folder_path, goal_text)
  135 + except ExecutionPausedError:
  136 + pass
  137 + except Exception as e:
  138 + _mark_blocked(conn, task_id, e)
  139 + finally:
  140 + conn.close()
  141 +
  142 + thread = threading.Thread(target=run, daemon=True)
  143 + thread.start()
  144 + return thread
  145 +
  146 +
  147 +def request_pause(conn, task_id, reason):
  148 + """`reason`: 'user_paused' or 'user_stopped' -- see module docstring
  149 + for why these are functionally identical (both a soft stop reusing
  150 + blocked_pending_human), kept as distinct strings only so the audit
  151 + trail (execution_event / blocked_reason) preserves which button the
  152 + user actually clicked. Only valid while a background execution could
  153 + plausibly be in flight; raises TaskExecutionError otherwise (nothing
  154 + running to pause, or already terminal)."""
  155 + row = conn.execute("SELECT project_id, status FROM task WHERE task_id = ?", (task_id,)).fetchone()
  156 + if row is None:
  157 + raise TaskExecutionError("no such task: " + task_id)
  158 + project_id, status = row
  159 + if status not in ("planning_brd_prd_sdd", "coding"):
  160 + raise TaskExecutionError(
  161 + "task {} is status={}, nothing in-flight to pause/stop".format(task_id, status)
  162 + )
  163 + scheduler._block_task(conn, task_id, reason)
  164 + return {"task_id": task_id, "status": "blocked_pending_human", "blocked_reason": reason}
... ...
src/test_backend_server.py
... ... @@ -716,6 +716,121 @@ class TaskQueryAndRouteRouteTest(unittest.TestCase):
716 716 self.assertEqual(body[0]["current_usage"], 10)
717 717  
718 718  
  719 +class TaskExecutionRouteTest(unittest.TestCase):
  720 + """v_task_detail Start/Pause/Stop (2026-08-03): c_task_execute,
  721 + c_task_pause -- task_execution.py's own logic (resume-from-status,
  722 + pause mid-flight) is already covered by test_task_execution.py, this
  723 + only exercises the HTTP wiring. `start_task_execution_in_background`
  724 + is mocked throughout: it spawns a real background thread that opens
  725 + its OWN sqlite connection via `schema.connect(db_path)`, which for a
  726 + ':memory:' test db would silently create a second, disconnected
  727 + database -- exactly the kind of lower-level mechanic the codebase's
  728 + own convention (see other route test classes) says belongs in
  729 + test_task_execution.py, not re-tested at the HTTP layer."""
  730 +
  731 + def setUp(self):
  732 + self.server = backend_server.create_server(db_path=":memory:")
  733 + self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
  734 + self.thread.start()
  735 + self.server.conn.execute(
  736 + "INSERT INTO project (project_id, name, target_folder_path, has_git_repo, status) "
  737 + "VALUES ('proj-1', 'P1', '/tmp/proj-1', 1, 'active')"
  738 + )
  739 + self.server.conn.execute(
  740 + "INSERT INTO task (task_id, project_id, goal_text, task_type, status, assigned_org_node) "
  741 + "VALUES ('task-1', 'proj-1', 'the goal', 'goal', 'queued', 'planner')"
  742 + )
  743 + self.server.conn.commit()
  744 +
  745 + def tearDown(self):
  746 + self.server.shutdown()
  747 + self.server.server_close()
  748 + self.thread.join(timeout=5)
  749 +
  750 + def _post(self, path, payload=None):
  751 + host, port = self.server.server_address
  752 + conn = http.client.HTTPConnection(host, port, timeout=5)
  753 + body = json.dumps(payload).encode("utf-8") if payload is not None else b""
  754 + conn.request("POST", path, body=body)
  755 + resp = conn.getresponse()
  756 + raw = resp.read()
  757 + body = json.loads(raw) if raw else None
  758 + conn.close()
  759 + return resp.status, body
  760 +
  761 + def test_execute_starts_queued_task(self):
  762 + with patch.object(backend_server.task_execution, "start_task_execution_in_background") as mock_start:
  763 + status, body = self._post("/api/tasks/task-1/execute")
  764 + self.assertEqual(status, 200)
  765 + self.assertEqual(body, {"task_id": "task-1", "started": True})
  766 + mock_start.assert_called_once_with(self.server.db_path, "task-1", "/tmp/proj-1", "the goal")
  767 +
  768 + def test_execute_nonexistent_task_returns_404(self):
  769 + status, body = self._post("/api/tasks/no-such-task/execute")
  770 + self.assertEqual(status, 404)
  771 + self.assertEqual(body["error"], "NOT_FOUND")
  772 +
  773 + def test_execute_non_queued_task_returns_409(self):
  774 + self.server.conn.execute("UPDATE task SET status = 'coding' WHERE task_id = 'task-1'")
  775 + self.server.conn.commit()
  776 + status, body = self._post("/api/tasks/task-1/execute")
  777 + self.assertEqual(status, 409)
  778 + self.assertEqual(body["error"], "TASK_NOT_QUEUED")
  779 +
  780 + def test_pause_blocks_in_flight_task(self):
  781 + self.server.conn.execute("UPDATE task SET status = 'coding' WHERE task_id = 'task-1'")
  782 + self.server.conn.commit()
  783 + status, body = self._post("/api/tasks/task-1/pause")
  784 + self.assertEqual(status, 200)
  785 + self.assertEqual(body, {"task_id": "task-1", "status": "blocked_pending_human", "blocked_reason": "user_paused"})
  786 + row = self.server.conn.execute("SELECT status, blocked_reason FROM task WHERE task_id = 'task-1'").fetchone()
  787 + self.assertEqual(tuple(row), ("blocked_pending_human", "user_paused"))
  788 +
  789 + def test_stop_blocks_in_flight_task_with_distinct_reason(self):
  790 + self.server.conn.execute("UPDATE task SET status = 'planning_brd_prd_sdd' WHERE task_id = 'task-1'")
  791 + self.server.conn.commit()
  792 + status, body = self._post("/api/tasks/task-1/stop")
  793 + self.assertEqual(status, 200)
  794 + self.assertEqual(body["blocked_reason"], "user_stopped")
  795 +
  796 + def test_pause_on_queued_task_returns_409(self):
  797 + status, body = self._post("/api/tasks/task-1/pause")
  798 + self.assertEqual(status, 409)
  799 + self.assertEqual(body["error"], "TASK_NOT_PAUSABLE")
  800 +
  801 + def test_manual_resume_after_user_pause_auto_continues(self):
  802 + self.server.conn.execute(
  803 + "UPDATE task SET status = 'blocked_pending_human', blocked_reason = 'user_paused' "
  804 + "WHERE task_id = 'task-1'"
  805 + )
  806 + self.server.conn.execute(
  807 + "INSERT INTO execution_event (event_id, project_id, task_id, event_type, payload) "
  808 + "VALUES ('evt-1', 'proj-1', 'task-1', 'task_status_changed', "
  809 + "'{\"from_status\": \"coding\", \"to_status\": \"blocked_pending_human\"}')"
  810 + )
  811 + self.server.conn.commit()
  812 + with patch.object(backend_server.task_execution, "start_task_execution_in_background") as mock_start:
  813 + status, body = self._post("/api/tasks/task-1/route", {"decided_route": "manual_resume"})
  814 + self.assertEqual(status, 200)
  815 + mock_start.assert_called_once_with(self.server.db_path, "task-1", "/tmp/proj-1", "the goal")
  816 +
  817 + def test_manual_resume_after_system_block_does_not_auto_continue(self):
  818 + self.server.conn.execute(
  819 + "UPDATE task SET status = 'blocked_pending_human', blocked_reason = 'loop_limit_reached' "
  820 + "WHERE task_id = 'task-1'"
  821 + )
  822 + self.server.conn.execute(
  823 + "INSERT INTO execution_event (event_id, project_id, task_id, event_type, payload) "
  824 + "VALUES ('evt-1', 'proj-1', 'task-1', 'task_status_changed', "
  825 + "'{\"from_status\": \"queued\", \"to_status\": \"blocked_pending_human\"}')"
  826 + )
  827 + self.server.conn.commit()
  828 + with patch.object(backend_server.task_execution, "start_task_execution_in_background") as mock_start:
  829 + status, body = self._post("/api/tasks/task-1/route", {"decided_route": "manual_resume"})
  830 + self.assertEqual(status, 200)
  831 + mock_start.assert_not_called()
  832 +
  833 +
719 834 def _ws_handshake_request(path, key):
720 835 return (
721 836 "GET {} HTTP/1.1\r\n"
... ...
src/test_task_execution.py 0 → 100644
  1 +"""c_task_execute / c_task_pause tests (2026-08-03): mocked at the
  2 +subprocess boundary (CLAUDE.md Testing constraint), same pattern as
  3 +test_e2e_pipeline.py -- this file tests task_execution.py's own resume/
  4 +pause wiring, not e2e_pipeline's phase mechanics again.
  5 +
  6 +Run: python3 -m unittest src.test_task_execution -v
  7 +"""
  8 +
  9 +import os
  10 +import subprocess
  11 +import tempfile
  12 +import unittest
  13 +from unittest.mock import patch
  14 +
  15 +from src import e2e_pipeline, org_node_lifecycle, org_node_trigger, project_scaffold, schema, scheduler, task_execution, work_graph_execute
  16 +
  17 +
  18 +def _fake_ready_trigger(org_node, prompt, target_folder_path, timeout=300):
  19 + assert prompt == e2e_pipeline._READY_PROMPT
  20 + return "Ready."
  21 +
  22 +
  23 +class TaskExecutionTestBase(unittest.TestCase):
  24 + def setUp(self):
  25 + self.tmp = tempfile.TemporaryDirectory()
  26 + self.addCleanup(self.tmp.cleanup)
  27 + subprocess.run(["git", "init"], cwd=self.tmp.name, check=True, capture_output=True)
  28 + self.conn = schema.connect(":memory:")
  29 + schema.create_schema(self.conn)
  30 + project = project_scaffold.create_project(self.conn, self.tmp.name, init_git_if_missing=False)
  31 + self.project_id = project["project_id"]
  32 +
  33 + def tearDown(self):
  34 + self.conn.close()
  35 +
  36 + def _insert_task(self, task_id, status, assigned_org_node="planner", loop_round=0):
  37 + self.conn.execute(
  38 + "INSERT INTO task (task_id, project_id, goal_text, task_type, status, assigned_org_node, loop_round) "
  39 + "VALUES (?, ?, 'a goal', 'goal', ?, ?, ?)",
  40 + (task_id, self.project_id, status, assigned_org_node, loop_round),
  41 + )
  42 + self.conn.commit()
  43 +
  44 + def _write_artifact(self, task_id, artifact_type, content):
  45 + run_id = "run-" + artifact_type
  46 + self.conn.execute(
  47 + "INSERT OR IGNORE INTO work_graph_run (run_id, task_id, org_node, graph_manifest, status) "
  48 + "VALUES (?, ?, 'planner', '{}', 'done')", (run_id, task_id),
  49 + )
  50 + rel_path = ".graphlemon/runs/{}/{}.md".format(task_id, artifact_type)
  51 + abs_path = os.path.join(self.tmp.name, rel_path)
  52 + os.makedirs(os.path.dirname(abs_path), exist_ok=True)
  53 + with open(abs_path, "w") as f:
  54 + f.write(content)
  55 + subprocess.run(["git", "add", "-A"], cwd=self.tmp.name, check=True, capture_output=True)
  56 + subprocess.run(
  57 + ["git", "-c", "user.name=t", "-c", "user.email=t@t", "commit", "-m", "x"],
  58 + cwd=self.tmp.name, check=True, capture_output=True,
  59 + )
  60 + sha = subprocess.run(
  61 + ["git", "rev-parse", "HEAD"], cwd=self.tmp.name, capture_output=True, text=True
  62 + ).stdout.strip()
  63 + self.conn.execute(
  64 + "INSERT INTO artifact (artifact_id, artifact_type, task_id, produced_by_run_id, file_path, "
  65 + "git_commit_sha, validation_status) VALUES (?, ?, ?, ?, ?, ?, 'passed')",
  66 + ("art-" + artifact_type, artifact_type, task_id, run_id, rel_path, sha),
  67 + )
  68 + self.conn.commit()
  69 +
  70 +
  71 +class ResumeFromCodingTest(TaskExecutionTestBase):
  72 + """Coder already ran (real code_diff/test_report artifacts exist) --
  73 + resuming from 'coding' must skip straight to Validator, not redo
  74 + Planner or Coder."""
  75 +
  76 + def test_skips_to_validator_only(self):
  77 + self._insert_task("task-1", status="coding", assigned_org_node="coder")
  78 + self._write_artifact("task-1", "sdd", "# SDD content")
  79 + self._write_artifact("task-1", "benchmark", "# Benchmark content")
  80 + self._write_artifact("task-1", "code_diff", "diff content")
  81 + self._write_artifact("task-1", "test_report", "report content")
  82 +
  83 + with patch.object(org_node_lifecycle, "trigger_org_node", side_effect=_fake_ready_trigger), \
  84 + patch.object(org_node_trigger, "trigger_org_node", return_value="VERDICT: PASS") as mock_trigger:
  85 + result = task_execution.run_or_resume_task_lifecycle(self.conn, "task-1", self.tmp.name, "a goal")
  86 +
  87 + self.assertEqual(result["verdict"]["verdict"], "PASS")
  88 + self.assertEqual(result["code_artifact_ids"]["code_diff"], "art-code_diff")
  89 + # only the validator's real call happened -- no coder call
  90 + mock_trigger.assert_called_once()
  91 + self.assertEqual(mock_trigger.call_args[0][0], "validator")
  92 + row = self.conn.execute("SELECT status FROM task WHERE task_id = 'task-1'").fetchone()
  93 + self.assertEqual(row[0], "validating")
  94 +
  95 +
  96 +class ResumeFromPlanningTest(TaskExecutionTestBase):
  97 + """Planner already ran (real doc artifacts exist) -- resuming from
  98 + 'planning_brd_prd_sdd' must run Coder then Validator, not redo Planner."""
  99 +
  100 + def test_runs_coder_then_validator(self):
  101 + self._insert_task("task-1", status="planning_brd_prd_sdd", assigned_org_node="planner")
  102 + # trigger_org_node_full("coder", ...) requires ALL FOUR doc artifacts
  103 + # validation_status='passed' (org_node_lifecycle._REQUIRED_ARTIFACTS),
  104 + # not just the two this module's own resume logic reads content from.
  105 + self._write_artifact("task-1", "brd", "# BRD content")
  106 + self._write_artifact("task-1", "prd", "# PRD content")
  107 + self._write_artifact("task-1", "sdd", "# SDD content")
  108 + self._write_artifact("task-1", "benchmark", "# Benchmark content")
  109 +
  110 + with patch.object(org_node_lifecycle, "trigger_org_node", side_effect=_fake_ready_trigger), \
  111 + patch.object(work_graph_execute, "write_workflow_script"), \
  112 + patch.object(work_graph_execute, "execute_work_graph", return_value={"response": "done, tests pass"}), \
  113 + patch.object(org_node_trigger, "trigger_org_node", return_value="VERDICT: PASS") as mock_trigger:
  114 + result = task_execution.run_or_resume_task_lifecycle(self.conn, "task-1", self.tmp.name, "a goal")
  115 +
  116 + self.assertEqual(result["verdict"]["verdict"], "PASS")
  117 + self.assertIn("code_diff", result["code_artifact_ids"])
  118 + mock_trigger.assert_called_once()
  119 + self.assertEqual(mock_trigger.call_args[0][0], "validator")
  120 +
  121 +
  122 +class ResumeUnresumableStatusTest(TaskExecutionTestBase):
  123 + def test_done_task_raises(self):
  124 + self._insert_task("task-1", status="done")
  125 + with self.assertRaises(task_execution.TaskExecutionError):
  126 + task_execution.run_or_resume_task_lifecycle(self.conn, "task-1", self.tmp.name, "a goal")
  127 +
  128 + def test_blocked_task_raises(self):
  129 + self._insert_task("task-1", status="blocked_pending_human")
  130 + with self.assertRaises(task_execution.TaskExecutionError):
  131 + task_execution.run_or_resume_task_lifecycle(self.conn, "task-1", self.tmp.name, "a goal")
  132 +
  133 +
  134 +class PauseMidResumeTest(TaskExecutionTestBase):
  135 + """If task.status flips to blocked_pending_human WHILE this thread is
  136 + between phases (checked right before each org_node trigger),
  137 + ExecutionPausedError must stop cleanly without triggering the next
  138 + phase."""
  139 +
  140 + def test_pause_before_validator_stops_cleanly(self):
  141 + self._insert_task("task-1", status="coding", assigned_org_node="coder")
  142 + self._write_artifact("task-1", "sdd", "# SDD content")
  143 + self._write_artifact("task-1", "benchmark", "# Benchmark content")
  144 + self._write_artifact("task-1", "code_diff", "diff content")
  145 + self._write_artifact("task-1", "test_report", "report content")
  146 +
  147 + real_lookup = task_execution._latest_artifact_ids
  148 +
  149 + def lookup_then_pause(conn, task_id, artifact_types):
  150 + # simulates a concurrent request_pause() landing right after
  151 + # this (real, unmocked) lookup runs -- the last step before
  152 + # _check_not_paused() is reached in the real code path.
  153 + result = real_lookup(conn, task_id, artifact_types)
  154 + conn.execute(
  155 + "UPDATE task SET status = 'blocked_pending_human', blocked_reason = 'user_paused' "
  156 + "WHERE task_id = 'task-1'"
  157 + )
  158 + conn.commit()
  159 + return result
  160 +
  161 + with patch.object(task_execution, "_latest_artifact_ids", side_effect=lookup_then_pause), \
  162 + patch.object(org_node_trigger, "trigger_org_node") as mock_trigger:
  163 + with self.assertRaises(task_execution.ExecutionPausedError):
  164 + task_execution.run_or_resume_task_lifecycle(self.conn, "task-1", self.tmp.name, "a goal")
  165 + mock_trigger.assert_not_called()
  166 +
  167 +
  168 +class CheckNotPausedTest(TaskExecutionTestBase):
  169 + def test_raises_when_blocked(self):
  170 + self._insert_task("task-1", status="blocked_pending_human")
  171 + with self.assertRaises(task_execution.ExecutionPausedError):
  172 + task_execution._check_not_paused(self.conn, "task-1")
  173 +
  174 + def test_does_not_raise_when_not_blocked(self):
  175 + self._insert_task("task-1", status="coding")
  176 + task_execution._check_not_paused(self.conn, "task-1") # no raise
  177 +
  178 +
  179 +class RequestPauseTest(TaskExecutionTestBase):
  180 + def test_pauses_a_coding_task(self):
  181 + self._insert_task("task-1", status="coding")
  182 + result = task_execution.request_pause(self.conn, "task-1", "user_paused")
  183 + self.assertEqual(result["status"], "blocked_pending_human")
  184 + row = self.conn.execute("SELECT status, blocked_reason FROM task WHERE task_id = 'task-1'").fetchone()
  185 + self.assertEqual(row, ("blocked_pending_human", "user_paused"))
  186 +
  187 + def test_stops_a_planning_task(self):
  188 + self._insert_task("task-1", status="planning_brd_prd_sdd")
  189 + result = task_execution.request_pause(self.conn, "task-1", "user_stopped")
  190 + self.assertEqual(result["blocked_reason"], "user_stopped")
  191 +
  192 + def test_cannot_pause_a_queued_task(self):
  193 + self._insert_task("task-1", status="queued")
  194 + with self.assertRaises(task_execution.TaskExecutionError):
  195 + task_execution.request_pause(self.conn, "task-1", "user_paused")
  196 +
  197 + def test_cannot_pause_a_done_task(self):
  198 + self._insert_task("task-1", status="done")
  199 + with self.assertRaises(task_execution.TaskExecutionError):
  200 + task_execution.request_pause(self.conn, "task-1", "user_paused")
  201 +
  202 + def test_nonexistent_task_raises(self):
  203 + with self.assertRaises(task_execution.TaskExecutionError):
  204 + task_execution.request_pause(self.conn, "no-such-task", "user_paused")
  205 +
  206 +
  207 +class StartTaskExecutionInBackgroundTest(unittest.TestCase):
  208 + """Real threading (own connection per thread, same as scheduler.py) --
  209 + needs a real file db_path, not ':memory:' (a fresh ':memory:' connection
  210 + per thread would be a completely separate, empty database)."""
  211 +
  212 + def setUp(self):
  213 + self.repo = tempfile.TemporaryDirectory()
  214 + self.addCleanup(self.repo.cleanup)
  215 + subprocess.run(["git", "init"], cwd=self.repo.name, check=True, capture_output=True)
  216 + self.tmp_db = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
  217 + self.tmp_db.close()
  218 + self.db_path = self.tmp_db.name
  219 + self.addCleanup(lambda: os.path.exists(self.db_path) and os.remove(self.db_path))
  220 +
  221 + conn = schema.connect(self.db_path)
  222 + schema.create_schema(conn)
  223 + project = project_scaffold.create_project(conn, self.repo.name, init_git_if_missing=False)
  224 + self.project_id = project["project_id"]
  225 + conn.execute(
  226 + "INSERT INTO task (task_id, project_id, goal_text, task_type, status, assigned_org_node) "
  227 + "VALUES ('task-1', ?, 'a goal', 'goal', 'coding', 'coder')", (self.project_id,)
  228 + )
  229 + conn.commit()
  230 + conn.close()
  231 +
  232 + def _write_artifact(self, task_id, artifact_type, content):
  233 + conn = schema.connect(self.db_path)
  234 + run_id = "run-" + artifact_type
  235 + conn.execute(
  236 + "INSERT OR IGNORE INTO work_graph_run (run_id, task_id, org_node, graph_manifest, status) "
  237 + "VALUES (?, ?, 'planner', '{}', 'done')", (run_id, task_id),
  238 + )
  239 + rel_path = ".graphlemon/runs/{}/{}.md".format(task_id, artifact_type)
  240 + abs_path = os.path.join(self.repo.name, rel_path)
  241 + os.makedirs(os.path.dirname(abs_path), exist_ok=True)
  242 + with open(abs_path, "w") as f:
  243 + f.write(content)
  244 + subprocess.run(["git", "add", "-A"], cwd=self.repo.name, check=True, capture_output=True)
  245 + subprocess.run(
  246 + ["git", "-c", "user.name=t", "-c", "user.email=t@t", "commit", "-m", "x"],
  247 + cwd=self.repo.name, check=True, capture_output=True,
  248 + )
  249 + sha = subprocess.run(
  250 + ["git", "rev-parse", "HEAD"], cwd=self.repo.name, capture_output=True, text=True
  251 + ).stdout.strip()
  252 + conn.execute(
  253 + "INSERT INTO artifact (artifact_id, artifact_type, task_id, produced_by_run_id, file_path, "
  254 + "git_commit_sha, validation_status) VALUES (?, ?, ?, ?, ?, ?, 'passed')",
  255 + ("art-" + artifact_type, artifact_type, task_id, run_id, rel_path, sha),
  256 + )
  257 + conn.commit()
  258 + conn.close()
  259 +
  260 + def test_real_thread_completes_and_closes_task(self):
  261 + self._write_artifact("task-1", "sdd", "# SDD")
  262 + self._write_artifact("task-1", "benchmark", "# Benchmark")
  263 + self._write_artifact("task-1", "code_diff", "diff")
  264 + self._write_artifact("task-1", "test_report", "report")
  265 +
  266 + with patch.object(org_node_lifecycle, "trigger_org_node", side_effect=_fake_ready_trigger), \
  267 + patch.object(org_node_trigger, "trigger_org_node", return_value="VERDICT: PASS"):
  268 + thread = task_execution.start_task_execution_in_background(
  269 + self.db_path, "task-1", self.repo.name, "a goal",
  270 + )
  271 + thread.join(timeout=10)
  272 +
  273 + conn = schema.connect(self.db_path)
  274 + status = conn.execute("SELECT status FROM task WHERE task_id = 'task-1'").fetchone()[0]
  275 + conn.close()
  276 + self.assertEqual(status, "done")
  277 +
  278 + def test_generic_failure_marks_blocked(self):
  279 + self._write_artifact("task-1", "sdd", "# SDD")
  280 + self._write_artifact("task-1", "benchmark", "# Benchmark")
  281 + self._write_artifact("task-1", "code_diff", "diff")
  282 + self._write_artifact("task-1", "test_report", "report")
  283 +
  284 + with patch.object(org_node_lifecycle, "trigger_org_node", side_effect=_fake_ready_trigger), \
  285 + patch.object(org_node_trigger, "trigger_org_node", side_effect=RuntimeError("boom")):
  286 + thread = task_execution.start_task_execution_in_background(
  287 + self.db_path, "task-1", self.repo.name, "a goal",
  288 + )
  289 + thread.join(timeout=10)
  290 +
  291 + conn = schema.connect(self.db_path)
  292 + row = conn.execute("SELECT status, blocked_reason FROM task WHERE task_id = 'task-1'").fetchone()
  293 + conn.close()
  294 + self.assertEqual(row, ("blocked_pending_human", "execution_error"))
  295 +
  296 +
  297 +if __name__ == "__main__":
  298 + unittest.main()
... ...