synapse-orch-ai 1.8.6 → 1.9.0

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 (120) hide show
  1. package/backend/core/models.py +4 -0
  2. package/backend/core/notifications.py +251 -0
  3. package/backend/core/orchestration/engine.py +25 -0
  4. package/backend/core/orchestration/journal.py +263 -0
  5. package/backend/core/orchestration/runner.py +53 -4
  6. package/backend/core/orchestration/state.py +35 -3
  7. package/backend/core/orchestration/steps.py +140 -4
  8. package/backend/core/react_engine.py +39 -3
  9. package/backend/core/routes/api_v1.py +26 -2
  10. package/backend/core/routes/data.py +6 -0
  11. package/backend/core/routes/notifications.py +65 -0
  12. package/backend/core/routes/orchestrations.py +195 -5
  13. package/backend/core/scheduler.py +8 -1
  14. package/backend/core/server.py +21 -0
  15. package/backend/requirements-messaging.lock +1 -1
  16. package/backend/requirements.lock +6 -6
  17. package/frontend-build/.next/BUILD_ID +1 -1
  18. package/frontend-build/.next/app-path-routes-manifest.json +2 -0
  19. package/frontend-build/.next/build-manifest.json +3 -3
  20. package/frontend-build/.next/prerender-manifest.json +3 -3
  21. package/frontend-build/.next/routes-manifest.json +14 -0
  22. package/frontend-build/.next/server/app/_global-error.html +1 -1
  23. package/frontend-build/.next/server/app/_global-error.rsc +1 -1
  24. package/frontend-build/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
  25. package/frontend-build/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  26. package/frontend-build/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  27. package/frontend-build/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  28. package/frontend-build/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  29. package/frontend-build/.next/server/app/_not-found/page.js +1 -1
  30. package/frontend-build/.next/server/app/_not-found/page.js.nft.json +1 -1
  31. package/frontend-build/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  32. package/frontend-build/.next/server/app/_not-found.html +1 -1
  33. package/frontend-build/.next/server/app/_not-found.rsc +17 -16
  34. package/frontend-build/.next/server/app/_not-found.segments/_full.segment.rsc +17 -16
  35. package/frontend-build/.next/server/app/_not-found.segments/_head.segment.rsc +4 -4
  36. package/frontend-build/.next/server/app/_not-found.segments/_index.segment.rsc +6 -5
  37. package/frontend-build/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +2 -2
  38. package/frontend-build/.next/server/app/_not-found.segments/_not-found.segment.rsc +3 -3
  39. package/frontend-build/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
  40. package/frontend-build/.next/server/app/api/notifications/stream/route/app-paths-manifest.json +3 -0
  41. package/frontend-build/.next/server/app/api/notifications/stream/route/build-manifest.json +9 -0
  42. package/frontend-build/.next/server/app/api/notifications/stream/route/server-reference-manifest.json +4 -0
  43. package/frontend-build/.next/server/app/api/notifications/stream/route.js +6 -0
  44. package/frontend-build/.next/server/app/api/notifications/stream/route.js.map +5 -0
  45. package/frontend-build/.next/server/app/api/notifications/stream/route.js.nft.json +1 -0
  46. package/frontend-build/.next/server/app/api/notifications/stream/route_client-reference-manifest.js +3 -0
  47. package/frontend-build/.next/server/app/api/orchestrations/runs/[run_id]/events/stream/route/app-paths-manifest.json +3 -0
  48. package/frontend-build/.next/server/app/api/orchestrations/runs/[run_id]/events/stream/route/build-manifest.json +9 -0
  49. package/frontend-build/.next/server/app/api/orchestrations/runs/[run_id]/events/stream/route/server-reference-manifest.json +4 -0
  50. package/frontend-build/.next/server/app/api/orchestrations/runs/[run_id]/events/stream/route.js +6 -0
  51. package/frontend-build/.next/server/app/api/orchestrations/runs/[run_id]/events/stream/route.js.map +5 -0
  52. package/frontend-build/.next/server/app/api/orchestrations/runs/[run_id]/events/stream/route.js.nft.json +1 -0
  53. package/frontend-build/.next/server/app/api/orchestrations/runs/[run_id]/events/stream/route_client-reference-manifest.js +3 -0
  54. package/frontend-build/.next/server/app/index.html +1 -1
  55. package/frontend-build/.next/server/app/index.rsc +19 -18
  56. package/frontend-build/.next/server/app/index.segments/__PAGE__.segment.rsc +4 -4
  57. package/frontend-build/.next/server/app/index.segments/_full.segment.rsc +19 -18
  58. package/frontend-build/.next/server/app/index.segments/_head.segment.rsc +4 -4
  59. package/frontend-build/.next/server/app/index.segments/_index.segment.rsc +6 -5
  60. package/frontend-build/.next/server/app/index.segments/_tree.segment.rsc +2 -2
  61. package/frontend-build/.next/server/app/login/page.js +1 -1
  62. package/frontend-build/.next/server/app/login/page.js.nft.json +1 -1
  63. package/frontend-build/.next/server/app/login/page_client-reference-manifest.js +1 -1
  64. package/frontend-build/.next/server/app/login.html +1 -1
  65. package/frontend-build/.next/server/app/login.rsc +21 -20
  66. package/frontend-build/.next/server/app/login.segments/_full.segment.rsc +21 -20
  67. package/frontend-build/.next/server/app/login.segments/_head.segment.rsc +4 -4
  68. package/frontend-build/.next/server/app/login.segments/_index.segment.rsc +6 -5
  69. package/frontend-build/.next/server/app/login.segments/_tree.segment.rsc +2 -2
  70. package/frontend-build/.next/server/app/login.segments/login/__PAGE__.segment.rsc +4 -4
  71. package/frontend-build/.next/server/app/login.segments/login.segment.rsc +3 -3
  72. package/frontend-build/.next/server/app/page.js +1 -1
  73. package/frontend-build/.next/server/app/page.js.nft.json +1 -1
  74. package/frontend-build/.next/server/app/page_client-reference-manifest.js +1 -1
  75. package/frontend-build/.next/server/app/settings/[tab]/page.js +1 -1
  76. package/frontend-build/.next/server/app/settings/[tab]/page.js.nft.json +1 -1
  77. package/frontend-build/.next/server/app/settings/[tab]/page_client-reference-manifest.js +1 -1
  78. package/frontend-build/.next/server/app-paths-manifest.json +2 -0
  79. package/frontend-build/.next/server/chunks/0zjb_server_app_api_orchestrations_runs_[run_id]_events_stream_route_actions_0~mhgd~.js +3 -0
  80. package/frontend-build/.next/server/chunks/[root-of-the-server]__0f5cyte._.js +3 -0
  81. package/frontend-build/.next/server/chunks/[root-of-the-server]__0fa0k5_._.js +3 -0
  82. package/frontend-build/.next/server/chunks/_next-internal_server_app_api_notifications_stream_route_actions_109.jhp.js +3 -0
  83. package/frontend-build/.next/server/chunks/ssr/[root-of-the-server]__0.9mzsa._.js +3 -0
  84. package/frontend-build/.next/server/chunks/ssr/{[root-of-the-server]__0wxl56w._.js → [root-of-the-server]__05u5pmw._.js} +2 -2
  85. package/frontend-build/.next/server/chunks/ssr/[root-of-the-server]__0fo_bex._.js +1 -1
  86. package/frontend-build/.next/server/chunks/ssr/[root-of-the-server]__0n2i~oj._.js +3 -0
  87. package/frontend-build/.next/server/chunks/ssr/{[root-of-the-server]__10.-84t._.js → [root-of-the-server]__10jhy6u._.js} +2 -2
  88. package/frontend-build/.next/server/chunks/ssr/_0.27p4o._.js +4 -0
  89. package/frontend-build/.next/server/chunks/ssr/_0b~n.nn._.js +15 -15
  90. package/frontend-build/.next/server/chunks/ssr/node_modules_lucide-react_dist_esm_icons_12ubua1._.js +3 -0
  91. package/frontend-build/.next/server/chunks/ssr/src_app_page_tsx_0ss2.w7._.js +1 -1
  92. package/frontend-build/.next/server/functions-config-manifest.json +6 -0
  93. package/frontend-build/.next/server/middleware-build-manifest.js +3 -3
  94. package/frontend-build/.next/server/pages/404.html +1 -1
  95. package/frontend-build/.next/server/pages/500.html +1 -1
  96. package/frontend-build/.next/server/server-reference-manifest.js +1 -1
  97. package/frontend-build/.next/server/server-reference-manifest.json +1 -1
  98. package/frontend-build/.next/static/chunks/{16j4jub4qpyf1.js → 02hfac~emo77v.js} +15 -15
  99. package/frontend-build/.next/static/chunks/047the8jlj6un.css +1 -0
  100. package/frontend-build/.next/static/chunks/0e_m35~cfcbua.js +1 -0
  101. package/frontend-build/.next/static/chunks/{0cwgnxu88mzyi.js → 0j-zkf3m3ioxl.js} +1 -1
  102. package/frontend-build/.next/static/chunks/0jo8lflqyoi98.js +1 -0
  103. package/frontend-build/.next/static/chunks/0qsyb4u8ttd2i.js +1 -0
  104. package/frontend-build/.next/static/chunks/{0g7bk6lyj687k.js → 0~o3t9bm0eiky.js} +1 -1
  105. package/frontend-build/.next/static/chunks/11a8am5wux-qu.js +2 -0
  106. package/frontend-build/.next/static/chunks/16~0az_az7p4a.js +1 -0
  107. package/package.json +1 -1
  108. package/frontend-build/.next/server/chunks/ssr/[root-of-the-server]__0_30ktt._.js +0 -3
  109. package/frontend-build/.next/server/chunks/ssr/[root-of-the-server]__0rj.gz.._.js +0 -3
  110. package/frontend-build/.next/server/chunks/ssr/_0rwng5.._.js +0 -4
  111. package/frontend-build/.next/server/chunks/ssr/node_modules_lucide-react_dist_esm_0__0o-2._.js +0 -3
  112. package/frontend-build/.next/static/chunks/03qf6ywkkwh~r.js +0 -1
  113. package/frontend-build/.next/static/chunks/0560sc9qpz__k.js +0 -2
  114. package/frontend-build/.next/static/chunks/0f8pg6_soqwt2.js +0 -1
  115. package/frontend-build/.next/static/chunks/0k0-nxx35rq9v.js +0 -1
  116. package/frontend-build/.next/static/chunks/0wtv8wcl1tn.d.js +0 -1
  117. package/frontend-build/.next/static/chunks/166_fx4i~0.31.css +0 -1
  118. /package/frontend-build/.next/static/{Yaj8BSKIx7sIJnGFuGLQZ → 08oDqCbK0BRht0JB9AgpU}/_buildManifest.js +0 -0
  119. /package/frontend-build/.next/static/{Yaj8BSKIx7sIJnGFuGLQZ → 08oDqCbK0BRht0JB9AgpU}/_clientMiddlewareManifest.js +0 -0
  120. /package/frontend-build/.next/static/{Yaj8BSKIx7sIJnGFuGLQZ → 08oDqCbK0BRht0JB9AgpU}/_ssgManifest.js +0 -0
@@ -131,6 +131,10 @@ class Settings(BaseModel):
131
131
  login_username: str = ""
132
132
  login_password_hash: str = ""
133
133
 
134
+ # Run notifications: messaging channel id to mirror run notifications to
135
+ # (human-input pauses, completions, failures). Empty = in-app only.
136
+ run_notification_channel_id: str = ""
137
+
134
138
  # Scale / distributed execution settings
135
139
  redis_url: str = ""
136
140
  scale_postgres_url: str = ""
@@ -0,0 +1,251 @@
1
+ """User-facing run notifications.
2
+
3
+ A process-wide hub that turns orchestration lifecycle events into
4
+ notifications the UI (and optionally a messaging channel) can consume:
5
+
6
+ - `human_input_required` → "… needs your input"
7
+ - `orchestration_complete`→ run completed / run failed
8
+ - `orchestration_error` → run failed
9
+
10
+ Fed by the runner pump via `observe_run_event` (registered on
11
+ `core.orchestration.runner.event_observers` at server startup), so every
12
+ entry point — UI, API, scheduler — produces notifications with no extra
13
+ wiring. Standalone-safe: a bounded in-memory ring with best-effort JSON
14
+ persistence, no external dependencies.
15
+
16
+ Delivery surfaces:
17
+ - `GET /api/notifications` + `/api/notifications/stream` (routes/notifications.py)
18
+ for the in-app provider (toasts, badges, browser notifications).
19
+ - Optional messaging dispatch: when settings.json has
20
+ `run_notification_channel_id` set to a configured messaging channel id,
21
+ each notification is also sent there (`notify_chat_id` or the channel's
22
+ last active chat).
23
+ """
24
+ import asyncio
25
+ import json
26
+ import time
27
+ from pathlib import Path
28
+
29
+ NOTIFICATIONS_FILE = Path(__file__).parent.parent / "logs" / "notifications.json"
30
+ CAPACITY = 200
31
+
32
+
33
+ class NotificationHub:
34
+ def __init__(self, capacity: int = CAPACITY, persist_path: Path = NOTIFICATIONS_FILE):
35
+ self._capacity = capacity
36
+ self._path = persist_path
37
+ self._items: list[dict] = self._load()
38
+ self._next_id = (self._items[-1]["id"] + 1) if self._items else 1
39
+ self._subs: set[asyncio.Queue] = set()
40
+ # run_id → orchestration display name (cached from orchestration_start)
41
+ self._orch_names: dict[str, str] = {}
42
+ # Runs whose terminal notification already fired — engine failure paths
43
+ # can emit both orchestration_error and orchestration_complete(failed).
44
+ self._terminal_notified: set[str] = set()
45
+ self._server_module = None
46
+
47
+ # ── persistence ──────────────────────────────────────────────────────
48
+ def _load(self) -> list[dict]:
49
+ try:
50
+ data = json.loads(self._path.read_text(encoding="utf-8"))
51
+ return data if isinstance(data, list) else []
52
+ except Exception:
53
+ return []
54
+
55
+ def _persist(self):
56
+ try:
57
+ self._path.parent.mkdir(parents=True, exist_ok=True)
58
+ self._path.write_text(json.dumps(self._items), encoding="utf-8")
59
+ except Exception:
60
+ pass # best-effort — the ring keeps working in memory
61
+
62
+ # ── core API ─────────────────────────────────────────────────────────
63
+ def publish(self, kind: str, title: str, body: str = "",
64
+ run_id: str | None = None, data: dict | None = None) -> dict:
65
+ item = {
66
+ "id": self._next_id,
67
+ "ts": time.time(),
68
+ "kind": kind, # human_input | run_completed | run_failed
69
+ "title": title[:200],
70
+ "body": (body or "")[:500],
71
+ "run_id": run_id,
72
+ "data": data or {},
73
+ }
74
+ self._next_id += 1
75
+ self._items.append(item)
76
+ if len(self._items) > self._capacity:
77
+ self._items = self._items[-self._capacity:]
78
+ self._persist()
79
+ for queue in list(self._subs):
80
+ queue.put_nowait(item)
81
+ self._dispatch_messaging(item)
82
+ return item
83
+
84
+ def list(self, after_id: int = 0) -> list[dict]:
85
+ return [i for i in self._items if i["id"] > after_id]
86
+
87
+ def last_id(self) -> int:
88
+ return self._next_id - 1
89
+
90
+ def subscribe(self) -> asyncio.Queue:
91
+ queue: asyncio.Queue = asyncio.Queue()
92
+ self._subs.add(queue)
93
+ return queue
94
+
95
+ def unsubscribe(self, queue: asyncio.Queue):
96
+ self._subs.discard(queue)
97
+
98
+ def configure(self, server_module):
99
+ """Give the hub access to the messaging manager (server startup)."""
100
+ self._server_module = server_module
101
+
102
+ # ── engine event observer (runner pump) ──────────────────────────────
103
+ def observe_run_event(self, run_id: str, event: dict):
104
+ etype = event.get("type", "")
105
+ if etype == "orchestration_start":
106
+ self._orch_names[run_id] = (
107
+ event.get("orchestration_name") or event.get("orchestration_id") or run_id
108
+ )
109
+ self._terminal_notified.discard(run_id)
110
+ elif etype == "step_start":
111
+ # Any forward progress re-arms terminal notification (a resumed
112
+ # run emits no orchestration_start) and means any pending
113
+ # human-input request for this run was answered.
114
+ self._terminal_notified.discard(run_id)
115
+ self._resolve_human_input(run_id)
116
+ elif etype == "human_input_required":
117
+ name = self._orch_name(run_id)
118
+ self.publish(
119
+ "human_input", f"{name} needs your input",
120
+ body=str(event.get("prompt") or ""),
121
+ run_id=run_id,
122
+ data={"step_id": event.get("orch_step_id")},
123
+ )
124
+ elif etype == "orchestration_complete":
125
+ self._resolve_human_input(run_id)
126
+ if run_id in self._terminal_notified:
127
+ return
128
+ self._terminal_notified.add(run_id)
129
+ name = self._orch_name(run_id)
130
+ status = event.get("status", "completed")
131
+ if status == "completed":
132
+ self.publish("run_completed", f"{name} completed", run_id=run_id)
133
+ else:
134
+ self.publish("run_failed", f"{name} {status}", run_id=run_id)
135
+ elif etype == "orchestration_error":
136
+ self._resolve_human_input(run_id)
137
+ if run_id in self._terminal_notified:
138
+ return
139
+ self._terminal_notified.add(run_id)
140
+ name = self._orch_name(run_id)
141
+ self.publish("run_failed", f"{name} failed",
142
+ body=str(event.get("error") or ""), run_id=run_id)
143
+ elif etype == "run_stream_end" and not event.get("paused"):
144
+ self._orch_names.pop(run_id, None)
145
+
146
+ def _resolve_human_input(self, run_id: str):
147
+ """Mark this run's human-input notifications answered. The updated
148
+ items (same id, resolved=true) are re-pushed to live subscribers;
149
+ clients upsert by id so the bell stops showing them as actionable."""
150
+ updated = [
151
+ item for item in self._items
152
+ if item["run_id"] == run_id and item["kind"] == "human_input"
153
+ and not item.get("resolved")
154
+ ]
155
+ if not updated:
156
+ return
157
+ for item in updated:
158
+ item["resolved"] = True
159
+ self._persist()
160
+ for queue in list(self._subs):
161
+ for item in updated:
162
+ queue.put_nowait(item)
163
+
164
+ def _orch_name(self, run_id: str) -> str:
165
+ """Cached name, with a checkpoint fallback for resumed runs (which
166
+ emit no orchestration_start)."""
167
+ name = self._orch_names.get(run_id)
168
+ if name:
169
+ return name
170
+ try:
171
+ from core.orchestration.state import SharedState
172
+ from core.routes.orchestrations import load_orchestrations
173
+ orch_id = SharedState.restore(run_id).run.orchestration_id
174
+ orch = next((o for o in load_orchestrations() if o["id"] == orch_id), None)
175
+ name = (orch or {}).get("name") or orch_id
176
+ except Exception:
177
+ name = "Orchestration"
178
+ self._orch_names[run_id] = name
179
+ return name
180
+
181
+ # ── startup: reconstruct missed human-input notifications ────────────
182
+ def reconstruct_missed(self, limit: int = 100):
183
+ """Re-publish 'needs your input' for runs still paused on a human step
184
+ (the server may have restarted since the pause fired)."""
185
+ try:
186
+ from core.orchestration.state import SharedState
187
+ # Only an *unresolved* item counts as already-notified: a run that
188
+ # paused again at a later step deserves a fresh notification.
189
+ notified_runs = {
190
+ i["run_id"] for i in self._items
191
+ if i["kind"] == "human_input" and not i.get("resolved")
192
+ }
193
+ paused_waiting: set[str] = set()
194
+ for summary in SharedState.list_runs(limit=limit):
195
+ if summary.get("status") != "paused":
196
+ continue
197
+ run_id = summary.get("run_id") or ""
198
+ try:
199
+ run = SharedState.restore(run_id).run
200
+ except Exception:
201
+ continue
202
+ if not run.waiting_for_human:
203
+ continue
204
+ paused_waiting.add(run_id)
205
+ if run_id not in notified_runs:
206
+ self.publish(
207
+ "human_input",
208
+ f"{self._orch_name(run_id)} needs your input",
209
+ body=str(run.human_prompt or ""),
210
+ run_id=run_id,
211
+ )
212
+ # Inverse sweep: unresolved items whose run is no longer waiting
213
+ # (answered/failed/swept while we were down) get resolved.
214
+ for run_id in notified_runs - paused_waiting:
215
+ self._resolve_human_input(run_id)
216
+ except Exception as e:
217
+ print(f"Warning: notification reconstruction failed: {e}", flush=True)
218
+
219
+ # ── optional messaging dispatch ──────────────────────────────────────
220
+ def _dispatch_messaging(self, item: dict):
221
+ try:
222
+ loop = asyncio.get_running_loop()
223
+ except RuntimeError:
224
+ return
225
+ loop.create_task(self._send_messaging(item))
226
+
227
+ async def _send_messaging(self, item: dict):
228
+ try:
229
+ from core.config import load_settings
230
+ channel_id = (load_settings() or {}).get("run_notification_channel_id") or ""
231
+ if not channel_id:
232
+ return
233
+ manager = getattr(self._server_module, "messaging_manager", None)
234
+ adapter = manager._adapters.get(channel_id) if manager else None
235
+ if adapter is None:
236
+ return
237
+ from core.messaging import store as channel_store
238
+ channel = channel_store.get_channel(channel_id) or {}
239
+ chat_id = channel.get("notify_chat_id") or getattr(adapter, "_last_chat_id", None)
240
+ if not chat_id:
241
+ return
242
+ icon = {"human_input": "⏸", "run_completed": "✅", "run_failed": "❌"}.get(item["kind"], "🔔")
243
+ text = f"{icon} {item['title']}"
244
+ if item.get("body"):
245
+ text += f"\n{item['body']}"
246
+ await adapter.send_message(chat_id, text)
247
+ except Exception as e:
248
+ print(f"Warning: messaging notification failed: {e}", flush=True)
249
+
250
+
251
+ hub = NotificationHub()
@@ -78,6 +78,14 @@ class OrchestrationEngine:
78
78
  )
79
79
  state = SharedState(run)
80
80
 
81
+ # Initial checkpoint: make the run visible to /runs listings and
82
+ # status polling from second zero. Without it the run file only
83
+ # appears at the first step boundary, so a run inside a long first
84
+ # step is invisible to the active-runs UI (and a crash there leaves
85
+ # no file to resume from). The startup zombie sweep handles these
86
+ # like any other interrupted "running" checkpoint.
87
+ state.checkpoint()
88
+
81
89
  self.logger = OrchestrationLogger(
82
90
  run_id=run_id,
83
91
  orchestration_id=self.orch.id,
@@ -356,6 +364,13 @@ class OrchestrationEngine:
356
364
  For failed runs: the failed step's history entry is removed so it re-executes.
357
365
  For cancelled runs: resumes from current_step_id (which was never started).
358
366
  All state accumulated by prior steps is preserved.
367
+
368
+ Mid-node continuity: agent/loop/parallel executors checkpoint private
369
+ progress keys in shared_state (_step_progress_*, _loop_progress_*,
370
+ _parallel_progress_*) as work completes, so the re-executed node
371
+ continues from its last turn/iteration/branch instead of starting
372
+ from scratch. The seeding happens inside the executors — this method
373
+ just replays the node.
359
374
  """
360
375
  from .state import SharedState as SS, _cancelled_run_ids
361
376
 
@@ -395,6 +410,11 @@ class OrchestrationEngine:
395
410
  run.status = "running"
396
411
 
397
412
  state = SharedState(run)
413
+ # Persist "running" before the first step so the run reappears in the
414
+ # active-runs listing immediately. Without this the checkpoint still
415
+ # reads failed/cancelled until the first step boundary, and a resumed
416
+ # run looks dead for as long as its first step takes.
417
+ state.checkpoint()
398
418
  async for event in engine._execute_loop(run, state):
399
419
  yield event
400
420
 
@@ -466,6 +486,9 @@ class OrchestrationEngine:
466
486
 
467
487
  print(f"[engine.resume] 🚀 entering _execute_loop with current_step_id={run.current_step_id!r} status={run.status}", flush=True)
468
488
  state = SharedState(run)
489
+ # Persist "running" + the cleared human flag before the first step, so
490
+ # the run stops showing as "needs input" the moment it resumes.
491
+ state.checkpoint()
469
492
  async for event in engine._execute_loop(run, state):
470
493
  yield event
471
494
  print(f"[engine.resume] 🏁 _execute_loop finished, run.status={run.status} run.current_step_id={run.current_step_id!r}", flush=True)
@@ -581,6 +604,8 @@ class OrchestrationEngine:
581
604
  run.current_step_id = next_id
582
605
 
583
606
  state = SharedState(run)
607
+ # Same as resume(): make the un-paused state visible before step one.
608
+ state.checkpoint()
584
609
  async for event in engine._execute_loop(run, state):
585
610
  yield event
586
611
 
@@ -0,0 +1,263 @@
1
+ """Append-only per-run event journal + in-process broker for live tailing.
2
+
3
+ Every event the engine emits for a run is journaled as one JSONL line in
4
+ `backend/logs/orchestration_events/{run_id}.jsonl`:
5
+
6
+ {"id": <monotonic seq>, "ts": <epoch seconds>, "event": {...}}
7
+
8
+ The id is embedded in the line (not derived from the byte offset) so a torn
9
+ final line — a crash mid-write — costs at most that one event: `read()` skips
10
+ unparseable lines, and the next pump recovers the sequence from the last valid
11
+ line and continues it. Run → resume → human-input pumps for the same run share
12
+ one journal instance (see `get_journal`), so ids stay monotonic across the
13
+ whole lifetime of a run.
14
+
15
+ This is the standalone-mode counterpart of the Redis Stream published by scale
16
+ workers (`core.scale.pubsub.RunEventPublisher`): the read endpoints in
17
+ `routes/orchestrations.py` serve whichever of the two exists for a run.
18
+
19
+ Builder flows (`builder_*` run ids) are intentionally NOT journaled: they are
20
+ ephemeral UI conversations driven by `routes/builder.py`, which bypasses the
21
+ runner pump on purpose.
22
+
23
+ Writes are synchronous: a flushed append of a bounded line is microseconds,
24
+ which is noise next to the LLM/tool latency between events, and it keeps this
25
+ module free of extra dependencies. The pump wraps every append in try/except —
26
+ a journal failure must never kill a run.
27
+ """
28
+ import json
29
+ import time
30
+ from pathlib import Path
31
+
32
+ import asyncio
33
+
34
+ EVENTS_DIR = Path(__file__).parent.parent.parent / "logs" / "orchestration_events"
35
+
36
+ # A single serialized event line larger than this gets its heavy fields
37
+ # truncated (the UI fetches full run state from the checkpoint endpoint).
38
+ MAX_EVENT_BYTES = 32 * 1024
39
+
40
+ # Keys that carry potentially unbounded payloads, shrunk deepest-first when a
41
+ # line exceeds MAX_EVENT_BYTES.
42
+ _HEAVY_KEYS = ("final_state", "shared_state", "data", "preview", "response",
43
+ "reasoning", "thought", "output", "result", "agent_context")
44
+
45
+ # After this many events a run journals only structural events — enough to
46
+ # reconstruct the run's shape without letting a chatty agent grow the file
47
+ # unboundedly.
48
+ MAX_EVENTS_PER_RUN = 20_000
49
+
50
+ STRUCTURAL_EVENT_TYPES = frozenset({
51
+ "orchestration_start", "step_start", "step_complete", "step_error",
52
+ "loop_iteration", "loop_complete", "loop_limit_reached",
53
+ "parallel_start", "parallel_complete", "branch_start", "merge_complete",
54
+ "routing_decision", "if_decision", "switch_decision",
55
+ "human_input_required", "orchestration_complete", "orchestration_error",
56
+ "orchestration_end", "final", "run_stream_end", "journal_truncated",
57
+ })
58
+
59
+
60
+ def _ensure_events_dir():
61
+ EVENTS_DIR.mkdir(parents=True, exist_ok=True)
62
+
63
+
64
+ def _truncate_strings(value, limit: int):
65
+ """Recursively cap every string in `value` to `limit` chars."""
66
+ if isinstance(value, str):
67
+ return value[:limit] + "…[truncated]" if len(value) > limit else value
68
+ if isinstance(value, dict):
69
+ return {k: _truncate_strings(v, limit) for k, v in value.items()}
70
+ if isinstance(value, list):
71
+ return [_truncate_strings(v, limit) for v in value]
72
+ return value
73
+
74
+
75
+ def _shrink(event: dict) -> dict:
76
+ """Bound an event to roughly MAX_EVENT_BYTES by truncating heavy fields.
77
+
78
+ The full payload still reaches the live SSE consumer untouched — only the
79
+ journaled copy is shrunk, and it is marked `"truncated": true` so replay
80
+ UIs can say so.
81
+ """
82
+ try:
83
+ if len(json.dumps(event, default=str)) <= MAX_EVENT_BYTES:
84
+ return event
85
+ except Exception:
86
+ return {"type": event.get("type", "unknown"), "truncated": True,
87
+ "error": "unserializable event"}
88
+ shrunk = dict(event)
89
+ for key in _HEAVY_KEYS:
90
+ if key in shrunk:
91
+ shrunk[key] = _truncate_strings(shrunk[key], 2_000)
92
+ shrunk["truncated"] = True
93
+ # Last resort: whole-event string cap if still oversized (e.g. many
94
+ # unlisted large fields).
95
+ if len(json.dumps(shrunk, default=str)) > MAX_EVENT_BYTES:
96
+ shrunk = {"type": event.get("type", "unknown"), "truncated": True}
97
+ return shrunk
98
+
99
+
100
+ class FileRunJournal:
101
+ """JSONL journal for one run. Instances are shared via `get_journal`."""
102
+
103
+ def __init__(self, run_id: str):
104
+ self.run_id = run_id
105
+ self._path = EVENTS_DIR / f"{run_id}.jsonl"
106
+ self._fh = None
107
+ self._seq = self._recover_seq()
108
+ self._capped = self._seq >= MAX_EVENTS_PER_RUN
109
+ self._cap_marked = self._capped
110
+
111
+ @property
112
+ def path(self) -> Path:
113
+ return self._path
114
+
115
+ def _recover_seq(self) -> int:
116
+ """Continue the id sequence from the last valid line of the file."""
117
+ if not self._path.exists():
118
+ return 0
119
+ try:
120
+ with open(self._path, "rb") as f:
121
+ f.seek(0, 2)
122
+ size = f.tell()
123
+ f.seek(max(0, size - 64 * 1024))
124
+ tail = f.read().decode("utf-8", errors="replace")
125
+ except OSError:
126
+ return 0
127
+ for line in reversed(tail.splitlines()):
128
+ try:
129
+ entry = json.loads(line)
130
+ return int(entry["id"])
131
+ except Exception:
132
+ continue # torn/partial line — keep scanning backwards
133
+ return 0
134
+
135
+ def append(self, event: dict) -> int:
136
+ """Append one event; returns its journal id (0 = skipped by the cap)."""
137
+ etype = event.get("type", "")
138
+ if self._capped and etype not in STRUCTURAL_EVENT_TYPES:
139
+ return 0
140
+ _ensure_events_dir()
141
+ if self._fh is None:
142
+ self._fh = open(self._path, "a", encoding="utf-8")
143
+ if not self._capped and self._seq >= MAX_EVENTS_PER_RUN:
144
+ self._capped = True
145
+ if not self._cap_marked:
146
+ self._cap_marked = True
147
+ self._seq += 1
148
+ self._fh.write(json.dumps({
149
+ "id": self._seq, "ts": time.time(),
150
+ "event": {"type": "journal_truncated",
151
+ "message": "event cap reached; only structural events are journaled from here"},
152
+ }) + "\n")
153
+ if etype not in STRUCTURAL_EVENT_TYPES:
154
+ self._fh.flush()
155
+ return 0
156
+ self._seq += 1
157
+ line = json.dumps(
158
+ {"id": self._seq, "ts": time.time(), "event": _shrink(event)},
159
+ default=str,
160
+ )
161
+ self._fh.write(line + "\n")
162
+ self._fh.flush()
163
+ return self._seq
164
+
165
+ def read(self, after_id: int = 0, limit: int = 5000) -> list[dict]:
166
+ """Return entries with id > after_id, oldest first, skipping torn lines."""
167
+ if self._fh is not None:
168
+ self._fh.flush()
169
+ if not self._path.exists():
170
+ return []
171
+ entries = []
172
+ try:
173
+ with open(self._path, "r", encoding="utf-8", errors="replace") as f:
174
+ for line in f:
175
+ try:
176
+ entry = json.loads(line)
177
+ except Exception:
178
+ continue # torn line (crash mid-write)
179
+ if entry.get("id", 0) > after_id:
180
+ entries.append(entry)
181
+ if len(entries) >= limit:
182
+ break
183
+ except OSError:
184
+ return []
185
+ return entries
186
+
187
+ def last_id(self) -> int:
188
+ return self._seq
189
+
190
+ def close(self):
191
+ if self._fh is not None:
192
+ try:
193
+ self._fh.close()
194
+ except OSError:
195
+ pass
196
+ self._fh = None
197
+
198
+ @classmethod
199
+ def exists(cls, run_id: str) -> bool:
200
+ return (EVENTS_DIR / f"{run_id}.jsonl").exists()
201
+
202
+ @classmethod
203
+ def delete(cls, run_id: str) -> bool:
204
+ close_journal(run_id)
205
+ path = EVENTS_DIR / f"{run_id}.jsonl"
206
+ try:
207
+ path.unlink()
208
+ return True
209
+ except FileNotFoundError:
210
+ return False
211
+
212
+
213
+ # ── Per-run journal registry ─────────────────────────────────────────
214
+ # Run/resume/human-input pumps for one run share an instance so the id
215
+ # sequence and file handle are continuous. Evicted when the pump ends.
216
+
217
+ _journals: dict[str, FileRunJournal] = {}
218
+
219
+
220
+ def get_journal(run_id: str) -> FileRunJournal:
221
+ journal = _journals.get(run_id)
222
+ if journal is None:
223
+ journal = FileRunJournal(run_id)
224
+ _journals[run_id] = journal
225
+ return journal
226
+
227
+
228
+ def close_journal(run_id: str):
229
+ journal = _journals.pop(run_id, None)
230
+ if journal is not None:
231
+ journal.close()
232
+
233
+
234
+ class RunEventBroker:
235
+ """In-process pub/sub keyed by run_id, for live-tailing SSE endpoints.
236
+
237
+ Subscribers get `{"id": n, "event": {...}}` entries. Queues are unbounded:
238
+ entries are small (journal-shrunk) and subscribers are SSE writers that
239
+ drain continuously; dropping events would break the replay→live dedupe
240
+ contract (monotonic ids with no gaps after the subscription point).
241
+ """
242
+
243
+ def __init__(self):
244
+ self._subs: dict[str, set[asyncio.Queue]] = {}
245
+
246
+ def publish(self, run_id: str, entry: dict):
247
+ for queue in self._subs.get(run_id, ()):
248
+ queue.put_nowait(entry)
249
+
250
+ def subscribe(self, run_id: str) -> asyncio.Queue:
251
+ queue: asyncio.Queue = asyncio.Queue()
252
+ self._subs.setdefault(run_id, set()).add(queue)
253
+ return queue
254
+
255
+ def unsubscribe(self, run_id: str, queue: asyncio.Queue):
256
+ subs = self._subs.get(run_id)
257
+ if subs is not None:
258
+ subs.discard(queue)
259
+ if not subs:
260
+ self._subs.pop(run_id, None)
261
+
262
+
263
+ broker = RunEventBroker()
@@ -8,11 +8,17 @@ in a fresh task raises "Attempted to exit cancel scope in a different task"
8
8
  (issue #356).
9
9
 
10
10
  Every orchestration entry point goes through here, so the launch path can no
11
- longer change how the engine is driven.
11
+ longer change how the engine is driven. That makes the pump the single choke
12
+ point for run observability: every event is appended to the run's journal
13
+ (`journal.FileRunJournal`) and fanned out to live tails (`journal.broker`)
14
+ before it reaches the SSE queue. A journal/broker/observer failure must never
15
+ kill a run -- all three are best-effort.
12
16
  """
13
17
  import asyncio
14
18
  from typing import AsyncGenerator
15
19
 
20
+ from core.orchestration.journal import broker, close_journal, get_journal
21
+
16
22
  # Live in-process runs, keyed by run_id. The cancel endpoint cancels the task
17
23
  # to interrupt an in-progress await; the run-status endpoint consults it to
18
24
  # report a run that is still inside its first step (no checkpoint yet).
@@ -21,6 +27,29 @@ _active_tasks: dict[str, asyncio.Task] = {}
21
27
  # Queue marker meaning "the engine generator is exhausted".
22
28
  SENTINEL = object()
23
29
 
30
+ # Observers called as fn(run_id, event) for every pumped event (best-effort).
31
+ # core.notifications registers here to derive user-facing notifications from
32
+ # lifecycle events without the pump knowing about notification semantics.
33
+ event_observers: list = []
34
+
35
+
36
+ def _observe(run_id: str, event: dict):
37
+ """Journal + broadcast + notify for one event. Never raises."""
38
+ entry_id = 0
39
+ try:
40
+ entry_id = get_journal(run_id).append(event)
41
+ except Exception as e:
42
+ print(f"WARNING: journal append failed for '{run_id}': {e}", flush=True)
43
+ try:
44
+ broker.publish(run_id, {"id": entry_id, "event": event})
45
+ except Exception:
46
+ pass
47
+ for observer in event_observers:
48
+ try:
49
+ observer(run_id, event)
50
+ except Exception:
51
+ pass
52
+
24
53
 
25
54
  def spawn_engine_run(agen, run_id: str) -> tuple[asyncio.Task, asyncio.Queue]:
26
55
  """Advance `agen` in one dedicated task, returning (task, queue).
@@ -32,19 +61,39 @@ def spawn_engine_run(agen, run_id: str) -> tuple[asyncio.Task, asyncio.Queue]:
32
61
  queue: asyncio.Queue = asyncio.Queue()
33
62
 
34
63
  async def _pump():
64
+ last_type = None
35
65
  try:
36
66
  async for event in agen:
67
+ _observe(run_id, event)
68
+ last_type = event.get("type")
37
69
  await queue.put(event)
38
70
  # Yield so the SSE consumer can dequeue and flush this event
39
71
  # to the HTTP response before the next one is enqueued.
40
72
  await asyncio.sleep(0)
41
73
  except asyncio.CancelledError:
42
- await queue.put({"type": "orchestration_error", "error": "Cancelled"})
74
+ event = {"type": "orchestration_error", "error": "Cancelled"}
75
+ _observe(run_id, event)
76
+ last_type = event["type"]
77
+ await queue.put(event)
43
78
  except FileNotFoundError:
44
- await queue.put({"type": "orchestration_error", "error": "Run not found"})
79
+ event = {"type": "orchestration_error", "error": "Run not found"}
80
+ _observe(run_id, event)
81
+ last_type = event["type"]
82
+ await queue.put(event)
45
83
  except Exception as e:
46
- await queue.put({"type": "orchestration_error", "error": str(e)})
84
+ event = {"type": "orchestration_error", "error": str(e)}
85
+ _observe(run_id, event)
86
+ last_type = event["type"]
87
+ await queue.put(event)
47
88
  finally:
89
+ # Journal/broker-only terminator (never sent on the POST SSE):
90
+ # tells tailing GET clients whether to stay open for a resume pump
91
+ # (paused on human input) or close (terminal).
92
+ _observe(run_id, {
93
+ "type": "run_stream_end",
94
+ "paused": last_type == "human_input_required",
95
+ })
96
+ close_journal(run_id)
48
97
  _active_tasks.pop(run_id, None)
49
98
  print(f"DEBUG SSE QUEUE: sentinel sent for '{run_id}', stream closing", flush=True)
50
99
  await queue.put(SENTINEL)