@pentatonic-ai/ai-agent-sdk 0.10.23 → 0.10.25

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/index.cjs CHANGED
@@ -878,7 +878,7 @@ function fireAndForgetEmit(clientConfig, sessionOpts, messages, result, model) {
878
878
  }
879
879
 
880
880
  // src/telemetry.js
881
- var VERSION = "0.10.23";
881
+ var VERSION = "0.10.25";
882
882
  var TELEMETRY_URL = "https://sdk-telemetry.philip-134.workers.dev";
883
883
  function machineId() {
884
884
  const raw = typeof process !== "undefined" ? `${process.env?.USER || process.env?.USERNAME || "u"}:${process.platform || "x"}:${process.arch || "x"}` : "browser";
package/dist/index.js CHANGED
@@ -847,7 +847,7 @@ function fireAndForgetEmit(clientConfig, sessionOpts, messages, result, model) {
847
847
  }
848
848
 
849
849
  // src/telemetry.js
850
- var VERSION = "0.10.23";
850
+ var VERSION = "0.10.25";
851
851
  var TELEMETRY_URL = "https://sdk-telemetry.philip-134.workers.dev";
852
852
  function machineId() {
853
853
  const raw = typeof process !== "undefined" ? `${process.env?.USER || process.env?.USERNAME || "u"}:${process.platform || "x"}:${process.arch || "x"}` : "browser";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pentatonic-ai/ai-agent-sdk",
3
- "version": "0.10.23",
3
+ "version": "0.10.25",
4
4
  "description": "TES SDK — LLM observability and lifecycle tracking via Pentatonic Thing Event System. Track token usage, tool calls, and conversations. Manage things through event-sourced lifecycle stages with AI enrichment and vector search.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",
@@ -15,5 +15,10 @@ COPY worker.py .
15
15
  # container crash-loop on import at startup (observed 2026-06-08 deploy).
16
16
  # The test_*.py files are intentionally excluded; pytest only, not runtime.
17
17
  COPY noise_filter.py confidence.py entity_id.py sensitive_filter.py extraction_schema.py source_time.py ./
18
+ # SEE-490 one-off backfill — a standalone script (not a worker.py import), but it
19
+ # does `from worker import …` so it must sit beside worker.py in /app. #141 added
20
+ # the script but not this COPY, so it never reached the image and the runbook's
21
+ # `/app/backfill_structured_edges.py` apply step had nothing to run.
22
+ COPY backfill_structured_edges.py ./
18
23
 
19
24
  CMD ["python", "worker.py"]
@@ -37,6 +37,17 @@ in the installed package first):
37
37
  python backfill_structured_edges.py --arena 'pentatonic-team%' --commit-every 500
38
38
 
39
39
  Reads `PG_DSN` from the environment (same as the worker).
40
+
41
+ Concurrent-writer contention (SEE-490 prod incident):
42
+ When run against PROD this backfill shares the `relationships`/`entities` rows
43
+ with the LIVE distiller worker, so the two writers can deadlock on the same
44
+ rows. A single unhandled `DeadlockDetected` used to kill the whole ~17h run
45
+ (the prod run died at ~78%). The write path is now wrapped in a bounded
46
+ retry-with-backoff loop and — crucially — the pending batch is BUFFERED
47
+ (computed, not yet written) so that on a deadlock we can `rollback()` and
48
+ re-apply the *entire* buffered batch, without re-reading from the streaming
49
+ cursor. Everything is idempotent (`ON CONFLICT (id) DO UPDATE`), so already
50
+ committed work is durable and a fresh resume simply re-does the tail.
40
51
  """
41
52
 
42
53
  from __future__ import annotations
@@ -44,12 +55,33 @@ from __future__ import annotations
44
55
  import argparse
45
56
  import logging
46
57
  import os
58
+ import random
47
59
  import sys
48
60
  import time
49
61
  from typing import Any
50
62
 
51
63
  import psycopg
52
64
 
65
+ # Transient, contention-driven failures that a retry can clear. A backfill that
66
+ # runs alongside the live distiller WILL hit these — Postgres picks one of two
67
+ # deadlocking transactions as the victim and aborts it; a serialization failure
68
+ # is the MVCC-level equivalent; OperationalError is the broad psycopg umbrella
69
+ # (dropped/again-busy connection) we also want to survive rather than abort a
70
+ # 17h run over. All are safe to retry here because every write is an idempotent
71
+ # ON CONFLICT (id) DO UPDATE upsert.
72
+ _RETRYABLE_ERRORS: tuple[type[BaseException], ...] = (
73
+ psycopg.errors.DeadlockDetected,
74
+ psycopg.errors.SerializationFailure,
75
+ psycopg.errors.OperationalError,
76
+ )
77
+
78
+ # Bounded exponential backoff with jitter. Real wall-clock sleep is fine in this
79
+ # operational one-off; the *decision* helpers below are pure so they stay unit
80
+ # testable without a live DB.
81
+ _MAX_WRITE_RETRIES = 5
82
+ _BACKOFF_BASE_S = 0.5
83
+ _BACKOFF_CAP_S = 30.0
84
+
53
85
  # Reuse the distiller's own functions so behaviour is byte-identical to the live
54
86
  # path — the builders, the entity resolution/keying, and the edge upsert.
55
87
  from worker import ( # type: ignore
@@ -111,6 +143,116 @@ def _source_filter(sources_arg: str) -> list[str]:
111
143
  return out
112
144
 
113
145
 
146
+ def _is_retryable(exc: BaseException) -> bool:
147
+ """True iff `exc` is a transient contention failure worth retrying.
148
+
149
+ Pure predicate (no I/O) so the retry decision is unit-testable without a DB.
150
+ Retryable types are the psycopg contention errors captured in
151
+ `_RETRYABLE_ERRORS`; anything else (a real data/logic error) must propagate.
152
+ """
153
+ return isinstance(exc, _RETRYABLE_ERRORS)
154
+
155
+
156
+ def _should_retry(exc: BaseException, attempt: int, max_retries: int = _MAX_WRITE_RETRIES) -> bool:
157
+ """Decide retry-vs-raise for `exc` on `attempt` (1-based) of a batch write.
158
+
159
+ Retry only when the error is contention-driven AND we have attempts left.
160
+ `attempt` counts the try that just failed, so attempt N == max_retries is the
161
+ last one we allow before giving up (re-raise). Pure — no sleeping, no I/O.
162
+ """
163
+ return _is_retryable(exc) and attempt < max_retries
164
+
165
+
166
+ def _backoff_seconds(
167
+ attempt: int,
168
+ base: float = _BACKOFF_BASE_S,
169
+ cap: float = _BACKOFF_CAP_S,
170
+ jitter: float | None = None,
171
+ ) -> float:
172
+ """Exponential backoff (base * 2**(attempt-1)), capped, plus small jitter.
173
+
174
+ `attempt` is 1-based. Jitter defaults to a random fraction of the step so
175
+ contending writers de-sync; pass an explicit `jitter` in [0,1) for
176
+ deterministic tests. Pure aside from the optional RNG draw.
177
+ """
178
+ step = min(base * (2 ** max(attempt - 1, 0)), cap)
179
+ if jitter is None:
180
+ jitter = random.random()
181
+ return step + jitter * base
182
+
183
+
184
+ def _apply_event_edges(
185
+ write_conn: "psycopg.Connection[Any]",
186
+ item: dict[str, Any],
187
+ ) -> None:
188
+ """Upsert one buffered edge-producing event's entities + relationships.
189
+
190
+ Uses the distiller's own upsert helpers so behaviour matches the live path.
191
+ Called inside the retry loop, so it must be safe to re-run after a rollback —
192
+ it is, because both upserts are idempotent ON CONFLICT (id) DO UPDATE.
193
+ """
194
+ name_to_id = upsert_entities(
195
+ write_conn,
196
+ item["arena"],
197
+ item["event_id"],
198
+ item["pset"],
199
+ item["disc"],
200
+ item["det_ents"],
201
+ item["event_time"],
202
+ item["attrs"],
203
+ )
204
+ upsert_relationships(
205
+ write_conn,
206
+ item["arena"],
207
+ item["event_id"],
208
+ item["pset"],
209
+ item["disc"],
210
+ item["det_rels"],
211
+ name_to_id,
212
+ item["event_time"],
213
+ )
214
+
215
+
216
+ def _commit_batch_with_retry(
217
+ write_conn: "psycopg.Connection[Any]",
218
+ batch: list[dict[str, Any]],
219
+ ) -> None:
220
+ """Apply the whole buffered `batch` and commit, retrying on contention.
221
+
222
+ The batch is re-applied FROM ITS BUFFER on every attempt — on a deadlock we
223
+ `rollback()` (discarding the partially-applied txn) and replay the entire
224
+ buffered batch, so the streaming READ cursor never has to rewind. Bounded by
225
+ `_MAX_WRITE_RETRIES`; a non-retryable error, or exhausted retries, re-raises.
226
+ """
227
+ if not batch:
228
+ return
229
+ lo = batch[0]["event_id"]
230
+ hi = batch[-1]["event_id"]
231
+ attempt = 0
232
+ while True:
233
+ attempt += 1
234
+ try:
235
+ for item in batch:
236
+ _apply_event_edges(write_conn, item)
237
+ write_conn.commit()
238
+ return
239
+ except Exception as exc: # noqa: BLE001 — classified by _should_retry
240
+ try:
241
+ write_conn.rollback()
242
+ except Exception: # rollback best-effort; don't mask the original
243
+ log.warning("rollback after write error also failed", exc_info=True)
244
+ if not _should_retry(exc, attempt):
245
+ raise
246
+ delay = _backoff_seconds(attempt)
247
+ log.warning(
248
+ "write contention (%s) on batch events %s..%s (%d edge-events); "
249
+ "retry attempt %d/%d after %.2fs backoff",
250
+ type(exc).__name__, lo, hi, len(batch),
251
+ attempt, _MAX_WRITE_RETRIES, delay,
252
+ )
253
+ time.sleep(delay)
254
+
255
+
114
256
  def main(argv: list[str]) -> int:
115
257
  args = _parse_args(argv)
116
258
  dsn = os.environ.get("PG_DSN")
@@ -125,8 +267,11 @@ def main(argv: list[str]) -> int:
125
267
 
126
268
  # Separate connections: a server-side (named) cursor STREAMS the read so we
127
269
  # never load the whole events table into memory; a second connection does the
128
- # batched writes. Read connection is autocommit (no long-lived read txn).
129
- read_conn = psycopg.connect(dsn, autocommit=True)
270
+ # batched writes. The read connection MUST NOT be autocommit psycopg3's
271
+ # named server-side cursor issues DECLARE CURSOR, which requires a
272
+ # transaction block ("DECLARE CURSOR can only be used in transaction
273
+ # blocks"). It's read-only (SELECT), never committed, just closed at the end.
274
+ read_conn = psycopg.connect(dsn)
130
275
  write_conn = psycopg.connect(dsn)
131
276
 
132
277
  scanned = 0
@@ -134,7 +279,12 @@ def main(argv: list[str]) -> int:
134
279
  edges_total = 0
135
280
  ents_total = 0
136
281
  t0 = time.perf_counter()
137
- pending_in_txn = 0
282
+ # Buffer of edge-producing events computed since the last successful commit.
283
+ # We hold the computed upsert args (NOT yet written) so that on a deadlock we
284
+ # can rollback the write txn and replay the whole batch without rewinding the
285
+ # streaming read cursor. Flushed (applied + committed, with retry) every
286
+ # --commit-every edge-producing events.
287
+ pending_batch: list[dict[str, Any]] = []
138
288
 
139
289
  try:
140
290
  with read_conn.cursor(name="backfill_events") as cur:
@@ -174,18 +324,22 @@ def main(argv: list[str]) -> int:
174
324
  pset = participant_set or [arena]
175
325
  disc = disclosure or "private"
176
326
  event_time = event_source_time({"attributes": attrs}) or emitted_at
177
- name_to_id = upsert_entities(
178
- write_conn, arena, event_id, pset, disc, det_ents,
179
- event_time, attrs,
180
- )
181
- upsert_relationships(
182
- write_conn, arena, event_id, pset, disc, det_rels,
183
- name_to_id, event_time,
184
- )
185
- pending_in_txn += 1
186
- if pending_in_txn >= args.commit_every:
187
- write_conn.commit()
188
- pending_in_txn = 0
327
+ # Buffer the computed upsert args; do NOT touch the write conn
328
+ # yet. The whole buffer is applied + committed together (with
329
+ # deadlock retry) at flush time.
330
+ pending_batch.append({
331
+ "arena": arena,
332
+ "event_id": event_id,
333
+ "pset": pset,
334
+ "disc": disc,
335
+ "det_ents": det_ents,
336
+ "det_rels": det_rels,
337
+ "event_time": event_time,
338
+ "attrs": attrs,
339
+ })
340
+ if len(pending_batch) >= args.commit_every:
341
+ _commit_batch_with_retry(write_conn, pending_batch)
342
+ pending_batch = []
189
343
 
190
344
  if with_edges % 5000 == 0 and with_edges:
191
345
  rate = scanned / max(time.perf_counter() - t0, 1e-6)
@@ -195,11 +349,20 @@ def main(argv: list[str]) -> int:
195
349
  )
196
350
 
197
351
  if not args.dry_run:
198
- write_conn.commit()
352
+ # Flush the final partial batch (same retry-on-contention path).
353
+ _commit_batch_with_retry(write_conn, pending_batch)
354
+ pending_batch = []
199
355
  except Exception:
200
356
  if not args.dry_run:
201
- write_conn.rollback()
202
- log.exception("backfill failed; write txn rolled back")
357
+ try:
358
+ write_conn.rollback()
359
+ except Exception:
360
+ log.warning("final rollback also failed", exc_info=True)
361
+ log.exception(
362
+ "backfill failed; write txn rolled back. Already-committed batches are "
363
+ "durable/idempotent — re-run to resume (ON CONFLICT makes it a no-op "
364
+ "over prior work)."
365
+ )
203
366
  return 1
204
367
  finally:
205
368
  read_conn.close()
@@ -55,3 +55,44 @@ def test_dry_run_and_sources_flags():
55
55
  assert args.dry_run is True
56
56
  assert args.limit == 10
57
57
  assert bf._source_filter(args.sources) == ["pip-gmail-ingest"]
58
+
59
+
60
+ # --- retry / backoff decision (SEE-490 prod deadlock survival) ----------------
61
+ # Pure-logic guards for the contention-retry path. The live DB replay is
62
+ # exercised by the run itself; here we pin the retry-vs-raise decision and the
63
+ # backoff schedule so a regression in either is caught without a live DB.
64
+
65
+ def test_deadlock_is_retryable_but_bounded():
66
+ # A deadlock (the exact prod failure) is retried while attempts remain and
67
+ # re-raised once they're exhausted.
68
+ exc = bf.psycopg.errors.DeadlockDetected("deadlock detected")
69
+ assert bf._is_retryable(exc) is True
70
+ assert bf._should_retry(exc, attempt=1, max_retries=5) is True
71
+ assert bf._should_retry(exc, attempt=4, max_retries=5) is True
72
+ # attempt == max_retries is the last allowed try; no further retry.
73
+ assert bf._should_retry(exc, attempt=5, max_retries=5) is False
74
+
75
+
76
+ def test_serialization_and_operational_are_retryable():
77
+ assert bf._is_retryable(bf.psycopg.errors.SerializationFailure("s")) is True
78
+ assert bf._is_retryable(bf.psycopg.errors.OperationalError("o")) is True
79
+
80
+
81
+ def test_non_contention_error_never_retries():
82
+ # A logic/data error (not contention) must propagate immediately, never retry.
83
+ exc = ValueError("bad data")
84
+ assert bf._is_retryable(exc) is False
85
+ assert bf._should_retry(exc, attempt=1, max_retries=5) is False
86
+
87
+
88
+ def test_backoff_is_exponential_capped_and_deterministic_with_fixed_jitter():
89
+ # With jitter pinned to 0 the schedule is pure exponential: base * 2**(n-1).
90
+ base, cap = 0.5, 30.0
91
+ assert bf._backoff_seconds(1, base=base, cap=cap, jitter=0.0) == 0.5
92
+ assert bf._backoff_seconds(2, base=base, cap=cap, jitter=0.0) == 1.0
93
+ assert bf._backoff_seconds(3, base=base, cap=cap, jitter=0.0) == 2.0
94
+ # Later attempts saturate at the cap (+ the deterministic jitter term).
95
+ assert bf._backoff_seconds(20, base=base, cap=cap, jitter=0.0) == cap
96
+ # Jitter adds at most `base`, keeping the value within [step, step+base).
97
+ jittered = bf._backoff_seconds(1, base=base, cap=cap, jitter=0.5)
98
+ assert 0.5 <= jittered < 0.5 + base