codex-autorunner 0.1.2__py3-none-any.whl → 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (189) hide show
  1. codex_autorunner/__main__.py +4 -0
  2. codex_autorunner/agents/opencode/client.py +68 -35
  3. codex_autorunner/agents/opencode/logging.py +21 -5
  4. codex_autorunner/agents/opencode/run_prompt.py +1 -0
  5. codex_autorunner/agents/opencode/runtime.py +118 -30
  6. codex_autorunner/agents/opencode/supervisor.py +36 -48
  7. codex_autorunner/agents/registry.py +136 -8
  8. codex_autorunner/api.py +25 -0
  9. codex_autorunner/bootstrap.py +16 -35
  10. codex_autorunner/cli.py +157 -139
  11. codex_autorunner/core/about_car.py +44 -32
  12. codex_autorunner/core/adapter_utils.py +21 -0
  13. codex_autorunner/core/app_server_logging.py +7 -3
  14. codex_autorunner/core/app_server_prompts.py +27 -260
  15. codex_autorunner/core/app_server_threads.py +15 -26
  16. codex_autorunner/core/codex_runner.py +6 -0
  17. codex_autorunner/core/config.py +390 -100
  18. codex_autorunner/core/docs.py +10 -2
  19. codex_autorunner/core/drafts.py +82 -0
  20. codex_autorunner/core/engine.py +278 -262
  21. codex_autorunner/core/flows/__init__.py +25 -0
  22. codex_autorunner/core/flows/controller.py +178 -0
  23. codex_autorunner/core/flows/definition.py +82 -0
  24. codex_autorunner/core/flows/models.py +75 -0
  25. codex_autorunner/core/flows/runtime.py +351 -0
  26. codex_autorunner/core/flows/store.py +485 -0
  27. codex_autorunner/core/flows/transition.py +133 -0
  28. codex_autorunner/core/flows/worker_process.py +242 -0
  29. codex_autorunner/core/hub.py +15 -9
  30. codex_autorunner/core/locks.py +4 -0
  31. codex_autorunner/core/prompt.py +15 -7
  32. codex_autorunner/core/redaction.py +29 -0
  33. codex_autorunner/core/review_context.py +5 -8
  34. codex_autorunner/core/run_index.py +6 -0
  35. codex_autorunner/core/runner_process.py +5 -2
  36. codex_autorunner/core/state.py +0 -88
  37. codex_autorunner/core/static_assets.py +55 -0
  38. codex_autorunner/core/supervisor_utils.py +67 -0
  39. codex_autorunner/core/update.py +20 -11
  40. codex_autorunner/core/update_runner.py +2 -0
  41. codex_autorunner/core/utils.py +29 -2
  42. codex_autorunner/discovery.py +2 -4
  43. codex_autorunner/flows/ticket_flow/__init__.py +3 -0
  44. codex_autorunner/flows/ticket_flow/definition.py +91 -0
  45. codex_autorunner/integrations/agents/__init__.py +27 -0
  46. codex_autorunner/integrations/agents/agent_backend.py +142 -0
  47. codex_autorunner/integrations/agents/codex_backend.py +307 -0
  48. codex_autorunner/integrations/agents/opencode_backend.py +325 -0
  49. codex_autorunner/integrations/agents/run_event.py +71 -0
  50. codex_autorunner/integrations/app_server/client.py +576 -92
  51. codex_autorunner/integrations/app_server/supervisor.py +59 -33
  52. codex_autorunner/integrations/telegram/adapter.py +141 -167
  53. codex_autorunner/integrations/telegram/api_schemas.py +120 -0
  54. codex_autorunner/integrations/telegram/config.py +175 -0
  55. codex_autorunner/integrations/telegram/constants.py +16 -1
  56. codex_autorunner/integrations/telegram/dispatch.py +17 -0
  57. codex_autorunner/integrations/telegram/doctor.py +47 -0
  58. codex_autorunner/integrations/telegram/handlers/callbacks.py +0 -4
  59. codex_autorunner/integrations/telegram/handlers/commands/__init__.py +2 -0
  60. codex_autorunner/integrations/telegram/handlers/commands/execution.py +53 -57
  61. codex_autorunner/integrations/telegram/handlers/commands/files.py +2 -6
  62. codex_autorunner/integrations/telegram/handlers/commands/flows.py +227 -0
  63. codex_autorunner/integrations/telegram/handlers/commands/formatting.py +1 -1
  64. codex_autorunner/integrations/telegram/handlers/commands/github.py +41 -582
  65. codex_autorunner/integrations/telegram/handlers/commands/workspace.py +8 -8
  66. codex_autorunner/integrations/telegram/handlers/commands_runtime.py +133 -475
  67. codex_autorunner/integrations/telegram/handlers/commands_spec.py +11 -4
  68. codex_autorunner/integrations/telegram/handlers/messages.py +120 -9
  69. codex_autorunner/integrations/telegram/helpers.py +88 -16
  70. codex_autorunner/integrations/telegram/outbox.py +208 -37
  71. codex_autorunner/integrations/telegram/progress_stream.py +3 -10
  72. codex_autorunner/integrations/telegram/service.py +214 -40
  73. codex_autorunner/integrations/telegram/state.py +100 -2
  74. codex_autorunner/integrations/telegram/ticket_flow_bridge.py +322 -0
  75. codex_autorunner/integrations/telegram/transport.py +36 -3
  76. codex_autorunner/integrations/telegram/trigger_mode.py +53 -0
  77. codex_autorunner/manifest.py +2 -0
  78. codex_autorunner/plugin_api.py +22 -0
  79. codex_autorunner/routes/__init__.py +23 -14
  80. codex_autorunner/routes/analytics.py +239 -0
  81. codex_autorunner/routes/base.py +81 -109
  82. codex_autorunner/routes/file_chat.py +836 -0
  83. codex_autorunner/routes/flows.py +980 -0
  84. codex_autorunner/routes/messages.py +459 -0
  85. codex_autorunner/routes/system.py +6 -1
  86. codex_autorunner/routes/usage.py +87 -0
  87. codex_autorunner/routes/workspace.py +271 -0
  88. codex_autorunner/server.py +2 -1
  89. codex_autorunner/static/agentControls.js +1 -0
  90. codex_autorunner/static/agentEvents.js +248 -0
  91. codex_autorunner/static/app.js +25 -22
  92. codex_autorunner/static/autoRefresh.js +29 -1
  93. codex_autorunner/static/bootstrap.js +1 -0
  94. codex_autorunner/static/bus.js +1 -0
  95. codex_autorunner/static/cache.js +1 -0
  96. codex_autorunner/static/constants.js +20 -4
  97. codex_autorunner/static/dashboard.js +162 -196
  98. codex_autorunner/static/diffRenderer.js +37 -0
  99. codex_autorunner/static/docChatCore.js +324 -0
  100. codex_autorunner/static/docChatStorage.js +65 -0
  101. codex_autorunner/static/docChatVoice.js +65 -0
  102. codex_autorunner/static/docEditor.js +133 -0
  103. codex_autorunner/static/env.js +1 -0
  104. codex_autorunner/static/eventSummarizer.js +166 -0
  105. codex_autorunner/static/fileChat.js +182 -0
  106. codex_autorunner/static/health.js +155 -0
  107. codex_autorunner/static/hub.js +41 -118
  108. codex_autorunner/static/index.html +787 -858
  109. codex_autorunner/static/liveUpdates.js +1 -0
  110. codex_autorunner/static/loader.js +1 -0
  111. codex_autorunner/static/messages.js +470 -0
  112. codex_autorunner/static/mobileCompact.js +2 -1
  113. codex_autorunner/static/settings.js +24 -211
  114. codex_autorunner/static/styles.css +7567 -3865
  115. codex_autorunner/static/tabs.js +28 -5
  116. codex_autorunner/static/terminal.js +14 -0
  117. codex_autorunner/static/terminalManager.js +34 -59
  118. codex_autorunner/static/ticketChatActions.js +333 -0
  119. codex_autorunner/static/ticketChatEvents.js +16 -0
  120. codex_autorunner/static/ticketChatStorage.js +16 -0
  121. codex_autorunner/static/ticketChatStream.js +264 -0
  122. codex_autorunner/static/ticketEditor.js +750 -0
  123. codex_autorunner/static/ticketVoice.js +9 -0
  124. codex_autorunner/static/tickets.js +1315 -0
  125. codex_autorunner/static/utils.js +32 -3
  126. codex_autorunner/static/voice.js +1 -0
  127. codex_autorunner/static/workspace.js +672 -0
  128. codex_autorunner/static/workspaceApi.js +53 -0
  129. codex_autorunner/static/workspaceFileBrowser.js +504 -0
  130. codex_autorunner/tickets/__init__.py +20 -0
  131. codex_autorunner/tickets/agent_pool.py +377 -0
  132. codex_autorunner/tickets/files.py +85 -0
  133. codex_autorunner/tickets/frontmatter.py +55 -0
  134. codex_autorunner/tickets/lint.py +102 -0
  135. codex_autorunner/tickets/models.py +95 -0
  136. codex_autorunner/tickets/outbox.py +232 -0
  137. codex_autorunner/tickets/replies.py +179 -0
  138. codex_autorunner/tickets/runner.py +823 -0
  139. codex_autorunner/tickets/spec_ingest.py +77 -0
  140. codex_autorunner/web/app.py +269 -91
  141. codex_autorunner/web/middleware.py +3 -4
  142. codex_autorunner/web/schemas.py +89 -109
  143. codex_autorunner/web/static_assets.py +1 -44
  144. codex_autorunner/workspace/__init__.py +40 -0
  145. codex_autorunner/workspace/paths.py +319 -0
  146. {codex_autorunner-0.1.2.dist-info → codex_autorunner-1.0.0.dist-info}/METADATA +18 -21
  147. codex_autorunner-1.0.0.dist-info/RECORD +251 -0
  148. {codex_autorunner-0.1.2.dist-info → codex_autorunner-1.0.0.dist-info}/WHEEL +1 -1
  149. codex_autorunner/agents/execution/policy.py +0 -292
  150. codex_autorunner/agents/factory.py +0 -52
  151. codex_autorunner/agents/orchestrator.py +0 -358
  152. codex_autorunner/core/doc_chat.py +0 -1446
  153. codex_autorunner/core/snapshot.py +0 -580
  154. codex_autorunner/integrations/github/chatops.py +0 -268
  155. codex_autorunner/integrations/github/pr_flow.py +0 -1314
  156. codex_autorunner/routes/docs.py +0 -381
  157. codex_autorunner/routes/github.py +0 -327
  158. codex_autorunner/routes/runs.py +0 -250
  159. codex_autorunner/spec_ingest.py +0 -812
  160. codex_autorunner/static/docChatActions.js +0 -287
  161. codex_autorunner/static/docChatEvents.js +0 -300
  162. codex_autorunner/static/docChatRender.js +0 -205
  163. codex_autorunner/static/docChatStream.js +0 -361
  164. codex_autorunner/static/docs.js +0 -20
  165. codex_autorunner/static/docsClipboard.js +0 -69
  166. codex_autorunner/static/docsCrud.js +0 -257
  167. codex_autorunner/static/docsDocUpdates.js +0 -62
  168. codex_autorunner/static/docsDrafts.js +0 -16
  169. codex_autorunner/static/docsElements.js +0 -69
  170. codex_autorunner/static/docsInit.js +0 -285
  171. codex_autorunner/static/docsParse.js +0 -160
  172. codex_autorunner/static/docsSnapshot.js +0 -87
  173. codex_autorunner/static/docsSpecIngest.js +0 -263
  174. codex_autorunner/static/docsState.js +0 -127
  175. codex_autorunner/static/docsThreadRegistry.js +0 -44
  176. codex_autorunner/static/docsUi.js +0 -153
  177. codex_autorunner/static/docsVoice.js +0 -56
  178. codex_autorunner/static/github.js +0 -504
  179. codex_autorunner/static/logs.js +0 -678
  180. codex_autorunner/static/review.js +0 -157
  181. codex_autorunner/static/runs.js +0 -418
  182. codex_autorunner/static/snapshot.js +0 -124
  183. codex_autorunner/static/state.js +0 -94
  184. codex_autorunner/static/todoPreview.js +0 -27
  185. codex_autorunner/workspace.py +0 -16
  186. codex_autorunner-0.1.2.dist-info/RECORD +0 -222
  187. {codex_autorunner-0.1.2.dist-info → codex_autorunner-1.0.0.dist-info}/entry_points.txt +0 -0
  188. {codex_autorunner-0.1.2.dist-info → codex_autorunner-1.0.0.dist-info}/licenses/LICENSE +0 -0
  189. {codex_autorunner-0.1.2.dist-info → codex_autorunner-1.0.0.dist-info}/top_level.txt +0 -0
@@ -7,24 +7,25 @@ import logging
7
7
  import os
8
8
  import socket
9
9
  import time
10
+ from datetime import datetime
10
11
  from pathlib import Path
11
- from typing import TYPE_CHECKING, Any, Coroutine, Optional, Sequence
12
+ from typing import TYPE_CHECKING, Any, Callable, Coroutine, Optional, Sequence
12
13
 
13
14
  if TYPE_CHECKING:
14
15
  from .progress_stream import TurnProgressTracker
15
16
  from .state import TelegramTopicRecord
16
17
 
17
18
  from ...agents.opencode.supervisor import OpenCodeSupervisor
19
+ from ...core.flows.models import FlowRunRecord
18
20
  from ...core.locks import process_alive
19
21
  from ...core.logging_utils import log_event
20
22
  from ...core.request_context import reset_conversation_id, set_conversation_id
21
23
  from ...core.state import now_iso
22
24
  from ...core.text_delta_coalescer import TextDeltaCoalescer
23
- from ...core.utils import (
24
- build_opencode_supervisor,
25
- )
25
+ from ...core.utils import build_opencode_supervisor
26
26
  from ...housekeeping import HousekeepingConfig, run_housekeeping_for_roots
27
27
  from ...manifest import load_manifest
28
+ from ...tickets.replies import dispatch_reply, ensure_reply_dirs, resolve_reply_paths
28
29
  from ...voice import VoiceConfig, VoiceService
29
30
  from ..app_server.supervisor import WorkspaceAppServerSupervisor
30
31
  from .adapter import (
@@ -45,21 +46,9 @@ from .config import (
45
46
  TelegramMediaCandidate,
46
47
  )
47
48
  from .constants import (
48
- CACHE_CLEANUP_INTERVAL_SECONDS,
49
- COALESCE_BUFFER_TTL_SECONDS,
50
49
  DEFAULT_INTERRUPT_TIMEOUT_SECONDS,
51
50
  DEFAULT_WORKSPACE_STATE_ROOT,
52
- MEDIA_BATCH_BUFFER_TTL_SECONDS,
53
- MODEL_PENDING_TTL_SECONDS,
54
- OVERSIZE_WARNING_TTL_SECONDS,
55
- PENDING_APPROVAL_TTL_SECONDS,
56
- PENDING_QUESTION_TTL_SECONDS,
57
- PROGRESS_STREAM_TTL_SECONDS,
58
51
  QUEUED_PLACEHOLDER_TEXT,
59
- REASONING_BUFFER_TTL_SECONDS,
60
- SELECTION_STATE_TTL_SECONDS,
61
- TURN_PREVIEW_TTL_SECONDS,
62
- UPDATE_ID_PERSIST_INTERVAL_SECONDS,
63
52
  TurnKey,
64
53
  )
65
54
  from .dispatch import dispatch_update
@@ -88,6 +77,7 @@ from .state import (
88
77
  parse_topic_key,
89
78
  topic_key,
90
79
  )
80
+ from .ticket_flow_bridge import TelegramTicketFlowBridge
91
81
  from .transport import TelegramMessageTransport
92
82
  from .types import (
93
83
  CompactState,
@@ -100,17 +90,19 @@ from .types import (
100
90
  )
101
91
  from .voice import TelegramVoiceManager
102
92
 
93
+ TICKET_FLOW_WATCH_INTERVAL_SECONDS = 20
94
+
103
95
 
104
96
  def _build_opencode_supervisor(
105
97
  config: TelegramBotConfig,
106
98
  *,
107
99
  logger: logging.Logger,
108
100
  ) -> Optional[OpenCodeSupervisor]:
109
- raw_command = os.environ.get("CAR_OPENCODE_COMMAND")
101
+ opencode_command = config.opencode_command or None
110
102
  opencode_binary = config.agent_binaries.get("opencode")
111
103
 
112
104
  supervisor = build_opencode_supervisor(
113
- opencode_command=[raw_command] if raw_command else None,
105
+ opencode_command=opencode_command,
114
106
  opencode_binary=opencode_binary,
115
107
  workspace_root=config.root,
116
108
  logger=logger,
@@ -133,6 +125,26 @@ def _build_opencode_supervisor(
133
125
  return supervisor
134
126
 
135
127
 
128
+ def _next_reply_seq_sync(reply_history_dir: Any) -> int:
129
+ from pathlib import Path
130
+
131
+ path = Path(reply_history_dir)
132
+ if not path.exists() or not path.is_dir():
133
+ return 1
134
+ existing: list[int] = []
135
+ _SEQ_RE = __import__("re").compile(r"^[0-9]{4}$")
136
+ for child in path.iterdir():
137
+ try:
138
+ if not child.is_dir():
139
+ continue
140
+ if not _SEQ_RE.fullmatch(child.name):
141
+ continue
142
+ existing.append(int(child.name))
143
+ except OSError:
144
+ continue
145
+ return (max(existing) + 1) if existing else 1
146
+
147
+
136
148
  class TelegramBotService(
137
149
  TelegramRuntimeHelpers,
138
150
  TelegramMessageTransport,
@@ -154,6 +166,8 @@ class TelegramBotService(
154
166
  housekeeping_config: Optional[HousekeepingConfig] = None,
155
167
  update_repo_url: Optional[str] = None,
156
168
  update_repo_ref: Optional[str] = None,
169
+ update_skip_checks: bool = False,
170
+ app_server_auto_restart: Optional[bool] = None,
157
171
  ) -> None:
158
172
  self._config = config
159
173
  self._logger = logger or logging.getLogger(__name__)
@@ -161,6 +175,8 @@ class TelegramBotService(
161
175
  self._manifest_path = manifest_path
162
176
  self._update_repo_url = update_repo_url
163
177
  self._update_repo_ref = update_repo_ref
178
+ self._update_skip_checks = update_skip_checks
179
+ self._app_server_auto_restart = app_server_auto_restart
164
180
  self._allowlist = config.allowlist()
165
181
  self._store = TelegramStateStore(
166
182
  config.state_file, default_approval_mode=config.defaults.approval_mode
@@ -174,6 +190,7 @@ class TelegramBotService(
174
190
  approval_handler=self._handle_approval_request,
175
191
  notification_handler=self._handle_app_server_notification,
176
192
  logger=self._logger,
193
+ auto_restart=self._app_server_auto_restart,
177
194
  max_handles=config.app_server_max_handles,
178
195
  idle_ttl_seconds=config.app_server_idle_ttl_seconds,
179
196
  )
@@ -224,6 +241,13 @@ class TelegramBotService(
224
241
  self._oversize_warnings: set[TurnKey] = set()
225
242
  self._pending_approvals: dict[str, PendingApproval] = {}
226
243
  self._pending_questions: dict[str, PendingQuestion] = {}
244
+ self._ticket_flow_pause_targets: dict[str, str] = {}
245
+ self._ticket_flow_bridge = TelegramTicketFlowBridge(
246
+ logger=self._logger,
247
+ store=self._store,
248
+ pause_targets=self._ticket_flow_pause_targets,
249
+ send_message_with_outbox=self._send_message_with_outbox,
250
+ )
227
251
  self._resume_options: dict[str, SelectionState] = {}
228
252
  self._bind_options: dict[str, SelectionState] = {}
229
253
  self._update_options: dict[str, SelectionState] = {}
@@ -249,6 +273,7 @@ class TelegramBotService(
249
273
  )
250
274
  self._outbox_task: Optional[asyncio.Task[None]] = None
251
275
  self._cache_cleanup_task: Optional[asyncio.Task[None]] = None
276
+ self._ticket_flow_watch_task: Optional[asyncio.Task[None]] = None
252
277
  self._cache_timestamps: dict[str, dict[object, float]] = {}
253
278
  self._last_update_ids: dict[str, int] = {}
254
279
  self._last_update_persisted_at: dict[str, float] = {}
@@ -540,6 +565,11 @@ class TelegramBotService(
540
565
  self._voice_task = asyncio.create_task(self._voice_manager.run_loop())
541
566
  self._housekeeping_task = asyncio.create_task(self._housekeeping_loop())
542
567
  self._cache_cleanup_task = asyncio.create_task(self._cache_cleanup_loop())
568
+ self._ticket_flow_watch_task = asyncio.create_task(
569
+ self._ticket_flow_bridge.watch_ticket_flow_pauses(
570
+ TICKET_FLOW_WATCH_INTERVAL_SECONDS
571
+ )
572
+ )
543
573
  self._spawn_task(self._prewarm_workspace_clients())
544
574
  log_event(
545
575
  self._logger,
@@ -557,6 +587,9 @@ class TelegramBotService(
557
587
  media_images=self._config.media.images,
558
588
  media_voice=self._config.media.voice,
559
589
  app_server_turn_timeout_seconds=self._config.app_server_turn_timeout_seconds,
590
+ agent_turn_timeout_seconds=dict(
591
+ self._config.agent_turn_timeout_seconds
592
+ ),
560
593
  poller_offset=self._poller.offset,
561
594
  )
562
595
  try:
@@ -622,6 +655,12 @@ class TelegramBotService(
622
655
  await self._cache_cleanup_task
623
656
  except asyncio.CancelledError:
624
657
  pass
658
+ if self._ticket_flow_watch_task is not None:
659
+ self._ticket_flow_watch_task.cancel()
660
+ try:
661
+ await self._ticket_flow_watch_task
662
+ except asyncio.CancelledError:
663
+ pass
625
664
  if self._spawned_tasks:
626
665
  for task in list(self._spawned_tasks):
627
666
  task.cancel()
@@ -871,73 +910,206 @@ class TelegramBotService(
871
910
  self._pending_questions.pop(key, None)
872
911
 
873
912
  async def _cache_cleanup_loop(self) -> None:
874
- interval = max(CACHE_CLEANUP_INTERVAL_SECONDS, 1.0)
913
+ interval = max(self._config.cache.cleanup_interval_seconds, 1.0)
875
914
  while True:
876
915
  await asyncio.sleep(interval)
877
916
  self._evict_expired_cache_entries(
878
- "reasoning_buffers", REASONING_BUFFER_TTL_SECONDS
917
+ "reasoning_buffers", self._config.cache.reasoning_buffer_ttl_seconds
918
+ )
919
+ self._evict_expired_cache_entries(
920
+ "turn_preview", self._config.cache.turn_preview_ttl_seconds
879
921
  )
880
- self._evict_expired_cache_entries("turn_preview", TURN_PREVIEW_TTL_SECONDS)
881
922
  self._evict_expired_cache_entries(
882
- "progress_trackers", PROGRESS_STREAM_TTL_SECONDS
923
+ "progress_trackers", self._config.cache.progress_stream_ttl_seconds
883
924
  )
884
925
  self._evict_expired_cache_entries(
885
- "oversize_warnings", OVERSIZE_WARNING_TTL_SECONDS
926
+ "oversize_warnings", self._config.cache.oversize_warning_ttl_seconds
886
927
  )
887
928
  self._evict_expired_cache_entries(
888
- "coalesced_buffers", COALESCE_BUFFER_TTL_SECONDS
929
+ "coalesced_buffers", self._config.cache.coalesce_buffer_ttl_seconds
889
930
  )
890
931
  self._evict_expired_cache_entries(
891
- "media_batch_buffers", MEDIA_BATCH_BUFFER_TTL_SECONDS
932
+ "media_batch_buffers",
933
+ self._config.cache.media_batch_buffer_ttl_seconds,
892
934
  )
893
935
  self._evict_expired_cache_entries(
894
- "resume_options", SELECTION_STATE_TTL_SECONDS
936
+ "resume_options", self._config.cache.selection_state_ttl_seconds
895
937
  )
896
938
  self._evict_expired_cache_entries(
897
- "bind_options", SELECTION_STATE_TTL_SECONDS
939
+ "bind_options", self._config.cache.selection_state_ttl_seconds
898
940
  )
899
941
  self._evict_expired_cache_entries(
900
- "agent_options", SELECTION_STATE_TTL_SECONDS
942
+ "agent_options", self._config.cache.selection_state_ttl_seconds
901
943
  )
902
944
  self._evict_expired_cache_entries(
903
- "update_options", SELECTION_STATE_TTL_SECONDS
945
+ "update_options", self._config.cache.selection_state_ttl_seconds
904
946
  )
905
947
  self._evict_expired_cache_entries(
906
- "update_confirm_options", SELECTION_STATE_TTL_SECONDS
948
+ "update_confirm_options",
949
+ self._config.cache.selection_state_ttl_seconds,
907
950
  )
908
951
  self._evict_expired_cache_entries(
909
- "review_commit_options", SELECTION_STATE_TTL_SECONDS
952
+ "review_commit_options",
953
+ self._config.cache.selection_state_ttl_seconds,
910
954
  )
911
955
  self._evict_expired_cache_entries(
912
- "review_commit_subjects", SELECTION_STATE_TTL_SECONDS
956
+ "review_commit_subjects",
957
+ self._config.cache.selection_state_ttl_seconds,
913
958
  )
914
959
  self._evict_expired_cache_entries(
915
- "pending_review_custom", SELECTION_STATE_TTL_SECONDS
960
+ "pending_review_custom",
961
+ self._config.cache.selection_state_ttl_seconds,
916
962
  )
917
963
  self._evict_expired_cache_entries(
918
- "compact_pending", SELECTION_STATE_TTL_SECONDS
964
+ "compact_pending", self._config.cache.selection_state_ttl_seconds
919
965
  )
920
966
  self._evict_expired_cache_entries(
921
- "model_options", SELECTION_STATE_TTL_SECONDS
967
+ "model_options", self._config.cache.selection_state_ttl_seconds
922
968
  )
923
969
  self._evict_expired_cache_entries(
924
- "model_pending", MODEL_PENDING_TTL_SECONDS
970
+ "model_pending", self._config.cache.model_pending_ttl_seconds
925
971
  )
926
972
  self._evict_expired_cache_entries(
927
- "pending_approvals", PENDING_APPROVAL_TTL_SECONDS
973
+ "pending_approvals", self._config.cache.pending_approval_ttl_seconds
928
974
  )
929
975
  self._evict_expired_cache_entries(
930
- "pending_questions", PENDING_QUESTION_TTL_SECONDS
976
+ "pending_questions", self._config.cache.pending_question_ttl_seconds
931
977
  )
932
978
  now = time.monotonic()
933
979
  expired_placeholders = []
934
980
  for key, timestamp in self._queued_placeholder_timestamps.items():
935
- if (now - timestamp) > PENDING_APPROVAL_TTL_SECONDS:
981
+ if (now - timestamp) > self._config.cache.pending_approval_ttl_seconds:
936
982
  expired_placeholders.append(key)
937
983
  for key in expired_placeholders:
938
984
  self._queued_placeholder_map.pop(key, None)
939
985
  self._queued_placeholder_timestamps.pop(key, None)
940
986
 
987
+ @staticmethod
988
+ def _parse_last_active(record: "TelegramTopicRecord") -> float:
989
+ raw = getattr(record, "last_active_at", None)
990
+ if isinstance(raw, str):
991
+ try:
992
+ return datetime.strptime(raw, "%Y-%m-%dT%H:%M:%SZ").timestamp()
993
+ except ValueError:
994
+ return float("-inf")
995
+ return float("-inf")
996
+
997
+ def _select_ticket_flow_topic(
998
+ self, entries: list[tuple[str, "TelegramTopicRecord"]]
999
+ ) -> Optional[tuple[str, "TelegramTopicRecord"]]:
1000
+ return self._ticket_flow_bridge._select_ticket_flow_topic(entries)
1001
+
1002
+ @staticmethod
1003
+ def _set_ticket_dispatch_marker(
1004
+ value: Optional[str],
1005
+ ) -> "Callable[[TelegramTopicRecord], None]":
1006
+ def apply(topic: "TelegramTopicRecord") -> None:
1007
+ topic.last_ticket_dispatch_seq = value
1008
+
1009
+ return apply
1010
+
1011
+ async def _ticket_flow_watch_loop(self) -> None:
1012
+ await self._ticket_flow_bridge.watch_ticket_flow_pauses(
1013
+ TICKET_FLOW_WATCH_INTERVAL_SECONDS
1014
+ )
1015
+
1016
+ async def _watch_ticket_flow_pauses(self) -> None:
1017
+ await self._ticket_flow_bridge._scan_and_notify_pauses()
1018
+
1019
+ async def _notify_ticket_flow_pause(
1020
+ self,
1021
+ workspace_root: Path,
1022
+ entries: list[tuple[str, "TelegramTopicRecord"]],
1023
+ ) -> None:
1024
+ await self._ticket_flow_bridge._notify_ticket_flow_pause(
1025
+ workspace_root, entries
1026
+ )
1027
+
1028
+ def _load_ticket_flow_pause(
1029
+ self, workspace_root: Path
1030
+ ) -> Optional[tuple[str, str, str]]:
1031
+ return self._ticket_flow_bridge._load_ticket_flow_pause(workspace_root)
1032
+
1033
+ def _latest_dispatch_seq(self, history_dir: Path) -> Optional[str]:
1034
+ return self._ticket_flow_bridge._latest_dispatch_seq(history_dir)
1035
+
1036
+ def _format_ticket_flow_pause_reason(self, record: "FlowRunRecord") -> str:
1037
+ return self._ticket_flow_bridge._format_ticket_flow_pause_reason(record)
1038
+
1039
+ def _format_ticket_flow_pause_message(
1040
+ self, run_id: str, seq: str, content: str
1041
+ ) -> str:
1042
+ return self._ticket_flow_bridge._format_ticket_flow_pause_message(
1043
+ run_id, seq, content
1044
+ )
1045
+
1046
+ def _get_paused_ticket_flow(
1047
+ self, workspace_root: Path, preferred_run_id: Optional[str] = None
1048
+ ) -> Optional[tuple[str, FlowRunRecord]]:
1049
+ return self._ticket_flow_bridge.get_paused_ticket_flow(
1050
+ workspace_root, preferred_run_id=preferred_run_id
1051
+ )
1052
+
1053
+ async def _write_user_reply_from_telegram(
1054
+ self,
1055
+ workspace_root: Path,
1056
+ run_id: str,
1057
+ run_record: FlowRunRecord,
1058
+ message: TelegramMessage,
1059
+ text: str,
1060
+ files: Optional[list[tuple[str, bytes]]] = None,
1061
+ ) -> tuple[bool, str]:
1062
+ try:
1063
+ input_data = dict(run_record.input_data or {})
1064
+ runs_dir_raw = input_data.get("runs_dir")
1065
+ runs_dir = (
1066
+ Path(runs_dir_raw)
1067
+ if isinstance(runs_dir_raw, str) and runs_dir_raw
1068
+ else Path(".codex-autorunner/runs")
1069
+ )
1070
+ reply_paths = resolve_reply_paths(
1071
+ workspace_root=workspace_root, runs_dir=runs_dir, run_id=run_id
1072
+ )
1073
+ ensure_reply_dirs(reply_paths)
1074
+
1075
+ cleaned_text = text.strip()
1076
+ raw = cleaned_text
1077
+ if raw and not raw.endswith("\n"):
1078
+ raw += "\n"
1079
+
1080
+ await asyncio.to_thread(
1081
+ reply_paths.user_reply_path.write_text, raw, encoding="utf-8"
1082
+ )
1083
+
1084
+ if files:
1085
+ for filename, data in files:
1086
+ dest = reply_paths.reply_dir / filename
1087
+ dest.parent.mkdir(parents=True, exist_ok=True)
1088
+ await asyncio.to_thread(dest.write_bytes, data)
1089
+
1090
+ seq = await asyncio.to_thread(
1091
+ lambda: _next_reply_seq_sync(reply_paths.reply_history_dir)
1092
+ )
1093
+ dispatch, errors = await asyncio.to_thread(
1094
+ dispatch_reply, reply_paths, next_seq=seq
1095
+ )
1096
+ if errors:
1097
+ return False, "\n".join(errors)
1098
+ if dispatch is None:
1099
+ return False, "Failed to archive reply"
1100
+ return (
1101
+ True,
1102
+ f"Reply archived (seq {dispatch.seq}). Use /flow resume to continue.",
1103
+ )
1104
+ except Exception as exc:
1105
+ self._logger.warning(
1106
+ "Failed to write USER_REPLY.md from Telegram",
1107
+ exc=exc,
1108
+ workspace_root=str(workspace_root),
1109
+ run_id=run_id,
1110
+ )
1111
+ return False, f"Failed to write reply: {exc}"
1112
+
941
1113
  async def _interrupt_timeout_check(
942
1114
  self, key: str, turn_id: str, message_id: int
943
1115
  ) -> None:
@@ -1205,7 +1377,9 @@ class TelegramBotService(
1205
1377
  async def _maybe_persist_update_id(self, key: str, update_id: int) -> None:
1206
1378
  now = time.monotonic()
1207
1379
  last_persisted = self._last_update_persisted_at.get(key, 0.0)
1208
- if (now - last_persisted) < UPDATE_ID_PERSIST_INTERVAL_SECONDS:
1380
+ if (
1381
+ now - last_persisted
1382
+ ) < self._config.cache.update_id_persist_interval_seconds:
1209
1383
  return
1210
1384
 
1211
1385
  def apply(record: "TelegramTopicRecord") -> None:
@@ -205,6 +205,7 @@ class TelegramTopicRecord:
205
205
  rollout_path: Optional[str] = None
206
206
  approval_mode: str = APPROVAL_MODE_YOLO
207
207
  last_active_at: Optional[str] = None
208
+ last_ticket_dispatch_seq: Optional[str] = None
208
209
 
209
210
  @classmethod
210
211
  def from_dict(
@@ -291,6 +292,11 @@ class TelegramTopicRecord:
291
292
  last_active_at = payload.get("last_active_at") or payload.get("lastActiveAt")
292
293
  if not isinstance(last_active_at, str):
293
294
  last_active_at = None
295
+ last_ticket_dispatch_seq = payload.get(
296
+ "last_ticket_dispatch_seq"
297
+ ) or payload.get("lastTicketDispatchSeq")
298
+ if not isinstance(last_ticket_dispatch_seq, str):
299
+ last_ticket_dispatch_seq = None
294
300
  return cls(
295
301
  repo_id=repo_id,
296
302
  workspace_path=workspace_path,
@@ -310,6 +316,7 @@ class TelegramTopicRecord:
310
316
  rollout_path=rollout_path,
311
317
  approval_mode=approval_mode,
312
318
  last_active_at=last_active_at,
319
+ last_ticket_dispatch_seq=last_ticket_dispatch_seq,
313
320
  )
314
321
 
315
322
  def to_dict(self) -> dict[str, Any]:
@@ -335,6 +342,7 @@ class TelegramTopicRecord:
335
342
  "rollout_path": self.rollout_path,
336
343
  "approval_mode": self.approval_mode,
337
344
  "last_active_at": self.last_active_at,
345
+ "last_ticket_dispatch_seq": self.last_ticket_dispatch_seq,
338
346
  }
339
347
 
340
348
 
@@ -444,6 +452,10 @@ class OutboxRecord:
444
452
  attempts: int = 0
445
453
  last_error: Optional[str] = None
446
454
  last_attempt_at: Optional[str] = None
455
+ next_attempt_at: Optional[str] = None
456
+ operation: Optional[str] = None
457
+ message_id: Optional[int] = None
458
+ outbox_key: Optional[str] = None
447
459
 
448
460
  @classmethod
449
461
  def from_dict(cls, payload: dict[str, Any]) -> Optional["OutboxRecord"]:
@@ -459,6 +471,10 @@ class OutboxRecord:
459
471
  attempts = payload.get("attempts", 0)
460
472
  last_error = payload.get("last_error")
461
473
  last_attempt_at = payload.get("last_attempt_at")
474
+ next_attempt_at = payload.get("next_attempt_at")
475
+ operation = payload.get("operation")
476
+ message_id = payload.get("message_id")
477
+ outbox_key = payload.get("outbox_key")
462
478
  if not isinstance(record_id, str) or not record_id:
463
479
  return None
464
480
  if not isinstance(chat_id, int):
@@ -481,6 +497,14 @@ class OutboxRecord:
481
497
  last_error = None
482
498
  if not isinstance(last_attempt_at, str):
483
499
  last_attempt_at = None
500
+ if not isinstance(next_attempt_at, str):
501
+ next_attempt_at = None
502
+ if not isinstance(operation, str):
503
+ operation = None
504
+ if message_id is not None and not isinstance(message_id, int):
505
+ message_id = None
506
+ if not isinstance(outbox_key, str):
507
+ outbox_key = None
484
508
  return cls(
485
509
  record_id=record_id,
486
510
  chat_id=chat_id,
@@ -492,6 +516,10 @@ class OutboxRecord:
492
516
  attempts=attempts,
493
517
  last_error=last_error,
494
518
  last_attempt_at=last_attempt_at,
519
+ next_attempt_at=next_attempt_at,
520
+ operation=operation,
521
+ message_id=message_id,
522
+ outbox_key=outbox_key,
495
523
  )
496
524
 
497
525
  def to_dict(self) -> dict[str, Any]:
@@ -506,6 +534,10 @@ class OutboxRecord:
506
534
  "attempts": self.attempts,
507
535
  "last_error": self.last_error,
508
536
  "last_attempt_at": self.last_attempt_at,
537
+ "next_attempt_at": self.next_attempt_at,
538
+ "operation": self.operation,
539
+ "message_id": self.message_id,
540
+ "outbox_key": self.outbox_key,
509
541
  }
510
542
 
511
543
 
@@ -674,6 +706,27 @@ class TelegramStateStore:
674
706
  async def get_topic(self, key: str) -> Optional[TelegramTopicRecord]:
675
707
  return await self._run(self._get_topic_sync, key)
676
708
 
709
+ async def list_topics(self) -> dict[str, TelegramTopicRecord]:
710
+ """Return all stored topics keyed by topic_key."""
711
+ return await self._run(self._list_topics_sync)
712
+
713
+ def _list_topics_sync(self) -> dict[str, TelegramTopicRecord]:
714
+ conn = self._ensure_connection()
715
+ cursor = conn.execute("SELECT topic_key, payload_json FROM telegram_topics")
716
+ topics: dict[str, TelegramTopicRecord] = {}
717
+ for key, payload_json in cursor.fetchall():
718
+ try:
719
+ payload = (
720
+ json.loads(payload_json) if isinstance(payload_json, str) else {}
721
+ )
722
+ except Exception:
723
+ payload = {}
724
+ record = TelegramTopicRecord.from_dict(
725
+ payload, default_approval_mode=self._default_approval_mode
726
+ )
727
+ topics[str(key)] = record
728
+ return topics
729
+
677
730
  async def get_topic_scope(self, key: str) -> Optional[str]:
678
731
  return await self._run(self._get_topic_scope_sync, key)
679
732
 
@@ -807,6 +860,11 @@ class TelegramStateStore:
807
860
  loop = asyncio.get_running_loop()
808
861
  return await loop.run_in_executor(self._executor, func, *args)
809
862
 
863
+ def _ensure_connection(self) -> sqlite3.Connection:
864
+ # Backwards-compatible helper used by older call sites.
865
+ # _connection_sync() remains the single source of truth for opening the DB.
866
+ return self._connection_sync()
867
+
810
868
  def _connection_sync(self) -> sqlite3.Connection:
811
869
  if self._connection is None:
812
870
  conn = connect_sqlite(self._path)
@@ -927,16 +985,41 @@ class TelegramStateStore:
927
985
  thread_id INTEGER,
928
986
  created_at TEXT NOT NULL,
929
987
  updated_at TEXT NOT NULL,
988
+ next_attempt_at TEXT,
989
+ operation TEXT,
990
+ message_id INTEGER,
991
+ outbox_key TEXT,
930
992
  payload_json TEXT NOT NULL
931
993
  )
932
994
  """
933
995
  )
996
+ # Ensure legacy DBs gain the newer columns before creating indexes that
997
+ # reference them. The ALTERs are idempotent and cheap.
998
+ for col, col_type in [
999
+ ("next_attempt_at", "TEXT"),
1000
+ ("operation", "TEXT"),
1001
+ ("message_id", "INTEGER"),
1002
+ ("outbox_key", "TEXT"),
1003
+ ]:
1004
+ try:
1005
+ conn.execute(
1006
+ f"ALTER TABLE telegram_outbox ADD COLUMN {col} {col_type}"
1007
+ )
1008
+ except sqlite3.OperationalError:
1009
+ pass
934
1010
  conn.execute(
935
1011
  """
936
1012
  CREATE INDEX IF NOT EXISTS idx_tg_outbox_created
937
1013
  ON telegram_outbox(created_at)
938
1014
  """
939
1015
  )
1016
+ conn.execute(
1017
+ """
1018
+ CREATE INDEX IF NOT EXISTS idx_tg_outbox_key
1019
+ ON telegram_outbox(outbox_key)
1020
+ WHERE outbox_key IS NOT NULL
1021
+ """
1022
+ )
940
1023
  conn.execute(
941
1024
  """
942
1025
  CREATE TABLE IF NOT EXISTS telegram_pending_voice (
@@ -1019,7 +1102,10 @@ class TelegramStateStore:
1019
1102
  def _load_legacy_state_json(self, path: Path) -> Optional[TelegramState]:
1020
1103
  try:
1021
1104
  payload = json.loads(path.read_text(encoding="utf-8"))
1022
- except (OSError, json.JSONDecodeError):
1105
+ except (OSError, json.JSONDecodeError, UnicodeDecodeError):
1106
+ # The path may already be a SQLite file (e.g., state_file still ends
1107
+ # with .json after the migration to SQLite). In that case, ignore the
1108
+ # legacy load attempt and treat the DB as the source of truth.
1023
1109
  return None
1024
1110
  if not isinstance(payload, dict):
1025
1111
  return None
@@ -1748,14 +1834,22 @@ class TelegramStateStore:
1748
1834
  thread_id,
1749
1835
  created_at,
1750
1836
  updated_at,
1837
+ next_attempt_at,
1838
+ operation,
1839
+ message_id,
1840
+ outbox_key,
1751
1841
  payload_json
1752
1842
  )
1753
- VALUES (?, ?, ?, ?, ?, ?)
1843
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1754
1844
  ON CONFLICT(record_id) DO UPDATE SET
1755
1845
  chat_id=excluded.chat_id,
1756
1846
  thread_id=excluded.thread_id,
1757
1847
  created_at=excluded.created_at,
1758
1848
  updated_at=excluded.updated_at,
1849
+ next_attempt_at=excluded.next_attempt_at,
1850
+ operation=excluded.operation,
1851
+ message_id=excluded.message_id,
1852
+ outbox_key=excluded.outbox_key,
1759
1853
  payload_json=excluded.payload_json
1760
1854
  """,
1761
1855
  (
@@ -1764,6 +1858,10 @@ class TelegramStateStore:
1764
1858
  record.thread_id,
1765
1859
  record.created_at,
1766
1860
  updated_at,
1861
+ record.next_attempt_at,
1862
+ record.operation,
1863
+ record.message_id,
1864
+ record.outbox_key,
1767
1865
  payload_json,
1768
1866
  ),
1769
1867
  )