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.
- {matrx_runtime-0.0.2 → matrx_runtime-0.0.3}/PKG-INFO +1 -1
- {matrx_runtime-0.0.2 → matrx_runtime-0.0.3}/matrx_runtime/engine.py +60 -0
- {matrx_runtime-0.0.2 → matrx_runtime-0.0.3}/matrx_runtime/orm_store.py +13 -0
- {matrx_runtime-0.0.2 → matrx_runtime-0.0.3}/matrx_runtime/store.py +30 -0
- {matrx_runtime-0.0.2 → matrx_runtime-0.0.3}/pyproject.toml +1 -1
- {matrx_runtime-0.0.2 → matrx_runtime-0.0.3}/tests/test_lease_reaper.py +62 -0
- {matrx_runtime-0.0.2 → matrx_runtime-0.0.3}/tests/test_orm_store.py +16 -1
- {matrx_runtime-0.0.2 → matrx_runtime-0.0.3}/tests/test_payload_blind.py +33 -0
- {matrx_runtime-0.0.2 → matrx_runtime-0.0.3}/tests/test_scope.py +55 -0
- {matrx_runtime-0.0.2 → matrx_runtime-0.0.3}/.gitignore +0 -0
- {matrx_runtime-0.0.2 → matrx_runtime-0.0.3}/CLAUDE.md +0 -0
- {matrx_runtime-0.0.2 → matrx_runtime-0.0.3}/README.md +0 -0
- {matrx_runtime-0.0.2 → matrx_runtime-0.0.3}/matrx_runtime/__init__.py +0 -0
- {matrx_runtime-0.0.2 → matrx_runtime-0.0.3}/matrx_runtime/_config.py +0 -0
- {matrx_runtime-0.0.2 → matrx_runtime-0.0.3}/matrx_runtime/db/__init__.py +0 -0
- {matrx_runtime-0.0.2 → matrx_runtime-0.0.3}/matrx_runtime/db/bootstrap/000_substrate.sql +0 -0
- {matrx_runtime-0.0.2 → matrx_runtime-0.0.3}/matrx_runtime/db/bootstrap/010_identity.sql +0 -0
- {matrx_runtime-0.0.2 → matrx_runtime-0.0.3}/matrx_runtime/db/bootstrap/020_runtime.sql +0 -0
- {matrx_runtime-0.0.2 → matrx_runtime-0.0.3}/matrx_runtime/db/models_runtime.py +0 -0
- {matrx_runtime-0.0.2 → matrx_runtime-0.0.3}/matrx_runtime/lifecycle.py +0 -0
- {matrx_runtime-0.0.2 → matrx_runtime-0.0.3}/matrx_runtime/models.py +0 -0
- {matrx_runtime-0.0.2 → matrx_runtime-0.0.3}/matrx_runtime/origins.py +0 -0
- {matrx_runtime-0.0.2 → matrx_runtime-0.0.3}/tests/__init__.py +0 -0
- {matrx_runtime-0.0.2 → matrx_runtime-0.0.3}/tests/test_bootstrap_schema.py +0 -0
- {matrx_runtime-0.0.2 → matrx_runtime-0.0.3}/tests/test_engine.py +0 -0
- {matrx_runtime-0.0.2 → matrx_runtime-0.0.3}/tests/test_idempotency.py +0 -0
- {matrx_runtime-0.0.2 → matrx_runtime-0.0.3}/tests/test_lifecycle.py +0 -0
- {matrx_runtime-0.0.2 → matrx_runtime-0.0.3}/tests/test_meter_and_context.py +0 -0
- {matrx_runtime-0.0.2 → matrx_runtime-0.0.3}/tests/test_origins.py +0 -0
- {matrx_runtime-0.0.2 → matrx_runtime-0.0.3}/tests/test_proof.py +0 -0
- {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.
|
|
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.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|