Add the composition root and a runnable entry point
CI / checks (push) Skipped
CI / checks (pull_request) Successful in 1m14s

Nothing had ever assembled the agent. main.py was still the hello-world
placeholder from the scaffold, and no code outside the test suite
constructed an Orchestrator — which is how two fully built, fully
tested components came to be reachable from nothing: the delegation
harness, and the Gitea comment poller. Unit tests that construct a
component directly cannot catch that; only assembly can.

- config.py validates every variable before anything is built and
  names all of them at once, rather than failing at first use twenty
  minutes into a milestone on the call that mattered. Whitespace-only
  counts as missing, since a token of spaces passes a truthiness check
  and fails at the first request instead
- agent.py builds the whole graph and owns startup and shutdown order:
  reconcile before accepting commands, and on stop, stop watching,
  then stop talking, then close the store last
- The Gitea account is read via whoami rather than configured: it names
  the identity every permission is evaluated against, and proves the
  token works before anything depends on it
- Poller lifecycle is now a state-machine concern: watch the plan issue
  while a gate stands, the PR while it is open, and stop on leaving.
  The orchestrator owns when to watch; an injected factory owns how to
  build one
- The executor reports each turn, because it runs synchronously inside
  the transport callback: without it the agent is silent for a whole
  milestone and a working run looks exactly like a hung one
- main.py loads .env, builds, and shuts down cleanly on SIGINT/SIGTERM

The entry-point tests changed shape: nisse-dev no longer greets, it
refuses to start unconfigured and says everything that is missing. Both
tests now run with a deliberately empty environment and a .env path
that does not exist, because the working directory's .env holds live
credentials and a test must never be one cwd away from starting a real
agent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
dongarandClaude Opus 5 committed 2026-09-08 22:26:26 +00:00
1 parent 6331fcafd9
commit 2fb25b8195
11 files changed
+694 -15

No files matched your search

+99
View File
@@ -0,0 +1,99 @@
"""Agent configuration (spec A5: from env/.env, nothing hardcoded).
Every value is collected and validated **before** anything is built, and a
single error names every missing variable at once. The alternative — failing
at first use — surfaces a missing token twenty minutes into a milestone, on
the call that mattered.
"""
import os
from collections.abc import Mapping
from dataclasses import dataclass
from pathlib import Path
from nisse_dev.llm import RoleMap
from nisse_dev.matrix import MatrixConfig
REQUIRED = (
"NISSE_STATE_DB",
"NISSE_WORKSPACE",
"NISSE_PROJECT",
"NISSE_LLM_BASE_URL",
"NISSE_LLM_TOKEN",
"NISSE_ROLE_EXECUTOR",
"NISSE_ROLE_PLANNER",
"NISSE_ROLE_DELEGATE",
"NISSE_ROLE_CONSULTANT",
"NISSE_MATRIX_HOMESERVER",
"NISSE_MATRIX_USER_ID",
"NISSE_MATRIX_TOKEN",
"NISSE_MATRIX_ROOM_ID",
"NISSE_MATRIX_OPERATORS",
"NISSE_GITEA_URL",
"NISSE_GITEA_TOKEN",
"NISSE_GITEA_REPO",
"NISSE_GITEA_OPERATORS",
)
class ConfigError(RuntimeError):
pass
def _people(raw: str) -> frozenset[str]:
"""Operator allowlists are comma-separated; blanks are dropped so a
trailing comma cannot admit an empty identity."""
return frozenset(part.strip() for part in raw.split(",") if part.strip())
@dataclass(frozen=True)
class AgentConfig:
state_db: Path
workspace: Path
project: str
llm_base_url: str
llm_token: str
roles: RoleMap
matrix: MatrixConfig
operators: frozenset[str]
gitea_url: str
gitea_token: str
gitea_repo: str
gitea_operators: frozenset[str]
base_branch: str = "main"
@classmethod
def from_env(cls, env: Mapping[str, str] = os.environ) -> "AgentConfig":
# Whitespace-only counts as missing: a token of spaces would pass a
# truthiness check and fail at the first request instead.
missing = [name for name in REQUIRED if not (env.get(name) or "").strip()]
if missing:
raise ConfigError(
"cannot start: these are unset or empty — " + ", ".join(missing)
)
return cls(
state_db=Path(env["NISSE_STATE_DB"]).expanduser(),
workspace=Path(env["NISSE_WORKSPACE"]).expanduser(),
project=env["NISSE_PROJECT"],
llm_base_url=env["NISSE_LLM_BASE_URL"],
llm_token=env["NISSE_LLM_TOKEN"],
roles=RoleMap.from_env(env),
matrix=MatrixConfig.from_env(env),
operators=_people(env["NISSE_MATRIX_OPERATORS"]),
gitea_url=env["NISSE_GITEA_URL"],
gitea_token=env["NISSE_GITEA_TOKEN"],
gitea_repo=env["NISSE_GITEA_REPO"],
gitea_operators=_people(env["NISSE_GITEA_OPERATORS"]),
base_branch=env.get("NISSE_BASE_BRANCH", "main"),
)
def redacted(self) -> dict[str, str]:
"""What the agent may safely announce about how it is configured."""
return {
"project": self.project,
"workspace": str(self.workspace),
"repo": self.gitea_repo,
"planner": self.roles.planner,
"executor": self.roles.executor,
"delegate": self.roles.delegate,
}