From f25af99952bd61a3f8d02d27538b0a05ce86f51d Mon Sep 17 00:00:00 2001 From: huangruiteng Date: Fri, 16 Oct 2026 12:45:41 +0800 Subject: [PATCH] fix(todos): default blocking work to P0 Signed-off-by: huangruiteng --- docs/project-agent-todo-contract.md | 23 +++++++++++-------- docs/quota-allocation.md | 5 ++-- loopx/cli_commands/quota_request.py | 6 ++++- loopx/cli_commands/todo_registration.py | 2 +- .../control_plane/coordination/todo_create.ts | 1 + .../quota/monitor_poll_commit.ts | 4 +++- .../scheduler/monitor_successor.ts | 4 +++- loopx/control_plane/todos/priority.ts | 8 +++++-- .../test_monitor_successor_validation.py | 13 ++++++++--- .../test_todo_priority_authoring.py | 9 ++++++++ tests/control_plane_ts/monitor_batch.test.ts | 9 ++++---- tests/control_plane_ts/todo_priority.test.ts | 8 +++++++ 12 files changed, 68 insertions(+), 24 deletions(-) diff --git a/docs/project-agent-todo-contract.md b/docs/project-agent-todo-contract.md index 4bdfc85aff..84acb348d6 100644 --- a/docs/project-agent-todo-contract.md +++ b/docs/project-agent-todo-contract.md @@ -29,11 +29,14 @@ sync catch up. ## Priority intent -Priority is scheduling intent, with `P0` highest and `P4` lowest. New Todos -default to **P1** when neither a priority parameter nor a legacy prefix is -supplied. Reserve P0 for explicitly urgent work; routine creation must not -compete with it just because the author omitted a field. Create or edit -priority independently of the task description: +Priority is scheduling intent, with `P0` highest and `P4` lowest. When neither +a priority parameter nor a legacy prefix is supplied, new machine-declared +blocking work (`user_gate`, `blocker`, or `status=blocked`) defaults to **P0**; +other new work defaults to **P1**. This keeps an omitted field from hiding a +known gate while routine creation does not compete with it. A runnable repair +that is still an ordinary `advancement_task` must declare P0 explicitly when +it is urgent; LoopX does not infer urgency from prose or action-kind names. +Create or edit priority independently of the task description: ```bash loopx todo add --goal-id --role agent --priority P1 --text '' @@ -62,12 +65,14 @@ Direct `complete`/`supersede` continuations retain the predecessor's declared priority unless the successor explicitly supplies a different legacy prefix. Monitor discoveries are independent work: `quota monitor-poll` accepts `--next-agent-priority` and `--next-user-priority`, each tied to its corresponding -successor. Omission uses the new-Todo default, not the monitor's priority. A P0 -watch therefore creates an ordinary P1 follow-up unless the caller declares -urgency. The successor record and receipt expose the resulting priority. +successor. Omission uses the new-Todo semantic default, not the monitor's +priority. A P0 watch therefore creates an ordinary P1 agent follow-up or user +action, while a declared `user_gate` defaults to P0. The successor record and +receipt expose the resulting priority. **Default change and compatibility:** previously omitted creation priority was -unranked; new CLI, Chat, canonical and monitor-created Todos now receive P1. +unranked; new CLI, Chat, canonical and monitor-created Todos now receive the +semantic P0/P1 default described above. Existing unranked Todos and explicit clears remain unranked and sort after P4. Ordinary edits preserve them. Creation defaults apply after original receipt lookup, so upgrading and retrying an old operation does not reprioritize its diff --git a/docs/quota-allocation.md b/docs/quota-allocation.md index 7914d24089..f6d780f734 100644 --- a/docs/quota-allocation.md +++ b/docs/quota-allocation.md @@ -943,8 +943,9 @@ follow-up instead of staying as an opaque watch. A user follow-up must declare a blocking owner decision and `user_action` for a visible reminder that must not block the bound agent lane. Omitting the task class fails before writeback. The monitor remains an observe-only `continuous_monitor`; `--next-agent-todo` -creates a separate runnable `advancement_task`. New successors default to P1 -independently of the monitor. Use `--next-agent-priority` and +creates a separate runnable `advancement_task`. Independently of the monitor, +agent successors and `user_action` default to P1 while `user_gate` defaults to +P0. Use `--next-agent-priority` and `--next-user-priority` to declare urgency for each successor; each option requires its corresponding Todo. Conflicting priority and legacy text prefix are rejected before observation writeback. For the corresponding diff --git a/loopx/cli_commands/quota_request.py b/loopx/cli_commands/quota_request.py index 1ce8cf0fdd..4d9ec8e011 100644 --- a/loopx/cli_commands/quota_request.py +++ b/loopx/cli_commands/quota_request.py @@ -108,7 +108,11 @@ def register_quota_monitor_poll_request_arguments( for role in ("agent", "user"): quota_parser.add_argument( f"--next-{role}-priority", choices=["P0", "P1", "P2", "P3", "P4"], - help=f"Priority of --next-{role}-todo; defaults to P1, independently of the monitor.", + help=( + f"Priority of --next-{role}-todo; defaults independently of the monitor. " + + ("A user_gate defaults to P0 and user_action to P1." + if role == "user" else "Agent advancement defaults to P1.") + ), ) diff --git a/loopx/cli_commands/todo_registration.py b/loopx/cli_commands/todo_registration.py index ef02ecc0db..681e582791 100644 --- a/loopx/cli_commands/todo_registration.py +++ b/loopx/cli_commands/todo_registration.py @@ -54,7 +54,7 @@ def register_todo_command( todo_parser.add_argument("--goal-id", required=True, help="Goal id whose active state should receive the todo.") todo_parser.add_argument("--role", choices=["user", "agent"], help="Todo owner. Required for add; optional todo_id search scope for lifecycle commands. Defaults to agent for archive-completed.") todo_parser.add_argument("--text", help="Todo text. Required for add; keep it short and public-safe enough for local status.") - todo_parser.add_argument("--priority", choices=["P0", "P1", "P2", "P3", "P4"], help="Declare Todo priority independently of text; add defaults to P1, update omission retains the current value.") + todo_parser.add_argument("--priority", choices=["P0", "P1", "P2", "P3", "P4"], help="Declare Todo priority independently of text; add defaults blocking user_gate/blocker or status=blocked work to P0 and other work to P1. Update omission retains the current value.") todo_parser.add_argument("--clear-priority", action="store_true", help="For update, explicitly remove priority; cannot be combined with --priority.") todo_parser.add_argument("--todo-id", help="Structured todo id from status/quota, such as todo_ab12cd34ef56.") todo_parser.add_argument( diff --git a/loopx/control_plane/coordination/todo_create.ts b/loopx/control_plane/coordination/todo_create.ts index a7d1d98446..e5c30401b6 100644 --- a/loopx/control_plane/coordination/todo_create.ts +++ b/loopx/control_plane/coordination/todo_create.ts @@ -143,6 +143,7 @@ function createCandidate( readModelSchema: unknown, ): JsonObject { const priority = planTodoCreationPriority({text: input.todo.text, + task_class: input.todo.task_class, status: input.todo.status, ...(Object.hasOwn(input.todo, "priority") ? {priority: input.todo.priority} : {})}); const candidate: JsonObject = { ...input.todo, diff --git a/loopx/control_plane/quota/monitor_poll_commit.ts b/loopx/control_plane/quota/monitor_poll_commit.ts index 8342f9c58d..49068b9c88 100644 --- a/loopx/control_plane/quota/monitor_poll_commit.ts +++ b/loopx/control_plane/quota/monitor_poll_commit.ts @@ -1102,7 +1102,9 @@ function requireProviderSuccessorPriority( const text = String(plan[`next_${role}_todo`]); const key = `next_${role}_priority`; const explicit = Object.hasOwn(plan, key); - const expected = planTodoCreationPriority({text, ...(explicit ? {priority: plan[key]} : {})}); + const taskClass = role === "agent" ? "advancement_task" : plan.next_user_task_class; + const expected = planTodoCreationPriority({text, task_class: taskClass, + ...(explicit ? {priority: plan[key]} : {})}); if (!explicit && nextTodo.todo === text) return; // Historical provider plan and original receipt. requireProviderTodoText(nextTodo.todo, String(expected.text), `${role} next_todo text`); requireProviderMatch(readTodoPriority(receipt), expected.priority, `${role} successor priority`); diff --git a/loopx/control_plane/scheduler/monitor_successor.ts b/loopx/control_plane/scheduler/monitor_successor.ts index e223b2704d..b104add4db 100644 --- a/loopx/control_plane/scheduler/monitor_successor.ts +++ b/loopx/control_plane/scheduler/monitor_successor.ts @@ -72,7 +72,9 @@ export function monitorSuccessorPriorities(input: JsonObject, defaultOmitted = f if (!supplied && (!defaultOmitted || !todo)) continue; if (!todo) throw new EffectRuntimeRequestError(`--next-${role}-priority requires --next-${role}-todo`); // The Todo owner validates values and conflicting legacy prefixes. - priorities[key] = planTodoCreationPriority({text: todo, ...(supplied ? {priority: input[key]} : {})}).priority; + const taskClass = role === "agent" ? "advancement_task" : text(input.next_user_task_class, "next_user_task_class"); + priorities[key] = planTodoCreationPriority({text: todo, task_class: taskClass, + ...(supplied ? {priority: input[key]} : {})}).priority; } return priorities; } diff --git a/loopx/control_plane/todos/priority.ts b/loopx/control_plane/todos/priority.ts index 215c4c45af..ef7110bd99 100644 --- a/loopx/control_plane/todos/priority.ts +++ b/loopx/control_plane/todos/priority.ts @@ -10,6 +10,7 @@ const contract = COORDINATION_STATE_CONTRACT.todo_priority; export const TODO_PRIORITIES = contract.values; export type TodoPriority = typeof TODO_PRIORITIES[number]; export const DEFAULT_TODO_PRIORITY: TodoPriority = "P1"; +export const BLOCKING_TODO_PRIORITY: TodoPriority = "P0"; const LEGACY_PREFIX = new RegExp(contract.legacy_prefix_pattern, "iu"); const LEGACY_LABEL = new RegExp(contract.legacy_label_pattern, "iu"); @@ -69,10 +70,13 @@ export function planTodoPriority(todo: JsonObject, intent: JsonObject): JsonObje title: parsed.title, priority}; } -/** Default only a fresh creation, after receipt lookup. Reads, edits and +/** Default only a fresh creation, after receipt lookup. Machine-declared + * blocking work is urgent; ordinary work remains P1. Reads, edits and * deliberate predecessor inheritance retain their original priority. */ export function planTodoCreationPriority(intent: JsonObject): JsonObject { - return planTodoPriority({priority: DEFAULT_TODO_PRIORITY}, intent); + const defaultPriority = intent.task_class === "user_gate" || intent.task_class === "blocker" || + intent.status === "blocked" ? BLOCKING_TODO_PRIORITY : DEFAULT_TODO_PRIORITY; + return planTodoPriority({priority: defaultPriority}, intent); } export function evaluateTodoPriority(value: unknown): JsonObject { diff --git a/tests/control_plane/test_monitor_successor_validation.py b/tests/control_plane/test_monitor_successor_validation.py index beeead5da4..a26707342f 100644 --- a/tests/control_plane/test_monitor_successor_validation.py +++ b/tests/control_plane/test_monitor_successor_validation.py @@ -48,7 +48,13 @@ def test_public_monitor_poll_accepts_repository_and_action_aliases(tmp_path): @pytest.mark.parametrize("provider", ["file", "sqlite"]) @pytest.mark.parametrize("explicit", [False, True]) -def test_monitor_priority_cli_and_readback(tmp_path, monkeypatch, provider, explicit): +@pytest.mark.parametrize(("user_task_class", "default_user_priority"), [ + ("user_action", "P1"), + ("user_gate", "P0"), +]) +def test_monitor_priority_cli_and_readback( + tmp_path, monkeypatch, provider, explicit, user_task_class, default_user_priority, +): from canonical_authority_fixture import isolate_sqlite_runtime from test_native_monitor_poll import _canonical from loopx.todos import update_goal_todo, list_goal_todos @@ -62,10 +68,11 @@ def test_monitor_priority_cli_and_readback(tmp_path, monkeypatch, provider, expl "--agent-id", AGENT_ID, "--runtime-profile", "generic_cli", "--todo-id", monitor["todo_id"], "--result-hash", "priority-change", "--material-change", "--next-agent-todo", "Validate the transition", "--next-action-kind", "validate", - "--next-user-todo", "Review the result", "--next-user-task-class", "user_action", + "--next-user-todo", "Review the result", "--next-user-task-class", user_task_class, *flags, "--execute", registry_path=registry, runtime_root=runtime) writeback = result["todo_writeback"] - expected = {"agent": "P2", "user": "P3"} if explicit else {"agent": "P1", "user": "P1"} + expected = ({"agent": "P2", "user": "P3"} if explicit + else {"agent": "P1", "user": default_user_priority}) for created, receipt in zip(writeback["next_todos"], writeback["successor_receipts"], strict=True): priority = expected[created["role"]] assert created["priority"] == receipt["priority"] == priority diff --git a/tests/control_plane/test_todo_priority_authoring.py b/tests/control_plane/test_todo_priority_authoring.py index 394142f5cd..7c53e5a769 100644 --- a/tests/control_plane/test_todo_priority_authoring.py +++ b/tests/control_plane/test_todo_priority_authoring.py @@ -75,6 +75,15 @@ def rows(value): selected = cli("quota", "should-run", "--codex-app", "--agent-id", AGENT_ID, "--todo-id", ordinary["todo_id"], "--turn-instance-id", "priority-selection", "--scan-path", str(project)) assert selected["should_run"] is True + gate = cli("todo", "add", "--role", "user", "--task-class", "user_gate", + "--blocks-agent", AGENT_ID, "--text", "Choose the blocking release option") + assert read(gate["todo_id"])["priority"] == "P0" + blocker = cli("todo", "add", "--role", "agent", "--task-class", "blocker", + "--text", "Record the blocked dependency") + assert read(blocker["todo_id"])["priority"] == "P0" + blocked = cli("todo", "add", "--role", "agent", "--status", "blocked", + "--text", "Wait for the required dependency") + assert read(blocked["todo_id"])["priority"] == "P0" cli("todo", "update", "--todo-id", todo_id, "--text", "Still explicitly unranked") assert read(todo_id).get("priority") is None create = ["todo", "add", "--role", "agent", "--text", "Default priority receipt", diff --git a/tests/control_plane_ts/monitor_batch.test.ts b/tests/control_plane_ts/monitor_batch.test.ts index a3d11185fb..6c33f83410 100644 --- a/tests/control_plane_ts/monitor_batch.test.ts +++ b/tests/control_plane_ts/monitor_batch.test.ts @@ -247,12 +247,13 @@ test("canonical fresh planning remains actor-owned", () => { test("canonical monitor discoveries default independently and accept per-role priorities", () => { const source = request(); source.todos = source.todos.map(todo => ({...todo, priority: "P0"})); - for (const [extra, expected] of [ - [{}, ["P1", "P1"]], - [{next_agent_priority: "P0", next_user_priority: "P3"}, ["P0", "P3"]], + for (const [taskClass, extra, expected] of [ + ["user_action", {}, ["P1", "P1"]], + ["user_gate", {}, ["P1", "P0"]], + ["user_gate", {next_agent_priority: "P0", next_user_priority: "P3"}, ["P0", "P3"]], ] as const) { const input = {...source, intent: {...source.intent, - next_user_todo: "Review result", next_user_task_class: "user_action", ...extra}}; + next_user_todo: "Review result", next_user_task_class: taskClass, ...extra}}; const first = planMonitorBatch(input); const writeback = first.writeback; assert.deepEqual((writeback.next_todos as JsonObject[]).map(todo => todo.priority), expected); diff --git a/tests/control_plane_ts/todo_priority.test.ts b/tests/control_plane_ts/todo_priority.test.ts index 1798a8949e..191fb13c19 100644 --- a/tests/control_plane_ts/todo_priority.test.ts +++ b/tests/control_plane_ts/todo_priority.test.ts @@ -77,6 +77,14 @@ test("canonical create defaults omission to P1, normalizes duplicates and respec test("creation defaults never override a declared prefix or explicit clearing", () => { assert.equal(planTodoCreationPriority({text: "Normal work"}).priority, "P1"); + for (const intent of [ + {text: "Owner decision", task_class: "user_gate"}, + {text: "Route is blocked", task_class: "blocker"}, + {text: "Waiting on a prerequisite", status: "blocked"}, + ]) assert.equal(planTodoCreationPriority(intent).priority, "P0"); + assert.equal(planTodoCreationPriority({ + text: "Explicitly lower owner decision", task_class: "user_gate", priority: "P2", + }).priority, "P2"); for (const priority of ["P0", "P1", "P2", "P3", "P4"]) { assert.equal(planTodoCreationPriority({text: "Normal work", priority}).priority, priority); assert.equal(planTodoCreationPriority({text: `[${priority}] Legacy work`}).priority, priority);