@senpi/runtime 3.0.102 → 3.0.103

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.
@@ -47,6 +47,16 @@ write to that stream — and the next call opens a fresh one. It is logged as
47
47
  `senpi_mcp_error` with `error_type: TimeoutError`, `timeout_seconds` and
48
48
  `elapsed_ms` so a stall is distinguishable from a severed stream in ClickStack.
49
49
 
50
+ AMENDMENT, third part (dev 2026-09-07): the SDK's transport is an anyio task
51
+ group, so a failure raised through it arrives as an `ExceptionGroup` around the
52
+ real cause. The client classifies and logs the LEAF, never the group: a wrapped
53
+ closed stream still discards the session (the group's own type names no
54
+ transport class, so without unwrapping the dead session would have been kept —
55
+ the original wedge in a new coat), `error_type` is the leaf's class (never
56
+ `ExceptionGroup`), and a sole-leaf group is re-raised to `scan()` as that leaf.
57
+ Several leaves stay a group, logged as the first leaf plus `leaf_count`, and
58
+ rebuild when ANY leaf is a transport failure.
59
+
50
60
  Response normalization MATCHES producer.mjs `createSenpiToolClient`:
51
61
  1. isError truthy -> RAISE with the error text (never a silent None).
52
62
  2. structuredContent present -> return it.
@@ -458,8 +468,54 @@ _TRANSPORT_ERROR_NAMES: frozenset[str] = frozenset(
458
468
  )
459
469
 
460
470
 
471
+ # The SDK's `streamablehttp_client` runs an anyio task group, so a failure
472
+ # raised through it (a closed stream, an httpx error on the POST) surfaces as an
473
+ # `ExceptionGroup` wrapping the real cause — observed on dev 2026-09-07 as
474
+ # `error_type: ExceptionGroup, error: "unhandled errors in a TaskGroup (1
475
+ # sub-exception)"` against a 503. Classification and the fleet fingerprint must
476
+ # look at the LEAVES: the group's own MRO names no transport class, so a wrapped
477
+ # `ClosedResourceError` on a live session would otherwise keep the dead session
478
+ # (the pre-rebuild wedge). Matched by name so the `exceptiongroup` backport on
479
+ # Python < 3.11 unwraps like the builtin.
480
+ _EXCEPTION_GROUP_NAME = "BaseExceptionGroup"
481
+
482
+
483
+ def _is_exception_group(exc: BaseException) -> bool:
484
+ return any(klass.__name__ == _EXCEPTION_GROUP_NAME for klass in type(exc).__mro__)
485
+
486
+
487
+ def _leaf_exceptions(exc: BaseException) -> list[BaseException]:
488
+ """Flatten an exception-group tree to its leaves; a plain exception is its own leaf."""
489
+ if not _is_exception_group(exc):
490
+ return [exc]
491
+ leaves: list[BaseException] = []
492
+ for sub in exc.exceptions: # type: ignore[attr-defined]
493
+ leaves.extend(_leaf_exceptions(sub))
494
+ return leaves or [exc]
495
+
496
+
497
+ def _unwrap_sole_leaf(exc: BaseException) -> BaseException:
498
+ """The one real cause behind a group, else `exc` itself.
499
+
500
+ A group with exactly one leaf is the anyio wrapping and nothing more, so
501
+ the caller gets the leaf. Several leaves are genuinely several failures
502
+ and stay a group — the log line then names the first leaf and carries the
503
+ count.
504
+ """
505
+ leaves = _leaf_exceptions(exc)
506
+ return leaves[0] if len(leaves) == 1 else exc
507
+
508
+
461
509
  def _is_transport_error(exc: BaseException) -> bool:
462
- """True when `exc` means the MCP session's transport is unusable."""
510
+ """True when `exc` means the MCP session's transport is unusable.
511
+
512
+ Looks through exception groups: a group is a transport failure when ANY of
513
+ its leaves is.
514
+ """
515
+ return any(_leaf_is_transport_error(leaf) for leaf in _leaf_exceptions(exc))
516
+
517
+
518
+ def _leaf_is_transport_error(exc: BaseException) -> bool:
463
519
  if isinstance(exc, (OSError, EOFError)):
464
520
  return True
465
521
  return any(klass.__name__ in _TRANSPORT_ERROR_NAMES for klass in type(exc).__mro__)
@@ -623,22 +679,33 @@ class SenpiMcpClient:
623
679
  # unwinds the tick untouched. This is the ONE exit for every failed
624
680
  # call, so the tally sees each failure exactly once.
625
681
  rebuilt = used is not None and _is_transport_error(exc) and self._discard(used)
626
- timed_out = isinstance(exc, SenpiMcpTimeoutError)
682
+ # Classify and log the LEAF, never the anyio ExceptionGroup around
683
+ # it (see _leaf_exceptions): the fingerprint has to tell a 503 from
684
+ # a closed stream. One leaf is the failure; several are logged as
685
+ # the first leaf plus `leaf_count`.
686
+ leaves = _leaf_exceptions(exc)
687
+ primary = leaves[0]
688
+ timed_out = isinstance(primary, SenpiMcpTimeoutError)
627
689
  # The fleet fingerprint for the bound is the builtin category, not
628
690
  # the scaffold subclass: ClickStack queries `TimeoutError`. The tally
629
691
  # counts under the SAME name, so the dominant type a blind tick
630
692
  # reports to /errors matches the senpi_mcp_error lines beside it.
631
- error_type = "TimeoutError" if timed_out else type(exc).__name__
693
+ error_type = "TimeoutError" if timed_out else type(primary).__name__
632
694
  self.tick_errors.record(error_type)
633
695
  log_event(
634
696
  "senpi_mcp_error",
635
697
  tool=name,
636
698
  error_type=error_type,
637
- error=str(exc)[:500],
699
+ error=str(primary)[:500],
638
700
  elapsed_ms=round((time.monotonic() - started) * 1000, 1),
701
+ **({"leaf_count": len(leaves)} if len(leaves) > 1 else {}),
639
702
  **({"timeout_seconds": self._call_timeout} if timed_out else {}),
640
703
  **({"session_rebuilt": True} if rebuilt else {}),
641
704
  )
705
+ if len(leaves) == 1 and primary is not exc:
706
+ # The author's scan() sees the real cause (so `except OSError`
707
+ # or `except TimeoutError` keeps working), not the wrapper.
708
+ raise primary
642
709
  raise
643
710
 
644
711
  # -- persistent session lifecycle -------------------------------------
@@ -742,13 +809,17 @@ class SenpiMcpClient:
742
809
  await close_requested.wait()
743
810
  except BaseException as exc:
744
811
  if not ready.done():
745
- # Opening failed: surface it to the waiting call_tool. A
746
- # non-Exception (task cancellation) is wrapped so the waiter
747
- # gets a plain error instead of blocking forever.
812
+ # Opening failed: surface it to the waiting call_tool. The
813
+ # transport's anyio task group wraps the cause in an
814
+ # ExceptionGroup; the waiter gets the sole leaf (an httpx 503,
815
+ # a refused connection) so it can classify and re-raise the
816
+ # real error. A non-Exception (task cancellation) is wrapped
817
+ # so the waiter gets a plain error instead of blocking forever.
818
+ cause = _unwrap_sole_leaf(exc)
748
819
  ready.set_exception(
749
- exc
750
- if isinstance(exc, Exception)
751
- else RuntimeError(f"senpi_mcp: session open aborted by {type(exc).__name__}")
820
+ cause
821
+ if isinstance(cause, Exception)
822
+ else RuntimeError(f"senpi_mcp: session open aborted by {type(cause).__name__}")
752
823
  )
753
824
  # Otherwise the transport died under a published session (the
754
825
  # next call_tool sees `alive` False and rebuilds) or its teardown
@@ -2,7 +2,7 @@
2
2
  "id": "runtime",
3
3
  "name": "Senpi Runtime",
4
4
  "description": "Senpi runtime plugin for OpenClaw. Orchestrates scanners, LLM-based trade decisions, and a DSL trailing stop-loss exit engine.",
5
- "version": "3.0.102",
5
+ "version": "3.0.103",
6
6
  "commandAliases": [
7
7
  {
8
8
  "name": "senpi"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@senpi/runtime",
3
- "version": "3.0.102",
3
+ "version": "3.0.103",
4
4
  "description": "Senpi runtime plugin for OpenClaw",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",