How on_session_ready() ended module blindness
and made the logging hook the first to benefit
A new post-composition lifecycle hook that gives every module a guaranteed moment
when the full coordinator is assembled — with real-world verification via
Digital Twin Universe.
April 2026
Use ← → or click to navigate
When a module's mount() runs, the coordinator is only partially assembled. Each phase completes before the next even begins:
_session_init.py simply returns.
There is no "all-modules-ready" signal. A hook that wanted to know whether a tool was already mounted
had to guess — and defend against both orderings.
Result: every cross-module interaction required defensive dual-path initialization. And one bug slipped through undetected for months.
# mount() registers the event handler AND # immediately runs setup if the tool is # already there — because it might not be. coordinator.hooks.register( "skills:discovered", on_skills_discovered ) if coordinator.get_capability( TOOL_SKILLS_DISCOVERY_CAPABILITY ) is not None: # duplicate path — tool mounted first await _refresh_watched_skills(...)
async def _discover_events(coordinator): # Path 1: core canonical list discovered = set(ALL_EVENTS) # Path 2: contribution channels contributions = await coordinator .collect_contributions("obs.events") for ev in contributions: discovered.update(ev) # Path 3: legacy capability fallback cap = coordinator.get_capability( "observability.events") if cap is not None: discovered.update(cap() if callable(cap) else cap) return discovered # 3 paths for 1 answer
The context-intelligence hook used collect_contributions() to discover observable events.
tool-delegate registered its 5 delegation events using the wrong API:
# tool-delegate/src/__init__.py obs_events = coordinator.get_capability( "observability.events") or [] obs_events.extend([ "delegate:agent_spawned", "delegate:agent_completed", ...3 more... ]) coordinator.register_capability( "observability.events", obs_events ) # register_capability ≠ register_contributor # collect_contributions() never sees this
# tool-delegate/src/__init__.py coordinator.register_contributor( "observability.events", "tool-delegate", lambda: [ "delegate:agent_spawned", "delegate:agent_resumed", "delegate:agent_completed", "delegate:agent_cancelled", "delegate:error", ], ) # now visible to collect_contributions()
Before touching the kernel, a multi-agent Parallax Discovery investigation mapped the full scope. Four dedicated sessions, ~1,400 JSONL events each. Three independent agent perspectives per topic.
grep -r "did_mount|post_mount|modules_ready" amplifier-core/
→ zero results. No lifecycle exists.
events.rs.
None were mount-lifecycle events.
| Pattern | Instances | Fixable |
|---|---|---|
| Dual-path init | 4 | 100% |
| Mount-order comments | 3+ | 100% |
| Event fallback chains | 2 | 100% |
| Defensive get_capability | 9 | 33% |
| App-level post-mount rewiring | ~6 | ~67% |
async def on_session_ready(coordinator) -> None: """Called after ALL modules have completed their mount() calls. The coordinator is fully composed. Return value is ignored. Exceptions are non-fatal and isolated. """ # Tools are here. Providers are here. # Every hook is here. No guessing. contributions = await coordinator .collect_contributions("obs.events")
__on_session_ready__ attribute — no new entry point needed.
# _session_init.py — Phase 6 on_session_ready_queue = loader.get_on_session_ready_queue() # sync for module_id, on_session_ready_fn in on_session_ready_queue: try: await on_session_ready_fn(coordinator) except Exception as e: logger.warning(f"on_session_ready for '{module_id}' raised") await coordinator.hooks.emit( MODULE_ON_SESSION_READY_FAILED, # observable — new event #42 {"module_id": module_id, "error": str(e)} ) loader.clear_on_session_ready_queue() # drain — prevents double-dispatch
Enqueue happens after each successful mount() — failed mounts never get a Phase 6 callback. Failures in Phase 6 are non-fatal and self-report via the event bus.
async def mount(coordinator, config=None): config = config or {} priority = int(config.get("priority", 100)) session_logger = SessionLogger(...) # define handler closure... async def handler(event, data): session_logger.write(...) events = list(ALL_EVENTS) if auto_discover: # race condition: tools may not be mounted discovered = coordinator.get_capability( "observability.events") or [] events.extend(discovered) for ev in events: coordinator.hooks.register(ev, handler)
_deferred_configs: list[dict] = [] async def mount(coordinator, config=None): _deferred_configs.append(config or {}) async def on_session_ready(coordinator): while _deferred_configs: config = _deferred_configs.pop(0) # ... build logger, closure ... # legacy path preserved if auto_discover: discovered = coordinator .get_capability("obs.events") or [] events.extend(discovered) # new path — safe here, all tools mounted if auto_discover: contributions = await coordinator .collect_contributions("obs.events") for c in contributions: events.extend(c) for ev in events: coordinator.hooks.register(ev, handler)
All unit tests pass in 0.09s. But pytest can't answer the real question: "Does the Amplifier module loader actually call on_session_ready? Does the kernel event bus work end-to-end? Are events written to disk?"
mount() defers config ✓on_session_ready() registers handlers ✓collect_contributions() is called ✓amplifier run session ✓ENVIRONMENT
Ubuntu 24.04 · Rust toolchain · uv · Gitea mirror
SOURCE
Local feat/on-session-ready branch via Gitea
BUILD
uv sync --group dev → compiles amplifier-core from Rust source
# run via amplifier-digital-twin exec cd /root/work/amplifier-module-hooks-logging && \ PATH=/root/.local/bin:/root/.cargo/bin:$PATH \ uv run pytest tests/ -v ======================== 24 passed in 0.09s ========================
WHEEL STRATEGY
pypi_overrides.wheel_from_git: builds amplifier-core wheel on host (where cargo lives), serves via in-container pypiserver. Container needs no Rust.
URL REWRITE
allow_uv_github_fast_path: false — critical flag. Without it, uv resolves SHAs via GitHub API, bypassing local Gitea mirror silently.
TEST BUNDLE
loop-basic + provider-mock + hooks-logging from Gitea. No API key required.
ASSERTION
grep JSONL on disk for session:start, prompt:submit, session:end. Binary PASS/FAIL.
for ev in "session:start" "prompt:submit" "session:end"; do grep -q '"event": "'"$ev"'"' "$JSONL" || MISSING="$MISSING $ev" done PASS: all required events present (session:start, prompt:submit, session:end)
register_capability → register_contributor). Gap closed immediately. Bundle guide updated.amplifier run → JSONL verified. PASS._deferred_configs: list[dict] = []. Append in mount(), pop in on_session_ready(). No global keyword.
amplifier-core>=1.4.0 to [dependency-groups] dev. Runtime dep stays empty — the CLI provides core.
# module/__init__.py # ───────────────────────────────────────────────────────────── # Module-level deferred state # list avoids `global`; .append() / .pop(0) mutate without reassignment _deferred_configs: list[dict] = [] async def mount(coordinator: ModuleCoordinator, config: dict | None = None) -> None: _deferred_configs.append(config or {}) # slim — just defer async def on_session_ready(coordinator: ModuleCoordinator) -> None: """Runs after ALL modules are mounted. Coordinator is fully composed.""" while _deferred_configs: config = _deferred_configs.pop(0) # ✅ Safe to call collect_contributions() here contributions = await coordinator.collect_contributions("observability.events") # ✅ Safe to get_capability() for any module tool = coordinator.get_capability("my-required-tool") if tool is None: return # fail gracefully — on_session_ready is non-fatal # ✅ Store session-scoped state via capabilities (not process globals) coordinator.register_capability("my-module.instance", MyInstance(config))
await on_session_ready(coordinator) after await mount(...) in tests.
Add an autouse fixture that clears _module._deferred_configs between test cases.
on_session_ready() hangs the entire session.
Implement your own deadline or rely on the caller's budget.
coordinator.register_capability()
for session-scoped instance state instead.
on_session_ready() emit module:on_session_ready_failed
on the event bus — event #42 in ALL_EVENTS. Hooks-logging will write it to JSONL.
asyncio.run().
Always declare async def on_session_ready.
amplifier-core/docs/CONTRACTS.md under
"Module Lifecycle: on_session_ready()".
| Change | Repo | Status |
|---|---|---|
| Phase 6 + loader + queue | amplifier-core |
Merged 1.4.0 |
register_capability → register_contributor |
amplifier-foundation (tool-delegate) |
Merged |
| on_session_ready adoption + DTU E2E | amplifier-module-hooks-logging |
Verified PASS |
| CONTRACTS.md lifecycle section | amplifier-core |
+171 lines |
| on_session_ready in BUNDLE_GUIDE.md | amplifier-foundation |
Documented |
mount() should migrate.