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.
- awp/__init__.py +64 -0
- awp/_spec/lifecycle.json +190 -0
- awp/_spec/schemas/action-cancel-result.schema.json +13 -0
- awp/_spec/schemas/action-ref.schema.json +11 -0
- awp/_spec/schemas/action-schema.schema.json +37 -0
- awp/_spec/schemas/action-status.schema.json +39 -0
- awp/_spec/schemas/action-submit-result.schema.json +16 -0
- awp/_spec/schemas/action-submit.schema.json +19 -0
- awp/_spec/schemas/agent-manifest.schema.json +27 -0
- awp/_spec/schemas/approval-requested.schema.json +25 -0
- awp/_spec/schemas/approval-respond.schema.json +31 -0
- awp/_spec/schemas/common.schema.json +150 -0
- awp/_spec/schemas/embodiment.schema.json +22 -0
- awp/_spec/schemas/empty-result.schema.json +8 -0
- awp/_spec/schemas/error.schema.json +25 -0
- awp/_spec/schemas/frame-inline.schema.json +20 -0
- awp/_spec/schemas/frame-tree.schema.json +26 -0
- awp/_spec/schemas/obs-report.schema.json +40 -0
- awp/_spec/schemas/observation-channel.schema.json +28 -0
- awp/_spec/schemas/ping-result.schema.json +13 -0
- awp/_spec/schemas/ping.schema.json +12 -0
- awp/_spec/schemas/profiles/gui-actions.schema.json +57 -0
- awp/_spec/schemas/reset-result.schema.json +11 -0
- awp/_spec/schemas/reset.schema.json +12 -0
- awp/_spec/schemas/restore.schema.json +11 -0
- awp/_spec/schemas/safety-policy.schema.json +81 -0
- awp/_spec/schemas/session-open.schema.json +41 -0
- awp/_spec/schemas/session-ready.schema.json +50 -0
- awp/_spec/schemas/session-resume.schema.json +12 -0
- awp/_spec/schemas/session-state.schema.json +14 -0
- awp/_spec/schemas/session-telemetry.schema.json +22 -0
- awp/_spec/schemas/session-transfer-result.schema.json +12 -0
- awp/_spec/schemas/session-transfer.schema.json +11 -0
- awp/_spec/schemas/snapshot-result.schema.json +11 -0
- awp/_spec/schemas/subscribe-result.schema.json +11 -0
- awp/_spec/schemas/subscribe.schema.json +11 -0
- awp/_spec/schemas/task-update.schema.json +11 -0
- awp/_spec/schemas/tick-result.schema.json +9 -0
- awp/_spec/schemas/tick.schema.json +12 -0
- awp/_spec/schemas/unsubscribe.schema.json +11 -0
- awp/_spec/schemas/world-event.schema.json +45 -0
- awp/_spec/schemas/world-manifest.schema.json +61 -0
- awp/aio.py +380 -0
- awp/client.py +781 -0
- awp/clock.py +61 -0
- awp/errors.py +112 -0
- awp/frames.py +174 -0
- awp/jsonrpc.py +84 -0
- awp/lifecycle.py +78 -0
- awp/py.typed +0 -0
- awp/schema.py +158 -0
- awp_python-0.1.0a1.dist-info/METADATA +127 -0
- awp_python-0.1.0a1.dist-info/RECORD +71 -0
- awp_python-0.1.0a1.dist-info/WHEEL +4 -0
- awp_python-0.1.0a1.dist-info/entry_points.txt +2 -0
- awp_python-0.1.0a1.dist-info/licenses/LICENSE +201 -0
- awp_sim/__init__.py +8 -0
- awp_sim/__main__.py +5 -0
- awp_sim/arm.py +163 -0
- awp_sim/audit.py +138 -0
- awp_sim/cli.py +238 -0
- awp_sim/config.py +225 -0
- awp_sim/demo.py +85 -0
- awp_sim/loopback.py +222 -0
- awp_sim/py.typed +0 -0
- awp_sim/recorder.py +72 -0
- awp_sim/replay.py +134 -0
- awp_sim/scenarios.py +427 -0
- awp_sim/server.py +333 -0
- awp_sim/session.py +153 -0
- 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
|
+
)
|