From 9197abd8da943e0c09c200bb9ac40017cfab8734 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Tue, 20 Oct 2026 16:11:26 +0800 Subject: [PATCH] fix(heartbeat): use inline scheduler ACK commands Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- loopx/control_plane/heartbeat/task_body.py | 3 +- .../test_heartbeat_envelope_transport.py | 38 ++++++++++++++++++- 2 files changed, 38 insertions(+), 3 deletions(-) diff --git a/loopx/control_plane/heartbeat/task_body.py b/loopx/control_plane/heartbeat/task_body.py index 3f25b6c89e..6f6d21dc09 100644 --- a/loopx/control_plane/heartbeat/task_body.py +++ b/loopx/control_plane/heartbeat/task_body.py @@ -643,7 +643,8 @@ def render_thin_heartbeat_task_body( ``` {SCOPE_BOUNDED_WORK_RULE} Observed capabilities -> --available-capability, never a user gate. -Apply `scheduler` as projected; read the saved full decision for exact ACK args. +Apply `scheduler` as projected using inline ACK argv. Read saved scheduler detail +only for a needed missing/truncated/referenced command; failure argv only after host update failure. Exact monitor settlement=no refresh/spend; auxiliary poll continues work. Validate work; execute `writeback.settlement_plan` and `writeback.next_cli_actions` with their exact identity. Do not guess commands from abbreviated previews. diff --git a/tests/control_plane/test_heartbeat_envelope_transport.py b/tests/control_plane/test_heartbeat_envelope_transport.py index 9e77293dcf..fb2fc52f81 100644 --- a/tests/control_plane/test_heartbeat_envelope_transport.py +++ b/tests/control_plane/test_heartbeat_envelope_transport.py @@ -3,6 +3,7 @@ import os import shlex import socket +import subprocess import sys import tempfile import threading @@ -14,8 +15,9 @@ from benchmark.runtime.worker import heartbeat_body from loopx.heartbeat_prompt import build_heartbeat_prompt from tests.control_plane.test_quota_settlement_cli import ( - AGENT_ID, GOAL_ID, TURN_ID, ALTERNATIVE_TODO_ID, REPO_ROOT, + AGENT_ID, GOAL_ID, TODO_ID, TURN_ID, ALTERNATIVE_TODO_ID, REPO_ROOT, _write_fixture, _configure_selectable_alternative, _run_cli, + _initialize_fixture_authority, _run_generated_cli, _heartbeat_receipt_count, _spend_run_count, ) @@ -38,6 +40,9 @@ def test_capture_preserves_full_dispatcher_and_envelope_needs_explicit_opt_in(tm assert 'writeback.settlement_plan.ordered_steps' in payload['task_body'] assert 'When present, read inline `agent_context` before planning.' in payload['task_body'] assert 'an `agent_context.detail_ref`' in payload['task_body'] + assert 'using inline ACK argv' in payload['task_body'] + assert 'needed missing/truncated/referenced command' in payload['task_body'] + assert 'failure argv only after host update failure' in payload['task_body'] assert '--decision-output-root' in payload['thin_prompt_command'] assert '--turn-envelope' in payload['thin_prompt_command'] for overrides in (dict(turn_instance_id=None), dict(thin=False, full=True), @@ -48,6 +53,36 @@ def test_capture_preserves_full_dispatcher_and_envelope_needs_explicit_opt_in(tm build_heartbeat_prompt(**args, decision_output_root=tmp_path / 'missing') +@pytest.mark.parametrize('provider', ['file', 'sqlite']) +def test_captured_scheduler_ack_is_inline_and_failure_detail_stays_on_original_source( + tmp_path, monkeypatch, provider, +): + from tests.control_plane.canonical_authority_fixture import isolate_sqlite_runtime + + isolate_sqlite_runtime(tmp_path, monkeypatch) + project, runtime, registry = _write_fixture(tmp_path) + _initialize_fixture_authority(project, runtime, provider=provider) + capture = tmp_path / 'capture' + code, envelope = _run_cli(registry, runtime, 'quota', 'should-run', + '--goal-id', GOAL_ID, '--agent-id', AGENT_ID, '--todo-id', TODO_ID, + '--turn-instance-id', TURN_ID, '--codex-app', '--turn-envelope', + '--decision-output-dir', str(capture), cwd=project) + assert code == 0, envelope + original = (capture / 'decision.json').read_bytes() + source = json.loads(original)['scheduler_hint']['app_automation'] + projected = envelope['scheduler']['app_automation'] + assert projected['ack_cli_args'] == source['ack_hint']['cli_args'] + assert 'ack_cli_args_detail_ref' not in projected + assert projected['failure_cli_args_detail_ref']['detail_ref'] == ( + 'full_decision.scheduler_hint.app_automation.failure_hint.cli_args' + ) + before = _heartbeat_receipt_count(runtime, TURN_ID) + read = subprocess.run(shlex.split(envelope['detail_ref']['full_decision']), + capture_output=True, check=True).stdout + assert read == original + assert _heartbeat_receipt_count(runtime, TURN_ID) == before + + @pytest.mark.parametrize('mode', ['plain', 'native-goal', 'loopx-goal', 'turn']) def test_incompatible_worker_rejected(mode): with pytest.raises(ValueError, match='turn_envelope'): @@ -107,7 +142,6 @@ def test_real_worker_generated_guard_selection_reentry_and_settle_once(tmp_path, guard = next(line for line in block.splitlines() if 'quota should-run' in line) guard = guard.replace('${LOOPX_TURN:?}', TURN_ID) argv = shlex.split(guard) - import subprocess result = subprocess.run(argv, env=env, cwd=project, capture_output=True, text=True) first = json.loads(result.stdout) assert result.returncode == 0, first