@whalent/agent 0.3.265 → 0.3.267
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/dist/core.cjs +7 -7
- package/dist/jupyter_sidecar.py +171 -14
- package/package.json +2 -2
package/dist/jupyter_sidecar.py
CHANGED
|
@@ -8,6 +8,7 @@ kernel executable are guaranteed to belong to that environment.
|
|
|
8
8
|
|
|
9
9
|
from __future__ import annotations
|
|
10
10
|
|
|
11
|
+
import base64
|
|
11
12
|
import json
|
|
12
13
|
import os
|
|
13
14
|
import queue
|
|
@@ -24,6 +25,12 @@ from jupyter_client.kernelspec import KernelSpec
|
|
|
24
25
|
|
|
25
26
|
WRITE_LOCK = threading.Lock()
|
|
26
27
|
KERNEL_LOCK = threading.RLock()
|
|
28
|
+
# Serializes every raw operation on the shell zmq socket: the execute worker
|
|
29
|
+
# polls replies while the stdin thread may send comm messages, and zmq sockets
|
|
30
|
+
# are not thread-safe.
|
|
31
|
+
SHELL_LOCK = threading.Lock()
|
|
32
|
+
# Guards EXECUTIONS, the parent-msg_id routing table the IOPub pump consults.
|
|
33
|
+
EXEC_LOCK = threading.Lock()
|
|
27
34
|
KM: KernelManager | None = None
|
|
28
35
|
KC: Any = None
|
|
29
36
|
GENERATION = 0
|
|
@@ -32,6 +39,12 @@ CURRENT_CELL: str | None = None
|
|
|
32
39
|
SHUTTING_DOWN = False
|
|
33
40
|
MAX_FRAME_BYTES = 8 * 1024 * 1024
|
|
34
41
|
|
|
42
|
+
# IOPub message types routed to the owning execution (same set the old
|
|
43
|
+
# in-worker polling loop handled); everything else with an unknown parent is
|
|
44
|
+
# dropped, exactly like the old parent-msg_id filter did.
|
|
45
|
+
ROUTED_IOPUB_TYPES = {"stream", "display_data", "execute_result", "error", "clear_output", "status"}
|
|
46
|
+
COMM_FRAME_TYPES = {"comm_open": "comm.open", "comm_msg": "comm.msg", "comm_close": "comm.close"}
|
|
47
|
+
|
|
35
48
|
|
|
36
49
|
def emit(payload: dict[str, Any]) -> None:
|
|
37
50
|
encoded = json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
|
|
@@ -74,6 +87,9 @@ def start_kernel() -> dict[str, Any]:
|
|
|
74
87
|
with KERNEL_LOCK:
|
|
75
88
|
if KM is not None and KM.is_alive():
|
|
76
89
|
return safe_status()
|
|
90
|
+
# A kernel that died on its own leaves a pump attached to dead
|
|
91
|
+
# channels; retire it before wiring up the replacement.
|
|
92
|
+
stop_iopub_pump()
|
|
77
93
|
STATE = "starting"
|
|
78
94
|
manager = KernelManager()
|
|
79
95
|
# The sidecar itself runs under the selected interpreter. Pin ipykernel
|
|
@@ -95,6 +111,10 @@ def start_kernel() -> dict[str, Any]:
|
|
|
95
111
|
KM = manager
|
|
96
112
|
KC = client
|
|
97
113
|
GENERATION += 1
|
|
114
|
+
# The pump outlives individual executes: it delivers kernel-initiated
|
|
115
|
+
# comm messages even while the kernel is idle. Started after the
|
|
116
|
+
# generation bump so its comm frames carry the new generation.
|
|
117
|
+
start_iopub_pump(client)
|
|
98
118
|
STATE = "idle"
|
|
99
119
|
emit({"type": "kernel.status", **safe_status()})
|
|
100
120
|
return safe_status()
|
|
@@ -135,9 +155,106 @@ def output_from_message(message: dict[str, Any]) -> dict[str, Any] | None:
|
|
|
135
155
|
return None
|
|
136
156
|
|
|
137
157
|
|
|
158
|
+
class ExecutionContext:
|
|
159
|
+
"""Mailbox for one in-flight execute; the IOPub pump routes matching-parent
|
|
160
|
+
messages here and a ``None`` sentinel means the kernel went away."""
|
|
161
|
+
|
|
162
|
+
__slots__ = ("cell_id", "generation", "events")
|
|
163
|
+
|
|
164
|
+
def __init__(self, cell_id: str, generation: int) -> None:
|
|
165
|
+
self.cell_id = cell_id
|
|
166
|
+
self.generation = generation
|
|
167
|
+
self.events: queue.Queue[dict[str, Any] | None] = queue.Queue()
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
EXECUTIONS: dict[str, ExecutionContext] = {}
|
|
171
|
+
PUMP_STOP: threading.Event | None = None
|
|
172
|
+
PUMP_THREAD: threading.Thread | None = None
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def encode_buffers(buffers: Any) -> list[str]:
|
|
176
|
+
encoded: list[str] = []
|
|
177
|
+
for item in buffers or []:
|
|
178
|
+
try:
|
|
179
|
+
encoded.append(base64.b64encode(bytes(item)).decode("ascii"))
|
|
180
|
+
except Exception:
|
|
181
|
+
continue
|
|
182
|
+
return encoded
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def iopub_pump(client: Any, stop: threading.Event) -> None:
|
|
186
|
+
"""Resident IOPub reader for one kernel incarnation.
|
|
187
|
+
|
|
188
|
+
Runs from right after start_kernel() until shutdown/restart stops it, so
|
|
189
|
+
kernel-initiated comm traffic is delivered even while no cell executes.
|
|
190
|
+
Execution outputs keep their old semantics: they are routed by parent
|
|
191
|
+
msg_id to the owning execute worker, which emits them itself.
|
|
192
|
+
"""
|
|
193
|
+
while not stop.is_set():
|
|
194
|
+
try:
|
|
195
|
+
message = client.get_iopub_msg(timeout=0.2)
|
|
196
|
+
except queue.Empty:
|
|
197
|
+
continue
|
|
198
|
+
except Exception:
|
|
199
|
+
if stop.is_set():
|
|
200
|
+
return
|
|
201
|
+
time.sleep(0.05)
|
|
202
|
+
continue
|
|
203
|
+
msg_type = str(message.get("header", {}).get("msg_type", ""))
|
|
204
|
+
if msg_type in COMM_FRAME_TYPES:
|
|
205
|
+
content = message.get("content", {}) if isinstance(message.get("content"), dict) else {}
|
|
206
|
+
frame: dict[str, Any] = {
|
|
207
|
+
"type": COMM_FRAME_TYPES[msg_type],
|
|
208
|
+
"comm_id": str(content.get("comm_id", "")),
|
|
209
|
+
"data": content.get("data", {}),
|
|
210
|
+
"buffers": encode_buffers(message.get("buffers")),
|
|
211
|
+
"generation": GENERATION,
|
|
212
|
+
}
|
|
213
|
+
if msg_type == "comm_open":
|
|
214
|
+
frame["target_name"] = str(content.get("target_name", ""))
|
|
215
|
+
emit(frame)
|
|
216
|
+
continue
|
|
217
|
+
if msg_type not in ROUTED_IOPUB_TYPES:
|
|
218
|
+
continue
|
|
219
|
+
parent = str(message.get("parent_header", {}).get("msg_id", ""))
|
|
220
|
+
with EXEC_LOCK:
|
|
221
|
+
context = EXECUTIONS.get(parent)
|
|
222
|
+
if context is not None:
|
|
223
|
+
context.events.put(message)
|
|
224
|
+
# No registered parent: drop, matching the old execute-window filter.
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def start_iopub_pump(client: Any) -> None:
|
|
228
|
+
global PUMP_STOP, PUMP_THREAD
|
|
229
|
+
stop = threading.Event()
|
|
230
|
+
thread = threading.Thread(target=iopub_pump, args=(client, stop), daemon=True)
|
|
231
|
+
PUMP_STOP = stop
|
|
232
|
+
PUMP_THREAD = thread
|
|
233
|
+
thread.start()
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def stop_iopub_pump() -> None:
|
|
237
|
+
global PUMP_STOP, PUMP_THREAD
|
|
238
|
+
stop, thread = PUMP_STOP, PUMP_THREAD
|
|
239
|
+
PUMP_STOP = None
|
|
240
|
+
PUMP_THREAD = None
|
|
241
|
+
if stop is not None:
|
|
242
|
+
stop.set()
|
|
243
|
+
if thread is not None and thread is not threading.current_thread():
|
|
244
|
+
thread.join(timeout=2.0)
|
|
245
|
+
# Wake blocked execute workers so they fail fast instead of sitting out
|
|
246
|
+
# their full timeout (and later interrupting an innocent new kernel).
|
|
247
|
+
with EXEC_LOCK:
|
|
248
|
+
contexts = list(EXECUTIONS.values())
|
|
249
|
+
for context in contexts:
|
|
250
|
+
context.events.put(None)
|
|
251
|
+
|
|
252
|
+
|
|
138
253
|
def execute_worker(request_id: str, cell_id: str, code: str, timeout_seconds: float) -> None:
|
|
139
254
|
global STATE, CURRENT_CELL
|
|
140
255
|
generation = GENERATION
|
|
256
|
+
msg_id = ""
|
|
257
|
+
context: ExecutionContext | None = None
|
|
141
258
|
try:
|
|
142
259
|
start_kernel()
|
|
143
260
|
with KERNEL_LOCK:
|
|
@@ -148,7 +265,12 @@ def execute_worker(request_id: str, cell_id: str, code: str, timeout_seconds: fl
|
|
|
148
265
|
client = KC
|
|
149
266
|
if client is None:
|
|
150
267
|
raise RuntimeError("kernel client unavailable")
|
|
151
|
-
|
|
268
|
+
# Register the routing entry atomically around the send: the pump
|
|
269
|
+
# must never observe an IOPub reply whose parent is not yet routed.
|
|
270
|
+
with EXEC_LOCK:
|
|
271
|
+
msg_id = client.execute(code, stop_on_error=True, allow_stdin=False)
|
|
272
|
+
context = ExecutionContext(cell_id, generation)
|
|
273
|
+
EXECUTIONS[msg_id] = context
|
|
152
274
|
STATE = "busy"
|
|
153
275
|
CURRENT_CELL = cell_id
|
|
154
276
|
emit({"type": "execution.started", "request_id": request_id, "cell_id": cell_id, **safe_status(), "generation": generation})
|
|
@@ -158,22 +280,24 @@ def execute_worker(request_id: str, cell_id: str, code: str, timeout_seconds: fl
|
|
|
158
280
|
idle = False
|
|
159
281
|
while time.monotonic() < deadline and not (shell_done and idle):
|
|
160
282
|
try:
|
|
161
|
-
message =
|
|
162
|
-
if message
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
283
|
+
message = context.events.get(timeout=0.2)
|
|
284
|
+
if message is None:
|
|
285
|
+
raise RuntimeError("kernel was shut down or restarted during execution")
|
|
286
|
+
msg_type = message.get("header", {}).get("msg_type")
|
|
287
|
+
if msg_type == "status" and message.get("content", {}).get("execution_state") == "idle":
|
|
288
|
+
idle = True
|
|
289
|
+
output = output_from_message(message)
|
|
290
|
+
if output:
|
|
291
|
+
if output.get("outputType") == "clear_output":
|
|
292
|
+
emit({"type": "output.clear", "cell_id": cell_id, "generation": generation, "wait": output.get("wait", False)})
|
|
293
|
+
else:
|
|
294
|
+
emit({"type": "output", "cell_id": cell_id, "generation": generation, "output": output})
|
|
172
295
|
except queue.Empty:
|
|
173
296
|
pass
|
|
174
297
|
if not shell_done:
|
|
175
298
|
try:
|
|
176
|
-
|
|
299
|
+
with SHELL_LOCK:
|
|
300
|
+
shell = client.get_shell_msg(timeout=0.01)
|
|
177
301
|
if shell.get("parent_header", {}).get("msg_id") == msg_id:
|
|
178
302
|
execution_count = shell.get("content", {}).get("execution_count")
|
|
179
303
|
shell_done = True
|
|
@@ -182,7 +306,9 @@ def execute_worker(request_id: str, cell_id: str, code: str, timeout_seconds: fl
|
|
|
182
306
|
if not (shell_done and idle):
|
|
183
307
|
try:
|
|
184
308
|
with KERNEL_LOCK:
|
|
185
|
-
|
|
309
|
+
# Interrupt only the kernel this execute ran on; after a
|
|
310
|
+
# restart the same handle points at an innocent successor.
|
|
311
|
+
if KM is not None and GENERATION == generation:
|
|
186
312
|
KM.interrupt_kernel()
|
|
187
313
|
except Exception:
|
|
188
314
|
pass
|
|
@@ -216,6 +342,33 @@ def execute_worker(request_id: str, cell_id: str, code: str, timeout_seconds: fl
|
|
|
216
342
|
**safe_status(),
|
|
217
343
|
"generation": generation,
|
|
218
344
|
})
|
|
345
|
+
finally:
|
|
346
|
+
if msg_id:
|
|
347
|
+
with EXEC_LOCK:
|
|
348
|
+
EXECUTIONS.pop(msg_id, None)
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def send_comm(params: dict[str, Any]) -> dict[str, Any]:
|
|
352
|
+
"""Forward one browser-originated comm_msg to the kernel's shell channel."""
|
|
353
|
+
comm_id = str(params.get("comm_id", ""))
|
|
354
|
+
if not comm_id:
|
|
355
|
+
raise ValueError("comm_id required")
|
|
356
|
+
data = params.get("data")
|
|
357
|
+
if data is None:
|
|
358
|
+
data = {}
|
|
359
|
+
if not isinstance(data, dict):
|
|
360
|
+
raise ValueError("comm data must be an object")
|
|
361
|
+
buffers = [base64.b64decode(str(item)) for item in params.get("buffers") or []]
|
|
362
|
+
with KERNEL_LOCK:
|
|
363
|
+
client = KC
|
|
364
|
+
if client is None or KM is None or not KM.is_alive():
|
|
365
|
+
raise RuntimeError("kernel is not running")
|
|
366
|
+
msg = client.session.msg("comm_msg", {"comm_id": comm_id, "data": data})
|
|
367
|
+
# jupyter_client 8.9.1: ZMQSocketChannel.send() takes no buffers
|
|
368
|
+
# argument, but Session.send() does — target the shell socket directly.
|
|
369
|
+
with SHELL_LOCK:
|
|
370
|
+
client.session.send(client.shell_channel.socket, msg, buffers=buffers or None)
|
|
371
|
+
return {"sent": True, "comm_id": comm_id, "buffers": len(buffers)}
|
|
219
372
|
|
|
220
373
|
|
|
221
374
|
def interrupt_kernel() -> dict[str, Any]:
|
|
@@ -235,6 +388,8 @@ def shutdown_kernel() -> dict[str, Any]:
|
|
|
235
388
|
client, manager = KC, KM
|
|
236
389
|
KC = None
|
|
237
390
|
KM = None
|
|
391
|
+
# Stop the pump before closing the channels it reads from.
|
|
392
|
+
stop_iopub_pump()
|
|
238
393
|
if client is not None:
|
|
239
394
|
try:
|
|
240
395
|
client.stop_channels()
|
|
@@ -285,6 +440,8 @@ def handle(message: dict[str, Any]) -> None:
|
|
|
285
440
|
STATE = "idle" if KM is not None and KM.is_alive() else "dead"
|
|
286
441
|
CURRENT_CELL = None
|
|
287
442
|
raise
|
|
443
|
+
elif method == "comm":
|
|
444
|
+
reply(request_id, True, send_comm(params))
|
|
288
445
|
elif method == "interrupt":
|
|
289
446
|
reply(request_id, True, interrupt_kernel())
|
|
290
447
|
elif method == "restart":
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@whalent/agent",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.267",
|
|
4
4
|
"description": "Stable wrapper for Whalent Agent core runtime",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.cjs",
|
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
"yaml": "^2.4.0"
|
|
55
55
|
},
|
|
56
56
|
"optionalDependencies": {
|
|
57
|
-
"@whalent/agent-core": "0.3.
|
|
57
|
+
"@whalent/agent-core": "0.3.267",
|
|
58
58
|
"node-pty": "^1.1.0"
|
|
59
59
|
},
|
|
60
60
|
"devDependencies": {
|