"
+ // 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")