fix(settings): ceo_name write-through renames the CEO agent row

The ceo_name setting only reached the panel header — the CEO agent row
kept its seeded name, so agent selectors (create-flag, assignee pickers,
kanban) still showed the old name after a rename. Setting ceo_name now
updates the CEO row's name in the same transaction, resolved via the
shared duplicate-tolerant get_agent_by_role lookup. The seed path never
overwrites existing rows, so a custom name survives restarts. The
agent-facing docs/map entries drop the personal name. Write-through
tests pin ancient created_at on fixture rows so they stay deterministic
against CEO rows other suite tests may have committed.
This commit is contained in:
Renn F
2026-07-22 03:31:01 +02:00
parent 5143aab698
commit 0296ec6fde
4 changed files with 102 additions and 6 deletions
+1 -1
View File
@@ -59,7 +59,7 @@
```mermaid ```mermaid
graph TB graph TB
CEO["CEO (Human — Renzo)"] CEO["CEO (Human)"]
Intake["Intake / Prompter<br/>(on-demand, human-only)"] Intake["Intake / Prompter<br/>(on-demand, human-only)"]
Sec["Secretary<br/>(on-demand, human-only)"] Sec["Secretary<br/>(on-demand, human-only)"]
PRRev["PR Reviewer<br/>(read-only reviewer)"] PRRev["PR Reviewer<br/>(read-only reviewer)"]
+1 -1
View File
@@ -59,7 +59,7 @@
```mermaid ```mermaid
graph TB graph TB
CEO["CEO (Human — Renzo)"] CEO["CEO (Human)"]
Intake["Intake / Prompter<br/>(on-demand, human-only)"] Intake["Intake / Prompter<br/>(on-demand, human-only)"]
Sec["Secretary<br/>(on-demand, human-only)"] Sec["Secretary<br/>(on-demand, human-only)"]
PRRev["PR Reviewer<br/>(read-only reviewer)"] PRRev["PR Reviewer<br/>(read-only reviewer)"]
+20 -4
View File
@@ -12,8 +12,9 @@ from typing import TYPE_CHECKING
from sqlalchemy import select from sqlalchemy import select
from roboco.db.tables import SystemSettingTable from roboco.db.tables import AgentRole, SystemSettingTable
from roboco.services.base import BaseService from roboco.services.base import BaseService
from roboco.services.repositories.query_helpers import get_agent_by_role
if TYPE_CHECKING: if TYPE_CHECKING:
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -114,9 +115,11 @@ def _validate_update_id(value: str) -> None:
_VALIDATORS = { _VALIDATORS = {
"transcript_retention_days": _validate_retention_days, "transcript_retention_days": _validate_retention_days,
# The CEO's panel display name (header chip + Settings User Info card). # The CEO's panel display name (header chip + Settings User Info card).
# No config/migration involved — an unset key just means the panel's # An unset key just means the panel's own hardcoded "Renzo" default
# own hardcoded "Renzo" default renders, same as transcript retention's # renders, same as transcript retention's client-side DEFAULT_RETENTION
# client-side DEFAULT_RETENTION fallback. # fallback. When set, `SettingsService.set` also writes through to the
# CEO agent row's `name` so GET /api/agents (kanban/assignee pickers)
# reflects the same name instead of the seeded literal.
"ceo_name": _validate_ceo_name, "ceo_name": _validate_ceo_name,
# Telegram inbound's getUpdates offset cursor. Not a feature flag (absent # Telegram inbound's getUpdates offset cursor. Not a feature flag (absent
# from FEATURE_FLAGS/the panel card) but reuses this same validated KV # from FEATURE_FLAGS/the panel card) but reuses this same validated KV
@@ -173,8 +176,21 @@ class SettingsService(BaseService):
self.session.add(SystemSettingTable(key=key, value=value)) self.session.add(SystemSettingTable(key=key, value=value))
else: else:
existing.value = value existing.value = value
if key == "ceo_name":
await self._sync_ceo_agent_name(value.strip())
await self.session.flush() await self.session.flush()
async def _sync_ceo_agent_name(self, name: str) -> None:
"""Write-through: keep the CEO agent row's `name` in sync with the setting.
Same-transaction as the setting write so the two never disagree.
Resolution rides `get_agent_by_role` (earliest-created row wins), the
shared duplicate-tolerant lookup every singleton-role consumer uses.
"""
agent = await get_agent_by_role(self.session, AgentRole.CEO)
if agent is not None:
agent.name = name
async def all(self) -> dict[str, str]: async def all(self) -> dict[str, str]:
"""Return every stored setting as a ``{key: value}`` map.""" """Return every stored setting as a ``{key: value}`` map."""
result = await self.session.execute(select(SystemSettingTable)) result = await self.session.execute(select(SystemSettingTable))
@@ -2,9 +2,12 @@
from __future__ import annotations from __future__ import annotations
from datetime import UTC, datetime
from typing import Any from typing import Any
from uuid import uuid4
import pytest import pytest
from roboco.db.tables import AgentRole, AgentStatus, AgentTable
from roboco.services.settings import ( from roboco.services.settings import (
SettingValidationError, SettingValidationError,
get_settings_service, get_settings_service,
@@ -12,6 +15,29 @@ from roboco.services.settings import (
) )
def _ceo_agent(name: str = "Renzo", created_at: datetime | None = None) -> AgentTable:
"""A minimal CEO-role agent row for write-through tests.
`created_at` defaults to an ancient timestamp so this row is the
earliest-created CEO regardless of rows other suite tests may have
committed into the shared DB the write-through targets the oldest.
"""
return AgentTable(
id=uuid4(),
name=name,
slug=f"ceo-{uuid4().hex[:6]}",
role=AgentRole.CEO,
team=None,
status=AgentStatus.ACTIVE,
model_config={},
system_prompt="x",
capabilities=[],
permissions={},
metrics={},
created_at=created_at or datetime(2000, 1, 1, tzinfo=UTC),
)
def test_validate_setting_rejects_unknown_key() -> None: def test_validate_setting_rejects_unknown_key() -> None:
with pytest.raises(SettingValidationError): with pytest.raises(SettingValidationError):
validate_setting("not_a_real_setting", "x") validate_setting("not_a_real_setting", "x")
@@ -77,3 +103,57 @@ async def test_ceo_name_set_rejects_blank_value(db_session: Any) -> None:
svc = get_settings_service(db_session) svc = get_settings_service(db_session)
with pytest.raises(SettingValidationError): with pytest.raises(SettingValidationError):
await svc.set("ceo_name", " ") await svc.set("ceo_name", " ")
@pytest.mark.asyncio
async def test_ceo_name_set_writes_through_to_ceo_agent_row(db_session: Any) -> None:
agent = _ceo_agent()
db_session.add(agent)
await db_session.flush()
svc = get_settings_service(db_session)
await svc.set("ceo_name", "Alice")
await db_session.refresh(agent)
assert agent.name == "Alice"
@pytest.mark.asyncio
async def test_ceo_name_set_strips_whitespace_on_agent_row(db_session: Any) -> None:
agent = _ceo_agent()
db_session.add(agent)
await db_session.flush()
svc = get_settings_service(db_session)
await svc.set("ceo_name", " Bob ")
await db_session.refresh(agent)
assert agent.name == "Bob"
@pytest.mark.asyncio
async def test_ceo_name_set_with_no_ceo_agent_does_not_raise(
db_session: Any,
) -> None:
svc = get_settings_service(db_session)
await svc.set("ceo_name", "Alice") # no CEO row seeded — no-op, no crash
assert await svc.get("ceo_name") == "Alice"
@pytest.mark.asyncio
async def test_ceo_name_set_tolerates_duplicate_ceo_rows(db_session: Any) -> None:
"""A second role=CEO row must not crash the write-through (no bare one_or_none)."""
older = _ceo_agent("Renzo", created_at=datetime(2000, 1, 1, tzinfo=UTC))
db_session.add(older)
await db_session.flush()
newer = _ceo_agent("Renzo", created_at=datetime(2000, 1, 2, tzinfo=UTC))
db_session.add(newer)
await db_session.flush()
svc = get_settings_service(db_session)
await svc.set("ceo_name", "Alice") # must not raise MultipleResultsFound
await db_session.refresh(older)
await db_session.refresh(newer)
assert older.name == "Alice" # earliest-created wins
assert newer.name == "Renzo" # the duplicate is left untouched