@senpi/runtime 3.0.100 → 3.0.101

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.
@@ -13,6 +13,7 @@ on error/timeout/malformed/failed-persist — transactional discard wins on fail
13
13
  """
14
14
 
15
15
  import inspect
16
+ import math
16
17
  import os
17
18
  import time
18
19
  import threading
@@ -38,6 +39,35 @@ from .state_manager import StateManager
38
39
  from .ulid import new_ulid
39
40
 
40
41
 
42
+ def _read_call_timeout_seconds(senpi_mcp_mod: Any) -> float:
43
+ """The per-call MCP bound from the launch env, or the documented default.
44
+
45
+ `SENPI_MCP_CALL_TIMEOUT_SECONDS` is operator-set on the runtime box and
46
+ reaches the scaffold child through the supervisor's inherited env. A value
47
+ that is not a positive finite number must not take the scanner down at
48
+ launch — it falls back to the default and says so (`senpi_mcp_config_invalid`),
49
+ the same "loud, not fatal" shape as a malformed launch config field.
50
+ """
51
+ variable = senpi_mcp_mod.CALL_TIMEOUT_ENV_VAR
52
+ default = senpi_mcp_mod.DEFAULT_CALL_TIMEOUT_SECONDS
53
+ raw = os.environ.get(variable)
54
+ if raw is None:
55
+ return default
56
+ try:
57
+ seconds = float(raw)
58
+ except ValueError:
59
+ seconds = math.nan
60
+ if not senpi_mcp_mod.is_valid_call_timeout(seconds):
61
+ log_event(
62
+ "senpi_mcp_config_invalid",
63
+ variable=variable,
64
+ value=raw[:100],
65
+ fallback_seconds=default,
66
+ )
67
+ return default
68
+ return seconds
69
+
70
+
41
71
  def _build_senpi_mcp() -> Any:
42
72
  """Construct the MCP client when creds are present; else return None.
43
73
 
@@ -45,7 +75,8 @@ def _build_senpi_mcp() -> Any:
45
75
  the launcher regardless of how the operator supplied them. With neither set,
46
76
  return None so the no-creds path does not crash — the client is loud at FIRST
47
77
  USE (see scaffold.senpi_mcp). Reference the class through the module attribute
48
- so a test stub patched onto scaffold.senpi_mcp is honored.
78
+ so a test stub patched onto scaffold.senpi_mcp is honored. The per-call bound
79
+ comes from SENPI_MCP_CALL_TIMEOUT_SECONDS (see _read_call_timeout_seconds).
49
80
  """
50
81
  api_key = os.environ.get("SENPI_AUTH_TOKEN") or os.environ.get("SENPI_API_KEY") or None
51
82
  mcp_url = os.environ.get("SENPI_MCP_URL") or None
@@ -63,6 +94,7 @@ def _build_senpi_mcp() -> Any:
63
94
  api_key=api_key,
64
95
  mcp_url=mcp_url,
65
96
  write_allowlist=senpi_mcp_mod.PRODUCER_WRITE_ALLOWLIST,
97
+ call_timeout_seconds=_read_call_timeout_seconds(senpi_mcp_mod),
66
98
  )
67
99
 
68
100
 
@@ -30,9 +30,22 @@ report `ok` with zero candidates. The session is therefore now REBUILT: a
30
30
  transport-level failure (closed/broken stream, connection reset, broken pipe,
31
31
  httpx transport error) discards the dead session and the NEXT call opens and
32
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`.
33
+ inside a call — the point is that the failure is no longer permanent. The
34
+ failure is logged once as `senpi_mcp_error` with `session_rebuilt: true`.
35
+
36
+ AMENDMENT, second half (same RCA): B5 also locked "no per-call timeout".
37
+ `call_tool` blocked on the loop's future with no bound, so one stalled
38
+ `POST /mcp` hung a call until Kong severed the stream at 60 s, and a handful of
39
+ such calls consumed the whole 300 s tick budget (`scaffold_tick_timeout`).
40
+ EVERY call is now BOUNDED — session open included — by
41
+ `call_timeout_seconds` (launch env `SENPI_MCP_CALL_TIMEOUT_SECONDS`, default
42
+ `DEFAULT_CALL_TIMEOUT_SECONDS`, well under the 60 s ceiling so the scaffold,
43
+ not the proxy, is what gives up). A call that outlives the bound raises
44
+ `SenpiMcpTimeoutError` (a `TimeoutError`) and is treated exactly like a
45
+ transport failure: the abandoned session is discarded — the server may still
46
+ write to that stream — and the next call opens a fresh one. It is logged as
47
+ `senpi_mcp_error` with `error_type: TimeoutError`, `timeout_seconds` and
48
+ `elapsed_ms` so a stall is distinguishable from a severed stream in ClickStack.
36
49
 
37
50
  Response normalization MATCHES producer.mjs `createSenpiToolClient`:
38
51
  1. isError truthy -> RAISE with the error text (never a silent None).
@@ -82,9 +95,11 @@ connection (streamable-http is request/response, no protocol goodbye).
82
95
  from __future__ import annotations
83
96
 
84
97
  import asyncio
98
+ import concurrent.futures
85
99
  import json
86
100
  import math
87
101
  import threading
102
+ import time
88
103
  from typing import Any, Iterable, Optional
89
104
 
90
105
  from .loop_primitives import log_event
@@ -388,9 +403,11 @@ def _cast_market_numerics(name: str, payload: Any, args: Optional[dict] = None)
388
403
  # three when the SDK's streams are closed or broken under a session; httpx's
389
404
  # `TransportError` is the base of every connect/read/write/protocol/timeout
390
405
  # 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.
406
+ # (reset, refused, aborted), BrokenPipeError and — deliberately — TimeoutError,
407
+ # which is what the per-call bound raises (`SenpiMcpTimeoutError`): a call that
408
+ # timed out at the socket or under the bound has left the session in an unknown
409
+ # state (the server may still write to the abandoned stream) and the next call
410
+ # must not inherit it.
394
411
  # Our own pre-connect raises (PermissionError for a blocked mutation tool,
395
412
  # RuntimeError for missing creds) happen before any session is held, so they
396
413
  # never reach this classification.
@@ -411,6 +428,42 @@ def _is_transport_error(exc: BaseException) -> bool:
411
428
  return any(klass.__name__ in _TRANSPORT_ERROR_NAMES for klass in type(exc).__mro__)
412
429
 
413
430
 
431
+ # --------------------------------------------------------------------------
432
+ # Per-call bound — the scaffold gives up before the proxy does.
433
+ # --------------------------------------------------------------------------
434
+ #
435
+ # Kong severs a stalled `POST /mcp` at 60.000 s; a tick has 300 s. 30 s is
436
+ # comfortably under the ceiling — a stall is visible as OUR timeout, not as a
437
+ # severed stream — and still leaves ten sequential calls inside one tick. The
438
+ # bound covers the whole call: opening the session (initialize) when there is
439
+ # none, plus the tool call itself.
440
+ DEFAULT_CALL_TIMEOUT_SECONDS: float = 30.0
441
+
442
+ # Launch-env knob for the bound, read by scaffold._build_senpi_mcp.
443
+ CALL_TIMEOUT_ENV_VAR = "SENPI_MCP_CALL_TIMEOUT_SECONDS"
444
+
445
+
446
+ def is_valid_call_timeout(seconds: Any) -> bool:
447
+ """A usable bound: a positive, finite number (bools are not numbers here).
448
+
449
+ Shared by the constructor (which rejects anything else) and the launch-env
450
+ reader in scaffold.py (which falls back to the default instead).
451
+ """
452
+ if isinstance(seconds, bool) or not isinstance(seconds, (int, float)):
453
+ return False
454
+ return math.isfinite(seconds) and seconds > 0
455
+
456
+
457
+ class SenpiMcpTimeoutError(TimeoutError):
458
+ """A `call_tool` outlived the client's per-call bound.
459
+
460
+ A `TimeoutError` so authored `scan()` code can `except TimeoutError`; a
461
+ distinct class so the scaffold's own bound is tellable from a socket
462
+ timeout. Classified as a transport failure: the session it happened on is
463
+ discarded and the next call opens a fresh one.
464
+ """
465
+
466
+
414
467
  class _McpSession:
415
468
  """One live MCP session: the SDK session plus the handles that end it.
416
469
 
@@ -449,9 +502,18 @@ class SenpiMcpClient:
449
502
  api_key: Optional[str] = None,
450
503
  mcp_url: Optional[str] = None,
451
504
  write_allowlist: Iterable[str] = (),
505
+ call_timeout_seconds: Optional[float] = None,
452
506
  ) -> None:
453
507
  self._api_key = api_key
454
508
  self._mcp_url = mcp_url
509
+ if call_timeout_seconds is None:
510
+ call_timeout_seconds = DEFAULT_CALL_TIMEOUT_SECONDS
511
+ if not is_valid_call_timeout(call_timeout_seconds):
512
+ raise ValueError(
513
+ "senpi_mcp: call_timeout_seconds must be a positive number, "
514
+ f"got {call_timeout_seconds!r}"
515
+ )
516
+ self._call_timeout = float(call_timeout_seconds)
455
517
  # Mutation tools the operator has explicitly permitted. EMPTY by default
456
518
  # → every MUTATION_TOOLS entry is blocked (read-only producer boundary).
457
519
  self._write_allowlist: frozenset[str] = frozenset(write_allowlist)
@@ -466,6 +528,11 @@ class SenpiMcpClient:
466
528
 
467
529
  # -- public sync surface ----------------------------------------------
468
530
 
531
+ @property
532
+ def call_timeout_seconds(self) -> float:
533
+ """The bound every `call_tool` fails within (session open included)."""
534
+ return self._call_timeout
535
+
469
536
  def call_tool(self, name: str, args: Optional[dict] = None) -> Any:
470
537
  """Invoke an MCP tool synchronously; return the unwrapped result.
471
538
 
@@ -479,8 +546,16 @@ class SenpiMcpClient:
479
546
  A transport-level failure (see `_is_transport_error`) additionally
480
547
  discards the session it happened on, so the NEXT call opens a fresh one
481
548
  instead of failing forever; its log event carries `session_rebuilt: true`.
549
+
550
+ The whole call — opening a session when there is none, then the tool
551
+ call — must finish within `call_timeout_seconds`, else it raises
552
+ `SenpiMcpTimeoutError` (logged with `error_type: TimeoutError` and the
553
+ elapsed ms) and the abandoned session is discarded like any other
554
+ transport failure.
482
555
  """
483
556
  used: Optional[_McpSession] = None
557
+ started = time.monotonic()
558
+ deadline = started + self._call_timeout
484
559
  try:
485
560
  # Read-only producer boundary, enforced PRE-CONNECT (no transport
486
561
  # opened for a blocked write). A mutation tool passes only if its exact
@@ -495,31 +570,38 @@ class SenpiMcpClient:
495
570
  "not reachable by authored producer code)."
496
571
  )
497
572
 
498
- used = self._ensure_connected()
499
- result = self._run(used.session.call_tool(name, arguments=args))
573
+ used = self._ensure_connected(name, deadline)
574
+ result = self._run(used.session.call_tool(name, arguments=args), name, deadline)
500
575
  return _cast_market_numerics(name, self._unwrap(name, result), args)
501
576
  except Exception as exc:
502
577
  # Catch every failure — mutation block, missing creds, the lazy
503
- # `import mcp`, transport errors, tool `isError` — log it, then re-raise
504
- # to the author's scan(). Scoped to Exception so a tick timeout
505
- # (_TickTimeout, a BaseException) still unwinds the tick untouched.
578
+ # `import mcp`, transport errors, timeouts, tool `isError` — log it,
579
+ # then re-raise to the author's scan(). Scoped to Exception so a tick
580
+ # timeout (_TickTimeout, a BaseException) still unwinds the tick untouched.
506
581
  rebuilt = used is not None and _is_transport_error(exc) and self._discard(used)
582
+ timed_out = isinstance(exc, SenpiMcpTimeoutError)
507
583
  log_event(
508
584
  "senpi_mcp_error",
509
585
  tool=name,
510
- error_type=type(exc).__name__,
586
+ # The fleet fingerprint for the bound is the builtin category,
587
+ # not the scaffold subclass: ClickStack queries `TimeoutError`.
588
+ error_type="TimeoutError" if timed_out else type(exc).__name__,
511
589
  error=str(exc)[:500],
590
+ elapsed_ms=round((time.monotonic() - started) * 1000, 1),
591
+ **({"timeout_seconds": self._call_timeout} if timed_out else {}),
512
592
  **({"session_rebuilt": True} if rebuilt else {}),
513
593
  )
514
594
  raise
515
595
 
516
596
  # -- persistent session lifecycle -------------------------------------
517
597
 
518
- def _ensure_connected(self) -> _McpSession:
598
+ def _ensure_connected(self, name: str, deadline: float) -> _McpSession:
519
599
  """Return the live session, opening one when there is none.
520
600
 
521
601
  A session whose lifetime task has ended on its own (the transport died
522
602
  between calls) counts as absent and is replaced here, before the call.
603
+ Opening counts against the call's `deadline`: a stalled initialize
604
+ raises `SenpiMcpTimeoutError` like a stalled tool call would.
523
605
  """
524
606
  current = self._current
525
607
  if current is not None and current.alive:
@@ -555,7 +637,7 @@ class SenpiMcpClient:
555
637
  )
556
638
  self._thread.start()
557
639
 
558
- self._current = self._run(self._open_session())
640
+ self._current = self._run(self._open_session(), name, deadline)
559
641
  return self._current
560
642
 
561
643
  def _discard(self, dead: _McpSession) -> bool:
@@ -579,12 +661,19 @@ class SenpiMcpClient:
579
661
 
580
662
  A failure while opening propagates to the caller; the lifetime task has
581
663
  already unwound whatever it had entered, so nothing is left half-built.
664
+ A cancelled open (the caller's bound ran out while initialize stalled)
665
+ cancels the lifetime task too, which unwinds the transport from inside
666
+ the task that entered it — a stalled open never leaks a session.
582
667
  """
583
668
  loop = asyncio.get_running_loop()
584
669
  ready: "asyncio.Future[Any]" = loop.create_future()
585
670
  close_requested = asyncio.Event()
586
671
  lifetime = loop.create_task(self._session_lifetime(ready, close_requested))
587
- session = await ready
672
+ try:
673
+ session = await ready
674
+ except asyncio.CancelledError:
675
+ lifetime.cancel()
676
+ raise
588
677
  return _McpSession(session, lifetime, close_requested)
589
678
 
590
679
  async def _session_lifetime(self, ready: "asyncio.Future[Any]", close_requested: asyncio.Event) -> None:
@@ -619,10 +708,25 @@ class SenpiMcpClient:
619
708
  if not isinstance(exc, Exception):
620
709
  raise
621
710
 
622
- def _run(self, coro: Any) -> Any:
623
- """Drive a coroutine to completion on the persistent loop, blocking."""
711
+ def _run(self, coro: Any, name: str, deadline: float) -> Any:
712
+ """Drive a coroutine on the persistent loop, blocking until `deadline`.
713
+
714
+ Past the deadline the loop-side task is cancelled and
715
+ `SenpiMcpTimeoutError` is raised. If the task finished in the instant
716
+ between the deadline and the cancel, its outcome is taken as-is — the
717
+ call made it, by a hair — so a completed open never leaks its session.
718
+ """
624
719
  future = asyncio.run_coroutine_threadsafe(coro, self._loop)
625
- return future.result()
720
+ try:
721
+ return future.result(timeout=max(0.0, deadline - time.monotonic()))
722
+ except concurrent.futures.TimeoutError:
723
+ if not future.cancel():
724
+ return future.result()
725
+ raise SenpiMcpTimeoutError(
726
+ f'senpi_mcp: tool "{name}" did not complete within '
727
+ f"{self._call_timeout:g}s ({CALL_TIMEOUT_ENV_VAR}); the session (or "
728
+ "the pending open) is discarded and the next call opens a fresh one"
729
+ ) from None
626
730
 
627
731
  # -- response normalization (matches producer.mjs createSenpiToolClient) --
628
732
 
@@ -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.100",
5
+ "version": "3.0.101",
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.100",
3
+ "version": "3.0.101",
4
4
  "description": "Senpi runtime plugin for OpenClaw",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",