Skip to content

Commit 7f7e1ea

Browse files
declan-scaleclaude
andcommitted
fix(cli): make default-codex lock failure-safe and echo inside lock
- Acquire the per-task lock with try/finally so the lock is released and evicted even if the codex turn raises (previously eviction was skipped on the error path, leaking the lock). - Echo the user message inside the lock so concurrent turns' echoes stay ordered with their turns. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 889fc3b commit 7f7e1ea

1 file changed

Lines changed: 16 additions & 12 deletions

File tree

  • src/agentex/lib/cli/templates/default-codex/project

src/agentex/lib/cli/templates/default-codex/project/acp.py.j2

Lines changed: 16 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -178,12 +178,15 @@ async def handle_task_event_send(params: SendEventParams):
178178

179179
logger.info("Processing message for task %s", task_id)
180180

181-
await adk.messages.create(task_id=task_id, content=content)
182-
183-
# Serialize the read-modify-write of ``codex_thread_id`` so two concurrent
184-
# turns on the same task cannot fork the codex session.
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.
185184
lock = _task_lock(task_id)
186-
async with lock:
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+
187190
task_state = await adk.state.get_by_task_and_agent(task_id=task_id, agent_id=agent_id)
188191
if task_state is None:
189192
state = ConversationState()
@@ -253,13 +256,14 @@ async def handle_task_event_send(params: SendEventParams):
253256
"final_text": result.final_text,
254257
"model": usage.model,
255258
}
256-
257-
# Evict the lock once this turn has released it and no other turn holds or
258-
# is waiting on it, so ``_task_locks`` stays bounded without breaking the
259-
# serialization guarantee. There is no await between ``_task_lock()`` and
260-
# acquiring it, so an unlocked, waiter-free lock has no in-flight user.
261-
if not lock.locked() and not getattr(lock, "_waiters", None):
262-
_task_locks.pop(task_id, None)
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)
263267

264268

265269
@acp.on_task_cancel

0 commit comments

Comments
 (0)