remote-coder 0.5.2__tar.gz → 0.5.3__tar.gz
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.
- {remote_coder-0.5.2 → remote_coder-0.5.3}/PKG-INFO +4 -2
- {remote_coder-0.5.2 → remote_coder-0.5.3}/README.md +3 -1
- {remote_coder-0.5.2 → remote_coder-0.5.3}/app/__init__.py +1 -1
- {remote_coder-0.5.2 → remote_coder-0.5.3}/app/ai/base.py +86 -16
- {remote_coder-0.5.2 → remote_coder-0.5.3}/app/jobs/execution_pipeline.py +3 -0
- {remote_coder-0.5.2 → remote_coder-0.5.3}/app/jobs/fix_pipeline.py +3 -0
- {remote_coder-0.5.2 → remote_coder-0.5.3}/app/jobs/manager.py +13 -0
- remote_coder-0.5.3/app/jobs/result_writer.py +223 -0
- {remote_coder-0.5.2 → remote_coder-0.5.3}/app/jobs/store.py +43 -0
- {remote_coder-0.5.2 → remote_coder-0.5.3}/app/main.py +40 -1
- remote_coder-0.5.3/app/system_startup.py +94 -0
- {remote_coder-0.5.2 → remote_coder-0.5.3}/app/telegram/commands/__init__.py +2 -1
- {remote_coder-0.5.2 → remote_coder-0.5.3}/app/telegram/commands/base.py +1 -0
- {remote_coder-0.5.2 → remote_coder-0.5.3}/app/telegram/commands/registry.py +2 -1
- {remote_coder-0.5.2 → remote_coder-0.5.3}/app/telegram/commands/status.py +70 -0
- {remote_coder-0.5.2 → remote_coder-0.5.3}/app/telegram/handlers/command_flow.py +1 -1
- {remote_coder-0.5.2 → remote_coder-0.5.3}/app/telegram/handlers/job_submission.py +48 -62
- {remote_coder-0.5.2 → remote_coder-0.5.3}/app/telegram/i18n.py +4 -0
- {remote_coder-0.5.2 → remote_coder-0.5.3}/app/telegram/messages.py +5 -1
- {remote_coder-0.5.2 → remote_coder-0.5.3}/app/telegram/notifier.py +9 -2
- {remote_coder-0.5.2 → remote_coder-0.5.3}/app/telegram/notifier_protocol.py +8 -1
- {remote_coder-0.5.2 → remote_coder-0.5.3}/pyproject.toml +1 -1
- {remote_coder-0.5.2 → remote_coder-0.5.3}/remote_coder.egg-info/PKG-INFO +4 -2
- {remote_coder-0.5.2 → remote_coder-0.5.3}/remote_coder.egg-info/SOURCES.txt +4 -0
- remote_coder-0.5.3/tests/test_base_runner.py +43 -0
- {remote_coder-0.5.2 → remote_coder-0.5.3}/tests/test_cli.py +1 -1
- remote_coder-0.5.3/tests/test_command_flow.py +83 -0
- {remote_coder-0.5.2 → remote_coder-0.5.3}/tests/test_commands.py +223 -0
- {remote_coder-0.5.2 → remote_coder-0.5.3}/tests/test_job_manager.py +98 -0
- {remote_coder-0.5.2 → remote_coder-0.5.3}/tests/test_job_store.py +108 -0
- remote_coder-0.5.3/tests/test_job_submission.py +44 -0
- remote_coder-0.5.3/tests/test_main_lifespan.py +99 -0
- {remote_coder-0.5.2 → remote_coder-0.5.3}/tests/test_notifier.py +4 -0
- remote_coder-0.5.3/tests/test_result_writer.py +68 -0
- {remote_coder-0.5.2 → remote_coder-0.5.3}/tests/test_system_startup.py +83 -1
- {remote_coder-0.5.2 → remote_coder-0.5.3}/tests/test_webhook.py +1 -1
- remote_coder-0.5.2/app/jobs/result_writer.py +0 -104
- remote_coder-0.5.2/app/system_startup.py +0 -34
- remote_coder-0.5.2/tests/test_main_lifespan.py +0 -67
- {remote_coder-0.5.2 → remote_coder-0.5.3}/LICENSE +0 -0
- {remote_coder-0.5.2 → remote_coder-0.5.3}/app/admin/__init__.py +0 -0
- {remote_coder-0.5.2 → remote_coder-0.5.3}/app/admin/advanced_settings.py +0 -0
- {remote_coder-0.5.2 → remote_coder-0.5.3}/app/admin/database_browser.py +0 -0
- {remote_coder-0.5.2 → remote_coder-0.5.3}/app/admin/router.py +0 -0
- {remote_coder-0.5.2 → remote_coder-0.5.3}/app/admin/static/admin.css +0 -0
- {remote_coder-0.5.2 → remote_coder-0.5.3}/app/admin/static/i18n.js +0 -0
- {remote_coder-0.5.2 → remote_coder-0.5.3}/app/admin/static/icons/advanced.svg +0 -0
- {remote_coder-0.5.2 → remote_coder-0.5.3}/app/admin/static/icons/database.svg +0 -0
- {remote_coder-0.5.2 → remote_coder-0.5.3}/app/admin/static/icons/download.svg +0 -0
- {remote_coder-0.5.2 → remote_coder-0.5.3}/app/admin/static/icons/home.svg +0 -0
- {remote_coder-0.5.2 → remote_coder-0.5.3}/app/admin/static/icons/logs.svg +0 -0
- {remote_coder-0.5.2 → remote_coder-0.5.3}/app/admin/static/icons/projects.svg +0 -0
- {remote_coder-0.5.2 → remote_coder-0.5.3}/app/admin/static/summary.js +0 -0
- {remote_coder-0.5.2 → remote_coder-0.5.3}/app/admin/templates/admin.html +0 -0
- {remote_coder-0.5.2 → remote_coder-0.5.3}/app/admin/templates/advanced.html +0 -0
- {remote_coder-0.5.2 → remote_coder-0.5.3}/app/admin/templates/database.html +0 -0
- {remote_coder-0.5.2 → remote_coder-0.5.3}/app/admin/templates/logs.html +0 -0
- {remote_coder-0.5.2 → remote_coder-0.5.3}/app/admin/templates/projects.html +0 -0
- {remote_coder-0.5.2 → remote_coder-0.5.3}/app/ai/__init__.py +0 -0
- {remote_coder-0.5.2 → remote_coder-0.5.3}/app/ai/claude.py +0 -0
- {remote_coder-0.5.2 → remote_coder-0.5.3}/app/ai/codex.py +0 -0
- {remote_coder-0.5.2 → remote_coder-0.5.3}/app/ai/factory.py +0 -0
- {remote_coder-0.5.2 → remote_coder-0.5.3}/app/ai/gemini.py +0 -0
- {remote_coder-0.5.2 → remote_coder-0.5.3}/app/ai/model_catalog.py +0 -0
- {remote_coder-0.5.2 → remote_coder-0.5.3}/app/ai/usage.py +0 -0
- {remote_coder-0.5.2 → remote_coder-0.5.3}/app/cli.py +0 -0
- {remote_coder-0.5.2 → remote_coder-0.5.3}/app/config.py +0 -0
- {remote_coder-0.5.2 → remote_coder-0.5.3}/app/diagnostics.py +0 -0
- {remote_coder-0.5.2 → remote_coder-0.5.3}/app/git/__init__.py +0 -0
- {remote_coder-0.5.2 → remote_coder-0.5.3}/app/git/ai_commit.py +0 -0
- {remote_coder-0.5.2 → remote_coder-0.5.3}/app/git/branch_naming.py +0 -0
- {remote_coder-0.5.2 → remote_coder-0.5.3}/app/git/commit_message.py +0 -0
- {remote_coder-0.5.2 → remote_coder-0.5.3}/app/git/service.py +0 -0
- {remote_coder-0.5.2 → remote_coder-0.5.3}/app/git/worktree_service.py +0 -0
- {remote_coder-0.5.2 → remote_coder-0.5.3}/app/jobs/__init__.py +0 -0
- {remote_coder-0.5.2 → remote_coder-0.5.3}/app/jobs/fix_support.py +0 -0
- {remote_coder-0.5.2 → remote_coder-0.5.3}/app/jobs/heartbeat.py +0 -0
- {remote_coder-0.5.2 → remote_coder-0.5.3}/app/jobs/plan_decisions.py +0 -0
- {remote_coder-0.5.2 → remote_coder-0.5.3}/app/jobs/schemas.py +0 -0
- {remote_coder-0.5.2 → remote_coder-0.5.3}/app/jobs/worktree_planner.py +0 -0
- {remote_coder-0.5.2 → remote_coder-0.5.3}/app/models.py +0 -0
- {remote_coder-0.5.2 → remote_coder-0.5.3}/app/monitoring/__init__.py +0 -0
- {remote_coder-0.5.2 → remote_coder-0.5.3}/app/monitoring/code.py +0 -0
- {remote_coder-0.5.2 → remote_coder-0.5.3}/app/monitoring/events.py +0 -0
- {remote_coder-0.5.2 → remote_coder-0.5.3}/app/monitoring/git.py +0 -0
- {remote_coder-0.5.2 → remote_coder-0.5.3}/app/monitoring/log_buffer.py +0 -0
- {remote_coder-0.5.2 → remote_coder-0.5.3}/app/monitoring/memory.py +0 -0
- {remote_coder-0.5.2 → remote_coder-0.5.3}/app/monitoring/model.py +0 -0
- {remote_coder-0.5.2 → remote_coder-0.5.3}/app/projects/__init__.py +0 -0
- {remote_coder-0.5.2 → remote_coder-0.5.3}/app/projects/registry.py +0 -0
- {remote_coder-0.5.2 → remote_coder-0.5.3}/app/security/__init__.py +0 -0
- {remote_coder-0.5.2 → remote_coder-0.5.3}/app/security/auth.py +0 -0
- {remote_coder-0.5.2 → remote_coder-0.5.3}/app/telegram/__init__.py +0 -0
- {remote_coder-0.5.2 → remote_coder-0.5.3}/app/telegram/bot_instances.py +0 -0
- {remote_coder-0.5.2 → remote_coder-0.5.3}/app/telegram/commands/branch.py +0 -0
- {remote_coder-0.5.2 → remote_coder-0.5.3}/app/telegram/commands/clear_stop.py +0 -0
- {remote_coder-0.5.2 → remote_coder-0.5.3}/app/telegram/commands/fix.py +0 -0
- {remote_coder-0.5.2 → remote_coder-0.5.3}/app/telegram/commands/model.py +0 -0
- {remote_coder-0.5.2 → remote_coder-0.5.3}/app/telegram/commands/monitor.py +0 -0
- {remote_coder-0.5.2 → remote_coder-0.5.3}/app/telegram/commands/system.py +0 -0
- {remote_coder-0.5.2 → remote_coder-0.5.3}/app/telegram/confirmations.py +0 -0
- {remote_coder-0.5.2 → remote_coder-0.5.3}/app/telegram/conversation/__init__.py +0 -0
- {remote_coder-0.5.2 → remote_coder-0.5.3}/app/telegram/conversation/collaborators.py +0 -0
- {remote_coder-0.5.2 → remote_coder-0.5.3}/app/telegram/conversation/context.py +0 -0
- {remote_coder-0.5.2 → remote_coder-0.5.3}/app/telegram/conversation/models.py +0 -0
- {remote_coder-0.5.2 → remote_coder-0.5.3}/app/telegram/conversation/protocols.py +0 -0
- {remote_coder-0.5.2 → remote_coder-0.5.3}/app/telegram/conversation/sqlite_rows.py +0 -0
- {remote_coder-0.5.2 → remote_coder-0.5.3}/app/telegram/conversation/sqlite_store.py +0 -0
- {remote_coder-0.5.2 → remote_coder-0.5.3}/app/telegram/conversation/store.py +0 -0
- {remote_coder-0.5.2 → remote_coder-0.5.3}/app/telegram/formatting.py +0 -0
- {remote_coder-0.5.2 → remote_coder-0.5.3}/app/telegram/handlers/__init__.py +0 -0
- {remote_coder-0.5.2 → remote_coder-0.5.3}/app/telegram/handlers/callback_dispatcher.py +0 -0
- {remote_coder-0.5.2 → remote_coder-0.5.3}/app/telegram/handlers/fix_flow.py +0 -0
- {remote_coder-0.5.2 → remote_coder-0.5.3}/app/telegram/handlers/natural_flow.py +0 -0
- {remote_coder-0.5.2 → remote_coder-0.5.3}/app/telegram/handlers/plan_flow.py +0 -0
- {remote_coder-0.5.2 → remote_coder-0.5.3}/app/telegram/handlers/presenters.py +0 -0
- {remote_coder-0.5.2 → remote_coder-0.5.3}/app/telegram/handlers/recent_updates.py +0 -0
- {remote_coder-0.5.2 → remote_coder-0.5.3}/app/telegram/handlers/request.py +0 -0
- {remote_coder-0.5.2 → remote_coder-0.5.3}/app/telegram/handlers/session_binding.py +0 -0
- {remote_coder-0.5.2 → remote_coder-0.5.3}/app/telegram/handlers/update_handler.py +0 -0
- {remote_coder-0.5.2 → remote_coder-0.5.3}/app/telegram/lists.py +0 -0
- {remote_coder-0.5.2 → remote_coder-0.5.3}/app/telegram/model_preferences.py +0 -0
- {remote_coder-0.5.2 → remote_coder-0.5.3}/app/telegram/parser.py +0 -0
- {remote_coder-0.5.2 → remote_coder-0.5.3}/app/telegram/plan_decisions_flow.py +0 -0
- {remote_coder-0.5.2 → remote_coder-0.5.3}/app/telegram/webhook.py +0 -0
- {remote_coder-0.5.2 → remote_coder-0.5.3}/app/telegram/webhook_registration.py +0 -0
- {remote_coder-0.5.2 → remote_coder-0.5.3}/app/tunnel.py +0 -0
- {remote_coder-0.5.2 → remote_coder-0.5.3}/remote_coder.egg-info/dependency_links.txt +0 -0
- {remote_coder-0.5.2 → remote_coder-0.5.3}/remote_coder.egg-info/entry_points.txt +0 -0
- {remote_coder-0.5.2 → remote_coder-0.5.3}/remote_coder.egg-info/requires.txt +0 -0
- {remote_coder-0.5.2 → remote_coder-0.5.3}/remote_coder.egg-info/top_level.txt +0 -0
- {remote_coder-0.5.2 → remote_coder-0.5.3}/setup.cfg +0 -0
- {remote_coder-0.5.2 → remote_coder-0.5.3}/tests/test_admin_router.py +0 -0
- {remote_coder-0.5.2 → remote_coder-0.5.3}/tests/test_ai_base.py +0 -0
- {remote_coder-0.5.2 → remote_coder-0.5.3}/tests/test_ai_commit.py +0 -0
- {remote_coder-0.5.2 → remote_coder-0.5.3}/tests/test_ai_factory.py +0 -0
- {remote_coder-0.5.2 → remote_coder-0.5.3}/tests/test_auth.py +0 -0
- {remote_coder-0.5.2 → remote_coder-0.5.3}/tests/test_bot_instance_manager.py +0 -0
- {remote_coder-0.5.2 → remote_coder-0.5.3}/tests/test_branch_naming.py +0 -0
- {remote_coder-0.5.2 → remote_coder-0.5.3}/tests/test_claude_runner.py +0 -0
- {remote_coder-0.5.2 → remote_coder-0.5.3}/tests/test_codex_runner.py +0 -0
- {remote_coder-0.5.2 → remote_coder-0.5.3}/tests/test_command_parser.py +0 -0
- {remote_coder-0.5.2 → remote_coder-0.5.3}/tests/test_commit_message_formatter.py +0 -0
- {remote_coder-0.5.2 → remote_coder-0.5.3}/tests/test_config.py +0 -0
- {remote_coder-0.5.2 → remote_coder-0.5.3}/tests/test_conversation_store.py +0 -0
- {remote_coder-0.5.2 → remote_coder-0.5.3}/tests/test_database_browser.py +0 -0
- {remote_coder-0.5.2 → remote_coder-0.5.3}/tests/test_diagnostics.py +0 -0
- {remote_coder-0.5.2 → remote_coder-0.5.3}/tests/test_event_logger.py +0 -0
- {remote_coder-0.5.2 → remote_coder-0.5.3}/tests/test_gemini_runner.py +0 -0
- {remote_coder-0.5.2 → remote_coder-0.5.3}/tests/test_git_service.py +0 -0
- {remote_coder-0.5.2 → remote_coder-0.5.3}/tests/test_i18n.py +0 -0
- {remote_coder-0.5.2 → remote_coder-0.5.3}/tests/test_job_status.py +0 -0
- {remote_coder-0.5.2 → remote_coder-0.5.3}/tests/test_lists.py +0 -0
- {remote_coder-0.5.2 → remote_coder-0.5.3}/tests/test_log_buffer.py +0 -0
- {remote_coder-0.5.2 → remote_coder-0.5.3}/tests/test_monitoring.py +0 -0
- {remote_coder-0.5.2 → remote_coder-0.5.3}/tests/test_plan_decisions.py +0 -0
- {remote_coder-0.5.2 → remote_coder-0.5.3}/tests/test_project_registry.py +0 -0
- {remote_coder-0.5.2 → remote_coder-0.5.3}/tests/test_project_scoped_state.py +0 -0
- {remote_coder-0.5.2 → remote_coder-0.5.3}/tests/test_telegram_formatting.py +0 -0
- {remote_coder-0.5.2 → remote_coder-0.5.3}/tests/test_tunnel.py +0 -0
- {remote_coder-0.5.2 → remote_coder-0.5.3}/tests/test_webhook_multibot.py +0 -0
- {remote_coder-0.5.2 → remote_coder-0.5.3}/tests/test_webhook_registration.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: remote-coder
|
|
3
|
-
Version: 0.5.
|
|
3
|
+
Version: 0.5.3
|
|
4
4
|
Summary: Telegram-based remote AI coding automation server
|
|
5
5
|
Author: Remote AI Coder contributors
|
|
6
6
|
License: Apache License
|
|
@@ -292,7 +292,8 @@ Each project uses its own bot. The webhook path contains the first 16 hex charac
|
|
|
292
292
|
|---|---|
|
|
293
293
|
| `/start`, `/help` | Open the menu or command help |
|
|
294
294
|
| `/model` | View or change the chat's default model |
|
|
295
|
-
| `/status [job_id]` | Inspect recent or specific jobs |
|
|
295
|
+
| `/status [job_id]` | Inspect recent or specific jobs; running jobs show the latest captured AI output when available |
|
|
296
|
+
| `/log [job_id]` | Pick a recent job or show captured AI stdout for a specific job |
|
|
296
297
|
| `/branch [name]` | Show or switch the bound project's local branch |
|
|
297
298
|
| `/pull` | Fetch remotes and pull the current branch |
|
|
298
299
|
| `/rebase [branch]` | Rebase and fast-forward a completed branch into `main` or `master` |
|
|
@@ -322,6 +323,7 @@ Day-to-day setup happens in the local admin UI. Files live under `REMOTE_CODER_H
|
|
|
322
323
|
- `projects.json` stores project records, bot tokens, allowlists, root paths, and default models.
|
|
323
324
|
- `advanced_settings.json` stores global behavior such as timeouts, sandbox mode, language, worktree retention, and memory limits.
|
|
324
325
|
- `worktrees/<project>/` contains managed job worktrees and logs.
|
|
326
|
+
- On server startup, queued Jobs stored in SQLite are rerun; Jobs that were running when the server stopped are marked failed with `server_restart` for `/status` review.
|
|
325
327
|
|
|
326
328
|
Useful overrides: `REMOTE_CODER_HOME`, `PROJECTS_CONFIG_PATH`, `CONVERSATION_DB_PATH`, and `JOB_DB_PATH`.
|
|
327
329
|
|
|
@@ -71,7 +71,8 @@ Each project uses its own bot. The webhook path contains the first 16 hex charac
|
|
|
71
71
|
|---|---|
|
|
72
72
|
| `/start`, `/help` | Open the menu or command help |
|
|
73
73
|
| `/model` | View or change the chat's default model |
|
|
74
|
-
| `/status [job_id]` | Inspect recent or specific jobs |
|
|
74
|
+
| `/status [job_id]` | Inspect recent or specific jobs; running jobs show the latest captured AI output when available |
|
|
75
|
+
| `/log [job_id]` | Pick a recent job or show captured AI stdout for a specific job |
|
|
75
76
|
| `/branch [name]` | Show or switch the bound project's local branch |
|
|
76
77
|
| `/pull` | Fetch remotes and pull the current branch |
|
|
77
78
|
| `/rebase [branch]` | Rebase and fast-forward a completed branch into `main` or `master` |
|
|
@@ -101,6 +102,7 @@ Day-to-day setup happens in the local admin UI. Files live under `REMOTE_CODER_H
|
|
|
101
102
|
- `projects.json` stores project records, bot tokens, allowlists, root paths, and default models.
|
|
102
103
|
- `advanced_settings.json` stores global behavior such as timeouts, sandbox mode, language, worktree retention, and memory limits.
|
|
103
104
|
- `worktrees/<project>/` contains managed job worktrees and logs.
|
|
105
|
+
- On server startup, queued Jobs stored in SQLite are rerun; Jobs that were running when the server stopped are marked failed with `server_restart` for `/status` review.
|
|
104
106
|
|
|
105
107
|
Useful overrides: `REMOTE_CODER_HOME`, `PROJECTS_CONFIG_PATH`, `CONVERSATION_DB_PATH`, and `JOB_DB_PATH`.
|
|
106
108
|
|
|
@@ -4,9 +4,11 @@ import re
|
|
|
4
4
|
import subprocess
|
|
5
5
|
import threading
|
|
6
6
|
from abc import ABC, abstractmethod
|
|
7
|
+
from collections.abc import Callable
|
|
7
8
|
from dataclasses import dataclass, field
|
|
8
9
|
from datetime import UTC, datetime
|
|
9
10
|
from pathlib import Path
|
|
11
|
+
from typing import Literal
|
|
10
12
|
|
|
11
13
|
from app.jobs.schemas import JobMode
|
|
12
14
|
from app.monitoring.events import EventLogger
|
|
@@ -95,6 +97,9 @@ class RunnerInput:
|
|
|
95
97
|
session_id: str | None = None
|
|
96
98
|
resume_token: str | None = None
|
|
97
99
|
native_resume_cwd_stable: bool = True
|
|
100
|
+
output_callback: Callable[[Literal["stdout", "stderr"], str], None] | None = field(
|
|
101
|
+
default=None, compare=False
|
|
102
|
+
)
|
|
98
103
|
|
|
99
104
|
|
|
100
105
|
@dataclass
|
|
@@ -213,23 +218,30 @@ class BaseCliRunner(AiRunner):
|
|
|
213
218
|
cancelled.set()
|
|
214
219
|
|
|
215
220
|
threading.Thread(target=_watch, daemon=True).start()
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
221
|
+
if runner_input.output_callback is None:
|
|
222
|
+
try:
|
|
223
|
+
stdout_data, stderr_data = proc.communicate(timeout=runner_input.timeout_seconds)
|
|
224
|
+
except subprocess.TimeoutExpired as exc:
|
|
225
|
+
proc.kill()
|
|
226
|
+
stdout_data, stderr_data = proc.communicate()
|
|
227
|
+
self._log.warning(
|
|
228
|
+
"timeout after %ds stdout_len=%d stderr_len=%d",
|
|
229
|
+
runner_input.timeout_seconds,
|
|
230
|
+
len(stdout_data),
|
|
231
|
+
len(stderr_data),
|
|
232
|
+
)
|
|
233
|
+
raise RunnerExecutionError(
|
|
234
|
+
f"runner timed out after {runner_input.timeout_seconds}s",
|
|
235
|
+
stdout=stdout_data,
|
|
236
|
+
stderr=stderr_data,
|
|
237
|
+
started_at=started_at,
|
|
238
|
+
) from exc
|
|
239
|
+
else:
|
|
240
|
+
stdout_data, stderr_data = self._communicate_with_output_callback(
|
|
241
|
+
proc,
|
|
242
|
+
runner_input,
|
|
243
|
+
started_at,
|
|
226
244
|
)
|
|
227
|
-
raise RunnerExecutionError(
|
|
228
|
-
f"runner timed out after {runner_input.timeout_seconds}s",
|
|
229
|
-
stdout=stdout_data,
|
|
230
|
-
stderr=stderr_data,
|
|
231
|
-
started_at=started_at,
|
|
232
|
-
) from exc
|
|
233
245
|
finished_at = datetime.now(UTC)
|
|
234
246
|
if cancelled.is_set():
|
|
235
247
|
raise RunnerExecutionError(
|
|
@@ -255,3 +267,61 @@ class BaseCliRunner(AiRunner):
|
|
|
255
267
|
finished_at=finished_at,
|
|
256
268
|
session_id=self._resolve_result_session_id(runner_input, session_files_before),
|
|
257
269
|
)
|
|
270
|
+
|
|
271
|
+
def _communicate_with_output_callback(
|
|
272
|
+
self,
|
|
273
|
+
proc: subprocess.Popen[str],
|
|
274
|
+
runner_input: RunnerInput,
|
|
275
|
+
started_at: datetime,
|
|
276
|
+
) -> tuple[str, str]:
|
|
277
|
+
output_callback = runner_input.output_callback
|
|
278
|
+
if output_callback is None:
|
|
279
|
+
raise ValueError("output_callback is required")
|
|
280
|
+
stdout_parts: list[str] = []
|
|
281
|
+
stderr_parts: list[str] = []
|
|
282
|
+
|
|
283
|
+
def _read_stream(
|
|
284
|
+
stream_name: Literal["stdout", "stderr"],
|
|
285
|
+
parts: list[str],
|
|
286
|
+
) -> None:
|
|
287
|
+
stream = proc.stdout if stream_name == "stdout" else proc.stderr
|
|
288
|
+
if stream is None:
|
|
289
|
+
return
|
|
290
|
+
while True:
|
|
291
|
+
chunk = stream.readline()
|
|
292
|
+
if chunk == "":
|
|
293
|
+
break
|
|
294
|
+
parts.append(chunk)
|
|
295
|
+
try:
|
|
296
|
+
output_callback(stream_name, chunk)
|
|
297
|
+
except Exception: # pylint: disable=broad-except
|
|
298
|
+
self._log.warning("output callback failed stream=%s", stream_name)
|
|
299
|
+
|
|
300
|
+
stdout_thread = threading.Thread(target=_read_stream, args=("stdout", stdout_parts))
|
|
301
|
+
stderr_thread = threading.Thread(target=_read_stream, args=("stderr", stderr_parts))
|
|
302
|
+
stdout_thread.start()
|
|
303
|
+
stderr_thread.start()
|
|
304
|
+
try:
|
|
305
|
+
proc.wait(timeout=runner_input.timeout_seconds)
|
|
306
|
+
except subprocess.TimeoutExpired as exc:
|
|
307
|
+
proc.kill()
|
|
308
|
+
proc.wait()
|
|
309
|
+
stdout_thread.join()
|
|
310
|
+
stderr_thread.join()
|
|
311
|
+
stdout_data = "".join(stdout_parts)
|
|
312
|
+
stderr_data = "".join(stderr_parts)
|
|
313
|
+
self._log.warning(
|
|
314
|
+
"timeout after %ds stdout_len=%d stderr_len=%d",
|
|
315
|
+
runner_input.timeout_seconds,
|
|
316
|
+
len(stdout_data),
|
|
317
|
+
len(stderr_data),
|
|
318
|
+
)
|
|
319
|
+
raise RunnerExecutionError(
|
|
320
|
+
f"runner timed out after {runner_input.timeout_seconds}s",
|
|
321
|
+
stdout=stdout_data,
|
|
322
|
+
stderr=stderr_data,
|
|
323
|
+
started_at=started_at,
|
|
324
|
+
) from exc
|
|
325
|
+
stdout_thread.join()
|
|
326
|
+
stderr_thread.join()
|
|
327
|
+
return "".join(stdout_parts), "".join(stderr_parts)
|
|
@@ -83,6 +83,7 @@ def run_job(manager, job_id: str) -> Job:
|
|
|
83
83
|
len(job.request.instruction),
|
|
84
84
|
**manager._job_ctx(job),
|
|
85
85
|
)
|
|
86
|
+
runner_log = manager._start_incremental_runner_log(job, worktree_base)
|
|
86
87
|
heartbeat = manager._start_heartbeat(job)
|
|
87
88
|
try:
|
|
88
89
|
runner_result = runner.run(
|
|
@@ -97,10 +98,12 @@ def run_job(manager, job_id: str) -> Job:
|
|
|
97
98
|
session_id=job.request.session_id,
|
|
98
99
|
resume_token=job.request.resume_session_token,
|
|
99
100
|
native_resume_cwd_stable=not created_worktree_for_job,
|
|
101
|
+
output_callback=runner_log.output_callback,
|
|
100
102
|
)
|
|
101
103
|
)
|
|
102
104
|
finally:
|
|
103
105
|
heartbeat.set()
|
|
106
|
+
runner_log.flush()
|
|
104
107
|
manager._save_runner_log(job, runner_result, worktree_base)
|
|
105
108
|
_joblog.info(
|
|
106
109
|
"runner exit=%d stdout_len=%d stderr_len=%d",
|
|
@@ -79,6 +79,7 @@ def run_fix_job(manager, job_id: str) -> Job:
|
|
|
79
79
|
runner = manager._runner_factory.create(job.request.model)
|
|
80
80
|
timeout_seconds = manager._effective_job_timeout_seconds()
|
|
81
81
|
fix_prompt = manager.compose_fix_source_prompt(parent_job, job.request.instruction)
|
|
82
|
+
runner_log = manager._start_incremental_runner_log(job, worktree_base)
|
|
82
83
|
heartbeat = manager._start_heartbeat(job)
|
|
83
84
|
try:
|
|
84
85
|
runner_result = runner.run(
|
|
@@ -93,10 +94,12 @@ def run_fix_job(manager, job_id: str) -> Job:
|
|
|
93
94
|
session_id=job.request.session_id,
|
|
94
95
|
resume_token=job.request.resume_session_token,
|
|
95
96
|
native_resume_cwd_stable=not created_worktree_for_job,
|
|
97
|
+
output_callback=runner_log.output_callback,
|
|
96
98
|
)
|
|
97
99
|
)
|
|
98
100
|
finally:
|
|
99
101
|
heartbeat.set()
|
|
102
|
+
runner_log.flush()
|
|
100
103
|
manager._save_runner_log(job, runner_result, worktree_base)
|
|
101
104
|
if runner_result.exit_code != 0:
|
|
102
105
|
raise RuntimeError(runner_result.stderr.strip() or "runner failed")
|
|
@@ -26,6 +26,7 @@ from app.jobs.result_writer import (
|
|
|
26
26
|
make_output_summary,
|
|
27
27
|
preserve_partial_output,
|
|
28
28
|
save_runner_log,
|
|
29
|
+
start_incremental_runner_log,
|
|
29
30
|
strip_links_for_stdout_summary,
|
|
30
31
|
)
|
|
31
32
|
from app.jobs.schemas import FixKind, Job, JobMode, JobRequest
|
|
@@ -186,6 +187,15 @@ class JobManager:
|
|
|
186
187
|
with self._project_lock(job.request.project):
|
|
187
188
|
return run_job(self, job_id)
|
|
188
189
|
|
|
190
|
+
def recover(self, job_id: str) -> Job:
|
|
191
|
+
job = self._job_store.get(job_id)
|
|
192
|
+
if job is None:
|
|
193
|
+
return run_job(self, job_id)
|
|
194
|
+
with self._project_lock(job.request.project):
|
|
195
|
+
if job.request.mode is JobMode.AGENT_FIX:
|
|
196
|
+
return self._run_fix(job_id)
|
|
197
|
+
return run_job(self, job_id)
|
|
198
|
+
|
|
189
199
|
def _project_lock(self, project: str) -> threading.Lock:
|
|
190
200
|
with self._project_locks_guard:
|
|
191
201
|
lock = self._project_locks.get(project)
|
|
@@ -282,6 +292,9 @@ class JobManager:
|
|
|
282
292
|
def _save_runner_log(self, job: Job, runner_result, worktree_base: Path) -> None:
|
|
283
293
|
save_runner_log(job, runner_result, worktree_base)
|
|
284
294
|
|
|
295
|
+
def _start_incremental_runner_log(self, job: Job, worktree_base: Path):
|
|
296
|
+
return start_incremental_runner_log(job, worktree_base, self._job_store.update)
|
|
297
|
+
|
|
285
298
|
@classmethod
|
|
286
299
|
def _strip_links_for_stdout_summary(cls, text: str) -> str:
|
|
287
300
|
return strip_links_for_stdout_summary(text)
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
import threading
|
|
5
|
+
import time
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from datetime import UTC, datetime
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Literal
|
|
10
|
+
|
|
11
|
+
from app.ai.base import RunnerExecutionError, RunnerResult
|
|
12
|
+
from app.ai.usage import extract_runner_usage
|
|
13
|
+
from app.jobs.schemas import Job
|
|
14
|
+
from app.monitoring.events import EventLogger
|
|
15
|
+
|
|
16
|
+
_joblog = EventLogger("app.jobs.lifecycle", "job.lifecycle")
|
|
17
|
+
|
|
18
|
+
_ANSI_ESCAPE_PATTERN = re.compile(r"\x1B\[[0-?]*[ -/]*[@-~]")
|
|
19
|
+
_MD_LINK_PATTERN = re.compile(r"\[([^\]]*)\]\([^)]+\)")
|
|
20
|
+
_HTTP_URL_PATTERN = re.compile(r"https?://[^\s\]\)>,]+", flags=re.IGNORECASE)
|
|
21
|
+
_WWW_URL_PATTERN = re.compile(r"\bwww\.[^\s\]\)>,]+", flags=re.IGNORECASE)
|
|
22
|
+
STDOUT_SUMMARY_LIMIT = 12000
|
|
23
|
+
STDERR_SUMMARY_LIMIT = 800
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class IncrementalRunnerLog:
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
job: Job,
|
|
30
|
+
worktree_base: Path,
|
|
31
|
+
update_job: Callable[[Job], None],
|
|
32
|
+
*,
|
|
33
|
+
update_interval_seconds: float = 1.0,
|
|
34
|
+
) -> None:
|
|
35
|
+
self._job = job
|
|
36
|
+
self._update_job = update_job
|
|
37
|
+
self._update_interval_seconds = update_interval_seconds
|
|
38
|
+
self._stdout_parts: list[str] = []
|
|
39
|
+
self._stderr_parts: list[str] = []
|
|
40
|
+
self._lock = threading.Lock()
|
|
41
|
+
self._started_at = datetime.now(UTC)
|
|
42
|
+
self._last_update = 0.0
|
|
43
|
+
log_dir = worktree_base / "_logs"
|
|
44
|
+
log_dir.mkdir(parents=True, exist_ok=True)
|
|
45
|
+
self._path = log_dir / f"{job.id}.log"
|
|
46
|
+
self._job.log_path = self._path
|
|
47
|
+
with self._lock:
|
|
48
|
+
self._write_log_locked(exit_code="running", finished_at=None)
|
|
49
|
+
self._refresh_summaries_locked()
|
|
50
|
+
self._update_job(self._job)
|
|
51
|
+
|
|
52
|
+
def output_callback(self, stream: Literal["stdout", "stderr"], chunk: str) -> None:
|
|
53
|
+
if not chunk:
|
|
54
|
+
return
|
|
55
|
+
with self._lock:
|
|
56
|
+
if stream == "stdout":
|
|
57
|
+
self._stdout_parts.append(chunk)
|
|
58
|
+
else:
|
|
59
|
+
self._stderr_parts.append(chunk)
|
|
60
|
+
self._write_log_locked(exit_code="running", finished_at=None)
|
|
61
|
+
self._refresh_summaries_locked()
|
|
62
|
+
now = time.monotonic()
|
|
63
|
+
if now - self._last_update < self._update_interval_seconds:
|
|
64
|
+
return
|
|
65
|
+
self._last_update = now
|
|
66
|
+
self._update_job(self._job)
|
|
67
|
+
|
|
68
|
+
def flush(self) -> None:
|
|
69
|
+
with self._lock:
|
|
70
|
+
self._refresh_summaries_locked()
|
|
71
|
+
self._update_job(self._job)
|
|
72
|
+
|
|
73
|
+
def _write_log_locked(self, *, exit_code: int | str, finished_at: datetime | None) -> None:
|
|
74
|
+
log_text = _runner_log_text(
|
|
75
|
+
self._job,
|
|
76
|
+
exit_code=exit_code,
|
|
77
|
+
started_at=self._started_at,
|
|
78
|
+
finished_at=finished_at,
|
|
79
|
+
stdout="".join(self._stdout_parts),
|
|
80
|
+
stderr="".join(self._stderr_parts),
|
|
81
|
+
)
|
|
82
|
+
self._path.write_text(log_text, encoding="utf-8")
|
|
83
|
+
|
|
84
|
+
def _refresh_summaries_locked(self) -> None:
|
|
85
|
+
self._job.runner_stdout_summary = make_output_summary(
|
|
86
|
+
"".join(self._stdout_parts),
|
|
87
|
+
limit=STDOUT_SUMMARY_LIMIT,
|
|
88
|
+
strip_links=True,
|
|
89
|
+
)
|
|
90
|
+
self._job.runner_stderr_summary = make_output_summary(
|
|
91
|
+
"".join(self._stderr_parts),
|
|
92
|
+
limit=STDERR_SUMMARY_LIMIT,
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def start_incremental_runner_log(
|
|
97
|
+
job: Job,
|
|
98
|
+
worktree_base: Path,
|
|
99
|
+
update_job: Callable[[Job], None],
|
|
100
|
+
) -> IncrementalRunnerLog:
|
|
101
|
+
return IncrementalRunnerLog(job, worktree_base, update_job)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def preserve_partial_output(job: Job, exc: BaseException, worktree_base: Path) -> None:
|
|
105
|
+
# A timed-out or cancelled runner still produced output; persist it so the failure
|
|
106
|
+
# notification can surface the log path and an output summary.
|
|
107
|
+
if not isinstance(exc, RunnerExecutionError):
|
|
108
|
+
return
|
|
109
|
+
save_runner_log(
|
|
110
|
+
job,
|
|
111
|
+
RunnerResult(
|
|
112
|
+
exit_code=-1,
|
|
113
|
+
stdout=exc.stdout,
|
|
114
|
+
stderr=exc.stderr,
|
|
115
|
+
started_at=exc.started_at,
|
|
116
|
+
finished_at=exc.finished_at,
|
|
117
|
+
),
|
|
118
|
+
worktree_base,
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def save_runner_log(job: Job, runner_result, worktree_base: Path) -> None:
|
|
123
|
+
log_dir = worktree_base / "_logs"
|
|
124
|
+
log_dir.mkdir(parents=True, exist_ok=True)
|
|
125
|
+
log_path = log_dir / f"{job.id}.log"
|
|
126
|
+
log_text = _runner_log_text(
|
|
127
|
+
job,
|
|
128
|
+
exit_code=runner_result.exit_code,
|
|
129
|
+
started_at=runner_result.started_at,
|
|
130
|
+
finished_at=runner_result.finished_at,
|
|
131
|
+
stdout=runner_result.stdout,
|
|
132
|
+
stderr=runner_result.stderr,
|
|
133
|
+
)
|
|
134
|
+
log_path.write_text(log_text, encoding="utf-8")
|
|
135
|
+
job.log_path = log_path
|
|
136
|
+
job.runner_stdout_summary = make_output_summary(
|
|
137
|
+
runner_result.stdout,
|
|
138
|
+
limit=STDOUT_SUMMARY_LIMIT,
|
|
139
|
+
strip_links=True,
|
|
140
|
+
)
|
|
141
|
+
job.runner_stderr_summary = make_output_summary(
|
|
142
|
+
runner_result.stderr, limit=STDERR_SUMMARY_LIMIT
|
|
143
|
+
)
|
|
144
|
+
usage = extract_runner_usage(f"{runner_result.stdout}\n{runner_result.stderr}")
|
|
145
|
+
job.runner_actual_model = usage.actual_model
|
|
146
|
+
job.runner_token_usage = usage.token_usage
|
|
147
|
+
job.runner_session_id = runner_result.session_id
|
|
148
|
+
_joblog.info(
|
|
149
|
+
"runner log saved file=%s stdout_summary=%s stderr_summary=%s actual_model=%s token_usage=%s",
|
|
150
|
+
log_path.name,
|
|
151
|
+
job.runner_stdout_summary is not None,
|
|
152
|
+
job.runner_stderr_summary is not None,
|
|
153
|
+
job.runner_actual_model or "-",
|
|
154
|
+
bool(job.runner_token_usage),
|
|
155
|
+
chat_id=job.request.chat_id,
|
|
156
|
+
user_id=job.request.requested_by,
|
|
157
|
+
project=job.request.project,
|
|
158
|
+
job_id=job.id,
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _runner_log_text(
|
|
163
|
+
job: Job,
|
|
164
|
+
*,
|
|
165
|
+
exit_code: int | str,
|
|
166
|
+
started_at: datetime | None,
|
|
167
|
+
finished_at: datetime | None,
|
|
168
|
+
stdout: str,
|
|
169
|
+
stderr: str,
|
|
170
|
+
) -> str:
|
|
171
|
+
return (
|
|
172
|
+
f"job_id={job.id}\n"
|
|
173
|
+
f"model={job.request.model.value}\n"
|
|
174
|
+
f"exit_code={exit_code}\n"
|
|
175
|
+
f"started_at={started_at}\n"
|
|
176
|
+
f"finished_at={finished_at}\n\n"
|
|
177
|
+
f"[stdout]\n{stdout}\n\n"
|
|
178
|
+
f"[stderr]\n{stderr}\n"
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def extract_stdout_from_log(path: Path) -> str | None:
|
|
183
|
+
"""Return the full [stdout] section of a saved runner log, ANSI-stripped."""
|
|
184
|
+
try:
|
|
185
|
+
log_text = path.read_text(encoding="utf-8", errors="replace")
|
|
186
|
+
except OSError:
|
|
187
|
+
return None
|
|
188
|
+
marker = "\n[stdout]\n"
|
|
189
|
+
start = log_text.find(marker)
|
|
190
|
+
if start == -1:
|
|
191
|
+
return None
|
|
192
|
+
start += len(marker)
|
|
193
|
+
stderr_at = log_text.rfind("\n\n[stderr]\n")
|
|
194
|
+
stdout = log_text[start:stderr_at] if stderr_at >= start else log_text[start:]
|
|
195
|
+
stdout = _ANSI_ESCAPE_PATTERN.sub("", stdout)
|
|
196
|
+
return stdout or None
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def strip_links_for_stdout_summary(text: str) -> str:
|
|
200
|
+
stripped = _MD_LINK_PATTERN.sub(r"\1", text)
|
|
201
|
+
stripped = _HTTP_URL_PATTERN.sub("", stripped)
|
|
202
|
+
stripped = _WWW_URL_PATTERN.sub("", stripped)
|
|
203
|
+
stripped = re.sub(r"[ \t]{2,}", " ", stripped)
|
|
204
|
+
return stripped
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def make_output_summary(
|
|
208
|
+
text: str,
|
|
209
|
+
limit: int,
|
|
210
|
+
*,
|
|
211
|
+
strip_links: bool = False,
|
|
212
|
+
) -> str | None:
|
|
213
|
+
if not text:
|
|
214
|
+
return None
|
|
215
|
+
no_ansi = _ANSI_ESCAPE_PATTERN.sub("", text)
|
|
216
|
+
if strip_links:
|
|
217
|
+
no_ansi = strip_links_for_stdout_summary(no_ansi)
|
|
218
|
+
normalized = "\n".join(line.rstrip() for line in no_ansi.splitlines()).strip()
|
|
219
|
+
if not normalized:
|
|
220
|
+
return None
|
|
221
|
+
if len(normalized) <= limit:
|
|
222
|
+
return normalized
|
|
223
|
+
return f"{normalized[:limit].rstrip()}...(truncated)"
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import sqlite3
|
|
4
|
+
from collections.abc import Sequence
|
|
4
5
|
from pathlib import Path
|
|
5
6
|
from threading import Lock
|
|
6
7
|
from typing import Protocol
|
|
@@ -37,6 +38,9 @@ class JobStore(Protocol):
|
|
|
37
38
|
def list_recent_for_project_chat(self, project: str, chat_id: int, limit: int = 20) -> list[Job]:
|
|
38
39
|
...
|
|
39
40
|
|
|
41
|
+
def list_latest_by_status(self, statuses: Sequence[JobStatus]) -> list[Job]:
|
|
42
|
+
...
|
|
43
|
+
|
|
40
44
|
|
|
41
45
|
class InMemoryJobStore:
|
|
42
46
|
def __init__(self) -> None:
|
|
@@ -92,6 +96,19 @@ class InMemoryJobStore:
|
|
|
92
96
|
values.sort(key=lambda job: job.created_at, reverse=True)
|
|
93
97
|
return values[:limit]
|
|
94
98
|
|
|
99
|
+
def list_latest_by_status(self, statuses: Sequence[JobStatus]) -> list[Job]:
|
|
100
|
+
wanted = {status.value for status in statuses}
|
|
101
|
+
if not wanted:
|
|
102
|
+
return []
|
|
103
|
+
with self._lock:
|
|
104
|
+
values = [
|
|
105
|
+
jobs[-1]
|
|
106
|
+
for jobs in self._jobs.values()
|
|
107
|
+
if jobs and jobs[-1].status.value in wanted
|
|
108
|
+
]
|
|
109
|
+
values.sort(key=lambda job: job.created_at)
|
|
110
|
+
return values
|
|
111
|
+
|
|
95
112
|
def get_latest_succeeded_branch_for_project_chat(
|
|
96
113
|
self, project: str, chat_id: int
|
|
97
114
|
) -> str | None:
|
|
@@ -299,6 +316,32 @@ class SQLiteJobStore:
|
|
|
299
316
|
(project, chat_id, limit),
|
|
300
317
|
)
|
|
301
318
|
|
|
319
|
+
def list_latest_by_status(self, statuses: Sequence[JobStatus]) -> list[Job]:
|
|
320
|
+
if not statuses:
|
|
321
|
+
return []
|
|
322
|
+
placeholders = ",".join("?" for _ in statuses)
|
|
323
|
+
return self._fetch_jobs(
|
|
324
|
+
f"""
|
|
325
|
+
SELECT payload
|
|
326
|
+
FROM jobs AS current
|
|
327
|
+
WHERE current.status IN ({placeholders})
|
|
328
|
+
AND NOT EXISTS (
|
|
329
|
+
SELECT 1
|
|
330
|
+
FROM jobs AS newer
|
|
331
|
+
WHERE newer.job_id = current.job_id
|
|
332
|
+
AND (
|
|
333
|
+
newer.created_at > current.created_at
|
|
334
|
+
OR (
|
|
335
|
+
newer.created_at = current.created_at
|
|
336
|
+
AND newer.row_id > current.row_id
|
|
337
|
+
)
|
|
338
|
+
)
|
|
339
|
+
)
|
|
340
|
+
ORDER BY current.created_at ASC, current.row_id ASC
|
|
341
|
+
""",
|
|
342
|
+
tuple(status.value for status in statuses),
|
|
343
|
+
)
|
|
344
|
+
|
|
302
345
|
def get_latest_succeeded_branch_for_project_chat(
|
|
303
346
|
self, project: str, chat_id: int
|
|
304
347
|
) -> str | None:
|
|
@@ -14,6 +14,7 @@ from app.git.ai_commit import AiCommitBodyGenerator
|
|
|
14
14
|
from app.git.branch_naming import TimestampSlugStrategy
|
|
15
15
|
from app.git.service import GitWorktreeService
|
|
16
16
|
from app.jobs.manager import JobManager
|
|
17
|
+
from app.jobs.schemas import Job
|
|
17
18
|
from app.jobs.store import SQLiteJobStore
|
|
18
19
|
from app.monitoring.log_buffer import InMemoryLogBuffer, attach_app_memory_log_handler
|
|
19
20
|
from app.monitoring.events import EventLogger
|
|
@@ -24,7 +25,7 @@ from app.projects.registry import (
|
|
|
24
25
|
projects_config_path,
|
|
25
26
|
)
|
|
26
27
|
from app.security.auth import AllowlistAuthService
|
|
27
|
-
from app.system_startup import run_startup_project_pulls
|
|
28
|
+
from app.system_startup import recover_startup_jobs, run_startup_project_pulls
|
|
28
29
|
from app.telegram.commands import (
|
|
29
30
|
CommandContext,
|
|
30
31
|
CommandRegistry,
|
|
@@ -34,6 +35,8 @@ from app.telegram.commands import (
|
|
|
34
35
|
from app.telegram.bot_instances import BotInstance, BotInstanceManager
|
|
35
36
|
from app.telegram.confirmations import InMemoryConfirmationStore
|
|
36
37
|
from app.telegram.conversation import SQLiteConversationStore
|
|
38
|
+
from app.telegram.handlers.job_submission import JobSubmission
|
|
39
|
+
from app.telegram.handlers.session_binding import SessionBinding
|
|
37
40
|
from app.telegram.notifier import Notifier, TelegramNotifier
|
|
38
41
|
from app.telegram.i18n import ui_message
|
|
39
42
|
from app.telegram.parser import CommandParser
|
|
@@ -138,6 +141,35 @@ webhook_registrar = (
|
|
|
138
141
|
)
|
|
139
142
|
|
|
140
143
|
|
|
144
|
+
def _record_recovered_job_result(final_job: Job) -> None:
|
|
145
|
+
JobSubmission(
|
|
146
|
+
job_manager=job_manager,
|
|
147
|
+
conversation_store=conversation_store,
|
|
148
|
+
attach_session=lambda _request: None,
|
|
149
|
+
persist_session_token=SessionBinding(conversation_store).persist_session_token,
|
|
150
|
+
).record_final_job_result(final_job)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _refresh_project_command_menus() -> None:
|
|
154
|
+
# Re-push setMyCommands for every enabled project on each startup. The Telegram command
|
|
155
|
+
# menu (hamburger button) and slash autocomplete are otherwise only refreshed during
|
|
156
|
+
# `remote-coder up` webhook registration or an admin settings save, so a bot registered
|
|
157
|
+
# before a new command/mode (e.g. research) was added keeps a stale menu on plain restarts.
|
|
158
|
+
registrar = webhook_registrar
|
|
159
|
+
if registrar is None:
|
|
160
|
+
registrar = TelegramWebhookRegistrar(
|
|
161
|
+
"",
|
|
162
|
+
bot_commands=command_registry.bot_commands(advanced_settings_store.get().ui_language),
|
|
163
|
+
)
|
|
164
|
+
synced = 0
|
|
165
|
+
for record in project_registry.list_projects():
|
|
166
|
+
if not record.enabled:
|
|
167
|
+
continue
|
|
168
|
+
if registrar.sync_project_commands(record):
|
|
169
|
+
synced += 1
|
|
170
|
+
_systemlog.info("startup command menus refreshed count=%d", synced)
|
|
171
|
+
|
|
172
|
+
|
|
141
173
|
def _run_startup_side_effects(instances: list[BotInstance], adv: AdvancedSettings) -> None:
|
|
142
174
|
startup_chat_total = sum(len(inst.auth_service.allowed_chat_ids) for inst in instances)
|
|
143
175
|
_systemlog.info(
|
|
@@ -146,6 +178,13 @@ def _run_startup_side_effects(instances: list[BotInstance], adv: AdvancedSetting
|
|
|
146
178
|
len(project_registry.list_projects()),
|
|
147
179
|
ModelName.CLAUDE.value,
|
|
148
180
|
)
|
|
181
|
+
_refresh_project_command_menus()
|
|
182
|
+
recover_startup_jobs(
|
|
183
|
+
job_store=job_store,
|
|
184
|
+
run_job=job_manager.recover,
|
|
185
|
+
record_final_job_result=_record_recovered_job_result,
|
|
186
|
+
system_log=_systemlog,
|
|
187
|
+
)
|
|
149
188
|
run_startup_project_pulls(
|
|
150
189
|
pull_projects_on_server_startup_enabled=adv.pull_projects_on_server_startup_enabled,
|
|
151
190
|
project_registry=project_registry,
|