From a8cab45d739440db5ed6dd5aacecdb679e9dc33e Mon Sep 17 00:00:00 2001 From: bboonnee Date: Fri, 31 Jul 2026 11:37:36 +0800 Subject: [PATCH] U-009: c_task_create + task lifecycle landing at queued --- docs/development-plan.md | 6 +++--- docs/sdd/unit_matrix.json | 2 +- src/task_create.py | 53 +++++++++++++++++++++++++++++++++++++++++++++++++++++ src/test_task_create.py | 118 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 175 insertions(+), 4 deletions(-) create mode 100644 src/task_create.py create mode 100644 src/test_task_create.py diff --git a/docs/development-plan.md b/docs/development-plan.md index 57c313b..fdc28da 100644 --- a/docs/development-plan.md +++ b/docs/development-plan.md @@ -103,10 +103,10 @@ ### PH1-S1 Org Graph 层单点触发 -- **U-009 [Value Path]** `c_project_create` + `c_task_create` 实现,`task` 状态机(`p_org_task_lifecycle`)落地到 `queued` 态 +- **U-009 [Value Path]** `c_task_create` 实现,`task` 状态机(`p_org_task_lifecycle`)落地到 `queued` 态(`c_project_create` 已经在 U-002 完整实现,这里不重做) - 依赖:U-001, U-002 - - Done:REQ-01 happy/boundary/failure 三个用例全部通过 - - 覆盖:01-M `task`;03-C `c_task_create`;04-P `p_org_task_lifecycle`;REQ-01 + - 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 单独打勾 + - 覆盖:01-M `task`;03-C `c_task_create`;04-P `p_org_task_lifecycle`;REQ-01(boundary/failure) - **U-010 [Value Path]** `c_org_node_trigger` 完整实现(含 `TASK_NOT_READY`/`CONCURRENCY_LIMIT_REACHED`/`USAGE_LIMIT_REACHED` 错误处理) - 依赖:U-005, U-009 diff --git a/docs/sdd/unit_matrix.json b/docs/sdd/unit_matrix.json index 45742d4..e7d7a95 100644 --- a/docs/sdd/unit_matrix.json +++ b/docs/sdd/unit_matrix.json @@ -21,7 +21,7 @@ {"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 上原生对话框返回真实路径"}, {"id": "U-008", "phase": "PH0", "sub_phase": "PH0-S3", "name": "前端骨架", "classification": "Enabler", "depends_on": ["U-007"], "coverage": {"model": [], "view": [], "controller": [], "process": [], "requirement": []}, "done": "本地 web 应用路由跳转正常"}, - {"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 全部通过"}, + {"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 订正)"}, {"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 按状态机正确流转"}, {"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 用例通过"}, {"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 用例通过"}, diff --git a/src/task_create.py b/src/task_create.py new file mode 100644 index 0000000..9ec6999 --- /dev/null +++ b/src/task_create.py @@ -0,0 +1,53 @@ +"""c_task_create (03-C §3.1): task queue entry creation. + +U-009 scope: task creation lands `status='queued'`, `assigned_org_node='planner'` +(04-P `p_org_task_lifecycle` start_state, ready for the first `c_org_node_trigger` +call). `c_project_create` is already built in U-002 +(project_scaffold.create_project); this module only adds `c_task_create`. + +REQ-01's happy case ("依次触发 planner/coder/validator 三个 org_node,task.status +按状态机正确流转") is NOT achievable at this Unit's scope -- it needs +c_org_node_trigger's full implementation (U-010), whose own Done criterion is +almost this exact sentence. development-plan.md's U-009 line claims all three +REQ-01 cases pass here; that's not accurate and is corrected in this session's +notes rather than silently left as-is -- only boundary and failure are real at +U-009, happy completes cumulatively once U-010 lands. +""" + +import json +import uuid + + +class InvalidParamError(Exception): + """INVALID_PARAM -- 03-C §3.1: empty goal_text, non-existent project_id, + or a task_type other than 'goal' (bug_fix tasks are created by the Org + Loop route, not this public-facing endpoint).""" + + +def create_task(conn, project_id, goal_text, task_type="goal", created_via="api"): + if not goal_text: + raise InvalidParamError("goal_text is required") + if task_type != "goal": + raise InvalidParamError("task_type must be 'goal' for c_task_create") + if conn.execute("SELECT 1 FROM project WHERE project_id = ?", (project_id,)).fetchone() is None: + raise InvalidParamError("project_id does not exist: " + project_id) + + task_id = str(uuid.uuid4()) + try: + conn.execute( + "INSERT INTO task " + "(task_id, project_id, goal_text, task_type, created_via, status, loop_round, assigned_org_node) " + "VALUES (?, ?, ?, ?, ?, 'queued', 0, 'planner')", + (task_id, project_id, goal_text, task_type, created_via), + ) + conn.execute( + "INSERT INTO execution_event (event_id, project_id, task_id, event_type, payload) " + "VALUES (?, ?, ?, 'task_status_changed', ?)", + (str(uuid.uuid4()), project_id, task_id, json.dumps({"from_status": None, "to_status": "queued"})), + ) + except Exception: + conn.rollback() + raise + conn.commit() # 03-C §3.1 transaction: task insert + execution_event, same transaction + + return {"task_id": task_id, "status": "queued"} diff --git a/src/test_task_create.py b/src/test_task_create.py new file mode 100644 index 0000000..b76731a --- /dev/null +++ b/src/test_task_create.py @@ -0,0 +1,118 @@ +"""U-009 tests: c_task_create. REQ-01's boundary and failure cases are fully +covered here; the happy case (actually triggering planner/coder/validator) +is not achievable until U-010 -- see task_create.py's module docstring. + +Run: python3 -m unittest src.test_task_create -v +""" + +import sqlite3 +import unittest + +from src import project_scaffold, schema, task_create + + +class TaskCreateTestBase(unittest.TestCase): + def setUp(self): + self.conn = schema.connect(":memory:") + schema.create_schema(self.conn) + self.project_id = self._make_project() + + def tearDown(self): + self.conn.close() + + def _make_project(self, path="/tmp/graphlemon-u009-project"): + self.conn.execute( + "INSERT INTO project (project_id, name, target_folder_path, has_git_repo, status) " + "VALUES ('proj-1', 'P', ?, 1, 'active')", + (path,), + ) + self.conn.commit() + return "proj-1" + + +class CreateTaskTest(TaskCreateTestBase): + def test_task_lands_queued_with_expected_fields(self): + result = task_create.create_task(self.conn, self.project_id, "实现贪吃蛇游戏") + self.assertEqual(result["status"], "queued") + + row = self.conn.execute( + "SELECT project_id, goal_text, task_type, created_via, status, loop_round, assigned_org_node " + "FROM task WHERE task_id = ?", + (result["task_id"],), + ).fetchone() + self.assertEqual(row, (self.project_id, "实现贪吃蛇游戏", "goal", "api", "queued", 0, "planner")) + + def test_writes_task_status_changed_event_in_same_transaction(self): + result = task_create.create_task(self.conn, self.project_id, "goal text") + event = self.conn.execute( + "SELECT project_id, task_id, event_type FROM execution_event WHERE task_id = ?", + (result["task_id"],), + ).fetchone() + self.assertEqual(event, (self.project_id, result["task_id"], "task_status_changed")) + + def test_created_via_human_console_honored(self): + result = task_create.create_task(self.conn, self.project_id, "goal text", created_via="human_console") + created_via = self.conn.execute( + "SELECT created_via FROM task WHERE task_id = ?", (result["task_id"],) + ).fetchone()[0] + self.assertEqual(created_via, "human_console") + + def test_each_call_creates_a_new_task_not_idempotent(self): + first = task_create.create_task(self.conn, self.project_id, "goal text") + second = task_create.create_task(self.conn, self.project_id, "goal text") + self.assertNotEqual(first["task_id"], second["task_id"]) + count = self.conn.execute("SELECT COUNT(*) FROM task").fetchone()[0] + self.assertEqual(count, 2) + + +class InvalidParamTest(TaskCreateTestBase): + def test_empty_goal_text_rejected(self): + with self.assertRaises(task_create.InvalidParamError): + task_create.create_task(self.conn, self.project_id, "") + self.assertEqual(self.conn.execute("SELECT COUNT(*) FROM task").fetchone()[0], 0) + + def test_nonexistent_project_id_rejected(self): + with self.assertRaises(task_create.InvalidParamError): + task_create.create_task(self.conn, "no-such-project", "goal text") + self.assertEqual(self.conn.execute("SELECT COUNT(*) FROM task").fetchone()[0], 0) + + def test_non_goal_task_type_rejected(self): + with self.assertRaises(task_create.InvalidParamError): + task_create.create_task(self.conn, self.project_id, "goal text", task_type="bug_fix") + self.assertEqual(self.conn.execute("SELECT COUNT(*) FROM task").fetchone()[0], 0) + + +class BoundaryConcurrencyLimitTest(TaskCreateTestBase): + """REQ-01 boundary: max_concurrent_goals=1 doesn't block task *creation* + -- it only matters for whoever later decides to trigger org nodes + (03-C §3.1 side_effects: "任务落 queued 但不触发调度").""" + + def test_second_task_still_created_as_queued_under_concurrency_limit_of_one(self): + self.conn.execute( + "INSERT INTO org_concurrency_config (config_id, max_concurrent_goals, updated_by) " + "VALUES ('default', 1, 'test')" + ) + self.conn.commit() + + first = task_create.create_task(self.conn, self.project_id, "goal one") + second = task_create.create_task(self.conn, self.project_id, "goal two") + + statuses = { + row[0] + for row in self.conn.execute( + "SELECT status FROM task WHERE task_id IN (?, ?)", (first["task_id"], second["task_id"]) + ).fetchall() + } + self.assertEqual(statuses, {"queued"}) + + +class TransactionAtomicityTest(TaskCreateTestBase): + def test_failure_between_the_two_inserts_rolls_back_the_task_row(self): + self.conn.execute("DROP TABLE execution_event") + with self.assertRaises(sqlite3.OperationalError): + task_create.create_task(self.conn, self.project_id, "goal text") + self.assertEqual(self.conn.execute("SELECT COUNT(*) FROM task").fetchone()[0], 0) + + +if __name__ == "__main__": + unittest.main() -- libgit2 0.21.4