@senpi/runtime 3.0.129 → 3.0.131

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 (252) hide show
  1. package/dist/actions/action-events.d.ts +21 -7
  2. package/dist/actions/action-events.d.ts.map +1 -1
  3. package/dist/actions/action-events.js +54 -26
  4. package/dist/actions/action-events.js.map +1 -1
  5. package/dist/actions/action-runtime-module.d.ts.map +1 -1
  6. package/dist/actions/action-runtime-module.js +38 -7
  7. package/dist/actions/action-runtime-module.js.map +1 -1
  8. package/dist/actions/close-position/close-position.action.d.ts.map +1 -1
  9. package/dist/actions/close-position/close-position.action.js +122 -15
  10. package/dist/actions/close-position/close-position.action.js.map +1 -1
  11. package/dist/actions/decision-engine.d.ts.map +1 -1
  12. package/dist/actions/decision-engine.js +15 -2
  13. package/dist/actions/decision-engine.js.map +1 -1
  14. package/dist/actions/event-derivation.d.ts +71 -2
  15. package/dist/actions/event-derivation.d.ts.map +1 -1
  16. package/dist/actions/event-derivation.js +251 -8
  17. package/dist/actions/event-derivation.js.map +1 -1
  18. package/dist/actions/open-position/open-position.action.d.ts +12 -2
  19. package/dist/actions/open-position/open-position.action.d.ts.map +1 -1
  20. package/dist/actions/open-position/open-position.action.js +141 -41
  21. package/dist/actions/open-position/open-position.action.js.map +1 -1
  22. package/dist/actions/position-tracker/position-tracker.action.d.ts +2 -1
  23. package/dist/actions/position-tracker/position-tracker.action.d.ts.map +1 -1
  24. package/dist/actions/position-tracker/position-tracker.action.js +62 -17
  25. package/dist/actions/position-tracker/position-tracker.action.js.map +1 -1
  26. package/dist/actions/skipped-signal-results.d.ts +25 -0
  27. package/dist/actions/skipped-signal-results.d.ts.map +1 -0
  28. package/dist/actions/skipped-signal-results.js +71 -0
  29. package/dist/actions/skipped-signal-results.js.map +1 -0
  30. package/dist/actions/store/types.d.ts +9 -2
  31. package/dist/actions/store/types.d.ts.map +1 -1
  32. package/dist/actions/store/types.js.map +1 -1
  33. package/dist/actions/types.d.ts +13 -0
  34. package/dist/actions/types.d.ts.map +1 -1
  35. package/dist/actions/types.js +13 -0
  36. package/dist/actions/types.js.map +1 -1
  37. package/dist/cli/guide-commands.d.ts.map +1 -1
  38. package/dist/cli/guide-commands.js +3 -0
  39. package/dist/cli/guide-commands.js.map +1 -1
  40. package/dist/dsl/engine-client/event-mapping.d.ts.map +1 -1
  41. package/dist/dsl/engine-client/event-mapping.js +14 -0
  42. package/dist/dsl/engine-client/event-mapping.js.map +1 -1
  43. package/dist/dsl/events/build-closed-event.d.ts +16 -1
  44. package/dist/dsl/events/build-closed-event.d.ts.map +1 -1
  45. package/dist/dsl/events/build-closed-event.js +6 -0
  46. package/dist/dsl/events/build-closed-event.js.map +1 -1
  47. package/dist/dsl/events/handlers.d.ts +7 -0
  48. package/dist/dsl/events/handlers.d.ts.map +1 -1
  49. package/dist/dsl/events/handlers.js +37 -7
  50. package/dist/dsl/events/handlers.js.map +1 -1
  51. package/dist/dsl/monitor/create-monitor.d.ts +6 -2
  52. package/dist/dsl/monitor/create-monitor.d.ts.map +1 -1
  53. package/dist/dsl/monitor/create-monitor.js +15 -9
  54. package/dist/dsl/monitor/create-monitor.js.map +1 -1
  55. package/dist/dsl/monitor/dsl-failure-events.d.ts +2 -0
  56. package/dist/dsl/monitor/dsl-failure-events.d.ts.map +1 -1
  57. package/dist/dsl/monitor/dsl-failure-events.js +2 -1
  58. package/dist/dsl/monitor/dsl-failure-events.js.map +1 -1
  59. package/dist/dsl/monitor/process-one-position.d.ts +1 -0
  60. package/dist/dsl/monitor/process-one-position.d.ts.map +1 -1
  61. package/dist/dsl/monitor/process-one-position.js +59 -25
  62. package/dist/dsl/monitor/process-one-position.js.map +1 -1
  63. package/dist/dsl/plugin/index.d.ts +8 -2
  64. package/dist/dsl/plugin/index.d.ts.map +1 -1
  65. package/dist/dsl/plugin/index.js +8 -6
  66. package/dist/dsl/plugin/index.js.map +1 -1
  67. package/dist/health/types.d.ts +13 -0
  68. package/dist/health/types.d.ts.map +1 -1
  69. package/dist/health/types.js +12 -0
  70. package/dist/health/types.js.map +1 -1
  71. package/dist/index.d.ts +27 -4
  72. package/dist/index.d.ts.map +1 -1
  73. package/dist/index.js +251 -39
  74. package/dist/index.js.map +1 -1
  75. package/dist/runtime/action-registry.d.ts.map +1 -1
  76. package/dist/runtime/action-registry.js +4 -3
  77. package/dist/runtime/action-registry.js.map +1 -1
  78. package/dist/runtime/domain-events.d.ts.map +1 -1
  79. package/dist/runtime/domain-events.js +7 -0
  80. package/dist/runtime/domain-events.js.map +1 -1
  81. package/dist/runtime/lifecycle-events.d.ts +9 -1
  82. package/dist/runtime/lifecycle-events.d.ts.map +1 -1
  83. package/dist/runtime/lifecycle-events.js +11 -2
  84. package/dist/runtime/lifecycle-events.js.map +1 -1
  85. package/dist/runtime/reconcile-run.d.ts +13 -3
  86. package/dist/runtime/reconcile-run.d.ts.map +1 -1
  87. package/dist/runtime/reconcile-run.js +4 -3
  88. package/dist/runtime/reconcile-run.js.map +1 -1
  89. package/dist/runtime/reconcile-state.d.ts +8 -0
  90. package/dist/runtime/reconcile-state.d.ts.map +1 -1
  91. package/dist/runtime/reconcile-state.js +4 -1
  92. package/dist/runtime/reconcile-state.js.map +1 -1
  93. package/dist/runtime/run.d.ts +1 -0
  94. package/dist/runtime/run.d.ts.map +1 -1
  95. package/dist/runtime/run.js.map +1 -1
  96. package/dist/runtime/runtime-schema.js +3 -3
  97. package/dist/runtime/runtime-schema.js.map +1 -1
  98. package/dist/runtime/runtime.d.ts +1 -0
  99. package/dist/runtime/runtime.d.ts.map +1 -1
  100. package/dist/runtime/runtime.js +27 -3
  101. package/dist/runtime/runtime.js.map +1 -1
  102. package/dist/runtime/supervisor/long-running-supervisor.d.ts +13 -0
  103. package/dist/runtime/supervisor/long-running-supervisor.d.ts.map +1 -1
  104. package/dist/runtime/supervisor/long-running-supervisor.js +1 -0
  105. package/dist/runtime/supervisor/long-running-supervisor.js.map +1 -1
  106. package/dist/runtime/validate-dsl-position-tracking.d.ts.map +1 -1
  107. package/dist/runtime/validate-dsl-position-tracking.js +2 -1
  108. package/dist/runtime/validate-dsl-position-tracking.js.map +1 -1
  109. package/dist/runtime-api/handlers/ingest-signals.d.ts.map +1 -1
  110. package/dist/runtime-api/handlers/ingest-signals.js +1 -0
  111. package/dist/runtime-api/handlers/ingest-signals.js.map +1 -1
  112. package/dist/runtime-api/handlers/types.d.ts +5 -0
  113. package/dist/runtime-api/handlers/types.d.ts.map +1 -1
  114. package/dist/runtime-api/intake/ingest-request.d.ts +17 -0
  115. package/dist/runtime-api/intake/ingest-request.d.ts.map +1 -0
  116. package/dist/runtime-api/intake/ingest-request.js +34 -0
  117. package/dist/runtime-api/intake/ingest-request.js.map +1 -0
  118. package/dist/runtime-api/intake/intake-events.d.ts +1 -1
  119. package/dist/runtime-api/intake/intake-events.d.ts.map +1 -1
  120. package/dist/runtime-api/intake/intake-events.js +9 -3
  121. package/dist/runtime-api/intake/intake-events.js.map +1 -1
  122. package/dist/runtime-api/intake/intake-mount.d.ts.map +1 -1
  123. package/dist/runtime-api/intake/intake-mount.js +4 -19
  124. package/dist/runtime-api/intake/intake-mount.js.map +1 -1
  125. package/dist/runtime-api/intake/process-error-post.d.ts +6 -1
  126. package/dist/runtime-api/intake/process-error-post.d.ts.map +1 -1
  127. package/dist/runtime-api/intake/process-error-post.js +5 -1
  128. package/dist/runtime-api/intake/process-error-post.js.map +1 -1
  129. package/dist/runtime-api/intake/process-signal-post.d.ts +7 -1
  130. package/dist/runtime-api/intake/process-signal-post.d.ts.map +1 -1
  131. package/dist/runtime-api/intake/process-signal-post.js +5 -0
  132. package/dist/runtime-api/intake/process-signal-post.js.map +1 -1
  133. package/dist/runtime-api/intake/scaffold-error-router.js.map +1 -1
  134. package/dist/runtime-api/intake/scaffold-tick-router.d.ts +39 -0
  135. package/dist/runtime-api/intake/scaffold-tick-router.d.ts.map +1 -0
  136. package/dist/runtime-api/intake/scaffold-tick-router.js +122 -0
  137. package/dist/runtime-api/intake/scaffold-tick-router.js.map +1 -0
  138. package/dist/runtime-api/intake/scanner-launcher.d.ts.map +1 -1
  139. package/dist/runtime-api/intake/scanner-launcher.js +15 -0
  140. package/dist/runtime-api/intake/scanner-launcher.js.map +1 -1
  141. package/dist/runtime-api/intake/types.d.ts +55 -0
  142. package/dist/runtime-api/intake/types.d.ts.map +1 -1
  143. package/dist/runtime-api/routes/signals.d.ts +2 -0
  144. package/dist/runtime-api/routes/signals.d.ts.map +1 -1
  145. package/dist/runtime-api/routes/signals.js +5 -1
  146. package/dist/runtime-api/routes/signals.js.map +1 -1
  147. package/dist/runtime-api/routes/signals.schema.d.ts +43 -0
  148. package/dist/runtime-api/routes/signals.schema.d.ts.map +1 -1
  149. package/dist/runtime-api/routes/signals.schema.js +43 -0
  150. package/dist/runtime-api/routes/signals.schema.js.map +1 -1
  151. package/dist/runtime-api/server.d.ts +7 -1
  152. package/dist/runtime-api/server.d.ts.map +1 -1
  153. package/dist/runtime-api/server.js +1 -0
  154. package/dist/runtime-api/server.js.map +1 -1
  155. package/dist/scanners/engine/engine.d.ts +22 -1
  156. package/dist/scanners/engine/engine.d.ts.map +1 -1
  157. package/dist/scanners/engine/engine.js +78 -11
  158. package/dist/scanners/engine/engine.js.map +1 -1
  159. package/dist/scanners/engine/lifecycle.d.ts +7 -1
  160. package/dist/scanners/engine/lifecycle.d.ts.map +1 -1
  161. package/dist/scanners/events.d.ts +4 -0
  162. package/dist/scanners/events.d.ts.map +1 -1
  163. package/dist/scanners/events.js +2 -0
  164. package/dist/scanners/events.js.map +1 -1
  165. package/dist/scanners/external/scaffold/delivery.py +194 -25
  166. package/dist/scanners/external/scaffold/scaffold.py +172 -31
  167. package/dist/scanners/external/scaffold/senpi_mcp.py +150 -8
  168. package/dist/scanners/external/scaffold/tick_facts.py +130 -0
  169. package/dist/scanners/external/scaffold/ulid.py +58 -0
  170. package/dist/scanners/external-scanner-receiver.d.ts +17 -0
  171. package/dist/scanners/external-scanner-receiver.d.ts.map +1 -1
  172. package/dist/scanners/external-scanner-receiver.js +8 -0
  173. package/dist/scanners/external-scanner-receiver.js.map +1 -1
  174. package/dist/scanners/runtime-module.d.ts +1 -0
  175. package/dist/scanners/runtime-module.d.ts.map +1 -1
  176. package/dist/scanners/runtime-module.js +66 -9
  177. package/dist/scanners/runtime-module.js.map +1 -1
  178. package/dist/telemetry/attributes.d.ts +20 -0
  179. package/dist/telemetry/attributes.d.ts.map +1 -1
  180. package/dist/telemetry/attributes.js +24 -0
  181. package/dist/telemetry/attributes.js.map +1 -1
  182. package/dist/telemetry/event-builders.d.ts +81 -6
  183. package/dist/telemetry/event-builders.d.ts.map +1 -1
  184. package/dist/telemetry/event-builders.js +141 -17
  185. package/dist/telemetry/event-builders.js.map +1 -1
  186. package/dist/telemetry/event-store.d.ts +6 -0
  187. package/dist/telemetry/event-store.d.ts.map +1 -1
  188. package/dist/telemetry/event-store.js +12 -8
  189. package/dist/telemetry/event-store.js.map +1 -1
  190. package/dist/telemetry/live-telemetry.d.ts +238 -0
  191. package/dist/telemetry/live-telemetry.d.ts.map +1 -0
  192. package/dist/telemetry/live-telemetry.js +853 -0
  193. package/dist/telemetry/live-telemetry.js.map +1 -0
  194. package/dist/telemetry/metrics.d.ts +14 -2
  195. package/dist/telemetry/metrics.d.ts.map +1 -1
  196. package/dist/telemetry/metrics.js +19 -4
  197. package/dist/telemetry/metrics.js.map +1 -1
  198. package/dist/telemetry/scanner-stats.d.ts +143 -0
  199. package/dist/telemetry/scanner-stats.d.ts.map +1 -0
  200. package/dist/telemetry/scanner-stats.js +426 -0
  201. package/dist/telemetry/scanner-stats.js.map +1 -0
  202. package/dist/telemetry/scanner-tick-feed.d.ts +87 -0
  203. package/dist/telemetry/scanner-tick-feed.d.ts.map +1 -0
  204. package/dist/telemetry/scanner-tick-feed.js +48 -0
  205. package/dist/telemetry/scanner-tick-feed.js.map +1 -0
  206. package/dist/telemetry/spans/redact-pii.d.ts.map +1 -1
  207. package/dist/telemetry/spans/redact-pii.js +18 -2
  208. package/dist/telemetry/spans/redact-pii.js.map +1 -1
  209. package/dist/telemetry/strategy-topology.d.ts +99 -0
  210. package/dist/telemetry/strategy-topology.d.ts.map +1 -0
  211. package/dist/telemetry/strategy-topology.js +93 -0
  212. package/dist/telemetry/strategy-topology.js.map +1 -0
  213. package/dist/telemetry/telemetry-publisher.d.ts +76 -0
  214. package/dist/telemetry/telemetry-publisher.d.ts.map +1 -0
  215. package/dist/telemetry/telemetry-publisher.js +247 -0
  216. package/dist/telemetry/telemetry-publisher.js.map +1 -0
  217. package/dist/telemetry/tick-frame.d.ts +212 -0
  218. package/dist/telemetry/tick-frame.d.ts.map +1 -0
  219. package/dist/telemetry/tick-frame.js +421 -0
  220. package/dist/telemetry/tick-frame.js.map +1 -0
  221. package/dist/telemetry/timeline-buffer.d.ts +95 -0
  222. package/dist/telemetry/timeline-buffer.d.ts.map +1 -0
  223. package/dist/telemetry/timeline-buffer.js +237 -0
  224. package/dist/telemetry/timeline-buffer.js.map +1 -0
  225. package/dist/telemetry/tracing.d.ts +9 -0
  226. package/dist/telemetry/tracing.d.ts.map +1 -1
  227. package/dist/telemetry/tracing.js +36 -0
  228. package/dist/telemetry/tracing.js.map +1 -1
  229. package/dist/types/dsl/index.d.ts +11 -0
  230. package/dist/types/dsl/index.d.ts.map +1 -1
  231. package/dist/types/scanner.d.ts +15 -0
  232. package/dist/types/scanner.d.ts.map +1 -1
  233. package/dist/utils/event-catalog.d.ts +166 -0
  234. package/dist/utils/event-catalog.d.ts.map +1 -1
  235. package/dist/utils/event-catalog.js +18 -0
  236. package/dist/utils/event-catalog.js.map +1 -1
  237. package/dist/utils/logger.d.ts +4 -1
  238. package/dist/utils/logger.d.ts.map +1 -1
  239. package/dist/utils/logger.js.map +1 -1
  240. package/dist/utils/telemetry-required-log.d.ts +60 -0
  241. package/dist/utils/telemetry-required-log.d.ts.map +1 -0
  242. package/dist/utils/telemetry-required-log.js +105 -0
  243. package/dist/utils/telemetry-required-log.js.map +1 -0
  244. package/dist/utils/ulid.d.ts +9 -0
  245. package/dist/utils/ulid.d.ts.map +1 -0
  246. package/dist/utils/ulid.js +43 -0
  247. package/dist/utils/ulid.js.map +1 -0
  248. package/dist/validate/recipe-checks.d.ts.map +1 -1
  249. package/dist/validate/recipe-checks.js +2 -2
  250. package/dist/validate/recipe-checks.js.map +1 -1
  251. package/openclaw.plugin.json +1 -1
  252. package/package.json +1 -1
@@ -3,7 +3,8 @@
3
3
  run_scaffold(...) loads the author's entrypoint, finds and validates scan(),
4
4
  builds the ScanContext, then drives the interval loop: time-box per tick, graceful
5
5
  stop on SIGTERM/SIGINT, parent-death detection via ppid, transactional state via
6
- ctx.state, candidates to sink.
6
+ ctx.state, candidates to sink. Each tick mints one id, handed to the sink with the
7
+ tick's candidates and carried on every error the tick reports.
7
8
 
8
9
  `path` is a scaffold concern only — it is put on sys.path before import and never
9
10
  passed to scan(). scan() is called as scan(inputs, ctx) — 2-arg, sync, returning a
@@ -42,6 +43,8 @@ from .loop_primitives import (
42
43
  log_event,
43
44
  )
44
45
  from .state_manager import StateManager
46
+ from .tick_facts import build_tick_facts, count_changed, project_state_row
47
+ from .ulid import new_ulid
45
48
 
46
49
 
47
50
  def _read_call_timeout_seconds(senpi_mcp_mod: Any) -> float:
@@ -103,6 +106,28 @@ def _build_senpi_mcp() -> Any:
103
106
  )
104
107
 
105
108
 
109
+ def _accepts_kwarg(fn: Any, name: str) -> bool:
110
+ """Whether `fn` takes `name` as a keyword argument.
111
+
112
+ Read from the signature, once, rather than calling with the keyword and retrying on
113
+ TypeError: a sink that raises TypeError from its own body would then be called twice and
114
+ deliver the same candidates twice. A callable whose signature cannot be read keeps the
115
+ narrower call it has today.
116
+ """
117
+ try:
118
+ params = list(inspect.signature(fn).parameters.values())
119
+ except (TypeError, ValueError):
120
+ return False
121
+ return any(
122
+ p.kind == inspect.Parameter.VAR_KEYWORD
123
+ or (
124
+ p.name == name
125
+ and p.kind in (inspect.Parameter.POSITIONAL_OR_KEYWORD, inspect.Parameter.KEYWORD_ONLY)
126
+ )
127
+ for p in params
128
+ )
129
+
130
+
106
131
  def _call_scan(scan: Callable, inputs: dict, ctx: Any):
107
132
  """Call scan(inputs, ctx) — the v2 2-arg contract.
108
133
 
@@ -148,7 +173,8 @@ def run_scaffold(
148
173
  state_history_max_count:
149
174
  Bound on the persisted state series. 0/unset = history disabled
150
175
  (ctx.state.append raises).
151
- sink: Callable(candidates) invoked after each clean tick. Default no-op.
176
+ sink: Callable(candidates, tick_id=None) invoked after each clean tick. A sink
177
+ that takes candidates only is called with candidates only. Default no-op.
152
178
  max_ticks: Stop after this many ticks. None = unbounded. Used by tests and by a
153
179
  validation run, which forces exactly one tick.
154
180
  senpi_mcp: Data client to hand to scan(). Default: built from the launch environment.
@@ -235,6 +261,24 @@ def run_scaffold(
235
261
  # The MCP boundary's per-tick failure tally, when the client exposes one. Duck-typed:
236
262
  # an injected stub without it (a validation run) simply never reclassifies a tick.
237
263
  mcp_tally = getattr(senpi_mcp, "tick_errors", None)
264
+ sink_takes_tick_id = sink is not None and _accepts_kwarg(sink, "tick_id")
265
+ # The per-tick observability facts ride the POST the sink already makes. A sink that
266
+ # predates them is called exactly as before, so an older sink is not broken by them.
267
+ sink_takes_tick = sink is not None and _accepts_kwarg(sink, "tick")
268
+ post_error_takes_tick = (
269
+ sink is not None
270
+ and hasattr(sink, "post_error")
271
+ and _accepts_kwarg(sink.post_error, "tick")
272
+ )
273
+
274
+ # Consecutive ticks that produced no candidate. 47 quiet ticks is information; the word
275
+ # "quiet" repeated 47 times is not, so the streak is counted here and sent as a number.
276
+ quiet_ticks = 0
277
+ # The previous tick's start, for the cadence lag. None on the first tick of a process:
278
+ # there is no gap to measure yet, and a 0 would claim the scanner was exactly on time.
279
+ prev_tick_started_at: Optional[float] = None
280
+ # The last projection reported, so `state_changed` says what MOVED rather than what exists.
281
+ prev_state_projection: Optional[dict] = None
238
282
 
239
283
  try:
240
284
  while not stop_event.is_set():
@@ -245,7 +289,18 @@ def run_scaffold(
245
289
  break
246
290
 
247
291
  tick_count += 1
292
+ # One id per tick, minted before any of the tick's work so every candidate and every
293
+ # error it reports names the same tick.
294
+ tick_id = new_ulid()
248
295
  tick_started_at = time.time()
296
+ # Actual gap between tick starts, minus the cadence the scanner is configured for.
297
+ # A positive number is a scanner falling behind its own interval.
298
+ lag_ms = (
299
+ None
300
+ if prev_tick_started_at is None
301
+ else int(round((tick_started_at - prev_tick_started_at - interval) * 1000))
302
+ )
303
+ prev_tick_started_at = tick_started_at
249
304
  tick_status = "ok"
250
305
  tick_error = None
251
306
  candidates: list = []
@@ -269,10 +324,15 @@ def run_scaffold(
269
324
  )
270
325
  # Carried on the error record, not only in this log line: the sink is what
271
326
  # a caller reads, and "malformed" without the reason is not actionable.
272
- tick_error = {"type": "malformed", "message": malformed_reason}
327
+ tick_error = {
328
+ "type": "malformed",
329
+ "tick_id": tick_id,
330
+ "message": malformed_reason,
331
+ }
273
332
  log_event(
274
333
  "scaffold_tick_malformed",
275
334
  tick=tick_count,
335
+ tick_id=tick_id,
276
336
  reason=malformed_reason,
277
337
  )
278
338
  else:
@@ -281,10 +341,11 @@ def run_scaffold(
281
341
  except _TickTimeout as exc:
282
342
  tick_status = "timeout"
283
343
  candidates = []
284
- tick_error = {"type": "timeout", "message": str(exc)}
344
+ tick_error = {"type": "timeout", "tick_id": tick_id, "message": str(exc)}
285
345
  log_event(
286
346
  "scaffold_tick_timeout",
287
347
  tick=tick_count,
348
+ tick_id=tick_id,
288
349
  reason=str(exc),
289
350
  )
290
351
  except Exception as exc: # noqa: BLE001 — author bug must not crash scaffold
@@ -292,12 +353,14 @@ def run_scaffold(
292
353
  candidates = []
293
354
  tick_error = {
294
355
  "type": "error",
356
+ "tick_id": tick_id,
295
357
  "error_type": type(exc).__name__,
296
358
  "message": str(exc),
297
359
  }
298
360
  log_event(
299
361
  "scaffold_tick_error",
300
362
  tick=tick_count,
363
+ tick_id=tick_id,
301
364
  error_type=type(exc).__name__,
302
365
  error=str(exc),
303
366
  )
@@ -317,15 +380,23 @@ def run_scaffold(
317
380
  mcp_error_count, mcp_failure = _classify_mcp_errors(mcp_tally, candidates)
318
381
  if tick_status == "ok" and mcp_failure is not None:
319
382
  tick_status = "mcp_error"
320
- tick_error = mcp_failure
383
+ # Stamped here, not in the classifier: every error record posted to
384
+ # `/errors` names the tick it came from, and the classifier is tick-agnostic.
385
+ tick_error = {**mcp_failure, "tick_id": tick_id}
321
386
  log_event(
322
387
  "scaffold_tick_mcp_error",
323
388
  tick=tick_count,
389
+ tick_id=tick_id,
324
390
  error_type=mcp_failure["error_type"],
325
391
  error_count=mcp_error_count,
326
392
  )
327
393
 
394
+ # The quiet streak counts ticks, not words: a tick that produced a
395
+ # candidate ends the streak whatever its status.
396
+ quiet_ticks = 0 if candidates else quiet_ticks + 1
397
+
328
398
  # --- Clean tick: commit ctx.state, deliver candidates ---
399
+ persist_error = None
329
400
  if tick_status == "ok":
330
401
  # Transactional commit. A failed persist RAISES (StateManager
331
402
  # enforces it) and does NOT advance the committed snapshot — so
@@ -341,46 +412,100 @@ def run_scaffold(
341
412
  log_event(
342
413
  "scaffold_state_persist_failed",
343
414
  tick=tick_count,
415
+ tick_id=tick_id,
344
416
  error_type=type(commit_exc).__name__,
345
417
  error=str(commit_exc),
346
418
  )
347
- if sink is not None and hasattr(sink, "post_error"):
348
- _post_error_safe(
349
- sink,
350
- {
351
- "type": "state_persist_failed",
352
- "tick": tick_count,
353
- "message": (
354
- "scanner state could not be persisted; "
355
- "tick discarded, state not advanced"
356
- ),
357
- },
358
- tick_count,
359
- )
419
+ persist_error = {
420
+ "type": "state_persist_failed",
421
+ "tick": tick_count,
422
+ "tick_id": tick_id,
423
+ "message": (
424
+ "scanner state could not be persisted; "
425
+ "tick discarded, state not advanced"
426
+ ),
427
+ }
428
+ commit_outcome = "committed" if committed else "persist_failed"
429
+ else:
430
+ # Non-ok tick (error / timeout / malformed): roll back the
431
+ # working buffer (discard the tick's mutations — state not
432
+ # advanced) and report to /errors via the sink, when supported.
433
+ ctx.state.rollback()
434
+ committed = False
435
+ commit_outcome = "rolled_back"
436
+
437
+ # Read AFTER the commit/rollback above, so this is always the last
438
+ # COMMITTED row — stale by one tick on a failed tick, which is exactly
439
+ # when the last state the scanner agreed on is worth seeing.
440
+ state_row = ctx.state.last()
441
+ state_projection = project_state_row(state_row)
442
+ state_fields = len(state_row) if isinstance(state_row, dict) else None
443
+ state_changed = (
444
+ None
445
+ if state_fields is None
446
+ else count_changed(state_projection, prev_state_projection)
447
+ )
448
+ prev_state_projection = state_projection if state_fields is not None else None
449
+
450
+ tick_facts = build_tick_facts(
451
+ tick_id=tick_id,
452
+ status=tick_status,
453
+ candidate_count=len(candidates),
454
+ duration_ms=duration_ms,
455
+ commit=commit_outcome,
456
+ quiet_ticks=quiet_ticks,
457
+ lag_ms=lag_ms,
458
+ mcp_snapshot=mcp_tally.snapshot() if mcp_tally is not None else None,
459
+ call_snapshot=(
460
+ mcp_tally.call_snapshot()
461
+ if mcp_tally is not None and hasattr(mcp_tally, "call_snapshot")
462
+ else None
463
+ ),
464
+ state_row=state_row,
465
+ state_fields=state_fields,
466
+ state_changed=state_changed,
467
+ )
468
+
469
+ # Exactly one POST carries this tick's facts: /signals on a clean tick,
470
+ # /errors on any tick that failed. Both already happen every tick, so the
471
+ # facts add no request of their own.
472
+ sink_can_post_error = sink is not None and hasattr(sink, "post_error")
473
+ facts_for_error = tick_facts if post_error_takes_tick else None
474
+ if tick_status == "ok":
475
+ if persist_error is not None and sink_can_post_error:
476
+ _post_error_safe(
477
+ sink, persist_error, tick_count, tick_id, facts_for_error
478
+ )
360
479
 
361
480
  if committed and sink is not None:
362
481
  try:
363
- sink(candidates)
482
+ kwargs = {}
483
+ if sink_takes_tick_id:
484
+ kwargs["tick_id"] = tick_id
485
+ if sink_takes_tick:
486
+ kwargs["tick"] = tick_facts
487
+ sink(candidates, **kwargs)
364
488
  except Exception as sink_exc: # noqa: BLE001
365
489
  log_event(
366
490
  "scaffold_sink_error",
367
491
  tick=tick_count,
492
+ tick_id=tick_id,
368
493
  error_type=type(sink_exc).__name__,
369
494
  error=str(sink_exc),
370
495
  )
371
- else:
372
- # Non-ok tick (error / timeout / malformed): roll back the
373
- # working buffer (discard the tick's mutations — state not
374
- # advanced) and report to /errors via the sink, when supported.
375
- ctx.state.rollback()
376
- if sink is not None and hasattr(sink, "post_error"):
377
- _post_error_safe(
378
- sink, tick_error or {"type": tick_status}, tick_count
379
- )
496
+ elif sink_can_post_error:
497
+ _post_error_safe(
498
+ sink,
499
+ tick_error or {"type": tick_status, "tick_id": tick_id},
500
+ tick_count,
501
+ tick_id,
502
+ facts_for_error,
503
+ )
380
504
 
381
505
  log_event(
382
506
  "scaffold_tick_finished",
383
507
  tick=tick_count,
508
+ tick_id=tick_id,
384
509
  status=tick_status,
385
510
  duration_ms=duration_ms,
386
511
  candidate_count=len(candidates),
@@ -402,6 +527,7 @@ def run_scaffold(
402
527
  log_event(
403
528
  "scaffold_tick_timeout_late",
404
529
  tick=tick_count,
530
+ tick_id=tick_id,
405
531
  reason=str(late_exc),
406
532
  )
407
533
 
@@ -454,14 +580,29 @@ def _classify_mcp_errors(mcp_tally: Any, candidates: list) -> tuple[int, Optiona
454
580
  }
455
581
 
456
582
 
457
- def _post_error_safe(sink: Any, error: dict, tick_count: int) -> None:
458
- """Call sink.post_error(error), swallowing/logging any failure."""
583
+ def _post_error_safe(
584
+ sink: Any,
585
+ error: dict,
586
+ tick_count: int,
587
+ tick_id: str,
588
+ tick: Optional[dict] = None,
589
+ ) -> None:
590
+ """Call sink.post_error(error), swallowing/logging any failure.
591
+
592
+ `tick` is passed only when the sink's signature accepts it — the caller checks
593
+ once at launch, so a sink that predates the per-tick facts keeps its one-argument
594
+ call rather than being handed a keyword it would raise on.
595
+ """
459
596
  try:
460
- sink.post_error(error)
597
+ if tick is None:
598
+ sink.post_error(error)
599
+ else:
600
+ sink.post_error(error, tick=tick)
461
601
  except Exception as perr: # noqa: BLE001
462
602
  log_event(
463
603
  "scaffold_post_error_failed",
464
604
  tick=tick_count,
605
+ tick_id=tick_id,
465
606
  error_type=type(perr).__name__,
466
607
  error=str(perr),
467
608
  )
@@ -404,28 +404,155 @@ def _cast_market_numerics(name: str, payload: Any, args: Optional[dict] = None)
404
404
  return payload
405
405
 
406
406
 
407
+ # Per-call detail kept for one tick. The counts and the total duration below are
408
+ # whole however many calls a tick makes; only this per-call list is cut, because
409
+ # a scanner that calls a tool in a loop would otherwise grow an unbounded list
410
+ # inside a process that must not grow.
411
+ MAX_TICK_CALL_RECORDS = 64
412
+
413
+ # Per-argument bounds for a recorded call. Arguments are author-supplied and
414
+ # reach a watching operator's screen, so what is kept is scalars only, capped.
415
+ MAX_TICK_CALL_ARG_KEYS = 16
416
+ MAX_TICK_CALL_ARG_CHARS = 1024
417
+
418
+ # A list of scalars is kept as well, because the assets a scanner priced arrive
419
+ # in that shape and are the most useful argument on the event. Both bounds apply
420
+ # to one list: at most this many elements, and no more rendered characters than a
421
+ # single string argument is allowed. MAX_TICK_CALL_ARG_CHARS is shared rather than
422
+ # doubled so one long list cannot cost more frame than one long string.
423
+ MAX_TICK_CALL_ARG_LIST_ITEMS = 16
424
+
425
+
426
+ def _is_arg_scalar(value: Any) -> bool:
427
+ """Whether one value is a scalar this event carries: str, bool, or a finite number.
428
+
429
+ The int branch covers bool, which subclasses it, and comes before the float
430
+ branch on purpose: an int cannot be infinite, and `math.isfinite` on one too
431
+ large to convert to a float raises OverflowError.
432
+ """
433
+ if isinstance(value, int):
434
+ return True
435
+ if isinstance(value, float):
436
+ return math.isfinite(value)
437
+ return isinstance(value, str)
438
+
439
+
440
+ def _record_arg_list(value: list) -> Optional[list]:
441
+ """One list argument, bounded — or None when the list is not carried at all.
442
+
443
+ A single non-scalar element rejects the whole list. Filtering it out instead
444
+ would let an author's unbounded dict decide how much of a nested payload gets
445
+ rendered, which is the input this event refuses to take.
446
+
447
+ Elements dropped by either bound are replaced by a trailing count, so a
448
+ shortened list reads as shortened rather than as a shorter call. The marker is
449
+ a plain string element because the consumer expands dictionary indexes and
450
+ scalars, and a nested object would not survive it.
451
+ """
452
+ if not all(_is_arg_scalar(item) for item in value):
453
+ return None
454
+ kept: list = []
455
+ chars = 0
456
+ for item in value[:MAX_TICK_CALL_ARG_LIST_ITEMS]:
457
+ rendered = len(item) if isinstance(item, str) else len(str(item))
458
+ if chars + rendered > MAX_TICK_CALL_ARG_CHARS:
459
+ break
460
+ chars += rendered
461
+ kept.append(item)
462
+ if len(kept) < len(value):
463
+ kept.append(f"+{len(value) - len(kept)} more")
464
+ return kept
465
+
466
+
467
+ def _record_args(args: Optional[dict]) -> dict:
468
+ """The scalar arguments of one call, bounded — never the caller's own dict.
469
+
470
+ A reference would let authored code mutate what the tally already recorded,
471
+ and a deep copy would pay for a payload the wire is going to drop anyway. A
472
+ kept list is a new list for the same reason.
473
+ """
474
+ if not isinstance(args, dict):
475
+ return {}
476
+ kept: dict = {}
477
+ for key in list(args.keys())[:MAX_TICK_CALL_ARG_KEYS]:
478
+ if not isinstance(key, str):
479
+ continue
480
+ value = args[key]
481
+ if isinstance(value, str):
482
+ kept[key] = value[:MAX_TICK_CALL_ARG_CHARS]
483
+ elif _is_arg_scalar(value):
484
+ # The same gate the list path applies, and for a harder reason here: an
485
+ # `inf` compares equal to itself, so a self-equality check lets it
486
+ # through, and json.dumps then writes the bare `Infinity` token. That
487
+ # token is not JSON, and the tick facts ride one POST body — a
488
+ # JavaScript JSON.parse on it throws and every other fact on the tick
489
+ # is lost with the one argument.
490
+ kept[key] = value
491
+ elif isinstance(value, list):
492
+ # An empty list is kept. Absent means unmeasured on this event, and a
493
+ # scanner that passed no elements did measure something: it asked for
494
+ # nothing. Dropping it would report the argument as never supplied.
495
+ rendered_list = _record_arg_list(value)
496
+ if rendered_list is not None:
497
+ kept[key] = rendered_list
498
+ return kept
499
+
500
+
407
501
  class TickErrorTally:
408
- """Per-tick count of MCP call failures, by error type.
502
+ """Per-tick record of what the MCP boundary did: every call, and every failure.
503
+
504
+ Two tallies, one object, because both are read at the same moment and reset
505
+ at the same moment.
506
+
507
+ FAILURES. Authored scan() code routinely catches the MCP exception and
508
+ returns [], so the exception alone never reaches the scaffold — the tick
509
+ would finish `ok` with zero candidates while the scanner is blind. The client
510
+ records every failure here as it re-raises; the scaffold resets the tally at
511
+ tick start and reads it at tick end to decide whether a candidate-less tick
512
+ was really ok.
513
+
514
+ CALLS. Every call is recorded, succeeded or failed, with the time it took.
515
+ Failures alone say what broke but not what the tick actually did or where its
516
+ time went — a tick with no failures and one 9-second call is a slow venue, and
517
+ nothing else on the box can tell an operator that.
409
518
 
410
- Authored scan() code routinely catches the MCP exception and returns [], so
411
- the exception alone never reaches the scaffold — the tick would finish `ok`
412
- with zero candidates while the scanner is blind. The client records every
413
- failure here as it re-raises; the scaffold resets the tally at tick start and
414
- reads it at tick end to decide whether a candidate-less tick was really ok.
415
519
  Thread-safe because call_tool may be driven from author threads.
416
520
  """
417
521
 
418
522
  def __init__(self) -> None:
419
523
  self._by_type: dict[str, int] = {}
524
+ self._calls: list[dict] = []
525
+ self._call_count = 0
526
+ self._total_ms = 0.0
420
527
  self._lock = threading.Lock()
421
528
 
422
529
  def record(self, error_type: str) -> None:
423
530
  with self._lock:
424
531
  self._by_type[error_type] = self._by_type.get(error_type, 0) + 1
425
532
 
533
+ def record_call(
534
+ self,
535
+ tool: str,
536
+ elapsed_ms: float,
537
+ args: Optional[dict] = None,
538
+ error_type: Optional[str] = None,
539
+ ) -> None:
540
+ """Record one completed call. Called on the success path and the failure path alike."""
541
+ entry = {"tool": tool, "ms": elapsed_ms, "args": _record_args(args)}
542
+ if error_type is not None:
543
+ entry["error_type"] = error_type
544
+ with self._lock:
545
+ self._call_count += 1
546
+ self._total_ms += elapsed_ms
547
+ if len(self._calls) < MAX_TICK_CALL_RECORDS:
548
+ self._calls.append(entry)
549
+
426
550
  def reset(self) -> None:
427
551
  with self._lock:
428
552
  self._by_type = {}
553
+ self._calls = []
554
+ self._call_count = 0
555
+ self._total_ms = 0.0
429
556
 
430
557
  def snapshot(self) -> dict[str, Any]:
431
558
  """{count, dominant_error_type, by_type}; count 0 / None when clean.
@@ -440,6 +567,15 @@ class TickErrorTally:
440
567
  dominant = max(by_type, key=by_type.__getitem__) if by_type else None
441
568
  return {"count": count, "dominant_error_type": dominant, "by_type": by_type}
442
569
 
570
+ def call_snapshot(self) -> dict[str, Any]:
571
+ """{count, total_ms, calls}. `count` and `total_ms` are whole; `calls` may be cut."""
572
+ with self._lock:
573
+ return {
574
+ "count": self._call_count,
575
+ "total_ms": round(self._total_ms, 1),
576
+ "calls": [dict(call) for call in self._calls],
577
+ }
578
+
443
579
 
444
580
  # --------------------------------------------------------------------------
445
581
  # Transport-failure classification — which errors mean "the session is dead".
@@ -670,7 +806,11 @@ class SenpiMcpClient:
670
806
 
671
807
  used = self._ensure_connected(name, deadline)
672
808
  result = self._run(used.session.call_tool(name, arguments=args), name, deadline)
673
- return _cast_market_numerics(name, self._unwrap(name, result), args)
809
+ value = _cast_market_numerics(name, self._unwrap(name, result), args)
810
+ # Recorded after the unwrap, so a call that returned an `isError` result is
811
+ # counted on the failure path below and never twice.
812
+ self.tick_errors.record_call(name, round((time.monotonic() - started) * 1000, 1), args)
813
+ return value
674
814
  except Exception as exc:
675
815
  # Catch every failure — mutation block, missing creds, the lazy
676
816
  # `import mcp`, transport errors, timeouts, tool `isError` — tally
@@ -691,13 +831,15 @@ class SenpiMcpClient:
691
831
  # counts under the SAME name, so the dominant type a blind tick
692
832
  # reports to /errors matches the senpi_mcp_error lines beside it.
693
833
  error_type = "TimeoutError" if timed_out else type(primary).__name__
834
+ elapsed_ms = round((time.monotonic() - started) * 1000, 1)
694
835
  self.tick_errors.record(error_type)
836
+ self.tick_errors.record_call(name, elapsed_ms, args, error_type)
695
837
  log_event(
696
838
  "senpi_mcp_error",
697
839
  tool=name,
698
840
  error_type=error_type,
699
841
  error=str(primary)[:500],
700
- elapsed_ms=round((time.monotonic() - started) * 1000, 1),
842
+ elapsed_ms=elapsed_ms,
701
843
  **({"leaf_count": len(leaves)} if len(leaves) > 1 else {}),
702
844
  **({"timeout_seconds": self._call_timeout} if timed_out else {}),
703
845
  **({"session_rebuilt": True} if rebuilt else {}),
@@ -0,0 +1,130 @@
1
+ """Per-tick observability facts the scaffold hands to the intake.
2
+
3
+ A scanner tick produces nothing on roughly 99% of ticks, so the only thing a
4
+ live screen can show about a working box is what the tick itself reports. These
5
+ facts are assembled here, once per tick, and ride the POST the delivery sink
6
+ already makes every tick — `/signals` on a clean tick, `/errors` on a failed
7
+ one. There is no second channel and no extra request.
8
+
9
+ ABSENT IS NOT ZERO. Every field is omitted when its value is unknown, never
10
+ defaulted. A `candidate_count` of 0 is a scanner that found nothing; an absent
11
+ one would be a scanner that did not say. The distinction has to survive from
12
+ here to the screen, so nothing in this module substitutes one for the other.
13
+
14
+ The state projection is the LAST COMMITTED row, which makes it stale by one tick
15
+ whenever the current tick failed. That is deliberate: a tick that could not
16
+ commit is exactly when the last agreed state is worth seeing.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from typing import Any, Optional
22
+
23
+
24
+ # Per-value character ceiling inside a projected row. An author may park a whole
25
+ # serialised payload in one state key; this bounds what one tick can put on the
26
+ # wire without bounding what the author may store.
27
+ MAX_STATE_VALUE_CHARS = 1024
28
+
29
+ # Keys projected out of one state row. Bounds an author's open-ended row the way
30
+ # the runtime bounds an open attribute bag.
31
+ MAX_STATE_KEYS = 32
32
+
33
+
34
+ def _is_scalar(value: Any) -> bool:
35
+ """A value a consumer can parse: str, bool, or a FINITE number.
36
+
37
+ bool is checked before the numeric branch only implicitly — it is accepted
38
+ either way. NaN and the infinities are rejected: they are not JSON, and a
39
+ consumer reading one has no way to tell it from a real measurement.
40
+ """
41
+ if isinstance(value, bool) or isinstance(value, str):
42
+ return True
43
+ if isinstance(value, (int, float)):
44
+ return value == value and value not in (float("inf"), float("-inf"))
45
+ return False
46
+
47
+
48
+ def project_state_row(row: Any) -> dict:
49
+ """The scalar fields of one state row, capped and bounded.
50
+
51
+ Returns {} for anything that is not a dict — a row the author replaced with
52
+ a list or a string projects to nothing rather than raising inside the loop.
53
+ """
54
+ if not isinstance(row, dict):
55
+ return {}
56
+ projected: dict = {}
57
+ for key in list(row.keys())[:MAX_STATE_KEYS]:
58
+ if not isinstance(key, str):
59
+ continue
60
+ value = row[key]
61
+ if not _is_scalar(value):
62
+ continue
63
+ projected[key] = value[:MAX_STATE_VALUE_CHARS] if isinstance(value, str) else value
64
+ return projected
65
+
66
+
67
+ def count_changed(current: dict, previous: Optional[dict]) -> int:
68
+ """How many projected fields moved since the previously reported row.
69
+
70
+ A first row counts every field as changed: nothing was known before it, so
71
+ every value is new information. A key that disappeared counts too — its
72
+ absence is a change the operator should see.
73
+ """
74
+ if previous is None:
75
+ return len(current)
76
+ keys = set(current) | set(previous)
77
+ return sum(1 for key in keys if current.get(key) != previous.get(key))
78
+
79
+
80
+ def build_tick_facts(
81
+ *,
82
+ tick_id: str,
83
+ status: str,
84
+ candidate_count: int,
85
+ duration_ms: int,
86
+ commit: Optional[str],
87
+ quiet_ticks: int,
88
+ lag_ms: Optional[int],
89
+ mcp_snapshot: Optional[dict],
90
+ call_snapshot: Optional[dict],
91
+ state_row: Any,
92
+ state_fields: Optional[int],
93
+ state_changed: Optional[int],
94
+ ) -> dict:
95
+ """Assemble one tick's facts for the wire.
96
+
97
+ `mcp_snapshot` / `call_snapshot` are the MCP boundary's own per-tick tallies,
98
+ or None when the tick ran without an MCP client — which is not the same as a
99
+ tick that made no calls, so both keys are then left off entirely.
100
+ """
101
+ facts: dict = {
102
+ "tick_id": tick_id,
103
+ "status": status,
104
+ "candidate_count": candidate_count,
105
+ "duration_ms": duration_ms,
106
+ # Always known: the loop either commits, rolls back, or fails to persist.
107
+ **({"commit": commit} if commit else {}),
108
+ # Zero is a real answer here (this tick produced a signal), so it is sent.
109
+ "quiet_ticks": quiet_ticks,
110
+ }
111
+ # Unknown on the very first tick of a process: there is no previous tick to
112
+ # measure the gap against, and a 0 would claim the scanner was exactly on time.
113
+ if lag_ms is not None:
114
+ facts["lag_ms"] = lag_ms
115
+ if mcp_snapshot is not None:
116
+ # Every failure the boundary saw this tick. The overwhelming case is a failure the
117
+ # author's own `except` caught and turned into an empty return — the one thing that
118
+ # otherwise leaves no trace at all. A failure that escaped scan() is in the count too,
119
+ # and needs no separating out: it already failed the tick, so `status` names it.
120
+ facts["mcp_failures"] = int(mcp_snapshot.get("count") or 0)
121
+ if call_snapshot is not None:
122
+ facts["mcp_ms"] = call_snapshot.get("total_ms", 0)
123
+ facts["mcp_calls"] = call_snapshot.get("calls", [])
124
+ projected = project_state_row(state_row)
125
+ if state_fields is not None:
126
+ facts["state"] = projected
127
+ facts["state_fields"] = state_fields
128
+ if state_changed is not None:
129
+ facts["state_changed"] = state_changed
130
+ return facts