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
@@ -0,0 +1,272 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+ from typing import Any, Callable, Optional, Protocol
6
+
7
+ from ...tickets.files import list_ticket_paths, ticket_is_done
8
+ from .models import FlowEventType, FlowRunRecord
9
+ from .store import FlowStore
10
+ from .worker_process import (
11
+ check_worker_health,
12
+ clear_worker_metadata,
13
+ spawn_flow_worker,
14
+ )
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class BootstrapCheckResult:
19
+ status: str
20
+ github_available: Optional[bool] = None
21
+ repo_slug: Optional[str] = None
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class IssueSeedResult:
26
+ content: str
27
+ issue_number: int
28
+ repo_slug: str
29
+
30
+
31
+ class GitHubServiceProtocol(Protocol):
32
+ def gh_available(self) -> bool: ...
33
+
34
+ def gh_authenticated(self) -> bool: ...
35
+
36
+ def repo_info(self) -> Any: ...
37
+
38
+ def validate_issue_same_repo(self, issue_ref: str) -> int: ...
39
+
40
+ def issue_view(self, number: int) -> dict: ...
41
+
42
+
43
+ def issue_md_path(repo_root: Path) -> Path:
44
+ return repo_root.resolve() / ".codex-autorunner" / "ISSUE.md"
45
+
46
+
47
+ def issue_md_has_content(repo_root: Path) -> bool:
48
+ issue_path = issue_md_path(repo_root)
49
+ if not issue_path.exists():
50
+ return False
51
+ try:
52
+ return bool(issue_path.read_text(encoding="utf-8").strip())
53
+ except OSError:
54
+ return False
55
+
56
+
57
+ def _ticket_dir(repo_root: Path) -> Path:
58
+ return repo_root.resolve() / ".codex-autorunner" / "tickets"
59
+
60
+
61
+ def ticket_progress(repo_root: Path) -> dict[str, int]:
62
+ ticket_dir = _ticket_dir(repo_root)
63
+ ticket_paths = list_ticket_paths(ticket_dir)
64
+ total = len(ticket_paths)
65
+ done = 0
66
+ if total:
67
+ for path in ticket_paths:
68
+ if ticket_is_done(path):
69
+ done += 1
70
+ return {"done": done, "total": total}
71
+
72
+
73
+ def bootstrap_check(
74
+ repo_root: Path,
75
+ github_service_factory: Optional[Callable[[Path], GitHubServiceProtocol]] = None,
76
+ ) -> BootstrapCheckResult:
77
+ if list_ticket_paths(_ticket_dir(repo_root)):
78
+ return BootstrapCheckResult(status="ready")
79
+
80
+ if issue_md_has_content(repo_root):
81
+ return BootstrapCheckResult(status="ready")
82
+
83
+ gh_available = False
84
+ repo_slug: Optional[str] = None
85
+ if github_service_factory is not None:
86
+ try:
87
+ gh = github_service_factory(repo_root)
88
+ gh_available = gh.gh_available() and gh.gh_authenticated()
89
+ if gh_available:
90
+ repo_info = gh.repo_info()
91
+ repo_slug = getattr(repo_info, "name_with_owner", None)
92
+ except Exception:
93
+ gh_available = False
94
+ repo_slug = None
95
+
96
+ return BootstrapCheckResult(
97
+ status="needs_issue", github_available=gh_available, repo_slug=repo_slug
98
+ )
99
+
100
+
101
+ def format_issue_as_markdown(issue: dict, repo_slug: Optional[str] = None) -> str:
102
+ number = issue.get("number")
103
+ title = issue.get("title") or ""
104
+ url = issue.get("url") or ""
105
+ state = issue.get("state") or ""
106
+ author = issue.get("author") or {}
107
+ author_name = (
108
+ author.get("login") if isinstance(author, dict) else str(author or "unknown")
109
+ )
110
+ labels = issue.get("labels")
111
+ label_names: list[str] = []
112
+ if isinstance(labels, list):
113
+ for label in labels:
114
+ if isinstance(label, dict):
115
+ name = label.get("name")
116
+ else:
117
+ name = label
118
+ if name:
119
+ label_names.append(str(name))
120
+ comments = issue.get("comments")
121
+ comment_count = None
122
+ if isinstance(comments, dict):
123
+ total = comments.get("totalCount")
124
+ if isinstance(total, int):
125
+ comment_count = total
126
+
127
+ body = issue.get("body") or "(no description)"
128
+ lines = [
129
+ f"# Issue #{number}: {title}".strip(),
130
+ "",
131
+ f"**Repo:** {repo_slug or 'unknown'}",
132
+ f"**URL:** {url}",
133
+ f"**State:** {state}",
134
+ f"**Author:** {author_name}",
135
+ ]
136
+ if label_names:
137
+ lines.append(f"**Labels:** {', '.join(label_names)}")
138
+ if comment_count is not None:
139
+ lines.append(f"**Comments:** {comment_count}")
140
+ lines.extend(["", "## Description", "", str(body).strip(), ""])
141
+ return "\n".join(lines)
142
+
143
+
144
+ def seed_issue_from_github(
145
+ repo_root: Path,
146
+ issue_ref: str,
147
+ github_service_factory: Optional[Callable[[Path], GitHubServiceProtocol]] = None,
148
+ ) -> IssueSeedResult:
149
+ if github_service_factory is None:
150
+ raise RuntimeError("GitHub service unavailable.")
151
+ gh = github_service_factory(repo_root)
152
+ if not (gh.gh_available() and gh.gh_authenticated()):
153
+ raise RuntimeError("GitHub CLI is not available or not authenticated.")
154
+ number = gh.validate_issue_same_repo(issue_ref)
155
+ issue = gh.issue_view(number=number)
156
+ repo_info = gh.repo_info()
157
+ content = format_issue_as_markdown(issue, repo_info.name_with_owner)
158
+ return IssueSeedResult(
159
+ content=content, issue_number=number, repo_slug=repo_info.name_with_owner
160
+ )
161
+
162
+
163
+ def seed_issue_from_text(plan_text: str) -> str:
164
+ return f"# Issue\n\n{plan_text.strip()}\n"
165
+
166
+
167
+ def _derive_effective_current_ticket(
168
+ record: FlowRunRecord, store: Optional[FlowStore]
169
+ ) -> Optional[str]:
170
+ if store is None:
171
+ return None
172
+ try:
173
+ if (
174
+ getattr(record, "flow_type", None) != "ticket_flow"
175
+ or not record.status.is_active()
176
+ ):
177
+ return None
178
+ last_started = store.get_last_event_seq_by_types(
179
+ record.id, [FlowEventType.STEP_STARTED]
180
+ )
181
+ last_finished = store.get_last_event_seq_by_types(
182
+ record.id, [FlowEventType.STEP_COMPLETED, FlowEventType.STEP_FAILED]
183
+ )
184
+ in_progress = bool(
185
+ last_started is not None
186
+ and (last_finished is None or last_started > last_finished)
187
+ )
188
+ if not in_progress:
189
+ return None
190
+ return store.get_latest_step_progress_current_ticket(
191
+ record.id, after_seq=last_finished
192
+ )
193
+ except Exception:
194
+ return None
195
+
196
+
197
+ def build_flow_status_snapshot(
198
+ repo_root: Path, record: FlowRunRecord, store: Optional[FlowStore]
199
+ ) -> dict:
200
+ last_event_seq = None
201
+ last_event_at = None
202
+ if store:
203
+ try:
204
+ last_event_seq, last_event_at = store.get_last_event_meta(record.id)
205
+ except Exception:
206
+ last_event_seq, last_event_at = None, None
207
+ health = check_worker_health(repo_root, record.id)
208
+
209
+ state = record.state or {}
210
+ current_ticket = None
211
+ if isinstance(state, dict):
212
+ ticket_engine = state.get("ticket_engine")
213
+ if isinstance(ticket_engine, dict):
214
+ current_ticket = ticket_engine.get("current_ticket")
215
+ if not (isinstance(current_ticket, str) and current_ticket.strip()):
216
+ current_ticket = None
217
+ effective_ticket = current_ticket
218
+ if not effective_ticket:
219
+ effective_ticket = _derive_effective_current_ticket(record, store)
220
+
221
+ updated_state: Optional[dict] = None
222
+ if effective_ticket and not current_ticket and isinstance(state, dict):
223
+ ticket_engine = state.get("ticket_engine")
224
+ ticket_engine = dict(ticket_engine) if isinstance(ticket_engine, dict) else {}
225
+ ticket_engine["current_ticket"] = effective_ticket
226
+ updated_state = dict(state)
227
+ updated_state["ticket_engine"] = ticket_engine
228
+
229
+ return {
230
+ "last_event_seq": last_event_seq,
231
+ "last_event_at": last_event_at,
232
+ "worker_health": health,
233
+ "effective_current_ticket": effective_ticket,
234
+ "ticket_progress": ticket_progress(repo_root),
235
+ "state": updated_state,
236
+ }
237
+
238
+
239
+ def ensure_worker(repo_root: Path, run_id: str, is_terminal: bool = False) -> dict:
240
+ health = check_worker_health(repo_root, run_id)
241
+ # Only clear metadata for dead/mismatch/invalid workers if not terminal
242
+ if not is_terminal and health.status in {"dead", "mismatch", "invalid"}:
243
+ try:
244
+ clear_worker_metadata(health.artifact_path.parent)
245
+ except Exception:
246
+ pass
247
+ if health.is_alive:
248
+ return {"status": "reused", "health": health}
249
+
250
+ proc, stdout_handle, stderr_handle = spawn_flow_worker(repo_root, run_id)
251
+ return {
252
+ "status": "spawned",
253
+ "health": health,
254
+ "proc": proc,
255
+ "stdout": stdout_handle,
256
+ "stderr": stderr_handle,
257
+ }
258
+
259
+
260
+ __all__ = [
261
+ "BootstrapCheckResult",
262
+ "IssueSeedResult",
263
+ "bootstrap_check",
264
+ "build_flow_status_snapshot",
265
+ "ensure_worker",
266
+ "format_issue_as_markdown",
267
+ "issue_md_has_content",
268
+ "issue_md_path",
269
+ "ticket_progress",
270
+ "seed_issue_from_github",
271
+ "seed_issue_from_text",
272
+ ]
@@ -152,7 +152,8 @@ def check_worker_health(
152
152
  try:
153
153
  data = json.loads(metadata_path.read_text(encoding="utf-8"))
154
154
  pid = int(data.get("pid")) if data.get("pid") is not None else None
155
- cmd = data.get("cmd") or []
155
+ raw_cmd = data.get("cmd") or []
156
+ cmd = [str(part) for part in raw_cmd] if isinstance(raw_cmd, list) else []
156
157
  except Exception:
157
158
  return FlowWorkerHealth(
158
159
  status="invalid",
@@ -166,7 +167,7 @@ def check_worker_health(
166
167
  return FlowWorkerHealth(
167
168
  status="invalid",
168
169
  pid=pid,
169
- cmdline=cmd if isinstance(cmd, list) else [],
170
+ cmdline=cmd,
170
171
  artifact_path=metadata_path,
171
172
  message="missing or invalid PID",
172
173
  )
@@ -175,19 +176,19 @@ def check_worker_health(
175
176
  return FlowWorkerHealth(
176
177
  status="dead",
177
178
  pid=pid,
178
- cmdline=cmd if isinstance(cmd, list) else [],
179
+ cmdline=cmd,
179
180
  artifact_path=metadata_path,
180
181
  message="worker PID not running",
181
182
  )
182
183
 
183
- expected_cmd = _build_worker_cmd(entrypoint, run_id)
184
+ expected_cmd = cmd or _build_worker_cmd(entrypoint, run_id)
184
185
  actual_cmd = _read_process_cmdline(pid)
185
186
  if actual_cmd is None:
186
187
  # Can't inspect cmdline; trust the PID check.
187
188
  return FlowWorkerHealth(
188
189
  status="alive",
189
190
  pid=pid,
190
- cmdline=cmd if isinstance(cmd, list) else [],
191
+ cmdline=cmd,
191
192
  artifact_path=metadata_path,
192
193
  message="worker running (cmdline unknown)",
193
194
  )
@@ -198,7 +199,7 @@ def check_worker_health(
198
199
  pid=pid,
199
200
  cmdline=actual_cmd,
200
201
  artifact_path=metadata_path,
201
- message="worker PID command does not match expected",
202
+ message="worker PID command does not match stored metadata",
202
203
  )
203
204
 
204
205
  return FlowWorkerHealth(
@@ -210,6 +211,31 @@ def check_worker_health(
210
211
  )
211
212
 
212
213
 
214
+ def register_worker_metadata(
215
+ repo_root: Path,
216
+ run_id: str,
217
+ *,
218
+ artifacts_root: Optional[Path] = None,
219
+ pid: Optional[int] = None,
220
+ cmd: Optional[list[str]] = None,
221
+ entrypoint: str = "codex_autorunner",
222
+ ) -> Path:
223
+ normalized_run_id = _normalized_run_id(run_id)
224
+ artifacts_dir = _worker_artifacts_dir(repo_root, normalized_run_id, artifacts_root)
225
+
226
+ resolved_pid = pid or os.getpid()
227
+ resolved_cmd = cmd or _read_process_cmdline(resolved_pid)
228
+ if not resolved_cmd:
229
+ resolved_cmd = _build_worker_cmd(entrypoint, normalized_run_id)
230
+
231
+ _write_worker_metadata(
232
+ _worker_metadata_path(artifacts_dir),
233
+ resolved_pid,
234
+ resolved_cmd,
235
+ )
236
+ return artifacts_dir
237
+
238
+
213
239
  def spawn_flow_worker(
214
240
  repo_root: Path,
215
241
  run_id: str,
@@ -232,3 +232,65 @@ def git_default_branch(repo_root: Path) -> Optional[str]:
232
232
  if raw.startswith("origin/"):
233
233
  return raw.split("/", 1)[1]
234
234
  return raw
235
+
236
+
237
+ def git_diff_stats(
238
+ repo_root: Path, from_ref: Optional[str] = None, *, include_staged: bool = True
239
+ ) -> Optional[dict]:
240
+ """
241
+ Get diff statistics (insertions/deletions) for changes.
242
+
243
+ Args:
244
+ repo_root: Repository root path
245
+ from_ref: Compare against this ref (e.g., a commit SHA). If None, compares
246
+ working tree against HEAD.
247
+ include_staged: When from_ref is None, include staged changes in the diff.
248
+
249
+ Returns:
250
+ Dict with insertions, deletions, files_changed, or None on error.
251
+ Example: {"insertions": 47, "deletions": 12, "files_changed": 5}
252
+ """
253
+ try:
254
+ if from_ref:
255
+ # Compare from_ref to working tree (includes all changes: committed + staged + unstaged)
256
+ proc = run_git(["diff", "--numstat", from_ref], repo_root)
257
+ elif include_staged:
258
+ # Working tree + staged vs HEAD
259
+ proc = run_git(["diff", "--numstat", "HEAD"], repo_root)
260
+ else:
261
+ # Only unstaged changes
262
+ proc = run_git(["diff", "--numstat"], repo_root)
263
+ except GitError:
264
+ return None
265
+ if proc.returncode != 0:
266
+ return None
267
+
268
+ insertions = 0
269
+ deletions = 0
270
+ files_changed = 0
271
+
272
+ for line in (proc.stdout or "").strip().splitlines():
273
+ if not line:
274
+ continue
275
+ parts = line.split("\t")
276
+ if len(parts) < 2:
277
+ continue
278
+ # Binary files show "-" for both counts
279
+ add_str, del_str = parts[0], parts[1]
280
+ if add_str != "-":
281
+ try:
282
+ insertions += int(add_str)
283
+ except ValueError:
284
+ pass
285
+ if del_str != "-":
286
+ try:
287
+ deletions += int(del_str)
288
+ except ValueError:
289
+ pass
290
+ files_changed += 1
291
+
292
+ return {
293
+ "insertions": insertions,
294
+ "deletions": deletions,
295
+ "files_changed": files_changed,
296
+ }