remote-coder 0.4.1__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.
- app/__init__.py +3 -0
- app/admin/__init__.py +0 -0
- app/admin/advanced_settings.py +88 -0
- app/admin/database_browser.py +301 -0
- app/admin/router.py +528 -0
- app/admin/static/i18n.js +401 -0
- app/admin/static/icons/advanced.svg +8 -0
- app/admin/static/icons/database.svg +5 -0
- app/admin/static/icons/download.svg +3 -0
- app/admin/static/icons/home.svg +4 -0
- app/admin/static/icons/logs.svg +3 -0
- app/admin/static/icons/projects.svg +5 -0
- app/admin/static/summary.js +73 -0
- app/admin/templates/admin.html +511 -0
- app/admin/templates/advanced.html +635 -0
- app/admin/templates/database.html +880 -0
- app/admin/templates/logs.html +686 -0
- app/admin/templates/projects.html +878 -0
- app/ai/__init__.py +0 -0
- app/ai/base.py +129 -0
- app/ai/claude.py +20 -0
- app/ai/codex.py +34 -0
- app/ai/factory.py +27 -0
- app/ai/gemini.py +20 -0
- app/ai/model_catalog.py +47 -0
- app/ai/usage.py +134 -0
- app/cli.py +238 -0
- app/config.py +130 -0
- app/git/__init__.py +0 -0
- app/git/ai_commit.py +88 -0
- app/git/branch_naming.py +21 -0
- app/git/commit_message.py +279 -0
- app/git/service.py +669 -0
- app/jobs/__init__.py +0 -0
- app/jobs/manager.py +770 -0
- app/jobs/schemas.py +116 -0
- app/jobs/store.py +334 -0
- app/main.py +265 -0
- app/models.py +20 -0
- app/monitoring/__init__.py +10 -0
- app/monitoring/code.py +161 -0
- app/monitoring/events.py +33 -0
- app/monitoring/git.py +103 -0
- app/monitoring/log_buffer.py +245 -0
- app/monitoring/memory.py +19 -0
- app/monitoring/model.py +598 -0
- app/projects/__init__.py +19 -0
- app/projects/registry.py +384 -0
- app/security/__init__.py +0 -0
- app/security/auth.py +19 -0
- app/system_startup.py +34 -0
- app/telegram/__init__.py +0 -0
- app/telegram/bot_instances.py +67 -0
- app/telegram/commands/__init__.py +64 -0
- app/telegram/commands/base.py +222 -0
- app/telegram/commands/branch.py +366 -0
- app/telegram/commands/clear_stop.py +221 -0
- app/telegram/commands/fix.py +219 -0
- app/telegram/commands/model.py +93 -0
- app/telegram/commands/monitor.py +185 -0
- app/telegram/commands/registry.py +110 -0
- app/telegram/commands/status.py +243 -0
- app/telegram/commands/system.py +201 -0
- app/telegram/confirmations.py +36 -0
- app/telegram/conversation.py +789 -0
- app/telegram/i18n.py +742 -0
- app/telegram/model_preferences.py +53 -0
- app/telegram/notifier.py +387 -0
- app/telegram/parser.py +267 -0
- app/telegram/webhook.py +988 -0
- app/telegram/webhook_registration.py +172 -0
- app/tunnel.py +104 -0
- remote_coder-0.4.1.dist-info/METADATA +520 -0
- remote_coder-0.4.1.dist-info/RECORD +78 -0
- remote_coder-0.4.1.dist-info/WHEEL +5 -0
- remote_coder-0.4.1.dist-info/entry_points.txt +2 -0
- remote_coder-0.4.1.dist-info/licenses/LICENSE +201 -0
- remote_coder-0.4.1.dist-info/top_level.txt +1 -0
app/jobs/manager.py
ADDED
|
@@ -0,0 +1,770 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
import threading
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from datetime import UTC, datetime
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from uuid import uuid4
|
|
10
|
+
|
|
11
|
+
from app.admin.advanced_settings import FileAdvancedSettingsStore
|
|
12
|
+
from app.ai.base import RunnerInput
|
|
13
|
+
from app.ai.factory import AiRunnerFactory
|
|
14
|
+
from app.ai.usage import extract_runner_usage
|
|
15
|
+
from app.config import Settings
|
|
16
|
+
from app.git.ai_commit import AiCommitBodyGenerator
|
|
17
|
+
from app.git.branch_naming import BranchNamingStrategy
|
|
18
|
+
from app.git.commit_message import CommitMessageFormatter
|
|
19
|
+
from app.git.service import GitWorktreeService
|
|
20
|
+
from app.jobs.schemas import FixKind, Job, JobMode, JobRequest, JobStatus
|
|
21
|
+
from app.jobs.store import JobStore
|
|
22
|
+
from app.monitoring.events import EventLogger
|
|
23
|
+
from app.projects.registry import ProjectRegistry
|
|
24
|
+
from app.telegram.notifier import Notifier
|
|
25
|
+
|
|
26
|
+
_joblog = EventLogger("app.jobs.lifecycle", "job.lifecycle")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class _WorktreePlan:
|
|
31
|
+
path: Path
|
|
32
|
+
created_for_job: bool
|
|
33
|
+
on_branch: bool
|
|
34
|
+
commit_to_requested_branch: bool
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class JobManager:
|
|
38
|
+
_ANSI_ESCAPE_PATTERN = re.compile(r"\x1B\[[0-?]*[ -/]*[@-~]")
|
|
39
|
+
_MD_LINK_PATTERN = re.compile(r"\[([^\]]*)\]\([^)]+\)")
|
|
40
|
+
_HTTP_URL_PATTERN = re.compile(r"https?://[^\s\]\)>,]+", flags=re.IGNORECASE)
|
|
41
|
+
_WWW_URL_PATTERN = re.compile(r"\bwww\.[^\s\]\)>,]+", flags=re.IGNORECASE)
|
|
42
|
+
_STDOUT_SUMMARY_LIMIT = 12000
|
|
43
|
+
_STDERR_SUMMARY_LIMIT = 800
|
|
44
|
+
|
|
45
|
+
def __init__(
|
|
46
|
+
self,
|
|
47
|
+
settings: Settings,
|
|
48
|
+
job_store: JobStore,
|
|
49
|
+
git_service: GitWorktreeService,
|
|
50
|
+
runner_factory: AiRunnerFactory,
|
|
51
|
+
branch_strategy: BranchNamingStrategy,
|
|
52
|
+
notifier_resolver: Callable[[str], Notifier],
|
|
53
|
+
project_registry: ProjectRegistry,
|
|
54
|
+
advanced_settings_store: FileAdvancedSettingsStore | None = None,
|
|
55
|
+
ai_commit_body_generator: AiCommitBodyGenerator | None = None,
|
|
56
|
+
) -> None:
|
|
57
|
+
self._settings = settings
|
|
58
|
+
self._job_store = job_store
|
|
59
|
+
self._git_service = git_service
|
|
60
|
+
self._runner_factory = runner_factory
|
|
61
|
+
self._branch_strategy = branch_strategy
|
|
62
|
+
self._notifier_resolver = notifier_resolver
|
|
63
|
+
self._project_registry = project_registry
|
|
64
|
+
self._advanced_settings_store = advanced_settings_store
|
|
65
|
+
self._ai_commit_body_generator = ai_commit_body_generator
|
|
66
|
+
self._cancel_events: dict[str, threading.Event] = {}
|
|
67
|
+
self._cancelled_job_ids: set[str] = set()
|
|
68
|
+
|
|
69
|
+
def _notifier_for(self, project: str) -> Notifier:
|
|
70
|
+
return self._notifier_resolver(project)
|
|
71
|
+
|
|
72
|
+
@staticmethod
|
|
73
|
+
def _job_ctx(job: Job) -> dict[str, object]:
|
|
74
|
+
return {
|
|
75
|
+
"chat_id": job.request.chat_id,
|
|
76
|
+
"user_id": job.request.requested_by,
|
|
77
|
+
"project": job.request.project,
|
|
78
|
+
"job_id": job.id,
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
@staticmethod
|
|
82
|
+
def _message_id_or_none(value: object) -> int | None:
|
|
83
|
+
return value if isinstance(value, int) else None
|
|
84
|
+
|
|
85
|
+
@staticmethod
|
|
86
|
+
def _message_ids_or_empty(value: object) -> list[int]:
|
|
87
|
+
if not isinstance(value, list):
|
|
88
|
+
return []
|
|
89
|
+
return [item for item in value if isinstance(item, int)]
|
|
90
|
+
|
|
91
|
+
def _send_result(self, job: Job) -> None:
|
|
92
|
+
job.result_message_ids = self._message_ids_or_empty(
|
|
93
|
+
self._notifier_for(job.request.project).send_job_result(job)
|
|
94
|
+
)
|
|
95
|
+
self._job_store.update(job)
|
|
96
|
+
|
|
97
|
+
def submit(self, request: JobRequest) -> Job:
|
|
98
|
+
job = Job(id=request.job_id or self._make_job_id(), request=request)
|
|
99
|
+
self._job_store.create(job)
|
|
100
|
+
_joblog.info(
|
|
101
|
+
"submitted model=%s model_id=%s",
|
|
102
|
+
request.model.value,
|
|
103
|
+
request.model_id or "-",
|
|
104
|
+
**self._job_ctx(job),
|
|
105
|
+
)
|
|
106
|
+
accepted_message_id = self._message_id_or_none(
|
|
107
|
+
self._notifier_for(request.project).send_job_accepted(job)
|
|
108
|
+
)
|
|
109
|
+
if accepted_message_id is not None:
|
|
110
|
+
job.accepted_message_id = accepted_message_id
|
|
111
|
+
self._job_store.update(job)
|
|
112
|
+
return job
|
|
113
|
+
|
|
114
|
+
def cancel(self, job_id: str) -> bool:
|
|
115
|
+
job = self._job_store.get(job_id)
|
|
116
|
+
if not job:
|
|
117
|
+
_joblog.warning("cancel requested for missing job job_id=%s", job_id)
|
|
118
|
+
return False
|
|
119
|
+
if job.status.value not in ("queued", "running"):
|
|
120
|
+
_joblog.info("cancel skipped status=%s", job.status.value, **self._job_ctx(job))
|
|
121
|
+
return False
|
|
122
|
+
self._cancelled_job_ids.add(job_id)
|
|
123
|
+
job.mark_cancelled()
|
|
124
|
+
self._job_store.update(job)
|
|
125
|
+
_joblog.info("cancelled", **self._job_ctx(job))
|
|
126
|
+
event = self._cancel_events.get(job_id)
|
|
127
|
+
if event is not None:
|
|
128
|
+
event.set()
|
|
129
|
+
return True
|
|
130
|
+
|
|
131
|
+
def _prepare_worktree_plan(
|
|
132
|
+
self, job: Job, project_path: Path, worktree_base: Path
|
|
133
|
+
) -> _WorktreePlan:
|
|
134
|
+
requested_branch = job.request.branch
|
|
135
|
+
if job.request.mode in (JobMode.PLAN, JobMode.ASK):
|
|
136
|
+
path = self._git_service.prepare_detached_worktree(
|
|
137
|
+
project_path, job.id, worktree_base_dir=worktree_base
|
|
138
|
+
)
|
|
139
|
+
_joblog.info(
|
|
140
|
+
"created detached worktree mode=%s worktree=%s",
|
|
141
|
+
job.request.mode.value,
|
|
142
|
+
path.name,
|
|
143
|
+
**self._job_ctx(job),
|
|
144
|
+
)
|
|
145
|
+
return _WorktreePlan(path, created_for_job=True, on_branch=False, commit_to_requested_branch=False)
|
|
146
|
+
|
|
147
|
+
if requested_branch and self._git_service.local_branch_exists(project_path, requested_branch):
|
|
148
|
+
_joblog.info("requested branch exists branch=%s", requested_branch, **self._job_ctx(job))
|
|
149
|
+
existing_worktree = self._git_service.find_linked_worktree_for_branch(
|
|
150
|
+
project_path, requested_branch
|
|
151
|
+
)
|
|
152
|
+
if existing_worktree is not None:
|
|
153
|
+
_joblog.info(
|
|
154
|
+
"reuse linked worktree branch=%s worktree=%s",
|
|
155
|
+
requested_branch,
|
|
156
|
+
existing_worktree.name,
|
|
157
|
+
**self._job_ctx(job),
|
|
158
|
+
)
|
|
159
|
+
return _WorktreePlan(
|
|
160
|
+
existing_worktree, created_for_job=False, on_branch=True, commit_to_requested_branch=True
|
|
161
|
+
)
|
|
162
|
+
if self._git_service.branch_is_checked_out(project_path, requested_branch):
|
|
163
|
+
path = self._git_service.prepare_detached_worktree(
|
|
164
|
+
project_path, job.id, worktree_base_dir=worktree_base, base_branch=requested_branch
|
|
165
|
+
)
|
|
166
|
+
_joblog.info(
|
|
167
|
+
"created detached worktree from checked-out branch branch=%s worktree=%s",
|
|
168
|
+
requested_branch,
|
|
169
|
+
path.name,
|
|
170
|
+
**self._job_ctx(job),
|
|
171
|
+
)
|
|
172
|
+
return _WorktreePlan(
|
|
173
|
+
path, created_for_job=True, on_branch=False, commit_to_requested_branch=False
|
|
174
|
+
)
|
|
175
|
+
path = self._git_service.prepare_branch_worktree(
|
|
176
|
+
project_path, requested_branch, job.id, worktree_base_dir=worktree_base
|
|
177
|
+
)
|
|
178
|
+
_joblog.info(
|
|
179
|
+
"created branch worktree branch=%s worktree=%s",
|
|
180
|
+
requested_branch,
|
|
181
|
+
path.name,
|
|
182
|
+
**self._job_ctx(job),
|
|
183
|
+
)
|
|
184
|
+
return _WorktreePlan(path, created_for_job=True, on_branch=True, commit_to_requested_branch=True)
|
|
185
|
+
|
|
186
|
+
path = self._git_service.prepare_detached_worktree(
|
|
187
|
+
project_path, job.id, worktree_base_dir=worktree_base
|
|
188
|
+
)
|
|
189
|
+
_joblog.info(
|
|
190
|
+
"created detached worktree requested_branch=%s worktree=%s",
|
|
191
|
+
requested_branch or "-",
|
|
192
|
+
path.name,
|
|
193
|
+
**self._job_ctx(job),
|
|
194
|
+
)
|
|
195
|
+
return _WorktreePlan(
|
|
196
|
+
path,
|
|
197
|
+
created_for_job=True,
|
|
198
|
+
on_branch=False,
|
|
199
|
+
commit_to_requested_branch=requested_branch is not None,
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
def run(self, job_id: str) -> Job:
|
|
203
|
+
job = self._job_store.get(job_id)
|
|
204
|
+
if not job:
|
|
205
|
+
_joblog.warning("run requested for missing job job_id=%s", job_id)
|
|
206
|
+
raise ValueError("job not found")
|
|
207
|
+
|
|
208
|
+
if job_id in self._cancelled_job_ids:
|
|
209
|
+
if job.status.value != "cancelled":
|
|
210
|
+
job.mark_cancelled()
|
|
211
|
+
self._job_store.update(job)
|
|
212
|
+
self._send_result(job)
|
|
213
|
+
return job
|
|
214
|
+
|
|
215
|
+
cancel_event = threading.Event()
|
|
216
|
+
self._cancel_events[job_id] = cancel_event
|
|
217
|
+
_joblog.info("cancel event registered", **self._job_ctx(job))
|
|
218
|
+
|
|
219
|
+
entry = self._project_registry.get(job.request.project)
|
|
220
|
+
if not entry or not entry.enabled:
|
|
221
|
+
_joblog.warning("unknown/disabled project", **self._job_ctx(job))
|
|
222
|
+
job.mark_failed("unknown or disabled project")
|
|
223
|
+
job.error_stage = "project_resolve"
|
|
224
|
+
self._job_store.update(job)
|
|
225
|
+
self._send_result(job)
|
|
226
|
+
return job
|
|
227
|
+
|
|
228
|
+
project_path = entry.root_path
|
|
229
|
+
worktree_base = entry.worktree_base_dir
|
|
230
|
+
_joblog.info(
|
|
231
|
+
"project resolved default_model=%s worktree_base=%s",
|
|
232
|
+
entry.default_model.value,
|
|
233
|
+
worktree_base.name,
|
|
234
|
+
**self._job_ctx(job),
|
|
235
|
+
)
|
|
236
|
+
worktree_path: Path | None = None
|
|
237
|
+
created_worktree_for_job = False
|
|
238
|
+
failed_stage: str | None = None
|
|
239
|
+
remote = self._settings.git_remote_name
|
|
240
|
+
read_only_job = job.request.mode in (JobMode.PLAN, JobMode.ASK)
|
|
241
|
+
try:
|
|
242
|
+
job.mark_running()
|
|
243
|
+
self._job_store.update(job)
|
|
244
|
+
_joblog.info("running", **self._job_ctx(job))
|
|
245
|
+
|
|
246
|
+
failed_stage = "git_worktree"
|
|
247
|
+
_joblog.info("stage=git_worktree", **self._job_ctx(job))
|
|
248
|
+
plan = self._prepare_worktree_plan(job, project_path, worktree_base)
|
|
249
|
+
worktree_path = plan.path
|
|
250
|
+
created_worktree_for_job = plan.created_for_job
|
|
251
|
+
worktree_on_branch = plan.on_branch
|
|
252
|
+
commit_to_requested_branch = plan.commit_to_requested_branch
|
|
253
|
+
self._git_service.ensure_worktree_writable(worktree_path)
|
|
254
|
+
_joblog.info("worktree writable", **self._job_ctx(job))
|
|
255
|
+
|
|
256
|
+
failed_stage = "runner"
|
|
257
|
+
_joblog.info(
|
|
258
|
+
"stage=runner model=%s model_id=%s",
|
|
259
|
+
job.request.model.value,
|
|
260
|
+
job.request.model_id or "-",
|
|
261
|
+
**self._job_ctx(job),
|
|
262
|
+
)
|
|
263
|
+
runner = self._runner_factory.create(job.request.model)
|
|
264
|
+
timeout_seconds = self._effective_job_timeout_seconds()
|
|
265
|
+
_joblog.info(
|
|
266
|
+
"runner created name=%s timeout=%d instruction_len=%d",
|
|
267
|
+
getattr(runner, "name", job.request.model.value),
|
|
268
|
+
timeout_seconds,
|
|
269
|
+
len(job.request.instruction),
|
|
270
|
+
**self._job_ctx(job),
|
|
271
|
+
)
|
|
272
|
+
runner_result = runner.run(
|
|
273
|
+
RunnerInput(
|
|
274
|
+
instruction=job.request.instruction,
|
|
275
|
+
cwd=worktree_path,
|
|
276
|
+
timeout_seconds=timeout_seconds,
|
|
277
|
+
model_id=job.request.model_id,
|
|
278
|
+
env=None,
|
|
279
|
+
cancel_event=cancel_event,
|
|
280
|
+
mode=job.request.mode,
|
|
281
|
+
)
|
|
282
|
+
)
|
|
283
|
+
self._save_runner_log(job, runner_result, worktree_base)
|
|
284
|
+
_joblog.info(
|
|
285
|
+
"runner exit=%d stdout_len=%d stderr_len=%d",
|
|
286
|
+
runner_result.exit_code,
|
|
287
|
+
len(runner_result.stdout),
|
|
288
|
+
len(runner_result.stderr),
|
|
289
|
+
**self._job_ctx(job),
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
if runner_result.exit_code != 0:
|
|
293
|
+
raise RuntimeError(runner_result.stderr.strip() or "runner failed")
|
|
294
|
+
|
|
295
|
+
if read_only_job:
|
|
296
|
+
job.branch = None
|
|
297
|
+
job.commit_hash = None
|
|
298
|
+
job.changed_files = []
|
|
299
|
+
job.mark_succeeded()
|
|
300
|
+
self._job_store.update(job)
|
|
301
|
+
_joblog.info(
|
|
302
|
+
"succeeded read_only mode=%s", job.request.mode.value, **self._job_ctx(job)
|
|
303
|
+
)
|
|
304
|
+
else:
|
|
305
|
+
failed_stage = "git_commit"
|
|
306
|
+
_joblog.info("stage=git_commit", **self._job_ctx(job))
|
|
307
|
+
job.changed_files = self._git_service.collect_changes(worktree_path)
|
|
308
|
+
_joblog.info("changes=%d", len(job.changed_files), **self._job_ctx(job))
|
|
309
|
+
|
|
310
|
+
if not job.changed_files:
|
|
311
|
+
job.branch = None
|
|
312
|
+
job.commit_hash = None
|
|
313
|
+
job.mark_succeeded()
|
|
314
|
+
self._job_store.update(job)
|
|
315
|
+
_joblog.info("succeeded branch=%s commit=%s", "-", "-", **self._job_ctx(job))
|
|
316
|
+
else:
|
|
317
|
+
job.branch = (
|
|
318
|
+
job.request.branch
|
|
319
|
+
if commit_to_requested_branch
|
|
320
|
+
else self._branch_strategy.make_branch_name(job.request.instruction)
|
|
321
|
+
)
|
|
322
|
+
self._job_store.update(job)
|
|
323
|
+
_joblog.info(
|
|
324
|
+
"branch selected branch=%s requested=%s",
|
|
325
|
+
job.branch,
|
|
326
|
+
commit_to_requested_branch,
|
|
327
|
+
**self._job_ctx(job),
|
|
328
|
+
)
|
|
329
|
+
if not worktree_on_branch:
|
|
330
|
+
self._git_service.create_branch_in_worktree(worktree_path, job.branch)
|
|
331
|
+
worktree_on_branch = True
|
|
332
|
+
_joblog.info(
|
|
333
|
+
"branch created in worktree branch=%s", job.branch, **self._job_ctx(job)
|
|
334
|
+
)
|
|
335
|
+
job.changed_files = self._git_service.collect_changes(worktree_path)
|
|
336
|
+
|
|
337
|
+
if job.request.commit:
|
|
338
|
+
ai_title = None
|
|
339
|
+
ai_body = None
|
|
340
|
+
if self._ai_commit_body_generator is not None:
|
|
341
|
+
ai_title, ai_body = self._ai_commit_body_generator.generate(
|
|
342
|
+
instruction=job.request.instruction,
|
|
343
|
+
changed_files=job.changed_files,
|
|
344
|
+
model_name=job.request.model,
|
|
345
|
+
)
|
|
346
|
+
commit_message = CommitMessageFormatter.format(
|
|
347
|
+
job_id=job.id,
|
|
348
|
+
instruction=job.request.instruction,
|
|
349
|
+
changed_files=job.changed_files,
|
|
350
|
+
ai_body=ai_body,
|
|
351
|
+
ai_title=ai_title,
|
|
352
|
+
)
|
|
353
|
+
_joblog.info(
|
|
354
|
+
"commit message ready changed_files=%d ai_title=%s ai_body=%s",
|
|
355
|
+
len(job.changed_files),
|
|
356
|
+
ai_title is not None,
|
|
357
|
+
ai_body is not None,
|
|
358
|
+
**self._job_ctx(job),
|
|
359
|
+
)
|
|
360
|
+
job.commit_hash = self._git_service.commit_all(worktree_path, commit_message)
|
|
361
|
+
_joblog.info(
|
|
362
|
+
"commit result hash=%s", job.commit_hash or "-", **self._job_ctx(job)
|
|
363
|
+
)
|
|
364
|
+
else:
|
|
365
|
+
job.commit_hash = None
|
|
366
|
+
_joblog.info("commit skipped by request", **self._job_ctx(job))
|
|
367
|
+
|
|
368
|
+
if job.request.commit and job.commit_hash:
|
|
369
|
+
failed_stage = "git_push"
|
|
370
|
+
_joblog.info("stage=git_push", **self._job_ctx(job))
|
|
371
|
+
self._git_service.push_branch(project_path, remote, job.branch)
|
|
372
|
+
|
|
373
|
+
if (
|
|
374
|
+
self._advanced_settings_store is not None
|
|
375
|
+
and self._advanced_settings_store.get().auto_merge_to_main_enabled
|
|
376
|
+
and job.request.commit
|
|
377
|
+
and job.commit_hash
|
|
378
|
+
and job.branch
|
|
379
|
+
):
|
|
380
|
+
failed_stage = "git_integrate_main"
|
|
381
|
+
_joblog.info("stage=git_integrate_main", **self._job_ctx(job))
|
|
382
|
+
ops_base = worktree_base / "_rebase_ops"
|
|
383
|
+
self._git_service.rebase_branch_onto_main_and_merge(
|
|
384
|
+
project_path,
|
|
385
|
+
job.branch,
|
|
386
|
+
remote,
|
|
387
|
+
ops_base,
|
|
388
|
+
)
|
|
389
|
+
|
|
390
|
+
job.mark_succeeded()
|
|
391
|
+
self._job_store.update(job)
|
|
392
|
+
_joblog.info(
|
|
393
|
+
"succeeded branch=%s commit=%s",
|
|
394
|
+
job.branch or "-",
|
|
395
|
+
job.commit_hash or "-",
|
|
396
|
+
**self._job_ctx(job),
|
|
397
|
+
)
|
|
398
|
+
except Exception as exc: # pylint: disable=broad-except
|
|
399
|
+
if job_id in self._cancelled_job_ids:
|
|
400
|
+
_joblog.info("runner stopped by cancellation", **self._job_ctx(job))
|
|
401
|
+
if job.status.value != "cancelled":
|
|
402
|
+
job.mark_cancelled()
|
|
403
|
+
self._job_store.update(job)
|
|
404
|
+
else:
|
|
405
|
+
_joblog.exception(
|
|
406
|
+
"failed stage=%s: %s",
|
|
407
|
+
failed_stage or "unknown",
|
|
408
|
+
exc,
|
|
409
|
+
**self._job_ctx(job),
|
|
410
|
+
)
|
|
411
|
+
job.mark_failed(str(exc))
|
|
412
|
+
job.error_stage = failed_stage or "unknown"
|
|
413
|
+
self._job_store.update(job)
|
|
414
|
+
finally:
|
|
415
|
+
self._cancel_events.pop(job_id, None)
|
|
416
|
+
self._cancelled_job_ids.discard(job_id)
|
|
417
|
+
read_only_succeeded = (
|
|
418
|
+
job.request.mode in (JobMode.PLAN, JobMode.ASK) and job.status.value == "succeeded"
|
|
419
|
+
)
|
|
420
|
+
cleanup_on_success = read_only_succeeded or not self._settings.keep_worktree_on_success
|
|
421
|
+
_joblog.info(
|
|
422
|
+
"job finalizing status=%s created_worktree=%s cleanup_on_success=%s",
|
|
423
|
+
job.status.value,
|
|
424
|
+
created_worktree_for_job,
|
|
425
|
+
cleanup_on_success,
|
|
426
|
+
**self._job_ctx(job),
|
|
427
|
+
)
|
|
428
|
+
if (
|
|
429
|
+
worktree_path
|
|
430
|
+
and created_worktree_for_job
|
|
431
|
+
and job.status.value == "succeeded"
|
|
432
|
+
and cleanup_on_success
|
|
433
|
+
):
|
|
434
|
+
try:
|
|
435
|
+
self._git_service.cleanup_worktree(project_path, worktree_path)
|
|
436
|
+
_joblog.info("worktree cleanup done", **self._job_ctx(job))
|
|
437
|
+
except RuntimeError as exc:
|
|
438
|
+
# cleanup 실패로 성공 Job 알림이 누락되지 않도록 삼킵니다.
|
|
439
|
+
_joblog.warning(
|
|
440
|
+
"worktree cleanup failed but result notification continues: %s",
|
|
441
|
+
exc,
|
|
442
|
+
**self._job_ctx(job),
|
|
443
|
+
)
|
|
444
|
+
self._send_result(job)
|
|
445
|
+
return job
|
|
446
|
+
|
|
447
|
+
def is_fix_candidate(self, job: Job, project: str, chat_id: int) -> bool:
|
|
448
|
+
return (
|
|
449
|
+
job.request.project == project
|
|
450
|
+
and job.request.chat_id == chat_id
|
|
451
|
+
and job.status == JobStatus.SUCCEEDED
|
|
452
|
+
and bool(job.branch)
|
|
453
|
+
and bool(job.commit_hash)
|
|
454
|
+
)
|
|
455
|
+
|
|
456
|
+
def list_fix_candidates(self, project: str, chat_id: int, limit: int = 8) -> list[Job]:
|
|
457
|
+
return [
|
|
458
|
+
job
|
|
459
|
+
for job in self._job_store.list_recent_for_project_chat(project, chat_id, limit * 4)
|
|
460
|
+
if self.is_fix_candidate(job, project, chat_id)
|
|
461
|
+
][:limit]
|
|
462
|
+
|
|
463
|
+
def build_fix_commit_preview(self, parent_job: Job) -> str:
|
|
464
|
+
ai_title = None
|
|
465
|
+
ai_body = None
|
|
466
|
+
if self._ai_commit_body_generator is not None:
|
|
467
|
+
ai_title, ai_body = self._ai_commit_body_generator.generate(
|
|
468
|
+
instruction=parent_job.request.instruction,
|
|
469
|
+
changed_files=parent_job.changed_files,
|
|
470
|
+
model_name=parent_job.request.model,
|
|
471
|
+
)
|
|
472
|
+
return CommitMessageFormatter.format(
|
|
473
|
+
job_id=parent_job.id,
|
|
474
|
+
instruction=parent_job.request.instruction,
|
|
475
|
+
changed_files=parent_job.changed_files,
|
|
476
|
+
ai_body=ai_body,
|
|
477
|
+
ai_title=ai_title,
|
|
478
|
+
)
|
|
479
|
+
|
|
480
|
+
@staticmethod
|
|
481
|
+
def compose_fix_source_prompt(parent_job: Job, fix_instruction: str) -> str:
|
|
482
|
+
original_files = (
|
|
483
|
+
"\n".join(f"- {path}" for path in parent_job.changed_files)
|
|
484
|
+
if parent_job.changed_files
|
|
485
|
+
else "(none)"
|
|
486
|
+
)
|
|
487
|
+
return (
|
|
488
|
+
"[Original request]\n"
|
|
489
|
+
f"{parent_job.request.instruction.strip()}\n\n"
|
|
490
|
+
"[Original changed files]\n"
|
|
491
|
+
f"{original_files}\n\n"
|
|
492
|
+
"[User follow-up fix request]\n"
|
|
493
|
+
f"{fix_instruction.strip()}\n\n"
|
|
494
|
+
"Apply the requested fix on top of the existing work. "
|
|
495
|
+
"Do not add new files or unrelated changes."
|
|
496
|
+
)
|
|
497
|
+
|
|
498
|
+
def execute_fix_job(
|
|
499
|
+
self,
|
|
500
|
+
request: JobRequest,
|
|
501
|
+
prepared_message: str | None = None,
|
|
502
|
+
) -> Job:
|
|
503
|
+
if request.mode is not JobMode.AGENT_FIX:
|
|
504
|
+
raise ValueError("execute_fix_job requires JobMode.AGENT_FIX")
|
|
505
|
+
if request.fix_kind is None:
|
|
506
|
+
raise ValueError("execute_fix_job requires fix_kind")
|
|
507
|
+
if not request.parent_job_id:
|
|
508
|
+
raise ValueError("execute_fix_job requires parent_job_id")
|
|
509
|
+
|
|
510
|
+
job = Job(id=request.job_id or self._make_job_id(), request=request)
|
|
511
|
+
self._job_store.create(job)
|
|
512
|
+
_joblog.info(
|
|
513
|
+
"fix submitted kind=%s parent=%s",
|
|
514
|
+
request.fix_kind.value,
|
|
515
|
+
request.parent_job_id,
|
|
516
|
+
**self._job_ctx(job),
|
|
517
|
+
)
|
|
518
|
+
accepted_message_id = self._message_id_or_none(
|
|
519
|
+
self._notifier_for(request.project).send_job_accepted(job)
|
|
520
|
+
)
|
|
521
|
+
if accepted_message_id is not None:
|
|
522
|
+
job.accepted_message_id = accepted_message_id
|
|
523
|
+
self._job_store.update(job)
|
|
524
|
+
return self._run_fix(job.id, prepared_message=prepared_message)
|
|
525
|
+
|
|
526
|
+
def _run_fix(self, job_id: str, prepared_message: str | None = None) -> Job:
|
|
527
|
+
job = self._job_store.get(job_id)
|
|
528
|
+
if job is None:
|
|
529
|
+
_joblog.warning("run_fix requested for missing job job_id=%s", job_id)
|
|
530
|
+
raise ValueError("job not found")
|
|
531
|
+
|
|
532
|
+
cancel_event = threading.Event()
|
|
533
|
+
self._cancel_events[job_id] = cancel_event
|
|
534
|
+
|
|
535
|
+
entry = self._project_registry.get(job.request.project)
|
|
536
|
+
if not entry or not entry.enabled:
|
|
537
|
+
job.mark_failed("unknown or disabled project")
|
|
538
|
+
job.error_stage = "project_resolve"
|
|
539
|
+
self._job_store.update(job)
|
|
540
|
+
self._send_result(job)
|
|
541
|
+
self._cancel_events.pop(job_id, None)
|
|
542
|
+
return job
|
|
543
|
+
|
|
544
|
+
project_path = entry.root_path
|
|
545
|
+
worktree_base = entry.worktree_base_dir
|
|
546
|
+
remote = self._settings.git_remote_name
|
|
547
|
+
worktree_path: Path | None = None
|
|
548
|
+
created_worktree_for_job = False
|
|
549
|
+
failed_stage: str | None = None
|
|
550
|
+
|
|
551
|
+
try:
|
|
552
|
+
job.mark_running()
|
|
553
|
+
self._job_store.update(job)
|
|
554
|
+
|
|
555
|
+
failed_stage = "fix_resolve_target"
|
|
556
|
+
parent_job = self._job_store.get(job.request.parent_job_id or "")
|
|
557
|
+
if parent_job is None or not self.is_fix_candidate(
|
|
558
|
+
parent_job, job.request.project, job.request.chat_id
|
|
559
|
+
):
|
|
560
|
+
raise RuntimeError("Fix target job was not found or can no longer be fixed.")
|
|
561
|
+
assert parent_job.branch is not None
|
|
562
|
+
assert parent_job.commit_hash is not None
|
|
563
|
+
|
|
564
|
+
failed_stage = "fix_worktree"
|
|
565
|
+
existing = self._git_service.find_linked_worktree_for_branch(
|
|
566
|
+
project_path, parent_job.branch
|
|
567
|
+
)
|
|
568
|
+
if existing is not None:
|
|
569
|
+
worktree_path = existing
|
|
570
|
+
else:
|
|
571
|
+
worktree_path = self._git_service.prepare_branch_worktree(
|
|
572
|
+
project_path,
|
|
573
|
+
parent_job.branch,
|
|
574
|
+
job.id,
|
|
575
|
+
worktree_base_dir=worktree_base,
|
|
576
|
+
)
|
|
577
|
+
created_worktree_for_job = True
|
|
578
|
+
self._git_service.ensure_worktree_writable(worktree_path)
|
|
579
|
+
|
|
580
|
+
if job.request.fix_kind is FixKind.SOURCE:
|
|
581
|
+
failed_stage = "fix_runner"
|
|
582
|
+
runner = self._runner_factory.create(job.request.model)
|
|
583
|
+
timeout_seconds = self._effective_job_timeout_seconds()
|
|
584
|
+
fix_prompt = self.compose_fix_source_prompt(parent_job, job.request.instruction)
|
|
585
|
+
runner_result = runner.run(
|
|
586
|
+
RunnerInput(
|
|
587
|
+
instruction=fix_prompt,
|
|
588
|
+
cwd=worktree_path,
|
|
589
|
+
timeout_seconds=timeout_seconds,
|
|
590
|
+
model_id=job.request.model_id,
|
|
591
|
+
env=None,
|
|
592
|
+
cancel_event=cancel_event,
|
|
593
|
+
mode=JobMode.AGENT,
|
|
594
|
+
)
|
|
595
|
+
)
|
|
596
|
+
self._save_runner_log(job, runner_result, worktree_base)
|
|
597
|
+
if runner_result.exit_code != 0:
|
|
598
|
+
raise RuntimeError(runner_result.stderr.strip() or "runner failed")
|
|
599
|
+
|
|
600
|
+
failed_stage = "fix_collect_changes"
|
|
601
|
+
new_changed = self._git_service.collect_changes(worktree_path)
|
|
602
|
+
merged = list(dict.fromkeys([*parent_job.changed_files, *new_changed]))
|
|
603
|
+
job.changed_files = merged
|
|
604
|
+
|
|
605
|
+
if not new_changed:
|
|
606
|
+
job.branch = parent_job.branch
|
|
607
|
+
job.commit_hash = parent_job.commit_hash
|
|
608
|
+
job.mark_succeeded()
|
|
609
|
+
self._job_store.update(job)
|
|
610
|
+
_joblog.info(
|
|
611
|
+
"fix source produced no changes parent=%s",
|
|
612
|
+
parent_job.id,
|
|
613
|
+
**self._job_ctx(job),
|
|
614
|
+
)
|
|
615
|
+
else:
|
|
616
|
+
failed_stage = "fix_message"
|
|
617
|
+
ai_title = None
|
|
618
|
+
ai_body = None
|
|
619
|
+
if self._ai_commit_body_generator is not None:
|
|
620
|
+
ai_title, ai_body = self._ai_commit_body_generator.generate(
|
|
621
|
+
instruction=self.compose_fix_source_prompt(
|
|
622
|
+
parent_job, job.request.instruction
|
|
623
|
+
),
|
|
624
|
+
changed_files=merged,
|
|
625
|
+
model_name=job.request.model,
|
|
626
|
+
)
|
|
627
|
+
commit_message = CommitMessageFormatter.format(
|
|
628
|
+
job_id=parent_job.id,
|
|
629
|
+
instruction=parent_job.request.instruction,
|
|
630
|
+
changed_files=merged,
|
|
631
|
+
ai_body=ai_body,
|
|
632
|
+
ai_title=ai_title,
|
|
633
|
+
)
|
|
634
|
+
|
|
635
|
+
failed_stage = "fix_amend"
|
|
636
|
+
job.commit_hash = self._git_service.amend_commit(worktree_path, commit_message)
|
|
637
|
+
job.branch = parent_job.branch
|
|
638
|
+
|
|
639
|
+
failed_stage = "fix_push"
|
|
640
|
+
self._git_service.push_branch_force_with_lease(
|
|
641
|
+
project_path, remote, parent_job.branch
|
|
642
|
+
)
|
|
643
|
+
|
|
644
|
+
parent_job.commit_hash = job.commit_hash
|
|
645
|
+
parent_job.changed_files = merged
|
|
646
|
+
self._job_store.update(parent_job)
|
|
647
|
+
|
|
648
|
+
job.mark_succeeded()
|
|
649
|
+
self._job_store.update(job)
|
|
650
|
+
else:
|
|
651
|
+
failed_stage = "fix_amend"
|
|
652
|
+
if prepared_message is None:
|
|
653
|
+
prepared_message = self.build_fix_commit_preview(parent_job)
|
|
654
|
+
job.changed_files = list(parent_job.changed_files)
|
|
655
|
+
job.commit_hash = self._git_service.amend_commit(worktree_path, prepared_message)
|
|
656
|
+
job.branch = parent_job.branch
|
|
657
|
+
|
|
658
|
+
failed_stage = "fix_push"
|
|
659
|
+
self._git_service.push_branch_force_with_lease(
|
|
660
|
+
project_path, remote, parent_job.branch
|
|
661
|
+
)
|
|
662
|
+
parent_job.commit_hash = job.commit_hash
|
|
663
|
+
self._job_store.update(parent_job)
|
|
664
|
+
job.mark_succeeded()
|
|
665
|
+
self._job_store.update(job)
|
|
666
|
+
except Exception as exc: # pylint: disable=broad-except
|
|
667
|
+
_joblog.exception(
|
|
668
|
+
"fix failed stage=%s parent=%s: %s",
|
|
669
|
+
failed_stage or "unknown",
|
|
670
|
+
job.request.parent_job_id or "-",
|
|
671
|
+
exc,
|
|
672
|
+
**self._job_ctx(job),
|
|
673
|
+
)
|
|
674
|
+
if job.status.value != "failed":
|
|
675
|
+
job.mark_failed(str(exc))
|
|
676
|
+
job.error_stage = failed_stage or "unknown"
|
|
677
|
+
self._job_store.update(job)
|
|
678
|
+
finally:
|
|
679
|
+
self._cancel_events.pop(job_id, None)
|
|
680
|
+
if (
|
|
681
|
+
worktree_path is not None
|
|
682
|
+
and created_worktree_for_job
|
|
683
|
+
and job.status.value == "succeeded"
|
|
684
|
+
and not self._settings.keep_worktree_on_success
|
|
685
|
+
):
|
|
686
|
+
try:
|
|
687
|
+
self._git_service.cleanup_worktree(project_path, worktree_path)
|
|
688
|
+
except RuntimeError as cleanup_exc:
|
|
689
|
+
_joblog.warning(
|
|
690
|
+
"fix worktree cleanup failed: %s",
|
|
691
|
+
cleanup_exc,
|
|
692
|
+
**self._job_ctx(job),
|
|
693
|
+
)
|
|
694
|
+
self._send_result(job)
|
|
695
|
+
return job
|
|
696
|
+
|
|
697
|
+
@staticmethod
|
|
698
|
+
def _make_job_id() -> str:
|
|
699
|
+
ts = datetime.now(UTC).strftime("%Y%m%d%H%M%S")
|
|
700
|
+
return f"job_{ts}_{uuid4().hex[:6]}"
|
|
701
|
+
|
|
702
|
+
def _effective_job_timeout_seconds(self) -> int:
|
|
703
|
+
if self._advanced_settings_store is None:
|
|
704
|
+
return self._settings.job_timeout_seconds
|
|
705
|
+
advanced_timeout = self._advanced_settings_store.get().job_timeout_seconds
|
|
706
|
+
return advanced_timeout or self._settings.job_timeout_seconds
|
|
707
|
+
|
|
708
|
+
def _save_runner_log(self, job: Job, runner_result, worktree_base: Path) -> None:
|
|
709
|
+
log_dir = worktree_base / "_logs"
|
|
710
|
+
log_dir.mkdir(parents=True, exist_ok=True)
|
|
711
|
+
log_path = log_dir / f"{job.id}.log"
|
|
712
|
+
log_text = (
|
|
713
|
+
f"job_id={job.id}\n"
|
|
714
|
+
f"model={job.request.model.value}\n"
|
|
715
|
+
f"exit_code={runner_result.exit_code}\n"
|
|
716
|
+
f"started_at={runner_result.started_at}\n"
|
|
717
|
+
f"finished_at={runner_result.finished_at}\n\n"
|
|
718
|
+
f"[stdout]\n{runner_result.stdout}\n\n"
|
|
719
|
+
f"[stderr]\n{runner_result.stderr}\n"
|
|
720
|
+
)
|
|
721
|
+
log_path.write_text(log_text, encoding="utf-8")
|
|
722
|
+
job.log_path = log_path
|
|
723
|
+
job.runner_stdout_summary = self._make_output_summary(
|
|
724
|
+
runner_result.stdout,
|
|
725
|
+
limit=self._STDOUT_SUMMARY_LIMIT,
|
|
726
|
+
strip_links=True,
|
|
727
|
+
)
|
|
728
|
+
job.runner_stderr_summary = self._make_output_summary(
|
|
729
|
+
runner_result.stderr, limit=self._STDERR_SUMMARY_LIMIT
|
|
730
|
+
)
|
|
731
|
+
usage = extract_runner_usage(f"{runner_result.stdout}\n{runner_result.stderr}")
|
|
732
|
+
job.runner_actual_model = usage.actual_model
|
|
733
|
+
job.runner_token_usage = usage.token_usage
|
|
734
|
+
_joblog.info(
|
|
735
|
+
"runner log saved file=%s stdout_summary=%s stderr_summary=%s actual_model=%s token_usage=%s",
|
|
736
|
+
log_path.name,
|
|
737
|
+
job.runner_stdout_summary is not None,
|
|
738
|
+
job.runner_stderr_summary is not None,
|
|
739
|
+
job.runner_actual_model or "-",
|
|
740
|
+
bool(job.runner_token_usage),
|
|
741
|
+
**self._job_ctx(job),
|
|
742
|
+
)
|
|
743
|
+
|
|
744
|
+
@classmethod
|
|
745
|
+
def _strip_links_for_stdout_summary(cls, text: str) -> str:
|
|
746
|
+
stripped = cls._MD_LINK_PATTERN.sub(r"\1", text)
|
|
747
|
+
stripped = cls._HTTP_URL_PATTERN.sub("", stripped)
|
|
748
|
+
stripped = cls._WWW_URL_PATTERN.sub("", stripped)
|
|
749
|
+
stripped = re.sub(r"[ \t]{2,}", " ", stripped)
|
|
750
|
+
return stripped
|
|
751
|
+
|
|
752
|
+
@classmethod
|
|
753
|
+
def _make_output_summary(
|
|
754
|
+
cls,
|
|
755
|
+
text: str,
|
|
756
|
+
limit: int,
|
|
757
|
+
*,
|
|
758
|
+
strip_links: bool = False,
|
|
759
|
+
) -> str | None:
|
|
760
|
+
if not text:
|
|
761
|
+
return None
|
|
762
|
+
no_ansi = cls._ANSI_ESCAPE_PATTERN.sub("", text)
|
|
763
|
+
if strip_links:
|
|
764
|
+
no_ansi = cls._strip_links_for_stdout_summary(no_ansi)
|
|
765
|
+
normalized = "\n".join(line.rstrip() for line in no_ansi.splitlines()).strip()
|
|
766
|
+
if not normalized:
|
|
767
|
+
return None
|
|
768
|
+
if len(normalized) <= limit:
|
|
769
|
+
return normalized
|
|
770
|
+
return f"{normalized[:limit].rstrip()}...(truncated)"
|