Commit a8cab45d739440db5ed6dd5aacecdb679e9dc33e
1 parent
ccf51c3f
U-009: c_task_create + task lifecycle landing at queued
create_task(): validates goal_text/project_id/task_type (INVALID_PARAM per
03-C §3.1), inserts the task row (status=queued, assigned_org_node=planner,
loop_round=0 -- 04-P p_org_task_lifecycle start_state) plus a
task_status_changed execution_event, both in one transaction (explicit
rollback on failure between the two inserts, tested by dropping
execution_event mid-call). c_project_create isn't reimplemented -- it's
already complete in U-002 (project_scaffold.create_project).
Corrects another Done-line overreach found while scoping this Unit:
development-plan.md claimed U-009 alone satisfies all three REQ-01 cases
(happy/boundary/failure), but the happy case ("依次触发 planner/coder/
validator...task.status 按状态机正确流转") needs c_org_node_trigger's full
implementation -- U-010's own Done criterion is almost that exact sentence,
and U-009 doesn't depend on U-010 or even touch org-node triggering. Same
class of issue as the U-002/U-036 corrections earlier this session (an
SDD/plan line claiming more than the Unit's actual dependencies support),
judged low-stakes enough (doesn't change what code to write, just when the
REQ-01 happy case gets marked satisfied) to fix directly rather than stop
and ask, per the graduated caution this session has been using. Boundary
(concurrency limit doesn't block task *creation*, only later scheduling)
and failure (INVALID_PARAM) are both fully real at U-009's scope and
tested; happy completes cumulatively once U-010 lands.
12 new stdlib unittest cases. 75 total across U-001-009, all passing.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Showing
4 changed files
with
175 additions
and
4 deletions
docs/development-plan.md
| @@ -103,10 +103,10 @@ | @@ -103,10 +103,10 @@ | ||
| 103 | 103 | ||
| 104 | ### PH1-S1 Org Graph 层单点触发 | 104 | ### PH1-S1 Org Graph 层单点触发 |
| 105 | 105 | ||
| 106 | -- **U-009 [Value Path]** `c_project_create` + `c_task_create` 实现,`task` 状态机(`p_org_task_lifecycle`)落地到 `queued` 态 | 106 | +- **U-009 [Value Path]** `c_task_create` 实现,`task` 状态机(`p_org_task_lifecycle`)落地到 `queued` 态(`c_project_create` 已经在 U-002 完整实现,这里不重做) |
| 107 | - 依赖:U-001, U-002 | 107 | - 依赖:U-001, U-002 |
| 108 | - - Done:REQ-01 happy/boundary/failure 三个用例全部通过 | ||
| 109 | - - 覆盖:01-M `task`;03-C `c_task_create`;04-P `p_org_task_lifecycle`;REQ-01 | 108 | + - Done(**2026-07-31 订正**:原来写"REQ-01 happy/boundary/failure 三个用例全部通过"不准确——happy 用例("依次触发 planner/coder/validator 三个 org_node,task.status 按状态机正确流转")需要 `c_org_node_trigger` 完整实现,U-009 本身不碰这个,做不到;U-010 的 Done 标准原话几乎就是这句 happy 用例。boundary(并发上限不阻塞任务*创建*本身)和 failure(`INVALID_PARAM`)两条在 U-009 范围内是真实可测的):boundary/failure 两个用例通过;happy 用例随 U-010 落地后累计满足,不在本 Unit 单独打勾 |
| 109 | + - 覆盖:01-M `task`;03-C `c_task_create`;04-P `p_org_task_lifecycle`;REQ-01(boundary/failure) | ||
| 110 | 110 | ||
| 111 | - **U-010 [Value Path]** `c_org_node_trigger` 完整实现(含 `TASK_NOT_READY`/`CONCURRENCY_LIMIT_REACHED`/`USAGE_LIMIT_REACHED` 错误处理) | 111 | - **U-010 [Value Path]** `c_org_node_trigger` 完整实现(含 `TASK_NOT_READY`/`CONCURRENCY_LIMIT_REACHED`/`USAGE_LIMIT_REACHED` 错误处理) |
| 112 | - 依赖:U-005, U-009 | 112 | - 依赖:U-005, U-009 |
docs/sdd/unit_matrix.json
| @@ -21,7 +21,7 @@ | @@ -21,7 +21,7 @@ | ||
| 21 | {"id": "U-007", "phase": "PH0", "sub_phase": "PH0-S3", "name": "本地后端骨架 + c_pick_folder macOS 实现", "classification": "Enabler", "depends_on": [], "coverage": {"model": [], "view": [], "controller": ["c_pick_folder"], "process": [], "requirement": []}, "done": "后端可启停;macOS 上原生对话框返回真实路径"}, | 21 | {"id": "U-007", "phase": "PH0", "sub_phase": "PH0-S3", "name": "本地后端骨架 + c_pick_folder macOS 实现", "classification": "Enabler", "depends_on": [], "coverage": {"model": [], "view": [], "controller": ["c_pick_folder"], "process": [], "requirement": []}, "done": "后端可启停;macOS 上原生对话框返回真实路径"}, |
| 22 | {"id": "U-008", "phase": "PH0", "sub_phase": "PH0-S3", "name": "前端骨架", "classification": "Enabler", "depends_on": ["U-007"], "coverage": {"model": [], "view": [], "controller": [], "process": [], "requirement": []}, "done": "本地 web 应用路由跳转正常"}, | 22 | {"id": "U-008", "phase": "PH0", "sub_phase": "PH0-S3", "name": "前端骨架", "classification": "Enabler", "depends_on": ["U-007"], "coverage": {"model": [], "view": [], "controller": [], "process": [], "requirement": []}, "done": "本地 web 应用路由跳转正常"}, |
| 23 | 23 | ||
| 24 | - {"id": "U-009", "phase": "PH1", "sub_phase": "PH1-S1", "name": "c_project_create + c_task_create + task 状态机", "classification": "Value Path", "depends_on": ["U-001", "U-002"], "coverage": {"model": ["task"], "view": [], "controller": ["c_task_create"], "process": ["p_org_task_lifecycle"], "requirement": ["REQ-01"]}, "done": "REQ-01 happy/boundary/failure 全部通过"}, | 24 | + {"id": "U-009", "phase": "PH1", "sub_phase": "PH1-S1", "name": "c_task_create + task 状态机(c_project_create 已在 U-002 完成)", "classification": "Value Path", "depends_on": ["U-001", "U-002"], "coverage": {"model": ["task"], "view": [], "controller": ["c_task_create"], "process": ["p_org_task_lifecycle"], "requirement": ["REQ-01"]}, "done": "REQ-01 boundary/failure 通过;happy 用例需要 c_org_node_trigger 完整实现,随 U-010 累计满足(2026-07-31 订正)"}, |
| 25 | {"id": "U-010", "phase": "PH1", "sub_phase": "PH1-S1", "name": "c_org_node_trigger 完整实现", "classification": "Value Path", "depends_on": ["U-005", "U-009"], "coverage": {"model": [], "view": [], "controller": ["c_org_node_trigger"], "process": [], "requirement": []}, "done": "三个 Org Agent 依次可被触发,task.status 按状态机正确流转"}, | 25 | {"id": "U-010", "phase": "PH1", "sub_phase": "PH1-S1", "name": "c_org_node_trigger 完整实现", "classification": "Value Path", "depends_on": ["U-005", "U-009"], "coverage": {"model": [], "view": [], "controller": ["c_org_node_trigger"], "process": [], "requirement": []}, "done": "三个 Org Agent 依次可被触发,task.status 按状态机正确流转"}, |
| 26 | {"id": "U-011", "phase": "PH1", "sub_phase": "PH1-S2", "name": "c_work_graph_plan 实现", "classification": "Value Path", "depends_on": ["U-004", "U-010"], "coverage": {"model": ["artifact", "work_graph_run"], "view": [], "controller": ["c_work_graph_plan"], "process": [], "requirement": ["REQ-02"]}, "done": "REQ-02 happy 用例通过"}, | 26 | {"id": "U-011", "phase": "PH1", "sub_phase": "PH1-S2", "name": "c_work_graph_plan 实现", "classification": "Value Path", "depends_on": ["U-004", "U-010"], "coverage": {"model": ["artifact", "work_graph_run"], "view": [], "controller": ["c_work_graph_plan"], "process": [], "requirement": ["REQ-02"]}, "done": "REQ-02 happy 用例通过"}, |
| 27 | {"id": "U-012", "phase": "PH1", "sub_phase": "PH1-S2", "name": "c_work_graph_execute 完整实现", "classification": "Value Path", "depends_on": ["U-006", "U-011"], "coverage": {"model": [], "view": [], "controller": ["c_work_graph_execute"], "process": ["p_work_graph_execution", "p_usage_limit_wait"], "requirement": ["REQ-07", "REQ-14"]}, "done": "REQ-07 全部通过;REQ-14 happy 用例通过"}, | 27 | {"id": "U-012", "phase": "PH1", "sub_phase": "PH1-S2", "name": "c_work_graph_execute 完整实现", "classification": "Value Path", "depends_on": ["U-006", "U-011"], "coverage": {"model": [], "view": [], "controller": ["c_work_graph_execute"], "process": ["p_work_graph_execution", "p_usage_limit_wait"], "requirement": ["REQ-07", "REQ-14"]}, "done": "REQ-07 全部通过;REQ-14 happy 用例通过"}, |
src/task_create.py
0 → 100644
| 1 | +"""c_task_create (03-C §3.1): task queue entry creation. | ||
| 2 | + | ||
| 3 | +U-009 scope: task creation lands `status='queued'`, `assigned_org_node='planner'` | ||
| 4 | +(04-P `p_org_task_lifecycle` start_state, ready for the first `c_org_node_trigger` | ||
| 5 | +call). `c_project_create` is already built in U-002 | ||
| 6 | +(project_scaffold.create_project); this module only adds `c_task_create`. | ||
| 7 | + | ||
| 8 | +REQ-01's happy case ("依次触发 planner/coder/validator 三个 org_node,task.status | ||
| 9 | +按状态机正确流转") is NOT achievable at this Unit's scope -- it needs | ||
| 10 | +c_org_node_trigger's full implementation (U-010), whose own Done criterion is | ||
| 11 | +almost this exact sentence. development-plan.md's U-009 line claims all three | ||
| 12 | +REQ-01 cases pass here; that's not accurate and is corrected in this session's | ||
| 13 | +notes rather than silently left as-is -- only boundary and failure are real at | ||
| 14 | +U-009, happy completes cumulatively once U-010 lands. | ||
| 15 | +""" | ||
| 16 | + | ||
| 17 | +import json | ||
| 18 | +import uuid | ||
| 19 | + | ||
| 20 | + | ||
| 21 | +class InvalidParamError(Exception): | ||
| 22 | + """INVALID_PARAM -- 03-C §3.1: empty goal_text, non-existent project_id, | ||
| 23 | + or a task_type other than 'goal' (bug_fix tasks are created by the Org | ||
| 24 | + Loop route, not this public-facing endpoint).""" | ||
| 25 | + | ||
| 26 | + | ||
| 27 | +def create_task(conn, project_id, goal_text, task_type="goal", created_via="api"): | ||
| 28 | + if not goal_text: | ||
| 29 | + raise InvalidParamError("goal_text is required") | ||
| 30 | + if task_type != "goal": | ||
| 31 | + raise InvalidParamError("task_type must be 'goal' for c_task_create") | ||
| 32 | + if conn.execute("SELECT 1 FROM project WHERE project_id = ?", (project_id,)).fetchone() is None: | ||
| 33 | + raise InvalidParamError("project_id does not exist: " + project_id) | ||
| 34 | + | ||
| 35 | + task_id = str(uuid.uuid4()) | ||
| 36 | + try: | ||
| 37 | + conn.execute( | ||
| 38 | + "INSERT INTO task " | ||
| 39 | + "(task_id, project_id, goal_text, task_type, created_via, status, loop_round, assigned_org_node) " | ||
| 40 | + "VALUES (?, ?, ?, ?, ?, 'queued', 0, 'planner')", | ||
| 41 | + (task_id, project_id, goal_text, task_type, created_via), | ||
| 42 | + ) | ||
| 43 | + conn.execute( | ||
| 44 | + "INSERT INTO execution_event (event_id, project_id, task_id, event_type, payload) " | ||
| 45 | + "VALUES (?, ?, ?, 'task_status_changed', ?)", | ||
| 46 | + (str(uuid.uuid4()), project_id, task_id, json.dumps({"from_status": None, "to_status": "queued"})), | ||
| 47 | + ) | ||
| 48 | + except Exception: | ||
| 49 | + conn.rollback() | ||
| 50 | + raise | ||
| 51 | + conn.commit() # 03-C §3.1 transaction: task insert + execution_event, same transaction | ||
| 52 | + | ||
| 53 | + return {"task_id": task_id, "status": "queued"} |
src/test_task_create.py
0 → 100644
| 1 | +"""U-009 tests: c_task_create. REQ-01's boundary and failure cases are fully | ||
| 2 | +covered here; the happy case (actually triggering planner/coder/validator) | ||
| 3 | +is not achievable until U-010 -- see task_create.py's module docstring. | ||
| 4 | + | ||
| 5 | +Run: python3 -m unittest src.test_task_create -v | ||
| 6 | +""" | ||
| 7 | + | ||
| 8 | +import sqlite3 | ||
| 9 | +import unittest | ||
| 10 | + | ||
| 11 | +from src import project_scaffold, schema, task_create | ||
| 12 | + | ||
| 13 | + | ||
| 14 | +class TaskCreateTestBase(unittest.TestCase): | ||
| 15 | + def setUp(self): | ||
| 16 | + self.conn = schema.connect(":memory:") | ||
| 17 | + schema.create_schema(self.conn) | ||
| 18 | + self.project_id = self._make_project() | ||
| 19 | + | ||
| 20 | + def tearDown(self): | ||
| 21 | + self.conn.close() | ||
| 22 | + | ||
| 23 | + def _make_project(self, path="/tmp/graphlemon-u009-project"): | ||
| 24 | + self.conn.execute( | ||
| 25 | + "INSERT INTO project (project_id, name, target_folder_path, has_git_repo, status) " | ||
| 26 | + "VALUES ('proj-1', 'P', ?, 1, 'active')", | ||
| 27 | + (path,), | ||
| 28 | + ) | ||
| 29 | + self.conn.commit() | ||
| 30 | + return "proj-1" | ||
| 31 | + | ||
| 32 | + | ||
| 33 | +class CreateTaskTest(TaskCreateTestBase): | ||
| 34 | + def test_task_lands_queued_with_expected_fields(self): | ||
| 35 | + result = task_create.create_task(self.conn, self.project_id, "实现贪吃蛇游戏") | ||
| 36 | + self.assertEqual(result["status"], "queued") | ||
| 37 | + | ||
| 38 | + row = self.conn.execute( | ||
| 39 | + "SELECT project_id, goal_text, task_type, created_via, status, loop_round, assigned_org_node " | ||
| 40 | + "FROM task WHERE task_id = ?", | ||
| 41 | + (result["task_id"],), | ||
| 42 | + ).fetchone() | ||
| 43 | + self.assertEqual(row, (self.project_id, "实现贪吃蛇游戏", "goal", "api", "queued", 0, "planner")) | ||
| 44 | + | ||
| 45 | + def test_writes_task_status_changed_event_in_same_transaction(self): | ||
| 46 | + result = task_create.create_task(self.conn, self.project_id, "goal text") | ||
| 47 | + event = self.conn.execute( | ||
| 48 | + "SELECT project_id, task_id, event_type FROM execution_event WHERE task_id = ?", | ||
| 49 | + (result["task_id"],), | ||
| 50 | + ).fetchone() | ||
| 51 | + self.assertEqual(event, (self.project_id, result["task_id"], "task_status_changed")) | ||
| 52 | + | ||
| 53 | + def test_created_via_human_console_honored(self): | ||
| 54 | + result = task_create.create_task(self.conn, self.project_id, "goal text", created_via="human_console") | ||
| 55 | + created_via = self.conn.execute( | ||
| 56 | + "SELECT created_via FROM task WHERE task_id = ?", (result["task_id"],) | ||
| 57 | + ).fetchone()[0] | ||
| 58 | + self.assertEqual(created_via, "human_console") | ||
| 59 | + | ||
| 60 | + def test_each_call_creates_a_new_task_not_idempotent(self): | ||
| 61 | + first = task_create.create_task(self.conn, self.project_id, "goal text") | ||
| 62 | + second = task_create.create_task(self.conn, self.project_id, "goal text") | ||
| 63 | + self.assertNotEqual(first["task_id"], second["task_id"]) | ||
| 64 | + count = self.conn.execute("SELECT COUNT(*) FROM task").fetchone()[0] | ||
| 65 | + self.assertEqual(count, 2) | ||
| 66 | + | ||
| 67 | + | ||
| 68 | +class InvalidParamTest(TaskCreateTestBase): | ||
| 69 | + def test_empty_goal_text_rejected(self): | ||
| 70 | + with self.assertRaises(task_create.InvalidParamError): | ||
| 71 | + task_create.create_task(self.conn, self.project_id, "") | ||
| 72 | + self.assertEqual(self.conn.execute("SELECT COUNT(*) FROM task").fetchone()[0], 0) | ||
| 73 | + | ||
| 74 | + def test_nonexistent_project_id_rejected(self): | ||
| 75 | + with self.assertRaises(task_create.InvalidParamError): | ||
| 76 | + task_create.create_task(self.conn, "no-such-project", "goal text") | ||
| 77 | + self.assertEqual(self.conn.execute("SELECT COUNT(*) FROM task").fetchone()[0], 0) | ||
| 78 | + | ||
| 79 | + def test_non_goal_task_type_rejected(self): | ||
| 80 | + with self.assertRaises(task_create.InvalidParamError): | ||
| 81 | + task_create.create_task(self.conn, self.project_id, "goal text", task_type="bug_fix") | ||
| 82 | + self.assertEqual(self.conn.execute("SELECT COUNT(*) FROM task").fetchone()[0], 0) | ||
| 83 | + | ||
| 84 | + | ||
| 85 | +class BoundaryConcurrencyLimitTest(TaskCreateTestBase): | ||
| 86 | + """REQ-01 boundary: max_concurrent_goals=1 doesn't block task *creation* | ||
| 87 | + -- it only matters for whoever later decides to trigger org nodes | ||
| 88 | + (03-C §3.1 side_effects: "任务落 queued 但不触发调度").""" | ||
| 89 | + | ||
| 90 | + def test_second_task_still_created_as_queued_under_concurrency_limit_of_one(self): | ||
| 91 | + self.conn.execute( | ||
| 92 | + "INSERT INTO org_concurrency_config (config_id, max_concurrent_goals, updated_by) " | ||
| 93 | + "VALUES ('default', 1, 'test')" | ||
| 94 | + ) | ||
| 95 | + self.conn.commit() | ||
| 96 | + | ||
| 97 | + first = task_create.create_task(self.conn, self.project_id, "goal one") | ||
| 98 | + second = task_create.create_task(self.conn, self.project_id, "goal two") | ||
| 99 | + | ||
| 100 | + statuses = { | ||
| 101 | + row[0] | ||
| 102 | + for row in self.conn.execute( | ||
| 103 | + "SELECT status FROM task WHERE task_id IN (?, ?)", (first["task_id"], second["task_id"]) | ||
| 104 | + ).fetchall() | ||
| 105 | + } | ||
| 106 | + self.assertEqual(statuses, {"queued"}) | ||
| 107 | + | ||
| 108 | + | ||
| 109 | +class TransactionAtomicityTest(TaskCreateTestBase): | ||
| 110 | + def test_failure_between_the_two_inserts_rolls_back_the_task_row(self): | ||
| 111 | + self.conn.execute("DROP TABLE execution_event") | ||
| 112 | + with self.assertRaises(sqlite3.OperationalError): | ||
| 113 | + task_create.create_task(self.conn, self.project_id, "goal text") | ||
| 114 | + self.assertEqual(self.conn.execute("SELECT COUNT(*) FROM task").fetchone()[0], 0) | ||
| 115 | + | ||
| 116 | + | ||
| 117 | +if __name__ == "__main__": | ||
| 118 | + unittest.main() |