From 4cd87c41d1f7e47b66723891d884613dceeb81d8 Mon Sep 17 00:00:00 2001 From: bboonnee Date: Thu, 13 Aug 2026 18:44:50 +0800 Subject: [PATCH] feat: task.execution_priority column + v_task_board display/edit column --- docs/todo/append-run-node-id-collision-across-org-nodes-requirements.md | 89 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ frontend/app.js | 24 ++++++++++++++++++++++-- src/backend_server.py | 24 +++++++++++++++++++----- src/schema.py | 45 +++++++++++++++++++++++++++++++++++++++------ src/task_query.py | 28 +++++++++++++++++++++++++--- src/test_backend_server.py | 15 +++++++++++++++ src/test_schema.py | 52 ++++++++++++++++++++++++++++++++++++++++++++++++++++ src/test_task_query.py | 31 +++++++++++++++++++++++++++++++ 8 files changed, 292 insertions(+), 16 deletions(-) create mode 100644 docs/todo/append-run-node-id-collision-across-org-nodes-requirements.md diff --git a/docs/todo/append-run-node-id-collision-across-org-nodes-requirements.md b/docs/todo/append-run-node-id-collision-across-org-nodes-requirements.md new file mode 100644 index 0000000..53e4571 --- /dev/null +++ b/docs/todo/append-run-node-id-collision-across-org-nodes-requirements.md @@ -0,0 +1,89 @@ +# 追加规划的 node_id 跨 org_node 撞名——需求文档 + +**日期**: 2026-08-13 +**性质**: 需求文档(BRD/PRD 层面),不含详细技术设计。喂给 graphlemon 自己实现的 Goal,本次会话 +不直接改代码。 +**状态**: 草案,待确认后作为 Goal 提交。 + +## 1. 背景 + +`work_graph_plan.py::plan_work_graph(..., append_to_run_id=...)`(Phase 2c 的合并 run 架构, +Coder/Validator 各自往同一个共享 run 里追加自己规划的节点)在检测到新规划的 `node_id` 跟已有节点 +撞名时会直接拒绝: + +```python +collision = existing_ids_set & set(new_node_ids) +if collision: + raise PlanningFailedError( + "append_to_run_id={} node_id collision with existing nodes: {}".format(append_to_run_id, collision) + ) +``` + +调用方(`plan_work_graph`)会把已有节点 id 列表写进 prompt,明确要求"不要重复用这些 id",但今天 +(2026-08-13)真实任务 `911861fe` 撞上了这个拒绝:Validator 自己规划节点时选了 `n_test_script` +这个 id,而这个 id 在同一个 run 里已经被 Coder 用过了。 + +根因不是"LLM 没读提示词"这么简单——`src/work_graph_plan.py` 里 Coder 和 Validator 的建议节点 +目录(`_CODER_NODE_CATALOG`/`_VALIDATOR_NODE_CATALOG`)**都**给"写一个可机械验证的测试脚本"这类 +节点建议了同一个 `node_type: "test_script"`。两个不同 org_node 在描述"概念上相似的东西"时,天然 +会往同一个规范化命名(`n_test_script`)收敛——即使 prompt 里明确列出了"这些 id 已经被用了", +自然语言指令本身也无法保证绝对不再犯。这不是这一次运气不好,是这个架构下的一个系统性碰撞概率 +问题:**只要两个 org_node 的建议目录用相同/相似的 `node_type` 描述概念上相似的节点,撞名概率就 +不是小概率事件**。 + +`PlanningFailedError` 不在 `scheduler._ROUTABLE_EXCEPTIONS` 白名单里,所以这次撞名直接变成了 +`blocked_pending_human`/`execution_error`,需要人工介入——跟 +`docs/todo/boss-agent-autonomous-blocked-task-arbiter-requirements.md` FR-7 是同一类问题(系统 +自己能分类的原因,不该都堆进笼统的兜底桶),但这条更进一步:**这类碰撞,理想情况下根本不该走到 +需要人工介入这一步**,应该有更根本的预防或自动恢复机制。 + +## 2. 目标 + +让追加规划(`append_to_run_id`)在遇到 `node_id` 跨 org_node 撞名时,不需要人工介入就能正确处理 +(预防撞名,或者撞了之后自动恢复),而不是直接硬失败变成 `blocked_pending_human`。 + +## 3. 非目标 + +1. **不裁定具体用哪种方案**(见 §7)——命名规范前缀(比如按 org_node 加前缀)、失败后带反馈重 + 规划(跟 planner_reviewer 的重试模式类似)、自动重命名并改写内部引用,三种代价和可靠性都不同, + 需要设计阶段权衡。 +2. **不改变 `_CODER_NODE_CATALOG`/`_VALIDATOR_NODE_CATALOG` 这两份目录已有的 `node_type` 分类 + 本身**——"Coder 和 Validator 都需要一个 test_script 类型的节点"这个业务事实是对的、不用改,需要 + 改的是"两者都可能选出同一个具体 `node_id` 字符串"这件事。 + +## 4. 功能需求 + +| # | 需求描述 | 优先级 | +|---|---|---| +| FR-1 | `append_to_run_id` 模式下发生 `node_id` 撞名时,不能直接终结为需要人工介入的 `blocked_pending_human`——需要预防(撞名概率显著降低)或自动恢复(撞名后不需要人工也能继续)之一,具体方案见 §7。 | 必须 | +| FR-2 | 如果最终方案里撞名之后仍然可能真正无法自动恢复(比如重试次数用尽),这种情况下打的原因也要专属、可区分,不能落入笼统的 `execution_error`(跟 Boss Agent 文档 FR-7 同一方向)。 | 必须 | + +## 5. 验收标准 + +构造一个真实场景:两个不同 org_node(比如 Coder 和 Validator)在同一个共享 run 里,各自的 +建议目录都指向同一个 `node_type`(如 `test_script`),触发一次真实的 `claude -p` 自主规划——验证 +最终不会因为 `node_id` 撞名而进入需要人工介入的状态。 + +## 6. 风险 + +如果选择"自动重命名"类方案,需要非常小心地同步改写这个新节点自己内部的 `depends_on`/ +`on_failure` 引用(如果它引用了自己),改写不完整会引入比"直接拒绝"更隐蔽的错误(图结构损坏, +但没有任何报错,节点执行时才会诡异地找不到依赖)——这条风险比"要不要做"更需要在设计阶段认真 +对待。 + +## 7. 开放问题 + +- [ ] **具体用哪种方案**: + - (a) 命名规范前缀——要求所有自规划节点的 `node_id` 强制带 org_node 前缀(如 + `validator_test_script`),从命名规则上让跨 org_node 撞名在正常情况下不可能发生。最简单、 + 风险最低,但需要确认这不会破坏已有的 `_normalize_graph_manifest`/`_extract_graph_manifest` + 对 `node_id` 格式的既有假设。 + - (b) 撞名后带反馈重规划——检测到撞名时不直接失败,而是把撞名信息作为反馈重新调用一次规划 + (类似 `_review_planner_output_with_retries` 的重试模式),要求换一个不同的 id。需要设计 + 重试上限。 + - (c) 自动重命名——检测到撞名时自动加后缀改名并同步改写这次新规划里所有对该 id 的内部引用。 + 见 §6 的风险,代价最高。 +- [ ] 跟 `docs/todo/boss-agent-autonomous-blocked-task-arbiter-requirements.md`/ + `docs/todo/agent-call-liveness-not-fixed-duration-timeout-requirements.md` 的实现顺序: + 三份文档都在处理"什么情况不该变成需要人工介入的 blocked_pending_human",建议一起规划实现 + 顺序,不要三个 Goal 各自独立设计出三套不一致的"是否需要人工介入"判断逻辑。 diff --git a/frontend/app.js b/frontend/app.js index a6a3f81..40bb343 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -939,7 +939,7 @@ function renderTaskBoard() { - +
task_idgoal_textstatusassigned_org_nodeloop_roundupdated_at
task_idgoal_textstatusassigned_org_nodeloop_roundupdated_at执行顺序
@@ -984,7 +984,7 @@ function mountTaskBoard(queryParams) { const resp = await fetch("/api/tasks?" + params.toString()); const tasks = await resp.json(); if (tasks.length === 0) { - rowsEl.innerHTML = '暂无任务'; + rowsEl.innerHTML = '暂无任务'; return; } rowsEl.innerHTML = tasks.map((t) => ( @@ -999,6 +999,15 @@ function mountTaskBoard(queryParams) { + "" + t.assigned_org_node + "" + "" + t.loop_round + "" + "" + escapeHtml(t.updated_at) + "" + // 2026-08-13 (real incident retrospective): manually-assignable + // execution-order hint -- serial steps are whole numbers (1, 2, 3), + // parallel siblings of one step are decimals sharing the same + // integer part (2.1, 2.2). Purely advisory (see task.execution_priority's + // own schema comment) -- editing this never blocks/reorders real + // execution, it's a planning aid rendered here for visibility. + + '' + "" )).join(""); for (const row of rowsEl.querySelectorAll("tr[data-task-id]")) { @@ -1006,6 +1015,17 @@ function mountTaskBoard(queryParams) { window.location.hash = "#/tasks/" + row.dataset.taskId; }); } + for (const input of rowsEl.querySelectorAll(".tb-priority-input")) { + input.addEventListener("change", async () => { + const raw = input.value.trim(); + const execution_priority = raw === "" ? null : Number(raw); + await fetch("/api/tasks/" + encodeURIComponent(input.dataset.taskId), { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ execution_priority }), + }); + }); + } } keywordEl.addEventListener("input", refresh); diff --git a/src/backend_server.py b/src/backend_server.py index 757225c..1663a7c 100644 --- a/src/backend_server.py +++ b/src/backend_server.py @@ -459,16 +459,29 @@ class _Handler(BaseHTTPRequestHandler): self._send_json(400, error) return goal_text = payload.get("goal_text") - if not goal_text: - self._send_json(400, {"error": "INVALID_PARAM", "detail": "goal_text is required"}) + # 2026-08-13: execution_priority is an independent, optional field on + # this same PUT -- unlike goal_text it's never gated to status= + # 'queued' (see update_task_execution_priority's own docstring), so + # it's handled as its own branch rather than folded into the + # goal_text-only contract this endpoint originally had. + has_priority = "execution_priority" in payload + if not goal_text and not has_priority: + self._send_json(400, {"error": "INVALID_PARAM", "detail": "goal_text or execution_priority is required"}) return try: with self.server.conn_lock: - result = task_query.update_task_goal_text(self.server.conn, task_id, goal_text) + result = None + if goal_text: + result = task_query.update_task_goal_text(self.server.conn, task_id, goal_text) + if has_priority: + result = task_query.update_task_execution_priority( + self.server.conn, task_id, payload.get("execution_priority") + ) except task_query.TaskNotFoundError: self._send_json(404, {"error": "NOT_FOUND"}) - except task_query.TaskNotEditableError as e: - self._send_json(409, {"error": "TASK_NOT_EDITABLE", "detail": str(e)}) + except (task_query.TaskNotEditableError, task_query.InvalidParamError) as e: + status = 409 if isinstance(e, task_query.TaskNotEditableError) else 400 + self._send_json(status, {"error": "TASK_NOT_EDITABLE" if status == 409 else "INVALID_PARAM", "detail": str(e)}) else: self._send_json(200, result) @@ -1043,6 +1056,7 @@ def _ensure_schema(conn): schema.migrate_relax_agent_profile_migrated_from_profile(conn) schema.migrate_drop_work_graph_run_org_node(conn) schema.migrate_add_concurrency_wait_event_type(conn) + schema.migrate_add_task_execution_priority(conn) class _Server(ThreadingHTTPServer): diff --git a/src/schema.py b/src/schema.py index 44a1250..aa1669d 100644 --- a/src/schema.py +++ b/src/schema.py @@ -116,6 +116,15 @@ CREATE TABLE task ( blocked_reason TEXT, -- H (2026-08-06): see skill.owner_org_node's comment above -- same relaxation. assigned_org_node TEXT NOT NULL CHECK (assigned_org_node != ''), + -- 2026-08-13 (real incident retrospective): a manually-assignable + -- execution-order hint across a project's tasks -- NULLable (most + -- tasks never get one), REAL not INTEGER so parallel siblings of one + -- serial step can be expressed as decimals (e.g. step 2 splits into + -- 2.1/2.2, both meant to run concurrently, before step 3). Purely + -- advisory: nothing in the scheduler/dispatch loop reads this column + -- to gate execution -- it's a human/Boss-Agent-facing planning aid + -- shown on v_task_board, not an enforcement mechanism. + execution_priority REAL, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ); @@ -494,11 +503,12 @@ def migrate_relax_org_node_constraints(conn): can't be toggled mid-transaction), rebuild, PRAGMA foreign_key_check to confirm nothing went dangling, PRAGMA foreign_keys=ON again. - Depends on migrate_add_work_graph_fork_columns() having already run - (called here unconditionally, it's idempotent) -- work_graph_run's - rebuild needs to know about D's parent_run_id/fork_of_node_exec_id - columns whether or not this specific database has seen that migration - yet. + Depends on migrate_add_work_graph_fork_columns() and (2026-08-13) + migrate_add_task_execution_priority() having already run (both called + here unconditionally, both idempotent) -- work_graph_run's rebuild + needs to know about D's parent_run_id/fork_of_node_exec_id columns, and + task's rebuild needs to know about execution_priority, whether or not + this specific database has seen either migration yet. Idempotent: each table's rebuild is skipped if its stored CREATE TABLE text no longer contains the old enum CHECK, so a fresh @@ -506,6 +516,7 @@ def migrate_relax_org_node_constraints(conn): same as migrate_expand_artifact_types's own convention. """ migrate_add_work_graph_fork_columns(conn) + migrate_add_task_execution_priority(conn) conn.commit() conn.execute("PRAGMA foreign_keys = OFF") @@ -548,6 +559,7 @@ def migrate_relax_org_node_constraints(conn): loop_round INTEGER NOT NULL DEFAULT 0, blocked_reason TEXT, assigned_org_node TEXT NOT NULL CHECK (assigned_org_node != ''), + execution_priority REAL, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ); @@ -736,10 +748,17 @@ def migrate_add_reviewing_planner_output_status(conn): migrate_relax_org_node_constraints() precedent (that function's own docstring explains the same PRAGMA foreign_keys=OFF/rebuild/ foreign_key_check/ON dance in more depth) instead of the leaf-table - executescript() shortcut this originally (wrongly) copied.""" + executescript() shortcut this originally (wrongly) copied. + + Depends on (2026-08-13) migrate_add_task_execution_priority() having + already run (called here unconditionally, it's idempotent) -- same + "rebuild needs to know about a later additive column" reasoning as + migrate_relax_org_node_constraints()'s own dependency on + migrate_add_work_graph_fork_columns().""" row = conn.execute("SELECT sql FROM sqlite_master WHERE type='table' AND name='task'").fetchone() if row is None or "'reviewing_planner_output'" in row[0]: return + migrate_add_task_execution_priority(conn) conn.execute("PRAGMA foreign_keys = OFF") try: conn.executescript( @@ -761,6 +780,7 @@ def migrate_add_reviewing_planner_output_status(conn): loop_round INTEGER NOT NULL DEFAULT 0, blocked_reason TEXT, assigned_org_node TEXT NOT NULL CHECK (assigned_org_node != ''), + execution_priority REAL, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ); @@ -860,6 +880,19 @@ def migrate_add_concurrency_wait_event_type(conn): conn.commit() +def migrate_add_task_execution_priority(conn): + """2026-08-13 (real incident retrospective): adds task.execution_priority + on an ALREADY-POPULATED database. Plain `ALTER TABLE ... ADD COLUMN` (no + CHECK constraint involved, no create-copy-drop-rename dance needed) -- + same pattern as migrate_add_work_graph_fork_columns. Idempotent and safe + to call unconditionally, including against a freshly create_schema()'d + database (which already has the column).""" + cols = {row[1] for row in conn.execute("PRAGMA table_info(task)").fetchall()} + if "execution_priority" not in cols: + conn.execute("ALTER TABLE task ADD COLUMN execution_priority REAL") + conn.commit() + + def migrate_drop_work_graph_run_org_node(conn): """Org Graph unification Phase 2c §2.1/§2.2 (2026-08-12, user-confirmed 2026-08-08): drops work_graph_run.org_node on an ALREADY-POPULATED diff --git a/src/task_query.py b/src/task_query.py index 04a18c2..690c999 100644 --- a/src/task_query.py +++ b/src/task_query.py @@ -30,7 +30,7 @@ def list_tasks(conn, project_id, status=None, org_node=None, keyword=None, sort= reaches the SQL text itself, only a pre-approved column name.""" query = ( "SELECT task_id, goal_text, task_type, status, assigned_org_node, loop_round, " - "created_at, updated_at FROM task WHERE project_id = ?" + "created_at, updated_at, execution_priority FROM task WHERE project_id = ?" ) params = [project_id] if status: @@ -54,6 +54,7 @@ def list_tasks(conn, project_id, status=None, org_node=None, keyword=None, sort= { "task_id": r[0], "goal_text": r[1], "task_type": r[2], "status": r[3], "assigned_org_node": r[4], "loop_round": r[5], "created_at": r[6], "updated_at": r[7], + "execution_priority": r[8], } for r in rows ] @@ -88,6 +89,26 @@ def update_task_goal_text(conn, task_id, goal_text): return get_task(conn, task_id) +def update_task_execution_priority(conn, task_id, execution_priority): + """2026-08-13 (real incident retrospective): unlike goal_text, this is + NOT gated to status='queued' -- execution_priority is a scheduling/ + display hint (v_task_board's new column), not a planning input that + invalidates work already done, so it should stay settable regardless of + a task's current status (including a task that's already blocked or + mid-flight). None clears it back to unset.""" + row = conn.execute("SELECT status FROM task WHERE task_id = ?", (task_id,)).fetchone() + if row is None: + raise TaskNotFoundError(task_id) + if execution_priority is not None and not isinstance(execution_priority, (int, float)): + raise InvalidParamError("execution_priority must be a number or null, got {!r}".format(execution_priority)) + conn.execute( + "UPDATE task SET execution_priority = ?, updated_at = CURRENT_TIMESTAMP WHERE task_id = ?", + (execution_priority, task_id), + ) + conn.commit() + return get_task(conn, task_id) + + def delete_task(conn, task_id): row = conn.execute("SELECT status FROM task WHERE task_id = ?", (task_id,)).fetchone() if row is None: @@ -115,7 +136,8 @@ def delete_task(conn, task_id): def get_task(conn, task_id): row = conn.execute( "SELECT task_id, project_id, goal_text, task_type, origin_ref, created_via, status, route, " - "loop_round, blocked_reason, assigned_org_node, created_at, updated_at FROM task WHERE task_id = ?", + "loop_round, blocked_reason, assigned_org_node, created_at, updated_at, execution_priority " + "FROM task WHERE task_id = ?", (task_id,), ).fetchone() if row is None: @@ -125,7 +147,7 @@ def get_task(conn, task_id): "task_id": row[0], "project_id": row[1], "goal_text": row[2], "task_type": row[3], "origin_ref": row[4], "created_via": row[5], "status": row[6], "route": row[7], "loop_round": row[8], "blocked_reason": row[9], "assigned_org_node": row[10], - "created_at": row[11], "updated_at": row[12], + "created_at": row[11], "updated_at": row[12], "execution_priority": row[13], } artifacts = conn.execute( diff --git a/src/test_backend_server.py b/src/test_backend_server.py index b17baee..ed365a6 100644 --- a/src/test_backend_server.py +++ b/src/test_backend_server.py @@ -507,6 +507,21 @@ class TaskCrudRouteTest(unittest.TestCase): self.assertEqual(status, 409) self.assertEqual(body["error"], "TASK_NOT_EDITABLE") + def test_put_execution_priority_works_regardless_of_status(self): + # 2026-08-13 (real incident retrospective): unlike goal_text, this + # must succeed even on a non-queued task. + self.server.conn.execute("UPDATE task SET status = 'coding' WHERE task_id = 'task-1'") + self.server.conn.commit() + status, body = self._put("/api/tasks/task-1", {"execution_priority": 2.1}) + self.assertEqual(status, 200) + self.assertEqual(body["execution_priority"], 2.1) + + def test_get_list_includes_execution_priority(self): + self._put("/api/tasks/task-1", {"execution_priority": 3}) + status, body = self._get("/api/tasks?project_id=proj-1") + self.assertEqual(status, 200) + self.assertEqual(body[0]["execution_priority"], 3) + def test_delete_removes_queued_task(self): status, body = self._delete("/api/tasks/task-1") self.assertEqual(status, 200) diff --git a/src/test_schema.py b/src/test_schema.py index e46b28c..3ac5b83 100644 --- a/src/test_schema.py +++ b/src/test_schema.py @@ -568,6 +568,58 @@ class MigrateAddWorkGraphForkColumnsTest(unittest.TestCase): fresh.close() +class MigrateAddTaskExecutionPriorityTest(unittest.TestCase): + """2026-08-13 (real incident retrospective): real upgrade path for an + already-populated database whose task table predates + execution_priority.""" + + def setUp(self): + self.conn = schema.connect(":memory:") + old_sql = re.sub( + r",\n( --[^\n]*\n)* execution_priority REAL,\n", + ",\n", + schema.SCHEMA_SQL, + ) + self.assertNotIn("execution_priority", old_sql) # sanity: the substitution actually matched + self.conn.executescript(old_sql) + self.conn.commit() + self.conn.execute( + "INSERT INTO project (project_id, name, target_folder_path, has_git_repo, status) " + "VALUES ('proj-1', 'P', '/tmp/proj-1', 1, 'active')" + ) + self.conn.execute( + "INSERT INTO task (task_id, project_id, goal_text, task_type, status, assigned_org_node) " + "VALUES ('task-1', 'proj-1', 'g', 'goal', 'queued', 'planner')" + ) + self.conn.commit() + + def tearDown(self): + self.conn.close() + + def test_migration_preserves_existing_rows_and_adds_nullable_column(self): + schema.migrate_add_task_execution_priority(self.conn) + row = self.conn.execute( + "SELECT status, execution_priority FROM task WHERE task_id = 'task-1'" + ).fetchone() + self.assertEqual(row, ("queued", None)) + + def test_idempotent_on_a_database_already_migrated(self): + schema.migrate_add_task_execution_priority(self.conn) + schema.migrate_add_task_execution_priority(self.conn) # must not raise + count = self.conn.execute("SELECT COUNT(*) FROM task").fetchone()[0] + self.assertEqual(count, 1) + + def test_noop_on_a_freshly_created_database(self): + fresh = schema.connect(":memory:") + try: + schema.create_schema(fresh) + schema.migrate_add_task_execution_priority(fresh) # must not raise + cols = {row[1] for row in fresh.execute("PRAGMA table_info(task)").fetchall()} + self.assertIn("execution_priority", cols) + finally: + fresh.close() + + _OLD_WORK_GRAPH_RUN_BLOCK = """-- 1.3 work_graph_run CREATE TABLE work_graph_run ( run_id TEXT PRIMARY KEY, diff --git a/src/test_task_query.py b/src/test_task_query.py index f12ceb6..dcdd2ed 100644 --- a/src/test_task_query.py +++ b/src/test_task_query.py @@ -340,6 +340,37 @@ class UpdateTaskGoalTextTest(TaskQueryTestBase): task_query.update_task_goal_text(self.conn, "task-1", "new text") +class UpdateTaskExecutionPriorityTest(TaskQueryTestBase): + """2026-08-13 (real incident retrospective): unlike goal_text, this is + NOT gated to status='queued' -- it's a scheduling/display hint, not a + planning input.""" + + def test_in_progress_task_can_still_be_edited(self): + self._insert_task("task-1", status="coding") + result = task_query.update_task_execution_priority(self.conn, "task-1", 2.1) + self.assertEqual(result["execution_priority"], 2.1) + + def test_serial_integer_value(self): + self._insert_task("task-1", status="queued") + result = task_query.update_task_execution_priority(self.conn, "task-1", 1) + self.assertEqual(result["execution_priority"], 1) + + def test_none_clears_it(self): + self._insert_task("task-1", status="queued") + task_query.update_task_execution_priority(self.conn, "task-1", 3) + result = task_query.update_task_execution_priority(self.conn, "task-1", None) + self.assertIsNone(result["execution_priority"]) + + def test_nonexistent_task_raises_not_found(self): + with self.assertRaises(task_query.TaskNotFoundError): + task_query.update_task_execution_priority(self.conn, "no-such-task", 1) + + def test_non_numeric_value_raises_invalid_param(self): + self._insert_task("task-1", status="queued") + with self.assertRaises(task_query.InvalidParamError): + task_query.update_task_execution_priority(self.conn, "task-1", "not a number") + + class DeleteTaskTest(TaskQueryTestBase): def test_queued_task_can_be_deleted(self): self._insert_task("task-1", status="queued") -- libgit2 0.21.4