Skip to content

Commit 1aea23e

Browse files
Jason RobertCopilot
andcommitted
fix(copilot): address review findings on runtime restart state machine
Blocking fixes (PR #484 review): - _restart_spawned_runtime no longer publishes the rebuilt client until it has actually started: _client/_started are invalidated first, so a failed start() (e.g. OOM at spawn) leaves the provider correctly believing no client is started, instead of silently disabling dead-runtime recovery for the rest of the process. - The consecutive-restart cap is now checked before incrementing the counter and is never left stale: the cap can no longer be tripped after zero actual restarts, the giving-up message reports the real restart count, and close() resets the counter so a cached provider isn't permanently wedged after a workflow crash-loops once. - Replaced the unfalsifiable cap-message assertion in test_copilot_runtime_recovery.py with one that pins the rendered clause and asserts the cap actually prevents the next rebuild. - Added tests/test_providers/conftest.py: an autouse fixture clearing COPILOT_PROVIDER_RUNTIME_URL/TOKEN so the runtime-recovery tests pass regardless of the developer's/CI runner's environment. - Added a regression test covering the corrupted-state bug: when the rebuilt client's start() raises, _started must end up False and a later _ensure_client_started() must re-attempt start(). Recommendations applied: - _runtime_unavailable_error now distinguishes a confirmed-dead process (poll() returned an exit code) from a broken connection to a still- alive process, instead of always claiming the process "died" and suggesting NODE_OPTIONS. - Client teardown during restart, and session.disconnect() in the per-agent finally block, now log a warning on failure instead of silently swallowing the exception (a leaked child / stranded session is diagnostically useful, especially given this PR's own OOM focus). - The session.error ProviderError path is now also routed through dead- runtime classification when retryable, instead of always surfacing a generic "Copilot SDK error" message that hides an exit-code 137 OOM kill. - Narrowed _spawned_runtime_process's return type from Any | None to subprocess.Popen[bytes] | None, matching the isinstance check the body already performs and the SDK's own annotation. - Added a one-time warning when a spawned, started client has no usable _cli_process handle, so a future SDK rename surfaces instead of silently degrading recovery to a no-op. - Fixed the inverted _FakeClient docstring/comments describing mock auto-vivification as looking "live" when it in fact reads as dead. - Scoped the restart-counter-reset comment to agent execution (several auxiliary paths increment without resetting). - Updated CHANGELOG.md, docs/configuration.md and AGENTS.md to name the restart cap (2, fixed, non-configurable), correct the "endlessly retrying" overstatement, and scope the SDK-boundary claim to agent-execution; documented the _cli_process vs _process split. Recommendations skipped (not applied): #5 (_interrupted_session reset + disclosure wording), #6 (max_session pre-flight), #12 (Liveness enum), #13 (_RestartBudget value type), #17 (per-generation client tracking for parallel groups), #18 (additional missing tests beyond the one added for finding #1), #19 (collapsing except clauses), #20 (extracting shared helpers) -- all correctness-neutral hardening/refactors judged to grow the diff beyond what this pass should touch; pyproject.toml dependency cap was also left alone as an unrelated, broader change. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 039ada5 commit 1aea23e

6 files changed

Lines changed: 192 additions & 55 deletions

File tree

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,7 @@ step-by-step checklist.
151151
- `base.py` - `AgentProvider` ABC defining `execute()`, `validate_connection()`, `close()`
152152
- `_output_shape.py` - `normalize_agent_output(content, schema)` — the single entry point providers call before `validate_output` (issue #343). It raises `ValidationError` when the parsed response is not a JSON object (a bare `42`/`null`/array), because `validate_output` would otherwise either raise `TypeError` from a membership test (numbers, booleans, null) or report a misleading "missing required field" (strings, arrays). It then applies `unwrap_scalar_wrappers`: fires only when the schema declares `string`/`number`/`boolean`, a `dict` arrived, and **exactly one** candidate slot has the expected type. Candidate slots are the field's own name plus the generic `value`/`result` keys, deduped so a field literally named `value` or `result` isn't rejected as ambiguous against itself. Two matches count as ambiguous; any other key shape is ignored. Both are left untouched (same object identity) so the caller re-prompts rather than guessing — this is what stops `{"answer": {"error": "..."}}` being laundered into an answer. Every unwrap logs a warning, naming discarded sibling keys when there are any. Kept out of `executor/output.py` on purpose — see the note there.
153153
- `_recovery_prompt.py` - `build_parse_recovery_prompt(...)` — the plain-text re-prompt shared by Copilot and Hermes (issue #343). Both providers correct an unusable response the same way (error + truncated response + rendered schema, with distinct schema-failure vs syntax-failure wording), and that text is covered by the provider-parity rule, so it lives in one place instead of two copies free to drift. Claude is deliberately not a caller: it re-prompts through its `emit_output` tool and never echoes the schema, so its instruction text stays in `claude.py::_build_recovery_instruction`.
154-
- `copilot.py` - GitHub Copilot SDK implementation. By default spawns a nested `copilot` runtime via `CopilotClient()` (in `_build_client`, called from `_ensure_client_started`). When a runtime connection is resolved (`runtime.provider.runtime_url` or `COPILOT_PROVIDER_RUNTIME_URL`, optional `runtime_token` / `COPILOT_PROVIDER_RUNTIME_TOKEN`), it instead builds `CopilotClient(connection=RuntimeConnection.for_uri(url, connection_token=token))` to connect to an already-running `copilot --headless` process; the SDK skips spawning for URI connections and its `stop()` leaves the externally-owned server running. `_resolve_runtime_connection()` reads YAML first, then the namespaced env var (env activates on its own — the zero-YAML path for external orchestrators). Runtime transport can be combined with custom model-provider routing.
154+
- `copilot.py` - GitHub Copilot SDK implementation. By default spawns a nested `copilot` runtime via `CopilotClient()` (in `_build_client`, called from `_ensure_client_started`). When a runtime connection is resolved (`runtime.provider.runtime_url` or `COPILOT_PROVIDER_RUNTIME_URL`, optional `runtime_token` / `COPILOT_PROVIDER_RUNTIME_TOKEN`), it instead builds `CopilotClient(connection=RuntimeConnection.for_uri(url, connection_token=token))` to connect to an already-running `copilot --headless` process; the SDK skips spawning for URI connections and its `stop()` leaves the externally-owned server running. `_resolve_runtime_connection()` reads YAML first, then the namespaced env var (env activates on its own — the zero-YAML path for external orchestrators). Runtime transport can be combined with custom model-provider routing. `_ensure_client_started()` also detects a spawned runtime whose child process has died (issue #483, `_runtime_is_dead()` polling the SDK's private `_cli_process` handle) and rebuilds the client under `_start_lock` (`_restart_spawned_runtime`) before returning, so the next SDK call lands on a fresh runtime; the rebuild invalidates `_client`/`_started` before constructing and starting the replacement, so a failed rebuild (e.g. OOM at spawn) cannot leave the provider believing a never-started client is started. A fixed, non-configurable cap (`_MAX_CONSECUTIVE_RUNTIME_RESTARTS`) on consecutive restarts with no intervening successful call prevents an infinite crash loop; a broken connection to an externally-owned runtime is never respawned. `_spawned_runtime_process` reads `_cli_process` (the spawned-child handle, `None` for URI and FFI connections) while `_fix_pipe_blocking_mode` reads `_process` (the transport handle — a `SocketWrapper` in TCP mode, an `_FfiProcessAdapter` with its own `poll()` in FFI mode); both are correct for their own purpose, and unifying the two reads onto `_process` would make FFI mode look like a killable child process.
155155
- `claude.py` - Anthropic Claude API provider using `pydantic-ai` (`AnthropicModel`) and the internal `_pydantic_ai` package (`converters`, `events`, `mcp_toolset`, `agent_builder`, `interrupt`, `retry`, `structured_output`, `usage`)
156156
- `claude_agent_sdk.py` - Claude Agent SDK implementation (uses `claude-agent-sdk` package)
157157
- `factory.py` - Provider instantiation

CHANGELOG.md

Lines changed: 20 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -65,23 +65,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
6565
paths now use the same bounded retry that `write_run_record` already used
6666
for its own `os.replace`.
6767
- **The Copilot provider now recovers automatically when its nested runtime
68-
process dies** (#483), instead of endlessly retrying against a dead
69-
process and reporting a misleading "Check that copilot CLI is installed
70-
and authenticated" error. A dead spawned runtime is now detected via
68+
process dies** (#483), instead of retrying against a dead process with
69+
a misleading "Check that copilot CLI is installed and authenticated"
70+
error. A dead spawned runtime is now detected via
7171
`subprocess.Popen.poll()` on the SDK's own child handle and via explicit
72-
recognition of `BrokenPipeError` / `ConnectionResetError` at the SDK
73-
boundary (including during idle-recovery "continue" prompts, which
74-
previously burned every recovery attempt and were reported as a stuck
75-
*agent* rather than a dead *process*). Recovery rebuilds the SDK client
76-
the next time it is needed, so the existing retry loop lands its next
77-
attempt on a fresh runtime with no change to retry-loop shape; a runtime
78-
that keeps dying without a single successful call in between fails fast
79-
after a small number of consecutive restarts rather than looping forever,
80-
while a long-running, otherwise-healthy workflow can restart it as many
81-
times as needed. A broken connection to an **externally-owned** runtime
82-
(`runtime_url` / `COPILOT_PROVIDER_RUNTIME_URL`) is treated differently:
83-
it is never retried or respawned, since the orchestrator that owns that
84-
runtime is responsible for its health checks and restarts.
72+
recognition of `BrokenPipeError` / `ConnectionResetError` at the
73+
agent-execution SDK boundary (including during idle-recovery "continue"
74+
prompts, which previously burned every recovery attempt and were
75+
reported as a stuck *agent* rather than a dead *process*). Recovery
76+
rebuilds the SDK client the next time it is needed, so the existing
77+
retry loop lands its next attempt on a fresh runtime with no change to
78+
retry-loop shape; a runtime that keeps dying without a single
79+
successful call in between fails fast after 2 consecutive restarts
80+
(a fixed, non-configurable cap — with the default `max_attempts` of 3,
81+
a single agent execution can only trigger 2 restarts on its own, so the
82+
cap mainly bites across agents in the same workflow) rather than
83+
looping forever, while a long-running, otherwise-healthy workflow can
84+
restart it as many times as needed. A broken connection to an
85+
**externally-owned** runtime (`runtime_url` /
86+
`COPILOT_PROVIDER_RUNTIME_URL`) is treated differently: it is never
87+
retried or respawned, since the orchestrator that owns that runtime is
88+
responsible for its health checks and restarts.
8589

8690
## [0.1.33](https://github.com/microsoft/conductor/compare/v0.1.32...v0.1.33) - 2026-08-18
8791

docs/configuration.md

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -339,13 +339,18 @@ conductor run review.yaml # connects; spawns no nested runtime
339339
- Closing the provider does **not** terminate the external runtime — the
340340
SDK only shuts down runtimes it spawned itself, so the orchestrator-owned
341341
server keeps running. The orchestrator is also responsible for runtime
342-
health checks and restarts: a broken connection to an external runtime
343-
fails the affected agent immediately (`is_retryable=false`) and is never
342+
health checks and restarts: a **lost connection** to an external runtime
343+
(a `BrokenPipeError` or `ConnectionResetError` at the SDK boundary) fails
344+
the affected agent immediately (`is_retryable=false`) and is never
344345
retried or respawned by Conductor. This differs from the default spawned
345346
runtime, which Conductor restarts automatically after a detected crash
346347
(a dead child process, or a `BrokenPipeError`/`ConnectionResetError` at
347-
the SDK boundary) and retries against, up to a small consecutive-failure
348-
cap.
348+
the SDK boundary) and retries against, up to a fixed cap of 2 consecutive
349+
restarts without an intervening successful call (not configurable via
350+
YAML or an environment variable). Note this covers a *lost* connection
351+
only; a *failed initial connect* to an external runtime (e.g. it is not
352+
reachable at all) is not classified by this mechanism and surfaces as a
353+
generic SDK error instead.
349354
- Runtime-spawn-only options (custom CLI path, injected env, etc.) do not
350355
apply when connecting to an existing runtime.
351356

src/conductor/providers/copilot.py

Lines changed: 96 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -146,10 +146,14 @@ class RetryConfig:
146146

147147
# Cap on consecutive spawned-runtime restarts with no intervening successful
148148
# SDK call (issue #483). This is a death-loop guard, not a per-agent budget:
149-
# any successful call resets the counter to 0 (see ``_execute_with_retry``),
150-
# so a long-running workflow that legitimately restarts many times over hours
151-
# is unaffected — only a runtime that dies again before ever succeeding trips
152-
# the cap.
149+
# any successful agent-execution call resets the counter to 0 (see
150+
# ``_execute_with_retry``), so a long-running workflow that legitimately
151+
# restarts many times over hours is unaffected via that path — only a runtime
152+
# that dies again before ever succeeding trips the cap. Auxiliary paths that
153+
# also call ``_ensure_client_started`` (``validate_connection``,
154+
# ``execute_dialog_turn``, ``get_max_prompt_tokens``, ``get_model_pricing``,
155+
# ``list_models``, ``_validate_reasoning_effort_for_model``) can increment the
156+
# counter without resetting it on success.
153157
_MAX_CONSECUTIVE_RUNTIME_RESTARTS = 2
154158

155159

@@ -419,6 +423,9 @@ def __init__(
419423
# SDK call. Reset to 0 on every successful call in
420424
# ``_execute_with_retry``; see ``_MAX_CONSECUTIVE_RUNTIME_RESTARTS``.
421425
self._consecutive_runtime_restarts = 0
426+
# Guards a one-time warning when a spawned, started client has no
427+
# ``_cli_process`` handle (see ``_spawned_runtime_process``).
428+
self._warned_missing_runtime_handle = False
422429
self._idle_recovery_config = idle_recovery_config or IdleRecoveryConfig()
423430
self._temperature = temperature
424431
self._default_max_agent_iterations = max_agent_iterations
@@ -1523,9 +1530,21 @@ async def _execute_sdk_call(
15231530
finally:
15241531
# Disconnect session unless it was kept alive for follow-up
15251532
if not session_destroyed:
1526-
await session.disconnect()
1533+
try:
1534+
await session.disconnect()
1535+
except asyncio.CancelledError:
1536+
raise
1537+
except Exception:
1538+
logger.warning(
1539+
"Failed to disconnect Copilot session for agent '%s' during "
1540+
"cleanup; continuing.",
1541+
agent.name,
1542+
exc_info=True,
1543+
)
15271544

1528-
except ProviderError:
1545+
except ProviderError as e:
1546+
if e.is_retryable and self._runtime_is_dead():
1547+
raise self._runtime_unavailable_error(e) from e
15291548
raise
15301549
except ValidationError:
15311550
# Deterministic failures: a configuration error (e.g. unsupported
@@ -2468,7 +2487,7 @@ async def _ensure_client_started(self) -> None:
24682487
elif self._runtime_is_dead():
24692488
await self._restart_spawned_runtime()
24702489

2471-
def _spawned_runtime_process(self) -> Any | None:
2490+
def _spawned_runtime_process(self) -> subprocess.Popen[bytes] | None:
24722491
"""Return the subprocess this provider owns and spawned, else None.
24732492
24742493
Returns ``None`` (rather than guessing) for every mode where this
@@ -2488,8 +2507,29 @@ def _spawned_runtime_process(self) -> Any | None:
24882507
return None
24892508
if self._client is None:
24902509
return None
2510+
# ``_cli_process`` is the spawned-child handle (a real OS process,
2511+
# None for URI and FFI connections). This is deliberately distinct
2512+
# from ``_fix_pipe_blocking_mode``'s ``_process``, which is the
2513+
# transport handle instead (a ``SocketWrapper`` in TCP mode, an
2514+
# ``_FfiProcessAdapter`` with its own ``poll()`` in FFI mode) --
2515+
# unifying the two reads onto ``_process`` would make FFI mode look
2516+
# like a killable child process.
24912517
process = getattr(self._client, "_cli_process", None)
24922518
if not isinstance(process, subprocess.Popen):
2519+
# We spawned something (not external, client built and started),
2520+
# so a handle should exist. The one benign explanation is FFI
2521+
# in-process mode, which has no OS child process at all; anything
2522+
# else here is a capability regression (e.g. an SDK rename of
2523+
# ``_cli_process``) that would otherwise silently disable
2524+
# dead-runtime recovery with no diagnostic.
2525+
if self._started and not self._warned_missing_runtime_handle:
2526+
self._warned_missing_runtime_handle = True
2527+
logger.warning(
2528+
"Spawned Copilot runtime has no usable _cli_process handle. "
2529+
"This is expected in FFI in-process mode; otherwise it may "
2530+
"indicate an incompatible Copilot SDK version, which would "
2531+
"silently disable dead-runtime restart recovery (issue #483)."
2532+
)
24932533
return None
24942534
return process
24952535

@@ -2529,32 +2569,42 @@ def _runtime_unavailable_error(self, exc: BaseException) -> ProviderError:
25292569

25302570
process = self._spawned_runtime_process()
25312571
exit_code = process.poll() if process is not None else None
2532-
exit_code_desc = f" (exit code {exit_code})" if exit_code is not None else ""
2572+
if exit_code is not None:
2573+
return ProviderError(
2574+
f"The Copilot runtime process died (exit code {exit_code}): {exc}",
2575+
suggestion=(
2576+
"The nested Copilot runtime will be restarted automatically on the "
2577+
"next attempt. If this recurs, it may indicate the runtime process "
2578+
"is running out of memory; try setting "
2579+
'NODE_OPTIONS="--max-old-space-size=8192" in the environment running '
2580+
"conductor."
2581+
),
2582+
is_retryable=True,
2583+
)
2584+
25332585
return ProviderError(
2534-
f"The Copilot runtime process died{exit_code_desc}: {exc}",
2586+
f"The connection to the Copilot runtime broke: {exc}",
25352587
suggestion=(
2536-
"The nested Copilot runtime will be restarted automatically on the "
2537-
"next attempt. If this recurs, it may indicate the runtime process "
2538-
"is running out of memory; try setting "
2539-
'NODE_OPTIONS="--max-old-space-size=8192" in the environment running '
2540-
"conductor."
2588+
"The runtime process is still running, so this was a transport "
2589+
"failure rather than a crash. The connection will be re-established "
2590+
"automatically on the next attempt."
25412591
),
25422592
is_retryable=True,
25432593
)
25442594

25452595
async def _restart_spawned_runtime(self) -> None:
25462596
"""Rebuild the Copilot client after detecting a dead spawned runtime.
25472597
2548-
Must be called while holding ``self._start_lock``. Increments the
2549-
consecutive-restart counter (reset on any successful SDK call, see
2550-
``_execute_with_retry``) and fails fast if a runtime keeps dying
2551-
before ever succeeding, rather than looping forever.
2598+
Must be called while holding ``self._start_lock``. Checks the
2599+
consecutive-restart cap (reset on any successful SDK call, see
2600+
``_execute_with_retry``) before incrementing it and fails fast if a
2601+
runtime keeps dying before ever succeeding, rather than looping
2602+
forever.
25522603
"""
2553-
self._consecutive_runtime_restarts += 1
2554-
if self._consecutive_runtime_restarts > _MAX_CONSECUTIVE_RUNTIME_RESTARTS:
2604+
if self._consecutive_runtime_restarts >= _MAX_CONSECUTIVE_RUNTIME_RESTARTS:
25552605
raise ProviderError(
25562606
"The Copilot runtime process died and was restarted "
2557-
f"{self._consecutive_runtime_restarts - 1} times in a row without a "
2607+
f"{_MAX_CONSECUTIVE_RUNTIME_RESTARTS} times in a row without a "
25582608
"single successful call. Giving up rather than restarting again.",
25592609
suggestion=(
25602610
"Check the runtime for a crash loop (e.g. persistent OOM). Try "
@@ -2564,6 +2614,8 @@ async def _restart_spawned_runtime(self) -> None:
25642614
is_retryable=False,
25652615
)
25662616

2617+
self._consecutive_runtime_restarts += 1
2618+
25672619
old_process = self._spawned_runtime_process()
25682620
old_exit_code = old_process.poll() if old_process is not None else None
25692621
logger.warning(
@@ -2576,12 +2628,30 @@ async def _restart_spawned_runtime(self) -> None:
25762628

25772629
# Best-effort teardown of the dead client. stop() attempts a graceful
25782630
# RPC shutdown plus process-exit waits against a corpse, so it is
2579-
# bounded rather than allowed to hang recovery.
2580-
with contextlib.suppress(Exception, asyncio.TimeoutError):
2631+
# bounded rather than allowed to hang recovery. Failures are logged
2632+
# rather than silently swallowed: a StopError here means a runtime
2633+
# child survived both terminate and kill, i.e. a leaked process.
2634+
try:
25812635
await asyncio.wait_for(self._client.stop(), timeout=10.0)
2636+
except asyncio.CancelledError:
2637+
raise
2638+
except Exception:
2639+
logger.warning(
2640+
"Teardown of the dead Copilot client failed; continuing with the "
2641+
"restart. A runtime child process may have been leaked.",
2642+
exc_info=True,
2643+
)
2644+
2645+
# Invalidate the old client before rebuilding: if the new client's
2646+
# start() raises (e.g. OOM at spawn), we must not be left believing
2647+
# a never-started client is started, which would silently skip
2648+
# recovery on the next call.
2649+
self._client = None
2650+
self._started = False
25822651

2583-
self._client = self._build_client()
2584-
await self._client.start()
2652+
new_client = self._build_client()
2653+
await new_client.start()
2654+
self._client = new_client
25852655
self._started = True
25862656
self._fix_pipe_blocking_mode()
25872657

@@ -2937,6 +3007,7 @@ async def close(self) -> None:
29373007
await self._client.stop()
29383008
self._client = None
29393009
self._started = False
3010+
self._consecutive_runtime_restarts = 0
29403011
self._call_history.clear()
29413012
self._retry_history.clear()
29423013

0 commit comments

Comments
 (0)