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.
- codex_autorunner/__main__.py +4 -0
- codex_autorunner/agents/__init__.py +20 -0
- codex_autorunner/agents/base.py +2 -2
- codex_autorunner/agents/codex/harness.py +1 -1
- codex_autorunner/agents/opencode/__init__.py +4 -0
- codex_autorunner/agents/opencode/agent_config.py +104 -0
- codex_autorunner/agents/opencode/client.py +305 -28
- codex_autorunner/agents/opencode/harness.py +71 -20
- codex_autorunner/agents/opencode/logging.py +225 -0
- codex_autorunner/agents/opencode/run_prompt.py +261 -0
- codex_autorunner/agents/opencode/runtime.py +1202 -132
- codex_autorunner/agents/opencode/supervisor.py +194 -68
- codex_autorunner/agents/registry.py +258 -0
- codex_autorunner/agents/types.py +2 -2
- codex_autorunner/api.py +25 -0
- codex_autorunner/bootstrap.py +19 -40
- codex_autorunner/cli.py +234 -151
- codex_autorunner/core/about_car.py +44 -32
- codex_autorunner/core/adapter_utils.py +21 -0
- codex_autorunner/core/app_server_events.py +15 -6
- codex_autorunner/core/app_server_logging.py +55 -15
- codex_autorunner/core/app_server_prompts.py +28 -259
- codex_autorunner/core/app_server_threads.py +15 -26
- codex_autorunner/core/circuit_breaker.py +183 -0
- codex_autorunner/core/codex_runner.py +6 -0
- codex_autorunner/core/config.py +555 -133
- codex_autorunner/core/docs.py +54 -9
- codex_autorunner/core/drafts.py +82 -0
- codex_autorunner/core/engine.py +828 -274
- codex_autorunner/core/exceptions.py +60 -0
- codex_autorunner/core/flows/__init__.py +25 -0
- codex_autorunner/core/flows/controller.py +178 -0
- codex_autorunner/core/flows/definition.py +82 -0
- codex_autorunner/core/flows/models.py +75 -0
- codex_autorunner/core/flows/runtime.py +351 -0
- codex_autorunner/core/flows/store.py +485 -0
- codex_autorunner/core/flows/transition.py +133 -0
- codex_autorunner/core/flows/worker_process.py +242 -0
- codex_autorunner/core/hub.py +21 -13
- codex_autorunner/core/locks.py +118 -1
- codex_autorunner/core/logging_utils.py +9 -6
- codex_autorunner/core/path_utils.py +123 -0
- codex_autorunner/core/prompt.py +15 -7
- codex_autorunner/core/redaction.py +29 -0
- codex_autorunner/core/retry.py +61 -0
- codex_autorunner/core/review.py +888 -0
- codex_autorunner/core/review_context.py +161 -0
- codex_autorunner/core/run_index.py +223 -0
- codex_autorunner/core/runner_controller.py +44 -1
- codex_autorunner/core/runner_process.py +30 -1
- codex_autorunner/core/sqlite_utils.py +32 -0
- codex_autorunner/core/state.py +273 -44
- codex_autorunner/core/static_assets.py +55 -0
- codex_autorunner/core/supervisor_utils.py +67 -0
- codex_autorunner/core/text_delta_coalescer.py +43 -0
- codex_autorunner/core/update.py +20 -11
- codex_autorunner/core/update_runner.py +2 -0
- codex_autorunner/core/usage.py +107 -75
- codex_autorunner/core/utils.py +167 -3
- codex_autorunner/discovery.py +3 -3
- codex_autorunner/flows/ticket_flow/__init__.py +3 -0
- codex_autorunner/flows/ticket_flow/definition.py +91 -0
- codex_autorunner/integrations/agents/__init__.py +27 -0
- codex_autorunner/integrations/agents/agent_backend.py +142 -0
- codex_autorunner/integrations/agents/codex_backend.py +307 -0
- codex_autorunner/integrations/agents/opencode_backend.py +325 -0
- codex_autorunner/integrations/agents/run_event.py +71 -0
- codex_autorunner/integrations/app_server/client.py +708 -153
- codex_autorunner/integrations/app_server/supervisor.py +59 -33
- codex_autorunner/integrations/telegram/adapter.py +474 -185
- codex_autorunner/integrations/telegram/api_schemas.py +120 -0
- codex_autorunner/integrations/telegram/config.py +239 -1
- codex_autorunner/integrations/telegram/constants.py +19 -1
- codex_autorunner/integrations/telegram/dispatch.py +44 -8
- codex_autorunner/integrations/telegram/doctor.py +47 -0
- codex_autorunner/integrations/telegram/handlers/approvals.py +12 -10
- codex_autorunner/integrations/telegram/handlers/callbacks.py +15 -1
- codex_autorunner/integrations/telegram/handlers/commands/__init__.py +29 -0
- codex_autorunner/integrations/telegram/handlers/commands/approvals.py +173 -0
- codex_autorunner/integrations/telegram/handlers/commands/execution.py +2595 -0
- codex_autorunner/integrations/telegram/handlers/commands/files.py +1408 -0
- codex_autorunner/integrations/telegram/handlers/commands/flows.py +227 -0
- codex_autorunner/integrations/telegram/handlers/commands/formatting.py +81 -0
- codex_autorunner/integrations/telegram/handlers/commands/github.py +1688 -0
- codex_autorunner/integrations/telegram/handlers/commands/shared.py +190 -0
- codex_autorunner/integrations/telegram/handlers/commands/voice.py +112 -0
- codex_autorunner/integrations/telegram/handlers/commands/workspace.py +2043 -0
- codex_autorunner/integrations/telegram/handlers/commands_runtime.py +954 -5689
- codex_autorunner/integrations/telegram/handlers/{commands.py → commands_spec.py} +11 -4
- codex_autorunner/integrations/telegram/handlers/messages.py +374 -49
- codex_autorunner/integrations/telegram/handlers/questions.py +389 -0
- codex_autorunner/integrations/telegram/handlers/selections.py +6 -4
- codex_autorunner/integrations/telegram/handlers/utils.py +171 -0
- codex_autorunner/integrations/telegram/helpers.py +90 -18
- codex_autorunner/integrations/telegram/notifications.py +126 -35
- codex_autorunner/integrations/telegram/outbox.py +214 -43
- codex_autorunner/integrations/telegram/progress_stream.py +42 -19
- codex_autorunner/integrations/telegram/runtime.py +24 -13
- codex_autorunner/integrations/telegram/service.py +500 -129
- codex_autorunner/integrations/telegram/state.py +1278 -330
- codex_autorunner/integrations/telegram/ticket_flow_bridge.py +322 -0
- codex_autorunner/integrations/telegram/transport.py +37 -4
- codex_autorunner/integrations/telegram/trigger_mode.py +53 -0
- codex_autorunner/integrations/telegram/types.py +22 -2
- codex_autorunner/integrations/telegram/voice.py +14 -15
- codex_autorunner/manifest.py +2 -0
- codex_autorunner/plugin_api.py +22 -0
- codex_autorunner/routes/__init__.py +25 -14
- codex_autorunner/routes/agents.py +18 -78
- codex_autorunner/routes/analytics.py +239 -0
- codex_autorunner/routes/base.py +142 -113
- codex_autorunner/routes/file_chat.py +836 -0
- codex_autorunner/routes/flows.py +980 -0
- codex_autorunner/routes/messages.py +459 -0
- codex_autorunner/routes/repos.py +17 -0
- codex_autorunner/routes/review.py +148 -0
- codex_autorunner/routes/sessions.py +16 -8
- codex_autorunner/routes/settings.py +22 -0
- codex_autorunner/routes/shared.py +33 -3
- codex_autorunner/routes/system.py +22 -1
- codex_autorunner/routes/usage.py +87 -0
- codex_autorunner/routes/voice.py +5 -13
- codex_autorunner/routes/workspace.py +271 -0
- codex_autorunner/server.py +2 -1
- codex_autorunner/static/agentControls.js +9 -1
- codex_autorunner/static/agentEvents.js +248 -0
- codex_autorunner/static/app.js +27 -22
- codex_autorunner/static/autoRefresh.js +29 -1
- codex_autorunner/static/bootstrap.js +1 -0
- codex_autorunner/static/bus.js +1 -0
- codex_autorunner/static/cache.js +1 -0
- codex_autorunner/static/constants.js +20 -4
- codex_autorunner/static/dashboard.js +162 -150
- codex_autorunner/static/diffRenderer.js +37 -0
- codex_autorunner/static/docChatCore.js +324 -0
- codex_autorunner/static/docChatStorage.js +65 -0
- codex_autorunner/static/docChatVoice.js +65 -0
- codex_autorunner/static/docEditor.js +133 -0
- codex_autorunner/static/env.js +1 -0
- codex_autorunner/static/eventSummarizer.js +166 -0
- codex_autorunner/static/fileChat.js +182 -0
- codex_autorunner/static/health.js +155 -0
- codex_autorunner/static/hub.js +67 -126
- codex_autorunner/static/index.html +788 -807
- codex_autorunner/static/liveUpdates.js +59 -0
- codex_autorunner/static/loader.js +1 -0
- codex_autorunner/static/messages.js +470 -0
- codex_autorunner/static/mobileCompact.js +2 -1
- codex_autorunner/static/settings.js +24 -205
- codex_autorunner/static/styles.css +7577 -3758
- codex_autorunner/static/tabs.js +28 -5
- codex_autorunner/static/terminal.js +14 -0
- codex_autorunner/static/terminalManager.js +53 -59
- codex_autorunner/static/ticketChatActions.js +333 -0
- codex_autorunner/static/ticketChatEvents.js +16 -0
- codex_autorunner/static/ticketChatStorage.js +16 -0
- codex_autorunner/static/ticketChatStream.js +264 -0
- codex_autorunner/static/ticketEditor.js +750 -0
- codex_autorunner/static/ticketVoice.js +9 -0
- codex_autorunner/static/tickets.js +1315 -0
- codex_autorunner/static/utils.js +32 -3
- codex_autorunner/static/voice.js +21 -7
- codex_autorunner/static/workspace.js +672 -0
- codex_autorunner/static/workspaceApi.js +53 -0
- codex_autorunner/static/workspaceFileBrowser.js +504 -0
- codex_autorunner/tickets/__init__.py +20 -0
- codex_autorunner/tickets/agent_pool.py +377 -0
- codex_autorunner/tickets/files.py +85 -0
- codex_autorunner/tickets/frontmatter.py +55 -0
- codex_autorunner/tickets/lint.py +102 -0
- codex_autorunner/tickets/models.py +95 -0
- codex_autorunner/tickets/outbox.py +232 -0
- codex_autorunner/tickets/replies.py +179 -0
- codex_autorunner/tickets/runner.py +823 -0
- codex_autorunner/tickets/spec_ingest.py +77 -0
- codex_autorunner/voice/capture.py +7 -7
- codex_autorunner/voice/service.py +51 -9
- codex_autorunner/web/app.py +419 -199
- codex_autorunner/web/hub_jobs.py +13 -2
- codex_autorunner/web/middleware.py +47 -13
- codex_autorunner/web/pty_session.py +26 -13
- codex_autorunner/web/schemas.py +114 -109
- codex_autorunner/web/static_assets.py +55 -42
- codex_autorunner/web/static_refresh.py +86 -0
- codex_autorunner/workspace/__init__.py +40 -0
- codex_autorunner/workspace/paths.py +319 -0
- {codex_autorunner-0.1.1.dist-info → codex_autorunner-1.0.0.dist-info}/METADATA +20 -21
- codex_autorunner-1.0.0.dist-info/RECORD +251 -0
- {codex_autorunner-0.1.1.dist-info → codex_autorunner-1.0.0.dist-info}/WHEEL +1 -1
- codex_autorunner/core/doc_chat.py +0 -1415
- codex_autorunner/core/snapshot.py +0 -580
- codex_autorunner/integrations/github/chatops.py +0 -268
- codex_autorunner/integrations/github/pr_flow.py +0 -1314
- codex_autorunner/routes/docs.py +0 -381
- codex_autorunner/routes/github.py +0 -327
- codex_autorunner/routes/runs.py +0 -118
- codex_autorunner/spec_ingest.py +0 -788
- codex_autorunner/static/docChatActions.js +0 -279
- codex_autorunner/static/docChatEvents.js +0 -300
- codex_autorunner/static/docChatRender.js +0 -205
- codex_autorunner/static/docChatStream.js +0 -361
- codex_autorunner/static/docs.js +0 -20
- codex_autorunner/static/docsClipboard.js +0 -69
- codex_autorunner/static/docsCrud.js +0 -257
- codex_autorunner/static/docsDocUpdates.js +0 -62
- codex_autorunner/static/docsDrafts.js +0 -16
- codex_autorunner/static/docsElements.js +0 -69
- codex_autorunner/static/docsInit.js +0 -274
- codex_autorunner/static/docsParse.js +0 -160
- codex_autorunner/static/docsSnapshot.js +0 -87
- codex_autorunner/static/docsSpecIngest.js +0 -263
- codex_autorunner/static/docsState.js +0 -127
- codex_autorunner/static/docsThreadRegistry.js +0 -44
- codex_autorunner/static/docsUi.js +0 -153
- codex_autorunner/static/docsVoice.js +0 -56
- codex_autorunner/static/github.js +0 -442
- codex_autorunner/static/logs.js +0 -640
- codex_autorunner/static/runs.js +0 -409
- codex_autorunner/static/snapshot.js +0 -124
- codex_autorunner/static/state.js +0 -86
- codex_autorunner/static/todoPreview.js +0 -27
- codex_autorunner/workspace.py +0 -16
- codex_autorunner-0.1.1.dist-info/RECORD +0 -191
- {codex_autorunner-0.1.1.dist-info → codex_autorunner-1.0.0.dist-info}/entry_points.txt +0 -0
- {codex_autorunner-0.1.1.dist-info → codex_autorunner-1.0.0.dist-info}/licenses/LICENSE +0 -0
- {codex_autorunner-0.1.1.dist-info → codex_autorunner-1.0.0.dist-info}/top_level.txt +0 -0
codex_autorunner/spec_ingest.py
DELETED
|
@@ -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
|