@senpi/runtime 3.0.99 → 3.0.100

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.
@@ -12,13 +12,27 @@ the OFFICIAL MCP Python SDK (`mcp`), which is async-only:
12
12
  await session.initialize()
13
13
  result = await session.call_tool(name, arguments=args)
14
14
 
15
- DECISION (locked, execution-plan B5): use the SDK the STANDARD way — a SINGLE
16
- PERSISTENT `ClientSession` created ONCE (and `initialize()`d once), reused across
17
- every call. NO extra guards: no custom timeout, no reconnect/retry wrapper, no
18
- pooling toggle, no fresh-connection-per-call. The async context managers must
19
- STAY OPEN for the client's lifetime, so we drive them on a dedicated event loop
20
- running in a background thread; `call_tool` submits work onto that loop and blocks
21
- for the result (the sync↔async bridge).
15
+ DECISION (execution-plan B5, AMENDED 2026-09-04): use the SDK the STANDARD way —
16
+ ONE persistent `ClientSession` created once (and `initialize()`d once), reused
17
+ across every call, with no pooling toggle and no fresh-connection-per-call. The
18
+ async context managers must STAY OPEN across calls, so we drive them on a
19
+ dedicated event loop running in a background thread; `call_tool` submits work
20
+ onto that loop and blocks for the result (the sync↔async bridge).
21
+
22
+ AMENDMENT (RCA 2026-09-04, "External scanners go permanently blind after one
23
+ MCP transport failure"): B5 originally also locked "no reconnect". In
24
+ production the MCP server's `POST /mcp` stream is severed at the 60 s Kong
25
+ upstream read timeout; the SDK then closes its streams but the client kept the
26
+ dead session for the life of the process, so every later `call_tool` raised
27
+ `ClosedResourceError` in ~1 ms until a runtime restart — 16–25 scanner MIDs
28
+ blind per day, and authored `scan()` code swallowing the error made every tick
29
+ report `ok` with zero candidates. The session is therefore now REBUILT: a
30
+ transport-level failure (closed/broken stream, connection reset, broken pipe,
31
+ httpx transport error) discards the dead session and the NEXT call opens and
32
+ initialises a fresh one. The failing call still fails — there is no retry
33
+ inside a call (bounding a call is a separate ticket) — the point is that the
34
+ failure is no longer permanent. The failure is logged once as
35
+ `senpi_mcp_error` with `session_rebuilt: true`.
22
36
 
23
37
  Response normalization MATCHES producer.mjs `createSenpiToolClient`:
24
38
  1. isError truthy -> RAISE with the error text (never a silent None).
@@ -42,18 +56,27 @@ The `mcp` package is LAZY-IMPORTED on first connect (NOT at module top), so
42
56
  `import scaffold.senpi_mcp` works even where `mcp` is not installed. Missing creds
43
57
  are LOUD AT FIRST USE: `call_tool` raises, construction never does.
44
58
 
59
+ SESSION LIFETIME: each session lives inside ONE long-lived task on the loop
60
+ (`_session_lifetime`), which enters the transport + ClientSession context
61
+ managers, publishes the initialised session, then parks until a close is
62
+ requested and exits the same context managers from the same task. That
63
+ same-task shape is required: the SDK's transport is an anyio task group, and
64
+ anyio refuses to exit a cancel scope from a task other than the one that
65
+ entered it — so a dead session cannot be closed by calling `aclose()` from the
66
+ `call_tool` task. A rebuild only flips the session's close event; the old
67
+ lifetime task unwinds by itself, and `call_tool` never blocks on that teardown,
68
+ so a wedged transport cannot hang a tick.
69
+
45
70
  TEARDOWN (intentional, one-process-per-scanner model): there is NO explicit
46
- close()/shutdown for the persistent session. The background loop runs on a DAEMON
47
- thread, so it dies with the process. The scaffold runs exactly ONE client per
48
- process for that process's whole life — the supervisor restarts by spawning a
49
- FRESH child process (not by re-running run_scaffold in-place), so a single client
50
- is never replaced in-process and there is no loop/session leak across restart.
51
- On graceful stop (SIGTERM/SIGINT) run_scaffold exits its loop and the process
52
- returns; on a hard kill the OS reclaims the thread. We deliberately do NOT drive
53
- an AsyncExitStack.aclose() from a shutdown hook: it buys nothing over the dropped
54
- TCP connection (streamable-http is request/response, no protocol goodbye) and a
55
- cross-thread aclose() on a wedged transport could itself hang a path that today
56
- cannot hang.
71
+ close()/shutdown for the client. The background loop runs on a DAEMON thread,
72
+ so it dies with the process. The scaffold runs exactly ONE client per process
73
+ for that process's whole life — the supervisor restarts by spawning a FRESH
74
+ child process (not by re-running run_scaffold in-place), so a single client is
75
+ never replaced in-process and there is no loop leak across restart. On
76
+ graceful stop (SIGTERM/SIGINT) run_scaffold exits its loop and the process
77
+ returns; on a hard kill the OS reclaims the thread. We deliberately do NOT
78
+ drive a close from a shutdown hook: it buys nothing over the dropped TCP
79
+ connection (streamable-http is request/response, no protocol goodbye).
57
80
  """
58
81
 
59
82
  from __future__ import annotations
@@ -356,13 +379,69 @@ def _cast_market_numerics(name: str, payload: Any, args: Optional[dict] = None)
356
379
  return payload
357
380
 
358
381
 
382
+ # --------------------------------------------------------------------------
383
+ # Transport-failure classification — which errors mean "the session is dead".
384
+ # --------------------------------------------------------------------------
385
+ #
386
+ # Matched by class NAME along the MRO so the module keeps its lazy-import
387
+ # contract (no `anyio`/`httpx` import at module top). anyio raises the first
388
+ # three when the SDK's streams are closed or broken under a session; httpx's
389
+ # `TransportError` is the base of every connect/read/write/protocol/timeout
390
+ # failure on the POST itself. OSError covers ConnectionError and its subclasses
391
+ # (reset, refused, aborted), BrokenPipeError and — deliberately — TimeoutError:
392
+ # a call that timed out at the socket, or under a future per-call bound, has
393
+ # left the session in an unknown state and the next call must not inherit it.
394
+ # Our own pre-connect raises (PermissionError for a blocked mutation tool,
395
+ # RuntimeError for missing creds) happen before any session is held, so they
396
+ # never reach this classification.
397
+ _TRANSPORT_ERROR_NAMES: frozenset[str] = frozenset(
398
+ {
399
+ "ClosedResourceError",
400
+ "BrokenResourceError",
401
+ "EndOfStream",
402
+ "TransportError",
403
+ }
404
+ )
405
+
406
+
407
+ def _is_transport_error(exc: BaseException) -> bool:
408
+ """True when `exc` means the MCP session's transport is unusable."""
409
+ if isinstance(exc, (OSError, EOFError)):
410
+ return True
411
+ return any(klass.__name__ in _TRANSPORT_ERROR_NAMES for klass in type(exc).__mro__)
412
+
413
+
414
+ class _McpSession:
415
+ """One live MCP session: the SDK session plus the handles that end it.
416
+
417
+ `lifetime` is the loop task that entered the SDK's context managers and
418
+ will exit them once `close_requested` is set. `lifetime.done()` while the
419
+ session is still held means the transport died on its own.
420
+ """
421
+
422
+ __slots__ = ("session", "lifetime", "close_requested")
423
+
424
+ def __init__(
425
+ self, session: Any, lifetime: "asyncio.Task[None]", close_requested: asyncio.Event
426
+ ) -> None:
427
+ self.session = session
428
+ self.lifetime = lifetime
429
+ self.close_requested = close_requested
430
+
431
+ @property
432
+ def alive(self) -> bool:
433
+ return not self.lifetime.done()
434
+
435
+
359
436
  class SenpiMcpClient:
360
437
  """Sync MCP client over the official async SDK with one persistent session.
361
438
 
362
439
  Construction is cheap and never touches the network or imports `mcp`. The
363
440
  first `call_tool` lazily imports the SDK, spins up a private event loop on a
364
- background thread, opens the transport + ClientSession ONCE, and initializes
365
- it ONCE. Every subsequent call reuses that same session.
441
+ background thread (once, for the client's life), opens the transport +
442
+ ClientSession, and initializes it once. Every subsequent call reuses that
443
+ same session — until a transport failure, after which the dead session is
444
+ discarded and the next call opens a fresh one (B5 amendment, RCA 2026-09-04).
366
445
  """
367
446
 
368
447
  def __init__(
@@ -377,12 +456,12 @@ class SenpiMcpClient:
377
456
  # → every MUTATION_TOOLS entry is blocked (read-only producer boundary).
378
457
  self._write_allowlist: frozenset[str] = frozenset(write_allowlist)
379
458
 
380
- # Persistent bridge state — created lazily on first call_tool.
459
+ # Persistent bridge state. The loop + thread are created lazily on the
460
+ # first call_tool and live for the client's life; the session is
461
+ # (re)opened lazily whenever there is no live one.
381
462
  self._loop: Optional[asyncio.AbstractEventLoop] = None
382
463
  self._thread: Optional[threading.Thread] = None
383
- self._session: Any = None
384
- self._stack: Any = None
385
- self._connected = False
464
+ self._current: Optional[_McpSession] = None
386
465
  self._connect_lock = threading.Lock()
387
466
 
388
467
  # -- public sync surface ----------------------------------------------
@@ -396,7 +475,12 @@ class SenpiMcpClient:
396
475
  scaffold event) before it is re-raised, so even when the author's scan()
397
476
  swallows the exception in its own try/except, the failure still leaves a
398
477
  visible record at this boundary instead of vanishing into an empty result.
478
+
479
+ A transport-level failure (see `_is_transport_error`) additionally
480
+ discards the session it happened on, so the NEXT call opens a fresh one
481
+ instead of failing forever; its log event carries `session_rebuilt: true`.
399
482
  """
483
+ used: Optional[_McpSession] = None
400
484
  try:
401
485
  # Read-only producer boundary, enforced PRE-CONNECT (no transport
402
486
  # opened for a blocked write). A mutation tool passes only if its exact
@@ -411,30 +495,39 @@ class SenpiMcpClient:
411
495
  "not reachable by authored producer code)."
412
496
  )
413
497
 
414
- self._ensure_connected()
415
- result = self._run(self._session.call_tool(name, arguments=args))
498
+ used = self._ensure_connected()
499
+ result = self._run(used.session.call_tool(name, arguments=args))
416
500
  return _cast_market_numerics(name, self._unwrap(name, result), args)
417
501
  except Exception as exc:
418
502
  # Catch every failure — mutation block, missing creds, the lazy
419
503
  # `import mcp`, transport errors, tool `isError` — log it, then re-raise
420
504
  # to the author's scan(). Scoped to Exception so a tick timeout
421
505
  # (_TickTimeout, a BaseException) still unwinds the tick untouched.
506
+ rebuilt = used is not None and _is_transport_error(exc) and self._discard(used)
422
507
  log_event(
423
508
  "senpi_mcp_error",
424
509
  tool=name,
425
510
  error_type=type(exc).__name__,
426
511
  error=str(exc)[:500],
512
+ **({"session_rebuilt": True} if rebuilt else {}),
427
513
  )
428
514
  raise
429
515
 
430
516
  # -- persistent session lifecycle -------------------------------------
431
517
 
432
- def _ensure_connected(self) -> None:
433
- if self._connected:
434
- return
518
+ def _ensure_connected(self) -> _McpSession:
519
+ """Return the live session, opening one when there is none.
520
+
521
+ A session whose lifetime task has ended on its own (the transport died
522
+ between calls) counts as absent and is replaced here, before the call.
523
+ """
524
+ current = self._current
525
+ if current is not None and current.alive:
526
+ return current
435
527
  with self._connect_lock:
436
- if self._connected:
437
- return
528
+ current = self._current
529
+ if current is not None and current.alive:
530
+ return current
438
531
 
439
532
  # Loud at first use: creds are required to reach the MCP server.
440
533
  if not self._api_key:
@@ -448,43 +541,83 @@ class SenpiMcpClient:
448
541
  "(creds are resolved at launch from SENPI_API_KEY/SENPI_MCP_URL)."
449
542
  )
450
543
 
451
- # Dedicated event loop on a background thread. The async context
452
- # managers (transport + session) must stay OPEN for the client's
453
- # lifetime, so we cannot use asyncio.run() per call — that would
454
- # tear the session down between calls.
455
- self._loop = asyncio.new_event_loop()
456
- self._thread = threading.Thread(
457
- target=self._loop.run_forever,
458
- name="senpi-mcp-loop",
459
- daemon=True,
460
- )
461
- self._thread.start()
544
+ if self._loop is None:
545
+ # Dedicated event loop on a background thread, created ONCE per
546
+ # client. The async context managers (transport + session) must
547
+ # stay OPEN across calls, so we cannot use asyncio.run() per
548
+ # call — that would tear the session down between calls. A
549
+ # rebuild reuses this loop; it never spawns another thread.
550
+ self._loop = asyncio.new_event_loop()
551
+ self._thread = threading.Thread(
552
+ target=self._loop.run_forever,
553
+ name="senpi-mcp-loop",
554
+ daemon=True,
555
+ )
556
+ self._thread.start()
462
557
 
463
- self._run(self._connect())
464
- self._connected = True
558
+ self._current = self._run(self._open_session())
559
+ return self._current
465
560
 
466
- async def _connect(self) -> None:
467
- """Open the transport + ClientSession ONCE and initialize ONCE.
561
+ def _discard(self, dead: _McpSession) -> bool:
562
+ """Drop `dead` so the next call opens a fresh session; ask it to close.
563
+
564
+ Returns False (and touches nothing) when `dead` is no longer the held
565
+ session — a concurrent caller already replaced it, and its successor
566
+ must not be thrown away for the predecessor's failure. The close is
567
+ fire-and-forget: the lifetime task exits its own context managers, and
568
+ the caller never waits on a possibly-wedged transport.
569
+ """
570
+ with self._connect_lock:
571
+ if self._current is not dead:
572
+ return False
573
+ self._current = None
574
+ self._loop.call_soon_threadsafe(dead.close_requested.set)
575
+ return True
576
+
577
+ async def _open_session(self) -> _McpSession:
578
+ """Start a session lifetime task and wait until its session is initialised.
579
+
580
+ A failure while opening propagates to the caller; the lifetime task has
581
+ already unwound whatever it had entered, so nothing is left half-built.
582
+ """
583
+ loop = asyncio.get_running_loop()
584
+ ready: "asyncio.Future[Any]" = loop.create_future()
585
+ close_requested = asyncio.Event()
586
+ lifetime = loop.create_task(self._session_lifetime(ready, close_requested))
587
+ session = await ready
588
+ return _McpSession(session, lifetime, close_requested)
589
+
590
+ async def _session_lifetime(self, ready: "asyncio.Future[Any]", close_requested: asyncio.Event) -> None:
591
+ """Own one session from open to close, all within this single task.
468
592
 
469
593
  Lazy-import the SDK here so `import scaffold.senpi_mcp` succeeds in envs
470
594
  without `mcp` installed (tests inject a fake `mcp` into sys.modules).
471
595
  """
472
- from mcp import ClientSession
473
- from mcp.client.streamable_http import streamablehttp_client
474
-
475
- # Keep the async context managers alive by holding their __aenter__
476
- # values on self and only __aexit__-ing them at close. We use
477
- # AsyncExitStack so both nest correctly and unwind in order.
478
- from contextlib import AsyncExitStack
479
-
480
- self._stack = AsyncExitStack()
481
- headers = {"Authorization": f"Bearer {self._api_key}"}
482
- read, write, _ = await self._stack.enter_async_context(
483
- streamablehttp_client(self._mcp_url, headers=headers)
484
- )
485
- session = await self._stack.enter_async_context(ClientSession(read, write))
486
- await session.initialize()
487
- self._session = session
596
+ try:
597
+ from mcp import ClientSession
598
+ from mcp.client.streamable_http import streamablehttp_client
599
+
600
+ headers = {"Authorization": f"Bearer {self._api_key}"}
601
+ async with streamablehttp_client(self._mcp_url, headers=headers) as (read, write, _):
602
+ async with ClientSession(read, write) as session:
603
+ await session.initialize()
604
+ ready.set_result(session)
605
+ await close_requested.wait()
606
+ except BaseException as exc:
607
+ if not ready.done():
608
+ # Opening failed: surface it to the waiting call_tool. A
609
+ # non-Exception (task cancellation) is wrapped so the waiter
610
+ # gets a plain error instead of blocking forever.
611
+ ready.set_exception(
612
+ exc
613
+ if isinstance(exc, Exception)
614
+ else RuntimeError(f"senpi_mcp: session open aborted by {type(exc).__name__}")
615
+ )
616
+ # Otherwise the transport died under a published session (the
617
+ # next call_tool sees `alive` False and rebuilds) or its teardown
618
+ # complained after a rebuild — neither has a caller left to tell.
619
+ if not isinstance(exc, Exception):
620
+ raise
488
621
 
489
622
  def _run(self, coro: Any) -> Any:
490
623
  """Drive a coroutine to completion on the persistent loop, blocking."""
@@ -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.99",
5
+ "version": "3.0.100",
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.99",
3
+ "version": "3.0.100",
4
4
  "description": "Senpi runtime plugin for OpenClaw",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",