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,52 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Optional
4
+
5
+ from .models import FlowRunStatus
6
+
7
+ MAX_REASON_SUMMARY_LEN = 120
8
+
9
+
10
+ def _truncate(text: str, max_len: int = MAX_REASON_SUMMARY_LEN) -> str:
11
+ if len(text) <= max_len:
12
+ return text
13
+ return f"{text[:max_len].rstrip()}…"
14
+
15
+
16
+ def ensure_reason_summary(
17
+ state: Any,
18
+ *,
19
+ status: FlowRunStatus,
20
+ error_message: Optional[str] = None,
21
+ default: Optional[str] = None,
22
+ ) -> dict[str, Any]:
23
+ """Ensure state includes a short reason_summary when stopping/pausing/failing."""
24
+ normalized: dict[str, Any] = dict(state) if isinstance(state, dict) else {}
25
+ existing = normalized.get("reason_summary")
26
+ if isinstance(existing, str) and existing.strip():
27
+ return normalized
28
+
29
+ reason: Optional[str] = None
30
+ engine = normalized.get("ticket_engine")
31
+ if isinstance(engine, dict):
32
+ engine_reason = engine.get("reason")
33
+ if isinstance(engine_reason, str) and engine_reason.strip():
34
+ reason = engine_reason.strip()
35
+
36
+ if not reason and isinstance(error_message, str) and error_message.strip():
37
+ reason = error_message.strip()
38
+
39
+ if not reason:
40
+ if default:
41
+ reason = default
42
+ else:
43
+ fallback = {
44
+ FlowRunStatus.PAUSED: "Paused",
45
+ FlowRunStatus.FAILED: "Failed",
46
+ FlowRunStatus.STOPPED: "Stopped",
47
+ }
48
+ reason = fallback.get(status)
49
+
50
+ if reason:
51
+ normalized["reason_summary"] = _truncate(reason)
52
+ return normalized
@@ -0,0 +1,134 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+ from typing import Optional
7
+
8
+ from ..locks import FileLockBusy, file_lock
9
+ from .models import FlowRunRecord, FlowRunStatus
10
+ from .store import UNSET, FlowStore
11
+ from .transition import resolve_flow_transition
12
+ from .worker_process import FlowWorkerHealth, check_worker_health, clear_worker_metadata
13
+
14
+ _logger = logging.getLogger(__name__)
15
+
16
+ _ACTIVE_STATUSES = (
17
+ FlowRunStatus.RUNNING,
18
+ FlowRunStatus.STOPPING,
19
+ FlowRunStatus.PAUSED,
20
+ )
21
+
22
+
23
+ @dataclass
24
+ class FlowReconcileSummary:
25
+ checked: int = 0
26
+ active: int = 0
27
+ updated: int = 0
28
+ locked: int = 0
29
+ errors: int = 0
30
+
31
+
32
+ @dataclass
33
+ class FlowReconcileResult:
34
+ records: list[FlowRunRecord]
35
+ summary: FlowReconcileSummary
36
+
37
+
38
+ def _reconcile_lock_path(repo_root: Path, run_id: str) -> Path:
39
+ return repo_root / ".codex-autorunner" / "flows" / run_id / "reconcile.lock"
40
+
41
+
42
+ def _ensure_worker_not_stale(health: FlowWorkerHealth) -> None:
43
+ if health.status in {"dead", "mismatch", "invalid"}:
44
+ try:
45
+ clear_worker_metadata(health.artifact_path.parent)
46
+ except Exception:
47
+ _logger.debug("Failed to clear worker metadata: %s", health.artifact_path)
48
+
49
+
50
+ def reconcile_flow_run(
51
+ repo_root: Path,
52
+ record: FlowRunRecord,
53
+ store: FlowStore,
54
+ *,
55
+ logger: Optional[logging.Logger] = None,
56
+ ) -> tuple[FlowRunRecord, bool, bool]:
57
+ if record.status not in _ACTIVE_STATUSES:
58
+ return record, False, False
59
+
60
+ lock_path = _reconcile_lock_path(repo_root, record.id)
61
+ lock_path.parent.mkdir(parents=True, exist_ok=True)
62
+ try:
63
+ with file_lock(lock_path, blocking=False):
64
+ health = check_worker_health(repo_root, record.id)
65
+ decision = resolve_flow_transition(record, health)
66
+
67
+ if (
68
+ decision.status == record.status
69
+ and decision.finished_at == record.finished_at
70
+ and decision.state == (record.state or {})
71
+ ):
72
+ return record, False, False
73
+
74
+ (logger or _logger).info(
75
+ "Reconciling flow %s: %s -> %s (%s)",
76
+ record.id,
77
+ record.status.value,
78
+ decision.status.value,
79
+ decision.note or "reconcile",
80
+ )
81
+
82
+ updated = store.update_flow_run_status(
83
+ run_id=record.id,
84
+ status=decision.status,
85
+ state=decision.state,
86
+ finished_at=decision.finished_at if decision.finished_at else UNSET,
87
+ )
88
+ _ensure_worker_not_stale(health)
89
+ return (updated or record), bool(updated), False
90
+ except FileLockBusy:
91
+ return record, False, True
92
+ except Exception as exc:
93
+ (logger or _logger).warning("Failed to reconcile flow %s: %s", record.id, exc)
94
+ return record, False, False
95
+
96
+
97
+ def reconcile_flow_runs(
98
+ repo_root: Path,
99
+ *,
100
+ flow_type: Optional[str] = None,
101
+ logger: Optional[logging.Logger] = None,
102
+ ) -> FlowReconcileResult:
103
+ db_path = repo_root / ".codex-autorunner" / "flows.db"
104
+ if not db_path.exists():
105
+ return FlowReconcileResult(records=[], summary=FlowReconcileSummary())
106
+ from ..config import load_repo_config
107
+
108
+ config = load_repo_config(repo_root)
109
+ store = FlowStore(db_path, durable=config.durable_writes)
110
+ summary = FlowReconcileSummary()
111
+ records: list[FlowRunRecord] = []
112
+ try:
113
+ store.initialize()
114
+ for record in store.list_flow_runs(flow_type=flow_type):
115
+ if record.status in _ACTIVE_STATUSES:
116
+ summary.active += 1
117
+ summary.checked += 1
118
+ record, updated, locked = reconcile_flow_run(
119
+ repo_root, record, store, logger=logger
120
+ )
121
+ if updated:
122
+ summary.updated += 1
123
+ if locked:
124
+ summary.locked += 1
125
+ records.append(record)
126
+ except Exception as exc:
127
+ summary.errors += 1
128
+ (logger or _logger).warning("Flow reconcile run failed: %s", exc)
129
+ finally:
130
+ try:
131
+ store.close()
132
+ except Exception:
133
+ pass
134
+ return FlowReconcileResult(records=records, summary=summary)
@@ -5,23 +5,38 @@ from typing import Any, Callable, Dict, Optional, Set, cast
5
5
 
6
6
  from .definition import FlowDefinition, StepFn, StepFn2, StepFn3
7
7
  from .models import FlowEvent, FlowEventType, FlowRunRecord, FlowRunStatus
8
+ from .reasons import ensure_reason_summary
8
9
  from .store import FlowStore, now_iso
9
10
 
10
11
  _logger = logging.getLogger(__name__)
11
12
 
12
13
 
14
+ LifecycleEventCallback = Optional[Callable[[str, str, str, Dict[str, Any]], None]]
15
+
16
+
13
17
  class FlowRuntime:
14
18
  def __init__(
15
19
  self,
16
20
  definition: FlowDefinition,
17
21
  store: FlowStore,
18
22
  emit_event: Optional[Callable[[FlowEvent], None]] = None,
23
+ emit_lifecycle_event: LifecycleEventCallback = None,
19
24
  ):
20
25
  self.definition = definition
21
26
  self.store = store
22
27
  self.emit_event = emit_event
28
+ self.emit_lifecycle_event = emit_lifecycle_event
23
29
  self._stop_check_interval = 0.5
24
30
 
31
+ def _emit_lifecycle(
32
+ self, event_type: str, repo_id: str, run_id: str, data: Dict[str, Any]
33
+ ) -> None:
34
+ if self.emit_lifecycle_event:
35
+ try:
36
+ self.emit_lifecycle_event(event_type, repo_id, run_id, data)
37
+ except Exception as exc:
38
+ _logger.exception("Error emitting lifecycle event: %s", exc)
39
+
25
40
  def _emit(
26
41
  self,
27
42
  event_type: FlowEventType,
@@ -97,14 +112,21 @@ class FlowRuntime:
97
112
  if record.stop_requested:
98
113
  self._emit(FlowEventType.FLOW_STOPPED, run_id)
99
114
  now = now_iso()
115
+ state = ensure_reason_summary(
116
+ dict(record.state or {}),
117
+ status=FlowRunStatus.STOPPED,
118
+ default="Stopped by user",
119
+ )
100
120
  updated = self.store.update_flow_run_status(
101
121
  run_id=run_id,
102
122
  status=FlowRunStatus.STOPPED,
103
123
  finished_at=now,
124
+ state=state,
104
125
  )
105
126
  if not updated:
106
127
  raise RuntimeError(f"Failed to stop flow run {run_id}")
107
128
  record = updated
129
+ self._emit_lifecycle("flow_stopped", "", run_id, {})
108
130
  break
109
131
 
110
132
  step_id = next_steps.pop()
@@ -128,17 +150,24 @@ class FlowRuntime:
128
150
  data={"error": str(e)},
129
151
  )
130
152
  now = now_iso()
153
+ state = ensure_reason_summary(
154
+ dict(record.state or {}),
155
+ status=FlowRunStatus.FAILED,
156
+ error_message=str(e),
157
+ )
131
158
  updated = self.store.update_flow_run_status(
132
159
  run_id=run_id,
133
160
  status=FlowRunStatus.FAILED,
134
161
  finished_at=now,
135
162
  error_message=str(e),
163
+ state=state,
136
164
  )
137
165
  if not updated:
138
166
  raise RuntimeError(
139
167
  f"Failed to update flow run {run_id} to failed state"
140
168
  ) from e
141
169
  record = updated
170
+ self._emit_lifecycle("flow_failed", "", run_id, {"error": str(e)})
142
171
  return record
143
172
 
144
173
  async def _execute_step(
@@ -257,6 +286,7 @@ class FlowRuntime:
257
286
  f"Failed to update flow run after step {step_id}"
258
287
  )
259
288
  record = updated
289
+ self._emit_lifecycle("flow_completed", "", record.id, {})
260
290
 
261
291
  elif outcome.status == FlowRunStatus.FAILED:
262
292
  self._emit(
@@ -267,12 +297,17 @@ class FlowRuntime:
267
297
  )
268
298
 
269
299
  now = now_iso()
300
+ state = ensure_reason_summary(
301
+ dict(record.state or {}),
302
+ status=FlowRunStatus.FAILED,
303
+ error_message=outcome.error,
304
+ )
270
305
  updated = self.store.update_flow_run_status(
271
306
  run_id=record.id,
272
307
  status=FlowRunStatus.FAILED,
273
308
  finished_at=now,
274
309
  error_message=outcome.error,
275
- state=record.state,
310
+ state=state,
276
311
  current_step=None,
277
312
  )
278
313
  if not updated:
@@ -280,6 +315,9 @@ class FlowRuntime:
280
315
  f"Failed to update flow run after step {step_id}"
281
316
  )
282
317
  record = updated
318
+ self._emit_lifecycle(
319
+ "flow_failed", "", record.id, {"error": outcome.error or ""}
320
+ )
283
321
 
284
322
  elif outcome.status == FlowRunStatus.STOPPED:
285
323
  self._emit(
@@ -290,11 +328,15 @@ class FlowRuntime:
290
328
  )
291
329
 
292
330
  now = now_iso()
331
+ state = ensure_reason_summary(
332
+ dict(record.state or {}),
333
+ status=FlowRunStatus.STOPPED,
334
+ )
293
335
  updated = self.store.update_flow_run_status(
294
336
  run_id=record.id,
295
337
  status=FlowRunStatus.STOPPED,
296
338
  finished_at=now,
297
- state=record.state,
339
+ state=state,
298
340
  current_step=None,
299
341
  )
300
342
  if not updated:
@@ -302,6 +344,7 @@ class FlowRuntime:
302
344
  f"Failed to update flow run after step {step_id}"
303
345
  )
304
346
  record = updated
347
+ self._emit_lifecycle("flow_stopped", "", record.id, {})
305
348
 
306
349
  elif outcome.status == FlowRunStatus.PAUSED:
307
350
  self._emit(
@@ -311,10 +354,14 @@ class FlowRuntime:
311
354
  step_id=step_id,
312
355
  )
313
356
 
357
+ state = ensure_reason_summary(
358
+ dict(record.state or {}),
359
+ status=FlowRunStatus.PAUSED,
360
+ )
314
361
  updated = self.store.update_flow_run_status(
315
362
  run_id=record.id,
316
363
  status=FlowRunStatus.PAUSED,
317
- state=record.state,
364
+ state=state,
318
365
  current_step=step_id,
319
366
  )
320
367
  if not updated:
@@ -322,6 +369,7 @@ class FlowRuntime:
322
369
  f"Failed to update flow run after step {step_id}"
323
370
  )
324
371
  record = updated
372
+ self._emit_lifecycle("flow_paused", "", record.id, {})
325
373
 
326
374
  return record
327
375
 
@@ -335,12 +383,17 @@ class FlowRuntime:
335
383
  )
336
384
 
337
385
  now = now_iso()
386
+ state = ensure_reason_summary(
387
+ dict(record.state or {}),
388
+ status=FlowRunStatus.FAILED,
389
+ error_message=str(e),
390
+ )
338
391
  updated = self.store.update_flow_run_status(
339
392
  run_id=record.id,
340
393
  status=FlowRunStatus.FAILED,
341
394
  finished_at=now,
342
395
  error_message=str(e),
343
- state=record.state,
396
+ state=state,
344
397
  current_step=None,
345
398
  )
346
399
  if not updated:
@@ -1,12 +1,15 @@
1
+ from __future__ import annotations
2
+
1
3
  import json
2
4
  import logging
3
5
  import sqlite3
4
6
  import threading
5
7
  from contextlib import contextmanager
6
- from datetime import datetime, timezone
7
8
  from pathlib import Path
8
9
  from typing import Any, Dict, Generator, List, Optional, cast
9
10
 
11
+ from ..sqlite_utils import SQLITE_PRAGMAS, SQLITE_PRAGMAS_DURABLE
12
+ from ..time_utils import now_iso
10
13
  from .models import (
11
14
  FlowArtifact,
12
15
  FlowEvent,
@@ -21,18 +24,22 @@ SCHEMA_VERSION = 2
21
24
  UNSET = object()
22
25
 
23
26
 
24
- def now_iso() -> str:
25
- return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
26
-
27
-
28
27
  class FlowStore:
29
- def __init__(self, db_path: Path):
28
+ def __init__(self, db_path: Path, durable: bool = False):
30
29
  self.db_path = db_path
30
+ self._durable = durable
31
31
  self._local: threading.local = threading.local()
32
32
 
33
+ def __enter__(self) -> FlowStore:
34
+ self.initialize()
35
+ return self
36
+
37
+ def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
38
+ self.close()
39
+
33
40
  def _get_conn(self) -> sqlite3.Connection:
34
41
  if not hasattr(self._local, "conn"):
35
- # Ensure parent directory exists so sqlite can create/open the file.
42
+ # Ensure parent directory exists so sqlite can create/open file.
36
43
  try:
37
44
  self.db_path.parent.mkdir(parents=True, exist_ok=True)
38
45
  except Exception:
@@ -42,6 +49,9 @@ class FlowStore:
42
49
  self.db_path, check_same_thread=False, isolation_level=None
43
50
  )
44
51
  self._local.conn.row_factory = sqlite3.Row
52
+ pragmas = SQLITE_PRAGMAS_DURABLE if self._durable else SQLITE_PRAGMAS
53
+ for pragma in pragmas:
54
+ self._local.conn.execute(pragma)
45
55
  return cast(sqlite3.Connection, self._local.conn)
46
56
 
47
57
  @contextmanager
@@ -384,6 +394,131 @@ class FlowStore:
384
394
  rows = conn.execute(query, params).fetchall()
385
395
  return [self._row_to_flow_event(row) for row in rows]
386
396
 
397
+ def get_events_by_types(
398
+ self,
399
+ run_id: str,
400
+ event_types: list[FlowEventType],
401
+ *,
402
+ after_seq: Optional[int] = None,
403
+ limit: Optional[int] = None,
404
+ ) -> List[FlowEvent]:
405
+ """Return events for a run filtered to specific event types."""
406
+ if not event_types:
407
+ return []
408
+ conn = self._get_conn()
409
+ placeholders = ", ".join("?" for _ in event_types)
410
+ query = f"""
411
+ SELECT *
412
+ FROM flow_events
413
+ WHERE run_id = ? AND event_type IN ({placeholders})
414
+ """
415
+ params: List[Any] = [run_id, *[t.value for t in event_types]]
416
+
417
+ if after_seq is not None:
418
+ query += " AND seq > ?"
419
+ params.append(after_seq)
420
+
421
+ query += " ORDER BY seq ASC"
422
+
423
+ if limit is not None:
424
+ query += " LIMIT ?"
425
+ params.append(limit)
426
+
427
+ rows = conn.execute(query, params).fetchall()
428
+ return [self._row_to_flow_event(row) for row in rows]
429
+
430
+ def get_events_by_type(
431
+ self,
432
+ run_id: str,
433
+ event_type: FlowEventType,
434
+ *,
435
+ after_seq: Optional[int] = None,
436
+ limit: Optional[int] = None,
437
+ ) -> List[FlowEvent]:
438
+ return self.get_events_by_types(
439
+ run_id, [event_type], after_seq=after_seq, limit=limit
440
+ )
441
+
442
+ def get_last_event_meta(self, run_id: str) -> tuple[Optional[int], Optional[str]]:
443
+ conn = self._get_conn()
444
+ row = conn.execute(
445
+ "SELECT seq, timestamp FROM flow_events WHERE run_id = ? ORDER BY seq DESC LIMIT 1",
446
+ (run_id,),
447
+ ).fetchone()
448
+ if row is None:
449
+ return None, None
450
+ return row["seq"], row["timestamp"]
451
+
452
+ def get_last_event_seq_by_types(
453
+ self, run_id: str, event_types: list[FlowEventType]
454
+ ) -> Optional[int]:
455
+ if not event_types:
456
+ return None
457
+ conn = self._get_conn()
458
+ placeholders = ", ".join("?" for _ in event_types)
459
+ params = [run_id, *[t.value for t in event_types]]
460
+ row = conn.execute(
461
+ f"""
462
+ SELECT seq
463
+ FROM flow_events
464
+ WHERE run_id = ? AND event_type IN ({placeholders})
465
+ ORDER BY seq DESC
466
+ LIMIT 1
467
+ """,
468
+ params,
469
+ ).fetchone()
470
+ if row is None:
471
+ return None
472
+ return cast(int, row["seq"])
473
+
474
+ def get_last_event_by_type(
475
+ self, run_id: str, event_type: FlowEventType
476
+ ) -> Optional[FlowEvent]:
477
+ conn = self._get_conn()
478
+ row = conn.execute(
479
+ """
480
+ SELECT *
481
+ FROM flow_events
482
+ WHERE run_id = ? AND event_type = ?
483
+ ORDER BY seq DESC
484
+ LIMIT 1
485
+ """,
486
+ (run_id, event_type.value),
487
+ ).fetchone()
488
+ if row is None:
489
+ return None
490
+ return self._row_to_flow_event(row)
491
+
492
+ def get_latest_step_progress_current_ticket(
493
+ self, run_id: str, *, after_seq: Optional[int] = None, limit: int = 50
494
+ ) -> Optional[str]:
495
+ """Return the most recent step_progress.data.current_ticket for a run.
496
+
497
+ This is intentionally lightweight to support UI polling endpoints.
498
+ """
499
+ conn = self._get_conn()
500
+ query = """
501
+ SELECT seq, data
502
+ FROM flow_events
503
+ WHERE run_id = ? AND event_type = ?
504
+ """
505
+ params: List[Any] = [run_id, FlowEventType.STEP_PROGRESS.value]
506
+ if after_seq is not None:
507
+ query += " AND seq > ?"
508
+ params.append(after_seq)
509
+ query += " ORDER BY seq DESC LIMIT ?"
510
+ params.append(limit)
511
+ rows = conn.execute(query, params).fetchall()
512
+ for row in rows:
513
+ try:
514
+ data = json.loads(row["data"] or "{}")
515
+ except Exception:
516
+ data = {}
517
+ current_ticket = data.get("current_ticket")
518
+ if isinstance(current_ticket, str) and current_ticket.strip():
519
+ return current_ticket.strip()
520
+ return None
521
+
387
522
  def create_artifact(
388
523
  self,
389
524
  artifact_id: str,
@@ -4,6 +4,7 @@ from dataclasses import dataclass
4
4
  from typing import Any, Optional
5
5
 
6
6
  from codex_autorunner.core.flows.models import FlowRunRecord, FlowRunStatus
7
+ from codex_autorunner.core.flows.reasons import ensure_reason_summary
7
8
  from codex_autorunner.core.flows.store import now_iso
8
9
 
9
10
 
@@ -44,23 +45,10 @@ def resolve_flow_transition(
44
45
  inner_status = engine.get("status")
45
46
  reason_code = engine.get("reason_code")
46
47
 
47
- # 1) Worker liveness overrides for active flows.
48
- if (
49
- record.status in (FlowRunStatus.RUNNING, FlowRunStatus.STOPPING)
50
- and not health.is_alive
51
- ):
52
- new_status = (
53
- FlowRunStatus.STOPPED
54
- if record.status == FlowRunStatus.STOPPING
55
- else FlowRunStatus.FAILED
56
- )
57
- return TransitionDecision(
58
- status=new_status, finished_at=now, state=state, note="worker-dead"
59
- )
60
-
61
- # 2) Inner engine reconciliation (worker is alive or not required).
48
+ # 1) Inner engine completion takes priority over worker liveness for active flows.
62
49
  if record.status == FlowRunStatus.RUNNING:
63
50
  if inner_status == "paused":
51
+ state = ensure_reason_summary(state, status=FlowRunStatus.PAUSED)
64
52
  return TransitionDecision(
65
53
  status=FlowRunStatus.PAUSED,
66
54
  finished_at=None,
@@ -76,10 +64,32 @@ def resolve_flow_transition(
76
64
  note="engine-completed",
77
65
  )
78
66
 
67
+ # 2) Worker liveness overrides for active flows (only if engine not completed).
68
+ if not health.is_alive:
69
+ new_status = FlowRunStatus.FAILED
70
+ state = ensure_reason_summary(
71
+ state, status=new_status, default="Worker died"
72
+ )
73
+ return TransitionDecision(
74
+ status=new_status, finished_at=now, state=state, note="worker-dead"
75
+ )
76
+
79
77
  return TransitionDecision(
80
78
  status=FlowRunStatus.RUNNING, finished_at=None, state=state, note="running"
81
79
  )
82
80
 
81
+ # Handle STOPPING case separately - worker liveness check still applies.
82
+ if record.status == FlowRunStatus.STOPPING and not health.is_alive:
83
+ state = ensure_reason_summary(
84
+ state, status=FlowRunStatus.STOPPED, default="Worker stopped"
85
+ )
86
+ return TransitionDecision(
87
+ status=FlowRunStatus.STOPPED,
88
+ finished_at=now,
89
+ state=state,
90
+ note="worker-dead",
91
+ )
92
+
83
93
  if record.status == FlowRunStatus.PAUSED:
84
94
  if inner_status == "completed":
85
95
  return TransitionDecision(
@@ -98,6 +108,7 @@ def resolve_flow_transition(
98
108
  engine.pop("reason", None)
99
109
  engine.pop("reason_details", None)
100
110
  engine.pop("reason_code", None)
111
+ state.pop("reason_summary", None)
101
112
  engine["status"] = "running"
102
113
  state["ticket_engine"] = engine
103
114
  return TransitionDecision(
@@ -115,6 +126,7 @@ def resolve_flow_transition(
115
126
  note="paused-worker-dead",
116
127
  )
117
128
 
129
+ state = ensure_reason_summary(state, status=FlowRunStatus.PAUSED)
118
130
  return TransitionDecision(
119
131
  status=FlowRunStatus.PAUSED, finished_at=None, state=state, note="paused"
120
132
  )