Skip to content

fix(0.6.0): establish explicit runtime lifecycle ownership - #226

Open
GionaGranchelli wants to merge 6 commits into
masterfrom
fix/0.6.0-runtime-lifecycle-ownership
Open

fix(0.6.0): establish explicit runtime lifecycle ownership#226
GionaGranchelli wants to merge 6 commits into
masterfrom
fix/0.6.0-runtime-lifecycle-ownership

Conversation

@GionaGranchelli

@GionaGranchelli GionaGranchelli commented Aug 10, 2026

Copy link
Copy Markdown
Owner

Summary

Implements Epic 1.3 — Runtime Lifecycle Ownership (docs/ROADMAP-0.6.0.md).

Invariant: every runtime-created engine has exactly one reachable lifecycle owner, and closing that owner
deterministically prevents further work and terminates TramAI-owned work.

Critical defect fixed: Tramai.create() created a fresh unreachable TramaiEngine on every call;
Tramai.runtime() created an independent second engine; Spring @AiService beans could therefore produce
several hidden engines with no lifecycle owner.

What changed

  • Tramai (standalone): now AutoCloseable, owns ONE lazily-created TramaiRuntime (→ one engine) shared
    by all create()/runtime() calls. Lifecycle state lives in class-body fields (lifecycleLock,
    ownedRuntime, closed) so the published JVM constructor descriptor is unchanged. close() is synchronized
    and idempotent. After close, create()/runtime() fail fast with a fixed IllegalStateException("Tramai runtime is closed").
  • TramaiEngine: close() cancels once and awaits engine-hierarchy termination (self-close safe via a
    thread marker). Proxies check a closed flag at the invocation seam before provider execution. Suspend
    bridges launch as children of the caller's job (preserving parent-cancellation propagation) while the engine
    tracks launched invocation jobs and cancels them on close. The caller continuation is resumed exactly
    once
    even when close() cancels a job before the dispatcher starts it.
  • SovereignTramai: create()/runtime() route through the delegate's owned runtime (no hidden engines);
    runtime() returns a cached wrapper; SovereignTramai is AutoCloseable, closing the delegate.
  • Spring: @Bean(destroyMethod = "close") — context destruction closes the shared runtime; all @AiService
    factory beans share the one owned engine.
  • Resource ownership rule (documented): TramAI closes only resources it creates; externally supplied
    providers, stores, clients, executors, observers remain caller-owned unless their API transfers ownership.

Verification

  • Full ./gradlew test --rerun-tasks green (all modules).
  • verifyCancellationSafety PASSED (no new findings).
  • verifyPr -PchangeClass=public-api PASSED (change policy + maintainability baseline).
  • apiDump/apiCheck green — additive only: Tramai/SovereignTramai gain AutoCloseable/close();
    all existing constructor descriptors byte-identical.

Key tests

  • Two create() calls share one runtime lifecycle; runtime() returns the same runtime
  • Concurrent create() creates only one engine (8 threads × 50)
  • create() racing with close() cannot resurrect the runtime (50 iterations)
  • Repeated close() harmless; close-before-first-use rejects
  • Old proxy invoked after close fails before provider executes (provider requests empty)
  • In-flight suspend invocation terminates on close
  • Close racing a fast suspend invocation never leaves work against a closed engine (100 iterations:
    provider-start timestamp must precede close-completion on any success)
  • Self-close from an owned coroutine does not deadlock
  • Spring context destruction closes the runtime; multiple AI-service beans all fail after close (proves no hidden engines)
  • SovereignTramai shares one owned engine; runtime() returns the same wrapper; close propagates
  • Externally supplied provider is NOT closed

Fix rounds (review findings addressed)

Round Head Finding → Fix
1 wave-1 commit agy P1-1: add-after-launch TOCTOU → synchronized launch+add with in-lock closed re-check; P2-1: cancel (not join) caller-parented jobs; P2-2: cached SovereignTramaiRuntime + identity test; P1-1 regression: exactly-once resume when a tracked job is cancelled pre-start (continuation freeze); race stress test with timestamp ordering; Spring multi-bean shared-runtime test
2 round-2 commits P2-3: blocking invocation racing close never delivers a result from a closed engine (post-runBlocking closed re-check + test); P3-3: CHANGELOG entry incl. AutoCloseable supertype note
3 round-3 commit Independent review P1: resumeApproval/registerService unguarded → fail fast on closed engine (+test); P2-1: suspend block re-checks closed after execute, converts in-flight success to fixed lifecycle error; P2-2: streaming flow body fails fast on collection against closed engine (+test, provider untouched)
4 round-4 commit Review P2: mid-collection close left a live stream delivering chunks after close → every emitted chunk gated on engine-open (emitWhileOpen), deterministic termination within one chunk latency (+test: second chunk never delivered after close). P3s accepted: in-flight resumeApproval delivers its committed result (failing post-hoc would orphan the consumed continuation); sovereign lazy wrapper returns inert-but-throwing wrapper post-close. P3 nit: test asserts first chunk delivered explicitly

Scope notes

  • Lifecycle only — Epic 1.4 (network boundary) and Epic 2.1 (EngineComponents) intentionally excluded.
  • .hermes/plans/*.md are working notes, not committed.

Epic 1.3 (Runtime Lifecycle Ownership): every runtime-created engine has
exactly one reachable lifecycle owner; closing it deterministically
prevents further work and terminates TramAI-owned work.

- Tramai now owns ONE lazily-created TramaiRuntime (one engine) shared by
  all create()/runtime() calls; lifecycle state lives in class-body fields
  so the published JVM constructor descriptor is unchanged. Tramai is now
  AutoCloseable; close() is idempotent and synchronized; after close,
  create()/runtime() fail fast with a fixed IllegalStateException.
- Engine proxies fail after close BEFORE provider execution (closed flag
  checked at the invocation handler seam).
- TramaiEngine.close() cancels once and joins (except from its own
  coroutines, avoiding self-close deadlock), and explicitly cancels tracked
  suspend-invocation jobs: suspend bridges launch as children of the CALLER
  job (preserving parent-cancellation propagation) while the engine tracks
  them so close() owns in-flight work.
- SovereignTramai propagates the same ownership: create()/runtime() share
  the delegate's owned runtime; SovereignTramai is AutoCloseable closing
  the delegate.
- Spring: the Tramai bean uses destroyMethod = close so context destruction
  closes the shared runtime; multiple @aiservice beans share one engine.
- Resource ownership rule documented: TramAI closes only resources it
  creates; externally supplied providers/stores/clients/observers remain
  caller-owned.
- Tests: shared lifecycle, single engine under concurrency, no resurrection
  after close, idempotent close, proxy-after-close fails before provider,
  in-flight suspend terminates on close, self-close no deadlock, Spring
  destruction + shared-engine, sovereign equivalence, external deps not
  closed. api dumps updated additively (AutoCloseable only).
Copilot AI lite review requested due to automatic review settings August 10, 2026 13: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

Establishes explicit runtime lifecycle ownership so a single Tramai instance deterministically owns (and can close) exactly one lazily-created runtime/engine, with corresponding changes in engine shutdown semantics, sovereign delegation, Spring bean lifecycle wiring, and new tests validating the ownership/closure invariants.

Changes:

  • Make Tramai and SovereignTramai AutoCloseable, with Tramai owning a single shared TramaiRuntime across create()/runtime() and failing fast after close.
  • Strengthen TramaiEngine shutdown behavior with a closed flag, invocation-time closed checks, and tracking/cancellation of in-flight suspend invocations on close.
  • Wire Spring to close the shared Tramai bean at context shutdown and add coverage across standalone/engine/sovereign/spring.

Reviewed changes

Copilot reviewed 11 out of 11 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
tramai-standalone/src/test/kotlin/dev/tramai/standalone/TramaiTest.kt Adds lifecycle ownership and close-behavior tests for standalone Tramai.
tramai-standalone/src/main/kotlin/dev/tramai/standalone/Tramai.kt Makes Tramai AutoCloseable and enforces a single owned runtime with synchronized lifecycle state.
tramai-standalone/api/tramai-standalone.api Public API update reflecting AutoCloseable + close().
tramai-spring/src/test/kotlin/dev/tramai/spring/TramaiAutoConfigurationTest.kt Adds tests ensuring Spring context destruction closes the shared runtime and invalidates proxies.
tramai-spring/src/main/kotlin/dev/tramai/spring/TramaiAutoConfiguration.kt Configures the Tramai bean with destroyMethod = "close".
tramai-sovereign/src/test/kotlin/dev/tramai/sovereign/SovereignTramaiTest.kt Adds tests for shared engine ownership and close propagation in sovereign mode.
tramai-sovereign/src/main/kotlin/dev/tramai/sovereign/SovereignTramai.kt Makes SovereignTramai AutoCloseable and delegates close() to standalone Tramai.
tramai-sovereign/api/tramai-sovereign.api Public API update reflecting AutoCloseable + close().
tramai-engine/src/test/kotlin/dev/tramai/engine/TramaiEngineTest.kt Adds tests for post-close proxy failure, in-flight cancellation on close, and self-close non-deadlock.
tramai-engine/src/main/kotlin/dev/tramai/engine/TramaiEngine.kt Implements closed-state gating, suspend-invocation job tracking, and close-time cancellation/join behavior.
docs/modules/tramai-engine.md Updates engine API reference text for close().

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +707 to +708
activeInvocationJobs += launched
launched.invokeOnCompletion { activeInvocationJobs -= launched }
model("claude-sonnet-4-20250514", "external")
}

tramai.close()
| `TramaiEngine` | Class | Main engine: creates AI-backed proxies from annotated interfaces |
| `TramaiEngine.create()` | Method | Returns a JVM proxy implementing the given service type |
| `TramaiEngine.close()` | Method | Cancels the engine's coroutine job hierarchy |
| `TramaiEngine.close()` | Method | Cancels the engine's coroutine job hierarchy and waits for externally initiated shutdown |
…n runtime cache, race coverage

agy round-1 review fixes (PR #226):

- Suspend invokeSuspend now resumes the caller continuation exactly once
  even when close() cancels the tracked job BEFORE the dispatcher starts
  it: the block records its outcome before resuming, and invokeOnCompletion
  resumes with a cancellation when the block never ran — otherwise the
  caller's suspension would freeze forever.
- close() cancels tracked caller-parented invocation jobs but never joins
  them: their completion is dispatched on the CALLER's dispatcher, which
  may be blocked waiting on this very close() (joining would deadlock).
  The engine scope job is still cancelled-and-joined.
- SovereignTramai.runtime() caches the wrapper around the delegate's single
  owned runtime (repeated calls return the same instance; identity test).
- New tests: close racing a fast suspend invocation never leaves work
  against a closed engine (100 iterations; provider-start vs close-complete
  ordering asserted); multiple Spring AI-service beans share one runtime and
  all fail after context close; sovereign runtime identity.
…gelog

agy round-1 P2-3 + P3-3 (PR #226):

- Blocking proxy invocations re-check the closed flag after the caller-owned
  runBlocking completes, so a call that raced close() surfaces the fixed
  'Tramai runtime is closed' IllegalStateException instead of delivering a
  result computed against an already-closed engine. Test added.
- CHANGELOG entry for PR #226 including the AutoCloseable supertype note
  (source-compatible; affects compiled negative instanceof checks).
…ming flows

Independent review findings (PR #226):

- P1: resumeApproval and registerService ran provider work deterministically
  after close() — the closed guard existed only on create(), the proxy invoke
  seam, and the suspend launch. Both entry points now fail fast with the fixed
  'Tramai runtime is closed' IllegalStateException.
- P2-1: the suspend invocation block could deliver a success computed against
  a closed engine (caller-parented job, not joined by close). The launched
  block now re-checks the closed flag after execute() and converts a success
  into the fixed lifecycle error, mirroring the blocking path.
- P2-2: streaming flows escaped close() entirely — a flow obtained before
  close() and collected after ran the full provider pipeline. The flow body
  now fails fast on collection against a closed engine.
- Tests: registerService/resumeApproval fail fast on a closed engine; streaming
  flow collected after close fails before provider executes (provider untouched).
Round-3 review P2 (PR #226): mid-collection close left a live provider
stream delivering chunks after close() — the flow-body start guard only
covered collection-after-close, and the collector's job is not cancelled by
close(), so cooperative cancellation never fired. Every emitted chunk is now
gated on the engine being open (emitWhileOpen), so a cold flow being
collected at close() time terminates deterministically within one chunk
latency with the fixed 'Tramai runtime is closed' error.

Test: mid-collection close terminates an in-flight stream (first chunk
delivered, close, gate release -> second chunk never delivered).
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