synapse-orch-ai 1.8.5 → 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.
- package/backend/core/models.py +4 -0
- package/backend/core/notifications.py +251 -0
- package/backend/core/orchestration/engine.py +25 -0
- package/backend/core/orchestration/journal.py +263 -0
- package/backend/core/orchestration/runner.py +53 -4
- package/backend/core/orchestration/state.py +35 -3
- package/backend/core/orchestration/steps.py +140 -4
- package/backend/core/react_engine.py +39 -3
- package/backend/core/routes/api_v1.py +26 -2
- package/backend/core/routes/data.py +6 -0
- package/backend/core/routes/notifications.py +65 -0
- package/backend/core/routes/orchestrations.py +195 -5
- package/backend/core/scheduler.py +8 -1
- package/backend/core/server.py +21 -0
- package/backend/requirements-coding.txt +5 -3
- package/backend/requirements-messaging.lock +167 -0
- package/backend/requirements-messaging.txt +8 -6
- package/backend/requirements.lock +617 -0
- package/backend/requirements.txt +45 -30
- package/frontend-build/.next/BUILD_ID +1 -1
- package/frontend-build/.next/app-path-routes-manifest.json +2 -0
- package/frontend-build/.next/build-manifest.json +3 -3
- package/frontend-build/.next/prerender-manifest.json +3 -3
- package/frontend-build/.next/routes-manifest.json +14 -0
- package/frontend-build/.next/server/app/_global-error.html +1 -1
- package/frontend-build/.next/server/app/_global-error.rsc +1 -1
- package/frontend-build/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
- package/frontend-build/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
- package/frontend-build/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
- package/frontend-build/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
- package/frontend-build/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/frontend-build/.next/server/app/_not-found/page.js +1 -1
- package/frontend-build/.next/server/app/_not-found/page.js.nft.json +1 -1
- package/frontend-build/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
- package/frontend-build/.next/server/app/_not-found.html +1 -1
- package/frontend-build/.next/server/app/_not-found.rsc +17 -16
- package/frontend-build/.next/server/app/_not-found.segments/_full.segment.rsc +17 -16
- package/frontend-build/.next/server/app/_not-found.segments/_head.segment.rsc +4 -4
- package/frontend-build/.next/server/app/_not-found.segments/_index.segment.rsc +6 -5
- package/frontend-build/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +2 -2
- package/frontend-build/.next/server/app/_not-found.segments/_not-found.segment.rsc +3 -3
- package/frontend-build/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
- package/frontend-build/.next/server/app/api/notifications/stream/route/app-paths-manifest.json +3 -0
- package/frontend-build/.next/server/app/api/notifications/stream/route/build-manifest.json +9 -0
- package/frontend-build/.next/server/app/api/notifications/stream/route/server-reference-manifest.json +4 -0
- package/frontend-build/.next/server/app/api/notifications/stream/route.js +6 -0
- package/frontend-build/.next/server/app/api/notifications/stream/route.js.map +5 -0
- package/frontend-build/.next/server/app/api/notifications/stream/route.js.nft.json +1 -0
- package/frontend-build/.next/server/app/api/notifications/stream/route_client-reference-manifest.js +3 -0
- package/frontend-build/.next/server/app/api/orchestrations/runs/[run_id]/events/stream/route/app-paths-manifest.json +3 -0
- package/frontend-build/.next/server/app/api/orchestrations/runs/[run_id]/events/stream/route/build-manifest.json +9 -0
- package/frontend-build/.next/server/app/api/orchestrations/runs/[run_id]/events/stream/route/server-reference-manifest.json +4 -0
- package/frontend-build/.next/server/app/api/orchestrations/runs/[run_id]/events/stream/route.js +6 -0
- package/frontend-build/.next/server/app/api/orchestrations/runs/[run_id]/events/stream/route.js.map +5 -0
- package/frontend-build/.next/server/app/api/orchestrations/runs/[run_id]/events/stream/route.js.nft.json +1 -0
- package/frontend-build/.next/server/app/api/orchestrations/runs/[run_id]/events/stream/route_client-reference-manifest.js +3 -0
- package/frontend-build/.next/server/app/index.html +1 -1
- package/frontend-build/.next/server/app/index.rsc +19 -18
- package/frontend-build/.next/server/app/index.segments/__PAGE__.segment.rsc +4 -4
- package/frontend-build/.next/server/app/index.segments/_full.segment.rsc +19 -18
- package/frontend-build/.next/server/app/index.segments/_head.segment.rsc +4 -4
- package/frontend-build/.next/server/app/index.segments/_index.segment.rsc +6 -5
- package/frontend-build/.next/server/app/index.segments/_tree.segment.rsc +2 -2
- package/frontend-build/.next/server/app/login/page.js +1 -1
- package/frontend-build/.next/server/app/login/page.js.nft.json +1 -1
- package/frontend-build/.next/server/app/login/page_client-reference-manifest.js +1 -1
- package/frontend-build/.next/server/app/login.html +1 -1
- package/frontend-build/.next/server/app/login.rsc +21 -20
- package/frontend-build/.next/server/app/login.segments/_full.segment.rsc +21 -20
- package/frontend-build/.next/server/app/login.segments/_head.segment.rsc +4 -4
- package/frontend-build/.next/server/app/login.segments/_index.segment.rsc +6 -5
- package/frontend-build/.next/server/app/login.segments/_tree.segment.rsc +2 -2
- package/frontend-build/.next/server/app/login.segments/login/__PAGE__.segment.rsc +4 -4
- package/frontend-build/.next/server/app/login.segments/login.segment.rsc +3 -3
- package/frontend-build/.next/server/app/page.js +1 -1
- package/frontend-build/.next/server/app/page.js.nft.json +1 -1
- package/frontend-build/.next/server/app/page_client-reference-manifest.js +1 -1
- package/frontend-build/.next/server/app/settings/[tab]/page.js +1 -1
- package/frontend-build/.next/server/app/settings/[tab]/page.js.nft.json +1 -1
- package/frontend-build/.next/server/app/settings/[tab]/page_client-reference-manifest.js +1 -1
- package/frontend-build/.next/server/app-paths-manifest.json +2 -0
- package/frontend-build/.next/server/chunks/0zjb_server_app_api_orchestrations_runs_[run_id]_events_stream_route_actions_0~mhgd~.js +3 -0
- package/frontend-build/.next/server/chunks/[root-of-the-server]__0f5cyte._.js +3 -0
- package/frontend-build/.next/server/chunks/[root-of-the-server]__0fa0k5_._.js +3 -0
- package/frontend-build/.next/server/chunks/_next-internal_server_app_api_notifications_stream_route_actions_109.jhp.js +3 -0
- package/frontend-build/.next/server/chunks/ssr/[root-of-the-server]__0.9mzsa._.js +3 -0
- package/frontend-build/.next/server/chunks/ssr/{[root-of-the-server]__0wxl56w._.js → [root-of-the-server]__05u5pmw._.js} +2 -2
- package/frontend-build/.next/server/chunks/ssr/[root-of-the-server]__0fo_bex._.js +1 -1
- package/frontend-build/.next/server/chunks/ssr/[root-of-the-server]__0n2i~oj._.js +3 -0
- package/frontend-build/.next/server/chunks/ssr/{[root-of-the-server]__10.-84t._.js → [root-of-the-server]__10jhy6u._.js} +2 -2
- package/frontend-build/.next/server/chunks/ssr/_0.27p4o._.js +4 -0
- package/frontend-build/.next/server/chunks/ssr/_0b~n.nn._.js +15 -15
- package/frontend-build/.next/server/chunks/ssr/node_modules_lucide-react_dist_esm_icons_12ubua1._.js +3 -0
- package/frontend-build/.next/server/chunks/ssr/src_app_page_tsx_0ss2.w7._.js +1 -1
- package/frontend-build/.next/server/functions-config-manifest.json +6 -0
- package/frontend-build/.next/server/middleware-build-manifest.js +3 -3
- package/frontend-build/.next/server/pages/404.html +1 -1
- package/frontend-build/.next/server/pages/500.html +1 -1
- package/frontend-build/.next/server/server-reference-manifest.js +1 -1
- package/frontend-build/.next/server/server-reference-manifest.json +1 -1
- package/frontend-build/.next/static/chunks/{16j4jub4qpyf1.js → 02hfac~emo77v.js} +15 -15
- package/frontend-build/.next/static/chunks/047the8jlj6un.css +1 -0
- package/frontend-build/.next/static/chunks/0e_m35~cfcbua.js +1 -0
- package/frontend-build/.next/static/chunks/{0cwgnxu88mzyi.js → 0j-zkf3m3ioxl.js} +1 -1
- package/frontend-build/.next/static/chunks/0jo8lflqyoi98.js +1 -0
- package/frontend-build/.next/static/chunks/0qsyb4u8ttd2i.js +1 -0
- package/frontend-build/.next/static/chunks/{0g7bk6lyj687k.js → 0~o3t9bm0eiky.js} +1 -1
- package/frontend-build/.next/static/chunks/11a8am5wux-qu.js +2 -0
- package/frontend-build/.next/static/chunks/16~0az_az7p4a.js +1 -0
- package/package.json +4 -2
- package/frontend-build/.next/server/chunks/ssr/[root-of-the-server]__0_30ktt._.js +0 -3
- package/frontend-build/.next/server/chunks/ssr/[root-of-the-server]__0rj.gz.._.js +0 -3
- package/frontend-build/.next/server/chunks/ssr/_0rwng5.._.js +0 -4
- package/frontend-build/.next/server/chunks/ssr/node_modules_lucide-react_dist_esm_0__0o-2._.js +0 -3
- package/frontend-build/.next/static/chunks/03qf6ywkkwh~r.js +0 -1
- package/frontend-build/.next/static/chunks/0560sc9qpz__k.js +0 -2
- package/frontend-build/.next/static/chunks/0f8pg6_soqwt2.js +0 -1
- package/frontend-build/.next/static/chunks/0k0-nxx35rq9v.js +0 -1
- package/frontend-build/.next/static/chunks/0wtv8wcl1tn.d.js +0 -1
- package/frontend-build/.next/static/chunks/166_fx4i~0.31.css +0 -1
- /package/frontend-build/.next/static/{ZnXvTR9Wo4Qc1Vgx3ou4L → 08oDqCbK0BRht0JB9AgpU}/_buildManifest.js +0 -0
- /package/frontend-build/.next/static/{ZnXvTR9Wo4Qc1Vgx3ou4L → 08oDqCbK0BRht0JB9AgpU}/_clientMiddlewareManifest.js +0 -0
- /package/frontend-build/.next/static/{ZnXvTR9Wo4Qc1Vgx3ou4L → 08oDqCbK0BRht0JB9AgpU}/_ssgManifest.js +0 -0
package/backend/core/models.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|