codex-autorunner 1.2.1__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.
Files changed (55) hide show
  1. codex_autorunner/bootstrap.py +26 -5
  2. codex_autorunner/core/config.py +176 -59
  3. codex_autorunner/core/filesystem.py +24 -0
  4. codex_autorunner/core/flows/controller.py +50 -12
  5. codex_autorunner/core/flows/runtime.py +8 -3
  6. codex_autorunner/core/hub.py +293 -16
  7. codex_autorunner/core/lifecycle_events.py +44 -5
  8. codex_autorunner/core/pma_delivery.py +81 -0
  9. codex_autorunner/core/pma_dispatches.py +224 -0
  10. codex_autorunner/core/pma_lane_worker.py +122 -0
  11. codex_autorunner/core/pma_queue.py +167 -18
  12. codex_autorunner/core/pma_reactive.py +91 -0
  13. codex_autorunner/core/pma_safety.py +58 -0
  14. codex_autorunner/core/pma_sink.py +104 -0
  15. codex_autorunner/core/pma_transcripts.py +183 -0
  16. codex_autorunner/core/safe_paths.py +117 -0
  17. codex_autorunner/housekeeping.py +77 -23
  18. codex_autorunner/integrations/agents/codex_backend.py +18 -12
  19. codex_autorunner/integrations/agents/wiring.py +2 -0
  20. codex_autorunner/integrations/app_server/client.py +31 -0
  21. codex_autorunner/integrations/app_server/supervisor.py +3 -0
  22. codex_autorunner/integrations/telegram/constants.py +1 -1
  23. codex_autorunner/integrations/telegram/handlers/commands/execution.py +16 -15
  24. codex_autorunner/integrations/telegram/handlers/commands/files.py +5 -8
  25. codex_autorunner/integrations/telegram/handlers/commands/github.py +10 -6
  26. codex_autorunner/integrations/telegram/handlers/commands/shared.py +9 -8
  27. codex_autorunner/integrations/telegram/handlers/commands/workspace.py +85 -2
  28. codex_autorunner/integrations/telegram/handlers/commands_runtime.py +29 -8
  29. codex_autorunner/integrations/telegram/helpers.py +30 -2
  30. codex_autorunner/integrations/telegram/ticket_flow_bridge.py +54 -3
  31. codex_autorunner/static/docChatCore.js +2 -0
  32. codex_autorunner/static/hub.js +59 -0
  33. codex_autorunner/static/index.html +70 -54
  34. codex_autorunner/static/notificationBell.js +173 -0
  35. codex_autorunner/static/notifications.js +154 -36
  36. codex_autorunner/static/pma.js +96 -35
  37. codex_autorunner/static/styles.css +415 -4
  38. codex_autorunner/static/utils.js +5 -1
  39. codex_autorunner/surfaces/cli/cli.py +206 -129
  40. codex_autorunner/surfaces/cli/template_repos.py +157 -0
  41. codex_autorunner/surfaces/web/app.py +193 -5
  42. codex_autorunner/surfaces/web/routes/file_chat.py +109 -61
  43. codex_autorunner/surfaces/web/routes/flows.py +125 -67
  44. codex_autorunner/surfaces/web/routes/pma.py +638 -57
  45. codex_autorunner/tickets/agent_pool.py +6 -1
  46. codex_autorunner/tickets/outbox.py +27 -14
  47. codex_autorunner/tickets/replies.py +4 -10
  48. codex_autorunner/tickets/runner.py +1 -0
  49. codex_autorunner/workspace/paths.py +8 -3
  50. {codex_autorunner-1.2.1.dist-info → codex_autorunner-1.3.0.dist-info}/METADATA +1 -1
  51. {codex_autorunner-1.2.1.dist-info → codex_autorunner-1.3.0.dist-info}/RECORD +55 -45
  52. {codex_autorunner-1.2.1.dist-info → codex_autorunner-1.3.0.dist-info}/WHEEL +0 -0
  53. {codex_autorunner-1.2.1.dist-info → codex_autorunner-1.3.0.dist-info}/entry_points.txt +0 -0
  54. {codex_autorunner-1.2.1.dist-info → codex_autorunner-1.3.0.dist-info}/licenses/LICENSE +0 -0
  55. {codex_autorunner-1.2.1.dist-info → codex_autorunner-1.3.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,224 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from dataclasses import dataclass
5
+ from datetime import datetime, timezone
6
+ from pathlib import Path
7
+ from typing import Any, Optional
8
+
9
+ import yaml
10
+
11
+ from ..tickets.frontmatter import parse_markdown_frontmatter
12
+ from .time_utils import now_iso
13
+ from .utils import atomic_write
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+ PMA_DISPATCHES_DIRNAME = "dispatches"
18
+ PMA_DISPATCH_ALLOWED_PRIORITIES = {"info", "warn", "action"}
19
+ PMA_DISPATCH_ALLOWED_EXTS = {".md", ".markdown"}
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class PmaDispatch:
24
+ dispatch_id: str
25
+ title: str
26
+ body: str
27
+ priority: str
28
+ links: list[dict[str, str]]
29
+ created_at: str
30
+ resolved_at: Optional[str]
31
+ source_turn_id: Optional[str]
32
+ path: Path
33
+
34
+
35
+ def _dispatches_root(hub_root: Path) -> Path:
36
+ return hub_root / ".codex-autorunner" / "pma" / PMA_DISPATCHES_DIRNAME
37
+
38
+
39
+ def ensure_pma_dispatches_dir(hub_root: Path) -> Path:
40
+ root = _dispatches_root(hub_root)
41
+ root.mkdir(parents=True, exist_ok=True)
42
+ return root
43
+
44
+
45
+ def _normalize_priority(value: Any) -> str:
46
+ if not isinstance(value, str):
47
+ return "info"
48
+ priority = value.strip().lower()
49
+ if priority in PMA_DISPATCH_ALLOWED_PRIORITIES:
50
+ return priority
51
+ return "info"
52
+
53
+
54
+ def _normalize_links(value: Any) -> list[dict[str, str]]:
55
+ if not isinstance(value, list):
56
+ return []
57
+ links: list[dict[str, str]] = []
58
+ for item in value:
59
+ if not isinstance(item, dict):
60
+ continue
61
+ label = item.get("label")
62
+ href = item.get("href")
63
+ if not isinstance(label, str) or not isinstance(href, str):
64
+ continue
65
+ label = label.strip()
66
+ href = href.strip()
67
+ if not label or not href:
68
+ continue
69
+ links.append({"label": label, "href": href})
70
+ return links
71
+
72
+
73
+ def _created_at_from_path(path: Path) -> str:
74
+ try:
75
+ stamp = path.stat().st_mtime
76
+ except OSError:
77
+ return now_iso()
78
+ return datetime.fromtimestamp(stamp, tz=timezone.utc).isoformat()
79
+
80
+
81
+ def _parse_iso(value: Optional[str]) -> Optional[datetime]:
82
+ if not isinstance(value, str) or not value:
83
+ return None
84
+ try:
85
+ return datetime.fromisoformat(value.replace("Z", "+00:00"))
86
+ except Exception:
87
+ return None
88
+
89
+
90
+ def parse_pma_dispatch(path: Path) -> tuple[Optional[PmaDispatch], list[str]]:
91
+ errors: list[str] = []
92
+ try:
93
+ raw = path.read_text(encoding="utf-8")
94
+ except OSError as exc:
95
+ return None, [f"Failed to read dispatch: {exc}"]
96
+
97
+ data, body = parse_markdown_frontmatter(raw)
98
+ if not isinstance(data, dict):
99
+ data = {}
100
+
101
+ raw_title = data.get("title")
102
+ title = raw_title if isinstance(raw_title, str) else ""
103
+ body = body or ""
104
+ priority = _normalize_priority(data.get("priority"))
105
+ links = _normalize_links(data.get("links"))
106
+ created_at = (
107
+ data.get("created_at") if isinstance(data.get("created_at"), str) else None
108
+ )
109
+ resolved_at = (
110
+ data.get("resolved_at") if isinstance(data.get("resolved_at"), str) else None
111
+ )
112
+ source_turn_id = (
113
+ data.get("source_turn_id")
114
+ if isinstance(data.get("source_turn_id"), str)
115
+ else None
116
+ )
117
+
118
+ if not created_at:
119
+ created_at = _created_at_from_path(path)
120
+
121
+ dispatch_id = path.stem
122
+
123
+ dispatch = PmaDispatch(
124
+ dispatch_id=dispatch_id,
125
+ title=title,
126
+ body=body,
127
+ priority=priority,
128
+ links=links,
129
+ created_at=created_at,
130
+ resolved_at=resolved_at,
131
+ source_turn_id=source_turn_id,
132
+ path=path,
133
+ )
134
+ return dispatch, errors
135
+
136
+
137
+ def list_pma_dispatches(
138
+ hub_root: Path,
139
+ *,
140
+ include_resolved: bool = False,
141
+ limit: Optional[int] = None,
142
+ ) -> list[PmaDispatch]:
143
+ root = ensure_pma_dispatches_dir(hub_root)
144
+ dispatches: list[PmaDispatch] = []
145
+ for child in sorted(root.iterdir(), key=lambda p: p.name):
146
+ if child.suffix.lower() not in PMA_DISPATCH_ALLOWED_EXTS:
147
+ continue
148
+ dispatch, errors = parse_pma_dispatch(child)
149
+ if errors or dispatch is None:
150
+ continue
151
+ if not include_resolved and dispatch.resolved_at:
152
+ continue
153
+ dispatches.append(dispatch)
154
+
155
+ dispatches.sort(
156
+ key=lambda d: _parse_iso(d.created_at)
157
+ or datetime.min.replace(tzinfo=timezone.utc),
158
+ reverse=True,
159
+ )
160
+ if isinstance(limit, int) and limit > 0:
161
+ dispatches = dispatches[:limit]
162
+ return dispatches
163
+
164
+
165
+ def list_pma_dispatches_for_turn(hub_root: Path, turn_id: str) -> list[PmaDispatch]:
166
+ if not isinstance(turn_id, str) or not turn_id:
167
+ return []
168
+ dispatches = list_pma_dispatches(hub_root, include_resolved=False)
169
+ return [d for d in dispatches if d.source_turn_id == turn_id]
170
+
171
+
172
+ def find_pma_dispatch_path(hub_root: Path, dispatch_id: str) -> Optional[Path]:
173
+ if not isinstance(dispatch_id, str) or not dispatch_id:
174
+ return None
175
+ safe_id = dispatch_id.strip()
176
+ if not safe_id or "/" in safe_id or "\\" in safe_id:
177
+ return None
178
+ root = ensure_pma_dispatches_dir(hub_root)
179
+ for ext in PMA_DISPATCH_ALLOWED_EXTS:
180
+ candidate = root / f"{safe_id}{ext}"
181
+ if candidate.exists():
182
+ return candidate
183
+ return None
184
+
185
+
186
+ def resolve_pma_dispatch(path: Path) -> tuple[Optional[PmaDispatch], list[str]]:
187
+ dispatch, errors = parse_pma_dispatch(path)
188
+ if errors or dispatch is None:
189
+ return dispatch, errors
190
+
191
+ if dispatch.resolved_at:
192
+ return dispatch, []
193
+
194
+ data = {
195
+ "title": dispatch.title,
196
+ "priority": dispatch.priority,
197
+ "links": dispatch.links,
198
+ "created_at": dispatch.created_at,
199
+ "resolved_at": now_iso(),
200
+ "source_turn_id": dispatch.source_turn_id,
201
+ }
202
+ frontmatter = yaml.safe_dump(data, sort_keys=False).strip()
203
+ content = f"---\n{frontmatter}\n---\n\n{dispatch.body.strip()}\n"
204
+ try:
205
+ atomic_write(path, content)
206
+ except OSError as exc:
207
+ return dispatch, [f"Failed to resolve dispatch: {exc}"]
208
+
209
+ updated, parse_errors = parse_pma_dispatch(path)
210
+ if parse_errors:
211
+ return updated, parse_errors
212
+ return updated, []
213
+
214
+
215
+ __all__ = [
216
+ "PmaDispatch",
217
+ "PMA_DISPATCHES_DIRNAME",
218
+ "ensure_pma_dispatches_dir",
219
+ "parse_pma_dispatch",
220
+ "list_pma_dispatches",
221
+ "list_pma_dispatches_for_turn",
222
+ "find_pma_dispatch_path",
223
+ "resolve_pma_dispatch",
224
+ ]
@@ -0,0 +1,122 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import logging
5
+ from typing import Any, Awaitable, Callable, Optional
6
+
7
+ from .pma_queue import PmaQueue, PmaQueueItem
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+ PmaLaneExecutor = Callable[[PmaQueueItem], Awaitable[dict[str, Any]]]
12
+ PmaLaneResultHook = Callable[
13
+ [PmaQueueItem, dict[str, Any]],
14
+ Optional[Awaitable[None]],
15
+ ]
16
+
17
+
18
+ class PmaLaneWorker:
19
+ """Background worker that drains a PMA lane queue."""
20
+
21
+ def __init__(
22
+ self,
23
+ lane_id: str,
24
+ queue: PmaQueue,
25
+ executor: PmaLaneExecutor,
26
+ *,
27
+ log: Optional[logging.Logger] = None,
28
+ on_result: Optional[PmaLaneResultHook] = None,
29
+ poll_interval_seconds: float = 1.0,
30
+ ) -> None:
31
+ self.lane_id = lane_id
32
+ self._queue = queue
33
+ self._executor = executor
34
+ self._log = log or logger
35
+ self._on_result = on_result
36
+ self._poll_interval_seconds = max(0.1, poll_interval_seconds)
37
+ self._task: Optional[asyncio.Task[None]] = None
38
+ self._cancel_event = asyncio.Event()
39
+ self._lock = asyncio.Lock()
40
+
41
+ @property
42
+ def is_running(self) -> bool:
43
+ return self._task is not None and not self._task.done()
44
+
45
+ async def start(self) -> bool:
46
+ async with self._lock:
47
+ if self.is_running:
48
+ return False
49
+ self._cancel_event = asyncio.Event()
50
+ self._task = asyncio.create_task(self._run())
51
+ self._log.info("PMA lane worker started (lane_id=%s)", self.lane_id)
52
+ return True
53
+
54
+ async def stop(self) -> None:
55
+ async with self._lock:
56
+ task = self._task
57
+ if task is None:
58
+ return
59
+ self._cancel_event.set()
60
+ if task is not None:
61
+ try:
62
+ await task
63
+ finally:
64
+ self._log.info("PMA lane worker stopped (lane_id=%s)", self.lane_id)
65
+
66
+ async def _run(self) -> None:
67
+ await self._queue.replay_pending(self.lane_id)
68
+ while not self._cancel_event.is_set():
69
+ item = await self._queue.dequeue(self.lane_id)
70
+ if item is None:
71
+ await self._queue.wait_for_lane_item(
72
+ self.lane_id,
73
+ self._cancel_event,
74
+ poll_interval_seconds=self._poll_interval_seconds,
75
+ )
76
+ continue
77
+
78
+ if self._cancel_event.is_set():
79
+ await self._queue.fail_item(item, "cancelled by lane stop")
80
+ await self._notify(item, {"status": "error", "detail": "lane stopped"})
81
+ continue
82
+
83
+ self._log.info(
84
+ "PMA lane item started (lane_id=%s item_id=%s)",
85
+ self.lane_id,
86
+ item.item_id,
87
+ )
88
+ try:
89
+ result = await self._executor(item)
90
+ await self._queue.complete_item(item, result)
91
+ self._log.info(
92
+ "PMA lane item completed (lane_id=%s item_id=%s status=%s)",
93
+ self.lane_id,
94
+ item.item_id,
95
+ result.get("status") if isinstance(result, dict) else None,
96
+ )
97
+ await self._notify(item, result)
98
+ except Exception as exc:
99
+ self._log.exception("Failed to process PMA queue item %s", item.item_id)
100
+ error_result = {"status": "error", "detail": str(exc)}
101
+ await self._queue.fail_item(item, str(exc))
102
+ self._log.info(
103
+ "PMA lane item failed (lane_id=%s item_id=%s error=%s)",
104
+ self.lane_id,
105
+ item.item_id,
106
+ str(exc),
107
+ )
108
+ await self._notify(item, error_result)
109
+
110
+ async def _notify(self, item: PmaQueueItem, result: dict[str, Any]) -> None:
111
+ if self._on_result is None:
112
+ return
113
+ try:
114
+ maybe = self._on_result(item, result)
115
+ if asyncio.iscoroutine(maybe):
116
+ await maybe
117
+ except Exception:
118
+ self._log.exception(
119
+ "PMA lane result hook failed (lane_id=%s item_id=%s)",
120
+ self.lane_id,
121
+ item.item_id,
122
+ )
@@ -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, lane_id: str, cancel_event: Optional[asyncio.Event] = None
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
- wait_tasks = [asyncio.create_task(event.wait())]
233
- if cancel_event is not None:
234
- wait_tasks.append(asyncio.create_task(cancel_event.wait()))
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
- done, pending = await asyncio.wait(
237
- wait_tasks, return_when=asyncio.FIRST_COMPLETED
238
- )
239
- for task in pending:
240
- task.cancel()
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
- if cancel_event is not None and cancel_event.is_set():
243
- return False
302
+ if cancel_event is not None and cancel_event.is_set():
303
+ return False
244
304
 
245
- if event.is_set():
246
- event.clear()
247
- return True
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
- try:
257
- content = path.read_text(encoding="utf-8")
258
- except OSError:
259
- return []
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
+ ]