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