@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 (
|
|
16
|
-
|
|
17
|
-
every call
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
returns; on a hard kill the OS reclaims the thread. We deliberately do NOT
|
|
53
|
-
|
|
54
|
-
|
|
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,
|
|
365
|
-
it
|
|
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
|
|
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.
|
|
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(
|
|
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) ->
|
|
433
|
-
|
|
434
|
-
|
|
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
|
-
|
|
437
|
-
|
|
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
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
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.
|
|
464
|
-
self.
|
|
558
|
+
self._current = self._run(self._open_session())
|
|
559
|
+
return self._current
|
|
465
560
|
|
|
466
|
-
|
|
467
|
-
"""
|
|
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
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
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."""
|
package/openclaw.plugin.json
CHANGED
|
@@ -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.
|
|
5
|
+
"version": "3.0.100",
|
|
6
6
|
"commandAliases": [
|
|
7
7
|
{
|
|
8
8
|
"name": "senpi"
|