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
5 changes: 5 additions & 0 deletions docs/memory/feature-flows/first-time-setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -358,6 +358,11 @@ async def set_admin_password(data, request, background_tasks):
# 6. Mark setup completed
db.set_setting('setup_completed', 'true')

# 6b. Deploy the system agent (#3237) — its boot-time attempt ran before this
# admin existed and failed. Background task, never raises, scheduled
# before the first-run seed (ensure_first_run_seeded).
background_tasks.add_task(_deploy_system_agent_after_setup)

# 7. Operator intake (trinity-enterprise#38): only on affirmative consent,
# scheduled as a BackgroundTask so it runs after the response
if data.consent_updates:
Expand Down
3 changes: 3 additions & 0 deletions docs/memory/feature-flows/internal-system-agent.md
Original file line number Diff line number Diff line change
Expand Up @@ -453,6 +453,8 @@ Backend Startup
└──────────────────────────────────┘
```

**Second trigger — first-run `/setup` (#3237).** On a browser-claimed install (every one-click / marketplace path) no admin exists at boot, so the create branch above fails with `Admin user 'admin' not found`. `POST /api/setup/admin-password` therefore schedules `routers/setup.py::_deploy_system_agent_after_setup` as a background task once the admin row is written — before `ensure_first_run_seeded`, matching the boot order (the seeder hosts its failure alerts on `trinity-system`). It never raises (Starlette runs background tasks in sequence) and skips without a Docker client; `ensure_deployed` is idempotent, so the next boot is a no-op.

### 2. Fleet Health Check Flow

```
Expand Down Expand Up @@ -1050,6 +1052,7 @@ ls -1 ~/reports/fleet/ | tail -1

| Date | Changes |
|------|---------|
| 2026-10-06 | **#3237**: `/setup` deploys the system agent after creating the admin — the boot-time attempt runs before the admin exists on browser-claimed installs and was never retried. |
| 2026-07-28 | **#1816 base-image adoption**: `ensure_deployed` is read-only when the container is running (3-state `check_base_image_state` → `base_image_state`, WARNING + an edge-triggered `base-image-stale-` operator alarm on `stale` only) and delegates to `start_agent_internal` when stopped, so the cold boundary adopts a rebuilt base image through the shared lifecycle. Creation converges on the recreate path's contract (`TRINITY_AGENT_AUTH_TOKEN`, `trinity.full-capabilities`) — the convergence invariant. AC2 is a **structural** gate in `start_agent_internal` (`is_system AND was_already_running` ⇒ `recreate_deferred="system_agent_running"`), independent of predicate count. `/restart` delegates; `/status` gains `base_image_state`. |
| 2026-02-11 | Fixed reinitialize flow diagram - cleanup command now shows actual paths (`/home/developer/.claude`, `.trinity`, `content`, `plans`) instead of obsolete workspace reference |
| 2026-01-27 | **Emergency Stop Prefix Filter**: Added `system_prefix` query parameter to `POST /api/ops/emergency-stop` (`routers/ops.py:607-696`). Allows targeting specific agents/schedules by name prefix. Schedule pausing respects prefix (line 638-639), agent stopping respects prefix (line 658-659). Enables safe testing with nonexistent prefix. |
Expand Down
31 changes: 31 additions & 0 deletions src/backend/routers/setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,31 @@ def _take_claim() -> Path:
return taken


async def _deploy_system_agent_after_setup() -> None:
"""Deploy `trinity-system` now that its owner exists (#3237).

The boot-time attempt (`main.py::_init_docker_and_system_agent`) runs before
/setup has created the admin on every browser-claimed install, fails with
"Admin user 'admin' not found", and is never retried. This is that retry.

Never raises: Starlette runs background tasks in sequence, so a raise here
would also skip the first-run seed and the operator intake. Imports are
lazy to keep this router's import isolation (see the setup tests).
"""
try:
from services.docker_service import docker_client
from services.system_agent_service import system_agent_service

if not docker_client:
return
result = await system_agent_service.ensure_deployed()
logger.info(f"System agent after setup: {result['action']} - {result['message']}")
if result.get('status') == 'error':
logger.warning(f" Warning: System agent deployment issue - {result.get('message')}")
except Exception as e:
logger.error(f"Error deploying system agent after setup: {e}")


@router.get("/status")
async def get_setup_status():
"""
Expand Down Expand Up @@ -317,6 +342,12 @@ async def set_admin_password(
except OSError as e:
logger.warning("Could not delete the setup claim file: %s", type(e).__name__)

# The system agent (#3237): its boot-time deploy ran before this admin
# existed and failed. Scheduled BEFORE the seed — the boot order, and the
# seeder hosts its failure alerts on `trinity-system`. Idempotent, so a
# later restart is a no-op.
background_tasks.add_task(_deploy_system_agent_after_setup)

# First-run seeding: the default Cornelius agent (ent#107) plus the default
# system manifest (trinity-enterprise#124), sequenced under ONE persisted
# freshness verdict. Now that the admin account exists, provision the bundled
Expand Down
11 changes: 11 additions & 0 deletions tests/registry.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,16 @@
{
"test_files": [
{
"file": "unit/test_3237_system_agent_after_setup.py",
"feature": "#3237",
"added": "2026-10-06",
"categories": [
"backend",
"setup",
"system-agent"
],
"description": "First-run /setup deploys the system agent: a successful setup schedules _deploy_system_agent_after_setup before ensure_first_run_seeded, the deploy runs with the admin row already present, never raises, is skipped without a Docker client, and a refused setup schedules nothing."
},
{
"file": "unit/test_ent761_outbound_control_oss.py",
"feature": "trinity-enterprise#761",
Expand Down
157 changes: 157 additions & 0 deletions tests/unit/test_3237_system_agent_after_setup.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
"""#3237 — a fresh install that creates its admin through /setup gets its system agent.

The only deploy attempt used to run at backend boot, BEFORE /setup had created
the admin, and failed with "Admin user 'admin' not found". Nothing retried it,
so `trinity-system` stayed absent until something restarted the backend — on
every one-click / marketplace path (DigitalOcean, AWS, the hosted script).

Under test:

* a successful setup schedules `system_agent_service.ensure_deployed()` as a
background task, and it runs AFTER the admin row exists;
* it is scheduled BEFORE first-run seeding — the boot order, and the seeder
hosts its failure alerts on `trinity-system`;
* the deploy task never raises (Starlette runs background tasks in sequence, so
a raise would also skip the seed and the intake) and is skipped without Docker;
* a refused setup schedules nothing.

Import isolation: `routers.setup` is imported lazily (see
test_setup_operator_profile.py for why).
"""
import asyncio
import sys
import types

import pytest
from fastapi import BackgroundTasks, HTTPException

pytestmark = pytest.mark.unit

PW = "Sup3rSecret!!"
HASH = "$2b$12$abcdefghijklmnopqrstuv"


def _setup():
import routers.setup as m
return m


class FakeDB:
def __init__(self, users=None):
self.settings = {"setup_completed": "false"}
self.users = users or {}

def get_user_by_username(self, username):
return self.users.get(username)

def get_setting_value(self, key, default=None):
return self.settings.get(key, default)

def set_setting(self, key, value):
self.settings[key] = value

def update_user_password(self, username, hashed):
self.users.setdefault(username, {"username": username})["password"] = hashed
return True

def update_user(self, username, updates):
self.users.setdefault(username, {"username": username}).update(updates)
return self.users[username]


class FakeSystemAgentService:
"""Mirrors `_create_system_agent`'s precondition: the admin row must exist."""

def __init__(self, db, raises=None):
self.db, self.raises, self.calls = db, raises, []

async def ensure_deployed(self):
self.calls.append(self.db.get_user_by_username("admin") is not None)
if self.raises:
raise self.raises
return {"action": "created", "status": "running", "message": "System agent created and started"}


@pytest.fixture
def env(monkeypatch):
def _apply(users=None, docker=True, raises=None):
setup = _setup()
db = FakeDB(users)
svc = FakeSystemAgentService(db, raises)
monkeypatch.delenv("ADMIN_PASSWORD_SOURCE", raising=False)
monkeypatch.delenv("ADMIN_PASSWORD", raising=False)
monkeypatch.delenv("ADMIN_USERNAME", raising=False)
monkeypatch.setattr(setup, "db", db)
monkeypatch.setattr(setup, "validate_password_strength", lambda p: [])
monkeypatch.setattr(setup, "hash_password", lambda p: "hashed:" + p)
# The helper imports both lazily; stub the modules it reaches for.
monkeypatch.setitem(
sys.modules, "services.system_agent_service",
types.SimpleNamespace(system_agent_service=svc),
)
monkeypatch.setitem(
sys.modules, "services.docker_service",
types.SimpleNamespace(docker_client=object() if docker else None),
)
return db, svc
return _apply


def _post(bg):
setup = _setup()
data = setup.SetAdminPasswordRequest(
password=PW, confirm_password=PW, email="me@example.com",
)
return asyncio.run(setup.set_admin_password(data, object(), bg))


def _run(task):
result = task.func(*task.args, **task.kwargs)
if asyncio.iscoroutine(result):
asyncio.run(result)


def _names(bg):
return [t.func.__name__ for t in bg.tasks]


def test_setup_schedules_the_system_agent_deploy_before_seeding(env):
env()
bg = BackgroundTasks()
assert _post(bg)["success"] is True
names = _names(bg)
assert "_deploy_system_agent_after_setup" in names, (
"/setup must deploy the system agent — the boot attempt ran before the admin existed"
)
assert names.index("_deploy_system_agent_after_setup") < names.index("ensure_first_run_seeded"), (
"boot order: the system agent before the seed, which hosts its alerts on trinity-system"
)


def test_the_deploy_runs_after_the_admin_exists(env):
_, svc = env()
bg = BackgroundTasks()
_post(bg)
_run(next(t for t in bg.tasks if t.func.__name__ == "_deploy_system_agent_after_setup"))
assert svc.calls == [True], "ensure_deployed ran once, with the admin row already present"


def test_a_failed_deploy_never_raises(env):
_, svc = env(raises=RuntimeError("docker unavailable"))
asyncio.run(_setup()._deploy_system_agent_after_setup())
assert svc.calls == [False]


def test_the_deploy_is_skipped_without_docker(env):
_, svc = env(docker=False)
asyncio.run(_setup()._deploy_system_agent_after_setup())
assert svc.calls == [], "demo mode (no Docker client): nothing to deploy, as at boot"


def test_a_refused_setup_schedules_nothing(env):
env(users={"admin": {"username": "admin", "password": HASH}})
bg = BackgroundTasks()
with pytest.raises(HTTPException) as ei:
_post(bg)
assert ei.value.status_code == 403
assert bg.tasks == []
Loading