Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
6 changes: 4 additions & 2 deletions agentlightning/controller/local_reconciler.py
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,8 @@ async def _reconcile_once(self, *, spawn_queued: bool = True) -> None:
await self._patch(rollout.rollout_id, RolloutState.RUNNING, last_attempt_id=item.attempt_id)
continue

await self._finish_proc(rollout, item)
if await self._finish_proc(rollout, item):
self._rid_to_proc.pop(rollout.rollout_id)

now = time.monotonic()
for rollout_id, item in list(self._rid_to_proc.items()):
Expand All @@ -178,8 +179,9 @@ async def _reconcile_once(self, *, spawn_queued: bool = True) -> None:
timeout is not None
and (now - item.spawned_at) > timeout
and await self._kill_process_group(rollout_id, item)
and await self._patch(rollout_id, RolloutState.FAILED, "local subprocess timed out")

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Preserved the timeout outcome through the existing killed marker. The retry regression now models the completed kill (returncode=-9) and asserts that both PATCH attempts carry local subprocess timed out. A separate case verifies that a natural signal exit without the marker still reports its exit code. The controller/server/package selection passes 42 tests; Ruff and Pyright pass.

):
await self._patch(rollout_id, RolloutState.FAILED, "local subprocess timed out")
self._rid_to_proc.pop(rollout_id)

async def _finish_proc(self, rollout: Rollout, item: Proc) -> bool:
if rollout.status.state == RolloutState.QUEUING:
Expand Down
114 changes: 110 additions & 4 deletions tests/controller/test_local_reconciler.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,15 @@
"""Unit tests for local subprocess reconciliation."""

import asyncio
from unittest.mock import AsyncMock
import time
from unittest.mock import AsyncMock, MagicMock

import httpx
import pytest
from omegaconf import OmegaConf

from agentlightning.client import AgentLightningAsyncClient
from agentlightning.controller.local_reconciler import LocalReconciler
from agentlightning.controller.local_reconciler import LocalReconciler, Proc
from agentlightning.schemas import Rollout, RolloutConfig, RolloutLifecycleStatus, RolloutState


Expand All @@ -22,11 +23,13 @@ def _response(json: object) -> httpx.Response:
)


def _reconciler(*, state: RolloutState = RolloutState.QUEUING) -> tuple[LocalReconciler, AsyncMock]:
def _reconciler(
*, state: RolloutState = RolloutState.QUEUING, timeout_seconds: int = 3600
) -> tuple[LocalReconciler, AsyncMock]:
rollout = Rollout(
rollout_id="rollout-1",
input={"question": "1 + 1"},
config=RolloutConfig(),
config=RolloutConfig(timeout_seconds=timeout_seconds),
status=RolloutLifecycleStatus(state=state, created_at=1.0, updated_at=1.0),
)
api = AsyncMock(spec=AgentLightningAsyncClient)
Expand Down Expand Up @@ -100,3 +103,106 @@ async def test_shutdown_still_fails_running_rollout_without_local_process() -> N
"state": "failed",
"error_message": "local subprocess is not running",
}


@pytest.mark.asyncio
@pytest.mark.parametrize(
("returncode", "expected_status"),
[
(0, {"state": "succeeded", "last_attempt_id": "attempt-1"}),
(1, {"state": "failed", "error_message": "subprocess exited with code 1"}),
],
)
async def test_reconcile_removes_completed_process_after_terminal_patch(
returncode: int, expected_status: dict[str, str]
) -> None:
reconciler, api = _reconciler(state=RolloutState.RUNNING)
proc = MagicMock(spec=asyncio.subprocess.Process)
proc.returncode = returncode
reconciler._rid_to_proc["rollout-1"] = Proc("attempt-1", proc, spawned_at=time.monotonic())

await reconciler._reconcile_once()

assert "rollout-1" not in reconciler._rid_to_proc
assert api.patch.await_args.kwargs["json"]["status"] == expected_status


@pytest.mark.asyncio
async def test_reconcile_keeps_completed_process_when_terminal_patch_fails_then_retries() -> None:
reconciler, api = _reconciler(state=RolloutState.RUNNING)
proc = MagicMock(spec=asyncio.subprocess.Process)
proc.returncode = 0
reconciler._rid_to_proc["rollout-1"] = Proc("attempt-1", proc, spawned_at=time.monotonic())
api.patch.side_effect = [
httpx.Response(500, request=httpx.Request("PATCH", "http://server/api/rollouts/rollout-1")),
_response({}),
]

await reconciler._reconcile_once()

assert "rollout-1" in reconciler._rid_to_proc

await reconciler._reconcile_once()

assert "rollout-1" not in reconciler._rid_to_proc
assert api.patch.await_count == 2


@pytest.mark.asyncio
async def test_reconcile_keeps_running_process() -> None:
reconciler, api = _reconciler(state=RolloutState.RUNNING)
proc = MagicMock(spec=asyncio.subprocess.Process)
proc.returncode = None
reconciler._rid_to_proc["rollout-1"] = Proc("attempt-1", proc, spawned_at=time.monotonic())

await reconciler._reconcile_once()

assert reconciler._rid_to_proc["rollout-1"].proc is proc
api.patch.assert_not_awaited()


@pytest.mark.asyncio
async def test_reconcile_removes_timed_out_process_after_failure_patch(monkeypatch: pytest.MonkeyPatch) -> None:
reconciler, api = _reconciler(state=RolloutState.RUNNING, timeout_seconds=1)
proc = MagicMock(spec=asyncio.subprocess.Process)
proc.returncode = None
reconciler._rid_to_proc["rollout-1"] = Proc("attempt-1", proc, spawned_at=time.monotonic() - 2)
kill_process_group = AsyncMock(return_value=True)
monkeypatch.setattr(reconciler, "_kill_process_group", kill_process_group)

await reconciler._reconcile_once()

assert "rollout-1" not in reconciler._rid_to_proc
kill_process_group.assert_awaited_once()
assert api.patch.await_args.kwargs["json"]["status"] == {
"state": "failed",
"error_message": "local subprocess timed out",
}


@pytest.mark.asyncio
async def test_reconcile_keeps_timed_out_process_when_failure_patch_fails_then_retries(
monkeypatch: pytest.MonkeyPatch,
) -> None:
reconciler, api = _reconciler(state=RolloutState.RUNNING, timeout_seconds=1)
proc = MagicMock(spec=asyncio.subprocess.Process)
proc.returncode = None
reconciler._rid_to_proc["rollout-1"] = Proc("attempt-1", proc, spawned_at=time.monotonic() - 2)
kill_process_group = AsyncMock(return_value=True)
monkeypatch.setattr(reconciler, "_kill_process_group", kill_process_group)
api.patch.side_effect = [
httpx.Response(500, request=httpx.Request("PATCH", "http://server/api/rollouts/rollout-1")),
_response({}),
]

await reconciler._reconcile_once()

assert "rollout-1" in reconciler._rid_to_proc

# A successful kill has completed; retry reporting through the exited path.
proc.returncode = -9
await reconciler._reconcile_once()

assert "rollout-1" not in reconciler._rid_to_proc
assert api.patch.await_count == 2
kill_process_group.assert_awaited_once()