@whalent/agent 0.3.263 → 0.3.265

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.
@@ -19,6 +19,7 @@ import traceback
19
19
  from typing import Any
20
20
 
21
21
  from jupyter_client import KernelManager
22
+ from jupyter_client.kernelspec import KernelSpec
22
23
 
23
24
 
24
25
  WRITE_LOCK = threading.Lock()
@@ -75,10 +76,19 @@ def start_kernel() -> dict[str, Any]:
75
76
  return safe_status()
76
77
  STATE = "starting"
77
78
  manager = KernelManager()
78
- # The sidecar itself runs under the selected interpreter. Explicitly
79
- # pin ipykernel to the same executable instead of trusting a global spec.
80
- manager.kernel_cmd = [sys.executable, "-m", "ipykernel_launcher", "-f", "{connection_file}"]
81
- manager.start_kernel()
79
+ # The sidecar itself runs under the selected interpreter. Pin ipykernel
80
+ # to the same executable via an explicit KernelSpec: assigning
81
+ # `manager.kernel_cmd` is a silent no-op on jupyter_client >= 7 (the
82
+ # trait is gone), while `_kernel_spec` is the cache slot behind the
83
+ # KernelManager.kernel_spec property and keeps its meaning on 5.x-8.x.
84
+ manager._kernel_spec = KernelSpec(
85
+ argv=[sys.executable, "-m", "ipykernel_launcher", "-f", "{connection_file}"],
86
+ display_name="Whalent (pinned)",
87
+ language="python",
88
+ )
89
+ # Kernel stdout/stderr go to the sidecar's stderr so raw fd writes from
90
+ # user code can never corrupt the NDJSON protocol on stdout.
91
+ manager.start_kernel(stdout=sys.stderr, stderr=sys.stderr)
82
92
  client = manager.client()
83
93
  client.start_channels()
84
94
  client.wait_for_ready(timeout=30)
@@ -127,16 +137,21 @@ def output_from_message(message: dict[str, Any]) -> dict[str, Any] | None:
127
137
 
128
138
  def execute_worker(request_id: str, cell_id: str, code: str, timeout_seconds: float) -> None:
129
139
  global STATE, CURRENT_CELL
140
+ generation = GENERATION
130
141
  try:
131
142
  start_kernel()
132
143
  with KERNEL_LOCK:
144
+ # Capture the generation this execute belongs to (start_kernel may
145
+ # have revived a dead kernel and bumped it); every frame below is
146
+ # stamped with it so the daemon can fence stale generations.
147
+ generation = GENERATION
133
148
  client = KC
134
149
  if client is None:
135
150
  raise RuntimeError("kernel client unavailable")
136
151
  msg_id = client.execute(code, stop_on_error=True, allow_stdin=False)
137
- STATE = "busy"
138
- CURRENT_CELL = cell_id
139
- emit({"type": "execution.started", "request_id": request_id, "cell_id": cell_id, **safe_status()})
152
+ STATE = "busy"
153
+ CURRENT_CELL = cell_id
154
+ emit({"type": "execution.started", "request_id": request_id, "cell_id": cell_id, **safe_status(), "generation": generation})
140
155
  deadline = time.monotonic() + timeout_seconds
141
156
  execution_count = None
142
157
  shell_done = False
@@ -151,9 +166,9 @@ def execute_worker(request_id: str, cell_id: str, code: str, timeout_seconds: fl
151
166
  output = output_from_message(message)
152
167
  if output:
153
168
  if output.get("outputType") == "clear_output":
154
- emit({"type": "output.clear", "cell_id": cell_id, "wait": output.get("wait", False)})
169
+ emit({"type": "output.clear", "cell_id": cell_id, "generation": generation, "wait": output.get("wait", False)})
155
170
  else:
156
- emit({"type": "output", "cell_id": cell_id, "output": output})
171
+ emit({"type": "output", "cell_id": cell_id, "generation": generation, "output": output})
157
172
  except queue.Empty:
158
173
  pass
159
174
  if not shell_done:
@@ -172,8 +187,10 @@ def execute_worker(request_id: str, cell_id: str, code: str, timeout_seconds: fl
172
187
  except Exception:
173
188
  pass
174
189
  raise TimeoutError(f"cell execution exceeded {int(timeout_seconds)} seconds")
175
- STATE = "idle"
176
- CURRENT_CELL = None
190
+ with KERNEL_LOCK:
191
+ if CURRENT_CELL == cell_id:
192
+ STATE = "idle"
193
+ CURRENT_CELL = None
177
194
  emit({
178
195
  "type": "execution.reply",
179
196
  "request_id": request_id,
@@ -181,10 +198,15 @@ def execute_worker(request_id: str, cell_id: str, code: str, timeout_seconds: fl
181
198
  "cell_id": cell_id,
182
199
  "execution_count": execution_count,
183
200
  **safe_status(),
201
+ "generation": generation,
184
202
  })
185
203
  except Exception as exc:
186
- STATE = "idle" if KM is not None and KM.is_alive() else "dead"
187
- CURRENT_CELL = None
204
+ # Only release the claim if this worker still owns it: a restart may
205
+ # already have handed the kernel to a newer execute.
206
+ with KERNEL_LOCK:
207
+ if CURRENT_CELL == cell_id:
208
+ STATE = "idle" if KM is not None and KM.is_alive() else "dead"
209
+ CURRENT_CELL = None
188
210
  emit({
189
211
  "type": "execution.reply",
190
212
  "request_id": request_id,
@@ -192,9 +214,8 @@ def execute_worker(request_id: str, cell_id: str, code: str, timeout_seconds: fl
192
214
  "cell_id": cell_id,
193
215
  "error": {"message": str(exc)},
194
216
  **safe_status(),
217
+ "generation": generation,
195
218
  })
196
- finally:
197
- CURRENT_CELL = None
198
219
 
199
220
 
200
221
  def interrupt_kernel() -> dict[str, Any]:
@@ -234,7 +255,7 @@ def shutdown_kernel() -> dict[str, Any]:
234
255
 
235
256
 
236
257
  def handle(message: dict[str, Any]) -> None:
237
- global GENERATION
258
+ global STATE, CURRENT_CELL
238
259
  request_id = str(message.get("id", ""))
239
260
  method = str(message.get("method", ""))
240
261
  params = message.get("params") if isinstance(message.get("params"), dict) else {}
@@ -246,11 +267,24 @@ def handle(message: dict[str, Any]) -> None:
246
267
  code = str(params.get("code", ""))
247
268
  if not cell_id:
248
269
  raise ValueError("cell_id required")
249
- threading.Thread(
250
- target=execute_worker,
251
- args=(request_id, cell_id, code, max(1.0, min(float(params.get("timeout_seconds", 1800)), 3600.0))),
252
- daemon=True,
253
- ).start()
270
+ with KERNEL_LOCK:
271
+ if CURRENT_CELL is not None:
272
+ raise ValueError(f"kernel busy: cell {CURRENT_CELL} still running")
273
+ # Claim the kernel before the worker thread exists so two
274
+ # racing execute requests can never drain IOPub concurrently.
275
+ STATE = "busy"
276
+ CURRENT_CELL = cell_id
277
+ try:
278
+ threading.Thread(
279
+ target=execute_worker,
280
+ args=(request_id, cell_id, code, max(1.0, min(float(params.get("timeout_seconds", 1800)), 3600.0))),
281
+ daemon=True,
282
+ ).start()
283
+ except BaseException:
284
+ with KERNEL_LOCK:
285
+ STATE = "idle" if KM is not None and KM.is_alive() else "dead"
286
+ CURRENT_CELL = None
287
+ raise
254
288
  elif method == "interrupt":
255
289
  reply(request_id, True, interrupt_kernel())
256
290
  elif method == "restart":
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@whalent/agent",
3
- "version": "0.3.263",
3
+ "version": "0.3.265",
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.263",
57
+ "@whalent/agent-core": "0.3.265",
58
58
  "node-pty": "^1.1.0"
59
59
  },
60
60
  "devDependencies": {