dosync 0.4.1__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.
- dosync/__init__.py +17 -0
- dosync/adapters/__init__.py +258 -0
- dosync/adapters/ble.py +199 -0
- dosync/adapters/homeassistant.py +655 -0
- dosync/adapters/matter.py +320 -0
- dosync/adapters/mavlink.py +1205 -0
- dosync/adapters/mqtt.py +409 -0
- dosync/adapters/notifications.py +153 -0
- dosync/adapters/shelly.py +348 -0
- dosync/adapters/wiz.py +357 -0
- dosync/audit_backup.py +184 -0
- dosync/auth.py +194 -0
- dosync/auth_fastapi.py +87 -0
- dosync/cert_signing.py +117 -0
- dosync/certify.py +1091 -0
- dosync/cli.py +61 -0
- dosync/composite_operations.py +306 -0
- dosync/db.py +826 -0
- dosync/device_arbiter.py +270 -0
- dosync/discovery.py +208 -0
- dosync/ed25519_pure.py +204 -0
- dosync/executor.py +97 -0
- dosync/geo.py +63 -0
- dosync/hub.py +2923 -0
- dosync/hub_monitor.py +144 -0
- dosync/manage.py +913 -0
- dosync/mcp_server.py +746 -0
- dosync/metrics.py +244 -0
- dosync/models.py +562 -0
- dosync/operation_guards.py +228 -0
- dosync/operation_supervisor.py +216 -0
- dosync/operations.py +331 -0
- dosync/policies.py +1210 -0
- dosync/policy_config.py +252 -0
- dosync/py.typed +0 -0
- dosync/reconciler.py +177 -0
- dosync/route_composer.py +189 -0
- dosync/security.py +680 -0
- dosync/server.py +1911 -0
- dosync/validation.py +98 -0
- dosync-0.4.1.dist-info/METADATA +372 -0
- dosync-0.4.1.dist-info/RECORD +46 -0
- dosync-0.4.1.dist-info/WHEEL +5 -0
- dosync-0.4.1.dist-info/entry_points.txt +4 -0
- dosync-0.4.1.dist-info/licenses/LICENSE +201 -0
- dosync-0.4.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
"""
|
|
2
|
+
DoSync — Operation guards (real-time in-flight monitoring for composite operations).
|
|
3
|
+
====================================================================================
|
|
4
|
+
|
|
5
|
+
The PolicyEngine is PRE-FLIGHT AUTHORIZATION: it judges a plan once, before dispatch,
|
|
6
|
+
and answers ALLOW/BLOCK/CONFIRM/MODIFY. These guards are IN-FLIGHT MONITORING: the
|
|
7
|
+
supervisor calls them on every tick while a composite operation is live, and they
|
|
8
|
+
answer "keep going" (None) or "abort, here's why" (a reason string). Two different
|
|
9
|
+
safety systems for two different moments — the panel was explicit that they must not
|
|
10
|
+
be merged (incompatible contracts), but they SHARE the geofence calculation (in
|
|
11
|
+
dosync/geo.py) so the formula never lives in two copies.
|
|
12
|
+
|
|
13
|
+
This is the brain watching the body's vital signs during the trip — not the
|
|
14
|
+
environment (no obstacle/“tree” sensing; that needs sensors the body does not have
|
|
15
|
+
yet), but everything the vehicle itself reports: position (still inside the fence?),
|
|
16
|
+
link (still alive?), mode (did a human take over?), battery, time-in-state.
|
|
17
|
+
|
|
18
|
+
DESIGN (validated by the expert panel):
|
|
19
|
+
* Each guard is an independent, composable object with a name and a check(comp)
|
|
20
|
+
method returning a reason-or-None. A GuardSet runs them in order; the first to
|
|
21
|
+
fire wins (like the PolicyEngine's first-block-wins, shared PATTERN, separate
|
|
22
|
+
component).
|
|
23
|
+
* The guards consume a CONTEXT object the supervisor refreshes from reconciled
|
|
24
|
+
state + telemetry each tick — they never read the raw telemetry queue (that is
|
|
25
|
+
the consumer's job; guards are decision, not perception).
|
|
26
|
+
* First version: DETECT and report a clear abort reason. Differentiated REACTIONS
|
|
27
|
+
(link-loss → return home; human took control → let go; battery → land here) are
|
|
28
|
+
a documented second layer; the supervisor's safe default today is to abort, and
|
|
29
|
+
the caller routes the vehicle home. Keeping the first version to "detect + abort
|
|
30
|
+
with a clear reason" is honest and already large.
|
|
31
|
+
* The human-takeover guard is special and the panel flagged it: when a human is
|
|
32
|
+
flying, the right behavior is NOT to fight for control. The guard fires (so the
|
|
33
|
+
supervisor stops dispatching), and the safe meaning is "let go," distinct from a
|
|
34
|
+
fault. This is recorded in the reason so the second-layer reaction can tell them
|
|
35
|
+
apart later.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
from __future__ import annotations
|
|
39
|
+
|
|
40
|
+
from dataclasses import dataclass, field
|
|
41
|
+
from typing import Optional
|
|
42
|
+
|
|
43
|
+
from .geo import is_within_perimeter
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass
|
|
47
|
+
class GuardContext:
|
|
48
|
+
"""A snapshot the supervisor refreshes each tick from reconciled state +
|
|
49
|
+
telemetry. The guards read THIS, never the raw queue. Every field is optional so
|
|
50
|
+
a deployment that cannot supply a signal simply leaves it None and the
|
|
51
|
+
corresponding guard abstains (it never invents a reading)."""
|
|
52
|
+
device_id: str
|
|
53
|
+
# Current reported position (from telemetry). None = unknown this tick.
|
|
54
|
+
lat: Optional[float] = None
|
|
55
|
+
lon: Optional[float] = None
|
|
56
|
+
alt: Optional[float] = None
|
|
57
|
+
# Seconds since the last telemetry message arrived. None = unknown.
|
|
58
|
+
seconds_since_telemetry: Optional[float] = None
|
|
59
|
+
# Whether a human currently holds manual control (operation interrupted).
|
|
60
|
+
manual_control_active: bool = False
|
|
61
|
+
# Battery remaining, 0..100. None = unknown.
|
|
62
|
+
battery_percent: Optional[float] = None
|
|
63
|
+
# Seconds the current mission step has been running. None = unknown.
|
|
64
|
+
seconds_in_step: Optional[float] = None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class BaseGuard:
|
|
68
|
+
"""A composable in-flight guard. Subclasses implement check(ctx) -> reason|None."""
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def name(self) -> str:
|
|
72
|
+
raise NotImplementedError
|
|
73
|
+
|
|
74
|
+
def check(self, ctx: GuardContext) -> Optional[str]:
|
|
75
|
+
raise NotImplementedError
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class GeofenceGuard(BaseGuard):
|
|
79
|
+
"""Fires if the vehicle's REPORTED position leaves the permitted perimeter.
|
|
80
|
+
|
|
81
|
+
This is the in-flight twin of GeofencePolicy. The policy checks a go_to's TARGET
|
|
82
|
+
before dispatch; this checks where the vehicle ACTUALLY is, every tick — catching
|
|
83
|
+
a vehicle blown off course by wind, GPS drift, or any drift the pre-flight check
|
|
84
|
+
could not foresee. Both call the same is_within_perimeter (dosync/geo.py): one
|
|
85
|
+
rule, two moments (admission + monitoring). Cinturón y airbag.
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
def __init__(self, center_lat: float, center_lon: float,
|
|
89
|
+
max_radius_m: float, max_altitude_m: Optional[float] = None):
|
|
90
|
+
self._center_lat = center_lat
|
|
91
|
+
self._center_lon = center_lon
|
|
92
|
+
self._max_radius_m = max_radius_m
|
|
93
|
+
self._max_altitude_m = max_altitude_m
|
|
94
|
+
|
|
95
|
+
@property
|
|
96
|
+
def name(self) -> str:
|
|
97
|
+
return "geofence_in_flight"
|
|
98
|
+
|
|
99
|
+
def check(self, ctx: GuardContext) -> Optional[str]:
|
|
100
|
+
if ctx.lat is None or ctx.lon is None:
|
|
101
|
+
return None # no position this tick — abstain, never invent
|
|
102
|
+
ok, reason = is_within_perimeter(
|
|
103
|
+
ctx.lat, ctx.lon, self._center_lat, self._center_lon,
|
|
104
|
+
self._max_radius_m, alt=ctx.alt, max_altitude_m=self._max_altitude_m)
|
|
105
|
+
if not ok:
|
|
106
|
+
return f"vehicle left the perimeter in flight: {reason}"
|
|
107
|
+
return None
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class LinkLossGuard(BaseGuard):
|
|
111
|
+
"""Fires if telemetry has gone silent for longer than the allowed gap — the link
|
|
112
|
+
is presumed lost. Silence is not success: a vehicle we cannot hear is a vehicle
|
|
113
|
+
we are not supervising, and the safe response is to stop and let it come home."""
|
|
114
|
+
|
|
115
|
+
def __init__(self, max_silence_s: float = 5.0):
|
|
116
|
+
self._max_silence_s = max_silence_s
|
|
117
|
+
|
|
118
|
+
@property
|
|
119
|
+
def name(self) -> str:
|
|
120
|
+
return "link_loss"
|
|
121
|
+
|
|
122
|
+
def check(self, ctx: GuardContext) -> Optional[str]:
|
|
123
|
+
if ctx.seconds_since_telemetry is None:
|
|
124
|
+
return None
|
|
125
|
+
if ctx.seconds_since_telemetry > self._max_silence_s:
|
|
126
|
+
return (f"telemetry link silent for {ctx.seconds_since_telemetry:.0f}s "
|
|
127
|
+
f"(> {self._max_silence_s:.0f}s) — link presumed lost")
|
|
128
|
+
return None
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class ManualControlGuard(BaseGuard):
|
|
132
|
+
"""Fires when a human has taken manual control. The panel flagged this as
|
|
133
|
+
special: the right behavior is NOT to fight the pilot. The guard fires so the
|
|
134
|
+
supervisor stops dispatching; the reason marks it as a handover (not a fault) so a
|
|
135
|
+
later reaction layer can 'let go' rather than 'return home'."""
|
|
136
|
+
|
|
137
|
+
@property
|
|
138
|
+
def name(self) -> str:
|
|
139
|
+
return "manual_control"
|
|
140
|
+
|
|
141
|
+
def check(self, ctx: GuardContext) -> Optional[str]:
|
|
142
|
+
if ctx.manual_control_active:
|
|
143
|
+
return ("human took manual control — DoSync yields (handover, not a "
|
|
144
|
+
"fault); stop dispatching and let the pilot fly")
|
|
145
|
+
return None
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class BatteryGuard(BaseGuard):
|
|
149
|
+
"""Fires when battery falls below the safe threshold. The vehicle needs enough
|
|
150
|
+
charge to get home; below the floor, the mission must stop."""
|
|
151
|
+
|
|
152
|
+
def __init__(self, min_percent: float = 25.0):
|
|
153
|
+
self._min_percent = min_percent
|
|
154
|
+
|
|
155
|
+
@property
|
|
156
|
+
def name(self) -> str:
|
|
157
|
+
return "battery"
|
|
158
|
+
|
|
159
|
+
def check(self, ctx: GuardContext) -> Optional[str]:
|
|
160
|
+
if ctx.battery_percent is None:
|
|
161
|
+
return None
|
|
162
|
+
if ctx.battery_percent < self._min_percent:
|
|
163
|
+
return (f"battery {ctx.battery_percent:.0f}% below the {self._min_percent:.0f}% "
|
|
164
|
+
f"floor — not enough charge to continue safely")
|
|
165
|
+
return None
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
class StepTimeoutGuard(BaseGuard):
|
|
169
|
+
"""Fires when a single step has run longer than allowed — a stalled step that the
|
|
170
|
+
supervisor's own backstop would also catch, surfaced here as a named guard so the
|
|
171
|
+
reason is explicit. Belt for the supervisor's suspenders."""
|
|
172
|
+
|
|
173
|
+
def __init__(self, max_step_s: float = 120.0):
|
|
174
|
+
self._max_step_s = max_step_s
|
|
175
|
+
|
|
176
|
+
@property
|
|
177
|
+
def name(self) -> str:
|
|
178
|
+
return "step_timeout"
|
|
179
|
+
|
|
180
|
+
def check(self, ctx: GuardContext) -> Optional[str]:
|
|
181
|
+
if ctx.seconds_in_step is None:
|
|
182
|
+
return None
|
|
183
|
+
if ctx.seconds_in_step > self._max_step_s:
|
|
184
|
+
return (f"step running for {ctx.seconds_in_step:.0f}s "
|
|
185
|
+
f"(> {self._max_step_s:.0f}s) — stalled")
|
|
186
|
+
return None
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
@dataclass
|
|
190
|
+
class GuardSet:
|
|
191
|
+
"""An ordered, composable set of guards. Runs them in order on each tick; the
|
|
192
|
+
first to fire wins (shared pattern with the PolicyEngine's first-block-wins,
|
|
193
|
+
separate component). A deployment registers only the guards it wants — a vehicle
|
|
194
|
+
with no battery telemetry simply does not add a BatteryGuard.
|
|
195
|
+
|
|
196
|
+
The GuardSet exposes a `make_guard_fn(context_provider)` that adapts it to the
|
|
197
|
+
supervisor's GuardFn hook: the supervisor passes a CompositeOperation, the
|
|
198
|
+
provider turns it (plus live telemetry) into a GuardContext, and the set runs.
|
|
199
|
+
"""
|
|
200
|
+
guards: list = field(default_factory=list)
|
|
201
|
+
|
|
202
|
+
def add(self, guard: BaseGuard) -> "GuardSet":
|
|
203
|
+
self.guards.append(guard)
|
|
204
|
+
return self
|
|
205
|
+
|
|
206
|
+
def check(self, ctx: GuardContext) -> Optional[str]:
|
|
207
|
+
"""Run all guards in order; return the first firing guard's reason (prefixed
|
|
208
|
+
with its name), or None if all pass."""
|
|
209
|
+
for guard in self.guards:
|
|
210
|
+
reason = guard.check(ctx)
|
|
211
|
+
if reason is not None:
|
|
212
|
+
return f"[{guard.name}] {reason}"
|
|
213
|
+
return None
|
|
214
|
+
|
|
215
|
+
def make_guard_fn(self, context_provider):
|
|
216
|
+
"""Adapt this set to the supervisor's GuardFn(comp) -> reason|None.
|
|
217
|
+
|
|
218
|
+
context_provider: a callable taking the live CompositeOperation and returning
|
|
219
|
+
a GuardContext (built from reconciled state + the latest telemetry). Keeping
|
|
220
|
+
this injection here means the GuardSet stays pure and testable: tests supply a
|
|
221
|
+
trivial provider; production supplies one that reads live telemetry.
|
|
222
|
+
"""
|
|
223
|
+
def guard_fn(comp) -> Optional[str]:
|
|
224
|
+
ctx = context_provider(comp)
|
|
225
|
+
if ctx is None:
|
|
226
|
+
return None
|
|
227
|
+
return self.check(ctx)
|
|
228
|
+
return guard_fn
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
"""
|
|
2
|
+
DoSync — Operation supervisor (the nervous-system trunk of a composite operation).
|
|
3
|
+
==================================================================================
|
|
4
|
+
|
|
5
|
+
CompositeOperation is the structure (the ordered steps). RouteComposer fills it with
|
|
6
|
+
geometry. This module is what makes them LIVE: the supervisor drives a composite
|
|
7
|
+
forward, step by step, in a CLOSED LOOP — it dispatches the current step, then WAITS
|
|
8
|
+
for that step's atomic operation to reach a positive terminal state (confirmed
|
|
9
|
+
arrival, never a timer), and only then advances. Between steps it samples continuously
|
|
10
|
+
so guards (next piece) can react in real time.
|
|
11
|
+
|
|
12
|
+
This is the brain coordinating the body: not "fire the plan and walk away" (open
|
|
13
|
+
loop), but "watch what the body actually did, decide the next move, repeat" (closed
|
|
14
|
+
loop). A waypoint's confirmed arrival is not the end of an instruction — it is
|
|
15
|
+
perception that feeds the next decision.
|
|
16
|
+
|
|
17
|
+
DESIGN (validated by a multi-pass expert panel incl. a drone manufacturer, a pilot,
|
|
18
|
+
and two systems professors):
|
|
19
|
+
|
|
20
|
+
* POLLING at a fixed, CONFIGURABLE cadence — not event subscription. The panel's
|
|
21
|
+
reasons: (1) the system ALREADY beats — the telemetry consumer
|
|
22
|
+
(drain_telemetry_once -> apply_telemetry) already runs on a rhythm; the
|
|
23
|
+
supervisor hangs off that nature instead of bolting on a callback mechanism.
|
|
24
|
+
(2) Real-time guards (geofence-in-flight, battery, link loss) are INHERENTLY
|
|
25
|
+
sampling — there is no "geofence-breach event"; you must look. One heartbeat
|
|
26
|
+
serves both arrival-detection and guards. (3) A predictable cadence is a SAFETY
|
|
27
|
+
property: you know exactly how often the brain looks at the body.
|
|
28
|
+
|
|
29
|
+
* The supervisor POLLS THE RECONCILED STATE, never the raw telemetry queue. The
|
|
30
|
+
consumer is perception (telemetry -> state); the supervisor is decision (state ->
|
|
31
|
+
next move). The brain reads the processed body-map, not the raw nerves. This
|
|
32
|
+
separation is non-negotiable — it is what keeps the nervous system clean.
|
|
33
|
+
|
|
34
|
+
* SILENCE IS NOT SUCCESS — inherited end to end. A step only advances when its
|
|
35
|
+
atomic operation reaches a POSITIVE terminal state (completed). A timeout or a
|
|
36
|
+
silent gap never advances anything; it is a stall the guards can act on.
|
|
37
|
+
|
|
38
|
+
* Dependency-injected dispatch and state-read so the whole loop is unit-testable
|
|
39
|
+
with NO drone, NO socket, NO hub: the test supplies a fake "dispatch" and a fake
|
|
40
|
+
"read state", and drives the composite through its lifecycle deterministically.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
from __future__ import annotations
|
|
44
|
+
|
|
45
|
+
import logging
|
|
46
|
+
from dataclasses import dataclass
|
|
47
|
+
from typing import Awaitable, Callable, Optional
|
|
48
|
+
|
|
49
|
+
from .composite_operations import CompositeOperation, CompositeState, CompositeStep
|
|
50
|
+
|
|
51
|
+
log = logging.getLogger("dosync.supervisor")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# Default sampling cadence. The panel required this be configurable, not hardcoded:
|
|
55
|
+
# a slow inspection can sample wider; a critical maneuver, finer.
|
|
56
|
+
_DEFAULT_POLL_INTERVAL_S = 0.2 # 5 Hz — the brain looks at the body 5x/second
|
|
57
|
+
# Safety ceiling: how long a single step may sit without reaching a terminal atom
|
|
58
|
+
# state before the supervisor treats it as stalled and aborts the composite. This is
|
|
59
|
+
# a backstop, not the primary mechanism (guards are) — silence must never hang forever.
|
|
60
|
+
_DEFAULT_STEP_TIMEOUT_S = 120.0
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# A step dispatcher: given a CompositeStep, start its atomic operation and return the
|
|
64
|
+
# operation_id the supervisor will then watch. Injected so tests need no hub/drone.
|
|
65
|
+
DispatchFn = Callable[[CompositeStep], Awaitable[str]]
|
|
66
|
+
|
|
67
|
+
# A state reader: given an operation_id, return the reconciled atomic state as a
|
|
68
|
+
# string ("pending" | "in_progress" | "completed" | "failed" | "interrupted" | ...),
|
|
69
|
+
# or None if unknown. Reads the RECONCILED state — never the raw queue.
|
|
70
|
+
ReadStateFn = Callable[[str], Optional[str]]
|
|
71
|
+
|
|
72
|
+
# A guard check: inspect the live composite and return an abort reason string if a
|
|
73
|
+
# guard fires, or None to continue. The guards themselves are the next piece; the
|
|
74
|
+
# supervisor just calls this hook each tick. Injected; defaults to "no guards".
|
|
75
|
+
GuardFn = Callable[[CompositeOperation], Optional[str]]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# Atomic states that count as a positive, successful completion of a step.
|
|
79
|
+
_POSITIVE_TERMINAL = {"completed"}
|
|
80
|
+
# Atomic states that mean the step ended without success → the composite cannot
|
|
81
|
+
# simply advance; it must react (abort / return home).
|
|
82
|
+
_NEGATIVE_TERMINAL = {"failed", "rejected", "cancelled", "interrupted"}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass
|
|
86
|
+
class SupervisorConfig:
|
|
87
|
+
poll_interval_s: float = _DEFAULT_POLL_INTERVAL_S
|
|
88
|
+
step_timeout_s: float = _DEFAULT_STEP_TIMEOUT_S
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class OperationSupervisor:
|
|
92
|
+
"""Drives one CompositeOperation through its steps in a closed loop.
|
|
93
|
+
|
|
94
|
+
The supervisor owns the composite's mission-level progression. For each step it:
|
|
95
|
+
1. transitions the composite to the right mission state (in_transit / returning),
|
|
96
|
+
2. dispatches the step's atomic action (injected DispatchFn) and records the
|
|
97
|
+
resulting operation_id on the step,
|
|
98
|
+
3. polls the reconciled atomic state at the configured cadence until it reaches
|
|
99
|
+
a terminal state — advancing on a positive one, aborting on a negative one,
|
|
100
|
+
4. on every tick, runs the guard hook; if a guard fires, it aborts (the safe
|
|
101
|
+
default is to stop and let the caller route to return-home).
|
|
102
|
+
|
|
103
|
+
It never drains the telemetry queue and never reaches into the atom's state
|
|
104
|
+
machine — it reads reconciled state and advances its OWN (composite) machine.
|
|
105
|
+
"""
|
|
106
|
+
|
|
107
|
+
def __init__(self, dispatch: DispatchFn, read_state: ReadStateFn,
|
|
108
|
+
guard: Optional[GuardFn] = None,
|
|
109
|
+
config: Optional[SupervisorConfig] = None,
|
|
110
|
+
sleep: Optional[Callable[[float], Awaitable[None]]] = None,
|
|
111
|
+
now: Optional[Callable[[], float]] = None):
|
|
112
|
+
self._dispatch = dispatch
|
|
113
|
+
self._read_state = read_state
|
|
114
|
+
self._guard = guard or (lambda comp: None)
|
|
115
|
+
self._config = config or SupervisorConfig()
|
|
116
|
+
# Injected clock/sleep so tests run instantly and deterministically.
|
|
117
|
+
if sleep is None:
|
|
118
|
+
import asyncio
|
|
119
|
+
sleep = asyncio.sleep
|
|
120
|
+
self._sleep = sleep
|
|
121
|
+
if now is None:
|
|
122
|
+
import time
|
|
123
|
+
now = time.monotonic
|
|
124
|
+
self._now = now
|
|
125
|
+
|
|
126
|
+
async def run(self, comp: CompositeOperation) -> CompositeState:
|
|
127
|
+
"""Drive the composite to a terminal mission state and return it.
|
|
128
|
+
|
|
129
|
+
Returns COMPLETED if every step reached a positive terminal state, or
|
|
130
|
+
ABORTED/FAILED if a guard fired, a step failed, or a step stalled.
|
|
131
|
+
"""
|
|
132
|
+
if comp.state != CompositeState.PLANNING:
|
|
133
|
+
raise ValueError(
|
|
134
|
+
f"supervisor expects a freshly composed operation in PLANNING, "
|
|
135
|
+
f"got {comp.state.value}")
|
|
136
|
+
|
|
137
|
+
comp.transition_to(CompositeState.IN_TRANSIT, reason="supervisor started")
|
|
138
|
+
|
|
139
|
+
while True:
|
|
140
|
+
step = comp.current_step
|
|
141
|
+
if step is None:
|
|
142
|
+
# All steps consumed without a dedicated return step having moved us to
|
|
143
|
+
# RETURNING — treat the sequence as done.
|
|
144
|
+
break
|
|
145
|
+
|
|
146
|
+
# The return step flips the mission into RETURNING (its own leg).
|
|
147
|
+
if step.kind == "return" and comp.state == CompositeState.IN_TRANSIT:
|
|
148
|
+
comp.transition_to(CompositeState.RETURNING, reason="returning to base")
|
|
149
|
+
|
|
150
|
+
# Guard check BEFORE dispatching the next step — never start a step if a
|
|
151
|
+
# guard already says stop.
|
|
152
|
+
reason = self._guard(comp)
|
|
153
|
+
if reason is not None:
|
|
154
|
+
return self._abort(comp, f"guard: {reason}")
|
|
155
|
+
|
|
156
|
+
# Dispatch the step's atomic action; record the operation_id to watch.
|
|
157
|
+
try:
|
|
158
|
+
operation_id = await self._dispatch(step)
|
|
159
|
+
except Exception as e:
|
|
160
|
+
return self._abort(comp, f"dispatch raised: {e}")
|
|
161
|
+
step.operation_id = operation_id
|
|
162
|
+
|
|
163
|
+
outcome = await self._await_step(comp, operation_id)
|
|
164
|
+
if outcome == "positive":
|
|
165
|
+
comp.advance()
|
|
166
|
+
continue
|
|
167
|
+
if outcome == "guard":
|
|
168
|
+
# _await_step already knows the reason via the guard; abort generically.
|
|
169
|
+
return self._abort(comp, "guard fired during step")
|
|
170
|
+
if outcome == "stalled":
|
|
171
|
+
return self._abort(comp, f"step '{step.action}' stalled (no positive signal)")
|
|
172
|
+
# negative terminal
|
|
173
|
+
return self._abort(comp, f"step '{step.action}' ended in a non-success state")
|
|
174
|
+
|
|
175
|
+
# Every step done. If we were returning, we are home; otherwise close directly.
|
|
176
|
+
if comp.state == CompositeState.RETURNING:
|
|
177
|
+
comp.transition_to(CompositeState.COMPLETED, reason="returned to base")
|
|
178
|
+
else:
|
|
179
|
+
# No explicit return step; still a successful completion of all steps.
|
|
180
|
+
comp.transition_to(CompositeState.RETURNING, reason="no explicit return step")
|
|
181
|
+
comp.transition_to(CompositeState.COMPLETED, reason="all steps completed")
|
|
182
|
+
return comp.state
|
|
183
|
+
|
|
184
|
+
async def _await_step(self, comp: CompositeOperation, operation_id: str) -> str:
|
|
185
|
+
"""Poll the reconciled atomic state until terminal. Returns one of:
|
|
186
|
+
'positive' | 'negative' | 'guard' | 'stalled'. Runs the guard hook each tick —
|
|
187
|
+
the closed loop: every sample, decide."""
|
|
188
|
+
started = self._now()
|
|
189
|
+
while True:
|
|
190
|
+
# Guard check every tick — real-time reaction, not only at waypoints.
|
|
191
|
+
reason = self._guard(comp)
|
|
192
|
+
if reason is not None:
|
|
193
|
+
log.info("supervisor: guard fired mid-step on %s: %s",
|
|
194
|
+
comp.composite_id, reason)
|
|
195
|
+
return "guard"
|
|
196
|
+
|
|
197
|
+
state = self._read_state(operation_id)
|
|
198
|
+
if state in _POSITIVE_TERMINAL:
|
|
199
|
+
return "positive"
|
|
200
|
+
if state in _NEGATIVE_TERMINAL:
|
|
201
|
+
return "negative"
|
|
202
|
+
|
|
203
|
+
# Backstop: a step that never reaches terminal must not hang forever.
|
|
204
|
+
if self._now() - started > self._config.step_timeout_s:
|
|
205
|
+
return "stalled"
|
|
206
|
+
|
|
207
|
+
await self._sleep(self._config.poll_interval_s)
|
|
208
|
+
|
|
209
|
+
def _abort(self, comp: CompositeOperation, reason: str) -> CompositeState:
|
|
210
|
+
"""Move the composite to ABORTED (safe default). The caller is responsible for
|
|
211
|
+
routing the vehicle home — the supervisor records the decision; the return
|
|
212
|
+
itself is a separate dispatched action the caller may issue."""
|
|
213
|
+
if not comp.is_terminal:
|
|
214
|
+
comp.transition_to(CompositeState.ABORTED, reason=reason)
|
|
215
|
+
log.warning("supervisor: composite %s aborted — %s", comp.composite_id, reason)
|
|
216
|
+
return comp.state
|