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
@@ -12,11 +12,10 @@ from collections import Counter
12
12
  from datetime import datetime, timezone
13
13
  from logging.handlers import RotatingFileHandler
14
14
  from pathlib import Path
15
- from typing import IO, Any, Dict, Iterator, Optional, Union
15
+ from typing import IO, Any, Iterator, Optional
16
16
 
17
17
  import yaml
18
18
 
19
- from ..agents.factory import create_orchestrator
20
19
  from ..agents.opencode.logging import OpenCodeEventFormatter
21
20
  from ..agents.opencode.runtime import (
22
21
  OpenCodeTurnOutput,
@@ -39,8 +38,9 @@ from ..integrations.app_server.client import (
39
38
  from ..integrations.app_server.env import build_app_server_env
40
39
  from ..integrations.app_server.supervisor import WorkspaceAppServerSupervisor
41
40
  from ..manifest import MANIFEST_VERSION
42
- from ..web.static_assets import missing_static_assets, resolve_static_dir
41
+ from ..tickets.files import list_ticket_paths, ticket_is_done
43
42
  from .about_car import ensure_about_car_file
43
+ from .adapter_utils import handle_agent_output
44
44
  from .app_server_events import AppServerEventBuffer
45
45
  from .app_server_logging import AppServerEventFormatter
46
46
  from .app_server_prompts import build_autorunner_prompt
@@ -67,9 +67,11 @@ from .locks import (
67
67
  from .notifications import NotificationManager
68
68
  from .optional_dependencies import missing_optional_dependencies
69
69
  from .prompt import build_final_summary_prompt
70
+ from .redaction import redact_text
70
71
  from .review_context import build_spec_progress_review_context
71
72
  from .run_index import RunIndexStore
72
73
  from .state import RunnerState, load_state, now_iso, save_state, state_lock
74
+ from .static_assets import missing_static_assets, resolve_static_dir
73
75
  from .utils import (
74
76
  RepoNotFoundError,
75
77
  atomic_write,
@@ -134,6 +136,7 @@ class Engine:
134
136
  self._run_index_store = RunIndexStore(self.state_path)
135
137
  self.lock_path = self.repo_root / ".codex-autorunner" / "lock"
136
138
  self.stop_path = self.repo_root / ".codex-autorunner" / "stop"
139
+ self._hub_path = hub_path
137
140
  self._active_global_handler: Optional[RotatingFileHandler] = None
138
141
  self._active_run_log: Optional[IO[str]] = None
139
142
  self._app_server_threads = AppServerThreadRegistry(
@@ -142,7 +145,10 @@ class Engine:
142
145
  self._app_server_threads_lock = threading.Lock()
143
146
  self._app_server_supervisor: Optional[WorkspaceAppServerSupervisor] = None
144
147
  self._app_server_logger = logging.getLogger("codex_autorunner.app_server")
145
- self._app_server_event_formatter = AppServerEventFormatter()
148
+ redact_enabled = self.config.security.get("redact_run_logs", True)
149
+ self._app_server_event_formatter = AppServerEventFormatter(
150
+ redact_enabled=redact_enabled
151
+ )
146
152
  self._app_server_events = AppServerEventBuffer()
147
153
  self._opencode_event_formatter = OpenCodeEventFormatter()
148
154
  self._opencode_supervisor: Optional[OpenCodeSupervisor] = None
@@ -262,41 +268,21 @@ class Engine:
262
268
  return None
263
269
 
264
270
  def todos_done(self) -> bool:
265
- return self.docs.todos_done()
271
+ # Ticket-first mode: completion is determined by ticket files, not TODO.md.
272
+ ticket_dir = self.repo_root / ".codex-autorunner" / "tickets"
273
+ ticket_paths = list_ticket_paths(ticket_dir)
274
+ if not ticket_paths:
275
+ return False
276
+ return all(ticket_is_done(path) for path in ticket_paths)
266
277
 
267
278
  def summary_finalized(self) -> bool:
268
- """Return True if SUMMARY.md contains the finalization marker."""
269
- try:
270
- text = self.docs.read_doc("summary")
271
- except (FileNotFoundError, OSError) as exc:
272
- self._app_server_logger.debug("Failed to read SUMMARY.md: %s", exc)
273
- return False
274
- return SUMMARY_FINALIZED_MARKER in (text or "")
279
+ # Legacy docs finalization no longer applies (no SUMMARY doc).
280
+ return True
275
281
 
276
282
  def _stamp_summary_finalized(self, run_id: int) -> None:
277
- """
278
- Append an idempotency marker to SUMMARY.md so the final summary job runs only once.
279
- Users may remove the marker to force regeneration.
280
- """
281
- path = self.config.doc_path("summary")
282
- try:
283
- existing = path.read_text(encoding="utf-8") if path.exists() else ""
284
- except (FileNotFoundError, OSError) as exc:
285
- self._app_server_logger.debug(
286
- "Failed to read SUMMARY.md for stamping: %s", exc
287
- )
288
- existing = ""
289
- if SUMMARY_FINALIZED_MARKER in existing:
290
- return
291
- stamp = f"{SUMMARY_FINALIZED_MARKER_PREFIX} run_id={int(run_id)} -->\n"
292
- new_text = existing
293
- if new_text and not new_text.endswith("\n"):
294
- new_text += "\n"
295
- # Keep a blank line before the marker for readability.
296
- if new_text and not new_text.endswith("\n\n"):
297
- new_text += "\n"
298
- new_text += stamp
299
- atomic_write(path, new_text)
283
+ # No-op: summary file no longer exists.
284
+ _ = run_id
285
+ return
300
286
 
301
287
  async def _execute_run_step(
302
288
  self,
@@ -317,7 +303,9 @@ class Engine:
317
303
  try:
318
304
  todo_before = self.docs.read_doc("todo")
319
305
  except (FileNotFoundError, OSError) as exc:
320
- self._app_server_logger.debug("Failed to read TODO.md before run: %s", exc)
306
+ self._app_server_logger.debug(
307
+ "Failed to read TODO.md before run %s: %s", run_id, exc
308
+ )
321
309
  todo_before = ""
322
310
  state = load_state(self.state_path)
323
311
  selected_agent = (state.autorunner_agent_override or "codex").strip().lower()
@@ -332,8 +320,23 @@ class Engine:
332
320
  self._update_state("running", run_id, None, started=True)
333
321
  self._last_run_interrupted = False
334
322
  self._start_run_telemetry(run_id)
323
+
324
+ actor: dict[str, Any] = {
325
+ "backend": "codex_app_server",
326
+ "agent_id": validated_agent,
327
+ "surface": "hub" if self._hub_path else "cli",
328
+ }
329
+ mode: dict[str, Any] = {
330
+ "approval_policy": state.autorunner_approval_policy or "never",
331
+ "sandbox": state.autorunner_sandbox_mode or "dangerFullAccess",
332
+ }
333
+ runner_cfg = self.config.raw.get("runner") or {}
334
+ review_cfg = runner_cfg.get("review")
335
+ if isinstance(review_cfg, dict):
336
+ mode["review_enabled"] = bool(review_cfg.get("enabled"))
337
+
335
338
  with self._run_log_context(run_id):
336
- self._write_run_marker(run_id, "start")
339
+ self._write_run_marker(run_id, "start", actor=actor, mode=mode)
337
340
  if validated_agent == "opencode":
338
341
  exit_code = await self._run_opencode_app_server_async(
339
342
  prompt,
@@ -353,7 +356,9 @@ class Engine:
353
356
  try:
354
357
  todo_after = self.docs.read_doc("todo")
355
358
  except (FileNotFoundError, OSError) as exc:
356
- self._app_server_logger.debug("Failed to read TODO.md after run: %s", exc)
359
+ self._app_server_logger.debug(
360
+ "Failed to read TODO.md after run %s: %s", run_id, exc
361
+ )
357
362
  todo_after = ""
358
363
  todo_delta = self._compute_todo_attribution(todo_before, todo_after)
359
364
  todo_snapshot = self._build_todo_snapshot(todo_before, todo_after)
@@ -380,6 +385,7 @@ class Engine:
380
385
  "thread_total_after": telemetry.token_total,
381
386
  }
382
387
  artifacts: dict[str, str] = {}
388
+ redact_enabled = self.config.security.get("redact_run_logs", True)
383
389
  if telemetry and telemetry.plan is not None:
384
390
  try:
385
391
  plan_content = (
@@ -391,25 +397,39 @@ class Engine:
391
397
  )
392
398
  except (TypeError, ValueError) as exc:
393
399
  self._app_server_logger.debug(
394
- "Failed to serialize plan to JSON: %s", exc
400
+ "Failed to serialize plan to JSON for run %s: %s", run_id, exc
395
401
  )
396
402
  plan_content = json.dumps(
397
403
  {"plan": str(telemetry.plan)}, ensure_ascii=True, indent=2
398
404
  )
405
+ if redact_enabled:
406
+ plan_content = redact_text(plan_content)
399
407
  plan_path = self._write_run_artifact(run_id, "plan.json", plan_content)
400
408
  artifacts["plan_path"] = str(plan_path)
401
409
  if telemetry and telemetry.diff is not None:
402
- diff_content = (
403
- telemetry.diff
404
- if isinstance(telemetry.diff, str)
405
- else json.dumps(
406
- telemetry.diff, ensure_ascii=True, indent=2, default=str
410
+ normalized_diff = self._normalize_diff_payload(telemetry.diff)
411
+ if normalized_diff is not None:
412
+ diff_content = (
413
+ normalized_diff
414
+ if isinstance(normalized_diff, str)
415
+ else json.dumps(
416
+ normalized_diff, ensure_ascii=True, indent=2, default=str
417
+ )
407
418
  )
408
- )
409
- diff_path = self._write_run_artifact(run_id, "diff.patch", diff_content)
410
- artifacts["diff_path"] = str(diff_path)
419
+ if redact_enabled:
420
+ diff_content = redact_text(diff_content)
421
+ diff_path = self._write_run_artifact(run_id, "diff.patch", diff_content)
422
+ artifacts["diff_path"] = str(diff_path)
411
423
  if artifacts:
412
424
  run_updates["artifacts"] = artifacts
425
+ if redact_enabled:
426
+ from .redaction import get_redaction_patterns
427
+
428
+ run_updates["security"] = {
429
+ "redaction_enabled": True,
430
+ "redaction_version": "1.0",
431
+ "redaction_patterns": get_redaction_patterns(),
432
+ }
413
433
  if run_updates:
414
434
  self._merge_run_index_entry(run_id, run_updates)
415
435
  self._clear_run_telemetry(run_id)
@@ -457,7 +477,7 @@ class Engine:
457
477
  text = run_log.read_text(encoding="utf-8")
458
478
  except (FileNotFoundError, OSError) as exc:
459
479
  self._app_server_logger.debug(
460
- "Failed to read previous run log: %s", exc
480
+ "Failed to read previous run log for run %s: %s", run_id, exc
461
481
  )
462
482
  text = ""
463
483
  if text:
@@ -508,10 +528,12 @@ class Engine:
508
528
  try:
509
529
  return run_log.read_text(encoding="utf-8")
510
530
  except (FileNotFoundError, OSError) as exc:
511
- self._app_server_logger.debug("Failed to read run log block: %s", exc)
531
+ self._app_server_logger.debug(
532
+ "Failed to read run log block for run %s: %s", run_id, exc
533
+ )
512
534
  return None
513
535
  if index_entry:
514
- block = self._read_log_range(index_entry)
536
+ block = self._read_log_range(run_id, index_entry)
515
537
  if block is not None:
516
538
  return block
517
539
  if not self.log_path.exists():
@@ -555,7 +577,7 @@ class Engine:
555
577
  return "\n".join(buf) if buf else None
556
578
  except (FileNotFoundError, OSError, ValueError) as exc:
557
579
  self._app_server_logger.debug(
558
- "Failed to read full log for run block: %s", exc
580
+ "Failed to read full log for run %s block: %s", run_id, exc
559
581
  )
560
582
  return None
561
583
  return None
@@ -582,7 +604,7 @@ class Engine:
582
604
  self._active_run_log.flush()
583
605
  except (OSError, IOError) as exc:
584
606
  self._app_server_logger.warning(
585
- "Failed to write to active run log: %s", exc
607
+ "Failed to write to active run log for run %s: %s", run_id, exc
586
608
  )
587
609
  else:
588
610
  run_log = self._run_log_path(run_id)
@@ -607,7 +629,7 @@ class Engine:
607
629
  f.write(_json.dumps(event_data) + "\n")
608
630
  except (OSError, IOError) as exc:
609
631
  self._app_server_logger.warning(
610
- "Failed to write event to events log: %s", exc
632
+ "Failed to write event to events log for run %s: %s", run_id, exc
611
633
  )
612
634
 
613
635
  def _ensure_log_path(self) -> None:
@@ -623,14 +645,25 @@ class Engine:
623
645
  (self.log_path.parent / "runs").mkdir(parents=True, exist_ok=True)
624
646
 
625
647
  def _write_run_marker(
626
- self, run_id: int, marker: str, exit_code: Optional[int] = None
648
+ self,
649
+ run_id: int,
650
+ marker: str,
651
+ exit_code: Optional[int] = None,
652
+ *,
653
+ actor: Optional[dict[str, Any]] = None,
654
+ mode: Optional[dict[str, Any]] = None,
627
655
  ) -> None:
628
656
  suffix = ""
629
657
  if marker == "end":
630
658
  suffix = f" (code {exit_code})"
631
659
  self._emit_event(run_id, "run.finished", exit_code=exit_code)
632
660
  elif marker == "start":
633
- self._emit_event(run_id, "run.started")
661
+ payload: dict[str, Any] = {}
662
+ if actor is not None:
663
+ payload["actor"] = actor
664
+ if mode is not None:
665
+ payload["mode"] = mode
666
+ self._emit_event(run_id, "run.started", **payload)
634
667
  text = f"=== run {run_id} {marker}{suffix} ==="
635
668
  offset = self._emit_global_line(text)
636
669
  if self._active_run_log is not None:
@@ -639,14 +672,18 @@ class Engine:
639
672
  self._active_run_log.flush()
640
673
  except (OSError, IOError) as exc:
641
674
  self._app_server_logger.warning(
642
- "Failed to write marker to active run log: %s", exc
675
+ "Failed to write marker to active run log for run %s: %s",
676
+ run_id,
677
+ exc,
643
678
  )
644
679
  else:
645
680
  self._ensure_run_log_dir()
646
681
  run_log = self._run_log_path(run_id)
647
682
  with run_log.open("a", encoding="utf-8") as f:
648
683
  f.write(f"{text}\n")
649
- self._update_run_index(run_id, marker, offset, exit_code)
684
+ self._update_run_index(
685
+ run_id, marker, offset, exit_code, actor=actor, mode=mode
686
+ )
650
687
 
651
688
  def _emit_global_line(self, text: str) -> Optional[tuple[int, int]]:
652
689
  if self._active_global_handler is None:
@@ -715,7 +752,7 @@ class Engine:
715
752
  handler.close()
716
753
  except (OSError, IOError) as exc:
717
754
  self._app_server_logger.debug(
718
- "Failed to close run log handler: %s", exc
755
+ "Failed to close run log handler for run %s: %s", run_id, exc
719
756
  )
720
757
 
721
758
  def _start_run_telemetry(self, run_id: int) -> None:
@@ -747,6 +784,29 @@ class Engine:
747
784
  return
748
785
  self._run_telemetry = None
749
786
 
787
+ @staticmethod
788
+ def _normalize_diff_payload(diff: Any) -> Optional[Any]:
789
+ if diff is None:
790
+ return None
791
+ if isinstance(diff, str):
792
+ return diff if diff.strip() else None
793
+ if isinstance(diff, dict):
794
+ # Prefer meaningful fields if present.
795
+ for key in ("diff", "patch", "content", "value"):
796
+ if key in diff:
797
+ val = diff.get(key)
798
+ if isinstance(val, str) and val.strip():
799
+ return val
800
+ if val not in (None, "", [], {}, ()):
801
+ return diff
802
+ for val in diff.values():
803
+ if isinstance(val, str) and val.strip():
804
+ return diff
805
+ if val not in (None, "", [], {}, ()):
806
+ return diff
807
+ return None
808
+ return diff
809
+
750
810
  def _maybe_update_run_index_telemetry(
751
811
  self, run_id: int, min_interval_seconds: float = 3.0
752
812
  ) -> None:
@@ -821,13 +881,12 @@ class Engine:
821
881
  if method == "turn/plan/updated":
822
882
  telemetry.plan = params.get("plan") if "plan" in params else params
823
883
  if method == "turn/diff/updated":
824
- diff = (
825
- params.get("diff")
826
- or params.get("patch")
827
- or params.get("content")
828
- or params.get("value")
829
- )
830
- telemetry.diff = diff if diff is not None else params
884
+ diff: Any = None
885
+ for key in ("diff", "patch", "content", "value"):
886
+ if key in params:
887
+ diff = params.get(key)
888
+ break
889
+ telemetry.diff = diff if diff is not None else params or None
831
890
  if run_id is None:
832
891
  return
833
892
  for line in self._app_server_event_formatter.format_event(message):
@@ -847,7 +906,10 @@ class Engine:
847
906
  """
848
907
  try:
849
908
  state = load_state(self.state_path)
850
- except Exception:
909
+ except Exception as exc:
910
+ self._app_server_logger.warning(
911
+ "Failed to load state during run index reconciliation: %s", exc
912
+ )
851
913
  return
852
914
 
853
915
  active_pid: Optional[int] = None
@@ -870,7 +932,10 @@ class Engine:
870
932
  now = now_iso()
871
933
  try:
872
934
  index = self._run_index_store.load_all()
873
- except Exception:
935
+ except Exception as exc:
936
+ self._app_server_logger.warning(
937
+ "Failed to load run index during reconciliation: %s", exc
938
+ )
874
939
  return
875
940
 
876
941
  for key, entry in index.items():
@@ -917,7 +982,10 @@ class Engine:
917
982
  ),
918
983
  },
919
984
  )
920
- except Exception:
985
+ except Exception as exc:
986
+ self._app_server_logger.warning(
987
+ "Failed to reconcile run index entry for run %d: %s", run_id, exc
988
+ )
921
989
  continue
922
990
 
923
991
  def _merge_run_index_entry(self, run_id: int, updates: dict[str, Any]) -> None:
@@ -929,6 +997,9 @@ class Engine:
929
997
  marker: str,
930
998
  offset: Optional[tuple[int, int]],
931
999
  exit_code: Optional[int],
1000
+ *,
1001
+ actor: Optional[dict[str, Any]] = None,
1002
+ mode: Optional[dict[str, Any]] = None,
932
1003
  ) -> None:
933
1004
  self._run_index_store.update_marker(
934
1005
  run_id,
@@ -937,6 +1008,8 @@ class Engine:
937
1008
  exit_code,
938
1009
  log_path=str(self.log_path),
939
1010
  run_log_path=str(self._run_log_path(run_id)),
1011
+ actor=actor,
1012
+ mode=mode,
940
1013
  )
941
1014
 
942
1015
  def _list_from_counts(self, source: list[str], counts: Counter[str]) -> list[str]:
@@ -1021,7 +1094,10 @@ class Engine:
1021
1094
  entry_id = int(key)
1022
1095
  except (TypeError, ValueError) as exc:
1023
1096
  self._app_server_logger.debug(
1024
- "Failed to parse run index key '%s': %s", key, exc
1097
+ "Failed to parse run index key '%s' while resolving run %s: %s",
1098
+ key,
1099
+ run_id,
1100
+ exc,
1025
1101
  )
1026
1102
  continue
1027
1103
  if entry_id >= run_id:
@@ -1106,7 +1182,7 @@ class Engine:
1106
1182
  atomic_write(path, content)
1107
1183
  return path
1108
1184
 
1109
- def _read_log_range(self, entry: dict) -> Optional[str]:
1185
+ def _read_log_range(self, run_id: int, entry: dict) -> Optional[str]:
1110
1186
  start = entry.get("start_offset")
1111
1187
  end = entry.get("end_offset")
1112
1188
  if start is None or end is None:
@@ -1115,7 +1191,9 @@ class Engine:
1115
1191
  start_offset = int(start)
1116
1192
  end_offset = int(end)
1117
1193
  except (TypeError, ValueError) as exc:
1118
- self._app_server_logger.debug("Failed to parse log range offsets: %s", exc)
1194
+ self._app_server_logger.debug(
1195
+ "Failed to parse log range offsets for run %s: %s", run_id, exc
1196
+ )
1119
1197
  return None
1120
1198
  if end_offset < start_offset:
1121
1199
  return None
@@ -1131,7 +1209,9 @@ class Engine:
1131
1209
  data = f.read(end_offset - start_offset)
1132
1210
  return data.decode("utf-8", errors="replace")
1133
1211
  except (FileNotFoundError, OSError) as exc:
1134
- self._app_server_logger.debug("Failed to read log range: %s", exc)
1212
+ self._app_server_logger.debug(
1213
+ "Failed to read log range for run %s: %s", run_id, exc
1214
+ )
1135
1215
  return None
1136
1216
 
1137
1217
  def _build_app_server_prompt(self, prev_output: Optional[str]) -> str:
@@ -1166,107 +1246,6 @@ class Engine:
1166
1246
  return 1
1167
1247
  raise
1168
1248
 
1169
- async def _run_agent_turn_async(
1170
- self,
1171
- agent_id: str,
1172
- prompt: str,
1173
- run_id: int,
1174
- *,
1175
- external_stop_flag: Optional[threading.Event] = None,
1176
- ) -> int:
1177
- orchestrator = self._get_orchestrator(agent_id)
1178
- if orchestrator is None:
1179
- self.log_line(
1180
- run_id,
1181
- f"error: agent '{agent_id}' backend is not configured",
1182
- )
1183
- return 1
1184
-
1185
- thread_key = f"autorunner.{agent_id}"
1186
- with state_lock(self.state_path):
1187
- state = load_state(self.state_path)
1188
- effective_model = state.autorunner_model_override or self.config.codex_model
1189
- effective_effort = (
1190
- state.autorunner_effort_override or self.config.codex_reasoning
1191
- )
1192
-
1193
- with self._app_server_threads_lock:
1194
- conversation_id = self._app_server_threads.get_thread_id(thread_key)
1195
- if not conversation_id:
1196
- try:
1197
- conversation_info = (
1198
- await orchestrator.create_or_resume_conversation(
1199
- self.repo_root, agent_id
1200
- )
1201
- )
1202
- conversation_id = conversation_info.id
1203
- self._app_server_threads.set_thread_id(thread_key, conversation_id)
1204
- except Exception as exc:
1205
- self.log_line(
1206
- run_id, f"error: failed to create conversation: {exc}"
1207
- )
1208
- return 1
1209
-
1210
- if conversation_id:
1211
- self._update_run_telemetry(run_id, thread_id=conversation_id)
1212
-
1213
- approval_policy = state.autorunner_approval_policy or "never"
1214
- sandbox_mode = state.autorunner_sandbox_mode or "dangerFullAccess"
1215
- if sandbox_mode == "workspaceWrite":
1216
- sandbox_policy: Union[Dict[str, Any], str] = {
1217
- "type": "workspaceWrite",
1218
- "writableRoots": [str(self.repo_root)],
1219
- "networkAccess": bool(state.autorunner_workspace_write_network),
1220
- }
1221
- else:
1222
- sandbox_policy = sandbox_mode
1223
-
1224
- stop_event = asyncio.Event()
1225
- stop_task: Optional[asyncio.Task] = None
1226
-
1227
- if external_stop_flag:
1228
- stop_task = asyncio.create_task(
1229
- self._wait_for_stop(external_stop_flag, stop_event)
1230
- )
1231
-
1232
- try:
1233
- result = await orchestrator.run_turn(
1234
- self.repo_root,
1235
- conversation_id,
1236
- prompt,
1237
- model=effective_model,
1238
- reasoning=effective_effort,
1239
- approval_mode=approval_policy,
1240
- sandbox_policy=sandbox_policy,
1241
- should_stop=stop_event.is_set,
1242
- )
1243
- if result.get("status") != "completed":
1244
- self.log_line(
1245
- run_id, f"error: turn failed with status {result.get('status')}"
1246
- )
1247
- return 1
1248
- output = result.get("output", "")
1249
- if output:
1250
- self._log_app_server_output(run_id, output.splitlines())
1251
- output_path = self._write_run_artifact(run_id, "output.txt", output)
1252
- self._merge_run_index_entry(
1253
- run_id, {"artifacts": {"output_path": str(output_path)}}
1254
- )
1255
- return 0
1256
- except Exception as exc:
1257
- self.log_line(run_id, f"error: {exc}")
1258
- return 1
1259
- finally:
1260
- if stop_task is not None:
1261
- stop_task.cancel()
1262
- with contextlib.suppress(asyncio.CancelledError):
1263
- await stop_task
1264
- if stop_event.is_set():
1265
- await orchestrator.interrupt_turn(
1266
- self.repo_root, conversation_id, grace_seconds=30.0
1267
- )
1268
- self._last_run_interrupted = True
1269
-
1270
1249
  async def _run_codex_app_server_async(
1271
1250
  self,
1272
1251
  prompt: str,
@@ -1379,15 +1358,13 @@ class Engine:
1379
1358
  supervisor=supervisor,
1380
1359
  )
1381
1360
  self._last_run_interrupted = interrupted
1382
- self._log_app_server_output(run_id, turn_result.agent_messages)
1383
- output_text = "\n\n".join(turn_result.agent_messages).strip()
1384
- if output_text:
1385
- output_path = self._write_run_artifact(
1386
- run_id, "output.txt", output_text
1387
- )
1388
- self._merge_run_index_entry(
1389
- run_id, {"artifacts": {"output_path": str(output_path)}}
1390
- )
1361
+ handle_agent_output(
1362
+ self._log_app_server_output,
1363
+ self._write_run_artifact,
1364
+ self._merge_run_index_entry,
1365
+ run_id,
1366
+ turn_result.agent_messages,
1367
+ )
1391
1368
  if turn_result.errors:
1392
1369
  for error in turn_result.errors:
1393
1370
  self.log_line(run_id, f"error: {error}")
@@ -1419,13 +1396,12 @@ class Engine:
1419
1396
  msg = self.config.git_commit_message_template.replace(
1420
1397
  "{run_id}", str(run_id)
1421
1398
  ).replace("#{run_id}", str(run_id))
1422
- paths = [
1423
- self.config.doc_path("todo"),
1424
- self.config.doc_path("progress"),
1425
- self.config.doc_path("opinions"),
1426
- self.config.doc_path("spec"),
1427
- self.config.doc_path("summary"),
1428
- ]
1399
+ paths = []
1400
+ for key in ("active_context", "decisions", "spec"):
1401
+ try:
1402
+ paths.append(self.config.doc_path(key))
1403
+ except KeyError:
1404
+ pass
1429
1405
  add_paths = [str(p.relative_to(self.repo_root)) for p in paths if p.exists()]
1430
1406
  if not add_paths:
1431
1407
  return
@@ -1465,9 +1441,19 @@ class Engine:
1465
1441
  env_builder=env_builder,
1466
1442
  logger=self._app_server_logger,
1467
1443
  notification_handler=self._handle_app_server_notification,
1444
+ auto_restart=config.auto_restart,
1468
1445
  max_handles=config.max_handles,
1469
1446
  idle_ttl_seconds=config.idle_ttl_seconds,
1470
1447
  request_timeout=config.request_timeout,
1448
+ turn_stall_timeout_seconds=config.turn_stall_timeout_seconds,
1449
+ turn_stall_poll_interval_seconds=config.turn_stall_poll_interval_seconds,
1450
+ turn_stall_recovery_min_interval_seconds=config.turn_stall_recovery_min_interval_seconds,
1451
+ max_message_bytes=config.client.max_message_bytes,
1452
+ oversize_preview_bytes=config.client.oversize_preview_bytes,
1453
+ max_oversize_drain_bytes=config.client.max_oversize_drain_bytes,
1454
+ restart_backoff_initial_seconds=config.client.restart_backoff_initial_seconds,
1455
+ restart_backoff_max_seconds=config.client.restart_backoff_max_seconds,
1456
+ restart_backoff_jitter_ratio=config.client.restart_backoff_jitter_ratio,
1471
1457
  )
1472
1458
 
1473
1459
  def _ensure_app_server_supervisor(
@@ -1509,6 +1495,7 @@ class Engine:
1509
1495
  request_timeout=config.request_timeout,
1510
1496
  max_handles=config.max_handles,
1511
1497
  idle_ttl_seconds=config.idle_ttl_seconds,
1498
+ session_stall_timeout_seconds=self.config.opencode.session_stall_timeout_seconds,
1512
1499
  base_env=None,
1513
1500
  subagent_models=subagent_models,
1514
1501
  )
@@ -1536,22 +1523,6 @@ class Engine:
1536
1523
  except Exception as exc:
1537
1524
  self._app_server_logger.warning("opencode supervisor close failed: %s", exc)
1538
1525
 
1539
- def _get_orchestrator(self, agent_id: str):
1540
- if agent_id == "opencode":
1541
- opencode_sup = self._ensure_opencode_supervisor()
1542
- if opencode_sup is None:
1543
- return None
1544
- return create_orchestrator(agent_id, opencode_supervisor=opencode_sup)
1545
- else:
1546
- app_server_sup = self._ensure_app_server_supervisor(
1547
- lambda workspace_root, workspace_id, state_dir: {}
1548
- )
1549
- return create_orchestrator(
1550
- agent_id,
1551
- codex_supervisor=app_server_sup,
1552
- codex_events=self._app_server_events,
1553
- )
1554
-
1555
1526
  async def _wait_for_stop(
1556
1527
  self,
1557
1528
  external_stop_flag: Optional[threading.Event],
@@ -1669,8 +1640,9 @@ class Engine:
1669
1640
  await client.get_session(thread_id)
1670
1641
  except Exception as exc:
1671
1642
  self._app_server_logger.debug(
1672
- "Failed to get existing opencode session '%s': %s",
1643
+ "Failed to get existing opencode session '%s' for run %s: %s",
1673
1644
  thread_id,
1645
+ run_id,
1674
1646
  exc,
1675
1647
  )
1676
1648
  self._app_server_threads.reset_thread(key)
@@ -1742,11 +1714,13 @@ class Engine:
1742
1714
  client,
1743
1715
  session_id=thread_id,
1744
1716
  workspace_path=str(self.repo_root),
1717
+ model_payload=model_payload,
1745
1718
  permission_policy=permission_policy,
1746
1719
  question_policy="auto_first_option",
1747
1720
  should_stop=active.interrupt_event.is_set,
1748
1721
  part_handler=_opencode_part_handler,
1749
1722
  ready_event=ready_event,
1723
+ stall_timeout_seconds=self.config.opencode.session_stall_timeout_seconds,
1750
1724
  )
1751
1725
  )
1752
1726
  with contextlib.suppress(asyncio.TimeoutError):
@@ -1843,7 +1817,12 @@ class Engine:
1843
1817
  output_result = OpenCodeTurnOutput(
1844
1818
  text=fallback.text, error=fallback.error
1845
1819
  )
1820
+ self.log_line(run_id, "info: opencode fallback message used")
1846
1821
  finally:
1822
+ # Flush buffered reasoning deltas before cleanup, so partial reasoning is still logged
1823
+ # even when the turn is aborted, times out, or is interrupted.
1824
+ for line in self._opencode_event_formatter.flush_all_reasoning():
1825
+ self.log_line(run_id, f"stdout: {line}" if line else "stdout: ")
1847
1826
  stop_task.cancel()
1848
1827
  with contextlib.suppress(asyncio.CancelledError):
1849
1828
  await stop_task
@@ -1854,17 +1833,20 @@ class Engine:
1854
1833
  if opencode_turn_started:
1855
1834
  await supervisor.mark_turn_finished(self.repo_root)
1856
1835
 
1857
- output = output_result.text
1858
- if output:
1859
- self._log_app_server_output(run_id, [output])
1860
- output_text = output.strip()
1861
- if output_text:
1862
- output_path = self._write_run_artifact(
1863
- run_id, "output.txt", output_text
1864
- )
1865
- self._merge_run_index_entry(
1866
- run_id, {"artifacts": {"output_path": str(output_path)}}
1867
- )
1836
+ if not output_result.text:
1837
+ self.log_line(
1838
+ run_id,
1839
+ "info: opencode returned empty output (error=%s)"
1840
+ % (output_result.error or "none"),
1841
+ )
1842
+ if output_result.text:
1843
+ handle_agent_output(
1844
+ self._log_app_server_output,
1845
+ self._write_run_artifact,
1846
+ self._merge_run_index_entry,
1847
+ run_id,
1848
+ output_result.text,
1849
+ )
1868
1850
  if output_result.error:
1869
1851
  self.log_line(
1870
1852
  run_id, f"error: opencode session error: {output_result.error}"
@@ -1894,8 +1876,10 @@ class Engine:
1894
1876
  )
1895
1877
  )
1896
1878
  no_progress_count = 0
1897
- last_outstanding_count = len(self.docs.todos()[0])
1898
- last_done_count = len(self.docs.todos()[1])
1879
+ ticket_dir = self.repo_root / ".codex-autorunner" / "tickets"
1880
+ initial_tickets = list_ticket_paths(ticket_dir)
1881
+ last_done_count = sum(1 for path in initial_tickets if ticket_is_done(path))
1882
+ last_outstanding_count = len(initial_tickets) - last_done_count
1899
1883
  exit_reason: Optional[str] = None
1900
1884
 
1901
1885
  try:
@@ -1949,9 +1933,11 @@ class Engine:
1949
1933
  break
1950
1934
 
1951
1935
  # Check for no progress across runs
1952
- current_outstanding, current_done = self.docs.todos()
1953
- current_outstanding_count = len(current_outstanding)
1954
- current_done_count = len(current_done)
1936
+ current_tickets = list_ticket_paths(ticket_dir)
1937
+ current_done_count = sum(
1938
+ 1 for path in current_tickets if ticket_is_done(path)
1939
+ )
1940
+ current_outstanding_count = len(current_tickets) - current_done_count
1955
1941
 
1956
1942
  # Check if there was any meaningful progress
1957
1943
  has_progress = (
@@ -1959,25 +1945,55 @@ class Engine:
1959
1945
  or current_done_count != last_done_count
1960
1946
  )
1961
1947
 
1962
- # Check if there was any meaningful output (diff, files changed, etc.)
1948
+ # Check if there was any meaningful output (diff, plan, etc.)
1963
1949
  has_output = False
1964
- try:
1965
- output_path = (
1966
- self.repo_root
1967
- / ".codex-autorunner"
1968
- / "runs"
1969
- / f"run-{run_id}"
1970
- / "output.txt"
1971
- )
1972
- if output_path.exists():
1973
- output_content = output_path.read_text(encoding="utf-8").strip()
1974
- # Consider it output if there's meaningful text (not just empty or whitespace)
1975
- has_output = len(output_content) > 100
1976
- except (OSError, IOError):
1977
- pass
1950
+ run_entry = self._run_index_store.get_entry(run_id)
1951
+ if run_entry:
1952
+ artifacts = run_entry.get("artifacts", {})
1953
+ if isinstance(artifacts, dict):
1954
+ diff_path = artifacts.get("diff_path")
1955
+ if diff_path:
1956
+ try:
1957
+ diff_content = (
1958
+ Path(diff_path).read_text(encoding="utf-8").strip()
1959
+ )
1960
+ has_output = len(diff_content) > 0
1961
+ except (OSError, IOError):
1962
+ pass
1963
+ if not has_output:
1964
+ plan_path = artifacts.get("plan_path")
1965
+ if plan_path:
1966
+ try:
1967
+ plan_content = (
1968
+ Path(plan_path)
1969
+ .read_text(encoding="utf-8")
1970
+ .strip()
1971
+ )
1972
+ has_output = len(plan_content) > 0
1973
+ except (OSError, IOError):
1974
+ pass
1978
1975
 
1979
1976
  if not has_progress and not has_output:
1980
1977
  no_progress_count += 1
1978
+
1979
+ evidence = {
1980
+ "outstanding_count": current_outstanding_count,
1981
+ "done_count": current_done_count,
1982
+ "has_diff": bool(
1983
+ run_entry
1984
+ and isinstance(run_entry.get("artifacts"), dict)
1985
+ and run_entry["artifacts"].get("diff_path")
1986
+ ),
1987
+ "has_plan": bool(
1988
+ run_entry
1989
+ and isinstance(run_entry.get("artifacts"), dict)
1990
+ and run_entry["artifacts"].get("plan_path")
1991
+ ),
1992
+ "run_id": run_id,
1993
+ }
1994
+ self._emit_event(
1995
+ run_id, "run.no_progress", count=no_progress_count, **evidence
1996
+ )
1981
1997
  self.log_line(
1982
1998
  run_id,
1983
1999
  f"info: no progress detected ({no_progress_count}/{self.config.runner_no_progress_threshold} runs without progress)",
@@ -2030,12 +2046,16 @@ class Engine:
2030
2046
  for line in tb.splitlines():
2031
2047
  self.log_line(run_id, f"traceback: {line}")
2032
2048
  except (OSError, IOError) as exc:
2033
- self._app_server_logger.error("Failed to log run_loop crash: %s", exc)
2049
+ self._app_server_logger.error(
2050
+ "Failed to log run_loop crash for run %s: %s", run_id, exc
2051
+ )
2034
2052
  try:
2035
2053
  self._update_state("error", run_id, 1, finished=True)
2036
2054
  except (OSError, IOError) as exc:
2037
2055
  self._app_server_logger.error(
2038
- "Failed to update state after run_loop crash: %s", exc
2056
+ "Failed to update state after run_loop crash for run %s: %s",
2057
+ run_id,
2058
+ exc,
2039
2059
  )
2040
2060
  finally:
2041
2061
  try:
@@ -2044,7 +2064,9 @@ class Engine:
2044
2064
  last_exit_code=last_exit_code,
2045
2065
  )
2046
2066
  except Exception as exc:
2047
- self._app_server_logger.warning("End-of-run review failed: %s", exc)
2067
+ self._app_server_logger.warning(
2068
+ "End-of-run review failed for run %s: %s", run_id, exc
2069
+ )
2048
2070
  await self._close_app_server_supervisor()
2049
2071
  await self._close_opencode_supervisor()
2050
2072
  # IMPORTANT: lock ownership is managed by the caller (CLI/Hub/Server runner).
@@ -2208,8 +2230,12 @@ class Engine:
2208
2230
  started: bool = False,
2209
2231
  finished: bool = False,
2210
2232
  ) -> None:
2233
+ prev_status: Optional[str] = None
2234
+ last_run_started_at: Optional[str] = None
2235
+ last_run_finished_at: Optional[str] = None
2211
2236
  with state_lock(self.state_path):
2212
2237
  current = load_state(self.state_path)
2238
+ prev_status = current.status
2213
2239
  last_run_started_at = current.last_run_started_at
2214
2240
  last_run_finished_at = current.last_run_finished_at
2215
2241
  runner_pid = current.runner_pid
@@ -2237,6 +2263,18 @@ class Engine:
2237
2263
  repo_to_session=current.repo_to_session,
2238
2264
  )
2239
2265
  save_state(self.state_path, new_state)
2266
+ if run_id > 0 and prev_status != status:
2267
+ payload: dict[str, Any] = {
2268
+ "from_status": prev_status,
2269
+ "to_status": status,
2270
+ }
2271
+ if exit_code is not None:
2272
+ payload["exit_code"] = exit_code
2273
+ if started and last_run_started_at:
2274
+ payload["started_at"] = last_run_started_at
2275
+ if finished and last_run_finished_at:
2276
+ payload["finished_at"] = last_run_finished_at
2277
+ self._emit_event(run_id, "run.state_changed", **payload)
2240
2278
 
2241
2279
 
2242
2280
  def clear_stale_lock(lock_path: Path) -> bool:
@@ -2612,26 +2650,4 @@ def doctor(start_path: Path) -> DoctorReport:
2612
2650
  "Install git or disable worktrees.",
2613
2651
  )
2614
2652
 
2615
- telegram_cfg = None
2616
- if isinstance(config.raw, dict):
2617
- telegram_cfg = config.raw.get("telegram_bot")
2618
- if isinstance(telegram_cfg, dict) and telegram_cfg.get("enabled") is True:
2619
- missing_telegram = missing_optional_dependencies((("httpx", "httpx"),))
2620
- if missing_telegram:
2621
- deps_list = ", ".join(missing_telegram)
2622
- _append_check(
2623
- checks,
2624
- "telegram.dependencies",
2625
- "error",
2626
- f"Telegram is enabled but missing optional deps: {deps_list}",
2627
- "Install with `pip install codex-autorunner[telegram]`.",
2628
- )
2629
- else:
2630
- _append_check(
2631
- checks,
2632
- "telegram.dependencies",
2633
- "ok",
2634
- "Telegram dependencies are installed.",
2635
- )
2636
-
2637
2653
  return DoctorReport(checks=checks)