fix(launch): stop dropping the initial message when provider init outlives the client - #566
fix(launch): stop dropping the initial message when provider init outlives the client#566tedswinyar wants to merge 3 commits into
Conversation
…lives the client (caom-7it) `cao launch --agents <codex-profile> --async "<MESSAGE>"` created the session and the terminal but never delivered MESSAGE. The CLI reported "Failed to connect to cao-server: ... Read timed out. (read timeout=30)" while a healthy idle Codex TUI sat there with `last_active` frozen at creation time. `launch` delivers MESSAGE from the CLIENT, as a SECOND request that only runs if the first one returns — `message` is never sent in the create call. But `POST /sessions` runs the provider's FULL `initialize()` synchronously server-side (session_service.create_session -> create_terminal -> `await provider.initialize()`, terminal_service.py:445; `defer_init` is False on this path), and that work is bounded by `provider_init_timeout` (60s, applied twice) plus the startup-prompt handler — while the client only allowed `mcp_request_timeout` (30s). When the client gave up, `requests` raised ReadTimeout, it was caught as a generic RequestException, and the `elif message:` branch never ran. Server-side init completed normally, so nothing retried: from the server's point of view the launch had succeeded. The manual `POST /terminals/<id>/inbox/messages` workaround worked because the client's second request was the only missing piece. Give `POST /sessions` a read budget that covers the init it is waiting on, and share the readiness-wait constant so the create call cannot give up on work the very next step still waits for. This closes the hole for every provider. Why it reproduced on codex only, with claude_code/kiro_cli workers fine in the same fan-out: codex has the slowest `initialize()` and was the last provider still running its init's blocking subprocess calls directly on cao-server's single shared event loop, so concurrent codex inits inflated each other past the budget. Offload them to threads, the same treatment claude_code got in awslabs#451 (b831c9f), which codex never received. Also make the trust-prompt budget configurable via `startup_prompt_handler_timeout` instead of a hard-coded 20.0, so a slow containerized host can widen it without a code change. Tests: 4 in test_launch.py pin the create-call budget (and that /input and /output still use `mcp_request_timeout`); 3 in test_codex_provider_unit.py assert the non-blocking property directly by measuring the worst gap between ticks of an independent concurrent heartbeat — 0.21s when loop-side vs ~0.01s offloaded. All 7 fail before this change and pass after.
…ixed overhead (caom-7it review) Review found 641452b only partially closed the silent-drop hole. Two gaps, both of which let POST /sessions time out client-side while the server was still legitimately working — dropping MESSAGE exactly as before. 1a. The budget ignored per-profile provider_init_timeout overrides. It read only the global setting, but providers resolve their cap via BaseProvider.get_init_timeout(profile) (base.py:532), which prefers the profile's own override — an override that exists precisely so a containerized profile whose wrapped CLI is slow can raise its cap without touching global config. A profile declaring 180 ran server-side for up to 2*180+20 while the client gave up at 2*60+20. The original bug, reintroduced for exactly the profiles the override exists to serve. Providers do NOT agree on the source, which rules out simply trusting the profile: claude_code (:653), antigravity_cli (:660) and kimi_cli (:598) honour the override, while codex (:584, :632) and copilot_cli (:295, :304) read the global directly. So a profile that LOWERS its override below the global would under-budget codex, which ignores the profile and still spends the global. New _effective_init_timeout() takes max(global, profile_override) — the only bound that covers either resolution path. Unloadable profiles fall back to the global; this is a timeout hint, never a reason to fail a launch the server would accept. 1b. The budget omitted server-side time neither init window bounds: pane/window creation runs BEFORE initialize() (terminal_service.py:308/330), plus codex's asyncio.sleep(2.0) warm-up (codex.py:606), send_keys latency, and the tail of a 1s poll interval either side of each bounded wait. On a 60/20 config the client allowed 140s against a worst-case ~141s server path — a timeout with no margin. _CREATE_OVERHEAD_MARGIN = 30 closes it: 170s on defaults, 410s for a 180s profile. Also read startup_prompt_handler_timeout via settings.get(..., 20) so a hand-edited partial settings.json cannot turn the widened budget into a KeyError that aborts the launch before the request is sent. Not fixed here: if the CLIENT's settings/env differ from the SERVER's, the client can still under-budget. Taking the max helps but does not solve it; the real fix is for the server to telegraph its own budget (return it from POST /sessions, or expose it on /health, which sync_backend_from_server already reads). That is an API change, so it is left for a follow-up. Tests: 5 new (incl. a parametrized case pinning that a LOWERED override must not shrink the budget, and one encoding the worst-case server path as an explicit sum with a drift guard), 3 existing expectations updated for the margin. All 6 fail before this change. test_launch.py + test_codex_provider_unit.py: 226 passed, 3 skipped. Full providers/services/cli suite: 1790 passed, 11 skipped, 1 xfailed.
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #566 +/- ##
=======================================
Coverage ? 90.98%
=======================================
Files ? 179
Lines ? 23281
Branches ? 0
=======================================
Hits ? 21182
Misses ? 2099
Partials ? 0
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
There was a problem hiding this comment.
Pull request overview
Adjusts cao launch and Codex provider initialization to avoid client-side timeouts that prevent the initial message from being delivered when provider initialization is slow, and reduces event-loop blocking during Codex startup.
Changes:
- Add a dedicated
POST /sessionstimeout computation that covers server-side provider initialization (including profile overrides and fixed overhead), with a readiness-wait floor. - Offload Codex backend subprocess-backed calls in
initialize()/_handle_trust_prompt()viaasyncio.to_threadto avoid starving the server event loop. - Add/extend unit tests covering session-create budgeting, profile override behavior, partial settings resilience, and Codex init non-starvation.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
src/cli_agent_orchestrator/cli/commands/launch.py |
Adds _create_session_timeout / _effective_init_timeout and applies widened timeout only to POST /sessions, plus centralizes readiness timeout constant. |
src/cli_agent_orchestrator/providers/codex.py |
Offloads blocking backend calls during trust-prompt handling and initialization; reads trust-prompt timeout from server settings. |
test/cli/commands/test_launch.py |
Adds tests asserting correct create-session budgeting, readiness floor, scaling, and profile override handling. |
test/providers/test_codex_provider_unit.py |
Adds heartbeat-based tests to assert Codex init/trust handling does not starve the event loop; tests configured trust-prompt timeout wiring. |
Suppressed comments (1)
src/cli_agent_orchestrator/providers/codex.py:635
- The TimeoutError message is now misleading: the init timeout is configurable via server settings, but this hard-codes "60 seconds". If
provider_init_timeoutis raised, the error should report the actual configured value to aid debugging.
if not await wait_until_status(
self.terminal_id,
{TerminalStatus.IDLE, TerminalStatus.COMPLETED},
timeout=float(get_server_settings()["provider_init_timeout"]),
polling_interval=1.0,
):
raise TimeoutError("Codex initialization timed out after 60 seconds")
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| finally: | ||
| beat.cancel() | ||
| return max_gap, ticks |
haofeif
left a comment
There was a problem hiding this comment.
The Codex event-loop fix looks sound, but the new create timeout still expires before several supported providers can finish a successful initialization, preserving the initial-message loss this PR is intended to fix.
| """ | ||
| init_timeout = _effective_init_timeout(agent_profile, settings) | ||
| return max( | ||
| 2 * init_timeout |
There was a problem hiding this comment.
[P1] Budget the providers' actual initialization paths. startup_prompt_handler_timeout is only the idle gap after a prompt; the Claude handler's hard outer cap is another full provider_init_timeout. A successful Claude path can therefore take shell(T) + handler(T) + ready(T) + the 5s input settle: 185s with defaults, while this returns 170s (and about 545s versus 410s for a profile with T=180). This is not Claude-specific: Kimi can take T + 2max(120,T) = 300s by default, Kiro's legacy fallback can take 4T = 240s, and Antigravity/OpenCode/Hermes also have successful paths beyond 170s. In each case requests can still time out before /sessions responds, so the second /input request is skipped and the initial message is silently lost—the exact failure this PR addresses. Please derive a bound from the real provider path, or avoid the race by putting the message in the existing CreateSessionBody.initial_message field so /sessions uses deferred initialization and delivery.
call-me-ram
left a comment
There was a problem hiding this comment.
The bug is real and the root-cause trace is correct — nice find, and the diagnosis is unusually well-evidenced. I confirmed every link in the chain against head: launch sends message only as a second request (launch.py:433-447), POST /sessions → session_service.create_session → create_terminal runs await provider_instance.initialize() inline (terminal_service.py:445) because defer_init is False when no initial_message is present, and requests.exceptions.ReadTimeout is swallowed by the blanket except requests.exceptions.RequestException at launch.py:459 into "Failed to connect to cao-server" — so the create-then-send branch never runs while the server happily finishes init and keeps the session. The codex event-loop offload (item 4) is also correct, is a genuine parity gap versus #451, and its three tests are real regression guards (I reverted codex.py to upstream/main in a worktree and all three fail).
Where I land differently is the fix. The timeout widening does not close the hole it claims to close — it closes it for codex and leaves it open for most other providers on default settings — and POST /sessions already has a purpose-built mechanism for this exact problem that cao launch is the last client not using. Details below.
Must-fix
1. launch.py:106-140 — the new budget is below the worst-case successful init for 7 of 9 providers on default settings, so the silent drop survives for everything except the provider that was reported.
I derived each provider's worst-case successful initialize() wall clock directly from head by stubbing every bounded wait to report the timeout it was handed. With defaults (provider_init_timeout=60, startup_prompt_handler_timeout=20), _create_session_timeout returns 170s, against:
| provider | worst-case successful init | vs 170s |
|---|---|---|
antigravity_cli |
10 + max(180,T) + max(180,T) = 370s |
+200 |
kimi_cli |
T + max(120,T) + max(120,T) = 300s |
+130 |
kiro_cli (non-yolo TUI→--legacy-ui fallback) |
4T = 240s |
+70 |
copilot_cli (shell-ready fallback) |
T + T + 10 + 60 = 190s |
+20 |
claude_code |
T + T + T + 5 = 185s |
+15 |
opencode_cli |
T + 120 = 180s |
+10 |
hermes |
T + 120 = 180s |
+10 |
codex |
T + 20 + T = 140s |
OK |
cursor_cli |
(under) | OK |
The formula 2*T + startup_prompt_handler_timeout + 30 encodes codex's shape specifically, and two of its three assumptions are false elsewhere:
startup_prompt_handler_timeoutis only the idle gap, not the handler's cap.claude_code._handle_startup_promptsandkimi_cli/antigravity_cli._handle_startup_dialogtakeouter_timeout— a fullprovider_init_timeout(ormax(120/180, T)) — as the hard cap (claude_code.py:565-575,kimi_cli.py:523-533,antigravity_cli.py:563-576). So the middle term isT-or-larger, not 20.- "the init timeout applies TWICE per init" isn't a general invariant:
kimi_clifloors its two readiness waits atmax(120, T)(kimi_cli.py:607),antigravity_cliatmax(180, T)(antigravity_cli.py:660-668), andkiro_clihas a four-window path when the TUI attempt times out and it retries with--legacy-ui(kiro_cli.py:285, 349, 363, 381).
With a profile override the gap widens rather than closes: for provider_init_timeout: 180, claude_code can legitimately spend 3*180+5 = 545s against a 410s budget. _CREATE_OVERHEAD_MARGIN doesn't help — the shortfall is structural, not marginal.
I confirmed the residual failure is byte-identical to the reported one at head:
Error: Failed to connect to cao-server: HTTPConnectionPool(host='127.0.0.1', port=8001): Read timed out. (read timeout=170)
posts made: ['http://127.0.0.1:9889/sessions']
One POST, message never sent, exit 1 — the original bug, just with a bigger number in it.
This is haofeif's P1 and I confirm it independently, with the same numbers for claude_code (185) and kimi_cli (300); antigravity_cli is worse than their "also beyond 170s" — it's 370s.
2. launch.py:390-394 — wrong layer: POST /sessions already accepts initial_message and defers init + delivery server-side, and cao launch is the only client that doesn't use it.
CreateSessionBody.initial_message exists (api/main.py:215, 219), and session_service.create_session sets defer_init=initial_message is not None (session_service.py:88), so the response returns as soon as the session and terminal record exist and _schedule_deferred_init (terminal_service.py:768-896) owns init and delivery. Both sibling clients already do this: mcp_server/server.py:321-335 and ops_mcp_server/server.py:123-125 pass {"initial_message": ...} — and mcp_server keeps timeout=_mcp_timeout() (30s) on that call, which is direct evidence that 30s is the right budget once the body field is used.
The layer matters beyond aesthetics, because client-side create-then-send is unrecoverable by construction: on ReadTimeout the client has no terminal_id — the identity of the thing it would need to retry against only arrives in the response that timed out. So the create call can never be made safe by widening it; it can only be made less likely to fail, and its failure is always a silent orphan. The deferred path is also strictly more robust than what the client can do: it confirms the worker actually started and re-submits (_confirm_worker_started_or_resubmit), and on failure notifies the caller's inbox and tears the worker down instead of leaving a healthy idle TUI with a frozen last_active.
Concretely: send {"initial_message": message} in the create body when message is set, keep mcp_request_timeout on the create call, and drop the client-side /input POST. Two things to handle: the deferred path returns TerminalStatus.UNKNOWN (terminal_service.py:460), so the --async branch should just report and return rather than waiting for IDLE; and the non---async branch should go straight to poll_until_done. Note also that message is silently ignored today on the attach (non---headless) path — worth deciding deliberately rather than inheriting.
No double-delivery risk in either direction here, since the client send would be removed in the same change — but please don't ship both paths concurrently, because _schedule_deferred_init + a client /input would deliver twice.
3. launch.py:393 — the widened value is a scalar, so it also raises the connect timeout from 30s to 170s (unbounded with a profile override), and a genuine connection failure is still reported with the same string as a slow init.
requests applies a scalar timeout to both connect and read. So the change trades a slow-init failure for a new one: with cao-server unreachable rather than refused (filtered port, wrong host, stale SERVER_HOST), cao launch now blocks for 170s with zero output after the confirmation prompt, where it used to fail in 30s. And AgentProfile.provider_init_timeout is an unbounded Optional[int] (models/agent_profile.py:66) — I measured provider_init_timeout: 3600 producing a 7250s (121 min) budget.
Minimal fix, whichever layer you land on: pass a tuple, timeout=(connect, read) with a small fixed connect (5-10s) and the computed read budget; cap the derived read budget; and split the handler so ReadTimeout is distinguishable from ConnectionError — right now launch.py:459 flattens "server is initialising, your message was NOT delivered, a session may now exist" into "Failed to connect to cao-server", which is the misleading message the PR body itself calls out as part of the bug.
Non-blocking
-
codex.py:458-465andlaunch.py:16-27— "codex was the last provider still running its init's blocking subprocess calls directly on cao-server's single shared event loop" is not true, and the claim is now baked into source comments. After this PR,kiro_cli.initialize()still makes four loop-side backend calls (kiro_cli.py:289, 344, 361, 380), plusopencode_cli.py:160andcursor_cli.py:589.kiro_cliisDEFAULT_PROVIDER. The offload is still right; please just reword the causal claim, since a future reader will otherwise trust "codex was last" and skip these. -
Item 5 in the PR body (
settings.get("startup_prompt_handler_timeout", 20)) guards a state that cannot occur, and the guard is self-defeating anyway.get_server_settings()starts fromdict(_SERVER_DEFAULTS)and merges only keys already in it (settings_service.py:249-250), so a hand-edited partialsettings.jsoncan never yield a missing key. And within the same expression_effective_init_timeouthard-indexessettings["provider_init_timeout"]— I passed{"startup_prompt_handler_timeout": 20}and gotKeyError('provider_init_timeout'), so the guard protects one of two keys against an impossible input.test_create_session_timeout_survives_partial_settingsasserts against a synthetic dictget_server_settings()cannot return. Either drop both, or make it consistent. Related:codex.py:626hard-indexes the same key thatlaunch.py:138guards — pick one convention. -
The codex non-starvation tests belong in
test/providers/test_startup_handler_nonblocking.py. That module already exists for exactly this property, is parametrized over_COROUTINE_TARGETS/_HEARTBEAT_CASES, and coverskimi_cli/antigravity_cli/copilot_clifrom #494. Adding codex there is ~2 list entries instead of a bespoke 50-line_heartbeat_gaphelper in the codex file, and it gives the still-loop-side providers from item 1 an obvious home. That module also doescancel()→await→ suppressCancelledError(test_startup_handler_nonblocking.py:87-91), which is Copilot's inline point ontest_codex_provider_unit.py:2410— that one is correct,beat.cancel()without an await can emit "Task was destroyed but it is pending". Moving the tests fixes it for free. -
codex.py:635still raisesTimeoutError("Codex initialization timed out after 60 seconds")while the timeout is configurable. Copilot suppressed this one; it's worth taking, since this PR is what makes the neighbouring timeout configurable — interpolate the value likeclaude_code.py:701does. -
_READINESS_WAIT_TIMEOUTas a floor penalises deliberately-low configs. An operator or CI run that setsprovider_init_timeout: 5for fast failure now still waits 120s on create. Also, the floor's stated rationale ("the create call gives up on work the very next step is still willing to wait for") doesn't hold on the synchronous path — the readiness wait runs after create returns and init has already completed, so the two waits are sequential, not competing. -
_effective_init_timeoutre-loads the profile thatlaunchalready loaded atlaunch.py:288on the non---yolopath. Pass it through. Also note the two load sites disagree on what they catch —(FileNotFoundError, RuntimeError)vs bareException.
Asks
mcp_server/app_tools.py:404calls_post_json("/sessions", params)with noinitial_messageandtimeout=MCP_REQUEST_TIMEOUT, so it has the same synchronous-init-outlives-the-client hole. Not this PR's job to fix, but the body's "This closes the hole for every provider" should be scoped to "forcao launch" — and if the answer to must-fix 2 isinitial_message, this becomes a natural follow-up.- Whatever the create budget ends up being,
cao launchprints nothing between "Proceed?" and the result. A 170s+ silent wait needs some progress feedback. - The "Known limitation (deferred)" section is honest and I agree with the diagnosis there — but adopting
initial_messagedissolves it rather than deferring it, since the client stops needing to guess the server's init budget at all.
On haofeif's review: their single P1 is correct and I reproduce it with the same numbers. I'd add that it's not only an arithmetic shortfall — their suggested alternative (CreateSessionBody.initial_message) is the one that removes the race rather than resizing it, and it's already the pattern used by both other POST /sessions clients.
What I verified
git fetch upstream pull/566/head→d041a7d; all reading and testing at that rev in an isolated worktree.- Diagnosis trace:
launch.py:433-447(second request),launch.py:459(blanketRequestException),session_service.py:88(defer_init=initial_message is not None),terminal_service.py:435-445(inlineinitialize()),api/main.py:215-232, 1919-1976(CreateSessionBody.initial_message),terminal_service.py:768-896(_schedule_deferred_init+ resubmit + failure notification). Confirmed. - Per-provider budget derivation: script stubbing
wait_for_shell/wait_until_status/ each startup handler /wait_until_input_readyto report the timeout each was handed, run against realinitialize()forclaude_code,kimi_cli,codex,opencode_cli(antigravity_cli,hermes,kiro_cli,copilot_cliread statically where the probe aborted on a missing binary). Output:client budget 170s;claude_code 185,kimi_cli 300,opencode_cli 180,codex 140. Table above. - Residual failure repro:
requests.poststubbed to raiseReadTimeoutat head → exit 1,Failed to connect to cao-server: ... (read timeout=170), exactly one POST, message never sent, no terminal id in output. - Scalar/unbounded timeout:
_create_session_timeoutreturnsint(scalar → governs connect too); withprofile.provider_init_timeout = 3600it returns7250. - Self-defeating settings guard:
_create_session_timeout({"startup_prompt_handler_timeout": 20}, "any")→KeyError('provider_init_timeout'). - Loop-side survey:
grepfor un-offloadedget_backend().across all 9 providers →kiro_cli×4,opencode_cli×1,cursor_cli×1 remain;copilot_cli's_send_enter/_send_keyare correctly offloaded inside_accept_trust_prompts. - Tests:
test/cli/commands/test_launch.py→ 58 passed.test/providers/test_codex_provider_unit.py+test/services/test_session_service.py→ 188 passed, 3 skipped. Revertingproviders/codex.pytoupstream/mainand re-runningTestCodexInitEventLoopBlocking→ 3 failed (genuine guards).
Problem
cao launch --agents <profile> --async "<message>"created the session and terminal but silently never delivered the message. The CLI reportedFailed to connect to cao-server: ... Read timed out. (read timeout=30)while a healthy idle TUI sat there withlast_activefrozen at creation time. Reproduced on codex workers in a concurrent fan-out; claude_code/kiro_cli workers in the same batch were unaffected.Root cause
launchdelivers the message from the client, as a second HTTP request that only runs if the first (POST /sessions) returns. ButPOST /sessionsruns the provider's fullinitialize()synchronously server-side (session_service.create_session→create_terminal→await provider.initialize()), bounded byprovider_init_timeoutapplied twice (wait_for_shell+wait_until_status) plus the startup-prompt handler — up to ~85s. The client only allowedmcp_request_timeout(30s).When the client's budget expired,
requestsraisedReadTimeout,launchcaught it as a genericRequestException→"Failed to connect to cao-server", and the create-then-send branch never ran. Server-side init completed normally, so nothing retried — from the server's point of view the launch had succeeded. The manualPOST /terminals/<id>/inbox/messagesworkaround worked because it was the missing second request.Why codex specifically, with claude_code/kiro_cli fine in the same fan-out: codex had the slowest
initialize()and was the last provider still running its init's blocking subprocess calls directly on cao-server's single shared event loop, so concurrent codex inits inflated each other past the budget.Fix
_create_session_timeout()— givePOST /sessionsa read budget covering the init it waits on, not the generic tool budget._effective_init_timeout()=max(global, profile_override)— providers disagree on where the init cap comes from:claude_code/antigravity_cli/kimi_clihonour a per-profileprovider_init_timeoutoverride viaBaseProvider.get_init_timeout(profile), whilecodex/copilot_cliread the global directly.maxis the only bound covering whichever the target provider uses, and ensures a lowered override can't shrink the budget below what a global-reading provider will actually spend._CREATE_OVERHEAD_MARGIN = 30— covers server-side time neither init window bounds: pane/window creation runs beforeinitialize(), plus codex'sasyncio.sleep(2.0)warm-up, send_keys latency, and poll-interval tails.initialize()/_handle_trust_promptbackend calls toasyncio.to_thread— the #451 parity fix codex never received. It was the last provider blocking the shared event loop, which is why a concurrent fan-out blew the budget.settings.get("startup_prompt_handler_timeout", 20)— a hand-edited partialsettings.jsonomitting the key must not turn the widened budget into aKeyErrorthat aborts launch.Tests
12 new/updated in
test_launch.py+test_codex_provider_unit.py:All fail before this change, pass after.
test_launch.py+test_codex_provider_unit.py: 226 passed, 3 skipped.Known limitation (deferred)
If the client's settings/env differ from the server's (e.g. different
CAO_PROVIDER_INIT_TIMEOUT, or different profile files on disk in a cross-host split), the client can still under-budget.max()mitigates the common cases; the robust fix is server-authoritative — have the server telegraph its own budget (return it fromPOST /sessions, or expose it on/health). That is an API change, left for a follow-up.Review provenance
Diagnosed, then adversarially reviewed by three independent reviewers across two model families (Claude ×2 + GPT-5.6 Sol) and two harnesses. The first revision was a partial fix (missed per-profile overrides and fixed overhead); review caught it and it was hardened. Every reviewer claim was reproduced against real code before acting on it.