matrx-runtime 0.0.2__tar.gz → 0.0.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. {matrx_runtime-0.0.2 → matrx_runtime-0.0.3}/PKG-INFO +1 -1
  2. {matrx_runtime-0.0.2 → matrx_runtime-0.0.3}/matrx_runtime/engine.py +60 -0
  3. {matrx_runtime-0.0.2 → matrx_runtime-0.0.3}/matrx_runtime/orm_store.py +13 -0
  4. {matrx_runtime-0.0.2 → matrx_runtime-0.0.3}/matrx_runtime/store.py +30 -0
  5. {matrx_runtime-0.0.2 → matrx_runtime-0.0.3}/pyproject.toml +1 -1
  6. {matrx_runtime-0.0.2 → matrx_runtime-0.0.3}/tests/test_lease_reaper.py +62 -0
  7. {matrx_runtime-0.0.2 → matrx_runtime-0.0.3}/tests/test_orm_store.py +16 -1
  8. {matrx_runtime-0.0.2 → matrx_runtime-0.0.3}/tests/test_payload_blind.py +33 -0
  9. {matrx_runtime-0.0.2 → matrx_runtime-0.0.3}/tests/test_scope.py +55 -0
  10. {matrx_runtime-0.0.2 → matrx_runtime-0.0.3}/.gitignore +0 -0
  11. {matrx_runtime-0.0.2 → matrx_runtime-0.0.3}/CLAUDE.md +0 -0
  12. {matrx_runtime-0.0.2 → matrx_runtime-0.0.3}/README.md +0 -0
  13. {matrx_runtime-0.0.2 → matrx_runtime-0.0.3}/matrx_runtime/__init__.py +0 -0
  14. {matrx_runtime-0.0.2 → matrx_runtime-0.0.3}/matrx_runtime/_config.py +0 -0
  15. {matrx_runtime-0.0.2 → matrx_runtime-0.0.3}/matrx_runtime/db/__init__.py +0 -0
  16. {matrx_runtime-0.0.2 → matrx_runtime-0.0.3}/matrx_runtime/db/bootstrap/000_substrate.sql +0 -0
  17. {matrx_runtime-0.0.2 → matrx_runtime-0.0.3}/matrx_runtime/db/bootstrap/010_identity.sql +0 -0
  18. {matrx_runtime-0.0.2 → matrx_runtime-0.0.3}/matrx_runtime/db/bootstrap/020_runtime.sql +0 -0
  19. {matrx_runtime-0.0.2 → matrx_runtime-0.0.3}/matrx_runtime/db/models_runtime.py +0 -0
  20. {matrx_runtime-0.0.2 → matrx_runtime-0.0.3}/matrx_runtime/lifecycle.py +0 -0
  21. {matrx_runtime-0.0.2 → matrx_runtime-0.0.3}/matrx_runtime/models.py +0 -0
  22. {matrx_runtime-0.0.2 → matrx_runtime-0.0.3}/matrx_runtime/origins.py +0 -0
  23. {matrx_runtime-0.0.2 → matrx_runtime-0.0.3}/tests/__init__.py +0 -0
  24. {matrx_runtime-0.0.2 → matrx_runtime-0.0.3}/tests/test_bootstrap_schema.py +0 -0
  25. {matrx_runtime-0.0.2 → matrx_runtime-0.0.3}/tests/test_engine.py +0 -0
  26. {matrx_runtime-0.0.2 → matrx_runtime-0.0.3}/tests/test_idempotency.py +0 -0
  27. {matrx_runtime-0.0.2 → matrx_runtime-0.0.3}/tests/test_lifecycle.py +0 -0
  28. {matrx_runtime-0.0.2 → matrx_runtime-0.0.3}/tests/test_meter_and_context.py +0 -0
  29. {matrx_runtime-0.0.2 → matrx_runtime-0.0.3}/tests/test_origins.py +0 -0
  30. {matrx_runtime-0.0.2 → matrx_runtime-0.0.3}/tests/test_proof.py +0 -0
  31. {matrx_runtime-0.0.2 → matrx_runtime-0.0.3}/tests/test_tree_read.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: matrx-runtime
3
- Version: 0.0.2
3
+ Version: 0.0.3
4
4
  Summary: The durable-execution substrate: one global_execution spine, one lifecycle state machine, one cost ledger, that every matrx-* executor (agent loop, workflow superstep, utilities) plugs into.
5
5
  Author-email: Matrx <admin@aimatrx.com>
6
6
  License: MIT
@@ -283,6 +283,39 @@ class ExecutionEngine:
283
283
  reaped.append(ex_id)
284
284
  return reaped
285
285
 
286
+ async def reap_stale_waiting(
287
+ self, *, older_than_seconds: float, now: datetime | None = None
288
+ ) -> list[str]:
289
+ """Far-future abandonment backstop for `waiting_input`. A legitimate unbounded
290
+ wait is excluded from BOTH crash-recovery layers (the reaper sweeps leased
291
+ RUNNING only; `find_stuck` excludes waiting_input) — so an input that never
292
+ arrives would otherwise stay open FOREVER. Any execution still waiting
293
+ `older_than_seconds` after its suspend (measured from the last status change,
294
+ never `created_at`) is settled FAILED with a structured
295
+ `matrx_execution_wait_abandoned` error, cost preserved. The threshold is the
296
+ HOST's (runtime doesn't know what the input is or how long is reasonable) and
297
+ must be far-future — a backstop, never an answer deadline. Idempotent + CAS-safe
298
+ exactly like `reap_expired`: an input answered between the sweep and the write
299
+ loses the CAS and is skipped."""
300
+ at = now or _now()
301
+ reaped: list[str] = []
302
+ for ex in await self._store.find_stale_waiting(
303
+ now=at, older_than_seconds=older_than_seconds
304
+ ):
305
+ error = ExecutionError(
306
+ error_type="matrx_execution_wait_abandoned",
307
+ message=(
308
+ f"execution {ex.id} abandoned: waiting_input for more than "
309
+ f"{older_than_seconds:.0f}s with no input; closed by the host backstop."
310
+ ),
311
+ )
312
+ try:
313
+ await self.fail(ex.id, error=error, cost=ex.cost)
314
+ except (ConcurrentTransition, InvalidExecutionTransition):
315
+ continue # resumed/settled out from under us — the live outcome wins
316
+ reaped.append(ex.id)
317
+ return reaped
318
+
286
319
  async def find_stuck(
287
320
  self,
288
321
  *,
@@ -686,6 +719,33 @@ class ExecutionScope:
686
719
  raise RuntimeError("ExecutionScope used before it was entered (`async with`).")
687
720
  return self._execution
688
721
 
722
+ @classmethod
723
+ def for_existing(
724
+ cls, engine: ExecutionEngine, execution: GlobalExecution
725
+ ) -> ExecutionScope:
726
+ """Bind a scope to an ALREADY-STARTED execution — the RESUME path, where ONE
727
+ execution spans many requests (design §`request` ⊂ `execution`). The caller has
728
+ re-`start()`ed a PAUSED/WAITING_INPUT execution back to RUNNING (with a fresh
729
+ lease); this wraps it so the SAME `record`/`note`/`complete`/`fail`/`request_input`
730
+ machinery drives the resumed segment — no create, no second execution.
731
+
732
+ `scope._cost` is SEEDED from the execution's prior `cost`, because the terminal
733
+ settle SETs the row's `cost` to `scope._cost` (absolute, not additive). Without the
734
+ seed a resumed segment's `complete(cost=segment)` would OVERWRITE the accumulated
735
+ cost with just this segment's spend, dropping every prior segment. Seeding makes the
736
+ terminal write the CUMULATIVE total (prior + this segment). Metered quantities
737
+ (tokens, etc.) are always additive via `record`, so they need no seeding."""
738
+ scope = cls(
739
+ engine,
740
+ type=execution.type,
741
+ execution_id=execution.id,
742
+ link_kind=execution.link_kind,
743
+ link_id=execution.link_id,
744
+ )
745
+ scope._execution = execution
746
+ scope._cost = execution.cost
747
+ return scope
748
+
689
749
  async def begin(self) -> ExecutionScope:
690
750
  """Create + start the execution (the `__aenter__` body, exposed). Use this with
691
751
  the explicit `complete`/`fail` settle when managing the lifecycle by hand
@@ -328,6 +328,19 @@ class OrmExecutionStore:
328
328
  if getattr(r, "lease_expires_at", None) is None or r.lease_expires_at < now
329
329
  ]
330
330
 
331
+ async def find_stale_waiting(
332
+ self, *, now: datetime, older_than_seconds: float
333
+ ) -> list[GlobalExecution]:
334
+ cutoff = now - timedelta(seconds=older_than_seconds)
335
+ # `updated_at` (row touch trigger) IS the suspend instant: a WAITING_INPUT row
336
+ # receives no writes while it waits, so staleness is measured from the last
337
+ # status change — a month-old execution that suspended yesterday is NOT stale.
338
+ rows = await _ExecRow.filter(
339
+ status=ExecutionStatus.WAITING_INPUT.value,
340
+ updated_at__lt=cutoff,
341
+ ).all()
342
+ return [_to_execution(r) for r in rows]
343
+
331
344
  # --- events / checkpoints / meters --------------------------------------
332
345
  async def append_event(self, event: ExecutionEvent) -> None:
333
346
  await _EventRow.create(
@@ -132,6 +132,19 @@ class ExecutionStore(Protocol):
132
132
  rows so the host can classify + scream."""
133
133
  ...
134
134
 
135
+ async def find_stale_waiting(
136
+ self, *, now: datetime, older_than_seconds: float
137
+ ) -> list[GlobalExecution]:
138
+ """`waiting_input` executions whose status last changed before
139
+ `now - older_than_seconds`. WAITING_INPUT is a legitimate unbounded wait, so it
140
+ is excluded from BOTH crash-recovery layers (the reaper sweeps leased RUNNING;
141
+ `find_stuck_executions` excludes it) — which means an input that never arrives
142
+ has no upper bound at all. This is the candidate set for the host's far-future
143
+ abandonment backstop (`ExecutionEngine.reap_stale_waiting`). Staleness is
144
+ measured from the LAST status change (a month-old execution that suspended
145
+ yesterday is NOT stale), never from `created_at`."""
146
+ ...
147
+
135
148
  # --- events / checkpoints / meters --------------------------------------
136
149
  async def append_event(self, event: ExecutionEvent) -> None: ...
137
150
  async def list_events(self, execution_id: str) -> list[ExecutionEvent]:
@@ -156,6 +169,9 @@ class InMemoryExecutionStore:
156
169
  self._meter_entries: list[MeterEntry] = []
157
170
  self._controls: dict[str, ExecutionControl] = {}
158
171
  self._checkpoints: dict[str, list[ExecutionCheckpoint]] = {}
172
+ # Last successful status-change instant per execution — the in-memory stand-in
173
+ # for the DB row's touch-trigger `updated_at`; feeds find_stale_waiting.
174
+ self._status_changed_at: dict[str, datetime] = {}
159
175
  self._lock = asyncio.Lock()
160
176
 
161
177
  @staticmethod
@@ -228,6 +244,7 @@ class InMemoryExecutionStore:
228
244
  "error": error if error is not None else ex.error,
229
245
  }
230
246
  )
247
+ self._status_changed_at[execution_id] = datetime.now(UTC)
231
248
  return True
232
249
 
233
250
  async def add_meters(
@@ -325,6 +342,19 @@ class InMemoryExecutionStore:
325
342
  and (ex.lease_expires_at is None or ex.lease_expires_at < now)
326
343
  ]
327
344
 
345
+ async def find_stale_waiting(
346
+ self, *, now: datetime, older_than_seconds: float
347
+ ) -> list[GlobalExecution]:
348
+ cutoff = now - timedelta(seconds=older_than_seconds)
349
+ out: list[GlobalExecution] = []
350
+ for ex in self._executions.values():
351
+ if ex.status is not ExecutionStatus.WAITING_INPUT:
352
+ continue
353
+ changed = self._status_changed_at.get(ex.id) or ex.created_at
354
+ if changed is not None and changed < cutoff:
355
+ out.append(ex)
356
+ return out
357
+
328
358
  # --- events / checkpoints / meters --------------------------------------
329
359
  async def append_event(self, event: ExecutionEvent) -> None:
330
360
  async with self._lock:
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "matrx-runtime"
3
- version = "0.0.2"
3
+ version = "0.0.3"
4
4
  description = "The durable-execution substrate: one global_execution spine, one lifecycle state machine, one cost ledger, that every matrx-* executor (agent loop, workflow superstep, utilities) plugs into."
5
5
  readme = "README.md"
6
6
  license = { text = "MIT" }
@@ -269,3 +269,65 @@ async def test_find_stuck_flags_stuck_paused_with_dead_resumer():
269
269
  later = ex.created_at + timedelta(seconds=2000)
270
270
  stuck = await engine.find_stuck(now=later, older_than_seconds=1800)
271
271
  assert [s.id for s in stuck] == [ex.id]
272
+
273
+
274
+ # --- waiting_input abandonment backstop (reap_stale_waiting) ------------------
275
+ # WAITING_INPUT is excluded from both crash-recovery layers by design; this is the
276
+ # host's far-future backstop so an input that never arrives eventually terminates.
277
+
278
+
279
+ async def test_stale_waiting_input_is_reaped_to_failed_wait_abandoned():
280
+ engine, store = _engine()
281
+ ex = await engine.create_root(type="conversation")
282
+ await engine.start(ex.id, holder="worker-1")
283
+ await engine.record(ex.id, Spend(usd=Decimal("0.40")))
284
+ await engine.request_input(ex.id)
285
+ store._status_changed_at[ex.id] = _at(0) # suspended at t0 (deterministic age)
286
+
287
+ reaped = await engine.reap_stale_waiting(older_than_seconds=3600, now=_at(7200))
288
+ assert reaped == [ex.id]
289
+ row = await engine.get_execution(ex.id)
290
+ assert row.status is ExecutionStatus.FAILED
291
+ assert row.error.error_type == "matrx_execution_wait_abandoned"
292
+ assert row.cost == Decimal("0.40") # spend preserved through the close
293
+
294
+
295
+ async def test_fresh_waiting_input_is_left_alone():
296
+ engine, store = _engine()
297
+ ex = await engine.create_root(type="conversation")
298
+ await engine.start(ex.id, holder="worker-1")
299
+ await engine.request_input(ex.id)
300
+ store._status_changed_at[ex.id] = _at(0)
301
+
302
+ assert await engine.reap_stale_waiting(older_than_seconds=3600, now=_at(1800)) == []
303
+ assert (await engine.get_execution(ex.id)).status is ExecutionStatus.WAITING_INPUT
304
+
305
+
306
+ async def test_staleness_measured_from_suspend_not_created_at():
307
+ """A month-old execution that suspended RECENTLY is NOT stale — staleness is
308
+ measured from the last status change, never created_at."""
309
+ engine, store = _engine()
310
+ ex = await engine.create_root(type="conversation")
311
+ await engine.start(ex.id, holder="worker-1")
312
+ await engine.request_input(ex.id)
313
+ # created long ago; suspend happened just now
314
+ store._executions[ex.id] = store._executions[ex.id].model_copy(
315
+ update={"created_at": _at(-30 * 24 * 3600)}
316
+ )
317
+ store._status_changed_at[ex.id] = _at(7000)
318
+
319
+ assert await engine.reap_stale_waiting(older_than_seconds=3600, now=_at(7200)) == []
320
+ assert (await engine.get_execution(ex.id)).status is ExecutionStatus.WAITING_INPUT
321
+
322
+
323
+ async def test_reap_stale_waiting_ignores_running_and_terminal():
324
+ engine, store = _engine()
325
+ running = await engine.create_root(type="utility")
326
+ await engine.start(running.id, holder="w")
327
+ done = await engine.create_root(type="utility")
328
+ await engine.start(done.id)
329
+ await engine.complete(done.id)
330
+ for ex_id in (running.id, done.id):
331
+ store._status_changed_at[ex_id] = _at(0)
332
+
333
+ assert await engine.reap_stale_waiting(older_than_seconds=60, now=_at(10_000)) == []
@@ -5,7 +5,7 @@ the mappings) for the payload-blind tables."""
5
5
 
6
6
  from __future__ import annotations
7
7
 
8
- from datetime import UTC, datetime
8
+ from datetime import UTC, datetime, timedelta
9
9
  from decimal import Decimal
10
10
  from types import SimpleNamespace
11
11
 
@@ -204,3 +204,18 @@ async def test_origin_upsert_conflict_on_app_surface(monkeypatch):
204
204
  await _store().upsert_origin(Origin(id="o1", app="frontend", surface="chat", allow_unknown=True))
205
205
  assert captured["conflict"] == ["app", "surface"]
206
206
  assert captured["data"]["allow_unknown"] is True
207
+
208
+
209
+ async def test_find_stale_waiting_filters_status_and_updated_at(monkeypatch):
210
+ seen = {}
211
+
212
+ def filter_(**kwargs):
213
+ seen.update(kwargs)
214
+ return _FakeQuery(rows=[])
215
+
216
+ _patch(monkeypatch, "_ExecRow", filter=filter_)
217
+ now = datetime(2026, 6, 25, 12, 0, tzinfo=UTC)
218
+ rows = await _store().find_stale_waiting(now=now, older_than_seconds=3600)
219
+ assert rows == []
220
+ assert seen["status"] == "waiting_input"
221
+ assert seen["updated_at__lt"] == now - timedelta(seconds=3600)
@@ -45,6 +45,18 @@ FORBIDDEN: frozenset[str] = frozenset(
45
45
  }
46
46
  )
47
47
 
48
+ # Exact identifiers that contain a forbidden WORD but are not a payload CONCEPT.
49
+ # Kept as whole identifiers (never a substring rule) so the guard keeps all its
50
+ # teeth: `token`, `access_token`, `token_count`, `tool_call` still trip.
51
+ #
52
+ # _entity_token — matrx-orm's MatrxEntity registry attribute, stamped into the
53
+ # GENERATED db/models_runtime.py by the schema generator (it is the ENTITY
54
+ # TYPE's name, e.g. `_entity_token = "global_execution"`). It is an ORM
55
+ # framework field, not an auth/LLM token, and its values are themselves
56
+ # payload-blind. The generated file cannot be hand-edited, so the exemption
57
+ # has to live here.
58
+ ALLOWED_IDENTIFIERS: frozenset[str] = frozenset({"_entity_token"})
59
+
48
60
  _PKG_ROOT = Path(__file__).resolve().parent.parent / "matrx_runtime"
49
61
 
50
62
 
@@ -54,6 +66,8 @@ def _forbidden_hits(path: Path) -> list[tuple[int, str]]:
54
66
  for tok in tokenize.tokenize(fh.readline):
55
67
  if tok.type != tokenize.NAME:
56
68
  continue
69
+ if tok.string in ALLOWED_IDENTIFIERS:
70
+ continue
57
71
  for part in tok.string.lower().split("_"):
58
72
  if part in FORBIDDEN:
59
73
  hits.append((tok.start[0], tok.string))
@@ -97,3 +111,22 @@ def test_the_scanner_actually_catches_a_forbidden_word(tmp_path: Path):
97
111
  " return label\n"
98
112
  )
99
113
  assert _forbidden_hits(clean) == []
114
+
115
+
116
+ def test_entity_token_exemption_is_narrow(tmp_path: Path):
117
+ """The ORM's `_entity_token` framework attribute is exempt — but the exemption
118
+ is an EXACT identifier match, so any real token concept still SCREAMS."""
119
+ ok = tmp_path / "ok.py"
120
+ ok.write_text('class M:\n _entity_token = "global_execution"\n')
121
+ assert _forbidden_hits(ok) == []
122
+
123
+ # Anything that is actually about tokens must still be caught.
124
+ for leak_src in (
125
+ "def f():\n token = 1\n",
126
+ "def f():\n access_token = 1\n",
127
+ "def f():\n token_count = 1\n",
128
+ "def f():\n entity_token = 1\n", # no leading underscore -> not the ORM attr
129
+ ):
130
+ leak = tmp_path / "leak2.py"
131
+ leak.write_text(leak_src)
132
+ assert _forbidden_hits(leak), f"guard went blind on: {leak_src!r}"
@@ -55,6 +55,61 @@ async def test_clean_exit_auto_completes_with_accrued_cost():
55
55
  assert settled.meters["input_tokens"] == Decimal("5")
56
56
 
57
57
 
58
+ async def test_for_existing_resume_accumulates_cost_across_segments():
59
+ """The RESUME path: ONE execution spans suspend→resume. A first segment parks the
60
+ execution WAITING_INPUT with cost recorded; a re-attached scope (ExecutionScope.for_existing
61
+ on the re-started execution) records more and completes — the terminal cost must be the
62
+ CUMULATIVE total (prior + this segment), NOT just the resumed segment (the row's cost is SET,
63
+ not added, at terminal, so the scope seeds _cost from the prior cost)."""
64
+ from matrx_runtime import ExecutionScope
65
+
66
+ engine = _engine()
67
+
68
+ # Segment 1: run, spend 0.10, then suspend awaiting input.
69
+ async with engine.execution(type="conversation") as exe:
70
+ await exe.record(Spend(usd=Decimal("0.10"), input_tokens=100))
71
+ ex_id = exe.id
72
+ await exe.request_input() # marks handled → clean exit does NOT auto-complete
73
+ parked = await engine.get_execution(ex_id)
74
+ assert parked.status is ExecutionStatus.WAITING_INPUT
75
+ assert parked.cost == Decimal("0.10")
76
+
77
+ # Segment 2 (resume): re-start → RUNNING, bind a scope to it, spend 0.05 more, complete.
78
+ started = await engine.start(ex_id, holder="resumer", lease_seconds=300)
79
+ assert started.status is ExecutionStatus.RUNNING
80
+ scope = ExecutionScope.for_existing(engine, started)
81
+ assert scope.cost == Decimal("0.10") # seeded from the prior cost
82
+ await scope.record(Spend(usd=Decimal("0.05"), input_tokens=50))
83
+ await scope.complete()
84
+
85
+ settled = await engine.get_execution(ex_id)
86
+ assert settled.status is ExecutionStatus.COMPLETED
87
+ assert settled.cost == Decimal("0.15") # cumulative, not just the 0.05 segment
88
+ assert settled.meters["input_tokens"] == Decimal("150") # tokens additive across segments
89
+
90
+
91
+ async def test_for_existing_resume_can_re_suspend():
92
+ """A resumed segment that suspends again re-parks the SAME execution WAITING_INPUT, ready
93
+ for the next resume — the execution cycles RUNNING↔WAITING_INPUT until it finally settles."""
94
+ from matrx_runtime import ExecutionScope
95
+
96
+ engine = _engine()
97
+ async with engine.execution(type="conversation") as exe:
98
+ await exe.record(Spend(usd=Decimal("0.10")))
99
+ ex_id = exe.id
100
+ await exe.request_input()
101
+
102
+ started = await engine.start(ex_id, holder="resumer", lease_seconds=300)
103
+ scope = ExecutionScope.for_existing(engine, started)
104
+ await scope.record(Spend(usd=Decimal("0.05")))
105
+ await scope.request_input() # re-suspend
106
+
107
+ reparked = await engine.get_execution(ex_id)
108
+ assert reparked.status is ExecutionStatus.WAITING_INPUT
109
+ assert reparked.cost == Decimal("0.15") # cumulative across both segments
110
+ assert reparked.lease_holder is None # lease released on re-suspend
111
+
112
+
58
113
  async def test_exception_auto_fails_and_reraises():
59
114
  engine = _engine()
60
115
  captured_id = None
File without changes
File without changes
File without changes