Add hot-reload for organism configuration
Implements runtime configuration reload via POST /api/v1/organism/reload: - StreamPump.reload_config() re-reads organism.yaml - Adds new listeners, removes old ones, updates changed ones - System listeners (system.*) are protected from removal - ReloadEvent emitted to notify WebSocket subscribers - ServerState.reload_config() refreshes agent runtime state 14 new tests covering add/remove/update scenarios. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
9ab6df85e1
commit
3ff399e849
5 changed files with 768 additions and 5 deletions
571
tests/test_hot_reload.py
Normal file
571
tests/test_hot_reload.py
Normal file
|
|
@ -0,0 +1,571 @@
|
||||||
|
"""
|
||||||
|
test_hot_reload.py — Tests for hot-reload functionality.
|
||||||
|
|
||||||
|
Tests that StreamPump can reload its configuration at runtime,
|
||||||
|
adding/removing/updating listeners without restarting.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import tempfile
|
||||||
|
import yaml
|
||||||
|
from pathlib import Path
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from third_party.xmlable import xmlify
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Test Payloads
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
@xmlify
|
||||||
|
@dataclass
|
||||||
|
class TestPayloadA:
|
||||||
|
"""Test payload A."""
|
||||||
|
value: str
|
||||||
|
|
||||||
|
|
||||||
|
@xmlify
|
||||||
|
@dataclass
|
||||||
|
class TestPayloadB:
|
||||||
|
"""Test payload B."""
|
||||||
|
count: int
|
||||||
|
|
||||||
|
|
||||||
|
@xmlify
|
||||||
|
@dataclass
|
||||||
|
class TestPayloadC:
|
||||||
|
"""Test payload C."""
|
||||||
|
name: str
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Test Handlers
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
async def handler_a(payload, metadata):
|
||||||
|
"""Handler A."""
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def handler_b(payload, metadata):
|
||||||
|
"""Handler B."""
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def handler_c(payload, metadata):
|
||||||
|
"""Handler C."""
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def handler_a_updated(payload, metadata):
|
||||||
|
"""Handler A (updated)."""
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Fixtures
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def base_config():
|
||||||
|
"""Base configuration with one listener."""
|
||||||
|
return {
|
||||||
|
"organism": {"name": "test-organism"},
|
||||||
|
"listeners": [
|
||||||
|
{
|
||||||
|
"name": "listener-a",
|
||||||
|
"payload_class": "tests.test_hot_reload.TestPayloadA",
|
||||||
|
"handler": "tests.test_hot_reload.handler_a",
|
||||||
|
"description": "Listener A",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def config_file(base_config):
|
||||||
|
"""Create a temporary config file."""
|
||||||
|
with tempfile.NamedTemporaryFile(
|
||||||
|
mode="w", suffix=".yaml", delete=False
|
||||||
|
) as f:
|
||||||
|
yaml.dump(base_config, f)
|
||||||
|
f.flush()
|
||||||
|
yield Path(f.name)
|
||||||
|
# Cleanup
|
||||||
|
Path(f.name).unlink(missing_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def pump(config_file):
|
||||||
|
"""Create a StreamPump from the config file."""
|
||||||
|
from xml_pipeline.message_bus import StreamPump, ConfigLoader
|
||||||
|
|
||||||
|
config = ConfigLoader.load(str(config_file))
|
||||||
|
pump = StreamPump(config, config_path=str(config_file))
|
||||||
|
pump.register_all()
|
||||||
|
return pump
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Tests: Reload Adds New Listeners
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
class TestReloadAddListeners:
|
||||||
|
"""Test that reload adds new listeners."""
|
||||||
|
|
||||||
|
def test_add_new_listener(self, pump, config_file):
|
||||||
|
"""New listeners in config should be registered."""
|
||||||
|
# Verify initial state
|
||||||
|
assert "listener-a" in pump.listeners
|
||||||
|
assert "listener-b" not in pump.listeners
|
||||||
|
|
||||||
|
# Update config to add listener-b
|
||||||
|
new_config = {
|
||||||
|
"organism": {"name": "test-organism"},
|
||||||
|
"listeners": [
|
||||||
|
{
|
||||||
|
"name": "listener-a",
|
||||||
|
"payload_class": "tests.test_hot_reload.TestPayloadA",
|
||||||
|
"handler": "tests.test_hot_reload.handler_a",
|
||||||
|
"description": "Listener A",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "listener-b",
|
||||||
|
"payload_class": "tests.test_hot_reload.TestPayloadB",
|
||||||
|
"handler": "tests.test_hot_reload.handler_b",
|
||||||
|
"description": "Listener B",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
with open(config_file, "w") as f:
|
||||||
|
yaml.dump(new_config, f)
|
||||||
|
|
||||||
|
# Reload
|
||||||
|
result = pump.reload_config()
|
||||||
|
|
||||||
|
# Verify
|
||||||
|
assert result.success
|
||||||
|
assert "listener-b" in result.added_listeners
|
||||||
|
assert "listener-b" in pump.listeners
|
||||||
|
assert pump.listeners["listener-b"].description == "Listener B"
|
||||||
|
|
||||||
|
def test_add_multiple_listeners(self, pump, config_file):
|
||||||
|
"""Multiple new listeners can be added at once."""
|
||||||
|
new_config = {
|
||||||
|
"organism": {"name": "test-organism"},
|
||||||
|
"listeners": [
|
||||||
|
{
|
||||||
|
"name": "listener-a",
|
||||||
|
"payload_class": "tests.test_hot_reload.TestPayloadA",
|
||||||
|
"handler": "tests.test_hot_reload.handler_a",
|
||||||
|
"description": "Listener A",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "listener-b",
|
||||||
|
"payload_class": "tests.test_hot_reload.TestPayloadB",
|
||||||
|
"handler": "tests.test_hot_reload.handler_b",
|
||||||
|
"description": "Listener B",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "listener-c",
|
||||||
|
"payload_class": "tests.test_hot_reload.TestPayloadC",
|
||||||
|
"handler": "tests.test_hot_reload.handler_c",
|
||||||
|
"description": "Listener C",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
with open(config_file, "w") as f:
|
||||||
|
yaml.dump(new_config, f)
|
||||||
|
|
||||||
|
result = pump.reload_config()
|
||||||
|
|
||||||
|
assert result.success
|
||||||
|
assert set(result.added_listeners) == {"listener-b", "listener-c"}
|
||||||
|
assert "listener-b" in pump.listeners
|
||||||
|
assert "listener-c" in pump.listeners
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Tests: Reload Removes Listeners
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
class TestReloadRemoveListeners:
|
||||||
|
"""Test that reload removes listeners no longer in config."""
|
||||||
|
|
||||||
|
def test_remove_listener(self, config_file):
|
||||||
|
"""Listeners not in new config should be unregistered."""
|
||||||
|
from xml_pipeline.message_bus import StreamPump, ConfigLoader
|
||||||
|
|
||||||
|
# Create pump with two listeners
|
||||||
|
initial_config = {
|
||||||
|
"organism": {"name": "test-organism"},
|
||||||
|
"listeners": [
|
||||||
|
{
|
||||||
|
"name": "listener-a",
|
||||||
|
"payload_class": "tests.test_hot_reload.TestPayloadA",
|
||||||
|
"handler": "tests.test_hot_reload.handler_a",
|
||||||
|
"description": "Listener A",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "listener-b",
|
||||||
|
"payload_class": "tests.test_hot_reload.TestPayloadB",
|
||||||
|
"handler": "tests.test_hot_reload.handler_b",
|
||||||
|
"description": "Listener B",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
with open(config_file, "w") as f:
|
||||||
|
yaml.dump(initial_config, f)
|
||||||
|
|
||||||
|
config = ConfigLoader.load(str(config_file))
|
||||||
|
pump = StreamPump(config, config_path=str(config_file))
|
||||||
|
pump.register_all()
|
||||||
|
|
||||||
|
assert "listener-a" in pump.listeners
|
||||||
|
assert "listener-b" in pump.listeners
|
||||||
|
|
||||||
|
# Update config to remove listener-b
|
||||||
|
new_config = {
|
||||||
|
"organism": {"name": "test-organism"},
|
||||||
|
"listeners": [
|
||||||
|
{
|
||||||
|
"name": "listener-a",
|
||||||
|
"payload_class": "tests.test_hot_reload.TestPayloadA",
|
||||||
|
"handler": "tests.test_hot_reload.handler_a",
|
||||||
|
"description": "Listener A",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
with open(config_file, "w") as f:
|
||||||
|
yaml.dump(new_config, f)
|
||||||
|
|
||||||
|
# Reload
|
||||||
|
result = pump.reload_config()
|
||||||
|
|
||||||
|
# Verify
|
||||||
|
assert result.success
|
||||||
|
assert "listener-b" in result.removed_listeners
|
||||||
|
assert "listener-b" not in pump.listeners
|
||||||
|
assert "listener-a" in pump.listeners # Still exists
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Tests: Reload Updates Listeners
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
class TestReloadUpdateListeners:
|
||||||
|
"""Test that reload updates changed listeners."""
|
||||||
|
|
||||||
|
def test_update_handler(self, pump, config_file):
|
||||||
|
"""Changed handler should be updated."""
|
||||||
|
old_handler = pump.listeners["listener-a"].handler
|
||||||
|
|
||||||
|
# Update config with new handler
|
||||||
|
new_config = {
|
||||||
|
"organism": {"name": "test-organism"},
|
||||||
|
"listeners": [
|
||||||
|
{
|
||||||
|
"name": "listener-a",
|
||||||
|
"payload_class": "tests.test_hot_reload.TestPayloadA",
|
||||||
|
"handler": "tests.test_hot_reload.handler_a_updated",
|
||||||
|
"description": "Listener A",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
with open(config_file, "w") as f:
|
||||||
|
yaml.dump(new_config, f)
|
||||||
|
|
||||||
|
result = pump.reload_config()
|
||||||
|
|
||||||
|
assert result.success
|
||||||
|
assert "listener-a" in result.updated_listeners
|
||||||
|
assert pump.listeners["listener-a"].handler != old_handler
|
||||||
|
|
||||||
|
def test_update_description(self, pump, config_file):
|
||||||
|
"""Changed description should be updated."""
|
||||||
|
# Update config with new description
|
||||||
|
new_config = {
|
||||||
|
"organism": {"name": "test-organism"},
|
||||||
|
"listeners": [
|
||||||
|
{
|
||||||
|
"name": "listener-a",
|
||||||
|
"payload_class": "tests.test_hot_reload.TestPayloadA",
|
||||||
|
"handler": "tests.test_hot_reload.handler_a",
|
||||||
|
"description": "Updated description",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
with open(config_file, "w") as f:
|
||||||
|
yaml.dump(new_config, f)
|
||||||
|
|
||||||
|
result = pump.reload_config()
|
||||||
|
|
||||||
|
assert result.success
|
||||||
|
assert "listener-a" in result.updated_listeners
|
||||||
|
assert pump.listeners["listener-a"].description == "Updated description"
|
||||||
|
|
||||||
|
def test_update_peers(self, pump, config_file):
|
||||||
|
"""Changed peers should be updated."""
|
||||||
|
# Update config with peers
|
||||||
|
new_config = {
|
||||||
|
"organism": {"name": "test-organism"},
|
||||||
|
"listeners": [
|
||||||
|
{
|
||||||
|
"name": "listener-a",
|
||||||
|
"payload_class": "tests.test_hot_reload.TestPayloadA",
|
||||||
|
"handler": "tests.test_hot_reload.handler_a",
|
||||||
|
"description": "Listener A",
|
||||||
|
"agent": True,
|
||||||
|
"peers": ["listener-b"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
with open(config_file, "w") as f:
|
||||||
|
yaml.dump(new_config, f)
|
||||||
|
|
||||||
|
result = pump.reload_config()
|
||||||
|
|
||||||
|
assert result.success
|
||||||
|
assert "listener-a" in result.updated_listeners
|
||||||
|
assert pump.listeners["listener-a"].peers == ["listener-b"]
|
||||||
|
|
||||||
|
def test_unchanged_listener_not_updated(self, pump, config_file):
|
||||||
|
"""Unchanged listeners should not appear in updated list."""
|
||||||
|
# Reload same config
|
||||||
|
result = pump.reload_config()
|
||||||
|
|
||||||
|
assert result.success
|
||||||
|
assert "listener-a" not in result.updated_listeners
|
||||||
|
assert "listener-a" not in result.added_listeners
|
||||||
|
assert "listener-a" not in result.removed_listeners
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Tests: Reload Events
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
class TestReloadEvents:
|
||||||
|
"""Test that reload emits events."""
|
||||||
|
|
||||||
|
def test_reload_emits_event(self, pump, config_file):
|
||||||
|
"""Reload should emit ReloadEvent."""
|
||||||
|
from xml_pipeline.message_bus import ReloadEvent
|
||||||
|
|
||||||
|
events = []
|
||||||
|
|
||||||
|
def capture_event(event):
|
||||||
|
events.append(event)
|
||||||
|
|
||||||
|
pump.subscribe_events(capture_event)
|
||||||
|
|
||||||
|
# Add a listener
|
||||||
|
new_config = {
|
||||||
|
"organism": {"name": "test-organism"},
|
||||||
|
"listeners": [
|
||||||
|
{
|
||||||
|
"name": "listener-a",
|
||||||
|
"payload_class": "tests.test_hot_reload.TestPayloadA",
|
||||||
|
"handler": "tests.test_hot_reload.handler_a",
|
||||||
|
"description": "Listener A",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "listener-b",
|
||||||
|
"payload_class": "tests.test_hot_reload.TestPayloadB",
|
||||||
|
"handler": "tests.test_hot_reload.handler_b",
|
||||||
|
"description": "Listener B",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
with open(config_file, "w") as f:
|
||||||
|
yaml.dump(new_config, f)
|
||||||
|
|
||||||
|
pump.reload_config()
|
||||||
|
|
||||||
|
# Check event was emitted
|
||||||
|
reload_events = [e for e in events if isinstance(e, ReloadEvent)]
|
||||||
|
assert len(reload_events) == 1
|
||||||
|
assert reload_events[0].success
|
||||||
|
assert "listener-b" in reload_events[0].added_listeners
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Tests: Reload Error Handling
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
class TestReloadErrors:
|
||||||
|
"""Test reload error handling."""
|
||||||
|
|
||||||
|
def test_reload_no_config_path(self):
|
||||||
|
"""Reload without config path should fail gracefully."""
|
||||||
|
from xml_pipeline.message_bus import StreamPump, OrganismConfig
|
||||||
|
|
||||||
|
config = OrganismConfig(name="test")
|
||||||
|
pump = StreamPump(config) # No config_path
|
||||||
|
|
||||||
|
result = pump.reload_config()
|
||||||
|
|
||||||
|
assert not result.success
|
||||||
|
assert "No config path" in result.error
|
||||||
|
|
||||||
|
def test_reload_invalid_config(self, pump, config_file):
|
||||||
|
"""Reload with invalid config should fail gracefully."""
|
||||||
|
# Write invalid YAML
|
||||||
|
with open(config_file, "w") as f:
|
||||||
|
f.write("invalid: yaml: content: [")
|
||||||
|
|
||||||
|
result = pump.reload_config()
|
||||||
|
|
||||||
|
assert not result.success
|
||||||
|
assert result.error is not None
|
||||||
|
|
||||||
|
def test_reload_missing_file(self, pump):
|
||||||
|
"""Reload with missing file should fail gracefully."""
|
||||||
|
pump.config_path = "/nonexistent/path.yaml"
|
||||||
|
|
||||||
|
result = pump.reload_config()
|
||||||
|
|
||||||
|
assert not result.success
|
||||||
|
assert result.error is not None
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Tests: System Listeners Protected
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
class TestSystemListenersProtected:
|
||||||
|
"""Test that system listeners are not affected by reload."""
|
||||||
|
|
||||||
|
def test_system_listeners_not_removed(self, config_file):
|
||||||
|
"""System listeners should not be removed during reload."""
|
||||||
|
from xml_pipeline.message_bus import StreamPump, ConfigLoader, ListenerConfig
|
||||||
|
|
||||||
|
# Load config
|
||||||
|
config = ConfigLoader.load(str(config_file))
|
||||||
|
pump = StreamPump(config, config_path=str(config_file))
|
||||||
|
|
||||||
|
# Manually add a system listener (simulating bootstrap)
|
||||||
|
@xmlify
|
||||||
|
@dataclass
|
||||||
|
class SystemPayload:
|
||||||
|
msg: str
|
||||||
|
|
||||||
|
async def system_handler(p, m):
|
||||||
|
return None
|
||||||
|
|
||||||
|
system_lc = ListenerConfig(
|
||||||
|
name="system.test",
|
||||||
|
payload_class_path="tests.test_hot_reload.TestPayloadA",
|
||||||
|
handler_path="tests.test_hot_reload.handler_a",
|
||||||
|
description="System test listener",
|
||||||
|
payload_class=SystemPayload,
|
||||||
|
handler=system_handler,
|
||||||
|
)
|
||||||
|
pump.register_listener(system_lc)
|
||||||
|
pump.register_all()
|
||||||
|
|
||||||
|
assert "system.test" in pump.listeners
|
||||||
|
|
||||||
|
# Reload with empty config (would remove all non-system listeners)
|
||||||
|
new_config = {
|
||||||
|
"organism": {"name": "test-organism"},
|
||||||
|
"listeners": [],
|
||||||
|
}
|
||||||
|
with open(config_file, "w") as f:
|
||||||
|
yaml.dump(new_config, f)
|
||||||
|
|
||||||
|
result = pump.reload_config()
|
||||||
|
|
||||||
|
# System listener should still exist
|
||||||
|
assert result.success
|
||||||
|
assert "system.test" in pump.listeners
|
||||||
|
assert "system.test" not in result.removed_listeners
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Tests: Routing Table Updates
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
class TestRoutingTableUpdates:
|
||||||
|
"""Test that routing table is updated correctly."""
|
||||||
|
|
||||||
|
def test_routing_table_updated_on_add(self, pump, config_file):
|
||||||
|
"""Routing table should include new listeners."""
|
||||||
|
# Add listener-b
|
||||||
|
new_config = {
|
||||||
|
"organism": {"name": "test-organism"},
|
||||||
|
"listeners": [
|
||||||
|
{
|
||||||
|
"name": "listener-a",
|
||||||
|
"payload_class": "tests.test_hot_reload.TestPayloadA",
|
||||||
|
"handler": "tests.test_hot_reload.handler_a",
|
||||||
|
"description": "Listener A",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "listener-b",
|
||||||
|
"payload_class": "tests.test_hot_reload.TestPayloadB",
|
||||||
|
"handler": "tests.test_hot_reload.handler_b",
|
||||||
|
"description": "Listener B",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
with open(config_file, "w") as f:
|
||||||
|
yaml.dump(new_config, f)
|
||||||
|
|
||||||
|
pump.reload_config()
|
||||||
|
|
||||||
|
# Check routing table
|
||||||
|
expected_root_tag = "listener-b.testpayloadb"
|
||||||
|
assert expected_root_tag in pump.routing_table
|
||||||
|
|
||||||
|
def test_routing_table_updated_on_remove(self, config_file):
|
||||||
|
"""Routing table should not include removed listeners."""
|
||||||
|
from xml_pipeline.message_bus import StreamPump, ConfigLoader
|
||||||
|
|
||||||
|
# Create pump with two listeners
|
||||||
|
initial_config = {
|
||||||
|
"organism": {"name": "test-organism"},
|
||||||
|
"listeners": [
|
||||||
|
{
|
||||||
|
"name": "listener-a",
|
||||||
|
"payload_class": "tests.test_hot_reload.TestPayloadA",
|
||||||
|
"handler": "tests.test_hot_reload.handler_a",
|
||||||
|
"description": "Listener A",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "listener-b",
|
||||||
|
"payload_class": "tests.test_hot_reload.TestPayloadB",
|
||||||
|
"handler": "tests.test_hot_reload.handler_b",
|
||||||
|
"description": "Listener B",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
with open(config_file, "w") as f:
|
||||||
|
yaml.dump(initial_config, f)
|
||||||
|
|
||||||
|
config = ConfigLoader.load(str(config_file))
|
||||||
|
pump = StreamPump(config, config_path=str(config_file))
|
||||||
|
pump.register_all()
|
||||||
|
|
||||||
|
# Verify both in routing table
|
||||||
|
assert "listener-b.testpayloadb" in pump.routing_table
|
||||||
|
|
||||||
|
# Remove listener-b
|
||||||
|
new_config = {
|
||||||
|
"organism": {"name": "test-organism"},
|
||||||
|
"listeners": [
|
||||||
|
{
|
||||||
|
"name": "listener-a",
|
||||||
|
"payload_class": "tests.test_hot_reload.TestPayloadA",
|
||||||
|
"handler": "tests.test_hot_reload.handler_a",
|
||||||
|
"description": "Listener A",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
with open(config_file, "w") as f:
|
||||||
|
yaml.dump(new_config, f)
|
||||||
|
|
||||||
|
pump.reload_config()
|
||||||
|
|
||||||
|
# Check routing table no longer has listener-b
|
||||||
|
assert "listener-b.testpayloadb" not in pump.routing_table
|
||||||
|
|
@ -39,6 +39,7 @@ from xml_pipeline.message_bus.stream_pump import (
|
||||||
MessageSentEvent,
|
MessageSentEvent,
|
||||||
AgentStateEvent,
|
AgentStateEvent,
|
||||||
ThreadEvent,
|
ThreadEvent,
|
||||||
|
ReloadEvent,
|
||||||
)
|
)
|
||||||
|
|
||||||
from xml_pipeline.message_bus.message_state import (
|
from xml_pipeline.message_bus.message_state import (
|
||||||
|
|
@ -83,6 +84,7 @@ __all__ = [
|
||||||
"MessageSentEvent",
|
"MessageSentEvent",
|
||||||
"AgentStateEvent",
|
"AgentStateEvent",
|
||||||
"ThreadEvent",
|
"ThreadEvent",
|
||||||
|
"ReloadEvent",
|
||||||
# Message state
|
# Message state
|
||||||
"MessageState",
|
"MessageState",
|
||||||
"HandlerMetadata",
|
"HandlerMetadata",
|
||||||
|
|
|
||||||
|
|
@ -96,6 +96,16 @@ class ThreadEvent(PumpEvent):
|
||||||
error: Optional[str] = None
|
error: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ReloadEvent(PumpEvent):
|
||||||
|
"""Fired when organism configuration is reloaded."""
|
||||||
|
success: bool
|
||||||
|
added_listeners: List[str] = field(default_factory=list)
|
||||||
|
removed_listeners: List[str] = field(default_factory=list)
|
||||||
|
updated_listeners: List[str] = field(default_factory=list)
|
||||||
|
error: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
EventCallback = Callable[[PumpEvent], None]
|
EventCallback = Callable[[PumpEvent], None]
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -253,8 +263,9 @@ class StreamPump:
|
||||||
shared backend (Redis or Manager) for cross-process data access.
|
shared backend (Redis or Manager) for cross-process data access.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, config: OrganismConfig):
|
def __init__(self, config: OrganismConfig, config_path: str = ""):
|
||||||
self.config = config
|
self.config = config
|
||||||
|
self.config_path = config_path # Store path for hot-reload
|
||||||
|
|
||||||
# Message queue feeds the stream
|
# Message queue feeds the stream
|
||||||
self.queue: asyncio.Queue[MessageState] = asyncio.Queue()
|
self.queue: asyncio.Queue[MessageState] = asyncio.Queue()
|
||||||
|
|
@ -1111,6 +1122,119 @@ class StreamPump:
|
||||||
pump_logger.info("ProcessPool shutdown complete")
|
pump_logger.info("ProcessPool shutdown complete")
|
||||||
self._process_pool = None
|
self._process_pool = None
|
||||||
|
|
||||||
|
def reload_config(self, config_path: Optional[str] = None) -> ReloadEvent:
|
||||||
|
"""
|
||||||
|
Hot-reload organism configuration.
|
||||||
|
|
||||||
|
Re-reads the config file and updates listeners:
|
||||||
|
- New listeners are registered
|
||||||
|
- Removed listeners are unregistered
|
||||||
|
- Changed listeners are updated (handler, peers, description)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
config_path: Path to config file. Uses stored path if not provided.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ReloadEvent with details of what changed
|
||||||
|
"""
|
||||||
|
path = config_path or self.config_path
|
||||||
|
if not path:
|
||||||
|
return ReloadEvent(
|
||||||
|
success=False,
|
||||||
|
error="No config path available for reload",
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Re-read config
|
||||||
|
new_config = ConfigLoader.load(path)
|
||||||
|
|
||||||
|
# Track changes
|
||||||
|
added: List[str] = []
|
||||||
|
removed: List[str] = []
|
||||||
|
updated: List[str] = []
|
||||||
|
|
||||||
|
# Get current listener names (excluding system listeners)
|
||||||
|
current_names = {
|
||||||
|
name for name in self.listeners.keys()
|
||||||
|
if not name.startswith("system.")
|
||||||
|
}
|
||||||
|
|
||||||
|
# Get new listener names
|
||||||
|
new_names = {lc.name for lc in new_config.listeners}
|
||||||
|
|
||||||
|
# Find removed listeners
|
||||||
|
for name in current_names - new_names:
|
||||||
|
if self.unregister_listener(name):
|
||||||
|
removed.append(name)
|
||||||
|
pump_logger.info(f"Hot-reload: removed listener '{name}'")
|
||||||
|
|
||||||
|
# Find new and updated listeners
|
||||||
|
for lc in new_config.listeners:
|
||||||
|
# Resolve imports for the listener config
|
||||||
|
ConfigLoader._resolve_imports(lc)
|
||||||
|
|
||||||
|
if lc.name in current_names:
|
||||||
|
# Check if changed
|
||||||
|
existing = self.listeners.get(lc.name)
|
||||||
|
if existing and self._listener_changed(existing, lc):
|
||||||
|
# Remove old and re-register
|
||||||
|
self.unregister_listener(lc.name)
|
||||||
|
self.register_listener(lc)
|
||||||
|
updated.append(lc.name)
|
||||||
|
pump_logger.info(f"Hot-reload: updated listener '{lc.name}'")
|
||||||
|
else:
|
||||||
|
# New listener
|
||||||
|
self.register_listener(lc)
|
||||||
|
added.append(lc.name)
|
||||||
|
pump_logger.info(f"Hot-reload: added listener '{lc.name}'")
|
||||||
|
|
||||||
|
# Rebuild usage instructions for all agents (peers may have changed)
|
||||||
|
for listener in self.listeners.values():
|
||||||
|
if listener.is_agent and listener.peers:
|
||||||
|
listener.usage_instructions = self._build_usage_instructions(listener)
|
||||||
|
|
||||||
|
# Update stored config
|
||||||
|
self.config = new_config
|
||||||
|
|
||||||
|
# Emit reload event
|
||||||
|
event = ReloadEvent(
|
||||||
|
success=True,
|
||||||
|
added_listeners=added,
|
||||||
|
removed_listeners=removed,
|
||||||
|
updated_listeners=updated,
|
||||||
|
)
|
||||||
|
self._emit_event(event)
|
||||||
|
|
||||||
|
pump_logger.info(
|
||||||
|
f"Hot-reload complete: +{len(added)} -{len(removed)} ~{len(updated)}"
|
||||||
|
)
|
||||||
|
return event
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
pump_logger.error(f"Hot-reload failed: {e}")
|
||||||
|
event = ReloadEvent(success=False, error=str(e))
|
||||||
|
self._emit_event(event)
|
||||||
|
return event
|
||||||
|
|
||||||
|
def _listener_changed(self, existing: Listener, new_config: ListenerConfig) -> bool:
|
||||||
|
"""Check if listener config has changed."""
|
||||||
|
# Compare key fields
|
||||||
|
if existing.handler != new_config.handler:
|
||||||
|
return True
|
||||||
|
if existing.payload_class != new_config.payload_class:
|
||||||
|
return True
|
||||||
|
if existing.description != new_config.description:
|
||||||
|
return True
|
||||||
|
if existing.is_agent != new_config.is_agent:
|
||||||
|
return True
|
||||||
|
if set(existing.peers) != set(new_config.peers):
|
||||||
|
return True
|
||||||
|
if existing.broadcast != new_config.broadcast:
|
||||||
|
return True
|
||||||
|
if existing.cpu_bound != new_config.cpu_bound:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# Config Loader (same as before)
|
# Config Loader (same as before)
|
||||||
|
|
@ -1208,7 +1332,7 @@ async def bootstrap(config_path: str = "config/organism.yaml") -> StreamPump:
|
||||||
print(f"Organism: {config.name}")
|
print(f"Organism: {config.name}")
|
||||||
print(f"Listeners: {len(config.listeners)}")
|
print(f"Listeners: {len(config.listeners)}")
|
||||||
|
|
||||||
pump = StreamPump(config)
|
pump = StreamPump(config, config_path=config_path)
|
||||||
|
|
||||||
# Register system listeners first
|
# Register system listeners first
|
||||||
boot_listener_config = ListenerConfig(
|
boot_listener_config = ListenerConfig(
|
||||||
|
|
|
||||||
|
|
@ -259,9 +259,21 @@ def create_router(state: "ServerState") -> APIRouter:
|
||||||
|
|
||||||
@router.post("/organism/reload")
|
@router.post("/organism/reload")
|
||||||
async def reload_config() -> dict:
|
async def reload_config() -> dict:
|
||||||
"""Hot-reload organism configuration."""
|
"""
|
||||||
# TODO: Implement hot-reload
|
Hot-reload organism configuration.
|
||||||
return {"success": False, "error": "Hot-reload not yet implemented"}
|
|
||||||
|
Re-reads organism.yaml and updates listeners:
|
||||||
|
- New listeners are registered
|
||||||
|
- Removed listeners are unregistered
|
||||||
|
- Changed listeners are updated
|
||||||
|
"""
|
||||||
|
result = await state.reload_config()
|
||||||
|
if not result["success"]:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500,
|
||||||
|
detail=result.get("error", "Reload failed"),
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
@router.post("/organism/stop")
|
@router.post("/organism/stop")
|
||||||
async def stop_organism() -> dict:
|
async def stop_organism() -> dict:
|
||||||
|
|
|
||||||
|
|
@ -222,6 +222,60 @@ class ServerState:
|
||||||
"""Mark organism as stopping."""
|
"""Mark organism as stopping."""
|
||||||
self._status = OrganismStatus.STOPPING
|
self._status = OrganismStatus.STOPPING
|
||||||
|
|
||||||
|
async def reload_config(self, config_path: Optional[str] = None) -> dict:
|
||||||
|
"""
|
||||||
|
Hot-reload organism configuration.
|
||||||
|
|
||||||
|
Calls pump.reload_config() and updates local agent state.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
config_path: Optional path to config file (uses stored path if not provided)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with reload results
|
||||||
|
"""
|
||||||
|
from xml_pipeline.message_bus import ReloadEvent
|
||||||
|
|
||||||
|
# Call pump's reload
|
||||||
|
event = self.pump.reload_config(config_path)
|
||||||
|
|
||||||
|
if event.success:
|
||||||
|
# Refresh agent states from pump
|
||||||
|
async with self._lock:
|
||||||
|
# Remove agents that were removed from pump
|
||||||
|
for name in event.removed_listeners:
|
||||||
|
if name in self._agents:
|
||||||
|
del self._agents[name]
|
||||||
|
|
||||||
|
# Add/update agents
|
||||||
|
for name in event.added_listeners + event.updated_listeners:
|
||||||
|
listener = self.pump.listeners.get(name)
|
||||||
|
if listener:
|
||||||
|
self._agents[name] = AgentRuntimeState(
|
||||||
|
name=name,
|
||||||
|
description=listener.description,
|
||||||
|
is_agent=listener.is_agent,
|
||||||
|
peers=list(listener.peers),
|
||||||
|
payload_class=f"{listener.payload_class.__module__}.{listener.payload_class.__name__}",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Notify subscribers of reload
|
||||||
|
await self._broadcast({
|
||||||
|
"event": "reload",
|
||||||
|
"success": True,
|
||||||
|
"added": event.added_listeners,
|
||||||
|
"removed": event.removed_listeners,
|
||||||
|
"updated": event.updated_listeners,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": event.success,
|
||||||
|
"added": event.added_listeners,
|
||||||
|
"removed": event.removed_listeners,
|
||||||
|
"updated": event.updated_listeners,
|
||||||
|
"error": event.error,
|
||||||
|
}
|
||||||
|
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
# Event Recording (called by pump hooks)
|
# Event Recording (called by pump hooks)
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue