@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.
@@ -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
- msg_id = client.execute(code, stop_on_error=True, allow_stdin=False)
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 = client.get_iopub_msg(timeout=0.2)
162
- if message.get("parent_header", {}).get("msg_id") == msg_id:
163
- msg_type = message.get("header", {}).get("msg_type")
164
- if msg_type == "status" and message.get("content", {}).get("execution_state") == "idle":
165
- idle = True
166
- output = output_from_message(message)
167
- if output:
168
- if output.get("outputType") == "clear_output":
169
- emit({"type": "output.clear", "cell_id": cell_id, "generation": generation, "wait": output.get("wait", False)})
170
- else:
171
- emit({"type": "output", "cell_id": cell_id, "generation": generation, "output": output})
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
- shell = client.get_shell_msg(timeout=0.01)
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
- if KM is not None:
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.265",
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.265",
57
+ "@whalent/agent-core": "0.3.267",
58
58
  "node-pty": "^1.1.0"
59
59
  },
60
60
  "devDependencies": {