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
@@ -0,0 +1,823 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from pathlib import Path
5
+ from typing import Any, Callable, Optional
6
+
7
+ from ..core.flows.models import FlowEventType
8
+ from ..core.git_utils import run_git
9
+ from ..workspace.paths import workspace_doc_path
10
+ from .agent_pool import AgentPool, AgentTurnRequest
11
+ from .files import list_ticket_paths, read_ticket, safe_relpath, ticket_is_done
12
+ from .frontmatter import parse_markdown_frontmatter
13
+ from .lint import lint_ticket_frontmatter
14
+ from .models import TicketFrontmatter, TicketResult, TicketRunConfig
15
+ from .outbox import (
16
+ archive_dispatch,
17
+ create_turn_summary,
18
+ ensure_outbox_dirs,
19
+ resolve_outbox_paths,
20
+ )
21
+ from .replies import ensure_reply_dirs, parse_user_reply, resolve_reply_paths
22
+
23
+ _logger = logging.getLogger(__name__)
24
+
25
+ WORKSPACE_DOC_MAX_CHARS = 4000
26
+
27
+
28
+ class TicketRunner:
29
+ """Execute a ticket directory one agent turn at a time.
30
+
31
+ This runner is intentionally small and file-backed:
32
+ - Tickets are markdown files under `config.ticket_dir`.
33
+ - User messages + optional attachments are written to an outbox under `config.runs_dir`.
34
+ - The orchestrator is stateless aside from the `state` dict passed into step().
35
+ """
36
+
37
+ def __init__(
38
+ self,
39
+ *,
40
+ workspace_root: Path,
41
+ run_id: str,
42
+ config: TicketRunConfig,
43
+ agent_pool: AgentPool,
44
+ ):
45
+ self._workspace_root = workspace_root
46
+ self._run_id = run_id
47
+ self._config = config
48
+ self._agent_pool = agent_pool
49
+
50
+ async def step(
51
+ self,
52
+ state: dict[str, Any],
53
+ *,
54
+ emit_event: Optional[Callable[[FlowEventType, dict[str, Any]], None]] = None,
55
+ ) -> TicketResult:
56
+ """Execute exactly one orchestration step.
57
+
58
+ A step is either:
59
+ - run one agent turn for the current ticket, or
60
+ - pause because prerequisites are missing, or
61
+ - mark the whole run completed (no remaining tickets).
62
+ """
63
+
64
+ state = dict(state or {})
65
+ # Clear transient reason from previous pause/resume cycles.
66
+ state.pop("reason", None)
67
+
68
+ _commit_raw = state.get("commit")
69
+ commit_state: dict[str, Any] = (
70
+ _commit_raw if isinstance(_commit_raw, dict) else {}
71
+ )
72
+ commit_pending = bool(commit_state.get("pending"))
73
+ commit_retries = int(commit_state.get("retries") or 0)
74
+ # Global counters.
75
+ total_turns = int(state.get("total_turns") or 0)
76
+ if total_turns >= self._config.max_total_turns:
77
+ return self._pause(
78
+ state,
79
+ reason=f"Max turns reached ({self._config.max_total_turns}). Review tickets and resume.",
80
+ )
81
+
82
+ ticket_dir = self._workspace_root / self._config.ticket_dir
83
+ runs_dir = self._config.runs_dir
84
+
85
+ # Ensure outbox dirs exist.
86
+ outbox_paths = resolve_outbox_paths(
87
+ workspace_root=self._workspace_root,
88
+ runs_dir=runs_dir,
89
+ run_id=self._run_id,
90
+ )
91
+ ensure_outbox_dirs(outbox_paths)
92
+
93
+ # Ensure reply inbox dirs exist (human -> agent messages).
94
+ reply_paths = resolve_reply_paths(
95
+ workspace_root=self._workspace_root,
96
+ runs_dir=runs_dir,
97
+ run_id=self._run_id,
98
+ )
99
+ ensure_reply_dirs(reply_paths)
100
+
101
+ ticket_paths = list_ticket_paths(ticket_dir)
102
+ if not ticket_paths:
103
+ return self._pause(
104
+ state,
105
+ reason=(
106
+ "No tickets found. Create tickets under "
107
+ f"{safe_relpath(ticket_dir, self._workspace_root)} and resume."
108
+ ),
109
+ )
110
+
111
+ current_ticket = state.get("current_ticket")
112
+ current_path: Optional[Path] = (
113
+ (self._workspace_root / current_ticket)
114
+ if isinstance(current_ticket, str) and current_ticket
115
+ else None
116
+ )
117
+
118
+ # If current ticket is done, clear it unless we're in the middle of a
119
+ # bounded "commit required" follow-up loop.
120
+ if current_path and ticket_is_done(current_path) and not commit_pending:
121
+ current_path = None
122
+ state.pop("current_ticket", None)
123
+ state.pop("ticket_turns", None)
124
+ state.pop("last_agent_output", None)
125
+ state.pop("lint", None)
126
+ state.pop("commit", None)
127
+
128
+ if current_path is None:
129
+ next_path = self._find_next_ticket(ticket_paths)
130
+ if next_path is None:
131
+ state["status"] = "completed"
132
+ return TicketResult(
133
+ status="completed", state=state, reason="All tickets done."
134
+ )
135
+ current_path = next_path
136
+ state["current_ticket"] = safe_relpath(current_path, self._workspace_root)
137
+ # New ticket resets per-ticket state.
138
+ state["ticket_turns"] = 0
139
+ state.pop("last_agent_output", None)
140
+ state.pop("lint", None)
141
+ state.pop("commit", None)
142
+
143
+ # Determine lint-retry mode early. When lint state is present, we allow the
144
+ # agent to fix the ticket frontmatter even if the ticket is currently
145
+ # unparsable by the strict lint rules.
146
+ if state.get("status") == "paused":
147
+ # Clear stale pause markers so upgraded logic can proceed without manual DB edits.
148
+ state["status"] = "running"
149
+ state.pop("reason", None)
150
+ state.pop("reason_details", None)
151
+
152
+ _lint_raw = state.get("lint")
153
+ lint_state: dict[str, Any] = _lint_raw if isinstance(_lint_raw, dict) else {}
154
+ _lint_errors_raw = lint_state.get("errors")
155
+ lint_errors: list[str] = (
156
+ _lint_errors_raw if isinstance(_lint_errors_raw, list) else []
157
+ )
158
+ lint_retries = int(lint_state.get("retries") or 0)
159
+ _conv_id_raw = lint_state.get("conversation_id")
160
+ reuse_conversation_id: Optional[str] = (
161
+ _conv_id_raw if isinstance(_conv_id_raw, str) else None
162
+ )
163
+
164
+ # Read ticket (may lint-fail). In lint-retry mode, fall back to a relaxed
165
+ # frontmatter parse so we can still execute an agent turn to repair the file.
166
+ ticket_doc = None
167
+ ticket_errors: list[str] = []
168
+ if lint_errors:
169
+ try:
170
+ raw = current_path.read_text(encoding="utf-8")
171
+ except OSError as exc:
172
+ return self._pause(
173
+ state,
174
+ reason=(
175
+ "Ticket unreadable during lint retry for "
176
+ f"{safe_relpath(current_path, self._workspace_root)}: {exc}"
177
+ ),
178
+ current_ticket=safe_relpath(current_path, self._workspace_root),
179
+ )
180
+
181
+ data, _ = parse_markdown_frontmatter(raw)
182
+ agent = data.get("agent")
183
+ agent_id = agent.strip() if isinstance(agent, str) else None
184
+ if not agent_id:
185
+ return self._pause(
186
+ state,
187
+ reason=(
188
+ "Cannot determine ticket agent during lint retry (missing frontmatter.agent). "
189
+ "Fix the ticket frontmatter manually and resume."
190
+ ),
191
+ current_ticket=safe_relpath(current_path, self._workspace_root),
192
+ )
193
+
194
+ # Validate agent id unless it is the special user sentinel.
195
+ if agent_id != "user":
196
+ try:
197
+ from ..agents.registry import validate_agent_id
198
+
199
+ agent_id = validate_agent_id(agent_id)
200
+ except Exception as exc:
201
+ return self._pause(
202
+ state,
203
+ reason=(
204
+ "Cannot determine valid agent during lint retry for "
205
+ f"{safe_relpath(current_path, self._workspace_root)}: {exc}"
206
+ ),
207
+ current_ticket=safe_relpath(current_path, self._workspace_root),
208
+ )
209
+
210
+ ticket_doc = type(
211
+ "_TicketDocForLintRetry",
212
+ (),
213
+ {
214
+ "frontmatter": TicketFrontmatter(
215
+ agent=agent_id,
216
+ done=False,
217
+ )
218
+ },
219
+ )()
220
+ else:
221
+ ticket_doc, ticket_errors = read_ticket(current_path)
222
+ if ticket_errors or ticket_doc is None:
223
+ return self._pause(
224
+ state,
225
+ reason=f"Ticket frontmatter invalid: {safe_relpath(current_path, self._workspace_root)}",
226
+ reason_details="Errors:\n- " + "\n- ".join(ticket_errors),
227
+ current_ticket=safe_relpath(current_path, self._workspace_root),
228
+ )
229
+
230
+ # Built-in manual user ticket.
231
+ if ticket_doc.frontmatter.agent == "user":
232
+ if ticket_doc.frontmatter.done:
233
+ # Nothing to do, will advance next step.
234
+ return TicketResult(status="continue", state=state)
235
+ return self._pause(
236
+ state,
237
+ reason=(
238
+ "Paused for user input. Mark ticket as done when ready: "
239
+ f"{safe_relpath(current_path, self._workspace_root)}"
240
+ ),
241
+ current_ticket=safe_relpath(current_path, self._workspace_root),
242
+ )
243
+
244
+ ticket_turns = int(state.get("ticket_turns") or 0)
245
+ reply_seq = int(state.get("reply_seq") or 0)
246
+ reply_context, reply_max_seq = self._build_reply_context(
247
+ reply_paths=reply_paths, last_seq=reply_seq
248
+ )
249
+
250
+ 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
259
+
260
+ prompt = self._build_prompt(
261
+ ticket_path=current_path,
262
+ ticket_doc=ticket_doc,
263
+ last_agent_output=(
264
+ state.get("last_agent_output")
265
+ if isinstance(state.get("last_agent_output"), str)
266
+ else None
267
+ ),
268
+ last_checkpoint_error=(
269
+ state.get("last_checkpoint_error")
270
+ if isinstance(state.get("last_checkpoint_error"), str)
271
+ else None
272
+ ),
273
+ commit_required=commit_pending,
274
+ commit_attempt=commit_retries + 1 if commit_pending else 0,
275
+ commit_max_attempts=self._config.max_commit_retries,
276
+ outbox_paths=outbox_paths,
277
+ lint_errors=lint_errors if lint_errors else None,
278
+ reply_context=reply_context,
279
+ previous_ticket_content=previous_ticket_content,
280
+ )
281
+
282
+ # Execute turn.
283
+ # Build options dict with model/reasoning from ticket frontmatter if set.
284
+ turn_options: dict[str, Any] = {}
285
+ if ticket_doc.frontmatter.model:
286
+ turn_options["model"] = ticket_doc.frontmatter.model
287
+ if ticket_doc.frontmatter.reasoning:
288
+ turn_options["reasoning"] = ticket_doc.frontmatter.reasoning
289
+ req = AgentTurnRequest(
290
+ agent_id=ticket_doc.frontmatter.agent,
291
+ prompt=prompt,
292
+ workspace_root=self._workspace_root,
293
+ conversation_id=reuse_conversation_id,
294
+ emit_event=emit_event,
295
+ options=turn_options if turn_options else None,
296
+ )
297
+
298
+ total_turns += 1
299
+ ticket_turns += 1
300
+ state["total_turns"] = total_turns
301
+ state["ticket_turns"] = ticket_turns
302
+
303
+ head_before_turn: Optional[str] = None
304
+ try:
305
+ head_proc = run_git(
306
+ ["rev-parse", "HEAD"], cwd=self._workspace_root, check=True
307
+ )
308
+ head_before_turn = (head_proc.stdout or "").strip() or None
309
+ except Exception:
310
+ head_before_turn = None
311
+
312
+ result = await self._agent_pool.run_turn(req)
313
+ if result.error:
314
+ state["last_agent_output"] = result.text
315
+ state["last_agent_id"] = result.agent_id
316
+ state["last_agent_conversation_id"] = result.conversation_id
317
+ state["last_agent_turn_id"] = result.turn_id
318
+ return self._pause(
319
+ state,
320
+ reason="Agent turn failed. Fix the issue and resume.",
321
+ reason_details=f"Error: {result.error}",
322
+ current_ticket=safe_relpath(current_path, self._workspace_root),
323
+ )
324
+
325
+ # Mark replies as consumed only after a successful agent turn.
326
+ if reply_max_seq > reply_seq:
327
+ state["reply_seq"] = reply_max_seq
328
+ state["last_agent_output"] = result.text
329
+ state["last_agent_id"] = result.agent_id
330
+ state["last_agent_conversation_id"] = result.conversation_id
331
+ state["last_agent_turn_id"] = result.turn_id
332
+
333
+ # Best-effort: check whether the agent created a commit and whether the
334
+ # working tree is clean, before any runner-driven checkpoint commit.
335
+ head_after_agent: Optional[str] = None
336
+ clean_after_agent: Optional[bool] = None
337
+ status_after_agent: Optional[str] = None
338
+ agent_committed_this_turn: Optional[bool] = None
339
+ try:
340
+ head_proc = run_git(
341
+ ["rev-parse", "HEAD"], cwd=self._workspace_root, check=True
342
+ )
343
+ head_after_agent = (head_proc.stdout or "").strip() or None
344
+ status_proc = run_git(
345
+ ["status", "--porcelain"], cwd=self._workspace_root, check=True
346
+ )
347
+ status_after_agent = (status_proc.stdout or "").strip()
348
+ clean_after_agent = not bool(status_after_agent)
349
+ if head_before_turn and head_after_agent:
350
+ agent_committed_this_turn = head_after_agent != head_before_turn
351
+ except Exception:
352
+ head_after_agent = None
353
+ clean_after_agent = None
354
+ status_after_agent = None
355
+ agent_committed_this_turn = None
356
+
357
+ # Post-turn: archive outbox if DISPATCH.md exists.
358
+ dispatch_seq = int(state.get("dispatch_seq") or 0)
359
+ current_ticket_id = safe_relpath(current_path, self._workspace_root)
360
+ dispatch, dispatch_errors = archive_dispatch(
361
+ outbox_paths, next_seq=dispatch_seq + 1, ticket_id=current_ticket_id
362
+ )
363
+ if dispatch_errors:
364
+ # Treat as pause: user should fix DISPATCH.md frontmatter. Keep outbox
365
+ # lint separate from ticket frontmatter lint to avoid mixing behaviors.
366
+ state["outbox_lint"] = dispatch_errors
367
+ return self._pause(
368
+ state,
369
+ reason="Invalid DISPATCH.md frontmatter.",
370
+ reason_details="Errors:\n- " + "\n- ".join(dispatch_errors),
371
+ current_ticket=safe_relpath(current_path, self._workspace_root),
372
+ )
373
+
374
+ if dispatch is not None:
375
+ state["dispatch_seq"] = dispatch.seq
376
+ state.pop("outbox_lint", None)
377
+
378
+ # Create turn summary record for the agent's final output.
379
+ # This appears in dispatch history as a distinct "turn summary" entry.
380
+ turn_summary_seq = int(state.get("dispatch_seq") or 0) + 1
381
+ turn_summary, turn_summary_errors = create_turn_summary(
382
+ outbox_paths,
383
+ next_seq=turn_summary_seq,
384
+ agent_output=result.text or "",
385
+ ticket_id=current_ticket_id,
386
+ agent_id=result.agent_id,
387
+ turn_number=total_turns,
388
+ )
389
+ if turn_summary is not None:
390
+ state["dispatch_seq"] = turn_summary.seq
391
+
392
+ # Post-turn: ticket frontmatter must remain valid.
393
+ updated_fm, fm_errors = self._recheck_ticket_frontmatter(current_path)
394
+ if fm_errors:
395
+ lint_retries += 1
396
+ if lint_retries > self._config.max_lint_retries:
397
+ return self._pause(
398
+ state,
399
+ reason="Ticket frontmatter invalid. Manual fix required.",
400
+ reason_details=(
401
+ "Exceeded lint retry limit. Fix the ticket frontmatter manually and resume.\n\n"
402
+ "Errors:\n- " + "\n- ".join(fm_errors)
403
+ ),
404
+ current_ticket=safe_relpath(current_path, self._workspace_root),
405
+ )
406
+
407
+ state["lint"] = {
408
+ "errors": fm_errors,
409
+ "retries": lint_retries,
410
+ "conversation_id": result.conversation_id,
411
+ }
412
+ return TicketResult(
413
+ status="continue",
414
+ state=state,
415
+ reason="Ticket frontmatter invalid; requesting agent fix.",
416
+ current_ticket=safe_relpath(current_path, self._workspace_root),
417
+ agent_output=result.text,
418
+ agent_id=result.agent_id,
419
+ agent_conversation_id=result.conversation_id,
420
+ agent_turn_id=result.turn_id,
421
+ )
422
+
423
+ # Clear lint state if previously set.
424
+ if state.get("lint"):
425
+ state.pop("lint", None)
426
+
427
+ # Optional: auto-commit checkpoint (best-effort).
428
+ checkpoint_error = None
429
+ commit_required_now = bool(
430
+ updated_fm and updated_fm.done and clean_after_agent is False
431
+ )
432
+ if self._config.auto_commit and not commit_pending and not commit_required_now:
433
+ checkpoint_error = self._checkpoint_git(
434
+ turn=total_turns, agent=result.agent_id
435
+ )
436
+
437
+ # If we dispatched a pause message, pause regardless of ticket completion.
438
+ if dispatch is not None and dispatch.dispatch.mode == "pause":
439
+ reason = dispatch.dispatch.title or "Paused for user input."
440
+ if checkpoint_error:
441
+ reason += f"\n\nNote: checkpoint commit failed: {checkpoint_error}"
442
+ state["reason"] = reason
443
+ return TicketResult(
444
+ status="paused",
445
+ state=state,
446
+ reason=reason,
447
+ dispatch=dispatch,
448
+ current_ticket=safe_relpath(current_path, self._workspace_root),
449
+ agent_output=result.text,
450
+ agent_id=result.agent_id,
451
+ agent_conversation_id=result.conversation_id,
452
+ agent_turn_id=result.turn_id,
453
+ )
454
+
455
+ # If ticket is marked done, require a clean working tree (i.e., changes
456
+ # committed) before advancing. This is bounded by max_commit_retries.
457
+ if updated_fm and updated_fm.done:
458
+ if clean_after_agent is False:
459
+ # Enter or continue bounded commit loop.
460
+ if commit_pending:
461
+ # A "commit required" turn just ran and did not succeed.
462
+ next_failed_attempts = commit_retries + 1
463
+ else:
464
+ # Ticket just transitioned to done, but repo is still dirty.
465
+ next_failed_attempts = 0
466
+
467
+ state["commit"] = {
468
+ "pending": True,
469
+ "retries": next_failed_attempts,
470
+ "head_before": head_before_turn,
471
+ "head_after": head_after_agent,
472
+ "agent_committed_this_turn": agent_committed_this_turn,
473
+ "status_porcelain": status_after_agent,
474
+ }
475
+
476
+ if (
477
+ commit_pending
478
+ and next_failed_attempts >= self._config.max_commit_retries
479
+ ):
480
+ detail = (status_after_agent or "").strip()
481
+ detail_lines = detail.splitlines()[:20]
482
+ details_parts = [
483
+ "Please commit manually (ensuring pre-commit hooks pass) and resume."
484
+ ]
485
+ if detail_lines:
486
+ details_parts.append(
487
+ "\n\nWorking tree status (git status --porcelain):\n- "
488
+ + "\n- ".join(detail_lines)
489
+ )
490
+ return self._pause(
491
+ state,
492
+ reason=(
493
+ f"Commit failed after {self._config.max_commit_retries} attempts. "
494
+ "Manual commit required."
495
+ ),
496
+ reason_details="".join(details_parts),
497
+ current_ticket=safe_relpath(current_path, self._workspace_root),
498
+ )
499
+
500
+ return TicketResult(
501
+ status="continue",
502
+ state=state,
503
+ reason="Ticket done but commit required; requesting agent commit.",
504
+ current_ticket=safe_relpath(current_path, self._workspace_root),
505
+ agent_output=result.text,
506
+ agent_id=result.agent_id,
507
+ agent_conversation_id=result.conversation_id,
508
+ agent_turn_id=result.turn_id,
509
+ )
510
+
511
+ # Clean (or unknown) → commit satisfied (or no changes / cannot check).
512
+ state.pop("commit", None)
513
+ state.pop("current_ticket", None)
514
+ state.pop("ticket_turns", None)
515
+ state.pop("last_agent_output", None)
516
+ state.pop("lint", None)
517
+ else:
518
+ # If the ticket is no longer done, clear any pending commit gating.
519
+ state.pop("commit", None)
520
+
521
+ if checkpoint_error:
522
+ # Non-fatal, but surface in state for UI.
523
+ state["last_checkpoint_error"] = checkpoint_error
524
+ else:
525
+ state.pop("last_checkpoint_error", None)
526
+
527
+ return TicketResult(
528
+ status="continue",
529
+ state=state,
530
+ reason="Turn complete.",
531
+ dispatch=dispatch,
532
+ current_ticket=safe_relpath(current_path, self._workspace_root),
533
+ agent_output=result.text,
534
+ agent_id=result.agent_id,
535
+ agent_conversation_id=result.conversation_id,
536
+ agent_turn_id=result.turn_id,
537
+ )
538
+
539
+ def _find_next_ticket(self, ticket_paths: list[Path]) -> Optional[Path]:
540
+ for path in ticket_paths:
541
+ if ticket_is_done(path):
542
+ continue
543
+ return path
544
+ return None
545
+
546
+ def _recheck_ticket_frontmatter(self, ticket_path: Path):
547
+ try:
548
+ raw = ticket_path.read_text(encoding="utf-8")
549
+ except OSError as exc:
550
+ return None, [f"Failed to read ticket after turn: {exc}"]
551
+ from .frontmatter import parse_markdown_frontmatter
552
+
553
+ data, _ = parse_markdown_frontmatter(raw)
554
+ fm, errors = lint_ticket_frontmatter(data)
555
+ return fm, errors
556
+
557
+ def _checkpoint_git(self, *, turn: int, agent: str) -> Optional[str]:
558
+ """Create a best-effort git commit checkpoint.
559
+
560
+ Returns an error string if the checkpoint failed, else None.
561
+ """
562
+
563
+ try:
564
+ status_proc = run_git(
565
+ ["status", "--porcelain"], cwd=self._workspace_root, check=True
566
+ )
567
+ if not (status_proc.stdout or "").strip():
568
+ return None
569
+ run_git(["add", "-A"], cwd=self._workspace_root, check=True)
570
+ msg = self._config.checkpoint_message_template.format(
571
+ run_id=self._run_id,
572
+ turn=turn,
573
+ agent=agent,
574
+ )
575
+ run_git(["commit", "-m", msg], cwd=self._workspace_root, check=True)
576
+ return None
577
+ except Exception as exc:
578
+ _logger.exception("Checkpoint commit failed")
579
+ return str(exc)
580
+
581
+ def _pause(
582
+ self,
583
+ state: dict[str, Any],
584
+ *,
585
+ reason: str,
586
+ reason_details: Optional[str] = None,
587
+ current_ticket: Optional[str] = None,
588
+ ) -> TicketResult:
589
+ state = dict(state)
590
+ state["status"] = "paused"
591
+ state["reason"] = reason
592
+ if reason_details:
593
+ state["reason_details"] = reason_details
594
+ else:
595
+ state.pop("reason_details", None)
596
+ return TicketResult(
597
+ status="paused",
598
+ state=state,
599
+ reason=reason,
600
+ reason_details=reason_details,
601
+ current_ticket=current_ticket
602
+ or (
603
+ state.get("current_ticket")
604
+ if isinstance(state.get("current_ticket"), str)
605
+ else None
606
+ ),
607
+ )
608
+
609
+ def _build_reply_context(self, *, reply_paths, last_seq: int) -> tuple[str, int]:
610
+ """Render new human replies (reply_history) into a prompt block.
611
+
612
+ Returns (rendered_text, max_seq_seen).
613
+ """
614
+
615
+ history_dir = getattr(reply_paths, "reply_history_dir", None)
616
+ if history_dir is None:
617
+ return "", last_seq
618
+ if not history_dir.exists() or not history_dir.is_dir():
619
+ return "", last_seq
620
+
621
+ entries: list[tuple[int, Path]] = []
622
+ try:
623
+ for child in history_dir.iterdir():
624
+ try:
625
+ if not child.is_dir():
626
+ continue
627
+ name = child.name
628
+ if not (len(name) == 4 and name.isdigit()):
629
+ continue
630
+ seq = int(name)
631
+ if seq <= last_seq:
632
+ continue
633
+ entries.append((seq, child))
634
+ except OSError:
635
+ continue
636
+ except OSError:
637
+ return "", last_seq
638
+
639
+ if not entries:
640
+ return "", last_seq
641
+
642
+ entries.sort(key=lambda x: x[0])
643
+ max_seq = max(seq for seq, _ in entries)
644
+
645
+ blocks: list[str] = []
646
+ for seq, entry_dir in entries:
647
+ reply_path = entry_dir / "USER_REPLY.md"
648
+ reply, errors = (
649
+ parse_user_reply(reply_path)
650
+ if reply_path.exists()
651
+ else (None, ["USER_REPLY.md missing"])
652
+ )
653
+
654
+ block_lines: list[str] = [f"[USER_REPLY {seq:04d}]"]
655
+ if errors:
656
+ block_lines.append("Errors:\n- " + "\n- ".join(errors))
657
+ if reply is not None:
658
+ if reply.title:
659
+ block_lines.append(f"Title: {reply.title}")
660
+ if reply.body:
661
+ block_lines.append(reply.body)
662
+
663
+ attachments: list[str] = []
664
+ try:
665
+ for child in sorted(entry_dir.iterdir(), key=lambda p: p.name):
666
+ try:
667
+ if child.name.startswith("."):
668
+ continue
669
+ if child.name == "USER_REPLY.md":
670
+ continue
671
+ if child.is_dir():
672
+ continue
673
+ attachments.append(safe_relpath(child, self._workspace_root))
674
+ except OSError:
675
+ continue
676
+ except OSError:
677
+ attachments = []
678
+
679
+ if attachments:
680
+ block_lines.append("Attachments:\n- " + "\n- ".join(attachments))
681
+
682
+ blocks.append("\n".join(block_lines).strip())
683
+
684
+ rendered = "\n\n".join(blocks).strip()
685
+ return rendered, max_seq
686
+
687
+ def _build_prompt(
688
+ self,
689
+ *,
690
+ ticket_path: Path,
691
+ ticket_doc,
692
+ last_agent_output: Optional[str],
693
+ last_checkpoint_error: Optional[str] = None,
694
+ commit_required: bool = False,
695
+ commit_attempt: int = 0,
696
+ commit_max_attempts: int = 2,
697
+ outbox_paths,
698
+ lint_errors: Optional[list[str]],
699
+ reply_context: Optional[str] = None,
700
+ previous_ticket_content: Optional[str] = None,
701
+ ) -> str:
702
+ rel_ticket = safe_relpath(ticket_path, self._workspace_root)
703
+ rel_dispatch_dir = safe_relpath(outbox_paths.dispatch_dir, self._workspace_root)
704
+ rel_dispatch_path = safe_relpath(
705
+ outbox_paths.dispatch_path, self._workspace_root
706
+ )
707
+
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
+ checkpoint_block = ""
722
+ if last_checkpoint_error:
723
+ checkpoint_block = (
724
+ "\n\n---\n\n"
725
+ "WARNING: The previous checkpoint git commit failed (often due to pre-commit hooks).\n"
726
+ "Resolve this before proceeding, or future turns may fail to checkpoint.\n\n"
727
+ "Checkpoint error:\n"
728
+ f"{last_checkpoint_error}\n"
729
+ )
730
+
731
+ commit_block = ""
732
+ if commit_required:
733
+ attempts_remaining = max(commit_max_attempts - commit_attempt + 1, 0)
734
+ 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"
740
+ )
741
+
742
+ if lint_errors:
743
+ lint_block = (
744
+ "\n\nTicket frontmatter lint failed. Fix ONLY the ticket frontmatter to satisfy:\n- "
745
+ + "\n- ".join(lint_errors)
746
+ + "\n"
747
+ )
748
+ else:
749
+ lint_block = ""
750
+
751
+ reply_block = ""
752
+ 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
+ )
758
+
759
+ workspace_block = ""
760
+ workspace_docs: list[tuple[str, str, str]] = []
761
+ for key, label in (
762
+ ("active_context", "Active context"),
763
+ ("decisions", "Decisions"),
764
+ ("spec", "Spec"),
765
+ ):
766
+ path = workspace_doc_path(self._workspace_root, key)
767
+ try:
768
+ if not path.exists():
769
+ continue
770
+ content = path.read_text(encoding="utf-8")
771
+ except OSError as exc:
772
+ _logger.debug("workspace doc read failed for %s: %s", path, exc)
773
+ continue
774
+ snippet = (content or "").strip()
775
+ if not snippet:
776
+ continue
777
+ workspace_docs.append(
778
+ (
779
+ label,
780
+ safe_relpath(path, self._workspace_root),
781
+ snippet[:WORKSPACE_DOC_MAX_CHARS],
782
+ )
783
+ )
784
+
785
+ if workspace_docs:
786
+ blocks = ["Workspace docs (truncated; skip if not relevant):"]
787
+ for label, rel, body in workspace_docs:
788
+ blocks.append(f"{label} [{rel}]:\n{body}")
789
+ workspace_block = "\n\n---\n\n" + "\n\n".join(blocks) + "\n"
790
+
791
+ prev_ticket_block = ""
792
+ if previous_ticket_content:
793
+ prev_ticket_block = (
794
+ "\n\n---\n\n"
795
+ "PREVIOUS TICKET CONTEXT (for reference only; do not edit):\n"
796
+ + previous_ticket_content
797
+ + "\n"
798
+ )
799
+
800
+ ticket_block = (
801
+ "\n\n---\n\n"
802
+ "TICKET CONTENT (edit this file to track progress; update frontmatter.done when complete):\n"
803
+ f"PATH: {rel_ticket}\n"
804
+ "\n" + ticket_path.read_text(encoding="utf-8")
805
+ )
806
+
807
+ prev_block = ""
808
+ if last_agent_output:
809
+ prev_block = (
810
+ "\n\n---\n\nPREVIOUS AGENT OUTPUT (same ticket):\n" + last_agent_output
811
+ )
812
+
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
823
+ )