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/scenarios.py ADDED
@@ -0,0 +1,427 @@
1
+ """Scripted scenarios: normal and failure behavior of the reference world, recorded as traces.
2
+
3
+ Each scenario runs real clients against the world on a virtual clock and returns its checks,
4
+ metrics (in virtual milliseconds), and one wire trace per agent in the spec's JSON Lines format.
5
+ Validate the traces with the spec's checker (`scripts/check_traces.py`).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ from collections.abc import Callable
12
+ from dataclasses import dataclass, field
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ from awp.client import (
17
+ ActionUpdated,
18
+ ErrorResponse,
19
+ FrameReceived,
20
+ ReplayCompleted,
21
+ Telemetry,
22
+ WorldEvent,
23
+ )
24
+ from awp.errors import AwpError, ErrorCode
25
+
26
+ from .config import WorldConfig
27
+ from .loopback import MS, Loopback, LoopbackAgent
28
+ from .world import World
29
+
30
+ FAR = {"pose": {"frame": "base", "p_m": [0.3, 0.2, 0.5], "q": [0, 0, 0, 1]}}
31
+ NEAR = {"pose": {"frame": "base", "p_m": [0.0, 0.1, 0.35], "q": [0, 0, 0, 1]}}
32
+ OUTSIDE = {"pose": {"frame": "base", "p_m": [0.9, 0.0, 0.4], "q": [0, 0, 0, 1]}}
33
+
34
+
35
+ @dataclass
36
+ class ScenarioResult:
37
+ name: str
38
+ description: str
39
+ checks: list[tuple[str, bool]] = field(default_factory=list)
40
+ metrics: dict[str, float] = field(default_factory=dict)
41
+ traces: dict[str, list[dict[str, Any]]] = field(default_factory=dict)
42
+ error: str | None = None
43
+
44
+ @property
45
+ def passed(self) -> bool:
46
+ return self.error is None and all(ok for _, ok in self.checks)
47
+
48
+ def summary(self) -> dict[str, Any]:
49
+ return {
50
+ "name": self.name,
51
+ "passed": self.passed,
52
+ "checks": [{"check": c, "ok": ok} for c, ok in self.checks],
53
+ "metrics": self.metrics,
54
+ "error": self.error,
55
+ }
56
+
57
+
58
+ class _Run:
59
+ def __init__(self, result: ScenarioResult, **config: Any) -> None:
60
+ self.result = result
61
+ self.net = Loopback(World(WorldConfig(**config)))
62
+
63
+ def agent(self, name: str = "agent", **kw: Any) -> LoopbackAgent:
64
+ return self.net.agent(name, **kw)
65
+
66
+ def check(self, label: str, ok: bool) -> None:
67
+ self.result.checks.append((label, bool(ok)))
68
+
69
+ def metric(self, name: str, value: float) -> None:
70
+ self.result.metrics[name] = round(value, 3)
71
+
72
+ def refused(self, fn: Callable[[], Any]) -> int | None:
73
+ try:
74
+ fn()
75
+ except AwpError as err:
76
+ return err.code
77
+ return None
78
+
79
+
80
+ def states(a: LoopbackAgent, action_id: str) -> list[str]:
81
+ out: list[str] = []
82
+ for e in a.of(ActionUpdated):
83
+ if e.action.action_id == action_id and (not out or out[-1] != e.status["state"]):
84
+ out.append(e.status["state"])
85
+ return out
86
+
87
+
88
+ def last_status(a: LoopbackAgent, action_id: str) -> dict[str, Any]:
89
+ return a.client.actions[action_id].status
90
+
91
+
92
+ def events(a: LoopbackAgent) -> list[WorldEvent]:
93
+ return a.of(WorldEvent)
94
+
95
+
96
+ def open_streaming(run: _Run, **kw: Any) -> LoopbackAgent:
97
+ a = run.agent(**kw)
98
+ a.open(mode="streaming", embodiment="arm_01", subscribe=["proprio", "arm_state"])
99
+ return a
100
+
101
+
102
+ SCENARIOS: dict[str, tuple[str, Callable[[_Run], None]]] = {}
103
+
104
+
105
+ def scenario(
106
+ name: str, description: str
107
+ ) -> Callable[[Callable[[_Run], None]], Callable[[_Run], None]]:
108
+ def register(fn: Callable[[_Run], None]) -> Callable[[_Run], None]:
109
+ SCENARIOS[name] = (description, fn)
110
+ return fn
111
+
112
+ return register
113
+
114
+
115
+ def run(name: str) -> ScenarioResult:
116
+ description, fn = SCENARIOS[name]
117
+ result = ScenarioResult(name, description)
118
+ runner = _Run(result, **_CONFIGS.get(name, {}))
119
+ try:
120
+ fn(runner)
121
+ except Exception as exc: # a scenario that raises has failed; keep the partial trace
122
+ result.error = f"{type(exc).__name__}: {exc}"
123
+ for a in runner.net.agents:
124
+ result.traces[a.name] = a.trace_json()
125
+ return result
126
+
127
+
128
+ def run_all(names: list[str] | None = None) -> list[ScenarioResult]:
129
+ return [run(n) for n in names or list(SCENARIOS)]
130
+
131
+
132
+ def write(results: list[ScenarioResult], out: Path) -> None:
133
+ out.mkdir(parents=True, exist_ok=True)
134
+ for r in results:
135
+ for agent, lines in r.traces.items():
136
+ path = out / f"{r.name}--{agent}.jsonl"
137
+ path.write_text(
138
+ "".join(json.dumps(line, separators=(",", ":")) + "\n" for line in lines)
139
+ )
140
+ report = {"passed": all(r.passed for r in results), "scenarios": [r.summary() for r in results]}
141
+ (out / "report.json").write_text(json.dumps(report, indent=2) + "\n")
142
+
143
+
144
+ _FAST_LIVENESS = {"watchdog_ms": 400, "heartbeat_interval_ms": 200, "reconnect_window_ms": 3000}
145
+ _CONFIGS: dict[str, dict[str, Any]] = {
146
+ "core-lockstep": {"mode": "lockstep"},
147
+ "disconnect-and-resume": _FAST_LIVENESS,
148
+ "quiet-agent": _FAST_LIVENESS,
149
+ "window-expiry": {**_FAST_LIVENESS, "reconnect_window_ms": 1000},
150
+ }
151
+
152
+
153
+ # ============================================================================ scenarios
154
+
155
+
156
+ @scenario("core-lockstep", "Discovery, session, staged action advanced by explicit ticks, close.")
157
+ def _core_lockstep(r: _Run) -> None:
158
+ a = r.agent(heartbeat_ms=None)
159
+ ready = a.open(mode="lockstep", embodiment="arm_01", subscribe=["proprio", "arm_state"])
160
+ r.check("session.ready carries tick 0", ready.get("tick") == 0)
161
+ r.check(
162
+ "initial observation per channel",
163
+ {f.channel for f in a.of(FrameReceived)} == {"proprio", "arm_state"},
164
+ )
165
+ action = a.submit("move_to_pose", NEAR)
166
+ r.net.advance(1000)
167
+ r.check("no progress without a tick", a.client.actions[action].state == "accepted")
168
+ ticks = 0
169
+ while not a.client.actions[action].terminal and ticks < 500:
170
+ a.call(a.client.advance(count=5))
171
+ ticks += 5
172
+ r.check(
173
+ "completed across advances", states(a, action) == ["accepted", "executing", "completed"]
174
+ )
175
+ per_tick = [f.frame.tick for f in a.of(FrameReceived) if f.channel == "proprio"]
176
+ r.check("exactly one frame per advance", per_tick == list(range(len(per_tick))))
177
+ r.metric("ticks_to_complete", ticks)
178
+ a.call(a.client.close())
179
+ r.check("closed", a.client.session_state == "closed")
180
+
181
+
182
+ @scenario("core-streaming", "Clock sync, basis and validity, progress, telemetry, report, close.")
183
+ def _core_streaming(r: _Run) -> None:
184
+ a = open_streaming(r)
185
+ r.net.advance(50)
186
+ basis = [f.frame for f in a.of(FrameReceived) if f.channel == "proprio"][-1]
187
+ action = a.submit("move_to_pose", FAR, basis=basis, valid_for_ms=200, deadline_ms=6000)
188
+ r.check("admitted", a.client.actions[action].state in ("accepted", "executing"))
189
+ r.net.run_until(lambda: a.client.actions[action].terminal, 5000)
190
+ r.check("completed", states(a, action) == ["accepted", "executing", "completed"])
191
+ r.net.advance(1100)
192
+ a.client.report()
193
+ r.net.settle()
194
+ telemetry = [e.params for e in a.of(Telemetry)]
195
+ r.check("telemetry at least once per second", len(telemetry) >= 2)
196
+ o2a = [
197
+ t["observation_to_action_ns"]["p50"] for t in telemetry if "observation_to_action_ns" in t
198
+ ]
199
+ r.check("observation-to-action reported", bool(o2a))
200
+ r.check("clock offset estimated", a.client.clock.samples > 0)
201
+ rid = a.client.close()
202
+ r.net.advance(10)
203
+ r.check("closed", a.call(rid) == {} and a.client.session_state == "closed")
204
+
205
+
206
+ @scenario(
207
+ "duplicate-and-conflict",
208
+ "Identical resubmissions never execute twice; changed content is refused.",
209
+ )
210
+ def _duplicate(r: _Run) -> None:
211
+ a = open_streaming(r)
212
+ action = a.submit("move_to_pose", FAR)
213
+ r.net.advance(100)
214
+ before = a.client.last_status_seq
215
+ first = a.call(a.client.resubmit(action))
216
+ second = a.call(a.client.resubmit(action))
217
+ r.check(
218
+ "resubmission reports the current state", first["state"] == second["state"] == "executing"
219
+ )
220
+ r.check("no status_seq consumed", a.client.last_status_seq == before)
221
+ changed = {"action_id": action, "type": "move_to_pose", "params": NEAR}
222
+ code = r.refused(lambda: a.call(a.client.request("action.submit", changed)))
223
+ r.check("changed content fails AWP_ACTION_ID_CONFLICT", code == ErrorCode.ACTION_ID_CONFLICT)
224
+ r.net.run_until(lambda: a.client.actions[action].terminal, 5000)
225
+ r.check("one execution", states(a, action) == ["accepted", "executing", "completed"])
226
+ session = next(iter(r.net.world.sessions.values()))
227
+ r.check("world holds one action", len(session.actions) == 1)
228
+
229
+
230
+ @scenario(
231
+ "lost-acknowledgement",
232
+ "An admission result is lost; resume replays it and a retry is idempotent.",
233
+ )
234
+ def _lost_ack(r: _Run) -> None:
235
+ a = open_streaming(r)
236
+ a.lose = lambda m: "result" in m and m["result"].get("state") == "accepted"
237
+ a.client.submit("move_to_pose", FAR, action_id="a-lost")
238
+ r.net.settle()
239
+ r.check("admission never reached the agent", a.client.actions["a-lost"].state == "submitted")
240
+ r.net.advance(150)
241
+ a.connect()
242
+ a.call(a.client.initialize())
243
+ a.call(a.client.resume())
244
+ r.check("replay completed", bool(a.of(ReplayCompleted)))
245
+ r.metric("replayed_notifications", sum(1 for e in a.events if getattr(e, "replayed", False)))
246
+ replaced = [e for e in a.events if getattr(e, "reason", None) == "connection_replaced"]
247
+ r.check("half-open connection replaced (AWP-SES-010)", bool(replaced))
248
+ retry = a.call(a.client.resubmit("a-lost"))
249
+ r.check("retry reports the running action", retry["state"] == "executing")
250
+ r.net.run_until(lambda: a.client.actions["a-lost"].terminal, 5000)
251
+ r.check("executed once", states(a, "a-lost") == ["accepted", "executing", "completed"])
252
+
253
+
254
+ @scenario(
255
+ "cancel-during-motion",
256
+ "Cancelling an executing move completes its safe abort within max_abort_ms.",
257
+ )
258
+ def _cancel(r: _Run) -> None:
259
+ a = open_streaming(r)
260
+ action = a.submit("move_to_pose", FAR)
261
+ r.net.advance(400)
262
+ a.call(a.client.cancel(action))
263
+ started = r.net.now
264
+ r.net.run_until(lambda: a.client.actions[action].terminal, 3000)
265
+ status = last_status(a, action)
266
+ r.check("cancelling then cancelled", states(a, action)[-2:] == ["cancelling", "cancelled"])
267
+ r.check("partial progress reported", 0 < status.get("aborted_at_progress", 0) < 1)
268
+ abort_ms = (r.net.now - started) / MS
269
+ r.metric("abort_ms", abort_ms)
270
+ r.check("abort within max_abort_ms", abort_ms <= r.net.world.config.max_abort_ms)
271
+
272
+
273
+ @scenario("estop-during-abort", "An e-stop outranks an abort in progress and cancels queued work.")
274
+ def _estop(r: _Run) -> None:
275
+ a = open_streaming(r)
276
+ action = a.submit("move_to_pose", FAR)
277
+ r.net.advance(60)
278
+ queued = a.submit("move_to_pose", NEAR, preempt="queue")
279
+ r.net.advance(300)
280
+ a.call(a.client.cancel(action))
281
+ r.net.advance(20)
282
+ r.net.deliver(r.net.world.engage_estop(r.net.now))
283
+ r.net.settle()
284
+ r.check(
285
+ "cancelling → failed(e_stop)",
286
+ (last_status(a, action)["state"], last_status(a, action).get("reason"))
287
+ == ("failed", "e_stop"),
288
+ )
289
+ r.check("queued → cancelled(e_stop)", last_status(a, queued).get("reason") == "e_stop")
290
+ r.net.advance(60)
291
+ code = r.refused(lambda: a.submit("stop", {}, action_id="a-stop"))
292
+ r.check("admission suspended", code == ErrorCode.ESTOP_ACTIVE)
293
+ r.net.deliver(r.net.world.release_estop(r.net.now))
294
+ r.net.settle()
295
+ a.submit("stop", {}, action_id="a-stop")
296
+ r.check(
297
+ "same id admitted after release (AWP-ACT-010)",
298
+ a.client.actions["a-stop"].state in ("executing", "completed"),
299
+ )
300
+
301
+
302
+ @scenario("limit-exceeded", "A target outside the spatial envelope is rejected and nothing moves.")
303
+ def _limit(r: _Run) -> None:
304
+ a = open_streaming(r)
305
+ start = r.net.world.arm.position
306
+ code = r.refused(lambda: a.submit("move_to_pose", OUTSIDE))
307
+ r.check("rejected AWP_ENVELOPE_EXCEEDED", code == ErrorCode.ENVELOPE_EXCEEDED)
308
+ r.net.advance(300)
309
+ r.check("arm did not move", r.net.world.arm.position == start)
310
+ r.check("no action created", not next(iter(r.net.world.sessions.values())).actions)
311
+
312
+
313
+ @scenario(
314
+ "replace-race", "A replacing submission preempts the running move and supersedes the queue."
315
+ )
316
+ def _replace(r: _Run) -> None:
317
+ a = open_streaming(r)
318
+ first = a.submit("move_to_pose", FAR)
319
+ r.net.advance(100)
320
+ queued = a.submit("move_to_pose", NEAR, preempt="queue")
321
+ r.net.advance(60)
322
+ new = a.submit(
323
+ "move_to_pose", {"pose": {**NEAR["pose"], "p_m": [-0.2, 0.0, 0.3]}}, preempt="replace"
324
+ )
325
+ r.check("running move preempted", states(a, first)[-1] == "preempted")
326
+ r.check("queued move superseded", last_status(a, queued).get("reason") == "superseded")
327
+ r.net.run_until(lambda: a.client.actions[new].terminal, 5000)
328
+ r.check("replacement completes", states(a, new)[-1] == "completed")
329
+
330
+
331
+ @scenario(
332
+ "disconnect-and-resume",
333
+ "The connection drops mid-move: safe state at watchdog_ms, full replay on resume.",
334
+ )
335
+ def _disconnect(r: _Run) -> None:
336
+ a = open_streaming(r)
337
+ action = a.submit("move_to_pose", FAR)
338
+ r.net.advance(100)
339
+ last_sent = r.net.now
340
+ a.drop()
341
+ r.net.run_until(
342
+ lambda: r.net.world.arm.at_rest and not next(iter(r.net.world.sessions.values())).running,
343
+ 3000,
344
+ )
345
+ r.net.advance(300)
346
+ a.connect()
347
+ a.call(a.client.initialize())
348
+ ready = a.call(a.client.resume())
349
+ r.check("embodiment reported in safe state", ready.get("safe_state") is True)
350
+ entered = [e for e in events(a) if e.event == "safe_state_entered"]
351
+ r.check("safe_state_entered replayed", bool(entered) and entered[0].replayed)
352
+ session = next(iter(r.net.world.sessions.values()))
353
+ if entered:
354
+ dt = (entered[0].params["ts_mono_ns"] - (last_sent - session.origin_ns)) / MS
355
+ r.metric("time_to_safe_state_ms", dt)
356
+ r.check("within watchdog_ms + 100 ms", dt <= r.net.world.config.watchdog_ms + 100)
357
+ r.check(
358
+ "action failed(connection_lost)", last_status(a, action).get("reason") == "connection_lost"
359
+ )
360
+ r.net.advance(50)
361
+ resync = [f for f in a.of(FrameReceived) if f.channel == "arm_state" and f.frame.resync]
362
+ r.check("reliable channel resyncs", len(resync) == 1)
363
+ again = a.submit("move_to_pose", NEAR)
364
+ r.check("new intent exits safe state", events(a)[-1].event == "safe_state_exited")
365
+ r.net.run_until(lambda: a.client.actions[again].terminal, 5000)
366
+
367
+
368
+ @scenario(
369
+ "quiet-agent", "A stalled agent on a live connection: pongs do not hold off the watchdog."
370
+ )
371
+ def _quiet(r: _Run) -> None:
372
+ a = open_streaming(r, heartbeat_ms=None)
373
+ action = a.submit("move_to_pose", FAR)
374
+ t0 = r.net.now
375
+ r.net.run_until(lambda: any(e.event == "safe_state_entered" for e in events(a)), 2000)
376
+ dt = (r.net.now - t0) / MS
377
+ r.metric("time_to_safe_state_ms", dt)
378
+ watchdog = r.net.world.config.watchdog_ms
379
+ r.check("safe state at watchdog_ms", watchdog <= dt <= watchdog + 100)
380
+ r.check("session still active", a.client.session_state == "active")
381
+ r.check(
382
+ "action failed(connection_lost)", last_status(a, action).get("reason") == "connection_lost"
383
+ )
384
+
385
+
386
+ @scenario(
387
+ "stale-observation", "An intent built on an old observation is refused before anything moves."
388
+ )
389
+ def _stale(r: _Run) -> None:
390
+ a = open_streaming(r)
391
+ r.net.advance(50)
392
+ old = next(f.frame for f in a.of(FrameReceived) if f.channel == "proprio")
393
+ r.net.advance(r.net.world.config.max_basis_age_ms + 50)
394
+ code = r.refused(lambda: a.submit("move_to_pose", FAR, basis=old))
395
+ r.check("stale basis → AWP_STALE_INTENT", code == ErrorCode.STALE_INTENT)
396
+ code = r.refused(lambda: a.submit("move_to_pose", FAR, valid_until_ns=1))
397
+ r.check("expired validity → AWP_STALE_INTENT", code == ErrorCode.STALE_INTENT)
398
+ fresh = [f.frame for f in a.of(FrameReceived) if f.channel == "proprio"][-1]
399
+ a.submit("move_to_pose", FAR, basis=fresh)
400
+ report = a.client.report()["params"]
401
+ r.net.settle()
402
+ staleness = report["channels"].get(str(a.client.channels["proprio"]), {}).get("staleness_ns")
403
+ r.check("staleness visible to the agent", staleness is not None)
404
+ r.check("errors were retryable", all(e.error.retryable for e in a.of(ErrorResponse)))
405
+
406
+
407
+ @scenario(
408
+ "window-expiry",
409
+ "An agent that never returns: safe state, then the session closes and its token expires.",
410
+ )
411
+ def _window(r: _Run) -> None:
412
+ a = open_streaming(r)
413
+ token = a.client.session_token
414
+ a.submit("move_to_pose", FAR)
415
+ a.abandon()
416
+ r.net.advance(r.net.world.config.reconnect_window_ms + 1000)
417
+ r.check("session closed", not r.net.world.sessions)
418
+ r.check("arm at rest", r.net.world.arm.at_rest)
419
+ b = r.agent("returning")
420
+ b.connect()
421
+ b.call(b.client.initialize())
422
+ code = r.refused(
423
+ lambda: b.call(
424
+ b.client.request("session.resume", {"session_token": token, "last_status_seq": 0})
425
+ )
426
+ )
427
+ r.check("resume fails AWP_SESSION_EXPIRED", code == ErrorCode.SESSION_EXPIRED)