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