llamactl: deployments update — single event loop across get + update (#602)

This commit is contained in:
Patricia
2026-04-30 11:53:13 -03:00
committed by GitHub
parent 24059467bd
commit 0b2098b762
3 changed files with 157 additions and 28 deletions
@@ -0,0 +1,5 @@
---
"llamactl": patch
---
Fix `deployments update` crashing with `Event loop is closed` after a transient failure on the internal git push. The command now runs `get_deployment` and `update_deployment` in a single event loop instead of reusing the same `ProjectClient` across two `asyncio.run` calls.
@@ -331,8 +331,9 @@ def edit_deployment(
def _push_internal_for_update(
deployment_id: str,
git_ref: str | None = None,
project_id_override: str | None = None,
git_ref: str | None,
base_url: str,
project_id: str,
) -> None:
"""Push local code to the internal repo before updating.
@@ -348,12 +349,9 @@ def _push_internal_for_update(
)
return
client = get_project_client(project_id_override=project_id_override)
api_key = get_api_key()
git_url = get_deployment_git_url(client.base_url, deployment_id)
remote_name = configure_git_remote(
git_url, api_key, client.project_id, deployment_id
)
git_url = get_deployment_git_url(base_url, deployment_id)
remote_name = configure_git_remote(git_url, api_key, project_id, deployment_id)
local_ref, target_ref = internal_push_refspec(git_ref)
with console.status("Pushing code..."):
push_result = push_to_remote(
@@ -394,28 +392,31 @@ def refresh_deployment(
rprint(f"[{WARNING}]No deployment selected[/]")
return
client = get_project_client(project_id_override=project)
current_deployment = asyncio.run(client.get_deployment(deployment_id))
deployment_name = current_deployment.display_name
# Single asyncio.run with one client: reusing a ProjectClient across
# two asyncio.run calls binds the underlying httpx pool to a closed
# loop and the next request raises "Event loop is closed".
async def _do_update() -> tuple[DeploymentResponse, DeploymentResponse]:
async with project_client_context(project_id_override=project) as client:
current = await client.get_deployment(deployment_id)
effective_git_ref = git_ref or current.git_ref
if current.repo_url == INTERNAL_CODE_REPO_SCHEME:
_push_internal_for_update(
deployment_id,
effective_git_ref,
base_url=client.base_url,
project_id=client.project_id,
)
# Re-resolves the branch to the latest commit SHA on the server.
with console.status(f"Refreshing {current.display_name}..."):
updated = await client.update_deployment(
deployment_id,
DeploymentUpdate(git_ref=effective_git_ref),
)
return current, updated
current_deployment, updated_deployment = asyncio.run(_do_update())
old_git_sha = current_deployment.git_sha or ""
# For internal repos, push local code first so the server has the
# latest commits to resolve against.
effective_git_ref = git_ref or current_deployment.git_ref
if current_deployment.repo_url == INTERNAL_CODE_REPO_SCHEME:
_push_internal_for_update(
deployment_id, effective_git_ref, project_id_override=project
)
# Re-resolves the branch to the latest commit SHA.
with console.status(f"Refreshing {deployment_name}..."):
updated_deployment = asyncio.run(
client.update_deployment(
deployment_id,
DeploymentUpdate(git_ref=effective_git_ref),
)
)
new_git_sha = updated_deployment.git_sha or ""
old_short = short_sha(old_git_sha) if old_git_sha else "-"
new_short = short_sha(new_git_sha) if new_git_sha else "-"
@@ -0,0 +1,123 @@
# SPDX-License-Identifier: MIT
# Copyright (c) 2026 LlamaIndex Inc.
"""Tests for ``deployments update``.
The headline regression here: ``deployments update <id>`` for an internal-code
deployment used to call ``asyncio.run`` twice with the same ``ProjectClient``.
The shared httpx pool was bound to the first (closed) event loop, and the
second run raised ``RuntimeError: Event loop is closed`` — surfacing in CI as
``Error: Event loop is closed`` after the "Continuing with update using last
pushed code" fallback when the internal git mirror returned a transient 500.
"""
from __future__ import annotations
import subprocess
from typing import Any
from unittest.mock import MagicMock, patch
from click.testing import CliRunner
from conftest import make_deployment, patch_project_client
from llama_agents.cli.app import app
from llama_agents.core.schema.deployments import (
INTERNAL_CODE_REPO_SCHEME,
DeploymentResponse,
)
def _client_mock(current: DeploymentResponse, updated: DeploymentResponse) -> MagicMock:
"""ProjectClient stand-in for the update flow."""
async def _get(
deployment_id: str, include_events: bool = False
) -> DeploymentResponse:
return current
async def _update(deployment_id: str, update: Any) -> DeploymentResponse:
return updated
client = MagicMock()
client.get_deployment.side_effect = _get
client.update_deployment.side_effect = _update
client.project_id = "proj_default"
client.base_url = "http://test:8011"
return client
def _completed_process(
returncode: int = 0, stdout: bytes = b"", stderr: bytes = b""
) -> subprocess.CompletedProcess[bytes]:
return subprocess.CompletedProcess(
args=[], returncode=returncode, stdout=stdout, stderr=stderr
)
def test_deployments_update_external_repo(patched_auth: Any) -> None:
"""External-repo update: no push step, single asyncio.run, exit 0."""
runner = CliRunner()
current = make_deployment("my-app", git_sha="a" * 40)
updated = make_deployment("my-app", git_sha="b" * 40)
client = _client_mock(current, updated)
with patch_project_client(client):
result = runner.invoke(
app, ["deployments", "update", "my-app", "--no-interactive"]
)
assert result.exit_code == 0, result.output
client.get_deployment.assert_called_once()
client.update_deployment.assert_called_once()
def test_deployments_update_internal_repo_push_failure_does_not_abort(
patched_auth: Any,
) -> None:
"""Push 500 → fallback path runs, deployment still updates, exit 0.
Reproduces the GitHub Actions failure mode where a transient 500 from the
internal git mirror caused ``Event loop is closed`` and a non-zero exit.
The fix routes both ``get_deployment`` and ``update_deployment`` through a
single ``asyncio.run`` so the httpx pool isn't reused across loops.
Patches are at the helper boundary (``configure_git_remote`` /
``push_to_remote``) so the test doesn't depend on the exact shape of the
underlying git plumbing.
"""
runner = CliRunner()
current = make_deployment(
"my-app", repo_url=INTERNAL_CODE_REPO_SCHEME, git_sha="a" * 40
)
updated = make_deployment(
"my-app", repo_url=INTERNAL_CODE_REPO_SCHEME, git_sha="b" * 40
)
client = _client_mock(current, updated)
with (
patch_project_client(client),
# ``git rev-parse --git-dir`` succeeds — we're "in a repo".
patch(
"llama_agents.cli.commands.deployment.subprocess.run",
return_value=_completed_process(returncode=0, stdout=b".git\n"),
),
patch(
"llama_agents.cli.commands.deployment.configure_git_remote",
return_value="llamaagents-my-app",
),
# The push itself fails with a 500 (the originally reported flake).
patch(
"llama_agents.cli.commands.deployment.push_to_remote",
return_value=_completed_process(
returncode=1,
stderr=(
b"error: RPC failed; HTTP 500\n"
b"fatal: the remote end hung up unexpectedly\n"
),
),
),
):
result = runner.invoke(
app, ["deployments", "update", "my-app", "--no-interactive"]
)
assert result.exit_code == 0, result.output
assert "Event loop is closed" not in result.output
client.get_deployment.assert_called_once()
client.update_deployment.assert_called_once()