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