Skip to content

Commit e642ce6

Browse files
authored
Add RuntimeService for IX-HapticSight orchestration
Implement high-level runtime service for IX-HapticSight, connecting backend components and managing session state, requests, and execution.
1 parent 3dc60e8 commit e642ce6

1 file changed

Lines changed: 356 additions & 0 deletions

File tree

Lines changed: 356 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,356 @@
1+
"""
2+
High-level runtime service for IX-HapticSight.
3+
4+
This module connects the major backend-agnostic runtime pieces introduced in
5+
the upgrade so far:
6+
7+
- SessionStore
8+
- RuntimeCoordinator
9+
- EventRecorder
10+
- ExecutionAdapter
11+
12+
The goal is to provide one explicit service layer that:
13+
- loads and updates session state
14+
- evaluates a runtime request
15+
- records the full decision trail
16+
- submits bounded execution requests when allowed
17+
- records execution status in the same structured event stream
18+
19+
This is still not a ROS 2 runtime and not a hardware runtime.
20+
It is a disciplined orchestration layer for local execution, replay, and tests.
21+
"""
22+
23+
from __future__ import annotations
24+
25+
from dataclasses import dataclass
26+
from typing import Optional
27+
28+
from ohip.contact_planner import PlannerHints
29+
from ohip.schemas import Nudge, Pose
30+
31+
from ohip_interfaces.execution_adapter import (
32+
BoundedExecutionRequest,
33+
ExecutionAdapter,
34+
ExecutionCommandKind,
35+
ExecutionResponse,
36+
ExecutionResultStatus,
37+
)
38+
from ohip_logging.recorder import EventRecorder
39+
40+
from .coordinator import RuntimeCoordinator
41+
from .requests import CoordinationDecision, InteractionRequest
42+
from .session_store import SessionStore
43+
from .state import ExecutionState, InteractionSession, InteractionState
44+
45+
46+
@dataclass(frozen=True)
47+
class RuntimeServiceResult:
48+
"""
49+
Result bundle for one handled runtime request.
50+
"""
51+
52+
session: InteractionSession
53+
decision: CoordinationDecision
54+
execution_response: Optional[ExecutionResponse] = None
55+
56+
@property
57+
def executed(self) -> bool:
58+
return self.execution_response is not None and bool(self.execution_response.accepted)
59+
60+
61+
class RuntimeService:
62+
"""
63+
High-level runtime orchestration service.
64+
65+
The service is intentionally conservative:
66+
- session state is stored explicitly
67+
- the coordinator remains the source of decision logic
68+
- the execution adapter may reject requests it cannot honor
69+
- event recording happens in the same request flow
70+
"""
71+
72+
def __init__(
73+
self,
74+
*,
75+
session_store: SessionStore,
76+
coordinator: RuntimeCoordinator,
77+
recorder: EventRecorder,
78+
execution_adapter: Optional[ExecutionAdapter] = None,
79+
) -> None:
80+
self._sessions = session_store
81+
self._coordinator = coordinator
82+
self._recorder = recorder
83+
self._execution = execution_adapter
84+
85+
def upsert_session(self, session: InteractionSession) -> InteractionSession:
86+
return self._sessions.upsert(session)
87+
88+
def get_session(self, session_id: str) -> Optional[InteractionSession]:
89+
return self._sessions.get(session_id)
90+
91+
def require_session(self, session_id: str) -> InteractionSession:
92+
return self._sessions.require(session_id)
93+
94+
def handle_request(
95+
self,
96+
*,
97+
request: InteractionRequest,
98+
nudge: Optional[Nudge] = None,
99+
profile_name: Optional[str] = None,
100+
hints: Optional[PlannerHints] = None,
101+
start_pose: Optional[Pose] = None,
102+
) -> RuntimeServiceResult:
103+
"""
104+
Evaluate and optionally execute one runtime request.
105+
106+
Flow:
107+
1. load session
108+
2. coordinator decides
109+
3. recorder logs request / decision cycle
110+
4. coordinator mutates session conservatively
111+
5. optional execution submission when executable
112+
6. recorder logs fault and/or execution state
113+
7. updated session is persisted
114+
"""
115+
session = self._sessions.require(request.session_id)
116+
117+
decision = self._coordinator.decide(
118+
session=session,
119+
request=request,
120+
nudge=nudge,
121+
profile_name=profile_name,
122+
hints=hints,
123+
start_pose=start_pose,
124+
)
125+
126+
self._recorder.record_decision_cycle(
127+
session=session,
128+
request=request,
129+
decision=decision,
130+
persist=True,
131+
)
132+
133+
before_interaction = session.interaction_state
134+
before_execution = session.execution_state
135+
136+
self._coordinator.apply_decision_to_session(
137+
session=session,
138+
decision=decision,
139+
)
140+
141+
self._record_session_side_effects(
142+
session=session,
143+
request_id=request.request_id,
144+
previous_interaction_state=before_interaction,
145+
previous_execution_state=before_execution,
146+
reason_code=decision.reason_code,
147+
)
148+
149+
execution_response: Optional[ExecutionResponse] = None
150+
151+
if decision.executable and self._execution is not None and decision.planning is not None:
152+
exec_request = BoundedExecutionRequest(
153+
request_id=request.request_id,
154+
session_id=session.session_id,
155+
command_kind=ExecutionCommandKind.PLAN,
156+
safety_level=decision.safety.safety_level,
157+
plan=decision.planning.plan,
158+
max_speed_scale=1.0,
159+
timeout_s=3.0,
160+
reason_code=decision.reason_code,
161+
)
162+
execution_response = self._execution.submit(exec_request)
163+
164+
prev_interaction = session.interaction_state
165+
prev_execution = session.execution_state
166+
167+
self._apply_execution_response_to_session(
168+
session=session,
169+
response=execution_response,
170+
)
171+
172+
if (
173+
session.interaction_state != prev_interaction
174+
or session.execution_state != prev_execution
175+
):
176+
self._recorder.record_state_transition(
177+
event_id=f"{request.request_id}:post_submit_transition",
178+
session_id=session.session_id,
179+
from_interaction_state=prev_interaction,
180+
to_interaction_state=session.interaction_state,
181+
from_execution_state=prev_execution,
182+
to_execution_state=session.execution_state,
183+
runtime_health=session.runtime_health,
184+
reason_code=execution_response.reason_code or "execution_submitted",
185+
persist=True,
186+
)
187+
188+
self._recorder.record_execution_status(
189+
event_id=f"{request.request_id}:execution_submit",
190+
session=session,
191+
request_id=request.request_id,
192+
reason_code=execution_response.reason_code or execution_response.status.value.lower(),
193+
accepted=execution_response.accepted,
194+
backend_status=execution_response.status.value,
195+
progress=1.0 if execution_response.status in {
196+
ExecutionResultStatus.ABORTED,
197+
ExecutionResultStatus.SAFE_HOLD,
198+
ExecutionResultStatus.COMPLETED,
199+
} else 0.0,
200+
persist=True,
201+
)
202+
203+
persisted = self._sessions.update(session)
204+
return RuntimeServiceResult(
205+
session=persisted,
206+
decision=decision,
207+
execution_response=execution_response,
208+
)
209+
210+
def abort_session(self, *, session_id: str, reason_code: str = "") -> Optional[ExecutionResponse]:
211+
if self._execution is None:
212+
return None
213+
214+
session = self._sessions.require(session_id)
215+
previous_interaction = session.interaction_state
216+
previous_execution = session.execution_state
217+
218+
response = self._execution.abort(session_id=session_id, reason_code=reason_code)
219+
self._apply_execution_response_to_session(session=session, response=response)
220+
221+
if (
222+
session.interaction_state != previous_interaction
223+
or session.execution_state != previous_execution
224+
):
225+
self._recorder.record_state_transition(
226+
event_id=f"{session_id}:abort_transition",
227+
session_id=session_id,
228+
from_interaction_state=previous_interaction,
229+
to_interaction_state=session.interaction_state,
230+
from_execution_state=previous_execution,
231+
to_execution_state=session.execution_state,
232+
runtime_health=session.runtime_health,
233+
reason_code=response.reason_code or "abort",
234+
persist=True,
235+
)
236+
237+
self._recorder.record_execution_status(
238+
event_id=f"{session_id}:abort_execution_status",
239+
session=session,
240+
request_id=response.request_id or None,
241+
reason_code=response.reason_code or "abort",
242+
accepted=response.accepted,
243+
backend_status=response.status.value,
244+
progress=1.0 if response.accepted else 0.0,
245+
persist=True,
246+
)
247+
self._sessions.update(session)
248+
return response
249+
250+
def safe_hold_session(self, *, session_id: str, reason_code: str = "") -> Optional[ExecutionResponse]:
251+
if self._execution is None:
252+
return None
253+
254+
session = self._sessions.require(session_id)
255+
previous_interaction = session.interaction_state
256+
previous_execution = session.execution_state
257+
258+
response = self._execution.safe_hold(session_id=session_id, reason_code=reason_code)
259+
self._apply_execution_response_to_session(session=session, response=response)
260+
261+
if (
262+
session.interaction_state != previous_interaction
263+
or session.execution_state != previous_execution
264+
):
265+
self._recorder.record_state_transition(
266+
event_id=f"{session_id}:safe_hold_transition",
267+
session_id=session_id,
268+
from_interaction_state=previous_interaction,
269+
to_interaction_state=session.interaction_state,
270+
from_execution_state=previous_execution,
271+
to_execution_state=session.execution_state,
272+
runtime_health=session.runtime_health,
273+
reason_code=response.reason_code or "safe_hold",
274+
persist=True,
275+
)
276+
277+
self._recorder.record_execution_status(
278+
event_id=f"{session_id}:safe_hold_execution_status",
279+
session=session,
280+
request_id=response.request_id or None,
281+
reason_code=response.reason_code or "safe_hold",
282+
accepted=response.accepted,
283+
backend_status=response.status.value,
284+
progress=1.0 if response.accepted else 0.0,
285+
persist=True,
286+
)
287+
self._sessions.update(session)
288+
return response
289+
290+
def _record_session_side_effects(
291+
self,
292+
*,
293+
session: InteractionSession,
294+
request_id: str,
295+
previous_interaction_state: InteractionState,
296+
previous_execution_state: ExecutionState,
297+
reason_code: str,
298+
) -> None:
299+
if (
300+
session.interaction_state != previous_interaction_state
301+
or session.execution_state != previous_execution_state
302+
):
303+
self._recorder.record_state_transition(
304+
event_id=f"{request_id}:session_transition",
305+
session_id=session.session_id,
306+
from_interaction_state=previous_interaction_state,
307+
to_interaction_state=session.interaction_state,
308+
from_execution_state=previous_execution_state,
309+
to_execution_state=session.execution_state,
310+
runtime_health=session.runtime_health,
311+
reason_code=reason_code,
312+
persist=True,
313+
)
314+
315+
if session.active_fault is not None:
316+
self._recorder.record_fault(
317+
session=session,
318+
fault=session.active_fault,
319+
persist=True,
320+
)
321+
322+
@staticmethod
323+
def _apply_execution_response_to_session(
324+
*,
325+
session: InteractionSession,
326+
response: ExecutionResponse,
327+
) -> None:
328+
"""
329+
Reflect immediate execution-adapter response into the runtime session.
330+
"""
331+
if response.status == ExecutionResultStatus.ACCEPTED:
332+
session.execution_state = ExecutionState.READY
333+
session.runtime_health = session.runtime_health
334+
elif response.status == ExecutionResultStatus.RUNNING:
335+
session.execution_state = ExecutionState.EXECUTING
336+
elif response.status == ExecutionResultStatus.RETREATING:
337+
session.interaction_state = InteractionState.RETREAT
338+
session.execution_state = ExecutionState.RETREATING
339+
elif response.status == ExecutionResultStatus.SAFE_HOLD:
340+
session.interaction_state = InteractionState.SAFE_HOLD
341+
session.execution_state = ExecutionState.SAFE_HOLD
342+
elif response.status == ExecutionResultStatus.ABORTED:
343+
session.execution_state = ExecutionState.ABORTING
344+
elif response.status == ExecutionResultStatus.FAULTED:
345+
session.interaction_state = InteractionState.FAULT_LATCHED
346+
session.execution_state = ExecutionState.FAULTED
347+
elif response.status == ExecutionResultStatus.UNAVAILABLE:
348+
session.execution_state = ExecutionState.UNAVAILABLE
349+
350+
session.mark_updated()
351+
352+
353+
__all__ = [
354+
"RuntimeServiceResult",
355+
"RuntimeService",
356+
]

0 commit comments

Comments
 (0)