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.
Files changed (78) hide show
  1. app/__init__.py +3 -0
  2. app/admin/__init__.py +0 -0
  3. app/admin/advanced_settings.py +88 -0
  4. app/admin/database_browser.py +301 -0
  5. app/admin/router.py +528 -0
  6. app/admin/static/i18n.js +401 -0
  7. app/admin/static/icons/advanced.svg +8 -0
  8. app/admin/static/icons/database.svg +5 -0
  9. app/admin/static/icons/download.svg +3 -0
  10. app/admin/static/icons/home.svg +4 -0
  11. app/admin/static/icons/logs.svg +3 -0
  12. app/admin/static/icons/projects.svg +5 -0
  13. app/admin/static/summary.js +73 -0
  14. app/admin/templates/admin.html +511 -0
  15. app/admin/templates/advanced.html +635 -0
  16. app/admin/templates/database.html +880 -0
  17. app/admin/templates/logs.html +686 -0
  18. app/admin/templates/projects.html +878 -0
  19. app/ai/__init__.py +0 -0
  20. app/ai/base.py +129 -0
  21. app/ai/claude.py +20 -0
  22. app/ai/codex.py +34 -0
  23. app/ai/factory.py +27 -0
  24. app/ai/gemini.py +20 -0
  25. app/ai/model_catalog.py +47 -0
  26. app/ai/usage.py +134 -0
  27. app/cli.py +238 -0
  28. app/config.py +130 -0
  29. app/git/__init__.py +0 -0
  30. app/git/ai_commit.py +88 -0
  31. app/git/branch_naming.py +21 -0
  32. app/git/commit_message.py +279 -0
  33. app/git/service.py +669 -0
  34. app/jobs/__init__.py +0 -0
  35. app/jobs/manager.py +770 -0
  36. app/jobs/schemas.py +116 -0
  37. app/jobs/store.py +334 -0
  38. app/main.py +265 -0
  39. app/models.py +20 -0
  40. app/monitoring/__init__.py +10 -0
  41. app/monitoring/code.py +161 -0
  42. app/monitoring/events.py +33 -0
  43. app/monitoring/git.py +103 -0
  44. app/monitoring/log_buffer.py +245 -0
  45. app/monitoring/memory.py +19 -0
  46. app/monitoring/model.py +598 -0
  47. app/projects/__init__.py +19 -0
  48. app/projects/registry.py +384 -0
  49. app/security/__init__.py +0 -0
  50. app/security/auth.py +19 -0
  51. app/system_startup.py +34 -0
  52. app/telegram/__init__.py +0 -0
  53. app/telegram/bot_instances.py +67 -0
  54. app/telegram/commands/__init__.py +64 -0
  55. app/telegram/commands/base.py +222 -0
  56. app/telegram/commands/branch.py +366 -0
  57. app/telegram/commands/clear_stop.py +221 -0
  58. app/telegram/commands/fix.py +219 -0
  59. app/telegram/commands/model.py +93 -0
  60. app/telegram/commands/monitor.py +185 -0
  61. app/telegram/commands/registry.py +110 -0
  62. app/telegram/commands/status.py +243 -0
  63. app/telegram/commands/system.py +201 -0
  64. app/telegram/confirmations.py +36 -0
  65. app/telegram/conversation.py +789 -0
  66. app/telegram/i18n.py +742 -0
  67. app/telegram/model_preferences.py +53 -0
  68. app/telegram/notifier.py +387 -0
  69. app/telegram/parser.py +267 -0
  70. app/telegram/webhook.py +988 -0
  71. app/telegram/webhook_registration.py +172 -0
  72. app/tunnel.py +104 -0
  73. remote_coder-0.4.1.dist-info/METADATA +520 -0
  74. remote_coder-0.4.1.dist-info/RECORD +78 -0
  75. remote_coder-0.4.1.dist-info/WHEEL +5 -0
  76. remote_coder-0.4.1.dist-info/entry_points.txt +2 -0
  77. remote_coder-0.4.1.dist-info/licenses/LICENSE +201 -0
  78. 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)"