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/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)
|