From 18b295e85b2bd218b438c7343eca23bdfad3c7ca Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sun, 4 Oct 2026 21:17:38 +0800 Subject: [PATCH 1/3] fix(cli): project structured Todo claim argument recovery Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- loopx/cli_commands/todo.py | 7 +- .../cli_commands/todo_argument_validation.py | 76 +++++-- loopx/cli_commands/todo_event.py | 13 +- loopx/control_plane/todos/markdown.py | 10 + .../project_registry_io_manifest_v1.json | 14 +- .../test_todo_claim_argument_recovery.py | 195 ++++++++++++++++++ 6 files changed, 289 insertions(+), 26 deletions(-) create mode 100644 tests/control_plane/test_todo_claim_argument_recovery.py diff --git a/loopx/cli_commands/todo.py b/loopx/cli_commands/todo.py index d5eb8ff4b2..c48d945e67 100644 --- a/loopx/cli_commands/todo.py +++ b/loopx/cli_commands/todo.py @@ -291,6 +291,8 @@ def handle_todo_command( "`loopx todo claim`, `loopx todo update`, or another command shown " "by `loopx todo --help`" ) + if args.todo_command == "claim": + validate_todo_claim_options(args) validate_shared_todo_options(args) validate_capability_gap_options(args) if getattr(args, "turn_instance_id", None): @@ -437,7 +439,6 @@ def handle_todo_command( if not payload.get("dry_run"): payload["host_action"] = "end_current_heartbeat" elif args.todo_command == "claim": - validate_todo_claim_options(args) payload = update_goal_todo( registry_path=registry_path, runtime_root_arg=runtime_root_arg, @@ -715,7 +716,9 @@ def handle_todo_command( except Exception as exc: from ..usage_ping import capture_failure capture_failure(exc) - payload = todo_error_payload(args, exc) + payload = todo_error_payload( + args, exc, registry_path=registry_path, runtime_root_arg=runtime_root_arg, + ) append_todo_rollout_event( payload, args=args, diff --git a/loopx/cli_commands/todo_argument_validation.py b/loopx/cli_commands/todo_argument_validation.py index e20124a839..b3168a4f7b 100644 --- a/loopx/cli_commands/todo_argument_validation.py +++ b/loopx/cli_commands/todo_argument_validation.py @@ -2,6 +2,7 @@ import argparse from collections.abc import Iterable +from pathlib import Path from ..control_plane.todos.contract import TODO_CONTINUATION_POLICY_VALUES @@ -352,28 +353,73 @@ def validate_todo_add_options(args: argparse.Namespace) -> None: raise ValueError("todo add does not support --successor-todo-id; use todo update/complete to link existing successor work") +_TODO_CLAIM_FIELDS = frozenset({ + "role", "todo_id", "claimed_by", "agent_id", "state_file", + "claim_operation_id", "task_lease_idempotency_key", "task_lease_expected_version", +}) + + +class TodoClaimArgumentError(ValueError): + """CLI grammar rejection; does not decide whether a claim is authorized.""" + + def recovery( + self, args: argparse.Namespace, *, registry_path: Path, + runtime_root_arg: str | None, + ) -> dict[str, object]: + missing = [ + flag for flag, field in (("--todo-id", "todo_id"), ("--claimed-by", "claimed_by")) + if not getattr(args, field, None) + ] + if args.task_lease_expected_version is not None and not args.task_lease_idempotency_key: + missing.append("--task-lease-idempotency-key") + cli_args = ["--registry", str(registry_path), "--format", "json"] + if runtime_root_arg is not None: + cli_args.extend(["--runtime-root", runtime_root_arg]) + cli_args.extend(["todo", "claim", "--goal-id", args.goal_id]) + # Reuse the grammar owner; preserve identities and zero-valued CAS versions. + for flag, field in TODO_OPTION_FIELDS: + value = getattr(args, field, None) + if field in _TODO_CLAIM_FIELDS and value is not None and value != "": + cli_args.extend([flag, str(value)]) + if args.project: + cli_args.extend(["--project", args.project]) + if args.dry_run: + cli_args.append("--dry-run") + return { + "command": "loopx todo claim", + "cli_args": cli_args, + "requires_flags": missing, + "remove_flags": unsupported_todo_options(args, allowed_fields=_TODO_CLAIM_FIELDS), + "reason": ( + "Review removed flags, append missing flags with explicit values, then retry. " + "Claim authority and canonical lease checks still apply; this is not an admission." + ), + } + + def validate_todo_claim_options(args: argparse.Namespace) -> None: + if getattr(args, "turn_instance_id", None): + raise TodoClaimArgumentError( + "--turn-instance-id is supported only by todo complete/supersede settlement" + ) if not args.todo_id: - raise ValueError("todo claim requires --todo-id") + raise TodoClaimArgumentError("todo claim requires --todo-id") if not args.claimed_by: - raise ValueError("todo claim requires --claimed-by") + raise TodoClaimArgumentError("todo claim requires --claimed-by") if args.clear_claim: - raise ValueError( + raise TodoClaimArgumentError( "todo claim requires --claimed-by and does not support --clear-claim" ) - _validate_todo_option_subset( - args, - { - "role", "todo_id", "claimed_by", "agent_id", "state_file", - "claim_operation_id", "task_lease_idempotency_key", - "task_lease_expected_version", - }, - "todo claim only accepts --todo-id, --claimed-by, --agent-id, optional --role, " - "--claim-operation-id, --task-lease-idempotency-key, " - "--task-lease-expected-version, --project, --state-file, and --dry-run; unsupported: ", - ) + unsupported = unsupported_todo_options(args, allowed_fields=_TODO_CLAIM_FIELDS) + if unsupported: + raise TodoClaimArgumentError( + "todo claim only accepts --todo-id, --claimed-by, --agent-id, optional --role, " + "--claim-operation-id, --task-lease-idempotency-key, " + "--task-lease-expected-version, --project, --state-file, and --dry-run; unsupported: " + + ", ".join(unsupported) + ) if args.task_lease_expected_version is not None and not args.task_lease_idempotency_key: - raise ValueError( + raise TodoClaimArgumentError( "--task-lease-expected-version requires --task-lease-idempotency-key" ) diff --git a/loopx/cli_commands/todo_event.py b/loopx/cli_commands/todo_event.py index 558e147270..4869e0bf87 100644 --- a/loopx/cli_commands/todo_event.py +++ b/loopx/cli_commands/todo_event.py @@ -16,6 +16,7 @@ from ..control_plane.coordination.shadow_management import ShadowManagementError from ..control_plane.coordination.runtime_shadow_writer_adapter import ActiveStateAuthorityMutationError from ..control_plane.coordination.local_authority import LocalCoordinationAuthorityUnavailable +from .todo_argument_validation import TodoClaimArgumentError RolloutEventAppender = Callable[..., dict[str, object]] @@ -30,7 +31,10 @@ } -def todo_error_payload(args: argparse.Namespace, exc: Exception) -> dict[str, object]: +def todo_error_payload( + args: argparse.Namespace, exc: Exception, *, registry_path: Path, + runtime_root_arg: str | None, +) -> dict[str, object]: payload: dict[str, object] = { "ok": False, "dry_run": not bool(args.execute) @@ -44,7 +48,12 @@ def todo_error_payload(args: argparse.Namespace, exc: Exception) -> dict[str, ob "error": str(exc), **lock_timeout_error_fields(exc), } - if isinstance(exc, (TaskLeaseError, HandoffModeError, LegacyCoordinationWriterFenced, ShadowManagementError, ActiveStateAuthorityMutationError, LocalCoordinationAuthorityUnavailable)): + if isinstance(exc, TodoClaimArgumentError): + payload["error_code"] = "todo_claim_invalid_arguments" + payload["recovery"] = exc.recovery( + args, registry_path=registry_path, runtime_root_arg=runtime_root_arg, + ) + elif isinstance(exc, (TaskLeaseError, HandoffModeError, LegacyCoordinationWriterFenced, ShadowManagementError, ActiveStateAuthorityMutationError, LocalCoordinationAuthorityUnavailable)): payload["error_code"] = exc.code payload.update(exc.payload) elif isinstance(exc, LocalCoordinationAuthorityRejection): diff --git a/loopx/control_plane/todos/markdown.py b/loopx/control_plane/todos/markdown.py index 76d1543dfc..7e39ca8d20 100644 --- a/loopx/control_plane/todos/markdown.py +++ b/loopx/control_plane/todos/markdown.py @@ -1,5 +1,6 @@ from __future__ import annotations +import json from typing import Any from .contract import TODO_STATUS_OPEN, todo_marker_for_status @@ -248,6 +249,15 @@ def render_todo_markdown(payload: dict[str, Any]) -> str: ) if payload.get("error"): lines.append(f"- error: {payload.get('error')}") + if payload.get("error_code") == "todo_claim_invalid_arguments": + recovery = payload["recovery"] + lines.extend([ + f"- recovery: {recovery['reason']}", + f"- requires_flags: `{', '.join(recovery['requires_flags']) or 'none'}`", + f"- remove_flags: `{', '.join(recovery['remove_flags']) or 'none'}`", + "- cli_args (append required values before running):", + "```json", json.dumps(recovery["cli_args"]), "```", + ]) lines.extend(_render_lease_recovery(payload)) lines.extend(_render_settlement_plan(payload.get("settlement_plan"))) if payload.get("operator_action"): diff --git a/loopx/semantics/project_registry_io_manifest_v1.json b/loopx/semantics/project_registry_io_manifest_v1.json index 94f98cb627..b38f801a4d 100644 --- a/loopx/semantics/project_registry_io_manifest_v1.json +++ b/loopx/semantics/project_registry_io_manifest_v1.json @@ -879,7 +879,7 @@ }, { "site": "loopx/cli_commands/todo.py::.handle_todo_command::codec_read:load_registry#1", - "line": 331, + "line": 333, "column": 49, "kind": "codec_read", "api": "load_registry", @@ -887,7 +887,7 @@ }, { "site": "loopx/cli_commands/todo.py::.handle_todo_command::codec_read:load_registry#2", - "line": 347, + "line": 349, "column": 24, "kind": "codec_read", "api": "load_registry", @@ -895,7 +895,7 @@ }, { "site": "loopx/cli_commands/todo.py::.handle_todo_command::codec_read:load_registry#3", - "line": 355, + "line": 357, "column": 24, "kind": "codec_read", "api": "load_registry", @@ -903,7 +903,7 @@ }, { "site": "loopx/cli_commands/todo.py::.handle_todo_command::codec_read:load_registry#4", - "line": 535, + "line": 536, "column": 53, "kind": "codec_read", "api": "load_registry", @@ -911,7 +911,7 @@ }, { "site": "loopx/cli_commands/todo.py::.handle_todo_command::codec_read:load_registry#5", - "line": 666, + "line": 667, "column": 25, "kind": "codec_read", "api": "load_registry", @@ -919,7 +919,7 @@ }, { "site": "loopx/cli_commands/todo.py::.handle_todo_command::codec_read:load_registry#6", - "line": 734, + "line": 737, "column": 13, "kind": "codec_read", "api": "load_registry", @@ -927,7 +927,7 @@ }, { "site": "loopx/cli_commands/todo.py::.handle_todo_command::codec_read:load_registry#7", - "line": 779, + "line": 782, "column": 38, "kind": "codec_read", "api": "load_registry", diff --git a/tests/control_plane/test_todo_claim_argument_recovery.py b/tests/control_plane/test_todo_claim_argument_recovery.py new file mode 100644 index 0000000000..9654394698 --- /dev/null +++ b/tests/control_plane/test_todo_claim_argument_recovery.py @@ -0,0 +1,195 @@ +"""Execute rejected claim repairs without weakening the real authority owner.""" +from __future__ import annotations + +import json +import subprocess +import sys +from pathlib import Path + +import pytest + +from canonical_authority_fixture import initialize_canonical_authority, isolate_sqlite_runtime +from loopx.control_plane.coordination.runtime_shadow import build_todo_runtime_shadow_projection + +REPO = Path(__file__).resolve().parents[2] +GOAL, TODO = "claim-arguments", "todo_claim_arguments" + + +@pytest.fixture(params=["legacy", "file", "sqlite"]) +def claim_cli(tmp_path, monkeypatch, request): + isolate_sqlite_runtime(tmp_path, monkeypatch) + provider = request.param + project = tmp_path / "project with spaces" + project.mkdir() + state, runtime, registry = project / "state.md", tmp_path / "runtime", tmp_path / "registry.json" + state.write_text( + "# Claim\n\n## Agent Todo\n\n- [ ] Claim one open task.\n" + f" \n" + ) + registry.write_text(json.dumps({"common_runtime_root": str(runtime), "goals": [{ + "id": GOAL, "repo": str(project), "state_file": state.name, + "coordination": {"registered_agents": ["peer-a", "peer-b"]}, + }]})) + if provider != "legacy": + projection = build_todo_runtime_shadow_projection( + goal_id=GOAL, handoff_mode="hard_lease", leases=[], todos=[{ + "schema_version": "todo_item_v0", "todo_id": TODO, "role": "agent", + "status": "open", "done": False, "text": "Claim one open task.", + "archive_state": "active", "source_section": "Agent Todo", "index": 1, + "task_class": "advancement_task", "required_write_scopes": ["src/**"], + }], + ) + initialize_canonical_authority(runtime, GOAL, projection, state_path=state, provider=provider) + state.unlink() + + prefix = ["--registry", str(registry), "--runtime-root", str(runtime), "--format", "json"] + claim = ["todo", "claim", "--goal-id", GOAL, "--todo-id", TODO, "--agent-id", "peer-a"] + lease = [] if provider == "legacy" else [ + "--claim-operation-id", "original-claim", "--task-lease-idempotency-key", "original-lease", + "--task-lease-expected-version", "0", + ] + + def run(arguments, expected_exit=0): + result = subprocess.run( + [sys.executable, "-m", "loopx.cli", *arguments], cwd=REPO, + capture_output=True, text=True, timeout=90, check=False, + ) + assert result.returncode == expected_exit, result.stdout + result.stderr + return json.loads(result.stdout) + + def snapshot(): + readback = run([*prefix, "todo", "list", "--goal-id", GOAL]) + return readback["todos"], readback.get("provider_revision"), state.read_bytes() if state.exists() else None + + yield provider, run, snapshot, prefix, claim, lease, project, state + subprocess.run( + [sys.executable, "-c", "from loopx.control_plane.effect_runtime import effect_runtime_result; " + "effect_runtime_result('runtime.shutdown', {}, retry_safe=False)"], + cwd=REPO, capture_output=True, text=True, timeout=30, check=True, + ) + + +def test_one_packet_repairs_all_grammar_errors_and_preserves_route(claim_cli): + provider, run, snapshot, prefix, claim, lease, project, state = claim_cli + before = snapshot() + rejected = run([ + *prefix, *claim, *lease, "--turn-instance-id", "unrelated-turn", "--note", "not a claim field", + "--project", str(project), "--state-file", str(state), "--role", "agent", "--dry-run", + ], expected_exit=1) + assert snapshot() == before + assert rejected["error_code"] == "todo_claim_invalid_arguments" + recovery = rejected["recovery"] + assert recovery["requires_flags"] == ["--claimed-by"] + assert recovery["remove_flags"] == ["--turn-instance-id", "--note"] + repaired = recovery["cli_args"] + # No executor inference, shell quoting, route changes or weakened preview/CAS. + assert "--claimed-by" not in repaired + for flag, value in ( + ("--registry", prefix[1]), ("--runtime-root", prefix[3]), ("--goal-id", GOAL), + ("--todo-id", TODO), ("--agent-id", "peer-a"), ("--project", str(project)), + ("--state-file", str(state)), ("--role", "agent"), + ): + assert repaired[repaired.index(flag) + 1] == value + assert "--dry-run" in repaired + if lease: + for flag, value in zip(lease[::2], lease[1::2]): + assert repaired[repaired.index(flag) + 1] == value + preview = run([*repaired, "--claimed-by", "peer-a"]) + assert preview["ok"] is True + assert snapshot() == before + repaired.remove("--dry-run") + applied = run([*repaired, "--claimed-by", "peer-a"]) + assert applied["ok"] is True + assert snapshot()[0][0]["claimed_by"] == "peer-a" + if provider != "legacy": + assert applied["lease"]["version"] == 1 + assert applied["lease"]["write_scopes"] == ["src/**"] + replay = run([*repaired, "--claimed-by", "peer-a"]) + assert replay["status"] == "replayed" + assert replay["changed"] is False + + +def test_syntax_repair_does_not_authorize_a_different_executor(claim_cli): + _, run, snapshot, prefix, claim, lease, _, _ = claim_cli + before = snapshot() + rejected = run([*prefix, *claim, *lease, "--claimed-by", "peer-b", "--note", "invalid"], expected_exit=1) + repaired = run(rejected["recovery"]["cli_args"], expected_exit=1) + assert repaired["ok"] is False + assert repaired.get("error_code") != "todo_claim_invalid_arguments" + assert snapshot() == before + + +def test_missing_required_inputs_are_reported_together(claim_cli): + _, run, snapshot, prefix, _, _, _, _ = claim_cli + before = snapshot() + rejected = run([*prefix, "todo", "claim", "--goal-id", GOAL, "--agent-id", "peer-a"], expected_exit=1) + assert rejected["recovery"]["requires_flags"] == ["--todo-id", "--claimed-by"] + assert "--claimed-by" not in rejected["recovery"]["cli_args"] + assert snapshot() == before + + +def test_repair_preserves_an_invalid_cas_instead_of_weakening_it(claim_cli): + _, run, snapshot, prefix, claim, _, _, _ = claim_cli + before = snapshot() + rejected = run([ + *prefix, *claim, "--claimed-by", "peer-a", "--note", "invalid", + "--task-lease-idempotency-key", "original-key", "--task-lease-expected-version", "3", + ], expected_exit=1) + repaired = rejected["recovery"]["cli_args"] + assert repaired[repaired.index("--task-lease-expected-version") + 1] == "3" + assert repaired[repaired.index("--task-lease-idempotency-key") + 1] == "original-key" + assert run(repaired, expected_exit=1)["ok"] is False + assert snapshot() == before + + +def test_lease_input_requirement_preserves_zero_cas(claim_cli): + provider, run, snapshot, prefix, claim, _, _, _ = claim_cli + before = snapshot() + rejected = run([ + *prefix, *claim, "--claimed-by", "peer-a", "--task-lease-expected-version", "0", + ], expected_exit=1) + assert snapshot() == before + recovery = rejected["recovery"] + assert recovery["requires_flags"] == ["--task-lease-idempotency-key"] + repaired = recovery["cli_args"] + assert repaired[repaired.index("--task-lease-expected-version") + 1] == "0" + completed_args = [*repaired, "--task-lease-idempotency-key", "explicit-execution"] + if provider == "legacy": + rejected = run(completed_args, expected_exit=1) + assert "requires promoted canonical authority" in rejected["error"] + assert snapshot() == before + else: + applied = run(completed_args) + assert applied["lease"]["version"] == 1 + + +def test_grammar_packet_does_not_guess_authority_mode(claim_cli): + provider, run, snapshot, prefix, claim, _, _, _ = claim_cli + before = snapshot() + flags = ["--claimed-by", "peer-a"] + if provider == "legacy": + flags += ["--task-lease-idempotency-key", "canonical-only"] + rejected = run([*prefix, *claim, *flags], expected_exit=1) + assert rejected.get("error_code") != "todo_claim_invalid_arguments" + assert snapshot() == before + if provider == "legacy": + assert "requires promoted canonical authority" in rejected["error"] + assert "recovery" not in rejected + else: + assert rejected["error_code"] == "handoff_mode_requires_lease" + assert rejected["recovery"]["requires_flags"] == ["--task-lease-idempotency-key"] + + +def test_markdown_exposes_the_same_required_input(): + from loopx.cli import build_parser + from loopx.cli_commands.todo_argument_validation import TodoClaimArgumentError, validate_todo_claim_options + from loopx.cli_commands.todo_event import todo_error_payload + from loopx.control_plane.todos.markdown import render_todo_markdown + + args = build_parser().parse_args(["todo", "claim", "--goal-id", GOAL, "--todo-id", TODO]) + with pytest.raises(TodoClaimArgumentError) as error: + validate_todo_claim_options(args) + payload = todo_error_payload(args, error.value, registry_path=Path("registry.json"), runtime_root_arg=None) + rendered = render_todo_markdown(payload) + assert "requires_flags: `--claimed-by`" in rendered + assert json.dumps(payload["recovery"]["cli_args"]) in rendered From b92c08d13a4c23f034c68a7c1d37f4de30eb1b93 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sun, 4 Oct 2026 21:18:14 +0800 Subject: [PATCH 2/3] docs: qualify the claim grammar recovery boundary Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../typescript-control-plane-migration-v0.md | 9 ++++ ...script-control-plane-migration-v0.zh-CN.md | 7 +++ docs/reference/source-cli-entrypoint.md | 54 +++++++++++++++++++ 3 files changed, 70 insertions(+) diff --git a/docs/architecture/rfcs/typescript-control-plane-migration-v0.md b/docs/architecture/rfcs/typescript-control-plane-migration-v0.md index 8fa226d176..4e5c86a59d 100644 --- a/docs/architecture/rfcs/typescript-control-plane-migration-v0.md +++ b/docs/architecture/rfcs/typescript-control-plane-migration-v0.md @@ -419,6 +419,15 @@ or passing accelerated volume tests is not ten-day continuity evidence. Local promotion waits for both volume and elapsed-time qualification; it does not wait for a PostgreSQL service and never expires receipts at day ten. +Bounded caller recovery now has [claim argument guidance](../../reference/source-cli-entrypoint.md#claim-argument-recovery--claim-参数恢复): +one rejected invocation exposes missing/unsupported flags and preserves the +original argv bindings, without inferring executors or changing source/lease +admission. This stays in the existing CLI grammar/formatting adapter; TypeScript +retains claim authority. Real Legacy/File/SQLite retry and negative cases qualify +that boundary. Larger errors versus avoided help, actual model token/retry costs, +source-mode repair and short normal/full replan context remain separate evidence +questions; this does not close long-goal or provider-performance acceptance. + ### Delivery semantics: correctness before migration The replan obligation outcome policy now lives in 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 42db11d366..42097b812c 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 @@ -305,6 +305,13 @@ promotion gate。 本地晋升等待容量与自然时间双重资格化,不等待 PostgreSQL 服务,也不在第十天使 receipt 过期。 +调用方恢复的有界切片现有 [claim 参数指引](../../reference/source-cli-entrypoint.md#claim-argument-recovery--claim-参数恢复): +一次拒绝同时列出缺失/非法参数,保留原 argv 绑定,不猜 executor 或改变 source/lease +准入。语法/展示归现有 CLI adapter,claim 权威保留在 TypeScript。真实 +Legacy/File/SQLite 重试与负例只验收这个边界。较大错误与省掉 help 的取舍、实际模型 +token/重试成本、source-mode 修复和常规短上下文/replan 完整历史仍是分别待验证的 +问题;不代表长目标或 provider 性能验收完成。 + ### 交付语义:先修正规则,再迁移 Replan 的义务结果规则现收敛到 `work_items/replan_semantics.ts`:接受结果选择、 diff --git a/docs/reference/source-cli-entrypoint.md b/docs/reference/source-cli-entrypoint.md index f2d6f704aa..0803a7ff52 100644 --- a/docs/reference/source-cli-entrypoint.md +++ b/docs/reference/source-cli-entrypoint.md @@ -75,6 +75,60 @@ No frontend asset or layout changes, so no repackaging is needed for this slice. transport。现有 usage-settings HTTP 交互验证共享机器设置。没有前端资源或布局 改动,因此此切片无需重新打包前端。 +## Claim argument recovery / Claim 参数恢复 + +`todo claim` grammar errors now include `error_code=todo_claim_invalid_arguments` +and one `recovery` object. Its `cli_args` is an argv array with the original +registry/runtime, Goal/Todo, supplied actor/executor, project/state path, +preview, operation identity and lease/CAS values. Review `remove_flags`, then +append each `requires_flags` entry with an explicit value before retrying. +Retry from the original working directory when supplied paths are relative. +A missing executor is never inferred from the actor. The command can still +fail admission, registration, ownership, source-mode or lease validation. +This is recovery guidance, not an authority grant or automatic retry. + +For example, a claim with an actor but no `--claimed-by`, plus an unsupported +`--turn-instance-id`, reports both in the same packet. The retry excludes the +Turn flag; it neither starts a new Turn nor re-fetches a quota packet. A lease +expected version of `0` is retained, including when its missing idempotency key +must be supplied. Canonical-only flags on legacy state remain a source-mode +rejection; this grammar projection does not remove them or promote state. + +The existing Python CLI grammar/formatting adapter owns this local error code +and argv projection. The shared TypeScript claim transaction remains the +authority owner; no new capability, setting, provider or bridge RPC is added. +JSON and Markdown expose the same repair facts. Source and console CLI callers +gain the guidance; frontend/Lark controls and persisted contracts do not change. +Successful commands retain their output. Invalid claim option combinations now +use the claim-specific validator before shared checks, so their first human +diagnostic can differ; existing claim-specific and Turn diagnostics remain. + +`todo claim` 语法错误现在返回上述 error code 和一个 `recovery`:`cli_args` 用 argv +数组保留原路由、Goal/Todo、已提供的 actor/executor、project/state、preview、 +operation 身份和 lease/CAS。先检查 `remove_flags`,再为每个 `requires_flags` +补入显式值后重试;路径为相对路径时,沿用原调用的工作目录。不会从 actor 猜执行者。 +修复语法后仍可能被注册、所有权、source mode 或 lease 校验拒绝。这是恢复指引, +不授予权限,也不自动执行。 +缺 `--claimed-by` 又误带 Turn 参数时,一次 packet 同时列出两处;修复不另建 Turn +或重新读取 quota。CAS=0 保留,缺 lease key 时必须填写;legacy 上的 canonical 参数 +继续按模式拒绝,不静默删除或 promote。语法/展示沿用 Python CLI adapter, +事务权限仍归 TS claim owner;不新增 capability、设置、provider 或 RPC。 +JSON/Markdown 展示相同事实,source/console 获得指引,frontend/Lark 控件和持久合同 +不变。成功输出保持原行为;非法参数先经过 claim validator,首条人类诊断可能改变, +既有 claim 专属和 Turn 诊断保留。 + +Real CLI tests execute the returned argv against isolated Legacy, File and +SQLite state, including preview/replay, actor mismatch, canonical-mode refusal, +hard-lease admission and CAS=0. They do not qualify PostgreSQL transaction +changes, sustained provider performance, model retry rates or benchmark scores. +Measure the error-plus-recovery journey: the richer error costs more bytes; +avoiding a separate help lookup is a consumer benefit, not backend IO savings. + +真实 CLI 测试在隔离 Legacy/File/SQLite 上执行返回的 argv,覆盖 preview/replay、 +actor 错配、canonical 模式拒绝、hard lease 和 CAS=0;不代表 PostgreSQL 事务改动、 +长程 provider 性能、模型重试率或 benchmark 得分验收。应测整个错误到恢复路径: +错误本身增加字节,省掉一次 help 是调用方收益,不等于后端 IO 优化。 + ## Qualification / 验收 Fresh-interpreter regressions compare source and console entries, preserve From ca2312361de943320e3ea1ec2d60f8ab81e82165 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sun, 4 Oct 2026 21:26:41 +0800 Subject: [PATCH 3/3] docs: distinguish parsed claim recovery from parser diagnostics Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- docs/reference/source-cli-entrypoint.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/docs/reference/source-cli-entrypoint.md b/docs/reference/source-cli-entrypoint.md index 0803a7ff52..1fac50349d 100644 --- a/docs/reference/source-cli-entrypoint.md +++ b/docs/reference/source-cli-entrypoint.md @@ -77,7 +77,7 @@ transport。现有 usage-settings HTTP 交互验证共享机器设置。没有 ## Claim argument recovery / Claim 参数恢复 -`todo claim` grammar errors now include `error_code=todo_claim_invalid_arguments` +Parsed `todo claim` usage errors now include `error_code=todo_claim_invalid_arguments` and one `recovery` object. Its `cli_args` is an argv array with the original registry/runtime, Goal/Todo, supplied actor/executor, project/state path, preview, operation identity and lease/CAS values. Review `remove_flags`, then @@ -86,6 +86,8 @@ Retry from the original working directory when supplied paths are relative. A missing executor is never inferred from the actor. The command can still fail admission, registration, ownership, source-mode or lease validation. This is recovery guidance, not an authority grant or automatic retry. +Unknown flags, invalid flag values and parser-required global inputs retain +their existing argparse diagnostics before this handler is reached. For example, a claim with an actor but no `--claimed-by`, plus an unsupported `--turn-instance-id`, reports both in the same packet. The retry excludes the @@ -103,12 +105,13 @@ Successful commands retain their output. Invalid claim option combinations now use the claim-specific validator before shared checks, so their first human diagnostic can differ; existing claim-specific and Turn diagnostics remain. -`todo claim` 语法错误现在返回上述 error code 和一个 `recovery`:`cli_args` 用 argv +已解析的 `todo claim` 用法错误现在返回上述 error code 和一个 `recovery`:`cli_args` 用 argv 数组保留原路由、Goal/Todo、已提供的 actor/executor、project/state、preview、 operation 身份和 lease/CAS。先检查 `remove_flags`,再为每个 `requires_flags` 补入显式值后重试;路径为相对路径时,沿用原调用的工作目录。不会从 actor 猜执行者。 修复语法后仍可能被注册、所有权、source mode 或 lease 校验拒绝。这是恢复指引, 不授予权限,也不自动执行。 +未知 flag、非法 flag 值和缺 parser 必填输入仍先走既有 argparse 诊断。 缺 `--claimed-by` 又误带 Turn 参数时,一次 packet 同时列出两处;修复不另建 Turn 或重新读取 quota。CAS=0 保留,缺 lease key 时必须填写;legacy 上的 canonical 参数 继续按模式拒绝,不静默删除或 promote。语法/展示沿用 Python CLI adapter,