Skip to content
Merged
3 changes: 2 additions & 1 deletion config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,8 @@ memory:
# value wins outright, so it disables both the workspace location and the
# fallback, and `nerve init` will keep regenerating a system.yaml nothing reads.
#
# Editing the cron files does not apply itself: POST /api/cron/reload does.
# Editing the cron files does not apply itself: run `nerve reload`, or let the
# next workspace sync do it.

# Workflow runs — budget-capped multi-agent jobs (Claude Workflow tool or
# Codex Ultracode) in dedicated tracked sessions. Nerve meters real dollar
Expand Down
197 changes: 182 additions & 15 deletions docs/config.md

Large diffs are not rendered by default.

4 changes: 3 additions & 1 deletion docs/cron.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,9 @@ Notes:
registered gates as they were, so a deleted plugin file only takes its gate
away once a reload succeeds.
- A job holding a [reserved id](#job-fields) is **skipped**, at reload and at
startup alike. The reload itself still succeeds; only that job is dropped.
startup alike. The reload itself still succeeds; only that job is dropped, and
its id comes back in the reload's `rejected` list, so a job that never runs is
visible from the API and not only in the log.
- An **invalid schedule** (a crontab whose fields the scheduler rejects, e.g.
`99 * * * *`) is refused the same way at reload: `400`, nothing applied.
Startup is the one case that differs — the daemon logs an error naming the job
Expand Down
10 changes: 9 additions & 1 deletion nerve/agent/backends/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,17 @@ class BackendDeps:

Callables (rather than direct references) where the underlying value
is hot-reloadable or wired up after engine construction.

``config`` is one of the hot ones. A config reload replaces the process
config object; a backend that captured the old one would keep sending the
options the operator has just changed, while the engine — which *is*
re-pointed — reported the change applied and rendered it in the UI. One key
live on one side of that seam and frozen on the other is the hardest state
to diagnose there is, so backends resolve config per read instead of holding
it.
"""

config: "NerveConfig"
config: Callable[[], "NerveConfig"]
db: "Database"
registry: Any # ToolRegistry
tool_ctx_factory: Callable[[str], Any] # session_id -> ToolContext
Expand Down
13 changes: 12 additions & 1 deletion nerve/agent/backends/claude.py
Original file line number Diff line number Diff line change
Expand Up @@ -367,7 +367,18 @@ class ClaudeBackend:

def __init__(self, deps: Any):
self._deps = deps
self.config = deps.config

@property
def config(self):
"""The live config, resolved per read rather than captured.

Everything below builds SDK options straight off this — the model, the
thinking budget, the 1M-context header, the sub-agent permission mode.
Holding a reference would freeze all of them at start-up while the
engine's own copy moved on with a reload, so the context bar could show
a 200k budget for sessions still being opened with the 1M header.
"""
return self._deps.config()

# -- policy -------------------------------------------------------- #

Expand Down
20 changes: 18 additions & 2 deletions nerve/agent/backends/codex/backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,14 +127,30 @@ class CodexBackend:

def __init__(self, deps: Any):
self._deps = deps
self.config = deps.config
self.codex = deps.config.codex
Path(self._home_dir()).mkdir(parents=True, exist_ok=True)
self._preflight_cache: tuple[float, dict[str, Any]] | None = None
self._preflight_lock = asyncio.Lock()
self._live_models: set[str] = set()
self._rate_limits: dict[str, Any] | None = None

@property
def config(self):
"""The live config, resolved per read rather than captured."""
return self._deps.config()

@property
def codex(self):
"""The live ``codex`` section.

A property and not an attribute for the same reason as :attr:`config`,
and more sharply: a sub-object caches one level deeper, so re-pointing
``config`` alone would leave the sandbox mode, approval policy, effort
map and auth choice on their start-up values while everything read off
``config`` had moved. Resolving through the deps callable means there is
no second place for the two to disagree.
"""
return self._deps.config().codex
Comment thread
alex-clickhouse marked this conversation as resolved.

# -- policy ---------------------------------------------------------- #

def default_model(self, source: str) -> str:
Expand Down
62 changes: 49 additions & 13 deletions nerve/agent/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -193,19 +193,15 @@ def __init__(self, config: NerveConfig, db: Database):
# when Nerve is invoked from within a Claude Code session (e.g. CLI).
os.environ.pop("CLAUDECODE", None)

self.config = config
self._config = config
self.db = db
self.sessions = SessionManager(
db,
sticky_period_minutes=config.sessions.sticky_period_minutes,
default_backend=config.agent.backend,
cron_backend=config.agent.resolved_cron_backend,
backend_models={
"claude": config.agent.model,
"codex": config.codex.model,
},
default_cwd=str(config.workspace),
)
# ``default_cwd`` is seeded once and never re-seeded: it comes from
# ``workspace``, which the skill manager, the tool context and the memory
# bridges also captured at construction, so following a reload here alone
# would put the session manager in a different workspace from everything
# else. Everything the setter below *does* re-seed follows a reload.
self.sessions = SessionManager(db, default_cwd=str(config.workspace))
self.config = config
self._semaphore = asyncio.Semaphore(config.agent.max_concurrent)
self._memory_bridge = None
self._xmemory_bridge = None
Expand Down Expand Up @@ -281,9 +277,12 @@ def __init__(self, config: NerveConfig, db: Database):
# session by the STICKY rule (stored sessions.backend first, then
# config) — see _backend_for. ``_session_backends`` mirrors the
# live client's backend for hot paths (finalize, idle watcher).
# Config is handed over as a callable reading this engine's attribute,
# so a reload that re-points the engine moves the backends with it and
# a single key can never be live here and frozen there.
self._backends: dict[str, AgentBackend] = build_backends(
BackendDeps(
config=self.config,
config=lambda: self.config,
db=self.db,
registry=self.registry,
tool_ctx_factory=self._build_tool_context,
Expand All @@ -295,6 +294,43 @@ def __init__(self, config: NerveConfig, db: Database):
)
self._session_backends: dict[str, str] = {}

@property
def config(self) -> NerveConfig:
return self._config

@config.setter
def config(self, config: NerveConfig) -> None:
"""Adopt a freshly loaded config, moving the seeded collaborators with it.

A setter rather than a method because assignment is how a config reload
hands the new object over, and because the values the session manager was
seeded with at construction have to move in the same step. Backend
resolution reads ``agent.backend`` live off this object but the session
manager holds its own copy for the rows it creates; leaving one behind
would route new sessions by the old default while every other read used
the new one — live on one side of the engine, frozen on the other, and
nothing to say which answer you were looking at.

The backends need nothing here: they resolve config through a callable
onto this attribute, so they follow automatically.

NOT re-seeded, and so still restart-only: ``agent.max_concurrent`` (its
semaphore cannot be resized under in-flight turns) and everything derived
from ``workspace``.
"""
self._config = config
sessions = getattr(self, "sessions", None)
if sessions is not None:
sessions.sticky_period_minutes = config.sessions.sticky_period_minutes
sessions.default_backend = config.agent.backend
sessions.cron_backend = (
config.agent.resolved_cron_backend or config.agent.backend
)
sessions.backend_models = {
"claude": config.agent.model,
"codex": config.codex.model,
}

def set_notification_service(self, service: Any) -> None:
"""Install the notification service used by per-session ``ToolContext``.

Expand Down
25 changes: 21 additions & 4 deletions nerve/channels/telegram.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
import zipfile
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, TYPE_CHECKING
from typing import Any, Callable, TYPE_CHECKING
from zoneinfo import ZoneInfo

from telegram import InlineKeyboardButton, InlineKeyboardMarkup, Update
Expand Down Expand Up @@ -439,11 +439,13 @@ class TelegramChannel(BaseChannel):
Delegates session management and agent execution to the ChannelRouter.
"""

def __init__(self, config: NerveConfig, router: ChannelRouter):
self.config = config
def __init__(
self, config: Callable[[], NerveConfig], router: ChannelRouter,
):
self._config = config
self.router = router
self._app: Application | None = None
self._allowed_users: set[int] = set(config.telegram.allowed_users)
self._allowed_users: set[int] = set(self.config.telegram.allowed_users)
self._notification_service = None # Set after service is created
self._watchdog_task: asyncio.Task | None = None
self._stopping = False
Expand All @@ -463,6 +465,21 @@ def set_notification_service(self, service) -> None:
"""Wire the notification service for callback query handling."""
self._notification_service = service

@property
def config(self) -> NerveConfig:
"""The live config, resolved per read rather than captured.

The bot outlives every reload, and ``dm_policy`` decides on each update
whether a stranger may talk to the agent. Holding the start-up object
meant a reload that tightened ``open`` to ``pairing`` reported success
and authorized everyone until the daemon was restarted.

Only the reads that happen per use follow from this. The bot token was
handed to the Application at build time and ``allowed_users`` was copied
into a set, so both still need a restart.
"""
return self._config()

@property
def name(self) -> str:
return "telegram"
Expand Down
Loading
Loading