Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion src/kiro_crew/acp/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
29 changes: 29 additions & 0 deletions test/test_acp_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading