matrx-runtime 0.0.2__tar.gz → 0.0.4__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.4}/CLAUDE.md +5 -1
  2. {matrx_runtime-0.0.2 → matrx_runtime-0.0.4}/PKG-INFO +1 -1
  3. {matrx_runtime-0.0.2 → matrx_runtime-0.0.4}/matrx_runtime/db/bootstrap/020_runtime.sql +6 -0
  4. {matrx_runtime-0.0.2 → matrx_runtime-0.0.4}/matrx_runtime/engine.py +77 -0
  5. {matrx_runtime-0.0.2 → matrx_runtime-0.0.4}/matrx_runtime/orm_store.py +13 -0
  6. {matrx_runtime-0.0.2 → matrx_runtime-0.0.4}/matrx_runtime/store.py +36 -0
  7. {matrx_runtime-0.0.2 → matrx_runtime-0.0.4}/pyproject.toml +1 -1
  8. {matrx_runtime-0.0.2 → matrx_runtime-0.0.4}/tests/test_lease_reaper.py +62 -0
  9. {matrx_runtime-0.0.2 → matrx_runtime-0.0.4}/tests/test_orm_store.py +16 -1
  10. {matrx_runtime-0.0.2 → matrx_runtime-0.0.4}/tests/test_payload_blind.py +33 -0
  11. {matrx_runtime-0.0.2 → matrx_runtime-0.0.4}/tests/test_scope.py +55 -0
  12. {matrx_runtime-0.0.2 → matrx_runtime-0.0.4}/.gitignore +0 -0
  13. {matrx_runtime-0.0.2 → matrx_runtime-0.0.4}/README.md +0 -0
  14. {matrx_runtime-0.0.2 → matrx_runtime-0.0.4}/matrx_runtime/__init__.py +0 -0
  15. {matrx_runtime-0.0.2 → matrx_runtime-0.0.4}/matrx_runtime/_config.py +0 -0
  16. {matrx_runtime-0.0.2 → matrx_runtime-0.0.4}/matrx_runtime/db/__init__.py +0 -0
  17. {matrx_runtime-0.0.2 → matrx_runtime-0.0.4}/matrx_runtime/db/bootstrap/000_substrate.sql +0 -0
  18. {matrx_runtime-0.0.2 → matrx_runtime-0.0.4}/matrx_runtime/db/bootstrap/010_identity.sql +0 -0
  19. {matrx_runtime-0.0.2 → matrx_runtime-0.0.4}/matrx_runtime/db/models_runtime.py +0 -0
  20. {matrx_runtime-0.0.2 → matrx_runtime-0.0.4}/matrx_runtime/lifecycle.py +0 -0
  21. {matrx_runtime-0.0.2 → matrx_runtime-0.0.4}/matrx_runtime/models.py +0 -0
  22. {matrx_runtime-0.0.2 → matrx_runtime-0.0.4}/matrx_runtime/origins.py +0 -0
  23. {matrx_runtime-0.0.2 → matrx_runtime-0.0.4}/tests/__init__.py +0 -0
  24. {matrx_runtime-0.0.2 → matrx_runtime-0.0.4}/tests/test_bootstrap_schema.py +0 -0
  25. {matrx_runtime-0.0.2 → matrx_runtime-0.0.4}/tests/test_engine.py +0 -0
  26. {matrx_runtime-0.0.2 → matrx_runtime-0.0.4}/tests/test_idempotency.py +0 -0
  27. {matrx_runtime-0.0.2 → matrx_runtime-0.0.4}/tests/test_lifecycle.py +0 -0
  28. {matrx_runtime-0.0.2 → matrx_runtime-0.0.4}/tests/test_meter_and_context.py +0 -0
  29. {matrx_runtime-0.0.2 → matrx_runtime-0.0.4}/tests/test_origins.py +0 -0
  30. {matrx_runtime-0.0.2 → matrx_runtime-0.0.4}/tests/test_proof.py +0 -0
  31. {matrx_runtime-0.0.2 → matrx_runtime-0.0.4}/tests/test_tree_read.py +0 -0
@@ -51,7 +51,11 @@ A test (`test_proof.py`) proves it: one engine drives `utility` + `conversation`
51
51
  unleased actives are structurally invisible to the reaper, **`find_stuck(now=, older_than=)`**
52
52
  is the independent second layer: it RETURNS (never mutates) the reaper-blind stuck rows
53
53
  (unleased/ never-started actives past a deadline) so the host watchdog screams — no work is
54
- ever silently lost. Both host-scheduled.
54
+ ever silently lost. `waiting_input` is excluded from BOTH layers (a legitimate unbounded
55
+ wait), so **`reap_stale_waiting(older_than_seconds=)`** is the host's far-future
56
+ abandonment backstop: any execution still waiting past the host threshold (staleness from
57
+ the last status change, never `created_at`) → FAILED `matrx_execution_wait_abandoned`,
58
+ cost preserved, CAS-safe. All host-scheduled.
55
59
  - **`MeterEvent`** (`models.py`) — the metering primitive. A consumer subclasses it
56
60
  with numeric fields; EVERY numeric is a tracked quantity summed per execution +
57
61
  tree; `money_field` feeds the budgeted `cost`. Covers money, tokens, rate-limits,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: matrx-runtime
3
- Version: 0.0.2
3
+ Version: 0.0.4
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
@@ -104,6 +104,12 @@ create index if not exists ix_exec_type_status on runtime.global_execution (type
104
104
  -- an index range scan, not a growing seq scan over every execution ever created.
105
105
  create index if not exists ix_exec_stuck_scan on runtime.global_execution (created_at)
106
106
  where status in ('pending','running','paused');
107
+ -- waiting_input abandonment backstop (reap_stale_waiting): stale-suspend scan by the
108
+ -- suspend instant (updated_at). PARTIAL for the same reason as the stuck scan — a
109
+ -- healthy spine holds few parked rows, so the per-minute sweep stays an index range
110
+ -- scan instead of a growing seq scan.
111
+ create index if not exists ix_exec_waiting_scan on runtime.global_execution (updated_at)
112
+ where status = 'waiting_input';
107
113
  create index if not exists ix_exec_event on runtime.global_execution_event (execution_id, created_at);
108
114
  create index if not exists ix_exec_ckpt on runtime.global_execution_checkpoint (execution_id, created_at desc);
109
115
  create index if not exists ix_meter_exec on runtime.global_meter_entry (execution_id);
@@ -283,6 +283,56 @@ 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
+ outcome = ExecutionOutcome(
313
+ status=ExecutionStatus.FAILED, cost=ex.cost, error=error
314
+ )
315
+ # The CAS is pinned to WAITING_INPUT — NOT the generic fail() path, whose
316
+ # _settle CAS's on whatever status it re-reads. With fail(), a resume that
317
+ # flipped WAITING_INPUT→RUNNING between the finder and the settle would be
318
+ # read as RUNNING and RUNNING→FAILED is a LEGAL transition — false-failing a
319
+ # live, freshly-resumed run. Pinning expected=WAITING_INPUT makes any racing
320
+ # resume win the CAS: the sweep then simply skips the row.
321
+ result = transition(ExecutionStatus.WAITING_INPUT, ExecutionStatus.FAILED, outcome=outcome)
322
+ won = await self._store.compare_and_set_status(
323
+ ex.id,
324
+ expected=ExecutionStatus.WAITING_INPUT,
325
+ new_status=ExecutionStatus.FAILED,
326
+ ended_at=_now(),
327
+ cost=ex.cost,
328
+ error=error,
329
+ )
330
+ if not won:
331
+ continue # resumed/settled out from under us — the live outcome wins
332
+ await self._emit(ex.id, result.event, detail=_outcome_detail(outcome))
333
+ reaped.append(ex.id)
334
+ return reaped
335
+
286
336
  async def find_stuck(
287
337
  self,
288
338
  *,
@@ -686,6 +736,33 @@ class ExecutionScope:
686
736
  raise RuntimeError("ExecutionScope used before it was entered (`async with`).")
687
737
  return self._execution
688
738
 
739
+ @classmethod
740
+ def for_existing(
741
+ cls, engine: ExecutionEngine, execution: GlobalExecution
742
+ ) -> ExecutionScope:
743
+ """Bind a scope to an ALREADY-STARTED execution — the RESUME path, where ONE
744
+ execution spans many requests (design §`request` ⊂ `execution`). The caller has
745
+ re-`start()`ed a PAUSED/WAITING_INPUT execution back to RUNNING (with a fresh
746
+ lease); this wraps it so the SAME `record`/`note`/`complete`/`fail`/`request_input`
747
+ machinery drives the resumed segment — no create, no second execution.
748
+
749
+ `scope._cost` is SEEDED from the execution's prior `cost`, because the terminal
750
+ settle SETs the row's `cost` to `scope._cost` (absolute, not additive). Without the
751
+ seed a resumed segment's `complete(cost=segment)` would OVERWRITE the accumulated
752
+ cost with just this segment's spend, dropping every prior segment. Seeding makes the
753
+ terminal write the CUMULATIVE total (prior + this segment). Metered quantities
754
+ (tokens, etc.) are always additive via `record`, so they need no seeding."""
755
+ scope = cls(
756
+ engine,
757
+ type=execution.type,
758
+ execution_id=execution.id,
759
+ link_kind=execution.link_kind,
760
+ link_id=execution.link_id,
761
+ )
762
+ scope._execution = execution
763
+ scope._cost = execution.cost
764
+ return scope
765
+
689
766
  async def begin(self) -> ExecutionScope:
690
767
  """Create + start the execution (the `__aenter__` body, exposed). Use this with
691
768
  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(
@@ -15,6 +15,7 @@ from typing import Protocol, runtime_checkable
15
15
 
16
16
  from matrx_runtime.models import (
17
17
  STUCK_CANDIDATE_STATUSES,
18
+ TERMINAL_STATUSES,
18
19
  ExecutionCheckpoint,
19
20
  ExecutionControl,
20
21
  ExecutionError,
@@ -132,6 +133,19 @@ class ExecutionStore(Protocol):
132
133
  rows so the host can classify + scream."""
133
134
  ...
134
135
 
136
+ async def find_stale_waiting(
137
+ self, *, now: datetime, older_than_seconds: float
138
+ ) -> list[GlobalExecution]:
139
+ """`waiting_input` executions whose status last changed before
140
+ `now - older_than_seconds`. WAITING_INPUT is a legitimate unbounded wait, so it
141
+ is excluded from BOTH crash-recovery layers (the reaper sweeps leased RUNNING;
142
+ `find_stuck_executions` excludes it) — which means an input that never arrives
143
+ has no upper bound at all. This is the candidate set for the host's far-future
144
+ abandonment backstop (`ExecutionEngine.reap_stale_waiting`). Staleness is
145
+ measured from the LAST status change (a month-old execution that suspended
146
+ yesterday is NOT stale), never from `created_at`."""
147
+ ...
148
+
135
149
  # --- events / checkpoints / meters --------------------------------------
136
150
  async def append_event(self, event: ExecutionEvent) -> None: ...
137
151
  async def list_events(self, execution_id: str) -> list[ExecutionEvent]:
@@ -156,6 +170,9 @@ class InMemoryExecutionStore:
156
170
  self._meter_entries: list[MeterEntry] = []
157
171
  self._controls: dict[str, ExecutionControl] = {}
158
172
  self._checkpoints: dict[str, list[ExecutionCheckpoint]] = {}
173
+ # Last successful status-change instant per execution — the in-memory stand-in
174
+ # for the DB row's touch-trigger `updated_at`; feeds find_stale_waiting.
175
+ self._status_changed_at: dict[str, datetime] = {}
159
176
  self._lock = asyncio.Lock()
160
177
 
161
178
  @staticmethod
@@ -228,6 +245,12 @@ class InMemoryExecutionStore:
228
245
  "error": error if error is not None else ex.error,
229
246
  }
230
247
  )
248
+ if new_status in TERMINAL_STATUSES:
249
+ # Terminal rows can never become stale-waiting candidates — drop the
250
+ # entry so the map doesn't grow unboundedly in a long-lived store.
251
+ self._status_changed_at.pop(execution_id, None)
252
+ else:
253
+ self._status_changed_at[execution_id] = datetime.now(UTC)
231
254
  return True
232
255
 
233
256
  async def add_meters(
@@ -325,6 +348,19 @@ class InMemoryExecutionStore:
325
348
  and (ex.lease_expires_at is None or ex.lease_expires_at < now)
326
349
  ]
327
350
 
351
+ async def find_stale_waiting(
352
+ self, *, now: datetime, older_than_seconds: float
353
+ ) -> list[GlobalExecution]:
354
+ cutoff = now - timedelta(seconds=older_than_seconds)
355
+ out: list[GlobalExecution] = []
356
+ for ex in self._executions.values():
357
+ if ex.status is not ExecutionStatus.WAITING_INPUT:
358
+ continue
359
+ changed = self._status_changed_at.get(ex.id) or ex.created_at
360
+ if changed is not None and changed < cutoff:
361
+ out.append(ex)
362
+ return out
363
+
328
364
  # --- events / checkpoints / meters --------------------------------------
329
365
  async def append_event(self, event: ExecutionEvent) -> None:
330
366
  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.4"
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