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
@@ -58,3 +58,7 @@ class CircuitOpenError(CriticalError):
58
58
  msg,
59
59
  user_message=f"{service_name} is temporarily unavailable. Please try again later.",
60
60
  )
61
+
62
+
63
+ class AppServerError(CodexError):
64
+ pass
@@ -103,7 +103,31 @@ class FlowController:
103
103
  cleared = self.store.set_stop_requested(run_id, False)
104
104
  if not cleared:
105
105
  raise RuntimeError(f"Failed to clear stop flag for run {run_id}")
106
- return cleared
106
+ if record.status == FlowRunStatus.COMPLETED:
107
+ return cleared
108
+ state = dict(record.state or {})
109
+ engine = state.get("ticket_engine")
110
+ if isinstance(engine, dict):
111
+ engine = dict(engine)
112
+ engine["status"] = "running"
113
+ engine.pop("reason", None)
114
+ engine.pop("reason_details", None)
115
+ engine.pop("reason_code", None)
116
+ state["ticket_engine"] = engine
117
+ state.pop("reason_summary", None)
118
+
119
+ updated = self.store.update_flow_run_status(
120
+ run_id=run_id,
121
+ status=FlowRunStatus.RUNNING,
122
+ state=state,
123
+ )
124
+ if updated:
125
+ return updated
126
+
127
+ updated = self.store.get_flow_run(run_id)
128
+ if not updated:
129
+ raise RuntimeError(f"Failed to get record for run {run_id}")
130
+ return updated
107
131
 
108
132
  def get_status(self, run_id: str) -> Optional[FlowRunRecord]:
109
133
  return self.store.get_flow_run(run_id)
@@ -32,13 +32,26 @@ class FlowEventType(str, Enum):
32
32
  STEP_COMPLETED = "step_completed"
33
33
  STEP_FAILED = "step_failed"
34
34
  AGENT_STREAM_DELTA = "agent_stream_delta"
35
+ AGENT_MESSAGE_COMPLETE = "agent_message_complete"
36
+ AGENT_FAILED = "agent_failed"
35
37
  APP_SERVER_EVENT = "app_server_event"
38
+ TOOL_CALL = "tool_call"
39
+ TOOL_RESULT = "tool_result"
40
+ APPROVAL_REQUESTED = "approval_requested"
36
41
  TOKEN_USAGE = "token_usage"
37
42
  FLOW_STARTED = "flow_started"
38
43
  FLOW_STOPPED = "flow_stopped"
39
44
  FLOW_RESUMED = "flow_resumed"
40
45
  FLOW_COMPLETED = "flow_completed"
41
46
  FLOW_FAILED = "flow_failed"
47
+ RUN_STARTED = "run_started"
48
+ RUN_FINISHED = "run_finished"
49
+ RUN_STATE_CHANGED = "run_state_changed"
50
+ RUN_NO_PROGRESS = "run_no_progress"
51
+ PLAN_UPDATED = "plan_updated"
52
+ DIFF_UPDATED = "diff_updated"
53
+ RUN_TIMEOUT = "run_timeout"
54
+ RUN_CANCELLED = "run_cancelled"
42
55
 
43
56
 
44
57
  class FlowRunRecord(BaseModel):
@@ -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,131 @@
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
+ store = FlowStore(db_path)
107
+ summary = FlowReconcileSummary()
108
+ records: list[FlowRunRecord] = []
109
+ try:
110
+ store.initialize()
111
+ for record in store.list_flow_runs(flow_type=flow_type):
112
+ if record.status in _ACTIVE_STATUSES:
113
+ summary.active += 1
114
+ summary.checked += 1
115
+ record, updated, locked = reconcile_flow_run(
116
+ repo_root, record, store, logger=logger
117
+ )
118
+ if updated:
119
+ summary.updated += 1
120
+ if locked:
121
+ summary.locked += 1
122
+ records.append(record)
123
+ except Exception as exc:
124
+ summary.errors += 1
125
+ (logger or _logger).warning("Flow reconcile run failed: %s", exc)
126
+ finally:
127
+ try:
128
+ store.close()
129
+ except Exception:
130
+ pass
131
+ return FlowReconcileResult(records=records, summary=summary)
@@ -5,6 +5,7 @@ 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__)
@@ -97,10 +98,16 @@ class FlowRuntime:
97
98
  if record.stop_requested:
98
99
  self._emit(FlowEventType.FLOW_STOPPED, run_id)
99
100
  now = now_iso()
101
+ state = ensure_reason_summary(
102
+ dict(record.state or {}),
103
+ status=FlowRunStatus.STOPPED,
104
+ default="Stopped by user",
105
+ )
100
106
  updated = self.store.update_flow_run_status(
101
107
  run_id=run_id,
102
108
  status=FlowRunStatus.STOPPED,
103
109
  finished_at=now,
110
+ state=state,
104
111
  )
105
112
  if not updated:
106
113
  raise RuntimeError(f"Failed to stop flow run {run_id}")
@@ -128,11 +135,17 @@ class FlowRuntime:
128
135
  data={"error": str(e)},
129
136
  )
130
137
  now = now_iso()
138
+ state = ensure_reason_summary(
139
+ dict(record.state or {}),
140
+ status=FlowRunStatus.FAILED,
141
+ error_message=str(e),
142
+ )
131
143
  updated = self.store.update_flow_run_status(
132
144
  run_id=run_id,
133
145
  status=FlowRunStatus.FAILED,
134
146
  finished_at=now,
135
147
  error_message=str(e),
148
+ state=state,
136
149
  )
137
150
  if not updated:
138
151
  raise RuntimeError(
@@ -267,12 +280,17 @@ class FlowRuntime:
267
280
  )
268
281
 
269
282
  now = now_iso()
283
+ state = ensure_reason_summary(
284
+ dict(record.state or {}),
285
+ status=FlowRunStatus.FAILED,
286
+ error_message=outcome.error,
287
+ )
270
288
  updated = self.store.update_flow_run_status(
271
289
  run_id=record.id,
272
290
  status=FlowRunStatus.FAILED,
273
291
  finished_at=now,
274
292
  error_message=outcome.error,
275
- state=record.state,
293
+ state=state,
276
294
  current_step=None,
277
295
  )
278
296
  if not updated:
@@ -290,11 +308,15 @@ class FlowRuntime:
290
308
  )
291
309
 
292
310
  now = now_iso()
311
+ state = ensure_reason_summary(
312
+ dict(record.state or {}),
313
+ status=FlowRunStatus.STOPPED,
314
+ )
293
315
  updated = self.store.update_flow_run_status(
294
316
  run_id=record.id,
295
317
  status=FlowRunStatus.STOPPED,
296
318
  finished_at=now,
297
- state=record.state,
319
+ state=state,
298
320
  current_step=None,
299
321
  )
300
322
  if not updated:
@@ -311,10 +333,14 @@ class FlowRuntime:
311
333
  step_id=step_id,
312
334
  )
313
335
 
336
+ state = ensure_reason_summary(
337
+ dict(record.state or {}),
338
+ status=FlowRunStatus.PAUSED,
339
+ )
314
340
  updated = self.store.update_flow_run_status(
315
341
  run_id=record.id,
316
342
  status=FlowRunStatus.PAUSED,
317
- state=record.state,
343
+ state=state,
318
344
  current_step=step_id,
319
345
  )
320
346
  if not updated:
@@ -335,12 +361,17 @@ class FlowRuntime:
335
361
  )
336
362
 
337
363
  now = now_iso()
364
+ state = ensure_reason_summary(
365
+ dict(record.state or {}),
366
+ status=FlowRunStatus.FAILED,
367
+ error_message=str(e),
368
+ )
338
369
  updated = self.store.update_flow_run_status(
339
370
  run_id=record.id,
340
371
  status=FlowRunStatus.FAILED,
341
372
  finished_at=now,
342
373
  error_message=str(e),
343
- state=record.state,
374
+ state=state,
344
375
  current_step=None,
345
376
  )
346
377
  if not updated:
@@ -7,6 +7,7 @@ from datetime import datetime, timezone
7
7
  from pathlib import Path
8
8
  from typing import Any, Dict, Generator, List, Optional, cast
9
9
 
10
+ from ..sqlite_utils import SQLITE_PRAGMAS
10
11
  from .models import (
11
12
  FlowArtifact,
12
13
  FlowEvent,
@@ -42,6 +43,8 @@ class FlowStore:
42
43
  self.db_path, check_same_thread=False, isolation_level=None
43
44
  )
44
45
  self._local.conn.row_factory = sqlite3.Row
46
+ for pragma in SQLITE_PRAGMAS:
47
+ self._local.conn.execute(pragma)
45
48
  return cast(sqlite3.Connection, self._local.conn)
46
49
 
47
50
  @contextmanager
@@ -384,6 +387,86 @@ class FlowStore:
384
387
  rows = conn.execute(query, params).fetchall()
385
388
  return [self._row_to_flow_event(row) for row in rows]
386
389
 
390
+ def get_last_event_meta(self, run_id: str) -> tuple[Optional[int], Optional[str]]:
391
+ conn = self._get_conn()
392
+ row = conn.execute(
393
+ "SELECT seq, timestamp FROM flow_events WHERE run_id = ? ORDER BY seq DESC LIMIT 1",
394
+ (run_id,),
395
+ ).fetchone()
396
+ if row is None:
397
+ return None, None
398
+ return row["seq"], row["timestamp"]
399
+
400
+ def get_last_event_seq_by_types(
401
+ self, run_id: str, event_types: list[FlowEventType]
402
+ ) -> Optional[int]:
403
+ if not event_types:
404
+ return None
405
+ conn = self._get_conn()
406
+ placeholders = ", ".join("?" for _ in event_types)
407
+ params = [run_id, *[t.value for t in event_types]]
408
+ row = conn.execute(
409
+ f"""
410
+ SELECT seq
411
+ FROM flow_events
412
+ WHERE run_id = ? AND event_type IN ({placeholders})
413
+ ORDER BY seq DESC
414
+ LIMIT 1
415
+ """,
416
+ params,
417
+ ).fetchone()
418
+ if row is None:
419
+ return None
420
+ return cast(int, row["seq"])
421
+
422
+ def get_last_event_by_type(
423
+ self, run_id: str, event_type: FlowEventType
424
+ ) -> Optional[FlowEvent]:
425
+ conn = self._get_conn()
426
+ row = conn.execute(
427
+ """
428
+ SELECT *
429
+ FROM flow_events
430
+ WHERE run_id = ? AND event_type = ?
431
+ ORDER BY seq DESC
432
+ LIMIT 1
433
+ """,
434
+ (run_id, event_type.value),
435
+ ).fetchone()
436
+ if row is None:
437
+ return None
438
+ return self._row_to_flow_event(row)
439
+
440
+ def get_latest_step_progress_current_ticket(
441
+ self, run_id: str, *, after_seq: Optional[int] = None, limit: int = 50
442
+ ) -> Optional[str]:
443
+ """Return the most recent step_progress.data.current_ticket for a run.
444
+
445
+ This is intentionally lightweight to support UI polling endpoints.
446
+ """
447
+ conn = self._get_conn()
448
+ query = """
449
+ SELECT seq, data
450
+ FROM flow_events
451
+ WHERE run_id = ? AND event_type = ?
452
+ """
453
+ params: List[Any] = [run_id, FlowEventType.STEP_PROGRESS.value]
454
+ if after_seq is not None:
455
+ query += " AND seq > ?"
456
+ params.append(after_seq)
457
+ query += " ORDER BY seq DESC LIMIT ?"
458
+ params.append(limit)
459
+ rows = conn.execute(query, params).fetchall()
460
+ for row in rows:
461
+ try:
462
+ data = json.loads(row["data"] or "{}")
463
+ except Exception:
464
+ data = {}
465
+ current_ticket = data.get("current_ticket")
466
+ if isinstance(current_ticket, str) and current_ticket.strip():
467
+ return current_ticket.strip()
468
+ return None
469
+
387
470
  def create_artifact(
388
471
  self,
389
472
  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
 
@@ -54,6 +55,7 @@ def resolve_flow_transition(
54
55
  if record.status == FlowRunStatus.STOPPING
55
56
  else FlowRunStatus.FAILED
56
57
  )
58
+ state = ensure_reason_summary(state, status=new_status, default="Worker died")
57
59
  return TransitionDecision(
58
60
  status=new_status, finished_at=now, state=state, note="worker-dead"
59
61
  )
@@ -61,6 +63,7 @@ def resolve_flow_transition(
61
63
  # 2) Inner engine reconciliation (worker is alive or not required).
62
64
  if record.status == FlowRunStatus.RUNNING:
63
65
  if inner_status == "paused":
66
+ state = ensure_reason_summary(state, status=FlowRunStatus.PAUSED)
64
67
  return TransitionDecision(
65
68
  status=FlowRunStatus.PAUSED,
66
69
  finished_at=None,
@@ -98,6 +101,7 @@ def resolve_flow_transition(
98
101
  engine.pop("reason", None)
99
102
  engine.pop("reason_details", None)
100
103
  engine.pop("reason_code", None)
104
+ state.pop("reason_summary", None)
101
105
  engine["status"] = "running"
102
106
  state["ticket_engine"] = engine
103
107
  return TransitionDecision(
@@ -115,6 +119,7 @@ def resolve_flow_transition(
115
119
  note="paused-worker-dead",
116
120
  )
117
121
 
122
+ state = ensure_reason_summary(state, status=FlowRunStatus.PAUSED)
118
123
  return TransitionDecision(
119
124
  status=FlowRunStatus.PAUSED, finished_at=None, state=state, note="paused"
120
125
  )