Skip to content

Commit 58c27ea

Browse files
committed
handle same workflow task/create gracefully (WorkflowAlreadyStartedError)
1 parent 72732b7 commit 58c27ea

3 files changed

Lines changed: 122 additions & 1 deletion

File tree

src/agentex/lib/core/clients/temporal/temporal_client.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,11 @@
55
from collections.abc import Callable
66

77
from temporalio.client import Client, WorkflowExecutionStatus
8-
from temporalio.common import RetryPolicy as TemporalRetryPolicy, WorkflowIDReusePolicy
8+
from temporalio.common import (
9+
RetryPolicy as TemporalRetryPolicy,
10+
WorkflowIDReusePolicy,
11+
WorkflowIDConflictPolicy,
12+
)
913
from temporalio.service import RPCError, RPCStatusCode
1014
from temporalio.converter import PayloadCodec, DataConverter
1115

@@ -151,6 +155,7 @@ async def start_workflow(
151155
self,
152156
*args: Any,
153157
duplicate_policy: DuplicateWorkflowPolicy = DuplicateWorkflowPolicy.ALLOW_DUPLICATE,
158+
id_conflict_policy: WorkflowIDConflictPolicy = WorkflowIDConflictPolicy.UNSPECIFIED,
154159
retry_policy: RetryPolicy = DEFAULT_RETRY_POLICY,
155160
task_timeout: timedelta = timedelta(seconds=10),
156161
execution_timeout: timedelta | None = None,
@@ -163,6 +168,7 @@ async def start_workflow(
163168
task_timeout=task_timeout,
164169
execution_timeout=execution_timeout,
165170
id_reuse_policy=DUPLICATE_POLICY_TO_ID_REUSE_POLICY[duplicate_policy],
171+
id_conflict_policy=id_conflict_policy,
166172
**kwargs,
167173
)
168174
return workflow_handle.id

src/agentex/lib/core/temporal/services/temporal_task_service.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
from contextlib import contextmanager
77
from collections.abc import Iterator
88

9+
from temporalio.common import WorkflowIDConflictPolicy
10+
911
from agentex.types.task import Task
1012
from agentex.types.agent import Agent
1113
from agentex.types.event import Event
@@ -89,6 +91,8 @@ async def submit_task(self, agent: Agent, task: Task, params: dict[str, Any] | N
8991
# value bounds the whole continue-as-new chain's wall-clock lifetime.
9092
timeout_seconds = self._env_vars.WORKFLOW_EXECUTION_TIMEOUT_SECONDS
9193
execution_timeout = timedelta(seconds=timeout_seconds) if timeout_seconds and timeout_seconds > 0 else None
94+
# USE_EXISTING makes task/create idempotent
95+
# If same task ID is already running Temporal returns a handle to the existing run instead of raising WorkflowAlreadyStarted
9296
with _acp_dispatch_span("acp.task_create", task_id=task.id):
9397
return await self._temporal_client.start_workflow(
9498
workflow=self._env_vars.WORKFLOW_NAME,
@@ -100,6 +104,7 @@ async def submit_task(self, agent: Agent, task: Task, params: dict[str, Any] | N
100104
id=task.id,
101105
task_queue=self._env_vars.WORKFLOW_TASK_QUEUE,
102106
execution_timeout=execution_timeout,
107+
id_conflict_policy=WorkflowIDConflictPolicy.USE_EXISTING,
103108
)
104109

105110
async def get_state(self, task_id: str) -> WorkflowState:
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
"""Unit tests for TemporalTaskService idempotency behavior.
2+
3+
Covers the ``task/create`` idempotency guarantee: duplicate submits for the
4+
same task ID must not raise ``WorkflowAlreadyStartedError``. The service
5+
achieves this by passing ``WorkflowIDConflictPolicy.USE_EXISTING`` to Temporal,
6+
which returns a handle to the existing run instead of erroring.
7+
"""
8+
9+
from __future__ import annotations
10+
11+
from unittest.mock import Mock, AsyncMock
12+
13+
import pytest
14+
from temporalio.common import WorkflowIDConflictPolicy
15+
16+
from agentex.types.task import Task
17+
from agentex.types.agent import Agent
18+
from agentex.lib.core.clients.temporal.temporal_client import TemporalClient
19+
from agentex.lib.core.temporal.services.temporal_task_service import TemporalTaskService
20+
21+
22+
def _agent() -> Agent:
23+
return Agent(
24+
id="test-agent-456",
25+
name="test-agent",
26+
description="test-agent",
27+
acp_type="async",
28+
created_at="2023-01-01T00:00:00Z",
29+
updated_at="2023-01-01T00:00:00Z",
30+
)
31+
32+
33+
def _task() -> Task:
34+
return Task(id="test-task-123", status="RUNNING")
35+
36+
37+
def _env_vars() -> Mock:
38+
env_vars = Mock()
39+
env_vars.WORKFLOW_NAME = "test-workflow"
40+
env_vars.WORKFLOW_TASK_QUEUE = "test-queue"
41+
env_vars.WORKFLOW_EXECUTION_TIMEOUT_SECONDS = 0
42+
return env_vars
43+
44+
45+
class TestSubmitTaskIdempotency:
46+
async def test_submit_task_uses_use_existing_conflict_policy(self) -> None:
47+
"""Duplicate task/create must be idempotent.
48+
49+
Passing ``WorkflowIDConflictPolicy.USE_EXISTING`` tells Temporal to
50+
return the existing workflow handle instead of raising
51+
``WorkflowAlreadyStartedError`` when a run with that ID is already
52+
active. Without this, load-balanced agentex-agent replicas racing on
53+
the same task ID surface Temporal's start conflict as an error log.
54+
"""
55+
temporal_client = Mock()
56+
temporal_client.start_workflow = AsyncMock(return_value="test-task-123")
57+
58+
service = TemporalTaskService(temporal_client=temporal_client, env_vars=_env_vars())
59+
60+
result = await service.submit_task(agent=_agent(), task=_task(), params=None)
61+
62+
temporal_client.start_workflow.assert_awaited_once()
63+
kwargs = temporal_client.start_workflow.await_args.kwargs
64+
assert kwargs["id_conflict_policy"] == WorkflowIDConflictPolicy.USE_EXISTING
65+
assert kwargs["id"] == "test-task-123"
66+
assert result == "test-task-123"
67+
68+
69+
class TestTemporalClientConflictPolicyPlumbing:
70+
"""Boundary tests: TemporalClient.start_workflow must forward
71+
``id_conflict_policy`` to the underlying temporalio client, and default
72+
to ``UNSPECIFIED`` so callers that don't opt in keep their current
73+
behavior (Temporal server treats UNSPECIFIED as FAIL on start).
74+
"""
75+
76+
async def test_forwards_id_conflict_policy_when_set(self) -> None:
77+
inner_client = Mock()
78+
inner_handle = Mock()
79+
inner_handle.id = "wf-1"
80+
inner_client.start_workflow = AsyncMock(return_value=inner_handle)
81+
82+
tc = TemporalClient(temporal_client=inner_client)
83+
84+
await tc.start_workflow(
85+
workflow="w",
86+
arg={},
87+
id="id-1",
88+
task_queue="q",
89+
id_conflict_policy=WorkflowIDConflictPolicy.USE_EXISTING,
90+
)
91+
92+
kwargs = inner_client.start_workflow.await_args.kwargs
93+
assert kwargs["id_conflict_policy"] == WorkflowIDConflictPolicy.USE_EXISTING
94+
95+
async def test_default_conflict_policy_is_unspecified(self) -> None:
96+
inner_client = Mock()
97+
inner_handle = Mock()
98+
inner_handle.id = "wf-1"
99+
inner_client.start_workflow = AsyncMock(return_value=inner_handle)
100+
101+
tc = TemporalClient(temporal_client=inner_client)
102+
103+
await tc.start_workflow(workflow="w", arg={}, id="id-1", task_queue="q")
104+
105+
kwargs = inner_client.start_workflow.await_args.kwargs
106+
assert kwargs["id_conflict_policy"] == WorkflowIDConflictPolicy.UNSPECIFIED
107+
108+
109+
if __name__ == "__main__": # pragma: no cover
110+
raise SystemExit(pytest.main([__file__, "-v"]))

0 commit comments

Comments
 (0)