diff --git a/docs/architecture/rfcs/automatic-execution-admission-v0.md b/docs/architecture/rfcs/automatic-execution-admission-v0.md index 852bf39b9d..fb46f942c6 100644 --- a/docs/architecture/rfcs/automatic-execution-admission-v0.md +++ b/docs/architecture/rfcs/automatic-execution-admission-v0.md @@ -129,8 +129,16 @@ Codex App ACK commands must re-read the matched heartbeat after the host update. The requested RRULE and the hint's earlier observation cannot certify that update. The typed scheduler commit rejects missing or mismatched current observations before migration, identity reset or persistence. This applies to the console's -native follow-up and the Python compatibility adapter; failure commands retain -their original target/observed pair. Real App API updates and readback on +native follow-up and the Python compatibility adapter. Unix and PowerShell +launchers route Codex host-match ACKs through that same entrypoint's fresh read; +other generated follow-ups keep their Node fast path. Outer-controlled writes +retain the entrypoint's existing permission guard before native dispatch. +The native decoder binds the host-match flag to the captured ACK fact, so +dropping that flag cannot bypass the launcher's current-readback route. +Other App adapters supply their own neutral current-RRULE argument; an explicit +unavailable observation clears earlier facts, while absence preserves their +bound observation. They never read Codex metadata to fill a missing observation. +Failure commands retain their original target/observed pair. Real App API updates and readback on disposable File/SQLite Goals qualify this schedule boundary separately from elapsed timer wakeups, installed-runtime adoption and pre-model admission. The activation path must be qualified separately before advertising one-click diff --git a/loopx/control_plane/scheduler/heartbeat_followup_cli.ts b/loopx/control_plane/scheduler/heartbeat_followup_cli.ts index 194f949d51..3c2ea4c7c7 100644 --- a/loopx/control_plane/scheduler/heartbeat_followup_cli.ts +++ b/loopx/control_plane/scheduler/heartbeat_followup_cli.ts @@ -218,6 +218,13 @@ function requestFromArgs(argv: string[]): { request: Record; fo ); } const values = parsed.values; + if (expectedOperation === "ack" && + parsed.booleans.has("--host-match-observed") !== (facts.host_match_observed === true)) { + throw new EffectRuntimeRequestError( + "Host-match flag does not match its bound ACK facts", + "scheduler_host_facts_identity_mismatch", + ); + } const neutralObservedRrule = values["--app-automation-current-rrule"] ?? ""; const legacyCodexObservedRrule = values["--codex-app-current-rrule"] ?? ""; if (neutralObservedRrule && legacyCodexObservedRrule && neutralObservedRrule !== legacyCodexObservedRrule) { @@ -273,7 +280,11 @@ function requestFromArgs(argv: string[]): { request: Record; fo reason_summary: values["--reason-summary"] ?? null, host_facts: { ...facts, - ...(expectedOperation === "ack" && facts.surface === "codex_app" + ...(expectedOperation === "ack" && ( + facts.surface === "codex_app" || + Object.hasOwn(values, "--app-automation-current-rrule") || + Object.hasOwn(values, "--codex-app-current-rrule") + ) ? { observed_host_rrule: neutralObservedRrule || legacyCodexObservedRrule } : {}), operation: expectedOperation, diff --git a/scripts/loopx b/scripts/loopx index 90fb1682d5..7e37b2a0e4 100755 --- a/scripts/loopx +++ b/scripts/loopx @@ -39,6 +39,8 @@ done native_scheduler_facts=0 native_scheduler_turn=0 +native_scheduler_host_match=0 +native_scheduler_trae=0 for arg in "$@"; do case "$arg" in --scheduler-host-facts-chunk|--scheduler-host-facts-chunk=*) @@ -47,13 +49,22 @@ for arg in "$@"; do --turn-instance-id|--turn-instance-id=*) native_scheduler_turn=1 ;; + --host-match-observed) + native_scheduler_host_match=1 + ;; + --trae_app) + native_scheduler_trae=1 + ;; esac done if [[ "$loopx_command" == "quota" \ && ( "$loopx_subcommand" == "scheduler-ack-current" || "$loopx_subcommand" == "scheduler-fail-current" ) \ && "$native_scheduler_facts" == "1" \ - && "$native_scheduler_turn" == "1" ]]; then + && "$native_scheduler_turn" == "1" \ + && "${LOOPX_KUNLUNCODE_OUTER_CONTROLLER:-}" != "1" \ + && ! ( "$loopx_subcommand" == "scheduler-ack-current" \ + && "$native_scheduler_host_match" == "1" && "$native_scheduler_trae" == "0" ) ]]; then native_scheduler_entry="$repo_root/loopx/control_plane/scheduler/heartbeat_followup_cli.ts" if ! command -v node >/dev/null 2>&1; then echo "loopx runtime error: native scheduler follow-up requires Node.js 22.22.3 or newer" >&2 @@ -63,6 +74,9 @@ if [[ "$loopx_command" == "quota" \ exec node --no-warnings --experimental-strip-types "$native_scheduler_entry" "$@" fi +# Codex host-match ACKs need the entrypoint's fresh App read before TS commit. +# Outer-controlled writes also retain that entrypoint's owning permission guard. + python_selector="$repo_root/scripts/loopx-python.sh" if [[ -n "${LOOPX_PYTHON:-}" ]]; then python_bin="$LOOPX_PYTHON" diff --git a/scripts/loopx.ps1 b/scripts/loopx.ps1 index 9dd30f267f..e6859bf057 100644 --- a/scripts/loopx.ps1 +++ b/scripts/loopx.ps1 @@ -54,11 +54,18 @@ $hasNativeSchedulerTurn = @( $_ -eq "--turn-instance-id" -or $_.StartsWith("--turn-instance-id=") } ).Count -gt 0 +$needsCodexHostRead = ( + $loopxSubcommand -eq "scheduler-ack-current" -and + $args -contains "--host-match-observed" -and + $args -notcontains "--trae_app" +) $isNativeSchedulerFollowup = ( $loopxCommand -eq "quota" -and $loopxSubcommand -in @("scheduler-ack-current", "scheduler-fail-current") -and $hasNativeSchedulerFacts -and - $hasNativeSchedulerTurn + $hasNativeSchedulerTurn -and + -not $needsCodexHostRead -and + $env:LOOPX_KUNLUNCODE_OUTER_CONTROLLER -ne "1" ) if ($isNativeSchedulerFollowup) { $node = Get-Command node -CommandType Application -ErrorAction SilentlyContinue @@ -75,6 +82,8 @@ if ($isNativeSchedulerFollowup) { exit $LASTEXITCODE } +# Fresh Codex App readback and the outer-controller guard belong to entrypoint. + if ([string]::IsNullOrWhiteSpace($python) -or -not (Test-Path -LiteralPath $python -PathType Leaf)) { throw "LoopX configured Python executable is invalid: $python" } diff --git a/tests/control_plane/test_source_cli_entrypoint.py b/tests/control_plane/test_source_cli_entrypoint.py index 37b091c7d6..059c096ffd 100644 --- a/tests/control_plane/test_source_cli_entrypoint.py +++ b/tests/control_plane/test_source_cli_entrypoint.py @@ -193,7 +193,9 @@ def replace_process(*args): def test_outer_controller_scope_preserves_reads_other_goals_and_recovery( tmp_path: Path, entry: str, ) -> None: - from examples.control_plane.quota_plan_fixtures import SCOPED_AGENT_ID, write_cli_fixture + from examples.control_plane.quota_plan_fixtures import ( + SCOPED_AGENT_ID, write_cli_fixture, write_codex_heartbeat_fixture, + ) from loopx.control_plane.scheduler.state import ( APP_AUTOMATION_STATEFUL_BACKOFF_STATE_KEY as state_key, load_scheduler_state, @@ -207,6 +209,10 @@ def test_outer_controller_scope_preserves_reads_other_goals_and_recovery( "LOOPX_KUNLUNCODE_OUTER_AGENT_ID": SCOPED_AGENT_ID} def selected_ack(goal: str) -> list[str]: + write_codex_heartbeat_fixture( + Path(env["CODEX_HOME"]), goal_id=goal, agent_id=SCOPED_AGENT_ID, + rrule="FREQ=MINUTELY;INTERVAL=60", + ) decision = run_entry(entry, [*prefix, "quota", "should-run", "--goal-id", goal, "--agent-id", SCOPED_AGENT_ID, "--codex-app", "--turn-instance-id", "scope-turn"], env=env) @@ -217,6 +223,12 @@ def selected_ack(goal: str) -> list[str]: payload = json.loads(decision.stdout) assert payload["mode"] == "should-run" assert payload["heartbeat_receipt"]["status"] == "committed" + expected_rrule = "FREQ=MINUTELY;INTERVAL=30" if goal == "needs-operator" else "FREQ=MINUTELY;INTERVAL=3" + assert payload["scheduler_hint"]["app_automation"]["recommended_rrule"] == expected_rrule + write_codex_heartbeat_fixture( + Path(env["CODEX_HOME"]), goal_id=goal, agent_id=SCOPED_AGENT_ID, + rrule=expected_rrule, + ) return [*prefix, *payload["scheduler_hint"]["app_automation"] ["ack_hint"]["cli_args"]] @@ -253,6 +265,10 @@ def selected_ack(goal: str) -> list[str]: # Release by the existing controller owner restores real receipt-bound # progress, not merely a different blocker or a mocked success payload. + write_codex_heartbeat_fixture( + Path(env["CODEX_HOME"]), goal_id="needs-operator", agent_id=SCOPED_AGENT_ID, + rrule="FREQ=MINUTELY;INTERVAL=30", + ) recovered = run_entry(entry, protected_ack, env={**env, "LOOPX_KUNLUNCODE_OUTER_CONTROLLER": "0"}) assert recovered.returncode == 0, recovered.stdout + recovered.stderr diff --git a/tests/control_plane_ts/scheduler_heartbeat_followup_cli.test.ts b/tests/control_plane_ts/scheduler_heartbeat_followup_cli.test.ts index 3ba548d8d9..07b0986fcb 100644 --- a/tests/control_plane_ts/scheduler_heartbeat_followup_cli.test.ts +++ b/tests/control_plane_ts/scheduler_heartbeat_followup_cli.test.ts @@ -89,7 +89,7 @@ function chunks(value: unknown): string[] { return encoded.match(/.{1,384}/g) ?? []; } -function argv(runtimeRoot: string, format = "json"): string[] { +function argv(runtimeRoot: string, format = "json", hint = hintPayload()): string[] { const result = [ "--format", format, @@ -108,7 +108,7 @@ function argv(runtimeRoot: string, format = "json"): string[] { "FREQ=MINUTELY;INTERVAL=15", "--host-match-observed", ]; - for (const chunk of chunks(hintPayload())) { + for (const chunk of chunks(hint)) { result.push("--scheduler-host-facts-chunk", chunk); } result.push("--turn-instance-id", turnId, "--execute"); @@ -215,6 +215,58 @@ test("native ACK cannot use its pre-update hint as fresh host readback", async ( } }); +test("dropping the bound host-match flag cannot bypass the launcher readback route", async (t) => { + const runtimeRoot = await tempRuntime(t); + await writeReceipt(runtimeRoot); + const args = argv(runtimeRoot); + args.splice(args.indexOf("--host-match-observed"), 1); + const child = runCli(args); + assert.equal(child.status, 1); + assert.equal(JSON.parse(child.stdout).error.code, "scheduler_host_facts_identity_mismatch"); +}); + +test("neutral current readback updates a Trae ACK without reading Codex metadata", async (t) => { + for (const [before, observed] of [ + ["FREQ=MINUTELY;INTERVAL=3", "FREQ=MINUTELY;INTERVAL=15"], + ["FREQ=MINUTELY;INTERVAL=15", "FREQ=MINUTELY;INTERVAL=3"], + ["FREQ=MINUTELY;INTERVAL=15", ""], + ]) { + await t.test(`${before} -> ${observed || "unavailable"}`, async (t) => { + const runtimeRoot = await tempRuntime(t); + await writeReceipt(runtimeRoot); + const hint = hintPayload(); + const facts = hint.host_facts as Record; + facts.surface = "trae_app"; + facts.observed_host_rrule = before; + const args = argv(runtimeRoot, "json", hint); + args[args.indexOf("-A")] = "--trae_app"; + args[args.indexOf("--app-automation-current-rrule") + 1] = observed; + const child = runCli(args); + const payload = JSON.parse(child.stdout) as Record; + const matches = observed === "FREQ=MINUTELY;INTERVAL=15"; + assert.equal(child.status, matches ? 0 : 1, child.stderr); + assert.equal(payload.ok, matches); + assert.equal((payload.scheduler_commit as Record).written, matches); + if (!matches) assert.equal(payload.reason_code, "rrule_mismatch"); + }); + } +}); + +test("non-Codex ACK without a new observation preserves its bound facts", async (t) => { + const runtimeRoot = await tempRuntime(t); + await writeReceipt(runtimeRoot); + const hint = hintPayload(); + const facts = hint.host_facts as Record; + facts.surface = "trae_app"; + facts.observed_host_rrule = "FREQ=MINUTELY;INTERVAL=15"; + const args = argv(runtimeRoot, "json", hint); + args[args.indexOf("-A")] = "--trae_app"; + args.splice(args.indexOf("--app-automation-current-rrule"), 2); + const child = runCli(args); + assert.equal(child.status, 0, child.stderr); + assert.equal(JSON.parse(child.stdout).scheduler_commit.status, "written"); +}); + test("native host-followup CLI preserves the scheduler Markdown surface", async (t) => { const runtimeRoot = await tempRuntime(t); await writeReceipt(runtimeRoot); diff --git a/tests/test_scheduler_native_launcher.py b/tests/test_scheduler_native_launcher.py index 5961a63896..b8f2d58664 100644 --- a/tests/test_scheduler_native_launcher.py +++ b/tests/test_scheduler_native_launcher.py @@ -4,6 +4,7 @@ import os import shutil import subprocess +import sys from pathlib import Path import pytest @@ -11,8 +12,18 @@ from examples.control_plane.quota_plan_fixtures import ( SCOPED_AGENT_ID, write_cli_fixture, + write_codex_heartbeat_fixture, ) from loopx.control_plane.testing.canary_harness import run_json_cli_result +from loopx.control_plane.coordination.runtime_shadow import build_todo_runtime_shadow_projection +from loopx.control_plane.coordination.coordination_state_contract import ( + TODO_DOMAIN_READ_RECORD_SCHEMA_VERSION, + TODO_DOMAIN_RECORD_FIELDS, +) +from tests.control_plane.canonical_authority_fixture import ( + initialize_canonical_authority, + isolate_sqlite_runtime, +) REPO_ROOT = Path(__file__).resolve().parents[1] @@ -98,8 +109,9 @@ def test_unix_launcher_keeps_unbound_calls_on_the_python_compatibility_route( @pytest.mark.skipif(shutil.which("node") is None, reason="Node.js is required") -def test_receipt_bound_should_run_hint_executes_native_without_python( - tmp_path: Path, +@pytest.mark.parametrize("launcher", ["unix", "powershell"]) +def test_receipt_bound_trae_hint_executes_native_without_python( + tmp_path: Path, launcher: str, ) -> None: registry_path, runtime_root, project = write_cli_fixture( tmp_path / "fixture", @@ -114,7 +126,9 @@ def test_receipt_bound_should_run_hint_executes_native_without_python( goal_id, "--agent-id", SCOPED_AGENT_ID, - "--codex-app", + "--trae_app", + "--app-automation-current-rrule", + "FREQ=MINUTELY;INTERVAL=3", "--turn-instance-id", turn_id, registry_path=registry_path, @@ -122,25 +136,24 @@ def test_receipt_bound_should_run_hint_executes_native_without_python( cwd=project, ) assert returncode == 0, guard - hint = guard["scheduler_hint"]["codex_app"]["ack_hint"] + hint = guard["scheduler_hint"]["app_automation"]["ack_hint"] assert "--scheduler-host-facts-chunk" in hint["cli_args"] assert hint["args"]["turn_instance_id"] == turn_id + entry, env = _launcher(tmp_path, launcher, python=str(tmp_path / "python-must-not-run")) completed = subprocess.run( [ - str(REPO_ROOT / "scripts" / "loopx"), + *entry, "--format", "json", *hint["cli_args"], + "--app-automation-current-rrule", "FREQ=MINUTELY;INTERVAL=30", ], cwd=project, check=False, capture_output=True, text=True, - env={ - **os.environ, - "LOOPX_PYTHON": str(tmp_path / "python-must-not-run"), - }, + env=env, ) assert completed.returncode == 0, completed.stderr @@ -149,13 +162,14 @@ def test_receipt_bound_should_run_hint_executes_native_without_python( assert result["scheduler_commit"]["status"] == "written" assert ( result["scheduler_ack_event"]["scheduler_state"]["last_applied_rrule"] - == guard["scheduler_hint"]["codex_app"]["recommended_rrule"] + == guard["scheduler_hint"]["app_automation"]["recommended_rrule"] ) @pytest.mark.skipif(shutil.which("node") is None, reason="Node.js is required") +@pytest.mark.parametrize("launcher", ["unix", "powershell"]) def test_receipt_bound_failure_hint_executes_native_without_python( - tmp_path: Path, + tmp_path: Path, launcher: str, ) -> None: registry_path, runtime_root, project = write_cli_fixture( tmp_path / "fixture-failure", @@ -180,9 +194,10 @@ def test_receipt_bound_failure_hint_executes_native_without_python( hint = guard["scheduler_hint"]["codex_app"]["failure_hint"] assert "--scheduler-host-facts-chunk" in hint["cli_args"] + entry, env = _launcher(tmp_path, launcher, python=str(tmp_path / "python-must-not-run")) completed = subprocess.run( [ - str(REPO_ROOT / "scripts" / "loopx"), + *entry, "--format", "json", *hint["cli_args"], @@ -191,10 +206,7 @@ def test_receipt_bound_failure_hint_executes_native_without_python( check=False, capture_output=True, text=True, - env={ - **os.environ, - "LOOPX_PYTHON": str(tmp_path / "python-must-not-run"), - }, + env=env, ) assert completed.returncode == 0, completed.stderr @@ -212,3 +224,137 @@ def test_windows_launcher_contains_the_same_exact_native_dispatch_gate() -> None assert '"--turn-instance-id"' in source assert "heartbeat_followup_cli.ts" in source assert "Get-Command node" in source + + +def _launcher( + tmp_path: Path, launcher: str, *, python: str = sys.executable, +) -> tuple[list[str], dict[str, str]]: + env = {**os.environ, "LOOPX_PYTHON": python} + if launcher == "unix": + return [str(REPO_ROOT / "scripts/loopx")], env + pwsh = shutil.which("pwsh") + if pwsh is None: + pytest.skip("PowerShell is required for its actual launcher") + pointer = tmp_path / "current-release.json" + pointer.write_text(json.dumps({"release_root": str(REPO_ROOT), "python": python}), encoding="utf-8") + env["LOOPX_CURRENT_RELEASE_FILE"] = str(pointer) + env["POWERSHELL_TELEMETRY_OPTOUT"] = "1" + return [pwsh, "-NoLogo", "-NoProfile", "-File", str(REPO_ROOT / "scripts/loopx.ps1")], env + + +@pytest.mark.parametrize("launcher", ["unix", "powershell"]) +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +@pytest.mark.parametrize("host", ["matches", "mismatch", "missing", "ambiguous"]) +def test_launcher_binds_ack_to_current_host_readback( + tmp_path: Path, monkeypatch, provider: str, host: str, launcher: str, +) -> None: + isolate_sqlite_runtime(tmp_path, monkeypatch) + registry, runtime, project = write_cli_fixture(tmp_path / "fixture", scoped_agents=True) + goal = "needs-operator" + state = project / f".codex/goals/{goal}/ACTIVE_GOAL_STATE.md" + projection = build_todo_runtime_shadow_projection( + goal_id=goal, todos=[], handoff_mode="soft_claim", + ) + projection["todo_read_model"] = { + **projection["todo_read_model"], + "schema_version": TODO_DOMAIN_READ_RECORD_SCHEMA_VERSION, + "contract_fields": list(TODO_DOMAIN_RECORD_FIELDS), + } + initialize_canonical_authority(runtime, goal, projection, state_path=state, provider=provider) + codex_home = tmp_path / "codex-home" + monkeypatch.setenv("CODEX_HOME", str(codex_home)) + monkeypatch.setenv("CODEX_THREAD_ID", "scheduler-fixture") + write_codex_heartbeat_fixture( + codex_home, goal_id=goal, agent_id=SCOPED_AGENT_ID, rrule="FREQ=MINUTELY;INTERVAL=3", + ) + _, guard = run_json_cli_result( + "quota", "should-run", "--goal-id", goal, "--agent-id", SCOPED_AGENT_ID, + "--codex-app", "--turn-instance-id", "current-host-launcher", + registry_path=registry, runtime_root=runtime, cwd=project, + ) + app = guard["scheduler_hint"]["app_automation"] + assert app["recommended_rrule"] == "FREQ=MINUTELY;INTERVAL=30" + manifest = codex_home / "automations/scheduler-fixture/automation.toml" + if host == "missing": + manifest.unlink() + else: + write_codex_heartbeat_fixture( + codex_home, goal_id=goal, agent_id=SCOPED_AGENT_ID, + rrule="FREQ=MINUTELY;INTERVAL=30" if host == "matches" else "FREQ=MINUTELY;INTERVAL=60", + ) + if host == "ambiguous": + duplicate = codex_home / "automations/duplicate/automation.toml" + duplicate.parent.mkdir() + shutil.copyfile(manifest, duplicate) + + command, env = _launcher(tmp_path, launcher) + command += ["--format", "json", *app["ack_hint"]["cli_args"]] + result = subprocess.run(command, cwd=project, env=env, text=True, capture_output=True, timeout=45) + payload = json.loads(result.stdout) + commit = payload["scheduler_commit"] + if host != "matches": + assert result.returncode == 1, result.stdout + result.stderr + assert payload["ok"] is False + assert commit["reason_code"] == "rrule_mismatch" + assert commit["written"] is False + assert not Path(commit["path"]).exists() + return + assert result.returncode == 0, result.stdout + result.stderr + assert commit["status"] == "written" + assert payload["scheduler_ack_event"]["scheduler_state"]["last_applied_rrule"] == "FREQ=MINUTELY;INTERVAL=30" + replay = subprocess.run(command, cwd=project, env=env, text=True, capture_output=True, timeout=45) + assert replay.returncode == 0, replay.stdout + replay.stderr + assert json.loads(replay.stdout)["scheduler_commit"]["status"] == "replayed" + _, stable = run_json_cli_result( + "quota", "should-run", "--goal-id", goal, "--agent-id", SCOPED_AGENT_ID, + "--codex-app", "--turn-instance-id", "current-host-launcher-stable", + registry_path=registry, runtime_root=runtime, cwd=project, + ) + assert stable["quota"]["spent_slots"] == 0 + backoff = stable["scheduler_hint"]["app_automation"]["stateful_backoff"] + assert backoff["apply_needed"] is False + assert backoff["ack_needed"] is False + + +@pytest.mark.parametrize("launcher", ["unix", "powershell"]) +@pytest.mark.parametrize("command", ["scheduler-ack-current", "scheduler-fail-current"]) +def test_launcher_preserves_outer_controller_write_guard( + tmp_path: Path, command: str, launcher: str, +) -> None: + registry, runtime, project = write_cli_fixture(tmp_path / "fixture", scoped_agents=True) + codex_home = tmp_path / "codex-home" + write_codex_heartbeat_fixture( + codex_home, goal_id="needs-operator", agent_id=SCOPED_AGENT_ID, + rrule="FREQ=MINUTELY;INTERVAL=60", + ) + entry, env = _launcher(tmp_path, launcher) + env.update(CODEX_HOME=str(codex_home), CODEX_THREAD_ID="scheduler-fixture") + guard_result = subprocess.run( + [sys.executable, "-m", "loopx.cli", "--registry", str(registry), + "--runtime-root", str(runtime), "--format", "json", "quota", "should-run", + "--goal-id", "needs-operator", "--agent-id", SCOPED_AGENT_ID, + "--codex-app", "--turn-instance-id", "outer-controller-launcher"], + cwd=project, env=env, text=True, capture_output=True, timeout=45, + ) + assert guard_result.returncode == 0, guard_result.stdout + guard_result.stderr + app = json.loads(guard_result.stdout)["scheduler_hint"]["app_automation"] + hint = app["ack_hint" if command == "scheduler-ack-current" else "failure_hint"] + arguments = ["--format", "json", *hint["cli_args"]] + if command == "scheduler-ack-current": + write_codex_heartbeat_fixture( + codex_home, goal_id="needs-operator", agent_id=SCOPED_AGENT_ID, + rrule="FREQ=MINUTELY;INTERVAL=30", + ) + arguments += ["--app-automation-current-rrule", "FREQ=MINUTELY;INTERVAL=30"] + result = subprocess.run( + [*entry, *arguments], cwd=project, + env={**env, + "LOOPX_KUNLUNCODE_OUTER_CONTROLLER": "1", + "LOOPX_KUNLUNCODE_OUTER_GOAL_ID": "needs-operator", + "LOOPX_KUNLUNCODE_OUTER_AGENT_ID": SCOPED_AGENT_ID}, + text=True, capture_output=True, timeout=45, + ) + assert result.returncode == 2, result.stdout + result.stderr + assert result.stdout == "" + assert json.loads(result.stderr)["error_code"] == "kunluncode_outer_controller_write_owned" + assert not list(runtime.glob("goals/*/scheduler-state/**/*.json"))