@senpi/runtime 3.0.0-dev.openclaw-upgrade-mai.20260623210452 → 3.0.0-dev.runtime-phase-2.20260623210537

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.
Files changed (196) hide show
  1. package/README.md +9 -10
  2. package/dist/actions/decision-engine.d.ts.map +1 -1
  3. package/dist/actions/decision-engine.js +0 -3
  4. package/dist/actions/decision-engine.js.map +1 -1
  5. package/dist/actions/open-position/open-position.action.d.ts.map +1 -1
  6. package/dist/actions/open-position/open-position.action.js +2 -6
  7. package/dist/actions/open-position/open-position.action.js.map +1 -1
  8. package/dist/cli/guide-commands.d.ts.map +1 -1
  9. package/dist/cli/guide-commands.js +56 -43
  10. package/dist/cli/guide-commands.js.map +1 -1
  11. package/dist/cli/senpi-commands.d.ts.map +1 -1
  12. package/dist/cli/senpi-commands.js +108 -0
  13. package/dist/cli/senpi-commands.js.map +1 -1
  14. package/dist/constants/dsl/index.d.ts +0 -2
  15. package/dist/constants/dsl/index.d.ts.map +1 -1
  16. package/dist/constants/dsl/index.js +0 -2
  17. package/dist/constants/dsl/index.js.map +1 -1
  18. package/dist/dsl/engine-client/config-mapping.d.ts +0 -7
  19. package/dist/dsl/engine-client/config-mapping.d.ts.map +1 -1
  20. package/dist/dsl/engine-client/config-mapping.js +2 -16
  21. package/dist/dsl/engine-client/config-mapping.js.map +1 -1
  22. package/dist/dsl/events/handlers.d.ts +2 -18
  23. package/dist/dsl/events/handlers.d.ts.map +1 -1
  24. package/dist/dsl/events/handlers.js +213 -302
  25. package/dist/dsl/events/handlers.js.map +1 -1
  26. package/dist/dsl/monitor/create-monitor.d.ts +0 -15
  27. package/dist/dsl/monitor/create-monitor.d.ts.map +1 -1
  28. package/dist/dsl/monitor/create-monitor.js +9 -44
  29. package/dist/dsl/monitor/create-monitor.js.map +1 -1
  30. package/dist/dsl/monitor/process-one-position.d.ts.map +1 -1
  31. package/dist/dsl/monitor/process-one-position.js +1 -24
  32. package/dist/dsl/monitor/process-one-position.js.map +1 -1
  33. package/dist/dsl/plugin/index.d.ts +1 -16
  34. package/dist/dsl/plugin/index.d.ts.map +1 -1
  35. package/dist/dsl/plugin/index.js +4 -35
  36. package/dist/dsl/plugin/index.js.map +1 -1
  37. package/dist/dsl/plugin/options.d.ts +0 -18
  38. package/dist/dsl/plugin/options.d.ts.map +1 -1
  39. package/dist/index.d.ts.map +1 -1
  40. package/dist/index.js +0 -4
  41. package/dist/index.js.map +1 -1
  42. package/dist/runtime/create-scanner-module.d.ts +3 -4
  43. package/dist/runtime/create-scanner-module.d.ts.map +1 -1
  44. package/dist/runtime/create-scanner-module.js +5 -14
  45. package/dist/runtime/create-scanner-module.js.map +1 -1
  46. package/dist/runtime/create-scanner-providers-for-run.d.ts.map +1 -1
  47. package/dist/runtime/create-scanner-providers-for-run.js +1 -12
  48. package/dist/runtime/create-scanner-providers-for-run.js.map +1 -1
  49. package/dist/runtime/load-runtime-config.js +1 -1
  50. package/dist/runtime/load-runtime-config.js.map +1 -1
  51. package/dist/runtime/map-strategies-registration.js +8 -7
  52. package/dist/runtime/map-strategies-registration.js.map +1 -1
  53. package/dist/runtime/map-strategies.d.ts.map +1 -1
  54. package/dist/runtime/map-strategies.js +3 -12
  55. package/dist/runtime/map-strategies.js.map +1 -1
  56. package/dist/runtime/reconcile-state.d.ts +0 -44
  57. package/dist/runtime/reconcile-state.d.ts.map +1 -1
  58. package/dist/runtime/reconcile-state.js +44 -80
  59. package/dist/runtime/reconcile-state.js.map +1 -1
  60. package/dist/runtime/risk-guard.d.ts.map +1 -1
  61. package/dist/runtime/risk-guard.js +12 -17
  62. package/dist/runtime/risk-guard.js.map +1 -1
  63. package/dist/runtime/runtime-schema.d.ts +995 -2855
  64. package/dist/runtime/runtime-schema.d.ts.map +1 -1
  65. package/dist/runtime/runtime-schema.js +40 -133
  66. package/dist/runtime/runtime-schema.js.map +1 -1
  67. package/dist/runtime/runtime.d.ts +0 -26
  68. package/dist/runtime/runtime.d.ts.map +1 -1
  69. package/dist/runtime/runtime.js +5 -233
  70. package/dist/runtime/runtime.js.map +1 -1
  71. package/dist/runtime/scanner-registry.d.ts +1 -1
  72. package/dist/runtime/scanner-registry.d.ts.map +1 -1
  73. package/dist/runtime/scanner-registry.js +12 -3
  74. package/dist/runtime/scanner-registry.js.map +1 -1
  75. package/dist/runtime/supervisor/long-running-supervisor.d.ts.map +1 -1
  76. package/dist/runtime/supervisor/long-running-supervisor.js +1 -1
  77. package/dist/runtime/supervisor/long-running-supervisor.js.map +1 -1
  78. package/dist/runtime/supervisor/orphan-sweep.js +1 -1
  79. package/dist/runtime/supervisor/orphan-sweep.js.map +1 -1
  80. package/dist/runtime/supervisor/supervisor-utils.d.ts +1 -1
  81. package/dist/runtime/supervisor/supervisor-utils.d.ts.map +1 -1
  82. package/dist/runtime/supervisor/supervisor-utils.js +1 -1
  83. package/dist/runtime/supervisor/supervisor-utils.js.map +1 -1
  84. package/dist/runtime-api/intake/intake-mount.d.ts.map +1 -1
  85. package/dist/runtime-api/intake/intake-mount.js +36 -28
  86. package/dist/runtime-api/intake/intake-mount.js.map +1 -1
  87. package/dist/runtime-api/intake/process-signal-post.d.ts.map +1 -1
  88. package/dist/runtime-api/intake/process-signal-post.js +0 -0
  89. package/dist/runtime-api/intake/process-signal-post.js.map +1 -1
  90. package/dist/runtime-api/intake/scanner-launcher.d.ts +4 -18
  91. package/dist/runtime-api/intake/scanner-launcher.d.ts.map +1 -1
  92. package/dist/runtime-api/intake/scanner-launcher.js +3 -24
  93. package/dist/runtime-api/intake/scanner-launcher.js.map +1 -1
  94. package/dist/runtime-api/intake/types.d.ts +0 -6
  95. package/dist/runtime-api/intake/types.d.ts.map +1 -1
  96. package/dist/runtime-api/routes/signals.schema.d.ts +0 -1
  97. package/dist/runtime-api/routes/signals.schema.d.ts.map +1 -1
  98. package/dist/runtime-api/routes/signals.schema.js +0 -5
  99. package/dist/runtime-api/routes/signals.schema.js.map +1 -1
  100. package/dist/scanners/external/liquidation-watchdog/README.md +107 -0
  101. package/dist/scanners/external/liquidation-watchdog/liquidation-watchdog.yaml +48 -0
  102. package/dist/scanners/external/liquidation-watchdog/producer.mjs +336 -0
  103. package/dist/scanners/external/momentum/README.md +148 -0
  104. package/dist/scanners/external/momentum/momentum.yaml +116 -0
  105. package/dist/scanners/external/momentum/producer.mjs +251 -0
  106. package/dist/scanners/external/scaffold/__main__.py +24 -65
  107. package/dist/scanners/external/scaffold/delivery.py +22 -56
  108. package/dist/scanners/external/scaffold/entrypoint_loader.py +48 -42
  109. package/dist/scanners/external/scaffold/journal.py +2 -13
  110. package/dist/scanners/external/scaffold/scaffold.py +128 -159
  111. package/dist/scanners/external/scaffold/signal_envelope.py +34 -177
  112. package/dist/scanners/external/shared/ingest.mjs +112 -0
  113. package/dist/scanners/external-scanner-receiver.d.ts +0 -6
  114. package/dist/scanners/external-scanner-receiver.d.ts.map +1 -1
  115. package/dist/scanners/external-scanner-receiver.js +1 -14
  116. package/dist/scanners/external-scanner-receiver.js.map +1 -1
  117. package/dist/scripts/run-dsl-standalone-live.d.ts +1 -1
  118. package/dist/scripts/run-dsl-standalone-live.js +3 -5
  119. package/dist/scripts/run-dsl-standalone-live.js.map +1 -1
  120. package/dist/senpi/client.d.ts +0 -12
  121. package/dist/senpi/client.d.ts.map +1 -1
  122. package/dist/senpi/client.js +2 -31
  123. package/dist/senpi/client.js.map +1 -1
  124. package/dist/state/state-manager.d.ts +0 -17
  125. package/dist/state/state-manager.d.ts.map +1 -1
  126. package/dist/state/state-manager.js +0 -28
  127. package/dist/state/state-manager.js.map +1 -1
  128. package/dist/strategy/strategy-state.d.ts.map +1 -1
  129. package/dist/strategy/strategy-state.js +1 -7
  130. package/dist/strategy/strategy-state.js.map +1 -1
  131. package/dist/tracking/constants.d.ts.map +1 -1
  132. package/dist/tracking/constants.js +0 -2
  133. package/dist/tracking/constants.js.map +1 -1
  134. package/dist/types/dsl/index.d.ts +1 -1
  135. package/dist/types/dsl/index.d.ts.map +1 -1
  136. package/dist/types/scanner.d.ts +0 -7
  137. package/dist/types/scanner.d.ts.map +1 -1
  138. package/examples/README.md +89 -0
  139. package/examples/scanners-consumer-quickstart.mjs +134 -0
  140. package/examples/scanners-emerging-movers-v4.mjs +287 -0
  141. package/examples/scanners-liquidation-watchdog.mjs +341 -0
  142. package/examples/scanners-live-mcp.mjs +208 -0
  143. package/examples/scanners-multi-strategy.mjs +165 -0
  144. package/examples/scanners-position-tracker-e2e.mjs +225 -0
  145. package/examples/scanners-scheduled-events.mjs +96 -0
  146. package/examples/scanners-senpi-provider-client.mjs +182 -0
  147. package/examples/scanners-single-strategy.mjs +198 -0
  148. package/examples/strategies/README.md +12 -17
  149. package/examples/strategies/dsl-showcase.yaml +68 -0
  150. package/examples/strategies/dsl.yaml +51 -0
  151. package/examples/strategies/fox-minimal-loosened.yaml +240 -0
  152. package/examples/strategies/fox-minimal.yaml +242 -0
  153. package/examples/strategies/minimal.yaml +46 -0
  154. package/examples/strategies/momentum/how-it-works.md +48 -0
  155. package/examples/strategies/momentum/momentum-guarded.yaml +161 -0
  156. package/examples/strategies/risk-showcase.yaml +93 -0
  157. package/examples/strategies/viper.yaml +67 -0
  158. package/examples/strategies/wolf.yaml +72 -0
  159. package/examples/yamls/sample-with-dsl-and-emerging-movers.yaml +123 -0
  160. package/package.json +1 -1
  161. package/dist/runtime/reconcile-decide.d.ts +0 -73
  162. package/dist/runtime/reconcile-decide.d.ts.map +0 -1
  163. package/dist/runtime/reconcile-decide.js +0 -166
  164. package/dist/runtime/reconcile-decide.js.map +0 -1
  165. package/dist/runtime/reconcile-run.d.ts +0 -62
  166. package/dist/runtime/reconcile-run.d.ts.map +0 -1
  167. package/dist/runtime/reconcile-run.js +0 -173
  168. package/dist/runtime/reconcile-run.js.map +0 -1
  169. package/dist/scanners/external/scaffold/ctx.py +0 -78
  170. package/dist/scanners/external/scaffold/requirements.txt +0 -11
  171. package/dist/scanners/external/scaffold/senpi_mcp.py +0 -250
  172. package/dist/scanners/external/scaffold/state_manager.py +0 -175
  173. package/dist/utils/keyed-mutex.d.ts +0 -37
  174. package/dist/utils/keyed-mutex.d.ts.map +0 -1
  175. package/dist/utils/keyed-mutex.js +0 -77
  176. package/dist/utils/keyed-mutex.js.map +0 -1
  177. package/examples/strategies/iguana/README.md +0 -135
  178. package/examples/strategies/iguana/recipe.yaml +0 -137
  179. package/examples/strategies/iguana/scanners/scan.py +0 -243
  180. package/examples/strategies/iguana/scanners/scoring.py +0 -152
  181. package/examples/strategies/raptor/README.md +0 -156
  182. package/examples/strategies/raptor/recipe.yaml +0 -154
  183. package/examples/strategies/raptor/scanners/scan.py +0 -349
  184. package/examples/strategies/raptor/scanners/scoring.py +0 -452
  185. package/examples/strategies/spider/README.md +0 -97
  186. package/examples/strategies/spider/scalp/recipe.yaml +0 -129
  187. package/examples/strategies/spider/scalp/scanners/scan.py +0 -219
  188. package/examples/strategies/spider/scalp/scanners/scoring.py +0 -179
  189. package/examples/strategies/spider/swing/recipe.yaml +0 -141
  190. package/examples/strategies/spider/swing/scanners/scan.py +0 -334
  191. package/examples/strategies/spider/swing/scanners/scoring.py +0 -184
  192. package/examples/strategies/turbine/README.md +0 -167
  193. package/examples/strategies/turbine/recipe-runners.yaml +0 -123
  194. package/examples/strategies/turbine/recipe-volume.yaml +0 -129
  195. package/examples/strategies/turbine/scanners/scan.py +0 -283
  196. package/examples/strategies/turbine/scanners/scoring.py +0 -190
@@ -20,7 +20,7 @@ class ScanEntrypointError(Exception):
20
20
 
21
21
 
22
22
  class ScanSignatureError(Exception):
23
- """scan's call shape is incompatible with scan(inputs, ctx)."""
23
+ """scan's call shape is incompatible with scaffold(params, state[, client])."""
24
24
 
25
25
 
26
26
  def add_path_to_sys_path(path: str) -> None:
@@ -32,22 +32,16 @@ def add_path_to_sys_path(path: str) -> None:
32
32
  def import_entrypoint(path: str, entrypoint: str):
33
33
  """Put `path` on sys.path, then import `entrypoint` by module name.
34
34
 
35
- The recipe expresses `entrypoint` as a FILENAME (e.g. `scan.py`, the shape the
36
- B1 launcher writes verbatim into the launch config and the §4a entrypoint FS
37
- check validates). importlib needs a MODULE name, so a trailing `.py` is
38
- stripped here — the only place the filename<->module-name bridge lives.
39
-
40
35
  Raises ScanEntrypointError on any import/syntax/missing-sibling failure,
41
36
  chaining the underlying exception.
42
37
  """
43
38
  add_path_to_sys_path(path)
44
- module_name = entrypoint[:-3] if entrypoint.endswith(".py") else entrypoint
45
39
  try:
46
40
  # Force re-import when the module has been previously imported so that
47
41
  # tests can write fresh modules to tmp_path without cache collisions.
48
- if module_name in sys.modules:
49
- del sys.modules[module_name]
50
- return importlib.import_module(module_name)
42
+ if entrypoint in sys.modules:
43
+ del sys.modules[entrypoint]
44
+ return importlib.import_module(entrypoint)
51
45
  except Exception as exc:
52
46
  raise ScanEntrypointError(
53
47
  f"Failed to import entrypoint '{entrypoint}' from '{path}': {exc}"
@@ -73,25 +67,19 @@ def locate_scan(module) -> Callable:
73
67
 
74
68
 
75
69
  def verify_scan_signature(scan: Callable) -> None:
76
- """Verify scan's call shape is compatible with the v2 contract scan(inputs, ctx).
77
-
78
- v2 calls scan with EXACTLY two positionals (inputs, ctx). A signature must be
79
- callable that way.
70
+ """Verify scan's call shape is compatible with scan(params, state[, client]).
80
71
 
81
72
  Accepted shapes:
82
- scan(inputs, ctx) — 2 required (the canonical shape)
83
- scan(inputs, ctx, extra=None) — 2 required + defaulted extra
84
- scan(inputs, ctx, *args) — variadic absorbs nothing extra here
73
+ scan(params, state) — 2 required
74
+ scan(params, state, client) — 3 required
75
+ scan(params, state, client=None) — 2 required, 1 optional
76
+ scan(params, state, *args) — variadic absorbs client
85
77
  scan(*args, **kwargs) — fully variadic, accepts anything
86
78
 
87
79
  Rejected shapes:
88
80
  scan() — 0 required
89
- scan(inputs) — 1 required
90
- scan(params, state, client) — 3 required (the OLD v1 client shape)
91
- scan(a, b, c, d) — >=4 required
92
-
93
- The contract is now EXACTLY two positionals: a 3rd REQUIRED positional cannot
94
- be satisfied by scan(inputs, ctx) and is rejected.
81
+ scan(params) — 1 required
82
+ scan(a, b, c, d) — >=4 fixed required
95
83
 
96
84
  Raises ScanSignatureError naming the found arity on mismatch.
97
85
  """
@@ -106,39 +94,57 @@ def verify_scan_signature(scan: Callable) -> None:
106
94
  has_var_positional = any(
107
95
  p.kind == inspect.Parameter.VAR_POSITIONAL for p in params
108
96
  )
97
+ has_var_keyword = any(
98
+ p.kind == inspect.Parameter.VAR_KEYWORD for p in params
99
+ )
109
100
 
110
- # Required (non-defaulted) fixed positional params.
111
- required_fixed = [
101
+ # A *args/**kwargs-only signature accepts any positional call.
102
+ if has_var_positional or has_var_keyword:
103
+ # Check that it actually accepts at least 2 positional args (i.e., no
104
+ # required positional params ahead of *args that block the call).
105
+ required_before_var = [
106
+ p for p in params
107
+ if p.kind in (
108
+ inspect.Parameter.POSITIONAL_ONLY,
109
+ inspect.Parameter.POSITIONAL_OR_KEYWORD,
110
+ )
111
+ and p.default is inspect.Parameter.empty
112
+ ]
113
+ # If there are >=4 required positionals even WITH *args, reject.
114
+ if len(required_before_var) >= 4:
115
+ raise ScanSignatureError(
116
+ f"scan requires {len(required_before_var)} fixed positional params; "
117
+ f"expected 2 or 3 (found arity: {len(required_before_var)})."
118
+ )
119
+ # Otherwise, variadic accepts our call.
120
+ return
121
+
122
+ # Count fixed positional params (POSITIONAL_ONLY or POSITIONAL_OR_KEYWORD).
123
+ fixed_positional = [
112
124
  p for p in params
113
125
  if p.kind in (
114
126
  inspect.Parameter.POSITIONAL_ONLY,
115
127
  inspect.Parameter.POSITIONAL_OR_KEYWORD,
116
128
  )
117
- and p.default is inspect.Parameter.empty
118
129
  ]
119
- n_required = len(required_fixed)
130
+ required_fixed = [
131
+ p for p in fixed_positional
132
+ if p.default is inspect.Parameter.empty
133
+ ]
120
134
 
121
- # A *args (or *args/**kwargs) signature accepts the 2-positional call as long
122
- # as it has no MORE than 2 required fixed positionals ahead of *args.
123
- if has_var_positional:
124
- if n_required > 2:
125
- raise ScanSignatureError(
126
- f"scan requires {n_required} fixed positional params; the v2 "
127
- f"contract calls scan(inputs, ctx) — exactly 2. Found arity: {n_required}."
128
- )
129
- return
135
+ n_required = len(required_fixed)
136
+ n_fixed = len(fixed_positional)
130
137
 
131
138
  if n_required < 2:
132
139
  raise ScanSignatureError(
133
- f"scan has {n_required} required positional param(s); the v2 contract "
134
- f"requires exactly 2 (inputs, ctx). Found arity: {n_required}."
140
+ f"scan has {n_required} required positional param(s); "
141
+ f"expected at least 2 (params, state). Found arity: {n_required}."
135
142
  )
136
143
 
137
- if n_required > 2:
144
+ if n_required >= 4:
138
145
  raise ScanSignatureError(
139
- f"scan has {n_required} required positional params; the v2 contract "
140
- f"calls scan(inputs, ctx) — exactly 2. A 3rd required positional "
141
- f"(the old v1 client shape) is no longer accepted. Found arity: {n_required}."
146
+ f"scan has {n_required} required positional params; "
147
+ f"expected at most 3 (params, state, client). Found arity: {n_required}."
142
148
  )
143
149
 
144
150
 
@@ -90,19 +90,8 @@ class Journal:
90
90
  # ------------------------------------------------------------------
91
91
 
92
92
  def record_produced(self, signal: dict, *, now_ms: int) -> str:
93
- """Write a 'produced' line and enqueue the signal. Returns entry id.
94
-
95
- The entry id IS the wire `signal_id`: the journal stamps `signal_id` onto
96
- the persisted/queued signal so the POSTed signal carries the dedup key, and
97
- a replay re-uses the SAME id (no fresh id minted on restart). If the signal
98
- already carries a `signal_id` (the delivery sink threaded the eid in via the
99
- envelope), that id is re-used as the entry id; otherwise one is minted.
100
- """
101
- existing = signal.get("signal_id")
102
- entry_id = existing if isinstance(existing, str) and existing else uuid.uuid4().hex
103
- # Stamp the id onto the wire signal so the persisted produced line and the
104
- # POSTed body both carry signal_id == entry id.
105
- signal["signal_id"] = entry_id
93
+ """Write a 'produced' line and enqueue the signal. Returns entry id."""
94
+ entry_id = uuid.uuid4().hex
106
95
  asset = signal.get("asset", "")
107
96
  valid_until = signal.get("valid_until", 0)
108
97
 
@@ -1,14 +1,11 @@
1
- """Scaffold core loop (v2).
1
+ """Scaffold core loop.
2
2
 
3
3
  run_scaffold(...) loads the author's entrypoint, finds and validates scan(),
4
- builds the ScanContext, then drives the interval loop: time-box per tick, graceful
5
- stop on SIGTERM/SIGINT, parent-death detection via ppid, transactional state via
6
- ctx.state, candidates to sink.
7
-
8
- `path` is a scaffold concern only — it is put on sys.path before import and never
9
- passed to scan(). scan() is called as scan(inputs, ctx) — 2-arg, sync, returning a
10
- plain list[dict]. State mutates ONLY via ctx.state (commit on a clean tick, rollback
11
- on error/timeout/malformed/failed-persist — transactional discard wins on failure).
4
+ then drives the interval loop: time-box per tick, graceful stop on SIGTERM/SIGINT,
5
+ parent-death detection via ppid, state in/out, candidates to sink.
6
+
7
+ `path` is a scaffold concern only — it is put on sys.path before import and
8
+ never passed to scan(). scan() is called as scan(params, state, client=None).
12
9
  """
13
10
 
14
11
  import inspect
@@ -17,7 +14,6 @@ import time
17
14
  import threading
18
15
  from typing import Any, Callable, Optional
19
16
 
20
- from .ctx import ScanContext
21
17
  from .entrypoint_loader import (
22
18
  ScanEntrypointError,
23
19
  ScanSignatureError,
@@ -33,44 +29,40 @@ from .loop_primitives import (
33
29
  _interruptible_sleep,
34
30
  log_event,
35
31
  )
36
- from .state_manager import StateManager
32
+ from .scaffold_state import read_state, write_state
37
33
 
38
34
 
39
- def _build_senpi_mcp() -> Any:
40
- """Construct the MCP client when creds are present; else return None.
35
+ def _call_scan(scan: Callable, params: dict, state: Any, client: Any) -> tuple:
36
+ """Call scan with the right arity.
41
37
 
42
- Creds come from the launch env (SENPI_API_KEY / SENPI_MCP_URL), injected by
43
- the launcher regardless of how the operator supplied them. With neither set,
44
- return None so the no-creds path does not crash — the client is loud at FIRST
45
- USE (see scaffold.senpi_mcp). Reference the class through the module attribute
46
- so a test stub patched onto scaffold.senpi_mcp is honored.
38
+ Tries scan(params, state, client) first; if that raises TypeError because
39
+ scan only accepts 2 positional args, falls back to scan(params, state).
40
+ This lets 2-arg and 3-arg (and variadic) scan functions all work uniformly.
47
41
  """
48
- api_key = os.environ.get("SENPI_API_KEY") or None
49
- mcp_url = os.environ.get("SENPI_MCP_URL") or None
50
- if not api_key and not mcp_url:
51
- return None
52
-
53
- from . import senpi_mcp as senpi_mcp_mod
54
-
55
- # Read-only-by-default producer MCP boundary: the set of MUTATION tools
56
- # producers are permitted to call is owned SOLELY by the scaffold-source
57
- # PRODUCER_WRITE_ALLOWLIST constant (EMPTY by default → all mutations
58
- # blocked). It is edited by the scaffold maintainer in senpi_mcp.py — it is
59
- # NOT operator/env-configurable and NOT reachable by authored producer code.
60
- return senpi_mcp_mod.SenpiMcpClient(
61
- api_key=api_key,
62
- mcp_url=mcp_url,
63
- write_allowlist=senpi_mcp_mod.PRODUCER_WRITE_ALLOWLIST,
64
- )
65
-
66
-
67
- def _call_scan(scan: Callable, inputs: dict, ctx: Any):
68
- """Call scan(inputs, ctx) — the v2 2-arg contract.
42
+ sig = None
43
+ try:
44
+ sig = inspect.signature(scan)
45
+ except (ValueError, TypeError):
46
+ pass
47
+
48
+ if sig is not None:
49
+ params_list = list(sig.parameters.values())
50
+ has_var_positional = any(
51
+ p.kind == inspect.Parameter.VAR_POSITIONAL for p in params_list
52
+ )
53
+ fixed_positional = [
54
+ p for p in params_list
55
+ if p.kind in (
56
+ inspect.Parameter.POSITIONAL_ONLY,
57
+ inspect.Parameter.POSITIONAL_OR_KEYWORD,
58
+ )
59
+ ]
60
+ # If only 2 fixed positional params (no *args), call 2-arg form.
61
+ if not has_var_positional and len(fixed_positional) == 2:
62
+ return scan(params, state)
69
63
 
70
- There is no `client` and no 3-arg path. scan returns a plain list[dict];
71
- validation of that return happens in the tick loop.
72
- """
73
- return scan(inputs, ctx)
64
+ # Default: 3-arg call (variadic or 3-param).
65
+ return scan(params, state, client)
74
66
 
75
67
 
76
68
  def run_scaffold(
@@ -79,17 +71,13 @@ def run_scaffold(
79
71
  *,
80
72
  interval: float,
81
73
  timeout_seconds: Optional[float] = None,
82
- inputs: Optional[dict] = None,
74
+ params: Optional[dict] = None,
83
75
  state_path: Optional[str] = None,
84
- scanner_name: str,
85
- wallet: str,
86
- default_signal_validity_seconds: int,
87
- state_history_max_count: int = 0,
88
76
  sink: Optional[Callable] = None,
89
77
  max_ticks: Optional[int] = None,
90
78
  install_signal_handlers: bool = True,
91
79
  ) -> int:
92
- """Load the author's entrypoint and run its scan() on an interval loop (v2).
80
+ """Load the author's entrypoint and run its scan() on an interval loop.
93
81
 
94
82
  Args:
95
83
  path: Directory added to sys.path before import. Never passed to scan().
@@ -98,15 +86,9 @@ def run_scaffold(
98
86
  timeout_seconds:
99
87
  Per-tick wall-clock budget. Defaults to `interval`. Must be > 0.
100
88
  Enforced via SIGALRM on platforms that support it.
101
- inputs: Tunables dict passed as scan()'s first argument. Default {}.
102
- state_path: Path to the JSON state file. None means no persistence.
103
- scanner_name: This scanner's recipe name (read-only ctx metadata).
104
- wallet: Strategy wallet (read-only ctx metadata).
105
- default_signal_validity_seconds:
106
- Fallback signal TTL (seconds); threaded to the envelope path.
107
- state_history_max_count:
108
- Bound on the persisted state series. 0/unset = history disabled
109
- (ctx.state.append raises).
89
+ params: Tunables dict passed as scan()'s first argument. Default {}.
90
+ state_path: Path to the JSON state file. Reads on start, written after
91
+ each clean tick. None means no persistence.
110
92
  sink: Callable(candidates) invoked after each clean tick. Default no-op.
111
93
  max_ticks: Stop after this many ticks (for tests). None = unbounded.
112
94
  install_signal_handlers:
@@ -130,41 +112,14 @@ def run_scaffold(
130
112
  if timeout_seconds <= 0:
131
113
  raise ValueError(f"timeout_seconds must be > 0; got {timeout_seconds!r}")
132
114
 
133
- if inputs is None:
134
- inputs = {}
115
+ if params is None:
116
+ params = {}
135
117
 
136
118
  # Import and locate scan — any failure raises before tick 1.
137
119
  module = import_entrypoint(path, entrypoint)
138
120
  scan = locate_scan(module)
139
121
  verify_scan_signature(scan)
140
122
 
141
- # ---------- ctx + state setup ----------
142
-
143
- # The StateManager (B2) is the persisted series. It reads any prior state from
144
- # disk on construction. state_history_max_count 0/unset => history disabled
145
- # (append raises). It is passed into ctx AS-IS (never wrapped).
146
- state = StateManager(state_path, state_history_max_count) if state_path is not None \
147
- else StateManager(None, state_history_max_count)
148
-
149
- # ctx.interval_seconds is the integer-seconds cadence (design §2.1). The loop's
150
- # `interval` may be a sub-second float in tests; expose the int seconds when it
151
- # is whole, else the float as-is.
152
- interval_seconds = int(interval) if float(interval).is_integer() else interval
153
-
154
- # B5: fill ctx.senpi_mcp when MCP creds are present (resolved at launch from
155
- # SENPI_API_KEY / SENPI_MCP_URL). With no creds the slot stays None and launch
156
- # must NOT crash — the client is loud at FIRST USE, not at construction. Import
157
- # is deferred so a stub patched onto scaffold.senpi_mcp in tests is honored.
158
- senpi_mcp = _build_senpi_mcp()
159
-
160
- ctx = ScanContext(
161
- state=state,
162
- wallet=wallet,
163
- scanner_name=scanner_name,
164
- interval_seconds=interval_seconds,
165
- senpi_mcp=senpi_mcp,
166
- )
167
-
168
123
  # ---------- Lifecycle setup ----------
169
124
 
170
125
  stop_event = threading.Event()
@@ -173,17 +128,24 @@ def run_scaffold(
173
128
 
174
129
  start_ppid = os.getppid()
175
130
 
131
+ # Read initial state — corrupt/missing -> None (logged inside read_state).
132
+ state = read_state(state_path) if state_path is not None else None
133
+
176
134
  log_event(
177
135
  "scaffold_started",
178
136
  path=path,
179
137
  entrypoint=entrypoint,
180
138
  interval=interval,
181
139
  timeout_seconds=timeout_seconds,
182
- scanner_name=scanner_name,
183
140
  max_ticks=max_ticks,
184
141
  )
185
142
 
143
+ client = None # placeholder; will be the real data handle in a later unit
186
144
  tick_count = 0
145
+ # True once a state write has failed and we have escalated to /errors;
146
+ # cleared when a write next succeeds. Throttles the escalation to one
147
+ # post per failure run (a persistent failure would otherwise post every tick).
148
+ state_persist_degraded = False
187
149
 
188
150
  try:
189
151
  while not stop_event.is_set():
@@ -197,28 +159,40 @@ def run_scaffold(
197
159
  tick_started_at = time.time()
198
160
  tick_status = "ok"
199
161
  tick_error = None
200
- candidates: list = []
162
+ candidates = []
163
+ new_state = state # default: keep current state on error
201
164
 
202
165
  armed = False
203
166
  try:
204
167
  armed = _arm_tick_alarm(timeout_seconds)
205
168
  try:
206
- result = _call_scan(scan, inputs, ctx)
169
+ result = _call_scan(scan, params, state, client)
207
170
 
208
- # v2 return contract: a PLAIN list[dict]. Anything else is
209
- # malformed (a tuple — the old v1 shape — included).
210
- if not isinstance(result, list):
171
+ # Validate the return value: must be a 2-tuple.
172
+ if (
173
+ not isinstance(result, tuple)
174
+ or len(result) != 2
175
+ ):
211
176
  tick_status = "malformed"
212
177
  log_event(
213
178
  "scaffold_tick_malformed",
214
179
  tick=tick_count,
215
- reason=(
216
- f"scan() returned {type(result).__name__!r}; expected a "
217
- f"plain list[dict] (the v1 (candidates, state) tuple is gone)"
218
- ),
180
+ reason=f"scan() returned {type(result).__name__!r}, expected (candidates, new_state) tuple",
219
181
  )
220
182
  else:
221
- candidates = result
183
+ raw_candidates, returned_state = result
184
+ # Validate that candidates is iterable.
185
+ try:
186
+ candidates = list(raw_candidates)
187
+ except TypeError:
188
+ tick_status = "malformed"
189
+ log_event(
190
+ "scaffold_tick_malformed",
191
+ tick=tick_count,
192
+ reason=f"candidates is not iterable (got {type(raw_candidates).__name__!r})",
193
+ )
194
+ else:
195
+ new_state = returned_state
222
196
 
223
197
  except _TickTimeout as exc:
224
198
  tick_status = "timeout"
@@ -249,40 +223,48 @@ def run_scaffold(
249
223
 
250
224
  duration_ms = int((time.time() - tick_started_at) * 1000)
251
225
 
252
- # --- Clean tick: commit ctx.state, deliver candidates ---
226
+ # --- Clean tick: persist (best-effort), advance state, deliver ---
253
227
  if tick_status == "ok":
254
- # Transactional commit. A failed persist RAISES (StateManager
255
- # enforces it) and does NOT advance the committed snapshot — so
256
- # the next tick recomputes from the un-advanced state. On a
257
- # commit failure we roll back and treat the tick as failed
258
- # (discard wins on failed-persist; no advance-on-persist-failure).
259
- committed = True
260
- try:
261
- ctx.state.commit()
262
- except Exception as commit_exc: # noqa: BLE001
263
- committed = False
264
- ctx.state.rollback()
265
- log_event(
266
- "scaffold_state_persist_failed",
267
- tick=tick_count,
268
- error_type=type(commit_exc).__name__,
269
- error=str(commit_exc),
270
- )
271
- if sink is not None and hasattr(sink, "post_error"):
272
- _post_error_safe(
273
- sink,
274
- {
275
- "type": "state_persist_failed",
276
- "tick": tick_count,
277
- "message": (
278
- "scanner state could not be persisted; "
279
- "tick discarded, state not advanced"
280
- ),
281
- },
282
- tick_count,
283
- )
284
-
285
- if committed and sink is not None:
228
+ # Disk persistence is best-effort: advance the in-memory state
229
+ # regardless of the write result, so the next tick never
230
+ # recomputes from stale state (which could re-deliver the same
231
+ # signal). But a persist failure is almost always persistent
232
+ # (unserialisable state, read-only dir, full disk), and the
233
+ # un-persisted ticks are LOST on the next restart — silently
234
+ # degrading any scanner that decides on accumulated history.
235
+ # So escalate it like a tick error: post to /errors once on the
236
+ # first failure (throttled while it keeps failing) and clear
237
+ # when a write next succeeds.
238
+ if state_path is not None:
239
+ if write_state(state_path, new_state):
240
+ if state_persist_degraded:
241
+ state_persist_degraded = False
242
+ log_event("scaffold_state_persist_recovered", tick=tick_count)
243
+ else:
244
+ log_event("scaffold_state_persist_failed", tick=tick_count)
245
+ if not state_persist_degraded:
246
+ state_persist_degraded = True
247
+ if sink is not None and hasattr(sink, "post_error"):
248
+ try:
249
+ sink.post_error({
250
+ "type": "state_persist_failed",
251
+ "tick": tick_count,
252
+ "message": (
253
+ "scanner state could not be persisted; "
254
+ "accumulated history will be lost on restart"
255
+ ),
256
+ })
257
+ except Exception as perr: # noqa: BLE001
258
+ log_event(
259
+ "scaffold_post_error_failed",
260
+ tick=tick_count,
261
+ error_type=type(perr).__name__,
262
+ error=str(perr),
263
+ )
264
+ state = new_state
265
+
266
+ # Hand candidates to the delivery sink.
267
+ if sink is not None:
286
268
  try:
287
269
  sink(candidates)
288
270
  except Exception as sink_exc: # noqa: BLE001
@@ -293,14 +275,18 @@ def run_scaffold(
293
275
  error=str(sink_exc),
294
276
  )
295
277
  else:
296
- # Non-ok tick (error / timeout / malformed): roll back the
297
- # working buffer (discard the tick's mutations — state not
298
- # advanced) and report to /errors via the sink, when supported.
299
- ctx.state.rollback()
278
+ # Non-ok tick (error / timeout / malformed): report it to the
279
+ # runtime's errors endpoint via the sink, when supported.
300
280
  if sink is not None and hasattr(sink, "post_error"):
301
- _post_error_safe(
302
- sink, tick_error or {"type": tick_status}, tick_count
303
- )
281
+ try:
282
+ sink.post_error(tick_error or {"type": tick_status})
283
+ except Exception as perr: # noqa: BLE001
284
+ log_event(
285
+ "scaffold_post_error_failed",
286
+ tick=tick_count,
287
+ error_type=type(perr).__name__,
288
+ error=str(perr),
289
+ )
304
290
 
305
291
  log_event(
306
292
  "scaffold_tick_finished",
@@ -311,15 +297,11 @@ def run_scaffold(
311
297
  )
312
298
 
313
299
  except _TickTimeout as late_exc:
314
- # A SIGALRM queued by the OS, delivered after the inner try/except
315
- # exited (e.g. during commit, sink, or log_event). Absorb it: the
316
- # tick already did its work; disarm, roll back any uncommitted
317
- # mutations, log the late arrival, and let the loop continue.
300
+ # A SIGALRM queued by the OS was delivered after the inner
301
+ # try/except exited (e.g. during write_state, sink, or log_event).
302
+ # Absorb it: the tick already did its work; disarm just in case,
303
+ # log the late arrival, and let the loop continue.
318
304
  _disarm_tick_alarm()
319
- try:
320
- ctx.state.rollback()
321
- except Exception: # noqa: BLE001
322
- pass
323
305
  log_event(
324
306
  "scaffold_tick_timeout_late",
325
307
  tick=tick_count,
@@ -346,16 +328,3 @@ def run_scaffold(
346
328
  log_event("scaffold_stopping", tick_count=tick_count)
347
329
 
348
330
  return tick_count
349
-
350
-
351
- def _post_error_safe(sink: Any, error: dict, tick_count: int) -> None:
352
- """Call sink.post_error(error), swallowing/logging any failure."""
353
- try:
354
- sink.post_error(error)
355
- except Exception as perr: # noqa: BLE001
356
- log_event(
357
- "scaffold_post_error_failed",
358
- tick=tick_count,
359
- error_type=type(perr).__name__,
360
- error=str(perr),
361
- )