temporalio-openai-codex 0.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,24 @@
1
+ """Durable OpenAI Codex sessions on Temporal.
2
+
3
+ Installed from the ``temporalio-openai-codex`` distribution as ``temporalio.openai_codex``.
4
+ """
5
+
6
+ from ._plugin import OpenAICodexPlugin
7
+ from ._subprocess import SubprocessConnector
8
+ from ._types import (
9
+ CodexActivityOptions,
10
+ CodexProcess,
11
+ ConnectActivityInput,
12
+ Connector,
13
+ TemporalCodexConfig,
14
+ )
15
+
16
+ __all__ = [
17
+ "OpenAICodexPlugin",
18
+ "TemporalCodexConfig",
19
+ "SubprocessConnector",
20
+ "Connector",
21
+ "CodexProcess",
22
+ "ConnectActivityInput",
23
+ "CodexActivityOptions",
24
+ ]
@@ -0,0 +1,223 @@
1
+ """The Activities a :class:`TemporalCodexClient` runs, which reach Codex through a :class:`Connector`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import contextlib
7
+ import json
8
+ from collections.abc import Callable
9
+ from dataclasses import dataclass, field
10
+ from typing import Any
11
+
12
+ from openai_codex import CodexError, TransportClosedError
13
+ from temporalio import activity
14
+ from temporalio.service import PayloadLimitsConfig
15
+
16
+ from ._constants import CLOSE, CONNECT, CONNECTION_LOST, READ, WRITE
17
+ from ._errors import activity_error
18
+ from ._heartbeat_decorator import auto_heartbeater
19
+ from ._types import (
20
+ CloseActivityInput,
21
+ CodexProcess,
22
+ ConnectActivityInput,
23
+ Connection,
24
+ Connector,
25
+ Message,
26
+ ReadActivityInput,
27
+ ReadResult,
28
+ WriteActivityInput,
29
+ )
30
+
31
+
32
+ @dataclass
33
+ class _Connection:
34
+ """A Codex ``connect`` started, with the output the Workflow hasn't confirmed it received."""
35
+
36
+ process: CodexProcess
37
+ unreceived: list[Message] = field(default_factory=list[Message])
38
+ """Message a read returned, kept until the next read confirms the Workflow got it."""
39
+ received: int = 0
40
+ """How many outputs the Workflow received before ``unreceived``."""
41
+ reading: asyncio.Future[list[Message]] | None = None
42
+ """The process read that's still running."""
43
+ lock: asyncio.Lock = field(default_factory=asyncio.Lock)
44
+ writes: dict[int, asyncio.Task[None]] = field(default_factory=dict)
45
+ write_lock: asyncio.Lock = field(default_factory=asyncio.Lock)
46
+
47
+ async def write_process(self, message: Message) -> None:
48
+ """Keep later writes behind an earlier attempt that outlived its caller."""
49
+ async with self.write_lock:
50
+ await self.process.write(message)
51
+
52
+ async def close(self) -> None:
53
+ """Close the process and collect pending I/O results."""
54
+ await self.process.close()
55
+ pending: list[asyncio.Future[Any]] = list(self.writes.values())
56
+ if self.reading is not None:
57
+ pending.append(self.reading)
58
+ await asyncio.gather(*pending, return_exceptions=True)
59
+
60
+ async def read_process(self) -> list[Message]:
61
+ """Wait for the process's next output.
62
+
63
+ A cancelled attempt leaves the read running, and the next attempt waits
64
+ for it, so no output is lost.
65
+ """
66
+ if self.reading is None:
67
+ self.reading = asyncio.ensure_future(self.process.read())
68
+ reading = self.reading
69
+ try:
70
+ return await asyncio.shield(reading)
71
+ finally:
72
+ # Once it's done, even with an error, the next attempt reads again.
73
+ # A retryable error, such as a dropped network call, can then succeed.
74
+ if reading.done():
75
+ self.reading = None
76
+
77
+
78
+ class ConnectionActivities:
79
+ """One Worker's Activities, with the connector they use, the Codex processes they started, and their connector queue."""
80
+
81
+ def __init__(self, connector: Connector, connector_queue: str) -> None:
82
+ """Create the Activities, with no Codex running.
83
+
84
+ Args:
85
+ connector: Starts Codex and reaches it.
86
+ connector_queue: The task queue only this process polls, where the
87
+ Activities after ``connect`` must run.
88
+ """
89
+ self._connector = connector
90
+ self.connector_queue = connector_queue
91
+ # Duplicate plugin instances share this owner's connector Worker.
92
+ self.connector_worker_running = False
93
+ self._connections: dict[str, _Connection] = {}
94
+
95
+ def connected_activities(self) -> list[Callable[..., Any]]:
96
+ """The Activities that reach a Codex ``connect`` started, so must run in this process."""
97
+ return [self.write, self.read, self.close]
98
+
99
+ @activity.defn(name=CONNECT)
100
+ @auto_heartbeater
101
+ async def connect(self, request: ConnectActivityInput) -> Connection:
102
+ """Start Codex, unless this launch started it already."""
103
+ info = activity.info()
104
+ handle = f"{info.workflow_run_id}/{info.activity_id}"
105
+
106
+ if handle not in self._connections:
107
+ # Shielded, so a cancelled launch still gets the process to close it.
108
+ connecting = asyncio.ensure_future(self._connector.connect(request))
109
+ try:
110
+ process = await asyncio.shield(connecting)
111
+ # An overlapping attempt may have finished first. Keep that
112
+ # connection and close this attempt's unused process.
113
+ connection = self._connections.setdefault(handle, _Connection(process))
114
+ if connection.process is not process:
115
+ await process.close()
116
+ except asyncio.CancelledError:
117
+ with contextlib.suppress(Exception):
118
+ await (await connecting).close()
119
+ raise
120
+ except (CodexError, FileNotFoundError, PermissionError) as error:
121
+ raise activity_error(error) from error
122
+
123
+ return Connection(handle, self.connector_queue)
124
+
125
+ @activity.defn(name=WRITE)
126
+ @auto_heartbeater
127
+ async def write(self, request: WriteActivityInput) -> None:
128
+ """Write to Codex's stdin."""
129
+ try:
130
+ connection = self._connection(request.handle)
131
+ # One task per logical write. Shielding retains in-flight writes when
132
+ # attempts overlap or the completion is lost. Completed tasks remain
133
+ # cached until close, including permanent failures.
134
+ task = connection.writes.get(request.operation_id)
135
+ if task is None:
136
+ task = asyncio.create_task(connection.write_process(request.message))
137
+ connection.writes[request.operation_id] = task
138
+ try:
139
+ await asyncio.shield(task)
140
+ except Exception as error:
141
+ if (
142
+ not isinstance(error, CodexError)
143
+ and connection.writes.get(request.operation_id) is task
144
+ ):
145
+ connection.writes.pop(request.operation_id, None)
146
+ raise
147
+ except CodexError as error:
148
+ raise activity_error(error) from error
149
+
150
+ @activity.defn(name=READ)
151
+ @auto_heartbeater
152
+ async def read(self, request: ReadActivityInput) -> ReadResult:
153
+ """Wait for Codex's output, and return as much as fits Temporal's payload limit.
154
+
155
+ The output stays here until a later read confirms the Workflow got it.
156
+ The server drops the result of an attempt it timed out, so the retry
157
+ returns the same output again.
158
+ """
159
+ try:
160
+ connection = self._connection(request.handle)
161
+
162
+ async with connection.lock:
163
+ if request.received > connection.received:
164
+ del connection.unreceived[: request.received - connection.received]
165
+ connection.received = request.received
166
+
167
+ if not connection.unreceived:
168
+ try:
169
+ connection.unreceived = await connection.read_process()
170
+ finally:
171
+ cancel = activity.cancellation_details()
172
+ if cancel is not None and cancel.worker_shutdown:
173
+ raise TransportClosedError(CONNECTION_LOST)
174
+
175
+ return ReadResult(_fit_payload(connection.unreceived))
176
+ except CodexError as error:
177
+ raise activity_error(error) from error
178
+
179
+ @activity.defn(name=CLOSE)
180
+ @auto_heartbeater
181
+ async def close(self, request: CloseActivityInput) -> None:
182
+ """Close Codex and everything it started."""
183
+ connection = self._connections.get(request.handle)
184
+ if connection is None: # A retry of a close that succeeded.
185
+ return
186
+
187
+ try:
188
+ await connection.close()
189
+ self._connections.pop(request.handle, None)
190
+ except CodexError as error:
191
+ raise activity_error(error) from error
192
+
193
+ async def shut_down(self) -> None:
194
+ """Close every Codex this Worker started."""
195
+ connections, self._connections = self._connections, {}
196
+
197
+ results = await asyncio.gather(
198
+ *(connection.close() for connection in connections.values()),
199
+ return_exceptions=True,
200
+ )
201
+
202
+ for result in results:
203
+ if isinstance(result, BaseException):
204
+ raise result
205
+
206
+ def _connection(self, handle: str) -> _Connection:
207
+ """The Codex ``connect`` started, if this Worker still holds it."""
208
+ connection = self._connections.get(handle)
209
+ if connection is None:
210
+ # Closed, or started by an earlier run, such as one a reset replaced.
211
+ raise TransportClosedError(CONNECTION_LOST)
212
+ return connection
213
+
214
+
215
+ def _fit_payload(outputs: list[Message]) -> list[Message]:
216
+ """The most of ``outputs``, from the start, that fits Temporal's payload limit, and at least one."""
217
+ limit = PayloadLimitsConfig().payloads_warn_size
218
+ size = 2 # The surrounding JSON list.
219
+ for count, output in enumerate(outputs):
220
+ size += len(json.dumps(output).encode()) + (2 if count else 0) # ", "
221
+ if count and size > limit:
222
+ return outputs[:count]
223
+ return list(outputs)