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.
- codex_autorunner/bootstrap.py +26 -5
- codex_autorunner/core/config.py +176 -59
- 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_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/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/helpers.py +30 -2
- codex_autorunner/integrations/telegram/ticket_flow_bridge.py +54 -3
- 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 +154 -36
- codex_autorunner/static/pma.js +96 -35
- codex_autorunner/static/styles.css +415 -4
- 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/file_chat.py +109 -61
- codex_autorunner/surfaces/web/routes/flows.py +125 -67
- codex_autorunner/surfaces/web/routes/pma.py +638 -57
- 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.1.dist-info → codex_autorunner-1.3.0.dist-info}/METADATA +1 -1
- {codex_autorunner-1.2.1.dist-info → codex_autorunner-1.3.0.dist-info}/RECORD +55 -45
- {codex_autorunner-1.2.1.dist-info → codex_autorunner-1.3.0.dist-info}/WHEEL +0 -0
- {codex_autorunner-1.2.1.dist-info → codex_autorunner-1.3.0.dist-info}/entry_points.txt +0 -0
- {codex_autorunner-1.2.1.dist-info → codex_autorunner-1.3.0.dist-info}/licenses/LICENSE +0 -0
- {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,
|
|
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
|
+
]
|