awp-python 0.1.0a1__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.
Files changed (71) hide show
  1. awp/__init__.py +64 -0
  2. awp/_spec/lifecycle.json +190 -0
  3. awp/_spec/schemas/action-cancel-result.schema.json +13 -0
  4. awp/_spec/schemas/action-ref.schema.json +11 -0
  5. awp/_spec/schemas/action-schema.schema.json +37 -0
  6. awp/_spec/schemas/action-status.schema.json +39 -0
  7. awp/_spec/schemas/action-submit-result.schema.json +16 -0
  8. awp/_spec/schemas/action-submit.schema.json +19 -0
  9. awp/_spec/schemas/agent-manifest.schema.json +27 -0
  10. awp/_spec/schemas/approval-requested.schema.json +25 -0
  11. awp/_spec/schemas/approval-respond.schema.json +31 -0
  12. awp/_spec/schemas/common.schema.json +150 -0
  13. awp/_spec/schemas/embodiment.schema.json +22 -0
  14. awp/_spec/schemas/empty-result.schema.json +8 -0
  15. awp/_spec/schemas/error.schema.json +25 -0
  16. awp/_spec/schemas/frame-inline.schema.json +20 -0
  17. awp/_spec/schemas/frame-tree.schema.json +26 -0
  18. awp/_spec/schemas/obs-report.schema.json +40 -0
  19. awp/_spec/schemas/observation-channel.schema.json +28 -0
  20. awp/_spec/schemas/ping-result.schema.json +13 -0
  21. awp/_spec/schemas/ping.schema.json +12 -0
  22. awp/_spec/schemas/profiles/gui-actions.schema.json +57 -0
  23. awp/_spec/schemas/reset-result.schema.json +11 -0
  24. awp/_spec/schemas/reset.schema.json +12 -0
  25. awp/_spec/schemas/restore.schema.json +11 -0
  26. awp/_spec/schemas/safety-policy.schema.json +81 -0
  27. awp/_spec/schemas/session-open.schema.json +41 -0
  28. awp/_spec/schemas/session-ready.schema.json +50 -0
  29. awp/_spec/schemas/session-resume.schema.json +12 -0
  30. awp/_spec/schemas/session-state.schema.json +14 -0
  31. awp/_spec/schemas/session-telemetry.schema.json +22 -0
  32. awp/_spec/schemas/session-transfer-result.schema.json +12 -0
  33. awp/_spec/schemas/session-transfer.schema.json +11 -0
  34. awp/_spec/schemas/snapshot-result.schema.json +11 -0
  35. awp/_spec/schemas/subscribe-result.schema.json +11 -0
  36. awp/_spec/schemas/subscribe.schema.json +11 -0
  37. awp/_spec/schemas/task-update.schema.json +11 -0
  38. awp/_spec/schemas/tick-result.schema.json +9 -0
  39. awp/_spec/schemas/tick.schema.json +12 -0
  40. awp/_spec/schemas/unsubscribe.schema.json +11 -0
  41. awp/_spec/schemas/world-event.schema.json +45 -0
  42. awp/_spec/schemas/world-manifest.schema.json +61 -0
  43. awp/aio.py +380 -0
  44. awp/client.py +781 -0
  45. awp/clock.py +61 -0
  46. awp/errors.py +112 -0
  47. awp/frames.py +174 -0
  48. awp/jsonrpc.py +84 -0
  49. awp/lifecycle.py +78 -0
  50. awp/py.typed +0 -0
  51. awp/schema.py +158 -0
  52. awp_python-0.1.0a1.dist-info/METADATA +127 -0
  53. awp_python-0.1.0a1.dist-info/RECORD +71 -0
  54. awp_python-0.1.0a1.dist-info/WHEEL +4 -0
  55. awp_python-0.1.0a1.dist-info/entry_points.txt +2 -0
  56. awp_python-0.1.0a1.dist-info/licenses/LICENSE +201 -0
  57. awp_sim/__init__.py +8 -0
  58. awp_sim/__main__.py +5 -0
  59. awp_sim/arm.py +163 -0
  60. awp_sim/audit.py +138 -0
  61. awp_sim/cli.py +238 -0
  62. awp_sim/config.py +225 -0
  63. awp_sim/demo.py +85 -0
  64. awp_sim/loopback.py +222 -0
  65. awp_sim/py.typed +0 -0
  66. awp_sim/recorder.py +72 -0
  67. awp_sim/replay.py +134 -0
  68. awp_sim/scenarios.py +427 -0
  69. awp_sim/server.py +333 -0
  70. awp_sim/session.py +153 -0
  71. awp_sim/world.py +1612 -0
awp_sim/world.py ADDED
@@ -0,0 +1,1612 @@
1
+ """The reference world engine: the world side of AWP Core as a sans-IO state machine.
2
+
3
+ Every entry point takes the current time (`now`, monotonic nanoseconds) and returns the outputs to
4
+ perform — messages to send and connections to close. Nothing here reads a clock or does IO.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import contextlib
10
+ import copy
11
+ import json
12
+ import logging
13
+ import random
14
+ import secrets
15
+ from collections import OrderedDict
16
+ from collections.abc import Callable, Hashable
17
+ from dataclasses import asdict, dataclass, field, fields, replace
18
+ from datetime import UTC, datetime
19
+ from typing import Any, Protocol
20
+
21
+ from awp import jsonrpc, schema
22
+ from awp.errors import AwpError, ErrorCode
23
+ from awp.frames import Frame, decode, encode, from_inline, to_inline
24
+ from awp.jsonrpc import Message
25
+ from awp.lifecycle import ActionState, same_submission
26
+
27
+ from .arm import Arm, Phase
28
+ from .config import EMBODIMENT, FRAME_TREE, HOME, PARK, SERVO_CHANNEL, WorldConfig
29
+ from .session import Action, ChannelGrant, Session, Telemetry
30
+
31
+ MS = 1_000_000
32
+ S = 1_000_000_000
33
+ _PREAMBLE_LIMIT = 256 # pre-session messages held for the audit log per connection
34
+ _CLOSED_TOKENS_LIMIT = 10_000
35
+
36
+ log = logging.getLogger("awp_sim")
37
+
38
+ _REFUSED_WHILE_CLOSING = {
39
+ "obs.subscribe",
40
+ "obs.unsubscribe",
41
+ "action.submit",
42
+ "action.cancel",
43
+ "world.tick",
44
+ "world.reset",
45
+ }
46
+ _NEEDS_SESSION = {
47
+ "session.close",
48
+ "obs.subscribe",
49
+ "obs.unsubscribe",
50
+ "action.submit",
51
+ "action.cancel",
52
+ "action.status",
53
+ "world.tick",
54
+ "world.reset",
55
+ }
56
+
57
+
58
+ @dataclass(frozen=True, slots=True)
59
+ class Send:
60
+ conn: Hashable
61
+ msg: Message
62
+ session: str | None = None
63
+ latest_wins: bool = False # the sender may replace this frame with a newer one (AWP-TRN-009)
64
+
65
+
66
+ @dataclass(frozen=True, slots=True)
67
+ class Close:
68
+ conn: Hashable
69
+ reason: str
70
+
71
+
72
+ @dataclass(frozen=True, slots=True)
73
+ class SendFrame:
74
+ """A binary frame for a stream connection (AWP-TRN-003)."""
75
+
76
+ conn: Hashable
77
+ frame: Frame
78
+ session: str
79
+ latest_wins: bool = False
80
+
81
+
82
+ Output = Send | SendFrame | Close
83
+
84
+
85
+ class AuditSink(Protocol):
86
+ def open(self, session_id: str, header: dict[str, Any]) -> None: ...
87
+ def record(self, session_id: str, ts_mono_ns: int, direction: str, msg: Message) -> None: ...
88
+ def close(self, session_id: str) -> None: ...
89
+
90
+
91
+ @dataclass(slots=True)
92
+ class _Conn:
93
+ id: Hashable
94
+ last_rx_ns: int
95
+ last_ping_ns: int
96
+ initialized: bool = False
97
+ approver: bool = False # authorized by the deployment to decide approvals (AWP-APR-005)
98
+ consumes: frozenset[str] = frozenset()
99
+ max_obs_rate_hz: float | None = None
100
+ session: Session | None = None
101
+ next_id: int = 1
102
+ preamble: list[tuple[str, Message]] = field(default_factory=list)
103
+
104
+
105
+ class World:
106
+ def __init__(
107
+ self,
108
+ config: WorldConfig | None = None,
109
+ *,
110
+ audit: AuditSink | None = None,
111
+ wall_clock: Callable[[], datetime] = lambda: datetime.now(UTC),
112
+ ) -> None:
113
+ self.config = config or WorldConfig()
114
+ self.manifest = self.config.manifest()
115
+ schema.check("world-manifest", self.manifest, sender=True)
116
+ self._params = schema.ParamsValidator(self.manifest)
117
+ self._decls = {d["type"]: d for d in self.manifest["action_schemas"]}
118
+ self._channels = {c["id"]: c for c in self.manifest["observation_channels"]}
119
+ self._audit = audit
120
+ self._wall_clock = wall_clock
121
+
122
+ self.arm = Arm(position=HOME, accel_mps2=self.config.accel_mps2, bounds=self.config.aabb_m)
123
+ self.tick = 0 # the world's tick: reset and restore move it
124
+ self.advances = 0 # every advance ever made: the lockstep session clock (AWP-TIM-013)
125
+ self.rng = random.Random(0) # simulated sensor noise, seeded (AWP-REP-001)
126
+ self._snapshots: dict[str, dict[str, Any]] = {}
127
+ self._approvals: dict[str, tuple[str, str, int]] = {} # id → (session, action, expires)
128
+ self._transfers: dict[str, tuple[str, int]] = {} # token → (session, expires, monotonic)
129
+ self.estop = False
130
+ self.sessions: dict[str, Session] = {}
131
+ self._tokens: dict[str, Session] = {}
132
+ self._closed_tokens: OrderedDict[str, None] = OrderedDict()
133
+ self._conns: dict[Hashable, _Conn] = {}
134
+ self._holder: Session | None = None
135
+ self._last_step_ns: int | None = None
136
+ self._out: list[Output] = []
137
+ self._started_ns: int | None = None
138
+ self._now = 0
139
+ self._streams: dict[Hashable, str] = {} # stream connection → session id
140
+ self.stream_url: str | None = None # set by the server when it offers the ws binding
141
+
142
+ @property
143
+ def _handlers(self) -> dict[str, Callable[[_Conn, Any, dict[str, Any], int], None]]:
144
+ return {
145
+ "initialize": self._rpc_initialize,
146
+ "world.manifest": self._rpc_world_manifest,
147
+ "ping": self._rpc_ping,
148
+ "session.open": self._rpc_session_open,
149
+ "session.resume": self._rpc_session_resume,
150
+ "session.close": self._rpc_session_close,
151
+ "obs.subscribe": self._rpc_obs_subscribe,
152
+ "obs.unsubscribe": self._rpc_obs_unsubscribe,
153
+ "action.submit": self._rpc_action_submit,
154
+ "action.cancel": self._rpc_action_cancel,
155
+ "action.status": self._rpc_action_status,
156
+ "world.tick": self._rpc_world_tick,
157
+ "world.reset": self._rpc_world_reset,
158
+ **self._feature_handlers,
159
+ }
160
+
161
+ @property
162
+ def _feature_handlers(self) -> dict[str, Callable[[_Conn, Any, dict[str, Any], int], None]]:
163
+ """Methods of advertised features; without them they are unknown (AWP-VER-007)."""
164
+ has = self.config.has
165
+ out: dict[str, Callable[[_Conn, Any, dict[str, Any], int], None]] = {}
166
+ if has("task"):
167
+ out["task.update"] = self._rpc_task_update
168
+ if has("sim"):
169
+ out["world.snapshot"] = self._rpc_world_snapshot
170
+ out["world.restore"] = self._rpc_world_restore
171
+ if has("approval"):
172
+ out["safety.approval.respond"] = self._rpc_approval_respond
173
+ if has("transfer"):
174
+ out["session.transfer"] = self._rpc_session_transfer
175
+ return out
176
+
177
+ @property
178
+ def lockstep(self) -> bool:
179
+ return self.config.mode == "lockstep"
180
+
181
+ # ================================================================ entry points
182
+
183
+ def connect(self, conn: Hashable, now: int, *, approver: bool = False) -> list[Output]:
184
+ self._now = now
185
+ self._started_ns = self._started_ns if self._started_ns is not None else now
186
+ self._conns[conn] = _Conn(conn, last_rx_ns=now, last_ping_ns=now, approver=approver)
187
+ return self._flush()
188
+
189
+ def receive_text(self, conn: Hashable, text: str | bytes, now: int) -> list[Output]:
190
+ self._now = now
191
+ c = self._conns.get(conn)
192
+ if c is None:
193
+ return []
194
+ try:
195
+ msg = jsonrpc.decode(text)
196
+ except AwpError as err:
197
+ c.last_rx_ns = now
198
+ self._send(c, jsonrpc.error(None, err))
199
+ if err.code == ErrorCode.INTEGER_RANGE: # AWP-CTL-009 closes the session
200
+ if c.session is not None:
201
+ self._close_session(c.session, now, "session_closed")
202
+ self._out.append(Close(conn, "AWP_INTEGER_RANGE"))
203
+ return self._flush()
204
+ return self.receive(conn, msg, now)
205
+
206
+ def receive(self, conn: Hashable, msg: Message, now: int) -> list[Output]:
207
+ self._now = now
208
+ c = self._conns.get(conn)
209
+ if c is None:
210
+ return []
211
+ c.last_rx_ns = now
212
+ s = c.session
213
+ self._audit_in(c, msg, now)
214
+ if s is not None and "method" in msg:
215
+ s.last_agent_ns = now # only messages the agent originates hold off the watchdog
216
+ if jsonrpc.is_request(msg):
217
+ self._on_request(c, msg, now)
218
+ elif jsonrpc.is_notification(msg) and msg["method"] == "obs.report" and s is not None:
219
+ pass # accepted (AWP-OBS-007); the reference world does not adapt rates
220
+ elif jsonrpc.is_notification(msg) and msg["method"] == "cmd.frame" and s is not None:
221
+ with contextlib.suppress(AwpError):
222
+ schema.check("frame-inline", msg.get("params"))
223
+ self._on_command_frame(s, from_inline(msg["params"]), now)
224
+ return self._flush()
225
+
226
+ def disconnect(self, conn: Hashable, now: int) -> list[Output]:
227
+ self._now = now
228
+ c = self._conns.pop(conn, None)
229
+ if c is not None and c.session is not None and c.session.conn == conn:
230
+ self._suspend(c.session, now, "connection_lost")
231
+ return self._flush()
232
+
233
+ def attach_stream(self, conn: Hashable, token: str, now: int) -> list[Output]:
234
+ """A stream connection presenting `token` (AWP-SEC-004). Its channels move to it."""
235
+ self._now = now
236
+ s = self._tokens.get(token)
237
+ if s is None or s.conn is None or s.closing_reason is not None or self.stream_url is None:
238
+ self._out.append(Close(conn, "no active session for this token"))
239
+ return self._flush()
240
+ if s.stream_conn is not None:
241
+ self._out.append(Close(s.stream_conn, "replaced by a new stream connection"))
242
+ self._streams.pop(s.stream_conn, None)
243
+ self._streams[conn] = s.id
244
+ s.stream_conn = conn
245
+ s.stream_lost_ns = None
246
+ s.stream_degraded_reported = False
247
+ for g in s.grants.values():
248
+ g.resync = True # the first frame on the new connection is a resync keyframe
249
+ self._emit_frame(s, g, now)
250
+ return self._flush()
251
+
252
+ def receive_stream(self, conn: Hashable, data: bytes, now: int) -> list[Output]:
253
+ """Agent→world frames. Without command channels every frame is discarded (AWP-CMD-003)."""
254
+ self._now = now
255
+ try:
256
+ frame = decode(data)
257
+ except AwpError as err:
258
+ self._out.append(Close(conn, err.message))
259
+ return self._flush()
260
+ s = self.sessions.get(self._streams.get(conn, ""))
261
+ if s is not None:
262
+ if self._audit is not None:
263
+ self._audit.record(
264
+ s.id,
265
+ self._clock(s, now),
266
+ "agent",
267
+ jsonrpc.notification("cmd.frame", to_inline(frame)),
268
+ )
269
+ s.last_agent_ns = now
270
+ self._on_command_frame(s, frame, now)
271
+ return self._flush()
272
+
273
+ def stream_lost(self, conn: Hashable, now: int) -> list[Output]:
274
+ self._now = now
275
+ s = self.sessions.get(self._streams.pop(conn, ""))
276
+ if s is not None and s.stream_conn == conn:
277
+ s.stream_conn = None
278
+ s.stream_lost_ns = now # its channels wait for a new stream connection (AWP-TRN-010)
279
+ return self._flush()
280
+
281
+ def frame_bytes(self, send: SendFrame, now: int) -> bytes:
282
+ """Encode a frame as it is handed to the transport, stamping `ts_send_ns` (AWP-OBS-006)."""
283
+ s = self.sessions.get(send.session)
284
+ frame = send.frame
285
+ if s is not None and not self.lockstep:
286
+ ts_send = max(self._clock(s, now), frame.ts_mono_ns)
287
+ frame = replace(frame, ts_send_ns=ts_send)
288
+ latency = ts_send - frame.ts_mono_ns
289
+ s.telemetry.observation.append(latency)
290
+ s.telemetry.channels.setdefault(frame.channel_id, []).append(latency)
291
+ return encode(frame)
292
+
293
+ def advance(self, now: int) -> list[Output]:
294
+ """Run timers and, in streaming, the simulation. Call often (every few milliseconds)."""
295
+ self._now = now
296
+ self._started_ns = self._started_ns if self._started_ns is not None else now
297
+ if not self.lockstep:
298
+ last = self._last_step_ns if self._last_step_ns is not None else now
299
+ self._step_arm(now - last)
300
+ self._last_step_ns = now
301
+ for s in list(self.sessions.values()):
302
+ if not self.lockstep:
303
+ self._update_actions(s, now)
304
+ self._check_deadlines(s, now)
305
+ self._check_watchdog(s, now)
306
+ self._check_retention(s, now)
307
+ self._check_stream(s, now)
308
+ if not self.lockstep:
309
+ self._check_approvals(now)
310
+ for s in list(self.sessions.values()):
311
+ if s.id in self.sessions and s.conn is not None and not self.lockstep:
312
+ self._stream_frames(s, now)
313
+ self._send_telemetry(s, now)
314
+ for c in list(self._conns.values()):
315
+ self._heartbeat(c, now)
316
+ return self._flush()
317
+
318
+ def frame_sent(self, send: Send, now: int) -> Message:
319
+ """Stamp `ts_send_ns` as the frame is handed to the transport (AWP-OBS-006)."""
320
+ s = self.sessions.get(send.session or "")
321
+ if s is None or self.lockstep:
322
+ return send.msg
323
+ params = dict(send.msg["params"])
324
+ ts_send = max(self._clock(s, now), params["ts_mono_ns"])
325
+ params["ts_send_ns"] = ts_send
326
+ latency = ts_send - params["ts_mono_ns"]
327
+ s.telemetry.observation.append(latency)
328
+ s.telemetry.channels.setdefault(params["channel_id"], []).append(latency)
329
+ return {**send.msg, "params": params}
330
+
331
+ def engage_estop(self, now: int, source: str = "operator") -> list[Output]:
332
+ self._now = now
333
+ if not self.estop:
334
+ self.estop = True
335
+ self.arm.halt()
336
+ for s in list(self.sessions.values()):
337
+ self._event(s, "e_stop_engaged", now, {"embodiment": EMBODIMENT, "source": source})
338
+ for a in list(s.actions.values()):
339
+ if a.state in (ActionState.EXECUTING, ActionState.CANCELLING):
340
+ self._finish(s, a, ActionState.FAILED, now, reason="e_stop", aborted=True)
341
+ elif a.state.pre_execution:
342
+ self._finish(s, a, ActionState.CANCELLED, now, reason="e_stop")
343
+ return self._flush()
344
+
345
+ def release_estop(self, now: int) -> list[Output]:
346
+ self._now = now
347
+ if self.estop:
348
+ self.estop = False
349
+ for s in list(self.sessions.values()):
350
+ self._event(s, "e_stop_released", now, {"embodiment": EMBODIMENT})
351
+ return self._flush()
352
+
353
+ def session_of(self, conn: Hashable) -> Session | None:
354
+ c = self._conns.get(conn)
355
+ return c.session if c else None
356
+
357
+ # ================================================================ plumbing
358
+
359
+ def _endpoints(self) -> list[dict[str, Any]]:
360
+ """Stream endpoints by preference; inline is last and always offered (AWP-TRN-004)."""
361
+ endpoints: list[dict[str, Any]] = []
362
+ if self.stream_url is not None:
363
+ endpoints.append({"binding": "ws", "url": self.stream_url, "max_frame_bytes": 1 << 20})
364
+ return [*endpoints, {"binding": "inline"}]
365
+
366
+ def _flush(self) -> list[Output]:
367
+ out, self._out = self._out, []
368
+ return out
369
+
370
+ def _clock(self, s: Session, now: int) -> int:
371
+ """The session clock (AWP-CLK-001). In lockstep it counts advances, so that reset and
372
+ restore, which move the tick, never move it backward (AWP-TIM-013)."""
373
+ if self.lockstep:
374
+ return self.advances * self.config.tick_ms * MS
375
+ return now - s.origin_ns
376
+
377
+ def _sim_ns(self) -> int | None:
378
+ """Simulated time, carried on frames by sim-profile worlds (AWP-CLK-003)."""
379
+ return self.tick * self.config.tick_ms * MS if self.config.has("sim") else None
380
+
381
+ def _send(self, c: _Conn, msg: Message, *, latest_wins: bool = False) -> None:
382
+ s = c.session
383
+ self._out.append(Send(c.id, msg, s.id if s else None, latest_wins))
384
+ if s is not None:
385
+ if self._audit is not None:
386
+ self._audit.record(s.id, self._clock(s, self._now), "world", msg)
387
+ elif len(c.preamble) < _PREAMBLE_LIMIT:
388
+ c.preamble.append(("world", msg))
389
+
390
+ def _to_session(self, s: Session, msg: Message, *, latest_wins: bool = False) -> None:
391
+ c = self._conns.get(s.conn) if s.conn is not None else None
392
+ if c is not None:
393
+ self._send(c, msg, latest_wins=latest_wins)
394
+ elif self._audit is not None and "status_seq" in msg.get("params", {}):
395
+ self._audit.record(s.id, self._clock(s, self._now), "world", msg)
396
+
397
+ def _audit_in(self, c: _Conn, msg: Message, now: int) -> None:
398
+ if c.session is None:
399
+ if len(c.preamble) < _PREAMBLE_LIMIT:
400
+ c.preamble.append(("agent", msg))
401
+ elif self._audit is not None:
402
+ self._audit.record(c.session.id, self._clock(c.session, now), "agent", msg)
403
+
404
+ def _reply(self, c: _Conn, rid: Any, result: dict[str, Any]) -> None:
405
+ self._send(c, jsonrpc.result(rid, result))
406
+
407
+ def _sequenced(self, s: Session, method: str, params: dict[str, Any]) -> dict[str, Any]:
408
+ seq = s.next_seq()
409
+ params = {**params, "status_seq": seq}
410
+ s.retain(seq, method, params)
411
+ self._to_session(s, jsonrpc.notification(method, params))
412
+ return params
413
+
414
+ def _event(
415
+ self, s: Session, event: str, now: int, detail: dict[str, Any] | None = None
416
+ ) -> None:
417
+ params: dict[str, Any] = {"event": event, "ts_mono_ns": self._clock(s, now)}
418
+ if self.lockstep:
419
+ params["tick"] = self.tick
420
+ if detail is not None:
421
+ params["detail"] = detail
422
+ self._sequenced(s, "world.event", params)
423
+
424
+ def _session_state(self, s: Session, state: str, reason: str, now: int) -> None:
425
+ s.state = state
426
+ self._sequenced(
427
+ s,
428
+ "session.state",
429
+ {"state": state, "ts_mono_ns": self._clock(s, now), "reason": reason},
430
+ )
431
+
432
+ def _status_params(self, s: Session, a: Action, now: int, **extra: Any) -> dict[str, Any]:
433
+ params: dict[str, Any] = {
434
+ "action_id": a.action_id,
435
+ "state": str(a.state),
436
+ "ts_mono_ns": self._clock(s, now),
437
+ }
438
+ if self.lockstep:
439
+ params["tick"] = self.tick
440
+ params.update({k: v for k, v in extra.items() if v is not None})
441
+ return params
442
+
443
+ def _transition(
444
+ self, s: Session, a: Action, state: ActionState, now: int, **extra: Any
445
+ ) -> None:
446
+ a.state = state
447
+ a.status = self._sequenced(s, "action.status", self._status_params(s, a, now, **extra))
448
+ if state.terminal:
449
+ a.terminal_ns = now
450
+ self._activate(s, now)
451
+
452
+ def _result_status(self, s: Session, a: Action, now: int, **extra: Any) -> dict[str, Any]:
453
+ """A transition first reported in a result: consumes a status_seq and is replayable."""
454
+ params = self._status_params(s, a, now, **extra)
455
+ seq = s.next_seq()
456
+ params["status_seq"] = seq
457
+ s.retain(seq, "action.status", params)
458
+ a.status = params
459
+ if a.state.terminal:
460
+ a.terminal_ns = self._now
461
+ return params
462
+
463
+ def _activate(self, s: Session, now: int) -> None:
464
+ if s.state == "ready":
465
+ self._session_state(s, "active", "first_activity", now)
466
+
467
+ def _new_session_token(self) -> str:
468
+ return "st_" + secrets.token_urlsafe(24)
469
+
470
+ # ================================================================ requests
471
+
472
+ def _on_request(self, c: _Conn, msg: Message, now: int) -> None:
473
+ method, rid = msg["method"], msg["id"]
474
+ params = msg.get("params") or {}
475
+ handler = self._handlers.get(method)
476
+ try:
477
+ if handler is None:
478
+ raise AwpError(ErrorCode.METHOD_NOT_FOUND, method)
479
+ if not isinstance(params, dict):
480
+ raise AwpError(ErrorCode.INVALID_PARAMS, "params must be an object")
481
+ if method != "initialize" and not c.initialized:
482
+ raise AwpError(ErrorCode.INVALID_REQUEST, "initialize first")
483
+ if method in _NEEDS_SESSION and c.session is None:
484
+ raise AwpError(ErrorCode.INVALID_REQUEST, "no session on this connection")
485
+ if method in _REFUSED_WHILE_CLOSING and c.session and c.session.closing_reason:
486
+ raise AwpError(ErrorCode.SESSION_EXPIRED, "the session is closing") # AWP-SES-011
487
+ params_schema = schema.schema_for(method, "params")
488
+ if params_schema is not None:
489
+ schema.check(params_schema, params)
490
+ handler(c, rid, params, now)
491
+ except AwpError as err:
492
+ self._send(c, jsonrpc.error(rid, err))
493
+ except Exception:
494
+ log.exception("internal error handling %s", method)
495
+ self._send(c, jsonrpc.error(rid, AwpError(ErrorCode.INTERNAL_ERROR)))
496
+
497
+ def _rpc_initialize(self, c: _Conn, rid: Any, p: dict[str, Any], now: int) -> None:
498
+ if "0.1" not in p["protocol_versions"]:
499
+ raise AwpError(ErrorCode.VERSION_UNSUPPORTED, "this world speaks 0.1")
500
+ c.initialized = True
501
+ c.consumes = frozenset(p["consumes_modalities"])
502
+ c.max_obs_rate_hz = p.get("max_obs_rate_hz")
503
+ self._reply(c, rid, self.manifest)
504
+
505
+ def _rpc_world_manifest(self, c: _Conn, rid: Any, p: dict[str, Any], now: int) -> None:
506
+ self._reply(c, rid, self.manifest)
507
+
508
+ def _rpc_ping(self, c: _Conn, rid: Any, p: dict[str, Any], now: int) -> None:
509
+ s = c.session
510
+ stamp = self._clock(s, now) if s else now - (self._started_ns or now)
511
+ if s is not None and "last_status_seq" in p:
512
+ s.acknowledge(min(p["last_status_seq"], s.seq))
513
+ self._reply(
514
+ c, rid, {"origin_ns": p["origin_ns"], "receive_ns": stamp, "transmit_ns": stamp}
515
+ )
516
+
517
+ def _rpc_session_open(self, c: _Conn, rid: Any, p: dict[str, Any], now: int) -> None:
518
+ if c.session is not None:
519
+ raise AwpError(ErrorCode.SESSION_EXISTS)
520
+ if p["mode"] != self.config.mode:
521
+ raise AwpError(ErrorCode.TIME_MODEL_UNSUPPORTED, f"this world runs {self.config.mode}")
522
+ if "embodiments" in p:
523
+ raise AwpError(ErrorCode.EMBODIMENT_UNAVAILABLE, "multi-bind is not offered")
524
+ if p.get("takeover") and not self.config.has("transfer"):
525
+ raise AwpError(ErrorCode.EMBODIMENT_UNAVAILABLE, "transfer is not offered")
526
+ embodiment = p.get("embodiment")
527
+ if embodiment is not None and embodiment != EMBODIMENT:
528
+ raise AwpError(ErrorCode.EMBODIMENT_UNAVAILABLE, f"unknown embodiment {embodiment}")
529
+ if embodiment is not None and self._holder is not None and not p.get("takeover"):
530
+ raise AwpError(ErrorCode.EMBODIMENT_UNAVAILABLE, f"{embodiment} is bound elsewhere")
531
+ readable = self._readable(embodiment, c.consumes)
532
+ requested = p.get("subscribe", [])
533
+ for sub in requested:
534
+ if sub["channel"] not in self._channels:
535
+ raise AwpError(ErrorCode.CHANNEL_UNKNOWN, sub["channel"])
536
+ if "task" in p and self.config.has("task"):
537
+ self._check_task(p["task"])
538
+ if p.get("takeover"):
539
+ self._take_over(p, now)
540
+
541
+ offered = self.manifest["embodiments"][0]["action_types"] if embodiment else []
542
+ wanted = p.get("action_types", offered)
543
+ admin = [op for op in p.get("admin", []) if self._may_grant_admin(op, embodiment)]
544
+ session_id = f"sess_{secrets.token_hex(6)}"
545
+ s = Session(
546
+ id=session_id,
547
+ token=self._new_session_token(),
548
+ mode=p["mode"],
549
+ embodiment=embodiment,
550
+ origin_ns=now,
551
+ clock_anchor=self._wall_clock().strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
552
+ conn=c.id,
553
+ action_types=[t for t in offered if t in wanted],
554
+ admin=admin,
555
+ last_agent_ns=now,
556
+ last_telemetry_ns=now,
557
+ )
558
+ for sub in requested:
559
+ if sub["channel"] in readable:
560
+ self._grant(s, sub["channel"], sub.get("rate_hz"), now, c.max_obs_rate_hz)
561
+ if "servo" in s.action_types: # the streaming type implies its channel (AWP-CMD-002)
562
+ cmd = self.manifest["command_channels"][0]
563
+ s.grants[SERVO_CHANNEL] = ChannelGrant(
564
+ SERVO_CHANNEL, s.next_channel_id, cmd["rate_hz"], cmd["loss_class"], command=True
565
+ )
566
+ s.next_channel_id += 1
567
+ if self.config.has("task"):
568
+ s.task = p.get("task")
569
+ if "seed" in p and self.config.has("sim"):
570
+ self.rng.seed(
571
+ p["seed"]
572
+ ) # seeds the noise; the world state is as it stands (AWP-REP-001)
573
+ self.sessions[s.id] = s
574
+ self._tokens[s.token] = s
575
+ if embodiment is not None:
576
+ self._holder = s
577
+ c.session = s
578
+ if self._audit is not None:
579
+ header: dict[str, Any] = {"manifest": self.manifest, "session_id": s.id}
580
+ if self.config.has("sim"): # what a replay bundle starts from (AWP-REP-003)
581
+ token = "snap_" + secrets.token_urlsafe(18)
582
+ self._snapshots[token] = self.world_state()
583
+ header.update(
584
+ snapshot_token=token,
585
+ initial_state=encode_state(self.world_state()),
586
+ config=encode_config(self.config),
587
+ )
588
+ self._audit.open(s.id, header)
589
+ for direction, m in c.preamble:
590
+ self._audit.record(s.id, 0, direction, m)
591
+ c.preamble.clear()
592
+
593
+ ready: dict[str, Any] = {
594
+ "session_id": s.id,
595
+ "session_token": s.token,
596
+ "reconnect_window_ms": self.config.reconnect_window_ms,
597
+ "heartbeat_interval_ms": self.config.heartbeat_interval_ms,
598
+ "granted": self._granted(s),
599
+ "stream_endpoints": self._endpoints(),
600
+ "frame_tree": FRAME_TREE,
601
+ "clock_anchor": s.clock_anchor,
602
+ }
603
+ if self.lockstep:
604
+ ready["tick"] = self.tick
605
+ self._reply(c, rid, ready)
606
+ self._session_state(s, "ready", "opened", now)
607
+ for g in s.grants.values():
608
+ self._emit_frame(s, g, now) # initial observation (AWP-TIM-009, AWP-OBS-005)
609
+
610
+ def _rpc_session_resume(self, c: _Conn, rid: Any, p: dict[str, Any], now: int) -> None:
611
+ if c.session is not None:
612
+ raise AwpError(ErrorCode.SESSION_EXISTS)
613
+ s = self._tokens.get(p["session_token"])
614
+ if s is None:
615
+ if p["session_token"] in self._closed_tokens:
616
+ raise AwpError(ErrorCode.SESSION_EXPIRED)
617
+ raise AwpError(ErrorCode.SESSION_UNKNOWN, "treat the session as closed (AWP-SES-008)")
618
+ if s.closing_reason is not None:
619
+ raise AwpError(ErrorCode.SESSION_EXPIRED, "the session is closing")
620
+ if s.conn is not None: # the old connection's loss is not yet detected (AWP-SES-010)
621
+ self._out.append(Close(s.conn, "replaced by session.resume"))
622
+ old = self._conns.pop(s.conn, None)
623
+ if old is not None:
624
+ old.session = None
625
+ self._suspend(s, now, "connection_replaced")
626
+ replay_to = s.seq
627
+ s.conn = c.id
628
+ s.last_agent_ns = now # session.resume is agent traffic on the session (AWP-SAF-003)
629
+ s.suspended_ns = None
630
+ s.degraded_reported = False
631
+ s.acknowledge(min(p["last_status_seq"], s.seq))
632
+ c.session = s
633
+ for direction, m in c.preamble:
634
+ if self._audit is not None:
635
+ self._audit.record(s.id, self._clock(s, now), direction, m)
636
+ c.preamble.clear()
637
+ readable = self._readable(s.embodiment, c.consumes)
638
+ for name in [n for n in s.grants if n not in readable]:
639
+ del s.grants[name] # the new connection does not consume it (AWP-AGM-001)
640
+ for g in s.grants.values():
641
+ g.resync = g.loss_class == "reliable" # AWP-TRN-008
642
+ ready: dict[str, Any] = {
643
+ "session_id": s.id,
644
+ "session_token": s.token,
645
+ "reconnect_window_ms": self.config.reconnect_window_ms,
646
+ "replay_to_status_seq": replay_to,
647
+ "heartbeat_interval_ms": self.config.heartbeat_interval_ms,
648
+ "granted": self._granted(s),
649
+ "stream_endpoints": self._endpoints(),
650
+ "frame_tree": FRAME_TREE,
651
+ "clock_anchor": s.clock_anchor,
652
+ "safe_state": s.safe_state,
653
+ }
654
+ if self.lockstep:
655
+ ready["tick"] = self.tick
656
+ self._reply(c, rid, ready)
657
+ for method, params in s.replay_after(p["last_status_seq"]):
658
+ self._send(c, jsonrpc.notification(method, params))
659
+ self._session_state(s, "active", "resumed", now)
660
+ if self.lockstep:
661
+ for g in s.grants.values():
662
+ self._emit_frame(s, g, now)
663
+
664
+ def _rpc_session_close(self, c: _Conn, rid: Any, p: dict[str, Any], now: int) -> None:
665
+ assert c.session is not None
666
+ c.session.closing.append((c.id, rid))
667
+ if c.session.closing_reason is None:
668
+ self._close_session(c.session, now, "session_closed")
669
+
670
+ def _rpc_obs_subscribe(self, c: _Conn, rid: Any, p: dict[str, Any], now: int) -> None:
671
+ s = c.session
672
+ assert s is not None
673
+ readable = self._readable(s.embodiment, c.consumes)
674
+ for sub in p["channels"]:
675
+ if sub["channel"] not in self._channels:
676
+ raise AwpError(ErrorCode.CHANNEL_UNKNOWN, sub["channel"])
677
+ if sub["channel"] not in readable:
678
+ raise AwpError(ErrorCode.FORBIDDEN, f"{sub['channel']} is not readable")
679
+ new = []
680
+ for sub in p["channels"]:
681
+ existing = s.grants.get(sub["channel"])
682
+ if existing is None:
683
+ new.append(
684
+ self._grant(s, sub["channel"], sub.get("rate_hz"), now, c.max_obs_rate_hz)
685
+ )
686
+ elif existing.rate_hz is not None:
687
+ existing.rate_hz = self._rate(sub["channel"], sub.get("rate_hz"), c.max_obs_rate_hz)
688
+ self._reply(c, rid, {"granted": [g.to_wire() for g in s.grants.values()]})
689
+ for g in new:
690
+ self._emit_frame(s, g, now)
691
+
692
+ def _rpc_obs_unsubscribe(self, c: _Conn, rid: Any, p: dict[str, Any], now: int) -> None:
693
+ s = c.session
694
+ assert s is not None
695
+ for name in p["channels"]:
696
+ if name not in self._channels:
697
+ raise AwpError(ErrorCode.CHANNEL_UNKNOWN, name)
698
+ for name in p["channels"]:
699
+ s.grants.pop(name, None)
700
+ self._reply(c, rid, {"granted": [g.to_wire() for g in s.grants.values()]})
701
+
702
+ def _rpc_world_reset(self, c: _Conn, rid: Any, p: dict[str, Any], now: int) -> None:
703
+ s = c.session
704
+ assert s is not None
705
+ if "reset" not in s.admin:
706
+ raise AwpError(ErrorCode.FORBIDDEN, "world.reset requires the reset admin grant")
707
+ initial = p.get("initial_state", "home")
708
+ if initial not in self.manifest["initial_states"]:
709
+ raise AwpError(ErrorCode.PARAMS_INVALID, f"unknown initial state {initial}")
710
+ if "seed" in p and not self.config.has("sim"):
711
+ raise AwpError(ErrorCode.PARAMS_INVALID, "seed needs capabilities.seed")
712
+ self._reset_effects(s, now, {"kind": "reset", "initial_state": initial})
713
+ self.arm.reset(HOME)
714
+ self.tick = 0
715
+ if "seed" in p:
716
+ self.rng.seed(p["seed"])
717
+ self._after_reset(c, rid, now)
718
+
719
+ def _reset_effects(self, s: Session, now: int, detail: dict[str, Any]) -> None:
720
+ """AWP-PRM-006 (1)-(2): warn every session, then end its actions with world_reset."""
721
+ for other in list(self.sessions.values()):
722
+ self._event(other, "world_resetting", now, {"initiator": s.id, **detail})
723
+ for a in list(other.actions.values()):
724
+ if a.state is ActionState.EXECUTING:
725
+ self._transition(other, a, ActionState.CANCELLING, now, reason="world_reset")
726
+ if a.state is ActionState.CANCELLING:
727
+ self._finish(
728
+ other, a, ActionState.CANCELLED, now, reason="world_reset", aborted=True
729
+ )
730
+ elif a.state.pre_execution:
731
+ self._finish(other, a, ActionState.CANCELLED, now, reason="world_reset")
732
+
733
+ def _after_reset(self, c: _Conn, rid: Any, now: int) -> None:
734
+ """AWP-PRM-006 (4): the result, then in lockstep a fresh frame carrying the new tick."""
735
+ self._reply(c, rid, {"tick": self.tick} if self.lockstep else {})
736
+ if self.lockstep:
737
+ for other in self.sessions.values():
738
+ for g in other.grants.values():
739
+ self._emit_frame(other, g, now)
740
+
741
+ # ================================================================ snapshots (sim)
742
+
743
+ def world_state(self) -> dict[str, Any]:
744
+ """Everything restore brings back: the arm, the tick, and the noise generator."""
745
+ return {"arm": copy.deepcopy(self.arm), "tick": self.tick, "rng": self.rng.getstate()}
746
+
747
+ def load_state(self, state: dict[str, Any]) -> None:
748
+ self.arm = copy.deepcopy(state["arm"])
749
+ self.tick = state["tick"]
750
+ self.rng.setstate(state["rng"])
751
+
752
+ def _rpc_world_snapshot(self, c: _Conn, rid: Any, p: dict[str, Any], now: int) -> None:
753
+ s = c.session
754
+ assert s is not None
755
+ if "snapshot" not in s.admin:
756
+ raise AwpError(ErrorCode.FORBIDDEN, "world.snapshot requires the snapshot admin grant")
757
+ token = "snap_" + secrets.token_urlsafe(18)
758
+ self._snapshots[token] = (
759
+ self.world_state()
760
+ ) # valid for the life of the process (AWP-REP-002)
761
+ self._reply(c, rid, {"snapshot_token": token})
762
+
763
+ def _rpc_world_restore(self, c: _Conn, rid: Any, p: dict[str, Any], now: int) -> None:
764
+ s = c.session
765
+ assert s is not None
766
+ if "restore" not in s.admin:
767
+ raise AwpError(ErrorCode.FORBIDDEN, "world.restore requires the restore admin grant")
768
+ state = self._snapshots.get(p["snapshot_token"])
769
+ if state is None:
770
+ raise AwpError(ErrorCode.PARAMS_INVALID, "unknown snapshot_token")
771
+ self._reset_effects(s, now, {"kind": "restore"})
772
+ self.load_state(state)
773
+ self._after_reset(c, rid, now)
774
+
775
+ # ================================================================ task
776
+
777
+ def _check_task(self, task: dict[str, Any]) -> None:
778
+ """AWP-TSK-002: text blocks; this world declares no modality for any other kind."""
779
+ kinds = {b["type"] for b in task["content"]}
780
+ if kinds - {"text"}:
781
+ raise AwpError(
782
+ ErrorCode.PARAMS_INVALID, f"unsupported task blocks {sorted(kinds - {'text'})}"
783
+ )
784
+
785
+ def _rpc_task_update(self, c: _Conn, rid: Any, p: dict[str, Any], now: int) -> None:
786
+ s = c.session
787
+ assert s is not None
788
+ self._check_task(p["task"])
789
+ s.task = p["task"] # whole, no diffing; running actions are unaffected (AWP-TSK-003)
790
+ self._reply(c, rid, {})
791
+
792
+ # ================================================================ actions
793
+
794
+ def _rpc_action_submit(self, c: _Conn, rid: Any, p: dict[str, Any], now: int) -> None:
795
+ s = c.session
796
+ assert s is not None
797
+ received = self._clock(s, now)
798
+ known = s.actions.get(p["action_id"])
799
+ if known is not None:
800
+ if not same_submission(known.content, p):
801
+ raise AwpError(ErrorCode.ACTION_ID_CONFLICT, f"{p['action_id']} has other content")
802
+ result = {
803
+ k: v
804
+ for k, v in known.status.items()
805
+ if k in ("action_id", "state", "status_seq", "ts_mono_ns", "reason")
806
+ }
807
+ self._reply(c, rid, {**result, "received_ts_mono_ns": known.received_ns})
808
+ return
809
+ decl = self._admit(s, p, received, now)
810
+ a = Action(p["action_id"], p, decl, received_ns=received)
811
+ if decl["type"] == "move_to_pose":
812
+ pose = p["params"]["pose"]
813
+ a.target = (pose["p_m"][0], pose["p_m"][1], pose["p_m"][2])
814
+ a.v_max = p["params"].get("max_velocity_mps", self.config.max_velocity_mps)
815
+ elif decl["type"] == "park":
816
+ a.target, a.v_max = PARK, self.config.max_velocity_mps
817
+ bound = [v for v in (p.get("deadline_ms"), decl.get("max_duration_ms")) if v is not None]
818
+ if bound and not self.lockstep:
819
+ a.deadline_ns = received + min(bound) * MS # AWP-ACT-004, AWP-ACT-008
820
+ preempt = p.get("preempt") or self._policies(decl)[0]
821
+ busy = self._busy(s, a.group)
822
+ s.actions[a.action_id] = a
823
+ s.last_admitted_ns = received
824
+ a.replaces = preempt in ("replace", "blend")
825
+ a.blends = preempt == "blend"
826
+ if a.replaces:
827
+ self._supersede_pending(s, a.group, now)
828
+ if decl.get("requires_approval"):
829
+ state = ActionState.PENDING_APPROVAL # admitted now, decided later (AWP-APR-001)
830
+ elif preempt == "queue" and busy:
831
+ state = ActionState.QUEUED
832
+ else:
833
+ state = ActionState.ACCEPTED
834
+ a.state = state
835
+ result = self._result_status(s, a, now)
836
+ admission = self._clock(s, now) - received
837
+ s.telemetry.admission.append(admission)
838
+ if "basis_ts_mono_ns" in p:
839
+ s.telemetry.observation_to_action.append(max(0, received - p["basis_ts_mono_ns"]))
840
+ self._reply(
841
+ c,
842
+ rid,
843
+ {
844
+ "action_id": a.action_id,
845
+ "state": str(state),
846
+ "status_seq": result["status_seq"],
847
+ "received_ts_mono_ns": received,
848
+ "ts_mono_ns": result["ts_mono_ns"],
849
+ },
850
+ )
851
+ self._activate(s, now)
852
+ if state is ActionState.PENDING_APPROVAL:
853
+ self._request_approval(s, a, now)
854
+ elif state is ActionState.QUEUED:
855
+ s.queue(a.group).append(a)
856
+ elif self.lockstep:
857
+ s.staged.append(a) # AWP-TIM-010
858
+ else:
859
+ self._execute(s, a, now)
860
+
861
+ def _admit(self, s: Session, p: dict[str, Any], received: int, now: int) -> dict[str, Any]:
862
+ """Admission checks, in the order a first-time submission is refused."""
863
+ if s.embodiment is None:
864
+ raise AwpError(ErrorCode.FORBIDDEN, "observer sessions cannot act (AWP-EMB-004)")
865
+ if p["type"] not in s.action_types:
866
+ raise AwpError(ErrorCode.FORBIDDEN, f"{p['type']} is not granted")
867
+ if p.get("embodiment_id", s.embodiment) != s.embodiment:
868
+ raise AwpError(ErrorCode.FORBIDDEN, f"{p['embodiment_id']} is not bound")
869
+ if self.estop:
870
+ raise AwpError(ErrorCode.ESTOP_ACTIVE)
871
+ interval = S / self.config.max_action_rate_hz
872
+ clock = self._clock(s, now)
873
+ if s.last_admitted_ns is not None and clock - s.last_admitted_ns < interval:
874
+ wait = (s.last_admitted_ns + interval - clock) / MS
875
+ raise AwpError(
876
+ ErrorCode.ENVELOPE_EXCEEDED,
877
+ "max_action_rate_hz",
878
+ retryable=True,
879
+ retry_after_ms=max(1, round(wait)),
880
+ )
881
+ decl: dict[str, Any] = self._decls[p["type"]]
882
+ problems = self._params.errors(p["type"], p["params"])
883
+ if problems:
884
+ raise AwpError(ErrorCode.PARAMS_INVALID, "; ".join(problems[:3]))
885
+ if p.get("preempt", self._policies(decl)[0]) not in self._policies(decl):
886
+ raise AwpError(
887
+ ErrorCode.PARAMS_INVALID, f"preempt must be one of {self._policies(decl)}"
888
+ )
889
+ if p["type"] == "move_to_pose":
890
+ self._check_envelope(p["params"])
891
+ self._check_intent(p, received)
892
+ policy = p.get("preempt", self._policies(decl)[0])
893
+ group = decl.get("concurrency_group", f"_{p['type']}")
894
+ if policy == "reject" and self._busy(s, group):
895
+ raise AwpError(ErrorCode.BUSY, f"{group} is busy")
896
+ if policy == "queue" and self._busy(s, group) and len(s.queue(group)) >= decl["max_queue"]:
897
+ raise AwpError(ErrorCode.QUEUE_FULL)
898
+ return decl
899
+
900
+ def _check_envelope(self, params: dict[str, Any]) -> None:
901
+ pose = params["pose"]
902
+ if pose["frame"] != "base":
903
+ raise AwpError(ErrorCode.PARAMS_INVALID, "pose.frame must be base")
904
+ lo, hi = self.config.aabb_m
905
+ if not all(lo[i] <= pose["p_m"][i] <= hi[i] for i in range(3)):
906
+ raise AwpError(ErrorCode.ENVELOPE_EXCEEDED, "target outside the spatial envelope")
907
+ if params.get("max_velocity_mps", 0) > self.config.max_velocity_mps:
908
+ raise AwpError(ErrorCode.ENVELOPE_EXCEEDED, "max_velocity_mps above the envelope")
909
+
910
+ def _check_intent(self, p: dict[str, Any], now_session: int) -> None:
911
+ """AWP-SAF-013; binding in streaming, advisory (and ignored) in lockstep."""
912
+ if self.lockstep:
913
+ return
914
+ basis = p.get("basis_ts_mono_ns")
915
+ if basis is not None and now_session - basis > self.config.max_basis_age_ms * MS:
916
+ raise AwpError(ErrorCode.STALE_INTENT, "basis older than max_basis_age_ms")
917
+ if p.get("valid_until_ns") is not None and p["valid_until_ns"] <= now_session:
918
+ raise AwpError(ErrorCode.STALE_INTENT, "valid_until_ns has passed")
919
+
920
+ def _still_valid(self, s: Session, a: Action, now: int) -> str | None:
921
+ """Re-check a queued action as it becomes accepted; returns a rejection reason."""
922
+ clock = self._clock(s, now)
923
+ if a.deadline_ns is not None and clock > a.deadline_ns:
924
+ return "deadline_exceeded"
925
+ try:
926
+ self._check_intent(a.content, clock)
927
+ except AwpError:
928
+ return "stale_intent"
929
+ return None
930
+
931
+ def _policies(self, decl: dict[str, Any]) -> list[str]:
932
+ pre = decl["preemption"]
933
+ return [pre] if isinstance(pre, str) else list(pre)
934
+
935
+ def _busy(self, s: Session, group: str) -> bool:
936
+ return group in s.running or bool(s.queue(group)) or any(a.group == group for a in s.staged)
937
+
938
+ def _supersede_pending(self, s: Session, group: str, now: int) -> None:
939
+ pending = [
940
+ a
941
+ for a in s.actions.values()
942
+ if a.state is ActionState.PENDING_APPROVAL and a.group == group
943
+ ]
944
+ for a in [*s.queue(group), *[a for a in s.staged if a.group == group], *pending]:
945
+ self._finish(s, a, ActionState.CANCELLED, now, reason="superseded")
946
+
947
+ def _preempt_running(self, s: Session, group: str, now: int, *, blended: bool = False) -> None:
948
+ """A replacing action takes over its group when it begins executing (AWP-PRE-003); a
949
+ blending one merges into the motion under way, so the arm does not stop (AWP-PRE-004)."""
950
+ running = s.running.get(group)
951
+ if running is None:
952
+ return
953
+ if running.state is ActionState.EXECUTING and running.failing_with is None:
954
+ self._finish(s, running, ActionState.PREEMPTED, now, blended=blended or None)
955
+ else: # an abort already under way ends as it would have (AWP-LIF-008)
956
+ state = ActionState.FAILED if running.failing_with else ActionState.CANCELLED
957
+ reason = running.failing_with or running.cancel_reason
958
+ self._finish(s, running, state, now, reason=reason, aborted=True)
959
+
960
+ def _execute(self, s: Session, a: Action, now: int) -> None:
961
+ if a.replaces:
962
+ self._preempt_running(s, a.group, now, blended=a.blends)
963
+ s.running[a.group] = a
964
+ a.last_progress_ns = now
965
+ a.last_frame_ns = now
966
+ exiting_safe_state = s.safe_state
967
+ if a.target is not None:
968
+ self.arm.move_to(a.target, a.v_max)
969
+ elif a.decl["duration"] == "streaming":
970
+ self.arm.servo() # setpoints arrive on the command channel (AWP-CMD-003)
971
+ a.stream = {"frames_applied": 0, "last_seq": 0, "clamped_count": 0}
972
+ else:
973
+ self.arm.stop()
974
+ if exiting_safe_state:
975
+ s.safe_state = False
976
+ self._event(s, "safe_state_exited", now, {"embodiment": EMBODIMENT}) # AWP-SAF-008
977
+ self._transition(s, a, ActionState.EXECUTING, now, progress=0.0)
978
+ if a.decl["duration"] == "instant":
979
+ self._finish(s, a, ActionState.COMPLETED, now, progress=1.0)
980
+
981
+ def _finish(
982
+ self,
983
+ s: Session,
984
+ a: Action,
985
+ state: ActionState,
986
+ now: int,
987
+ *,
988
+ reason: str | None = None,
989
+ aborted: bool = False,
990
+ progress: float | None = None,
991
+ blended: bool | None = None,
992
+ ) -> None:
993
+ was_executing = a.state in (ActionState.EXECUTING, ActionState.CANCELLING)
994
+ if a.approval_id is not None:
995
+ self._approvals.pop(a.approval_id, None)
996
+ self._transition(
997
+ s,
998
+ a,
999
+ state,
1000
+ now,
1001
+ reason=reason,
1002
+ progress=progress,
1003
+ blended=blended,
1004
+ aborted_at_progress=round(self.arm.progress, 4) if aborted else None,
1005
+ stream=a.stream,
1006
+ )
1007
+ if s.running.get(a.group) is a:
1008
+ del s.running[a.group]
1009
+ queue = s.queue(a.group)
1010
+ if a in queue:
1011
+ queue.remove(a)
1012
+ if a in s.staged:
1013
+ s.staged.remove(a)
1014
+ if was_executing and self.arm.phase in (Phase.MOVING, Phase.SERVO) and not blended:
1015
+ self.arm.stop()
1016
+
1017
+ def _rpc_action_cancel(self, c: _Conn, rid: Any, p: dict[str, Any], now: int) -> None:
1018
+ s = c.session
1019
+ assert s is not None
1020
+ a = s.actions.get(p["action_id"])
1021
+ if a is None:
1022
+ raise AwpError(ErrorCode.ACTION_UNKNOWN, p["action_id"])
1023
+ if a.state.pre_execution:
1024
+ self._remove_pending(s, a)
1025
+ a.state = ActionState.CANCELLED
1026
+ res = self._result_status(s, a, now, reason="cancelled_by_agent")
1027
+ elif a.state is ActionState.EXECUTING:
1028
+ a.state = ActionState.CANCELLING
1029
+ a.cancel_reason = "cancelled_by_agent"
1030
+ a.cancel_started_ns = now
1031
+ self.arm.stop()
1032
+ res = self._result_status(s, a, now, reason="cancelled_by_agent")
1033
+ else:
1034
+ res = a.status # cancelling or terminal: nothing changes
1035
+ self._reply(
1036
+ c, rid, {k: res[k] for k in ("action_id", "state", "status_seq", "reason") if k in res}
1037
+ )
1038
+ self._activate(s, now)
1039
+
1040
+ def _remove_pending(self, s: Session, a: Action) -> None:
1041
+ queue = s.queue(a.group)
1042
+ if a in queue:
1043
+ queue.remove(a)
1044
+ if a in s.staged:
1045
+ s.staged.remove(a)
1046
+
1047
+ def _rpc_action_status(self, c: _Conn, rid: Any, p: dict[str, Any], now: int) -> None:
1048
+ s = c.session
1049
+ assert s is not None
1050
+ a = s.actions.get(p["action_id"])
1051
+ if a is None:
1052
+ raise AwpError(ErrorCode.ACTION_UNKNOWN, p["action_id"])
1053
+ self._reply(c, rid, a.status)
1054
+
1055
+ def _promote(self, s: Session, group: str, now: int) -> None:
1056
+ """Move the queue head into execution once its group frees (AWP-PRE-002)."""
1057
+ queue = s.queue(group)
1058
+ while queue and group not in s.running:
1059
+ a = queue.popleft()
1060
+ reason = self._still_valid(s, a, now)
1061
+ if reason is not None:
1062
+ self._transition(s, a, ActionState.REJECTED, now, reason=reason)
1063
+ continue
1064
+ self._transition(s, a, ActionState.ACCEPTED, now)
1065
+ if self.lockstep:
1066
+ s.staged.append(a)
1067
+ return
1068
+ self._execute(s, a, now)
1069
+
1070
+ # ================================================================ approval
1071
+
1072
+ def _request_approval(self, s: Session, a: Action, now: int) -> None:
1073
+ """AWP-APR-001: to every approver connection; logged in the requester's audit record."""
1074
+ approval_id = "ap_" + secrets.token_hex(6)
1075
+ expires = self._clock(s, now) + self.config.approval_timeout_ms * MS
1076
+ a.approval_id = approval_id
1077
+ self._approvals[approval_id] = (s.id, a.action_id, expires)
1078
+ params: dict[str, Any] = {
1079
+ "approval_id": approval_id,
1080
+ "action_id": a.action_id,
1081
+ "type": a.type,
1082
+ "params": a.content["params"],
1083
+ "requester": {"session": s.id, "embodiment": s.embodiment},
1084
+ "expires_at_ns": expires,
1085
+ }
1086
+ if s.task is not None:
1087
+ params["task"] = s.task # AWP-TSK-005
1088
+ msg = jsonrpc.notification("safety.approval_requested", params)
1089
+ if self._audit is not None:
1090
+ self._audit.record(s.id, self._clock(s, now), "world", msg)
1091
+ for c in self._conns.values():
1092
+ if c.approver and c.initialized:
1093
+ self._out.append(Send(c.id, msg))
1094
+
1095
+ def _rpc_approval_respond(self, c: _Conn, rid: Any, p: dict[str, Any], now: int) -> None:
1096
+ if not c.approver:
1097
+ raise AwpError(ErrorCode.FORBIDDEN, "this connection may not decide approvals")
1098
+ entry = self._approvals.get(p["approval_id"])
1099
+ if entry is None:
1100
+ raise AwpError(ErrorCode.PARAMS_INVALID, "unknown or already decided approval_id")
1101
+ session_id, action_id, _ = entry
1102
+ s = self.sessions[session_id]
1103
+ a = s.actions[action_id]
1104
+ if self._audit is not None:
1105
+ self._audit.record(
1106
+ s.id,
1107
+ self._clock(s, now),
1108
+ "approver",
1109
+ jsonrpc.request(rid, "safety.approval.respond", p),
1110
+ )
1111
+ self._reply(c, rid, {})
1112
+ if p["decision"] == "deny":
1113
+ self._finish(s, a, ActionState.REJECTED, now, reason="approval_denied") # AWP-APR-002
1114
+ return
1115
+ del self._approvals[p["approval_id"]]
1116
+ a.approval_id = None
1117
+ reason = self._still_valid(s, a, now)
1118
+ if reason is not None:
1119
+ self._transition(s, a, ActionState.REJECTED, now, reason=reason)
1120
+ elif self._busy(s, a.group):
1121
+ self._transition(s, a, ActionState.QUEUED, now)
1122
+ s.queue(a.group).append(a)
1123
+ else:
1124
+ self._transition(s, a, ActionState.ACCEPTED, now)
1125
+ if self.lockstep:
1126
+ s.staged.append(a)
1127
+ else:
1128
+ self._execute(s, a, now)
1129
+
1130
+ def _check_approvals(self, now: int) -> None:
1131
+ """AWP-APR-003: no decision by expires_at_ns (the session clock) is a rejection."""
1132
+ for approval_id, (session_id, action_id, expires) in list(self._approvals.items()):
1133
+ s = self.sessions.get(session_id)
1134
+ if s is None or self._clock(s, now) <= expires:
1135
+ continue
1136
+ a = s.actions.get(action_id)
1137
+ if a is not None and a.state is ActionState.PENDING_APPROVAL:
1138
+ self._finish(s, a, ActionState.REJECTED, now, reason="approval_timeout")
1139
+ self._approvals.pop(approval_id, None)
1140
+
1141
+ # ================================================================ transfer
1142
+
1143
+ def _rpc_session_transfer(self, c: _Conn, rid: Any, p: dict[str, Any], now: int) -> None:
1144
+ s = c.session
1145
+ assert s is not None
1146
+ if s.embodiment is None:
1147
+ raise AwpError(ErrorCode.FORBIDDEN, "only the holder of an embodiment can transfer it")
1148
+ ttl = p.get("expires_in_ms", 30000)
1149
+ token = "tt_" + secrets.token_urlsafe(18)
1150
+ self._transfers[token] = (s.id, now + ttl * MS)
1151
+ self._reply(c, rid, {"transfer_token": token, "expires_in_ms": ttl})
1152
+
1153
+ def _take_over(self, p: dict[str, Any], now: int) -> None:
1154
+ """AWP-EMB-003: a single-use token from the holder moves the embodiment to this open."""
1155
+ entry = self._transfers.pop(p.get("transfer_token", ""), None)
1156
+ holder = self.sessions.get(entry[0]) if entry else None
1157
+ if (
1158
+ entry is None
1159
+ or now > entry[1]
1160
+ or holder is None
1161
+ or holder.embodiment != p.get("embodiment")
1162
+ ):
1163
+ raise AwpError(ErrorCode.EMBODIMENT_UNAVAILABLE, "invalid or expired transfer_token")
1164
+ for a in list(holder.actions.values()):
1165
+ if a.state is ActionState.EXECUTING:
1166
+ self._finish(holder, a, ActionState.PREEMPTED, now, reason="transferred")
1167
+ elif a.state is ActionState.CANCELLING: # the abort ends as it would have (AWP-LIF-008)
1168
+ state = ActionState.FAILED if a.failing_with else ActionState.CANCELLED
1169
+ self._finish(
1170
+ holder, a, state, now, reason=a.failing_with or a.cancel_reason, aborted=True
1171
+ )
1172
+ elif a.state.pre_execution:
1173
+ self._finish(holder, a, ActionState.CANCELLED, now, reason="transferred")
1174
+ detail = {"embodiment": holder.embodiment}
1175
+ holder.embodiment = None # it continues as an observer session
1176
+ self._holder = None
1177
+ self._event(holder, "embodiment_transferred", now, detail)
1178
+
1179
+ # ================================================================ execution
1180
+
1181
+ def _step_arm(self, dt_ns: int) -> None:
1182
+ while dt_ns > 0:
1183
+ step = min(dt_ns, 5 * MS)
1184
+ self.arm.step(step / S)
1185
+ dt_ns -= step
1186
+
1187
+ def _update_actions(self, s: Session, now: int) -> None:
1188
+ for a in list(s.running.values()):
1189
+ aborting = a.state is ActionState.CANCELLING or a.failing_with is not None
1190
+ if aborting and self.arm.at_rest:
1191
+ if a.state is ActionState.CANCELLING:
1192
+ self._finish(
1193
+ s, a, ActionState.CANCELLED, now, reason=a.cancel_reason, aborted=True
1194
+ )
1195
+ else:
1196
+ self._finish(s, a, ActionState.FAILED, now, reason=a.failing_with, aborted=True)
1197
+ elif aborting and self._abort_overdue(a, now): # AWP-LIF-010
1198
+ self._finish(s, a, ActionState.FAILED, now, reason="abort_failed", aborted=True)
1199
+ self._enter_safe_state(s, now)
1200
+ elif a.state is ActionState.EXECUTING and a.decl["duration"] == "streaming":
1201
+ if now - a.last_frame_ns > a.decl["watchdog_ms"] * MS: # AWP-CMD-005
1202
+ a.failing_with = "watchdog"
1203
+ a.cancel_started_ns = now
1204
+ self.arm.stop()
1205
+ elif self._progress_due(a, now):
1206
+ a.last_progress_ns = now
1207
+ self._transition(s, a, ActionState.EXECUTING, now, stream=dict(a.stream or {}))
1208
+ elif a.state is ActionState.EXECUTING and not aborting:
1209
+ if self.arm.phase is Phase.IDLE and self.arm.at_rest:
1210
+ self._finish(s, a, ActionState.COMPLETED, now, progress=1.0)
1211
+ elif self._progress_due(a, now):
1212
+ a.last_progress_ns = now
1213
+ self._transition(
1214
+ s, a, ActionState.EXECUTING, now, progress=round(self.arm.progress, 4)
1215
+ )
1216
+ for group in [g for g, q in s.queues.items() if q and g not in s.running]:
1217
+ self._promote(s, group, now)
1218
+ if s.closing_reason is not None and not s.running:
1219
+ self._finalize_close(s, now)
1220
+
1221
+ def _abort_overdue(self, a: Action, now: int) -> bool:
1222
+ limit = a.decl.get("max_abort_ms")
1223
+ return (
1224
+ not self.lockstep
1225
+ and limit is not None
1226
+ and a.cancel_started_ns is not None
1227
+ and now - a.cancel_started_ns > limit * MS
1228
+ )
1229
+
1230
+ def _progress_due(self, a: Action, now: int) -> bool:
1231
+ if self.lockstep:
1232
+ return bool(a.status.get("tick") != self.tick) # once per advance (AWP-LIF-003)
1233
+ return now - a.last_progress_ns >= self.config.progress_interval_ms * MS
1234
+
1235
+ def _check_deadlines(self, s: Session, now: int) -> None:
1236
+ clock = self._clock(s, now)
1237
+ for a in list(s.actions.values()):
1238
+ if a.deadline_ns is None or clock <= a.deadline_ns or a.state.terminal:
1239
+ continue
1240
+ if a.state.pre_execution:
1241
+ self._remove_pending(s, a)
1242
+ self._transition(s, a, ActionState.REJECTED, now, reason="deadline_exceeded")
1243
+ elif a.state is ActionState.EXECUTING and a.failing_with is None:
1244
+ a.failing_with = "deadline_exceeded" # fails once the safe abort completes
1245
+ a.cancel_started_ns = now
1246
+ self.arm.stop()
1247
+
1248
+ def _check_watchdog(self, s: Session, now: int) -> None:
1249
+ if s.embodiment is None or s.safe_state or s.state == "closed":
1250
+ return
1251
+ if now - s.last_agent_ns > self.config.watchdog_ms * MS:
1252
+ self._enter_safe_state(s, now)
1253
+
1254
+ def _enter_safe_state(self, s: Session, now: int) -> None:
1255
+ """AWP-SAF-004: behavior first, then terminations, then the event."""
1256
+ self.arm.stop()
1257
+ if self.arm.stuck:
1258
+ self.arm.halt()
1259
+ s.safe_state = True
1260
+ for a in list(s.actions.values()):
1261
+ if a.state in (ActionState.EXECUTING, ActionState.CANCELLING):
1262
+ self._finish(s, a, ActionState.FAILED, now, reason="connection_lost", aborted=True)
1263
+ elif a.state.pre_execution:
1264
+ self._finish(s, a, ActionState.CANCELLED, now, reason="safe_state")
1265
+ self._event(
1266
+ s, "safe_state_entered", now, {"behavior": "safe_stop", "embodiment": EMBODIMENT}
1267
+ )
1268
+
1269
+ # ================================================================ lockstep
1270
+
1271
+ def _rpc_world_tick(self, c: _Conn, rid: Any, p: dict[str, Any], now: int) -> None:
1272
+ s = c.session
1273
+ assert s is not None
1274
+ if not self.lockstep:
1275
+ raise AwpError(ErrorCode.TIME_MODEL_UNSUPPORTED, "world.tick is lockstep-only")
1276
+ if s.embodiment is None:
1277
+ raise AwpError(ErrorCode.TICK_NOT_AUTHORIZED, "observer sessions cannot tick")
1278
+ if p["expected_tick"] != self.tick:
1279
+ raise AwpError(
1280
+ ErrorCode.TICK_MISMATCH,
1281
+ f"expected_tick {p['expected_tick']}, current {self.tick}",
1282
+ tick=self.tick,
1283
+ )
1284
+ count = p.get("count", 1)
1285
+ if not 1 <= count <= 10_000:
1286
+ raise AwpError(ErrorCode.INVALID_PARAMS, "count must be 1..10000")
1287
+ for _ in range(count):
1288
+ self._advance_tick(now)
1289
+ self._reply(c, rid, {"tick": self.tick})
1290
+
1291
+ def _advance_tick(self, now: int) -> None:
1292
+ """One advance: statuses and events, then a frame per per-tick channel (AWP-TIM-003)."""
1293
+ self.tick += 1
1294
+ self.advances += 1
1295
+ self._check_approvals(now)
1296
+ for s in list(self.sessions.values()):
1297
+ for a in list(s.staged):
1298
+ s.staged.remove(a)
1299
+ if a.state is ActionState.ACCEPTED:
1300
+ self._execute(s, a, now)
1301
+ self.arm.step(self.config.tick_ms / 1000)
1302
+ for s in list(self.sessions.values()):
1303
+ self._update_actions(s, now)
1304
+ for s in list(self.sessions.values()):
1305
+ if s.conn is not None:
1306
+ for g in s.grants.values():
1307
+ self._emit_frame(s, g, now)
1308
+
1309
+ # ================================================================ frames and telemetry
1310
+
1311
+ def _readable(self, embodiment: str | None, consumes: frozenset[str]) -> set[str]:
1312
+ """Channels the session may read: its embodiment's (every channel for an observer), and
1313
+ only in modalities the agent declared (AWP-AGM-001)."""
1314
+ names = (
1315
+ set(self._channels)
1316
+ if embodiment is None
1317
+ else set(self.manifest["embodiments"][0]["channels"])
1318
+ )
1319
+ return {
1320
+ n for n in names if n in self._channels and self._channels[n]["modality"] in consumes
1321
+ }
1322
+
1323
+ def _rate(
1324
+ self, channel: str, requested: float | None, cap: float | None = None
1325
+ ) -> float | None:
1326
+ """The granted rate: never above the declared one, the request, or the agent's
1327
+ max_obs_rate_hz (AWP-NEG-003, AWP-AGM-002)."""
1328
+ declared: float | None = self._channels[channel]["rate_hz"]
1329
+ if declared is None:
1330
+ return None
1331
+ bounds = [declared, *(float(v) for v in (requested, cap) if v is not None)]
1332
+ return min(bounds)
1333
+
1334
+ def _grant(
1335
+ self, s: Session, channel: str, rate: float | None, now: int, cap: float | None = None
1336
+ ) -> ChannelGrant:
1337
+ g = ChannelGrant(
1338
+ channel,
1339
+ s.next_channel_id,
1340
+ self._rate(channel, rate, cap),
1341
+ self._channels[channel]["loss_class"],
1342
+ next_due_ns=now,
1343
+ )
1344
+ s.next_channel_id += 1
1345
+ s.grants[channel] = g
1346
+ return g
1347
+
1348
+ def _granted(self, s: Session) -> dict[str, Any]:
1349
+ return {
1350
+ "channels": [g.to_wire() for g in s.grants.values()],
1351
+ "action_types": s.action_types,
1352
+ "admin": s.admin,
1353
+ "envelopes": [self.config.envelope] if s.embodiment else [],
1354
+ }
1355
+
1356
+ def _may_grant_admin(self, op: str, embodiment: str | None) -> bool:
1357
+ if op == "tick":
1358
+ return self.lockstep and embodiment is not None
1359
+ if op in ("reset", "restore"): # at most one session at a time (AWP-PRM-005)
1360
+ if op == "restore" and not self.config.has("sim"):
1361
+ return False
1362
+ return not any(op in other.admin for other in self.sessions.values())
1363
+ return op == "snapshot" and self.config.has("sim")
1364
+
1365
+ def _payload(self, channel: str) -> bytes:
1366
+ arm = self.arm
1367
+ if channel == "proprio":
1368
+ noise = self.config.has("sim")
1369
+ body: dict[str, Any] = {
1370
+ "p_m": [
1371
+ round(v + (self.rng.gauss(0, 1e-4) if noise else 0), 6) for v in arm.position
1372
+ ],
1373
+ "v_mps": [round(v, 6) for v in arm.velocity],
1374
+ }
1375
+ else:
1376
+ holder = self._holder
1377
+ running = holder.running.get("arm_motion") if holder else None
1378
+ body = {
1379
+ "phase": str(arm.phase),
1380
+ "target_m": list(arm.target)
1381
+ if arm.target and arm.phase is not Phase.IDLE
1382
+ else None,
1383
+ "action_id": running.action_id if running else None,
1384
+ }
1385
+ return json.dumps(body, separators=(",", ":")).encode()
1386
+
1387
+ def _emit_frame(self, s: Session, g: ChannelGrant, now: int) -> None:
1388
+ if g.command:
1389
+ return # agent→world
1390
+ if s.conn is None or (s.stream_conn is None and s.stream_lost_ns is not None):
1391
+ return # no control connection, or the channel waits for its stream (AWP-TRN-010)
1392
+ g.seq += 1
1393
+ frame = Frame(
1394
+ channel_id=g.channel_id,
1395
+ seq=g.seq,
1396
+ ts_mono_ns=self._clock(s, now),
1397
+ payload=self._payload(g.name),
1398
+ keyframe=True,
1399
+ resync=g.resync,
1400
+ tick=self.tick if self.lockstep else None,
1401
+ ts_sim_ns=self._sim_ns() if self.lockstep else None,
1402
+ )
1403
+ g.resync = False
1404
+ streaming_lw = not self.lockstep and g.loss_class == "latest-wins"
1405
+ inline = jsonrpc.notification("obs.frame", to_inline(frame))
1406
+ if s.stream_conn is not None:
1407
+ self._out.append(SendFrame(s.stream_conn, frame, s.id, streaming_lw))
1408
+ if self._audit is not None:
1409
+ self._audit.record(s.id, frame.ts_mono_ns, "world", inline)
1410
+ else:
1411
+ self._to_session(s, inline, latest_wins=streaming_lw)
1412
+ self._activate(s, now)
1413
+
1414
+ # ================================================================ command channels
1415
+
1416
+ def _on_command_frame(self, s: Session, frame: Frame, now: int) -> None:
1417
+ """A setpoint: applied only while its servo action executes (AWP-CMD-003), in seq order
1418
+ (AWP-CMD-007), and envelope-checked before actuation (AWP-CMD-006)."""
1419
+ grant = s.grants.get(SERVO_CHANNEL)
1420
+ a = s.running.get("arm_motion")
1421
+ if grant is None or frame.channel_id != grant.channel_id or a is None:
1422
+ return
1423
+ if a.decl["duration"] != "streaming" or a.state is not ActionState.EXECUTING:
1424
+ return
1425
+ if a.failing_with is not None or frame.seq <= a.command_seq:
1426
+ return
1427
+ try:
1428
+ v = json.loads(frame.payload)["v_mps"]
1429
+ velocity = (float(v[0]), float(v[1]), float(v[2]))
1430
+ except (ValueError, KeyError, TypeError, IndexError):
1431
+ return
1432
+ assert a.stream is not None
1433
+ a.command_seq = frame.seq
1434
+ a.last_frame_ns = now
1435
+ s.telemetry.command.append(max(0, self._clock(s, now) - frame.ts_mono_ns))
1436
+ if sum(x * x for x in velocity) ** 0.5 > self.config.max_velocity_mps:
1437
+ a.stream["clamped_count"] += 1 # on_violation: reject drops the frame
1438
+ return
1439
+ self.arm.command(velocity)
1440
+ a.stream["frames_applied"] += 1
1441
+ a.stream["last_seq"] = frame.seq
1442
+
1443
+ def _stream_frames(self, s: Session, now: int) -> None:
1444
+ for g in s.grants.values():
1445
+ if g.command or g.rate_hz is None or now < g.next_due_ns:
1446
+ continue
1447
+ period = round(S / g.rate_hz)
1448
+ g.next_due_ns = max(g.next_due_ns + period, now - period)
1449
+ self._emit_frame(s, g, now)
1450
+
1451
+ def _send_telemetry(self, s: Session, now: int) -> None:
1452
+ interval = self.config.telemetry_interval_ms * MS
1453
+ if now - s.last_telemetry_ns < interval:
1454
+ return
1455
+ window = (now - s.last_telemetry_ns) // MS
1456
+ s.last_telemetry_ns = now
1457
+ self._to_session(s, jsonrpc.notification("session.telemetry", s.telemetry.snapshot(window)))
1458
+ s.telemetry = Telemetry()
1459
+
1460
+ # ================================================================ liveness and retention
1461
+
1462
+ def _heartbeat(self, c: _Conn, now: int) -> None:
1463
+ interval = self.config.heartbeat_interval_ms * MS
1464
+ if c.session is None:
1465
+ if now - c.last_rx_ns > max(15 * S, 3 * interval): # AWP-SES-012
1466
+ self._out.append(Close(c.id, "idle"))
1467
+ self._conns.pop(c.id, None)
1468
+ return
1469
+ if now - c.last_rx_ns > 3 * interval: # AWP-SAF-002
1470
+ self._out.append(Close(c.id, "heartbeat lost"))
1471
+ self._conns.pop(c.id, None)
1472
+ if c.session.conn == c.id:
1473
+ self._suspend(c.session, now, "connection_lost")
1474
+ return
1475
+ if now - c.last_ping_ns >= interval:
1476
+ c.last_ping_ns = now
1477
+ rid = f"w{c.next_id}"
1478
+ c.next_id += 1
1479
+ self._send(c, jsonrpc.request(rid, "ping", {"origin_ns": self._clock(c.session, now)}))
1480
+
1481
+ def _end_stream(self, s: Session, reason: str) -> None:
1482
+ if s.stream_conn is not None:
1483
+ self._out.append(Close(s.stream_conn, reason))
1484
+ self._streams.pop(s.stream_conn, None)
1485
+ s.stream_conn = None
1486
+ s.stream_lost_ns = None # frames go inline until the agent attaches again (AWP-TRN-008)
1487
+
1488
+ def _check_stream(self, s: Session, now: int) -> None:
1489
+ """A reliable channel whose stream connection is gone is degraded (AWP-SAF-009)."""
1490
+ if s.stream_lost_ns is None or s.stream_degraded_reported:
1491
+ return
1492
+ for g in s.grants.values():
1493
+ stale = self._channels[g.name].get("stale_after_ms")
1494
+ limit = (stale or 2000 / g.rate_hz) * MS if g.rate_hz else None
1495
+ if g.loss_class == "reliable" and limit and now - s.stream_lost_ns > limit:
1496
+ s.stream_degraded_reported = True
1497
+ self._event(s, "channel_degraded", now, {"channel": g.name})
1498
+
1499
+ def _suspend(self, s: Session, now: int, reason: str) -> None:
1500
+ self._end_stream(s, "session suspended")
1501
+ s.conn = None
1502
+ s.suspended_ns = now
1503
+ if s.state != "closed":
1504
+ self._session_state(s, "suspended", reason, now)
1505
+
1506
+ def _check_retention(self, s: Session, now: int) -> None:
1507
+ window = self.config.reconnect_window_ms * MS
1508
+ expired = [
1509
+ a.action_id
1510
+ for a in s.actions.values()
1511
+ if a.terminal_ns is not None and now - a.terminal_ns > window
1512
+ ]
1513
+ for action_id in expired:
1514
+ del s.actions[
1515
+ action_id
1516
+ ] # retained for the window after the terminal transition (AWP-ACT-006)
1517
+ if s.suspended_ns is None:
1518
+ return
1519
+ away = now - s.suspended_ns
1520
+ if not s.degraded_reported:
1521
+ for g in s.grants.values():
1522
+ stale = self._channels[g.name].get("stale_after_ms")
1523
+ if (
1524
+ g.loss_class == "reliable"
1525
+ and g.rate_hz
1526
+ and away > (stale or 2000 / g.rate_hz) * MS
1527
+ ):
1528
+ s.degraded_reported = True
1529
+ self._event(s, "channel_degraded", now, {"channel": g.name}) # AWP-SAF-009
1530
+ if away > self.config.reconnect_window_ms * MS:
1531
+ if s.embodiment is not None and not s.safe_state and not self.lockstep:
1532
+ self._enter_safe_state(s, now) # AWP-SES-005
1533
+ self._close_session(s, now, "window_expired")
1534
+
1535
+ # ================================================================ closing
1536
+
1537
+ def _close_session(self, s: Session, now: int, reason: str) -> None:
1538
+ """AWP-SES-006: pre-execution cancelled; executing aborted through cancelling."""
1539
+ s.closing_reason = reason
1540
+ for a in s.pre_execution():
1541
+ self._finish(s, a, ActionState.CANCELLED, now, reason="session_closed")
1542
+ for a in list(s.running.values()):
1543
+ a.cancel_reason = "session_closed" # close outranks a cancel in progress (AWP-LIF-008)
1544
+ if a.state is ActionState.EXECUTING and a.failing_with is None:
1545
+ a.cancel_started_ns = now
1546
+ self._transition(s, a, ActionState.CANCELLING, now, reason="session_closed")
1547
+ self.arm.stop()
1548
+ if self.lockstep or s.conn is None:
1549
+ self.arm.halt() # no simulated time will pass for the abort to run in
1550
+ for a in list(s.running.values()):
1551
+ state = ActionState.FAILED if a.failing_with else ActionState.CANCELLED
1552
+ why = a.failing_with or a.cancel_reason
1553
+ self._finish(s, a, state, now, reason=why, aborted=True)
1554
+ if not s.running:
1555
+ self._finalize_close(s, now)
1556
+
1557
+ def _finalize_close(self, s: Session, now: int) -> None:
1558
+ self._session_state(s, "closed", s.closing_reason or "session_closed", now)
1559
+ self._end_stream(s, "session closed")
1560
+ for conn, rid in s.closing:
1561
+ if conn in self._conns:
1562
+ self._send(self._conns[conn], jsonrpc.result(rid, {}))
1563
+ s.closing.clear()
1564
+ if self._holder is s:
1565
+ self._holder = None
1566
+ self.sessions.pop(s.id, None)
1567
+ self._tokens.pop(s.token, None)
1568
+ self._closed_tokens[s.token] = None
1569
+ while len(self._closed_tokens) > _CLOSED_TOKENS_LIMIT:
1570
+ self._closed_tokens.popitem(last=False)
1571
+ if s.conn is not None and s.conn in self._conns:
1572
+ self._conns[s.conn].session = None
1573
+ s.conn = None
1574
+ if self._audit is not None:
1575
+ self._audit.close(s.id)
1576
+
1577
+
1578
+ def encode_state(state: dict[str, Any]) -> dict[str, Any]:
1579
+ """`World.world_state()` as JSON, for replay bundles."""
1580
+ arm = {f.name: getattr(state["arm"], f.name) for f in fields(Arm)}
1581
+ version, internal, gauss = state["rng"]
1582
+ return {
1583
+ "arm": json.loads(json.dumps(arm)),
1584
+ "tick": state["tick"],
1585
+ "rng": [version, list(internal), gauss],
1586
+ }
1587
+
1588
+
1589
+ def decode_state(data: dict[str, Any]) -> dict[str, Any]:
1590
+ def tuples(v: Any) -> Any:
1591
+ return tuple(tuples(x) for x in v) if isinstance(v, list) else v
1592
+
1593
+ arm = {k: tuples(v) for k, v in data["arm"].items()}
1594
+ arm["phase"] = Phase(arm["phase"])
1595
+ version, internal, gauss = data["rng"]
1596
+ return {"arm": Arm(**arm), "tick": data["tick"], "rng": (version, tuple(internal), gauss)}
1597
+
1598
+
1599
+ def encode_config(config: WorldConfig) -> dict[str, Any]:
1600
+ out = asdict(config)
1601
+ out["features"] = sorted(config.features)
1602
+ encoded: dict[str, Any] = json.loads(json.dumps(out))
1603
+ return encoded
1604
+
1605
+
1606
+ def decode_config(data: dict[str, Any]) -> WorldConfig:
1607
+ def tuples(v: Any) -> Any:
1608
+ return tuple(tuples(x) for x in v) if isinstance(v, list) else v
1609
+
1610
+ return WorldConfig(
1611
+ **{**data, "features": frozenset(data["features"]), "aabb_m": tuples(data["aabb_m"])}
1612
+ )