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
35 changes: 26 additions & 9 deletions docs/project-agent-todo-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <goal-id> --role agent --priority P1 --text '<agent action>'
Expand All @@ -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;
Expand All @@ -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.
Expand Down
9 changes: 7 additions & 2 deletions docs/quota-allocation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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):

Expand All @@ -952,7 +956,8 @@ loopx quota monitor-poll --goal-id <GOAL_ID> \
--target-key <TARGET_KEY> --result-hash <HASH> --material-change \
--next-user-todo "<PUBLIC_SAFE_REVIEW_REMINDER>" \
--next-user-task-class user_action \
--next-agent-todo "<PUBLIC_SAFE_FOLLOW_UP>" --execute
--next-agent-todo "<PUBLIC_SAFE_FOLLOW_UP>" --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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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});}
},
Expand Down
2 changes: 2 additions & 0 deletions loopx/cli_commands/quota_monitor_poll.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
9 changes: 9 additions & 0 deletions loopx/cli_commands/quota_request.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))
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="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(
Expand Down
6 changes: 3 additions & 3 deletions loopx/control_plane/coordination/todo_create.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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,
Expand Down
10 changes: 10 additions & 0 deletions loopx/control_plane/quota/monitor_poll.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
Expand Down Expand Up @@ -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 {}),
}


Expand Down Expand Up @@ -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"),
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
)
Expand Down
32 changes: 21 additions & 11 deletions loopx/control_plane/quota/monitor_poll_commit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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"),
Expand All @@ -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"),
Expand Down Expand Up @@ -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" ||
Expand Down
3 changes: 2 additions & 1 deletion loopx/control_plane/scheduler/legacy_monitor_poll.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 [],
Expand All @@ -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
Expand Down
Loading
Loading