From 442b9a989de4c25be98480bcc778b97bc5d352a3 Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Sun, 20 Sep 2026 23:55:02 +0800 Subject: [PATCH 1/4] fix(control-plane): require explicit mutation actors Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- loopx/chat_goal_lifecycle_actions.py | 3 +- loopx/cli_commands/goal_lifecycle.py | 10 ++++ loopx/cli_commands/project_lifecycle.py | 9 +++ loopx/control_plane/actor_identity.py | 29 ++++++++++ loopx/control_plane/goals/activation.py | 9 ++- .../control_plane/goals/activation_service.py | 10 +++- loopx/control_plane/goals/operator_actions.ts | 2 + .../goals/ssh_lifecycle_transport.py | 2 + loopx/control_plane/runtime/run_compaction.py | 1 + loopx/feedback.py | 7 +++ .../presentation/renderers/status_markdown.py | 2 +- loopx/status_server.py | 2 + .../dsh-loopx-plugin/src/goalbar/service.ts | 1 + .../tests/goalbar-service.spec.ts | 2 + scripts/codex_app_apply_rrule.py | 6 +- tests/control_plane/test_goal_activation.py | 12 ++++ .../test_goal_operator_actions.py | 2 + .../test_mutation_actor_identity.py | 57 +++++++++++++++++++ .../test_shadow_writer_boundaries.py | 8 ++- tests/test_codex_app_apply_rrule.py | 23 +++++++- tests/test_feedback_run_selection.py | 2 + tests/test_ssh_tunnel_source.py | 1 + 22 files changed, 190 insertions(+), 10 deletions(-) create mode 100644 loopx/control_plane/actor_identity.py create mode 100644 tests/control_plane/test_mutation_actor_identity.py diff --git a/loopx/chat_goal_lifecycle_actions.py b/loopx/chat_goal_lifecycle_actions.py index 1f12cd17a5..8ce34aae72 100644 --- a/loopx/chat_goal_lifecycle_actions.py +++ b/loopx/chat_goal_lifecycle_actions.py @@ -125,6 +125,7 @@ def _apply_goal_lifecycle( goal_id=goal_id, state=target_state, reason=parameters.get("reason"), + actor_kind="owner", execute=True, ) if not result.get("ok") or not (result.get("readback") or {}).get( @@ -159,4 +160,4 @@ def _apply_goal_lifecycle( ), receipt=receipt, ) - return {"proposal": stored, "turn": None} \ No newline at end of file + return {"proposal": stored, "turn": None} diff --git a/loopx/cli_commands/goal_lifecycle.py b/loopx/cli_commands/goal_lifecycle.py index e13a2f6c54..bfc348a3cf 100644 --- a/loopx/cli_commands/goal_lifecycle.py +++ b/loopx/cli_commands/goal_lifecycle.py @@ -34,6 +34,14 @@ def register_goal_lifecycle_command( help="Stop automatic advancement or restore eligibility.", ) parser.add_argument("--reason", help="Bounded owner-visible transition reason.") + parser.add_argument( + "--actor-kind", + choices=("owner", "controller"), + help=( + "Explicit non-Agent actor for --execute. Anonymous preview remains " + "available when this option is omitted." + ), + ) parser.add_argument( "--expected-state-fingerprint", help="SHA-256 registry fingerprint from a fresh goal-actions projection.", @@ -59,6 +67,7 @@ def handle_goal_lifecycle_command( reason=args.reason, runtime_root_override=args.runtime_root, expected_state_fingerprint=args.expected_state_fingerprint, + actor_kind=args.actor_kind, execute=bool(args.execute), ) except Exception as exc: @@ -68,6 +77,7 @@ def handle_goal_lifecycle_command( "dry_run": not bool(args.execute), "execute": bool(args.execute), "goal_id": args.goal_id, + "actor_kind": args.actor_kind, "changed": False, "written": False, "error": str(exc), diff --git a/loopx/cli_commands/project_lifecycle.py b/loopx/cli_commands/project_lifecycle.py index 6e32d23290..2f55daaada 100644 --- a/loopx/cli_commands/project_lifecycle.py +++ b/loopx/cli_commands/project_lifecycle.py @@ -103,6 +103,14 @@ def register_project_lifecycle_commands( help="Exact run generated_at timestamp. Defaults to the latest compact run for the goal.", ) reward_parser.add_argument("--recorded-at", help="Reward timestamp. Defaults to current UTC time.") + reward_parser.add_argument( + "--actor-kind", + choices=("owner", "controller"), + help=( + "Explicit non-Agent actor for the durable append. Anonymous dry-run " + "remains available when this option is omitted." + ), + ) reward_parser.add_argument("--decision", required=True, help="Operator decision label, such as continue_route.") reward_parser.add_argument( "--reward", @@ -265,6 +273,7 @@ def handle_project_lifecycle_command( goal_id=args.goal_id, run_generated_at=args.run_generated_at, reward=reward, + actor_kind=args.actor_kind, dry_run=bool(args.dry_run), state_file_override=Path(args.state_file).expanduser() if args.state_file else None, write_active_state_summary=bool(args.write_active_state_summary), diff --git a/loopx/control_plane/actor_identity.py b/loopx/control_plane/actor_identity.py new file mode 100644 index 0000000000..b24b20312a --- /dev/null +++ b/loopx/control_plane/actor_identity.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +from enum import Enum +from typing import Any + + +class OwnerControllerActorKind(str, Enum): + OWNER = "owner" + CONTROLLER = "controller" + + +def normalize_owner_controller_actor( + value: Any, + *, + required: bool, +) -> OwnerControllerActorKind | None: + """Decode the explicit non-Agent actor for a durable local mutation.""" + + normalized = str(value or "").strip().lower() + if not normalized: + if required: + raise ValueError( + "actor kind is required for state mutation; use owner or controller" + ) + return None + try: + return OwnerControllerActorKind(normalized) + except ValueError as exc: + raise ValueError("actor kind must be owner or controller") from exc diff --git a/loopx/control_plane/goals/activation.py b/loopx/control_plane/goals/activation.py index fd073774a0..4d11940220 100644 --- a/loopx/control_plane/goals/activation.py +++ b/loopx/control_plane/goals/activation.py @@ -3,6 +3,8 @@ from enum import Enum from typing import Any, Mapping +from ..actor_identity import normalize_owner_controller_actor + GOAL_ACTIVATION_SCHEMA_VERSION = "loopx_goal_activation_v1" @@ -46,6 +48,7 @@ def build_goal_activation( state: GoalActivationState | str, updated_at: str, reason: str, + actor_kind: str | None = None, ) -> dict[str, str]: normalized_state = normalize_goal_activation_state(state) timestamp = str(updated_at or "").strip() @@ -54,12 +57,16 @@ def build_goal_activation( compact_reason = " ".join(str(reason or "").split()).strip() if not compact_reason or len(compact_reason) > 600: raise ValueError("goal activation reason must be bounded visible text") - return { + actor = normalize_owner_controller_actor(actor_kind, required=False) + activation = { "schema_version": GOAL_ACTIVATION_SCHEMA_VERSION, "state": normalized_state.value, "updated_at": timestamp, "reason": compact_reason, } + if actor is not None: + activation["actor_kind"] = actor.value + return activation def goal_is_stopped(goal: Mapping[str, Any] | None) -> bool: diff --git a/loopx/control_plane/goals/activation_service.py b/loopx/control_plane/goals/activation_service.py index 7f82a04c5a..491cbaef5e 100644 --- a/loopx/control_plane/goals/activation_service.py +++ b/loopx/control_plane/goals/activation_service.py @@ -12,6 +12,7 @@ from ...history import load_registry from ...registry import atomic_write_json, registry_goals from ...registry_writability import probe_registry_write_path +from ..actor_identity import normalize_owner_controller_actor from ..runtime.time import now_local_iso from .activation import ( GoalActivationState, @@ -211,6 +212,7 @@ def set_goal_activation_state( reason: str | None = None, runtime_root_override: str | None = None, expected_state_fingerprint: str | None = None, + actor_kind: str | None = None, execute: bool = False, ) -> dict[str, Any]: """Preview or apply one reversible Goal activation transition.""" @@ -218,6 +220,7 @@ def set_goal_activation_state( normalized_goal_id = str(goal_id or "").strip() if not normalized_goal_id: raise ValueError("goal id is required") + actor = normalize_owner_controller_actor(actor_kind, required=execute) target_state = normalize_goal_activation_state(state) authority_route = _source_and_target( registry_path=registry_path, @@ -237,16 +240,18 @@ def set_goal_activation_state( observed_fingerprint = hashlib.sha256(source_registry.read_bytes()).hexdigest() before_state = goal_activation_state(source_goal) changed = before_state is not target_state + actor_label = actor.value if actor is not None else "owner" default_reason = ( - "Stopped by owner" + f"Stopped by {actor_label}" if target_state is GoalActivationState.STOPPED - else "Resumed by owner" + else f"Resumed by {actor_label}" ) reason_text = " ".join(str(reason or default_reason).split()).strip() proposed_activation = build_goal_activation( state=target_state, updated_at=now_local_iso(), reason=reason_text, + actor_kind=actor.value if actor is not None else None, ) payload: dict[str, Any] = { "ok": True, @@ -265,6 +270,7 @@ def set_goal_activation_state( "expected_state_fingerprint": normalized_fingerprint, "observed_state_fingerprint": observed_fingerprint, "activation": proposed_activation, + "actor_kind": actor.value if actor is not None else None, "readback": { "schema_version": GOAL_ACTIVATION_READBACK_SCHEMA_VERSION, "status": "not_executed" if changed else "not_required", diff --git a/loopx/control_plane/goals/operator_actions.ts b/loopx/control_plane/goals/operator_actions.ts index 98d9d61d53..eab7319063 100644 --- a/loopx/control_plane/goals/operator_actions.ts +++ b/loopx/control_plane/goals/operator_actions.ts @@ -54,6 +54,8 @@ function lifecycleAction( goalId, "--operation", operation, + "--actor-kind", + "owner", "--expected-state-fingerprint", fingerprint, "--execute", diff --git a/loopx/control_plane/goals/ssh_lifecycle_transport.py b/loopx/control_plane/goals/ssh_lifecycle_transport.py index dde243afe8..f0743c990c 100644 --- a/loopx/control_plane/goals/ssh_lifecycle_transport.py +++ b/loopx/control_plane/goals/ssh_lifecycle_transport.py @@ -67,6 +67,8 @@ def apply_ssh_goal_lifecycle( shlex.quote(normalized_goal_id), "--operation", normalized_operation, + "--actor-kind", + "owner", "--reason", shlex.quote(normalized_reason), "--execute", diff --git a/loopx/control_plane/runtime/run_compaction.py b/loopx/control_plane/runtime/run_compaction.py index 804f253be1..9f1fddd9e3 100644 --- a/loopx/control_plane/runtime/run_compaction.py +++ b/loopx/control_plane/runtime/run_compaction.py @@ -5,6 +5,7 @@ HUMAN_REWARD_COMPACT_FIELDS = ( "recorded_at", + "actor_kind", "decision", "reward", "reason_summary", diff --git a/loopx/feedback.py b/loopx/feedback.py index 03ca67e288..1efa077a87 100644 --- a/loopx/feedback.py +++ b/loopx/feedback.py @@ -19,6 +19,7 @@ find_private_text_match, ) from .registry import registry_goals, resolve_state_file +from .control_plane.actor_identity import normalize_owner_controller_actor GOAL_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]*$") @@ -402,10 +403,12 @@ def append_human_reward( goal_id: str, run_generated_at: str | None, reward: dict[str, Any], + actor_kind: str | None = None, dry_run: bool = False, state_file_override: Path | None = None, write_active_state_summary: bool = False, ) -> dict[str, Any]: + actor = normalize_owner_controller_actor(actor_kind, required=not dry_run) validate_goal_id(goal_id) registry = load_registry(registry_path) runtime_root = resolve_runtime_root(registry, runtime_root_override, registry_path=registry_path) @@ -432,6 +435,8 @@ def append_human_reward( for field in HUMAN_REWARD_FIELDS if field in reward } + if actor is not None: + index_record["human_reward"]["actor_kind"] = actor.value selected_run = { "generated_at": selected.get("generated_at"), @@ -500,6 +505,7 @@ def append_human_reward( "dry_run": dry_run, "selected_run": selected_run, "human_reward": index_record["human_reward"], + "actor_kind": actor.value if actor is not None else None, **coordination, "active_state_update": active_state_update, "index_record": index_record, @@ -568,6 +574,7 @@ def render_reward_markdown(payload: dict[str, Any]) -> str: "", "## Reward", f"- recorded_at: `{reward.get('recorded_at')}`", + f"- actor_kind: `{reward.get('actor_kind')}`", f"- decision: `{reward.get('decision')}`", f"- reward: `{reward.get('reward')}`", f"- reason_summary: {reward.get('reason_summary')}", diff --git a/loopx/presentation/renderers/status_markdown.py b/loopx/presentation/renderers/status_markdown.py index 46ace4b4aa..c9f1395165 100644 --- a/loopx/presentation/renderers/status_markdown.py +++ b/loopx/presentation/renderers/status_markdown.py @@ -172,7 +172,7 @@ def append_global_registry_findings_markdown( def append_human_reward_markdown(lines: list[str], goal_id: Any, reward: dict[str, Any]) -> None: headline_parts = [] - for field in ("recorded_at", "decision", "reward"): + for field in ("recorded_at", "actor_kind", "decision", "reward"): value = reward.get(field) if value: headline_parts.append(f"{field}={markdown_scalar(value)}") diff --git a/loopx/status_server.py b/loopx/status_server.py index 88081bfe6c..9171d5c954 100644 --- a/loopx/status_server.py +++ b/loopx/status_server.py @@ -348,6 +348,7 @@ def _reward_dry_run_payload(self, body: dict[str, Any], *, append: bool = False) goal_id=goal_id, run_generated_at=run_generated_at, reward=reward, + actor_kind="owner", dry_run=True, write_active_state_summary=( _json_boolean(body, "write_active_state_summary", default=True) @@ -428,6 +429,7 @@ def _handle_reward_append(self) -> None: goal_id=goal_id, run_generated_at=run_generated_at, reward=reward, + actor_kind="owner", dry_run=False, write_active_state_summary=_json_boolean( body, "write_active_state_summary", default=True diff --git a/packages/dsh-loopx-plugin/src/goalbar/service.ts b/packages/dsh-loopx-plugin/src/goalbar/service.ts index 50339d1997..f6f44b8dcd 100644 --- a/packages/dsh-loopx-plugin/src/goalbar/service.ts +++ b/packages/dsh-loopx-plugin/src/goalbar/service.ts @@ -679,6 +679,7 @@ export class GoalBarService implements GoalBarServiceHandle { 'goal-lifecycle', '--goal-id', binding.goalId, '--operation', operation, + '--actor-kind', 'owner', '--execute', ], { runner: this.runner, diff --git a/packages/dsh-loopx-plugin/tests/goalbar-service.spec.ts b/packages/dsh-loopx-plugin/tests/goalbar-service.spec.ts index 230b6843b8..05424ab081 100644 --- a/packages/dsh-loopx-plugin/tests/goalbar-service.spec.ts +++ b/packages/dsh-loopx-plugin/tests/goalbar-service.spec.ts @@ -763,6 +763,7 @@ describe('GoalBar Host lifecycle authority', () => { 'goal-lifecycle', '--goal-id', goalId, '--operation', 'resume', + '--actor-kind', 'owner', '--execute', ]) await fixture.service.dispose() @@ -790,6 +791,7 @@ describe('GoalBar Host lifecycle authority', () => { 'goal-lifecycle', '--goal-id', goalId, '--operation', 'stop', + '--actor-kind', 'owner', '--execute', ]) expect(fixture.driverCalls).toEqual(['cancelQueued']) diff --git a/scripts/codex_app_apply_rrule.py b/scripts/codex_app_apply_rrule.py index fd5aaf7f7e..6b13e41a93 100644 --- a/scripts/codex_app_apply_rrule.py +++ b/scripts/codex_app_apply_rrule.py @@ -454,7 +454,11 @@ def _parse_args(argv: list[str]) -> argparse.Namespace: ) ) parser.add_argument("--goal-id", default="loopx-meta") - parser.add_argument("--agent-id", default="codex-side-bypass") + parser.add_argument( + "--agent-id", + required=True, + help="Registered LoopX Agent that owns this scheduler mutation.", + ) parser.add_argument( "--registry", type=Path, diff --git a/tests/control_plane/test_goal_activation.py b/tests/control_plane/test_goal_activation.py index b2af9287b3..e07bbe4b90 100644 --- a/tests/control_plane/test_goal_activation.py +++ b/tests/control_plane/test_goal_activation.py @@ -147,6 +147,7 @@ def test_stop_orphaned_global_goal_uses_fail_safe_fallback( registry_path=global_registry, goal_id="orphaned-goal", state="stopped", + actor_kind="owner", execute=True, ) @@ -181,6 +182,7 @@ def test_resume_orphaned_global_goal_fails_closed(tmp_path: Path) -> None: registry_path=global_registry, goal_id="orphaned-goal", state="active", + actor_kind="owner", execute=True, ) @@ -205,6 +207,7 @@ def test_stop_does_not_fallback_for_non_global_registry(tmp_path: Path) -> None: registry_path=project_registry, goal_id="orphaned-goal", state="stopped", + actor_kind="owner", execute=True, ) @@ -224,12 +227,14 @@ def test_stop_and_resume_sync_source_global_and_quota( goal_id="goal-one", state="stopped", reason="Owner is reducing the active workspace", + actor_kind="owner", execute=True, ) assert stopped["ok"] is True assert stopped["written"] is True assert stopped["readback"]["verified"] is True + assert _goal(source_registry)["activation"]["actor_kind"] == "owner" assert goal_activation_state(_goal(source_registry)) is GoalActivationState.STOPPED assert goal_activation_state(_goal(global_registry)) is GoalActivationState.STOPPED stopped_quota = quota_status(_goal(global_registry), waiting_on="codex") @@ -246,6 +251,7 @@ def test_stop_and_resume_sync_source_global_and_quota( registry_path=global_registry, goal_id="goal-one", state="active", + actor_kind="owner", execute=True, ) @@ -337,6 +343,7 @@ def test_idempotent_execute_reconciles_drifted_global_projection( registry_path=global_registry, goal_id="goal-one", state="stopped", + actor_kind="owner", execute=True, ) @@ -366,6 +373,7 @@ def test_stop_migrates_legacy_projected_activation_state( registry_path=global_registry, goal_id="goal-one", state="stopped", + actor_kind="owner", execute=True, ) @@ -416,6 +424,7 @@ def test_delete_stopped_goal_removes_source_and_global( registry_path=global_registry, goal_id="goal-one", state="stopped", + actor_kind="owner", execute=True, ) @@ -480,6 +489,7 @@ def test_owner_confirmed_typed_action_deletes_stopped_goal( registry_path=global_registry, goal_id="goal-one", state="stopped", + actor_kind="owner", execute=True, ) service = ChatActionService( @@ -516,6 +526,7 @@ def test_owner_confirmed_typed_action_rejects_stale_delete_without_writing( registry_path=global_registry, goal_id="goal-one", state="stopped", + actor_kind="owner", execute=True, ) service = ChatActionService( @@ -570,6 +581,7 @@ def test_goal_deletion_backups_are_unique_and_preserve_preimages( registry_path=global_registry, goal_id=goal_id, state="stopped", + actor_kind="owner", execute=True, )["ok"] is True diff --git a/tests/control_plane/test_goal_operator_actions.py b/tests/control_plane/test_goal_operator_actions.py index 3207cdc6e2..c5ebc197ff 100644 --- a/tests/control_plane/test_goal_operator_actions.py +++ b/tests/control_plane/test_goal_operator_actions.py @@ -181,6 +181,8 @@ def test_goal_actions_cli_projects_exact_fresh_execution_identity( GOAL_ID, "--operation", "stop", + "--actor-kind", + "owner", "--expected-state-fingerprint", packet["state_fingerprint"], "--execute", diff --git a/tests/control_plane/test_mutation_actor_identity.py b/tests/control_plane/test_mutation_actor_identity.py new file mode 100644 index 0000000000..3e0f1d2498 --- /dev/null +++ b/tests/control_plane/test_mutation_actor_identity.py @@ -0,0 +1,57 @@ +from pathlib import Path + +import pytest + +from loopx.control_plane.actor_identity import normalize_owner_controller_actor +from loopx.control_plane.goals.activation_service import set_goal_activation_state +from loopx.control_plane.runtime.run_compaction import compact_human_reward +from loopx.feedback import append_human_reward +from scripts.codex_app_apply_rrule import _parse_args + + +def test_mutation_entrypoints_fail_before_io_without_an_explicit_actor( + tmp_path: Path, +) -> None: + missing_registry = tmp_path / "missing-registry.json" + + with pytest.raises(ValueError, match="actor kind is required"): + set_goal_activation_state( + registry_path=missing_registry, + goal_id="fixture-goal", + state="stopped", + execute=True, + ) + + with pytest.raises(ValueError, match="actor kind is required"): + append_human_reward( + registry_path=missing_registry, + runtime_root_override=None, + goal_id="fixture-goal", + run_generated_at=None, + reward={}, + ) + + with pytest.raises(SystemExit) as scheduler_error: + _parse_args([]) + assert scheduler_error.value.code == 2 + + +def test_owner_controller_actor_is_typed_and_optional_for_read_only_preview() -> None: + assert normalize_owner_controller_actor(None, required=False) is None + assert normalize_owner_controller_actor("owner", required=True).value == "owner" + assert ( + normalize_owner_controller_actor("controller", required=True).value + == "controller" + ) + with pytest.raises(ValueError, match="must be owner or controller"): + normalize_owner_controller_actor("agent", required=True) + + assert compact_human_reward( + { + "recorded_at": "2026-09-20T00:00:00Z", + "actor_kind": "owner", + "decision": "continue", + "reward": "positive", + "reason_summary": "Validated result.", + } + )["actor_kind"] == "owner" diff --git a/tests/control_plane/test_shadow_writer_boundaries.py b/tests/control_plane/test_shadow_writer_boundaries.py index 716badd2c6..41cae12b58 100644 --- a/tests/control_plane/test_shadow_writer_boundaries.py +++ b/tests/control_plane/test_shadow_writer_boundaries.py @@ -155,6 +155,7 @@ def test_reward_summary_cannot_inject_a_canonical_todo(tmp_path: Path) -> None: with pytest.raises(ActiveStateAuthorityMutationError): append_human_reward(registry_path=registry, runtime_root_override=None, goal_id=GOAL, run_generated_at=None, reward=reward, + actor_kind="owner", write_active_state_summary=True) assert (state.read_bytes(), index.read_bytes()) == before @@ -173,6 +174,7 @@ def plan_then_edit(**kwargs: object): monkeypatch.setattr(feedback, "plan_active_state_update", plan_then_edit) feedback.append_human_reward(registry_path=registry, runtime_root_override=None, goal_id=GOAL, run_generated_at=None, reward=reward, + actor_kind="owner", write_active_state_summary=True) assert "Concurrent task." in state.read_text() assert "Review accepted." in state.read_text() @@ -417,7 +419,8 @@ def test_prose_only_reward_remains_allowed_under_a_legacy_fence(tmp_path: Path) # comparison proves that no Todo/lease field is changed. fence.write_text("{invalid", encoding="utf-8") result = append_human_reward(registry_path=registry, runtime_root_override=None, - goal_id=GOAL, run_generated_at=None, reward=reward, write_active_state_summary=True) + goal_id=GOAL, run_generated_at=None, reward=reward, actor_kind="owner", + write_active_state_summary=True) assert result["appended"] is True assert "Review accepted." in state.read_text() assert not (root / "authority-shadow").exists() @@ -433,7 +436,8 @@ def test_prose_only_reward_holds_before_index_append_during_maintenance(tmp_path before = state.read_bytes(), index.read_bytes() with pytest.raises(ShadowManagementError): append_human_reward(registry_path=registry, runtime_root_override=None, - goal_id=GOAL, run_generated_at=None, reward=reward, write_active_state_summary=True) + goal_id=GOAL, run_generated_at=None, reward=reward, actor_kind="owner", + write_active_state_summary=True) assert (state.read_bytes(), index.read_bytes()) == before diff --git a/tests/test_codex_app_apply_rrule.py b/tests/test_codex_app_apply_rrule.py index 2963ec81b3..6cdf923b2b 100644 --- a/tests/test_codex_app_apply_rrule.py +++ b/tests/test_codex_app_apply_rrule.py @@ -120,6 +120,8 @@ def fake_run(command, **kwargs): assert ( main( [ + "--agent-id", + "codex-fixture", "--automations-root", str(tmp_path / "automations"), "--db-path", @@ -148,15 +150,24 @@ def test_default_app_stores_follow_codex_home_and_capabilities_are_explicit( codex_home = tmp_path / "codex-home" monkeypatch.setenv("CODEX_HOME", str(codex_home)) - defaults = _parse_args([]) + defaults = _parse_args(["--agent-id", "codex-fixture"]) assert defaults.automations_root == codex_home / "automations" assert defaults.db_path == codex_home / "sqlite/codex-dev.db" assert defaults.capability == [] - explicit = _parse_args(["--capability", "network"]) + explicit = _parse_args( + ["--agent-id", "codex-fixture", "--capability", "network"] + ) assert explicit.capability == ["network"] +def test_scheduler_bridge_requires_explicit_agent_identity() -> None: + with pytest.raises(SystemExit) as error: + _parse_args([]) + + assert error.value.code == 2 + + def test_heartbeat_guide_matches_parent_turn_replay_contract() -> None: guide = ( Path(__file__).parents[1] / "docs/heartbeat-automation-prompt.md" @@ -188,6 +199,8 @@ def test_should_run_failure_reports_structured_stdout( with pytest.raises(SystemExit) as raised: main( [ + "--agent-id", + "codex-fixture", "--automations-root", str(tmp_path / "automations"), "--db-path", @@ -226,6 +239,8 @@ def fake_run(command, **kwargs): code = main( [ + "--agent-id", + "codex-fixture", "--automations-root", str(tmp_path / "automations"), "--db-path", @@ -268,6 +283,8 @@ def fake_run(command, **kwargs): code = main( [ + "--agent-id", + "codex-fixture", "--automations-root", str(tmp_path / "automations"), "--db-path", @@ -309,6 +326,8 @@ def fake_run(command, **kwargs): code = main( [ + "--agent-id", + "codex-fixture", "--automations-root", str(tmp_path / "automations"), "--db-path", diff --git a/tests/test_feedback_run_selection.py b/tests/test_feedback_run_selection.py index 9a6609f9eb..c23e584c47 100644 --- a/tests/test_feedback_run_selection.py +++ b/tests/test_feedback_run_selection.py @@ -78,6 +78,7 @@ def test_append_human_reward_binds_default_overlay_to_latest_utc_run( runtime_root_override=None, goal_id="fixture-goal", run_generated_at=None, + actor_kind="owner", reward={ "recorded_at": "2026-08-31T02:00:00Z", "decision": "continue", @@ -88,6 +89,7 @@ def test_append_human_reward_binds_default_overlay_to_latest_utc_run( assert result["selected_run"]["classification"] == "newer-utc" assert result["index_record"]["classification"] == "newer-utc" + assert result["human_reward"]["actor_kind"] == "owner" @pytest.mark.parametrize( diff --git a/tests/test_ssh_tunnel_source.py b/tests/test_ssh_tunnel_source.py index c2b5ce5109..f317a668ae 100644 --- a/tests/test_ssh_tunnel_source.py +++ b/tests/test_ssh_tunnel_source.py @@ -178,6 +178,7 @@ def test_apply_ssh_goal_lifecycle_uses_remote_typed_contract_without_local_fallb argv = run.call_args.args[0] assert argv[:4] == ["ssh", "-o", "ConnectTimeout=5", "ark-devbox"] assert "goal-lifecycle" in argv[4] + assert "--actor-kind owner" in argv[4] assert '"$HOME/.codex/loopx/registry.global.json"' in argv[4] assert result == { "ok": True, From 1fb1e5a078e40b1cf712c960cafa39eefa16de54 Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Sun, 20 Sep 2026 23:55:02 +0800 Subject: [PATCH 2/4] docs: document explicit mutation actors Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- apps/presentation/dashboard/README.md | 4 ++-- docs/book/chapters/workspace-v1.md | 5 ++++- docs/book/en/chapters/workspace-v1.md | 5 ++++- docs/guides/codex-app-autonomous-goal-experience.md | 4 ++-- docs/guides/getting-started.md | 1 + docs/guides/personal-workspace-user-guide.md | 7 +++++-- docs/heartbeat-automation-prompt.md | 4 +++- docs/integration.md | 7 +++++++ docs/product/roadmaps/experiment-controller-milestone.md | 1 + .../contracts/dashboard-reward-write-boundary.md | 3 +++ .../contracts/reward-gate-direct-write-contract.md | 2 ++ docs/status-data-contract.md | 9 ++++++--- 12 files changed, 40 insertions(+), 12 deletions(-) diff --git a/apps/presentation/dashboard/README.md b/apps/presentation/dashboard/README.md index a565dd5d32..0ff0265f77 100644 --- a/apps/presentation/dashboard/README.md +++ b/apps/presentation/dashboard/README.md @@ -207,8 +207,8 @@ It provides a unified, coherent experience for managing long-running agent Goals ```bash loopx goal-lifecycle --goal-id --operation stop - loopx goal-lifecycle --goal-id --operation stop --execute - loopx goal-lifecycle --goal-id --operation resume --execute + loopx goal-lifecycle --goal-id --operation stop --actor-kind owner --execute + loopx goal-lifecycle --goal-id --operation resume --actor-kind owner --execute loopx quota status --goal-id ``` diff --git a/docs/book/chapters/workspace-v1.md b/docs/book/chapters/workspace-v1.md index e2f488f85a..92029022ec 100644 --- a/docs/book/chapters/workspace-v1.md +++ b/docs/book/chapters/workspace-v1.md @@ -101,10 +101,13 @@ Preview 冻结规范化参数、影响范围和当前 revision。Apply 只能执 ```bash loopx goal-lifecycle --goal-id --operation stop -loopx goal-lifecycle --goal-id --operation stop --execute +loopx goal-lifecycle --goal-id --operation stop --actor-kind owner --execute loopx quota status --goal-id ``` +执行 lifecycle transition 时必须显式传入 `--actor-kind owner` 或 `controller`; +匿名预览仍然保持只读。 + 暂停会让该 Goal 退出 active attention,并使有效自动运行 quota 投影为 0;Todo、历史、证据和配置 仍保留。恢复使用显式 `resume --execute`,且不会绕过 Todo、Gate 或 quota。不要把 stop 写成 “完成 Goal”,也不要用改 quota 的方式意外恢复一个被 owner 停止的 Goal。 diff --git a/docs/book/en/chapters/workspace-v1.md b/docs/book/en/chapters/workspace-v1.md index 4f756f927f..4dbea57c3a 100644 --- a/docs/book/en/chapters/workspace-v1.md +++ b/docs/book/en/chapters/workspace-v1.md @@ -111,10 +111,13 @@ For example, the first Goal-stop command is preview-only: ```bash loopx goal-lifecycle --goal-id --operation stop -loopx goal-lifecycle --goal-id --operation stop --execute +loopx goal-lifecycle --goal-id --operation stop --actor-kind owner --execute loopx quota status --goal-id ``` +Executed lifecycle transitions require an explicit `--actor-kind owner` or +`controller`; anonymous previews remain read-only. + Stopping a Goal removes it from active attention and projects zero effective automatic-run quota while preserving Todos, history, evidence, and configuration. Explicit `resume --execute` restores scheduling eligibility but does not bypass Todo, Gate, or quota rules. Do not describe stop as completing the Goal, and diff --git a/docs/guides/codex-app-autonomous-goal-experience.md b/docs/guides/codex-app-autonomous-goal-experience.md index aec12f7729..427b9a137c 100644 --- a/docs/guides/codex-app-autonomous-goal-experience.md +++ b/docs/guides/codex-app-autonomous-goal-experience.md @@ -207,7 +207,7 @@ loopx history --goal-id "$GOAL_ID" ```bash loopx goal-lifecycle --goal-id "$GOAL_ID" --operation stop -loopx goal-lifecycle --goal-id "$GOAL_ID" --operation stop --execute +loopx goal-lifecycle --goal-id "$GOAL_ID" --operation stop --actor-kind owner --execute ``` **预期效果:** @@ -225,7 +225,7 @@ loopx goal-lifecycle --goal-id "$GOAL_ID" --operation stop --execute **输入:** ```bash -loopx goal-lifecycle --goal-id "$GOAL_ID" --operation resume --execute +loopx goal-lifecycle --goal-id "$GOAL_ID" --operation resume --actor-kind owner --execute loopx quota should-run \ --goal-id "$GOAL_ID" \ --agent-id "$AGENT_ID" \ diff --git a/docs/guides/getting-started.md b/docs/guides/getting-started.md index 7866b80179..d9cabc9f27 100644 --- a/docs/guides/getting-started.md +++ b/docs/guides/getting-started.md @@ -866,6 +866,7 @@ loopx operator-gate \ loopx reward \ --goal-id your-project-goal \ + --actor-kind owner \ --decision continue_route \ --reward positive \ --reason-summary "validation improved and the route is worth extending" diff --git a/docs/guides/personal-workspace-user-guide.md b/docs/guides/personal-workspace-user-guide.md index 292707f8c8..8bcede8342 100644 --- a/docs/guides/personal-workspace-user-guide.md +++ b/docs/guides/personal-workspace-user-guide.md @@ -108,13 +108,16 @@ CLI 提供同一套可预览、可验证的生命周期操作: loopx goal-lifecycle --goal-id --operation stop # 确认执行,再读取 quota 验证自动推进已暂停 -loopx goal-lifecycle --goal-id --operation stop --execute +loopx goal-lifecycle --goal-id --operation stop --actor-kind owner --execute loopx quota status --goal-id # 恢复;不会绕过其他运行门禁 -loopx goal-lifecycle --goal-id --operation resume --execute +loopx goal-lifecycle --goal-id --operation resume --actor-kind owner --execute ``` +`--execute` 必须显式声明 `--actor-kind owner` 或 `controller`;不带 actor 的 +预览仍保持只读。写入的 activation receipt 会保留该 actor kind。 + 执行时,LoopX 会写入权威 source registry、同步全局 registry,并验证两端 readback;任一端未验证成功时不会宣称操作完成。 切换到 SSH 状态来源后,只有来源与本机 OpenSSH 配置中的精确 Host alias 绑定时, diff --git a/docs/heartbeat-automation-prompt.md b/docs/heartbeat-automation-prompt.md index 6e8b7518e2..b41b1bdaa6 100644 --- a/docs/heartbeat-automation-prompt.md +++ b/docs/heartbeat-automation-prompt.md @@ -658,7 +658,9 @@ hint directly, otherwise do nothing. For the uniquely matched current heartbeat, If `automation_update` is unavailable in the session and `scheduler_hint.app_automation.fallback_hint.available=true`, run the bound -`fallback_hint.cli_args` (`loopx-apply-rrule`) once instead. It backs up +`fallback_hint.cli_args` (`loopx-apply-rrule`) once instead. The fallback +requires the projected registered `--agent-id`; there is no implicit Agent +default. It backs up `codex-dev.db`, syncs the automation TOML and SQLite row, and runs the bound ACK; direct SQLite edits bypass the app API, so this is a bounded fallback and never the routine path. The bridge reuses the provided parent Turn for its diff --git a/docs/integration.md b/docs/integration.md index e547d3d5d4..b60130925e 100644 --- a/docs/integration.md +++ b/docs/integration.md @@ -573,12 +573,17 @@ editing the run JSON by hand: ```bash loopx reward \ --goal-id project-goal \ + --actor-kind owner \ --decision continue_route \ --reward positive \ --reason-summary "comparable validation improved and the route is worth extending" \ --follow-up "promote to the next longer-window check" ``` +Durable reward writes require an explicit `--actor-kind owner` or +`--actor-kind controller`; `--dry-run` remains available without an actor. +The selected kind is stored with the run-bound overlay. + By default the command attaches feedback to the latest compact run for the goal. Pass `--run-generated-at ` to target an older run. The writer appends a JSONL overlay to the same `index.jsonl`; it does not mutate private @@ -604,6 +609,7 @@ overlay instead of creating a separate memory store: ```bash loopx reward \ --goal-id project-goal \ + --actor-kind owner \ --decision route_correction \ --reward mixed \ --reason-summary "fix lifecycle counters before adding more benchmark cases" \ @@ -629,6 +635,7 @@ the durable loop in one CLI call: ```bash loopx reward \ --goal-id project-goal \ + --actor-kind owner \ --decision continue_route \ --reward positive \ --reason-summary "comparable validation improved and the route is worth extending" \ diff --git a/docs/product/roadmaps/experiment-controller-milestone.md b/docs/product/roadmaps/experiment-controller-milestone.md index 88f1d5c30d..0fa4b6d45b 100644 --- a/docs/product/roadmaps/experiment-controller-milestone.md +++ b/docs/product/roadmaps/experiment-controller-milestone.md @@ -102,6 +102,7 @@ Use `loopx reward` to append this compact signal to an existing run: ```bash loopx reward \ --goal-id example-experiment-goal \ + --actor-kind owner \ --run-generated-at 2026-06-01T00:00:00+00:00 \ --decision continue_route \ --reward positive \ diff --git a/docs/reference/contracts/dashboard-reward-write-boundary.md b/docs/reference/contracts/dashboard-reward-write-boundary.md index 4f2aec53f1..5c736d190f 100644 --- a/docs/reference/contracts/dashboard-reward-write-boundary.md +++ b/docs/reference/contracts/dashboard-reward-write-boundary.md @@ -38,6 +38,9 @@ A browser append endpoint may be implemented only when all of these are true: - The payload has already passed the same validation as `/reward/dry-run`. - The response remains compact and does not return `index_path`, `json_path`, `markdown_path`, local absolute paths, or raw private evidence. +- The trusted loopback adapter records `actor_kind=owner` in both preview and + append receipts; the canonical CLI requires an explicit owner/controller + actor kind for a durable write. ## Preview Handshake diff --git a/docs/reference/contracts/reward-gate-direct-write-contract.md b/docs/reference/contracts/reward-gate-direct-write-contract.md index c31cc02255..4ba8064ac8 100644 --- a/docs/reference/contracts/reward-gate-direct-write-contract.md +++ b/docs/reference/contracts/reward-gate-direct-write-contract.md @@ -63,6 +63,8 @@ Browser append is allowed only when all of these are true: Successful append writes one run-bound `human_reward` overlay row. Active state may carry a summary, but the run overlay remains the durable source of truth. +The CLI requires `--actor-kind owner|controller`; the opt-in loopback adapter +records `owner` for its reviewed preview/apply path. ## Operator Gate diff --git a/docs/status-data-contract.md b/docs/status-data-contract.md index f47bd5f5b0..549886e174 100644 --- a/docs/status-data-contract.md +++ b/docs/status-data-contract.md @@ -2000,9 +2000,9 @@ quiet skip. For `controller_readiness`, the status export keeps only controller-stage booleans, missing gate names, operator-facing review text, next handoff condition, and compact gate rows with `id`, `ok`, and `review`. For -`human_reward`, the status export keeps only `recorded_at`, `decision`, -`reward`, `reason_summary`, and `follow_up`. For `operator_gate`, the status -export keeps only `recorded_at`, `gate`, `decision`, `operator_question`, +`human_reward`, the status export keeps only `recorded_at`, `actor_kind`, +`decision`, `reward`, `reason_summary`, and `follow_up`. For `operator_gate`, +the status export keeps only `recorded_at`, `gate`, `decision`, `operator_question`, `reason_summary`, `follow_up`, and `agent_command`. Operator-gate runs may also include a compact `operator_gate_resume_contract` with `version=operator_gate_resume_contract_v0`, `gate_id`, `created_state_ref`, @@ -2040,6 +2040,7 @@ Operators can append `human_reward` with the CLI: ```bash loopx reward \ --goal-id example-experiment-goal \ + --actor-kind owner \ --decision continue_route \ --reward positive \ --reason-summary "comparable validation improved and the route is worth extending" @@ -2055,6 +2056,7 @@ operating-rule correction, the overlay may also include a compact lesson: ```bash loopx reward \ --goal-id example-experiment-goal \ + --actor-kind owner \ --decision route_correction \ --reward mixed \ --reason-summary "run the driver repair before expanding cases" \ @@ -2105,6 +2107,7 @@ operator explicitly asks for it: ```bash loopx reward \ --goal-id example-experiment-goal \ + --actor-kind owner \ --decision continue_route \ --reward positive \ --reason-summary "comparable validation improved and the route is worth extending" \ From 5143b4f9fab0aa0e5a540c1ca84094089a70d22d Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Mon, 21 Sep 2026 01:30:55 +0800 Subject: [PATCH 3/4] fix(control-plane): complete actor migration Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- loopx/cli_commands/goal_lifecycle.py | 3 ++- loopx/cli_commands/project_lifecycle.py | 3 ++- loopx/control_plane/actor_identity.py | 5 +++++ tests/control_plane/test_shadow_observable_e2e.py | 2 +- tests/control_plane/test_shadow_writer_variant_e2e.py | 4 ++-- 5 files changed, 12 insertions(+), 5 deletions(-) diff --git a/loopx/cli_commands/goal_lifecycle.py b/loopx/cli_commands/goal_lifecycle.py index bfc348a3cf..784e41da16 100644 --- a/loopx/cli_commands/goal_lifecycle.py +++ b/loopx/cli_commands/goal_lifecycle.py @@ -4,6 +4,7 @@ from collections.abc import Callable from pathlib import Path +from ..control_plane.actor_identity import OWNER_CONTROLLER_ACTOR_CHOICES from ..control_plane.goals.activation_service import ( render_goal_activation_markdown, set_goal_activation_state, @@ -36,7 +37,7 @@ def register_goal_lifecycle_command( parser.add_argument("--reason", help="Bounded owner-visible transition reason.") parser.add_argument( "--actor-kind", - choices=("owner", "controller"), + choices=OWNER_CONTROLLER_ACTOR_CHOICES, help=( "Explicit non-Agent actor for --execute. Anonymous preview remains " "available when this option is omitted." diff --git a/loopx/cli_commands/project_lifecycle.py b/loopx/cli_commands/project_lifecycle.py index 2f55daaada..e54166e8c9 100644 --- a/loopx/cli_commands/project_lifecycle.py +++ b/loopx/cli_commands/project_lifecycle.py @@ -7,6 +7,7 @@ from ..control_plane.capability_hooks import ( PostWritebackHookRegistration, ) +from ..control_plane.actor_identity import OWNER_CONTROLLER_ACTOR_CHOICES from ..feedback import ( LESSON_KINDS, append_human_reward, @@ -105,7 +106,7 @@ def register_project_lifecycle_commands( reward_parser.add_argument("--recorded-at", help="Reward timestamp. Defaults to current UTC time.") reward_parser.add_argument( "--actor-kind", - choices=("owner", "controller"), + choices=OWNER_CONTROLLER_ACTOR_CHOICES, help=( "Explicit non-Agent actor for the durable append. Anonymous dry-run " "remains available when this option is omitted." diff --git a/loopx/control_plane/actor_identity.py b/loopx/control_plane/actor_identity.py index b24b20312a..99bc40ae74 100644 --- a/loopx/control_plane/actor_identity.py +++ b/loopx/control_plane/actor_identity.py @@ -9,6 +9,11 @@ class OwnerControllerActorKind(str, Enum): CONTROLLER = "controller" +OWNER_CONTROLLER_ACTOR_CHOICES = tuple( + actor.value for actor in OwnerControllerActorKind +) + + def normalize_owner_controller_actor( value: Any, *, diff --git a/tests/control_plane/test_shadow_observable_e2e.py b/tests/control_plane/test_shadow_observable_e2e.py index d4ecce056c..e4082854bb 100644 --- a/tests/control_plane/test_shadow_observable_e2e.py +++ b/tests/control_plane/test_shadow_observable_e2e.py @@ -184,7 +184,7 @@ def test_refresh_and_reward_owned_prose(caller: Caller) -> None: assert refreshed['ok'] is True, refreshed assert 'Read the independent lease snapshot.' in w.state.read_text() assert w.read(todo) == record - args = ('reward', '--recorded-at', '2026-09-01T12:00:00+00:00', '--decision', 'continue', + args = ('reward', '--actor-kind', 'owner', '--recorded-at', '2026-09-01T12:00:00+00:00', '--decision', 'continue', '--reward', 'positive', '--reason-summary', 'Retained argument evidence.', '--write-active-state-summary') before = w.primary() assert w.call(*args, '--dry-run')['ok'] is True diff --git a/tests/control_plane/test_shadow_writer_variant_e2e.py b/tests/control_plane/test_shadow_writer_variant_e2e.py index bb40dd0807..6f96c2eeb2 100644 --- a/tests/control_plane/test_shadow_writer_variant_e2e.py +++ b/tests/control_plane/test_shadow_writer_variant_e2e.py @@ -250,8 +250,8 @@ def prepare_rewards(ws: ShadowWorkspace) -> Path: def reward_args(reason: str, timestamp: str) -> tuple[str, ...]: - return ("reward", "--decision", "continue", "--reward", "positive", "--reason-summary", reason, - "--recorded-at", timestamp, "--write-active-state-summary") + return ("reward", "--actor-kind", "owner", "--decision", "continue", "--reward", "positive", + "--reason-summary", reason, "--recorded-at", timestamp, "--write-active-state-summary") def test_concurrent_public_rewards_preserve_both_summaries_and_run_overlays(tmp_path: Path) -> None: From 64811dac671918a048c4d0b5c414ad8cb81c4233 Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Mon, 21 Sep 2026 01:31:00 +0800 Subject: [PATCH 4/4] docs(skill): require explicit reward actors Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- ...-lifecycle-command-modularization-smoke.py | 67 ++++++++++++++++++- skills/loopx-project/SKILL.md | 14 ++-- 2 files changed, 74 insertions(+), 7 deletions(-) diff --git a/examples/cli-project-lifecycle-command-modularization-smoke.py b/examples/cli-project-lifecycle-command-modularization-smoke.py index 320010d9ea..7d1fb655fa 100644 --- a/examples/cli-project-lifecycle-command-modularization-smoke.py +++ b/examples/cli-project-lifecycle-command-modularization-smoke.py @@ -12,6 +12,7 @@ ROOT = Path(__file__).resolve().parents[1] CLI = ROOT / "loopx" / "cli.py" MODULE = ROOT / "loopx" / "cli_commands" / "project_lifecycle.py" +PROJECT_SKILL = ROOT / "skills" / "loopx-project" / "SKILL.md" # The `refresh-state` command owns its own module since #4521, so the markers # that belong to that command are required there instead of in the dispatcher. REFRESH_MODULE = ROOT / "loopx" / "cli_commands" / "project_lifecycle_refresh_state.py" @@ -164,6 +165,18 @@ def main() -> None: cli_source = CLI.read_text(encoding="utf-8") module_source = MODULE.read_text(encoding="utf-8") init_source = INIT.read_text(encoding="utf-8") + project_skill = PROJECT_SKILL.read_text(encoding="utf-8") + reward_skill = project_skill.split("## Record Human Reward", 1)[1].split( + "## Multi-Project Status", 1 + )[0] + + for marker in ( + "--dry-run", + "--actor-kind owner", + "--actor-kind controller", + "Never infer", + ): + require(marker in reward_skill, f"project skill reward flow omitted {marker}") forbidden_cli_markers = [ "refresh_state_parser = sub.add_parser", @@ -218,7 +231,7 @@ def main() -> None: "--dry-run", ), "read-only-map": ("--recommended-action", "--dry-run"), - "reward": ("--write-active-state-summary", "--lesson-kind", "--lesson-avoid", "--dry-run"), + "reward": ("--actor-kind", "--write-active-state-summary", "--lesson-kind", "--lesson-avoid", "--dry-run"), "operator-gate": ("--agent-command", "--no-global-sync"), }.items(): help_text = require_success(run_cli(command, "--help")) @@ -320,6 +333,53 @@ def main() -> None: "reward lesson avoid changed", ) + rejected_reward = run_cli( + *command_prefix, + "reward", + "--goal-id", + GOAL_ID, + "--decision", + "continue_route", + "--reward", + "positive", + "--reason-summary", + "synthetic durable reward", + "--format", + "json", + ) + require(rejected_reward.returncode == 1, "anonymous durable reward should fail") + rejected_payload = json.loads(rejected_reward.stdout) + require( + "actor kind is required" in str(rejected_payload.get("error") or ""), + f"anonymous durable reward returned the wrong error: {rejected_payload}", + ) + require(index_path.read_text(encoding="utf-8") == before_index, "rejected reward mutated run index") + + durable_reward = require_json_success( + run_cli( + *command_prefix, + "reward", + "--goal-id", + GOAL_ID, + "--actor-kind", + "owner", + "--decision", + "continue_route", + "--reward", + "positive", + "--reason-summary", + "synthetic durable reward", + "--format", + "json", + ) + ) + require(durable_reward.get("actor_kind") == "owner", "durable reward lost its actor") + persisted_reward = json.loads(index_path.read_text(encoding="utf-8").splitlines()[-1]) + require( + (persisted_reward.get("human_reward") or {}).get("actor_kind") == "owner", + "durable reward index row lost its actor", + ) + gate_payload = require_json_success( run_cli( *command_prefix, @@ -344,7 +404,10 @@ def main() -> None: (gate_payload.get("operator_gate") or {}).get("decision") == "defer", "operator-gate decision changed", ) - require(index_path.read_text(encoding="utf-8") == before_index, "dry-run commands mutated run index") + require( + len(index_path.read_text(encoding="utf-8").splitlines()) == 2, + "project lifecycle smoke wrote an unexpected number of run rows", + ) print("cli-project-lifecycle-command-modularization-smoke: ok") diff --git a/skills/loopx-project/SKILL.md b/skills/loopx-project/SKILL.md index 337bfd4e8d..c96d821930 100644 --- a/skills/loopx-project/SKILL.md +++ b/skills/loopx-project/SKILL.md @@ -968,11 +968,15 @@ loopx reward \ --dry-run ``` -Only after the user has explicitly approved recording the reward, rerun without -`--dry-run`. The durable source of truth is still the run-bound -`human_reward` overlay. The active-state writeback is a `Progress Ledger` -summary for future agents; project agents should read the reward through the -returned `project_agent_visibility.history_command`. +The preview may stay anonymous. Only after the user has explicitly approved +recording the reward, rerun without `--dry-run` and add exactly one reviewed +actor classification: `--actor-kind owner` for the owner's judgment or +`--actor-kind controller` for an authorized controller's judgment. Never infer +that classification from the process, Agent id, or command defaults. The +durable source of truth is still the run-bound `human_reward` overlay. The +active-state writeback is a `Progress Ledger` summary for future agents; +project agents should read the reward through the returned +`project_agent_visibility.history_command`. ## Multi-Project Status