Skip to content

feat(runtime): Runtime, memory store, stub engine, contract suite - #51

Merged
sagi5060 merged 2 commits into
devfrom
feat/runtime-stub-engine
Aug 4, 2026
Merged

feat(runtime): Runtime, memory store, stub engine, contract suite#51
sagi5060 merged 2 commits into
devfrom
feat/runtime-stub-engine

Conversation

@sagi5060

@sagi5060 sagi5060 commented Aug 4, 2026

Copy link
Copy Markdown
Collaborator

Milestone-0 Step 2 — "Runtime + memory store + stub engine. The stamp/append/fan-out/yield loop against a scripted stub engine; contract-suite skeleton with the first invariants." The rest of epic Story 1 (status.py, ControlPort, threading RunContext through App) is deliberately not here — see the ledger.

Summary

Runtime.run() resolves an invocable, opens the run with run.started, iterates the engine's payloads, and for each one: stamps the envelope → appends to the log → fans out to sinks → yields. The order is the contract, so an event a consumer has seen is already persisted and a consumer that spots a seq gap can always refetch it.

StubEngine is the reference implementation of EnginePort, not a placeholder: a spec's native is a script of payloads to yield, with any exception in the sequence raised where it sits. That reproduces every way a run can end — completes, fails mid-stream, interrupts, or stops without a terminal event — with no model, network, or clock, which is why it stays the contract suite's fastest engine.

tests/contract/ is the deliverable. One flat CASES list; adding an engine means appending its cases and inheriting every invariant unchanged.

Production files touched

New: core/context.py, core/invocable.py, core/ports/{__init__,engine,store,sink}.py, runtime/service.py, adapters/{__init__,engines/__init__,engines/stub/__init__,stores/__init__,stores/memory/__init__}.py.
Modified: core/__init__.py (re-exports), .importlinter (two contracts), pyproject.toml (pytest pythonpath), CHANGELOG.md, docs/design/agentdeck-v2-architecture.md (three dated amendments).

Nothing in v1 changed. agentdeck/runtime/__init__.py is untouched, so importing the Runtime adds no new coupling to v1 exports — but note that the package __init__ still pulls in v1 settings/workspace transitively; Story 2 cleans that.

Judgment ledger

What the Runtime does beyond the design sketch

  1. The Runtime emits run.started itself, at seq 0. The payload's context snapshot is RunContext data; no engine should be trusted to copy it, and this guarantees the run's join point is always seq 0. Engines must not emit it (documented on EnginePort); a second one would show up as a contract-suite failure.
  2. An engine that ends on neither a terminal nor a suspending kind gets run.failed recorded for it. A run left open hangs every consumer forever, so check_terminal holding is worth three lines here. SUSPENDED_KINDS = {"run.interrupted", "run.paused"} lives in runtime/service.py, not core/events.py — the Runtime is the only thing that cares, and I did not want a schema-file edit in a non-schema PR (§7).
  3. run.failed.message carries the exception type name and the engine, never str(exc). An exception message can hold request content or a secret, and events go to sinks and (later) over SSE — this matches what tests/golden/test_golden_wire.py::test_failures_never_echo_the_error_message already guarantees at the HTTP edge. The full traceback goes to the log at ERROR with exc_info. Tested.
  4. The exception still reaches the caller (raise after recording). §5: the event is the record, the exception is the caller's — both, always.
  5. The Runtime does not convert engine exceptions to core types. §5 puts that at the adapter boundary, so a bare raise here is correct and errors.py needed no new class.
  6. log_key = session_id or run_id (a property on RunContext). Without it a sessionless run's events go nowhere and persist-before-yield quietly stops applying to one-off runs. This renamed the store port's parameter from session_id — amended in the design doc.
  7. from_seq: int = 0, inclusive, replacing the design's after_seq: int = 0, which would have excluded event 0 given contiguous-from-0 seq. Amended in the design doc.
  8. Sinks: create_task per sink per event, held in a set, exceptions logged and dropped. Never awaited, so a slow sink cannot pin the run (NFR-6, tested with a sink that never returns). The set is the reference-holder so the loop cannot collect a task mid-emit; §6's "tasks have owners" is satisfied by that plus the swallowing wrapper, not by a join. Ceiling noted in a comment: unbounded set, bounded per-sink queue if one ever piles up.
  9. clock is injected (Callable[[], datetime], defaulting to datetime.now(UTC)) so tests need no wall clock (§8).
  10. MemoryEventStore keys on (tenant, log_key). Two tenants may pick the same session id; isolation is not something a store gets to skip (§12). Tested.

What I deliberately did not build

  1. RunContext.gate and .caps — the two fields that are behavior rather than data. Nothing calls checkpoint() or require() until Stories 3 and 4, which build the things those fields would point at. Every data field is there, plus parent_run_id and triggered_by so run.started can be filled from the context alone. principal is a str (the schema's RunContextSnapshot.principal is a str); a Principal dataclass arrives with an auth story.
  2. EnginePort.resume() and .supports() — resume needs a checkpointer and thread-id semantics that do not exist yet; supports() exists for capability checks (Story 4). Selection is on engine: ClassVar[str] today. Amended in the design doc.
  3. core/status.py, ControlPort — a status machine with no signal source has no test that isn't tautological. They land together in Story 3. This is the main thing left of epic Story 1's acceptance list.
  4. InvocableRegistryRuntime takes a Mapping[str, InvocableSpec]. A dict already has the one method the Runtime needs; the registry arrives with discovery in Story 2.
  5. origin is spec.name for every event in the run. Per-payload attribution (an openai-agents handoff, where a sub-agent's events need their own origin) needs the port to carry it, and lands with the adapter that has the first real case — UC1, Step 3. Flagging it because UC1's falsifier is exactly "the renderer distinguishes speakers using only origin + message_id".
  6. adapters/<family>/<name>/ as packages with the class in __init__.py, per the design's target layout. Modules would have been the same file count once the family directories exist, so there was nothing to gain by diverging.
  7. InvocableSpec.capabilities — Story 4 owns CapabilityRequest. metadata and native cover the stub's needs.

Tests

  1. The harness is a flat CASES list in tests/contract/cases.py, not a per-engine class. Each Case declares ends: "terminal" | "suspended" so the suite knows which invariant to hold it to; the tests see nothing else about the engine. Adding LangGraph in Story 2 means appending its cases.
  2. Cases live in cases.py, not conftest.py, so the test modules can import Case/Played for annotations — import conftest is not safe across test directories (the basename collision that bit tests/golden in PR feat(core): canonical event schema v1 #49). tests/contract is on pythonpath explicitly, matching the existing tests/golden rationale.
  3. Two of the five stub cases are misbehaving engines on purpose (raises-midstream, stops-without-a-terminal-event) — the terminal-event invariant has to hold for them too, which is what proves the Runtime's guards rather than the stub's good manners.
  4. > 500 changed lines (1105). Production is ~450 and within the cap; the rest is the test suite this story exists to produce. The available slices — a Runtime with no engine, or a store with no caller — are untestable halves, so I declared the overage instead of splitting into unverifiable PRs (§13).

Test evidence CI cannot show

The two new import contracts bite. Added from agentdeck.adapters.stores.memory import MemoryEventStore to runtime/service.py and import redis to the memory store, then ran lint-imports:

agentdeck.runtime.service is not allowed to import redis:
-   agentdeck.runtime.service -> agentdeck.adapters.stores.memory (l.13)
    agentdeck.adapters.stores.memory -> redis (l.7)

agentdeck.adapters.stores.memory is not allowed to import redis:
-   agentdeck.adapters.stores.memory -> redis (l.7)

Reverted; 4 kept, 0 broken. The existing AST test (tests/core/test_import_law.py) already rglobs core, so it covers core/ports/ without a change.

Gate: ruff check + ruff format --check clean, ty check agentdeck clean, lint-imports 4 kept / 0 broken, pytest tests/193 passed, 5 skipped (was 135; the 5 skips are the ends-conditional invariant pair). Goldens untouched — no schema change, so tests/core/snapshots/ and tests/golden/snapshots/ are byte-identical.

Doc amendments

Three dated as built notes in docs/design/agentdeck-v2-architecture.md: §4.3 (which RunContext fields exist and why gate/caps wait), §4.5 (log_key/from_seq, EnginePort shipping with start only, unbuilt ports), §4.6 (the constructor as built, plus the two behaviors the sketch omits — Runtime-emitted run.started and the engine-left-open guard).

Milestone-0 Step 2 gate

Gate: contract suite green on the stub; killing a consumer mid-stream does not corrupt the store.

Both green — the second is test_an_abandoned_stream_leaves_the_store_intact, which closes the generator mid-run and asserts the log is a contiguous prefix opening with run.started.

The first moving part: Runtime.run() looks up an invocable, gets an event
iterator from an engine, and per event stamps the envelope, appends to the
log, fans out to sinks, then yields — persist-before-yield, in that order.

Adds the pieces it needs and nothing else: RunContext, InvocableSpec, the
first three ports (EnginePort, SessionStorePort, EventSinkPort), a memory
event log, and StubEngine — which is not a placeholder but the reference
implementation of the engine contract, playing scripted event sequences so
the invariants can be tested with no model, network or clock in the way.

The deliverable is tests/contract/: the cross-engine invariant suite,
parametrized over a flat list of cases each engine appends to. It asserts
run.started first at seq 0, contiguous seq, exactly one terminal event and
it is last, persist-before-yield checked at every step, the envelope coming
from the context rather than the engine, and an abandoned stream leaving the
log intact.

Two import-linter contracts activated: the Runtime imports core only, and
the two pure adapters stay pure.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI lite review requested due to automatic review settings August 4, 2026 22:17

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Introduces the first runnable v2 “use-case layer” runtime loop (persist-before-yield), plus a pure in-memory event store, a scripted stub engine, and a contract test suite that codifies cross-engine invariants for event streams.

Changes:

  • Add Runtime.run() orchestration (emit run.started, stamp envelopes, append→fan-out→yield, engine failure handling, sink fan-out semantics).
  • Add core v2 building blocks (RunContext, InvocableSpec, and engine/store/sink ports) plus pure adapters (MemoryEventStore, StubEngine).
  • Add a contract suite (tests/contract/) and targeted runtime/store tests to lock in invariants and edge cases.

Reviewed changes

Copilot reviewed 22 out of 22 changed files in this pull request and generated no comments.

Show a summary per file
File Description
agentdeck/runtime/service.py Implements the runtime event loop, envelope stamping, store persistence, sink fan-out, and engine termination guards.
agentdeck/core/context.py Adds RunContext (frozen) and log_key behavior (session-or-run keying).
agentdeck/core/invocable.py Adds InvocableSpec / InvocableKind for engine-neutral invocable selection.
agentdeck/core/ports/engine.py Defines EnginePort contract: engines yield payloads only.
agentdeck/core/ports/store.py Defines SessionStorePort event-log contract keyed by log_key, inclusive ranged reads.
agentdeck/core/ports/sink.py Defines EventSinkPort for fire-and-forget stream taps.
agentdeck/core/ports/init.py Re-exports ports for ergonomic imports.
agentdeck/core/init.py Re-exports RunContext, invocable types alongside existing schema exports.
agentdeck/adapters/engines/stub/init.py Adds StubEngine + stub_spec scripted engine for contract testing and deterministic runs.
agentdeck/adapters/stores/memory/init.py Adds MemoryEventStore keyed by (tenant, log_key) for isolation and deterministic tests.
agentdeck/adapters/init.py Documents adapter ring boundaries.
agentdeck/adapters/engines/init.py Documents engine adapter family.
agentdeck/adapters/stores/init.py Documents store adapter family.
tests/contract/cases.py Defines the flat CASES list and harness types (Case, Played) for contract runs.
tests/contract/conftest.py Wires a per-case runtime + memory store + frozen clock fixtures for contract execution.
tests/contract/test_event_stream.py Adds cross-engine invariants: run.started at seq 0, contiguous seq, terminal/suspended rules, persist-before-yield, etc.
tests/test_runtime_service.py Adds runtime-focused tests: resolution failures, injected clock, sessionless persistence, sink behavior, exception recording/propagation, engine history passing.
tests/test_memory_store.py Adds store-focused tests: append ordering, inclusive from_seq, tenant isolation, read immutability, payload round-tripping.
pyproject.toml Extends pytest pythonpath for contract suite imports (cases).
.importlinter Adds contracts preventing runtime/service from importing adapters/surfaces and keeping pure adapters dependency-clean.
docs/design/agentdeck-v2-architecture.md Adds “as built” amendments describing the realized runtime/store/port shapes (note: date issue flagged in review).
CHANGELOG.md Records the new runtime/core ports/adapters/contract suite as Unreleased changes.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Review findings on #51.

A seq range over a whole log spliced together the tail of every run in it,
because seq restarts at 0 per run while log_key is the session. The store
now reads either a whole log (read) or one run's inclusive range
(read_run) — the latter is what a consumer calls to refetch after a gap,
and it has to name the run for the range to mean anything.

The Runtime guarded "no terminal event" but not "exactly one, and last":
an engine yielding anything after a terminal payload got a second one
appended, producing a log that said the run both completed and failed.
A terminal payload now ends the read, so terminal-is-last holds by
construction and stray payloads are discarded. Added the third
misbehaving-engine case to the contract suite to prove it.

An abandoned stream left the run open in the log, indistinguishable from
one still in flight — the failure the no-terminal guard exists to prevent,
arriving by another door. The consumer walking away now records
run.cancelled, and the contract suite asserts the run is closed rather
than merely contiguous.

Also: EventSinkPort documents that emit is concurrent and unordered (and
the test sink now awaits before recording, so it can only pass if that
holds); Runtime.drain() awaits in-flight emits so a shutdown doesn't
destroy the last audit events; EnginePort.start returns an AsyncGenerator,
since the Runtime closes the stream when it stops reading early; the
memory store refuses an event stamped for another tenant; the stub raises
ConfigError rather than a bare TypeError for a missing script; and the
unbounded per-run history read carries a ponytail comment naming the
windowing trigger.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@sagi5060

sagi5060 commented Aug 4, 2026

Copy link
Copy Markdown
Collaborator Author

All seven addressed in d04dd17. Both blockers were real; #3 turned out to be a third instance of the same bug, not a deferral.

1. from_seq splicing runs — fixed by splitting the read. You were right that the one operation the whole contiguity argument rests on was the broken one. read(log_key, ctx) is now the session's whole history in append order; read_run(log_key, run_id, ctx, from_seq=0) is the inclusive range. A range read has to name the run, because seq restarts at 0 per run — the port docstring now says the log is ordered by append, not by seq, since claiming otherwise was the root of it. Two tests: test_a_seq_range_covers_one_run_and_never_splices_two reproduces your exact case, and the contract suite gained test_a_gap_can_be_refetched_from_the_store_by_run, which makes the refetch story executable instead of rhetorical.

I did consider deleting from_seq outright (nothing calls it with a non-default yet), but gap-refetch is the stated justification for contiguous seq, so leaving no way to do it would have weakened the invariant this PR exists to establish.

2. Terminal-is-last now holds by construction. Took your snippet: aclosing + break at the first terminal payload. Anything after one is discarded rather than logged, documented on EnginePort. Added stub/yields-after-a-terminal-event (message.completed, run.completed, usage.reported, a second run.completed) — the contract suite's existing terminal assertion catches it, and test_nothing_an_engine_yields_after_a_terminal_payload_reaches_the_log pins the log to ['run.started', 'run.completed'].

aclosing forced one signature change: EnginePort.start now returns AsyncGenerator, not AsyncIterator, because an AsyncIterator has no aclose and ty rejected it. That's the honest annotation — the Runtime closing the stream early is a real requirement on engines, not an implementation detail, so an engine's finally runs whether the run ended or the consumer walked away.

3. Abandonment — fixed, not deferred. Your framing convinced me: "run left open in the log" is one failure mode with three doors, and I'd only closed two. except GeneratorExit records run.cancelled(reason="consumer stopped reading") — no yield, nobody's listening, just the write. The guard is the exception type rather than a finally, so a completed run doesn't get a second terminal and an interrupted run isn't wrongly closed; both are now tests. The opening yield moved inside the try, since your repro abandons after exactly one event. test_an_abandoned_stream_leaves_a_closed_run_behind asserts check_terminal(stored) is None, not just contiguity.

4. Sink concurrency — documented on the port, and the test sink now earns its assertion. EventSinkPort.emit states it is called concurrently and out of order, and that a sink needing order sorts by seq or reads the store. Recorder awaits before appending, so it can only pass if the loose contract holds, and the assertion is recorder.by_seq() == events rather than an ordering the code never promised.

5. Runtime.drain()gather(*self._sink_tasks, return_exceptions=True), for the composition root at shutdown, never per event. Two sink tests now use it instead of racing an asyncio.Event.

6. ponytail: comment on the history read naming the windowing trigger.

7. Nits — all taken: ConfigError from the stub with a test; the memory store refuses an event stamped for another tenant (raise, not assert, since -O strips those and it's an isolation boundary); the .importlinter comment now says the contract asserts less than it appears to until Story 2 empties runtime/__init__.py; cases.pycontract_cases.py; __all__ sorted.

Gate: ruff + ruff-format clean, ty check agentdeck clean, lint-imports 4 kept / 0 broken, pytest tests/215 passed, 6 skipped (was 193/5). Goldens untouched.

Design doc amendments updated in place rather than added to: §4.5 now records the read/read_run split and the AsyncGenerator return, and §4.6 records all four ways the Runtime closes a run plus drain().

One thing I left alone deliberately: read_run's only production caller is still hypothetical — the gap-detecting consumer arrives with UC3's chaos test. The contract suite exercises it, so it isn't dead, but it is the one piece here whose real caller is downstream.

@sagi5060
sagi5060 merged commit bf49d33 into dev Aug 4, 2026
1 check passed
@sagi5060
sagi5060 deleted the feat/runtime-stub-engine branch August 4, 2026 22:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants