Skip to content

Commit 619297c

Browse files
authored
Merge pull request #5219 from loopx-project/codex/scheduler-compat-authority-20260928
2 parents 88b00d7 + f571573 commit 619297c

8 files changed

Lines changed: 317 additions & 16 deletions

File tree

‎docs/development/control-plane-course/07-host-scheduler-and-heartbeat.md‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,30 @@ CLI acknowledged exact applied state
210210

211211
Scheduler ACK 本身不构成 delivery,不 spend。
212212

213+
### 兼容投影不能改变状态权威 / Compatibility preserves state authority
214+
215+
`app_automation` 与 `codex_app` 是同一 proposal 的宿主投影,不是两个 scheduler
216+
writer。读取优先采用 common state;仅有旧 Codex state 时,保留它经 TS 验证的
217+
`state_key`。两份投影的 backoff、host facts 与 ACK/failure 命令都必须携带同一个键。
218+
否则 common state 的当前 identity/index 会被误写到旧键,触发真实的初始档位冲突;
219+
即使 CAS digest 相等也不代表 proposal 的状态归属正确。
220+
221+
`app_automation` and `codex_app` project one proposal, not two scheduler writers.
222+
Reads prefer common state; a legacy-only Codex installation retains its
223+
TS-validated legacy key. Both projections must carry that same key through
224+
backoff, host facts and ACK/failure commands. Equal CAS digests do not prove that
225+
a proposal belongs to the chosen state scope.
226+
227+
兼容 Python API 与手工 Codex CLI 未显式传 `state_key` 时,从当前 packet 取得实际键;
228+
显式键仍须匹配,不得静默改写。Trae 仍只接受 common key。这不是隐式迁移:不能把
229+
旧状态的非零档位当作空 common state 的首次 ACK,也不能放松 TS 的 reset/CAS 校验。
230+
231+
When Python compatibility APIs or manual Codex CLI calls omit `state_key`, they
232+
use the current packet's key. Explicit keys must still match; Trae remains
233+
common-key-only. This is not an implicit migration: a nonzero legacy stage must
234+
not become a first ACK into missing common state, and TS reset/CAS checks remain
235+
unchanged.
236+
213237
### Proposal、Host Effect 与 Durable Receipt
214238

215239
Scheduler 交互包含三个时间点,不能压成一个 `RRULE matches`:

‎loopx/cli_commands/quota_context.py‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,6 @@
2626
)
2727
from ..control_plane.scheduler.state import (
2828
APP_AUTOMATION_STATEFUL_BACKOFF_STATE_KEY,
29-
CODEX_APP_STATEFUL_BACKOFF_STATE_KEY,
3029
)
3130
from ..status import AUTONOMOUS_REPLAN_PERIODIC_LOOKBACK, collect_status
3231
from ..turn_identity import mint_turn_instance_id, normalize_turn_instance_id
@@ -232,7 +231,7 @@ def validate_quota_command_context_request(
232231
default_state_key = (
233232
APP_AUTOMATION_STATEFUL_BACKOFF_STATE_KEY
234233
if selected_surface == HostSurface.TRAE_APP.value
235-
else CODEX_APP_STATEFUL_BACKOFF_STATE_KEY
234+
else None
236235
)
237236
if (
238237
selected_surface == HostSurface.TRAE_APP.value

‎loopx/control_plane/quota/scheduler_ack.py‎

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ def _scheduler_packet(
2525
before: dict[str, Any],
2626
*,
2727
surface: str,
28-
state_key: str,
28+
state_key: str | None,
2929
) -> tuple[dict[str, Any], dict[str, Any], dict[str, Any]]:
3030
scheduler_hint = (
3131
before.get("scheduler_hint")
@@ -36,7 +36,10 @@ def _scheduler_packet(
3636
packet_key = "app_automation"
3737
elif (
3838
surface == CODEX_APP_SURFACE
39-
and state_key == APP_AUTOMATION_STATEFUL_BACKOFF_STATE_KEY
39+
and (
40+
state_key == APP_AUTOMATION_STATEFUL_BACKOFF_STATE_KEY
41+
or (state_key is None and isinstance(scheduler_hint.get("app_automation"), dict))
42+
)
4043
):
4144
packet_key = "app_automation"
4245
else:
@@ -54,6 +57,15 @@ def _scheduler_packet(
5457
return scheduler_hint, surface_packet, stateful_backoff
5558

5659

60+
def _followup_state_key(
61+
before: dict[str, Any], *, surface: str, state_key: str | None,
62+
) -> str:
63+
if state_key is not None:
64+
return state_key
65+
_, _, backoff = _scheduler_packet(before, surface=surface, state_key=None)
66+
return str(backoff.get("state_key") or CODEX_APP_STATEFUL_BACKOFF_STATE_KEY)
67+
68+
5769
def _current_hint_identity(
5870
before: dict[str, Any],
5971
*,
@@ -277,7 +289,7 @@ def record_quota_scheduler_ack_for_decision(
277289
agent_id: str | None,
278290
execute: bool = False,
279291
surface: str = CODEX_APP_SURFACE,
280-
state_key: str = CODEX_APP_STATEFUL_BACKOFF_STATE_KEY,
292+
state_key: str | None = None,
281293
applied_rrule: str | None = None,
282294
reset_token: str | None = None,
283295
identity_signature: str | None = None,
@@ -286,6 +298,7 @@ def record_quota_scheduler_ack_for_decision(
286298
use_current_hint: bool = False,
287299
host_match_observed: bool = False,
288300
) -> dict[str, Any]:
301+
state_key = _followup_state_key(before, surface=surface, state_key=state_key)
289302
safe_agent_id = normalize_todo_claimed_by(agent_id)
290303
if host_match_observed and (
291304
not str(applied_rrule or "").strip()
@@ -374,12 +387,13 @@ def record_quota_scheduler_failure_for_decision(
374387
agent_id: str | None,
375388
execute: bool = False,
376389
surface: str = CODEX_APP_SURFACE,
377-
state_key: str = CODEX_APP_STATEFUL_BACKOFF_STATE_KEY,
390+
state_key: str | None = None,
378391
failed_rrule: str | None = None,
379392
observed_host_rrule: str | None = None,
380393
failure_kind: str = "host_tool_failure",
381394
generated_at: str | None = None,
382395
) -> dict[str, Any]:
396+
state_key = _followup_state_key(before, surface=surface, state_key=state_key)
383397
safe_agent_id = normalize_todo_claimed_by(agent_id)
384398
target_rrule = normalize_scheduler_rrule(failed_rrule)
385399
try:

‎loopx/control_plane/scheduler/app_automation_compat.py‎

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ def build_codex_app_compatibility_projection(
2727
build_failure_hint: Callable[..., dict[str, Any]],
2828
build_fallback_hint: Callable[..., dict[str, Any]],
2929
) -> dict[str, Any]:
30-
"""Translate the canonical App packet to the exact legacy Codex shape."""
30+
"""Translate the App packet without changing its durable authority scope."""
3131

3232
legacy = copy.deepcopy(app_automation)
3333
legacy["applicability"] = "applicable"
@@ -37,14 +37,18 @@ def build_codex_app_compatibility_projection(
3737
else None
3838
)
3939
backoff = legacy.get("stateful_backoff")
40+
state_key = (
41+
backoff["state_key"]
42+
if isinstance(backoff, dict)
43+
else CODEX_APP_STATEFUL_BACKOFF_STATE_KEY
44+
)
4045
if isinstance(backoff, dict):
4146
backoff["schema_version"] = CODEX_APP_STATEFUL_BACKOFF_SCHEMA_VERSION
42-
backoff["state_key"] = CODEX_APP_STATEFUL_BACKOFF_STATE_KEY
4347
legacy_facts = (
4448
{
4549
**scheduler_host_facts,
4650
"surface": CODEX_APP_SURFACE,
47-
"state_key": CODEX_APP_STATEFUL_BACKOFF_STATE_KEY,
51+
"state_key": state_key,
4852
}
4953
if isinstance(scheduler_host_facts, Mapping)
5054
else None
@@ -64,6 +68,7 @@ def build_codex_app_compatibility_projection(
6468
else None
6569
)
6670
legacy["failure_hint"] = build_failure_hint(
71+
state_key=state_key,
6772
goal_id=goal_id,
6873
agent_id=agent_id,
6974
failed_rrule=legacy.get("recommended_rrule"),
@@ -86,6 +91,7 @@ def build_codex_app_compatibility_projection(
8691
else {}
8792
)
8893
legacy["ack_hint"] = build_ack_hint(
94+
state_key=state_key,
8995
goal_id=goal_id,
9096
agent_id=agent_id,
9197
applied_rrule=canonical_args.get("applied_rrule"),

‎loopx/control_plane/scheduler/scheduler_hint.py‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -686,6 +686,12 @@ def build(
686686
if context is not None and context.app_automation_applicable
687687
else CODEX_APP_SURFACE
688688
)
689+
# The TS store has already validated this scope. Preserve it through
690+
# both host projections, including a legacy-only Codex installation.
691+
app_state_key = (
692+
(self.codex_app_scheduler_state or {}).get("state_key")
693+
or APP_AUTOMATION_STATEFUL_BACKOFF_STATE_KEY
694+
)
689695
cadence = self._cadence_projections(codex_interval, codex_max, multiplier, cadence_progression_override)
690696
local_cadence_progression, app_cadence_progression = cadence["local"], cadence["app"]
691697
app_host_max, codex_max, floor = cadence["app_max"], cadence["local_max"], cadence["floor"]
@@ -880,7 +886,7 @@ def build(
880886
),
881887
"stateful_backoff": {
882888
"schema_version": APP_AUTOMATION_STATEFUL_BACKOFF_SCHEMA_VERSION,
883-
"state_key": APP_AUTOMATION_STATEFUL_BACKOFF_STATE_KEY,
889+
"state_key": app_state_key,
884890
"identity_signature": identity_signature,
885891
"reset_token": reset_token,
886892
"progression_index": current_index,
@@ -921,7 +927,7 @@ def build(
921927
"goal_id": str(goal_id),
922928
"agent_id": str(agent_id),
923929
"surface": app_surface,
924-
"state_key": APP_AUTOMATION_STATEFUL_BACKOFF_STATE_KEY,
930+
"state_key": app_state_key,
925931
"reset_token": reset_token,
926932
"identity_signature": identity_signature,
927933
"progression_index": current_index,
@@ -940,6 +946,7 @@ def build(
940946
app_automation["recommended_rrule"] = current_rrule
941947
if goal_id and agent_id:
942948
app_automation["failure_hint"] = build_app_automation_scheduler_failure_hint(
949+
state_key=app_state_key,
943950
goal_id=goal_id,
944951
agent_id=agent_id,
945952
failed_rrule=current_rrule,
@@ -965,6 +972,7 @@ def build(
965972
)
966973
if ack_needed and goal_id and agent_id:
967974
app_automation["ack_hint"] = build_app_automation_scheduler_ack_hint(
975+
state_key=app_state_key,
968976
goal_id=goal_id,
969977
agent_id=agent_id,
970978
applied_rrule=current_rrule,

‎loopx/control_plane/scheduler/state.py‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -319,8 +319,9 @@ def load_app_automation_scheduler_state(
319319
)
320320
if current is not None or surface != CODEX_APP_SURFACE:
321321
return current
322-
# Codex alone reads its pre-app_automation key so the next successful ACK
323-
# can rewrite the cadence state under the provider-neutral contract.
322+
# Codex alone reads its pre-app_automation key. Follow-ups retain that
323+
# validated scope; copying its nonzero progression into a missing common
324+
# state would incorrectly turn an acknowledged continuation into a reset.
324325
return load_scheduler_state(
325326
runtime_root,
326327
goal_id=goal_id,

‎loopx/quota.py‎

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,6 @@
9393
SchedulerExecutionContextResolution,
9494
)
9595
from .control_plane.scheduler.state import (
96-
CODEX_APP_STATEFUL_BACKOFF_STATE_KEY,
9796
CODEX_APP_SURFACE,
9897
)
9998
from .control_plane.todos.contract import (
@@ -984,7 +983,7 @@ def record_quota_scheduler_ack(
984983
agent_id: str | None = None,
985984
available_capabilities: Any = None,
986985
surface: str = CODEX_APP_SURFACE,
987-
state_key: str = CODEX_APP_STATEFUL_BACKOFF_STATE_KEY,
986+
state_key: str | None = None,
988987
applied_rrule: str | None = None,
989988
reset_token: str | None = None,
990989
identity_signature: str | None = None,
@@ -1023,7 +1022,7 @@ def record_quota_scheduler_ack(
10231022
agent_id=safe_agent_id,
10241023
execute=execute,
10251024
surface=str(surface or CODEX_APP_SURFACE).strip() or CODEX_APP_SURFACE,
1026-
state_key=str(state_key or CODEX_APP_STATEFUL_BACKOFF_STATE_KEY).strip(),
1025+
state_key=str(state_key).strip() if state_key is not None else None,
10271026
applied_rrule=applied_rrule,
10281027
reset_token=reset_token,
10291028
identity_signature=identity_signature,

0 commit comments

Comments
 (0)