@@ -32,6 +32,7 @@ load_dotenv()
3232import agentex.lib.adk as adk
3333from agentex.lib.adk import CodexTurn
3434from agentex.lib.types.acp import SendEventParams, CancelTaskParams, CreateTaskParams
35+ from agentex.types.text_content import TextContent
3536from agentex.lib.core.harness import UnifiedEmitter
3637from agentex.lib.types.fastacp import AsyncACPConfig
3738from agentex.lib.types.tracing import SGPTracingProcessorConfig
@@ -57,6 +58,20 @@ acp = FastACP.create(
5758
5859MODEL = os.environ.get("CODEX_MODEL", "o4-mini")
5960
61+ # Serialize turns per task. Two ``task/event/send`` calls for the same task can
62+ # otherwise both read the old ``codex_thread_id`` (or ``None``), run independent
63+ # codex turns, and race to overwrite the stored thread id — forking the session.
64+ # A per-task lock keeps turns sequential without blocking other tasks.
65+ _task_locks: dict[str, asyncio.Lock] = {}
66+
67+
68+ def _task_lock(task_id: str) -> asyncio.Lock:
69+ lock = _task_locks.get(task_id)
70+ if lock is None:
71+ lock = asyncio.Lock()
72+ _task_locks[task_id] = lock
73+ return lock
74+
6075
6176class ConversationState(BaseModel):
6277 """Per-task conversation state persisted via ``adk.state``.
@@ -150,81 +165,105 @@ async def handle_task_event_send(params: SendEventParams):
150165 """Handle each user message: spawn codex, stream events, save thread ID."""
151166 task_id = params.task.id
152167 agent_id = params.agent.id
153- user_message = params.event.content.content
154-
155- logger.info("Processing message for task %s", task_id)
156-
157- await adk.messages.create(task_id=task_id, content=params.event.content)
158-
159- task_state = await adk.state.get_by_task_and_agent(task_id=task_id, agent_id=agent_id)
160- if task_state is None:
161- state = ConversationState()
162- task_state = await adk.state.create(task_id=task_id, agent_id=agent_id, state=state)
163- else:
164- state = ConversationState.model_validate(task_state.state)
165-
166- state.turn_number += 1
167-
168- async with adk.tracing.span(
169- trace_id=task_id,
170- task_id=task_id,
171- name=f"Turn {state.turn_number}",
172- input={"message": user_message},
173- data={"__span_type__": "AGENT_WORKFLOW"},
174- ) as turn_span:
175- start_ms = int(time.monotonic() * 1000)
176168
177- process = await _spawn_codex(MODEL, thread_id=state.codex_thread_id)
178-
179- assert process.stdin is not None
180- process.stdin.write(user_message.encode("utf-8"))
181- await process.stdin.drain()
182- process.stdin.close()
183-
184- turn = CodexTurn(
185- events=_process_stdout(process),
186- model=MODEL,
169+ content = params.event.content
170+ if not isinstance(content, TextContent):
171+ logger.warning(
172+ "Ignoring non-text event content (type=%s) for task %s",
173+ getattr(content, "type", "?"),
174+ task_id,
187175 )
176+ return
177+ user_message = content.content
188178
189- emitter = UnifiedEmitter(
190- task_id=task_id,
191- trace_id=task_id,
192- parent_span_id=turn_span.id if turn_span else None,
193- )
179+ logger.info("Processing message for task %s", task_id)
194180
195- # Guarantee the subprocess is reaped even if auto_send_turn raises
196- # (e.g. a Redis error); otherwise codex stays blocked writing to a full
197- # stdout pipe buffer and the OS process leaks until the server restarts.
198- try:
199- result = await emitter.auto_send_turn(turn)
200- finally:
201- if process.returncode is None:
202- process.kill()
203- await process.wait()
204-
205- # Record the real wall-clock duration AFTER streaming completes; setting
206- # it before the stream ran would capture only subprocess spawn overhead.
207- turn.duration_ms = int(time.monotonic() * 1000) - start_ms
208-
209- usage = turn.usage()
210-
211- # Persist the codex session id (public accessor; valid post-stream) so the
212- # next turn resumes the same session.
213- if turn.session_id:
214- state.codex_thread_id = turn.session_id
215-
216- await adk.state.update(
217- state_id=task_state.id,
181+ # Serialize the whole turn (echo + the read-modify-write of
182+ # ``codex_thread_id``) so two concurrent turns on the same task cannot fork
183+ # the codex session or interleave their echoed messages.
184+ lock = _task_lock(task_id)
185+ await lock.acquire()
186+ try:
187+ # Echo inside the lock so this turn's message stays ordered with it.
188+ await adk.messages.create(task_id=task_id, content=content)
189+
190+ task_state = await adk.state.get_by_task_and_agent(task_id=task_id, agent_id=agent_id)
191+ if task_state is None:
192+ state = ConversationState()
193+ task_state = await adk.state.create(task_id=task_id, agent_id=agent_id, state=state)
194+ else:
195+ state = ConversationState.model_validate(task_state.state)
196+
197+ state.turn_number += 1
198+
199+ async with adk.tracing.span(
200+ trace_id=task_id,
218201 task_id=task_id,
219- agent_id=agent_id,
220- state=state,
221- )
222-
223- if turn_span:
224- turn_span.output = {
225- "final_text": result.final_text,
226- "model": usage.model,
227- }
202+ name=f"Turn {state.turn_number}",
203+ input={"message": user_message},
204+ data={"__span_type__": "AGENT_WORKFLOW"},
205+ ) as turn_span:
206+ start_ms = int(time.monotonic() * 1000)
207+
208+ process = await _spawn_codex(MODEL, thread_id=state.codex_thread_id)
209+
210+ assert process.stdin is not None
211+ process.stdin.write(user_message.encode("utf-8"))
212+ await process.stdin.drain()
213+ process.stdin.close()
214+
215+ turn = CodexTurn(
216+ events=_process_stdout(process),
217+ model=MODEL,
218+ )
219+
220+ emitter = UnifiedEmitter(
221+ task_id=task_id,
222+ trace_id=task_id,
223+ parent_span_id=turn_span.id if turn_span else None,
224+ )
225+
226+ # Guarantee the subprocess is reaped even if auto_send_turn raises
227+ # (e.g. a Redis error); otherwise codex stays blocked writing to a full
228+ # stdout pipe buffer and the OS process leaks until the server restarts.
229+ try:
230+ result = await emitter.auto_send_turn(turn)
231+ finally:
232+ if process.returncode is None:
233+ process.kill()
234+ await process.wait()
235+
236+ # Record the real wall-clock duration AFTER streaming completes; setting
237+ # it before the stream ran would capture only subprocess spawn overhead.
238+ turn.duration_ms = int(time.monotonic() * 1000) - start_ms
239+
240+ usage = turn.usage()
241+
242+ # Persist the codex session id (public accessor; valid post-stream) so the
243+ # next turn resumes the same session.
244+ if turn.session_id:
245+ state.codex_thread_id = turn.session_id
246+
247+ await adk.state.update(
248+ state_id=task_state.id,
249+ task_id=task_id,
250+ agent_id=agent_id,
251+ state=state,
252+ )
253+
254+ if turn_span:
255+ turn_span.output = {
256+ "final_text": result.final_text,
257+ "model": usage.model,
258+ }
259+ finally:
260+ lock.release()
261+ # Evict the lock once released and idle (unlocked, no waiters) so
262+ # ``_task_locks`` stays bounded even if the turn raised. There is no
263+ # await between ``_task_lock()`` and acquiring it, so an unlocked,
264+ # waiter-free lock has no in-flight user.
265+ if not lock.locked() and not getattr(lock, "_waiters", None):
266+ _task_locks.pop(task_id, None)
228267
229268
230269@acp.on_task_cancel
0 commit comments