@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.
- package/dist/actions/action-events.d.ts +21 -7
- package/dist/actions/action-events.d.ts.map +1 -1
- package/dist/actions/action-events.js +54 -26
- package/dist/actions/action-events.js.map +1 -1
- package/dist/actions/action-runtime-module.d.ts.map +1 -1
- package/dist/actions/action-runtime-module.js +38 -7
- package/dist/actions/action-runtime-module.js.map +1 -1
- package/dist/actions/close-position/close-position.action.d.ts.map +1 -1
- package/dist/actions/close-position/close-position.action.js +122 -15
- package/dist/actions/close-position/close-position.action.js.map +1 -1
- package/dist/actions/decision-engine.d.ts.map +1 -1
- package/dist/actions/decision-engine.js +15 -2
- package/dist/actions/decision-engine.js.map +1 -1
- package/dist/actions/event-derivation.d.ts +71 -2
- package/dist/actions/event-derivation.d.ts.map +1 -1
- package/dist/actions/event-derivation.js +251 -8
- package/dist/actions/event-derivation.js.map +1 -1
- package/dist/actions/open-position/open-position.action.d.ts +12 -2
- package/dist/actions/open-position/open-position.action.d.ts.map +1 -1
- package/dist/actions/open-position/open-position.action.js +141 -41
- package/dist/actions/open-position/open-position.action.js.map +1 -1
- package/dist/actions/position-tracker/position-tracker.action.d.ts +2 -1
- package/dist/actions/position-tracker/position-tracker.action.d.ts.map +1 -1
- package/dist/actions/position-tracker/position-tracker.action.js +62 -17
- package/dist/actions/position-tracker/position-tracker.action.js.map +1 -1
- package/dist/actions/skipped-signal-results.d.ts +25 -0
- package/dist/actions/skipped-signal-results.d.ts.map +1 -0
- package/dist/actions/skipped-signal-results.js +71 -0
- package/dist/actions/skipped-signal-results.js.map +1 -0
- package/dist/actions/store/types.d.ts +9 -2
- package/dist/actions/store/types.d.ts.map +1 -1
- package/dist/actions/store/types.js.map +1 -1
- package/dist/actions/types.d.ts +13 -0
- package/dist/actions/types.d.ts.map +1 -1
- package/dist/actions/types.js +13 -0
- package/dist/actions/types.js.map +1 -1
- package/dist/cli/guide-commands.d.ts.map +1 -1
- package/dist/cli/guide-commands.js +3 -0
- package/dist/cli/guide-commands.js.map +1 -1
- package/dist/dsl/engine-client/event-mapping.d.ts.map +1 -1
- package/dist/dsl/engine-client/event-mapping.js +14 -0
- package/dist/dsl/engine-client/event-mapping.js.map +1 -1
- package/dist/dsl/events/build-closed-event.d.ts +16 -1
- package/dist/dsl/events/build-closed-event.d.ts.map +1 -1
- package/dist/dsl/events/build-closed-event.js +6 -0
- package/dist/dsl/events/build-closed-event.js.map +1 -1
- package/dist/dsl/events/handlers.d.ts +7 -0
- package/dist/dsl/events/handlers.d.ts.map +1 -1
- package/dist/dsl/events/handlers.js +37 -7
- package/dist/dsl/events/handlers.js.map +1 -1
- package/dist/dsl/monitor/create-monitor.d.ts +6 -2
- package/dist/dsl/monitor/create-monitor.d.ts.map +1 -1
- package/dist/dsl/monitor/create-monitor.js +15 -9
- package/dist/dsl/monitor/create-monitor.js.map +1 -1
- package/dist/dsl/monitor/dsl-failure-events.d.ts +2 -0
- package/dist/dsl/monitor/dsl-failure-events.d.ts.map +1 -1
- package/dist/dsl/monitor/dsl-failure-events.js +2 -1
- package/dist/dsl/monitor/dsl-failure-events.js.map +1 -1
- package/dist/dsl/monitor/process-one-position.d.ts +1 -0
- package/dist/dsl/monitor/process-one-position.d.ts.map +1 -1
- package/dist/dsl/monitor/process-one-position.js +59 -25
- package/dist/dsl/monitor/process-one-position.js.map +1 -1
- package/dist/dsl/plugin/index.d.ts +8 -2
- package/dist/dsl/plugin/index.d.ts.map +1 -1
- package/dist/dsl/plugin/index.js +8 -6
- package/dist/dsl/plugin/index.js.map +1 -1
- package/dist/health/types.d.ts +13 -0
- package/dist/health/types.d.ts.map +1 -1
- package/dist/health/types.js +12 -0
- package/dist/health/types.js.map +1 -1
- package/dist/index.d.ts +27 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +251 -39
- package/dist/index.js.map +1 -1
- package/dist/runtime/action-registry.d.ts.map +1 -1
- package/dist/runtime/action-registry.js +4 -3
- package/dist/runtime/action-registry.js.map +1 -1
- package/dist/runtime/domain-events.d.ts.map +1 -1
- package/dist/runtime/domain-events.js +7 -0
- package/dist/runtime/domain-events.js.map +1 -1
- package/dist/runtime/lifecycle-events.d.ts +9 -1
- package/dist/runtime/lifecycle-events.d.ts.map +1 -1
- package/dist/runtime/lifecycle-events.js +11 -2
- package/dist/runtime/lifecycle-events.js.map +1 -1
- package/dist/runtime/reconcile-run.d.ts +13 -3
- package/dist/runtime/reconcile-run.d.ts.map +1 -1
- package/dist/runtime/reconcile-run.js +4 -3
- package/dist/runtime/reconcile-run.js.map +1 -1
- package/dist/runtime/reconcile-state.d.ts +8 -0
- package/dist/runtime/reconcile-state.d.ts.map +1 -1
- package/dist/runtime/reconcile-state.js +4 -1
- package/dist/runtime/reconcile-state.js.map +1 -1
- package/dist/runtime/run.d.ts +1 -0
- package/dist/runtime/run.d.ts.map +1 -1
- package/dist/runtime/run.js.map +1 -1
- package/dist/runtime/runtime-schema.js +3 -3
- package/dist/runtime/runtime-schema.js.map +1 -1
- package/dist/runtime/runtime.d.ts +1 -0
- package/dist/runtime/runtime.d.ts.map +1 -1
- package/dist/runtime/runtime.js +27 -3
- package/dist/runtime/runtime.js.map +1 -1
- package/dist/runtime/supervisor/long-running-supervisor.d.ts +13 -0
- package/dist/runtime/supervisor/long-running-supervisor.d.ts.map +1 -1
- package/dist/runtime/supervisor/long-running-supervisor.js +1 -0
- package/dist/runtime/supervisor/long-running-supervisor.js.map +1 -1
- package/dist/runtime/validate-dsl-position-tracking.d.ts.map +1 -1
- package/dist/runtime/validate-dsl-position-tracking.js +2 -1
- package/dist/runtime/validate-dsl-position-tracking.js.map +1 -1
- package/dist/runtime-api/handlers/ingest-signals.d.ts.map +1 -1
- package/dist/runtime-api/handlers/ingest-signals.js +1 -0
- package/dist/runtime-api/handlers/ingest-signals.js.map +1 -1
- package/dist/runtime-api/handlers/types.d.ts +5 -0
- package/dist/runtime-api/handlers/types.d.ts.map +1 -1
- package/dist/runtime-api/intake/ingest-request.d.ts +17 -0
- package/dist/runtime-api/intake/ingest-request.d.ts.map +1 -0
- package/dist/runtime-api/intake/ingest-request.js +34 -0
- package/dist/runtime-api/intake/ingest-request.js.map +1 -0
- package/dist/runtime-api/intake/intake-events.d.ts +1 -1
- package/dist/runtime-api/intake/intake-events.d.ts.map +1 -1
- package/dist/runtime-api/intake/intake-events.js +9 -3
- package/dist/runtime-api/intake/intake-events.js.map +1 -1
- package/dist/runtime-api/intake/intake-mount.d.ts.map +1 -1
- package/dist/runtime-api/intake/intake-mount.js +4 -19
- package/dist/runtime-api/intake/intake-mount.js.map +1 -1
- package/dist/runtime-api/intake/process-error-post.d.ts +6 -1
- package/dist/runtime-api/intake/process-error-post.d.ts.map +1 -1
- package/dist/runtime-api/intake/process-error-post.js +5 -1
- package/dist/runtime-api/intake/process-error-post.js.map +1 -1
- package/dist/runtime-api/intake/process-signal-post.d.ts +7 -1
- package/dist/runtime-api/intake/process-signal-post.d.ts.map +1 -1
- package/dist/runtime-api/intake/process-signal-post.js +5 -0
- package/dist/runtime-api/intake/process-signal-post.js.map +1 -1
- package/dist/runtime-api/intake/scaffold-error-router.js.map +1 -1
- package/dist/runtime-api/intake/scaffold-tick-router.d.ts +39 -0
- package/dist/runtime-api/intake/scaffold-tick-router.d.ts.map +1 -0
- package/dist/runtime-api/intake/scaffold-tick-router.js +122 -0
- package/dist/runtime-api/intake/scaffold-tick-router.js.map +1 -0
- package/dist/runtime-api/intake/scanner-launcher.d.ts.map +1 -1
- package/dist/runtime-api/intake/scanner-launcher.js +15 -0
- package/dist/runtime-api/intake/scanner-launcher.js.map +1 -1
- package/dist/runtime-api/intake/types.d.ts +55 -0
- package/dist/runtime-api/intake/types.d.ts.map +1 -1
- package/dist/runtime-api/routes/signals.d.ts +2 -0
- package/dist/runtime-api/routes/signals.d.ts.map +1 -1
- package/dist/runtime-api/routes/signals.js +5 -1
- package/dist/runtime-api/routes/signals.js.map +1 -1
- package/dist/runtime-api/routes/signals.schema.d.ts +43 -0
- package/dist/runtime-api/routes/signals.schema.d.ts.map +1 -1
- package/dist/runtime-api/routes/signals.schema.js +43 -0
- package/dist/runtime-api/routes/signals.schema.js.map +1 -1
- package/dist/runtime-api/server.d.ts +7 -1
- package/dist/runtime-api/server.d.ts.map +1 -1
- package/dist/runtime-api/server.js +1 -0
- package/dist/runtime-api/server.js.map +1 -1
- package/dist/scanners/engine/engine.d.ts +22 -1
- package/dist/scanners/engine/engine.d.ts.map +1 -1
- package/dist/scanners/engine/engine.js +78 -11
- package/dist/scanners/engine/engine.js.map +1 -1
- package/dist/scanners/engine/lifecycle.d.ts +7 -1
- package/dist/scanners/engine/lifecycle.d.ts.map +1 -1
- package/dist/scanners/events.d.ts +4 -0
- package/dist/scanners/events.d.ts.map +1 -1
- package/dist/scanners/events.js +2 -0
- package/dist/scanners/events.js.map +1 -1
- package/dist/scanners/external/scaffold/delivery.py +194 -25
- package/dist/scanners/external/scaffold/scaffold.py +172 -31
- package/dist/scanners/external/scaffold/senpi_mcp.py +150 -8
- package/dist/scanners/external/scaffold/tick_facts.py +130 -0
- package/dist/scanners/external/scaffold/ulid.py +58 -0
- package/dist/scanners/external-scanner-receiver.d.ts +17 -0
- package/dist/scanners/external-scanner-receiver.d.ts.map +1 -1
- package/dist/scanners/external-scanner-receiver.js +8 -0
- package/dist/scanners/external-scanner-receiver.js.map +1 -1
- package/dist/scanners/runtime-module.d.ts +1 -0
- package/dist/scanners/runtime-module.d.ts.map +1 -1
- package/dist/scanners/runtime-module.js +66 -9
- package/dist/scanners/runtime-module.js.map +1 -1
- package/dist/telemetry/attributes.d.ts +20 -0
- package/dist/telemetry/attributes.d.ts.map +1 -1
- package/dist/telemetry/attributes.js +24 -0
- package/dist/telemetry/attributes.js.map +1 -1
- package/dist/telemetry/event-builders.d.ts +81 -6
- package/dist/telemetry/event-builders.d.ts.map +1 -1
- package/dist/telemetry/event-builders.js +141 -17
- package/dist/telemetry/event-builders.js.map +1 -1
- package/dist/telemetry/event-store.d.ts +6 -0
- package/dist/telemetry/event-store.d.ts.map +1 -1
- package/dist/telemetry/event-store.js +12 -8
- package/dist/telemetry/event-store.js.map +1 -1
- package/dist/telemetry/live-telemetry.d.ts +238 -0
- package/dist/telemetry/live-telemetry.d.ts.map +1 -0
- package/dist/telemetry/live-telemetry.js +853 -0
- package/dist/telemetry/live-telemetry.js.map +1 -0
- package/dist/telemetry/metrics.d.ts +14 -2
- package/dist/telemetry/metrics.d.ts.map +1 -1
- package/dist/telemetry/metrics.js +19 -4
- package/dist/telemetry/metrics.js.map +1 -1
- package/dist/telemetry/scanner-stats.d.ts +143 -0
- package/dist/telemetry/scanner-stats.d.ts.map +1 -0
- package/dist/telemetry/scanner-stats.js +426 -0
- package/dist/telemetry/scanner-stats.js.map +1 -0
- package/dist/telemetry/scanner-tick-feed.d.ts +87 -0
- package/dist/telemetry/scanner-tick-feed.d.ts.map +1 -0
- package/dist/telemetry/scanner-tick-feed.js +48 -0
- package/dist/telemetry/scanner-tick-feed.js.map +1 -0
- package/dist/telemetry/spans/redact-pii.d.ts.map +1 -1
- package/dist/telemetry/spans/redact-pii.js +18 -2
- package/dist/telemetry/spans/redact-pii.js.map +1 -1
- package/dist/telemetry/strategy-topology.d.ts +99 -0
- package/dist/telemetry/strategy-topology.d.ts.map +1 -0
- package/dist/telemetry/strategy-topology.js +93 -0
- package/dist/telemetry/strategy-topology.js.map +1 -0
- package/dist/telemetry/telemetry-publisher.d.ts +76 -0
- package/dist/telemetry/telemetry-publisher.d.ts.map +1 -0
- package/dist/telemetry/telemetry-publisher.js +247 -0
- package/dist/telemetry/telemetry-publisher.js.map +1 -0
- package/dist/telemetry/tick-frame.d.ts +212 -0
- package/dist/telemetry/tick-frame.d.ts.map +1 -0
- package/dist/telemetry/tick-frame.js +421 -0
- package/dist/telemetry/tick-frame.js.map +1 -0
- package/dist/telemetry/timeline-buffer.d.ts +95 -0
- package/dist/telemetry/timeline-buffer.d.ts.map +1 -0
- package/dist/telemetry/timeline-buffer.js +237 -0
- package/dist/telemetry/timeline-buffer.js.map +1 -0
- package/dist/telemetry/tracing.d.ts +9 -0
- package/dist/telemetry/tracing.d.ts.map +1 -1
- package/dist/telemetry/tracing.js +36 -0
- package/dist/telemetry/tracing.js.map +1 -1
- package/dist/types/dsl/index.d.ts +11 -0
- package/dist/types/dsl/index.d.ts.map +1 -1
- package/dist/types/scanner.d.ts +15 -0
- package/dist/types/scanner.d.ts.map +1 -1
- package/dist/utils/event-catalog.d.ts +166 -0
- package/dist/utils/event-catalog.d.ts.map +1 -1
- package/dist/utils/event-catalog.js +18 -0
- package/dist/utils/event-catalog.js.map +1 -1
- package/dist/utils/logger.d.ts +4 -1
- package/dist/utils/logger.d.ts.map +1 -1
- package/dist/utils/logger.js.map +1 -1
- package/dist/utils/telemetry-required-log.d.ts +60 -0
- package/dist/utils/telemetry-required-log.d.ts.map +1 -0
- package/dist/utils/telemetry-required-log.js +105 -0
- package/dist/utils/telemetry-required-log.js.map +1 -0
- package/dist/utils/ulid.d.ts +9 -0
- package/dist/utils/ulid.d.ts.map +1 -0
- package/dist/utils/ulid.js +43 -0
- package/dist/utils/ulid.js.map +1 -0
- package/dist/validate/recipe-checks.d.ts.map +1 -1
- package/dist/validate/recipe-checks.js +2 -2
- package/dist/validate/recipe-checks.js.map +1 -1
- package/openclaw.plugin.json +1 -1
- 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.
|
|
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 = {
|
|
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
|
-
|
|
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
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
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
|
-
|
|
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
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
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(
|
|
458
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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=
|
|
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
|