openclaw-agent-dashboard 1.0.39 → 1.0.40

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 (54) hide show
  1. package/dashboard/api/agent_config_api.py +28 -7
  2. package/dashboard/api/agents.py +48 -10
  3. package/dashboard/api/agents_config.py +5 -1
  4. package/dashboard/api/chains.py +25 -5
  5. package/dashboard/api/collaboration.py +10 -9
  6. package/dashboard/api/debug_paths.py +5 -1
  7. package/dashboard/api/error_analysis.py +29 -11
  8. package/dashboard/api/errors.py +27 -11
  9. package/dashboard/api/fortify_routes.py +80 -0
  10. package/dashboard/api/input_safety.py +60 -0
  11. package/dashboard/api/performance.py +73 -53
  12. package/dashboard/api/subagents.py +95 -99
  13. package/dashboard/api/timeline.py +24 -3
  14. package/dashboard/api/version.py +2 -0
  15. package/dashboard/api/websocket.py +9 -7
  16. package/dashboard/core/__init__.py +1 -0
  17. package/dashboard/core/config_fortify.py +112 -0
  18. package/dashboard/core/error_handler.py +339 -0
  19. package/dashboard/core/fallback_manager.py +70 -0
  20. package/dashboard/core/safe_api_error.py +76 -0
  21. package/dashboard/core/schemas/__init__.py +16 -0
  22. package/dashboard/core/schemas/base.py +43 -0
  23. package/dashboard/core/schemas/session_schema.py +40 -0
  24. package/dashboard/core/schemas/subagent_schema.py +23 -0
  25. package/dashboard/data/agent_config_manager.py +6 -4
  26. package/dashboard/data/chain_reader.py +16 -12
  27. package/dashboard/data/error_analyzer.py +15 -11
  28. package/dashboard/data/session_reader.py +268 -46
  29. package/dashboard/data/subagent_reader.py +74 -49
  30. package/dashboard/data/timeline_reader.py +35 -49
  31. package/dashboard/main.py +24 -2
  32. package/dashboard/mechanism_reader.py +4 -5
  33. package/dashboard/mechanisms.py +2 -2
  34. package/dashboard/pytest.ini +3 -0
  35. package/dashboard/requirements.txt +5 -0
  36. package/dashboard/status/cache_fp_probe.py +40 -0
  37. package/dashboard/status/status_cache.py +199 -72
  38. package/dashboard/status/status_calculator.py +50 -30
  39. package/dashboard/tests/conftest.py +84 -0
  40. package/dashboard/tests/test_api_contracts.py +372 -0
  41. package/dashboard/tests/test_bench_fortify.py +176 -0
  42. package/dashboard/tests/test_fortify.py +741 -0
  43. package/dashboard/utils/__init__.py +1 -0
  44. package/dashboard/utils/data_repair.py +210 -0
  45. package/dashboard/watchers/file_watcher.py +367 -77
  46. package/openclaw.plugin.json +1 -1
  47. package/package.json +1 -1
  48. package/dashboard/agents.py +0 -74
  49. package/dashboard/collaboration.py +0 -407
  50. package/dashboard/errors.py +0 -63
  51. package/dashboard/performance.py +0 -474
  52. package/dashboard/session_reader.py +0 -240
  53. package/dashboard/status_calculator.py +0 -121
  54. package/dashboard/subagent_reader.py +0 -232
@@ -6,7 +6,16 @@ from pathlib import Path
6
6
  from typing import List, Dict, Any, Optional
7
7
 
8
8
  from data.config_reader import get_openclaw_root, normalize_openclaw_agent_id
9
- from data.session_reader import normalize_sessions_index, resolve_session_jsonl_path
9
+ from data.session_reader import (
10
+ normalize_sessions_index,
11
+ resolve_session_jsonl_path,
12
+ _load_sessions_index_file,
13
+ )
14
+ from core.config_fortify import get_fortify_config
15
+ from core.error_handler import record_error
16
+ from core.schemas.base import SchemaValidator
17
+ from core.schemas.subagent_schema import subagent_runs_root_schema
18
+ from utils.data_repair import parse_session_jsonl_line
10
19
 
11
20
 
12
21
  def load_subagent_runs() -> List[Dict[str, Any]]:
@@ -17,13 +26,35 @@ def load_subagent_runs() -> List[Dict[str, Any]]:
17
26
  runs_path = get_openclaw_root() / "subagents" / "runs.json"
18
27
  if not runs_path.exists():
19
28
  return []
20
-
21
- with open(runs_path, 'r', encoding='utf-8') as f:
22
- data = json.load(f)
23
-
29
+
30
+ try:
31
+ with open(runs_path, 'r', encoding='utf-8') as f:
32
+ data = json.load(f)
33
+ except (json.JSONDecodeError, OSError) as e:
34
+ record_error("parsing-error", str(e), "subagent_runs")
35
+ return []
36
+
37
+ if not isinstance(data, dict):
38
+ return []
39
+
40
+ cfg = get_fortify_config()
41
+ vr = SchemaValidator(subagent_runs_root_schema, strict=cfg.json_strict).validate(data)
42
+ if not vr.is_valid:
43
+ record_error("validation-error", vr.error_message, "subagent_runs")
44
+ if cfg.json_strict:
45
+ return []
46
+
24
47
  runs = data.get('runs', {})
25
48
  if isinstance(runs, dict):
26
- return list(runs.values())
49
+ out: List[Dict[str, Any]] = []
50
+ for run_id, rec in runs.items():
51
+ if not isinstance(rec, dict):
52
+ continue
53
+ merged = dict(rec)
54
+ if not merged.get('runId'):
55
+ merged['runId'] = run_id
56
+ out.append(merged)
57
+ return out
27
58
  return runs if isinstance(runs, list) else []
28
59
 
29
60
 
@@ -118,8 +149,9 @@ def get_agent_output_for_run(child_session_key: str, max_chars: int = 10000) ->
118
149
  return None
119
150
 
120
151
  try:
121
- with open(sessions_index, 'r', encoding='utf-8') as f:
122
- index_data = json.load(f)
152
+ index_data = _load_sessions_index_file(sessions_index)
153
+ if not index_data:
154
+ return None
123
155
  index_map = normalize_sessions_index(index_data)
124
156
  entry = index_map.get(child_session_key)
125
157
  if not entry:
@@ -132,22 +164,18 @@ def get_agent_output_for_run(child_session_key: str, max_chars: int = 10000) ->
132
164
  last_text = None
133
165
  with open(session_path, 'r', encoding='utf-8') as f:
134
166
  for line in f:
135
- try:
136
- data = json.loads(line)
137
- if data.get('type') != 'message':
138
- continue
139
- msg = data.get('message', {})
140
- if msg.get('role') != 'assistant':
141
- continue
142
- content = msg.get('content', [])
143
- for c in content:
144
- if isinstance(c, dict) and c.get('type') == 'text':
145
- text = c.get('text', '')
146
- if text and text.strip():
147
- last_text = text
148
- break
149
- except (json.JSONDecodeError, KeyError):
167
+ envelope, msg = parse_session_jsonl_line(line)
168
+ if not envelope or envelope.get('type') != 'message' or msg is None:
150
169
  continue
170
+ if msg.get('role') != 'assistant':
171
+ continue
172
+ content = msg.get('content', [])
173
+ for c in content:
174
+ if isinstance(c, dict) and c.get('type') == 'text':
175
+ text = c.get('text', '')
176
+ if text and text.strip():
177
+ last_text = text
178
+ break
151
179
 
152
180
  if not last_text or not last_text.strip():
153
181
  return None
@@ -155,7 +183,7 @@ def get_agent_output_for_run(child_session_key: str, max_chars: int = 10000) ->
155
183
  return last_text[:max_chars] + '\n\n...(输出已截断)'
156
184
  return last_text
157
185
  except Exception as e:
158
- print(f"get_agent_output_for_run 失败: {e}")
186
+ record_error("io-error", str(e), "subagent_reader:get_agent_output_for_run", exc=e)
159
187
  return None
160
188
 
161
189
 
@@ -180,8 +208,9 @@ def get_agent_files_for_run(child_session_key: str) -> List[str]:
180
208
  return []
181
209
 
182
210
  try:
183
- with open(sessions_index, 'r', encoding='utf-8') as f:
184
- index_data = json.load(f)
211
+ index_data = _load_sessions_index_file(sessions_index)
212
+ if not index_data:
213
+ return []
185
214
  index_map = normalize_sessions_index(index_data)
186
215
  entry = index_map.get(child_session_key)
187
216
  if not entry:
@@ -196,31 +225,27 @@ def get_agent_files_for_run(child_session_key: str) -> List[str]:
196
225
 
197
226
  with open(session_path, 'r', encoding='utf-8') as f:
198
227
  for line in f:
199
- try:
200
- data = json.loads(line)
201
- if data.get('type') != 'message':
228
+ envelope, msg = parse_session_jsonl_line(line)
229
+ if not envelope or envelope.get('type') != 'message' or msg is None:
230
+ continue
231
+ if msg.get('role') != 'assistant':
232
+ continue
233
+ content = msg.get('content', [])
234
+ for c in content:
235
+ if not isinstance(c, dict) or c.get('type') != 'toolCall':
202
236
  continue
203
- msg = data.get('message', {})
204
- if msg.get('role') != 'assistant':
237
+ name = c.get('name', '')
238
+ if name not in file_tools:
205
239
  continue
206
- content = msg.get('content', [])
207
- for c in content:
208
- if not isinstance(c, dict) or c.get('type') != 'toolCall':
240
+ args = c.get('arguments', {})
241
+ if isinstance(args, str):
242
+ try:
243
+ args = json.loads(args)
244
+ except json.JSONDecodeError:
209
245
  continue
210
- name = c.get('name', '')
211
- if name not in file_tools:
212
- continue
213
- args = c.get('arguments', {})
214
- if isinstance(args, str):
215
- try:
216
- args = json.loads(args)
217
- except json.JSONDecodeError:
218
- continue
219
- path = args.get('path') or args.get('file_path')
220
- if path and isinstance(path, str) and path.strip():
221
- file_paths.append(path.strip())
222
- except (json.JSONDecodeError, KeyError):
223
- continue
246
+ path = args.get('path') or args.get('file_path')
247
+ if path and isinstance(path, str) and path.strip():
248
+ file_paths.append(path.strip())
224
249
 
225
250
  # 去重并保持顺序
226
251
  seen = set()
@@ -231,5 +256,5 @@ def get_agent_files_for_run(child_session_key: str) -> List[str]:
231
256
  result.append(p)
232
257
  return result
233
258
  except Exception as e:
234
- print(f"get_agent_files_for_run 失败: {e}")
259
+ record_error("io-error", str(e), "subagent_reader:get_agent_files_for_run", exc=e)
235
260
  return []
@@ -1,7 +1,6 @@
1
1
  """
2
2
  时序数据读取器 - 将 session jsonl 解析为可视化时序步骤
3
3
  """
4
- import json
5
4
  import logging
6
5
  import os
7
6
  from functools import lru_cache
@@ -92,7 +91,13 @@ class TimelineStep:
92
91
 
93
92
 
94
93
  from data.config_reader import get_openclaw_root, normalize_openclaw_agent_id, agent_ids_equal
95
- from data.session_reader import normalize_sessions_index, resolve_session_jsonl_path
94
+ from data.session_reader import (
95
+ normalize_sessions_index,
96
+ resolve_session_jsonl_path,
97
+ _load_sessions_index_file,
98
+ )
99
+ from data.subagent_reader import load_subagent_runs
100
+ from utils.data_repair import parse_session_jsonl_line
96
101
 
97
102
 
98
103
  def _read_session_header_timestamp(path: Path) -> Optional[int]:
@@ -102,10 +107,10 @@ def _read_session_header_timestamp(path: Path) -> Optional[int]:
102
107
  first = f.readline()
103
108
  if not first.strip():
104
109
  return None
105
- data = json.loads(first.strip())
106
- if data.get('type') == 'session':
107
- return _parse_timestamp(data.get('timestamp', 0))
108
- except (json.JSONDecodeError, OSError, IOError):
110
+ envelope, _ = parse_session_jsonl_line(first.strip())
111
+ if envelope and envelope.get('type') == 'session':
112
+ return _parse_timestamp(envelope.get('timestamp', 0))
113
+ except (OSError, IOError):
109
114
  pass
110
115
  return None
111
116
 
@@ -260,17 +265,11 @@ def get_subagent_runs() -> Dict[str, List[Dict]]:
260
265
 
261
266
  @lru_cache(maxsize=16)
262
267
  def _get_subagent_runs_cached(mtime: float) -> Dict[str, List[Dict]]:
263
- runs_file = get_openclaw_root() / "subagents" / "runs.json"
264
- try:
265
- with open(runs_file, 'r', encoding='utf-8') as f:
266
- data = json.load(f)
267
- except (json.JSONDecodeError, IOError, OSError):
268
- return {}
269
268
  runs_by_agent: Dict[str, List[Dict]] = {}
270
- runs = data.get('runs', {})
271
- for run_id, run_info in runs.items():
269
+ for run_info in load_subagent_runs():
272
270
  if not isinstance(run_info, dict):
273
271
  continue
272
+ run_id = str(run_info.get('runId', ''))
274
273
  child_key = run_info.get('childSessionKey', '')
275
274
  if ':' in child_key:
276
275
  parts = child_key.split(':')
@@ -297,9 +296,11 @@ def _get_requester_info_for_session(agent_id: str, session_key: Optional[str]) -
297
296
  sessions_index = get_openclaw_root() / f"agents/{state_id}/sessions/sessions.json"
298
297
  if sessions_index.exists():
299
298
  try:
300
- with open(sessions_index, 'r', encoding='utf-8') as f:
301
- index_data = json.load(f)
302
- index_map = normalize_sessions_index(index_data)
299
+ index_data = _load_sessions_index_file(sessions_index)
300
+ if not index_data:
301
+ index_map = {}
302
+ else:
303
+ index_map = normalize_sessions_index(index_data)
303
304
  if not session_key:
304
305
  entries = list(index_map.items())
305
306
  if entries:
@@ -328,14 +329,8 @@ def _get_requester_info_for_session(agent_id: str, session_key: Optional[str]) -
328
329
  session_key = runs[0].get('childSessionKey')
329
330
  if not session_key:
330
331
  return {}
331
- runs_file = get_openclaw_root() / "subagents" / "runs.json"
332
- if not runs_file.exists():
333
- return {}
334
332
  try:
335
- with open(runs_file, 'r', encoding='utf-8') as f:
336
- data = json.load(f)
337
- runs = data.get('runs', {})
338
- for run_id, run_info in runs.items():
333
+ for run_info in load_subagent_runs():
339
334
  if not isinstance(run_info, dict):
340
335
  continue
341
336
  child_key = run_info.get('childSessionKey', '')
@@ -612,10 +607,10 @@ def resolve_agent_session_jsonl(
612
607
  index_path = sessions_path / "sessions.json"
613
608
  index_map: Dict[str, Dict[str, Any]] = {}
614
609
  if index_path.exists():
615
- try:
616
- with open(index_path, 'r', encoding='utf-8') as f:
617
- index_map = normalize_sessions_index(json.load(f))
618
- except (json.JSONDecodeError, IOError):
610
+ raw = _load_sessions_index_file(index_path)
611
+ if raw:
612
+ index_map = normalize_sessions_index(raw)
613
+ else:
619
614
  index_map = {}
620
615
 
621
616
  prefix = f"agent:{state_id}:"
@@ -895,17 +890,13 @@ def _extract_subagent_steps_from_main_lines(
895
890
  for line in lines:
896
891
  if '"type":"message"' not in line and '"type": "message"' not in line:
897
892
  continue
898
- try:
899
- data = json.loads(line.strip())
900
- except json.JSONDecodeError:
901
- continue
902
- if data.get('type') != 'message':
893
+ envelope, msg = parse_session_jsonl_line(line)
894
+ if envelope is None or envelope.get('type') != 'message' or msg is None:
903
895
  continue
904
- msg = data.get('message', {})
905
896
  role = msg.get('role')
906
897
  if not role:
907
898
  continue
908
- timestamp = _parse_timestamp(msg.get('timestamp') or data.get('timestamp', 0))
899
+ timestamp = _parse_timestamp(msg.get('timestamp') or envelope.get('timestamp', 0))
909
900
  duration = 0
910
901
  if last_timestamp and timestamp:
911
902
  duration = timestamp - last_timestamp
@@ -1112,21 +1103,19 @@ def _parse_session_lines(
1112
1103
  sender_id = requester_info.get('senderId') if requester_info else None
1113
1104
  sender_name = requester_info.get('senderName') if requester_info else None
1114
1105
  for line in lines:
1115
- try:
1116
- data = json.loads(line.strip())
1117
- except json.JSONDecodeError:
1106
+ envelope, msg = parse_session_jsonl_line(line)
1107
+ if envelope is None:
1118
1108
  continue
1119
- msg_type = data.get('type')
1109
+ msg_type = envelope.get('type')
1120
1110
  if msg_type == 'session':
1121
- started_at = _parse_timestamp(data.get('timestamp', 0))
1111
+ started_at = _parse_timestamp(envelope.get('timestamp', 0))
1122
1112
  continue
1123
- if msg_type != 'message':
1113
+ if msg_type != 'message' or msg is None:
1124
1114
  continue
1125
- msg = data.get('message', {})
1126
1115
  role = msg.get('role')
1127
1116
  if not role:
1128
1117
  continue
1129
- timestamp = _parse_timestamp(msg.get('timestamp') or data.get('timestamp', 0))
1118
+ timestamp = _parse_timestamp(msg.get('timestamp') or envelope.get('timestamp', 0))
1130
1119
  duration = 0
1131
1120
  if last_timestamp and timestamp:
1132
1121
  duration = timestamp - last_timestamp
@@ -1295,13 +1284,10 @@ def _line_index_of_first_user_message(path: Path) -> Optional[int]:
1295
1284
  for i, line in enumerate(f):
1296
1285
  if '"role"' not in line or 'user' not in line:
1297
1286
  continue
1298
- try:
1299
- d = json.loads(line.strip())
1300
- except json.JSONDecodeError:
1301
- continue
1302
- if d.get('type') != 'message':
1287
+ env, msg = parse_session_jsonl_line(line)
1288
+ if env is None or env.get('type') != 'message' or msg is None:
1303
1289
  continue
1304
- if (d.get('message') or {}).get('role') == 'user':
1290
+ if msg.get('role') == 'user':
1305
1291
  return i
1306
1292
  except (OSError, IOError):
1307
1293
  pass
package/dashboard/main.py CHANGED
@@ -13,14 +13,35 @@ import asyncio
13
13
  async def lifespan(app: FastAPI):
14
14
  """应用生命周期:启动时启动文件监听,关闭时停止"""
15
15
  loop = asyncio.get_running_loop()
16
+ probe_stop = None
16
17
  try:
17
18
  from watchers.file_watcher import start_file_watcher
19
+ from core.config_fortify import get_fortify_config
20
+
18
21
  start_file_watcher(loop)
22
+ cfg = get_fortify_config()
23
+ if cfg.cache_preload:
24
+ try:
25
+ from status.status_calculator import get_agents_with_status
26
+
27
+ get_agents_with_status()
28
+ except Exception as e:
29
+ from core.error_handler import record_error
30
+
31
+ record_error("unknown", str(e), "main:cache_preload", exc=e)
32
+ from status.cache_fp_probe import start_cache_fp_probe_background
33
+
34
+ probe_stop = start_cache_fp_probe_background()
19
35
  except Exception as e:
20
- print(f"[Main] 文件监听启动失败: {e}")
36
+ from core.error_handler import record_error
37
+
38
+ record_error("unknown", str(e), "main:file_watcher_start", exc=e)
21
39
  yield
22
40
  try:
41
+ if probe_stop is not None:
42
+ probe_stop.set()
23
43
  from watchers.file_watcher import stop_file_watcher
44
+
24
45
  stop_file_watcher()
25
46
  except Exception:
26
47
  pass
@@ -47,11 +68,12 @@ app.add_middleware(
47
68
  import sys
48
69
  sys.path.append(str(Path(__file__).parent))
49
70
 
50
- from api import agents, subagents, websocket, performance, collaboration, agents_config, errors, timeline, chains, agent_config_api, error_analysis, debug_paths, version
71
+ from api import agents, subagents, websocket, performance, collaboration, agents_config, errors, timeline, chains, agent_config_api, error_analysis, debug_paths, version, fortify_routes
51
72
 
52
73
  # 注册 API 路由
53
74
  app.include_router(agents.router, prefix="/api", tags=["agents"])
54
75
  app.include_router(errors.router, prefix="/api", tags=["errors"])
76
+ app.include_router(fortify_routes.router, prefix="/api", tags=["fortify"])
55
77
  app.include_router(agents_config.router, prefix="/api", tags=["agents-config"])
56
78
  app.include_router(subagents.router, prefix="/api", tags=["subagents"])
57
79
  app.include_router(websocket.router, tags=["websocket"])
@@ -7,7 +7,7 @@ from typing import Dict, Any, List
7
7
 
8
8
 
9
9
  from data.config_reader import get_openclaw_root, normalize_openclaw_agent_id
10
- from data.session_reader import normalize_sessions_index
10
+ from data.session_reader import normalize_sessions_index, _load_sessions_index_file
11
11
 
12
12
 
13
13
  def get_agent_mechanisms(agent_id: str) -> Dict[str, Any]:
@@ -30,9 +30,8 @@ def get_agent_mechanisms(agent_id: str) -> Dict[str, Any]:
30
30
  return result
31
31
 
32
32
  try:
33
- with open(sessions_index, 'r', encoding='utf-8') as f:
34
- data = json.load(f)
35
- if not isinstance(data, dict):
33
+ data = _load_sessions_index_file(sessions_index)
34
+ if not data:
36
35
  return result
37
36
 
38
37
  # 取最新 session 的机制信息(兼容 sessions.json 顶层或 entries 嵌套)
@@ -129,6 +128,6 @@ def get_agent_mechanisms(agent_id: str) -> Dict[str, Any]:
129
128
 
130
129
  def get_all_agents_mechanisms() -> List[Dict[str, Any]]:
131
130
  """获取所有 Agent 的机制使用情况"""
132
- from .config_reader import get_agents_list
131
+ from data.config_reader import get_agents_list
133
132
  agents = get_agents_list()
134
133
  return [get_agent_mechanisms(a.get('id', '')) for a in agents if a.get('id')]
@@ -14,14 +14,14 @@ router = APIRouter()
14
14
  @router.get("/mechanisms")
15
15
  async def get_mechanisms():
16
16
  """获取所有 Agent 的机制使用情况"""
17
- from data.mechanism_reader import get_all_agents_mechanisms
17
+ from mechanism_reader import get_all_agents_mechanisms
18
18
  return get_all_agents_mechanisms()
19
19
 
20
20
 
21
21
  @router.get("/mechanisms/{agent_id}")
22
22
  async def get_agent_mechanisms(agent_id: str):
23
23
  """获取单个 Agent 的机制使用情况"""
24
- from data.mechanism_reader import get_agent_mechanisms
24
+ from mechanism_reader import get_agent_mechanisms
25
25
  from data.config_reader import get_agent_config
26
26
 
27
27
  if not get_agent_config(agent_id):
@@ -0,0 +1,3 @@
1
+ [pytest]
2
+ markers =
3
+ benchmark: 可选性能烟测(对负载敏感,默认与主套件一起跑)
@@ -4,3 +4,8 @@ pydantic==2.11.7
4
4
  python-multipart==0.0.20
5
5
  watchdog>=3.0.0
6
6
  tzdata
7
+ jsonschema>=4.0.0
8
+ psutil>=5.9.0
9
+ pytest>=7.0.0
10
+ pytest-asyncio>=0.23.0
11
+ httpx>=0.27.0
@@ -0,0 +1,40 @@
1
+ """可选后台线程:周期性对 StatusCache 做 mtime 双验证剔除(RISK-004)。"""
2
+ from __future__ import annotations
3
+
4
+ import logging
5
+ import threading
6
+ from typing import Optional
7
+
8
+ _LOG = logging.getLogger("openclaw.fortify.cache_probe")
9
+
10
+
11
+ def start_cache_fp_probe_background() -> Optional[threading.Event]:
12
+ """
13
+ 若 OPENCLAW_CACHE_FP_PROBE_INTERVAL > 0,启动守护线程并返回用于停止的 Event;
14
+ 否则返回 None。
15
+ """
16
+ from core.config_fortify import get_fortify_config
17
+
18
+ interval = get_fortify_config().cache_fp_probe_interval_sec
19
+ if interval <= 0:
20
+ return None
21
+
22
+ stop = threading.Event()
23
+
24
+ def loop() -> None:
25
+ from status.status_cache import get_cache
26
+
27
+ while not stop.is_set():
28
+ if stop.wait(timeout=interval):
29
+ break
30
+ try:
31
+ n = get_cache().invalidate_stale_fp_entries()
32
+ if n:
33
+ _LOG.info("cache_fp_probe removed %s stale cache entries", n)
34
+ except Exception as e:
35
+ from core.error_handler import record_error
36
+
37
+ record_error("unknown", str(e), "cache_fp_probe", exc=e)
38
+
39
+ threading.Thread(target=loop, daemon=True, name="openclaw_cache_fp_probe").start()
40
+ return stop