codex-autorunner 1.2.0__py3-none-any.whl → 1.3.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.
- codex_autorunner/bootstrap.py +26 -5
- codex_autorunner/core/about_car.py +12 -12
- codex_autorunner/core/config.py +178 -61
- codex_autorunner/core/context_awareness.py +1 -0
- codex_autorunner/core/filesystem.py +24 -0
- codex_autorunner/core/flows/controller.py +50 -12
- codex_autorunner/core/flows/runtime.py +8 -3
- codex_autorunner/core/hub.py +293 -16
- codex_autorunner/core/lifecycle_events.py +44 -5
- codex_autorunner/core/pma_context.py +188 -1
- codex_autorunner/core/pma_delivery.py +81 -0
- codex_autorunner/core/pma_dispatches.py +224 -0
- codex_autorunner/core/pma_lane_worker.py +122 -0
- codex_autorunner/core/pma_queue.py +167 -18
- codex_autorunner/core/pma_reactive.py +91 -0
- codex_autorunner/core/pma_safety.py +58 -0
- codex_autorunner/core/pma_sink.py +104 -0
- codex_autorunner/core/pma_transcripts.py +183 -0
- codex_autorunner/core/safe_paths.py +117 -0
- codex_autorunner/housekeeping.py +77 -23
- codex_autorunner/integrations/agents/codex_backend.py +18 -12
- codex_autorunner/integrations/agents/wiring.py +2 -0
- codex_autorunner/integrations/app_server/client.py +31 -0
- codex_autorunner/integrations/app_server/supervisor.py +3 -0
- codex_autorunner/integrations/telegram/adapter.py +1 -1
- codex_autorunner/integrations/telegram/config.py +1 -1
- codex_autorunner/integrations/telegram/constants.py +1 -1
- codex_autorunner/integrations/telegram/handlers/commands/execution.py +16 -15
- codex_autorunner/integrations/telegram/handlers/commands/files.py +5 -8
- codex_autorunner/integrations/telegram/handlers/commands/github.py +10 -6
- codex_autorunner/integrations/telegram/handlers/commands/shared.py +9 -8
- codex_autorunner/integrations/telegram/handlers/commands/workspace.py +85 -2
- codex_autorunner/integrations/telegram/handlers/commands_runtime.py +29 -8
- codex_autorunner/integrations/telegram/handlers/messages.py +8 -2
- codex_autorunner/integrations/telegram/helpers.py +30 -2
- codex_autorunner/integrations/telegram/ticket_flow_bridge.py +54 -3
- codex_autorunner/static/archive.js +274 -81
- codex_autorunner/static/archiveApi.js +21 -0
- codex_autorunner/static/constants.js +1 -1
- codex_autorunner/static/docChatCore.js +2 -0
- codex_autorunner/static/hub.js +59 -0
- codex_autorunner/static/index.html +70 -54
- codex_autorunner/static/notificationBell.js +173 -0
- codex_autorunner/static/notifications.js +187 -36
- codex_autorunner/static/pma.js +96 -35
- codex_autorunner/static/styles.css +431 -4
- codex_autorunner/static/terminalManager.js +22 -3
- codex_autorunner/static/utils.js +5 -1
- codex_autorunner/surfaces/cli/cli.py +206 -129
- codex_autorunner/surfaces/cli/template_repos.py +157 -0
- codex_autorunner/surfaces/web/app.py +193 -5
- codex_autorunner/surfaces/web/routes/archive.py +197 -0
- codex_autorunner/surfaces/web/routes/file_chat.py +115 -87
- codex_autorunner/surfaces/web/routes/flows.py +125 -67
- codex_autorunner/surfaces/web/routes/pma.py +638 -57
- codex_autorunner/surfaces/web/schemas.py +11 -0
- codex_autorunner/tickets/agent_pool.py +6 -1
- codex_autorunner/tickets/outbox.py +27 -14
- codex_autorunner/tickets/replies.py +4 -10
- codex_autorunner/tickets/runner.py +1 -0
- codex_autorunner/workspace/paths.py +8 -3
- {codex_autorunner-1.2.0.dist-info → codex_autorunner-1.3.0.dist-info}/METADATA +1 -1
- {codex_autorunner-1.2.0.dist-info → codex_autorunner-1.3.0.dist-info}/RECORD +67 -57
- {codex_autorunner-1.2.0.dist-info → codex_autorunner-1.3.0.dist-info}/WHEEL +0 -0
- {codex_autorunner-1.2.0.dist-info → codex_autorunner-1.3.0.dist-info}/entry_points.txt +0 -0
- {codex_autorunner-1.2.0.dist-info → codex_autorunner-1.3.0.dist-info}/licenses/LICENSE +0 -0
- {codex_autorunner-1.2.0.dist-info → codex_autorunner-1.3.0.dist-info}/top_level.txt +0 -0
|
@@ -83,8 +83,10 @@ class PmaQueue:
|
|
|
83
83
|
self._lane_queues: dict[str, asyncio.Queue[PmaQueueItem]] = {}
|
|
84
84
|
self._lane_locks: dict[str, asyncio.Lock] = {}
|
|
85
85
|
self._lane_events: dict[str, asyncio.Event] = {}
|
|
86
|
+
self._lane_known_ids: dict[str, set[str]] = {}
|
|
86
87
|
self._replayed_lanes: set[str] = set()
|
|
87
88
|
self._lock = asyncio.Lock()
|
|
89
|
+
self._loop: Optional[asyncio.AbstractEventLoop] = None
|
|
88
90
|
|
|
89
91
|
def _lane_queue_path(self, lane_id: str) -> Path:
|
|
90
92
|
safe_lane_id = lane_id.replace(":", "__COLON__").replace("/", "__SLASH__")
|
|
@@ -115,6 +117,21 @@ class PmaQueue:
|
|
|
115
117
|
self._lane_queues[lane_id] = queue
|
|
116
118
|
return queue
|
|
117
119
|
|
|
120
|
+
def _ensure_lane_known_ids(self, lane_id: str) -> set[str]:
|
|
121
|
+
known = self._lane_known_ids.get(lane_id)
|
|
122
|
+
if known is None:
|
|
123
|
+
known = set()
|
|
124
|
+
self._lane_known_ids[lane_id] = known
|
|
125
|
+
return known
|
|
126
|
+
|
|
127
|
+
def _record_loop(self) -> None:
|
|
128
|
+
if self._loop is not None:
|
|
129
|
+
return
|
|
130
|
+
try:
|
|
131
|
+
self._loop = asyncio.get_running_loop()
|
|
132
|
+
except RuntimeError:
|
|
133
|
+
return
|
|
134
|
+
|
|
118
135
|
async def enqueue(
|
|
119
136
|
self,
|
|
120
137
|
lane_id: str,
|
|
@@ -122,6 +139,7 @@ class PmaQueue:
|
|
|
122
139
|
payload: dict[str, Any],
|
|
123
140
|
) -> tuple[PmaQueueItem, Optional[str]]:
|
|
124
141
|
async with self._lock:
|
|
142
|
+
self._record_loop()
|
|
125
143
|
existing = await self._find_by_idempotency_key(lane_id, idempotency_key)
|
|
126
144
|
if existing:
|
|
127
145
|
if existing.state in (QueueItemState.PENDING, QueueItemState.RUNNING):
|
|
@@ -139,10 +157,36 @@ class PmaQueue:
|
|
|
139
157
|
await self._append_to_file(item)
|
|
140
158
|
queue = self._ensure_lane_queue(lane_id)
|
|
141
159
|
await queue.put(item)
|
|
160
|
+
self._ensure_lane_known_ids(lane_id).add(item.item_id)
|
|
142
161
|
self._ensure_lane_event(lane_id).set()
|
|
143
162
|
return item, None
|
|
144
163
|
|
|
164
|
+
def enqueue_sync(
|
|
165
|
+
self,
|
|
166
|
+
lane_id: str,
|
|
167
|
+
idempotency_key: str,
|
|
168
|
+
payload: dict[str, Any],
|
|
169
|
+
) -> tuple[PmaQueueItem, Optional[str]]:
|
|
170
|
+
existing = self._find_by_idempotency_key_sync(lane_id, idempotency_key)
|
|
171
|
+
if existing:
|
|
172
|
+
if existing.state in (QueueItemState.PENDING, QueueItemState.RUNNING):
|
|
173
|
+
dedupe_item = PmaQueueItem.create(
|
|
174
|
+
lane_id=lane_id,
|
|
175
|
+
idempotency_key=idempotency_key,
|
|
176
|
+
payload=payload,
|
|
177
|
+
)
|
|
178
|
+
dedupe_item.state = QueueItemState.DEDUPED
|
|
179
|
+
dedupe_item.dedupe_reason = f"duplicate_of_{existing.item_id}"
|
|
180
|
+
self._append_to_file_sync(dedupe_item)
|
|
181
|
+
return dedupe_item, f"duplicate of {existing.item_id}"
|
|
182
|
+
|
|
183
|
+
item = PmaQueueItem.create(lane_id, idempotency_key, payload)
|
|
184
|
+
self._append_to_file_sync(item)
|
|
185
|
+
self._notify_in_memory_enqueue(item)
|
|
186
|
+
return item, None
|
|
187
|
+
|
|
145
188
|
async def dequeue(self, lane_id: str) -> Optional[PmaQueueItem]:
|
|
189
|
+
self._record_loop()
|
|
146
190
|
queue = self._lane_queues.get(lane_id)
|
|
147
191
|
if queue is None or queue.empty():
|
|
148
192
|
return None
|
|
@@ -206,11 +250,15 @@ class PmaQueue:
|
|
|
206
250
|
return cancelled
|
|
207
251
|
|
|
208
252
|
async def replay_pending(self, lane_id: str) -> int:
|
|
253
|
+
self._record_loop()
|
|
209
254
|
if lane_id in self._replayed_lanes:
|
|
210
255
|
return 0
|
|
211
256
|
self._replayed_lanes.add(lane_id)
|
|
212
257
|
|
|
213
258
|
items = await self.list_items(lane_id)
|
|
259
|
+
known = self._ensure_lane_known_ids(lane_id)
|
|
260
|
+
for item in items:
|
|
261
|
+
known.add(item.item_id)
|
|
214
262
|
pending = [item for item in items if item.state == QueueItemState.PENDING]
|
|
215
263
|
if not pending:
|
|
216
264
|
return 0
|
|
@@ -222,29 +270,45 @@ class PmaQueue:
|
|
|
222
270
|
return len(pending)
|
|
223
271
|
|
|
224
272
|
async def wait_for_lane_item(
|
|
225
|
-
self,
|
|
273
|
+
self,
|
|
274
|
+
lane_id: str,
|
|
275
|
+
cancel_event: Optional[asyncio.Event] = None,
|
|
276
|
+
*,
|
|
277
|
+
poll_interval_seconds: float = 1.0,
|
|
226
278
|
) -> bool:
|
|
279
|
+
self._record_loop()
|
|
227
280
|
event = self._ensure_lane_event(lane_id)
|
|
228
281
|
if event.is_set():
|
|
229
282
|
event.clear()
|
|
230
283
|
return True
|
|
231
284
|
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
wait_tasks
|
|
285
|
+
poll_interval = max(0.1, poll_interval_seconds)
|
|
286
|
+
while True:
|
|
287
|
+
wait_tasks = [asyncio.create_task(event.wait())]
|
|
288
|
+
if cancel_event is not None:
|
|
289
|
+
wait_tasks.append(asyncio.create_task(cancel_event.wait()))
|
|
235
290
|
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
291
|
+
try:
|
|
292
|
+
done, pending = await asyncio.wait(
|
|
293
|
+
wait_tasks,
|
|
294
|
+
timeout=poll_interval,
|
|
295
|
+
return_when=asyncio.FIRST_COMPLETED,
|
|
296
|
+
)
|
|
297
|
+
finally:
|
|
298
|
+
for task in wait_tasks:
|
|
299
|
+
if not task.done():
|
|
300
|
+
task.cancel()
|
|
241
301
|
|
|
242
|
-
|
|
243
|
-
|
|
302
|
+
if cancel_event is not None and cancel_event.is_set():
|
|
303
|
+
return False
|
|
244
304
|
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
305
|
+
if event.is_set():
|
|
306
|
+
event.clear()
|
|
307
|
+
return True
|
|
308
|
+
|
|
309
|
+
added = await self._refresh_lane_from_disk(lane_id)
|
|
310
|
+
if added:
|
|
311
|
+
return True
|
|
248
312
|
|
|
249
313
|
async def list_items(self, lane_id: str) -> list[PmaQueueItem]:
|
|
250
314
|
path = self._lane_queue_path(lane_id)
|
|
@@ -253,10 +317,11 @@ class PmaQueue:
|
|
|
253
317
|
|
|
254
318
|
items: list[PmaQueueItem] = []
|
|
255
319
|
async with self._ensure_lane_lock(lane_id):
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
320
|
+
with file_lock(self._lane_queue_lock_path(lane_id)):
|
|
321
|
+
try:
|
|
322
|
+
content = path.read_text(encoding="utf-8")
|
|
323
|
+
except OSError:
|
|
324
|
+
return []
|
|
260
325
|
|
|
261
326
|
for line in content.strip().splitlines():
|
|
262
327
|
line = line.strip()
|
|
@@ -270,6 +335,29 @@ class PmaQueue:
|
|
|
270
335
|
|
|
271
336
|
return items
|
|
272
337
|
|
|
338
|
+
async def _refresh_lane_from_disk(self, lane_id: str) -> int:
|
|
339
|
+
items = await self.list_items(lane_id)
|
|
340
|
+
if not items:
|
|
341
|
+
return 0
|
|
342
|
+
|
|
343
|
+
known = self._ensure_lane_known_ids(lane_id)
|
|
344
|
+
new_pending: list[PmaQueueItem] = []
|
|
345
|
+
for item in items:
|
|
346
|
+
if item.item_id in known:
|
|
347
|
+
continue
|
|
348
|
+
known.add(item.item_id)
|
|
349
|
+
if item.state == QueueItemState.PENDING:
|
|
350
|
+
new_pending.append(item)
|
|
351
|
+
|
|
352
|
+
if not new_pending:
|
|
353
|
+
return 0
|
|
354
|
+
|
|
355
|
+
queue = self._ensure_lane_queue(lane_id)
|
|
356
|
+
for item in new_pending:
|
|
357
|
+
await queue.put(item)
|
|
358
|
+
self._ensure_lane_event(lane_id).set()
|
|
359
|
+
return len(new_pending)
|
|
360
|
+
|
|
273
361
|
async def _find_by_idempotency_key(
|
|
274
362
|
self, lane_id: str, idempotency_key: str
|
|
275
363
|
) -> Optional[PmaQueueItem]:
|
|
@@ -324,6 +412,67 @@ class PmaQueue:
|
|
|
324
412
|
if updated:
|
|
325
413
|
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
326
414
|
|
|
415
|
+
def _append_to_file_sync(self, item: PmaQueueItem) -> None:
|
|
416
|
+
path = self._lane_queue_path(item.lane_id)
|
|
417
|
+
with file_lock(self._lane_queue_lock_path(item.lane_id)):
|
|
418
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
419
|
+
line = json.dumps(item.to_dict(), separators=(",", ":")) + "\n"
|
|
420
|
+
with path.open("a", encoding="utf-8") as f:
|
|
421
|
+
f.write(line)
|
|
422
|
+
|
|
423
|
+
def _read_items_sync(self, lane_id: str) -> list[PmaQueueItem]:
|
|
424
|
+
path = self._lane_queue_path(lane_id)
|
|
425
|
+
if not path.exists():
|
|
426
|
+
return []
|
|
427
|
+
|
|
428
|
+
items: list[PmaQueueItem] = []
|
|
429
|
+
with file_lock(self._lane_queue_lock_path(lane_id)):
|
|
430
|
+
try:
|
|
431
|
+
content = path.read_text(encoding="utf-8")
|
|
432
|
+
except OSError:
|
|
433
|
+
return []
|
|
434
|
+
|
|
435
|
+
for line in content.strip().splitlines():
|
|
436
|
+
line = line.strip()
|
|
437
|
+
if not line:
|
|
438
|
+
continue
|
|
439
|
+
try:
|
|
440
|
+
data = json.loads(line)
|
|
441
|
+
items.append(PmaQueueItem.from_dict(data))
|
|
442
|
+
except (json.JSONDecodeError, ValueError):
|
|
443
|
+
continue
|
|
444
|
+
|
|
445
|
+
return items
|
|
446
|
+
|
|
447
|
+
def _find_by_idempotency_key_sync(
|
|
448
|
+
self, lane_id: str, idempotency_key: str
|
|
449
|
+
) -> Optional[PmaQueueItem]:
|
|
450
|
+
items = self._read_items_sync(lane_id)
|
|
451
|
+
for item in items:
|
|
452
|
+
if item.idempotency_key == idempotency_key and item.state in (
|
|
453
|
+
QueueItemState.PENDING,
|
|
454
|
+
QueueItemState.RUNNING,
|
|
455
|
+
):
|
|
456
|
+
return item
|
|
457
|
+
return None
|
|
458
|
+
|
|
459
|
+
def _notify_in_memory_enqueue(self, item: PmaQueueItem) -> None:
|
|
460
|
+
self._ensure_lane_known_ids(item.lane_id).add(item.item_id)
|
|
461
|
+
queue = self._lane_queues.get(item.lane_id)
|
|
462
|
+
event = self._lane_events.get(item.lane_id)
|
|
463
|
+
loop = self._loop
|
|
464
|
+
if loop is None or loop.is_closed() or queue is None or event is None:
|
|
465
|
+
return
|
|
466
|
+
|
|
467
|
+
def _enqueue() -> None:
|
|
468
|
+
queue.put_nowait(item)
|
|
469
|
+
event.set()
|
|
470
|
+
|
|
471
|
+
try:
|
|
472
|
+
loop.call_soon_threadsafe(_enqueue)
|
|
473
|
+
except RuntimeError:
|
|
474
|
+
return
|
|
475
|
+
|
|
327
476
|
async def get_lane_stats(self, lane_id: str) -> dict[str, Any]:
|
|
328
477
|
items = await self.list_items(lane_id)
|
|
329
478
|
by_state: dict[str, int] = {}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
import time
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, Optional
|
|
8
|
+
|
|
9
|
+
from .locks import file_lock
|
|
10
|
+
from .utils import atomic_write
|
|
11
|
+
|
|
12
|
+
PMA_REACTIVE_STATE_FILENAME = "reactive_state.json"
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def default_pma_reactive_state() -> dict[str, Any]:
|
|
18
|
+
return {
|
|
19
|
+
"version": 1,
|
|
20
|
+
"last_enqueued": {},
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class PmaReactiveStore:
|
|
25
|
+
def __init__(self, hub_root: Path) -> None:
|
|
26
|
+
self._path = (
|
|
27
|
+
hub_root / ".codex-autorunner" / "pma" / PMA_REACTIVE_STATE_FILENAME
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
def _lock_path(self) -> Path:
|
|
31
|
+
return self._path.with_suffix(self._path.suffix + ".lock")
|
|
32
|
+
|
|
33
|
+
def load(self) -> dict[str, Any]:
|
|
34
|
+
with file_lock(self._lock_path()):
|
|
35
|
+
state = self._load_unlocked()
|
|
36
|
+
if state is not None:
|
|
37
|
+
return state
|
|
38
|
+
state = default_pma_reactive_state()
|
|
39
|
+
self._save_unlocked(state)
|
|
40
|
+
return state
|
|
41
|
+
|
|
42
|
+
def check_and_update(self, key: str, debounce_seconds: int) -> bool:
|
|
43
|
+
"""
|
|
44
|
+
Return True if enqueue is allowed; otherwise False if debounced.
|
|
45
|
+
Updates the last_enqueued timestamp when allowed.
|
|
46
|
+
"""
|
|
47
|
+
now = time.time()
|
|
48
|
+
with file_lock(self._lock_path()):
|
|
49
|
+
state = self._load_unlocked() or default_pma_reactive_state()
|
|
50
|
+
last_enqueued = state.get("last_enqueued")
|
|
51
|
+
if not isinstance(last_enqueued, dict):
|
|
52
|
+
last_enqueued = {}
|
|
53
|
+
state["last_enqueued"] = last_enqueued
|
|
54
|
+
|
|
55
|
+
last = last_enqueued.get(key)
|
|
56
|
+
if debounce_seconds > 0 and isinstance(last, (int, float)):
|
|
57
|
+
if now - float(last) < debounce_seconds:
|
|
58
|
+
return False
|
|
59
|
+
|
|
60
|
+
last_enqueued[key] = now
|
|
61
|
+
self._save_unlocked(state)
|
|
62
|
+
return True
|
|
63
|
+
|
|
64
|
+
def _load_unlocked(self) -> Optional[dict[str, Any]]:
|
|
65
|
+
if not self._path.exists():
|
|
66
|
+
return None
|
|
67
|
+
try:
|
|
68
|
+
raw = self._path.read_text(encoding="utf-8")
|
|
69
|
+
except OSError as exc:
|
|
70
|
+
logger.warning(
|
|
71
|
+
"Failed to read PMA reactive state at %s: %s", self._path, exc
|
|
72
|
+
)
|
|
73
|
+
return None
|
|
74
|
+
try:
|
|
75
|
+
data = json.loads(raw)
|
|
76
|
+
except json.JSONDecodeError:
|
|
77
|
+
return default_pma_reactive_state()
|
|
78
|
+
if not isinstance(data, dict):
|
|
79
|
+
return default_pma_reactive_state()
|
|
80
|
+
return data
|
|
81
|
+
|
|
82
|
+
def _save_unlocked(self, state: dict[str, Any]) -> None:
|
|
83
|
+
self._path.parent.mkdir(parents=True, exist_ok=True)
|
|
84
|
+
atomic_write(self._path, json.dumps(state, indent=2) + "\n")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
__all__ = [
|
|
88
|
+
"PMA_REACTIVE_STATE_FILENAME",
|
|
89
|
+
"PmaReactiveStore",
|
|
90
|
+
"default_pma_reactive_state",
|
|
91
|
+
]
|
|
@@ -141,6 +141,45 @@ class PmaSafetyChecker:
|
|
|
141
141
|
|
|
142
142
|
return SafetyCheckResult(allowed=True)
|
|
143
143
|
|
|
144
|
+
def check_reactive_turn(self, *, key: str = "reactive") -> SafetyCheckResult:
|
|
145
|
+
if self._is_circuit_breaker_active():
|
|
146
|
+
return SafetyCheckResult(
|
|
147
|
+
allowed=False,
|
|
148
|
+
reason="circuit_breaker_active",
|
|
149
|
+
details={
|
|
150
|
+
"cooldown_remaining_seconds": (
|
|
151
|
+
int(
|
|
152
|
+
self._circuit_breaker_until
|
|
153
|
+
- datetime.now(timezone.utc).timestamp()
|
|
154
|
+
)
|
|
155
|
+
if self._circuit_breaker_until
|
|
156
|
+
else 0
|
|
157
|
+
)
|
|
158
|
+
},
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
if self._config.enable_rate_limit:
|
|
162
|
+
now = datetime.now(timezone.utc).timestamp()
|
|
163
|
+
self._action_timestamps[key] = [
|
|
164
|
+
ts
|
|
165
|
+
for ts in self._action_timestamps[key]
|
|
166
|
+
if now - ts < self._config.rate_limit_window_seconds
|
|
167
|
+
]
|
|
168
|
+
if len(self._action_timestamps[key]) >= self._config.max_actions_per_window:
|
|
169
|
+
return SafetyCheckResult(
|
|
170
|
+
allowed=False,
|
|
171
|
+
reason="rate_limit_exceeded",
|
|
172
|
+
details={
|
|
173
|
+
"key": key,
|
|
174
|
+
"count": len(self._action_timestamps[key]),
|
|
175
|
+
"max_allowed": self._config.max_actions_per_window,
|
|
176
|
+
"window_seconds": self._config.rate_limit_window_seconds,
|
|
177
|
+
},
|
|
178
|
+
)
|
|
179
|
+
self._action_timestamps[key].append(now)
|
|
180
|
+
|
|
181
|
+
return SafetyCheckResult(allowed=True)
|
|
182
|
+
|
|
144
183
|
def record_chat_result(
|
|
145
184
|
self,
|
|
146
185
|
agent: str,
|
|
@@ -159,6 +198,25 @@ class PmaSafetyChecker:
|
|
|
159
198
|
key = f"chat:{agent}"
|
|
160
199
|
self._failure_counts[key] = 0
|
|
161
200
|
|
|
201
|
+
def record_reactive_result(
|
|
202
|
+
self,
|
|
203
|
+
*,
|
|
204
|
+
status: str,
|
|
205
|
+
error: Optional[str] = None,
|
|
206
|
+
key: str = "reactive",
|
|
207
|
+
) -> None:
|
|
208
|
+
if (
|
|
209
|
+
status in ("error", "failed", "interrupted")
|
|
210
|
+
and self._config.enable_circuit_breaker
|
|
211
|
+
):
|
|
212
|
+
self._failure_counts[key] += 1
|
|
213
|
+
if self._failure_counts[key] >= self._config.circuit_breaker_threshold:
|
|
214
|
+
self._activate_circuit_breaker()
|
|
215
|
+
if error:
|
|
216
|
+
logger.warning("PMA reactive circuit breaker error: %s", error)
|
|
217
|
+
else:
|
|
218
|
+
self._failure_counts[key] = 0
|
|
219
|
+
|
|
162
220
|
def record_action(
|
|
163
221
|
self,
|
|
164
222
|
action_type: PmaActionType,
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any, Optional
|
|
7
|
+
|
|
8
|
+
from .locks import file_lock
|
|
9
|
+
from .time_utils import now_iso
|
|
10
|
+
from .utils import atomic_write
|
|
11
|
+
|
|
12
|
+
PMA_ACTIVE_SINK_FILENAME = "active_sink.json"
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class PmaActiveSinkStore:
|
|
18
|
+
def __init__(self, hub_root: Path) -> None:
|
|
19
|
+
self._path = hub_root / ".codex-autorunner" / "pma" / PMA_ACTIVE_SINK_FILENAME
|
|
20
|
+
|
|
21
|
+
def _lock_path(self) -> Path:
|
|
22
|
+
return self._path.with_suffix(self._path.suffix + ".lock")
|
|
23
|
+
|
|
24
|
+
def load(self) -> Optional[dict[str, Any]]:
|
|
25
|
+
with file_lock(self._lock_path()):
|
|
26
|
+
return self._load_unlocked()
|
|
27
|
+
|
|
28
|
+
def set_web(self) -> dict[str, Any]:
|
|
29
|
+
payload = {
|
|
30
|
+
"version": 1,
|
|
31
|
+
"kind": "web",
|
|
32
|
+
"updated_at": now_iso(),
|
|
33
|
+
"last_delivery_turn_id": None,
|
|
34
|
+
}
|
|
35
|
+
with file_lock(self._lock_path()):
|
|
36
|
+
self._save_unlocked(payload)
|
|
37
|
+
return payload
|
|
38
|
+
|
|
39
|
+
def set_telegram(
|
|
40
|
+
self,
|
|
41
|
+
*,
|
|
42
|
+
chat_id: int,
|
|
43
|
+
thread_id: Optional[int],
|
|
44
|
+
topic_key: Optional[str] = None,
|
|
45
|
+
) -> dict[str, Any]:
|
|
46
|
+
payload: dict[str, Any] = {
|
|
47
|
+
"version": 1,
|
|
48
|
+
"kind": "telegram",
|
|
49
|
+
"chat_id": int(chat_id),
|
|
50
|
+
"thread_id": int(thread_id) if thread_id is not None else None,
|
|
51
|
+
"updated_at": now_iso(),
|
|
52
|
+
"last_delivery_turn_id": None,
|
|
53
|
+
}
|
|
54
|
+
if topic_key:
|
|
55
|
+
payload["topic_key"] = topic_key
|
|
56
|
+
with file_lock(self._lock_path()):
|
|
57
|
+
self._save_unlocked(payload)
|
|
58
|
+
return payload
|
|
59
|
+
|
|
60
|
+
def clear(self) -> None:
|
|
61
|
+
with file_lock(self._lock_path()):
|
|
62
|
+
try:
|
|
63
|
+
self._path.unlink()
|
|
64
|
+
except FileNotFoundError:
|
|
65
|
+
return
|
|
66
|
+
except OSError as exc:
|
|
67
|
+
logger.warning("Failed to clear PMA active sink: %s", exc)
|
|
68
|
+
|
|
69
|
+
def mark_delivered(self, turn_id: str) -> bool:
|
|
70
|
+
if not isinstance(turn_id, str) or not turn_id:
|
|
71
|
+
return False
|
|
72
|
+
with file_lock(self._lock_path()):
|
|
73
|
+
payload = self._load_unlocked()
|
|
74
|
+
if not isinstance(payload, dict):
|
|
75
|
+
return False
|
|
76
|
+
if payload.get("last_delivery_turn_id") == turn_id:
|
|
77
|
+
return False
|
|
78
|
+
payload["last_delivery_turn_id"] = turn_id
|
|
79
|
+
payload["updated_at"] = now_iso()
|
|
80
|
+
self._save_unlocked(payload)
|
|
81
|
+
return True
|
|
82
|
+
|
|
83
|
+
def _load_unlocked(self) -> Optional[dict[str, Any]]:
|
|
84
|
+
if not self._path.exists():
|
|
85
|
+
return None
|
|
86
|
+
try:
|
|
87
|
+
raw = self._path.read_text(encoding="utf-8")
|
|
88
|
+
except OSError as exc:
|
|
89
|
+
logger.warning("Failed to read PMA active sink: %s", exc)
|
|
90
|
+
return None
|
|
91
|
+
try:
|
|
92
|
+
payload = json.loads(raw)
|
|
93
|
+
except json.JSONDecodeError:
|
|
94
|
+
return None
|
|
95
|
+
if not isinstance(payload, dict):
|
|
96
|
+
return None
|
|
97
|
+
return payload
|
|
98
|
+
|
|
99
|
+
def _save_unlocked(self, payload: dict[str, Any]) -> None:
|
|
100
|
+
self._path.parent.mkdir(parents=True, exist_ok=True)
|
|
101
|
+
atomic_write(self._path, json.dumps(payload, indent=2) + "\n")
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
__all__ = ["PmaActiveSinkStore", "PMA_ACTIVE_SINK_FILENAME"]
|