Commit a8cab45d739440db5ed6dd5aacecdb679e9dc33e

Authored by bboonnee
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>
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()