fix(0.6.0): establish explicit runtime lifecycle ownership - #226
Open
GionaGranchelli wants to merge 6 commits into
Open
fix(0.6.0): establish explicit runtime lifecycle ownership#226GionaGranchelli wants to merge 6 commits into
GionaGranchelli wants to merge 6 commits into
Conversation
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).
There was a problem hiding this comment.
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
TramaiandSovereignTramaiAutoCloseable, withTramaiowning a single sharedTramaiRuntimeacrosscreate()/runtime()and failing fast after close. - Strengthen
TramaiEngineshutdown 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
Tramaibean 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).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 unreachableTramaiEngineon every call;Tramai.runtime()created an independent second engine; Spring@AiServicebeans could therefore produceseveral hidden engines with no lifecycle owner.
What changed
Tramai(standalone): nowAutoCloseable, owns ONE lazily-createdTramaiRuntime(→ one engine) sharedby all
create()/runtime()calls. Lifecycle state lives in class-body fields (lifecycleLock,ownedRuntime,closed) so the published JVM constructor descriptor is unchanged.close()is synchronizedand idempotent. After close,
create()/runtime()fail fast with a fixedIllegalStateException("Tramai runtime is closed").TramaiEngine:close()cancels once and awaits engine-hierarchy termination (self-close safe via athread 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;SovereignTramaiisAutoCloseable, closing the delegate.@Bean(destroyMethod = "close")— context destruction closes the shared runtime; all@AiServicefactory beans share the one owned engine.
providers, stores, clients, executors, observers remain caller-owned unless their API transfers ownership.
Verification
./gradlew test --rerun-tasksgreen (all modules).verifyCancellationSafetyPASSED (no new findings).verifyPr -PchangeClass=public-apiPASSED (change policy + maintainability baseline).apiDump/apiCheckgreen — additive only:Tramai/SovereignTramaigainAutoCloseable/close();all existing constructor descriptors byte-identical.
Key tests
create()calls share one runtime lifecycle;runtime()returns the same runtimecreate()creates only one engine (8 threads × 50)create()racing withclose()cannot resurrect the runtime (50 iterations)close()harmless; close-before-first-use rejectsprovider-start timestamp must precede close-completion on any success)
runtime()returns the same wrapper; close propagatesFix rounds (review findings addressed)
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 testrunBlockingclosed re-check + test); P3-3: CHANGELOG entry incl. AutoCloseable supertype noteresumeApproval/registerServiceunguarded → 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)emitWhileOpen), deterministic termination within one chunk latency (+test: second chunk never delivered after close). P3s accepted: in-flightresumeApprovaldelivers 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 explicitlyScope notes
.hermes/plans/*.mdare working notes, not committed.