From 4c8a71480fba1cb0e8da9bf9e9fd34a381b494ca Mon Sep 17 00:00:00 2001 From: huangruiteng Date: Thu, 10 Sep 2026 22:57:25 +0800 Subject: [PATCH 01/26] fix(heartbeat): share live dispatch across automation and goal hosts Signed-off-by: huangruiteng --- docs/heartbeat-automation-prompt.md | 45 ++++++++++++++++ docs/reference/automation-prompt-upgrades.md | 14 +++-- .../control_plane/heartbeat-prompt-smoke.py | 2 - .../heartbeat/automation_upgrade.py | 23 ++++++-- loopx/control_plane/heartbeat/rules.py | 4 +- loopx/control_plane/heartbeat/task_body.py | 50 ++++++------------ loopx/control_plane/quota/spend_sources.py | 11 +++- .../test_automation_prompt_upgrade.py | 25 ++++++++- .../test_goal_prompt_dispatch.py | 52 +++++++++++++++++++ .../test_heartbeat_prompt_support.py | 4 +- tests/control_plane/test_quota_settlement.py | 46 ++++++++++------ .../test_quota_settlement_cli.py | 27 ++++++++-- tests/test_ark_managed_agent_host.py | 23 +++----- ...test_ark_managed_agent_issue_fix_matrix.py | 9 ++-- tests/test_host_loop_activation.py | 32 ++++++------ .../test_visible_goal_terminal_settlement.py | 17 +++--- 16 files changed, 268 insertions(+), 116 deletions(-) create mode 100644 tests/control_plane/test_goal_prompt_dispatch.py diff --git a/docs/heartbeat-automation-prompt.md b/docs/heartbeat-automation-prompt.md index 81062b0c22..7307825309 100644 --- a/docs/heartbeat-automation-prompt.md +++ b/docs/heartbeat-automation-prompt.md @@ -55,6 +55,51 @@ transport-neutral goal prompt and lets the goal runtime own inner iteration; see the host integration protocol instead of adapting this recurring automation contract. +### Native Goal bootstrap and live execution instructions + +The automation lifecycle is the reference for shared execution, not a wrapper +around native Goal behavior. Thin automation and Codex CLI/SSH, TraeX and Ark +Managed Agent Goal bodies share quota dispatch: selection/re-entry, admitted work +and validation, then the current writeback/settlement instructions. They do not +share scheduler ownership, host completion, or blocked/resume rules. + +Native Goal bodies share a compact bootstrap. +Generate it with the host's existing profile (for example `heartbeat-prompt +--runtime-profile codex_cli --goal-id --agent-id `). +The persistent body binds the Goal/Agent and quota entrypoint; each work iteration +reads the current complete, successful quota JSON. The **inner execution +instructions remain in `interaction_contract`**, including selection/re-entry, +admitted work, and exact `cli_channel.settlement_plan.ordered_steps`. + +Native Goal bodies no longer embed a second static accountable refresh/spend +template. Those command fields remain available in the generator response for +compatibility/inspection, but are not a fallback for the live settlement plan. +When no ordered settlement plan applies, consume the current `next_cli_actions`, +including any required re-entry; do not substitute a saved generator command. +Execute selection/re-entry before work and writeback/spend only after the +corresponding validated work; a projected accounting command is not evidence +that work happened. Preserve the plan's identity and flags, and follow readback +or recovery after an ambiguous write instead of retrying a guessed command. +Failed or incomplete contract reads permit neither work nor spend. + +An unbound Codex CLI or Ark Goal with selected Todo/replan work now receives a +quota re-entry template with `--turn-instance-id`. Fill it with one public-safe +unique work-iteration id and reuse that id on retries. The next packet supplies +the same ordered settlement machinery used by automation, with `visible-goal` +attribution. SSH Goal continues to use its existing `--begin-turn` path. This +fixes the previous unbound native refresh/spend projection: those commands could +not satisfy the existing settlement identity guard. It does not turn CLI/Ark +Goals into App heartbeat receipts or move scheduler ownership into LoopX. + +The bootstrap retains work-sizing guidance and the distinction between progress +and Goal completion. A new Todo is not a new host Goal; quiet/blocked states are +not terminal no-follow-up. Codex alone retains its native blocked/resume rule. +User/repository authority still applies; a trusted host is not blanket permission. +This changes newly generated native Goal bodies and thin automation dispatch, +not active host Goal objectives or benchmark prompts already pinned to a run. +An installed runtime supplies updated dynamic contracts on later reads; upgrading +it does not retroactively remove old text from an existing Goal. + For Codex App, the generated quota command carries the compact explicit runtime profile `--runtime-profile codex_app_heartbeat` (generated commands use the equivalent compact alias `--codex-app`). The prompt does not restate the diff --git a/docs/reference/automation-prompt-upgrades.md b/docs/reference/automation-prompt-upgrades.md index 22c7808c73..387a747f49 100644 --- a/docs/reference/automation-prompt-upgrades.md +++ b/docs/reference/automation-prompt-upgrades.md @@ -17,6 +17,13 @@ another scheduler. Failure to load a complete successful response stops work and spending; it must not fall back to remembered rules. Project watches and business policy belong in LoopX state, not in this bootstrap. +The v2 wrapper distinguishes continued work, notifications and real waits: +one operation does not end the work; notification silence is not execution +silence; waits follow the live scheduler contract instead of unchanged polling. +Local entrypoint errors may be repaired within existing authority, but an +unavailable contract still forbids delivery and spending. This is not permission +to bypass a gate, retry indefinitely, or disable a healthy automation. + For a one-agent trial, pass `--cli-bin loopx-canary` to preview and apply. The bootstrap and the thin prompt's generated commands both use that executable; other automations continue to use their existing runtime. Do not promote the @@ -90,9 +97,10 @@ for rollback. Disable automatic rule adoption by replacing the bootstrap with an explicitly pinned prompt using the App, or pause the task there. LoopX runtime rollback also changes the rules loaded on the next wake. Future incompatible bootstrap -revisions still require an explicit migration; the v1 wrapper does not silently -rewrite itself. `upgrade-plan` recognizes exact v1 wrappers as runtime-loaded -thin prompts, rather than repeatedly reporting their body as stale. +revisions still require an explicit migration; a wrapper does not silently +rewrite itself. Exact v1 wrappers remain recognized as runtime-loaded thin +prompts; `automation-prompts plan` proposes v2 for explicit adoption and performs +no writes. Customized wrappers are not recognized merely from their header. ## 中文摘要 diff --git a/examples/control_plane/heartbeat-prompt-smoke.py b/examples/control_plane/heartbeat-prompt-smoke.py index 72bea59e88..b46378317f 100644 --- a/examples/control_plane/heartbeat-prompt-smoke.py +++ b/examples/control_plane/heartbeat-prompt-smoke.py @@ -599,7 +599,6 @@ def main() -> int: "`agent_read_required`", "drain/read/triage before work; settle/ACK", "P0 blocked: safe P1/P2; monitor quiet/no-spend", - "No project branches", "No learning queue unless asked", "Stop: private material, credentials, destructive git, unauthorized prod", ): @@ -697,7 +696,6 @@ def main() -> int: "guard receipt; 2 stalls->replan", "P0 blocked: safe P1/P2", "monitor quiet/no-spend", - "No project branches", "No learning queue unless asked", "Stop: private material, credentials, destructive git, unauthorized prod", ): diff --git a/loopx/control_plane/heartbeat/automation_upgrade.py b/loopx/control_plane/heartbeat/automation_upgrade.py index 460df5123b..5a4edc69f1 100644 --- a/loopx/control_plane/heartbeat/automation_upgrade.py +++ b/loopx/control_plane/heartbeat/automation_upgrade.py @@ -23,7 +23,17 @@ ) SCHEMA = "loopx_automation_prompt_upgrade_v0" -BOOTSTRAP = "LoopX managed heartbeat bootstrap v1" +BOOTSTRAP = "LoopX managed heartbeat bootstrap v2" +_LEGACY_BOOTSTRAP = "LoopX managed heartbeat bootstrap v1" +_LEGACY_INSTRUCTION = ( + "读取完整结果;仅 ok=true 时按本次 task_body 执行,不复用旧指令;" + "失败或结果不完整则停止并报告,不执行任务或记账。" +) +_BOOTSTRAP_INSTRUCTION = ( + "读取完整结果;仅 ok=true 时按本次 task_body 推进,不复用旧指令。" + "一次操作不代表结束;通知与执行分开,等待按当前调度契约,不反复空查。" + "入口异常先做权限内恢复;契约仍不可用时不执行任务或记账,并报告阻塞。" +) def digest(value: str) -> str: @@ -47,8 +57,7 @@ def bootstrap_prompt(*, registry: Path, goal_id: str, agent_id: str, f"{BOOTSTRAP}\n" "每次唤醒先执行:\n" f"```sh\n{shlex.join(args)}\n```\n" - "读取完整结果;仅 ok=true 时按本次 task_body 执行,不复用旧指令;" - "失败或结果不完整则停止并报告,不执行任务或记账。" + f"{_BOOTSTRAP_INSTRUCTION}" ) @@ -72,7 +81,7 @@ def _atomic(path: Path, text: str) -> None: def bootstrap_binding(prompt: str) -> dict | None: - if not prompt.startswith(BOOTSTRAP + "\n"): + if not prompt.startswith((BOOTSTRAP + "\n", _LEGACY_BOOTSTRAP + "\n")): return None try: command = prompt.split("```sh\n", 1)[1].split("\n```", 1)[0] @@ -98,7 +107,11 @@ def bootstrap_binding(prompt: str) -> dict | None: values["registry"] = Path(values["registry"]) if tokens[0] != values.get("cli_bin", "loopx"): return None - return values if bootstrap_prompt(**values) == prompt else None + expected = bootstrap_prompt(**values) + legacy = expected.replace(BOOTSTRAP, _LEGACY_BOOTSTRAP, 1).removesuffix( + _BOOTSTRAP_INSTRUCTION + ) + _LEGACY_INSTRUCTION + return values if prompt in (expected, legacy) else None except (IndexError, KeyError, TypeError, ValueError): return None diff --git a/loopx/control_plane/heartbeat/rules.py b/loopx/control_plane/heartbeat/rules.py index a7577c6c2a..de4929d47e 100644 --- a/loopx/control_plane/heartbeat/rules.py +++ b/loopx/control_plane/heartbeat/rules.py @@ -55,8 +55,8 @@ "lifecycle/registry and `loopx-self-repair` for runtime/projection drift." ) HOST_LOOP_QUOTA_DISPATCH_RULE = ( - "After quota, use selection_command when required; otherwise run " - "next_cli_actions[0]." + "Quota: use selection_command when required; " + "先按指令重新进入,完成获准工作并验证后,再按 next_cli_actions 写回和记账。" ) HOST_LOOP_TODO_CLOSEOUT_RULE = ( "Done -> successor first; final -> accountable refresh, spend, then " diff --git a/loopx/control_plane/heartbeat/task_body.py b/loopx/control_plane/heartbeat/task_body.py index 2d2a5ae7d0..c09b66b9b5 100644 --- a/loopx/control_plane/heartbeat/task_body.py +++ b/loopx/control_plane/heartbeat/task_body.py @@ -480,8 +480,6 @@ def render_visible_goal_task_body( completion_subject="visible Goal", pr_review_pre_quota_command=pr_review_pre_quota_command, quota_guard_command=quota_guard_command, - quota_spend_command=quota_spend_command, - progress_refresh_state_command=progress_refresh_state_command, material_queue_rule=material_queue_rule, permission_rule=permission_rule, agent_scope_instruction=agent_scope_instruction, @@ -525,8 +523,6 @@ def render_traex_visible_goal_task_body( completion_subject="visible Goal", pr_review_pre_quota_command=pr_review_pre_quota_command, quota_guard_command=quota_guard_command, - quota_spend_command=quota_spend_command, - progress_refresh_state_command=progress_refresh_state_command, material_queue_rule=material_queue_rule, permission_rule=permission_rule, agent_scope_instruction=agent_scope_instruction, @@ -540,8 +536,6 @@ def _render_goal_task_body( completion_subject: str, pr_review_pre_quota_command: str, quota_guard_command: str, - quota_spend_command: str, - progress_refresh_state_command: str, material_queue_rule: str, permission_rule: str, agent_scope_instruction: str, @@ -556,36 +550,28 @@ def _render_goal_task_body( policy_tail = _render_compact_policy_tail( material_queue_rule=material_queue_rule, permission_rule=permission_rule, - include_default_permission=True, ) return f"""Advance LoopX goal `{goal_id}` from `{active_state}` {host_preamble} {scope_block} -{RUNTIME_EXECUTION_ROUTING_RULE} - -{prequota_block}{HOST_LOOP_QUOTA_DISPATCH_RULE} -Guard: `{quota_guard_command}`. - -`should_run=false`: no delivery/spend; NOTIFY: Chinese action/gate; -otherwise wait.{host_wait_rule} +{prequota_block}Each work iteration, read complete successful JSON from: +`{quota_guard_command}` +Use the current `interaction_contract`, not remembered commands. +{HOST_LOOP_QUOTA_DISPATCH_RULE} +Use `cli_channel.settlement_plan.ordered_steps` when present; preserve identities/flags +and supply truthful evidence/actual outcomes. +Do not reconstruct refresh/spend commands. Ambiguous writes need readback/recovery, +not blind repeats. Repair local entrypoint failures within authority; while the +contract is unavailable or incomplete, no work/spend and no claim of completion. -`should_run=true`: take highest-priority unblocked in-scope todo by default; choose any -other eligible Todo with a reason. Honor claims/leases and blocker-push/recovery obligations. -Before dependencies, persist changed scope/acceptance/non-goal evidence and next todo. {SCOPE_BOUNDED_WORK_RULE} -Progress is not a new Goal boundary. Reuse this Goal until terminal; -do not create a successor merely to continue. Validate; write public-safe evidence. -{HOST_LOOP_TODO_CLOSEOUT_RULE} - -For classification/scale/outcome, never default or upgrade them to -`multi_surface` / `outcome_progress`; refresh the accountable progress record -before spending: `{progress_refresh_state_command}`. Then spend exactly once -against that refresh; no pipe/retry: `{quota_spend_command}`. -Rerun the same guard read-only. Complete {completion_subject} only on -`should_run=false` + terminal no-follow-up; else obey next action. - -No spend: gate/wait/dry-run/preflight failure/no-op/duplicate. Stop: private/company -material, credentials, destructive git, unauthorized production, or repo rules. +Continue allowed work within user/repository authority; notification controls +output, not execution. A tool call or settlement is not a stopping target. +Progress is not a new Goal boundary: do not create a new host Goal merely to +continue. After settlement recheck quota; use current continuation/wait guidance, +not repeated unchanged polling. +Complete {completion_subject} only on `should_run=false` + terminal no-follow-up; +other no-work states mean wait, not completion.{host_wait_rule} {policy_tail}""" def render_ark_managed_agent_goal_task_body( @@ -626,8 +612,6 @@ def render_ark_managed_agent_goal_task_body( completion_subject="Goal", pr_review_pre_quota_command=pr_review_pre_quota_command, quota_guard_command=quota_guard_command, - quota_spend_command=quota_spend_command, - progress_refresh_state_command=progress_refresh_state_command, material_queue_rule=material_queue_rule, permission_rule=permission_rule, agent_scope_instruction=agent_scope_instruction, @@ -694,7 +678,7 @@ def render_thin_heartbeat_task_body( P0 blocked: safe P1/P2; monitor quiet/no-spend. -No project branches; {policy_tail} Stop: private material, credentials, +{policy_tail} Stop: private material, credentials, destructive git, unauthorized prod.""" def render_heartbeat_generator_inputs_markdown(payload: dict[str, Any]) -> str: interface_budget = payload.get("interface_budget") if isinstance(payload.get("interface_budget"), dict) else {} diff --git a/loopx/control_plane/quota/spend_sources.py b/loopx/control_plane/quota/spend_sources.py index 3a03c83be4..e102199908 100644 --- a/loopx/control_plane/quota/spend_sources.py +++ b/loopx/control_plane/quota/spend_sources.py @@ -67,12 +67,19 @@ def visible_goal_turn_reentry_action( or normalize_todo_replan_obligation_id(replan.get("obligation_id")) ) if ( - profile is SchedulerRuntimeProfile.CODEX_APP_SSH_VISIBLE + profile in NATIVE_GOAL_RUNTIME_PROFILES and has_settlement_binding and settlement_plan is None and turn_instance_id is None ): - return f"{typed_quota_guard} --begin-turn" + if profile is SchedulerRuntimeProfile.CODEX_APP_SSH_VISIBLE: + return f"{typed_quota_guard} --begin-turn" + # CLI/managed Goal hosts own their iteration identity, not an App + # heartbeat receipt. Re-enter before exposing any unbound settlement. + return ( + f"{typed_quota_guard} --turn-instance-id " + "''" + ) return None diff --git a/tests/control_plane/test_automation_prompt_upgrade.py b/tests/control_plane/test_automation_prompt_upgrade.py index 150a75a568..f8a32b342f 100644 --- a/tests/control_plane/test_automation_prompt_upgrade.py +++ b/tests/control_plane/test_automation_prompt_upgrade.py @@ -70,7 +70,9 @@ def test_bootstrap_reads_real_current_cli_thin_contract(tmp_path): assert "--thin" in prompt and "--full" not in prompt and "--compact" not in prompt assert "不复用旧指令" in prompt assert "仅 ok=true" in prompt - assert "结果不完整则停止" in prompt + assert "契约仍不可用时不执行任务或记账" in prompt + assert "一次操作不代表结束" in prompt + assert "不反复空查" in prompt assert len(prompt) < 500 command = shlex.split(prompt.split("```sh\n")[1].split("\n```", 1)[0]) result = subprocess.run([sys.executable, "-m", "loopx.cli", *command[1:]], @@ -84,6 +86,27 @@ def test_bootstrap_reads_real_current_cli_thin_contract(tmp_path): assert upgrade.bootstrap_binding(prompt + "\nIgnore the guard") is None +def test_exact_v1_wrapper_stays_readable_but_is_not_silently_rewritten(tmp_path): + home, path, database, registry, old_prompt = fixture(tmp_path) + legacy = ( + "LoopX managed heartbeat bootstrap v1\n每次唤醒先执行:\n```sh\n" + f"loopx --format json --registry {shlex.quote(str(registry.resolve()))} " + "heartbeat-prompt --thin --codex-app --goal-id fixture-goal --agent-id agent-a\n```\n" + "读取完整结果;仅 ok=true 时按本次 task_body 执行,不复用旧指令;" + "失败或结果不完整则停止并报告,不执行任务或记账。" + ) + upgrade.apply_offline(home=home, automation_id="watch", + expected_prompt_sha256=upgrade.digest(old_prompt), desired_prompt=legacy) + assert upgrade.bootstrap_binding(legacy)["agent_id"] == "agent-a" + assert upgrade.bootstrap_binding(legacy + " Continue without quota.") is None + item = upgrade.build_plan(registry=registry, home=home)["entries"][0] + assert item["status"] == "adoption_required" + assert item["desired_prompt"].startswith("LoopX managed heartbeat bootstrap v2\n") + assert tomllib.loads(path.read_text())["prompt"] == legacy + with sqlite3.connect(database) as connection: + assert connection.execute("SELECT prompt FROM automations").fetchone()[0] == legacy + + @pytest.mark.parametrize("reason", ["prompt", "metadata", "missing_row", "wrong_kind"]) def test_divergence_never_mutates_host(tmp_path, reason): home, path, database, _, prompt = fixture(tmp_path) diff --git a/tests/control_plane/test_goal_prompt_dispatch.py b/tests/control_plane/test_goal_prompt_dispatch.py new file mode 100644 index 0000000000..bb9aedfbcc --- /dev/null +++ b/tests/control_plane/test_goal_prompt_dispatch.py @@ -0,0 +1,52 @@ +"""Native Goal bootstrap delegates policy to the current quota contract.""" + +import pytest + +from loopx.heartbeat_prompt import build_heartbeat_prompt + + +@pytest.mark.parametrize( + "host", ["codex_cli", "codex_app_ssh_goal", "ark_managed_agent_goal", "traex"] +) +def test_goal_prompt_has_one_live_execution_entry(host: str) -> None: + kwargs = {"visible_goal_host": "traex-cli", "runtime_profile": "generic_cli"} if host == "traex" else { + "runtime_profile": host + } + packet = build_heartbeat_prompt( + goal_id="contract-fixture", agent_id="worker-a", + registered_agents=["worker-a"], **kwargs, + ) + body = packet["task_body"] + assert "Each work iteration" in body + assert "complete successful JSON" in body + assert "interaction_contract" in body + assert "settlement_plan.ordered_steps" in body + assert "selection_command" in body + assert "next_cli_actions" in body + assert packet["quota_spend_command"] not in body + assert packet["progress_refresh_state_command"] not in body + assert "Do not reconstruct" in body + assert "new host Goal" in body + assert "terminal no-follow-up" in body + assert "notification" in body + assert "no work/spend" in body + assert "No permission asks in a trusted session" not in body + assert len(body) < 2400 + + +def test_codex_wait_rule_is_not_exported_to_other_goal_hosts() -> None: + codex = build_heartbeat_prompt(goal_id="contract-fixture", runtime_profile="codex_cli") + ark = build_heartbeat_prompt(goal_id="contract-fixture", runtime_profile="ark_managed_agent_goal") + assert "status=blocked" in codex["task_body"] + assert "status=blocked" not in ark["task_body"] + assert "do not invoke LoopX Turn" in ark["task_body"] + + +def test_explicit_goal_policy_is_preserved_not_replaced_by_bootstrap_defaults() -> None: + packet = build_heartbeat_prompt( + goal_id="contract-fixture", runtime_profile="codex_cli", + permission_rule="Only edit the assigned workspace.", + material_queue_rule="Use the approved reference set only.", + ) + assert "Only edit the assigned workspace." in packet["task_body"] + assert "Use the approved reference set only." in packet["task_body"] diff --git a/tests/control_plane/test_heartbeat_prompt_support.py b/tests/control_plane/test_heartbeat_prompt_support.py index d75c2eedf4..eab2e3cd62 100644 --- a/tests/control_plane/test_heartbeat_prompt_support.py +++ b/tests/control_plane/test_heartbeat_prompt_support.py @@ -147,9 +147,9 @@ def test_goal_hosts_preserve_sizing_and_terminal_boundary(profile: str) -> None: payload = build_heartbeat_prompt(goal_id="sizing-fixture", runtime_profile=profile) body = payload["task_body"] assert body.count(SCOPE_BOUNDED_WORK_RULE) == 1 - assert "`should_run=false`: no delivery/spend" in body + assert "no work/spend" in body assert "terminal no-follow-up" in body - assert "Then spend exactly once" in body + assert "settlement_plan.ordered_steps" in body assert payload["interface_budget"]["within_budget"] is True diff --git a/tests/control_plane/test_quota_settlement.py b/tests/control_plane/test_quota_settlement.py index e6368ea89d..13c0c76b29 100644 --- a/tests/control_plane/test_quota_settlement.py +++ b/tests/control_plane/test_quota_settlement.py @@ -631,7 +631,7 @@ def test_codex_app_actions_preserve_a_concrete_admitted_turn_identity() -> None: SchedulerRuntimeProfile.CODEX_CLI_VISIBLE, ), ) -def test_unbound_native_goal_actions_preserve_visible_goal_spend_attribution( +def test_unbound_native_goal_actions_require_host_identity_before_settlement( profile: SchedulerRuntimeProfile, ) -> None: todo_id = "todo_visible_goal" @@ -647,14 +647,12 @@ def test_unbound_native_goal_actions_preserve_visible_goal_spend_attribution( ), ) - assert len(actions) == 2 - assert actions[0].startswith("loopx refresh-state") - assert actions[1] == ( - f"loopx quota spend-slot --goal-id {GOAL_ID} --slots 1 " - f"--source visible-goal --execute --agent-id {AGENT_ID}" - ) - assert all("--todo-id" not in command for command in actions) - assert all("--turn-instance-id" not in command for command in actions) + assert len(actions) == 1 + assert "quota should-run" in actions[0] + assert "--turn-instance-id" in actions[0] + assert "--begin-turn" not in actions[0] + assert "spend-slot" not in actions[0] + assert "refresh-state" not in actions[0] def test_unbound_codex_app_ssh_goal_requires_a_guided_turn_before_delivery() -> None: @@ -677,7 +675,14 @@ def test_unbound_codex_app_ssh_goal_requires_a_guided_turn_before_delivery() -> assert "spend-slot" not in actions[0] -def test_unbound_codex_app_ssh_goal_requires_a_guided_turn_before_replan() -> None: +@pytest.mark.parametrize( + "profile", ( + SchedulerRuntimeProfile.CODEX_APP_SSH_VISIBLE, + SchedulerRuntimeProfile.CODEX_CLI_VISIBLE, + SchedulerRuntimeProfile.ARK_MANAGED_AGENT_GOAL, + ), +) +def test_unbound_native_goal_requires_identity_before_replan(profile) -> None: actions = interaction_next_cli_actions( { "goal_id": GOAL_ID, @@ -698,19 +703,30 @@ def test_unbound_codex_app_ssh_goal_requires_a_guided_turn_before_replan() -> No }, mode="autonomous_replan", scheduler_execution_context=scheduler_execution_context_for_runtime_profile( - SchedulerRuntimeProfile.CODEX_APP_SSH_VISIBLE + profile ), ) assert len(actions) == 1 assert actions[0].startswith("loopx --format json quota should-run") - assert "--runtime-profile codex_app_ssh_goal" in actions[0] - assert actions[0].endswith("--begin-turn") + assert f"--runtime-profile {profile.value}" in actions[0] + if profile is SchedulerRuntimeProfile.CODEX_APP_SSH_VISIBLE: + assert actions[0].endswith("--begin-turn") + else: + assert "--turn-instance-id" in actions[0] + assert "--begin-turn" not in actions[0] assert "refresh-state" not in actions[0] assert "spend-slot" not in actions[0] -def test_turn_bound_codex_app_ssh_goal_preserves_visible_goal_settlement() -> None: +@pytest.mark.parametrize( + "profile", ( + SchedulerRuntimeProfile.CODEX_APP_SSH_VISIBLE, + SchedulerRuntimeProfile.CODEX_CLI_VISIBLE, + SchedulerRuntimeProfile.ARK_MANAGED_AGENT_GOAL, + ), +) +def test_turn_bound_native_goal_preserves_visible_goal_settlement(profile) -> None: turn_instance_id = "guided-start:native-visible-goal" actions = interaction_next_cli_actions( @@ -721,7 +737,7 @@ def test_turn_bound_codex_app_ssh_goal_preserves_visible_goal_settlement() -> No }, mode="bounded_delivery", scheduler_execution_context=scheduler_execution_context_for_runtime_profile( - SchedulerRuntimeProfile.CODEX_APP_SSH_VISIBLE + profile ), turn_instance_id=turn_instance_id, ) diff --git a/tests/control_plane/test_quota_settlement_cli.py b/tests/control_plane/test_quota_settlement_cli.py index 293429ea08..d101b717a8 100644 --- a/tests/control_plane/test_quota_settlement_cli.py +++ b/tests/control_plane/test_quota_settlement_cli.py @@ -2016,8 +2016,9 @@ def test_visible_goal_unbound_spend_recovers_delivery_after_capability_replan( assert _spend_run_count(runtime) == 1 +@pytest.mark.parametrize("profile", ["codex_app_ssh_goal", "codex_cli", "ark_managed_agent_goal"]) def test_unbound_visible_goal_spend_returns_typed_mismatch_without_receipt( - tmp_path: Path, + tmp_path: Path, profile: str, ) -> None: project, runtime, registry_path = _write_fixture(tmp_path) @@ -2027,7 +2028,7 @@ def test_unbound_visible_goal_spend_returns_typed_mismatch_without_receipt( "quota", "should-run", "--runtime-profile", - "codex_app_ssh_goal", + profile, "--goal-id", GOAL_ID, "--agent-id", @@ -2038,7 +2039,11 @@ def test_unbound_visible_goal_spend_returns_typed_mismatch_without_receipt( assert guard_rc == 0, guard actions = guard["interaction_contract"]["cli_channel"]["next_cli_actions"] assert len(actions) == 1 - assert actions[0].endswith("--begin-turn") + if profile == "codex_app_ssh_goal": + assert actions[0].endswith("--begin-turn") + else: + assert "--turn-instance-id" in actions[0] + assert "--begin-turn" not in actions[0] assert all("spend-slot" not in action for action in actions) spend_rc, spend = _run_cli( @@ -2065,6 +2070,22 @@ def test_unbound_visible_goal_spend_returns_typed_mismatch_without_receipt( assert spend["delivery_workspace_causality"] is None assert _spend_run_count(runtime) == 0 + # Execute the projected re-entry, filling only the host-owned identity. + command = actions[0].replace( + "", TURN_ID, + ) + bound_rc, bound = _run_generated_cli(command, registry_path=registry_path) + assert bound_rc == 0, bound + plan = bound["interaction_contract"]["cli_channel"]["settlement_plan"] + assert plan["identity"]["todo_id"] == TODO_ID + assert plan["identity"]["agent_id"] == AGENT_ID + if profile != "codex_app_ssh_goal": + assert plan["identity"]["turn_instance_id"] == TURN_ID + assert [step["kind"] for step in plan["ordered_steps"]] == [ + "validation", "durable_writeback", "quota_spend", "terminal_closeout", + ] + assert _spend_run_count(runtime) == 0 + def test_begin_turn_rejects_a_non_receipt_runtime_profile(tmp_path: Path) -> None: project, runtime, registry_path = _write_fixture(tmp_path) diff --git a/tests/test_ark_managed_agent_host.py b/tests/test_ark_managed_agent_host.py index c9405ea659..ab9b5f10a5 100644 --- a/tests/test_ark_managed_agent_host.py +++ b/tests/test_ark_managed_agent_host.py @@ -73,22 +73,13 @@ def test_goal_prompt_is_one_transport_independent_activation() -> None: assert "goal loop, not automation" in normalized assert "invoke LoopX Turn" in normalized assert "Progress is not a new Goal boundary" in normalized - assert ( - "Reuse this Goal until terminal" - ) in normalized - assert "do not create a successor merely to continue" in normalized - assert ( - "Normal turns use CLI `interaction_contract`; use `loopx-project` for " - "lifecycle/registry and `loopx-self-repair` for runtime/projection drift." - in normalized - ) - assert "take highest-priority unblocked in-scope todo" in normalized - assert "claims/leases and blocker-push/recovery obligations" in normalized - assert ( - "Before dependencies, persist changed scope/acceptance/non-goal evidence " - "and next todo" - ) in normalized - assert "refresh the accountable progress record before spending" in normalized + assert "do not create a new host Goal merely to continue" in normalized + assert "current `interaction_contract`" in normalized + assert "selection_command" in normalized + assert "settlement_plan.ordered_steps" in normalized + assert "terminal no-follow-up" in normalized + assert local_development["progress_refresh_state_command"] not in local_development["task_body"] + assert local_development["quota_spend_command"] not in local_development["task_body"] def test_goal_prompt_projects_goal_only_host_contract() -> None: diff --git a/tests/test_ark_managed_agent_issue_fix_matrix.py b/tests/test_ark_managed_agent_issue_fix_matrix.py index 45220e2e88..7f67c029c7 100644 --- a/tests/test_ark_managed_agent_issue_fix_matrix.py +++ b/tests/test_ark_managed_agent_issue_fix_matrix.py @@ -167,10 +167,11 @@ def test_one_shot_host_contract_keeps_goal_closure_with_the_host() -> None: task_body = prompt["task_body"] normalized = " ".join(task_body.split()) assert "Progress is not a new Goal boundary" in normalized - assert "do not create a successor merely to continue" in normalized - assert "refresh the accountable progress record before spending" in normalized - assert "Then spend exactly once against that refresh" in normalized - assert task_body.index("loopx refresh-state") < task_body.index("quota spend-slot") + assert "do not create a new host Goal merely to continue" in normalized + assert "settlement_plan.ordered_steps" in normalized + assert "Do not reconstruct refresh/spend commands" in normalized + assert prompt["progress_refresh_state_command"] not in task_body + assert prompt["quota_spend_command"] not in task_body assert activation["activation_method"] == "submit_goal_once" assert activation["host_mutation"]["prompt_field"] == "task_body" diff --git a/tests/test_host_loop_activation.py b/tests/test_host_loop_activation.py index b9da6728aa..2eb67d6ef5 100644 --- a/tests/test_host_loop_activation.py +++ b/tests/test_host_loop_activation.py @@ -322,7 +322,7 @@ def test_deepseek_harness_native_is_distinct_same_session_host() -> None: "runtime_profile", ("ark_managed_agent_goal", "codex_app_ssh_goal"), ) -def test_goal_hosts_attribute_spend_to_current_progress_refresh( +def test_goal_hosts_delegate_spend_to_live_settlement_not_static_templates( runtime_profile: str, ) -> None: payload = build_heartbeat_prompt( @@ -334,19 +334,18 @@ def test_goal_hosts_attribute_spend_to_current_progress_refresh( refresh_command = f"`{payload['progress_refresh_state_command']}`" spend_command = f"`{payload['quota_spend_command']}`" - assert task_body.index(refresh_command) < task_body.index(spend_command) + assert refresh_command not in task_body + assert spend_command not in task_body + assert "settlement_plan.ordered_steps" in task_body + assert "preserve identities/flags" in task_body assert "" in refresh_command assert "" in refresh_command assert "" in refresh_command assert "--delivery-batch-scale multi_surface" not in refresh_command assert "--delivery-outcome outcome_progress" not in refresh_command - normalized_task_body = " ".join(task_body.split()) assert payload["quota_spend_command"].startswith("loopx --format json ") - assert ( - "never default or upgrade them to `multi_surface` / `outcome_progress`" - in normalized_task_body - ) - assert "no pipe/retry" in normalized_task_body + assert "actual outcomes" in task_body + assert "readback/recovery" in task_body def test_heartbeat_prompt_commands_keep_explicit_runtime_root() -> None: @@ -386,7 +385,7 @@ def test_heartbeat_prompt_commands_keep_explicit_runtime_root() -> None: "runtime_profile", ("ark_managed_agent_goal", "codex_app_ssh_goal"), ) -def test_goal_hosts_share_narrow_runtime_skill_routing( +def test_goal_hosts_enter_live_contract_without_a_mandatory_skill_detour( runtime_profile: str, ) -> None: payload = build_heartbeat_prompt( @@ -396,13 +395,11 @@ def test_goal_hosts_share_narrow_runtime_skill_routing( ) task_body = " ".join(payload["task_body"].split()) - assert ( - "Normal turns use CLI `interaction_contract`; use `loopx-project` for " - "lifecycle/registry and `loopx-self-repair` for runtime/projection drift." - in task_body - ) + assert "Use the current `interaction_contract`, not remembered commands" in task_body + assert "loopx-project" not in task_body + assert "loopx-self-repair" not in task_body assert "Progress is not a new Goal boundary" in task_body - assert "do not create a successor merely to continue" in task_body + assert "do not create a new host Goal merely to continue" in task_body def test_goal_hosts_reuse_thin_dispatch_and_stay_compact() -> None: @@ -435,8 +432,9 @@ def test_goal_hosts_reuse_thin_dispatch_and_stay_compact() -> None: for rule in shared_rules: assert rule in generic["task_body"] for payload in goal_hosts: - for rule in shared_rules: - assert rule in payload["task_body"] + assert "selection_command" in payload["task_body"] + assert "No learning queue unless asked." in payload["task_body"] + assert "完成获准工作并验证后,再按 next_cli_actions 写回和记账" in payload["task_body"] assert payload["interface_budget"]["budget_char_count"] <= 2_800 assert payload["interface_budget"]["within_budget"] is True diff --git a/tests/test_visible_goal_terminal_settlement.py b/tests/test_visible_goal_terminal_settlement.py index 3eb00adf3f..f0814eae29 100644 --- a/tests/test_visible_goal_terminal_settlement.py +++ b/tests/test_visible_goal_terminal_settlement.py @@ -3,7 +3,7 @@ from loopx.heartbeat_prompt import build_heartbeat_prompt -def test_visible_goal_keeps_final_todo_nonterminal_until_spend() -> None: +def test_visible_goal_delegates_settlement_then_checks_terminal_readback() -> None: payload = build_heartbeat_prompt( goal_id="terminal-settlement-fixture", thin=True, @@ -11,20 +11,15 @@ def test_visible_goal_keeps_final_todo_nonterminal_until_spend() -> None: ) task_body = " ".join(payload["task_body"].split()) - terminal_rule = ( - "Done -> successor first; final -> accountable refresh, spend, then " - "no-follow-up completion." - ) - refresh = "refresh the accountable progress record before spending" - spend = "Then spend exactly once against that refresh" - readback = "Rerun the same guard read-only" + # Ordering belongs to the live settlement contract (covered by the real CLI + # suite), not a second static list embedded in the host objective. + settlement = "cli_channel.settlement_plan.ordered_steps" + readback = "After settlement recheck quota" terminal_readback = ( "Complete visible Goal only on `should_run=false` + terminal " "no-follow-up" ) - assert task_body.index(terminal_rule) < task_body.index(refresh) - assert task_body.index(refresh) < task_body.index(spend) - assert task_body.index(spend) < task_body.index(readback) + assert task_body.index(settlement) < task_body.index(readback) assert terminal_readback in task_body assert payload["interface_budget"]["within_budget"] is True From ca2e5e2503d9f6f254cb38c438547b5f61ea5cb6 Mon Sep 17 00:00:00 2001 From: huangruiteng Date: Thu, 10 Sep 2026 23:05:36 +0800 Subject: [PATCH 02/26] test(heartbeat): add opt-in release native Goal regression Signed-off-by: huangruiteng --- docs/development/testing-and-quality.md | 31 +++ docs/product/release-readiness.md | 4 + scripts/qualify-native-goal-release.py | 192 ++++++++++++++++++ tests/fixtures/native_goal_ledger/TASK.md | 25 +++ tests/fixtures/native_goal_ledger/verify.py | 74 +++++++ .../test_native_goal_release_qualification.py | 89 ++++++++ 6 files changed, 415 insertions(+) create mode 100644 scripts/qualify-native-goal-release.py create mode 100644 tests/fixtures/native_goal_ledger/TASK.md create mode 100644 tests/fixtures/native_goal_ledger/verify.py create mode 100644 tests/test_native_goal_release_qualification.py diff --git a/docs/development/testing-and-quality.md b/docs/development/testing-and-quality.md index 881556b017..6e540fd40d 100644 --- a/docs/development/testing-and-quality.md +++ b/docs/development/testing-and-quality.md @@ -737,6 +737,37 @@ Onboarding 输入来自正式 guided packet builder;provider 调用前只替 门禁错误、写入或 quota 消耗。Human gate 的优先级是显式规则:等待用户时没有 executable work 属于预期状态,不能误判为 projection gap。 +## Release-only native Goal regression / 仅发布前的原生 Goal 回归 + +`scripts/qualify-native-goal-release.py` exercises the real Codex CLI app-server +Goal lifecycle on a disposable ledger project with two dependent Todos. It +reuses the shipped native Goal transport and current prompt, then checks an +independent acceptance oracle, completed Todos, unique bound spends, durable +writeback readback, and terminal no-follow-up quota. This is not a benchmark +score or evidence of universal model reliability. + +```bash +# No model invocation, no token cost; explicit skipped result, exit 0. +python3 scripts/qualify-native-goal-release.py +# Release operator opt-in only; uses the existing configured Codex model/auth. +python3 scripts/qualify-native-goal-release.py --release-live +``` + +Do not add the live command to default pytest, PR CI, per-diff canaries, or +ordinary developer iteration. The deterministic runner-policy tests may run +there; they never opt into real model execution. Missing CLI, native Goals or +Codex authentication returns `skipped` and exit 0, not a claimed live pass. +Once qualification is attempted, failed acceptance, incomplete settlement, +blocked/unfinished Goals and deadline expiry fail with exit 1. The default +deadline is 1,200 seconds; this is a wall-clock ceiling, not a token budget. + +仅 release 前显式开启,避免默认消耗开发者 token。CI/本机环境不支持时跳过且不阻塞, +但保留 `skipped` 标记;真实执行后失败不能冒充环境跳过。复用当前 Codex 的模型与登录, +不复制凭据或会话、不修改活跃 Goal/automation。任务、registry、runtime 与 Git worktree +在一次性目录内;沙箱允许该目录及本地 TS worker 所需的网络能力, +这不是网络隔离,任务不授权外部操作。回归脚本不采集或上传原始对话/工具日志, +公开结果仅包含状态、计数和错误类别;Codex 自身仍按当前 host 配置保存会话。 + ## Exact Release Commit Gate / 精确发布 Commit 门 The final release gate does not rerun tests through a second orchestration diff --git a/docs/product/release-readiness.md b/docs/product/release-readiness.md index cbaaee4ab9..179d95ac22 100644 --- a/docs/product/release-readiness.md +++ b/docs/product/release-readiness.md @@ -144,6 +144,10 @@ Before moving `stable`, maintainers should: - bump `loopx.__version__` and `pyproject.toml` together when user-visible release behavior changes; - create or verify the matching Git tag, for example `v0.1.3`; +- for host Goal/prompt changes, explicitly run the + [release-only native Goal regression](../development/testing-and-quality.md#release-only-native-goal-regression--仅发布前的原生-goal-回归) + in a supported Codex environment; record an unavailable environment as + `skipped`, not a live pass. Never enable paid model execution in default PR CI; - fast-forward `stable` to that tagged commit after the release canary passes; - confirm `release.json`, `loopx doctor`, and `loopx update check` report the same package version and tag; diff --git a/scripts/qualify-native-goal-release.py b/scripts/qualify-native-goal-release.py new file mode 100644 index 0000000000..9c38389f66 --- /dev/null +++ b/scripts/qualify-native-goal-release.py @@ -0,0 +1,192 @@ +#!/usr/bin/env python3 +"""Opt-in, release-only Codex Goal qualification. Default execution costs no tokens.""" + +from __future__ import annotations + +import argparse +import importlib.util +import json +import os +from pathlib import Path +import shlex +import shutil +import subprocess +import sys +import tempfile + +REPO = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(REPO)) +FIXTURE = REPO / "tests/fixtures/native_goal_ledger" +GOAL = "heartbeat-flow-main-control" +AGENT = "worker-a" +TODOS = {"todo_reducer", "todo_cli"} + + +def prerequisite_failure(codex: str) -> str | None: + for executable in (codex, "git", "node"): + if not shutil.which(executable): + return "required_executable_unavailable" + for command, reason in ( + ([codex, "login", "status"], "codex_auth_unavailable"), + ([codex, "features", "list"], "native_goals_unavailable"), + ): + result = subprocess.run(command, capture_output=True, text=True, timeout=30) + if result.returncode: + return reason + if command[1] == "features" and not any( + line.split()[:1] == ["goals"] for line in result.stdout.splitlines() + ): + return reason + return None + + +def setup(root: Path) -> tuple[Path, Path, Path]: + # Reuse the existing real CLI fixture, not an alternate registry contract. + spec = importlib.util.spec_from_file_location( + "heartbeat_fixture", REPO / "examples/control_plane/heartbeat-quota-flow-smoke.py", + ) + fixture = importlib.util.module_from_spec(spec) + spec.loader.exec_module(fixture) + project, runtime, registry = fixture.write_fixture(root) + config = json.loads(registry.read_text()) + goal = config["goals"][0] + goal.update(state_file="ACTIVE_GOAL_STATE.md", coordination={"registered_agents": [AGENT]}) + goal["quota"]["allowed_slots"] = 12 + registry.write_text(json.dumps(config)) + runtime.mkdir(parents=True, exist_ok=True) + (runtime / "registry.global.json").write_text(json.dumps(config)) + (project / "ACTIVE_GOAL_STATE.md").write_text( + '---\nstatus: active-read-only\nowner_mode: goal\n' + 'objective: "Deliver the finite local ledger specification."\n---\n\n' + '# Ledger\n\n## Objective\n\nDeliver TASK.md, validate and settle both Todos.\n\n' + '## Next Action\n\nImplement the reducer, then the CLI.\n\n' + '## User Todo\n\n## Agent Todo\n\n' + '- [ ] [P1] Implement and validate the TASK.md reducer.\n' + ' \n' + '- [ ] [P1] Implement the CLI, verify full acceptance and close this finite Goal.\n' + ' \n', + encoding="utf-8", + ) + shutil.copyfile(FIXTURE / "TASK.md", project / "TASK.md") + launcher = project / "bin/loopx" + launcher.parent.mkdir() + launcher.write_text( + "#!/bin/sh\nexport LOOPX_PYTHON=" + shlex.quote(sys.executable) + "\nexec " + + shlex.join([str(REPO / "scripts/loopx"), "--registry", str(registry), + "--runtime-root", str(runtime)]) + ' "$@"\n', + ) + launcher.chmod(0o700) + git = ["git", "-C", str(project), "-c", "core.hooksPath=/dev/null", + "-c", "user.name=Fixture", "-c", "user.email=fixture@example.com"] + for args in (("init", "-q"), ("remote", "add", "origin", "https://example.com/ledger.git"), + ("add", "TASK.md"), ("commit", "-qm", "Initialize synthetic acceptance")): + subprocess.run([*git, *args], check=True, capture_output=True, timeout=30) + return project, runtime, launcher + + +def cli(launcher: Path, *args: str) -> dict: + result = subprocess.run( + [str(launcher), "--format", "json", *args], cwd=launcher.parent.parent, + capture_output=True, text=True, timeout=120, + ) + if result.returncode: + raise RuntimeError("fixture_cli_failed") + packet = json.loads(result.stdout) + if packet.get("ok") is not True: + raise RuntimeError("fixture_cli_not_ok") + return packet + + +def verify_settlement(runtime: Path, todos: list[dict]) -> int: + from loopx.control_plane.quota.settlement import read_heartbeat_settlement + + assert {t["todo_id"] for t in todos if t.get("status") == "done"} == TODOS + rows = [json.loads(line) for line in + (runtime / "goals" / GOAL / "runs/index.jsonl").read_text().splitlines()] + spends = [r for r in rows if r.get("classification") == "quota_slot_spent"] + assert {r.get("todo_id") for r in spends if r.get("todo_id")} == TODOS + assert all(isinstance(r.get("settlement_identity"), dict) for r in spends), "unbound_spend" + identities = [r["settlement_identity"]["effect_id"] for r in spends] + assert len(identities) == len(set(identities)), "duplicate_spend" + for row in spends: + readback = read_heartbeat_settlement( + runtime, goal_id=GOAL, agent_id=AGENT, todo_id=row.get("todo_id"), + replan_obligation_id=row.get("replan_obligation_id"), + turn_instance_id=row["turn_instance_id"], + ) + assert readback and readback.writeback and readback.spend + return len(spends) + + +def qualify(root: Path, codex: str, timeout: int) -> dict: + from loopx.capabilities.benchmark_toolkit.native_codex_goal import ( + NativeGoalConfig, StdioNativeGoalTransport, run_native_goal_until_terminal, + ) + + project, runtime, launcher = setup(root) + prompt = cli(launcher, "heartbeat-prompt", "--runtime-profile", "codex_cli", + "--goal-id", GOAL, "--agent-id", AGENT, "--cli-bin", str(launcher)) + cli(launcher, "quota", "should-run", "--runtime-profile", "codex_cli", + "--goal-id", GOAL, "--agent-id", AGENT) + config = NativeGoalConfig( + cwd=str(project), objective=prompt["task_body"], + task_instruction="Proceed with the active Goal. Read TASK.md. Use bin/loopx " + "for projected LoopX commands; preserve its isolated binding.", + sandbox_policy={"type": "workspaceWrite", "writableRoots": [str(root)], + "networkAccess": True}, # Local TS worker needs loopback. + ) + command = [codex, "--enable", "goals", "-c", 'shell_environment_policy.inherit="all"', + "-c", "project_doc_max_bytes=0", "app-server", "--stdio"] + with tempfile.TemporaryFile(mode="w+") as stderr: + with StdioNativeGoalTransport.spawn(command, cwd=str(project), env=dict(os.environ), + stderr=stderr) as transport: + turn = run_native_goal_until_terminal(transport, config, timeout_sec=timeout) + assert turn.post_goal_status == "complete", "native_goal_did_not_complete" + assert (project / "TASK.md").read_bytes() == (FIXTURE / "TASK.md").read_bytes() + oracle = subprocess.run([sys.executable, str(FIXTURE / "verify.py"), str(project)], + capture_output=True, timeout=60) + assert oracle.returncode == 0, "independent_acceptance_failed" + todos = cli(launcher, "todo", "list", "--goal-id", GOAL, "--role", "agent")["todos"] + spends = verify_settlement(runtime, todos) + quota = cli(launcher, "quota", "should-run", "--goal-id", GOAL, + "--agent-id", AGENT, "--runtime-profile", "codex_cli") + assert quota["should_run"] is False + assert quota["interaction_contract"]["mode"] == "terminal_no_followup" + return {"status": "passed", "model_executed": True, + "native_turns": turn.turn_completed_count, "settled_spends": spends, + "independent_acceptance": "passed"} + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--release-live", action="store_true", + help="Explicit release qualification; permits real model cost.") + parser.add_argument("--codex-bin", default="codex") + parser.add_argument("--timeout-seconds", type=int, default=1200) + args = parser.parse_args(argv) + if args.timeout_seconds <= 0: + parser.error("timeout must be positive") + if not args.release_live: + result = {"status": "skipped", "reason": "release_opt_in_required", "model_executed": False} + else: + try: + reason = prerequisite_failure(args.codex_bin) + if reason: + result = {"status": "skipped", "reason": reason, "model_executed": False} + else: + with tempfile.TemporaryDirectory(prefix="loopx-release-goal-") as raw: + result = qualify(Path(raw), args.codex_bin, args.timeout_seconds) + except Exception as exc: + # Never turn an attempted-but-failing qualification into an environment skip. + # Raw errors can contain host paths, prompts or account details. + result = {"status": "failed", "error_kind": type(exc).__name__} + print(json.dumps(result, sort_keys=True)) + return 1 if result["status"] == "failed" else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/fixtures/native_goal_ledger/TASK.md b/tests/fixtures/native_goal_ledger/TASK.md new file mode 100644 index 0000000000..cb2fbda446 --- /dev/null +++ b/tests/fixtures/native_goal_ledger/TASK.md @@ -0,0 +1,25 @@ +# Replay-safe local ledger + +Build `ledger.py` using only the Python standard library. Implement +`reduce_events(events)` and `python ledger.py INPUT` (UTF-8 JSONL). + +Every event has nonempty string `id`, `account`, and `type`. Credit/debit events +have a strictly positive integer `amount` (booleans are invalid). Reverse events +have `target`, referencing an earlier credit/debit in the same account. Reverse +an original at most once; never reverse a reversal. Reject overdrafts at every +prefix, including reversal of a credit. Exact event replays are idempotent; +conflicting reuse of an id is invalid. Reject missing/extra fields, invalid +types and references with `ValueError`. Never mutate input dictionaries. Keep +zero-balance accounts; names are case-sensitive and may contain Unicode. + +The CLI ignores blank lines and prints exactly one JSON object sorted by account +with a trailing newline. Empty input succeeds with `{}`. Invalid JSON or domain +input must leave stdout empty, write a concise diagnostic to stderr, and exit +nonzero. Add reducer and subprocess tests, including long replay, and a README. + +Complete both dependent LoopX Todos and settle validated work using the current +contract. This is a finite local task: no upload, publication, deployment, +package installation, or external side effect is part of acceptance. Keep all +changes in the disposable project/worktrees. Do not change this specification +or LoopX source to make the task pass. Do not substitute fabricated evidence +or a non-delivery declaration for actual code delivery. diff --git a/tests/fixtures/native_goal_ledger/verify.py b/tests/fixtures/native_goal_ledger/verify.py new file mode 100644 index 0000000000..b0ca4869d1 --- /dev/null +++ b/tests/fixtures/native_goal_ledger/verify.py @@ -0,0 +1,74 @@ +"""Independent release oracle; the model's own tests are not acceptance authority.""" + +import copy +import importlib.util +import json +from pathlib import Path +import subprocess +import sys +import tempfile + + +def verify(project: Path) -> None: + spec = importlib.util.spec_from_file_location("candidate_ledger", project / "ledger.py") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + reduce_events = module.reduce_events + credit = {"id": "c", "account": "雪", "type": "credit", "amount": 100} + debit = {"id": "d", "account": "雪", "type": "debit", "amount": 35} + reverse = {"id": "r", "account": "雪", "type": "reverse", "target": "d"} + events = [credit, debit, credit.copy(), reverse, reverse.copy()] + before = copy.deepcopy(events) + assert reduce_events(events) == {"雪": 100} + assert events == before + assert reduce_events([credit, {**reverse, "target": "c"}]) == {"雪": 0} + assert reduce_events([]) == {} + for invalid in ( + [{**credit, "amount": True}], [{**credit, "amount": 1.0}], + [{**credit, "amount": 0}], [{**credit, "extra": 1}], + [{"id": "missing"}], [debit], [reverse, credit], + [credit, {**credit, "amount": 2}], + [credit, debit, {**reverse, "target": "c"}], + [credit, debit, {**reverse, "account": "other"}], + [credit, debit, reverse, {**reverse, "id": "r2"}], + [credit, debit, reverse, {**reverse, "id": "r2", "target": "r"}], + ): + untouched = copy.deepcopy(invalid) + try: + reduce_events(invalid) + except ValueError: + pass + else: + raise AssertionError("invalid_event_accepted") + assert invalid == untouched + long = [{**credit, "id": str(i), "account": "A" if i % 2 else "a"} + for i in range(12000)] + assert reduce_events(long + long) == {"A": 600000, "a": 600000} + with tempfile.TemporaryDirectory(prefix="ledger-oracle-") as raw: + source = Path(raw) / "input.jsonl" + for content, expected in ( + ("\n" + "\n".join(json.dumps(e) for e in events), {"雪": 100}), + ("\n".join(json.dumps(e) for e in [credit, {**credit, "id": "a", "account": "A"}]), + {"A": 100, "雪": 100}), + ("\n", {}), + (json.dumps(credit) + "\nnot-json", None), + (json.dumps(debit), None), + ): + source.write_text(content, encoding="utf-8") + result = subprocess.run( + [sys.executable, str(project / "ledger.py"), str(source)], + capture_output=True, text=True, timeout=30, + ) + if expected is None: + assert result.returncode != 0 and result.stdout == "" and result.stderr + else: + assert result.returncode == 0 and result.stderr == "" + assert result.stdout.endswith("\n") and len(result.stdout.splitlines()) == 1 + assert json.loads(result.stdout) == expected + assert list(json.loads(result.stdout)) == sorted(expected) + assert (project / "README.md").is_file() + + +if __name__ == "__main__": + verify(Path(sys.argv[1]).resolve()) + print("ledger acceptance passed") diff --git a/tests/test_native_goal_release_qualification.py b/tests/test_native_goal_release_qualification.py new file mode 100644 index 0000000000..8ec588a820 --- /dev/null +++ b/tests/test_native_goal_release_qualification.py @@ -0,0 +1,89 @@ +"""Runner policy tests never start a real model, including under ordinary CI.""" + +import importlib.util +import json +from pathlib import Path + +import pytest + +REPO = Path(__file__).resolve().parents[1] +spec = importlib.util.spec_from_file_location( + "release_goal", REPO / "scripts/qualify-native-goal-release.py", +) +runner = importlib.util.module_from_spec(spec) +spec.loader.exec_module(runner) + + +def test_default_does_not_even_probe_model_environment(monkeypatch, capsys): + def forbidden(*args): + raise AssertionError("default must not touch a model host") + monkeypatch.setattr(runner, "prerequisite_failure", forbidden) + monkeypatch.setattr(runner, "qualify", forbidden) + assert runner.main([]) == 0 + result = json.loads(capsys.readouterr().out) + assert result == {"status": "skipped", "reason": "release_opt_in_required", "model_executed": False} + + +def test_missing_release_environment_is_explicit_skip(monkeypatch, capsys): + monkeypatch.setattr(runner, "prerequisite_failure", lambda _: "codex_auth_unavailable") + assert runner.main(["--release-live"]) == 0 + assert json.loads(capsys.readouterr().out)["status"] == "skipped" + + +def test_attempted_release_failure_is_not_converted_to_skip(monkeypatch, capsys): + monkeypatch.setattr(runner, "prerequisite_failure", lambda _: None) + def failing(*args): + raise RuntimeError("sensitive diagnostic sentinel") + monkeypatch.setattr(runner, "qualify", failing) + assert runner.main(["--release-live"]) == 1 + result = json.loads(capsys.readouterr().out) + assert result == {"status": "failed", "error_kind": "RuntimeError"} + + +def test_release_timeout_must_be_positive(): + with pytest.raises(SystemExit): + runner.main(["--timeout-seconds", "0"]) + + +def test_synthetic_fixture_real_cli_projects_identity_reentry(tmp_path): + project, _, launcher = runner.setup(tmp_path) + assert (project / "TASK.md").read_bytes() == (runner.FIXTURE / "TASK.md").read_bytes() + quota = runner.cli(launcher, "quota", "should-run", "--runtime-profile", "codex_cli", + "--goal-id", runner.GOAL, "--agent-id", runner.AGENT) + assert quota["should_run"] is True + assert quota["selected_todo"]["todo_id"] == "todo_reducer" + actions = quota["interaction_contract"]["cli_channel"]["next_cli_actions"] + assert len(actions) == 1 and "--turn-instance-id" in actions[0] + assert "spend-slot" not in actions[0] + + # Qualify the fixture's terminal shape without paying for a model run. A + # missing user section is not proof of zero user obligations. + state = project / "ACTIVE_GOAL_STATE.md" + state.write_text(state.read_text().replace("- [ ]", "- [x]").replace( + "status=open", "status=done no_followup=true", + )) + terminal = runner.cli(launcher, "quota", "should-run", "--runtime-profile", "codex_cli", + "--goal-id", runner.GOAL, "--agent-id", runner.AGENT) + assert terminal["should_run"] is False + assert terminal["interaction_contract"]["mode"] == "terminal_no_followup" + + state.write_text(state.read_text().replace("## User Todo\n\n", "")) + incomplete = runner.cli(launcher, "quota", "should-run", "--runtime-profile", "codex_cli", + "--goal-id", runner.GOAL, "--agent-id", runner.AGENT) + assert incomplete["interaction_contract"]["mode"] != "terminal_no_followup" + + +@pytest.mark.parametrize("mutation", ["unbound", "duplicate"]) +def test_settlement_oracle_rejects_missing_identity_and_duplicate_spend(tmp_path, mutation): + rows = [{"classification": "quota_slot_spent", "todo_id": todo, + "settlement_identity": {"effect_id": todo}} for todo in sorted(runner.TODOS)] + if mutation == "unbound": + rows.append({"classification": "quota_slot_spent"}) + else: + rows.append(rows[0].copy()) + index = tmp_path / "goals" / runner.GOAL / "runs/index.jsonl" + index.parent.mkdir(parents=True) + index.write_text("\n".join(json.dumps(row) for row in rows)) + todos = [{"todo_id": todo, "status": "done"} for todo in runner.TODOS] + with pytest.raises(AssertionError): + runner.verify_settlement(tmp_path, todos) From bea6b4e742bcaabd4ddd2421ebf5b7877f6bc0be Mon Sep 17 00:00:00 2001 From: huangruiteng Date: Thu, 10 Sep 2026 23:30:54 +0800 Subject: [PATCH 03/26] test(hosts): add Claude release and response-loss coverage Signed-off-by: huangruiteng --- docs/development/testing-and-quality.md | 36 +++++ docs/heartbeat-automation-prompt.md | 8 + .../claude_goal_mode/scripts/goalmode_cmd.py | 29 ++-- scripts/qualify-claude-goal-release.py | 145 ++++++++++++++++++ scripts/qualify-native-goal-release.py | 28 ++-- .../test_claude_goal_release_qualification.py | 115 ++++++++++++++ tests/test_goal_mode_mcp_settlement.py | 42 +++++ .../test_native_goal_release_qualification.py | 19 +++ 8 files changed, 401 insertions(+), 21 deletions(-) create mode 100644 scripts/qualify-claude-goal-release.py create mode 100644 tests/test_claude_goal_release_qualification.py diff --git a/docs/development/testing-and-quality.md b/docs/development/testing-and-quality.md index 6e540fd40d..c543d5a24e 100644 --- a/docs/development/testing-and-quality.md +++ b/docs/development/testing-and-quality.md @@ -768,6 +768,42 @@ deadline is 1,200 seconds; this is a wall-clock ceiling, not a token budget. 这不是网络隔离,任务不授权外部操作。回归脚本不采集或上传原始对话/工具日志, 公开结果仅包含状态、计数和错误类别;Codex 自身仍按当前 host 配置保存会话。 +### Claude Code and release coverage / Claude Code 与发布覆盖 + +```bash +# No provider call by default. Explicit release opt-in uses ARK_API_KEY from the environment. +python3 scripts/qualify-claude-goal-release.py --release-live +``` + +This arm uses the same ledger specification, independent oracle and durable +settlement readback as the Codex arm. It launches actual Claude Code with the +project's shipped `loop.md` and LoopX stdio MCP server, using +`doubao-seed-evolving` through Ark's Anthropic-compatible API. It does not +inherit another Anthropic account, install into the user's Claude configuration, +or retain host sessions. The subprocess timeout also cleans its process group +on POSIX. Allowed local development tools are not a security sandbox; the +synthetic task authorizes no external side effects. + +**A headless work-loop pass is not a `/loop` timer pass.** The release report +explicitly returns `scheduler_qualification=not_run_headless`; interactive +native wakeup, cancel/resume and process-restart behavior need their own host +qualification. Do not turn repeated `claude -p` invocations into a substitute +scheduler and claim host lifecycle coverage. + +Before calling a changed host surface release-qualified, distinguish: + +| Boundary | Required evidence | +| --- | --- | +| Work and terminal closeout | Final candidate, actual host, independent artifact checks, completed Todos and terminal quota; code delivery alone is insufficient. | +| Idempotency and failure | Real committed lifecycle/writeback/spend followed by lost-response injection and same-intent retries; one final spend. Failed declared validation must not complete or spend. | +| Authority and transport | Actual MCP initialization/tool invocation and mismatched-agent rejection; existing claim/lease and validation suites remain required. | +| Host lifecycle | Native scheduler wakeup/cancellation/resume on supported versions, reported separately from headless execution. | +| Upgrade and isolation | Exact managed-wrapper recognition, preview/apply revision checks, preserved scheduler state, explicit skips, no default model calls or leaked test processes. | + +普通 CI 只跑确定性规则、真实 CLI/MCP 和故障注入;模型执行仍仅 release 前显式启用。 +环境缺失可 skip 且退出成功,但最终版本没有完整的真实 host 结果时,不得写成 +“产品级发布验证通过”。单次成功也不是模型可靠性或长程调度 soak 的证明。 + ## Exact Release Commit Gate / 精确发布 Commit 门 The final release gate does not rerun tests through a second orchestration diff --git a/docs/heartbeat-automation-prompt.md b/docs/heartbeat-automation-prompt.md index 7307825309..3ad905ea34 100644 --- a/docs/heartbeat-automation-prompt.md +++ b/docs/heartbeat-automation-prompt.md @@ -100,6 +100,14 @@ not active host Goal objectives or benchmark prompts already pinned to a run. An installed runtime supplies updated dynamic contracts on later reads; upgrading it does not retroactively remove old text from an existing Goal. +Claude Code's MCP-backed `loop.md` follows the same work-sizing rule and current +quota contract, without a fixed one-segment limit or empty-Todo-list completion +shortcut. Its `complete_task` tool already owns the ordered writeback/spend +transaction: callers must not perform a second accounting sequence through CLI. +Partial work is not Todo completion. Native `/loop` remains Claude's scheduler; +only the current Goal's wakeup may be cancelled after terminal no-follow-up. +These changes apply when `loop.md` is regenerated, not by editing active user files. + For Codex App, the generated quota command carries the compact explicit runtime profile `--runtime-profile codex_app_heartbeat` (generated commands use the equivalent compact alias `--codex-app`). The prompt does not restate the diff --git a/loopx/claude_goal_mode/scripts/goalmode_cmd.py b/loopx/claude_goal_mode/scripts/goalmode_cmd.py index 2009089a66..829469d9ae 100644 --- a/loopx/claude_goal_mode/scripts/goalmode_cmd.py +++ b/loopx/claude_goal_mode/scripts/goalmode_cmd.py @@ -36,6 +36,9 @@ # registry-driven context, shared with the hooks/MCP sys.path.insert(0, str(HERE.parent / "hooks")) from goal_state import goal_context, find_registry, loop_md_path # noqa: E402 +from loopx.control_plane.heartbeat.rules import ( # noqa: E402 + SCOPE_BOUNDED_WORK_RULE, +) def gh_prefix(): @@ -66,14 +69,22 @@ def loop_md_content(goal_id, agent_id) -> str: f"\n" f"loopx tick — advance goal `{goal_id}` (agent `{agent_id}`). Use the wired loopx MCP\n" f"tools; do NOT run `loopx --help` or guess ids.\n\n" - f"1. Call `should_run()`. If should_run=false, say why in ONE line and STOP this\n" - f" iteration (do nothing else) — loopx has paused, gated, or converged.\n" - f"2. If should_run=true: `claim_task` the next open todo, do ONE bounded segment,\n" - f" then VERIFY it with a real check (build/test) — never claim success from\n" - f" reasoning — and `complete_task(..., agent_id=\"{agent_id}\", evidence=\"\")`.\n" - f"3. Stay within the goal's scope; do not start initiatives outside the todos.\n" - f" Irreversible actions (push/delete) only to finish work already authorized.\n" - f"4. Re-check `should_run()`; stop when should_run=false or no open todos remain.\n" + "Read complete successful `should_run()` JSON each work iteration. Follow its\n" + "current `interaction_contract`: selection/re-entry before admitted work,\n" + "then validation and settlement. Never infer completion from an empty Todo list.\n" + f"{SCOPE_BOUNDED_WORK_RULE}\n" + "Honor claim/lease and user/repository authority; claim only when required.\n" + "Run real acceptance checks before `complete_task`; supply truthful evidence\n" + f"and the bound agent_id=\"{agent_id}\". Complete only finished Todos, not partial work.\n" + "That MCP operation owns writeback/spend; do not repeat its accounting via CLI.\n" + "After a lost response, read back or retry the same completion intent; do not\n" + "invent a new successor or settlement identity. Recheck `should_run()` afterward.\n" + "Continue authorized work while the live contract requires it; notification\n" + "silence is not execution silence. Waiting is not completion: follow current\n" + "host scheduling guidance without repeated unchanged polling. Terminal\n" + "no-follow-up ends this Goal's work; cancel only its own recurring wakeup.\n" + "Repair entrypoint errors within authority; an unavailable/incomplete contract\n" + "permits neither work nor spending and must not be reported as completion.\n" ) @@ -230,7 +241,7 @@ def main(): print(f" todo_id : {tid}") print(f" scope : {proj}") print(f" task : {task}") - print(f" wrote : .claude/loop.md (the per-tick protocol)") + print(" wrote : .claude/loop.md (the per-tick protocol)") print() print("START WORKING — run native `/loop` (Claude self-paces) or `/loop 10m` (fixed cadence).") print("Each /loop tick runs: should_run -> claim_task -> ONE bounded verified segment -> complete_task.") diff --git a/scripts/qualify-claude-goal-release.py b/scripts/qualify-claude-goal-release.py new file mode 100644 index 0000000000..349cf7f985 --- /dev/null +++ b/scripts/qualify-claude-goal-release.py @@ -0,0 +1,145 @@ +#!/usr/bin/env python3 +"""Release-only Claude Code + Doubao work-loop qualification, never a default live test.""" + +from __future__ import annotations + +import argparse +import importlib.util +import json +import os +from pathlib import Path +import shutil +import signal +import subprocess +import sys +import tempfile + +REPO = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(REPO)) +spec = importlib.util.spec_from_file_location("goal_release", REPO / "scripts/qualify-native-goal-release.py") +shared = importlib.util.module_from_spec(spec) +spec.loader.exec_module(shared) + +from loopx.control_plane.testing.doubao_model_behavior_actor import ( # noqa: E402 + DOUBAO_SEED_EVOLVING_MODEL, +) + +ARK_ANTHROPIC_BASE = "https://ark.cn-beijing.volces.com/api/compatible" + + +def run_host(command: list[str], *, cwd: Path, env: dict, timeout: float) -> str: + """Bound output memory and clean this test's process group, including on timeout.""" + with tempfile.TemporaryFile() as stdout, tempfile.TemporaryFile() as stderr: + process = subprocess.Popen(command, cwd=cwd, env=env, stdout=stdout, stderr=stderr, + start_new_session=True) + try: + code = process.wait(timeout=timeout) + finally: + if os.name == "posix": + try: + os.killpg(process.pid, signal.SIGKILL) + except ProcessLookupError: + pass + elif process.poll() is None: + process.kill() + process.wait(timeout=10) + assert code == 0, "claude_host_failed" + stdout.seek(0) + output = stdout.read(32 * 1024 * 1024 + 1) + assert len(output) <= 32 * 1024 * 1024, "claude_output_exceeded_limit" + return output.decode("utf-8") + + +def prerequisite_failure(claude: str) -> str | None: + if not all(shutil.which(command) for command in (claude, "node", "git")): + return "required_executable_unavailable" + if not os.environ.get("ARK_API_KEY"): + return "ark_api_key_unavailable" + try: + import mcp.server.fastmcp # noqa: F401 + except ImportError: + return "mcp_sdk_v1_unavailable" + return None + + +def host_environment(root: Path, launcher: Path) -> dict[str, str]: + # No user settings, OAuth/keychain import or persistent host installation. + env = {k: v for k, v in os.environ.items() if not k.startswith(("ANTHROPIC_", "CLAUDE_"))} + env.update( + ANTHROPIC_API_KEY=os.environ["ARK_API_KEY"], + ANTHROPIC_BASE_URL=ARK_ANTHROPIC_BASE, + ANTHROPIC_MODEL=DOUBAO_SEED_EVOLVING_MODEL, + ANTHROPIC_DEFAULT_HAIKU_MODEL=DOUBAO_SEED_EVOLVING_MODEL, + ANTHROPIC_DEFAULT_SONNET_MODEL=DOUBAO_SEED_EVOLVING_MODEL, + ANTHROPIC_DEFAULT_OPUS_MODEL=DOUBAO_SEED_EVOLVING_MODEL, + CLAUDE_CONFIG_DIR=str(root / "claude-config"), + CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC="1", + PATH=str(launcher.parent) + os.pathsep + os.environ.get("PATH", ""), + PYTHONPATH=str(REPO), + ) + return env + + +def qualify(root: Path, claude: str, timeout: int) -> dict: + from loopx.claude_goal_mode.scripts.goalmode_cmd import write_loop_md + + project, runtime, launcher = shared.setup(root) + write_loop_md(project, shared.GOAL, shared.AGENT) + config = root / "mcp.json" + config.write_text(json.dumps({"mcpServers": {"loopx": { + "command": sys.executable, + "args": [str(REPO / "loopx/claude_goal_mode/mcp/loopx_mcp.py")], + }}})) + command = [ + claude, "--bare", "--setting-sources", "", "--no-session-persistence", + "--strict-mcp-config", "--mcp-config", str(config), + "--model", DOUBAO_SEED_EVOLVING_MODEL, + "--permission-mode", "dontAsk", "--allowedTools", + "Read", "Edit", "Write", "Bash", "mcp__loopx__*", + "--output-format", "stream-json", "--verbose", "-p", + "Read .claude/loop.md and follow this project's active LoopX work contract. " + "Read TASK.md for acceptance. Use the bound LoopX MCP tools; preserve the " + "isolated project binding. Do not create timers in this headless qualification.", + ] + # This executes the actual per-iteration adapter, not Claude's interactive + # /loop timer. Never report headless delivery as scheduler qualification. + output = run_host(command, cwd=project, env=host_environment(root, launcher), timeout=timeout) + events = [json.loads(line) for line in output.splitlines() if line.strip()] + results = [e for e in events if e.get("type") == "result"] + assert len(results) == 1 and results[0].get("is_error") is False, "claude_turn_failed" + calls = [block.get("name") for event in events if event.get("type") == "assistant" + for block in (event.get("message") or {}).get("content", []) + if block.get("type") == "tool_use"] + assert "mcp__loopx__should_run" in calls and "mcp__loopx__complete_task" in calls, "mcp_not_exercised" + return {"status": "passed", "model_executed": True, "model": DOUBAO_SEED_EVOLVING_MODEL, + "host": "claude_code", "scheduler_qualification": "not_run_headless", + "mcp_tool_calls": sum(str(c).startswith("mcp__loopx__") for c in calls), + **shared.verify_delivery(project, runtime, launcher, "claude_code")} + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--release-live", action="store_true") + parser.add_argument("--claude-bin", default="claude") + parser.add_argument("--timeout-seconds", type=int, default=1200) + args = parser.parse_args(argv) + if args.timeout_seconds <= 0: + parser.error("timeout must be positive") + if not args.release_live: + result = {"status": "skipped", "reason": "release_opt_in_required", "model_executed": False} + else: + try: + reason = prerequisite_failure(args.claude_bin) + if reason: + result = {"status": "skipped", "reason": reason, "model_executed": False} + else: + with tempfile.TemporaryDirectory(prefix="loopx-claude-release-") as raw: + result = qualify(Path(raw), args.claude_bin, args.timeout_seconds) + except Exception as exc: + result = {"status": "failed", "error_kind": type(exc).__name__} + print(json.dumps(result, sort_keys=True)) + return 1 if result["status"] == "failed" else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/qualify-native-goal-release.py b/scripts/qualify-native-goal-release.py index 9c38389f66..081c631044 100644 --- a/scripts/qualify-native-goal-release.py +++ b/scripts/qualify-native-goal-release.py @@ -122,6 +122,20 @@ def verify_settlement(runtime: Path, todos: list[dict]) -> int: return len(spends) +def verify_delivery(project: Path, runtime: Path, launcher: Path, profile: str) -> dict: + assert (project / "TASK.md").read_bytes() == (FIXTURE / "TASK.md").read_bytes() + oracle = subprocess.run([sys.executable, str(FIXTURE / "verify.py"), str(project)], + capture_output=True, timeout=60) + assert oracle.returncode == 0, "independent_acceptance_failed" + todos = cli(launcher, "todo", "list", "--goal-id", GOAL, "--role", "agent")["todos"] + spends = verify_settlement(runtime, todos) + quota = cli(launcher, "quota", "should-run", "--goal-id", GOAL, + "--agent-id", AGENT, "--runtime-profile", profile) + assert quota["should_run"] is False + assert quota["interaction_contract"]["mode"] == "terminal_no_followup" + return {"settled_spends": spends, "independent_acceptance": "passed"} + + def qualify(root: Path, codex: str, timeout: int) -> dict: from loopx.capabilities.benchmark_toolkit.native_codex_goal import ( NativeGoalConfig, StdioNativeGoalTransport, run_native_goal_until_terminal, @@ -146,19 +160,9 @@ def qualify(root: Path, codex: str, timeout: int) -> dict: stderr=stderr) as transport: turn = run_native_goal_until_terminal(transport, config, timeout_sec=timeout) assert turn.post_goal_status == "complete", "native_goal_did_not_complete" - assert (project / "TASK.md").read_bytes() == (FIXTURE / "TASK.md").read_bytes() - oracle = subprocess.run([sys.executable, str(FIXTURE / "verify.py"), str(project)], - capture_output=True, timeout=60) - assert oracle.returncode == 0, "independent_acceptance_failed" - todos = cli(launcher, "todo", "list", "--goal-id", GOAL, "--role", "agent")["todos"] - spends = verify_settlement(runtime, todos) - quota = cli(launcher, "quota", "should-run", "--goal-id", GOAL, - "--agent-id", AGENT, "--runtime-profile", "codex_cli") - assert quota["should_run"] is False - assert quota["interaction_contract"]["mode"] == "terminal_no_followup" return {"status": "passed", "model_executed": True, - "native_turns": turn.turn_completed_count, "settled_spends": spends, - "independent_acceptance": "passed"} + "native_turns": turn.turn_completed_count, + **verify_delivery(project, runtime, launcher, "codex_cli")} def main(argv: list[str] | None = None) -> int: diff --git a/tests/test_claude_goal_release_qualification.py b/tests/test_claude_goal_release_qualification.py new file mode 100644 index 0000000000..044a5e6b16 --- /dev/null +++ b/tests/test_claude_goal_release_qualification.py @@ -0,0 +1,115 @@ +"""Ordinary CI covers policy/transport; no test here invokes a model.""" + +import asyncio +import importlib.util +import json +import os +import subprocess +from pathlib import Path +import sys + +import pytest + +REPO = Path(__file__).resolve().parents[1] +spec = importlib.util.spec_from_file_location("claude_release", REPO / "scripts/qualify-claude-goal-release.py") +runner = importlib.util.module_from_spec(spec) +spec.loader.exec_module(runner) + + +def test_default_never_probes_or_calls_model(monkeypatch, capsys): + def forbidden(*_): + raise AssertionError("must not execute") + monkeypatch.setattr(runner, "qualify", forbidden) + monkeypatch.setattr(runner, "prerequisite_failure", forbidden) + assert runner.main([]) == 0 + assert json.loads(capsys.readouterr().out)["model_executed"] is False + + +def test_missing_environment_skips_but_attempted_failure_fails(monkeypatch, capsys): + monkeypatch.setattr(runner, "prerequisite_failure", lambda _: "ark_api_key_unavailable") + assert runner.main(["--release-live"]) == 0 + assert json.loads(capsys.readouterr().out)["status"] == "skipped" + monkeypatch.setattr(runner, "prerequisite_failure", lambda _: None) + def failure(*_): + raise RuntimeError("private sentinel must not reach public result") + monkeypatch.setattr(runner, "qualify", failure) + assert runner.main(["--release-live"]) == 1 + assert json.loads(capsys.readouterr().out) == {"status": "failed", "error_kind": "RuntimeError"} + + +def test_provider_binding_does_not_inherit_another_anthropic_account(monkeypatch, tmp_path): + monkeypatch.setenv("ARK_API_KEY", "synthetic-ark-key") + monkeypatch.setenv("ANTHROPIC_AUTH_TOKEN", "synthetic-other-provider-key") + monkeypatch.setenv("ANTHROPIC_BASE_URL", "https://example.com") + monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN", "synthetic-oauth") + env = runner.host_environment(tmp_path, tmp_path / "bin/loopx") + assert env["ANTHROPIC_API_KEY"] == "synthetic-ark-key" + assert env["ANTHROPIC_BASE_URL"] == runner.ARK_ANTHROPIC_BASE + assert env["ANTHROPIC_MODEL"] == "doubao-seed-evolving" + assert "ANTHROPIC_AUTH_TOKEN" not in env and "CLAUDE_CODE_OAUTH_TOKEN" not in env + assert os.environ["ANTHROPIC_AUTH_TOKEN"] == "synthetic-other-provider-key" + + +def test_claude_loop_uses_current_contract_not_segment_or_empty_list_stop(): + from loopx.claude_goal_mode.scripts.goalmode_cmd import loop_md_content + from loopx.control_plane.heartbeat.rules import SCOPE_BOUNDED_WORK_RULE + + prompt = loop_md_content("goal-a", "agent-a") + assert SCOPE_BOUNDED_WORK_RULE in prompt + assert "interaction_contract" in prompt and "notification" in prompt + assert "ONE bounded segment" not in prompt and "no open todos remain" not in prompt + assert "Complete only finished Todos, not partial work" in prompt + assert 'agent_id="agent-a"' in prompt + assert "That MCP operation owns writeback/spend" in prompt + assert "unavailable/incomplete contract" in prompt + + +def test_host_timeout_fails_and_reaps_the_spawned_process(tmp_path, monkeypatch): + original = subprocess.Popen + children = [] + def capture(*args, **kwargs): + child = original(*args, **kwargs) + children.append(child) + return child + monkeypatch.setattr(runner.subprocess, "Popen", capture) + with pytest.raises(subprocess.TimeoutExpired): + runner.run_host([sys.executable, "-c", "import time; time.sleep(30)"], + cwd=tmp_path, env=dict(os.environ), timeout=0.1) + assert len(children) == 1 and children[0].poll() is not None + + +def test_host_nonzero_is_not_reported_as_a_successful_model_turn(tmp_path): + with pytest.raises(AssertionError, match="claude_host_failed"): + runner.run_host([sys.executable, "-c", "raise SystemExit(2)"], + cwd=tmp_path, env=dict(os.environ), timeout=10) + + +def test_real_claude_stdio_mcp_binding_and_identity_gate(tmp_path): + pytest.importorskip("mcp.server.fastmcp") + from mcp import ClientSession, StdioServerParameters + from mcp.client.stdio import stdio_client + from loopx.claude_goal_mode.scripts.goalmode_cmd import write_loop_md + + project, _, launcher = runner.shared.setup(tmp_path) + write_loop_md(project, runner.shared.GOAL, runner.shared.AGENT) + state = project / "ACTIVE_GOAL_STATE.md" + before = state.read_bytes() + params = StdioServerParameters( + command=sys.executable, + args=[str(REPO / "loopx/claude_goal_mode/mcp/loopx_mcp.py")], + cwd=str(project), env={**os.environ, "PYTHONPATH": str(REPO), + "PATH": str(launcher.parent) + os.pathsep + os.environ["PATH"]}, + ) + async def exercise(): + async with stdio_client(params) as (read, write): + async with ClientSession(read, write) as session: + await session.initialize() + tools = await session.list_tools() + assert {"should_run", "claim_task", "complete_task"} <= {t.name for t in tools.tools} + guard = await session.call_tool("should_run", {}) + payload = json.loads(guard.content[0].text) + assert payload["ok"] is True and payload["selected_todo"]["todo_id"] == "todo_reducer" + rejected = await session.call_tool("claim_task", {"todo_id": "todo_reducer", "agent_id": "other-agent"}) + assert json.loads(rejected.content[0].text)["ok"] is False + asyncio.run(exercise()) + assert state.read_bytes() == before diff --git a/tests/test_goal_mode_mcp_settlement.py b/tests/test_goal_mode_mcp_settlement.py index 6a17df4da1..4d49b58504 100644 --- a/tests/test_goal_mode_mcp_settlement.py +++ b/tests/test_goal_mode_mcp_settlement.py @@ -461,3 +461,45 @@ def test_real_mcp_terminal_completion_closes_out_after_spend( ) assert completed["status"] == "done" assert "no_followup=true" in state_file.read_text(encoding="utf-8") + + +@pytest.mark.parametrize("lost_after", ["lifecycle", "writeback", "spend"]) +def test_real_mcp_completion_recovers_a_lost_mutation_response(tmp_path, lost_after): + """The real write commits, but its caller sees a failure: retry must not pay twice.""" + registry, _ = _write_fixture(tmp_path) + added = add_goal_todo( + registry_path=registry, goal_id=GOAL_ID, role="agent", + text="Validate response-loss recovery.", task_class="advancement_task", + continuation_policy="same_agent_non_delivery", claimed_by=AGENT_ID, + ) + control = _control(registry) + original = control.run_cli + injected = False + + def lose_once(args, **kwargs): + nonlocal injected + output = original(args, **kwargs) + step = {"lifecycle": ["todo", "complete"], "writeback": ["refresh-state"], + "spend": ["quota", "spend-slot"]}[lost_after] + if not injected and args[:len(step)] == step and json.loads(output).get("ok"): + injected = True + return json.dumps({"ok": False, "error": "synthetic_response_lost_after_commit"}) + return output + + control.run_cli = lose_once + first = json.loads(control.complete_task( + added["todo_id"], AGENT_ID, "recovery fixture check passed", no_follow_up=True, + )) + assert injected and first["ok"] is False + replay = json.loads(control.complete_task( + added["todo_id"], AGENT_ID, "recovery fixture check passed", no_follow_up=True, + )) + assert replay["ok"] is True + again = json.loads(control.complete_task( + added["todo_id"], AGENT_ID, "recovery fixture check passed", no_follow_up=True, + )) + assert again["ok"] is True + assert again["settlement_identity"] == replay["settlement_identity"] + assert again["settlement"]["quota_spend"]["appended"] is False + status = json.loads(control.should_run()) + assert status["quota"]["spent_slots"] == 1 diff --git a/tests/test_native_goal_release_qualification.py b/tests/test_native_goal_release_qualification.py index 8ec588a820..359bbd2700 100644 --- a/tests/test_native_goal_release_qualification.py +++ b/tests/test_native_goal_release_qualification.py @@ -3,6 +3,7 @@ import importlib.util import json from pathlib import Path +from types import SimpleNamespace import pytest @@ -87,3 +88,21 @@ def test_settlement_oracle_rejects_missing_identity_and_duplicate_spend(tmp_path todos = [{"todo_id": todo, "status": "done"} for todo in runner.TODOS] with pytest.raises(AssertionError): runner.verify_settlement(tmp_path, todos) + + +@pytest.mark.parametrize("missing", ["writeback", "spend", "receipt"]) +def test_settlement_oracle_requires_durable_receipts_not_only_index_rows(tmp_path, monkeypatch, missing): + from loopx.control_plane.quota import settlement + + receipt = None if missing == "receipt" else SimpleNamespace( + writeback=None if missing == "writeback" else {"ok": True}, + spend=None if missing == "spend" else {"ok": True}, + ) + monkeypatch.setattr(settlement, "read_heartbeat_settlement", lambda *_, **__: receipt) + rows = [{"classification": "quota_slot_spent", "todo_id": todo, "turn_instance_id": todo, + "settlement_identity": {"effect_id": todo}} for todo in sorted(runner.TODOS)] + index = tmp_path / "goals" / runner.GOAL / "runs/index.jsonl" + index.parent.mkdir(parents=True) + index.write_text("\n".join(json.dumps(row) for row in rows)) + with pytest.raises(AssertionError): + runner.verify_settlement(tmp_path, [{"todo_id": t, "status": "done"} for t in runner.TODOS]) From 5605159a76f79dac455fcf9bf1f4cc77afe0610a Mon Sep 17 00:00:00 2001 From: huangruiteng Date: Thu, 10 Sep 2026 23:58:36 +0800 Subject: [PATCH 04/26] fix(hosts): separate Todo acceptance from Turn settlement Signed-off-by: huangruiteng --- docs/development/testing-and-quality.md | 8 +++ docs/heartbeat-automation-prompt.md | 6 +++ docs/project-agent-todo-contract.md | 24 +++++---- .../claude_goal_mode/scripts/goalmode_cmd.py | 4 +- loopx/cli_commands/todo.py | 48 ++--------------- loopx/goal_mode_mcp.py | 17 ++++++- loopx/kunluncode_goal_mode/guards.py | 2 + scripts/qualify-claude-goal-release.py | 29 +++++++++++ scripts/qualify-native-goal-release.py | 14 +++-- tests/control_plane/test_quota_settlement.py | 27 ++-------- tests/fixtures/native_goal_ledger/TASK.md | 5 +- .../test_claude_goal_release_qualification.py | 47 +++++++++++++++++ ...est_goal_mode_mcp_completion_validation.py | 28 +++++----- tests/test_goal_mode_mcp_settlement.py | 51 ++++++++++++++++++- .../test_native_goal_release_qualification.py | 15 +++++- 15 files changed, 225 insertions(+), 100 deletions(-) diff --git a/docs/development/testing-and-quality.md b/docs/development/testing-and-quality.md index c543d5a24e..bee8a8f795 100644 --- a/docs/development/testing-and-quality.md +++ b/docs/development/testing-and-quality.md @@ -804,6 +804,14 @@ Before calling a changed host surface release-qualified, distinguish: 环境缺失可 skip 且退出成功,但最终版本没有完整的真实 host 结果时,不得写成 “产品级发布验证通过”。单次成功也不是模型可靠性或长程调度 soak 的证明。 +The real delivery regression covers ordinary Todo acceptance before internal +writeback/spend, existing-successor linking, and receipt-backed terminal closure. +Its task specification describes only the deliverable; the external oracle also +checks LoopX accounting. Passing non-delivery fixtures does not qualify delivery. +The same delivery class must pass failed-validation rejection and committed +response-loss recovery without duplicate spending or premature terminal closure. +Do not relabel delivery work or weaken the independent oracle to pass a host test. + ## Exact Release Commit Gate / 精确发布 Commit 门 The final release gate does not rerun tests through a second orchestration diff --git a/docs/heartbeat-automation-prompt.md b/docs/heartbeat-automation-prompt.md index 3ad905ea34..d0bb28ff1a 100644 --- a/docs/heartbeat-automation-prompt.md +++ b/docs/heartbeat-automation-prompt.md @@ -107,6 +107,12 @@ transaction: callers must not perform a second accounting sequence through CLI. Partial work is not Todo completion. Native `/loop` remains Claude's scheduler; only the current Goal's wakeup may be cancelled after terminal no-follow-up. These changes apply when `loop.md` is regenerated, not by editing active user files. +The MCP tool now exposes `successor_todo_ids`, reusing CLI/TS completion semantics +to link known follow-up without creating another Todo. Ordinary acceptance and +Turn settlement are distinct: the adapter validates/completes work before its +writeback/spend; only terminal closeout requires the full receipt chain. This +removes the former delivery-class circular prerequisite, not validation or +accounting. See the [release test guide](development/testing-and-quality.md#claude-code-and-release-coverage--claude-code-与发布覆盖). For Codex App, the generated quota command carries the compact explicit runtime profile `--runtime-profile codex_app_heartbeat` (generated commands use the diff --git a/docs/project-agent-todo-contract.md b/docs/project-agent-todo-contract.md index 84e1479ff7..2c59897506 100644 --- a/docs/project-agent-todo-contract.md +++ b/docs/project-agent-todo-contract.md @@ -444,11 +444,12 @@ the agent should do one of two things: This succession decision is durable Todo state. A later progress observation, vision ACK, coverage-exhausted result, or rewritten rationale cannot substitute for it. Every new completion therefore retains an opaque completion identity. -A quota-bound completion still permits only the receipt-backed same-turn -`todo complete` transition for agent advancement work. Complete the matching -accountable `refresh-state` and `quota spend-slot` first; explicit -`same_agent_non_delivery` work, monitors, user actions, and user gates keep -their existing lifecycle paths. An ordinary unscoped completion gets a +A quota-bound ordinary completion proves the exact admitted identity and Todo +acceptance (including declared validation), not completion of Turn accounting. +It may precede the same-turn writeback and spend. The existing typed replay +phase remains `settlement_pending` until those receipts exist; Todo `done` alone +does not mean the Turn settled. Terminal `--no-follow-up` still requires the +complete matching writeback/spend chain. An ordinary unscoped completion gets a stable `local_completion_*` identity; if a later `refresh-state` discovers that the finished Goal has no real successor, its typed rejection may project `--completion-identity-key` for one direct lifecycle reentry. That command is @@ -458,11 +459,14 @@ It cannot be supplied for an open Todo or used as a quota turn identity. Otherwise add/link a real successor. Do not create a user gate merely to silence a succession warning. -Compatibility host adapters whose established transaction completes the Todo -before writing the same-turn refresh and quota receipts must explicitly mark -their non-repository work `same_agent_non_delivery`. Repository advancement -through those adapters fails closed with a typed settlement blocker until the -adapter adopts a writeback-and-spend-before-completion transaction. +Host adapters own the internal sequence: validate/complete, write back, spend, +then terminal closeout if requested. A failed internal step is not a request to +redo accepted task work: retry the same identity and recover the missing receipt. +MCP returns success only after the whole requested sequence succeeds. Task +acceptance must not prescribe LoopX bookkeeping, and delivery work must not be +relabeled `same_agent_non_delivery` to escape a contradictory internal ordering. +This intentionally removes the old task-class-dependent CLI prerequisite while +retaining declared validation, claim/lease checks, identity and terminal fences. This keeps the active checklist honest without making LoopX a heavyweight project-management state machine. diff --git a/loopx/claude_goal_mode/scripts/goalmode_cmd.py b/loopx/claude_goal_mode/scripts/goalmode_cmd.py index 829469d9ae..02e7887208 100644 --- a/loopx/claude_goal_mode/scripts/goalmode_cmd.py +++ b/loopx/claude_goal_mode/scripts/goalmode_cmd.py @@ -77,6 +77,8 @@ def loop_md_content(goal_id, agent_id) -> str: "Run real acceptance checks before `complete_task`; supply truthful evidence\n" f"and the bound agent_id=\"{agent_id}\". Complete only finished Todos, not partial work.\n" "That MCP operation owns writeback/spend; do not repeat its accounting via CLI.\n" + "Link already planned follow-up via successor_todo_ids; next_agent_todo creates\n" + "new work, not a reference to an existing id. Do not duplicate the current plan.\n" "After a lost response, read back or retry the same completion intent; do not\n" "invent a new successor or settlement identity. Recheck `should_run()` afterward.\n" "Continue authorized work while the live contract requires it; notification\n" @@ -244,7 +246,7 @@ def main(): print(" wrote : .claude/loop.md (the per-tick protocol)") print() print("START WORKING — run native `/loop` (Claude self-paces) or `/loop 10m` (fixed cadence).") - print("Each /loop tick runs: should_run -> claim_task -> ONE bounded verified segment -> complete_task.") + print("Each /loop tick follows should_run's current contract; complete_task settles only verified, finished work.") print("Stop with Esc or `/loopx off`.") diff --git a/loopx/cli_commands/todo.py b/loopx/cli_commands/todo.py index 70c894c2ae..4a3135ae92 100644 --- a/loopx/cli_commands/todo.py +++ b/loopx/cli_commands/todo.py @@ -6,9 +6,6 @@ from ..control_plane.coordination.local_authority import read_canonical_todo_fields_if_promoted from ..control_plane.todos.contract import ( - TODO_TASK_CLASS_ADVANCEMENT, - normalize_todo_continuation_policy, - normalize_todo_task_class, replan_successor_semantic_binding, ) from ..control_plane.capability_hooks import PostWritebackHookRegistration @@ -78,44 +75,18 @@ ] -def _completion_settlement_requirement( - todo: dict[str, object], - *, - no_follow_up: bool, -) -> str | None: - if no_follow_up: - return "terminal no-follow-up closeout" - task_class = normalize_todo_task_class( - todo.get("task_class"), - text=str(todo.get("text") or ""), - action_kind=todo.get("action_kind"), - ) - continuation_policy = normalize_todo_continuation_policy( - todo.get("continuation_policy") - ) - if ( - str(todo.get("role") or "") == "agent" - and task_class == TODO_TASK_CLASS_ADVANCEMENT - and continuation_policy != "same_agent_non_delivery" - ): - return "turn-scoped advancement completion" - return None - - def _completion_settlement_error( - todo: dict[str, object], settlement_readback: QuotaSettlementReadback, *, no_follow_up: bool, ) -> str | None: - requirement = _completion_settlement_requirement( - todo, - no_follow_up=no_follow_up, - ) - if requirement is None or settlement_readback.settlement.failure is None: + # Todo acceptance and Turn settlement are distinct facts. Ordinary + # completion can precede accounting (including controller validation). + # Only terminal intent requires the full chain before closing out. + if not no_follow_up or settlement_readback.settlement.failure is None: return None return ( - f"{requirement} requires matching writeback and quota spend receipts: " + "terminal no-follow-up closeout requires matching writeback and quota spend receipts: " + settlement_readback.settlement.failure.reason ) @@ -418,7 +389,6 @@ def handle_todo_command( settlement_result = None settlement_identity = None settlement_readback = None - completion_requires_settlement = False completion_error = None completion_turn_key = None completion_identity_source = None @@ -466,13 +436,7 @@ def handle_todo_command( raise ValueError( "turn-scoped Todo completion requires one durable Todo" ) - completion_requirement = _completion_settlement_requirement( - todo, - no_follow_up=bool(args.no_follow_up), - ) - completion_requires_settlement = completion_requirement is not None completion_error = _completion_settlement_error( - todo, settlement_readback=settlement_readback, no_follow_up=bool(args.no_follow_up), ) @@ -638,8 +602,6 @@ def handle_todo_command( settlement_result = ( settlement_readback.terminal_settlement if args.no_follow_up and settlement_identity is not None - else settlement_readback.settlement - if completion_requires_settlement else settlement_readback.identity ) payload["settlement_result"] = settlement_result_payload( diff --git a/loopx/goal_mode_mcp.py b/loopx/goal_mode_mcp.py index 328f53e840..95eebdc6b6 100644 --- a/loopx/goal_mode_mcp.py +++ b/loopx/goal_mode_mcp.py @@ -179,6 +179,7 @@ def complete_task( task_lease_idempotency_key: str = "", task_lease_expected_version: ExpectedTaskLeaseVersion = None, no_follow_up: bool = False, + successor_todo_ids: list[str] | None = None, ) -> str: goal_id, _ = self.context() if not goal_id: @@ -193,6 +194,13 @@ def complete_task( "error": "next_agent_todo and no_follow_up are mutually exclusive", } ) + if successor_todo_ids is not None and ( + not isinstance(successor_todo_ids, list) + or any(not isinstance(value, str) or not value.strip() for value in successor_todo_ids) + ): + return json.dumps({"ok": False, "error": "successor_todo_ids must be a list of nonempty ids"}) + if successor_todo_ids and (next_agent_todo or no_follow_up): + return json.dumps({"ok": False, "error": "choose existing successors, a new successor, or no follow-up"}) args = [ "todo", "complete", @@ -209,6 +217,8 @@ def complete_task( ] if next_agent_todo: args += ["--next-agent-todo", next_agent_todo] + for successor in successor_todo_ids or []: + args += ["--successor-todo-id", successor] if task_lease_idempotency_key: args += ["--task-lease-idempotency-key", task_lease_idempotency_key] if task_lease_expected_version is not None: @@ -273,8 +283,12 @@ def complete_task( task_lease_idempotency_key: str = "", task_lease_expected_version: ExpectedTaskLeaseVersion = None, no_follow_up: bool = False, + successor_todo_ids: list[str] | None = None, ) -> str: - """Complete one verified todo, write follow-up state, then spend quota.""" + """Complete verified work and settle once. Link existing planned successors + with successor_todo_ids; next_agent_todo creates a NEW Todo, not an id link. + Use no_follow_up only for terminal intent. Do not duplicate existing work. + """ return control.complete_task( todo_id, agent_id, @@ -283,6 +297,7 @@ def complete_task( task_lease_idempotency_key=task_lease_idempotency_key, task_lease_expected_version=task_lease_expected_version, no_follow_up=no_follow_up, + successor_todo_ids=successor_todo_ids, ) return server, control diff --git a/loopx/kunluncode_goal_mode/guards.py b/loopx/kunluncode_goal_mode/guards.py index ff5d91bcb2..15b8fd367e 100644 --- a/loopx/kunluncode_goal_mode/guards.py +++ b/loopx/kunluncode_goal_mode/guards.py @@ -41,12 +41,14 @@ def blocked_complete( task_lease_idempotency_key: str = "", task_lease_expected_version: int | None = None, no_follow_up: bool = False, + successor_todo_ids: list[str] | None = None, ) -> str: del ( next_agent_todo, task_lease_idempotency_key, task_lease_expected_version, no_follow_up, + successor_todo_ids, ) return _native_controller_rejection("complete_task") diff --git a/scripts/qualify-claude-goal-release.py b/scripts/qualify-claude-goal-release.py index 349cf7f985..bd3511ce51 100644 --- a/scripts/qualify-claude-goal-release.py +++ b/scripts/qualify-claude-goal-release.py @@ -80,6 +80,34 @@ def host_environment(root: Path, launcher: Path) -> dict[str, str]: return env +def verify_mcp_completions(events: list[dict]) -> None: + """A tool invocation is not evidence that the MCP transaction succeeded.""" + pending: dict[str, str] = {} + completed: set[str] = set() + for event in events: + for block in (event.get("message") or {}).get("content", []): + if block.get("type") == "tool_use" and block.get("name") == "mcp__loopx__complete_task": + pending[block["id"]] = (block.get("input") or {}).get("todo_id") + if block.get("type") != "tool_result" or block.get("tool_use_id") not in pending: + continue + content = block.get("content") + if isinstance(content, list): + content = "".join(item.get("text", "") for item in content if item.get("type") == "text") + try: + payload = json.loads(content) + if isinstance(payload, dict) and isinstance(payload.get("result"), str): + payload = json.loads(payload["result"]) + except (TypeError, ValueError): + continue + if not isinstance(payload, dict) or payload.get("ok") is not True: + continue + todo_id = pending[block["tool_use_id"]] + assert payload.get("todo_id") == todo_id and payload.get("completed") is True + assert (payload.get("settlement") or {}).get("ok") is True + completed.add(todo_id) + assert completed == shared.TODOS, "mcp_delivery_transactions_not_completed" + + def qualify(root: Path, claude: str, timeout: int) -> dict: from loopx.claude_goal_mode.scripts.goalmode_cmd import write_loop_md @@ -111,6 +139,7 @@ def qualify(root: Path, claude: str, timeout: int) -> dict: for block in (event.get("message") or {}).get("content", []) if block.get("type") == "tool_use"] assert "mcp__loopx__should_run" in calls and "mcp__loopx__complete_task" in calls, "mcp_not_exercised" + verify_mcp_completions(events) return {"status": "passed", "model_executed": True, "model": DOUBAO_SEED_EVOLVING_MODEL, "host": "claude_code", "scheduler_qualification": "not_run_headless", "mcp_tool_calls": sum(str(c).startswith("mcp__loopx__") for c in calls), diff --git a/scripts/qualify-native-goal-release.py b/scripts/qualify-native-goal-release.py index 081c631044..03f8264945 100644 --- a/scripts/qualify-native-goal-release.py +++ b/scripts/qualify-native-goal-release.py @@ -58,7 +58,7 @@ def setup(root: Path) -> tuple[Path, Path, Path]: (project / "ACTIVE_GOAL_STATE.md").write_text( '---\nstatus: active-read-only\nowner_mode: goal\n' 'objective: "Deliver the finite local ledger specification."\n---\n\n' - '# Ledger\n\n## Objective\n\nDeliver TASK.md, validate and settle both Todos.\n\n' + '# Ledger\n\n## Objective\n\nDeliver and validate TASK.md.\n\n' '## Next Action\n\nImplement the reducer, then the CLI.\n\n' '## User Todo\n\n## Agent Todo\n\n' '- [ ] [P1] Implement and validate the TASK.md reducer.\n' @@ -102,9 +102,11 @@ def cli(launcher: Path, *args: str) -> dict: def verify_settlement(runtime: Path, todos: list[dict]) -> int: + from loopx.control_plane.effect_program import SettlementStepKind from loopx.control_plane.quota.settlement import read_heartbeat_settlement - assert {t["todo_id"] for t in todos if t.get("status") == "done"} == TODOS + assert len(todos) == len(TODOS) and {t["todo_id"] for t in todos} == TODOS + assert all(t.get("status") == "done" for t in todos) rows = [json.loads(line) for line in (runtime / "goals" / GOAL / "runs/index.jsonl").read_text().splitlines()] spends = [r for r in rows if r.get("classification") == "quota_slot_spent"] @@ -118,7 +120,13 @@ def verify_settlement(runtime: Path, todos: list[dict]) -> int: replan_obligation_id=row.get("replan_obligation_id"), turn_instance_id=row["turn_instance_id"], ) - assert readback and readback.writeback and readback.spend + assert readback is not None, "missing_settlement_readback" + assert readback.settlement.failure is None, "incomplete_settlement" + assert {r.step_kind for r in readback.settlement.receipts} >= { + SettlementStepKind.VALIDATION, SettlementStepKind.DURABLE_WRITEBACK, + SettlementStepKind.QUOTA_SPEND, + }, "missing_settlement_receipts" + assert readback.writeback_run is not None and readback.spend_run is not None return len(spends) diff --git a/tests/control_plane/test_quota_settlement.py b/tests/control_plane/test_quota_settlement.py index 13c0c76b29..c69bb82df3 100644 --- a/tests/control_plane/test_quota_settlement.py +++ b/tests/control_plane/test_quota_settlement.py @@ -273,7 +273,7 @@ def test_quota_settlement_readback_returns_the_complete_typed_chain( assert readback.spend_run is not None -def test_advancement_completion_requires_the_complete_settlement_chain( +def test_only_terminal_closeout_requires_the_complete_settlement_chain( tmp_path: Path, ) -> None: runtime_root = tmp_path / "runtime" @@ -289,18 +289,12 @@ def test_advancement_completion_requires_the_complete_settlement_chain( ) assert incomplete is not None error = _completion_settlement_error( - { - "role": "agent", - "task_class": "advancement_task", - "action_kind": "implement", - "text": "Ship the repository change.", - }, incomplete, - no_follow_up=False, + no_follow_up=True, ) assert error is not None assert error.startswith( - "turn-scoped advancement completion requires matching writeback and " + "terminal no-follow-up closeout requires matching writeback and " "quota spend receipts:" ) @@ -318,26 +312,13 @@ def test_advancement_completion_requires_the_complete_settlement_chain( ) assert ( _completion_settlement_error( - { - "role": "agent", - "task_class": "advancement_task", - "action_kind": "implement", - "text": "Ship the repository change.", - }, settled, - no_follow_up=False, + no_follow_up=True, ) is None ) assert ( _completion_settlement_error( - { - "role": "agent", - "task_class": "advancement_task", - "action_kind": "research", - "continuation_policy": "same_agent_non_delivery", - "text": "Analyze the evidence.", - }, incomplete, no_follow_up=False, ) diff --git a/tests/fixtures/native_goal_ledger/TASK.md b/tests/fixtures/native_goal_ledger/TASK.md index cb2fbda446..0f59fa4aa7 100644 --- a/tests/fixtures/native_goal_ledger/TASK.md +++ b/tests/fixtures/native_goal_ledger/TASK.md @@ -17,9 +17,8 @@ with a trailing newline. Empty input succeeds with `{}`. Invalid JSON or domain input must leave stdout empty, write a concise diagnostic to stderr, and exit nonzero. Add reducer and subprocess tests, including long replay, and a README. -Complete both dependent LoopX Todos and settle validated work using the current -contract. This is a finite local task: no upload, publication, deployment, +This is a finite local task: no upload, publication, deployment, package installation, or external side effect is part of acceptance. Keep all changes in the disposable project/worktrees. Do not change this specification or LoopX source to make the task pass. Do not substitute fabricated evidence -or a non-delivery declaration for actual code delivery. +for actual code delivery. diff --git a/tests/test_claude_goal_release_qualification.py b/tests/test_claude_goal_release_qualification.py index 044a5e6b16..9b050ece77 100644 --- a/tests/test_claude_goal_release_qualification.py +++ b/tests/test_claude_goal_release_qualification.py @@ -61,6 +61,7 @@ def test_claude_loop_uses_current_contract_not_segment_or_empty_list_stop(): assert "Complete only finished Todos, not partial work" in prompt assert 'agent_id="agent-a"' in prompt assert "That MCP operation owns writeback/spend" in prompt + assert "successor_todo_ids" in prompt assert "unavailable/incomplete contract" in prompt @@ -84,6 +85,23 @@ def test_host_nonzero_is_not_reported_as_a_successful_model_turn(tmp_path): cwd=tmp_path, env=dict(os.environ), timeout=10) +@pytest.mark.parametrize("failed", [False, True]) +def test_mcp_oracle_requires_successful_transactions_not_only_invocations(failed): + events = [] + for todo in sorted(runner.shared.TODOS): + events.append({"message": {"content": [{"type": "tool_use", "id": todo, + "name": "mcp__loopx__complete_task", "input": {"todo_id": todo}}]}}) + result = {"ok": not failed, "completed": True, "todo_id": todo, + "settlement": {"ok": not failed}} + events.append({"message": {"content": [{"type": "tool_result", "tool_use_id": todo, + "content": json.dumps({"result": json.dumps(result)})}]}}) + if failed: + with pytest.raises(AssertionError, match="mcp_delivery_transactions_not_completed"): + runner.verify_mcp_completions(events) + else: + runner.verify_mcp_completions(events) + + def test_real_claude_stdio_mcp_binding_and_identity_gate(tmp_path): pytest.importorskip("mcp.server.fastmcp") from mcp import ClientSession, StdioServerParameters @@ -106,6 +124,8 @@ async def exercise(): await session.initialize() tools = await session.list_tools() assert {"should_run", "claim_task", "complete_task"} <= {t.name for t in tools.tools} + complete = next(t for t in tools.tools if t.name == "complete_task") + assert "successor_todo_ids" in complete.inputSchema["properties"] guard = await session.call_tool("should_run", {}) payload = json.loads(guard.content[0].text) assert payload["ok"] is True and payload["selected_todo"]["todo_id"] == "todo_reducer" @@ -113,3 +133,30 @@ async def exercise(): assert json.loads(rejected.content[0].text)["ok"] is False asyncio.run(exercise()) assert state.read_bytes() == before + + +def test_real_mcp_delivery_completes_and_settles_existing_plan(tmp_path): + from loopx.goal_mode_mcp import GoalModeMCPConfig, GoalModeMCPControlPlane + + project, runtime, launcher = runner.shared.setup(tmp_path) + # Real delivery class: do not substitute same_agent_non_delivery to make + # this acceptance test green. No live model or external side effect. + (project / "delivery.txt").write_text("synthetic verified delivery\n") + control = GoalModeMCPControlPlane( + GoalModeMCPConfig(server_name="loopx", runtime_profile="claude_code", legacy_host_surface="claude_code"), + lambda: {"goal_id": runner.shared.GOAL, "agent_id": runner.shared.AGENT, + "registry": str(project / ".loopx/registry.json")}, + ) + control.command_prefix = lambda: [str(launcher)] + result = json.loads(control.complete_task( + "todo_reducer", runner.shared.AGENT, "synthetic delivery validation passed", + successor_todo_ids=["todo_cli"], + )) + assert result["ok"] is True, "unexpected completion failure" + todos = runner.shared.cli(launcher, "todo", "list", "--goal-id", runner.shared.GOAL, "--role", "agent")["todos"] + assert {row["todo_id"] for row in todos} == runner.shared.TODOS + assert next(row for row in todos if row["todo_id"] == "todo_reducer")["status"] == "done" + index = runtime / "goals" / runner.shared.GOAL / "runs/index.jsonl" + spends = [json.loads(line) for line in index.read_text().splitlines() + if json.loads(line).get("classification") == "quota_slot_spent"] + assert len(spends) == 1 and spends[0]["todo_id"] == "todo_reducer" diff --git a/tests/test_goal_mode_mcp_completion_validation.py b/tests/test_goal_mode_mcp_completion_validation.py index 36301cdca4..e0573167e4 100644 --- a/tests/test_goal_mode_mcp_completion_validation.py +++ b/tests/test_goal_mode_mcp_completion_validation.py @@ -5,6 +5,8 @@ import sys from pathlib import Path +import pytest + from loopx.goal_mode_mcp import GoalModeMCPConfig, GoalModeMCPControlPlane from loopx.status import parse_active_state_todos from loopx.todos import add_goal_todo @@ -68,7 +70,6 @@ def _add_todo(registry: Path, *, validation_command: str | None = None) -> str: text="Deliver one bounded change.", task_class="advancement_task", claimed_by=AGENT, - continuation_policy="same_agent_non_delivery", validation_command=validation_command, ) return str(todo["todo_id"]) @@ -125,10 +126,13 @@ def test_mcp_complete_task_fails_closed_on_failing_declared_validation( assert _agent_todo_status(state, todo_id) != "done" -def test_mcp_advancement_completion_returns_typed_settlement_blocker( - tmp_path: Path, +@pytest.mark.parametrize("no_follow_up", [False, True]) +def test_mcp_advancement_validation_precedes_controller_owned_settlement( + tmp_path: Path, no_follow_up: bool, ) -> None: registry, state = _write_fixture(tmp_path) + validation = state.parent / "check.py" + validation.write_text("from pathlib import Path\nPath('validation-ran').write_text('passed')\n") todo = add_goal_todo( registry_path=registry, goal_id=GOAL_ID, @@ -136,22 +140,22 @@ def test_mcp_advancement_completion_returns_typed_settlement_blocker( text="Deliver one repository advancement.", task_class="advancement_task", claimed_by=AGENT, + validation_command=shlex.join([sys.executable, str(validation)]), ) todo_id = str(todo["todo_id"]) payload = _first_json_blob( - _control(registry).complete_task(todo_id, AGENT, "claimed done") + _control(registry).complete_task(todo_id, AGENT, "validated delivery", no_follow_up=no_follow_up) ) - assert payload["ok"] is False - assert payload["completed"] is False - assert payload["changed"] is False - assert payload["settlement_blocked_completion"] is True + assert payload["ok"] is True, payload + assert payload["completed"] is True assert payload["settlement_identity"]["todo_id"] == todo_id - assert payload["settlement_result"]["failure"]["kind"] == ( - "writeback_missing" - ) - assert _agent_todo_status(state, todo_id) != "done" + assert (state.parent / "validation-ran").read_text() == "passed" + assert payload["settlement"]["durable_writeback"]["ok"] is True + assert payload["settlement"]["quota_spend"]["appended"] is True + assert ("terminal_closeout" in payload["settlement"]) is no_follow_up + assert _agent_todo_status(state, todo_id) == "done" def test_expected_lease_version_annotation_rejects_bool_at_the_boundary() -> None: """FastMCP validates tool arguments with pydantic, and lax pydantic diff --git a/tests/test_goal_mode_mcp_settlement.py b/tests/test_goal_mode_mcp_settlement.py index 4d49b58504..3dfafc7fc9 100644 --- a/tests/test_goal_mode_mcp_settlement.py +++ b/tests/test_goal_mode_mcp_settlement.py @@ -470,19 +470,21 @@ def test_real_mcp_completion_recovers_a_lost_mutation_response(tmp_path, lost_af added = add_goal_todo( registry_path=registry, goal_id=GOAL_ID, role="agent", text="Validate response-loss recovery.", task_class="advancement_task", - continuation_policy="same_agent_non_delivery", claimed_by=AGENT_ID, + claimed_by=AGENT_ID, ) control = _control(registry) original = control.run_cli injected = False + bound_identity = None def lose_once(args, **kwargs): - nonlocal injected + nonlocal injected, bound_identity output = original(args, **kwargs) step = {"lifecycle": ["todo", "complete"], "writeback": ["refresh-state"], "spend": ["quota", "spend-slot"]}[lost_after] if not injected and args[:len(step)] == step and json.loads(output).get("ok"): injected = True + bound_identity = json.loads(output)["settlement_identity"] return json.dumps({"ok": False, "error": "synthetic_response_lost_after_commit"}) return output @@ -491,6 +493,20 @@ def lose_once(args, **kwargs): added["todo_id"], AGENT_ID, "recovery fixture check passed", no_follow_up=True, )) assert injected and first["ok"] is False + from loopx.control_plane.quota.settlement import read_heartbeat_settlement + readback = read_heartbeat_settlement( + tmp_path / "runtime", goal_id=GOAL_ID, agent_id=AGENT_ID, + todo_id=added["todo_id"], turn_instance_id=bound_identity["turn_instance_id"], + ) + assert readback.replay_phase == ("settled" if lost_after == "spend" else "settlement_pending") + if lost_after != "spend": + rc, terminal = _run_cli( + registry, "todo", "complete", "--goal-id", GOAL_ID, + "--todo-id", added["todo_id"], "--agent-id", AGENT_ID, + "--turn-instance-id", bound_identity["turn_instance_id"], + "--no-follow-up", "--evidence", "synthetic terminal intent", + ) + assert rc != 0 and terminal["settlement_blocked_completion"] is True replay = json.loads(control.complete_task( added["todo_id"], AGENT_ID, "recovery fixture check passed", no_follow_up=True, )) @@ -503,3 +519,34 @@ def lose_once(args, **kwargs): assert again["settlement"]["quota_spend"]["appended"] is False status = json.loads(control.should_run()) assert status["quota"]["spent_slots"] == 1 + + +def test_real_mcp_links_existing_successor_without_creating_another_todo(tmp_path): + registry, state_file = _write_fixture(tmp_path) + ids = [str(add_goal_todo( + registry_path=registry, goal_id=GOAL_ID, role="agent", text=text, + task_class="advancement_task", + claimed_by=AGENT_ID, + )["todo_id"]) for text in ("First accepted step.", "Already planned follow-up.")] + control = _control(registry) + before = state_file.read_bytes() + rejected = json.loads(control.complete_task( + ids[0], AGENT_ID, "synthetic check passed", successor_todo_ids=[ids[1]], no_follow_up=True, + )) + assert rejected["ok"] is False + assert state_file.read_bytes() == before + first = json.loads(control.complete_task( + ids[0], AGENT_ID, "synthetic check passed", successor_todo_ids=[ids[1]], + )) + assert first["ok"] is True, first + replay = json.loads(control.complete_task( + ids[0], AGENT_ID, "synthetic check passed", successor_todo_ids=[ids[1]], + )) + assert replay["ok"] is True + assert replay["settlement_identity"] == first["settlement_identity"] + todos = parse_active_state_todos(state_file.read_text())["agent_todos"]["items"] + by_id = {row["todo_id"]: row for row in todos} + assert set(by_id) == set(ids) + assert by_id[ids[0]]["status"] == "done" and by_id[ids[1]]["status"] == "open" + assert by_id[ids[0]]["successor_todo_ids"] == [ids[1]] + assert json.loads(control.should_run())["quota"]["spent_slots"] == 1 diff --git a/tests/test_native_goal_release_qualification.py b/tests/test_native_goal_release_qualification.py index 359bbd2700..5fa9b84276 100644 --- a/tests/test_native_goal_release_qualification.py +++ b/tests/test_native_goal_release_qualification.py @@ -93,10 +93,14 @@ def test_settlement_oracle_rejects_missing_identity_and_duplicate_spend(tmp_path @pytest.mark.parametrize("missing", ["writeback", "spend", "receipt"]) def test_settlement_oracle_requires_durable_receipts_not_only_index_rows(tmp_path, monkeypatch, missing): from loopx.control_plane.quota import settlement + from loopx.control_plane.effect_program import SettlementFailure, SettlementFailureKind, SettlementResult receipt = None if missing == "receipt" else SimpleNamespace( - writeback=None if missing == "writeback" else {"ok": True}, - spend=None if missing == "spend" else {"ok": True}, + # Real failed result objects are truthy, unlike None. The oracle must + # inspect the typed failure, not accept object existence as evidence. + settlement=SettlementResult(value=None, failure=SettlementFailure( + kind=SettlementFailureKind.RECEIPT_MISSING, reason=missing, step_kind=None, + )), ) monkeypatch.setattr(settlement, "read_heartbeat_settlement", lambda *_, **__: receipt) rows = [{"classification": "quota_slot_spent", "todo_id": todo, "turn_instance_id": todo, @@ -106,3 +110,10 @@ def test_settlement_oracle_requires_durable_receipts_not_only_index_rows(tmp_pat index.write_text("\n".join(json.dumps(row) for row in rows)) with pytest.raises(AssertionError): runner.verify_settlement(tmp_path, [{"todo_id": t, "status": "done"} for t in runner.TODOS]) + + +def test_settlement_oracle_rejects_duplicate_open_successor(tmp_path): + todos = [{"todo_id": t, "status": "done"} for t in runner.TODOS] + todos.append({"todo_id": "todo_duplicate", "status": "open"}) + with pytest.raises(AssertionError): + runner.verify_settlement(tmp_path, todos) From 9d7e3f9c3926ef218ae625bfa22655a7f948f199 Mon Sep 17 00:00:00 2001 From: Green hats <183933879+Green-hats@users.noreply.github.com> Date: Fri, 11 Sep 2026 11:00:49 +0800 Subject: [PATCH 05/26] fix(heartbeat): restore host boundaries and isolate release environments Signed-off-by: Green hats <183933879+Green-hats@users.noreply.github.com> --- docs/development/testing-and-quality.md | 19 +++++-- .../control_plane/heartbeat-prompt-smoke.py | 2 +- examples/install-local-smoke.py | 2 +- .../heartbeat/automation_upgrade.py | 4 +- loopx/control_plane/heartbeat/task_body.py | 6 +- scripts/qualify-claude-goal-release.py | 2 +- scripts/qualify-native-goal-release.py | 41 +++++++++++++- .../test_automation_prompt_upgrade.py | 18 +++++- .../test_heartbeat_prompt_support.py | 34 ++++++++++++ .../test_claude_goal_release_qualification.py | 9 +++ .../test_native_goal_release_qualification.py | 55 +++++++++++++++++++ 11 files changed, 179 insertions(+), 13 deletions(-) diff --git a/docs/development/testing-and-quality.md b/docs/development/testing-and-quality.md index bee8a8f795..8e69963e96 100644 --- a/docs/development/testing-and-quality.md +++ b/docs/development/testing-and-quality.md @@ -749,7 +749,7 @@ score or evidence of universal model reliability. ```bash # No model invocation, no token cost; explicit skipped result, exit 0. python3 scripts/qualify-native-goal-release.py -# Release operator opt-in only; uses the existing configured Codex model/auth. +# Release operator opt-in only; uses selected Codex authentication in an isolated home. python3 scripts/qualify-native-goal-release.py --release-live ``` @@ -762,11 +762,22 @@ blocked/unfinished Goals and deadline expiry fail with exit 1. The default deadline is 1,200 seconds; this is a wall-clock ceiling, not a token budget. 仅 release 前显式开启,避免默认消耗开发者 token。CI/本机环境不支持时跳过且不阻塞, -但保留 `skipped` 标记;真实执行后失败不能冒充环境跳过。复用当前 Codex 的模型与登录, -不复制凭据或会话、不修改活跃 Goal/automation。任务、registry、runtime 与 Git worktree +但保留 `skipped` 标记;真实执行后失败不能冒充环境跳过。Codex 优先使用显式 +`OPENAI_API_KEY`,否则仅复制当前 `auth.json` 到权限受限的一次性 Codex 目录; +不继承用户配置、MCP 配置、会话或其他提供商凭据,不修改活跃 Goal/automation。 +任务、registry、runtime 与 Git worktree 在一次性目录内;沙箱允许该目录及本地 TS worker 所需的网络能力, 这不是网络隔离,任务不授权外部操作。回归脚本不采集或上传原始对话/工具日志, -公开结果仅包含状态、计数和错误类别;Codex 自身仍按当前 host 配置保存会话。 +公开结果仅包含状态、计数和错误类别;Codex 会话也保存在一次性目录中。 + +Both release hosts use an explicit environment allowlist and isolated home, +configuration, cache and temporary directories. Codex receives only the selected +API key or a private temporary copy of its authentication file; user model/MCP +configuration is not imported. Its shell tools receive an explicit environment +without the API key. Claude receives the Ark key only as `ANTHROPIC_API_KEY`, +not also as `ARK_API_KEY`; unrelated tokens and authentication sockets are not +forwarded. These are process-environment boundaries, not filesystem or network +isolation guarantees for the model's tools. ### Claude Code and release coverage / Claude Code 与发布覆盖 diff --git a/examples/control_plane/heartbeat-prompt-smoke.py b/examples/control_plane/heartbeat-prompt-smoke.py index b46378317f..a59aa73c38 100644 --- a/examples/control_plane/heartbeat-prompt-smoke.py +++ b/examples/control_plane/heartbeat-prompt-smoke.py @@ -567,7 +567,7 @@ def main() -> int: assert live_peer_budget["within_budget"] is True, live_peer_budget assert len(str(live_peer_payload["task_body"])) <= int(live_peer_budget["max_chars"]), live_peer_budget assert "correctness.." not in live_peer_task, live_peer_task - assert live_peer_task.index("`LOOPX_TURN=`") < live_peer_task.index( + assert live_peer_task.index("`export LOOPX_TURN=`") < live_peer_task.index( "quota should-run" ), live_peer_task for phrase in ( diff --git a/examples/install-local-smoke.py b/examples/install-local-smoke.py index ba25fa2b76..1b0340370f 100644 --- a/examples/install-local-smoke.py +++ b/examples/install-local-smoke.py @@ -790,7 +790,7 @@ def main() -> int: assert not normal_turns_use_cli_interaction_contract( "Normal turns use the runtime skill and repair contract." ) - assert "`LOOPX_TURN=`; reuse." in payload["task_body"], payload + assert "`export LOOPX_TURN=` alone; reuse." in payload["task_body"], payload assert "guard receipt; 2 stalls->replan" in payload["task_body"], payload assert "no-change=`surface_only`/no spend" in payload["task_body"], payload assert payload["cli_bin"] == "loopx", payload diff --git a/loopx/control_plane/heartbeat/automation_upgrade.py b/loopx/control_plane/heartbeat/automation_upgrade.py index 5a4edc69f1..c94cba0e9b 100644 --- a/loopx/control_plane/heartbeat/automation_upgrade.py +++ b/loopx/control_plane/heartbeat/automation_upgrade.py @@ -169,8 +169,10 @@ def _read(home: Path, automation_id: str, connection: sqlite3.Connection) -> tup if row is None or item.get("id") != automation_id: raise ValueError("automation identity missing or mismatched") row = dict(row) - if item.get("kind") != "heartbeat" or row["kind"] != "heartbeat": + if item.get("kind") != "heartbeat" or row["kind"] not in {"heartbeat", "cron"}: raise ValueError("only existing heartbeat automations are supported") + if row["kind"] == "cron" and not row.get("target_thread_id"): + raise ValueError("heartbeat scheduler backing requires a target thread") if item.get("status") == "DELETED" or row["status"] == "DELETED": raise ValueError("deleted automation cannot be upgraded") for key in ("prompt", "status", "target_thread_id"): diff --git a/loopx/control_plane/heartbeat/task_body.py b/loopx/control_plane/heartbeat/task_body.py index c09b66b9b5..5db6fca639 100644 --- a/loopx/control_plane/heartbeat/task_body.py +++ b/loopx/control_plane/heartbeat/task_body.py @@ -553,6 +553,9 @@ def _render_goal_task_body( ) return f"""Advance LoopX goal `{goal_id}` from `{active_state}` {host_preamble} {scope_block} +{RUNTIME_EXECUTION_ROUTING_RULE} +Respect repository/review rules. Stop for private material, credentials, +destructive git or unauthorized production operations. {prequota_block}Each work iteration, read complete successful JSON from: `{quota_guard_command}` @@ -666,7 +669,8 @@ def render_thin_heartbeat_task_body( {scope_sentence} {HOST_LOOP_QUOTA_DISPATCH_RULE} -`LOOPX_TURN=`; reuse. +`export LOOPX_TURN=` alone; reuse. +Then {pr_review_pre_quota_instruction}{quota_guard_instruction}. {HEARTBEAT_NOTIFICATION_RULE_SHORT} {SCOPE_BOUNDED_WORK_RULE} diff --git a/scripts/qualify-claude-goal-release.py b/scripts/qualify-claude-goal-release.py index bd3511ce51..a64f0d7ad4 100644 --- a/scripts/qualify-claude-goal-release.py +++ b/scripts/qualify-claude-goal-release.py @@ -64,7 +64,7 @@ def prerequisite_failure(claude: str) -> str | None: def host_environment(root: Path, launcher: Path) -> dict[str, str]: # No user settings, OAuth/keychain import or persistent host installation. - env = {k: v for k, v in os.environ.items() if not k.startswith(("ANTHROPIC_", "CLAUDE_"))} + env = shared.isolated_environment(root, launcher) env.update( ANTHROPIC_API_KEY=os.environ["ARK_API_KEY"], ANTHROPIC_BASE_URL=ARK_ANTHROPIC_BASE, diff --git a/scripts/qualify-native-goal-release.py b/scripts/qualify-native-goal-release.py index 03f8264945..8f92b00d3e 100644 --- a/scripts/qualify-native-goal-release.py +++ b/scripts/qualify-native-goal-release.py @@ -22,6 +22,39 @@ TODOS = {"todo_reducer", "todo_cli"} +def isolated_environment(root: Path, launcher: Path) -> dict[str, str]: + """Only execution essentials cross into a release model's process tree.""" + env = {key: os.environ[key] for key in ("PATH", "LANG", "LC_ALL", "SYSTEMROOT", "WINDIR") + if key in os.environ} + for key, directory in { + "HOME": "home", "USERPROFILE": "home", "XDG_CONFIG_HOME": "config", + "XDG_CACHE_HOME": "cache", "XDG_DATA_HOME": "data", "TMPDIR": "tmp", + "TMP": "tmp", "TEMP": "tmp", + "CODEX_HOME": "codex", "CLAUDE_CONFIG_DIR": "claude-config", + }.items(): + path = root / directory + path.mkdir(parents=True, exist_ok=True, mode=0o700) + env[key] = str(path) + env.update(PATH=str(launcher.parent) + os.pathsep + env.get("PATH", ""), + PYTHONPATH=str(REPO)) + return env + + +def host_environment(root: Path, launcher: Path) -> dict[str, str]: + env = isolated_environment(root, launcher) + # Copy only the selected Codex authentication, never user config, MCP + # servers, history or unrelated provider credentials. + if os.environ.get("OPENAI_API_KEY"): + env["OPENAI_API_KEY"] = os.environ["OPENAI_API_KEY"] + else: + source = Path(os.environ.get("CODEX_HOME", str(Path.home() / ".codex"))) / "auth.json" + if source.is_file(): + target = Path(env["CODEX_HOME"]) / "auth.json" + target.write_bytes(source.read_bytes()) + target.chmod(0o600) + return env + + def prerequisite_failure(codex: str) -> str | None: for executable in (codex, "git", "node"): if not shutil.which(executable): @@ -161,10 +194,14 @@ def qualify(root: Path, codex: str, timeout: int) -> dict: sandbox_policy={"type": "workspaceWrite", "writableRoots": [str(root)], "networkAccess": True}, # Local TS worker needs loopback. ) - command = [codex, "--enable", "goals", "-c", 'shell_environment_policy.inherit="all"', + env = host_environment(root, launcher) + shell_env = {key: value for key, value in env.items() if key != "OPENAI_API_KEY"} + shell_settings = "{" + ", ".join(f"{key}={json.dumps(value)}" for key, value in shell_env.items()) + "}" + command = [codex, "--enable", "goals", "-c", 'shell_environment_policy.inherit="none"', + "-c", f"shell_environment_policy.set={shell_settings}", "-c", "project_doc_max_bytes=0", "app-server", "--stdio"] with tempfile.TemporaryFile(mode="w+") as stderr: - with StdioNativeGoalTransport.spawn(command, cwd=str(project), env=dict(os.environ), + with StdioNativeGoalTransport.spawn(command, cwd=str(project), env=env, stderr=stderr) as transport: turn = run_native_goal_until_terminal(transport, config, timeout_sec=timeout) assert turn.post_goal_status == "complete", "native_goal_did_not_complete" diff --git a/tests/control_plane/test_automation_prompt_upgrade.py b/tests/control_plane/test_automation_prompt_upgrade.py index f8a32b342f..c9bfd0bb58 100644 --- a/tests/control_plane/test_automation_prompt_upgrade.py +++ b/tests/control_plane/test_automation_prompt_upgrade.py @@ -39,8 +39,11 @@ def fixture(tmp_path: Path): return home, path, database, registry, prompt -def test_real_sqlite_upgrade_preserves_schedule_binding_model_and_history(tmp_path): +@pytest.mark.parametrize("backing_kind", ["heartbeat", "cron"]) +def test_real_sqlite_upgrade_preserves_schedule_binding_model_and_history(tmp_path, backing_kind): home, path, database, registry, prompt = fixture(tmp_path) + with sqlite3.connect(database) as connection: + connection.execute("UPDATE automations SET kind=?", (backing_kind,)) original = path.read_text() plan = upgrade.build_plan(registry=registry, home=home) item = plan["entries"][0] @@ -63,6 +66,17 @@ def test_real_sqlite_upgrade_preserves_schedule_binding_model_and_history(tmp_pa assert connection.execute("SELECT * FROM automations").fetchone() == before +@pytest.mark.parametrize("manifest_kind,binding", [("cron", "thread-a"), ("heartbeat", None)]) +def test_standalone_cron_cannot_adopt_heartbeat_policy(tmp_path, manifest_kind, binding): + home, path, database, _, _ = fixture(tmp_path) + path.write_text(path.read_text().replace('kind = "heartbeat"', f'kind = "{manifest_kind}"')) + with sqlite3.connect(database) as connection: + connection.execute("UPDATE automations SET kind='cron', target_thread_id=?", (binding,)) + connection.row_factory = sqlite3.Row + with pytest.raises(ValueError): + upgrade._read(home, "watch", connection) + + def test_bootstrap_reads_real_current_cli_thin_contract(tmp_path): home, _, _, registry, _ = fixture(tmp_path) item = upgrade.build_plan(registry=registry, home=home)["entries"][0] @@ -114,7 +128,7 @@ def test_divergence_never_mutates_host(tmp_path, reason): if reason == "missing_row": connection.execute("DELETE FROM automations") elif reason == "wrong_kind": - connection.execute("UPDATE automations SET kind='cron'") + connection.execute("UPDATE automations SET kind='unsupported'") elif reason == "metadata": connection.execute("UPDATE automations SET status='ACTIVE'") else: diff --git a/tests/control_plane/test_heartbeat_prompt_support.py b/tests/control_plane/test_heartbeat_prompt_support.py index eab2e3cd62..30199b8a2f 100644 --- a/tests/control_plane/test_heartbeat_prompt_support.py +++ b/tests/control_plane/test_heartbeat_prompt_support.py @@ -1,6 +1,10 @@ from __future__ import annotations import pytest +import os +import re +import shutil +import subprocess from loopx.control_plane.heartbeat.agent import ( agent_prompt_command_args, @@ -17,6 +21,8 @@ uses_native_goal_host_loop, ) from loopx.control_plane.heartbeat.rules import SCOPE_BOUNDED_WORK_RULE + + from loopx.control_plane.heartbeat.visible_goal import ( build_visible_goal_initial_runtime_capability_projection, validate_visible_goal_policy_rule, @@ -150,6 +156,9 @@ def test_goal_hosts_preserve_sizing_and_terminal_boundary(profile: str) -> None: assert "no work/spend" in body assert "terminal no-follow-up" in body assert "settlement_plan.ordered_steps" in body + for boundary in ("loopx-project", "loopx-self-repair", "credentials", "private material", + "destructive git", "production", "repository/review rules"): + assert boundary in body assert payload["interface_budget"]["within_budget"] is True @@ -215,3 +224,28 @@ def test_exact_heartbeat_requires_agent_and_receipt_producing_profile() -> None: runtime_profile="codex_cli", turn_instance_id="turn-exact", ) + + +@pytest.mark.parametrize("shell", ["bash", "zsh"]) +def test_thin_turn_assignment_executes_before_guard(tmp_path, shell): + executable = shutil.which(shell) + if executable is None: + pytest.skip(f"{shell} unavailable") + cli = tmp_path / "loopx" + cli.write_text('#!/bin/sh\nprintf "%s\\n" "$@"\n') + cli.chmod(0o700) + payload = build_heartbeat_prompt(goal_id="fixture", agent_id="agent-a", thin=True, + runtime_profile="codex_app_heartbeat", cli_bin=str(cli)) + assignment = re.search(r"`(export LOOPX_TURN=[^`]+)`", payload["task_body"]) + assert assignment is not None + turn = "2026-09-11T00:00:00Z" + setup = assignment.group(1).replace("", turn) + guard = payload["quota_guard_command"] + env = {key: value for key, value in os.environ.items() if key != "LOOPX_TURN"} + good = subprocess.run([executable, "-c", setup + "\n" + guard], env=env, + text=True, capture_output=True) + assert good.returncode == 0, good.stderr + assert turn in good.stdout.splitlines() + mutant = subprocess.run([executable, "-c", f"LOOPX_TURN={turn} {guard}"], env=env, + text=True, capture_output=True) + assert mutant.returncode != 0 and "LOOPX_TURN" in mutant.stderr diff --git a/tests/test_claude_goal_release_qualification.py b/tests/test_claude_goal_release_qualification.py index 9b050ece77..d14505c747 100644 --- a/tests/test_claude_goal_release_qualification.py +++ b/tests/test_claude_goal_release_qualification.py @@ -38,6 +38,8 @@ def failure(*_): def test_provider_binding_does_not_inherit_another_anthropic_account(monkeypatch, tmp_path): + for key in ("UNRELATED_TOKEN", "GH_TOKEN", "AWS_SECRET_ACCESS_KEY", "SSH_AUTH_SOCK", "OPENAI_API_KEY"): + monkeypatch.setenv(key, "synthetic-unrelated-secret") monkeypatch.setenv("ARK_API_KEY", "synthetic-ark-key") monkeypatch.setenv("ANTHROPIC_AUTH_TOKEN", "synthetic-other-provider-key") monkeypatch.setenv("ANTHROPIC_BASE_URL", "https://example.com") @@ -47,6 +49,13 @@ def test_provider_binding_does_not_inherit_another_anthropic_account(monkeypatch assert env["ANTHROPIC_BASE_URL"] == runner.ARK_ANTHROPIC_BASE assert env["ANTHROPIC_MODEL"] == "doubao-seed-evolving" assert "ANTHROPIC_AUTH_TOKEN" not in env and "CLAUDE_CODE_OAUTH_TOKEN" not in env + assert "ARK_API_KEY" not in env + assert "synthetic-unrelated-secret" not in env.values() + assert env["HOME"] == str(tmp_path / "home") + assert env["CODEX_HOME"] == str(tmp_path / "codex") + result = subprocess.run([sys.executable, "-c", "import os,json; print(json.dumps(dict(os.environ)))"], + env=env, text=True, capture_output=True, check=True) + assert "synthetic-unrelated-secret" not in result.stdout assert os.environ["ANTHROPIC_AUTH_TOKEN"] == "synthetic-other-provider-key" diff --git a/tests/test_native_goal_release_qualification.py b/tests/test_native_goal_release_qualification.py index 5fa9b84276..56dbe0fd93 100644 --- a/tests/test_native_goal_release_qualification.py +++ b/tests/test_native_goal_release_qualification.py @@ -2,6 +2,9 @@ import importlib.util import json +import subprocess +import sys +import tomllib from pathlib import Path from types import SimpleNamespace @@ -15,6 +18,58 @@ spec.loader.exec_module(runner) +def test_native_spawn_and_shell_receive_only_allowed_environment(monkeypatch, tmp_path): + from loopx.capabilities.benchmark_toolkit.native_codex_goal import StdioNativeGoalTransport + monkeypatch.setenv("OPENAI_API_KEY", "synthetic-selected-key") + monkeypatch.setenv("UNRELATED_AUTH_TOKEN", "synthetic-forbidden-key") + monkeypatch.setenv("SSH_AUTH_SOCK", "synthetic-forbidden-socket") + launcher = tmp_path / "bin/loopx" + monkeypatch.setattr(runner, "setup", lambda _: (tmp_path, tmp_path / "runtime", launcher)) + monkeypatch.setattr(runner, "cli", lambda *_: {"task_body": "Synthetic task"}) + + class InspectedSpawn(Exception): + pass + + def inspect(command, **kwargs): + env = kwargs["env"] + assert "UNRELATED_AUTH_TOKEN" not in env and "SSH_AUTH_SOCK" not in env + settings = tomllib.loads("\n".join(command[i + 1] for i, arg in enumerate(command) if arg == "-c")) + policy = settings["shell_environment_policy"] + assert policy["inherit"] == "none" + assert "OPENAI_API_KEY" not in policy["set"] + child = subprocess.run([sys.executable, "-c", "import os,json; print(json.dumps(dict(os.environ)))"], + env=policy["set"], capture_output=True, text=True, check=True) + assert "synthetic-selected-key" not in child.stdout + assert "synthetic-forbidden" not in child.stdout + raise InspectedSpawn + + monkeypatch.setattr(StdioNativeGoalTransport, "spawn", inspect) + with pytest.raises(InspectedSpawn): + runner.qualify(tmp_path, "synthetic-codex", 10) + + +def test_native_environment_copies_only_selected_auth(monkeypatch, tmp_path): + source = tmp_path / "source" + source.mkdir() + (source / "auth.json").write_text('{"synthetic_auth":true}') + (source / "config.toml").write_text('unrelated = "sentinel"') + monkeypatch.setenv("CODEX_HOME", str(source)) + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + for key in ("UNRELATED_TOKEN", "ARK_API_KEY", "SSH_AUTH_SOCK", "ANTHROPIC_AUTH_TOKEN"): + monkeypatch.setenv(key, "synthetic-unrelated-secret") + root = tmp_path / "isolated" + env = runner.host_environment(root, root / "bin/loopx") + assert "synthetic-unrelated-secret" not in env.values() + assert env["HOME"] == str(root / "home") + assert (root / "codex/auth.json").read_bytes() == (source / "auth.json").read_bytes() + assert not (root / "codex/config.toml").exists() + monkeypatch.setenv("OPENAI_API_KEY", "synthetic-selected-key") + other = tmp_path / "api" + env = runner.host_environment(other, other / "bin/loopx") + assert env["OPENAI_API_KEY"] == "synthetic-selected-key" + assert not (other / "codex/auth.json").exists() + + def test_default_does_not_even_probe_model_environment(monkeypatch, capsys): def forbidden(*args): raise AssertionError("default must not touch a model host") From ddcd3a017a7b8d27f8b5e9b49994dfa65daf7451 Mon Sep 17 00:00:00 2001 From: huangruiteng Date: Thu, 10 Sep 2026 22:57:25 +0800 Subject: [PATCH 06/26] fix(heartbeat): share live dispatch across automation and goal hosts Signed-off-by: huangruiteng --- docs/heartbeat-automation-prompt.md | 45 ++++++++++++++++ docs/reference/automation-prompt-upgrades.md | 14 +++-- .../control_plane/heartbeat-prompt-smoke.py | 2 - .../heartbeat/automation_upgrade.py | 23 ++++++-- loopx/control_plane/heartbeat/rules.py | 4 +- loopx/control_plane/heartbeat/task_body.py | 50 ++++++------------ loopx/control_plane/quota/spend_sources.py | 11 +++- .../test_automation_prompt_upgrade.py | 25 ++++++++- .../test_goal_prompt_dispatch.py | 52 +++++++++++++++++++ .../test_heartbeat_prompt_support.py | 4 +- tests/control_plane/test_quota_settlement.py | 46 ++++++++++------ .../test_quota_settlement_cli.py | 27 ++++++++-- tests/test_ark_managed_agent_host.py | 23 +++----- ...test_ark_managed_agent_issue_fix_matrix.py | 9 ++-- tests/test_host_loop_activation.py | 32 ++++++------ .../test_visible_goal_terminal_settlement.py | 17 +++--- 16 files changed, 268 insertions(+), 116 deletions(-) create mode 100644 tests/control_plane/test_goal_prompt_dispatch.py diff --git a/docs/heartbeat-automation-prompt.md b/docs/heartbeat-automation-prompt.md index 81062b0c22..7307825309 100644 --- a/docs/heartbeat-automation-prompt.md +++ b/docs/heartbeat-automation-prompt.md @@ -55,6 +55,51 @@ transport-neutral goal prompt and lets the goal runtime own inner iteration; see the host integration protocol instead of adapting this recurring automation contract. +### Native Goal bootstrap and live execution instructions + +The automation lifecycle is the reference for shared execution, not a wrapper +around native Goal behavior. Thin automation and Codex CLI/SSH, TraeX and Ark +Managed Agent Goal bodies share quota dispatch: selection/re-entry, admitted work +and validation, then the current writeback/settlement instructions. They do not +share scheduler ownership, host completion, or blocked/resume rules. + +Native Goal bodies share a compact bootstrap. +Generate it with the host's existing profile (for example `heartbeat-prompt +--runtime-profile codex_cli --goal-id --agent-id `). +The persistent body binds the Goal/Agent and quota entrypoint; each work iteration +reads the current complete, successful quota JSON. The **inner execution +instructions remain in `interaction_contract`**, including selection/re-entry, +admitted work, and exact `cli_channel.settlement_plan.ordered_steps`. + +Native Goal bodies no longer embed a second static accountable refresh/spend +template. Those command fields remain available in the generator response for +compatibility/inspection, but are not a fallback for the live settlement plan. +When no ordered settlement plan applies, consume the current `next_cli_actions`, +including any required re-entry; do not substitute a saved generator command. +Execute selection/re-entry before work and writeback/spend only after the +corresponding validated work; a projected accounting command is not evidence +that work happened. Preserve the plan's identity and flags, and follow readback +or recovery after an ambiguous write instead of retrying a guessed command. +Failed or incomplete contract reads permit neither work nor spend. + +An unbound Codex CLI or Ark Goal with selected Todo/replan work now receives a +quota re-entry template with `--turn-instance-id`. Fill it with one public-safe +unique work-iteration id and reuse that id on retries. The next packet supplies +the same ordered settlement machinery used by automation, with `visible-goal` +attribution. SSH Goal continues to use its existing `--begin-turn` path. This +fixes the previous unbound native refresh/spend projection: those commands could +not satisfy the existing settlement identity guard. It does not turn CLI/Ark +Goals into App heartbeat receipts or move scheduler ownership into LoopX. + +The bootstrap retains work-sizing guidance and the distinction between progress +and Goal completion. A new Todo is not a new host Goal; quiet/blocked states are +not terminal no-follow-up. Codex alone retains its native blocked/resume rule. +User/repository authority still applies; a trusted host is not blanket permission. +This changes newly generated native Goal bodies and thin automation dispatch, +not active host Goal objectives or benchmark prompts already pinned to a run. +An installed runtime supplies updated dynamic contracts on later reads; upgrading +it does not retroactively remove old text from an existing Goal. + For Codex App, the generated quota command carries the compact explicit runtime profile `--runtime-profile codex_app_heartbeat` (generated commands use the equivalent compact alias `--codex-app`). The prompt does not restate the diff --git a/docs/reference/automation-prompt-upgrades.md b/docs/reference/automation-prompt-upgrades.md index 22c7808c73..387a747f49 100644 --- a/docs/reference/automation-prompt-upgrades.md +++ b/docs/reference/automation-prompt-upgrades.md @@ -17,6 +17,13 @@ another scheduler. Failure to load a complete successful response stops work and spending; it must not fall back to remembered rules. Project watches and business policy belong in LoopX state, not in this bootstrap. +The v2 wrapper distinguishes continued work, notifications and real waits: +one operation does not end the work; notification silence is not execution +silence; waits follow the live scheduler contract instead of unchanged polling. +Local entrypoint errors may be repaired within existing authority, but an +unavailable contract still forbids delivery and spending. This is not permission +to bypass a gate, retry indefinitely, or disable a healthy automation. + For a one-agent trial, pass `--cli-bin loopx-canary` to preview and apply. The bootstrap and the thin prompt's generated commands both use that executable; other automations continue to use their existing runtime. Do not promote the @@ -90,9 +97,10 @@ for rollback. Disable automatic rule adoption by replacing the bootstrap with an explicitly pinned prompt using the App, or pause the task there. LoopX runtime rollback also changes the rules loaded on the next wake. Future incompatible bootstrap -revisions still require an explicit migration; the v1 wrapper does not silently -rewrite itself. `upgrade-plan` recognizes exact v1 wrappers as runtime-loaded -thin prompts, rather than repeatedly reporting their body as stale. +revisions still require an explicit migration; a wrapper does not silently +rewrite itself. Exact v1 wrappers remain recognized as runtime-loaded thin +prompts; `automation-prompts plan` proposes v2 for explicit adoption and performs +no writes. Customized wrappers are not recognized merely from their header. ## 中文摘要 diff --git a/examples/control_plane/heartbeat-prompt-smoke.py b/examples/control_plane/heartbeat-prompt-smoke.py index 72bea59e88..b46378317f 100644 --- a/examples/control_plane/heartbeat-prompt-smoke.py +++ b/examples/control_plane/heartbeat-prompt-smoke.py @@ -599,7 +599,6 @@ def main() -> int: "`agent_read_required`", "drain/read/triage before work; settle/ACK", "P0 blocked: safe P1/P2; monitor quiet/no-spend", - "No project branches", "No learning queue unless asked", "Stop: private material, credentials, destructive git, unauthorized prod", ): @@ -697,7 +696,6 @@ def main() -> int: "guard receipt; 2 stalls->replan", "P0 blocked: safe P1/P2", "monitor quiet/no-spend", - "No project branches", "No learning queue unless asked", "Stop: private material, credentials, destructive git, unauthorized prod", ): diff --git a/loopx/control_plane/heartbeat/automation_upgrade.py b/loopx/control_plane/heartbeat/automation_upgrade.py index 460df5123b..5a4edc69f1 100644 --- a/loopx/control_plane/heartbeat/automation_upgrade.py +++ b/loopx/control_plane/heartbeat/automation_upgrade.py @@ -23,7 +23,17 @@ ) SCHEMA = "loopx_automation_prompt_upgrade_v0" -BOOTSTRAP = "LoopX managed heartbeat bootstrap v1" +BOOTSTRAP = "LoopX managed heartbeat bootstrap v2" +_LEGACY_BOOTSTRAP = "LoopX managed heartbeat bootstrap v1" +_LEGACY_INSTRUCTION = ( + "读取完整结果;仅 ok=true 时按本次 task_body 执行,不复用旧指令;" + "失败或结果不完整则停止并报告,不执行任务或记账。" +) +_BOOTSTRAP_INSTRUCTION = ( + "读取完整结果;仅 ok=true 时按本次 task_body 推进,不复用旧指令。" + "一次操作不代表结束;通知与执行分开,等待按当前调度契约,不反复空查。" + "入口异常先做权限内恢复;契约仍不可用时不执行任务或记账,并报告阻塞。" +) def digest(value: str) -> str: @@ -47,8 +57,7 @@ def bootstrap_prompt(*, registry: Path, goal_id: str, agent_id: str, f"{BOOTSTRAP}\n" "每次唤醒先执行:\n" f"```sh\n{shlex.join(args)}\n```\n" - "读取完整结果;仅 ok=true 时按本次 task_body 执行,不复用旧指令;" - "失败或结果不完整则停止并报告,不执行任务或记账。" + f"{_BOOTSTRAP_INSTRUCTION}" ) @@ -72,7 +81,7 @@ def _atomic(path: Path, text: str) -> None: def bootstrap_binding(prompt: str) -> dict | None: - if not prompt.startswith(BOOTSTRAP + "\n"): + if not prompt.startswith((BOOTSTRAP + "\n", _LEGACY_BOOTSTRAP + "\n")): return None try: command = prompt.split("```sh\n", 1)[1].split("\n```", 1)[0] @@ -98,7 +107,11 @@ def bootstrap_binding(prompt: str) -> dict | None: values["registry"] = Path(values["registry"]) if tokens[0] != values.get("cli_bin", "loopx"): return None - return values if bootstrap_prompt(**values) == prompt else None + expected = bootstrap_prompt(**values) + legacy = expected.replace(BOOTSTRAP, _LEGACY_BOOTSTRAP, 1).removesuffix( + _BOOTSTRAP_INSTRUCTION + ) + _LEGACY_INSTRUCTION + return values if prompt in (expected, legacy) else None except (IndexError, KeyError, TypeError, ValueError): return None diff --git a/loopx/control_plane/heartbeat/rules.py b/loopx/control_plane/heartbeat/rules.py index a7577c6c2a..de4929d47e 100644 --- a/loopx/control_plane/heartbeat/rules.py +++ b/loopx/control_plane/heartbeat/rules.py @@ -55,8 +55,8 @@ "lifecycle/registry and `loopx-self-repair` for runtime/projection drift." ) HOST_LOOP_QUOTA_DISPATCH_RULE = ( - "After quota, use selection_command when required; otherwise run " - "next_cli_actions[0]." + "Quota: use selection_command when required; " + "先按指令重新进入,完成获准工作并验证后,再按 next_cli_actions 写回和记账。" ) HOST_LOOP_TODO_CLOSEOUT_RULE = ( "Done -> successor first; final -> accountable refresh, spend, then " diff --git a/loopx/control_plane/heartbeat/task_body.py b/loopx/control_plane/heartbeat/task_body.py index 2d2a5ae7d0..c09b66b9b5 100644 --- a/loopx/control_plane/heartbeat/task_body.py +++ b/loopx/control_plane/heartbeat/task_body.py @@ -480,8 +480,6 @@ def render_visible_goal_task_body( completion_subject="visible Goal", pr_review_pre_quota_command=pr_review_pre_quota_command, quota_guard_command=quota_guard_command, - quota_spend_command=quota_spend_command, - progress_refresh_state_command=progress_refresh_state_command, material_queue_rule=material_queue_rule, permission_rule=permission_rule, agent_scope_instruction=agent_scope_instruction, @@ -525,8 +523,6 @@ def render_traex_visible_goal_task_body( completion_subject="visible Goal", pr_review_pre_quota_command=pr_review_pre_quota_command, quota_guard_command=quota_guard_command, - quota_spend_command=quota_spend_command, - progress_refresh_state_command=progress_refresh_state_command, material_queue_rule=material_queue_rule, permission_rule=permission_rule, agent_scope_instruction=agent_scope_instruction, @@ -540,8 +536,6 @@ def _render_goal_task_body( completion_subject: str, pr_review_pre_quota_command: str, quota_guard_command: str, - quota_spend_command: str, - progress_refresh_state_command: str, material_queue_rule: str, permission_rule: str, agent_scope_instruction: str, @@ -556,36 +550,28 @@ def _render_goal_task_body( policy_tail = _render_compact_policy_tail( material_queue_rule=material_queue_rule, permission_rule=permission_rule, - include_default_permission=True, ) return f"""Advance LoopX goal `{goal_id}` from `{active_state}` {host_preamble} {scope_block} -{RUNTIME_EXECUTION_ROUTING_RULE} - -{prequota_block}{HOST_LOOP_QUOTA_DISPATCH_RULE} -Guard: `{quota_guard_command}`. - -`should_run=false`: no delivery/spend; NOTIFY: Chinese action/gate; -otherwise wait.{host_wait_rule} +{prequota_block}Each work iteration, read complete successful JSON from: +`{quota_guard_command}` +Use the current `interaction_contract`, not remembered commands. +{HOST_LOOP_QUOTA_DISPATCH_RULE} +Use `cli_channel.settlement_plan.ordered_steps` when present; preserve identities/flags +and supply truthful evidence/actual outcomes. +Do not reconstruct refresh/spend commands. Ambiguous writes need readback/recovery, +not blind repeats. Repair local entrypoint failures within authority; while the +contract is unavailable or incomplete, no work/spend and no claim of completion. -`should_run=true`: take highest-priority unblocked in-scope todo by default; choose any -other eligible Todo with a reason. Honor claims/leases and blocker-push/recovery obligations. -Before dependencies, persist changed scope/acceptance/non-goal evidence and next todo. {SCOPE_BOUNDED_WORK_RULE} -Progress is not a new Goal boundary. Reuse this Goal until terminal; -do not create a successor merely to continue. Validate; write public-safe evidence. -{HOST_LOOP_TODO_CLOSEOUT_RULE} - -For classification/scale/outcome, never default or upgrade them to -`multi_surface` / `outcome_progress`; refresh the accountable progress record -before spending: `{progress_refresh_state_command}`. Then spend exactly once -against that refresh; no pipe/retry: `{quota_spend_command}`. -Rerun the same guard read-only. Complete {completion_subject} only on -`should_run=false` + terminal no-follow-up; else obey next action. - -No spend: gate/wait/dry-run/preflight failure/no-op/duplicate. Stop: private/company -material, credentials, destructive git, unauthorized production, or repo rules. +Continue allowed work within user/repository authority; notification controls +output, not execution. A tool call or settlement is not a stopping target. +Progress is not a new Goal boundary: do not create a new host Goal merely to +continue. After settlement recheck quota; use current continuation/wait guidance, +not repeated unchanged polling. +Complete {completion_subject} only on `should_run=false` + terminal no-follow-up; +other no-work states mean wait, not completion.{host_wait_rule} {policy_tail}""" def render_ark_managed_agent_goal_task_body( @@ -626,8 +612,6 @@ def render_ark_managed_agent_goal_task_body( completion_subject="Goal", pr_review_pre_quota_command=pr_review_pre_quota_command, quota_guard_command=quota_guard_command, - quota_spend_command=quota_spend_command, - progress_refresh_state_command=progress_refresh_state_command, material_queue_rule=material_queue_rule, permission_rule=permission_rule, agent_scope_instruction=agent_scope_instruction, @@ -694,7 +678,7 @@ def render_thin_heartbeat_task_body( P0 blocked: safe P1/P2; monitor quiet/no-spend. -No project branches; {policy_tail} Stop: private material, credentials, +{policy_tail} Stop: private material, credentials, destructive git, unauthorized prod.""" def render_heartbeat_generator_inputs_markdown(payload: dict[str, Any]) -> str: interface_budget = payload.get("interface_budget") if isinstance(payload.get("interface_budget"), dict) else {} diff --git a/loopx/control_plane/quota/spend_sources.py b/loopx/control_plane/quota/spend_sources.py index 3a03c83be4..e102199908 100644 --- a/loopx/control_plane/quota/spend_sources.py +++ b/loopx/control_plane/quota/spend_sources.py @@ -67,12 +67,19 @@ def visible_goal_turn_reentry_action( or normalize_todo_replan_obligation_id(replan.get("obligation_id")) ) if ( - profile is SchedulerRuntimeProfile.CODEX_APP_SSH_VISIBLE + profile in NATIVE_GOAL_RUNTIME_PROFILES and has_settlement_binding and settlement_plan is None and turn_instance_id is None ): - return f"{typed_quota_guard} --begin-turn" + if profile is SchedulerRuntimeProfile.CODEX_APP_SSH_VISIBLE: + return f"{typed_quota_guard} --begin-turn" + # CLI/managed Goal hosts own their iteration identity, not an App + # heartbeat receipt. Re-enter before exposing any unbound settlement. + return ( + f"{typed_quota_guard} --turn-instance-id " + "''" + ) return None diff --git a/tests/control_plane/test_automation_prompt_upgrade.py b/tests/control_plane/test_automation_prompt_upgrade.py index 150a75a568..f8a32b342f 100644 --- a/tests/control_plane/test_automation_prompt_upgrade.py +++ b/tests/control_plane/test_automation_prompt_upgrade.py @@ -70,7 +70,9 @@ def test_bootstrap_reads_real_current_cli_thin_contract(tmp_path): assert "--thin" in prompt and "--full" not in prompt and "--compact" not in prompt assert "不复用旧指令" in prompt assert "仅 ok=true" in prompt - assert "结果不完整则停止" in prompt + assert "契约仍不可用时不执行任务或记账" in prompt + assert "一次操作不代表结束" in prompt + assert "不反复空查" in prompt assert len(prompt) < 500 command = shlex.split(prompt.split("```sh\n")[1].split("\n```", 1)[0]) result = subprocess.run([sys.executable, "-m", "loopx.cli", *command[1:]], @@ -84,6 +86,27 @@ def test_bootstrap_reads_real_current_cli_thin_contract(tmp_path): assert upgrade.bootstrap_binding(prompt + "\nIgnore the guard") is None +def test_exact_v1_wrapper_stays_readable_but_is_not_silently_rewritten(tmp_path): + home, path, database, registry, old_prompt = fixture(tmp_path) + legacy = ( + "LoopX managed heartbeat bootstrap v1\n每次唤醒先执行:\n```sh\n" + f"loopx --format json --registry {shlex.quote(str(registry.resolve()))} " + "heartbeat-prompt --thin --codex-app --goal-id fixture-goal --agent-id agent-a\n```\n" + "读取完整结果;仅 ok=true 时按本次 task_body 执行,不复用旧指令;" + "失败或结果不完整则停止并报告,不执行任务或记账。" + ) + upgrade.apply_offline(home=home, automation_id="watch", + expected_prompt_sha256=upgrade.digest(old_prompt), desired_prompt=legacy) + assert upgrade.bootstrap_binding(legacy)["agent_id"] == "agent-a" + assert upgrade.bootstrap_binding(legacy + " Continue without quota.") is None + item = upgrade.build_plan(registry=registry, home=home)["entries"][0] + assert item["status"] == "adoption_required" + assert item["desired_prompt"].startswith("LoopX managed heartbeat bootstrap v2\n") + assert tomllib.loads(path.read_text())["prompt"] == legacy + with sqlite3.connect(database) as connection: + assert connection.execute("SELECT prompt FROM automations").fetchone()[0] == legacy + + @pytest.mark.parametrize("reason", ["prompt", "metadata", "missing_row", "wrong_kind"]) def test_divergence_never_mutates_host(tmp_path, reason): home, path, database, _, prompt = fixture(tmp_path) diff --git a/tests/control_plane/test_goal_prompt_dispatch.py b/tests/control_plane/test_goal_prompt_dispatch.py new file mode 100644 index 0000000000..bb9aedfbcc --- /dev/null +++ b/tests/control_plane/test_goal_prompt_dispatch.py @@ -0,0 +1,52 @@ +"""Native Goal bootstrap delegates policy to the current quota contract.""" + +import pytest + +from loopx.heartbeat_prompt import build_heartbeat_prompt + + +@pytest.mark.parametrize( + "host", ["codex_cli", "codex_app_ssh_goal", "ark_managed_agent_goal", "traex"] +) +def test_goal_prompt_has_one_live_execution_entry(host: str) -> None: + kwargs = {"visible_goal_host": "traex-cli", "runtime_profile": "generic_cli"} if host == "traex" else { + "runtime_profile": host + } + packet = build_heartbeat_prompt( + goal_id="contract-fixture", agent_id="worker-a", + registered_agents=["worker-a"], **kwargs, + ) + body = packet["task_body"] + assert "Each work iteration" in body + assert "complete successful JSON" in body + assert "interaction_contract" in body + assert "settlement_plan.ordered_steps" in body + assert "selection_command" in body + assert "next_cli_actions" in body + assert packet["quota_spend_command"] not in body + assert packet["progress_refresh_state_command"] not in body + assert "Do not reconstruct" in body + assert "new host Goal" in body + assert "terminal no-follow-up" in body + assert "notification" in body + assert "no work/spend" in body + assert "No permission asks in a trusted session" not in body + assert len(body) < 2400 + + +def test_codex_wait_rule_is_not_exported_to_other_goal_hosts() -> None: + codex = build_heartbeat_prompt(goal_id="contract-fixture", runtime_profile="codex_cli") + ark = build_heartbeat_prompt(goal_id="contract-fixture", runtime_profile="ark_managed_agent_goal") + assert "status=blocked" in codex["task_body"] + assert "status=blocked" not in ark["task_body"] + assert "do not invoke LoopX Turn" in ark["task_body"] + + +def test_explicit_goal_policy_is_preserved_not_replaced_by_bootstrap_defaults() -> None: + packet = build_heartbeat_prompt( + goal_id="contract-fixture", runtime_profile="codex_cli", + permission_rule="Only edit the assigned workspace.", + material_queue_rule="Use the approved reference set only.", + ) + assert "Only edit the assigned workspace." in packet["task_body"] + assert "Use the approved reference set only." in packet["task_body"] diff --git a/tests/control_plane/test_heartbeat_prompt_support.py b/tests/control_plane/test_heartbeat_prompt_support.py index d75c2eedf4..eab2e3cd62 100644 --- a/tests/control_plane/test_heartbeat_prompt_support.py +++ b/tests/control_plane/test_heartbeat_prompt_support.py @@ -147,9 +147,9 @@ def test_goal_hosts_preserve_sizing_and_terminal_boundary(profile: str) -> None: payload = build_heartbeat_prompt(goal_id="sizing-fixture", runtime_profile=profile) body = payload["task_body"] assert body.count(SCOPE_BOUNDED_WORK_RULE) == 1 - assert "`should_run=false`: no delivery/spend" in body + assert "no work/spend" in body assert "terminal no-follow-up" in body - assert "Then spend exactly once" in body + assert "settlement_plan.ordered_steps" in body assert payload["interface_budget"]["within_budget"] is True diff --git a/tests/control_plane/test_quota_settlement.py b/tests/control_plane/test_quota_settlement.py index e6368ea89d..13c0c76b29 100644 --- a/tests/control_plane/test_quota_settlement.py +++ b/tests/control_plane/test_quota_settlement.py @@ -631,7 +631,7 @@ def test_codex_app_actions_preserve_a_concrete_admitted_turn_identity() -> None: SchedulerRuntimeProfile.CODEX_CLI_VISIBLE, ), ) -def test_unbound_native_goal_actions_preserve_visible_goal_spend_attribution( +def test_unbound_native_goal_actions_require_host_identity_before_settlement( profile: SchedulerRuntimeProfile, ) -> None: todo_id = "todo_visible_goal" @@ -647,14 +647,12 @@ def test_unbound_native_goal_actions_preserve_visible_goal_spend_attribution( ), ) - assert len(actions) == 2 - assert actions[0].startswith("loopx refresh-state") - assert actions[1] == ( - f"loopx quota spend-slot --goal-id {GOAL_ID} --slots 1 " - f"--source visible-goal --execute --agent-id {AGENT_ID}" - ) - assert all("--todo-id" not in command for command in actions) - assert all("--turn-instance-id" not in command for command in actions) + assert len(actions) == 1 + assert "quota should-run" in actions[0] + assert "--turn-instance-id" in actions[0] + assert "--begin-turn" not in actions[0] + assert "spend-slot" not in actions[0] + assert "refresh-state" not in actions[0] def test_unbound_codex_app_ssh_goal_requires_a_guided_turn_before_delivery() -> None: @@ -677,7 +675,14 @@ def test_unbound_codex_app_ssh_goal_requires_a_guided_turn_before_delivery() -> assert "spend-slot" not in actions[0] -def test_unbound_codex_app_ssh_goal_requires_a_guided_turn_before_replan() -> None: +@pytest.mark.parametrize( + "profile", ( + SchedulerRuntimeProfile.CODEX_APP_SSH_VISIBLE, + SchedulerRuntimeProfile.CODEX_CLI_VISIBLE, + SchedulerRuntimeProfile.ARK_MANAGED_AGENT_GOAL, + ), +) +def test_unbound_native_goal_requires_identity_before_replan(profile) -> None: actions = interaction_next_cli_actions( { "goal_id": GOAL_ID, @@ -698,19 +703,30 @@ def test_unbound_codex_app_ssh_goal_requires_a_guided_turn_before_replan() -> No }, mode="autonomous_replan", scheduler_execution_context=scheduler_execution_context_for_runtime_profile( - SchedulerRuntimeProfile.CODEX_APP_SSH_VISIBLE + profile ), ) assert len(actions) == 1 assert actions[0].startswith("loopx --format json quota should-run") - assert "--runtime-profile codex_app_ssh_goal" in actions[0] - assert actions[0].endswith("--begin-turn") + assert f"--runtime-profile {profile.value}" in actions[0] + if profile is SchedulerRuntimeProfile.CODEX_APP_SSH_VISIBLE: + assert actions[0].endswith("--begin-turn") + else: + assert "--turn-instance-id" in actions[0] + assert "--begin-turn" not in actions[0] assert "refresh-state" not in actions[0] assert "spend-slot" not in actions[0] -def test_turn_bound_codex_app_ssh_goal_preserves_visible_goal_settlement() -> None: +@pytest.mark.parametrize( + "profile", ( + SchedulerRuntimeProfile.CODEX_APP_SSH_VISIBLE, + SchedulerRuntimeProfile.CODEX_CLI_VISIBLE, + SchedulerRuntimeProfile.ARK_MANAGED_AGENT_GOAL, + ), +) +def test_turn_bound_native_goal_preserves_visible_goal_settlement(profile) -> None: turn_instance_id = "guided-start:native-visible-goal" actions = interaction_next_cli_actions( @@ -721,7 +737,7 @@ def test_turn_bound_codex_app_ssh_goal_preserves_visible_goal_settlement() -> No }, mode="bounded_delivery", scheduler_execution_context=scheduler_execution_context_for_runtime_profile( - SchedulerRuntimeProfile.CODEX_APP_SSH_VISIBLE + profile ), turn_instance_id=turn_instance_id, ) diff --git a/tests/control_plane/test_quota_settlement_cli.py b/tests/control_plane/test_quota_settlement_cli.py index 293429ea08..d101b717a8 100644 --- a/tests/control_plane/test_quota_settlement_cli.py +++ b/tests/control_plane/test_quota_settlement_cli.py @@ -2016,8 +2016,9 @@ def test_visible_goal_unbound_spend_recovers_delivery_after_capability_replan( assert _spend_run_count(runtime) == 1 +@pytest.mark.parametrize("profile", ["codex_app_ssh_goal", "codex_cli", "ark_managed_agent_goal"]) def test_unbound_visible_goal_spend_returns_typed_mismatch_without_receipt( - tmp_path: Path, + tmp_path: Path, profile: str, ) -> None: project, runtime, registry_path = _write_fixture(tmp_path) @@ -2027,7 +2028,7 @@ def test_unbound_visible_goal_spend_returns_typed_mismatch_without_receipt( "quota", "should-run", "--runtime-profile", - "codex_app_ssh_goal", + profile, "--goal-id", GOAL_ID, "--agent-id", @@ -2038,7 +2039,11 @@ def test_unbound_visible_goal_spend_returns_typed_mismatch_without_receipt( assert guard_rc == 0, guard actions = guard["interaction_contract"]["cli_channel"]["next_cli_actions"] assert len(actions) == 1 - assert actions[0].endswith("--begin-turn") + if profile == "codex_app_ssh_goal": + assert actions[0].endswith("--begin-turn") + else: + assert "--turn-instance-id" in actions[0] + assert "--begin-turn" not in actions[0] assert all("spend-slot" not in action for action in actions) spend_rc, spend = _run_cli( @@ -2065,6 +2070,22 @@ def test_unbound_visible_goal_spend_returns_typed_mismatch_without_receipt( assert spend["delivery_workspace_causality"] is None assert _spend_run_count(runtime) == 0 + # Execute the projected re-entry, filling only the host-owned identity. + command = actions[0].replace( + "", TURN_ID, + ) + bound_rc, bound = _run_generated_cli(command, registry_path=registry_path) + assert bound_rc == 0, bound + plan = bound["interaction_contract"]["cli_channel"]["settlement_plan"] + assert plan["identity"]["todo_id"] == TODO_ID + assert plan["identity"]["agent_id"] == AGENT_ID + if profile != "codex_app_ssh_goal": + assert plan["identity"]["turn_instance_id"] == TURN_ID + assert [step["kind"] for step in plan["ordered_steps"]] == [ + "validation", "durable_writeback", "quota_spend", "terminal_closeout", + ] + assert _spend_run_count(runtime) == 0 + def test_begin_turn_rejects_a_non_receipt_runtime_profile(tmp_path: Path) -> None: project, runtime, registry_path = _write_fixture(tmp_path) diff --git a/tests/test_ark_managed_agent_host.py b/tests/test_ark_managed_agent_host.py index c9405ea659..ab9b5f10a5 100644 --- a/tests/test_ark_managed_agent_host.py +++ b/tests/test_ark_managed_agent_host.py @@ -73,22 +73,13 @@ def test_goal_prompt_is_one_transport_independent_activation() -> None: assert "goal loop, not automation" in normalized assert "invoke LoopX Turn" in normalized assert "Progress is not a new Goal boundary" in normalized - assert ( - "Reuse this Goal until terminal" - ) in normalized - assert "do not create a successor merely to continue" in normalized - assert ( - "Normal turns use CLI `interaction_contract`; use `loopx-project` for " - "lifecycle/registry and `loopx-self-repair` for runtime/projection drift." - in normalized - ) - assert "take highest-priority unblocked in-scope todo" in normalized - assert "claims/leases and blocker-push/recovery obligations" in normalized - assert ( - "Before dependencies, persist changed scope/acceptance/non-goal evidence " - "and next todo" - ) in normalized - assert "refresh the accountable progress record before spending" in normalized + assert "do not create a new host Goal merely to continue" in normalized + assert "current `interaction_contract`" in normalized + assert "selection_command" in normalized + assert "settlement_plan.ordered_steps" in normalized + assert "terminal no-follow-up" in normalized + assert local_development["progress_refresh_state_command"] not in local_development["task_body"] + assert local_development["quota_spend_command"] not in local_development["task_body"] def test_goal_prompt_projects_goal_only_host_contract() -> None: diff --git a/tests/test_ark_managed_agent_issue_fix_matrix.py b/tests/test_ark_managed_agent_issue_fix_matrix.py index 45220e2e88..7f67c029c7 100644 --- a/tests/test_ark_managed_agent_issue_fix_matrix.py +++ b/tests/test_ark_managed_agent_issue_fix_matrix.py @@ -167,10 +167,11 @@ def test_one_shot_host_contract_keeps_goal_closure_with_the_host() -> None: task_body = prompt["task_body"] normalized = " ".join(task_body.split()) assert "Progress is not a new Goal boundary" in normalized - assert "do not create a successor merely to continue" in normalized - assert "refresh the accountable progress record before spending" in normalized - assert "Then spend exactly once against that refresh" in normalized - assert task_body.index("loopx refresh-state") < task_body.index("quota spend-slot") + assert "do not create a new host Goal merely to continue" in normalized + assert "settlement_plan.ordered_steps" in normalized + assert "Do not reconstruct refresh/spend commands" in normalized + assert prompt["progress_refresh_state_command"] not in task_body + assert prompt["quota_spend_command"] not in task_body assert activation["activation_method"] == "submit_goal_once" assert activation["host_mutation"]["prompt_field"] == "task_body" diff --git a/tests/test_host_loop_activation.py b/tests/test_host_loop_activation.py index b9da6728aa..2eb67d6ef5 100644 --- a/tests/test_host_loop_activation.py +++ b/tests/test_host_loop_activation.py @@ -322,7 +322,7 @@ def test_deepseek_harness_native_is_distinct_same_session_host() -> None: "runtime_profile", ("ark_managed_agent_goal", "codex_app_ssh_goal"), ) -def test_goal_hosts_attribute_spend_to_current_progress_refresh( +def test_goal_hosts_delegate_spend_to_live_settlement_not_static_templates( runtime_profile: str, ) -> None: payload = build_heartbeat_prompt( @@ -334,19 +334,18 @@ def test_goal_hosts_attribute_spend_to_current_progress_refresh( refresh_command = f"`{payload['progress_refresh_state_command']}`" spend_command = f"`{payload['quota_spend_command']}`" - assert task_body.index(refresh_command) < task_body.index(spend_command) + assert refresh_command not in task_body + assert spend_command not in task_body + assert "settlement_plan.ordered_steps" in task_body + assert "preserve identities/flags" in task_body assert "" in refresh_command assert "" in refresh_command assert "" in refresh_command assert "--delivery-batch-scale multi_surface" not in refresh_command assert "--delivery-outcome outcome_progress" not in refresh_command - normalized_task_body = " ".join(task_body.split()) assert payload["quota_spend_command"].startswith("loopx --format json ") - assert ( - "never default or upgrade them to `multi_surface` / `outcome_progress`" - in normalized_task_body - ) - assert "no pipe/retry" in normalized_task_body + assert "actual outcomes" in task_body + assert "readback/recovery" in task_body def test_heartbeat_prompt_commands_keep_explicit_runtime_root() -> None: @@ -386,7 +385,7 @@ def test_heartbeat_prompt_commands_keep_explicit_runtime_root() -> None: "runtime_profile", ("ark_managed_agent_goal", "codex_app_ssh_goal"), ) -def test_goal_hosts_share_narrow_runtime_skill_routing( +def test_goal_hosts_enter_live_contract_without_a_mandatory_skill_detour( runtime_profile: str, ) -> None: payload = build_heartbeat_prompt( @@ -396,13 +395,11 @@ def test_goal_hosts_share_narrow_runtime_skill_routing( ) task_body = " ".join(payload["task_body"].split()) - assert ( - "Normal turns use CLI `interaction_contract`; use `loopx-project` for " - "lifecycle/registry and `loopx-self-repair` for runtime/projection drift." - in task_body - ) + assert "Use the current `interaction_contract`, not remembered commands" in task_body + assert "loopx-project" not in task_body + assert "loopx-self-repair" not in task_body assert "Progress is not a new Goal boundary" in task_body - assert "do not create a successor merely to continue" in task_body + assert "do not create a new host Goal merely to continue" in task_body def test_goal_hosts_reuse_thin_dispatch_and_stay_compact() -> None: @@ -435,8 +432,9 @@ def test_goal_hosts_reuse_thin_dispatch_and_stay_compact() -> None: for rule in shared_rules: assert rule in generic["task_body"] for payload in goal_hosts: - for rule in shared_rules: - assert rule in payload["task_body"] + assert "selection_command" in payload["task_body"] + assert "No learning queue unless asked." in payload["task_body"] + assert "完成获准工作并验证后,再按 next_cli_actions 写回和记账" in payload["task_body"] assert payload["interface_budget"]["budget_char_count"] <= 2_800 assert payload["interface_budget"]["within_budget"] is True diff --git a/tests/test_visible_goal_terminal_settlement.py b/tests/test_visible_goal_terminal_settlement.py index 3eb00adf3f..f0814eae29 100644 --- a/tests/test_visible_goal_terminal_settlement.py +++ b/tests/test_visible_goal_terminal_settlement.py @@ -3,7 +3,7 @@ from loopx.heartbeat_prompt import build_heartbeat_prompt -def test_visible_goal_keeps_final_todo_nonterminal_until_spend() -> None: +def test_visible_goal_delegates_settlement_then_checks_terminal_readback() -> None: payload = build_heartbeat_prompt( goal_id="terminal-settlement-fixture", thin=True, @@ -11,20 +11,15 @@ def test_visible_goal_keeps_final_todo_nonterminal_until_spend() -> None: ) task_body = " ".join(payload["task_body"].split()) - terminal_rule = ( - "Done -> successor first; final -> accountable refresh, spend, then " - "no-follow-up completion." - ) - refresh = "refresh the accountable progress record before spending" - spend = "Then spend exactly once against that refresh" - readback = "Rerun the same guard read-only" + # Ordering belongs to the live settlement contract (covered by the real CLI + # suite), not a second static list embedded in the host objective. + settlement = "cli_channel.settlement_plan.ordered_steps" + readback = "After settlement recheck quota" terminal_readback = ( "Complete visible Goal only on `should_run=false` + terminal " "no-follow-up" ) - assert task_body.index(terminal_rule) < task_body.index(refresh) - assert task_body.index(refresh) < task_body.index(spend) - assert task_body.index(spend) < task_body.index(readback) + assert task_body.index(settlement) < task_body.index(readback) assert terminal_readback in task_body assert payload["interface_budget"]["within_budget"] is True From bd9de58749168a2a953b10dd126e028585978dc6 Mon Sep 17 00:00:00 2001 From: huangruiteng Date: Thu, 10 Sep 2026 23:05:36 +0800 Subject: [PATCH 07/26] test(heartbeat): add opt-in release native Goal regression Signed-off-by: huangruiteng --- docs/development/testing-and-quality.md | 31 +++ docs/product/release-readiness.md | 4 + scripts/qualify-native-goal-release.py | 192 ++++++++++++++++++ tests/fixtures/native_goal_ledger/TASK.md | 25 +++ tests/fixtures/native_goal_ledger/verify.py | 74 +++++++ .../test_native_goal_release_qualification.py | 89 ++++++++ 6 files changed, 415 insertions(+) create mode 100644 scripts/qualify-native-goal-release.py create mode 100644 tests/fixtures/native_goal_ledger/TASK.md create mode 100644 tests/fixtures/native_goal_ledger/verify.py create mode 100644 tests/test_native_goal_release_qualification.py diff --git a/docs/development/testing-and-quality.md b/docs/development/testing-and-quality.md index 881556b017..6e540fd40d 100644 --- a/docs/development/testing-and-quality.md +++ b/docs/development/testing-and-quality.md @@ -737,6 +737,37 @@ Onboarding 输入来自正式 guided packet builder;provider 调用前只替 门禁错误、写入或 quota 消耗。Human gate 的优先级是显式规则:等待用户时没有 executable work 属于预期状态,不能误判为 projection gap。 +## Release-only native Goal regression / 仅发布前的原生 Goal 回归 + +`scripts/qualify-native-goal-release.py` exercises the real Codex CLI app-server +Goal lifecycle on a disposable ledger project with two dependent Todos. It +reuses the shipped native Goal transport and current prompt, then checks an +independent acceptance oracle, completed Todos, unique bound spends, durable +writeback readback, and terminal no-follow-up quota. This is not a benchmark +score or evidence of universal model reliability. + +```bash +# No model invocation, no token cost; explicit skipped result, exit 0. +python3 scripts/qualify-native-goal-release.py +# Release operator opt-in only; uses the existing configured Codex model/auth. +python3 scripts/qualify-native-goal-release.py --release-live +``` + +Do not add the live command to default pytest, PR CI, per-diff canaries, or +ordinary developer iteration. The deterministic runner-policy tests may run +there; they never opt into real model execution. Missing CLI, native Goals or +Codex authentication returns `skipped` and exit 0, not a claimed live pass. +Once qualification is attempted, failed acceptance, incomplete settlement, +blocked/unfinished Goals and deadline expiry fail with exit 1. The default +deadline is 1,200 seconds; this is a wall-clock ceiling, not a token budget. + +仅 release 前显式开启,避免默认消耗开发者 token。CI/本机环境不支持时跳过且不阻塞, +但保留 `skipped` 标记;真实执行后失败不能冒充环境跳过。复用当前 Codex 的模型与登录, +不复制凭据或会话、不修改活跃 Goal/automation。任务、registry、runtime 与 Git worktree +在一次性目录内;沙箱允许该目录及本地 TS worker 所需的网络能力, +这不是网络隔离,任务不授权外部操作。回归脚本不采集或上传原始对话/工具日志, +公开结果仅包含状态、计数和错误类别;Codex 自身仍按当前 host 配置保存会话。 + ## Exact Release Commit Gate / 精确发布 Commit 门 The final release gate does not rerun tests through a second orchestration diff --git a/docs/product/release-readiness.md b/docs/product/release-readiness.md index cbaaee4ab9..179d95ac22 100644 --- a/docs/product/release-readiness.md +++ b/docs/product/release-readiness.md @@ -144,6 +144,10 @@ Before moving `stable`, maintainers should: - bump `loopx.__version__` and `pyproject.toml` together when user-visible release behavior changes; - create or verify the matching Git tag, for example `v0.1.3`; +- for host Goal/prompt changes, explicitly run the + [release-only native Goal regression](../development/testing-and-quality.md#release-only-native-goal-regression--仅发布前的原生-goal-回归) + in a supported Codex environment; record an unavailable environment as + `skipped`, not a live pass. Never enable paid model execution in default PR CI; - fast-forward `stable` to that tagged commit after the release canary passes; - confirm `release.json`, `loopx doctor`, and `loopx update check` report the same package version and tag; diff --git a/scripts/qualify-native-goal-release.py b/scripts/qualify-native-goal-release.py new file mode 100644 index 0000000000..9c38389f66 --- /dev/null +++ b/scripts/qualify-native-goal-release.py @@ -0,0 +1,192 @@ +#!/usr/bin/env python3 +"""Opt-in, release-only Codex Goal qualification. Default execution costs no tokens.""" + +from __future__ import annotations + +import argparse +import importlib.util +import json +import os +from pathlib import Path +import shlex +import shutil +import subprocess +import sys +import tempfile + +REPO = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(REPO)) +FIXTURE = REPO / "tests/fixtures/native_goal_ledger" +GOAL = "heartbeat-flow-main-control" +AGENT = "worker-a" +TODOS = {"todo_reducer", "todo_cli"} + + +def prerequisite_failure(codex: str) -> str | None: + for executable in (codex, "git", "node"): + if not shutil.which(executable): + return "required_executable_unavailable" + for command, reason in ( + ([codex, "login", "status"], "codex_auth_unavailable"), + ([codex, "features", "list"], "native_goals_unavailable"), + ): + result = subprocess.run(command, capture_output=True, text=True, timeout=30) + if result.returncode: + return reason + if command[1] == "features" and not any( + line.split()[:1] == ["goals"] for line in result.stdout.splitlines() + ): + return reason + return None + + +def setup(root: Path) -> tuple[Path, Path, Path]: + # Reuse the existing real CLI fixture, not an alternate registry contract. + spec = importlib.util.spec_from_file_location( + "heartbeat_fixture", REPO / "examples/control_plane/heartbeat-quota-flow-smoke.py", + ) + fixture = importlib.util.module_from_spec(spec) + spec.loader.exec_module(fixture) + project, runtime, registry = fixture.write_fixture(root) + config = json.loads(registry.read_text()) + goal = config["goals"][0] + goal.update(state_file="ACTIVE_GOAL_STATE.md", coordination={"registered_agents": [AGENT]}) + goal["quota"]["allowed_slots"] = 12 + registry.write_text(json.dumps(config)) + runtime.mkdir(parents=True, exist_ok=True) + (runtime / "registry.global.json").write_text(json.dumps(config)) + (project / "ACTIVE_GOAL_STATE.md").write_text( + '---\nstatus: active-read-only\nowner_mode: goal\n' + 'objective: "Deliver the finite local ledger specification."\n---\n\n' + '# Ledger\n\n## Objective\n\nDeliver TASK.md, validate and settle both Todos.\n\n' + '## Next Action\n\nImplement the reducer, then the CLI.\n\n' + '## User Todo\n\n## Agent Todo\n\n' + '- [ ] [P1] Implement and validate the TASK.md reducer.\n' + ' \n' + '- [ ] [P1] Implement the CLI, verify full acceptance and close this finite Goal.\n' + ' \n', + encoding="utf-8", + ) + shutil.copyfile(FIXTURE / "TASK.md", project / "TASK.md") + launcher = project / "bin/loopx" + launcher.parent.mkdir() + launcher.write_text( + "#!/bin/sh\nexport LOOPX_PYTHON=" + shlex.quote(sys.executable) + "\nexec " + + shlex.join([str(REPO / "scripts/loopx"), "--registry", str(registry), + "--runtime-root", str(runtime)]) + ' "$@"\n', + ) + launcher.chmod(0o700) + git = ["git", "-C", str(project), "-c", "core.hooksPath=/dev/null", + "-c", "user.name=Fixture", "-c", "user.email=fixture@example.com"] + for args in (("init", "-q"), ("remote", "add", "origin", "https://example.com/ledger.git"), + ("add", "TASK.md"), ("commit", "-qm", "Initialize synthetic acceptance")): + subprocess.run([*git, *args], check=True, capture_output=True, timeout=30) + return project, runtime, launcher + + +def cli(launcher: Path, *args: str) -> dict: + result = subprocess.run( + [str(launcher), "--format", "json", *args], cwd=launcher.parent.parent, + capture_output=True, text=True, timeout=120, + ) + if result.returncode: + raise RuntimeError("fixture_cli_failed") + packet = json.loads(result.stdout) + if packet.get("ok") is not True: + raise RuntimeError("fixture_cli_not_ok") + return packet + + +def verify_settlement(runtime: Path, todos: list[dict]) -> int: + from loopx.control_plane.quota.settlement import read_heartbeat_settlement + + assert {t["todo_id"] for t in todos if t.get("status") == "done"} == TODOS + rows = [json.loads(line) for line in + (runtime / "goals" / GOAL / "runs/index.jsonl").read_text().splitlines()] + spends = [r for r in rows if r.get("classification") == "quota_slot_spent"] + assert {r.get("todo_id") for r in spends if r.get("todo_id")} == TODOS + assert all(isinstance(r.get("settlement_identity"), dict) for r in spends), "unbound_spend" + identities = [r["settlement_identity"]["effect_id"] for r in spends] + assert len(identities) == len(set(identities)), "duplicate_spend" + for row in spends: + readback = read_heartbeat_settlement( + runtime, goal_id=GOAL, agent_id=AGENT, todo_id=row.get("todo_id"), + replan_obligation_id=row.get("replan_obligation_id"), + turn_instance_id=row["turn_instance_id"], + ) + assert readback and readback.writeback and readback.spend + return len(spends) + + +def qualify(root: Path, codex: str, timeout: int) -> dict: + from loopx.capabilities.benchmark_toolkit.native_codex_goal import ( + NativeGoalConfig, StdioNativeGoalTransport, run_native_goal_until_terminal, + ) + + project, runtime, launcher = setup(root) + prompt = cli(launcher, "heartbeat-prompt", "--runtime-profile", "codex_cli", + "--goal-id", GOAL, "--agent-id", AGENT, "--cli-bin", str(launcher)) + cli(launcher, "quota", "should-run", "--runtime-profile", "codex_cli", + "--goal-id", GOAL, "--agent-id", AGENT) + config = NativeGoalConfig( + cwd=str(project), objective=prompt["task_body"], + task_instruction="Proceed with the active Goal. Read TASK.md. Use bin/loopx " + "for projected LoopX commands; preserve its isolated binding.", + sandbox_policy={"type": "workspaceWrite", "writableRoots": [str(root)], + "networkAccess": True}, # Local TS worker needs loopback. + ) + command = [codex, "--enable", "goals", "-c", 'shell_environment_policy.inherit="all"', + "-c", "project_doc_max_bytes=0", "app-server", "--stdio"] + with tempfile.TemporaryFile(mode="w+") as stderr: + with StdioNativeGoalTransport.spawn(command, cwd=str(project), env=dict(os.environ), + stderr=stderr) as transport: + turn = run_native_goal_until_terminal(transport, config, timeout_sec=timeout) + assert turn.post_goal_status == "complete", "native_goal_did_not_complete" + assert (project / "TASK.md").read_bytes() == (FIXTURE / "TASK.md").read_bytes() + oracle = subprocess.run([sys.executable, str(FIXTURE / "verify.py"), str(project)], + capture_output=True, timeout=60) + assert oracle.returncode == 0, "independent_acceptance_failed" + todos = cli(launcher, "todo", "list", "--goal-id", GOAL, "--role", "agent")["todos"] + spends = verify_settlement(runtime, todos) + quota = cli(launcher, "quota", "should-run", "--goal-id", GOAL, + "--agent-id", AGENT, "--runtime-profile", "codex_cli") + assert quota["should_run"] is False + assert quota["interaction_contract"]["mode"] == "terminal_no_followup" + return {"status": "passed", "model_executed": True, + "native_turns": turn.turn_completed_count, "settled_spends": spends, + "independent_acceptance": "passed"} + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--release-live", action="store_true", + help="Explicit release qualification; permits real model cost.") + parser.add_argument("--codex-bin", default="codex") + parser.add_argument("--timeout-seconds", type=int, default=1200) + args = parser.parse_args(argv) + if args.timeout_seconds <= 0: + parser.error("timeout must be positive") + if not args.release_live: + result = {"status": "skipped", "reason": "release_opt_in_required", "model_executed": False} + else: + try: + reason = prerequisite_failure(args.codex_bin) + if reason: + result = {"status": "skipped", "reason": reason, "model_executed": False} + else: + with tempfile.TemporaryDirectory(prefix="loopx-release-goal-") as raw: + result = qualify(Path(raw), args.codex_bin, args.timeout_seconds) + except Exception as exc: + # Never turn an attempted-but-failing qualification into an environment skip. + # Raw errors can contain host paths, prompts or account details. + result = {"status": "failed", "error_kind": type(exc).__name__} + print(json.dumps(result, sort_keys=True)) + return 1 if result["status"] == "failed" else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/fixtures/native_goal_ledger/TASK.md b/tests/fixtures/native_goal_ledger/TASK.md new file mode 100644 index 0000000000..cb2fbda446 --- /dev/null +++ b/tests/fixtures/native_goal_ledger/TASK.md @@ -0,0 +1,25 @@ +# Replay-safe local ledger + +Build `ledger.py` using only the Python standard library. Implement +`reduce_events(events)` and `python ledger.py INPUT` (UTF-8 JSONL). + +Every event has nonempty string `id`, `account`, and `type`. Credit/debit events +have a strictly positive integer `amount` (booleans are invalid). Reverse events +have `target`, referencing an earlier credit/debit in the same account. Reverse +an original at most once; never reverse a reversal. Reject overdrafts at every +prefix, including reversal of a credit. Exact event replays are idempotent; +conflicting reuse of an id is invalid. Reject missing/extra fields, invalid +types and references with `ValueError`. Never mutate input dictionaries. Keep +zero-balance accounts; names are case-sensitive and may contain Unicode. + +The CLI ignores blank lines and prints exactly one JSON object sorted by account +with a trailing newline. Empty input succeeds with `{}`. Invalid JSON or domain +input must leave stdout empty, write a concise diagnostic to stderr, and exit +nonzero. Add reducer and subprocess tests, including long replay, and a README. + +Complete both dependent LoopX Todos and settle validated work using the current +contract. This is a finite local task: no upload, publication, deployment, +package installation, or external side effect is part of acceptance. Keep all +changes in the disposable project/worktrees. Do not change this specification +or LoopX source to make the task pass. Do not substitute fabricated evidence +or a non-delivery declaration for actual code delivery. diff --git a/tests/fixtures/native_goal_ledger/verify.py b/tests/fixtures/native_goal_ledger/verify.py new file mode 100644 index 0000000000..b0ca4869d1 --- /dev/null +++ b/tests/fixtures/native_goal_ledger/verify.py @@ -0,0 +1,74 @@ +"""Independent release oracle; the model's own tests are not acceptance authority.""" + +import copy +import importlib.util +import json +from pathlib import Path +import subprocess +import sys +import tempfile + + +def verify(project: Path) -> None: + spec = importlib.util.spec_from_file_location("candidate_ledger", project / "ledger.py") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + reduce_events = module.reduce_events + credit = {"id": "c", "account": "雪", "type": "credit", "amount": 100} + debit = {"id": "d", "account": "雪", "type": "debit", "amount": 35} + reverse = {"id": "r", "account": "雪", "type": "reverse", "target": "d"} + events = [credit, debit, credit.copy(), reverse, reverse.copy()] + before = copy.deepcopy(events) + assert reduce_events(events) == {"雪": 100} + assert events == before + assert reduce_events([credit, {**reverse, "target": "c"}]) == {"雪": 0} + assert reduce_events([]) == {} + for invalid in ( + [{**credit, "amount": True}], [{**credit, "amount": 1.0}], + [{**credit, "amount": 0}], [{**credit, "extra": 1}], + [{"id": "missing"}], [debit], [reverse, credit], + [credit, {**credit, "amount": 2}], + [credit, debit, {**reverse, "target": "c"}], + [credit, debit, {**reverse, "account": "other"}], + [credit, debit, reverse, {**reverse, "id": "r2"}], + [credit, debit, reverse, {**reverse, "id": "r2", "target": "r"}], + ): + untouched = copy.deepcopy(invalid) + try: + reduce_events(invalid) + except ValueError: + pass + else: + raise AssertionError("invalid_event_accepted") + assert invalid == untouched + long = [{**credit, "id": str(i), "account": "A" if i % 2 else "a"} + for i in range(12000)] + assert reduce_events(long + long) == {"A": 600000, "a": 600000} + with tempfile.TemporaryDirectory(prefix="ledger-oracle-") as raw: + source = Path(raw) / "input.jsonl" + for content, expected in ( + ("\n" + "\n".join(json.dumps(e) for e in events), {"雪": 100}), + ("\n".join(json.dumps(e) for e in [credit, {**credit, "id": "a", "account": "A"}]), + {"A": 100, "雪": 100}), + ("\n", {}), + (json.dumps(credit) + "\nnot-json", None), + (json.dumps(debit), None), + ): + source.write_text(content, encoding="utf-8") + result = subprocess.run( + [sys.executable, str(project / "ledger.py"), str(source)], + capture_output=True, text=True, timeout=30, + ) + if expected is None: + assert result.returncode != 0 and result.stdout == "" and result.stderr + else: + assert result.returncode == 0 and result.stderr == "" + assert result.stdout.endswith("\n") and len(result.stdout.splitlines()) == 1 + assert json.loads(result.stdout) == expected + assert list(json.loads(result.stdout)) == sorted(expected) + assert (project / "README.md").is_file() + + +if __name__ == "__main__": + verify(Path(sys.argv[1]).resolve()) + print("ledger acceptance passed") diff --git a/tests/test_native_goal_release_qualification.py b/tests/test_native_goal_release_qualification.py new file mode 100644 index 0000000000..8ec588a820 --- /dev/null +++ b/tests/test_native_goal_release_qualification.py @@ -0,0 +1,89 @@ +"""Runner policy tests never start a real model, including under ordinary CI.""" + +import importlib.util +import json +from pathlib import Path + +import pytest + +REPO = Path(__file__).resolve().parents[1] +spec = importlib.util.spec_from_file_location( + "release_goal", REPO / "scripts/qualify-native-goal-release.py", +) +runner = importlib.util.module_from_spec(spec) +spec.loader.exec_module(runner) + + +def test_default_does_not_even_probe_model_environment(monkeypatch, capsys): + def forbidden(*args): + raise AssertionError("default must not touch a model host") + monkeypatch.setattr(runner, "prerequisite_failure", forbidden) + monkeypatch.setattr(runner, "qualify", forbidden) + assert runner.main([]) == 0 + result = json.loads(capsys.readouterr().out) + assert result == {"status": "skipped", "reason": "release_opt_in_required", "model_executed": False} + + +def test_missing_release_environment_is_explicit_skip(monkeypatch, capsys): + monkeypatch.setattr(runner, "prerequisite_failure", lambda _: "codex_auth_unavailable") + assert runner.main(["--release-live"]) == 0 + assert json.loads(capsys.readouterr().out)["status"] == "skipped" + + +def test_attempted_release_failure_is_not_converted_to_skip(monkeypatch, capsys): + monkeypatch.setattr(runner, "prerequisite_failure", lambda _: None) + def failing(*args): + raise RuntimeError("sensitive diagnostic sentinel") + monkeypatch.setattr(runner, "qualify", failing) + assert runner.main(["--release-live"]) == 1 + result = json.loads(capsys.readouterr().out) + assert result == {"status": "failed", "error_kind": "RuntimeError"} + + +def test_release_timeout_must_be_positive(): + with pytest.raises(SystemExit): + runner.main(["--timeout-seconds", "0"]) + + +def test_synthetic_fixture_real_cli_projects_identity_reentry(tmp_path): + project, _, launcher = runner.setup(tmp_path) + assert (project / "TASK.md").read_bytes() == (runner.FIXTURE / "TASK.md").read_bytes() + quota = runner.cli(launcher, "quota", "should-run", "--runtime-profile", "codex_cli", + "--goal-id", runner.GOAL, "--agent-id", runner.AGENT) + assert quota["should_run"] is True + assert quota["selected_todo"]["todo_id"] == "todo_reducer" + actions = quota["interaction_contract"]["cli_channel"]["next_cli_actions"] + assert len(actions) == 1 and "--turn-instance-id" in actions[0] + assert "spend-slot" not in actions[0] + + # Qualify the fixture's terminal shape without paying for a model run. A + # missing user section is not proof of zero user obligations. + state = project / "ACTIVE_GOAL_STATE.md" + state.write_text(state.read_text().replace("- [ ]", "- [x]").replace( + "status=open", "status=done no_followup=true", + )) + terminal = runner.cli(launcher, "quota", "should-run", "--runtime-profile", "codex_cli", + "--goal-id", runner.GOAL, "--agent-id", runner.AGENT) + assert terminal["should_run"] is False + assert terminal["interaction_contract"]["mode"] == "terminal_no_followup" + + state.write_text(state.read_text().replace("## User Todo\n\n", "")) + incomplete = runner.cli(launcher, "quota", "should-run", "--runtime-profile", "codex_cli", + "--goal-id", runner.GOAL, "--agent-id", runner.AGENT) + assert incomplete["interaction_contract"]["mode"] != "terminal_no_followup" + + +@pytest.mark.parametrize("mutation", ["unbound", "duplicate"]) +def test_settlement_oracle_rejects_missing_identity_and_duplicate_spend(tmp_path, mutation): + rows = [{"classification": "quota_slot_spent", "todo_id": todo, + "settlement_identity": {"effect_id": todo}} for todo in sorted(runner.TODOS)] + if mutation == "unbound": + rows.append({"classification": "quota_slot_spent"}) + else: + rows.append(rows[0].copy()) + index = tmp_path / "goals" / runner.GOAL / "runs/index.jsonl" + index.parent.mkdir(parents=True) + index.write_text("\n".join(json.dumps(row) for row in rows)) + todos = [{"todo_id": todo, "status": "done"} for todo in runner.TODOS] + with pytest.raises(AssertionError): + runner.verify_settlement(tmp_path, todos) From 4be0d719b9295d7110bc15479b3efa96f9e7eb02 Mon Sep 17 00:00:00 2001 From: huangruiteng Date: Thu, 10 Sep 2026 23:30:54 +0800 Subject: [PATCH 08/26] test(hosts): add Claude release and response-loss coverage Signed-off-by: huangruiteng --- docs/development/testing-and-quality.md | 36 +++++ docs/heartbeat-automation-prompt.md | 8 + .../claude_goal_mode/scripts/goalmode_cmd.py | 29 ++-- scripts/qualify-claude-goal-release.py | 145 ++++++++++++++++++ scripts/qualify-native-goal-release.py | 28 ++-- .../test_claude_goal_release_qualification.py | 115 ++++++++++++++ tests/test_goal_mode_mcp_settlement.py | 42 +++++ .../test_native_goal_release_qualification.py | 19 +++ 8 files changed, 401 insertions(+), 21 deletions(-) create mode 100644 scripts/qualify-claude-goal-release.py create mode 100644 tests/test_claude_goal_release_qualification.py diff --git a/docs/development/testing-and-quality.md b/docs/development/testing-and-quality.md index 6e540fd40d..c543d5a24e 100644 --- a/docs/development/testing-and-quality.md +++ b/docs/development/testing-and-quality.md @@ -768,6 +768,42 @@ deadline is 1,200 seconds; this is a wall-clock ceiling, not a token budget. 这不是网络隔离,任务不授权外部操作。回归脚本不采集或上传原始对话/工具日志, 公开结果仅包含状态、计数和错误类别;Codex 自身仍按当前 host 配置保存会话。 +### Claude Code and release coverage / Claude Code 与发布覆盖 + +```bash +# No provider call by default. Explicit release opt-in uses ARK_API_KEY from the environment. +python3 scripts/qualify-claude-goal-release.py --release-live +``` + +This arm uses the same ledger specification, independent oracle and durable +settlement readback as the Codex arm. It launches actual Claude Code with the +project's shipped `loop.md` and LoopX stdio MCP server, using +`doubao-seed-evolving` through Ark's Anthropic-compatible API. It does not +inherit another Anthropic account, install into the user's Claude configuration, +or retain host sessions. The subprocess timeout also cleans its process group +on POSIX. Allowed local development tools are not a security sandbox; the +synthetic task authorizes no external side effects. + +**A headless work-loop pass is not a `/loop` timer pass.** The release report +explicitly returns `scheduler_qualification=not_run_headless`; interactive +native wakeup, cancel/resume and process-restart behavior need their own host +qualification. Do not turn repeated `claude -p` invocations into a substitute +scheduler and claim host lifecycle coverage. + +Before calling a changed host surface release-qualified, distinguish: + +| Boundary | Required evidence | +| --- | --- | +| Work and terminal closeout | Final candidate, actual host, independent artifact checks, completed Todos and terminal quota; code delivery alone is insufficient. | +| Idempotency and failure | Real committed lifecycle/writeback/spend followed by lost-response injection and same-intent retries; one final spend. Failed declared validation must not complete or spend. | +| Authority and transport | Actual MCP initialization/tool invocation and mismatched-agent rejection; existing claim/lease and validation suites remain required. | +| Host lifecycle | Native scheduler wakeup/cancellation/resume on supported versions, reported separately from headless execution. | +| Upgrade and isolation | Exact managed-wrapper recognition, preview/apply revision checks, preserved scheduler state, explicit skips, no default model calls or leaked test processes. | + +普通 CI 只跑确定性规则、真实 CLI/MCP 和故障注入;模型执行仍仅 release 前显式启用。 +环境缺失可 skip 且退出成功,但最终版本没有完整的真实 host 结果时,不得写成 +“产品级发布验证通过”。单次成功也不是模型可靠性或长程调度 soak 的证明。 + ## Exact Release Commit Gate / 精确发布 Commit 门 The final release gate does not rerun tests through a second orchestration diff --git a/docs/heartbeat-automation-prompt.md b/docs/heartbeat-automation-prompt.md index 7307825309..3ad905ea34 100644 --- a/docs/heartbeat-automation-prompt.md +++ b/docs/heartbeat-automation-prompt.md @@ -100,6 +100,14 @@ not active host Goal objectives or benchmark prompts already pinned to a run. An installed runtime supplies updated dynamic contracts on later reads; upgrading it does not retroactively remove old text from an existing Goal. +Claude Code's MCP-backed `loop.md` follows the same work-sizing rule and current +quota contract, without a fixed one-segment limit or empty-Todo-list completion +shortcut. Its `complete_task` tool already owns the ordered writeback/spend +transaction: callers must not perform a second accounting sequence through CLI. +Partial work is not Todo completion. Native `/loop` remains Claude's scheduler; +only the current Goal's wakeup may be cancelled after terminal no-follow-up. +These changes apply when `loop.md` is regenerated, not by editing active user files. + For Codex App, the generated quota command carries the compact explicit runtime profile `--runtime-profile codex_app_heartbeat` (generated commands use the equivalent compact alias `--codex-app`). The prompt does not restate the diff --git a/loopx/claude_goal_mode/scripts/goalmode_cmd.py b/loopx/claude_goal_mode/scripts/goalmode_cmd.py index 2009089a66..829469d9ae 100644 --- a/loopx/claude_goal_mode/scripts/goalmode_cmd.py +++ b/loopx/claude_goal_mode/scripts/goalmode_cmd.py @@ -36,6 +36,9 @@ # registry-driven context, shared with the hooks/MCP sys.path.insert(0, str(HERE.parent / "hooks")) from goal_state import goal_context, find_registry, loop_md_path # noqa: E402 +from loopx.control_plane.heartbeat.rules import ( # noqa: E402 + SCOPE_BOUNDED_WORK_RULE, +) def gh_prefix(): @@ -66,14 +69,22 @@ def loop_md_content(goal_id, agent_id) -> str: f"\n" f"loopx tick — advance goal `{goal_id}` (agent `{agent_id}`). Use the wired loopx MCP\n" f"tools; do NOT run `loopx --help` or guess ids.\n\n" - f"1. Call `should_run()`. If should_run=false, say why in ONE line and STOP this\n" - f" iteration (do nothing else) — loopx has paused, gated, or converged.\n" - f"2. If should_run=true: `claim_task` the next open todo, do ONE bounded segment,\n" - f" then VERIFY it with a real check (build/test) — never claim success from\n" - f" reasoning — and `complete_task(..., agent_id=\"{agent_id}\", evidence=\"\")`.\n" - f"3. Stay within the goal's scope; do not start initiatives outside the todos.\n" - f" Irreversible actions (push/delete) only to finish work already authorized.\n" - f"4. Re-check `should_run()`; stop when should_run=false or no open todos remain.\n" + "Read complete successful `should_run()` JSON each work iteration. Follow its\n" + "current `interaction_contract`: selection/re-entry before admitted work,\n" + "then validation and settlement. Never infer completion from an empty Todo list.\n" + f"{SCOPE_BOUNDED_WORK_RULE}\n" + "Honor claim/lease and user/repository authority; claim only when required.\n" + "Run real acceptance checks before `complete_task`; supply truthful evidence\n" + f"and the bound agent_id=\"{agent_id}\". Complete only finished Todos, not partial work.\n" + "That MCP operation owns writeback/spend; do not repeat its accounting via CLI.\n" + "After a lost response, read back or retry the same completion intent; do not\n" + "invent a new successor or settlement identity. Recheck `should_run()` afterward.\n" + "Continue authorized work while the live contract requires it; notification\n" + "silence is not execution silence. Waiting is not completion: follow current\n" + "host scheduling guidance without repeated unchanged polling. Terminal\n" + "no-follow-up ends this Goal's work; cancel only its own recurring wakeup.\n" + "Repair entrypoint errors within authority; an unavailable/incomplete contract\n" + "permits neither work nor spending and must not be reported as completion.\n" ) @@ -230,7 +241,7 @@ def main(): print(f" todo_id : {tid}") print(f" scope : {proj}") print(f" task : {task}") - print(f" wrote : .claude/loop.md (the per-tick protocol)") + print(" wrote : .claude/loop.md (the per-tick protocol)") print() print("START WORKING — run native `/loop` (Claude self-paces) or `/loop 10m` (fixed cadence).") print("Each /loop tick runs: should_run -> claim_task -> ONE bounded verified segment -> complete_task.") diff --git a/scripts/qualify-claude-goal-release.py b/scripts/qualify-claude-goal-release.py new file mode 100644 index 0000000000..349cf7f985 --- /dev/null +++ b/scripts/qualify-claude-goal-release.py @@ -0,0 +1,145 @@ +#!/usr/bin/env python3 +"""Release-only Claude Code + Doubao work-loop qualification, never a default live test.""" + +from __future__ import annotations + +import argparse +import importlib.util +import json +import os +from pathlib import Path +import shutil +import signal +import subprocess +import sys +import tempfile + +REPO = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(REPO)) +spec = importlib.util.spec_from_file_location("goal_release", REPO / "scripts/qualify-native-goal-release.py") +shared = importlib.util.module_from_spec(spec) +spec.loader.exec_module(shared) + +from loopx.control_plane.testing.doubao_model_behavior_actor import ( # noqa: E402 + DOUBAO_SEED_EVOLVING_MODEL, +) + +ARK_ANTHROPIC_BASE = "https://ark.cn-beijing.volces.com/api/compatible" + + +def run_host(command: list[str], *, cwd: Path, env: dict, timeout: float) -> str: + """Bound output memory and clean this test's process group, including on timeout.""" + with tempfile.TemporaryFile() as stdout, tempfile.TemporaryFile() as stderr: + process = subprocess.Popen(command, cwd=cwd, env=env, stdout=stdout, stderr=stderr, + start_new_session=True) + try: + code = process.wait(timeout=timeout) + finally: + if os.name == "posix": + try: + os.killpg(process.pid, signal.SIGKILL) + except ProcessLookupError: + pass + elif process.poll() is None: + process.kill() + process.wait(timeout=10) + assert code == 0, "claude_host_failed" + stdout.seek(0) + output = stdout.read(32 * 1024 * 1024 + 1) + assert len(output) <= 32 * 1024 * 1024, "claude_output_exceeded_limit" + return output.decode("utf-8") + + +def prerequisite_failure(claude: str) -> str | None: + if not all(shutil.which(command) for command in (claude, "node", "git")): + return "required_executable_unavailable" + if not os.environ.get("ARK_API_KEY"): + return "ark_api_key_unavailable" + try: + import mcp.server.fastmcp # noqa: F401 + except ImportError: + return "mcp_sdk_v1_unavailable" + return None + + +def host_environment(root: Path, launcher: Path) -> dict[str, str]: + # No user settings, OAuth/keychain import or persistent host installation. + env = {k: v for k, v in os.environ.items() if not k.startswith(("ANTHROPIC_", "CLAUDE_"))} + env.update( + ANTHROPIC_API_KEY=os.environ["ARK_API_KEY"], + ANTHROPIC_BASE_URL=ARK_ANTHROPIC_BASE, + ANTHROPIC_MODEL=DOUBAO_SEED_EVOLVING_MODEL, + ANTHROPIC_DEFAULT_HAIKU_MODEL=DOUBAO_SEED_EVOLVING_MODEL, + ANTHROPIC_DEFAULT_SONNET_MODEL=DOUBAO_SEED_EVOLVING_MODEL, + ANTHROPIC_DEFAULT_OPUS_MODEL=DOUBAO_SEED_EVOLVING_MODEL, + CLAUDE_CONFIG_DIR=str(root / "claude-config"), + CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC="1", + PATH=str(launcher.parent) + os.pathsep + os.environ.get("PATH", ""), + PYTHONPATH=str(REPO), + ) + return env + + +def qualify(root: Path, claude: str, timeout: int) -> dict: + from loopx.claude_goal_mode.scripts.goalmode_cmd import write_loop_md + + project, runtime, launcher = shared.setup(root) + write_loop_md(project, shared.GOAL, shared.AGENT) + config = root / "mcp.json" + config.write_text(json.dumps({"mcpServers": {"loopx": { + "command": sys.executable, + "args": [str(REPO / "loopx/claude_goal_mode/mcp/loopx_mcp.py")], + }}})) + command = [ + claude, "--bare", "--setting-sources", "", "--no-session-persistence", + "--strict-mcp-config", "--mcp-config", str(config), + "--model", DOUBAO_SEED_EVOLVING_MODEL, + "--permission-mode", "dontAsk", "--allowedTools", + "Read", "Edit", "Write", "Bash", "mcp__loopx__*", + "--output-format", "stream-json", "--verbose", "-p", + "Read .claude/loop.md and follow this project's active LoopX work contract. " + "Read TASK.md for acceptance. Use the bound LoopX MCP tools; preserve the " + "isolated project binding. Do not create timers in this headless qualification.", + ] + # This executes the actual per-iteration adapter, not Claude's interactive + # /loop timer. Never report headless delivery as scheduler qualification. + output = run_host(command, cwd=project, env=host_environment(root, launcher), timeout=timeout) + events = [json.loads(line) for line in output.splitlines() if line.strip()] + results = [e for e in events if e.get("type") == "result"] + assert len(results) == 1 and results[0].get("is_error") is False, "claude_turn_failed" + calls = [block.get("name") for event in events if event.get("type") == "assistant" + for block in (event.get("message") or {}).get("content", []) + if block.get("type") == "tool_use"] + assert "mcp__loopx__should_run" in calls and "mcp__loopx__complete_task" in calls, "mcp_not_exercised" + return {"status": "passed", "model_executed": True, "model": DOUBAO_SEED_EVOLVING_MODEL, + "host": "claude_code", "scheduler_qualification": "not_run_headless", + "mcp_tool_calls": sum(str(c).startswith("mcp__loopx__") for c in calls), + **shared.verify_delivery(project, runtime, launcher, "claude_code")} + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--release-live", action="store_true") + parser.add_argument("--claude-bin", default="claude") + parser.add_argument("--timeout-seconds", type=int, default=1200) + args = parser.parse_args(argv) + if args.timeout_seconds <= 0: + parser.error("timeout must be positive") + if not args.release_live: + result = {"status": "skipped", "reason": "release_opt_in_required", "model_executed": False} + else: + try: + reason = prerequisite_failure(args.claude_bin) + if reason: + result = {"status": "skipped", "reason": reason, "model_executed": False} + else: + with tempfile.TemporaryDirectory(prefix="loopx-claude-release-") as raw: + result = qualify(Path(raw), args.claude_bin, args.timeout_seconds) + except Exception as exc: + result = {"status": "failed", "error_kind": type(exc).__name__} + print(json.dumps(result, sort_keys=True)) + return 1 if result["status"] == "failed" else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/qualify-native-goal-release.py b/scripts/qualify-native-goal-release.py index 9c38389f66..081c631044 100644 --- a/scripts/qualify-native-goal-release.py +++ b/scripts/qualify-native-goal-release.py @@ -122,6 +122,20 @@ def verify_settlement(runtime: Path, todos: list[dict]) -> int: return len(spends) +def verify_delivery(project: Path, runtime: Path, launcher: Path, profile: str) -> dict: + assert (project / "TASK.md").read_bytes() == (FIXTURE / "TASK.md").read_bytes() + oracle = subprocess.run([sys.executable, str(FIXTURE / "verify.py"), str(project)], + capture_output=True, timeout=60) + assert oracle.returncode == 0, "independent_acceptance_failed" + todos = cli(launcher, "todo", "list", "--goal-id", GOAL, "--role", "agent")["todos"] + spends = verify_settlement(runtime, todos) + quota = cli(launcher, "quota", "should-run", "--goal-id", GOAL, + "--agent-id", AGENT, "--runtime-profile", profile) + assert quota["should_run"] is False + assert quota["interaction_contract"]["mode"] == "terminal_no_followup" + return {"settled_spends": spends, "independent_acceptance": "passed"} + + def qualify(root: Path, codex: str, timeout: int) -> dict: from loopx.capabilities.benchmark_toolkit.native_codex_goal import ( NativeGoalConfig, StdioNativeGoalTransport, run_native_goal_until_terminal, @@ -146,19 +160,9 @@ def qualify(root: Path, codex: str, timeout: int) -> dict: stderr=stderr) as transport: turn = run_native_goal_until_terminal(transport, config, timeout_sec=timeout) assert turn.post_goal_status == "complete", "native_goal_did_not_complete" - assert (project / "TASK.md").read_bytes() == (FIXTURE / "TASK.md").read_bytes() - oracle = subprocess.run([sys.executable, str(FIXTURE / "verify.py"), str(project)], - capture_output=True, timeout=60) - assert oracle.returncode == 0, "independent_acceptance_failed" - todos = cli(launcher, "todo", "list", "--goal-id", GOAL, "--role", "agent")["todos"] - spends = verify_settlement(runtime, todos) - quota = cli(launcher, "quota", "should-run", "--goal-id", GOAL, - "--agent-id", AGENT, "--runtime-profile", "codex_cli") - assert quota["should_run"] is False - assert quota["interaction_contract"]["mode"] == "terminal_no_followup" return {"status": "passed", "model_executed": True, - "native_turns": turn.turn_completed_count, "settled_spends": spends, - "independent_acceptance": "passed"} + "native_turns": turn.turn_completed_count, + **verify_delivery(project, runtime, launcher, "codex_cli")} def main(argv: list[str] | None = None) -> int: diff --git a/tests/test_claude_goal_release_qualification.py b/tests/test_claude_goal_release_qualification.py new file mode 100644 index 0000000000..044a5e6b16 --- /dev/null +++ b/tests/test_claude_goal_release_qualification.py @@ -0,0 +1,115 @@ +"""Ordinary CI covers policy/transport; no test here invokes a model.""" + +import asyncio +import importlib.util +import json +import os +import subprocess +from pathlib import Path +import sys + +import pytest + +REPO = Path(__file__).resolve().parents[1] +spec = importlib.util.spec_from_file_location("claude_release", REPO / "scripts/qualify-claude-goal-release.py") +runner = importlib.util.module_from_spec(spec) +spec.loader.exec_module(runner) + + +def test_default_never_probes_or_calls_model(monkeypatch, capsys): + def forbidden(*_): + raise AssertionError("must not execute") + monkeypatch.setattr(runner, "qualify", forbidden) + monkeypatch.setattr(runner, "prerequisite_failure", forbidden) + assert runner.main([]) == 0 + assert json.loads(capsys.readouterr().out)["model_executed"] is False + + +def test_missing_environment_skips_but_attempted_failure_fails(monkeypatch, capsys): + monkeypatch.setattr(runner, "prerequisite_failure", lambda _: "ark_api_key_unavailable") + assert runner.main(["--release-live"]) == 0 + assert json.loads(capsys.readouterr().out)["status"] == "skipped" + monkeypatch.setattr(runner, "prerequisite_failure", lambda _: None) + def failure(*_): + raise RuntimeError("private sentinel must not reach public result") + monkeypatch.setattr(runner, "qualify", failure) + assert runner.main(["--release-live"]) == 1 + assert json.loads(capsys.readouterr().out) == {"status": "failed", "error_kind": "RuntimeError"} + + +def test_provider_binding_does_not_inherit_another_anthropic_account(monkeypatch, tmp_path): + monkeypatch.setenv("ARK_API_KEY", "synthetic-ark-key") + monkeypatch.setenv("ANTHROPIC_AUTH_TOKEN", "synthetic-other-provider-key") + monkeypatch.setenv("ANTHROPIC_BASE_URL", "https://example.com") + monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN", "synthetic-oauth") + env = runner.host_environment(tmp_path, tmp_path / "bin/loopx") + assert env["ANTHROPIC_API_KEY"] == "synthetic-ark-key" + assert env["ANTHROPIC_BASE_URL"] == runner.ARK_ANTHROPIC_BASE + assert env["ANTHROPIC_MODEL"] == "doubao-seed-evolving" + assert "ANTHROPIC_AUTH_TOKEN" not in env and "CLAUDE_CODE_OAUTH_TOKEN" not in env + assert os.environ["ANTHROPIC_AUTH_TOKEN"] == "synthetic-other-provider-key" + + +def test_claude_loop_uses_current_contract_not_segment_or_empty_list_stop(): + from loopx.claude_goal_mode.scripts.goalmode_cmd import loop_md_content + from loopx.control_plane.heartbeat.rules import SCOPE_BOUNDED_WORK_RULE + + prompt = loop_md_content("goal-a", "agent-a") + assert SCOPE_BOUNDED_WORK_RULE in prompt + assert "interaction_contract" in prompt and "notification" in prompt + assert "ONE bounded segment" not in prompt and "no open todos remain" not in prompt + assert "Complete only finished Todos, not partial work" in prompt + assert 'agent_id="agent-a"' in prompt + assert "That MCP operation owns writeback/spend" in prompt + assert "unavailable/incomplete contract" in prompt + + +def test_host_timeout_fails_and_reaps_the_spawned_process(tmp_path, monkeypatch): + original = subprocess.Popen + children = [] + def capture(*args, **kwargs): + child = original(*args, **kwargs) + children.append(child) + return child + monkeypatch.setattr(runner.subprocess, "Popen", capture) + with pytest.raises(subprocess.TimeoutExpired): + runner.run_host([sys.executable, "-c", "import time; time.sleep(30)"], + cwd=tmp_path, env=dict(os.environ), timeout=0.1) + assert len(children) == 1 and children[0].poll() is not None + + +def test_host_nonzero_is_not_reported_as_a_successful_model_turn(tmp_path): + with pytest.raises(AssertionError, match="claude_host_failed"): + runner.run_host([sys.executable, "-c", "raise SystemExit(2)"], + cwd=tmp_path, env=dict(os.environ), timeout=10) + + +def test_real_claude_stdio_mcp_binding_and_identity_gate(tmp_path): + pytest.importorskip("mcp.server.fastmcp") + from mcp import ClientSession, StdioServerParameters + from mcp.client.stdio import stdio_client + from loopx.claude_goal_mode.scripts.goalmode_cmd import write_loop_md + + project, _, launcher = runner.shared.setup(tmp_path) + write_loop_md(project, runner.shared.GOAL, runner.shared.AGENT) + state = project / "ACTIVE_GOAL_STATE.md" + before = state.read_bytes() + params = StdioServerParameters( + command=sys.executable, + args=[str(REPO / "loopx/claude_goal_mode/mcp/loopx_mcp.py")], + cwd=str(project), env={**os.environ, "PYTHONPATH": str(REPO), + "PATH": str(launcher.parent) + os.pathsep + os.environ["PATH"]}, + ) + async def exercise(): + async with stdio_client(params) as (read, write): + async with ClientSession(read, write) as session: + await session.initialize() + tools = await session.list_tools() + assert {"should_run", "claim_task", "complete_task"} <= {t.name for t in tools.tools} + guard = await session.call_tool("should_run", {}) + payload = json.loads(guard.content[0].text) + assert payload["ok"] is True and payload["selected_todo"]["todo_id"] == "todo_reducer" + rejected = await session.call_tool("claim_task", {"todo_id": "todo_reducer", "agent_id": "other-agent"}) + assert json.loads(rejected.content[0].text)["ok"] is False + asyncio.run(exercise()) + assert state.read_bytes() == before diff --git a/tests/test_goal_mode_mcp_settlement.py b/tests/test_goal_mode_mcp_settlement.py index 6a17df4da1..4d49b58504 100644 --- a/tests/test_goal_mode_mcp_settlement.py +++ b/tests/test_goal_mode_mcp_settlement.py @@ -461,3 +461,45 @@ def test_real_mcp_terminal_completion_closes_out_after_spend( ) assert completed["status"] == "done" assert "no_followup=true" in state_file.read_text(encoding="utf-8") + + +@pytest.mark.parametrize("lost_after", ["lifecycle", "writeback", "spend"]) +def test_real_mcp_completion_recovers_a_lost_mutation_response(tmp_path, lost_after): + """The real write commits, but its caller sees a failure: retry must not pay twice.""" + registry, _ = _write_fixture(tmp_path) + added = add_goal_todo( + registry_path=registry, goal_id=GOAL_ID, role="agent", + text="Validate response-loss recovery.", task_class="advancement_task", + continuation_policy="same_agent_non_delivery", claimed_by=AGENT_ID, + ) + control = _control(registry) + original = control.run_cli + injected = False + + def lose_once(args, **kwargs): + nonlocal injected + output = original(args, **kwargs) + step = {"lifecycle": ["todo", "complete"], "writeback": ["refresh-state"], + "spend": ["quota", "spend-slot"]}[lost_after] + if not injected and args[:len(step)] == step and json.loads(output).get("ok"): + injected = True + return json.dumps({"ok": False, "error": "synthetic_response_lost_after_commit"}) + return output + + control.run_cli = lose_once + first = json.loads(control.complete_task( + added["todo_id"], AGENT_ID, "recovery fixture check passed", no_follow_up=True, + )) + assert injected and first["ok"] is False + replay = json.loads(control.complete_task( + added["todo_id"], AGENT_ID, "recovery fixture check passed", no_follow_up=True, + )) + assert replay["ok"] is True + again = json.loads(control.complete_task( + added["todo_id"], AGENT_ID, "recovery fixture check passed", no_follow_up=True, + )) + assert again["ok"] is True + assert again["settlement_identity"] == replay["settlement_identity"] + assert again["settlement"]["quota_spend"]["appended"] is False + status = json.loads(control.should_run()) + assert status["quota"]["spent_slots"] == 1 diff --git a/tests/test_native_goal_release_qualification.py b/tests/test_native_goal_release_qualification.py index 8ec588a820..359bbd2700 100644 --- a/tests/test_native_goal_release_qualification.py +++ b/tests/test_native_goal_release_qualification.py @@ -3,6 +3,7 @@ import importlib.util import json from pathlib import Path +from types import SimpleNamespace import pytest @@ -87,3 +88,21 @@ def test_settlement_oracle_rejects_missing_identity_and_duplicate_spend(tmp_path todos = [{"todo_id": todo, "status": "done"} for todo in runner.TODOS] with pytest.raises(AssertionError): runner.verify_settlement(tmp_path, todos) + + +@pytest.mark.parametrize("missing", ["writeback", "spend", "receipt"]) +def test_settlement_oracle_requires_durable_receipts_not_only_index_rows(tmp_path, monkeypatch, missing): + from loopx.control_plane.quota import settlement + + receipt = None if missing == "receipt" else SimpleNamespace( + writeback=None if missing == "writeback" else {"ok": True}, + spend=None if missing == "spend" else {"ok": True}, + ) + monkeypatch.setattr(settlement, "read_heartbeat_settlement", lambda *_, **__: receipt) + rows = [{"classification": "quota_slot_spent", "todo_id": todo, "turn_instance_id": todo, + "settlement_identity": {"effect_id": todo}} for todo in sorted(runner.TODOS)] + index = tmp_path / "goals" / runner.GOAL / "runs/index.jsonl" + index.parent.mkdir(parents=True) + index.write_text("\n".join(json.dumps(row) for row in rows)) + with pytest.raises(AssertionError): + runner.verify_settlement(tmp_path, [{"todo_id": t, "status": "done"} for t in runner.TODOS]) From d40bb275b3eb64fc779e9c9845824f2c42d4f902 Mon Sep 17 00:00:00 2001 From: huangruiteng Date: Thu, 10 Sep 2026 23:58:36 +0800 Subject: [PATCH 09/26] fix(hosts): separate Todo acceptance from Turn settlement Signed-off-by: huangruiteng --- docs/development/testing-and-quality.md | 8 +++ docs/heartbeat-automation-prompt.md | 6 +++ docs/project-agent-todo-contract.md | 24 +++++---- .../claude_goal_mode/scripts/goalmode_cmd.py | 4 +- loopx/cli_commands/todo.py | 48 ++--------------- loopx/goal_mode_mcp.py | 17 ++++++- loopx/kunluncode_goal_mode/guards.py | 2 + scripts/qualify-claude-goal-release.py | 29 +++++++++++ scripts/qualify-native-goal-release.py | 14 +++-- tests/control_plane/test_quota_settlement.py | 27 ++-------- tests/fixtures/native_goal_ledger/TASK.md | 5 +- .../test_claude_goal_release_qualification.py | 47 +++++++++++++++++ ...est_goal_mode_mcp_completion_validation.py | 28 +++++----- tests/test_goal_mode_mcp_settlement.py | 51 ++++++++++++++++++- .../test_native_goal_release_qualification.py | 15 +++++- 15 files changed, 225 insertions(+), 100 deletions(-) diff --git a/docs/development/testing-and-quality.md b/docs/development/testing-and-quality.md index c543d5a24e..bee8a8f795 100644 --- a/docs/development/testing-and-quality.md +++ b/docs/development/testing-and-quality.md @@ -804,6 +804,14 @@ Before calling a changed host surface release-qualified, distinguish: 环境缺失可 skip 且退出成功,但最终版本没有完整的真实 host 结果时,不得写成 “产品级发布验证通过”。单次成功也不是模型可靠性或长程调度 soak 的证明。 +The real delivery regression covers ordinary Todo acceptance before internal +writeback/spend, existing-successor linking, and receipt-backed terminal closure. +Its task specification describes only the deliverable; the external oracle also +checks LoopX accounting. Passing non-delivery fixtures does not qualify delivery. +The same delivery class must pass failed-validation rejection and committed +response-loss recovery without duplicate spending or premature terminal closure. +Do not relabel delivery work or weaken the independent oracle to pass a host test. + ## Exact Release Commit Gate / 精确发布 Commit 门 The final release gate does not rerun tests through a second orchestration diff --git a/docs/heartbeat-automation-prompt.md b/docs/heartbeat-automation-prompt.md index 3ad905ea34..d0bb28ff1a 100644 --- a/docs/heartbeat-automation-prompt.md +++ b/docs/heartbeat-automation-prompt.md @@ -107,6 +107,12 @@ transaction: callers must not perform a second accounting sequence through CLI. Partial work is not Todo completion. Native `/loop` remains Claude's scheduler; only the current Goal's wakeup may be cancelled after terminal no-follow-up. These changes apply when `loop.md` is regenerated, not by editing active user files. +The MCP tool now exposes `successor_todo_ids`, reusing CLI/TS completion semantics +to link known follow-up without creating another Todo. Ordinary acceptance and +Turn settlement are distinct: the adapter validates/completes work before its +writeback/spend; only terminal closeout requires the full receipt chain. This +removes the former delivery-class circular prerequisite, not validation or +accounting. See the [release test guide](development/testing-and-quality.md#claude-code-and-release-coverage--claude-code-与发布覆盖). For Codex App, the generated quota command carries the compact explicit runtime profile `--runtime-profile codex_app_heartbeat` (generated commands use the diff --git a/docs/project-agent-todo-contract.md b/docs/project-agent-todo-contract.md index 7ba679a51b..72b389f4ba 100644 --- a/docs/project-agent-todo-contract.md +++ b/docs/project-agent-todo-contract.md @@ -444,11 +444,12 @@ the agent should do one of two things: This succession decision is durable Todo state. A later progress observation, vision ACK, coverage-exhausted result, or rewritten rationale cannot substitute for it. Every new completion therefore retains an opaque completion identity. -A quota-bound completion still permits only the receipt-backed same-turn -`todo complete` transition for agent advancement work. Complete the matching -accountable `refresh-state` and `quota spend-slot` first; explicit -`same_agent_non_delivery` work, monitors, user actions, and user gates keep -their existing lifecycle paths. An ordinary unscoped completion gets a +A quota-bound ordinary completion proves the exact admitted identity and Todo +acceptance (including declared validation), not completion of Turn accounting. +It may precede the same-turn writeback and spend. The existing typed replay +phase remains `settlement_pending` until those receipts exist; Todo `done` alone +does not mean the Turn settled. Terminal `--no-follow-up` still requires the +complete matching writeback/spend chain. An ordinary unscoped completion gets a stable `local_completion_*` identity; if a later `refresh-state` discovers that the finished Goal has no real successor, its typed rejection may project `--completion-identity-key` for one direct lifecycle reentry. That command is @@ -458,11 +459,14 @@ It cannot be supplied for an open Todo or used as a quota turn identity. Otherwise add/link a real successor. Do not create a user gate merely to silence a succession warning. -Compatibility host adapters whose established transaction completes the Todo -before writing the same-turn refresh and quota receipts must explicitly mark -their non-repository work `same_agent_non_delivery`. Repository advancement -through those adapters fails closed with a typed settlement blocker until the -adapter adopts a writeback-and-spend-before-completion transaction. +Host adapters own the internal sequence: validate/complete, write back, spend, +then terminal closeout if requested. A failed internal step is not a request to +redo accepted task work: retry the same identity and recover the missing receipt. +MCP returns success only after the whole requested sequence succeeds. Task +acceptance must not prescribe LoopX bookkeeping, and delivery work must not be +relabeled `same_agent_non_delivery` to escape a contradictory internal ordering. +This intentionally removes the old task-class-dependent CLI prerequisite while +retaining declared validation, claim/lease checks, identity and terminal fences. This keeps the active checklist honest without making LoopX a heavyweight project-management state machine. diff --git a/loopx/claude_goal_mode/scripts/goalmode_cmd.py b/loopx/claude_goal_mode/scripts/goalmode_cmd.py index 829469d9ae..02e7887208 100644 --- a/loopx/claude_goal_mode/scripts/goalmode_cmd.py +++ b/loopx/claude_goal_mode/scripts/goalmode_cmd.py @@ -77,6 +77,8 @@ def loop_md_content(goal_id, agent_id) -> str: "Run real acceptance checks before `complete_task`; supply truthful evidence\n" f"and the bound agent_id=\"{agent_id}\". Complete only finished Todos, not partial work.\n" "That MCP operation owns writeback/spend; do not repeat its accounting via CLI.\n" + "Link already planned follow-up via successor_todo_ids; next_agent_todo creates\n" + "new work, not a reference to an existing id. Do not duplicate the current plan.\n" "After a lost response, read back or retry the same completion intent; do not\n" "invent a new successor or settlement identity. Recheck `should_run()` afterward.\n" "Continue authorized work while the live contract requires it; notification\n" @@ -244,7 +246,7 @@ def main(): print(" wrote : .claude/loop.md (the per-tick protocol)") print() print("START WORKING — run native `/loop` (Claude self-paces) or `/loop 10m` (fixed cadence).") - print("Each /loop tick runs: should_run -> claim_task -> ONE bounded verified segment -> complete_task.") + print("Each /loop tick follows should_run's current contract; complete_task settles only verified, finished work.") print("Stop with Esc or `/loopx off`.") diff --git a/loopx/cli_commands/todo.py b/loopx/cli_commands/todo.py index 70c894c2ae..4a3135ae92 100644 --- a/loopx/cli_commands/todo.py +++ b/loopx/cli_commands/todo.py @@ -6,9 +6,6 @@ from ..control_plane.coordination.local_authority import read_canonical_todo_fields_if_promoted from ..control_plane.todos.contract import ( - TODO_TASK_CLASS_ADVANCEMENT, - normalize_todo_continuation_policy, - normalize_todo_task_class, replan_successor_semantic_binding, ) from ..control_plane.capability_hooks import PostWritebackHookRegistration @@ -78,44 +75,18 @@ ] -def _completion_settlement_requirement( - todo: dict[str, object], - *, - no_follow_up: bool, -) -> str | None: - if no_follow_up: - return "terminal no-follow-up closeout" - task_class = normalize_todo_task_class( - todo.get("task_class"), - text=str(todo.get("text") or ""), - action_kind=todo.get("action_kind"), - ) - continuation_policy = normalize_todo_continuation_policy( - todo.get("continuation_policy") - ) - if ( - str(todo.get("role") or "") == "agent" - and task_class == TODO_TASK_CLASS_ADVANCEMENT - and continuation_policy != "same_agent_non_delivery" - ): - return "turn-scoped advancement completion" - return None - - def _completion_settlement_error( - todo: dict[str, object], settlement_readback: QuotaSettlementReadback, *, no_follow_up: bool, ) -> str | None: - requirement = _completion_settlement_requirement( - todo, - no_follow_up=no_follow_up, - ) - if requirement is None or settlement_readback.settlement.failure is None: + # Todo acceptance and Turn settlement are distinct facts. Ordinary + # completion can precede accounting (including controller validation). + # Only terminal intent requires the full chain before closing out. + if not no_follow_up or settlement_readback.settlement.failure is None: return None return ( - f"{requirement} requires matching writeback and quota spend receipts: " + "terminal no-follow-up closeout requires matching writeback and quota spend receipts: " + settlement_readback.settlement.failure.reason ) @@ -418,7 +389,6 @@ def handle_todo_command( settlement_result = None settlement_identity = None settlement_readback = None - completion_requires_settlement = False completion_error = None completion_turn_key = None completion_identity_source = None @@ -466,13 +436,7 @@ def handle_todo_command( raise ValueError( "turn-scoped Todo completion requires one durable Todo" ) - completion_requirement = _completion_settlement_requirement( - todo, - no_follow_up=bool(args.no_follow_up), - ) - completion_requires_settlement = completion_requirement is not None completion_error = _completion_settlement_error( - todo, settlement_readback=settlement_readback, no_follow_up=bool(args.no_follow_up), ) @@ -638,8 +602,6 @@ def handle_todo_command( settlement_result = ( settlement_readback.terminal_settlement if args.no_follow_up and settlement_identity is not None - else settlement_readback.settlement - if completion_requires_settlement else settlement_readback.identity ) payload["settlement_result"] = settlement_result_payload( diff --git a/loopx/goal_mode_mcp.py b/loopx/goal_mode_mcp.py index 328f53e840..95eebdc6b6 100644 --- a/loopx/goal_mode_mcp.py +++ b/loopx/goal_mode_mcp.py @@ -179,6 +179,7 @@ def complete_task( task_lease_idempotency_key: str = "", task_lease_expected_version: ExpectedTaskLeaseVersion = None, no_follow_up: bool = False, + successor_todo_ids: list[str] | None = None, ) -> str: goal_id, _ = self.context() if not goal_id: @@ -193,6 +194,13 @@ def complete_task( "error": "next_agent_todo and no_follow_up are mutually exclusive", } ) + if successor_todo_ids is not None and ( + not isinstance(successor_todo_ids, list) + or any(not isinstance(value, str) or not value.strip() for value in successor_todo_ids) + ): + return json.dumps({"ok": False, "error": "successor_todo_ids must be a list of nonempty ids"}) + if successor_todo_ids and (next_agent_todo or no_follow_up): + return json.dumps({"ok": False, "error": "choose existing successors, a new successor, or no follow-up"}) args = [ "todo", "complete", @@ -209,6 +217,8 @@ def complete_task( ] if next_agent_todo: args += ["--next-agent-todo", next_agent_todo] + for successor in successor_todo_ids or []: + args += ["--successor-todo-id", successor] if task_lease_idempotency_key: args += ["--task-lease-idempotency-key", task_lease_idempotency_key] if task_lease_expected_version is not None: @@ -273,8 +283,12 @@ def complete_task( task_lease_idempotency_key: str = "", task_lease_expected_version: ExpectedTaskLeaseVersion = None, no_follow_up: bool = False, + successor_todo_ids: list[str] | None = None, ) -> str: - """Complete one verified todo, write follow-up state, then spend quota.""" + """Complete verified work and settle once. Link existing planned successors + with successor_todo_ids; next_agent_todo creates a NEW Todo, not an id link. + Use no_follow_up only for terminal intent. Do not duplicate existing work. + """ return control.complete_task( todo_id, agent_id, @@ -283,6 +297,7 @@ def complete_task( task_lease_idempotency_key=task_lease_idempotency_key, task_lease_expected_version=task_lease_expected_version, no_follow_up=no_follow_up, + successor_todo_ids=successor_todo_ids, ) return server, control diff --git a/loopx/kunluncode_goal_mode/guards.py b/loopx/kunluncode_goal_mode/guards.py index ff5d91bcb2..15b8fd367e 100644 --- a/loopx/kunluncode_goal_mode/guards.py +++ b/loopx/kunluncode_goal_mode/guards.py @@ -41,12 +41,14 @@ def blocked_complete( task_lease_idempotency_key: str = "", task_lease_expected_version: int | None = None, no_follow_up: bool = False, + successor_todo_ids: list[str] | None = None, ) -> str: del ( next_agent_todo, task_lease_idempotency_key, task_lease_expected_version, no_follow_up, + successor_todo_ids, ) return _native_controller_rejection("complete_task") diff --git a/scripts/qualify-claude-goal-release.py b/scripts/qualify-claude-goal-release.py index 349cf7f985..bd3511ce51 100644 --- a/scripts/qualify-claude-goal-release.py +++ b/scripts/qualify-claude-goal-release.py @@ -80,6 +80,34 @@ def host_environment(root: Path, launcher: Path) -> dict[str, str]: return env +def verify_mcp_completions(events: list[dict]) -> None: + """A tool invocation is not evidence that the MCP transaction succeeded.""" + pending: dict[str, str] = {} + completed: set[str] = set() + for event in events: + for block in (event.get("message") or {}).get("content", []): + if block.get("type") == "tool_use" and block.get("name") == "mcp__loopx__complete_task": + pending[block["id"]] = (block.get("input") or {}).get("todo_id") + if block.get("type") != "tool_result" or block.get("tool_use_id") not in pending: + continue + content = block.get("content") + if isinstance(content, list): + content = "".join(item.get("text", "") for item in content if item.get("type") == "text") + try: + payload = json.loads(content) + if isinstance(payload, dict) and isinstance(payload.get("result"), str): + payload = json.loads(payload["result"]) + except (TypeError, ValueError): + continue + if not isinstance(payload, dict) or payload.get("ok") is not True: + continue + todo_id = pending[block["tool_use_id"]] + assert payload.get("todo_id") == todo_id and payload.get("completed") is True + assert (payload.get("settlement") or {}).get("ok") is True + completed.add(todo_id) + assert completed == shared.TODOS, "mcp_delivery_transactions_not_completed" + + def qualify(root: Path, claude: str, timeout: int) -> dict: from loopx.claude_goal_mode.scripts.goalmode_cmd import write_loop_md @@ -111,6 +139,7 @@ def qualify(root: Path, claude: str, timeout: int) -> dict: for block in (event.get("message") or {}).get("content", []) if block.get("type") == "tool_use"] assert "mcp__loopx__should_run" in calls and "mcp__loopx__complete_task" in calls, "mcp_not_exercised" + verify_mcp_completions(events) return {"status": "passed", "model_executed": True, "model": DOUBAO_SEED_EVOLVING_MODEL, "host": "claude_code", "scheduler_qualification": "not_run_headless", "mcp_tool_calls": sum(str(c).startswith("mcp__loopx__") for c in calls), diff --git a/scripts/qualify-native-goal-release.py b/scripts/qualify-native-goal-release.py index 081c631044..03f8264945 100644 --- a/scripts/qualify-native-goal-release.py +++ b/scripts/qualify-native-goal-release.py @@ -58,7 +58,7 @@ def setup(root: Path) -> tuple[Path, Path, Path]: (project / "ACTIVE_GOAL_STATE.md").write_text( '---\nstatus: active-read-only\nowner_mode: goal\n' 'objective: "Deliver the finite local ledger specification."\n---\n\n' - '# Ledger\n\n## Objective\n\nDeliver TASK.md, validate and settle both Todos.\n\n' + '# Ledger\n\n## Objective\n\nDeliver and validate TASK.md.\n\n' '## Next Action\n\nImplement the reducer, then the CLI.\n\n' '## User Todo\n\n## Agent Todo\n\n' '- [ ] [P1] Implement and validate the TASK.md reducer.\n' @@ -102,9 +102,11 @@ def cli(launcher: Path, *args: str) -> dict: def verify_settlement(runtime: Path, todos: list[dict]) -> int: + from loopx.control_plane.effect_program import SettlementStepKind from loopx.control_plane.quota.settlement import read_heartbeat_settlement - assert {t["todo_id"] for t in todos if t.get("status") == "done"} == TODOS + assert len(todos) == len(TODOS) and {t["todo_id"] for t in todos} == TODOS + assert all(t.get("status") == "done" for t in todos) rows = [json.loads(line) for line in (runtime / "goals" / GOAL / "runs/index.jsonl").read_text().splitlines()] spends = [r for r in rows if r.get("classification") == "quota_slot_spent"] @@ -118,7 +120,13 @@ def verify_settlement(runtime: Path, todos: list[dict]) -> int: replan_obligation_id=row.get("replan_obligation_id"), turn_instance_id=row["turn_instance_id"], ) - assert readback and readback.writeback and readback.spend + assert readback is not None, "missing_settlement_readback" + assert readback.settlement.failure is None, "incomplete_settlement" + assert {r.step_kind for r in readback.settlement.receipts} >= { + SettlementStepKind.VALIDATION, SettlementStepKind.DURABLE_WRITEBACK, + SettlementStepKind.QUOTA_SPEND, + }, "missing_settlement_receipts" + assert readback.writeback_run is not None and readback.spend_run is not None return len(spends) diff --git a/tests/control_plane/test_quota_settlement.py b/tests/control_plane/test_quota_settlement.py index 13c0c76b29..c69bb82df3 100644 --- a/tests/control_plane/test_quota_settlement.py +++ b/tests/control_plane/test_quota_settlement.py @@ -273,7 +273,7 @@ def test_quota_settlement_readback_returns_the_complete_typed_chain( assert readback.spend_run is not None -def test_advancement_completion_requires_the_complete_settlement_chain( +def test_only_terminal_closeout_requires_the_complete_settlement_chain( tmp_path: Path, ) -> None: runtime_root = tmp_path / "runtime" @@ -289,18 +289,12 @@ def test_advancement_completion_requires_the_complete_settlement_chain( ) assert incomplete is not None error = _completion_settlement_error( - { - "role": "agent", - "task_class": "advancement_task", - "action_kind": "implement", - "text": "Ship the repository change.", - }, incomplete, - no_follow_up=False, + no_follow_up=True, ) assert error is not None assert error.startswith( - "turn-scoped advancement completion requires matching writeback and " + "terminal no-follow-up closeout requires matching writeback and " "quota spend receipts:" ) @@ -318,26 +312,13 @@ def test_advancement_completion_requires_the_complete_settlement_chain( ) assert ( _completion_settlement_error( - { - "role": "agent", - "task_class": "advancement_task", - "action_kind": "implement", - "text": "Ship the repository change.", - }, settled, - no_follow_up=False, + no_follow_up=True, ) is None ) assert ( _completion_settlement_error( - { - "role": "agent", - "task_class": "advancement_task", - "action_kind": "research", - "continuation_policy": "same_agent_non_delivery", - "text": "Analyze the evidence.", - }, incomplete, no_follow_up=False, ) diff --git a/tests/fixtures/native_goal_ledger/TASK.md b/tests/fixtures/native_goal_ledger/TASK.md index cb2fbda446..0f59fa4aa7 100644 --- a/tests/fixtures/native_goal_ledger/TASK.md +++ b/tests/fixtures/native_goal_ledger/TASK.md @@ -17,9 +17,8 @@ with a trailing newline. Empty input succeeds with `{}`. Invalid JSON or domain input must leave stdout empty, write a concise diagnostic to stderr, and exit nonzero. Add reducer and subprocess tests, including long replay, and a README. -Complete both dependent LoopX Todos and settle validated work using the current -contract. This is a finite local task: no upload, publication, deployment, +This is a finite local task: no upload, publication, deployment, package installation, or external side effect is part of acceptance. Keep all changes in the disposable project/worktrees. Do not change this specification or LoopX source to make the task pass. Do not substitute fabricated evidence -or a non-delivery declaration for actual code delivery. +for actual code delivery. diff --git a/tests/test_claude_goal_release_qualification.py b/tests/test_claude_goal_release_qualification.py index 044a5e6b16..9b050ece77 100644 --- a/tests/test_claude_goal_release_qualification.py +++ b/tests/test_claude_goal_release_qualification.py @@ -61,6 +61,7 @@ def test_claude_loop_uses_current_contract_not_segment_or_empty_list_stop(): assert "Complete only finished Todos, not partial work" in prompt assert 'agent_id="agent-a"' in prompt assert "That MCP operation owns writeback/spend" in prompt + assert "successor_todo_ids" in prompt assert "unavailable/incomplete contract" in prompt @@ -84,6 +85,23 @@ def test_host_nonzero_is_not_reported_as_a_successful_model_turn(tmp_path): cwd=tmp_path, env=dict(os.environ), timeout=10) +@pytest.mark.parametrize("failed", [False, True]) +def test_mcp_oracle_requires_successful_transactions_not_only_invocations(failed): + events = [] + for todo in sorted(runner.shared.TODOS): + events.append({"message": {"content": [{"type": "tool_use", "id": todo, + "name": "mcp__loopx__complete_task", "input": {"todo_id": todo}}]}}) + result = {"ok": not failed, "completed": True, "todo_id": todo, + "settlement": {"ok": not failed}} + events.append({"message": {"content": [{"type": "tool_result", "tool_use_id": todo, + "content": json.dumps({"result": json.dumps(result)})}]}}) + if failed: + with pytest.raises(AssertionError, match="mcp_delivery_transactions_not_completed"): + runner.verify_mcp_completions(events) + else: + runner.verify_mcp_completions(events) + + def test_real_claude_stdio_mcp_binding_and_identity_gate(tmp_path): pytest.importorskip("mcp.server.fastmcp") from mcp import ClientSession, StdioServerParameters @@ -106,6 +124,8 @@ async def exercise(): await session.initialize() tools = await session.list_tools() assert {"should_run", "claim_task", "complete_task"} <= {t.name for t in tools.tools} + complete = next(t for t in tools.tools if t.name == "complete_task") + assert "successor_todo_ids" in complete.inputSchema["properties"] guard = await session.call_tool("should_run", {}) payload = json.loads(guard.content[0].text) assert payload["ok"] is True and payload["selected_todo"]["todo_id"] == "todo_reducer" @@ -113,3 +133,30 @@ async def exercise(): assert json.loads(rejected.content[0].text)["ok"] is False asyncio.run(exercise()) assert state.read_bytes() == before + + +def test_real_mcp_delivery_completes_and_settles_existing_plan(tmp_path): + from loopx.goal_mode_mcp import GoalModeMCPConfig, GoalModeMCPControlPlane + + project, runtime, launcher = runner.shared.setup(tmp_path) + # Real delivery class: do not substitute same_agent_non_delivery to make + # this acceptance test green. No live model or external side effect. + (project / "delivery.txt").write_text("synthetic verified delivery\n") + control = GoalModeMCPControlPlane( + GoalModeMCPConfig(server_name="loopx", runtime_profile="claude_code", legacy_host_surface="claude_code"), + lambda: {"goal_id": runner.shared.GOAL, "agent_id": runner.shared.AGENT, + "registry": str(project / ".loopx/registry.json")}, + ) + control.command_prefix = lambda: [str(launcher)] + result = json.loads(control.complete_task( + "todo_reducer", runner.shared.AGENT, "synthetic delivery validation passed", + successor_todo_ids=["todo_cli"], + )) + assert result["ok"] is True, "unexpected completion failure" + todos = runner.shared.cli(launcher, "todo", "list", "--goal-id", runner.shared.GOAL, "--role", "agent")["todos"] + assert {row["todo_id"] for row in todos} == runner.shared.TODOS + assert next(row for row in todos if row["todo_id"] == "todo_reducer")["status"] == "done" + index = runtime / "goals" / runner.shared.GOAL / "runs/index.jsonl" + spends = [json.loads(line) for line in index.read_text().splitlines() + if json.loads(line).get("classification") == "quota_slot_spent"] + assert len(spends) == 1 and spends[0]["todo_id"] == "todo_reducer" diff --git a/tests/test_goal_mode_mcp_completion_validation.py b/tests/test_goal_mode_mcp_completion_validation.py index 36301cdca4..e0573167e4 100644 --- a/tests/test_goal_mode_mcp_completion_validation.py +++ b/tests/test_goal_mode_mcp_completion_validation.py @@ -5,6 +5,8 @@ import sys from pathlib import Path +import pytest + from loopx.goal_mode_mcp import GoalModeMCPConfig, GoalModeMCPControlPlane from loopx.status import parse_active_state_todos from loopx.todos import add_goal_todo @@ -68,7 +70,6 @@ def _add_todo(registry: Path, *, validation_command: str | None = None) -> str: text="Deliver one bounded change.", task_class="advancement_task", claimed_by=AGENT, - continuation_policy="same_agent_non_delivery", validation_command=validation_command, ) return str(todo["todo_id"]) @@ -125,10 +126,13 @@ def test_mcp_complete_task_fails_closed_on_failing_declared_validation( assert _agent_todo_status(state, todo_id) != "done" -def test_mcp_advancement_completion_returns_typed_settlement_blocker( - tmp_path: Path, +@pytest.mark.parametrize("no_follow_up", [False, True]) +def test_mcp_advancement_validation_precedes_controller_owned_settlement( + tmp_path: Path, no_follow_up: bool, ) -> None: registry, state = _write_fixture(tmp_path) + validation = state.parent / "check.py" + validation.write_text("from pathlib import Path\nPath('validation-ran').write_text('passed')\n") todo = add_goal_todo( registry_path=registry, goal_id=GOAL_ID, @@ -136,22 +140,22 @@ def test_mcp_advancement_completion_returns_typed_settlement_blocker( text="Deliver one repository advancement.", task_class="advancement_task", claimed_by=AGENT, + validation_command=shlex.join([sys.executable, str(validation)]), ) todo_id = str(todo["todo_id"]) payload = _first_json_blob( - _control(registry).complete_task(todo_id, AGENT, "claimed done") + _control(registry).complete_task(todo_id, AGENT, "validated delivery", no_follow_up=no_follow_up) ) - assert payload["ok"] is False - assert payload["completed"] is False - assert payload["changed"] is False - assert payload["settlement_blocked_completion"] is True + assert payload["ok"] is True, payload + assert payload["completed"] is True assert payload["settlement_identity"]["todo_id"] == todo_id - assert payload["settlement_result"]["failure"]["kind"] == ( - "writeback_missing" - ) - assert _agent_todo_status(state, todo_id) != "done" + assert (state.parent / "validation-ran").read_text() == "passed" + assert payload["settlement"]["durable_writeback"]["ok"] is True + assert payload["settlement"]["quota_spend"]["appended"] is True + assert ("terminal_closeout" in payload["settlement"]) is no_follow_up + assert _agent_todo_status(state, todo_id) == "done" def test_expected_lease_version_annotation_rejects_bool_at_the_boundary() -> None: """FastMCP validates tool arguments with pydantic, and lax pydantic diff --git a/tests/test_goal_mode_mcp_settlement.py b/tests/test_goal_mode_mcp_settlement.py index 4d49b58504..3dfafc7fc9 100644 --- a/tests/test_goal_mode_mcp_settlement.py +++ b/tests/test_goal_mode_mcp_settlement.py @@ -470,19 +470,21 @@ def test_real_mcp_completion_recovers_a_lost_mutation_response(tmp_path, lost_af added = add_goal_todo( registry_path=registry, goal_id=GOAL_ID, role="agent", text="Validate response-loss recovery.", task_class="advancement_task", - continuation_policy="same_agent_non_delivery", claimed_by=AGENT_ID, + claimed_by=AGENT_ID, ) control = _control(registry) original = control.run_cli injected = False + bound_identity = None def lose_once(args, **kwargs): - nonlocal injected + nonlocal injected, bound_identity output = original(args, **kwargs) step = {"lifecycle": ["todo", "complete"], "writeback": ["refresh-state"], "spend": ["quota", "spend-slot"]}[lost_after] if not injected and args[:len(step)] == step and json.loads(output).get("ok"): injected = True + bound_identity = json.loads(output)["settlement_identity"] return json.dumps({"ok": False, "error": "synthetic_response_lost_after_commit"}) return output @@ -491,6 +493,20 @@ def lose_once(args, **kwargs): added["todo_id"], AGENT_ID, "recovery fixture check passed", no_follow_up=True, )) assert injected and first["ok"] is False + from loopx.control_plane.quota.settlement import read_heartbeat_settlement + readback = read_heartbeat_settlement( + tmp_path / "runtime", goal_id=GOAL_ID, agent_id=AGENT_ID, + todo_id=added["todo_id"], turn_instance_id=bound_identity["turn_instance_id"], + ) + assert readback.replay_phase == ("settled" if lost_after == "spend" else "settlement_pending") + if lost_after != "spend": + rc, terminal = _run_cli( + registry, "todo", "complete", "--goal-id", GOAL_ID, + "--todo-id", added["todo_id"], "--agent-id", AGENT_ID, + "--turn-instance-id", bound_identity["turn_instance_id"], + "--no-follow-up", "--evidence", "synthetic terminal intent", + ) + assert rc != 0 and terminal["settlement_blocked_completion"] is True replay = json.loads(control.complete_task( added["todo_id"], AGENT_ID, "recovery fixture check passed", no_follow_up=True, )) @@ -503,3 +519,34 @@ def lose_once(args, **kwargs): assert again["settlement"]["quota_spend"]["appended"] is False status = json.loads(control.should_run()) assert status["quota"]["spent_slots"] == 1 + + +def test_real_mcp_links_existing_successor_without_creating_another_todo(tmp_path): + registry, state_file = _write_fixture(tmp_path) + ids = [str(add_goal_todo( + registry_path=registry, goal_id=GOAL_ID, role="agent", text=text, + task_class="advancement_task", + claimed_by=AGENT_ID, + )["todo_id"]) for text in ("First accepted step.", "Already planned follow-up.")] + control = _control(registry) + before = state_file.read_bytes() + rejected = json.loads(control.complete_task( + ids[0], AGENT_ID, "synthetic check passed", successor_todo_ids=[ids[1]], no_follow_up=True, + )) + assert rejected["ok"] is False + assert state_file.read_bytes() == before + first = json.loads(control.complete_task( + ids[0], AGENT_ID, "synthetic check passed", successor_todo_ids=[ids[1]], + )) + assert first["ok"] is True, first + replay = json.loads(control.complete_task( + ids[0], AGENT_ID, "synthetic check passed", successor_todo_ids=[ids[1]], + )) + assert replay["ok"] is True + assert replay["settlement_identity"] == first["settlement_identity"] + todos = parse_active_state_todos(state_file.read_text())["agent_todos"]["items"] + by_id = {row["todo_id"]: row for row in todos} + assert set(by_id) == set(ids) + assert by_id[ids[0]]["status"] == "done" and by_id[ids[1]]["status"] == "open" + assert by_id[ids[0]]["successor_todo_ids"] == [ids[1]] + assert json.loads(control.should_run())["quota"]["spent_slots"] == 1 diff --git a/tests/test_native_goal_release_qualification.py b/tests/test_native_goal_release_qualification.py index 359bbd2700..5fa9b84276 100644 --- a/tests/test_native_goal_release_qualification.py +++ b/tests/test_native_goal_release_qualification.py @@ -93,10 +93,14 @@ def test_settlement_oracle_rejects_missing_identity_and_duplicate_spend(tmp_path @pytest.mark.parametrize("missing", ["writeback", "spend", "receipt"]) def test_settlement_oracle_requires_durable_receipts_not_only_index_rows(tmp_path, monkeypatch, missing): from loopx.control_plane.quota import settlement + from loopx.control_plane.effect_program import SettlementFailure, SettlementFailureKind, SettlementResult receipt = None if missing == "receipt" else SimpleNamespace( - writeback=None if missing == "writeback" else {"ok": True}, - spend=None if missing == "spend" else {"ok": True}, + # Real failed result objects are truthy, unlike None. The oracle must + # inspect the typed failure, not accept object existence as evidence. + settlement=SettlementResult(value=None, failure=SettlementFailure( + kind=SettlementFailureKind.RECEIPT_MISSING, reason=missing, step_kind=None, + )), ) monkeypatch.setattr(settlement, "read_heartbeat_settlement", lambda *_, **__: receipt) rows = [{"classification": "quota_slot_spent", "todo_id": todo, "turn_instance_id": todo, @@ -106,3 +110,10 @@ def test_settlement_oracle_requires_durable_receipts_not_only_index_rows(tmp_pat index.write_text("\n".join(json.dumps(row) for row in rows)) with pytest.raises(AssertionError): runner.verify_settlement(tmp_path, [{"todo_id": t, "status": "done"} for t in runner.TODOS]) + + +def test_settlement_oracle_rejects_duplicate_open_successor(tmp_path): + todos = [{"todo_id": t, "status": "done"} for t in runner.TODOS] + todos.append({"todo_id": "todo_duplicate", "status": "open"}) + with pytest.raises(AssertionError): + runner.verify_settlement(tmp_path, todos) From 47aa6e12391f3624a1171609f948d416a195d67b Mon Sep 17 00:00:00 2001 From: huangruiteng Date: Fri, 11 Sep 2026 11:01:41 +0800 Subject: [PATCH 10/26] fix(heartbeat): preserve static safety without stale execution policy Signed-off-by: huangruiteng --- docs/heartbeat-automation-prompt.md | 34 ++++++++-- docs/reference/automation-prompt-upgrades.md | 6 ++ .../control_plane/heartbeat-prompt-smoke.py | 29 +++------ .../claude_goal_mode/scripts/goalmode_cmd.py | 3 + .../heartbeat/automation_upgrade.py | 5 +- loopx/control_plane/heartbeat/budget.py | 4 +- loopx/control_plane/heartbeat/rules.py | 16 ++++- loopx/control_plane/heartbeat/task_body.py | 64 ++++++++----------- .../test_automation_prompt_upgrade.py | 25 +++++++- .../test_goal_prompt_dispatch.py | 50 ++++++++++++++- tests/test_host_loop_activation.py | 4 +- 11 files changed, 168 insertions(+), 72 deletions(-) diff --git a/docs/heartbeat-automation-prompt.md b/docs/heartbeat-automation-prompt.md index d0bb28ff1a..ae179bb503 100644 --- a/docs/heartbeat-automation-prompt.md +++ b/docs/heartbeat-automation-prompt.md @@ -57,6 +57,35 @@ automation contract. ### Native Goal bootstrap and live execution instructions +#### Static semantics retained across hosts + +Thinning removes duplicated recipes, not authority boundaries. The shared +runtime body keeps repository rules, credential/private-material protection, +explicit authorization for destructive Git/production actions, and exception +routes (`loopx-project` for lifecycle/registry, `loopx-self-repair` for drift). +These routes are conditional, not mandatory skill calls on every iteration. +Ordinary Claude MCP iterations still use MCP; the CLI route is not a second +accounting path. + +| Semantic | Owner after thinning | +| --- | --- | +| Privacy, repository rules, dangerous-action authority | Shared static safety rule; a trusted host is not blanket permission | +| Lifecycle or projection repair | Conditional static repair route; repair does not bypass gates | +| Selection, claims, vision replan, exact settlement identities/order | Current successful interaction contract, not saved command recipes | +| Blocked path vs whole Goal | Gate only the affected path; continue independent admitted work; only terminal no-follow-up completes the Goal | +| Git branch/worktree/PR policy | User and repository rules; no generic `No project branches` restriction | +| Prompt authoring/maintenance advice | Documentation, not per-iteration executor instructions | + +For heartbeat shells, assign `LOOPX_TURN` in a separate statement before the +guard, in the same shell invocation. A command-prefix assignment does not make +the variable available to argument expansion in Bash/zsh. Reuse the same value +on retries. Native Goal entry remains host-specific and does not inherit this +heartbeat bootstrap. + +Thin's ceiling is 2,500 characters (previously 1,900), and compact's is 6,500 +(previously 6,200): the additional room covers shared safety and an executable +Turn/guard block rather than omitting identities or static obligations. + The automation lifecycle is the reference for shared execution, not a wrapper around native Goal behavior. Thin automation and Codex CLI/SSH, TraeX and Ark Managed Agent Goal bodies share quota dispatch: selection/re-entry, admitted work @@ -344,11 +373,6 @@ Replace the placeholders before installing the automation: ```text Advance the goal described in . -Generic LoopX lifecycle. Keep project-specific branching out of the -automation prompt. Put local policy in registry, active-state sections, adapter -output, quota should-run.goal_boundary, or boundary rules; if a lifecycle rule -is needed, update loopx heartbeat-prompt so all projects inherit it. - Before spending delivery compute, first make the LoopX CLI reachable in this automation shell, then run the quota guard: diff --git a/docs/reference/automation-prompt-upgrades.md b/docs/reference/automation-prompt-upgrades.md index 387a747f49..126249e2a2 100644 --- a/docs/reference/automation-prompt-upgrades.md +++ b/docs/reference/automation-prompt-upgrades.md @@ -75,6 +75,12 @@ readback completes. Restart afterward. This adapter targets the observed local schema, **not an official stable Codex storage API**; no Windows/cloud support is claimed. Use the native API if the host changes its storage contract. +A legacy TOML heartbeat label with a SQLite `cron` row is not enough to prove +thread ownership. In particular, a missing database thread binding must not be +filled from TOML by a prompt-only migration. Such records require App-mediated +reconciliation first; the offline adapter reports that action explicitly and +does not convert scheduler kind, infer a thread, or offer an executable upgrade. + ## Recovery and rollback The fallback stores a private per-task journal before writing. SQLite commit diff --git a/examples/control_plane/heartbeat-prompt-smoke.py b/examples/control_plane/heartbeat-prompt-smoke.py index b46378317f..3462e97fd6 100644 --- a/examples/control_plane/heartbeat-prompt-smoke.py +++ b/examples/control_plane/heartbeat-prompt-smoke.py @@ -337,7 +337,7 @@ def main() -> int: state_only_refresh = str(prompt_payload["refresh_state_command"]) assert task_body.index(progress_refresh) < task_body.index(quota_spend), prompt_label assert task_body.index(quota_spend) < task_body.rindex(state_only_refresh), prompt_label - assert len(str(compact_payload["task_body"])) < len(str(payload["task_body"])) * 0.47, ( + assert len(str(compact_payload["task_body"])) < len(str(payload["task_body"])), ( len(str(compact_payload["task_body"])), len(str(payload["task_body"])), ) @@ -485,7 +485,7 @@ def main() -> int: "steering audit", "bottleneck lens", "no-progress self-repair", - "Public-safe commit/push/PR may proceed", + "Gate only the affected path; continue independent allowed work", "loopx todo add --goal-id public-heartbeat-goal --role user --task-class user_gate|user_action", "owner todos and `--role agent` for agent todos, not prose", "Done->successor first; final->refresh->spend->no-follow-up", @@ -567,7 +567,7 @@ def main() -> int: assert live_peer_budget["within_budget"] is True, live_peer_budget assert len(str(live_peer_payload["task_body"])) <= int(live_peer_budget["max_chars"]), live_peer_budget assert "correctness.." not in live_peer_task, live_peer_task - assert live_peer_task.index("`LOOPX_TURN=`") < live_peer_task.index( + assert live_peer_task.index("LOOPX_TURN=") < live_peer_task.index( "quota should-run" ), live_peer_task for phrase in ( @@ -600,7 +600,7 @@ def main() -> int: "drain/read/triage before work; settle/ACK", "P0 blocked: safe P1/P2; monitor quiet/no-spend", "No learning queue unless asked", - "Stop: private material, credentials, destructive git, unauthorized prod", + "Destructive Git/production requires explicit authorization", ): assert phrase in live_peer_task, phrase for phrase in ( @@ -640,7 +640,7 @@ def main() -> int: for phrase in ( "Brief LoopX heartbeat; detail", "loopx heartbeat-prompt --compact --goal-id public-heartbeat-goal --active-state /tmp/public-heartbeat-goal/ACTIVE_GOAL_STATE.md", - "Guard/retry; `LOOPX_TURN=`", + "Run assignment and guard as separate statements in one shell", 'loopx --format json --registry "$HOME/.codex/loopx/registry.global.json" quota should-run --goal-id public-heartbeat-goal', "`user_channel.notify` controls OUTPUT only: NOTIFY=向用户输出动作; DONT_NOTIFY=安静输出", "Due/peer非用户动作", @@ -684,7 +684,7 @@ def main() -> int: "Normal turns use CLI `interaction_contract`; use `loopx-project` for " "lifecycle/registry and `loopx-self-repair` for runtime/projection drift", "use selection_command when required", - "`quota should-run`", + "quota should-run", "`user_channel.notify` controls OUTPUT only: NOTIFY=向用户输出动作; DONT_NOTIFY=安静输出", "Due/peer非用户动作", "NOTIFY缺动作→", @@ -697,7 +697,7 @@ def main() -> int: "P0 blocked: safe P1/P2", "monitor quiet/no-spend", "No learning queue unless asked", - "Stop: private material, credentials, destructive git, unauthorized prod", + "Destructive Git/production requires explicit authorization", ): assert phrase in thin_task, phrase for label, task in ( @@ -722,11 +722,6 @@ def main() -> int: must_have = ( "", "", - "Generic LoopX lifecycle", - "Keep project-specific branching out of the automation prompt", - "Put local policy in registry, active-state sections, adapter output", - "quota should-run.goal_boundary", - "update loopx heartbeat-prompt so all projects inherit it", 'export PATH="$HOME/.local/bin:$PATH"', 'install_script="$HOME/loopx/scripts/install-local.sh"', "loopx doctor >/dev/null", @@ -854,10 +849,6 @@ def main() -> int: for phrase in ( 'export PATH="$HOME/.local/bin:$PATH"', 'install_script="$HOME/loopx/scripts/install-local.sh"', - "Generic LoopX lifecycle", - "Keep project-specific branching out of the automation prompt", - "Put local policy in registry, active-state sections, adapter output", - "quota should-run.goal_boundary", "loopx doctor >/dev/null", 'loopx --format json --registry "$HOME/.codex/loopx/registry.global.json" quota should-run --goal-id public-heartbeat-goal', "If that preflight still fails", @@ -932,9 +923,9 @@ def main() -> int: "授权/预算内推进可验证结果", "a focused correction may suffice", "Stay inside `goal_boundary` when present", - "Public-safe repo publication is not an operator gate by itself", - "commit, push, and PR creation may proceed autonomously after validation", - "clean public/private boundary scan", + "Follow user authority and repository rules", + "publish public-safe evidence", + "Destructive Git/production requires explicit authorization", "Plan/top todo/route changes need todo/Next Action writeback", "If a user/owner todo appears", "loopx todo add --goal-id public-heartbeat-goal --role user --task-class user_gate", diff --git a/loopx/claude_goal_mode/scripts/goalmode_cmd.py b/loopx/claude_goal_mode/scripts/goalmode_cmd.py index 02e7887208..b5e6dbcb59 100644 --- a/loopx/claude_goal_mode/scripts/goalmode_cmd.py +++ b/loopx/claude_goal_mode/scripts/goalmode_cmd.py @@ -37,6 +37,8 @@ sys.path.insert(0, str(HERE.parent / "hooks")) from goal_state import goal_context, find_registry, loop_md_path # noqa: E402 from loopx.control_plane.heartbeat.rules import ( # noqa: E402 + HOST_LOOP_SAFETY_RULE, + RUNTIME_REPAIR_ROUTING_RULE, SCOPE_BOUNDED_WORK_RULE, ) @@ -69,6 +71,7 @@ def loop_md_content(goal_id, agent_id) -> str: f"\n" f"loopx tick — advance goal `{goal_id}` (agent `{agent_id}`). Use the wired loopx MCP\n" f"tools; do NOT run `loopx --help` or guess ids.\n\n" + f"{HOST_LOOP_SAFETY_RULE}\n{RUNTIME_REPAIR_ROUTING_RULE}\n" "Read complete successful `should_run()` JSON each work iteration. Follow its\n" "current `interaction_contract`: selection/re-entry before admitted work,\n" "then validation and settlement. Never infer completion from an empty Todo list.\n" diff --git a/loopx/control_plane/heartbeat/automation_upgrade.py b/loopx/control_plane/heartbeat/automation_upgrade.py index 5a4edc69f1..88faae40e5 100644 --- a/loopx/control_plane/heartbeat/automation_upgrade.py +++ b/loopx/control_plane/heartbeat/automation_upgrade.py @@ -170,7 +170,10 @@ def _read(home: Path, automation_id: str, connection: sqlite3.Connection) -> tup raise ValueError("automation identity missing or mismatched") row = dict(row) if item.get("kind") != "heartbeat" or row["kind"] != "heartbeat": - raise ValueError("only existing heartbeat automations are supported") + raise ValueError( + "heartbeat kind/binding is not confirmed by both stores; reconcile through " + "the App before prompt adoption (do not convert a cron row or infer its thread)" + ) if item.get("status") == "DELETED" or row["status"] == "DELETED": raise ValueError("deleted automation cannot be upgraded") for key in ("prompt", "status", "target_thread_id"): diff --git a/loopx/control_plane/heartbeat/budget.py b/loopx/control_plane/heartbeat/budget.py index 07b43eab73..d89c401de7 100644 --- a/loopx/control_plane/heartbeat/budget.py +++ b/loopx/control_plane/heartbeat/budget.py @@ -7,9 +7,9 @@ INTERFACE_BUDGET_CHARS = { "full": 12_000, - "compact": 6_200, + "compact": 6_500, "brief": 3_500, - "thin": 1_900, + "thin": 2_500, "visible_goal": 4_000, } NATIVE_GOAL_HOST_MAX_CHARS = INTERFACE_BUDGET_CHARS["visible_goal"] diff --git a/loopx/control_plane/heartbeat/rules.py b/loopx/control_plane/heartbeat/rules.py index de4929d47e..f994fc13ab 100644 --- a/loopx/control_plane/heartbeat/rules.py +++ b/loopx/control_plane/heartbeat/rules.py @@ -50,10 +50,22 @@ RUNTIME_CAPABILITY_PROJECTION_THIN_RULE = ( "Observed capabilities -> `--available-capability`; never user gates." ) -RUNTIME_EXECUTION_ROUTING_RULE = ( - "Normal turns use CLI `interaction_contract`; use `loopx-project` for " +RUNTIME_REPAIR_ROUTING_RULE = ( + "use `loopx-project` for " "lifecycle/registry and `loopx-self-repair` for runtime/projection drift." ) +RUNTIME_EXECUTION_ROUTING_RULE = ( + "Normal turns use CLI `interaction_contract`; " + RUNTIME_REPAIR_ROUTING_RULE +) +HOST_LOOP_SAFETY_RULE = ( + "Follow user authority and repository rules. Protect credentials/private material; " + "publish public-safe evidence. Destructive Git/production requires explicit authorization. " + "Gate only the affected path; continue independent allowed work." +) +HEARTBEAT_TURN_BOOTSTRAP_RULE = ( + "Per wake, replace `` once. Run assignment and guard as separate " + "statements in one shell, not a command-prefix assignment; reuse the value on retries." +) HOST_LOOP_QUOTA_DISPATCH_RULE = ( "Quota: use selection_command when required; " "先按指令重新进入,完成获准工作并验证后,再按 next_cli_actions 写回和记账。" diff --git a/loopx/control_plane/heartbeat/task_body.py b/loopx/control_plane/heartbeat/task_body.py index c09b66b9b5..ce2583ae67 100644 --- a/loopx/control_plane/heartbeat/task_body.py +++ b/loopx/control_plane/heartbeat/task_body.py @@ -10,12 +10,15 @@ DEFAULT_PERMISSION_RULE, HEARTBEAT_NOTIFICATION_RULE_SHORT, HEARTBEAT_NOTIFICATION_RULE_THIN, + HEARTBEAT_TURN_BOOTSTRAP_RULE, HEARTBEAT_VISION_WRITEBACK_RULE_SHORT, HOST_LOOP_QUOTA_DISPATCH_RULE, + HOST_LOOP_SAFETY_RULE, HOST_LOOP_TODO_CLOSEOUT_COMPACT_RULE, HOST_LOOP_TODO_CLOSEOUT_RULE, RUNTIME_CAPABILITY_PROJECTION_THIN_RULE, RUNTIME_EXECUTION_ROUTING_RULE, + RUNTIME_REPAIR_ROUTING_RULE, SCHEDULER_HINT_APPLICATION_RULE, SCHEDULER_HINT_COMPACT_RULE, SCHEDULER_HINT_THIN_RULE, @@ -86,17 +89,14 @@ def render_heartbeat_task_body( ) return f"""Advance `{goal_id}` using `{active_state}`. -Generic LoopX lifecycle. Keep project-specific branching out of the -automation prompt. Put local policy in registry, active-state sections, adapter -output, `quota should-run.goal_boundary`, or boundary rules; if a lifecycle -rule is needed, update `{cli_bin} heartbeat-prompt` so all projects inherit it. {scope_block} -Before spending delivery compute, make the CLI reachable; set -`LOOPX_TURN=` per trigger, reuse it on retries, and run guard: +Before spending delivery compute, make the CLI reachable. +{HEARTBEAT_TURN_BOOTSTRAP_RULE} ```bash {cli_preflight} +LOOPX_TURN= {pr_review_pre_quota_block}{quota_guard_command} ``` @@ -213,14 +213,8 @@ def render_heartbeat_task_body( for 2 more eligible turns; no spend for the self-cancel turn. 4. {SCOPE_BOUNDED_WORK_RULE} Related work can form a coherent effort; a focused correction may suffice. -5. Execute that scoped work. Stay inside `goal_boundary` when present and keep - public/private boundaries intact. Public-safe repo publication is not an - operator gate by itself: for routine public project work, commit, push, and - PR creation may proceed autonomously after validation and a clean - public/private boundary scan. Stop and surface a user/controller gate only - for private or company-internal material, credentials, destructive git - operations, production actions, or repository rules that explicitly require - review. +5. Execute that scoped work. Stay inside `goal_boundary` when present. + {HOST_LOOP_SAFETY_RULE} 6. Run validation proportionate to the change and risk. 7. Write back changed files, validation, critic, and next action to the active state. If a user/owner todo appears, do not hide it in prose: use @@ -296,10 +290,11 @@ def render_brief_heartbeat_task_body( `{compact_prompt_command}`. {scope_block} -Guard/retry; `LOOPX_TURN=`: +{HEARTBEAT_TURN_BOOTSTRAP_RULE} ```bash {cli_preflight} +LOOPX_TURN= {pr_review_pre_quota_block}{quota_guard_command} ``` @@ -333,6 +328,9 @@ def render_brief_heartbeat_task_body( No spend for quiet skips, preflight failures, blocker-push asks, dry-runs, or duplicate accounting. Return only under `user_channel.notify=NOTIFY`; else quiet. +{HOST_LOOP_SAFETY_RULE} +{RUNTIME_REPAIR_ROUTING_RULE} + {material_queue_rule} {permission_rule}""" def render_compact_heartbeat_task_body( @@ -364,10 +362,11 @@ def render_compact_heartbeat_task_body( Detail: `{expanded_prompt_command}`. {scope_block} -Preflight/guard; `LOOPX_TURN=`; reuse: +{HEARTBEAT_TURN_BOOTSTRAP_RULE} ```bash {cli_preflight} +LOOPX_TURN= {pr_review_pre_quota_block}{quota_guard_command} ``` @@ -421,8 +420,7 @@ def render_compact_heartbeat_task_body( heartbeats with only status/brief checks, replan before quiet no-op. Pause/delete only if repair stays stuck 2 more turns. 7. {SCOPE_BOUNDED_WORK_RULE} - Public-safe commit/push/PR may proceed after validation/clean scan. Stop for - private/company material, credentials, destructive git, production, or review rules. + {HOST_LOOP_SAFETY_RULE} 8. Validate; write files/validation/critic/next action to active state; use `{cli_bin} todo add --goal-id {goal_id} --role user --task-class user_gate|user_action` for owner todos and `--role agent` for agent todos, not prose. @@ -554,6 +552,9 @@ def _render_goal_task_body( return f"""Advance LoopX goal `{goal_id}` from `{active_state}` {host_preamble} {scope_block} +{RUNTIME_EXECUTION_ROUTING_RULE} +{HOST_LOOP_SAFETY_RULE} + {prequota_block}Each work iteration, read complete successful JSON from: `{quota_guard_command}` Use the current `interaction_contract`, not remembered commands. @@ -641,33 +642,23 @@ def render_thin_heartbeat_task_body( permission_rule=permission_rule, ) scope_sentence = f"\n{agent_scope_instruction}" if agent_scope_instruction else "" - quota_guard_instruction = ( - f"`{quota_guard_command}`" - if any( - marker in quota_guard_command - for marker in ( - "--available-capability", - "--runtime-profile", - "--codex-app", - "--host-surface", - " -H ", - ) - ) - else "`quota should-run`" - ) pr_review_pre_quota_instruction = ( - f"`{pr_review_pre_quota_command}`\n" + f"{pr_review_pre_quota_command}\n" if pr_review_pre_quota_command else "" ) return f"""Advance `{goal_id}` from {active_state}. {RUNTIME_EXECUTION_ROUTING_RULE} +{HOST_LOOP_SAFETY_RULE} {scope_sentence} {HOST_LOOP_QUOTA_DISPATCH_RULE} -`LOOPX_TURN=`; reuse. -{pr_review_pre_quota_instruction}{quota_guard_instruction}. +{HEARTBEAT_TURN_BOOTSTRAP_RULE} +```sh +LOOPX_TURN= +{pr_review_pre_quota_instruction}{quota_guard_command} +``` {HEARTBEAT_NOTIFICATION_RULE_SHORT} {SCOPE_BOUNDED_WORK_RULE} {RUNTIME_CAPABILITY_PROJECTION_THIN_RULE} @@ -678,8 +669,7 @@ def render_thin_heartbeat_task_body( P0 blocked: safe P1/P2; monitor quiet/no-spend. -{policy_tail} Stop: private material, credentials, -destructive git, unauthorized prod.""" +{policy_tail}""" def render_heartbeat_generator_inputs_markdown(payload: dict[str, Any]) -> str: interface_budget = payload.get("interface_budget") if isinstance(payload.get("interface_budget"), dict) else {} lines = [ diff --git a/tests/control_plane/test_automation_prompt_upgrade.py b/tests/control_plane/test_automation_prompt_upgrade.py index f8a32b342f..b397df97b2 100644 --- a/tests/control_plane/test_automation_prompt_upgrade.py +++ b/tests/control_plane/test_automation_prompt_upgrade.py @@ -13,7 +13,7 @@ from loopx.control_plane.heartbeat import automation_upgrade as upgrade -def fixture(tmp_path: Path): +def fixture(tmp_path: Path, backing_kind="heartbeat"): home = tmp_path / "host" path = home / "automations/watch/automation.toml" path.parent.mkdir(parents=True) @@ -28,7 +28,7 @@ def fixture(tmp_path: Path): with sqlite3.connect(database) as connection: connection.execute("CREATE TABLE automations (id TEXT PRIMARY KEY, kind TEXT, prompt TEXT, status TEXT, target_thread_id TEXT, rrule TEXT, model TEXT, updated_at INTEGER, next_run_at INTEGER)") connection.execute("INSERT INTO automations VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", - ("watch", "heartbeat", prompt, "PAUSED", "thread-a", "FREQ=HOURLY", "fixture-model", 123, 456)) + ("watch", backing_kind, prompt, "PAUSED", "thread-a", "FREQ=HOURLY", "fixture-model", 123, 456)) connection.execute("CREATE TABLE sessions (id TEXT)") connection.execute("INSERT INTO sessions VALUES ('do-not-touch')") registry = tmp_path / "registry.json" @@ -114,7 +114,7 @@ def test_divergence_never_mutates_host(tmp_path, reason): if reason == "missing_row": connection.execute("DELETE FROM automations") elif reason == "wrong_kind": - connection.execute("UPDATE automations SET kind='cron'") + connection.execute("UPDATE automations SET kind='unknown'") elif reason == "metadata": connection.execute("UPDATE automations SET status='ACTIVE'") else: @@ -127,6 +127,25 @@ def test_divergence_never_mutates_host(tmp_path, reason): assert not (home / "loopx-automation-backups").exists() +@pytest.mark.parametrize("mutation", ["standalone", "legacy_mirror", "mismatched_thread"]) +def test_cron_backing_is_not_inferred_to_be_a_bound_heartbeat(tmp_path, mutation): + home, path, database, registry, _ = fixture(tmp_path, "cron") + if mutation == "standalone": + path.write_text(path.read_text().replace('kind = "heartbeat"', 'kind = "cron"')) + elif mutation == "legacy_mirror": + # Observed legacy shape: TOML claims a thread but the scheduler does not. + with sqlite3.connect(database) as connection: + connection.execute("UPDATE automations SET target_thread_id=NULL") + else: + with sqlite3.connect(database) as connection: + connection.execute("UPDATE automations SET target_thread_id='thread-b'") + before = path.read_bytes() + entry = upgrade.build_plan(registry=registry, home=home)["entries"][0] + assert entry["status"] == "blocked" and "desired_prompt" not in entry + assert "App" in entry["reason"] + assert path.read_bytes() == before + + def test_failure_after_db_commit_is_recoverable_without_duplicate_mutation(tmp_path, monkeypatch): home, path, database, _, prompt = fixture(tmp_path) atomic = upgrade._atomic diff --git a/tests/control_plane/test_goal_prompt_dispatch.py b/tests/control_plane/test_goal_prompt_dispatch.py index bb9aedfbcc..74c24b25a2 100644 --- a/tests/control_plane/test_goal_prompt_dispatch.py +++ b/tests/control_plane/test_goal_prompt_dispatch.py @@ -1,5 +1,10 @@ """Native Goal bootstrap delegates policy to the current quota contract.""" +import os +import re +import shutil +import subprocess + import pytest from loopx.heartbeat_prompt import build_heartbeat_prompt @@ -31,7 +36,7 @@ def test_goal_prompt_has_one_live_execution_entry(host: str) -> None: assert "notification" in body assert "no work/spend" in body assert "No permission asks in a trusted session" not in body - assert len(body) < 2400 + assert len(body) < 3300 def test_codex_wait_rule_is_not_exported_to_other_goal_hosts() -> None: @@ -50,3 +55,46 @@ def test_explicit_goal_policy_is_preserved_not_replaced_by_bootstrap_defaults() ) assert "Only edit the assigned workspace." in packet["task_body"] assert "Use the approved reference set only." in packet["task_body"] + + +@pytest.mark.parametrize("profile", ["codex_cli", None, "ark_managed_agent_goal"]) +def test_shared_static_safety_and_exception_routing_survive_thinning(profile): + body = build_heartbeat_prompt(goal_id="contract-fixture", runtime_profile=profile, + thin=True)["task_body"] + for obligation in ("repository rules", "credentials", "private material", + "Destructive Git", "production", "loopx-project", "loopx-self-repair"): + assert obligation in body + assert "project-specific workflow" not in body + assert "No project branches" not in body + assert "only the affected path" in body + + +@pytest.mark.parametrize("shell", ["bash", "zsh"]) +def test_emitted_heartbeat_bootstrap_expands_turn_before_real_guard(shell, tmp_path): + executable = shutil.which(shell) + if executable is None: + pytest.skip(f"{shell} unavailable") + # The actual emitted shell block must work without any inherited Turn. + from importlib.util import module_from_spec, spec_from_file_location + from pathlib import Path + spec = spec_from_file_location("goal_runner", Path(__file__).resolve().parents[2] / + "scripts/qualify-native-goal-release.py") + runner = module_from_spec(spec) + spec.loader.exec_module(runner) + project, _, launcher = runner.setup(tmp_path) + packet = runner.cli(launcher, "heartbeat-prompt", "--thin", "--codex-app", + "--goal-id", runner.GOAL, "--agent-id", runner.AGENT, + "--cli-bin", str(launcher)) + script = re.search(r"```sh\n(.*?)\n```", packet["task_body"], re.S).group(1) + script = script.replace("", "2026-09-01T00:00:00Z") + env = {k: v for k, v in os.environ.items() if k != "LOOPX_TURN"} + result = subprocess.run([executable, "-c", script], cwd=project, env=env, + capture_output=True, text=True, timeout=120) + assert result.returncode == 0, result.stderr + assert '"ok": true' in result.stdout + # Prefix assignment is a genuine shell failure, not a LoopX rejection. + broken = script.replace("LOOPX_TURN=2026-09-01T00:00:00Z\n", + "LOOPX_TURN=2026-09-01T00:00:00Z ", 1) + rejected = subprocess.run([executable, "-c", broken], cwd=project, env=env, + capture_output=True, text=True, timeout=120) + assert rejected.returncode != 0 and "LOOPX_TURN" in rejected.stderr diff --git a/tests/test_host_loop_activation.py b/tests/test_host_loop_activation.py index 2eb67d6ef5..bfa069cb91 100644 --- a/tests/test_host_loop_activation.py +++ b/tests/test_host_loop_activation.py @@ -396,8 +396,8 @@ def test_goal_hosts_enter_live_contract_without_a_mandatory_skill_detour( task_body = " ".join(payload["task_body"].split()) assert "Use the current `interaction_contract`, not remembered commands" in task_body - assert "loopx-project" not in task_body - assert "loopx-self-repair" not in task_body + assert "loopx-project" in task_body + assert "loopx-self-repair" in task_body assert "Progress is not a new Goal boundary" in task_body assert "do not create a new host Goal merely to continue" in task_body From 258d380f59501453a87cd722bb904ad3eac54c29 Mon Sep 17 00:00:00 2001 From: huangruiteng Date: Fri, 11 Sep 2026 11:01:50 +0800 Subject: [PATCH 11/26] fix(qualification): isolate host configuration and child environments Signed-off-by: huangruiteng --- docs/development/testing-and-quality.md | 18 +++-- scripts/qualify-claude-goal-release.py | 2 +- scripts/qualify-native-goal-release.py | 65 +++++++++++++++---- .../test_claude_goal_release_qualification.py | 19 +++++- .../test_native_goal_release_qualification.py | 28 ++++++++ 5 files changed, 113 insertions(+), 19 deletions(-) diff --git a/docs/development/testing-and-quality.md b/docs/development/testing-and-quality.md index bee8a8f795..0f475c1f08 100644 --- a/docs/development/testing-and-quality.md +++ b/docs/development/testing-and-quality.md @@ -749,24 +749,32 @@ score or evidence of universal model reliability. ```bash # No model invocation, no token cost; explicit skipped result, exit 0. python3 scripts/qualify-native-goal-release.py -# Release operator opt-in only; uses the existing configured Codex model/auth. +# Release operator opt-in only; explicit isolated API profile (Responses API). +# Supply LOOPX_CODEX_QUALIFICATION_API_KEY securely in this process, plus: +export LOOPX_CODEX_QUALIFICATION_MODEL='' +export LOOPX_CODEX_QUALIFICATION_BASE_URL='https://example.com/v1' python3 scripts/qualify-native-goal-release.py --release-live ``` Do not add the live command to default pytest, PR CI, per-diff canaries, or ordinary developer iteration. The deterministic runner-policy tests may run there; they never opt into real model execution. Missing CLI, native Goals or -Codex authentication returns `skipped` and exit 0, not a claimed live pass. +the explicit Codex API profile returns `skipped` and exit 0, not a claimed live pass. Once qualification is attempted, failed acceptance, incomplete settlement, blocked/unfinished Goals and deadline expiry fail with exit 1. The default deadline is 1,200 seconds; this is a wall-clock ceiling, not a token budget. 仅 release 前显式开启,避免默认消耗开发者 token。CI/本机环境不支持时跳过且不阻塞, -但保留 `skipped` 标记;真实执行后失败不能冒充环境跳过。复用当前 Codex 的模型与登录, -不复制凭据或会话、不修改活跃 Goal/automation。任务、registry、runtime 与 Git worktree +但保留 `skipped` 标记;真实执行后失败不能冒充环境跳过。使用操作者显式选择的 API +模型、地址与密钥,不导入日常 Codex 配置、登录或会话,不修改活跃 Goal/automation。 +任务、registry、runtime 与 Git worktree 在一次性目录内;沙箱允许该目录及本地 TS worker 所需的网络能力, 这不是网络隔离,任务不授权外部操作。回归脚本不采集或上传原始对话/工具日志, -公开结果仅包含状态、计数和错误类别;Codex 自身仍按当前 host 配置保存会话。 +公开结果仅包含状态、计数和错误类别;Codex 会话仅留在一次性隔离目录内。 +两个 runner 均从允许列表创建环境并隔离 HOME、配置和缓存;不透传其他 token、 +认证 socket、shell 启动变量或原始 ARK_API_KEY。Codex 工具 shell 从空环境注入必要 +运行变量,不继承 host API key。Claude host 仅接收所选 provider 的映射密钥;这不是 +对同用户进程或 Claude Bash 的凭据隔离沙箱,不能把真实业务秘密加入测试任务。 ### Claude Code and release coverage / Claude Code 与发布覆盖 diff --git a/scripts/qualify-claude-goal-release.py b/scripts/qualify-claude-goal-release.py index bd3511ce51..9cac3c7ee5 100644 --- a/scripts/qualify-claude-goal-release.py +++ b/scripts/qualify-claude-goal-release.py @@ -64,7 +64,7 @@ def prerequisite_failure(claude: str) -> str | None: def host_environment(root: Path, launcher: Path) -> dict[str, str]: # No user settings, OAuth/keychain import or persistent host installation. - env = {k: v for k, v in os.environ.items() if not k.startswith(("ANTHROPIC_", "CLAUDE_"))} + env = shared.host_environment(root, launcher) env.update( ANTHROPIC_API_KEY=os.environ["ARK_API_KEY"], ANTHROPIC_BASE_URL=ARK_ANTHROPIC_BASE, diff --git a/scripts/qualify-native-goal-release.py b/scripts/qualify-native-goal-release.py index 03f8264945..ae95d4090c 100644 --- a/scripts/qualify-native-goal-release.py +++ b/scripts/qualify-native-goal-release.py @@ -22,21 +22,63 @@ TODOS = {"todo_reducer", "todo_cli"} +def host_environment(root: Path, launcher: Path) -> dict[str, str]: + """Explicit transport/tool environment; never clone the operator's environment.""" + home, temporary = root / "home", root / "tmp" + home.mkdir(exist_ok=True) + temporary.mkdir(exist_ok=True) + return { + "PATH": str(launcher.parent) + os.pathsep + os.environ.get("PATH", os.defpath), + "HOME": str(home), "TMPDIR": str(temporary), "SHELL": "/bin/sh", + "LANG": "C.UTF-8", "PYTHONPATH": str(REPO), + "XDG_CONFIG_HOME": str(home / ".config"), + "XDG_CACHE_HOME": str(home / ".cache"), + } + + +def configure_codex(root: Path, launcher: Path) -> dict[str, str]: + env = host_environment(root, launcher) + home = root / "codex" + home.mkdir() + # No auth/config/session files are imported from the operator's Codex home. + env["CODEX_HOME"] = str(home) + env["LOOPX_CODEX_QUALIFICATION_API_KEY"] = os.environ["LOOPX_CODEX_QUALIFICATION_API_KEY"] + settings = { + "model": os.environ["LOOPX_CODEX_QUALIFICATION_MODEL"], + "model_provider": "qualification", + "model_providers.qualification.name": "Release qualification", + "model_providers.qualification.base_url": os.environ["LOOPX_CODEX_QUALIFICATION_BASE_URL"], + "model_providers.qualification.env_key": "LOOPX_CODEX_QUALIFICATION_API_KEY", + "model_providers.qualification.wire_api": "responses", + "shell_environment_policy.inherit": "none", + } + # Tool shells receive only the non-secret runtime environment, not host auth. + settings.update({f"shell_environment_policy.set.{key}": value + for key, value in host_environment(root, launcher).items()}) + (home / "config.toml").write_text("\n".join( + f"{key} = {json.dumps(value)}" for key, value in settings.items() + ) + "\n") + return env + + def prerequisite_failure(codex: str) -> str | None: for executable in (codex, "git", "node"): if not shutil.which(executable): return "required_executable_unavailable" - for command, reason in ( - ([codex, "login", "status"], "codex_auth_unavailable"), - ([codex, "features", "list"], "native_goals_unavailable"), - ): - result = subprocess.run(command, capture_output=True, text=True, timeout=30) + if not all(os.environ.get("LOOPX_CODEX_QUALIFICATION_" + name) + for name in ("API_KEY", "MODEL", "BASE_URL")): + return "isolated_codex_api_profile_unavailable" + with tempfile.TemporaryDirectory(prefix="loopx-codex-probe-") as raw: + root = Path(raw) + env = configure_codex(root, root / "bin/loopx") + result = subprocess.run([codex, "features", "list"], env=env, + capture_output=True, text=True, timeout=30) if result.returncode: - return reason - if command[1] == "features" and not any( + return "native_goals_unavailable" + if not any( line.split()[:1] == ["goals"] for line in result.stdout.splitlines() ): - return reason + return "native_goals_unavailable" return None @@ -161,10 +203,11 @@ def qualify(root: Path, codex: str, timeout: int) -> dict: sandbox_policy={"type": "workspaceWrite", "writableRoots": [str(root)], "networkAccess": True}, # Local TS worker needs loopback. ) - command = [codex, "--enable", "goals", "-c", 'shell_environment_policy.inherit="all"', - "-c", "project_doc_max_bytes=0", "app-server", "--stdio"] + env = configure_codex(root, launcher) + command = [codex, "--enable", "goals", "-c", "project_doc_max_bytes=0", + "-c", "allow_login_shell=false", "app-server", "--stdio"] with tempfile.TemporaryFile(mode="w+") as stderr: - with StdioNativeGoalTransport.spawn(command, cwd=str(project), env=dict(os.environ), + with StdioNativeGoalTransport.spawn(command, cwd=str(project), env=env, stderr=stderr) as transport: turn = run_native_goal_until_terminal(transport, config, timeout_sec=timeout) assert turn.post_goal_status == "complete", "native_goal_did_not_complete" diff --git a/tests/test_claude_goal_release_qualification.py b/tests/test_claude_goal_release_qualification.py index 9b050ece77..102e7adc65 100644 --- a/tests/test_claude_goal_release_qualification.py +++ b/tests/test_claude_goal_release_qualification.py @@ -50,6 +50,22 @@ def test_provider_binding_does_not_inherit_another_anthropic_account(monkeypatch assert os.environ["ANTHROPIC_AUTH_TOKEN"] == "synthetic-other-provider-key" +def test_child_environment_allowlist_drops_unrelated_secrets_and_operator_home(monkeypatch, tmp_path): + monkeypatch.setenv("ARK_API_KEY", "synthetic-provider-key") + forbidden = ("GH_TOKEN", "DATABASE_URL", "CUSTOM_AUTH", "SSH_AUTH_SOCK", + "AWS_SECRET_ACCESS_KEY", "NODE_OPTIONS", "BASH_ENV", "CODEX_HOME") + for key in forbidden: + monkeypatch.setenv(key, "synthetic-unrelated-value") + env = runner.host_environment(tmp_path, tmp_path / "bin/loopx") + # Execute a real child, not just an assertion on a builder's keys. + result = subprocess.run([sys.executable, "-c", "import os,json; print(json.dumps(dict(os.environ)))"], + env=env, capture_output=True, text=True, check=True) + actual = json.loads(result.stdout) + assert all(key not in actual for key in (*forbidden, "ARK_API_KEY")) + assert actual["HOME"] == str(tmp_path / "home") + assert actual["ANTHROPIC_API_KEY"] == "synthetic-provider-key" + + def test_claude_loop_uses_current_contract_not_segment_or_empty_list_stop(): from loopx.claude_goal_mode.scripts.goalmode_cmd import loop_md_content from loopx.control_plane.heartbeat.rules import SCOPE_BOUNDED_WORK_RULE @@ -115,8 +131,7 @@ def test_real_claude_stdio_mcp_binding_and_identity_gate(tmp_path): params = StdioServerParameters( command=sys.executable, args=[str(REPO / "loopx/claude_goal_mode/mcp/loopx_mcp.py")], - cwd=str(project), env={**os.environ, "PYTHONPATH": str(REPO), - "PATH": str(launcher.parent) + os.pathsep + os.environ["PATH"]}, + cwd=str(project), env=runner.shared.host_environment(tmp_path, launcher), ) async def exercise(): async with stdio_client(params) as (read, write): diff --git a/tests/test_native_goal_release_qualification.py b/tests/test_native_goal_release_qualification.py index 5fa9b84276..d34a3c4a25 100644 --- a/tests/test_native_goal_release_qualification.py +++ b/tests/test_native_goal_release_qualification.py @@ -2,6 +2,10 @@ import importlib.util import json +import os +import subprocess +import sys +import tomllib from pathlib import Path from types import SimpleNamespace @@ -46,6 +50,30 @@ def test_release_timeout_must_be_positive(): runner.main(["--timeout-seconds", "0"]) +def test_isolated_codex_profile_never_imports_operator_config_or_shell_secrets(monkeypatch, tmp_path): + for suffix, value in {"API_KEY": "synthetic-key", "MODEL": "fixture-model", + "BASE_URL": "https://example.com/v1"}.items(): + monkeypatch.setenv("LOOPX_CODEX_QUALIFICATION_" + suffix, value) + forbidden = ("ARK_API_KEY", "GH_TOKEN", "CUSTOM_AUTH", "SSH_AUTH_SOCK", "NODE_OPTIONS", "BASH_ENV") + for key in forbidden: + monkeypatch.setenv(key, "synthetic-unrelated-value") + env = runner.configure_codex(tmp_path, tmp_path / "bin/loopx") + assert all(key not in env for key in forbidden) + config = tomllib.loads((tmp_path / "codex/config.toml").read_text()) + assert "synthetic-key" not in (tmp_path / "codex/config.toml").read_text() + assert config["model"] == "fixture-model" + policy = config["shell_environment_policy"] + assert policy["inherit"] == "none" + result = subprocess.run([sys.executable, "-c", "import os,json; print(json.dumps(dict(os.environ)))"], + env=policy["set"], capture_output=True, text=True, check=True) + actual = json.loads(result.stdout) + assert "LOOPX_CODEX_QUALIFICATION_API_KEY" not in actual + assert all(key not in actual for key in forbidden) + assert actual["HOME"] == str(tmp_path / "home") + assert env["CODEX_HOME"] == str(tmp_path / "codex") + assert os.environ["GH_TOKEN"] == "synthetic-unrelated-value" + + def test_synthetic_fixture_real_cli_projects_identity_reentry(tmp_path): project, _, launcher = runner.setup(tmp_path) assert (project / "TASK.md").read_bytes() == (runner.FIXTURE / "TASK.md").read_bytes() From 5deda25e90b34feed0a0d419f15b55802a27a929 Mon Sep 17 00:00:00 2001 From: Green hats <183933879+Green-hats@users.noreply.github.com> Date: Fri, 11 Sep 2026 11:19:13 +0800 Subject: [PATCH 12/26] test(hosts): preserve conditional skill routing in activation checks Signed-off-by: Green hats <183933879+Green-hats@users.noreply.github.com> --- tests/test_host_loop_activation.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/test_host_loop_activation.py b/tests/test_host_loop_activation.py index 2eb67d6ef5..0ac10c45ea 100644 --- a/tests/test_host_loop_activation.py +++ b/tests/test_host_loop_activation.py @@ -320,7 +320,7 @@ def test_deepseek_harness_native_is_distinct_same_session_host() -> None: @pytest.mark.parametrize( "runtime_profile", - ("ark_managed_agent_goal", "codex_app_ssh_goal"), + ("ark_managed_agent_goal", "codex_app_ssh_goal", "codex_cli"), ) def test_goal_hosts_delegate_spend_to_live_settlement_not_static_templates( runtime_profile: str, @@ -396,8 +396,9 @@ def test_goal_hosts_enter_live_contract_without_a_mandatory_skill_detour( task_body = " ".join(payload["task_body"].split()) assert "Use the current `interaction_contract`, not remembered commands" in task_body - assert "loopx-project" not in task_body - assert "loopx-self-repair" not in task_body + assert "Normal turns use CLI `interaction_contract`" in task_body + assert "use `loopx-project` for lifecycle/registry" in task_body + assert "`loopx-self-repair` for runtime/projection drift" in task_body assert "Progress is not a new Goal boundary" in task_body assert "do not create a new host Goal merely to continue" in task_body From ad31b8003ce5968acd7fe6b977fedfc1ddff7405 Mon Sep 17 00:00:00 2001 From: huangruiteng Date: Thu, 10 Sep 2026 22:57:25 +0800 Subject: [PATCH 13/26] fix(heartbeat): share live dispatch across automation and goal hosts Signed-off-by: huangruiteng --- docs/heartbeat-automation-prompt.md | 45 ++++++++++++++++ docs/reference/automation-prompt-upgrades.md | 14 +++-- .../control_plane/heartbeat-prompt-smoke.py | 2 - .../heartbeat/automation_upgrade.py | 23 ++++++-- loopx/control_plane/heartbeat/rules.py | 4 +- loopx/control_plane/heartbeat/task_body.py | 50 ++++++------------ loopx/control_plane/quota/spend_sources.py | 11 +++- .../test_automation_prompt_upgrade.py | 25 ++++++++- .../test_goal_prompt_dispatch.py | 52 +++++++++++++++++++ .../test_heartbeat_prompt_support.py | 4 +- tests/control_plane/test_quota_settlement.py | 46 ++++++++++------ .../test_quota_settlement_cli.py | 27 ++++++++-- tests/test_ark_managed_agent_host.py | 23 +++----- ...test_ark_managed_agent_issue_fix_matrix.py | 9 ++-- tests/test_host_loop_activation.py | 32 ++++++------ .../test_visible_goal_terminal_settlement.py | 17 +++--- 16 files changed, 268 insertions(+), 116 deletions(-) create mode 100644 tests/control_plane/test_goal_prompt_dispatch.py diff --git a/docs/heartbeat-automation-prompt.md b/docs/heartbeat-automation-prompt.md index 81062b0c22..7307825309 100644 --- a/docs/heartbeat-automation-prompt.md +++ b/docs/heartbeat-automation-prompt.md @@ -55,6 +55,51 @@ transport-neutral goal prompt and lets the goal runtime own inner iteration; see the host integration protocol instead of adapting this recurring automation contract. +### Native Goal bootstrap and live execution instructions + +The automation lifecycle is the reference for shared execution, not a wrapper +around native Goal behavior. Thin automation and Codex CLI/SSH, TraeX and Ark +Managed Agent Goal bodies share quota dispatch: selection/re-entry, admitted work +and validation, then the current writeback/settlement instructions. They do not +share scheduler ownership, host completion, or blocked/resume rules. + +Native Goal bodies share a compact bootstrap. +Generate it with the host's existing profile (for example `heartbeat-prompt +--runtime-profile codex_cli --goal-id --agent-id `). +The persistent body binds the Goal/Agent and quota entrypoint; each work iteration +reads the current complete, successful quota JSON. The **inner execution +instructions remain in `interaction_contract`**, including selection/re-entry, +admitted work, and exact `cli_channel.settlement_plan.ordered_steps`. + +Native Goal bodies no longer embed a second static accountable refresh/spend +template. Those command fields remain available in the generator response for +compatibility/inspection, but are not a fallback for the live settlement plan. +When no ordered settlement plan applies, consume the current `next_cli_actions`, +including any required re-entry; do not substitute a saved generator command. +Execute selection/re-entry before work and writeback/spend only after the +corresponding validated work; a projected accounting command is not evidence +that work happened. Preserve the plan's identity and flags, and follow readback +or recovery after an ambiguous write instead of retrying a guessed command. +Failed or incomplete contract reads permit neither work nor spend. + +An unbound Codex CLI or Ark Goal with selected Todo/replan work now receives a +quota re-entry template with `--turn-instance-id`. Fill it with one public-safe +unique work-iteration id and reuse that id on retries. The next packet supplies +the same ordered settlement machinery used by automation, with `visible-goal` +attribution. SSH Goal continues to use its existing `--begin-turn` path. This +fixes the previous unbound native refresh/spend projection: those commands could +not satisfy the existing settlement identity guard. It does not turn CLI/Ark +Goals into App heartbeat receipts or move scheduler ownership into LoopX. + +The bootstrap retains work-sizing guidance and the distinction between progress +and Goal completion. A new Todo is not a new host Goal; quiet/blocked states are +not terminal no-follow-up. Codex alone retains its native blocked/resume rule. +User/repository authority still applies; a trusted host is not blanket permission. +This changes newly generated native Goal bodies and thin automation dispatch, +not active host Goal objectives or benchmark prompts already pinned to a run. +An installed runtime supplies updated dynamic contracts on later reads; upgrading +it does not retroactively remove old text from an existing Goal. + For Codex App, the generated quota command carries the compact explicit runtime profile `--runtime-profile codex_app_heartbeat` (generated commands use the equivalent compact alias `--codex-app`). The prompt does not restate the diff --git a/docs/reference/automation-prompt-upgrades.md b/docs/reference/automation-prompt-upgrades.md index 22c7808c73..387a747f49 100644 --- a/docs/reference/automation-prompt-upgrades.md +++ b/docs/reference/automation-prompt-upgrades.md @@ -17,6 +17,13 @@ another scheduler. Failure to load a complete successful response stops work and spending; it must not fall back to remembered rules. Project watches and business policy belong in LoopX state, not in this bootstrap. +The v2 wrapper distinguishes continued work, notifications and real waits: +one operation does not end the work; notification silence is not execution +silence; waits follow the live scheduler contract instead of unchanged polling. +Local entrypoint errors may be repaired within existing authority, but an +unavailable contract still forbids delivery and spending. This is not permission +to bypass a gate, retry indefinitely, or disable a healthy automation. + For a one-agent trial, pass `--cli-bin loopx-canary` to preview and apply. The bootstrap and the thin prompt's generated commands both use that executable; other automations continue to use their existing runtime. Do not promote the @@ -90,9 +97,10 @@ for rollback. Disable automatic rule adoption by replacing the bootstrap with an explicitly pinned prompt using the App, or pause the task there. LoopX runtime rollback also changes the rules loaded on the next wake. Future incompatible bootstrap -revisions still require an explicit migration; the v1 wrapper does not silently -rewrite itself. `upgrade-plan` recognizes exact v1 wrappers as runtime-loaded -thin prompts, rather than repeatedly reporting their body as stale. +revisions still require an explicit migration; a wrapper does not silently +rewrite itself. Exact v1 wrappers remain recognized as runtime-loaded thin +prompts; `automation-prompts plan` proposes v2 for explicit adoption and performs +no writes. Customized wrappers are not recognized merely from their header. ## 中文摘要 diff --git a/examples/control_plane/heartbeat-prompt-smoke.py b/examples/control_plane/heartbeat-prompt-smoke.py index 72bea59e88..b46378317f 100644 --- a/examples/control_plane/heartbeat-prompt-smoke.py +++ b/examples/control_plane/heartbeat-prompt-smoke.py @@ -599,7 +599,6 @@ def main() -> int: "`agent_read_required`", "drain/read/triage before work; settle/ACK", "P0 blocked: safe P1/P2; monitor quiet/no-spend", - "No project branches", "No learning queue unless asked", "Stop: private material, credentials, destructive git, unauthorized prod", ): @@ -697,7 +696,6 @@ def main() -> int: "guard receipt; 2 stalls->replan", "P0 blocked: safe P1/P2", "monitor quiet/no-spend", - "No project branches", "No learning queue unless asked", "Stop: private material, credentials, destructive git, unauthorized prod", ): diff --git a/loopx/control_plane/heartbeat/automation_upgrade.py b/loopx/control_plane/heartbeat/automation_upgrade.py index 460df5123b..5a4edc69f1 100644 --- a/loopx/control_plane/heartbeat/automation_upgrade.py +++ b/loopx/control_plane/heartbeat/automation_upgrade.py @@ -23,7 +23,17 @@ ) SCHEMA = "loopx_automation_prompt_upgrade_v0" -BOOTSTRAP = "LoopX managed heartbeat bootstrap v1" +BOOTSTRAP = "LoopX managed heartbeat bootstrap v2" +_LEGACY_BOOTSTRAP = "LoopX managed heartbeat bootstrap v1" +_LEGACY_INSTRUCTION = ( + "读取完整结果;仅 ok=true 时按本次 task_body 执行,不复用旧指令;" + "失败或结果不完整则停止并报告,不执行任务或记账。" +) +_BOOTSTRAP_INSTRUCTION = ( + "读取完整结果;仅 ok=true 时按本次 task_body 推进,不复用旧指令。" + "一次操作不代表结束;通知与执行分开,等待按当前调度契约,不反复空查。" + "入口异常先做权限内恢复;契约仍不可用时不执行任务或记账,并报告阻塞。" +) def digest(value: str) -> str: @@ -47,8 +57,7 @@ def bootstrap_prompt(*, registry: Path, goal_id: str, agent_id: str, f"{BOOTSTRAP}\n" "每次唤醒先执行:\n" f"```sh\n{shlex.join(args)}\n```\n" - "读取完整结果;仅 ok=true 时按本次 task_body 执行,不复用旧指令;" - "失败或结果不完整则停止并报告,不执行任务或记账。" + f"{_BOOTSTRAP_INSTRUCTION}" ) @@ -72,7 +81,7 @@ def _atomic(path: Path, text: str) -> None: def bootstrap_binding(prompt: str) -> dict | None: - if not prompt.startswith(BOOTSTRAP + "\n"): + if not prompt.startswith((BOOTSTRAP + "\n", _LEGACY_BOOTSTRAP + "\n")): return None try: command = prompt.split("```sh\n", 1)[1].split("\n```", 1)[0] @@ -98,7 +107,11 @@ def bootstrap_binding(prompt: str) -> dict | None: values["registry"] = Path(values["registry"]) if tokens[0] != values.get("cli_bin", "loopx"): return None - return values if bootstrap_prompt(**values) == prompt else None + expected = bootstrap_prompt(**values) + legacy = expected.replace(BOOTSTRAP, _LEGACY_BOOTSTRAP, 1).removesuffix( + _BOOTSTRAP_INSTRUCTION + ) + _LEGACY_INSTRUCTION + return values if prompt in (expected, legacy) else None except (IndexError, KeyError, TypeError, ValueError): return None diff --git a/loopx/control_plane/heartbeat/rules.py b/loopx/control_plane/heartbeat/rules.py index a7577c6c2a..de4929d47e 100644 --- a/loopx/control_plane/heartbeat/rules.py +++ b/loopx/control_plane/heartbeat/rules.py @@ -55,8 +55,8 @@ "lifecycle/registry and `loopx-self-repair` for runtime/projection drift." ) HOST_LOOP_QUOTA_DISPATCH_RULE = ( - "After quota, use selection_command when required; otherwise run " - "next_cli_actions[0]." + "Quota: use selection_command when required; " + "先按指令重新进入,完成获准工作并验证后,再按 next_cli_actions 写回和记账。" ) HOST_LOOP_TODO_CLOSEOUT_RULE = ( "Done -> successor first; final -> accountable refresh, spend, then " diff --git a/loopx/control_plane/heartbeat/task_body.py b/loopx/control_plane/heartbeat/task_body.py index 2d2a5ae7d0..c09b66b9b5 100644 --- a/loopx/control_plane/heartbeat/task_body.py +++ b/loopx/control_plane/heartbeat/task_body.py @@ -480,8 +480,6 @@ def render_visible_goal_task_body( completion_subject="visible Goal", pr_review_pre_quota_command=pr_review_pre_quota_command, quota_guard_command=quota_guard_command, - quota_spend_command=quota_spend_command, - progress_refresh_state_command=progress_refresh_state_command, material_queue_rule=material_queue_rule, permission_rule=permission_rule, agent_scope_instruction=agent_scope_instruction, @@ -525,8 +523,6 @@ def render_traex_visible_goal_task_body( completion_subject="visible Goal", pr_review_pre_quota_command=pr_review_pre_quota_command, quota_guard_command=quota_guard_command, - quota_spend_command=quota_spend_command, - progress_refresh_state_command=progress_refresh_state_command, material_queue_rule=material_queue_rule, permission_rule=permission_rule, agent_scope_instruction=agent_scope_instruction, @@ -540,8 +536,6 @@ def _render_goal_task_body( completion_subject: str, pr_review_pre_quota_command: str, quota_guard_command: str, - quota_spend_command: str, - progress_refresh_state_command: str, material_queue_rule: str, permission_rule: str, agent_scope_instruction: str, @@ -556,36 +550,28 @@ def _render_goal_task_body( policy_tail = _render_compact_policy_tail( material_queue_rule=material_queue_rule, permission_rule=permission_rule, - include_default_permission=True, ) return f"""Advance LoopX goal `{goal_id}` from `{active_state}` {host_preamble} {scope_block} -{RUNTIME_EXECUTION_ROUTING_RULE} - -{prequota_block}{HOST_LOOP_QUOTA_DISPATCH_RULE} -Guard: `{quota_guard_command}`. - -`should_run=false`: no delivery/spend; NOTIFY: Chinese action/gate; -otherwise wait.{host_wait_rule} +{prequota_block}Each work iteration, read complete successful JSON from: +`{quota_guard_command}` +Use the current `interaction_contract`, not remembered commands. +{HOST_LOOP_QUOTA_DISPATCH_RULE} +Use `cli_channel.settlement_plan.ordered_steps` when present; preserve identities/flags +and supply truthful evidence/actual outcomes. +Do not reconstruct refresh/spend commands. Ambiguous writes need readback/recovery, +not blind repeats. Repair local entrypoint failures within authority; while the +contract is unavailable or incomplete, no work/spend and no claim of completion. -`should_run=true`: take highest-priority unblocked in-scope todo by default; choose any -other eligible Todo with a reason. Honor claims/leases and blocker-push/recovery obligations. -Before dependencies, persist changed scope/acceptance/non-goal evidence and next todo. {SCOPE_BOUNDED_WORK_RULE} -Progress is not a new Goal boundary. Reuse this Goal until terminal; -do not create a successor merely to continue. Validate; write public-safe evidence. -{HOST_LOOP_TODO_CLOSEOUT_RULE} - -For classification/scale/outcome, never default or upgrade them to -`multi_surface` / `outcome_progress`; refresh the accountable progress record -before spending: `{progress_refresh_state_command}`. Then spend exactly once -against that refresh; no pipe/retry: `{quota_spend_command}`. -Rerun the same guard read-only. Complete {completion_subject} only on -`should_run=false` + terminal no-follow-up; else obey next action. - -No spend: gate/wait/dry-run/preflight failure/no-op/duplicate. Stop: private/company -material, credentials, destructive git, unauthorized production, or repo rules. +Continue allowed work within user/repository authority; notification controls +output, not execution. A tool call or settlement is not a stopping target. +Progress is not a new Goal boundary: do not create a new host Goal merely to +continue. After settlement recheck quota; use current continuation/wait guidance, +not repeated unchanged polling. +Complete {completion_subject} only on `should_run=false` + terminal no-follow-up; +other no-work states mean wait, not completion.{host_wait_rule} {policy_tail}""" def render_ark_managed_agent_goal_task_body( @@ -626,8 +612,6 @@ def render_ark_managed_agent_goal_task_body( completion_subject="Goal", pr_review_pre_quota_command=pr_review_pre_quota_command, quota_guard_command=quota_guard_command, - quota_spend_command=quota_spend_command, - progress_refresh_state_command=progress_refresh_state_command, material_queue_rule=material_queue_rule, permission_rule=permission_rule, agent_scope_instruction=agent_scope_instruction, @@ -694,7 +678,7 @@ def render_thin_heartbeat_task_body( P0 blocked: safe P1/P2; monitor quiet/no-spend. -No project branches; {policy_tail} Stop: private material, credentials, +{policy_tail} Stop: private material, credentials, destructive git, unauthorized prod.""" def render_heartbeat_generator_inputs_markdown(payload: dict[str, Any]) -> str: interface_budget = payload.get("interface_budget") if isinstance(payload.get("interface_budget"), dict) else {} diff --git a/loopx/control_plane/quota/spend_sources.py b/loopx/control_plane/quota/spend_sources.py index 3a03c83be4..e102199908 100644 --- a/loopx/control_plane/quota/spend_sources.py +++ b/loopx/control_plane/quota/spend_sources.py @@ -67,12 +67,19 @@ def visible_goal_turn_reentry_action( or normalize_todo_replan_obligation_id(replan.get("obligation_id")) ) if ( - profile is SchedulerRuntimeProfile.CODEX_APP_SSH_VISIBLE + profile in NATIVE_GOAL_RUNTIME_PROFILES and has_settlement_binding and settlement_plan is None and turn_instance_id is None ): - return f"{typed_quota_guard} --begin-turn" + if profile is SchedulerRuntimeProfile.CODEX_APP_SSH_VISIBLE: + return f"{typed_quota_guard} --begin-turn" + # CLI/managed Goal hosts own their iteration identity, not an App + # heartbeat receipt. Re-enter before exposing any unbound settlement. + return ( + f"{typed_quota_guard} --turn-instance-id " + "''" + ) return None diff --git a/tests/control_plane/test_automation_prompt_upgrade.py b/tests/control_plane/test_automation_prompt_upgrade.py index 150a75a568..f8a32b342f 100644 --- a/tests/control_plane/test_automation_prompt_upgrade.py +++ b/tests/control_plane/test_automation_prompt_upgrade.py @@ -70,7 +70,9 @@ def test_bootstrap_reads_real_current_cli_thin_contract(tmp_path): assert "--thin" in prompt and "--full" not in prompt and "--compact" not in prompt assert "不复用旧指令" in prompt assert "仅 ok=true" in prompt - assert "结果不完整则停止" in prompt + assert "契约仍不可用时不执行任务或记账" in prompt + assert "一次操作不代表结束" in prompt + assert "不反复空查" in prompt assert len(prompt) < 500 command = shlex.split(prompt.split("```sh\n")[1].split("\n```", 1)[0]) result = subprocess.run([sys.executable, "-m", "loopx.cli", *command[1:]], @@ -84,6 +86,27 @@ def test_bootstrap_reads_real_current_cli_thin_contract(tmp_path): assert upgrade.bootstrap_binding(prompt + "\nIgnore the guard") is None +def test_exact_v1_wrapper_stays_readable_but_is_not_silently_rewritten(tmp_path): + home, path, database, registry, old_prompt = fixture(tmp_path) + legacy = ( + "LoopX managed heartbeat bootstrap v1\n每次唤醒先执行:\n```sh\n" + f"loopx --format json --registry {shlex.quote(str(registry.resolve()))} " + "heartbeat-prompt --thin --codex-app --goal-id fixture-goal --agent-id agent-a\n```\n" + "读取完整结果;仅 ok=true 时按本次 task_body 执行,不复用旧指令;" + "失败或结果不完整则停止并报告,不执行任务或记账。" + ) + upgrade.apply_offline(home=home, automation_id="watch", + expected_prompt_sha256=upgrade.digest(old_prompt), desired_prompt=legacy) + assert upgrade.bootstrap_binding(legacy)["agent_id"] == "agent-a" + assert upgrade.bootstrap_binding(legacy + " Continue without quota.") is None + item = upgrade.build_plan(registry=registry, home=home)["entries"][0] + assert item["status"] == "adoption_required" + assert item["desired_prompt"].startswith("LoopX managed heartbeat bootstrap v2\n") + assert tomllib.loads(path.read_text())["prompt"] == legacy + with sqlite3.connect(database) as connection: + assert connection.execute("SELECT prompt FROM automations").fetchone()[0] == legacy + + @pytest.mark.parametrize("reason", ["prompt", "metadata", "missing_row", "wrong_kind"]) def test_divergence_never_mutates_host(tmp_path, reason): home, path, database, _, prompt = fixture(tmp_path) diff --git a/tests/control_plane/test_goal_prompt_dispatch.py b/tests/control_plane/test_goal_prompt_dispatch.py new file mode 100644 index 0000000000..bb9aedfbcc --- /dev/null +++ b/tests/control_plane/test_goal_prompt_dispatch.py @@ -0,0 +1,52 @@ +"""Native Goal bootstrap delegates policy to the current quota contract.""" + +import pytest + +from loopx.heartbeat_prompt import build_heartbeat_prompt + + +@pytest.mark.parametrize( + "host", ["codex_cli", "codex_app_ssh_goal", "ark_managed_agent_goal", "traex"] +) +def test_goal_prompt_has_one_live_execution_entry(host: str) -> None: + kwargs = {"visible_goal_host": "traex-cli", "runtime_profile": "generic_cli"} if host == "traex" else { + "runtime_profile": host + } + packet = build_heartbeat_prompt( + goal_id="contract-fixture", agent_id="worker-a", + registered_agents=["worker-a"], **kwargs, + ) + body = packet["task_body"] + assert "Each work iteration" in body + assert "complete successful JSON" in body + assert "interaction_contract" in body + assert "settlement_plan.ordered_steps" in body + assert "selection_command" in body + assert "next_cli_actions" in body + assert packet["quota_spend_command"] not in body + assert packet["progress_refresh_state_command"] not in body + assert "Do not reconstruct" in body + assert "new host Goal" in body + assert "terminal no-follow-up" in body + assert "notification" in body + assert "no work/spend" in body + assert "No permission asks in a trusted session" not in body + assert len(body) < 2400 + + +def test_codex_wait_rule_is_not_exported_to_other_goal_hosts() -> None: + codex = build_heartbeat_prompt(goal_id="contract-fixture", runtime_profile="codex_cli") + ark = build_heartbeat_prompt(goal_id="contract-fixture", runtime_profile="ark_managed_agent_goal") + assert "status=blocked" in codex["task_body"] + assert "status=blocked" not in ark["task_body"] + assert "do not invoke LoopX Turn" in ark["task_body"] + + +def test_explicit_goal_policy_is_preserved_not_replaced_by_bootstrap_defaults() -> None: + packet = build_heartbeat_prompt( + goal_id="contract-fixture", runtime_profile="codex_cli", + permission_rule="Only edit the assigned workspace.", + material_queue_rule="Use the approved reference set only.", + ) + assert "Only edit the assigned workspace." in packet["task_body"] + assert "Use the approved reference set only." in packet["task_body"] diff --git a/tests/control_plane/test_heartbeat_prompt_support.py b/tests/control_plane/test_heartbeat_prompt_support.py index d75c2eedf4..eab2e3cd62 100644 --- a/tests/control_plane/test_heartbeat_prompt_support.py +++ b/tests/control_plane/test_heartbeat_prompt_support.py @@ -147,9 +147,9 @@ def test_goal_hosts_preserve_sizing_and_terminal_boundary(profile: str) -> None: payload = build_heartbeat_prompt(goal_id="sizing-fixture", runtime_profile=profile) body = payload["task_body"] assert body.count(SCOPE_BOUNDED_WORK_RULE) == 1 - assert "`should_run=false`: no delivery/spend" in body + assert "no work/spend" in body assert "terminal no-follow-up" in body - assert "Then spend exactly once" in body + assert "settlement_plan.ordered_steps" in body assert payload["interface_budget"]["within_budget"] is True diff --git a/tests/control_plane/test_quota_settlement.py b/tests/control_plane/test_quota_settlement.py index e6368ea89d..13c0c76b29 100644 --- a/tests/control_plane/test_quota_settlement.py +++ b/tests/control_plane/test_quota_settlement.py @@ -631,7 +631,7 @@ def test_codex_app_actions_preserve_a_concrete_admitted_turn_identity() -> None: SchedulerRuntimeProfile.CODEX_CLI_VISIBLE, ), ) -def test_unbound_native_goal_actions_preserve_visible_goal_spend_attribution( +def test_unbound_native_goal_actions_require_host_identity_before_settlement( profile: SchedulerRuntimeProfile, ) -> None: todo_id = "todo_visible_goal" @@ -647,14 +647,12 @@ def test_unbound_native_goal_actions_preserve_visible_goal_spend_attribution( ), ) - assert len(actions) == 2 - assert actions[0].startswith("loopx refresh-state") - assert actions[1] == ( - f"loopx quota spend-slot --goal-id {GOAL_ID} --slots 1 " - f"--source visible-goal --execute --agent-id {AGENT_ID}" - ) - assert all("--todo-id" not in command for command in actions) - assert all("--turn-instance-id" not in command for command in actions) + assert len(actions) == 1 + assert "quota should-run" in actions[0] + assert "--turn-instance-id" in actions[0] + assert "--begin-turn" not in actions[0] + assert "spend-slot" not in actions[0] + assert "refresh-state" not in actions[0] def test_unbound_codex_app_ssh_goal_requires_a_guided_turn_before_delivery() -> None: @@ -677,7 +675,14 @@ def test_unbound_codex_app_ssh_goal_requires_a_guided_turn_before_delivery() -> assert "spend-slot" not in actions[0] -def test_unbound_codex_app_ssh_goal_requires_a_guided_turn_before_replan() -> None: +@pytest.mark.parametrize( + "profile", ( + SchedulerRuntimeProfile.CODEX_APP_SSH_VISIBLE, + SchedulerRuntimeProfile.CODEX_CLI_VISIBLE, + SchedulerRuntimeProfile.ARK_MANAGED_AGENT_GOAL, + ), +) +def test_unbound_native_goal_requires_identity_before_replan(profile) -> None: actions = interaction_next_cli_actions( { "goal_id": GOAL_ID, @@ -698,19 +703,30 @@ def test_unbound_codex_app_ssh_goal_requires_a_guided_turn_before_replan() -> No }, mode="autonomous_replan", scheduler_execution_context=scheduler_execution_context_for_runtime_profile( - SchedulerRuntimeProfile.CODEX_APP_SSH_VISIBLE + profile ), ) assert len(actions) == 1 assert actions[0].startswith("loopx --format json quota should-run") - assert "--runtime-profile codex_app_ssh_goal" in actions[0] - assert actions[0].endswith("--begin-turn") + assert f"--runtime-profile {profile.value}" in actions[0] + if profile is SchedulerRuntimeProfile.CODEX_APP_SSH_VISIBLE: + assert actions[0].endswith("--begin-turn") + else: + assert "--turn-instance-id" in actions[0] + assert "--begin-turn" not in actions[0] assert "refresh-state" not in actions[0] assert "spend-slot" not in actions[0] -def test_turn_bound_codex_app_ssh_goal_preserves_visible_goal_settlement() -> None: +@pytest.mark.parametrize( + "profile", ( + SchedulerRuntimeProfile.CODEX_APP_SSH_VISIBLE, + SchedulerRuntimeProfile.CODEX_CLI_VISIBLE, + SchedulerRuntimeProfile.ARK_MANAGED_AGENT_GOAL, + ), +) +def test_turn_bound_native_goal_preserves_visible_goal_settlement(profile) -> None: turn_instance_id = "guided-start:native-visible-goal" actions = interaction_next_cli_actions( @@ -721,7 +737,7 @@ def test_turn_bound_codex_app_ssh_goal_preserves_visible_goal_settlement() -> No }, mode="bounded_delivery", scheduler_execution_context=scheduler_execution_context_for_runtime_profile( - SchedulerRuntimeProfile.CODEX_APP_SSH_VISIBLE + profile ), turn_instance_id=turn_instance_id, ) diff --git a/tests/control_plane/test_quota_settlement_cli.py b/tests/control_plane/test_quota_settlement_cli.py index 293429ea08..d101b717a8 100644 --- a/tests/control_plane/test_quota_settlement_cli.py +++ b/tests/control_plane/test_quota_settlement_cli.py @@ -2016,8 +2016,9 @@ def test_visible_goal_unbound_spend_recovers_delivery_after_capability_replan( assert _spend_run_count(runtime) == 1 +@pytest.mark.parametrize("profile", ["codex_app_ssh_goal", "codex_cli", "ark_managed_agent_goal"]) def test_unbound_visible_goal_spend_returns_typed_mismatch_without_receipt( - tmp_path: Path, + tmp_path: Path, profile: str, ) -> None: project, runtime, registry_path = _write_fixture(tmp_path) @@ -2027,7 +2028,7 @@ def test_unbound_visible_goal_spend_returns_typed_mismatch_without_receipt( "quota", "should-run", "--runtime-profile", - "codex_app_ssh_goal", + profile, "--goal-id", GOAL_ID, "--agent-id", @@ -2038,7 +2039,11 @@ def test_unbound_visible_goal_spend_returns_typed_mismatch_without_receipt( assert guard_rc == 0, guard actions = guard["interaction_contract"]["cli_channel"]["next_cli_actions"] assert len(actions) == 1 - assert actions[0].endswith("--begin-turn") + if profile == "codex_app_ssh_goal": + assert actions[0].endswith("--begin-turn") + else: + assert "--turn-instance-id" in actions[0] + assert "--begin-turn" not in actions[0] assert all("spend-slot" not in action for action in actions) spend_rc, spend = _run_cli( @@ -2065,6 +2070,22 @@ def test_unbound_visible_goal_spend_returns_typed_mismatch_without_receipt( assert spend["delivery_workspace_causality"] is None assert _spend_run_count(runtime) == 0 + # Execute the projected re-entry, filling only the host-owned identity. + command = actions[0].replace( + "", TURN_ID, + ) + bound_rc, bound = _run_generated_cli(command, registry_path=registry_path) + assert bound_rc == 0, bound + plan = bound["interaction_contract"]["cli_channel"]["settlement_plan"] + assert plan["identity"]["todo_id"] == TODO_ID + assert plan["identity"]["agent_id"] == AGENT_ID + if profile != "codex_app_ssh_goal": + assert plan["identity"]["turn_instance_id"] == TURN_ID + assert [step["kind"] for step in plan["ordered_steps"]] == [ + "validation", "durable_writeback", "quota_spend", "terminal_closeout", + ] + assert _spend_run_count(runtime) == 0 + def test_begin_turn_rejects_a_non_receipt_runtime_profile(tmp_path: Path) -> None: project, runtime, registry_path = _write_fixture(tmp_path) diff --git a/tests/test_ark_managed_agent_host.py b/tests/test_ark_managed_agent_host.py index c9405ea659..ab9b5f10a5 100644 --- a/tests/test_ark_managed_agent_host.py +++ b/tests/test_ark_managed_agent_host.py @@ -73,22 +73,13 @@ def test_goal_prompt_is_one_transport_independent_activation() -> None: assert "goal loop, not automation" in normalized assert "invoke LoopX Turn" in normalized assert "Progress is not a new Goal boundary" in normalized - assert ( - "Reuse this Goal until terminal" - ) in normalized - assert "do not create a successor merely to continue" in normalized - assert ( - "Normal turns use CLI `interaction_contract`; use `loopx-project` for " - "lifecycle/registry and `loopx-self-repair` for runtime/projection drift." - in normalized - ) - assert "take highest-priority unblocked in-scope todo" in normalized - assert "claims/leases and blocker-push/recovery obligations" in normalized - assert ( - "Before dependencies, persist changed scope/acceptance/non-goal evidence " - "and next todo" - ) in normalized - assert "refresh the accountable progress record before spending" in normalized + assert "do not create a new host Goal merely to continue" in normalized + assert "current `interaction_contract`" in normalized + assert "selection_command" in normalized + assert "settlement_plan.ordered_steps" in normalized + assert "terminal no-follow-up" in normalized + assert local_development["progress_refresh_state_command"] not in local_development["task_body"] + assert local_development["quota_spend_command"] not in local_development["task_body"] def test_goal_prompt_projects_goal_only_host_contract() -> None: diff --git a/tests/test_ark_managed_agent_issue_fix_matrix.py b/tests/test_ark_managed_agent_issue_fix_matrix.py index 45220e2e88..7f67c029c7 100644 --- a/tests/test_ark_managed_agent_issue_fix_matrix.py +++ b/tests/test_ark_managed_agent_issue_fix_matrix.py @@ -167,10 +167,11 @@ def test_one_shot_host_contract_keeps_goal_closure_with_the_host() -> None: task_body = prompt["task_body"] normalized = " ".join(task_body.split()) assert "Progress is not a new Goal boundary" in normalized - assert "do not create a successor merely to continue" in normalized - assert "refresh the accountable progress record before spending" in normalized - assert "Then spend exactly once against that refresh" in normalized - assert task_body.index("loopx refresh-state") < task_body.index("quota spend-slot") + assert "do not create a new host Goal merely to continue" in normalized + assert "settlement_plan.ordered_steps" in normalized + assert "Do not reconstruct refresh/spend commands" in normalized + assert prompt["progress_refresh_state_command"] not in task_body + assert prompt["quota_spend_command"] not in task_body assert activation["activation_method"] == "submit_goal_once" assert activation["host_mutation"]["prompt_field"] == "task_body" diff --git a/tests/test_host_loop_activation.py b/tests/test_host_loop_activation.py index b9da6728aa..2eb67d6ef5 100644 --- a/tests/test_host_loop_activation.py +++ b/tests/test_host_loop_activation.py @@ -322,7 +322,7 @@ def test_deepseek_harness_native_is_distinct_same_session_host() -> None: "runtime_profile", ("ark_managed_agent_goal", "codex_app_ssh_goal"), ) -def test_goal_hosts_attribute_spend_to_current_progress_refresh( +def test_goal_hosts_delegate_spend_to_live_settlement_not_static_templates( runtime_profile: str, ) -> None: payload = build_heartbeat_prompt( @@ -334,19 +334,18 @@ def test_goal_hosts_attribute_spend_to_current_progress_refresh( refresh_command = f"`{payload['progress_refresh_state_command']}`" spend_command = f"`{payload['quota_spend_command']}`" - assert task_body.index(refresh_command) < task_body.index(spend_command) + assert refresh_command not in task_body + assert spend_command not in task_body + assert "settlement_plan.ordered_steps" in task_body + assert "preserve identities/flags" in task_body assert "" in refresh_command assert "" in refresh_command assert "" in refresh_command assert "--delivery-batch-scale multi_surface" not in refresh_command assert "--delivery-outcome outcome_progress" not in refresh_command - normalized_task_body = " ".join(task_body.split()) assert payload["quota_spend_command"].startswith("loopx --format json ") - assert ( - "never default or upgrade them to `multi_surface` / `outcome_progress`" - in normalized_task_body - ) - assert "no pipe/retry" in normalized_task_body + assert "actual outcomes" in task_body + assert "readback/recovery" in task_body def test_heartbeat_prompt_commands_keep_explicit_runtime_root() -> None: @@ -386,7 +385,7 @@ def test_heartbeat_prompt_commands_keep_explicit_runtime_root() -> None: "runtime_profile", ("ark_managed_agent_goal", "codex_app_ssh_goal"), ) -def test_goal_hosts_share_narrow_runtime_skill_routing( +def test_goal_hosts_enter_live_contract_without_a_mandatory_skill_detour( runtime_profile: str, ) -> None: payload = build_heartbeat_prompt( @@ -396,13 +395,11 @@ def test_goal_hosts_share_narrow_runtime_skill_routing( ) task_body = " ".join(payload["task_body"].split()) - assert ( - "Normal turns use CLI `interaction_contract`; use `loopx-project` for " - "lifecycle/registry and `loopx-self-repair` for runtime/projection drift." - in task_body - ) + assert "Use the current `interaction_contract`, not remembered commands" in task_body + assert "loopx-project" not in task_body + assert "loopx-self-repair" not in task_body assert "Progress is not a new Goal boundary" in task_body - assert "do not create a successor merely to continue" in task_body + assert "do not create a new host Goal merely to continue" in task_body def test_goal_hosts_reuse_thin_dispatch_and_stay_compact() -> None: @@ -435,8 +432,9 @@ def test_goal_hosts_reuse_thin_dispatch_and_stay_compact() -> None: for rule in shared_rules: assert rule in generic["task_body"] for payload in goal_hosts: - for rule in shared_rules: - assert rule in payload["task_body"] + assert "selection_command" in payload["task_body"] + assert "No learning queue unless asked." in payload["task_body"] + assert "完成获准工作并验证后,再按 next_cli_actions 写回和记账" in payload["task_body"] assert payload["interface_budget"]["budget_char_count"] <= 2_800 assert payload["interface_budget"]["within_budget"] is True diff --git a/tests/test_visible_goal_terminal_settlement.py b/tests/test_visible_goal_terminal_settlement.py index 3eb00adf3f..f0814eae29 100644 --- a/tests/test_visible_goal_terminal_settlement.py +++ b/tests/test_visible_goal_terminal_settlement.py @@ -3,7 +3,7 @@ from loopx.heartbeat_prompt import build_heartbeat_prompt -def test_visible_goal_keeps_final_todo_nonterminal_until_spend() -> None: +def test_visible_goal_delegates_settlement_then_checks_terminal_readback() -> None: payload = build_heartbeat_prompt( goal_id="terminal-settlement-fixture", thin=True, @@ -11,20 +11,15 @@ def test_visible_goal_keeps_final_todo_nonterminal_until_spend() -> None: ) task_body = " ".join(payload["task_body"].split()) - terminal_rule = ( - "Done -> successor first; final -> accountable refresh, spend, then " - "no-follow-up completion." - ) - refresh = "refresh the accountable progress record before spending" - spend = "Then spend exactly once against that refresh" - readback = "Rerun the same guard read-only" + # Ordering belongs to the live settlement contract (covered by the real CLI + # suite), not a second static list embedded in the host objective. + settlement = "cli_channel.settlement_plan.ordered_steps" + readback = "After settlement recheck quota" terminal_readback = ( "Complete visible Goal only on `should_run=false` + terminal " "no-follow-up" ) - assert task_body.index(terminal_rule) < task_body.index(refresh) - assert task_body.index(refresh) < task_body.index(spend) - assert task_body.index(spend) < task_body.index(readback) + assert task_body.index(settlement) < task_body.index(readback) assert terminal_readback in task_body assert payload["interface_budget"]["within_budget"] is True From 24c2ec15f51adae8ee6e12d1e8a53ed607a012fd Mon Sep 17 00:00:00 2001 From: huangruiteng Date: Thu, 10 Sep 2026 23:05:36 +0800 Subject: [PATCH 14/26] test(heartbeat): add opt-in release native Goal regression Signed-off-by: huangruiteng --- docs/development/testing-and-quality.md | 31 +++ docs/product/release-readiness.md | 4 + scripts/qualify-native-goal-release.py | 192 ++++++++++++++++++ tests/fixtures/native_goal_ledger/TASK.md | 25 +++ tests/fixtures/native_goal_ledger/verify.py | 74 +++++++ .../test_native_goal_release_qualification.py | 89 ++++++++ 6 files changed, 415 insertions(+) create mode 100644 scripts/qualify-native-goal-release.py create mode 100644 tests/fixtures/native_goal_ledger/TASK.md create mode 100644 tests/fixtures/native_goal_ledger/verify.py create mode 100644 tests/test_native_goal_release_qualification.py diff --git a/docs/development/testing-and-quality.md b/docs/development/testing-and-quality.md index 881556b017..6e540fd40d 100644 --- a/docs/development/testing-and-quality.md +++ b/docs/development/testing-and-quality.md @@ -737,6 +737,37 @@ Onboarding 输入来自正式 guided packet builder;provider 调用前只替 门禁错误、写入或 quota 消耗。Human gate 的优先级是显式规则:等待用户时没有 executable work 属于预期状态,不能误判为 projection gap。 +## Release-only native Goal regression / 仅发布前的原生 Goal 回归 + +`scripts/qualify-native-goal-release.py` exercises the real Codex CLI app-server +Goal lifecycle on a disposable ledger project with two dependent Todos. It +reuses the shipped native Goal transport and current prompt, then checks an +independent acceptance oracle, completed Todos, unique bound spends, durable +writeback readback, and terminal no-follow-up quota. This is not a benchmark +score or evidence of universal model reliability. + +```bash +# No model invocation, no token cost; explicit skipped result, exit 0. +python3 scripts/qualify-native-goal-release.py +# Release operator opt-in only; uses the existing configured Codex model/auth. +python3 scripts/qualify-native-goal-release.py --release-live +``` + +Do not add the live command to default pytest, PR CI, per-diff canaries, or +ordinary developer iteration. The deterministic runner-policy tests may run +there; they never opt into real model execution. Missing CLI, native Goals or +Codex authentication returns `skipped` and exit 0, not a claimed live pass. +Once qualification is attempted, failed acceptance, incomplete settlement, +blocked/unfinished Goals and deadline expiry fail with exit 1. The default +deadline is 1,200 seconds; this is a wall-clock ceiling, not a token budget. + +仅 release 前显式开启,避免默认消耗开发者 token。CI/本机环境不支持时跳过且不阻塞, +但保留 `skipped` 标记;真实执行后失败不能冒充环境跳过。复用当前 Codex 的模型与登录, +不复制凭据或会话、不修改活跃 Goal/automation。任务、registry、runtime 与 Git worktree +在一次性目录内;沙箱允许该目录及本地 TS worker 所需的网络能力, +这不是网络隔离,任务不授权外部操作。回归脚本不采集或上传原始对话/工具日志, +公开结果仅包含状态、计数和错误类别;Codex 自身仍按当前 host 配置保存会话。 + ## Exact Release Commit Gate / 精确发布 Commit 门 The final release gate does not rerun tests through a second orchestration diff --git a/docs/product/release-readiness.md b/docs/product/release-readiness.md index cbaaee4ab9..179d95ac22 100644 --- a/docs/product/release-readiness.md +++ b/docs/product/release-readiness.md @@ -144,6 +144,10 @@ Before moving `stable`, maintainers should: - bump `loopx.__version__` and `pyproject.toml` together when user-visible release behavior changes; - create or verify the matching Git tag, for example `v0.1.3`; +- for host Goal/prompt changes, explicitly run the + [release-only native Goal regression](../development/testing-and-quality.md#release-only-native-goal-regression--仅发布前的原生-goal-回归) + in a supported Codex environment; record an unavailable environment as + `skipped`, not a live pass. Never enable paid model execution in default PR CI; - fast-forward `stable` to that tagged commit after the release canary passes; - confirm `release.json`, `loopx doctor`, and `loopx update check` report the same package version and tag; diff --git a/scripts/qualify-native-goal-release.py b/scripts/qualify-native-goal-release.py new file mode 100644 index 0000000000..9c38389f66 --- /dev/null +++ b/scripts/qualify-native-goal-release.py @@ -0,0 +1,192 @@ +#!/usr/bin/env python3 +"""Opt-in, release-only Codex Goal qualification. Default execution costs no tokens.""" + +from __future__ import annotations + +import argparse +import importlib.util +import json +import os +from pathlib import Path +import shlex +import shutil +import subprocess +import sys +import tempfile + +REPO = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(REPO)) +FIXTURE = REPO / "tests/fixtures/native_goal_ledger" +GOAL = "heartbeat-flow-main-control" +AGENT = "worker-a" +TODOS = {"todo_reducer", "todo_cli"} + + +def prerequisite_failure(codex: str) -> str | None: + for executable in (codex, "git", "node"): + if not shutil.which(executable): + return "required_executable_unavailable" + for command, reason in ( + ([codex, "login", "status"], "codex_auth_unavailable"), + ([codex, "features", "list"], "native_goals_unavailable"), + ): + result = subprocess.run(command, capture_output=True, text=True, timeout=30) + if result.returncode: + return reason + if command[1] == "features" and not any( + line.split()[:1] == ["goals"] for line in result.stdout.splitlines() + ): + return reason + return None + + +def setup(root: Path) -> tuple[Path, Path, Path]: + # Reuse the existing real CLI fixture, not an alternate registry contract. + spec = importlib.util.spec_from_file_location( + "heartbeat_fixture", REPO / "examples/control_plane/heartbeat-quota-flow-smoke.py", + ) + fixture = importlib.util.module_from_spec(spec) + spec.loader.exec_module(fixture) + project, runtime, registry = fixture.write_fixture(root) + config = json.loads(registry.read_text()) + goal = config["goals"][0] + goal.update(state_file="ACTIVE_GOAL_STATE.md", coordination={"registered_agents": [AGENT]}) + goal["quota"]["allowed_slots"] = 12 + registry.write_text(json.dumps(config)) + runtime.mkdir(parents=True, exist_ok=True) + (runtime / "registry.global.json").write_text(json.dumps(config)) + (project / "ACTIVE_GOAL_STATE.md").write_text( + '---\nstatus: active-read-only\nowner_mode: goal\n' + 'objective: "Deliver the finite local ledger specification."\n---\n\n' + '# Ledger\n\n## Objective\n\nDeliver TASK.md, validate and settle both Todos.\n\n' + '## Next Action\n\nImplement the reducer, then the CLI.\n\n' + '## User Todo\n\n## Agent Todo\n\n' + '- [ ] [P1] Implement and validate the TASK.md reducer.\n' + ' \n' + '- [ ] [P1] Implement the CLI, verify full acceptance and close this finite Goal.\n' + ' \n', + encoding="utf-8", + ) + shutil.copyfile(FIXTURE / "TASK.md", project / "TASK.md") + launcher = project / "bin/loopx" + launcher.parent.mkdir() + launcher.write_text( + "#!/bin/sh\nexport LOOPX_PYTHON=" + shlex.quote(sys.executable) + "\nexec " + + shlex.join([str(REPO / "scripts/loopx"), "--registry", str(registry), + "--runtime-root", str(runtime)]) + ' "$@"\n', + ) + launcher.chmod(0o700) + git = ["git", "-C", str(project), "-c", "core.hooksPath=/dev/null", + "-c", "user.name=Fixture", "-c", "user.email=fixture@example.com"] + for args in (("init", "-q"), ("remote", "add", "origin", "https://example.com/ledger.git"), + ("add", "TASK.md"), ("commit", "-qm", "Initialize synthetic acceptance")): + subprocess.run([*git, *args], check=True, capture_output=True, timeout=30) + return project, runtime, launcher + + +def cli(launcher: Path, *args: str) -> dict: + result = subprocess.run( + [str(launcher), "--format", "json", *args], cwd=launcher.parent.parent, + capture_output=True, text=True, timeout=120, + ) + if result.returncode: + raise RuntimeError("fixture_cli_failed") + packet = json.loads(result.stdout) + if packet.get("ok") is not True: + raise RuntimeError("fixture_cli_not_ok") + return packet + + +def verify_settlement(runtime: Path, todos: list[dict]) -> int: + from loopx.control_plane.quota.settlement import read_heartbeat_settlement + + assert {t["todo_id"] for t in todos if t.get("status") == "done"} == TODOS + rows = [json.loads(line) for line in + (runtime / "goals" / GOAL / "runs/index.jsonl").read_text().splitlines()] + spends = [r for r in rows if r.get("classification") == "quota_slot_spent"] + assert {r.get("todo_id") for r in spends if r.get("todo_id")} == TODOS + assert all(isinstance(r.get("settlement_identity"), dict) for r in spends), "unbound_spend" + identities = [r["settlement_identity"]["effect_id"] for r in spends] + assert len(identities) == len(set(identities)), "duplicate_spend" + for row in spends: + readback = read_heartbeat_settlement( + runtime, goal_id=GOAL, agent_id=AGENT, todo_id=row.get("todo_id"), + replan_obligation_id=row.get("replan_obligation_id"), + turn_instance_id=row["turn_instance_id"], + ) + assert readback and readback.writeback and readback.spend + return len(spends) + + +def qualify(root: Path, codex: str, timeout: int) -> dict: + from loopx.capabilities.benchmark_toolkit.native_codex_goal import ( + NativeGoalConfig, StdioNativeGoalTransport, run_native_goal_until_terminal, + ) + + project, runtime, launcher = setup(root) + prompt = cli(launcher, "heartbeat-prompt", "--runtime-profile", "codex_cli", + "--goal-id", GOAL, "--agent-id", AGENT, "--cli-bin", str(launcher)) + cli(launcher, "quota", "should-run", "--runtime-profile", "codex_cli", + "--goal-id", GOAL, "--agent-id", AGENT) + config = NativeGoalConfig( + cwd=str(project), objective=prompt["task_body"], + task_instruction="Proceed with the active Goal. Read TASK.md. Use bin/loopx " + "for projected LoopX commands; preserve its isolated binding.", + sandbox_policy={"type": "workspaceWrite", "writableRoots": [str(root)], + "networkAccess": True}, # Local TS worker needs loopback. + ) + command = [codex, "--enable", "goals", "-c", 'shell_environment_policy.inherit="all"', + "-c", "project_doc_max_bytes=0", "app-server", "--stdio"] + with tempfile.TemporaryFile(mode="w+") as stderr: + with StdioNativeGoalTransport.spawn(command, cwd=str(project), env=dict(os.environ), + stderr=stderr) as transport: + turn = run_native_goal_until_terminal(transport, config, timeout_sec=timeout) + assert turn.post_goal_status == "complete", "native_goal_did_not_complete" + assert (project / "TASK.md").read_bytes() == (FIXTURE / "TASK.md").read_bytes() + oracle = subprocess.run([sys.executable, str(FIXTURE / "verify.py"), str(project)], + capture_output=True, timeout=60) + assert oracle.returncode == 0, "independent_acceptance_failed" + todos = cli(launcher, "todo", "list", "--goal-id", GOAL, "--role", "agent")["todos"] + spends = verify_settlement(runtime, todos) + quota = cli(launcher, "quota", "should-run", "--goal-id", GOAL, + "--agent-id", AGENT, "--runtime-profile", "codex_cli") + assert quota["should_run"] is False + assert quota["interaction_contract"]["mode"] == "terminal_no_followup" + return {"status": "passed", "model_executed": True, + "native_turns": turn.turn_completed_count, "settled_spends": spends, + "independent_acceptance": "passed"} + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--release-live", action="store_true", + help="Explicit release qualification; permits real model cost.") + parser.add_argument("--codex-bin", default="codex") + parser.add_argument("--timeout-seconds", type=int, default=1200) + args = parser.parse_args(argv) + if args.timeout_seconds <= 0: + parser.error("timeout must be positive") + if not args.release_live: + result = {"status": "skipped", "reason": "release_opt_in_required", "model_executed": False} + else: + try: + reason = prerequisite_failure(args.codex_bin) + if reason: + result = {"status": "skipped", "reason": reason, "model_executed": False} + else: + with tempfile.TemporaryDirectory(prefix="loopx-release-goal-") as raw: + result = qualify(Path(raw), args.codex_bin, args.timeout_seconds) + except Exception as exc: + # Never turn an attempted-but-failing qualification into an environment skip. + # Raw errors can contain host paths, prompts or account details. + result = {"status": "failed", "error_kind": type(exc).__name__} + print(json.dumps(result, sort_keys=True)) + return 1 if result["status"] == "failed" else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/fixtures/native_goal_ledger/TASK.md b/tests/fixtures/native_goal_ledger/TASK.md new file mode 100644 index 0000000000..cb2fbda446 --- /dev/null +++ b/tests/fixtures/native_goal_ledger/TASK.md @@ -0,0 +1,25 @@ +# Replay-safe local ledger + +Build `ledger.py` using only the Python standard library. Implement +`reduce_events(events)` and `python ledger.py INPUT` (UTF-8 JSONL). + +Every event has nonempty string `id`, `account`, and `type`. Credit/debit events +have a strictly positive integer `amount` (booleans are invalid). Reverse events +have `target`, referencing an earlier credit/debit in the same account. Reverse +an original at most once; never reverse a reversal. Reject overdrafts at every +prefix, including reversal of a credit. Exact event replays are idempotent; +conflicting reuse of an id is invalid. Reject missing/extra fields, invalid +types and references with `ValueError`. Never mutate input dictionaries. Keep +zero-balance accounts; names are case-sensitive and may contain Unicode. + +The CLI ignores blank lines and prints exactly one JSON object sorted by account +with a trailing newline. Empty input succeeds with `{}`. Invalid JSON or domain +input must leave stdout empty, write a concise diagnostic to stderr, and exit +nonzero. Add reducer and subprocess tests, including long replay, and a README. + +Complete both dependent LoopX Todos and settle validated work using the current +contract. This is a finite local task: no upload, publication, deployment, +package installation, or external side effect is part of acceptance. Keep all +changes in the disposable project/worktrees. Do not change this specification +or LoopX source to make the task pass. Do not substitute fabricated evidence +or a non-delivery declaration for actual code delivery. diff --git a/tests/fixtures/native_goal_ledger/verify.py b/tests/fixtures/native_goal_ledger/verify.py new file mode 100644 index 0000000000..b0ca4869d1 --- /dev/null +++ b/tests/fixtures/native_goal_ledger/verify.py @@ -0,0 +1,74 @@ +"""Independent release oracle; the model's own tests are not acceptance authority.""" + +import copy +import importlib.util +import json +from pathlib import Path +import subprocess +import sys +import tempfile + + +def verify(project: Path) -> None: + spec = importlib.util.spec_from_file_location("candidate_ledger", project / "ledger.py") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + reduce_events = module.reduce_events + credit = {"id": "c", "account": "雪", "type": "credit", "amount": 100} + debit = {"id": "d", "account": "雪", "type": "debit", "amount": 35} + reverse = {"id": "r", "account": "雪", "type": "reverse", "target": "d"} + events = [credit, debit, credit.copy(), reverse, reverse.copy()] + before = copy.deepcopy(events) + assert reduce_events(events) == {"雪": 100} + assert events == before + assert reduce_events([credit, {**reverse, "target": "c"}]) == {"雪": 0} + assert reduce_events([]) == {} + for invalid in ( + [{**credit, "amount": True}], [{**credit, "amount": 1.0}], + [{**credit, "amount": 0}], [{**credit, "extra": 1}], + [{"id": "missing"}], [debit], [reverse, credit], + [credit, {**credit, "amount": 2}], + [credit, debit, {**reverse, "target": "c"}], + [credit, debit, {**reverse, "account": "other"}], + [credit, debit, reverse, {**reverse, "id": "r2"}], + [credit, debit, reverse, {**reverse, "id": "r2", "target": "r"}], + ): + untouched = copy.deepcopy(invalid) + try: + reduce_events(invalid) + except ValueError: + pass + else: + raise AssertionError("invalid_event_accepted") + assert invalid == untouched + long = [{**credit, "id": str(i), "account": "A" if i % 2 else "a"} + for i in range(12000)] + assert reduce_events(long + long) == {"A": 600000, "a": 600000} + with tempfile.TemporaryDirectory(prefix="ledger-oracle-") as raw: + source = Path(raw) / "input.jsonl" + for content, expected in ( + ("\n" + "\n".join(json.dumps(e) for e in events), {"雪": 100}), + ("\n".join(json.dumps(e) for e in [credit, {**credit, "id": "a", "account": "A"}]), + {"A": 100, "雪": 100}), + ("\n", {}), + (json.dumps(credit) + "\nnot-json", None), + (json.dumps(debit), None), + ): + source.write_text(content, encoding="utf-8") + result = subprocess.run( + [sys.executable, str(project / "ledger.py"), str(source)], + capture_output=True, text=True, timeout=30, + ) + if expected is None: + assert result.returncode != 0 and result.stdout == "" and result.stderr + else: + assert result.returncode == 0 and result.stderr == "" + assert result.stdout.endswith("\n") and len(result.stdout.splitlines()) == 1 + assert json.loads(result.stdout) == expected + assert list(json.loads(result.stdout)) == sorted(expected) + assert (project / "README.md").is_file() + + +if __name__ == "__main__": + verify(Path(sys.argv[1]).resolve()) + print("ledger acceptance passed") diff --git a/tests/test_native_goal_release_qualification.py b/tests/test_native_goal_release_qualification.py new file mode 100644 index 0000000000..8ec588a820 --- /dev/null +++ b/tests/test_native_goal_release_qualification.py @@ -0,0 +1,89 @@ +"""Runner policy tests never start a real model, including under ordinary CI.""" + +import importlib.util +import json +from pathlib import Path + +import pytest + +REPO = Path(__file__).resolve().parents[1] +spec = importlib.util.spec_from_file_location( + "release_goal", REPO / "scripts/qualify-native-goal-release.py", +) +runner = importlib.util.module_from_spec(spec) +spec.loader.exec_module(runner) + + +def test_default_does_not_even_probe_model_environment(monkeypatch, capsys): + def forbidden(*args): + raise AssertionError("default must not touch a model host") + monkeypatch.setattr(runner, "prerequisite_failure", forbidden) + monkeypatch.setattr(runner, "qualify", forbidden) + assert runner.main([]) == 0 + result = json.loads(capsys.readouterr().out) + assert result == {"status": "skipped", "reason": "release_opt_in_required", "model_executed": False} + + +def test_missing_release_environment_is_explicit_skip(monkeypatch, capsys): + monkeypatch.setattr(runner, "prerequisite_failure", lambda _: "codex_auth_unavailable") + assert runner.main(["--release-live"]) == 0 + assert json.loads(capsys.readouterr().out)["status"] == "skipped" + + +def test_attempted_release_failure_is_not_converted_to_skip(monkeypatch, capsys): + monkeypatch.setattr(runner, "prerequisite_failure", lambda _: None) + def failing(*args): + raise RuntimeError("sensitive diagnostic sentinel") + monkeypatch.setattr(runner, "qualify", failing) + assert runner.main(["--release-live"]) == 1 + result = json.loads(capsys.readouterr().out) + assert result == {"status": "failed", "error_kind": "RuntimeError"} + + +def test_release_timeout_must_be_positive(): + with pytest.raises(SystemExit): + runner.main(["--timeout-seconds", "0"]) + + +def test_synthetic_fixture_real_cli_projects_identity_reentry(tmp_path): + project, _, launcher = runner.setup(tmp_path) + assert (project / "TASK.md").read_bytes() == (runner.FIXTURE / "TASK.md").read_bytes() + quota = runner.cli(launcher, "quota", "should-run", "--runtime-profile", "codex_cli", + "--goal-id", runner.GOAL, "--agent-id", runner.AGENT) + assert quota["should_run"] is True + assert quota["selected_todo"]["todo_id"] == "todo_reducer" + actions = quota["interaction_contract"]["cli_channel"]["next_cli_actions"] + assert len(actions) == 1 and "--turn-instance-id" in actions[0] + assert "spend-slot" not in actions[0] + + # Qualify the fixture's terminal shape without paying for a model run. A + # missing user section is not proof of zero user obligations. + state = project / "ACTIVE_GOAL_STATE.md" + state.write_text(state.read_text().replace("- [ ]", "- [x]").replace( + "status=open", "status=done no_followup=true", + )) + terminal = runner.cli(launcher, "quota", "should-run", "--runtime-profile", "codex_cli", + "--goal-id", runner.GOAL, "--agent-id", runner.AGENT) + assert terminal["should_run"] is False + assert terminal["interaction_contract"]["mode"] == "terminal_no_followup" + + state.write_text(state.read_text().replace("## User Todo\n\n", "")) + incomplete = runner.cli(launcher, "quota", "should-run", "--runtime-profile", "codex_cli", + "--goal-id", runner.GOAL, "--agent-id", runner.AGENT) + assert incomplete["interaction_contract"]["mode"] != "terminal_no_followup" + + +@pytest.mark.parametrize("mutation", ["unbound", "duplicate"]) +def test_settlement_oracle_rejects_missing_identity_and_duplicate_spend(tmp_path, mutation): + rows = [{"classification": "quota_slot_spent", "todo_id": todo, + "settlement_identity": {"effect_id": todo}} for todo in sorted(runner.TODOS)] + if mutation == "unbound": + rows.append({"classification": "quota_slot_spent"}) + else: + rows.append(rows[0].copy()) + index = tmp_path / "goals" / runner.GOAL / "runs/index.jsonl" + index.parent.mkdir(parents=True) + index.write_text("\n".join(json.dumps(row) for row in rows)) + todos = [{"todo_id": todo, "status": "done"} for todo in runner.TODOS] + with pytest.raises(AssertionError): + runner.verify_settlement(tmp_path, todos) From 8f05fc49f21f7bbf35588b7cd3d84f66d74f744b Mon Sep 17 00:00:00 2001 From: huangruiteng Date: Thu, 10 Sep 2026 23:30:54 +0800 Subject: [PATCH 15/26] test(hosts): add Claude release and response-loss coverage Signed-off-by: huangruiteng --- docs/development/testing-and-quality.md | 36 +++++ docs/heartbeat-automation-prompt.md | 8 + .../claude_goal_mode/scripts/goalmode_cmd.py | 29 ++-- scripts/qualify-claude-goal-release.py | 145 ++++++++++++++++++ scripts/qualify-native-goal-release.py | 28 ++-- .../test_claude_goal_release_qualification.py | 115 ++++++++++++++ tests/test_goal_mode_mcp_settlement.py | 42 +++++ .../test_native_goal_release_qualification.py | 19 +++ 8 files changed, 401 insertions(+), 21 deletions(-) create mode 100644 scripts/qualify-claude-goal-release.py create mode 100644 tests/test_claude_goal_release_qualification.py diff --git a/docs/development/testing-and-quality.md b/docs/development/testing-and-quality.md index 6e540fd40d..c543d5a24e 100644 --- a/docs/development/testing-and-quality.md +++ b/docs/development/testing-and-quality.md @@ -768,6 +768,42 @@ deadline is 1,200 seconds; this is a wall-clock ceiling, not a token budget. 这不是网络隔离,任务不授权外部操作。回归脚本不采集或上传原始对话/工具日志, 公开结果仅包含状态、计数和错误类别;Codex 自身仍按当前 host 配置保存会话。 +### Claude Code and release coverage / Claude Code 与发布覆盖 + +```bash +# No provider call by default. Explicit release opt-in uses ARK_API_KEY from the environment. +python3 scripts/qualify-claude-goal-release.py --release-live +``` + +This arm uses the same ledger specification, independent oracle and durable +settlement readback as the Codex arm. It launches actual Claude Code with the +project's shipped `loop.md` and LoopX stdio MCP server, using +`doubao-seed-evolving` through Ark's Anthropic-compatible API. It does not +inherit another Anthropic account, install into the user's Claude configuration, +or retain host sessions. The subprocess timeout also cleans its process group +on POSIX. Allowed local development tools are not a security sandbox; the +synthetic task authorizes no external side effects. + +**A headless work-loop pass is not a `/loop` timer pass.** The release report +explicitly returns `scheduler_qualification=not_run_headless`; interactive +native wakeup, cancel/resume and process-restart behavior need their own host +qualification. Do not turn repeated `claude -p` invocations into a substitute +scheduler and claim host lifecycle coverage. + +Before calling a changed host surface release-qualified, distinguish: + +| Boundary | Required evidence | +| --- | --- | +| Work and terminal closeout | Final candidate, actual host, independent artifact checks, completed Todos and terminal quota; code delivery alone is insufficient. | +| Idempotency and failure | Real committed lifecycle/writeback/spend followed by lost-response injection and same-intent retries; one final spend. Failed declared validation must not complete or spend. | +| Authority and transport | Actual MCP initialization/tool invocation and mismatched-agent rejection; existing claim/lease and validation suites remain required. | +| Host lifecycle | Native scheduler wakeup/cancellation/resume on supported versions, reported separately from headless execution. | +| Upgrade and isolation | Exact managed-wrapper recognition, preview/apply revision checks, preserved scheduler state, explicit skips, no default model calls or leaked test processes. | + +普通 CI 只跑确定性规则、真实 CLI/MCP 和故障注入;模型执行仍仅 release 前显式启用。 +环境缺失可 skip 且退出成功,但最终版本没有完整的真实 host 结果时,不得写成 +“产品级发布验证通过”。单次成功也不是模型可靠性或长程调度 soak 的证明。 + ## Exact Release Commit Gate / 精确发布 Commit 门 The final release gate does not rerun tests through a second orchestration diff --git a/docs/heartbeat-automation-prompt.md b/docs/heartbeat-automation-prompt.md index 7307825309..3ad905ea34 100644 --- a/docs/heartbeat-automation-prompt.md +++ b/docs/heartbeat-automation-prompt.md @@ -100,6 +100,14 @@ not active host Goal objectives or benchmark prompts already pinned to a run. An installed runtime supplies updated dynamic contracts on later reads; upgrading it does not retroactively remove old text from an existing Goal. +Claude Code's MCP-backed `loop.md` follows the same work-sizing rule and current +quota contract, without a fixed one-segment limit or empty-Todo-list completion +shortcut. Its `complete_task` tool already owns the ordered writeback/spend +transaction: callers must not perform a second accounting sequence through CLI. +Partial work is not Todo completion. Native `/loop` remains Claude's scheduler; +only the current Goal's wakeup may be cancelled after terminal no-follow-up. +These changes apply when `loop.md` is regenerated, not by editing active user files. + For Codex App, the generated quota command carries the compact explicit runtime profile `--runtime-profile codex_app_heartbeat` (generated commands use the equivalent compact alias `--codex-app`). The prompt does not restate the diff --git a/loopx/claude_goal_mode/scripts/goalmode_cmd.py b/loopx/claude_goal_mode/scripts/goalmode_cmd.py index 2009089a66..829469d9ae 100644 --- a/loopx/claude_goal_mode/scripts/goalmode_cmd.py +++ b/loopx/claude_goal_mode/scripts/goalmode_cmd.py @@ -36,6 +36,9 @@ # registry-driven context, shared with the hooks/MCP sys.path.insert(0, str(HERE.parent / "hooks")) from goal_state import goal_context, find_registry, loop_md_path # noqa: E402 +from loopx.control_plane.heartbeat.rules import ( # noqa: E402 + SCOPE_BOUNDED_WORK_RULE, +) def gh_prefix(): @@ -66,14 +69,22 @@ def loop_md_content(goal_id, agent_id) -> str: f"\n" f"loopx tick — advance goal `{goal_id}` (agent `{agent_id}`). Use the wired loopx MCP\n" f"tools; do NOT run `loopx --help` or guess ids.\n\n" - f"1. Call `should_run()`. If should_run=false, say why in ONE line and STOP this\n" - f" iteration (do nothing else) — loopx has paused, gated, or converged.\n" - f"2. If should_run=true: `claim_task` the next open todo, do ONE bounded segment,\n" - f" then VERIFY it with a real check (build/test) — never claim success from\n" - f" reasoning — and `complete_task(..., agent_id=\"{agent_id}\", evidence=\"\")`.\n" - f"3. Stay within the goal's scope; do not start initiatives outside the todos.\n" - f" Irreversible actions (push/delete) only to finish work already authorized.\n" - f"4. Re-check `should_run()`; stop when should_run=false or no open todos remain.\n" + "Read complete successful `should_run()` JSON each work iteration. Follow its\n" + "current `interaction_contract`: selection/re-entry before admitted work,\n" + "then validation and settlement. Never infer completion from an empty Todo list.\n" + f"{SCOPE_BOUNDED_WORK_RULE}\n" + "Honor claim/lease and user/repository authority; claim only when required.\n" + "Run real acceptance checks before `complete_task`; supply truthful evidence\n" + f"and the bound agent_id=\"{agent_id}\". Complete only finished Todos, not partial work.\n" + "That MCP operation owns writeback/spend; do not repeat its accounting via CLI.\n" + "After a lost response, read back or retry the same completion intent; do not\n" + "invent a new successor or settlement identity. Recheck `should_run()` afterward.\n" + "Continue authorized work while the live contract requires it; notification\n" + "silence is not execution silence. Waiting is not completion: follow current\n" + "host scheduling guidance without repeated unchanged polling. Terminal\n" + "no-follow-up ends this Goal's work; cancel only its own recurring wakeup.\n" + "Repair entrypoint errors within authority; an unavailable/incomplete contract\n" + "permits neither work nor spending and must not be reported as completion.\n" ) @@ -230,7 +241,7 @@ def main(): print(f" todo_id : {tid}") print(f" scope : {proj}") print(f" task : {task}") - print(f" wrote : .claude/loop.md (the per-tick protocol)") + print(" wrote : .claude/loop.md (the per-tick protocol)") print() print("START WORKING — run native `/loop` (Claude self-paces) or `/loop 10m` (fixed cadence).") print("Each /loop tick runs: should_run -> claim_task -> ONE bounded verified segment -> complete_task.") diff --git a/scripts/qualify-claude-goal-release.py b/scripts/qualify-claude-goal-release.py new file mode 100644 index 0000000000..349cf7f985 --- /dev/null +++ b/scripts/qualify-claude-goal-release.py @@ -0,0 +1,145 @@ +#!/usr/bin/env python3 +"""Release-only Claude Code + Doubao work-loop qualification, never a default live test.""" + +from __future__ import annotations + +import argparse +import importlib.util +import json +import os +from pathlib import Path +import shutil +import signal +import subprocess +import sys +import tempfile + +REPO = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(REPO)) +spec = importlib.util.spec_from_file_location("goal_release", REPO / "scripts/qualify-native-goal-release.py") +shared = importlib.util.module_from_spec(spec) +spec.loader.exec_module(shared) + +from loopx.control_plane.testing.doubao_model_behavior_actor import ( # noqa: E402 + DOUBAO_SEED_EVOLVING_MODEL, +) + +ARK_ANTHROPIC_BASE = "https://ark.cn-beijing.volces.com/api/compatible" + + +def run_host(command: list[str], *, cwd: Path, env: dict, timeout: float) -> str: + """Bound output memory and clean this test's process group, including on timeout.""" + with tempfile.TemporaryFile() as stdout, tempfile.TemporaryFile() as stderr: + process = subprocess.Popen(command, cwd=cwd, env=env, stdout=stdout, stderr=stderr, + start_new_session=True) + try: + code = process.wait(timeout=timeout) + finally: + if os.name == "posix": + try: + os.killpg(process.pid, signal.SIGKILL) + except ProcessLookupError: + pass + elif process.poll() is None: + process.kill() + process.wait(timeout=10) + assert code == 0, "claude_host_failed" + stdout.seek(0) + output = stdout.read(32 * 1024 * 1024 + 1) + assert len(output) <= 32 * 1024 * 1024, "claude_output_exceeded_limit" + return output.decode("utf-8") + + +def prerequisite_failure(claude: str) -> str | None: + if not all(shutil.which(command) for command in (claude, "node", "git")): + return "required_executable_unavailable" + if not os.environ.get("ARK_API_KEY"): + return "ark_api_key_unavailable" + try: + import mcp.server.fastmcp # noqa: F401 + except ImportError: + return "mcp_sdk_v1_unavailable" + return None + + +def host_environment(root: Path, launcher: Path) -> dict[str, str]: + # No user settings, OAuth/keychain import or persistent host installation. + env = {k: v for k, v in os.environ.items() if not k.startswith(("ANTHROPIC_", "CLAUDE_"))} + env.update( + ANTHROPIC_API_KEY=os.environ["ARK_API_KEY"], + ANTHROPIC_BASE_URL=ARK_ANTHROPIC_BASE, + ANTHROPIC_MODEL=DOUBAO_SEED_EVOLVING_MODEL, + ANTHROPIC_DEFAULT_HAIKU_MODEL=DOUBAO_SEED_EVOLVING_MODEL, + ANTHROPIC_DEFAULT_SONNET_MODEL=DOUBAO_SEED_EVOLVING_MODEL, + ANTHROPIC_DEFAULT_OPUS_MODEL=DOUBAO_SEED_EVOLVING_MODEL, + CLAUDE_CONFIG_DIR=str(root / "claude-config"), + CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC="1", + PATH=str(launcher.parent) + os.pathsep + os.environ.get("PATH", ""), + PYTHONPATH=str(REPO), + ) + return env + + +def qualify(root: Path, claude: str, timeout: int) -> dict: + from loopx.claude_goal_mode.scripts.goalmode_cmd import write_loop_md + + project, runtime, launcher = shared.setup(root) + write_loop_md(project, shared.GOAL, shared.AGENT) + config = root / "mcp.json" + config.write_text(json.dumps({"mcpServers": {"loopx": { + "command": sys.executable, + "args": [str(REPO / "loopx/claude_goal_mode/mcp/loopx_mcp.py")], + }}})) + command = [ + claude, "--bare", "--setting-sources", "", "--no-session-persistence", + "--strict-mcp-config", "--mcp-config", str(config), + "--model", DOUBAO_SEED_EVOLVING_MODEL, + "--permission-mode", "dontAsk", "--allowedTools", + "Read", "Edit", "Write", "Bash", "mcp__loopx__*", + "--output-format", "stream-json", "--verbose", "-p", + "Read .claude/loop.md and follow this project's active LoopX work contract. " + "Read TASK.md for acceptance. Use the bound LoopX MCP tools; preserve the " + "isolated project binding. Do not create timers in this headless qualification.", + ] + # This executes the actual per-iteration adapter, not Claude's interactive + # /loop timer. Never report headless delivery as scheduler qualification. + output = run_host(command, cwd=project, env=host_environment(root, launcher), timeout=timeout) + events = [json.loads(line) for line in output.splitlines() if line.strip()] + results = [e for e in events if e.get("type") == "result"] + assert len(results) == 1 and results[0].get("is_error") is False, "claude_turn_failed" + calls = [block.get("name") for event in events if event.get("type") == "assistant" + for block in (event.get("message") or {}).get("content", []) + if block.get("type") == "tool_use"] + assert "mcp__loopx__should_run" in calls and "mcp__loopx__complete_task" in calls, "mcp_not_exercised" + return {"status": "passed", "model_executed": True, "model": DOUBAO_SEED_EVOLVING_MODEL, + "host": "claude_code", "scheduler_qualification": "not_run_headless", + "mcp_tool_calls": sum(str(c).startswith("mcp__loopx__") for c in calls), + **shared.verify_delivery(project, runtime, launcher, "claude_code")} + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--release-live", action="store_true") + parser.add_argument("--claude-bin", default="claude") + parser.add_argument("--timeout-seconds", type=int, default=1200) + args = parser.parse_args(argv) + if args.timeout_seconds <= 0: + parser.error("timeout must be positive") + if not args.release_live: + result = {"status": "skipped", "reason": "release_opt_in_required", "model_executed": False} + else: + try: + reason = prerequisite_failure(args.claude_bin) + if reason: + result = {"status": "skipped", "reason": reason, "model_executed": False} + else: + with tempfile.TemporaryDirectory(prefix="loopx-claude-release-") as raw: + result = qualify(Path(raw), args.claude_bin, args.timeout_seconds) + except Exception as exc: + result = {"status": "failed", "error_kind": type(exc).__name__} + print(json.dumps(result, sort_keys=True)) + return 1 if result["status"] == "failed" else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/qualify-native-goal-release.py b/scripts/qualify-native-goal-release.py index 9c38389f66..081c631044 100644 --- a/scripts/qualify-native-goal-release.py +++ b/scripts/qualify-native-goal-release.py @@ -122,6 +122,20 @@ def verify_settlement(runtime: Path, todos: list[dict]) -> int: return len(spends) +def verify_delivery(project: Path, runtime: Path, launcher: Path, profile: str) -> dict: + assert (project / "TASK.md").read_bytes() == (FIXTURE / "TASK.md").read_bytes() + oracle = subprocess.run([sys.executable, str(FIXTURE / "verify.py"), str(project)], + capture_output=True, timeout=60) + assert oracle.returncode == 0, "independent_acceptance_failed" + todos = cli(launcher, "todo", "list", "--goal-id", GOAL, "--role", "agent")["todos"] + spends = verify_settlement(runtime, todos) + quota = cli(launcher, "quota", "should-run", "--goal-id", GOAL, + "--agent-id", AGENT, "--runtime-profile", profile) + assert quota["should_run"] is False + assert quota["interaction_contract"]["mode"] == "terminal_no_followup" + return {"settled_spends": spends, "independent_acceptance": "passed"} + + def qualify(root: Path, codex: str, timeout: int) -> dict: from loopx.capabilities.benchmark_toolkit.native_codex_goal import ( NativeGoalConfig, StdioNativeGoalTransport, run_native_goal_until_terminal, @@ -146,19 +160,9 @@ def qualify(root: Path, codex: str, timeout: int) -> dict: stderr=stderr) as transport: turn = run_native_goal_until_terminal(transport, config, timeout_sec=timeout) assert turn.post_goal_status == "complete", "native_goal_did_not_complete" - assert (project / "TASK.md").read_bytes() == (FIXTURE / "TASK.md").read_bytes() - oracle = subprocess.run([sys.executable, str(FIXTURE / "verify.py"), str(project)], - capture_output=True, timeout=60) - assert oracle.returncode == 0, "independent_acceptance_failed" - todos = cli(launcher, "todo", "list", "--goal-id", GOAL, "--role", "agent")["todos"] - spends = verify_settlement(runtime, todos) - quota = cli(launcher, "quota", "should-run", "--goal-id", GOAL, - "--agent-id", AGENT, "--runtime-profile", "codex_cli") - assert quota["should_run"] is False - assert quota["interaction_contract"]["mode"] == "terminal_no_followup" return {"status": "passed", "model_executed": True, - "native_turns": turn.turn_completed_count, "settled_spends": spends, - "independent_acceptance": "passed"} + "native_turns": turn.turn_completed_count, + **verify_delivery(project, runtime, launcher, "codex_cli")} def main(argv: list[str] | None = None) -> int: diff --git a/tests/test_claude_goal_release_qualification.py b/tests/test_claude_goal_release_qualification.py new file mode 100644 index 0000000000..044a5e6b16 --- /dev/null +++ b/tests/test_claude_goal_release_qualification.py @@ -0,0 +1,115 @@ +"""Ordinary CI covers policy/transport; no test here invokes a model.""" + +import asyncio +import importlib.util +import json +import os +import subprocess +from pathlib import Path +import sys + +import pytest + +REPO = Path(__file__).resolve().parents[1] +spec = importlib.util.spec_from_file_location("claude_release", REPO / "scripts/qualify-claude-goal-release.py") +runner = importlib.util.module_from_spec(spec) +spec.loader.exec_module(runner) + + +def test_default_never_probes_or_calls_model(monkeypatch, capsys): + def forbidden(*_): + raise AssertionError("must not execute") + monkeypatch.setattr(runner, "qualify", forbidden) + monkeypatch.setattr(runner, "prerequisite_failure", forbidden) + assert runner.main([]) == 0 + assert json.loads(capsys.readouterr().out)["model_executed"] is False + + +def test_missing_environment_skips_but_attempted_failure_fails(monkeypatch, capsys): + monkeypatch.setattr(runner, "prerequisite_failure", lambda _: "ark_api_key_unavailable") + assert runner.main(["--release-live"]) == 0 + assert json.loads(capsys.readouterr().out)["status"] == "skipped" + monkeypatch.setattr(runner, "prerequisite_failure", lambda _: None) + def failure(*_): + raise RuntimeError("private sentinel must not reach public result") + monkeypatch.setattr(runner, "qualify", failure) + assert runner.main(["--release-live"]) == 1 + assert json.loads(capsys.readouterr().out) == {"status": "failed", "error_kind": "RuntimeError"} + + +def test_provider_binding_does_not_inherit_another_anthropic_account(monkeypatch, tmp_path): + monkeypatch.setenv("ARK_API_KEY", "synthetic-ark-key") + monkeypatch.setenv("ANTHROPIC_AUTH_TOKEN", "synthetic-other-provider-key") + monkeypatch.setenv("ANTHROPIC_BASE_URL", "https://example.com") + monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN", "synthetic-oauth") + env = runner.host_environment(tmp_path, tmp_path / "bin/loopx") + assert env["ANTHROPIC_API_KEY"] == "synthetic-ark-key" + assert env["ANTHROPIC_BASE_URL"] == runner.ARK_ANTHROPIC_BASE + assert env["ANTHROPIC_MODEL"] == "doubao-seed-evolving" + assert "ANTHROPIC_AUTH_TOKEN" not in env and "CLAUDE_CODE_OAUTH_TOKEN" not in env + assert os.environ["ANTHROPIC_AUTH_TOKEN"] == "synthetic-other-provider-key" + + +def test_claude_loop_uses_current_contract_not_segment_or_empty_list_stop(): + from loopx.claude_goal_mode.scripts.goalmode_cmd import loop_md_content + from loopx.control_plane.heartbeat.rules import SCOPE_BOUNDED_WORK_RULE + + prompt = loop_md_content("goal-a", "agent-a") + assert SCOPE_BOUNDED_WORK_RULE in prompt + assert "interaction_contract" in prompt and "notification" in prompt + assert "ONE bounded segment" not in prompt and "no open todos remain" not in prompt + assert "Complete only finished Todos, not partial work" in prompt + assert 'agent_id="agent-a"' in prompt + assert "That MCP operation owns writeback/spend" in prompt + assert "unavailable/incomplete contract" in prompt + + +def test_host_timeout_fails_and_reaps_the_spawned_process(tmp_path, monkeypatch): + original = subprocess.Popen + children = [] + def capture(*args, **kwargs): + child = original(*args, **kwargs) + children.append(child) + return child + monkeypatch.setattr(runner.subprocess, "Popen", capture) + with pytest.raises(subprocess.TimeoutExpired): + runner.run_host([sys.executable, "-c", "import time; time.sleep(30)"], + cwd=tmp_path, env=dict(os.environ), timeout=0.1) + assert len(children) == 1 and children[0].poll() is not None + + +def test_host_nonzero_is_not_reported_as_a_successful_model_turn(tmp_path): + with pytest.raises(AssertionError, match="claude_host_failed"): + runner.run_host([sys.executable, "-c", "raise SystemExit(2)"], + cwd=tmp_path, env=dict(os.environ), timeout=10) + + +def test_real_claude_stdio_mcp_binding_and_identity_gate(tmp_path): + pytest.importorskip("mcp.server.fastmcp") + from mcp import ClientSession, StdioServerParameters + from mcp.client.stdio import stdio_client + from loopx.claude_goal_mode.scripts.goalmode_cmd import write_loop_md + + project, _, launcher = runner.shared.setup(tmp_path) + write_loop_md(project, runner.shared.GOAL, runner.shared.AGENT) + state = project / "ACTIVE_GOAL_STATE.md" + before = state.read_bytes() + params = StdioServerParameters( + command=sys.executable, + args=[str(REPO / "loopx/claude_goal_mode/mcp/loopx_mcp.py")], + cwd=str(project), env={**os.environ, "PYTHONPATH": str(REPO), + "PATH": str(launcher.parent) + os.pathsep + os.environ["PATH"]}, + ) + async def exercise(): + async with stdio_client(params) as (read, write): + async with ClientSession(read, write) as session: + await session.initialize() + tools = await session.list_tools() + assert {"should_run", "claim_task", "complete_task"} <= {t.name for t in tools.tools} + guard = await session.call_tool("should_run", {}) + payload = json.loads(guard.content[0].text) + assert payload["ok"] is True and payload["selected_todo"]["todo_id"] == "todo_reducer" + rejected = await session.call_tool("claim_task", {"todo_id": "todo_reducer", "agent_id": "other-agent"}) + assert json.loads(rejected.content[0].text)["ok"] is False + asyncio.run(exercise()) + assert state.read_bytes() == before diff --git a/tests/test_goal_mode_mcp_settlement.py b/tests/test_goal_mode_mcp_settlement.py index 6a17df4da1..4d49b58504 100644 --- a/tests/test_goal_mode_mcp_settlement.py +++ b/tests/test_goal_mode_mcp_settlement.py @@ -461,3 +461,45 @@ def test_real_mcp_terminal_completion_closes_out_after_spend( ) assert completed["status"] == "done" assert "no_followup=true" in state_file.read_text(encoding="utf-8") + + +@pytest.mark.parametrize("lost_after", ["lifecycle", "writeback", "spend"]) +def test_real_mcp_completion_recovers_a_lost_mutation_response(tmp_path, lost_after): + """The real write commits, but its caller sees a failure: retry must not pay twice.""" + registry, _ = _write_fixture(tmp_path) + added = add_goal_todo( + registry_path=registry, goal_id=GOAL_ID, role="agent", + text="Validate response-loss recovery.", task_class="advancement_task", + continuation_policy="same_agent_non_delivery", claimed_by=AGENT_ID, + ) + control = _control(registry) + original = control.run_cli + injected = False + + def lose_once(args, **kwargs): + nonlocal injected + output = original(args, **kwargs) + step = {"lifecycle": ["todo", "complete"], "writeback": ["refresh-state"], + "spend": ["quota", "spend-slot"]}[lost_after] + if not injected and args[:len(step)] == step and json.loads(output).get("ok"): + injected = True + return json.dumps({"ok": False, "error": "synthetic_response_lost_after_commit"}) + return output + + control.run_cli = lose_once + first = json.loads(control.complete_task( + added["todo_id"], AGENT_ID, "recovery fixture check passed", no_follow_up=True, + )) + assert injected and first["ok"] is False + replay = json.loads(control.complete_task( + added["todo_id"], AGENT_ID, "recovery fixture check passed", no_follow_up=True, + )) + assert replay["ok"] is True + again = json.loads(control.complete_task( + added["todo_id"], AGENT_ID, "recovery fixture check passed", no_follow_up=True, + )) + assert again["ok"] is True + assert again["settlement_identity"] == replay["settlement_identity"] + assert again["settlement"]["quota_spend"]["appended"] is False + status = json.loads(control.should_run()) + assert status["quota"]["spent_slots"] == 1 diff --git a/tests/test_native_goal_release_qualification.py b/tests/test_native_goal_release_qualification.py index 8ec588a820..359bbd2700 100644 --- a/tests/test_native_goal_release_qualification.py +++ b/tests/test_native_goal_release_qualification.py @@ -3,6 +3,7 @@ import importlib.util import json from pathlib import Path +from types import SimpleNamespace import pytest @@ -87,3 +88,21 @@ def test_settlement_oracle_rejects_missing_identity_and_duplicate_spend(tmp_path todos = [{"todo_id": todo, "status": "done"} for todo in runner.TODOS] with pytest.raises(AssertionError): runner.verify_settlement(tmp_path, todos) + + +@pytest.mark.parametrize("missing", ["writeback", "spend", "receipt"]) +def test_settlement_oracle_requires_durable_receipts_not_only_index_rows(tmp_path, monkeypatch, missing): + from loopx.control_plane.quota import settlement + + receipt = None if missing == "receipt" else SimpleNamespace( + writeback=None if missing == "writeback" else {"ok": True}, + spend=None if missing == "spend" else {"ok": True}, + ) + monkeypatch.setattr(settlement, "read_heartbeat_settlement", lambda *_, **__: receipt) + rows = [{"classification": "quota_slot_spent", "todo_id": todo, "turn_instance_id": todo, + "settlement_identity": {"effect_id": todo}} for todo in sorted(runner.TODOS)] + index = tmp_path / "goals" / runner.GOAL / "runs/index.jsonl" + index.parent.mkdir(parents=True) + index.write_text("\n".join(json.dumps(row) for row in rows)) + with pytest.raises(AssertionError): + runner.verify_settlement(tmp_path, [{"todo_id": t, "status": "done"} for t in runner.TODOS]) From 520c06f1da0917cb202fa7e6a2bad54137c3860d Mon Sep 17 00:00:00 2001 From: huangruiteng Date: Thu, 10 Sep 2026 23:58:36 +0800 Subject: [PATCH 16/26] fix(hosts): separate Todo acceptance from Turn settlement Signed-off-by: huangruiteng --- docs/development/testing-and-quality.md | 8 +++ docs/heartbeat-automation-prompt.md | 6 +++ docs/project-agent-todo-contract.md | 24 +++++---- .../claude_goal_mode/scripts/goalmode_cmd.py | 4 +- loopx/cli_commands/todo.py | 48 ++--------------- loopx/goal_mode_mcp.py | 17 ++++++- loopx/kunluncode_goal_mode/guards.py | 2 + scripts/qualify-claude-goal-release.py | 29 +++++++++++ scripts/qualify-native-goal-release.py | 14 +++-- tests/control_plane/test_quota_settlement.py | 27 ++-------- tests/fixtures/native_goal_ledger/TASK.md | 5 +- .../test_claude_goal_release_qualification.py | 47 +++++++++++++++++ ...est_goal_mode_mcp_completion_validation.py | 28 +++++----- tests/test_goal_mode_mcp_settlement.py | 51 ++++++++++++++++++- .../test_native_goal_release_qualification.py | 15 +++++- 15 files changed, 225 insertions(+), 100 deletions(-) diff --git a/docs/development/testing-and-quality.md b/docs/development/testing-and-quality.md index c543d5a24e..bee8a8f795 100644 --- a/docs/development/testing-and-quality.md +++ b/docs/development/testing-and-quality.md @@ -804,6 +804,14 @@ Before calling a changed host surface release-qualified, distinguish: 环境缺失可 skip 且退出成功,但最终版本没有完整的真实 host 结果时,不得写成 “产品级发布验证通过”。单次成功也不是模型可靠性或长程调度 soak 的证明。 +The real delivery regression covers ordinary Todo acceptance before internal +writeback/spend, existing-successor linking, and receipt-backed terminal closure. +Its task specification describes only the deliverable; the external oracle also +checks LoopX accounting. Passing non-delivery fixtures does not qualify delivery. +The same delivery class must pass failed-validation rejection and committed +response-loss recovery without duplicate spending or premature terminal closure. +Do not relabel delivery work or weaken the independent oracle to pass a host test. + ## Exact Release Commit Gate / 精确发布 Commit 门 The final release gate does not rerun tests through a second orchestration diff --git a/docs/heartbeat-automation-prompt.md b/docs/heartbeat-automation-prompt.md index 3ad905ea34..d0bb28ff1a 100644 --- a/docs/heartbeat-automation-prompt.md +++ b/docs/heartbeat-automation-prompt.md @@ -107,6 +107,12 @@ transaction: callers must not perform a second accounting sequence through CLI. Partial work is not Todo completion. Native `/loop` remains Claude's scheduler; only the current Goal's wakeup may be cancelled after terminal no-follow-up. These changes apply when `loop.md` is regenerated, not by editing active user files. +The MCP tool now exposes `successor_todo_ids`, reusing CLI/TS completion semantics +to link known follow-up without creating another Todo. Ordinary acceptance and +Turn settlement are distinct: the adapter validates/completes work before its +writeback/spend; only terminal closeout requires the full receipt chain. This +removes the former delivery-class circular prerequisite, not validation or +accounting. See the [release test guide](development/testing-and-quality.md#claude-code-and-release-coverage--claude-code-与发布覆盖). For Codex App, the generated quota command carries the compact explicit runtime profile `--runtime-profile codex_app_heartbeat` (generated commands use the diff --git a/docs/project-agent-todo-contract.md b/docs/project-agent-todo-contract.md index 7ba679a51b..72b389f4ba 100644 --- a/docs/project-agent-todo-contract.md +++ b/docs/project-agent-todo-contract.md @@ -444,11 +444,12 @@ the agent should do one of two things: This succession decision is durable Todo state. A later progress observation, vision ACK, coverage-exhausted result, or rewritten rationale cannot substitute for it. Every new completion therefore retains an opaque completion identity. -A quota-bound completion still permits only the receipt-backed same-turn -`todo complete` transition for agent advancement work. Complete the matching -accountable `refresh-state` and `quota spend-slot` first; explicit -`same_agent_non_delivery` work, monitors, user actions, and user gates keep -their existing lifecycle paths. An ordinary unscoped completion gets a +A quota-bound ordinary completion proves the exact admitted identity and Todo +acceptance (including declared validation), not completion of Turn accounting. +It may precede the same-turn writeback and spend. The existing typed replay +phase remains `settlement_pending` until those receipts exist; Todo `done` alone +does not mean the Turn settled. Terminal `--no-follow-up` still requires the +complete matching writeback/spend chain. An ordinary unscoped completion gets a stable `local_completion_*` identity; if a later `refresh-state` discovers that the finished Goal has no real successor, its typed rejection may project `--completion-identity-key` for one direct lifecycle reentry. That command is @@ -458,11 +459,14 @@ It cannot be supplied for an open Todo or used as a quota turn identity. Otherwise add/link a real successor. Do not create a user gate merely to silence a succession warning. -Compatibility host adapters whose established transaction completes the Todo -before writing the same-turn refresh and quota receipts must explicitly mark -their non-repository work `same_agent_non_delivery`. Repository advancement -through those adapters fails closed with a typed settlement blocker until the -adapter adopts a writeback-and-spend-before-completion transaction. +Host adapters own the internal sequence: validate/complete, write back, spend, +then terminal closeout if requested. A failed internal step is not a request to +redo accepted task work: retry the same identity and recover the missing receipt. +MCP returns success only after the whole requested sequence succeeds. Task +acceptance must not prescribe LoopX bookkeeping, and delivery work must not be +relabeled `same_agent_non_delivery` to escape a contradictory internal ordering. +This intentionally removes the old task-class-dependent CLI prerequisite while +retaining declared validation, claim/lease checks, identity and terminal fences. This keeps the active checklist honest without making LoopX a heavyweight project-management state machine. diff --git a/loopx/claude_goal_mode/scripts/goalmode_cmd.py b/loopx/claude_goal_mode/scripts/goalmode_cmd.py index 829469d9ae..02e7887208 100644 --- a/loopx/claude_goal_mode/scripts/goalmode_cmd.py +++ b/loopx/claude_goal_mode/scripts/goalmode_cmd.py @@ -77,6 +77,8 @@ def loop_md_content(goal_id, agent_id) -> str: "Run real acceptance checks before `complete_task`; supply truthful evidence\n" f"and the bound agent_id=\"{agent_id}\". Complete only finished Todos, not partial work.\n" "That MCP operation owns writeback/spend; do not repeat its accounting via CLI.\n" + "Link already planned follow-up via successor_todo_ids; next_agent_todo creates\n" + "new work, not a reference to an existing id. Do not duplicate the current plan.\n" "After a lost response, read back or retry the same completion intent; do not\n" "invent a new successor or settlement identity. Recheck `should_run()` afterward.\n" "Continue authorized work while the live contract requires it; notification\n" @@ -244,7 +246,7 @@ def main(): print(" wrote : .claude/loop.md (the per-tick protocol)") print() print("START WORKING — run native `/loop` (Claude self-paces) or `/loop 10m` (fixed cadence).") - print("Each /loop tick runs: should_run -> claim_task -> ONE bounded verified segment -> complete_task.") + print("Each /loop tick follows should_run's current contract; complete_task settles only verified, finished work.") print("Stop with Esc or `/loopx off`.") diff --git a/loopx/cli_commands/todo.py b/loopx/cli_commands/todo.py index 70c894c2ae..4a3135ae92 100644 --- a/loopx/cli_commands/todo.py +++ b/loopx/cli_commands/todo.py @@ -6,9 +6,6 @@ from ..control_plane.coordination.local_authority import read_canonical_todo_fields_if_promoted from ..control_plane.todos.contract import ( - TODO_TASK_CLASS_ADVANCEMENT, - normalize_todo_continuation_policy, - normalize_todo_task_class, replan_successor_semantic_binding, ) from ..control_plane.capability_hooks import PostWritebackHookRegistration @@ -78,44 +75,18 @@ ] -def _completion_settlement_requirement( - todo: dict[str, object], - *, - no_follow_up: bool, -) -> str | None: - if no_follow_up: - return "terminal no-follow-up closeout" - task_class = normalize_todo_task_class( - todo.get("task_class"), - text=str(todo.get("text") or ""), - action_kind=todo.get("action_kind"), - ) - continuation_policy = normalize_todo_continuation_policy( - todo.get("continuation_policy") - ) - if ( - str(todo.get("role") or "") == "agent" - and task_class == TODO_TASK_CLASS_ADVANCEMENT - and continuation_policy != "same_agent_non_delivery" - ): - return "turn-scoped advancement completion" - return None - - def _completion_settlement_error( - todo: dict[str, object], settlement_readback: QuotaSettlementReadback, *, no_follow_up: bool, ) -> str | None: - requirement = _completion_settlement_requirement( - todo, - no_follow_up=no_follow_up, - ) - if requirement is None or settlement_readback.settlement.failure is None: + # Todo acceptance and Turn settlement are distinct facts. Ordinary + # completion can precede accounting (including controller validation). + # Only terminal intent requires the full chain before closing out. + if not no_follow_up or settlement_readback.settlement.failure is None: return None return ( - f"{requirement} requires matching writeback and quota spend receipts: " + "terminal no-follow-up closeout requires matching writeback and quota spend receipts: " + settlement_readback.settlement.failure.reason ) @@ -418,7 +389,6 @@ def handle_todo_command( settlement_result = None settlement_identity = None settlement_readback = None - completion_requires_settlement = False completion_error = None completion_turn_key = None completion_identity_source = None @@ -466,13 +436,7 @@ def handle_todo_command( raise ValueError( "turn-scoped Todo completion requires one durable Todo" ) - completion_requirement = _completion_settlement_requirement( - todo, - no_follow_up=bool(args.no_follow_up), - ) - completion_requires_settlement = completion_requirement is not None completion_error = _completion_settlement_error( - todo, settlement_readback=settlement_readback, no_follow_up=bool(args.no_follow_up), ) @@ -638,8 +602,6 @@ def handle_todo_command( settlement_result = ( settlement_readback.terminal_settlement if args.no_follow_up and settlement_identity is not None - else settlement_readback.settlement - if completion_requires_settlement else settlement_readback.identity ) payload["settlement_result"] = settlement_result_payload( diff --git a/loopx/goal_mode_mcp.py b/loopx/goal_mode_mcp.py index 328f53e840..95eebdc6b6 100644 --- a/loopx/goal_mode_mcp.py +++ b/loopx/goal_mode_mcp.py @@ -179,6 +179,7 @@ def complete_task( task_lease_idempotency_key: str = "", task_lease_expected_version: ExpectedTaskLeaseVersion = None, no_follow_up: bool = False, + successor_todo_ids: list[str] | None = None, ) -> str: goal_id, _ = self.context() if not goal_id: @@ -193,6 +194,13 @@ def complete_task( "error": "next_agent_todo and no_follow_up are mutually exclusive", } ) + if successor_todo_ids is not None and ( + not isinstance(successor_todo_ids, list) + or any(not isinstance(value, str) or not value.strip() for value in successor_todo_ids) + ): + return json.dumps({"ok": False, "error": "successor_todo_ids must be a list of nonempty ids"}) + if successor_todo_ids and (next_agent_todo or no_follow_up): + return json.dumps({"ok": False, "error": "choose existing successors, a new successor, or no follow-up"}) args = [ "todo", "complete", @@ -209,6 +217,8 @@ def complete_task( ] if next_agent_todo: args += ["--next-agent-todo", next_agent_todo] + for successor in successor_todo_ids or []: + args += ["--successor-todo-id", successor] if task_lease_idempotency_key: args += ["--task-lease-idempotency-key", task_lease_idempotency_key] if task_lease_expected_version is not None: @@ -273,8 +283,12 @@ def complete_task( task_lease_idempotency_key: str = "", task_lease_expected_version: ExpectedTaskLeaseVersion = None, no_follow_up: bool = False, + successor_todo_ids: list[str] | None = None, ) -> str: - """Complete one verified todo, write follow-up state, then spend quota.""" + """Complete verified work and settle once. Link existing planned successors + with successor_todo_ids; next_agent_todo creates a NEW Todo, not an id link. + Use no_follow_up only for terminal intent. Do not duplicate existing work. + """ return control.complete_task( todo_id, agent_id, @@ -283,6 +297,7 @@ def complete_task( task_lease_idempotency_key=task_lease_idempotency_key, task_lease_expected_version=task_lease_expected_version, no_follow_up=no_follow_up, + successor_todo_ids=successor_todo_ids, ) return server, control diff --git a/loopx/kunluncode_goal_mode/guards.py b/loopx/kunluncode_goal_mode/guards.py index ff5d91bcb2..15b8fd367e 100644 --- a/loopx/kunluncode_goal_mode/guards.py +++ b/loopx/kunluncode_goal_mode/guards.py @@ -41,12 +41,14 @@ def blocked_complete( task_lease_idempotency_key: str = "", task_lease_expected_version: int | None = None, no_follow_up: bool = False, + successor_todo_ids: list[str] | None = None, ) -> str: del ( next_agent_todo, task_lease_idempotency_key, task_lease_expected_version, no_follow_up, + successor_todo_ids, ) return _native_controller_rejection("complete_task") diff --git a/scripts/qualify-claude-goal-release.py b/scripts/qualify-claude-goal-release.py index 349cf7f985..bd3511ce51 100644 --- a/scripts/qualify-claude-goal-release.py +++ b/scripts/qualify-claude-goal-release.py @@ -80,6 +80,34 @@ def host_environment(root: Path, launcher: Path) -> dict[str, str]: return env +def verify_mcp_completions(events: list[dict]) -> None: + """A tool invocation is not evidence that the MCP transaction succeeded.""" + pending: dict[str, str] = {} + completed: set[str] = set() + for event in events: + for block in (event.get("message") or {}).get("content", []): + if block.get("type") == "tool_use" and block.get("name") == "mcp__loopx__complete_task": + pending[block["id"]] = (block.get("input") or {}).get("todo_id") + if block.get("type") != "tool_result" or block.get("tool_use_id") not in pending: + continue + content = block.get("content") + if isinstance(content, list): + content = "".join(item.get("text", "") for item in content if item.get("type") == "text") + try: + payload = json.loads(content) + if isinstance(payload, dict) and isinstance(payload.get("result"), str): + payload = json.loads(payload["result"]) + except (TypeError, ValueError): + continue + if not isinstance(payload, dict) or payload.get("ok") is not True: + continue + todo_id = pending[block["tool_use_id"]] + assert payload.get("todo_id") == todo_id and payload.get("completed") is True + assert (payload.get("settlement") or {}).get("ok") is True + completed.add(todo_id) + assert completed == shared.TODOS, "mcp_delivery_transactions_not_completed" + + def qualify(root: Path, claude: str, timeout: int) -> dict: from loopx.claude_goal_mode.scripts.goalmode_cmd import write_loop_md @@ -111,6 +139,7 @@ def qualify(root: Path, claude: str, timeout: int) -> dict: for block in (event.get("message") or {}).get("content", []) if block.get("type") == "tool_use"] assert "mcp__loopx__should_run" in calls and "mcp__loopx__complete_task" in calls, "mcp_not_exercised" + verify_mcp_completions(events) return {"status": "passed", "model_executed": True, "model": DOUBAO_SEED_EVOLVING_MODEL, "host": "claude_code", "scheduler_qualification": "not_run_headless", "mcp_tool_calls": sum(str(c).startswith("mcp__loopx__") for c in calls), diff --git a/scripts/qualify-native-goal-release.py b/scripts/qualify-native-goal-release.py index 081c631044..03f8264945 100644 --- a/scripts/qualify-native-goal-release.py +++ b/scripts/qualify-native-goal-release.py @@ -58,7 +58,7 @@ def setup(root: Path) -> tuple[Path, Path, Path]: (project / "ACTIVE_GOAL_STATE.md").write_text( '---\nstatus: active-read-only\nowner_mode: goal\n' 'objective: "Deliver the finite local ledger specification."\n---\n\n' - '# Ledger\n\n## Objective\n\nDeliver TASK.md, validate and settle both Todos.\n\n' + '# Ledger\n\n## Objective\n\nDeliver and validate TASK.md.\n\n' '## Next Action\n\nImplement the reducer, then the CLI.\n\n' '## User Todo\n\n## Agent Todo\n\n' '- [ ] [P1] Implement and validate the TASK.md reducer.\n' @@ -102,9 +102,11 @@ def cli(launcher: Path, *args: str) -> dict: def verify_settlement(runtime: Path, todos: list[dict]) -> int: + from loopx.control_plane.effect_program import SettlementStepKind from loopx.control_plane.quota.settlement import read_heartbeat_settlement - assert {t["todo_id"] for t in todos if t.get("status") == "done"} == TODOS + assert len(todos) == len(TODOS) and {t["todo_id"] for t in todos} == TODOS + assert all(t.get("status") == "done" for t in todos) rows = [json.loads(line) for line in (runtime / "goals" / GOAL / "runs/index.jsonl").read_text().splitlines()] spends = [r for r in rows if r.get("classification") == "quota_slot_spent"] @@ -118,7 +120,13 @@ def verify_settlement(runtime: Path, todos: list[dict]) -> int: replan_obligation_id=row.get("replan_obligation_id"), turn_instance_id=row["turn_instance_id"], ) - assert readback and readback.writeback and readback.spend + assert readback is not None, "missing_settlement_readback" + assert readback.settlement.failure is None, "incomplete_settlement" + assert {r.step_kind for r in readback.settlement.receipts} >= { + SettlementStepKind.VALIDATION, SettlementStepKind.DURABLE_WRITEBACK, + SettlementStepKind.QUOTA_SPEND, + }, "missing_settlement_receipts" + assert readback.writeback_run is not None and readback.spend_run is not None return len(spends) diff --git a/tests/control_plane/test_quota_settlement.py b/tests/control_plane/test_quota_settlement.py index 13c0c76b29..c69bb82df3 100644 --- a/tests/control_plane/test_quota_settlement.py +++ b/tests/control_plane/test_quota_settlement.py @@ -273,7 +273,7 @@ def test_quota_settlement_readback_returns_the_complete_typed_chain( assert readback.spend_run is not None -def test_advancement_completion_requires_the_complete_settlement_chain( +def test_only_terminal_closeout_requires_the_complete_settlement_chain( tmp_path: Path, ) -> None: runtime_root = tmp_path / "runtime" @@ -289,18 +289,12 @@ def test_advancement_completion_requires_the_complete_settlement_chain( ) assert incomplete is not None error = _completion_settlement_error( - { - "role": "agent", - "task_class": "advancement_task", - "action_kind": "implement", - "text": "Ship the repository change.", - }, incomplete, - no_follow_up=False, + no_follow_up=True, ) assert error is not None assert error.startswith( - "turn-scoped advancement completion requires matching writeback and " + "terminal no-follow-up closeout requires matching writeback and " "quota spend receipts:" ) @@ -318,26 +312,13 @@ def test_advancement_completion_requires_the_complete_settlement_chain( ) assert ( _completion_settlement_error( - { - "role": "agent", - "task_class": "advancement_task", - "action_kind": "implement", - "text": "Ship the repository change.", - }, settled, - no_follow_up=False, + no_follow_up=True, ) is None ) assert ( _completion_settlement_error( - { - "role": "agent", - "task_class": "advancement_task", - "action_kind": "research", - "continuation_policy": "same_agent_non_delivery", - "text": "Analyze the evidence.", - }, incomplete, no_follow_up=False, ) diff --git a/tests/fixtures/native_goal_ledger/TASK.md b/tests/fixtures/native_goal_ledger/TASK.md index cb2fbda446..0f59fa4aa7 100644 --- a/tests/fixtures/native_goal_ledger/TASK.md +++ b/tests/fixtures/native_goal_ledger/TASK.md @@ -17,9 +17,8 @@ with a trailing newline. Empty input succeeds with `{}`. Invalid JSON or domain input must leave stdout empty, write a concise diagnostic to stderr, and exit nonzero. Add reducer and subprocess tests, including long replay, and a README. -Complete both dependent LoopX Todos and settle validated work using the current -contract. This is a finite local task: no upload, publication, deployment, +This is a finite local task: no upload, publication, deployment, package installation, or external side effect is part of acceptance. Keep all changes in the disposable project/worktrees. Do not change this specification or LoopX source to make the task pass. Do not substitute fabricated evidence -or a non-delivery declaration for actual code delivery. +for actual code delivery. diff --git a/tests/test_claude_goal_release_qualification.py b/tests/test_claude_goal_release_qualification.py index 044a5e6b16..9b050ece77 100644 --- a/tests/test_claude_goal_release_qualification.py +++ b/tests/test_claude_goal_release_qualification.py @@ -61,6 +61,7 @@ def test_claude_loop_uses_current_contract_not_segment_or_empty_list_stop(): assert "Complete only finished Todos, not partial work" in prompt assert 'agent_id="agent-a"' in prompt assert "That MCP operation owns writeback/spend" in prompt + assert "successor_todo_ids" in prompt assert "unavailable/incomplete contract" in prompt @@ -84,6 +85,23 @@ def test_host_nonzero_is_not_reported_as_a_successful_model_turn(tmp_path): cwd=tmp_path, env=dict(os.environ), timeout=10) +@pytest.mark.parametrize("failed", [False, True]) +def test_mcp_oracle_requires_successful_transactions_not_only_invocations(failed): + events = [] + for todo in sorted(runner.shared.TODOS): + events.append({"message": {"content": [{"type": "tool_use", "id": todo, + "name": "mcp__loopx__complete_task", "input": {"todo_id": todo}}]}}) + result = {"ok": not failed, "completed": True, "todo_id": todo, + "settlement": {"ok": not failed}} + events.append({"message": {"content": [{"type": "tool_result", "tool_use_id": todo, + "content": json.dumps({"result": json.dumps(result)})}]}}) + if failed: + with pytest.raises(AssertionError, match="mcp_delivery_transactions_not_completed"): + runner.verify_mcp_completions(events) + else: + runner.verify_mcp_completions(events) + + def test_real_claude_stdio_mcp_binding_and_identity_gate(tmp_path): pytest.importorskip("mcp.server.fastmcp") from mcp import ClientSession, StdioServerParameters @@ -106,6 +124,8 @@ async def exercise(): await session.initialize() tools = await session.list_tools() assert {"should_run", "claim_task", "complete_task"} <= {t.name for t in tools.tools} + complete = next(t for t in tools.tools if t.name == "complete_task") + assert "successor_todo_ids" in complete.inputSchema["properties"] guard = await session.call_tool("should_run", {}) payload = json.loads(guard.content[0].text) assert payload["ok"] is True and payload["selected_todo"]["todo_id"] == "todo_reducer" @@ -113,3 +133,30 @@ async def exercise(): assert json.loads(rejected.content[0].text)["ok"] is False asyncio.run(exercise()) assert state.read_bytes() == before + + +def test_real_mcp_delivery_completes_and_settles_existing_plan(tmp_path): + from loopx.goal_mode_mcp import GoalModeMCPConfig, GoalModeMCPControlPlane + + project, runtime, launcher = runner.shared.setup(tmp_path) + # Real delivery class: do not substitute same_agent_non_delivery to make + # this acceptance test green. No live model or external side effect. + (project / "delivery.txt").write_text("synthetic verified delivery\n") + control = GoalModeMCPControlPlane( + GoalModeMCPConfig(server_name="loopx", runtime_profile="claude_code", legacy_host_surface="claude_code"), + lambda: {"goal_id": runner.shared.GOAL, "agent_id": runner.shared.AGENT, + "registry": str(project / ".loopx/registry.json")}, + ) + control.command_prefix = lambda: [str(launcher)] + result = json.loads(control.complete_task( + "todo_reducer", runner.shared.AGENT, "synthetic delivery validation passed", + successor_todo_ids=["todo_cli"], + )) + assert result["ok"] is True, "unexpected completion failure" + todos = runner.shared.cli(launcher, "todo", "list", "--goal-id", runner.shared.GOAL, "--role", "agent")["todos"] + assert {row["todo_id"] for row in todos} == runner.shared.TODOS + assert next(row for row in todos if row["todo_id"] == "todo_reducer")["status"] == "done" + index = runtime / "goals" / runner.shared.GOAL / "runs/index.jsonl" + spends = [json.loads(line) for line in index.read_text().splitlines() + if json.loads(line).get("classification") == "quota_slot_spent"] + assert len(spends) == 1 and spends[0]["todo_id"] == "todo_reducer" diff --git a/tests/test_goal_mode_mcp_completion_validation.py b/tests/test_goal_mode_mcp_completion_validation.py index 36301cdca4..e0573167e4 100644 --- a/tests/test_goal_mode_mcp_completion_validation.py +++ b/tests/test_goal_mode_mcp_completion_validation.py @@ -5,6 +5,8 @@ import sys from pathlib import Path +import pytest + from loopx.goal_mode_mcp import GoalModeMCPConfig, GoalModeMCPControlPlane from loopx.status import parse_active_state_todos from loopx.todos import add_goal_todo @@ -68,7 +70,6 @@ def _add_todo(registry: Path, *, validation_command: str | None = None) -> str: text="Deliver one bounded change.", task_class="advancement_task", claimed_by=AGENT, - continuation_policy="same_agent_non_delivery", validation_command=validation_command, ) return str(todo["todo_id"]) @@ -125,10 +126,13 @@ def test_mcp_complete_task_fails_closed_on_failing_declared_validation( assert _agent_todo_status(state, todo_id) != "done" -def test_mcp_advancement_completion_returns_typed_settlement_blocker( - tmp_path: Path, +@pytest.mark.parametrize("no_follow_up", [False, True]) +def test_mcp_advancement_validation_precedes_controller_owned_settlement( + tmp_path: Path, no_follow_up: bool, ) -> None: registry, state = _write_fixture(tmp_path) + validation = state.parent / "check.py" + validation.write_text("from pathlib import Path\nPath('validation-ran').write_text('passed')\n") todo = add_goal_todo( registry_path=registry, goal_id=GOAL_ID, @@ -136,22 +140,22 @@ def test_mcp_advancement_completion_returns_typed_settlement_blocker( text="Deliver one repository advancement.", task_class="advancement_task", claimed_by=AGENT, + validation_command=shlex.join([sys.executable, str(validation)]), ) todo_id = str(todo["todo_id"]) payload = _first_json_blob( - _control(registry).complete_task(todo_id, AGENT, "claimed done") + _control(registry).complete_task(todo_id, AGENT, "validated delivery", no_follow_up=no_follow_up) ) - assert payload["ok"] is False - assert payload["completed"] is False - assert payload["changed"] is False - assert payload["settlement_blocked_completion"] is True + assert payload["ok"] is True, payload + assert payload["completed"] is True assert payload["settlement_identity"]["todo_id"] == todo_id - assert payload["settlement_result"]["failure"]["kind"] == ( - "writeback_missing" - ) - assert _agent_todo_status(state, todo_id) != "done" + assert (state.parent / "validation-ran").read_text() == "passed" + assert payload["settlement"]["durable_writeback"]["ok"] is True + assert payload["settlement"]["quota_spend"]["appended"] is True + assert ("terminal_closeout" in payload["settlement"]) is no_follow_up + assert _agent_todo_status(state, todo_id) == "done" def test_expected_lease_version_annotation_rejects_bool_at_the_boundary() -> None: """FastMCP validates tool arguments with pydantic, and lax pydantic diff --git a/tests/test_goal_mode_mcp_settlement.py b/tests/test_goal_mode_mcp_settlement.py index 4d49b58504..3dfafc7fc9 100644 --- a/tests/test_goal_mode_mcp_settlement.py +++ b/tests/test_goal_mode_mcp_settlement.py @@ -470,19 +470,21 @@ def test_real_mcp_completion_recovers_a_lost_mutation_response(tmp_path, lost_af added = add_goal_todo( registry_path=registry, goal_id=GOAL_ID, role="agent", text="Validate response-loss recovery.", task_class="advancement_task", - continuation_policy="same_agent_non_delivery", claimed_by=AGENT_ID, + claimed_by=AGENT_ID, ) control = _control(registry) original = control.run_cli injected = False + bound_identity = None def lose_once(args, **kwargs): - nonlocal injected + nonlocal injected, bound_identity output = original(args, **kwargs) step = {"lifecycle": ["todo", "complete"], "writeback": ["refresh-state"], "spend": ["quota", "spend-slot"]}[lost_after] if not injected and args[:len(step)] == step and json.loads(output).get("ok"): injected = True + bound_identity = json.loads(output)["settlement_identity"] return json.dumps({"ok": False, "error": "synthetic_response_lost_after_commit"}) return output @@ -491,6 +493,20 @@ def lose_once(args, **kwargs): added["todo_id"], AGENT_ID, "recovery fixture check passed", no_follow_up=True, )) assert injected and first["ok"] is False + from loopx.control_plane.quota.settlement import read_heartbeat_settlement + readback = read_heartbeat_settlement( + tmp_path / "runtime", goal_id=GOAL_ID, agent_id=AGENT_ID, + todo_id=added["todo_id"], turn_instance_id=bound_identity["turn_instance_id"], + ) + assert readback.replay_phase == ("settled" if lost_after == "spend" else "settlement_pending") + if lost_after != "spend": + rc, terminal = _run_cli( + registry, "todo", "complete", "--goal-id", GOAL_ID, + "--todo-id", added["todo_id"], "--agent-id", AGENT_ID, + "--turn-instance-id", bound_identity["turn_instance_id"], + "--no-follow-up", "--evidence", "synthetic terminal intent", + ) + assert rc != 0 and terminal["settlement_blocked_completion"] is True replay = json.loads(control.complete_task( added["todo_id"], AGENT_ID, "recovery fixture check passed", no_follow_up=True, )) @@ -503,3 +519,34 @@ def lose_once(args, **kwargs): assert again["settlement"]["quota_spend"]["appended"] is False status = json.loads(control.should_run()) assert status["quota"]["spent_slots"] == 1 + + +def test_real_mcp_links_existing_successor_without_creating_another_todo(tmp_path): + registry, state_file = _write_fixture(tmp_path) + ids = [str(add_goal_todo( + registry_path=registry, goal_id=GOAL_ID, role="agent", text=text, + task_class="advancement_task", + claimed_by=AGENT_ID, + )["todo_id"]) for text in ("First accepted step.", "Already planned follow-up.")] + control = _control(registry) + before = state_file.read_bytes() + rejected = json.loads(control.complete_task( + ids[0], AGENT_ID, "synthetic check passed", successor_todo_ids=[ids[1]], no_follow_up=True, + )) + assert rejected["ok"] is False + assert state_file.read_bytes() == before + first = json.loads(control.complete_task( + ids[0], AGENT_ID, "synthetic check passed", successor_todo_ids=[ids[1]], + )) + assert first["ok"] is True, first + replay = json.loads(control.complete_task( + ids[0], AGENT_ID, "synthetic check passed", successor_todo_ids=[ids[1]], + )) + assert replay["ok"] is True + assert replay["settlement_identity"] == first["settlement_identity"] + todos = parse_active_state_todos(state_file.read_text())["agent_todos"]["items"] + by_id = {row["todo_id"]: row for row in todos} + assert set(by_id) == set(ids) + assert by_id[ids[0]]["status"] == "done" and by_id[ids[1]]["status"] == "open" + assert by_id[ids[0]]["successor_todo_ids"] == [ids[1]] + assert json.loads(control.should_run())["quota"]["spent_slots"] == 1 diff --git a/tests/test_native_goal_release_qualification.py b/tests/test_native_goal_release_qualification.py index 359bbd2700..5fa9b84276 100644 --- a/tests/test_native_goal_release_qualification.py +++ b/tests/test_native_goal_release_qualification.py @@ -93,10 +93,14 @@ def test_settlement_oracle_rejects_missing_identity_and_duplicate_spend(tmp_path @pytest.mark.parametrize("missing", ["writeback", "spend", "receipt"]) def test_settlement_oracle_requires_durable_receipts_not_only_index_rows(tmp_path, monkeypatch, missing): from loopx.control_plane.quota import settlement + from loopx.control_plane.effect_program import SettlementFailure, SettlementFailureKind, SettlementResult receipt = None if missing == "receipt" else SimpleNamespace( - writeback=None if missing == "writeback" else {"ok": True}, - spend=None if missing == "spend" else {"ok": True}, + # Real failed result objects are truthy, unlike None. The oracle must + # inspect the typed failure, not accept object existence as evidence. + settlement=SettlementResult(value=None, failure=SettlementFailure( + kind=SettlementFailureKind.RECEIPT_MISSING, reason=missing, step_kind=None, + )), ) monkeypatch.setattr(settlement, "read_heartbeat_settlement", lambda *_, **__: receipt) rows = [{"classification": "quota_slot_spent", "todo_id": todo, "turn_instance_id": todo, @@ -106,3 +110,10 @@ def test_settlement_oracle_requires_durable_receipts_not_only_index_rows(tmp_pat index.write_text("\n".join(json.dumps(row) for row in rows)) with pytest.raises(AssertionError): runner.verify_settlement(tmp_path, [{"todo_id": t, "status": "done"} for t in runner.TODOS]) + + +def test_settlement_oracle_rejects_duplicate_open_successor(tmp_path): + todos = [{"todo_id": t, "status": "done"} for t in runner.TODOS] + todos.append({"todo_id": "todo_duplicate", "status": "open"}) + with pytest.raises(AssertionError): + runner.verify_settlement(tmp_path, todos) From 263c79470dac6bbce7e7a5845ee34854b60ac76a Mon Sep 17 00:00:00 2001 From: huangruiteng Date: Fri, 11 Sep 2026 11:01:41 +0800 Subject: [PATCH 17/26] fix(heartbeat): preserve static safety without stale execution policy Signed-off-by: huangruiteng --- docs/heartbeat-automation-prompt.md | 34 ++++++++-- docs/reference/automation-prompt-upgrades.md | 6 ++ .../control_plane/heartbeat-prompt-smoke.py | 29 +++------ .../claude_goal_mode/scripts/goalmode_cmd.py | 3 + .../heartbeat/automation_upgrade.py | 5 +- loopx/control_plane/heartbeat/budget.py | 4 +- loopx/control_plane/heartbeat/rules.py | 16 ++++- loopx/control_plane/heartbeat/task_body.py | 64 ++++++++----------- .../test_automation_prompt_upgrade.py | 25 +++++++- .../test_goal_prompt_dispatch.py | 50 ++++++++++++++- tests/test_host_loop_activation.py | 4 +- 11 files changed, 168 insertions(+), 72 deletions(-) diff --git a/docs/heartbeat-automation-prompt.md b/docs/heartbeat-automation-prompt.md index d0bb28ff1a..ae179bb503 100644 --- a/docs/heartbeat-automation-prompt.md +++ b/docs/heartbeat-automation-prompt.md @@ -57,6 +57,35 @@ automation contract. ### Native Goal bootstrap and live execution instructions +#### Static semantics retained across hosts + +Thinning removes duplicated recipes, not authority boundaries. The shared +runtime body keeps repository rules, credential/private-material protection, +explicit authorization for destructive Git/production actions, and exception +routes (`loopx-project` for lifecycle/registry, `loopx-self-repair` for drift). +These routes are conditional, not mandatory skill calls on every iteration. +Ordinary Claude MCP iterations still use MCP; the CLI route is not a second +accounting path. + +| Semantic | Owner after thinning | +| --- | --- | +| Privacy, repository rules, dangerous-action authority | Shared static safety rule; a trusted host is not blanket permission | +| Lifecycle or projection repair | Conditional static repair route; repair does not bypass gates | +| Selection, claims, vision replan, exact settlement identities/order | Current successful interaction contract, not saved command recipes | +| Blocked path vs whole Goal | Gate only the affected path; continue independent admitted work; only terminal no-follow-up completes the Goal | +| Git branch/worktree/PR policy | User and repository rules; no generic `No project branches` restriction | +| Prompt authoring/maintenance advice | Documentation, not per-iteration executor instructions | + +For heartbeat shells, assign `LOOPX_TURN` in a separate statement before the +guard, in the same shell invocation. A command-prefix assignment does not make +the variable available to argument expansion in Bash/zsh. Reuse the same value +on retries. Native Goal entry remains host-specific and does not inherit this +heartbeat bootstrap. + +Thin's ceiling is 2,500 characters (previously 1,900), and compact's is 6,500 +(previously 6,200): the additional room covers shared safety and an executable +Turn/guard block rather than omitting identities or static obligations. + The automation lifecycle is the reference for shared execution, not a wrapper around native Goal behavior. Thin automation and Codex CLI/SSH, TraeX and Ark Managed Agent Goal bodies share quota dispatch: selection/re-entry, admitted work @@ -344,11 +373,6 @@ Replace the placeholders before installing the automation: ```text Advance the goal described in . -Generic LoopX lifecycle. Keep project-specific branching out of the -automation prompt. Put local policy in registry, active-state sections, adapter -output, quota should-run.goal_boundary, or boundary rules; if a lifecycle rule -is needed, update loopx heartbeat-prompt so all projects inherit it. - Before spending delivery compute, first make the LoopX CLI reachable in this automation shell, then run the quota guard: diff --git a/docs/reference/automation-prompt-upgrades.md b/docs/reference/automation-prompt-upgrades.md index 387a747f49..126249e2a2 100644 --- a/docs/reference/automation-prompt-upgrades.md +++ b/docs/reference/automation-prompt-upgrades.md @@ -75,6 +75,12 @@ readback completes. Restart afterward. This adapter targets the observed local schema, **not an official stable Codex storage API**; no Windows/cloud support is claimed. Use the native API if the host changes its storage contract. +A legacy TOML heartbeat label with a SQLite `cron` row is not enough to prove +thread ownership. In particular, a missing database thread binding must not be +filled from TOML by a prompt-only migration. Such records require App-mediated +reconciliation first; the offline adapter reports that action explicitly and +does not convert scheduler kind, infer a thread, or offer an executable upgrade. + ## Recovery and rollback The fallback stores a private per-task journal before writing. SQLite commit diff --git a/examples/control_plane/heartbeat-prompt-smoke.py b/examples/control_plane/heartbeat-prompt-smoke.py index b46378317f..3462e97fd6 100644 --- a/examples/control_plane/heartbeat-prompt-smoke.py +++ b/examples/control_plane/heartbeat-prompt-smoke.py @@ -337,7 +337,7 @@ def main() -> int: state_only_refresh = str(prompt_payload["refresh_state_command"]) assert task_body.index(progress_refresh) < task_body.index(quota_spend), prompt_label assert task_body.index(quota_spend) < task_body.rindex(state_only_refresh), prompt_label - assert len(str(compact_payload["task_body"])) < len(str(payload["task_body"])) * 0.47, ( + assert len(str(compact_payload["task_body"])) < len(str(payload["task_body"])), ( len(str(compact_payload["task_body"])), len(str(payload["task_body"])), ) @@ -485,7 +485,7 @@ def main() -> int: "steering audit", "bottleneck lens", "no-progress self-repair", - "Public-safe commit/push/PR may proceed", + "Gate only the affected path; continue independent allowed work", "loopx todo add --goal-id public-heartbeat-goal --role user --task-class user_gate|user_action", "owner todos and `--role agent` for agent todos, not prose", "Done->successor first; final->refresh->spend->no-follow-up", @@ -567,7 +567,7 @@ def main() -> int: assert live_peer_budget["within_budget"] is True, live_peer_budget assert len(str(live_peer_payload["task_body"])) <= int(live_peer_budget["max_chars"]), live_peer_budget assert "correctness.." not in live_peer_task, live_peer_task - assert live_peer_task.index("`LOOPX_TURN=`") < live_peer_task.index( + assert live_peer_task.index("LOOPX_TURN=") < live_peer_task.index( "quota should-run" ), live_peer_task for phrase in ( @@ -600,7 +600,7 @@ def main() -> int: "drain/read/triage before work; settle/ACK", "P0 blocked: safe P1/P2; monitor quiet/no-spend", "No learning queue unless asked", - "Stop: private material, credentials, destructive git, unauthorized prod", + "Destructive Git/production requires explicit authorization", ): assert phrase in live_peer_task, phrase for phrase in ( @@ -640,7 +640,7 @@ def main() -> int: for phrase in ( "Brief LoopX heartbeat; detail", "loopx heartbeat-prompt --compact --goal-id public-heartbeat-goal --active-state /tmp/public-heartbeat-goal/ACTIVE_GOAL_STATE.md", - "Guard/retry; `LOOPX_TURN=`", + "Run assignment and guard as separate statements in one shell", 'loopx --format json --registry "$HOME/.codex/loopx/registry.global.json" quota should-run --goal-id public-heartbeat-goal', "`user_channel.notify` controls OUTPUT only: NOTIFY=向用户输出动作; DONT_NOTIFY=安静输出", "Due/peer非用户动作", @@ -684,7 +684,7 @@ def main() -> int: "Normal turns use CLI `interaction_contract`; use `loopx-project` for " "lifecycle/registry and `loopx-self-repair` for runtime/projection drift", "use selection_command when required", - "`quota should-run`", + "quota should-run", "`user_channel.notify` controls OUTPUT only: NOTIFY=向用户输出动作; DONT_NOTIFY=安静输出", "Due/peer非用户动作", "NOTIFY缺动作→", @@ -697,7 +697,7 @@ def main() -> int: "P0 blocked: safe P1/P2", "monitor quiet/no-spend", "No learning queue unless asked", - "Stop: private material, credentials, destructive git, unauthorized prod", + "Destructive Git/production requires explicit authorization", ): assert phrase in thin_task, phrase for label, task in ( @@ -722,11 +722,6 @@ def main() -> int: must_have = ( "", "", - "Generic LoopX lifecycle", - "Keep project-specific branching out of the automation prompt", - "Put local policy in registry, active-state sections, adapter output", - "quota should-run.goal_boundary", - "update loopx heartbeat-prompt so all projects inherit it", 'export PATH="$HOME/.local/bin:$PATH"', 'install_script="$HOME/loopx/scripts/install-local.sh"', "loopx doctor >/dev/null", @@ -854,10 +849,6 @@ def main() -> int: for phrase in ( 'export PATH="$HOME/.local/bin:$PATH"', 'install_script="$HOME/loopx/scripts/install-local.sh"', - "Generic LoopX lifecycle", - "Keep project-specific branching out of the automation prompt", - "Put local policy in registry, active-state sections, adapter output", - "quota should-run.goal_boundary", "loopx doctor >/dev/null", 'loopx --format json --registry "$HOME/.codex/loopx/registry.global.json" quota should-run --goal-id public-heartbeat-goal', "If that preflight still fails", @@ -932,9 +923,9 @@ def main() -> int: "授权/预算内推进可验证结果", "a focused correction may suffice", "Stay inside `goal_boundary` when present", - "Public-safe repo publication is not an operator gate by itself", - "commit, push, and PR creation may proceed autonomously after validation", - "clean public/private boundary scan", + "Follow user authority and repository rules", + "publish public-safe evidence", + "Destructive Git/production requires explicit authorization", "Plan/top todo/route changes need todo/Next Action writeback", "If a user/owner todo appears", "loopx todo add --goal-id public-heartbeat-goal --role user --task-class user_gate", diff --git a/loopx/claude_goal_mode/scripts/goalmode_cmd.py b/loopx/claude_goal_mode/scripts/goalmode_cmd.py index 02e7887208..b5e6dbcb59 100644 --- a/loopx/claude_goal_mode/scripts/goalmode_cmd.py +++ b/loopx/claude_goal_mode/scripts/goalmode_cmd.py @@ -37,6 +37,8 @@ sys.path.insert(0, str(HERE.parent / "hooks")) from goal_state import goal_context, find_registry, loop_md_path # noqa: E402 from loopx.control_plane.heartbeat.rules import ( # noqa: E402 + HOST_LOOP_SAFETY_RULE, + RUNTIME_REPAIR_ROUTING_RULE, SCOPE_BOUNDED_WORK_RULE, ) @@ -69,6 +71,7 @@ def loop_md_content(goal_id, agent_id) -> str: f"\n" f"loopx tick — advance goal `{goal_id}` (agent `{agent_id}`). Use the wired loopx MCP\n" f"tools; do NOT run `loopx --help` or guess ids.\n\n" + f"{HOST_LOOP_SAFETY_RULE}\n{RUNTIME_REPAIR_ROUTING_RULE}\n" "Read complete successful `should_run()` JSON each work iteration. Follow its\n" "current `interaction_contract`: selection/re-entry before admitted work,\n" "then validation and settlement. Never infer completion from an empty Todo list.\n" diff --git a/loopx/control_plane/heartbeat/automation_upgrade.py b/loopx/control_plane/heartbeat/automation_upgrade.py index 5a4edc69f1..88faae40e5 100644 --- a/loopx/control_plane/heartbeat/automation_upgrade.py +++ b/loopx/control_plane/heartbeat/automation_upgrade.py @@ -170,7 +170,10 @@ def _read(home: Path, automation_id: str, connection: sqlite3.Connection) -> tup raise ValueError("automation identity missing or mismatched") row = dict(row) if item.get("kind") != "heartbeat" or row["kind"] != "heartbeat": - raise ValueError("only existing heartbeat automations are supported") + raise ValueError( + "heartbeat kind/binding is not confirmed by both stores; reconcile through " + "the App before prompt adoption (do not convert a cron row or infer its thread)" + ) if item.get("status") == "DELETED" or row["status"] == "DELETED": raise ValueError("deleted automation cannot be upgraded") for key in ("prompt", "status", "target_thread_id"): diff --git a/loopx/control_plane/heartbeat/budget.py b/loopx/control_plane/heartbeat/budget.py index 07b43eab73..d89c401de7 100644 --- a/loopx/control_plane/heartbeat/budget.py +++ b/loopx/control_plane/heartbeat/budget.py @@ -7,9 +7,9 @@ INTERFACE_BUDGET_CHARS = { "full": 12_000, - "compact": 6_200, + "compact": 6_500, "brief": 3_500, - "thin": 1_900, + "thin": 2_500, "visible_goal": 4_000, } NATIVE_GOAL_HOST_MAX_CHARS = INTERFACE_BUDGET_CHARS["visible_goal"] diff --git a/loopx/control_plane/heartbeat/rules.py b/loopx/control_plane/heartbeat/rules.py index de4929d47e..f994fc13ab 100644 --- a/loopx/control_plane/heartbeat/rules.py +++ b/loopx/control_plane/heartbeat/rules.py @@ -50,10 +50,22 @@ RUNTIME_CAPABILITY_PROJECTION_THIN_RULE = ( "Observed capabilities -> `--available-capability`; never user gates." ) -RUNTIME_EXECUTION_ROUTING_RULE = ( - "Normal turns use CLI `interaction_contract`; use `loopx-project` for " +RUNTIME_REPAIR_ROUTING_RULE = ( + "use `loopx-project` for " "lifecycle/registry and `loopx-self-repair` for runtime/projection drift." ) +RUNTIME_EXECUTION_ROUTING_RULE = ( + "Normal turns use CLI `interaction_contract`; " + RUNTIME_REPAIR_ROUTING_RULE +) +HOST_LOOP_SAFETY_RULE = ( + "Follow user authority and repository rules. Protect credentials/private material; " + "publish public-safe evidence. Destructive Git/production requires explicit authorization. " + "Gate only the affected path; continue independent allowed work." +) +HEARTBEAT_TURN_BOOTSTRAP_RULE = ( + "Per wake, replace `` once. Run assignment and guard as separate " + "statements in one shell, not a command-prefix assignment; reuse the value on retries." +) HOST_LOOP_QUOTA_DISPATCH_RULE = ( "Quota: use selection_command when required; " "先按指令重新进入,完成获准工作并验证后,再按 next_cli_actions 写回和记账。" diff --git a/loopx/control_plane/heartbeat/task_body.py b/loopx/control_plane/heartbeat/task_body.py index c09b66b9b5..ce2583ae67 100644 --- a/loopx/control_plane/heartbeat/task_body.py +++ b/loopx/control_plane/heartbeat/task_body.py @@ -10,12 +10,15 @@ DEFAULT_PERMISSION_RULE, HEARTBEAT_NOTIFICATION_RULE_SHORT, HEARTBEAT_NOTIFICATION_RULE_THIN, + HEARTBEAT_TURN_BOOTSTRAP_RULE, HEARTBEAT_VISION_WRITEBACK_RULE_SHORT, HOST_LOOP_QUOTA_DISPATCH_RULE, + HOST_LOOP_SAFETY_RULE, HOST_LOOP_TODO_CLOSEOUT_COMPACT_RULE, HOST_LOOP_TODO_CLOSEOUT_RULE, RUNTIME_CAPABILITY_PROJECTION_THIN_RULE, RUNTIME_EXECUTION_ROUTING_RULE, + RUNTIME_REPAIR_ROUTING_RULE, SCHEDULER_HINT_APPLICATION_RULE, SCHEDULER_HINT_COMPACT_RULE, SCHEDULER_HINT_THIN_RULE, @@ -86,17 +89,14 @@ def render_heartbeat_task_body( ) return f"""Advance `{goal_id}` using `{active_state}`. -Generic LoopX lifecycle. Keep project-specific branching out of the -automation prompt. Put local policy in registry, active-state sections, adapter -output, `quota should-run.goal_boundary`, or boundary rules; if a lifecycle -rule is needed, update `{cli_bin} heartbeat-prompt` so all projects inherit it. {scope_block} -Before spending delivery compute, make the CLI reachable; set -`LOOPX_TURN=` per trigger, reuse it on retries, and run guard: +Before spending delivery compute, make the CLI reachable. +{HEARTBEAT_TURN_BOOTSTRAP_RULE} ```bash {cli_preflight} +LOOPX_TURN= {pr_review_pre_quota_block}{quota_guard_command} ``` @@ -213,14 +213,8 @@ def render_heartbeat_task_body( for 2 more eligible turns; no spend for the self-cancel turn. 4. {SCOPE_BOUNDED_WORK_RULE} Related work can form a coherent effort; a focused correction may suffice. -5. Execute that scoped work. Stay inside `goal_boundary` when present and keep - public/private boundaries intact. Public-safe repo publication is not an - operator gate by itself: for routine public project work, commit, push, and - PR creation may proceed autonomously after validation and a clean - public/private boundary scan. Stop and surface a user/controller gate only - for private or company-internal material, credentials, destructive git - operations, production actions, or repository rules that explicitly require - review. +5. Execute that scoped work. Stay inside `goal_boundary` when present. + {HOST_LOOP_SAFETY_RULE} 6. Run validation proportionate to the change and risk. 7. Write back changed files, validation, critic, and next action to the active state. If a user/owner todo appears, do not hide it in prose: use @@ -296,10 +290,11 @@ def render_brief_heartbeat_task_body( `{compact_prompt_command}`. {scope_block} -Guard/retry; `LOOPX_TURN=`: +{HEARTBEAT_TURN_BOOTSTRAP_RULE} ```bash {cli_preflight} +LOOPX_TURN= {pr_review_pre_quota_block}{quota_guard_command} ``` @@ -333,6 +328,9 @@ def render_brief_heartbeat_task_body( No spend for quiet skips, preflight failures, blocker-push asks, dry-runs, or duplicate accounting. Return only under `user_channel.notify=NOTIFY`; else quiet. +{HOST_LOOP_SAFETY_RULE} +{RUNTIME_REPAIR_ROUTING_RULE} + {material_queue_rule} {permission_rule}""" def render_compact_heartbeat_task_body( @@ -364,10 +362,11 @@ def render_compact_heartbeat_task_body( Detail: `{expanded_prompt_command}`. {scope_block} -Preflight/guard; `LOOPX_TURN=`; reuse: +{HEARTBEAT_TURN_BOOTSTRAP_RULE} ```bash {cli_preflight} +LOOPX_TURN= {pr_review_pre_quota_block}{quota_guard_command} ``` @@ -421,8 +420,7 @@ def render_compact_heartbeat_task_body( heartbeats with only status/brief checks, replan before quiet no-op. Pause/delete only if repair stays stuck 2 more turns. 7. {SCOPE_BOUNDED_WORK_RULE} - Public-safe commit/push/PR may proceed after validation/clean scan. Stop for - private/company material, credentials, destructive git, production, or review rules. + {HOST_LOOP_SAFETY_RULE} 8. Validate; write files/validation/critic/next action to active state; use `{cli_bin} todo add --goal-id {goal_id} --role user --task-class user_gate|user_action` for owner todos and `--role agent` for agent todos, not prose. @@ -554,6 +552,9 @@ def _render_goal_task_body( return f"""Advance LoopX goal `{goal_id}` from `{active_state}` {host_preamble} {scope_block} +{RUNTIME_EXECUTION_ROUTING_RULE} +{HOST_LOOP_SAFETY_RULE} + {prequota_block}Each work iteration, read complete successful JSON from: `{quota_guard_command}` Use the current `interaction_contract`, not remembered commands. @@ -641,33 +642,23 @@ def render_thin_heartbeat_task_body( permission_rule=permission_rule, ) scope_sentence = f"\n{agent_scope_instruction}" if agent_scope_instruction else "" - quota_guard_instruction = ( - f"`{quota_guard_command}`" - if any( - marker in quota_guard_command - for marker in ( - "--available-capability", - "--runtime-profile", - "--codex-app", - "--host-surface", - " -H ", - ) - ) - else "`quota should-run`" - ) pr_review_pre_quota_instruction = ( - f"`{pr_review_pre_quota_command}`\n" + f"{pr_review_pre_quota_command}\n" if pr_review_pre_quota_command else "" ) return f"""Advance `{goal_id}` from {active_state}. {RUNTIME_EXECUTION_ROUTING_RULE} +{HOST_LOOP_SAFETY_RULE} {scope_sentence} {HOST_LOOP_QUOTA_DISPATCH_RULE} -`LOOPX_TURN=`; reuse. -{pr_review_pre_quota_instruction}{quota_guard_instruction}. +{HEARTBEAT_TURN_BOOTSTRAP_RULE} +```sh +LOOPX_TURN= +{pr_review_pre_quota_instruction}{quota_guard_command} +``` {HEARTBEAT_NOTIFICATION_RULE_SHORT} {SCOPE_BOUNDED_WORK_RULE} {RUNTIME_CAPABILITY_PROJECTION_THIN_RULE} @@ -678,8 +669,7 @@ def render_thin_heartbeat_task_body( P0 blocked: safe P1/P2; monitor quiet/no-spend. -{policy_tail} Stop: private material, credentials, -destructive git, unauthorized prod.""" +{policy_tail}""" def render_heartbeat_generator_inputs_markdown(payload: dict[str, Any]) -> str: interface_budget = payload.get("interface_budget") if isinstance(payload.get("interface_budget"), dict) else {} lines = [ diff --git a/tests/control_plane/test_automation_prompt_upgrade.py b/tests/control_plane/test_automation_prompt_upgrade.py index f8a32b342f..b397df97b2 100644 --- a/tests/control_plane/test_automation_prompt_upgrade.py +++ b/tests/control_plane/test_automation_prompt_upgrade.py @@ -13,7 +13,7 @@ from loopx.control_plane.heartbeat import automation_upgrade as upgrade -def fixture(tmp_path: Path): +def fixture(tmp_path: Path, backing_kind="heartbeat"): home = tmp_path / "host" path = home / "automations/watch/automation.toml" path.parent.mkdir(parents=True) @@ -28,7 +28,7 @@ def fixture(tmp_path: Path): with sqlite3.connect(database) as connection: connection.execute("CREATE TABLE automations (id TEXT PRIMARY KEY, kind TEXT, prompt TEXT, status TEXT, target_thread_id TEXT, rrule TEXT, model TEXT, updated_at INTEGER, next_run_at INTEGER)") connection.execute("INSERT INTO automations VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", - ("watch", "heartbeat", prompt, "PAUSED", "thread-a", "FREQ=HOURLY", "fixture-model", 123, 456)) + ("watch", backing_kind, prompt, "PAUSED", "thread-a", "FREQ=HOURLY", "fixture-model", 123, 456)) connection.execute("CREATE TABLE sessions (id TEXT)") connection.execute("INSERT INTO sessions VALUES ('do-not-touch')") registry = tmp_path / "registry.json" @@ -114,7 +114,7 @@ def test_divergence_never_mutates_host(tmp_path, reason): if reason == "missing_row": connection.execute("DELETE FROM automations") elif reason == "wrong_kind": - connection.execute("UPDATE automations SET kind='cron'") + connection.execute("UPDATE automations SET kind='unknown'") elif reason == "metadata": connection.execute("UPDATE automations SET status='ACTIVE'") else: @@ -127,6 +127,25 @@ def test_divergence_never_mutates_host(tmp_path, reason): assert not (home / "loopx-automation-backups").exists() +@pytest.mark.parametrize("mutation", ["standalone", "legacy_mirror", "mismatched_thread"]) +def test_cron_backing_is_not_inferred_to_be_a_bound_heartbeat(tmp_path, mutation): + home, path, database, registry, _ = fixture(tmp_path, "cron") + if mutation == "standalone": + path.write_text(path.read_text().replace('kind = "heartbeat"', 'kind = "cron"')) + elif mutation == "legacy_mirror": + # Observed legacy shape: TOML claims a thread but the scheduler does not. + with sqlite3.connect(database) as connection: + connection.execute("UPDATE automations SET target_thread_id=NULL") + else: + with sqlite3.connect(database) as connection: + connection.execute("UPDATE automations SET target_thread_id='thread-b'") + before = path.read_bytes() + entry = upgrade.build_plan(registry=registry, home=home)["entries"][0] + assert entry["status"] == "blocked" and "desired_prompt" not in entry + assert "App" in entry["reason"] + assert path.read_bytes() == before + + def test_failure_after_db_commit_is_recoverable_without_duplicate_mutation(tmp_path, monkeypatch): home, path, database, _, prompt = fixture(tmp_path) atomic = upgrade._atomic diff --git a/tests/control_plane/test_goal_prompt_dispatch.py b/tests/control_plane/test_goal_prompt_dispatch.py index bb9aedfbcc..74c24b25a2 100644 --- a/tests/control_plane/test_goal_prompt_dispatch.py +++ b/tests/control_plane/test_goal_prompt_dispatch.py @@ -1,5 +1,10 @@ """Native Goal bootstrap delegates policy to the current quota contract.""" +import os +import re +import shutil +import subprocess + import pytest from loopx.heartbeat_prompt import build_heartbeat_prompt @@ -31,7 +36,7 @@ def test_goal_prompt_has_one_live_execution_entry(host: str) -> None: assert "notification" in body assert "no work/spend" in body assert "No permission asks in a trusted session" not in body - assert len(body) < 2400 + assert len(body) < 3300 def test_codex_wait_rule_is_not_exported_to_other_goal_hosts() -> None: @@ -50,3 +55,46 @@ def test_explicit_goal_policy_is_preserved_not_replaced_by_bootstrap_defaults() ) assert "Only edit the assigned workspace." in packet["task_body"] assert "Use the approved reference set only." in packet["task_body"] + + +@pytest.mark.parametrize("profile", ["codex_cli", None, "ark_managed_agent_goal"]) +def test_shared_static_safety_and_exception_routing_survive_thinning(profile): + body = build_heartbeat_prompt(goal_id="contract-fixture", runtime_profile=profile, + thin=True)["task_body"] + for obligation in ("repository rules", "credentials", "private material", + "Destructive Git", "production", "loopx-project", "loopx-self-repair"): + assert obligation in body + assert "project-specific workflow" not in body + assert "No project branches" not in body + assert "only the affected path" in body + + +@pytest.mark.parametrize("shell", ["bash", "zsh"]) +def test_emitted_heartbeat_bootstrap_expands_turn_before_real_guard(shell, tmp_path): + executable = shutil.which(shell) + if executable is None: + pytest.skip(f"{shell} unavailable") + # The actual emitted shell block must work without any inherited Turn. + from importlib.util import module_from_spec, spec_from_file_location + from pathlib import Path + spec = spec_from_file_location("goal_runner", Path(__file__).resolve().parents[2] / + "scripts/qualify-native-goal-release.py") + runner = module_from_spec(spec) + spec.loader.exec_module(runner) + project, _, launcher = runner.setup(tmp_path) + packet = runner.cli(launcher, "heartbeat-prompt", "--thin", "--codex-app", + "--goal-id", runner.GOAL, "--agent-id", runner.AGENT, + "--cli-bin", str(launcher)) + script = re.search(r"```sh\n(.*?)\n```", packet["task_body"], re.S).group(1) + script = script.replace("", "2026-09-01T00:00:00Z") + env = {k: v for k, v in os.environ.items() if k != "LOOPX_TURN"} + result = subprocess.run([executable, "-c", script], cwd=project, env=env, + capture_output=True, text=True, timeout=120) + assert result.returncode == 0, result.stderr + assert '"ok": true' in result.stdout + # Prefix assignment is a genuine shell failure, not a LoopX rejection. + broken = script.replace("LOOPX_TURN=2026-09-01T00:00:00Z\n", + "LOOPX_TURN=2026-09-01T00:00:00Z ", 1) + rejected = subprocess.run([executable, "-c", broken], cwd=project, env=env, + capture_output=True, text=True, timeout=120) + assert rejected.returncode != 0 and "LOOPX_TURN" in rejected.stderr diff --git a/tests/test_host_loop_activation.py b/tests/test_host_loop_activation.py index 2eb67d6ef5..bfa069cb91 100644 --- a/tests/test_host_loop_activation.py +++ b/tests/test_host_loop_activation.py @@ -396,8 +396,8 @@ def test_goal_hosts_enter_live_contract_without_a_mandatory_skill_detour( task_body = " ".join(payload["task_body"].split()) assert "Use the current `interaction_contract`, not remembered commands" in task_body - assert "loopx-project" not in task_body - assert "loopx-self-repair" not in task_body + assert "loopx-project" in task_body + assert "loopx-self-repair" in task_body assert "Progress is not a new Goal boundary" in task_body assert "do not create a new host Goal merely to continue" in task_body From e14d922b46c1b09cff8f8732eed4622a88fd8b85 Mon Sep 17 00:00:00 2001 From: huangruiteng Date: Fri, 11 Sep 2026 11:01:50 +0800 Subject: [PATCH 18/26] fix(qualification): isolate host configuration and child environments Signed-off-by: huangruiteng --- docs/development/testing-and-quality.md | 18 +++-- scripts/qualify-claude-goal-release.py | 2 +- scripts/qualify-native-goal-release.py | 65 +++++++++++++++---- .../test_claude_goal_release_qualification.py | 19 +++++- .../test_native_goal_release_qualification.py | 28 ++++++++ 5 files changed, 113 insertions(+), 19 deletions(-) diff --git a/docs/development/testing-and-quality.md b/docs/development/testing-and-quality.md index bee8a8f795..0f475c1f08 100644 --- a/docs/development/testing-and-quality.md +++ b/docs/development/testing-and-quality.md @@ -749,24 +749,32 @@ score or evidence of universal model reliability. ```bash # No model invocation, no token cost; explicit skipped result, exit 0. python3 scripts/qualify-native-goal-release.py -# Release operator opt-in only; uses the existing configured Codex model/auth. +# Release operator opt-in only; explicit isolated API profile (Responses API). +# Supply LOOPX_CODEX_QUALIFICATION_API_KEY securely in this process, plus: +export LOOPX_CODEX_QUALIFICATION_MODEL='' +export LOOPX_CODEX_QUALIFICATION_BASE_URL='https://example.com/v1' python3 scripts/qualify-native-goal-release.py --release-live ``` Do not add the live command to default pytest, PR CI, per-diff canaries, or ordinary developer iteration. The deterministic runner-policy tests may run there; they never opt into real model execution. Missing CLI, native Goals or -Codex authentication returns `skipped` and exit 0, not a claimed live pass. +the explicit Codex API profile returns `skipped` and exit 0, not a claimed live pass. Once qualification is attempted, failed acceptance, incomplete settlement, blocked/unfinished Goals and deadline expiry fail with exit 1. The default deadline is 1,200 seconds; this is a wall-clock ceiling, not a token budget. 仅 release 前显式开启,避免默认消耗开发者 token。CI/本机环境不支持时跳过且不阻塞, -但保留 `skipped` 标记;真实执行后失败不能冒充环境跳过。复用当前 Codex 的模型与登录, -不复制凭据或会话、不修改活跃 Goal/automation。任务、registry、runtime 与 Git worktree +但保留 `skipped` 标记;真实执行后失败不能冒充环境跳过。使用操作者显式选择的 API +模型、地址与密钥,不导入日常 Codex 配置、登录或会话,不修改活跃 Goal/automation。 +任务、registry、runtime 与 Git worktree 在一次性目录内;沙箱允许该目录及本地 TS worker 所需的网络能力, 这不是网络隔离,任务不授权外部操作。回归脚本不采集或上传原始对话/工具日志, -公开结果仅包含状态、计数和错误类别;Codex 自身仍按当前 host 配置保存会话。 +公开结果仅包含状态、计数和错误类别;Codex 会话仅留在一次性隔离目录内。 +两个 runner 均从允许列表创建环境并隔离 HOME、配置和缓存;不透传其他 token、 +认证 socket、shell 启动变量或原始 ARK_API_KEY。Codex 工具 shell 从空环境注入必要 +运行变量,不继承 host API key。Claude host 仅接收所选 provider 的映射密钥;这不是 +对同用户进程或 Claude Bash 的凭据隔离沙箱,不能把真实业务秘密加入测试任务。 ### Claude Code and release coverage / Claude Code 与发布覆盖 diff --git a/scripts/qualify-claude-goal-release.py b/scripts/qualify-claude-goal-release.py index bd3511ce51..9cac3c7ee5 100644 --- a/scripts/qualify-claude-goal-release.py +++ b/scripts/qualify-claude-goal-release.py @@ -64,7 +64,7 @@ def prerequisite_failure(claude: str) -> str | None: def host_environment(root: Path, launcher: Path) -> dict[str, str]: # No user settings, OAuth/keychain import or persistent host installation. - env = {k: v for k, v in os.environ.items() if not k.startswith(("ANTHROPIC_", "CLAUDE_"))} + env = shared.host_environment(root, launcher) env.update( ANTHROPIC_API_KEY=os.environ["ARK_API_KEY"], ANTHROPIC_BASE_URL=ARK_ANTHROPIC_BASE, diff --git a/scripts/qualify-native-goal-release.py b/scripts/qualify-native-goal-release.py index 03f8264945..ae95d4090c 100644 --- a/scripts/qualify-native-goal-release.py +++ b/scripts/qualify-native-goal-release.py @@ -22,21 +22,63 @@ TODOS = {"todo_reducer", "todo_cli"} +def host_environment(root: Path, launcher: Path) -> dict[str, str]: + """Explicit transport/tool environment; never clone the operator's environment.""" + home, temporary = root / "home", root / "tmp" + home.mkdir(exist_ok=True) + temporary.mkdir(exist_ok=True) + return { + "PATH": str(launcher.parent) + os.pathsep + os.environ.get("PATH", os.defpath), + "HOME": str(home), "TMPDIR": str(temporary), "SHELL": "/bin/sh", + "LANG": "C.UTF-8", "PYTHONPATH": str(REPO), + "XDG_CONFIG_HOME": str(home / ".config"), + "XDG_CACHE_HOME": str(home / ".cache"), + } + + +def configure_codex(root: Path, launcher: Path) -> dict[str, str]: + env = host_environment(root, launcher) + home = root / "codex" + home.mkdir() + # No auth/config/session files are imported from the operator's Codex home. + env["CODEX_HOME"] = str(home) + env["LOOPX_CODEX_QUALIFICATION_API_KEY"] = os.environ["LOOPX_CODEX_QUALIFICATION_API_KEY"] + settings = { + "model": os.environ["LOOPX_CODEX_QUALIFICATION_MODEL"], + "model_provider": "qualification", + "model_providers.qualification.name": "Release qualification", + "model_providers.qualification.base_url": os.environ["LOOPX_CODEX_QUALIFICATION_BASE_URL"], + "model_providers.qualification.env_key": "LOOPX_CODEX_QUALIFICATION_API_KEY", + "model_providers.qualification.wire_api": "responses", + "shell_environment_policy.inherit": "none", + } + # Tool shells receive only the non-secret runtime environment, not host auth. + settings.update({f"shell_environment_policy.set.{key}": value + for key, value in host_environment(root, launcher).items()}) + (home / "config.toml").write_text("\n".join( + f"{key} = {json.dumps(value)}" for key, value in settings.items() + ) + "\n") + return env + + def prerequisite_failure(codex: str) -> str | None: for executable in (codex, "git", "node"): if not shutil.which(executable): return "required_executable_unavailable" - for command, reason in ( - ([codex, "login", "status"], "codex_auth_unavailable"), - ([codex, "features", "list"], "native_goals_unavailable"), - ): - result = subprocess.run(command, capture_output=True, text=True, timeout=30) + if not all(os.environ.get("LOOPX_CODEX_QUALIFICATION_" + name) + for name in ("API_KEY", "MODEL", "BASE_URL")): + return "isolated_codex_api_profile_unavailable" + with tempfile.TemporaryDirectory(prefix="loopx-codex-probe-") as raw: + root = Path(raw) + env = configure_codex(root, root / "bin/loopx") + result = subprocess.run([codex, "features", "list"], env=env, + capture_output=True, text=True, timeout=30) if result.returncode: - return reason - if command[1] == "features" and not any( + return "native_goals_unavailable" + if not any( line.split()[:1] == ["goals"] for line in result.stdout.splitlines() ): - return reason + return "native_goals_unavailable" return None @@ -161,10 +203,11 @@ def qualify(root: Path, codex: str, timeout: int) -> dict: sandbox_policy={"type": "workspaceWrite", "writableRoots": [str(root)], "networkAccess": True}, # Local TS worker needs loopback. ) - command = [codex, "--enable", "goals", "-c", 'shell_environment_policy.inherit="all"', - "-c", "project_doc_max_bytes=0", "app-server", "--stdio"] + env = configure_codex(root, launcher) + command = [codex, "--enable", "goals", "-c", "project_doc_max_bytes=0", + "-c", "allow_login_shell=false", "app-server", "--stdio"] with tempfile.TemporaryFile(mode="w+") as stderr: - with StdioNativeGoalTransport.spawn(command, cwd=str(project), env=dict(os.environ), + with StdioNativeGoalTransport.spawn(command, cwd=str(project), env=env, stderr=stderr) as transport: turn = run_native_goal_until_terminal(transport, config, timeout_sec=timeout) assert turn.post_goal_status == "complete", "native_goal_did_not_complete" diff --git a/tests/test_claude_goal_release_qualification.py b/tests/test_claude_goal_release_qualification.py index 9b050ece77..102e7adc65 100644 --- a/tests/test_claude_goal_release_qualification.py +++ b/tests/test_claude_goal_release_qualification.py @@ -50,6 +50,22 @@ def test_provider_binding_does_not_inherit_another_anthropic_account(monkeypatch assert os.environ["ANTHROPIC_AUTH_TOKEN"] == "synthetic-other-provider-key" +def test_child_environment_allowlist_drops_unrelated_secrets_and_operator_home(monkeypatch, tmp_path): + monkeypatch.setenv("ARK_API_KEY", "synthetic-provider-key") + forbidden = ("GH_TOKEN", "DATABASE_URL", "CUSTOM_AUTH", "SSH_AUTH_SOCK", + "AWS_SECRET_ACCESS_KEY", "NODE_OPTIONS", "BASH_ENV", "CODEX_HOME") + for key in forbidden: + monkeypatch.setenv(key, "synthetic-unrelated-value") + env = runner.host_environment(tmp_path, tmp_path / "bin/loopx") + # Execute a real child, not just an assertion on a builder's keys. + result = subprocess.run([sys.executable, "-c", "import os,json; print(json.dumps(dict(os.environ)))"], + env=env, capture_output=True, text=True, check=True) + actual = json.loads(result.stdout) + assert all(key not in actual for key in (*forbidden, "ARK_API_KEY")) + assert actual["HOME"] == str(tmp_path / "home") + assert actual["ANTHROPIC_API_KEY"] == "synthetic-provider-key" + + def test_claude_loop_uses_current_contract_not_segment_or_empty_list_stop(): from loopx.claude_goal_mode.scripts.goalmode_cmd import loop_md_content from loopx.control_plane.heartbeat.rules import SCOPE_BOUNDED_WORK_RULE @@ -115,8 +131,7 @@ def test_real_claude_stdio_mcp_binding_and_identity_gate(tmp_path): params = StdioServerParameters( command=sys.executable, args=[str(REPO / "loopx/claude_goal_mode/mcp/loopx_mcp.py")], - cwd=str(project), env={**os.environ, "PYTHONPATH": str(REPO), - "PATH": str(launcher.parent) + os.pathsep + os.environ["PATH"]}, + cwd=str(project), env=runner.shared.host_environment(tmp_path, launcher), ) async def exercise(): async with stdio_client(params) as (read, write): diff --git a/tests/test_native_goal_release_qualification.py b/tests/test_native_goal_release_qualification.py index 5fa9b84276..d34a3c4a25 100644 --- a/tests/test_native_goal_release_qualification.py +++ b/tests/test_native_goal_release_qualification.py @@ -2,6 +2,10 @@ import importlib.util import json +import os +import subprocess +import sys +import tomllib from pathlib import Path from types import SimpleNamespace @@ -46,6 +50,30 @@ def test_release_timeout_must_be_positive(): runner.main(["--timeout-seconds", "0"]) +def test_isolated_codex_profile_never_imports_operator_config_or_shell_secrets(monkeypatch, tmp_path): + for suffix, value in {"API_KEY": "synthetic-key", "MODEL": "fixture-model", + "BASE_URL": "https://example.com/v1"}.items(): + monkeypatch.setenv("LOOPX_CODEX_QUALIFICATION_" + suffix, value) + forbidden = ("ARK_API_KEY", "GH_TOKEN", "CUSTOM_AUTH", "SSH_AUTH_SOCK", "NODE_OPTIONS", "BASH_ENV") + for key in forbidden: + monkeypatch.setenv(key, "synthetic-unrelated-value") + env = runner.configure_codex(tmp_path, tmp_path / "bin/loopx") + assert all(key not in env for key in forbidden) + config = tomllib.loads((tmp_path / "codex/config.toml").read_text()) + assert "synthetic-key" not in (tmp_path / "codex/config.toml").read_text() + assert config["model"] == "fixture-model" + policy = config["shell_environment_policy"] + assert policy["inherit"] == "none" + result = subprocess.run([sys.executable, "-c", "import os,json; print(json.dumps(dict(os.environ)))"], + env=policy["set"], capture_output=True, text=True, check=True) + actual = json.loads(result.stdout) + assert "LOOPX_CODEX_QUALIFICATION_API_KEY" not in actual + assert all(key not in actual for key in forbidden) + assert actual["HOME"] == str(tmp_path / "home") + assert env["CODEX_HOME"] == str(tmp_path / "codex") + assert os.environ["GH_TOKEN"] == "synthetic-unrelated-value" + + def test_synthetic_fixture_real_cli_projects_identity_reentry(tmp_path): project, _, launcher = runner.setup(tmp_path) assert (project / "TASK.md").read_bytes() == (runner.FIXTURE / "TASK.md").read_bytes() From 4324180d1645a31fb3afff1ac062f3eb6eeaad6f Mon Sep 17 00:00:00 2001 From: huangruiteng Date: Fri, 11 Sep 2026 11:52:02 +0800 Subject: [PATCH 19/26] test(hosts): absorb contributor qualification regressions from #4216 Co-authored-by: Green hats <183933879+Green-hats@users.noreply.github.com> Signed-off-by: huangruiteng --- examples/install-local-smoke.py | 3 +- .../test_native_goal_release_qualification.py | 33 +++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/examples/install-local-smoke.py b/examples/install-local-smoke.py index ba25fa2b76..4701cfa4d9 100644 --- a/examples/install-local-smoke.py +++ b/examples/install-local-smoke.py @@ -790,7 +790,8 @@ def main() -> int: assert not normal_turns_use_cli_interaction_contract( "Normal turns use the runtime skill and repair contract." ) - assert "`LOOPX_TURN=`; reuse." in payload["task_body"], payload + assert "```sh\nLOOPX_TURN=\n" in payload["task_body"], payload + assert "not a command-prefix assignment" in payload["task_body"], payload assert "guard receipt; 2 stalls->replan" in payload["task_body"], payload assert "no-change=`surface_only`/no spend" in payload["task_body"], payload assert payload["cli_bin"] == "loopx", payload diff --git a/tests/test_native_goal_release_qualification.py b/tests/test_native_goal_release_qualification.py index d34a3c4a25..0ea1f73dfe 100644 --- a/tests/test_native_goal_release_qualification.py +++ b/tests/test_native_goal_release_qualification.py @@ -19,6 +19,39 @@ spec.loader.exec_module(runner) +def test_native_spawn_preserves_isolated_profile_and_secret_free_shell(monkeypatch, tmp_path): + from loopx.capabilities.benchmark_toolkit.native_codex_goal import StdioNativeGoalTransport + + for suffix, value in {"API_KEY": "synthetic-selected-key", "MODEL": "fixture-model", + "BASE_URL": "https://example.com/v1"}.items(): + monkeypatch.setenv("LOOPX_CODEX_QUALIFICATION_" + suffix, value) + monkeypatch.setenv("UNRELATED_AUTH_TOKEN", "synthetic-forbidden-key") + monkeypatch.setenv("SSH_AUTH_SOCK", "synthetic-forbidden-socket") + launcher = tmp_path / "bin/loopx" + monkeypatch.setattr(runner, "setup", lambda _: (tmp_path, tmp_path / "runtime", launcher)) + monkeypatch.setattr(runner, "cli", lambda *_: {"task_body": "Synthetic task"}) + + class InspectedSpawn(Exception): + pass + + def inspect(command, **kwargs): + env = kwargs["env"] + assert "UNRELATED_AUTH_TOKEN" not in env and "SSH_AUTH_SOCK" not in env + settings = tomllib.loads((Path(env["CODEX_HOME"]) / "config.toml").read_text()) + policy = settings["shell_environment_policy"] + assert policy["inherit"] == "none" + assert "LOOPX_CODEX_QUALIFICATION_API_KEY" not in policy["set"] + child = subprocess.run([sys.executable, "-c", "import os,json; print(json.dumps(dict(os.environ)))"], + env=policy["set"], capture_output=True, text=True, check=True) + assert "synthetic-selected-key" not in child.stdout + assert "synthetic-forbidden" not in child.stdout + raise InspectedSpawn + + monkeypatch.setattr(StdioNativeGoalTransport, "spawn", inspect) + with pytest.raises(InspectedSpawn): + runner.qualify(tmp_path, "synthetic-codex", 10) + + def test_default_does_not_even_probe_model_environment(monkeypatch, capsys): def forbidden(*args): raise AssertionError("default must not touch a model host") From 80158624118a604df30ca429f5173441584321d6 Mon Sep 17 00:00:00 2001 From: huangruiteng Date: Fri, 11 Sep 2026 11:52:02 +0800 Subject: [PATCH 20/26] feat(hosts): load current Goal rules and reconcile prompts during upgrades Signed-off-by: huangruiteng --- docs/development/testing-and-quality.md | 16 ++ docs/heartbeat-automation-prompt.md | 11 ++ docs/reference/automation-prompt-upgrades.md | 76 +++++-- ...gent-onboard-host-loop-activation-smoke.py | 12 ++ .../claude_goal_mode/scripts/goalmode_cmd.py | 12 +- loopx/cli_commands/automation_prompts.py | 26 +-- loopx/cli_commands/support_control.py | 19 +- .../support_control_heartbeat_registration.py | 4 + .../heartbeat/automation_upgrade.py | 59 +++--- .../heartbeat/bootstrap_prompt.py | 99 ++++++++++ .../heartbeat/installed_prompt_update.py | 186 ++++++++++++++++++ loopx/goal_mode_mcp.py | 15 ++ loopx/host_loop_activation.py | 3 + loopx/self_update.py | 10 + scripts/qualify-native-goal-release.py | 2 +- .../test_automation_prompt_upgrade.py | 186 +++++++++++++++++- .../test_host_bootstrap_lifecycle.py | 85 ++++++++ .../test_claude_goal_release_qualification.py | 15 +- 18 files changed, 777 insertions(+), 59 deletions(-) create mode 100644 loopx/control_plane/heartbeat/bootstrap_prompt.py create mode 100644 loopx/control_plane/heartbeat/installed_prompt_update.py create mode 100644 tests/control_plane/test_host_bootstrap_lifecycle.py diff --git a/docs/development/testing-and-quality.md b/docs/development/testing-and-quality.md index 0f475c1f08..9af73f5351 100644 --- a/docs/development/testing-and-quality.md +++ b/docs/development/testing-and-quality.md @@ -746,6 +746,22 @@ independent acceptance oracle, completed Todos, unique bound spends, durable writeback readback, and terminal no-follow-up quota. This is not a benchmark score or evidence of universal model reliability. +The Codex release arm requests the shipped bootstrap, not an injected private +work recipe. Deterministic regressions execute the saved CLI loader, change its +registry inputs, and prove fresh loading, non-recursion, preserved explicit +policy and removed-agent rejection. Claude's stdio regression loads +`host_prompt` through the real MCP transport and verifies the same bound Goal. +These tests are free of model calls; passing them is not a live model pass. + +Upgrade regression uses real temporary SQLite/TOML stores, a second connection, +writer-lock contention, injected mirror failure, journal recovery and stale or +custom-input rejection. New-runtime reconciliation is exercised through the +real CLI, while package installation is substituted in that focused test. +Running-App deployment additionally needs a selected owner-authorized canary +and delayed readback; synthetic SQLite tests alone do not qualify App caches. +The output differential permits a bounded one-time transition to the exact +static-safety marker, not permanent growth allowances or relaxed quota budgets. + ```bash # No model invocation, no token cost; explicit skipped result, exit 0. python3 scripts/qualify-native-goal-release.py diff --git a/docs/heartbeat-automation-prompt.md b/docs/heartbeat-automation-prompt.md index ae179bb503..81b3366b63 100644 --- a/docs/heartbeat-automation-prompt.md +++ b/docs/heartbeat-automation-prompt.md @@ -57,6 +57,17 @@ automation contract. ### Native Goal bootstrap and live execution instructions +New supported host activations use `heartbeat-prompt --bootstrap`: a saved +loader requests the installed rules rather than freezing a long execution body. +The loader and automation bootstrap share rendering and successful-response +checks. Registry-derived state is resolved at load time; explicitly supplied +policy remains bound. The inner command does not request another bootstrap. +Claude Code loads its inner body through the bound MCP `host_prompt` tool. +TraeX's separate capability projection remains separate, not embedded by this +loader. See [prompt upgrade lifecycle](reference/automation-prompt-upgrades.md) +for automatic exact-managed adoption during `update --apply`, including the +qualified running-App SQLite/TOML adapter and conflict recovery boundary. + #### Static semantics retained across hosts Thinning removes duplicated recipes, not authority boundaries. The shared diff --git a/docs/reference/automation-prompt-upgrades.md b/docs/reference/automation-prompt-upgrades.md index 126249e2a2..9d17e22488 100644 --- a/docs/reference/automation-prompt-upgrades.md +++ b/docs/reference/automation-prompt-upgrades.md @@ -35,6 +35,40 @@ not Todo, quota or scheduler authority. ## Preview and adopt existing tasks +`loopx update --apply` now captures an owner-only snapshot **before** replacing +the runtime and invokes `automation-prompts sync-installed` in the **new** +runtime afterward. Exact recognized managed bootstraps and byte-identical +prompts reproduced by the old installed generator may migrate automatically. +An automation name, matching prose or Goal id alone never authorizes adoption. +Custom instructions remain `review_required`; a canary executable, different +registry/home or changed preview is not silently retargeted. Binary-install +success and prompt-migration success are reported separately. + +On the qualified macOS heartbeat schema this upgrade path can write directly +with the App running. It holds a SQLite writer transaction through TOML delivery, +compares the entire previewed manifest, preserves every non-prompt field, and +reads both stores back. It neither pauses tasks nor kills the App. A scheduler +invocation that already loaded its prompt is not rewritten; the next invocation +can load the new prompt. This is a local storage compatibility adapter, not an +official Codex API or a guarantee against every possible external filesystem +race. Uncoordinated TOML writes cannot participate in the SQLite transaction; +detected changes/crashes retain the private journal and require reconciliation. + +Unsupported platforms/schemas, custom prompts and conflicts are reported per +task. Where an eligible task can instead use the native App writer, the report +includes a complete `automation_update` request with preserved fields and a +fresh-view/hash precondition. A CLI cannot invoke an in-App tool itself. Do not +blindly replay a request after a user edit. A failed runtime installation never +starts prompt migration. A pending report retains the private pre-update plan +so a selected task can be retried, without scanning another Codex home: + +```sh +loopx automation-prompts sync-installed --plan-file ./private-before.json --automation-id TASK_ID --execute +``` + +The existing `plan` command remains read-only and explicit `apply` remains an +offline, reviewed path; it does not automatically adopt custom instructions. + ```sh loopx --format json automation-prompts plan --plan-file ./private-prompt-plan.json ``` @@ -69,7 +103,7 @@ history are preserved. Each selected task commits independently; the command reports partial failure instead of claiming the whole batch succeeded. Only existing heartbeat records with matching SQLite/TOML identity, prompt, -status and thread binding are eligible. Unknown schemas and stale previews fail +status, schedule and thread binding are eligible. Unknown schemas and stale previews fail closed. The fallback checks that the macOS App is closed; keep it closed until readback completes. Restart afterward. This adapter targets the observed local schema, **not an official stable Codex storage API**; no Windows/cloud support @@ -83,8 +117,9 @@ does not convert scheduler kind, infer a thread, or offer an executable upgrade. ## Recovery and rollback -The fallback stores a private per-task journal before writing. SQLite commit -and TOML replacement are not one transaction: a crash can leave a mirror pending. +The adapter stores a private per-task journal before writing. SQLite commit +and TOML replacement are not one transaction: a crash can leave a mirror pending, +including a new TOML prompt with a rolled-back SQLite prompt. While the App remains closed, recover that exact task: ```sh @@ -102,11 +137,25 @@ for rollback. Disable automatic rule adoption by replacing the bootstrap with an explicitly pinned prompt using the App, or pause the task there. LoopX runtime rollback -also changes the rules loaded on the next wake. Future incompatible bootstrap -revisions still require an explicit migration; a wrapper does not silently -rewrite itself. Exact v1 wrappers remain recognized as runtime-loaded thin -prompts; `automation-prompts plan` proposes v2 for explicit adoption and performs -no writes. Customized wrappers are not recognized merely from their header. +also changes the rules loaded on the next wake. Exact v1 wrappers remain +recognized as runtime-loaded thin prompts; `automation-prompts plan` proposes +v2 without writing, while the update-time exact-owned path can migrate them. +Customized wrappers are not recognized merely from their header. + +## Goal host loaders + +`heartbeat-prompt --bootstrap` uses the same shell loader renderer as automation. +New Codex App/SSH, Codex CLI/IDE and managed-agent activations store this loader; +it preserves explicit caller policy but re-resolves registry state on each load. +Its inner command omits `--bootstrap`, preventing recursion. Persisting a fixed +turn-instance id is rejected. Existing saved native Goal objectives are not +rewritten through SQLite. + +Claude Code stores a bound MCP loader: `host_prompt` returns the current inner +rules for its existing Goal and agent. Restart an existing MCP server after a +runtime upgrade; already imported Python code does not hot-reload. TraeX retains +its direct Goal projection when capabilities require a separate host surface; +the generic loader must not move those declarations into prompt text. ## 中文摘要 @@ -114,7 +163,10 @@ no writes. Customized wrappers are not recognized merely from their header. thin 指令;之后升级 LoopX 即可让下一轮采用新版规则,无需逐版本改 SQLite。 正在运行的轮次不热切换。启动器不是另一套执行规则,也不增加权限。 -先批量预览,再明确接管;自定义内容不猜测合并,不自动删除。App 运行时用 -原生更新接口;离线兼容通道要求关闭 App、精确预览校验、双存储读回,并保留 -私有恢复记录。日程、暂停状态、模型、线程、通知偏好和历史均不迁移。 -本批提供命令行批量迁移,不新增 Dashboard 按钮;也不保证模型行为已通过在线评测。 +升级主流程在替换 runtime 前留存旧模板证据,升级后由新 runtime 生成并迁移。 +仅完整匹配的托管指令可自动更新;自定义内容不猜测合并、不自动删除。macOS +已匹配的 heartbeat 存储支持 App 运行中直接写入,仅改 prompt,持有数据库写锁 +直到 TOML 交付和读回完成。两种存储并非一个原子事务;冲突或异常保留私有日志, +不伪报成功。日程、暂停状态、模型、线程、通知偏好和历史均不迁移。 +不支持的存储仍需原生 API;运行中的本轮不热切换。普通测试不消耗模型 token, +真实模型发布资格仍需独立评测,不能由迁移成功推断。 diff --git a/examples/control_plane/agent-onboard-host-loop-activation-smoke.py b/examples/control_plane/agent-onboard-host-loop-activation-smoke.py index 4933b82100..03ab638fe3 100644 --- a/examples/control_plane/agent-onboard-host-loop-activation-smoke.py +++ b/examples/control_plane/agent-onboard-host-loop-activation-smoke.py @@ -49,6 +49,16 @@ def run_cli( ) +def load_bootstrap(packet: dict, cli_bin: str, home: Path) -> dict: + assert packet["ok"] and packet["bootstrap"] + assert packet["interface_budget"]["within_budget"] + loader = shlex.split(packet["task_body"].split("```sh\n", 1)[1].split("\n```", 1)[0]) + assert "--bootstrap" not in loader + loader[0] = cli_bin + return json.loads(subprocess.run(loader, env={**os.environ, "HOME": str(home)}, + check=True, text=True, capture_output=True, timeout=120).stdout) + + def main() -> int: catalog = build_agent_type_catalog() agent_types = {item["agent_type"] for item in catalog["canonical_agent_types"]} @@ -423,6 +433,7 @@ def main() -> int: ) app_ssh_prompt = json.loads(app_ssh_prompt_run.stdout) assert app_ssh_prompt["ok"] is True, app_ssh_prompt + app_ssh_prompt = load_bootstrap(app_ssh_prompt, cli_bin, home) assert app_ssh_prompt["interface_budget"]["mode"] == "visible_goal", app_ssh_prompt assert app_ssh_prompt["interface_budget"]["max_chars"] == 4_000, app_ssh_prompt assert app_ssh_prompt["interface_budget"]["within_budget"] is True, app_ssh_prompt @@ -500,6 +511,7 @@ def main() -> int: timeout=120, ) cli_prompt = json.loads(cli_prompt_run.stdout) + cli_prompt = load_bootstrap(cli_prompt, cli_bin, home) assert cli_prompt["interface_budget"]["mode"] == "visible_goal", cli_prompt assert "--turn-instance-id" not in cli_prompt["quota_guard_command"], cli_prompt assert "--source visible-goal" in cli_prompt["quota_spend_command"], cli_prompt diff --git a/loopx/claude_goal_mode/scripts/goalmode_cmd.py b/loopx/claude_goal_mode/scripts/goalmode_cmd.py index b5e6dbcb59..5dff5ea4fd 100644 --- a/loopx/claude_goal_mode/scripts/goalmode_cmd.py +++ b/loopx/claude_goal_mode/scripts/goalmode_cmd.py @@ -58,7 +58,7 @@ def slug(name: str) -> str: return f"cc-{s}"[:48] -def loop_md_content(goal_id, agent_id) -> str: +def loop_execution_content(goal_id, agent_id) -> str: """The per-iteration protocol that native `/loop` runs (written to .claude/loop.md). loopx's should_run is the deterministic per-tick gate; the agent uses the wired loopx MCP tools, never raw CLI guessing. @@ -93,6 +93,16 @@ def loop_md_content(goal_id, agent_id) -> str: ) +def loop_md_content(goal_id, agent_id) -> str: + from loopx.control_plane.heartbeat.bootstrap_prompt import BOOTSTRAP_INSTRUCTION + armed = json.dumps({"goal_id": goal_id, "agent_id": agent_id}) + return (f"\nLoopX managed MCP bootstrap v1\n" + "Each entry/resume: call the bound LoopX `host_prompt` MCP tool, " + "verify its goal_id and agent_id match the armed binding above, " + "then read its complete task_body. Do not create another Goal or scheduler.\n" + f"{BOOTSTRAP_INSTRUCTION}\n") + + def write_loop_md(proj: Path, goal_id, agent_id) -> Path: """Write the protocol to /.claude/loop.md (bare `/loop` runs it).""" path = loop_md_path(proj) diff --git a/loopx/cli_commands/automation_prompts.py b/loopx/cli_commands/automation_prompts.py index 68a5668398..b34009223f 100644 --- a/loopx/cli_commands/automation_prompts.py +++ b/loopx/cli_commands/automation_prompts.py @@ -5,19 +5,18 @@ import json import sqlite3 from pathlib import Path -import subprocess -import sys from loopx.control_plane.heartbeat.automation_upgrade import ( SCHEMA, _atomic, apply_offline, build_plan, recover_offline, ) from loopx.upgrade import codex_home +from loopx.control_plane.heartbeat.installed_prompt_update import require_closed_app as _require_offline def register_automation_prompts(subparsers, add_format) -> None: parser = subparsers.add_parser("automation-prompts", help="Preview and migrate existing Codex heartbeats to live LoopX rules.") add_format(parser) - parser.add_argument("action", choices=("plan", "apply", "recover", "rollback")) + parser.add_argument("action", choices=("plan", "apply", "recover", "rollback", "sync-installed")) parser.add_argument("--codex-home", type=Path, help="One explicit host home; never discovers or migrates other homes.") parser.add_argument("--plan-file", type=Path, help="Private reviewed plan file; plan saves it, apply reads it.") parser.add_argument("--automation-id", action="append", default=[]) @@ -26,17 +25,20 @@ def register_automation_prompts(subparsers, add_format) -> None: parser.add_argument("--offline", action="store_true", help="Acknowledge the Codex App is closed; use its automation API while running.") -def _require_offline() -> None: - if sys.platform != "darwin": - raise ValueError("offline adapter is qualified only on macOS; use the App automation API") - for name in ("Codex", "ChatGPT"): - observed = subprocess.run(["/usr/bin/pgrep", "-x", name], capture_output=True, check=False) - if observed.returncode != 1: - raise ValueError("close the Codex/ChatGPT App before offline migration; otherwise use automation_update") - - def run(args: argparse.Namespace, registry: Path) -> dict: home = (args.codex_home or codex_home()).expanduser().resolve() + if args.action == "sync-installed": + from loopx.control_plane.heartbeat.installed_prompt_update import reconcile, snapshot + if not args.execute: + return snapshot(registry=registry, home=home, runtime_root=args.runtime_root, cli_bin=args.cli_bin) + if not args.plan_file: + raise ValueError("sync-installed --execute requires the private pre-update --plan-file") + before = json.loads(args.plan_file.read_text()) + if args.automation_id: + before["entries"] = [entry for entry in before.get("entries", []) + if entry["automation_id"] in args.automation_id] + return reconcile(before=before, registry=registry, + home=home, runtime_root=args.runtime_root, cli_bin=args.cli_bin) if args.action == "plan": if args.execute: raise ValueError("plan cannot execute") diff --git a/loopx/cli_commands/support_control.py b/loopx/cli_commands/support_control.py index 9731a4360e..ed7d61e114 100644 --- a/loopx/cli_commands/support_control.py +++ b/loopx/cli_commands/support_control.py @@ -646,6 +646,18 @@ def handle_support_control_command( turn_granularity=turn_granularity, turn_instance_id=args.turn_instance_id, ) + if args.bootstrap and payload.get("ok"): + from ..control_plane.heartbeat.bootstrap_prompt import goal_bootstrap + from ..control_plane.heartbeat.budget import build_interface_budget + body = goal_bootstrap(args, registry=agent_registry_path) + payload["task_body"] = body + payload["bootstrap"] = True + payload["interface_budget"] = build_interface_budget( + task_body=body, goal_id=args.goal_id, + active_state=str(payload.get("active_state") or ""), thin=True, + ) + if not payload["interface_budget"]["within_budget"]: + raise ValueError("bootstrap exceeds the thin budget; move lengthy policy into registered state") except Exception as exc: fallback_active_state = active_state fallback_resolved_active_state = resolved_active_state @@ -822,8 +834,11 @@ def handle_support_control_command( if update_action is UpdateAction.APPLY and payload.get("plan", {}).get( "apply_supported" ): - payload = execute_update_plan( - payload, timeout_seconds=args.timeout_seconds + from ..control_plane.heartbeat.installed_prompt_update import update_with_prompts + payload = update_with_prompts( + payload, registry=(registry_path if registry_was_supplied else explicit_global_registry(args.runtime_root)), + runtime_root=args.runtime_root, + timeout_seconds=args.timeout_seconds, runtime_update=execute_update_plan, ) except Exception as exc: payload = { diff --git a/loopx/cli_commands/support_control_heartbeat_registration.py b/loopx/cli_commands/support_control_heartbeat_registration.py index b3cb098d8a..7b0ba5fcf3 100644 --- a/loopx/cli_commands/support_control_heartbeat_registration.py +++ b/loopx/cli_commands/support_control_heartbeat_registration.py @@ -17,6 +17,10 @@ def register_heartbeat_control_commands( help="Generate a guarded heartbeat or visible-goal host-loop task body.", ) add_subcommand_format(heartbeat_prompt_parser) + heartbeat_prompt_parser.add_argument( + "--bootstrap", action="store_true", + help="Generate a stable host entrypoint that reloads installed rules; no persisted Turn identity.", + ) heartbeat_prompt_parser.add_argument( "--goal-id", required=True, help="Stable LoopX goal id." ) diff --git a/loopx/control_plane/heartbeat/automation_upgrade.py b/loopx/control_plane/heartbeat/automation_upgrade.py index 88faae40e5..a40467470b 100644 --- a/loopx/control_plane/heartbeat/automation_upgrade.py +++ b/loopx/control_plane/heartbeat/automation_upgrade.py @@ -1,7 +1,7 @@ """Installed prompt lifecycle; execution policy stays in heartbeat-prompt. -The App API is the preferred writer. The SQLite writer is an explicit offline -compatibility adapter, not a public Codex API or a scheduler implementation. +The App API is the preferred interactive writer. The journaled store writer is +a qualified local compatibility adapter, not a public Codex storage API. """ from __future__ import annotations @@ -17,6 +17,8 @@ import tomllib from typing import Any +from .bootstrap_prompt import BOOTSTRAP_INSTRUCTION, host_bootstrap_binding, render_bootstrap + from loopx.upgrade import ( codex_home, infer_agent_id_from_prompt, infer_goal_id_from_prompt, infer_available_capabilities_from_prompt, @@ -29,11 +31,7 @@ "读取完整结果;仅 ok=true 时按本次 task_body 执行,不复用旧指令;" "失败或结果不完整则停止并报告,不执行任务或记账。" ) -_BOOTSTRAP_INSTRUCTION = ( - "读取完整结果;仅 ok=true 时按本次 task_body 推进,不复用旧指令。" - "一次操作不代表结束;通知与执行分开,等待按当前调度契约,不反复空查。" - "入口异常先做权限内恢复;契约仍不可用时不执行任务或记账,并报告阻塞。" -) +_BOOTSTRAP_INSTRUCTION = BOOTSTRAP_INSTRUCTION def digest(value: str) -> str: @@ -53,12 +51,7 @@ def bootstrap_prompt(*, registry: Path, goal_id: str, agent_id: str, args += ["--cli-bin", cli_bin] for capability in capabilities or []: args += ["--available-capability", capability] - return ( - f"{BOOTSTRAP}\n" - "每次唤醒先执行:\n" - f"```sh\n{shlex.join(args)}\n```\n" - f"{_BOOTSTRAP_INSTRUCTION}" - ) + return render_bootstrap(args, title=BOOTSTRAP, entry="每次唤醒先执行:") def _atomic(path: Path, text: str) -> None: @@ -176,7 +169,7 @@ def _read(home: Path, automation_id: str, connection: sqlite3.Connection) -> tup ) if item.get("status") == "DELETED" or row["status"] == "DELETED": raise ValueError("deleted automation cannot be upgraded") - for key in ("prompt", "status", "target_thread_id"): + for key in ("prompt", "status", "target_thread_id", "rrule"): if item.get(key) != row.get(key): raise ValueError(f"automation stores disagree on {key}; reconcile through the App") return source, item, row @@ -209,6 +202,13 @@ def build_plan(*, registry: Path, home: Path | None = None, desired = bootstrap_prompt(registry=registry, goal_id=goal_id, agent_id=agent_id, runtime_root=runtime_root, capabilities=infer_available_capabilities_from_prompt(prompt), cli_bin=cli_bin) + loaded_binding = host_bootstrap_binding(prompt) + if (loaded_binding and loaded_binding["registry"].resolve() == registry.resolve() + and (loaded_binding.get("codex_app") or + loaded_binding.get("runtime_profile") == "codex_app_heartbeat")): + # Already dynamically loaded, including explicit owner + # policy. Do not replace it with a narrower old wrapper. + desired = prompt entry.update(status="current" if prompt == desired else "adoption_required", goal_id=goal_id, agent_id=agent_id, prompt_sha256=digest(prompt), current_prompt=prompt, @@ -223,11 +223,14 @@ def build_plan(*, registry: Path, home: Path | None = None, def apply_offline(*, home: Path, automation_id: str, expected_prompt_sha256: str, - desired_prompt: str) -> dict[str, Any]: - """Explicit offline fallback. Persist recovery before either host-store write. + desired_prompt: str, expected_source_sha256: str | None = None) -> dict[str, Any]: + """Journaled prompt-only write, also used by qualified update-time migration. - Scheduler/thread/history tables are never touched. The caller must close - the App: its external TOML writes cannot join this SQLite transaction. + Keep the SQLite writer lock through mirror replacement and readback. TOML + cannot join that transaction: a crash is explicitly journal-recoverable, + not falsely advertised as an atomic two-store commit. Concurrent external + edits detected at either boundary fail instead of being retried blindly. + The historical Python name is retained for the explicit offline CLI. """ home = home.expanduser().resolve() journal = home / "loopx-automation-backups" / (automation_id + ".json") @@ -236,6 +239,9 @@ def apply_offline(*, home: Path, automation_id: str, expected_prompt_sha256: str with closing(_connect(home, writable=True)) as connection: connection.execute("BEGIN IMMEDIATE") source, item, row = _read(home, automation_id, connection) + path = home / "automations" / automation_id / "automation.toml" + if expected_source_sha256 is not None and digest(source) != expected_source_sha256: + raise ValueError("automation metadata changed after preview; no writes performed") if digest(item["prompt"]) != expected_prompt_sha256: raise ValueError("prompt changed after preview; no writes performed") if item["prompt"] == desired_prompt: @@ -246,12 +252,21 @@ def apply_offline(*, home: Path, automation_id: str, expected_prompt_sha256: str # Entire originals stay private for recovery; no raw prompts in receipts. _atomic(journal, json.dumps({"schema_version": SCHEMA, "automation_id": automation_id, "before": source, "after": replacement, "row": row}, ensure_ascii=False)) - connection.execute("UPDATE automations SET prompt=? WHERE id=? AND prompt=?", - (desired_prompt, automation_id, item["prompt"])) + if path.read_text(encoding="utf-8") != source: + raise ValueError("automation manifest changed during migration; journal retained") + changed = connection.execute("UPDATE automations SET prompt=? WHERE id=? AND prompt=?", + (desired_prompt, automation_id, item["prompt"])) + if changed.rowcount != 1: + raise ValueError("automation prompt compare-and-swap failed") + _atomic(path, replacement) + if path.read_text(encoding="utf-8") != replacement: + raise ValueError("automation manifest changed during readback; journal retained") + _read(home, automation_id, connection) connection.commit() - _atomic(home / "automations" / automation_id / "automation.toml", replacement) with closing(_connect(home)) as connection: - _read(home, automation_id, connection) + final_source, _, final_row = _read(home, automation_id, connection) + if final_source != replacement or final_row["prompt"] != desired_prompt: + raise ValueError("automation changed after commit; inspect the retained journal") return {"ok": True, "status": "updated", "automation_id": automation_id, "backup": str(journal), "future_policy": "read installed heartbeat-prompt on every wake"} diff --git a/loopx/control_plane/heartbeat/bootstrap_prompt.py b/loopx/control_plane/heartbeat/bootstrap_prompt.py new file mode 100644 index 0000000000..1528d60fbc --- /dev/null +++ b/loopx/control_plane/heartbeat/bootstrap_prompt.py @@ -0,0 +1,99 @@ +"""Stable host entrypoints; changing execution policy stays in task_body.""" +from __future__ import annotations + +import shlex +from pathlib import Path +from types import SimpleNamespace + + +BOOTSTRAP_INSTRUCTION = ( + "读取完整结果;仅 ok=true 时按本次 task_body 推进,不复用旧指令。" + "一次操作不代表结束;通知与执行分开,等待按当前调度契约,不反复空查。" + "入口异常先做权限内恢复;契约仍不可用时不执行任务或记账,并报告阻塞。" +) + + +def render_bootstrap(command: list[str], *, title: str, entry: str) -> str: + return f"{title}\n{entry}\n```sh\n{shlex.join(command)}\n```\n{BOOTSTRAP_INSTRUCTION}" + + +def goal_bootstrap(args, *, registry: Path) -> str: + """Preserve explicit caller inputs, not yesterday's resolved registry values. + + The loaded command deliberately omits --bootstrap: one load cannot recurse. + A persistent entrypoint cannot pin an individual settlement identity. + """ + if args.turn_instance_id: + raise ValueError("--bootstrap cannot persist a --turn-instance-id; bind each work iteration through quota") + if args.visible_goal_host == "traex-cli" and args.available_capabilities: + raise ValueError("TraeX capability declarations require its separate host projection; use the direct Goal body") + command = [args.cli_bin, "--format", "json", "--registry", str(registry.resolve())] + if args.runtime_root: + command += ["--runtime-root", str(Path(args.runtime_root).expanduser().resolve())] + command += ["heartbeat-prompt", "--goal-id", args.goal_id] + for field, flag in ( + ("agent_id", "--agent-id"), ("active_state", "--active-state"), + ("material_rule", "--material-rule"), ("permission_rule", "--permission-rule"), + ("runtime_profile", "--runtime-profile"), ("visible_goal_host", "--visible-goal-host"), + ("host_surface", "--host-surface"), ("scheduler_owner", "--scheduler-owner"), + ("execution_mode", "--execution-mode"), + ): + value = getattr(args, field, None) + if value is not None: + if field == "active_state": + value = str(Path(value).expanduser().resolve()) + command += [flag, value] + for field, flag in (("agent_scopes", "--agent-scope"), + ("available_capabilities", "--available-capability")): + for value in getattr(args, field, None) or []: + command += [flag, value] + if args.cli_bin != "loopx": + command += ["--cli-bin", args.cli_bin] + if args.codex_app: + command.append("--codex-app") + mode = next((mode for mode in ("full", "compact", "brief", "thin") if getattr(args, mode)), "thin") + command.append("--" + mode) + return render_bootstrap(command, title="LoopX managed host bootstrap v1", + entry="每次进入或恢复本 Goal 时先加载当前规则;升级后重新加载,不创建新 Goal、不接管宿主调度:") + + +def host_bootstrap_binding(prompt: str) -> dict | None: + """Recognize only a complete, canonical loader, never a matching prefix.""" + if not prompt.startswith("LoopX managed host bootstrap v1\n"): + return None + try: + command = shlex.split(prompt.split("```sh\n", 1)[1].split("\n```", 1)[0]) + if command[1:3] != ["--format", "json"]: + return None + values = dict(cli_bin=command[0], turn_instance_id=None, codex_app=False, + full=False, compact=False, brief=False, thin=False, + visible_goal_host=None, available_capabilities=[], agent_scopes=[], + runtime_root=None) + singles = {"--" + name.replace("_", "-"): name for name in ( + "registry", "runtime_root", "goal_id", "agent_id", "active_state", + "material_rule", "permission_rule", "runtime_profile", "visible_goal_host", + "host_surface", "scheduler_owner", "execution_mode", "cli_bin")} + repeated = {"--agent-scope": "agent_scopes", "--available-capability": "available_capabilities"} + booleans = {"--" + name.replace("_", "-"): name for name in ("codex_app", "full", "compact", "brief", "thin")} + index = 3 + while index < len(command): + token = command[index] + if token == "heartbeat-prompt": + index += 1 + continue + if token in booleans: + values[booleans[token]] = True + index += 1 + continue + if token in repeated: + values[repeated[token]].append(command[index + 1]) + elif token in singles: + values[singles[token]] = command[index + 1] + else: + return None + index += 2 + registry = Path(values.pop("registry")) + expected = goal_bootstrap(SimpleNamespace(**values), registry=registry) + return {**values, "registry": registry} if prompt == expected else None + except (AttributeError, IndexError, KeyError, TypeError, ValueError): + return None diff --git a/loopx/control_plane/heartbeat/installed_prompt_update.py b/loopx/control_plane/heartbeat/installed_prompt_update.py new file mode 100644 index 0000000000..9151aa714e --- /dev/null +++ b/loopx/control_plane/heartbeat/installed_prompt_update.py @@ -0,0 +1,186 @@ +"""Update-time discovery and conservative adoption of installed host prompts. + +Only a byte-exact generated prompt may opt into unattended replacement. Names, +Goal ids and prose heuristics can suggest review, never authorize replacement. +The App remains the preferred writer while running. +""" +from __future__ import annotations + +import json +import os +from pathlib import Path +import sqlite3 +import subprocess +import sys +import tempfile + +from .automation_upgrade import SCHEMA, _atomic, apply_offline, bootstrap_binding, build_plan + + +def require_closed_app() -> None: + if sys.platform != "darwin": + raise ValueError("offline adapter is qualified only on macOS; use the App automation API") + for name in ("Codex", "ChatGPT"): + observed = subprocess.run(["/usr/bin/pgrep", "-x", name], capture_output=True, check=False) + if observed.returncode != 1: + raise ValueError("close the Codex/ChatGPT App before offline migration; otherwise use automation_update") + + +def _owned(entry: dict, registry: Path, runtime_root: str | None, cli_bin: str) -> bool: + prompt = entry.get("current_prompt", "") + binding = bootstrap_binding(prompt) + if binding is not None: + # Never retarget a canary binary, home, registry or runtime implicitly. + return (binding["registry"].resolve() == registry.resolve() + and binding.get("cli_bin", "loopx") == cli_bin + and binding.get("runtime_root") == runtime_root) + # Before replacing the installed version, reproduce its uncustomized output. + # Older/custom bodies which cannot be reproduced remain review-only. + from loopx.agent_registry import agent_profile_from_registry, registered_agent_ids_from_registry + from loopx.heartbeat_prompt import build_heartbeat_prompt + + for mode in ("thin", "brief", "compact", "full"): + try: + generated = build_heartbeat_prompt( + goal_id=entry["goal_id"], agent_id=entry["agent_id"], + registered_agents=registered_agent_ids_from_registry(registry, entry["goal_id"]), + agent_profile=agent_profile_from_registry(registry, entry["goal_id"], entry["agent_id"]), + runtime_profile="codex_app_heartbeat", runtime_root=runtime_root, + cli_bin=cli_bin, **{mode: True}, + ) + except ValueError: + continue + if generated.get("ok") and generated.get("task_body") == prompt: + return True + return False + + +def snapshot(*, registry: Path, home: Path, runtime_root: str | None = None, + cli_bin: str = "loopx") -> dict: + if not (home / "sqlite/codex-dev.db").is_file(): + return {"ok": True, "status": "not_installed", "entries": []} + plan = build_plan(registry=registry, home=home, runtime_root=runtime_root, cli_bin=cli_bin) + for entry in plan["entries"]: + entry["automatic_eligible"] = (entry["status"] in {"current", "adoption_required"} + and _owned(entry, registry, runtime_root, cli_bin)) + return plan + + +def reconcile(*, before: dict, registry: Path, home: Path, + runtime_root: str | None = None, cli_bin: str = "loopx") -> dict: + """Re-read after installation; report per-task outcomes without raw prompts.""" + if before.get("status") == "not_installed": + return {"ok": True, "status": "not_installed", "results": []} + if before.get("codex_home") != str(home.resolve()): + raise ValueError("update snapshot belongs to another host home") + if before.get("schema_version") != SCHEMA: + raise ValueError("unsupported update snapshot schema") + current = {entry["automation_id"]: entry for entry in + build_plan(registry=registry, home=home, runtime_root=runtime_root, cli_bin=cli_bin)["entries"]} + results = [] + api_updates = [] + for old in before["entries"]: + identifier = old["automation_id"] + now = current.get(identifier) + result = {"automation_id": identifier, "status": "review_required"} + if now is None: + result["status"] = "missing" + elif now["status"] in {"current", "unmanaged", "blocked"}: + result["status"] = now["status"] + elif any(old.get(key) != now.get(key) for key in + ("source_sha256", "prompt_sha256", "target_thread_id", "goal_id", "agent_id")): + result["status"] = "changed_since_snapshot" + elif old.get("automatic_eligible") is True: + try: + if sys.platform != "darwin": + raise ValueError("direct update adapter is qualified only on macOS; use the App API") + applied = apply_offline(home=home, automation_id=identifier, + expected_prompt_sha256=old["prompt_sha256"], desired_prompt=now["desired_prompt"], + expected_source_sha256=old["source_sha256"]) + result["status"] = applied["status"] + except (OSError, ValueError, sqlite3.Error) as error: + result.update(status="deferred", reason=str(error)) + # The CLI cannot call an in-App tool itself. Give its host a + # complete prompt-only request, plus a precondition to re-view. + import tomllib + try: + manifest = tomllib.loads((home / "automations" / identifier / "automation.toml").read_text()) + except (OSError, ValueError): + manifest = {} + required = {"name", "status", "rrule", "target_thread_id"} + if required <= manifest.keys() and manifest.get("prompt") == now["current_prompt"]: + api_updates.append({"tool": "automation_update", + "expected_prompt_sha256": now["prompt_sha256"], + "precondition": "View the same automation; verify this prompt hash and all preserved fields before update; read back afterward.", + "arguments": {"mode": "update", "id": identifier, "kind": "heartbeat", + "name": manifest["name"], "status": manifest["status"], + "rrule": manifest["rrule"], "targetThreadId": manifest["target_thread_id"], + "notificationPolicy": manifest.get("notification_policy"), + "prompt": now["desired_prompt"]}}) + results.append(result) + pending = any(result["status"] not in {"current", "updated", "unmanaged", "missing"} for result in results) + return {"ok": not pending, "status": "attention_required" if pending else "current", "results": results, + "api_updates": api_updates, + "next_action": "Use automation-prompts plan and the App automation API for pending entries; never rewrite scheduling or thread bindings." if pending else None} + + +def save_snapshot(path: Path, payload: dict) -> None: + _atomic(path, json.dumps(payload, ensure_ascii=False)) + + +def update_with_prompts(payload: dict, *, registry: Path, runtime_root: str | None, + timeout_seconds: int, runtime_update) -> dict: + """Capture old-template evidence, then run reconciliation in the NEW runtime. + + Prompt problems are separate from binary installation success. Never report + the application fully updated merely because its executable was replaced. + """ + from loopx.upgrade import codex_home + + home = codex_home().expanduser().resolve() + try: + before = snapshot(registry=registry, home=home, runtime_root=runtime_root) + except (ValueError, OSError, sqlite3.Error) as error: + before = {"ok": False, "status": "discovery_failed", "reason": str(error)} + updated = runtime_update(payload, timeout_seconds=timeout_seconds) + if not updated.get("ok"): + updated["automation_prompt_upgrade"] = {"status": "skipped_runtime_update_failed"} + return updated + if not before.get("ok") or not before.get("entries"): + updated["automation_prompt_upgrade"] = { + key: value for key, value in before.items() if key in {"ok", "status", "reason"}} + return updated + directory = Path(tempfile.mkdtemp(prefix="loopx-prompt-update-")) + plan_file = directory / "before.json" + save_snapshot(plan_file, before) + driver = payload.get("install_lifecycle", {}).get("execution_driver") + command = ([sys.executable, "-m", "loopx.cli"] if driver == "python_pip" + else [str(Path.home() / ".local/bin/loopx")] if driver == "archive_snapshot" + else ["loopx"]) + command += ["--format", "json", "--registry", str(registry.resolve())] + if runtime_root: + command += ["--runtime-root", runtime_root] + command += ["automation-prompts", "sync-installed", "--codex-home", str(home), + "--plan-file", str(plan_file), "--execute"] + try: + # Do not accidentally import a checkout through the parent's PYTHONPATH. + env = {key: value for key, value in os.environ.items() if key != "PYTHONPATH"} + result = subprocess.run(command, capture_output=True, text=True, env=env, + timeout=timeout_seconds, cwd=directory) + report = json.loads(result.stdout) + if not isinstance(report, dict) or "results" not in report: + raise ValueError("new runtime returned no prompt migration result") + except (ValueError, OSError, subprocess.TimeoutExpired): + report = {"ok": False, "status": "reconciliation_failed"} + if not report.get("ok"): + report["snapshot_file"] = str(plan_file) + updated["recommended_action"] = "Runtime updated; review pending automation prompts using the App API, or retry the saved snapshot with the App closed." + updated["next_action"] = {"kind": "apply_host_prompt_updates", "mutating": True, + "requires_explicit_approval": False, + "reason": "Only byte-exact managed prompts have automatic adoption authority; custom entries require separate review.", + "api_updates": report.get("api_updates", [])} + else: + plan_file.unlink() + directory.rmdir() + updated["automation_prompt_upgrade"] = report + return updated diff --git a/loopx/goal_mode_mcp.py b/loopx/goal_mode_mcp.py index 95eebdc6b6..ef17890127 100644 --- a/loopx/goal_mode_mcp.py +++ b/loopx/goal_mode_mcp.py @@ -148,6 +148,15 @@ def should_run(self) -> str: def list_todos(self) -> str: return self.should_run() + def host_prompt(self) -> str: + from .claude_goal_mode.scripts.goalmode_cmd import loop_execution_content + state = self.state() + goal_id, agent_id = state.get("goal_id"), state.get("agent_id") + if not goal_id or not agent_id: + return json.dumps({"ok": False, "error": "bound Goal and agent are required"}) + return json.dumps({"ok": True, "goal_id": goal_id, "agent_id": agent_id, + "task_body": loop_execution_content(goal_id, agent_id)}) + def claim_task(self, todo_id: str, agent_id: str) -> str: goal_id, _ = self.context() if not goal_id: @@ -259,6 +268,12 @@ def create_fastmcp_server( control = GoalModeMCPControlPlane(config, context_resolver) server = FastMCP(config.server_name) + if config.legacy_host_surface == "claude_code": + @server.tool() + def host_prompt() -> str: + """Read current Claude Goal execution rules for this server's bound identity.""" + return control.host_prompt() + @server.tool() def should_run() -> str: """Whether the bound goal and agent should run now.""" diff --git a/loopx/host_loop_activation.py b/loopx/host_loop_activation.py index 28172ed474..326e39d025 100644 --- a/loopx/host_loop_activation.py +++ b/loopx/host_loop_activation.py @@ -544,6 +544,9 @@ def _heartbeat_commands( **renderer_binding, ), } + if agent_type in {"codex-app", "codex-app-ssh", "codex-cli", "codex-ide-plugin", + "ark-managed-agent"}: + commands = {key: command + " --bootstrap" for key, command in commands.items()} if renderer_binding: commands["visible_goal_prompt_json"] = commands["heartbeat_prompt_json"] return commands diff --git a/loopx/self_update.py b/loopx/self_update.py index 8f8628a038..c3be2a9f17 100644 --- a/loopx/self_update.py +++ b/loopx/self_update.py @@ -1382,6 +1382,16 @@ def render_update_plan_markdown(payload: dict[str, Any]) -> str: next_command = next_action.get("command") if next_command: lines.extend(["", "```bash", str(next_command), "```"]) + prompt_upgrade = payload.get("automation_prompt_upgrade") + if isinstance(prompt_upgrade, dict): + lines.extend(["", "## Automation Prompts", "", + f"- Status: `{prompt_upgrade.get('status')}`"]) + for item in prompt_upgrade.get("results", []): + lines.append(f"- `{item['automation_id']}`: `{item['status']}`") + if prompt_upgrade.get("snapshot_file"): + lines.append(f"- Private recovery snapshot: `{prompt_upgrade['snapshot_file']}`") + if prompt_upgrade.get("next_action"): + lines.append(f"- Next: {prompt_upgrade['next_action']}") lines.extend( [ "", diff --git a/scripts/qualify-native-goal-release.py b/scripts/qualify-native-goal-release.py index ae95d4090c..7dae7ae430 100644 --- a/scripts/qualify-native-goal-release.py +++ b/scripts/qualify-native-goal-release.py @@ -192,7 +192,7 @@ def qualify(root: Path, codex: str, timeout: int) -> dict: ) project, runtime, launcher = setup(root) - prompt = cli(launcher, "heartbeat-prompt", "--runtime-profile", "codex_cli", + prompt = cli(launcher, "heartbeat-prompt", "--bootstrap", "--runtime-profile", "codex_cli", "--goal-id", GOAL, "--agent-id", AGENT, "--cli-bin", str(launcher)) cli(launcher, "quota", "should-run", "--runtime-profile", "codex_cli", "--goal-id", GOAL, "--agent-id", AGENT) diff --git a/tests/control_plane/test_automation_prompt_upgrade.py b/tests/control_plane/test_automation_prompt_upgrade.py index b397df97b2..4b79f488e2 100644 --- a/tests/control_plane/test_automation_prompt_upgrade.py +++ b/tests/control_plane/test_automation_prompt_upgrade.py @@ -18,7 +18,7 @@ def fixture(tmp_path: Path, backing_kind="heartbeat"): path = home / "automations/watch/automation.toml" path.parent.mkdir(parents=True) prompt = "Advance `fixture-goal` from registry. --agent-id agent-a" - path.write_text('version = 1\nid = "watch"\nkind = "heartbeat"\n' + path.write_text('version = 1\nid = "watch"\nname = "Fixture watch"\nkind = "heartbeat"\n' 'status = "PAUSED"\ntarget_thread_id = "thread-a"\n' 'rrule = "FREQ=HOURLY"\nnotification_policy = "failed_runs_only"\n' '# retain custom metadata\n[unused]\nvalue = 1\n', encoding="utf-8") @@ -127,7 +127,7 @@ def test_divergence_never_mutates_host(tmp_path, reason): assert not (home / "loopx-automation-backups").exists() -@pytest.mark.parametrize("mutation", ["standalone", "legacy_mirror", "mismatched_thread"]) +@pytest.mark.parametrize("mutation", ["standalone", "legacy_mirror", "mismatched_thread", "matching_thread"]) def test_cron_backing_is_not_inferred_to_be_a_bound_heartbeat(tmp_path, mutation): home, path, database, registry, _ = fixture(tmp_path, "cron") if mutation == "standalone": @@ -136,7 +136,7 @@ def test_cron_backing_is_not_inferred_to_be_a_bound_heartbeat(tmp_path, mutation # Observed legacy shape: TOML claims a thread but the scheduler does not. with sqlite3.connect(database) as connection: connection.execute("UPDATE automations SET target_thread_id=NULL") - else: + elif mutation == "mismatched_thread": with sqlite3.connect(database) as connection: connection.execute("UPDATE automations SET target_thread_id='thread-b'") before = path.read_bytes() @@ -146,26 +146,200 @@ def test_cron_backing_is_not_inferred_to_be_a_bound_heartbeat(tmp_path, mutation assert path.read_bytes() == before -def test_failure_after_db_commit_is_recoverable_without_duplicate_mutation(tmp_path, monkeypatch): +def _set_fixture_prompt(path, database, prompt): + path.write_text(upgrade._replace_prompt(path.read_text(), prompt)) + with sqlite3.connect(database) as connection: + connection.execute("UPDATE automations SET prompt=?", (prompt,)) + + +def test_runtime_update_invokes_new_cli_with_private_snapshot_and_reports_deferral(tmp_path, monkeypatch): + from loopx.control_plane.heartbeat import installed_prompt_update as lifecycle + from loopx.self_update import render_update_plan_markdown + home, _, _, registry, _ = fixture(tmp_path) + monkeypatch.setattr("loopx.upgrade.codex_home", lambda: home) + real_run = subprocess.run + invoked = [] + def run(command, **kwargs): + invoked.append(command) + assert "PYTHONPATH" not in kwargs["env"] + assert "sync-installed" in command and "--execute" in command + plan_file = Path(command[command.index("--plan-file") + 1]) + assert plan_file.stat().st_mode & 0o077 == 0 + # Actual new-runtime CLI and real SQLite readback. The custom legacy + # fixture has no auto-adoption authority and therefore performs no write. + return real_run([sys.executable, "-m", "loopx.cli", *command[3:]], + capture_output=True, text=True, timeout=60) + monkeypatch.setattr(lifecycle.subprocess, "run", run) + result = lifecycle.update_with_prompts( + {"install_lifecycle": {"execution_driver": "python_pip"}}, registry=registry, + runtime_root=None, timeout_seconds=60, + runtime_update=lambda payload, **_: {**payload, "ok": True, "changes_applied": True}) + assert len(invoked) == 1 and result["ok"] + report = result["automation_prompt_upgrade"] + assert report["status"] == "attention_required" + assert report["results"] == [{"automation_id": "watch", "status": "review_required"}] + assert Path(report["snapshot_file"]).exists() + assert "Automation Prompts" in render_update_plan_markdown(result) + + +def test_failed_install_never_attempts_prompt_writes(tmp_path, monkeypatch): + from loopx.control_plane.heartbeat import installed_prompt_update as lifecycle + home, path, _, registry, _ = fixture(tmp_path) + monkeypatch.setattr("loopx.upgrade.codex_home", lambda: home) + original = path.read_bytes() + def forbidden(*args, **kwargs): + raise AssertionError("failed installer must not invoke prompt writer") + monkeypatch.setattr(lifecycle.subprocess, "run", forbidden) + result = lifecycle.update_with_prompts({}, registry=registry, runtime_root=None, + timeout_seconds=1, runtime_update=lambda payload, **_: {"ok": False}) + assert result["automation_prompt_upgrade"]["status"] == "skipped_runtime_update_failed" + assert path.read_bytes() == original + + +def test_update_identifies_owned_legacy_body_and_migrates_without_changing_schedule(tmp_path, monkeypatch): + from loopx.control_plane.heartbeat import installed_prompt_update as lifecycle + from loopx.heartbeat_prompt import build_heartbeat_prompt + home, path, database, registry, _ = fixture(tmp_path) + prompt = build_heartbeat_prompt(goal_id="fixture-goal", agent_id="agent-a", + registered_agents=["agent-a"], runtime_profile="codex_app_heartbeat", thin=True)["task_body"] + _set_fixture_prompt(path, database, prompt) + before = lifecycle.snapshot(registry=registry, home=home) + assert before["entries"][0]["automatic_eligible"] is True + metadata = tomllib.loads(path.read_text()) + monkeypatch.setattr(lifecycle, "require_closed_app", lambda: None) + monkeypatch.setattr(lifecycle.sys, "platform", "darwin") + result = lifecycle.reconcile(before=before, registry=registry, home=home) + assert result["ok"] and result["results"][0]["status"] == "updated" + after = tomllib.loads(path.read_text()) + assert {k: v for k, v in after.items() if k != "prompt"} == {k: v for k, v in metadata.items() if k != "prompt"} + assert upgrade.bootstrap_binding(after["prompt"]) is not None + assert lifecycle.reconcile(before=before, registry=registry, home=home)["results"][0]["status"] == "current" + + +@pytest.mark.parametrize("scenario", ["custom", "unsupported_host", "race", "wrong_home", "canary"]) +def test_update_does_not_overwrite_custom_changed_or_foreign_hosts(tmp_path, monkeypatch, scenario): + from loopx.control_plane.heartbeat import installed_prompt_update as lifecycle + home, path, database, registry, _ = fixture(tmp_path) + prompt = upgrade.bootstrap_prompt(registry=registry, goal_id="fixture-goal", agent_id="agent-a", + cli_bin="loopx-canary" if scenario == "canary" else "loopx") + prompt = prompt.replace(upgrade.BOOTSTRAP, upgrade._LEGACY_BOOTSTRAP, 1).removesuffix( + upgrade._BOOTSTRAP_INSTRUCTION) + upgrade._LEGACY_INSTRUCTION + if scenario == "custom": + prompt += "\nAdditional owner instruction." + _set_fixture_prompt(path, database, prompt) + before = lifecycle.snapshot(registry=registry, home=home) + if scenario == "race": + _set_fixture_prompt(path, database, prompt + "\nConcurrent edit.") + original = path.read_bytes() + monkeypatch.setattr(lifecycle.sys, "platform", "linux") + if scenario == "wrong_home": + with pytest.raises(ValueError, match="another host"): + lifecycle.reconcile(before=before, registry=registry, home=tmp_path / "other") + else: + result = lifecycle.reconcile(before=before, registry=registry, home=home) + expected = {"custom": "review_required", "canary": "review_required", + "unsupported_host": "deferred", "race": "changed_since_snapshot"}[scenario] + assert result["results"][0]["status"] == expected + assert not result["ok"] + if scenario == "unsupported_host": + request = result["api_updates"][0]["arguments"] + assert request["name"] == "Fixture watch" + assert request["targetThreadId"] == "thread-a" and request["status"] == "PAUSED" + assert path.read_bytes() == original + assert not (home / "loopx-automation-backups").exists() + + +@pytest.mark.parametrize("after_replace", [False, True]) +def test_mirror_failure_rolls_back_db_and_is_journal_recoverable(tmp_path, monkeypatch, after_replace): home, path, database, _, prompt = fixture(tmp_path) atomic = upgrade._atomic def fail_mirror(target, text): if target == path: + if after_replace: + atomic(target, text) raise OSError("synthetic mirror failure") atomic(target, text) monkeypatch.setattr(upgrade, "_atomic", fail_mirror) with pytest.raises(OSError): upgrade.apply_offline(home=home, automation_id="watch", expected_prompt_sha256=upgrade.digest(prompt), desired_prompt="new") - assert tomllib.loads(path.read_text())["prompt"] == prompt + assert tomllib.loads(path.read_text())["prompt"] == ("new" if after_replace else prompt) with sqlite3.connect(database) as connection: - assert connection.execute("SELECT prompt FROM automations").fetchone()[0] == "new" + assert connection.execute("SELECT prompt FROM automations").fetchone()[0] == prompt monkeypatch.setattr(upgrade, "_atomic", atomic) assert upgrade.recover_offline(home=home, automation_id="watch")["status"] == "recovered" assert tomllib.loads(path.read_text())["prompt"] == "new" assert upgrade.recover_offline(home=home, automation_id="watch")["status"] == "recovered" +def test_upgrade_migrates_with_running_app_without_process_or_schedule_mutations(tmp_path, monkeypatch): + from loopx.control_plane.heartbeat import installed_prompt_update as lifecycle + home, path, database, registry, _ = fixture(tmp_path) + prompt = upgrade.bootstrap_prompt(registry=registry, goal_id="fixture-goal", agent_id="agent-a") + legacy = prompt.replace(upgrade.BOOTSTRAP, upgrade._LEGACY_BOOTSTRAP, 1).removesuffix( + upgrade._BOOTSTRAP_INSTRUCTION) + upgrade._LEGACY_INSTRUCTION + _set_fixture_prompt(path, database, legacy) + before = lifecycle.snapshot(registry=registry, home=home) + monkeypatch.setattr(lifecycle.sys, "platform", "darwin") + def forbidden(): + raise AssertionError("the App is running; upgrade must not close or pause it") + monkeypatch.setattr(lifecycle, "require_closed_app", forbidden) + with sqlite3.connect(database) as observer: + metadata = observer.execute("SELECT status, target_thread_id, rrule, model, updated_at, next_run_at FROM automations").fetchone() + result = lifecycle.reconcile(before=before, registry=registry, home=home) + assert result["ok"] and result["results"][0]["status"] == "updated" + assert observer.execute("SELECT prompt FROM automations").fetchone()[0] == prompt + assert observer.execute("SELECT status, target_thread_id, rrule, model, updated_at, next_run_at FROM automations").fetchone() == metadata + assert tomllib.loads(path.read_text())["prompt"] == prompt + + +def test_concurrent_manifest_change_before_write_is_not_overwritten(tmp_path, monkeypatch): + home, path, database, _, prompt = fixture(tmp_path) + original = path.read_text() + atomic = upgrade._atomic + def concurrent_edit(target, text): + atomic(target, text) + if target != path: + path.write_text(original + '\n# concurrent owner edit\n') + monkeypatch.setattr(upgrade, "_atomic", concurrent_edit) + with pytest.raises(ValueError, match="manifest changed"): + upgrade.apply_offline(home=home, automation_id="watch", + expected_prompt_sha256=upgrade.digest(prompt), desired_prompt="new") + assert "concurrent owner edit" in path.read_text() + with sqlite3.connect(database) as connection: + assert connection.execute("SELECT prompt FROM automations").fetchone()[0] == prompt + + +def test_sqlite_writer_lock_covers_mirror_delivery(tmp_path, monkeypatch): + home, path, database, _, prompt = fixture(tmp_path) + atomic = upgrade._atomic + def while_locked(target, text): + if target == path: + with sqlite3.connect(database, timeout=0) as other: + with pytest.raises(sqlite3.OperationalError, match="locked"): + other.execute("UPDATE automations SET prompt='concurrent'") + atomic(target, text) + monkeypatch.setattr(upgrade, "_atomic", while_locked) + assert upgrade.apply_offline(home=home, automation_id="watch", + expected_prompt_sha256=upgrade.digest(prompt), desired_prompt="new")["ok"] + + +def test_readback_detects_external_manifest_edit_without_rolling_it_back(tmp_path, monkeypatch): + home, path, database, _, prompt = fixture(tmp_path) + atomic = upgrade._atomic + def interference(target, text): + atomic(target, text) + if target == path: + path.write_text(text + '\n# owner edit during readback\n') + monkeypatch.setattr(upgrade, "_atomic", interference) + with pytest.raises(ValueError, match="readback"): + upgrade.apply_offline(home=home, automation_id="watch", + expected_prompt_sha256=upgrade.digest(prompt), desired_prompt="new") + assert 'owner edit' in path.read_text() + with sqlite3.connect(database) as connection: + assert connection.execute("SELECT prompt FROM automations").fetchone()[0] == prompt + + def test_recovery_refuses_later_customization(tmp_path): home, path, _, _, prompt = fixture(tmp_path) upgrade.apply_offline(home=home, automation_id="watch", diff --git a/tests/control_plane/test_host_bootstrap_lifecycle.py b/tests/control_plane/test_host_bootstrap_lifecycle.py new file mode 100644 index 0000000000..b599b563c3 --- /dev/null +++ b/tests/control_plane/test_host_bootstrap_lifecycle.py @@ -0,0 +1,85 @@ +"""The persistent host entrypoint reloads policy, not scheduler ownership.""" +import json +import shlex +import subprocess +import sys + +import pytest + + +def cli(registry, *arguments): + result = subprocess.run([sys.executable, "-m", "loopx.cli", "--format", "json", + "--registry", str(registry), "heartbeat-prompt", "--goal-id", "fixture-goal", + "--agent-id", "worker-a", *arguments], capture_output=True, text=True, timeout=60) + return json.loads(result.stdout) + + +@pytest.fixture +def registry(tmp_path): + state = tmp_path / "STATE.md" + state.write_text("# Fixture\n") + registry = tmp_path / "registry.json" + registry.write_text(json.dumps({"goals": [{"id": "fixture-goal", "repo": str(tmp_path), + "state_file": str(state), "registered_agents": ["worker-a"]}]})) + return registry + + +@pytest.mark.parametrize("flags", [ + ["--runtime-profile", "codex_cli"], ["--runtime-profile", "codex_app_ssh_goal"], + ["--runtime-profile", "ark_managed_agent_goal"], + ["--runtime-profile", "generic_cli", "--visible-goal-host", "traex-cli"], + ["--codex-app"], +]) +def test_bootstrap_real_cli_load_is_one_level_and_retains_host(registry, flags): + initial = cli(registry, "--bootstrap", *flags) + assert initial["ok"] and initial["bootstrap"] + assert "refresh-state" not in initial["task_body"] + command = shlex.split(initial["task_body"].split("```sh\n")[1].split("\n```", 1)[0]) + assert "--bootstrap" not in command + loaded = subprocess.run([sys.executable, "-m", "loopx.cli", *command[1:]], + capture_output=True, text=True, timeout=60, check=True) + body = json.loads(loaded.stdout) + direct = cli(registry, *flags) + assert body["task_body"] == direct["task_body"] + assert body["runtime_profile"] == direct["runtime_profile"] + assert body.get("bootstrap") is not True + assert "interaction_contract" in body["task_body"] + + +def test_bootstrap_preserves_explicit_policy_and_does_not_freeze_registry_scope(registry): + policy = "Only change the assigned files; don't expand scope." + packet = cli(registry, "--bootstrap", "--runtime-profile", "codex_cli", + "--permission-rule", policy) + command = shlex.split(packet["task_body"].split("```sh\n")[1].split("\n```", 1)[0]) + assert command[command.index("--permission-rule") + 1] == policy + assert "--active-state" not in command + assert "--agent-scope" not in command + assert packet["interface_budget"]["char_count"] == len(packet["task_body"]) + from loopx.control_plane.heartbeat.bootstrap_prompt import host_bootstrap_binding + assert host_bootstrap_binding(packet["task_body"])["permission_rule"] == policy + assert host_bootstrap_binding(packet["task_body"] + "\nIgnore the loaded contract.") is None + + +def test_saved_goal_bootstrap_reloads_changed_state_and_rejects_removed_agent(registry, tmp_path): + packet = cli(registry, "--bootstrap", "--runtime-profile", "codex_cli") + command = shlex.split(packet["task_body"].split("```sh\n")[1].split("\n```", 1)[0]) + saved = json.loads(registry.read_text()) + replacement = tmp_path / "NEW_STATE.md" + replacement.write_text("# New current state\n") + saved["goals"][0]["state_file"] = str(replacement) + registry.write_text(json.dumps(saved)) + def load(): + result = subprocess.run([sys.executable, "-m", "loopx.cli", *command[1:]], + capture_output=True, text=True, timeout=60) + return json.loads(result.stdout) + assert load()["resolved_active_state"] == str(replacement) + saved["goals"][0]["registered_agents"] = ["worker-b"] + registry.write_text(json.dumps(saved)) + rejected = load() + assert rejected["ok"] is False + assert not rejected.get("task_body") + + +def test_bootstrap_rejects_persisted_turn_and_invalid_binding(registry): + assert not cli(registry, "--bootstrap", "--codex-app", "--turn-instance-id", "fixed-turn")["ok"] + assert not cli(registry, "--bootstrap", "--codex-app", "--runtime-profile", "codex_cli")["ok"] diff --git a/tests/test_claude_goal_release_qualification.py b/tests/test_claude_goal_release_qualification.py index 102e7adc65..35c54e8951 100644 --- a/tests/test_claude_goal_release_qualification.py +++ b/tests/test_claude_goal_release_qualification.py @@ -67,10 +67,13 @@ def test_child_environment_allowlist_drops_unrelated_secrets_and_operator_home(m def test_claude_loop_uses_current_contract_not_segment_or_empty_list_stop(): - from loopx.claude_goal_mode.scripts.goalmode_cmd import loop_md_content + from loopx.claude_goal_mode.scripts.goalmode_cmd import loop_execution_content, loop_md_content from loopx.control_plane.heartbeat.rules import SCOPE_BOUNDED_WORK_RULE - prompt = loop_md_content("goal-a", "agent-a") + bootstrap = loop_md_content("goal-a", "agent-a") + assert "host_prompt" in bootstrap and "loopx:armed" in bootstrap + assert "writeback/spend" not in bootstrap + prompt = loop_execution_content("goal-a", "agent-a") assert SCOPE_BOUNDED_WORK_RULE in prompt assert "interaction_contract" in prompt and "notification" in prompt assert "ONE bounded segment" not in prompt and "no open todos remain" not in prompt @@ -138,7 +141,13 @@ async def exercise(): async with ClientSession(read, write) as session: await session.initialize() tools = await session.list_tools() - assert {"should_run", "claim_task", "complete_task"} <= {t.name for t in tools.tools} + assert {"host_prompt", "should_run", "claim_task", "complete_task"} <= {t.name for t in tools.tools} + loaded = await session.call_tool("host_prompt", {}) + current = json.loads(loaded.content[0].text) + assert current["ok"] and current["goal_id"] == runner.shared.GOAL + assert current["agent_id"] == runner.shared.AGENT + assert "complete_task" in current["task_body"] + assert "call the bound LoopX `host_prompt`" not in current["task_body"] complete = next(t for t in tools.tools if t.name == "complete_task") assert "successor_todo_ids" in complete.inputSchema["properties"] guard = await session.call_tool("should_run", {}) From 55c0e0e1849418c54fff471a708b024fc35d5f3c Mon Sep 17 00:00:00 2001 From: huangruiteng Date: Fri, 11 Sep 2026 11:52:02 +0800 Subject: [PATCH 21/26] test(cli): attribute one-time static safety output growth Signed-off-by: huangruiteng --- .../control_plane/cli-output-probe-runner.py | 1 + .../testing/cli_output_differential.py | 8 ++++++++ .../testing/cli_output_semantics.py | 12 ++++++++++++ .../test_cli_output_differential.py | 16 ++++++++++++++++ 4 files changed, 37 insertions(+) diff --git a/examples/control_plane/cli-output-probe-runner.py b/examples/control_plane/cli-output-probe-runner.py index ca1f172184..c74b55d840 100644 --- a/examples/control_plane/cli-output-probe-runner.py +++ b/examples/control_plane/cli-output-probe-runner.py @@ -102,6 +102,7 @@ def _receipt_row( "runtime_root_command_route_count": ( semantics.runtime_root_command_route_count(text) ), + "host_prompt_static_safety_revision": semantics.host_prompt_static_safety_revision(text), "guided_todo_delta_schema_versions": ( semantics.guided_todo_delta_schema_versions(payload) if isinstance(payload, dict) diff --git a/loopx/control_plane/testing/cli_output_differential.py b/loopx/control_plane/testing/cli_output_differential.py index d0feb0f8aa..a98191bb5c 100644 --- a/loopx/control_plane/testing/cli_output_differential.py +++ b/loopx/control_plane/testing/cli_output_differential.py @@ -471,6 +471,14 @@ def _compare_row(base: dict[str, Any], candidate: dict[str, Any]) -> dict[str, A # do not relax quota or other agent-facing surfaces with this allowance. if row_id.startswith("surface/heartbeat_prompt_thin/") and metric == "utf8_bytes": allowance = max(allowance, 192) + if (row_id.startswith(("surface/heartbeat_prompt_thin/", "surface/heartbeat_prompt_brief/", "surface/heartbeat_prompt_compact/")) + and base.get("host_prompt_static_safety_revision") is None + and candidate.get("host_prompt_static_safety_revision") == "host_prompt_static_safety_v1"): + # Authorized static safety + executable shell bootstrap restoration. + # Absolute ceilings stay enforced by the probe; once merged, v1->v1 + # receives no allowance. Quota/status and other surfaces are excluded. + allowance = max(allowance, {"chars": 512, "utf8_bytes": 640, + "lines": 5, "compact_payload_chars": 512}[metric]) if migration.portfolio_growth_migration: allowance = max( allowance, diff --git a/loopx/control_plane/testing/cli_output_semantics.py b/loopx/control_plane/testing/cli_output_semantics.py index d25a996d0f..3060f2b76a 100644 --- a/loopx/control_plane/testing/cli_output_semantics.py +++ b/loopx/control_plane/testing/cli_output_semantics.py @@ -5,6 +5,18 @@ import re from typing import Any +def host_prompt_static_safety_revision(text: str) -> str | None: + """Exact renderer evidence for the one-time static-safety budget transition. + + This is test-output attribution, never a runtime permission classifier. + Keep the full invariant block, not a substring such as 'safe' or 'LoopX'. + """ + block = ( + "Follow user authority and repository rules. Protect credentials/private material; " + "publish public-safe evidence. Destructive Git/production requires explicit authorization. " + "Gate only the affected path; continue independent allowed work." + ) + return "host_prompt_static_safety_v1" if block in text else None _MARKDOWN_HEADING = re.compile(r"^#{1,6}\s+.+$") _RUNTIME_ROOT_COMMAND_ROUTE = re.compile( diff --git a/tests/control_plane/test_cli_output_differential.py b/tests/control_plane/test_cli_output_differential.py index 2dac0ac73a..8f364bd1f1 100644 --- a/tests/control_plane/test_cli_output_differential.py +++ b/tests/control_plane/test_cli_output_differential.py @@ -85,6 +85,22 @@ def test_sync_commit_uses_main_as_cli_output_base() -> None: assert selected == "origin/main" +def test_host_safety_restoration_budget_is_one_time_bounded_and_prompt_only(): + from loopx.control_plane.testing.cli_output_differential import _compare_row + from loopx.control_plane.testing.cli_output_semantics import host_prompt_static_safety_revision + from loopx.control_plane.heartbeat.rules import HOST_LOOP_SAFETY_RULE + assert host_prompt_static_safety_revision(HOST_LOOP_SAFETY_RULE) == "host_prompt_static_safety_v1" + assert host_prompt_static_safety_revision(HOST_LOOP_SAFETY_RULE.replace("requires explicit authorization", "is always allowed")) is None + base = _row(row_id="surface/heartbeat_prompt_thin/small/json") + current = {**base, "chars": base["chars"] + 500, + "host_prompt_static_safety_revision": "host_prompt_static_safety_v1"} + assert not _compare_row(base, current)["failures"] + assert _compare_row(base, {**current, "chars": base["chars"] + 513})["failures"] + assert _compare_row(current, {**current, "chars": current["chars"] + 500})["failures"] + assert _compare_row({**base, "row_id": "surface/status/small/json"}, + {**current, "row_id": "surface/status/small/json"})["failures"] + + def test_regular_integration_pr_keeps_requested_cli_output_base() -> None: ancestors = { ("origin/main", "HEAD"), From 94b67cfc11c00d76f4604cc86ebec5a3fe243db3 Mon Sep 17 00:00:00 2001 From: huangruiteng Date: Fri, 11 Sep 2026 11:55:01 +0800 Subject: [PATCH 22/26] test(cli): cover brief and compact safety transition variants Signed-off-by: huangruiteng --- loopx/control_plane/testing/cli_output_differential.py | 4 +++- tests/control_plane/test_cli_output_differential.py | 6 ++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/loopx/control_plane/testing/cli_output_differential.py b/loopx/control_plane/testing/cli_output_differential.py index a98191bb5c..e6b3029403 100644 --- a/loopx/control_plane/testing/cli_output_differential.py +++ b/loopx/control_plane/testing/cli_output_differential.py @@ -471,7 +471,9 @@ def _compare_row(base: dict[str, Any], candidate: dict[str, Any]) -> dict[str, A # do not relax quota or other agent-facing surfaces with this allowance. if row_id.startswith("surface/heartbeat_prompt_thin/") and metric == "utf8_bytes": allowance = max(allowance, 192) - if (row_id.startswith(("surface/heartbeat_prompt_thin/", "surface/heartbeat_prompt_brief/", "surface/heartbeat_prompt_compact/")) + if (row_id.startswith(("surface/", "variant/")) + and row_id.partition("/")[2].partition("/")[0] in { + "heartbeat_prompt_thin", "heartbeat_prompt_brief", "heartbeat_prompt_compact"} and base.get("host_prompt_static_safety_revision") is None and candidate.get("host_prompt_static_safety_revision") == "host_prompt_static_safety_v1"): # Authorized static safety + executable shell bootstrap restoration. diff --git a/tests/control_plane/test_cli_output_differential.py b/tests/control_plane/test_cli_output_differential.py index 8f364bd1f1..bad1c40fb7 100644 --- a/tests/control_plane/test_cli_output_differential.py +++ b/tests/control_plane/test_cli_output_differential.py @@ -85,13 +85,15 @@ def test_sync_commit_uses_main_as_cli_output_base() -> None: assert selected == "origin/main" -def test_host_safety_restoration_budget_is_one_time_bounded_and_prompt_only(): +@pytest.mark.parametrize("row_kind", ["surface", "variant"]) +@pytest.mark.parametrize("mode", ["thin", "brief", "compact"]) +def test_host_safety_restoration_budget_is_one_time_bounded_and_prompt_only(row_kind, mode): from loopx.control_plane.testing.cli_output_differential import _compare_row from loopx.control_plane.testing.cli_output_semantics import host_prompt_static_safety_revision from loopx.control_plane.heartbeat.rules import HOST_LOOP_SAFETY_RULE assert host_prompt_static_safety_revision(HOST_LOOP_SAFETY_RULE) == "host_prompt_static_safety_v1" assert host_prompt_static_safety_revision(HOST_LOOP_SAFETY_RULE.replace("requires explicit authorization", "is always allowed")) is None - base = _row(row_id="surface/heartbeat_prompt_thin/small/json") + base = _row(row_id=f"{row_kind}/heartbeat_prompt_{mode}/small/json") current = {**base, "chars": base["chars"] + 500, "host_prompt_static_safety_revision": "host_prompt_static_safety_v1"} assert not _compare_row(base, current)["failures"] From 08f597a39c051d9268f5ac5c9f437b5c8ea7bc8c Mon Sep 17 00:00:00 2001 From: huangruiteng Date: Fri, 11 Sep 2026 11:59:29 +0800 Subject: [PATCH 23/26] fix(update): reconcile through the upgraded pipx interpreter Signed-off-by: huangruiteng --- loopx/control_plane/heartbeat/installed_prompt_update.py | 4 +++- tests/control_plane/test_automation_prompt_upgrade.py | 6 ++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/loopx/control_plane/heartbeat/installed_prompt_update.py b/loopx/control_plane/heartbeat/installed_prompt_update.py index 9151aa714e..4b10593ce9 100644 --- a/loopx/control_plane/heartbeat/installed_prompt_update.py +++ b/loopx/control_plane/heartbeat/installed_prompt_update.py @@ -154,7 +154,9 @@ def update_with_prompts(payload: dict, *, registry: Path, runtime_root: str | No plan_file = directory / "before.json" save_snapshot(plan_file, before) driver = payload.get("install_lifecycle", {}).get("execution_driver") - command = ([sys.executable, "-m", "loopx.cli"] if driver == "python_pip" + if driver is None and isinstance(payload.get("source"), dict): + driver = "archive_snapshot" + command = ([sys.executable, "-m", "loopx.cli"] if driver in {"python_pip", "python_pipx"} else [str(Path.home() / ".local/bin/loopx")] if driver == "archive_snapshot" else ["loopx"]) command += ["--format", "json", "--registry", str(registry.resolve())] diff --git a/tests/control_plane/test_automation_prompt_upgrade.py b/tests/control_plane/test_automation_prompt_upgrade.py index 4b79f488e2..e62e0bff8f 100644 --- a/tests/control_plane/test_automation_prompt_upgrade.py +++ b/tests/control_plane/test_automation_prompt_upgrade.py @@ -152,7 +152,8 @@ def _set_fixture_prompt(path, database, prompt): connection.execute("UPDATE automations SET prompt=?", (prompt,)) -def test_runtime_update_invokes_new_cli_with_private_snapshot_and_reports_deferral(tmp_path, monkeypatch): +@pytest.mark.parametrize("driver", ["python_pip", "python_pipx"]) +def test_runtime_update_invokes_new_cli_with_private_snapshot_and_reports_deferral(tmp_path, monkeypatch, driver): from loopx.control_plane.heartbeat import installed_prompt_update as lifecycle from loopx.self_update import render_update_plan_markdown home, _, _, registry, _ = fixture(tmp_path) @@ -162,6 +163,7 @@ def test_runtime_update_invokes_new_cli_with_private_snapshot_and_reports_deferr def run(command, **kwargs): invoked.append(command) assert "PYTHONPATH" not in kwargs["env"] + assert command[:3] == [sys.executable, "-m", "loopx.cli"] assert "sync-installed" in command and "--execute" in command plan_file = Path(command[command.index("--plan-file") + 1]) assert plan_file.stat().st_mode & 0o077 == 0 @@ -171,7 +173,7 @@ def run(command, **kwargs): capture_output=True, text=True, timeout=60) monkeypatch.setattr(lifecycle.subprocess, "run", run) result = lifecycle.update_with_prompts( - {"install_lifecycle": {"execution_driver": "python_pip"}}, registry=registry, + {"install_lifecycle": {"execution_driver": driver}}, registry=registry, runtime_root=None, timeout_seconds=60, runtime_update=lambda payload, **_: {**payload, "ok": True, "changes_applied": True}) assert len(invoked) == 1 and result["ok"] From b572baf3817c005edde04e507168d0c73c2ece74 Mon Sep 17 00:00:00 2001 From: huangruiteng Date: Fri, 11 Sep 2026 12:46:11 +0800 Subject: [PATCH 24/26] fix(hosts): qualify brief output and automatic prompt adoption Signed-off-by: huangruiteng --- docs/reference/automation-prompt-upgrades.md | 8 +++- .../cli-output-budget-regression-smoke.py | 1 + .../control_plane/heartbeat-prompt-smoke.py | 31 ++++++++------- loopx/control_plane/heartbeat/task_body.py | 39 ++++++++++--------- .../test_automation_prompt_upgrade.py | 37 ++++++++++++++---- tests/control_plane/test_cli_output_budget.py | 26 ++++++++++++- tests/test_ark_managed_agent_host.py | 7 ++-- 7 files changed, 104 insertions(+), 45 deletions(-) diff --git a/docs/reference/automation-prompt-upgrades.md b/docs/reference/automation-prompt-upgrades.md index 9d17e22488..a676c8c4cb 100644 --- a/docs/reference/automation-prompt-upgrades.md +++ b/docs/reference/automation-prompt-upgrades.md @@ -33,7 +33,7 @@ The owner is the existing heartbeat/upgrade boundary; there is no new optional capability or extension provider. SQLite/TOML handling is a local host adapter, not Todo, quota or scheduler authority. -## Preview and adopt existing tasks +## Automatic upgrade and manual adoption `loopx update --apply` now captures an owner-only snapshot **before** replacing the runtime and invokes `automation-prompts sync-installed` in the **new** @@ -44,6 +44,12 @@ Custom instructions remain `review_required`; a canary executable, different registry/home or changed preview is not silently retargeted. Binary-install success and prompt-migration success are reported separately. +**Exact managed v1 wrappers upgrade to v2 automatically through this path; +they do not require per-task approval.** `automation-prompts plan` is only a +read-only preview, not the upgrade executor. Do not infer a manual-only policy +from its `adoption_required` status. Custom or inconsistent entries still need +review; automatic prompt migration never grants scheduler or thread authority. + On the qualified macOS heartbeat schema this upgrade path can write directly with the App running. It holds a SQLite writer transaction through TOML delivery, compares the entire previewed manifest, preserves every non-prompt field, and diff --git a/examples/control_plane/cli-output-budget-regression-smoke.py b/examples/control_plane/cli-output-budget-regression-smoke.py index 2e46b81289..1f0ee28fb0 100644 --- a/examples/control_plane/cli-output-budget-regression-smoke.py +++ b/examples/control_plane/cli-output-budget-regression-smoke.py @@ -26,6 +26,7 @@ def _run_budget_checks() -> None: tests = runpy.run_path(str(TEST_PATH)) tests["test_manifest_covers_the_declared_agent_facing_surface_set"]() + tests["test_brief_budget_retains_full_commands_on_real_long_paths"]() with tempfile.TemporaryDirectory(prefix="loopx-cli-output-budget-") as temp_dir: root = Path(temp_dir) tests["test_real_cli_output_stays_inside_the_characterized_baseline"]( diff --git a/examples/control_plane/heartbeat-prompt-smoke.py b/examples/control_plane/heartbeat-prompt-smoke.py index 3462e97fd6..d9128b3a77 100644 --- a/examples/control_plane/heartbeat-prompt-smoke.py +++ b/examples/control_plane/heartbeat-prompt-smoke.py @@ -70,7 +70,7 @@ def user_output_policy(task_body: str, *, mode: str) -> dict[str, str]: assert "需修复LoopX状态投影" in body assert "静默时内部修复" in body if mode == "brief": - assert "Return only under `user_channel.notify=NOTIFY`; else quiet." in body + assert "仅 `user_channel.notify=NOTIFY` 时输出,否则静默。" in body return { "authority": "interaction_contract.user_channel.notify", "external": "NOTIFY", @@ -106,7 +106,7 @@ def assert_sole_notification_authority(task_body: str, *, mode: str) -> None: return if mode == "brief": - assert "Return only under `user_channel.notify=NOTIFY`; else quiet." in body + assert "仅 `user_channel.notify=NOTIFY` 时输出,否则静默。" in body return assert mode == "thin", mode @@ -638,7 +638,7 @@ def main() -> int: ) brief_task = normalized(str(brief_payload["task_body"])) for phrase in ( - "Brief LoopX heartbeat; detail", + "Brief 详情:", "loopx heartbeat-prompt --compact --goal-id public-heartbeat-goal --active-state /tmp/public-heartbeat-goal/ACTIVE_GOAL_STATE.md", "Run assignment and guard as separate statements in one shell", 'loopx --format json --registry "$HOME/.codex/loopx/registry.global.json" quota should-run --goal-id public-heartbeat-goal', @@ -647,24 +647,27 @@ def main() -> int: "Done->successor first; final->refresh->spend->no-follow-up", "NOTIFY缺动作→", "具体user todo未投影", - "follow user channel", + "按 user channel", "monitor_quiet_skip", - "receipt/stall done", - "retry same id", - "one read-only poll", + "已记 receipt/stall", + "写失败同 id 重试", + "只读一次", "safe_bypass_kind=outcome_floor_recovery", - "ranker/cross-domain evidence recovery", + "恢复 ranker/cross-domain evidence", "status --limit 3", "review-packet --handoff-only", "heartbeat_recommendation", "goal_boundary", - "scope-bounded work", - "validate/writeback/todos", - "Progress(actual,no upgrade)", - "Spend once; no pipe/retry", - "Post-spend state", + "授权/预算内推进可验证结果", + "`must_attempt_work=true` 须推进", + "验证/写回/Todos", + "实际进展(非升级)", + "扣额一次,不管道/重试", + "扣额后刷新", 'loopx --format json --registry "$HOME/.codex/loopx/registry.global.json" quota spend-slot --goal-id public-heartbeat-goal --slots 1 --source heartbeat --execute', - "No spend for quiet skips", + "静默跳过、preflight 失败、blocker-push 提问、dry-run、重复记账均不扣额", + "No learning queue unless asked.", + "No permission asks in a trusted session.", ): assert phrase in brief_task, phrase assert thin_payload["thin"] is True, thin_payload diff --git a/loopx/control_plane/heartbeat/task_body.py b/loopx/control_plane/heartbeat/task_body.py index ce2583ae67..184ed33688 100644 --- a/loopx/control_plane/heartbeat/task_body.py +++ b/loopx/control_plane/heartbeat/task_body.py @@ -284,9 +284,14 @@ def render_brief_heartbeat_task_body( pr_review_pre_quota_block = ( f"{pr_review_pre_quota_command}\n" if pr_review_pre_quota_command else "" ) - return f"""Advance `{goal_id}` using `{active_state}`. + policy_tail = _render_compact_policy_tail( + material_queue_rule=material_queue_rule, + permission_rule=permission_rule, + include_default_permission=True, + ) + return f"""推进 `{goal_id}`;状态 `{active_state}`。 -Brief LoopX heartbeat; detail: +Brief 详情: `{compact_prompt_command}`. {scope_block} @@ -304,35 +309,33 @@ def render_brief_heartbeat_task_body( {SCOPE_BOUNDED_WORK_RULE} {HEARTBEAT_VISION_WRITEBACK_RULE_SHORT} -If `should_run=false`: follow user channel. `monitor_quiet_skip`: receipt/stall -done; quiet unless replan; write failure: retry same id. External/wait monitor: -one read-only poll; new evidence -> writeback/spend. Safe bypass if allowed. +`should_run=false`:按 user channel。`monitor_quiet_skip` 已记 receipt/stall; +无 replan 静默,写失败同 id 重试。external/wait monitor 只读一次, +新证据才 writeback/spend;bypass 须获准。 {SCHEDULER_HINT_THIN_RULE} `agent_read_required`: drain/read/triage before work; settle/ACK. -If `should_run=true`: fetch compact; use `status --limit 3` and -`review-packet --handoff-only`. Obey -`execution_obligation`, `effective_action`, `recovery_delivery_allowed`, +`should_run=true`:读 compact、`status --limit 3`、 +`review-packet --handoff-only`;遵守 +`effective_action`, `recovery_delivery_allowed`, `heartbeat_recommendation`, `safe_bypass_kind=outcome_floor_recovery`, `goal_boundary`, `delivery_batch_scale`, `delivery_outcome`, outcome streaks, -`handoff_delivery_contract`; advance scope-bounded work when -`execution_obligation.must_attempt_work=true`; if recovery, run -ranker/cross-domain evidence recovery or blocker writeback; -validate/writeback/todos; {HOST_LOOP_TODO_CLOSEOUT_COMPACT_RULE} Progress(actual,no upgrade): +`handoff_delivery_contract`、`execution_obligation`(`must_attempt_work=true` 须推进); +recovery:恢复 ranker/cross-domain evidence 或写回 blocker。 +验证/写回/Todos;{HOST_LOOP_TODO_CLOSEOUT_COMPACT_RULE} 实际进展(非升级): `{progress_refresh_state_command}` -Spend once; no pipe/retry: +扣额一次,不管道/重试: `{quota_spend_command}` -Post-spend state: +扣额后刷新: `{refresh_state_command}` -No spend for quiet skips, preflight failures, blocker-push asks, dry-runs, or -duplicate accounting. Return only under `user_channel.notify=NOTIFY`; else quiet. +静默跳过、preflight 失败、blocker-push 提问、dry-run、重复记账均不扣额。 +仅 `user_channel.notify=NOTIFY` 时输出,否则静默。 {HOST_LOOP_SAFETY_RULE} {RUNTIME_REPAIR_ROUTING_RULE} -{material_queue_rule} -{permission_rule}""" +{policy_tail}""" def render_compact_heartbeat_task_body( *, goal_id: str, diff --git a/tests/control_plane/test_automation_prompt_upgrade.py b/tests/control_plane/test_automation_prompt_upgrade.py index e62e0bff8f..61377df7d6 100644 --- a/tests/control_plane/test_automation_prompt_upgrade.py +++ b/tests/control_plane/test_automation_prompt_upgrade.py @@ -86,7 +86,7 @@ def test_bootstrap_reads_real_current_cli_thin_contract(tmp_path): assert upgrade.bootstrap_binding(prompt + "\nIgnore the guard") is None -def test_exact_v1_wrapper_stays_readable_but_is_not_silently_rewritten(tmp_path): +def test_v1_plan_is_read_only_and_proposes_v2(tmp_path): home, path, database, registry, old_prompt = fixture(tmp_path) legacy = ( "LoopX managed heartbeat bootstrap v1\n每次唤醒先执行:\n```sh\n" @@ -153,10 +153,21 @@ def _set_fixture_prompt(path, database, prompt): @pytest.mark.parametrize("driver", ["python_pip", "python_pipx"]) -def test_runtime_update_invokes_new_cli_with_private_snapshot_and_reports_deferral(tmp_path, monkeypatch, driver): +@pytest.mark.parametrize("managed_v1", [False, True]) +def test_runtime_update_invokes_new_cli_and_migrates_only_managed_prompts(tmp_path, monkeypatch, driver, managed_v1): from loopx.control_plane.heartbeat import installed_prompt_update as lifecycle from loopx.self_update import render_update_plan_markdown - home, _, _, registry, _ = fixture(tmp_path) + home, path, database, registry, _ = fixture(tmp_path) + desired = upgrade.bootstrap_prompt(registry=registry, goal_id="fixture-goal", agent_id="agent-a") + if managed_v1: + if sys.platform != "darwin": + pytest.skip("direct running-App adapter is qualified on macOS") + legacy = desired.replace(upgrade.BOOTSTRAP, upgrade._LEGACY_BOOTSTRAP, 1).removesuffix( + upgrade._BOOTSTRAP_INSTRUCTION) + upgrade._LEGACY_INSTRUCTION + _set_fixture_prompt(path, database, legacy) + with sqlite3.connect(database) as connection: + original = connection.execute("SELECT * FROM automations").fetchone() + manifest = tomllib.loads(path.read_text()) monkeypatch.setattr("loopx.upgrade.codex_home", lambda: home) real_run = subprocess.run invoked = [] @@ -167,8 +178,8 @@ def run(command, **kwargs): assert "sync-installed" in command and "--execute" in command plan_file = Path(command[command.index("--plan-file") + 1]) assert plan_file.stat().st_mode & 0o077 == 0 - # Actual new-runtime CLI and real SQLite readback. The custom legacy - # fixture has no auto-adoption authority and therefore performs no write. + # Actual new-runtime CLI and real SQLite/TOML, not a mocked reconciler. + # Only package replacement is substituted in this lifecycle test. return real_run([sys.executable, "-m", "loopx.cli", *command[3:]], capture_output=True, text=True, timeout=60) monkeypatch.setattr(lifecycle.subprocess, "run", run) @@ -178,9 +189,19 @@ def run(command, **kwargs): runtime_update=lambda payload, **_: {**payload, "ok": True, "changes_applied": True}) assert len(invoked) == 1 and result["ok"] report = result["automation_prompt_upgrade"] - assert report["status"] == "attention_required" - assert report["results"] == [{"automation_id": "watch", "status": "review_required"}] - assert Path(report["snapshot_file"]).exists() + assert report["status"] == ("current" if managed_v1 else "attention_required") + assert report["results"] == [{"automation_id": "watch", "status": "updated" if managed_v1 else "review_required"}] + if managed_v1: + assert "snapshot_file" not in report + else: + assert Path(report["snapshot_file"]).exists() + after_manifest = tomllib.loads(path.read_text()) + assert after_manifest == {**manifest, "prompt": desired if managed_v1 else manifest["prompt"]} + with sqlite3.connect(database) as connection: + after = connection.execute("SELECT * FROM automations").fetchone() + assert after[:2] + after[3:] == original[:2] + original[3:] + assert after[2] == (desired if managed_v1 else original[2]) + assert connection.execute("SELECT * FROM sessions").fetchall() == [("do-not-touch",)] assert "Automation Prompts" in render_update_plan_markdown(result) diff --git a/tests/control_plane/test_cli_output_budget.py b/tests/control_plane/test_cli_output_budget.py index 2e7504329d..9f65be4bb4 100644 --- a/tests/control_plane/test_cli_output_budget.py +++ b/tests/control_plane/test_cli_output_budget.py @@ -6,6 +6,7 @@ import json import os import shlex +import tempfile from dataclasses import dataclass from pathlib import Path @@ -1429,7 +1430,14 @@ def test_collection_growth_and_bootstrap_duplication_are_explicit(tmp_path: Path def test_explicit_compact_and_detail_modes_are_characterized(tmp_path: Path) -> None: - project, runtime, registry_path, state_file = _write_fixture(tmp_path, SCENARIOS[0]) + # Match the other budget scenarios: runner/xdist path length is not a + # prompt revision. Exercise real long paths separately below. + with _stable_budget_fixture_root(tmp_path / "variants") as root: + _assert_mode_variant_budgets(root) + + +def _assert_mode_variant_budgets(root: Path, *, only: str | None = None) -> None: + project, runtime, registry_path, state_file = _write_fixture(root, SCENARIOS[0]) for output_format in ("json", "markdown"): commands = _mode_variant_commands( project=project, @@ -1439,6 +1447,8 @@ def test_explicit_compact_and_detail_modes_are_characterized(tmp_path: Path) -> output_format=output_format, ) for variant_id, command in commands.items(): + if only is not None and variant_id != only: + continue spec = CLI_OUTPUT_MODE_VARIANT_BY_ID[variant_id] if output_format not in spec.output_formats: continue @@ -1453,6 +1463,20 @@ def test_explicit_compact_and_detail_modes_are_characterized(tmp_path: Path) -> ) +def test_brief_budget_retains_full_commands_on_real_long_paths() -> None: + # A reproducible 128-character absolute root, independent of pytest's + # ever-growing temp/worker prefix. Do not shorten rendered paths or raise + # the absolute output ceiling to make this case pass. + parent = Path(tempfile.gettempdir()).resolve() + # tempfile contributes an eight-character random suffix. Hold input size + # constant across Linux /tmp and macOS's longer temporary-directory root. + prefix = "loopx-brief-".ljust(128 - len(str(parent)) - 1 - 8, "p") + with tempfile.TemporaryDirectory(prefix=prefix, dir=parent) as directory: + root = Path(directory).resolve() + assert len(str(root)) == 128 + _assert_mode_variant_budgets(root, only="heartbeat_prompt_brief") + + def test_todo_list_explicit_limit_stays_bounded_and_default_path_unchanged( tmp_path: Path, ) -> None: diff --git a/tests/test_ark_managed_agent_host.py b/tests/test_ark_managed_agent_host.py index ab9b5f10a5..8699210dec 100644 --- a/tests/test_ark_managed_agent_host.py +++ b/tests/test_ark_managed_agent_host.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +import shlex import shutil from pathlib import Path @@ -146,9 +147,9 @@ def test_host_activation_submits_one_goal_without_turn_or_automation() -> None: "runtime_capability_reentry_v0" in step and "do not rewrite task_body" in step for step in packet["activation_steps"] ) - assert packet["commands"]["heartbeat_prompt"].endswith( - "--runtime-profile ark_managed_agent_goal" - ) + prompt_args = shlex.split(packet["commands"]["heartbeat_prompt"]) + assert prompt_args[prompt_args.index("--runtime-profile") + 1] == "ark_managed_agent_goal" + assert prompt_args.count("--bootstrap") == 1 assert "automation_update" not in str(packet) assert "loopx turn run-once" not in str(packet).lower() From f1d227e623d4f93ce1d80e5ff364ae20e8faff60 Mon Sep 17 00:00:00 2001 From: huangruiteng Date: Fri, 11 Sep 2026 14:41:54 +0800 Subject: [PATCH 25/26] fix(heartbeat): preserve brief authority within the App budget Signed-off-by: huangruiteng --- docs/heartbeat-automation-prompt.md | 13 ++++++++ .../control_plane/heartbeat-prompt-smoke.py | 24 +++++++------- loopx/control_plane/heartbeat/rules.py | 6 ---- loopx/control_plane/heartbeat/task_body.py | 21 +++++-------- .../test_heartbeat_notification_rule.py | 13 ++++---- .../test_host_bootstrap_lifecycle.py | 31 +++++++++++++++++++ 6 files changed, 69 insertions(+), 39 deletions(-) diff --git a/docs/heartbeat-automation-prompt.md b/docs/heartbeat-automation-prompt.md index 81b3366b63..75de259fc9 100644 --- a/docs/heartbeat-automation-prompt.md +++ b/docs/heartbeat-automation-prompt.md @@ -57,6 +57,19 @@ automation contract. ### Native Goal bootstrap and live execution instructions +Brief automation now uses the same fully qualified notification/execution rule +as thin, including `heartbeat_recommendation.agent_must_attempt` and +`execution_obligation.must_attempt_work`. Brief no longer embeds a second +static refresh/spend sequence: after validated work it follows the current +`interaction_contract.cli_channel.settlement_plan.ordered_steps`, or current +`next_cli_actions` when there is no plan. Generator command fields remain for +compatibility, not as a stale fallback. Todo acceptance alone is not Turn +settlement or terminal vision closure. The brief budget remains 3,500 characters. + +Brief 与 thin 共用完整执行义务路径;这次有意移除 brief 固定结算配方,而不是 +删除结算义务。真实 App preflight、registry scope、完整 guard 和静态安全规则 +均保留;结算顺序与身份以本轮动态 contract 为准,vision replan 不由历史成功清账。 + New supported host activations use `heartbeat-prompt --bootstrap`: a saved loader requests the installed rules rather than freezing a long execution body. The loader and automation bootstrap share rendering and successful-response diff --git a/examples/control_plane/heartbeat-prompt-smoke.py b/examples/control_plane/heartbeat-prompt-smoke.py index d9128b3a77..0c93a09ddd 100644 --- a/examples/control_plane/heartbeat-prompt-smoke.py +++ b/examples/control_plane/heartbeat-prompt-smoke.py @@ -329,7 +329,6 @@ def main() -> int: for prompt_label, prompt_payload in ( ("full", payload), ("compact", compact_payload), - ("brief", brief_payload), ): task_body = str(prompt_payload["task_body"]) progress_refresh = str(prompt_payload["progress_refresh_state_command"]) @@ -644,7 +643,7 @@ def main() -> int: 'loopx --format json --registry "$HOME/.codex/loopx/registry.global.json" quota should-run --goal-id public-heartbeat-goal', "`user_channel.notify` controls OUTPUT only: NOTIFY=向用户输出动作; DONT_NOTIFY=安静输出", "Due/peer非用户动作", - "Done->successor first; final->refresh->spend->no-follow-up", + "Todo 验收不等于 Turn 结算或 Goal 完成", "NOTIFY缺动作→", "具体user todo未投影", "按 user channel", @@ -652,24 +651,25 @@ def main() -> int: "已记 receipt/stall", "写失败同 id 重试", "只读一次", - "safe_bypass_kind=outcome_floor_recovery", + "outcome-floor recovery", "恢复 ranker/cross-domain evidence", "status --limit 3", "review-packet --handoff-only", - "heartbeat_recommendation", - "goal_boundary", + "heartbeat_recommendation.agent_must_attempt", + "遵守本轮 quota/contract 的权限、交付规模/结果", "授权/预算内推进可验证结果", - "`must_attempt_work=true` 须推进", - "验证/写回/Todos", - "实际进展(非升级)", - "扣额一次,不管道/重试", - "扣额后刷新", - 'loopx --format json --registry "$HOME/.codex/loopx/registry.global.json" quota spend-slot --goal-id public-heartbeat-goal --slots 1 --source heartbeat --execute', + "execution_obligation.must_attempt_work", + "interaction_contract.cli_channel.settlement_plan.ordered_steps", + "精确 identity/effect 顺序结算", + "不使用旧 refresh/spend 配方", + "仅 terminal no-follow-up 才能收尾,保留 vision replan", "静默跳过、preflight 失败、blocker-push 提问、dry-run、重复记账均不扣额", "No learning queue unless asked.", "No permission asks in a trusted session.", ): assert phrase in brief_task, phrase + for command_key in ("quota_spend_command", "refresh_state_command", "progress_refresh_state_command"): + assert brief_payload[command_key] not in brief_payload["task_body"] assert thin_payload["thin"] is True, thin_payload assert thin_payload["brief"] is False, thin_payload assert thin_payload["compact"] is False, thin_payload @@ -951,7 +951,7 @@ def main() -> int: assert "If false/0, allow quiet/no-user-todo" not in compact_generated, compact_generated assert_ordered( - doc, + doc[doc.index("Before spending delivery compute, first make the LoopX CLI reachable"):], ( "Before spending delivery compute, first make the LoopX CLI reachable", 'export PATH="$HOME/.local/bin:$PATH"', diff --git a/loopx/control_plane/heartbeat/rules.py b/loopx/control_plane/heartbeat/rules.py index f994fc13ab..8a20ee7302 100644 --- a/loopx/control_plane/heartbeat/rules.py +++ b/loopx/control_plane/heartbeat/rules.py @@ -21,12 +21,6 @@ "Due/peer非用户动作;NOTIFY缺动作→" "具体user todo未投影,需修复LoopX状态投影;静默时内部修复。" ) -HEARTBEAT_NOTIFICATION_RULE_THIN = ( - "`user_channel.notify` controls OUTPUT only: NOTIFY=向用户输出动作; " - "DONT_NOTIFY=安静输出。执行义务看 `agent_must_attempt`/`must_attempt_work`。" - "Due/peer非用户动作;NOTIFY缺动作→" - "具体user todo未投影,需修复LoopX状态投影;静默时内部修复。" -) HEARTBEAT_VISION_WRITEBACK_RULE_SHORT = ( "writeback: no-change=`surface_only`/no spend; " "unchanged->`--vision-unchanged-reason`; material->actual outcome." diff --git a/loopx/control_plane/heartbeat/task_body.py b/loopx/control_plane/heartbeat/task_body.py index 184ed33688..9a566d92a8 100644 --- a/loopx/control_plane/heartbeat/task_body.py +++ b/loopx/control_plane/heartbeat/task_body.py @@ -9,7 +9,6 @@ DEFAULT_MATERIAL_QUEUE_RULE, DEFAULT_PERMISSION_RULE, HEARTBEAT_NOTIFICATION_RULE_SHORT, - HEARTBEAT_NOTIFICATION_RULE_THIN, HEARTBEAT_TURN_BOOTSTRAP_RULE, HEARTBEAT_VISION_WRITEBACK_RULE_SHORT, HOST_LOOP_QUOTA_DISPATCH_RULE, @@ -305,7 +304,7 @@ def render_brief_heartbeat_task_body( Fail:quiet. -{HEARTBEAT_NOTIFICATION_RULE_THIN} +{HEARTBEAT_NOTIFICATION_RULE_SHORT} {SCOPE_BOUNDED_WORK_RULE} {HEARTBEAT_VISION_WRITEBACK_RULE_SHORT} @@ -316,18 +315,12 @@ def render_brief_heartbeat_task_body( `agent_read_required`: drain/read/triage before work; settle/ACK. `should_run=true`:读 compact、`status --limit 3`、 -`review-packet --handoff-only`;遵守 -`effective_action`, `recovery_delivery_allowed`, -`heartbeat_recommendation`, `safe_bypass_kind=outcome_floor_recovery`, -`goal_boundary`, `delivery_batch_scale`, `delivery_outcome`, outcome streaks, -`handoff_delivery_contract`、`execution_obligation`(`must_attempt_work=true` 须推进); -recovery:恢复 ranker/cross-domain evidence 或写回 blocker。 -验证/写回/Todos;{HOST_LOOP_TODO_CLOSEOUT_COMPACT_RULE} 实际进展(非升级): -`{progress_refresh_state_command}` -扣额一次,不管道/重试: -`{quota_spend_command}` -扣额后刷新: -`{refresh_state_command}` +`review-packet --handoff-only`;遵守本轮 quota/contract 的权限、交付规模/结果、 +历史约束与 handoff;outcome-floor recovery 须恢复 ranker/cross-domain evidence 或写回 blocker。 +{HOST_LOOP_QUOTA_DISPATCH_RULE} +交付并验证后,按当前 `interaction_contract.cli_channel.settlement_plan.ordered_steps` +的精确 identity/effect 顺序结算;无 plan 时按当前 `next_cli_actions`,不使用旧 refresh/spend 配方。 +Todo 验收不等于 Turn 结算或 Goal 完成;仅 terminal no-follow-up 才能收尾,保留 vision replan。 静默跳过、preflight 失败、blocker-push 提问、dry-run、重复记账均不扣额。 仅 `user_channel.notify=NOTIFY` 时输出,否则静默。 diff --git a/tests/control_plane/test_heartbeat_notification_rule.py b/tests/control_plane/test_heartbeat_notification_rule.py index 348ea90539..63bda2a32b 100644 --- a/tests/control_plane/test_heartbeat_notification_rule.py +++ b/tests/control_plane/test_heartbeat_notification_rule.py @@ -11,7 +11,6 @@ from loopx.control_plane.heartbeat.rules import ( HEARTBEAT_NOTIFICATION_RULE_SHORT, - HEARTBEAT_NOTIFICATION_RULE_THIN, ) from loopx.control_plane.heartbeat.task_body import ( render_brief_heartbeat_task_body, @@ -42,11 +41,11 @@ def test_short_rule_qualifies_dont_notify_as_output_only() -> None: assert "execution_obligation.must_attempt_work" in rule -def test_short_rules_keep_projection_repair_and_quiet_boundary() -> None: - for rule in (HEARTBEAT_NOTIFICATION_RULE_SHORT, HEARTBEAT_NOTIFICATION_RULE_THIN): - assert "NOTIFY缺动作→具体user todo未投影" in rule - assert "需修复LoopX状态投影" in rule - assert "静默时内部修复" in rule +def test_shared_rule_keeps_projection_repair_and_quiet_boundary() -> None: + rule = HEARTBEAT_NOTIFICATION_RULE_SHORT + assert "NOTIFY缺动作→具体user todo未投影" in rule + assert "需修复LoopX状态投影" in rule + assert "静默时内部修复" in rule def test_rendered_task_bodies_keep_execution_obligation_authority() -> None: @@ -70,7 +69,7 @@ def test_rendered_task_bodies_keep_execution_obligation_authority() -> None: ) for renderer in (render_thin_heartbeat_task_body, render_brief_heartbeat_task_body): body = renderer(**kwargs) - assert "agent_must_attempt" in body + assert "heartbeat_recommendation.agent_must_attempt" in body assert "execution_obligation.must_attempt_work" in body assert "OUTPUT only" in body assert "需修复LoopX状态投影" in body diff --git a/tests/control_plane/test_host_bootstrap_lifecycle.py b/tests/control_plane/test_host_bootstrap_lifecycle.py index b599b563c3..2f6d4cfbb2 100644 --- a/tests/control_plane/test_host_bootstrap_lifecycle.py +++ b/tests/control_plane/test_host_bootstrap_lifecycle.py @@ -83,3 +83,34 @@ def load(): def test_bootstrap_rejects_persisted_turn_and_invalid_binding(registry): assert not cli(registry, "--bootstrap", "--codex-app", "--turn-instance-id", "fixed-turn")["ok"] assert not cli(registry, "--bootstrap", "--codex-app", "--runtime-profile", "codex_cli")["ok"] + + +def test_app_brief_with_registry_profile_keeps_budget_and_current_settlement(registry): + scopes = [ + "Maintain shared runtime contracts and validate compatibility across hosts. " + "Use isolated worktrees and exercise public entrypoints.", + "Record evidence, leave unrelated work untouched and coordinate peer-owned " + "tasks through their owners.", + ] + saved = json.loads(registry.read_text()) + saved["goals"][0]["coordination"] = { + "registered_agents": ["worker-a"], "agent_model": "peer_v1", + "agent_profiles": {"worker-a": {"schema_version": "agent_profile_v1", "scopes": scopes}}, + } + registry.write_text(json.dumps(saved)) + packet = cli(registry, "--brief", "--codex-app") + assert packet["ok"], packet.get("error") + body = packet["task_body"] + assert all(scope.rstrip(".!?") in body for scope in scopes) + assert packet["agent_scope_source"] == "agent_profile_v1" + assert packet["interface_budget"]["max_chars"] == 3500 + assert packet["interface_budget"]["within_budget"], packet["interface_budget"] + assert packet["cli_preflight"] in body + assert "--codex-app" in body + assert "execution_obligation.must_attempt_work" in body + assert "heartbeat_recommendation.agent_must_attempt" in body + assert "interaction_contract.cli_channel.settlement_plan.ordered_steps" in body + assert "terminal no-follow-up" in body + assert packet["quota_spend_command"] not in body + assert packet["progress_refresh_state_command"] not in body + assert packet["refresh_state_command"] not in body From 1beb54ef04fa0a8dbb3168901312d2e8b3281eaf Mon Sep 17 00:00:00 2001 From: huangruiteng Date: Fri, 11 Sep 2026 14:41:54 +0800 Subject: [PATCH 26/26] test(hosts): qualify prompt decisions with release-only model probes Signed-off-by: huangruiteng --- docs/development/testing-and-quality.md | 20 ++++ .../testing/host_prompt_behavior.py | 94 ++++++++++++++++++ scripts/qualify-host-prompt-release.py | 45 +++++++++ .../test_host_prompt_behavior.py | 95 +++++++++++++++++++ 4 files changed, 254 insertions(+) create mode 100644 loopx/control_plane/testing/host_prompt_behavior.py create mode 100644 scripts/qualify-host-prompt-release.py create mode 100644 tests/control_plane/test_host_prompt_behavior.py diff --git a/docs/development/testing-and-quality.md b/docs/development/testing-and-quality.md index 9af73f5351..ffb7a6d524 100644 --- a/docs/development/testing-and-quality.md +++ b/docs/development/testing-and-quality.md @@ -794,6 +794,26 @@ deadline is 1,200 seconds; this is a wall-clock ceiling, not a token budget. ### Claude Code and release coverage / Claude Code 与发布覆盖 +For focused thin/brief prompt-decision regression, use +`python3 scripts/qualify-host-prompt-release.py --release-live` only during +explicit release qualification. It defaults to no calls; missing credentials +report `skipped`, not a live pass. With securely injected `ARK_API_KEY`, it uses +Doubao evolving for two independent repetitions of quiet-work, notifying-wait, +quiet-wait and required-vision-replan cases in each mode. Expected decisions +remain outside model input. All attempts must pass; no answer correction or +retry-until-pass is used. Ordinary pytest only checks the probe and negative +oracles with scripted responses, without provider calls. + +This is a synthetic decision-level probe using current generated prompts, +not proof of tool execution, host scheduling, upgrade delivery or full-Goal +completion. Keep the Codex/Claude live Goal arms and real CLI/MCP/SQLite tests +as separate evidence. Only hashes and pass/fail receipts are emitted, not raw +prompts/responses. Model transport failures fail qualification rather than +becoming environment skips. + +仅发布前显式执行,普通 CI 不调用模型。检查静默不等于空转、等待不能擅自执行、 +vision replan 未关闭时不能提前结束 Goal;这不是完整 Claude/Codex 行为验收的替代。 + ```bash # No provider call by default. Explicit release opt-in uses ARK_API_KEY from the environment. python3 scripts/qualify-claude-goal-release.py --release-live diff --git a/loopx/control_plane/testing/host_prompt_behavior.py b/loopx/control_plane/testing/host_prompt_behavior.py new file mode 100644 index 0000000000..5c75f38cd8 --- /dev/null +++ b/loopx/control_plane/testing/host_prompt_behavior.py @@ -0,0 +1,94 @@ +"""Release-only prompt decision probes, not host execution/settlement proof.""" +from __future__ import annotations + +import json +from hashlib import sha256 +from pathlib import Path + +from ...heartbeat_prompt import build_heartbeat_prompt +from .model_tool_behavior import DoubaoExecToolClient + + +def cases() -> list[dict]: + # Independent semantic oracle: silence does not cancel work; a gate does; + # required vision replan is not terminal closure. Never send expected to + # the model, or derive it from the renderer being qualified. + rows = ( + ("quiet_work", True, False, False, "work"), + ("notifying_wait", False, True, False, "wait"), + ("quiet_wait", False, False, False, "wait"), + ("vision_replan", True, False, True, "replan"), + ) + return [{ + "id": name, + "packet": { + "ok": True, + "should_run": work, + "effective_action": "autonomous_replan_required" if replan else "run" if work else "wait", + "execution_obligation": {"must_attempt_work": work}, + "heartbeat_recommendation": {"agent_must_attempt": work}, + "autonomous_replan_obligation": {"required": replan}, + "interaction_contract": { + "user_channel": {"notify": "NOTIFY" if notify else "DONT_NOTIFY"}, + "agent_channel": {"delivery_allowed": work and not replan}, + }, + "operator_question": "Approve the pending change?" if notify else None, + # Historical success cannot override the present work/gate/replan. + "run_history": {"latest_runs": [{"delivery_outcome": "outcome_progress"}]}, + }, + "expected": {"action": action, "notify": notify, "finish_goal": False}, + } for name, work, notify, replan, action in rows] + + +def probe_messages(mode: str, packet: dict) -> list[dict]: + if mode not in {"thin", "brief"}: + raise ValueError("unsupported host prompt mode") + prompt = build_heartbeat_prompt( + goal_id="host-prompt-fixture", active_state=Path("ACTIVE_GOAL_STATE.md"), + agent_id="worker-a", registered_agents=["worker-a"], + runtime_profile="codex_app_heartbeat", **{mode: True}, + ) + if not prompt["ok"] or not prompt["interface_budget"]["within_budget"]: + raise ValueError("production prompt exceeds its declared budget") + return [ + {"role": "system", "content": ( + "This is a decision-only host simulation; no tools or side effects are available. " + "The current quota result has already been read. Decide the next action using " + "the supplied host instructions and result. Return only JSON with exactly " + "action (work, wait, replan or stop), notify (boolean), finish_goal (boolean). " + "work means advancing ordinary delivery; replan means revising the frontier; " + "wait means no delivery now; stop means terminate the Goal." + )}, + {"role": "user", "content": prompt["task_body"]}, + {"role": "user", "content": "Current quota result:\n" + json.dumps(packet)}, + ] + + +def run_probe(client: DoubaoExecToolClient, *, repeats: int = 2) -> dict: + if not 1 <= repeats <= 5: + raise ValueError("repeats must be between 1 and 5") + results = [] + for mode in ("thin", "brief"): + for case in cases(): + messages = probe_messages(mode, case["packet"]) + for attempt in range(repeats): + # Each repetition is independent; failed attempts are not + # repaired by feeding an answer or retried until one passes. + response = client.next_final_content(messages) + try: + decision = json.loads(response or "") + except (ValueError, TypeError): + decision = None + valid = (isinstance(decision, dict) + and set(decision) == {"action", "notify", "finish_goal"} + and type(decision.get("notify")) is bool + and type(decision.get("finish_goal")) is bool) + results.append({"mode": mode, "case": case["id"], "attempt": attempt + 1, + "passed": bool(valid and decision == case["expected"]), + "input_sha256": sha256(json.dumps(messages, sort_keys=True).encode()).hexdigest()}) + return {"schema_version": "host_prompt_decision_probe_v0", + "qualification_passed": all(row["passed"] for row in results), + "actor_ref": client.actor_ref, "provider_call_count": len(results), + "scope": "synthetic_prompt_decisions_only", + "host_execution_qualified": False, "raw_responses_retained": False, + "results": results} diff --git a/scripts/qualify-host-prompt-release.py b/scripts/qualify-host-prompt-release.py new file mode 100644 index 0000000000..de60b12d2a --- /dev/null +++ b/scripts/qualify-host-prompt-release.py @@ -0,0 +1,45 @@ +#!/usr/bin/env python3 +"""Explicit release opt-in; never called by default PR CI or canaries.""" +from __future__ import annotations + +import argparse +import json +import os +from pathlib import Path +import sys + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from loopx.control_plane.testing.doubao_model_behavior_actor import ( # noqa: E402 + DOUBAO_SEED_EVOLVING_MODEL, _direct_ark_transport, +) +from loopx.control_plane.testing.host_prompt_behavior import run_probe # noqa: E402 +from loopx.control_plane.testing.model_tool_behavior import DoubaoExecToolClient # noqa: E402 + + +def main(argv=None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--release-live", action="store_true") + parser.add_argument("--repeats", type=int, choices=range(1, 6), default=2) + args = parser.parse_args(argv) + if not args.release_live: + print(json.dumps({"status": "skipped", "reason": "release_opt_in_required", "provider_call_count": 0})) + return 0 + key = os.environ.get("ARK_API_KEY", "") + if not key: + print(json.dumps({"status": "skipped", "reason": "provider_credential_unavailable", "provider_call_count": 0})) + return 0 + client = DoubaoExecToolClient(api_key=key, model=DOUBAO_SEED_EVOLVING_MODEL, + timeout_seconds=90, transport=_direct_ark_transport) + try: + report = run_probe(client, repeats=args.repeats) + except Exception: + # Never publish a transport exception, raw response or credential. + print(json.dumps({"status": "failed", "reason": "probe_execution_failed"})) + return 1 + print(json.dumps(report, indent=2)) + return 0 if report["qualification_passed"] else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/control_plane/test_host_prompt_behavior.py b/tests/control_plane/test_host_prompt_behavior.py new file mode 100644 index 0000000000..1828d910c1 --- /dev/null +++ b/tests/control_plane/test_host_prompt_behavior.py @@ -0,0 +1,95 @@ +"""Deterministic probe/oracle checks; no provider calls in pytest.""" +import json +import runpy +from pathlib import Path + +import pytest + +from loopx.control_plane.testing.host_prompt_behavior import cases, probe_messages, run_probe + + +ANSWERS = [ + {"action": "work", "notify": False, "finish_goal": False}, + {"action": "wait", "notify": True, "finish_goal": False}, + {"action": "wait", "notify": False, "finish_goal": False}, + {"action": "replan", "notify": False, "finish_goal": False}, +] + + +class ScriptedClient: + actor_ref = "scripted-not-model-evidence" + + def __init__(self, mutation=None, repeats=1): + self.count = 0 + self.mutation = mutation + self.repeats = repeats + + def next_final_content(self, messages): + assert "expected" not in json.dumps(messages) + answer = dict(ANSWERS[(self.count // self.repeats) % 4]) + self.count += 1 + if self.mutation: + answer.update(self.mutation) + return json.dumps(answer) + + +def test_probe_uses_current_production_prompts_and_hidden_independent_oracle(): + for mode in ("thin", "brief"): + for case in cases(): + messages = probe_messages(mode, case["packet"]) + body = messages[1]["content"] + assert "execution_obligation.must_attempt_work" in body + assert "heartbeat_recommendation.agent_must_attempt" in body + assert "--codex-app" in body + assert "LOOPX_TURN=" in body + assert case["id"] not in json.dumps(messages) + assert "expected" not in json.dumps(messages) + client = ScriptedClient() + report = run_probe(client, repeats=1) + assert report["qualification_passed"] + assert client.count == report["provider_call_count"] == 8 + assert not report["host_execution_qualified"] + + +@pytest.mark.parametrize("mutation", [ + {"action": "wait"}, {"notify": True}, {"finish_goal": True}, {"notify": 0}, +]) +def test_oracle_rejects_quiet_noop_spurious_notification_early_finish_and_wrong_types(mutation): + report = run_probe(ScriptedClient(mutation), repeats=1) + assert not report["qualification_passed"] + + +def test_later_success_does_not_erase_a_failed_independent_attempt(): + class FailOnce(ScriptedClient): + def next_final_content(self, messages): + self.mutation = {"action": "wait"} if self.count == 0 else None + return super().next_final_content(messages) + + report = run_probe(FailOnce(repeats=2), repeats=2) + assert report["provider_call_count"] == 16 + assert not report["qualification_passed"] + assert sum(not row["passed"] for row in report["results"]) == 1 + + +def test_release_probe_is_default_off_and_missing_environment_is_not_a_live_pass(monkeypatch, capsys): + script = Path(__file__).resolve().parents[2] / "scripts/qualify-host-prompt-release.py" + main = runpy.run_path(str(script))["main"] + monkeypatch.setenv("ARK_API_KEY", "test-only-unused") + assert main([]) == 0 + assert json.loads(capsys.readouterr().out)["provider_call_count"] == 0 + monkeypatch.delenv("ARK_API_KEY") + assert main(["--release-live"]) == 0 + assert json.loads(capsys.readouterr().out)["status"] == "skipped" + + +def test_attempted_failure_is_not_reported_as_environment_skip(monkeypatch, capsys): + script = Path(__file__).resolve().parents[2] / "scripts/qualify-host-prompt-release.py" + main = runpy.run_path(str(script))["main"] + def fail(*args, **kwargs): + raise RuntimeError("private provider failure must not escape") + monkeypatch.setitem(main.__globals__, "run_probe", fail) + monkeypatch.setenv("ARK_API_KEY", "test-only-unused") + assert main(["--release-live"]) == 1 + result = capsys.readouterr().out + assert "private provider" not in result + assert json.loads(result)["status"] == "failed"