You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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>
- `_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.
153
153
-`_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.
155
155
-`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`)
156
156
-`claude_agent_sdk.py` - Claude Agent SDK implementation (uses `claude-agent-sdk` package)
0 commit comments