From 256acb05019081c742ac918d6bd6882f36f35ff3 Mon Sep 17 00:00:00 2001 From: huangruiteng Date: Sat, 12 Sep 2026 00:31:36 +0800 Subject: [PATCH 1/3] feat(todo): close native work requirement edits with shared typed validation Signed-off-by: huangruiteng --- .../control_plane/coordination/todo_update.ts | 6 + .../quota/monitor_poll_commit.ts | 7 +- .../scheduler/monitor_successor.ts | 59 +--------- .../control_plane/todos/native_update_plan.ts | 6 +- loopx/control_plane/todos/public_update.ts | 6 +- .../control_plane/todos/work_requirements.ts | 106 ++++++++++++++++++ loopx/todos.py | 8 +- .../test_native_todo_planning_update.py | 74 ++++++++++++ .../monitor_successor.test.ts | 3 +- .../native_planning_update_conformance.ts | 38 ++++++- .../production_scale_coordination_fixture.ts | 8 ++ .../todo_work_requirements.test.ts | 44 ++++++++ 12 files changed, 300 insertions(+), 65 deletions(-) create mode 100644 loopx/control_plane/todos/work_requirements.ts create mode 100644 tests/control_plane_ts/todo_work_requirements.test.ts diff --git a/loopx/control_plane/coordination/todo_update.ts b/loopx/control_plane/coordination/todo_update.ts index 2999b76467..b24f7168fa 100644 --- a/loopx/control_plane/coordination/todo_update.ts +++ b/loopx/control_plane/coordination/todo_update.ts @@ -1,4 +1,5 @@ import type { JsonObject } from "../effect_program.ts"; +import { TODO_WORK_REQUIREMENT_FIELDS } from "../todos/work_requirements.ts"; import type { AuthorityStore, AuthorityStoreCommit, AuthorityStoreReceiptResult } from "./authority_store.ts"; import { AuthorityStoreProtocolError, @@ -230,6 +231,11 @@ function targetRejection( return failure("update_owner_mismatch", "Leased Todo update requires the current claim owner"); } const status = input.planning_intent?.status; + if (lease !== undefined && TODO_WORK_REQUIREMENT_FIELDS.some(field => + Object.hasOwn(input.planning_intent ?? {}, field))) { + return failure("update_lease_requirements_transition_unsupported", + "Changing leased work requirements requires a new execution grant; metadata update leaves the lease unchanged"); + } if (lease !== undefined && typeof status === "string" && status.toLowerCase() !== todo.status) { return failure("update_lease_status_transition_unsupported", "Changing a leased Todo status requires an atomic lifecycle operation; planning update leaves the lease unchanged"); diff --git a/loopx/control_plane/quota/monitor_poll_commit.ts b/loopx/control_plane/quota/monitor_poll_commit.ts index ec05ef6182..c120f4baa3 100644 --- a/loopx/control_plane/quota/monitor_poll_commit.ts +++ b/loopx/control_plane/quota/monitor_poll_commit.ts @@ -1,7 +1,8 @@ import { createHash } from "node:crypto"; import { access, readFile, rm } from "node:fs/promises"; import { basename, dirname, join, resolve } from "node:path"; -import { monitorSuccessorIntent, monitorSuccessorRoute, monitorSuccessorCapabilities } from "../scheduler/monitor_successor.ts"; +import { monitorSuccessorIntent, monitorSuccessorRoute } from "../scheduler/monitor_successor.ts"; +import { normalizeTodoCapabilities } from "../todos/work_requirements.ts"; import type { JsonObject } from "../effect_program.ts"; import { EffectRuntimeRequestError } from "../effect_runtime_errors.ts"; @@ -868,8 +869,8 @@ function requireProviderCapabilityMatch( expected: readonly string[], label: string, ): void { - const actualCapabilities = monitorSuccessorCapabilities(actual, label); - const expectedCapabilities = monitorSuccessorCapabilities(expected, label); + const actualCapabilities = normalizeTodoCapabilities(actual, label); + const expectedCapabilities = normalizeTodoCapabilities(expected, label); if (pythonJson(actualCapabilities) !== pythonJson(expectedCapabilities)) { throw new EffectRuntimeRequestError(`${label} must match provider plan`); } diff --git a/loopx/control_plane/scheduler/monitor_successor.ts b/loopx/control_plane/scheduler/monitor_successor.ts index 7431728c0b..9ef197496e 100644 --- a/loopx/control_plane/scheduler/monitor_successor.ts +++ b/loopx/control_plane/scheduler/monitor_successor.ts @@ -3,8 +3,9 @@ import { createHash } from "node:crypto"; import type { JsonObject } from "../effect_program.ts"; import { EffectRuntimeRequestError } from "../effect_runtime_errors.ts"; -import { optionalNonEmptyString, requireBoolean, requireJsonObject, requireStringArray } from "../runtime_decode.ts"; -import { compactPythonWhitespace, normalizeTodoAgent, stripPythonWhitespace } from "../coordination/todo_agents.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"; export const MONITOR_SUCCESSOR_REQUEST_SCHEMA = "loopx_monitor_successor_plan_request_v0"; export const MONITOR_SUCCESSOR_RESULT_SCHEMA = "loopx_monitor_successor_plan_result_v0"; @@ -47,55 +48,7 @@ function text(value: unknown, field: string): string | null { return raw === null ? null : stripPythonWhitespace(raw) || null; } -// The node-independent repository/bootstrap codec remains in repository_identity.py. -// This pure transport codec is characterized against that public contract; do -// not use WHATWG's normalized pathname, which silently removes dot segments. -function repository(value: unknown): string | null { - let raw = text(value, "next_task_repository"); - if (!raw) return null; - if (/[\\\s\u0000-\u001f\u007f]/u.test(raw)) { - throw new EffectRuntimeRequestError("--next-task-repository must be a credential-free Git remote without control characters or backslashes"); - } - let host: string, path: string; - const canonical = /^git:([a-z0-9.-]+(?::[0-9]{1,5})?)\/([A-Za-z0-9._~+/-]+)$/.exec(raw); - if (canonical) [host, path] = [canonical[1], canonical[2]]; - else { - const scp = /^(?:[^@/]+@)?([^:/]+):(.+)$/.exec(raw); - if (scp && !raw.includes("://")) raw = `ssh://${scp[1]}/${scp[2]}`; - try { - const url = new URL(raw); - if (!["git:", "http:", "https:", "ssh:"].includes(url.protocol) || - !url.hostname || url.password || url.search || url.hash) throw new Error(); - host = url.hostname.toLowerCase().replace(/^\[|\]$/g, ""); - const port = Number(url.port); - if (port && !((["http:", "git:"].includes(url.protocol) && port === 80) || - (["https:", "ssh:"].includes(url.protocol) && [22, 443].includes(port)))) host += `:${port}`; - const pathMatch = /^[^:]+:\/\/[^/?#]*([^?#]*)/.exec(raw); - if (!pathMatch) throw new Error(); - path = pathMatch[1]; - } catch { - throw new EffectRuntimeRequestError("--next-task-repository must be a credential-free Git remote or canonical git:/ identity"); - } - } - path = path.replace(/\/+/g, "/").replace(/^\/+|\/+$/g, "").replace(/\.git$/, ""); - if (!/^[A-Za-z0-9._~+/-]+$/.test(path) || !/^[a-z0-9.-]+(?::[0-9]{1,5})?$/.test(host) || - path.split("/").some(part => part === "." || part === "..")) { - throw new EffectRuntimeRequestError("--next-task-repository must include a safe repository path"); - } - return `git:${host}/${path}`; -} - -export function monitorSuccessorCapabilities(value: unknown, label: string): string[] { - const result: string[] = []; - for (const raw of requireStringArray(value ?? [], label)) { - const token = compactPythonWhitespace(raw).toLowerCase().replaceAll("-", "_").replaceAll(" ", "_"); - if (!/^[a-z][a-z0-9_:-]{0,63}$/.test(token)) { - throw new EffectRuntimeRequestError(`${label} must contain public-safe capability tokens; invalid entries cannot be dropped`); - } - if (!result.includes(token)) result.push(token); - } - return result; -} +// Shared with public Todo metadata updates; no second route codec here. export interface MonitorSuccessorIntent extends JsonObject { next_agent_todo: string | null; @@ -121,8 +74,8 @@ export function monitorSuccessorIntent(value: unknown): MonitorSuccessorIntent { const policy = text(input.next_continuation_policy, "next_continuation_policy")?.toLowerCase() ?? null; const target = text(input.next_target_key, "next_target_key"); const claim = text(input.next_claimed_by, "next_claimed_by"); - const repo = repository(input.next_task_repository); - const capabilities = monitorSuccessorCapabilities(input.next_required_capabilities, "--next-required-capability"); + const repo = normalizeTodoRepository(input.next_task_repository, "--next-task-repository"); + const capabilities = normalizeTodoCapabilities(input.next_required_capabilities, "--next-required-capability"); if (!agentTodo && (action || policy || target || claim || repo || capabilities.length)) { throw new EffectRuntimeRequestError("monitor successor routing options require --next-agent-todo"); } diff --git a/loopx/control_plane/todos/native_update_plan.ts b/loopx/control_plane/todos/native_update_plan.ts index 20a06d3490..2ec1cd3244 100644 --- a/loopx/control_plane/todos/native_update_plan.ts +++ b/loopx/control_plane/todos/native_update_plan.ts @@ -6,10 +6,11 @@ import { AuthorityStoreProtocolError } from "../coordination/authority_store_cod import { compactPythonWhitespace } from "../coordination/todo_agents.ts"; import { normalizeTodoId } from "../work_items/task_lease_acquire.ts"; import { planPublicTodoUpdate, TODO_PUBLIC_UPDATE_REQUEST_SCHEMA } from "./public_update.ts"; +import { normalizeTodoWorkRequirements, TODO_WORK_REQUIREMENT_FIELDS } from "./work_requirements.ts"; const STRINGS = new Set(["status", "evidence", "reason", "resume_when", "unblocks_todo_id"]); const BOOLEANS = new Set(["clear_resume_when", "no_followup"]); -const FIELDS = new Set([...STRINGS, ...BOOLEANS, "successor_todo_ids"]); +const FIELDS = new Set([...STRINGS, ...BOOLEANS, "successor_todo_ids", ...TODO_WORK_REQUIREMENT_FIELDS]); /** A separate intent namespace preserves the shipped text/note patch and its * historical receipt encoding. Raw field patches do not gain new authority. */ @@ -19,6 +20,7 @@ export function normalizeNativePlanningIntent(value: unknown): JsonObject { const intent: JsonObject = {}; for (const [field, value] of Object.entries(raw)) { if (!FIELDS.has(field)) throw new AuthorityStoreProtocolError(`Todo planning update does not own ${field}`); + if ((TODO_WORK_REQUIREMENT_FIELDS as readonly string[]).includes(field)) continue; if (value === null) continue; if (STRINGS.has(field)) { if (typeof value !== "string") throw new AuthorityStoreProtocolError(`${field} must be a string`); @@ -32,7 +34,7 @@ export function normalizeNativePlanningIntent(value: unknown): JsonObject { intent[field] = [...new Set(value.map(item => normalizeTodoId(item, "successor_todo_id")))]; } } - return intent; + return {...intent, ...normalizeTodoWorkRequirements(raw)}; } export function planNativeTodoUpdate(todo: JsonObject, intent: JsonObject, diff --git a/loopx/control_plane/todos/public_update.ts b/loopx/control_plane/todos/public_update.ts index 1c4bf4789e..ac26ba887a 100644 --- a/loopx/control_plane/todos/public_update.ts +++ b/loopx/control_plane/todos/public_update.ts @@ -6,6 +6,7 @@ import { EffectRuntimeRequestError } from "../effect_runtime_errors.ts"; import { planTodoAuthoringScope, TODO_AUTHORING_SCOPE_REQUEST_SCHEMA } from "./authoring_scope.ts"; import { planTodoFieldUpdate, TODO_FIELD_UPDATE_REQUEST_SCHEMA } from "./field_update.ts"; import { planTodoExternalWaitTransition, TODO_EXTERNAL_WAIT_REQUEST_SCHEMA_VERSION } from "./resume_condition.ts"; +import { normalizeTodoWorkRequirements, TODO_WORK_REQUIREMENT_FIELDS } from "./work_requirements.ts"; export const TODO_PUBLIC_UPDATE_REQUEST_SCHEMA = "todo_public_update_request_v0"; @@ -42,7 +43,10 @@ export function planPublicTodoUpdate(value: unknown): JsonObject { throw new EffectRuntimeRequestError("public Todo update schema mismatch"); } const todo = requireJsonObject(request.todo, "public Todo update source"); - const intent = requireJsonObject(request.intent, "public Todo update intent"); + const rawIntent = requireJsonObject(request.intent, "public Todo update intent"); + const intent: JsonObject = {...rawIntent}; + for (const field of TODO_WORK_REQUIREMENT_FIELDS) delete intent[field]; + Object.assign(intent, normalizeTodoWorkRequirements(rawIntent)); const context = requireJsonObject(request.context, "public Todo update context"); const scope = planTodoAuthoringScope({schema_version: TODO_AUTHORING_SCOPE_REQUEST_SCHEMA, command: "update", role: context.role, todo, diff --git a/loopx/control_plane/todos/work_requirements.ts b/loopx/control_plane/todos/work_requirements.ts new file mode 100644 index 0000000000..97db9ed688 --- /dev/null +++ b/loopx/control_plane/todos/work_requirements.ts @@ -0,0 +1,106 @@ +/** Work declaration codecs shared by public updates and Monitor successors. + * These validate requirements, never grant capabilities or write authority. */ +import type { JsonObject } from "../effect_program.ts"; +import { EffectRuntimeRequestError } from "../effect_runtime_errors.ts"; +import { optionalNonEmptyString, requireStringArray } from "../runtime_decode.ts"; +import { compactPythonWhitespace, stripPythonWhitespace } from "../coordination/todo_agents.ts"; +import { normalizeWriteScopes } from "../work_items/task_lease_acquire.ts"; + +function optionalText(value: unknown, label: string): string | null { + const raw = optionalNonEmptyString(value, label); + return raw === null ? null : stripPythonWhitespace(raw) || null; +} + +// The node-independent repository/bootstrap codec remains in repository_identity.py. +// This pure transport codec is characterized against that public contract; do +// not use WHATWG's normalized pathname, which silently removes dot segments. +export function normalizeTodoRepository(value: unknown, label = "task_repository"): string | null { + let raw = optionalText(value, label); + if (!raw) return null; + if (/[\\\s\u0000-\u001f\u007f]/u.test(raw)) { + throw new EffectRuntimeRequestError(`${label} must be a credential-free Git remote without control characters or backslashes`); + } + let host: string, path: string; + const canonical = /^git:([a-z0-9.-]+(?::[0-9]{1,5})?)\/([A-Za-z0-9._~+/-]+)$/.exec(raw); + if (canonical) [host, path] = [canonical[1], canonical[2]]; + else { + const scp = /^(?:[^@/:]+@)?([^:/]+):(.+)$/.exec(raw); + if (scp && !raw.includes("://")) raw = `ssh://${scp[1]}/${scp[2]}`; + try { + const url = new URL(raw); + if (!["git:", "http:", "https:", "ssh:"].includes(url.protocol) || + !url.hostname || url.password || url.search || url.hash) throw new Error(); + host = url.hostname.toLowerCase().replace(/^\[|\]$/g, ""); + const port = Number(url.port); + if (port && !((["http:", "git:"].includes(url.protocol) && port === 80) || + (["https:", "ssh:"].includes(url.protocol) && [22, 443].includes(port)))) host += `:${port}`; + const pathMatch = /^[^:]+:\/\/[^/?#]*([^?#]*)/.exec(raw); + if (!pathMatch) throw new Error(); + path = pathMatch[1]; + } catch { + throw new EffectRuntimeRequestError(`${label} must be a credential-free Git remote or canonical git:/ identity`); + } + } + path = path.replace(/\/+/g, "/").replace(/^\/+|\/+$/g, "").replace(/\.git$/, ""); + if (!/^[A-Za-z0-9._~+/-]+$/.test(path) || !/^[a-z0-9.-]+(?::[0-9]{1,5})?$/.test(host) || + path.split("/").some(part => part === "." || part === "..")) { + throw new EffectRuntimeRequestError(`${label} must include a safe repository path`); + } + return `git:${host}/${path}`; +} + +export function normalizeTodoCapabilities(value: unknown, label: string): string[] { + const result: string[] = []; + for (const raw of requireStringArray(value ?? [], label)) { + const token = compactPythonWhitespace(raw).toLowerCase().replaceAll("-", "_").replaceAll(" ", "_"); + if (!/^[a-z][a-z0-9_:-]{0,63}$/.test(token)) { + throw new EffectRuntimeRequestError(`${label} must contain public-safe capability tokens; invalid entries cannot be dropped`); + } + if (!result.includes(token)) result.push(token); + } + return result; +} + + +export const TODO_WORK_REQUIREMENT_FIELDS = [ + "action_kind", "task_domain", "task_repository", "required_write_scopes", + "required_capabilities", "target_capabilities", "explore_result_node_refs", +] as const; + +/** Normalize only explicitly supplied edits. [] is a clear; omitted/blank + * scalar text is unchanged. Reject invalid members, not just invalid totals. */ +export function normalizeTodoWorkRequirements(intent: JsonObject): JsonObject { + const result: JsonObject = {}; + for (const field of TODO_WORK_REQUIREMENT_FIELDS) { + const value = intent[field]; + if (value === undefined || value === null) continue; + if (field === "required_capabilities" || field === "target_capabilities") { + result[field] = normalizeTodoCapabilities(value, field); + } else if (field === "required_write_scopes") { + const values = requireStringArray(value, field); + const normalized = normalizeWriteScopes(values); + if (values.some(raw => !normalizeWriteScopes([raw]).length)) { + throw new EffectRuntimeRequestError("required_write_scopes must contain valid relative scope tokens; invalid entries cannot be dropped"); + } + result[field] = normalized; + } else if (field === "explore_result_node_refs") { + const refs = [...new Set(requireStringArray(value, field).map(compactPythonWhitespace))]; + if (refs.length > 8 || refs.some(ref => !/^[A-Za-z][A-Za-z0-9_.:-]{0,95}$/.test(ref))) { + throw new EffectRuntimeRequestError("explore_result_node_refs requires at most eight valid Explore node ids"); + } + result[field] = refs; + } else { + if (typeof value !== "string") throw new EffectRuntimeRequestError(`${field} must be a string`); + const text = stripPythonWhitespace(value); + if (!text) continue; + if (field === "task_repository") result[field] = normalizeTodoRepository(text); + else { + const normalized = text.toLowerCase(); + const pattern = field === "action_kind" ? /^[a-z][a-z0-9_-]{0,63}$/ : /^[a-z][a-z0-9_.-]{0,63}$/; + if (!pattern.test(normalized)) throw new EffectRuntimeRequestError(`${field} must be a public-safe token`); + result[field] = normalized; + } + } + } + return result; +} diff --git a/loopx/todos.py b/loopx/todos.py index 95a778bae7..27ed09f3fa 100644 --- a/loopx/todos.py +++ b/loopx/todos.py @@ -1119,15 +1119,17 @@ def update_goal_todo( "resume_when": resume_when, "clear_resume_when": clear_resume_when or None, "unblocks_todo_id": unblocks_todo_id, "successor_todo_ids": successor_todo_ids, "no_followup": no_followup, + "action_kind": action_kind, "task_domain": task_domain, + "task_repository": task_repository, "required_write_scopes": required_write_scopes, + "required_capabilities": required_capabilities, "target_capabilities": target_capabilities, + "explore_result_node_refs": explore_result_node_refs, }.items() if value is not None} if not claim_only and (text is not None or note is not None or planning_intent) and not any(( monitor_metadata, goal_bound, clear_blocks_agent, clear_excluded_agents, global_gate, clear_global_gate, clear_claim, authority_reason, )) and all(value is None for value in ( - task_class, action_kind, task_domain, - task_repository, continuation_policy, required_write_scopes, - required_capabilities, target_capabilities, explore_result_node_refs, + task_class, continuation_policy, decision_scope, required_decision_scopes, claimed_by, bound_agent, blocks_agent, excluded_agents, authority_reason, )): diff --git a/tests/control_plane/test_native_todo_planning_update.py b/tests/control_plane/test_native_todo_planning_update.py index 4897794dfa..9a44747d1c 100644 --- a/tests/control_plane/test_native_todo_planning_update.py +++ b/tests/control_plane/test_native_todo_planning_update.py @@ -58,6 +58,80 @@ def records(registry: Path) -> dict[str, dict]: return {item["todo_id"]: item for item in list_goal_todos(registry_path=registry, goal_id="goal-a")["todos"]} +@pytest.mark.parametrize("promoted", [False, True]) +def test_work_requirements_update_preserves_authority_and_supports_explicit_empty(tmp_path: Path, promoted: bool) -> None: + registry, _state = fixture(tmp_path, promoted) + before = records(registry) + result = update_goal_todo( + registry_path=registry, goal_id="goal-a", todo_id="todo_target", agent_id="agent-a", + action_kind="IMPLEMENT", task_domain="Code.Review", + task_repository="git@github.com:example/project.git", + required_capabilities=["Code-Review", "code_review"], + target_capabilities=["Delivery"], required_write_scopes=["src/**", "tests/**"], + explore_result_node_refs=["Node:alpha"], + ) + assert result["ok"] is True + todo = records(registry)["todo_target"] + assert todo["action_kind"] == "implement" + assert todo["task_domain"] == "code.review" + assert todo["task_repository"] == "git:github.com/example/project" + assert todo["required_capabilities"] == ["code_review"] + assert todo["target_capabilities"] == ["delivery"] + assert todo["required_write_scopes"] == ["src/**", "tests/**"] + assert todo["explore_result_node_refs"] == ["Node:alpha"] + assert todo["claimed_by"] == "agent-a" + assert records(registry)["todo_other"] == before["todo_other"] + update_goal_todo(registry_path=registry, goal_id="goal-a", todo_id="todo_target", + agent_id="agent-a", required_capabilities=[], required_write_scopes=[], + target_capabilities=[], explore_result_node_refs=[]) + cleared = records(registry)["todo_target"] + for field in ("required_capabilities", "target_capabilities", "required_write_scopes", "explore_result_node_refs"): + assert not cleared.get(field) + assert cleared["task_repository"] == todo["task_repository"] + + +@pytest.mark.parametrize("promoted", [False, True]) +@pytest.mark.parametrize("intent", [ + {"required_capabilities": ["code_review", "bad/token"]}, + {"required_write_scopes": ["src/**", "../escape"]}, + {"task_repository": "https://user:password@example.com/project"}, + {"task_repository": "user:password@example.com:project"}, + {"explore_result_node_refs": ["Node:alpha", "bad/ref"]}, +]) +def test_invalid_work_requirement_is_not_silently_dropped(tmp_path: Path, promoted: bool, intent: dict) -> None: + registry, state = fixture(tmp_path, promoted) + before = records(registry) + with pytest.raises((ValueError, RuntimeError)): + update_goal_todo(registry_path=registry, goal_id="goal-a", todo_id="todo_target", + agent_id="agent-a", text="Must not partially commit", **intent) + assert records(registry) == before + if promoted: + assert not state.exists() + + +@pytest.mark.parametrize("promoted", [False, True]) +def test_cli_routes_work_requirements_without_display_dependency(tmp_path: Path, promoted: bool) -> None: + registry, state = fixture(tmp_path, promoted) + args = ["--action-kind", "IMPLEMENT", "--task-domain", "code", + "--task-repository", "https://github.com/example/project", + "--required-capability", "Code-Review", "--target-capability", "Delivery", + "--required-write-scope", "src/**", "--explore-result-node-ref", "Node:alpha"] + if promoted: + args += ["--update-operation-id", "requirements-cli"] + update(registry, *args, "--dry-run") + if promoted: + assert not state.exists() + update(registry, *args) + assert records(registry)["todo_target"]["required_capabilities"] == ["code_review"] + if promoted: + assert update(registry, *args)["status"] == "replayed" + update(registry, "--clear-explore-result-node-refs") + assert not records(registry)["todo_target"].get("explore_result_node_refs") + before = records(registry) + update(registry, "--required-capability", "valid", "--required-capability", "bad/token", ok=False) + assert records(registry) == before + + @pytest.mark.parametrize("promoted", [False, True]) @pytest.mark.parametrize("surface", ["cli", "python_api"]) @pytest.mark.parametrize(("label", "note", "expected_note"), [ diff --git a/tests/control_plane_ts/monitor_successor.test.ts b/tests/control_plane_ts/monitor_successor.test.ts index 4b9126490c..0e7fe9af41 100644 --- a/tests/control_plane_ts/monitor_successor.test.ts +++ b/tests/control_plane_ts/monitor_successor.test.ts @@ -53,7 +53,8 @@ test("repository transport aliases match the retained node-independent codec", ( test("unsafe repository routes cannot be silently repaired by URL parsing", () => { for (const repo of ["https://github.com/example/../other", "git:github.com/example/./repo", - "https://user:password@example.invalid/repo", "https://example.invalid/repo?credential=value", + "https://user:password@example.invalid/repo", "user:password@example.invalid:repo", + "https://example.invalid/repo?credential=value", "https://example.invalid/repo#fragment", "file:///repo", "https://example.invalid/", "not a repository", "https://example.invalid\\other/repo", "https://example.invalid/a%2fb", "https://example.invalid/a b"]) { assert.throws(() => plan({next_task_repository: repo})); diff --git a/tests/control_plane_ts/native_planning_update_conformance.ts b/tests/control_plane_ts/native_planning_update_conformance.ts index a106c47565..62dcbfaf49 100644 --- a/tests/control_plane_ts/native_planning_update_conformance.ts +++ b/tests/control_plane_ts/native_planning_update_conformance.ts @@ -41,7 +41,12 @@ export function registerNativePlanningUpdateConformance(provider: string, factor const request = {goal_id: goal, todo_id: "todo_aaa_target", expected_role: "agent", actor_agent_id: "agent-a", registered_agents: ["agent-a", "agent-b"], operation_id: "wait", patch: {text: "Synthetic planning record"}, clear_fields: [], - planning_intent: {resume_when: "monitor_changed:todo_zzz_monitor", reason: "Await material change"}, + planning_intent: {resume_when: "monitor_changed:todo_zzz_monitor", reason: "Await material change", + action_kind: "IMPLEMENT", task_domain: "Code.Review", + task_repository: "git@github.com:example/project.git", + required_capabilities: ["Code-Review", "code_review"], + target_capabilities: ["Delivery"], required_write_scopes: ["src/**"], + explore_result_node_refs: ["Node:alpha"]}, dry_run: false, now: new Date("2026-09-10T00:00:00Z")}; const before = await head(store); const preview = await executeCoordinationTodoUpdate(store, {...request, dry_run: true}); @@ -52,6 +57,13 @@ export function registerNativePlanningUpdateConformance(provider: string, factor let current = await head(store); let target = (current.head.todos as JsonObject[])[0]!; assert.equal(target.resume_monitor_generation, 7); + assert.equal(target.action_kind, "implement"); + assert.equal(target.task_repository, "git:github.com/example/project"); + assert.equal(target.task_domain, "code.review"); + assert.deepEqual(target.required_capabilities, ["code_review"]); + assert.deepEqual(target.target_capabilities, ["delivery"]); + assert.deepEqual(target.required_write_scopes, ["src/**"]); + assert.deepEqual(target.explore_result_node_refs, ["Node:alpha"]); assert.deepEqual((current.head.todos as JsonObject[]).slice(1), todos.slice(1)); // Simulate an independent observation at a new canonical revision. const observed = structuredClone(current.head); @@ -81,7 +93,8 @@ export function registerNativePlanningUpdateConformance(provider: string, factor commitAuthority: async input => {await store.commitAuthority(input); return { status: "ambiguous", reason_code: "lost_response", reason: "Synthetic lost response"};}}; const cleared = await executeCoordinationTodoUpdate(lostResponse, {...request, operation_id: "clear", - planning_intent: {clear_resume_when: true, successor_todo_ids: [], no_followup: false}}); + planning_intent: {clear_resume_when: true, successor_todo_ids: [], no_followup: false, + required_capabilities: [], target_capabilities: [], required_write_scopes: [], explore_result_node_refs: []}}); assert.equal(cleared.status, "recovered", JSON.stringify(cleared)); target = ((await head(store)).head.todos as JsonObject[])[0]!; assert.equal(target.resume_when, undefined); @@ -90,6 +103,26 @@ export function registerNativePlanningUpdateConformance(provider: string, factor assert.equal(target.no_followup, false); assert.equal(target.claimed_by, "agent-a"); assert.equal(target.note, "retained"); + assert.deepEqual(target.required_capabilities, []); + const aliasReplay = await executeCoordinationTodoUpdate(store, {...request, planning_intent: { + ...request.planning_intent, required_capabilities: ["code_review"], + task_repository: "https://github.com/example/project", action_kind: "implement", + }}); + assert.equal(aliasReplay.status, "replayed", JSON.stringify(aliasReplay)); + assert.deepEqual(((await head(store)).head.todos as JsonObject[])[0], target, + "replaying the original declaration must not restore requirements subsequently cleared"); + for (const planning_intent of [ + {required_capabilities: ["valid", "bad/token"]}, {required_write_scopes: ["src/**", "../escape"]}, + {task_repository: "https://user:password@example.com/project"}, + {decision_outcome: "approve"}, {required_decision_scopes: []}, + ]) { + const beforeInvalid = await head(store); + const result = await executeCoordinationTodoUpdate(store, {...request, + operation_id: "invalid-requirement", planning_intent}); + assert.equal(result.status, "failed", JSON.stringify(result)); + assert.deepEqual(await head(store), beforeInvalid); + assert.equal((await store.readReceipt("invalid-requirement")).status, "missing"); + } // Force a canonical-head race between plan and CAS; do not recompute against a stale dependency. const racing: AuthorityStore = {...lostResponse, commitAuthority: async input => { @@ -132,6 +165,7 @@ export function registerNativePlanningUpdateConformance(provider: string, factor for (const [operation_id, change] of [ ["no-proof", {lease_idempotency_key: null, lease_expected_version: null}], ["status-change", {planning_intent: {status: "deferred", resume_when: "pr_merged:#123"}}], + ["requirements-change", {planning_intent: {required_write_scopes: ["new/**"]}}], ["wrong-owner", {actor_agent_id: "agent-b"}], ] as const) { const result = await executeCoordinationTodoUpdate(store, {...request, operation_id, ...change}); diff --git a/tests/control_plane_ts/production_scale_coordination_fixture.ts b/tests/control_plane_ts/production_scale_coordination_fixture.ts index c11af5b937..27a3f6c9ab 100644 --- a/tests/control_plane_ts/production_scale_coordination_fixture.ts +++ b/tests/control_plane_ts/production_scale_coordination_fixture.ts @@ -89,6 +89,14 @@ function todoRecords( if (role === "agent" && status !== "done" && status !== "deferred") { record.claimed_by = index % 2 === 0 ? "agent-a" : "agent-b"; } + if (role === "agent" && record.task_class === "advancement_task") { + // Full requirement declarations survive unrelated transitions and archive; + // editing a declaration must not change an existing execution grant. + Object.assign(record, {action_kind: "implement", task_domain: "code", + task_repository: "git:github.com/example/project", + required_write_scopes: ["src/**", "tests/**"], required_capabilities: ["code_review"], + target_capabilities: ["delivery"], explore_result_node_refs: [`Node:fixture-${index}`]}); + } if (record.task_class === "continuous_monitor") { // Durable mixed-source observation shapes: bounded and watch-only, // untouched and previously changed, with cadence and retained generation. diff --git a/tests/control_plane_ts/todo_work_requirements.test.ts b/tests/control_plane_ts/todo_work_requirements.test.ts new file mode 100644 index 0000000000..78ef8a2fec --- /dev/null +++ b/tests/control_plane_ts/todo_work_requirements.test.ts @@ -0,0 +1,44 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import {normalizeTodoWorkRequirements} from "../../loopx/control_plane/todos/work_requirements.ts"; +import {planPublicTodoUpdate, TODO_PUBLIC_UPDATE_REQUEST_SCHEMA} from "../../loopx/control_plane/todos/public_update.ts"; + +test("work requirements distinguish omission, blank scalar and explicit empty collections", () => { + assert.deepEqual(normalizeTodoWorkRequirements({task_domain: "", action_kind: " \t ", + task_repository: null, required_capabilities: null}), {}); + assert.deepEqual(normalizeTodoWorkRequirements({required_capabilities: [], target_capabilities: [], + required_write_scopes: [], explore_result_node_refs: []}), + {required_capabilities: [], target_capabilities: [], required_write_scopes: [], explore_result_node_refs: []}); +}); + +for (const intent of [ + {required_capabilities: "code_review"}, {required_capabilities: ["good", 1]}, + {required_capabilities: ["good", "bad/token"]}, {target_capabilities: ["good", ""]}, + {required_write_scopes: ["src/**", "/absolute"]}, {required_write_scopes: ["src/**", "../escape"]}, + {required_write_scopes: ["src/**", "has space"]}, + {explore_result_node_refs: ["Node:ok", "bad/ref"]}, + {explore_result_node_refs: Array.from({length: 9}, (_, i) => `Node:${i}`)}, + {action_kind: "invalid action"}, {task_domain: "invalid/domain"}, + {task_repository: "https://user:password@example.com/project"}, + {task_repository: "user:password@example.com:project"}, +]) { + test(`invalid declaration is rejected in full: ${JSON.stringify(intent)}`, () => { + assert.throws(() => normalizeTodoWorkRequirements(intent)); + }); +} + +test("metadata correction neither validates unrelated historic declarations nor re-arms a retained wait", () => { + const todo = {todo_id: "todo_target", role: "agent", status: "open", + task_class: "advancement_task", required_capabilities: ["legacy/invalid"], + resume_when: "monitor_changed:todo_monitor", resume_monitor_generation: 7}; + const result = planPublicTodoUpdate({schema_version: TODO_PUBLIC_UPDATE_REQUEST_SCHEMA, + todo, intent: {required_capabilities: ["Code Review"]}, updated_at: "2026-09-12T00:00:00Z", + context: {goal_id: "goal-a", role: "agent", actor_agent_id: "agent-a", enforce_monitor_boundedness: true, + registered_agents: ["agent-a"], items: [{todo_id: "todo_monitor", role: "agent", + status: "open", task_class: "continuous_monitor", material_change_generation: 8}]}}); + const updates = result.metadata_updates as Record; + assert.deepEqual(updates.required_capabilities, ["code_review"]); + assert.equal(Object.hasOwn(updates, "resume_when"), false); + assert.equal(Object.hasOwn(updates, "resume_monitor_generation"), false); + assert.deepEqual(todo.required_capabilities, ["legacy/invalid"]); +}); From 086a053060d324c258fc21c37ca127d6150f27d5 Mon Sep 17 00:00:00 2001 From: huangruiteng Date: Sat, 12 Sep 2026 00:31:37 +0800 Subject: [PATCH 2/3] docs(rfc): record work requirement closure and remaining authority boundaries Signed-off-by: huangruiteng --- ...shared-goal-authority-state-provider-v0.md | 7 +++++++ ...-goal-authority-state-provider-v0.zh-CN.md | 5 +++++ .../typescript-control-plane-migration-v0.md | 21 +++++++++++++++++++ ...script-control-plane-migration-v0.zh-CN.md | 15 +++++++++++++ .../active-state-structured-projection-v0.md | 14 ++++++++++++- 5 files changed, 61 insertions(+), 1 deletion(-) diff --git a/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.md b/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.md index 9ce7e05802..877b15a7e9 100644 --- a/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.md +++ b/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.md @@ -2698,6 +2698,13 @@ lease status changes and Monitor planning/effects remain unsupported. Neither an admission result nor a lease-fence result is a commit receipt. Keep provider CAS/replay and existing writer lock lifetimes unchanged while collecting this deletion payoff. +The same transaction now accepts bounded work-requirement declarations through +the shared public TS planner (field list and intentional rejection changes are +in T1). File, NoKV, SQLite and PostgreSQL conformance exercise aliases, explicit +clear, replay after a later edit, invalid-input atomicity and lease rejection. +The production-scale fixture carries requirements across unrelated lifecycle +operations. This does not qualify a new profile, widen an execution grant, or +change D1–D3/promotion holds; Markdown remains an independent permanent projection. Waiting/resume lane selection is now one TS read-policy owner shared by quota, vision-wait, agent-scope and replan. The obsolete Python selector module is deleted; the adapter accepts the same canonical summary after promotion and diff --git a/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.zh-CN.md b/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.zh-CN.md index 197fcb6a5b..b918713a4c 100644 --- a/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.zh-CN.md +++ b/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.zh-CN.md @@ -2125,6 +2125,11 @@ wire,不改变 provider 默认或 promotion。这仍是有界的非 terminal p 不是通用 native metadata 支持;Active lease 下的状态变化及 Monitor 规划/effect 仍不 支持。准入结果和 lease-fence 结果都不是 commit receipt;兑现删除收益时,provider CAS/replay 与既有 writer 持锁生命周期不变。 +同一事务现通过共享公开 TS planner 接受有界工作要求声明,字段清单和有意拒绝变化见 +T1。File、NoKV、SQLite 与 PostgreSQL conformance 覆盖别名、显式清空、后续编辑后 +的旧操作重放、非法输入原子性和 lease 拒绝;复杂容量 fixture 携带工作要求验证其他 +lifecycle 操作不会丢字段。这不资格化新 profile、不扩大 execution grant,也不改变 +D1–D3/promotion hold;Markdown 继续作为独立的永久投影。 等待/恢复 lane 选择现由 quota、vision-wait、agent-scope、replan 共用一个 TS 读取 策略 owner,删除旧 Python selector 模块。适配层在 promotion 后消费同一 canonical summary,之前消费 legacy summary;真实 CLI 覆盖容量变化和 promoted display diff --git a/docs/architecture/rfcs/typescript-control-plane-migration-v0.md b/docs/architecture/rfcs/typescript-control-plane-migration-v0.md index 48d12a57bc..6fb4f9665c 100644 --- a/docs/architecture/rfcs/typescript-control-plane-migration-v0.md +++ b/docs/architecture/rfcs/typescript-control-plane-migration-v0.md @@ -420,6 +420,27 @@ atomic follow-up are not fully closed. Lease-edit PR #4152 is merged; bounded planning updates now reuse that fence and the existing CAS/receipt transaction. Continue with the remaining field/effect inventory, not another update engine. +Work-requirement editing is now closed for non-Monitor Agent Todos without a +retained lease: `action_kind`, `task_domain`, `task_repository`, +`required_write_scopes`, `required_capabilities`, `target_capabilities` and +`explore_result_node_refs` use the existing v1 planning transaction. Public +legacy edits and native planning share `todos/work_requirements.ts`; Monitor +successor authoring and receipt verification reuse its repository/capability +codecs instead of retaining scheduler-owned copies. No new RPC or store is added. +Omitted/blank scalar input preserves state; explicit empty collections clear +requirements. Deliberate correction: invalid members, unsafe repository routes +and over-capacity Explore references reject the whole public update rather than +silently dropping requirements or truncating references. +SCP-style password-bearing userinfo is rejected too, including Monitor successor +routes; username-only Git transports remain valid. Unrelated historical +fields are not revalidated by a copy edit. Repository/capability aliases retain +one normalized replay identity. Requirements declare needed work, not a grant: +ownership, decision outcomes, generic raw patches, Monitor edits and leased +requirement changes remain fenced. The Python reader/bootstrap codec and legacy +writer still have real callers; this slice does not retire them or complete T1. +Next close ownership/decision metadata with their lifecycle admission and +validation effects, then the remaining leased Monitor transaction in T2. + - Reuse the current provider text/note transaction, lifecycle admission, field-plan and completion rules. Enumerate actual public metadata edits and explicit-clear behavior before implementation; this is not permission to diff --git a/docs/architecture/rfcs/typescript-control-plane-migration-v0.zh-CN.md b/docs/architecture/rfcs/typescript-control-plane-migration-v0.zh-CN.md index a33d6740ef..a38c3b3d87 100644 --- a/docs/architecture/rfcs/typescript-control-plane-migration-v0.zh-CN.md +++ b/docs/architecture/rfcs/typescript-control-plane-migration-v0.zh-CN.md @@ -325,6 +325,21 @@ field codec 仍有真实 caller,不引入公开 update 限制。Native metadat T2 原子后续动作尚未全部闭合。Lease-edit PR #4152 已合入;有界规划更新复用该 fence 及既有 CAS/receipt 事务。下一步继续剩余字段/effect 清单,不另建 update engine。 +工作要求编辑现已闭合:没有保留 lease 的非 Monitor Agent Todo,可通过既有 v1 +planning 事务更新 `action_kind`、`task_domain`、`task_repository`、 +`required_write_scopes`、`required_capabilities`、`target_capabilities` 和 +`explore_result_node_refs`。公开 legacy 编辑与 native planning 共用 +`todos/work_requirements.ts`;Monitor successor authoring 与 receipt verification +复用其仓库/capability codec,删除 scheduler 私有副本,不增加 RPC 或 store。 +省略/空白标量保留原值,显式空集合清除要求。有意修正:非法成员、不安全仓库和超出 +容量的 Explore 引用使整笔公开更新拒绝,不再静默丢掉要求或截断引用;纯文案编辑不会 +重新审查无关历史字段。SCP 风格的含密码 userinfo 同样拒绝,包括 Monitor 后继路由; +仅带用户名的 Git transport 仍合法。仓库/capability 别名保持同一规范化 replay identity。 +要求不是授权:ownership、决策结果、任意 raw patch、Monitor 编辑及带 lease 的要求 +变化仍受限。Python 读取/bootstrap codec 与 legacy writer 仍有真实调用者,本批 +不退役它们,也不宣称完整 T1。下一步结合 lifecycle admission 与 validation effect +闭合 ownership/decision metadata,再推进 T2 剩余带 lease Monitor 事务。 + - 复用现有 provider text/note 事务、lifecycle 准入、field-plan 和 completion 规则。先枚举公开 metadata 编辑与显式 clear,不把 `UPDATE_FIELDS` 扩成所有存储 字段,也不让 generic patch 获得 terminal transition 权限。 diff --git a/docs/reference/protocols/active-state-structured-projection-v0.md b/docs/reference/protocols/active-state-structured-projection-v0.md index 9f99202634..9daaa7a7e0 100644 --- a/docs/reference/protocols/active-state-structured-projection-v0.md +++ b/docs/reference/protocols/active-state-structured-projection-v0.md @@ -199,7 +199,7 @@ machine-owned `Completed Work Archive` region (created when needed) and retain their original `role`. Unknown canonical fields and unsafe region ownership continue to fail closed. -For promoted provider-first Todo create, claim, and narrow text/note update, +For promoted provider-first Todo create, claim, and supported text/planning updates, the committed authority journal is the transaction-bound projection outbox: the canonical mutation, complete head, cursor, revision, and receipt land in one provider transaction. After that commit, the Python compatibility adapter @@ -209,6 +209,18 @@ evidence without reversing or hiding the canonical commit. A later successful mutation or `todo project-markdown --execute` replays the current head idempotently. This is projection recovery, not a second authority path. +Supported non-Monitor Agent updates include action/domain/repository and required +write scopes, required/target capabilities and Explore node references. These +declarations use the same canonical planning transaction, not a direct Markdown +edit. Invalid supplied members reject the entire update; empty collections clear +the declaration. They do not grant execution rights, change a lease, or approve +a User decision. Work-requirement edits with a retained lease remain unsupported. + +非 Monitor Agent Todo 的 action/domain/repository、写入范围、required/target +capability 和 Explore 引用声明复用同一 canonical planning 事务,不直接编辑 +Markdown。非法输入整笔拒绝,空集合明确清除;声明不授予执行权、不变更 lease, +也不批准 User 决策。带保留 lease 的工作要求编辑仍不支持。 + ### Generated display recovery / 生成式展示恢复 LoopX state documents are generated and maintained by Agents through LoopX. From 004fab2b1774386f893762253acb46af47b077a7 Mon Sep 17 00:00:00 2001 From: huangruiteng Date: Sat, 12 Sep 2026 11:38:28 +0800 Subject: [PATCH 3/3] test: retarget todo capability mutant Signed-off-by: huangruiteng --- examples/shared-goal-authority-e2e/mutants.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/shared-goal-authority-e2e/mutants.py b/examples/shared-goal-authority-e2e/mutants.py index 35c4cf999c..aef1a9a424 100644 --- a/examples/shared-goal-authority-e2e/mutants.py +++ b/examples/shared-goal-authority-e2e/mutants.py @@ -57,7 +57,7 @@ def command(self) -> list[str]: Case('todo_successor_scope_unbound', (('loopx/control_plane/todos/authoring_scope.ts', replacement( 'if (blocks && (goal || !bound || bound !== blocks)) return "agent_binding_conflict";', '')),), 'tests/control_plane_ts/todo_authoring_scope.test.ts', 'resolved successor scope'), - Case('monitor_route_drops_invalid_capability', (('loopx/control_plane/scheduler/monitor_successor.ts', replacement( + Case('monitor_route_drops_invalid_capability', (('loopx/control_plane/todos/work_requirements.ts', replacement( ' throw new EffectRuntimeRequestError(`${label} must contain public-safe capability tokens; invalid entries cannot be dropped`);', ' continue;')),), 'tests/control_plane_ts/monitor_successor.test.ts', 'invalid successor intent is rejected'),