codex-autorunner 1.0.0__py3-none-any.whl → 1.1.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 (170) hide show
  1. codex_autorunner/__init__.py +12 -1
  2. codex_autorunner/agents/codex/harness.py +1 -1
  3. codex_autorunner/agents/opencode/constants.py +3 -0
  4. codex_autorunner/agents/opencode/harness.py +6 -1
  5. codex_autorunner/agents/opencode/runtime.py +59 -18
  6. codex_autorunner/agents/registry.py +22 -3
  7. codex_autorunner/bootstrap.py +7 -3
  8. codex_autorunner/cli.py +5 -1174
  9. codex_autorunner/codex_cli.py +20 -84
  10. codex_autorunner/core/__init__.py +4 -0
  11. codex_autorunner/core/about_car.py +6 -1
  12. codex_autorunner/core/app_server_ids.py +59 -0
  13. codex_autorunner/core/app_server_threads.py +11 -2
  14. codex_autorunner/core/app_server_utils.py +165 -0
  15. codex_autorunner/core/archive.py +349 -0
  16. codex_autorunner/core/codex_runner.py +6 -2
  17. codex_autorunner/core/config.py +197 -3
  18. codex_autorunner/core/drafts.py +58 -4
  19. codex_autorunner/core/engine.py +1329 -680
  20. codex_autorunner/core/exceptions.py +4 -0
  21. codex_autorunner/core/flows/controller.py +25 -1
  22. codex_autorunner/core/flows/models.py +13 -0
  23. codex_autorunner/core/flows/reasons.py +52 -0
  24. codex_autorunner/core/flows/reconciler.py +131 -0
  25. codex_autorunner/core/flows/runtime.py +35 -4
  26. codex_autorunner/core/flows/store.py +83 -0
  27. codex_autorunner/core/flows/transition.py +5 -0
  28. codex_autorunner/core/flows/ux_helpers.py +257 -0
  29. codex_autorunner/core/git_utils.py +62 -0
  30. codex_autorunner/core/hub.py +121 -7
  31. codex_autorunner/core/notifications.py +14 -2
  32. codex_autorunner/core/ports/__init__.py +28 -0
  33. codex_autorunner/{integrations/agents → core/ports}/agent_backend.py +11 -3
  34. codex_autorunner/core/ports/backend_orchestrator.py +41 -0
  35. codex_autorunner/{integrations/agents → core/ports}/run_event.py +22 -2
  36. codex_autorunner/core/state_roots.py +57 -0
  37. codex_autorunner/core/supervisor_protocol.py +15 -0
  38. codex_autorunner/core/text_delta_coalescer.py +54 -0
  39. codex_autorunner/core/ticket_linter_cli.py +201 -0
  40. codex_autorunner/core/ticket_manager_cli.py +432 -0
  41. codex_autorunner/core/update.py +4 -5
  42. codex_autorunner/core/update_paths.py +28 -0
  43. codex_autorunner/core/usage.py +164 -12
  44. codex_autorunner/core/utils.py +91 -9
  45. codex_autorunner/flows/review/__init__.py +17 -0
  46. codex_autorunner/{core/review.py → flows/review/service.py} +15 -10
  47. codex_autorunner/flows/ticket_flow/definition.py +9 -2
  48. codex_autorunner/integrations/agents/__init__.py +9 -19
  49. codex_autorunner/integrations/agents/backend_orchestrator.py +284 -0
  50. codex_autorunner/integrations/agents/codex_adapter.py +90 -0
  51. codex_autorunner/integrations/agents/codex_backend.py +158 -17
  52. codex_autorunner/integrations/agents/opencode_adapter.py +108 -0
  53. codex_autorunner/integrations/agents/opencode_backend.py +305 -32
  54. codex_autorunner/integrations/agents/runner.py +91 -0
  55. codex_autorunner/integrations/agents/wiring.py +271 -0
  56. codex_autorunner/integrations/app_server/client.py +7 -60
  57. codex_autorunner/integrations/app_server/env.py +2 -107
  58. codex_autorunner/{core/app_server_events.py → integrations/app_server/event_buffer.py} +15 -8
  59. codex_autorunner/integrations/telegram/adapter.py +65 -0
  60. codex_autorunner/integrations/telegram/config.py +46 -0
  61. codex_autorunner/integrations/telegram/constants.py +1 -1
  62. codex_autorunner/integrations/telegram/handlers/callbacks.py +7 -0
  63. codex_autorunner/integrations/telegram/handlers/commands/flows.py +1203 -66
  64. codex_autorunner/integrations/telegram/handlers/commands_runtime.py +4 -3
  65. codex_autorunner/integrations/telegram/handlers/commands_spec.py +8 -2
  66. codex_autorunner/integrations/telegram/handlers/messages.py +1 -0
  67. codex_autorunner/integrations/telegram/handlers/selections.py +61 -1
  68. codex_autorunner/integrations/telegram/helpers.py +24 -1
  69. codex_autorunner/integrations/telegram/service.py +15 -10
  70. codex_autorunner/integrations/telegram/ticket_flow_bridge.py +329 -40
  71. codex_autorunner/integrations/telegram/transport.py +3 -1
  72. codex_autorunner/routes/__init__.py +37 -76
  73. codex_autorunner/routes/agents.py +2 -137
  74. codex_autorunner/routes/analytics.py +2 -238
  75. codex_autorunner/routes/app_server.py +2 -131
  76. codex_autorunner/routes/base.py +2 -596
  77. codex_autorunner/routes/file_chat.py +4 -833
  78. codex_autorunner/routes/flows.py +4 -977
  79. codex_autorunner/routes/messages.py +4 -456
  80. codex_autorunner/routes/repos.py +2 -196
  81. codex_autorunner/routes/review.py +2 -147
  82. codex_autorunner/routes/sessions.py +2 -175
  83. codex_autorunner/routes/settings.py +2 -168
  84. codex_autorunner/routes/shared.py +2 -275
  85. codex_autorunner/routes/system.py +4 -193
  86. codex_autorunner/routes/usage.py +2 -86
  87. codex_autorunner/routes/voice.py +2 -119
  88. codex_autorunner/routes/workspace.py +2 -270
  89. codex_autorunner/server.py +2 -2
  90. codex_autorunner/static/agentControls.js +40 -11
  91. codex_autorunner/static/app.js +11 -3
  92. codex_autorunner/static/archive.js +826 -0
  93. codex_autorunner/static/archiveApi.js +37 -0
  94. codex_autorunner/static/autoRefresh.js +7 -7
  95. codex_autorunner/static/dashboard.js +224 -171
  96. codex_autorunner/static/hub.js +112 -94
  97. codex_autorunner/static/index.html +80 -33
  98. codex_autorunner/static/messages.js +486 -83
  99. codex_autorunner/static/preserve.js +17 -0
  100. codex_autorunner/static/settings.js +125 -6
  101. codex_autorunner/static/smartRefresh.js +52 -0
  102. codex_autorunner/static/styles.css +1373 -101
  103. codex_autorunner/static/tabs.js +152 -11
  104. codex_autorunner/static/terminal.js +18 -0
  105. codex_autorunner/static/ticketEditor.js +99 -5
  106. codex_autorunner/static/tickets.js +760 -87
  107. codex_autorunner/static/utils.js +11 -0
  108. codex_autorunner/static/workspace.js +133 -40
  109. codex_autorunner/static/workspaceFileBrowser.js +9 -9
  110. codex_autorunner/surfaces/__init__.py +5 -0
  111. codex_autorunner/surfaces/cli/__init__.py +6 -0
  112. codex_autorunner/surfaces/cli/cli.py +1224 -0
  113. codex_autorunner/surfaces/cli/codex_cli.py +20 -0
  114. codex_autorunner/surfaces/telegram/__init__.py +3 -0
  115. codex_autorunner/surfaces/web/__init__.py +1 -0
  116. codex_autorunner/surfaces/web/app.py +2019 -0
  117. codex_autorunner/surfaces/web/hub_jobs.py +192 -0
  118. codex_autorunner/surfaces/web/middleware.py +587 -0
  119. codex_autorunner/surfaces/web/pty_session.py +370 -0
  120. codex_autorunner/surfaces/web/review.py +6 -0
  121. codex_autorunner/surfaces/web/routes/__init__.py +78 -0
  122. codex_autorunner/surfaces/web/routes/agents.py +138 -0
  123. codex_autorunner/surfaces/web/routes/analytics.py +277 -0
  124. codex_autorunner/surfaces/web/routes/app_server.py +132 -0
  125. codex_autorunner/surfaces/web/routes/archive.py +357 -0
  126. codex_autorunner/surfaces/web/routes/base.py +615 -0
  127. codex_autorunner/surfaces/web/routes/file_chat.py +836 -0
  128. codex_autorunner/surfaces/web/routes/flows.py +1164 -0
  129. codex_autorunner/surfaces/web/routes/messages.py +459 -0
  130. codex_autorunner/surfaces/web/routes/repos.py +197 -0
  131. codex_autorunner/surfaces/web/routes/review.py +148 -0
  132. codex_autorunner/surfaces/web/routes/sessions.py +176 -0
  133. codex_autorunner/surfaces/web/routes/settings.py +169 -0
  134. codex_autorunner/surfaces/web/routes/shared.py +280 -0
  135. codex_autorunner/surfaces/web/routes/system.py +196 -0
  136. codex_autorunner/surfaces/web/routes/usage.py +89 -0
  137. codex_autorunner/surfaces/web/routes/voice.py +120 -0
  138. codex_autorunner/surfaces/web/routes/workspace.py +271 -0
  139. codex_autorunner/surfaces/web/runner_manager.py +25 -0
  140. codex_autorunner/surfaces/web/schemas.py +417 -0
  141. codex_autorunner/surfaces/web/static_assets.py +490 -0
  142. codex_autorunner/surfaces/web/static_refresh.py +86 -0
  143. codex_autorunner/surfaces/web/terminal_sessions.py +78 -0
  144. codex_autorunner/tickets/__init__.py +8 -1
  145. codex_autorunner/tickets/agent_pool.py +26 -4
  146. codex_autorunner/tickets/files.py +6 -2
  147. codex_autorunner/tickets/models.py +3 -1
  148. codex_autorunner/tickets/outbox.py +12 -0
  149. codex_autorunner/tickets/runner.py +63 -5
  150. codex_autorunner/web/__init__.py +5 -1
  151. codex_autorunner/web/app.py +2 -1949
  152. codex_autorunner/web/hub_jobs.py +2 -191
  153. codex_autorunner/web/middleware.py +2 -586
  154. codex_autorunner/web/pty_session.py +2 -369
  155. codex_autorunner/web/runner_manager.py +2 -24
  156. codex_autorunner/web/schemas.py +2 -376
  157. codex_autorunner/web/static_assets.py +4 -441
  158. codex_autorunner/web/static_refresh.py +2 -85
  159. codex_autorunner/web/terminal_sessions.py +2 -77
  160. codex_autorunner/workspace/paths.py +49 -33
  161. codex_autorunner-1.1.0.dist-info/METADATA +154 -0
  162. codex_autorunner-1.1.0.dist-info/RECORD +308 -0
  163. codex_autorunner/core/static_assets.py +0 -55
  164. codex_autorunner-1.0.0.dist-info/METADATA +0 -246
  165. codex_autorunner-1.0.0.dist-info/RECORD +0 -251
  166. /codex_autorunner/{routes → surfaces/web/routes}/terminal_images.py +0 -0
  167. {codex_autorunner-1.0.0.dist-info → codex_autorunner-1.1.0.dist-info}/WHEEL +0 -0
  168. {codex_autorunner-1.0.0.dist-info → codex_autorunner-1.1.0.dist-info}/entry_points.txt +0 -0
  169. {codex_autorunner-1.0.0.dist-info → codex_autorunner-1.1.0.dist-info}/licenses/LICENSE +0 -0
  170. {codex_autorunner-1.0.0.dist-info → codex_autorunner-1.1.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,257 @@
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
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 bootstrap_check(
62
+ repo_root: Path,
63
+ github_service_factory: Optional[Callable[[Path], GitHubServiceProtocol]] = None,
64
+ ) -> BootstrapCheckResult:
65
+ if list_ticket_paths(_ticket_dir(repo_root)):
66
+ return BootstrapCheckResult(status="ready")
67
+
68
+ if issue_md_has_content(repo_root):
69
+ return BootstrapCheckResult(status="ready")
70
+
71
+ gh_available = False
72
+ repo_slug: Optional[str] = None
73
+ if github_service_factory is not None:
74
+ try:
75
+ gh = github_service_factory(repo_root)
76
+ gh_available = gh.gh_available() and gh.gh_authenticated()
77
+ if gh_available:
78
+ repo_info = gh.repo_info()
79
+ repo_slug = getattr(repo_info, "name_with_owner", None)
80
+ except Exception:
81
+ gh_available = False
82
+ repo_slug = None
83
+
84
+ return BootstrapCheckResult(
85
+ status="needs_issue", github_available=gh_available, repo_slug=repo_slug
86
+ )
87
+
88
+
89
+ def format_issue_as_markdown(issue: dict, repo_slug: Optional[str] = None) -> str:
90
+ number = issue.get("number")
91
+ title = issue.get("title") or ""
92
+ url = issue.get("url") or ""
93
+ state = issue.get("state") or ""
94
+ author = issue.get("author") or {}
95
+ author_name = (
96
+ author.get("login") if isinstance(author, dict) else str(author or "unknown")
97
+ )
98
+ labels = issue.get("labels")
99
+ label_names: list[str] = []
100
+ if isinstance(labels, list):
101
+ for label in labels:
102
+ if isinstance(label, dict):
103
+ name = label.get("name")
104
+ else:
105
+ name = label
106
+ if name:
107
+ label_names.append(str(name))
108
+ comments = issue.get("comments")
109
+ comment_count = None
110
+ if isinstance(comments, dict):
111
+ total = comments.get("totalCount")
112
+ if isinstance(total, int):
113
+ comment_count = total
114
+
115
+ body = issue.get("body") or "(no description)"
116
+ lines = [
117
+ f"# Issue #{number}: {title}".strip(),
118
+ "",
119
+ f"**Repo:** {repo_slug or 'unknown'}",
120
+ f"**URL:** {url}",
121
+ f"**State:** {state}",
122
+ f"**Author:** {author_name}",
123
+ ]
124
+ if label_names:
125
+ lines.append(f"**Labels:** {', '.join(label_names)}")
126
+ if comment_count is not None:
127
+ lines.append(f"**Comments:** {comment_count}")
128
+ lines.extend(["", "## Description", "", str(body).strip(), ""])
129
+ return "\n".join(lines)
130
+
131
+
132
+ def seed_issue_from_github(
133
+ repo_root: Path,
134
+ issue_ref: str,
135
+ github_service_factory: Optional[Callable[[Path], GitHubServiceProtocol]] = None,
136
+ ) -> IssueSeedResult:
137
+ if github_service_factory is None:
138
+ raise RuntimeError("GitHub service unavailable.")
139
+ gh = github_service_factory(repo_root)
140
+ if not (gh.gh_available() and gh.gh_authenticated()):
141
+ raise RuntimeError("GitHub CLI is not available or not authenticated.")
142
+ number = gh.validate_issue_same_repo(issue_ref)
143
+ issue = gh.issue_view(number=number)
144
+ repo_info = gh.repo_info()
145
+ content = format_issue_as_markdown(issue, repo_info.name_with_owner)
146
+ return IssueSeedResult(
147
+ content=content, issue_number=number, repo_slug=repo_info.name_with_owner
148
+ )
149
+
150
+
151
+ def seed_issue_from_text(plan_text: str) -> str:
152
+ return f"# Issue\n\n{plan_text.strip()}\n"
153
+
154
+
155
+ def _derive_effective_current_ticket(
156
+ record: FlowRunRecord, store: Optional[FlowStore]
157
+ ) -> Optional[str]:
158
+ if store is None:
159
+ return None
160
+ try:
161
+ if (
162
+ getattr(record, "flow_type", None) != "ticket_flow"
163
+ or not record.status.is_active()
164
+ ):
165
+ return None
166
+ last_started = store.get_last_event_seq_by_types(
167
+ record.id, [FlowEventType.STEP_STARTED]
168
+ )
169
+ last_finished = store.get_last_event_seq_by_types(
170
+ record.id, [FlowEventType.STEP_COMPLETED, FlowEventType.STEP_FAILED]
171
+ )
172
+ in_progress = bool(
173
+ last_started is not None
174
+ and (last_finished is None or last_started > last_finished)
175
+ )
176
+ if not in_progress:
177
+ return None
178
+ return store.get_latest_step_progress_current_ticket(
179
+ record.id, after_seq=last_finished
180
+ )
181
+ except Exception:
182
+ return None
183
+
184
+
185
+ def build_flow_status_snapshot(
186
+ repo_root: Path, record: FlowRunRecord, store: Optional[FlowStore]
187
+ ) -> dict:
188
+ last_event_seq = None
189
+ last_event_at = None
190
+ if store:
191
+ try:
192
+ last_event_seq, last_event_at = store.get_last_event_meta(record.id)
193
+ except Exception:
194
+ last_event_seq, last_event_at = None, None
195
+ health = check_worker_health(repo_root, record.id)
196
+
197
+ state = record.state or {}
198
+ current_ticket = None
199
+ if isinstance(state, dict):
200
+ ticket_engine = state.get("ticket_engine")
201
+ if isinstance(ticket_engine, dict):
202
+ current_ticket = ticket_engine.get("current_ticket")
203
+ if not (isinstance(current_ticket, str) and current_ticket.strip()):
204
+ current_ticket = None
205
+ effective_ticket = current_ticket
206
+ if not effective_ticket:
207
+ effective_ticket = _derive_effective_current_ticket(record, store)
208
+
209
+ updated_state: Optional[dict] = None
210
+ if effective_ticket and not current_ticket and isinstance(state, dict):
211
+ ticket_engine = state.get("ticket_engine")
212
+ ticket_engine = dict(ticket_engine) if isinstance(ticket_engine, dict) else {}
213
+ ticket_engine["current_ticket"] = effective_ticket
214
+ updated_state = dict(state)
215
+ updated_state["ticket_engine"] = ticket_engine
216
+
217
+ return {
218
+ "last_event_seq": last_event_seq,
219
+ "last_event_at": last_event_at,
220
+ "worker_health": health,
221
+ "effective_current_ticket": effective_ticket,
222
+ "state": updated_state,
223
+ }
224
+
225
+
226
+ def ensure_worker(repo_root: Path, run_id: str) -> dict:
227
+ health = check_worker_health(repo_root, run_id)
228
+ if health.status in {"dead", "mismatch", "invalid"}:
229
+ try:
230
+ clear_worker_metadata(health.artifact_path.parent)
231
+ except Exception:
232
+ pass
233
+ if health.is_alive:
234
+ return {"status": "reused", "health": health}
235
+
236
+ proc, stdout_handle, stderr_handle = spawn_flow_worker(repo_root, run_id)
237
+ return {
238
+ "status": "spawned",
239
+ "health": health,
240
+ "proc": proc,
241
+ "stdout": stdout_handle,
242
+ "stderr": stderr_handle,
243
+ }
244
+
245
+
246
+ __all__ = [
247
+ "BootstrapCheckResult",
248
+ "IssueSeedResult",
249
+ "bootstrap_check",
250
+ "build_flow_status_snapshot",
251
+ "ensure_worker",
252
+ "format_issue_as_markdown",
253
+ "issue_md_has_content",
254
+ "issue_md_path",
255
+ "seed_issue_from_github",
256
+ "seed_issue_from_text",
257
+ ]
@@ -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
+ }
@@ -5,7 +5,7 @@ import re
5
5
  import shutil
6
6
  import time
7
7
  from pathlib import Path
8
- from typing import Dict, List, Optional, Tuple
8
+ from typing import Callable, Dict, List, Optional, Tuple
9
9
 
10
10
  from ..bootstrap import seed_repo_files
11
11
  from ..discovery import DiscoveryRecord, discover_and_init
@@ -16,12 +16,15 @@ from ..manifest import (
16
16
  sanitize_repo_id,
17
17
  save_manifest,
18
18
  )
19
+ from .archive import archive_worktree_snapshot, build_snapshot_id
19
20
  from .config import HubConfig, RepoConfig, derive_repo_config, load_hub_config
20
- from .engine import Engine
21
+ from .engine import AppServerSupervisorFactory, BackendFactory, Engine
21
22
  from .git_utils import (
22
23
  GitError,
23
24
  git_available,
25
+ git_branch,
24
26
  git_default_branch,
27
+ git_head_sha,
25
28
  git_is_clean,
26
29
  git_upstream_status,
27
30
  run_git,
@@ -33,6 +36,9 @@ from .utils import atomic_write
33
36
 
34
37
  logger = logging.getLogger("codex_autorunner.hub")
35
38
 
39
+ BackendFactoryBuilder = Callable[[Path, RepoConfig], BackendFactory]
40
+ AppServerSupervisorFactoryBuilder = Callable[[RepoConfig], AppServerSupervisorFactory]
41
+
36
42
 
37
43
  def _git_failure_detail(proc) -> str:
38
44
  return (proc.stderr or proc.stdout or "").strip() or f"exit {proc.returncode}"
@@ -195,9 +201,30 @@ class RepoRunner:
195
201
  *,
196
202
  repo_config: RepoConfig,
197
203
  spawn_fn: Optional[SpawnRunnerFn] = None,
204
+ backend_factory_builder: Optional[BackendFactoryBuilder] = None,
205
+ app_server_supervisor_factory_builder: Optional[
206
+ AppServerSupervisorFactoryBuilder
207
+ ] = None,
208
+ agent_id_validator: Optional[Callable[[str], str]] = None,
198
209
  ):
199
210
  self.repo_id = repo_id
200
- self._engine = Engine(repo_root, config=repo_config)
211
+ backend_factory = (
212
+ backend_factory_builder(repo_root, repo_config)
213
+ if backend_factory_builder is not None
214
+ else None
215
+ )
216
+ app_server_supervisor_factory = (
217
+ app_server_supervisor_factory_builder(repo_config)
218
+ if app_server_supervisor_factory_builder is not None
219
+ else None
220
+ )
221
+ self._engine = Engine(
222
+ repo_root,
223
+ config=repo_config,
224
+ backend_factory=backend_factory,
225
+ app_server_supervisor_factory=app_server_supervisor_factory,
226
+ agent_id_validator=agent_id_validator,
227
+ )
201
228
  self._controller = ProcessRunnerController(self._engine, spawn_fn=spawn_fn)
202
229
 
203
230
  @property
@@ -219,21 +246,46 @@ class RepoRunner:
219
246
 
220
247
  class HubSupervisor:
221
248
  def __init__(
222
- self, hub_config: HubConfig, *, spawn_fn: Optional[SpawnRunnerFn] = None
249
+ self,
250
+ hub_config: HubConfig,
251
+ *,
252
+ spawn_fn: Optional[SpawnRunnerFn] = None,
253
+ backend_factory_builder: Optional[BackendFactoryBuilder] = None,
254
+ app_server_supervisor_factory_builder: Optional[
255
+ AppServerSupervisorFactoryBuilder
256
+ ] = None,
257
+ agent_id_validator: Optional[Callable[[str], str]] = None,
223
258
  ):
224
259
  self.hub_config = hub_config
225
260
  self.state_path = hub_config.root / ".codex-autorunner" / "hub_state.json"
226
261
  self._runners: Dict[str, RepoRunner] = {}
227
262
  self._spawn_fn = spawn_fn
263
+ self._backend_factory_builder = backend_factory_builder
264
+ self._app_server_supervisor_factory_builder = (
265
+ app_server_supervisor_factory_builder
266
+ )
267
+ self._agent_id_validator = agent_id_validator
228
268
  self.state = load_hub_state(self.state_path, self.hub_config.root)
229
269
  self._list_cache_at: Optional[float] = None
230
270
  self._list_cache: Optional[List[RepoSnapshot]] = None
231
271
  self._reconcile_startup()
232
272
 
233
273
  @classmethod
234
- def from_path(cls, path: Path) -> "HubSupervisor":
274
+ def from_path(
275
+ cls,
276
+ path: Path,
277
+ *,
278
+ backend_factory_builder: Optional[BackendFactoryBuilder] = None,
279
+ app_server_supervisor_factory_builder: Optional[
280
+ AppServerSupervisorFactoryBuilder
281
+ ] = None,
282
+ ) -> "HubSupervisor":
235
283
  config = load_hub_config(path)
236
- return cls(config)
284
+ return cls(
285
+ config,
286
+ backend_factory_builder=backend_factory_builder,
287
+ app_server_supervisor_factory_builder=app_server_supervisor_factory_builder,
288
+ )
237
289
 
238
290
  def scan(self) -> List[RepoSnapshot]:
239
291
  self._invalidate_list_cache()
@@ -268,8 +320,24 @@ class HubSupervisor:
268
320
  repo_config = derive_repo_config(
269
321
  self.hub_config, record.absolute_path, load_env=False
270
322
  )
323
+ backend_factory = (
324
+ self._backend_factory_builder(record.absolute_path, repo_config)
325
+ if self._backend_factory_builder is not None
326
+ else None
327
+ )
328
+ app_server_supervisor_factory = (
329
+ self._app_server_supervisor_factory_builder(repo_config)
330
+ if self._app_server_supervisor_factory_builder is not None
331
+ else None
332
+ )
271
333
  controller = ProcessRunnerController(
272
- Engine(record.absolute_path, config=repo_config)
334
+ Engine(
335
+ record.absolute_path,
336
+ config=repo_config,
337
+ backend_factory=backend_factory,
338
+ app_server_supervisor_factory=app_server_supervisor_factory,
339
+ agent_id_validator=self._agent_id_validator,
340
+ )
273
341
  )
274
342
  controller.reconcile()
275
343
  except Exception as exc:
@@ -593,6 +661,9 @@ class HubSupervisor:
593
661
  worktree_repo_id: str,
594
662
  delete_branch: bool = False,
595
663
  delete_remote: bool = False,
664
+ archive: bool = True,
665
+ force_archive: bool = False,
666
+ archive_note: Optional[str] = None,
596
667
  ) -> None:
597
668
  self._invalidate_list_cache()
598
669
  manifest = load_manifest(self.hub_config.manifest_path, self.hub_config.root)
@@ -613,6 +684,44 @@ class HubSupervisor:
613
684
  if runner:
614
685
  runner.stop()
615
686
 
687
+ if archive:
688
+ branch_name = entry.branch or git_branch(worktree_path) or "unknown"
689
+ head_sha = git_head_sha(worktree_path) or "unknown"
690
+ snapshot_id = build_snapshot_id(branch_name, head_sha)
691
+ logger.info(
692
+ "Hub archive worktree start id=%s snapshot_id=%s",
693
+ worktree_repo_id,
694
+ snapshot_id,
695
+ )
696
+ try:
697
+ result = archive_worktree_snapshot(
698
+ base_repo_root=base_path,
699
+ base_repo_id=base.id,
700
+ worktree_repo_root=worktree_path,
701
+ worktree_repo_id=worktree_repo_id,
702
+ branch=branch_name,
703
+ worktree_of=entry.worktree_of,
704
+ note=archive_note,
705
+ snapshot_id=snapshot_id,
706
+ head_sha=head_sha,
707
+ source_path=entry.path,
708
+ )
709
+ except Exception as exc:
710
+ logger.exception(
711
+ "Hub archive worktree failed id=%s snapshot_id=%s",
712
+ worktree_repo_id,
713
+ snapshot_id,
714
+ )
715
+ if not force_archive:
716
+ raise ValueError(f"Worktree archive failed: {exc}") from exc
717
+ else:
718
+ logger.info(
719
+ "Hub archive worktree complete id=%s snapshot_id=%s status=%s",
720
+ worktree_repo_id,
721
+ result.snapshot_id,
722
+ result.status,
723
+ )
724
+
616
725
  # Remove worktree from base repo.
617
726
  try:
618
727
  proc = run_git(
@@ -777,6 +886,11 @@ class HubSupervisor:
777
886
  repo_root,
778
887
  repo_config=repo_config,
779
888
  spawn_fn=self._spawn_fn,
889
+ backend_factory_builder=self._backend_factory_builder,
890
+ app_server_supervisor_factory_builder=(
891
+ self._app_server_supervisor_factory_builder
892
+ ),
893
+ agent_id_validator=self._agent_id_validator,
780
894
  )
781
895
  self._runners[repo_id] = runner
782
896
  return runner
@@ -23,6 +23,18 @@ class NotificationManager:
23
23
  self._warned_missing: set[str] = set()
24
24
  self._enabled_mode = self._parse_enabled(self._cfg.get("enabled"))
25
25
  self._events = self._normalize_events(self._cfg.get("events"))
26
+ timeout_raw = self._cfg.get("timeout_seconds", DEFAULT_TIMEOUT_SECONDS)
27
+ try:
28
+ timeout_seconds = (
29
+ float(timeout_raw)
30
+ if timeout_raw is not None
31
+ else DEFAULT_TIMEOUT_SECONDS
32
+ )
33
+ except (TypeError, ValueError):
34
+ timeout_seconds = DEFAULT_TIMEOUT_SECONDS
35
+ if timeout_seconds <= 0:
36
+ timeout_seconds = DEFAULT_TIMEOUT_SECONDS
37
+ self._timeout_seconds = timeout_seconds
26
38
  self._warn_unknown_events(self._events)
27
39
  discord_cfg = self._cfg.get("discord")
28
40
  self._discord: Dict[str, Any] = (
@@ -202,7 +214,7 @@ class NotificationManager:
202
214
  if not targets:
203
215
  return
204
216
  try:
205
- with httpx.Client(timeout=DEFAULT_TIMEOUT_SECONDS) as client:
217
+ with httpx.Client(timeout=self._timeout_seconds) as client:
206
218
  self._send_sync(client, targets, message)
207
219
  except Exception as exc:
208
220
  self._log_warning("Notification delivery failed", exc)
@@ -216,7 +228,7 @@ class NotificationManager:
216
228
  if not targets:
217
229
  return
218
230
  try:
219
- async with httpx.AsyncClient(timeout=DEFAULT_TIMEOUT_SECONDS) as client:
231
+ async with httpx.AsyncClient(timeout=self._timeout_seconds) as client:
220
232
  await self._send_async(client, targets, message)
221
233
  except Exception as exc:
222
234
  self._log_warning("Notification delivery failed", exc)
@@ -0,0 +1,28 @@
1
+ from .agent_backend import AgentBackend, AgentEvent, AgentEventType, now_iso
2
+ from .run_event import (
3
+ ApprovalRequested,
4
+ Completed,
5
+ Failed,
6
+ OutputDelta,
7
+ RunEvent,
8
+ RunNotice,
9
+ Started,
10
+ TokenUsage,
11
+ ToolCall,
12
+ )
13
+
14
+ __all__ = [
15
+ "AgentBackend",
16
+ "AgentEvent",
17
+ "AgentEventType",
18
+ "now_iso",
19
+ "RunEvent",
20
+ "Started",
21
+ "OutputDelta",
22
+ "ToolCall",
23
+ "ApprovalRequested",
24
+ "TokenUsage",
25
+ "RunNotice",
26
+ "Completed",
27
+ "Failed",
28
+ ]
@@ -117,15 +117,15 @@ class AgentBackend:
117
117
  async def start_session(self, target: dict, context: dict) -> str:
118
118
  raise NotImplementedError
119
119
 
120
- async def run_turn(
120
+ def run_turn(
121
121
  self, session_id: str, message: str
122
122
  ) -> AsyncGenerator[AgentEvent, None]:
123
123
  raise NotImplementedError
124
124
 
125
- async def stream_events(self, session_id: str) -> AsyncGenerator[AgentEvent, None]:
125
+ def stream_events(self, session_id: str) -> AsyncGenerator[AgentEvent, None]:
126
126
  raise NotImplementedError
127
127
 
128
- async def run_turn_events(
128
+ def run_turn_events(
129
129
  self, session_id: str, message: str
130
130
  ) -> AsyncGenerator[Any, None]:
131
131
  raise NotImplementedError
@@ -140,3 +140,11 @@ class AgentBackend:
140
140
  self, description: str, context: Optional[Dict[str, Any]] = None
141
141
  ) -> bool:
142
142
  raise NotImplementedError
143
+
144
+
145
+ __all__ = [
146
+ "AgentBackend",
147
+ "AgentEvent",
148
+ "AgentEventType",
149
+ "now_iso",
150
+ ]
@@ -0,0 +1,41 @@
1
+ """Protocol for backend orchestrators used by the Engine."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, AsyncGenerator, Optional, Protocol
6
+
7
+ from .run_event import RunEvent
8
+
9
+
10
+ class BackendOrchestrator(Protocol):
11
+ def run_turn(
12
+ self,
13
+ *,
14
+ agent_id: str,
15
+ state: Any,
16
+ prompt: str,
17
+ model: Optional[str],
18
+ reasoning: Optional[str],
19
+ session_key: str,
20
+ ) -> AsyncGenerator[RunEvent, None]: ...
21
+
22
+ async def interrupt(self, agent_id: str, state: Any) -> None: ...
23
+
24
+ def get_thread_id(self, session_key: str) -> Optional[str]: ...
25
+
26
+ def set_thread_id(self, session_key: str, thread_id: str) -> None: ...
27
+
28
+ def build_app_server_supervisor(
29
+ self,
30
+ *,
31
+ event_prefix: str,
32
+ notification_handler: Optional[Any] = None,
33
+ ) -> Optional[Any]: ...
34
+
35
+ def ensure_opencode_supervisor(self) -> Optional[Any]: ...
36
+
37
+ def get_last_turn_id(self) -> Optional[str]: ...
38
+
39
+ def get_last_thread_info(self) -> Optional[dict[str, Any]]: ...
40
+
41
+ def get_last_token_total(self) -> Optional[dict[str, Any]]: ...