Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -380,6 +380,14 @@ async def client(app, tmp_data_dir):
if device_store._db is not None:
await device_store.close()
await device_store.init()
council_roles = app.state.council_roles
if council_roles._db is not None:
await council_roles.close()
await council_roles.init()
council_members = app.state.council_members
if council_members._db is not None:
await council_members.close()
await council_members.init()
# BrowserApp v2 stores
from tinyagentos.routes.desktop_browser.store import BrowserStore, BrowserCookieStore
_browser_store = BrowserStore(tmp_data_dir / "browser.sqlite3")
Expand Down Expand Up @@ -445,6 +453,8 @@ async def client(app, tmp_data_dir):
await app.state.http_client.aclose()
await _browser_store.close()
await _browser_cookie_store.close()
await app.state.council_roles.close()
await app.state.council_members.close()


def create_test_qmd_db(db_path):
Expand Down
52 changes: 52 additions & 0 deletions tests/test_council_routes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import pytest


@pytest.mark.asyncio
class TestCouncilRoutes:
async def test_list_roles_returns_seeded_roles(self, client, app):
roles_resp = await client.get("/api/council/roles")
assert roles_resp.status_code == 200
roles = roles_resp.json()
assert len(roles) == 10
slugs = {r["slug"] for r in roles}
expected = {
"coder", "reviewer", "writer", "editor", "summarizer",
"translator", "researcher", "planner", "critic", "data_analyst",
}
assert slugs == expected

async def test_list_roles_has_expected_fields(self, client, app):
roles_resp = await client.get("/api/council/roles")
assert roles_resp.status_code == 200
roles = roles_resp.json()
for role in roles:
assert "slug" in role
assert "display_name" in role
assert "description" in role
assert "gauge_status" in role
assert "builtin" in role

async def test_list_members_returns_empty_when_none(self, client, app):
members_resp = await client.get("/api/council/members")
assert members_resp.status_code == 200
assert members_resp.json() == []

async def test_list_members_returns_added_members(self, client, app):
store = app.state.council_members
if store._db is not None:
await store.close()
await store.init()
await store.add_member(
canonical_id="agent-route-1",
model_id="kilo-auto/free",
provider="kilocode",
roles=[{"role": "coder", "score": 90, "source": "local"}],
autonomy={"coder": "propose"},
)

members_resp = await client.get("/api/council/members")
assert members_resp.status_code == 200
members = members_resp.json()
assert len(members) == 1
assert members[0]["canonical_id"] == "agent-route-1"
assert members[0]["roles"] == [{"role": "coder", "score": 90, "source": "local"}]
130 changes: 130 additions & 0 deletions tests/test_council_stores.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import pytest
import pytest_asyncio
from pathlib import Path

from tinyagentos.council.member_store import MemberStore
from tinyagentos.council.role_registry import RoleRegistry


@pytest_asyncio.fixture
async def role_registry(tmp_path):
store = RoleRegistry(tmp_path / "council_roles.db")
await store.init()
yield store
await store.close()


@pytest_asyncio.fixture
async def member_store(tmp_path):
store = MemberStore(tmp_path / "council_members.db")
await store.init()
yield store
await store.close()


@pytest.mark.asyncio
class TestRoleRegistry:
async def test_seeds_ten_roles(self, role_registry):
roles = await role_registry.list_roles()
assert len(roles) == 10
slugs = {r["slug"] for r in roles}
expected = {
"coder", "reviewer", "writer", "editor", "summarizer",
"translator", "researcher", "planner", "critic", "data_analyst",
}
assert slugs == expected

async def test_get_existing_role(self, role_registry):
role = await role_registry.get_role("coder")
assert role is not None
assert role["display_name"] == "Coder"
assert role["gauge_status"] == "proven"
assert role["builtin"] is True

async def test_get_missing_role_returns_none(self, role_registry):
role = await role_registry.get_role("nonexistent")
assert role is None

async def test_roles_sorted_by_slug(self, role_registry):
roles = await role_registry.list_roles()
slugs = [r["slug"] for r in roles]
assert slugs == sorted(slugs)

async def test_proven_roles_have_suite_version(self, role_registry):
roles = await role_registry.list_roles()
proven = [r for r in roles if r["gauge_status"] == "proven"]
for r in proven:
assert r["suite_version"] is not None

async def test_provisional_roles_have_null_suite_version(self, role_registry):
roles = await role_registry.list_roles()
provisional = [r for r in roles if r["gauge_status"] == "provisional"]
for r in provisional:
assert r["suite_version"] is None


@pytest.mark.asyncio
class TestMemberStore:
async def test_add_and_list_member(self, member_store):
member = await member_store.add_member(
canonical_id="agent-001",
model_id="kilo-auto/free",
provider="kilocode",
roles=[{"role": "coder", "score": 85, "source": "local"}],
autonomy={"coder": "propose"},
)
assert member["status"] == "active"
assert member["id"] is not None

members = await member_store.list_members()
assert len(members) == 1
assert members[0]["canonical_id"] == "agent-001"
assert members[0]["model_id"] == "kilo-auto/free"
assert members[0]["roles"] == [{"role": "coder", "score": 85, "source": "local"}]
assert members[0]["autonomy"] == {"coder": "propose"}

async def test_get_member(self, member_store):
created = await member_store.add_member(
canonical_id="agent-002",
model_id="stepflash:free",
provider="kilocode",
roles=[],
autonomy={},
)
fetched = await member_store.get_member(created["id"])
assert fetched is not None
assert fetched["canonical_id"] == "agent-002"
assert fetched["provider"] == "kilocode"

async def test_get_missing_returns_none(self, member_store):
assert await member_store.get_member("does-not-exist") is None

async def test_list_empty(self, member_store):
assert await member_store.list_members() == []

async def test_duplicate_canonical_id_rejected(self, member_store):
await member_store.add_member(
canonical_id="agent-dup",
model_id="m1",
provider="p",
roles=[],
autonomy={},
)
with pytest.raises(Exception):
await member_store.add_member(
canonical_id="agent-dup",
model_id="m2",
provider="p",
roles=[],
autonomy={},
)

async def test_default_status_is_active(self, member_store):
member = await member_store.add_member(
canonical_id="agent-003",
model_id="hy3:free",
provider="kilocode",
roles=[],
autonomy={},
)
assert member["status"] == "active"
11 changes: 11 additions & 0 deletions tinyagentos/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,11 @@ def create_app(data_dir: Path | None = None, catalog_dir: Path | None = None) ->
score_cache = ScoreCache(benchmark_store, poll_interval_seconds=15.0)
scheduler_history_store = HistoryStore(data_dir / "scheduler_history.db")

from tinyagentos.council.role_registry import RoleRegistry
from tinyagentos.council.member_store import MemberStore
council_role_registry = RoleRegistry(data_dir / "council.db")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: Two stores open the same council.db file with separate connections

Both council_role_registry and council_member_store point at data_dir / "council.db" as independent BaseStore instances, so two separate aiosqlite connections are opened against one file. This is inconsistent with the rest of the codebase (every other store gets its own .db), and under any concurrent read/write from the two connections SQLite can raise "database is locked". Consider giving each store its own file (e.g. council_roles.db / council_members.db) or merging them into a single store.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

council_member_store = MemberStore(data_dir / "council.db")

async def _probe_backend(backend: dict) -> dict:
return await check_backend_health(http_client, backend)

Expand Down Expand Up @@ -631,6 +636,8 @@ async def lifespan(app: FastAPI):

await benchmark_store.init()
await scheduler_history_store.init()
await council_role_registry.init()
await council_member_store.init()
app.state.config = config
app.state.config_path = config_path
app.state.data_dir = data_dir
Expand Down Expand Up @@ -867,6 +874,8 @@ async def _browser_reap_loop(app: FastAPI) -> None:
app.state.benchmark_store = benchmark_store
app.state.score_cache = score_cache
app.state.scheduler_history_store = scheduler_history_store
app.state.council_roles = council_role_registry
app.state.council_members = council_member_store
orchestrator = RestartOrchestrator(app.state)
app.state.orchestrator = orchestrator
# Boot-time: check if a pending restart was applied successfully
Expand Down Expand Up @@ -1637,6 +1646,8 @@ async def dispatch(self, request, call_next):
app.state.license_acceptances = license_acceptances_store
app.state.cluster_pairing = cluster_pairing_store
app.state.capability_map = capability_map_store
app.state.council_roles = council_role_registry
app.state.council_members = council_member_store

# Detect and set container runtime (eager, so tests work without lifespan)
try:
Expand Down
1 change: 1 addition & 0 deletions tinyagentos/council/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""taOS Council slice S1: role registry + member store."""
105 changes: 105 additions & 0 deletions tinyagentos/council/member_store.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
from __future__ import annotations

import json
import logging
import time
import uuid
from pathlib import Path

from tinyagentos.base_store import BaseStore

logger = logging.getLogger(__name__)

MEMBER_STORE_SCHEMA = """
CREATE TABLE IF NOT EXISTS council_members (
id TEXT PRIMARY KEY,
canonical_id TEXT NOT NULL UNIQUE,
model_id TEXT NOT NULL,
provider TEXT NOT NULL,
roles TEXT NOT NULL,
autonomy TEXT NOT NULL,
status TEXT NOT NULL,
added_at TEXT NOT NULL
);
"""


class MemberStore(BaseStore):
SCHEMA = MEMBER_STORE_SCHEMA

async def add_member(
self,
canonical_id: str,
model_id: str,
provider: str,
roles: list[dict],
autonomy: dict,
status: str = "active",
) -> dict:
member_id = uuid.uuid4().hex
now = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
await self._db.execute(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SUGGESTION: Duplicate canonical_id surfaces a raw sqlite3.IntegrityError

add_member performs the INSERT without handling the UNIQUE violation on canonical_id, so callers receive a low-level sqlite3.IntegrityError (the test only asserts Exception). Consider catching it and raising a domain-specific error, and explicitly rollback() so a failed transaction doesn't leak into subsequent statements on the shared connection.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

"""
INSERT INTO council_members (id, canonical_id, model_id, provider, roles, autonomy, status, added_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""",
(
member_id,
canonical_id,
model_id,
provider,
json.dumps(roles),
json.dumps(autonomy),
status,
now,
),
)
await self._db.commit()
return {
"id": member_id,
"canonical_id": canonical_id,
"model_id": model_id,
"provider": provider,
"roles": roles,
"autonomy": autonomy,
"status": status,
"added_at": now,
}

async def list_members(self) -> list[dict]:
async with self._db.execute(
"SELECT id, canonical_id, model_id, provider, roles, autonomy, status, added_at FROM council_members ORDER BY added_at"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SUGGESTION: list_members orders only by added_at, which uses second-granularity timestamps

added_at is formatted as %Y-%m-%dT%H:%M:%SZ (no sub-second precision) and it is the sole ORDER BY key. Members inserted within the same second have undefined order, which will make any future count/order assertion on list_members flaky. Add a deterministic secondary sort key, e.g. ORDER BY added_at, id (or canonical_id).


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

) as cursor:
rows = await cursor.fetchall()
return [
{
"id": r[0],
"canonical_id": r[1],
"model_id": r[2],
"provider": r[3],
"roles": json.loads(r[4]),
"autonomy": json.loads(r[5]),
"status": r[6],
"added_at": r[7],
}
for r in rows
]

async def get_member(self, member_id: str) -> dict | None:
async with self._db.execute(
"SELECT id, canonical_id, model_id, provider, roles, autonomy, status, added_at FROM council_members WHERE id = ?",
(member_id,),
) as cursor:
row = await cursor.fetchone()
if row is None:
return None
return {
"id": row[0],
"canonical_id": row[1],
"model_id": row[2],
"provider": row[3],
"roles": json.loads(row[4]),
"autonomy": json.loads(row[5]),
"status": row[6],
"added_at": row[7],
}
Loading
Loading