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,788 +0,0 @@
1
- import asyncio
2
- import contextlib
3
- import difflib
4
- import re
5
- import threading
6
- from contextlib import asynccontextmanager, contextmanager
7
- from dataclasses import dataclass, field
8
- from pathlib import Path
9
- from typing import Any, Dict, Optional
10
-
11
- from .agents.opencode.runtime import (
12
- PERMISSION_ALLOW,
13
- build_turn_id,
14
- collect_opencode_output,
15
- extract_session_id,
16
- opencode_missing_env,
17
- split_model_id,
18
- )
19
- from .agents.opencode.supervisor import OpenCodeSupervisor
20
- from .core.app_server_events import AppServerEventBuffer
21
- from .core.app_server_prompts import (
22
- build_spec_ingest_prompt as build_app_server_spec_ingest_prompt,
23
- )
24
- from .core.app_server_threads import (
25
- AppServerThreadRegistry,
26
- default_app_server_threads_path,
27
- )
28
- from .core.docs import validate_todo_markdown
29
- from .core.engine import Engine
30
- from .core.locks import FileLock, FileLockBusy, FileLockError
31
- from .core.patch_utils import (
32
- PatchError,
33
- apply_patch_file,
34
- ensure_patch_targets_allowed,
35
- normalize_patch_text,
36
- preview_patch,
37
- )
38
- from .core.utils import atomic_write
39
- from .integrations.app_server.client import CodexAppServerError
40
- from .integrations.app_server.supervisor import WorkspaceAppServerSupervisor
41
-
42
- SPEC_INGEST_TIMEOUT_SECONDS = 240
43
- SPEC_INGEST_INTERRUPT_GRACE_SECONDS = 10
44
- SPEC_INGEST_PATCH_NAME = "spec-ingest.patch"
45
-
46
-
47
- class SpecIngestError(Exception):
48
- """Raised when ingesting a SPEC fails."""
49
-
50
-
51
- @dataclass
52
- class ActiveSpecIngestTurn:
53
- thread_id: str
54
- turn_id: str
55
- client: Any
56
- interrupted: bool = False
57
- interrupt_sent: bool = False
58
- interrupt_event: asyncio.Event = field(default_factory=asyncio.Event)
59
-
60
-
61
- def ensure_can_overwrite(engine: Engine, force: bool) -> None:
62
- if force:
63
- return
64
- for key in ("todo", "progress", "opinions"):
65
- existing = engine.docs.read_doc(key).strip()
66
- if existing:
67
- raise SpecIngestError(
68
- "TODO/PROGRESS/OPINIONS already contain content; rerun with --force to overwrite"
69
- )
70
-
71
-
72
- def clear_work_docs(engine: Engine) -> Dict[str, str]:
73
- defaults = {
74
- "todo": "# TODO\n\n",
75
- "progress": "# Progress\n\n",
76
- "opinions": "# Opinions\n\n",
77
- }
78
- for key, content in defaults.items():
79
- atomic_write(engine.config.doc_path(key), content)
80
- # Read back to reflect actual on-disk content.
81
- return {k: engine.docs.read_doc(k) for k in defaults.keys()}
82
-
83
-
84
- class SpecIngestService:
85
- def __init__(
86
- self,
87
- engine: Engine,
88
- *,
89
- app_server_supervisor: Optional[WorkspaceAppServerSupervisor] = None,
90
- app_server_threads: Optional[AppServerThreadRegistry] = None,
91
- app_server_events: Optional[AppServerEventBuffer] = None,
92
- opencode_supervisor: Optional[OpenCodeSupervisor] = None,
93
- ) -> None:
94
- self.engine = engine
95
- self._app_server_supervisor = app_server_supervisor
96
- self._app_server_threads = app_server_threads or AppServerThreadRegistry(
97
- default_app_server_threads_path(self.engine.repo_root)
98
- )
99
- self._app_server_events = app_server_events
100
- self._opencode_supervisor = opencode_supervisor
101
- self.patch_path = (
102
- self.engine.repo_root / ".codex-autorunner" / SPEC_INGEST_PATCH_NAME
103
- )
104
- self.last_agent_message: Optional[str] = None
105
- self._lock: Optional[asyncio.Lock] = None
106
- self._lock_path = (
107
- self.engine.repo_root / ".codex-autorunner" / "locks" / "spec_ingest.lock"
108
- )
109
- self._thread_lock = threading.Lock()
110
- self._active_turn: Optional[ActiveSpecIngestTurn] = None
111
- self._active_turn_lock = threading.Lock()
112
- self._pending_interrupt = False
113
-
114
- def _ensure_lock(self) -> asyncio.Lock:
115
- if self._lock is None:
116
- try:
117
- self._lock = asyncio.Lock()
118
- except RuntimeError:
119
- asyncio.set_event_loop(asyncio.new_event_loop())
120
- self._lock = asyncio.Lock()
121
- return self._lock
122
-
123
- def _ingest_busy(self) -> bool:
124
- lock = self._ensure_lock()
125
- if lock.locked():
126
- return True
127
- file_lock = FileLock(self._lock_path)
128
- try:
129
- file_lock.acquire(blocking=False)
130
- except FileLockBusy:
131
- return True
132
- except FileLockError:
133
- return True
134
- finally:
135
- file_lock.release()
136
- return False
137
-
138
- @asynccontextmanager
139
- async def ingest_lock(self):
140
- if not self._thread_lock.acquire(blocking=False):
141
- raise SpecIngestError("Spec ingest is already running")
142
- lock = self._ensure_lock()
143
- if lock.locked():
144
- self._thread_lock.release()
145
- raise SpecIngestError("Spec ingest is already running")
146
- await lock.acquire()
147
- file_lock = FileLock(self._lock_path)
148
- try:
149
- try:
150
- file_lock.acquire(blocking=False)
151
- except FileLockBusy as exc:
152
- raise SpecIngestError("Spec ingest is already running") from exc
153
- except FileLockError as exc:
154
- raise SpecIngestError(str(exc)) from exc
155
- yield
156
- finally:
157
- file_lock.release()
158
- lock.release()
159
- self._thread_lock.release()
160
- with self._active_turn_lock:
161
- self._pending_interrupt = False
162
-
163
- @contextmanager
164
- def _patch_lock(self):
165
- if not self._thread_lock.acquire(blocking=False):
166
- raise SpecIngestError("Spec ingest is already running")
167
- lock = self._ensure_lock()
168
- if lock.locked():
169
- self._thread_lock.release()
170
- raise SpecIngestError("Spec ingest is already running")
171
- file_lock = FileLock(self._lock_path)
172
- try:
173
- file_lock.acquire(blocking=False)
174
- except FileLockBusy as exc:
175
- self._thread_lock.release()
176
- raise SpecIngestError("Spec ingest is already running") from exc
177
- except FileLockError as exc:
178
- self._thread_lock.release()
179
- raise SpecIngestError(str(exc)) from exc
180
- try:
181
- yield
182
- finally:
183
- file_lock.release()
184
- self._thread_lock.release()
185
-
186
- def _ensure_app_server(self) -> WorkspaceAppServerSupervisor:
187
- if self._app_server_supervisor is None:
188
- raise SpecIngestError("App-server backend is not configured")
189
- return self._app_server_supervisor
190
-
191
- def _ensure_opencode(self) -> OpenCodeSupervisor:
192
- if self._opencode_supervisor is None:
193
- raise SpecIngestError("OpenCode backend is not configured")
194
- return self._opencode_supervisor
195
-
196
- def _get_active_turn(self) -> Optional[ActiveSpecIngestTurn]:
197
- with self._active_turn_lock:
198
- return self._active_turn
199
-
200
- def _clear_active_turn(self, turn_id: str) -> None:
201
- with self._active_turn_lock:
202
- if self._active_turn and self._active_turn.turn_id == turn_id:
203
- self._active_turn = None
204
-
205
- def _register_active_turn(
206
- self, client: Any, turn_id: str, thread_id: str
207
- ) -> ActiveSpecIngestTurn:
208
- interrupt_event = asyncio.Event()
209
- active = ActiveSpecIngestTurn(
210
- thread_id=thread_id,
211
- turn_id=turn_id,
212
- client=client,
213
- interrupted=False,
214
- interrupt_sent=False,
215
- interrupt_event=interrupt_event,
216
- )
217
- with self._active_turn_lock:
218
- self._active_turn = active
219
- if self._pending_interrupt:
220
- self._pending_interrupt = False
221
- active.interrupted = True
222
- interrupt_event.set()
223
- return active
224
-
225
- async def _interrupt_turn(self, active: ActiveSpecIngestTurn) -> None:
226
- if active.interrupt_sent:
227
- return
228
- active.interrupt_sent = True
229
- try:
230
- if not hasattr(active.client, "turn_interrupt"):
231
- return
232
- await asyncio.wait_for(
233
- active.client.turn_interrupt(
234
- active.turn_id, thread_id=active.thread_id
235
- ),
236
- timeout=SPEC_INGEST_INTERRUPT_GRACE_SECONDS,
237
- )
238
- except asyncio.TimeoutError:
239
- pass
240
- except CodexAppServerError:
241
- pass
242
-
243
- async def _abort_opencode(
244
- self, active: ActiveSpecIngestTurn, thread_id: str
245
- ) -> None:
246
- if active.interrupt_sent:
247
- return
248
- active.interrupt_sent = True
249
- try:
250
- if not hasattr(active.client, "abort"):
251
- return
252
- await asyncio.wait_for(
253
- active.client.abort(thread_id),
254
- timeout=SPEC_INGEST_INTERRUPT_GRACE_SECONDS,
255
- )
256
- except asyncio.TimeoutError:
257
- pass
258
- except Exception:
259
- pass
260
-
261
- async def interrupt(self) -> Dict[str, str]:
262
- active = self._get_active_turn()
263
- if active is None:
264
- pending = self._ingest_busy()
265
- with self._active_turn_lock:
266
- self._pending_interrupt = pending
267
- return self._assemble_response(
268
- {},
269
- status="interrupted",
270
- agent_message="Spec ingest interrupted",
271
- )
272
- active.interrupted = True
273
- active.interrupt_event.set()
274
- await self._interrupt_turn(active)
275
- return self._assemble_response(
276
- {},
277
- status="interrupted",
278
- agent_message="Spec ingest interrupted",
279
- )
280
-
281
- def _allowed_targets(self) -> Dict[str, str]:
282
- config = self.engine.config
283
- rel = {}
284
- for key in ("todo", "progress", "opinions"):
285
- rel[key] = str(config.doc_path(key).relative_to(self.engine.repo_root))
286
- return rel
287
-
288
- def _spec_path(self, spec_path: Optional[Path]) -> Path:
289
- target = spec_path or self.engine.config.doc_path("spec")
290
- if not target.exists():
291
- raise SpecIngestError(f"SPEC not found at {target}")
292
- text = target.read_text(encoding="utf-8")
293
- if not text.strip():
294
- raise SpecIngestError(f"SPEC at {target} is empty")
295
- return target
296
-
297
- def _assemble_response(
298
- self,
299
- docs: Dict[str, str],
300
- *,
301
- patch: Optional[str] = None,
302
- agent_message: Optional[str] = None,
303
- status: str = "ok",
304
- ) -> Dict[str, str]:
305
- return {
306
- "status": status,
307
- "todo": docs.get("todo", self.engine.docs.read_doc("todo")),
308
- "progress": docs.get("progress", self.engine.docs.read_doc("progress")),
309
- "opinions": docs.get("opinions", self.engine.docs.read_doc("opinions")),
310
- "spec": self.engine.docs.read_doc("spec"),
311
- "summary": self.engine.docs.read_doc("summary"),
312
- "patch": patch or "",
313
- "agent_message": agent_message or "",
314
- }
315
-
316
- def pending_patch(self) -> Optional[Dict[str, str]]:
317
- with self._patch_lock():
318
- if not self.patch_path.exists():
319
- return None
320
- patch_text_raw = self.patch_path.read_text(encoding="utf-8")
321
- targets = self._allowed_targets()
322
- try:
323
- patch_text, raw_targets = normalize_patch_text(patch_text_raw)
324
- ensure_patch_targets_allowed(raw_targets, targets.values())
325
- preview = preview_patch(self.engine.repo_root, patch_text, raw_targets)
326
- except PatchError:
327
- return None
328
- docs = {
329
- key: preview.get(path, self.engine.docs.read_doc(key))
330
- for key, path in targets.items()
331
- }
332
- return self._assemble_response(
333
- docs, patch=patch_text, agent_message=self.last_agent_message
334
- )
335
-
336
- def apply_patch(self) -> Dict[str, str]:
337
- with self._patch_lock():
338
- if not self.patch_path.exists():
339
- raise SpecIngestError("No pending spec ingest patch")
340
- patch_text_raw = self.patch_path.read_text(encoding="utf-8")
341
- targets = self._allowed_targets()
342
- try:
343
- patch_text, raw_targets = normalize_patch_text(patch_text_raw)
344
- ensure_patch_targets_allowed(raw_targets, targets.values())
345
- self.patch_path.write_text(patch_text, encoding="utf-8")
346
- apply_patch_file(self.engine.repo_root, self.patch_path, raw_targets)
347
- except PatchError as exc:
348
- raise SpecIngestError(str(exc)) from exc
349
- self.patch_path.unlink(missing_ok=True)
350
- return self._assemble_response(
351
- {
352
- key: self.engine.docs.read_doc(key)
353
- for key in ("todo", "progress", "opinions")
354
- }
355
- )
356
-
357
- def discard_patch(self) -> Dict[str, str]:
358
- with self._patch_lock():
359
- if self.patch_path.exists():
360
- self.patch_path.unlink(missing_ok=True)
361
- return self._assemble_response(
362
- {
363
- key: self.engine.docs.read_doc(key)
364
- for key in ("todo", "progress", "opinions")
365
- }
366
- )
367
-
368
- def _build_patch(self, rel_path: str, before: str, after: str) -> str:
369
- diff = difflib.unified_diff(
370
- before.splitlines(),
371
- after.splitlines(),
372
- fromfile=f"a/{rel_path}",
373
- tofile=f"b/{rel_path}",
374
- lineterm="",
375
- )
376
- return "\n".join(diff)
377
-
378
- def _restore_docs(self, backups: Dict[str, str]) -> None:
379
- config = self.engine.config
380
- for key, content in backups.items():
381
- path = config.doc_path(key)
382
- try:
383
- current = path.read_text(encoding="utf-8")
384
- except OSError:
385
- current = ""
386
- if current != content:
387
- atomic_write(path, content)
388
-
389
- async def _execute_app_server(
390
- self,
391
- *,
392
- force: bool,
393
- spec_path: Optional[Path],
394
- message: Optional[str],
395
- model: Optional[str] = None,
396
- reasoning: Optional[str] = None,
397
- ) -> Dict[str, str]:
398
- if not force:
399
- ensure_can_overwrite(self.engine, force=False)
400
- spec_target = self._spec_path(spec_path)
401
- prompt = build_app_server_spec_ingest_prompt(
402
- self.engine.config,
403
- message=message or "Ingest SPEC into TODO/PROGRESS/OPINIONS.",
404
- spec_path=spec_target,
405
- )
406
-
407
- # Backup docs
408
- backups = {}
409
- for key in ("todo", "progress", "opinions"):
410
- backups[key] = self.engine.docs.read_doc(key)
411
-
412
- supervisor = self._ensure_app_server()
413
- client = await supervisor.get_client(self.engine.repo_root)
414
- key = "spec_ingest"
415
- thread_id = self._app_server_threads.get_thread_id(key)
416
- if thread_id:
417
- try:
418
- result = await client.thread_resume(thread_id)
419
- resumed = result.get("id")
420
- if isinstance(resumed, str) and resumed:
421
- thread_id = resumed
422
- self._app_server_threads.set_thread_id(key, thread_id)
423
- except CodexAppServerError:
424
- self._app_server_threads.reset_thread(key)
425
- thread_id = None
426
- if not thread_id:
427
- thread = await client.thread_start(str(self.engine.repo_root))
428
- thread_id = thread.get("id")
429
- if not isinstance(thread_id, str) or not thread_id:
430
- raise SpecIngestError("App-server did not return a thread id")
431
- self._app_server_threads.set_thread_id(key, thread_id)
432
-
433
- turn_kwargs: dict[str, Any] = {}
434
- if model:
435
- turn_kwargs["model"] = model
436
- if reasoning:
437
- turn_kwargs["effort"] = reasoning
438
- handle = await client.turn_start(
439
- thread_id,
440
- prompt,
441
- approval_policy="never",
442
- sandbox_policy="dangerFullAccess", # Allowed for doc edits per user request
443
- **turn_kwargs,
444
- )
445
- active = self._register_active_turn(client, handle.turn_id, handle.thread_id)
446
- if self._app_server_events is not None:
447
- try:
448
- await self._app_server_events.register_turn(
449
- handle.thread_id, handle.turn_id
450
- )
451
- except Exception:
452
- pass
453
-
454
- turn_task = asyncio.create_task(handle.wait(timeout=None))
455
- timeout_task = asyncio.create_task(asyncio.sleep(SPEC_INGEST_TIMEOUT_SECONDS))
456
- interrupt_task = asyncio.create_task(active.interrupt_event.wait())
457
-
458
- try:
459
- tasks = {turn_task, timeout_task, interrupt_task}
460
- done, _pending = await asyncio.wait(
461
- tasks, return_when=asyncio.FIRST_COMPLETED
462
- )
463
- if timeout_task in done:
464
- turn_task.add_done_callback(lambda task: task.exception())
465
- raise SpecIngestError("Spec ingest agent timed out")
466
- if interrupt_task in done:
467
- active.interrupted = True
468
- await self._interrupt_turn(active)
469
- done, _pending = await asyncio.wait(
470
- {turn_task}, timeout=SPEC_INGEST_INTERRUPT_GRACE_SECONDS
471
- )
472
- if not done:
473
- turn_task.add_done_callback(lambda task: task.exception())
474
- return self._assemble_response(
475
- {},
476
- status="interrupted",
477
- agent_message="Spec ingest interrupted",
478
- )
479
- result = await turn_task
480
- finally:
481
- self._clear_active_turn(handle.turn_id)
482
- timeout_task.cancel()
483
- with contextlib.suppress(asyncio.CancelledError):
484
- await timeout_task
485
- interrupt_task.cancel()
486
- with contextlib.suppress(asyncio.CancelledError):
487
- await interrupt_task
488
-
489
- if active.interrupted:
490
- # Restore docs if interrupted
491
- self._restore_docs(backups)
492
- return self._assemble_response(
493
- {},
494
- status="interrupted",
495
- agent_message="Spec ingest interrupted",
496
- )
497
-
498
- if result.errors:
499
- # Restore docs on error
500
- self._restore_docs(backups)
501
- raise SpecIngestError(result.errors[-1])
502
-
503
- output = "\n".join(result.agent_messages).strip()
504
- agent_message = SpecIngestPatchParser.parse_agent_message(output)
505
-
506
- # Compute patch from file changes
507
- patches = []
508
- docs_preview = {}
509
- targets = self._allowed_targets()
510
-
511
- for key in ("todo", "progress", "opinions"):
512
- path = self.engine.config.doc_path(key)
513
- try:
514
- after = path.read_text(encoding="utf-8")
515
- except OSError:
516
- after = ""
517
- before = backups.get(key, "")
518
- docs_preview[key] = after
519
-
520
- if after == before:
521
- continue
522
-
523
- rel_path = targets[key]
524
- patch = self._build_patch(rel_path, before, after)
525
- if patch.strip():
526
- patches.append(patch)
527
-
528
- todo_errors = validate_todo_markdown(docs_preview.get("todo", ""))
529
- if todo_errors:
530
- # Restore docs before failing.
531
- self._restore_docs(backups)
532
- raise SpecIngestError("Invalid TODO format: " + "; ".join(todo_errors))
533
-
534
- # Always restore docs to state before ingest (user must apply patch)
535
- self._restore_docs(backups)
536
-
537
- patch_text = "\n".join(patches)
538
- if not patch_text.strip():
539
- raise SpecIngestError(
540
- "App-server did not make any changes to TODO/PROGRESS/OPINIONS"
541
- )
542
-
543
- self.patch_path.write_text(patch_text, encoding="utf-8")
544
- self.last_agent_message = agent_message
545
-
546
- return self._assemble_response(
547
- docs_preview, patch=patch_text, agent_message=agent_message
548
- )
549
-
550
- async def _execute_opencode(
551
- self,
552
- *,
553
- force: bool,
554
- spec_path: Optional[Path],
555
- message: Optional[str],
556
- model: Optional[str],
557
- reasoning: Optional[str],
558
- ) -> Dict[str, str]:
559
- if not force:
560
- ensure_can_overwrite(self.engine, force=False)
561
- spec_target = self._spec_path(spec_path)
562
- prompt = build_app_server_spec_ingest_prompt(
563
- self.engine.config,
564
- message=message or "Ingest SPEC into TODO/PROGRESS/OPINIONS.",
565
- spec_path=spec_target,
566
- )
567
- backups = {
568
- key: self.engine.docs.read_doc(key)
569
- for key in ("todo", "progress", "opinions")
570
- }
571
- supervisor = self._ensure_opencode()
572
- client = await supervisor.get_client(self.engine.repo_root)
573
- key = "spec_ingest.opencode"
574
- thread_id = self._app_server_threads.get_thread_id(key)
575
- if thread_id:
576
- try:
577
- await client.get_session(thread_id)
578
- except Exception:
579
- self._app_server_threads.reset_thread(key)
580
- thread_id = None
581
- if not thread_id:
582
- session = await client.create_session(directory=str(self.engine.repo_root))
583
- thread_id = extract_session_id(session, allow_fallback_id=True)
584
- if not isinstance(thread_id, str) or not thread_id:
585
- raise SpecIngestError("OpenCode did not return a session id")
586
- self._app_server_threads.set_thread_id(key, thread_id)
587
-
588
- model_payload = split_model_id(model)
589
- missing_env = await opencode_missing_env(
590
- client, str(self.engine.repo_root), model_payload
591
- )
592
- if missing_env:
593
- provider_id = model_payload.get("providerID") if model_payload else None
594
- missing_label = ", ".join(missing_env)
595
- raise SpecIngestError(
596
- "OpenCode provider "
597
- f"{provider_id or 'selected'} requires env vars: {missing_label}"
598
- )
599
- opencode_turn_started = False
600
- await supervisor.mark_turn_started(self.engine.repo_root)
601
- opencode_turn_started = True
602
- turn_id = build_turn_id(thread_id)
603
- active = self._register_active_turn(client, turn_id, thread_id)
604
- permission_policy = PERMISSION_ALLOW
605
- output_task = asyncio.create_task(
606
- collect_opencode_output(
607
- client,
608
- session_id=thread_id,
609
- workspace_path=str(self.engine.repo_root),
610
- permission_policy=permission_policy,
611
- should_stop=active.interrupt_event.is_set,
612
- )
613
- )
614
- prompt_task = asyncio.create_task(
615
- client.prompt(
616
- thread_id,
617
- message=prompt,
618
- model=model_payload,
619
- variant=reasoning,
620
- )
621
- )
622
- timeout_task = asyncio.create_task(asyncio.sleep(SPEC_INGEST_TIMEOUT_SECONDS))
623
- interrupt_task = asyncio.create_task(active.interrupt_event.wait())
624
- try:
625
- try:
626
- await prompt_task
627
- except Exception as exc:
628
- active.interrupt_event.set()
629
- output_task.cancel()
630
- with contextlib.suppress(asyncio.CancelledError):
631
- await output_task
632
- raise SpecIngestError(f"OpenCode prompt failed: {exc}") from exc
633
- tasks = {output_task, timeout_task, interrupt_task}
634
- done, _pending = await asyncio.wait(
635
- tasks, return_when=asyncio.FIRST_COMPLETED
636
- )
637
- if timeout_task in done:
638
- output_task.add_done_callback(lambda task: task.exception())
639
- raise SpecIngestError("Spec ingest agent timed out")
640
- if interrupt_task in done:
641
- active.interrupted = True
642
- await self._abort_opencode(active, thread_id)
643
- done, _pending = await asyncio.wait(
644
- {output_task}, timeout=SPEC_INGEST_INTERRUPT_GRACE_SECONDS
645
- )
646
- if not done:
647
- output_task.add_done_callback(lambda task: task.exception())
648
- output_result = await output_task
649
- finally:
650
- self._clear_active_turn(turn_id)
651
- timeout_task.cancel()
652
- with contextlib.suppress(asyncio.CancelledError):
653
- await timeout_task
654
- interrupt_task.cancel()
655
- with contextlib.suppress(asyncio.CancelledError):
656
- await interrupt_task
657
- if opencode_turn_started:
658
- await supervisor.mark_turn_finished(self.engine.repo_root)
659
-
660
- if active.interrupted:
661
- self._restore_docs(backups)
662
- return self._assemble_response(
663
- {},
664
- status="interrupted",
665
- agent_message="Spec ingest interrupted",
666
- )
667
-
668
- if output_result.error:
669
- raise SpecIngestError(output_result.error)
670
- agent_message = SpecIngestPatchParser.parse_agent_message(output_result.text)
671
- patches = []
672
- docs_preview = {}
673
- targets = self._allowed_targets()
674
-
675
- for key in ("todo", "progress", "opinions"):
676
- path = self.engine.config.doc_path(key)
677
- try:
678
- after = path.read_text(encoding="utf-8")
679
- except OSError:
680
- after = ""
681
- before = backups.get(key, "")
682
- docs_preview[key] = after
683
-
684
- if after == before:
685
- continue
686
-
687
- rel_path = targets[key]
688
- patch = self._build_patch(rel_path, before, after)
689
- if patch.strip():
690
- patches.append(patch)
691
-
692
- todo_errors = validate_todo_markdown(docs_preview.get("todo", ""))
693
- if todo_errors:
694
- self._restore_docs(backups)
695
- raise SpecIngestError("Invalid TODO format: " + "; ".join(todo_errors))
696
-
697
- self._restore_docs(backups)
698
-
699
- patch_text = "\n".join(patches)
700
- if not patch_text.strip():
701
- raise SpecIngestError(
702
- "OpenCode did not make any changes to TODO/PROGRESS/OPINIONS"
703
- )
704
-
705
- self.patch_path.write_text(patch_text, encoding="utf-8")
706
- self.last_agent_message = agent_message
707
-
708
- return self._assemble_response(
709
- docs_preview, patch=patch_text, agent_message=agent_message
710
- )
711
-
712
- async def execute(
713
- self,
714
- *,
715
- force: bool,
716
- spec_path: Optional[Path] = None,
717
- message: Optional[str] = None,
718
- agent: Optional[str] = None,
719
- model: Optional[str] = None,
720
- reasoning: Optional[str] = None,
721
- ) -> Dict[str, str]:
722
- async with self.ingest_lock():
723
- if (agent or "").strip().lower() == "opencode":
724
- return await self._execute_opencode(
725
- force=force,
726
- spec_path=spec_path,
727
- message=message,
728
- model=model,
729
- reasoning=reasoning,
730
- )
731
- return await self._execute_app_server(
732
- force=force,
733
- spec_path=spec_path,
734
- message=message,
735
- model=model,
736
- reasoning=reasoning,
737
- )
738
-
739
-
740
- class SpecIngestPatchParser:
741
- @staticmethod
742
- def parse_agent_message(text: str) -> str:
743
- clean = (text or "").strip()
744
- if not clean:
745
- return "Updated docs via spec ingest."
746
- for line in clean.splitlines():
747
- if line.lower().startswith("agent:"):
748
- return line[len("agent:") :].strip() or "Updated docs via spec ingest."
749
- return clean.splitlines()[0].strip()
750
-
751
- @staticmethod
752
- def strip_code_fences(text: str) -> str:
753
- # Kept for backward compatibility if needed, but likely unused in new flow
754
- lines = text.strip().splitlines()
755
- if (
756
- len(lines) >= 2
757
- and lines[0].startswith("```")
758
- and lines[-1].startswith("```")
759
- ):
760
- return "\n".join(lines[1:-1]).strip()
761
- return text.strip()
762
-
763
- @classmethod
764
- def split_patch(cls, output: str) -> tuple[str, str]:
765
- # Kept for backward compatibility if needed, but likely unused in new flow
766
- if not output:
767
- return "", ""
768
- match = re.search(
769
- r"<PATCH>(.*?)</PATCH>", output, flags=re.IGNORECASE | re.DOTALL
770
- )
771
- if match:
772
- patch_text = cls.strip_code_fences(match.group(1))
773
- before = output[: match.start()].strip()
774
- after = output[match.end() :].strip()
775
- message_text = "\n".join(part for part in [before, after] if part)
776
- return message_text, patch_text
777
- lines = output.splitlines()
778
- start_idx = None
779
- for idx, line in enumerate(lines):
780
- if line.startswith("--- ") or line.startswith("*** Begin Patch"):
781
- start_idx = idx
782
- break
783
- if start_idx is None:
784
- return output.strip(), ""
785
- message_text = "\n".join(lines[:start_idx]).strip()
786
- patch_text = "\n".join(lines[start_idx:]).strip()
787
- patch_text = cls.strip_code_fences(patch_text)
788
- return message_text, patch_text