Skip to content

Commit c37646c

Browse files
declan-scaleclaude
andcommitted
fix(codex): run codex CLI in a Temporal activity, not workflow code
The temporal tutorial spawned codex exec directly in the workflow signal handler. Temporal runs signal-handler bodies on its deterministic sandbox event loop, which does not implement asyncio.create_subprocess_exec — so the worker crashed with NotImplementedError. (Replay was never the issue, contrary to the old docstring; the sandboxed loop is.) Move the subprocess + CodexTurn + UnifiedEmitter.auto_send_turn into a new run_codex_turn activity (project/activities.py), register it on the worker, and have the signal handler delegate via workflow.execute_activity. The activity returns final_text + session_id so codex-thread resume still works. Offline tests now cover both the workflow's state update and the activity directly. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 87922b7 commit c37646c

4 files changed

Lines changed: 236 additions & 133 deletions

File tree

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
"""Temporal activity for the Codex harness tutorial.
2+
3+
Subprocess spawning (and any other I/O) must run inside a Temporal *activity*,
4+
not in workflow code. Temporal runs workflow + signal-handler bodies on a
5+
deterministic sandbox event loop that does not implement ``subprocess_exec``
6+
(or threads / sockets), so spawning ``codex exec`` directly in the signal
7+
handler raises ``NotImplementedError``. This activity runs codex, drives the
8+
``CodexTurn`` through ``UnifiedEmitter.auto_send_turn`` (the async Redis push
9+
path), and returns the turn result to the workflow.
10+
11+
The ``_spawn_codex`` / ``_process_stdout`` seams are injectable: offline tests
12+
replace them with fakes that yield pre-recorded event lines so no real CLI
13+
runs.
14+
"""
15+
16+
from __future__ import annotations
17+
18+
import os
19+
import asyncio
20+
from typing import Any
21+
from datetime import datetime
22+
from collections.abc import AsyncIterator
23+
24+
from temporalio import activity
25+
26+
from agentex.lib.adk import CodexTurn
27+
from agentex.lib.core.harness import UnifiedEmitter
28+
from agentex.lib.utils.logging import make_logger
29+
from agentex.lib.utils.model_utils import BaseModel
30+
31+
logger = make_logger(__name__)
32+
33+
RUN_CODEX_TURN_ACTIVITY = "run_codex_turn"
34+
35+
36+
class RunCodexTurnParams(BaseModel):
37+
"""Arguments for one codex turn run inside an activity."""
38+
39+
task_id: str
40+
prompt: str
41+
model: str
42+
trace_id: str | None = None
43+
parent_span_id: str | None = None
44+
thread_id: str | None = None
45+
created_at: datetime | None = None
46+
47+
48+
class RunCodexTurnResult(BaseModel):
49+
"""Result returned from the activity to the workflow."""
50+
51+
final_text: str
52+
session_id: str | None = None
53+
model: str | None = None
54+
55+
56+
async def _spawn_codex(
57+
model: str,
58+
thread_id: str | None = None,
59+
) -> asyncio.subprocess.Process:
60+
"""Spawn ``codex exec --json`` locally and return the live process.
61+
62+
Injection seam: tests replace this function with a fake that returns a
63+
mock process whose stdout yields pre-recorded event lines.
64+
65+
The caller writes the prompt to stdin after the process starts, then
66+
closes stdin so codex knows input is complete.
67+
"""
68+
base_flags = [
69+
"--json",
70+
"--skip-git-repo-check",
71+
"--dangerously-bypass-approvals-and-sandbox",
72+
"--model",
73+
model,
74+
]
75+
76+
if thread_id:
77+
cmd = ["codex", "exec", *base_flags, "resume", thread_id, "-"]
78+
else:
79+
cmd = ["codex", "exec", *base_flags, "-"]
80+
81+
return await asyncio.create_subprocess_exec(
82+
*cmd,
83+
stdin=asyncio.subprocess.PIPE,
84+
stdout=asyncio.subprocess.PIPE,
85+
stderr=asyncio.subprocess.PIPE,
86+
env={**os.environ},
87+
)
88+
89+
90+
async def _process_stdout(process: asyncio.subprocess.Process) -> AsyncIterator[str]:
91+
"""Yield newline-delimited JSON lines from the process stdout."""
92+
assert process.stdout is not None
93+
buffer = ""
94+
while True:
95+
chunk = await process.stdout.read(4096)
96+
if not chunk:
97+
break
98+
buffer += chunk.decode("utf-8", errors="replace")
99+
while "\n" in buffer:
100+
line, buffer = buffer.split("\n", 1)
101+
line = line.strip()
102+
if line:
103+
yield line
104+
if buffer.strip():
105+
yield buffer.strip()
106+
107+
108+
@activity.defn(name=RUN_CODEX_TURN_ACTIVITY)
109+
async def run_codex_turn(params: RunCodexTurnParams) -> dict[str, Any]:
110+
"""Run one codex turn end-to-end and stream events to the task.
111+
112+
Runs in an activity (real asyncio loop) so subprocess I/O is permitted.
113+
"""
114+
process = await _spawn_codex(params.model, thread_id=params.thread_id)
115+
116+
assert process.stdin is not None
117+
process.stdin.write(params.prompt.encode("utf-8"))
118+
await process.stdin.drain()
119+
process.stdin.close()
120+
121+
turn = CodexTurn(events=_process_stdout(process), model=params.model)
122+
emitter = UnifiedEmitter(
123+
task_id=params.task_id,
124+
trace_id=params.trace_id,
125+
parent_span_id=params.parent_span_id,
126+
)
127+
result = await emitter.auto_send_turn(turn, created_at=params.created_at)
128+
129+
await process.wait()
130+
131+
return RunCodexTurnResult(
132+
final_text=result.final_text,
133+
session_id=turn.session_id,
134+
model=turn.usage().model,
135+
).model_dump()

examples/tutorials/10_async/10_temporal/harness_codex/project/run_worker.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,15 @@
33
Run as a separate long-lived process alongside the ACP HTTP server. The
44
worker polls Temporal for workflow + activity tasks and executes them.
55
6-
Codex subprocess calls happen inside signal handler bodies (not activities),
7-
so no extra activity registrations are needed beyond the standard Agentex set.
6+
The codex CLI subprocess runs in the ``run_codex_turn`` activity (registered
7+
below alongside the built-in Agentex activities), because subprocess I/O is not
8+
permitted on the Temporal workflow event loop.
89
"""
910

1011
import asyncio
1112

1213
from project.workflow import AtHarnessCodexWorkflow
14+
from project.activities import run_codex_turn
1315
from agentex.lib.utils.debug import setup_debug_if_enabled
1416
from agentex.lib.utils.logging import make_logger
1517
from agentex.lib.environment_variables import EnvironmentVariables
@@ -30,7 +32,7 @@ async def main():
3032
worker = AgentexWorker(task_queue=task_queue_name)
3133

3234
await worker.run(
33-
activities=get_all_activities(),
35+
activities=[run_codex_turn, *get_all_activities()],
3436
workflow=AtHarnessCodexWorkflow,
3537
)
3638

examples/tutorials/10_async/10_temporal/harness_codex/project/workflow.py

Lines changed: 32 additions & 96 deletions
Original file line numberDiff line numberDiff line change
@@ -4,36 +4,29 @@
44
``UnifiedEmitter`` for a Temporal-durable ACP agent.
55
66
KEY CONCEPTS DEMONSTRATED:
7-
- Spawning ``codex exec --json`` as a local asyncio subprocess inside a
8-
Temporal workflow signal handler. The subprocess itself is NOT a Temporal
9-
activity — for a tutorial that is fine. Production agents would wrap the
10-
spawn in a Temporal activity for durability + observability.
11-
- Wrapping the stdout line stream in a ``CodexTurn``.
7+
- Running ``codex exec --json`` in the ``run_codex_turn`` activity. Subprocess
8+
I/O is not permitted on the Temporal workflow event loop (the deterministic
9+
sandbox loop does not implement ``subprocess_exec``), so the signal handler
10+
delegates the turn to an activity, which also gets Temporal's retry + timeout
11+
guarantees.
12+
- Wrapping the stdout line stream in a ``CodexTurn`` (inside the activity).
1213
- Delivering events via ``UnifiedEmitter.auto_send_turn``, which pushes
1314
``StreamTaskMessage*`` events to Redis so the UI sees tokens in real time.
1415
- Passing ``created_at=workflow.now()`` for deterministic timestamps under
1516
Temporal replay (required for Temporal-safe delivery).
1617
- Persisting the codex thread ID on the workflow instance itself — Temporal's
1718
workflow state is durable, so no external ``adk.state`` round-trip is needed.
18-
19-
NOTE: Subprocess spawning is safe inside a Temporal signal handler because
20-
Temporal does NOT replay signal handler bodies (only ``@workflow.run`` is
21-
subject to replay determinism constraints). Signal handlers run in the live
22-
process after the initial replay is complete.
2319
"""
2420

2521
from __future__ import annotations
2622

2723
import os
28-
import asyncio
29-
from collections.abc import AsyncIterator
24+
from datetime import timedelta
3025

3126
from temporalio import workflow
3227

3328
from agentex.lib import adk
34-
from agentex.lib.adk import CodexTurn
3529
from agentex.lib.types.acp import SendEventParams, CreateTaskParams
36-
from agentex.lib.core.harness import UnifiedEmitter
3730
from agentex.lib.types.tracing import SGPTracingProcessorConfig
3831
from agentex.lib.utils.logging import make_logger
3932
from agentex.types.text_content import TextContent
@@ -42,6 +35,9 @@
4235
from agentex.lib.core.temporal.workflows.workflow import BaseWorkflow
4336
from agentex.lib.core.tracing.tracing_processor_manager import add_tracing_processor_config
4437

38+
with workflow.unsafe.imports_passed_through():
39+
from project.activities import RunCodexTurnParams, run_codex_turn
40+
4541
add_tracing_processor_config(
4642
SGPTracingProcessorConfig(
4743
sgp_api_key=os.environ.get("SGP_API_KEY", ""),
@@ -62,62 +58,6 @@
6258
MODEL = os.environ.get("CODEX_MODEL", "o4-mini")
6359

6460

65-
async def _spawn_codex(
66-
model: str,
67-
thread_id: str | None = None,
68-
) -> asyncio.subprocess.Process:
69-
"""Spawn ``codex exec --json`` locally and return the live process.
70-
71-
Injection seam: tests replace this function with a fake that returns a
72-
mock process whose stdout yields pre-recorded event lines.
73-
74-
NOTE: This function must NOT be called during Temporal workflow replay
75-
(Temporal's determinism guard would flag it as a non-deterministic side
76-
effect). Signal handler bodies are safe: Temporal does not replay them.
77-
78-
The caller writes the prompt to stdin after the process starts, then
79-
closes stdin so codex knows input is complete.
80-
"""
81-
base_flags = [
82-
"--json",
83-
"--skip-git-repo-check",
84-
"--dangerously-bypass-approvals-and-sandbox",
85-
"--model",
86-
model,
87-
]
88-
89-
if thread_id:
90-
cmd = ["codex", "exec", *base_flags, "resume", thread_id, "-"]
91-
else:
92-
cmd = ["codex", "exec", *base_flags, "-"]
93-
94-
return await asyncio.create_subprocess_exec(
95-
*cmd,
96-
stdin=asyncio.subprocess.PIPE,
97-
stdout=asyncio.subprocess.PIPE,
98-
stderr=asyncio.subprocess.PIPE,
99-
env={**os.environ},
100-
)
101-
102-
103-
async def _process_stdout(process: asyncio.subprocess.Process) -> AsyncIterator[str]:
104-
"""Yield newline-delimited JSON lines from the process stdout."""
105-
assert process.stdout is not None
106-
buffer = ""
107-
while True:
108-
chunk = await process.stdout.read(4096)
109-
if not chunk:
110-
break
111-
buffer += chunk.decode("utf-8", errors="replace")
112-
while "\n" in buffer:
113-
line, buffer = buffer.split("\n", 1)
114-
line = line.strip()
115-
if line:
116-
yield line
117-
if buffer.strip():
118-
yield buffer.strip()
119-
120-
12161
@workflow.defn(name=environment_variables.WORKFLOW_NAME)
12262
class AtHarnessCodexWorkflow(BaseWorkflow):
12363
"""Long-running Temporal workflow that runs codex exec for each turn.
@@ -149,37 +89,33 @@ async def on_task_event_send(self, params: SendEventParams) -> None:
14989
name=f"Turn {self._turn_number}",
15090
input={"message": user_message},
15191
) as span:
152-
process = await _spawn_codex(MODEL, thread_id=self._codex_thread_id)
153-
154-
assert process.stdin is not None
155-
process.stdin.write(user_message.encode("utf-8"))
156-
await process.stdin.drain()
157-
process.stdin.close()
158-
159-
turn = CodexTurn(
160-
events=_process_stdout(process),
161-
model=MODEL,
162-
)
163-
164-
emitter = UnifiedEmitter(
165-
task_id=params.task.id,
166-
trace_id=params.task.id,
167-
parent_span_id=span.id if span else None,
92+
# Delegate the subprocess turn to an activity: subprocess I/O is not
93+
# permitted on the Temporal workflow event loop. The activity streams
94+
# events to the task and returns the final text + codex thread id.
95+
# workflow.now() gives a deterministic timestamp under replay.
96+
result = await workflow.execute_activity(
97+
run_codex_turn,
98+
RunCodexTurnParams(
99+
task_id=params.task.id,
100+
prompt=user_message,
101+
model=MODEL,
102+
trace_id=params.task.id,
103+
parent_span_id=span.id if span else None,
104+
thread_id=self._codex_thread_id,
105+
created_at=workflow.now(),
106+
),
107+
start_to_close_timeout=timedelta(minutes=5),
168108
)
169109

170-
# Pass workflow.now() so Temporal replay stamps messages with a
171-
# deterministic timestamp from the workflow clock, not wall time.
172-
result = await emitter.auto_send_turn(turn, created_at=workflow.now())
173-
174-
await process.wait()
175-
176-
if turn.session_id:
177-
self._codex_thread_id = turn.session_id
110+
# Persist the codex thread id so the next turn resumes the session.
111+
session_id = result.get("session_id")
112+
if session_id:
113+
self._codex_thread_id = session_id
178114

179115
if span:
180116
span.output = {
181-
"final_text": result.final_text,
182-
"model": turn.usage().model,
117+
"final_text": result.get("final_text"),
118+
"model": result.get("model"),
183119
}
184120

185121
@workflow.run

0 commit comments

Comments
 (0)