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