@senpi/runtime 3.0.93 → 3.0.94

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.
@@ -60,6 +60,11 @@ def build_delivery_sink(
60
60
 
61
61
  journal = Journal(journal_path)
62
62
  replayed = [False] # mutable cell; replay runs once on first invocation
63
+ # Mutable cell; the id of the tick currently inside _sink, so the reject/warn
64
+ # callbacks — which are built once, here — can name it. _sink sets it on entry,
65
+ # before anything can read it, and both callbacks run only from inside _sink.
66
+ # None when the caller passed no id, or one the wire would refuse.
67
+ current_tick_id: list[str | None] = [None]
63
68
  # Distinct dropped-field shapes already warned about (rate-limit: a scanner that
64
69
  # sets a stray key every tick warns ONCE per shape, not every tick).
65
70
  warned_dropped_shapes: set[frozenset] = set()
@@ -68,6 +73,22 @@ def build_delivery_sink(
68
73
  # Internal helpers
69
74
  # ------------------------------------------------------------------
70
75
 
76
+ def _tick_field(tick_id: Any) -> dict:
77
+ """`tick_id=...` as log kwargs, or nothing at all when no tick is nameable.
78
+
79
+ An absent key is the honest answer; a null or a blank would join to nothing
80
+ while looking like an id.
81
+ """
82
+ return {"tick_id": tick_id} if isinstance(tick_id, str) and tick_id else {}
83
+
84
+ def _signal_tick_id(signal: Any) -> Any:
85
+ """The producing tick's id off a stored signal, or None.
86
+
87
+ A journal line is arbitrary text on disk: `signal` may be a null, a string or
88
+ a list, so this never assumes a mapping.
89
+ """
90
+ return signal.get("tick_id") if isinstance(signal, dict) else None
91
+
71
92
  def _now_ms() -> int:
72
93
  return int(clock() * 1000)
73
94
 
@@ -90,7 +111,14 @@ def build_delivery_sink(
90
111
  to_drop.append(entry)
91
112
  for entry in to_drop:
92
113
  journal.record_outcome(entry.id, "dropped_expired")
93
- log_event("delivery_dropped_expired", entry_id=entry.id, asset=entry.asset)
114
+ # The producing tick's id, read off the journalled signal — the tick that
115
+ # noticed the expiry did not cause it.
116
+ log_event(
117
+ "delivery_dropped_expired",
118
+ entry_id=entry.id,
119
+ asset=entry.asset,
120
+ **_tick_field(_signal_tick_id(entry.signal)),
121
+ )
94
122
  return to_send
95
123
 
96
124
  def _usable_tick_id(tick_id: Any) -> str | None:
@@ -102,12 +130,12 @@ def build_delivery_sink(
102
130
  """
103
131
  if tick_id is None or (isinstance(tick_id, str) and tick_id):
104
132
  return tick_id
105
- log_event("delivery_tick_id_unusable", scanner_id=scanner_id, tick_id=repr(tick_id)[:80])
133
+ log_event("delivery_tick_id_unusable", scanner_id=scanner_id, rejected_tick_id=repr(tick_id)[:80])
106
134
  return None
107
135
 
108
136
  def _on_reject(reason: dict) -> None:
109
137
  """Loud rejection: log AND POST to /errors (design §4 — never a silent drop)."""
110
- log_event("delivery_candidate_invalid", **reason)
138
+ log_event("delivery_candidate_invalid", **_tick_field(current_tick_id[0]), **reason)
111
139
  _post_error({"type": "candidate_rejected", **reason})
112
140
 
113
141
  def _on_warn(info: dict) -> None:
@@ -123,6 +151,7 @@ def build_delivery_sink(
123
151
  scanner_id=scanner_id,
124
152
  dropped=info.get("dropped"),
125
153
  asset=info.get("asset"),
154
+ **_tick_field(current_tick_id[0]),
126
155
  )
127
156
 
128
157
  def _wrap_candidates(candidates, produced_at_ms: int, tick_id: str | None) -> list[tuple]:
@@ -171,7 +200,7 @@ def build_delivery_sink(
171
200
  url = intake_url.rstrip("/") + SIGNALS_PATH
172
201
  return post_json(url, body, timeout)
173
202
 
174
- def _interpret_response(status: int, parsed: Any, pending_eids: list[str], pending_entries: list) -> bool:
203
+ def _interpret_response(status: int, parsed: Any, pending_eids: list[str], pending_signals: list) -> bool:
175
204
  """Interpret POST response; journal outcomes. Returns True if successful envelope."""
176
205
  if status != 200 or parsed is None:
177
206
  if isinstance(parsed, dict):
@@ -211,6 +240,10 @@ def build_delivery_sink(
211
240
  # signals), and unknown/missing signal_id (ignored safely, never applied by
212
241
  # position). An unmatched entry stays pending and is retried.
213
242
  pending = set(pending_eids)
243
+ # eid -> the tick that produced it, for the per-entry lines below.
244
+ produced_by = {
245
+ eid: _signal_tick_id(sig) for eid, sig in zip(pending_eids, pending_signals)
246
+ }
214
247
  for sig_status in per_signal:
215
248
  if not isinstance(sig_status, dict):
216
249
  continue
@@ -228,7 +261,12 @@ def build_delivery_sink(
228
261
  journal.record_outcome(eid, st)
229
262
  else:
230
263
  # Unknown status — treat as undelivered (no outcome written)
231
- log_event("delivery_unknown_status", entry_id=eid, status=st)
264
+ log_event(
265
+ "delivery_unknown_status",
266
+ entry_id=eid,
267
+ status=st,
268
+ **_tick_field(produced_by.get(eid)),
269
+ )
232
270
  # Entries with no matching status returned stay pending (retry next tick).
233
271
 
234
272
  return True
@@ -239,6 +277,7 @@ def build_delivery_sink(
239
277
 
240
278
  def _sink(candidates, tick_id=None) -> None:
241
279
  tick_id = _usable_tick_id(tick_id)
280
+ current_tick_id[0] = tick_id
242
281
 
243
282
  # Replay journal on very first invocation
244
283
  if not replayed[0]:
@@ -284,18 +323,21 @@ def build_delivery_sink(
284
323
  # 4. Interpret response
285
324
  # The response per-signal list corresponds only to all_signals that have
286
325
  # produced entries (all_eids). Empty no-ops: no eids.
287
- _interpret_response(status, parsed, all_eids, [])
326
+ _interpret_response(status, parsed, all_eids, all_signals)
288
327
 
289
328
  def _post_error(error: dict) -> None:
290
329
  """POST an error dict. Fire-and-forget."""
291
330
  body = {"scanner_id": scanner_id, "error": error}
292
331
  url = intake_url.rstrip("/") + ERRORS_PATH
332
+ # The error belongs to exactly one tick and already names it; a body that
333
+ # names none leaves the key off rather than borrowing the current one.
334
+ origin = _tick_field(error.get("tick_id")) if isinstance(error, dict) else {}
293
335
  try:
294
336
  post_json(url, body, timeout)
295
337
  except TransportError as exc:
296
- log_event("delivery_error_post_failed", error=str(exc))
338
+ log_event("delivery_error_post_failed", error=str(exc), **origin)
297
339
  except Exception as exc: # noqa: BLE001
298
- log_event("delivery_error_post_failed", error=str(exc))
340
+ log_event("delivery_error_post_failed", error=str(exc), **origin)
299
341
 
300
342
  _sink.post_error = _post_error # type: ignore[attr-defined]
301
343
  return _sink
@@ -261,6 +261,7 @@ def run_scaffold(
261
261
  log_event(
262
262
  "scaffold_tick_malformed",
263
263
  tick=tick_count,
264
+ tick_id=tick_id,
264
265
  reason=malformed_reason,
265
266
  )
266
267
  else:
@@ -273,6 +274,7 @@ def run_scaffold(
273
274
  log_event(
274
275
  "scaffold_tick_timeout",
275
276
  tick=tick_count,
277
+ tick_id=tick_id,
276
278
  reason=str(exc),
277
279
  )
278
280
  except Exception as exc: # noqa: BLE001 — author bug must not crash scaffold
@@ -287,6 +289,7 @@ def run_scaffold(
287
289
  log_event(
288
290
  "scaffold_tick_error",
289
291
  tick=tick_count,
292
+ tick_id=tick_id,
290
293
  error_type=type(exc).__name__,
291
294
  error=str(exc),
292
295
  )
@@ -312,6 +315,7 @@ def run_scaffold(
312
315
  log_event(
313
316
  "scaffold_state_persist_failed",
314
317
  tick=tick_count,
318
+ tick_id=tick_id,
315
319
  error_type=type(commit_exc).__name__,
316
320
  error=str(commit_exc),
317
321
  )
@@ -328,6 +332,7 @@ def run_scaffold(
328
332
  ),
329
333
  },
330
334
  tick_count,
335
+ tick_id,
331
336
  )
332
337
 
333
338
  if committed and sink is not None:
@@ -340,6 +345,7 @@ def run_scaffold(
340
345
  log_event(
341
346
  "scaffold_sink_error",
342
347
  tick=tick_count,
348
+ tick_id=tick_id,
343
349
  error_type=type(sink_exc).__name__,
344
350
  error=str(sink_exc),
345
351
  )
@@ -353,11 +359,13 @@ def run_scaffold(
353
359
  sink,
354
360
  tick_error or {"type": tick_status, "tick_id": tick_id},
355
361
  tick_count,
362
+ tick_id,
356
363
  )
357
364
 
358
365
  log_event(
359
366
  "scaffold_tick_finished",
360
367
  tick=tick_count,
368
+ tick_id=tick_id,
361
369
  status=tick_status,
362
370
  duration_ms=duration_ms,
363
371
  candidate_count=len(candidates),
@@ -376,6 +384,7 @@ def run_scaffold(
376
384
  log_event(
377
385
  "scaffold_tick_timeout_late",
378
386
  tick=tick_count,
387
+ tick_id=tick_id,
379
388
  reason=str(late_exc),
380
389
  )
381
390
 
@@ -401,7 +410,7 @@ def run_scaffold(
401
410
  return tick_count
402
411
 
403
412
 
404
- def _post_error_safe(sink: Any, error: dict, tick_count: int) -> None:
413
+ def _post_error_safe(sink: Any, error: dict, tick_count: int, tick_id: str) -> None:
405
414
  """Call sink.post_error(error), swallowing/logging any failure."""
406
415
  try:
407
416
  sink.post_error(error)
@@ -409,6 +418,7 @@ def _post_error_safe(sink: Any, error: dict, tick_count: int) -> None:
409
418
  log_event(
410
419
  "scaffold_post_error_failed",
411
420
  tick=tick_count,
421
+ tick_id=tick_id,
412
422
  error_type=type(perr).__name__,
413
423
  error=str(perr),
414
424
  )
@@ -2,7 +2,7 @@
2
2
  "id": "runtime",
3
3
  "name": "Senpi Runtime",
4
4
  "description": "Senpi runtime plugin for OpenClaw. Orchestrates scanners, LLM-based trade decisions, and a DSL trailing stop-loss exit engine.",
5
- "version": "3.0.93",
5
+ "version": "3.0.94",
6
6
  "commandAliases": [
7
7
  {
8
8
  "name": "senpi"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@senpi/runtime",
3
- "version": "3.0.93",
3
+ "version": "3.0.94",
4
4
  "description": "Senpi runtime plugin for OpenClaw",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",