Skip to content
40 changes: 31 additions & 9 deletions docs/architecture/rfcs/automatic-execution-admission-v0.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
- **Delivery maturity:** Partial, proposed implementation; not promoted
- **Owners:** Quota, scheduler and host-runtime maintainers
- **Created / last normative revision:** 2026-09-23
- **Implementation baseline:** `23edcb19c`
- **Implementation baseline:** `79241d7ef`
- **Language mirror:** [中文版](automatic-execution-admission-v0.zh-CN.md) is the semantic mirror.
- **Related contracts:** [roadmap](loopx-overall-roadmap-v0.md), [quota](../../quota-allocation.md), [cadence hint](../../operations/long-task-cadence-policy.md), [session execution modes](agent-session-execution-modes-v0.md)

Expand All @@ -19,9 +19,11 @@ cannot rewrite it. A quota slot, a timer tick and a model invocation remain
different events. The target design requires every controlled new host invocation to pass temporal
admission as well as the existing budget, permission, binding and work gates.

No configured interval preserves existing behavior. The first milestone changes Codex App schedule recommendations, including reset
and backoff. It does not yet enforce launch admission. M2 will require every new
host invocation, retry and continuation to pass admission; cached settlement will not.
No configured interval preserves existing behavior. M1 changes Codex App schedule
recommendations, including reset and backoff. M2 adds pre-host admission to managed
`turn run-once`; App timer and other launchers remain separate qualification work.
Every new managed host invocation and failed-result retry passes admission; cached
settlement does not.
This RFC does not authorize changes to existing automations, model selection,
quota allocation, remote services or public publishing.

Expand Down Expand Up @@ -108,7 +110,7 @@ protection against clock manipulation.

| Host path | Required contract | Initial boundary |
| --- | --- | --- |
| Managed `turn run-once` | Atomic admission immediately before a new host attempt | Planned for M2, including failed-result recovery |
| Managed `turn run-once` | Atomic admission before a new host attempt, including failed-result recovery | M2 candidate; isolated CLI, concurrency and crash-recovery tests, no external host promotion |
| Local legacy scheduler / external launchers | Route launches through admitted Turn or implement the same owner call | Not yet qualified; do not advertise enforcement |
| Codex App automation | Apply floor-compatible timer, read actual schedule, ACK only matching facts | M1 schedule recommendation floor; hook coverage not qualified |
| Attached interactive/manual session | Explicit manual intent; existing authority gates remain | Caller records reason; automatic continuation cannot masquerade as manual |
Expand Down Expand Up @@ -209,10 +211,30 @@ product journey. The broader product goal remains open until M2/M3 acceptance.

## Appendix: implementation ledger

Baseline audit at `23edcb19c`: no durable owner minimum interval. M1 is an
unmerged candidate; M2/M3/M4 and live App qualification remain unverified.
Tests and PR validation must distinguish deterministic evidence from host
promotion. No existing automation is activated or rebound by this proposal.
Baseline audit at `23edcb19c`: no durable owner minimum interval. M1 merged in
PR #4921. The M2 candidate reserves a managed Turn start in the same quota
policy file and lock before host invocation. Denial returns the next eligible
time without a host call, writeback or quota spend; a failed host consumes its
start, and settlement replay skips admission. Goal floors apply per agent;
automation floors require an explicit stable `--automation-id`. A manual start
requires `--manual-interval-bypass-reason`, records a start, and bypasses only
the interval. The local CLI remains a same-UID trust boundary.

The App timer-to-hook path, non-Turn launchers, packaged settings UI and live
model-host promotion remain unqualified. No existing automation is activated
or rebound by this proposal.
A managed start is two-phase in the same store: admission reserves the interval
slot, and the Turn executor confirms that reservation only after the host
attempt is durable in its journal. A crash between the two leaves the
reservation resumable by the same Turn identity once the floor is reached, so a
reserved-but-unstarted start never strands a Turn; a confirmed start stays
fail-closed for the same identity, and an explicit manual reason cannot bypass
that. A store record written without the phase field is read as an attempted
start, so an older or hand-edited file fails closed rather than resuming.
M1 policy files are read as v1 and upgraded in place to v2 on the first
configuration write or admitted start. The path stays stable; older binaries
reject the v2 schema rather than silently discarding start records. Pause the
launcher before downgrade.

### Hook research — 2026-09-23

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
- **交付成熟度:** Partial,候选实现,尚未推广
- **维护边界:** quota、scheduler、host runtime
- **创建 / 规范修订:** 2026-09-23
- **实现基线:** `23edcb19c`
- **实现基线:** `79241d7ef`
- **语言镜像:** 本文与 [English](automatic-execution-admission-v0.md) 互为语义镜像。
- **相关契约:** [路线图](loopx-overall-roadmap-v0.zh-CN.md)、[quota](../../quota-allocation.md)、[节奏提示](../../operations/long-task-cadence-policy.md)、[执行模式](agent-session-execution-modes-v0.md)

Expand All @@ -16,8 +16,10 @@
scheduler 退避消费这一约束,不能改写它。配额槽、定时器唤醒和模型调用是三个事件。
目标设计要求受控执行器启动每次新的 host 调用前,同时满足时间准入、预算、权限、绑定和工作门禁。

未配置时保持原行为。M1 优先修改 Codex App 的调度建议、重置与退避,不宣称已经强制执行启动准入。
M2 再要求每次新 host 调用、重试与续跑重新准入;缓存结果和结算不重新消耗准入。本 RFC 不授权修改现有自动化、模型选择、配额分配、远程服务或公开发布。
未配置时保持原行为。M1 修改 Codex App 的调度建议、重置与退避。M2 为 managed
`turn run-once` 增加 host 启动前准入;App 定时器和其他 launcher 仍需单独验收。
每次新的 managed host 调用及失败结果重试重新准入;缓存结果和结算不重复消耗准入。
本 RFC 不授权修改现有自动化、模型选择、配额分配、远程服务或公开发布。

## 2. 问题与不变量

Expand Down Expand Up @@ -76,7 +78,7 @@ App、Turn、前端、Lark 不得另存一套策略。共享 authority provider

| 入口 | 必须履行的契约 | 本阶段边界 |
| --- | --- | --- |
| Managed `turn run-once` | 每次新 host 尝试前原子准入 | M2 计划,含错误结果后的恢复 |
| Managed `turn run-once` | 每次新 host 尝试及失败重试前原子准入 | M2 候选;已做隔离 CLI、并发与跨边界崩溃恢复测试,未推广外部宿主 |
| 旧 local scheduler / 外部 launcher | 经受控 Turn 启动,或调用相同准入 owner | 尚未验收,不宣传为已强制执行 |
| Codex App automation | 应用满足下限的定时器,回读真实值,事实匹配后 ACK | M1 调度建议下限;hook 覆盖范围未验收 |
| 附着式交互 / 手动会话 | 显式手动意图;其余门禁保留 | 记录原因;自动续跑不能冒充手动 |
Expand Down Expand Up @@ -155,8 +157,22 @@ M1 对 App 调度管理有独立价值,但不代表多宿主产品旅程完成

## 附录:实现记录

`23edcb19c` 基线没有持久用户最短间隔。M1 是未合并候选;M2/M3/M4 与真实 App 验收尚未完成。
测试和 PR 必须区分确定性验证与宿主推广。本提案不激活、不改绑任何已有自动化。
`23edcb19c` 基线没有持久用户最短间隔。M1 已由 PR #4921 合并。M2 候选在
同一 quota 策略文件与锁中预留 managed Turn 的启动位,再调用 host。拒绝时返回下次
可执行时间,不调用 host、不写回、不花 quota;失败的 host 消耗已预留间隔,结算回放
跳过准入。Goal 下限按 agent 生效;automation 下限要求显式稳定的 `--automation-id`。
手动启动要求 `--manual-interval-bypass-reason`,记录这次启动,只绕过时间下限。
本地 CLI 仍以相同 OS 用户为信任边界。

App 定时器到 hook、非 Turn launcher、打包设置界面及真实模型宿主推广仍未验收。
本提案不激活、不改绑任何已有自动化。
managed 启动在同一 store 内分两步:准入预留间隔位,Turn executor 只在该 host 尝试
已写入 Turn journal 之后确认该预留。两步之间进程退出时,同一 Turn 身份在满足时间
下限后仍可恢复,因此"已预留但未启动"不会永久卡住 Turn;已确认的启动对同一身份保持
fail-closed,显式手动理由也无法绕过。缺少阶段字段的旧记录按"已尝试启动"读取,
旧版或手工改写的文件因此 fail-closed,而不是被当作可恢复预留。
M1 的 v1 策略文件在首次配置写入或获准启动时原地升级为 v2,文件路径保持不变。
旧版程序会拒绝 v2 schema,避免静默丢弃启动记录;降级前必须暂停 launcher。

### Hook 调研 — 2026-09-23

Expand Down
127 changes: 110 additions & 17 deletions loopx/cli_commands/turn.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@
import argparse
import json
import shlex
import time
from collections.abc import Callable, Mapping
from pathlib import Path
from typing import Any
from typing import Any, NamedTuple

from ..cli_rollout import append_cli_rollout_event
from ..capabilities.explore.composition_frontier import (
Expand All @@ -20,6 +21,7 @@
)
from ..capabilities.periodic_report.cadence_runtime import extend_cadence_turn_start_dispatch
from ..control_plane.quota.live_decision import build_live_quota_should_run_decision
from ..control_plane.effect_runtime import effect_runtime_result
from ..control_plane.agents.workspace_guard import capture_delivery_workspace
from ..control_plane.quota.heartbeat_receipt import (
ensure_turn_heartbeat_settlement_receipt,
Expand Down Expand Up @@ -89,6 +91,86 @@
FormatSelector = Callable[..., str]


class ManagedCadenceStart(NamedTuple):
"""Owner-cadence callbacks for one managed Turn start.

`admit` reserves (or resumes) the interval slot before any host or journal
attempt; `confirm` marks that reservation as a real host attempt once the
Turn journal is durable. Keeping them separate means a crash in between
leaves a resumable reservation rather than a permanently rejected Turn.
"""

admit: Callable[[Mapping[str, Any]], dict[str, Any]]
confirm: Callable[[], None]


def managed_cadence_start(
*,
runtime_root: Path,
goal_id: str,
agent_id: str | None,
automation_id: str | None,
manual_reason: str | None,
on_admitted: Callable[[], None] | None = None,
) -> ManagedCadenceStart:
"""Bind one managed Turn start to the TypeScript owner-cadence store."""

admitted_request: dict[str, Any] = {}

def admit(identity: Mapping[str, Any]) -> dict[str, Any]:
now_ms = time.time_ns() // 1_000_000
request_id = f"{identity['turn_key']}:{identity['attempt']}"
admission = effect_runtime_result(
"quota.automation_cadence.admit",
{
"runtime_root": str(runtime_root),
"goal_id": goal_id,
"agent_id": agent_id,
"automation_id": automation_id,
"request_id": request_id,
"trigger_at_ms": now_ms,
"now_ms": now_ms,
"manual_reason": manual_reason,
},
retry_safe=False,
)
admitted_request.clear()
if admission.get("admitted") is True:
admitted_request["request_id"] = request_id
admitted_request["reserved"] = admission.get("reserved") is True
if on_admitted is not None:
on_admitted()
return {
key: admission.get(key)
for key in (
"admitted", "reserved", "resumed", "reason", "next_eligible_at_ms",
"min_interval_minutes", "pre_model_admission",
)
}

def confirm() -> None:
request_id = admitted_request.get("request_id")
if admitted_request.get("reserved") is not True or not isinstance(request_id, str):
return
confirmation = effect_runtime_result(
"quota.automation_cadence.confirm_start",
{
"runtime_root": str(runtime_root),
"goal_id": goal_id,
"agent_id": agent_id,
"automation_id": automation_id,
"request_id": request_id,
},
retry_safe=True,
)
if confirmation.get("confirmed") is not True:
raise ValueError(
"managed Turn start could not be confirmed against the owner cadence store"
)

return ManagedCadenceStart(admit=admit, confirm=confirm)




def handle_turn_command(
Expand Down Expand Up @@ -395,22 +477,14 @@ def handle_turn_command(
and persisted_effect_id != settlement_identity.effect_id
):
raise ValueError("Turn settlement identity effect_id is inconsistent")
if args.execute:
ensure_turn_heartbeat_settlement_receipt(
runtime_root,
settlement_identity,
semantic_replan_guard_scoped=(
"replan_action_packet" in envelope
),
semantic_replan_obligation_id=(
replan_obligation_id_from_packet(
envelope.get("replan_action_packet")
)
if "replan_action_packet" in envelope
else None
),
)

stable_envelope: Mapping[str, Any] = (
envelope if isinstance(envelope, Mapping) else {}
)
replan_guard_scoped = "replan_action_packet" in stable_envelope
replan_obligation_id = (
replan_obligation_id_from_packet(stable_envelope.get("replan_action_packet"))
if replan_guard_scoped else None
)
def require_effect_ref(
effect_ref: str,
step_kind: SettlementStepKind,
Expand Down Expand Up @@ -1049,6 +1123,23 @@ def post_settlement_reward_memory(
settlement_evidence=settlement_evidence,
)

def on_managed_start_admitted() -> None:
ensure_turn_heartbeat_settlement_receipt(
runtime_root,
settlement_identity,
semantic_replan_guard_scoped=replan_guard_scoped,
semantic_replan_obligation_id=replan_obligation_id,
)

managed_cadence = managed_cadence_start(
runtime_root=runtime_root,
goal_id=args.goal_id,
agent_id=args.agent_id,
automation_id=args.automation_id,
manual_reason=args.manual_interval_bypass_reason,
on_admitted=on_managed_start_admitted,
)

payload = run_loopx_turn_once(
payload,
host_argv=raw_argv,
Expand Down Expand Up @@ -1077,6 +1168,8 @@ def post_settlement_reward_memory(
if args.execute and args.host == "codex-cli"
else None
),
admit_start=managed_cadence.admit if args.execute else None,
confirm_start=managed_cadence.confirm if args.execute else None,
)
else:
raise ValueError("turn requires the `plan` or `run-once` subcommand")
Expand Down
8 changes: 8 additions & 0 deletions loopx/cli_commands/turn_registration.py
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,14 @@ def register_turn_commands(
default_execution_mode="isolated-headless",
)
run_once.add_argument("--project", required=True)
run_once.add_argument(
"--automation-id",
help="Stable automation identity for an automation-scoped execution interval.",
)
run_once.add_argument(
"--manual-interval-bypass-reason",
help="Explicit manual intent; bypass only the interval and record this start.",
)
run_once.add_argument(
"--host-command-json",
"--host-adapter-command-json",
Expand Down
16 changes: 16 additions & 0 deletions loopx/cli_commands/turn_rendering.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
from __future__ import annotations

from datetime import datetime, timezone
from typing import Any

from ..presentation.renderers.turn_envelope_markdown import (
turn_envelope_budget_warning_lines,
)
Expand Down Expand Up @@ -33,6 +36,14 @@ def render_loopx_turn_plan_markdown(payload: dict[str, object]) -> str:

def render_loopx_turn_execution_markdown(payload: dict[str, object]) -> str:
effects = payload.get("effects") if isinstance(payload.get("effects"), dict) else {}
raw_admission = payload.get("admission")
admission: dict[str, Any] = raw_admission if isinstance(raw_admission, dict) else {}
next_ms = admission.get("next_eligible_at_ms")
next_at = (
datetime.fromtimestamp(next_ms / 1000, tz=timezone.utc).isoformat().replace("+00:00", "Z")
if isinstance(next_ms, (int, float)) and not isinstance(next_ms, bool)
else None
)
receipt = payload.get("receipt") if isinstance(payload.get("receipt"), dict) else {}
validation = (
payload.get("validation") if isinstance(payload.get("validation"), dict) else {}
Expand Down Expand Up @@ -68,6 +79,11 @@ def render_loopx_turn_execution_markdown(payload: dict[str, object]) -> str:
"# LoopX Turn Run Once",
f"- status: {payload.get('status')}",
f"- result_kind: {payload.get('result_kind')}",
*(
[f"- interval_reason: {admission.get('reason')}",
*([f"- next_eligible_at: {next_at}"] if next_at else [])]
if payload.get("status") == "interval_wait" else []
),
*(
[f"- execution_profile: {managed_executor['execution_profile']}"]
if managed_executor.get("execution_profile") else []
Expand Down
4 changes: 3 additions & 1 deletion loopx/control_plane/effect_runtime_handlers.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import {admitAutomationStart, confirmAutomationStart, manageAutomationCadence, projectCadenceSchedule} from "./quota/automation_cadence.ts";
import {deliverShadowEntry} from "./coordination/shadow_entry_delivery.ts";
import {readShadowDrainPlan} from "./coordination/shadow_drain_plan.ts";
import {manageAutomationCadence, projectCadenceSchedule} from "./quota/automation_cadence.ts";
import {readCanonicalSnapshotPage} from "./coordination/canonical_snapshot_page.ts";
import {manageLocalAuthorityArchive} from "./coordination/local_authority_archive.ts";
import {selectPeriodicReportProgress, selectPeriodicReportApprovalRetry} from "./capabilities/periodic_report_progress.ts";
Expand Down Expand Up @@ -485,6 +485,8 @@ export function createEffectRuntimeHandlers(
["todo.external_wait.plan", planTodoExternalWaitTransition],
["scheduler.state_transition.evaluate", evaluateSchedulerStateTransition],
["quota.automation_cadence.manage", manageAutomationCadence],
["quota.automation_cadence.admit", admitAutomationStart],
["quota.automation_cadence.confirm_start", confirmAutomationStart],
["quota.automation_cadence.schedule", projectCadenceSchedule],
["scheduler.state.evaluate", evaluateSchedulerStateOperation],
["scheduler.state.load", loadSchedulerState],
Expand Down
Loading
Loading