From 85c72a30a0d5e48547a2305e781d06ff5cf6c171 Mon Sep 17 00:00:00 2001 From: huangruiteng Date: Thu, 15 Oct 2026 22:49:53 +0800 Subject: [PATCH 1/3] fix(todos): default new work to P1 and preserve successor priority intent Signed-off-by: huangruiteng --- loopx/cli_commands/quota_monitor_poll.py | 2 ++ loopx/cli_commands/quota_request.py | 9 ++++++ loopx/cli_commands/todo_registration.py | 2 +- .../control_plane/coordination/todo_create.ts | 6 ++-- loopx/control_plane/quota/monitor_poll.py | 10 ++++++ .../quota/monitor_poll_commit.ts | 32 ++++++++++++------- .../scheduler/legacy_monitor_poll.py | 3 +- .../control_plane/scheduler/monitor_batch.ts | 7 ++-- .../scheduler/monitor_poll_writeback.py | 7 ++++ .../scheduler/monitor_successor.ts | 18 ++++++++++- loopx/control_plane/todos/mutation_api.py | 5 +++ .../control_plane/todos/mutation_response.py | 3 ++ loopx/control_plane/todos/priority.ts | 14 +++++++- loopx/control_plane/todos/text.py | 5 ++- loopx/quota.py | 4 +++ 15 files changed, 106 insertions(+), 21 deletions(-) diff --git a/loopx/cli_commands/quota_monitor_poll.py b/loopx/cli_commands/quota_monitor_poll.py index 95bd8639de..245cea7f7b 100644 --- a/loopx/cli_commands/quota_monitor_poll.py +++ b/loopx/cli_commands/quota_monitor_poll.py @@ -78,6 +78,8 @@ def record_quota_monitor_poll_for_cli( cadence=args.cadence, next_due_at=args.next_due_at, next_agent_todo=args.next_agent_todo, + next_agent_priority=getattr(args, "next_agent_priority", None), + next_user_priority=getattr(args, "next_user_priority", None), next_action_kind=args.next_action_kind, next_task_repository=args.next_task_repository, next_required_capabilities=args.next_required_capabilities, diff --git a/loopx/cli_commands/quota_request.py b/loopx/cli_commands/quota_request.py index a807644451..1ce8cf0fdd 100644 --- a/loopx/cli_commands/quota_request.py +++ b/loopx/cli_commands/quota_request.py @@ -105,10 +105,19 @@ def register_quota_monitor_poll_request_arguments( help=("For an executing, turn-scoped monitor-poll with --todo-id, resolve the canonical " "lease proof or replay the exact prior transaction proof. Does not acquire or renew a lease.")) quota_parser.add_argument("--next-claimed-by", help="Registered agent id to claim the `--next-agent-todo` follow-up.") + 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.", + ) def validate_quota_command_request(args: argparse.Namespace) -> None: command = args.quota_command + if command != "monitor-poll" and any( + getattr(args, f"next_{role}_priority", None) is not None for role in ("agent", "user") + ): + raise QuotaCommandValidationError("successor priority requires quota monitor-poll") lease_key = getattr(args, "task_lease_idempotency_key", None) lease_version = getattr(args, "task_lease_expected_version", None) use_current_lease = bool(getattr(args, "use_current_task_lease", False)) diff --git a/loopx/cli_commands/todo_registration.py b/loopx/cli_commands/todo_registration.py index 4e381ba238..fe60eefed6 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="For add/update, declare Todo priority independently of text; 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 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 dd7abb35ef..a7d1d98446 100644 --- a/loopx/control_plane/coordination/todo_create.ts +++ b/loopx/control_plane/coordination/todo_create.ts @@ -1,4 +1,4 @@ -import {planTodoPriority} from "../todos/priority.ts"; +import {planTodoCreationPriority} from "../todos/priority.ts"; import {AUTHORITY_SOURCE_CHANGED, uncheckedAuthoritySource, type AuthoritySourceCheck} from "./authority_source.ts"; import type { JsonObject } from "../effect_program.ts"; import {CoordinationCommandReceipt} from "./command_receipt.ts"; @@ -142,8 +142,8 @@ function createCandidate( input: CoordinationTodoCreateInput, readModelSchema: unknown, ): JsonObject { - const priority = Object.hasOwn(input.todo, "priority") - ? planTodoPriority({}, {text: input.todo.text, priority: input.todo.priority}) : {}; + const priority = planTodoCreationPriority({text: input.todo.text, + ...(Object.hasOwn(input.todo, "priority") ? {priority: input.todo.priority} : {})}); const candidate: JsonObject = { ...input.todo, ...priority, diff --git a/loopx/control_plane/quota/monitor_poll.py b/loopx/control_plane/quota/monitor_poll.py index 76d8323438..fa978cd9fc 100644 --- a/loopx/control_plane/quota/monitor_poll.py +++ b/loopx/control_plane/quota/monitor_poll.py @@ -266,6 +266,8 @@ def _observation_packet( next_user_todo: str | None, next_user_task_class: str | None, next_claimed_by: str | None, + next_agent_priority: str | None = None, + next_user_priority: str | None = None, task_lease_idempotency_key: str | None = None, task_lease_expected_version: int | None = None, ) -> dict[str, Any]: @@ -293,6 +295,8 @@ def _observation_packet( "next_user_todo": next_user_todo, "next_user_task_class": next_user_task_class, "next_claimed_by": next_claimed_by, + **({"next_agent_priority": next_agent_priority} if next_agent_priority is not None else {}), + **({"next_user_priority": next_user_priority} if next_user_priority is not None else {}), } @@ -730,6 +734,8 @@ def _provider_writeback( next_user_todo=plan.get("next_user_todo"), next_user_task_class=plan.get("next_user_task_class"), next_claimed_by=plan.get("next_claimed_by"), + next_agent_priority=plan.get("next_agent_priority"), + next_user_priority=plan.get("next_user_priority"), agent_id=plan.get("agent_id"), task_lease_idempotency_key=(plan.get("lease_proof") or {}).get("idempotency_key"), task_lease_expected_version=(plan.get("lease_proof") or {}).get("expected_version"), @@ -769,6 +775,8 @@ def record_quota_monitor_poll_for_decision( next_user_todo: str | None = None, next_user_task_class: str | None = None, next_claimed_by: str | None = None, + next_agent_priority: str | None = None, + next_user_priority: str | None = None, task_lease_idempotency_key: str | None = None, task_lease_expected_version: int | None = None, use_current_task_lease: bool = False, @@ -859,6 +867,8 @@ def record_quota_monitor_poll_for_decision( next_user_todo=next_user_todo, next_user_task_class=next_user_task_class, next_claimed_by=next_claimed_by, + next_agent_priority=next_agent_priority, + next_user_priority=next_user_priority, task_lease_idempotency_key=task_lease_idempotency_key, task_lease_expected_version=task_lease_expected_version, ) diff --git a/loopx/control_plane/quota/monitor_poll_commit.ts b/loopx/control_plane/quota/monitor_poll_commit.ts index f4e83a19e1..630991e2d6 100644 --- a/loopx/control_plane/quota/monitor_poll_commit.ts +++ b/loopx/control_plane/quota/monitor_poll_commit.ts @@ -5,7 +5,8 @@ import { AgentScopeFrontierAction } from "../agents/agent_scope_frontier.generat import { createHash } from "node:crypto"; import { access, readFile, rm } from "node:fs/promises"; import { basename, dirname, join, resolve } from "node:path"; -import { monitorSuccessorIntent, monitorSuccessorRoute } from "../scheduler/monitor_successor.ts"; +import {planTodoCreationPriority, readTodoPriority} from "../todos/priority.ts"; +import { monitorSuccessorIntent, monitorSuccessorRoute, monitorSuccessorPriorities } from "../scheduler/monitor_successor.ts"; import { normalizeTodoCapabilities } from "../todos/work_requirements.ts"; import { parseProjectionDelivery } from "../todos/projection_delivery.ts"; import { isMonitorUnavailableWait } from "./blocked_wait.ts"; @@ -366,6 +367,7 @@ function observationObject(value: unknown): MonitorObservation { observation.next_due_at, "observation.next_due_at", )?.trim() ?? null, + ...monitorSuccessorPriorities(observation), next_agent_todo: optionalString( observation.next_agent_todo, "observation.next_agent_todo", @@ -961,6 +963,7 @@ function providerPlanFor(request: MonitorRequest, gateGuard = Boolean( cadence: request.observation.cadence, next_due_at: request.observation.next_due_at, reason_summary: request.observation.reason_summary, + ...monitorSuccessorPriorities(request.observation, true), next_agent_todo: request.observation.next_agent_todo, next_action_kind: request.observation.next_action_kind, next_task_repository: request.observation.next_task_repository, @@ -1017,6 +1020,7 @@ function providerPlanObject(value: unknown): MonitorProviderPlan { cadence: optionalString(plan.cadence, "provider_plan.cadence")?.trim() ?? null, next_due_at: optionalString(plan.next_due_at, "provider_plan.next_due_at")?.trim() ?? null, reason_summary: optionalString(plan.reason_summary, "provider_plan.reason_summary"), + ...monitorSuccessorPriorities(plan), next_agent_todo: optionalString(plan.next_agent_todo, "provider_plan.next_agent_todo"), next_action_kind: optionalString(plan.next_action_kind, "provider_plan.next_action_kind") ?.trim() ?? null, @@ -1092,6 +1096,19 @@ function requireProviderTodoText( requireProviderMatch(actual, compactExpected, label); } +function requireProviderSuccessorPriority( + receipt: JsonObject, nextTodo: JsonObject, plan: MonitorProviderPlan, role: "agent" | "user", +): void { + 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]} : {})}); + 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`); + requireProviderMatch(readTodoPriority(nextTodo), expected.priority, `${role} next_todo priority`); +} + function requireCanonicalSuccessorRoute( value: JsonObject, expected: JsonObject, @@ -1180,11 +1197,7 @@ function validateSuccessorReceipts( todoId, "agent next_todo unblocks_todo_id", ); - requireProviderTodoText( - nextTodo.todo, - plan.next_agent_todo, - "agent next_todo text", - ); + requireProviderSuccessorPriority(receipt, nextTodo, plan, "agent"); requireProviderMatch( requiredProviderTodoId(receipt.todo_id, "agent successor todo_id"), requiredProviderTodoId(nextTodo.todo_id, "agent next_todo todo_id"), @@ -1208,11 +1221,7 @@ function validateSuccessorReceipts( plan.next_user_task_class, "user next_todo task_class", ); - requireProviderTodoText( - nextTodo.todo, - plan.next_user_todo, - "user next_todo text", - ); + requireProviderSuccessorPriority(receipt, nextTodo, plan, "user"); requireProviderMatch( requiredProviderTodoId(receipt.todo_id, "user successor todo_id"), requiredProviderTodoId(nextTodo.todo_id, "user next_todo todo_id"), @@ -2109,6 +2118,7 @@ function validateNoEffect(receipt: JsonObject | null, plan: MonitorProviderPlan) "next_agent_todo", "next_action_kind", "next_task_repository", "next_required_capabilities", "next_continuation_policy", "next_target_key", "next_claimed_by", "next_user_todo", "next_user_task_class", ].map(key => [key, plan[key]])); + Object.assign(intent, monitorSuccessorPriorities(plan)); const hash = monitorPollRequestHash({goal_id: plan.goal_id, actor_agent_id: plan.agent_id, dry_run: !plan.execute, observation, intent, lease_proof: plan.lease_proof}); if (receipt?.schema_version !== "loopx_coordination_monitor_poll_result_v0" || diff --git a/loopx/control_plane/scheduler/legacy_monitor_poll.py b/loopx/control_plane/scheduler/legacy_monitor_poll.py index 0b9169c456..a1b2ab2af1 100644 --- a/loopx/control_plane/scheduler/legacy_monitor_poll.py +++ b/loopx/control_plane/scheduler/legacy_monitor_poll.py @@ -89,6 +89,7 @@ def _legacy_writeback( "section": next((match[2] for match in [section_bounds(original_lines, role)] if match), "User Todo / Owner Review Reading Queue" if role == "user" else "Agent Todo"), "status": record["status"], "task_class": record.get("task_class"), + "priority": record.get("priority"), "action_kind": record.get("action_kind"), "required_write_scopes": record.get("required_write_scopes") or [], "required_capabilities": record.get("required_capabilities") or [], @@ -109,7 +110,7 @@ def _legacy_writeback( result["next_todos"] = successors result["successor_receipts"] = [ {key: todo[key] for key in ( - "todo_id", "role", "task_class", "action_kind", "task_repository", + "todo_id", "role", "priority", "task_class", "action_kind", "task_repository", "continuation_policy", "required_capabilities", "claimed_by", "unblocks_todo_id", "target_key") if todo.get(key) not in (None, "", [])} for todo in successors diff --git a/loopx/control_plane/scheduler/monitor_batch.ts b/loopx/control_plane/scheduler/monitor_batch.ts index 2859f0da40..38ddfa8382 100644 --- a/loopx/control_plane/scheduler/monitor_batch.ts +++ b/loopx/control_plane/scheduler/monitor_batch.ts @@ -47,7 +47,8 @@ export function normalizeMonitorPollFields(observation: unknown, intent: unknown const observationFields = new Set(["todo_id", "target_key", "generated_at", "result_hash", "material_change", "cadence", "next_due_at", "reason_summary"]); for (const key of Object.keys(fields.observation)) if (!observationFields.has(key)) throw new EffectRuntimeRequestError(`unsupported Monitor observation field: ${key}`); const intentFields = new Set(["next_agent_todo", "next_action_kind", "next_task_repository", "next_required_capabilities", - "next_continuation_policy", "next_target_key", "next_claimed_by", "next_user_todo", "next_user_task_class"]); + "next_continuation_policy", "next_target_key", "next_claimed_by", "next_user_todo", "next_user_task_class", + "next_agent_priority", "next_user_priority"]); for (const key of Object.keys(fields.intent)) if (!intentFields.has(key)) throw new EffectRuntimeRequestError(`unsupported Monitor successor field: ${key}`); return fields; } @@ -103,6 +104,8 @@ function planWriteback(input: MonitorBatchInput, const todo: JsonObject = {schema_version: TODO_DOMAIN_ITEM_SCHEMA, todo_id: id, role, text, status: "open", done: false, archive_state: "active", task_class: role === "agent" ? "advancement_task" : intent.next_user_task_class}; + const priorityKey = `next_${role}_priority`; + if (Object.hasOwn(intent, priorityKey)) todo.priority = intent[priorityKey]; if (role === "agent") { Object.assign(todo, Object.fromEntries(Object.entries(route).filter(([, value]) => value !== null)), {unblocks_todo_id: monitor.todo_id}); @@ -127,7 +130,7 @@ function planWriteback(input: MonitorBatchInput, if (created.status === "planned") mutations.push({kind: "todo_upsert", todo: record}); plannedTodos.set(String(record.todo_id), record); } - const receiptFields = ["todo_id", "role", "task_class", "action_kind", "task_repository", + const receiptFields = ["todo_id", "role", "priority", "task_class", "action_kind", "task_repository", "continuation_policy", "required_capabilities", "claimed_by", "unblocks_todo_id", "target_key"]; const writeback: JsonObject = {schema_version: "monitor_poll_todo_writeback_v0", dry_run: input.dry_run, goal_id: input.goal_id, todo_id: monitor.todo_id, monitor_effect_id: input.operation_id, diff --git a/loopx/control_plane/scheduler/monitor_poll_writeback.py b/loopx/control_plane/scheduler/monitor_poll_writeback.py index a686881282..70432ca11b 100644 --- a/loopx/control_plane/scheduler/monitor_poll_writeback.py +++ b/loopx/control_plane/scheduler/monitor_poll_writeback.py @@ -67,6 +67,8 @@ def write_monitor_poll_todo_state( next_user_todo: str | None = None, next_user_task_class: str | None = None, next_claimed_by: str | None = None, + next_agent_priority: str | None = None, + next_user_priority: str | None = None, agent_id: str | None = None, task_lease_idempotency_key: str | None = None, task_lease_expected_version: int | None = None, @@ -94,6 +96,11 @@ def write_monitor_poll_todo_state( "next_continuation_policy": next_continuation_policy, "next_target_key": next_target_key, "next_claimed_by": next_claimed_by, "next_user_todo": next_user_todo, "next_user_task_class": next_user_task_class} + # Omitted additive fields must retain old request hashes during replay. + if next_agent_priority is not None: + intent["next_agent_priority"] = next_agent_priority + if next_user_priority is not None: + intent["next_user_priority"] = next_user_priority request = {"schema_version": "loopx_monitor_batch_plan_request_v0", "goal_id": goal_id, "operation_id": monitor_effect_id or f"monitor-poll:{goal_id}:{uuid4().hex}", "actor_agent_id": agent_id, "dry_run": not execute, diff --git a/loopx/control_plane/scheduler/monitor_successor.ts b/loopx/control_plane/scheduler/monitor_successor.ts index 9ef197496e..e223b2704d 100644 --- a/loopx/control_plane/scheduler/monitor_successor.ts +++ b/loopx/control_plane/scheduler/monitor_successor.ts @@ -6,6 +6,7 @@ import { EffectRuntimeRequestError } from "../effect_runtime_errors.ts"; import { optionalNonEmptyString, requireBoolean, requireJsonObject } from "../runtime_decode.ts"; import { normalizeTodoAgent, stripPythonWhitespace } from "../coordination/todo_agents.ts"; import { normalizeTodoRepository, normalizeTodoCapabilities } from "../todos/work_requirements.ts"; +import {planTodoCreationPriority} from "../todos/priority.ts"; export const MONITOR_SUCCESSOR_REQUEST_SCHEMA = "loopx_monitor_successor_plan_request_v0"; export const MONITOR_SUCCESSOR_RESULT_SCHEMA = "loopx_monitor_successor_plan_result_v0"; @@ -62,6 +63,20 @@ export interface MonitorSuccessorIntent extends JsonObject { next_user_task_class: "user_action" | "user_gate" | null; } +/** Optional authoring fields stay absent in historical observation identities. */ +export function monitorSuccessorPriorities(input: JsonObject, defaultOmitted = false): JsonObject { + const priorities: JsonObject = {}; + for (const [role, todo] of [["agent", text(input.next_agent_todo, "next_agent_todo")], ["user", text(input.next_user_todo, "next_user_todo")]] as const) { + const key = `next_${role}_priority`; + const supplied = Object.hasOwn(input, key); + 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; + } + return priorities; +} + export function monitorSuccessorIntent(value: unknown): MonitorSuccessorIntent { const input = requireJsonObject(value, "monitor successor intent"); const material = requireBoolean(input.material_change, "material_change"); @@ -99,7 +114,8 @@ export function monitorSuccessorIntent(value: unknown): MonitorSuccessorIntent { try { owner = normalizeTodoAgent(claim, "next_claimed_by"); } catch { throw new EffectRuntimeRequestError("--next-claimed-by must be a public-safe agent id"); } } - return {next_agent_todo: agentTodo, next_action_kind: action, + const priorities = monitorSuccessorPriorities(input); + return {...priorities, next_agent_todo: agentTodo, next_action_kind: action, next_task_repository: repo, next_required_capabilities: capabilities, next_continuation_policy: policy, next_target_key: target, next_claimed_by: owner, next_user_todo: userTodo, next_user_task_class: userClass}; diff --git a/loopx/control_plane/todos/mutation_api.py b/loopx/control_plane/todos/mutation_api.py index 11bc58b71f..8c069c17c9 100644 --- a/loopx/control_plane/todos/mutation_api.py +++ b/loopx/control_plane/todos/mutation_api.py @@ -207,6 +207,11 @@ def add_goal_todo( raise ValueError("todo add --operation-id requires promoted canonical authority") from .legacy_mutation import _add_goal_todo_legacy + # Canonical create applies the default only after checking its original + # receipt. Keep its transport intent stable across upgrades; legacy add has + # no operation receipt and uses the same typed creation policy here. + todo_text = str(plan_todo_priority({}, {"text": text, + **({"priority": priority} if priority is not None else {})}, create=True)["text"]) return _add_goal_todo_legacy(**call, _prepared={ "effective_agent_id": effective_agent_id, "effective_blocks_agent": effective_blocks_agent, diff --git a/loopx/control_plane/todos/mutation_response.py b/loopx/control_plane/todos/mutation_response.py index 3b8e5da54e..2875c62cc1 100644 --- a/loopx/control_plane/todos/mutation_response.py +++ b/loopx/control_plane/todos/mutation_response.py @@ -19,6 +19,7 @@ from .completion_state import ( normalize_todo_completion_continuation, normalize_todo_completion_recovery, ) +from .todo_semantics import todo_priority_label def serialize_added_todo_payload( *, add_result: Mapping[str, Any], goal_id: str, role: str, todo_text: str, @@ -41,6 +42,8 @@ def serialize_added_todo_payload( "role": role, "section": add_result.get("section"), "todo": todo_text, + "priority": todo_priority_label({"text": todo_text, + **({"priority": add_result["priority"]} if "priority" in add_result else {})}), "todo_id": add_result.get("todo_id"), "status": add_result.get("status"), "task_class": add_result.get("task_class"), diff --git a/loopx/control_plane/todos/priority.ts b/loopx/control_plane/todos/priority.ts index c61a7cce79..215c4c45af 100644 --- a/loopx/control_plane/todos/priority.ts +++ b/loopx/control_plane/todos/priority.ts @@ -9,6 +9,7 @@ import {compactPythonWhitespace} from "../coordination/todo_agents.ts"; 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"; const LEGACY_PREFIX = new RegExp(contract.legacy_prefix_pattern, "iu"); const LEGACY_LABEL = new RegExp(contract.legacy_label_pattern, "iu"); @@ -68,8 +69,19 @@ export function planTodoPriority(todo: JsonObject, intent: JsonObject): JsonObje title: parsed.title, priority}; } +/** Default only a fresh creation, after receipt lookup. Reads, edits and + * deliberate predecessor inheritance retain their original priority. */ +export function planTodoCreationPriority(intent: JsonObject): JsonObject { + return planTodoPriority({priority: DEFAULT_TODO_PRIORITY}, intent); +} + export function evaluateTodoPriority(value: unknown): JsonObject { const request = requireJsonObject(value, "Todo priority request"); if (request.schema_version !== "todo_priority_request_v0") throw new EffectRuntimeRequestError("Todo priority schema mismatch"); - return planTodoPriority(requireJsonObject(request.todo, "Todo"), requireJsonObject(request.intent, "priority intent")); + if (request.create !== undefined && typeof request.create !== "boolean") { + throw new EffectRuntimeRequestError("create must be a boolean"); + } + const todo = requireJsonObject(request.todo, "Todo"); + const intent = requireJsonObject(request.intent, "priority intent"); + return request.create === true ? planTodoCreationPriority(intent) : planTodoPriority(todo, intent); } diff --git a/loopx/control_plane/todos/text.py b/loopx/control_plane/todos/text.py index df286c942d..9459d40a96 100644 --- a/loopx/control_plane/todos/text.py +++ b/loopx/control_plane/todos/text.py @@ -12,10 +12,13 @@ def normalize_new_todo(text: str) -> str: return compact -def plan_todo_priority(todo: dict[str, Any], intent: dict[str, Any]) -> dict[str, Any]: +def plan_todo_priority( + todo: dict[str, Any], intent: dict[str, Any], *, create: bool = False, +) -> dict[str, Any]: try: result = effect_runtime_result("todo.priority.plan", { "schema_version": "todo_priority_request_v0", "todo": todo, "intent": intent, + **({"create": True} if create else {}), }) except EffectRuntimeRejected as exc: raise ValueError(str(exc)) from None diff --git a/loopx/quota.py b/loopx/quota.py index 829dd7d406..2c04e259f5 100644 --- a/loopx/quota.py +++ b/loopx/quota.py @@ -1159,6 +1159,8 @@ def record_quota_monitor_poll( next_user_todo: str | None = None, next_user_task_class: str | None = None, next_claimed_by: str | None = None, + next_agent_priority: str | None = None, + next_user_priority: str | None = None, task_lease_idempotency_key: str | None = None, task_lease_expected_version: int | None = None, use_current_task_lease: bool = False, @@ -1277,6 +1279,8 @@ def should_run(current_status: dict[str, Any]) -> dict[str, Any]: next_user_todo=next_user_todo, next_user_task_class=next_user_task_class, next_claimed_by=next_claimed_by, + next_agent_priority=next_agent_priority, + next_user_priority=next_user_priority, task_lease_idempotency_key=task_lease_idempotency_key, task_lease_expected_version=task_lease_expected_version, use_current_task_lease=use_current_task_lease, From 3a5785805d15948abb3fa3dd38d47c7999155b35 Mon Sep 17 00:00:00 2001 From: huangruiteng Date: Thu, 15 Oct 2026 22:50:02 +0800 Subject: [PATCH 2/3] docs(todos): explain creation defaults and priority compatibility Signed-off-by: huangruiteng --- docs/project-agent-todo-contract.md | 35 +++++++++++++++++++++-------- docs/quota-allocation.md | 9 ++++++-- 2 files changed, 33 insertions(+), 11 deletions(-) diff --git a/docs/project-agent-todo-contract.md b/docs/project-agent-todo-contract.md index feb771b1d2..4bdfc85aff 100644 --- a/docs/project-agent-todo-contract.md +++ b/docs/project-agent-todo-contract.md @@ -29,8 +29,11 @@ sync catch up. ## Priority intent -Priority is an explicit scheduling field, with `P0` highest and `P4` lowest. -Create or edit it independently of the task description: +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: ```bash loopx todo add --goal-id --role agent --priority P1 --text '' @@ -42,10 +45,9 @@ Pass the registered `--agent-id` on updates when the Goal has multiple Agents. The existing authoring, ownership, lease and review requirements still apply. Chat `todo.create` accepts `priority`; reviewed `todo.update` edits accept `priority` or `clear_priority`. The task management panel uses that reviewed -update path for its priority selector. Priority never grants eligibility, -capabilities, a claim or quota; missing priority is allowed and sorts after P4. -The generated agent authoring hint shows an explicit P1 example, not a global -default or a prerequisite for same-turn binding. +update path for its priority selector. CLI, Chat and canonical creation use the +same typed default. Priority never grants eligibility, capabilities, a claim, +quota, or permission to bypass a gate. Omitting priority from an ordinary text edit preserves the current priority. Use `--clear-priority` to remove it. Legacy `--text '[P2] Task'` remains supported; @@ -54,9 +56,24 @@ conflicting prefix are rejected before writing, as are simultaneous set/clear instructions. Words such as `P0` inside ordinary prose have no scheduling meaning. Historical decorated prefixes such as `[P2-review]` read as P2; an edit renders the normalized `[P2]` prefix. P3/P4 participate in ordering and repair suggestions -without being silently promoted to P1. Existing repair-suggestion and historical -event-replay defaults retain their own contracts; they do not impose a default -on new unprioritized Todos. +without being silently promoted to P1. + +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. + +**Default change and compatibility:** previously omitted creation priority was +unranked; new CLI, Chat, canonical and monitor-created Todos now receive P1. +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 +result. Historical event replay and repair-suggestion policies retain their own +contracts. To choose another priority, pass it explicitly; to make an existing +Todo unranked, use the reviewed edit or `--clear-priority`. `todos/priority.ts` owns authoring intent and ordering. The generated coordination contract supplies its vocabulary and legacy grammar to Python read adapters. diff --git a/docs/quota-allocation.md b/docs/quota-allocation.md index 411513d291..7914d24089 100644 --- a/docs/quota-allocation.md +++ b/docs/quota-allocation.md @@ -943,7 +943,11 @@ 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`. For the corresponding +creates a separate runnable `advancement_task`. New successors default to P1 +independently of the monitor. 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 generation-fenced waiting-Todo recipe, see [Project Agent Todo Contract](project-agent-todo-contract.md#machine-readable-resume-conditions): @@ -952,7 +956,8 @@ loopx quota monitor-poll --goal-id \ --target-key --result-hash --material-change \ --next-user-todo "" \ --next-user-task-class user_action \ - --next-agent-todo "" --execute + --next-agent-todo "" --next-action-kind validate \ + --next-agent-priority P1 --next-user-priority P2 --execute ``` The command appends a `quota_monitor_poll` run record, does not mutate the From ceb56fb85f99952e80dbec93e3fb188800786a43 Mon Sep 17 00:00:00 2001 From: huangruiteng Date: Thu, 15 Oct 2026 22:50:03 +0800 Subject: [PATCH 3/3] test(todos): cover priority defaults, monitor receipts and packaged creation Signed-off-by: huangruiteng --- .../goal-workspace-create.mjs | 7 +- .../test_monitor_poll_transaction_recovery.py | 16 ++--- .../test_monitor_successor_validation.py | 67 ++++++++++++++++++- .../test_todo_mutation_authority.py | 2 +- .../test_todo_priority_authoring.py | 17 ++++- tests/control_plane_ts/monitor_batch.test.ts | 41 ++++++++++++ .../quota_monitor_poll_commit.test.ts | 19 ++++-- .../todo_create_reviewed.test.ts | 1 + tests/control_plane_ts/todo_priority.test.ts | 59 +++++++++++++++- tests/test_chat_todo_priority.py | 10 +++ tests/test_todo_write_serialization.py | 2 +- 11 files changed, 215 insertions(+), 26 deletions(-) diff --git a/examples/personal-workspace-browser/goal-workspace-create.mjs b/examples/personal-workspace-browser/goal-workspace-create.mjs index 67b53b6e6a..30b044f8c2 100644 --- a/examples/personal-workspace-browser/goal-workspace-create.mjs +++ b/examples/personal-workspace-browser/goal-workspace-create.mjs @@ -41,6 +41,7 @@ export const goalWorkspaceCreateScenario = { await task.fill(text);await now.getByRole('button',{name:'检查任务',exact:true}).click(); const drawer=page.locator('.personal-context-drawer');await drawer.waitFor(); assert.equal(requests.length,1);assert.equal(requests[0].normalized_parameters.text,text); + assert.equal(requests[0].normalized_parameters.priority,undefined,'Ordinary creation leaves priority to the Todo owner'); await drawer.locator('.personal-primary-action').click(); await drawer.getByText(/失败/).first().waitFor(); await page.locator('.personal-drawer-close').click();assert.equal(await task.inputValue(),text); @@ -53,9 +54,11 @@ export const goalWorkspaceCreateScenario = { await now.locator('.goal-work-row').filter({hasText:text}).waitFor(); assert.equal(await task.inputValue(),''); const {stdout}=await promisify(execFile)(python,['-c','from loopx.entrypoint import main; main()','--format','json','--registry',ready.registry,'todo','list','--goal-id','progress-projection','--role','agent'],{cwd:repoRoot,env:{...process.env,PYTHONPATH:repoRoot,LOOPX_USAGE_PING:'0'},timeout:30000}); - const result=JSON.parse(stdout);assert.equal(result.todos.filter(todo=>todo.text===text).length,1,'CLI must read exactly one created task'); + const result=JSON.parse(stdout),created=result.todos.filter(todo=>todo.title===text); + assert.equal(created.length,1,'CLI must read exactly one created task'); + assert.equal(created[0].priority,'P1','The real Todo owner defaults omitted creation priority'); await page.screenshot({path:resolve(outputDir,'goal-workspace-created.png'),animations:'disabled'}); - return {coverageEntries:[],note:'Packaged inline Add task -> real action HTTP/File Todo owner -> refreshed row and independent CLI readback. Failed apply retains draft; recovery creates once. No model execution.'}; + return {coverageEntries:[],note:'Packaged inline Add task -> real action HTTP/File Todo owner -> refreshed row and independent CLI readback with default P1. Failed apply retains draft; recovery creates once. No model execution.'}; } catch(error){if(diagnostics)process.stderr.write(diagnostics);if(context)await context.page.screenshot({path:resolve(outputDir,'goal-workspace-create-failed.png')});throw error;} finally {clearTimeout(timer);await context?.close();lines.close();if(child.exitCode===null&&child.signalCode===null){child.kill('SIGTERM');const kill=setTimeout(()=>child.kill('SIGKILL'),5000);try{await closed;}finally{clearTimeout(kill);}}await rm(root,{recursive:true,force:true});} }, diff --git a/tests/control_plane/test_monitor_poll_transaction_recovery.py b/tests/control_plane/test_monitor_poll_transaction_recovery.py index e7c4c3c175..2e0e7b2542 100644 --- a/tests/control_plane/test_monitor_poll_transaction_recovery.py +++ b/tests/control_plane/test_monitor_poll_transaction_recovery.py @@ -143,8 +143,8 @@ def _fixture(tmp_path: Path) -> tuple[Path, Path, Path, str, tuple[str, ...]]: def _successors(registry: Path, runtime: Path) -> dict[str, dict]: todos = _ok(registry, runtime, "todo", "list", "--goal-id", GOAL)["todos"] - return {todo["text"]: todo for todo in todos - if todo["text"] in (AGENT_TASK, USER_TASK)} + return {todo["title"]: todo for todo in todos + if todo["title"] in (AGENT_TASK, USER_TASK)} def _poll_events(runtime: Path) -> list[dict]: @@ -331,7 +331,7 @@ def test_enabled_shadow_business_replay_then_bounded_drain_recovers_batch( before = read_local_authority_shadow(runtime_root=runtime, goal_id=GOAL, scan_limit=20) assert {todo["text"] for todo in before["head"]["todos"]} == { - "Observe public synthetic target"} + "[P1] Observe public synthetic target"} state_after = state.read_bytes() original_ids = { todo["todo_id"] for todo in _business_receipt(state)["writeback"]["next_todos"]} @@ -356,7 +356,7 @@ def test_enabled_shadow_business_replay_then_bounded_drain_recovers_batch( runtime_root=runtime, goal_id=GOAL, scan_limit=20, )["proof"]["transactions"] == before["proof"]["transactions"] assert {todo["todo"] for todo in retry["todo_writeback"]["next_todos"]} == { - AGENT_TASK, USER_TASK} + f"[P1] {AGENT_TASK}", f"[P1] {USER_TASK}"} assert set(retry["successor_todo_ids"]) == original_ids assert state.read_bytes() == state_after receipt_after = _quota_receipt(runtime).read_bytes() @@ -370,7 +370,7 @@ def test_enabled_shadow_business_replay_then_bounded_drain_recovers_batch( view = read_local_authority_shadow(runtime_root=runtime, goal_id=GOAL, scan_limit=20) assert {todo["text"] for todo in view["head"]["todos"]} == { - "Observe public synthetic target", AGENT_TASK, USER_TASK} + "[P1] Observe public synthetic target", f"[P1] {AGENT_TASK}", f"[P1] {USER_TASK}"} assert len(view["proof"]["transactions"]) == 2 # Baseline and original batch. assert list(outbox.glob("*.prepared.json")) == [] late_retry = _ok(registry, runtime, *poll) @@ -556,7 +556,7 @@ def test_historical_canonical_pending_plan_settles_existing_business_receipt( assert (code, result) == (73, None) committed = read_canonical_todos_if_promoted(runtime_root=runtime, goal_id=GOAL) successor_ids = {todo["todo_id"] for todo in committed["todos"] - if todo["text"] in (AGENT_TASK, USER_TASK)} + if todo["title"] in (AGENT_TASK, USER_TASK)} assert len(successor_ids) == 2 assert _poll_events(runtime) == [] pending_path = _quota_receipt(runtime) @@ -645,8 +645,8 @@ def test_pending_retry_keeps_original_successor_identity_after_public_edit( retry = _ok(registry, runtime, *poll) writeback = retry["todo_writeback"] - assert writeback["todo_update"]["todo"] == "Observe public synthetic target" - assert [todo["todo"] for todo in writeback["next_todos"]] == [AGENT_TASK, USER_TASK] + assert writeback["todo_update"]["todo"] == "[P1] Observe public synthetic target" + assert [todo["todo"] for todo in writeback["next_todos"]] == [f"[P1] {AGENT_TASK}", f"[P1] {USER_TASK}"] assert all(todo["added"] is True for todo in writeback["next_todos"]) assert set(retry["successor_todo_ids"]) == original_ids assert state.read_bytes() == edited_state diff --git a/tests/control_plane/test_monitor_successor_validation.py b/tests/control_plane/test_monitor_successor_validation.py index b8509c822e..ee70a9329d 100644 --- a/tests/control_plane/test_monitor_successor_validation.py +++ b/tests/control_plane/test_monitor_successor_validation.py @@ -10,6 +10,9 @@ {"next_required_capabilities": ["filesystem-write", "not/a/capability"]}, {"next_claimed_by": "invalid/actor"}, {"material_change": False}, + {"next_agent_priority": "P5"}, + {"next_agent_priority": "P0", "next_agent_todo": "[P1] Conflicting task"}, + {"next_user_priority": "P0"}, ]) def test_invalid_successor_intent_does_not_partially_update_monitor(tmp_path, invalid): registry, runtime, state = _write_fixture(tmp_path) @@ -19,8 +22,8 @@ def test_invalid_successor_intent_does_not_partially_update_monitor(tmp_path, in write_monitor_poll_todo_state(registry_path=registry, runtime_root=runtime, goal_id=GOAL_ID, todo_id=monitor["todo_id"], execute=True, generated_at="2026-09-09T12:00:00Z", result_hash="revision-a", agent_id=AGENT_ID, - next_agent_todo="Validate the material change.", next_action_kind="validate", - **{"material_change": True, **invalid}) + next_action_kind="validate", + **{"material_change": True, "next_agent_todo": "Validate the material change.", **invalid}) assert state.read_bytes() == before @@ -41,3 +44,63 @@ def test_public_monitor_poll_accepts_repository_and_action_aliases(tmp_path): assert successor["required_capabilities"] == ["file__write"] assert successor["task_class"] == "advancement_task" assert successor["unblocks_todo_id"] == monitor["todo_id"] + + +@pytest.mark.parametrize("provider", ["legacy", "file", "sqlite"]) +@pytest.mark.parametrize("explicit", [False, True]) +def test_monitor_priority_cli_and_readback(tmp_path, monkeypatch, provider, explicit): + 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 + + isolate_sqlite_runtime(tmp_path, monkeypatch) + if provider == "legacy": + registry, runtime, _ = _write_fixture(tmp_path) + monitor = _add_monitor(registry, text="Watch ordinary changes", target_key="public-target", + next_due_at="2000-01-01T00:00:00Z") + else: + registry, runtime, _, monitor = _canonical(tmp_path, provider=provider) + update_goal_todo(registry_path=registry, runtime_root_arg=str(runtime), goal_id=GOAL_ID, + todo_id=monitor["todo_id"], agent_id=AGENT_ID, priority="P0") + flags = ["--next-agent-priority", "P2", "--next-user-priority", "P3"] if explicit else [] + result = run_json_cli("quota", "monitor-poll", "--goal-id", GOAL_ID, + "--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", + *flags, "--execute", registry_path=registry, runtime_root=runtime) + writeback = result["todo_writeback"] + expected = {"agent": "P2", "user": "P3"} if explicit else {"agent": "P1", "user": "P1"} + for created, receipt in zip(writeback["next_todos"], writeback["successor_receipts"], strict=True): + priority = expected[created["role"]] + assert created["priority"] == receipt["priority"] == priority + current = list_goal_todos(registry_path=registry, runtime_root_arg=str(runtime), + goal_id=GOAL_ID, role=created["role"])["todos"] + assert next(row for row in current if row["todo_id"] == created["todo_id"])["priority"] == priority + + +@pytest.mark.parametrize("provider", ["legacy", "file", "sqlite"]) +def test_monitor_priority_retry_keeps_original_intent(tmp_path, monkeypatch, provider): + from canonical_authority_fixture import isolate_sqlite_runtime + from test_native_monitor_poll import _canonical + + isolate_sqlite_runtime(tmp_path, monkeypatch) + if provider == "legacy": + registry, runtime, _ = _write_fixture(tmp_path) + monitor = _add_monitor(registry, text="Watch changes", target_key="public-target") + else: + registry, runtime, _, monitor = _canonical(tmp_path, provider=provider) + args = dict(registry_path=registry, runtime_root=runtime, goal_id=GOAL_ID, + todo_id=monitor["todo_id"], agent_id=AGENT_ID, execute=True, + generated_at="2026-09-09T12:00:00Z", result_hash="priority-a", material_change=True, + monitor_effect_id="priority-once", next_agent_todo="Repair transition", + next_action_kind="repair", next_agent_priority="P0") + first = write_monitor_poll_todo_state(**args) + replay = write_monitor_poll_todo_state(**args) + assert replay["provider_replayed"] is True + fields = ("todo_id", "role", "todo", "priority", "action_kind", "target_key") + assert [{k: row.get(k) for k in fields} for row in replay["next_todos"]] == [ + {k: row.get(k) for k in fields} for row in first["next_todos"]] + assert first["next_todos"][0]["priority"] == "P0" + with pytest.raises((ValueError, RuntimeError), match="identity|mismatch|different coordination request"): + write_monitor_poll_todo_state(**{**args, "next_agent_priority": "P4"}) diff --git a/tests/control_plane/test_todo_mutation_authority.py b/tests/control_plane/test_todo_mutation_authority.py index e65263948b..b8f4100b41 100644 --- a/tests/control_plane/test_todo_mutation_authority.py +++ b/tests/control_plane/test_todo_mutation_authority.py @@ -221,7 +221,7 @@ def test_registered_actor_can_correct_unclaimed_todo_without_claim(tmp_path: Pat ) assert result["mutation_authority"]["mode"] == "registered_peer_actor" corrected = _agent_todo(state, todo["todo_id"]) - assert corrected["text"] == "Correct unclaimed copy" + assert corrected["text"] == "[P1] Correct unclaimed copy" assert corrected["note"] == "Correct note" assert not corrected.get("claimed_by") diff --git a/tests/control_plane/test_todo_priority_authoring.py b/tests/control_plane/test_todo_priority_authoring.py index 7a748c3b7c..3f48dd9d07 100644 --- a/tests/control_plane/test_todo_priority_authoring.py +++ b/tests/control_plane/test_todo_priority_authoring.py @@ -73,8 +73,19 @@ def rows(value): cli("todo", "update", "--todo-id", todo_id, "--clear-priority") assert read(todo_id).get("priority") is None assert "[P4] New task title" not in state.read_text() - unranked = cli("todo", "add", "--role", "agent", "--text", "Investigate P0 prose without declaring priority") - assert read(unranked["todo_id"]).get("priority") is None + ordinary = cli("todo", "add", "--role", "agent", "--text", "Investigate P0 prose without declaring priority") + assert read(ordinary["todo_id"])["priority"] == "P1" selected = cli("quota", "should-run", "--codex-app", "--agent-id", AGENT_ID, - "--todo-id", unranked["todo_id"], "--turn-instance-id", "priority-selection", "--scan-path", str(project)) + "--todo-id", ordinary["todo_id"], "--turn-instance-id", "priority-selection", "--scan-path", str(project)) assert selected["should_run"] is True + cli("todo", "update", "--todo-id", todo_id, "--text", "Still explicitly unranked") + assert read(todo_id).get("priority") is None + if provider != "legacy": + create = ["todo", "add", "--role", "agent", "--text", "Default priority receipt", + "--operation-id", "default-priority-once"] + first = cli(*create) + assert read(first["todo_id"])["priority"] == "P1" + cli("todo", "update", "--todo-id", first["todo_id"], "--priority", "P4") + assert cli(*create)["status"] == "replayed" + assert read(first["todo_id"])["priority"] == "P4" + cli(*create, "--priority", "P0", success=False) diff --git a/tests/control_plane_ts/monitor_batch.test.ts b/tests/control_plane_ts/monitor_batch.test.ts index 1c039bc735..c5756bc25a 100644 --- a/tests/control_plane_ts/monitor_batch.test.ts +++ b/tests/control_plane_ts/monitor_batch.test.ts @@ -130,3 +130,44 @@ test("legacy successor reuses a semantic duplicate with omitted empty capabiliti (source.todos as JsonObject[])[0], missingLink]}), /different unblocks_todo_id/); }); + + +test("monitor discoveries default independently and accept per-role priorities", () => { + const source = request(); + source.todos = (source.todos as JsonObject[]).map(todo => ({...todo, priority: "P0"})); + for (const [extra, expected] of [ + [{}, ["P1", "P1"]], + [{next_agent_priority: "P0", next_user_priority: "P3"}, ["P0", "P3"]], + ] as const) { + const input = {...source, intent: {...(source.intent as JsonObject), + next_user_todo: "Review result", next_user_task_class: "user_action", ...extra}}; + const first = planLegacyMonitorBatch(input); + const writeback = first.writeback as JsonObject; + assert.deepEqual((writeback.next_todos as JsonObject[]).map(todo => todo.priority), expected); + assert.deepEqual((writeback.successor_receipts as JsonObject[]).map(todo => todo.priority), expected); + const retry = planLegacyMonitorBatch({...input, previous_receipt: first.receipt}); + assert.equal(retry.replayed, true); + assert.deepEqual(retry.mutations, []); + assert.throws(() => planLegacyMonitorBatch({...input, previous_receipt: first.receipt, + intent: {...input.intent, next_agent_priority: "P4"}}), /request mismatch/); + } + for (const extra of [{next_agent_priority: "P5"}, {next_user_priority: "P0"}, + {next_agent_priority: "P0", next_agent_todo: "[P1] Conflict"}]) { + assert.throws(() => planLegacyMonitorBatch({...source, intent: {...(source.intent as JsonObject), ...extra}})); + } +}); + +test("historical unranked monitor receipts replay without applying the new default", () => { + const first = planLegacyMonitorBatch(request()); + const receipt = structuredClone(first.receipt) as JsonObject; + const writeback = receipt.writeback as JsonObject; + for (const todo of writeback.next_todos as JsonObject[]) { + todo.text = todo.todo = "Deliver observed result"; + delete todo.priority; delete todo.title; + } + for (const todo of writeback.successor_receipts as JsonObject[]) delete todo.priority; + const replay = planLegacyMonitorBatch({...request(), previous_receipt: receipt, todos: []}); + assert.equal(replay.replayed, true); + assert.deepEqual(replay.mutations, []); + assert.equal(((replay.writeback as JsonObject).next_todos as JsonObject[])[0].priority, undefined); +}); diff --git a/tests/control_plane_ts/quota_monitor_poll_commit.test.ts b/tests/control_plane_ts/quota_monitor_poll_commit.test.ts index 7ce8c52752..6da5573beb 100644 --- a/tests/control_plane_ts/quota_monitor_poll_commit.test.ts +++ b/tests/control_plane_ts/quota_monitor_poll_commit.test.ts @@ -586,7 +586,7 @@ test("Todo commit fences the full material successor receipt", async (t) => { target_key: "public-release:42", result_hash: "approved-42", material_change: true, - next_agent_todo: "Advance the approved release.", + next_agent_todo: "[P1] Advance the approved release.", next_action_kind: "ADVANCE_RELEASE", next_task_repository: "https://github.com/owner/repo.git", next_required_capabilities: ["filesystem-write"], @@ -599,6 +599,7 @@ test("Todo commit fences the full material successor receipt", async (t) => { const successor = { todo_id: "todo_successor001", + priority: "P1", role: "agent", task_class: "advancement_task", action_kind: "advance_release", @@ -626,7 +627,7 @@ test("Todo commit fences the full material successor receipt", async (t) => { todo_update: { ok: true }, next_todos: [{ ...successor, - todo: "Advance the approved release.", + todo: "[P1] Advance the approved release.", }], successor_receipts: [successor], }; @@ -654,7 +655,7 @@ test("Todo commit fences the full material successor receipt", async (t) => { ...providerReceipt, next_todos: [{ ...successor, - todo: "Advance the approved release.", + todo: "[P1] Advance the approved release.", target_key: "public-release:42:other", }], }, @@ -662,6 +663,11 @@ test("Todo commit fences the full material successor receipt", async (t) => { /agent next_todo target_key must match provider plan/, ); + for (const priority of ["P0", null]) { + await assert.rejects(() => evaluateQuotaMonitorPollCommit({...params, phase: "commit", + provider_receipt: {...providerReceipt, successor_receipts: [{...successor, priority}]}}), + /successor priority must match/); + } const written = await evaluateQuotaMonitorPollCommit({ ...params, phase: "commit", @@ -730,7 +736,7 @@ test("Todo commit rejects injected defaults in the material successor route", as todo_id: "todo_public_monitor", result_hash: "approved-default", material_change: true, - next_agent_todo: "Advance the approved release.", + next_agent_todo: "[P1] Advance the approved release.", next_action_kind: "advance_release", }), }); @@ -739,6 +745,7 @@ test("Todo commit rejects injected defaults in the material successor route", as .update("approved-default", "utf8").digest("hex").slice(0, 16)}`; const successor = { todo_id: "todo_successor002", + priority: "P1", role: "agent", task_class: "advancement_task", action_kind: "advance_release", @@ -761,7 +768,7 @@ test("Todo commit rejects injected defaults in the material successor route", as next_due_at: null, cadence: null, todo_update: {}, - next_todos: [{ ...successor, todo: "Advance the approved release." }], + next_todos: [{ ...successor, todo: "[P1] Advance the approved release." }], successor_receipts: [successor], }; @@ -782,7 +789,7 @@ test("Todo commit rejects injected defaults in the material successor route", as next_todos: [{ ...successor, ...mutation, - todo: "Advance the approved release.", + todo: "[P1] Advance the approved release.", }], }, }), diff --git a/tests/control_plane_ts/todo_create_reviewed.test.ts b/tests/control_plane_ts/todo_create_reviewed.test.ts index ed956586b8..d69128580a 100644 --- a/tests/control_plane_ts/todo_create_reviewed.test.ts +++ b/tests/control_plane_ts/todo_create_reviewed.test.ts @@ -64,6 +64,7 @@ for (const provider of ["file", "sqlite", "postgresql"] as const) { const current = {...request, expected_provider_revision: later.provider_revision}; const applied = await executeCoordinationTodoCreate(store, current); assert.equal(applied.status, "applied"); + assert.equal((applied.todo as Record).priority, "P1"); const accepted = await store.loadAuthority(); assert.equal(accepted.status, "loaded"); if (accepted.status !== "loaded") throw new Error("missing accepted head"); diff --git a/tests/control_plane_ts/todo_priority.test.ts b/tests/control_plane_ts/todo_priority.test.ts index 6942c01e03..1798a8949e 100644 --- a/tests/control_plane_ts/todo_priority.test.ts +++ b/tests/control_plane_ts/todo_priority.test.ts @@ -1,10 +1,17 @@ +import {mkdtemp, rm} from "node:fs/promises"; +import {tmpdir} from "node:os"; +import {join} from "node:path"; +import {FileAuthorityStore} from "../../loopx/control_plane/coordination/file_authority_store.ts"; +import {SqliteAuthorityStore} from "../../loopx/control_plane/coordination/sqlite_authority_store.ts"; +import {coordinationTodoReadModel} from "../../loopx/control_plane/coordination/coordination_projection.ts"; +import {canonicalAuthoritySha256} from "../../loopx/control_plane/coordination/authority_store_codec.ts"; import type {JsonObject} from "../../loopx/control_plane/effect_program.ts"; -import {planCoordinationTodoCreate} from "../../loopx/control_plane/coordination/todo_create.ts"; +import {executeCoordinationTodoCreate, COORDINATION_TODO_CREATE_RECEIPT_SCHEMA, planCoordinationTodoCreate} from "../../loopx/control_plane/coordination/todo_create.ts"; import {TODO_DOMAIN_ITEM_SCHEMA, TODO_DOMAIN_READ_RECORD_SCHEMA} from "../../loopx/control_plane/coordination/coordination_state_contract.ts"; import {readFileSync} from "node:fs"; import assert from "node:assert/strict"; import test from "node:test"; -import {planTodoPriority, readTodoPriority, todoPriorityRank} from "../../loopx/control_plane/todos/priority.ts"; +import {planTodoCreationPriority, planTodoPriority, readTodoPriority, todoPriorityRank} from "../../loopx/control_plane/todos/priority.ts"; test("priority is declared metadata; prose is never scheduling input", () => { assert.equal(readTodoPriority({text: "Investigate P0 parser behavior"}), null); @@ -44,7 +51,7 @@ test("complex fixture priority vocabulary matches the compatibility codec", () = }); -test("canonical create normalizes before duplicate matching and clears absent priority", () => { +test("canonical create defaults omission to P1, normalizes duplicates and respects explicit null", () => { const input = {goal_id: "priority-goal", actor_agent_id: null, registered_agents: [], operation_id: "create-priority", dry_run: false, now: new Date("2026-09-20T00:00:00Z"), todo: {schema_version: TODO_DOMAIN_ITEM_SCHEMA, todo_id: "new-todo", role: "agent", @@ -60,4 +67,50 @@ test("canonical create normalizes before duplicate matching and clears absent pr const unranked = planCoordinationTodoCreate({...input, todo: {...input.todo, priority: null}}, new Map(), TODO_DOMAIN_READ_RECORD_SCHEMA).todo as JsonObject; assert.equal(unranked.priority, undefined); assert.equal(unranked.text, "A task"); + const {priority: _omitted, ...todo} = input.todo; + const ordinary = planCoordinationTodoCreate({...input, todo}, + new Map(), TODO_DOMAIN_READ_RECORD_SCHEMA).todo as JsonObject; + assert.equal(ordinary.priority, "P1"); + assert.equal(ordinary.text, "[P1] A task"); }); + + +test("creation defaults never override a declared prefix or explicit clearing", () => { + assert.equal(planTodoCreationPriority({text: "Normal work"}).priority, "P1"); + 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); + } + assert.equal(planTodoCreationPriority({text: "Unranked", priority: null}).priority, null); + assert.equal(planTodoCreationPriority({text: "Unranked", clear_priority: true}).priority, null); + assert.equal(planTodoPriority({text: "Historical work"}, {text: "Edited historical work"}).priority, null); + assert.throws(() => planTodoCreationPriority({text: "[P0] Conflict", priority: "P1"}), /conflict/); +}); + + +for (const provider of ["file", "sqlite"] as const) { + test(`${provider}: an old creation receipt keeps its unranked result on upgrade`, async t => { + const root = await mkdtemp(join(tmpdir(), "loopx-priority-replay-")); + t.after(() => rm(root, {recursive: true, force: true})); + const store = provider === "file" ? new FileAuthorityStore(root, "goal-a") : new SqliteAuthorityStore(root, "goal-a"); + const todo = {schema_version: TODO_DOMAIN_ITEM_SCHEMA, todo_id: "todo-old", role: "agent", + status: "open", done: false, archive_state: "active", text: "Historical unranked task"}; + const request = {goal_id: "goal-a", operation_id: "old-create", actor_agent_id: null, + registered_agents: [], dry_run: false, todo, now: new Date("2026-01-01T00:00:00Z")}; + const receipt = {schema_version: COORDINATION_TODO_CREATE_RECEIPT_SCHEMA, + operation_id: request.operation_id, goal_id: request.goal_id, todo_id: todo.todo_id, todo, + request_sha256: canonicalAuthoritySha256({goal_id: request.goal_id, todo, + actor_agent_id: null, dry_run: false})}; + assert.equal((await store.commitAuthority({operation_id: "old-create", expected_provider_revision: null, + events: [], receipts: [receipt], next_projection: {goal_id: "goal-a", handoff_mode: "soft_claim", + todos: [todo], leases: [], todo_read_model: coordinationTodoReadModel([todo], TODO_DOMAIN_READ_RECORD_SCHEMA)}})).status, "applied"); + const before = await store.loadAuthority(); + const result = await executeCoordinationTodoCreate(store, request, + async () => {throw new Error("receipt replay must precede current-source admission");}); + assert.equal(result.status, "replayed"); + assert.deepEqual(result.todo, todo); + assert.deepEqual(await store.loadAuthority(), before); + const changed = await executeCoordinationTodoCreate(store, {...request, todo: {...todo, priority: "P0"}}); + assert.equal(changed.reason_code, "coordination_operation_identity_mismatch"); + }); +} diff --git a/tests/test_chat_todo_priority.py b/tests/test_chat_todo_priority.py index 2cfb2bff6a..0be9b4699e 100644 --- a/tests/test_chat_todo_priority.py +++ b/tests/test_chat_todo_priority.py @@ -32,3 +32,13 @@ def test_chat_create_keeps_priority_and_rejects_conflicting_marker(tmp_path): with pytest.raises(ValueError, match="conflict"): service.preview({**request, "idempotency_key": "priority-conflict", "normalized_parameters": {"goal_id": GOAL_ID, "text": "[P1] Task", "priority": "P0"}}) + + +def test_chat_create_defaults_without_overriding_reviewed_priority(tmp_path): + project, _, service = _fixture(tmp_path) + request = {"action_kind": "todo.create", "summary": "Create ordinary task", + "normalized_parameters": {"goal_id": GOAL_ID, "text": "An ordinary task"}, + "context": {}, "idempotency_key": "priority-default"} + preview = service.preview(request) + assert service.apply(preview["proposal_id"])["proposal"]["status"] == "applied" + assert "[P1] An ordinary task" in _todos(project) diff --git a/tests/test_todo_write_serialization.py b/tests/test_todo_write_serialization.py index 602ca9e73f..cb51c74d35 100644 --- a/tests/test_todo_write_serialization.py +++ b/tests/test_todo_write_serialization.py @@ -147,6 +147,6 @@ def deterministic_lock( by_id = {str(item["todo_id"]): item for item in items} assert str(added["todo_id"]) in by_id, items - assert by_id[str(added["todo_id"])]["text"] == CONCURRENT_TODO + assert by_id[str(added["todo_id"])]["text"] == f"[P1] {CONCURRENT_TODO}" assert by_id[target_id]["status"] == "blocked" assert by_id[target_id]["reason"] == UPDATE_REASON