@senpi/runtime 3.0.109 → 3.0.112
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/cli/guide-commands.d.ts.map +1 -1
- package/dist/cli/guide-commands.js +11 -0
- package/dist/cli/guide-commands.js.map +1 -1
- package/dist/cli/senpi-commands.d.ts.map +1 -1
- package/dist/cli/senpi-commands.js +70 -0
- package/dist/cli/senpi-commands.js.map +1 -1
- package/dist/constants/dsl/index.d.ts +15 -0
- package/dist/constants/dsl/index.d.ts.map +1 -1
- package/dist/constants/dsl/index.js +15 -0
- package/dist/constants/dsl/index.js.map +1 -1
- package/dist/dsl/monitor/process-one-position.d.ts.map +1 -1
- package/dist/dsl/monitor/process-one-position.js +13 -4
- package/dist/dsl/monitor/process-one-position.js.map +1 -1
- package/dist/index.d.ts +11 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +61 -1
- package/dist/index.js.map +1 -1
- package/dist/runtime/auto-update/coordinator.d.ts +15 -1
- package/dist/runtime/auto-update/coordinator.d.ts.map +1 -1
- package/dist/runtime/auto-update/coordinator.js +53 -0
- package/dist/runtime/auto-update/coordinator.js.map +1 -1
- package/dist/runtime/auto-update/types.d.ts +14 -0
- package/dist/runtime/auto-update/types.d.ts.map +1 -1
- package/dist/runtime/auto-update/types.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-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/types.d.ts +49 -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 +3 -0
- package/dist/runtime-api/routes/signals.js.map +1 -1
- package/dist/runtime-api/routes/signals.schema.d.ts +42 -0
- package/dist/runtime-api/routes/signals.schema.d.ts.map +1 -1
- package/dist/runtime-api/routes/signals.schema.js +37 -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/external/scaffold/delivery.py +64 -17
- package/dist/scanners/external/scaffold/scaffold.py +128 -40
- package/dist/scanners/external/scaffold/senpi_mcp.py +88 -8
- package/dist/scanners/external/scaffold/tick_facts.py +130 -0
- package/dist/scanners/runtime-module.d.ts.map +1 -1
- package/dist/scanners/runtime-module.js +8 -1
- package/dist/scanners/runtime-module.js.map +1 -1
- package/dist/scripts/run-dsl-standalone-mock.js +6 -2
- package/dist/scripts/run-dsl-standalone-mock.js.map +1 -1
- package/dist/senpi/client.d.ts +3 -1
- package/dist/senpi/client.d.ts.map +1 -1
- package/dist/senpi/client.js +12 -7
- package/dist/senpi/client.js.map +1 -1
- package/dist/skills-manager/manifest.d.ts.map +1 -1
- package/dist/skills-manager/manifest.js +1 -0
- package/dist/skills-manager/manifest.js.map +1 -1
- package/dist/skills-manager/skills-manifest.json +1 -0
- package/dist/strategy/strategy-state.d.ts +3 -1
- package/dist/strategy/strategy-state.d.ts.map +1 -1
- package/dist/strategy/strategy-state.js +2 -2
- package/dist/strategy/strategy-state.js.map +1 -1
- package/dist/telemetry/live-telemetry.d.ts +54 -8
- package/dist/telemetry/live-telemetry.d.ts.map +1 -1
- package/dist/telemetry/live-telemetry.js +177 -7
- package/dist/telemetry/live-telemetry.js.map +1 -1
- 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/tick-frame.d.ts +197 -0
- package/dist/telemetry/tick-frame.d.ts.map +1 -0
- package/dist/telemetry/tick-frame.js +347 -0
- package/dist/telemetry/tick-frame.js.map +1 -0
- package/dist/types/strategy.d.ts +4 -1
- package/dist/types/strategy.d.ts.map +1 -1
- package/dist/utils/telemetry-required-log.d.ts +13 -0
- package/dist/utils/telemetry-required-log.d.ts.map +1 -1
- package/dist/utils/telemetry-required-log.js +5 -1
- package/dist/utils/telemetry-required-log.js.map +1 -1
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
|
@@ -19,7 +19,7 @@ import type { RuntimeHandle } from "../runtime/run.js";
|
|
|
19
19
|
import type { Logger } from "../utils/logger.js";
|
|
20
20
|
import type { ApiConfig } from "./config.js";
|
|
21
21
|
import type { IntakeContext } from "./intake/intake-context.js";
|
|
22
|
-
import type { IntakeSignal } from "./intake/types.js";
|
|
22
|
+
import type { IntakeSignal, IntakeTickFacts } from "./intake/types.js";
|
|
23
23
|
/**
|
|
24
24
|
* Dependencies passed to {@link createApi}.
|
|
25
25
|
*/
|
|
@@ -53,6 +53,12 @@ export interface CreateApiDeps {
|
|
|
53
53
|
* a no-op (which is what made the `/errors` boundary silent before B1 Task 2).
|
|
54
54
|
*/
|
|
55
55
|
onError?: (scannerId: string, error: Record<string, unknown>) => void;
|
|
56
|
+
/**
|
|
57
|
+
* Called once per producer tick, from whichever of the two intake routes that tick took, with
|
|
58
|
+
* the facts the producer measured about the run itself. Feeds the live per-tick level and
|
|
59
|
+
* nothing else. Only used when `intakeContext` is present; defaults to a no-op.
|
|
60
|
+
*/
|
|
61
|
+
onTick?: (scannerId: string, tick: IntakeTickFacts) => void;
|
|
56
62
|
}
|
|
57
63
|
/**
|
|
58
64
|
* Artifacts returned by {@link createApi}.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../../src/runtime-api/server.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH,OAAgB,EAAE,KAAK,eAAe,EAAE,MAAM,SAAS,CAAC;AAKxD,OAAO,EAAE,eAAe,EAAE,MAAM,gBAAgB,CAAC;AACjD,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,mBAAmB,CAAC;AACvD,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,oBAAoB,CAAC;AACjD,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AAC7C,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,4BAA4B,CAAC;AAChE,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAC;
|
|
1
|
+
{"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../../src/runtime-api/server.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH,OAAgB,EAAE,KAAK,eAAe,EAAE,MAAM,SAAS,CAAC;AAKxD,OAAO,EAAE,eAAe,EAAE,MAAM,gBAAgB,CAAC;AACjD,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,mBAAmB,CAAC;AACvD,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,oBAAoB,CAAC;AACjD,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AAC7C,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,4BAA4B,CAAC;AAChE,OAAO,KAAK,EAAE,YAAY,EAAE,eAAe,EAAE,MAAM,mBAAmB,CAAC;AAIvE;;GAEG;AACH,MAAM,WAAW,aAAa;IAC5B,kFAAkF;IAClF,cAAc,EAAE,GAAG,CAAC,MAAM,EAAE,aAAa,CAAC,CAAC;IAC3C,4EAA4E;IAC5E,SAAS,EAAE,SAAS,CAAC;IACrB,mFAAmF;IACnF,MAAM,EAAE,MAAM,CAAC;IACf;;;OAGG;IACH,OAAO,EAAE,MAAM,CAAC;IAChB;;;OAGG;IACH,aAAa,CAAC,EAAE,aAAa,CAAC;IAC9B;;;;OAIG;IACH,UAAU,CAAC,EAAE,CAAC,MAAM,EAAE,YAAY,EAAE,aAAa,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,KAAK,IAAI,CAAC;IACtF;;;;;OAKG;IACH,OAAO,CAAC,EAAE,CAAC,SAAS,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAK,IAAI,CAAC;IACtE;;;;OAIG;IACH,MAAM,CAAC,EAAE,CAAC,SAAS,EAAE,MAAM,EAAE,IAAI,EAAE,eAAe,KAAK,IAAI,CAAC;CAC7D;AAED;;;;;;;;;GASG;AACH,MAAM,WAAW,YAAY;IAC3B,OAAO,EAAE,eAAe,CAAC;IACzB,SAAS,EAAE,UAAU,CAAC,OAAO,eAAe,CAAC,CAAC;IAC9C,QAAQ,EAAE,MAAM,CAAC;CAClB;AAED;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,wBAAgB,SAAS,CAAC,IAAI,EAAE,aAAa,GAAG,YAAY,CAuF3D"}
|
|
@@ -100,6 +100,7 @@ export function createApi(deps) {
|
|
|
100
100
|
updateLiveness: (id, now) => deps.intakeContext.liveness.touch(id, now),
|
|
101
101
|
onAccepted: deps.onAccepted ?? (() => { }),
|
|
102
102
|
onError: deps.onError ?? (() => { }),
|
|
103
|
+
onTick: deps.onTick ?? (() => { }),
|
|
103
104
|
maxItemsPerSignalsRequest: deps.apiConfig.maxItemsPerSignalsRequest,
|
|
104
105
|
logger: deps.logger,
|
|
105
106
|
});
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"server.js","sourceRoot":"","sources":["../../src/runtime-api/server.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH,OAAO,OAAiC,MAAM,SAAS,CAAC;AACxD,OAAO,EAAE,oBAAoB,EAAE,MAAM,qBAAqB,CAAC;AAC3D,OAAO,EAAE,kBAAkB,EAAE,MAAM,mBAAmB,CAAC;AACvD,OAAO,EAAE,mBAAmB,EAAE,MAAM,oBAAoB,CAAC;AACzD,OAAO,EAAE,kBAAkB,EAAE,MAAM,mBAAmB,CAAC;AACvD,OAAO,EAAE,eAAe,EAAE,MAAM,gBAAgB,CAAC;AAMjD,OAAO,EAAE,SAAS,EAAE,MAAM,gCAAgC,CAAC;AAC3D,OAAO,EAAE,wBAAwB,EAAE,MAAM,2BAA2B,CAAC;
|
|
1
|
+
{"version":3,"file":"server.js","sourceRoot":"","sources":["../../src/runtime-api/server.ts"],"names":[],"mappings":"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"}
|
|
@@ -11,8 +11,10 @@ build_delivery_sink(
|
|
|
11
11
|
clock: Callable[[], float] = time.time,
|
|
12
12
|
) -> sink
|
|
13
13
|
|
|
14
|
-
The returned sink is a callable: sink(candidates, tick_id=None)
|
|
15
|
-
|
|
14
|
+
The returned sink is a callable: sink(candidates, tick_id=None, tick=None)
|
|
15
|
+
`tick` is the scaffold's per-tick observability facts; the sink stamps the journal's
|
|
16
|
+
pending count onto them (it owns the journal) and rides them on the POST it already
|
|
17
|
+
makes. On each call it:
|
|
16
18
|
1. Replays journal on first call (if file-backed), re-queuing valid undelivered entries
|
|
17
19
|
2. Flushes still-valid undelivered entries from prior ticks (retry)
|
|
18
20
|
3. Wraps this tick's candidates into wire signals (skips invalid ones), stamping the
|
|
@@ -22,8 +24,10 @@ On each call it:
|
|
|
22
24
|
6. Interprets the response: accepted/expired/superseded/rejected = delivered;
|
|
23
25
|
transport failure or non-success envelope leaves entry queued for retry
|
|
24
26
|
|
|
25
|
-
sink.post_error(error: dict) -> None
|
|
26
|
-
POSTs {scanner_id, error} to <intake_url>/errors. Fire-and-forget.
|
|
27
|
+
sink.post_error(error: dict, tick: dict | None = None) -> None
|
|
28
|
+
POSTs {scanner_id, error} to <intake_url>/errors. Fire-and-forget. A failed tick's
|
|
29
|
+
facts ride here for the same reason they ride /signals on a clean one: this is the
|
|
30
|
+
POST that tick already makes.
|
|
27
31
|
|
|
28
32
|
Module constants:
|
|
29
33
|
SIGNALS_PATH = "/signals"
|
|
@@ -76,6 +80,10 @@ def build_delivery_sink(
|
|
|
76
80
|
# is not written again. Never cleared: an intake that refuses the field once would refuse it
|
|
77
81
|
# again, so re-arming would buy back the guaranteed-400 round-trip on every later tick.
|
|
78
82
|
intake_predates_tick_id = [False]
|
|
83
|
+
# The same latch for the body-level `tick` facts. Separate from the one above because
|
|
84
|
+
# they are separate fields on separate levels of the body: an intake may know one and
|
|
85
|
+
# not the other, and stripping both on either refusal would throw away what still works.
|
|
86
|
+
intake_predates_tick_facts = [False]
|
|
79
87
|
|
|
80
88
|
# ------------------------------------------------------------------
|
|
81
89
|
# Internal helpers
|
|
@@ -221,9 +229,23 @@ def build_delivery_sink(
|
|
|
221
229
|
for sig in signals
|
|
222
230
|
]
|
|
223
231
|
|
|
224
|
-
def
|
|
225
|
-
"""
|
|
232
|
+
def _with_pending(tick: dict | None) -> dict | None:
|
|
233
|
+
"""The tick facts plus the journal's BACKLOG, which only this closure knows.
|
|
234
|
+
|
|
235
|
+
Counted before this tick's own candidates are journalled, so the number is
|
|
236
|
+
signals from EARLIER ticks that the intake never acknowledged — a scanner
|
|
237
|
+
producing work that is not arriving. Counting this tick's own signals too
|
|
238
|
+
would put a 1 on every healthy signal-bearing tick and say nothing.
|
|
239
|
+
"""
|
|
240
|
+
if tick is None:
|
|
241
|
+
return None
|
|
242
|
+
return {**tick, "journal_pending": len(journal.pending())}
|
|
243
|
+
|
|
244
|
+
def _post_signals(signals: list[dict], tick: dict | None = None) -> tuple[int, Any]:
|
|
245
|
+
"""POST the signals list to the intake, with this tick's facts when it has any."""
|
|
226
246
|
body = {"scanner_id": scanner_id, "signals": signals}
|
|
247
|
+
if tick is not None and not intake_predates_tick_facts[0]:
|
|
248
|
+
body["tick"] = tick
|
|
227
249
|
# Log the exact wire shape so a 400 can be matched to the field that broke.
|
|
228
250
|
if signals:
|
|
229
251
|
log_event(
|
|
@@ -315,7 +337,7 @@ def build_delivery_sink(
|
|
|
315
337
|
# The sink callable
|
|
316
338
|
# ------------------------------------------------------------------
|
|
317
339
|
|
|
318
|
-
def _sink(candidates, tick_id=None) -> None:
|
|
340
|
+
def _sink(candidates, tick_id=None, tick=None) -> None:
|
|
319
341
|
tick_id = _usable_tick_id(tick_id)
|
|
320
342
|
current_tick_id[0] = tick_id
|
|
321
343
|
|
|
@@ -330,6 +352,10 @@ def build_delivery_sink(
|
|
|
330
352
|
# 1. Flush prior pending (retry candidates from older ticks)
|
|
331
353
|
prior_entries = _flush_prior_pending(now_ms)
|
|
332
354
|
|
|
355
|
+
# Read here and not below: the backlog is what EARLIER ticks left behind, and
|
|
356
|
+
# step 2 is about to journal this tick's own candidates on top of it.
|
|
357
|
+
tick_body = _with_pending(tick)
|
|
358
|
+
|
|
333
359
|
# 2. Wrap new candidates into signals
|
|
334
360
|
new_signal_pairs = _wrap_candidates(candidates, produced_at_ms, tick_id)
|
|
335
361
|
|
|
@@ -351,9 +377,10 @@ def build_delivery_sink(
|
|
|
351
377
|
all_signals.append(sig)
|
|
352
378
|
all_eids.append(eid)
|
|
353
379
|
|
|
354
|
-
# 3. POST (always, even when signals==[])
|
|
380
|
+
# 3. POST (always, even when signals==[]) — which is what makes this the channel
|
|
381
|
+
# the tick facts ride: a quiet scanner still posts, every tick.
|
|
355
382
|
try:
|
|
356
|
-
status, parsed = _post_signals(all_signals)
|
|
383
|
+
status, parsed = _post_signals(all_signals, tick_body)
|
|
357
384
|
except TransportError as exc:
|
|
358
385
|
log_event("delivery_transport_error", error=str(exc))
|
|
359
386
|
# All signals remain undelivered -> retry on next tick
|
|
@@ -367,12 +394,15 @@ def build_delivery_sink(
|
|
|
367
394
|
# the same batch without the field so one unknown key cannot strand real signals, and
|
|
368
395
|
# latch the discovery so later ticks leave the field off and stop paying the refused POST.
|
|
369
396
|
# Entries already journalled with an id still reach here and are still stripped per POST.
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
):
|
|
373
|
-
if not intake_predates_tick_id[0]:
|
|
397
|
+
sent_tick_id = any(isinstance(sig, dict) and "tick_id" in sig for sig in all_signals)
|
|
398
|
+
sent_tick_facts = tick_body is not None and not intake_predates_tick_facts[0]
|
|
399
|
+
if _is_shape_reject(status, parsed) and (sent_tick_id or sent_tick_facts):
|
|
400
|
+
if sent_tick_id and not intake_predates_tick_id[0]:
|
|
374
401
|
intake_predates_tick_id[0] = True
|
|
375
402
|
log_event("delivery_intake_predates_tick_id", scanner_id=scanner_id)
|
|
403
|
+
if sent_tick_facts:
|
|
404
|
+
intake_predates_tick_facts[0] = True
|
|
405
|
+
log_event("delivery_intake_predates_tick_facts", scanner_id=scanner_id)
|
|
376
406
|
try:
|
|
377
407
|
status, parsed = _post_signals(_without_tick_id(all_signals))
|
|
378
408
|
except TransportError as exc:
|
|
@@ -386,15 +416,32 @@ def build_delivery_sink(
|
|
|
386
416
|
# even when the retry above had to send them without it.
|
|
387
417
|
_interpret_response(status, parsed, all_eids, all_signals)
|
|
388
418
|
|
|
389
|
-
def _post_error(error: dict) -> None:
|
|
390
|
-
"""POST an error dict
|
|
391
|
-
|
|
419
|
+
def _post_error(error: dict, tick: dict | None = None) -> None:
|
|
420
|
+
"""POST an error dict, with the failed tick's facts when the caller has them.
|
|
421
|
+
|
|
422
|
+
Fire-and-forget, with one exception: an intake that refuses the body because it
|
|
423
|
+
does not know `tick` is retried once without it and latched, so a failed tick's
|
|
424
|
+
error record is never lost to a field that only telemetry wanted.
|
|
425
|
+
"""
|
|
426
|
+
tick_body = _with_pending(tick)
|
|
392
427
|
url = intake_url.rstrip("/") + ERRORS_PATH
|
|
393
428
|
# The error belongs to exactly one tick and already names it; a body that
|
|
394
429
|
# names none leaves the key off rather than borrowing the current one.
|
|
395
430
|
origin = _tick_field(error.get("tick_id")) if isinstance(error, dict) else {}
|
|
431
|
+
|
|
432
|
+
def _body(with_tick: bool) -> dict:
|
|
433
|
+
body = {"scanner_id": scanner_id, "error": error}
|
|
434
|
+
if with_tick and tick_body is not None and not intake_predates_tick_facts[0]:
|
|
435
|
+
body["tick"] = tick_body
|
|
436
|
+
return body
|
|
437
|
+
|
|
396
438
|
try:
|
|
397
|
-
|
|
439
|
+
sent_tick_facts = tick_body is not None and not intake_predates_tick_facts[0]
|
|
440
|
+
status, parsed = post_json(url, _body(True), timeout)
|
|
441
|
+
if sent_tick_facts and _is_shape_reject(status, parsed):
|
|
442
|
+
intake_predates_tick_facts[0] = True
|
|
443
|
+
log_event("delivery_intake_predates_tick_facts", scanner_id=scanner_id)
|
|
444
|
+
post_json(url, _body(False), timeout)
|
|
398
445
|
except TransportError as exc:
|
|
399
446
|
log_event("delivery_error_post_failed", error=str(exc), **origin)
|
|
400
447
|
except Exception as exc: # noqa: BLE001
|
|
@@ -43,6 +43,7 @@ from .loop_primitives import (
|
|
|
43
43
|
log_event,
|
|
44
44
|
)
|
|
45
45
|
from .state_manager import StateManager
|
|
46
|
+
from .tick_facts import build_tick_facts, count_changed, project_state_row
|
|
46
47
|
from .ulid import new_ulid
|
|
47
48
|
|
|
48
49
|
|
|
@@ -105,22 +106,22 @@ def _build_senpi_mcp() -> Any:
|
|
|
105
106
|
)
|
|
106
107
|
|
|
107
108
|
|
|
108
|
-
def
|
|
109
|
-
"""Whether `
|
|
109
|
+
def _accepts_kwarg(fn: Any, name: str) -> bool:
|
|
110
|
+
"""Whether `fn` takes `name` as a keyword argument.
|
|
110
111
|
|
|
111
112
|
Read from the signature, once, rather than calling with the keyword and retrying on
|
|
112
113
|
TypeError: a sink that raises TypeError from its own body would then be called twice and
|
|
113
|
-
deliver the same candidates twice. A
|
|
114
|
-
|
|
114
|
+
deliver the same candidates twice. A callable whose signature cannot be read keeps the
|
|
115
|
+
narrower call it has today.
|
|
115
116
|
"""
|
|
116
117
|
try:
|
|
117
|
-
params = list(inspect.signature(
|
|
118
|
+
params = list(inspect.signature(fn).parameters.values())
|
|
118
119
|
except (TypeError, ValueError):
|
|
119
120
|
return False
|
|
120
121
|
return any(
|
|
121
122
|
p.kind == inspect.Parameter.VAR_KEYWORD
|
|
122
123
|
or (
|
|
123
|
-
p.name ==
|
|
124
|
+
p.name == name
|
|
124
125
|
and p.kind in (inspect.Parameter.POSITIONAL_OR_KEYWORD, inspect.Parameter.KEYWORD_ONLY)
|
|
125
126
|
)
|
|
126
127
|
for p in params
|
|
@@ -260,7 +261,24 @@ def run_scaffold(
|
|
|
260
261
|
# The MCP boundary's per-tick failure tally, when the client exposes one. Duck-typed:
|
|
261
262
|
# an injected stub without it (a validation run) simply never reclassifies a tick.
|
|
262
263
|
mcp_tally = getattr(senpi_mcp, "tick_errors", None)
|
|
263
|
-
sink_takes_tick_id = sink is not None and
|
|
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
|
|
264
282
|
|
|
265
283
|
try:
|
|
266
284
|
while not stop_event.is_set():
|
|
@@ -275,6 +293,14 @@ def run_scaffold(
|
|
|
275
293
|
# error it reports names the same tick.
|
|
276
294
|
tick_id = new_ulid()
|
|
277
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
|
|
278
304
|
tick_status = "ok"
|
|
279
305
|
tick_error = None
|
|
280
306
|
candidates: list = []
|
|
@@ -365,7 +391,12 @@ def run_scaffold(
|
|
|
365
391
|
error_count=mcp_error_count,
|
|
366
392
|
)
|
|
367
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
|
+
|
|
368
398
|
# --- Clean tick: commit ctx.state, deliver candidates ---
|
|
399
|
+
persist_error = None
|
|
369
400
|
if tick_status == "ok":
|
|
370
401
|
# Transactional commit. A failed persist RAISES (StateManager
|
|
371
402
|
# enforces it) and does NOT advance the committed snapshot — so
|
|
@@ -385,28 +416,75 @@ def run_scaffold(
|
|
|
385
416
|
error_type=type(commit_exc).__name__,
|
|
386
417
|
error=str(commit_exc),
|
|
387
418
|
)
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
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
|
+
)
|
|
403
479
|
|
|
404
480
|
if committed and sink is not None:
|
|
405
481
|
try:
|
|
482
|
+
kwargs = {}
|
|
406
483
|
if sink_takes_tick_id:
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
484
|
+
kwargs["tick_id"] = tick_id
|
|
485
|
+
if sink_takes_tick:
|
|
486
|
+
kwargs["tick"] = tick_facts
|
|
487
|
+
sink(candidates, **kwargs)
|
|
410
488
|
except Exception as sink_exc: # noqa: BLE001
|
|
411
489
|
log_event(
|
|
412
490
|
"scaffold_sink_error",
|
|
@@ -415,18 +493,14 @@ def run_scaffold(
|
|
|
415
493
|
error_type=type(sink_exc).__name__,
|
|
416
494
|
error=str(sink_exc),
|
|
417
495
|
)
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
tick_error or {"type": tick_status, "tick_id": tick_id},
|
|
427
|
-
tick_count,
|
|
428
|
-
tick_id,
|
|
429
|
-
)
|
|
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
|
+
)
|
|
430
504
|
|
|
431
505
|
log_event(
|
|
432
506
|
"scaffold_tick_finished",
|
|
@@ -506,10 +580,24 @@ def _classify_mcp_errors(mcp_tally: Any, candidates: list) -> tuple[int, Optiona
|
|
|
506
580
|
}
|
|
507
581
|
|
|
508
582
|
|
|
509
|
-
def _post_error_safe(
|
|
510
|
-
|
|
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
|
+
"""
|
|
511
596
|
try:
|
|
512
|
-
|
|
597
|
+
if tick is None:
|
|
598
|
+
sink.post_error(error)
|
|
599
|
+
else:
|
|
600
|
+
sink.post_error(error, tick=tick)
|
|
513
601
|
except Exception as perr: # noqa: BLE001
|
|
514
602
|
log_event(
|
|
515
603
|
"scaffold_post_error_failed",
|
|
@@ -404,28 +404,93 @@ 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
|
+
|
|
419
|
+
def _record_args(args: Optional[dict]) -> dict:
|
|
420
|
+
"""The scalar arguments of one call, bounded — never the caller's own dict.
|
|
421
|
+
|
|
422
|
+
A reference would let authored code mutate what the tally already recorded,
|
|
423
|
+
and a deep copy would pay for a payload the wire is going to drop anyway.
|
|
424
|
+
"""
|
|
425
|
+
if not isinstance(args, dict):
|
|
426
|
+
return {}
|
|
427
|
+
kept: dict = {}
|
|
428
|
+
for key in list(args.keys())[:MAX_TICK_CALL_ARG_KEYS]:
|
|
429
|
+
if not isinstance(key, str):
|
|
430
|
+
continue
|
|
431
|
+
value = args[key]
|
|
432
|
+
if isinstance(value, str):
|
|
433
|
+
kept[key] = value[:MAX_TICK_CALL_ARG_CHARS]
|
|
434
|
+
elif isinstance(value, bool) or (isinstance(value, (int, float)) and value == value):
|
|
435
|
+
kept[key] = value
|
|
436
|
+
return kept
|
|
437
|
+
|
|
438
|
+
|
|
407
439
|
class TickErrorTally:
|
|
408
|
-
"""Per-tick
|
|
440
|
+
"""Per-tick record of what the MCP boundary did: every call, and every failure.
|
|
441
|
+
|
|
442
|
+
Two tallies, one object, because both are read at the same moment and reset
|
|
443
|
+
at the same moment.
|
|
444
|
+
|
|
445
|
+
FAILURES. Authored scan() code routinely catches the MCP exception and
|
|
446
|
+
returns [], so the exception alone never reaches the scaffold — the tick
|
|
447
|
+
would finish `ok` with zero candidates while the scanner is blind. The client
|
|
448
|
+
records every failure here as it re-raises; the scaffold resets the tally at
|
|
449
|
+
tick start and reads it at tick end to decide whether a candidate-less tick
|
|
450
|
+
was really ok.
|
|
451
|
+
|
|
452
|
+
CALLS. Every call is recorded, succeeded or failed, with the time it took.
|
|
453
|
+
Failures alone say what broke but not what the tick actually did or where its
|
|
454
|
+
time went — a tick with no failures and one 9-second call is a slow venue, and
|
|
455
|
+
nothing else on the box can tell an operator that.
|
|
409
456
|
|
|
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
457
|
Thread-safe because call_tool may be driven from author threads.
|
|
416
458
|
"""
|
|
417
459
|
|
|
418
460
|
def __init__(self) -> None:
|
|
419
461
|
self._by_type: dict[str, int] = {}
|
|
462
|
+
self._calls: list[dict] = []
|
|
463
|
+
self._call_count = 0
|
|
464
|
+
self._total_ms = 0.0
|
|
420
465
|
self._lock = threading.Lock()
|
|
421
466
|
|
|
422
467
|
def record(self, error_type: str) -> None:
|
|
423
468
|
with self._lock:
|
|
424
469
|
self._by_type[error_type] = self._by_type.get(error_type, 0) + 1
|
|
425
470
|
|
|
471
|
+
def record_call(
|
|
472
|
+
self,
|
|
473
|
+
tool: str,
|
|
474
|
+
elapsed_ms: float,
|
|
475
|
+
args: Optional[dict] = None,
|
|
476
|
+
error_type: Optional[str] = None,
|
|
477
|
+
) -> None:
|
|
478
|
+
"""Record one completed call. Called on the success path and the failure path alike."""
|
|
479
|
+
entry = {"tool": tool, "ms": elapsed_ms, "args": _record_args(args)}
|
|
480
|
+
if error_type is not None:
|
|
481
|
+
entry["error_type"] = error_type
|
|
482
|
+
with self._lock:
|
|
483
|
+
self._call_count += 1
|
|
484
|
+
self._total_ms += elapsed_ms
|
|
485
|
+
if len(self._calls) < MAX_TICK_CALL_RECORDS:
|
|
486
|
+
self._calls.append(entry)
|
|
487
|
+
|
|
426
488
|
def reset(self) -> None:
|
|
427
489
|
with self._lock:
|
|
428
490
|
self._by_type = {}
|
|
491
|
+
self._calls = []
|
|
492
|
+
self._call_count = 0
|
|
493
|
+
self._total_ms = 0.0
|
|
429
494
|
|
|
430
495
|
def snapshot(self) -> dict[str, Any]:
|
|
431
496
|
"""{count, dominant_error_type, by_type}; count 0 / None when clean.
|
|
@@ -440,6 +505,15 @@ class TickErrorTally:
|
|
|
440
505
|
dominant = max(by_type, key=by_type.__getitem__) if by_type else None
|
|
441
506
|
return {"count": count, "dominant_error_type": dominant, "by_type": by_type}
|
|
442
507
|
|
|
508
|
+
def call_snapshot(self) -> dict[str, Any]:
|
|
509
|
+
"""{count, total_ms, calls}. `count` and `total_ms` are whole; `calls` may be cut."""
|
|
510
|
+
with self._lock:
|
|
511
|
+
return {
|
|
512
|
+
"count": self._call_count,
|
|
513
|
+
"total_ms": round(self._total_ms, 1),
|
|
514
|
+
"calls": [dict(call) for call in self._calls],
|
|
515
|
+
}
|
|
516
|
+
|
|
443
517
|
|
|
444
518
|
# --------------------------------------------------------------------------
|
|
445
519
|
# Transport-failure classification — which errors mean "the session is dead".
|
|
@@ -670,7 +744,11 @@ class SenpiMcpClient:
|
|
|
670
744
|
|
|
671
745
|
used = self._ensure_connected(name, deadline)
|
|
672
746
|
result = self._run(used.session.call_tool(name, arguments=args), name, deadline)
|
|
673
|
-
|
|
747
|
+
value = _cast_market_numerics(name, self._unwrap(name, result), args)
|
|
748
|
+
# Recorded after the unwrap, so a call that returned an `isError` result is
|
|
749
|
+
# counted on the failure path below and never twice.
|
|
750
|
+
self.tick_errors.record_call(name, round((time.monotonic() - started) * 1000, 1), args)
|
|
751
|
+
return value
|
|
674
752
|
except Exception as exc:
|
|
675
753
|
# Catch every failure — mutation block, missing creds, the lazy
|
|
676
754
|
# `import mcp`, transport errors, timeouts, tool `isError` — tally
|
|
@@ -691,13 +769,15 @@ class SenpiMcpClient:
|
|
|
691
769
|
# counts under the SAME name, so the dominant type a blind tick
|
|
692
770
|
# reports to /errors matches the senpi_mcp_error lines beside it.
|
|
693
771
|
error_type = "TimeoutError" if timed_out else type(primary).__name__
|
|
772
|
+
elapsed_ms = round((time.monotonic() - started) * 1000, 1)
|
|
694
773
|
self.tick_errors.record(error_type)
|
|
774
|
+
self.tick_errors.record_call(name, elapsed_ms, args, error_type)
|
|
695
775
|
log_event(
|
|
696
776
|
"senpi_mcp_error",
|
|
697
777
|
tool=name,
|
|
698
778
|
error_type=error_type,
|
|
699
779
|
error=str(primary)[:500],
|
|
700
|
-
elapsed_ms=
|
|
780
|
+
elapsed_ms=elapsed_ms,
|
|
701
781
|
**({"leaf_count": len(leaves)} if len(leaves) > 1 else {}),
|
|
702
782
|
**({"timeout_seconds": self._call_timeout} if timed_out else {}),
|
|
703
783
|
**({"session_rebuilt": True} if rebuilt else {}),
|