|
1 | 1 | """Temporal workflow for the Claude Code tutorial. |
2 | 2 |
|
3 | 3 | Holds conversation state (session_id for multi-turn resume) durably across |
4 | | -crashes. Each user message triggers ``on_task_event_send``, which spawns the |
5 | | -Claude Code CLI locally as an asyncio subprocess, wraps the stdout line |
6 | | -stream in ``ClaudeCodeTurn``, and delivers the turn via |
| 4 | +crashes. Each user message triggers ``on_task_event_send``, which delegates the |
| 5 | +turn to the ``run_claude_code_turn`` activity. The activity spawns the Claude |
| 6 | +Code CLI, wraps its stdout in ``ClaudeCodeTurn``, and delivers the turn via |
7 | 7 | ``UnifiedEmitter.auto_send_turn`` (the async Redis push path). |
8 | 8 |
|
9 | 9 | Note on subprocess inside Temporal |
10 | 10 | ------------------------------------ |
11 | | -Temporal activities, not workflow code, should do I/O. However, this tutorial |
12 | | -executes the subprocess directly in the signal handler (workflow code) to keep |
13 | | -the example minimal. For production use, move the subprocess spawn into a |
14 | | -dedicated activity so it benefits from Temporal's retry and timeout guarantees. |
15 | | -See ``examples/tutorials/10_async/10_temporal/030_custom_activities/`` for |
16 | | -the activity pattern. |
| 11 | +Subprocess (and all other) I/O must run in a Temporal *activity*, never in |
| 12 | +workflow code. Temporal runs workflow + signal-handler bodies on a |
| 13 | +deterministic sandbox event loop that does not implement ``subprocess_exec`` |
| 14 | +(spawning the CLI there raises ``NotImplementedError``). The activity also gets |
| 15 | +Temporal's retry + timeout guarantees. See |
| 16 | +``examples/tutorials/10_async/10_temporal/030_custom_activities/`` for the |
| 17 | +activity pattern. |
17 | 18 | """ |
18 | 19 |
|
19 | 20 | from __future__ import annotations |
20 | 21 |
|
21 | 22 | import os |
22 | 23 | import json |
23 | | -import asyncio |
24 | | -from typing import AsyncIterator |
| 24 | +from datetime import timedelta |
25 | 25 |
|
26 | 26 | from temporalio import workflow |
27 | 27 |
|
28 | 28 | from agentex.lib import adk |
29 | | -from agentex.lib.adk import ClaudeCodeTurn |
30 | 29 | from agentex.lib.types.acp import SendEventParams, CreateTaskParams |
31 | | -from agentex.lib.core.harness import UnifiedEmitter |
32 | 30 | from agentex.lib.types.tracing import SGPTracingProcessorConfig |
33 | 31 | from agentex.lib.utils.logging import make_logger |
34 | 32 | from agentex.types.text_content import TextContent |
|
37 | 35 | from agentex.lib.core.temporal.workflows.workflow import BaseWorkflow |
38 | 36 | from agentex.lib.core.tracing.tracing_processor_manager import add_tracing_processor_config |
39 | 37 |
|
| 38 | +with workflow.unsafe.imports_passed_through(): |
| 39 | + from project.activities import RunClaudeCodeTurnParams, run_claude_code_turn |
| 40 | + |
40 | 41 | add_tracing_processor_config( |
41 | 42 | SGPTracingProcessorConfig( |
42 | 43 | sgp_api_key=os.environ.get("SGP_API_KEY", ""), |
|
55 | 56 | logger = make_logger(__name__) |
56 | 57 |
|
57 | 58 |
|
58 | | -async def _spawn_claude(prompt: str, session_id: str | None = None) -> AsyncIterator[str]: |
59 | | - """Spawn ``claude -p --output-format stream-json`` locally and yield stdout lines. |
60 | | -
|
61 | | - Pass ``session_id`` to resume a previous Claude Code session (multi-turn |
62 | | - memory via ``-r <session_id>``). |
63 | | -
|
64 | | - Injectable seam: tests monkeypatch this with a fake async iterator so no |
65 | | - real CLI invocation is needed offline. |
66 | | - """ |
67 | | - cmd = [ |
68 | | - "claude", |
69 | | - "-p", |
70 | | - "--output-format", |
71 | | - "stream-json", |
72 | | - "--verbose", |
73 | | - ] |
74 | | - if session_id: |
75 | | - cmd.extend(["-r", session_id]) |
76 | | - |
77 | | - proc = await asyncio.create_subprocess_exec( |
78 | | - *cmd, |
79 | | - stdin=asyncio.subprocess.PIPE, |
80 | | - stdout=asyncio.subprocess.PIPE, |
81 | | - stderr=asyncio.subprocess.PIPE, |
82 | | - ) |
83 | | - assert proc.stdout is not None |
84 | | - assert proc.stdin is not None |
85 | | - |
86 | | - proc.stdin.write(prompt.encode()) |
87 | | - proc.stdin.close() |
88 | | - |
89 | | - # Drain stderr concurrently. With --verbose, Claude Code can write enough to |
90 | | - # stderr to fill the OS pipe buffer; if we only read stdout, the CLI blocks |
91 | | - # on its stderr write while we block reading stdout — a deadlock. A |
92 | | - # background task keeps stderr flowing so stdout never stalls. |
93 | | - async def _drain_stderr() -> None: |
94 | | - assert proc.stderr is not None |
95 | | - async for _ in proc.stderr: |
96 | | - pass |
97 | | - |
98 | | - stderr_task = asyncio.create_task(_drain_stderr()) |
99 | | - |
100 | | - try: |
101 | | - buffer = "" |
102 | | - async for chunk in proc.stdout: |
103 | | - buffer += chunk.decode("utf-8", errors="replace") |
104 | | - while "\n" in buffer: |
105 | | - line, buffer = buffer.split("\n", 1) |
106 | | - line = line.strip() |
107 | | - if line: |
108 | | - yield line |
109 | | - |
110 | | - if buffer.strip(): |
111 | | - yield buffer.strip() |
112 | | - |
113 | | - await proc.wait() |
114 | | - finally: |
115 | | - # Release the subprocess and stderr drain task even if the consumer |
116 | | - # abandons the generator early (task cancellation / client disconnect): |
117 | | - # cancel the drain task and terminate+reap the process if it is still |
118 | | - # running, so neither is leaked. |
119 | | - stderr_task.cancel() |
120 | | - try: |
121 | | - await stderr_task |
122 | | - except asyncio.CancelledError: |
123 | | - pass |
124 | | - if proc.returncode is None: |
125 | | - try: |
126 | | - proc.terminate() |
127 | | - except ProcessLookupError: |
128 | | - pass |
129 | | - await proc.wait() |
130 | | - |
131 | | - |
132 | 59 | @workflow.defn(name=environment_variables.WORKFLOW_NAME) |
133 | 60 | class At140ClaudeCodeWorkflow(BaseWorkflow): |
134 | 61 | """Temporal workflow that runs Claude Code locally for each user message. |
@@ -161,29 +88,30 @@ async def on_task_event_send(self, params: SendEventParams) -> None: |
161 | 88 | name=f"Turn {self._turn_number}", |
162 | 89 | input={"message": prompt}, |
163 | 90 | ) as span: |
164 | | - emitter = UnifiedEmitter( |
165 | | - task_id=task_id, |
166 | | - trace_id=task_id, |
167 | | - parent_span_id=span.id if span else None, |
| 91 | + # Delegate the subprocess turn to an activity: subprocess I/O is not |
| 92 | + # permitted on the Temporal workflow event loop. The activity streams |
| 93 | + # events to the task and returns the final text + session_id. |
| 94 | + # workflow.now() gives a deterministic timestamp under replay. |
| 95 | + result = await workflow.execute_activity( |
| 96 | + run_claude_code_turn, |
| 97 | + RunClaudeCodeTurnParams( |
| 98 | + task_id=task_id, |
| 99 | + prompt=prompt, |
| 100 | + trace_id=task_id, |
| 101 | + parent_span_id=span.id if span else None, |
| 102 | + session_id=self._session_id, |
| 103 | + created_at=workflow.now(), |
| 104 | + ), |
| 105 | + start_to_close_timeout=timedelta(minutes=5), |
168 | 106 | ) |
169 | 107 |
|
170 | | - # Use workflow.now() for deterministic timestamps under Temporal replay. |
171 | | - created_at = workflow.now() |
172 | | - |
173 | | - turn = ClaudeCodeTurn(_spawn_claude(prompt, session_id=self._session_id)) |
174 | | - result = await emitter.auto_send_turn(turn, created_at=created_at) |
175 | | - |
176 | | - # Capture session_id from result envelope to enable resume on next turn. |
177 | | - # ClaudeCodeTurn.usage() gives us access to the raw result envelope via |
178 | | - # TurnUsage -- but session_id is not part of TurnUsage. We extract it |
179 | | - # separately by looking at the turn's internal state post-exhaust. |
180 | | - if hasattr(turn, "_result_envelope") and turn._result_envelope: |
181 | | - sid = turn._result_envelope.get("session_id") |
182 | | - if sid: |
183 | | - self._session_id = sid |
| 108 | + # Capture session_id to enable Claude Code resume on the next turn. |
| 109 | + sid = result.get("session_id") |
| 110 | + if sid: |
| 111 | + self._session_id = sid |
184 | 112 |
|
185 | 113 | if span: |
186 | | - span.output = {"final_text": result.final_text} |
| 114 | + span.output = {"final_text": result.get("final_text")} |
187 | 115 |
|
188 | 116 | @workflow.run |
189 | 117 | async def on_task_create(self, params: CreateTaskParams) -> str: |
|
0 commit comments