codex-autorunner 1.0.0__py3-none-any.whl → 1.2.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 (227) hide show
  1. codex_autorunner/__init__.py +12 -1
  2. codex_autorunner/agents/codex/harness.py +1 -1
  3. codex_autorunner/agents/opencode/client.py +113 -4
  4. codex_autorunner/agents/opencode/constants.py +3 -0
  5. codex_autorunner/agents/opencode/harness.py +6 -1
  6. codex_autorunner/agents/opencode/runtime.py +59 -18
  7. codex_autorunner/agents/opencode/supervisor.py +4 -0
  8. codex_autorunner/agents/registry.py +36 -7
  9. codex_autorunner/bootstrap.py +226 -4
  10. codex_autorunner/cli.py +5 -1174
  11. codex_autorunner/codex_cli.py +20 -84
  12. codex_autorunner/core/__init__.py +20 -0
  13. codex_autorunner/core/about_car.py +119 -1
  14. codex_autorunner/core/app_server_ids.py +59 -0
  15. codex_autorunner/core/app_server_threads.py +17 -2
  16. codex_autorunner/core/app_server_utils.py +165 -0
  17. codex_autorunner/core/archive.py +349 -0
  18. codex_autorunner/core/codex_runner.py +6 -2
  19. codex_autorunner/core/config.py +433 -4
  20. codex_autorunner/core/context_awareness.py +38 -0
  21. codex_autorunner/core/docs.py +0 -122
  22. codex_autorunner/core/drafts.py +58 -4
  23. codex_autorunner/core/exceptions.py +4 -0
  24. codex_autorunner/core/filebox.py +265 -0
  25. codex_autorunner/core/flows/controller.py +96 -2
  26. codex_autorunner/core/flows/models.py +13 -0
  27. codex_autorunner/core/flows/reasons.py +52 -0
  28. codex_autorunner/core/flows/reconciler.py +134 -0
  29. codex_autorunner/core/flows/runtime.py +57 -4
  30. codex_autorunner/core/flows/store.py +142 -7
  31. codex_autorunner/core/flows/transition.py +27 -15
  32. codex_autorunner/core/flows/ux_helpers.py +272 -0
  33. codex_autorunner/core/flows/worker_process.py +32 -6
  34. codex_autorunner/core/git_utils.py +62 -0
  35. codex_autorunner/core/hub.py +291 -20
  36. codex_autorunner/core/lifecycle_events.py +253 -0
  37. codex_autorunner/core/notifications.py +14 -2
  38. codex_autorunner/core/path_utils.py +2 -1
  39. codex_autorunner/core/pma_audit.py +224 -0
  40. codex_autorunner/core/pma_context.py +496 -0
  41. codex_autorunner/core/pma_dispatch_interceptor.py +284 -0
  42. codex_autorunner/core/pma_lifecycle.py +527 -0
  43. codex_autorunner/core/pma_queue.py +367 -0
  44. codex_autorunner/core/pma_safety.py +221 -0
  45. codex_autorunner/core/pma_state.py +115 -0
  46. codex_autorunner/core/ports/__init__.py +28 -0
  47. codex_autorunner/{integrations/agents → core/ports}/agent_backend.py +13 -8
  48. codex_autorunner/core/ports/backend_orchestrator.py +41 -0
  49. codex_autorunner/{integrations/agents → core/ports}/run_event.py +23 -6
  50. codex_autorunner/core/prompt.py +0 -80
  51. codex_autorunner/core/prompts.py +56 -172
  52. codex_autorunner/core/redaction.py +0 -4
  53. codex_autorunner/core/review_context.py +11 -9
  54. codex_autorunner/core/runner_controller.py +35 -33
  55. codex_autorunner/core/runner_state.py +147 -0
  56. codex_autorunner/core/runtime.py +829 -0
  57. codex_autorunner/core/sqlite_utils.py +13 -4
  58. codex_autorunner/core/state.py +7 -10
  59. codex_autorunner/core/state_roots.py +62 -0
  60. codex_autorunner/core/supervisor_protocol.py +15 -0
  61. codex_autorunner/core/templates/__init__.py +39 -0
  62. codex_autorunner/core/templates/git_mirror.py +234 -0
  63. codex_autorunner/core/templates/provenance.py +56 -0
  64. codex_autorunner/core/templates/scan_cache.py +120 -0
  65. codex_autorunner/core/text_delta_coalescer.py +54 -0
  66. codex_autorunner/core/ticket_linter_cli.py +218 -0
  67. codex_autorunner/core/ticket_manager_cli.py +494 -0
  68. codex_autorunner/core/time_utils.py +11 -0
  69. codex_autorunner/core/types.py +18 -0
  70. codex_autorunner/core/update.py +4 -5
  71. codex_autorunner/core/update_paths.py +28 -0
  72. codex_autorunner/core/usage.py +164 -12
  73. codex_autorunner/core/utils.py +125 -15
  74. codex_autorunner/flows/review/__init__.py +17 -0
  75. codex_autorunner/{core/review.py → flows/review/service.py} +37 -34
  76. codex_autorunner/flows/ticket_flow/definition.py +52 -3
  77. codex_autorunner/integrations/agents/__init__.py +11 -19
  78. codex_autorunner/integrations/agents/backend_orchestrator.py +302 -0
  79. codex_autorunner/integrations/agents/codex_adapter.py +90 -0
  80. codex_autorunner/integrations/agents/codex_backend.py +177 -25
  81. codex_autorunner/integrations/agents/opencode_adapter.py +108 -0
  82. codex_autorunner/integrations/agents/opencode_backend.py +305 -32
  83. codex_autorunner/integrations/agents/runner.py +86 -0
  84. codex_autorunner/integrations/agents/wiring.py +279 -0
  85. codex_autorunner/integrations/app_server/client.py +7 -60
  86. codex_autorunner/integrations/app_server/env.py +2 -107
  87. codex_autorunner/{core/app_server_events.py → integrations/app_server/event_buffer.py} +15 -8
  88. codex_autorunner/integrations/telegram/adapter.py +65 -0
  89. codex_autorunner/integrations/telegram/config.py +46 -0
  90. codex_autorunner/integrations/telegram/constants.py +1 -1
  91. codex_autorunner/integrations/telegram/doctor.py +228 -6
  92. codex_autorunner/integrations/telegram/handlers/callbacks.py +7 -0
  93. codex_autorunner/integrations/telegram/handlers/commands/execution.py +236 -74
  94. codex_autorunner/integrations/telegram/handlers/commands/files.py +314 -75
  95. codex_autorunner/integrations/telegram/handlers/commands/flows.py +1496 -71
  96. codex_autorunner/integrations/telegram/handlers/commands/workspace.py +498 -37
  97. codex_autorunner/integrations/telegram/handlers/commands_runtime.py +206 -48
  98. codex_autorunner/integrations/telegram/handlers/commands_spec.py +20 -3
  99. codex_autorunner/integrations/telegram/handlers/messages.py +27 -1
  100. codex_autorunner/integrations/telegram/handlers/selections.py +61 -1
  101. codex_autorunner/integrations/telegram/helpers.py +22 -1
  102. codex_autorunner/integrations/telegram/runtime.py +9 -4
  103. codex_autorunner/integrations/telegram/service.py +45 -10
  104. codex_autorunner/integrations/telegram/state.py +38 -0
  105. codex_autorunner/integrations/telegram/ticket_flow_bridge.py +338 -43
  106. codex_autorunner/integrations/telegram/transport.py +13 -4
  107. codex_autorunner/integrations/templates/__init__.py +27 -0
  108. codex_autorunner/integrations/templates/scan_agent.py +312 -0
  109. codex_autorunner/routes/__init__.py +37 -76
  110. codex_autorunner/routes/agents.py +2 -137
  111. codex_autorunner/routes/analytics.py +2 -238
  112. codex_autorunner/routes/app_server.py +2 -131
  113. codex_autorunner/routes/base.py +2 -596
  114. codex_autorunner/routes/file_chat.py +4 -833
  115. codex_autorunner/routes/flows.py +4 -977
  116. codex_autorunner/routes/messages.py +4 -456
  117. codex_autorunner/routes/repos.py +2 -196
  118. codex_autorunner/routes/review.py +2 -147
  119. codex_autorunner/routes/sessions.py +2 -175
  120. codex_autorunner/routes/settings.py +2 -168
  121. codex_autorunner/routes/shared.py +2 -275
  122. codex_autorunner/routes/system.py +4 -193
  123. codex_autorunner/routes/usage.py +2 -86
  124. codex_autorunner/routes/voice.py +2 -119
  125. codex_autorunner/routes/workspace.py +2 -270
  126. codex_autorunner/server.py +4 -4
  127. codex_autorunner/static/agentControls.js +61 -16
  128. codex_autorunner/static/app.js +126 -14
  129. codex_autorunner/static/archive.js +826 -0
  130. codex_autorunner/static/archiveApi.js +37 -0
  131. codex_autorunner/static/autoRefresh.js +7 -7
  132. codex_autorunner/static/chatUploads.js +137 -0
  133. codex_autorunner/static/dashboard.js +224 -171
  134. codex_autorunner/static/docChatCore.js +185 -13
  135. codex_autorunner/static/fileChat.js +68 -40
  136. codex_autorunner/static/fileboxUi.js +159 -0
  137. codex_autorunner/static/hub.js +114 -131
  138. codex_autorunner/static/index.html +375 -49
  139. codex_autorunner/static/messages.js +568 -87
  140. codex_autorunner/static/notifications.js +255 -0
  141. codex_autorunner/static/pma.js +1167 -0
  142. codex_autorunner/static/preserve.js +17 -0
  143. codex_autorunner/static/settings.js +128 -6
  144. codex_autorunner/static/smartRefresh.js +52 -0
  145. codex_autorunner/static/streamUtils.js +57 -0
  146. codex_autorunner/static/styles.css +9798 -6143
  147. codex_autorunner/static/tabs.js +152 -11
  148. codex_autorunner/static/templateReposSettings.js +225 -0
  149. codex_autorunner/static/terminal.js +18 -0
  150. codex_autorunner/static/ticketChatActions.js +165 -3
  151. codex_autorunner/static/ticketChatStream.js +17 -119
  152. codex_autorunner/static/ticketEditor.js +137 -15
  153. codex_autorunner/static/ticketTemplates.js +798 -0
  154. codex_autorunner/static/tickets.js +821 -98
  155. codex_autorunner/static/turnEvents.js +27 -0
  156. codex_autorunner/static/turnResume.js +33 -0
  157. codex_autorunner/static/utils.js +39 -0
  158. codex_autorunner/static/workspace.js +389 -82
  159. codex_autorunner/static/workspaceFileBrowser.js +15 -13
  160. codex_autorunner/surfaces/__init__.py +5 -0
  161. codex_autorunner/surfaces/cli/__init__.py +6 -0
  162. codex_autorunner/surfaces/cli/cli.py +2534 -0
  163. codex_autorunner/surfaces/cli/codex_cli.py +20 -0
  164. codex_autorunner/surfaces/cli/pma_cli.py +817 -0
  165. codex_autorunner/surfaces/telegram/__init__.py +3 -0
  166. codex_autorunner/surfaces/web/__init__.py +1 -0
  167. codex_autorunner/surfaces/web/app.py +2223 -0
  168. codex_autorunner/surfaces/web/hub_jobs.py +192 -0
  169. codex_autorunner/surfaces/web/middleware.py +587 -0
  170. codex_autorunner/surfaces/web/pty_session.py +370 -0
  171. codex_autorunner/surfaces/web/review.py +6 -0
  172. codex_autorunner/surfaces/web/routes/__init__.py +82 -0
  173. codex_autorunner/surfaces/web/routes/agents.py +138 -0
  174. codex_autorunner/surfaces/web/routes/analytics.py +284 -0
  175. codex_autorunner/surfaces/web/routes/app_server.py +132 -0
  176. codex_autorunner/surfaces/web/routes/archive.py +357 -0
  177. codex_autorunner/surfaces/web/routes/base.py +615 -0
  178. codex_autorunner/surfaces/web/routes/file_chat.py +1117 -0
  179. codex_autorunner/surfaces/web/routes/filebox.py +227 -0
  180. codex_autorunner/surfaces/web/routes/flows.py +1354 -0
  181. codex_autorunner/surfaces/web/routes/messages.py +490 -0
  182. codex_autorunner/surfaces/web/routes/pma.py +1652 -0
  183. codex_autorunner/surfaces/web/routes/repos.py +197 -0
  184. codex_autorunner/surfaces/web/routes/review.py +148 -0
  185. codex_autorunner/surfaces/web/routes/sessions.py +176 -0
  186. codex_autorunner/surfaces/web/routes/settings.py +169 -0
  187. codex_autorunner/surfaces/web/routes/shared.py +277 -0
  188. codex_autorunner/surfaces/web/routes/system.py +196 -0
  189. codex_autorunner/surfaces/web/routes/templates.py +634 -0
  190. codex_autorunner/surfaces/web/routes/usage.py +89 -0
  191. codex_autorunner/surfaces/web/routes/voice.py +120 -0
  192. codex_autorunner/surfaces/web/routes/workspace.py +271 -0
  193. codex_autorunner/surfaces/web/runner_manager.py +25 -0
  194. codex_autorunner/surfaces/web/schemas.py +469 -0
  195. codex_autorunner/surfaces/web/static_assets.py +490 -0
  196. codex_autorunner/surfaces/web/static_refresh.py +86 -0
  197. codex_autorunner/surfaces/web/terminal_sessions.py +78 -0
  198. codex_autorunner/tickets/__init__.py +8 -1
  199. codex_autorunner/tickets/agent_pool.py +53 -4
  200. codex_autorunner/tickets/files.py +37 -16
  201. codex_autorunner/tickets/lint.py +50 -0
  202. codex_autorunner/tickets/models.py +6 -1
  203. codex_autorunner/tickets/outbox.py +50 -2
  204. codex_autorunner/tickets/runner.py +396 -57
  205. codex_autorunner/web/__init__.py +5 -1
  206. codex_autorunner/web/app.py +2 -1949
  207. codex_autorunner/web/hub_jobs.py +2 -191
  208. codex_autorunner/web/middleware.py +2 -586
  209. codex_autorunner/web/pty_session.py +2 -369
  210. codex_autorunner/web/runner_manager.py +2 -24
  211. codex_autorunner/web/schemas.py +2 -376
  212. codex_autorunner/web/static_assets.py +4 -441
  213. codex_autorunner/web/static_refresh.py +2 -85
  214. codex_autorunner/web/terminal_sessions.py +2 -77
  215. codex_autorunner/workspace/paths.py +49 -33
  216. codex_autorunner-1.2.0.dist-info/METADATA +150 -0
  217. codex_autorunner-1.2.0.dist-info/RECORD +339 -0
  218. codex_autorunner/core/adapter_utils.py +0 -21
  219. codex_autorunner/core/engine.py +0 -2653
  220. codex_autorunner/core/static_assets.py +0 -55
  221. codex_autorunner-1.0.0.dist-info/METADATA +0 -246
  222. codex_autorunner-1.0.0.dist-info/RECORD +0 -251
  223. /codex_autorunner/{routes → surfaces/web/routes}/terminal_images.py +0 -0
  224. {codex_autorunner-1.0.0.dist-info → codex_autorunner-1.2.0.dist-info}/WHEEL +0 -0
  225. {codex_autorunner-1.0.0.dist-info → codex_autorunner-1.2.0.dist-info}/entry_points.txt +0 -0
  226. {codex_autorunner-1.0.0.dist-info → codex_autorunner-1.2.0.dist-info}/licenses/LICENSE +0 -0
  227. {codex_autorunner-1.0.0.dist-info → codex_autorunner-1.2.0.dist-info}/top_level.txt +0 -0
@@ -5,12 +5,12 @@ from pathlib import Path
5
5
  from typing import Any, Callable, Optional
6
6
 
7
7
  from ..core.flows.models import FlowEventType
8
- from ..core.git_utils import run_git
8
+ from ..core.git_utils import git_diff_stats, run_git
9
9
  from ..workspace.paths import workspace_doc_path
10
10
  from .agent_pool import AgentPool, AgentTurnRequest
11
11
  from .files import list_ticket_paths, read_ticket, safe_relpath, ticket_is_done
12
12
  from .frontmatter import parse_markdown_frontmatter
13
- from .lint import lint_ticket_frontmatter
13
+ from .lint import lint_ticket_directory, lint_ticket_frontmatter
14
14
  from .models import TicketFrontmatter, TicketResult, TicketRunConfig
15
15
  from .outbox import (
16
16
  archive_dispatch,
@@ -18,11 +18,154 @@ from .outbox import (
18
18
  ensure_outbox_dirs,
19
19
  resolve_outbox_paths,
20
20
  )
21
- from .replies import ensure_reply_dirs, parse_user_reply, resolve_reply_paths
21
+ from .replies import (
22
+ dispatch_reply,
23
+ ensure_reply_dirs,
24
+ next_reply_seq,
25
+ parse_user_reply,
26
+ resolve_reply_paths,
27
+ )
22
28
 
23
29
  _logger = logging.getLogger(__name__)
24
30
 
25
31
  WORKSPACE_DOC_MAX_CHARS = 4000
32
+ TRUNCATION_MARKER = "\n\n[... TRUNCATED ...]\n\n"
33
+
34
+
35
+ def _truncate_text_by_bytes(text: str, max_bytes: int) -> str:
36
+ """Truncate text to fit within max_bytes UTF-8 encoded size."""
37
+ if max_bytes <= 0:
38
+ return ""
39
+ normalized = text or ""
40
+ encoded = normalized.encode("utf-8")
41
+ if len(encoded) <= max_bytes:
42
+ return normalized
43
+ marker_bytes = len(TRUNCATION_MARKER.encode("utf-8"))
44
+ if max_bytes <= marker_bytes:
45
+ return TRUNCATION_MARKER.encode("utf-8")[:max_bytes].decode(
46
+ "utf-8", errors="ignore"
47
+ )
48
+ target_bytes = max_bytes - marker_bytes
49
+ truncated = encoded[:target_bytes].decode("utf-8", errors="ignore")
50
+ return truncated + TRUNCATION_MARKER
51
+
52
+
53
+ def _is_network_error(error_message: str) -> bool:
54
+ """Check if an error message indicates a transient network issue.
55
+
56
+ Returns True if the error appears to be network-related and retryable.
57
+ This includes connection errors, timeouts, and transport failures.
58
+ """
59
+ if not error_message:
60
+ return False
61
+ error_lower = error_message.lower()
62
+ network_indicators = [
63
+ "network error",
64
+ "connection",
65
+ "timeout",
66
+ "transport error",
67
+ "disconnected",
68
+ "unreachable",
69
+ "reconnecting",
70
+ "connection refused",
71
+ "connection reset",
72
+ "connection broken",
73
+ "temporary failure",
74
+ ]
75
+ return any(indicator in error_lower for indicator in network_indicators)
76
+
77
+
78
+ def _preserve_ticket_structure(ticket_block: str, max_bytes: int) -> str:
79
+ """Truncate ticket block while preserving prefix and ticket frontmatter.
80
+
81
+ ticket_block format:
82
+ "\\n\\n<CAR_CURRENT_TICKET_FILE>\\nPATH: ...\\n<TICKET_MARKDOWN>\\n"
83
+ "{ticket_raw_content}\\n</TICKET_MARKDOWN>\\n</CAR_CURRENT_TICKET_FILE>\\n"
84
+ where ticket_raw_content itself contains markdown frontmatter.
85
+ """
86
+ if len(ticket_block.encode("utf-8")) <= max_bytes:
87
+ return ticket_block
88
+
89
+ # ticket_block structure:
90
+ # "<CAR_CURRENT_TICKET_FILE>\n"
91
+ # "PATH: {rel_ticket}\n"
92
+ # "<TICKET_MARKDOWN>\n"
93
+ # "---\n" - ticket frontmatter start
94
+ # "agent: ...\n"
95
+ # "done: ...\n"
96
+ # "title: ...\n"
97
+ # "goal: ...\n"
98
+ # "---\n" - ticket frontmatter end (what we want to preserve)
99
+ # ticket body...
100
+ # "</TICKET_MARKDOWN>\n"
101
+ # "</CAR_CURRENT_TICKET_FILE>\n"
102
+
103
+ # Find the frontmatter markers after <TICKET_MARKDOWN>.
104
+ marker = "\n---\n"
105
+ ticket_md_idx = ticket_block.find("<TICKET_MARKDOWN>")
106
+ if ticket_md_idx == -1:
107
+ return _truncate_text_by_bytes(ticket_block, max_bytes)
108
+
109
+ first_marker_idx = ticket_block.find(marker, ticket_md_idx)
110
+ if first_marker_idx == -1:
111
+ return _truncate_text_by_bytes(ticket_block, max_bytes)
112
+
113
+ second_marker_idx = ticket_block.find(marker, first_marker_idx + 1)
114
+ if second_marker_idx == -1:
115
+ return _truncate_text_by_bytes(ticket_block, max_bytes)
116
+
117
+ # Preserve everything up to and including the second marker
118
+ preserve_end = second_marker_idx + len(marker)
119
+ preserved_part = ticket_block[:preserve_end]
120
+
121
+ # Check if we still have room (account for truncation marker that will be added)
122
+ preserved_bytes = len(preserved_part.encode("utf-8"))
123
+ marker_bytes = len(TRUNCATION_MARKER.encode("utf-8"))
124
+ remaining_bytes = max(max_bytes - preserved_bytes, 0)
125
+
126
+ if remaining_bytes > 0:
127
+ body = ticket_block[preserve_end:]
128
+ # Account for marker in the body budget
129
+ body_budget = max(remaining_bytes - marker_bytes, 0)
130
+ truncated_body = _truncate_text_by_bytes(body, body_budget)
131
+ return preserved_part + truncated_body
132
+
133
+ # Not enough room even for preserved part, fall back to simple truncation
134
+ return _truncate_text_by_bytes(ticket_block, max_bytes)
135
+
136
+
137
+ def _shrink_prompt(
138
+ *,
139
+ max_bytes: int,
140
+ render: Callable[[], str],
141
+ sections: dict[str, str],
142
+ order: list[str],
143
+ ) -> str:
144
+ """Shrink prompt by truncating sections in order of priority."""
145
+ prompt = render()
146
+ if len(prompt.encode("utf-8")) <= max_bytes:
147
+ return prompt
148
+
149
+ for key in order:
150
+ if len(prompt.encode("utf-8")) <= max_bytes:
151
+ break
152
+ value = sections.get(key, "")
153
+ if not value:
154
+ continue
155
+ overflow = len(prompt.encode("utf-8")) - max_bytes
156
+ value_bytes = len(value.encode("utf-8"))
157
+ new_limit = max(value_bytes - overflow, 0)
158
+
159
+ if key == "ticket_block":
160
+ sections[key] = _preserve_ticket_structure(value, new_limit)
161
+ else:
162
+ sections[key] = _truncate_text_by_bytes(value, new_limit)
163
+ prompt = render()
164
+
165
+ if len(prompt.encode("utf-8")) > max_bytes:
166
+ prompt = _truncate_text_by_bytes(prompt, max_bytes)
167
+
168
+ return prompt
26
169
 
27
170
 
28
171
  class TicketRunner:
@@ -41,11 +184,13 @@ class TicketRunner:
41
184
  run_id: str,
42
185
  config: TicketRunConfig,
43
186
  agent_pool: AgentPool,
187
+ repo_id: str = "",
44
188
  ):
45
189
  self._workspace_root = workspace_root
46
190
  self._run_id = run_id
47
191
  self._config = config
48
192
  self._agent_pool = agent_pool
193
+ self._repo_id = repo_id
49
194
 
50
195
  async def step(
51
196
  self,
@@ -73,10 +218,17 @@ class TicketRunner:
73
218
  commit_retries = int(commit_state.get("retries") or 0)
74
219
  # Global counters.
75
220
  total_turns = int(state.get("total_turns") or 0)
221
+
222
+ _network_raw = state.get("network_retry")
223
+ network_retry_state: dict[str, Any] = (
224
+ _network_raw if isinstance(_network_raw, dict) else {}
225
+ )
226
+ network_retries = int(network_retry_state.get("retries") or 0)
76
227
  if total_turns >= self._config.max_total_turns:
77
228
  return self._pause(
78
229
  state,
79
230
  reason=f"Max turns reached ({self._config.max_total_turns}). Review tickets and resume.",
231
+ reason_code="max_turns",
80
232
  )
81
233
 
82
234
  ticket_dir = self._workspace_root / self._config.ticket_dir
@@ -97,6 +249,23 @@ class TicketRunner:
97
249
  run_id=self._run_id,
98
250
  )
99
251
  ensure_reply_dirs(reply_paths)
252
+ if reply_paths.user_reply_path.exists():
253
+ next_seq = next_reply_seq(reply_paths.reply_history_dir)
254
+ archived, errors = dispatch_reply(reply_paths, next_seq=next_seq)
255
+ if errors:
256
+ return self._pause(
257
+ state,
258
+ reason="Failed to archive USER_REPLY.md.",
259
+ reason_details="Errors:\n- " + "\n- ".join(errors),
260
+ reason_code="needs_user_fix",
261
+ )
262
+ if archived is None:
263
+ return self._pause(
264
+ state,
265
+ reason="Failed to archive USER_REPLY.md.",
266
+ reason_details="Errors:\n- Failed to archive reply",
267
+ reason_code="needs_user_fix",
268
+ )
100
269
 
101
270
  ticket_paths = list_ticket_paths(ticket_dir)
102
271
  if not ticket_paths:
@@ -106,6 +275,17 @@ class TicketRunner:
106
275
  "No tickets found. Create tickets under "
107
276
  f"{safe_relpath(ticket_dir, self._workspace_root)} and resume."
108
277
  ),
278
+ reason_code="no_tickets",
279
+ )
280
+
281
+ # Check for duplicate ticket indices before proceeding.
282
+ dir_lint_errors = lint_ticket_directory(ticket_dir)
283
+ if dir_lint_errors:
284
+ return self._pause(
285
+ state,
286
+ reason="Duplicate ticket indices detected.",
287
+ reason_details="Errors:\n- " + "\n- ".join(dir_lint_errors),
288
+ reason_code="needs_user_fix",
109
289
  )
110
290
 
111
291
  current_ticket = state.get("current_ticket")
@@ -134,6 +314,16 @@ class TicketRunner:
134
314
  )
135
315
  current_path = next_path
136
316
  state["current_ticket"] = safe_relpath(current_path, self._workspace_root)
317
+ # Inform listeners immediately which ticket is about to run so the UI
318
+ # can show the active indicator before the first turn completes.
319
+ if emit_event is not None:
320
+ emit_event(
321
+ FlowEventType.STEP_PROGRESS,
322
+ {
323
+ "message": "Selected ticket",
324
+ "current_ticket": state["current_ticket"],
325
+ },
326
+ )
137
327
  # New ticket resets per-ticket state.
138
328
  state["ticket_turns"] = 0
139
329
  state.pop("last_agent_output", None)
@@ -148,6 +338,7 @@ class TicketRunner:
148
338
  state["status"] = "running"
149
339
  state.pop("reason", None)
150
340
  state.pop("reason_details", None)
341
+ state.pop("reason_code", None)
151
342
 
152
343
  _lint_raw = state.get("lint")
153
344
  lint_state: dict[str, Any] = _lint_raw if isinstance(_lint_raw, dict) else {}
@@ -176,6 +367,7 @@ class TicketRunner:
176
367
  f"{safe_relpath(current_path, self._workspace_root)}: {exc}"
177
368
  ),
178
369
  current_ticket=safe_relpath(current_path, self._workspace_root),
370
+ reason_code="infra_error",
179
371
  )
180
372
 
181
373
  data, _ = parse_markdown_frontmatter(raw)
@@ -189,6 +381,7 @@ class TicketRunner:
189
381
  "Fix the ticket frontmatter manually and resume."
190
382
  ),
191
383
  current_ticket=safe_relpath(current_path, self._workspace_root),
384
+ reason_code="needs_user_fix",
192
385
  )
193
386
 
194
387
  # Validate agent id unless it is the special user sentinel.
@@ -205,6 +398,7 @@ class TicketRunner:
205
398
  f"{safe_relpath(current_path, self._workspace_root)}: {exc}"
206
399
  ),
207
400
  current_ticket=safe_relpath(current_path, self._workspace_root),
401
+ reason_code="needs_user_fix",
208
402
  )
209
403
 
210
404
  ticket_doc = type(
@@ -225,6 +419,7 @@ class TicketRunner:
225
419
  reason=f"Ticket frontmatter invalid: {safe_relpath(current_path, self._workspace_root)}",
226
420
  reason_details="Errors:\n- " + "\n- ".join(ticket_errors),
227
421
  current_ticket=safe_relpath(current_path, self._workspace_root),
422
+ reason_code="needs_user_fix",
228
423
  )
229
424
 
230
425
  # Built-in manual user ticket.
@@ -239,6 +434,7 @@ class TicketRunner:
239
434
  f"{safe_relpath(current_path, self._workspace_root)}"
240
435
  ),
241
436
  current_ticket=safe_relpath(current_path, self._workspace_root),
437
+ reason_code="user_pause",
242
438
  )
243
439
 
244
440
  ticket_turns = int(state.get("ticket_turns") or 0)
@@ -248,14 +444,18 @@ class TicketRunner:
248
444
  )
249
445
 
250
446
  previous_ticket_content: Optional[str] = None
251
- try:
252
- if current_path in ticket_paths:
253
- curr_idx = ticket_paths.index(current_path)
254
- if curr_idx > 0:
255
- prev_path = ticket_paths[curr_idx - 1]
256
- previous_ticket_content = prev_path.read_text(encoding="utf-8")
257
- except Exception:
258
- pass
447
+ if self._config.include_previous_ticket_context:
448
+ try:
449
+ if current_path in ticket_paths:
450
+ curr_idx = ticket_paths.index(current_path)
451
+ if curr_idx > 0:
452
+ prev_path = ticket_paths[curr_idx - 1]
453
+ content = prev_path.read_text(encoding="utf-8")
454
+ previous_ticket_content = _truncate_text_by_bytes(
455
+ content, 16384
456
+ )
457
+ except Exception:
458
+ pass
259
459
 
260
460
  prompt = self._build_prompt(
261
461
  ticket_path=current_path,
@@ -315,17 +515,45 @@ class TicketRunner:
315
515
  state["last_agent_id"] = result.agent_id
316
516
  state["last_agent_conversation_id"] = result.conversation_id
317
517
  state["last_agent_turn_id"] = result.turn_id
518
+
519
+ # Check if this is a network error that should be retried
520
+ if _is_network_error(result.error):
521
+ network_retries += 1
522
+ if network_retries <= self._config.max_network_retries:
523
+ state["network_retry"] = {
524
+ "retries": network_retries,
525
+ "last_error": result.error,
526
+ }
527
+ return TicketResult(
528
+ status="continue",
529
+ state=state,
530
+ reason=(
531
+ f"Network error detected (attempt {network_retries}/{self._config.max_network_retries}): {result.error}\n"
532
+ "Retrying automatically..."
533
+ ),
534
+ current_ticket=safe_relpath(current_path, self._workspace_root),
535
+ agent_output=result.text,
536
+ agent_id=result.agent_id,
537
+ agent_conversation_id=result.conversation_id,
538
+ agent_turn_id=result.turn_id,
539
+ )
540
+
541
+ # Not a network error or retries exhausted - pause for user intervention
542
+ state.pop("network_retry", None)
318
543
  return self._pause(
319
544
  state,
320
545
  reason="Agent turn failed. Fix the issue and resume.",
321
546
  reason_details=f"Error: {result.error}",
322
547
  current_ticket=safe_relpath(current_path, self._workspace_root),
548
+ reason_code="infra_error",
323
549
  )
324
550
 
325
551
  # Mark replies as consumed only after a successful agent turn.
326
552
  if reply_max_seq > reply_seq:
327
553
  state["reply_seq"] = reply_max_seq
328
554
  state["last_agent_output"] = result.text
555
+ # Clear network retry state on successful turn
556
+ state.pop("network_retry", None)
329
557
  state["last_agent_id"] = result.agent_id
330
558
  state["last_agent_conversation_id"] = result.conversation_id
331
559
  state["last_agent_turn_id"] = result.turn_id
@@ -358,7 +586,11 @@ class TicketRunner:
358
586
  dispatch_seq = int(state.get("dispatch_seq") or 0)
359
587
  current_ticket_id = safe_relpath(current_path, self._workspace_root)
360
588
  dispatch, dispatch_errors = archive_dispatch(
361
- outbox_paths, next_seq=dispatch_seq + 1, ticket_id=current_ticket_id
589
+ outbox_paths,
590
+ next_seq=dispatch_seq + 1,
591
+ ticket_id=current_ticket_id,
592
+ repo_id=self._repo_id,
593
+ run_id=self._run_id,
362
594
  )
363
595
  if dispatch_errors:
364
596
  # Treat as pause: user should fix DISPATCH.md frontmatter. Keep outbox
@@ -369,6 +601,7 @@ class TicketRunner:
369
601
  reason="Invalid DISPATCH.md frontmatter.",
370
602
  reason_details="Errors:\n- " + "\n- ".join(dispatch_errors),
371
603
  current_ticket=safe_relpath(current_path, self._workspace_root),
604
+ reason_code="needs_user_fix",
372
605
  )
373
606
 
374
607
  if dispatch is not None:
@@ -378,6 +611,25 @@ class TicketRunner:
378
611
  # Create turn summary record for the agent's final output.
379
612
  # This appears in dispatch history as a distinct "turn summary" entry.
380
613
  turn_summary_seq = int(state.get("dispatch_seq") or 0) + 1
614
+
615
+ # Compute diff stats for this turn (changes since head_before_turn).
616
+ # This captures both committed and uncommitted changes made by the agent.
617
+ turn_diff_stats = None
618
+ try:
619
+ if head_before_turn:
620
+ # Compare current state (HEAD + working tree) against pre-turn commit
621
+ turn_diff_stats = git_diff_stats(
622
+ self._workspace_root, from_ref=head_before_turn
623
+ )
624
+ else:
625
+ # No reference commit; show all uncommitted changes
626
+ turn_diff_stats = git_diff_stats(
627
+ self._workspace_root, from_ref=None, include_staged=True
628
+ )
629
+ except Exception:
630
+ # Best-effort; don't block on stats computation errors
631
+ turn_diff_stats = None
632
+
381
633
  turn_summary, turn_summary_errors = create_turn_summary(
382
634
  outbox_paths,
383
635
  next_seq=turn_summary_seq,
@@ -385,10 +637,31 @@ class TicketRunner:
385
637
  ticket_id=current_ticket_id,
386
638
  agent_id=result.agent_id,
387
639
  turn_number=total_turns,
640
+ diff_stats=turn_diff_stats,
388
641
  )
389
642
  if turn_summary is not None:
390
643
  state["dispatch_seq"] = turn_summary.seq
391
644
 
645
+ # Persist per-turn diff stats in FlowStore as a structured event
646
+ # instead of embedding them into DISPATCH.md metadata.
647
+ if emit_event is not None and isinstance(turn_diff_stats, dict):
648
+ try:
649
+ emit_event(
650
+ FlowEventType.DIFF_UPDATED,
651
+ {
652
+ "ticket_id": current_ticket_id,
653
+ "dispatch_seq": turn_summary.seq,
654
+ "insertions": int(turn_diff_stats.get("insertions") or 0),
655
+ "deletions": int(turn_diff_stats.get("deletions") or 0),
656
+ "files_changed": int(
657
+ turn_diff_stats.get("files_changed") or 0
658
+ ),
659
+ },
660
+ )
661
+ except Exception:
662
+ # Best-effort; do not block ticket execution on event emission.
663
+ pass
664
+
392
665
  # Post-turn: ticket frontmatter must remain valid.
393
666
  updated_fm, fm_errors = self._recheck_ticket_frontmatter(current_path)
394
667
  if fm_errors:
@@ -402,6 +675,7 @@ class TicketRunner:
402
675
  "Errors:\n- " + "\n- ".join(fm_errors)
403
676
  ),
404
677
  current_ticket=safe_relpath(current_path, self._workspace_root),
678
+ reason_code="needs_user_fix",
405
679
  )
406
680
 
407
681
  state["lint"] = {
@@ -439,7 +713,9 @@ class TicketRunner:
439
713
  reason = dispatch.dispatch.title or "Paused for user input."
440
714
  if checkpoint_error:
441
715
  reason += f"\n\nNote: checkpoint commit failed: {checkpoint_error}"
716
+ state["status"] = "paused"
442
717
  state["reason"] = reason
718
+ state["reason_code"] = "user_pause"
443
719
  return TicketResult(
444
720
  status="paused",
445
721
  state=state,
@@ -495,6 +771,7 @@ class TicketRunner:
495
771
  ),
496
772
  reason_details="".join(details_parts),
497
773
  current_ticket=safe_relpath(current_path, self._workspace_root),
774
+ reason_code="needs_user_fix",
498
775
  )
499
776
 
500
777
  return TicketResult(
@@ -583,12 +860,14 @@ class TicketRunner:
583
860
  state: dict[str, Any],
584
861
  *,
585
862
  reason: str,
863
+ reason_code: str = "needs_user_fix",
586
864
  reason_details: Optional[str] = None,
587
865
  current_ticket: Optional[str] = None,
588
866
  ) -> TicketResult:
589
867
  state = dict(state)
590
868
  state["status"] = "paused"
591
869
  state["reason"] = reason
870
+ state["reason_code"] = reason_code
592
871
  if reason_details:
593
872
  state["reason_details"] = reason_details
594
873
  else:
@@ -705,56 +984,41 @@ class TicketRunner:
705
984
  outbox_paths.dispatch_path, self._workspace_root
706
985
  )
707
986
 
708
- header = (
709
- "You are running inside Codex AutoRunner (CAR) in a ticket-based workflow.\n"
710
- "Complete the current ticket by making changes in the repo and updating the ticket file.\n\n"
711
- "Key rules:\n"
712
- f"- Current ticket file: {rel_ticket}\n"
713
- "- Ticket completion is controlled by YAML frontmatter: set 'done: true' when finished.\n"
714
- "- To message the user, optionally write attachments first to the dispatch directory, then write DISPATCH.md last.\n"
715
- f" - Dispatch directory: {rel_dispatch_dir}\n"
716
- f" - DISPATCH.md path: {rel_dispatch_path}\n"
717
- " DISPATCH.md frontmatter supports: mode: notify|pause (pause will wait for a user response; notify will continue without waiting for user input).\n"
718
- "- Keep tickets minimal and avoid scope creep. You may create new tickets only if blocking the current SPEC.\n"
719
- )
720
-
721
987
  checkpoint_block = ""
722
988
  if last_checkpoint_error:
723
989
  checkpoint_block = (
724
- "\n\n---\n\n"
990
+ "<CAR_CHECKPOINT_WARNING>\n"
725
991
  "WARNING: The previous checkpoint git commit failed (often due to pre-commit hooks).\n"
726
992
  "Resolve this before proceeding, or future turns may fail to checkpoint.\n\n"
727
993
  "Checkpoint error:\n"
728
994
  f"{last_checkpoint_error}\n"
995
+ "</CAR_CHECKPOINT_WARNING>"
729
996
  )
730
997
 
731
998
  commit_block = ""
732
999
  if commit_required:
733
1000
  attempts_remaining = max(commit_max_attempts - commit_attempt + 1, 0)
734
1001
  commit_block = (
735
- "\n\n---\n\n"
736
- "ACTION REQUIRED: Commit your changes, ensuring any pre-commit hooks pass.\n"
737
- "- Use a meaningful commit message that matches what you implemented.\n"
738
- "- If hooks fail, fix the underlying issues and retry the commit.\n"
739
- f"- Attempts remaining before user intervention: {attempts_remaining}\n"
1002
+ "<CAR_COMMIT_REQUIRED>\n"
1003
+ "ACTION REQUIRED: The repo is dirty but the ticket is marked done.\n"
1004
+ "Commit your changes (ensuring any pre-commit hooks pass) so the flow can advance.\n\n"
1005
+ f"Attempts remaining before user intervention: {attempts_remaining}\n"
1006
+ "</CAR_COMMIT_REQUIRED>"
740
1007
  )
741
1008
 
742
1009
  if lint_errors:
743
1010
  lint_block = (
744
- "\n\nTicket frontmatter lint failed. Fix ONLY the ticket frontmatter to satisfy:\n- "
1011
+ "<CAR_TICKET_FRONTMATTER_LINT_REPAIR>\n"
1012
+ "Ticket frontmatter lint failed. Fix ONLY the ticket YAML frontmatter to satisfy:\n- "
745
1013
  + "\n- ".join(lint_errors)
746
- + "\n"
1014
+ + "\n</CAR_TICKET_FRONTMATTER_LINT_REPAIR>"
747
1015
  )
748
1016
  else:
749
1017
  lint_block = ""
750
1018
 
751
1019
  reply_block = ""
752
1020
  if reply_context:
753
- reply_block = (
754
- "\n\n---\n\nHUMAN REPLIES (from reply_history; newest since last turn):\n"
755
- + reply_context
756
- + "\n"
757
- )
1021
+ reply_block = reply_context
758
1022
 
759
1023
  workspace_block = ""
760
1024
  workspace_docs: list[tuple[str, str, str]] = []
@@ -786,38 +1050,113 @@ class TicketRunner:
786
1050
  blocks = ["Workspace docs (truncated; skip if not relevant):"]
787
1051
  for label, rel, body in workspace_docs:
788
1052
  blocks.append(f"{label} [{rel}]:\n{body}")
789
- workspace_block = "\n\n---\n\n" + "\n\n".join(blocks) + "\n"
1053
+ workspace_block = "\n\n".join(blocks)
790
1054
 
791
1055
  prev_ticket_block = ""
792
1056
  if previous_ticket_content:
793
1057
  prev_ticket_block = (
794
- "\n\n---\n\n"
795
- "PREVIOUS TICKET CONTEXT (for reference only; do not edit):\n"
1058
+ "PREVIOUS TICKET CONTEXT (truncated to 16KB; for reference only; do not edit):\n"
1059
+ "Cross-ticket context should flow through workspace docs (active_context.md, decisions.md, spec.md) "
1060
+ "rather than implicit previous ticket content. This is included only for legacy compatibility.\n"
796
1061
  + previous_ticket_content
797
- + "\n"
798
1062
  )
799
1063
 
1064
+ ticket_raw_content = ticket_path.read_text(encoding="utf-8")
800
1065
  ticket_block = (
801
- "\n\n---\n\n"
802
- "TICKET CONTENT (edit this file to track progress; update frontmatter.done when complete):\n"
1066
+ "<CAR_CURRENT_TICKET_FILE>\n"
803
1067
  f"PATH: {rel_ticket}\n"
804
- "\n" + ticket_path.read_text(encoding="utf-8")
1068
+ "<TICKET_MARKDOWN>\n"
1069
+ f"{ticket_raw_content}\n"
1070
+ "</TICKET_MARKDOWN>\n"
1071
+ "</CAR_CURRENT_TICKET_FILE>"
805
1072
  )
806
1073
 
807
1074
  prev_block = ""
808
1075
  if last_agent_output:
809
- prev_block = (
810
- "\n\n---\n\nPREVIOUS AGENT OUTPUT (same ticket):\n" + last_agent_output
1076
+ prev_block = last_agent_output
1077
+
1078
+ sections = {
1079
+ "prev_block": prev_block,
1080
+ "prev_ticket_block": prev_ticket_block,
1081
+ "workspace_block": workspace_block,
1082
+ "reply_block": reply_block,
1083
+ "ticket_block": ticket_block,
1084
+ }
1085
+
1086
+ def render() -> str:
1087
+ return (
1088
+ "<CAR_TICKET_FLOW_PROMPT>\n\n"
1089
+ "<CAR_TICKET_FLOW_INSTRUCTIONS>\n"
1090
+ "You are running inside Codex Autorunner (CAR) in a ticket-based workflow.\n\n"
1091
+ "Your job in this turn:\n"
1092
+ "- Read the current ticket file.\n"
1093
+ "- Make the required repo changes.\n"
1094
+ "- Update the ticket file to reflect progress.\n"
1095
+ "- Set `done: true` in the ticket YAML frontmatter only when the ticket is truly complete.\n\n"
1096
+ "CAR orientation (80/20):\n"
1097
+ "- `.codex-autorunner/tickets/` is the queue that drives the flow (files named `TICKET-###*.md`, processed in numeric order).\n"
1098
+ "- `.codex-autorunner/workspace/` holds durable context shared across ticket turns (especially `active_context.md` and `spec.md`).\n"
1099
+ "- `.codex-autorunner/ABOUT_CAR.md` is the repo-local briefing (what CAR auto-generates + helper scripts) if you need operational details.\n\n"
1100
+ "Communicating with the user (optional):\n"
1101
+ "- To send a message or request input, write to the dispatch directory:\n"
1102
+ " 1) write any attachments to the dispatch directory\n"
1103
+ " 2) write `DISPATCH.md` last\n"
1104
+ "- `DISPATCH.md` YAML supports `mode: notify|pause`.\n"
1105
+ " - `pause` waits for user input; `notify` continues without waiting.\n"
1106
+ " - Example:\n"
1107
+ " ---\n"
1108
+ " mode: pause\n"
1109
+ " ---\n"
1110
+ " Need clarification on X before proceeding.\n"
1111
+ "- You do not need a “final” dispatch when you finish; the runner will archive your turn output automatically. Dispatch only if you want something to stand out or you need user input.\n\n"
1112
+ "If blocked:\n"
1113
+ "- Dispatch with `mode: pause` rather than guessing.\n\n"
1114
+ "Creating follow-up tickets (optional):\n"
1115
+ "- New tickets live under `.codex-autorunner/tickets/` and follow the `TICKET-###*.md` naming pattern.\n"
1116
+ "- If present, `.codex-autorunner/bin/ticket_tool.py` can create/insert/move tickets; `.codex-autorunner/bin/lint_tickets.py` lints ticket frontmatter (see `.codex-autorunner/ABOUT_CAR.md`).\n"
1117
+ "Using ticket templates (optional):\n"
1118
+ "- If you need a standard ticket pattern, prefer: `car templates fetch <repo_id>:<path>[@<ref>]`\n"
1119
+ " - Trusted repos skip scanning; untrusted repos are scanned (cached by blob SHA).\n\n"
1120
+ "Workspace docs:\n"
1121
+ "- You may update or add context under `.codex-autorunner/workspace/` so future ticket turns have durable context.\n"
1122
+ "- Prefer referencing these docs instead of creating duplicate “shadow” docs elsewhere.\n\n"
1123
+ "Repo hygiene:\n"
1124
+ "- Do not add new `.codex-autorunner/` artifacts to git unless they are already tracked.\n"
1125
+ "</CAR_TICKET_FLOW_INSTRUCTIONS>\n\n"
1126
+ "<CAR_RUNTIME_PATHS>\n"
1127
+ f"Current ticket file: {rel_ticket}\n"
1128
+ f"Dispatch directory: {rel_dispatch_dir}\n"
1129
+ f"DISPATCH.md path: {rel_dispatch_path}\n"
1130
+ "</CAR_RUNTIME_PATHS>\n\n"
1131
+ f"{checkpoint_block}\n\n"
1132
+ f"{commit_block}\n\n"
1133
+ f"{lint_block}\n\n"
1134
+ "<CAR_WORKSPACE_DOCS>\n"
1135
+ f"{sections['workspace_block']}\n"
1136
+ "</CAR_WORKSPACE_DOCS>\n\n"
1137
+ "<CAR_HUMAN_REPLIES>\n"
1138
+ f"{sections['reply_block']}\n"
1139
+ "</CAR_HUMAN_REPLIES>\n\n"
1140
+ "<CAR_PREVIOUS_TICKET_REFERENCE>\n"
1141
+ f"{sections['prev_ticket_block']}\n"
1142
+ "</CAR_PREVIOUS_TICKET_REFERENCE>\n\n"
1143
+ f"{sections['ticket_block']}\n\n"
1144
+ "<CAR_PREVIOUS_AGENT_OUTPUT>\n"
1145
+ f"{sections['prev_block']}\n"
1146
+ "</CAR_PREVIOUS_AGENT_OUTPUT>\n\n"
1147
+ "</CAR_TICKET_FLOW_PROMPT>"
811
1148
  )
812
1149
 
813
- return (
814
- header
815
- + checkpoint_block
816
- + commit_block
817
- + lint_block
818
- + workspace_block
819
- + reply_block
820
- + prev_ticket_block
821
- + ticket_block
822
- + prev_block
1150
+ prompt = _shrink_prompt(
1151
+ max_bytes=self._config.prompt_max_bytes,
1152
+ render=render,
1153
+ sections=sections,
1154
+ order=[
1155
+ "prev_block",
1156
+ "prev_ticket_block",
1157
+ "reply_block",
1158
+ "workspace_block",
1159
+ "ticket_block",
1160
+ ],
823
1161
  )
1162
+ return prompt