codex-autorunner 1.0.0__py3-none-any.whl → 1.2.0__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.
Files changed (227) hide show
  1. codex_autorunner/__init__.py +12 -1
  2. codex_autorunner/agents/codex/harness.py +1 -1
  3. codex_autorunner/agents/opencode/client.py +113 -4
  4. codex_autorunner/agents/opencode/constants.py +3 -0
  5. codex_autorunner/agents/opencode/harness.py +6 -1
  6. codex_autorunner/agents/opencode/runtime.py +59 -18
  7. codex_autorunner/agents/opencode/supervisor.py +4 -0
  8. codex_autorunner/agents/registry.py +36 -7
  9. codex_autorunner/bootstrap.py +226 -4
  10. codex_autorunner/cli.py +5 -1174
  11. codex_autorunner/codex_cli.py +20 -84
  12. codex_autorunner/core/__init__.py +20 -0
  13. codex_autorunner/core/about_car.py +119 -1
  14. codex_autorunner/core/app_server_ids.py +59 -0
  15. codex_autorunner/core/app_server_threads.py +17 -2
  16. codex_autorunner/core/app_server_utils.py +165 -0
  17. codex_autorunner/core/archive.py +349 -0
  18. codex_autorunner/core/codex_runner.py +6 -2
  19. codex_autorunner/core/config.py +433 -4
  20. codex_autorunner/core/context_awareness.py +38 -0
  21. codex_autorunner/core/docs.py +0 -122
  22. codex_autorunner/core/drafts.py +58 -4
  23. codex_autorunner/core/exceptions.py +4 -0
  24. codex_autorunner/core/filebox.py +265 -0
  25. codex_autorunner/core/flows/controller.py +96 -2
  26. codex_autorunner/core/flows/models.py +13 -0
  27. codex_autorunner/core/flows/reasons.py +52 -0
  28. codex_autorunner/core/flows/reconciler.py +134 -0
  29. codex_autorunner/core/flows/runtime.py +57 -4
  30. codex_autorunner/core/flows/store.py +142 -7
  31. codex_autorunner/core/flows/transition.py +27 -15
  32. codex_autorunner/core/flows/ux_helpers.py +272 -0
  33. codex_autorunner/core/flows/worker_process.py +32 -6
  34. codex_autorunner/core/git_utils.py +62 -0
  35. codex_autorunner/core/hub.py +291 -20
  36. codex_autorunner/core/lifecycle_events.py +253 -0
  37. codex_autorunner/core/notifications.py +14 -2
  38. codex_autorunner/core/path_utils.py +2 -1
  39. codex_autorunner/core/pma_audit.py +224 -0
  40. codex_autorunner/core/pma_context.py +496 -0
  41. codex_autorunner/core/pma_dispatch_interceptor.py +284 -0
  42. codex_autorunner/core/pma_lifecycle.py +527 -0
  43. codex_autorunner/core/pma_queue.py +367 -0
  44. codex_autorunner/core/pma_safety.py +221 -0
  45. codex_autorunner/core/pma_state.py +115 -0
  46. codex_autorunner/core/ports/__init__.py +28 -0
  47. codex_autorunner/{integrations/agents → core/ports}/agent_backend.py +13 -8
  48. codex_autorunner/core/ports/backend_orchestrator.py +41 -0
  49. codex_autorunner/{integrations/agents → core/ports}/run_event.py +23 -6
  50. codex_autorunner/core/prompt.py +0 -80
  51. codex_autorunner/core/prompts.py +56 -172
  52. codex_autorunner/core/redaction.py +0 -4
  53. codex_autorunner/core/review_context.py +11 -9
  54. codex_autorunner/core/runner_controller.py +35 -33
  55. codex_autorunner/core/runner_state.py +147 -0
  56. codex_autorunner/core/runtime.py +829 -0
  57. codex_autorunner/core/sqlite_utils.py +13 -4
  58. codex_autorunner/core/state.py +7 -10
  59. codex_autorunner/core/state_roots.py +62 -0
  60. codex_autorunner/core/supervisor_protocol.py +15 -0
  61. codex_autorunner/core/templates/__init__.py +39 -0
  62. codex_autorunner/core/templates/git_mirror.py +234 -0
  63. codex_autorunner/core/templates/provenance.py +56 -0
  64. codex_autorunner/core/templates/scan_cache.py +120 -0
  65. codex_autorunner/core/text_delta_coalescer.py +54 -0
  66. codex_autorunner/core/ticket_linter_cli.py +218 -0
  67. codex_autorunner/core/ticket_manager_cli.py +494 -0
  68. codex_autorunner/core/time_utils.py +11 -0
  69. codex_autorunner/core/types.py +18 -0
  70. codex_autorunner/core/update.py +4 -5
  71. codex_autorunner/core/update_paths.py +28 -0
  72. codex_autorunner/core/usage.py +164 -12
  73. codex_autorunner/core/utils.py +125 -15
  74. codex_autorunner/flows/review/__init__.py +17 -0
  75. codex_autorunner/{core/review.py → flows/review/service.py} +37 -34
  76. codex_autorunner/flows/ticket_flow/definition.py +52 -3
  77. codex_autorunner/integrations/agents/__init__.py +11 -19
  78. codex_autorunner/integrations/agents/backend_orchestrator.py +302 -0
  79. codex_autorunner/integrations/agents/codex_adapter.py +90 -0
  80. codex_autorunner/integrations/agents/codex_backend.py +177 -25
  81. codex_autorunner/integrations/agents/opencode_adapter.py +108 -0
  82. codex_autorunner/integrations/agents/opencode_backend.py +305 -32
  83. codex_autorunner/integrations/agents/runner.py +86 -0
  84. codex_autorunner/integrations/agents/wiring.py +279 -0
  85. codex_autorunner/integrations/app_server/client.py +7 -60
  86. codex_autorunner/integrations/app_server/env.py +2 -107
  87. codex_autorunner/{core/app_server_events.py → integrations/app_server/event_buffer.py} +15 -8
  88. codex_autorunner/integrations/telegram/adapter.py +65 -0
  89. codex_autorunner/integrations/telegram/config.py +46 -0
  90. codex_autorunner/integrations/telegram/constants.py +1 -1
  91. codex_autorunner/integrations/telegram/doctor.py +228 -6
  92. codex_autorunner/integrations/telegram/handlers/callbacks.py +7 -0
  93. codex_autorunner/integrations/telegram/handlers/commands/execution.py +236 -74
  94. codex_autorunner/integrations/telegram/handlers/commands/files.py +314 -75
  95. codex_autorunner/integrations/telegram/handlers/commands/flows.py +1496 -71
  96. codex_autorunner/integrations/telegram/handlers/commands/workspace.py +498 -37
  97. codex_autorunner/integrations/telegram/handlers/commands_runtime.py +206 -48
  98. codex_autorunner/integrations/telegram/handlers/commands_spec.py +20 -3
  99. codex_autorunner/integrations/telegram/handlers/messages.py +27 -1
  100. codex_autorunner/integrations/telegram/handlers/selections.py +61 -1
  101. codex_autorunner/integrations/telegram/helpers.py +22 -1
  102. codex_autorunner/integrations/telegram/runtime.py +9 -4
  103. codex_autorunner/integrations/telegram/service.py +45 -10
  104. codex_autorunner/integrations/telegram/state.py +38 -0
  105. codex_autorunner/integrations/telegram/ticket_flow_bridge.py +338 -43
  106. codex_autorunner/integrations/telegram/transport.py +13 -4
  107. codex_autorunner/integrations/templates/__init__.py +27 -0
  108. codex_autorunner/integrations/templates/scan_agent.py +312 -0
  109. codex_autorunner/routes/__init__.py +37 -76
  110. codex_autorunner/routes/agents.py +2 -137
  111. codex_autorunner/routes/analytics.py +2 -238
  112. codex_autorunner/routes/app_server.py +2 -131
  113. codex_autorunner/routes/base.py +2 -596
  114. codex_autorunner/routes/file_chat.py +4 -833
  115. codex_autorunner/routes/flows.py +4 -977
  116. codex_autorunner/routes/messages.py +4 -456
  117. codex_autorunner/routes/repos.py +2 -196
  118. codex_autorunner/routes/review.py +2 -147
  119. codex_autorunner/routes/sessions.py +2 -175
  120. codex_autorunner/routes/settings.py +2 -168
  121. codex_autorunner/routes/shared.py +2 -275
  122. codex_autorunner/routes/system.py +4 -193
  123. codex_autorunner/routes/usage.py +2 -86
  124. codex_autorunner/routes/voice.py +2 -119
  125. codex_autorunner/routes/workspace.py +2 -270
  126. codex_autorunner/server.py +4 -4
  127. codex_autorunner/static/agentControls.js +61 -16
  128. codex_autorunner/static/app.js +126 -14
  129. codex_autorunner/static/archive.js +826 -0
  130. codex_autorunner/static/archiveApi.js +37 -0
  131. codex_autorunner/static/autoRefresh.js +7 -7
  132. codex_autorunner/static/chatUploads.js +137 -0
  133. codex_autorunner/static/dashboard.js +224 -171
  134. codex_autorunner/static/docChatCore.js +185 -13
  135. codex_autorunner/static/fileChat.js +68 -40
  136. codex_autorunner/static/fileboxUi.js +159 -0
  137. codex_autorunner/static/hub.js +114 -131
  138. codex_autorunner/static/index.html +375 -49
  139. codex_autorunner/static/messages.js +568 -87
  140. codex_autorunner/static/notifications.js +255 -0
  141. codex_autorunner/static/pma.js +1167 -0
  142. codex_autorunner/static/preserve.js +17 -0
  143. codex_autorunner/static/settings.js +128 -6
  144. codex_autorunner/static/smartRefresh.js +52 -0
  145. codex_autorunner/static/streamUtils.js +57 -0
  146. codex_autorunner/static/styles.css +9798 -6143
  147. codex_autorunner/static/tabs.js +152 -11
  148. codex_autorunner/static/templateReposSettings.js +225 -0
  149. codex_autorunner/static/terminal.js +18 -0
  150. codex_autorunner/static/ticketChatActions.js +165 -3
  151. codex_autorunner/static/ticketChatStream.js +17 -119
  152. codex_autorunner/static/ticketEditor.js +137 -15
  153. codex_autorunner/static/ticketTemplates.js +798 -0
  154. codex_autorunner/static/tickets.js +821 -98
  155. codex_autorunner/static/turnEvents.js +27 -0
  156. codex_autorunner/static/turnResume.js +33 -0
  157. codex_autorunner/static/utils.js +39 -0
  158. codex_autorunner/static/workspace.js +389 -82
  159. codex_autorunner/static/workspaceFileBrowser.js +15 -13
  160. codex_autorunner/surfaces/__init__.py +5 -0
  161. codex_autorunner/surfaces/cli/__init__.py +6 -0
  162. codex_autorunner/surfaces/cli/cli.py +2534 -0
  163. codex_autorunner/surfaces/cli/codex_cli.py +20 -0
  164. codex_autorunner/surfaces/cli/pma_cli.py +817 -0
  165. codex_autorunner/surfaces/telegram/__init__.py +3 -0
  166. codex_autorunner/surfaces/web/__init__.py +1 -0
  167. codex_autorunner/surfaces/web/app.py +2223 -0
  168. codex_autorunner/surfaces/web/hub_jobs.py +192 -0
  169. codex_autorunner/surfaces/web/middleware.py +587 -0
  170. codex_autorunner/surfaces/web/pty_session.py +370 -0
  171. codex_autorunner/surfaces/web/review.py +6 -0
  172. codex_autorunner/surfaces/web/routes/__init__.py +82 -0
  173. codex_autorunner/surfaces/web/routes/agents.py +138 -0
  174. codex_autorunner/surfaces/web/routes/analytics.py +284 -0
  175. codex_autorunner/surfaces/web/routes/app_server.py +132 -0
  176. codex_autorunner/surfaces/web/routes/archive.py +357 -0
  177. codex_autorunner/surfaces/web/routes/base.py +615 -0
  178. codex_autorunner/surfaces/web/routes/file_chat.py +1117 -0
  179. codex_autorunner/surfaces/web/routes/filebox.py +227 -0
  180. codex_autorunner/surfaces/web/routes/flows.py +1354 -0
  181. codex_autorunner/surfaces/web/routes/messages.py +490 -0
  182. codex_autorunner/surfaces/web/routes/pma.py +1652 -0
  183. codex_autorunner/surfaces/web/routes/repos.py +197 -0
  184. codex_autorunner/surfaces/web/routes/review.py +148 -0
  185. codex_autorunner/surfaces/web/routes/sessions.py +176 -0
  186. codex_autorunner/surfaces/web/routes/settings.py +169 -0
  187. codex_autorunner/surfaces/web/routes/shared.py +277 -0
  188. codex_autorunner/surfaces/web/routes/system.py +196 -0
  189. codex_autorunner/surfaces/web/routes/templates.py +634 -0
  190. codex_autorunner/surfaces/web/routes/usage.py +89 -0
  191. codex_autorunner/surfaces/web/routes/voice.py +120 -0
  192. codex_autorunner/surfaces/web/routes/workspace.py +271 -0
  193. codex_autorunner/surfaces/web/runner_manager.py +25 -0
  194. codex_autorunner/surfaces/web/schemas.py +469 -0
  195. codex_autorunner/surfaces/web/static_assets.py +490 -0
  196. codex_autorunner/surfaces/web/static_refresh.py +86 -0
  197. codex_autorunner/surfaces/web/terminal_sessions.py +78 -0
  198. codex_autorunner/tickets/__init__.py +8 -1
  199. codex_autorunner/tickets/agent_pool.py +53 -4
  200. codex_autorunner/tickets/files.py +37 -16
  201. codex_autorunner/tickets/lint.py +50 -0
  202. codex_autorunner/tickets/models.py +6 -1
  203. codex_autorunner/tickets/outbox.py +50 -2
  204. codex_autorunner/tickets/runner.py +396 -57
  205. codex_autorunner/web/__init__.py +5 -1
  206. codex_autorunner/web/app.py +2 -1949
  207. codex_autorunner/web/hub_jobs.py +2 -191
  208. codex_autorunner/web/middleware.py +2 -586
  209. codex_autorunner/web/pty_session.py +2 -369
  210. codex_autorunner/web/runner_manager.py +2 -24
  211. codex_autorunner/web/schemas.py +2 -376
  212. codex_autorunner/web/static_assets.py +4 -441
  213. codex_autorunner/web/static_refresh.py +2 -85
  214. codex_autorunner/web/terminal_sessions.py +2 -77
  215. codex_autorunner/workspace/paths.py +49 -33
  216. codex_autorunner-1.2.0.dist-info/METADATA +150 -0
  217. codex_autorunner-1.2.0.dist-info/RECORD +339 -0
  218. codex_autorunner/core/adapter_utils.py +0 -21
  219. codex_autorunner/core/engine.py +0 -2653
  220. codex_autorunner/core/static_assets.py +0 -55
  221. codex_autorunner-1.0.0.dist-info/METADATA +0 -246
  222. codex_autorunner-1.0.0.dist-info/RECORD +0 -251
  223. /codex_autorunner/{routes → surfaces/web/routes}/terminal_images.py +0 -0
  224. {codex_autorunner-1.0.0.dist-info → codex_autorunner-1.2.0.dist-info}/WHEEL +0 -0
  225. {codex_autorunner-1.0.0.dist-info → codex_autorunner-1.2.0.dist-info}/entry_points.txt +0 -0
  226. {codex_autorunner-1.0.0.dist-info → codex_autorunner-1.2.0.dist-info}/licenses/LICENSE +0 -0
  227. {codex_autorunner-1.0.0.dist-info → codex_autorunner-1.2.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,367 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import json
5
+ import logging
6
+ import uuid
7
+ from dataclasses import asdict, dataclass
8
+ from enum import Enum
9
+ from pathlib import Path
10
+ from typing import Any, Optional
11
+
12
+ from .locks import file_lock
13
+ from .time_utils import now_iso
14
+
15
+ PMA_QUEUE_DIR = ".codex-autorunner/pma/queue"
16
+ QUEUE_FILE_SUFFIX = ".jsonl"
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ class QueueItemState(str, Enum):
22
+ PENDING = "pending"
23
+ RUNNING = "running"
24
+ COMPLETED = "completed"
25
+ FAILED = "failed"
26
+ CANCELLED = "cancelled"
27
+ DEDUPED = "deduped"
28
+
29
+
30
+ @dataclass
31
+ class PmaQueueItem:
32
+ item_id: str
33
+ lane_id: str
34
+ enqueued_at: str
35
+ idempotency_key: str
36
+ payload: dict[str, Any]
37
+ state: QueueItemState = QueueItemState.PENDING
38
+ started_at: Optional[str] = None
39
+ finished_at: Optional[str] = None
40
+ error: Optional[str] = None
41
+ dedupe_reason: Optional[str] = None
42
+ result: Optional[dict[str, Any]] = None
43
+
44
+ @classmethod
45
+ def create(
46
+ cls,
47
+ lane_id: str,
48
+ idempotency_key: str,
49
+ payload: dict[str, Any],
50
+ ) -> "PmaQueueItem":
51
+ return cls(
52
+ item_id=str(uuid.uuid4()),
53
+ lane_id=lane_id,
54
+ enqueued_at=now_iso(),
55
+ idempotency_key=idempotency_key,
56
+ payload=payload,
57
+ state=QueueItemState.PENDING,
58
+ )
59
+
60
+ def to_dict(self) -> dict[str, Any]:
61
+ data = asdict(self)
62
+ data["state"] = self.state.value
63
+ return data
64
+
65
+ @classmethod
66
+ def from_dict(cls, data: dict[str, Any]) -> "PmaQueueItem":
67
+ data = dict(data)
68
+ if isinstance(data.get("state"), str):
69
+ try:
70
+ data["state"] = QueueItemState(data["state"])
71
+ except ValueError:
72
+ data["state"] = QueueItemState.PENDING
73
+ return cls(**data)
74
+
75
+
76
+ class PmaQueue:
77
+ """PMA queue backed by JSONL state; pending items are replayed into memory."""
78
+
79
+ def __init__(self, hub_root: Path) -> None:
80
+ self._hub_root = hub_root
81
+ self._queue_dir = hub_root / PMA_QUEUE_DIR
82
+ self._queue_dir.mkdir(parents=True, exist_ok=True)
83
+ self._lane_queues: dict[str, asyncio.Queue[PmaQueueItem]] = {}
84
+ self._lane_locks: dict[str, asyncio.Lock] = {}
85
+ self._lane_events: dict[str, asyncio.Event] = {}
86
+ self._replayed_lanes: set[str] = set()
87
+ self._lock = asyncio.Lock()
88
+
89
+ def _lane_queue_path(self, lane_id: str) -> Path:
90
+ safe_lane_id = lane_id.replace(":", "__COLON__").replace("/", "__SLASH__")
91
+ return self._queue_dir / f"{safe_lane_id}{QUEUE_FILE_SUFFIX}"
92
+
93
+ def _lane_queue_lock_path(self, lane_id: str) -> Path:
94
+ path = self._lane_queue_path(lane_id)
95
+ return path.with_suffix(path.suffix + ".lock")
96
+
97
+ def _ensure_lane_lock(self, lane_id: str) -> asyncio.Lock:
98
+ lock = self._lane_locks.get(lane_id)
99
+ if lock is None:
100
+ lock = asyncio.Lock()
101
+ self._lane_locks[lane_id] = lock
102
+ return lock
103
+
104
+ def _ensure_lane_event(self, lane_id: str) -> asyncio.Event:
105
+ event = self._lane_events.get(lane_id)
106
+ if event is None:
107
+ event = asyncio.Event()
108
+ self._lane_events[lane_id] = event
109
+ return event
110
+
111
+ def _ensure_lane_queue(self, lane_id: str) -> asyncio.Queue[PmaQueueItem]:
112
+ queue = self._lane_queues.get(lane_id)
113
+ if queue is None:
114
+ queue = asyncio.Queue()
115
+ self._lane_queues[lane_id] = queue
116
+ return queue
117
+
118
+ async def enqueue(
119
+ self,
120
+ lane_id: str,
121
+ idempotency_key: str,
122
+ payload: dict[str, Any],
123
+ ) -> tuple[PmaQueueItem, Optional[str]]:
124
+ async with self._lock:
125
+ existing = await self._find_by_idempotency_key(lane_id, idempotency_key)
126
+ if existing:
127
+ if existing.state in (QueueItemState.PENDING, QueueItemState.RUNNING):
128
+ dedupe_item = PmaQueueItem.create(
129
+ lane_id=lane_id,
130
+ idempotency_key=idempotency_key,
131
+ payload=payload,
132
+ )
133
+ dedupe_item.state = QueueItemState.DEDUPED
134
+ dedupe_item.dedupe_reason = f"duplicate_of_{existing.item_id}"
135
+ await self._append_to_file(dedupe_item)
136
+ return dedupe_item, f"duplicate of {existing.item_id}"
137
+
138
+ item = PmaQueueItem.create(lane_id, idempotency_key, payload)
139
+ await self._append_to_file(item)
140
+ queue = self._ensure_lane_queue(lane_id)
141
+ await queue.put(item)
142
+ self._ensure_lane_event(lane_id).set()
143
+ return item, None
144
+
145
+ async def dequeue(self, lane_id: str) -> Optional[PmaQueueItem]:
146
+ queue = self._lane_queues.get(lane_id)
147
+ if queue is None or queue.empty():
148
+ return None
149
+ try:
150
+ item = queue.get_nowait()
151
+ item.state = QueueItemState.RUNNING
152
+ item.started_at = now_iso()
153
+ await self._update_in_file(item)
154
+ return item
155
+ except asyncio.QueueEmpty:
156
+ return None
157
+
158
+ async def complete_item(
159
+ self, item: PmaQueueItem, result: Optional[dict[str, Any]] = None
160
+ ) -> None:
161
+ item.state = QueueItemState.COMPLETED
162
+ item.finished_at = now_iso()
163
+ if result is not None:
164
+ item.result = result
165
+ await self._update_in_file(item)
166
+
167
+ async def fail_item(self, item: PmaQueueItem, error: str) -> None:
168
+ item.state = QueueItemState.FAILED
169
+ item.finished_at = now_iso()
170
+ item.error = error
171
+ await self._update_in_file(item)
172
+
173
+ async def cancel_lane(self, lane_id: str) -> int:
174
+ cancelled = 0
175
+ cancelled_ids: set[str] = set()
176
+ items = await self.list_items(lane_id)
177
+ for item in items:
178
+ if item.state == QueueItemState.PENDING:
179
+ item.state = QueueItemState.CANCELLED
180
+ item.finished_at = now_iso()
181
+ await self._update_in_file(item)
182
+ cancelled += 1
183
+ cancelled_ids.add(item.item_id)
184
+
185
+ queue = self._lane_queues.get(lane_id)
186
+ if queue is not None:
187
+ while not queue.empty():
188
+ try:
189
+ queued_item = queue.get_nowait()
190
+ except asyncio.QueueEmpty:
191
+ break
192
+ if queued_item.item_id in cancelled_ids:
193
+ continue
194
+ if queued_item.state != QueueItemState.PENDING:
195
+ continue
196
+ queued_item.state = QueueItemState.CANCELLED
197
+ queued_item.finished_at = now_iso()
198
+ await self._update_in_file(queued_item)
199
+ cancelled += 1
200
+ cancelled_ids.add(queued_item.item_id)
201
+
202
+ event = self._lane_events.get(lane_id)
203
+ if event is not None:
204
+ event.set()
205
+
206
+ return cancelled
207
+
208
+ async def replay_pending(self, lane_id: str) -> int:
209
+ if lane_id in self._replayed_lanes:
210
+ return 0
211
+ self._replayed_lanes.add(lane_id)
212
+
213
+ items = await self.list_items(lane_id)
214
+ pending = [item for item in items if item.state == QueueItemState.PENDING]
215
+ if not pending:
216
+ return 0
217
+
218
+ queue = self._ensure_lane_queue(lane_id)
219
+ for item in pending:
220
+ await queue.put(item)
221
+ self._ensure_lane_event(lane_id).set()
222
+ return len(pending)
223
+
224
+ async def wait_for_lane_item(
225
+ self, lane_id: str, cancel_event: Optional[asyncio.Event] = None
226
+ ) -> bool:
227
+ event = self._ensure_lane_event(lane_id)
228
+ if event.is_set():
229
+ event.clear()
230
+ return True
231
+
232
+ wait_tasks = [asyncio.create_task(event.wait())]
233
+ if cancel_event is not None:
234
+ wait_tasks.append(asyncio.create_task(cancel_event.wait()))
235
+
236
+ done, pending = await asyncio.wait(
237
+ wait_tasks, return_when=asyncio.FIRST_COMPLETED
238
+ )
239
+ for task in pending:
240
+ task.cancel()
241
+
242
+ if cancel_event is not None and cancel_event.is_set():
243
+ return False
244
+
245
+ if event.is_set():
246
+ event.clear()
247
+ return True
248
+
249
+ async def list_items(self, lane_id: str) -> list[PmaQueueItem]:
250
+ path = self._lane_queue_path(lane_id)
251
+ if not path.exists():
252
+ return []
253
+
254
+ items: list[PmaQueueItem] = []
255
+ async with self._ensure_lane_lock(lane_id):
256
+ try:
257
+ content = path.read_text(encoding="utf-8")
258
+ except OSError:
259
+ return []
260
+
261
+ for line in content.strip().splitlines():
262
+ line = line.strip()
263
+ if not line:
264
+ continue
265
+ try:
266
+ data = json.loads(line)
267
+ items.append(PmaQueueItem.from_dict(data))
268
+ except (json.JSONDecodeError, ValueError):
269
+ continue
270
+
271
+ return items
272
+
273
+ async def _find_by_idempotency_key(
274
+ self, lane_id: str, idempotency_key: str
275
+ ) -> Optional[PmaQueueItem]:
276
+ items = await self.list_items(lane_id)
277
+ for item in items:
278
+ if item.idempotency_key == idempotency_key and item.state in (
279
+ QueueItemState.PENDING,
280
+ QueueItemState.RUNNING,
281
+ ):
282
+ return item
283
+ return None
284
+
285
+ async def _append_to_file(self, item: PmaQueueItem) -> None:
286
+ path = self._lane_queue_path(item.lane_id)
287
+ async with self._ensure_lane_lock(item.lane_id):
288
+ with file_lock(self._lane_queue_lock_path(item.lane_id)):
289
+ path.parent.mkdir(parents=True, exist_ok=True)
290
+ line = json.dumps(item.to_dict(), separators=(",", ":")) + "\n"
291
+ with path.open("a", encoding="utf-8") as f:
292
+ f.write(line)
293
+
294
+ async def _update_in_file(self, item: PmaQueueItem) -> None:
295
+ path = self._lane_queue_path(item.lane_id)
296
+ async with self._ensure_lane_lock(item.lane_id):
297
+ with file_lock(self._lane_queue_lock_path(item.lane_id)):
298
+ if not path.exists():
299
+ return
300
+
301
+ try:
302
+ content = path.read_text(encoding="utf-8")
303
+ except OSError:
304
+ return
305
+
306
+ lines: list[str] = []
307
+ updated = False
308
+ for line in content.strip().splitlines():
309
+ line = line.strip()
310
+ if not line:
311
+ continue
312
+ try:
313
+ data = json.loads(line)
314
+ if data.get("item_id") == item.item_id:
315
+ lines.append(
316
+ json.dumps(item.to_dict(), separators=(",", ":"))
317
+ )
318
+ updated = True
319
+ else:
320
+ lines.append(line)
321
+ except (json.JSONDecodeError, ValueError):
322
+ lines.append(line)
323
+
324
+ if updated:
325
+ path.write_text("\n".join(lines) + "\n", encoding="utf-8")
326
+
327
+ async def get_lane_stats(self, lane_id: str) -> dict[str, Any]:
328
+ items = await self.list_items(lane_id)
329
+ by_state: dict[str, int] = {}
330
+ for item in items:
331
+ state = item.state.value
332
+ by_state[state] = by_state.get(state, 0) + 1
333
+
334
+ return {
335
+ "lane_id": lane_id,
336
+ "total_items": len(items),
337
+ "by_state": by_state,
338
+ }
339
+
340
+ async def get_all_lanes(self) -> list[str]:
341
+ lanes: set[str] = set()
342
+ if not self._queue_dir.exists():
343
+ return []
344
+
345
+ for path in self._queue_dir.iterdir():
346
+ if path.is_file() and path.suffix == QUEUE_FILE_SUFFIX:
347
+ lane_name = path.stem.replace("__SLASH__", "/").replace(
348
+ "__COLON__", ":"
349
+ )
350
+ lanes.add(lane_name)
351
+
352
+ return sorted(lanes)
353
+
354
+ async def get_queue_summary(self) -> dict[str, Any]:
355
+ lanes = await self.get_all_lanes()
356
+ summary: dict[str, Any] = {"lanes": {}}
357
+ for lane in lanes:
358
+ summary["lanes"][lane] = await self.get_lane_stats(lane)
359
+ summary["total_lanes"] = len(lanes)
360
+ return summary
361
+
362
+
363
+ __all__ = [
364
+ "QueueItemState",
365
+ "PmaQueueItem",
366
+ "PmaQueue",
367
+ ]
@@ -0,0 +1,221 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from collections import defaultdict
5
+ from dataclasses import dataclass
6
+ from datetime import datetime, timezone
7
+ from pathlib import Path
8
+ from typing import Any, Optional
9
+
10
+ from .pma_audit import PmaActionType, PmaAuditEntry, PmaAuditLog
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ @dataclass
16
+ class PmaSafetyConfig:
17
+ dedup_window_seconds: int = 300
18
+ max_duplicate_actions: int = 3
19
+ rate_limit_window_seconds: int = 60
20
+ max_actions_per_window: int = 20
21
+ circuit_breaker_threshold: int = 5
22
+ circuit_breaker_cooldown_seconds: int = 600
23
+ enable_dedup: bool = True
24
+ enable_rate_limit: bool = True
25
+ enable_circuit_breaker: bool = True
26
+
27
+
28
+ @dataclass
29
+ class SafetyCheckResult:
30
+ allowed: bool
31
+ reason: Optional[str] = None
32
+ details: Optional[dict[str, Any]] = None
33
+
34
+ def __post_init__(self):
35
+ if self.details is None:
36
+ object.__setattr__(self, "details", {})
37
+
38
+
39
+ class PmaSafetyChecker:
40
+ def __init__(
41
+ self, hub_root: Path, *, config: Optional[PmaSafetyConfig] = None
42
+ ) -> None:
43
+ self._hub_root = hub_root
44
+ self._config = config or PmaSafetyConfig()
45
+ self._audit_log = PmaAuditLog(hub_root)
46
+ self._action_timestamps: defaultdict[str, list[float]] = defaultdict(list)
47
+ self._failure_counts: defaultdict[str, int] = defaultdict(int)
48
+ self._circuit_breaker_until: Optional[float] = None
49
+
50
+ def _is_circuit_breaker_active(self) -> bool:
51
+ if not self._config.enable_circuit_breaker:
52
+ return False
53
+ if self._circuit_breaker_until is None:
54
+ return False
55
+ now = datetime.now(timezone.utc).timestamp()
56
+ if now >= self._circuit_breaker_until:
57
+ self._reset_circuit_breaker()
58
+ return False
59
+ return True
60
+
61
+ def _activate_circuit_breaker(self) -> None:
62
+ self._circuit_breaker_until = (
63
+ datetime.now(timezone.utc).timestamp()
64
+ + self._config.circuit_breaker_cooldown_seconds
65
+ )
66
+ logger.warning(
67
+ "PMA circuit breaker activated (cooldown: %d seconds)",
68
+ self._config.circuit_breaker_cooldown_seconds,
69
+ )
70
+
71
+ def _reset_circuit_breaker(self) -> None:
72
+ if self._circuit_breaker_until:
73
+ self._circuit_breaker_until = None
74
+ self._failure_counts.clear()
75
+ logger.info("PMA circuit breaker reset")
76
+
77
+ def check_chat_start(
78
+ self,
79
+ agent: str,
80
+ message: str,
81
+ client_turn_id: Optional[str] = None,
82
+ ) -> SafetyCheckResult:
83
+ if self._is_circuit_breaker_active():
84
+ return SafetyCheckResult(
85
+ allowed=False,
86
+ reason="circuit_breaker_active",
87
+ details={
88
+ "cooldown_remaining_seconds": (
89
+ int(
90
+ self._circuit_breaker_until
91
+ - datetime.now(timezone.utc).timestamp()
92
+ )
93
+ if self._circuit_breaker_until
94
+ else 0
95
+ )
96
+ },
97
+ )
98
+
99
+ if self._config.enable_dedup:
100
+ fingerprint = self._compute_chat_fingerprint(agent, message)
101
+ recent_count = self._audit_log.count_fingerprint(
102
+ fingerprint, within_seconds=self._config.dedup_window_seconds
103
+ )
104
+ if recent_count >= self._config.max_duplicate_actions:
105
+ logger.warning(
106
+ "PMA duplicate action blocked (fingerprint: %s, count: %d)",
107
+ fingerprint,
108
+ recent_count,
109
+ )
110
+ return SafetyCheckResult(
111
+ allowed=False,
112
+ reason="duplicate_action",
113
+ details={
114
+ "fingerprint": fingerprint,
115
+ "count": recent_count,
116
+ "max_allowed": self._config.max_duplicate_actions,
117
+ "window_seconds": self._config.dedup_window_seconds,
118
+ },
119
+ )
120
+
121
+ if self._config.enable_rate_limit:
122
+ now = datetime.now(timezone.utc).timestamp()
123
+ key = f"chat:{agent}"
124
+ self._action_timestamps[key] = [
125
+ ts
126
+ for ts in self._action_timestamps[key]
127
+ if now - ts < self._config.rate_limit_window_seconds
128
+ ]
129
+ if len(self._action_timestamps[key]) >= self._config.max_actions_per_window:
130
+ return SafetyCheckResult(
131
+ allowed=False,
132
+ reason="rate_limit_exceeded",
133
+ details={
134
+ "agent": agent,
135
+ "count": len(self._action_timestamps[key]),
136
+ "max_allowed": self._config.max_actions_per_window,
137
+ "window_seconds": self._config.rate_limit_window_seconds,
138
+ },
139
+ )
140
+ self._action_timestamps[key].append(now)
141
+
142
+ return SafetyCheckResult(allowed=True)
143
+
144
+ def record_chat_result(
145
+ self,
146
+ agent: str,
147
+ status: str,
148
+ error: Optional[str] = None,
149
+ ) -> None:
150
+ if (
151
+ status in ("error", "failed", "interrupted")
152
+ and self._config.enable_circuit_breaker
153
+ ):
154
+ key = f"chat:{agent}"
155
+ self._failure_counts[key] += 1
156
+ if self._failure_counts[key] >= self._config.circuit_breaker_threshold:
157
+ self._activate_circuit_breaker()
158
+ else:
159
+ key = f"chat:{agent}"
160
+ self._failure_counts[key] = 0
161
+
162
+ def record_action(
163
+ self,
164
+ action_type: PmaActionType,
165
+ agent: Optional[str] = None,
166
+ details: Optional[dict[str, Any]] = None,
167
+ status: str = "ok",
168
+ error: Optional[str] = None,
169
+ thread_id: Optional[str] = None,
170
+ turn_id: Optional[str] = None,
171
+ client_turn_id: Optional[str] = None,
172
+ ) -> str:
173
+ entry = PmaAuditEntry(
174
+ action_type=action_type,
175
+ agent=agent,
176
+ thread_id=thread_id,
177
+ turn_id=turn_id,
178
+ client_turn_id=client_turn_id,
179
+ details=details or {},
180
+ status=status,
181
+ error=error,
182
+ )
183
+ entry_id = self._audit_log.append(entry)
184
+ return entry_id
185
+
186
+ def _compute_chat_fingerprint(self, agent: str, message: str) -> str:
187
+ from .pma_audit import PmaAuditEntry
188
+
189
+ temp_entry = PmaAuditEntry(
190
+ action_type=PmaActionType.CHAT_STARTED,
191
+ agent=agent,
192
+ details={"message_truncated": message[:200]},
193
+ )
194
+ return temp_entry.fingerprint
195
+
196
+ def get_stats(self) -> dict[str, Any]:
197
+ recent = self._audit_log.list_recent(limit=100)
198
+ by_type: dict[str, int] = {}
199
+ for entry in recent:
200
+ atype = entry.action_type.value
201
+ by_type[atype] = by_type.get(atype, 0) + 1
202
+ return {
203
+ "circuit_breaker_active": self._is_circuit_breaker_active(),
204
+ "circuit_breaker_cooldown_remaining": (
205
+ int(
206
+ self._circuit_breaker_until - datetime.now(timezone.utc).timestamp()
207
+ )
208
+ if self._circuit_breaker_until
209
+ else 0
210
+ ),
211
+ "recent_actions_count": len(recent),
212
+ "recent_actions_by_type": by_type,
213
+ "failure_counts": dict(self._failure_counts),
214
+ }
215
+
216
+
217
+ __all__ = [
218
+ "PmaSafetyConfig",
219
+ "SafetyCheckResult",
220
+ "PmaSafetyChecker",
221
+ ]
@@ -0,0 +1,115 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import logging
5
+ from datetime import datetime, timezone
6
+ from pathlib import Path
7
+ from typing import Any, Optional
8
+
9
+ from .locks import file_lock
10
+ from .time_utils import now_iso
11
+ from .utils import atomic_write
12
+
13
+ PMA_STATE_FILENAME = "state.json"
14
+ PMA_STATE_CORRUPT_SUFFIX = ".corrupt"
15
+ PMA_STATE_NOTICE_SUFFIX = ".corrupt.json"
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ def default_pma_state() -> dict[str, Any]:
21
+ return {
22
+ "version": 1,
23
+ "active": False,
24
+ "current": {},
25
+ "last_result": {},
26
+ "updated_at": now_iso(),
27
+ }
28
+
29
+
30
+ def default_pma_state_path(hub_root: Path) -> Path:
31
+ return hub_root / ".codex-autorunner" / "pma" / PMA_STATE_FILENAME
32
+
33
+
34
+ class PmaStateStore:
35
+ def __init__(self, hub_root: Path) -> None:
36
+ self._path = default_pma_state_path(hub_root)
37
+
38
+ @property
39
+ def path(self) -> Path:
40
+ return self._path
41
+
42
+ def _lock_path(self) -> Path:
43
+ return self._path.with_suffix(self._path.suffix + ".lock")
44
+
45
+ def _stamp(self) -> str:
46
+ return datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
47
+
48
+ def _notice_path(self) -> Path:
49
+ return self._path.with_name(f"{self._path.name}{PMA_STATE_NOTICE_SUFFIX}")
50
+
51
+ def load(self, *, ensure_exists: bool = True) -> dict[str, Any]:
52
+ with file_lock(self._lock_path()):
53
+ state = self._load_unlocked()
54
+ if state is not None:
55
+ return state
56
+ state = default_pma_state()
57
+ if ensure_exists:
58
+ self._save_unlocked(state)
59
+ return state
60
+
61
+ def save(self, state: dict[str, Any]) -> None:
62
+ with file_lock(self._lock_path()):
63
+ self._save_unlocked(state)
64
+
65
+ def _load_unlocked(self) -> Optional[dict[str, Any]]:
66
+ if not self._path.exists():
67
+ return None
68
+ try:
69
+ raw = self._path.read_text(encoding="utf-8")
70
+ except OSError as exc:
71
+ logger.warning("Failed to read PMA state at %s: %s", self._path, exc)
72
+ return None
73
+ try:
74
+ data = json.loads(raw)
75
+ except json.JSONDecodeError as exc:
76
+ self._handle_corrupt_state(str(exc))
77
+ return default_pma_state()
78
+ if not isinstance(data, dict):
79
+ self._handle_corrupt_state(
80
+ f"Expected JSON object, got {type(data).__name__}"
81
+ )
82
+ return default_pma_state()
83
+ return data
84
+
85
+ def _save_unlocked(self, state: dict[str, Any]) -> None:
86
+ self._path.parent.mkdir(parents=True, exist_ok=True)
87
+ atomic_write(self._path, json.dumps(state, indent=2) + "\n")
88
+
89
+ def _handle_corrupt_state(self, detail: str) -> None:
90
+ stamp = self._stamp()
91
+ backup_path = self._path.with_name(
92
+ f"{self._path.name}{PMA_STATE_CORRUPT_SUFFIX}.{stamp}"
93
+ )
94
+ self._path.parent.mkdir(parents=True, exist_ok=True)
95
+ try:
96
+ self._path.replace(backup_path)
97
+ backup_value = str(backup_path)
98
+ except OSError:
99
+ backup_value = ""
100
+ notice = {
101
+ "status": "corrupt",
102
+ "message": "PMA state reset due to corrupted state.json.",
103
+ "detail": detail,
104
+ "detected_at": stamp,
105
+ "backup_path": backup_value,
106
+ }
107
+ notice_path = self._notice_path()
108
+ try:
109
+ atomic_write(notice_path, json.dumps(notice, indent=2) + "\n")
110
+ except Exception:
111
+ logger.warning("Failed to write PMA corruption notice at %s", notice_path)
112
+ try:
113
+ self._save_unlocked(default_pma_state())
114
+ except Exception:
115
+ logger.warning("Failed to reset PMA state at %s", self._path)