feat: unified event bus, sealed state machines, and startup verification

Introduce a unified EventBus as the single broadcast channel for all
system events, replacing the 6 disconnected event mechanisms. Seal
Thread/Turn/ContainerState fields behind private accessors with validated
transitions to prevent invalid state mutations. Fix TOCTOU races in
thread_ops and session_manager.

Event bus (src/event_bus/):
- SystemEvent envelope with EventPayload (Domain, StateChange, Telemetry,
  StateTransition, ToolExecution, AuthEvent, ConfigChange)
- Four sinks: SSE (→SseManager), audit (→DB with JSONL fallback),
  state (→StateBus), metrics (→Observer)
- AuditStore trait + implementations for PostgreSQL and libSQL
- V13 audit_log migration for both backends
- Wired into AppComponents and AgentDeps (Option<EventBus> for compat)
- Worker dual-emit through bus alongside legacy SSE+DB paths

Sealed state machines:
- Thread.state private with state() accessor, can_transition_to(),
  set_processing(), reset_to_idle()
- Turn.state private with state() accessor
- ContainerHandle.state private with new() constructor,
  mark_running/stopped/failed(), can_transition_to()
- TOCTOU fix: thread_ops moves safety validation before lock, then
  checks state + starts turn atomically under single lock
- SessionManager TOCTOU fix: atomic check-and-insert with write lock
  held for entire UUID adoption sequence

Startup verification:
- AppComponents::verify_readiness() checks component presence vs config
- ToolRegistry::verify_expected_tools() validates builtin registration
- Config::validate() checks cross-field invariants (Docker, WASM dir)

[skip-regression-check]

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
This commit is contained in:
Illia Polosukhin
2026-03-16 00:54:21 -07:00
co-authored by Claude Opus 4.6
parent 6fc652a24d
commit 274175184e
39 changed files with 2034 additions and 184 deletions
+16 -8
View File
@@ -213,8 +213,10 @@ mod tests {
assert_eq!(counts.len(), 3, "Should return 3 routines"); // safety: test-only
assert_eq!(counts[&r1], 2, "r1 should have 2 running"); // safety: test-only
assert_eq!(counts[&r2], 1, "r2 should have 1 running"); // safety: test-only
assert_eq!( // safety: test-only
counts[&r3], 0,
assert_eq!(
// safety: test-only
counts[&r3],
0,
"r3 should have 0 running (Ok status is not running)"
);
}
@@ -360,8 +362,10 @@ mod tests {
.await
.expect("batch query should work"); // safety: test-only
assert_eq!( // safety: test-only
counts[&routine_id], 2,
assert_eq!(
// safety: test-only
counts[&routine_id],
2,
"Should only count 2 Running status runs"
);
}
@@ -454,12 +458,16 @@ mod tests {
.expect("batch query should work"); // safety: test-only
// Verify counts match the limits
assert_eq!( // safety: test-only
counts[&r1], 1,
assert_eq!(
// safety: test-only
counts[&r1],
1,
"r1 should have 1 running (at max_concurrent=1)"
);
assert_eq!( // safety: test-only
counts[&r2], 2,
assert_eq!(
// safety: test-only
counts[&r2],
2,
"r2 should have 2 running (at max_concurrent=2)"
);
+2 -1
View File
@@ -310,7 +310,8 @@ fn domain_event_all_variants_serialize() {
for variant in &variants {
let json = serde_json::to_string(variant).unwrap(); // safety: test-only
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap(); // safety: test-only
assert!( // safety: test-only
assert!(
// safety: test-only
// safety: test-only
parsed.get("type").is_some(),
"missing 'type' field in {:?}",
@@ -252,6 +252,7 @@ impl GatewayWorkflowHarness {
http_interceptor: None,
transcription: None,
document_extraction: None,
event_bus: Some(components.event_bus.clone()),
},
channels,
None,
+1
View File
@@ -641,6 +641,7 @@ impl TestRigBuilder {
},
transcription: None,
document_extraction: None,
event_bus: Some(components.event_bus.clone()),
};
// 7. Create TestChannel and ChannelManager.