codex-autorunner 0.1.1__py3-none-any.whl → 1.0.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 (226) hide show
  1. codex_autorunner/__main__.py +4 -0
  2. codex_autorunner/agents/__init__.py +20 -0
  3. codex_autorunner/agents/base.py +2 -2
  4. codex_autorunner/agents/codex/harness.py +1 -1
  5. codex_autorunner/agents/opencode/__init__.py +4 -0
  6. codex_autorunner/agents/opencode/agent_config.py +104 -0
  7. codex_autorunner/agents/opencode/client.py +305 -28
  8. codex_autorunner/agents/opencode/harness.py +71 -20
  9. codex_autorunner/agents/opencode/logging.py +225 -0
  10. codex_autorunner/agents/opencode/run_prompt.py +261 -0
  11. codex_autorunner/agents/opencode/runtime.py +1202 -132
  12. codex_autorunner/agents/opencode/supervisor.py +194 -68
  13. codex_autorunner/agents/registry.py +258 -0
  14. codex_autorunner/agents/types.py +2 -2
  15. codex_autorunner/api.py +25 -0
  16. codex_autorunner/bootstrap.py +19 -40
  17. codex_autorunner/cli.py +234 -151
  18. codex_autorunner/core/about_car.py +44 -32
  19. codex_autorunner/core/adapter_utils.py +21 -0
  20. codex_autorunner/core/app_server_events.py +15 -6
  21. codex_autorunner/core/app_server_logging.py +55 -15
  22. codex_autorunner/core/app_server_prompts.py +28 -259
  23. codex_autorunner/core/app_server_threads.py +15 -26
  24. codex_autorunner/core/circuit_breaker.py +183 -0
  25. codex_autorunner/core/codex_runner.py +6 -0
  26. codex_autorunner/core/config.py +555 -133
  27. codex_autorunner/core/docs.py +54 -9
  28. codex_autorunner/core/drafts.py +82 -0
  29. codex_autorunner/core/engine.py +828 -274
  30. codex_autorunner/core/exceptions.py +60 -0
  31. codex_autorunner/core/flows/__init__.py +25 -0
  32. codex_autorunner/core/flows/controller.py +178 -0
  33. codex_autorunner/core/flows/definition.py +82 -0
  34. codex_autorunner/core/flows/models.py +75 -0
  35. codex_autorunner/core/flows/runtime.py +351 -0
  36. codex_autorunner/core/flows/store.py +485 -0
  37. codex_autorunner/core/flows/transition.py +133 -0
  38. codex_autorunner/core/flows/worker_process.py +242 -0
  39. codex_autorunner/core/hub.py +21 -13
  40. codex_autorunner/core/locks.py +118 -1
  41. codex_autorunner/core/logging_utils.py +9 -6
  42. codex_autorunner/core/path_utils.py +123 -0
  43. codex_autorunner/core/prompt.py +15 -7
  44. codex_autorunner/core/redaction.py +29 -0
  45. codex_autorunner/core/retry.py +61 -0
  46. codex_autorunner/core/review.py +888 -0
  47. codex_autorunner/core/review_context.py +161 -0
  48. codex_autorunner/core/run_index.py +223 -0
  49. codex_autorunner/core/runner_controller.py +44 -1
  50. codex_autorunner/core/runner_process.py +30 -1
  51. codex_autorunner/core/sqlite_utils.py +32 -0
  52. codex_autorunner/core/state.py +273 -44
  53. codex_autorunner/core/static_assets.py +55 -0
  54. codex_autorunner/core/supervisor_utils.py +67 -0
  55. codex_autorunner/core/text_delta_coalescer.py +43 -0
  56. codex_autorunner/core/update.py +20 -11
  57. codex_autorunner/core/update_runner.py +2 -0
  58. codex_autorunner/core/usage.py +107 -75
  59. codex_autorunner/core/utils.py +167 -3
  60. codex_autorunner/discovery.py +3 -3
  61. codex_autorunner/flows/ticket_flow/__init__.py +3 -0
  62. codex_autorunner/flows/ticket_flow/definition.py +91 -0
  63. codex_autorunner/integrations/agents/__init__.py +27 -0
  64. codex_autorunner/integrations/agents/agent_backend.py +142 -0
  65. codex_autorunner/integrations/agents/codex_backend.py +307 -0
  66. codex_autorunner/integrations/agents/opencode_backend.py +325 -0
  67. codex_autorunner/integrations/agents/run_event.py +71 -0
  68. codex_autorunner/integrations/app_server/client.py +708 -153
  69. codex_autorunner/integrations/app_server/supervisor.py +59 -33
  70. codex_autorunner/integrations/telegram/adapter.py +474 -185
  71. codex_autorunner/integrations/telegram/api_schemas.py +120 -0
  72. codex_autorunner/integrations/telegram/config.py +239 -1
  73. codex_autorunner/integrations/telegram/constants.py +19 -1
  74. codex_autorunner/integrations/telegram/dispatch.py +44 -8
  75. codex_autorunner/integrations/telegram/doctor.py +47 -0
  76. codex_autorunner/integrations/telegram/handlers/approvals.py +12 -10
  77. codex_autorunner/integrations/telegram/handlers/callbacks.py +15 -1
  78. codex_autorunner/integrations/telegram/handlers/commands/__init__.py +29 -0
  79. codex_autorunner/integrations/telegram/handlers/commands/approvals.py +173 -0
  80. codex_autorunner/integrations/telegram/handlers/commands/execution.py +2595 -0
  81. codex_autorunner/integrations/telegram/handlers/commands/files.py +1408 -0
  82. codex_autorunner/integrations/telegram/handlers/commands/flows.py +227 -0
  83. codex_autorunner/integrations/telegram/handlers/commands/formatting.py +81 -0
  84. codex_autorunner/integrations/telegram/handlers/commands/github.py +1688 -0
  85. codex_autorunner/integrations/telegram/handlers/commands/shared.py +190 -0
  86. codex_autorunner/integrations/telegram/handlers/commands/voice.py +112 -0
  87. codex_autorunner/integrations/telegram/handlers/commands/workspace.py +2043 -0
  88. codex_autorunner/integrations/telegram/handlers/commands_runtime.py +954 -5689
  89. codex_autorunner/integrations/telegram/handlers/{commands.py → commands_spec.py} +11 -4
  90. codex_autorunner/integrations/telegram/handlers/messages.py +374 -49
  91. codex_autorunner/integrations/telegram/handlers/questions.py +389 -0
  92. codex_autorunner/integrations/telegram/handlers/selections.py +6 -4
  93. codex_autorunner/integrations/telegram/handlers/utils.py +171 -0
  94. codex_autorunner/integrations/telegram/helpers.py +90 -18
  95. codex_autorunner/integrations/telegram/notifications.py +126 -35
  96. codex_autorunner/integrations/telegram/outbox.py +214 -43
  97. codex_autorunner/integrations/telegram/progress_stream.py +42 -19
  98. codex_autorunner/integrations/telegram/runtime.py +24 -13
  99. codex_autorunner/integrations/telegram/service.py +500 -129
  100. codex_autorunner/integrations/telegram/state.py +1278 -330
  101. codex_autorunner/integrations/telegram/ticket_flow_bridge.py +322 -0
  102. codex_autorunner/integrations/telegram/transport.py +37 -4
  103. codex_autorunner/integrations/telegram/trigger_mode.py +53 -0
  104. codex_autorunner/integrations/telegram/types.py +22 -2
  105. codex_autorunner/integrations/telegram/voice.py +14 -15
  106. codex_autorunner/manifest.py +2 -0
  107. codex_autorunner/plugin_api.py +22 -0
  108. codex_autorunner/routes/__init__.py +25 -14
  109. codex_autorunner/routes/agents.py +18 -78
  110. codex_autorunner/routes/analytics.py +239 -0
  111. codex_autorunner/routes/base.py +142 -113
  112. codex_autorunner/routes/file_chat.py +836 -0
  113. codex_autorunner/routes/flows.py +980 -0
  114. codex_autorunner/routes/messages.py +459 -0
  115. codex_autorunner/routes/repos.py +17 -0
  116. codex_autorunner/routes/review.py +148 -0
  117. codex_autorunner/routes/sessions.py +16 -8
  118. codex_autorunner/routes/settings.py +22 -0
  119. codex_autorunner/routes/shared.py +33 -3
  120. codex_autorunner/routes/system.py +22 -1
  121. codex_autorunner/routes/usage.py +87 -0
  122. codex_autorunner/routes/voice.py +5 -13
  123. codex_autorunner/routes/workspace.py +271 -0
  124. codex_autorunner/server.py +2 -1
  125. codex_autorunner/static/agentControls.js +9 -1
  126. codex_autorunner/static/agentEvents.js +248 -0
  127. codex_autorunner/static/app.js +27 -22
  128. codex_autorunner/static/autoRefresh.js +29 -1
  129. codex_autorunner/static/bootstrap.js +1 -0
  130. codex_autorunner/static/bus.js +1 -0
  131. codex_autorunner/static/cache.js +1 -0
  132. codex_autorunner/static/constants.js +20 -4
  133. codex_autorunner/static/dashboard.js +162 -150
  134. codex_autorunner/static/diffRenderer.js +37 -0
  135. codex_autorunner/static/docChatCore.js +324 -0
  136. codex_autorunner/static/docChatStorage.js +65 -0
  137. codex_autorunner/static/docChatVoice.js +65 -0
  138. codex_autorunner/static/docEditor.js +133 -0
  139. codex_autorunner/static/env.js +1 -0
  140. codex_autorunner/static/eventSummarizer.js +166 -0
  141. codex_autorunner/static/fileChat.js +182 -0
  142. codex_autorunner/static/health.js +155 -0
  143. codex_autorunner/static/hub.js +67 -126
  144. codex_autorunner/static/index.html +788 -807
  145. codex_autorunner/static/liveUpdates.js +59 -0
  146. codex_autorunner/static/loader.js +1 -0
  147. codex_autorunner/static/messages.js +470 -0
  148. codex_autorunner/static/mobileCompact.js +2 -1
  149. codex_autorunner/static/settings.js +24 -205
  150. codex_autorunner/static/styles.css +7577 -3758
  151. codex_autorunner/static/tabs.js +28 -5
  152. codex_autorunner/static/terminal.js +14 -0
  153. codex_autorunner/static/terminalManager.js +53 -59
  154. codex_autorunner/static/ticketChatActions.js +333 -0
  155. codex_autorunner/static/ticketChatEvents.js +16 -0
  156. codex_autorunner/static/ticketChatStorage.js +16 -0
  157. codex_autorunner/static/ticketChatStream.js +264 -0
  158. codex_autorunner/static/ticketEditor.js +750 -0
  159. codex_autorunner/static/ticketVoice.js +9 -0
  160. codex_autorunner/static/tickets.js +1315 -0
  161. codex_autorunner/static/utils.js +32 -3
  162. codex_autorunner/static/voice.js +21 -7
  163. codex_autorunner/static/workspace.js +672 -0
  164. codex_autorunner/static/workspaceApi.js +53 -0
  165. codex_autorunner/static/workspaceFileBrowser.js +504 -0
  166. codex_autorunner/tickets/__init__.py +20 -0
  167. codex_autorunner/tickets/agent_pool.py +377 -0
  168. codex_autorunner/tickets/files.py +85 -0
  169. codex_autorunner/tickets/frontmatter.py +55 -0
  170. codex_autorunner/tickets/lint.py +102 -0
  171. codex_autorunner/tickets/models.py +95 -0
  172. codex_autorunner/tickets/outbox.py +232 -0
  173. codex_autorunner/tickets/replies.py +179 -0
  174. codex_autorunner/tickets/runner.py +823 -0
  175. codex_autorunner/tickets/spec_ingest.py +77 -0
  176. codex_autorunner/voice/capture.py +7 -7
  177. codex_autorunner/voice/service.py +51 -9
  178. codex_autorunner/web/app.py +419 -199
  179. codex_autorunner/web/hub_jobs.py +13 -2
  180. codex_autorunner/web/middleware.py +47 -13
  181. codex_autorunner/web/pty_session.py +26 -13
  182. codex_autorunner/web/schemas.py +114 -109
  183. codex_autorunner/web/static_assets.py +55 -42
  184. codex_autorunner/web/static_refresh.py +86 -0
  185. codex_autorunner/workspace/__init__.py +40 -0
  186. codex_autorunner/workspace/paths.py +319 -0
  187. {codex_autorunner-0.1.1.dist-info → codex_autorunner-1.0.0.dist-info}/METADATA +20 -21
  188. codex_autorunner-1.0.0.dist-info/RECORD +251 -0
  189. {codex_autorunner-0.1.1.dist-info → codex_autorunner-1.0.0.dist-info}/WHEEL +1 -1
  190. codex_autorunner/core/doc_chat.py +0 -1415
  191. codex_autorunner/core/snapshot.py +0 -580
  192. codex_autorunner/integrations/github/chatops.py +0 -268
  193. codex_autorunner/integrations/github/pr_flow.py +0 -1314
  194. codex_autorunner/routes/docs.py +0 -381
  195. codex_autorunner/routes/github.py +0 -327
  196. codex_autorunner/routes/runs.py +0 -118
  197. codex_autorunner/spec_ingest.py +0 -788
  198. codex_autorunner/static/docChatActions.js +0 -279
  199. codex_autorunner/static/docChatEvents.js +0 -300
  200. codex_autorunner/static/docChatRender.js +0 -205
  201. codex_autorunner/static/docChatStream.js +0 -361
  202. codex_autorunner/static/docs.js +0 -20
  203. codex_autorunner/static/docsClipboard.js +0 -69
  204. codex_autorunner/static/docsCrud.js +0 -257
  205. codex_autorunner/static/docsDocUpdates.js +0 -62
  206. codex_autorunner/static/docsDrafts.js +0 -16
  207. codex_autorunner/static/docsElements.js +0 -69
  208. codex_autorunner/static/docsInit.js +0 -274
  209. codex_autorunner/static/docsParse.js +0 -160
  210. codex_autorunner/static/docsSnapshot.js +0 -87
  211. codex_autorunner/static/docsSpecIngest.js +0 -263
  212. codex_autorunner/static/docsState.js +0 -127
  213. codex_autorunner/static/docsThreadRegistry.js +0 -44
  214. codex_autorunner/static/docsUi.js +0 -153
  215. codex_autorunner/static/docsVoice.js +0 -56
  216. codex_autorunner/static/github.js +0 -442
  217. codex_autorunner/static/logs.js +0 -640
  218. codex_autorunner/static/runs.js +0 -409
  219. codex_autorunner/static/snapshot.js +0 -124
  220. codex_autorunner/static/state.js +0 -86
  221. codex_autorunner/static/todoPreview.js +0 -27
  222. codex_autorunner/workspace.py +0 -16
  223. codex_autorunner-0.1.1.dist-info/RECORD +0 -191
  224. {codex_autorunner-0.1.1.dist-info → codex_autorunner-1.0.0.dist-info}/entry_points.txt +0 -0
  225. {codex_autorunner-0.1.1.dist-info → codex_autorunner-1.0.0.dist-info}/licenses/LICENSE +0 -0
  226. {codex_autorunner-0.1.1.dist-info → codex_autorunner-1.0.0.dist-info}/top_level.txt +0 -0
@@ -1,1415 +0,0 @@
1
- import asyncio
2
- import contextlib
3
- import difflib
4
- import hashlib
5
- import json
6
- import re
7
- import threading
8
- import time
9
- import uuid
10
- from contextlib import asynccontextmanager
11
- from dataclasses import dataclass, field
12
- from pathlib import Path
13
- from typing import Any, AsyncIterator, Awaitable, Callable, Dict, Optional, Tuple
14
-
15
- from ..agents.opencode.runtime import (
16
- PERMISSION_ALLOW,
17
- build_turn_id,
18
- collect_opencode_output,
19
- extract_session_id,
20
- opencode_missing_env,
21
- split_model_id,
22
- )
23
- from ..agents.opencode.supervisor import OpenCodeSupervisor
24
- from ..integrations.app_server.client import CodexAppServerError
25
- from ..integrations.app_server.supervisor import WorkspaceAppServerSupervisor
26
- from .app_server_events import AppServerEventBuffer
27
- from .app_server_logging import AppServerEventFormatter
28
- from .app_server_prompts import build_doc_chat_prompt
29
- from .app_server_threads import (
30
- DOC_CHAT_KEY,
31
- DOC_CHAT_OPENCODE_KEY,
32
- DOC_CHAT_PREFIX,
33
- AppServerThreadRegistry,
34
- default_app_server_threads_path,
35
- )
36
- from .config import RepoConfig
37
- from .docs import validate_todo_markdown
38
- from .engine import Engine, timestamp
39
- from .locks import FileLock, FileLockBusy, FileLockError
40
- from .patch_utils import (
41
- PatchError,
42
- ensure_patch_targets_allowed,
43
- normalize_patch_text,
44
- preview_patch,
45
- )
46
- from .state import load_state, now_iso
47
- from .utils import atomic_write
48
-
49
- ALLOWED_DOC_KINDS = ("todo", "progress", "opinions", "spec", "summary")
50
- DOC_CHAT_TIMEOUT_SECONDS = 180
51
- DOC_CHAT_INTERRUPT_GRACE_SECONDS = 10
52
- DOC_CHAT_STATE_NAME = "doc_chat_state.json"
53
- DOC_CHAT_STATE_VERSION = 1
54
-
55
-
56
- @dataclass
57
- class DocChatRequest:
58
- message: str
59
- stream: bool = False
60
- targets: Optional[tuple[str, ...]] = None
61
- context_doc: Optional[str] = None
62
- agent: Optional[str] = None
63
- model: Optional[str] = None
64
- reasoning: Optional[str] = None
65
-
66
-
67
- @dataclass
68
- class ActiveDocChatTurn:
69
- thread_id: str
70
- turn_id: str
71
- client: Any
72
- interrupted: bool = False
73
- interrupt_sent: bool = False
74
- interrupt_event: asyncio.Event = field(default_factory=asyncio.Event)
75
-
76
-
77
- class DocChatError(Exception):
78
- """Base error for doc chat failures."""
79
-
80
-
81
- class DocChatValidationError(DocChatError):
82
- """Raised when a request payload is invalid."""
83
-
84
-
85
- class DocChatBusyError(DocChatError):
86
- """Raised when a doc chat is already running for the target doc."""
87
-
88
-
89
- class DocChatConflictError(DocChatError):
90
- """Raised when a doc draft conflicts with newer edits."""
91
-
92
-
93
- def _normalize_kind(kind: str) -> str:
94
- key = (kind or "").lower()
95
- if key not in ALLOWED_DOC_KINDS:
96
- raise DocChatValidationError("invalid doc kind")
97
- return key
98
-
99
-
100
- def _normalize_message(message: str) -> str:
101
- msg = (message or "").strip()
102
- if not msg:
103
- raise DocChatValidationError("message is required")
104
- return msg
105
-
106
-
107
- @dataclass
108
- class DocChatDraftState:
109
- content: str
110
- patch: str
111
- agent_message: str
112
- created_at: str
113
- base_hash: str
114
-
115
- def to_dict(self) -> dict[str, str]:
116
- return {
117
- "content": self.content,
118
- "patch": self.patch,
119
- "agent_message": self.agent_message,
120
- "created_at": self.created_at,
121
- "base_hash": self.base_hash,
122
- }
123
-
124
- @classmethod
125
- def from_dict(cls, payload: dict) -> Optional["DocChatDraftState"]:
126
- if not isinstance(payload, dict):
127
- return None
128
- content = payload.get("content")
129
- patch = payload.get("patch")
130
- agent_message = payload.get("agent_message")
131
- created_at = payload.get("created_at")
132
- base_hash = payload.get("base_hash")
133
- if not isinstance(content, str) or not isinstance(patch, str):
134
- return None
135
- if not isinstance(agent_message, str):
136
- agent_message = ""
137
- if not isinstance(created_at, str):
138
- created_at = ""
139
- if not isinstance(base_hash, str):
140
- base_hash = ""
141
- return cls(
142
- content=content,
143
- patch=patch,
144
- agent_message=agent_message,
145
- created_at=created_at,
146
- base_hash=base_hash,
147
- )
148
-
149
-
150
- def format_sse(event: str, data: object) -> str:
151
- payload = data if isinstance(data, str) else json.dumps(data)
152
- lines = payload.splitlines() or [""]
153
- parts = [f"event: {event}"]
154
- for line in lines:
155
- parts.append(f"data: {line}")
156
- return "\n".join(parts) + "\n\n"
157
-
158
-
159
- class DocChatService:
160
- def __init__(
161
- self,
162
- engine: Engine,
163
- *,
164
- app_server_supervisor: Optional[WorkspaceAppServerSupervisor] = None,
165
- app_server_threads: Optional[AppServerThreadRegistry] = None,
166
- app_server_events: Optional[AppServerEventBuffer] = None,
167
- opencode_supervisor: Optional[OpenCodeSupervisor] = None,
168
- ):
169
- self.engine = engine
170
- self._recent_summary_cache: Optional[str] = None
171
- self._drafts_path = (
172
- self.engine.repo_root / ".codex-autorunner" / DOC_CHAT_STATE_NAME
173
- )
174
- self._lock_root = self.engine.repo_root / ".codex-autorunner" / "locks"
175
- self._app_server_supervisor = app_server_supervisor
176
- self._app_server_threads = app_server_threads or AppServerThreadRegistry(
177
- default_app_server_threads_path(self.engine.repo_root)
178
- )
179
- self._app_server_events = app_server_events
180
- self._opencode_supervisor = opencode_supervisor
181
- self._lock: Optional[asyncio.Lock] = None
182
- self._thread_lock = threading.Lock()
183
- self._active_turn: Optional[ActiveDocChatTurn] = None
184
- self._active_turn_lock = threading.Lock()
185
- self._pending_interrupt = False
186
-
187
- def _repo_config(self) -> RepoConfig:
188
- if not isinstance(self.engine.config, RepoConfig):
189
- raise DocChatError("Doc chat requires a repo workspace config")
190
- return self.engine.config
191
-
192
- def _get_active_turn(self) -> Optional[ActiveDocChatTurn]:
193
- with self._active_turn_lock:
194
- return self._active_turn
195
-
196
- def _clear_active_turn(self, turn_id: str) -> None:
197
- with self._active_turn_lock:
198
- if self._active_turn and self._active_turn.turn_id == turn_id:
199
- self._active_turn = None
200
-
201
- def _register_active_turn(
202
- self, client: Any, turn_id: str, thread_id: str
203
- ) -> ActiveDocChatTurn:
204
- interrupt_event = asyncio.Event()
205
- active = ActiveDocChatTurn(
206
- thread_id=thread_id,
207
- turn_id=turn_id,
208
- client=client,
209
- interrupted=False,
210
- interrupt_sent=False,
211
- interrupt_event=interrupt_event,
212
- )
213
- with self._active_turn_lock:
214
- self._active_turn = active
215
- if self._pending_interrupt:
216
- self._pending_interrupt = False
217
- active.interrupted = True
218
- interrupt_event.set()
219
- return active
220
-
221
- async def _interrupt_turn(self, active: ActiveDocChatTurn) -> None:
222
- if active.interrupt_sent:
223
- return
224
- active.interrupt_sent = True
225
- chat_id = self._chat_id()
226
- try:
227
- if not hasattr(active.client, "turn_interrupt"):
228
- return
229
- await asyncio.wait_for(
230
- active.client.turn_interrupt(
231
- active.turn_id, thread_id=active.thread_id
232
- ),
233
- timeout=DOC_CHAT_INTERRUPT_GRACE_SECONDS,
234
- )
235
- except asyncio.TimeoutError:
236
- self._log(
237
- chat_id,
238
- 'result=error detail="interrupt_timeout" backend=app_server',
239
- )
240
- except CodexAppServerError as exc:
241
- self._log(
242
- chat_id,
243
- "result=error "
244
- f'detail="interrupt_failed:{self._compact_message(str(exc))}" '
245
- "backend=app_server",
246
- )
247
-
248
- async def _abort_opencode(self, active: ActiveDocChatTurn, thread_id: str) -> None:
249
- if active.interrupt_sent:
250
- return
251
- active.interrupt_sent = True
252
- chat_id = self._chat_id()
253
- try:
254
- if not hasattr(active.client, "abort"):
255
- return
256
- await asyncio.wait_for(
257
- active.client.abort(thread_id),
258
- timeout=DOC_CHAT_INTERRUPT_GRACE_SECONDS,
259
- )
260
- except asyncio.TimeoutError:
261
- self._log(
262
- chat_id,
263
- 'result=error detail="abort_timeout" backend=opencode',
264
- )
265
- except Exception as exc:
266
- self._log(
267
- chat_id,
268
- "result=error "
269
- f'detail="abort_failed:{self._compact_message(str(exc))}" '
270
- "backend=opencode",
271
- )
272
-
273
- async def interrupt(self, _kind: Optional[str] = None) -> Dict[str, str]:
274
- active = self._get_active_turn()
275
- if active is None:
276
- pending = self._local_busy()
277
- with self._active_turn_lock:
278
- self._pending_interrupt = pending
279
- return {
280
- "status": "interrupted",
281
- "detail": "No active turn",
282
- }
283
- active.interrupted = True
284
- active.interrupt_event.set()
285
- await self._interrupt_turn(active)
286
- return {"status": "interrupted", "detail": "Doc chat interrupted"}
287
-
288
- def parse_request(
289
- self, payload: Optional[dict], *, kind: Optional[str] = None
290
- ) -> DocChatRequest:
291
- if payload is None or not isinstance(payload, dict):
292
- raise DocChatValidationError("invalid payload")
293
- message = _normalize_message(str(payload.get("message", "")))
294
- stream = bool(payload.get("stream", False))
295
- raw_targets = payload.get("targets") or payload.get("target")
296
- targets: Optional[tuple[str, ...]] = None
297
- raw_context = (
298
- payload.get("context_doc")
299
- or payload.get("contextDoc")
300
- or payload.get("viewing")
301
- )
302
- raw_agent = payload.get("agent")
303
- raw_model = payload.get("model")
304
- raw_reasoning = payload.get("reasoning")
305
- context_doc: Optional[str] = None
306
- if isinstance(raw_context, str) and raw_context.strip():
307
- try:
308
- context_doc = _normalize_kind(raw_context)
309
- except DocChatValidationError:
310
- raise
311
- if isinstance(raw_targets, (list, tuple)):
312
- normalized = []
313
- for entry in raw_targets:
314
- try:
315
- normalized.append(_normalize_kind(str(entry)))
316
- except DocChatValidationError:
317
- raise
318
- if normalized:
319
- targets = tuple(dict.fromkeys(normalized))
320
- else:
321
- raise DocChatValidationError("target is required")
322
- elif isinstance(raw_targets, str) and raw_targets.strip():
323
- try:
324
- targets = (_normalize_kind(raw_targets),)
325
- except DocChatValidationError:
326
- raise
327
- if kind:
328
- normalized_kind = _normalize_kind(kind)
329
- if targets is None:
330
- targets = (normalized_kind,)
331
- else:
332
- if any(target != normalized_kind for target in targets):
333
- raise DocChatValidationError("target must match doc kind")
334
- targets = (normalized_kind,)
335
- if context_doc is None:
336
- context_doc = normalized_kind
337
- elif context_doc != normalized_kind:
338
- raise DocChatValidationError("context_doc must match doc kind")
339
- return DocChatRequest(
340
- message=message,
341
- stream=stream,
342
- targets=targets,
343
- context_doc=context_doc,
344
- agent=str(raw_agent).strip() if isinstance(raw_agent, str) else None,
345
- model=str(raw_model).strip() if isinstance(raw_model, str) else None,
346
- reasoning=(
347
- str(raw_reasoning).strip() if isinstance(raw_reasoning, str) else None
348
- ),
349
- )
350
-
351
- def repo_blocked_reason(self) -> Optional[str]:
352
- return self.engine.repo_busy_reason()
353
-
354
- def doc_busy(self, _kind: Optional[str] = None) -> bool:
355
- lock = self._ensure_lock()
356
- if lock.locked():
357
- return True
358
- file_lock = FileLock(self._doc_lock_path())
359
- try:
360
- file_lock.acquire(blocking=False)
361
- except FileLockBusy:
362
- return True
363
- except FileLockError:
364
- return True
365
- finally:
366
- file_lock.release()
367
- return False
368
-
369
- def _local_busy(self) -> bool:
370
- if self._thread_lock.locked():
371
- return True
372
- lock = self._lock
373
- return bool(lock and lock.locked())
374
-
375
- def _ensure_lock(self) -> asyncio.Lock:
376
- if self._lock is None:
377
- try:
378
- self._lock = asyncio.Lock()
379
- except RuntimeError:
380
- asyncio.set_event_loop(asyncio.new_event_loop())
381
- self._lock = asyncio.Lock()
382
- return self._lock
383
-
384
- @asynccontextmanager
385
- async def doc_lock(self, _kind: Optional[str] = None):
386
- if not self._thread_lock.acquire(blocking=False):
387
- raise DocChatBusyError("Doc chat already running")
388
- lock = self._ensure_lock()
389
- if lock.locked():
390
- self._thread_lock.release()
391
- raise DocChatBusyError("Doc chat already running")
392
- await lock.acquire()
393
- file_lock = FileLock(self._doc_lock_path())
394
- try:
395
- try:
396
- file_lock.acquire(blocking=False)
397
- except FileLockBusy as exc:
398
- raise DocChatBusyError("Doc chat already running") from exc
399
- except FileLockError as exc:
400
- raise DocChatError(str(exc)) from exc
401
- yield
402
- finally:
403
- file_lock.release()
404
- lock.release()
405
- self._thread_lock.release()
406
- with self._active_turn_lock:
407
- self._pending_interrupt = False
408
-
409
- def _doc_lock_path(self) -> Path:
410
- return self._lock_root / "doc_chat.lock"
411
-
412
- def _chat_id(self) -> str:
413
- return uuid.uuid4().hex[:8]
414
-
415
- def _log(self, chat_id: str, message: str) -> None:
416
- line = f"[{timestamp()}] doc-chat id={chat_id} {message}\n"
417
- self.engine.log_path.parent.mkdir(parents=True, exist_ok=True)
418
- with self.engine.log_path.open("a", encoding="utf-8") as f:
419
- f.write(line)
420
-
421
- def _log_event_line(self, chat_id: str, line: str) -> None:
422
- self._log(chat_id, f"stdout: {line}" if line else "stdout: ")
423
-
424
- def _log_output(
425
- self, chat_id: str, text: Optional[str], label: str = "stdout"
426
- ) -> None:
427
- if text is None:
428
- return
429
- lines = text.splitlines()
430
- if not lines:
431
- self._log(chat_id, f"{label}: ")
432
- return
433
- for line in lines:
434
- self._log(chat_id, f"{label}: {line}")
435
-
436
- def _doc_pointer(self, targets: Optional[tuple[str, ...]]) -> str:
437
- config = self._repo_config()
438
- if not targets:
439
- return "auto"
440
- paths = []
441
- for kind in targets:
442
- path = config.doc_path(kind)
443
- try:
444
- paths.append(str(path.relative_to(self.engine.repo_root)))
445
- except ValueError:
446
- paths.append(str(path))
447
- return ",".join(paths) if paths else "auto"
448
-
449
- @staticmethod
450
- def _compact_message(message: str, limit: int = 240) -> str:
451
- compact = " ".join((message or "").split()).replace('"', "'")
452
- if len(compact) > limit:
453
- return compact[: limit - 3] + "..."
454
- return compact
455
-
456
- @staticmethod
457
- async def _maybe_await(value: Any) -> Any:
458
- if asyncio.iscoroutine(value):
459
- return await value
460
- return value
461
-
462
- async def _handle_turn_start(
463
- self,
464
- thread_id: str,
465
- turn_id: str,
466
- *,
467
- log_context: Optional[dict[str, Any]] = None,
468
- on_turn_start: Optional[Callable[[str, str], Awaitable[None]]] = None,
469
- ) -> None:
470
- if self._app_server_events is not None:
471
- try:
472
- await self._app_server_events.register_turn(
473
- thread_id, turn_id, context=log_context
474
- )
475
- except Exception:
476
- pass
477
- if on_turn_start is None:
478
- return
479
- try:
480
- await self._maybe_await(on_turn_start(thread_id, turn_id))
481
- except Exception:
482
- pass
483
-
484
- def _recent_run_summary(self) -> Optional[str]:
485
- if self._recent_summary_cache is not None:
486
- return self._recent_summary_cache
487
- state = load_state(self.engine.state_path)
488
- if not state.last_run_id:
489
- return None
490
- summary = self.engine.extract_prev_output(state.last_run_id)
491
- self._recent_summary_cache = summary
492
- return summary
493
-
494
- def _doc_bases(
495
- self, drafts: dict[str, DocChatDraftState]
496
- ) -> dict[str, dict[str, str]]:
497
- config = self._repo_config()
498
- bases: dict[str, dict[str, str]] = {}
499
- for kind in ALLOWED_DOC_KINDS:
500
- draft = drafts.get(kind)
501
- if draft is not None:
502
- bases[kind] = {"content": draft.content, "source": "draft"}
503
- else:
504
- bases[kind] = {
505
- "content": config.doc_path(kind).read_text(encoding="utf-8"),
506
- "source": "disk",
507
- }
508
- return bases
509
-
510
- def _prepare_docs_for_run(
511
- self, drafts: dict[str, DocChatDraftState]
512
- ) -> tuple[dict[str, dict[str, str]], dict[str, str], dict[str, str]]:
513
- config = self._repo_config()
514
- docs = self._doc_bases(drafts)
515
- backups: dict[str, str] = {}
516
- working: dict[str, str] = {}
517
- for kind in ALLOWED_DOC_KINDS:
518
- path = config.doc_path(kind)
519
- current = path.read_text(encoding="utf-8")
520
- backups[kind] = current
521
- desired = docs.get(kind, {}).get("content", current)
522
- working[kind] = desired
523
- if desired != current:
524
- atomic_write(path, desired)
525
- return docs, backups, working
526
-
527
- def _restore_docs(self, backups: dict[str, str]) -> None:
528
- config = self._repo_config()
529
- for kind, content in backups.items():
530
- path = config.doc_path(kind)
531
- try:
532
- current = path.read_text(encoding="utf-8")
533
- except OSError:
534
- current = ""
535
- if current != content:
536
- atomic_write(path, content)
537
-
538
- def _build_app_server_prompt(
539
- self, request: DocChatRequest, docs: dict[str, dict[str, str]]
540
- ) -> str:
541
- return build_doc_chat_prompt(
542
- self.engine.config,
543
- message=request.message,
544
- recent_summary=self._recent_run_summary(),
545
- docs=docs,
546
- context_doc=request.context_doc,
547
- )
548
-
549
- def _ensure_app_server(self) -> WorkspaceAppServerSupervisor:
550
- if self._app_server_supervisor is None:
551
- raise DocChatError("App-server backend is not configured")
552
- return self._app_server_supervisor
553
-
554
- def _ensure_opencode(self) -> OpenCodeSupervisor:
555
- if self._opencode_supervisor is None:
556
- raise DocChatError("OpenCode backend is not configured")
557
- return self._opencode_supervisor
558
-
559
- def _thread_key(self, agent: Optional[str]) -> str:
560
- if (agent or "").strip().lower() == "opencode":
561
- return DOC_CHAT_OPENCODE_KEY
562
- return DOC_CHAT_KEY
563
-
564
- def _legacy_thread_id(self, agent: Optional[str]) -> Optional[str]:
565
- if (agent or "").strip().lower() == "opencode":
566
- return None
567
- try:
568
- threads = self._app_server_threads.load()
569
- except Exception:
570
- return None
571
- for key, value in threads.items():
572
- if not key.startswith(DOC_CHAT_PREFIX):
573
- continue
574
- if isinstance(value, str) and value:
575
- return value
576
- return None
577
-
578
- def _apply_patch_to_drafts(
579
- self,
580
- *,
581
- patch_text_raw: str,
582
- drafts: dict[str, DocChatDraftState],
583
- docs: dict[str, dict[str, str]],
584
- agent_message: str,
585
- allowed_kinds: Optional[tuple[str, ...]] = None,
586
- ) -> tuple[dict[str, DocChatDraftState], list[str], dict[str, dict]]:
587
- config = self._repo_config()
588
- targets = self._doc_targets()
589
- if allowed_kinds:
590
- targets = {
591
- kind: path for kind, path in targets.items() if kind in allowed_kinds
592
- }
593
- allowed_paths = list(targets.values())
594
- patch_text, raw_targets = normalize_patch_text(patch_text_raw)
595
- normalized_targets = ensure_patch_targets_allowed(raw_targets, allowed_paths)
596
- path_to_kind = {path: kind for kind, path in targets.items()}
597
- base_content = {path: docs[kind]["content"] for kind, path in targets.items()}
598
- preview = preview_patch(
599
- self.engine.repo_root,
600
- patch_text,
601
- raw_targets,
602
- base_content=base_content,
603
- )
604
- updated = dict(drafts)
605
- updated_kinds: list[str] = []
606
- payloads: dict[str, dict] = {}
607
- created_at = now_iso()
608
- for target in normalized_targets:
609
- kind = path_to_kind.get(target)
610
- if kind is None:
611
- continue
612
- before = base_content.get(target, "")
613
- after = preview.get(target, before)
614
- patch_for_doc = self._build_patch(target, before, after)
615
- if not patch_for_doc.strip():
616
- continue
617
- base_hash = self._hash_content(before)
618
- existing = drafts.get(kind)
619
- if existing and docs.get(kind, {}).get("source") == "draft":
620
- if existing.base_hash:
621
- base_hash = existing.base_hash
622
- else:
623
- try:
624
- base_hash = self._hash_content(
625
- config.doc_path(kind).read_text(encoding="utf-8")
626
- )
627
- except OSError:
628
- base_hash = self._hash_content(before)
629
- updated[kind] = DocChatDraftState(
630
- content=after,
631
- patch=patch_for_doc,
632
- agent_message=agent_message,
633
- created_at=created_at,
634
- base_hash=base_hash,
635
- )
636
- updated_kinds.append(kind)
637
- payloads[kind] = updated[kind].to_dict()
638
- return updated, updated_kinds, payloads
639
-
640
- @staticmethod
641
- def _parse_agent_message(output: str) -> str:
642
- text = (output or "").strip()
643
- if not text:
644
- return "Updated docs via doc chat."
645
- for line in text.splitlines():
646
- if line.lower().startswith("agent:"):
647
- return line[len("agent:") :].strip() or "Updated docs via doc chat."
648
- return text.splitlines()[0].strip()
649
-
650
- @staticmethod
651
- def _strip_code_fences(text: str) -> str:
652
- lines = text.strip().splitlines()
653
- if (
654
- len(lines) >= 2
655
- and lines[0].startswith("```")
656
- and lines[-1].startswith("```")
657
- ):
658
- return "\n".join(lines[1:-1]).strip()
659
- return text.strip()
660
-
661
- @classmethod
662
- def _looks_like_patch(cls, text: str) -> bool:
663
- if not text:
664
- return False
665
- markers = (
666
- "*** Begin Patch",
667
- "--- ",
668
- "diff --git ",
669
- "Index: ",
670
- )
671
- return any(marker in text for marker in markers)
672
-
673
- @classmethod
674
- def _extract_fenced_patch(cls, output: str) -> Optional[Tuple[str, str]]:
675
- for match in re.finditer(
676
- r"```[^\n]*\n(.*?)```", output, flags=re.DOTALL | re.IGNORECASE
677
- ):
678
- candidate = (match.group(1) or "").strip()
679
- if not cls._looks_like_patch(candidate):
680
- continue
681
- before = output[: match.start()].strip()
682
- after = output[match.end() :].strip()
683
- message_text = "\n".join(part for part in [before, after] if part)
684
- return message_text, candidate
685
- return None
686
-
687
- @staticmethod
688
- def _strip_trailing_fence(text: str) -> str:
689
- lines = text.strip().splitlines()
690
- if lines and lines[-1].startswith("```"):
691
- lines = lines[:-1]
692
- return "\n".join(lines).strip()
693
-
694
- @classmethod
695
- def _split_patch_from_output(cls, output: str) -> Tuple[str, str]:
696
- if not output:
697
- return "", ""
698
- match = re.search(
699
- r"<(PATCH|APPLY_PATCH)>(.*?)</\1>",
700
- output,
701
- flags=re.IGNORECASE | re.DOTALL,
702
- )
703
- if match:
704
- patch_text = cls._strip_code_fences(match.group(2))
705
- before = output[: match.start()].strip()
706
- after = output[match.end() :].strip()
707
- message_text = "\n".join(part for part in [before, after] if part)
708
- return message_text, patch_text
709
- fenced = cls._extract_fenced_patch(output)
710
- if fenced:
711
- message_text, patch_text = fenced
712
- return message_text, patch_text
713
- lines = output.splitlines()
714
- start_idx = None
715
- for idx, line in enumerate(lines):
716
- if (
717
- line.startswith("--- ")
718
- or line.startswith("*** Begin Patch")
719
- or line.startswith("diff --git ")
720
- or line.startswith("Index: ")
721
- ):
722
- start_idx = idx
723
- break
724
- if start_idx is None:
725
- return output.strip(), ""
726
- message_text = "\n".join(lines[:start_idx]).strip()
727
- patch_text = "\n".join(lines[start_idx:]).strip()
728
- patch_text = cls._strip_trailing_fence(cls._strip_code_fences(patch_text))
729
- return message_text, patch_text
730
-
731
- def _load_drafts(self) -> dict[str, DocChatDraftState]:
732
- if not self._drafts_path.exists():
733
- return {}
734
- try:
735
- payload = json.loads(self._drafts_path.read_text(encoding="utf-8"))
736
- except Exception:
737
- return {}
738
- if not isinstance(payload, dict):
739
- return {}
740
- raw_drafts = payload.get("drafts")
741
- if not isinstance(raw_drafts, dict):
742
- return {}
743
- drafts: dict[str, DocChatDraftState] = {}
744
- for kind, entry in raw_drafts.items():
745
- if kind not in ALLOWED_DOC_KINDS:
746
- continue
747
- draft = DocChatDraftState.from_dict(entry)
748
- if draft is not None:
749
- drafts[kind] = draft
750
- return drafts
751
-
752
- def _save_drafts(self, drafts: dict[str, DocChatDraftState]) -> None:
753
- payload = {
754
- "version": DOC_CHAT_STATE_VERSION,
755
- "drafts": {kind: draft.to_dict() for kind, draft in drafts.items()},
756
- }
757
- atomic_write(self._drafts_path, json.dumps(payload, indent=2) + "\n")
758
-
759
- def _doc_targets(self) -> dict[str, str]:
760
- config = self._repo_config()
761
- targets = {}
762
- for kind in ALLOWED_DOC_KINDS:
763
- targets[kind] = str(
764
- config.doc_path(kind).relative_to(self.engine.repo_root)
765
- )
766
- return targets
767
-
768
- def _hash_content(self, content: str) -> str:
769
- return hashlib.sha256(content.encode("utf-8")).hexdigest()
770
-
771
- def _build_patch(self, rel_path: str, before: str, after: str) -> str:
772
- diff = difflib.unified_diff(
773
- before.splitlines(),
774
- after.splitlines(),
775
- fromfile=f"a/{rel_path}",
776
- tofile=f"b/{rel_path}",
777
- lineterm="",
778
- )
779
- return "\n".join(diff)
780
-
781
- def apply_saved_patch(self, kind: str) -> str:
782
- key = _normalize_kind(kind)
783
- drafts = self._load_drafts()
784
- draft = drafts.get(key)
785
- if draft is None:
786
- raise DocChatError("No pending patch")
787
- config = self._repo_config()
788
- target_path = config.doc_path(key)
789
- current = target_path.read_text(encoding="utf-8")
790
- if draft.base_hash and self._hash_content(current) != draft.base_hash:
791
- raise DocChatConflictError(
792
- "Doc changed since draft created; reload before applying."
793
- )
794
- atomic_write(target_path, draft.content)
795
- drafts.pop(key, None)
796
- self._save_drafts(drafts)
797
- return target_path.read_text(encoding="utf-8")
798
-
799
- def discard_patch(self, kind: str) -> str:
800
- key = _normalize_kind(kind)
801
- drafts = self._load_drafts()
802
- drafts.pop(key, None)
803
- self._save_drafts(drafts)
804
- config = self._repo_config()
805
- return config.doc_path(key).read_text(encoding="utf-8")
806
-
807
- def pending_patch(self, kind: str) -> Optional[dict]:
808
- key = _normalize_kind(kind)
809
- drafts = self._load_drafts()
810
- draft = drafts.get(key)
811
- if draft is None:
812
- return None
813
- return {
814
- "status": "ok",
815
- "kind": key,
816
- "patch": draft.patch,
817
- "agent_message": draft.agent_message or "Draft ready",
818
- "content": draft.content,
819
- "created_at": draft.created_at,
820
- "base_hash": draft.base_hash,
821
- }
822
-
823
- async def _execute_app_server(
824
- self,
825
- request: DocChatRequest,
826
- *,
827
- on_turn_start: Optional[Callable[[str, str], Awaitable[None]]] = None,
828
- ) -> dict:
829
- chat_id = self._chat_id()
830
- started_at = time.time()
831
- doc_pointer = self._doc_pointer(request.targets)
832
- message_for_log = self._compact_message(request.message)
833
- turn_id: Optional[str] = None
834
- thread_id: Optional[str] = None
835
- targets_label = ",".join(request.targets or ()) or "auto"
836
- drafts = self._load_drafts()
837
- docs: dict[str, dict[str, str]] = {}
838
- backups: dict[str, str] = {}
839
- working: dict[str, str] = {}
840
- self._log(
841
- chat_id,
842
- f'start targets={targets_label} path={doc_pointer} message="{message_for_log}"',
843
- )
844
- try:
845
- docs, backups, working = self._prepare_docs_for_run(drafts)
846
- supervisor = self._ensure_app_server()
847
- client = await supervisor.get_client(self.engine.repo_root)
848
- key = self._thread_key(request.agent)
849
- thread_id = self._app_server_threads.get_thread_id(key)
850
- if not thread_id:
851
- legacy = self._legacy_thread_id(request.agent)
852
- if legacy:
853
- thread_id = legacy
854
- try:
855
- self._app_server_threads.set_thread_id(key, thread_id)
856
- except Exception:
857
- pass
858
- if thread_id:
859
- try:
860
- resume_result = await client.thread_resume(thread_id)
861
- resumed = resume_result.get("id")
862
- if isinstance(resumed, str) and resumed:
863
- thread_id = resumed
864
- self._app_server_threads.set_thread_id(key, thread_id)
865
- except CodexAppServerError:
866
- self._app_server_threads.reset_thread(key)
867
- thread_id = None
868
- if not thread_id:
869
- thread = await client.thread_start(str(self.engine.repo_root))
870
- thread_id = thread.get("id")
871
- if not isinstance(thread_id, str) or not thread_id:
872
- raise DocChatError("App-server did not return a thread id")
873
- self._app_server_threads.set_thread_id(key, thread_id)
874
- prompt = self._build_app_server_prompt(request, docs)
875
- turn_kwargs: dict[str, Any] = {}
876
- if request.model:
877
- turn_kwargs["model"] = request.model
878
- if request.reasoning:
879
- turn_kwargs["effort"] = request.reasoning
880
- handle = await client.turn_start(
881
- thread_id,
882
- prompt,
883
- approval_policy="on-request",
884
- sandbox_policy="dangerFullAccess",
885
- **turn_kwargs,
886
- )
887
- turn_id = handle.turn_id
888
- thread_id = handle.thread_id
889
- active = self._register_active_turn(client, turn_id, thread_id)
890
- await self._handle_turn_start(
891
- thread_id,
892
- turn_id,
893
- log_context={
894
- "emit": lambda line, _chat=chat_id: self._log_event_line(
895
- _chat, line
896
- ),
897
- "formatter": AppServerEventFormatter(),
898
- },
899
- on_turn_start=on_turn_start,
900
- )
901
- turn_task = asyncio.create_task(handle.wait(timeout=None))
902
- timeout_task = asyncio.create_task(asyncio.sleep(DOC_CHAT_TIMEOUT_SECONDS))
903
- interrupt_task = asyncio.create_task(active.interrupt_event.wait())
904
- try:
905
- tasks = {turn_task, timeout_task, interrupt_task}
906
- done, _pending = await asyncio.wait(
907
- tasks, return_when=asyncio.FIRST_COMPLETED
908
- )
909
- if timeout_task in done:
910
- turn_task.add_done_callback(lambda task: task.exception())
911
- raise asyncio.TimeoutError()
912
- if interrupt_task in done:
913
- active.interrupted = True
914
- await self._interrupt_turn(active)
915
- done, _pending = await asyncio.wait(
916
- {turn_task}, timeout=DOC_CHAT_INTERRUPT_GRACE_SECONDS
917
- )
918
- if not done:
919
- turn_task.add_done_callback(lambda task: task.exception())
920
- duration_ms = int((time.time() - started_at) * 1000)
921
- self._log(
922
- chat_id,
923
- "result=interrupted "
924
- f"targets={targets_label} path={doc_pointer} "
925
- f"duration_ms={duration_ms} "
926
- f'message="{message_for_log}" backend=app_server',
927
- )
928
- return {
929
- "status": "interrupted",
930
- "detail": "Doc chat interrupted",
931
- "thread_id": thread_id,
932
- "turn_id": turn_id,
933
- }
934
- turn_result = await turn_task
935
- finally:
936
- self._clear_active_turn(handle.turn_id)
937
- timeout_task.cancel()
938
- with contextlib.suppress(asyncio.CancelledError):
939
- await timeout_task
940
- interrupt_task.cancel()
941
- with contextlib.suppress(asyncio.CancelledError):
942
- await interrupt_task
943
- if active.interrupted:
944
- duration_ms = int((time.time() - started_at) * 1000)
945
- self._log(
946
- chat_id,
947
- "result=interrupted "
948
- f"targets={targets_label} path={doc_pointer} "
949
- f"duration_ms={duration_ms} "
950
- f'message="{message_for_log}" backend=app_server',
951
- )
952
- return {
953
- "status": "interrupted",
954
- "detail": "Doc chat interrupted",
955
- "thread_id": thread_id,
956
- "turn_id": turn_id,
957
- }
958
- if turn_result.errors:
959
- raise DocChatError(turn_result.errors[-1])
960
- output = "\n".join(turn_result.agent_messages).strip()
961
- return self._finalize_doc_chat_output(
962
- output=output,
963
- drafts=drafts,
964
- docs=docs,
965
- backups=backups,
966
- working=working,
967
- started_at=started_at,
968
- chat_id=chat_id,
969
- targets_label=targets_label,
970
- doc_pointer=doc_pointer,
971
- message_for_log=message_for_log,
972
- thread_id=thread_id,
973
- turn_id=turn_id,
974
- backend="app_server",
975
- )
976
- except asyncio.TimeoutError:
977
- duration_ms = int((time.time() - started_at) * 1000)
978
- self._log(
979
- chat_id,
980
- "result=error "
981
- f"targets={targets_label} path={doc_pointer} duration_ms={duration_ms} "
982
- f'message="{message_for_log}" detail="timeout" backend=app_server',
983
- )
984
- return {
985
- "status": "error",
986
- "detail": "Doc chat agent timed out",
987
- "thread_id": thread_id,
988
- "turn_id": turn_id,
989
- }
990
- except DocChatError as exc:
991
- duration_ms = int((time.time() - started_at) * 1000)
992
- detail = self._compact_message(str(exc))
993
- self._log(
994
- chat_id,
995
- "result=error "
996
- f"targets={targets_label} path={doc_pointer} duration_ms={duration_ms} "
997
- f'message="{message_for_log}" detail="{detail}" backend=app_server',
998
- )
999
- return {
1000
- "status": "error",
1001
- "detail": str(exc),
1002
- "thread_id": thread_id,
1003
- "turn_id": turn_id,
1004
- }
1005
- except Exception as exc: # pragma: no cover - defensive
1006
- duration_ms = int((time.time() - started_at) * 1000)
1007
- detail = self._compact_message(str(exc))
1008
- self._log(
1009
- chat_id,
1010
- "result=error kind={kind} path={path} duration_ms={duration_ms} "
1011
- 'message="{message}" detail="{detail}" backend=app_server'.format(
1012
- kind=targets_label,
1013
- path=doc_pointer,
1014
- duration_ms=duration_ms,
1015
- message=message_for_log,
1016
- detail=detail,
1017
- ),
1018
- )
1019
- return {
1020
- "status": "error",
1021
- "detail": "Doc chat failed",
1022
- "thread_id": thread_id,
1023
- "turn_id": turn_id,
1024
- }
1025
- finally:
1026
- if backups:
1027
- self._restore_docs(backups)
1028
-
1029
- async def _execute_opencode(
1030
- self,
1031
- request: DocChatRequest,
1032
- *,
1033
- on_turn_start: Optional[Callable[[str, str], Awaitable[None]]] = None,
1034
- ) -> dict:
1035
- chat_id = self._chat_id()
1036
- started_at = time.time()
1037
- doc_pointer = self._doc_pointer(request.targets)
1038
- message_for_log = self._compact_message(request.message)
1039
- turn_id: Optional[str] = None
1040
- thread_id: Optional[str] = None
1041
- targets_label = ",".join(request.targets or ()) or "auto"
1042
- drafts = self._load_drafts()
1043
- docs: dict[str, dict[str, str]] = {}
1044
- backups: dict[str, str] = {}
1045
- working: dict[str, str] = {}
1046
- self._log(
1047
- chat_id,
1048
- f'start targets={targets_label} path={doc_pointer} message="{message_for_log}"',
1049
- )
1050
- try:
1051
- docs, backups, working = self._prepare_docs_for_run(drafts)
1052
- supervisor = self._ensure_opencode()
1053
- client = await supervisor.get_client(self.engine.repo_root)
1054
- key = self._thread_key(request.agent)
1055
- thread_id = self._app_server_threads.get_thread_id(key)
1056
- if thread_id:
1057
- try:
1058
- await client.get_session(thread_id)
1059
- except Exception:
1060
- self._app_server_threads.reset_thread(key)
1061
- thread_id = None
1062
- if not thread_id:
1063
- session = await client.create_session(
1064
- directory=str(self.engine.repo_root)
1065
- )
1066
- thread_id = extract_session_id(session, allow_fallback_id=True)
1067
- if not isinstance(thread_id, str) or not thread_id:
1068
- raise DocChatError("OpenCode did not return a session id")
1069
- self._app_server_threads.set_thread_id(key, thread_id)
1070
- prompt = self._build_app_server_prompt(request, docs)
1071
- model_payload = split_model_id(request.model)
1072
- missing_env = await opencode_missing_env(
1073
- client, str(self.engine.repo_root), model_payload
1074
- )
1075
- if missing_env:
1076
- provider_id = model_payload.get("providerID") if model_payload else None
1077
- missing_label = ", ".join(missing_env)
1078
- raise DocChatError(
1079
- "OpenCode provider "
1080
- f"{provider_id or 'selected'} requires env vars: {missing_label}"
1081
- )
1082
- opencode_turn_started = False
1083
- await supervisor.mark_turn_started(self.engine.repo_root)
1084
- opencode_turn_started = True
1085
- turn_id = build_turn_id(thread_id)
1086
- active = self._register_active_turn(client, turn_id, thread_id)
1087
- await self._handle_turn_start(
1088
- thread_id,
1089
- turn_id,
1090
- log_context=None,
1091
- on_turn_start=on_turn_start,
1092
- )
1093
- permission_policy = PERMISSION_ALLOW
1094
- output_task = asyncio.create_task(
1095
- collect_opencode_output(
1096
- client,
1097
- session_id=thread_id,
1098
- workspace_path=str(self.engine.repo_root),
1099
- permission_policy=permission_policy,
1100
- should_stop=active.interrupt_event.is_set,
1101
- )
1102
- )
1103
- prompt_task = asyncio.create_task(
1104
- client.prompt(
1105
- thread_id,
1106
- message=prompt,
1107
- model=model_payload,
1108
- variant=request.reasoning,
1109
- )
1110
- )
1111
- timeout_task = asyncio.create_task(asyncio.sleep(DOC_CHAT_TIMEOUT_SECONDS))
1112
- interrupt_task = asyncio.create_task(active.interrupt_event.wait())
1113
- try:
1114
- try:
1115
- await prompt_task
1116
- except Exception as exc:
1117
- active.interrupt_event.set()
1118
- output_task.cancel()
1119
- with contextlib.suppress(asyncio.CancelledError):
1120
- await output_task
1121
- raise DocChatError(f"OpenCode prompt failed: {exc}") from exc
1122
- tasks = {output_task, timeout_task, interrupt_task}
1123
- done, _pending = await asyncio.wait(
1124
- tasks, return_when=asyncio.FIRST_COMPLETED
1125
- )
1126
- if timeout_task in done:
1127
- output_task.add_done_callback(lambda task: task.exception())
1128
- raise asyncio.TimeoutError()
1129
- if interrupt_task in done:
1130
- active.interrupted = True
1131
- active.interrupt_event.set()
1132
- await self._abort_opencode(active, thread_id)
1133
- done, _pending = await asyncio.wait(
1134
- {output_task}, timeout=DOC_CHAT_INTERRUPT_GRACE_SECONDS
1135
- )
1136
- if not done:
1137
- output_task.add_done_callback(lambda task: task.exception())
1138
- duration_ms = int((time.time() - started_at) * 1000)
1139
- self._log(
1140
- chat_id,
1141
- "result=interrupted "
1142
- f"targets={targets_label} path={doc_pointer} "
1143
- f"duration_ms={duration_ms} "
1144
- f'message="{message_for_log}" backend=opencode',
1145
- )
1146
- return {
1147
- "status": "interrupted",
1148
- "detail": "Doc chat interrupted",
1149
- "thread_id": thread_id,
1150
- "turn_id": turn_id,
1151
- }
1152
- output_result = await output_task
1153
- finally:
1154
- self._clear_active_turn(turn_id)
1155
- timeout_task.cancel()
1156
- with contextlib.suppress(asyncio.CancelledError):
1157
- await timeout_task
1158
- interrupt_task.cancel()
1159
- with contextlib.suppress(asyncio.CancelledError):
1160
- await interrupt_task
1161
- if opencode_turn_started:
1162
- await supervisor.mark_turn_finished(self.engine.repo_root)
1163
- if output_result.error:
1164
- duration_ms = int((time.time() - started_at) * 1000)
1165
- detail = self._compact_message(output_result.error)
1166
- self._log(
1167
- chat_id,
1168
- "result=error "
1169
- f"targets={targets_label} path={doc_pointer} "
1170
- f"duration_ms={duration_ms} "
1171
- f'message="{message_for_log}" detail="{detail}" backend=opencode',
1172
- )
1173
- return {
1174
- "status": "error",
1175
- "detail": output_result.error,
1176
- "thread_id": thread_id,
1177
- "turn_id": turn_id,
1178
- }
1179
- return self._finalize_doc_chat_output(
1180
- output=output_result.text,
1181
- drafts=drafts,
1182
- docs=docs,
1183
- backups=backups,
1184
- working=working,
1185
- started_at=started_at,
1186
- chat_id=chat_id,
1187
- targets_label=targets_label,
1188
- doc_pointer=doc_pointer,
1189
- message_for_log=message_for_log,
1190
- thread_id=thread_id,
1191
- turn_id=turn_id,
1192
- backend="opencode",
1193
- )
1194
- except asyncio.TimeoutError:
1195
- duration_ms = int((time.time() - started_at) * 1000)
1196
- self._log(
1197
- chat_id,
1198
- "result=error "
1199
- f"targets={targets_label} path={doc_pointer} duration_ms={duration_ms} "
1200
- f'message="{message_for_log}" detail="timeout" backend=opencode',
1201
- )
1202
- return {
1203
- "status": "error",
1204
- "detail": "Doc chat agent timed out",
1205
- "thread_id": thread_id,
1206
- "turn_id": turn_id,
1207
- }
1208
- except DocChatError as exc:
1209
- duration_ms = int((time.time() - started_at) * 1000)
1210
- detail = self._compact_message(str(exc))
1211
- self._log(
1212
- chat_id,
1213
- "result=error "
1214
- f"targets={targets_label} path={doc_pointer} duration_ms={duration_ms} "
1215
- f'message="{message_for_log}" detail="{detail}" backend=opencode',
1216
- )
1217
- return {
1218
- "status": "error",
1219
- "detail": str(exc),
1220
- "thread_id": thread_id,
1221
- "turn_id": turn_id,
1222
- }
1223
- except Exception as exc:
1224
- duration_ms = int((time.time() - started_at) * 1000)
1225
- detail = self._compact_message(str(exc))
1226
- self._log(
1227
- chat_id,
1228
- "result=error "
1229
- f"targets={targets_label} path={doc_pointer} duration_ms={duration_ms} "
1230
- f'message="{message_for_log}" detail="{detail}" backend=opencode',
1231
- )
1232
- return {
1233
- "status": "error",
1234
- "detail": "Doc chat failed",
1235
- "thread_id": thread_id,
1236
- "turn_id": turn_id,
1237
- }
1238
- finally:
1239
- if backups:
1240
- self._restore_docs(backups)
1241
-
1242
- def _finalize_doc_chat_output(
1243
- self,
1244
- *,
1245
- output: str,
1246
- drafts: dict[str, DocChatDraftState],
1247
- docs: dict[str, dict[str, str]],
1248
- backups: dict[str, str],
1249
- working: dict[str, str],
1250
- started_at: float,
1251
- chat_id: str,
1252
- targets_label: str,
1253
- doc_pointer: str,
1254
- message_for_log: str,
1255
- thread_id: Optional[str],
1256
- turn_id: Optional[str],
1257
- backend: str,
1258
- ) -> dict:
1259
- message_text, patch_text_raw = self._split_patch_from_output(output)
1260
- agent_message = self._parse_agent_message(message_text or output)
1261
- response_text = message_text.strip() or output.strip() or agent_message
1262
- updated = dict(drafts)
1263
- updated_kinds: list[str] = []
1264
- payloads: dict[str, dict] = {}
1265
- created_at = now_iso()
1266
- allowed_kinds = ALLOWED_DOC_KINDS
1267
- unexpected: list[str] = []
1268
- config = self._repo_config()
1269
- self._log_output(chat_id, response_text or output)
1270
- for kind in ALLOWED_DOC_KINDS:
1271
- path = config.doc_path(kind)
1272
- after = path.read_text(encoding="utf-8")
1273
- before = working.get(kind, backups.get(kind, ""))
1274
- if kind not in allowed_kinds:
1275
- if after != before:
1276
- unexpected.append(kind)
1277
- continue
1278
- if after == before:
1279
- continue
1280
- rel_path = str(path.relative_to(self.engine.repo_root))
1281
- patch_for_doc = self._build_patch(rel_path, before, after)
1282
- if not patch_for_doc.strip():
1283
- continue
1284
- base_hash = self._hash_content(backups.get(kind, before))
1285
- existing = drafts.get(kind)
1286
- if existing and docs.get(kind, {}).get("source") == "draft":
1287
- if existing.base_hash:
1288
- base_hash = existing.base_hash
1289
- updated[kind] = DocChatDraftState(
1290
- content=after,
1291
- patch=patch_for_doc,
1292
- agent_message=agent_message,
1293
- created_at=created_at,
1294
- base_hash=base_hash,
1295
- )
1296
- updated_kinds.append(kind)
1297
- payloads[kind] = updated[kind].to_dict()
1298
- if unexpected:
1299
- raise DocChatError(
1300
- "Doc chat updated unexpected docs: " + ", ".join(unexpected)
1301
- )
1302
- if patch_text_raw.strip() and not payloads:
1303
- try:
1304
- updated, updated_kinds, payloads = self._apply_patch_to_drafts(
1305
- patch_text_raw=patch_text_raw,
1306
- drafts=updated,
1307
- docs=docs,
1308
- agent_message=agent_message,
1309
- allowed_kinds=allowed_kinds,
1310
- )
1311
- except PatchError as exc:
1312
- raise DocChatError(str(exc)) from exc
1313
- if not payloads:
1314
- raise DocChatError("Doc chat patch did not produce updates")
1315
- if "todo" in payloads:
1316
- todo_content = payloads["todo"].get("content", "")
1317
- if not isinstance(todo_content, str):
1318
- raise DocChatError("Invalid TODO draft content")
1319
- todo_errors = validate_todo_markdown(todo_content)
1320
- if todo_errors:
1321
- raise DocChatError("Invalid TODO format: " + "; ".join(todo_errors))
1322
- if payloads:
1323
- self._save_drafts(updated)
1324
- duration_ms = int((time.time() - started_at) * 1000)
1325
- self._log(
1326
- chat_id,
1327
- "result=success "
1328
- f"targets={targets_label} path={doc_pointer} "
1329
- f"duration_ms={duration_ms} "
1330
- f'message="{message_for_log}" backend={backend}',
1331
- )
1332
- return {
1333
- "status": "ok",
1334
- "agent_message": agent_message,
1335
- "message": response_text,
1336
- "updated": updated_kinds,
1337
- "drafts": payloads,
1338
- "thread_id": thread_id,
1339
- "turn_id": turn_id,
1340
- }
1341
-
1342
- async def execute(
1343
- self,
1344
- request: DocChatRequest,
1345
- *,
1346
- on_turn_start: Optional[Callable[[str, str], Awaitable[None]]] = None,
1347
- ) -> dict:
1348
- if (request.agent or "").strip().lower() == "opencode":
1349
- if on_turn_start is None:
1350
- return await self._execute_opencode(request)
1351
- return await self._execute_opencode(request, on_turn_start=on_turn_start)
1352
- if on_turn_start is None:
1353
- return await self._execute_app_server(request)
1354
- return await self._execute_app_server(request, on_turn_start=on_turn_start)
1355
-
1356
- async def stream(self, request: DocChatRequest) -> AsyncIterator[str]:
1357
- try:
1358
- async with self.doc_lock():
1359
- yield format_sse("status", {"status": "queued"})
1360
- try:
1361
- turn_queue: asyncio.Queue[dict] = asyncio.Queue(maxsize=1)
1362
-
1363
- async def _on_turn_start(thread_id: str, turn_id: str) -> None:
1364
- payload = {
1365
- "thread_id": thread_id,
1366
- "turn_id": turn_id,
1367
- "targets": list(request.targets or ()),
1368
- }
1369
- if request.agent:
1370
- payload["agent"] = request.agent
1371
- if request.model:
1372
- payload["model"] = request.model
1373
- if request.reasoning:
1374
- payload["reasoning"] = request.reasoning
1375
- if turn_queue.full():
1376
- return
1377
- await turn_queue.put(payload)
1378
-
1379
- execute_task = asyncio.create_task(
1380
- self.execute(request, on_turn_start=_on_turn_start)
1381
- )
1382
- turn_task = asyncio.create_task(turn_queue.get())
1383
- done, pending = await asyncio.wait(
1384
- {execute_task, turn_task},
1385
- return_when=asyncio.FIRST_COMPLETED,
1386
- )
1387
- if turn_task in done:
1388
- yield format_sse("turn", turn_task.result())
1389
- yield format_sse("status", {"status": "running"})
1390
- else:
1391
- turn_task.cancel()
1392
- with contextlib.suppress(asyncio.CancelledError):
1393
- await turn_task
1394
- if execute_task in done:
1395
- result = execute_task.result()
1396
- else:
1397
- result = await execute_task
1398
- except DocChatError as exc:
1399
- yield format_sse("error", {"detail": str(exc)})
1400
- return
1401
- if result.get("status") == "ok":
1402
- yield format_sse("update", result)
1403
- yield format_sse("done", {"status": "ok"})
1404
- elif result.get("status") == "interrupted":
1405
- yield format_sse(
1406
- "interrupted",
1407
- {"detail": result.get("detail") or "Doc chat interrupted"},
1408
- )
1409
- else:
1410
- detail = result.get("detail") or "Doc chat failed"
1411
- yield format_sse("error", {"detail": detail})
1412
- except DocChatBusyError as exc:
1413
- yield format_sse("error", {"detail": str(exc)})
1414
- except Exception as exc: # pragma: no cover - defensive
1415
- yield format_sse("error", {"detail": str(exc)})