Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions docs/architecture/rfcs/automatic-execution-admission-v0.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 12 additions & 1 deletion loopx/control_plane/scheduler/heartbeat_followup_cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,13 @@ function requestFromArgs(argv: string[]): { request: Record<string, unknown>; 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) {
Expand Down Expand Up @@ -273,7 +280,11 @@ function requestFromArgs(argv: string[]): { request: Record<string, unknown>; 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,
Expand Down
16 changes: 15 additions & 1 deletion scripts/loopx
Original file line number Diff line number Diff line change
Expand Up @@ -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=*)
Expand All @@ -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
Expand All @@ -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"
Expand Down
11 changes: 10 additions & 1 deletion scripts/loopx.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"
}
Expand Down
18 changes: 17 additions & 1 deletion tests/control_plane/test_source_cli_entrypoint.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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)
Expand All @@ -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"]]

Expand Down Expand Up @@ -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
Expand Down
56 changes: 54 additions & 2 deletions tests/control_plane_ts/scheduler_heartbeat_followup_cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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");
Expand Down Expand Up @@ -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<string, unknown>;
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<string, unknown>;
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<string, unknown>).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<string, unknown>;
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);
Expand Down
Loading
Loading