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
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import asyncio
|
|
2
|
+
import json
|
|
2
3
|
import logging
|
|
3
4
|
import os
|
|
4
5
|
import shlex
|
|
@@ -6,8 +7,9 @@ import sys
|
|
|
6
7
|
import threading
|
|
7
8
|
from contextlib import ExitStack, asynccontextmanager
|
|
8
9
|
from dataclasses import dataclass
|
|
10
|
+
from datetime import datetime, timezone
|
|
9
11
|
from pathlib import Path
|
|
10
|
-
from typing import Mapping, Optional
|
|
12
|
+
from typing import Any, Mapping, Optional
|
|
11
13
|
|
|
12
14
|
from fastapi import FastAPI, HTTPException
|
|
13
15
|
from fastapi.responses import HTMLResponse
|
|
@@ -53,6 +55,7 @@ from ...core.usage import (
|
|
|
53
55
|
parse_iso_datetime,
|
|
54
56
|
)
|
|
55
57
|
from ...core.utils import (
|
|
58
|
+
atomic_write,
|
|
56
59
|
build_opencode_supervisor,
|
|
57
60
|
reset_repo_root_context,
|
|
58
61
|
set_repo_root_context,
|
|
@@ -71,6 +74,7 @@ from ...manifest import load_manifest
|
|
|
71
74
|
from ...tickets.files import list_ticket_paths, safe_relpath, ticket_is_done
|
|
72
75
|
from ...tickets.models import Dispatch
|
|
73
76
|
from ...tickets.outbox import parse_dispatch, resolve_outbox_paths
|
|
77
|
+
from ...tickets.replies import resolve_reply_paths
|
|
74
78
|
from ...voice import VoiceConfig, VoiceService
|
|
75
79
|
from .hub_jobs import HubJobManager
|
|
76
80
|
from .middleware import (
|
|
@@ -158,6 +162,86 @@ class ServerOverrides:
|
|
|
158
162
|
auth_token_env: Optional[str] = None
|
|
159
163
|
|
|
160
164
|
|
|
165
|
+
_HUB_INBOX_DISMISSALS_FILENAME = "hub_inbox_dismissals.json"
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _hub_inbox_dismissals_path(repo_root: Path) -> Path:
|
|
169
|
+
return repo_root / ".codex-autorunner" / _HUB_INBOX_DISMISSALS_FILENAME
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _dismissal_key(run_id: str, seq: int) -> str:
|
|
173
|
+
return f"{run_id}:{seq}"
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _load_hub_inbox_dismissals(repo_root: Path) -> dict[str, dict[str, Any]]:
|
|
177
|
+
path = _hub_inbox_dismissals_path(repo_root)
|
|
178
|
+
if not path.exists():
|
|
179
|
+
return {}
|
|
180
|
+
try:
|
|
181
|
+
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
182
|
+
except Exception:
|
|
183
|
+
return {}
|
|
184
|
+
if not isinstance(payload, dict):
|
|
185
|
+
return {}
|
|
186
|
+
items = payload.get("items")
|
|
187
|
+
if not isinstance(items, dict):
|
|
188
|
+
return {}
|
|
189
|
+
out: dict[str, dict[str, Any]] = {}
|
|
190
|
+
for key, value in items.items():
|
|
191
|
+
if not isinstance(key, str) or not isinstance(value, dict):
|
|
192
|
+
continue
|
|
193
|
+
out[key] = dict(value)
|
|
194
|
+
return out
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _save_hub_inbox_dismissals(
|
|
198
|
+
repo_root: Path, items: dict[str, dict[str, Any]]
|
|
199
|
+
) -> None:
|
|
200
|
+
path = _hub_inbox_dismissals_path(repo_root)
|
|
201
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
202
|
+
payload = {"version": 1, "items": items}
|
|
203
|
+
atomic_write(path, json.dumps(payload, indent=2, sort_keys=True) + "\n")
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _resolve_workspace_and_runs(
|
|
207
|
+
record_input: dict[str, Any], repo_root: Path
|
|
208
|
+
) -> tuple[Path, Path]:
|
|
209
|
+
workspace_raw = record_input.get("workspace_root")
|
|
210
|
+
workspace_root = Path(workspace_raw) if workspace_raw else repo_root
|
|
211
|
+
if not workspace_root.is_absolute():
|
|
212
|
+
workspace_root = (repo_root / workspace_root).resolve()
|
|
213
|
+
else:
|
|
214
|
+
workspace_root = workspace_root.resolve()
|
|
215
|
+
runs_raw = record_input.get("runs_dir") or ".codex-autorunner/runs"
|
|
216
|
+
runs_dir = Path(runs_raw)
|
|
217
|
+
if not runs_dir.is_absolute():
|
|
218
|
+
runs_dir = (workspace_root / runs_dir).resolve()
|
|
219
|
+
return workspace_root, runs_dir
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _latest_reply_history_seq(
|
|
223
|
+
repo_root: Path, run_id: str, record_input: dict[str, Any]
|
|
224
|
+
) -> int:
|
|
225
|
+
workspace_root, runs_dir = _resolve_workspace_and_runs(record_input, repo_root)
|
|
226
|
+
reply_paths = resolve_reply_paths(
|
|
227
|
+
workspace_root=workspace_root, runs_dir=runs_dir, run_id=run_id
|
|
228
|
+
)
|
|
229
|
+
history_dir = reply_paths.reply_history_dir
|
|
230
|
+
if not history_dir.exists() or not history_dir.is_dir():
|
|
231
|
+
return 0
|
|
232
|
+
latest = 0
|
|
233
|
+
try:
|
|
234
|
+
for child in history_dir.iterdir():
|
|
235
|
+
if not child.is_dir():
|
|
236
|
+
continue
|
|
237
|
+
name = child.name
|
|
238
|
+
if len(name) == 4 and name.isdigit():
|
|
239
|
+
latest = max(latest, int(name))
|
|
240
|
+
except OSError:
|
|
241
|
+
return latest
|
|
242
|
+
return latest
|
|
243
|
+
|
|
244
|
+
|
|
161
245
|
def _app_server_prune_interval(idle_ttl_seconds: Optional[int]) -> Optional[float]:
|
|
162
246
|
if not idle_ttl_seconds or idle_ttl_seconds <= 0:
|
|
163
247
|
return None
|
|
@@ -294,6 +378,7 @@ def _build_app_server_supervisor(
|
|
|
294
378
|
restart_backoff_initial_seconds=config.client.restart_backoff_initial_seconds,
|
|
295
379
|
restart_backoff_max_seconds=config.client.restart_backoff_max_seconds,
|
|
296
380
|
restart_backoff_jitter_ratio=config.client.restart_backoff_jitter_ratio,
|
|
381
|
+
output_policy=config.output.policy,
|
|
297
382
|
notification_handler=notification_handler,
|
|
298
383
|
approval_handler=approval_handler,
|
|
299
384
|
)
|
|
@@ -1154,7 +1239,14 @@ def create_hub_app(
|
|
|
1154
1239
|
raw_config = getattr(context.config, "raw", {})
|
|
1155
1240
|
pma_config = raw_config.get("pma", {}) if isinstance(raw_config, dict) else {}
|
|
1156
1241
|
if isinstance(pma_config, dict) and pma_config.get("enabled"):
|
|
1157
|
-
|
|
1242
|
+
pma_router = build_pma_routes()
|
|
1243
|
+
app.include_router(pma_router)
|
|
1244
|
+
app.state.pma_lane_worker_start = getattr(
|
|
1245
|
+
pma_router, "_pma_start_lane_worker", None
|
|
1246
|
+
)
|
|
1247
|
+
app.state.pma_lane_worker_stop = getattr(
|
|
1248
|
+
pma_router, "_pma_stop_lane_worker", None
|
|
1249
|
+
)
|
|
1158
1250
|
app.include_router(build_hub_filebox_routes())
|
|
1159
1251
|
mounted_repos: set[str] = set()
|
|
1160
1252
|
mount_errors: dict[str, str] = {}
|
|
@@ -1496,6 +1588,19 @@ def create_hub_app(
|
|
|
1496
1588
|
)
|
|
1497
1589
|
|
|
1498
1590
|
asyncio.create_task(_opencode_prune_loop())
|
|
1591
|
+
pma_cfg = getattr(app.state.config, "pma", None)
|
|
1592
|
+
if pma_cfg is not None and pma_cfg.enabled:
|
|
1593
|
+
starter = getattr(app.state, "pma_lane_worker_start", None)
|
|
1594
|
+
if starter is not None:
|
|
1595
|
+
try:
|
|
1596
|
+
await starter(app, "pma:default")
|
|
1597
|
+
except Exception as exc:
|
|
1598
|
+
safe_log(
|
|
1599
|
+
app.state.logger,
|
|
1600
|
+
logging.WARNING,
|
|
1601
|
+
"PMA lane worker startup failed",
|
|
1602
|
+
exc,
|
|
1603
|
+
)
|
|
1499
1604
|
mount_lock = await _get_mount_lock()
|
|
1500
1605
|
async with mount_lock:
|
|
1501
1606
|
for prefix in list(mount_order):
|
|
@@ -1536,6 +1641,17 @@ def create_hub_app(
|
|
|
1536
1641
|
static_context = getattr(app.state, "static_assets_context", None)
|
|
1537
1642
|
if static_context is not None:
|
|
1538
1643
|
static_context.close()
|
|
1644
|
+
stopper = getattr(app.state, "pma_lane_worker_stop", None)
|
|
1645
|
+
if stopper is not None:
|
|
1646
|
+
try:
|
|
1647
|
+
await stopper(app, "pma:default")
|
|
1648
|
+
except Exception as exc:
|
|
1649
|
+
safe_log(
|
|
1650
|
+
app.state.logger,
|
|
1651
|
+
logging.WARNING,
|
|
1652
|
+
"PMA lane worker shutdown failed",
|
|
1653
|
+
exc,
|
|
1654
|
+
)
|
|
1539
1655
|
|
|
1540
1656
|
app.router.lifespan_context = lifespan
|
|
1541
1657
|
|
|
@@ -1616,6 +1732,18 @@ def create_hub_app(
|
|
|
1616
1732
|
**series,
|
|
1617
1733
|
}
|
|
1618
1734
|
|
|
1735
|
+
hub_dismissal_locks: dict[str, asyncio.Lock] = {}
|
|
1736
|
+
hub_dismissal_locks_guard = asyncio.Lock()
|
|
1737
|
+
|
|
1738
|
+
async def _repo_dismissal_lock(repo_root: Path) -> asyncio.Lock:
|
|
1739
|
+
key = str(repo_root.resolve())
|
|
1740
|
+
async with hub_dismissal_locks_guard:
|
|
1741
|
+
lock = hub_dismissal_locks.get(key)
|
|
1742
|
+
if lock is None:
|
|
1743
|
+
lock = asyncio.Lock()
|
|
1744
|
+
hub_dismissal_locks[key] = lock
|
|
1745
|
+
return lock
|
|
1746
|
+
|
|
1619
1747
|
@app.get("/hub/messages")
|
|
1620
1748
|
async def hub_messages(limit: int = 100):
|
|
1621
1749
|
"""Return paused ticket_flow dispatches across all repos.
|
|
@@ -1746,6 +1874,7 @@ def create_hub_app(
|
|
|
1746
1874
|
for snap in snapshots:
|
|
1747
1875
|
if not (snap.initialized and snap.exists_on_disk):
|
|
1748
1876
|
continue
|
|
1877
|
+
dismissals = _load_hub_inbox_dismissals(snap.path)
|
|
1749
1878
|
repo_root = snap.path
|
|
1750
1879
|
db_path = repo_root / ".codex-autorunner" / "flows.db"
|
|
1751
1880
|
if not db_path.exists():
|
|
@@ -1761,11 +1890,24 @@ def create_hub_app(
|
|
|
1761
1890
|
if not paused:
|
|
1762
1891
|
continue
|
|
1763
1892
|
for record in paused:
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
)
|
|
1893
|
+
record_input = dict(record.input_data or {})
|
|
1894
|
+
latest = _latest_dispatch(repo_root, str(record.id), record_input)
|
|
1767
1895
|
if not latest or not latest.get("dispatch"):
|
|
1768
1896
|
continue
|
|
1897
|
+
seq = int(latest.get("seq") or 0)
|
|
1898
|
+
if seq <= 0:
|
|
1899
|
+
continue
|
|
1900
|
+
if _dismissal_key(str(record.id), seq) in dismissals:
|
|
1901
|
+
continue
|
|
1902
|
+
# Reconcile stale inbox items: if reply history already
|
|
1903
|
+
# reached this dispatch seq, treat it as resolved.
|
|
1904
|
+
if (
|
|
1905
|
+
_latest_reply_history_seq(
|
|
1906
|
+
repo_root, str(record.id), record_input
|
|
1907
|
+
)
|
|
1908
|
+
>= seq
|
|
1909
|
+
):
|
|
1910
|
+
continue
|
|
1769
1911
|
messages.append(
|
|
1770
1912
|
{
|
|
1771
1913
|
"repo_id": snap.id,
|
|
@@ -1788,6 +1930,52 @@ def create_hub_app(
|
|
|
1788
1930
|
items = await asyncio.to_thread(_gather)
|
|
1789
1931
|
return {"items": items}
|
|
1790
1932
|
|
|
1933
|
+
@app.post("/hub/messages/dismiss")
|
|
1934
|
+
async def dismiss_hub_message(payload: dict[str, Any]):
|
|
1935
|
+
repo_id = str(payload.get("repo_id") or "").strip()
|
|
1936
|
+
run_id = str(payload.get("run_id") or "").strip()
|
|
1937
|
+
seq_raw = payload.get("seq")
|
|
1938
|
+
reason_raw = payload.get("reason")
|
|
1939
|
+
reason = str(reason_raw).strip() if isinstance(reason_raw, str) else ""
|
|
1940
|
+
if not repo_id:
|
|
1941
|
+
raise HTTPException(status_code=400, detail="Missing repo_id")
|
|
1942
|
+
if not run_id:
|
|
1943
|
+
raise HTTPException(status_code=400, detail="Missing run_id")
|
|
1944
|
+
try:
|
|
1945
|
+
seq = int(seq_raw)
|
|
1946
|
+
except (TypeError, ValueError):
|
|
1947
|
+
raise HTTPException(status_code=400, detail="Invalid seq") from None
|
|
1948
|
+
if seq <= 0:
|
|
1949
|
+
raise HTTPException(status_code=400, detail="Invalid seq")
|
|
1950
|
+
|
|
1951
|
+
snapshots = await asyncio.to_thread(context.supervisor.list_repos)
|
|
1952
|
+
snapshot = next((s for s in snapshots if s.id == repo_id), None)
|
|
1953
|
+
if snapshot is None or not snapshot.exists_on_disk:
|
|
1954
|
+
raise HTTPException(status_code=404, detail="Repo not found")
|
|
1955
|
+
|
|
1956
|
+
repo_lock = await _repo_dismissal_lock(snapshot.path)
|
|
1957
|
+
async with repo_lock:
|
|
1958
|
+
dismissed_at = datetime.now(timezone.utc).isoformat()
|
|
1959
|
+
items = _load_hub_inbox_dismissals(snapshot.path)
|
|
1960
|
+
items[_dismissal_key(run_id, seq)] = {
|
|
1961
|
+
"repo_id": repo_id,
|
|
1962
|
+
"run_id": run_id,
|
|
1963
|
+
"seq": seq,
|
|
1964
|
+
"reason": reason or None,
|
|
1965
|
+
"dismissed_at": dismissed_at,
|
|
1966
|
+
}
|
|
1967
|
+
_save_hub_inbox_dismissals(snapshot.path, items)
|
|
1968
|
+
return {
|
|
1969
|
+
"status": "ok",
|
|
1970
|
+
"dismissed": {
|
|
1971
|
+
"repo_id": repo_id,
|
|
1972
|
+
"run_id": run_id,
|
|
1973
|
+
"seq": seq,
|
|
1974
|
+
"reason": reason or None,
|
|
1975
|
+
"dismissed_at": dismissed_at,
|
|
1976
|
+
},
|
|
1977
|
+
}
|
|
1978
|
+
|
|
1791
1979
|
@app.get("/hub/repos")
|
|
1792
1980
|
async def list_repos():
|
|
1793
1981
|
safe_log(app.state.logger, logging.INFO, "Hub list_repos")
|
|
@@ -51,6 +51,24 @@ class _Target:
|
|
|
51
51
|
state_key: str
|
|
52
52
|
|
|
53
53
|
|
|
54
|
+
@dataclass
|
|
55
|
+
class FileChatRoutesState:
|
|
56
|
+
active_chats: Dict[str, asyncio.Event]
|
|
57
|
+
chat_lock: asyncio.Lock
|
|
58
|
+
turn_lock: asyncio.Lock
|
|
59
|
+
current_by_target: Dict[str, Dict[str, Any]]
|
|
60
|
+
current_by_client: Dict[str, Dict[str, Any]]
|
|
61
|
+
last_by_client: Dict[str, Dict[str, Any]]
|
|
62
|
+
|
|
63
|
+
def __init__(self) -> None:
|
|
64
|
+
self.active_chats = {}
|
|
65
|
+
self.chat_lock = asyncio.Lock()
|
|
66
|
+
self.turn_lock = asyncio.Lock()
|
|
67
|
+
self.current_by_target = {}
|
|
68
|
+
self.current_by_client = {}
|
|
69
|
+
self.last_by_client = {}
|
|
70
|
+
|
|
71
|
+
|
|
54
72
|
def _state_path(repo_root: Path) -> Path:
|
|
55
73
|
return draft_utils.state_path(repo_root)
|
|
56
74
|
|
|
@@ -205,25 +223,32 @@ def _build_patch(rel_path: str, before: str, after: str) -> str:
|
|
|
205
223
|
|
|
206
224
|
def build_file_chat_routes() -> APIRouter:
|
|
207
225
|
router = APIRouter(prefix="/api", tags=["file-chat"])
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
async def _get_or_create_interrupt_event(
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
async with
|
|
226
|
+
state = FileChatRoutesState()
|
|
227
|
+
|
|
228
|
+
def _get_state(request: Request) -> FileChatRoutesState:
|
|
229
|
+
if not hasattr(request.app.state, "file_chat_routes_state"):
|
|
230
|
+
request.app.state.file_chat_routes_state = state
|
|
231
|
+
return request.app.state.file_chat_routes_state
|
|
232
|
+
|
|
233
|
+
async def _get_or_create_interrupt_event(
|
|
234
|
+
request: Request, key: str
|
|
235
|
+
) -> asyncio.Event:
|
|
236
|
+
s = _get_state(request)
|
|
237
|
+
async with s.chat_lock:
|
|
238
|
+
if key not in s.active_chats:
|
|
239
|
+
s.active_chats[key] = asyncio.Event()
|
|
240
|
+
return s.active_chats[key]
|
|
241
|
+
|
|
242
|
+
async def _clear_interrupt_event(request: Request, key: str) -> None:
|
|
243
|
+
s = _get_state(request)
|
|
244
|
+
async with s.chat_lock:
|
|
245
|
+
s.active_chats.pop(key, None)
|
|
246
|
+
|
|
247
|
+
async def _begin_turn_state(
|
|
248
|
+
request: Request, target: _Target, client_turn_id: Optional[str]
|
|
249
|
+
) -> None:
|
|
250
|
+
s = _get_state(request)
|
|
251
|
+
async with s.turn_lock:
|
|
227
252
|
state: Dict[str, Any] = {
|
|
228
253
|
"client_turn_id": client_turn_id or "",
|
|
229
254
|
"target": target.target,
|
|
@@ -232,13 +257,16 @@ def build_file_chat_routes() -> APIRouter:
|
|
|
232
257
|
"thread_id": None,
|
|
233
258
|
"turn_id": None,
|
|
234
259
|
}
|
|
235
|
-
|
|
260
|
+
s.current_by_target[target.state_key] = state
|
|
236
261
|
if client_turn_id:
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
async def _update_turn_state(
|
|
240
|
-
|
|
241
|
-
|
|
262
|
+
s.current_by_client[client_turn_id] = state
|
|
263
|
+
|
|
264
|
+
async def _update_turn_state(
|
|
265
|
+
request: Request, target: _Target, **updates: Any
|
|
266
|
+
) -> None:
|
|
267
|
+
s = _get_state(request)
|
|
268
|
+
async with s.turn_lock:
|
|
269
|
+
state = s.current_by_target.get(target.state_key)
|
|
242
270
|
if not state:
|
|
243
271
|
return
|
|
244
272
|
for key, value in updates.items():
|
|
@@ -247,34 +275,45 @@ def build_file_chat_routes() -> APIRouter:
|
|
|
247
275
|
state[key] = value
|
|
248
276
|
cid = state.get("client_turn_id") or ""
|
|
249
277
|
if cid:
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
async def _finalize_turn_state(
|
|
253
|
-
|
|
254
|
-
|
|
278
|
+
s.current_by_client[cid] = state
|
|
279
|
+
|
|
280
|
+
async def _finalize_turn_state(
|
|
281
|
+
request: Request, target: _Target, result: Dict[str, Any]
|
|
282
|
+
) -> None:
|
|
283
|
+
s = _get_state(request)
|
|
284
|
+
async with s.turn_lock:
|
|
285
|
+
state = s.current_by_target.pop(target.state_key, None)
|
|
255
286
|
cid = ""
|
|
256
287
|
if state:
|
|
257
288
|
cid = state.get("client_turn_id", "") or ""
|
|
258
289
|
if cid:
|
|
259
|
-
|
|
260
|
-
|
|
290
|
+
s.current_by_client.pop(cid, None)
|
|
291
|
+
s.last_by_client[cid] = dict(result or {})
|
|
261
292
|
|
|
262
|
-
async def _active_for_client(
|
|
293
|
+
async def _active_for_client(
|
|
294
|
+
request: Request, client_turn_id: Optional[str]
|
|
295
|
+
) -> Dict[str, Any]:
|
|
263
296
|
if not client_turn_id:
|
|
264
297
|
return {}
|
|
265
|
-
|
|
266
|
-
|
|
298
|
+
s = _get_state(request)
|
|
299
|
+
async with s.turn_lock:
|
|
300
|
+
return dict(s.current_by_client.get(client_turn_id, {}))
|
|
267
301
|
|
|
268
|
-
async def _last_for_client(
|
|
302
|
+
async def _last_for_client(
|
|
303
|
+
request: Request, client_turn_id: Optional[str]
|
|
304
|
+
) -> Dict[str, Any]:
|
|
269
305
|
if not client_turn_id:
|
|
270
306
|
return {}
|
|
271
|
-
|
|
272
|
-
|
|
307
|
+
s = _get_state(request)
|
|
308
|
+
async with s.turn_lock:
|
|
309
|
+
return dict(s.last_by_client.get(client_turn_id, {}))
|
|
273
310
|
|
|
274
311
|
@router.get("/file-chat/active")
|
|
275
|
-
async def file_chat_active(
|
|
276
|
-
|
|
277
|
-
|
|
312
|
+
async def file_chat_active(
|
|
313
|
+
request: Request, client_turn_id: Optional[str] = None
|
|
314
|
+
) -> Dict[str, Any]:
|
|
315
|
+
current = await _active_for_client(request, client_turn_id)
|
|
316
|
+
last = await _last_for_client(request, client_turn_id)
|
|
278
317
|
return {"active": bool(current), "current": current, "last_result": last}
|
|
279
318
|
|
|
280
319
|
@router.post("/file-chat")
|
|
@@ -300,13 +339,14 @@ def build_file_chat_routes() -> APIRouter:
|
|
|
300
339
|
target.path.parent.mkdir(parents=True, exist_ok=True)
|
|
301
340
|
|
|
302
341
|
# Concurrency guard per target
|
|
303
|
-
|
|
304
|
-
|
|
342
|
+
s = _get_state(request)
|
|
343
|
+
async with s.chat_lock:
|
|
344
|
+
existing = s.active_chats.get(target.state_key)
|
|
305
345
|
if existing is not None and not existing.is_set():
|
|
306
346
|
raise HTTPException(status_code=409, detail="File chat already running")
|
|
307
|
-
|
|
347
|
+
s.active_chats[target.state_key] = asyncio.Event()
|
|
308
348
|
|
|
309
|
-
await _begin_turn_state(target, client_turn_id)
|
|
349
|
+
await _begin_turn_state(request, target, client_turn_id)
|
|
310
350
|
|
|
311
351
|
if stream:
|
|
312
352
|
return StreamingResponse(
|
|
@@ -329,6 +369,7 @@ def build_file_chat_routes() -> APIRouter:
|
|
|
329
369
|
|
|
330
370
|
async def _on_meta(agent_id: str, thread_id: str, turn_id: str) -> None:
|
|
331
371
|
await _update_turn_state(
|
|
372
|
+
request,
|
|
332
373
|
target,
|
|
333
374
|
agent=agent_id,
|
|
334
375
|
thread_id=thread_id,
|
|
@@ -348,6 +389,7 @@ def build_file_chat_routes() -> APIRouter:
|
|
|
348
389
|
)
|
|
349
390
|
except Exception as exc:
|
|
350
391
|
await _finalize_turn_state(
|
|
392
|
+
request,
|
|
351
393
|
target,
|
|
352
394
|
{
|
|
353
395
|
"status": "error",
|
|
@@ -358,10 +400,10 @@ def build_file_chat_routes() -> APIRouter:
|
|
|
358
400
|
raise
|
|
359
401
|
result = dict(result or {})
|
|
360
402
|
result["client_turn_id"] = client_turn_id or ""
|
|
361
|
-
await _finalize_turn_state(target, result)
|
|
403
|
+
await _finalize_turn_state(request, target, result)
|
|
362
404
|
return result
|
|
363
405
|
finally:
|
|
364
|
-
await _clear_interrupt_event(target.state_key)
|
|
406
|
+
await _clear_interrupt_event(request, target.state_key)
|
|
365
407
|
|
|
366
408
|
async def _stream_file_chat(
|
|
367
409
|
request: Request,
|
|
@@ -379,6 +421,7 @@ def build_file_chat_routes() -> APIRouter:
|
|
|
379
421
|
|
|
380
422
|
async def _on_meta(agent_id: str, thread_id: str, turn_id: str) -> None:
|
|
381
423
|
await _update_turn_state(
|
|
424
|
+
request,
|
|
382
425
|
target,
|
|
383
426
|
agent=agent_id,
|
|
384
427
|
thread_id=thread_id,
|
|
@@ -410,7 +453,7 @@ def build_file_chat_routes() -> APIRouter:
|
|
|
410
453
|
}
|
|
411
454
|
result = dict(result or {})
|
|
412
455
|
result["client_turn_id"] = client_turn_id or ""
|
|
413
|
-
await _finalize_turn_state(target, result)
|
|
456
|
+
await _finalize_turn_state(request, target, result)
|
|
414
457
|
|
|
415
458
|
asyncio.create_task(_finalize())
|
|
416
459
|
|
|
@@ -443,7 +486,7 @@ def build_file_chat_routes() -> APIRouter:
|
|
|
443
486
|
logger.exception("file chat stream failed")
|
|
444
487
|
yield format_sse("error", {"detail": "File chat failed"})
|
|
445
488
|
finally:
|
|
446
|
-
await _clear_interrupt_event(target.state_key)
|
|
489
|
+
await _clear_interrupt_event(request, target.state_key)
|
|
447
490
|
|
|
448
491
|
async def _execute_file_chat(
|
|
449
492
|
request: Request,
|
|
@@ -479,7 +522,9 @@ def build_file_chat_routes() -> APIRouter:
|
|
|
479
522
|
|
|
480
523
|
prompt = _build_file_chat_prompt(target=target, message=message, before=before)
|
|
481
524
|
|
|
482
|
-
interrupt_event = await _get_or_create_interrupt_event(
|
|
525
|
+
interrupt_event = await _get_or_create_interrupt_event(
|
|
526
|
+
request, target.state_key
|
|
527
|
+
)
|
|
483
528
|
if interrupt_event.is_set():
|
|
484
529
|
return {"status": "interrupted", "detail": "File chat interrupted"}
|
|
485
530
|
|
|
@@ -489,7 +534,7 @@ def build_file_chat_routes() -> APIRouter:
|
|
|
489
534
|
agent_id = "codex"
|
|
490
535
|
|
|
491
536
|
thread_key = f"file_chat.{target.state_key}"
|
|
492
|
-
await _update_turn_state(target, status="running", agent=agent_id)
|
|
537
|
+
await _update_turn_state(request, target, status="running", agent=agent_id)
|
|
493
538
|
|
|
494
539
|
if agent_id == "opencode":
|
|
495
540
|
if opencode is None:
|
|
@@ -952,8 +997,9 @@ def build_file_chat_routes() -> APIRouter:
|
|
|
952
997
|
body = await request.json()
|
|
953
998
|
repo_root = _resolve_repo_root(request)
|
|
954
999
|
resolved = _parse_target(repo_root, str(body.get("target") or ""))
|
|
955
|
-
|
|
956
|
-
|
|
1000
|
+
s = _get_state(request)
|
|
1001
|
+
async with s.chat_lock:
|
|
1002
|
+
ev = s.active_chats.get(resolved.state_key)
|
|
957
1003
|
if ev is None:
|
|
958
1004
|
return {"status": "ok", "detail": "No active chat to interrupt"}
|
|
959
1005
|
ev.set()
|
|
@@ -977,14 +1023,15 @@ def build_file_chat_routes() -> APIRouter:
|
|
|
977
1023
|
repo_root = _resolve_repo_root(request)
|
|
978
1024
|
target = _parse_target(repo_root, f"ticket:{int(index)}")
|
|
979
1025
|
|
|
980
|
-
|
|
981
|
-
|
|
1026
|
+
s = _get_state(request)
|
|
1027
|
+
async with s.chat_lock:
|
|
1028
|
+
existing = s.active_chats.get(target.state_key)
|
|
982
1029
|
if existing is not None and not existing.is_set():
|
|
983
1030
|
raise HTTPException(
|
|
984
1031
|
status_code=409, detail="Ticket chat already running"
|
|
985
1032
|
)
|
|
986
|
-
|
|
987
|
-
await _begin_turn_state(target, client_turn_id)
|
|
1033
|
+
s.active_chats[target.state_key] = asyncio.Event()
|
|
1034
|
+
await _begin_turn_state(request, target, client_turn_id)
|
|
988
1035
|
|
|
989
1036
|
if stream:
|
|
990
1037
|
return StreamingResponse(
|
|
@@ -1014,10 +1061,10 @@ def build_file_chat_routes() -> APIRouter:
|
|
|
1014
1061
|
)
|
|
1015
1062
|
result = dict(result or {})
|
|
1016
1063
|
result["client_turn_id"] = client_turn_id or ""
|
|
1017
|
-
await _finalize_turn_state(target, result)
|
|
1064
|
+
await _finalize_turn_state(request, target, result)
|
|
1018
1065
|
return result
|
|
1019
1066
|
finally:
|
|
1020
|
-
await _clear_interrupt_event(target.state_key)
|
|
1067
|
+
await _clear_interrupt_event(request, target.state_key)
|
|
1021
1068
|
|
|
1022
1069
|
@router.get("/tickets/{index}/chat/pending")
|
|
1023
1070
|
async def pending_ticket_patch(index: int, request: Request):
|
|
@@ -1087,8 +1134,9 @@ def build_file_chat_routes() -> APIRouter:
|
|
|
1087
1134
|
async def interrupt_ticket_chat(index: int, request: Request):
|
|
1088
1135
|
repo_root = _resolve_repo_root(request)
|
|
1089
1136
|
target = _parse_target(repo_root, f"ticket:{int(index)}")
|
|
1090
|
-
|
|
1091
|
-
|
|
1137
|
+
s = _get_state(request)
|
|
1138
|
+
async with s.chat_lock:
|
|
1139
|
+
ev = s.active_chats.get(target.state_key)
|
|
1092
1140
|
if ev is None:
|
|
1093
1141
|
return {"status": "ok", "detail": "No active chat to interrupt"}
|
|
1094
1142
|
ev.set()
|