Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 14 additions & 9 deletions docs/project-agent-todo-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <goal-id> --role agent --priority P1 --text '<agent action>'
Expand Down Expand Up @@ -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
Expand Down
5 changes: 3 additions & 2 deletions docs/quota-allocation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 5 additions & 1 deletion loopx/cli_commands/quota_request.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.")
),
)


Expand Down
2 changes: 1 addition & 1 deletion loopx/cli_commands/todo_registration.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
1 change: 1 addition & 0 deletions loopx/control_plane/coordination/todo_create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
4 changes: 3 additions & 1 deletion loopx/control_plane/quota/monitor_poll_commit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`);
Expand Down
4 changes: 3 additions & 1 deletion loopx/control_plane/scheduler/monitor_successor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
8 changes: 6 additions & 2 deletions loopx/control_plane/todos/priority.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");

Expand Down Expand Up @@ -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 {
Expand Down
13 changes: 10 additions & 3 deletions tests/control_plane/test_monitor_successor_validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
9 changes: 9 additions & 0 deletions tests/control_plane/test_todo_priority_authoring.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
9 changes: 5 additions & 4 deletions tests/control_plane_ts/monitor_batch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
8 changes: 8 additions & 0 deletions tests/control_plane_ts/todo_priority.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading