verifiers 0.3.2.dev99__py3-none-any.whl → 0.3.2.dev101__py3-none-any.whl

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.
@@ -483,6 +483,7 @@ class InterceptionServer(Interception):
483
483
  acp=acp,
484
484
  )
485
485
  )
486
+ session.trace.notify()
486
487
 
487
488
  async def handle_request(
488
489
  self, request: web.Request, dialect: Dialect
@@ -660,6 +661,9 @@ class InterceptionServer(Interception):
660
661
  return web.json_response(dialect.error_body(str(error)), status=400)
661
662
  except RolloutError as error:
662
663
  return self._fail(session, dialect, error)
664
+ # The tail is what the harness added since the last turn (tool results, user
665
+ # turns): live watchers see it now rather than with the model's reply.
666
+ session.trace.preview(turn, turn.tail)
663
667
 
664
668
  inspect_response = bool(session.response_interceptors or session.response_stops)
665
669
  if relay_streaming:
@@ -782,6 +786,8 @@ class InterceptionServer(Interception):
782
786
  error = e
783
787
  raise
784
788
  finally:
789
+ if node is None:
790
+ turn.abandon()
785
791
  # The turn's one per-exchange record: settings, timing, outcome, and
786
792
  # the error that ended it (if any).
787
793
  self.record_call(
@@ -1047,6 +1053,8 @@ class InterceptionServer(Interception):
1047
1053
  error = e
1048
1054
  raise
1049
1055
  finally:
1056
+ if node is None:
1057
+ turn.abandon()
1050
1058
  # The turn's one per-exchange record: settings, timing, outcome, and the
1051
1059
  # error that ended it (if any).
1052
1060
  self.record_call(
verifiers/v1/rollout.py CHANGED
@@ -180,6 +180,7 @@ class Rollout:
180
180
  proceed; a setup failure is captured onto the trace."""
181
181
  self._opened = True
182
182
  self.trace.timing.boot.start = time.time()
183
+ self.trace.notify()
183
184
  if self._borrowed_runtime is None:
184
185
  self.runtime = make_runtime(self.runtime_config, name=self.trace.id)
185
186
  elif self._borrowed_runtime is not None and self._borrowed_runtime.stopped:
@@ -219,6 +220,7 @@ class Rollout:
219
220
  now = time.time()
220
221
  self.trace.timing.boot.end = now
221
222
  self.trace.timing.setup.start = now
223
+ self.trace.notify()
222
224
  # Task setup and harness provisioning share one setup-stage deadline.
223
225
  setup_deadline = (
224
226
  None
@@ -342,6 +344,7 @@ class Rollout:
342
344
  now = time.time()
343
345
  self.trace.timing.setup.end = now
344
346
  self.trace.timing.agent.start = now
347
+ self.trace.notify()
345
348
  return not self._session.stopped
346
349
 
347
350
  async def step(self, messages: Messages | None = None) -> bool:
@@ -464,6 +467,7 @@ class Rollout:
464
467
  finally:
465
468
  if trace.timing.agent.start and not trace.timing.agent.end:
466
469
  trace.timing.agent.end = time.time()
470
+ trace.notify()
467
471
  if not self._failed and self._opened:
468
472
  assert runtime is not None
469
473
  trace.timing.finalize.start = time.time()
@@ -479,6 +483,7 @@ class Rollout:
479
483
  now = time.time()
480
484
  trace.timing.finalize.end = now
481
485
  trace.timing.scoring.start = now
486
+ trace.notify()
482
487
  async with boundary(TaskError, "scoring"):
483
488
  # Cross-trace judgement runs later, after the runtime is gone.
484
489
  await asyncio.wait_for(
@@ -489,6 +494,7 @@ class Rollout:
489
494
  self._timeouts.scoring,
490
495
  )
491
496
  trace.timing.scoring.end = time.time()
497
+ trace.notify()
492
498
  except Exception as e: # noqa: BLE001 - finalize boundary records every rollout failure
493
499
  self.fail(e)
494
500
  finally:
@@ -1,4 +1,5 @@
1
1
  from verifiers.v1.serve.client import EnvClient
2
+ from verifiers.v1.serve.delta import EpisodeAssembly
2
3
  from verifiers.v1.serve.pool import EnvServerPool, env_config_data, serve_env
3
4
  from verifiers.v1.serve.server import EnvServer
4
5
  from verifiers.v1.serve.types import (
@@ -12,6 +13,7 @@ __all__ = [
12
13
  "EnvClient",
13
14
  "EnvServer",
14
15
  "EnvServerPool",
16
+ "EpisodeAssembly",
15
17
  "HealthRequest",
16
18
  "HealthResponse",
17
19
  "RunRequest",
@@ -1,11 +1,13 @@
1
1
  """ZMQ client for the env server.
2
2
 
3
- A DEALER socket + msgpack, with a single receive loop matching responses to
4
- per-request futures by `request_id`. Speaks the typed pydantic request/response
5
- models (`serve/types.py`) end-to-end: a request is `model_dump`ed onto the wire
6
- and the reply is `model_validate`d back — `Trace`s come back typed as
7
- `Trace[WireTaskData]` (non-strict task, so env fields survive without importing the
8
- env). Health is just another request (no dedicated probe thread).
3
+ A DEALER socket + msgpack, with a single receive loop matching replies to
4
+ per-request futures by `request_id` and routing a run's `delta` frames to its
5
+ assembly (`serve/delta.py`). Speaks the typed pydantic request/response models
6
+ (`serve/types.py`) end-to-end: a request is `model_dump`ed onto the wire; a run's
7
+ traces stream back turn by turn and the assembled record is `model_validate`d into a
8
+ `WireEpisode` once the reply lands — `Trace`s typed as `Trace[WireTaskData]`
9
+ (non-strict task, so env fields survive without importing the env). Health is just
10
+ another request (no dedicated probe thread).
9
11
  """
10
12
 
11
13
  import asyncio
@@ -13,6 +15,7 @@ import contextlib
13
15
  import logging
14
16
  import time
15
17
  import uuid
18
+ from collections.abc import Callable
16
19
  from typing import TypeVar
17
20
 
18
21
  import msgpack
@@ -21,6 +24,7 @@ import zmq.asyncio
21
24
 
22
25
  from verifiers.v1.configs.client import ClientConfig
23
26
  from verifiers.v1.episode import WireEpisode
27
+ from verifiers.v1.serve.delta import EpisodeAssembly, unpack
24
28
  from verifiers.v1.serve.types import (
25
29
  BaseRequest,
26
30
  BaseResponse,
@@ -47,6 +51,7 @@ class EnvClient:
47
51
  self.socket.setsockopt(zmq.RCVHWM, 0)
48
52
  self.socket.connect(address)
49
53
  self._pending: dict[str, asyncio.Future[bytes]] = {}
54
+ self._deltas: dict[str, Callable[[bytes], None]] = {}
50
55
  # Strong refs to in-flight fire-and-forget cancels: the loop only
51
56
  # holds weak references to tasks, so an unreferenced one can be
52
57
  # garbage-collected before it ever sends
@@ -59,27 +64,48 @@ class EnvClient:
59
64
  self._receiver = asyncio.create_task(self._receive_loop())
60
65
 
61
66
  async def _receive_loop(self) -> None:
67
+ # The one receiver serves every in-flight request, so a bad frame or a delta
68
+ # handler that raises is logged and skipped rather than allowed to end the loop.
62
69
  while True:
63
70
  try:
64
- request_id_bytes, data = await self.socket.recv_multipart()
71
+ frames = await self.socket.recv_multipart()
65
72
  except asyncio.CancelledError:
66
73
  break
67
- future = self._pending.pop(request_id_bytes.decode(), None)
68
- if future is not None and not future.done():
69
- future.set_result(data)
74
+ try:
75
+ if len(frames) != 3:
76
+ raise ValueError(
77
+ f"expected [request_id, kind, data], got {len(frames)} frames - "
78
+ "is the env server speaking the same serve protocol?"
79
+ )
80
+ request_id_bytes, kind, data = frames
81
+ request_id = request_id_bytes.decode()
82
+ if kind == b"delta":
83
+ on_delta = self._deltas.get(request_id)
84
+ if on_delta is not None:
85
+ on_delta(data)
86
+ continue
87
+ future = self._pending.pop(request_id, None)
88
+ if future is not None and not future.done():
89
+ future.set_result(data)
90
+ except Exception: # keep receiving for the other requests
91
+ logger.warning("dropping a malformed env-server frame", exc_info=True)
70
92
 
71
93
  async def _request(
72
94
  self,
73
95
  request: BaseRequest,
74
96
  response_type: type[ResponseT],
75
97
  timeout: float | None = None,
98
+ on_delta: Callable[[bytes], None] | None = None,
76
99
  ) -> ResponseT:
77
100
  """Send a typed request and validate the reply into `response_type`. A
78
- `timeout` is only used for health polling — rollouts run untimed."""
101
+ `timeout` is only used for health polling — rollouts run untimed. `on_delta`
102
+ receives each `delta` frame the request streams before its reply."""
79
103
  self._ensure_receiver()
80
104
  request_id = uuid.uuid4().hex
81
105
  future: asyncio.Future[bytes] = asyncio.get_running_loop().create_future()
82
106
  self._pending[request_id] = future
107
+ if on_delta is not None:
108
+ self._deltas[request_id] = on_delta
83
109
  payload = msgpack.packb(request.model_dump(mode="json"), use_bin_type=True)
84
110
  await self.socket.send_multipart(
85
111
  [request_id.encode(), request.method.encode(), payload]
@@ -101,31 +127,30 @@ class EnvClient:
101
127
  self._cancel_tasks.add(task)
102
128
  task.add_done_callback(self._cancel_tasks.discard)
103
129
  raise
104
- if response_type is HealthResponse:
105
- response = response_type.model_validate(msgpack.unpackb(data, raw=False))
106
- else:
107
- # Keep large trace replies compact on the loop and expand only one at a time.
108
- await self._decode_slots.acquire()
109
- decoding = asyncio.create_task(
110
- asyncio.to_thread(
111
- lambda: response_type.model_validate(
112
- msgpack.unpackb(data, raw=False)
113
- )
114
- )
115
- )
116
- # Hold the slot until the worker finishes so cancellation cannot overlap decodes.
117
- decoding.add_done_callback(lambda _: self._decode_slots.release())
118
- try:
119
- response = await asyncio.shield(decoding)
120
- except asyncio.CancelledError:
121
- decoding.add_done_callback(
122
- lambda task: None if task.cancelled() else task.exception()
123
- )
124
- raise
130
+ finally:
131
+ self._deltas.pop(request_id, None)
132
+ response = response_type.model_validate(unpack(data))
125
133
  if not response.success:
126
134
  raise RuntimeError(response.error or "env server request failed")
127
135
  return response
128
136
 
137
+ async def _validate_episode(self, record: dict) -> WireEpisode:
138
+ """Type the assembled record off the loop, one episode at a time: a long
139
+ trace is a lot of token spans to validate."""
140
+ await self._decode_slots.acquire()
141
+ decoding = asyncio.create_task(
142
+ asyncio.to_thread(WireEpisode.model_validate, record)
143
+ )
144
+ # Hold the slot until the worker finishes so cancellation cannot overlap decodes.
145
+ decoding.add_done_callback(lambda _: self._decode_slots.release())
146
+ try:
147
+ return await asyncio.shield(decoding)
148
+ except asyncio.CancelledError:
149
+ decoding.add_done_callback(
150
+ lambda task: None if task.cancelled() else task.exception()
151
+ )
152
+ raise
153
+
129
154
  async def _send_cancel(self, run_request_id: str) -> None:
130
155
  """Best-effort server-side abort of an abandoned run."""
131
156
  with contextlib.suppress(Exception):
@@ -164,10 +189,22 @@ class EnvClient:
164
189
  model: str,
165
190
  sampling: SamplingConfig,
166
191
  task_data: dict,
192
+ on_delta: Callable[[dict], None] | None = None,
167
193
  ) -> WireEpisode:
168
194
  """Run one rollout; return its episode record — flat traces (typed
169
195
  `Trace[WireTaskData]`) plus the shared stamp. The server takes the task
170
- itself (`task_data`, its dumped `TaskData`)."""
196
+ itself (`task_data`, its dumped `TaskData`). The traces stream in as the
197
+ rollout runs, one delta per turn or phase change (`serve.delta`); `on_delta`
198
+ sees each as it lands, so a caller can relay or persist the stream. The
199
+ episode returned is assembled from the same deltas."""
200
+ assembly = EpisodeAssembly()
201
+
202
+ def apply(data: bytes) -> None:
203
+ delta = unpack(data)
204
+ assembly.apply(delta)
205
+ if on_delta is not None:
206
+ on_delta(delta)
207
+
171
208
  response = await self._request(
172
209
  RunRequest(
173
210
  task_data=task_data,
@@ -176,8 +213,12 @@ class EnvClient:
176
213
  sampling=sampling,
177
214
  ),
178
215
  RunResponse,
216
+ on_delta=apply,
217
+ )
218
+ assert response.head is not None
219
+ return await self._validate_episode(
220
+ assembly.finish(response.head, response.traces)
179
221
  )
180
- return response.episode
181
222
 
182
223
  async def close(self) -> None:
183
224
  if self._receiver is not None:
@@ -0,0 +1,281 @@
1
+ """Turn-by-turn episode deltas over the env-serve wire.
2
+
3
+ The worker streams a served episode as it grows. Each trace announces its own changes
4
+ (`Trace.notify`, fired by the rollout at every phase change and by the interception proxy
5
+ after every recorded turn); the `DeltaStreamer` then diffs the run's live traces against
6
+ what it has already sent and ships only the new part — the trace header once, then
7
+ appended nodes / calls / errors, semantic links landing on earlier nodes, the scalar
8
+ fields whose value changed (timing spans, stop condition, rewards, ...), and the
9
+ `pending` preview — the messages of the request in flight that no node holds yet, so a
10
+ watcher sees a tool result before the model has answered it. The `Trace` is
11
+ append-only at turn granularity (a turn's nodes are committed complete, with their
12
+ tokens), so apart from the preview, which a committed turn repeats, every byte of the
13
+ episode crosses the wire once and the stream costs about what a single reply would; the
14
+ reply that ends the run carries only the episode head and per-trace counts the client
15
+ checks its assembly against. A cursor advances only once its delta is on the wire, so a
16
+ send that fails is simply diffed again at the next flush.
17
+
18
+ The client applies deltas into raw dicts (`EpisodeAssembly`) — cheap enough to hand a
19
+ caller after every turn — and validates the assembled record into a `WireEpisode` once,
20
+ when the run's reply lands.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import asyncio
26
+ import copy
27
+ import logging
28
+ from collections.abc import Awaitable, Callable
29
+ from typing import TYPE_CHECKING, Any, Self
30
+
31
+ import msgpack
32
+ from pydantic import BaseModel
33
+
34
+ from verifiers.v1.serve.encoding import msgpack_encoder
35
+
36
+ if TYPE_CHECKING:
37
+ from verifiers.v1.env import RunSlot
38
+ from verifiers.v1.trace import Trace
39
+
40
+ logger = logging.getLogger(__name__)
41
+
42
+ HEADER_FIELDS = ("version", "id", "verifiers", "task")
43
+ """Sent once, when a trace first appears."""
44
+
45
+ LIST_FIELDS = (
46
+ "nodes",
47
+ "calls",
48
+ "errors",
49
+ "extra_usage",
50
+ "request_rewrites",
51
+ "response_rewrites",
52
+ )
53
+ """Append-only on the worker: each delta carries the items past the sent count."""
54
+
55
+ SCALAR_FIELDS = (
56
+ "agent",
57
+ "tools",
58
+ "mm_token_type_id_map",
59
+ "rewards",
60
+ "metrics",
61
+ "info",
62
+ "root_reply",
63
+ "is_completed",
64
+ "ok",
65
+ "stop_condition",
66
+ "timing",
67
+ )
68
+ """Re-sent whole whenever their dumped value changes."""
69
+
70
+
71
+ def pack(payload: Any) -> bytes:
72
+ return msgpack.packb(payload, default=msgpack_encoder, use_bin_type=True)
73
+
74
+
75
+ def unpack(data: bytes) -> Any:
76
+ # `links` is keyed by node index (int).
77
+ return msgpack.unpackb(data, raw=False, strict_map_key=False)
78
+
79
+
80
+ def dump(model: BaseModel, **kwargs: Any) -> dict:
81
+ # Task data and agent configs are subclasses of their declared types; dump the
82
+ # runtime type so env-specific fields survive the wire.
83
+ return model.model_dump(mode="python", serialize_as_any=True, **kwargs)
84
+
85
+
86
+ class TraceCursor:
87
+ """What of one trace has been sent."""
88
+
89
+ def __init__(self) -> None:
90
+ self.sent = dict.fromkeys(LIST_FIELDS, 0)
91
+ self.links: list[int] = []
92
+ """Per sent node, how many of its semantic links went out with or after it."""
93
+ self.scalars: dict[str, bytes] = {}
94
+ self.pending: bytes = pack([])
95
+
96
+
97
+ class DeltaStreamer:
98
+ """Sends a `RunSlot`'s trace changes as deltas, one flush per burst of changes.
99
+
100
+ `watch` subscribes a minted trace (pass it as `run_slot`'s `on_trace`); each
101
+ `Trace.notify` schedules a flush, and changes landing in the same loop iteration
102
+ ride one flush. Use as an async context manager around the rollout: a clean exit
103
+ flushes the final state (the slot then holds the finished episode's traces), a
104
+ cancelled rollout flushes nothing — its client has already gone."""
105
+
106
+ def __init__(self, slot: RunSlot, send: Callable[[bytes], Awaitable[None]]) -> None:
107
+ self.slot = slot
108
+ self.send = send
109
+ self.cursors: dict[str, TraceCursor] = {}
110
+ self._scheduled: asyncio.Handle | None = None
111
+ self._flushes: set[asyncio.Task] = set()
112
+ self._lock = asyncio.Lock()
113
+
114
+ async def __aenter__(self) -> Self:
115
+ return self
116
+
117
+ async def __aexit__(self, exc_type, exc, tb) -> None:
118
+ if self._scheduled is not None:
119
+ self._scheduled.cancel()
120
+ self._scheduled = None
121
+ if exc_type is None:
122
+ # let in-flight flushes finish, then send whatever the run left behind
123
+ await asyncio.gather(*self._flushes, return_exceptions=True)
124
+ await self.flush()
125
+ else:
126
+ for task in self._flushes:
127
+ task.cancel()
128
+
129
+ def watch(self, trace: Trace) -> None:
130
+ trace.watch(self._changed)
131
+ self._changed(trace)
132
+
133
+ def _changed(self, trace: Trace) -> None:
134
+ if self._scheduled is not None:
135
+ return
136
+ self._scheduled = asyncio.get_running_loop().call_soon(self._start_flush)
137
+
138
+ def _start_flush(self) -> None:
139
+ self._scheduled = None
140
+ task = asyncio.create_task(self.flush())
141
+ self._flushes.add(task)
142
+ task.add_done_callback(self._flushes.discard)
143
+
144
+ async def flush(self) -> None:
145
+ # Serialized: deltas must leave in diff order, and the reply after the last.
146
+ async with self._lock:
147
+ for trace_id, delta, cursor in self.diff():
148
+ try:
149
+ await self.send(pack(delta))
150
+ except Exception: # the cursor stays put: the next flush diffs it again
151
+ logger.warning(
152
+ "failed to send delta for %s", trace_id, exc_info=True
153
+ )
154
+ continue
155
+ if cursor is None:
156
+ self.cursors.pop(trace_id, None)
157
+ else:
158
+ self.cursors[trace_id] = cursor
159
+
160
+ def diff(self) -> list[tuple[str, dict, TraceCursor | None]]:
161
+ """Each trace's delta against its sent cursor, with the cursor as it stands once
162
+ that delta is sent (None for a discard). Nothing here is committed: `flush`
163
+ stores a cursor only after its delta left."""
164
+ traces = list(self.slot.traces)
165
+ live = {trace.id for trace in traces}
166
+ deltas: list[tuple[str, dict, TraceCursor | None]] = []
167
+ for trace_id in [trace_id for trace_id in self.cursors if trace_id not in live]:
168
+ # A retried attempt abandons its traces; the client drops them too.
169
+ deltas.append((trace_id, {"trace": trace_id, "discard": True}, None))
170
+ for trace in traces:
171
+ delta: dict[str, Any] = {"trace": trace.id}
172
+ sent = self.cursors.get(trace.id)
173
+ cursor = copy.deepcopy(sent) if sent is not None else TraceCursor()
174
+ if sent is None:
175
+ delta["open"] = dump(trace, include=set(HEADER_FIELDS))
176
+ links: dict[int, list[dict]] = {}
177
+ for index in range(cursor.sent["nodes"]):
178
+ node_links = trace.nodes[index].semantic_parents
179
+ if len(node_links) > cursor.links[index]:
180
+ links[index] = [
181
+ dump(link) for link in node_links[cursor.links[index] :]
182
+ ]
183
+ cursor.links[index] = len(node_links)
184
+ if links:
185
+ delta["links"] = links
186
+ for field in LIST_FIELDS:
187
+ items = getattr(trace, field)
188
+ sent = cursor.sent[field]
189
+ if len(items) > sent:
190
+ delta[field] = [dump(item) for item in items[sent:]]
191
+ cursor.sent[field] = len(items)
192
+ if field == "nodes":
193
+ cursor.links.extend(
194
+ len(node.semantic_parents) for node in items[sent:]
195
+ )
196
+ changed: dict[str, Any] = {}
197
+ for field, value in dump(trace, include=set(SCALAR_FIELDS)).items():
198
+ packed = pack(value)
199
+ if cursor.scalars.get(field) != packed:
200
+ cursor.scalars[field] = packed
201
+ changed[field] = value
202
+ if changed:
203
+ delta["set"] = changed
204
+ pending = [dump(message) for message in trace.pending]
205
+ packed = pack(pending)
206
+ if packed != cursor.pending:
207
+ cursor.pending = packed
208
+ # nodes landing clear the preview on the client by themselves
209
+ if pending or "nodes" not in delta:
210
+ delta["pending"] = pending
211
+ if len(delta) > 1:
212
+ deltas.append((trace.id, delta, cursor))
213
+ return deltas
214
+
215
+
216
+ class EpisodeAssembly:
217
+ """The client's growing picture of one served episode: raw trace dicts in arrival
218
+ order, each shaped like a dumped `Trace` plus its `pending` preview."""
219
+
220
+ def __init__(self) -> None:
221
+ self.traces: dict[str, dict] = {}
222
+
223
+ def apply(self, delta: dict) -> None:
224
+ trace_id = delta["trace"]
225
+ if delta.get("discard"):
226
+ self.traces.pop(trace_id, None)
227
+ return
228
+ trace = self.traces.get(trace_id)
229
+ if trace is None:
230
+ trace = self.traces[trace_id] = {
231
+ **delta["open"],
232
+ **{field: [] for field in LIST_FIELDS},
233
+ "pending": [],
234
+ }
235
+ for index, links in (delta.get("links") or {}).items():
236
+ trace["nodes"][int(index)]["semantic_parents"].extend(links)
237
+ # a later `links` delta grows a node's semantic_parents in place, so the node
238
+ # is copied: the delta stays as it was when the caller received it
239
+ if "nodes" in delta:
240
+ trace["nodes"].extend(
241
+ {**node, "semantic_parents": list(node.get("semantic_parents") or [])}
242
+ for node in delta["nodes"]
243
+ )
244
+ for field in LIST_FIELDS:
245
+ if field in delta and field != "nodes":
246
+ trace[field].extend(delta[field])
247
+ trace.update(delta.get("set") or {})
248
+ # a committed turn absorbs the preview; an explicit preview replaces it
249
+ if "nodes" in delta:
250
+ trace["pending"] = []
251
+ if "pending" in delta:
252
+ trace["pending"] = delta["pending"]
253
+
254
+ def finish(self, head: dict, summaries: list[TraceSummary]) -> dict:
255
+ """The full episode record: `head` (the episode without its traces) joined with
256
+ the assembled traces in the server's order. A count mismatch means a delta was
257
+ lost, which the wire never does in a live connection — fail loudly."""
258
+ traces = []
259
+ for summary in summaries:
260
+ trace = self.traces.get(summary.id)
261
+ if trace is None:
262
+ raise RuntimeError(f"served episode is missing trace {summary.id}")
263
+ trace = {key: value for key, value in trace.items() if key != "pending"}
264
+ if (
265
+ len(trace["nodes"]) != summary.nodes
266
+ or len(trace["calls"]) != summary.calls
267
+ ):
268
+ raise RuntimeError(
269
+ f"served trace {summary.id} assembled {len(trace['nodes'])} nodes / "
270
+ f"{len(trace['calls'])} calls, server reports {summary.nodes} / {summary.calls}"
271
+ )
272
+ traces.append(trace)
273
+ return {**head, "traces": traces}
274
+
275
+
276
+ class TraceSummary(BaseModel):
277
+ """A finished trace's identity and size, for the client's assembly check."""
278
+
279
+ id: str
280
+ nodes: int
281
+ calls: int
@@ -193,7 +193,7 @@ class EnvServerPool:
193
193
  ) = await self.frontend.recv_multipart()
194
194
  if method == b"health":
195
195
  await self.frontend.send_multipart(
196
- [client_id, request_id, _HEALTH]
196
+ [client_id, request_id, b"reply", _HEALTH]
197
197
  )
198
198
  elif method == b"cancel":
199
199
  # Route the cancel to the worker holding the target run;
@@ -208,7 +208,7 @@ class EnvServerPool:
208
208
  target_entry = pending.get(target)
209
209
  if target_entry is None:
210
210
  await self.frontend.send_multipart(
211
- [client_id, request_id, _CANCEL_MISS]
211
+ [client_id, request_id, b"reply", _CANCEL_MISS]
212
212
  )
213
213
  else:
214
214
  worker = target_entry["worker"]
@@ -238,16 +238,25 @@ class EnvServerPool:
238
238
  self._maybe_scale_up(in_flight)
239
239
  for w in self.workers:
240
240
  if w["dealer"] in events:
241
- request_id, data = await w["dealer"].recv_multipart(copy=False)
242
- # Copy only the routing key; relay the response Frames unchanged.
243
- entry = pending.pop(request_id.bytes, None)
244
- if entry is None:
245
- continue
246
- entry["worker"]["active"] -= 1
247
- in_flight -= 1
241
+ request_id, kind, data = await w["dealer"].recv_multipart(
242
+ copy=False
243
+ )
244
+ # Copy only the routing key; relay the Frames unchanged. A
245
+ # `delta` is one of many for its request — only the `reply`
246
+ # closes the request out.
247
+ if kind.bytes == b"reply":
248
+ entry = pending.pop(request_id.bytes, None)
249
+ if entry is None:
250
+ continue
251
+ entry["worker"]["active"] -= 1
252
+ in_flight -= 1
253
+ else:
254
+ entry = pending.get(request_id.bytes)
255
+ if entry is None:
256
+ continue
248
257
  with contextlib.suppress(zmq.ZMQError):
249
258
  await self.frontend.send_multipart(
250
- [entry["client_id"], request_id, data], copy=False
259
+ [entry["client_id"], request_id, kind, data], copy=False
251
260
  )
252
261
  except (asyncio.CancelledError, KeyboardInterrupt):
253
262
  pass