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.
Files changed (162) hide show
  1. {remote_coder-0.5.2 → remote_coder-0.5.3}/PKG-INFO +4 -2
  2. {remote_coder-0.5.2 → remote_coder-0.5.3}/README.md +3 -1
  3. {remote_coder-0.5.2 → remote_coder-0.5.3}/app/__init__.py +1 -1
  4. {remote_coder-0.5.2 → remote_coder-0.5.3}/app/ai/base.py +86 -16
  5. {remote_coder-0.5.2 → remote_coder-0.5.3}/app/jobs/execution_pipeline.py +3 -0
  6. {remote_coder-0.5.2 → remote_coder-0.5.3}/app/jobs/fix_pipeline.py +3 -0
  7. {remote_coder-0.5.2 → remote_coder-0.5.3}/app/jobs/manager.py +13 -0
  8. remote_coder-0.5.3/app/jobs/result_writer.py +223 -0
  9. {remote_coder-0.5.2 → remote_coder-0.5.3}/app/jobs/store.py +43 -0
  10. {remote_coder-0.5.2 → remote_coder-0.5.3}/app/main.py +40 -1
  11. remote_coder-0.5.3/app/system_startup.py +94 -0
  12. {remote_coder-0.5.2 → remote_coder-0.5.3}/app/telegram/commands/__init__.py +2 -1
  13. {remote_coder-0.5.2 → remote_coder-0.5.3}/app/telegram/commands/base.py +1 -0
  14. {remote_coder-0.5.2 → remote_coder-0.5.3}/app/telegram/commands/registry.py +2 -1
  15. {remote_coder-0.5.2 → remote_coder-0.5.3}/app/telegram/commands/status.py +70 -0
  16. {remote_coder-0.5.2 → remote_coder-0.5.3}/app/telegram/handlers/command_flow.py +1 -1
  17. {remote_coder-0.5.2 → remote_coder-0.5.3}/app/telegram/handlers/job_submission.py +48 -62
  18. {remote_coder-0.5.2 → remote_coder-0.5.3}/app/telegram/i18n.py +4 -0
  19. {remote_coder-0.5.2 → remote_coder-0.5.3}/app/telegram/messages.py +5 -1
  20. {remote_coder-0.5.2 → remote_coder-0.5.3}/app/telegram/notifier.py +9 -2
  21. {remote_coder-0.5.2 → remote_coder-0.5.3}/app/telegram/notifier_protocol.py +8 -1
  22. {remote_coder-0.5.2 → remote_coder-0.5.3}/pyproject.toml +1 -1
  23. {remote_coder-0.5.2 → remote_coder-0.5.3}/remote_coder.egg-info/PKG-INFO +4 -2
  24. {remote_coder-0.5.2 → remote_coder-0.5.3}/remote_coder.egg-info/SOURCES.txt +4 -0
  25. remote_coder-0.5.3/tests/test_base_runner.py +43 -0
  26. {remote_coder-0.5.2 → remote_coder-0.5.3}/tests/test_cli.py +1 -1
  27. remote_coder-0.5.3/tests/test_command_flow.py +83 -0
  28. {remote_coder-0.5.2 → remote_coder-0.5.3}/tests/test_commands.py +223 -0
  29. {remote_coder-0.5.2 → remote_coder-0.5.3}/tests/test_job_manager.py +98 -0
  30. {remote_coder-0.5.2 → remote_coder-0.5.3}/tests/test_job_store.py +108 -0
  31. remote_coder-0.5.3/tests/test_job_submission.py +44 -0
  32. remote_coder-0.5.3/tests/test_main_lifespan.py +99 -0
  33. {remote_coder-0.5.2 → remote_coder-0.5.3}/tests/test_notifier.py +4 -0
  34. remote_coder-0.5.3/tests/test_result_writer.py +68 -0
  35. {remote_coder-0.5.2 → remote_coder-0.5.3}/tests/test_system_startup.py +83 -1
  36. {remote_coder-0.5.2 → remote_coder-0.5.3}/tests/test_webhook.py +1 -1
  37. remote_coder-0.5.2/app/jobs/result_writer.py +0 -104
  38. remote_coder-0.5.2/app/system_startup.py +0 -34
  39. remote_coder-0.5.2/tests/test_main_lifespan.py +0 -67
  40. {remote_coder-0.5.2 → remote_coder-0.5.3}/LICENSE +0 -0
  41. {remote_coder-0.5.2 → remote_coder-0.5.3}/app/admin/__init__.py +0 -0
  42. {remote_coder-0.5.2 → remote_coder-0.5.3}/app/admin/advanced_settings.py +0 -0
  43. {remote_coder-0.5.2 → remote_coder-0.5.3}/app/admin/database_browser.py +0 -0
  44. {remote_coder-0.5.2 → remote_coder-0.5.3}/app/admin/router.py +0 -0
  45. {remote_coder-0.5.2 → remote_coder-0.5.3}/app/admin/static/admin.css +0 -0
  46. {remote_coder-0.5.2 → remote_coder-0.5.3}/app/admin/static/i18n.js +0 -0
  47. {remote_coder-0.5.2 → remote_coder-0.5.3}/app/admin/static/icons/advanced.svg +0 -0
  48. {remote_coder-0.5.2 → remote_coder-0.5.3}/app/admin/static/icons/database.svg +0 -0
  49. {remote_coder-0.5.2 → remote_coder-0.5.3}/app/admin/static/icons/download.svg +0 -0
  50. {remote_coder-0.5.2 → remote_coder-0.5.3}/app/admin/static/icons/home.svg +0 -0
  51. {remote_coder-0.5.2 → remote_coder-0.5.3}/app/admin/static/icons/logs.svg +0 -0
  52. {remote_coder-0.5.2 → remote_coder-0.5.3}/app/admin/static/icons/projects.svg +0 -0
  53. {remote_coder-0.5.2 → remote_coder-0.5.3}/app/admin/static/summary.js +0 -0
  54. {remote_coder-0.5.2 → remote_coder-0.5.3}/app/admin/templates/admin.html +0 -0
  55. {remote_coder-0.5.2 → remote_coder-0.5.3}/app/admin/templates/advanced.html +0 -0
  56. {remote_coder-0.5.2 → remote_coder-0.5.3}/app/admin/templates/database.html +0 -0
  57. {remote_coder-0.5.2 → remote_coder-0.5.3}/app/admin/templates/logs.html +0 -0
  58. {remote_coder-0.5.2 → remote_coder-0.5.3}/app/admin/templates/projects.html +0 -0
  59. {remote_coder-0.5.2 → remote_coder-0.5.3}/app/ai/__init__.py +0 -0
  60. {remote_coder-0.5.2 → remote_coder-0.5.3}/app/ai/claude.py +0 -0
  61. {remote_coder-0.5.2 → remote_coder-0.5.3}/app/ai/codex.py +0 -0
  62. {remote_coder-0.5.2 → remote_coder-0.5.3}/app/ai/factory.py +0 -0
  63. {remote_coder-0.5.2 → remote_coder-0.5.3}/app/ai/gemini.py +0 -0
  64. {remote_coder-0.5.2 → remote_coder-0.5.3}/app/ai/model_catalog.py +0 -0
  65. {remote_coder-0.5.2 → remote_coder-0.5.3}/app/ai/usage.py +0 -0
  66. {remote_coder-0.5.2 → remote_coder-0.5.3}/app/cli.py +0 -0
  67. {remote_coder-0.5.2 → remote_coder-0.5.3}/app/config.py +0 -0
  68. {remote_coder-0.5.2 → remote_coder-0.5.3}/app/diagnostics.py +0 -0
  69. {remote_coder-0.5.2 → remote_coder-0.5.3}/app/git/__init__.py +0 -0
  70. {remote_coder-0.5.2 → remote_coder-0.5.3}/app/git/ai_commit.py +0 -0
  71. {remote_coder-0.5.2 → remote_coder-0.5.3}/app/git/branch_naming.py +0 -0
  72. {remote_coder-0.5.2 → remote_coder-0.5.3}/app/git/commit_message.py +0 -0
  73. {remote_coder-0.5.2 → remote_coder-0.5.3}/app/git/service.py +0 -0
  74. {remote_coder-0.5.2 → remote_coder-0.5.3}/app/git/worktree_service.py +0 -0
  75. {remote_coder-0.5.2 → remote_coder-0.5.3}/app/jobs/__init__.py +0 -0
  76. {remote_coder-0.5.2 → remote_coder-0.5.3}/app/jobs/fix_support.py +0 -0
  77. {remote_coder-0.5.2 → remote_coder-0.5.3}/app/jobs/heartbeat.py +0 -0
  78. {remote_coder-0.5.2 → remote_coder-0.5.3}/app/jobs/plan_decisions.py +0 -0
  79. {remote_coder-0.5.2 → remote_coder-0.5.3}/app/jobs/schemas.py +0 -0
  80. {remote_coder-0.5.2 → remote_coder-0.5.3}/app/jobs/worktree_planner.py +0 -0
  81. {remote_coder-0.5.2 → remote_coder-0.5.3}/app/models.py +0 -0
  82. {remote_coder-0.5.2 → remote_coder-0.5.3}/app/monitoring/__init__.py +0 -0
  83. {remote_coder-0.5.2 → remote_coder-0.5.3}/app/monitoring/code.py +0 -0
  84. {remote_coder-0.5.2 → remote_coder-0.5.3}/app/monitoring/events.py +0 -0
  85. {remote_coder-0.5.2 → remote_coder-0.5.3}/app/monitoring/git.py +0 -0
  86. {remote_coder-0.5.2 → remote_coder-0.5.3}/app/monitoring/log_buffer.py +0 -0
  87. {remote_coder-0.5.2 → remote_coder-0.5.3}/app/monitoring/memory.py +0 -0
  88. {remote_coder-0.5.2 → remote_coder-0.5.3}/app/monitoring/model.py +0 -0
  89. {remote_coder-0.5.2 → remote_coder-0.5.3}/app/projects/__init__.py +0 -0
  90. {remote_coder-0.5.2 → remote_coder-0.5.3}/app/projects/registry.py +0 -0
  91. {remote_coder-0.5.2 → remote_coder-0.5.3}/app/security/__init__.py +0 -0
  92. {remote_coder-0.5.2 → remote_coder-0.5.3}/app/security/auth.py +0 -0
  93. {remote_coder-0.5.2 → remote_coder-0.5.3}/app/telegram/__init__.py +0 -0
  94. {remote_coder-0.5.2 → remote_coder-0.5.3}/app/telegram/bot_instances.py +0 -0
  95. {remote_coder-0.5.2 → remote_coder-0.5.3}/app/telegram/commands/branch.py +0 -0
  96. {remote_coder-0.5.2 → remote_coder-0.5.3}/app/telegram/commands/clear_stop.py +0 -0
  97. {remote_coder-0.5.2 → remote_coder-0.5.3}/app/telegram/commands/fix.py +0 -0
  98. {remote_coder-0.5.2 → remote_coder-0.5.3}/app/telegram/commands/model.py +0 -0
  99. {remote_coder-0.5.2 → remote_coder-0.5.3}/app/telegram/commands/monitor.py +0 -0
  100. {remote_coder-0.5.2 → remote_coder-0.5.3}/app/telegram/commands/system.py +0 -0
  101. {remote_coder-0.5.2 → remote_coder-0.5.3}/app/telegram/confirmations.py +0 -0
  102. {remote_coder-0.5.2 → remote_coder-0.5.3}/app/telegram/conversation/__init__.py +0 -0
  103. {remote_coder-0.5.2 → remote_coder-0.5.3}/app/telegram/conversation/collaborators.py +0 -0
  104. {remote_coder-0.5.2 → remote_coder-0.5.3}/app/telegram/conversation/context.py +0 -0
  105. {remote_coder-0.5.2 → remote_coder-0.5.3}/app/telegram/conversation/models.py +0 -0
  106. {remote_coder-0.5.2 → remote_coder-0.5.3}/app/telegram/conversation/protocols.py +0 -0
  107. {remote_coder-0.5.2 → remote_coder-0.5.3}/app/telegram/conversation/sqlite_rows.py +0 -0
  108. {remote_coder-0.5.2 → remote_coder-0.5.3}/app/telegram/conversation/sqlite_store.py +0 -0
  109. {remote_coder-0.5.2 → remote_coder-0.5.3}/app/telegram/conversation/store.py +0 -0
  110. {remote_coder-0.5.2 → remote_coder-0.5.3}/app/telegram/formatting.py +0 -0
  111. {remote_coder-0.5.2 → remote_coder-0.5.3}/app/telegram/handlers/__init__.py +0 -0
  112. {remote_coder-0.5.2 → remote_coder-0.5.3}/app/telegram/handlers/callback_dispatcher.py +0 -0
  113. {remote_coder-0.5.2 → remote_coder-0.5.3}/app/telegram/handlers/fix_flow.py +0 -0
  114. {remote_coder-0.5.2 → remote_coder-0.5.3}/app/telegram/handlers/natural_flow.py +0 -0
  115. {remote_coder-0.5.2 → remote_coder-0.5.3}/app/telegram/handlers/plan_flow.py +0 -0
  116. {remote_coder-0.5.2 → remote_coder-0.5.3}/app/telegram/handlers/presenters.py +0 -0
  117. {remote_coder-0.5.2 → remote_coder-0.5.3}/app/telegram/handlers/recent_updates.py +0 -0
  118. {remote_coder-0.5.2 → remote_coder-0.5.3}/app/telegram/handlers/request.py +0 -0
  119. {remote_coder-0.5.2 → remote_coder-0.5.3}/app/telegram/handlers/session_binding.py +0 -0
  120. {remote_coder-0.5.2 → remote_coder-0.5.3}/app/telegram/handlers/update_handler.py +0 -0
  121. {remote_coder-0.5.2 → remote_coder-0.5.3}/app/telegram/lists.py +0 -0
  122. {remote_coder-0.5.2 → remote_coder-0.5.3}/app/telegram/model_preferences.py +0 -0
  123. {remote_coder-0.5.2 → remote_coder-0.5.3}/app/telegram/parser.py +0 -0
  124. {remote_coder-0.5.2 → remote_coder-0.5.3}/app/telegram/plan_decisions_flow.py +0 -0
  125. {remote_coder-0.5.2 → remote_coder-0.5.3}/app/telegram/webhook.py +0 -0
  126. {remote_coder-0.5.2 → remote_coder-0.5.3}/app/telegram/webhook_registration.py +0 -0
  127. {remote_coder-0.5.2 → remote_coder-0.5.3}/app/tunnel.py +0 -0
  128. {remote_coder-0.5.2 → remote_coder-0.5.3}/remote_coder.egg-info/dependency_links.txt +0 -0
  129. {remote_coder-0.5.2 → remote_coder-0.5.3}/remote_coder.egg-info/entry_points.txt +0 -0
  130. {remote_coder-0.5.2 → remote_coder-0.5.3}/remote_coder.egg-info/requires.txt +0 -0
  131. {remote_coder-0.5.2 → remote_coder-0.5.3}/remote_coder.egg-info/top_level.txt +0 -0
  132. {remote_coder-0.5.2 → remote_coder-0.5.3}/setup.cfg +0 -0
  133. {remote_coder-0.5.2 → remote_coder-0.5.3}/tests/test_admin_router.py +0 -0
  134. {remote_coder-0.5.2 → remote_coder-0.5.3}/tests/test_ai_base.py +0 -0
  135. {remote_coder-0.5.2 → remote_coder-0.5.3}/tests/test_ai_commit.py +0 -0
  136. {remote_coder-0.5.2 → remote_coder-0.5.3}/tests/test_ai_factory.py +0 -0
  137. {remote_coder-0.5.2 → remote_coder-0.5.3}/tests/test_auth.py +0 -0
  138. {remote_coder-0.5.2 → remote_coder-0.5.3}/tests/test_bot_instance_manager.py +0 -0
  139. {remote_coder-0.5.2 → remote_coder-0.5.3}/tests/test_branch_naming.py +0 -0
  140. {remote_coder-0.5.2 → remote_coder-0.5.3}/tests/test_claude_runner.py +0 -0
  141. {remote_coder-0.5.2 → remote_coder-0.5.3}/tests/test_codex_runner.py +0 -0
  142. {remote_coder-0.5.2 → remote_coder-0.5.3}/tests/test_command_parser.py +0 -0
  143. {remote_coder-0.5.2 → remote_coder-0.5.3}/tests/test_commit_message_formatter.py +0 -0
  144. {remote_coder-0.5.2 → remote_coder-0.5.3}/tests/test_config.py +0 -0
  145. {remote_coder-0.5.2 → remote_coder-0.5.3}/tests/test_conversation_store.py +0 -0
  146. {remote_coder-0.5.2 → remote_coder-0.5.3}/tests/test_database_browser.py +0 -0
  147. {remote_coder-0.5.2 → remote_coder-0.5.3}/tests/test_diagnostics.py +0 -0
  148. {remote_coder-0.5.2 → remote_coder-0.5.3}/tests/test_event_logger.py +0 -0
  149. {remote_coder-0.5.2 → remote_coder-0.5.3}/tests/test_gemini_runner.py +0 -0
  150. {remote_coder-0.5.2 → remote_coder-0.5.3}/tests/test_git_service.py +0 -0
  151. {remote_coder-0.5.2 → remote_coder-0.5.3}/tests/test_i18n.py +0 -0
  152. {remote_coder-0.5.2 → remote_coder-0.5.3}/tests/test_job_status.py +0 -0
  153. {remote_coder-0.5.2 → remote_coder-0.5.3}/tests/test_lists.py +0 -0
  154. {remote_coder-0.5.2 → remote_coder-0.5.3}/tests/test_log_buffer.py +0 -0
  155. {remote_coder-0.5.2 → remote_coder-0.5.3}/tests/test_monitoring.py +0 -0
  156. {remote_coder-0.5.2 → remote_coder-0.5.3}/tests/test_plan_decisions.py +0 -0
  157. {remote_coder-0.5.2 → remote_coder-0.5.3}/tests/test_project_registry.py +0 -0
  158. {remote_coder-0.5.2 → remote_coder-0.5.3}/tests/test_project_scoped_state.py +0 -0
  159. {remote_coder-0.5.2 → remote_coder-0.5.3}/tests/test_telegram_formatting.py +0 -0
  160. {remote_coder-0.5.2 → remote_coder-0.5.3}/tests/test_tunnel.py +0 -0
  161. {remote_coder-0.5.2 → remote_coder-0.5.3}/tests/test_webhook_multibot.py +0 -0
  162. {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.2
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
 
@@ -1,3 +1,3 @@
1
1
  """Remote AI Coder application package."""
2
2
 
3
- __version__ = "0.5.2"
3
+ __version__ = "0.5.3"
@@ -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
- try:
217
- stdout_data, stderr_data = proc.communicate(timeout=runner_input.timeout_seconds)
218
- except subprocess.TimeoutExpired as exc:
219
- proc.kill()
220
- stdout_data, stderr_data = proc.communicate()
221
- self._log.warning(
222
- "timeout after %ds stdout_len=%d stderr_len=%d",
223
- runner_input.timeout_seconds,
224
- len(stdout_data),
225
- len(stderr_data),
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,