Skip to content

Commit c15caef

Browse files
authored
fix(standalone): repair in-process execution flow (#780)
* fix(standalone): repair in-process execution flow * docs(standalone): add in-process executor docstrings * test(standalone): refine in-process executor coverage * test(standalone): add docstrings to in-process executor tests * test(standalone): add docstrings to executor tests
1 parent a641de4 commit c15caef

2 files changed

Lines changed: 150 additions & 17 deletions

File tree

backend/app/services/execution/inprocess_executor.py

Lines changed: 29 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
)
2828
from shared.models.responses_api import ResponsesAPIStreamEvents
2929
from shared.models.responses_api_emitter import EventTransport
30+
from shared.status import TaskStatus
3031

3132
from .dispatcher import _require_non_empty_tool_use_id, extract_completed_result
3233
from .emitters import ResultEmitter
@@ -263,11 +264,17 @@ async def execute(
263264
request: ExecutionRequest,
264265
emitter: ResultEmitter,
265266
) -> None:
266-
"""Execute task in-process.
267+
"""Execute one standalone task inside the backend process.
267268
268269
Args:
269270
request: Execution request
270271
emitter: Result emitter for event emission
272+
273+
Raises:
274+
RuntimeError: When agent creation fails or the in-process lifecycle
275+
returns a terminal failure status. The exception is re-raised
276+
after emitting an error event so outer dispatcher layers can
277+
keep their normal failure handling.
271278
"""
272279
logger.info(
273280
f"[InprocessExecutor] Starting in-process execution: "
@@ -297,15 +304,12 @@ async def execute(
297304
.build()
298305
)
299306

300-
# Convert request to task_data dict for AgentService
301-
task_data = request.to_dict()
302-
303307
# Get or create agent service (singleton)
304308
agent_service = AgentService()
305309

306310
# Create agent with custom emitter
307311
# AgentService caches agents by task_id, so same task reuses the same agent
308-
agent = agent_service.create_agent(task_data)
312+
agent = agent_service.create_agent(request)
309313
if not agent:
310314
raise RuntimeError(f"Failed to create agent for task {request.task_id}")
311315

@@ -337,6 +341,12 @@ async def execute(
337341
f"task_id={request.task_id}, status={status}, error={error_message}"
338342
)
339343

344+
if status == TaskStatus.FAILED:
345+
raise RuntimeError(
346+
error_message
347+
or f"In-process execution failed for task {request.task_id}"
348+
)
349+
340350
# Note: The agent's emitter will have already sent DONE/ERROR events
341351
# through the bridge transport, so we don't need to emit them again here
342352

@@ -363,28 +373,33 @@ async def _execute_agent_async(
363373
self,
364374
agent,
365375
) -> tuple:
366-
"""Execute agent asynchronously in the current event loop.
376+
"""Run the agent lifecycle in the current event loop.
367377
368-
This method handles the agent lifecycle (pre_execute, execute, post_execute)
369-
similar to Agent.handle(), but uses async methods to stay in the current
370-
event loop.
378+
This mirrors ``Agent.handle()`` for standalone mode by awaiting
379+
``pre_execute()`` and then using ``execute_async()`` when available.
380+
It returns a ``FAILED`` status tuple instead of raising so ``execute()``
381+
can centralize frontend error emission and dispatcher propagation.
371382
372383
Args:
373384
agent: The agent instance to execute
374385
375386
Returns:
376-
Tuple of (TaskStatus, error_message)
387+
Tuple of ``(TaskStatus, error_message)`` where ``error_message`` is
388+
populated only for failure states.
377389
"""
378390
from shared.status import TaskStatus
379391

380392
try:
381-
# Pre-execute phase (sync, but fast)
393+
# Mirror Agent.handle() semantics in the current event loop.
382394
logger.info(
383395
f"Agent[{agent.get_name()}][{agent.task_id}] handle: Starting pre_execute."
384396
)
385-
pre_status = agent.pre_execute()
386-
if pre_status not in (TaskStatus.SUCCESS, TaskStatus.RUNNING):
387-
return pre_status, f"Pre-execute failed with status: {pre_status}"
397+
pre_status, pre_error = await agent.pre_execute()
398+
if pre_status != TaskStatus.SUCCESS:
399+
error_msg = f"Pre-execute failed with status: {pre_status}"
400+
if pre_error:
401+
error_msg = f"{error_msg}\n{pre_error}"
402+
return TaskStatus.FAILED, error_msg
388403

389404
# Execute phase - use async method if available
390405
logger.info(
@@ -404,9 +419,6 @@ async def _execute_agent_async(
404419
f"Agent[{agent.get_name()}][{agent.task_id}] handle: execute finished with result: {status}"
405420
)
406421

407-
# Post-execute phase (sync, but fast)
408-
agent.post_execute()
409-
410422
return status, None
411423

412424
except Exception as e:
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import sys
2+
import types
3+
from types import SimpleNamespace
4+
from unittest.mock import AsyncMock, MagicMock, patch
5+
6+
import pytest
7+
8+
from app.services.execution.inprocess_executor import InprocessExecutor
9+
from shared.models.execution import ExecutionRequest
10+
from shared.status import TaskStatus
11+
12+
13+
@pytest.mark.asyncio
14+
async def test_execute_passes_execution_request_to_agent_service() -> None:
15+
"""Pass an ExecutionRequest object directly to AgentService."""
16+
request = ExecutionRequest(task_id=1, subtask_id=2, message_id=3)
17+
emitter = AsyncMock()
18+
executor = InprocessExecutor()
19+
20+
agent = MagicMock()
21+
agent.get_name.return_value = "fake-agent"
22+
23+
agent_service = MagicMock()
24+
agent_service.create_agent.return_value = agent
25+
fake_module = types.ModuleType("executor.services.agent_service")
26+
fake_module.AgentService = MagicMock(return_value=agent_service)
27+
28+
with (
29+
patch.dict(sys.modules, {"executor.services.agent_service": fake_module}),
30+
patch.object(
31+
executor,
32+
"_execute_agent_async",
33+
new=AsyncMock(return_value=(TaskStatus.SUCCESS, None)),
34+
) as mock_execute_agent,
35+
patch.object(
36+
executor,
37+
"_cleanup_task_clients",
38+
new=AsyncMock(),
39+
) as mock_cleanup,
40+
):
41+
await executor.execute(request, emitter)
42+
43+
agent_service.create_agent.assert_called_once()
44+
assert agent_service.create_agent.call_args.args[0] is request
45+
mock_execute_agent.assert_awaited_once_with(agent)
46+
mock_cleanup.assert_awaited_once_with(request.task_id)
47+
48+
49+
@pytest.mark.asyncio
50+
async def test_execute_agent_async_awaits_pre_execute_and_skips_missing_post_execute() -> None:
51+
"""Await async lifecycle hooks without requiring post_execute()."""
52+
executor = InprocessExecutor()
53+
agent = SimpleNamespace(
54+
task_id=3,
55+
get_name=lambda: "fake-agent",
56+
pre_execute=AsyncMock(return_value=(TaskStatus.SUCCESS, None)),
57+
execute_async=AsyncMock(return_value=TaskStatus.COMPLETED),
58+
)
59+
60+
status, error = await executor._execute_agent_async(agent)
61+
62+
assert status == TaskStatus.COMPLETED
63+
assert error is None
64+
agent.pre_execute.assert_awaited_once()
65+
agent.execute_async.assert_awaited_once()
66+
67+
68+
@pytest.mark.asyncio
69+
async def test_execute_agent_async_returns_failed_when_pre_execute_fails() -> None:
70+
"""Return FAILED when pre_execute reports a failure status."""
71+
executor = InprocessExecutor()
72+
agent = SimpleNamespace(
73+
task_id=4,
74+
get_name=lambda: "fake-agent",
75+
pre_execute=AsyncMock(return_value=(TaskStatus.FAILED, "boom")),
76+
execute_async=AsyncMock(),
77+
)
78+
79+
status, error = await executor._execute_agent_async(agent)
80+
81+
assert status == TaskStatus.FAILED
82+
assert error is not None
83+
assert "Pre-execute failed" in error
84+
assert "boom" in error
85+
agent.pre_execute.assert_awaited_once()
86+
agent.execute_async.assert_not_awaited()
87+
88+
89+
@pytest.mark.asyncio
90+
async def test_execute_raises_and_emits_error_when_agent_lifecycle_returns_failed() -> None:
91+
"""Raise and emit an error when the agent lifecycle returns FAILED."""
92+
request = ExecutionRequest(task_id=10, subtask_id=11, message_id=12)
93+
emitter = AsyncMock()
94+
executor = InprocessExecutor()
95+
96+
agent = MagicMock()
97+
agent.get_name.return_value = "fake-agent"
98+
99+
agent_service = MagicMock()
100+
agent_service.create_agent.return_value = agent
101+
fake_module = types.ModuleType("executor.services.agent_service")
102+
fake_module.AgentService = MagicMock(return_value=agent_service)
103+
104+
with (
105+
patch.dict(sys.modules, {"executor.services.agent_service": fake_module}),
106+
patch.object(
107+
executor,
108+
"_execute_agent_async",
109+
new=AsyncMock(return_value=(TaskStatus.FAILED, "pre-execute failed")),
110+
),
111+
patch.object(
112+
executor,
113+
"_cleanup_task_clients",
114+
new=AsyncMock(),
115+
) as mock_cleanup,
116+
pytest.raises(RuntimeError, match="pre-execute failed"),
117+
):
118+
await executor.execute(request, emitter)
119+
120+
emitter.emit_error.assert_awaited_once()
121+
mock_cleanup.assert_not_awaited()

0 commit comments

Comments
 (0)