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
@@ -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
- app.include_router(build_pma_routes())
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
- latest = _latest_dispatch(
1765
- repo_root, str(record.id), dict(record.input_data or {})
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
- _active_chats: Dict[str, asyncio.Event] = {}
209
- _chat_lock = asyncio.Lock()
210
- _turn_lock = asyncio.Lock()
211
- _current_by_target: Dict[str, Dict[str, Any]] = {}
212
- _current_by_client: Dict[str, Dict[str, Any]] = {}
213
- _last_by_client: Dict[str, Dict[str, Any]] = {}
214
-
215
- async def _get_or_create_interrupt_event(key: str) -> asyncio.Event:
216
- async with _chat_lock:
217
- if key not in _active_chats:
218
- _active_chats[key] = asyncio.Event()
219
- return _active_chats[key]
220
-
221
- async def _clear_interrupt_event(key: str) -> None:
222
- async with _chat_lock:
223
- _active_chats.pop(key, None)
224
-
225
- async def _begin_turn_state(target: _Target, client_turn_id: Optional[str]) -> None:
226
- async with _turn_lock:
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
- _current_by_target[target.state_key] = state
260
+ s.current_by_target[target.state_key] = state
236
261
  if client_turn_id:
237
- _current_by_client[client_turn_id] = state
238
-
239
- async def _update_turn_state(target: _Target, **updates: Any) -> None:
240
- async with _turn_lock:
241
- state = _current_by_target.get(target.state_key)
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
- _current_by_client[cid] = state
251
-
252
- async def _finalize_turn_state(target: _Target, result: Dict[str, Any]) -> None:
253
- async with _turn_lock:
254
- state = _current_by_target.pop(target.state_key, None)
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
- _current_by_client.pop(cid, None)
260
- _last_by_client[cid] = dict(result or {})
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(client_turn_id: Optional[str]) -> Dict[str, Any]:
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
- async with _turn_lock:
266
- return dict(_current_by_client.get(client_turn_id, {}))
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(client_turn_id: Optional[str]) -> Dict[str, Any]:
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
- async with _turn_lock:
272
- return dict(_last_by_client.get(client_turn_id, {}))
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(client_turn_id: Optional[str] = None) -> Dict[str, Any]:
276
- current = await _active_for_client(client_turn_id)
277
- last = await _last_for_client(client_turn_id)
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
- async with _chat_lock:
304
- existing = _active_chats.get(target.state_key)
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
- _active_chats[target.state_key] = asyncio.Event()
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(target.state_key)
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
- async with _chat_lock:
956
- ev = _active_chats.get(resolved.state_key)
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
- async with _chat_lock:
981
- existing = _active_chats.get(target.state_key)
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
- _active_chats[target.state_key] = asyncio.Event()
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
- async with _chat_lock:
1091
- ev = _active_chats.get(target.state_key)
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()