Projects as configuration entries (M1.4, D8)
CI / checks (push) Skipped
CI / checks (pull_request) Failing after 5m7s

- Config: `projects: {name: entry}` with repo, base_branch, workspace,
  build_host, push credential, labels (defaults added to every issue),
  secrets by ${VAR} reference with visibility (commands | harness), and
  egress (recorded, not enforced). Every problem in every entry is
  reported at once; an inline secret, a bad visibility, a repeated repo or
  a non-slug name is an error.
- The old loop runs exactly one project (only_project) and refuses more.
- The new loop is per project: each its own clone, workspace (with its
  commands-visible secrets, redacted on return), push credential and
  milestones; intake polls every project's repository; items are scoped
  to their project for every run, publish and PR watch. Issues are named
  project#14 when there is more than one project, and a bare #14 that
  matches two is asked about. Switching projects parks the one worked on
  last (all others after a boot).
- A restatement that ends in a question is sent back to the driver, not
  posted: the e2e driver kept asking "Is this the right interpretation?",
  which a plain yes can't answer.

Tests: config entries and errors, two-project assembly, two-project loop
tests (intake, own workspaces and repos, #14 in both, do-next, label
defaults, parking across projects), the property walk over two projects
with a new own_repository_only property (an item using the first
project's repo fails 15 of 16 seeds), and the restatement check.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPQ14L3LikyefCMFBqEbKE
This commit is contained in:
dongarandClaude Opus 5.5 committed 2026-10-06 01:12:57 +00:00
1 parent 4ecff19933
commit 064d9bf554
17 files changed
+1139 -315

No files matched your search

+253 -66
View File
@@ -17,7 +17,7 @@ silently ignored would look exactly like a knob that has no effect.
import os
import re
from collections.abc import Mapping
from dataclasses import dataclass, field, fields
from dataclasses import dataclass, field, fields, replace
from pathlib import Path
from typing import Any
@@ -30,8 +30,6 @@ DEFAULT_CONFIG_FILE = "nisse-dev.yaml"
REQUIRED_KEYS = (
"state_db",
"workspace",
"project",
"llm.base_url",
"llm.token",
"roles.executor",
@@ -45,22 +43,31 @@ REQUIRED_KEYS = (
"matrix.operators",
"gitea.url",
"gitea.token",
"gitea.repo",
"gitea.operators",
"build_host.address",
"build_host.key",
)
OPTIONAL_KEYS = (
"llm.max_output_tokens",
"llm.request_timeout_s",
"gitea.base_branch",
"gitea.push_key",
"gitea.push_user",
"gitea.push_token",
"build_host.user",
"build_host.port",
"loop",
)
# A project entry (D8), under `projects: {name: {...}}`.
PROJECT_REQUIRED = ("repo", "workspace", "build_host.address", "build_host.key")
PROJECT_OPTIONAL = (
"base_branch",
"push_key",
"push_user",
"push_token",
"build_host.user",
"build_host.port",
"labels",
"secrets",
"egress",
)
# Who may see a project secret (D7, D8): the model's commands get it as an
# environment variable, redacted from what comes back; harness-only ones are
# never in a command the model chose.
VISIBILITY = ("commands", "harness")
_PROJECT_NAME = re.compile(r"[a-z0-9][a-z0-9-]*")
_REFERENCE = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}")
@@ -225,6 +232,136 @@ def _read_file(path: Path, errors: list[str]) -> dict[str, Any]:
return loaded
def _parse_projects(
raw: Any, env: Mapping[str, str], errors: list[str]
) -> tuple["Project", ...]:
"""`projects:`, a mapping of name to entry (D8). Every problem in every
entry is reported, like the rest of the file."""
if not isinstance(raw, dict) or not raw:
errors.append("projects: must name at least one project")
return ()
projects: list[Project] = []
repos: dict[str, str] = {}
for name, entry in raw.items():
where = f"projects.{name}"
if not isinstance(name, str) or not _PROJECT_NAME.fullmatch(name):
errors.append(f"{where}: a project name is lowercase letters, digits, -")
continue
if not isinstance(entry, dict):
errors.append(f"{where}: must be a mapping")
continue
project = _parse_project(name, entry, env, errors)
if project is None:
continue
if project.repo in repos:
errors.append(
f"{where}.repo: {project.repo} is also project {repos[project.repo]}'s"
)
repos[project.repo] = name
projects.append(project)
return tuple(projects)
def _parse_project(
name: str, entry: Mapping[str, Any], env: Mapping[str, str], errors: list[str]
) -> "Project | None":
where = f"projects.{name}"
# labels, secrets and egress are structured; the rest flattens.
flat = _flatten({k: v for k, v in entry.items() if k not in ("secrets",)})
known = set(PROJECT_REQUIRED) | set(PROJECT_OPTIONAL)
for key in sorted(set(flat) - known):
errors.append(f"{where}.{key}: unknown setting")
values = {
key: _resolve(value, f"{where}.{key}", env, errors)
for key, value in flat.items()
if key in known
}
missing = [
key
for key in PROJECT_REQUIRED
if values.get(key) is None
or (isinstance(values[key], str) and not values[key].strip())
]
if missing:
errors.append(f"{where}: unset or empty — " + ", ".join(missing))
return None
port = values.get("build_host.port", 22)
try:
port = int(port)
if not 0 < port < 65536:
raise ValueError
except (TypeError, ValueError):
errors.append(f"{where}.build_host.port: must be a TCP port number")
push_user = values.get("push_user")
push_token = values.get("push_token")
push_key = values.get("push_key")
if bool(push_user) != bool(push_token):
errors.append(f"{where}: push_user and push_token: set both or neither")
if push_token and push_key:
errors.append(f"{where}: set a push token or a push key, not both")
labels = _strings(values.get("labels", []), f"{where}.labels", errors)
egress = _strings(values.get("egress", []), f"{where}.egress", errors)
secrets = _parse_secrets(entry.get("secrets"), where, env, errors)
return Project(
name=name,
repo=str(values["repo"]),
workspace=Path(values["workspace"]).expanduser(),
build_host=BuildHost(
address=str(values["build_host.address"]),
key=Path(values["build_host.key"]).expanduser(),
user=str(values.get("build_host.user") or "root"),
port=port if isinstance(port, int) else 22,
),
base_branch=str(values.get("base_branch") or "main"),
push_key=Path(push_key).expanduser() if push_key else None,
push_user=str(push_user) if push_user else None,
push_token=str(push_token) if push_token else None,
labels=labels,
secrets=secrets,
egress=egress,
)
def _strings(raw: Any, where: str, errors: list[str]) -> tuple[str, ...]:
if not isinstance(raw, list) or not all(
isinstance(x, str) and x.strip() for x in raw
):
errors.append(f"{where}: must be a list of names")
return ()
return tuple(x.strip() for x in raw)
def _parse_secrets(
raw: Any, where: str, env: Mapping[str, str], errors: list[str]
) -> dict[str, "Secret"]:
"""name → {value: ${VAR}, visible: commands | harness}. A value must be
a reference: secrets live in secret stores, never in this file."""
if raw is None:
return {}
if not isinstance(raw, dict):
errors.append(f"{where}.secrets: must be a mapping of name to secret")
return {}
secrets: dict[str, Secret] = {}
for name, spec in raw.items():
at = f"{where}.secrets.{name}"
if not isinstance(name, str) or not re.fullmatch(r"[A-Z_][A-Z0-9_]*", name):
errors.append(f"{at}: a secret's name is an environment variable name")
continue
if not isinstance(spec, dict) or set(spec) - {"value", "visible"}:
errors.append(f"{at}: give value and visible, nothing else")
continue
visible = spec.get("visible")
if visible not in VISIBILITY:
errors.append(f"{at}.visible: must be one of {', '.join(VISIBILITY)}")
continue
value = spec.get("value")
if not isinstance(value, str) or not _REFERENCE.fullmatch(value.strip()):
errors.append(f"{at}.value: must be a ${{VAR}} reference, never inline")
continue
secrets[name] = Secret(_resolve(value.strip(), at, env, errors), visible)
return secrets
@dataclass(frozen=True)
class BuildHost:
"""The tool VM the workspace lives on (L13, D3). ``key`` is the path to
@@ -236,11 +373,42 @@ class BuildHost:
port: int = 22
@dataclass(frozen=True)
class Secret:
value: str = field(repr=False)
visible: str = "harness" # one of VISIBILITY
@dataclass(frozen=True)
class Project:
"""A project entry (D8): what makes a project known to the agent, and
everything per-project the rest of the agent refers to."""
name: str
repo: str
workspace: Path # on the build VM
build_host: BuildHost
base_branch: str = "main"
# What may push to the project repository, handed to each push on the
# build VM and to nothing else (D4): a push-only account's HTTPS token
# (push_user, push_token), or the path to a deploy key (push_key).
push_key: Path | None = None
push_user: str | None = None
push_token: str | None = field(default=None, repr=False)
labels: tuple[str, ...] = () # added to every issue's own (D1)
secrets: Mapping[str, Secret] = field(default_factory=dict)
egress: tuple[str, ...] = () # beyond the default; recorded, not yet enforced
def command_secrets(self) -> dict[str, str]:
"""The secrets the model's commands may see, by name."""
return {
name: s.value for name, s in self.secrets.items() if s.visible == "commands"
}
@dataclass(frozen=True)
class AgentConfig:
state_db: Path
workspace: Path
project: str
llm_base_url: str
# Credentials never appear in a repr: a failing test or a log line
# would print them.
@@ -250,16 +418,8 @@ class AgentConfig:
operators: frozenset[str]
gitea_url: str
gitea_token: str = field(repr=False)
gitea_repo: str
gitea_operators: frozenset[str]
build_host: BuildHost
base_branch: str = "main"
# What may push to the project repository, handed to each push on the
# build VM and to nothing else (D4): a push-only account's HTTPS token
# (push_user, push_token), or the path to a deploy key (push_key).
push_key: Path | None = None
push_user: str | None = None
push_token: str | None = field(default=None, repr=False)
projects: tuple[Project, ...]
max_output_tokens: int = DEFAULT_MAX_OUTPUT_TOKENS
request_timeout_s: float = DEFAULT_REQUEST_TIMEOUT_S
tuning: Tuning = field(default_factory=Tuning)
@@ -268,6 +428,55 @@ class AgentConfig:
loop: str = "old"
config_file: Path | None = None
# -- one project, for the old loop ----------------------------------------
# control.py (until M2.8) knows one project; these read it, and refuse a
# configuration with more.
def only_project(self) -> Project:
if len(self.projects) != 1:
names = ", ".join(p.name for p in self.projects)
raise ConfigError(
f"cannot start: the old loop runs exactly one project, but "
f"{len(self.projects)} are configured ({names}); use `loop: new`"
)
return self.projects[0]
def with_project(self, **changes: Any) -> "AgentConfig":
"""This configuration with its one project changed (for tests)."""
return replace(self, projects=(replace(self.only_project(), **changes),))
@property
def project(self) -> str:
return self.only_project().name
@property
def gitea_repo(self) -> str:
return self.only_project().repo
@property
def workspace(self) -> Path:
return self.only_project().workspace
@property
def build_host(self) -> BuildHost:
return self.only_project().build_host
@property
def base_branch(self) -> str:
return self.only_project().base_branch
@property
def push_key(self) -> Path | None:
return self.only_project().push_key
@property
def push_user(self) -> str | None:
return self.only_project().push_user
@property
def push_token(self) -> str | None:
return self.only_project().push_token
@classmethod
def load(cls, env: Mapping[str, str] = os.environ) -> "AgentConfig":
"""The file named by NISSE_CONFIG, or ./nisse-dev.yaml."""
@@ -304,7 +513,8 @@ class AgentConfig:
config_file: Path | None,
) -> "AgentConfig":
tuning = _parse_tuning(tree.get("tuning"), errors)
flat = _flatten(tree)
projects = _parse_projects(tree.get("projects"), env, errors)
flat = _flatten({k: v for k, v in tree.items() if k != "projects"})
flat.pop("tuning", None)
known = set(REQUIRED_KEYS) | set(OPTIONAL_KEYS)
@@ -343,30 +553,14 @@ class AgentConfig:
raise ValueError
except (TypeError, ValueError):
errors.append("llm.request_timeout_s: must be a positive number of seconds")
port = values.get("build_host.port", 22)
try:
port = int(port)
if not 0 < port < 65536:
raise ValueError
except (TypeError, ValueError):
errors.append("build_host.port: must be a TCP port number")
loop = str(values.get("loop") or "old")
if loop not in ("old", "new"):
errors.append("loop: must be old or new")
push_user = values.get("gitea.push_user")
push_token = values.get("gitea.push_token")
if bool(push_user) != bool(push_token):
errors.append("gitea.push_user and gitea.push_token: set both or neither")
if push_token and values.get("gitea.push_key"):
errors.append("gitea: set a push token or a push key, not both")
if errors:
raise ConfigError("cannot start: " + "; ".join(errors))
push_key = values.get("gitea.push_key")
config = cls(
state_db=Path(values["state_db"]).expanduser(),
workspace=Path(values["workspace"]).expanduser(),
project=str(values["project"]),
llm_base_url=str(values["llm.base_url"]),
llm_token=str(values["llm.token"]),
roles=RoleMap(
@@ -384,18 +578,8 @@ class AgentConfig:
operators=_people(values["matrix.operators"]),
gitea_url=str(values["gitea.url"]),
gitea_token=str(values["gitea.token"]),
gitea_repo=str(values["gitea.repo"]),
gitea_operators=_people(values["gitea.operators"]),
build_host=BuildHost(
address=str(values["build_host.address"]),
key=Path(values["build_host.key"]).expanduser(),
user=str(values.get("build_host.user") or "root"),
port=port,
),
push_key=Path(push_key).expanduser() if push_key else None,
push_user=str(push_user) if push_user else None,
push_token=str(push_token) if push_token else None,
base_branch=str(values.get("gitea.base_branch") or "main"),
projects=projects,
max_output_tokens=max_output,
request_timeout_s=request_timeout,
tuning=tuning,
@@ -416,31 +600,34 @@ class AgentConfig:
from and how the agent is deployed, which is not the project's
business either.
"""
workspace = self.workspace.expanduser().resolve()
candidates = [
("state_db", self.state_db),
("NISSE_ENV", Path(env.get("NISSE_ENV", ".env"))),
]
if self.config_file is not None:
candidates.append(("NISSE_CONFIG", self.config_file))
offenders = []
for label, path in candidates:
resolved = path.expanduser().resolve()
if resolved == workspace or workspace in resolved.parents:
offenders.append(f"{label} ({resolved})")
if offenders:
raise ConfigError(
"refusing to start: these sit inside the workspace clone at "
f"{workspace} and would be committed and pushed to the project "
"repository — " + ", ".join(offenders)
)
for project in self.projects:
workspace = project.workspace.expanduser().resolve()
offenders = []
for label, path in candidates:
resolved = path.expanduser().resolve()
if resolved == workspace or workspace in resolved.parents:
offenders.append(f"{label} ({resolved})")
if offenders:
raise ConfigError(
"refusing to start: these sit inside the workspace clone at "
f"{workspace} (project {project.name}) and would be "
"committed and pushed to the project repository — "
+ ", ".join(offenders)
)
def redacted(self) -> dict[str, str]:
"""What the agent may safely announce about how it is configured."""
return {
"project": self.project,
"workspace": f"{self.build_host.address}:{self.workspace}",
"repo": self.gitea_repo,
"projects": ", ".join(
f"{p.name} ({p.repo}, {p.build_host.address}:{p.workspace})"
for p in self.projects
),
"planner": self.roles.planner,
"executor": self.roles.executor,
"helper": self.roles.helper,