diff --git a/docs/book/chapters/06-codex-app.md b/docs/book/chapters/06-codex-app.md index 0bdc7b25e9..72d91e3d6f 100644 --- a/docs/book/chapters/06-codex-app.md +++ b/docs/book/chapters/06-codex-app.md @@ -101,7 +101,7 @@ loopx history --goal-id --limit 10 - `scheduler_hint` 是否适用于 `codex_app`; - 最新 run 是否包含验证和 writeback,而不只是 status poll。 -如果 `scheduler_hint.codex_app.stateful_backoff.apply_needed=true`,还要确认 App 实际应用了 +如果 `scheduler_hint.app_automation.stateful_backoff.apply_needed=true`,还要确认 App 实际应用了 `recommended_rrule`,随后执行 packet 提供的完整 `ack_hint.cli_args`。仅看到 recommendation 或 本地 ACK ledger 都不足以证明 cadence 已生效;实际 Host RRULE readback 若报告 drift,必须按当前 hint 修复。 diff --git a/docs/book/en/chapters/06-codex-app.md b/docs/book/en/chapters/06-codex-app.md index 90e880389e..3d9f95569d 100644 --- a/docs/book/en/chapters/06-codex-app.md +++ b/docs/book/en/chapters/06-codex-app.md @@ -103,7 +103,7 @@ Check: - whether `scheduler_hint` applies to `codex_app`; - whether the latest run contains validation and writeback rather than only a status poll. -When `scheduler_hint.codex_app.stateful_backoff.apply_needed=true`, also verify that the App applied the +When `scheduler_hint.app_automation.stateful_backoff.apply_needed=true`, also verify that the App applied the `recommended_rrule` and then ran the packet's complete `ack_hint.cli_args`. A recommendation or local ACK ledger alone does not prove that Host cadence changed. If actual RRULE readback reports drift, repair it from the current hint. diff --git a/docs/development/control-plane-course/03-first-real-loop.md b/docs/development/control-plane-course/03-first-real-loop.md index 67bfdc6273..8da2be7125 100644 --- a/docs/development/control-plane-course/03-first-real-loop.md +++ b/docs/development/control-plane-course/03-first-real-loop.md @@ -647,7 +647,7 @@ Scheduler ACK 应保留的关键字段: goal_id = agent_id = surface = codex_app -state_key = scheduler_hint.codex_app.stateful_backoff +state_key = scheduler_hint.app_automation.stateful_backoff RRULE = FREQ=MINUTELY;INTERVAL=3 ``` diff --git a/docs/development/control-plane-course/07-host-scheduler-and-heartbeat.md b/docs/development/control-plane-course/07-host-scheduler-and-heartbeat.md index 21daab01c2..2c3d7cba1c 100644 --- a/docs/development/control-plane-course/07-host-scheduler-and-heartbeat.md +++ b/docs/development/control-plane-course/07-host-scheduler-and-heartbeat.md @@ -174,7 +174,7 @@ loopx quota scheduler-ack-current \ --goal-id \ --agent-id \ --surface codex_app \ - --state-key scheduler_hint.codex_app.stateful_backoff \ + --state-key scheduler_hint.app_automation.stateful_backoff \ --reset-token \ --identity-signature \ --applied-rrule 'FREQ=MINUTELY;INTERVAL=30' \ diff --git a/docs/heartbeat-automation-prompt.md b/docs/heartbeat-automation-prompt.md index 1d3bcddab8..463b7e64ec 100644 --- a/docs/heartbeat-automation-prompt.md +++ b/docs/heartbeat-automation-prompt.md @@ -631,23 +631,23 @@ When creating a heartbeat in Codex App, keep the visible instruction short and put the lifecycle in the automation task body. The default onboarding cadence starts at 3 minutes; after the first guard, follow `quota should-run.scheduler_hint` to back off long waits and stop external loops -after a final quota/replan check confirms repeated unchanged polls. Codex App +after a final quota/replan check confirms repeated unchanged polls. App-hosted heartbeats should search/use `automation_update` when available. If `scheduler_hint.action=stop_until_explicit_resume` and -`scheduler_hint.codex_app.host_action=pause_or_delete_current_heartbeat`: in +`scheduler_hint.app_automation.host_action=pause_or_delete_current_heartbeat`: in that terminal case, call `automation_update` once to pause the current heartbeat (delete only if pause is unavailable), verify the host result, spend no quota, and end the turn without a scheduler ACK. Otherwise call it only when -`scheduler_hint.codex_app.stateful_backoff.apply_needed=true` and -`scheduler_hint.codex_app.recommended_rrule` is present. After a successful +`scheduler_hint.app_automation.stateful_backoff.apply_needed=true` and +`scheduler_hint.app_automation.recommended_rrule` is present. After a successful RRULE update, run `loopx` with -`scheduler_hint.codex_app.ack_hint.cli_args`; current payloads use +`scheduler_hint.app_automation.ack_hint.cli_args`; current payloads use `quota scheduler-ack-current` so LoopX re-reads the latest hint and owns the progression/reset state. The ACK settles that RRULE; an immediate final guard may verify the same target but must not be treated as another elapsed poll. Attempt the host update at most once per hint and turn. If it fails or times out, do not retry or ACK; run -`scheduler_hint.codex_app.failure_hint.cli_args` once to persist the failed +`scheduler_hint.app_automation.failure_hint.cli_args` once to persist the failed target and observed host RRULE without spending quota. Exact repeats are then suppressed until either value changes. Continue any allowed delivery under the observed host cadence. When the desired RRULE is already applied, skip @@ -657,7 +657,7 @@ hint directly, otherwise do nothing. For the uniquely matched current heartbeat, `host_observation.status=drift_detected` result reopens `apply_needed`: If `automation_update` is unavailable in the session and -`scheduler_hint.codex_app.fallback_hint.available=true`, run the bound +`scheduler_hint.app_automation.fallback_hint.available=true`, run the bound `fallback_hint.cli_args` (`loopx-apply-rrule`) once instead. It backs up `codex-dev.db`, syncs the automation TOML and SQLite row, and runs the bound ACK; direct SQLite edits bypass the app API, so this is a bounded fallback and @@ -813,10 +813,10 @@ This prompt is intentionally a lifecycle template. Scheduling policy lives in controller loop, Codex CLI TUI, Claude Code loop, or future Codex goal-mode automations can all share the same LoopX quota guard without hard-coding different wait loops. Host implementations should first honor a terminal -`codex_app.host_action=pause_or_delete_current_heartbeat` by stopping the +`app_automation.host_action=pause_or_delete_current_heartbeat` by stopping the current heartbeat once, verifying the result, and ending without scheduler ACK or quota spend. Otherwise they should read the compact -`codex_app.stateful_backoff` packet, call `automation_update` only when +`app_automation.stateful_backoff` packet, call `automation_update` only when `apply_needed=true`, and then let `quota scheduler-ack-current` persist the applied RRULE state from the latest scheduler hint without spending quota. A matching reset readback may instead set `ack_needed=true`; in that case skip the diff --git a/docs/integrations/runtime-connector-catalog.md b/docs/integrations/runtime-connector-catalog.md index 96cd2af726..1c26b99c10 100644 --- a/docs/integrations/runtime-connector-catalog.md +++ b/docs/integrations/runtime-connector-catalog.md @@ -32,7 +32,8 @@ about the work. | id | surface | execution_mode | wake_triggers | state_writeback | liveness_signal | stop_reset_policy | budget_meter | human_visibility | boundary | smoke_expectation | | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | -| `codex_app_heartbeat` | Codex App automation | Scheduled headless app thread | Codex App heartbeat RRULE; `scheduler_hint.reset_policy.reset_token` | `todo` lifecycle, `refresh-state`, then `quota spend-slot` after validation | Heartbeat run plus quota event | Apply `scheduler_hint.codex_app` reset/backoff; cadence-only changes do not spend | Per-goal/per-agent quota slot after validated writeback | Visible thread, heartbeat XML, concrete user todo when required | Generated heartbeat prompt; scoped `--agent-id`; no project-specific prompt branches | Prompt smoke covers scheduler hint, reset token, identity, and no-spend cadence change. | +| `codex_app_heartbeat` | Codex App automation | Scheduled headless app thread | Codex App heartbeat RRULE; `scheduler_hint.reset_policy.reset_token` | `todo` lifecycle, `refresh-state`, then `quota spend-slot` after validation | Heartbeat run plus quota event | Apply `scheduler_hint.app_automation` reset/backoff; cadence-only changes do not spend | Per-goal/per-agent quota slot after validated writeback | Visible thread, heartbeat XML, concrete user todo when required | Generated heartbeat prompt; scoped `--agent-id`; no project-specific prompt branches | Prompt smoke covers scheduler hint, reset token, identity, and no-spend cadence change. | +| `trae_app` | Trae App automation | Scheduled app thread | Trae App heartbeat schedule; `scheduler_hint.reset_policy.reset_token` | `todo` lifecycle, `refresh-state`, then `quota spend-slot` after validation | Heartbeat run plus quota event | Apply the provider-neutral `scheduler_hint.app_automation` cadence packet with `host_surface=trae_app` through Trae `automation_update`; keep non-terminal settled turns active; no Codex packet or local-store fallback | Per-goal/per-agent quota slot after validated writeback | Visible Trae thread and concrete user todo when required | Generated heartbeat prompt; scoped `--agent-id`; ambient `TRAECLI_THREAD_ID`; no Codex local automation-store mutation | Host activation smoke covers distinct runtime identity, automation setup, three-minute initial cadence contract, and non-terminal continuation. | | `codex_app_ssh_goal` | Codex App connected to a remote workspace over SSH | Visible interactive Goal loop | Host `/goal` continuation; no automation-tool dependency | Same CLI todo/refresh/spend path, with `--source visible-goal` after validation | Visible Goal state plus compact LoopX status | Complete only on terminal no-follow-up; after three unchanged blocked turns, native `update_goal(status=blocked)` stops host continuation while LoopX remains active; user `/goal resume` reactivates it | Quota slot after validated writeback; no spend for gates, waits, final checks, or host blocking | User sees the active Codex task and its concrete gate or next action | Generated body stays within the `/goal` 4000-character limit and never invents `LOOPX_TURN` or calls `automation_update` | Agent-onboard smoke proves exact host selection, typed scheduler context, body budget, bounded quiet stop, and absence of heartbeat-only instructions. | | `codex_cli_tui` | Codex CLI TUI | Visible interactive terminal loop | User bootstrap, `/goal`, or visible continuation | Same CLI todo/refresh/spend path | TUI transcript plus compact LoopX status | Uses the same native `update_goal(status=blocked)` and `/goal resume` contract after the typed unchanged limit | Quota slot after validated writeback; no spend for blocked/final-check transitions | User sees the active TUI turn | Do not silently switch to hidden headless execution or copy raw transcripts | TUI prompt/bootstrap smoke covers scoped identity and native blocked/resume semantics. | | `claude_code_loop` | Claude Code loop | Visible local agent loop | Slash command, local loop tick, or host loop continuation | Same CLI todo/refresh/spend path | Loop status plus compact transcript pointer | Final quota/replan check before stop when unchanged limit is configured | Quota slot after validated writeback; no spend for stop/final check | User sees local loop status and response | No private material, credentials, production action, or hidden approval bypass | Loop smoke covers scoped identity, unchanged final check, and stop-without-spend. | @@ -49,7 +50,7 @@ about the work. ### Reusable shell_worker reference `scripts/external_scheduler_worker.py` is a scheduler-hint-aware `shell_worker` -for generic visible CLI loops (for example TraeX). Each tick runs +for generic visible CLI loops (for example TraeX CLI). Each tick runs `quota should-run --include-detail scheduler`, projects a one-line public-safe status (`waiting`/`should_run`/`terminal`, cadence class, next check minutes, unchanged count), and sleeps per the `local_scheduler` progression ladder. It diff --git a/docs/quota-allocation.md b/docs/quota-allocation.md index 9678c1637d..93657babcd 100644 --- a/docs/quota-allocation.md +++ b/docs/quota-allocation.md @@ -883,7 +883,7 @@ of an error string. "execution_required": false, "request": "loopx quota should-run --include-detail scheduler", "hot_path_runtime_fields": [ - "codex_app", + "app_automation", "unchanged_poll", "reset_policy" ], @@ -900,8 +900,8 @@ of an error string. "reset_policy": { "reset_token": "0123456789abcdef", "host_state_key": "scheduler_hint.reset_policy.reset_token", - "codex_app_initial_interval_minutes": 30, - "codex_app_initial_rrule": "FREQ=MINUTELY;INTERVAL=30", + "app_automation_initial_interval_minutes": 30, + "app_automation_initial_rrule": "FREQ=MINUTELY;INTERVAL=30", "identity_signature": "123456789abc" } }, @@ -1088,19 +1088,19 @@ agent-to-agent handoff cadence too quickly; `backoff_until_fresh_evidence` handles mapped or post-handoff no-op waits. For Codex App and local schedulers, `recommended_interval_minutes` is the next target interval. For Codex App heartbeats, `recommended_rrule` is emitted only -when `codex_app.stateful_backoff.apply_needed=true`; if the desired RRULE is +when `app_automation.stateful_backoff.apply_needed=true`; if the desired RRULE is already applied, it is omitted so the agent does not call a host tool again. If that match still needs a reset-token/identity binding, `stateful_backoff.ack_needed=true` and the bound ack runs without a host update. When an apply is required but `automation_update` is unavailable in the -session, `codex_app.fallback_hint` carries the bounded `loopx-apply-rrule` +session, `app_automation.fallback_hint` carries the bounded `loopx-apply-rrule` command for the resolved automation (backup `codex-dev.db`, sync TOML+SQLite, run the bound ACK). Direct SQLite edits bypass the app API, so the fallback is projected only for this gap and never as the routine path; an unresolved automation id projects `available=false` and requires the pasteable heartbeat gate instead of guessing. After a successful host RRULE update, the agent records that fact with -`loopx` plus `codex_app.ack_hint.cli_args`; current payloads use +`loopx` plus `app_automation.ack_hint.cli_args`; current payloads use `quota scheduler-ack-current` to re-read the latest scheduler hint before LoopX advances the per goal/agent scheduler state without spending quota. Human gates can move Codex App heartbeats through `[30, 60]` after the concrete user todo @@ -1131,7 +1131,7 @@ Agent-scope waits use a more conservative adjustment curve such as agent-to-agent interaction cadence before cooling further. The compact hot path carries only the reset fields hosts need to act: `reset_policy.reset_token`, `host_state_key`, -`codex_app_initial_interval_minutes`, `codex_app_initial_rrule`, and the short +`app_automation_initial_interval_minutes`, `app_automation_initial_rrule`, and the short `identity_signature`. Hosts should cache and compare `reset_token` across unchanged polls and reset the unchanged streak whenever the token changes. The token is derived from scheduler action plus the current identity/profile inputs; @@ -1140,13 +1140,13 @@ stateful-backoff policy live in `scheduler_hint.cold_path_detail` when callers request `loopx quota should-run --include-detail scheduler`. Hosts should also reset when an external event makes the goal actionable again, such as user feedback in the thread, a new or reassigned todo, a resolved gate, or material -evidence transition. A reset applies `codex_app_initial_interval_minutes` (and +evidence transition. A reset applies `app_automation_initial_interval_minutes` (and the matching local scheduler initial interval) before starting unchanged backoff again; it never spends quota. For Codex App heartbeats, hosts and agents should use `automation_update` only -when `codex_app.stateful_backoff.apply_needed=true` and -`codex_app.recommended_rrule` is present. After `automation_update` succeeds, -the agent must run `codex_app.ack_hint.cli_args`. Current payloads use +when `app_automation.stateful_backoff.apply_needed=true` and +`app_automation.recommended_rrule` is present. After `automation_update` succeeds, +the agent must run `app_automation.ack_hint.cli_args`. Current payloads use `quota scheduler-ack-current`, so LoopX then persists `reset_token`, `identity_signature`, `progression_index`, and `last_applied_rrule` under the runtime root. Repeated unchanged identity @@ -1160,7 +1160,7 @@ quota state. If `apply_needed=false` and `ack_needed=true`, the same command records an exact matching host readback without calling `automation_update`. If `automation_update` fails or times out, the agent must not ACK. LoopX keeps the observed host RRULE authoritative. The agent runs -`codex_app.failure_hint.cli_args` once to persist the failed target/observed-host +`app_automation.failure_hint.cli_args` once to persist the failed target/observed-host pair without quota spend. LoopX retains up to four distinct pairs for 24 hours, so active-work and monitor-wait targets cannot overwrite one another while the host RRULE remains unchanged. Later heartbeats expose `apply_needed=false` and diff --git a/docs/reference/contracts/dashboard-budget-governance-contract.md b/docs/reference/contracts/dashboard-budget-governance-contract.md index 1471bd01b3..83177a639d 100644 --- a/docs/reference/contracts/dashboard-budget-governance-contract.md +++ b/docs/reference/contracts/dashboard-budget-governance-contract.md @@ -12,7 +12,7 @@ creating a browser-side source of truth. | Operator concept | Source fields | Meaning in the dashboard | | --- | --- | --- | | Budget | `quota.compute`, `quota.allowed_slots`, `quota.spent_slots`, `quota.state` | How much automatic agent time this goal may consume in the current quota window, and whether it can run now. | -| Cadence | `scheduler_hint.codex_app`, `scheduler_hint.unchanged_poll`, `scheduler_hint.reset_policy`; opt-in cold detail from `scheduler_hint.cold_path_detail.local_scheduler` | How often the host should wake the agent, when backoff applies, and when user feedback or new work resets the interval. | +| Cadence | `scheduler_hint.app_automation`, `scheduler_hint.unchanged_poll`, `scheduler_hint.reset_policy`; opt-in cold detail from `scheduler_hint.cold_path_detail.local_scheduler` | How often the host should wake the agent, when backoff applies, and when user feedback or new work resets the interval. | | Spend rule | `interaction_contract.cli_channel.spend_policy`, `scheduler_hint.unchanged_poll.spend_policy`, `work_lane_contract` | Which transitions spend quota and which lifecycle checks are no-spend. | | Human controls | user todos, operator gates, `local_dashboard_api`, future control-plane dry-run/apply paths | What a human can approve, pause, override, or resume, and whether the browser is allowed to preview or apply a change. | | Evidence | todo ids, run ids, quota spend events, compact artifacts, source warnings | Why the dashboard believes the current budget/governance state and where to audit it. | diff --git a/docs/reference/contracts/interface-budget-contract.md b/docs/reference/contracts/interface-budget-contract.md index bcb3c83f5f..c4fb1c7729 100644 --- a/docs/reference/contracts/interface-budget-contract.md +++ b/docs/reference/contracts/interface-budget-contract.md @@ -10,7 +10,7 @@ and size/count budgets. | --- | --- | --- | --- | --- | --- | --- | | `heartbeat_prompt_json` | heartbeat automation | wake and route one bounded turn | `quota should-run`, `status`, or `review-packet --handoff-only` | `json_chars <= 4800` plus `interface_budget.within_budget=true` | `nested_keys <= 40` | `top_level_keys <= 30` | | `review_packet_handoff_only_json` | project-agent handoff | forward the smallest sufficient task packet | full `review-packet` or run-history artifact | `json_chars <= 3000` plus `handoff_interface_budget.within_budget=true` | `nested_keys <= 40` | `top_level_keys <= 18` | -| `quota_should_run_json` | quota guard | decide whether the selected goal may spend compute | `status`, `history`, or active state | `json_chars <= 13000` | `nested_keys <= 330` | `top_level_keys <= 52` | +| `quota_should_run_json` | quota guard | decide whether the selected goal may spend compute | `status`, `history`, or active state | `json_chars <= 14000` | `nested_keys <= 350` | `top_level_keys <= 52` | | `dashboard_status_json` | operator dashboard | render first-screen operator state | `history`, run artifacts, or project-local adapter output | `json_chars <= 19500` | `nested_keys <= 260` | `top_level_keys <= 25` | These four budgets measure compact machine payloads. For diff --git a/docs/reference/effect-interpreter-packet.md b/docs/reference/effect-interpreter-packet.md index b256157ce3..fd25eca131 100644 --- a/docs/reference/effect-interpreter-packet.md +++ b/docs/reference/effect-interpreter-packet.md @@ -142,8 +142,8 @@ The observation points back into the loop: | `execution_mode` | Execution strategy (`serial` / `parallel` / `interleaved`) for an ordered effect program | | `scheduler_hint.action` | Scheduler around decision | | `scheduler_hint.cadence_class` | Cadence for the next host wake | -| `scheduler_hint.codex_app.ack_hint.cli_args` | Host ACK effect | -| `scheduler_hint.codex_app.failure_hint.cli_args` | Host failure effect | +| `scheduler_hint.app_automation.ack_hint.cli_args` | Host ACK effect | +| `scheduler_hint.app_automation.failure_hint.cli_args` | Host failure effect | `EffectTurn.next_effect` is the code lens for this slot. It keeps the data-encoded handler visible: the host invokes the CLI actions and settles diff --git a/docs/reference/protocols/host-integration-plugin-plan-v0.md b/docs/reference/protocols/host-integration-plugin-plan-v0.md index 25470bef3f..511b17010b 100644 --- a/docs/reference/protocols/host-integration-plugin-plan-v0.md +++ b/docs/reference/protocols/host-integration-plugin-plan-v0.md @@ -38,7 +38,7 @@ The eventual host plugin should be small and explicit: | Lifecycle reads | Surface status, quota, review packet, and command-pack output as compact host packets. | CLI JSON from `status`, `quota should-run`, `review-packet`, and `bootstrap-command-pack`. | | Controlled writes | Offer only CLI-equivalent todo/gate/reward/refresh/spend operations, with dry-run when required. | LoopX CLI commands and active-state/event ledger writes. | | Automation install | Create or refresh the host heartbeat using `heartbeat-prompt --thin` and scoped agent identity. | Generated heartbeat prompt and registry coordination fields. | -| Scheduler adapter | Apply `scheduler_hint.codex_app.recommended_rrule` through `automation_update` only when `stateful_backoff.apply_needed=true`, then run `codex_app.ack_hint.cli_args`; when only `ack_needed=true`, skip the host write and run the bound ack directly. | `quota should-run.scheduler_hint`, `quota scheduler-ack-current`. | +| Scheduler adapter | Apply `scheduler_hint.app_automation.recommended_rrule` through `automation_update` only when `stateful_backoff.apply_needed=true`, then run `app_automation.ack_hint.cli_args`; when only `ack_needed=true`, skip the host write and run the bound ack directly. | `quota should-run.scheduler_hint`, `quota scheduler-ack-current`. | | Privacy guard | Redact local paths and reject raw transcript/session-file/credential payloads. | Public/private boundary plus host projection boundary checks. | ## Phased Path @@ -105,10 +105,10 @@ Exit criteria: The host applies `quota should-run.scheduler_hint` after each heartbeat result: - `run_now` restores or keeps active cadence. -- wait/backoff states expose `codex_app.recommended_rrule` only when host update +- wait/backoff states expose `app_automation.recommended_rrule` only when host update work is needed. -- `codex_app.stateful_backoff.apply_needed=true` means call `automation_update` - for that RRULE; after success, run `codex_app.ack_hint.cli_args` so LoopX +- `app_automation.stateful_backoff.apply_needed=true` means call `automation_update` + for that RRULE; after success, run `app_automation.ack_hint.cli_args` so LoopX persists reset token, identity signature, progression index, and last applied RRULE. diff --git a/docs/reference/protocols/loopx-goal-command-v0.md b/docs/reference/protocols/loopx-goal-command-v0.md index 0712504a4a..0663a506f3 100644 --- a/docs/reference/protocols/loopx-goal-command-v0.md +++ b/docs/reference/protocols/loopx-goal-command-v0.md @@ -23,6 +23,13 @@ When the user provides text after `/loopx`, the host should: 6. Activate the host loop if it is missing, unknown, or stale: - `codex-app`: create or update the Codex App heartbeat automation from the generated `heartbeat-prompt` task body. + - `trae_app`: create or update the Trae App heartbeat automation from the + generated `heartbeat-prompt` task body. The `trae_app` runtime + profile preserves Trae host identity while consuming the provider-neutral + `scheduler_hint.app_automation` cadence, ACK, and terminal-stop contract. + Its packet carries `host_surface=trae_app` and never requires a Codex + automation-store fallback. A settled non-terminal turn keeps + the automation active so the next wake can select the successor. - `codex-app-ssh`: when Codex App is attached to a remote workspace over SSH and host automation tools are unavailable, set the current visible task to `/goal ` using the generated `codex_app_ssh_goal` profile. After @@ -44,9 +51,9 @@ When the user provides text after `/loopx`, the host should: `generic_cli` runtime profile. TraeX `/goal` requires `[features] goals = true` in `~/.trae/traecli.toml`; if goal mode is off, show the pasteable `/goal ` gate. Do not route to `/loop` - unless a verified LoopX adapter is installed. LoopX ships no Codex App - automation and no slash-command installer for TraeX; it loads skills - from `~/.trae/skills`. + unless a verified LoopX adapter is installed. This CLI surface does not + create an App automation and has no slash-command installer; TraeX loads + skills from `~/.trae/skills`. - `pi`: call `loopx_goal_activate` from the installed LoopX Pi extension; the extension gates settled continuations and timer wakes through `quota should-run` and stops only on validated terminal no-follow-up. @@ -66,6 +73,8 @@ loopx agent-onboard --list-agent-types Ambiguous values such as `codex` must fail closed because Codex App automation, Codex App over SSH, the IDE plugin, and Codex CLI use different host-loop activation paths. +Likewise, callers must choose `trae_app` for Trae App heartbeat automation or +`traex-cli` for the existing visible `/goal` loop. Codex App SSH, Codex CLI/IDE, and Ark Managed Agent form one native Goal host family. They share the stable `loopx_goal_prompt_v0` body, the 4,000-character diff --git a/docs/status-data-contract.md b/docs/status-data-contract.md index 8f6b8cc37c..af617f6960 100644 --- a/docs/status-data-contract.md +++ b/docs/status-data-contract.md @@ -1549,9 +1549,9 @@ available from `scheduler_hint.cold_path_detail` when callers request App/local cadence back to the current profile's initial interval before unchanged backoff resumes, and does not spend quota. Codex App heartbeats should use `automation_update` only when -`codex_app.stateful_backoff.apply_needed=true` and -`codex_app.recommended_rrule` is present. If that update succeeds, the agent -must run `codex_app.ack_hint.cli_args`; +`app_automation.stateful_backoff.apply_needed=true` and +`app_automation.recommended_rrule` is present. If that update succeeds, the agent +must run `app_automation.ack_hint.cli_args`; current payloads use `quota scheduler-ack-current` so LoopX re-reads the latest hint, then persists `reset_token`, `identity_signature`, `progression_index`, and `last_applied_rrule` under the runtime root. When the same identity repeats, @@ -1559,7 +1559,7 @@ LoopX advances the progression after the applied interval has elapsed, until the max interval. An immediate post-ACK readback remains on the acknowledged RRULE so repeated reconciliation converges rather than oscillates. When the reset token changes, the next projected RRULE returns to -`reset_policy.codex_app_initial_rrule`. If the current desired RRULE is already +`reset_policy.app_automation_initial_rrule`. If the current desired RRULE is already applied, `recommended_rrule` is omitted and the host update should be skipped. When that matching readback still needs a reset-token/identity binding, `ack_needed=true`; run the bound ack directly. Otherwise no scheduler action diff --git a/examples/agent-diagnose-packet-smoke.py b/examples/agent-diagnose-packet-smoke.py index d21b4afc35..4c15da01aa 100644 --- a/examples/agent-diagnose-packet-smoke.py +++ b/examples/agent-diagnose-packet-smoke.py @@ -13,7 +13,12 @@ REPO_ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(REPO_ROOT)) -from loopx.diagnose import _first_agent_todo_text, render_diagnosis_markdown # noqa: E402 +from loopx.diagnose import ( # noqa: E402 + _compact_scheduler_hint, + _first_agent_todo_text, + _scheduler_hint_line, + render_diagnosis_markdown, +) GOAL_ID = "diagnose-smoke-goal" SCOPED_GOAL_ID = "diagnose-smoke-agent-scoped" @@ -65,6 +70,26 @@ def assert_selected_agent_todo_preferred() -> None: ) +def assert_diagnose_preserves_app_scheduler_identity() -> None: + legacy = { + "codex_app": {"apply": "none"}, + "reset_policy": {"codex_app_initial_rrule": "FREQ=MINUTELY;INTERVAL=3"}, + } + trae = { + "app_automation": {"apply": "none", "host_surface": "trae_app"}, + "reset_policy": { + "app_automation_initial_rrule": "FREQ=MINUTELY;INTERVAL=3" + }, + } + + legacy_compact = _compact_scheduler_hint(legacy) + trae_compact = _compact_scheduler_hint(trae) + assert "codex_app" in legacy_compact and "app_automation" not in legacy_compact + assert "app_automation" in trae_compact and "codex_app" not in trae_compact + assert "codex_app_apply=none" in (_scheduler_hint_line(legacy) or "") + assert "app_automation_apply=none" in (_scheduler_hint_line(trae) or "") + + def assert_diagnose_markdown_separates_status_and_packet_goal_counts() -> None: markdown = render_diagnosis_markdown( { @@ -284,6 +309,7 @@ def write_capability_scoped_registry(root: Path, runtime: Path) -> Path: def main() -> int: assert_selected_agent_todo_preferred() + assert_diagnose_preserves_app_scheduler_identity() assert_diagnose_markdown_separates_status_and_packet_goal_counts() with tempfile.TemporaryDirectory(prefix="loopx-agent-diagnose-smoke-") as tmp: root = Path(tmp) diff --git a/examples/control_plane/agent-onboard-host-loop-activation-smoke.py b/examples/control_plane/agent-onboard-host-loop-activation-smoke.py index 6d262f3d87..58cecb0326 100644 --- a/examples/control_plane/agent-onboard-host-loop-activation-smoke.py +++ b/examples/control_plane/agent-onboard-host-loop-activation-smoke.py @@ -23,6 +23,9 @@ ) from loopx.cli import build_parser # noqa: E402 from loopx.control_plane.quota.usage_summary import is_automation_run # noqa: E402 +from loopx.control_plane.scheduler.automation_liveness import ( # noqa: E402 + build_automation_liveness, +) from loopx.control_plane.scheduler.execution_context import ( # noqa: E402 scheduler_execution_context_for_runtime_profile, ) @@ -72,6 +75,7 @@ def main() -> int: agent_types = {item["agent_type"] for item in catalog["canonical_agent_types"]} assert { "codex-app", + "trae_app", "codex-app-ssh", "codex-ide-plugin", "codex-cli", @@ -91,6 +95,7 @@ def main() -> int: ], ambiguous assert agent_type_for_host_surface("chat-box") == "codex-app" + assert agent_type_for_host_surface("trae_app") == "trae_app" assert agent_type_for_host_surface("codex-app-ssh") == "codex-app-ssh" assert agent_type_for_host_surface("codex-ide-plugin") == "codex-ide-plugin" assert agent_type_for_host_surface("codex-ide") == "codex-ide-plugin" @@ -105,6 +110,7 @@ def main() -> int: assert agent_type_for_host_surface("dsh") == "deepseek-harness" codex_app = build_host_loop_activation_packet(agent_type="codex-app", goal_id="demo") + trae_app = build_host_loop_activation_packet(agent_type="trae_app", goal_id="demo") codex_app_ssh = build_host_loop_activation_packet( agent_type="codex-app-ssh", goal_id="demo", @@ -121,6 +127,24 @@ def main() -> int: traex_cli = build_host_loop_activation_packet(agent_type="traex-cli", goal_id="demo") dsh = build_host_loop_activation_packet(agent_type="deepseek-harness", goal_id="demo") assert codex_app["activation_method"] == "create_or_update_codex_app_automation", codex_app + assert trae_app["activation_method"] == "create_or_update_trae_app_automation", trae_app + assert trae_app["host_mutation"]["preferred_tool"] == "automation_update", trae_app + assert "--trae_app" in trae_app["commands"]["heartbeat_prompt"], trae_app + trae_scheduler = scheduler_execution_context_for_runtime_profile( + "trae_app" + ) + assert trae_scheduler.ok, trae_scheduler + assert trae_scheduler.projection()["host_surface"] == "trae_app" + assert trae_scheduler.projection()["scheduler_owner"] == "host_automation" + settled = build_automation_liveness( + { + "effective_action": "heartbeat_settled_skip", + "heartbeat_recommendation": {}, + "execution_obligation": {"must_attempt_work": False}, + } + ) + assert settled["keep_active"] is True, settled + assert settled["next_trigger"] == "next heartbeat turn with a fresh turn identity" assert codex_app_ssh["activation_method"] == "set_visible_goal", codex_app_ssh assert codex_app_ssh["host_surface"] == "codex_app_ssh_visible_goal_mode", codex_app_ssh assert any( diff --git a/examples/control_plane/heartbeat-prompt-smoke.py b/examples/control_plane/heartbeat-prompt-smoke.py index 27f0dd9fa0..71167f43a2 100644 --- a/examples/control_plane/heartbeat-prompt-smoke.py +++ b/examples/control_plane/heartbeat-prompt-smoke.py @@ -594,7 +594,7 @@ def main() -> int: "具体user todo未投影", "Observed capabilities -> `--available-capability`; never user gates", "host_action=pause_or_delete_current_heartbeat->automation_update stop(no-spend)", - "else RRULE/fallback_hint/ack/fail", + "else RRULE/projected-fallback_hint/ack/fail", "no-change=`surface_only`/no spend", "unchanged->`--vision-unchanged-reason`", "guard; 2 stalls->replan", @@ -696,7 +696,7 @@ def main() -> int: "NOTIFY缺动作→", "具体user todo未投影", "host_action=pause_or_delete_current_heartbeat->automation_update stop(no-spend)", - "else RRULE/fallback_hint/ack/fail", + "else RRULE/projected-fallback_hint/ack/fail", "no-change=`surface_only`/no spend", "unchanged->`--vision-unchanged-reason`", "guard; 2 stalls->replan", @@ -1015,13 +1015,13 @@ def main() -> int: assert "Create a heartbeat automation starting at 3 minutes" in doc, doc assert "quota should-run.scheduler_hint" in doc, doc assert "automation_update" in doc, doc - assert "scheduler_hint.codex_app.stateful_backoff" in doc, doc + assert "scheduler_hint.app_automation.stateful_backoff" in doc, doc assert "scheduler_hint.action=stop_until_explicit_resume" in doc, doc assert "host_action=pause_or_delete_current_heartbeat" in doc, doc assert "apply_needed=true" in doc, doc - assert "codex_app.ack_hint.cli_args" in doc, doc + assert "app_automation.ack_hint.cli_args" in doc, doc assert "quota scheduler-ack-current" in doc, doc - assert "scheduler_hint.codex_app.failure_hint.cli_args" in doc, doc + assert "scheduler_hint.app_automation.failure_hint.cli_args" in doc, doc assert "recommended_rrule" in doc, doc normalized_doc = normalized(doc) assert "Attempt the host update at most once per hint and turn" in normalized_doc, doc @@ -1082,13 +1082,13 @@ def main() -> int: assert "execution_obligation" in project_skill, project_skill assert "scheduler_hint" in project_skill, project_skill assert "automation_update" in project_skill, project_skill - assert "scheduler_hint.codex_app.stateful_backoff" in project_skill, project_skill + assert "scheduler_hint.app_automation.stateful_backoff" in project_skill, project_skill assert "scheduler_hint.action=stop_until_explicit_resume" in project_skill, project_skill assert "host_action=pause_or_delete_current_heartbeat" in project_skill, project_skill assert "apply_needed=true" in project_skill, project_skill - assert "codex_app.ack_hint.cli_args" in project_skill, project_skill + assert "app_automation.ack_hint.cli_args" in project_skill, project_skill assert "quota scheduler-ack-current" in project_skill, project_skill - assert "scheduler_hint.codex_app.failure_hint.cli_args" in project_skill, project_skill + assert "scheduler_hint.app_automation.failure_hint.cli_args" in project_skill, project_skill assert "recommended_rrule" in project_skill, project_skill normalized_project_skill = normalized(project_skill) assert "Attempt the host update at most once per hint and turn" in normalized_project_skill, project_skill diff --git a/examples/control_plane/heartbeat-quota-flow-smoke.py b/examples/control_plane/heartbeat-quota-flow-smoke.py index f4366d2f7e..906b429bed 100644 --- a/examples/control_plane/heartbeat-quota-flow-smoke.py +++ b/examples/control_plane/heartbeat-quota-flow-smoke.py @@ -806,7 +806,7 @@ def main() -> int: "FREQ=MINUTELY;INTERVAL=15" ), first_guard reset = first_guard["scheduler_hint"]["reset_policy"] - assert reset["codex_app_initial_rrule"] == "FREQ=MINUTELY;INTERVAL=15", reset + assert reset["app_automation_initial_rrule"] == "FREQ=MINUTELY;INTERVAL=15", reset assert "reset_condition_summary" not in reset, reset frontier = first_guard["goal_frontier_projection"] assert frontier["monitor_only_lanes"]["present"] is True, frontier diff --git a/examples/control_plane/hot-path-interface-budget-smoke.py b/examples/control_plane/hot-path-interface-budget-smoke.py index 5eb564a7fb..b06c1e22a3 100644 --- a/examples/control_plane/hot-path-interface-budget-smoke.py +++ b/examples/control_plane/hot-path-interface-budget-smoke.py @@ -68,8 +68,10 @@ "owner": "quota guard", "consumer": "decide whether the selected goal may spend compute", "cold_path": "status, history, or active state", - "max_json_chars": 13_000, - "max_nested_keys": 330, + # Codex keeps a lossless codex_app compatibility alias while the + # provider-neutral app_automation packet becomes canonical. + "max_json_chars": 14_000, + "max_nested_keys": 350, "max_top_level_keys": 52, }, "dashboard_status_json": { @@ -416,7 +418,7 @@ def main() -> int: assert quota_payload["should_run"] is True, quota_payload reset_policy = quota_payload["scheduler_hint"]["reset_policy"] assert reset_policy["reset_token"], reset_policy - assert reset_policy["codex_app_initial_rrule"], reset_policy + assert reset_policy["app_automation_initial_rrule"], reset_policy assert reset_policy["host_state_key"] == "scheduler_hint.reset_policy.reset_token", reset_policy assert "identity_snapshot" not in reset_policy, reset_policy assert "profile_snapshot" not in reset_policy, reset_policy diff --git a/examples/control_plane/quota-agent-scoped-user-gate-smoke.py b/examples/control_plane/quota-agent-scoped-user-gate-smoke.py index ad27dbf54c..fd21cdf41b 100644 --- a/examples/control_plane/quota-agent-scoped-user-gate-smoke.py +++ b/examples/control_plane/quota-agent-scoped-user-gate-smoke.py @@ -1022,8 +1022,8 @@ def assert_agent_without_advancement_candidate_and_only_monitor_work_stays_quiet reset = scheduler["reset_policy"] assert isinstance(reset["reset_token"], str) and len(reset["reset_token"]) == 16, reset assert reset["host_state_key"] == "scheduler_hint.reset_policy.reset_token", reset - assert reset["codex_app_initial_interval_minutes"] == 15, reset - assert reset["codex_app_initial_rrule"] == "FREQ=MINUTELY;INTERVAL=15", reset + assert reset["app_automation_initial_interval_minutes"] == 15, reset + assert reset["app_automation_initial_rrule"] == "FREQ=MINUTELY;INTERVAL=15", reset assert scheduler["codex_app"]["max_interval_minutes"] == 60, scheduler assert len(reset["identity_signature"]) == 12, reset assert "identity_snapshot" not in reset, reset diff --git a/examples/control_plane/quota-plan-smoke.py b/examples/control_plane/quota-plan-smoke.py index f2abd52ff5..c61a173d69 100644 --- a/examples/control_plane/quota-plan-smoke.py +++ b/examples/control_plane/quota-plan-smoke.py @@ -1033,8 +1033,8 @@ def assert_heartbeat_recommendation_lifecycle() -> None: assert isinstance(reset["reset_token"], str) and len(reset["reset_token"]) == 16, reset assert reset["reset_token"] == expected_scheduler_reset_token(scheduler, mapped_decision), reset assert reset["host_state_key"] == "scheduler_hint.reset_policy.reset_token", reset - assert reset["codex_app_initial_interval_minutes"] == 60, reset - assert reset["codex_app_initial_rrule"] == "FREQ=MINUTELY;INTERVAL=60", reset + assert reset["app_automation_initial_interval_minutes"] == 60, reset + assert reset["app_automation_initial_rrule"] == "FREQ=MINUTELY;INTERVAL=60", reset assert len(reset["identity_signature"]) == 12, reset assert "identity_snapshot" not in reset, reset assert "profile_snapshot" not in reset, reset @@ -1057,8 +1057,8 @@ def assert_heartbeat_recommendation_lifecycle() -> None: assert "heartbeat_recommendation: mode=mapped_noop_if_unchanged notify=DONT_NOTIFY" in mapped_markdown assert "heartbeat_stop_if_unchanged: `True`" in mapped_markdown, mapped_markdown assert "scheduler_hint: action=backoff_until_fresh_evidence" in mapped_markdown, mapped_markdown - assert "codex_app_rrule=FREQ=MINUTELY;INTERVAL=60" in mapped_markdown, mapped_markdown - assert "codex_app_progression=[60]" in mapped_markdown, mapped_markdown + assert "app_automation_rrule=FREQ=MINUTELY;INTERVAL=60" in mapped_markdown, mapped_markdown + assert "app_automation_progression=[60]" in mapped_markdown, mapped_markdown assert "scheduler_reset: initial_interval=60" in mapped_markdown, mapped_markdown assert "initial_rrule=FREQ=MINUTELY;INTERVAL=60" in mapped_markdown, mapped_markdown assert "reset_generation=" in mapped_markdown, mapped_markdown diff --git a/examples/control_plane/quota-scheduler-hint-compaction-smoke.py b/examples/control_plane/quota-scheduler-hint-compaction-smoke.py index 4eaa66d696..91b36f2b36 100644 --- a/examples/control_plane/quota-scheduler-hint-compaction-smoke.py +++ b/examples/control_plane/quota-scheduler-hint-compaction-smoke.py @@ -4,12 +4,14 @@ from __future__ import annotations from copy import deepcopy +from datetime import datetime, timezone import importlib.util import json from pathlib import Path import subprocess import sys import tempfile +from unittest.mock import patch REPO_ROOT = Path(__file__).resolve().parents[2] @@ -99,11 +101,16 @@ def scheduler_host_fact_chunks(args: list[str]) -> list[str]: chunks.append(chunk) index += 1 continue - assert token == SCHEDULER_HOST_FACTS_CHUNK_FLAG, args + if token == SCHEDULER_HOST_FACTS_CHUNK_FLAG: + assert index + 1 < len(args), args + chunk = args[index + 1] + assert chunk and not chunk.startswith("-"), args + chunks.append(chunk) + index += 2 + continue + assert token in {"--surface", "--state-key"}, args assert index + 1 < len(args), args - chunk = args[index + 1] - assert chunk and not chunk.startswith("-"), args - chunks.append(chunk) + assert args[index + 1] and not args[index + 1].startswith("-"), args index += 2 return chunks @@ -117,32 +124,32 @@ def assert_compact_runtime_policy_complete( expected_registry_path: Path | None = None, expected_runtime_root: Path | None = None, ) -> None: - codex_app = compact["codex_app"] + app_automation = compact["app_automation"] unchanged_poll = compact["unchanged_poll"] - stateful_backoff = codex_app["stateful_backoff"] - ack_hint = codex_app["ack_hint"] - failure_hint = codex_app["failure_hint"] + stateful_backoff = app_automation["stateful_backoff"] + ack_hint = app_automation["ack_hint"] + failure_hint = app_automation["failure_hint"] ack_args = ack_hint["args"] ack_cli_args = ack_hint["cli_args"] - assert codex_app["recommended_interval_minutes"], (name, compact) - assert codex_app["recommended_rrule"], (name, compact) - assert codex_app["max_interval_minutes"], (name, compact) - assert isinstance(codex_app["example_progression_minutes"], list), (name, compact) - assert codex_app["host_tool"] == "automation_update", (name, compact) - assert codex_app["host_action"] == "update_current_heartbeat_rrule", (name, compact) - assert "automation_update" in codex_app["host_action_contract"], (name, compact) - assert codex_app["rrule_source"] == "scheduler_hint.codex_app.recommended_rrule", (name, compact) - assert stateful_backoff["schema_version"] == "codex_app_stateful_backoff_v0", (name, compact) - assert stateful_backoff["state_key"] == "scheduler_hint.codex_app.stateful_backoff", (name, compact) + assert app_automation["recommended_interval_minutes"], (name, compact) + assert app_automation["recommended_rrule"], (name, compact) + assert app_automation["max_interval_minutes"], (name, compact) + assert isinstance(app_automation["example_progression_minutes"], list), (name, compact) + assert app_automation["host_tool"] == "automation_update", (name, compact) + assert app_automation["host_action"] == "update_current_heartbeat_rrule", (name, compact) + assert "automation_update" in app_automation["host_action_contract"], (name, compact) + assert app_automation["rrule_source"] == "scheduler_hint.app_automation.recommended_rrule", (name, compact) + assert stateful_backoff["schema_version"] == "app_automation_stateful_backoff_v0", (name, compact) + assert stateful_backoff["state_key"] == "scheduler_hint.app_automation.stateful_backoff", (name, compact) assert stateful_backoff["identity_signature"] == compact["reset_policy"]["identity_signature"], ( name, compact, ) assert stateful_backoff["reset_token"] == compact["reset_policy"]["reset_token"], (name, compact) assert stateful_backoff["apply_needed"] is True, (name, compact) - assert stateful_backoff["current_rrule"] == codex_app["recommended_rrule"], (name, compact) + assert stateful_backoff["current_rrule"] == app_automation["recommended_rrule"], (name, compact) assert stateful_backoff["state_status"] == "missing", (name, compact) - assert ack_hint["schema_version"] == "codex_app_scheduler_ack_hint_v0", (name, compact) + assert ack_hint["schema_version"] == "app_automation_scheduler_ack_hint_v0", (name, compact) assert ack_hint["after"] == "automation_update_rrule_success", (name, compact) assert ack_hint["command"] == "quota scheduler-ack-current", (name, compact) assert ack_hint["execute"] is True, (name, compact) @@ -152,7 +159,7 @@ def assert_compact_runtime_policy_complete( assert ack_args["agent_id"] == expected_agent_id, (name, compact) assert ack_args["surface"] == "codex_app", (name, compact) assert ack_args["state_key"] == stateful_backoff["state_key"], (name, compact) - assert ack_args["applied_rrule"] == codex_app["recommended_rrule"], (name, compact) + assert ack_args["applied_rrule"] == app_automation["recommended_rrule"], (name, compact) assert ack_args["reset_token"] == stateful_backoff["reset_token"], (name, compact) assert ack_args["identity_signature"] == stateful_backoff["identity_signature"], (name, compact) assert ack_args["host_match_observed"] is True, (name, compact) @@ -197,7 +204,7 @@ def assert_compact_runtime_policy_complete( host_fact_args = ack_cli_args[len(expected_cli_prefix) : -len(expected_cli_suffix)] assert scheduler_host_fact_chunks(host_fact_args), (name, compact) failure_cli_args = failure_hint["cli_args"] - assert failure_hint["schema_version"] == "codex_app_scheduler_failure_hint_v0", ( + assert failure_hint["schema_version"] == "app_automation_scheduler_failure_hint_v0", ( name, compact, ) @@ -250,11 +257,11 @@ def assert_compact_runtime_policy_complete( assert "final_quota_replan_check_action" in unchanged_poll, (name, compact) assert unchanged_poll["spend_policy"], (name, compact) assert compact["reset_policy"]["reset_token"], (name, compact) - assert compact["reset_policy"]["codex_app_initial_rrule"], (name, compact) + assert compact["reset_policy"]["app_automation_initial_rrule"], (name, compact) for omitted in ( "schema_version", - "codex_app_tool", - "codex_app_apply", + "app_automation_tool", + "app_automation_apply", "profile_signature", "identity_key_count", "reset_condition_summary", @@ -263,28 +270,33 @@ def assert_compact_runtime_policy_complete( detail_ref = compact["detail_ref"] assert detail_ref["omitted_by_default"] is True, (name, compact) assert detail_ref["execution_required"] is False, (name, compact) - assert detail_ref["hot_path_runtime_fields"] == ["codex_app", "unchanged_poll", "reset_policy"], ( + assert detail_ref["hot_path_runtime_fields"] == ["app_automation", "unchanged_poll", "reset_policy"], ( name, compact, ) def assert_compact_scheduler(name: str, source_payload: dict) -> None: - compact = build_scheduler_hint( - deepcopy(source_payload), - user_action_required=False, - scheduler_execution_context=APP_SCHEDULER_CONTEXT, - ) - wrapper = _scheduler_hint( - deepcopy(source_payload), - scheduler_execution_context=APP_SCHEDULER_CONTEXT, - ) - detailed = build_scheduler_hint( - deepcopy(source_payload), - user_action_required=False, - include_detail=True, - scheduler_execution_context=APP_SCHEDULER_CONTEXT, - ) + fixed_now = datetime(2026, 9, 12, 0, 0, tzinfo=timezone.utc) + with patch( + "loopx.control_plane.scheduler.scheduler_hint.now_utc", + return_value=fixed_now, + ): + compact = build_scheduler_hint( + deepcopy(source_payload), + user_action_required=False, + scheduler_execution_context=APP_SCHEDULER_CONTEXT, + ) + wrapper = _scheduler_hint( + deepcopy(source_payload), + scheduler_execution_context=APP_SCHEDULER_CONTEXT, + ) + detailed = build_scheduler_hint( + deepcopy(source_payload), + user_action_required=False, + include_detail=True, + scheduler_execution_context=APP_SCHEDULER_CONTEXT, + ) assert compact == wrapper, (name, compact, wrapper) assert compact["schema_version"] == "scheduler_hint_v0", (name, compact) @@ -302,7 +314,7 @@ def assert_compact_scheduler(name: str, source_payload: dict) -> None: expected_agent_id=source_payload["agent_identity"]["agent_id"], ) assert compact["reset_policy"]["reset_token"], (name, compact) - assert compact["reset_policy"]["codex_app_initial_rrule"] == compact["codex_app"]["recommended_rrule"], ( + assert compact["reset_policy"]["app_automation_initial_rrule"] == compact["app_automation"]["recommended_rrule"], ( name, compact, ) @@ -324,7 +336,7 @@ def assert_compact_scheduler(name: str, source_payload: dict) -> None: ) assert cold_path["claude_code_loop"]["after_limit"], (name, detailed) stateful_detail = cold_path["stateful_backoff_detail"] - assert stateful_detail["progression_minutes"] == compact["codex_app"]["example_progression_minutes"], ( + assert stateful_detail["progression_minutes"] == compact["app_automation"]["example_progression_minutes"], ( name, detailed, ) @@ -340,8 +352,8 @@ def assert_compact_scheduler(name: str, source_payload: dict) -> None: ) reset_detail = cold_path["reset_policy_detail"] assert reset_detail["schema_version"] == "scheduler_reset_policy_v0", (name, detailed) - assert reset_detail["codex_app_tool"] == "automation_update", (name, detailed) - assert "automation_update" in reset_detail["codex_app_apply"], (name, detailed) + assert reset_detail["app_automation_tool"] == "automation_update", (name, detailed) + assert "automation_update" in reset_detail["app_automation_apply"], (name, detailed) assert len(reset_detail["profile_signature"]) == 12, (name, detailed) assert json_size(compact) < json_size(detailed), (name, json_size(compact), json_size(detailed)) # Native scheduler follow-up embeds bounded host-fact chunks in the ack and @@ -440,9 +452,9 @@ def assert_cli_compact_and_detail_contract() -> None: assert detailed["cold_path_detail"]["claude_code_loop"]["after_limit"] == ( compact["unchanged_poll"]["after_limits"]["claude_code_loop"] ), detailed - assert detailed["cold_path_detail"]["reset_policy_detail"]["codex_app_tool"] == "automation_update", detailed + assert detailed["cold_path_detail"]["reset_policy_detail"]["app_automation_tool"] == "automation_update", detailed assert detailed["cold_path_detail"]["stateful_backoff_detail"]["progression_minutes"] == ( - compact["codex_app"]["example_progression_minutes"] + compact["app_automation"]["example_progression_minutes"] ), detailed diff --git a/examples/control_plane/quota-scheduler-policy-smoke.py b/examples/control_plane/quota-scheduler-policy-smoke.py index 204a4dde22..88edfe85c6 100644 --- a/examples/control_plane/quota-scheduler-policy-smoke.py +++ b/examples/control_plane/quota-scheduler-policy-smoke.py @@ -114,21 +114,21 @@ def assert_policy_case( assert extracted["schema_version"] == "scheduler_hint_v0", (name, extracted) assert extracted["source"] == "quota.should-run", (name, extracted) assert extracted["action"] == expected_action, (name, extracted) - assert extracted["codex_app"]["recommended_rrule"] == expected_rrule, (name, extracted) - assert extracted["codex_app"]["host_tool"] == "automation_update", (name, extracted) - assert extracted["codex_app"]["host_action"] == "update_current_heartbeat_rrule", (name, extracted) - assert extracted["codex_app"]["rrule_source"] == "scheduler_hint.codex_app.recommended_rrule", ( + assert extracted["app_automation"]["recommended_rrule"] == expected_rrule, (name, extracted) + assert extracted["app_automation"]["host_tool"] == "automation_update", (name, extracted) + assert extracted["app_automation"]["host_action"] == "update_current_heartbeat_rrule", (name, extracted) + assert extracted["app_automation"]["rrule_source"] == "scheduler_hint.app_automation.recommended_rrule", ( name, extracted, ) - stateful_backoff = extracted["codex_app"]["stateful_backoff"] + stateful_backoff = extracted["app_automation"]["stateful_backoff"] if expected_progression is not None: - assert extracted["codex_app"]["example_progression_minutes"] == expected_progression, ( + assert extracted["app_automation"]["example_progression_minutes"] == expected_progression, ( name, extracted, ) - assert stateful_backoff["schema_version"] == "codex_app_stateful_backoff_v0", (name, extracted) - assert stateful_backoff["state_key"] == "scheduler_hint.codex_app.stateful_backoff", (name, extracted) + assert stateful_backoff["schema_version"] == "app_automation_stateful_backoff_v0", (name, extracted) + assert stateful_backoff["state_key"] == "scheduler_hint.app_automation.stateful_backoff", (name, extracted) assert stateful_backoff["apply_needed"] is True, (name, extracted) assert stateful_backoff["current_rrule"] == expected_rrule, (name, extracted) assert stateful_backoff["state_status"] == "missing", (name, extracted) @@ -170,8 +170,8 @@ def assert_policy_case( assert len(reset["identity_signature"]) == 12, (name, reset) reset_detail = detailed["cold_path_detail"]["reset_policy_detail"] assert reset_detail["schema_version"] == "scheduler_reset_policy_v0", (name, reset_detail) - assert reset_detail["codex_app_tool"] == "automation_update", (name, reset_detail) - assert "automation_update" in reset_detail["codex_app_apply"], (name, reset_detail) + assert reset_detail["app_automation_tool"] == "automation_update", (name, reset_detail) + assert "automation_update" in reset_detail["app_automation_apply"], (name, reset_detail) assert len(reset_detail["profile_signature"]) == 12, (name, reset_detail) assert stateful_backoff["reset_token"] == reset["reset_token"], (name, reset) assert stateful_backoff["identity_signature"] == reset["identity_signature"], (name, reset) diff --git a/examples/control_plane/status-quota-review-packet-parity-smoke.py b/examples/control_plane/status-quota-review-packet-parity-smoke.py index 28c2c9263f..ffa989d09a 100644 --- a/examples/control_plane/status-quota-review-packet-parity-smoke.py +++ b/examples/control_plane/status-quota-review-packet-parity-smoke.py @@ -173,7 +173,7 @@ def assert_quota_parity(payload: dict) -> None: assert codex_app["recommended_rrule"] == "FREQ=MINUTELY;INTERVAL=3", scheduler assert codex_app["recommended_interval_minutes"] == 3, scheduler assert codex_app["no_spend_for_cadence_change"] is True, scheduler - assert scheduler["reset_policy"]["codex_app_initial_rrule"] == "FREQ=MINUTELY;INTERVAL=3", scheduler + assert scheduler["reset_policy"]["app_automation_initial_rrule"] == "FREQ=MINUTELY;INTERVAL=3", scheduler def assert_handoff_parity(payload: dict) -> None: diff --git a/loopx/agent_onboarding.py b/loopx/agent_onboarding.py index 659914b742..a861db2fd7 100644 --- a/loopx/agent_onboarding.py +++ b/loopx/agent_onboarding.py @@ -282,6 +282,7 @@ def _bootstrap_pack_command( ) -> str: surface_by_type = { "codex-app": "codex-app", + "trae_app": "trae_app", "codex-app-ssh": "codex-app-ssh", "codex-ide-plugin": "codex-ide-plugin", "codex-cli": "codex-cli-tui", @@ -322,6 +323,8 @@ def _bootstrap_pack_command( def _start_instruction(agent_type: str) -> str: if agent_type == "codex-app": return "Use `$loopx ` or select the LoopX skill from `/skills`; Codex App should then create/update the heartbeat automation." + if agent_type == "trae_app": + return "Use `$loopx ` or select the LoopX skill from `/skills`; Trae App should then create/update the heartbeat automation." if agent_type == "codex-app-ssh": return "Use `$loopx ` or select the LoopX skill from `/skills`; after todos are written, set `/goal ` in the visible Codex App task." if agent_type == "codex-ide-plugin": diff --git a/loopx/bootstrap_command_pack.py b/loopx/bootstrap_command_pack.py index bbf3bb5bda..6274250498 100644 --- a/loopx/bootstrap_command_pack.py +++ b/loopx/bootstrap_command_pack.py @@ -61,6 +61,7 @@ START_GOAL_CAPABILITY_ROUTES = ("issue-fix",) START_GOAL_HOST_SURFACES = ( "codex-app", + "trae_app", "codex-app-ssh", "codex-ide-plugin", "codex-cli-tui", @@ -349,6 +350,7 @@ def build_start_goal_host_surface_selection_packet( normalized_goal_text = " ".join(goal_text.split()) host_descriptions = { "codex-app": "Codex desktop app with heartbeat automation support", + "trae_app": "Trae desktop app with heartbeat automation support", "codex-app-ssh": "Codex desktop app over SSH with visible /goal support", "codex-ide-plugin": "Codex IDE plugin; activate its visible goal mode", "codex-cli-tui": "terminal Codex TUI with visible /goal support", @@ -1480,11 +1482,11 @@ def rerun_start_goal(selected_agent_id: str) -> str: { "id": "scheduler_ack_when_needed", "kind": "scheduler_state", - "command_source": "quota.should-run.scheduler_hint.codex_app.ack_hint.cli_args", - "purpose": "ack an applied Codex App RRULE without spending quota", + "command_source": "quota.should-run.scheduler_hint.app_automation.ack_hint.cli_args", + "purpose": "ack an applied App automation RRULE without spending quota", } ] - if host_surface == "codex-app" + if host_surface in {"codex-app", "trae_app"} else [] ) bind_thread_steps = ( diff --git a/loopx/cli_commands/_host_thread.py b/loopx/cli_commands/_host_thread.py index 68e40adf6f..2a8435e29c 100644 --- a/loopx/cli_commands/_host_thread.py +++ b/loopx/cli_commands/_host_thread.py @@ -15,6 +15,7 @@ "codex-app-ssh": "CODEX_THREAD_ID", "codex-ide-plugin": "CODEX_THREAD_ID", "codex-cli-tui": "CODEX_THREAD_ID", + "trae_app": "TRAECLI_THREAD_ID", KIRO_CLI_AGENT_TYPE: KIRO_CLI_SESSION_ID_ENV, } diff --git a/loopx/cli_commands/quota.py b/loopx/cli_commands/quota.py index 6bbf950b06..ad5afd1944 100644 --- a/loopx/cli_commands/quota.py +++ b/loopx/cli_commands/quota.py @@ -353,6 +353,7 @@ def _requested_quota_action_todo_id( ) -> str | None: if not ( bool(args.codex_app) + or bool(getattr(args, "trae_app", False)) or args.runtime_profile in {profile.value for profile in GUIDED_START_TURN_RUNTIME_PROFILES} ): @@ -701,7 +702,7 @@ def handle_quota_command( "agent-todos" in detail_sections and not bool(getattr(args, "turn_envelope", False)) ), - codex_app_current_rrule=args.codex_app_current_rrule, + codex_app_current_rrule=args.app_automation_current_rrule, registry_path=registry_path, runtime_root=runtime_root, host_observation_resolver=resolve_codex_app_automation_rrule, @@ -798,7 +799,7 @@ def handle_quota_command( "agent-todos" in detail_sections and not bool(getattr(args, "turn_envelope", False)) ), - codex_app_current_rrule=args.codex_app_current_rrule, + codex_app_current_rrule=args.app_automation_current_rrule, registry_path=registry_path, runtime_root=runtime_root, host_observation_resolver=resolve_codex_app_automation_rrule, diff --git a/loopx/cli_commands/quota_context.py b/loopx/cli_commands/quota_context.py index e3c6004e30..064853d714 100644 --- a/loopx/cli_commands/quota_context.py +++ b/loopx/cli_commands/quota_context.py @@ -16,10 +16,16 @@ ) from ..control_plane.scheduler.execution_context import ( GUIDED_START_TURN_RUNTIME_PROFILES, + HostSurface, SchedulerExecutionContextResolution, SchedulerRuntimeProfile, scheduler_execution_context_for_runtime_profile, scheduler_runtime_profile_for_execution_context, + resolve_scheduler_execution_context, +) +from ..control_plane.scheduler.state import ( + APP_AUTOMATION_STATEFUL_BACKOFF_STATE_KEY, + CODEX_APP_STATEFUL_BACKOFF_STATE_KEY, ) from ..status import AUTONOMOUS_REPLAN_PERIODIC_LOOKBACK, collect_status from ..turn_identity import mint_turn_instance_id, normalize_turn_instance_id @@ -40,6 +46,9 @@ "spend-slot", } ) +QUOTA_SCHEDULER_FOLLOWUP_COMMANDS = frozenset( + {"scheduler-ack", "scheduler-ack-current", "scheduler-fail-current"} +) @dataclass(frozen=True, slots=True) @@ -64,9 +73,16 @@ def _scheduler_execution_context_from_args( args.scheduler_owner, args.execution_mode, ) - if args.codex_app and (args.runtime_profile or any(explicit_scheduler_fields)): + codex_app = bool(getattr(args, "codex_app", False)) + trae_app = bool(getattr(args, "trae_app", False)) + app_alias_count = int(codex_app) + int(trae_app) + if app_alias_count > 1: + raise QuotaCommandValidationError( + "--codex-app and --trae_app are mutually exclusive" + ) + if app_alias_count and (args.runtime_profile or any(explicit_scheduler_fields)): raise QuotaCommandValidationError( - "--codex-app cannot be combined with --runtime-profile, " + "app runtime aliases cannot be combined with --runtime-profile, " "--host-surface, --scheduler-owner, or --execution-mode" ) if args.runtime_profile and any(explicit_scheduler_fields): @@ -76,7 +92,9 @@ def _scheduler_execution_context_from_args( ) runtime_profile = ( SchedulerRuntimeProfile.CODEX_APP_HEARTBEAT.value - if args.codex_app + if codex_app + else SchedulerRuntimeProfile.TRAE_APP.value + if trae_app else args.runtime_profile ) if runtime_profile: @@ -172,13 +190,65 @@ def validate_quota_command_context_request( if command in QUOTA_SCHEDULER_COMMANDS else None ) + resolved_scheduler_context = resolve_scheduler_execution_context(scheduler_context) + neutral_rrule = str( + getattr(args, "app_automation_current_rrule", None) or "" + ).strip() + legacy_codex_rrule = str( + getattr(args, "codex_app_current_rrule", None) or "" + ).strip() + if neutral_rrule and legacy_codex_rrule and neutral_rrule != legacy_codex_rrule: + raise QuotaCommandValidationError( + "--app-automation-current-rrule and --codex-app-current-rrule disagree" + ) + if ( + legacy_codex_rrule + and resolved_scheduler_context.ok + and resolved_scheduler_context.context is not None + and resolved_scheduler_context.context.host_surface is HostSurface.TRAE_APP + ): + raise QuotaCommandValidationError( + "Trae App uses --app-automation-current-rrule, not the Codex compatibility alias" + ) + args.app_automation_current_rrule = neutral_rrule or legacy_codex_rrule or None + if command in QUOTA_SCHEDULER_FOLLOWUP_COMMANDS: + selected_surface = ( + resolved_scheduler_context.context.host_surface.value + if resolved_scheduler_context.ok + and resolved_scheduler_context.context is not None + and resolved_scheduler_context.context.app_automation_applicable + else "codex_app" + ) + supplied_surface = str(getattr(args, "surface", None) or "").strip() + if supplied_surface and supplied_surface != selected_surface: + raise QuotaCommandValidationError( + f"--surface {supplied_surface} does not match selected App runtime {selected_surface}" + ) + args.surface = selected_surface + supplied_state_key = str( + getattr(args, "state_key", None) or "" + ).strip() + default_state_key = ( + APP_AUTOMATION_STATEFUL_BACKOFF_STATE_KEY + if selected_surface == HostSurface.TRAE_APP.value + else CODEX_APP_STATEFUL_BACKOFF_STATE_KEY + ) + if ( + selected_surface == HostSurface.TRAE_APP.value + and supplied_state_key + and supplied_state_key != APP_AUTOMATION_STATEFUL_BACKOFF_STATE_KEY + ): + raise QuotaCommandValidationError( + "Trae App scheduler follow-up requires the app_automation state key" + ) + args.state_key = supplied_state_key or default_state_key validate_quota_command_request(args) if begin_turn: profile = scheduler_runtime_profile_for_execution_context(scheduler_context) if profile not in GUIDED_START_TURN_RUNTIME_PROFILES: raise QuotaCommandValidationError( - "--begin-turn requires runtime-profile codex_app_heartbeat " - "or codex_app_ssh_goal; every other host starts its turn by " + "--begin-turn requires runtime-profile codex_app_heartbeat, " + "trae_app, or codex_app_ssh_goal; every other host starts its turn by " "passing its own --turn-instance-id" ) if ( diff --git a/loopx/cli_commands/quota_registration.py b/loopx/cli_commands/quota_registration.py index a2f975c3dc..e3b5a77d00 100644 --- a/loopx/cli_commands/quota_registration.py +++ b/loopx/cli_commands/quota_registration.py @@ -90,12 +90,21 @@ def register_quota_command( # Deprecated compatibility alias. Remove in the next breaking release. help=argparse.SUPPRESS, ) + quota_parser.add_argument( + "--app-automation-current-rrule", + help=( + "Current RRULE observed from the selected hosted App heartbeat. " + "This provider-neutral input reconciles host reality with LoopX's " + "last scheduler ACK." + ), + ) quota_parser.add_argument( "--codex-app-current-rrule", help=( "Current RRULE observed from the active Codex App heartbeat. For " "`quota should-run`, this reconciles host reality with LoopX's last " - "scheduler ACK so a stale ACK cannot suppress a required update." + "scheduler ACK so a stale ACK cannot suppress a required update. " + "Deprecated compatibility alias for --app-automation-current-rrule." ), ) quota_parser.add_argument( @@ -117,6 +126,15 @@ def register_quota_command( "runtime or execution context." ), ) + quota_parser.add_argument( + "--trae_app", + action="store_true", + help=( + "Compact explicit alias for --runtime-profile " + "trae_app. Cannot be combined with another scheduler " + "runtime or execution context." + ), + ) quota_parser.add_argument( "-H", "--host-surface", @@ -125,6 +143,7 @@ def register_quota_command( "codex_app", "codex_app_ssh", "codex_cli", + "trae_app", "generic_cli", "claude_code", "local_scheduler", @@ -211,13 +230,17 @@ def register_quota_command( register_quota_monitor_poll_request_arguments(quota_parser) quota_parser.add_argument( "--surface", - default="codex_app", - help="Scheduler surface for scheduler ACK/failure commands; defaults to codex_app.", + help=( + "Scheduler surface for scheduler ACK/failure commands; derived from " + "the selected App runtime, or codex_app for a legacy unscoped call." + ), ) quota_parser.add_argument( "--state-key", - default="scheduler_hint.codex_app.stateful_backoff", - help="Scheduler state key for scheduler ACK/failure commands.", + help=( + "Scheduler state key for scheduler ACK/failure commands; derived from " + "the selected App runtime when omitted." + ), ) quota_parser.add_argument( "--applied-rrule", diff --git a/loopx/cli_commands/quota_scheduler_followup.py b/loopx/cli_commands/quota_scheduler_followup.py index 0d30db9f6a..84e518b8e8 100644 --- a/loopx/cli_commands/quota_scheduler_followup.py +++ b/loopx/cli_commands/quota_scheduler_followup.py @@ -20,7 +20,9 @@ record_quota_scheduler_failure_for_decision, ) from ..control_plane.scheduler.execution_context import ( + HostSurface, SchedulerExecutionContextResolution, + resolve_scheduler_execution_context, ) from ..quota import record_quota_scheduler_ack from ..upgrade import resolve_codex_app_automation_rrule @@ -127,8 +129,22 @@ def build_scheduler_followup_payload( "delivery_outcome": "surface_only", } - observed_rrule = str(args.codex_app_current_rrule or "").strip() - if args.quota_command == "scheduler-fail-current" and not observed_rrule: + observed_rrule = str( + getattr(args, "app_automation_current_rrule", None) + or getattr(args, "codex_app_current_rrule", None) + or "" + ).strip() + resolved_context = resolve_scheduler_execution_context(scheduler_context) + codex_app_host = bool( + resolved_context.ok + and resolved_context.context is not None + and resolved_context.context.host_surface is HostSurface.CODEX_APP + ) + if ( + args.quota_command == "scheduler-fail-current" + and not observed_rrule + and codex_app_host + ): host_observation = resolve_codex_app_automation_rrule( goal_id=args.goal_id, agent_id=args.agent_id, diff --git a/loopx/cli_commands/start_goal.py b/loopx/cli_commands/start_goal.py index 9655b7aeb6..f122734160 100644 --- a/loopx/cli_commands/start_goal.py +++ b/loopx/cli_commands/start_goal.py @@ -147,7 +147,8 @@ def register_start_goal_command(subparsers: argparse._SubParsersAction) -> None: "--thread-id", help=( "Stable opaque host thread id used to reuse the bound agent lane. " - "Codex App defaults to the ambient CODEX_THREAD_ID when available." + "Codex App defaults to CODEX_THREAD_ID and Trae App to " + "TRAECLI_THREAD_ID when available." ), ) start_goal_parser.add_argument( diff --git a/loopx/cli_commands/starter_bootstrap_registration.py b/loopx/cli_commands/starter_bootstrap_registration.py index 7cb1b723bd..aa3f5f7c6c 100644 --- a/loopx/cli_commands/starter_bootstrap_registration.py +++ b/loopx/cli_commands/starter_bootstrap_registration.py @@ -23,7 +23,7 @@ def register_starter_bootstrap_commands(subparsers: argparse._SubParsersAction) ) agent_onboard_parser.add_argument( "--agent-type", - help="Agent runtime type: codex-app, codex-app-ssh, codex-ide-plugin, codex-cli, claude-code, opencode, pi, manual, or other-agent.", + help="Agent runtime type: codex-app, trae_app, traex-cli, codex-app-ssh, codex-ide-plugin, codex-cli, claude-code, opencode, pi, manual, or other-agent.", ) agent_onboard_parser.add_argument( "--list-agent-types", diff --git a/loopx/cli_commands/support_control.py b/loopx/cli_commands/support_control.py index 40ea45914e..78e39d567c 100644 --- a/loopx/cli_commands/support_control.py +++ b/loopx/cli_commands/support_control.py @@ -512,6 +512,8 @@ def handle_support_control_command( requested_runtime_profile = ( SchedulerRuntimeProfile.CODEX_APP_HEARTBEAT.value if args.codex_app + else SchedulerRuntimeProfile.TRAE_APP.value + if getattr(args, "trae_app", False) else args.runtime_profile ) try: @@ -564,11 +566,18 @@ def handle_support_control_command( args.scheduler_owner, args.execution_mode, ) - if args.codex_app and ( + app_alias_count = int(bool(args.codex_app)) + int( + bool(getattr(args, "trae_app", False)) + ) + if app_alias_count > 1: + raise ValueError( + "--codex-app and --trae_app are mutually exclusive" + ) + if app_alias_count and ( args.runtime_profile or any(explicit_scheduler_fields) ): raise ValueError( - "--codex-app cannot be combined with --runtime-profile, " + "app runtime aliases cannot be combined with --runtime-profile, " "--host-surface, --scheduler-owner, or --execution-mode" ) if args.runtime_profile and any(explicit_scheduler_fields): @@ -659,6 +668,7 @@ def handle_support_control_command( recurring_runtime_profile = requested_runtime_profile in { None, SchedulerRuntimeProfile.CODEX_APP_HEARTBEAT.value, + SchedulerRuntimeProfile.TRAE_APP.value, SchedulerRuntimeProfile.GENERIC_CLI_AGENT_LOOP.value, } recurring_thin_surface = bool( diff --git a/loopx/cli_commands/support_control_heartbeat_registration.py b/loopx/cli_commands/support_control_heartbeat_registration.py index 7b0ba5fcf3..50b70f52d6 100644 --- a/loopx/cli_commands/support_control_heartbeat_registration.py +++ b/loopx/cli_commands/support_control_heartbeat_registration.py @@ -84,6 +84,14 @@ def register_heartbeat_control_commands( "codex_app_heartbeat in generated heartbeat commands." ), ) + heartbeat_prompt_parser.add_argument( + "--trae_app", + action="store_true", + help=( + "Compact explicit alias for --runtime-profile " + "trae_app in generated heartbeat commands." + ), + ) heartbeat_prompt_parser.add_argument( "--visible-goal-host", choices=["traex-cli"], @@ -100,6 +108,7 @@ def register_heartbeat_control_commands( "codex_app", "codex_app_ssh", "codex_cli", + "trae_app", "generic_cli", "claude_code", "kunluncode", diff --git a/loopx/codex_cli_probe_markdown.py b/loopx/codex_cli_probe_markdown.py index 38cec7b3f1..07d0492bd3 100644 --- a/loopx/codex_cli_probe_markdown.py +++ b/loopx/codex_cli_probe_markdown.py @@ -237,7 +237,9 @@ def render_codex_cli_local_scheduler_tick_markdown(payload: dict[str, Any]) -> s else {} ) codex_app = ( - scheduler_hint.get("codex_app") + scheduler_hint.get("app_automation") + if isinstance(scheduler_hint.get("app_automation"), dict) + else scheduler_hint.get("codex_app") if isinstance(scheduler_hint.get("codex_app"), dict) else {} ) diff --git a/loopx/codex_cli_scheduler.py b/loopx/codex_cli_scheduler.py index a6808f4968..31327549f4 100644 --- a/loopx/codex_cli_scheduler.py +++ b/loopx/codex_cli_scheduler.py @@ -110,7 +110,9 @@ def build_codex_cli_local_scheduler_tick( else {} ) codex_app_hint = ( - scheduler_hint.get("codex_app") + scheduler_hint.get("app_automation") + if isinstance(scheduler_hint.get("app_automation"), dict) + else scheduler_hint.get("codex_app") if isinstance(scheduler_hint.get("codex_app"), dict) else {} ) diff --git a/loopx/control_plane/goals/start_contract.py b/loopx/control_plane/goals/start_contract.py index e85e0f7d84..ce3579f427 100644 --- a/loopx/control_plane/goals/start_contract.py +++ b/loopx/control_plane/goals/start_contract.py @@ -83,6 +83,7 @@ def build_goal_start_contract( "agent_type_discovery": "loopx agent-onboard --list-agent-types", "host_surfaces": { "codex-app": "Codex App heartbeat automation", + "trae_app": "Trae App heartbeat automation", "codex-cli": "visible Codex CLI `/goal `", "claude-code": "Claude Code native `/loop` after `/loopx ` arms LoopX", "opencode": "OpenCode `loopx_goal_activate`", diff --git a/loopx/control_plane/heartbeat/bootstrap_prompt.py b/loopx/control_plane/heartbeat/bootstrap_prompt.py index c00e2d296b..7429da1e3d 100644 --- a/loopx/control_plane/heartbeat/bootstrap_prompt.py +++ b/loopx/control_plane/heartbeat/bootstrap_prompt.py @@ -57,6 +57,8 @@ def _goal_command(args, *, registry: Path, legacy: bool) -> list[str]: command.append("--" + mode) if args.codex_app: command.append("--codex-app") + if getattr(args, "trae_app", False): + command.append("--trae_app") command += ["--goal-id", args.goal_id] for field, flag in ( ("agent_id", "--agent-id"), ("active_state", "--active-state"), @@ -79,13 +81,20 @@ def _goal_command(args, *, registry: Path, legacy: bool) -> list[str]: if legacy: if args.codex_app: command.append("--codex-app") + if getattr(args, "trae_app", False): + command.append("--trae_app") command.append("--" + mode) return command def goal_bootstrap(args, *, registry: Path) -> str: """Use the heartbeat wrapper only for an App heartbeat scheduler.""" - heartbeat = args.codex_app or getattr(args, "runtime_profile", None) == "codex_app_heartbeat" + heartbeat = ( + args.codex_app + or getattr(args, "trae_app", False) + or getattr(args, "runtime_profile", None) + in {"codex_app_heartbeat", "trae_app"} + ) command = _goal_command(args, registry=registry, legacy=not heartbeat) if heartbeat: return render_heartbeat_bootstrap(command) @@ -102,6 +111,7 @@ def host_bootstrap_binding(prompt: str) -> dict | None: if command[1:3] != ["--format", "json"]: return None values = dict(cli_bin=command[0], turn_instance_id=None, codex_app=False, + trae_app=False, full=False, compact=False, brief=False, thin=False, visible_goal_host=None, available_capabilities=[], agent_scopes=[], runtime_root=None) @@ -110,7 +120,11 @@ def host_bootstrap_binding(prompt: str) -> dict | None: "material_rule", "permission_rule", "runtime_profile", "visible_goal_host", "host_surface", "scheduler_owner", "execution_mode", "cli_bin")} repeated = {"--agent-scope": "agent_scopes", "--available-capability": "available_capabilities"} - booleans = {"--" + name.replace("_", "-"): name for name in ("codex_app", "full", "compact", "brief", "thin")} + booleans = { + "--codex-app": "codex_app", + "--trae_app": "trae_app", + **{"--" + name.replace("_", "-"): name for name in ("full", "compact", "brief", "thin")}, + } index = 3 while index < len(command): token = command[index] diff --git a/loopx/control_plane/heartbeat/host.py b/loopx/control_plane/heartbeat/host.py index b20238e233..c78c4ebc40 100644 --- a/loopx/control_plane/heartbeat/host.py +++ b/loopx/control_plane/heartbeat/host.py @@ -93,10 +93,12 @@ def resolve_exact_heartbeat_turn_identity( if profile not in { SchedulerRuntimeProfile.GENERIC_CLI_AGENT_LOOP, SchedulerRuntimeProfile.CODEX_APP_HEARTBEAT, + SchedulerRuntimeProfile.TRAE_APP, }: raise ValueError( "--turn-instance-id requires runtime-profile generic_cli or " - "codex_app_heartbeat so quota guard creates a heartbeat receipt" + "codex_app_heartbeat, or trae_app so quota guard creates " + "a heartbeat receipt" ) return normalized, f" --turn-instance-id {shlex.quote(normalized)}", { "schema_version": HEARTBEAT_PROMPT_SCHEMA_VERSION, diff --git a/loopx/control_plane/heartbeat/rules.py b/loopx/control_plane/heartbeat/rules.py index 74c9103160..264bd5f2b9 100644 --- a/loopx/control_plane/heartbeat/rules.py +++ b/loopx/control_plane/heartbeat/rules.py @@ -2,7 +2,7 @@ DEFAULT_MATERIAL_QUEUE_RULE = "Do not consume the learning material queue unless the user explicitly asks." -DEFAULT_PERMISSION_RULE = "Do not ask for permissions when the current Codex session is already trusted." +DEFAULT_PERMISSION_RULE = "Do not ask for permissions when the current host session is already trusted." SCOPE_BOUNDED_WORK_RULE = ( "授权/预算内推进可验证结果;按任务/证据/风险定规模,不按操作/文件数/心跳间隔。" "操作/写回不自动结束;遵守停止/重规划。" @@ -42,18 +42,19 @@ SCHEDULER_HINT_APPLICATION_RULE = ( "`scheduler_hint` no-spend. host_action=pause_or_delete_current_heartbeat -> " "automation_update stop once, verify, end; else apply_needed -> RRULE via " - "automation_update; unavailable -> fallback_hint.cli_args (SQLite/app API " + "automation_update; unavailable -> use fallback_hint.cli_args only when projected " + "(SQLite/app API " "bypass - fallback only), then ack; further failure -> failure_hint; " "ack_needed -> ack." ) SCHEDULER_HINT_COMPACT_RULE = ( "host_action=pause_or_delete_current_heartbeat: automation_update stop; " - "else RRULE apply via automation_update, fallback_hint when unavailable, " + "else RRULE apply via automation_update, projected fallback_hint when unavailable, " "then ack/fail. No spend." ) SCHEDULER_HINT_THIN_RULE = ( "host_action=pause_or_delete_current_heartbeat->automation_update stop(no-spend); " - "else RRULE/fallback_hint/ack/fail." + "else RRULE/projected-fallback_hint/ack/fail." ) RUNTIME_CAPABILITY_PROJECTION_THIN_RULE = ( "Observed capabilities -> `--available-capability`; never user gates." diff --git a/loopx/control_plane/heartbeat/task_body.py b/loopx/control_plane/heartbeat/task_body.py index b10d33cb6a..1243c8834c 100644 --- a/loopx/control_plane/heartbeat/task_body.py +++ b/loopx/control_plane/heartbeat/task_body.py @@ -145,7 +145,7 @@ def render_heartbeat_task_body( - Otherwise, do not do implementation work, adapter work, file edits, research, or project exploration in this turn. Return a quiet heartbeat `DONT_NOTIFY` response with the skip reason. - {SCHEDULER_HINT_APPLICATION_RULE} Codex App cadence changes are host + {SCHEDULER_HINT_APPLICATION_RULE} App automation cadence changes are host scheduling updates only; they never consume quota or authorize delivery work. If the result says `should_run=true`: @@ -174,7 +174,7 @@ def render_heartbeat_task_body( `must_attempt_work=true` requires scope-bounded work even with `notify=DONT_NOTIFY`; quiet no-op needs `must_attempt_work=false` and `user_channel.notify=DONT_NOTIFY`. Use - `scheduler_hint` for wakeup and unchanged-loop limits. For Codex App: + `scheduler_hint` for wakeup and unchanged-loop limits. For App automation: `apply_needed=true` -> update `recommended_rrule` once; on success run `ack_hint.cli_args`; on failure/timeout do not retry or ack, run `failure_hint.cli_args` once. LoopX suppresses that target/host pair until @@ -750,9 +750,11 @@ def render_heartbeat_prompt_markdown(payload: dict[str, Any]) -> str: style = "compact " else: style = "" + runtime_profile = str(payload.get("runtime_profile") or "") + host_label = "Trae App" if runtime_profile == "trae_app" else "Codex App" return f"""# Heartbeat Automation Prompt -Copy this {style}task body into a Codex App heartbeat automation. +Copy this {style}task body into a {host_label} heartbeat automation. ````text {payload.get("task_body", "")} diff --git a/loopx/control_plane/quota/live_decision.py b/loopx/control_plane/quota/live_decision.py index a4536673ea..b08c8a9ed1 100644 --- a/loopx/control_plane/quota/live_decision.py +++ b/loopx/control_plane/quota/live_decision.py @@ -257,67 +257,70 @@ def bind_scheduler_followup_cli_routes( scheduler_hint = payload.get("scheduler_hint") if not isinstance(scheduler_hint, dict): return - codex_app = scheduler_hint.get("codex_app") - if not isinstance(codex_app, dict): - return - for hint_name in ("ack_hint", "failure_hint", "fallback_hint"): - followup_hint = codex_app.get(hint_name) - if not isinstance(followup_hint, dict): - continue - cli_args = followup_hint.get("cli_args") - if not isinstance(cli_args, list) or not cli_args: - continue - if hint_name == "fallback_hint": - if cli_args[0] != "loopx-apply-rrule" or "--registry" in cli_args: + app_packets = [ + packet + for packet_key in ("app_automation", "codex_app") + if isinstance((packet := scheduler_hint.get(packet_key)), dict) + ] + for app_packet in app_packets: + for hint_name in ("ack_hint", "failure_hint", "fallback_hint"): + followup_hint = app_packet.get(hint_name) + if not isinstance(followup_hint, dict): continue - followup_hint["cli_args"] = [ - cli_args[0], - "--registry", - str(registry_path.expanduser().resolve()), - *cli_args[1:], - ] + cli_args = followup_hint.get("cli_args") + if not isinstance(cli_args, list) or not cli_args: + continue + if hint_name == "fallback_hint": + if cli_args[0] != "loopx-apply-rrule" or "--registry" in cli_args: + continue + followup_hint["cli_args"] = [ + cli_args[0], + "--registry", + str(registry_path.expanduser().resolve()), + *cli_args[1:], + ] + followup_hint["route_binding"] = { + "schema_version": "codex_app_scheduler_fallback_route_v0", + "source": source, + "registry_bound": True, + "runtime_root_bound": False, + } + continue + bound_cli_args = list(cli_args) + if bound_cli_args[0] != "--registry": + bound_cli_args = [ + "--registry", + str(registry_path.expanduser().resolve()), + "--runtime-root", + str(runtime_root.expanduser().resolve()), + *bound_cli_args, + ] + safe_turn_instance_id = str(turn_instance_id or "").strip() + if safe_turn_instance_id and "--turn-instance-id" not in bound_cli_args: + execute_index = ( + bound_cli_args.index("--execute") + if "--execute" in bound_cli_args + else len(bound_cli_args) + ) + bound_cli_args[execute_index:execute_index] = [ + "--turn-instance-id", + safe_turn_instance_id, + ] + args_value = followup_hint.get("args") + if isinstance(args_value, dict): + args_value["turn_instance_id"] = safe_turn_instance_id + followup_hint["cli_args"] = bound_cli_args followup_hint["route_binding"] = { - "schema_version": "codex_app_scheduler_fallback_route_v0", + "schema_version": ( + "scheduler_ack_cli_route_v0" + if hint_name == "ack_hint" + else "scheduler_failure_cli_route_v0" + ), "source": source, "registry_bound": True, - "runtime_root_bound": False, + "runtime_root_bound": True, + "turn_instance_bound": bool(safe_turn_instance_id), } - continue - bound_cli_args = list(cli_args) - if bound_cli_args[0] != "--registry": - bound_cli_args = [ - "--registry", - str(registry_path.expanduser().resolve()), - "--runtime-root", - str(runtime_root.expanduser().resolve()), - *bound_cli_args, - ] - safe_turn_instance_id = str(turn_instance_id or "").strip() - if safe_turn_instance_id and "--turn-instance-id" not in bound_cli_args: - execute_index = ( - bound_cli_args.index("--execute") - if "--execute" in bound_cli_args - else len(bound_cli_args) - ) - bound_cli_args[execute_index:execute_index] = [ - "--turn-instance-id", - safe_turn_instance_id, - ] - args_value = followup_hint.get("args") - if isinstance(args_value, dict): - args_value["turn_instance_id"] = safe_turn_instance_id - followup_hint["cli_args"] = bound_cli_args - followup_hint["route_binding"] = { - "schema_version": ( - "scheduler_ack_cli_route_v0" - if hint_name == "ack_hint" - else "scheduler_failure_cli_route_v0" - ), - "source": source, - "registry_bound": True, - "runtime_root_bound": True, - "turn_instance_bound": bool(safe_turn_instance_id), - } def bind_action_selection_cli_routes( @@ -394,15 +397,20 @@ def build_live_quota_should_run_decision( ) -> dict[str, Any]: """Build one live CLI decision while keeping host observation injectable.""" resolved_context = resolve_scheduler_execution_context(scheduler_execution_context) - codex_app_applicable = ( + app_automation_applicable = ( resolved_context.ok and resolved_context.context is not None - and resolved_context.context.codex_app_applicable + and resolved_context.context.app_automation_applicable + ) + codex_app_host = bool( + app_automation_applicable + and resolved_context.context is not None + and resolved_context.context.host_surface.value == "codex_app" ) observed_rrule = str(codex_app_current_rrule or "").strip() observed_automation_id = "" if ( - codex_app_applicable + codex_app_host and not observed_rrule and host_observation_resolver is not None ): diff --git a/loopx/control_plane/quota/scheduler_ack.py b/loopx/control_plane/quota/scheduler_ack.py index a435296f95..03d22dffb8 100644 --- a/loopx/control_plane/quota/scheduler_ack.py +++ b/loopx/control_plane/quota/scheduler_ack.py @@ -8,6 +8,7 @@ from ..effect_runtime import _node_executable from ..runtime.time import now_local_iso from ..scheduler.state import ( + APP_AUTOMATION_STATEFUL_BACKOFF_STATE_KEY, CODEX_APP_STATEFUL_BACKOFF_STATE_KEY, CODEX_APP_SURFACE, normalize_scheduler_rrule, @@ -24,15 +25,25 @@ def _scheduler_packet( before: dict[str, Any], *, surface: str, + state_key: str, ) -> tuple[dict[str, Any], dict[str, Any], dict[str, Any]]: scheduler_hint = ( before.get("scheduler_hint") if isinstance(before.get("scheduler_hint"), dict) else {} ) + if surface == "trae_app": + packet_key = "app_automation" + elif ( + surface == CODEX_APP_SURFACE + and state_key == APP_AUTOMATION_STATEFUL_BACKOFF_STATE_KEY + ): + packet_key = "app_automation" + else: + packet_key = surface surface_packet = ( - scheduler_hint.get(surface) - if isinstance(scheduler_hint.get(surface), dict) + scheduler_hint.get(packet_key) + if isinstance(scheduler_hint.get(packet_key), dict) else {} ) stateful_backoff = ( @@ -47,6 +58,7 @@ def _current_hint_identity( before: dict[str, Any], *, surface: str, + state_key: str, applied_rrule: str | None, reset_token: str | None, identity_signature: str | None, @@ -54,6 +66,7 @@ def _current_hint_identity( _, surface_packet, stateful_backoff = _scheduler_packet( before, surface=surface, + state_key=state_key, ) ack_hint = ( surface_packet.get("ack_hint") @@ -96,10 +109,11 @@ def _host_facts( scheduler_hint, surface_packet, stateful_backoff = _scheduler_packet( before, surface=surface, + state_key=state_key, ) if not stateful_backoff: raise ValueError( - "current quota decision has no Codex App stateful scheduler packet" + "current quota decision has no App automation stateful scheduler packet" ) if str(stateful_backoff.get("state_key") or "") != state_key: raise ValueError("--state-key does not match the current scheduler hint") @@ -297,6 +311,7 @@ def record_quota_scheduler_ack_for_decision( applied_rrule, reset_token, identity_signature = _current_hint_identity( before, surface=surface, + state_key=state_key, applied_rrule=applied_rrule, reset_token=reset_token, identity_signature=identity_signature, @@ -304,7 +319,9 @@ def record_quota_scheduler_ack_for_decision( try: if not safe_agent_id: raise ValueError("`loopx quota scheduler-ack` requires --agent-id") - _, _, stateful_backoff = _scheduler_packet(before, surface=surface) + _, _, stateful_backoff = _scheduler_packet( + before, surface=surface, state_key=state_key + ) if reset_token and str(reset_token).strip() != str( stateful_backoff.get("reset_token") or "" ): @@ -373,6 +390,7 @@ def record_quota_scheduler_failure_for_decision( _, surface_packet, stateful_backoff = _scheduler_packet( before, surface=surface, + state_key=state_key, ) target_rrule = normalize_scheduler_rrule( target_rrule diff --git a/loopx/control_plane/quota/should_run_packet.py b/loopx/control_plane/quota/should_run_packet.py index a5034bc2dd..01b1548cb9 100644 --- a/loopx/control_plane/quota/should_run_packet.py +++ b/loopx/control_plane/quota/should_run_packet.py @@ -88,9 +88,7 @@ ) from ..scheduler.scheduler_hint import build_scheduler_hint from ..scheduler.state import ( - CODEX_APP_STATEFUL_BACKOFF_STATE_KEY, - CODEX_APP_SURFACE, - load_scheduler_state, + load_app_automation_scheduler_state, ) from ..todos.contract import ( normalize_todo_claimed_by, @@ -203,22 +201,22 @@ def _scheduler_hint( ) -def _load_codex_app_scheduler_state( +def _load_app_automation_scheduler_state( status_payload: dict[str, Any], *, goal_id: str, agent_id: str | None, + surface: str, ) -> dict[str, Any] | None: raw_runtime_root = status_payload.get("runtime_root") safe_agent_id = normalize_todo_claimed_by(agent_id) if not raw_runtime_root or not safe_agent_id: return None - return load_scheduler_state( + return load_app_automation_scheduler_state( Path(str(raw_runtime_root)).expanduser(), goal_id=goal_id, agent_id=safe_agent_id, - surface=CODEX_APP_SURFACE, - state_key=CODEX_APP_STATEFUL_BACKOFF_STATE_KEY, + surface=surface, ) @@ -1467,15 +1465,16 @@ def _build_quota_should_run_payload( include_detail=prepared.include_scheduler_detail, available_capabilities=prepared.runtime_available_capabilities, codex_app_scheduler_state=( - _load_codex_app_scheduler_state( + _load_app_automation_scheduler_state( prepared.status_payload, goal_id=prepared.safe_goal_id, agent_id=quota_decision_agent_id(payload) or prepared.requested_agent_id, + surface=prepared.resolved_scheduler_context.context.host_surface.value, ) if prepared.resolved_scheduler_context.ok and prepared.resolved_scheduler_context.context is not None - and prepared.resolved_scheduler_context.context.codex_app_applicable + and prepared.resolved_scheduler_context.context.app_automation_applicable else None ), codex_app_current_rrule=prepared.codex_app_current_rrule, diff --git a/loopx/control_plane/quota/turn_envelope.ts b/loopx/control_plane/quota/turn_envelope.ts index bf95915173..a03fdd2886 100644 --- a/loopx/control_plane/quota/turn_envelope.ts +++ b/loopx/control_plane/quota/turn_envelope.ts @@ -353,13 +353,20 @@ function scheduler(payload: JsonObject, turn: ReturnType 0 + ? appAutomation + : legacyCodexApp; + if (Object.keys(sourceApp).length === 0) return result; const app: JsonObject = {}; - for (const field of ["apply", "host_action", "recommended_rrule", "no_spend_for_cadence_change"]) { - if (codexApp[field] !== null && codexApp[field] !== undefined) app[field] = codexApp[field]; + for (const field of [ + "host_surface", "apply", "host_action", "recommended_rrule", + "no_spend_for_cadence_change", + ]) { + if (sourceApp[field] !== null && sourceApp[field] !== undefined) app[field] = sourceApp[field]; } - const state = object(codexApp.stateful_backoff); + const state = object(sourceApp.stateful_backoff); if (Object.keys(state).length > 0) { const compactState: JsonObject = {}; for (const field of ["state_key", "current_rrule", "apply_needed", "ack_needed", "state_status"]) { @@ -375,7 +382,7 @@ function scheduler(payload: JsonObject, turn: ReturnType 0) { app.ack_cli_args = cliArgs; @@ -385,13 +392,19 @@ function scheduler(payload: JsonObject, turn: ReturnType 0) result.codex_app = app; + if (Object.keys(app).length > 0) { + if (Object.keys(appAutomation).length > 0) result.app_automation = app; + // Preserve the historical compact field only when the full source packet + // also supplied the Codex compatibility projection. A Trae packet never + // acquires a Codex identity while crossing the Turn boundary. + if (Object.keys(legacyCodexApp).length > 0) result.codex_app = app; + } return result; } diff --git a/loopx/control_plane/scheduler/app_automation_compat.py b/loopx/control_plane/scheduler/app_automation_compat.py new file mode 100644 index 0000000000..2a7beca0f2 --- /dev/null +++ b/loopx/control_plane/scheduler/app_automation_compat.py @@ -0,0 +1,112 @@ +from __future__ import annotations + +import copy +from collections.abc import Mapping +from typing import Any, Callable + +from .state import CODEX_APP_STATEFUL_BACKOFF_STATE_KEY, CODEX_APP_SURFACE + +CODEX_APP_STATEFUL_BACKOFF_SCHEMA_VERSION = "codex_app_stateful_backoff_v0" +CODEX_APP_SCHEDULER_ACK_HINT_SCHEMA_VERSION = "codex_app_scheduler_ack_hint_v0" +CODEX_APP_SCHEDULER_FAILURE_HINT_SCHEMA_VERSION = ( + "codex_app_scheduler_failure_hint_v0" +) + + +def build_codex_app_compatibility_projection( + app_automation: dict[str, Any], + *, + goal_id: Any, + agent_id: Any, + available_capabilities: Any, + scheduler_host_facts: Mapping[str, Any] | None, + observed_host_rrule: Any, + scheduler_before: Mapping[str, Any], + automation_id: Any, + build_ack_hint: Callable[..., dict[str, Any]], + build_failure_hint: Callable[..., dict[str, Any]], + build_fallback_hint: Callable[..., dict[str, Any]], +) -> dict[str, Any]: + """Translate the canonical App packet to the exact legacy Codex shape.""" + + legacy = copy.deepcopy(app_automation) + legacy["applicability"] = "applicable" + legacy["rrule_source"] = ( + "scheduler_hint.codex_app.recommended_rrule" + if legacy.get("recommended_rrule") + else None + ) + backoff = legacy.get("stateful_backoff") + if isinstance(backoff, dict): + backoff["schema_version"] = CODEX_APP_STATEFUL_BACKOFF_SCHEMA_VERSION + backoff["state_key"] = CODEX_APP_STATEFUL_BACKOFF_STATE_KEY + legacy_facts = ( + { + **scheduler_host_facts, + "surface": CODEX_APP_SURFACE, + "state_key": CODEX_APP_STATEFUL_BACKOFF_STATE_KEY, + } + if isinstance(scheduler_host_facts, Mapping) + else None + ) + if isinstance(legacy.get("failure_hint"), dict): + failure_facts = ( + { + **legacy_facts, + "operation": "host_failure", + "applied_rrule": observed_host_rrule, + "observed_host_rrule": observed_host_rrule, + "failure_kind": "host_tool_failure", + "source": "quota_scheduler_host_update_failure", + "host_match_observed": False, + } + if legacy_facts is not None + else None + ) + legacy["failure_hint"] = build_failure_hint( + goal_id=goal_id, + agent_id=agent_id, + failed_rrule=legacy.get("recommended_rrule"), + observed_host_rrule=observed_host_rrule, + available_capabilities=available_capabilities, + scheduler_host_facts=failure_facts, + scheduler_before=scheduler_before, + ) + legacy["fallback_hint"] = build_fallback_hint( + goal_id=goal_id, + agent_id=agent_id, + automation_id=automation_id, + ) + if isinstance(legacy.get("ack_hint"), dict): + canonical_ack = app_automation["ack_hint"] + canonical_args = ( + canonical_ack.get("args") + if isinstance(canonical_ack.get("args"), dict) + else {} + ) + legacy["ack_hint"] = build_ack_hint( + goal_id=goal_id, + agent_id=agent_id, + applied_rrule=canonical_args.get("applied_rrule"), + reset_token=canonical_args.get("reset_token"), + identity_signature=canonical_args.get("identity_signature"), + available_capabilities=available_capabilities, + after=canonical_ack.get("after", "automation_update_rrule_success"), + host_match_observed=canonical_args.get("host_match_observed") is True, + scheduler_host_facts=( + { + **legacy_facts, + "operation": "ack", + "applied_rrule": canonical_args.get("applied_rrule"), + "observed_host_rrule": observed_host_rrule, + "source": "quota_scheduler_ack", + "host_match_observed": ( + canonical_args.get("host_match_observed") is True + ), + } + if legacy_facts is not None + else None + ), + scheduler_before=scheduler_before, + ) + return legacy diff --git a/loopx/control_plane/scheduler/execution_context.py b/loopx/control_plane/scheduler/execution_context.py index 340f87ad9f..a3dea4b7d8 100644 --- a/loopx/control_plane/scheduler/execution_context.py +++ b/loopx/control_plane/scheduler/execution_context.py @@ -1,5 +1,6 @@ from __future__ import annotations +import copy import shlex from collections.abc import Mapping from dataclasses import dataclass @@ -15,6 +16,7 @@ class HostSurface(str, Enum): CODEX_APP = "codex_app" CODEX_APP_SSH = "codex_app_ssh" CODEX_CLI = "codex_cli" + TRAE_APP = "trae_app" GENERIC_CLI = "generic_cli" CLAUDE_CODE = "claude_code" KUNLUNCODE = "kunluncode" @@ -40,6 +42,7 @@ class SchedulerRuntimeProfile(str, Enum): CODEX_APP_HEARTBEAT = "codex_app_heartbeat" CODEX_APP_SSH_VISIBLE = "codex_app_ssh_goal" CODEX_CLI_VISIBLE = "codex_cli" + TRAE_APP = "trae_app" CLAUDE_CODE_VISIBLE = "claude_code" KUNLUNCODE_VISIBLE = "kunluncode" GENERIC_CLI_AGENT_LOOP = "generic_cli" @@ -87,11 +90,24 @@ class GoalRuntimeContinuationDisposition(str, Enum): GUIDED_START_TURN_RUNTIME_PROFILES = frozenset( { SchedulerRuntimeProfile.CODEX_APP_HEARTBEAT, + SchedulerRuntimeProfile.TRAE_APP, SchedulerRuntimeProfile.CODEX_APP_SSH_VISIBLE, } ) +# Hosted App runtimes share the receipt-bound settlement lifecycle while +# retaining independent host, state, and provider identities. Keep this set as +# the typed owner so downstream work-item code does not drift through repeated +# Codex-only profile checks. +APP_HEARTBEAT_SETTLEMENT_RUNTIME_PROFILES = frozenset( + { + SchedulerRuntimeProfile.CODEX_APP_HEARTBEAT, + SchedulerRuntimeProfile.TRAE_APP, + } +) + + _SCHEDULER_RUNTIME_PROFILE_CONTEXTS = { SchedulerRuntimeProfile.ARK_MANAGED_AGENT_GOAL: ( HostSurface.ARK_MANAGED_AGENT, @@ -113,6 +129,11 @@ class GoalRuntimeContinuationDisposition(str, Enum): SchedulerOwner.AGENT_CLI_LOOP, ExecutionMode.INTERACTIVE, ), + SchedulerRuntimeProfile.TRAE_APP: ( + HostSurface.TRAE_APP, + SchedulerOwner.HOST_AUTOMATION, + ExecutionMode.HOSTED_AUTOMATION, + ), SchedulerRuntimeProfile.CLAUDE_CODE_VISIBLE: ( HostSurface.CLAUDE_CODE, SchedulerOwner.AGENT_CLI_LOOP, @@ -158,18 +179,34 @@ def codex_app_applicable(self) -> bool: and self.execution_mode is ExecutionMode.HOSTED_AUTOMATION ) + @property + def app_automation_applicable(self) -> bool: + return ( + self.host_surface in {HostSurface.CODEX_APP, HostSurface.TRAE_APP} + and self.scheduler_owner is SchedulerOwner.HOST_AUTOMATION + and self.execution_mode is ExecutionMode.HOSTED_AUTOMATION + ) + def projection(self) -> dict[str, Any]: - return { + projection = { "schema_version": SCHEDULER_EXECUTION_CONTEXT_SCHEMA_VERSION, "host_surface": self.host_surface.value, "scheduler_owner": self.scheduler_owner.value, "execution_mode": self.execution_mode.value, "source": self.source, "valid": True, - "codex_app_applicability": ( - "applicable" if self.codex_app_applicable else "not_applicable" - ), } + # Keep existing non-Trae projections byte-compatible. The neutral App + # field is needed to identify Trae's independent automation contract, + # but a second negative applicability flag adds no information for CLI + # and Goal runtimes. + if self.host_surface is HostSurface.TRAE_APP: + projection["app_automation_applicability"] = "applicable" + else: + projection["codex_app_applicability"] = ( + "applicable" if self.codex_app_applicable else "not_applicable" + ) + return projection @dataclass(frozen=True) @@ -186,16 +223,20 @@ def projection(self) -> dict[str, Any]: if self.context is not None: return self.context.projection() supplied = dict(self.supplied or {}) - return { + projection = { "schema_version": SCHEDULER_EXECUTION_CONTEXT_SCHEMA_VERSION, "host_surface": supplied.get("host_surface"), "scheduler_owner": supplied.get("scheduler_owner"), "execution_mode": supplied.get("execution_mode"), "source": supplied.get("source") or "explicit", "valid": False, - "codex_app_applicability": "blocked_invalid_context", "errors": list(self.errors), } + if supplied.get("host_surface") == HostSurface.TRAE_APP.value: + projection["app_automation_applicability"] = "blocked_invalid_context" + else: + projection["codex_app_applicability"] = "blocked_invalid_context" + return projection def _validation_errors(context: SchedulerExecutionContext) -> list[str]: @@ -207,11 +248,12 @@ def _validation_errors(context: SchedulerExecutionContext) -> list[str]: HostSurface.CLAUDE_CODE, HostSurface.KUNLUNCODE, } - if context.host_surface is HostSurface.CODEX_APP: + if context.host_surface in {HostSurface.CODEX_APP, HostSurface.TRAE_APP}: + host_name = context.host_surface.value if context.scheduler_owner is not SchedulerOwner.HOST_AUTOMATION: - errors.append("codex_app requires scheduler_owner=host_automation") + errors.append(f"{host_name} requires scheduler_owner=host_automation") if context.execution_mode is not ExecutionMode.HOSTED_AUTOMATION: - errors.append("codex_app requires execution_mode=hosted_automation") + errors.append(f"{host_name} requires execution_mode=hosted_automation") if context.host_surface is HostSurface.ARK_MANAGED_AGENT: if context.scheduler_owner is not SchedulerOwner.GOAL_RUNTIME: errors.append("ark_managed_agent requires scheduler_owner=goal_runtime") @@ -372,6 +414,8 @@ def _render_scheduler_runtime_profile_args( ) -> str: if profile is SchedulerRuntimeProfile.CODEX_APP_HEARTBEAT: return " --codex-app" + if profile is SchedulerRuntimeProfile.TRAE_APP: + return " --trae_app" return f" --runtime-profile {shlex.quote(profile.value)}" @@ -476,7 +520,9 @@ def build_goal_runtime_continuation( continuation["recheck_after_seconds"] = frontier_recheck_after_seconds continuation["recheck_source"] = "frontier_earliest_material_transition" else: - host_cadence = scheduler_hint.get("codex_app") + host_cadence = scheduler_hint.get("app_automation") + if not isinstance(host_cadence, Mapping): + host_cadence = scheduler_hint.get("codex_app") host_cadence = host_cadence if isinstance(host_cadence, Mapping) else {} recommended_interval = host_cadence.get("recommended_interval_minutes") if not isinstance(recommended_interval, int) or recommended_interval <= 0: @@ -500,22 +546,62 @@ def apply_scheduler_execution_context( raise ValueError("cannot apply an invalid scheduler execution context") context = resolution.context - codex_app = ( - result.get("codex_app") if isinstance(result.get("codex_app"), dict) else {} + app_automation = ( + result.get("app_automation") + if isinstance(result.get("app_automation"), dict) + else {} ) - if context.codex_app_applicable: - codex_app["applicability"] = "applicable" + if context.app_automation_applicable: + app_automation["applicability"] = "applicable" + app_automation["host_surface"] = context.host_surface.value backoff = ( - codex_app.get("stateful_backoff") - if isinstance(codex_app.get("stateful_backoff"), dict) + app_automation.get("stateful_backoff") + if isinstance(app_automation.get("stateful_backoff"), dict) else {} ) apply_needed = ( backoff.get("apply_needed") is True - or codex_app.get("host_action_required") is True + or app_automation.get("host_action_required") is True ) ack_needed = backoff.get("ack_needed") is True - result["codex_app"] = codex_app + result["app_automation"] = app_automation + if context.host_surface is HostSurface.CODEX_APP: + codex_app = result.get("codex_app") + if not isinstance(codex_app, dict): + # Stateless stop/pause packets do not pass through the stateful + # compatibility builder. Keep their legacy view independent so + # later route binding cannot mutate the canonical packet. + result["codex_app"] = copy.deepcopy(app_automation) + reset_policy = result.get("reset_policy") + if isinstance(reset_policy, dict): + reset_policy["codex_app_initial_interval_minutes"] = ( + reset_policy.get("app_automation_initial_interval_minutes") + ) + reset_policy["codex_app_initial_rrule"] = reset_policy.get( + "app_automation_initial_rrule" + ) + cold_path = result.get("cold_path_detail") + reset_detail = ( + cold_path.get("reset_policy_detail") + if isinstance(cold_path, dict) + and isinstance(cold_path.get("reset_policy_detail"), dict) + else None + ) + if reset_detail is not None: + reset_detail["codex_app_initial_interval_minutes"] = ( + reset_detail.get("app_automation_initial_interval_minutes") + ) + reset_detail["codex_app_initial_rrule"] = reset_detail.get( + "app_automation_initial_rrule" + ) + reset_detail["codex_app_tool"] = reset_detail.get( + "app_automation_tool" + ) + reset_detail["codex_app_apply"] = reset_detail.get( + "app_automation_apply" + ) + else: + result.pop("codex_app", None) execution_phase = { "schema_version": "scheduler_execution_phase_v0", "host_surface": context.host_surface.value, @@ -544,7 +630,7 @@ def apply_scheduler_execution_context( ) result["execution_context"] = resolution.projection() - result["codex_app"] = { + not_applicable = { "applicability": "not_applicable", "reason_code": f"cadence_owned_by_{context.scheduler_owner.value}", "apply": "none", @@ -552,10 +638,16 @@ def apply_scheduler_execution_context( "ack_required": False, "no_spend_for_cadence_change": True, } + # Preserve the historical non-App response exactly. Provider-neutral App + # automation is emitted only when a real App host owns the cadence; adding + # it to unrelated CLI/Goal-runtime responses would duplicate a negative + # projection and expand every agent-facing payload. + result.pop("app_automation", None) + result["codex_app"] = not_applicable reset_policy = result.get("reset_policy") if isinstance(reset_policy, dict): for key in tuple(reset_policy): - if key.startswith("codex_app_"): + if key.startswith(("app_automation_", "codex_app_")): reset_policy.pop(key, None) cold_path = result.get("cold_path_detail") if isinstance(cold_path, dict): @@ -563,7 +655,7 @@ def apply_scheduler_execution_context( reset_detail = cold_path.get("reset_policy_detail") if isinstance(reset_detail, dict): for key in tuple(reset_detail): - if key.startswith("codex_app_"): + if key.startswith(("app_automation_", "codex_app_")): reset_detail.pop(key, None) owner = context.scheduler_owner.value result["execution_phase"] = { diff --git a/loopx/control_plane/scheduler/heartbeat_followup.ts b/loopx/control_plane/scheduler/heartbeat_followup.ts index 274fd2addd..179ab327d7 100644 --- a/loopx/control_plane/scheduler/heartbeat_followup.ts +++ b/loopx/control_plane/scheduler/heartbeat_followup.ts @@ -247,7 +247,7 @@ function compatibilityFailure( goal_id: facts.goal_id, agent_id: facts.agent_id, surface: facts.surface ?? "codex_app", - state_key: facts.state_key ?? "scheduler_hint.codex_app.stateful_backoff", + state_key: facts.state_key ?? "scheduler_hint.app_automation.stateful_backoff", applied_rrule: applied, appended: false, registry_mutated: false, @@ -281,7 +281,7 @@ function ackResult( const event: JsonObject = { event_type: ACK_CLASSIFICATION, surface: facts.surface ?? "codex_app", - state_key: facts.state_key ?? "scheduler_hint.codex_app.stateful_backoff", + state_key: facts.state_key ?? "scheduler_hint.app_automation.stateful_backoff", applied_rrule: appliedRrule, before: compact, scheduler_state: state, @@ -301,7 +301,7 @@ function ackResult( goal_id: facts.goal_id, agent_id: facts.agent_id, surface: facts.surface ?? "codex_app", - state_key: facts.state_key ?? "scheduler_hint.codex_app.stateful_backoff", + state_key: facts.state_key ?? "scheduler_hint.app_automation.stateful_backoff", applied_rrule: appliedRrule, classification: ACK_CLASSIFICATION, generated_at: facts.generated_at, @@ -354,7 +354,7 @@ function failureResult( goal_id: facts.goal_id, agent_id: facts.agent_id, surface: facts.surface ?? "codex_app", - state_key: facts.state_key ?? "scheduler_hint.codex_app.stateful_backoff", + state_key: facts.state_key ?? "scheduler_hint.app_automation.stateful_backoff", failed_rrule: targetRrule, observed_host_rrule: observedRrule, failure_kind: facts.failure_kind ?? "host_tool_failure", @@ -367,7 +367,7 @@ function failureResult( scheduler_failure_event: { event_type: FAILURE_CLASSIFICATION, surface: facts.surface ?? "codex_app", - state_key: facts.state_key ?? "scheduler_hint.codex_app.stateful_backoff", + state_key: facts.state_key ?? "scheduler_hint.app_automation.stateful_backoff", before: compact, scheduler_state: state, host_update_failure: failure, diff --git a/loopx/control_plane/scheduler/heartbeat_followup_cli.ts b/loopx/control_plane/scheduler/heartbeat_followup_cli.ts index f198009e77..c8d86cfc50 100644 --- a/loopx/control_plane/scheduler/heartbeat_followup_cli.ts +++ b/loopx/control_plane/scheduler/heartbeat_followup_cli.ts @@ -65,6 +65,7 @@ function parseArgs(argv: string[]): ParsedArgs { "--applied-rrule", "--failed-rrule", "--failure-kind", + "--app-automation-current-rrule", "--codex-app-current-rrule", "--turn-instance-id", "--reset-token", @@ -76,6 +77,7 @@ function parseArgs(argv: string[]): ParsedArgs { const booleanOptions = new Set([ "-A", "--codex-app", + "--trae_app", "--execute", "--dry-run", "--host-match-observed", @@ -215,6 +217,26 @@ function requestFromArgs(argv: string[]): { request: Record; fo ); } const values = parsed.values; + const neutralObservedRrule = values["--app-automation-current-rrule"] ?? ""; + const legacyCodexObservedRrule = values["--codex-app-current-rrule"] ?? ""; + if (neutralObservedRrule && legacyCodexObservedRrule && neutralObservedRrule !== legacyCodexObservedRrule) { + throw new EffectRuntimeRequestError( + "App automation and Codex compatibility RRULE observations disagree", + "scheduler_host_facts_identity_mismatch", + ); + } + if (parsed.booleans.has("--trae_app")) { + matchingText(facts, "surface", "trae_app", "--trae_app"); + if (legacyCodexObservedRrule) { + throw new EffectRuntimeRequestError( + "Trae App cannot use the Codex RRULE compatibility alias", + "scheduler_host_facts_identity_mismatch", + ); + } + } + if (parsed.booleans.has("-A") || parsed.booleans.has("--codex-app")) { + matchingText(facts, "surface", "codex_app", "--codex-app"); + } if (values["--surface"]) matchingText(facts, "surface", values["--surface"], "--surface"); if (values["--state-key"]) matchingText(facts, "state_key", values["--state-key"], "--state-key"); if (values["--reset-token"]) matchingText(facts, "reset_token", values["--reset-token"], "--reset-token"); @@ -227,12 +249,14 @@ function requestFromArgs(argv: string[]): { request: Record; fo if (expectedOperation === "host_failure" && values["--failed-rrule"]) { matchingText(facts, "expected_rrule", values["--failed-rrule"], "--failed-rrule"); } - if (values["--codex-app-current-rrule"]) { + if (neutralObservedRrule || legacyCodexObservedRrule) { matchingText( facts, "observed_host_rrule", - values["--codex-app-current-rrule"], - "--codex-app-current-rrule", + neutralObservedRrule || legacyCodexObservedRrule, + neutralObservedRrule + ? "--app-automation-current-rrule" + : "--codex-app-current-rrule", ); } if (values["--failure-kind"]) matchingText(facts, "failure_kind", values["--failure-kind"], "--failure-kind"); diff --git a/loopx/control_plane/scheduler/scheduler_hint.py b/loopx/control_plane/scheduler/scheduler_hint.py index e3f766e527..f385ce5f05 100644 --- a/loopx/control_plane/scheduler/scheduler_hint.py +++ b/loopx/control_plane/scheduler/scheduler_hint.py @@ -18,6 +18,11 @@ SchedulerDisposition, build_scheduler_arbitration, ) +from .app_automation_compat import ( + CODEX_APP_SCHEDULER_ACK_HINT_SCHEMA_VERSION, + CODEX_APP_SCHEDULER_FAILURE_HINT_SCHEMA_VERSION, + build_codex_app_compatibility_projection, +) from .execution_context import ( SchedulerExecutionContextResolution, SchedulerOwner, @@ -34,6 +39,7 @@ build_monitor_wait_cadence_plan, ) from .state import ( + APP_AUTOMATION_STATEFUL_BACKOFF_STATE_KEY, CODEX_APP_STATEFUL_BACKOFF_STATE_KEY, CODEX_APP_SURFACE, normalize_scheduler_rrule, @@ -46,9 +52,15 @@ SCHEDULER_HINT_SCHEMA_VERSION = "scheduler_hint_v0" SCHEDULER_RESET_POLICY_SCHEMA_VERSION = "scheduler_reset_policy_v0" SCHEDULER_HINT_DETAIL_SCHEMA_VERSION = "scheduler_hint_detail_v0" -CODEX_APP_STATEFUL_BACKOFF_SCHEMA_VERSION = "codex_app_stateful_backoff_v0" -CODEX_APP_SCHEDULER_ACK_HINT_SCHEMA_VERSION = "codex_app_scheduler_ack_hint_v0" -CODEX_APP_SCHEDULER_FAILURE_HINT_SCHEMA_VERSION = "codex_app_scheduler_failure_hint_v0" +APP_AUTOMATION_STATEFUL_BACKOFF_SCHEMA_VERSION = ( + "app_automation_stateful_backoff_v0" +) +APP_AUTOMATION_SCHEDULER_ACK_HINT_SCHEMA_VERSION = ( + "app_automation_scheduler_ack_hint_v0" +) +APP_AUTOMATION_SCHEDULER_FAILURE_HINT_SCHEMA_VERSION = ( + "app_automation_scheduler_failure_hint_v0" +) CODEX_APP_SCHEDULER_FALLBACK_HINT_SCHEMA_VERSION = ( "codex_app_scheduler_fallback_hint_v0" ) @@ -127,6 +139,59 @@ def _dict_or_empty(value: Any) -> dict[str, Any]: return value if isinstance(value, dict) else {} +def _scheduler_profile_digest_snapshot( + profile_snapshot: Mapping[str, Any], +) -> dict[str, Any]: + """Keep the persisted v0 reset identity stable across the App rename. + + These legacy key names are part of the reset-token hash protocol, not the + public scheduler projection. Changing them would reset an existing Codex + App backoff even when the cadence semantics are unchanged. + """ + + key_aliases = { + "app_automation_initial_interval_minutes": ( + "codex_app_initial_interval_minutes" + ), + "app_automation_initial_rrule": "codex_app_initial_rrule", + "app_automation_max_interval_minutes": ( + "codex_app_max_interval_minutes" + ), + "app_automation_progression_minutes": ( + "codex_app_progression_minutes" + ), + } + return {key_aliases.get(key, key): value for key, value in profile_snapshot.items()} + + +def _scheduler_reset_identity_digests( + *, + action: str, + identity_snapshot: Mapping[str, Any], + profile_snapshot: Mapping[str, Any], + reset_profile_snapshot: Mapping[str, Any], +) -> tuple[str, str, str, str]: + """Build the stable v0 reset token and its component signatures.""" + + profile_digest_snapshot = _scheduler_profile_digest_snapshot(profile_snapshot) + reset_profile_digest_snapshot = _scheduler_profile_digest_snapshot( + reset_profile_snapshot + ) + return ( + _stable_digest( + { + "action": action, + "identity_snapshot": identity_snapshot, + "profile_snapshot": reset_profile_digest_snapshot, + }, + length=16, + ), + _stable_digest(identity_snapshot, length=12), + _stable_digest(profile_digest_snapshot, length=12), + _stable_digest(reset_profile_digest_snapshot, length=12), + ) + + def _scheduler_identity_keys( *, cadence_class: str, @@ -170,7 +235,7 @@ def _build_scheduler_stop_hint( "reason_code": reason_code, "reason": reason, "spend_policy": spend_policy, - "codex_app": { + "app_automation": { "apply": "pause_or_delete_current_heartbeat_if_possible", "host_tool": "automation_update", "host_action": "pause_or_delete_current_heartbeat", @@ -295,7 +360,7 @@ def _bounded_scheduler_followup_cli_args( raise ValueError("native scheduler follow-up CLI arguments exceed the transport bound") -def build_codex_app_scheduler_ack_hint( +def build_app_automation_scheduler_ack_hint( *, goal_id: Any, agent_id: Any, @@ -306,7 +371,7 @@ def build_codex_app_scheduler_ack_hint( after: str = "automation_update_rrule_success", host_match_observed: bool = False, surface: str = CODEX_APP_SURFACE, - state_key: str = CODEX_APP_STATEFUL_BACKOFF_STATE_KEY, + state_key: str = APP_AUTOMATION_STATEFUL_BACKOFF_STATE_KEY, scheduler_host_facts: Mapping[str, Any] | None = None, scheduler_before: Mapping[str, Any] | None = None, ) -> dict[str, Any]: @@ -327,6 +392,7 @@ def build_codex_app_scheduler_ack_hint( and safe_capability not in safe_available_capabilities ): safe_available_capabilities.append(safe_capability) + runtime_alias = "--trae_app" if safe_surface == "trae_app" else "-A" cli_args = [ "quota", "scheduler-ack-current", @@ -334,7 +400,7 @@ def build_codex_app_scheduler_ack_hint( safe_goal_id, "--agent-id", safe_agent_id, - "-A", + runtime_alias, ] for capability in safe_available_capabilities: cli_args.extend(["--available-capability", capability]) @@ -375,7 +441,7 @@ def build_codex_app_scheduler_ack_hint( if host_match_observed: args["host_match_observed"] = True return { - "schema_version": CODEX_APP_SCHEDULER_ACK_HINT_SCHEMA_VERSION, + "schema_version": APP_AUTOMATION_SCHEDULER_ACK_HINT_SCHEMA_VERSION, "after": str(after or "automation_update_rrule_success").strip(), "command": "quota scheduler-ack-current", "execute": True, @@ -386,7 +452,7 @@ def build_codex_app_scheduler_ack_hint( } -def build_codex_app_scheduler_failure_hint( +def build_app_automation_scheduler_failure_hint( *, goal_id: Any, agent_id: Any, @@ -395,6 +461,8 @@ def build_codex_app_scheduler_failure_hint( available_capabilities: Any = None, scheduler_host_facts: Mapping[str, Any] | None = None, scheduler_before: Mapping[str, Any] | None = None, + surface: str = CODEX_APP_SURFACE, + state_key: str = APP_AUTOMATION_STATEFUL_BACKOFF_STATE_KEY, ) -> dict[str, Any]: safe_goal_id = str(goal_id or "").strip() safe_agent_id = str(agent_id or "").strip() @@ -410,6 +478,9 @@ def build_codex_app_scheduler_failure_hint( and safe_capability not in safe_capabilities ): safe_capabilities.append(safe_capability) + safe_surface = str(surface or "").strip() + safe_state_key = str(state_key or "").strip() + runtime_alias = "--trae_app" if safe_surface == "trae_app" else "-A" cli_args = [ "quota", "scheduler-fail-current", @@ -417,7 +488,7 @@ def build_codex_app_scheduler_failure_hint( safe_goal_id, "--agent-id", safe_agent_id, - "-A", + runtime_alias, ] for capability in safe_capabilities: cli_args.extend(["--available-capability", capability]) @@ -427,6 +498,10 @@ def build_codex_app_scheduler_failure_hint( use_current_hint=False, ) cli_args.extend(native_args) + if safe_surface != CODEX_APP_SURFACE: + cli_args.extend(["--surface", safe_surface]) + if safe_state_key != CODEX_APP_STATEFUL_BACKOFF_STATE_KEY: + cli_args.extend(["--state-key", safe_state_key]) cli_args.extend( [ "--failed-rrule", @@ -434,15 +509,43 @@ def build_codex_app_scheduler_failure_hint( ] ) if safe_observed_rrule: - cli_args.extend(["--codex-app-current-rrule", safe_observed_rrule]) + cli_args.extend(["--app-automation-current-rrule", safe_observed_rrule]) cli_args.append("--execute") cli_args = _bounded_scheduler_followup_cli_args(cli_args, native_args=native_args) return { - "schema_version": CODEX_APP_SCHEDULER_FAILURE_HINT_SCHEMA_VERSION, + "schema_version": APP_AUTOMATION_SCHEDULER_FAILURE_HINT_SCHEMA_VERSION, "cli_args": cli_args, } +def build_codex_app_scheduler_ack_hint(**kwargs: Any) -> dict[str, Any]: + """Build the historical Codex packet while the neutral packet coexists.""" + + kwargs.setdefault("surface", CODEX_APP_SURFACE) + kwargs.setdefault("state_key", CODEX_APP_STATEFUL_BACKOFF_STATE_KEY) + hint = build_app_automation_scheduler_ack_hint(**kwargs) + hint["schema_version"] = CODEX_APP_SCHEDULER_ACK_HINT_SCHEMA_VERSION + return hint + + +def build_codex_app_scheduler_failure_hint(**kwargs: Any) -> dict[str, Any]: + """Build the historical Codex packet while the neutral packet coexists.""" + + kwargs.setdefault("surface", CODEX_APP_SURFACE) + kwargs.setdefault("state_key", CODEX_APP_STATEFUL_BACKOFF_STATE_KEY) + hint = build_app_automation_scheduler_failure_hint(**kwargs) + hint["schema_version"] = CODEX_APP_SCHEDULER_FAILURE_HINT_SCHEMA_VERSION + cli_args = hint.get("cli_args") + if isinstance(cli_args, list): + hint["cli_args"] = [ + "--codex-app-current-rrule" + if item == "--app-automation-current-rrule" + else item + for item in cli_args + ] + return hint + + def build_codex_app_scheduler_fallback_hint( *, goal_id: Any, @@ -558,19 +661,25 @@ def build( notification_cooldown_interval_minutes: int | None = None, advance_same_identity: bool = True, ) -> dict[str, Any]: + context = self.execution_context.context + app_surface = ( + context.host_surface.value + if context is not None and context.app_automation_applicable + else CODEX_APP_SURFACE + ) local_cadence_progression = cadence_progression_override or [ min(codex_interval * (multiplier**step), codex_max) for step in range(3) ] - codex_host_max = min(max(1, codex_max), CODEX_APP_MAX_INTERVAL_MINUTES) - codex_cadence_progression: list[int] = [] + app_host_max = min(max(1, codex_max), CODEX_APP_MAX_INTERVAL_MINUTES) + app_cadence_progression: list[int] = [] for interval in local_cadence_progression: - bounded_interval = min(max(1, int(interval)), codex_host_max) + bounded_interval = min(max(1, int(interval)), app_host_max) if ( - not codex_cadence_progression - or codex_cadence_progression[-1] != bounded_interval + not app_cadence_progression + or app_cadence_progression[-1] != bounded_interval ): - codex_cadence_progression.append(bounded_interval) - codex_initial_interval = codex_cadence_progression[0] + app_cadence_progression.append(bounded_interval) + app_initial_interval = app_cadence_progression[0] local_initial_interval = local_cadence_progression[0] final_replan_check = { "enabled": cli_limit is not None or claude_limit is not None, @@ -588,29 +697,29 @@ def build( ) ) identity_snapshot = {key: self._identity_value(key) for key in identity_keys} - codex_rrule = rrule_for_minutes(codex_initial_interval) + app_rrule = rrule_for_minutes(app_initial_interval) profile_snapshot = { "cadence_class": cadence_class, - "codex_app_initial_interval_minutes": codex_initial_interval, - "codex_app_initial_rrule": codex_rrule, - "codex_app_max_interval_minutes": codex_host_max, - "codex_app_progression_minutes": codex_cadence_progression, + "app_automation_initial_interval_minutes": app_initial_interval, + "app_automation_initial_rrule": app_rrule, + "app_automation_max_interval_minutes": app_host_max, + "app_automation_progression_minutes": app_cadence_progression, "unchanged_poll_backoff_multiplier": multiplier, "local_scheduler_unchanged_poll_limit": cli_limit, "claude_code_loop_unchanged_poll_limit": claude_limit, } reset_profile_snapshot = reset_profile_snapshot_override or profile_snapshot - reset_token = _stable_digest( - { - "action": action, - "identity_snapshot": identity_snapshot, - "profile_snapshot": reset_profile_snapshot, - }, - length=16, + ( + reset_token, + identity_signature, + profile_signature, + reset_profile_signature, + ) = _scheduler_reset_identity_digests( + action=action, + identity_snapshot=identity_snapshot, + profile_snapshot=profile_snapshot, + reset_profile_snapshot=reset_profile_snapshot, ) - identity_signature = _stable_digest(identity_snapshot, length=12) - profile_signature = _stable_digest(profile_snapshot, length=12) - reset_profile_signature = _stable_digest(reset_profile_snapshot, length=12) reset_policy_detail = { "schema_version": SCHEDULER_RESET_POLICY_SCHEMA_VERSION, "source": "quota.should-run", @@ -618,8 +727,8 @@ def build( "profile_action": action, "reset_token": reset_token, "host_state_key": "scheduler_hint.reset_policy.reset_token", - "codex_app_initial_interval_minutes": codex_initial_interval, - "codex_app_initial_rrule": codex_rrule, + "app_automation_initial_interval_minutes": app_initial_interval, + "app_automation_initial_rrule": app_rrule, "local_scheduler_initial_interval_minutes": local_initial_interval, "clear_unchanged_poll_state": True, "identity_key_count": len(identity_keys), @@ -628,15 +737,15 @@ def build( "reset_profile_signature": reset_profile_signature, "reset_condition_summary": "token_changed|user_feedback|new_or_reassigned_todo|gate_or_material_transition|active_work_projected", "after_reset": "apply_initial_interval_before_backoff", - "codex_app_tool": "automation_update", - "codex_app_apply": "call_automation_update_to_restore_initial_rrule_on_token_change", + "app_automation_tool": "automation_update", + "app_automation_apply": "call_automation_update_to_restore_initial_rrule_on_token_change", "no_spend_for_reset": True, } reset_policy = { "reset_token": reset_token, "host_state_key": "scheduler_hint.reset_policy.reset_token", - "codex_app_initial_interval_minutes": codex_initial_interval, - "codex_app_initial_rrule": codex_rrule, + "app_automation_initial_interval_minutes": app_initial_interval, + "app_automation_initial_rrule": app_rrule, "identity_signature": identity_signature, } local_scheduler = { @@ -673,7 +782,7 @@ def build( scheduler_state = _dict_or_empty(self.codex_app_scheduler_state) scheduler_now = now_utc() backoff_decision = decide_scheduler_backoff_state( - codex_cadence_progression, + app_cadence_progression, scheduler_state=scheduler_state, reset_token=reset_token, identity_signature=identity_signature, @@ -701,9 +810,9 @@ def build( if host_failure_suppressed: state_status = "host_update_failure_suppressed" stateful_backoff_detail = { - "progression_minutes": codex_cadence_progression, + "progression_minutes": app_cadence_progression, "current_interval_minutes": current_interval, - "host_max_interval_minutes": codex_host_max, + "host_max_interval_minutes": app_host_max, "coarser_wait_fallback": "local_scheduler_only", "host_update_failure": "cache_recent_failed_target_and_observed_host_pairs_then_suppress_each_exact_repeat_until_host_changes_ack_or_expiry", "ack_required_after_apply": apply_needed, @@ -717,11 +826,11 @@ def build( "reset_action": "clear_progression_index_apply_initial_rrule", "automation_update_scope": "rrule_only_preserve_body_name_status", } - codex_app = { + app_automation = { "recommended_interval_minutes": current_interval, - "max_interval_minutes": codex_host_max, + "max_interval_minutes": app_host_max, "unchanged_poll_backoff_multiplier": multiplier, - "example_progression_minutes": codex_cadence_progression, + "example_progression_minutes": app_cadence_progression, "apply": ( "update_automation_cadence_if_possible" if apply_needed @@ -755,11 +864,13 @@ def build( ) ), "rrule_source": ( - "scheduler_hint.codex_app.recommended_rrule" if apply_needed else None + "scheduler_hint.app_automation.recommended_rrule" + if apply_needed + else None ), "stateful_backoff": { - "schema_version": CODEX_APP_STATEFUL_BACKOFF_SCHEMA_VERSION, - "state_key": CODEX_APP_STATEFUL_BACKOFF_STATE_KEY, + "schema_version": APP_AUTOMATION_STATEFUL_BACKOFF_SCHEMA_VERSION, + "state_key": APP_AUTOMATION_STATEFUL_BACKOFF_STATE_KEY, "identity_signature": identity_signature, "reset_token": reset_token, "progression_index": current_index, @@ -770,7 +881,7 @@ def build( }, "no_spend_for_cadence_change": True, } - stateful_backoff = codex_app["stateful_backoff"] + stateful_backoff = app_automation["stateful_backoff"] if host_update_failures: stateful_backoff["host_update_failures"] = [ dict(failure) for failure in host_update_failures @@ -794,12 +905,12 @@ def build( "schema_version": "loopx_scheduler_heartbeat_host_facts_v0", "goal_id": str(goal_id), "agent_id": str(agent_id), - "surface": CODEX_APP_SURFACE, - "state_key": CODEX_APP_STATEFUL_BACKOFF_STATE_KEY, + "surface": app_surface, + "state_key": APP_AUTOMATION_STATEFUL_BACKOFF_STATE_KEY, "reset_token": reset_token, "identity_signature": identity_signature, "progression_index": current_index, - "progression_minutes": codex_cadence_progression, + "progression_minutes": app_cadence_progression, "expected_rrule": current_rrule, "cadence_class": cadence_class, "stale_tolerance_minutes": SCHEDULER_ACK_STALE_HINT_TOLERANCE_MINUTES, @@ -811,9 +922,9 @@ def build( else None ) if apply_needed: - codex_app["recommended_rrule"] = current_rrule + app_automation["recommended_rrule"] = current_rrule if goal_id and agent_id: - codex_app["failure_hint"] = build_codex_app_scheduler_failure_hint( + app_automation["failure_hint"] = build_app_automation_scheduler_failure_hint( goal_id=goal_id, agent_id=agent_id, failed_rrule=current_rrule, @@ -829,14 +940,16 @@ def build( "host_match_observed": False, }, scheduler_before=self.payload, + surface=app_surface, ) - codex_app["fallback_hint"] = build_codex_app_scheduler_fallback_hint( - goal_id=goal_id, - agent_id=agent_id, - automation_id=self.codex_app_automation_id, - ) + if app_surface == CODEX_APP_SURFACE: + app_automation["fallback_hint"] = build_codex_app_scheduler_fallback_hint( + goal_id=goal_id, + agent_id=agent_id, + automation_id=self.codex_app_automation_id, + ) if ack_needed and goal_id and agent_id: - codex_app["ack_hint"] = build_codex_app_scheduler_ack_hint( + app_automation["ack_hint"] = build_app_automation_scheduler_ack_hint( goal_id=goal_id, agent_id=agent_id, applied_rrule=current_rrule, @@ -859,6 +972,7 @@ def build( "host_match_observed": True, }, scheduler_before=self.payload, + surface=app_surface, ) unchanged_poll_limits = { "local_scheduler": cli_limit, @@ -892,7 +1006,7 @@ def build( "reason_code": self.arbitration.reason_code, "reason": reason, "spend_policy": self.spend_policy, - "codex_app": codex_app, + "app_automation": app_automation, "unchanged_poll": { "limits": unchanged_poll_limits, "after_limits": unchanged_poll_after_limits, @@ -912,13 +1026,27 @@ def build( "execution_required": False, "request": "loopx quota should-run --include-detail scheduler", "hot_path_runtime_fields": [ - "codex_app", + "app_automation", "unchanged_poll", "reset_policy", ], "contains": detail_contains, }, } + if app_surface == CODEX_APP_SURFACE: + scheduler_hint["codex_app"] = build_codex_app_compatibility_projection( + app_automation, + goal_id=goal_id, + agent_id=agent_id, + available_capabilities=self.scheduler_ack_capabilities, + scheduler_host_facts=scheduler_host_facts, + observed_host_rrule=effective_host_rrule, + scheduler_before=self.payload, + automation_id=self.codex_app_automation_id, + build_ack_hint=build_codex_app_scheduler_ack_hint, + build_failure_hint=build_codex_app_scheduler_failure_hint, + build_fallback_hint=build_codex_app_scheduler_fallback_hint, + ) notification_cooldown = _user_gate_notification_cooldown( cadence_class=cadence_class, host_failure_suppressed=host_failure_suppressed, @@ -994,9 +1122,9 @@ def _monitor_bounded_wait_profile( monitor_reset_profile = ( { "cadence_class": cadence_class, - "codex_app_initial_interval_minutes": initial_interval, - "codex_app_initial_rrule": rrule_for_minutes(initial_interval), - "codex_app_max_interval_minutes": max_interval_minutes, + "app_automation_initial_interval_minutes": initial_interval, + "app_automation_initial_rrule": rrule_for_minutes(initial_interval), + "app_automation_max_interval_minutes": max_interval_minutes, "unchanged_poll_backoff_multiplier": 2, "local_scheduler_unchanged_poll_limit": 3, "claude_code_loop_unchanged_poll_limit": 3, @@ -1038,7 +1166,13 @@ def build_scheduler_hint( execution_context = resolve_scheduler_execution_context(scheduler_execution_context) if not execution_context.ok: - return { + blocked_app_automation = { + "applicability": "blocked_invalid_context", + "apply": "none", + "host_action": "none", + "ack_required": False, + } + blocked_hint = { "schema_version": SCHEDULER_HINT_SCHEMA_VERSION, "source": "quota.should-run", "action": "repair_scheduler_execution_context", @@ -1058,12 +1192,6 @@ def build_scheduler_hint( "ack_needed": False, "acknowledged": False, }, - "codex_app": { - "applicability": "blocked_invalid_context", - "apply": "none", - "host_action": "none", - "ack_required": False, - }, "unchanged_poll": { "local_scheduler": "stop_until_context_repaired", "codex_cli_tui": "stop_until_context_repaired", @@ -1077,6 +1205,14 @@ def build_scheduler_hint( "errors": list(execution_context.errors), }, } + supplied_host_surface = str( + (execution_context.supplied or {}).get("host_surface") or "" + ).strip() + if supplied_host_surface == "trae_app": + blocked_hint["app_automation"] = blocked_app_automation + else: + blocked_hint["codex_app"] = blocked_app_automation + return blocked_hint heartbeat_recommendation = _dict_or_empty(payload.get("heartbeat_recommendation")) pause_mode = str(heartbeat_recommendation.get("recommended_mode") or "") @@ -1107,7 +1243,7 @@ def build_scheduler_hint( if goal_stopped else "no quota spend for paused automation shutdown" ), - "codex_app": { + "app_automation": { "apply": "pause_or_delete_current_heartbeat_if_possible", "host_tool": "automation_update", "host_action": "pause_or_delete_current_heartbeat", diff --git a/loopx/control_plane/scheduler/state.py b/loopx/control_plane/scheduler/state.py index be786c2d31..ab2db5f74e 100644 --- a/loopx/control_plane/scheduler/state.py +++ b/loopx/control_plane/scheduler/state.py @@ -18,7 +18,17 @@ ) SCHEDULER_STATE_STORE_REQUEST_SCHEMA = "loopx_scheduler_state_store_request_v0" SCHEDULER_STATE_STORE_RESULT_SCHEMA = "loopx_scheduler_state_store_result_v0" -CODEX_APP_STATEFUL_BACKOFF_STATE_KEY = "scheduler_hint.codex_app.stateful_backoff" +APP_AUTOMATION_STATEFUL_BACKOFF_STATE_KEY = ( + "scheduler_hint.app_automation.stateful_backoff" +) +LEGACY_CODEX_APP_STATEFUL_BACKOFF_STATE_KEY = ( + "scheduler_hint.codex_app.stateful_backoff" +) +# Historical Codex callers continue to default to their legacy key. Canonical +# App packets pass APP_AUTOMATION_STATEFUL_BACKOFF_STATE_KEY explicitly. +CODEX_APP_STATEFUL_BACKOFF_STATE_KEY = ( + LEGACY_CODEX_APP_STATEFUL_BACKOFF_STATE_KEY +) CODEX_APP_SURFACE = "codex_app" @@ -293,6 +303,33 @@ def load_scheduler_state( return state +def load_app_automation_scheduler_state( + runtime_root: Path, + *, + goal_id: str, + agent_id: str | None, + surface: str, +) -> dict[str, Any] | None: + current = load_scheduler_state( + runtime_root, + goal_id=goal_id, + agent_id=agent_id, + surface=surface, + state_key=APP_AUTOMATION_STATEFUL_BACKOFF_STATE_KEY, + ) + if current is not None or surface != CODEX_APP_SURFACE: + return current + # Codex alone reads its pre-app_automation key so the next successful ACK + # can rewrite the cadence state under the provider-neutral contract. + return load_scheduler_state( + runtime_root, + goal_id=goal_id, + agent_id=agent_id, + surface=surface, + state_key=LEGACY_CODEX_APP_STATEFUL_BACKOFF_STATE_KEY, + ) + + def write_scheduler_state( runtime_root: Path, state: dict[str, Any], diff --git a/loopx/control_plane/scheduler/state_store.ts b/loopx/control_plane/scheduler/state_store.ts index 3a1acbec78..5dbd1714a7 100644 --- a/loopx/control_plane/scheduler/state_store.ts +++ b/loopx/control_plane/scheduler/state_store.ts @@ -29,8 +29,12 @@ export const SCHEDULER_STATE_STORE_REQUEST_SCHEMA = export const SCHEDULER_STATE_STORE_RESULT_SCHEMA = "loopx_scheduler_state_store_result_v0"; -export const CODEX_APP_STATEFUL_BACKOFF_STATE_KEY = +export const APP_AUTOMATION_STATEFUL_BACKOFF_STATE_KEY = + "scheduler_hint.app_automation.stateful_backoff"; +export const LEGACY_CODEX_APP_STATEFUL_BACKOFF_STATE_KEY = "scheduler_hint.codex_app.stateful_backoff"; +export const CODEX_APP_STATEFUL_BACKOFF_STATE_KEY = + LEGACY_CODEX_APP_STATEFUL_BACKOFF_STATE_KEY; export const CODEX_APP_SURFACE = "codex_app"; const HOST_UPDATE_FAILURE_CACHE_LIMIT = 4; diff --git a/loopx/control_plane/testing/decision_replay.py b/loopx/control_plane/testing/decision_replay.py index a479c52114..da9658fea0 100644 --- a/loopx/control_plane/testing/decision_replay.py +++ b/loopx/control_plane/testing/decision_replay.py @@ -96,7 +96,9 @@ def reduce_public_safe_decision( user_channel = _mapping(interaction.get("user_channel")) agent_channel = _mapping(interaction.get("agent_channel")) scheduler = _mapping(payload.get("scheduler_hint")) - codex_app = _mapping(scheduler.get("codex_app")) + codex_app = _mapping( + scheduler.get("app_automation") or scheduler.get("codex_app") + ) selected_todo = _mapping(payload.get("selected_todo")) reduced = { "schema_version": PUBLIC_SAFE_DECISION_CASE_SCHEMA_VERSION, diff --git a/loopx/control_plane/testing/model_behavior_packet_safety.py b/loopx/control_plane/testing/model_behavior_packet_safety.py index 9a2a9d7877..086df37a21 100644 --- a/loopx/control_plane/testing/model_behavior_packet_safety.py +++ b/loopx/control_plane/testing/model_behavior_packet_safety.py @@ -125,19 +125,19 @@ def scheduler_transport_validation_view(packet: Mapping[str, Any], *, arm: str) scheduler = view.get("scheduler") if not isinstance(scheduler, Mapping): return view - codex_app = scheduler.get("codex_app") - if not isinstance(codex_app, Mapping): + app_automation = scheduler.get("app_automation") or scheduler.get("codex_app") + if not isinstance(app_automation, Mapping): return view if arm == "full_packet": for kind, command in (("ack", "scheduler-ack-current"), ("failure", "scheduler-fail-current")): - hint = codex_app.get(kind + "_hint") + hint = app_automation.get(kind + "_hint") args = hint.get("cli_args") if isinstance(hint, Mapping) else None if isinstance(hint, Mapping) and isinstance(args, list): _expose_chunks(args, command=command, schema_valid=scheduler.get("schema_version") == "scheduler_hint_v0" - and hint.get("schema_version") == f"codex_app_scheduler_{kind}_hint_v0") + and hint.get("schema_version") == f"app_automation_scheduler_{kind}_hint_v0") else: - args = codex_app.get("ack_cli_args") + args = app_automation.get("ack_cli_args") if isinstance(args, list): _expose_chunks(args, command="scheduler-ack-current", schema_valid=packet.get("schema_version") == "loopx_turn_envelope_v0") diff --git a/loopx/control_plane/work_items/accountable_settlement.py b/loopx/control_plane/work_items/accountable_settlement.py index a0785f2709..81fcfb7976 100644 --- a/loopx/control_plane/work_items/accountable_settlement.py +++ b/loopx/control_plane/work_items/accountable_settlement.py @@ -7,6 +7,7 @@ build_turn_scoped_cli_settlement_plan, ) from ..scheduler.execution_context import ( + APP_HEARTBEAT_SETTLEMENT_RUNTIME_PROFILES, SchedulerRuntimeProfile, VISIBLE_GOAL_SETTLEMENT_RUNTIME_PROFILES, ) @@ -25,7 +26,7 @@ def build_accountable_work_item_settlement_plan( delivery_boundary: str | None = None, command_prefix: str = "loopx", ) -> SettlementPlan | None: - if runtime_profile is SchedulerRuntimeProfile.CODEX_APP_HEARTBEAT: + if runtime_profile in APP_HEARTBEAT_SETTLEMENT_RUNTIME_PROFILES: normalized_turn_instance_id = normalize_turn_instance_id(turn_instance_id) return build_codex_app_settlement_plan( goal_id=goal_id, diff --git a/loopx/control_plane/work_items/interaction_contract.py b/loopx/control_plane/work_items/interaction_contract.py index 3bb5c671d6..68c8ebfd06 100644 --- a/loopx/control_plane/work_items/interaction_contract.py +++ b/loopx/control_plane/work_items/interaction_contract.py @@ -19,8 +19,8 @@ host_goal_turn_reentry_action, ) from ..scheduler.execution_context import ( + APP_HEARTBEAT_SETTLEMENT_RUNTIME_PROFILES, SchedulerExecutionContextResolution, - SchedulerRuntimeProfile, render_scheduler_execution_args, scheduler_runtime_profile_for_execution_context, ) @@ -740,7 +740,7 @@ def interaction_next_cli_actions( scheduler_runtime_profile_for_execution_context( scheduler_execution_context ) - is SchedulerRuntimeProfile.CODEX_APP_HEARTBEAT + in APP_HEARTBEAT_SETTLEMENT_RUNTIME_PROFILES ) if mode == "governed_capability_intent": projection = ( diff --git a/loopx/diagnose.py b/loopx/diagnose.py index 5e61bebb45..4451726448 100644 --- a/loopx/diagnose.py +++ b/loopx/diagnose.py @@ -287,23 +287,26 @@ def _compact_interaction_contract(contract: dict[str, Any]) -> dict[str, Any]: def _compact_scheduler_hint(scheduler_hint: dict[str, Any]) -> dict[str, Any]: if not scheduler_hint: return {} - codex_app = _as_dict(scheduler_hint.get("codex_app")) - stateful_backoff = _as_dict(codex_app.get("stateful_backoff")) + canonical_app_automation = _as_dict(scheduler_hint.get("app_automation")) + legacy_codex_app = _as_dict(scheduler_hint.get("codex_app")) + app_automation = canonical_app_automation or legacy_codex_app + app_key = "app_automation" if canonical_app_automation else "codex_app" + stateful_backoff = _as_dict(app_automation.get("stateful_backoff")) reset_policy = _as_dict(scheduler_hint.get("reset_policy")) unchanged_poll = _as_dict(scheduler_hint.get("unchanged_poll")) - return { + compact = { "schema_version": "diagnose_scheduler_hint_summary_v0", "action": scheduler_hint.get("action"), "cadence_class": scheduler_hint.get("cadence_class"), "reason": scheduler_hint.get("reason"), - "codex_app": { - "apply": codex_app.get("apply"), - "host_action": codex_app.get("host_action"), - "recommended_rrule": codex_app.get("recommended_rrule"), - "recommended_interval_minutes": codex_app.get("recommended_interval_minutes"), + app_key: { + "apply": app_automation.get("apply"), + "host_action": app_automation.get("host_action"), + "recommended_rrule": app_automation.get("recommended_rrule"), + "recommended_interval_minutes": app_automation.get("recommended_interval_minutes"), "current_rrule": stateful_backoff.get("current_rrule"), "apply_needed": stateful_backoff.get("apply_needed"), - "no_spend_for_cadence_change": codex_app.get("no_spend_for_cadence_change"), + "no_spend_for_cadence_change": app_automation.get("no_spend_for_cadence_change"), }, "unchanged_poll": { "final_quota_replan_check_enabled": unchanged_poll.get( @@ -315,9 +318,15 @@ def _compact_scheduler_hint(scheduler_hint: dict[str, Any]) -> dict[str, Any]: }, "reset_policy": { "reset_token": reset_policy.get("reset_token"), - "codex_app_initial_rrule": reset_policy.get("codex_app_initial_rrule"), }, } + reset_key = ( + "app_automation_initial_rrule" + if canonical_app_automation + else "codex_app_initial_rrule" + ) + compact["reset_policy"][reset_key] = reset_policy.get(reset_key) + return compact def _goal_frontier_projection_line(goal_frontier: dict[str, Any]) -> str | None: @@ -371,18 +380,24 @@ def _goal_frontier_projection_line(goal_frontier: dict[str, Any]) -> str | None: def _scheduler_hint_line(scheduler_hint: dict[str, Any]) -> str | None: if not scheduler_hint: return None - codex_app = _as_dict(scheduler_hint.get("codex_app")) + canonical_app_automation = _as_dict(scheduler_hint.get("app_automation")) + app_automation = canonical_app_automation or _as_dict( + scheduler_hint.get("codex_app") + ) + apply_label = ( + "app_automation_apply" if canonical_app_automation else "codex_app_apply" + ) unchanged_poll = _as_dict(scheduler_hint.get("unchanged_poll")) return ( "- scheduler_hint: " f"action={scheduler_hint.get('action')} " f"cadence={scheduler_hint.get('cadence_class')} " - f"codex_app_apply={codex_app.get('apply')} " - f"apply_needed={codex_app.get('apply_needed')} " - f"recommended_rrule={codex_app.get('recommended_rrule')} " - f"current_rrule={codex_app.get('current_rrule')} " + f"{apply_label}={app_automation.get('apply')} " + f"apply_needed={app_automation.get('apply_needed')} " + f"recommended_rrule={app_automation.get('recommended_rrule')} " + f"current_rrule={app_automation.get('current_rrule')} " f"final_replan_check={unchanged_poll.get('final_quota_replan_check_enabled')} " - f"no_spend_for_cadence_change={codex_app.get('no_spend_for_cadence_change')}" + f"no_spend_for_cadence_change={app_automation.get('no_spend_for_cadence_change')}" ) diff --git a/loopx/host_loop_activation.py b/loopx/host_loop_activation.py index 0a9484d9ab..2f69c8d9b7 100644 --- a/loopx/host_loop_activation.py +++ b/loopx/host_loop_activation.py @@ -31,6 +31,7 @@ { "ark-managed-agent", "deepseek-harness-native", + "trae_app", "traex-cli", "other-agent", } @@ -47,6 +48,7 @@ def scheduler_command_binding_for_agent_type( "codex-app-ssh": SchedulerRuntimeProfile.CODEX_APP_SSH_VISIBLE, "codex-cli": SchedulerRuntimeProfile.CODEX_CLI_VISIBLE, "codex-ide-plugin": SchedulerRuntimeProfile.CODEX_CLI_VISIBLE, + "trae_app": SchedulerRuntimeProfile.TRAE_APP, "claude-code": SchedulerRuntimeProfile.CLAUDE_CODE_VISIBLE, "kunluncode": SchedulerRuntimeProfile.KUNLUNCODE_VISIBLE, "opencode": SchedulerRuntimeProfile.GENERIC_CLI_AGENT_LOOP, @@ -76,6 +78,7 @@ def agent_type_uses_host_managed_skills(agent_type: str) -> bool: "codex-app-ssh", "codex-ide-plugin", "codex-cli", + "trae_app", "claude-code", "kunluncode", "opencode", @@ -140,6 +143,14 @@ def agent_type_uses_host_managed_skills(agent_type: str) -> bool: "codex tui", ], }, + "trae_app": { + "display_name": "Trae App", + "host_loop": "Trae App heartbeat automation", + "entry": "$loopx or the explicit LoopX skill from /skills", + "accepted_inputs": [ + "trae_app", + ], + }, "codex-ide-plugin": { "display_name": "Codex IDE plugin", "host_loop": "visible Codex IDE plugin /goal", @@ -324,6 +335,7 @@ def _agent_type_key(value: str | None) -> str: _agent_type_key(alias): canonical for canonical, metadata in AGENT_TYPE_CATALOG.items() for alias in metadata["accepted_inputs"] + if alias != canonical } @@ -352,6 +364,7 @@ def to_payload(self) -> dict[str, Any]: "codex-app": "codex-app", "codex-app-ssh": "codex-app-ssh", "chat-box": "codex-app", + "trae_app": "trae_app", "codex-ide-plugin": "codex-ide-plugin", "codex-ide": "codex-ide-plugin", "codex-cli-tui": "codex-cli", @@ -455,6 +468,9 @@ def render_agent_type_catalog_markdown(payload: dict[str, Any]) -> str: def normalize_agent_type(value: str | None) -> str: + canonical = (value or "").strip().lower() + if canonical in AGENT_TYPE_CATALOG: + return canonical key = _agent_type_key(value) if not key: raise AgentTypeError( @@ -501,6 +517,7 @@ def _heartbeat_commands( scope_by_type = { "ark-managed-agent": "Ark Managed Agent one-shot Goal activation", "codex-app": "Codex App heartbeat automation", + "trae_app": "Trae App heartbeat automation", "codex-app-ssh": "Codex App SSH /goal visible task loop", "codex-ide-plugin": "Codex IDE plugin /goal visible task loop", "codex-cli": "Codex CLI /goal visible TUI loop", @@ -544,7 +561,7 @@ def _heartbeat_commands( **renderer_binding, ), } - if agent_type in {"codex-app", "codex-app-ssh", "codex-cli", "codex-ide-plugin", + if agent_type in {"codex-app", "trae_app", "codex-app-ssh", "codex-cli", "codex-ide-plugin", "ark-managed-agent"}: commands = {key: command + " --bootstrap" for key, command in commands.items()} if renderer_binding: @@ -771,6 +788,35 @@ def _codex_app_activation(commands: dict[str, str]) -> dict[str, Any]: } +def _trae_app_activation(commands: dict[str, str]) -> dict[str, Any]: + return { + "host_surface": "trae_app", + "entry_command_hint": "$loopx or the explicit LoopX skill from /skills", + "activation_method": "create_or_update_trae_app_automation", + "activation_input_command": commands["heartbeat_prompt_json"], + "host_mutation": { + "owner": "Trae App host", + "preferred_tool": "automation_update", + "cli_can_mutate_directly": False, + "missing_host_tool_gate": ( + "Trae App automation_update is unavailable; surface a pasteable " + "heartbeat task_body gate instead of claiming autonomous setup." + ), + }, + "activation_steps": [ + "Run the heartbeat-prompt JSON command after project state and todos are written.", + "Read task_body from the JSON payload.", + "Create or update a Trae App heartbeat automation starting at 3 minutes.", + "On later ticks, follow quota should-run scheduler_hint for backoff, reset, and scheduler-ack.", + ], + "success_criteria": [ + "A Trae App heartbeat automation exists for this goal and uses the generated task_body.", + "A settled non-terminal turn leaves the automation active for a fresh successor turn.", + "The next wakeup starts from LoopX quota/status/state, not stale chat memory.", + ], + } + + def _ark_managed_agent_activation(commands: dict[str, str]) -> dict[str, Any]: return { "host_surface": "ark_managed_agent_goal_mode", @@ -1215,6 +1261,8 @@ def build_host_loop_activation_packet( surface = _ark_managed_agent_activation(commands) elif canonical == "codex-app": surface = _codex_app_activation(commands) + elif canonical == "trae_app": + surface = _trae_app_activation(commands) elif canonical == "codex-app-ssh": surface = _codex_app_ssh_activation(commands) elif canonical == "codex-ide-plugin": diff --git a/loopx/presentation/renderers/quota_markdown.py b/loopx/presentation/renderers/quota_markdown.py index c17ccce3bf..940357e874 100644 --- a/loopx/presentation/renderers/quota_markdown.py +++ b/loopx/presentation/renderers/quota_markdown.py @@ -873,7 +873,9 @@ def render_quota_should_run_markdown(payload: dict[str, Any]) -> str: lines.append(f"- automation_pause_policy: {automation_liveness.get('pause_policy')}") scheduler_hint = as_dict(payload.get("scheduler_hint")) if scheduler_hint: - codex_app = as_dict(scheduler_hint.get("codex_app")) + app_automation = as_dict( + scheduler_hint.get("app_automation") or scheduler_hint.get("codex_app") + ) unchanged_poll = as_dict(scheduler_hint.get("unchanged_poll")) limits = as_dict(unchanged_poll.get("limits")) codex_cli_tui = as_dict(scheduler_hint.get("codex_cli_tui")) @@ -892,10 +894,10 @@ def render_quota_should_run_markdown(payload: dict[str, Any]) -> str: "- scheduler_hint: " f"action={scheduler_hint.get('action')} " f"cadence={scheduler_hint.get('cadence_class')} " - f"codex_app_minutes={codex_app.get('recommended_interval_minutes')} " - f"codex_app_rrule={codex_app.get('recommended_rrule')} " - f"codex_app_apply_needed={(codex_app.get('stateful_backoff') or {}).get('apply_needed') if isinstance(codex_app.get('stateful_backoff'), dict) else None} " - f"codex_app_progression={codex_app.get('example_progression_minutes')} " + f"app_automation_minutes={app_automation.get('recommended_interval_minutes')} " + f"app_automation_rrule={app_automation.get('recommended_rrule')} " + f"app_automation_apply_needed={(app_automation.get('stateful_backoff') or {}).get('apply_needed') if isinstance(app_automation.get('stateful_backoff'), dict) else None} " + f"app_automation_progression={app_automation.get('example_progression_minutes')} " f"cli_unchanged_limit={cli_unchanged_limit} " f"claude_unchanged_limit={claude_unchanged_limit}" ) @@ -904,8 +906,8 @@ def render_quota_should_run_markdown(payload: dict[str, Any]) -> str: if reset_policy := as_dict(scheduler_hint.get("reset_policy")): lines.append( "- scheduler_reset: " - f"initial_interval={reset_policy.get('codex_app_initial_interval_minutes')} " - f"initial_rrule={reset_policy.get('codex_app_initial_rrule')} " + f"initial_interval={reset_policy.get('app_automation_initial_interval_minutes')} " + f"initial_rrule={reset_policy.get('app_automation_initial_rrule')} " f"reset_generation={reset_policy.get('reset_token')} " f"identity_signature={reset_policy.get('identity_signature')}" ) diff --git a/loopx/ready_score.py b/loopx/ready_score.py index edad431221..bcb13fa339 100644 --- a/loopx/ready_score.py +++ b/loopx/ready_score.py @@ -75,8 +75,10 @@ def _first_executable_count(summary: dict[str, Any]) -> int: def _quota_scheduler_apply_needed(quota_payload: dict[str, Any]) -> bool | None: scheduler = _as_dict(quota_payload.get("scheduler_hint")) - codex_app = _as_dict(scheduler.get("codex_app")) - stateful = _as_dict(codex_app.get("stateful_backoff")) + app_automation = _as_dict( + scheduler.get("app_automation") or scheduler.get("codex_app") + ) + stateful = _as_dict(app_automation.get("stateful_backoff")) value = stateful.get("apply_needed") return value if isinstance(value, bool) else None diff --git a/loopx/slash_command_install.py b/loopx/slash_command_install.py index d163b3b293..d60b5da97e 100644 --- a/loopx/slash_command_install.py +++ b/loopx/slash_command_install.py @@ -171,7 +171,7 @@ def _command_prompt_specs(*, cli_bin: str, include_legacy_aliases: bool) -> list "argument_hint": "[--fine-grained] [--capability-route issue-fix] [task text]", "instructions": [ "Visible command arguments: `$ARGUMENTS`.", - "Identify the exact current host surface (codex-app, codex-app-ssh, codex-ide-plugin, codex-cli-tui, opencode, opencode2, traex-cli, pi, gemini-cli, cursor-agent, zcode, agy, kiro-cli, deepseek-harness, or ark-managed-agent).", + "Identify the exact current host surface (codex-app, trae_app, codex-app-ssh, codex-ide-plugin, codex-cli-tui, opencode, opencode2, traex-cli, pi, gemini-cli, cursor-agent, zcode, agy, kiro-cli, deepseek-harness, or ark-managed-agent).", _loopx_start_goal_arguments_instruction( cli_bin=cli_bin, host_surface=None, diff --git a/loopx/slash_commands.py b/loopx/slash_commands.py index 6b3f582f4f..023a08c079 100644 --- a/loopx/slash_commands.py +++ b/loopx/slash_commands.py @@ -68,6 +68,7 @@ def build_slash_command_catalog( "host_loop_activation_catalog": f"{cli_bin} agent-onboard --list-agent-types", "host_loop_activation_by_agent_type": { "codex-app": "create/update Codex App heartbeat automation from heartbeat-prompt task_body", + "trae_app": "create/update Trae App heartbeat automation from heartbeat-prompt task_body", "codex-cli": "set visible Codex CLI TUI `/goal `", "claude-code": "arm LoopX with `/loopx `, then run native `/loop`", "opencode": "call `loopx_goal_activate`", diff --git a/skills/loopx-project/SKILL.md b/skills/loopx-project/SKILL.md index 13fd49587a..5e13884959 100644 --- a/skills/loopx-project/SKILL.md +++ b/skills/loopx-project/SKILL.md @@ -71,8 +71,9 @@ Append `--capability-route issue-fix` only when the caller supplied that exact explicit route switch. Include `--goal-id ` when known. Codex App automatically reads -the stable ambient `CODEX_THREAD_ID`; other hosts that expose a stable opaque -thread id should pass it as `--thread-id ` on every `/loopx` +the stable ambient `CODEX_THREAD_ID`, while Trae App reads +`TRAECLI_THREAD_ID`; other hosts that expose a stable opaque thread id should +pass it as `--thread-id ` on every `/loopx` invocation. If that thread is already bound, reuse the returned `--agent-id ` on start, heartbeat, quota, refresh-state, and Todo commands. Include `--agent-id ` only when the @@ -574,22 +575,22 @@ the next wakeup cadence and external-loop unchanged-poll self-stop; this is scheduling policy, not delivery permission. Codex CLI TUI and Claude Code loops should run the final quota/replan check from `scheduler_hint` before applying their `after_limit`; if the guard changes or returns `run_now`, follow the new -quota contract instead of stopping. Codex App heartbeat workers should +quota contract instead of stopping. App-hosted heartbeat workers should search/use `automation_update` when available. If `scheduler_hint.action=stop_until_explicit_resume` and -`scheduler_hint.codex_app.host_action=pause_or_delete_current_heartbeat`, call +`scheduler_hint.app_automation.host_action=pause_or_delete_current_heartbeat`, call `automation_update` once to pause the current heartbeat (delete only when the host cannot pause), verify the host result, spend no quota, and end the turn. This terminal host action takes precedence over RRULE handling and requires no scheduler ACK. Otherwise use `automation_update` only when -`scheduler_hint.codex_app.stateful_backoff.apply_needed=true` and -`scheduler_hint.codex_app.recommended_rrule` is present. After a successful +`scheduler_hint.app_automation.stateful_backoff.apply_needed=true` and +`scheduler_hint.app_automation.recommended_rrule` is present. After a successful RRULE update, run `loopx` with -`scheduler_hint.codex_app.ack_hint.cli_args` (normally `quota scheduler-ack-current`, +`scheduler_hint.app_automation.ack_hint.cli_args` (normally `quota scheduler-ack-current`, which re-reads the latest scheduler hint instead of hand-copying short-lived reset tokens). Attempt the host update at most once per hint and turn. If it fails or times out, do not retry or ACK; run -`scheduler_hint.codex_app.failure_hint.cli_args` once. That no-spend writeback +`scheduler_hint.app_automation.failure_hint.cli_args` once. That no-spend writeback records the failed target/observed-host pair so later heartbeats suppress the exact repeat until either value changes. Continue allowed delivery under the observed host cadence. If @@ -599,7 +600,7 @@ ack hint directly. LoopX owns reset/progression state and omits `recommended_rrule` when the desired RRULE is already applied. Cadence changes, reset-to-initial updates, final checks, and self-stop changes do not spend quota. -For a uniquely matched active Codex App heartbeat, `quota should-run` +For a uniquely matched active App heartbeat, `quota should-run` automatically reconciles the installed RRULE with LoopX's ACK ledger. Treat `stateful_backoff.host_observation.status=drift_detected` as authoritative for cadence repair; a stale or premature ACK must not suppress `apply_needed`. diff --git a/skills/loopx-self-repair/references/repair-patterns.md b/skills/loopx-self-repair/references/repair-patterns.md index bb920aae25..ef01f0cf13 100644 --- a/skills/loopx-self-repair/references/repair-patterns.md +++ b/skills/loopx-self-repair/references/repair-patterns.md @@ -119,10 +119,10 @@ teaches a reusable control-plane lesson. | `vision_replan_writeback_gap` | The user or agent identifies a missing outcome, acceptance condition, or product bottleneck, but later `quota should-run` shows no `goal_frontier_projection.acceptance_gaps[]`, no per-agent `agent_vision` / `vision_checkpoint_v0`, and no replan obligation; the agent keeps following older runnable todos or monitor waits. | recent user correction, `loopx history --goal-id`, latest runs' `agent_vision` and `vision_checkpoint`, quota `goal_frontier_projection.acceptance_gaps`, active-state todos, bad-case report or incident note. | The insight stayed in chat, prose, or a report instead of being converted into a bounded `goal_vision_replan_contract_v0` packet, per-agent checkpoint decision, or concrete successor todo. | Write `loopx refresh-state --agent-id --vision-summary ... --vision-replan-trigger ...` or `--agent-vision-json ` with a compact `replan_trigger_summary`; use `--vision-unchanged-reason` when the current per-agent vision still applies; and add/link a successor todo when the next executable step is known. Then rerun quota so the vision gap or `vision_checkpoint_missing` can become `autonomous_replan_required` before local quiet/wait states. | | `vision_checkpoint_rearm_loop` | An accepted vision replan alternates with ordinary work on the same Todo, and a later `unchanged_with_reason` closeout immediately creates another `vision_outcome_checkpoint_required`; an agent may also reconstruct `semantic_closeout` even though quota projected `in_flight_continuation`. | exact quota settlement template, selected Todo id, delivery boundary/outcome, latest accepted semantic ACK, latest agent vision revision and evidence-linked path, latest outcome checkpoint decision and continuity basis. | The TypeScript checkpoint accepted an unchanged decision without exporting which vision revision it covered, so the outcome reducer rejected every unchanged checkpoint; weak agents also summarized quota and rebuilt writeback instead of preserving the projected flags. | Bind `unchanged_with_reason` to the exact persisted vision revision in the TypeScript checkpoint and accept it only while that same revision still carries an evidence-linked legal path outcome. Preserve accepted ACKs behind newer rejected attempts. Require heartbeat agents to start from `cli_channel.next_cli_actions[0]` and keep every projected boundary/identity flag. A missing checkpoint, changed revision, or evidence-free path must still re-arm. | | `active_goal_closed_vision_succession_gap` | A bounded stage is correctly marked `vision_closed`, but the registry goal remains active; quota shows no acceptance gap or exposes one without requiring replan, then continues ordinary or resume-ready deferred work or monitor quiet without establishing the next vision. | latest per-agent `agent_vision`, registry goal status, quota `goal_frontier_projection`, remaining open/deferred advancement todos, terminal lane rationale. | Closed vision was treated as terminal, or a generic ready-deferred shortcut returned before the successor-vision gap was evaluated, so closing one accepted stage erased or demoted the long-horizon vision frontier. | Treat `vision_closed` as stage closure: for an active registry goal, project `vision_successor_required` and run replan even when ordinary or resume-ready deferred advancement already exists. Explicit user/blocking handoff gates still take precedence. Only `retired`, `superseded`, or `no_followup` may terminate the lane without another vision. Cover active/inactive goal and runnable/deferred/empty frontier cases. | -| `scheduler_liveness_backoff_gap` | `quota should-run` says `should_run=false`, `quiet_noop_allowed=true`, no spend, or `automation_action=keep_active_quiet`, but the host keeps polling at the bootstrap cadence for hours; Codex CLI TUI or Claude Code `/loop` also keeps repeating unchanged checks. | quota payload `automation_liveness`, `interaction_contract`, `heartbeat_recommendation`, host automation cadence, CLI/Claude loop logs or statusline, recent no-spend poll history. | LoopX separated execution permission from user notification, but did not expose a machine-readable next-wakeup/backoff/final-check/exit policy for host runtimes. | Add or repair `quota should-run.scheduler_hint`; host runtimes must apply Codex App cadence backoff and CLI/Claude unchanged-poll final quota/replan check before self-stop, without quota spend. Prompt/docs should treat fixed 3-minute cadence as bootstrap only. | +| `scheduler_liveness_backoff_gap` | `quota should-run` says `should_run=false`, `quiet_noop_allowed=true`, no spend, or `automation_action=keep_active_quiet`, but the host keeps polling at the bootstrap cadence for hours; Codex CLI TUI or Claude Code `/loop` also keeps repeating unchanged checks. | quota payload `automation_liveness`, `interaction_contract`, `heartbeat_recommendation`, host automation cadence, CLI/Claude loop logs or statusline, recent no-spend poll history. | LoopX separated execution permission from user notification, but did not expose a machine-readable next-wakeup/backoff/final-check/exit policy for host runtimes. | Add or repair `quota should-run.scheduler_hint`; App hosts must apply the provider-neutral `app_automation` cadence packet, while CLI/Claude loops run a final quota/replan check before self-stop, without quota spend. Prompt/docs should treat fixed 3-minute cadence as bootstrap only. | | `monitor_host_floor_deadline_violation` | A monitor declares a cadence or next due time below 15 minutes, but Codex App remains on the `[15, 30, 60]` quiet-wait progression and wakes after the monitor is due. | quota scheduler detail `cadence_context`, monitor `cadence`/`next_due_at`, recommended and observed host RRULE, host-update failure state, recent wake timestamps. | The quiet-monitor 15-minute backoff floor was applied before the explicit monitor cadence/due cap, turning an efficiency default into a deadline floor. | Derive the effective host floor from the tightest explicit monitor cadence or due horizon; retain `[15, 30, 60]` only when no tighter wake is required. Cover both sub-15 cadence and sub-15 due-horizon cases with the monitor scheduler smoke. | | `terminal_no_followup_retrigger` | Complete valid user/agent todo sources carry no-follow-up closure evidence and the normalized goal frontier is empty, but `quota should-run` still returns `eligible`, `must_attempt_work=true`, or `scheduler_hint.action=run_now`; another form emits a stop hint but the Codex App heartbeat remains active. | derived `goal_frontier_projection.terminal_state`, source completeness, todo summaries, execution obligation, automation liveness, scheduler hint, generated heartbeat prompt, and active host automation state. | Quota treated every `waiting_on=codex` goal with compute as runnable, trusted a free-form status marker, or exposed a host action that the heartbeat prompt never executed. | Derive read-only terminal state only from valid complete todo sources, closure intent, and an independently empty frontier; fail closed on missing/malformed data or any open dimension. Project no execution/spend plus `stop_until_explicit_resume`, and require the heartbeat to call `automation_update` once and verify the stop. | -| `scheduler_reset_application_gap` | Codex App heartbeat cadence backs off, but repeated unchanged polls do not advance the progression; after user feedback, a new/reassigned todo, resolved gate, material transition, or `run_now`, the automation remains slow; LoopX reports `apply_needed=false` while the installed App RRULE differs; or a matching reset RRULE still demands a no-op host update before ACK. | actual automation RRULE, quota payload `scheduler_hint.codex_app.stateful_backoff` and `host_observation`, `codex_app.recommended_rrule`, `codex_app.ack_hint.cli_args`, `quota scheduler-ack-current` result, recent user interaction/todo/history transition. | The scheduler ACK ledger was treated as host truth even though ACK can precede/fail to follow `automation_update`, or the host can later drift; matching host evidence was also lost when `scheduler-ack-current` recomputed the decision. | Keep the CLI-owned ACK protocol, but reconcile a uniquely matched active heartbeat's observed RRULE before suppressing work. A mismatch must set `host_observation.status=drift_detected` and re-emit `recommended_rrule`; a matching unbound reset sets `ack_needed=true` and carries its exact reset token, identity, and CLI route into a no-write ACK. Missing readback remains `apply_needed=true`. Never read the prompt body into the hot payload or edit App manifests directly. Cover false/stale ACK, ambiguous host-update result, real cadence change, host match, and reset-to-`run_now` with smokes. | +| `scheduler_reset_application_gap` | An App heartbeat cadence backs off, but repeated unchanged polls do not advance the progression; after user feedback, a new/reassigned todo, resolved gate, material transition, or `run_now`, the automation remains slow; LoopX reports `apply_needed=false` while the installed App RRULE differs; or a matching reset RRULE still demands a no-op host update before ACK. | actual automation RRULE, quota payload `scheduler_hint.app_automation.stateful_backoff` and `host_observation`, `app_automation.recommended_rrule`, `app_automation.ack_hint.cli_args`, `quota scheduler-ack-current` result, recent user interaction/todo/history transition. | The scheduler ACK ledger was treated as host truth even though ACK can precede/fail to follow `automation_update`, or the host can later drift; matching host evidence was also lost when `scheduler-ack-current` recomputed the decision. | Keep the CLI-owned ACK protocol, but reconcile a uniquely matched active heartbeat's observed RRULE before suppressing work. A mismatch must set `host_observation.status=drift_detected` and re-emit `recommended_rrule`; a matching unbound reset sets `ack_needed=true` and carries its exact reset token, identity, and CLI route into a no-write ACK. Missing readback remains `apply_needed=true`. Never read the prompt body into the hot payload or edit App manifests directly. Cover false/stale ACK, ambiguous host-update result, real cadence change, host match, and reset-to-`run_now` with smokes. | | `skill_cli_contract_drift` | A skill, installed release snapshot, generated prompt, smoke fixture, or agent handbook suggests a CLI form that the current parser rejects, or tells an already activated Goal to bootstrap another Goal. | `loopx --help`, current host task contract, failing command stderr, `rg` over `skills/`, installed skill copies, release snapshot, generated prompts, and relevant smoke fixtures. | Process documentation drifted from the CLI or host lifecycle contract, or a fixture preserved old command text without labeling it as legacy/non-executable. | Update the durable skill/source docs and installed skill copy, make activated continuation follow the exact CLI `interaction_contract` before bootstrap, add or update a focused smoke, and keep legacy fixture strings only when the test explicitly asserts they are untrusted historical payload. | | `effect_runtime_source_topology_fingerprint_gap` | A control-plane `.ts` or contract `.json` file is added or removed, but the managed Effect runtime fingerprint does not rotate and an older process remains eligible for reuse; or a concurrent install/update leaks `FileNotFoundError` between inventory, metadata collection, and content hashing. | Exact source-file inventory before and after the change, directory metadata on the affected filesystem, fingerprint values, bounded full-fingerprint attempts, and managed runtime process identity. | Source topology caching treated directory `mtime`/`ctime` as authoritative change evidence even though some filesystems preserve that metadata across rapid entry changes; deleting that cache also deleted its bounded race recovery, and restoring the retry around metadata alone left the later content-read boundary exposed. | Discover the bounded owned source inventory on each fingerprint check, retain content hashing behind the file-metadata snapshot cache, retry the complete inventory-plus-metadata-plus-content-hash operation once when a source disappears, then raise a stable diagnostic if churn continues. Cover unchanged directory metadata, added and removed files, deterministic scan/stat disappearance, and stat/read disappearance through request and readiness. Do not add a watcher or TTL. | | `local_release_snapshot_stale_after_merge` | Source checkout or `PYTHONPATH= python -m loopx.cli` shows a fix, but the default `loopx` command still returns the pre-fix quota/status behavior immediately after a self-merge or fast-forward. | `which loopx`, `readlink ~/.local/bin/loopx`, `loopx doctor` install freshness and promotion mode, default `loopx ...` payload, source-run `PYTHONPATH= python3 -m loopx.cli ...` payload, latest `origin/main`. | The default command is a release snapshot wrapper that forces `PYTHONPATH` to its release root; the source checkout was updated but the installed local release was not refreshed. | Refresh with `loopx update --execute --ref main` or a clean `origin/main` checkout's `scripts/install-local.sh`, rerun the default command without `PYTHONPATH`, and write back the repair. Dirty/non-main worktrees are canary-only unless an explicit validated promotion uses `LOOPX_PROMOTE_DEFAULT=1`. | @@ -152,9 +152,9 @@ teaches a reusable control-plane lesson. | `slash_command_packet_projection_drift` | A slash command tool is called, but the agent pipes or summarizes its JSON into a narrow view such as `.summary` / `.review_sequence`, then ignores the packet's response contract, templates, evidence commands, or final-answer requirements. | Raw session tool calls, command stdout, slash-command catalog `agent_contract`, full CLI JSON packet, skill fallback instructions, final answer shape. | The agent treated the CLI as a queue/statistics helper instead of the authoritative interaction contract; key machine fields never entered model context. | Make the skill name the exact JSON command that preserves contract/template fields, add machine-readable `required_packet_fields_to_preserve`, forbid summary-only projection for the first pass, and cover the required fields with focused smoke tests. | | `loop_surface_activation_gap` | A thread reports LoopX setup success because `register-agent` or `quota should-run --agent-id` works, but no Codex App heartbeat automation, Codex CLI `/goal`, or other host loop has been installed for that goal. | `upgrade-plan` / host-loop activation status, `$CODEX_HOME/automations/*/automation.toml`, generated `heartbeat-prompt`, target thread id, recent setup final answer, registry coordination fields. | The control-plane identity layer was treated as the host scheduler layer; setup success criteria stopped at registry/quota instead of proving `host_loop_activation.activated=true` or surfacing a concrete host-tool gate. | Project setup and agent registration must surface `host_loop_activation`; create/update the host loop from generated scoped `heartbeat-prompt`, or report the exact missing host capability. Do not claim "connected" as autonomous until the host loop is active. | | `codex_app_automation_toml_contract_gap` | A multiline Codex App heartbeat is visible under a permissive line reader, then disappears from upgrade or RRULE resolution after a standards-compliant TOML reader is introduced; prompts containing backslashes, quote delimiters, or escape-like text are common triggers. | Exact writer output, standard-library TOML readback, prompt digest/counts, manifest parse-error projection, and RRULE resolution before and after the reader change. | The writer interpolated arbitrary prompt text into a TOML basic string without serialization, while the old partial reader masked the invalid file and the strict reader's catch-and-skip path erased the difference between absent and malformed automation. | Treat writer and reader as one storage contract: encode every string through a TOML-safe serializer, parse with the standard library at all read sites, fail closed on malformed content, and expose bounded content-free parse diagnostics. Cover backslashes, embedded delimiters, line-ending escapes, exact prompt identity, and capped malformed-file reporting in the real writer-to-reader path. | -| `scheduler_host_update_retry_loop` | The same Codex App heartbeat repeatedly hangs or fails while applying the same recommended RRULE against the same observed host RRULE, including when active-work and monitor-wait targets alternate. | `scheduler_hint.codex_app`, persisted scheduler state, host RRULE observation, recent heartbeat history, host-tool timeout/failure. | Host update failure was treated as turn-local advice or persisted as one scalar, so a cadence phase change overwrote the previous failed pair and the next phase retried it. | Persist a bounded, expiring set of failed target/observed-host pairs with `failure_hint.cli_args`, retain the latest scalar only for compatibility, suppress every retained exact pair without ACK or spend, invalidate old-host entries when the observation changes, and clear the cache after a successful ACK. | +| `scheduler_host_update_retry_loop` | The same App heartbeat repeatedly hangs or fails while applying the same recommended RRULE against the same observed host RRULE, including when active-work and monitor-wait targets alternate. | `scheduler_hint.app_automation`, persisted scheduler state, host RRULE observation, recent heartbeat history, host-tool timeout/failure. | Host update failure was treated as turn-local advice or persisted as one scalar, so a cadence phase change overwrote the previous failed pair and the next phase retried it. | Persist a bounded, expiring set of failed target/observed-host pairs with `failure_hint.cli_args`, retain the latest scalar only for compatibility, suppress every retained exact pair without ACK or spend, invalidate old-host entries when the observation changes, and clear the cache after a successful ACK. | | `scheduler_implicit_host_default` | A generic or CLI caller omits scheduler ownership fields yet receives Codex App RRULE/backoff actions, so tests pass without proving the real host path uses the same contract. | `scheduler_hint.execution_context`, quota CLI argv, heartbeat generator output, host-loop activation packet, scheduler tests. | The shared resolver treated missing context as a legacy Codex App default instead of assigning compatibility at the actual App boundary. | Make missing generic context fail closed; preserve App behavior only through an explicit compact runtime profile emitted by the Codex App generator; pass typed context for other hosts; cover bare failure, profile behavior, generated command, and real CLI integration. | -| `scheduler_followup_runtime_binding_gap` | A Codex App quota decision emits an ACK or failure command, but executing that generated command recomputes a generic fail-closed scheduler packet and rejects the writeback. | `scheduler_hint.codex_app.ack_hint.cli_args`, `failure_hint.cli_args`, the recomputed `before.scheduler_hint.execution_context`, and a real generated-command CLI smoke. | The initial `quota should-run` carried an explicit App runtime profile, but the follow-up command and current-hint handler dropped it during recomputation. | Carry the compact App profile in generated ACK/failure commands, resolve the same typed context in every quota command that recomputes scheduler state, and execute the generated command in a durable CLI smoke before claiming convergence. | +| `scheduler_followup_runtime_binding_gap` | An App-hosted quota decision emits an ACK or failure command, but executing that generated command recomputes a generic fail-closed scheduler packet and rejects the writeback. | `scheduler_hint.app_automation.ack_hint.cli_args`, `failure_hint.cli_args`, the recomputed `before.scheduler_hint.execution_context`, and a real generated-command CLI smoke. | The initial `quota should-run` carried an explicit App runtime profile, but the follow-up command and current-hint handler dropped it during recomputation. | Carry the compact App profile in generated ACK/failure commands, resolve the same typed context in every quota command that recomputes scheduler state, and execute the generated command in a durable CLI smoke before claiming convergence. | | `scheduler_followup_turn_projection_gap` | A settled receipt-bound heartbeat recommends one host cadence, but its generated ACK/failure command recomputes an unscoped successor or monitor lane with a different reset token; a delayed Todo-less autonomous-replan follow-up may also replay after the Turn has fully settled, and a human gate may push the host past an earlier continuous-monitor deadline. | Originating heartbeat receipt and Turn id, exact ACK/failure argv, binding kind, receipt-bound settlement phase, recomputed selected Todo and scheduler identity, user-channel contract, continuous-monitor cadence/due horizon, and observed host RRULE. | Scheduler follow-ups preserved runtime/registry routing but dropped the originating Turn or treated a settled Todo-less replan replay as fresh scheduler authority, while human-gate notification authority was also treated as cadence authority over tighter monitor deadlines. | Bind the exact `turn_instance_id` into generated ACK/failure commands and rebuild follow-ups through the receipt-bound live-decision path. Once that exact Todo-less replan Turn is durably settled, return a zero-write idempotent follow-up and let a fresh Turn own current cadence reconciliation; preserve ordinary Todo-bound ACK behavior. Cap human-wait cadence with the shared monitor deadline plan while retaining distinct human-gate notification cooldown. Cover exact generated-command success before and after replan settlement, one-spend replay, Todo-bound ACK mutation, ordinary 30-minute human wait, and a three-minute monitor deadline under the gate. | | `user_gate_host_cadence_spam` | An unresolved user gate is correctly projected, but a failed host cadence update leaves a tight poll and `repeat_until_resolved` sends the same notification every few minutes. | quota interaction contract, scheduler host failure state, desired and observed RRULEs, recent notification cadence. | Gate persistence and per-poll notification delivery were represented by one boolean, so scheduler failure suppression stopped host retries but not user-message retries. | Preserve the open gate and first notice, then derive a notification-only cooldown from the existing failed target/observed-host pair. Quiet short polls outside a host-sized reminder window; reopen on the window, gate identity change, or host RRULE change. | | `tiny_turn_under_delivery` | Many heartbeats make only one small doc/prose edit despite quota permitting a coherent batch. | recent history durations, active priority stack, user feedback, todo completion rate. | Agent optimized for avoiding mistakes rather than delivering a bounded batch. | Run steering audit, choose a larger verifiable batch, and record why it remains within boundary. Consider cadence/budget policy updates if repeated. | @@ -199,7 +199,7 @@ teaches a reusable control-plane lesson. | `repository_delivery_gate_projection_gap` | Git rejects commit or push through an effective global or repository-local guard while quota still exposes only broad `delivery_allowed=true`, so preparation eligibility is mistaken for repository-delivery admission. | Path-free `change-window status` diagnostic, provider verification checks, typed policy decision, interaction-contract repository delivery gate, typed hook registration/result, linked-worktree and separate-clone readback. | Provider discovery exposed only repository-local install state, while Kernel interaction state had no trusted, provider-neutral seam for a capability-owned commit/push decision. | Detect configured external guard surfaces without returning paths or inferring policy; recognize only a bounded legacy signature for preview-first layering. Register a bounded read-only interaction projection hook at the composition root; keep typed validation, slot conflicts, and failure isolation core-owned. Project commit/push admission only from a fully verified typed repository provider, keep preparation/validation distinct, carry `next_eligible_at`, and never grant remote-write authority. | | `dashboard_verified_mutation_projection_gap` | A preview-locked dashboard mutation reports a successful shared-state readback, but the initiating control still shows its old value; a second click then says the requested setting already exists. | Exact apply receipt, no-change canonical preview, shared-state readback verification, status projection generation/revision, rendered control state, and refresh outcome. | The data adapter verified the canonical write or no-change state, then the UI discarded that receipt and rebound immediately to a separate stale status projection. | Return the verified configuration through apply and no-change preview callbacks and use it as a drawer-scoped read model for the same Goal; refresh the normal projection independently, surface refresh failure without undoing the verified result, and clear the override when the drawer selection changes. Keep preview state visibly pending rather than presenting it as applied. Cover a deliberately stale status response in the browser smoke. | | `dashboard_open_token_picker_gap` | A bounded dashboard setting asks users to type protocol tokens such as `task_domain`; the placeholder looks like a current value, users cannot discover legal choices, or Goals without tagged work cannot enable a capability whose runtime treats the token filter as optional. | Current Goal Todo index, configured token allowlist, option-to-Todo match counts, canonical empty-filter semantics, preview payload, empty state, and packaged browser behavior. | An intentionally open optional backend vocabulary was exposed as a required product authority boundary; reading only a compact Goal-card Todo slice can also hide valid choices. | Keep the open typed token contract in the owning control-plane boundary, but present it as an optional per-Goal multi-select derived from the authoritative Todo index plus already configured values, using compact Goal Todo rows only as a compatibility fallback. Empty means no token filter while every independent admission boundary remains enforced; a non-empty selection remains a strict allowlist. Show match counts, preserve configured zero-match values, and cover unrestricted, restricted, invalid-token, preview, readback, empty-state, and packaged parity behavior. | - +| `app_host_heartbeat_identity_gap` | An App host can create recurring automations, but LoopX classifies it as a generic visible CLI loop; the agent may complete one phase and then stop because no host-owned successor wake is activated. Another form builds the right full scheduler packet but drops it at a compact Turn-envelope boundary that still reads a sibling App's legacy field. | Exact App versus CLI host surface, ambient thread id, runtime profile, activation packet, full and compact scheduler projections, scheduler ownership, settled-turn liveness, and terminal no-follow-up evidence. | Host capability existed, but LoopX modeled only the sibling CLI surface or left a downstream transport coupled to that sibling's packet name, so the App automation contract or successor wake did not survive the real execution path. | Add a distinct App host/runtime identity while reusing the provider-neutral `app_automation` cadence and ACK rules end to end. Bind the host's ambient thread id, preserve the packet through Turn compaction without a sibling-host alias, create/update the host automation after Todo writeback, keep settled non-terminal turns active for a fresh successor turn, and stop only on validated terminal no-follow-up. Preserve the CLI host and its native visible Goal path. Never reuse another App's local-store fallback. | ## Minimal Evidence Packet Desktop runtime preparation failures must remain inside the live startup diff --git a/tests/control_plane/test_host_bootstrap_lifecycle.py b/tests/control_plane/test_host_bootstrap_lifecycle.py index b6e072ee7a..ae6799f037 100644 --- a/tests/control_plane/test_host_bootstrap_lifecycle.py +++ b/tests/control_plane/test_host_bootstrap_lifecycle.py @@ -117,6 +117,34 @@ def test_host_and_automation_bootstraps_share_the_v2_prompt(registry): assert host == automation +def test_trae_app_bootstrap_round_trips_its_host_identity(registry): + from loopx.control_plane.heartbeat.bootstrap_prompt import host_bootstrap_binding + + initial = cli(registry, "--bootstrap", "--trae_app") + assert initial["ok"] and initial["bootstrap"] + prompt = initial["task_body"] + assert prompt.startswith( + "LoopX managed heartbeat bootstrap v2\n每次唤醒先执行:\n" + ) + command = shlex.split(prompt.split("```sh\n")[1].split("\n```", 1)[0]) + heartbeat_index = command.index("heartbeat-prompt") + assert command[heartbeat_index + 2] == "--trae_app" + assert "--codex-app" not in command + assert host_bootstrap_binding(prompt)["trae_app"] is True + loaded = subprocess.run( + [sys.executable, "-m", "loopx.cli", *command[1:]], + capture_output=True, + text=True, + timeout=60, + check=True, + ) + body = json.loads(loaded.stdout) + direct = cli(registry, "--trae_app") + assert body["task_body"] == direct["task_body"] + assert body["schema_version"] == direct["schema_version"] == "heartbeat_agent_input_v1" + assert "--trae_app" in body["task_body"] + + def test_saved_goal_bootstrap_reloads_changed_state_and_rejects_removed_agent(registry, tmp_path): packet = cli(registry, "--bootstrap", "--runtime-profile", "codex_cli") command = shlex.split(packet["task_body"].split("```sh\n")[1].split("\n```", 1)[0]) diff --git a/tests/control_plane/test_model_behavior_qualification.py b/tests/control_plane/test_model_behavior_qualification.py index 9e4e1d41eb..8fcd118a67 100644 --- a/tests/control_plane/test_model_behavior_qualification.py +++ b/tests/control_plane/test_model_behavior_qualification.py @@ -192,12 +192,12 @@ def _scheduler_transport_packet( packet = _full_packet() if arm == "full_packet": kind = "ack" if operation == "ack" else "failure" - packet["scheduler_hint"] = {"schema_version": "scheduler_hint_v0", "codex_app": { - f"{kind}_hint": {"schema_version": f"codex_app_scheduler_{kind}_hint_v0", "cli_args": args}, + packet["scheduler_hint"] = {"schema_version": "scheduler_hint_v0", "app_automation": { + f"{kind}_hint": {"schema_version": f"app_automation_scheduler_{kind}_hint_v0", "cli_args": args}, }} else: packet = build_turn_envelope(packet) - packet["scheduler"] = {"codex_app": {"ack_cli_args": args}} + packet["scheduler"] = {"app_automation": {"ack_cli_args": args}} return packet, args @@ -292,7 +292,7 @@ def test_actor_rejects_malformed_or_unbounded_scheduler_wire(case: str, alphabet def test_actor_rejects_unrecognized_or_malformed_scheduler_arguments(case: str) -> None: packet, args = _scheduler_transport_packet("full_packet", "ack") if case == "hint_schema": - packet["scheduler_hint"]["codex_app"]["ack_hint"]["schema_version"] = "future_hint" + packet["scheduler_hint"]["app_automation"]["ack_hint"]["schema_version"] = "future_hint" elif case == "scheduler_schema": packet["scheduler_hint"]["schema_version"] = "future_scheduler" elif case == "command": diff --git a/tests/control_plane/test_quota_settlement.py b/tests/control_plane/test_quota_settlement.py index 75d4a4a1f5..7b609ed9dc 100644 --- a/tests/control_plane/test_quota_settlement.py +++ b/tests/control_plane/test_quota_settlement.py @@ -560,7 +560,16 @@ def test_codex_app_plan_rejects_ambiguous_settlement_binding( ) -def test_standard_codex_app_actions_use_typed_settlement_before_turn_driver() -> None: +@pytest.mark.parametrize( + "profile", + ( + SchedulerRuntimeProfile.CODEX_APP_HEARTBEAT, + SchedulerRuntimeProfile.TRAE_APP, + ), +) +def test_standard_app_actions_use_typed_settlement_before_turn_driver( + profile: SchedulerRuntimeProfile, +) -> None: todo_id = "todo_123456789abc" actions = interaction_next_cli_actions( { @@ -570,7 +579,7 @@ def test_standard_codex_app_actions_use_typed_settlement_before_turn_driver() -> }, mode="bounded_delivery", scheduler_execution_context=scheduler_execution_context_for_runtime_profile( - SchedulerRuntimeProfile.CODEX_APP_HEARTBEAT + profile ), ) diff --git a/tests/control_plane/test_quota_settlement_cli.py b/tests/control_plane/test_quota_settlement_cli.py index 473fc27829..561aea9b8c 100644 --- a/tests/control_plane/test_quota_settlement_cli.py +++ b/tests/control_plane/test_quota_settlement_cli.py @@ -2561,8 +2561,8 @@ def test_begin_turn_rejects_a_non_receipt_runtime_profile(tmp_path: Path) -> Non # Kiro CLI, ZCode, agy, Gemini CLI, Cursor and custom runners all land on # generic_cli and mint their own identity instead. assert guard["reason"] == ( - "--begin-turn requires runtime-profile codex_app_heartbeat " - "or codex_app_ssh_goal; every other host starts its turn by " + "--begin-turn requires runtime-profile codex_app_heartbeat, " + "trae_app, or codex_app_ssh_goal; every other host starts its turn by " "passing its own --turn-instance-id" ) diff --git a/tests/control_plane/test_scheduler_ack_current_host_binding.py b/tests/control_plane/test_scheduler_ack_current_host_binding.py index 5d6eaf71a9..e895352150 100644 --- a/tests/control_plane/test_scheduler_ack_current_host_binding.py +++ b/tests/control_plane/test_scheduler_ack_current_host_binding.py @@ -4,15 +4,26 @@ import json from pathlib import Path +import pytest + from examples.control_plane.quota_plan_fixtures import SCOPED_AGENT_ID, write_cli_fixture +from loopx.cli import build_parser from loopx.cli_commands import quota as quota_command from loopx.cli_commands import quota_scheduler_followup +from loopx.cli_commands.quota_context import validate_quota_command_context_request from loopx.control_plane.testing.canary_harness import run_json_cli, run_json_cli_result from loopx.control_plane.scheduler.state import ( + APP_AUTOMATION_STATEFUL_BACKOFF_STATE_KEY, build_scheduler_state, + load_scheduler_state, scheduler_state_path, write_scheduler_state, ) +from loopx.control_plane.scheduler.execution_context import ( + SchedulerRuntimeProfile, + scheduler_execution_context_for_runtime_profile, +) +from loopx.control_plane.quota.turn_envelope import build_turn_envelope from loopx.status import AUTONOMOUS_REPLAN_PERIODIC_LOOKBACK @@ -63,6 +74,153 @@ def _quota( return payload +def _trae_quota( + registry_path: Path, + runtime_root: Path, + project: Path, + *, + current_rrule: str | None = None, +) -> dict: + rrule_args = ( + ["--app-automation-current-rrule", current_rrule] + if current_rrule + else [] + ) + _returncode, payload = run_json_cli_result( + "quota", + "should-run", + "--goal-id", + GOAL_ID, + "--agent-id", + SCOPED_AGENT_ID, + "--trae_app", + *rrule_args, + registry_path=registry_path, + runtime_root=runtime_root, + cwd=project, + ) + return payload + + +def test_trae_app_real_cli_ack_and_failure_keep_independent_identity( + tmp_path: Path, +) -> None: + registry_path, runtime_root, project = write_cli_fixture( + tmp_path / "fixture", + scoped_agents=True, + ) + + first = _trae_quota(registry_path, runtime_root, project) + assert "codex_app" not in first["scheduler_hint"] + app = first["scheduler_hint"]["app_automation"] + assert app["host_surface"] == "trae_app" + assert app["stateful_backoff"]["state_key"] == ( + APP_AUTOMATION_STATEFUL_BACKOFF_STATE_KEY + ) + assert "fallback_hint" not in app + envelope = build_turn_envelope( + first, + scheduler_execution_context=scheduler_execution_context_for_runtime_profile( + SchedulerRuntimeProfile.TRAE_APP + ), + ) + assert "codex_app" not in envelope["scheduler"] + compact_app = envelope["scheduler"]["app_automation"] + assert compact_app["host_surface"] == "trae_app" + assert compact_app["ack_cli_args"] == app["ack_hint"]["cli_args"] + + ack = run_json_cli( + *app["ack_hint"]["cli_args"], + registry_path=registry_path, + runtime_root=runtime_root, + cwd=project, + ) + assert ack["scheduler_state_mutated"] is True + assert ack["surface"] == "trae_app" + assert ack["state_key"] == APP_AUTOMATION_STATEFUL_BACKOFF_STATE_KEY + assert load_scheduler_state( + runtime_root, + goal_id=GOAL_ID, + agent_id=SCOPED_AGENT_ID, + surface="trae_app", + state_key=APP_AUTOMATION_STATEFUL_BACKOFF_STATE_KEY, + ) is not None + assert load_scheduler_state( + runtime_root, + goal_id=GOAL_ID, + agent_id=SCOPED_AGENT_ID, + surface="codex_app", + state_key=APP_AUTOMATION_STATEFUL_BACKOFF_STATE_KEY, + ) is None + + failure_registry, failure_runtime, failure_project = write_cli_fixture( + tmp_path / "failure-fixture", + scoped_agents=True, + ) + failure_app = _trae_quota( + failure_registry, + failure_runtime, + failure_project, + current_rrule="FREQ=MINUTELY;INTERVAL=5", + )["scheduler_hint"]["app_automation"] + failure_args = failure_app["failure_hint"]["cli_args"] + assert "--app-automation-current-rrule" in failure_args + assert "--codex-app-current-rrule" not in failure_args + failure = run_json_cli( + *failure_args, + registry_path=failure_registry, + runtime_root=failure_runtime, + cwd=failure_project, + ) + assert failure["scheduler_state_mutated"] is True + assert failure["surface"] == "trae_app" + assert failure["state_key"] == APP_AUTOMATION_STATEFUL_BACKOFF_STATE_KEY + + +def test_trae_app_followup_defaults_to_its_own_surface_and_state_key() -> None: + args = build_parser().parse_args( + [ + "quota", + "scheduler-fail-current", + "--goal-id", + GOAL_ID, + "--agent-id", + SCOPED_AGENT_ID, + "--trae_app", + "--failed-rrule", + "FREQ=MINUTELY;INTERVAL=3", + "--execute", + ] + ) + + validate_quota_command_context_request(args) + + assert args.surface == "trae_app" + assert args.state_key == APP_AUTOMATION_STATEFUL_BACKOFF_STATE_KEY + + +def test_trae_app_followup_rejects_codex_rrule_alias() -> None: + args = build_parser().parse_args( + [ + "quota", + "scheduler-fail-current", + "--goal-id", + GOAL_ID, + "--agent-id", + SCOPED_AGENT_ID, + "--trae_app", + "--failed-rrule", + "FREQ=MINUTELY;INTERVAL=3", + "--codex-app-current-rrule", + "FREQ=MINUTELY;INTERVAL=5", + "--execute", + ] + ) + + with pytest.raises(ValueError, match="Trae App uses --app-automation"): + validate_quota_command_context_request(args) + + def test_scheduler_ack_current_replays_host_binding_after_update( tmp_path: Path, monkeypatch, @@ -244,6 +402,69 @@ def test_scheduler_fail_current_rejects_missing_turn_receipt_without_state_write assert not state_path.exists() +def test_trae_app_scheduler_failure_does_not_read_codex_automation_store( + tmp_path: Path, + monkeypatch, +) -> None: + observed: dict[str, object] = {} + + def reject_codex_store_read(**_kwargs): + raise AssertionError("Trae App must not read the Codex automation store") + + def fake_build_decision(*_args, **_kwargs): + return {"effective_action": "execute_todo"} + + def fake_record_failure(_decision, **kwargs): + observed.update(kwargs) + return {"ok": True} + + monkeypatch.setattr( + quota_scheduler_followup, + "resolve_codex_app_automation_rrule", + reject_codex_store_read, + ) + monkeypatch.setattr( + quota_scheduler_followup, + "_build_scheduler_followup_decision", + fake_build_decision, + ) + monkeypatch.setattr( + quota_scheduler_followup, + "record_quota_scheduler_failure_for_decision", + fake_record_failure, + ) + args = Namespace( + quota_command="scheduler-fail-current", + goal_id=GOAL_ID, + agent_id=SCOPED_AGENT_ID, + codex_app_current_rrule=None, + applied_rrule="FREQ=MINUTELY;INTERVAL=3", + host_match_observed=False, + execute=True, + surface="trae_app", + state_key=APP_AUTOMATION_STATEFUL_BACKOFF_STATE_KEY, + failed_rrule="FREQ=MINUTELY;INTERVAL=3", + failure_kind="host_update_failed", + available_capabilities=[], + ) + + payload = quota_scheduler_followup.build_scheduler_followup_payload( + {}, + args, + registry_path=tmp_path / "registry.json", + runtime_root=tmp_path / "runtime", + turn_instance_id=None, + scheduler_context=scheduler_execution_context_for_runtime_profile( + SchedulerRuntimeProfile.TRAE_APP + ), + operator_inbox_urgency_projector=lambda **_kwargs: {}, + ) + + assert payload == {"ok": True} + assert observed["surface"] == "trae_app" + assert observed["observed_host_rrule"] == "" + + def test_scheduler_ack_collects_the_periodic_should_run_lookback( tmp_path: Path, monkeypatch, @@ -313,7 +534,7 @@ def fake_build_lark_projector(*, runtime_root_arg): next_user_todo=None, next_claimed_by=None, surface="codex_app", - state_key="scheduler_hint.codex_app.stateful_backoff", + state_key=APP_AUTOMATION_STATEFUL_BACKOFF_STATE_KEY, applied_rrule="FREQ=MINUTELY;INTERVAL=10", reset_token="fixture-reset-token", identity_signature="fixture-identity-signature", diff --git a/tests/control_plane/test_scheduler_execution_context.py b/tests/control_plane/test_scheduler_execution_context.py index e361f16d32..b1f87d1daa 100644 --- a/tests/control_plane/test_scheduler_execution_context.py +++ b/tests/control_plane/test_scheduler_execution_context.py @@ -19,6 +19,9 @@ scheduler_execution_context_for_runtime_profile, scheduler_runtime_profile_for_execution_context, ) +from loopx.control_plane.scheduler.automation_liveness import ( + build_automation_liveness, +) from loopx.control_plane.scheduler.scheduler_hint import build_scheduler_hint from loopx.control_plane.testing.quota_fixtures import ( quota_status_payload, @@ -37,6 +40,7 @@ VALID_COMBINATIONS = { ("ark_managed_agent", "goal_runtime", "interactive"), ("codex_app", "host_automation", "hosted_automation"), + ("trae_app", "host_automation", "hosted_automation"), ("codex_app_ssh", "agent_cli_loop", "interactive"), ("local_scheduler", "host_automation", "hosted_automation"), *{ @@ -62,6 +66,11 @@ ("codex_app", "host_automation", "hosted_automation"), " --codex-app", ), + ( + SchedulerRuntimeProfile.TRAE_APP, + ("trae_app", "host_automation", "hosted_automation"), + " --trae_app", + ), ( SchedulerRuntimeProfile.CODEX_APP_SSH_VISIBLE, ("codex_app_ssh", "agent_cli_loop", "interactive"), @@ -191,21 +200,41 @@ def test_scheduler_execution_context_decision_table( ) assert resolution.ok is (values in VALID_COMBINATIONS) + context_projection = resolution.projection() + if host_surface is HostSurface.TRAE_APP: + assert context_projection["app_automation_applicability"] == ( + "applicable" if resolution.ok else "blocked_invalid_context" + ) + assert "codex_app_applicability" not in context_projection + else: + assert "app_automation_applicability" not in context_projection + assert context_projection["codex_app_applicability"] == ( + "applicable" + if resolution.ok and host_surface is HostSurface.CODEX_APP + else "not_applicable" if resolution.ok else "blocked_invalid_context" + ) if values not in VALID_COMBINATIONS: assert hint["execution_phase"]["disposition"] == "contract_error" assert hint["execution_phase"]["completed"] is False - assert hint["codex_app"]["applicability"] == "blocked_invalid_context" + packet_key = ( + "app_automation" if host_surface is HostSurface.TRAE_APP else "codex_app" + ) + assert hint[packet_key]["applicability"] == "blocked_invalid_context" + assert ("app_automation" in hint) is (host_surface is HostSurface.TRAE_APP) + assert ("codex_app" in hint) is (host_surface is not HostSurface.TRAE_APP) return - app_expected = values == ( - "codex_app", - "host_automation", - "hosted_automation", - ) - assert hint["codex_app"]["applicability"] == ( + app_expected = values in { + ("codex_app", "host_automation", "hosted_automation"), + ("trae_app", "host_automation", "hosted_automation"), + } + packet_key = "app_automation" if app_expected else "codex_app" + assert hint[packet_key]["applicability"] == ( "applicable" if app_expected else "not_applicable" ) - assert ("stateful_backoff" in hint["codex_app"]) is app_expected + assert ("stateful_backoff" in hint[packet_key]) is app_expected + assert ("app_automation" in hint) is app_expected + assert ("codex_app" in hint) is (host_surface is not HostSurface.TRAE_APP) if app_expected: assert "execution_context" not in hint assert "execution_phase" not in hint @@ -254,10 +283,120 @@ def test_codex_app_runtime_profile_preserves_host_backoff() -> None: hint["cold_path_detail"]["execution_context"]["codex_app_applicability"] == "applicable" ) - assert hint["codex_app"]["stateful_backoff"]["apply_needed"] is True + canonical = hint["app_automation"] + legacy = hint["codex_app"] + assert canonical is not legacy + assert legacy["applicability"] == "applicable" + assert canonical["stateful_backoff"]["schema_version"] == ( + "app_automation_stateful_backoff_v0" + ) + assert canonical["stateful_backoff"]["state_key"] == ( + "scheduler_hint.app_automation.stateful_backoff" + ) + assert canonical["ack_hint"]["schema_version"] == ( + "app_automation_scheduler_ack_hint_v0" + ) + assert legacy["stateful_backoff"]["schema_version"] == ( + "codex_app_stateful_backoff_v0" + ) + assert legacy["stateful_backoff"]["state_key"] == ( + "scheduler_hint.codex_app.stateful_backoff" + ) + assert legacy["ack_hint"]["schema_version"] == ( + "codex_app_scheduler_ack_hint_v0" + ) + assert legacy["rrule_source"] == "scheduler_hint.codex_app.recommended_rrule" + assert legacy["stateful_backoff"]["apply_needed"] is True + assert legacy["recommended_interval_minutes"] == 3 + assert hint["reset_policy"]["reset_token"] == "3e649fb65bf246f9" assert hint["cold_path_detail"]["execution_phase"]["apply_needed"] is True +def test_trae_app_runtime_profile_preserves_host_backoff_and_identity() -> None: + context = scheduler_execution_context_for_runtime_profile( + SchedulerRuntimeProfile.TRAE_APP + ) + hint = build_scheduler_hint( + _active_payload(), + include_detail=True, + scheduler_execution_context=context, + ) + + execution_context = hint["cold_path_detail"]["execution_context"] + assert execution_context["source"] == "runtime_profile:trae_app" + assert execution_context["host_surface"] == "trae_app" + assert "codex_app_applicability" not in execution_context + assert execution_context["app_automation_applicability"] == "applicable" + assert "codex_app" not in hint + app = hint["app_automation"] + assert app["host_surface"] == "trae_app" + assert app["stateful_backoff"]["apply_needed"] is True + assert app["stateful_backoff"]["state_key"] == ( + "scheduler_hint.app_automation.stateful_backoff" + ) + assert app["recommended_interval_minutes"] == 3 + assert app["rrule_source"] == "scheduler_hint.app_automation.recommended_rrule" + assert "fallback_hint" not in app + assert "--trae_app" in app["ack_hint"]["cli_args"] + assert "--surface" in app["ack_hint"]["cli_args"] + assert hint["cold_path_detail"]["execution_phase"]["host_surface"] == ( + "trae_app" + ) + + +def test_codex_app_monitor_reset_token_preserves_v0_profile_identity() -> None: + context = scheduler_execution_context_for_runtime_profile( + SchedulerRuntimeProfile.CODEX_APP_HEARTBEAT + ) + + hint = build_scheduler_hint( + _monitor_wait_payload(), + include_detail=True, + scheduler_execution_context=context, + ) + + assert hint["reset_policy"]["reset_token"] == "704ba9bb4bd17d43" + reset_detail = hint["cold_path_detail"]["reset_policy_detail"] + assert reset_detail["profile_signature"] == "d8601294ac8d" + assert reset_detail["reset_profile_signature"] == "d8601294ac8d" + + +@pytest.mark.parametrize( + "profile", + ( + SchedulerRuntimeProfile.CODEX_APP_HEARTBEAT, + SchedulerRuntimeProfile.TRAE_APP, + ), +) +def test_app_heartbeat_settlement_keeps_automation_active_until_terminal( + profile: SchedulerRuntimeProfile, +) -> None: + context = scheduler_execution_context_for_runtime_profile(profile) + assert context.ok and context.context is not None + assert context.context.app_automation_applicable is True + + settled = build_automation_liveness( + { + "effective_action": "heartbeat_settled_skip", + "heartbeat_recommendation": {}, + "execution_obligation": {"must_attempt_work": False}, + } + ) + terminal = build_automation_liveness( + { + "effective_action": "terminal_no_followup", + "heartbeat_recommendation": {}, + "execution_obligation": {"must_attempt_work": False}, + } + ) + + assert settled["keep_active"] is True + assert settled["next_trigger"] == ( + "next heartbeat turn with a fresh turn identity" + ) + assert terminal["keep_active"] is False + + def test_goal_runtime_projects_typed_immediate_continuation() -> None: context = scheduler_execution_context_for_runtime_profile( SchedulerRuntimeProfile.ARK_MANAGED_AGENT_GOAL @@ -1377,10 +1516,18 @@ def test_stale_unbound_guard_converges_after_profile_regeneration( assert stale_hint["action"] == "repair_scheduler_execution_context" assert regenerated_hint.get("action") != "repair_scheduler_execution_context" - assert regenerated_hint["codex_app"]["applicability"] in { - "applicable", - "not_applicable", + app_profile = profile in { + SchedulerRuntimeProfile.CODEX_APP_HEARTBEAT, + SchedulerRuntimeProfile.TRAE_APP, } + packet_key = "app_automation" if app_profile else "codex_app" + assert regenerated_hint[packet_key]["applicability"] == ( + "applicable" if app_profile else "not_applicable" + ) + assert ("app_automation" in regenerated_hint) is app_profile + assert ("codex_app" in regenerated_hint) is ( + profile is not SchedulerRuntimeProfile.TRAE_APP + ) def test_generic_outer_controller_rerun_actions_are_typed() -> None: diff --git a/tests/control_plane/test_scheduler_fallback_hint.py b/tests/control_plane/test_scheduler_fallback_hint.py index cf60ef6582..96fac2844f 100644 --- a/tests/control_plane/test_scheduler_fallback_hint.py +++ b/tests/control_plane/test_scheduler_fallback_hint.py @@ -167,7 +167,7 @@ def test_settled_cadence_omits_fallback_hint(tmp_path: Path) -> None: def test_fallback_hint_cli_route_binds_registry(tmp_path: Path) -> None: payload = { "scheduler_hint": { - "codex_app": { + "app_automation": { "fallback_hint": { "cli_args": [ "loopx-apply-rrule", @@ -190,7 +190,7 @@ def test_fallback_hint_cli_route_binds_registry(tmp_path: Path) -> None: registry_path=registry_path, runtime_root=tmp_path / "runtime", ) - fallback = payload["scheduler_hint"]["codex_app"]["fallback_hint"] + fallback = payload["scheduler_hint"]["app_automation"]["fallback_hint"] assert fallback["cli_args"][:3] == [ "loopx-apply-rrule", "--registry", @@ -206,7 +206,7 @@ def test_fallback_hint_cli_route_binds_registry(tmp_path: Path) -> None: def test_scheduler_followup_routes_preserve_turn_lineage(tmp_path: Path) -> None: payload = { "scheduler_hint": { - "codex_app": { + "app_automation": { "ack_hint": { "cli_args": [ "quota", @@ -234,16 +234,16 @@ def test_scheduler_followup_routes_preserve_turn_lineage(tmp_path: Path) -> None turn_instance_id=turn_instance_id, ) - codex_app = payload["scheduler_hint"]["codex_app"] + app_automation = payload["scheduler_hint"]["app_automation"] for hint_name in ("ack_hint", "failure_hint"): - hint = codex_app[hint_name] + hint = app_automation[hint_name] assert hint["cli_args"][-3:] == [ "--turn-instance-id", turn_instance_id, "--execute", ] assert hint["route_binding"]["turn_instance_bound"] is True - assert codex_app["ack_hint"]["args"]["turn_instance_id"] == turn_instance_id + assert app_automation["ack_hint"]["args"]["turn_instance_id"] == turn_instance_id def test_heartbeat_scheduler_rules_name_the_fallback(tmp_path: Path) -> None: diff --git a/tests/control_plane/test_scheduler_host_followup_hint_transport.py b/tests/control_plane/test_scheduler_host_followup_hint_transport.py index 5e815e67a3..23054b4f3c 100644 --- a/tests/control_plane/test_scheduler_host_followup_hint_transport.py +++ b/tests/control_plane/test_scheduler_host_followup_hint_transport.py @@ -9,8 +9,8 @@ from loopx.control_plane.scheduler import scheduler_hint from loopx.control_plane.scheduler.scheduler_hint import ( - build_codex_app_scheduler_ack_hint, - build_codex_app_scheduler_failure_hint, + build_app_automation_scheduler_ack_hint, + build_app_automation_scheduler_failure_hint, ) from loopx.control_plane.runtime.public_safety import SECRET_LIKE_SURFACE_PATTERN @@ -24,7 +24,7 @@ def _host_facts(operation: str) -> dict[str, object]: "goal_id": "goal-native-followup", "agent_id": "agent-native-followup", "surface": "codex_app", - "state_key": "scheduler_hint.codex_app.stateful_backoff", + "state_key": "scheduler_hint.app_automation.stateful_backoff", "reset_token": "reset-native-followup", "identity_signature": "identity-native-followup", "progression_index": 0, @@ -81,7 +81,7 @@ def _decode(cli_args: list[str]) -> dict[str, object]: def test_ack_hint_carries_bounded_native_followup_facts_without_changing_verb() -> None: - hint = build_codex_app_scheduler_ack_hint( + hint = build_app_automation_scheduler_ack_hint( goal_id="goal-native-followup", agent_id="agent-native-followup", applied_rrule="FREQ=MINUTELY;INTERVAL=15", @@ -115,7 +115,7 @@ def test_ack_hint_carries_bounded_native_followup_facts_without_changing_verb() def test_failure_hint_carries_the_same_versioned_native_boundary() -> None: facts = _host_facts("host_failure") - hint = build_codex_app_scheduler_failure_hint( + hint = build_app_automation_scheduler_failure_hint( goal_id="goal-native-followup", agent_id="agent-native-followup", failed_rrule="FREQ=MINUTELY;INTERVAL=15", @@ -139,7 +139,7 @@ def test_native_facts_are_not_dropped_when_cli_args_exceed_legacy_budget() -> No f"capability-{index}-" + (chr(97 + index) * 140) for index in range(12) ] - hint = build_codex_app_scheduler_ack_hint( + hint = build_app_automation_scheduler_ack_hint( goal_id="goal-native-followup", agent_id="agent-native-followup", applied_rrule="FREQ=MINUTELY;INTERVAL=15", @@ -165,7 +165,7 @@ def test_oversized_native_facts_fail_instead_of_falling_back_to_python() -> None ) with pytest.raises(ValueError, match="exceed the native CLI transport bound"): - build_codex_app_scheduler_ack_hint( + build_app_automation_scheduler_ack_hint( goal_id="goal-native-followup", agent_id="agent-native-followup", applied_rrule="FREQ=MINUTELY;INTERVAL=15", @@ -197,7 +197,7 @@ def test_native_facts_chunks_do_not_look_like_credentials( def test_legacy_hint_builder_without_host_facts_keeps_the_compatibility_route() -> None: - hint = build_codex_app_scheduler_ack_hint( + hint = build_app_automation_scheduler_ack_hint( goal_id="goal-native-followup", agent_id="agent-native-followup", applied_rrule="FREQ=MINUTELY;INTERVAL=15", diff --git a/tests/control_plane/test_scheduler_interaction_arbitration.py b/tests/control_plane/test_scheduler_interaction_arbitration.py index 08659fc899..bcc6723c63 100644 --- a/tests/control_plane/test_scheduler_interaction_arbitration.py +++ b/tests/control_plane/test_scheduler_interaction_arbitration.py @@ -372,7 +372,7 @@ def test_human_gate_respects_a_tighter_continuous_monitor_deadline( assert hint["codex_app"]["recommended_interval_minutes"] == 3 assert hint["codex_app"]["example_progression_minutes"] == [3] assert hint["cold_path_detail"]["cadence_context"]["cap_minutes"] == 3 - assert hint["reset_policy"]["codex_app_initial_interval_minutes"] == 3 + assert hint["reset_policy"]["app_automation_initial_interval_minutes"] == 3 def test_raw_should_run_cannot_override_blocking_gate() -> None: diff --git a/tests/control_plane/test_start_goal_compact_projection.py b/tests/control_plane/test_start_goal_compact_projection.py index 66e9768495..08be6261a2 100644 --- a/tests/control_plane/test_start_goal_compact_projection.py +++ b/tests/control_plane/test_start_goal_compact_projection.py @@ -27,6 +27,7 @@ validate_todo_add_options, ) from loopx.control_plane import effect_runtime +from loopx.slash_commands import build_slash_command_catalog GOAL_ID = "guided-projection-goal" AGENT_ID = "codex-guided-projection" @@ -321,6 +322,18 @@ def test_goal_start_packet_is_parity_complete_behavior_authority( contract = payload["command_pack"]["goal_start_contract"] assert "ordered_steps + goal_start_contract" in contract["behavior_authority"] assert "passes raw arguments" in contract["behavior_authority"] + assert contract["activation"]["host_surfaces"]["trae_app"] == ( + "Trae App heartbeat automation" + ) + slash_catalog = build_slash_command_catalog() + goal_start = next( + command + for command in slash_catalog["commands"] + if command["command"] == "/loopx " + ) + assert goal_start["agent_contract"]["host_loop_activation_by_agent_type"][ + "trae_app" + ].startswith("create/update Trae App heartbeat automation") invariants = contract["execution_invariants"] for marker in ( @@ -884,6 +897,47 @@ def test_cli_codex_app_reuses_ambient_thread_binding( assert payload["guided_transaction"].get("blocked_by") is None +def test_cli_trae_app_reuses_ambient_thread_binding( + tmp_path: Path, monkeypatch +) -> None: + project = _write_connected_project(tmp_path) + registry_path = project / ".loopx" / "registry.json" + registry = json.loads(registry_path.read_text(encoding="utf-8")) + registry["goals"][0]["coordination"]["thread_agent_bindings"] = [ + {"thread_id": "trae-thread", "host_surface": "trae_app", "agent_id": AGENT_ID} + ] + registry_path.write_text(json.dumps(registry, indent=2) + "\n", encoding="utf-8") + monkeypatch.setenv("TRAECLI_THREAD_ID", "trae-thread") + + output = io.StringIO() + with contextlib.redirect_stdout(output): + exit_code = cli_main( + [ + "--format", + "json", + "start-goal", + "--guided", + "--project", + str(project), + "--goal-id", + GOAL_ID, + "--host-surface", + "trae_app", + "--goal-text", + GOAL_TEXT, + ] + ) + + assert exit_code == 0 + payload = json.loads(output.getvalue()) + assert payload["thread_id"] == "trae-thread" + assert payload["agent_id"] == AGENT_ID + assert payload["thread_agent_binding"]["status"] == "bound" + assert "--trae_app" in payload["command_pack"]["commands"][ + "heartbeat_prompt" + ] + + def test_start_goal_binds_selected_lane_before_todo_writeback( tmp_path: Path, ) -> None: @@ -1613,6 +1667,7 @@ def test_cli_without_host_returns_read_only_host_selection_gate( choices = payload["host_surface_selection_gate"]["choices"] assert [choice["host_surface"] for choice in choices] == [ "codex-app", + "trae_app", "codex-app-ssh", "codex-ide-plugin", "codex-cli-tui", diff --git a/tests/control_plane_ts/scheduler_heartbeat_commit.test.ts b/tests/control_plane_ts/scheduler_heartbeat_commit.test.ts index e2575df900..3260069431 100644 --- a/tests/control_plane_ts/scheduler_heartbeat_commit.test.ts +++ b/tests/control_plane_ts/scheduler_heartbeat_commit.test.ts @@ -22,7 +22,7 @@ const scope = { goal_id: "goal-heartbeat", agent_id: "agent-heartbeat", surface: "codex_app", - state_key: "scheduler_hint.codex_app.stateful_backoff", + state_key: "scheduler_hint.app_automation.stateful_backoff", }; function request( diff --git a/tests/control_plane_ts/scheduler_heartbeat_commit_cli.test.ts b/tests/control_plane_ts/scheduler_heartbeat_commit_cli.test.ts index 1f0ece4b3b..3b6b72195e 100644 --- a/tests/control_plane_ts/scheduler_heartbeat_commit_cli.test.ts +++ b/tests/control_plane_ts/scheduler_heartbeat_commit_cli.test.ts @@ -18,7 +18,7 @@ const scope = { goal_id: "goal-heartbeat-cli", agent_id: "agent-heartbeat-cli", surface: "codex_app", - state_key: "scheduler_hint.codex_app.stateful_backoff", + state_key: "scheduler_hint.app_automation.stateful_backoff", }; function facts(runtimeRoot: string, extra: Record = {}) { diff --git a/tests/control_plane_ts/scheduler_heartbeat_followup.test.ts b/tests/control_plane_ts/scheduler_heartbeat_followup.test.ts index a8f610be81..7683b85fc7 100644 --- a/tests/control_plane_ts/scheduler_heartbeat_followup.test.ts +++ b/tests/control_plane_ts/scheduler_heartbeat_followup.test.ts @@ -19,7 +19,7 @@ const scope = { goal_id: "goal-followup", agent_id: "agent-followup", surface: "codex_app", - state_key: "scheduler_hint.codex_app.stateful_backoff", + state_key: "scheduler_hint.app_automation.stateful_backoff", }; const before = { 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 f1d099c31e..ef7255dd56 100644 --- a/tests/control_plane_ts/scheduler_heartbeat_followup_cli.test.ts +++ b/tests/control_plane_ts/scheduler_heartbeat_followup_cli.test.ts @@ -56,7 +56,7 @@ function hintPayload(): Record { goal_id: goalId, agent_id: agentId, surface: "codex_app", - state_key: "scheduler_hint.codex_app.stateful_backoff", + state_key: "scheduler_hint.app_automation.stateful_backoff", reset_token: "reset-cli-followup", identity_signature: "identity-cli-followup", progression_index: 0, diff --git a/tests/control_plane_ts/scheduler_state_store.test.ts b/tests/control_plane_ts/scheduler_state_store.test.ts index 12ab1ea2ef..33f75bee33 100644 --- a/tests/control_plane_ts/scheduler_state_store.test.ts +++ b/tests/control_plane_ts/scheduler_state_store.test.ts @@ -32,7 +32,7 @@ const scope = { goalId: "goal-a", agentId: "agent-a", surface: "codex_app", - stateKey: "scheduler_hint.codex_app.stateful_backoff", + stateKey: "scheduler_hint.app_automation.stateful_backoff", }; function state(lastAppliedRrule = "FREQ=MINUTELY;INTERVAL=3") { @@ -186,7 +186,7 @@ test("state path is scoped, sanitized, and stable", () => { "scheduler-state", "agent-..-..-other-55571d8866830ec6", "codex_app-b32e6f37f5dad64e", - "d9ad89d416adc9b2.json", + "8a41f410c67e7c0e.json", ), ); }); @@ -219,7 +219,7 @@ test("legacy scheduler state is migrated once into the collision-safe layout", a "scheduler-state", scope.agentId, scope.surface, - "d9ad89d416adc9b2.json", + "8a41f410c67e7c0e.json", ); await writeSchedulerState(storeRequest(runtimeRoot, { state: state() })); const canonicalPath = schedulerStatePath(runtimeRoot, scope); diff --git a/tests/control_plane_ts/turn_envelope.test.ts b/tests/control_plane_ts/turn_envelope.test.ts index 85896a3870..9e3e076b4e 100644 --- a/tests/control_plane_ts/turn_envelope.test.ts +++ b/tests/control_plane_ts/turn_envelope.test.ts @@ -143,6 +143,61 @@ test("Turn envelope transaction owns compaction and signature construction", () ); }); +test("Trae App Turn envelope preserves app automation without a Codex alias", () => { + const source = payload(); + source.scheduler_hint = { + action: "run_now", + cadence_class: "active_work", + app_automation: { + host_surface: "trae_app", + apply: "update_automation_cadence_if_possible", + recommended_rrule: "FREQ=MINUTELY;INTERVAL=3", + stateful_backoff: { + state_key: "scheduler_hint.app_automation.stateful_backoff", + current_rrule: "FREQ=MINUTELY;INTERVAL=15", + apply_needed: true, + ack_needed: false, + state_status: "reset_required", + }, + ack_hint: { + cli_args: [ + "quota", "scheduler-ack-current", "--surface", "trae_app", + "--execute", + ], + }, + failure_hint: { + cli_args: [ + "quota", "scheduler-fail-current", "--surface", "trae_app", + "--execute", + ], + }, + }, + }; + + const envelope = buildTurnEnvelope({ + payload: source, + protocol_action_fields: protocolActionFields, + scheduler_execution_args: " --scheduler-runtime-profile trae_app", + }); + const scheduler = envelope.scheduler as Record; + const app = scheduler.app_automation as Record; + + assert.equal(scheduler.codex_app, undefined); + assert.equal(app.host_surface, "trae_app"); + assert.deepEqual(app.ack_cli_args, [ + "quota", "scheduler-ack-current", "--surface", "trae_app", + "--execute", + ]); + assert.equal( + (app.stateful_backoff as Record).state_key, + "scheduler_hint.app_automation.stateful_backoff", + ); + assert.deepEqual(app.failure_cli_args_detail_ref, { + reason: "cold_path_until_host_update_failure", + request: "loopx quota should-run --include-detail scheduler", + }); +}); + test("monitor-only capsule preserves the non-runnable non-monitor count", () => { const source = payload(); source.should_run = false; diff --git a/tests/test_host_loop_activation.py b/tests/test_host_loop_activation.py index f5849705a3..73d520fb25 100644 --- a/tests/test_host_loop_activation.py +++ b/tests/test_host_loop_activation.py @@ -172,6 +172,7 @@ def test_codex_ide_plugin_is_an_exact_host_type_with_visible_goal_activation() - ( ("ark-managed-agent", "ark_managed_agent_goal"), ("codex-app", "codex_app_heartbeat"), + ("trae_app", "trae_app"), ("codex-app-ssh", "codex_app_ssh_goal"), ("codex-cli", "codex_cli"), ("codex-ide-plugin", "codex_cli"), @@ -515,6 +516,46 @@ def test_codex_app_activation_uses_narrow_runtime_profile() -> None: assert "--execution-mode" not in command +def test_trae_app_activation_uses_host_automation_and_stays_distinct_from_cli() -> None: + assert normalize_agent_type("trae_app") == "trae_app" + assert agent_type_for_host_surface("trae_app") == "trae_app" + assert normalize_agent_type("traex") == "traex-cli" + with pytest.raises(AgentTypeError, match="unsupported agent_type"): + normalize_agent_type("Trae App") + + packet = build_host_loop_activation_packet( + agent_type="trae_app", + goal_id="fixture-goal", + agent_id="trae_app-fixture", + registered_agents=["trae_app-fixture"], + ) + + command = packet["commands"]["heartbeat_prompt"] + assert packet["host_surface"] == "trae_app" + assert packet["activation_method"] == ( + "create_or_update_trae_app_automation" + ) + assert packet["host_mutation"]["preferred_tool"] == "automation_update" + assert "--trae_app" in command + assert "--codex-app" not in command + assert "--runtime-profile" not in command + assert any( + "settled non-terminal turn leaves the automation active" in criterion + for criterion in packet["success_criteria"] + ) + + prompt = build_heartbeat_prompt( + goal_id="trae_app-prompt-fixture", + thin=True, + runtime_profile="trae_app", + ) + assert "--trae_app" in prompt["quota_guard_command"] + assert "--trae_app" in prompt["task_body"] + rendered = render_heartbeat_prompt_markdown(prompt) + assert "Trae App heartbeat automation" in rendered + assert "Codex App heartbeat automation" not in rendered + + def test_new_agent_onboarding_defaults_to_fresh_identity() -> None: packet = build_host_loop_activation_packet( agent_type="codex-app", diff --git a/tests/test_host_parity_smoke.py b/tests/test_host_parity_smoke.py index ec1a129560..66a4d60553 100644 --- a/tests/test_host_parity_smoke.py +++ b/tests/test_host_parity_smoke.py @@ -69,6 +69,7 @@ def test_canonical_count(self): assert catalog["ok"] types = {t["agent_type"] for t in catalog["canonical_agent_types"]} assert "codex-app" in types + assert "trae_app" in types assert "codex-cli" in types assert "claude-code" in types assert "opencode" in types @@ -88,6 +89,7 @@ def test_canonical_count(self): @pytest.mark.parametrize("surface,expected", [ ("chat-box", "codex-app"), + ("trae_app", "trae_app"), ("codex-cli-tui", "codex-cli"), ("claude-code", "claude-code"), ("opencode", "opencode"), @@ -113,6 +115,7 @@ def test_host_managed_skill_types(self): host = { "ark-managed-agent", "deepseek-harness-native", + "trae_app", "traex-cli", "other-agent", } @@ -129,6 +132,7 @@ def test_runtime_profiles(self): expected = { "ark-managed-agent": "ark_managed_agent_goal", "codex-app": "codex_app_heartbeat", + "trae_app": "trae_app", "codex-app-ssh": "codex_app_ssh_goal", "codex-cli": "codex_cli", "codex-ide-plugin": "codex_cli", diff --git a/tests/test_skill_delivery_parity.py b/tests/test_skill_delivery_parity.py index 9ba5fbc00d..ff5704ec26 100644 --- a/tests/test_skill_delivery_parity.py +++ b/tests/test_skill_delivery_parity.py @@ -336,7 +336,12 @@ def test_relative_expanded_to_home(self): class TestSkillDeliveryModes: def test_host_managed_types(self): assert HOST_MANAGED_SKILL_AGENT_TYPES == { - "ark-managed-agent", "deepseek-harness-native", "traex-cli", "other-agent"} + "ark-managed-agent", + "deepseek-harness-native", + "trae_app", + "traex-cli", + "other-agent", + } def test_delivery_mode_per_agent_type(self): host_managed = HOST_MANAGED_SKILL_AGENT_TYPES