openclaw-agent-dashboard 1.0.38 → 1.0.39

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.
@@ -151,6 +151,7 @@ _AGENT_ID_TO_LABEL = {
151
151
  "analyst-agent": "分析师",
152
152
  "architect-agent": "架构师",
153
153
  "devops-agent": "运维",
154
+ "coder-agent": "开发",
154
155
  "project-manager": "项目经理",
155
156
  "test-agent": "测试",
156
157
  "frontend-agent": "前端",
@@ -423,10 +424,15 @@ def _rebuild_subagent_timeline_payload(
423
424
  result: Dict[str, Any],
424
425
  limit: int,
425
426
  round_mode: bool,
427
+ prefer_start: bool = False,
426
428
  ) -> None:
427
429
  """就地更新 result 的 steps / stats / rounds(步骤已为 dict 列表)。"""
428
430
  if len(steps) > limit:
429
- steps = steps[-limit:]
431
+ # 子任务需从「本次下发」起展示:保留 [:limit];主会话等仍用尾部 100
432
+ if prefer_start:
433
+ steps = steps[:limit]
434
+ else:
435
+ steps = steps[-limit:]
430
436
  result['steps'] = _pair_tool_calls_and_results(steps)
431
437
  total_duration = 0
432
438
  total_input = 0
@@ -452,24 +458,60 @@ def _rebuild_subagent_timeline_payload(
452
458
  result['roundMode'] = True
453
459
 
454
460
 
461
+ def _include_subagent_steps_before_run_anchor(
462
+ steps_in: List[Dict[str, Any]],
463
+ first_kept_index: int,
464
+ _anchor_ms: int,
465
+ cutoff: int,
466
+ ) -> int:
467
+ """
468
+ 从 runs 取的 startedAt 可能略晚于「PM/主控下发 user」的落盘时间,且其之间常有 thinking/工具 步;
469
+ 在第一个 ts>=cutoff 的步骤之前,把同一工作段内、早于 cutoff 的连续步骤并回(时间窗 20 分钟内)。
470
+ """
471
+ if first_kept_index <= 0:
472
+ return 0
473
+ t_first = (steps_in[first_kept_index].get('timestamp') or 0) if first_kept_index < len(steps_in) else 0
474
+ if not t_first:
475
+ return first_kept_index
476
+ i0 = first_kept_index
477
+ # 在「首条已跨 cutoff」的步之前,并回同一 burst 中所有更早的步骤(含 PM 的 user 与紧挨的工具链)
478
+ while i0 > 0:
479
+ tlo = (steps_in[i0 - 1].get('timestamp') or 0)
480
+ if tlo >= cutoff:
481
+ i0 -= 1
482
+ continue
483
+ if (t_first - tlo) <= 20 * 60 * 1000:
484
+ i0 -= 1
485
+ else:
486
+ break
487
+ return i0
488
+
489
+
455
490
  def _apply_subagent_run_anchor_to_result(
456
491
  result: Dict[str, Any],
457
492
  anchor_ms: int,
458
493
  limit: int,
459
494
  round_mode: bool,
460
495
  ) -> Dict[str, Any]:
461
- """去掉 anchor 之前的步骤;若过滤后为空则保留原步骤。"""
496
+ """去掉 anchor 之前无关步骤,并补回被锚点切掉的 PM/下发 user;若过滤后为空则保留原步骤。"""
462
497
  steps_in = result.get('steps') or []
463
498
  if not steps_in:
464
499
  return result
465
500
  cutoff = anchor_ms - _RUN_ANCHOR_SLACK_MS
466
- filtered = [s for s in steps_in if (s.get('timestamp') or 0) >= cutoff]
501
+ first_i = next(
502
+ (i for i, s in enumerate(steps_in) if (s.get('timestamp') or 0) >= cutoff),
503
+ len(steps_in),
504
+ )
505
+ if first_i >= len(steps_in):
506
+ return result
507
+ i0 = _include_subagent_steps_before_run_anchor(steps_in, first_i, anchor_ms, cutoff)
508
+ filtered = steps_in[i0:]
467
509
  if not filtered:
468
510
  return result
469
511
  result['runStartedAt'] = anchor_ms
470
512
  if result.get('steps') and filtered[0].get('timestamp') is not None:
471
513
  result['startedAt'] = filtered[0]['timestamp']
472
- _rebuild_subagent_timeline_payload(filtered, result, limit, round_mode)
514
+ _rebuild_subagent_timeline_payload(filtered, result, limit, round_mode, prefer_start=True)
473
515
  return result
474
516
 
475
517
 
@@ -605,7 +647,22 @@ def resolve_agent_session_jsonl(
605
647
  sid = ent.get('sessionId') or preferred_key
606
648
  return p, sid, preferred_key
607
649
 
608
- # 2) 目录下最新的 *.jsonl(mtime
650
+ # 2) sessions.json 的 updatedAt/lastMessageAt 选最近会话(在 glob mtime 之前)
651
+ # OpenClaw 在任务结束后可能从 runs.json 移除 run,此处仍可定位「最近活跃」子会话 jsonl。
652
+ # 多文件时比仅凭 *.jsonl 的 mtime 更稳,且与 4/24 当晚最晚更新 session 一致。
653
+ if agent_keys:
654
+ agent_keys.sort(
655
+ key=lambda k: (index_map[k].get('updatedAt') or index_map[k].get('lastMessageAt') or 0),
656
+ reverse=True,
657
+ )
658
+ for k in agent_keys:
659
+ ent = index_map[k]
660
+ p = resolve_session_jsonl_path(sessions_path, ent)
661
+ if p and p.is_file():
662
+ sid = ent.get('sessionId') or k
663
+ return p, sid, k
664
+
665
+ # 3) 无索引条目时:目录下最新的 *.jsonl(mtime)
609
666
  jsonl_files = list(sessions_path.glob("*.jsonl"))
610
667
  if jsonl_files:
611
668
  jsonl_files.sort(key=lambda x: x.stat().st_mtime, reverse=True)
@@ -631,19 +688,6 @@ def resolve_agent_session_jsonl(
631
688
  continue
632
689
  return f, sid, resolved_key
633
690
 
634
- # 3) 仅有 sessions.json 索引、尚无 glob 到的文件时:按 updatedAt 试解析路径
635
- if agent_keys:
636
- agent_keys.sort(
637
- key=lambda k: (index_map[k].get('updatedAt') or index_map[k].get('lastMessageAt') or 0),
638
- reverse=True,
639
- )
640
- for k in agent_keys:
641
- ent = index_map[k]
642
- p = resolve_session_jsonl_path(sessions_path, ent)
643
- if p and p.is_file():
644
- sid = ent.get('sessionId') or k
645
- return p, sid, k
646
-
647
691
  return None, None, None
648
692
 
649
693
 
@@ -686,12 +730,23 @@ def get_timeline_steps(
686
730
  if session_file and session_file.exists():
687
731
  req_key = session_key if session_key else resolved_key
688
732
  requester_info = _get_requester_info_for_session(agent_id, req_key)
689
- result = _parse_session_file(session_file, agent_id, session_id, limit, requester_info, round_mode)
690
- # Agent:从 runs.json 本次 run 的 startedAt 起展示(对应派发进子会话的时刻,含 PM 经链路下发)
691
- if not agent_ids_equal(agent_id, _get_main_agent_id()):
692
- anchor = _subagent_run_anchor_ms(agent_id, resolved_key)
693
- if anchor is not None:
694
- result = _apply_subagent_run_anchor_to_result(result, anchor, limit, round_mode)
733
+ is_sub = not agent_ids_equal(agent_id, _get_main_agent_id())
734
+ subagent_anchor: Optional[int] = None
735
+ if is_sub:
736
+ subagent_anchor = _subagent_run_anchor_ms(agent_id, resolved_key)
737
+ result = _parse_session_file(
738
+ session_file,
739
+ agent_id,
740
+ session_id,
741
+ limit,
742
+ requester_info,
743
+ round_mode,
744
+ is_subagent=is_sub,
745
+ subagent_anchor_ms=subagent_anchor,
746
+ )
747
+ # 子 Agent:从 runs.json 本次 run 的 startedAt 起展示(含 PM/主控经链路下发到 coder 等子 agent)
748
+ if is_sub and subagent_anchor is not None:
749
+ result = _apply_subagent_run_anchor_to_result(result, subagent_anchor, limit, round_mode)
695
750
  return result
696
751
  if agent_ids_equal(agent_id, _get_main_agent_id()):
697
752
  return _empty_main_agent_timeline(agent_id)
@@ -1224,33 +1279,147 @@ def _parse_session_lines(
1224
1279
  return steps, started_at, session_status
1225
1280
 
1226
1281
 
1282
+ # 子 agent 会话 jsonl 全量读的安全上限(仅防极端大文件 OOM,主逻辑不据此判断「从哪开始」)
1283
+ _SUBAGENT_READ_SAFETY_BYTES = 32 * 1024 * 1024
1284
+ # 自「首条 user」起再读多少行入内存(有 runs 时仍从文件头解析以便锚点;无 runs 时从首条 user 行起)
1285
+ _MAX_LINES_AFTER_TASK_START = 20000
1286
+
1287
+
1288
+ def _line_index_of_first_user_message(path: Path) -> Optional[int]:
1289
+ """
1290
+ 扫描 jsonl,返回第一条 type=message 且 role=user 的所在行号(0-based)。
1291
+ 用于「无 runs 锚点」时从 PM/主控下发(或 Subagent 首条 user)起构时序,而非按文件体积选 tail/head。
1292
+ """
1293
+ try:
1294
+ with open(path, 'r', encoding='utf-8') as f:
1295
+ for i, line in enumerate(f):
1296
+ if '"role"' not in line or 'user' not in line:
1297
+ continue
1298
+ try:
1299
+ d = json.loads(line.strip())
1300
+ except json.JSONDecodeError:
1301
+ continue
1302
+ if d.get('type') != 'message':
1303
+ continue
1304
+ if (d.get('message') or {}).get('role') == 'user':
1305
+ return i
1306
+ except (OSError, IOError):
1307
+ pass
1308
+ return None
1309
+
1310
+
1311
+ def _read_text_line_window(path: Path, start_line: int, max_lines: int) -> List[str]:
1312
+ """从第 start_line 行起,最多读 max_lines 行(供超大文件、无锚点时从首条 user 后解析)。"""
1313
+ out: List[str] = []
1314
+ try:
1315
+ with open(path, 'r', encoding='utf-8') as f:
1316
+ for i, line in enumerate(f):
1317
+ if i < start_line:
1318
+ continue
1319
+ out.append(line)
1320
+ if len(out) >= max_lines:
1321
+ break
1322
+ except (OSError, IOError):
1323
+ pass
1324
+ return out
1325
+
1326
+
1327
+ def _slice_subagent_steps_from_first_user(
1328
+ steps: List[TimelineStep],
1329
+ ) -> List[TimelineStep]:
1330
+ """从本会话中第一条 user(PM/主控下发到子 agent)起展示。"""
1331
+ for i, s in enumerate(steps):
1332
+ if s.type == StepType.USER.value:
1333
+ return steps[i:]
1334
+ return steps
1335
+
1336
+
1227
1337
  def _parse_session_file(
1228
1338
  session_file: Path,
1229
1339
  agent_id: str,
1230
1340
  session_id: Optional[str],
1231
1341
  limit: int,
1232
1342
  requester_info: Optional[Dict[str, str]] = None,
1233
- round_mode: bool = True
1343
+ round_mode: bool = True,
1344
+ is_subagent: bool = False,
1345
+ subagent_anchor_ms: Optional[int] = None,
1234
1346
  ) -> Dict[str, Any]:
1235
- """解析 session jsonl;大文件优先解析尾部窗口,步骤不足时再整文件解析。"""
1347
+ """
1348
+ 解析 session jsonl;大文件对主 agent 仍可用尾部窗口。
1349
+
1350
+ 子 agent:会话范围已由 resolve 指向「最近」jsonl。展示起点由语义决定——
1351
+ 有 runs 锚点用 startedAt 对齐;无锚点时从首条 user(PM/主控下发)起。仅对超大文件用 _SUBAGENT_READ_SAFETY_BYTES
1352
+ 与行数窗做**内存**保护,不作为「是否从 PM 起」的主判据。
1353
+ """
1236
1354
  path = session_file
1237
1355
  header_ts = _read_session_header_timestamp(path)
1238
- tail_lines = _read_jsonl_tail_line_slice(path)
1239
- if tail_lines is not None:
1240
- steps, started_at, session_status = _parse_session_lines(
1241
- tail_lines, requester_info, started_at_hint=header_ts
1242
- )
1243
- if len(steps) < limit:
1356
+ step_budget = limit
1357
+ if is_subagent:
1358
+ step_budget = max(5000, min(limit * 50, 20000))
1359
+ try:
1360
+ file_size = path.stat().st_size
1361
+ except OSError:
1362
+ file_size = 0
1363
+
1364
+ steps: List[TimelineStep] = []
1365
+ started_at: Optional[int] = header_ts
1366
+ session_status = "completed"
1367
+
1368
+ if is_subagent:
1369
+ if file_size == 0:
1370
+ steps, started_at, session_status = _parse_session_lines(
1371
+ [], requester_info, started_at_hint=header_ts
1372
+ )
1373
+ elif file_size <= _SUBAGENT_READ_SAFETY_BYTES:
1244
1374
  steps, started_at, session_status = _parse_session_lines(
1245
1375
  _read_text_lines(path), requester_info, started_at_hint=header_ts
1246
1376
  )
1377
+ if subagent_anchor_ms is None:
1378
+ steps = _slice_subagent_steps_from_first_user(steps)
1379
+ elif subagent_anchor_ms is not None:
1380
+ # 超大 + 有 run:以尾部为窗口(近期)再交给 get_timeline 的 _apply 锚定
1381
+ tail_lines = _read_jsonl_tail_line_slice(path)
1382
+ if tail_lines is not None:
1383
+ steps, started_at, session_status = _parse_session_lines(
1384
+ tail_lines, requester_info, started_at_hint=header_ts
1385
+ )
1386
+ else:
1387
+ steps, started_at, session_status = _parse_session_lines(
1388
+ _read_text_line_window(path, 0, _MAX_LINES_AFTER_TASK_START),
1389
+ requester_info, started_at_hint=header_ts
1390
+ )
1391
+ else:
1392
+ # 超大 + 无 run:先定位首条 user 行,自 PM/主控下发起读有限行
1393
+ uidx = _line_index_of_first_user_message(path)
1394
+ start = uidx if uidx is not None else 0
1395
+ part = _read_text_line_window(path, start, _MAX_LINES_AFTER_TASK_START)
1396
+ steps, started_at, session_status = _parse_session_lines(
1397
+ part, requester_info, started_at_hint=header_ts
1398
+ )
1399
+ steps = _slice_subagent_steps_from_first_user(steps)
1247
1400
  else:
1248
- steps, started_at, session_status = _parse_session_lines(
1249
- _read_text_lines(path), requester_info, started_at_hint=header_ts
1250
- )
1401
+ tail_lines = _read_jsonl_tail_line_slice(path)
1402
+ if tail_lines is not None:
1403
+ steps, started_at, session_status = _parse_session_lines(
1404
+ tail_lines, requester_info, started_at_hint=header_ts
1405
+ )
1406
+ if len(steps) < limit:
1407
+ steps, started_at, session_status = _parse_session_lines(
1408
+ _read_text_lines(path), requester_info, started_at_hint=header_ts
1409
+ )
1410
+ else:
1411
+ steps, started_at, session_status = _parse_session_lines(
1412
+ _read_text_lines(path), requester_info, started_at_hint=header_ts
1413
+ )
1251
1414
 
1252
- if len(steps) > limit:
1253
- steps = steps[-limit:]
1415
+ if len(steps) > step_budget:
1416
+ if is_subagent:
1417
+ steps = steps[:step_budget]
1418
+ else:
1419
+ steps = steps[-limit:]
1420
+
1421
+ if is_subagent and subagent_anchor_ms is None and len(steps) > limit:
1422
+ steps = steps[:limit]
1254
1423
 
1255
1424
  total_duration = 0
1256
1425
  total_input = 0