diff --git a/src/kiro_crew/acp/client.py b/src/kiro_crew/acp/client.py index f4939350e8d..e698940311b 100644 --- a/src/kiro_crew/acp/client.py +++ b/src/kiro_crew/acp/client.py @@ -206,6 +206,9 @@ KIRO_CLI_SUBCMD = "acp" CLAUDE_ACP_BIN = "claude-agent-acp" +# A self-updating ACP adapter can briefly disappear or remain locked while its +# executable is replaced. Delay the one permitted startup retry past that window. +_ACP_RESPAWN_BACKOFF_S = 2.0 # On-disk name of the Claude backend CLI. The claude-agent-acp adapter # delegates the actual model turn to @anthropic-ai/claude-agent-sdk, which # needs a per-platform native binary (~250 MB each). Those ship as npm @@ -5526,11 +5529,13 @@ async def ensure_ready(self) -> None: await self._cleanup_failed_live_spawn() self._reset_state() raise - except (AcpTimeoutError, AcpError) as exc: + except (AcpTimeoutError, AcpError, OSError) as exc: if attempt == 0: logger.warning("ACP init failed (%s), retrying with fresh process...", exc) await self._cleanup_failed_live_spawn() self._reset_state() + if isinstance(exc, OSError): + await asyncio.sleep(_ACP_RESPAWN_BACKOFF_S) else: # AcpAuthRequired subclasses AcpError; label it distinctly # so a not-logged-in exit is never counted as a generic diff --git a/test/test_acp_client.py b/test/test_acp_client.py index 176ff58506f..7d3eed5fa9d 100644 --- a/test/test_acp_client.py +++ b/test/test_acp_client.py @@ -3717,6 +3717,35 @@ def fake_reset(): assert client._session_id == "sess-ok" client._kill_process.assert_called_once_with(force=True) + @pytest.mark.asyncio + async def test_retries_spawn_os_error_after_backoff(self, monkeypatch): + client = AcpClient() + client._work_dir_ready = True + spawn_count = 0 + + async def fake_spawn(): + nonlocal spawn_count + spawn_count += 1 + if spawn_count == 1: + raise FileNotFoundError("adapter replaced in place") + client._process = MagicMock(returncode=None, pid=101) + + async def fake_init(): + client._session_id = "sess-ok" + + sleep = AsyncMock() + client._spawn = fake_spawn + client._initialize_session = fake_init + client._cleanup_failed_live_spawn = AsyncMock() + client._snapshot_process_tree = AsyncMock() + monkeypatch.setattr(acp_client.asyncio, "sleep", sleep) + + await client.ensure_ready() + + assert spawn_count == 2 + sleep.assert_awaited_once_with(acp_client._ACP_RESPAWN_BACKOFF_S) + client._cleanup_failed_live_spawn.assert_awaited_once() + @pytest.mark.asyncio async def test_cancel_during_retry_kill_releases_bound_workspace(self, tmp_path, monkeypatch): client = AcpClient(work_dir=tmp_path)