toolplane-python-client 0.1.0__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.
- toolplane/__init__.py +106 -0
- toolplane/common/__init__.py +93 -0
- toolplane/common/base_config.py +129 -0
- toolplane/common/base_connection_manager.py +171 -0
- toolplane/common/base_session_manager.py +321 -0
- toolplane/common/base_tool_manager.py +347 -0
- toolplane/common/constants.py +47 -0
- toolplane/common/utils.py +310 -0
- toolplane/core/__init__.py +67 -0
- toolplane/core/config.py +107 -0
- toolplane/core/connection.py +285 -0
- toolplane/core/errors.py +298 -0
- toolplane/core/machine.py +480 -0
- toolplane/core/request.py +775 -0
- toolplane/core/session.py +332 -0
- toolplane/core/session_context.py +514 -0
- toolplane/core/task.py +130 -0
- toolplane/core/tool.py +329 -0
- toolplane/http_core/__init__.py +37 -0
- toolplane/http_core/http_config.py +97 -0
- toolplane/http_core/http_connection.py +409 -0
- toolplane/http_core/http_machine.py +298 -0
- toolplane/http_core/http_request.py +748 -0
- toolplane/http_core/http_session.py +348 -0
- toolplane/http_core/http_session_context.py +491 -0
- toolplane/http_core/http_task.py +101 -0
- toolplane/http_core/http_tool.py +400 -0
- toolplane/interfaces/__init__.py +27 -0
- toolplane/interfaces/client_interface.py +122 -0
- toolplane/interfaces/connection_interface.py +193 -0
- toolplane/interfaces/event_interface.py +290 -0
- toolplane/interfaces/request_interface.py +439 -0
- toolplane/interfaces/session_interface.py +288 -0
- toolplane/interfaces/tool_interface.py +441 -0
- toolplane/proto/__init__.py +0 -0
- toolplane/proto/service_pb2.py +315 -0
- toolplane/proto/service_pb2_grpc.py +2240 -0
- toolplane/provider_cli.py +268 -0
- toolplane/provider_registry.py +77 -0
- toolplane/provider_runtime.py +302 -0
- toolplane/toolkits/__init__.py +0 -0
- toolplane/toolkits/standalone_tools/__init__.py +0 -0
- toolplane/toolkits/standalone_tools/create_directory.py +94 -0
- toolplane/toolkits/standalone_tools/create_file.py +124 -0
- toolplane/toolkits/standalone_tools/file_search.py +229 -0
- toolplane/toolkits/standalone_tools/grep_search.py +372 -0
- toolplane/toolkits/standalone_tools/launcher.py +146 -0
- toolplane/toolkits/standalone_tools/list_dir.py +395 -0
- toolplane/toolkits/standalone_tools/read_file.py +346 -0
- toolplane/toolkits/standalone_tools/replace_string_in_file.py +407 -0
- toolplane/toolkits/standalone_tools/run_tests.py +66 -0
- toolplane/toolkits/standalone_tools/semantic_search.py +485 -0
- toolplane/toolkits/standalone_tools/standalone_toolkit.py +979 -0
- toolplane/toolkits/standalone_tools/test_failure_analysis.py +618 -0
- toolplane/toolkits/standalone_tools/test_standalone_toolkit.py +517 -0
- toolplane/toolkits/swe/__init__.py +35 -0
- toolplane/toolkits/swe/create_directory.py +15 -0
- toolplane/toolkits/swe/create_file.py +15 -0
- toolplane/toolkits/swe/descriptions.py +273 -0
- toolplane/toolkits/swe/execute_bash.py +93 -0
- toolplane/toolkits/swe/file_editor.py +775 -0
- toolplane/toolkits/swe/file_search.py +16 -0
- toolplane/toolkits/swe/finish.py +50 -0
- toolplane/toolkits/swe/grep_search.py +19 -0
- toolplane/toolkits/swe/list_dir.py +407 -0
- toolplane/toolkits/swe/read_file.py +18 -0
- toolplane/toolkits/swe/replace_string_in_file.py +17 -0
- toolplane/toolkits/swe/search.py +260 -0
- toolplane/toolkits/swe/semantic_search.py +20 -0
- toolplane/toolkits/swe/str_replace_editor.py +647 -0
- toolplane/toolkits/swe/submit.py +29 -0
- toolplane/toolkits/swe/swe_toolkit.py +1296 -0
- toolplane/toolplane_client.py +686 -0
- toolplane/toolplane_http_client.py +681 -0
- toolplane/utils/__init__.py +3 -0
- toolplane/utils/schema.py +146 -0
- toolplane_python_client-0.1.0.dist-info/METADATA +543 -0
- toolplane_python_client-0.1.0.dist-info/RECORD +81 -0
- toolplane_python_client-0.1.0.dist-info/WHEEL +5 -0
- toolplane_python_client-0.1.0.dist-info/entry_points.txt +2 -0
- toolplane_python_client-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,480 @@
|
|
|
1
|
+
"""Machine management for Toolplane client."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import threading
|
|
5
|
+
import time
|
|
6
|
+
from typing import Any, Dict, List, Optional
|
|
7
|
+
|
|
8
|
+
import grpc
|
|
9
|
+
|
|
10
|
+
from toolplane.interfaces.event_interface import Event, EventType
|
|
11
|
+
from toolplane.proto.service_pb2 import (
|
|
12
|
+
DrainMachineRequest,
|
|
13
|
+
GetMachineRequest,
|
|
14
|
+
ListMachinesRequest,
|
|
15
|
+
RegisterMachineRequest,
|
|
16
|
+
UnregisterMachineRequest,
|
|
17
|
+
UpdateMachinePingRequest,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
from ..common.utils import timestamp_to_iso
|
|
21
|
+
from .connection import ConnectionManager
|
|
22
|
+
from .errors import MachineError, api_error_from_rpc_error
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class MachineManager:
|
|
28
|
+
"""Manages machine registration and heartbeats."""
|
|
29
|
+
|
|
30
|
+
def __init__(
|
|
31
|
+
self,
|
|
32
|
+
connection_manager: ConnectionManager,
|
|
33
|
+
event_emitter: Optional[Any] = None,
|
|
34
|
+
tool_manager=None,
|
|
35
|
+
session_manager=None,
|
|
36
|
+
):
|
|
37
|
+
"""Initialize machine manager."""
|
|
38
|
+
self.connection_manager = connection_manager
|
|
39
|
+
self.machines: Dict[str, str] = {} # session_id -> machine_id
|
|
40
|
+
self.machines_lock = threading.RLock()
|
|
41
|
+
self._last_heartbeat: Dict[str, float] = {}
|
|
42
|
+
self._heartbeat_thread: Optional[threading.Thread] = None
|
|
43
|
+
self._running = False
|
|
44
|
+
self._tool_manager = tool_manager
|
|
45
|
+
self._session_manager = session_manager
|
|
46
|
+
self._last_recovery_attempt: Dict[str, float] = {}
|
|
47
|
+
self._event_emitter = event_emitter
|
|
48
|
+
|
|
49
|
+
def _emit_event(self, event_type: EventType, data: Dict[str, Any]) -> None:
|
|
50
|
+
if not self._event_emitter:
|
|
51
|
+
return
|
|
52
|
+
try:
|
|
53
|
+
self._event_emitter.emit(
|
|
54
|
+
Event(type=event_type, source="machine_manager", data=data)
|
|
55
|
+
)
|
|
56
|
+
except Exception:
|
|
57
|
+
# Event emission should not break core flows
|
|
58
|
+
pass
|
|
59
|
+
|
|
60
|
+
def attach_tool_manager(self, tool_manager):
|
|
61
|
+
"""Attach tool manager to support re-registration after recovery."""
|
|
62
|
+
self._tool_manager = tool_manager
|
|
63
|
+
|
|
64
|
+
def attach_session_manager(self, session_manager):
|
|
65
|
+
"""Attach session manager to update session context on recovery."""
|
|
66
|
+
self._session_manager = session_manager
|
|
67
|
+
|
|
68
|
+
def _normalize_machine(self, machine: Any) -> Dict[str, Any]:
|
|
69
|
+
return {
|
|
70
|
+
"id": machine.id,
|
|
71
|
+
"session_id": machine.session_id,
|
|
72
|
+
"sdk_version": machine.sdk_version,
|
|
73
|
+
"sdk_language": machine.sdk_language,
|
|
74
|
+
"ip": machine.ip,
|
|
75
|
+
"created_at": timestamp_to_iso(machine.created_at),
|
|
76
|
+
"last_ping_at": timestamp_to_iso(getattr(machine, "last_ping_at", None)),
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
def _clear_local_machine(self, session_id: str, machine_id: str) -> None:
|
|
80
|
+
with self.machines_lock:
|
|
81
|
+
if self.machines.get(session_id) == machine_id:
|
|
82
|
+
self.machines.pop(session_id, None)
|
|
83
|
+
self._last_heartbeat.pop(session_id, None)
|
|
84
|
+
|
|
85
|
+
def list_machines(self, session_id: str) -> List[Dict[str, Any]]:
|
|
86
|
+
"""List machines for a session."""
|
|
87
|
+
self.connection_manager.ensure_connected()
|
|
88
|
+
|
|
89
|
+
try:
|
|
90
|
+
request = ListMachinesRequest(session_id=session_id)
|
|
91
|
+
response = self.connection_manager.machine_stub.ListMachines(
|
|
92
|
+
request, metadata=self.connection_manager.get_metadata()
|
|
93
|
+
)
|
|
94
|
+
return [self._normalize_machine(machine) for machine in response.machines]
|
|
95
|
+
except grpc.RpcError as rpc_error:
|
|
96
|
+
self._handle_rpc_error(rpc_error)
|
|
97
|
+
raise api_error_from_rpc_error(
|
|
98
|
+
rpc_error, context=f"Failed to list machines for session {session_id}"
|
|
99
|
+
) from rpc_error
|
|
100
|
+
except Exception as e:
|
|
101
|
+
raise MachineError(f"Failed to list machines for session {session_id}: {e}")
|
|
102
|
+
|
|
103
|
+
def get_machine(self, session_id: str, machine_id: str) -> Dict[str, Any]:
|
|
104
|
+
"""Get a specific machine by ID."""
|
|
105
|
+
self.connection_manager.ensure_connected()
|
|
106
|
+
|
|
107
|
+
try:
|
|
108
|
+
request = GetMachineRequest(session_id=session_id, machine_id=machine_id)
|
|
109
|
+
response = self.connection_manager.machine_stub.GetMachine(
|
|
110
|
+
request, metadata=self.connection_manager.get_metadata()
|
|
111
|
+
)
|
|
112
|
+
return self._normalize_machine(response)
|
|
113
|
+
except grpc.RpcError as rpc_error:
|
|
114
|
+
self._handle_rpc_error(rpc_error)
|
|
115
|
+
raise api_error_from_rpc_error(
|
|
116
|
+
rpc_error,
|
|
117
|
+
context=f"Failed to get machine {machine_id} for session {session_id}",
|
|
118
|
+
) from rpc_error
|
|
119
|
+
except Exception as e:
|
|
120
|
+
raise MachineError(
|
|
121
|
+
f"Failed to get machine {machine_id} for session {session_id}: {e}"
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
def register_machine(self, session_id: str) -> str:
|
|
125
|
+
"""Register a machine for a session."""
|
|
126
|
+
self.connection_manager.ensure_connected()
|
|
127
|
+
|
|
128
|
+
self._emit_event(
|
|
129
|
+
EventType.MACHINE_REGISTERED,
|
|
130
|
+
{
|
|
131
|
+
"session_id": session_id,
|
|
132
|
+
"phase": "start",
|
|
133
|
+
},
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
try:
|
|
137
|
+
request = RegisterMachineRequest(
|
|
138
|
+
session_id=session_id,
|
|
139
|
+
machine_id="", # Server generates ID
|
|
140
|
+
sdk_version="1.0.0",
|
|
141
|
+
sdk_language="python",
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
response = self.connection_manager.machine_stub.RegisterMachine(
|
|
145
|
+
request, metadata=self.connection_manager.get_metadata()
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
machine_id = response.id
|
|
149
|
+
|
|
150
|
+
# The registration response carries the per-machine credential
|
|
151
|
+
# exactly once. Store it so every subsequent provide-scoped call
|
|
152
|
+
# presents it (see ConnectionManager.get_metadata).
|
|
153
|
+
machine_token = getattr(response, "machine_token", "")
|
|
154
|
+
if machine_token:
|
|
155
|
+
self.connection_manager.set_machine_token(machine_token)
|
|
156
|
+
|
|
157
|
+
with self.machines_lock:
|
|
158
|
+
self.machines[session_id] = machine_id
|
|
159
|
+
self._last_heartbeat[session_id] = time.time()
|
|
160
|
+
|
|
161
|
+
self._emit_event(
|
|
162
|
+
EventType.MACHINE_REGISTERED,
|
|
163
|
+
{
|
|
164
|
+
"session_id": session_id,
|
|
165
|
+
"machine_id": machine_id,
|
|
166
|
+
"phase": "success",
|
|
167
|
+
},
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
return machine_id
|
|
171
|
+
|
|
172
|
+
except grpc.RpcError as rpc_error:
|
|
173
|
+
self._emit_event(
|
|
174
|
+
EventType.MACHINE_REGISTERED,
|
|
175
|
+
{
|
|
176
|
+
"session_id": session_id,
|
|
177
|
+
"phase": "error",
|
|
178
|
+
"error": str(rpc_error),
|
|
179
|
+
},
|
|
180
|
+
)
|
|
181
|
+
self._handle_rpc_error(rpc_error)
|
|
182
|
+
raise api_error_from_rpc_error(
|
|
183
|
+
rpc_error,
|
|
184
|
+
context=f"Failed to register machine for session {session_id}",
|
|
185
|
+
) from rpc_error
|
|
186
|
+
except Exception as e:
|
|
187
|
+
self._emit_event(
|
|
188
|
+
EventType.MACHINE_REGISTERED,
|
|
189
|
+
{
|
|
190
|
+
"session_id": session_id,
|
|
191
|
+
"phase": "error",
|
|
192
|
+
"error": str(e),
|
|
193
|
+
},
|
|
194
|
+
)
|
|
195
|
+
raise MachineError(
|
|
196
|
+
f"Failed to register machine for session {session_id}: {e}"
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
def unregister_machine(
|
|
200
|
+
self,
|
|
201
|
+
session_id: str,
|
|
202
|
+
machine_id: Optional[str] = None,
|
|
203
|
+
reason: str = "explicit",
|
|
204
|
+
) -> bool:
|
|
205
|
+
"""Unregister a machine for a session."""
|
|
206
|
+
machine_id = machine_id or self.get_machine_id(session_id)
|
|
207
|
+
if not machine_id:
|
|
208
|
+
return True
|
|
209
|
+
|
|
210
|
+
try:
|
|
211
|
+
self._emit_event(
|
|
212
|
+
EventType.MACHINE_UNREGISTERED,
|
|
213
|
+
{
|
|
214
|
+
"session_id": session_id,
|
|
215
|
+
"machine_id": machine_id,
|
|
216
|
+
"phase": "start",
|
|
217
|
+
"reason": reason,
|
|
218
|
+
},
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
# Drain machine first
|
|
222
|
+
self.drain_machine(session_id, machine_id)
|
|
223
|
+
|
|
224
|
+
# Unregister machine
|
|
225
|
+
request = UnregisterMachineRequest(
|
|
226
|
+
session_id=session_id,
|
|
227
|
+
machine_id=machine_id,
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
self.connection_manager.machine_stub.UnregisterMachine(
|
|
231
|
+
request, metadata=self.connection_manager.get_metadata()
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
self._clear_local_machine(session_id, machine_id)
|
|
235
|
+
|
|
236
|
+
self._emit_event(
|
|
237
|
+
EventType.MACHINE_UNREGISTERED,
|
|
238
|
+
{
|
|
239
|
+
"session_id": session_id,
|
|
240
|
+
"machine_id": machine_id,
|
|
241
|
+
"phase": "success",
|
|
242
|
+
"reason": reason,
|
|
243
|
+
},
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
return True
|
|
247
|
+
|
|
248
|
+
except grpc.RpcError as rpc_error:
|
|
249
|
+
self._emit_event(
|
|
250
|
+
EventType.MACHINE_UNREGISTERED,
|
|
251
|
+
{
|
|
252
|
+
"session_id": session_id,
|
|
253
|
+
"machine_id": machine_id,
|
|
254
|
+
"phase": "error",
|
|
255
|
+
"reason": reason,
|
|
256
|
+
"error": str(rpc_error),
|
|
257
|
+
},
|
|
258
|
+
)
|
|
259
|
+
self._handle_rpc_error(rpc_error)
|
|
260
|
+
raise api_error_from_rpc_error(
|
|
261
|
+
rpc_error,
|
|
262
|
+
context=f"Failed to unregister machine for session {session_id}",
|
|
263
|
+
) from rpc_error
|
|
264
|
+
except Exception as e:
|
|
265
|
+
self._emit_event(
|
|
266
|
+
EventType.MACHINE_UNREGISTERED,
|
|
267
|
+
{
|
|
268
|
+
"session_id": session_id,
|
|
269
|
+
"machine_id": machine_id,
|
|
270
|
+
"phase": "error",
|
|
271
|
+
"reason": reason,
|
|
272
|
+
"error": str(e),
|
|
273
|
+
},
|
|
274
|
+
)
|
|
275
|
+
raise MachineError(
|
|
276
|
+
f"Failed to unregister machine for session {session_id}: {e}"
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
def drain_machine(self, session_id: str, machine_id: Optional[str] = None) -> bool:
|
|
280
|
+
"""Drain a machine for a session."""
|
|
281
|
+
machine_id = machine_id or self.get_machine_id(session_id)
|
|
282
|
+
if not machine_id:
|
|
283
|
+
return True
|
|
284
|
+
|
|
285
|
+
try:
|
|
286
|
+
request = DrainMachineRequest(
|
|
287
|
+
session_id=session_id,
|
|
288
|
+
machine_id=machine_id,
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
self.connection_manager.machine_stub.DrainMachine(
|
|
292
|
+
request, metadata=self.connection_manager.get_metadata()
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
self._clear_local_machine(session_id, machine_id)
|
|
296
|
+
|
|
297
|
+
return True
|
|
298
|
+
|
|
299
|
+
except grpc.RpcError as rpc_error:
|
|
300
|
+
self._handle_rpc_error(rpc_error)
|
|
301
|
+
raise api_error_from_rpc_error(
|
|
302
|
+
rpc_error, context=f"Failed to drain machine for session {session_id}"
|
|
303
|
+
) from rpc_error
|
|
304
|
+
except Exception as e:
|
|
305
|
+
raise MachineError(f"Failed to drain machine for session {session_id}: {e}")
|
|
306
|
+
|
|
307
|
+
def get_machine_id(self, session_id: str) -> Optional[str]:
|
|
308
|
+
"""Get machine ID for a session."""
|
|
309
|
+
with self.machines_lock:
|
|
310
|
+
return self.machines.get(session_id)
|
|
311
|
+
|
|
312
|
+
def start_heartbeat(self, heartbeat_interval: int = 60):
|
|
313
|
+
"""Start heartbeat thread."""
|
|
314
|
+
if self._running:
|
|
315
|
+
return
|
|
316
|
+
|
|
317
|
+
self._running = True
|
|
318
|
+
self._heartbeat_thread = threading.Thread(
|
|
319
|
+
target=self._heartbeat_loop, args=(heartbeat_interval,), daemon=True
|
|
320
|
+
)
|
|
321
|
+
self._heartbeat_thread.start()
|
|
322
|
+
|
|
323
|
+
def stop_heartbeat(self):
|
|
324
|
+
"""Stop heartbeat thread."""
|
|
325
|
+
self._running = False
|
|
326
|
+
if self._heartbeat_thread:
|
|
327
|
+
self._heartbeat_thread.join(timeout=1)
|
|
328
|
+
|
|
329
|
+
def _heartbeat_loop(self, interval: int):
|
|
330
|
+
"""Heartbeat loop for all machines."""
|
|
331
|
+
while self._running:
|
|
332
|
+
sessions_to_recover: List[str] = []
|
|
333
|
+
|
|
334
|
+
try:
|
|
335
|
+
self.connection_manager.ensure_connected()
|
|
336
|
+
except Exception:
|
|
337
|
+
time.sleep(min(interval, 5))
|
|
338
|
+
continue
|
|
339
|
+
|
|
340
|
+
now = time.time()
|
|
341
|
+
|
|
342
|
+
with self.machines_lock:
|
|
343
|
+
due_machines = [
|
|
344
|
+
(session_id, machine_id)
|
|
345
|
+
for session_id, machine_id in self.machines.items()
|
|
346
|
+
if now - self._last_heartbeat.get(session_id, 0) >= interval
|
|
347
|
+
]
|
|
348
|
+
|
|
349
|
+
for session_id, machine_id in due_machines:
|
|
350
|
+
try:
|
|
351
|
+
request = UpdateMachinePingRequest(
|
|
352
|
+
session_id=session_id, machine_id=machine_id
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
self.connection_manager.machine_stub.UpdateMachinePing(
|
|
356
|
+
request, metadata=self.connection_manager.get_metadata()
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
with self.machines_lock:
|
|
360
|
+
self._last_heartbeat[session_id] = now
|
|
361
|
+
|
|
362
|
+
except grpc.RpcError as rpc_error:
|
|
363
|
+
code = rpc_error.code()
|
|
364
|
+
if code in (
|
|
365
|
+
grpc.StatusCode.NOT_FOUND,
|
|
366
|
+
grpc.StatusCode.FAILED_PRECONDITION,
|
|
367
|
+
):
|
|
368
|
+
self._emit_event(
|
|
369
|
+
EventType.MACHINE_HEARTBEAT_FAILED,
|
|
370
|
+
{
|
|
371
|
+
"session_id": session_id,
|
|
372
|
+
"machine_id": machine_id,
|
|
373
|
+
"status_code": code.name,
|
|
374
|
+
"error": str(rpc_error),
|
|
375
|
+
},
|
|
376
|
+
)
|
|
377
|
+
last_attempt = self._last_recovery_attempt.get(session_id, 0)
|
|
378
|
+
if now - last_attempt >= interval:
|
|
379
|
+
self._last_recovery_attempt[session_id] = now
|
|
380
|
+
sessions_to_recover.append(session_id)
|
|
381
|
+
with self.machines_lock:
|
|
382
|
+
self._last_heartbeat[session_id] = now
|
|
383
|
+
elif code == grpc.StatusCode.UNAVAILABLE:
|
|
384
|
+
self.connection_manager.mark_unhealthy()
|
|
385
|
+
with self.machines_lock:
|
|
386
|
+
self._last_heartbeat[session_id] = now
|
|
387
|
+
else:
|
|
388
|
+
logger.warning(
|
|
389
|
+
"Heartbeat error for session %s: %s", session_id, rpc_error
|
|
390
|
+
)
|
|
391
|
+
with self.machines_lock:
|
|
392
|
+
self._last_heartbeat[session_id] = now
|
|
393
|
+
|
|
394
|
+
except Exception as exc:
|
|
395
|
+
logger.warning(
|
|
396
|
+
"Unexpected heartbeat error for session %s: %s",
|
|
397
|
+
session_id,
|
|
398
|
+
exc,
|
|
399
|
+
)
|
|
400
|
+
with self.machines_lock:
|
|
401
|
+
self._last_heartbeat[session_id] = now
|
|
402
|
+
|
|
403
|
+
for session_id in sessions_to_recover:
|
|
404
|
+
self._recover_session(session_id)
|
|
405
|
+
|
|
406
|
+
time.sleep(5) # Check frequently, but send only per interval
|
|
407
|
+
|
|
408
|
+
def cleanup_all(self):
|
|
409
|
+
"""Cleanup all machines."""
|
|
410
|
+
self.stop_heartbeat()
|
|
411
|
+
|
|
412
|
+
with self.machines_lock:
|
|
413
|
+
self._last_recovery_attempt.clear()
|
|
414
|
+
for session_id in list(self.machines.keys()):
|
|
415
|
+
try:
|
|
416
|
+
self.unregister_machine(session_id, reason="cleanup_all")
|
|
417
|
+
except Exception:
|
|
418
|
+
# Ignore cleanup errors
|
|
419
|
+
pass
|
|
420
|
+
|
|
421
|
+
def _handle_rpc_error(self, rpc_error: grpc.RpcError):
|
|
422
|
+
"""Handle channel-level failures by resetting the connection.
|
|
423
|
+
|
|
424
|
+
Only UNAVAILABLE indicates the channel itself is broken. Credential
|
|
425
|
+
and state errors are deterministic per-call outcomes; recycling the
|
|
426
|
+
channel (which triggers machine re-registration) cannot fix them.
|
|
427
|
+
"""
|
|
428
|
+
if rpc_error.code() == grpc.StatusCode.UNAVAILABLE:
|
|
429
|
+
self.connection_manager.mark_unhealthy()
|
|
430
|
+
|
|
431
|
+
def _recover_session(self, session_id: str):
|
|
432
|
+
"""Attempt to recover machine and tool registrations for a session."""
|
|
433
|
+
try:
|
|
434
|
+
previous_machine_id = self.get_machine_id(session_id)
|
|
435
|
+
self._emit_event(
|
|
436
|
+
EventType.MACHINE_RECOVERY_STARTED,
|
|
437
|
+
{
|
|
438
|
+
"session_id": session_id,
|
|
439
|
+
"previous_machine_id": previous_machine_id,
|
|
440
|
+
},
|
|
441
|
+
)
|
|
442
|
+
|
|
443
|
+
new_machine_id = self.register_machine(session_id)
|
|
444
|
+
|
|
445
|
+
if self._tool_manager:
|
|
446
|
+
self._tool_manager.reregister_session_tools(session_id, new_machine_id)
|
|
447
|
+
|
|
448
|
+
if self._session_manager:
|
|
449
|
+
context = self._session_manager.get_session_context(session_id)
|
|
450
|
+
if context:
|
|
451
|
+
context.machine_id = new_machine_id
|
|
452
|
+
|
|
453
|
+
with self.machines_lock:
|
|
454
|
+
self._last_recovery_attempt.pop(session_id, None)
|
|
455
|
+
|
|
456
|
+
logger.info(
|
|
457
|
+
"Recovered machine for session %s with new machine ID %s",
|
|
458
|
+
session_id,
|
|
459
|
+
new_machine_id,
|
|
460
|
+
)
|
|
461
|
+
|
|
462
|
+
self._emit_event(
|
|
463
|
+
EventType.MACHINE_RECOVERY_SUCCEEDED,
|
|
464
|
+
{
|
|
465
|
+
"session_id": session_id,
|
|
466
|
+
"machine_id": new_machine_id,
|
|
467
|
+
},
|
|
468
|
+
)
|
|
469
|
+
|
|
470
|
+
except Exception as exc:
|
|
471
|
+
self._emit_event(
|
|
472
|
+
EventType.MACHINE_RECOVERY_FAILED,
|
|
473
|
+
{
|
|
474
|
+
"session_id": session_id,
|
|
475
|
+
"error": str(exc),
|
|
476
|
+
},
|
|
477
|
+
)
|
|
478
|
+
logger.warning(
|
|
479
|
+
"Failed to recover machine for session %s: %s", session_id, exc
|
|
480
|
+
)
|