From f5a15a9c644ade5db014387bc0537b6bf1b04edd Mon Sep 17 00:00:00 2001 From: Momo the Bestest <45446348+Svagtlys@users.noreply.github.com> Date: Sun, 27 Sep 2026 16:24:28 +0100 Subject: [PATCH 01/10] chore: start work on #26 From 49dce07d0e85de2718476209201d27c7d7725d6b Mon Sep 17 00:00:00 2001 From: Momo the Bestest <45446348+Svagtlys@users.noreply.github.com> Date: Sun, 27 Sep 2026 17:52:34 +0100 Subject: [PATCH 02/10] =?UTF-8?q?feat:=20add=20AgentRegistry.list=5Finstan?= =?UTF-8?q?ces=20over=20agent=5Finstances=20=E2=8B=88=20agents?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/octave/agent/registry.py | 96 +++++++++++++++++++++ backend/tests/agent/test_registry.py | 120 +++++++++++++++++++++++++++ 2 files changed, 216 insertions(+) create mode 100644 backend/src/octave/agent/registry.py create mode 100644 backend/tests/agent/test_registry.py diff --git a/backend/src/octave/agent/registry.py b/backend/src/octave/agent/registry.py new file mode 100644 index 0000000..239df32 --- /dev/null +++ b/backend/src/octave/agent/registry.py @@ -0,0 +1,96 @@ +"""Agent registry: read-only queries over running instances (issue #26). + +Rows in ``agent_instances`` are the durable record of what is running +(spawn inserts, destroy deletes — design spec 2026-09-27); this module is +the source-of-truth *view*, not a parallel bookkeeping structure. No states +added, no transitions enforced: ``AgentInstanceManager`` remains the only +write path. Follows the VaultStore convention — constructed with the +caller's AsyncSession, never commits, never mutates. +""" + +import logging +from dataclasses import dataclass +from datetime import datetime + +from pydantic import TypeAdapter +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from octave.db.models import Agent, AgentInstance +from octave.db.types import AgentAssignments, AgentStatus, InstanceStatus + +__all__ = ["AgentRegistry", "RunningAgent"] + +logger = logging.getLogger(__name__) + +_ASSIGNMENTS_ADAPTER: TypeAdapter[AgentAssignments] = TypeAdapter(AgentAssignments) + + +@dataclass(frozen=True) +class RunningAgent: + """One row of the registry view: instance fields + joined definition + fields. ``model_binding`` is deliberately absent (design spec: IDs, + lifecycle state, assigned context — add a field if the dashboard needs + the binding).""" + + instance_id: str + agent_id: str + agent_name: str + definition_status: AgentStatus + instance_status: InstanceStatus + session_id: str + assignments: AgentAssignments + created_at: datetime + updated_at: datetime + + +def _to_running_agent(instance: AgentInstance, agent: Agent) -> RunningAgent: + """Status parses are loud (a bad enum value is DB corruption; silently + coercing it would misreport lifecycle state). Assignments parsing is + made lenient in Task 4 (reporting surface, not a validation gate).""" + return RunningAgent( + instance_id=instance.id, + agent_id=instance.agent_id, + agent_name=agent.name, + definition_status=AgentStatus(agent.status), + instance_status=InstanceStatus(instance.status), + session_id=instance.session_id, + assignments=_ASSIGNMENTS_ADAPTER.validate_python(agent.assignments), + created_at=instance.created_at, + updated_at=instance.updated_at, + ) + + +class AgentRegistry: + """Read-only queries over ``agent_instances ⋈ agents``. The RESTRICT FK + guarantees every instance has its definition row, so the join is safe; + ``sessions`` is not joined — ``session_id`` lives on the instance row. + Never commits; callers own transaction boundaries (``octave.db.deps``). + """ + + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def list_instances( + self, + *, + agent_id: str | None = None, + session_id: str | None = None, + status: InstanceStatus | None = None, + ) -> list[RunningAgent]: + """Filters compose with AND; ``status`` is the *instance* status + (idle | active), not the definition's. Ordering ``created_at ASC, + id ASC`` — deterministic even on timestamp ties.""" + stmt = ( + select(AgentInstance, Agent) + .join(Agent, AgentInstance.agent_id == Agent.id) + .order_by(AgentInstance.created_at, AgentInstance.id) + ) + if agent_id is not None: + stmt = stmt.where(AgentInstance.agent_id == agent_id) + if session_id is not None: + stmt = stmt.where(AgentInstance.session_id == session_id) + if status is not None: + stmt = stmt.where(AgentInstance.status == str(status)) + rows = await self._session.execute(stmt) + return [_to_running_agent(inst, ag) for inst, ag in rows] diff --git a/backend/tests/agent/test_registry.py b/backend/tests/agent/test_registry.py new file mode 100644 index 0000000..2a6d1d9 --- /dev/null +++ b/backend/tests/agent/test_registry.py @@ -0,0 +1,120 @@ +"""AgentRegistry read surface (design spec 2026-09-27-agent-registry). + +Read-only queries over the #25 substrate: real SQLite via session_factory, +spawns driven through AgentInstanceManager (the only write path). +""" + +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker + +from octave.agent import AgentInstanceManager +from octave.agent.registry import AgentRegistry +from octave.db.models import Agent, Session, User +from octave.db.types import InstanceStatus + +_TAG_BINDING = {"kind": "tag", "tag": "quick"} +_ASSIGNMENTS = { + "prompt": "v_p", + "skills": ["v_s1", "v_s2"], + "preference_tags": ["terse"], +} + + +async def _seed_defs( + session_factory: async_sessionmaker[AsyncSession], +) -> None: + """One user; agents a_1/a_2 active + a_3 paused; sessions s_1/s_2.""" + async with session_factory() as session: + session.add(User(id="u_1", display_name="Alice")) + session.add( + Agent( + id="a_1", + name="Octave", + model_binding=_TAG_BINDING, + assignments=dict(_ASSIGNMENTS), + ) + ) + session.add(Agent(id="a_2", name="Helper", model_binding=_TAG_BINDING)) + session.add( + Agent( + id="a_3", name="Paused", status="paused", model_binding=_TAG_BINDING + ) + ) + session.add( + Session(id="s_1", created_by_user_id="u_1", status="active") + ) + session.add( + Session(id="s_2", created_by_user_id="u_1", status="active") + ) + await session.commit() + + +async def _spawn( + session_factory: async_sessionmaker[AsyncSession], + agent_id: str, + session_id: str, +) -> str: + async with session_factory() as session: + instance = await AgentInstanceManager(session).spawn( + agent_id=agent_id, session_id=session_id + ) + await session.commit() + return instance.id + + +async def test_list_empty( + session_factory: async_sessionmaker[AsyncSession], +) -> None: + await _seed_defs(session_factory) + async with session_factory() as session: + assert await AgentRegistry(session).list_instances() == [] + + +async def test_list_returns_joined_fields( + session_factory: async_sessionmaker[AsyncSession], +) -> None: + await _seed_defs(session_factory) + instance_id = await _spawn(session_factory, "a_1", "s_1") + async with session_factory() as session: + [running] = await AgentRegistry(session).list_instances() + assert running.instance_id == instance_id + assert running.agent_id == "a_1" + assert running.agent_name == "Octave" + assert running.definition_status == "active" + assert running.instance_status == "idle" + assert running.session_id == "s_1" + assert running.assignments.prompt == "v_p" + assert running.assignments.skills == ["v_s1", "v_s2"] + assert running.assignments.preference_tags == ["terse"] + assert running.created_at <= running.updated_at + + +async def test_list_filters_compose( + session_factory: async_sessionmaker[AsyncSession], +) -> None: + await _seed_defs(session_factory) + i1 = await _spawn(session_factory, "a_1", "s_1") + i2 = await _spawn(session_factory, "a_2", "s_1") + i3 = await _spawn(session_factory, "a_1", "s_2") + async with session_factory() as session: + registry = AgentRegistry(session) + by_agent = await registry.list_instances(agent_id="a_1") + by_session = await registry.list_instances(session_id="s_1") + by_status = await registry.list_instances(status=InstanceStatus.IDLE) + combined = await registry.list_instances(agent_id="a_1", session_id="s_1") + assert {r.instance_id for r in by_agent} == {i1, i3} + assert {r.instance_id for r in by_session} == {i1, i2} + assert {r.instance_id for r in by_status} == {i1, i2, i3} + assert [r.instance_id for r in combined] == [i1] + + +async def test_list_ordering_deterministic( + session_factory: async_sessionmaker[AsyncSession], +) -> None: + await _seed_defs(session_factory) + ids = [await _spawn(session_factory, a, s) for a, s in + [("a_1", "s_1"), ("a_2", "s_1"), ("a_1", "s_2")]] + async with session_factory() as session: + rows = await AgentRegistry(session).list_instances() + keys = [(r.created_at, r.instance_id) for r in rows] + assert keys == sorted(keys) + assert {r.instance_id for r in rows} == set(ids) From 98ade7875299c648888672fa209f284fcd09aa69 Mon Sep 17 00:00:00 2001 From: Momo the Bestest <45446348+Svagtlys@users.noreply.github.com> Date: Sun, 27 Sep 2026 17:54:49 +0100 Subject: [PATCH 03/10] feat: add AgentRegistry.get_instance raising InstanceNotFoundError --- backend/src/octave/agent/registry.py | 14 ++++++++++++++ backend/tests/agent/test_registry.py | 24 +++++++++++++++++++++++- 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/backend/src/octave/agent/registry.py b/backend/src/octave/agent/registry.py index 239df32..5e80d7d 100644 --- a/backend/src/octave/agent/registry.py +++ b/backend/src/octave/agent/registry.py @@ -16,6 +16,7 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession +from octave.agent.errors import InstanceNotFoundError from octave.db.models import Agent, AgentInstance from octave.db.types import AgentAssignments, AgentStatus, InstanceStatus @@ -94,3 +95,16 @@ async def list_instances( stmt = stmt.where(AgentInstance.status == str(status)) rows = await self._session.execute(stmt) return [_to_running_agent(inst, ag) for inst, ag in rows] + + async def get_instance(self, instance_id: str) -> RunningAgent: + """Raise on miss — same contract as the manager's ``_get``.""" + row = ( + await self._session.execute( + select(AgentInstance, Agent) + .join(Agent, AgentInstance.agent_id == Agent.id) + .where(AgentInstance.id == instance_id) + ) + ).first() + if row is None: + raise InstanceNotFoundError(instance_id) + return _to_running_agent(row[0], row[1]) diff --git a/backend/tests/agent/test_registry.py b/backend/tests/agent/test_registry.py index 2a6d1d9..e3cc6b7 100644 --- a/backend/tests/agent/test_registry.py +++ b/backend/tests/agent/test_registry.py @@ -4,9 +4,10 @@ spawns driven through AgentInstanceManager (the only write path). """ +import pytest from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker -from octave.agent import AgentInstanceManager +from octave.agent import AgentInstanceManager, InstanceNotFoundError from octave.agent.registry import AgentRegistry from octave.db.models import Agent, Session, User from octave.db.types import InstanceStatus @@ -118,3 +119,24 @@ async def test_list_ordering_deterministic( keys = [(r.created_at, r.instance_id) for r in rows] assert keys == sorted(keys) assert {r.instance_id for r in rows} == set(ids) + + +async def test_get_instance_returns_joined_record( + session_factory: async_sessionmaker[AsyncSession], +) -> None: + await _seed_defs(session_factory) + instance_id = await _spawn(session_factory, "a_1", "s_1") + async with session_factory() as session: + running = await AgentRegistry(session).get_instance(instance_id) + assert running.instance_id == instance_id + assert running.agent_name == "Octave" + assert running.assignments.prompt == "v_p" + + +async def test_get_instance_missing_raises( + session_factory: async_sessionmaker[AsyncSession], +) -> None: + await _seed_defs(session_factory) + async with session_factory() as session: + with pytest.raises(InstanceNotFoundError): + await AgentRegistry(session).get_instance("nope") From 4c60fe1207e917b8ddff0a0d852f4a3230e9ff7c Mon Sep 17 00:00:00 2001 From: Momo the Bestest <45446348+Svagtlys@users.noreply.github.com> Date: Sun, 27 Sep 2026 17:56:39 +0100 Subject: [PATCH 04/10] feat: add AgentRegistry.count_by_status with zero-filled enum keys --- backend/src/octave/agent/registry.py | 15 ++++++++++++++- backend/tests/agent/test_registry.py | 24 ++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/backend/src/octave/agent/registry.py b/backend/src/octave/agent/registry.py index 5e80d7d..680faa0 100644 --- a/backend/src/octave/agent/registry.py +++ b/backend/src/octave/agent/registry.py @@ -13,7 +13,7 @@ from datetime import datetime from pydantic import TypeAdapter -from sqlalchemy import select +from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from octave.agent.errors import InstanceNotFoundError @@ -108,3 +108,16 @@ async def get_instance(self, instance_id: str) -> RunningAgent: if row is None: raise InstanceNotFoundError(instance_id) return _to_running_agent(row[0], row[1]) + + async def count_by_status(self) -> dict[InstanceStatus, int]: + """Dashboard counter over all rows (no filters — this is not a + filtered aggregate). Both enum keys present, zero-filled.""" + rows = await self._session.execute( + select(AgentInstance.status, func.count()).group_by( + AgentInstance.status + ) + ) + counts = {status: 0 for status in InstanceStatus} + for raw, total in rows: + counts[InstanceStatus(raw)] += int(total) + return counts diff --git a/backend/tests/agent/test_registry.py b/backend/tests/agent/test_registry.py index e3cc6b7..8199d96 100644 --- a/backend/tests/agent/test_registry.py +++ b/backend/tests/agent/test_registry.py @@ -140,3 +140,27 @@ async def test_get_instance_missing_raises( async with session_factory() as session: with pytest.raises(InstanceNotFoundError): await AgentRegistry(session).get_instance("nope") + + +async def test_count_by_status_zero_filled( + session_factory: async_sessionmaker[AsyncSession], +) -> None: + await _seed_defs(session_factory) + async with session_factory() as session: + counts = await AgentRegistry(session).count_by_status() + assert counts == {InstanceStatus.IDLE: 0, InstanceStatus.ACTIVE: 0} + + +async def test_count_by_status_mixed( + session_factory: async_sessionmaker[AsyncSession], +) -> None: + await _seed_defs(session_factory) + await _spawn(session_factory, "a_1", "s_1") + await _spawn(session_factory, "a_2", "s_1") + i3 = await _spawn(session_factory, "a_1", "s_2") + async with session_factory() as session: + await AgentInstanceManager(session).begin_turn(i3) + await session.commit() + async with session_factory() as session: + counts = await AgentRegistry(session).count_by_status() + assert counts == {InstanceStatus.IDLE: 2, InstanceStatus.ACTIVE: 1} From 3bb23152a30082b0d1f57b6dfd5b5d086a41424a Mon Sep 17 00:00:00 2001 From: Momo the Bestest <45446348+Svagtlys@users.noreply.github.com> Date: Sun, 27 Sep 2026 17:58:59 +0100 Subject: [PATCH 05/10] feat: lenient assignments parsing in AgentRegistry (warn + empty) --- backend/src/octave/agent/registry.py | 23 ++++++++++++---- backend/tests/agent/test_registry.py | 41 +++++++++++++++++++++++++++- 2 files changed, 58 insertions(+), 6 deletions(-) diff --git a/backend/src/octave/agent/registry.py b/backend/src/octave/agent/registry.py index 680faa0..638159e 100644 --- a/backend/src/octave/agent/registry.py +++ b/backend/src/octave/agent/registry.py @@ -12,7 +12,7 @@ from dataclasses import dataclass from datetime import datetime -from pydantic import TypeAdapter +from pydantic import TypeAdapter, ValidationError from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession @@ -45,10 +45,23 @@ class RunningAgent: updated_at: datetime +def _parse_assignments(agent: Agent) -> AgentAssignments: + """Lenient by design (reporting surface, not a validation gate): + malformed references are a write-path bug, and a registry that raises + because one definition row is corrupt would hide it worse. Mirrors the + #25 dangling-reference rule — skip + warn.""" + try: + return _ASSIGNMENTS_ADAPTER.validate_python(agent.assignments) + except ValidationError: + logger.warning( + "agent %s has invalid assignments JSON; reporting empty", agent.id + ) + return AgentAssignments() + + def _to_running_agent(instance: AgentInstance, agent: Agent) -> RunningAgent: - """Status parses are loud (a bad enum value is DB corruption; silently - coercing it would misreport lifecycle state). Assignments parsing is - made lenient in Task 4 (reporting surface, not a validation gate).""" + """Status parses stay loud (a bad enum value is DB corruption; silently + coercing it would misreport lifecycle state).""" return RunningAgent( instance_id=instance.id, agent_id=instance.agent_id, @@ -56,7 +69,7 @@ def _to_running_agent(instance: AgentInstance, agent: Agent) -> RunningAgent: definition_status=AgentStatus(agent.status), instance_status=InstanceStatus(instance.status), session_id=instance.session_id, - assignments=_ASSIGNMENTS_ADAPTER.validate_python(agent.assignments), + assignments=_parse_assignments(agent), created_at=instance.created_at, updated_at=instance.updated_at, ) diff --git a/backend/tests/agent/test_registry.py b/backend/tests/agent/test_registry.py index 8199d96..34d7424 100644 --- a/backend/tests/agent/test_registry.py +++ b/backend/tests/agent/test_registry.py @@ -4,12 +4,15 @@ spawns driven through AgentInstanceManager (the only write path). """ +import logging + import pytest +from sqlalchemy import update from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from octave.agent import AgentInstanceManager, InstanceNotFoundError from octave.agent.registry import AgentRegistry -from octave.db.models import Agent, Session, User +from octave.db.models import Agent, AgentInstance, Session, User from octave.db.types import InstanceStatus _TAG_BINDING = {"kind": "tag", "tag": "quick"} @@ -164,3 +167,39 @@ async def test_count_by_status_mixed( async with session_factory() as session: counts = await AgentRegistry(session).count_by_status() assert counts == {InstanceStatus.IDLE: 2, InstanceStatus.ACTIVE: 1} + + +async def test_corrupt_assignments_warn_and_empty( + session_factory: async_sessionmaker[AsyncSession], caplog +) -> None: + await _seed_defs(session_factory) + await _spawn(session_factory, "a_1", "s_1") + async with session_factory() as session: + await session.execute( + update(Agent).where(Agent.id == "a_1").values(assignments={"skills": "not-a-list"}) + ) + await session.commit() + with caplog.at_level(logging.WARNING, logger="octave.agent.registry"): + async with session_factory() as session: + [running] = await AgentRegistry(session).list_instances() + assert running.assignments.prompt is None + assert running.assignments.skills == [] + assert running.assignments.preference_tags == [] + assert "invalid assignments" in caplog.text + + +async def test_bogus_instance_status_fails_loud( + session_factory: async_sessionmaker[AsyncSession], +) -> None: + await _seed_defs(session_factory) + instance_id = await _spawn(session_factory, "a_1", "s_1") + async with session_factory() as session: + await session.execute( + update(AgentInstance) + .where(AgentInstance.id == instance_id) + .values(status="bogus") + ) + await session.commit() + async with session_factory() as session: + with pytest.raises(ValueError): + await AgentRegistry(session).list_instances() From d917eaf27963317500f64ad2e7fdf4d13ebe188a Mon Sep 17 00:00:00 2001 From: Momo the Bestest <45446348+Svagtlys@users.noreply.github.com> Date: Sun, 27 Sep 2026 17:59:44 +0100 Subject: [PATCH 06/10] test: pin registry lifecycle visibility and immutability guarantees --- backend/tests/agent/test_registry.py | 49 ++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/backend/tests/agent/test_registry.py b/backend/tests/agent/test_registry.py index 34d7424..18cef23 100644 --- a/backend/tests/agent/test_registry.py +++ b/backend/tests/agent/test_registry.py @@ -203,3 +203,52 @@ async def test_bogus_instance_status_fails_loud( async with session_factory() as session: with pytest.raises(ValueError): await AgentRegistry(session).list_instances() + + +async def test_lifecycle_visible_in_registry( + session_factory: async_sessionmaker[AsyncSession], +) -> None: + await _seed_defs(session_factory) + instance_id = await _spawn(session_factory, "a_1", "s_1") + async with session_factory() as session: + [running] = await AgentRegistry(session).list_instances() + assert running.instance_status == InstanceStatus.IDLE + async with session_factory() as session: + await AgentInstanceManager(session).begin_turn(instance_id) + await session.commit() + async with session_factory() as session: + [running] = await AgentRegistry(session).list_instances() + assert running.instance_status == InstanceStatus.ACTIVE + assert (await AgentRegistry(session).count_by_status())[ + InstanceStatus.ACTIVE + ] == 1 + async with session_factory() as session: + await AgentInstanceManager(session).end_turn(instance_id) + await session.commit() + async with session_factory() as session: + [running] = await AgentRegistry(session).list_instances() + assert running.instance_status == InstanceStatus.IDLE + async with session_factory() as session: + assert await AgentInstanceManager(session).destroy(instance_id) is True + await session.commit() + async with session_factory() as session: + registry = AgentRegistry(session) + assert await registry.list_instances() == [] + assert (await registry.count_by_status())[InstanceStatus.IDLE] == 0 + with pytest.raises(InstanceNotFoundError): + await registry.get_instance(instance_id) + + +async def test_registry_never_mutates( + session_factory: async_sessionmaker[AsyncSession], +) -> None: + await _seed_defs(session_factory) + await _spawn(session_factory, "a_1", "s_1") + async with session_factory() as session: + registry = AgentRegistry(session) + [running] = await registry.list_instances() + await registry.get_instance(running.instance_id) + await registry.count_by_status() + assert not session.new and not session.dirty and not session.deleted + async with session_factory() as session: + assert len(await AgentRegistry(session).list_instances()) == 1 From 662a8367f56756f14f723fcdc9312425dc1c5f1f Mon Sep 17 00:00:00 2001 From: Momo the Bestest <45446348+Svagtlys@users.noreply.github.com> Date: Sun, 27 Sep 2026 18:01:08 +0100 Subject: [PATCH 07/10] feat: export AgentRegistry and RunningAgent from octave.agent --- backend/src/octave/agent/__init__.py | 7 +++++-- backend/tests/agent/test_package.py | 2 ++ 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/backend/src/octave/agent/__init__.py b/backend/src/octave/agent/__init__.py index bfdef6d..fc937c0 100644 --- a/backend/src/octave/agent/__init__.py +++ b/backend/src/octave/agent/__init__.py @@ -3,8 +3,8 @@ Composition layer — the only package importing both octave.mcp and octave.inference, plus octave.db for the instance manager. Future Agent -Manager components (registry, router) land here as additional modules; -they do not exist yet. +Manager components (router, result collector) land here as additional +modules; they do not exist yet. """ from octave.agent.errors import ( @@ -24,6 +24,7 @@ from octave.agent.instances import AgentInstanceManager, ResolvedModel, resolve_model from octave.agent.loop import ToolLoop from octave.agent.mcp_executor import McpToolExecutor +from octave.agent.registry import AgentRegistry, RunningAgent from octave.agent.types import ToolOutcome, ToolTurn from octave.tools.errors import ToolError @@ -32,12 +33,14 @@ "AgentInstanceManager", "AgentNotFoundError", "AgentPausedError", + "AgentRegistry", "InstanceExistsError", "InstanceNotFoundError", "InvalidTransitionError", "McpToolExecutor", "ModelBindingError", "ResolvedModel", + "RunningAgent", "SessionNotFoundError", "TerminalSessionError", "ToolError", diff --git a/backend/tests/agent/test_package.py b/backend/tests/agent/test_package.py index 576ef2a..7fa6201 100644 --- a/backend/tests/agent/test_package.py +++ b/backend/tests/agent/test_package.py @@ -31,12 +31,14 @@ def test_public_names_are_exported() -> None: "AgentInstanceManager", "AgentNotFoundError", "AgentPausedError", + "AgentRegistry", "InstanceExistsError", "InstanceNotFoundError", "InvalidTransitionError", "McpToolExecutor", "ModelBindingError", "ResolvedModel", + "RunningAgent", "SessionNotFoundError", "TerminalSessionError", "ToolError", From 79b807ff5e6ec26544c09300a06b2f654ed3b16d Mon Sep 17 00:00:00 2001 From: Momo the Bestest <45446348+Svagtlys@users.noreply.github.com> Date: Sun, 27 Sep 2026 18:15:07 +0100 Subject: [PATCH 08/10] style: wrap long update() chain in registry test (ruff E501) --- backend/tests/agent/test_registry.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/backend/tests/agent/test_registry.py b/backend/tests/agent/test_registry.py index 18cef23..6e76489 100644 --- a/backend/tests/agent/test_registry.py +++ b/backend/tests/agent/test_registry.py @@ -176,7 +176,9 @@ async def test_corrupt_assignments_warn_and_empty( await _spawn(session_factory, "a_1", "s_1") async with session_factory() as session: await session.execute( - update(Agent).where(Agent.id == "a_1").values(assignments={"skills": "not-a-list"}) + update(Agent) + .where(Agent.id == "a_1") + .values(assignments={"skills": "not-a-list"}) ) await session.commit() with caplog.at_level(logging.WARNING, logger="octave.agent.registry"): From d3eea8134c877d2214cf2ec1e4b7b575f607ccec Mon Sep 17 00:00:00 2001 From: Momo the Bestest <45446348+Svagtlys@users.noreply.github.com> Date: Sun, 27 Sep 2026 18:15:07 +0100 Subject: [PATCH 09/10] docs: mark Agent Manager #2 (agent registry) done --- docs/TODO.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/TODO.md b/docs/TODO.md index 56b9a0c..5312b5c 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -105,7 +105,7 @@ ## Agent Manager - [x] 1. Design agent lifecycle model (definition active/paused gate; instance spawn → idle ⇄ active → destroy) — PR #105 (lifecycle vocabulary, `agent_instances` table, `AgentInstanceManager`; router/registry/scheduling remain #2–6) -- [ ] 2. Build agent registry (track running agents, their IDs, status, and assigned context) +- [x] 2. Build agent registry (track running agents, their IDs, status, and assigned context) — PR #106 (read-only `AgentRegistry` over `agent_instances ⋈ agents`; no new table) - [ ] 3. Implement agent message routing (deliver messages to correct agent, broadcast when needed) - [ ] 4. Create agent result collection (capture agent outputs and make them queryable) - [ ] 5. Build inter-agent result sharing (allow agents to request and receive results from other agents) From 60736a9864c9b692228dfe7f7fcc94ed989e2018 Mon Sep 17 00:00:00 2001 From: Momo the Bestest <45446348+Svagtlys@users.noreply.github.com> Date: Sun, 27 Sep 2026 18:53:11 +0100 Subject: [PATCH 10/10] docs: add agent registry design spec, plan, and architecture notes --- .../specs/2026-09-27-agent-registry-design.md | 183 +++++ .agents/specs/2026-09-27-agent-registry.md | 697 ++++++++++++++++++ docs/ARCHITECTURE.md | 11 + 3 files changed, 891 insertions(+) create mode 100644 .agents/specs/2026-09-27-agent-registry-design.md create mode 100644 .agents/specs/2026-09-27-agent-registry.md diff --git a/.agents/specs/2026-09-27-agent-registry-design.md b/.agents/specs/2026-09-27-agent-registry-design.md new file mode 100644 index 0000000..00be773 --- /dev/null +++ b/.agents/specs/2026-09-27-agent-registry-design.md @@ -0,0 +1,183 @@ +# Agent Registry — Design + +> Issue: #26 · PR: #106 · Branch: `feature/agent-registry` +> Date: 2026-09-27 · Status: Approved in brainstorming +> Depends on: 2026-09-27 Agent Lifecycle Model design (#25, PR #105 — shipped) +> Consumers: Agent Manager roadmap #3–#6, Agent Manager View UI + +## Problem Statement + +Issue #26 asks for the agent registry: a central source of truth tracking all +running agents, their IDs, current lifecycle state, and assigned context, +persisted to the database. + +The #25 lifecycle design already shipped the persistence substrate: +`agent_instances` rows *are* the durable record of which agents are running — +spawn inserts, destroy hard-deletes, session end cascades, and crash +reconciliation resets stale `active` rows. Its Consequences section decides +this work item's shape in advance: *"Agent Manager #2 (registry) becomes +queries over `agent_instances` rather than a parallel bookkeeping structure."* + +This work item therefore adds the **read surface** over that substrate — +typed, filterable, join-enriched queries — without introducing a second +source of truth. + +## Design Decisions (from brainstorming) + +| # | Decision | Rejected alternative | +|---|----------|----------------------| +| 1 | Read-only `AgentRegistry` class in `octave.agent.registry`, separate from the write-path `AgentInstanceManager`; constructed with a caller-supplied `AsyncSession`, never commits | Query methods bolted onto `AgentInstanceManager`; a parallel `agent_registry` table | +| 2 | **No new table, no migration** — persistence shipped with #25; the registry is SELECTs over `agent_instances` ⋈ `agents` | A mirrored registry table (dual-write desync; contradicts the approved #25 design) | +| 3 | Assigned context is **raw pass-through**: `agents.assignments` JSON references + agent name; no vault resolution | Joining `vault_items` to hydrate prompt/skill content (couples the registry to Context Manager #4 injection semantics); an opt-in `resolve=True` flag (second code path, no confirmed consumer) | +| 4 | Lean surface: `list_instances` (composable filters), `get_instance` (raise on miss), `count_by_status` | List-only minimalism (every consumer re-implements get/count); speculative session-centric views (`instances_for_session`, `sessions_for_agent`) | +| 5 | **Library-level only** — no REST/WebSocket routes | A read-only REST surface (route-layer conventions are not yet established by any agent work item; composition arrives with Integration & Testing #1, per #25) | + +## Read Model + +`RunningAgent` — frozen dataclass, one row of the registry view: + +| Field | Type | Source | +|---|---|---| +| `instance_id` | `str` | `agent_instances.id` | +| `agent_id` | `str` | `agent_instances.agent_id` | +| `agent_name` | `str` | `agents.name` | +| `definition_status` | `AgentStatus` | `agents.status`, parsed | +| `instance_status` | `InstanceStatus` | `agent_instances.status`, parsed | +| `session_id` | `str` | `agent_instances.session_id` | +| `assignments` | `AgentAssignments` | `agents.assignments` JSON, validated leniently (below) | +| `created_at` / `updated_at` | `datetime` | `agent_instances` timestamps | + +`model_binding` is deliberately **not** in the read model: the issue names +IDs, lifecycle state, and assigned context. If the dashboard needs the model +binding, adding one field is additive. + +## Interface + +```python +class AgentRegistry: + """Read-only queries over agent_instances ⋈ agents. Never commits; + never mutates. Constructed with the caller's AsyncSession + (VaultStore / AgentInstanceManager convention).""" + + def __init__(self, session: AsyncSession) -> None: ... + + async def list_instances( + self, + *, + agent_id: str | None = None, + session_id: str | None = None, + status: InstanceStatus | None = None, + ) -> list[RunningAgent]: ... + + async def get_instance(self, instance_id: str) -> RunningAgent: ... + + async def count_by_status(self) -> dict[InstanceStatus, int]: ... +``` + +### Query semantics + +- **Join**: inner join `agent_instances` → `agents`. The `RESTRICT` FK + guarantees every instance has its definition row; no orphan handling. + `sessions` is *not* joined — `session_id` lives on the instance row and no + session column is read. +- **Filters** compose with AND; `status` filters the *instance* status + (`idle | active`), not the definition status. +- **Ordering**: `created_at ASC, id ASC` (deterministic; `id` breaks + same-timestamp ties). +- **`get_instance`** raises `InstanceNotFoundError` (existing class in + `octave.agent.errors`) on a miss — same contract as the manager's `_get`. +- **`count_by_status`** returns both enum keys, zero-filled, over all rows + (no filters — it is a dashboard counter, not a filtered aggregate). +- **No pagination**: local-first single-user workload; deferred the same way + `ToolRegistry` deferred cursor pagination (follow-up if a consumer needs it). + +### Lenient parsing (reporting surface, not a validation gate) + +The registry must never raise on data it merely reports: + +- `assignments` JSON invalid → log a warning, substitute + `AgentAssignments()` (empty). Mirrors the #25 dangling-reference rule + (skip + warn): malformed references are a write-path bug, but a registry + that 500s because one definition row is corrupt would hide it worse. +- `agents.status` / `agent_instances.status` outside the enum → raise. + These are written only by the manager and definition CRUD (which + validates); an out-of-vocabulary value means genuine DB corruption, and + silently coercing it would misreport lifecycle state. The asymmetry is + deliberate: assignments are *references* (tolerate), status *is* the + registry's subject matter (fail loud). + +## Lifecycle Integration + +The registry adds no states and enforces no transitions — #25's state +machine and turn mutex remain the only write path. Notable reported states: + +- **Paused definition + live instance** is legitimate (pause is a gate, not + an interrupt; active turns run to completion). The registry reports both + statuses side by side; consumers decide policy. +- **Crash reconciliation** stays owned by the write path + (`AgentInstanceManager.reconcile` on startup). The registry reports current + rows as-is; it never invokes the manager. +- **Destroyed instances** simply disappear from every query (hard delete). + +## Error Handling + +- `get_instance` miss → `InstanceNotFoundError`. No new error classes. +- `assignments` malformed → warn + empty model (above). +- Registry methods issue SELECTs only; nothing to roll back, nothing to + commit. Callers own transaction boundaries (`octave.db.deps`). + +## Non-Goals + +- REST/WebSocket routes, frontend wiring (composition = Integration #1; UI = + Agent Manager View). +- Vault resolution of assignment references (Context Manager #4). +- Message routing (#3), result collection (#4), inter-agent sharing (#5), + priority/scheduling (#6). +- New tables, migrations, or indexes (`agent_instances` indexes from #25 + suffice for the filter columns). +- Pagination, sorting options, projection options. + +## Testing + +Real SQLite via the shared `session_factory` fixture; seed helpers modeled +on `tests/agent/test_instances.py` (`_seed_defs`): + +- **list_instances**: empty DB → `[]`; multiple instances across + agents/sessions → all returned with joined fields correct (name, + definition_status, assignments pass-through); each filter alone + (`agent_id`, `session_id`, `status`) and combined; ordering deterministic. +- **get_instance**: returns the enriched record; missing id raises + `InstanceNotFoundError`. +- **count_by_status**: zero-filled on empty DB; mixed idle/active counts + both keys; destroyed instances excluded. +- **Lifecycle visibility**: spawn → appears; `begin_turn` → status flips to + active; `end_turn` → idle; `destroy` → gone from list/get/count. +- **Lenient assignments**: corrupt `agents.assignments` JSON → warning logged + (caplog), `AgentAssignments()` returned, no raise. +- **No mutation**: registry calls leave the DB byte-identical and require no + commit (fresh session, no writes flushed). +- **Package exports**: `AgentRegistry`, `RunningAgent` in `octave.agent` + `__all__` (mirrors `test_package.py` convention). + +## Implementation Sketch + +Paths for the implementation plan to refine: + +| File | Change | +|---|---| +| `backend/src/octave/agent/registry.py` | new: `RunningAgent` dataclass, `AgentRegistry` (list/get/count) | +| `backend/src/octave/agent/__init__.py` | export `AgentRegistry`, `RunningAgent` | +| `backend/tests/agent/test_registry.py` | new: all tests above | +| `docs/TODO.md` | mark Agent Manager #2 done | + +## Consequences + +- `octave.agent` gains its first read surface; consumers (#3 router, #4 + collector, dashboard UI) depend on `RunningAgent` as their DTO contract. +- The SDK-quarantine AST guard is unaffected (no `openai`/`mcp` imports). +- `octave.agent.registry` is the third `registry` module in the codebase + (`octave.mcp.registry` = tool inventory, `octave.db.registry` = adapter + registry). Module paths disambiguate; the name matches the roadmap + vocabulary ("agent registry"). +- No schema change means #26 ships with a green migration chain untouched — + the registry is purely additive Python. diff --git a/.agents/specs/2026-09-27-agent-registry.md b/.agents/specs/2026-09-27-agent-registry.md new file mode 100644 index 0000000..81f95c8 --- /dev/null +++ b/.agents/specs/2026-09-27-agent-registry.md @@ -0,0 +1,697 @@ +# Agent Registry Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ship the read-only `AgentRegistry` — typed, filterable queries over `agent_instances ⋈ agents` — as the central source of truth for running agents (issue #26). + +**Architecture:** A single new module `octave/agent/registry.py` exposes `AgentRegistry` (constructed with a caller-supplied `AsyncSession`, never commits, never mutates) returning frozen `RunningAgent` read models. No new table, no migration: persistence already shipped with the #25 lifecycle model (`AgentInstanceManager` remains the only write path). Library-level only — no routes. + +**Tech Stack:** Python 3.13, SQLAlchemy 2.x async ORM (`select`, `func.count`), Pydantic `TypeAdapter` for `AgentAssignments` validation, pytest + pytest-asyncio against real SQLite (shared `session_factory` fixture in `backend/tests/conftest.py`). + +**Spec:** [`.agents/specs/2026-09-27-agent-registry-design.md`](2026-09-27-agent-registry-design.md) · Branch: `feature/agent-registry` · PR: #106 + +**Commands run from `backend/`.** Test style mirrors `tests/agent/test_instances.py`: real SQLite via `session_factory`, explicit commits, no mocks. + +--- + +## File Structure + +| File | Responsibility | +|---|---| +| Create `backend/src/octave/agent/registry.py` | `RunningAgent` read model + `AgentRegistry` (list / get / count). The entire registry. | +| Create `backend/tests/agent/test_registry.py` | All registry tests. | +| Modify `backend/src/octave/agent/__init__.py` | Export `AgentRegistry`, `RunningAgent`. | +| Modify `backend/tests/agent/test_package.py` | Assert the two new public names. | +| Modify `docs/TODO.md` | Mark Agent Manager #2 done. | + +--- + +### Task 1: `RunningAgent` read model + `AgentRegistry.list_instances` + +**Files:** +- Create: `backend/src/octave/agent/registry.py` +- Create: `backend/tests/agent/test_registry.py` + +- [ ] **Step 1: Write the failing tests** + +Create `backend/tests/agent/test_registry.py`: + +```python +"""AgentRegistry read surface (design spec 2026-09-27-agent-registry). + +Read-only queries over the #25 substrate: real SQLite via session_factory, +spawns driven through AgentInstanceManager (the only write path). +""" + +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker + +from octave.agent import AgentInstanceManager +from octave.agent.registry import AgentRegistry +from octave.db.models import Agent, Session, User +from octave.db.types import InstanceStatus + +_TAG_BINDING = {"kind": "tag", "tag": "quick"} +_ASSIGNMENTS = { + "prompt": "v_p", + "skills": ["v_s1", "v_s2"], + "preference_tags": ["terse"], +} + + +async def _seed_defs( + session_factory: async_sessionmaker[AsyncSession], +) -> None: + """One user; agents a_1/a_2 active + a_3 paused; sessions s_1/s_2.""" + async with session_factory() as session: + session.add(User(id="u_1", display_name="Alice")) + session.add( + Agent( + id="a_1", + name="Octave", + model_binding=_TAG_BINDING, + assignments=dict(_ASSIGNMENTS), + ) + ) + session.add(Agent(id="a_2", name="Helper", model_binding=_TAG_BINDING)) + session.add( + Agent( + id="a_3", name="Paused", status="paused", model_binding=_TAG_BINDING + ) + ) + session.add( + Session(id="s_1", created_by_user_id="u_1", status="active") + ) + session.add( + Session(id="s_2", created_by_user_id="u_1", status="active") + ) + await session.commit() + + +async def _spawn( + session_factory: async_sessionmaker[AsyncSession], + agent_id: str, + session_id: str, +) -> str: + async with session_factory() as session: + instance = await AgentInstanceManager(session).spawn( + agent_id=agent_id, session_id=session_id + ) + await session.commit() + return instance.id + + +async def test_list_empty( + session_factory: async_sessionmaker[AsyncSession], +) -> None: + await _seed_defs(session_factory) + async with session_factory() as session: + assert await AgentRegistry(session).list_instances() == [] + + +async def test_list_returns_joined_fields( + session_factory: async_sessionmaker[AsyncSession], +) -> None: + await _seed_defs(session_factory) + instance_id = await _spawn(session_factory, "a_1", "s_1") + async with session_factory() as session: + [running] = await AgentRegistry(session).list_instances() + assert running.instance_id == instance_id + assert running.agent_id == "a_1" + assert running.agent_name == "Octave" + assert running.definition_status == "active" + assert running.instance_status == "idle" + assert running.session_id == "s_1" + assert running.assignments.prompt == "v_p" + assert running.assignments.skills == ["v_s1", "v_s2"] + assert running.assignments.preference_tags == ["terse"] + assert running.created_at <= running.updated_at + + +async def test_list_filters_compose( + session_factory: async_sessionmaker[AsyncSession], +) -> None: + await _seed_defs(session_factory) + i1 = await _spawn(session_factory, "a_1", "s_1") + i2 = await _spawn(session_factory, "a_2", "s_1") + i3 = await _spawn(session_factory, "a_1", "s_2") + async with session_factory() as session: + registry = AgentRegistry(session) + by_agent = await registry.list_instances(agent_id="a_1") + by_session = await registry.list_instances(session_id="s_1") + by_status = await registry.list_instances(status=InstanceStatus.IDLE) + combined = await registry.list_instances(agent_id="a_1", session_id="s_1") + assert {r.instance_id for r in by_agent} == {i1, i3} + assert {r.instance_id for r in by_session} == {i1, i2} + assert {r.instance_id for r in by_status} == {i1, i2, i3} + assert [r.instance_id for r in combined] == [i1] + + +async def test_list_ordering_deterministic( + session_factory: async_sessionmaker[AsyncSession], +) -> None: + await _seed_defs(session_factory) + ids = [await _spawn(session_factory, a, s) for a, s in + [("a_1", "s_1"), ("a_2", "s_1"), ("a_1", "s_2")]] + async with session_factory() as session: + rows = await AgentRegistry(session).list_instances() + keys = [(r.created_at, r.instance_id) for r in rows] + assert keys == sorted(keys) + assert {r.instance_id for r in rows} == set(ids) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd backend && uv run pytest tests/agent/test_registry.py -v` +Expected: collection error — `ImportError: cannot import name 'AgentRegistry'` (module `octave.agent.registry` does not exist). + +- [ ] **Step 3: Write minimal implementation** + +Create `backend/src/octave/agent/registry.py`: + +```python +"""Agent registry: read-only queries over running instances (issue #26). + +Rows in ``agent_instances`` are the durable record of what is running +(spawn inserts, destroy deletes — design spec 2026-09-27); this module is +the source-of-truth *view*, not a parallel bookkeeping structure. No states +added, no transitions enforced: ``AgentInstanceManager`` remains the only +write path. Follows the VaultStore convention — constructed with the +caller's AsyncSession, never commits, never mutates. +""" + +import logging +from dataclasses import dataclass +from datetime import datetime + +from pydantic import TypeAdapter +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from octave.db.models import Agent, AgentInstance +from octave.db.types import AgentAssignments, AgentStatus, InstanceStatus + +__all__ = ["AgentRegistry", "RunningAgent"] + +logger = logging.getLogger(__name__) + +_ASSIGNMENTS_ADAPTER: TypeAdapter[AgentAssignments] = TypeAdapter(AgentAssignments) + + +@dataclass(frozen=True) +class RunningAgent: + """One row of the registry view: instance fields + joined definition + fields. ``model_binding`` is deliberately absent (design spec: IDs, + lifecycle state, assigned context — add a field if the dashboard needs + the binding).""" + + instance_id: str + agent_id: str + agent_name: str + definition_status: AgentStatus + instance_status: InstanceStatus + session_id: str + assignments: AgentAssignments + created_at: datetime + updated_at: datetime + + +def _to_running_agent(instance: AgentInstance, agent: Agent) -> RunningAgent: + """Status parses are loud (a bad enum value is DB corruption; silently + coercing it would misreport lifecycle state). Assignments parsing is + made lenient in Task 4 (reporting surface, not a validation gate).""" + return RunningAgent( + instance_id=instance.id, + agent_id=instance.agent_id, + agent_name=agent.name, + definition_status=AgentStatus(agent.status), + instance_status=InstanceStatus(instance.status), + session_id=instance.session_id, + assignments=_ASSIGNMENTS_ADAPTER.validate_python(agent.assignments), + created_at=instance.created_at, + updated_at=instance.updated_at, + ) + + +class AgentRegistry: + """Read-only queries over ``agent_instances ⋈ agents``. The RESTRICT FK + guarantees every instance has its definition row, so the join is safe; + ``sessions`` is not joined — ``session_id`` lives on the instance row. + Never commits; callers own transaction boundaries (``octave.db.deps``). + """ + + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def list_instances( + self, + *, + agent_id: str | None = None, + session_id: str | None = None, + status: InstanceStatus | None = None, + ) -> list[RunningAgent]: + """Filters compose with AND; ``status`` is the *instance* status + (idle | active), not the definition's. Ordering ``created_at ASC, + id ASC`` — deterministic even on timestamp ties.""" + stmt = ( + select(AgentInstance, Agent) + .join(Agent, AgentInstance.agent_id == Agent.id) + .order_by(AgentInstance.created_at, AgentInstance.id) + ) + if agent_id is not None: + stmt = stmt.where(AgentInstance.agent_id == agent_id) + if session_id is not None: + stmt = stmt.where(AgentInstance.session_id == session_id) + if status is not None: + stmt = stmt.where(AgentInstance.status == str(status)) + rows = await self._session.execute(stmt) + return [_to_running_agent(inst, ag) for inst, ag in rows] +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd backend && uv run pytest tests/agent/test_registry.py -v` +Expected: 4 passed. + +- [ ] **Step 5: Commit** + +```bash +git add backend/src/octave/agent/registry.py backend/tests/agent/test_registry.py +git commit -m "feat: add AgentRegistry.list_instances over agent_instances ⋈ agents" +``` + +--- + +### Task 2: `AgentRegistry.get_instance` + +**Files:** +- Modify: `backend/src/octave/agent/registry.py` +- Modify: `backend/tests/agent/test_registry.py` + +- [ ] **Step 1: Write the failing tests** + +Append to `backend/tests/agent/test_registry.py` (and add `InstanceNotFoundError` to the existing `from octave.agent import AgentInstanceManager` line → `from octave.agent import AgentInstanceManager, InstanceNotFoundError` — it is re-exported by the package; also add `import pytest` at the top): + +```python +async def test_get_instance_returns_joined_record( + session_factory: async_sessionmaker[AsyncSession], +) -> None: + await _seed_defs(session_factory) + instance_id = await _spawn(session_factory, "a_1", "s_1") + async with session_factory() as session: + running = await AgentRegistry(session).get_instance(instance_id) + assert running.instance_id == instance_id + assert running.agent_name == "Octave" + assert running.assignments.prompt == "v_p" + + +async def test_get_instance_missing_raises( + session_factory: async_sessionmaker[AsyncSession], +) -> None: + await _seed_defs(session_factory) + async with session_factory() as session: + with pytest.raises(InstanceNotFoundError): + await AgentRegistry(session).get_instance("nope") +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd backend && uv run pytest tests/agent/test_registry.py -v` +Expected: 2 new tests FAIL with `AttributeError: 'AgentRegistry' object has no attribute 'get_instance'`. + +- [ ] **Step 3: Write minimal implementation** + +In `backend/src/octave/agent/registry.py`, add the import (with the other `octave` imports): + +```python +from octave.agent.errors import InstanceNotFoundError +``` + +and add the method to `AgentRegistry` after `list_instances`: + +```python + async def get_instance(self, instance_id: str) -> RunningAgent: + """Raise on miss — same contract as the manager's ``_get``.""" + row = ( + await self._session.execute( + select(AgentInstance, Agent) + .join(Agent, AgentInstance.agent_id == Agent.id) + .where(AgentInstance.id == instance_id) + ) + ).first() + if row is None: + raise InstanceNotFoundError(instance_id) + return _to_running_agent(row[0], row[1]) +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd backend && uv run pytest tests/agent/test_registry.py -v` +Expected: 6 passed. + +- [ ] **Step 5: Commit** + +```bash +git add backend/src/octave/agent/registry.py backend/tests/agent/test_registry.py +git commit -m "feat: add AgentRegistry.get_instance raising InstanceNotFoundError" +``` + +--- + +### Task 3: `AgentRegistry.count_by_status` + +**Files:** +- Modify: `backend/src/octave/agent/registry.py` +- Modify: `backend/tests/agent/test_registry.py` + +- [ ] **Step 1: Write the failing tests** + +Append to `backend/tests/agent/test_registry.py`: + +```python +async def test_count_by_status_zero_filled( + session_factory: async_sessionmaker[AsyncSession], +) -> None: + await _seed_defs(session_factory) + async with session_factory() as session: + counts = await AgentRegistry(session).count_by_status() + assert counts == {InstanceStatus.IDLE: 0, InstanceStatus.ACTIVE: 0} + + +async def test_count_by_status_mixed( + session_factory: async_sessionmaker[AsyncSession], +) -> None: + await _seed_defs(session_factory) + await _spawn(session_factory, "a_1", "s_1") + await _spawn(session_factory, "a_2", "s_1") + i3 = await _spawn(session_factory, "a_1", "s_2") + async with session_factory() as session: + await AgentInstanceManager(session).begin_turn(i3) + await session.commit() + async with session_factory() as session: + counts = await AgentRegistry(session).count_by_status() + assert counts == {InstanceStatus.IDLE: 2, InstanceStatus.ACTIVE: 1} +``` + +(`InstanceStatus` is already imported in Task 1's test file header.) + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd backend && uv run pytest tests/agent/test_registry.py -v` +Expected: 2 new tests FAIL with `AttributeError: ... no attribute 'count_by_status'`. + +- [ ] **Step 3: Write minimal implementation** + +In `registry.py`, extend the sqlalchemy import to `from sqlalchemy import func, select` and add the method to `AgentRegistry`: + +```python + async def count_by_status(self) -> dict[InstanceStatus, int]: + """Dashboard counter over all rows (no filters — this is not a + filtered aggregate). Both enum keys present, zero-filled.""" + rows = await self._session.execute( + select(AgentInstance.status, func.count()).group_by( + AgentInstance.status + ) + ) + counts = {status: 0 for status in InstanceStatus} + for raw, total in rows: + counts[InstanceStatus(raw)] += int(total) + return counts +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd backend && uv run pytest tests/agent/test_registry.py -v` +Expected: 8 passed. + +- [ ] **Step 5: Commit** + +```bash +git add backend/src/octave/agent/registry.py backend/tests/agent/test_registry.py +git commit -m "feat: add AgentRegistry.count_by_status with zero-filled enum keys" +``` + +--- + +### Task 4: Lenient assignments parsing (warn + empty) + +Spec §"Lenient parsing": the registry must never raise on data it merely reports. Assignments are references → tolerate; status *is* the subject matter → fail loud (already satisfied by the `AgentStatus(...)`/`InstanceStatus(...)` constructors in `_to_running_agent`; the second test below pins that). + +**Files:** +- Modify: `backend/src/octave/agent/registry.py` +- Modify: `backend/tests/agent/test_registry.py` + +- [ ] **Step 1: Write the failing tests** + +Add `import logging` and `from sqlalchemy import update` to the test file imports. Append: + +```python +async def test_corrupt_assignments_warn_and_empty( + session_factory: async_sessionmaker[AsyncSession], caplog +) -> None: + await _seed_defs(session_factory) + await _spawn(session_factory, "a_1", "s_1") + async with session_factory() as session: + await session.execute( + update(Agent).where(Agent.id == "a_1").values(assignments={"skills": "not-a-list"}) + ) + await session.commit() + with caplog.at_level(logging.WARNING, logger="octave.agent.registry"): + async with session_factory() as session: + [running] = await AgentRegistry(session).list_instances() + assert running.assignments.prompt is None + assert running.assignments.skills == [] + assert running.assignments.preference_tags == [] + assert "invalid assignments" in caplog.text + + +async def test_bogus_instance_status_fails_loud( + session_factory: async_sessionmaker[AsyncSession], +) -> None: + await _seed_defs(session_factory) + instance_id = await _spawn(session_factory, "a_1", "s_1") + async with session_factory() as session: + await session.execute( + update(AgentInstance) + .where(AgentInstance.id == instance_id) + .values(status="bogus") + ) + await session.commit() + async with session_factory() as session: + with pytest.raises(ValueError): + await AgentRegistry(session).list_instances() +``` + +Also extend the models import to `from octave.db.models import Agent, AgentInstance, Session, User`. + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd backend && uv run pytest tests/agent/test_registry.py -v` +Expected: `test_corrupt_assignments_warn_and_empty` FAILS (pydantic `ValidationError` escapes `list_instances`); `test_bogus_instance_status_fails_loud` PASSES (loud parse already in place — this test pins intentional behavior). + +- [ ] **Step 3: Write minimal implementation** + +In `registry.py`, add `ValidationError` to the pydantic import (`from pydantic import TypeAdapter, ValidationError`) and replace the body of `_to_running_agent`'s assignments line via a helper — replace the function with: + +```python +def _parse_assignments(agent: Agent) -> AgentAssignments: + """Lenient by design (reporting surface, not a validation gate): + malformed references are a write-path bug, and a registry that raises + because one definition row is corrupt would hide it worse. Mirrors the + #25 dangling-reference rule — skip + warn.""" + try: + return _ASSIGNMENTS_ADAPTER.validate_python(agent.assignments) + except ValidationError: + logger.warning( + "agent %s has invalid assignments JSON; reporting empty", agent.id + ) + return AgentAssignments() + + +def _to_running_agent(instance: AgentInstance, agent: Agent) -> RunningAgent: + """Status parses stay loud (a bad enum value is DB corruption; silently + coercing it would misreport lifecycle state).""" + return RunningAgent( + instance_id=instance.id, + agent_id=instance.agent_id, + agent_name=agent.name, + definition_status=AgentStatus(agent.status), + instance_status=InstanceStatus(instance.status), + session_id=instance.session_id, + assignments=_parse_assignments(agent), + created_at=instance.created_at, + updated_at=instance.updated_at, + ) +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd backend && uv run pytest tests/agent/test_registry.py -v` +Expected: 10 passed. + +- [ ] **Step 5: Commit** + +```bash +git add backend/src/octave/agent/registry.py backend/tests/agent/test_registry.py +git commit -m "feat: lenient assignments parsing in AgentRegistry (warn + empty)" +``` + +--- + +### Task 5: Lifecycle visibility + immutability guarantees + +**Files:** +- Modify: `backend/tests/agent/test_registry.py` + +No implementation changes expected — these tests pin the spec's integration guarantees against the shipped manager. If one fails, the bug is real; fix `registry.py` accordingly. + +- [ ] **Step 1: Write the tests** + +Append to `backend/tests/agent/test_registry.py`: + +```python +async def test_lifecycle_visible_in_registry( + session_factory: async_sessionmaker[AsyncSession], +) -> None: + await _seed_defs(session_factory) + instance_id = await _spawn(session_factory, "a_1", "s_1") + async with session_factory() as session: + [running] = await AgentRegistry(session).list_instances() + assert running.instance_status == InstanceStatus.IDLE + async with session_factory() as session: + await AgentInstanceManager(session).begin_turn(instance_id) + await session.commit() + async with session_factory() as session: + [running] = await AgentRegistry(session).list_instances() + assert running.instance_status == InstanceStatus.ACTIVE + assert (await AgentRegistry(session).count_by_status())[ + InstanceStatus.ACTIVE + ] == 1 + async with session_factory() as session: + await AgentInstanceManager(session).end_turn(instance_id) + await session.commit() + async with session_factory() as session: + [running] = await AgentRegistry(session).list_instances() + assert running.instance_status == InstanceStatus.IDLE + async with session_factory() as session: + assert await AgentInstanceManager(session).destroy(instance_id) is True + await session.commit() + async with session_factory() as session: + registry = AgentRegistry(session) + assert await registry.list_instances() == [] + assert (await registry.count_by_status())[InstanceStatus.IDLE] == 0 + with pytest.raises(InstanceNotFoundError): + await registry.get_instance(instance_id) + + +async def test_registry_never_mutates( + session_factory: async_sessionmaker[AsyncSession], +) -> None: + await _seed_defs(session_factory) + await _spawn(session_factory, "a_1", "s_1") + async with session_factory() as session: + registry = AgentRegistry(session) + [running] = await registry.list_instances() + await registry.get_instance(running.instance_id) + await registry.count_by_status() + assert not session.new and not session.dirty and not session.deleted + async with session_factory() as session: + assert len(await AgentRegistry(session).list_instances()) == 1 +``` + +- [ ] **Step 2: Run tests to verify they pass** + +Run: `cd backend && uv run pytest tests/agent/test_registry.py -v` +Expected: 12 passed. (If these fail, do not paper over — the spec's integration contract is broken; investigate `registry.py`.) + +- [ ] **Step 3: Commit** + +```bash +git add backend/tests/agent/test_registry.py +git commit -m "test: pin registry lifecycle visibility and immutability guarantees" +``` + +--- + +### Task 6: Package exports + +**Files:** +- Modify: `backend/src/octave/agent/__init__.py` +- Modify: `backend/tests/agent/test_package.py` + +- [ ] **Step 1: Write the failing test** + +In `backend/tests/agent/test_package.py`, add `"AgentRegistry"` after `"AgentPausedError"` and `"RunningAgent"` after `"ResolvedModel"` in the `test_public_names_are_exported` tuple. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd backend && uv run pytest tests/agent/test_package.py -v` +Expected: `test_public_names_are_exported` FAILS with `AssertionError: AgentRegistry`. + +- [ ] **Step 3: Write minimal implementation** + +In `backend/src/octave/agent/__init__.py`, add after the `loop` import line: + +```python +from octave.agent.registry import AgentRegistry, RunningAgent +``` + +and add `"AgentRegistry"` (after `"AgentPausedError"`) and `"RunningAgent"` (after `"ResolvedModel"`) to `__all__`. Update the module docstring sentence "Future Agent Manager components (registry, router) land here" → "Future Agent Manager components (router, result collector) land here". + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd backend && uv run pytest tests/agent/ -v` +Expected: all agent tests pass, including `test_no_sdk_imports` (registry imports no `openai`/`mcp`). + +- [ ] **Step 5: Commit** + +```bash +git add backend/src/octave/agent/__init__.py backend/tests/agent/test_package.py +git commit -m "feat: export AgentRegistry and RunningAgent from octave.agent" +``` + +--- + +### Task 7: Roadmap checkbox + full verification + +**Files:** +- Modify: `docs/TODO.md` + +- [ ] **Step 1: Update the roadmap** + +In `docs/TODO.md`, change Agent Manager item 2 from: + +```markdown +- [ ] 2. Build agent registry (track running agents, their IDs, status, and assigned context) +``` + +to: + +```markdown +- [x] 2. Build agent registry (track running agents, their IDs, status, and assigned context) — PR #106 (read-only `AgentRegistry` over `agent_instances ⋈ agents`; no new table) +``` + +- [ ] **Step 2: Run the full backend suite** + +Run: `cd backend && uv run pytest -v` +Expected: all tests pass (registry suite green; no regressions). + +- [ ] **Step 3: Run lint/type checks** + +Run: `cd backend && uv run ruff check . && uv run mypy src/octave/agent/registry.py` +Expected: no errors. (If the repo's ruff/mypy config differs, use whatever `pyproject.toml` defines; fix any findings.) + +- [ ] **Step 4: Commit and push** + +```bash +git add docs/TODO.md +git commit -m "docs: mark Agent Manager #2 (agent registry) done" +git push +``` + +--- + +## Self-Review Notes + +- **Spec coverage:** read model (Task 1), list + filters + ordering (Task 1), get + raise (Task 2), count zero-filled (Task 3), lenient assignments / loud status asymmetry (Task 4), lifecycle visibility / no-mutation / destroy-gone (Task 5), exports (Task 6), roadmap (Task 7). Non-goals (routes, vault resolution, pagination) have no tasks — correct. +- **Type consistency:** `RunningAgent` fields, method names (`list_instances` / `get_instance` / `count_by_status`), and `InstanceNotFoundError(instance_id)` usage match the spec's interface block verbatim across all tasks. +- **No placeholders:** every code step contains full code; every command has expected output. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 3714732..4eec204 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -156,6 +156,17 @@ fail-loud on tag misses (tag→model lookup lands with Inference #7). Library-on routes/lifespan wiring yet. Design: [`.agents/specs/2026-09-27-agent-lifecycle-model-design.md`](../.agents/specs/2026-09-27-agent-lifecycle-model-design.md). +**Implemented — agent registry (issue #26):** `AgentRegistry` (`octave.agent.registry`) +is the read-only source-of-truth view over `agent_instances ⋈ agents` — `list_instances` +(filters: `agent_id`/`session_id`/instance `status`, AND-composed; deterministic +`created_at, id` ordering), `get_instance` (raises `InstanceNotFoundError`), and +`count_by_status` (zero-filled per-enum dashboard counter). Returns frozen +`RunningAgent` read models; same `VaultStore` convention (caller-supplied +`AsyncSession`, never commits, never mutates — `AgentInstanceManager` stays the only +write path). Malformed `assignments` JSON degrades to empty with a warning (reporting +surface); bad status enums fail loud. Library-only: no routes. Design: +[`.agents/specs/2026-09-27-agent-registry-design.md`](../.agents/specs/2026-09-27-agent-registry-design.md). + --- ## Data Layer