Commit add263a97b46e6aa6f384dd3eda5bae2914af7ff
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>
Showing
6 changed files
with
733 additions
and
5 deletions
docs/development-plan.md
| @@ -473,3 +473,17 @@ PH0-PH545 荳ェ Unit牙驛ィ螳梧縲ゞ-042 逵溷ョ櫁荳セ蜴区オ玖ソ幄。御クュ譛滄龍 | @@ -473,3 +473,17 @@ PH0-PH545 荳ェ Unit牙驛ィ螳梧縲ゞ-042 逵溷ョ櫁荳セ蜴区オ玖ソ幄。御クュ譛滄龍 | ||
| 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峨 | 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 | - **逵溷ョ樣ェ瑚ッ∵慮蜿醍鴫蟷カ菫ョ螟堺ク荳ェ逵 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()` 霍ッ蠕噪蝗槫ス呈オ玖ッ暮煤豁サ縲 | 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 | - **鬪瑚ッ∵婿蠑**啻src/test_project_query.py`12 荳ェ逕ィ萓具シ峨〜src/test_task_query.py` 譁ー蠅樒噪謗貞コ/郛冶セ/蛻髯、逕ィ萓具シ亥性荳企擇霑呎擅逵溷ョ櫁キッ蠕屓蠖抵シ峨〜src/test_backend_server.py` 譁ー蠅樔ク、荳ェ逵溷ョ HTTP 霍ッ逕ア豬玖ッ慕アサProjectListAndCrudRouteTest`/`TaskCrudRouteTest`檎悄螳櫁オキ譛榊苅蝎ィ+逵溷ョ櫁ッキ豎ゑシ御ク mock峨よオ剰ァ亥勣謇ゥ螻墓悽谺。莉堺ク榊庄逕ィ碁「晏、門ッケ逹逵溷ョ櫁ソ占。檎噪譛榊苅蝎ィ逕ィ `curl` 襍ー莠ク驕榊驛ィ譁ー蠅樊磁蜿」亥性蜑埼擇謠仙芦逧悄蛻髯、 bug 蟆ア譏ッ霑吩ク豁・謚灘芦逧シ峨474 荳ェ豬玖ッ募扈ソ縲 | 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,12 +598,30 @@ function mountDashboard() { | ||
| 598 | quotaBanner.style.display = "none"; | 598 | quotaBanner.style.display = "none"; |
| 599 | return; | 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 | taskSummary.innerHTML = ( | 610 | taskSummary.innerHTML = ( |
| 602 | '<div class="row"><span>Goal</span><span>' + escapeHtml(active.goal_text) + "</span></div>" | 611 | '<div class="row"><span>Goal</span><span>' + escapeHtml(active.goal_text) + "</span></div>" |
| 603 | + '<div class="row"><span>Loop Round</span><span>' + active.loop_round + "</span></div>" | 612 | + '<div class="row"><span>Loop Round</span><span>' + active.loop_round + "</span></div>" |
| 604 | + '<div class="row"><span>Status</span><span><a href="#/tasks/' + active.task_id + '">' | 613 | + '<div class="row"><span>Status</span><span><a href="#/tasks/' + active.task_id + '">' |
| 605 | + escapeHtml(active.status) + "</a></span></div>" | 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 | const detailResp = await fetch("/api/tasks/" + active.task_id); | 626 | const detailResp = await fetch("/api/tasks/" + active.task_id); |
| 609 | const detail = await detailResp.json(); | 627 | const detail = await detailResp.json(); |
| @@ -1031,7 +1049,27 @@ function mountTaskDetail(taskId) { | @@ -1031,7 +1049,27 @@ function mountTaskDetail(taskId) { | ||
| 1031 | return '<div class="org-node' + (activeSet.has(node) ? " active" : "") + '">' + node + "</div>" + arrow; | 1049 | return '<div class="org-node' + (activeSet.has(node) ? " active" : "") + '">' + node + "</div>" + arrow; |
| 1032 | }).join(""); | 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 | '<div class="alert-banner">⚠ 已阻塞(' + escapeHtml(task.blocked_reason || "") + '),需要人工干预' | 1073 | '<div class="alert-banner">⚠ 已阻塞(' + escapeHtml(task.blocked_reason || "") + '),需要人工干预' |
| 1036 | + '<button class="btn btn-danger" id="btn_manual_resume" type="button">人工干预(manual_resume)</button></div>' | 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,6 +1121,7 @@ function mountTaskDetail(taskId) { | ||
| 1083 | content.innerHTML = ( | 1121 | content.innerHTML = ( |
| 1084 | "<h1>" + escapeHtml(task.goal_text) + "</h1>" | 1122 | "<h1>" + escapeHtml(task.goal_text) + "</h1>" |
| 1085 | + goalCrudHtml | 1123 | + goalCrudHtml |
| 1124 | + + execControlsHtml | ||
| 1086 | + alertHtml | 1125 | + alertHtml |
| 1087 | + '<div class="meta-row">' | 1126 | + '<div class="meta-row">' |
| 1088 | + "<div><b>STATUS</b>" + escapeHtml(task.status) + "</div>" | 1127 | + "<div><b>STATUS</b>" + escapeHtml(task.status) + "</div>" |
| @@ -1126,6 +1165,31 @@ function mountTaskDetail(taskId) { | @@ -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 | const editBtn = document.getElementById("btn_edit_goal_text"); | 1193 | const editBtn = document.getElementById("btn_edit_goal_text"); |
| 1130 | const deleteBtn = document.getElementById("btn_delete_goal"); | 1194 | const deleteBtn = document.getElementById("btn_delete_goal"); |
| 1131 | if (editBtn) { | 1195 | if (editBtn) { |
src/backend_server.py
| @@ -39,7 +39,7 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer | @@ -39,7 +39,7 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer | ||
| 39 | from src import ( | 39 | from src import ( |
| 40 | artifact_query, budget_guardrail, bug_record, deployment_query, execution_log, | 40 | artifact_query, budget_guardrail, bug_record, deployment_query, execution_log, |
| 41 | org_loop_route, paths, project_query, project_scaffold, schema, settings_config, | 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 | _FRONTEND_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "frontend") | 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,6 +228,15 @@ class _Handler(BaseHTTPRequestHandler): | ||
| 228 | elif self.path.startswith(_TASKS_PREFIX) and self.path.endswith("/route"): | 228 | elif self.path.startswith(_TASKS_PREFIX) and self.path.endswith("/route"): |
| 229 | task_id = self.path[len(_TASKS_PREFIX):-len("/route")] | 229 | task_id = self.path[len(_TASKS_PREFIX):-len("/route")] |
| 230 | self._handle_task_route(task_id) | 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 | elif self.path.startswith(_SKILLS_PREFIX) and self.path.endswith("/rollback"): | 240 | elif self.path.startswith(_SKILLS_PREFIX) and self.path.endswith("/rollback"): |
| 232 | skill_id = self.path[len(_SKILLS_PREFIX):-len("/rollback")] | 241 | skill_id = self.path[len(_SKILLS_PREFIX):-len("/rollback")] |
| 233 | self._handle_skill_rollback(skill_id) | 242 | self._handle_skill_rollback(skill_id) |
| @@ -457,18 +466,80 @@ class _Handler(BaseHTTPRequestHandler): | @@ -457,18 +466,80 @@ class _Handler(BaseHTTPRequestHandler): | ||
| 457 | def _handle_task_route(self, task_id): | 466 | def _handle_task_route(self, task_id): |
| 458 | """c_org_loop_route (03-C §3.11) -- the underlying route_org_loop() | 467 | """c_org_loop_route (03-C §3.11) -- the underlying route_org_loop() |
| 459 | was built in U-016 with no HTTP route until now; v_task_detail's | 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 | payload, error = self._read_json_body() | 482 | payload, error = self._read_json_body() |
| 462 | if error: | 483 | if error: |
| 463 | self._send_json(400, error) | 484 | self._send_json(400, error) |
| 464 | return | 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 | try: | 493 | try: |
| 466 | with self.server.conn_lock: | 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 | except org_loop_route.LoopLimitReachedError as e: | 496 | except org_loop_route.LoopLimitReachedError as e: |
| 469 | self._send_json(409, {"error": "LOOP_LIMIT_REACHED", "detail": str(e)}) | 497 | self._send_json(409, {"error": "LOOP_LIMIT_REACHED", "detail": str(e)}) |
| 498 | + return | ||
| 470 | except (org_loop_route.TaskNotBlockedError, org_loop_route.TaskNotValidatingError, ValueError) as e: | 499 | except (org_loop_route.TaskNotBlockedError, org_loop_route.TaskNotValidatingError, ValueError) as e: |
| 471 | self._send_json(400, {"error": "INVALID_PARAM", "detail": str(e)}) | 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 | else: | 543 | else: |
| 473 | self._send_json(200, result) | 544 | self._send_json(200, result) |
| 474 | 545 | ||
| @@ -818,10 +889,12 @@ def create_server(host="127.0.0.1", port=0, db_path=None, skill_repo_path=None): | @@ -818,10 +889,12 @@ def create_server(host="127.0.0.1", port=0, db_path=None, skill_repo_path=None): | ||
| 818 | write an actual commit into whatever repo this process happens to be | 889 | write an actual commit into whatever repo this process happens to be |
| 819 | running from. Never exposed to HTTP clients -- only settable here, at | 890 | running from. Never exposed to HTTP clients -- only settable here, at |
| 820 | process/test-construction time.""" | 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 | _ensure_schema(conn) | 894 | _ensure_schema(conn) |
| 823 | server = _Server((host, port), _Handler) | 895 | server = _Server((host, port), _Handler) |
| 824 | server.conn = conn | 896 | server.conn = conn |
| 897 | + server.db_path = resolved_db_path | ||
| 825 | server.conn_lock = threading.Lock() | 898 | server.conn_lock = threading.Lock() |
| 826 | server.skill_repo_path = skill_repo_path if skill_repo_path is not None else skill_query._REPO_ROOT | 899 | server.skill_repo_path = skill_repo_path if skill_repo_path is not None else skill_query._REPO_ROOT |
| 827 | return server | 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,6 +716,121 @@ class TaskQueryAndRouteRouteTest(unittest.TestCase): | ||
| 716 | self.assertEqual(body[0]["current_usage"], 10) | 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 | def _ws_handshake_request(path, key): | 834 | def _ws_handshake_request(path, key): |
| 720 | return ( | 835 | return ( |
| 721 | "GET {} HTTP/1.1\r\n" | 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() |